Compare commits
300 Commits
e7b2e6382a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6abf45e7e | ||
|
|
5c5c099fb2 | ||
|
|
a12fe0e8a9 | ||
|
|
3c828bfc4a | ||
|
|
11101ac204 | ||
|
|
28bcdc4208 | ||
|
|
890c132890 | ||
|
|
257ca94a25 | ||
|
|
7b5138a630 | ||
|
|
e8ccee02d5 | ||
|
|
4335f7e144 | ||
|
|
66329852b8 | ||
|
|
085163ec7a | ||
|
|
0c28969c3b | ||
|
|
8490344d69 | ||
|
|
e4b19090b8 | ||
|
|
07217336e7 | ||
|
|
19705e31bd | ||
|
|
3e1413aebc | ||
|
|
36f2ba381a | ||
|
|
a3273ca581 | ||
|
|
f58c60599b | ||
|
|
28dafa9bea | ||
|
|
81c174a902 | ||
|
|
3dac6a9eda | ||
|
|
22b8ac7ac6 | ||
|
|
297a151b0c | ||
|
|
c82f7bda1d | ||
|
|
645ec39e8b | ||
|
|
6d5a711d2c | ||
|
|
786f57c151 | ||
|
|
60dc4dba7a | ||
|
|
85a7dacd16 | ||
|
|
0acf901893 | ||
|
|
a9821ab832 | ||
|
|
1613e3cfe9 | ||
|
|
43f0ba7057 | ||
|
|
5467394ffe | ||
|
|
80ef48a3a3 | ||
|
|
570377a31f | ||
|
|
5fd8e88825 | ||
|
|
4a95a83d6b | ||
|
|
36275eb307 | ||
|
|
263bba264a | ||
|
|
f7bf5a86ea | ||
|
|
d9818c263e | ||
|
|
c452ae81d1 | ||
|
|
a1cbb9fb1d | ||
|
|
a78ee2f154 | ||
|
|
51c41acfa7 | ||
|
|
f668e64266 | ||
|
|
ced93934f1 | ||
|
|
482871301e | ||
|
|
087e23e57b | ||
|
|
741aaf0e40 | ||
|
|
4f84c94a42 | ||
|
|
b1a96ace1f | ||
|
|
e9cfbd108a | ||
|
|
049d230bae | ||
|
|
a62332f1c4 | ||
|
|
1f91dcc5cc | ||
|
|
8a0c9670e6 | ||
|
|
7dac749eff | ||
|
|
0da59c6a0e | ||
|
|
d2512ca9db | ||
|
|
70f69a2008 | ||
|
|
3592b55556 | ||
|
|
2d2e1e191e | ||
|
|
75a70d2e46 | ||
|
|
54116d1a1f | ||
|
|
553de13cd5 | ||
|
|
7fb92714c7 | ||
|
|
3186c5aee9 | ||
|
|
c268229311 | ||
|
|
50b9e8d683 | ||
|
|
a16e86bf04 | ||
|
|
63ff8660fc | ||
|
|
105cae0565 | ||
|
|
37acd34154 | ||
|
|
b728618d61 | ||
|
|
74b1d44068 | ||
|
|
24bb8e7bca | ||
|
|
4d02b2b531 | ||
|
|
93f6e87220 | ||
|
|
84b671d1e5 | ||
|
|
062b4493e4 | ||
|
|
0f55d26076 | ||
|
|
15b5781dbb | ||
|
|
2acd9485c7 | ||
|
|
99dad17eac | ||
|
|
bef2ea7169 | ||
|
|
8d288cadfa | ||
|
|
888fa108ef | ||
|
|
0774dd75ad | ||
|
|
b6838c1bc1 | ||
|
|
438f9ca3f4 | ||
|
|
68ced2bae9 | ||
|
|
3aa436f872 | ||
|
|
2b90db4028 | ||
|
|
95fa09c383 | ||
|
|
0a9272bcf6 | ||
|
|
7e57565ecd | ||
|
|
7b17f94bc0 | ||
|
|
3ff17382ff | ||
|
|
0a5290aee4 | ||
|
|
ef422f354d | ||
|
|
c35ea83799 | ||
|
|
f54fb336dc | ||
|
|
a5b3396adc | ||
|
|
69c3de15f5 | ||
|
|
b235f67c31 | ||
|
|
4be26592f4 | ||
|
|
d68c7be098 | ||
|
|
e78eb1af07 | ||
|
|
77cf866adf | ||
|
|
1b52787b26 | ||
|
|
1135439403 | ||
|
|
d436888ca5 | ||
|
|
444dc7dd8d | ||
|
|
30a578ee00 | ||
|
|
cde3a863a2 | ||
|
|
8cfc5709dc | ||
|
|
29b47ae4e4 | ||
|
|
2e9f6621a3 | ||
|
|
3a14b7efe3 | ||
|
|
4c1d98116a | ||
|
|
bb5298ee0f | ||
|
|
975d699e42 | ||
|
|
62c02e0f15 | ||
|
|
70aacf47a0 | ||
|
|
24562dd54b | ||
|
|
c5b686499c | ||
|
|
8656896847 | ||
|
|
43894446d9 | ||
|
|
fa0a788cf9 | ||
|
|
feab61b132 | ||
|
|
2afe3a8848 | ||
|
|
5140552ff6 | ||
|
|
20bd9e8cb4 | ||
|
|
f4b5d55f24 | ||
|
|
6709df62ed | ||
|
|
c0e0e2a6c3 | ||
|
|
37cdeebb95 | ||
|
|
c93ae0bc66 | ||
|
|
0e789b530a | ||
|
|
120df86e58 | ||
|
|
8f7f75ac25 | ||
|
|
1602b7bbad | ||
|
|
6d1a7fba98 | ||
|
|
bc6206c0df | ||
|
|
e9451875a8 | ||
|
|
0d3e45300f | ||
|
|
443bfbae61 | ||
|
|
7a016e4ed5 | ||
|
|
7a73a90238 | ||
|
|
8a53948934 | ||
|
|
3ddd04b422 | ||
|
|
80bc60f5e4 | ||
|
|
34504d4179 | ||
|
|
c6c94ebb84 | ||
|
|
ec87ae85cf | ||
|
|
c208dcc6f5 | ||
|
|
d712ad78c3 | ||
|
|
78c783d332 | ||
|
|
3e4baa38a6 | ||
|
|
70322e4132 | ||
|
|
3412d807e3 | ||
|
|
d378e154c4 | ||
|
|
bba47b7b1c | ||
|
|
9d07ea0be0 | ||
|
|
84afeaf9f2 | ||
|
|
209acaa15d | ||
|
|
1a6409eb30 | ||
|
|
32df9c0655 | ||
|
|
2e4d98c479 | ||
|
|
603af83aa9 | ||
|
|
dd44c1526f | ||
|
|
0006e427e2 | ||
|
|
2cc0f5af25 | ||
|
|
e8ee441ae1 | ||
|
|
23cd62a70f | ||
|
|
63ead0c442 | ||
|
|
b6e780e649 | ||
|
|
3bc4597041 | ||
|
|
5e52b0a34c | ||
|
|
310a3cec90 | ||
|
|
963556c079 | ||
|
|
4aa014de0d | ||
|
|
ab2c9bbc43 | ||
|
|
620af8988b | ||
|
|
61397186e7 | ||
|
|
f13a240000 | ||
|
|
a174f88b6f | ||
|
|
5261468953 | ||
|
|
8e177ca705 | ||
|
|
7764f7f8a6 | ||
|
|
8a972f8f4d | ||
|
|
a1fa51206f | ||
|
|
0fb8b98c72 | ||
|
|
f4b536accb | ||
|
|
8dd269d150 | ||
|
|
0f32d28ddb | ||
|
|
ebae393e90 | ||
|
|
797c4e9e20 | ||
|
|
4cde4acddc | ||
|
|
e1ebae4ed0 | ||
|
|
ae1c9ccc77 | ||
|
|
669ca44360 | ||
|
|
6eb2bf9c80 | ||
|
|
a95e3d8645 | ||
|
|
95d7989a9f | ||
|
|
73119fe026 | ||
|
|
ac2797e1b7 | ||
|
|
fc1d51e6f1 | ||
|
|
988b405c5d | ||
|
|
ff073c83a5 | ||
|
|
75bf900950 | ||
|
|
6d66a392db | ||
|
|
81dd3d2bda | ||
|
|
758bc210e1 | ||
|
|
3cba699ca0 | ||
|
|
8b837c0591 | ||
|
|
598c06885f | ||
|
|
92c1c3c17d | ||
|
|
5d2402a1e7 | ||
|
|
0a4825be99 | ||
|
|
388948e348 | ||
|
|
5053908444 | ||
|
|
69f9e1a61a | ||
|
|
4b3193fcd6 | ||
|
|
415d7617c8 | ||
|
|
6e761ae22b | ||
|
|
b30897119b | ||
|
|
3b6f72d5c0 | ||
|
|
92e6cf0c43 | ||
|
|
9b8307fbba | ||
|
|
577d2a32b1 | ||
|
|
7789a5e227 | ||
|
|
2fb0535164 | ||
|
|
6046ed23c9 | ||
|
|
31e623a947 | ||
|
|
3b38562533 | ||
|
|
9b8c2ff7e1 | ||
|
|
63d8b7a65d | ||
|
|
50772878da | ||
|
|
813843e8cc | ||
|
|
f05ca00c75 | ||
|
|
8f9895be98 | ||
|
|
0dcaf7915f | ||
|
|
44bb31197e | ||
|
|
36a55e116e | ||
|
|
84fafb0bc5 | ||
|
|
1bebb57765 | ||
|
|
a96b065190 | ||
|
|
b00fe44880 | ||
|
|
32eef5ecf1 | ||
|
|
13f553590b | ||
|
|
931edc3025 | ||
|
|
d8735eb45c | ||
|
|
82cea6a108 | ||
|
|
22e35ad233 | ||
|
|
d2dfac82e3 | ||
|
|
c0e3d26b71 | ||
|
|
1925568c13 | ||
|
|
cec487bd2c | ||
|
|
ef0b784f4f | ||
|
|
43769dae5a | ||
|
|
26a9781d4f | ||
|
|
30344d474f | ||
|
|
dffa2dd47d | ||
|
|
facc8b0d24 | ||
|
|
cb6f5cc651 | ||
|
|
9015a2b85e | ||
|
|
202c6dd0d2 | ||
|
|
cac61637ce | ||
|
|
f6ccb8a35c | ||
|
|
a491eb19a6 | ||
|
|
c6e8048bc5 | ||
|
|
2f4be6dcd0 | ||
|
|
1bde4b44c0 | ||
|
|
4eb874f52d | ||
|
|
5ab8bf8479 | ||
|
|
f99892ee16 | ||
|
|
10c79c5e39 | ||
|
|
1cf5f59d8c | ||
|
|
a84378ab50 | ||
|
|
493b479373 | ||
|
|
27c32e5561 | ||
|
|
cf844a561f | ||
|
|
1c9e7ccf1d | ||
|
|
8aac96b62f | ||
|
|
4745b1e824 | ||
|
|
781e1191a5 | ||
|
|
e5546efa41 | ||
|
|
99093d8143 | ||
|
|
e76f4feb4f | ||
|
|
601b2d7f52 | ||
|
|
00f615d8e5 | ||
|
|
8a61ae3f8e | ||
|
|
d715647a73 |
11
.lintstagedrc.js
Normal file
11
.lintstagedrc.js
Normal file
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
'*.rs': [
|
||||
'cargo fmt --check --',
|
||||
() => 'cargo clippy -p erp-health -p erp-server -- -D warnings',
|
||||
],
|
||||
'apps/web/src/**/*.{ts,tsx}': (filenames) =>
|
||||
`npx eslint --fix ${filenames.join(' ')}`,
|
||||
'apps/web/src/**/*.test.{ts,tsx}': [
|
||||
'cd apps/web && npx vitest run --reporter=verbose',
|
||||
],
|
||||
};
|
||||
238
Cargo.lock
generated
238
Cargo.lock
generated
@@ -288,6 +288,28 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.16.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.40.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.8"
|
||||
@@ -555,7 +577,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8144c22e24bbcf26ade86cb6501a0916c46b7e4787abdb0045a467eb1645a1d"
|
||||
dependencies = [
|
||||
"ambient-authority",
|
||||
"rand",
|
||||
"rand 0.8.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -681,6 +703,15 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cobs"
|
||||
version = "0.3.0"
|
||||
@@ -1056,7 +1087,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
@@ -1330,6 +1361,12 @@ dependencies = [
|
||||
"dtoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
@@ -1374,10 +1411,12 @@ dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"erp-core",
|
||||
"futures",
|
||||
"handlebars",
|
||||
"hex",
|
||||
"redis",
|
||||
"reqwest",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
@@ -1453,7 +1492,7 @@ dependencies = [
|
||||
"dashmap",
|
||||
"hex",
|
||||
"hmac",
|
||||
"rand",
|
||||
"rand 0.8.5",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1490,6 +1529,7 @@ name = "erp-health"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
@@ -1497,7 +1537,9 @@ dependencies = [
|
||||
"erp-core",
|
||||
"hex",
|
||||
"hmac",
|
||||
"jsonwebtoken",
|
||||
"num-traits",
|
||||
"rand_core 0.6.4",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1625,30 +1667,12 @@ dependencies = [
|
||||
"wit-bindgen 0.55.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "erp-points"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"chrono",
|
||||
"erp-core",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"utoipa",
|
||||
"uuid",
|
||||
"validator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "erp-server"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"chrono",
|
||||
"config",
|
||||
@@ -1660,14 +1684,18 @@ dependencies = [
|
||||
"erp-health",
|
||||
"erp-message",
|
||||
"erp-plugin",
|
||||
"erp-points",
|
||||
"erp-server-migration",
|
||||
"erp-workflow",
|
||||
"futures",
|
||||
"hex",
|
||||
"metrics",
|
||||
"metrics-exporter-prometheus",
|
||||
"moka",
|
||||
"redis",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"tokio",
|
||||
"tower",
|
||||
@@ -1842,6 +1870,12 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "funty"
|
||||
version = "2.0.0"
|
||||
@@ -2286,6 +2320,7 @@ dependencies = [
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
@@ -2478,8 +2513,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af1955a75fa080c677d3972822ec4bad316169ab1cfc6c257a942c2265dbe5fe"
|
||||
dependencies = [
|
||||
"bitmaps",
|
||||
"rand_core",
|
||||
"rand_xoshiro",
|
||||
"rand_core 0.6.4",
|
||||
"rand_xoshiro 0.6.0",
|
||||
"sized-chunks",
|
||||
"typenum",
|
||||
"version_check",
|
||||
@@ -2856,6 +2891,53 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "metrics"
|
||||
version = "0.24.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d5312e9ba3771cfa961b585728215e3d972c950a3eed9252aa093d6301277e8"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "metrics-exporter-prometheus"
|
||||
version = "0.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd7399781913e5393588a8d8c6a2867bf85fb38eaf2502fdce465aad2dc6f034"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"indexmap",
|
||||
"ipnet",
|
||||
"metrics",
|
||||
"metrics-util",
|
||||
"quanta",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "metrics-util"
|
||||
version = "0.19.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8496cc523d1f94c1385dd8f0f0c2c480b2b8aeccb5b7e4485ad6365523ae376"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
"hashbrown 0.15.5",
|
||||
"metrics",
|
||||
"quanta",
|
||||
"rand 0.9.4",
|
||||
"rand_xoshiro 0.7.0",
|
||||
"sketches-ddsketch",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
@@ -3009,7 +3091,7 @@ dependencies = [
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-traits",
|
||||
"rand",
|
||||
"rand 0.8.5",
|
||||
"smallvec",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -3218,7 +3300,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
@@ -3342,7 +3424,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"rand",
|
||||
"rand 0.8.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3572,6 +3654,21 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quanta"
|
||||
version = "0.12.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"raw-cpuid",
|
||||
"wasi",
|
||||
"web-sys",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
@@ -3606,8 +3703,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3617,7 +3724,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3629,13 +3746,40 @@ dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_xoshiro"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_xoshiro"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41"
|
||||
dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-cpuid"
|
||||
version = "11.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3876,7 +4020,7 @@ dependencies = [
|
||||
"num-traits",
|
||||
"pkcs1",
|
||||
"pkcs8",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
"signature",
|
||||
"spki",
|
||||
"subtle",
|
||||
@@ -3903,7 +4047,7 @@ dependencies = [
|
||||
"borsh",
|
||||
"bytes",
|
||||
"num-traits",
|
||||
"rand",
|
||||
"rand 0.8.5",
|
||||
"rkyv",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3982,6 +4126,7 @@ version = "0.23.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -3990,6 +4135,18 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.0"
|
||||
@@ -4005,6 +4162,7 @@ version = "0.103.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
@@ -4398,7 +4556,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4441,6 +4599,12 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sketches-ddsketch"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@@ -4618,7 +4782,7 @@ dependencies = [
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"rand",
|
||||
"rand 0.8.5",
|
||||
"rsa",
|
||||
"rust_decimal",
|
||||
"serde",
|
||||
@@ -4662,7 +4826,7 @@ dependencies = [
|
||||
"memchr",
|
||||
"num-bigint",
|
||||
"once_cell",
|
||||
"rand",
|
||||
"rand 0.8.5",
|
||||
"rust_decimal",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -17,7 +17,6 @@ members = [
|
||||
"crates/erp-plugin-itops",
|
||||
"crates/erp-health",
|
||||
"crates/erp-ai",
|
||||
"crates/erp-points",
|
||||
"crates/erp-plugin-assessment",
|
||||
"crates/erp-dialysis",
|
||||
]
|
||||
@@ -106,16 +105,20 @@ erp-config = { path = "crates/erp-config" }
|
||||
erp-plugin = { path = "crates/erp-plugin" }
|
||||
erp-health = { path = "crates/erp-health" }
|
||||
erp-ai = { path = "crates/erp-ai" }
|
||||
erp-points = { path = "crates/erp-points" }
|
||||
erp-dialysis = { path = "crates/erp-dialysis" }
|
||||
|
||||
# Async streaming
|
||||
futures = "0.3"
|
||||
tokio-stream = "0.1"
|
||||
async-stream = "0.3"
|
||||
dashmap = "6"
|
||||
|
||||
# Template engine
|
||||
handlebars = "6"
|
||||
|
||||
# HTML sanitization
|
||||
ammonia = "4"
|
||||
|
||||
# Metrics
|
||||
metrics = "0.24"
|
||||
metrics-exporter-prometheus = "0.16"
|
||||
|
||||
69
apps/miniprogram/__tests__/services/ble/BLEManager.test.ts
Normal file
69
apps/miniprogram/__tests__/services/ble/BLEManager.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
// 使用 vi.hoisted 确保 storage 在 mock 提升前可用
|
||||
const { storage } = vi.hoisted(() => ({
|
||||
storage: new Map<string, string>(),
|
||||
}));
|
||||
|
||||
vi.mock('@tarojs/taro', () => ({
|
||||
default: {
|
||||
openBluetoothAdapter: vi.fn().mockResolvedValue({}),
|
||||
closeBluetoothAdapter: vi.fn().mockResolvedValue({}),
|
||||
startBluetoothDevicesDiscovery: vi.fn().mockResolvedValue({}),
|
||||
stopBluetoothDevicesDiscovery: vi.fn().mockResolvedValue({}),
|
||||
onBluetoothDeviceFound: vi.fn(),
|
||||
offBluetoothDeviceFound: vi.fn(),
|
||||
createBLEConnection: vi.fn().mockResolvedValue({}),
|
||||
closeBLEConnection: vi.fn().mockResolvedValue({}),
|
||||
getBLEDeviceServices: vi.fn().mockResolvedValue({ services: [] }),
|
||||
getBLEDeviceCharacteristics: vi.fn().mockResolvedValue({ characteristics: [] }),
|
||||
notifyBLECharacteristicValueChange: vi.fn().mockResolvedValue({}),
|
||||
onBLECharacteristicValueChange: vi.fn(),
|
||||
onBLEConnectionStateChange: vi.fn(),
|
||||
getStorageSync: vi.fn((key: string) => storage.get(key) || ''),
|
||||
setStorageSync: vi.fn((key: string, value: string) => { storage.set(key, value); }),
|
||||
removeStorageSync: vi.fn((key: string) => { storage.delete(key); }),
|
||||
},
|
||||
}));
|
||||
|
||||
import { BLEManager } from '@/services/ble/BLEManager';
|
||||
import { XiaomiBandAdapter } from '@/services/ble/adapters/XiaomiBandAdapter';
|
||||
|
||||
describe('BLEManager DataBuffer 集成', () => {
|
||||
let manager: BLEManager;
|
||||
|
||||
beforeEach(() => {
|
||||
storage.clear();
|
||||
manager = new BLEManager();
|
||||
manager.registerAdapter(XiaomiBandAdapter);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await manager.destroy();
|
||||
});
|
||||
|
||||
it('registerAdapter 添加适配器', () => {
|
||||
const count = (manager as any).adapters.length;
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('getCachedReadings 返回空数组(未连接时)', () => {
|
||||
const readings = manager.getCachedReadings();
|
||||
expect(readings).toEqual([]);
|
||||
});
|
||||
|
||||
it('flushPendingReadings 无缓存时返回 0', async () => {
|
||||
const uploadFn = vi.fn().mockResolvedValue(0);
|
||||
const count = await manager.flushPendingReadings(uploadFn);
|
||||
expect(count).toBe(0);
|
||||
expect(uploadFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('DataBuffer 实例已初始化', () => {
|
||||
const buffer = (manager as any).dataBuffer;
|
||||
expect(buffer).toBeDefined();
|
||||
expect(typeof buffer.push).toBe('function');
|
||||
expect(typeof buffer.flush).toBe('function');
|
||||
expect(typeof buffer.restore).toBe('function');
|
||||
});
|
||||
});
|
||||
89
apps/miniprogram/__tests__/services/ble/DataBuffer.test.ts
Normal file
89
apps/miniprogram/__tests__/services/ble/DataBuffer.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { DataBuffer } from '@/services/ble/DataBuffer';
|
||||
import type { NormalizedReading } from '@/services/ble/types';
|
||||
|
||||
// Mock Taro Storage
|
||||
const storage = new Map<string, string>();
|
||||
vi.mock('@tarojs/taro', () => ({
|
||||
default: {
|
||||
getStorageSync: vi.fn((key: string) => storage.get(key) || ''),
|
||||
setStorageSync: vi.fn((key: string, value: string) => { storage.set(key, value); }),
|
||||
removeStorageSync: vi.fn((key: string) => { storage.delete(key); }),
|
||||
getStorageInfoSync: vi.fn(() => ({ keys: Array.from(storage.keys()), limitSize: 10240, currentSize: storage.size })),
|
||||
},
|
||||
}));
|
||||
|
||||
function makeReading(overrides: Partial<NormalizedReading> = {}): NormalizedReading {
|
||||
return {
|
||||
device_type: 'heart_rate',
|
||||
values: { heart_rate: 72 },
|
||||
measured_at: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('DataBuffer', () => {
|
||||
let buffer: DataBuffer;
|
||||
|
||||
beforeEach(() => {
|
||||
storage.clear();
|
||||
buffer = new DataBuffer({ bucketSize: 100 });
|
||||
});
|
||||
|
||||
it('push 添加读数并持久化', () => {
|
||||
const reading = makeReading();
|
||||
buffer.push(reading);
|
||||
expect(buffer.size()).toBe(1);
|
||||
});
|
||||
|
||||
it('push 批量添加读数', () => {
|
||||
const readings = Array.from({ length: 10 }, (_, i) =>
|
||||
makeReading({ measured_at: new Date(Date.now() + i * 1000).toISOString() }),
|
||||
);
|
||||
buffer.push(readings);
|
||||
expect(buffer.size()).toBe(10);
|
||||
});
|
||||
|
||||
it('flush 返回并清空缓冲区', () => {
|
||||
buffer.push([
|
||||
makeReading({ measured_at: '2026-05-04T10:00:00.000Z' }),
|
||||
makeReading({ measured_at: '2026-05-04T10:00:01.000Z' }),
|
||||
]);
|
||||
const flushed = buffer.flush();
|
||||
expect(flushed.length).toBe(2);
|
||||
expect(buffer.size()).toBe(0);
|
||||
});
|
||||
|
||||
it('超过 maxTotal 时丢弃最旧数据', () => {
|
||||
const smallBuffer = new DataBuffer({ bucketSize: 5, maxTotal: 10 });
|
||||
for (let i = 0; i < 15; i++) {
|
||||
smallBuffer.push(makeReading({ measured_at: new Date(i * 1000).toISOString() }));
|
||||
}
|
||||
expect(smallBuffer.size()).toBe(10);
|
||||
});
|
||||
|
||||
it('去重:相同 measured_at + device_type 不重复存储', () => {
|
||||
const ts = '2026-05-04T10:00:00.000Z';
|
||||
buffer.push(makeReading({ measured_at: ts }));
|
||||
buffer.push(makeReading({ measured_at: ts }));
|
||||
expect(buffer.size()).toBe(1);
|
||||
});
|
||||
|
||||
it('restore 从 Storage 恢复未上传数据', () => {
|
||||
buffer.push([
|
||||
makeReading({ measured_at: '2026-05-04T10:00:00.000Z' }),
|
||||
makeReading({ measured_at: '2026-05-04T10:00:01.000Z' }),
|
||||
]);
|
||||
// 模拟重启:新建 DataBuffer 并 restore
|
||||
const restored = new DataBuffer({ bucketSize: 100 });
|
||||
const count = restored.restore();
|
||||
expect(count).toBe(2);
|
||||
expect(restored.size()).toBe(2);
|
||||
});
|
||||
|
||||
it('clear 清空缓冲区和 Storage', () => {
|
||||
buffer.push(makeReading());
|
||||
buffer.clear();
|
||||
expect(buffer.size()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { DataSyncScheduler } from '@/services/ble/DataSyncScheduler';
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
vi.mock('@tarojs/taro', () => ({
|
||||
default: {
|
||||
getStorageSync: vi.fn((key: string) => storage.get(key) || ''),
|
||||
setStorageSync: vi.fn((key: string, value: string) => { storage.set(key, value); }),
|
||||
removeStorageSync: vi.fn((key: string) => { storage.delete(key); }),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('DataSyncScheduler', () => {
|
||||
let scheduler: DataSyncScheduler;
|
||||
let syncFn: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
storage.clear();
|
||||
syncFn = vi.fn().mockResolvedValue({ success: true, uploadedCount: 5 });
|
||||
scheduler = new DataSyncScheduler({
|
||||
intervalMs: 60 * 60 * 1000,
|
||||
storageKey: 'last_ble_sync',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
scheduler.destroy();
|
||||
});
|
||||
|
||||
it('首次同步:无记录时立即需要同步', () => {
|
||||
expect(scheduler.needsSync()).toBe(true);
|
||||
});
|
||||
|
||||
it('同步后记录时间戳', async () => {
|
||||
await scheduler.recordSync(syncFn);
|
||||
expect(storage.has('last_ble_sync')).toBe(true);
|
||||
expect(syncFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('同步后不需要再次同步', async () => {
|
||||
await scheduler.recordSync(syncFn);
|
||||
expect(scheduler.needsSync()).toBe(false);
|
||||
});
|
||||
|
||||
it('超过间隔后需要再次同步', async () => {
|
||||
const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
|
||||
storage.set('last_ble_sync', JSON.stringify({ lastSyncAt: twoHoursAgo }));
|
||||
scheduler = new DataSyncScheduler({ intervalMs: 60 * 60 * 1000, storageKey: 'last_ble_sync' });
|
||||
|
||||
expect(scheduler.needsSync()).toBe(true);
|
||||
});
|
||||
|
||||
it('同步失败不更新时间戳', async () => {
|
||||
const failFn = vi.fn().mockRejectedValue(new Error('network error'));
|
||||
const oneHourAgo = Date.now() - 60 * 60 * 1000;
|
||||
storage.set('last_ble_sync', JSON.stringify({ lastSyncAt: oneHourAgo }));
|
||||
|
||||
await scheduler.recordSync(failFn);
|
||||
const stored = JSON.parse(storage.get('last_ble_sync') || '{}');
|
||||
expect(stored.lastSyncAt).toBe(oneHourAgo);
|
||||
});
|
||||
|
||||
it('tryAutoSync 首次时触发同步', async () => {
|
||||
const result = await scheduler.tryAutoSync(syncFn);
|
||||
expect(result).toBe(true);
|
||||
expect(syncFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('tryAutoSync 未超时不触发', async () => {
|
||||
await scheduler.recordSync(syncFn);
|
||||
syncFn.mockClear();
|
||||
const result = await scheduler.tryAutoSync(syncFn);
|
||||
expect(result).toBe(false);
|
||||
expect(syncFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('destroy 清理定时器', () => {
|
||||
const clearIntervalSpy = vi.spyOn(global, 'clearInterval');
|
||||
scheduler.startPeriodicCheck(syncFn, 30000);
|
||||
scheduler.destroy();
|
||||
expect(clearIntervalSpy).toHaveBeenCalled();
|
||||
clearIntervalSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('getLastSyncAt 返回上次同步时间', async () => {
|
||||
await scheduler.recordSync(syncFn);
|
||||
const lastSync = scheduler.getLastSyncAt();
|
||||
expect(lastSync).toBeTruthy();
|
||||
expect(typeof lastSync).toBe('number');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createGenericBleAdapter } from '@/services/ble/adapters/GenericBleAdapter';
|
||||
import type { GenericBLEProfile } from '@/services/ble/types';
|
||||
|
||||
// ---- Heart Rate (0x180D / 0x2A37) ----
|
||||
// Flag byte=0x00 (UINT8), HR=75
|
||||
function makeHeartRateData(hr: number, isUint16 = false): ArrayBuffer {
|
||||
const buf = new ArrayBuffer(isUint16 ? 3 : 2);
|
||||
const view = new DataView(buf);
|
||||
view.setUint8(0, isUint16 ? 0x01 : 0x00);
|
||||
if (isUint16) {
|
||||
view.setUint16(1, hr, true);
|
||||
} else {
|
||||
view.setUint8(1, hr);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
// ---- Health Thermometer (0x1809 / 0x2A1C) ----
|
||||
// IEEE 11073 FLOAT: 32-bit — mantissa (24-bit) + exponent (8-bit)
|
||||
function makeTemperatureData(tempCelsius: number): ArrayBuffer {
|
||||
const buf = new ArrayBuffer(4);
|
||||
const view = new DataView(buf);
|
||||
// flags byte: 0x00 = Celsius, no timestamp, no type
|
||||
view.setUint8(0, 0x00);
|
||||
// 11073 FLOAT: mantissa * 10^exponent
|
||||
// For 36.5: mantissa=365, exponent=-1
|
||||
const mantissa = Math.round(tempCelsius * 10);
|
||||
const exponent = -1;
|
||||
view.setInt16(1, mantissa, true);
|
||||
view.setInt8(3, exponent);
|
||||
return buf;
|
||||
}
|
||||
|
||||
describe('GenericBleAdapter', () => {
|
||||
describe('心率解析', () => {
|
||||
const adapter = createGenericBleAdapter({
|
||||
name: 'Test Wristband',
|
||||
supportedModels: ['TestBand'],
|
||||
profiles: ['heart_rate'],
|
||||
});
|
||||
|
||||
it('解析 UINT8 心率', () => {
|
||||
const data = makeHeartRateData(75);
|
||||
const results = adapter.parseNotification(
|
||||
'0000180D-0000-1000-8000-00805f9b34fb',
|
||||
'00002A37-0000-1000-8000-00805f9b34fb',
|
||||
data,
|
||||
);
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].device_type).toBe('heart_rate');
|
||||
expect(results[0].values.heart_rate).toBe(75);
|
||||
});
|
||||
|
||||
it('解析 UINT16 心率', () => {
|
||||
const data = makeHeartRateData(200, true);
|
||||
const results = adapter.parseNotification(
|
||||
'0000180D-0000-1000-8000-00805f9b34fb',
|
||||
'00002A37-0000-1000-8000-00805f9b34fb',
|
||||
data,
|
||||
);
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].values.heart_rate).toBe(200);
|
||||
});
|
||||
|
||||
it('忽略非目标 Characteristic', () => {
|
||||
const data = makeHeartRateData(75);
|
||||
const results = adapter.parseNotification(
|
||||
'0000180D-0000-1000-8000-00805f9b34fb',
|
||||
'00002A38-0000-1000-8000-00805f9b34fb', // Body Sensor Location
|
||||
data,
|
||||
);
|
||||
expect(results.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('体温解析', () => {
|
||||
const adapter = createGenericBleAdapter({
|
||||
name: 'Test Thermometer',
|
||||
supportedModels: ['TestThermo'],
|
||||
profiles: ['health_thermometer'],
|
||||
});
|
||||
|
||||
it('解析体温读数', () => {
|
||||
const data = makeTemperatureData(36.5);
|
||||
const results = adapter.parseNotification(
|
||||
'00001809-0000-1000-8000-00805f9b34fb',
|
||||
'00002A1C-0000-1000-8000-00805f9b34fb',
|
||||
data,
|
||||
);
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].device_type).toBe('temperature');
|
||||
expect(results[0].values.value).toBeCloseTo(36.5, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('多 Profile 适配器', () => {
|
||||
const adapter = createGenericBleAdapter({
|
||||
name: 'Multi-Profile Band',
|
||||
supportedModels: ['CustomBand', 'MedicalBand'],
|
||||
profiles: ['heart_rate', 'health_thermometer'],
|
||||
});
|
||||
|
||||
it('包含两个 Service UUID', () => {
|
||||
expect(adapter.serviceUUIDs.length).toBe(2);
|
||||
});
|
||||
|
||||
it('包含两个 Profile 的 Characteristic', () => {
|
||||
expect(adapter.notifyCharacteristics.length).toBe(2);
|
||||
});
|
||||
|
||||
it('supportedModels 配置正确', () => {
|
||||
expect(adapter.supportedModels).toEqual(['CustomBand', 'MedicalBand']);
|
||||
});
|
||||
|
||||
it('解析心率 + 体温', () => {
|
||||
const hrResults = adapter.parseNotification(
|
||||
'0000180D-0000-1000-8000-00805f9b34fb',
|
||||
'00002A37-0000-1000-8000-00805f9b34fb',
|
||||
makeHeartRateData(80),
|
||||
);
|
||||
expect(hrResults[0].device_type).toBe('heart_rate');
|
||||
|
||||
const tempResults = adapter.parseNotification(
|
||||
'00001809-0000-1000-8000-00805f9b34fb',
|
||||
'00002A1C-0000-1000-8000-00805f9b34fb',
|
||||
makeTemperatureData(37.2),
|
||||
);
|
||||
expect(tempResults[0].device_type).toBe('temperature');
|
||||
});
|
||||
});
|
||||
|
||||
describe('边界情况', () => {
|
||||
const adapter = createGenericBleAdapter({
|
||||
name: 'Edge Case Band',
|
||||
supportedModels: ['Edge'],
|
||||
profiles: ['heart_rate'],
|
||||
});
|
||||
|
||||
it('空数据返回空数组', () => {
|
||||
const results = adapter.parseNotification(
|
||||
'0000180D-0000-1000-8000-00805f9b34fb',
|
||||
'00002A37-0000-1000-8000-00805f9b34fb',
|
||||
new ArrayBuffer(0),
|
||||
);
|
||||
expect(results.length).toBe(0);
|
||||
});
|
||||
|
||||
it('心率超范围 (>300) 返回空数组', () => {
|
||||
const results = adapter.parseNotification(
|
||||
'0000180D-0000-1000-8000-00805f9b34fb',
|
||||
'00002A37-0000-1000-8000-00805f9b34fb',
|
||||
makeHeartRateData(0),
|
||||
);
|
||||
expect(results.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,10 @@ import type { UserConfigExport } from '@tarojs/cli';
|
||||
|
||||
export default {
|
||||
logger: { quiet: false },
|
||||
mini: {},
|
||||
mini: {
|
||||
miniCssExtractPluginOption: {
|
||||
ignoreOrder: true,
|
||||
},
|
||||
},
|
||||
h5: {},
|
||||
} satisfies UserConfigExport;
|
||||
|
||||
@@ -5,7 +5,7 @@ export default defineConfig(async (merge) => {
|
||||
const baseConfig = {
|
||||
projectName: 'hms-miniprogram',
|
||||
date: '2026-4-23',
|
||||
designWidth: 750,
|
||||
designWidth: 375,
|
||||
deviceRatio: { 640: 2.34 / 2, 750: 1, 375: 2, 828: 1.81 / 2 },
|
||||
sourceRoot: 'src',
|
||||
outputRoot: 'dist',
|
||||
@@ -14,6 +14,11 @@ export default defineConfig(async (merge) => {
|
||||
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
|
||||
'process.env.TARO_APP_API_URL': JSON.stringify(process.env.TARO_APP_API_URL || 'http://localhost:3000/api/v1'),
|
||||
'process.env.TARO_APP_ENCRYPTION_KEY': JSON.stringify(process.env.TARO_APP_ENCRYPTION_KEY || ''),
|
||||
'process.env.TARO_APP_WX_TEMPLATE_APPOINTMENT': JSON.stringify(process.env.TARO_APP_WX_TEMPLATE_APPOINTMENT || ''),
|
||||
'process.env.TARO_APP_WX_TEMPLATE_FOLLOWUP': JSON.stringify(process.env.TARO_APP_WX_TEMPLATE_FOLLOWUP || ''),
|
||||
'process.env.TARO_APP_WX_TEMPLATE_REPORT': JSON.stringify(process.env.TARO_APP_WX_TEMPLATE_REPORT || ''),
|
||||
'process.env.TARO_APP_WX_TEMPLATE_CRITICAL_ALERT': JSON.stringify(process.env.TARO_APP_WX_TEMPLATE_CRITICAL_ALERT || ''),
|
||||
'process.env.TARO_APP_WX_TEMPLATE_HEALTH_ABNORMAL': JSON.stringify(process.env.TARO_APP_WX_TEMPLATE_HEALTH_ABNORMAL || ''),
|
||||
},
|
||||
copy: { patterns: [], options: {} },
|
||||
framework: 'react',
|
||||
@@ -27,6 +32,9 @@ export default defineConfig(async (merge) => {
|
||||
mini: {
|
||||
compile: {
|
||||
exclude: [],
|
||||
include: [
|
||||
require.resolve('zod').replace(/[\\/]index\.cjs$/, ''),
|
||||
],
|
||||
},
|
||||
postcss: {
|
||||
pxtransform: { enable: true, config: {} },
|
||||
|
||||
18
apps/miniprogram/e2e/check-readiness.ts
Normal file
18
apps/miniprogram/e2e/check-readiness.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// apps/miniprogram/e2e/check-readiness.ts
|
||||
async function check(url: string, label: string) {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (res.ok) return;
|
||||
} catch { /* retry */ }
|
||||
console.log(`⏳ ${label} 未就绪 (${i + 1}/5)...`);
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
throw new Error(`❌ ${label} 未就绪: ${url}`);
|
||||
}
|
||||
|
||||
export default async function setup() {
|
||||
const apiBase = process.env.E2E_API_URL || 'http://localhost:3000';
|
||||
await check(`${apiBase}/api/v1/health`, '后端 API');
|
||||
console.log('✅ 小程序 E2E 环境就绪');
|
||||
}
|
||||
41
apps/miniprogram/e2e/flows/mall-flow.spec.ts
Normal file
41
apps/miniprogram/e2e/flows/mall-flow.spec.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// apps/miniprogram/e2e/flows/mall-flow.spec.ts
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { AutomatorClient } from '../helpers/automator-client';
|
||||
import { MpAuthHelper } from '../helpers/auth.helper';
|
||||
import { MpApiClient } from '../helpers/api-client';
|
||||
import { MpNavigator } from '../helpers/navigation.helper';
|
||||
|
||||
describe('积分商城浏览链路', () => {
|
||||
let client: AutomatorClient;
|
||||
let auth: MpAuthHelper;
|
||||
let nav: MpNavigator;
|
||||
|
||||
beforeAll(async () => {
|
||||
const api = new MpApiClient();
|
||||
client = new AutomatorClient();
|
||||
await client.connect();
|
||||
auth = new MpAuthHelper(client, api);
|
||||
nav = new MpNavigator(client);
|
||||
await auth.loginAsTestPatient();
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
it('商城首页加载', async () => {
|
||||
await nav.goToMall();
|
||||
const el = await client.waitForElement('.container', 5000);
|
||||
expect(el).toBeDefined();
|
||||
});
|
||||
|
||||
it('浏览商品分类', async () => {
|
||||
const tabs = await client.getElements('.tab-item, .category-item, .ant-tabs-tab');
|
||||
if (tabs.length > 1) {
|
||||
await tabs[1].tap();
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
const pageData = await client.getPageData();
|
||||
expect(pageData).toBeDefined();
|
||||
});
|
||||
});
|
||||
45
apps/miniprogram/e2e/flows/patient-health-view.spec.ts
Normal file
45
apps/miniprogram/e2e/flows/patient-health-view.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// apps/miniprogram/e2e/flows/patient-health-view.spec.ts
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { AutomatorClient } from '../helpers/automator-client';
|
||||
import { MpAuthHelper } from '../helpers/auth.helper';
|
||||
import { MpApiClient } from '../helpers/api-client';
|
||||
import { MpNavigator } from '../helpers/navigation.helper';
|
||||
|
||||
describe('患者健康数据查看链路', () => {
|
||||
let client: AutomatorClient;
|
||||
let auth: MpAuthHelper;
|
||||
let nav: MpNavigator;
|
||||
let api: MpApiClient;
|
||||
|
||||
beforeAll(async () => {
|
||||
api = new MpApiClient();
|
||||
client = new AutomatorClient();
|
||||
await client.connect();
|
||||
auth = new MpAuthHelper(client, api);
|
||||
nav = new MpNavigator(client);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
it('登录后查看首页健康数据', async () => {
|
||||
await auth.loginAsTestPatient();
|
||||
await nav.goToHealthHome();
|
||||
|
||||
const pageData = await client.getPageData();
|
||||
expect(pageData).toBeDefined();
|
||||
});
|
||||
|
||||
it('查看体征趋势', async () => {
|
||||
await nav.goToVitalSignsTrend();
|
||||
const el = await client.waitForElement('.trend-chart, canvas, .container', 5000);
|
||||
expect(el).toBeDefined();
|
||||
});
|
||||
|
||||
it('查看随访任务列表', async () => {
|
||||
await nav.goToFollowUpTasks();
|
||||
const el = await client.waitForElement('.task-list, .container', 5000);
|
||||
expect(el).toBeDefined();
|
||||
});
|
||||
});
|
||||
47
apps/miniprogram/e2e/flows/points-flow.spec.ts
Normal file
47
apps/miniprogram/e2e/flows/points-flow.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// apps/miniprogram/e2e/flows/points-flow.spec.ts
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { AutomatorClient } from '../helpers/automator-client';
|
||||
import { MpAuthHelper } from '../helpers/auth.helper';
|
||||
import { MpApiClient } from '../helpers/api-client';
|
||||
import { MpNavigator } from '../helpers/navigation.helper';
|
||||
|
||||
describe('积分签到兑换链路', () => {
|
||||
let client: AutomatorClient;
|
||||
let auth: MpAuthHelper;
|
||||
let nav: MpNavigator;
|
||||
let api: MpApiClient;
|
||||
|
||||
beforeAll(async () => {
|
||||
api = new MpApiClient();
|
||||
client = new AutomatorClient();
|
||||
await client.connect();
|
||||
auth = new MpAuthHelper(client, api);
|
||||
nav = new MpNavigator(client);
|
||||
await auth.loginAsTestPatient();
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
it('浏览积分商城', async () => {
|
||||
await nav.goToMall();
|
||||
const el = await client.waitForElement('.product-list, .container', 5000);
|
||||
expect(el).toBeDefined();
|
||||
});
|
||||
|
||||
it('查看商品详情', async () => {
|
||||
const items = await client.getElements('.product-item, .product-card');
|
||||
if (items.length > 0) {
|
||||
await items[0].tap();
|
||||
const pageData = await client.getPageData();
|
||||
expect(pageData).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('查看订单列表', async () => {
|
||||
await nav.goToOrders();
|
||||
const el = await client.waitForElement('.order-list, .container, .empty', 5000);
|
||||
expect(el).toBeDefined();
|
||||
});
|
||||
});
|
||||
47
apps/miniprogram/e2e/flows/vital-signs-input.spec.ts
Normal file
47
apps/miniprogram/e2e/flows/vital-signs-input.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// apps/miniprogram/e2e/flows/vital-signs-input.spec.ts
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
|
||||
import { AutomatorClient } from '../helpers/automator-client';
|
||||
import { MpAuthHelper } from '../helpers/auth.helper';
|
||||
import { MpApiClient } from '../helpers/api-client';
|
||||
import { MpNavigator } from '../helpers/navigation.helper';
|
||||
|
||||
describe('体征数据录入链路', () => {
|
||||
let client: AutomatorClient;
|
||||
let auth: MpAuthHelper;
|
||||
let nav: MpNavigator;
|
||||
let api: MpApiClient;
|
||||
const cleanup: Array<() => Promise<void>> = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
api = new MpApiClient();
|
||||
await api.login();
|
||||
client = new AutomatorClient();
|
||||
await client.connect();
|
||||
auth = new MpAuthHelper(client, api);
|
||||
nav = new MpNavigator(client);
|
||||
await auth.loginAsTestPatient();
|
||||
}, 30_000);
|
||||
|
||||
afterEach(async () => {
|
||||
for (const fn of cleanup.reverse()) await fn().catch(() => {});
|
||||
cleanup.length = 0;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
it('填写并提交血压心率数据', async () => {
|
||||
await nav.goToVitalSignsInput();
|
||||
|
||||
await client.inputText('input[placeholder*="收缩压"], #systolic', '118');
|
||||
await client.inputText('input[placeholder*="舒张压"], #diastolic', '76');
|
||||
await client.inputText('input[placeholder*="心率"], #heartRate', '68');
|
||||
|
||||
await client.tap('button[type="submit"], .submit-btn');
|
||||
|
||||
const el = await client.waitForElement('.success, .ant-message-success, [class*="toast"]', 5000).catch(() => null);
|
||||
const pageData = await client.getPageData();
|
||||
expect(pageData).toBeDefined();
|
||||
});
|
||||
});
|
||||
70
apps/miniprogram/e2e/helpers/api-client.ts
Normal file
70
apps/miniprogram/e2e/helpers/api-client.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// apps/miniprogram/e2e/helpers/api-client.ts
|
||||
// 简化版 API Client,用于小程序 E2E 数据准备/清理
|
||||
|
||||
const API_BASE = process.env.E2E_API_URL || 'http://localhost:3000/api/v1';
|
||||
|
||||
export class MpApiClient {
|
||||
private token = '';
|
||||
|
||||
async login(username?: string, password?: string) {
|
||||
const res = await fetch(`${API_BASE}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: username || process.env.E2E_ADMIN_USER || 'admin',
|
||||
password: password || process.env.E2E_ADMIN_PASS || 'Admin@2026',
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!json.success) throw new Error('Login failed');
|
||||
this.token = json.data.access_token;
|
||||
return json.data;
|
||||
}
|
||||
|
||||
getToken() { return this.token; }
|
||||
|
||||
async createPatient(overrides?: Record<string, unknown>) {
|
||||
return this.post('/health/patients', overrides ?? {});
|
||||
}
|
||||
|
||||
async deletePatient(id: string, version: number) {
|
||||
await this.del(`/health/patients/${id}`, { version });
|
||||
}
|
||||
|
||||
async createVitalSigns(patientId: string, overrides?: Record<string, unknown>) {
|
||||
return this.post(`/health/patients/${patientId}/vital-signs`, overrides ?? {});
|
||||
}
|
||||
|
||||
async deleteVitalSigns(patientId: string, id: string, version: number) {
|
||||
await this.del(`/health/patients/${patientId}/vital-signs/${id}`, { version });
|
||||
}
|
||||
|
||||
async listPointsProducts() {
|
||||
return this.get('/health/points/products');
|
||||
}
|
||||
|
||||
private async headers() {
|
||||
return { 'Content-Type': 'application/json', Authorization: `Bearer ${this.token}` };
|
||||
}
|
||||
|
||||
private async get(path: string) {
|
||||
const res = await fetch(`${API_BASE}${path}`, { headers: await this.headers() });
|
||||
const json = await res.json();
|
||||
return json.data;
|
||||
}
|
||||
|
||||
private async post(path: string, body: unknown) {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method: 'POST', headers: await this.headers(), body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!json.success) throw new Error(`POST ${path} failed`);
|
||||
return json.data;
|
||||
}
|
||||
|
||||
private async del(path: string, body?: unknown) {
|
||||
await fetch(`${API_BASE}${path}`, {
|
||||
method: 'DELETE', headers: await this.headers(), body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
26
apps/miniprogram/e2e/helpers/auth.helper.ts
Normal file
26
apps/miniprogram/e2e/helpers/auth.helper.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// apps/miniprogram/e2e/helpers/auth.helper.ts
|
||||
import { AutomatorClient } from './automator-client';
|
||||
import { MpApiClient } from './api-client';
|
||||
|
||||
export class MpAuthHelper {
|
||||
constructor(
|
||||
private client: AutomatorClient,
|
||||
private api: MpApiClient,
|
||||
) {}
|
||||
|
||||
async loginAsTestPatient() {
|
||||
const loginRes = await this.api.login(
|
||||
process.env.E2E_MP_USER || 'mp_e2e_test',
|
||||
process.env.E2E_MP_PASS || 'Test@2026',
|
||||
);
|
||||
|
||||
await this.client.reLaunch('/pages/index/index');
|
||||
const page = await this.client.currentPage();
|
||||
|
||||
await this.client.callMethod('page', 'setData', {
|
||||
'access_token': loginRes.access_token,
|
||||
});
|
||||
|
||||
await this.client.reLaunch('pages/index/index');
|
||||
}
|
||||
}
|
||||
96
apps/miniprogram/e2e/helpers/automator-client.ts
Normal file
96
apps/miniprogram/e2e/helpers/automator-client.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
// apps/miniprogram/e2e/helpers/automator-client.ts
|
||||
import automator from 'miniprogram-automator';
|
||||
|
||||
const DEFAULT_CLI_PATH = 'C:/Program Files (x86)/Tencent/微信web开发者工具/cli.bat';
|
||||
const DEFAULT_PROJECT_PATH = process.cwd();
|
||||
|
||||
export class AutomatorClient {
|
||||
private mini: automator.MiniProgram | null = null;
|
||||
|
||||
async connect(cliPath?: string, projectPath?: string) {
|
||||
this.mini = await automator.launch({
|
||||
cliPath: cliPath || DEFAULT_CLI_PATH,
|
||||
projectPath: projectPath || DEFAULT_PROJECT_PATH,
|
||||
});
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
if (this.mini) {
|
||||
await this.mini.close();
|
||||
this.mini = null;
|
||||
}
|
||||
}
|
||||
|
||||
private getMini(): automator.MiniProgram {
|
||||
if (!this.mini) throw new Error('AutomatorClient 未连接,请先调用 connect()');
|
||||
return this.mini;
|
||||
}
|
||||
|
||||
async currentPage(): Promise<automator.Page> {
|
||||
return this.getMini().currentPage();
|
||||
}
|
||||
|
||||
async navigateTo(path: string, _query?: Record<string, string>) {
|
||||
const page = await this.getMini().navigateTo(`/${path.replace(/^\//, '')}`);
|
||||
return page;
|
||||
}
|
||||
|
||||
async navigateBack() {
|
||||
await this.getMini().navigateBack();
|
||||
}
|
||||
|
||||
async reLaunch(path: string) {
|
||||
await this.getMini().reLaunch(`/${path.replace(/^\//, '')}`);
|
||||
}
|
||||
|
||||
async tap(selector: string) {
|
||||
const page = this.getMini().currentPage();
|
||||
const element = await page.$(selector);
|
||||
if (!element) throw new Error(`元素未找到: ${selector}`);
|
||||
await element.tap();
|
||||
}
|
||||
|
||||
async inputText(selector: string, value: string) {
|
||||
const page = this.getMini().currentPage();
|
||||
const element = await page.$(selector);
|
||||
if (!element) throw new Error(`元素未找到: ${selector}`);
|
||||
await element.setValue(value);
|
||||
}
|
||||
|
||||
async getElement(selector: string) {
|
||||
const page = this.getMini().currentPage();
|
||||
return page.$(selector);
|
||||
}
|
||||
|
||||
async getElements(selector: string) {
|
||||
const page = this.getMini().currentPage();
|
||||
return page.$$(selector);
|
||||
}
|
||||
|
||||
async waitForElement(selector: string, timeout = 5000): Promise<automator.Element> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
const el = await this.getElement(selector);
|
||||
if (el) return el;
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
throw new Error(`等待元素超时: ${selector} (${timeout}ms)`);
|
||||
}
|
||||
|
||||
async getPageData(path?: string) {
|
||||
const page = this.getMini().currentPage();
|
||||
return page.data(path);
|
||||
}
|
||||
|
||||
async screenshot(path?: string): Promise<Buffer> {
|
||||
const page = this.getMini().currentPage();
|
||||
return page.screenshot({ path });
|
||||
}
|
||||
|
||||
async callMethod(selector: string, method: string, ...args: unknown[]) {
|
||||
const page = this.getMini().currentPage();
|
||||
const element = await page.$(selector);
|
||||
if (!element) throw new Error(`元素未找到: ${selector}`);
|
||||
return element.callMethod(method, ...args);
|
||||
}
|
||||
}
|
||||
34
apps/miniprogram/e2e/helpers/navigation.helper.ts
Normal file
34
apps/miniprogram/e2e/helpers/navigation.helper.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// apps/miniprogram/e2e/helpers/navigation.helper.ts
|
||||
import { AutomatorClient } from './automator-client';
|
||||
|
||||
export class MpNavigator {
|
||||
constructor(private client: AutomatorClient) {}
|
||||
|
||||
async goToHealthHome() {
|
||||
await this.client.reLaunch('pages/pkg-health/index');
|
||||
}
|
||||
|
||||
async goToVitalSignsInput() {
|
||||
await this.client.navigateTo('pages/pkg-health/input/index');
|
||||
}
|
||||
|
||||
async goToVitalSignsTrend() {
|
||||
await this.client.navigateTo('pages/pkg-health/trend/index');
|
||||
}
|
||||
|
||||
async goToProfile() {
|
||||
await this.client.navigateTo('pages/pkg-profile/index');
|
||||
}
|
||||
|
||||
async goToMall() {
|
||||
await this.client.reLaunch('pages/pkg-mall/index');
|
||||
}
|
||||
|
||||
async goToFollowUpTasks() {
|
||||
await this.client.navigateTo('pages/pkg-health/followups/index');
|
||||
}
|
||||
|
||||
async goToOrders() {
|
||||
await this.client.navigateTo('pages/pkg-mall/orders/index');
|
||||
}
|
||||
}
|
||||
13
apps/miniprogram/e2e/vitest.config.ts
Normal file
13
apps/miniprogram/e2e/vitest.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// apps/miniprogram/e2e/vitest.config.ts
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
root: './e2e',
|
||||
testTimeout: 30_000,
|
||||
hookTimeout: 30_000,
|
||||
testSequence: { sequential: true },
|
||||
reporter: 'verbose',
|
||||
globalSetup: ['./check-readiness.ts'],
|
||||
},
|
||||
});
|
||||
@@ -5,7 +5,8 @@
|
||||
"description": "HMS 健康管理平台患者小程序",
|
||||
"scripts": {
|
||||
"build:weapp": "taro build --type weapp",
|
||||
"dev:weapp": "taro build --type weapp --watch"
|
||||
"dev:weapp": "taro build --type weapp --watch",
|
||||
"test:e2e": "vitest run --config e2e/vitest.config.ts"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 3 versions",
|
||||
@@ -41,6 +42,8 @@
|
||||
"miniprogram-automator": "^0.12.1",
|
||||
"sass": "^1.87.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vite": "^8.0.10",
|
||||
"vitest": "^4.1.5",
|
||||
"webpack": "~5.95.0"
|
||||
}
|
||||
}
|
||||
|
||||
716
apps/miniprogram/pnpm-lock.yaml
generated
716
apps/miniprogram/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -9,8 +9,12 @@
|
||||
"enhance": false,
|
||||
"compileHotReLoad": true,
|
||||
"postcss": false,
|
||||
"minified": false,
|
||||
"minified": true,
|
||||
"bundle": false,
|
||||
"minifyWXML": true
|
||||
}
|
||||
"minifyWXML": true,
|
||||
"packNpmManually": false,
|
||||
"packNpmRelationList": [],
|
||||
"ignoreUploadUnusedFiles": true
|
||||
},
|
||||
"condition": {}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
"lazyloadPlaceholderEnable": false,
|
||||
"skylineRenderEnable": false,
|
||||
"preloadBackgroundData": false,
|
||||
"autoAudits": false,
|
||||
"autoAudits": true,
|
||||
"useApiHook": true,
|
||||
"showShadowRootInWxmlPanel": false,
|
||||
"useStaticServer": false,
|
||||
|
||||
@@ -3,6 +3,7 @@ export default defineAppConfig({
|
||||
'pages/index/index',
|
||||
'pages/login/index',
|
||||
'pages/health/index',
|
||||
'pages/messages/index',
|
||||
'pages/consultation/index',
|
||||
'pages/consultation/detail/index',
|
||||
'pages/mall/index',
|
||||
@@ -10,14 +11,13 @@ export default defineAppConfig({
|
||||
'pages/appointment/index',
|
||||
'pages/appointment/create/index',
|
||||
'pages/appointment/detail/index',
|
||||
'pages/article/index',
|
||||
'pages/legal/user-agreement',
|
||||
'pages/legal/privacy-policy',
|
||||
],
|
||||
subPackages: [
|
||||
{
|
||||
root: 'pages/health',
|
||||
pages: ['trend/index', 'input/index', 'daily-monitoring/index'],
|
||||
root: 'pages/pkg-health',
|
||||
pages: ['trend/index', 'input/index', 'daily-monitoring/index', 'alerts/index'],
|
||||
},
|
||||
{
|
||||
root: 'pages/doctor',
|
||||
@@ -26,26 +26,50 @@ export default defineAppConfig({
|
||||
'consultation/index', 'consultation/detail/index',
|
||||
'followup/index', 'followup/detail/index',
|
||||
'report/index', 'report/detail/index',
|
||||
'alerts/index', 'alerts/detail/index',
|
||||
'action-inbox/index',
|
||||
'dialysis/index', 'dialysis/detail/index', 'dialysis/create/index',
|
||||
'prescription/index', 'prescription/detail/index', 'prescription/create/index',
|
||||
],
|
||||
},
|
||||
{
|
||||
root: 'pages/mall',
|
||||
root: 'pages/pkg-mall',
|
||||
pages: ['exchange/index', 'orders/index', 'detail/index'],
|
||||
},
|
||||
{
|
||||
root: 'pages/profile',
|
||||
root: 'pages/pkg-profile',
|
||||
pages: [
|
||||
'family/index', 'family-add/index', 'reports/index',
|
||||
'followups/index', 'medication/index', 'settings/index',
|
||||
'dialysis-records/index', 'dialysis-records/detail/index',
|
||||
'dialysis-prescriptions/index', 'dialysis-prescriptions/detail/index',
|
||||
'consents/index', 'health-records/index', 'diagnoses/index',
|
||||
'elder-mode/index',
|
||||
],
|
||||
},
|
||||
{
|
||||
root: 'pages',
|
||||
pages: [
|
||||
'article/detail/index', 'ai-report/list/index',
|
||||
'ai-report/detail/index', 'report/detail/index',
|
||||
'followup/detail/index', 'events/index', 'device-sync/index',
|
||||
],
|
||||
root: 'pages/ai-report',
|
||||
pages: ['list/index', 'detail/index'],
|
||||
},
|
||||
{
|
||||
root: 'pages/article',
|
||||
pages: ['index', 'detail/index'],
|
||||
},
|
||||
{
|
||||
root: 'pages/report',
|
||||
pages: ['detail/index'],
|
||||
},
|
||||
{
|
||||
root: 'pages/followup',
|
||||
pages: ['detail/index'],
|
||||
},
|
||||
{
|
||||
root: 'pages/events',
|
||||
pages: ['index'],
|
||||
},
|
||||
{
|
||||
root: 'pages/device-sync',
|
||||
pages: ['index'],
|
||||
},
|
||||
],
|
||||
tabBar: {
|
||||
@@ -55,9 +79,8 @@ export default defineAppConfig({
|
||||
borderStyle: 'white',
|
||||
list: [
|
||||
{ pagePath: 'pages/index/index', text: '首页', iconPath: 'assets/tabbar/home.png', selectedIconPath: 'assets/tabbar/home-active.png' },
|
||||
{ pagePath: 'pages/health/index', text: '上报', iconPath: 'assets/tabbar/health.png', selectedIconPath: 'assets/tabbar/health-active.png' },
|
||||
{ pagePath: 'pages/consultation/index', text: '咨询', iconPath: 'assets/tabbar/appointment.png', selectedIconPath: 'assets/tabbar/appointment-active.png' },
|
||||
{ pagePath: 'pages/mall/index', text: '商城', iconPath: 'assets/tabbar/article.png', selectedIconPath: 'assets/tabbar/article-active.png' },
|
||||
{ pagePath: 'pages/health/index', text: '健康', iconPath: 'assets/tabbar/health.png', selectedIconPath: 'assets/tabbar/health-active.png' },
|
||||
{ pagePath: 'pages/messages/index', text: '消息', iconPath: 'assets/tabbar/message.png', selectedIconPath: 'assets/tabbar/message-active.png' },
|
||||
{ pagePath: 'pages/profile/index', text: '我的', iconPath: 'assets/tabbar/profile.png', selectedIconPath: 'assets/tabbar/profile-active.png' },
|
||||
],
|
||||
},
|
||||
@@ -66,5 +89,6 @@ export default defineAppConfig({
|
||||
navigationBarBackgroundColor: '#FFFFFF',
|
||||
navigationBarTitleText: '健康管理',
|
||||
navigationBarTextStyle: 'black',
|
||||
enablePullDownRefresh: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
@import './styles/variables.scss';
|
||||
@import './styles/tokens.scss';
|
||||
@import './styles/elder-mode.scss';
|
||||
|
||||
page {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica, 'PingFang SC',
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { useEffect, PropsWithChildren } from 'react';
|
||||
import Taro from '@tarojs/taro';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import { flushEvents } from './services/analytics';
|
||||
import { useAuthStore } from './stores/auth';
|
||||
import { useUIStore } from './stores/ui';
|
||||
import './app.scss';
|
||||
|
||||
function App({ children }: PropsWithChildren<Record<string, unknown>>) {
|
||||
const restoreAuth = useAuthStore((s) => s.restore);
|
||||
const restoreUI = useUIStore((s) => s.restore);
|
||||
|
||||
useDidShow(() => {
|
||||
restoreAuth();
|
||||
restoreUI();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
restoreAuth();
|
||||
const timer = setInterval(() => {
|
||||
flushEvents();
|
||||
}, 30000);
|
||||
|
||||
BIN
apps/miniprogram/src/assets/tabbar/message-active.png
Normal file
BIN
apps/miniprogram/src/assets/tabbar/message-active.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 333 B |
BIN
apps/miniprogram/src/assets/tabbar/message.png
Normal file
BIN
apps/miniprogram/src/assets/tabbar/message.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 334 B |
51
apps/miniprogram/src/components/DeviceCard/index.scss
Normal file
51
apps/miniprogram/src/components/DeviceCard/index.scss
Normal file
@@ -0,0 +1,51 @@
|
||||
@import '../../styles/variables.scss';
|
||||
|
||||
.device-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 24rpx;
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
margin-bottom: 16rpx;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
.device-icon {
|
||||
font-size: var(--tk-font-h2);
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.device-info {
|
||||
flex: 1;
|
||||
|
||||
.device-name {
|
||||
font-size: var(--tk-font-cap);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.device-status {
|
||||
font-size: var(--tk-font-micro);
|
||||
margin-top: 4rpx;
|
||||
display: block;
|
||||
|
||||
&.connected { color: $pri; }
|
||||
&.idle { color: $tx3; }
|
||||
}
|
||||
|
||||
.last-sync {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx3;
|
||||
margin-top: 4rpx;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.sync-btn {
|
||||
padding: 12rpx 28rpx;
|
||||
background: $pri;
|
||||
color: #fff;
|
||||
border-radius: $r-pill;
|
||||
font-size: var(--tk-font-micro);
|
||||
}
|
||||
}
|
||||
39
apps/miniprogram/src/components/DeviceCard/index.tsx
Normal file
39
apps/miniprogram/src/components/DeviceCard/index.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import './index.scss';
|
||||
|
||||
interface DeviceCardProps {
|
||||
deviceName: string;
|
||||
deviceType: string;
|
||||
lastSyncAt?: string;
|
||||
status: 'connected' | 'disconnected' | 'never';
|
||||
}
|
||||
|
||||
const DEVICE_ICONS: Record<string, string> = {
|
||||
blood_pressure: '\u{1FA7A}',
|
||||
blood_glucose: '\u{1FA78}',
|
||||
heart_rate: '\u{2764}',
|
||||
blood_oxygen: '\u{1FAB1}',
|
||||
};
|
||||
|
||||
export default function DeviceCard({ deviceName, deviceType, lastSyncAt, status }: DeviceCardProps) {
|
||||
const icon = DEVICE_ICONS[deviceType] || '\u{1F4F1}';
|
||||
const statusLabel = status === 'connected' ? '已连接' : status === 'disconnected' ? '未连接' : '未配对';
|
||||
const statusClass = status === 'connected' ? 'connected' : 'idle';
|
||||
|
||||
const handleSync = () => {
|
||||
Taro.navigateTo({ url: '/pages/device-sync/index' });
|
||||
};
|
||||
|
||||
return (
|
||||
<View className='device-card' onClick={handleSync}>
|
||||
<View className='device-icon'>{icon}</View>
|
||||
<View className='device-info'>
|
||||
<Text className='device-name'>{deviceName}</Text>
|
||||
<Text className={`device-status ${statusClass}`}>{statusLabel}</Text>
|
||||
{lastSyncAt && <Text className='last-sync'>最近同步: {lastSyncAt}</Text>}
|
||||
</View>
|
||||
<View className='sync-btn'>同步</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -8,20 +8,33 @@
|
||||
padding: 120px 40px;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 80px;
|
||||
.empty-state-icon-wrap {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
background: $surface-alt;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.empty-state-icon-char {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-hero);
|
||||
font-weight: 600;
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.empty-state-text {
|
||||
font-size: 30px;
|
||||
font-size: var(--tk-font-num);
|
||||
color: $tx2;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-state-hint {
|
||||
font-size: 24px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: var(--tk-text-secondary);
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
@@ -32,6 +45,6 @@
|
||||
}
|
||||
|
||||
.empty-state-action-text {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -11,15 +11,18 @@ interface EmptyStateProps {
|
||||
}
|
||||
|
||||
export default React.memo(function EmptyState({
|
||||
icon = '📭',
|
||||
icon,
|
||||
text,
|
||||
hint,
|
||||
actionText,
|
||||
onAction,
|
||||
}: EmptyStateProps) {
|
||||
const displayChar = icon || text.charAt(0);
|
||||
return (
|
||||
<View className='empty-state'>
|
||||
<Text className='empty-state-icon'>{icon}</Text>
|
||||
<View className='empty-state-icon-wrap'>
|
||||
<Text className='empty-state-icon-char'>{displayChar}</Text>
|
||||
</View>
|
||||
<Text className='empty-state-text'>{text}</Text>
|
||||
{hint && <Text className='empty-state-hint'>{hint}</Text>}
|
||||
{actionText && onAction && (
|
||||
|
||||
@@ -23,13 +23,25 @@ export default class ErrorBoundary extends Component<Props, State> {
|
||||
console.error('[ErrorBoundary]', error, info.componentStack);
|
||||
}
|
||||
|
||||
handleRetry = () => {
|
||||
this.setState({ hasError: false });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<View style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '60vh', padding: '40px' }}>
|
||||
<Text style={{ fontSize: '48px', marginBottom: '20px' }}>😵</Text>
|
||||
<Text style={{ fontSize: '32px', color: '#134E4A', marginBottom: '12px' }}>页面出了点问题</Text>
|
||||
<Text style={{ fontSize: '24px', color: '#94A3B8', marginBottom: '24px' }}>请返回重试</Text>
|
||||
<View style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '60vh', padding: '40px 24px' }}>
|
||||
<View style={{ width: '64px', height: '64px', borderRadius: '32px', background: '#F0DDD4', display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: '20px' }}>
|
||||
<Text style={{ fontFamily: 'Georgia, serif', fontSize: '28px', fontWeight: 600, color: '#8B3E1F' }}>!</Text>
|
||||
</View>
|
||||
<Text style={{ fontSize: '32px', color: '#2D2A26', marginBottom: '12px', fontWeight: 600 }}>页面出了点问题</Text>
|
||||
<Text style={{ fontSize: '24px', color: '#78716C', marginBottom: '32px' }}>请返回重试</Text>
|
||||
<View
|
||||
onClick={this.handleRetry}
|
||||
style={{ background: '#C4623A', borderRadius: '12px', padding: '14px 48px' }}
|
||||
>
|
||||
<Text style={{ color: '#FFFFFF', fontSize: '28px' }}>重新加载</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,12 +9,12 @@
|
||||
}
|
||||
|
||||
.error-state-icon {
|
||||
font-size: 80px;
|
||||
font-size: 80px; /* hero icon — kept as-is */
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.error-state-text {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx2;
|
||||
margin-bottom: 32px;
|
||||
text-align: center;
|
||||
@@ -27,6 +27,6 @@
|
||||
}
|
||||
|
||||
.error-state-retry-text {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
64
apps/miniprogram/src/components/GuestGuard/index.scss
Normal file
64
apps/miniprogram/src/components/GuestGuard/index.scss
Normal file
@@ -0,0 +1,64 @@
|
||||
@import '../../styles/variables.scss';
|
||||
@import '../../styles/mixins.scss';
|
||||
|
||||
.guard-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 24px;
|
||||
}
|
||||
|
||||
.guard-card {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.guard-icon-wrap {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 40px;
|
||||
background: $surface-alt;
|
||||
@include flex-center;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
.guard-icon {
|
||||
font-size: var(--tk-font-num);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.guard-title {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.guard-desc {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: var(--tk-text-secondary);
|
||||
display: block;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.guard-btn {
|
||||
display: inline-block;
|
||||
height: 48px;
|
||||
padding: 0 32px;
|
||||
background: $pri;
|
||||
border-radius: $r-pill;
|
||||
@include flex-center;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.guard-btn-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
28
apps/miniprogram/src/components/GuestGuard/index.tsx
Normal file
28
apps/miniprogram/src/components/GuestGuard/index.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import { navigateToLogin } from '../../utils/navigate';
|
||||
import './index.scss';
|
||||
|
||||
interface GuestGuardProps {
|
||||
title: string;
|
||||
desc?: string;
|
||||
}
|
||||
|
||||
export default function GuestGuard({ title, desc }: GuestGuardProps) {
|
||||
return (
|
||||
<View className='guard-page'>
|
||||
<View className='guard-card'>
|
||||
<View className='guard-icon-wrap'>
|
||||
<Text className='guard-icon'>锁</Text>
|
||||
</View>
|
||||
<Text className='guard-title'>{title}</Text>
|
||||
{desc && <Text className='guard-desc'>{desc}</Text>}
|
||||
<View
|
||||
className='guard-btn'
|
||||
onClick={navigateToLogin}
|
||||
>
|
||||
<Text className='guard-btn-text'>立即登录</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,6 @@
|
||||
}
|
||||
|
||||
.loading-state-text {
|
||||
font-size: 26px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: var(--tk-text-secondary);
|
||||
}
|
||||
|
||||
29
apps/miniprogram/src/components/ProgressRing.scss
Normal file
29
apps/miniprogram/src/components/ProgressRing.scss
Normal file
@@ -0,0 +1,29 @@
|
||||
@import '../styles/variables.scss';
|
||||
|
||||
.progress-ring {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.progress-ring-inner {
|
||||
background: $card;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.progress-ring-percent {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.progress-ring-unit {
|
||||
font-size: var(--tk-font-micro);
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
40
apps/miniprogram/src/components/ProgressRing.tsx
Normal file
40
apps/miniprogram/src/components/ProgressRing.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import './ProgressRing.scss';
|
||||
|
||||
interface ProgressRingProps {
|
||||
percent: number;
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
color?: string;
|
||||
trackColor?: string;
|
||||
}
|
||||
|
||||
export default function ProgressRing({
|
||||
percent,
|
||||
size = 72,
|
||||
strokeWidth = 7,
|
||||
color = '#C4623A',
|
||||
trackColor = '#E8E2DC',
|
||||
}: ProgressRingProps) {
|
||||
const clamped = Math.max(0, Math.min(100, percent));
|
||||
const innerSize = size - strokeWidth * 2;
|
||||
|
||||
return (
|
||||
<View
|
||||
className='progress-ring'
|
||||
style={`width:${size}px;height:${size}px;background:conic-gradient(${color} ${clamped}%, ${trackColor} ${clamped}%);border-radius:50%;padding:${strokeWidth}px;`}
|
||||
>
|
||||
<View
|
||||
className='progress-ring-inner'
|
||||
style={`width:${innerSize}px;height:${innerSize}px;`}
|
||||
>
|
||||
<Text className='progress-ring-percent' style={`color:${color};`}>
|
||||
{clamped}
|
||||
</Text>
|
||||
<Text className='progress-ring-unit' style={`color:${color};`}>
|
||||
%
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -39,7 +39,7 @@
|
||||
justify-content: center;
|
||||
background: $bd-l;
|
||||
color: $tx3;
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
transition: all 0.3s ease;
|
||||
z-index: 1;
|
||||
|
||||
@@ -55,8 +55,8 @@
|
||||
}
|
||||
|
||||
.step-label {
|
||||
font-size: 22px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-body);
|
||||
color: var(--tk-text-secondary);
|
||||
margin-top: 8px;
|
||||
text-align: center;
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
}
|
||||
|
||||
.trend-chart-empty-text {
|
||||
font-size: 28px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: var(--tk-text-secondary);
|
||||
}
|
||||
|
||||
.trend-chart-skeleton {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useEffect, useRef, useCallback } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import EcCanvas from '../EcCanvas';
|
||||
import type { EcCanvasRef } from '../EcCanvas';
|
||||
import { Canvas, View, Text } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import './index.scss';
|
||||
|
||||
interface TrendChartProps {
|
||||
@@ -12,6 +11,24 @@ interface TrendChartProps {
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const DPR = Taro.getSystemInfoSync().pixelRatio || 2;
|
||||
|
||||
function drawLine(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
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();
|
||||
}
|
||||
|
||||
export default React.memo(function TrendChart({
|
||||
data,
|
||||
referenceMin,
|
||||
@@ -19,97 +36,132 @@ export default React.memo(function TrendChart({
|
||||
unit = '',
|
||||
height = 500,
|
||||
}: TrendChartProps) {
|
||||
const chartRef = useRef<EcCanvasRef>(null);
|
||||
const canvasRef = useRef<any>(null);
|
||||
|
||||
const getOption = useCallback(() => {
|
||||
if (!data || data.length === 0) return null;
|
||||
const draw = useCallback(() => {
|
||||
const node = canvasRef.current;
|
||||
if (!node || !data || data.length === 0) return;
|
||||
|
||||
const series: any[] = [];
|
||||
const markArea: any = {};
|
||||
const w = node.width / DPR;
|
||||
const h = node.height / DPR;
|
||||
const ctx = node.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.clearRect(0, 0, node.width, node.height);
|
||||
ctx.save();
|
||||
ctx.scale(DPR, DPR);
|
||||
|
||||
const pad = { left: 45, right: 15, top: 20, bottom: 30 };
|
||||
const cw = w - pad.left - pad.right;
|
||||
const ch = h - pad.top - pad.bottom;
|
||||
|
||||
const values = data.map((d) => d.value);
|
||||
let yMin = Math.min(...values);
|
||||
let yMax = Math.max(...values);
|
||||
if (referenceMin != null) yMin = Math.min(yMin, referenceMin);
|
||||
if (referenceMax != null) yMax = Math.max(yMax, referenceMax);
|
||||
const yRange = yMax - yMin || 1;
|
||||
const yPad = yRange * 0.1;
|
||||
yMin -= yPad;
|
||||
yMax += yPad;
|
||||
const yTotal = yMax - yMin;
|
||||
|
||||
const toX = (i: number) => pad.left + (i / Math.max(data.length - 1, 1)) * cw;
|
||||
const toY = (v: number) => pad.top + ch - ((v - yMin) / yTotal) * ch;
|
||||
|
||||
// Reference band
|
||||
if (referenceMin != null && referenceMax != null) {
|
||||
markArea.data = [
|
||||
[
|
||||
{
|
||||
yAxis: referenceMin,
|
||||
itemStyle: { color: 'rgba(5,150,105,0.08)' },
|
||||
},
|
||||
{ yAxis: referenceMax },
|
||||
],
|
||||
];
|
||||
const ry1 = toY(referenceMax);
|
||||
const ry2 = toY(referenceMin);
|
||||
ctx.fillStyle = 'rgba(5,150,105,0.08)';
|
||||
ctx.fillRect(pad.left, ry1, cw, ry2 - ry1);
|
||||
}
|
||||
|
||||
series.push({
|
||||
type: 'line',
|
||||
data: data.map((d) => d.value),
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
lineStyle: { color: '#0891B2', width: 2 },
|
||||
itemStyle: { color: '#0891B2' },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(8,145,178,0.15)' },
|
||||
{ offset: 1, color: 'rgba(8,145,178,0.01)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
markArea: markArea.data
|
||||
? { silent: true, data: markArea.data }
|
||||
: undefined,
|
||||
markPoint:
|
||||
referenceMin != null && referenceMax != null
|
||||
? {
|
||||
data: data
|
||||
.filter((d) => d.value < referenceMin || d.value > referenceMax)
|
||||
.map((d) => ({
|
||||
coord: [data.indexOf(d), d.value],
|
||||
itemStyle: { color: '#DC2626' },
|
||||
symbolSize: 12,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
// Grid lines
|
||||
ctx.strokeStyle = '#F3F4F6';
|
||||
ctx.lineWidth = 1;
|
||||
const gridLines = 4;
|
||||
for (let i = 0; i <= gridLines; i++) {
|
||||
const gy = pad.top + (ch / gridLines) * i;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pad.left, gy);
|
||||
ctx.lineTo(pad.left + cw, gy);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
return {
|
||||
grid: { left: 45, right: 15, top: 20, bottom: 30 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.map((d) => d.date.slice(5)),
|
||||
axisLabel: { fontSize: 10, color: '#94A3B8' },
|
||||
axisLine: { lineStyle: { color: '#E5E7EB' } },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: { fontSize: 10, color: '#94A3B8' },
|
||||
splitLine: { lineStyle: { color: '#F3F4F6' } },
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params: any) => {
|
||||
const p = params[0];
|
||||
const idx = p.dataIndex;
|
||||
return `${data[idx]?.date || ''}\n${p.value}${unit ? ' ' + unit : ''}`;
|
||||
},
|
||||
},
|
||||
series,
|
||||
};
|
||||
}, [data, referenceMin, referenceMax, unit]);
|
||||
// Y-axis labels
|
||||
ctx.fillStyle = '#94A3B8';
|
||||
ctx.font = '10px sans-serif';
|
||||
ctx.textAlign = 'right';
|
||||
for (let i = 0; i <= gridLines; i++) {
|
||||
const val = yMax - (yTotal / gridLines) * i;
|
||||
const gy = pad.top + (ch / gridLines) * i;
|
||||
ctx.fillText(val.toFixed(1), pad.left - 6, gy + 3);
|
||||
}
|
||||
|
||||
// X-axis labels
|
||||
ctx.textAlign = 'center';
|
||||
const step = Math.max(1, Math.floor(data.length / 5));
|
||||
for (let i = 0; i < data.length; i += step) {
|
||||
const lx = toX(i);
|
||||
ctx.fillText(data[i].date.slice(5), lx, h - 8);
|
||||
}
|
||||
|
||||
// Area fill
|
||||
const chartPoints = data.map((d, i) => ({ x: toX(i), y: toY(d.value) }));
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(chartPoints[0].x, toY(yMin));
|
||||
ctx.lineTo(chartPoints[0].x, chartPoints[0].y);
|
||||
for (let i = 1; i < chartPoints.length; i++) {
|
||||
const prev = chartPoints[i - 1];
|
||||
const curr = chartPoints[i];
|
||||
const cpx = (prev.x + curr.x) / 2;
|
||||
ctx.bezierCurveTo(cpx, prev.y, cpx, curr.y, curr.x, curr.y);
|
||||
}
|
||||
ctx.lineTo(chartPoints[chartPoints.length - 1].x, toY(yMin));
|
||||
ctx.closePath();
|
||||
const grad = ctx.createLinearGradient(0, pad.top, 0, pad.top + ch);
|
||||
grad.addColorStop(0, 'rgba(8,145,178,0.15)');
|
||||
grad.addColorStop(1, 'rgba(8,145,178,0.01)');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fill();
|
||||
|
||||
// Line
|
||||
ctx.strokeStyle = '#0891B2';
|
||||
ctx.lineWidth = 2;
|
||||
drawLine(ctx, chartPoints);
|
||||
|
||||
// Data points
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const d = data[i];
|
||||
const isAbnormal =
|
||||
(referenceMin != null && d.value < referenceMin) ||
|
||||
(referenceMax != null && d.value > referenceMax);
|
||||
ctx.beginPath();
|
||||
ctx.arc(chartPoints[i].x, chartPoints[i].y, isAbnormal ? 5 : 3, 0, Math.PI * 2);
|
||||
ctx.fillStyle = isAbnormal ? '#DC2626' : '#0891B2';
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}, [data, referenceMin, referenceMax]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chartRef.current && data && data.length > 0) {
|
||||
const option = getOption();
|
||||
if (option) {
|
||||
chartRef.current.setOption(option);
|
||||
}
|
||||
}
|
||||
}, [data, getOption]);
|
||||
const query = Taro.createSelectorQuery();
|
||||
query
|
||||
.select('#trend-chart-canvas')
|
||||
.node()
|
||||
.exec((res) => {
|
||||
const node = res[0]?.node;
|
||||
if (!node) return;
|
||||
canvasRef.current = node;
|
||||
const sysInfo = Taro.getSystemInfoSync();
|
||||
const canvasW = (sysInfo.windowWidth * 750) / sysInfo.windowWidth;
|
||||
node.width = sysInfo.windowWidth * DPR;
|
||||
node.height = ((height / 750) * sysInfo.windowWidth) * DPR;
|
||||
draw();
|
||||
});
|
||||
}, [draw, height]);
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
@@ -121,7 +173,11 @@ export default React.memo(function TrendChart({
|
||||
|
||||
return (
|
||||
<View className='trend-chart' style={{ height: `${height}rpx` }}>
|
||||
<EcCanvas canvasId='trend-chart-canvas' ref={chartRef} height={height} />
|
||||
<Canvas
|
||||
type='2d'
|
||||
id='trend-chart-canvas'
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -14,13 +14,13 @@
|
||||
}
|
||||
|
||||
.week-arrow {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $pri;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.week-label {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx;
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -39,13 +39,13 @@
|
||||
}
|
||||
|
||||
.cell-weekday {
|
||||
font-size: 20px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-body);
|
||||
color: var(--tk-text-secondary);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cell-date {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
|
||||
6
apps/miniprogram/src/hooks/useElderClass.ts
Normal file
6
apps/miniprogram/src/hooks/useElderClass.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { useUIStore } from '../stores/ui';
|
||||
|
||||
export function useElderClass(): string {
|
||||
const mode = useUIStore((s) => s.mode);
|
||||
return mode === 'elder' ? 'elder-mode' : '';
|
||||
}
|
||||
@@ -1,23 +1,5 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
|
||||
@mixin section-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
margin-bottom: 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@mixin tag($bg, $color) {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
background: $bg;
|
||||
color: $color;
|
||||
}
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
.detail-page {
|
||||
min-height: 100vh;
|
||||
@@ -45,7 +27,7 @@
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
@@ -63,7 +45,7 @@
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
line-height: 1.8;
|
||||
margin-bottom: 16px;
|
||||
@@ -75,7 +57,7 @@
|
||||
}
|
||||
|
||||
li {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
line-height: 1.8;
|
||||
margin-bottom: 8px;
|
||||
@@ -87,7 +69,7 @@
|
||||
}
|
||||
|
||||
.report-content {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
line-height: 1.8;
|
||||
color: $tx;
|
||||
}
|
||||
@@ -96,6 +78,35 @@
|
||||
display: block;
|
||||
text-align: center;
|
||||
padding: 120px 0;
|
||||
color: $tx3;
|
||||
font-size: 28px;
|
||||
color: var(--tk-text-secondary);
|
||||
font-size: var(--tk-font-body-lg);
|
||||
}
|
||||
|
||||
.auto-badge {
|
||||
margin-top: 16px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.auto-badge-text {
|
||||
display: inline-block;
|
||||
padding: 4px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 500;
|
||||
background: #f0e6ff;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.trend-tip-card {
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: $r;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.trend-tip-text {
|
||||
font-size: var(--tk-font-body);
|
||||
color: #92400e;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { View, Text, RichText } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { getAiAnalysisDetail, type AiAnalysisItem } from '@/services/ai-analysis';
|
||||
import Loading from '@/components/Loading';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
@@ -12,8 +13,26 @@ const TYPE_LABELS: Record<string, string> = {
|
||||
report_summary_generation: '报告摘要',
|
||||
};
|
||||
|
||||
/** 移除危险的 HTML 标签和事件属性,防止 XSS */
|
||||
function sanitizeHtml(html: string): string {
|
||||
return html
|
||||
// 移除 <script> 标签及其内容
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||
// 移除 <iframe>, <object>, <embed>, <form>, <input>, <textarea>, <style> 标签
|
||||
.replace(/<\/?(?:iframe|object|embed|form|input|textarea|style)\b[^>]*>/gi, '')
|
||||
// 移除 <link> 和 <meta> 标签
|
||||
.replace(/<\/?(?:link|meta)\b[^>]*>/gi, '')
|
||||
// 移除所有 on* 事件属性 (onclick, onerror, onload 等)
|
||||
.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')
|
||||
// 移除 javascript: 和 data: 协议的 href/src 属性
|
||||
.replace(/(href|src)\s*=\s*(?:"javascript:[^"]*"|'javascript:[^']*')/gi, '')
|
||||
.replace(/(href|src)\s*=\s*(?:"data:[^"]*"|'data:[^']*')/gi, '');
|
||||
}
|
||||
|
||||
function markdownToHtml(md: string): string {
|
||||
return md
|
||||
// 先转义 markdown 中可能存在的原始 HTML 标签
|
||||
const escaped = sanitizeHtml(md);
|
||||
return escaped
|
||||
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
||||
@@ -26,6 +45,7 @@ function markdownToHtml(md: string): string {
|
||||
}
|
||||
|
||||
export default function AiReportDetail() {
|
||||
const modeClass = useElderClass();
|
||||
const router = useRouter();
|
||||
const id = router.params.id || '';
|
||||
|
||||
@@ -45,7 +65,7 @@ export default function AiReportDetail() {
|
||||
|
||||
if (!analysis) {
|
||||
return (
|
||||
<View className='detail-page'>
|
||||
<View className={`detail-page ${modeClass}`}>
|
||||
<Text className='empty-text'>报告不存在</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -55,16 +75,32 @@ export default function AiReportDetail() {
|
||||
? markdownToHtml(analysis.result_content)
|
||||
: '<p>暂无分析结果</p>';
|
||||
|
||||
const isTrendAnalysis = analysis.analysis_type === 'trend';
|
||||
const isAutoAnalysis = (analysis.result_metadata as Record<string, unknown>)?.auto_analysis === true;
|
||||
|
||||
return (
|
||||
<View className='detail-page'>
|
||||
<View className={`detail-page ${modeClass}`}>
|
||||
<View className='detail-card'>
|
||||
<Text className='detail-type'>{TYPE_LABELS[analysis.analysis_type] || analysis.analysis_type}</Text>
|
||||
<View className='detail-meta'>
|
||||
<Text className='meta-item'>模型: {analysis.model_used}</Text>
|
||||
<Text className='meta-item'>{new Date(analysis.created_at).toLocaleString('zh-CN')}</Text>
|
||||
</View>
|
||||
{isAutoAnalysis && (
|
||||
<View className='auto-badge'>
|
||||
<Text className='auto-badge-text'>系统自动分析</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{isTrendAnalysis && (
|
||||
<View className='trend-tip-card'>
|
||||
<Text className='trend-tip-text'>
|
||||
趋势分析基于最小二乘法线性回归和 2 倍标准差异常检测。R² 越接近 1 表示趋势拟合越好。斜率为正表示上升趋势,斜率为负表示下降趋势。
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className='content-card'>
|
||||
<RichText className='report-content' nodes={htmlContent} />
|
||||
</View>
|
||||
|
||||
@@ -1,28 +1,5 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
|
||||
@mixin serif-number {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@mixin section-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
margin-bottom: 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@mixin tag($bg, $color) {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
background: $bg;
|
||||
color: $color;
|
||||
}
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
.ai-report-page {
|
||||
min-height: 100vh;
|
||||
@@ -55,7 +32,7 @@
|
||||
}
|
||||
|
||||
.card-type {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 500;
|
||||
color: $tx;
|
||||
}
|
||||
@@ -87,19 +64,19 @@
|
||||
}
|
||||
|
||||
.card-time {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.card-model {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.no-more {
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: var(--tk-text-secondary);
|
||||
padding: 24px 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import Taro from '@tarojs/taro';
|
||||
import { listAiAnalysis, type AiAnalysisItem } from '@/services/ai-analysis';
|
||||
import Loading from '@/components/Loading';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
@@ -21,6 +22,7 @@ const STATUS_MAP: Record<string, { text: string; className: string }> = {
|
||||
};
|
||||
|
||||
export default function AiReportList() {
|
||||
const modeClass = useElderClass();
|
||||
const [list, setList] = useState<AiAnalysisItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -60,14 +62,14 @@ export default function AiReportList() {
|
||||
|
||||
if (list.length === 0) {
|
||||
return (
|
||||
<View className='ai-report-page'>
|
||||
<View className={`ai-report-page ${modeClass}`}>
|
||||
<EmptyState text='暂无 AI 分析报告' />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='ai-report-page'>
|
||||
<View className={`ai-report-page ${modeClass}`}>
|
||||
<View className='page-title'>AI 分析报告</View>
|
||||
<ScrollView scrollY className='report-scroll' onScrollToLower={loadMore}>
|
||||
{list.map((item) => {
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
|
||||
.dept-initial-text {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 30px;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $pri;
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
}
|
||||
|
||||
.dept-label {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -77,7 +77,7 @@
|
||||
|
||||
.slot-section-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
margin-bottom: 16px;
|
||||
@@ -114,14 +114,14 @@
|
||||
|
||||
.slot-time {
|
||||
@include serif-number;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.slot-count {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
@@ -156,7 +156,7 @@
|
||||
|
||||
.confirm-icon-serif {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
font-weight: bold;
|
||||
color: $pri;
|
||||
}
|
||||
@@ -169,12 +169,12 @@
|
||||
}
|
||||
|
||||
.confirm-label {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.confirm-value {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
}
|
||||
@@ -185,7 +185,7 @@
|
||||
}
|
||||
|
||||
.confirm-dept-text {
|
||||
font-size: 20px;
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 500;
|
||||
color: $pri;
|
||||
}
|
||||
@@ -225,7 +225,7 @@
|
||||
|
||||
.doctor-avatar-text {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 32px;
|
||||
font-size: var(--tk-font-num);
|
||||
color: $pri;
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -238,18 +238,18 @@
|
||||
}
|
||||
|
||||
.doctor-name {
|
||||
font-size: 30px;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.doctor-title {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.doctor-specialty {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
@@ -263,7 +263,7 @@
|
||||
}
|
||||
|
||||
.doctor-check-text {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -274,7 +274,7 @@
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
margin-bottom: 12px;
|
||||
display: block;
|
||||
@@ -284,7 +284,7 @@
|
||||
background: $card;
|
||||
border-radius: $r-sm;
|
||||
padding: 24px 28px;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
@@ -298,8 +298,8 @@
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 28px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: var(--tk-text-secondary);
|
||||
}
|
||||
|
||||
/* 底部操作栏 */
|
||||
@@ -339,7 +339,7 @@
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
font-size: 30px;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { TEMPLATE_IDS } from '@/services/wechat-templates';
|
||||
import { trackEvent } from '@/services/analytics';
|
||||
import StepIndicator from '../../../components/StepIndicator';
|
||||
import WeekCalendar from '../../../components/WeekCalendar';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
const DEPARTMENTS = [
|
||||
@@ -44,6 +45,7 @@ export default function AppointmentCreate() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [schedules, setSchedules] = useState<any[]>([]);
|
||||
const [timeSlots, setTimeSlots] = useState<TimeSlot[]>([]);
|
||||
const modeClass = useElderClass();
|
||||
|
||||
const currentPatient = useAuthStore((s) => s.currentPatient);
|
||||
|
||||
@@ -148,7 +150,7 @@ export default function AppointmentCreate() {
|
||||
};
|
||||
|
||||
return (
|
||||
<View className='create-page'>
|
||||
<View className={`create-page ${modeClass}`}>
|
||||
<StepIndicator
|
||||
steps={[{ label: '选科室' }, { label: '选医生' }, { label: '选时段' }]}
|
||||
current={currentStep}
|
||||
|
||||
@@ -23,14 +23,14 @@
|
||||
}
|
||||
|
||||
.back-text {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $pri;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 34px;
|
||||
font-size: var(--tk-font-num-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
}
|
||||
@@ -80,19 +80,19 @@
|
||||
}
|
||||
|
||||
.status-tag-text {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-doctor {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 36px;
|
||||
font-size: var(--tk-font-num-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.status-dept {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
|
||||
.info-icon-serif {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 20px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $pri;
|
||||
background: $pri-l;
|
||||
width: 36px;
|
||||
@@ -143,12 +143,12 @@
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -163,8 +163,8 @@
|
||||
|
||||
.info-id {
|
||||
@include serif-number;
|
||||
font-size: 22px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-body);
|
||||
color: var(--tk-text-secondary);
|
||||
word-break: break-all;
|
||||
max-width: 400px;
|
||||
text-align: right;
|
||||
@@ -180,7 +180,7 @@
|
||||
|
||||
.tips-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $wrn;
|
||||
margin-bottom: 12px;
|
||||
@@ -188,7 +188,7 @@
|
||||
}
|
||||
|
||||
.tips-text {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -219,7 +219,7 @@
|
||||
}
|
||||
|
||||
.cancel-text {
|
||||
font-size: 30px;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $dan;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getAppointment, cancelAppointment } from '../../../services/appointment
|
||||
import type { Appointment } from '../../../services/appointment';
|
||||
import Loading from '../../../components/Loading';
|
||||
import ErrorState from '../../../components/ErrorState';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; className: string }> = {
|
||||
@@ -22,6 +23,7 @@ export default function AppointmentDetail() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const modeClass = useElderClass();
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
@@ -65,7 +67,7 @@ export default function AppointmentDetail() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View className='detail-page'>
|
||||
<View className={`detail-page ${modeClass}`}>
|
||||
<View className='detail-header'>
|
||||
<View className='back-btn' onClick={goBack}><Text className='back-text'>返回</Text></View>
|
||||
<Text className='header-title'>预约详情</Text>
|
||||
@@ -78,7 +80,7 @@ export default function AppointmentDetail() {
|
||||
|
||||
if (error || !appointment) {
|
||||
return (
|
||||
<View className='detail-page'>
|
||||
<View className={`detail-page ${modeClass}`}>
|
||||
<View className='detail-header'>
|
||||
<View className='back-btn' onClick={goBack}><Text className='back-text'>返回</Text></View>
|
||||
<Text className='header-title'>预约详情</Text>
|
||||
@@ -90,7 +92,7 @@ export default function AppointmentDetail() {
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='detail-page'>
|
||||
<View className={`detail-page ${modeClass}`}>
|
||||
<View className='detail-header'>
|
||||
<View className='back-btn' onClick={goBack}><Text className='back-text'>返回</Text></View>
|
||||
<Text className='header-title'>预约详情</Text>
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
.page-title {
|
||||
@include section-title;
|
||||
margin-bottom: 4px;
|
||||
font-size: 36px;
|
||||
font-size: var(--tk-font-num-lg);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 22px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-body);
|
||||
color: var(--tk-text-secondary);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
|
||||
.dept-initial-text {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 32px;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $pri;
|
||||
}
|
||||
@@ -80,7 +80,7 @@
|
||||
}
|
||||
|
||||
.doctor-name {
|
||||
font-size: 30px;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
overflow: hidden;
|
||||
@@ -93,7 +93,7 @@
|
||||
}
|
||||
|
||||
.dept-tag-text {
|
||||
font-size: 20px;
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 500;
|
||||
color: $pri;
|
||||
}
|
||||
@@ -124,7 +124,7 @@
|
||||
}
|
||||
|
||||
.status-tag-text {
|
||||
font-size: 20px;
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -157,12 +157,12 @@
|
||||
|
||||
.info-icon-serif {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
}
|
||||
|
||||
.fab-text {
|
||||
font-size: 30px;
|
||||
font-size: var(--tk-font-num);
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
letter-spacing: 2px;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { listAppointments } from '../../services/appointment';
|
||||
import type { Appointment } from '../../services/appointment';
|
||||
import EmptyState from '../../components/EmptyState';
|
||||
import Loading from '../../components/Loading';
|
||||
import { useElderClass } from '../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; className: string }> = {
|
||||
@@ -30,6 +31,7 @@ export default function AppointmentList() {
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const loadingRef = useRef(false);
|
||||
const modeClass = useElderClass();
|
||||
|
||||
const fetchData = useCallback(async (pageNum: number, isRefresh = false) => {
|
||||
if (loadingRef.current) return;
|
||||
@@ -86,11 +88,10 @@ export default function AppointmentList() {
|
||||
};
|
||||
|
||||
return (
|
||||
<View className='appointment-page'>
|
||||
<View className={`appointment-page ${modeClass}`}>
|
||||
{/* 页面标题 */}
|
||||
<View className='page-header'>
|
||||
<Text className='page-title'>预约挂号</Text>
|
||||
<Text className='page-subtitle'>Appointment</Text>
|
||||
</View>
|
||||
|
||||
{/* 预约列表 */}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
}
|
||||
|
||||
.article-title {
|
||||
font-size: 38px;
|
||||
font-size: var(--tk-font-hero);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
display: block;
|
||||
@@ -29,7 +29,7 @@
|
||||
}
|
||||
|
||||
.article-category {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $pri;
|
||||
background: $pri-l;
|
||||
padding: 4px 12px;
|
||||
@@ -37,13 +37,13 @@
|
||||
}
|
||||
|
||||
.article-author {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.article-date {
|
||||
font-size: 24px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: var(--tk-text-secondary);
|
||||
}
|
||||
|
||||
.article-summary {
|
||||
@@ -53,7 +53,7 @@
|
||||
}
|
||||
|
||||
.summary-text {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -70,7 +70,7 @@
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
line-height: 1.8;
|
||||
margin-bottom: 16px;
|
||||
@@ -93,6 +93,6 @@
|
||||
|
||||
.loading-text,
|
||||
.empty-text {
|
||||
font-size: 28px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: var(--tk-text-secondary);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ import { View, Text, RichText } from '@tarojs/components';
|
||||
import Taro, { useRouter, useShareAppMessage } from '@tarojs/taro';
|
||||
import { getArticleDetail, Article } from '../../../services/article';
|
||||
import { trackEvent } from '@/services/analytics';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
export default function ArticleDetail() {
|
||||
const modeClass = useElderClass();
|
||||
const router = useRouter();
|
||||
const id = router.params.id || '';
|
||||
|
||||
@@ -31,7 +33,7 @@ export default function ArticleDetail() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View className='article-detail-page'>
|
||||
<View className={`article-detail-page ${modeClass}`}>
|
||||
<View className='loading-state'>
|
||||
<Text className='loading-text'>加载中...</Text>
|
||||
</View>
|
||||
@@ -41,7 +43,7 @@ export default function ArticleDetail() {
|
||||
|
||||
if (!article) {
|
||||
return (
|
||||
<View className='article-detail-page'>
|
||||
<View className={`article-detail-page ${modeClass}`}>
|
||||
<View className='empty-state'>
|
||||
<Text className='empty-text'>文章不存在</Text>
|
||||
</View>
|
||||
@@ -50,7 +52,7 @@ export default function ArticleDetail() {
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='article-detail-page'>
|
||||
<View className={`article-detail-page ${modeClass}`}>
|
||||
{/* 文章头部 */}
|
||||
<View className='article-header'>
|
||||
<Text className='article-title'>{article.title}</Text>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
display: inline-block;
|
||||
padding: 12px 28px;
|
||||
margin-right: 12px;
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
background: $card;
|
||||
border-radius: 32px;
|
||||
@@ -52,7 +52,7 @@
|
||||
}
|
||||
|
||||
.article-card-title {
|
||||
font-size: 30px;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
line-height: 1.4;
|
||||
@@ -66,7 +66,7 @@
|
||||
}
|
||||
|
||||
.article-card-summary {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
line-height: 1.4;
|
||||
display: block;
|
||||
@@ -83,7 +83,7 @@
|
||||
}
|
||||
|
||||
.article-card-tag {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $pri;
|
||||
background: $pri-l;
|
||||
padding: 2px 12px;
|
||||
@@ -91,7 +91,7 @@
|
||||
}
|
||||
|
||||
.article-card-date {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
@@ -116,8 +116,8 @@
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 28px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: var(--tk-text-secondary);
|
||||
}
|
||||
|
||||
.loading-hint {
|
||||
@@ -126,6 +126,6 @@
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 24px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: var(--tk-text-secondary);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ import Taro, { useDidShow, usePullDownRefresh, useReachBottom } from '@tarojs/ta
|
||||
import { listArticles, listCategories, Article, ArticleCategory } from '../../services/article';
|
||||
import EmptyState from '../../components/EmptyState';
|
||||
import Loading from '../../components/Loading';
|
||||
import { useElderClass } from '../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
export default function ArticleList() {
|
||||
const modeClass = useElderClass();
|
||||
const [articles, setArticles] = useState<Article[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -72,7 +74,7 @@ export default function ArticleList() {
|
||||
};
|
||||
|
||||
return (
|
||||
<View className='article-page'>
|
||||
<View className={`article-page ${modeClass}`}>
|
||||
{/* 分类筛选 */}
|
||||
{categories.length > 0 && (
|
||||
<ScrollView scrollX className='article-categories'>
|
||||
|
||||
@@ -8,61 +8,108 @@
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
/* ─── 导航栏 ─── */
|
||||
.chat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px 32px;
|
||||
padding: 12px 16px;
|
||||
background: $card;
|
||||
border-bottom: 1px solid $bd;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 30px;
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
.chat-header__back {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
z-index: 1;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
&__status {
|
||||
font-size: 24px;
|
||||
.chat-header__back-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
.chat-header__center {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chat-header__title {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.chat-header__status {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $acc;
|
||||
margin-top: 2px;
|
||||
|
||||
&--closed {
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 消息区 ─── */
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
padding: 16px 16px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.msg-row {
|
||||
display: flex;
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 16px;
|
||||
gap: 8px;
|
||||
|
||||
&--self {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 医生头像 ─── */
|
||||
.msg-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 16px;
|
||||
background: $pri-l;
|
||||
@include flex-center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.msg-avatar-char {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-cap);
|
||||
font-weight: 700;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
/* ─── 消息气泡 ─── */
|
||||
.msg-bubble {
|
||||
max-width: 70%;
|
||||
padding: 20px 24px;
|
||||
border-radius: $r-lg;
|
||||
position: relative;
|
||||
padding: 12px 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&--other {
|
||||
background: $card;
|
||||
border-top-left-radius: $r-sm;
|
||||
border-radius: $r $r $r $r-xs;
|
||||
}
|
||||
|
||||
&--self {
|
||||
background: $pri;
|
||||
border-top-right-radius: $r-sm;
|
||||
border-radius: $r $r $r-xs $r;
|
||||
}
|
||||
}
|
||||
|
||||
.msg-text {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx;
|
||||
display: block;
|
||||
line-height: 1.6;
|
||||
@@ -76,88 +123,95 @@
|
||||
.msg-date-divider {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px 0 12px;
|
||||
padding: 12px 0;
|
||||
|
||||
&__text {
|
||||
font-size: 22px;
|
||||
color: #94A3B8;
|
||||
background: #F1F5F9;
|
||||
padding: 4px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: var(--tk-font-micro);
|
||||
color: var(--tk-text-secondary);
|
||||
background: $surface-alt;
|
||||
padding: 2px 12px;
|
||||
border-radius: $r-pill;
|
||||
}
|
||||
}
|
||||
|
||||
.msg-image {
|
||||
width: 320px;
|
||||
border-radius: 12px;
|
||||
width: 200px;
|
||||
border-radius: $r-sm;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.msg-time {
|
||||
font-size: 20px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-micro);
|
||||
color: var(--tk-text-secondary);
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
text-align: right;
|
||||
margin-top: 4px;
|
||||
|
||||
.msg-bubble--self & {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-empty {
|
||||
text-align: center;
|
||||
padding: 120px 32px;
|
||||
padding: 80px 24px;
|
||||
|
||||
&__text {
|
||||
font-size: 26px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-cap);
|
||||
color: var(--tk-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 输入栏 ─── */
|
||||
.chat-input-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
gap: 10px;
|
||||
padding: 10px 16px 38px;
|
||||
background: $card;
|
||||
border-top: 1px solid $bd;
|
||||
padding-bottom: calc(16px + env(safe-area-inset-bottom));
|
||||
border-top: 1px solid $bd-l;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
height: 40px;
|
||||
background: $bg;
|
||||
border-radius: $r;
|
||||
padding: 16px 20px;
|
||||
font-size: 28px;
|
||||
margin-right: 16px;
|
||||
border: 1.5px solid $bd;
|
||||
border-radius: 20px;
|
||||
padding: 0 14px;
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.chat-send-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 20px;
|
||||
background: $pri;
|
||||
border-radius: $r;
|
||||
padding: 16px 28px;
|
||||
@include flex-center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 6px rgba(196, 98, 58, 0.3);
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: 28px;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
.chat-send-btn__icon {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chat-closed-bar {
|
||||
padding: 24px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
background: $card;
|
||||
border-top: 1px solid $bd;
|
||||
border-top: 1px solid $bd-l;
|
||||
|
||||
&__text {
|
||||
font-size: 26px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-cap);
|
||||
color: var(--tk-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,14 @@ import {
|
||||
listMessages,
|
||||
sendMessage,
|
||||
markSessionRead,
|
||||
pollMessages,
|
||||
type ConsultationSession,
|
||||
type ConsultationMessage,
|
||||
} from '@/services/consultation';
|
||||
import Loading from '@/components/Loading';
|
||||
import { useElderClass } from '@/hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
const POLL_INTERVAL = 8000;
|
||||
|
||||
export default function ConsultationDetail() {
|
||||
const router = useRouter();
|
||||
const sessionId = router.params.id || '';
|
||||
@@ -23,43 +23,35 @@ export default function ConsultationDetail() {
|
||||
const [sending, setSending] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const scrollViewRef = useRef('');
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const pollingRef = useRef(false);
|
||||
const modeClass = useElderClass();
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionId) {
|
||||
loadData();
|
||||
markRead();
|
||||
startPolling();
|
||||
startLongPolling();
|
||||
}
|
||||
return () => stopPolling();
|
||||
return () => { pollingRef.current = false; };
|
||||
}, [sessionId]);
|
||||
|
||||
const startPolling = () => {
|
||||
stopPolling();
|
||||
pollTimerRef.current = setInterval(pollNewMessages, POLL_INTERVAL);
|
||||
useEffect(() => {
|
||||
if (session?.status === 'closed') {
|
||||
pollingRef.current = false;
|
||||
}
|
||||
}, [session?.status]);
|
||||
|
||||
const startLongPolling = () => {
|
||||
pollingRef.current = true;
|
||||
longPoll();
|
||||
};
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollTimerRef.current) {
|
||||
clearInterval(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const pollNewMessages = async () => {
|
||||
if (!session || session.status === 'closed') {
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
const longPoll = async () => {
|
||||
if (!pollingRef.current) return;
|
||||
try {
|
||||
const lastId = messages.length > 0 ? messages[messages.length - 1].id : undefined;
|
||||
const m = await listMessages(sessionId, {
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
after_id: lastId,
|
||||
});
|
||||
const newMsgs = m.data || [];
|
||||
if (newMsgs.length > 0) {
|
||||
const newMsgs = await pollMessages(sessionId, lastId);
|
||||
if (newMsgs && newMsgs.length > 0) {
|
||||
setMessages((prev) => {
|
||||
const existing = new Set(prev.map((msg) => msg.id));
|
||||
const fresh = newMsgs.filter((msg) => !existing.has(msg.id));
|
||||
@@ -67,7 +59,12 @@ export default function ConsultationDetail() {
|
||||
});
|
||||
scrollViewRef.current = `msg-${messages.length + newMsgs.length}`;
|
||||
}
|
||||
} catch { /* 轮询失败静默忽略 */ }
|
||||
} catch {
|
||||
// 超时或网络错误,静默重试
|
||||
}
|
||||
if (pollingRef.current) {
|
||||
longPoll();
|
||||
}
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
@@ -80,7 +77,7 @@ export default function ConsultationDetail() {
|
||||
setSession(s);
|
||||
setMessages(m.data || []);
|
||||
scrollViewRef.current = `msg-${(m.data || []).length}`;
|
||||
if (s.status === 'closed') stopPolling();
|
||||
if (s.status === 'closed') pollingRef.current = false;
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
@@ -137,14 +134,24 @@ export default function ConsultationDetail() {
|
||||
if (loading) return <Loading />;
|
||||
|
||||
const isOpen = session?.status !== 'closed';
|
||||
const doctorInitial = (session?.subject || '医').charAt(0);
|
||||
const statusLabel = session?.status === 'active' ? '进行中'
|
||||
: session?.status === 'pending' ? '等待接诊'
|
||||
: '已结束';
|
||||
|
||||
return (
|
||||
<View className='chat-page'>
|
||||
<View className={`chat-page ${modeClass}`}>
|
||||
{/* 导航栏 — 对齐设计稿:返回 + 标题 + 副标题 */}
|
||||
<View className='chat-header'>
|
||||
<Text className='chat-header__title'>{session?.subject || '在线咨询'}</Text>
|
||||
{!isOpen && (
|
||||
<Text className='chat-header__status'>已结束</Text>
|
||||
)}
|
||||
<View className='chat-header__back' onClick={() => Taro.navigateBack()}>
|
||||
<Text className='chat-header__back-text'>‹ 返回</Text>
|
||||
</View>
|
||||
<View className='chat-header__center'>
|
||||
<Text className='chat-header__title'>{session?.subject || '在线咨询'}</Text>
|
||||
<Text className={`chat-header__status ${isOpen ? '' : 'chat-header__status--closed'}`}>
|
||||
{statusLabel}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
@@ -164,6 +171,11 @@ export default function ConsultationDetail() {
|
||||
</View>
|
||||
)}
|
||||
<View id={`msg-${idx + 1}`} className={`msg-row ${isSelf ? 'msg-row--self' : ''}`}>
|
||||
{!isSelf && (
|
||||
<View className='msg-avatar'>
|
||||
<Text className='msg-avatar-char'>{doctorInitial}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className={`msg-bubble ${isSelf ? 'msg-bubble--self' : 'msg-bubble--other'}`}>
|
||||
{isImageUrl(msg.content) ? (
|
||||
<Image
|
||||
@@ -203,7 +215,7 @@ export default function ConsultationDetail() {
|
||||
className={`chat-send-btn ${(!inputText.trim() || sending) ? 'chat-send-btn--disabled' : ''}`}
|
||||
onClick={handleSend}
|
||||
>
|
||||
<Text className='chat-send-btn__text'>{sending ? '...' : '发送'}</Text>
|
||||
<Text className='chat-send-btn__icon'>发</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
|
||||
@@ -6,26 +6,36 @@
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
/* ─── 页头 ─── */
|
||||
.consultation-header {
|
||||
background: linear-gradient(135deg, $pri 0%, $pri-d 100%);
|
||||
padding: 48px 32px 36px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.consultation-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 40px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
.consultation-body {
|
||||
padding: 12px 24px 24px;
|
||||
}
|
||||
|
||||
/* ─── 副标题 ─── */
|
||||
.consultation-subtitle {
|
||||
font-size: 24px;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
font-size: var(--tk-font-cap);
|
||||
color: var(--tk-text-secondary);
|
||||
display: block;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* ─── 发起咨询按钮 — 实心主色 ─── */
|
||||
.consultation-create-btn {
|
||||
height: 48px;
|
||||
border-radius: $r;
|
||||
background: $pri;
|
||||
@include flex-center;
|
||||
box-shadow: 0 2px 8px rgba(196, 98, 58, 0.25);
|
||||
margin-bottom: 20px;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.consultation-create-btn-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ─── 居中容器 ─── */
|
||||
@@ -37,7 +47,7 @@
|
||||
}
|
||||
|
||||
.consultation-error {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $dan;
|
||||
}
|
||||
|
||||
@@ -47,51 +57,52 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 160px 40px;
|
||||
padding: 120px 40px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: $pri-l;
|
||||
@include flex-center;
|
||||
margin-bottom: 32px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.empty-char {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 52px;
|
||||
font-weight: bold;
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: 700;
|
||||
color: $pri;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 32px;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 26px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-cap);
|
||||
color: var(--tk-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ─── 会话列表 ─── */
|
||||
.session-list {
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.session-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
margin-bottom: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&:active {
|
||||
@@ -99,7 +110,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
.session-main {
|
||||
.session-card-closed {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.session-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 18px;
|
||||
background: $pri-l;
|
||||
@include flex-center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-avatar-char {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 700;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
.session-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -108,56 +139,73 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.session-subject {
|
||||
font-size: 28px;
|
||||
color: $tx;
|
||||
font-size: var(--tk-font-cap);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.session-tag {
|
||||
&.tag-ok { @include tag($acc-l, $acc); }
|
||||
&.tag-warn { @include tag($wrn-l, $wrn); }
|
||||
&.tag-default { @include tag($bd-l, $tx2); }
|
||||
}
|
||||
|
||||
.session-message {
|
||||
font-size: 26px;
|
||||
color: $tx2;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 8px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.session-time {
|
||||
font-size: 22px;
|
||||
color: $tx3;
|
||||
display: block;
|
||||
font-size: var(--tk-font-micro);
|
||||
color: var(--tk-text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-tag {
|
||||
font-size: var(--tk-font-micro);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
display: inline-block;
|
||||
|
||||
&.tag-ok { background: $acc-l; color: $acc; }
|
||||
&.tag-warn { background: $wrn-l; color: $wrn; }
|
||||
&.tag-default { background: $surface-alt; color: $tx3; }
|
||||
}
|
||||
|
||||
.session-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.session-message-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.session-message {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* ─── 未读角标 ─── */
|
||||
.session-badge {
|
||||
background: $dan;
|
||||
border-radius: $r-pill;
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
@include flex-center;
|
||||
padding: 0 10px;
|
||||
margin-left: 12px;
|
||||
padding: 0 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-badge-text {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-micro);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
|
||||
import Taro, { useDidShow, usePullDownRefresh, useReachBottom } from '@tarojs/taro';
|
||||
import { listConsultations, ConsultationSession } from '@/services/consultation';
|
||||
import Loading from '../../components/Loading';
|
||||
import { useElderClass } from '../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
function getStatusTag(status: string) {
|
||||
@@ -33,99 +34,137 @@ export default function Consultation() {
|
||||
const [sessions, setSessions] = useState<ConsultationSession[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const modeClass = useElderClass();
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
const loadSessions = async () => {
|
||||
setLoading(true);
|
||||
const loadSessions = async (pageNum: number, isRefresh = false) => {
|
||||
if (loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
if (isRefresh) setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const resp = await listConsultations({ page: 1, page_size: 20 });
|
||||
setSessions(resp.data || []);
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '加载失败';
|
||||
setError(msg);
|
||||
const resp = await listConsultations({ page: pageNum, page_size: 20 });
|
||||
const list = resp.data || [];
|
||||
if (isRefresh) {
|
||||
setSessions(list);
|
||||
} else {
|
||||
setSessions((prev) => [...prev, ...list]);
|
||||
}
|
||||
setTotal(resp.total || 0);
|
||||
setPage(pageNum);
|
||||
} catch {
|
||||
if (isRefresh) {
|
||||
setSessions([]);
|
||||
setTotal(0);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
loadingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
useDidShow(() => {
|
||||
Taro.setNavigationBarTitle({ title: '在线咨询' });
|
||||
loadSessions();
|
||||
loadSessions(1, true);
|
||||
});
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
loadSessions().finally(() => {
|
||||
loadSessions(1, true).finally(() => {
|
||||
Taro.stopPullDownRefresh();
|
||||
});
|
||||
});
|
||||
|
||||
useReachBottom(() => {
|
||||
if (!loading && sessions.length < total) {
|
||||
loadSessions(page + 1);
|
||||
}
|
||||
});
|
||||
|
||||
const handleTapSession = (session: ConsultationSession) => {
|
||||
Taro.navigateTo({ url: `/pages/consultation/detail/index?id=${session.id}` });
|
||||
};
|
||||
|
||||
return (
|
||||
<View className='consultation-page'>
|
||||
{/* 页头 */}
|
||||
<View className='consultation-header'>
|
||||
<Text className='consultation-title'>在线咨询</Text>
|
||||
<View className={`consultation-page ${modeClass}`}>
|
||||
<View className='consultation-body'>
|
||||
{/* 副标题 */}
|
||||
<Text className='consultation-subtitle'>随时随地,连接专业医生</Text>
|
||||
</View>
|
||||
|
||||
{/* 内容区 */}
|
||||
{loading ? (
|
||||
<View className='consultation-center'>
|
||||
<Loading text='加载中...' />
|
||||
{/* 发起咨询按钮 — 实心主色 */}
|
||||
<View
|
||||
className='consultation-create-btn'
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/consultation/create/index' })}
|
||||
>
|
||||
<Text className='consultation-create-btn-text'>发起咨询</Text>
|
||||
</View>
|
||||
) : error ? (
|
||||
<View className='consultation-center'>
|
||||
<Text className='consultation-error'>{error}</Text>
|
||||
</View>
|
||||
) : sessions.length === 0 ? (
|
||||
<View className='consultation-empty'>
|
||||
<View className='empty-icon'>
|
||||
<Text className='empty-char'>问</Text>
|
||||
|
||||
{/* 内容区 */}
|
||||
{loading ? (
|
||||
<View className='consultation-center'>
|
||||
<Loading text='加载中...' />
|
||||
</View>
|
||||
<Text className='empty-title'>暂无咨询记录</Text>
|
||||
<Text className='empty-hint'>发起咨询后即可在这里与医生交流</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className='session-list'>
|
||||
{sessions.map((session) => {
|
||||
const tag = getStatusTag(session.status);
|
||||
return (
|
||||
<View
|
||||
key={session.id}
|
||||
className='session-card'
|
||||
onClick={() => handleTapSession(session)}
|
||||
>
|
||||
<View className='session-main'>
|
||||
<View className='session-top'>
|
||||
<Text className='session-subject'>
|
||||
{session.subject || '在线咨询'}
|
||||
</Text>
|
||||
<Text className={`session-tag ${tag.cls}`}>{tag.label}</Text>
|
||||
) : error ? (
|
||||
<View className='consultation-center'>
|
||||
<Text className='consultation-error'>{error}</Text>
|
||||
</View>
|
||||
) : sessions.length === 0 ? (
|
||||
<View className='consultation-empty'>
|
||||
<View className='empty-icon'>
|
||||
<Text className='empty-char'>问</Text>
|
||||
</View>
|
||||
<Text className='empty-title'>暂无咨询记录</Text>
|
||||
<Text className='empty-hint'>发起咨询后即可在这里与医生交流</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className='session-list'>
|
||||
{sessions.map((session) => {
|
||||
const tag = getStatusTag(session.status);
|
||||
const initial = (session.subject || '咨').charAt(0);
|
||||
const isClosed = session.status === 'closed' || session.status === 'cancelled';
|
||||
return (
|
||||
<View
|
||||
key={session.id}
|
||||
className={`session-card ${isClosed ? 'session-card-closed' : ''}`}
|
||||
onClick={() => handleTapSession(session)}
|
||||
>
|
||||
<View className='session-avatar'>
|
||||
<Text className='session-avatar-char'>{initial}</Text>
|
||||
</View>
|
||||
<View className='session-body'>
|
||||
<View className='session-top'>
|
||||
<Text className='session-subject'>
|
||||
{session.subject || '在线咨询'}
|
||||
</Text>
|
||||
<Text className='session-time'>
|
||||
{session.last_message_at
|
||||
? formatTime(session.last_message_at)
|
||||
: formatTime(session.created_at)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='session-meta'>
|
||||
<Text className={`session-tag ${tag.cls}`}>{tag.label}</Text>
|
||||
</View>
|
||||
<View className='session-message-row'>
|
||||
<Text className='session-message'>
|
||||
{session.last_message || '暂无消息'}
|
||||
</Text>
|
||||
{session.unread_count_patient > 0 && (
|
||||
<View className='session-badge'>
|
||||
<Text className='session-badge-text'>
|
||||
{session.unread_count_patient > 99 ? '99+' : session.unread_count_patient}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<Text className='session-message'>
|
||||
{session.last_message || '暂无消息'}
|
||||
</Text>
|
||||
<Text className='session-time'>
|
||||
{session.last_message_at
|
||||
? formatTime(session.last_message_at)
|
||||
: formatTime(session.created_at)}
|
||||
</Text>
|
||||
</View>
|
||||
{session.unread_count_patient > 0 && (
|
||||
<View className='session-badge'>
|
||||
<Text className='session-badge-text'>
|
||||
{session.unread_count_patient > 99 ? '99+' : session.unread_count_patient}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,5 @@
|
||||
@import '../../styles/variables.scss';
|
||||
|
||||
@mixin serif-number {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@mixin section-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
margin-bottom: 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@mixin tag($bg, $color) {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
background: $bg;
|
||||
color: $color;
|
||||
}
|
||||
|
||||
@mixin flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@import '../../styles/mixins.scss';
|
||||
|
||||
.device-sync-page {
|
||||
min-height: 100vh;
|
||||
@@ -43,9 +14,8 @@
|
||||
}
|
||||
|
||||
.sync-header-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 40px;
|
||||
font-weight: bold;
|
||||
@include section-title;
|
||||
color: $card;
|
||||
}
|
||||
|
||||
.sync-section {
|
||||
@@ -72,20 +42,17 @@
|
||||
margin-bottom: 20px;
|
||||
color: $pri;
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 36px;
|
||||
font-size: var(--tk-font-num-lg);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.sync-hero-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 34px;
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
@include section-title;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sync-hero-desc {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
@@ -110,7 +77,7 @@
|
||||
|
||||
.sync-action-text {
|
||||
color: $card;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -120,7 +87,7 @@
|
||||
|
||||
.sync-section-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
margin-bottom: 12px;
|
||||
@@ -144,19 +111,19 @@
|
||||
}
|
||||
|
||||
.sync-device-name {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 500;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.sync-device-adapter {
|
||||
font-size: 22px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-body);
|
||||
color: var(--tk-text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.sync-device-rssi {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
@@ -183,7 +150,7 @@
|
||||
}
|
||||
|
||||
.sync-status-text {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
@@ -208,12 +175,12 @@
|
||||
}
|
||||
|
||||
.sync-reading-type {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.sync-reading-value {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $pri;
|
||||
@include serif-number;
|
||||
@@ -222,8 +189,8 @@
|
||||
.sync-readings-count {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
font-size: 22px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-body);
|
||||
color: var(--tk-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -240,7 +207,7 @@
|
||||
}
|
||||
|
||||
.sync-error-text {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $dan;
|
||||
}
|
||||
|
||||
@@ -250,7 +217,7 @@
|
||||
}
|
||||
|
||||
.sync-loading-text {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
@@ -273,20 +240,17 @@
|
||||
@include flex-center;
|
||||
color: $acc;
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 36px;
|
||||
font-size: var(--tk-font-num-lg);
|
||||
font-weight: bold;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.sync-result-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 34px;
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
@include section-title;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sync-result-count {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
@@ -1,26 +1,43 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import { useDidShow } from '@tarojs/taro';
|
||||
import Taro, { useDidShow, useRouter } from '@tarojs/taro';
|
||||
import { BLEManager } from '@/services/ble/BLEManager';
|
||||
import { XiaomiBandAdapter } from '@/services/ble/adapters/XiaomiBandAdapter';
|
||||
import { BloodPressureAdapter } from '@/services/ble/adapters/BloodPressureAdapter';
|
||||
import { GlucoseMeterAdapter } from '@/services/ble/adapters/GlucoseMeterAdapter';
|
||||
import { CustomBandAdapter } from '@/services/ble/adapters/GenericBleAdapter';
|
||||
import { DataSyncScheduler } from '@/services/ble/DataSyncScheduler';
|
||||
import { uploadReadings } from '@/services/device-sync';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import type { BLEDevice, NormalizedReading } from '@/services/ble/types';
|
||||
import { useElderClass } from '../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
const bleManager = new BLEManager({ scanTimeout: 10000, retryCount: 3 });
|
||||
bleManager.registerAdapter(XiaomiBandAdapter);
|
||||
bleManager.registerAdapter(BloodPressureAdapter);
|
||||
bleManager.registerAdapter(GlucoseMeterAdapter);
|
||||
bleManager.registerAdapter(CustomBandAdapter);
|
||||
|
||||
type PageState = 'idle' | 'scanning' | 'connecting' | 'connected' | 'syncing' | 'done' | 'error';
|
||||
|
||||
export default function DeviceSync() {
|
||||
const modeClass = useElderClass();
|
||||
const { currentPatient } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const returnTo = router.params.returnTo || '';
|
||||
const [pageState, setPageState] = useState<PageState>('idle');
|
||||
const [devices, setDevices] = useState<BLEDevice[]>([]);
|
||||
const [selectedDevice, setSelectedDevice] = useState<BLEDevice | null>(null);
|
||||
const [liveReadings, setLiveReadings] = useState<NormalizedReading[]>([]);
|
||||
const [syncCount, setSyncCount] = useState(0);
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
const [lastSyncAt, setLastSyncAt] = useState<number | null>(null);
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
|
||||
const scheduler = useMemo(() => new DataSyncScheduler({
|
||||
intervalMs: 60 * 60 * 1000,
|
||||
}), []);
|
||||
|
||||
useDidShow(() => {
|
||||
bleManager.setOnConnectionChange(() => {});
|
||||
@@ -28,7 +45,29 @@ export default function DeviceSync() {
|
||||
setLiveReadings((prev) => [...prev, ...readings]);
|
||||
});
|
||||
|
||||
// 显示上次同步时间
|
||||
setLastSyncAt(scheduler.getLastSyncAt());
|
||||
|
||||
// 检查是否有未上传的缓冲数据
|
||||
const buffer = (bleManager as any).dataBuffer;
|
||||
if (buffer) {
|
||||
setPendingCount(buffer.size());
|
||||
}
|
||||
|
||||
// 自动同步:超过间隔时尝试上传缓冲数据
|
||||
if (currentPatient && scheduler.needsSync()) {
|
||||
scheduler.tryAutoSync(async () => {
|
||||
const count = await bleManager.flushPendingReadings(async (readings) => {
|
||||
return uploadReadings(currentPatient.id, 'buffered', undefined, readings);
|
||||
});
|
||||
setLastSyncAt(Date.now());
|
||||
setPendingCount(0);
|
||||
return { success: count > 0, uploadedCount: count };
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
scheduler.destroy();
|
||||
bleManager.destroy();
|
||||
};
|
||||
});
|
||||
@@ -41,7 +80,7 @@ export default function DeviceSync() {
|
||||
const found = await bleManager.scanDevices();
|
||||
setDevices(found);
|
||||
if (found.length === 0) {
|
||||
setErrorMsg('未发现支持的设备,请确认手环已开启蓝牙并靠近手机');
|
||||
setErrorMsg('未发现支持的设备,请确认设备已开启蓝牙并靠近手机');
|
||||
}
|
||||
setPageState('idle');
|
||||
} catch (e: any) {
|
||||
@@ -81,7 +120,29 @@ export default function DeviceSync() {
|
||||
|
||||
if (result.success) {
|
||||
setSyncCount(result.uploadedCount);
|
||||
setLastSyncAt(Date.now());
|
||||
setPageState('done');
|
||||
|
||||
// 如果从体征录入页跳转而来,将最新读数写入 storage 供回填
|
||||
if (returnTo === 'input' && liveReadings.length > 0) {
|
||||
const mapped: Record<string, number> = {};
|
||||
for (const r of liveReadings) {
|
||||
if (r.device_type === 'blood_pressure') {
|
||||
if (r.metric === 'systolic' && typeof r.values.value === 'number') mapped.systolic = r.values.value;
|
||||
if (r.metric === 'diastolic' && typeof r.values.value === 'number') mapped.diastolic = r.values.value;
|
||||
// 兼容 values 中直接包含 systolic/diastolic 的格式
|
||||
if (typeof r.values.systolic === 'number') mapped.systolic = r.values.systolic as number;
|
||||
if (typeof r.values.diastolic === 'number') mapped.diastolic = r.values.diastolic as number;
|
||||
} else if (r.device_type === 'blood_glucose' && typeof r.values.blood_glucose === 'number') {
|
||||
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) {
|
||||
Taro.setStorageSync('device_sync_result', JSON.stringify(mapped));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setErrorMsg(result.error || '同步失败');
|
||||
setPageState('error');
|
||||
@@ -90,7 +151,7 @@ export default function DeviceSync() {
|
||||
setErrorMsg(e.message || '同步失败');
|
||||
setPageState('error');
|
||||
}
|
||||
}, [currentPatient, selectedDevice]);
|
||||
}, [currentPatient, selectedDevice, liveReadings, returnTo]);
|
||||
|
||||
const handleDisconnect = useCallback(async () => {
|
||||
await bleManager.disconnect();
|
||||
@@ -106,9 +167,24 @@ export default function DeviceSync() {
|
||||
<View className="sync-hero">
|
||||
<Text className="sync-hero-icon">D</Text>
|
||||
<Text className="sync-hero-title">设备同步</Text>
|
||||
<Text className="sync-hero-desc">连接智能手环,自动采集健康数据</Text>
|
||||
<Text className="sync-hero-desc">连接智能手环、血压计、血糖仪,自动采集健康数据</Text>
|
||||
</View>
|
||||
|
||||
{(lastSyncAt || pendingCount > 0) && (
|
||||
<View className="sync-status-info">
|
||||
{lastSyncAt && (
|
||||
<Text className="sync-status-time">
|
||||
上次同步: {new Date(lastSyncAt).toLocaleTimeString()}
|
||||
</Text>
|
||||
)}
|
||||
{pendingCount > 0 && (
|
||||
<Text className="sync-status-pending">
|
||||
{pendingCount} 条数据待上传
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className="sync-action" onClick={handleScan}>
|
||||
<Text className="sync-action-text">扫描设备</Text>
|
||||
</View>
|
||||
@@ -147,12 +223,17 @@ export default function DeviceSync() {
|
||||
{liveReadings.slice(-5).reverse().map((r, i) => (
|
||||
<View key={i} className="sync-reading-item">
|
||||
<Text className="sync-reading-type">
|
||||
{r.device_type === 'heart_rate' ? '心率' : r.device_type}
|
||||
{r.device_type === 'heart_rate' ? '心率'
|
||||
: r.device_type === 'blood_pressure' ? `血压(${r.metric === 'systolic' ? '收缩压' : r.metric === 'diastolic' ? '舒张压' : 'MAP'})`
|
||||
: r.device_type === 'blood_glucose' ? '血糖'
|
||||
: r.device_type}
|
||||
</Text>
|
||||
<Text className="sync-reading-value">
|
||||
{r.device_type === 'heart_rate'
|
||||
? `${r.values.heart_rate} bpm`
|
||||
: JSON.stringify(r.values)}
|
||||
: r.metric
|
||||
? `${r.values.value} ${r.values.unit}`
|
||||
: JSON.stringify(r.values)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
@@ -180,14 +261,19 @@ export default function DeviceSync() {
|
||||
<Text className="sync-result-title">同步完成</Text>
|
||||
<Text className="sync-result-count">成功上传 {syncCount} 条数据</Text>
|
||||
</View>
|
||||
<View className="sync-action" onClick={handleDisconnect}>
|
||||
<Text className="sync-action-text">完成</Text>
|
||||
<View className="sync-action" onClick={() => {
|
||||
handleDisconnect();
|
||||
if (returnTo === 'input') {
|
||||
Taro.navigateBack();
|
||||
}
|
||||
}}>
|
||||
<Text className="sync-action-text">{returnTo === 'input' ? '返回录入' : '完成'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="device-sync-page">
|
||||
<View className={`device-sync-page ${modeClass}`}>
|
||||
<View className="sync-header">
|
||||
<Text className="sync-header-title">设备同步</Text>
|
||||
</View>
|
||||
|
||||
189
apps/miniprogram/src/pages/doctor/action-inbox/index.scss
Normal file
189
apps/miniprogram/src/pages/doctor/action-inbox/index.scss
Normal file
@@ -0,0 +1,189 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
.action-inbox-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.inbox-tabs {
|
||||
display: flex;
|
||||
background: $card;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid $bd;
|
||||
|
||||
.inbox-tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 12px 0;
|
||||
|
||||
&.active {
|
||||
.inbox-tab-text {
|
||||
color: $pri;
|
||||
font-weight: 600;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -12px;
|
||||
left: 30%;
|
||||
right: 30%;
|
||||
height: 3px;
|
||||
background: $pri;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.inbox-tab-text {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
}
|
||||
}
|
||||
|
||||
.inbox-list {
|
||||
height: calc(100vh - 50px);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.inbox-card {
|
||||
background: $card;
|
||||
border-radius: $r-sm;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 10px;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
.inbox-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.inbox-type-tag {
|
||||
color: $card;
|
||||
font-size: var(--tk-font-micro);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.inbox-card-title {
|
||||
font-size: var(--tk-font-cap);
|
||||
font-weight: 500;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.inbox-card-desc {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.inbox-empty {
|
||||
text-align: center;
|
||||
padding: 80px 0;
|
||||
|
||||
.inbox-empty-text {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.half-screen-dialog {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 70vh;
|
||||
background: $card;
|
||||
border-radius: $r-lg $r-lg 0 0;
|
||||
z-index: 1000;
|
||||
overflow-y: auto;
|
||||
box-shadow: $shadow-lg;
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
.dialog-title {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.dialog-close {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.dialog-patient {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.thread-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.thread-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-top: 4px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.completed { background: $acc; }
|
||||
&.in_progress { background: $wrn; }
|
||||
&.pending { background: $tx3; }
|
||||
&.dismissed { background: $dan; }
|
||||
}
|
||||
|
||||
.thread-content {
|
||||
.thread-label {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.thread-time {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px 20px 20px;
|
||||
border-top: 1px solid $bd-l;
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
border-radius: $r-sm;
|
||||
font-size: var(--tk-font-cap);
|
||||
font-weight: 500;
|
||||
|
||||
&.primary { background: $pri; color: $card; }
|
||||
&.danger { background: $dan; color: $card; }
|
||||
&.default { background: $surface-alt; color: $tx2; }
|
||||
}
|
||||
}
|
||||
}
|
||||
231
apps/miniprogram/src/pages/doctor/action-inbox/index.tsx
Normal file
231
apps/miniprogram/src/pages/doctor/action-inbox/index.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
|
||||
import {
|
||||
listActionItems,
|
||||
getActionThread,
|
||||
type ActionItem,
|
||||
type ThreadResponse,
|
||||
} from '@/services/action-inbox';
|
||||
import Loading from '@/components/Loading';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag';
|
||||
import './index.scss';
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
ai_suggestion: 'AI建议',
|
||||
alert: '告警',
|
||||
followup: '随访',
|
||||
data_anomaly: '异常',
|
||||
};
|
||||
|
||||
const TYPE_COLOR: Record<string, string> = {
|
||||
ai_suggestion: '#722ed1',
|
||||
alert: '#f5222d',
|
||||
followup: '#1890ff',
|
||||
data_anomaly: '#fa8c16',
|
||||
};
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'pending', label: '待处理' },
|
||||
{ key: 'in_progress', label: '进行中' },
|
||||
{ key: 'completed', label: '已完成' },
|
||||
];
|
||||
|
||||
export default function ActionInboxPage() {
|
||||
const modeClass = useElderClass();
|
||||
const [items, setItems] = useState<ActionItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [_page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('');
|
||||
const [threadData, setThreadData] = useState<ThreadResponse | null>(null);
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
const fetchItems = useCallback(
|
||||
async (pageNum: number, status: string, isRefresh = false) => {
|
||||
if (loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const resp = await listActionItems({
|
||||
page: pageNum,
|
||||
page_size: 20,
|
||||
status: status || undefined,
|
||||
});
|
||||
const list = resp.data || [];
|
||||
if (isRefresh) {
|
||||
setItems(list);
|
||||
} else {
|
||||
setItems((prev) => [...prev, ...list]);
|
||||
}
|
||||
setTotal(resp.total);
|
||||
setPage(pageNum);
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
loadingRef.current = false;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useDidShow(() => {
|
||||
Taro.setNavigationBarTitle({ title: '待办事项' });
|
||||
fetchItems(1, activeTab, true);
|
||||
});
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
fetchItems(1, activeTab, true).then(() =>
|
||||
Taro.stopPullDownRefresh(),
|
||||
);
|
||||
});
|
||||
|
||||
const handleTabChange = (key: string) => {
|
||||
setActiveTab(key);
|
||||
fetchItems(1, key, true);
|
||||
};
|
||||
|
||||
const handleItemClick = async (item: ActionItem) => {
|
||||
try {
|
||||
const data = await getActionThread(item.source_ref);
|
||||
setThreadData(data);
|
||||
setShowDetail(true);
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = async (action: {
|
||||
key: string;
|
||||
api_endpoint?: string;
|
||||
}) => {
|
||||
if (!action.api_endpoint || !threadData) return;
|
||||
try {
|
||||
await Taro.request({
|
||||
url: `${process.env.TARO_APP_API_URL}${action.api_endpoint}`,
|
||||
method: 'POST',
|
||||
header: { 'Content-Type': 'application/json' },
|
||||
data: { action: action.key },
|
||||
});
|
||||
Taro.showToast({ title: '操作成功', icon: 'success' });
|
||||
setShowDetail(false);
|
||||
fetchItems(1, activeTab, true);
|
||||
} catch {
|
||||
Taro.showToast({ title: '操作失败', icon: 'none' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View className={`action-inbox-page ${modeClass}`}>
|
||||
<View className="inbox-tabs">
|
||||
{STATUS_TABS.map((tab) => (
|
||||
<View
|
||||
key={tab.key}
|
||||
className={`inbox-tab ${activeTab === tab.key ? 'active' : ''}`}
|
||||
onClick={() => handleTabChange(tab.key)}
|
||||
>
|
||||
<Text
|
||||
className={`inbox-tab-text ${activeTab === tab.key ? 'active' : ''}`}
|
||||
>
|
||||
{tab.label}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{items.length === 0 && !loading ? (
|
||||
<View className="inbox-empty">
|
||||
<Text className="inbox-empty-text">暂无待办事项</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView scrollY className="inbox-list">
|
||||
{items.map((item) => (
|
||||
<View
|
||||
key={item.id}
|
||||
className="inbox-card"
|
||||
onClick={() => handleItemClick(item)}
|
||||
>
|
||||
<View className="inbox-card-header">
|
||||
<Text
|
||||
className="inbox-type-tag"
|
||||
style={{
|
||||
background: TYPE_COLOR[item.action_type] || '#999',
|
||||
}}
|
||||
>
|
||||
{TYPE_LABEL[item.action_type] || '未知'}
|
||||
</Text>
|
||||
<Text className="inbox-card-title">{item.title}</Text>
|
||||
</View>
|
||||
<Text className="inbox-card-desc">
|
||||
{item.patient_name} ·{' '}
|
||||
{new Date(item.created_at).toLocaleDateString('zh-CN')}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
{loading && <Loading />}
|
||||
{!loading && items.length >= total && total > 0 && (
|
||||
<Loading text="没有更多了" />
|
||||
)}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{showDetail && threadData && (
|
||||
<View className="half-screen-dialog">
|
||||
<View className="dialog-header">
|
||||
<Text className="dialog-title">
|
||||
{threadData.action_item.title}
|
||||
</Text>
|
||||
<Text
|
||||
className="dialog-close"
|
||||
onClick={() => setShowDetail(false)}
|
||||
>
|
||||
收起
|
||||
</Text>
|
||||
</View>
|
||||
<View className="dialog-body">
|
||||
<Text className="dialog-patient">
|
||||
{threadData.action_item.patient_name} ·{' '}
|
||||
{threadData.action_item.priority === 'urgent'
|
||||
? '紧急'
|
||||
: threadData.action_item.priority === 'high'
|
||||
? '高'
|
||||
: '中'}
|
||||
</Text>
|
||||
<View className="thread-timeline">
|
||||
{threadData.thread.map((evt, idx) => (
|
||||
<View key={idx} className="thread-item">
|
||||
<View className={`thread-dot ${evt.status}`} />
|
||||
<View className="thread-content">
|
||||
<Text className="thread-label">{evt.label}</Text>
|
||||
{evt.timestamp && (
|
||||
<Text className="thread-time">
|
||||
{new Date(evt.timestamp).toLocaleDateString('zh-CN')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
{threadData.available_actions.length > 0 && (
|
||||
<View className="dialog-actions">
|
||||
{threadData.available_actions.map((action) => (
|
||||
<View
|
||||
key={action.key}
|
||||
className={`action-btn ${action.variant}`}
|
||||
onClick={() => handleAction(action)}
|
||||
>
|
||||
{action.label}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
166
apps/miniprogram/src/pages/doctor/alerts/detail/index.scss
Normal file
166
apps/miniprogram/src/pages/doctor/alerts/detail/index.scss
Normal file
@@ -0,0 +1,166 @@
|
||||
@import '../../../../styles/variables.scss';
|
||||
@import '../../../../styles/mixins.scss';
|
||||
|
||||
.alert-detail-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding: 24px;
|
||||
padding-bottom: 160px;
|
||||
}
|
||||
|
||||
.alert-detail-header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
&__tags {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__time {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-severity {
|
||||
font-size: var(--tk-font-h2);
|
||||
font-weight: 600;
|
||||
padding: 6px 16px;
|
||||
border-radius: $r-sm;
|
||||
|
||||
&--info {
|
||||
background: $bd-l;
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
&--warning {
|
||||
background: $wrn-l;
|
||||
color: $wrn;
|
||||
}
|
||||
|
||||
&--critical {
|
||||
background: $dan-l;
|
||||
color: $dan;
|
||||
}
|
||||
|
||||
&--urgent {
|
||||
background: $dan-l;
|
||||
color: $dan;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-status {
|
||||
font-size: var(--tk-font-h2);
|
||||
padding: 6px 16px;
|
||||
border-radius: $r-sm;
|
||||
|
||||
&--pending {
|
||||
background: $wrn-l;
|
||||
color: $wrn;
|
||||
}
|
||||
|
||||
&--acknowledged {
|
||||
background: $pri-l;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
&--resolved {
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
}
|
||||
|
||||
&--dismissed {
|
||||
background: $bd-l;
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-detail-card {
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&__label {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
word-break: break-all;
|
||||
|
||||
&--id {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
&--detail {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx2;
|
||||
font-family: monospace;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
background: $bg;
|
||||
padding: 16px;
|
||||
border-radius: $r;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.alert-detail-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 32px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.alert-action-btn {
|
||||
flex: 1;
|
||||
height: 88px;
|
||||
line-height: 88px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
border-radius: $r-lg;
|
||||
text-align: center;
|
||||
|
||||
&--primary {
|
||||
background: $pri;
|
||||
color: $card;
|
||||
border: none;
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
&--default {
|
||||
background: $bd-l;
|
||||
color: $tx2;
|
||||
border: none;
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
&--resolve {
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
border: none;
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
212
apps/miniprogram/src/pages/doctor/alerts/detail/index.tsx
Normal file
212
apps/miniprogram/src/pages/doctor/alerts/detail/index.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { View, Text, ScrollView, Button } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import * as doctorApi from '@/services/doctor';
|
||||
import Loading from '@/components/Loading';
|
||||
import { useElderClass } from '../../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
const SEVERITY_MAP: Record<string, { label: string; className: string }> = {
|
||||
info: { label: '提示', className: 'detail-severity--info' },
|
||||
warning: { label: '警告', className: 'detail-severity--warning' },
|
||||
critical: { label: '严重', className: 'detail-severity--critical' },
|
||||
urgent: { label: '紧急', className: 'detail-severity--urgent' },
|
||||
};
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; className: string }> = {
|
||||
pending: { label: '待处理', className: 'detail-status--pending' },
|
||||
acknowledged: { label: '已确认', className: 'detail-status--acknowledged' },
|
||||
resolved: { label: '已恢复', className: 'detail-status--resolved' },
|
||||
dismissed: { label: '已忽略', className: 'detail-status--dismissed' },
|
||||
};
|
||||
|
||||
export default function AlertDetail() {
|
||||
const modeClass = useElderClass();
|
||||
const [alert, setAlert] = useState<doctorApi.Alert | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const params = Taro.getCurrentInstance().router?.params;
|
||||
if (params?.id) {
|
||||
loadAlert(params.id);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadAlert = async (id: string) => {
|
||||
try {
|
||||
// 告警列表 API 支持按 ID 查询,此处用列表加载后过滤
|
||||
const res = await doctorApi.listAlerts({ page: 1, page_size: 100 });
|
||||
const found = (res.data || []).find((a) => a.id === id);
|
||||
if (found) {
|
||||
setAlert(found);
|
||||
} else {
|
||||
Taro.showToast({ title: '告警不存在', icon: 'none' });
|
||||
}
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAcknowledge = async () => {
|
||||
if (!alert) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const updated = await doctorApi.acknowledgeAlert(alert.id, alert.version);
|
||||
setAlert(updated);
|
||||
Taro.showToast({ title: '已确认', icon: 'success' });
|
||||
} catch {
|
||||
Taro.showToast({ title: '操作失败', icon: 'none' });
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismiss = async () => {
|
||||
if (!alert) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const updated = await doctorApi.dismissAlert(alert.id, alert.version);
|
||||
setAlert(updated);
|
||||
Taro.showToast({ title: '已忽略', icon: 'success' });
|
||||
} catch {
|
||||
Taro.showToast({ title: '操作失败', icon: 'none' });
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResolve = async () => {
|
||||
if (!alert) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const updated = await doctorApi.resolveAlert(alert.id, alert.version);
|
||||
setAlert(updated);
|
||||
Taro.showToast({ title: '已恢复', icon: 'success' });
|
||||
} catch {
|
||||
Taro.showToast({ title: '操作失败', icon: 'none' });
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Loading />;
|
||||
if (!alert) {
|
||||
return (
|
||||
<View className={`alert-detail-page ${modeClass}`}>
|
||||
<Text>告警不存在</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const severity = SEVERITY_MAP[alert.severity] ?? SEVERITY_MAP.info;
|
||||
const status = STATUS_MAP[alert.status] ?? STATUS_MAP.pending;
|
||||
const isPending = alert.status === 'pending';
|
||||
const isAcknowledged = alert.status === 'acknowledged';
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className={`alert-detail-page ${modeClass}`}>
|
||||
{/* 顶部状态 */}
|
||||
<View className='alert-detail-header'>
|
||||
<View className='alert-detail-header__tags'>
|
||||
<Text className={`detail-severity ${severity.className}`}>
|
||||
{severity.label}
|
||||
</Text>
|
||||
<Text className={`detail-status ${status.className}`}>
|
||||
{status.label}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className='alert-detail-header__time'>
|
||||
{new Date(alert.created_at).toLocaleString('zh-CN')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 告警信息 */}
|
||||
<View className='alert-detail-card'>
|
||||
<Text className='alert-detail-card__label'>告警标题</Text>
|
||||
<Text className='alert-detail-card__value'>{alert.title}</Text>
|
||||
</View>
|
||||
|
||||
<View className='alert-detail-card'>
|
||||
<Text className='alert-detail-card__label'>患者 ID</Text>
|
||||
<Text className='alert-detail-card__value alert-detail-card__value--id'>
|
||||
{alert.patient_id.slice(0, 8)}...
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className='alert-detail-card'>
|
||||
<Text className='alert-detail-card__label'>严重程度</Text>
|
||||
<Text className='alert-detail-card__value'>{severity.label}</Text>
|
||||
</View>
|
||||
|
||||
{alert.detail && (
|
||||
<View className='alert-detail-card'>
|
||||
<Text className='alert-detail-card__label'>告警详情</Text>
|
||||
<Text className='alert-detail-card__value alert-detail-card__value--detail'>
|
||||
{JSON.stringify(alert.detail, null, 2)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{alert.acknowledged_by && (
|
||||
<View className='alert-detail-card'>
|
||||
<Text className='alert-detail-card__label'>处理人</Text>
|
||||
<Text className='alert-detail-card__value'>{alert.acknowledged_by}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{alert.acknowledged_at && (
|
||||
<View className='alert-detail-card'>
|
||||
<Text className='alert-detail-card__label'>确认时间</Text>
|
||||
<Text className='alert-detail-card__value'>
|
||||
{new Date(alert.acknowledged_at).toLocaleString('zh-CN')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{alert.resolved_at && (
|
||||
<View className='alert-detail-card'>
|
||||
<Text className='alert-detail-card__label'>恢复时间</Text>
|
||||
<Text className='alert-detail-card__value'>
|
||||
{new Date(alert.resolved_at).toLocaleString('zh-CN')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
{(isPending || isAcknowledged) && (
|
||||
<View className='alert-detail-actions'>
|
||||
{isPending && (
|
||||
<>
|
||||
<Button
|
||||
className='alert-action-btn alert-action-btn--primary'
|
||||
onClick={handleAcknowledge}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
确认
|
||||
</Button>
|
||||
<Button
|
||||
className='alert-action-btn alert-action-btn--default'
|
||||
onClick={handleDismiss}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
忽略
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{(isPending || isAcknowledged) && (
|
||||
<Button
|
||||
className='alert-action-btn alert-action-btn--resolve'
|
||||
onClick={handleResolve}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
恢复
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
174
apps/miniprogram/src/pages/doctor/alerts/index.scss
Normal file
174
apps/miniprogram/src/pages/doctor/alerts/index.scss
Normal file
@@ -0,0 +1,174 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
.alert-list-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding: 24px;
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
.alert-list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.alert-list-title {
|
||||
font-size: var(--tk-font-num-lg);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.alert-list-count {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.alert-tabs {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.alert-tab {
|
||||
padding: 10px 24px;
|
||||
border-radius: $r-pill;
|
||||
background: $bd-l;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
transition: all 0.2s;
|
||||
|
||||
&--active {
|
||||
background: $pri;
|
||||
color: $card;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.alert-card {
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
padding: 24px;
|
||||
box-shadow: $shadow-sm;
|
||||
border-left: 4px solid $wrn;
|
||||
|
||||
&:active {
|
||||
background: $bd-l;
|
||||
}
|
||||
|
||||
&--critical {
|
||||
border-left-color: $dan;
|
||||
}
|
||||
|
||||
&--info {
|
||||
border-left-color: $tx3;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 500;
|
||||
color: $tx;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__time {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-severity {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
padding: 4px 12px;
|
||||
border-radius: $r-sm;
|
||||
|
||||
&--info {
|
||||
background: $bd-l;
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
&--warning {
|
||||
background: $wrn-l;
|
||||
color: $wrn;
|
||||
}
|
||||
|
||||
&--critical {
|
||||
background: $dan-l;
|
||||
color: $dan;
|
||||
}
|
||||
|
||||
&--urgent {
|
||||
background: $dan-l;
|
||||
color: $dan;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-status {
|
||||
font-size: var(--tk-font-body);
|
||||
padding: 4px 12px;
|
||||
border-radius: $r-sm;
|
||||
|
||||
&--pending {
|
||||
background: $wrn-l;
|
||||
color: $wrn;
|
||||
}
|
||||
|
||||
&--acknowledged {
|
||||
background: $pri-l;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
&--resolved {
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
}
|
||||
|
||||
&--dismissed {
|
||||
background: $bd-l;
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin-top: 32px;
|
||||
|
||||
&__btn {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $pri;
|
||||
padding: 12px 24px;
|
||||
|
||||
&.disabled {
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
&__info {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
}
|
||||
156
apps/miniprogram/src/pages/doctor/alerts/index.tsx
Normal file
156
apps/miniprogram/src/pages/doctor/alerts/index.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import * as doctorApi from '@/services/doctor';
|
||||
import Loading from '@/components/Loading';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
const SEVERITY_MAP: Record<string, { label: string; className: string }> = {
|
||||
info: { label: '提示', className: 'alert-severity--info' },
|
||||
warning: { label: '警告', className: 'alert-severity--warning' },
|
||||
critical: { label: '严重', className: 'alert-severity--critical' },
|
||||
urgent: { label: '紧急', className: 'alert-severity--urgent' },
|
||||
};
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; className: string }> = {
|
||||
pending: { label: '待处理', className: 'alert-status--pending' },
|
||||
acknowledged: { label: '已确认', className: 'alert-status--acknowledged' },
|
||||
resolved: { label: '已恢复', className: 'alert-status--resolved' },
|
||||
dismissed: { label: '已忽略', className: 'alert-status--dismissed' },
|
||||
};
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'pending', label: '待处理' },
|
||||
{ value: 'acknowledged', label: '已确认' },
|
||||
{ value: 'resolved', label: '已恢复' },
|
||||
];
|
||||
|
||||
export default function AlertList() {
|
||||
const modeClass = useElderClass();
|
||||
const [alerts, setAlerts] = useState<doctorApi.Alert[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState('');
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const totalPages = useMemo(() => Math.ceil(total / 20), [total]);
|
||||
|
||||
useEffect(() => {
|
||||
loadAlerts();
|
||||
}, [page, activeTab]);
|
||||
|
||||
const loadAlerts = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await doctorApi.listAlerts({
|
||||
status: activeTab || undefined,
|
||||
page,
|
||||
page_size: 20,
|
||||
});
|
||||
setAlerts(res.data || []);
|
||||
setTotal(res.total || 0);
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleAlertClick = (alert: doctorApi.Alert) => {
|
||||
Taro.navigateTo({ url: `/pages/doctor/alerts/detail/index?id=${alert.id}` });
|
||||
};
|
||||
|
||||
const formatTime = (dateStr: string) => {
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return '刚刚';
|
||||
if (minutes < 60) return `${minutes}分钟前`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}小时前`;
|
||||
return d.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
if (loading && alerts.length === 0) return <Loading />;
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className={`alert-list-page ${modeClass}`}>
|
||||
<View className='alert-list-header'>
|
||||
<Text className='alert-list-title'>告警列表</Text>
|
||||
<Text className='alert-list-count'>共 {total} 条</Text>
|
||||
</View>
|
||||
|
||||
<View className='alert-tabs'>
|
||||
{STATUS_TABS.map((tab) => (
|
||||
<Text
|
||||
key={tab.value}
|
||||
className={`alert-tab ${activeTab === tab.value ? 'alert-tab--active' : ''}`}
|
||||
onClick={() => handleTabChange(tab.value)}
|
||||
>
|
||||
{tab.label}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{alerts.length === 0 ? (
|
||||
<EmptyState description='暂无告警' />
|
||||
) : (
|
||||
<View className='alert-cards'>
|
||||
{alerts.map((alert) => {
|
||||
const severity = SEVERITY_MAP[alert.severity] ?? SEVERITY_MAP.info;
|
||||
const status = STATUS_MAP[alert.status] ?? STATUS_MAP.pending;
|
||||
return (
|
||||
<View
|
||||
key={alert.id}
|
||||
className='alert-card'
|
||||
onClick={() => handleAlertClick(alert)}
|
||||
>
|
||||
<View className='alert-card__header'>
|
||||
<Text className={`alert-severity ${severity.className}`}>
|
||||
{severity.label}
|
||||
</Text>
|
||||
<Text className={`alert-status ${status.className}`}>
|
||||
{status.label}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className='alert-card__title'>{alert.title}</Text>
|
||||
<View className='alert-card__footer'>
|
||||
<Text className='alert-card__time'>{formatTime(alert.created_at)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{total > 20 && (
|
||||
<View className='alert-pagination'>
|
||||
<Text
|
||||
className={`alert-pagination__btn ${page <= 1 ? 'disabled' : ''}`}
|
||||
onClick={() => page > 1 && setPage(page - 1)}
|
||||
>
|
||||
上一页
|
||||
</Text>
|
||||
<Text className='alert-pagination__info'>
|
||||
{page} / {totalPages}
|
||||
</Text>
|
||||
<Text
|
||||
className={`alert-pagination__btn ${page >= totalPages ? 'disabled' : ''}`}
|
||||
onClick={() => page < totalPages && setPage(page + 1)}
|
||||
>
|
||||
下一页
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -18,13 +18,13 @@
|
||||
|
||||
&__title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 30px;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
&__close {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $dan;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
@@ -63,7 +63,7 @@
|
||||
}
|
||||
|
||||
.msg-text {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
display: block;
|
||||
line-height: 1.6;
|
||||
@@ -76,7 +76,7 @@
|
||||
|
||||
.msg-time {
|
||||
@include serif-number;
|
||||
font-size: 20px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
@@ -92,7 +92,7 @@
|
||||
padding: 120px 32px;
|
||||
|
||||
&__text {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
@@ -111,7 +111,7 @@
|
||||
background: $bd-l;
|
||||
border-radius: $r;
|
||||
padding: 16px 20px;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $card;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -139,7 +139,7 @@
|
||||
border-top: 1px solid $bd;
|
||||
|
||||
&__text {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,56 +3,47 @@ import { View, Text, Input, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import * as doctorApi from '@/services/doctor';
|
||||
import Loading from '@/components/Loading';
|
||||
import { useElderClass } from '../../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
const POLL_INTERVAL = 8000;
|
||||
|
||||
export default function ConsultationDetail() {
|
||||
const router = useRouter();
|
||||
const sessionId = router.params.id || '';
|
||||
const modeClass = useElderClass();
|
||||
const [session, setSession] = useState<doctorApi.ConsultationSession | null>(null);
|
||||
const [messages, setMessages] = useState<doctorApi.ConsultationMessage[]>([]);
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const scrollViewRef = useRef('');
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const pollingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionId) {
|
||||
loadData();
|
||||
markRead();
|
||||
startPolling();
|
||||
startLongPolling();
|
||||
}
|
||||
return () => stopPolling();
|
||||
return () => { pollingRef.current = false; };
|
||||
}, [sessionId]);
|
||||
|
||||
const startPolling = () => {
|
||||
stopPolling();
|
||||
pollTimerRef.current = setInterval(pollNewMessages, POLL_INTERVAL);
|
||||
useEffect(() => {
|
||||
if (session?.status === 'closed') {
|
||||
pollingRef.current = false;
|
||||
}
|
||||
}, [session?.status]);
|
||||
|
||||
const startLongPolling = () => {
|
||||
pollingRef.current = true;
|
||||
longPoll();
|
||||
};
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollTimerRef.current) {
|
||||
clearInterval(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const pollNewMessages = async () => {
|
||||
if (!session || session.status === 'closed') {
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
const longPoll = async () => {
|
||||
if (!pollingRef.current) return;
|
||||
try {
|
||||
const lastId = messages.length > 0 ? messages[messages.length - 1].id : undefined;
|
||||
const m = await doctorApi.listMessages(sessionId, {
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
after_id: lastId,
|
||||
});
|
||||
const newMsgs = m.data || [];
|
||||
if (newMsgs.length > 0) {
|
||||
const newMsgs = await doctorApi.pollMessages(sessionId, lastId);
|
||||
if (newMsgs && newMsgs.length > 0) {
|
||||
setMessages((prev) => {
|
||||
const existing = new Set(prev.map((msg) => msg.id));
|
||||
const fresh = newMsgs.filter((msg) => !existing.has(msg.id));
|
||||
@@ -60,7 +51,12 @@ export default function ConsultationDetail() {
|
||||
});
|
||||
scrollViewRef.current = `msg-${messages.length + newMsgs.length}`;
|
||||
}
|
||||
} catch { /* 轮询失败静默忽略 */ }
|
||||
} catch {
|
||||
// 超时或网络错误,静默重试
|
||||
}
|
||||
if (pollingRef.current) {
|
||||
longPoll();
|
||||
}
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
@@ -73,7 +69,7 @@ export default function ConsultationDetail() {
|
||||
setSession(s);
|
||||
setMessages(m.data || []);
|
||||
scrollViewRef.current = `msg-${(m.data || []).length}`;
|
||||
if (s.status === 'closed') stopPolling();
|
||||
if (s.status === 'closed') pollingRef.current = false;
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
@@ -132,7 +128,7 @@ export default function ConsultationDetail() {
|
||||
const isOpen = session?.status !== 'closed';
|
||||
|
||||
return (
|
||||
<View className='chat-page'>
|
||||
<View className={`chat-page ${modeClass}`}>
|
||||
{/* Header */}
|
||||
<View className='chat-header'>
|
||||
<Text className='chat-header__title'>{session?.subject || '在线咨询'}</Text>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx2;
|
||||
position: relative;
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
}
|
||||
|
||||
&__subject {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
flex: 1;
|
||||
@@ -80,7 +80,7 @@
|
||||
}
|
||||
|
||||
&__status-text {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -96,12 +96,12 @@
|
||||
}
|
||||
|
||||
&__time {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&__preview {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -123,7 +123,7 @@
|
||||
|
||||
&__badge-text {
|
||||
@include serif-number;
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $card;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -137,7 +137,7 @@
|
||||
padding: 24px;
|
||||
|
||||
&__btn {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $pri;
|
||||
padding: 12px 24px;
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
}
|
||||
|
||||
&__info {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import * as doctorApi from '@/services/doctor';
|
||||
import Loading from '@/components/Loading';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag';
|
||||
import { formatDateTime } from '@/utils/date';
|
||||
import './index.scss';
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
||||
waiting: { label: '等待中', color: '#f59e0b' },
|
||||
active: { label: '进行中', color: '#10b981' },
|
||||
closed: { label: '已关闭', color: '#94a3b8' },
|
||||
};
|
||||
|
||||
const TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'active', label: '进行中' },
|
||||
@@ -20,12 +17,15 @@ const TABS = [
|
||||
];
|
||||
|
||||
export default function ConsultationList() {
|
||||
const modeClass = useElderClass();
|
||||
const [sessions, setSessions] = useState<doctorApi.ConsultationSession[]>([]);
|
||||
const [activeTab, setActiveTab] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const totalPages = useMemo(() => Math.ceil(total / 20), [total]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
}, [page, activeTab]);
|
||||
@@ -54,18 +54,13 @@ export default function ConsultationList() {
|
||||
|
||||
const formatTime = (dateStr?: string | null) => {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
if (d.toDateString() === now.toDateString()) {
|
||||
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
return d.toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' });
|
||||
return formatDateTime(dateStr);
|
||||
};
|
||||
|
||||
if (loading && sessions.length === 0) return <Loading />;
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className='consultation-page'>
|
||||
<ScrollView scrollY className={`consultation-page ${modeClass}`}>
|
||||
<View className='tabs'>
|
||||
{TABS.map((t) => (
|
||||
<View
|
||||
@@ -83,7 +78,6 @@ export default function ConsultationList() {
|
||||
) : (
|
||||
<View className='session-list'>
|
||||
{sessions.map((s) => {
|
||||
const st = STATUS_MAP[s.status] || { label: s.status, color: '#94a3b8' };
|
||||
return (
|
||||
<View
|
||||
key={s.id}
|
||||
@@ -92,8 +86,8 @@ export default function ConsultationList() {
|
||||
>
|
||||
<View className='session-card__top'>
|
||||
<Text className='session-card__subject'>{s.subject || '在线咨询'}</Text>
|
||||
<View className='session-card__status' style={`background: ${st.color}20; color: ${st.color}`}>
|
||||
<Text className='session-card__status-text'>{st.label}</Text>
|
||||
<View className='session-card__status' style={getStatusInlineStyle(s.status)}>
|
||||
<Text className='session-card__status-text'>{getStatusLabel(s.status)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className='session-card__info'>
|
||||
@@ -122,10 +116,10 @@ export default function ConsultationList() {
|
||||
className={`pagination__btn ${page <= 1 ? 'disabled' : ''}`}
|
||||
onClick={() => page > 1 && setPage(page - 1)}
|
||||
>上一页</Text>
|
||||
<Text className='pagination__info'>{page} / {Math.ceil(total / 20)}</Text>
|
||||
<Text className='pagination__info'>{page} / {totalPages}</Text>
|
||||
<Text
|
||||
className={`pagination__btn ${page >= Math.ceil(total / 20) ? 'disabled' : ''}`}
|
||||
onClick={() => page < Math.ceil(total / 20) && setPage(page + 1)}
|
||||
className={`pagination__btn ${page >= totalPages ? 'disabled' : ''}`}
|
||||
onClick={() => page < totalPages && setPage(page + 1)}
|
||||
>下一页</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
98
apps/miniprogram/src/pages/doctor/dialysis/create/index.scss
Normal file
98
apps/miniprogram/src/pages/doctor/dialysis/create/index.scss
Normal file
@@ -0,0 +1,98 @@
|
||||
@import '../../../../styles/variables.scss';
|
||||
@import '../../../../styles/mixins.scss';
|
||||
|
||||
.create-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding: 24px;
|
||||
padding-bottom: 200px;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&--textarea {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
flex-shrink: 0;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.form-value {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
|
||||
&.placeholder {
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
min-height: 120px;
|
||||
background: $bg;
|
||||
border-radius: $r-sm;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
background: $pri;
|
||||
border-radius: $r-sm;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
|
||||
&:active {
|
||||
background: $pri-d;
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.submit-btn__text {
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
}
|
||||
246
apps/miniprogram/src/pages/doctor/dialysis/create/index.tsx
Normal file
246
apps/miniprogram/src/pages/doctor/dialysis/create/index.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { View, Text, Input, Textarea, Picker, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import * as doctorApi from '@/services/doctor';
|
||||
import Loading from '@/components/Loading';
|
||||
import { useElderClass } from '../../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
const DIALYSIS_TYPES = ['HD', 'HDF', 'HF'];
|
||||
|
||||
interface FormState {
|
||||
patient_id: string;
|
||||
dialysis_date: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
dialysis_type: string;
|
||||
dialysis_duration: string;
|
||||
blood_flow_rate: string;
|
||||
dry_weight: string;
|
||||
pre_weight: string;
|
||||
post_weight: string;
|
||||
pre_bp_systolic: string;
|
||||
pre_bp_diastolic: string;
|
||||
post_bp_systolic: string;
|
||||
post_bp_diastolic: string;
|
||||
pre_heart_rate: string;
|
||||
post_heart_rate: string;
|
||||
ultrafiltration_volume: string;
|
||||
complication_notes: string;
|
||||
}
|
||||
|
||||
const initialForm: FormState = {
|
||||
patient_id: '',
|
||||
dialysis_date: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
dialysis_type: 'HD',
|
||||
dialysis_duration: '',
|
||||
blood_flow_rate: '',
|
||||
dry_weight: '',
|
||||
pre_weight: '',
|
||||
post_weight: '',
|
||||
pre_bp_systolic: '',
|
||||
pre_bp_diastolic: '',
|
||||
post_bp_systolic: '',
|
||||
post_bp_diastolic: '',
|
||||
pre_heart_rate: '',
|
||||
post_heart_rate: '',
|
||||
ultrafiltration_volume: '',
|
||||
complication_notes: '',
|
||||
};
|
||||
|
||||
export default function DialysisCreate() {
|
||||
const router = useRouter();
|
||||
const id = router.params.id || '';
|
||||
const version = router.params.version ? Number(router.params.version) : 0;
|
||||
const patientIdFromRoute = router.params.patientId || '';
|
||||
const isEdit = !!id;
|
||||
const modeClass = useElderClass();
|
||||
|
||||
const [form, setForm] = useState<FormState>({ ...initialForm, patient_id: patientIdFromRoute });
|
||||
const [loading, setLoading] = useState(isEdit);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && id) loadRecord();
|
||||
}, [id]);
|
||||
|
||||
const loadRecord = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await doctorApi.getDialysisRecord(id);
|
||||
setForm({
|
||||
patient_id: r.patient_id,
|
||||
dialysis_date: r.dialysis_date || '',
|
||||
start_time: r.start_time || '',
|
||||
end_time: r.end_time || '',
|
||||
dialysis_type: r.dialysis_type || 'HD',
|
||||
dialysis_duration: r.dialysis_duration != null ? String(r.dialysis_duration) : '',
|
||||
blood_flow_rate: r.blood_flow_rate != null ? String(r.blood_flow_rate) : '',
|
||||
dry_weight: r.dry_weight != null ? String(r.dry_weight) : '',
|
||||
pre_weight: r.pre_weight != null ? String(r.pre_weight) : '',
|
||||
post_weight: r.post_weight != null ? String(r.post_weight) : '',
|
||||
pre_bp_systolic: r.pre_bp_systolic != null ? String(r.pre_bp_systolic) : '',
|
||||
pre_bp_diastolic: r.pre_bp_diastolic != null ? String(r.pre_bp_diastolic) : '',
|
||||
post_bp_systolic: r.post_bp_systolic != null ? String(r.post_bp_systolic) : '',
|
||||
post_bp_diastolic: r.post_bp_diastolic != null ? String(r.post_bp_diastolic) : '',
|
||||
pre_heart_rate: r.pre_heart_rate != null ? String(r.pre_heart_rate) : '',
|
||||
post_heart_rate: r.post_heart_rate != null ? String(r.post_heart_rate) : '',
|
||||
ultrafiltration_volume: r.ultrafiltration_volume != null ? String(r.ultrafiltration_volume) : '',
|
||||
complication_notes: r.complication_notes || '',
|
||||
});
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateField = (key: keyof FormState, value: string) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.dialysis_date) {
|
||||
Taro.showToast({ title: '请选择透析日期', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (!form.patient_id) {
|
||||
Taro.showToast({ title: '缺少患者信息', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
const num = (v: string) => v ? Number(v) : undefined;
|
||||
const payload = {
|
||||
patient_id: form.patient_id,
|
||||
dialysis_date: form.dialysis_date,
|
||||
start_time: form.start_time || undefined,
|
||||
end_time: form.end_time || undefined,
|
||||
dialysis_type: form.dialysis_type,
|
||||
dialysis_duration: num(form.dialysis_duration),
|
||||
blood_flow_rate: num(form.blood_flow_rate),
|
||||
dry_weight: num(form.dry_weight),
|
||||
pre_weight: num(form.pre_weight),
|
||||
post_weight: num(form.post_weight),
|
||||
pre_bp_systolic: num(form.pre_bp_systolic),
|
||||
pre_bp_diastolic: num(form.pre_bp_diastolic),
|
||||
post_bp_systolic: num(form.post_bp_systolic),
|
||||
post_bp_diastolic: num(form.post_bp_diastolic),
|
||||
pre_heart_rate: num(form.pre_heart_rate),
|
||||
post_heart_rate: num(form.post_heart_rate),
|
||||
ultrafiltration_volume: num(form.ultrafiltration_volume),
|
||||
complication_notes: form.complication_notes || undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
const { patient_id, ...updateData } = payload;
|
||||
await doctorApi.updateDialysisRecord(id, updateData, version);
|
||||
Taro.showToast({ title: '更新成功', icon: 'success' });
|
||||
} else {
|
||||
await doctorApi.createDialysisRecord(payload);
|
||||
Taro.showToast({ title: '创建成功', icon: 'success' });
|
||||
}
|
||||
setTimeout(() => Taro.navigateBack(), 1000);
|
||||
} catch {
|
||||
Taro.showToast({ title: isEdit ? '更新失败' : '创建失败', icon: 'none' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Loading />;
|
||||
|
||||
const InputField = ({ label, field, placeholder, type = 'digit' }: {
|
||||
label: string; field: keyof FormState; placeholder: string; type?: string;
|
||||
}) => (
|
||||
<View className='form-row'>
|
||||
<Text className='form-label'>{label}</Text>
|
||||
<Input
|
||||
className='form-input'
|
||||
type={type as any}
|
||||
placeholder={placeholder}
|
||||
value={form[field]}
|
||||
onInput={(e) => updateField(field, e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className={`create-page ${modeClass}`}>
|
||||
<View className='section'>
|
||||
<Text className='section-title'>基本信息</Text>
|
||||
<View className='form-row'>
|
||||
<Text className='form-label'>透析日期</Text>
|
||||
<Picker mode='date' value={form.dialysis_date} onChange={(e) => updateField('dialysis_date', e.detail.value)}>
|
||||
<Text className={`form-value ${!form.dialysis_date ? 'placeholder' : ''}`}>
|
||||
{form.dialysis_date || '请选择日期'}
|
||||
</Text>
|
||||
</Picker>
|
||||
</View>
|
||||
<View className='form-row'>
|
||||
<Text className='form-label'>开始时间</Text>
|
||||
<Picker mode='time' value={form.start_time} onChange={(e) => updateField('start_time', e.detail.value)}>
|
||||
<Text className={`form-value ${!form.start_time ? 'placeholder' : ''}`}>
|
||||
{form.start_time || '请选择时间'}
|
||||
</Text>
|
||||
</Picker>
|
||||
</View>
|
||||
<View className='form-row'>
|
||||
<Text className='form-label'>结束时间</Text>
|
||||
<Picker mode='time' value={form.end_time} onChange={(e) => updateField('end_time', e.detail.value)}>
|
||||
<Text className={`form-value ${!form.end_time ? 'placeholder' : ''}`}>
|
||||
{form.end_time || '请选择时间'}
|
||||
</Text>
|
||||
</Picker>
|
||||
</View>
|
||||
<View className='form-row'>
|
||||
<Text className='form-label'>透析类型</Text>
|
||||
<Picker mode='selector' range={DIALYSIS_TYPES} value={DIALYSIS_TYPES.indexOf(form.dialysis_type)} onChange={(e) => updateField('dialysis_type', DIALYSIS_TYPES[Number(e.detail.value)])}>
|
||||
<Text className='form-value'>{form.dialysis_type}</Text>
|
||||
</Picker>
|
||||
</View>
|
||||
<InputField label='透析时长' field='dialysis_duration' placeholder='分钟' type='number' />
|
||||
<InputField label='血流速' field='blood_flow_rate' placeholder='ml/min' type='number' />
|
||||
</View>
|
||||
|
||||
<View className='section'>
|
||||
<Text className='section-title'>体重</Text>
|
||||
<InputField label='干体重' field='dry_weight' placeholder='kg' />
|
||||
<InputField label='透前体重' field='pre_weight' placeholder='kg' />
|
||||
<InputField label='透后体重' field='post_weight' placeholder='kg' />
|
||||
</View>
|
||||
|
||||
<View className='section'>
|
||||
<Text className='section-title'>血压与心率</Text>
|
||||
<InputField label='透前收缩压' field='pre_bp_systolic' placeholder='mmHg' type='number' />
|
||||
<InputField label='透前舒张压' field='pre_bp_diastolic' placeholder='mmHg' type='number' />
|
||||
<InputField label='透后收缩压' field='post_bp_systolic' placeholder='mmHg' type='number' />
|
||||
<InputField label='透后舒张压' field='post_bp_diastolic' placeholder='mmHg' type='number' />
|
||||
<InputField label='透前心率' field='pre_heart_rate' placeholder='bpm' type='number' />
|
||||
<InputField label='透后心率' field='post_heart_rate' placeholder='bpm' type='number' />
|
||||
</View>
|
||||
|
||||
<View className='section'>
|
||||
<Text className='section-title'>超滤与备注</Text>
|
||||
<InputField label='超滤量' field='ultrafiltration_volume' placeholder='ml' type='number' />
|
||||
<View className='form-row form-row--textarea'>
|
||||
<Text className='form-label'>并发症备注</Text>
|
||||
<Textarea
|
||||
className='form-textarea'
|
||||
placeholder='请输入...'
|
||||
value={form.complication_notes}
|
||||
onInput={(e) => updateField('complication_notes', e.detail.value)}
|
||||
maxlength={500}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className={`submit-btn ${submitting ? 'submit-btn--disabled' : ''}`} onClick={handleSubmit}>
|
||||
<Text className='submit-btn__text'>{submitting ? '提交中...' : isEdit ? '更新记录' : '创建记录'}</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
156
apps/miniprogram/src/pages/doctor/dialysis/detail/index.scss
Normal file
156
apps/miniprogram/src/pages/doctor/dialysis/detail/index.scss
Normal file
@@ -0,0 +1,156 @@
|
||||
@import '../../../../styles/variables.scss';
|
||||
@import '../../../../styles/mixins.scss';
|
||||
|
||||
.dialysis-detail {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding: 24px;
|
||||
padding-bottom: 200px;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.record-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.record-header__title {
|
||||
font-size: var(--tk-font-num-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.record-header__status {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: var(--tk-font-body);
|
||||
background: $bd-l;
|
||||
color: $tx3;
|
||||
|
||||
&--completed {
|
||||
background: $pri-l;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
&--reviewed {
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
}
|
||||
}
|
||||
|
||||
.record-sub {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.review-info {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
text-align: right;
|
||||
flex: 1;
|
||||
margin-left: 24px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
text-align: center;
|
||||
padding: 120px 0;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
border-radius: $r-sm;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
|
||||
&--primary {
|
||||
background: $pri;
|
||||
|
||||
.action-btn__text {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: $pri-d;
|
||||
}
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
background: $card;
|
||||
border: 1px solid $bd;
|
||||
|
||||
.action-btn__text {
|
||||
color: $pri;
|
||||
}
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background: $card;
|
||||
border: 1px solid $dan;
|
||||
|
||||
.action-btn__text {
|
||||
color: $dan;
|
||||
}
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn__text {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 500;
|
||||
}
|
||||
174
apps/miniprogram/src/pages/doctor/dialysis/detail/index.tsx
Normal file
174
apps/miniprogram/src/pages/doctor/dialysis/detail/index.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import * as doctorApi from '@/services/doctor';
|
||||
import Loading from '@/components/Loading';
|
||||
import { useElderClass } from '../../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
export default function DialysisDetail() {
|
||||
const router = useRouter();
|
||||
const id = router.params.id || '';
|
||||
const modeClass = useElderClass();
|
||||
const [record, setRecord] = useState<doctorApi.DialysisRecord | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) loadRecord();
|
||||
}, [id]);
|
||||
|
||||
const loadRecord = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await doctorApi.getDialysisRecord(id);
|
||||
setRecord(r);
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReview = async () => {
|
||||
if (!record) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const updated = await doctorApi.reviewDialysisRecord(id, record.version);
|
||||
setRecord(updated);
|
||||
Taro.showToast({ title: '审核完成', icon: 'success' });
|
||||
} catch {
|
||||
Taro.showToast({ title: '审核失败', icon: 'none' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = async () => {
|
||||
if (!record) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const updated = await doctorApi.updateDialysisRecord(id, { status: 'completed' }, record.version);
|
||||
setRecord(updated);
|
||||
Taro.showToast({ title: '已标记完成', icon: 'success' });
|
||||
} catch {
|
||||
Taro.showToast({ title: '操作失败', icon: 'none' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!record) return;
|
||||
const { confirm } = await Taro.showModal({
|
||||
title: '确认删除',
|
||||
content: '删除后不可恢复,确定要删除这条记录吗?',
|
||||
});
|
||||
if (!confirm) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await doctorApi.deleteDialysisRecord(id, record.version);
|
||||
Taro.showToast({ title: '已删除', icon: 'success' });
|
||||
setTimeout(() => Taro.navigateBack(), 1000);
|
||||
} catch {
|
||||
Taro.showToast({ title: '删除失败', icon: 'none' });
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const Row = ({ label, value, unit }: { label: string; value?: string | number | null; unit?: string }) => {
|
||||
if (value == null) return null;
|
||||
return (
|
||||
<View className='detail-row'>
|
||||
<Text className='detail-label'>{label}</Text>
|
||||
<Text className='detail-value'>{value}{unit || ''}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) return <Loading />;
|
||||
if (!record) return <View className={`error-text ${modeClass}`}><Text>记录加载失败</Text></View>;
|
||||
|
||||
const canComplete = record.status === 'draft';
|
||||
const canReview = record.status === 'completed';
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className={`dialysis-detail ${modeClass}`}>
|
||||
{/* 状态头部 */}
|
||||
<View className='section'>
|
||||
<View className='record-header'>
|
||||
<Text className='record-header__title'>{record.dialysis_date}</Text>
|
||||
<Text className={`record-header__status record-header__status--${record.status}`}>
|
||||
{record.status === 'draft' ? '草稿' : record.status === 'completed' ? '已完成' : '已审核'}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className='record-sub'>
|
||||
{(record.dialysis_type === 'HD' ? '血液透析' : record.dialysis_type === 'HDF' ? '血液透析滤过' : record.dialysis_type === 'HF' ? '血液滤过' : record.dialysis_type)}
|
||||
</Text>
|
||||
{record.reviewed_at && <Text className='review-info'>审核于 {record.reviewed_at}</Text>}
|
||||
</View>
|
||||
|
||||
{/* 基本信息 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>基本信息</Text>
|
||||
<Row label='透析日期' value={record.dialysis_date} />
|
||||
<Row label='开始时间' value={record.start_time} />
|
||||
<Row label='结束时间' value={record.end_time} />
|
||||
<Row label='透析时长' value={record.dialysis_duration} unit=' 分钟' />
|
||||
<Row label='血流速' value={record.blood_flow_rate} unit=' ml/min' />
|
||||
<Row label='超滤量' value={record.ultrafiltration_volume} unit=' ml' />
|
||||
</View>
|
||||
|
||||
{/* 体重与血压 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>体重与血压</Text>
|
||||
<Row label='干体重' value={record.dry_weight} unit=' kg' />
|
||||
<Row label='透前体重' value={record.pre_weight} unit=' kg' />
|
||||
<Row label='透后体重' value={record.post_weight} unit=' kg' />
|
||||
{record.pre_bp_systolic != null && record.pre_bp_diastolic != null && (
|
||||
<Row label='透前血压' value={`${record.pre_bp_systolic}/${record.pre_bp_diastolic}`} unit=' mmHg' />
|
||||
)}
|
||||
{record.post_bp_systolic != null && record.post_bp_diastolic != null && (
|
||||
<Row label='透后血压' value={`${record.post_bp_systolic}/${record.post_bp_diastolic}`} unit=' mmHg' />
|
||||
)}
|
||||
<Row label='透前心率' value={record.pre_heart_rate} unit=' bpm' />
|
||||
<Row label='透后心率' value={record.post_heart_rate} unit=' bpm' />
|
||||
</View>
|
||||
|
||||
{/* 症状与并发症 */}
|
||||
{(record.symptoms || record.complication_notes) && (
|
||||
<View className='section'>
|
||||
<Text className='section-title'>症状与并发症</Text>
|
||||
{record.symptoms && (
|
||||
<Row label='症状' value={JSON.stringify(record.symptoms)} />
|
||||
)}
|
||||
<Row label='并发症备注' value={record.complication_notes} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<View className='actions'>
|
||||
{canComplete && (
|
||||
<View className={`action-btn action-btn--primary ${submitting ? 'action-btn--disabled' : ''}`} onClick={handleComplete}>
|
||||
<Text className='action-btn__text'>{submitting ? '处理中...' : '标记完成'}</Text>
|
||||
</View>
|
||||
)}
|
||||
{canReview && (
|
||||
<View className={`action-btn action-btn--primary ${submitting ? 'action-btn--disabled' : ''}`} onClick={handleReview}>
|
||||
<Text className='action-btn__text'>{submitting ? '审核中...' : '确认审核'}</Text>
|
||||
</View>
|
||||
)}
|
||||
{record.status === 'draft' && (
|
||||
<View className='action-btn action-btn--secondary' onClick={() => Taro.navigateTo({
|
||||
url: `/pages/doctor/dialysis/create/index?id=${id}&version=${record.version}`,
|
||||
})}>
|
||||
<Text className='action-btn__text'>编辑</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className='action-btn action-btn--danger' onClick={handleDelete}>
|
||||
<Text className='action-btn__text'>删除</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
194
apps/miniprogram/src/pages/doctor/dialysis/index.scss
Normal file
194
apps/miniprogram/src/pages/doctor/dialysis/index.scss
Normal file
@@ -0,0 +1,194 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
.dialysis-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
padding: 16px 24px;
|
||||
background: $card;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
background: $bg;
|
||||
border-radius: $r-sm;
|
||||
padding: 16px 20px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
padding: 0 24px;
|
||||
background: $card;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
position: relative;
|
||||
|
||||
&--active {
|
||||
.tab-text {
|
||||
color: $pri;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 25%;
|
||||
right: 25%;
|
||||
height: 4px;
|
||||
background: $pri;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-text {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.record-list {
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.record-count {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
padding: 8px 0 16px;
|
||||
}
|
||||
|
||||
.record-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&:active {
|
||||
box-shadow: $shadow-md;
|
||||
}
|
||||
}
|
||||
|
||||
.record-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.type-tag {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
background: $pri-l;
|
||||
color: $pri-d;
|
||||
|
||||
&--hdf {
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
}
|
||||
|
||||
&--hf {
|
||||
background: $wrn-l;
|
||||
color: $wrn;
|
||||
}
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: var(--tk-font-body);
|
||||
background: $bd-l;
|
||||
color: $tx3;
|
||||
|
||||
&--completed {
|
||||
background: $pri-l;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
&--reviewed {
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
}
|
||||
}
|
||||
|
||||
.record-card__body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.record-card__date {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.record-card__meta {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
padding: 12px 24px;
|
||||
background: $card;
|
||||
border-radius: $r-sm;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $pri;
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 32px;
|
||||
bottom: 120px;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 48px;
|
||||
background: $pri;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: $shadow-md;
|
||||
z-index: 10;
|
||||
|
||||
&:active {
|
||||
background: $pri-d;
|
||||
}
|
||||
}
|
||||
|
||||
.fab-text {
|
||||
font-size: var(--tk-font-hero);
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
}
|
||||
171
apps/miniprogram/src/pages/doctor/dialysis/index.tsx
Normal file
171
apps/miniprogram/src/pages/doctor/dialysis/index.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { View, Text, Input, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import * as doctorApi from '@/services/doctor';
|
||||
import Loading from '@/components/Loading';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
const TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'draft', label: '草稿' },
|
||||
{ key: 'completed', label: '已完成' },
|
||||
{ key: 'reviewed', label: '已审核' },
|
||||
];
|
||||
|
||||
const TYPE_MAP: Record<string, string> = { HD: 'HD', HDF: 'HDF', HF: 'HF' };
|
||||
|
||||
export default function DialysisList() {
|
||||
const router = useRouter();
|
||||
const patientId = router.params.patientId || '';
|
||||
const modeClass = useElderClass();
|
||||
const [searchPatient, setSearchPatient] = useState('');
|
||||
const [currentPatientId, setCurrentPatientId] = useState(patientId);
|
||||
const [activeTab, setActiveTab] = useState('');
|
||||
const [records, setRecords] = useState<doctorApi.DialysisRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPatientId) loadRecords(1);
|
||||
}, [currentPatientId, activeTab]);
|
||||
|
||||
const loadRecords = async (p: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await doctorApi.listDialysisRecords(currentPatientId, { page: p, page_size: 20 });
|
||||
setRecords(res.data || []);
|
||||
setTotal(res.total || 0);
|
||||
setPage(p);
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchPatient.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await doctorApi.listPatients({ search: searchPatient.trim(), page: 1, page_size: 1 });
|
||||
if (res.data && res.data.length > 0) {
|
||||
setCurrentPatientId(res.data[0].id);
|
||||
Taro.setNavigationBarTitle({ title: res.data[0].name + '的透析记录' });
|
||||
} else {
|
||||
Taro.showToast({ title: '未找到患者', icon: 'none' });
|
||||
}
|
||||
} catch {
|
||||
Taro.showToast({ title: '搜索失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTab = (key: string) => {
|
||||
setActiveTab(key);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const filtered = activeTab ? records.filter((r) => r.status === activeTab) : records;
|
||||
|
||||
if (loading && records.length === 0) return <Loading />;
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className={`dialysis-page ${modeClass}`}>
|
||||
{!patientId && (
|
||||
<View className='search-bar'>
|
||||
<Input
|
||||
className='search-input'
|
||||
placeholder='搜索患者姓名'
|
||||
value={searchPatient}
|
||||
onInput={(e) => setSearchPatient(e.detail.value)}
|
||||
confirmType='search'
|
||||
onConfirm={handleSearch}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className='tabs'>
|
||||
{TABS.map((t) => (
|
||||
<View
|
||||
key={t.key}
|
||||
className={`tab ${activeTab === t.key ? 'tab--active' : ''}`}
|
||||
onClick={() => handleTab(t.key)}
|
||||
>
|
||||
<Text className='tab-text'>{t.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{!currentPatientId ? (
|
||||
<EmptyState text='请搜索并选择患者' />
|
||||
) : filtered.length === 0 ? (
|
||||
<EmptyState text='暂无透析记录' />
|
||||
) : (
|
||||
<View className='record-list'>
|
||||
<View className='record-count'><Text>共 {total} 条记录</Text></View>
|
||||
{filtered.map((r) => (
|
||||
<View
|
||||
key={r.id}
|
||||
className='record-card'
|
||||
onClick={() => Taro.navigateTo({
|
||||
url: `/pages/doctor/dialysis/detail/index?id=${r.id}`,
|
||||
})}
|
||||
>
|
||||
<View className='record-card__header'>
|
||||
<Text className={`type-tag type-tag--${(r.dialysis_type || 'hd').toLowerCase()}`}>
|
||||
{TYPE_MAP[r.dialysis_type] || r.dialysis_type}
|
||||
</Text>
|
||||
<Text className={`status-tag status-tag--${r.status}`}>
|
||||
{r.status === 'draft' ? '草稿' : r.status === 'completed' ? '已完成' : '已审核'}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='record-card__body'>
|
||||
<Text className='record-card__date'>{r.dialysis_date}</Text>
|
||||
{r.dialysis_duration != null && (
|
||||
<Text className='record-card__meta'>时长 {r.dialysis_duration}分钟</Text>
|
||||
)}
|
||||
{r.ultrafiltration_volume != null && (
|
||||
<Text className='record-card__meta'>超滤 {r.ultrafiltration_volume}ml</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
{total > 20 && (
|
||||
<View className='pagination'>
|
||||
<View
|
||||
className={`page-btn ${page <= 1 ? 'page-btn--disabled' : ''}`}
|
||||
onClick={() => page > 1 && loadRecords(page - 1)}
|
||||
>
|
||||
<Text>上一页</Text>
|
||||
</View>
|
||||
<Text className='page-info'>{page} / {Math.ceil(total / 20)}</Text>
|
||||
<View
|
||||
className={`page-btn ${page * 20 >= total ? 'page-btn--disabled' : ''}`}
|
||||
onClick={() => page * 20 < total && loadRecords(page + 1)}
|
||||
>
|
||||
<Text>下一页</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View
|
||||
className='fab'
|
||||
onClick={() => {
|
||||
if (!currentPatientId) {
|
||||
Taro.showToast({ title: '请先选择患者', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
Taro.navigateTo({ url: `/pages/doctor/dialysis/create/index?patientId=${currentPatientId}` });
|
||||
}}
|
||||
>
|
||||
<Text className='fab-text'>+</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -28,13 +28,13 @@
|
||||
|
||||
&__title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 30px;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
&__status {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
padding: 6px 16px;
|
||||
border-radius: $r;
|
||||
font-weight: 500;
|
||||
@@ -60,12 +60,12 @@
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -77,14 +77,14 @@
|
||||
border-radius: $r;
|
||||
|
||||
&__label {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx2;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -99,14 +99,14 @@
|
||||
}
|
||||
|
||||
&__date {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
@@ -121,7 +121,7 @@
|
||||
border-radius: $r;
|
||||
margin-bottom: 24px;
|
||||
color: $card;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
@@ -143,7 +143,7 @@
|
||||
background: $bd-l;
|
||||
border-radius: $r;
|
||||
padding: 16px 20px;
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
box-sizing: border-box;
|
||||
line-height: 1.6;
|
||||
@@ -154,7 +154,7 @@
|
||||
padding: 16px 20px;
|
||||
background: $bd-l;
|
||||
border-radius: $r;
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -171,7 +171,7 @@
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $card;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -181,5 +181,5 @@
|
||||
text-align: center;
|
||||
padding: 80px 32px;
|
||||
color: $tx3;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { View, Text, Textarea, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import * as doctorApi from '@/services/doctor';
|
||||
import Loading from '@/components/Loading';
|
||||
import { useElderClass } from '../../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
@@ -16,6 +17,7 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
export default function FollowUpDetail() {
|
||||
const router = useRouter();
|
||||
const taskId = router.params.id || '';
|
||||
const modeClass = useElderClass();
|
||||
const [task, setTask] = useState<doctorApi.FollowUpTask | null>(null);
|
||||
const [records, setRecords] = useState<doctorApi.FollowUpRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -87,12 +89,12 @@ export default function FollowUpDetail() {
|
||||
const formatDate = (d: string) => new Date(d).toLocaleDateString('zh-CN');
|
||||
|
||||
if (loading) return <Loading />;
|
||||
if (!task) return <View className='error-text'><Text>任务加载失败</Text></View>;
|
||||
if (!task) return <View className={`error-text ${modeClass}`}><Text>任务加载失败</Text></View>;
|
||||
|
||||
const canSubmit = task.status === 'in_progress' || task.status === 'pending' || task.status === 'overdue';
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className='followup-detail'>
|
||||
<ScrollView scrollY className={`followup-detail ${modeClass}`}>
|
||||
<View className='section'>
|
||||
<View className='task-header'>
|
||||
<Text className='task-header__title'>随访详情</Text>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
.tab {
|
||||
display: inline-block;
|
||||
padding: 24px 16px;
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
@@ -44,7 +44,7 @@
|
||||
padding: 20px 28px;
|
||||
|
||||
text {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
@@ -75,19 +75,19 @@
|
||||
|
||||
&__type {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
&__status {
|
||||
@include tag(transparent, $tx2);
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__patient {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
@@ -99,7 +99,7 @@
|
||||
}
|
||||
|
||||
&__date {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,16 +4,10 @@ import Taro, { useRouter } from '@tarojs/taro';
|
||||
import * as doctorApi from '@/services/doctor';
|
||||
import Loading from '@/components/Loading';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag';
|
||||
import './index.scss';
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
||||
pending: { label: '待处理', color: '#f59e0b' },
|
||||
in_progress: { label: '进行中', color: '#0891b2' },
|
||||
completed: { label: '已完成', color: '#10b981' },
|
||||
overdue: { label: '已逾期', color: '#ef4444' },
|
||||
cancelled: { label: '已取消', color: '#94a3b8' },
|
||||
};
|
||||
|
||||
const TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'pending', label: '待处理' },
|
||||
@@ -25,6 +19,7 @@ const TABS = [
|
||||
export default function FollowUpList() {
|
||||
const router = useRouter();
|
||||
const patientId = router.params.patientId || '';
|
||||
const modeClass = useElderClass();
|
||||
const [tasks, setTasks] = useState<doctorApi.FollowUpTask[]>([]);
|
||||
const [activeTab, setActiveTab] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -69,7 +64,7 @@ export default function FollowUpList() {
|
||||
if (loading && tasks.length === 0) return <Loading />;
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className='followup-page'>
|
||||
<ScrollView scrollY className={`followup-page ${modeClass}`}>
|
||||
<View className='tabs'>
|
||||
{TABS.map((t) => (
|
||||
<View
|
||||
@@ -91,7 +86,6 @@ export default function FollowUpList() {
|
||||
) : (
|
||||
<View className='task-list'>
|
||||
{tasks.map((task) => {
|
||||
const st = STATUS_MAP[task.status] || { label: task.status, color: '#94a3b8' };
|
||||
return (
|
||||
<View
|
||||
key={task.id}
|
||||
@@ -100,8 +94,8 @@ export default function FollowUpList() {
|
||||
>
|
||||
<View className='task-card__header'>
|
||||
<Text className='task-card__type'>{getTypeLabel(task.follow_up_type)}</Text>
|
||||
<View className='task-card__status' style={`background: ${st.color}20; color: ${st.color}`}>
|
||||
<Text>{st.label}</Text>
|
||||
<View className='task-card__status' style={getStatusInlineStyle(task.status)}>
|
||||
<Text>{getStatusLabel(task.status)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className='task-card__patient'>{task.patient_name || '未知患者'}</Text>
|
||||
|
||||
@@ -13,19 +13,18 @@
|
||||
|
||||
&__title {
|
||||
@include section-title;
|
||||
font-size: 40px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__greeting {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__date {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
@@ -34,34 +33,34 @@
|
||||
align-items: center;
|
||||
margin: 16px 24px;
|
||||
padding: 16px 20px;
|
||||
background: #FEF2F2;
|
||||
background: $dan-l;
|
||||
border-radius: $r;
|
||||
border-left: 4px solid #EF4444;
|
||||
border-left: 4px solid $dan;
|
||||
}
|
||||
|
||||
&__alert-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: #EF4444;
|
||||
background: $dan;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
line-height: 36px;
|
||||
font-weight: bold;
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__alert-text {
|
||||
flex: 1;
|
||||
font-size: 26px;
|
||||
color: #991B1B;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $dan;
|
||||
}
|
||||
|
||||
&__alert-link {
|
||||
font-size: 24px;
|
||||
color: #EF4444;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $dan;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -70,11 +69,11 @@
|
||||
}
|
||||
|
||||
&__search-input {
|
||||
background: #F1F5F9;
|
||||
background: $surface-alt;
|
||||
border-radius: $r;
|
||||
padding: 16px 20px;
|
||||
font-size: 26px;
|
||||
color: #94A3B8;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&__section {
|
||||
@@ -113,14 +112,14 @@
|
||||
background: $pri-l;
|
||||
color: $pri;
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__card-num {
|
||||
@include serif-number;
|
||||
font-size: 48px;
|
||||
font-size: var(--tk-font-hero);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
display: block;
|
||||
@@ -128,7 +127,7 @@
|
||||
}
|
||||
|
||||
&__card-label {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
@@ -145,7 +144,7 @@
|
||||
|
||||
&__logout {
|
||||
color: $dan;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-h2);
|
||||
padding: 16px 48px;
|
||||
display: inline-block;
|
||||
}
|
||||
@@ -172,13 +171,34 @@
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__icon-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__badge {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -12px;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
text-align: center;
|
||||
background: $dan;
|
||||
color: #fff;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 700;
|
||||
border-radius: $r-pill;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { View, Text, Input, ScrollView } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useElderClass } from '../../hooks/useElderClass';
|
||||
import * as doctorApi from '@/services/doctor';
|
||||
import Loading from '@/components/Loading';
|
||||
import './index.scss';
|
||||
@@ -11,34 +12,67 @@ interface CardConfig {
|
||||
label: string;
|
||||
initial: string;
|
||||
route: string;
|
||||
roles?: string[];
|
||||
}
|
||||
|
||||
const CARDS: CardConfig[] = [
|
||||
const ALL_CARDS: CardConfig[] = [
|
||||
{ key: 'total_patients', label: '我的患者', initial: '患', route: '/pages/doctor/patients/index' },
|
||||
{ key: 'unread_messages', label: '未读消息', initial: '消', route: '/pages/doctor/consultation/index' },
|
||||
{ key: 'pending_follow_ups', label: '待处理随访', initial: '随', route: '/pages/doctor/followup/index' },
|
||||
{ key: 'today_consultations', label: '今日咨询', initial: '诊', route: '/pages/doctor/consultation/index' },
|
||||
{ key: 'pending_follow_ups', label: '待处理随访', initial: '随', route: '/pages/doctor/followup/index', roles: ['doctor', 'nurse', 'health_manager'] },
|
||||
{ key: 'today_consultations', label: '今日咨询', initial: '诊', route: '/pages/doctor/consultation/index', roles: ['doctor', 'health_manager'] },
|
||||
];
|
||||
|
||||
const HEALTH_CARDS: CardConfig[] = [
|
||||
{ key: 'pending_dialysis_review', label: '待审透析', initial: '透', route: '/pages/doctor/patients/index' },
|
||||
{ key: 'pending_lab_review', label: '待审化验', initial: '化', route: '/pages/doctor/report/index' },
|
||||
const ALL_HEALTH_CARDS: CardConfig[] = [
|
||||
{ key: 'pending_lab_review', label: '待审化验', initial: '化', route: '/pages/doctor/report/index', roles: ['doctor'] },
|
||||
{ key: 'today_appointments', label: '今日预约', initial: '约', route: '/pages/doctor/patients/index' },
|
||||
];
|
||||
|
||||
const QUICK_ACTIONS = [
|
||||
{ label: '化验审核', initial: '审', route: '/pages/doctor/report/index' },
|
||||
{ label: '患者查询', initial: '查', route: '/pages/doctor/patients/index' },
|
||||
{ label: '随访记录', initial: '随', route: '/pages/doctor/followup/index' },
|
||||
{ label: '排班查看', initial: '排', route: '/pages/doctor/patients/index' },
|
||||
interface QuickAction {
|
||||
label: string;
|
||||
initial: string;
|
||||
route: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
const ALL_QUICK_ACTIONS: QuickAction[] = [
|
||||
{ label: '化验审核', initial: '审', route: '/pages/doctor/report/index', roles: ['doctor'] },
|
||||
{ label: '患者查询', initial: '查', route: '/pages/doctor/patients/index', roles: ['doctor', 'nurse', 'health_manager'] },
|
||||
{ label: '随访记录', initial: '随', route: '/pages/doctor/followup/index', roles: ['doctor', 'nurse', 'health_manager'] },
|
||||
{ label: '告警中心', initial: '警', route: '/pages/doctor/alerts/index', roles: ['doctor', 'nurse', 'health_manager'] },
|
||||
{ label: '透析管理', initial: '透', route: '/pages/doctor/dialysis/index', roles: ['doctor'] },
|
||||
{ label: '处方管理', initial: '方', route: '/pages/doctor/prescription/index', roles: ['doctor'] },
|
||||
{ label: '行动收件箱', initial: '行', route: '/pages/doctor/action-inbox/index', roles: ['doctor', 'nurse', 'health_manager'] },
|
||||
];
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
doctor: '医生',
|
||||
nurse: '护士',
|
||||
health_manager: '健康管理师',
|
||||
admin: '管理员',
|
||||
operator: '运营',
|
||||
};
|
||||
|
||||
export default function DoctorHome() {
|
||||
const { user, logout } = useAuthStore();
|
||||
const { user, logout, roles } = useAuthStore();
|
||||
const modeClass = useElderClass();
|
||||
const [dashboard, setDashboard] = useState<doctorApi.DoctorDashboard | null>(null);
|
||||
const [alertCount, setAlertCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const hasRole = (allowed: string[] | undefined) => {
|
||||
if (!allowed) return true;
|
||||
return roles.some((r) => r === 'admin' || allowed.includes(r));
|
||||
};
|
||||
|
||||
const cards = useMemo(() => ALL_CARDS.filter((c) => hasRole(c.roles)), [roles]);
|
||||
const healthCards = useMemo(() => ALL_HEALTH_CARDS.filter((c) => hasRole(c.roles)), [roles]);
|
||||
const quickActions = useMemo(() => ALL_QUICK_ACTIONS.filter((a) => hasRole(a.roles)), [roles]);
|
||||
|
||||
const roleLabel = useMemo(() => {
|
||||
const primary = roles.find((r) => r !== 'admin');
|
||||
return primary ? (ROLE_LABELS[primary] || primary) : '医护';
|
||||
}, [roles]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboard();
|
||||
}, []);
|
||||
@@ -73,11 +107,11 @@ export default function DoctorHome() {
|
||||
if (loading) return <Loading />;
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className='doctor-home'>
|
||||
<ScrollView scrollY className={`doctor-home ${modeClass}`}>
|
||||
<View className='doctor-home__header'>
|
||||
<Text className='doctor-home__title'>医护工作台</Text>
|
||||
<Text className='doctor-home__greeting'>
|
||||
{user?.display_name || user?.username || '医生'},您好
|
||||
{user?.display_name || user?.username || roleLabel},您好
|
||||
</Text>
|
||||
<Text className='doctor-home__date'>
|
||||
{new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' })}
|
||||
@@ -88,7 +122,7 @@ export default function DoctorHome() {
|
||||
<View className='doctor-home__alert'>
|
||||
<Text className='doctor-home__alert-icon'>!</Text>
|
||||
<Text className='doctor-home__alert-text'>{alertCount} 位患者体征异常</Text>
|
||||
<Text className='doctor-home__alert-link' onClick={() => Taro.navigateTo({ url: '/pages/doctor/patients/index' })}>查看 →</Text>
|
||||
<Text className='doctor-home__alert-link' onClick={() => Taro.navigateTo({ url: '/pages/doctor/alerts/index' })}>查看 →</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -103,7 +137,7 @@ export default function DoctorHome() {
|
||||
<View className='doctor-home__section'>
|
||||
<Text className='doctor-home__section-title'>工作概览</Text>
|
||||
<View className='doctor-home__grid'>
|
||||
{CARDS.map((card) => (
|
||||
{cards.map((card) => (
|
||||
<View
|
||||
key={card.key}
|
||||
className='doctor-home__card'
|
||||
@@ -117,10 +151,10 @@ export default function DoctorHome() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='doctor-home__section'>
|
||||
{healthCards.length > 0 && (<View className='doctor-home__section'>
|
||||
<Text className='doctor-home__section-title'>健康审核</Text>
|
||||
<View className='doctor-home__grid'>
|
||||
{HEALTH_CARDS.map((card) => (
|
||||
{healthCards.map((card) => (
|
||||
<View
|
||||
key={card.key}
|
||||
className='doctor-home__card'
|
||||
@@ -132,18 +166,23 @@ export default function DoctorHome() {
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>)}
|
||||
|
||||
<View className='doctor-home__section'>
|
||||
<Text className='doctor-home__section-title'>快捷操作</Text>
|
||||
<View className='doctor-home__quick-actions'>
|
||||
{QUICK_ACTIONS.map((action) => (
|
||||
{quickActions.map((action) => (
|
||||
<View
|
||||
key={action.route}
|
||||
className='quick-action'
|
||||
onClick={() => Taro.navigateTo({ url: action.route })}
|
||||
>
|
||||
<Text className='quick-action__initial'>{action.initial}</Text>
|
||||
<View className='quick-action__icon-wrap'>
|
||||
<Text className='quick-action__initial'>{action.initial}</Text>
|
||||
{action.label === '告警中心' && alertCount > 0 && (
|
||||
<Text className='quick-action__badge'>{alertCount > 99 ? '99+' : alertCount}</Text>
|
||||
)}
|
||||
</View>
|
||||
<Text className='quick-action__label'>{action.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
|
||||
@@ -33,12 +33,12 @@
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -51,7 +51,7 @@
|
||||
}
|
||||
|
||||
.warning-label {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $wrn;
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
@@ -59,7 +59,7 @@
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $pri-d;
|
||||
}
|
||||
|
||||
@@ -68,14 +68,14 @@
|
||||
}
|
||||
|
||||
.info-block-label {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.info-block-text {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -96,7 +96,7 @@
|
||||
|
||||
.vital-value {
|
||||
@include serif-number;
|
||||
font-size: 36px;
|
||||
font-size: var(--tk-font-num-lg);
|
||||
font-weight: 700;
|
||||
color: $pri;
|
||||
display: block;
|
||||
@@ -104,7 +104,7 @@
|
||||
}
|
||||
|
||||
.vital-label {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
@@ -116,13 +116,13 @@
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
@include serif-number;
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
|
||||
@@ -151,18 +151,18 @@
|
||||
}
|
||||
|
||||
&__type {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: 500;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
&__date {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&__abnormal {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $dan;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -180,7 +180,7 @@
|
||||
border-radius: $r;
|
||||
background: $pri;
|
||||
color: $card;
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: 500;
|
||||
|
||||
&:active {
|
||||
@@ -196,5 +196,5 @@
|
||||
text-align: center;
|
||||
padding: 80px 32px;
|
||||
color: $tx3;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@ import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import * as doctorApi from '@/services/doctor';
|
||||
import Loading from '@/components/Loading';
|
||||
import { useElderClass } from '../../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
export default function PatientDetail() {
|
||||
const router = useRouter();
|
||||
const patientId = router.params.id || '';
|
||||
const modeClass = useElderClass();
|
||||
const [patient, setPatient] = useState<doctorApi.PatientDetail | null>(null);
|
||||
const [summary, setSummary] = useState<doctorApi.HealthSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -40,10 +42,10 @@ export default function PatientDetail() {
|
||||
};
|
||||
|
||||
if (loading) return <Loading />;
|
||||
if (!patient) return <View className='error-text'><Text>患者信息加载失败</Text></View>;
|
||||
if (!patient) return <View className={`error-text ${modeClass}`}><Text>患者信息加载失败</Text></View>;
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className='patient-detail'>
|
||||
<ScrollView scrollY className={`patient-detail ${modeClass}`}>
|
||||
{/* 基本信息 */}
|
||||
<View className='section'>
|
||||
<View className='section-header'>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px 24px;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
box-shadow: $shadow-sm;
|
||||
@@ -33,7 +33,7 @@
|
||||
padding: 10px 24px;
|
||||
border-radius: $r-pill;
|
||||
background: $bd-l;
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
margin-right: 16px;
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
margin-bottom: 16px;
|
||||
|
||||
text {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
@@ -75,14 +75,14 @@
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 30px;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@
|
||||
background: $pri-l;
|
||||
|
||||
&__text {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
margin-top: 32px;
|
||||
|
||||
&__btn {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $pri;
|
||||
padding: 12px 24px;
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
}
|
||||
|
||||
&__info {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { View, Text, Input, ScrollView } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import Taro, { usePullDownRefresh, useReachBottom } from '@tarojs/taro';
|
||||
import * as doctorApi from '@/services/doctor';
|
||||
import Loading from '@/components/Loading';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
export default function PatientList() {
|
||||
const modeClass = useElderClass();
|
||||
const [patients, setPatients] = useState<doctorApi.PatientItem[]>([]);
|
||||
const [tags, setTags] = useState<doctorApi.PatientTag[]>([]);
|
||||
const [activeTag, setActiveTag] = useState<string>('');
|
||||
@@ -14,14 +16,15 @@ export default function PatientList() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadTags();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadPatients();
|
||||
}, [page, activeTag]);
|
||||
loadPatients(1, true);
|
||||
}, [activeTag]);
|
||||
|
||||
const loadTags = async () => {
|
||||
try {
|
||||
@@ -30,32 +33,51 @@ export default function PatientList() {
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const loadPatients = async () => {
|
||||
setLoading(true);
|
||||
const loadPatients = async (pageNum: number, isRefresh = false) => {
|
||||
if (loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
if (isRefresh) setLoading(true);
|
||||
try {
|
||||
const res = await doctorApi.listPatients({
|
||||
page,
|
||||
page: pageNum,
|
||||
page_size: 20,
|
||||
search: search || undefined,
|
||||
tag_id: activeTag || undefined,
|
||||
});
|
||||
setPatients(res.data || []);
|
||||
const list = res.data || [];
|
||||
if (isRefresh) {
|
||||
setPatients(list);
|
||||
} else {
|
||||
setPatients((prev) => [...prev, ...list]);
|
||||
}
|
||||
setTotal(res.total || 0);
|
||||
setPage(pageNum);
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
loadingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
loadPatients(1, true).finally(() => {
|
||||
Taro.stopPullDownRefresh();
|
||||
});
|
||||
});
|
||||
|
||||
useReachBottom(() => {
|
||||
if (!loading && patients.length < total) {
|
||||
loadPatients(page + 1);
|
||||
}
|
||||
});
|
||||
|
||||
const handleSearch = () => {
|
||||
setPage(1);
|
||||
loadPatients();
|
||||
loadPatients(1, true);
|
||||
};
|
||||
|
||||
const handleTagFilter = (tagId: string) => {
|
||||
setActiveTag(tagId === activeTag ? '' : tagId);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const getGenderLabel = (gender?: string) => {
|
||||
@@ -78,7 +100,7 @@ export default function PatientList() {
|
||||
if (loading && patients.length === 0) return <Loading />;
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className='patient-list-page'>
|
||||
<ScrollView scrollY className={`patient-list-page ${modeClass}`}>
|
||||
<View className='search-bar'>
|
||||
<Input
|
||||
className='search-input'
|
||||
@@ -154,23 +176,12 @@ export default function PatientList() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{total > 20 && (
|
||||
<View className='pagination'>
|
||||
<Text
|
||||
className={`pagination__btn ${page <= 1 ? 'disabled' : ''}`}
|
||||
onClick={() => page > 1 && setPage(page - 1)}
|
||||
>
|
||||
上一页
|
||||
</Text>
|
||||
<Text className='pagination__info'>{page} / {Math.ceil(total / 20)}</Text>
|
||||
<Text
|
||||
className={`pagination__btn ${page >= Math.ceil(total / 20) ? 'disabled' : ''}`}
|
||||
onClick={() => page < Math.ceil(total / 20) && setPage(page + 1)}
|
||||
>
|
||||
下一页
|
||||
</Text>
|
||||
{!loading && patients.length >= total && total > 0 && (
|
||||
<View style={{ textAlign: 'center', padding: '20px' }}>
|
||||
<Text style={{ fontSize: '24px', color: '#78716C' }}>没有更多了</Text>
|
||||
</View>
|
||||
)}
|
||||
{loading && patients.length > 0 && <Loading />}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
@import '../../../../styles/variables.scss';
|
||||
@import '../../../../styles/mixins.scss';
|
||||
|
||||
.create-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding: 24px;
|
||||
padding-bottom: 200px;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
flex-shrink: 0;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.form-value {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
|
||||
&.placeholder {
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
min-height: 120px;
|
||||
background: $bg;
|
||||
border-radius: $r-sm;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
background: $pri;
|
||||
border-radius: $r-sm;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
|
||||
&:active {
|
||||
background: $pri-d;
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.submit-btn__text {
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
}
|
||||
198
apps/miniprogram/src/pages/doctor/prescription/create/index.tsx
Normal file
198
apps/miniprogram/src/pages/doctor/prescription/create/index.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import { useState } from 'react';
|
||||
import { View, Text, Input, Textarea, Picker, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import * as doctorApi from '@/services/doctor';
|
||||
import Loading from '@/components/Loading';
|
||||
import { useElderClass } from '../../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
interface FormState {
|
||||
dialyzer_model: string;
|
||||
membrane_area: string;
|
||||
dialysate_potassium: string;
|
||||
dialysate_calcium: string;
|
||||
dialysate_bicarbonate: string;
|
||||
anticoagulation_type: string;
|
||||
anticoagulation_dose: string;
|
||||
target_ultrafiltration_ml: string;
|
||||
target_dry_weight: string;
|
||||
blood_flow_rate: string;
|
||||
dialysate_flow_rate: string;
|
||||
frequency_per_week: string;
|
||||
duration_minutes: string;
|
||||
vascular_access_type: string;
|
||||
vascular_access_location: string;
|
||||
effective_from: string;
|
||||
effective_to: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
const initialForm: FormState = {
|
||||
dialyzer_model: '',
|
||||
membrane_area: '',
|
||||
dialysate_potassium: '',
|
||||
dialysate_calcium: '',
|
||||
dialysate_bicarbonate: '',
|
||||
anticoagulation_type: '',
|
||||
anticoagulation_dose: '',
|
||||
target_ultrafiltration_ml: '',
|
||||
target_dry_weight: '',
|
||||
blood_flow_rate: '',
|
||||
dialysate_flow_rate: '',
|
||||
frequency_per_week: '',
|
||||
duration_minutes: '',
|
||||
vascular_access_type: '',
|
||||
vascular_access_location: '',
|
||||
effective_from: '',
|
||||
effective_to: '',
|
||||
notes: '',
|
||||
};
|
||||
|
||||
export default function PrescriptionCreate() {
|
||||
const router = useRouter();
|
||||
const patientId = router.params.patientId || '';
|
||||
const modeClass = useElderClass();
|
||||
const [form, setForm] = useState<FormState>(initialForm);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const updateField = (key: keyof FormState, value: string) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!patientId) {
|
||||
Taro.showToast({ title: '缺少患者信息', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
const num = (v: string) => v ? Number(v) : undefined;
|
||||
const payload = {
|
||||
patient_id: patientId,
|
||||
dialyzer_model: form.dialyzer_model || undefined,
|
||||
membrane_area: num(form.membrane_area),
|
||||
dialysate_potassium: num(form.dialysate_potassium),
|
||||
dialysate_calcium: num(form.dialysate_calcium),
|
||||
dialysate_bicarbonate: num(form.dialysate_bicarbonate),
|
||||
anticoagulation_type: form.anticoagulation_type || undefined,
|
||||
anticoagulation_dose: form.anticoagulation_dose || undefined,
|
||||
target_ultrafiltration_ml: num(form.target_ultrafiltration_ml),
|
||||
target_dry_weight: num(form.target_dry_weight),
|
||||
blood_flow_rate: num(form.blood_flow_rate),
|
||||
dialysate_flow_rate: num(form.dialysate_flow_rate),
|
||||
frequency_per_week: num(form.frequency_per_week),
|
||||
duration_minutes: num(form.duration_minutes),
|
||||
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 || undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
await doctorApi.createDialysisPrescription(payload);
|
||||
Taro.showToast({ title: '创建成功', icon: 'success' });
|
||||
setTimeout(() => Taro.navigateBack(), 1000);
|
||||
} catch {
|
||||
Taro.showToast({ title: '创建失败', icon: 'none' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const InputField = ({ label, field, placeholder, type = 'digit' }: {
|
||||
label: string; field: keyof FormState; placeholder: string; type?: string;
|
||||
}) => (
|
||||
<View className='form-row'>
|
||||
<Text className='form-label'>{label}</Text>
|
||||
<Input
|
||||
className='form-input'
|
||||
type={type as any}
|
||||
placeholder={placeholder}
|
||||
value={form[field]}
|
||||
onInput={(e) => updateField(field, e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className={`create-page ${modeClass}`}>
|
||||
{/* 透析器 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>透析器</Text>
|
||||
<InputField label='透析器型号' field='dialyzer_model' placeholder='请输入型号' type='text' />
|
||||
<InputField label='膜面积' field='membrane_area' placeholder='m²' />
|
||||
</View>
|
||||
|
||||
{/* 透析液 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>透析液配比</Text>
|
||||
<InputField label='钾浓度' field='dialysate_potassium' placeholder='mmol/L' />
|
||||
<InputField label='钙浓度' field='dialysate_calcium' placeholder='mmol/L' />
|
||||
<InputField label='碳酸氢盐' field='dialysate_bicarbonate' placeholder='mmol/L' />
|
||||
</View>
|
||||
|
||||
{/* 抗凝 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>抗凝方案</Text>
|
||||
<InputField label='抗凝类型' field='anticoagulation_type' placeholder='请输入' type='text' />
|
||||
<InputField label='抗凝剂量' field='anticoagulation_dose' placeholder='请输入' type='text' />
|
||||
</View>
|
||||
|
||||
{/* 参数 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>参数设置</Text>
|
||||
<InputField label='目标超滤量' field='target_ultrafiltration_ml' placeholder='ml' type='number' />
|
||||
<InputField label='目标干体重' field='target_dry_weight' placeholder='kg' />
|
||||
<InputField label='血流速' field='blood_flow_rate' placeholder='ml/min' type='number' />
|
||||
<InputField label='透析液流量' field='dialysate_flow_rate' placeholder='ml/min' type='number' />
|
||||
<InputField label='每周频次' field='frequency_per_week' placeholder='次/周' type='number' />
|
||||
<InputField label='每次时长' field='duration_minutes' placeholder='分钟' type='number' />
|
||||
</View>
|
||||
|
||||
{/* 血管通路 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>血管通路</Text>
|
||||
<InputField label='通路类型' field='vascular_access_type' placeholder='请输入' type='text' />
|
||||
<InputField label='通路位置' field='vascular_access_location' placeholder='请输入' type='text' />
|
||||
</View>
|
||||
|
||||
{/* 生效日期 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>生效日期</Text>
|
||||
<View className='form-row'>
|
||||
<Text className='form-label'>生效日期</Text>
|
||||
<Picker mode='date' value={form.effective_from} onChange={(e) => updateField('effective_from', e.detail.value)}>
|
||||
<Text className={`form-value ${!form.effective_from ? 'placeholder' : ''}`}>
|
||||
{form.effective_from || '请选择'}
|
||||
</Text>
|
||||
</Picker>
|
||||
</View>
|
||||
<View className='form-row'>
|
||||
<Text className='form-label'>失效日期</Text>
|
||||
<Picker mode='date' value={form.effective_to} onChange={(e) => updateField('effective_to', e.detail.value)}>
|
||||
<Text className={`form-value ${!form.effective_to ? 'placeholder' : ''}`}>
|
||||
{form.effective_to || '请选择'}
|
||||
</Text>
|
||||
</Picker>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 备注 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>备注</Text>
|
||||
<Textarea
|
||||
className='form-textarea'
|
||||
placeholder='请输入备注...'
|
||||
value={form.notes}
|
||||
onInput={(e) => updateField('notes', e.detail.value)}
|
||||
maxlength={500}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className={`submit-btn ${submitting ? 'submit-btn--disabled' : ''}`} onClick={handleSubmit}>
|
||||
<Text className='submit-btn__text'>{submitting ? '提交中...' : '创建处方'}</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
137
apps/miniprogram/src/pages/doctor/prescription/detail/index.scss
Normal file
137
apps/miniprogram/src/pages/doctor/prescription/detail/index.scss
Normal file
@@ -0,0 +1,137 @@
|
||||
@import '../../../../styles/variables.scss';
|
||||
@import '../../../../styles/mixins.scss';
|
||||
|
||||
.prescription-detail {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding: 24px;
|
||||
padding-bottom: 200px;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.rx-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.rx-header__title {
|
||||
font-size: var(--tk-font-num-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.rx-header__status {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: var(--tk-font-body);
|
||||
background: $bd-l;
|
||||
color: $tx3;
|
||||
|
||||
&--active {
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
}
|
||||
}
|
||||
|
||||
.rx-sub {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
display: block;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
text-align: right;
|
||||
flex: 1;
|
||||
margin-left: 24px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.notes-text {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
text-align: center;
|
||||
padding: 120px 0;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
border-radius: $r-sm;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
|
||||
&--secondary {
|
||||
background: $card;
|
||||
border: 1px solid $bd;
|
||||
|
||||
.action-btn__text {
|
||||
color: $pri;
|
||||
}
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background: $card;
|
||||
border: 1px solid $dan;
|
||||
|
||||
.action-btn__text {
|
||||
color: $dan;
|
||||
}
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn__text {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 500;
|
||||
}
|
||||
163
apps/miniprogram/src/pages/doctor/prescription/detail/index.tsx
Normal file
163
apps/miniprogram/src/pages/doctor/prescription/detail/index.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import * as doctorApi from '@/services/doctor';
|
||||
import Loading from '@/components/Loading';
|
||||
import { useElderClass } from '../../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
export default function PrescriptionDetail() {
|
||||
const router = useRouter();
|
||||
const id = router.params.id || '';
|
||||
const modeClass = useElderClass();
|
||||
const [rx, setRx] = useState<doctorApi.DialysisPrescription | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) loadRx();
|
||||
}, [id]);
|
||||
|
||||
const loadRx = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await doctorApi.getDialysisPrescription(id);
|
||||
setRx(data);
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeactivate = async () => {
|
||||
if (!rx) return;
|
||||
const { confirm } = await Taro.showModal({
|
||||
title: '确认停用',
|
||||
content: '停用后该处方将不再生效,确定停用吗?',
|
||||
});
|
||||
if (!confirm) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const updated = await doctorApi.updateDialysisPrescription(id, { status: 'inactive' }, rx.version);
|
||||
setRx(updated);
|
||||
Taro.showToast({ title: '已停用', icon: 'success' });
|
||||
} catch {
|
||||
Taro.showToast({ title: '操作失败', icon: 'none' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!rx) return;
|
||||
const { confirm } = await Taro.showModal({
|
||||
title: '确认删除',
|
||||
content: '删除后不可恢复,确定要删除这条处方吗?',
|
||||
});
|
||||
if (!confirm) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await doctorApi.deleteDialysisPrescription(id, rx.version);
|
||||
Taro.showToast({ title: '已删除', icon: 'success' });
|
||||
setTimeout(() => Taro.navigateBack(), 1000);
|
||||
} catch {
|
||||
Taro.showToast({ title: '删除失败', icon: 'none' });
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const Row = ({ label, value, unit }: { label: string; value?: string | number | null; unit?: string }) => {
|
||||
if (value == null) return null;
|
||||
return (
|
||||
<View className='detail-row'>
|
||||
<Text className='detail-label'>{label}</Text>
|
||||
<Text className='detail-value'>{value}{unit || ''}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) return <Loading />;
|
||||
if (!rx) return <View className={`error-text ${modeClass}`}><Text>处方加载失败</Text></View>;
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className={`prescription-detail ${modeClass}`}>
|
||||
{/* 状态头部 */}
|
||||
<View className='section'>
|
||||
<View className='rx-header'>
|
||||
<Text className='rx-header__title'>{rx.dialyzer_model || '透析处方'}</Text>
|
||||
<Text className={`rx-header__status rx-header__status--${rx.status}`}>
|
||||
{rx.status === 'active' ? '生效中' : rx.status === 'inactive' ? '已停用' : rx.status}
|
||||
</Text>
|
||||
</View>
|
||||
{(rx.effective_from || rx.effective_to) && (
|
||||
<Text className='rx-sub'>{rx.effective_from || '...'} ~ {rx.effective_to || '...'}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 基本参数 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>基本参数</Text>
|
||||
<Row label='透析器型号' value={rx.dialyzer_model} />
|
||||
<Row label='膜面积' value={rx.membrane_area != null ? `${rx.membrane_area}` : null} unit=' m²' />
|
||||
<Row label='血流速' value={rx.blood_flow_rate} unit=' ml/min' />
|
||||
<Row label='透析液流量' value={rx.dialysate_flow_rate} unit=' ml/min' />
|
||||
<Row label='频率' value={rx.frequency_per_week != null ? `${rx.frequency_per_week} 次/周` : null} />
|
||||
<Row label='每次时长' value={rx.duration_minutes} unit=' 分钟' />
|
||||
</View>
|
||||
|
||||
{/* 透析液配比 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>透析液配比</Text>
|
||||
<Row label='钾浓度' value={rx.dialysate_potassium} unit=' mmol/L' />
|
||||
<Row label='钙浓度' value={rx.dialysate_calcium} unit=' mmol/L' />
|
||||
<Row label='碳酸氢盐' value={rx.dialysate_bicarbonate} unit=' mmol/L' />
|
||||
</View>
|
||||
|
||||
{/* 抗凝方案 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>抗凝方案</Text>
|
||||
<Row label='抗凝类型' value={rx.anticoagulation_type} />
|
||||
<Row label='抗凝剂量' value={rx.anticoagulation_dose} />
|
||||
</View>
|
||||
|
||||
{/* 血管通路 */}
|
||||
{(rx.vascular_access_type || rx.vascular_access_location) && (
|
||||
<View className='section'>
|
||||
<Text className='section-title'>血管通路</Text>
|
||||
<Row label='通路类型' value={rx.vascular_access_type} />
|
||||
<Row label='通路位置' value={rx.vascular_access_location} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 超滤目标 */}
|
||||
{(rx.target_ultrafiltration_ml != null || rx.target_dry_weight != null) && (
|
||||
<View className='section'>
|
||||
<Text className='section-title'>超滤目标</Text>
|
||||
<Row label='目标超滤量' value={rx.target_ultrafiltration_ml} unit=' ml' />
|
||||
<Row label='目标干体重' value={rx.target_dry_weight} unit=' kg' />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 备注 */}
|
||||
{rx.notes && (
|
||||
<View className='section'>
|
||||
<Text className='section-title'>备注</Text>
|
||||
<Text className='notes-text'>{rx.notes}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<View className='actions'>
|
||||
{rx.status === 'active' && (
|
||||
<View className={`action-btn action-btn--secondary ${submitting ? 'action-btn--disabled' : ''}`} onClick={handleDeactivate}>
|
||||
<Text className='action-btn__text'>停用处方</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className='action-btn action-btn--danger' onClick={handleDelete}>
|
||||
<Text className='action-btn__text'>删除</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
176
apps/miniprogram/src/pages/doctor/prescription/index.scss
Normal file
176
apps/miniprogram/src/pages/doctor/prescription/index.scss
Normal file
@@ -0,0 +1,176 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
.prescription-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
padding: 16px 24px;
|
||||
background: $card;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
background: $bg;
|
||||
border-radius: $r-sm;
|
||||
padding: 16px 20px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
padding: 0 24px;
|
||||
background: $card;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
position: relative;
|
||||
|
||||
&--active {
|
||||
.tab-text {
|
||||
color: $pri;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 25%;
|
||||
right: 25%;
|
||||
height: 4px;
|
||||
background: $pri;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-text {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.prescription-list {
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.prescription-count {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
padding: 8px 0 16px;
|
||||
}
|
||||
|
||||
.prescription-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&:active {
|
||||
box-shadow: $shadow-md;
|
||||
}
|
||||
}
|
||||
|
||||
.prescription-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.prescription-card__model {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: var(--tk-font-body);
|
||||
background: $bd-l;
|
||||
color: $tx3;
|
||||
|
||||
&--active {
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
}
|
||||
}
|
||||
|
||||
.prescription-card__body {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.prescription-card__meta {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.prescription-card__date {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
display: block;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
padding: 12px 24px;
|
||||
background: $card;
|
||||
border-radius: $r-sm;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $pri;
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 32px;
|
||||
bottom: 120px;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 48px;
|
||||
background: $pri;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: $shadow-md;
|
||||
z-index: 10;
|
||||
|
||||
&:active {
|
||||
background: $pri-d;
|
||||
}
|
||||
}
|
||||
|
||||
.fab-text {
|
||||
font-size: var(--tk-font-hero);
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
}
|
||||
165
apps/miniprogram/src/pages/doctor/prescription/index.tsx
Normal file
165
apps/miniprogram/src/pages/doctor/prescription/index.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { View, Text, Input, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import * as doctorApi from '@/services/doctor';
|
||||
import Loading from '@/components/Loading';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
const TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'active', label: '生效中' },
|
||||
{ key: 'inactive', label: '已停用' },
|
||||
];
|
||||
|
||||
export default function PrescriptionList() {
|
||||
const router = useRouter();
|
||||
const patientId = router.params.patientId || '';
|
||||
const modeClass = useElderClass();
|
||||
const [searchPatient, setSearchPatient] = useState('');
|
||||
const [currentPatientId, setCurrentPatientId] = useState(patientId);
|
||||
const [activeTab, setActiveTab] = useState('');
|
||||
const [prescriptions, setPrescriptions] = useState<doctorApi.DialysisPrescription[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
loadData(1);
|
||||
}, [currentPatientId, activeTab]);
|
||||
|
||||
const loadData = async (p: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await doctorApi.listDialysisPrescriptions({
|
||||
patient_id: currentPatientId || undefined,
|
||||
status: activeTab || undefined,
|
||||
page: p,
|
||||
page_size: 20,
|
||||
});
|
||||
setPrescriptions(res.data || []);
|
||||
setTotal(res.total || 0);
|
||||
setPage(p);
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchPatient.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await doctorApi.listPatients({ search: searchPatient.trim(), page: 1, page_size: 1 });
|
||||
if (res.data && res.data.length > 0) {
|
||||
setCurrentPatientId(res.data[0].id);
|
||||
} else {
|
||||
Taro.showToast({ title: '未找到患者', icon: 'none' });
|
||||
}
|
||||
} catch {
|
||||
Taro.showToast({ title: '搜索失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && prescriptions.length === 0) return <Loading />;
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className={`prescription-page ${modeClass}`}>
|
||||
{!patientId && (
|
||||
<View className='search-bar'>
|
||||
<Input
|
||||
className='search-input'
|
||||
placeholder='搜索患者姓名'
|
||||
value={searchPatient}
|
||||
onInput={(e) => setSearchPatient(e.detail.value)}
|
||||
confirmType='search'
|
||||
onConfirm={handleSearch}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className='tabs'>
|
||||
{TABS.map((t) => (
|
||||
<View
|
||||
key={t.key}
|
||||
className={`tab ${activeTab === t.key ? 'tab--active' : ''}`}
|
||||
onClick={() => { setActiveTab(t.key); setPage(1); }}
|
||||
>
|
||||
<Text className='tab-text'>{t.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{prescriptions.length === 0 ? (
|
||||
<EmptyState text='暂无透析处方' />
|
||||
) : (
|
||||
<View className='prescription-list'>
|
||||
<View className='prescription-count'><Text>共 {total} 条处方</Text></View>
|
||||
{prescriptions.map((p) => (
|
||||
<View
|
||||
key={p.id}
|
||||
className='prescription-card'
|
||||
onClick={() => Taro.navigateTo({
|
||||
url: `/pages/doctor/prescription/detail/index?id=${p.id}`,
|
||||
})}
|
||||
>
|
||||
<View className='prescription-card__header'>
|
||||
<Text className='prescription-card__model'>{p.dialyzer_model || '透析处方'}</Text>
|
||||
<Text className={`status-tag status-tag--${p.status}`}>
|
||||
{p.status === 'active' ? '生效中' : p.status === 'inactive' ? '已停用' : p.status}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='prescription-card__body'>
|
||||
{p.frequency_per_week != null && (
|
||||
<Text className='prescription-card__meta'>{p.frequency_per_week}次/周</Text>
|
||||
)}
|
||||
{p.duration_minutes != null && (
|
||||
<Text className='prescription-card__meta'>每次{p.duration_minutes}分钟</Text>
|
||||
)}
|
||||
</View>
|
||||
{(p.effective_from || p.effective_to) && (
|
||||
<Text className='prescription-card__date'>
|
||||
{p.effective_from || '...'} ~ {p.effective_to || '...'}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
{total > 20 && (
|
||||
<View className='pagination'>
|
||||
<View
|
||||
className={`page-btn ${page <= 1 ? 'page-btn--disabled' : ''}`}
|
||||
onClick={() => page > 1 && loadData(page - 1)}
|
||||
>
|
||||
<Text>上一页</Text>
|
||||
</View>
|
||||
<Text className='page-info'>{page} / {Math.ceil(total / 20)}</Text>
|
||||
<View
|
||||
className={`page-btn ${page * 20 >= total ? 'page-btn--disabled' : ''}`}
|
||||
onClick={() => page * 20 < total && loadData(page + 1)}
|
||||
>
|
||||
<Text>下一页</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View
|
||||
className='fab'
|
||||
onClick={() => {
|
||||
if (!currentPatientId) {
|
||||
Taro.showToast({ title: '请先选择患者', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
Taro.navigateTo({ url: `/pages/doctor/prescription/create/index?patientId=${currentPatientId}` });
|
||||
}}
|
||||
>
|
||||
<Text className='fab-text'>+</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -28,13 +28,13 @@
|
||||
|
||||
&__type {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 32px;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
&__status {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
padding: 6px 16px;
|
||||
border-radius: $r;
|
||||
font-weight: 500;
|
||||
@@ -45,13 +45,13 @@
|
||||
}
|
||||
|
||||
.report-date {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.review-info {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $acc;
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
@@ -81,7 +81,7 @@
|
||||
}
|
||||
|
||||
.indicator-cell {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
|
||||
&--name {
|
||||
flex: 2;
|
||||
@@ -116,7 +116,7 @@
|
||||
}
|
||||
|
||||
.indicator-row--header & {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
font-weight: 400;
|
||||
}
|
||||
@@ -129,7 +129,7 @@
|
||||
}
|
||||
|
||||
.notes-text {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -140,7 +140,7 @@
|
||||
background: $bd-l;
|
||||
border-radius: $r;
|
||||
padding: 20px;
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
box-sizing: border-box;
|
||||
line-height: 1.6;
|
||||
@@ -158,7 +158,7 @@
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $card;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -168,5 +168,5 @@
|
||||
text-align: center;
|
||||
padding: 80px 32px;
|
||||
color: $tx3;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
}
|
||||
|
||||
@@ -3,12 +3,14 @@ import { View, Text, Textarea, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import * as doctorApi from '@/services/doctor';
|
||||
import Loading from '@/components/Loading';
|
||||
import { useElderClass } from '../../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
export default function ReportDetail() {
|
||||
const router = useRouter();
|
||||
const patientId = router.params.patientId || '';
|
||||
const reportId = router.params.id || '';
|
||||
const modeClass = useElderClass();
|
||||
const [report, setReport] = useState<doctorApi.LabReportDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [doctorNotes, setDoctorNotes] = useState('');
|
||||
@@ -51,10 +53,10 @@ export default function ReportDetail() {
|
||||
const formatDate = (d: string) => new Date(d).toLocaleDateString('zh-CN');
|
||||
|
||||
if (loading) return <Loading />;
|
||||
if (!report) return <View className='error-text'><Text>报告加载失败</Text></View>;
|
||||
if (!report) return <View className={`error-text ${modeClass}`}><Text>报告加载失败</Text></View>;
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className='report-detail'>
|
||||
<ScrollView scrollY className={`report-detail ${modeClass}`}>
|
||||
{/* 基本信息 */}
|
||||
<View className='section'>
|
||||
<View className='report-header'>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px 24px;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
box-shadow: $shadow-sm;
|
||||
@@ -26,7 +26,7 @@
|
||||
margin-bottom: 16px;
|
||||
|
||||
text {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
@@ -56,13 +56,13 @@
|
||||
|
||||
&__type {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
&__date {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
@@ -73,13 +73,13 @@
|
||||
}
|
||||
|
||||
&__abnormal {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $dan;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__normal {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $acc;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,13 @@ import Taro, { useRouter } from '@tarojs/taro';
|
||||
import * as doctorApi from '@/services/doctor';
|
||||
import Loading from '@/components/Loading';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
export default function ReportList() {
|
||||
const router = useRouter();
|
||||
const patientId = router.params.patientId || '';
|
||||
const modeClass = useElderClass();
|
||||
const [searchPatient, setSearchPatient] = useState('');
|
||||
const [currentPatientId, setCurrentPatientId] = useState(patientId);
|
||||
const [reports, setReports] = useState<doctorApi.LabReportItem[]>([]);
|
||||
@@ -55,7 +57,7 @@ export default function ReportList() {
|
||||
if (loading && reports.length === 0) return <Loading />;
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className='report-page'>
|
||||
<ScrollView scrollY className={`report-page ${modeClass}`}>
|
||||
{!patientId && (
|
||||
<View className='search-bar'>
|
||||
<Input
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user