Compare commits
419 Commits
2defbd7ab3
...
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 | ||
|
|
e7b2e6382a | ||
|
|
8a5b14e087 | ||
|
|
83e243f03e | ||
|
|
679d83d3b6 | ||
|
|
40a71e5a1c | ||
|
|
0aab27295c | ||
|
|
ace04ee56d | ||
|
|
26aa66d6e3 | ||
|
|
50e63530d9 | ||
|
|
dde6b09017 | ||
|
|
5941a6b764 | ||
|
|
75cd305996 | ||
|
|
ac1033dbaf | ||
|
|
fa9278590d | ||
|
|
e00c2abdcd | ||
|
|
147fd886e3 | ||
|
|
96c9a8ada9 | ||
|
|
ade8497c2d | ||
|
|
be8fca1d76 | ||
|
|
10755cde0e | ||
|
|
e03a2be1b6 | ||
|
|
fcfc0ba5d9 | ||
|
|
1bece3d41f | ||
|
|
b7b09c0727 | ||
|
|
80b99dba46 | ||
|
|
644efce760 | ||
|
|
298e439fb2 | ||
|
|
3284a59c55 | ||
|
|
988f6cd6a5 | ||
|
|
c556bda82b | ||
|
|
aa5b26bf12 | ||
|
|
755d95480e | ||
|
|
92486cad8e | ||
|
|
f93321bd56 | ||
|
|
8edbe7be7b | ||
|
|
0e45778fc3 | ||
|
|
852a429ef3 | ||
|
|
24c7f9451f | ||
|
|
3d787adceb | ||
|
|
1e7a5f5498 | ||
|
|
7dcb324abe | ||
|
|
2f42ebff1d | ||
|
|
35d4f6c843 | ||
|
|
4cfbdec5fc | ||
|
|
5b47f13ecf | ||
|
|
16a776c213 | ||
|
|
ca32be59be | ||
|
|
1404cc8f1a | ||
|
|
a66d59e86b | ||
|
|
d1d8079494 | ||
|
|
1e6e783fcc | ||
|
|
9dd6095e77 | ||
|
|
3d34e021a9 | ||
|
|
1265935fa3 | ||
|
|
11777e3b68 | ||
|
|
30f2452933 | ||
|
|
e56cd73e49 | ||
|
|
50eae8b809 | ||
|
|
fbb28e655d | ||
|
|
83162817ce | ||
|
|
3177a704ff | ||
|
|
5aec02e4ad | ||
|
|
2d5b6d4c50 | ||
|
|
f58f1f73c5 | ||
|
|
7420a66291 | ||
|
|
c53f5625bc | ||
|
|
e1d9f97d79 | ||
|
|
fdbbc47a60 | ||
|
|
dc09cc4e2a | ||
|
|
55a7d7a03e | ||
|
|
3aaa0a9598 | ||
|
|
88d01b5d84 | ||
|
|
6997bb1d90 | ||
|
|
41af241238 | ||
|
|
fdceed7284 | ||
|
|
22ef5b6d1f | ||
|
|
633bf8c62d | ||
|
|
d5c9654370 | ||
|
|
bcaeb0beef | ||
|
|
b7b9f50d00 | ||
|
|
3197dde33c | ||
|
|
97bb592688 | ||
|
|
d31d7beb1f | ||
|
|
8d55d98f4f | ||
|
|
13b23e90f4 | ||
|
|
dc5879228e | ||
|
|
ca96310a84 | ||
|
|
19cb2bf8bf | ||
|
|
a36720cbbc | ||
|
|
a5646ddbb3 | ||
|
|
2519ad8fee | ||
|
|
a4daa8f49c | ||
|
|
a2c1b5ece8 | ||
|
|
a1bc62cd5e | ||
|
|
7c0f0ce906 | ||
|
|
bab0d6619b | ||
|
|
67f2d07809 | ||
|
|
7e66561a5f | ||
|
|
6a7d83ec4d | ||
|
|
47df2e2aa6 | ||
|
|
af44476c0f | ||
|
|
1c7184b6bc | ||
|
|
0929825ae7 | ||
|
|
0a387c189a | ||
|
|
04c5f3c0d5 | ||
|
|
f934ca0eaf | ||
|
|
c6856370c6 | ||
|
|
4a5dbaeaeb | ||
|
|
432f6e3554 | ||
|
|
c09f6ecdc8 | ||
|
|
59a22e762d | ||
|
|
587f51c0c1 | ||
|
|
d460316d23 | ||
|
|
c314093c76 | ||
|
|
b410fa9f78 | ||
|
|
215fb35e0e | ||
|
|
d1ab8074a3 | ||
|
|
5f83080ab8 | ||
|
|
3424a33b6b |
78
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
name: Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backend-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: 123123
|
||||||
|
POSTGRES_DB: postgres
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
|
||||||
|
env:
|
||||||
|
TEST_DB_URL: postgres://postgres:123123@localhost:5432/postgres
|
||||||
|
JWT_SECRET: test-jwt-secret-for-ci
|
||||||
|
DATABASE_URL: postgres://postgres:123123@localhost:5432/erp_ci
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
|
- name: Check
|
||||||
|
run: cargo check --workspace
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: cargo test --workspace --lib --bins -- --test-threads=2
|
||||||
|
|
||||||
|
- name: Run integration tests
|
||||||
|
run: cargo test -p erp-server --test integration -- --test-threads=1
|
||||||
|
|
||||||
|
- name: Clippy
|
||||||
|
run: cargo clippy --workspace -- -D warnings
|
||||||
|
|
||||||
|
frontend-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: apps/web
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: pnpm
|
||||||
|
cache-dependency-path: apps/web/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: TypeScript check
|
||||||
|
run: npx tsc --noEmit
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: pnpm test -- --run
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
29
.gitignore
vendored
@@ -35,3 +35,32 @@ docs/debug-*.png
|
|||||||
# Development env
|
# Development env
|
||||||
.env.development
|
.env.development
|
||||||
docker/docker-compose.override.yml
|
docker/docker-compose.override.yml
|
||||||
|
.agents/skills/
|
||||||
|
.claude/skills/
|
||||||
|
.kiro/skills/
|
||||||
|
.trae/skills/
|
||||||
|
.windsurf/skills/
|
||||||
|
skills/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
.logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Playwright reports
|
||||||
|
**/playwright-report/
|
||||||
|
|
||||||
|
# Plans
|
||||||
|
plans/
|
||||||
|
|
||||||
|
# MCP config
|
||||||
|
.mcp.json
|
||||||
|
|
||||||
|
# Superpowers temp
|
||||||
|
.superpowers/brainstorm/
|
||||||
|
|
||||||
|
# Test temp files
|
||||||
|
.test_token*
|
||||||
|
chi_sim.traineddata
|
||||||
|
|
||||||
|
# Local settings
|
||||||
|
.claude/settings.local.json
|
||||||
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',
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
warning: unused import: `PluginError`
|
|
||||||
--> crates\erp-plugin\src\plugin_validator.rs:1:20
|
|
||||||
|
|
|
||||||
1 | use crate::error::{PluginError, PluginResult};
|
|
||||||
| ^^^^^^^^^^^
|
|
||||||
|
|
|
||||||
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
|
|
||||||
|
|
||||||
warning: unused import: `parse_manifest`
|
|
||||||
--> crates\erp-plugin\src\plugin_validator.rs:2:23
|
|
||||||
|
|
|
||||||
2 | use crate::manifest::{parse_manifest, PluginManifest};
|
|
||||||
| ^^^^^^^^^^^^^^
|
|
||||||
|
|
||||||
warning: field `chk` is never read
|
|
||||||
--> crates\erp-plugin\src\data_service.rs:445:39
|
|
||||||
|
|
|
||||||
445 | struct RefCheck { chk: Option<i32> }
|
|
||||||
| -------- ^^^
|
|
||||||
| |
|
|
||||||
| field in this struct
|
|
||||||
|
|
|
||||||
= note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default
|
|
||||||
|
|
||||||
warning: field `chk` is never read
|
|
||||||
--> crates\erp-plugin\src\data_service.rs:684:51
|
|
||||||
|
|
|
||||||
684 | ... struct RefCheck { chk: Option<i32> }
|
|
||||||
| -------- ^^^
|
|
||||||
| |
|
|
||||||
| field in this struct
|
|
||||||
|
|
||||||
warning: field `check_result` is never read
|
|
||||||
--> crates\erp-plugin\src\data_service.rs:1329:30
|
|
||||||
|
|
|
||||||
1329 | struct ExistsCheck { check_result: Option<i32> }
|
|
||||||
| ----------- ^^^^^^^^^^^^
|
|
||||||
| |
|
|
||||||
| field in this struct
|
|
||||||
|
|
||||||
warning: `erp-plugin` (lib) generated 5 warnings (run `cargo fix --lib -p erp-plugin` to apply 2 suggestions)
|
|
||||||
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.73s
|
|
||||||
Running `target\debug\erp-server.exe`
|
|
||||||
Error: configuration file "config/default" not found
|
|
||||||
error: process didn't exit successfully: `target\debug\erp-server.exe` (exit code: 1)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
10056
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
|
|
||||||
> web@0.0.0 dev G:\erp\apps\web
|
|
||||||
> vite "--" "--strictPort"
|
|
||||||
|
|
||||||
Port 5174 is in use, trying another one...
|
|
||||||
|
|
||||||
[32m[1mVITE[22m v8.0.8[39m [2mready in [0m[1m316[22m[2m[0m ms[22m
|
|
||||||
|
|
||||||
[32m➜[39m [1mLocal[22m: [36mhttp://localhost:[1m5175[22m/[39m
|
|
||||||
[2m [32m➜[39m [1mNetwork[22m[2m: use [22m[1m--host[22m[2m to expose[22m
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
50960
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"reason":"owner process exited","timestamp":1776267637695}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
<h2>客户管理插件 — 三种实现方案</h2>
|
|
||||||
<p class="subtitle">选择最适合项目架构和交付目标的实现路径</p>
|
|
||||||
|
|
||||||
<div class="options">
|
|
||||||
<div class="option" data-choice="a" onclick="toggleSelect(this)">
|
|
||||||
<div class="letter">A</div>
|
|
||||||
<div class="content">
|
|
||||||
<h3>纯 WASM 插件 + 增强插件 UI 引擎</h3>
|
|
||||||
<p><strong>数据层:</strong>全部通过 WASM Host API,5 个动态表存储在 JSONB</p>
|
|
||||||
<p><strong>UI 层:</strong>扩展 PluginCRUDPage,新增 ui_widget 类型(tree、graph、timeline)</p>
|
|
||||||
<p><strong>关系图谱:</strong>新增 "graph" ui_widget,前端用 D3/AntV G6 渲染</p>
|
|
||||||
<div class="pros-cons">
|
|
||||||
<div class="pros"><h4>优势</h4><ul>
|
|
||||||
<li>完全在插件架构内,验证插件系统能力</li>
|
|
||||||
<li>新增的 ui_widget 可被所有未来插件复用</li>
|
|
||||||
<li>动态安装/卸载,热插拔</li>
|
|
||||||
</ul></div>
|
|
||||||
<div class="cons"><h4>劣势</h4><ul>
|
|
||||||
<li>JSONB 查询性能弱于原生列</li>
|
|
||||||
<li>复杂关系查询需多次 Host API 调用</li>
|
|
||||||
<li>ui_widget 扩展工作量大(3-4 种新控件)</li>
|
|
||||||
</ul></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="option" data-choice="b" onclick="toggleSelect(this)">
|
|
||||||
<div class="letter">B</div>
|
|
||||||
<div class="content">
|
|
||||||
<h3>WASM 插件数据层 + 专用前端页面(推荐)</h3>
|
|
||||||
<p><strong>数据层:</strong>WASM 插件管理 5 个实体,通过 Host API 操作</p>
|
|
||||||
<p><strong>UI 层:</strong>前端新增 CRM 专用页面组件(客户列表、联系人、沟通时间线、关系图谱)</p>
|
|
||||||
<p><strong>路由:</strong>插件的 manifest.ui.pages 声明自定义页面,前端按 pluginId 匹配加载</p>
|
|
||||||
<div class="pros-cons">
|
|
||||||
<div class="pros"><h4>优势</h4><ul>
|
|
||||||
<li>最佳 UX — 专为 CRM 设计的交互</li>
|
|
||||||
<li>关系图谱可用专业图表库(AntV G6)</li>
|
|
||||||
<li>数据层仍验证 WASM 插件系统</li>
|
|
||||||
<li>前后端分离,UI 可独立迭代</li>
|
|
||||||
</ul></div>
|
|
||||||
<div class="cons"><h4>劣势</h4><ul>
|
|
||||||
<li>每增加一个插件需写前端代码</li>
|
|
||||||
<li>插件 UI 不能完全动态化</li>
|
|
||||||
<li>数据查询仍受 Host API 限制</li>
|
|
||||||
</ul></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="option" data-choice="c" onclick="toggleSelect(this)">
|
|
||||||
<div class="letter">C</div>
|
|
||||||
<div class="content">
|
|
||||||
<h3>内置 erp-crm crate + 独立前端</h3>
|
|
||||||
<p><strong>数据层:</strong>新建 erp-crm crate,直接 SeaORM Entity,原生列存储</p>
|
|
||||||
<p><strong>UI 层:</strong>独立前端页面,直接调用 CRM API</p>
|
|
||||||
<p><strong>关系图谱:</strong>数据库原生支持关系查询,前端 AntV G6</p>
|
|
||||||
<div class="pros-cons">
|
|
||||||
<div class="pros"><h4>优势</h4><ul>
|
|
||||||
<li>性能最优 — 原生 SQL + 索引</li>
|
|
||||||
<li>复杂关系查询简单高效</li>
|
|
||||||
<li>完全控制数据模型</li>
|
|
||||||
</ul></div>
|
|
||||||
<div class="cons"><h4>劣势</h4><ul>
|
|
||||||
<li>不走插件架构,违背"插件优先"原则</li>
|
|
||||||
<li>编译时耦合,不能动态安装</li>
|
|
||||||
<li>不适合作为"第一个插件"验证目标</li>
|
|
||||||
</ul></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section" style="margin-top: 2rem; padding: 1.5rem; background: var(--card); border-radius: 12px; border-left: 4px solid #1677ff;">
|
|
||||||
<h3>💡 推荐方案:B(WASM 数据层 + 专用前端)</h3>
|
|
||||||
<p style="line-height: 1.8;">
|
|
||||||
作为"第一个插件",方案 B 在<strong>验证插件系统</strong>和<strong>交付可用产品</strong>之间取得最佳平衡:
|
|
||||||
数据层走 WASM Host API 验证插件架构的可行性,UI 层不受 schema 驱动的限制,
|
|
||||||
可以提供真正好用的 CRM 交互体验。同时为未来的插件 UI 定制建立模式(manifest.ui.pages 的前端路由匹配机制)。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"reason":"owner process exited","timestamp":1776364845649}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
{"type":"server-started","port":55597,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:55597","screen_dir":"g:/erp/.superpowers/brainstorm/4473-1776364785"}
|
|
||||||
{"type":"server-stopped","reason":"owner process exited"}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
4481
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
<h2>CRM 插件专家组头脑风暴 — 综合发现</h2>
|
|
||||||
<p class="subtitle">6 个专家组深度分析结果 · 28 项发现 · 4 个 Critical 级别</p>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3>严重程度分布</h3>
|
|
||||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:16px 0">
|
|
||||||
<div style="background:#FEE2E2;border:1px solid #FCA5A5;border-radius:8px;padding:12px;text-align:center">
|
|
||||||
<div style="font-size:28px;font-weight:700;color:#DC2626">4</div>
|
|
||||||
<div style="font-size:13px;color:#991B1B;margin-top:4px">Critical</div>
|
|
||||||
<div style="font-size:11px;color:#B91C1C;margin-top:2px">必须立即修复</div>
|
|
||||||
</div>
|
|
||||||
<div style="background:#FEF3C7;border:1px solid #FDE68A;border-radius:8px;padding:12px;text-align:center">
|
|
||||||
<div style="font-size:28px;font-weight:700;color:#D97706">8</div>
|
|
||||||
<div style="font-size:13px;color:#92400E;margin-top:4px">High</div>
|
|
||||||
<div style="font-size:11px;color:#A16207;margin-top:2px">下一版本必须解决</div>
|
|
||||||
</div>
|
|
||||||
<div style="background:#DBEAFE;border:1px solid #93C5FD;border-radius:8px;padding:12px;text-align:center">
|
|
||||||
<div style="font-size:28px;font-weight:700;color:#2563EB">10</div>
|
|
||||||
<div style="font-size:13px;color:#1E40AF;margin-top:4px">Medium</div>
|
|
||||||
<div style="font-size:11px;color:#1D4ED8;margin-top:2px">应规划解决</div>
|
|
||||||
</div>
|
|
||||||
<div style="background:#D1FAE5;border:1px solid #6EE7B7;border-radius:8px;padding:12px;text-align:center">
|
|
||||||
<div style="font-size:28px;font-weight:700;color:#059669">6</div>
|
|
||||||
<div style="font-size:13px;color:#065F46;margin-top:4px">Low/Info</div>
|
|
||||||
<div style="font-size:11px;color:#047857;margin-top:2px">记录待定</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>6 专家组核心发现</h3>
|
|
||||||
|
|
||||||
<div class="cards">
|
|
||||||
<div class="card" data-choice="arch" onclick="toggleSelect(this)">
|
|
||||||
<div class="card-body">
|
|
||||||
<h3 style="color:#7C3AED">🏗️ 后端架构师</h3>
|
|
||||||
<p><strong>核心判断:</strong>当前是"声明式插件框架"穿了"命令式 WASM 沙箱"的外衣。CRM 的 WASM Guest 仅 30 行空壳,100% 流量绕过 WASM 层。</p>
|
|
||||||
<p><strong>推荐方案:</strong>三层插件模型 — L1 声明式(80%) / L2 钩子式(15%) / L3 计算密集(5%)。JSONB + PostgreSQL Generated Column 混合存储。</p>
|
|
||||||
<ul style="font-size:13px;color:#666">
|
|
||||||
<li>C-01: db_query 不可用(Host API 半成品)</li>
|
|
||||||
<li>H-01: JSONB 类型安全缺失(字符串排序非数值排序)</li>
|
|
||||||
<li>H-02: 无插件版本升级迁移能力</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" data-choice="crm" onclick="toggleSelect(this)">
|
|
||||||
<div class="card-body">
|
|
||||||
<h3 style="color:#059669">💼 CRM 产品专家</h3>
|
|
||||||
<p><strong>核心判断:</strong>当前是"客户通讯录"而非 CRM。缺少销售流程引擎(线索→商机→漏斗→赢单)这个灵魂。</p>
|
|
||||||
<p><strong>推荐路线:</strong>MVP 加 lead+opportunity 实体 + kanban 页面 → V2 团队协作+公海池 → V3 智能化+跨模块联动。</p>
|
|
||||||
<ul style="font-size:13px;color:#666">
|
|
||||||
<li>C-02: 无商机/漏斗管理 — CRM 不是 CRM</li>
|
|
||||||
<li>H-03: JSONB 零 FK 完整性</li>
|
|
||||||
<li>H-04: 无数据校验(手机号/邮箱格式)</li>
|
|
||||||
<li>H-05: 无跟进提醒机制</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" data-choice="sec" onclick="toggleSelect(this)">
|
|
||||||
<div class="card-body">
|
|
||||||
<h3 style="color:#DC2626">🔐 安全工程师</h3>
|
|
||||||
<p><strong>核心判断:</strong>行级数据权限完全缺失是最大的安全风险。plugin.admin 权限过宽等同于超级用户。</p>
|
|
||||||
<p><strong>紧急修复:</strong>① 收紧权限 fallback ② 行级数据权限框架 ③ 插件间 entity 白名单。</p>
|
|
||||||
<ul style="font-size:13px;color:#666">
|
|
||||||
<li>C-03: 行级数据权限缺失(销售A看销售B客户)</li>
|
|
||||||
<li>C-04: plugin.admin 获得所有插件的超级权限</li>
|
|
||||||
<li>H-06: 插件间无 entity 白名单隔离</li>
|
|
||||||
<li>H-07: JSONB 查询注入风险</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" data-choice="fe" onclick="toggleSelect(this)">
|
|
||||||
<div class="card-body">
|
|
||||||
<h3 style="color:#2563EB">🎨 前端架构师</h3>
|
|
||||||
<p><strong>核心判断:</strong>Schema 驱动 UI 已覆盖 70% 后台场景,但无法描述"行为"。关联选择器、批量操作、看板是三个最高优先级突破。</p>
|
|
||||||
<p><strong>推荐策略:</strong>声明式 DSL 扩展(短期)→ Iframe 沙箱自定义 UI(中期)→ Web Component(远期)。</p>
|
|
||||||
<ul style="font-size:13px;color:#666">
|
|
||||||
<li>H-08: 无 entity_select 关联选择器</li>
|
|
||||||
<li>H-09: 无批量操作(多选+批量处理)</li>
|
|
||||||
<li>M-01: visible_when 只支持 field==value</li>
|
|
||||||
<li>M-02: 图谱/树全量加载性能问题</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" data-choice="plat" onclick="toggleSelect(this)">
|
|
||||||
<div class="card-body">
|
|
||||||
<h3 style="color:#D97706">🔌 平台架构师</h3>
|
|
||||||
<p><strong>核心判断:</strong>插件是信息孤岛,无法互相发现和协作。PluginEngine 的 DashMap key 设计阻碍多版本共存。</p>
|
|
||||||
<p><strong>三层通信模型:</strong>事件契约注册 → 跨插件只读查询 → 插件间 RPC(远期)。自定义 API 用通配路由分发。</p>
|
|
||||||
<ul style="font-size:13px;color:#666">
|
|
||||||
<li>H-10: dependencies 字段已声明但从未校验</li>
|
|
||||||
<li>M-03: DashMap key 为 manifest id,多版本冲突</li>
|
|
||||||
<li>M-04: 无自定义 API 端点能力</li>
|
|
||||||
<li>M-05: WIT 接口无版本化</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" data-choice="perf" onclick="toggleSelect(this)">
|
|
||||||
<div class="card-body">
|
|
||||||
<h3 style="color:#0891B2">⚡ 性能工程师</h3>
|
|
||||||
<p><strong>核心判断:</strong>JSONB 排序无 B-tree 索引、ILIKE '%..%' 全表扫描、深翻页 OFFSET 退化是三大性能瓶颈。当前在万级数据以内可用,十万级会崩。</p>
|
|
||||||
<p><strong>核心方案:</strong>Generated Column 提取高频字段 + pg_trgm 加速搜索 + Keyset Pagination + 聚合 Redis 缓存。</p>
|
|
||||||
<ul style="font-size:13px;color:#666">
|
|
||||||
<li>M-06: ORDER BY data->>'field' 全表扫描</li>
|
|
||||||
<li>M-07: ILIKE '%keyword%' 无法用索引</li>
|
|
||||||
<li>M-08: OFFSET 深翻页线性退化</li>
|
|
||||||
<li>M-09: 每次请求双重查库(schema 解析)</li>
|
|
||||||
<li>M-10: Dashboard 串行聚合</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>跨专家组共识 Top 5</h3>
|
|
||||||
<div style="margin:12px 0">
|
|
||||||
<div style="display:flex;align-items:center;gap:10px;padding:10px;background:#F5F3FF;border-radius:6px;margin-bottom:6px">
|
|
||||||
<span style="background:#7C3AED;color:white;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:600">#1</span>
|
|
||||||
<strong>JSONB + Generated Column 混合存储</strong>
|
|
||||||
<span style="font-size:12px;color:#666;margin-left:auto">后端+性能+产品 三组一致推荐</span>
|
|
||||||
</div>
|
|
||||||
<div style="display:flex;align-items:center;gap:10px;padding:10px;background:#ECFDF5;border-radius:6px;margin-bottom:6px">
|
|
||||||
<span style="background:#059669;color:white;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:600">#2</span>
|
|
||||||
<strong>ref_entity 应用层外键校验 + 级联策略</strong>
|
|
||||||
<span style="font-size:12px;color:#666;margin-left:auto">后端+产品+安全 三组一致推荐</span>
|
|
||||||
</div>
|
|
||||||
<div style="display:flex;align-items:center;gap:10px;padding:10px;background:#EFF6FF;border-radius:6px;margin-bottom:6px">
|
|
||||||
<span style="background:#2563EB;color:white;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:600">#3</span>
|
|
||||||
<strong>entity_select 关联选择器 + kanban 看板页面</strong>
|
|
||||||
<span style="font-size:12px;color:#666;margin-left:auto">前端+产品 两组核心诉求</span>
|
|
||||||
</div>
|
|
||||||
<div style="display:flex;align-items:center;gap:10px;padding:10px;background:#FEF2F2;border-radius:6px;margin-bottom:6px">
|
|
||||||
<span style="background:#DC2626;color:white;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:600">#4</span>
|
|
||||||
<strong>行级数据权限 + 权限 fallback 收紧</strong>
|
|
||||||
<span style="font-size:12px;color:#666;margin-left:auto">安全 Critical + 平台架构支持</span>
|
|
||||||
</div>
|
|
||||||
<div style="display:flex;align-items:center;gap:10px;padding:10px;background:#FFFBEB;border-radius:6px;margin-bottom:6px">
|
|
||||||
<span style="background:#D97706;color:white;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:600">#5</span>
|
|
||||||
<strong>跨插件事件契约 + 只读数据查询</strong>
|
|
||||||
<span style="font-size:12px;color:#666;margin-left:auto">平台+产品 两组跨模块联动需求</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p style="text-align:center;color:#999;font-size:13px;margin-top:20px">请在终端中回复,告诉我你的优先级偏好</p>
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
|
|
||||||
<p class="subtitle">设计规格已提交,继续在终端中推进...</p>
|
|
||||||
</div>
|
|
||||||
@@ -158,6 +158,7 @@
|
|||||||
- 事件必须持久化到 `domain_events` 表(outbox 模式)
|
- 事件必须持久化到 `domain_events` 表(outbox 模式)
|
||||||
- 事件处理失败记录到 dead-letter 存储
|
- 事件处理失败记录到 dead-letter 存储
|
||||||
- 事件类型命名:`{模块}.{动作}` 如 `user.created`, `workflow.task.completed`
|
- 事件类型命名:`{模块}.{动作}` 如 `user.created`, `workflow.task.completed`
|
||||||
|
- **铁律:每个事件必须有至少一个消费者,否则功能不算完成。** 新增事件发布时必须同步实现消费者和对应测试。详见 `docs/discussions/2026-04-28-architecture-retrospective.md` §4。
|
||||||
|
|
||||||
### 3.5 Rust 代码规范
|
### 3.5 Rust 代码规范
|
||||||
|
|
||||||
@@ -255,7 +256,11 @@ docker exec erp-postgres psql -U erp -c "\dt"
|
|||||||
| `message` | erp-message |
|
| `message` | erp-message |
|
||||||
| `config` | erp-config |
|
| `config` | erp-config |
|
||||||
| `server` | erp-server |
|
| `server` | erp-server |
|
||||||
|
| `health` | erp-health |
|
||||||
|
| `ai` | erp-ai |
|
||||||
|
| `dialysis` | erp-dialysis |
|
||||||
| `plugin` | erp-plugin / erp-plugin-prototype / erp-plugin-test-sample |
|
| `plugin` | erp-plugin / erp-plugin-prototype / erp-plugin-test-sample |
|
||||||
|
| `assessment` | erp-plugin-assessment |
|
||||||
| `crm` | erp-plugin-crm |
|
| `crm` | erp-plugin-crm |
|
||||||
| `inventory` | erp-plugin-inventory |
|
| `inventory` | erp-plugin-inventory |
|
||||||
| `web` | Web 前端 |
|
| `web` | Web 前端 |
|
||||||
|
|||||||
265
Cargo.lock
generated
@@ -288,6 +288,28 @@ version = "1.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
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]]
|
[[package]]
|
||||||
name = "axum"
|
name = "axum"
|
||||||
version = "0.8.8"
|
version = "0.8.8"
|
||||||
@@ -555,7 +577,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d8144c22e24bbcf26ade86cb6501a0916c46b7e4787abdb0045a467eb1645a1d"
|
checksum = "d8144c22e24bbcf26ade86cb6501a0916c46b7e4787abdb0045a467eb1645a1d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ambient-authority",
|
"ambient-authority",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -681,6 +703,15 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cmake"
|
||||||
|
version = "0.1.58"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cobs"
|
name = "cobs"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -1056,7 +1087,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array",
|
"generic-array",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1330,6 +1361,12 @@ dependencies = [
|
|||||||
"dtoa",
|
"dtoa",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dunce"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.15.0"
|
version = "1.15.0"
|
||||||
@@ -1374,10 +1411,12 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"dashmap",
|
||||||
"erp-core",
|
"erp-core",
|
||||||
"futures",
|
"futures",
|
||||||
"handlebars",
|
"handlebars",
|
||||||
"hex",
|
"hex",
|
||||||
|
"redis",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"sea-orm",
|
"sea-orm",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1405,6 +1444,7 @@ dependencies = [
|
|||||||
"erp-core",
|
"erp-core",
|
||||||
"hex",
|
"hex",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
|
"redis",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"sea-orm",
|
"sea-orm",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1452,7 +1492,7 @@ dependencies = [
|
|||||||
"dashmap",
|
"dashmap",
|
||||||
"hex",
|
"hex",
|
||||||
"hmac",
|
"hmac",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
"sea-orm",
|
"sea-orm",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1464,11 +1504,32 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "erp-dialysis"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"axum",
|
||||||
|
"chrono",
|
||||||
|
"erp-core",
|
||||||
|
"num-traits",
|
||||||
|
"sea-orm",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"utoipa",
|
||||||
|
"uuid",
|
||||||
|
"validator",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "erp-health"
|
name = "erp-health"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
|
"argon2",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
@@ -1476,7 +1537,9 @@ dependencies = [
|
|||||||
"erp-core",
|
"erp-core",
|
||||||
"hex",
|
"hex",
|
||||||
"hmac",
|
"hmac",
|
||||||
|
"jsonwebtoken",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
|
"rand_core 0.6.4",
|
||||||
"sea-orm",
|
"sea-orm",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1487,6 +1550,7 @@ dependencies = [
|
|||||||
"utoipa",
|
"utoipa",
|
||||||
"uuid",
|
"uuid",
|
||||||
"validator",
|
"validator",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1540,6 +1604,15 @@ dependencies = [
|
|||||||
"wasmtime-wasi",
|
"wasmtime-wasi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "erp-plugin-assessment"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"wit-bindgen 0.55.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "erp-plugin-crm"
|
name = "erp-plugin-crm"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -1599,6 +1672,7 @@ name = "erp-server"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
"config",
|
"config",
|
||||||
@@ -1606,16 +1680,23 @@ dependencies = [
|
|||||||
"erp-auth",
|
"erp-auth",
|
||||||
"erp-config",
|
"erp-config",
|
||||||
"erp-core",
|
"erp-core",
|
||||||
|
"erp-dialysis",
|
||||||
"erp-health",
|
"erp-health",
|
||||||
"erp-message",
|
"erp-message",
|
||||||
"erp-plugin",
|
"erp-plugin",
|
||||||
"erp-server-migration",
|
"erp-server-migration",
|
||||||
"erp-workflow",
|
"erp-workflow",
|
||||||
|
"futures",
|
||||||
|
"hex",
|
||||||
|
"metrics",
|
||||||
|
"metrics-exporter-prometheus",
|
||||||
"moka",
|
"moka",
|
||||||
"redis",
|
"redis",
|
||||||
"sea-orm",
|
"sea-orm",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
@@ -1789,6 +1870,12 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"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]]
|
[[package]]
|
||||||
name = "funty"
|
name = "funty"
|
||||||
version = "2.0.0"
|
version = "2.0.0"
|
||||||
@@ -2233,6 +2320,7 @@ dependencies = [
|
|||||||
"hyper",
|
"hyper",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"rustls",
|
"rustls",
|
||||||
|
"rustls-native-certs",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@@ -2425,8 +2513,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "af1955a75fa080c677d3972822ec4bad316169ab1cfc6c257a942c2265dbe5fe"
|
checksum = "af1955a75fa080c677d3972822ec4bad316169ab1cfc6c257a942c2265dbe5fe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitmaps",
|
"bitmaps",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
"rand_xoshiro",
|
"rand_xoshiro 0.6.0",
|
||||||
"sized-chunks",
|
"sized-chunks",
|
||||||
"typenum",
|
"typenum",
|
||||||
"version_check",
|
"version_check",
|
||||||
@@ -2803,6 +2891,53 @@ dependencies = [
|
|||||||
"autocfg",
|
"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]]
|
[[package]]
|
||||||
name = "mime"
|
name = "mime"
|
||||||
version = "0.3.17"
|
version = "0.3.17"
|
||||||
@@ -2956,7 +3091,7 @@ dependencies = [
|
|||||||
"num-integer",
|
"num-integer",
|
||||||
"num-iter",
|
"num-iter",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@@ -3165,7 +3300,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64ct",
|
"base64ct",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3289,7 +3424,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf_shared",
|
"phf_shared",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3519,6 +3654,21 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.45"
|
version = "1.0.45"
|
||||||
@@ -3553,8 +3703,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"rand_chacha",
|
"rand_chacha 0.3.1",
|
||||||
"rand_core",
|
"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]]
|
[[package]]
|
||||||
@@ -3564,7 +3724,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ppv-lite86",
|
"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]]
|
[[package]]
|
||||||
@@ -3576,13 +3746,40 @@ dependencies = [
|
|||||||
"getrandom 0.2.17",
|
"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]]
|
[[package]]
|
||||||
name = "rand_xoshiro"
|
name = "rand_xoshiro"
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa"
|
checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa"
|
||||||
dependencies = [
|
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]]
|
[[package]]
|
||||||
@@ -3823,7 +4020,7 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
"pkcs1",
|
"pkcs1",
|
||||||
"pkcs8",
|
"pkcs8",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
"signature",
|
"signature",
|
||||||
"spki",
|
"spki",
|
||||||
"subtle",
|
"subtle",
|
||||||
@@ -3850,7 +4047,7 @@ dependencies = [
|
|||||||
"borsh",
|
"borsh",
|
||||||
"bytes",
|
"bytes",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
"rkyv",
|
"rkyv",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -3929,6 +4126,7 @@ version = "0.23.37"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aws-lc-rs",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
@@ -3937,6 +4135,18 @@ dependencies = [
|
|||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "rustls-pki-types"
|
name = "rustls-pki-types"
|
||||||
version = "1.14.0"
|
version = "1.14.0"
|
||||||
@@ -3952,6 +4162,7 @@ version = "0.103.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4"
|
checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aws-lc-rs",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"untrusted",
|
"untrusted",
|
||||||
@@ -4345,7 +4556,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"digest",
|
"digest",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4388,6 +4599,12 @@ dependencies = [
|
|||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sketches-ddsketch"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -4565,7 +4782,7 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
"rsa",
|
"rsa",
|
||||||
"rust_decimal",
|
"rust_decimal",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -4609,7 +4826,7 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
"num-bigint",
|
"num-bigint",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
"rust_decimal",
|
"rust_decimal",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -6731,6 +6948,20 @@ name = "zeroize"
|
|||||||
version = "1.8.2"
|
version = "1.8.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||||
|
dependencies = [
|
||||||
|
"zeroize_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize_derive"
|
||||||
|
version = "1.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ members = [
|
|||||||
"crates/erp-plugin-itops",
|
"crates/erp-plugin-itops",
|
||||||
"crates/erp-health",
|
"crates/erp-health",
|
||||||
"crates/erp-ai",
|
"crates/erp-ai",
|
||||||
|
"crates/erp-plugin-assessment",
|
||||||
|
"crates/erp-dialysis",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
@@ -38,6 +40,7 @@ sea-orm = { version = "1.1", features = [
|
|||||||
"sqlx-postgres", "runtime-tokio-rustls", "macros", "with-uuid", "with-chrono", "with-json"
|
"sqlx-postgres", "runtime-tokio-rustls", "macros", "with-uuid", "with-chrono", "with-json"
|
||||||
] }
|
] }
|
||||||
sea-orm-migration = { version = "1.1", features = ["sqlx-postgres", "runtime-tokio-rustls"] }
|
sea-orm-migration = { version = "1.1", features = ["sqlx-postgres", "runtime-tokio-rustls"] }
|
||||||
|
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid"] }
|
||||||
|
|
||||||
# Serialization
|
# Serialization
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
@@ -102,14 +105,20 @@ erp-config = { path = "crates/erp-config" }
|
|||||||
erp-plugin = { path = "crates/erp-plugin" }
|
erp-plugin = { path = "crates/erp-plugin" }
|
||||||
erp-health = { path = "crates/erp-health" }
|
erp-health = { path = "crates/erp-health" }
|
||||||
erp-ai = { path = "crates/erp-ai" }
|
erp-ai = { path = "crates/erp-ai" }
|
||||||
|
erp-dialysis = { path = "crates/erp-dialysis" }
|
||||||
|
|
||||||
# Async streaming
|
# Async streaming
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
tokio-stream = "0.1"
|
tokio-stream = "0.1"
|
||||||
async-stream = "0.3"
|
async-stream = "0.3"
|
||||||
|
dashmap = "6"
|
||||||
|
|
||||||
# Template engine
|
# Template engine
|
||||||
handlebars = "6"
|
handlebars = "6"
|
||||||
|
|
||||||
# HTML sanitization
|
# HTML sanitization
|
||||||
ammonia = "4"
|
ammonia = "4"
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
metrics = "0.24"
|
||||||
|
metrics-exporter-prometheus = "0.16"
|
||||||
|
|||||||
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
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
67
apps/miniprogram/audit-detail-pages.cjs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* 审计详情页(带参数)- 测试带假 ID 的页面是否优雅降级
|
||||||
|
*/
|
||||||
|
const automator = require('miniprogram-automator');
|
||||||
|
|
||||||
|
const DETAIL_PAGES = [
|
||||||
|
'pages/appointment/detail/index?id=00000000-0000-0000-0000-000000000000',
|
||||||
|
'pages/article/detail/index?id=00000000-0000-0000-0000-000000000000',
|
||||||
|
'pages/report/detail/index?id=00000000-0000-0000-0000-000000000000',
|
||||||
|
'pages/ai-report/detail/index?id=00000000-0000-0000-0000-000000000000',
|
||||||
|
'pages/mall/detail/index?id=00000000-0000-0000-0000-000000000000',
|
||||||
|
'pages/mall/exchange/index?id=00000000-0000-0000-0000-000000000000',
|
||||||
|
'pages/profile/family-add/index',
|
||||||
|
'pages/doctor/patients/detail/index?id=00000000-0000-0000-0000-000000000000',
|
||||||
|
'pages/doctor/consultation/detail/index?id=00000000-0000-0000-0000-000000000000',
|
||||||
|
'pages/doctor/followup/detail/index?id=00000000-0000-0000-0000-000000000000',
|
||||||
|
'pages/doctor/report/detail/index?id=00000000-0000-0000-0000-000000000000',
|
||||||
|
];
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('连接...');
|
||||||
|
const mp = await automator.connect({ wsEndpoint: 'ws://localhost:9420' });
|
||||||
|
|
||||||
|
const results = { ok: [], crash: [], login: [] };
|
||||||
|
|
||||||
|
for (const pageUrl of DETAIL_PAGES) {
|
||||||
|
const pagePath = pageUrl.split('?')[0];
|
||||||
|
try {
|
||||||
|
await mp.reLaunch(`/${pageUrl}`);
|
||||||
|
await new Promise(r => setTimeout(r, 2000));
|
||||||
|
const current = await mp.currentPage();
|
||||||
|
|
||||||
|
if (current.path === pagePath) {
|
||||||
|
// 检查页面是否有错误提示或空状态
|
||||||
|
const content = await mp.evaluate(() => {
|
||||||
|
const texts = [];
|
||||||
|
document.querySelectorAll && document.querySelectorAll('.error-state, .empty-state, [class*="error"], [class*="empty"]').forEach(el => {
|
||||||
|
texts.push(el.textContent);
|
||||||
|
});
|
||||||
|
return texts.length > 0 ? texts.join('; ') : 'loaded';
|
||||||
|
}).catch(() => 'loaded');
|
||||||
|
results.ok.push(`${pagePath} (${content.slice(0, 30)})`);
|
||||||
|
console.log(` OK: ${pagePath} - ${content.slice(0, 40)}`);
|
||||||
|
} else if (current.path === 'pages/login/index') {
|
||||||
|
results.login.push(pagePath);
|
||||||
|
console.log(` AUTH: ${pagePath} → login`);
|
||||||
|
} else {
|
||||||
|
results.crash.push(`${pagePath} → ${current.path}`);
|
||||||
|
console.log(` REDIR: ${pagePath} → ${current.path}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
results.crash.push(`${pagePath}: ${e.message.slice(0, 50)}`);
|
||||||
|
console.log(` ERR: ${pagePath} - ${e.message.slice(0, 40)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n===== 详情页审计 =====`);
|
||||||
|
console.log(`正常: ${results.ok.length}`);
|
||||||
|
console.log(`需登录: ${results.login.length}`);
|
||||||
|
console.log(`异常: ${results.crash.length}`);
|
||||||
|
results.crash.forEach(p => console.log(` 异常: ${p}`));
|
||||||
|
results.login.forEach(p => console.log(` 需登录: ${p}`));
|
||||||
|
|
||||||
|
await mp.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => { console.error(e); process.exit(1); });
|
||||||
112
apps/miniprogram/audit-pages.cjs
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* 批量审计页面(使用 reLaunch 避免页面栈限制)
|
||||||
|
*/
|
||||||
|
const automator = require('miniprogram-automator');
|
||||||
|
|
||||||
|
const ALL_PAGES = [
|
||||||
|
'pages/health/input/index',
|
||||||
|
'pages/health/trend/index',
|
||||||
|
'pages/health/daily-monitoring/index',
|
||||||
|
'pages/appointment/index',
|
||||||
|
'pages/appointment/create/index',
|
||||||
|
'pages/article/index',
|
||||||
|
'pages/ai-report/list/index',
|
||||||
|
'pages/followup/detail/index',
|
||||||
|
'pages/consultation/detail/index',
|
||||||
|
'pages/mall/orders/index',
|
||||||
|
'pages/profile/family/index',
|
||||||
|
'pages/profile/reports/index',
|
||||||
|
'pages/profile/followups/index',
|
||||||
|
'pages/profile/medication/index',
|
||||||
|
'pages/profile/settings/index',
|
||||||
|
'pages/legal/user-agreement',
|
||||||
|
'pages/legal/privacy-policy',
|
||||||
|
'pages/doctor/index',
|
||||||
|
'pages/doctor/patients/index',
|
||||||
|
'pages/doctor/consultation/index',
|
||||||
|
'pages/doctor/followup/index',
|
||||||
|
'pages/doctor/report/index',
|
||||||
|
'pages/events/index',
|
||||||
|
'pages/device-sync/index',
|
||||||
|
];
|
||||||
|
|
||||||
|
// 带参数的页面(需要 id 等参数)
|
||||||
|
const PAGES_WITH_PARAMS = {
|
||||||
|
'pages/appointment/detail/index': '?id=test-123',
|
||||||
|
'pages/article/detail/index': '?id=test-123',
|
||||||
|
'pages/report/detail/index': '?id=test-123',
|
||||||
|
'pages/ai-report/detail/index': '?id=test-123',
|
||||||
|
'pages/mall/exchange/index': '?id=test-123',
|
||||||
|
'pages/mall/detail/index': '?id=test-123',
|
||||||
|
'pages/profile/family-add/index': '?id=test-123',
|
||||||
|
'pages/doctor/patients/detail/index': '?id=test-123',
|
||||||
|
'pages/doctor/consultation/detail/index': '?id=test-123',
|
||||||
|
'pages/doctor/followup/detail/index': '?id=test-123',
|
||||||
|
'pages/doctor/report/detail/index': '?id=test-123',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('连接 DevTools...');
|
||||||
|
const mp = await automator.connect({ wsEndpoint: 'ws://localhost:9420' });
|
||||||
|
|
||||||
|
// 验证 token
|
||||||
|
const tokenLen = await mp.evaluate(() => {
|
||||||
|
const t = wx.getStorageSync('access_token');
|
||||||
|
return t ? t.length : 0;
|
||||||
|
});
|
||||||
|
if (tokenLen === 0) {
|
||||||
|
console.log('ERROR: 无 token,请先运行 inject-auth.cjs');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(`Token: ${tokenLen} chars\n`);
|
||||||
|
|
||||||
|
const results = { ok: [], redirectToLogin: [], redirectOther: [], error: [] };
|
||||||
|
|
||||||
|
for (const pagePath of ALL_PAGES) {
|
||||||
|
const param = PAGES_WITH_PARAMS[pagePath] || '';
|
||||||
|
const url = `/${pagePath}${param}`;
|
||||||
|
try {
|
||||||
|
await mp.reLaunch(url);
|
||||||
|
await new Promise(r => setTimeout(r, 2000));
|
||||||
|
const current = await mp.currentPage();
|
||||||
|
|
||||||
|
if (current.path === pagePath) {
|
||||||
|
results.ok.push(pagePath);
|
||||||
|
console.log(` OK: ${pagePath}`);
|
||||||
|
} else if (current.path === 'pages/login/index') {
|
||||||
|
results.redirectToLogin.push(pagePath);
|
||||||
|
console.log(` AUTH: ${pagePath} → login (需登录)`);
|
||||||
|
} else {
|
||||||
|
results.redirectOther.push(`${pagePath} → ${current.path}`);
|
||||||
|
console.log(` REDIR: ${pagePath} → ${current.path}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e.message.slice(0, 60);
|
||||||
|
results.error.push(`${pagePath}: ${msg}`);
|
||||||
|
console.log(` ERR: ${pagePath} - ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n===== 审计摘要 =====`);
|
||||||
|
console.log(`正常: ${results.ok.length}/${ALL_PAGES.length}`);
|
||||||
|
console.log(`需登录: ${results.redirectToLogin.length}`);
|
||||||
|
console.log(`重定向: ${results.redirectOther.length}`);
|
||||||
|
console.log(`错误: ${results.error.length}`);
|
||||||
|
|
||||||
|
if (results.redirectToLogin.length > 0) {
|
||||||
|
console.log(`\n需登录页面 (API 401 → login):`);
|
||||||
|
results.redirectToLogin.forEach(p => console.log(` - ${p}`));
|
||||||
|
}
|
||||||
|
if (results.error.length > 0) {
|
||||||
|
console.log(`\n错误页面:`);
|
||||||
|
results.error.forEach(p => console.log(` - ${p}`));
|
||||||
|
}
|
||||||
|
if (results.ok.length > 0) {
|
||||||
|
console.log(`\n正常页面:`);
|
||||||
|
results.ok.forEach(p => console.log(` - ${p}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
await mp.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => { console.error(e); process.exit(1); });
|
||||||
109
apps/miniprogram/audit-verify.cjs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* 审计修复验证脚本
|
||||||
|
* 验证 F1: 今日体征概览 API 支持 patient_id 参数
|
||||||
|
*/
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
const BASE = 'http://localhost:3000/api/v1';
|
||||||
|
|
||||||
|
function request(method, path, body, token) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const url = new URL(BASE + path);
|
||||||
|
const opts = {
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port,
|
||||||
|
path: url.pathname + url.search,
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
timeout: 10000,
|
||||||
|
};
|
||||||
|
if (token) opts.headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
const req = http.request(opts, (res) => {
|
||||||
|
const chunks = [];
|
||||||
|
res.on('data', (c) => chunks.push(c));
|
||||||
|
res.on('end', () => {
|
||||||
|
const raw = Buffer.concat(chunks).toString();
|
||||||
|
try {
|
||||||
|
resolve({ status: res.statusCode, data: JSON.parse(raw) });
|
||||||
|
} catch {
|
||||||
|
resolve({ status: res.statusCode, data: raw });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
|
||||||
|
if (body) req.write(JSON.stringify(body));
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('=== 审计修复验证 ===\n');
|
||||||
|
|
||||||
|
// 1. 登录
|
||||||
|
console.log('1. 登录...');
|
||||||
|
const loginRes = await request('POST', '/auth/login', {
|
||||||
|
username: 'admin',
|
||||||
|
password: 'Admin@2026',
|
||||||
|
});
|
||||||
|
const token = loginRes.data?.data?.access_token;
|
||||||
|
if (!token) {
|
||||||
|
console.error(' FAIL: 登录失败', JSON.stringify(loginRes.data).substring(0, 200));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(' OK: token 长度', token.length);
|
||||||
|
|
||||||
|
// 2. 获取患者列表(找第一个患者 ID)
|
||||||
|
console.log('\n2. 获取患者列表...');
|
||||||
|
const patientsRes = await request('GET', '/health/patients?page=1&page_size=5', null, token);
|
||||||
|
const patients = patientsRes.data?.data?.data || [];
|
||||||
|
console.log(' 患者数量:', patients.length);
|
||||||
|
const patientId = patients[0]?.id;
|
||||||
|
if (!patientId) {
|
||||||
|
console.log(' WARN: 无患者数据,跳过后续测试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(' 使用患者 ID:', patientId);
|
||||||
|
|
||||||
|
// 3. F1 验证:今日体征概览 - 不带 patient_id
|
||||||
|
console.log('\n3. F1 验证: 今日体征概览(不带 patient_id)...');
|
||||||
|
const todayRes1 = await request('GET', '/health/vital-signs/today', null, token);
|
||||||
|
console.log(' 状态:', todayRes1.status, todayRes1.data?.success ? 'OK' : 'FAIL');
|
||||||
|
|
||||||
|
// 4. F1 验证:今日体征概览 - 带 patient_id 参数
|
||||||
|
console.log('\n4. F1 验证: 今日体征概览(带 patient_id 参数)...');
|
||||||
|
const todayRes2 = await request('GET', `/health/vital-signs/today?patient_id=${patientId}`, null, token);
|
||||||
|
console.log(' 状态:', todayRes2.status, todayRes2.data?.success ? 'OK' : 'FAIL');
|
||||||
|
if (todayRes2.status === 200 && todayRes2.data?.success) {
|
||||||
|
console.log(' 返回数据:', JSON.stringify(todayRes2.data.data || {}).substring(0, 200));
|
||||||
|
} else {
|
||||||
|
console.log(' 响应:', JSON.stringify(todayRes2.data).substring(0, 300));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 验证趋势 API
|
||||||
|
console.log('\n5. 趋势 API 验证...');
|
||||||
|
const trendRes = await request('GET', '/health/vital-signs/trend?indicator=weight&range=7d', null, token);
|
||||||
|
console.log(' 状态:', trendRes.status, trendRes.data?.success ? 'OK' : 'FAIL');
|
||||||
|
|
||||||
|
// 6. 日常监测 API 验证
|
||||||
|
console.log('\n6. 日常监测 API 验证...');
|
||||||
|
const dmRes = await request('POST', '/health/daily-monitoring', {
|
||||||
|
patient_id: patientId,
|
||||||
|
record_date: new Date().toISOString().slice(0, 10),
|
||||||
|
weight: 999, // 超出合理范围,验证后端校验
|
||||||
|
}, token);
|
||||||
|
console.log(' 状态:', dmRes.status);
|
||||||
|
// 后端应该接受或拒绝(取决于后端校验强度)
|
||||||
|
if (dmRes.status >= 400) {
|
||||||
|
console.log(' 后端拒绝了请求(预期:应有范围校验):', JSON.stringify(dmRes.data).substring(0, 200));
|
||||||
|
} else {
|
||||||
|
console.log(' 后端接受了请求:', dmRes.data?.success ? 'OK' : 'FAIL');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== 验证完成 ===');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error('验证失败:', e.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
5
apps/miniprogram/cli-wrapper.bat
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
"%~dp0.\node.exe" "%~dp0.\cli.js" %*
|
||||||
|
endlocal
|
||||||
@@ -2,6 +2,10 @@ import type { UserConfigExport } from '@tarojs/cli';
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
logger: { quiet: false },
|
logger: { quiet: false },
|
||||||
mini: {},
|
mini: {
|
||||||
|
miniCssExtractPluginOption: {
|
||||||
|
ignoreOrder: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
h5: {},
|
h5: {},
|
||||||
} satisfies UserConfigExport;
|
} satisfies UserConfigExport;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export default defineConfig(async (merge) => {
|
|||||||
const baseConfig = {
|
const baseConfig = {
|
||||||
projectName: 'hms-miniprogram',
|
projectName: 'hms-miniprogram',
|
||||||
date: '2026-4-23',
|
date: '2026-4-23',
|
||||||
designWidth: 750,
|
designWidth: 375,
|
||||||
deviceRatio: { 640: 2.34 / 2, 750: 1, 375: 2, 828: 1.81 / 2 },
|
deviceRatio: { 640: 2.34 / 2, 750: 1, 375: 2, 828: 1.81 / 2 },
|
||||||
sourceRoot: 'src',
|
sourceRoot: 'src',
|
||||||
outputRoot: 'dist',
|
outputRoot: 'dist',
|
||||||
@@ -14,6 +14,11 @@ export default defineConfig(async (merge) => {
|
|||||||
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
|
'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_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_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: {} },
|
copy: { patterns: [], options: {} },
|
||||||
framework: 'react',
|
framework: 'react',
|
||||||
@@ -27,6 +32,9 @@ export default defineConfig(async (merge) => {
|
|||||||
mini: {
|
mini: {
|
||||||
compile: {
|
compile: {
|
||||||
exclude: [],
|
exclude: [],
|
||||||
|
include: [
|
||||||
|
require.resolve('zod').replace(/[\\/]index\.cjs$/, ''),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
postcss: {
|
postcss: {
|
||||||
pxtransform: { enable: true, config: {} },
|
pxtransform: { enable: true, config: {} },
|
||||||
|
|||||||
@@ -1,8 +1,20 @@
|
|||||||
import type { UserConfigExport } from '@tarojs/cli';
|
import type { UserConfigExport } from '@tarojs/cli';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
logger: { quiet: false },
|
logger: { quiet: true },
|
||||||
mini: { miniCssExtractPluginOption: { ignoreOrder: true } },
|
mini: {
|
||||||
|
miniCssExtractPluginOption: { ignoreOrder: true },
|
||||||
|
terserOption: {
|
||||||
|
compress: {
|
||||||
|
drop_console: true,
|
||||||
|
drop_debugger: true,
|
||||||
|
pure_funcs: ['console.log', 'console.info', 'console.debug'],
|
||||||
|
},
|
||||||
|
format: {
|
||||||
|
comments: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
h5: {
|
h5: {
|
||||||
miniCssExtractPluginOption: {
|
miniCssExtractPluginOption: {
|
||||||
ignoreOrder: true,
|
ignoreOrder: true,
|
||||||
|
|||||||
22
apps/miniprogram/debug-out.txt
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
2026-04-24T08:58:11.754Z automator:protocol 2026-04-24 16:58:11:753 SEND ► {"id":"2fce4e29-c0a6-4ed2-92e7-3c751ce7beb8","method":"Tool.getInfo","params":{}}
|
||||||
|
2026-04-24T08:58:11.757Z automator:protocol 2026-04-24 16:58:11:757 ◀ RECV {"id":"2fce4e29-c0a6-4ed2-92e7-3c751ce7beb8","result":{"version":"2.01.2510290","SDKVersion":"3.15.2"}}
|
||||||
|
Connected
|
||||||
|
2026-04-24T08:58:11.758Z automator:protocol 2026-04-24 16:58:11:758 SEND ► {"id":"5f642bf3-882c-496d-807c-1745eeb39f0c","method":"App.getCurrentPage","params":{}}
|
||||||
|
Error: timeout
|
||||||
|
2026-04-24T08:58:19.770Z automator:protocol 2026-04-24 16:58:19:770 SEND ► {"id":"4f6c3d82-6081-48ad-bea6-f2f5ff441213","method":"App.exit","params":{}}
|
||||||
|
2026-04-24T09:00:16.074Z automator:protocol 2026-04-24 17:00:16:073 ◀ RECV {"id":"e73069e8-8dbc-4fbe-90f9-351a4ddc16d4","result":{"version":"2.01.2510290","SDKVersion":"3.15.2"}}
|
||||||
|
2026-04-24T09:00:16.079Z automator:protocol 2026-04-24 17:00:16:079 ◀ RECV {"id":"8c58159f-9f1d-4d38-9de9-531b3821bd56","error":{"message":"unimplemented"}}
|
||||||
|
2026-04-24T09:02:33.550Z automator:protocol 2026-04-24 17:02:33:550 SEND ► {"id":"d1f78954-5df4-425e-a72e-c8fcd41170ba","method":"Tool.close","params":{}}
|
||||||
|
G:\hms\apps\miniprogram\node_modules\.pnpm\miniprogram-automator@0.12.1\node_modules\miniprogram-automator\out\Connection.js:1
|
||||||
|
"use strict";var __importDefault=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(exports,"__esModule",{value:!0});const ws_1=__importDefault(require("ws")),Transport_1=__importDefault(require("./Transport")),debug_1=__importDefault(require("debug")),uuid_1=__importDefault(require("licia/uuid")),events_1=require("events"),dateFormat_1=__importDefault(require("licia/dateFormat")),stringify_1=__importDefault(require("licia/stringify")),debugProtocol=debug_1.default("automator:protocol"),closeErrTip="Connection closed, check if wechat web devTools is still running";class Connection extends events_1.EventEmitter{constructor(e){super(),this.callbacks=new Map,this.onMessage=e=>{debugProtocol(`${dateFormat_1.default("yyyy-mm-dd HH:MM:ss:l")} ◀ RECV ${e}`);const t=JSON.parse(e),{id:r,method:s,error:o,result:i,params:a}=t;if(!r)return this.emit(s,a);const{callbacks:n}=this;if(r&&n.has(r)){const e=n.get(r);n.delete(r),o?e.reject(Error(o.message)):e.resolve(i)}},this.onClose=()=>{const{callbacks:e}=this;e.forEach((e=>{e.reject(Error(closeErrTip))}))},this.transport=e,e.on("message",this.onMessage),e.on("close",this.onClose)}send(e,t={}){const r=uuid_1.default(),s=stringify_1.default({id:r,method:e,params:t});return debugProtocol(`${dateFormat_1.default("yyyy-mm-dd HH:MM:ss:l")} SEND ► ${s}`),new Promise(((e,t)=>{try{this.transport.send(s)}catch(e){t(Error(closeErrTip))}this.callbacks.set(r,{resolve:e,reject:t})}))}dispose(){this.transport.close()}static create(e){return new Promise(((t,r)=>{const s=new ws_1.default(e);s.addEventListener("open",(()=>{t(new Connection(new Transport_1.default(s)))})),s.addEventListener("error",r)}))}}exports.default=Connection;
|
||||||
|
|
||||||
|
|
||||||
|
Error: Connection closed, check if wechat web devTools is still running
|
||||||
|
at G:\hms\apps\miniprogram\node_modules\.pnpm\miniprogram-automator@0.12.1\node_modules\miniprogram-automator\out\Connection.js:1:1413
|
||||||
|
at new Promise (<anonymous>)
|
||||||
|
at Connection.send (G:\hms\apps\miniprogram\node_modules\.pnpm\miniprogram-automator@0.12.1\node_modules\miniprogram-automator\out\Connection.js:1:1354)
|
||||||
|
at MiniProgram.send (G:\hms\apps\miniprogram\node_modules\.pnpm\miniprogram-automator@0.12.1\node_modules\miniprogram-automator\out\MiniProgram.js:1:4820)
|
||||||
|
at MiniProgram.close (G:\hms\apps\miniprogram\node_modules\.pnpm\miniprogram-automator@0.12.1\node_modules\miniprogram-automator\out\MiniProgram.js:1:3011)
|
||||||
|
at async [eval]:16:3
|
||||||
|
|
||||||
|
Node.js v24.14.0
|
||||||
453
apps/miniprogram/e2e-chain-test.cjs
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
/**
|
||||||
|
* HMS 小程序端到端链路验证
|
||||||
|
* 模拟真实用户操作,验证每条功能链路从 UI → API → 后端 → 数据 是否闭环
|
||||||
|
*/
|
||||||
|
const automator = require('miniprogram-automator');
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
const CLI_PATH = 'D:/微信web开发者工具/cli.bat';
|
||||||
|
const PROJECT_PATH = 'g:/hms/apps/miniprogram';
|
||||||
|
const BASE = 'http://localhost:3000/api/v1';
|
||||||
|
|
||||||
|
// ---- HTTP helper ----
|
||||||
|
function apiRequest(method, path, body, token) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const url = new URL(BASE + path);
|
||||||
|
const opts = {
|
||||||
|
hostname: url.hostname, port: url.port,
|
||||||
|
path: url.pathname + url.search, method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
timeout: 10000,
|
||||||
|
};
|
||||||
|
if (token) opts.headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
const req = http.request(opts, (res) => {
|
||||||
|
const chunks = [];
|
||||||
|
res.on('data', c => chunks.push(c));
|
||||||
|
res.on('end', () => {
|
||||||
|
const raw = Buffer.concat(chunks).toString();
|
||||||
|
try { resolve({ status: res.statusCode, data: JSON.parse(raw) }); }
|
||||||
|
catch { resolve({ status: res.statusCode, data: raw }); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
|
||||||
|
if (body) req.write(JSON.stringify(body));
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Results tracking ----
|
||||||
|
const results = [];
|
||||||
|
function log(chain, step, status, detail) {
|
||||||
|
const icon = status === 'PASS' ? '✅' : status === 'FAIL' ? '❌' : '⚠️';
|
||||||
|
console.log(` ${icon} [${chain}] ${step}: ${detail}`);
|
||||||
|
results.push({ chain, step, status, detail });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||||
|
|
||||||
|
// ---- Main test ----
|
||||||
|
async function main() {
|
||||||
|
console.log('\n=== HMS 小程序端到端链路验证 ===\n');
|
||||||
|
console.log('正在连接微信开发者工具...');
|
||||||
|
|
||||||
|
let mini;
|
||||||
|
try {
|
||||||
|
mini = await automator.launch({
|
||||||
|
cliPath: CLI_PATH,
|
||||||
|
projectPath: PROJECT_PATH,
|
||||||
|
});
|
||||||
|
console.log('连接成功!\n');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('连接失败:', e.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 辅助函数 ======
|
||||||
|
async function currentPage() {
|
||||||
|
const page = await mini.currentPage();
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPagePath() {
|
||||||
|
const page = await currentPage();
|
||||||
|
return page.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigateTo(url) {
|
||||||
|
await mini.navigateTo({ url });
|
||||||
|
await sleep(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goBack() {
|
||||||
|
await mini.navigateBack();
|
||||||
|
await sleep(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForElement(page, selector, timeout = 5000) {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeout) {
|
||||||
|
try {
|
||||||
|
const el = await page.$(selector);
|
||||||
|
if (el) return el;
|
||||||
|
} catch {}
|
||||||
|
await sleep(300);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function takeScreenshot(name) {
|
||||||
|
try {
|
||||||
|
const page = await currentPage();
|
||||||
|
// await page.screenshot({ path: `.logs/e2e-${name}.png` });
|
||||||
|
log('screenshot', name, 'PASS', `截图 ${name}`);
|
||||||
|
} catch (e) {
|
||||||
|
// screenshot may not be supported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 链路 0: 后端健康检查
|
||||||
|
// ============================================
|
||||||
|
console.log('--- 链路 0: 后端健康检查 ---');
|
||||||
|
try {
|
||||||
|
const res = await apiRequest('POST', '/auth/login', { username: 'admin', password: 'Admin@2026' });
|
||||||
|
const token = res.data?.data?.access_token;
|
||||||
|
if (token && res.status === 200) {
|
||||||
|
log('后端', '登录', 'PASS', `status=${res.status}, token长度=${token.length}`);
|
||||||
|
} else {
|
||||||
|
log('后端', '登录', 'FAIL', `status=${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取患者列表(后续链路需要)
|
||||||
|
const patientsRes = await apiRequest('GET', '/health/patients?page=1&page_size=10', null, token);
|
||||||
|
const patients = patientsRes.data?.data?.data || [];
|
||||||
|
log('后端', '患者列表', patients.length > 0 ? 'PASS' : 'WARN', `共 ${patients.length} 个患者`);
|
||||||
|
|
||||||
|
// 保存全局变量
|
||||||
|
globalThis._token = token;
|
||||||
|
globalThis._patients = patients;
|
||||||
|
globalThis._patientId = patients[0]?.id;
|
||||||
|
} catch (e) {
|
||||||
|
log('后端', '健康检查', 'FAIL', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 链路 1: 认证流程
|
||||||
|
// ============================================
|
||||||
|
console.log('\n--- 链路 1: 认证流程 ---');
|
||||||
|
try {
|
||||||
|
// 检查首页是否加载(需要先登录或已登录)
|
||||||
|
const path = await getPagePath();
|
||||||
|
log('认证', '页面加载', 'PASS', `当前页面: ${path}`);
|
||||||
|
|
||||||
|
// 检查是否存在 token(通过 evaluate)
|
||||||
|
const page = await currentPage();
|
||||||
|
const hasToken = await page.evaluate(() => {
|
||||||
|
try {
|
||||||
|
const store = require('./stores/auth').useAuthStore;
|
||||||
|
return { hasToken: !!store?.getState?.()?.token, loggedIn: !!store?.getState?.()?.isLoggedIn };
|
||||||
|
} catch { return { error: 'store not accessible' }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasToken.loggedIn || hasToken.hasToken) {
|
||||||
|
log('认证', '登录状态', 'PASS', `isLoggedIn=${hasToken.loggedIn}`);
|
||||||
|
} else {
|
||||||
|
log('认证', '登录状态', 'WARN', '未检测到登录状态(可能需要微信环境)');
|
||||||
|
}
|
||||||
|
|
||||||
|
await takeScreenshot('auth-home');
|
||||||
|
} catch (e) {
|
||||||
|
log('认证', '页面检查', 'FAIL', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 链路 2: 首页 → 健康数据导航
|
||||||
|
// ============================================
|
||||||
|
console.log('\n--- 链路 2: 页面导航 ---');
|
||||||
|
try {
|
||||||
|
// 导航到健康数据页
|
||||||
|
await navigateTo('/pages/health/index');
|
||||||
|
const healthPath = await getPagePath();
|
||||||
|
log('导航', '健康数据页', healthPath.includes('health') ? 'PASS' : 'FAIL', `路径: ${healthPath}`);
|
||||||
|
|
||||||
|
await takeScreenshot('health-page');
|
||||||
|
await goBack();
|
||||||
|
} catch (e) {
|
||||||
|
log('导航', '健康数据页', 'FAIL', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 链路 3: 健康数据录入
|
||||||
|
// ============================================
|
||||||
|
console.log('\n--- 链路 3: 健康数据录入 ---');
|
||||||
|
try {
|
||||||
|
await navigateTo('/pages/health/input/index');
|
||||||
|
const inputPath = await getPagePath();
|
||||||
|
log('健康录入', '页面加载', inputPath.includes('input') ? 'PASS' : 'FAIL', `路径: ${inputPath}`);
|
||||||
|
|
||||||
|
const page = await currentPage();
|
||||||
|
|
||||||
|
// 检查是否有指标选择器
|
||||||
|
const indicatorSelector = await page.$('.indicator-tabs, .hi-type-list, .type-item, [class*="indicator"], [class*="type"]');
|
||||||
|
if (indicatorSelector) {
|
||||||
|
log('健康录入', '指标选择器', 'PASS', '找到指标选择区域');
|
||||||
|
} else {
|
||||||
|
log('健康录入', '指标选择器', 'WARN', '未找到指标选择器(可能需要手动查看)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找输入框
|
||||||
|
const inputs = await page.$$('input');
|
||||||
|
log('健康录入', '输入框', inputs.length > 0 ? 'PASS' : 'FAIL', `找到 ${inputs.length} 个输入框`);
|
||||||
|
|
||||||
|
// 尝试填写体重
|
||||||
|
if (inputs.length > 0) {
|
||||||
|
try {
|
||||||
|
// 找到数值输入框
|
||||||
|
for (const input of inputs) {
|
||||||
|
const type = await input.attribute('type');
|
||||||
|
if (type === 'digit' || type === 'number') {
|
||||||
|
await input.input('65.5');
|
||||||
|
log('健康录入', '填写数据', 'PASS', '输入体重 65.5');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log('健康录入', '填写数据', 'WARN', `输入失败: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await takeScreenshot('health-input');
|
||||||
|
await goBack();
|
||||||
|
} catch (e) {
|
||||||
|
log('健康录入', '页面操作', 'FAIL', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 链路 4: 日常监测
|
||||||
|
// ============================================
|
||||||
|
console.log('\n--- 链路 4: 日常监测 ---');
|
||||||
|
try {
|
||||||
|
await navigateTo('/pages/health/daily-monitoring/index');
|
||||||
|
const dmPath = await getPagePath();
|
||||||
|
log('日常监测', '页面加载', dmPath.includes('daily-monitoring') ? 'PASS' : 'FAIL', `路径: ${dmPath}`);
|
||||||
|
|
||||||
|
const page = await currentPage();
|
||||||
|
|
||||||
|
// 查找输入框
|
||||||
|
const inputs = await page.$$('.dm-input');
|
||||||
|
log('日常监测', '表单字段', inputs.length > 0 ? 'PASS' : 'FAIL', `找到 ${inputs.length} 个输入字段`);
|
||||||
|
|
||||||
|
// 测试 Zod 验证 — 输入超出范围的值
|
||||||
|
if (inputs.length > 0) {
|
||||||
|
try {
|
||||||
|
// 在第一个输入框(晨起收缩压)输入 999
|
||||||
|
await inputs[0].input('999');
|
||||||
|
log('日常监测', 'Zod验证测试', 'PASS', '已输入收缩压 999(应在提交时被 Zod 拦截)');
|
||||||
|
|
||||||
|
// 查找提交按钮
|
||||||
|
const submitBtn = await page.$('.dm-submit');
|
||||||
|
if (submitBtn) {
|
||||||
|
// 不实际点击提交(避免创建脏数据),只验证按钮存在
|
||||||
|
log('日常监测', '提交按钮', 'PASS', '找到提交按钮');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log('日常监测', '表单操作', 'WARN', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await takeScreenshot('daily-monitoring');
|
||||||
|
await goBack();
|
||||||
|
} catch (e) {
|
||||||
|
log('日常监测', '页面操作', 'FAIL', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 链路 5: 积分商城
|
||||||
|
// ============================================
|
||||||
|
console.log('\n--- 链路 5: 积分商城 ---');
|
||||||
|
try {
|
||||||
|
await navigateTo('/pages/mall/index');
|
||||||
|
const mallPath = await getPagePath();
|
||||||
|
log('积分商城', '页面加载', mallPath.includes('mall') ? 'PASS' : 'FAIL', `路径: ${mallPath}`);
|
||||||
|
|
||||||
|
const page = await currentPage();
|
||||||
|
|
||||||
|
// 检查积分卡片
|
||||||
|
const pointsCard = await page.$('.points-card, .mall-header');
|
||||||
|
log('积分商城', '积分卡片', pointsCard ? 'PASS' : 'WARN', pointsCard ? '找到积分卡片' : '可能无档案降级显示');
|
||||||
|
|
||||||
|
// 检查签到按钮
|
||||||
|
const checkinBtn = await page.$('.checkin-btn');
|
||||||
|
log('积分商城', '签到按钮', checkinBtn ? 'PASS' : 'WARN', checkinBtn ? '找到签到按钮' : '无签到按钮(可能无档案)');
|
||||||
|
|
||||||
|
// 检查商品列表
|
||||||
|
const products = await page.$$('.product-card');
|
||||||
|
log('积分商城', '商品列表', 'PASS', `找到 ${products.length} 个商品卡片`);
|
||||||
|
|
||||||
|
// 检查无档案降级 UI
|
||||||
|
const emptyState = await page.$('.empty-state, .mall-empty');
|
||||||
|
if (emptyState) {
|
||||||
|
log('积分商城', '无档案降级', 'PASS', '显示无档案引导 UI(F2 修复验证)');
|
||||||
|
}
|
||||||
|
|
||||||
|
await takeScreenshot('mall');
|
||||||
|
await goBack();
|
||||||
|
} catch (e) {
|
||||||
|
log('积分商城', '页面操作', 'FAIL', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 链路 6: 预约挂号
|
||||||
|
// ============================================
|
||||||
|
console.log('\n--- 链路 6: 预约挂号 ---');
|
||||||
|
try {
|
||||||
|
await navigateTo('/pages/health/appointment/index');
|
||||||
|
const aptPath = await getPagePath();
|
||||||
|
log('预约挂号', '页面加载', aptPath.includes('appointment') ? 'PASS' : 'FAIL', `路径: ${aptPath}`);
|
||||||
|
|
||||||
|
const page = await currentPage();
|
||||||
|
await takeScreenshot('appointment');
|
||||||
|
|
||||||
|
// 检查科室选择等元素
|
||||||
|
const deptElements = await page.$$('[class*="dept"], [class*="department"], [class*="category"]');
|
||||||
|
log('预约挂号', '科室选择', deptElements.length > 0 ? 'PASS' : 'WARN', `找到 ${deptElements.length} 个科室相关元素`);
|
||||||
|
|
||||||
|
await goBack();
|
||||||
|
} catch (e) {
|
||||||
|
log('预约挂号', '页面操作', 'FAIL', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 链路 7: 家庭成员管理
|
||||||
|
// ============================================
|
||||||
|
console.log('\n--- 链路 7: 家庭成员管理 ---');
|
||||||
|
try {
|
||||||
|
await navigateTo('/pages/profile/family/index');
|
||||||
|
const famPath = await getPagePath();
|
||||||
|
log('家庭成员', '页面加载', famPath.includes('family') ? 'PASS' : 'FAIL', `路径: ${famPath}`);
|
||||||
|
|
||||||
|
const page = await currentPage();
|
||||||
|
|
||||||
|
// 检查家庭成员列表
|
||||||
|
const memberCards = await page.$$('[class*="member"], [class*="patient"], [class*="card"]');
|
||||||
|
log('家庭成员', '列表渲染', memberCards.length > 0 ? 'PASS' : 'WARN', `找到 ${memberCards.length} 个成员元素`);
|
||||||
|
|
||||||
|
await takeScreenshot('family');
|
||||||
|
await goBack();
|
||||||
|
} catch (e) {
|
||||||
|
log('家庭成员', '页面操作', 'FAIL', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 链路 8: 咨询
|
||||||
|
// ============================================
|
||||||
|
console.log('\n--- 链路 8: 咨询 ---');
|
||||||
|
try {
|
||||||
|
await navigateTo('/pages/health/consultation/index');
|
||||||
|
const consPath = await getPagePath();
|
||||||
|
log('咨询', '页面加载', consPath.includes('consultation') ? 'PASS' : 'FAIL', `路径: ${consPath}`);
|
||||||
|
|
||||||
|
const page = await currentPage();
|
||||||
|
const sessionItems = await page.$$('[class*="session"], [class*="item"], [class*="card"]');
|
||||||
|
log('咨询', '会话列表', 'PASS', `找到 ${sessionItems.length} 个元素`);
|
||||||
|
|
||||||
|
await takeScreenshot('consultation');
|
||||||
|
await goBack();
|
||||||
|
} catch (e) {
|
||||||
|
log('咨询', '页面操作', 'FAIL', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 链路 9: 文章与健康知识
|
||||||
|
// ============================================
|
||||||
|
console.log('\n--- 链路 9: 文章与健康知识 ---');
|
||||||
|
try {
|
||||||
|
await navigateTo('/pages/health/articles/index');
|
||||||
|
const artPath = await getPagePath();
|
||||||
|
log('文章', '页面加载', artPath.includes('article') ? 'PASS' : 'FAIL', `路径: ${artPath}`);
|
||||||
|
|
||||||
|
const page = await currentPage();
|
||||||
|
const articles = await page.$$('[class*="article"], [class*="card"]');
|
||||||
|
log('文章', '文章列表', 'PASS', `找到 ${articles.length} 个元素`);
|
||||||
|
|
||||||
|
await takeScreenshot('articles');
|
||||||
|
await goBack();
|
||||||
|
} catch (e) {
|
||||||
|
log('文章', '页面操作', 'FAIL', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 链路 10: 健康趋势
|
||||||
|
// ============================================
|
||||||
|
console.log('\n--- 链路 10: 健康趋势 ---');
|
||||||
|
try {
|
||||||
|
await navigateTo('/pages/health/trend/index');
|
||||||
|
const trendPath = await getPagePath();
|
||||||
|
log('趋势', '页面加载', trendPath.includes('trend') ? 'PASS' : 'FAIL', `路径: ${trendPath}`);
|
||||||
|
|
||||||
|
const page = await currentPage();
|
||||||
|
await takeScreenshot('trend');
|
||||||
|
await goBack();
|
||||||
|
} catch (e) {
|
||||||
|
log('趋势', '页面操作', 'FAIL', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// API 闭环验证(后端确认数据一致性)
|
||||||
|
// ============================================
|
||||||
|
console.log('\n--- API 闭环验证 ---');
|
||||||
|
try {
|
||||||
|
const token = globalThis._token;
|
||||||
|
const patientId = globalThis._patientId;
|
||||||
|
|
||||||
|
if (token && patientId) {
|
||||||
|
// 验证 F1: 今日体征带 patient_id 参数
|
||||||
|
const todayRes = await apiRequest('GET', `/health/vital-signs/today?patient_id=${patientId}`, null, token);
|
||||||
|
log('API闭环', '今日体征(F1)', todayRes.status === 200 ? 'PASS' : 'FAIL',
|
||||||
|
`status=${todayRes.status}, hasData=${!!todayRes.data?.data}`);
|
||||||
|
|
||||||
|
// 验证趋势 API
|
||||||
|
const trendRes = await apiRequest('GET', '/health/vital-signs/trend?indicator=weight&range=7d', null, token);
|
||||||
|
log('API闭环', '趋势数据', trendRes.status === 200 ? 'PASS' : 'FAIL', `status=${trendRes.status}`);
|
||||||
|
|
||||||
|
// 验证患者详情
|
||||||
|
const patRes = await apiRequest('GET', `/health/patients/${patientId}`, null, token);
|
||||||
|
log('API闭环', '患者详情', patRes.status === 200 ? 'PASS' : 'FAIL',
|
||||||
|
`status=${patRes.status}, name=${patRes.data?.data?.name || 'N/A'}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log('API闭环', '验证', 'FAIL', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 关闭连接 ======
|
||||||
|
await mini.close();
|
||||||
|
|
||||||
|
// ====== 汇总报告 ======
|
||||||
|
console.log('\n\n========================================');
|
||||||
|
console.log(' HMS 小程序端到端链路验证报告');
|
||||||
|
console.log('========================================\n');
|
||||||
|
|
||||||
|
const chains = [...new Set(results.map(r => r.chain))];
|
||||||
|
for (const chain of chains) {
|
||||||
|
const items = results.filter(r => r.chain === chain);
|
||||||
|
const passed = items.filter(r => r.status === 'PASS').length;
|
||||||
|
const failed = items.filter(r => r.status === 'FAIL').length;
|
||||||
|
const warned = items.filter(r => r.status === 'WARN').length;
|
||||||
|
const icon = failed > 0 ? '❌' : warned > 0 ? '⚠️' : '✅';
|
||||||
|
console.log(`${icon} ${chain}: ${passed}通过 / ${failed}失败 / ${warned}警告`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPass = results.filter(r => r.status === 'PASS').length;
|
||||||
|
const totalFail = results.filter(r => r.status === 'FAIL').length;
|
||||||
|
const totalWarn = results.filter(r => r.status === 'WARN').length;
|
||||||
|
console.log(`\n总计: ${results.length} 项检查 — ${totalPass}通过 / ${totalFail}失败 / ${totalWarn}警告`);
|
||||||
|
console.log('========================================\n');
|
||||||
|
|
||||||
|
process.exit(totalFail > 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => {
|
||||||
|
console.error('致命错误:', e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
363
apps/miniprogram/e2e-chain-v2.cjs
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
/**
|
||||||
|
* HMS 小程序端到端链路验证 v2
|
||||||
|
* 使用 connect 模式连接已打开的微信开发者工具
|
||||||
|
* 每步有超时保护,不会卡死
|
||||||
|
*/
|
||||||
|
const automator = require('miniprogram-automator');
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
const BASE = 'http://localhost:3000/api/v1';
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
function log(chain, step, status, detail) {
|
||||||
|
const icon = status === 'PASS' ? '✅' : status === 'FAIL' ? '❌' : '⚠️';
|
||||||
|
console.log(` ${icon} [${chain}] ${step}: ${detail}`);
|
||||||
|
results.push({ chain, step, status, detail });
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiRequest(method, path, body, token) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const url = new URL(BASE + path);
|
||||||
|
const opts = {
|
||||||
|
hostname: url.hostname, port: url.port,
|
||||||
|
path: url.pathname + url.search, method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
timeout: 10000,
|
||||||
|
};
|
||||||
|
if (token) opts.headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
const req = http.request(opts, (res) => {
|
||||||
|
const chunks = [];
|
||||||
|
res.on('data', c => chunks.push(c));
|
||||||
|
res.on('end', () => {
|
||||||
|
const raw = Buffer.concat(chunks).toString();
|
||||||
|
try { resolve({ status: res.statusCode, data: JSON.parse(raw) }); }
|
||||||
|
catch { resolve({ status: res.statusCode, data: raw }); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
|
||||||
|
if (body) req.write(JSON.stringify(body));
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTimeout(promise, ms, label) {
|
||||||
|
return Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise((_, reject) => setTimeout(() => reject(new Error(`${label} 超时(${ms}ms)`)), ms))
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||||
|
|
||||||
|
async function safePageAction(label, fn) {
|
||||||
|
try {
|
||||||
|
return await withTimeout(fn(), 8000, label);
|
||||||
|
} catch (e) {
|
||||||
|
log(label, '操作超时/异常', 'WARN', e.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('\n=== HMS 小程序端到端链路验证 v2 ===\n');
|
||||||
|
|
||||||
|
// ---- 连接 ----
|
||||||
|
console.log('连接微信开发者工具...');
|
||||||
|
let mini;
|
||||||
|
try {
|
||||||
|
mini = await withTimeout(
|
||||||
|
automator.connect({ wsEndpoint: 'ws://localhost:9420' }),
|
||||||
|
10000, '连接'
|
||||||
|
);
|
||||||
|
console.log('连接成功!\n');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('连接失败:', e.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 辅助 ----
|
||||||
|
async function getPages() {
|
||||||
|
return await withTimeout(mini.pages(), 5000, '获取页面列表');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPageInfo() {
|
||||||
|
const pages = await getPages();
|
||||||
|
if (pages && pages.length > 0) {
|
||||||
|
const last = pages[pages.length - 1];
|
||||||
|
try {
|
||||||
|
const path = await withTimeout(last.path, 3000, '获取路径');
|
||||||
|
return { page: last, path };
|
||||||
|
} catch {
|
||||||
|
return { page: last, path: 'unknown' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { page: null, path: 'none' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nav(url) {
|
||||||
|
try {
|
||||||
|
await withTimeout(mini.navigateTo({ url }), 5000, `导航 ${url}`);
|
||||||
|
await sleep(2000);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
log('导航', url, 'WARN', e.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function back() {
|
||||||
|
try {
|
||||||
|
await withTimeout(mini.navigateBack(), 3000, '返回');
|
||||||
|
await sleep(1000);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 0. 后端健康检查
|
||||||
|
// ============================================
|
||||||
|
console.log('--- 后端健康检查 ---');
|
||||||
|
let token, patients;
|
||||||
|
try {
|
||||||
|
const loginRes = await apiRequest('POST', '/auth/login', { username: 'admin', password: 'Admin@2026' });
|
||||||
|
token = loginRes.data?.data?.access_token;
|
||||||
|
log('后端', '登录', token ? 'PASS' : 'FAIL', `status=${loginRes.status}, token=${token ? token.length : 0}字符`);
|
||||||
|
|
||||||
|
const patRes = await apiRequest('GET', '/health/patients?page=1&page_size=10', null, token);
|
||||||
|
patients = patRes.data?.data?.data || [];
|
||||||
|
log('后端', '患者列表', patients.length > 0 ? 'PASS' : 'WARN', `${patients.length} 个患者`);
|
||||||
|
} catch (e) {
|
||||||
|
log('后端', '检查', 'FAIL', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 1. 当前页面检查
|
||||||
|
// ============================================
|
||||||
|
console.log('\n--- 链路1: 首页 & 认证状态 ---');
|
||||||
|
const { page: homePage, path: homePath } = await safePageAction('首页', getPageInfo);
|
||||||
|
if (homePage) {
|
||||||
|
log('首页', '页面加载', 'PASS', `当前路径: ${homePath}`);
|
||||||
|
} else {
|
||||||
|
log('首页', '页面加载', 'FAIL', '无法获取当前页面');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 2. 健康数据页
|
||||||
|
// ============================================
|
||||||
|
console.log('\n--- 链路2: 健康数据 ---');
|
||||||
|
if (await nav('/pages/health/index')) {
|
||||||
|
const { path } = await safePageAction('健康数据', getPageInfo);
|
||||||
|
log('健康数据', '页面加载', path?.includes('health') ? 'PASS' : 'FAIL', `路径: ${path}`);
|
||||||
|
|
||||||
|
// 通过 API 验证数据链路
|
||||||
|
if (token && patients?.[0]?.id) {
|
||||||
|
const todayRes = await apiRequest('GET', `/health/vital-signs/today?patient_id=${patients[0].id}`, null, token);
|
||||||
|
log('健康数据', '今日体征API(F1修复)', todayRes.status === 200 ? 'PASS' : 'FAIL',
|
||||||
|
`status=${todayRes.status}, hasData=${!!todayRes.data?.data}`);
|
||||||
|
|
||||||
|
const trendRes = await apiRequest('GET', '/health/vital-signs/trend?indicator=weight&range=7d', null, token);
|
||||||
|
log('健康数据', '趋势API', trendRes.status === 200 ? 'PASS' : 'FAIL', `status=${trendRes.status}`);
|
||||||
|
}
|
||||||
|
await back();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 3. 健康数据录入
|
||||||
|
// ============================================
|
||||||
|
console.log('\n--- 链路3: 健康数据录入 ---');
|
||||||
|
if (await nav('/pages/health/input/index')) {
|
||||||
|
const { page: inputPage, path: inputPath } = await safePageAction('录入页', getPageInfo);
|
||||||
|
log('健康录入', '页面加载', inputPath?.includes('input') ? 'PASS' : 'FAIL', `路径: ${inputPath}`);
|
||||||
|
|
||||||
|
if (inputPage) {
|
||||||
|
const inputs = await safePageAction('输入框', () => inputPage.$$('input'));
|
||||||
|
log('健康录入', '表单字段', inputs && inputs.length > 0 ? 'PASS' : 'WARN',
|
||||||
|
`${inputs?.length || 0} 个输入框`);
|
||||||
|
}
|
||||||
|
await back();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 4. 日常监测
|
||||||
|
// ============================================
|
||||||
|
console.log('\n--- 链路4: 日常监测 ---');
|
||||||
|
if (await nav('/pages/health/daily-monitoring/index')) {
|
||||||
|
const { page: dmPage, path: dmPath } = await safePageAction('监测页', getPageInfo);
|
||||||
|
log('日常监测', '页面加载', dmPath?.includes('daily-monitoring') ? 'PASS' : 'FAIL', `路径: ${dmPath}`);
|
||||||
|
|
||||||
|
if (dmPage) {
|
||||||
|
const inputs = await safePageAction('DM输入框', () => dmPage.$$('.dm-input'));
|
||||||
|
log('日常监测', '表单字段(M6修复)', inputs && inputs.length > 0 ? 'PASS' : 'WARN',
|
||||||
|
`${inputs?.length || 0} 个.dm-input字段`);
|
||||||
|
|
||||||
|
const submitBtn = await safePageAction('提交按钮', () => dmPage.$('.dm-submit'));
|
||||||
|
log('日常监测', '提交按钮', submitBtn ? 'PASS' : 'WARN', submitBtn ? '找到' : '未找到');
|
||||||
|
}
|
||||||
|
await back();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 5. 积分商城
|
||||||
|
// ============================================
|
||||||
|
console.log('\n--- 链路5: 积分商城 ---');
|
||||||
|
if (await nav('/pages/mall/index')) {
|
||||||
|
const { page: mallPage, path: mallPath } = await safePageAction('商城页', getPageInfo);
|
||||||
|
log('积分商城', '页面加载', mallPath?.includes('mall') ? 'PASS' : 'FAIL', `路径: ${mallPath}`);
|
||||||
|
|
||||||
|
if (mallPage) {
|
||||||
|
const pointsCard = await safePageAction('积分卡片', () => mallPage.$('.points-card'));
|
||||||
|
log('积分商城', '积分卡片', pointsCard ? 'PASS' : 'WARN', pointsCard ? '找到' : '未找到');
|
||||||
|
|
||||||
|
const checkinBtn = await safePageAction('签到', () => mallPage.$('.checkin-btn'));
|
||||||
|
log('积分商城', '签到按钮', checkinBtn ? 'PASS' : 'WARN', checkinBtn ? '找到' : '未找到');
|
||||||
|
|
||||||
|
const products = await safePageAction('商品列表', () => mallPage.$$('.product-card'));
|
||||||
|
log('积分商城', '商品列表', 'PASS', `${products?.length || 0} 个商品`);
|
||||||
|
|
||||||
|
// F2 修复验证: 检查无档案降级
|
||||||
|
const emptyState = await safePageAction('降级UI', () => mallPage.$('.empty-state'));
|
||||||
|
if (emptyState) {
|
||||||
|
log('积分商城', '无档案降级(F2修复)', 'PASS', '显示了无档案引导 UI');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await back();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 6. 预约挂号
|
||||||
|
// ============================================
|
||||||
|
console.log('\n--- 链路6: 预约挂号 ---');
|
||||||
|
if (await nav('/pages/health/appointment/index')) {
|
||||||
|
const { path: aptPath } = await safePageAction('预约页', getPageInfo);
|
||||||
|
log('预约挂号', '页面加载', aptPath?.includes('appointment') ? 'PASS' : 'FAIL', `路径: ${aptPath}`);
|
||||||
|
await back();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 7. 家庭成员
|
||||||
|
// ============================================
|
||||||
|
console.log('\n--- 链路7: 家庭成员管理 ---');
|
||||||
|
if (await nav('/pages/profile/family/index')) {
|
||||||
|
const { page: famPage, path: famPath } = await safePageAction('家庭页', getPageInfo);
|
||||||
|
log('家庭成员', '页面加载', famPath?.includes('family') ? 'PASS' : 'FAIL', `路径: ${famPath}`);
|
||||||
|
|
||||||
|
if (famPage) {
|
||||||
|
const cards = await safePageAction('成员卡片', () => famPage.$$('[class*="card"], [class*="member"]'));
|
||||||
|
log('家庭成员', '列表渲染', 'PASS', `${cards?.length || 0} 个成员元素`);
|
||||||
|
}
|
||||||
|
await back();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 8. 咨询
|
||||||
|
// ============================================
|
||||||
|
console.log('\n--- 链路8: 咨询 ---');
|
||||||
|
if (await nav('/pages/health/consultation/index')) {
|
||||||
|
const { path: consPath } = await safePageAction('咨询页', getPageInfo);
|
||||||
|
log('咨询', '页面加载', consPath?.includes('consultation') ? 'PASS' : 'FAIL', `路径: ${consPath}`);
|
||||||
|
await back();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 9. 文章
|
||||||
|
// ============================================
|
||||||
|
console.log('\n--- 链路9: 文章 ---');
|
||||||
|
if (await nav('/pages/health/articles/index')) {
|
||||||
|
const { path: artPath } = await safePageAction('文章页', getPageInfo);
|
||||||
|
log('文章', '页面加载', artPath?.includes('article') ? 'PASS' : 'FAIL', `路径: ${artPath}`);
|
||||||
|
await back();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 10. 健康趋势
|
||||||
|
// ============================================
|
||||||
|
console.log('\n--- 链路10: 健康趋势 ---');
|
||||||
|
if (await nav('/pages/health/trend/index')) {
|
||||||
|
const { path: trendPath } = await safePageAction('趋势页', getPageInfo);
|
||||||
|
log('趋势', '页面加载', trendPath?.includes('trend') ? 'PASS' : 'FAIL', `路径: ${trendPath}`);
|
||||||
|
await back();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 11. 我的报告
|
||||||
|
// ============================================
|
||||||
|
console.log('\n--- 链路11: 我的报告 ---');
|
||||||
|
if (await nav('/pages/health/reports/index')) {
|
||||||
|
const { path: repPath } = await safePageAction('报告页', getPageInfo);
|
||||||
|
log('报告', '页面加载', repPath?.includes('report') ? 'PASS' : 'FAIL', `路径: ${repPath}`);
|
||||||
|
await back();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// API 数据闭环验证
|
||||||
|
// ============================================
|
||||||
|
console.log('\n--- API 数据闭环 ---');
|
||||||
|
if (token && patients?.[0]?.id) {
|
||||||
|
const pid = patients[0].id;
|
||||||
|
|
||||||
|
// 患者详情
|
||||||
|
const patRes = await apiRequest('GET', `/health/patients/${pid}`, null, token);
|
||||||
|
log('API闭环', '患者详情', patRes.status === 200 ? 'PASS' : 'FAIL',
|
||||||
|
`status=${patRes.status}, name=${patRes.data?.data?.name || 'N/A'}`);
|
||||||
|
|
||||||
|
// 预约列表
|
||||||
|
const aptRes = await apiRequest('GET', `/health/patients/${pid}/appointments?page=1&page_size=5`, null, token);
|
||||||
|
log('API闭环', '预约列表', aptRes.status === 200 ? 'PASS' : 'FAIL', `status=${aptRes.status}`);
|
||||||
|
|
||||||
|
// 咨询列表
|
||||||
|
const consRes = await apiRequest('GET', `/health/patients/${pid}/consultation-sessions?page=1&page_size=5`, null, token);
|
||||||
|
log('API闭环', '咨询列表', consRes.status === 200 ? 'PASS' : 'FAIL', `status=${consRes.status}`);
|
||||||
|
|
||||||
|
// 日常监测列表
|
||||||
|
const dmRes = await apiRequest('GET', `/health/patients/${pid}/daily-monitoring?page=1&page_size=5`, null, token);
|
||||||
|
log('API闭环', '日常监测列表', dmRes.status === 200 ? 'PASS' : 'FAIL', `status=${dmRes.status}`);
|
||||||
|
|
||||||
|
// 积分账户
|
||||||
|
const acctRes = await apiRequest('GET', '/health/points/account', null, token);
|
||||||
|
log('API闭环', '积分账户', acctRes.status === 200 ? 'PASS' : 'FAIL',
|
||||||
|
`status=${acctRes.status}, balance=${acctRes.data?.data?.balance ?? 'N/A'}`);
|
||||||
|
|
||||||
|
// 签到状态
|
||||||
|
const checkRes = await apiRequest('GET', '/health/points/checkin-status', null, token);
|
||||||
|
log('API闭环', '签到状态', checkRes.status === 200 ? 'PASS' : 'FAIL', `status=${checkRes.status}`);
|
||||||
|
|
||||||
|
// 商品列表
|
||||||
|
const prodRes = await apiRequest('GET', '/health/points/products?page=1&page_size=5', null, token);
|
||||||
|
log('API闭环', '商品列表', prodRes.status === 200 ? 'PASS' : 'FAIL',
|
||||||
|
`status=${prodRes.status}, count=${prodRes.data?.data?.total ?? 'N/A'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 关闭 ----
|
||||||
|
try { await mini.close(); } catch {}
|
||||||
|
|
||||||
|
// ---- 汇总 ----
|
||||||
|
console.log('\n\n========================================');
|
||||||
|
console.log(' HMS 小程序端到端链路验证报告');
|
||||||
|
console.log('========================================\n');
|
||||||
|
|
||||||
|
const chains = [...new Set(results.map(r => r.chain))];
|
||||||
|
for (const chain of chains) {
|
||||||
|
const items = results.filter(r => r.chain === chain);
|
||||||
|
const passed = items.filter(r => r.status === 'PASS').length;
|
||||||
|
const failed = items.filter(r => r.status === 'FAIL').length;
|
||||||
|
const warned = items.filter(r => r.status === 'WARN').length;
|
||||||
|
const icon = failed > 0 ? '❌' : warned > 0 ? '⚠️' : '✅';
|
||||||
|
console.log(`${icon} ${chain}: ${passed}通过 / ${failed}失败 / ${warned}警告`);
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.status === 'FAIL') console.log(` ❌ ${item.step}: ${item.detail}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPass = results.filter(r => r.status === 'PASS').length;
|
||||||
|
const totalFail = results.filter(r => r.status === 'FAIL').length;
|
||||||
|
const totalWarn = results.filter(r => r.status === 'WARN').length;
|
||||||
|
console.log(`\n总计: ${results.length} 项 — ${totalPass}通过 / ${totalFail}失败 / ${totalWarn}警告`);
|
||||||
|
console.log('========================================\n');
|
||||||
|
|
||||||
|
process.exit(totalFail > 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => {
|
||||||
|
console.error('致命错误:', e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
272
apps/miniprogram/e2e-chain-v3.cjs
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
/**
|
||||||
|
* HMS 小程序端到端链路验证 v3
|
||||||
|
* 使用 pageStack + 超时保护避免卡死
|
||||||
|
*/
|
||||||
|
const automator = require('miniprogram-automator');
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
const BASE = 'http://localhost:3000/api/v1';
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
function log(chain, step, status, detail) {
|
||||||
|
const icon = status === 'PASS' ? '✅' : status === 'FAIL' ? '❌' : '⚠️';
|
||||||
|
console.log(` ${icon} [${chain}] ${step}: ${detail}`);
|
||||||
|
results.push({ chain, step, status, detail });
|
||||||
|
}
|
||||||
|
|
||||||
|
function api(method, path, body, token) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const url = new URL(BASE + path);
|
||||||
|
const opts = {
|
||||||
|
hostname: url.hostname, port: url.port,
|
||||||
|
path: url.pathname + url.search, method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
timeout: 10000,
|
||||||
|
};
|
||||||
|
if (token) opts.headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
const req = http.request(opts, (res) => {
|
||||||
|
const chunks = [];
|
||||||
|
res.on('data', c => chunks.push(c));
|
||||||
|
res.on('end', () => {
|
||||||
|
try { resolve({ status: res.statusCode, data: JSON.parse(Buffer.concat(chunks).toString()) }); }
|
||||||
|
catch { resolve({ status: res.statusCode, raw: true }); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
if (body) req.write(JSON.stringify(body));
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeout(ms) { return new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), ms)); }
|
||||||
|
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||||
|
function race(p, ms, label) { return Promise.race([p, timeout(ms)]).catch(e => ({ _err: label + ': ' + e.message })); }
|
||||||
|
|
||||||
|
async function getPage(mini) {
|
||||||
|
// pageStack 可能在某些状态下卡住,改用 screenshot + evaluate 来验证
|
||||||
|
try {
|
||||||
|
const stack = await Promise.race([mini.pageStack(), timeout(3000)]);
|
||||||
|
if (Array.isArray(stack) && stack.length > 0) {
|
||||||
|
const last = stack[stack.length - 1];
|
||||||
|
try {
|
||||||
|
const p = await Promise.race([last.path, timeout(2000)]);
|
||||||
|
return { page: last, path: typeof p === 'string' ? p : 'unknown' };
|
||||||
|
} catch { return { page: last, path: 'ok' }; }
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return { path: 'stack_timeout' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nav(mini, url) {
|
||||||
|
const r = await race(mini.navigateTo({ url }), 5000, 'nav');
|
||||||
|
await sleep(2000);
|
||||||
|
return !r._err;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function back(mini) {
|
||||||
|
await race(mini.navigateBack(), 3000, 'back');
|
||||||
|
await sleep(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('\n=== HMS 小程序端到端链路验证 v3 ===\n');
|
||||||
|
|
||||||
|
// 连接
|
||||||
|
let mini;
|
||||||
|
try {
|
||||||
|
mini = await Promise.race([automator.connect({ wsEndpoint: 'ws://localhost:9420' }), timeout(10000)]);
|
||||||
|
console.log('连接成功!\n');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('连接失败:', e.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 后端健康检查 ======
|
||||||
|
console.log('--- 后端健康检查 ---');
|
||||||
|
let token, patients;
|
||||||
|
try {
|
||||||
|
const lr = await api('POST', '/auth/login', { username: 'admin', password: 'Admin@2026' });
|
||||||
|
token = lr.data?.data?.access_token;
|
||||||
|
log('后端', '登录', token ? 'PASS' : 'FAIL', `status=${lr.status}`);
|
||||||
|
|
||||||
|
const pr = await api('GET', '/health/patients?page=1&page_size=10', null, token);
|
||||||
|
patients = pr.data?.data?.data || [];
|
||||||
|
log('后端', '患者列表', patients.length > 0 ? 'PASS' : 'WARN', `${patients.length}个`);
|
||||||
|
} catch (e) {
|
||||||
|
log('后端', '检查', 'FAIL', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 链路1: 首页 ======
|
||||||
|
console.log('\n--- 链路1: 首页 ---');
|
||||||
|
const home = await getPage(mini);
|
||||||
|
log('首页', '页面', 'PASS', `路径: ${home.path}`);
|
||||||
|
|
||||||
|
// ====== 链路2: 健康数据 ======
|
||||||
|
console.log('\n--- 链路2: 健康数据 ---');
|
||||||
|
if (await nav(mini, '/pages/health/index')) {
|
||||||
|
const h = await getPage(mini);
|
||||||
|
log('健康数据', '页面加载', h.path.includes('health') ? 'PASS' : 'FAIL', `路径: ${h.path}`);
|
||||||
|
|
||||||
|
if (token && patients?.[0]) {
|
||||||
|
const tr = await api('GET', `/health/vital-signs/today?patient_id=${patients[0].id}`, null, token);
|
||||||
|
log('健康数据', '今日体征(F1)', tr.status === 200 ? 'PASS' : 'FAIL', `status=${tr.status}`);
|
||||||
|
|
||||||
|
const trendR = await api('GET', '/health/vital-signs/trend?indicator=weight&range=7d', null, token);
|
||||||
|
log('健康数据', '趋势API', trendR.status === 200 ? 'PASS' : 'FAIL', `status=${trendR.status}`);
|
||||||
|
}
|
||||||
|
await back(mini);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 链路3: 健康录入 ======
|
||||||
|
console.log('\n--- 链路3: 健康录入 ---');
|
||||||
|
if (await nav(mini, '/pages/health/input/index')) {
|
||||||
|
const h = await getPage(mini);
|
||||||
|
log('健康录入', '页面加载', h.path.includes('input') ? 'PASS' : 'FAIL', `路径: ${h.path}`);
|
||||||
|
if (h.page) {
|
||||||
|
const inputs = await race(h.page.$$('input'), 3000, 'inputs');
|
||||||
|
log('健康录入', '输入框', !inputs?._err && inputs?.length > 0 ? 'PASS' : 'WARN', `${inputs?.length || 0}个`);
|
||||||
|
}
|
||||||
|
await back(mini);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 链路4: 日常监测 ======
|
||||||
|
console.log('\n--- 链路4: 日常监测 ---');
|
||||||
|
if (await nav(mini, '/pages/health/daily-monitoring/index')) {
|
||||||
|
const h = await getPage(mini);
|
||||||
|
log('日常监测', '页面加载', h.path.includes('daily') ? 'PASS' : 'FAIL', `路径: ${h.path}`);
|
||||||
|
if (h.page) {
|
||||||
|
const inputs = await race(h.page.$$('.dm-input'), 3000, 'inputs');
|
||||||
|
log('日常监测', '表单字段(M6)', !inputs?._err && inputs?.length > 0 ? 'PASS' : 'WARN', `${inputs?.length || 0}个`);
|
||||||
|
const btn = await race(h.page.$('.dm-submit'), 3000, 'btn');
|
||||||
|
log('日常监测', '提交按钮', !btn?._err && btn ? 'PASS' : 'WARN', btn ? '找到' : '未找到');
|
||||||
|
}
|
||||||
|
await back(mini);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 链路5: 积分商城 ======
|
||||||
|
console.log('\n--- 链路5: 积分商城 ---');
|
||||||
|
if (await nav(mini, '/pages/mall/index')) {
|
||||||
|
const h = await getPage(mini);
|
||||||
|
log('积分商城', '页面加载', h.path.includes('mall') ? 'PASS' : 'FAIL', `路径: ${h.path}`);
|
||||||
|
if (h.page) {
|
||||||
|
const pc = await race(h.page.$('.points-card'), 3000, 'points');
|
||||||
|
log('积分商城', '积分卡片', !pc?._err && pc ? 'PASS' : 'WARN', pc ? '找到' : '未找到');
|
||||||
|
const cb = await race(h.page.$('.checkin-btn'), 3000, 'checkin');
|
||||||
|
log('积分商城', '签到按钮', !cb?._err && cb ? 'PASS' : 'WARN', cb ? '找到' : '未找到');
|
||||||
|
const prods = await race(h.page.$$('.product-card'), 3000, 'prods');
|
||||||
|
log('积分商城', '商品列表', 'PASS', `${prods?.length || 0}个商品`);
|
||||||
|
const empty = await race(h.page.$('.empty-state'), 3000, 'empty');
|
||||||
|
if (!empty?._err && empty) {
|
||||||
|
log('积分商城', '无档案降级(F2)', 'PASS', '显示降级UI');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await back(mini);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 链路6: 预约挂号 ======
|
||||||
|
console.log('\n--- 链路6: 预约挂号 ---');
|
||||||
|
if (await nav(mini, '/pages/health/appointment/index')) {
|
||||||
|
const h = await getPage(mini);
|
||||||
|
log('预约', '页面加载', h.path.includes('appointment') ? 'PASS' : 'FAIL', `路径: ${h.path}`);
|
||||||
|
await back(mini);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 链路7: 家庭成员 ======
|
||||||
|
console.log('\n--- 链路7: 家庭成员 ---');
|
||||||
|
if (await nav(mini, '/pages/profile/family/index')) {
|
||||||
|
const h = await getPage(mini);
|
||||||
|
log('家庭成员', '页面加载', h.path.includes('family') ? 'PASS' : 'FAIL', `路径: ${h.path}`);
|
||||||
|
await back(mini);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 链路8: 咨询 ======
|
||||||
|
console.log('\n--- 链路8: 咨询 ---');
|
||||||
|
if (await nav(mini, '/pages/health/consultation/index')) {
|
||||||
|
const h = await getPage(mini);
|
||||||
|
log('咨询', '页面加载', h.path.includes('consultation') ? 'PASS' : 'FAIL', `路径: ${h.path}`);
|
||||||
|
await back(mini);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 链路9: 文章 ======
|
||||||
|
console.log('\n--- 链路9: 文章 ---');
|
||||||
|
if (await nav(mini, '/pages/health/articles/index')) {
|
||||||
|
const h = await getPage(mini);
|
||||||
|
log('文章', '页面加载', h.path.includes('article') ? 'PASS' : 'FAIL', `路径: ${h.path}`);
|
||||||
|
await back(mini);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 链路10: 趋势 ======
|
||||||
|
console.log('\n--- 链路10: 趋势 ---');
|
||||||
|
if (await nav(mini, '/pages/health/trend/index')) {
|
||||||
|
const h = await getPage(mini);
|
||||||
|
log('趋势', '页面加载', h.path.includes('trend') ? 'PASS' : 'FAIL', `路径: ${h.path}`);
|
||||||
|
await back(mini);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 链路11: 报告 ======
|
||||||
|
console.log('\n--- 链路11: 报告 ---');
|
||||||
|
if (await nav(mini, '/pages/health/reports/index')) {
|
||||||
|
const h = await getPage(mini);
|
||||||
|
log('报告', '页面加载', h.path.includes('report') ? 'PASS' : 'FAIL', `路径: ${h.path}`);
|
||||||
|
await back(mini);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== API 数据闭环 ======
|
||||||
|
console.log('\n--- API 数据闭环 ---');
|
||||||
|
if (token && patients?.[0]) {
|
||||||
|
const pid = patients[0].id;
|
||||||
|
const checks = [
|
||||||
|
['患者详情', 'GET', `/health/patients/${pid}`],
|
||||||
|
['预约列表', 'GET', '/health/appointments?page=1&page_size=5'],
|
||||||
|
['咨询列表', 'GET', '/health/consultation-sessions?page=1&page_size=5'],
|
||||||
|
['日常监测', 'GET', `/health/patients/${pid}/daily-monitoring?page=1&page_size=5`],
|
||||||
|
['积分账户', 'GET', '/health/points/account'],
|
||||||
|
['签到状态', 'GET', '/health/points/checkin/status'],
|
||||||
|
['商品列表', 'GET', '/health/points/products?page=1&page_size=5'],
|
||||||
|
];
|
||||||
|
for (const [label, method, path] of checks) {
|
||||||
|
try {
|
||||||
|
const r = await api(method, path, null, token);
|
||||||
|
const ok = r.status === 200;
|
||||||
|
const detail = r.data?.data?.name ? `${label}: ${r.data.data.name}` :
|
||||||
|
r.data?.data?.total !== undefined ? `${label}: total=${r.data.data.total}` :
|
||||||
|
`status=${r.status}`;
|
||||||
|
log('API闭环', label, ok ? 'PASS' : 'FAIL', detail);
|
||||||
|
} catch (e) {
|
||||||
|
log('API闭环', label, 'FAIL', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 关闭 ----
|
||||||
|
try { await mini.disconnect(); } catch {}
|
||||||
|
try { await mini.close(); } catch {}
|
||||||
|
|
||||||
|
// ---- 汇总 ----
|
||||||
|
console.log('\n\n========================================');
|
||||||
|
console.log(' HMS 小程序端到端链路验证报告');
|
||||||
|
console.log('========================================\n');
|
||||||
|
|
||||||
|
const chains = [...new Set(results.map(r => r.chain))];
|
||||||
|
for (const chain of chains) {
|
||||||
|
const items = results.filter(r => r.chain === chain);
|
||||||
|
const p = items.filter(r => r.status === 'PASS').length;
|
||||||
|
const f = items.filter(r => r.status === 'FAIL').length;
|
||||||
|
const w = items.filter(r => r.status === 'WARN').length;
|
||||||
|
const icon = f > 0 ? '❌' : w > 0 ? '⚠️' : '✅';
|
||||||
|
console.log(`${icon} ${chain}: ${p}通过/${f}失败/${w}警告`);
|
||||||
|
for (const item of items.filter(i => i.status !== 'PASS')) {
|
||||||
|
console.log(` ${item.status === 'FAIL' ? '❌' : '⚠️'} ${item.step}: ${item.detail}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tp = results.filter(r => r.status === 'PASS').length;
|
||||||
|
const tf = results.filter(r => r.status === 'FAIL').length;
|
||||||
|
const tw = results.filter(r => r.status === 'WARN').length;
|
||||||
|
console.log(`\n总计: ${results.length}项 — ✅${tp}通过 / ❌${tf}失败 / ⚠️${tw}警告`);
|
||||||
|
console.log('========================================\n');
|
||||||
|
|
||||||
|
process.exit(tf > 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => { console.error('致命错误:', e); process.exit(1); });
|
||||||
419
apps/miniprogram/e2e-final.cjs
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
/**
|
||||||
|
* HMS 小程序端到端链路验证 v4 (final)
|
||||||
|
* 前置条件: dist/ 已构建, 开发者工具已打开项目, 自动化端口 9420 已开放
|
||||||
|
*/
|
||||||
|
const automator = require('miniprogram-automator');
|
||||||
|
const http = require('http');
|
||||||
|
const CryptoJS = require('crypto-js');
|
||||||
|
|
||||||
|
const ENC_KEY = '0a17b71d46064b06f993c9c202b342425e311a79f5be026d830562e7ad51f522';
|
||||||
|
function encrypt(plaintext) { return CryptoJS.AES.encrypt(plaintext, ENC_KEY).toString(); }
|
||||||
|
|
||||||
|
const BASE = 'http://localhost:3000/api/v1';
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
function log(chain, step, status, detail) {
|
||||||
|
const icon = status === 'PASS' ? '✅' : status === 'FAIL' ? '❌' : '⚠️';
|
||||||
|
console.log(` ${icon} [${chain}] ${step}: ${detail}`);
|
||||||
|
results.push({ chain, step, status, detail });
|
||||||
|
}
|
||||||
|
|
||||||
|
function api(method, path, body, token) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const url = new URL(BASE + path);
|
||||||
|
const opts = {
|
||||||
|
hostname: url.hostname, port: url.port,
|
||||||
|
path: url.pathname + url.search, method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
timeout: 10000,
|
||||||
|
};
|
||||||
|
if (token) opts.headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
const req = http.request(opts, (res) => {
|
||||||
|
const chunks = [];
|
||||||
|
res.on('data', c => chunks.push(c));
|
||||||
|
res.on('end', () => {
|
||||||
|
try { resolve({ status: res.statusCode, data: JSON.parse(Buffer.concat(chunks).toString()) }); }
|
||||||
|
catch { resolve({ status: res.statusCode, raw: true }); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
if (body) req.write(JSON.stringify(body));
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const T = (ms) => new Promise((_, r) => setTimeout(() => r(new Error('timeout')), ms));
|
||||||
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('\n=== HMS 小程序端到端链路验证 ===\n');
|
||||||
|
|
||||||
|
// ====== 连接 ======
|
||||||
|
console.log('连接微信开发者工具...');
|
||||||
|
const mini = await automator.connect({ wsEndpoint: 'ws://localhost:9420' });
|
||||||
|
const info = await mini.systemInfo();
|
||||||
|
console.log(`已连接 (SDK ${info.SDKVersion}, ${info.model})\n`);
|
||||||
|
|
||||||
|
// ====== 辅助 ======
|
||||||
|
async function curPage() {
|
||||||
|
const page = await mini.currentPage();
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function curPath() {
|
||||||
|
const page = await curPage();
|
||||||
|
return page.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabPages = new Set(['pages/index/index', 'pages/health/index', 'pages/consultation/index', 'pages/mall/index', 'pages/profile/index']);
|
||||||
|
|
||||||
|
async function nav(url) {
|
||||||
|
const cleanUrl = url.startsWith('/') ? url.slice(1) : url;
|
||||||
|
try {
|
||||||
|
if (tabPages.has(cleanUrl)) {
|
||||||
|
await Promise.race([mini.switchTab('/' + cleanUrl), T(8000)]);
|
||||||
|
} else {
|
||||||
|
await Promise.race([mini.navigateTo('/' + cleanUrl), T(8000)]);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(` ⚠ 导航超时: ${url} - ${e.message}`);
|
||||||
|
}
|
||||||
|
await sleep(2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function back() {
|
||||||
|
try { await Promise.race([mini.navigateBack(), T(5000)]); } catch {}
|
||||||
|
await sleep(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tap(selector) {
|
||||||
|
const page = await curPage();
|
||||||
|
const el = await page.$(selector);
|
||||||
|
if (el) { await el.tap(); await sleep(800); return true; }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 后端准备
|
||||||
|
// ========================================
|
||||||
|
console.log('--- 后端准备 ---');
|
||||||
|
const lr = await api('POST', '/auth/login', { username: 'admin', password: 'Admin@2026' });
|
||||||
|
const token = lr.data?.data?.access_token;
|
||||||
|
log('后端', '管理员登录', token ? 'PASS' : 'FAIL', `status=${lr.status}`);
|
||||||
|
|
||||||
|
const pr = await api('GET', '/health/patients?page=1&page_size=10', null, token);
|
||||||
|
const patients = pr.data?.data?.data || [];
|
||||||
|
log('后端', '患者列表', patients.length > 0 ? 'PASS' : 'WARN', `${patients.length}个患者`);
|
||||||
|
const patientId = patients[0]?.id;
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 链路1: 登录页 → 首页(通过加密 storage 绕过)
|
||||||
|
// ========================================
|
||||||
|
console.log('--- 链路1: 认证流程 ---');
|
||||||
|
let startPath = await curPath();
|
||||||
|
log('认证', '初始页面', 'PASS', `路径: ${startPath}`);
|
||||||
|
|
||||||
|
if (startPath.includes('login')) {
|
||||||
|
const loginBtn = await (await curPage()).$('.login-btn, .auth-btn, button, [class*="login"]');
|
||||||
|
log('认证', '登录按钮', loginBtn ? 'PASS' : 'WARN', loginBtn ? '找到' : '未找到');
|
||||||
|
|
||||||
|
// 用 API 获取 admin token,加密后写入 storage
|
||||||
|
try {
|
||||||
|
const loginRes = await api('POST', '/auth/login', { username: 'admin', password: 'Admin@2026' });
|
||||||
|
const apiToken = loginRes.data?.data?.access_token;
|
||||||
|
if (apiToken) {
|
||||||
|
await mini.callWxMethod('setStorageSync', 'access_token', encrypt(apiToken));
|
||||||
|
await mini.callWxMethod('setStorageSync', 'refresh_token', encrypt('dummy'));
|
||||||
|
await mini.callWxMethod('setStorageSync', 'user_data', encrypt(JSON.stringify({ id: 'test', username: 'admin', display_name: '管理员', tenant_id: '019d0da7-a2c1-7820-b0a3-3d5266a3a324' })));
|
||||||
|
await mini.callWxMethod('setStorageSync', 'user_roles', encrypt(JSON.stringify(['admin'])));
|
||||||
|
await mini.callWxMethod('setStorageSync', 'tenant_id', encrypt('019d0da7-a2c1-7820-b0a3-3d5266a3a324'));
|
||||||
|
await mini.callWxMethod('setStorageSync', 'current_patient', { id: patients[0]?.id || 'x', name: patients[0]?.name || '测试', relation: 'self' });
|
||||||
|
await mini.callWxMethod('setStorageSync', 'current_patient_id', patients[0]?.id || 'x');
|
||||||
|
log('认证', '加密Token写入', 'PASS', '加密 storage 已设置');
|
||||||
|
}
|
||||||
|
|
||||||
|
await mini.reLaunch('/pages/index/index');
|
||||||
|
await sleep(4000);
|
||||||
|
const afterPath = await curPath();
|
||||||
|
log('认证', 'reLaunch首页', afterPath.includes('index') ? 'PASS' : 'FAIL', `路径: ${afterPath}`);
|
||||||
|
|
||||||
|
if (afterPath.includes('login')) {
|
||||||
|
log('认证', '状态', 'FAIL', '仍在登录页');
|
||||||
|
} else {
|
||||||
|
log('认证', '登录成功', 'PASS', `已进入: ${afterPath}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log('认证', '异常', 'FAIL', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 链路2: 健康数据页
|
||||||
|
// ========================================
|
||||||
|
console.log('\n--- 链路2: 健康数据 ---');
|
||||||
|
try {
|
||||||
|
await nav('/pages/health/index');
|
||||||
|
const hPath = await curPath();
|
||||||
|
log('健康数据', '页面导航', hPath.includes('health') ? 'PASS' : 'FAIL', `路径: ${hPath}`);
|
||||||
|
|
||||||
|
// API 链路验证
|
||||||
|
if (token && patientId) {
|
||||||
|
const tr = await api('GET', `/health/vital-signs/today?patient_id=${patientId}`, null, token);
|
||||||
|
log('健康数据', '今日体征API(F1)', tr.status === 200 ? 'PASS' : 'FAIL',
|
||||||
|
`status=${tr.status}, data=${JSON.stringify(tr.data?.data || {}).substring(0, 100)}`);
|
||||||
|
|
||||||
|
const trendR = await api('GET', '/health/vital-signs/trend?indicator=weight&range=7d', null, token);
|
||||||
|
log('健康数据', '趋势API', trendR.status === 200 ? 'PASS' : 'FAIL', `status=${trendR.status}`);
|
||||||
|
}
|
||||||
|
// 健康数据是 tabbar 页面,切回首页
|
||||||
|
await mini.switchTab('/pages/index/index');
|
||||||
|
await sleep(2000);
|
||||||
|
} catch (e) {
|
||||||
|
log('健康数据', '操作异常', 'FAIL', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 链路3: 健康录入
|
||||||
|
// ========================================
|
||||||
|
console.log('\n--- 链路3: 健康录入 ---');
|
||||||
|
try {
|
||||||
|
await nav('/pages/health/input/index');
|
||||||
|
const iPath = await curPath();
|
||||||
|
log('健康录入', '页面导航', iPath.includes('input') ? 'PASS' : 'FAIL', `路径: ${iPath}`);
|
||||||
|
|
||||||
|
const page = await curPage();
|
||||||
|
const inputs = await page.$$('input');
|
||||||
|
log('健康录入', '表单输入框', inputs.length > 0 ? 'PASS' : 'FAIL', `${inputs.length}个`);
|
||||||
|
|
||||||
|
// 尝试在第一个数字输入框输入数据
|
||||||
|
if (inputs.length > 0) {
|
||||||
|
for (const inp of inputs) {
|
||||||
|
const type = await inp.attribute('type');
|
||||||
|
if (type === 'digit' || type === 'number') {
|
||||||
|
await inp.input('65.5');
|
||||||
|
log('健康录入', '填写数据', 'PASS', '输入 65.5');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await back();
|
||||||
|
} catch (e) {
|
||||||
|
log('健康录入', '操作异常', 'FAIL', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 链路4: 日常监测
|
||||||
|
// ========================================
|
||||||
|
console.log('\n--- 链路4: 日常监测 ---');
|
||||||
|
try {
|
||||||
|
await nav('/pages/health/daily-monitoring/index');
|
||||||
|
const dPath = await curPath();
|
||||||
|
log('日常监测', '页面导航', dPath.includes('daily') ? 'PASS' : 'FAIL', `路径: ${dPath}`);
|
||||||
|
|
||||||
|
const page = await curPage();
|
||||||
|
const dmInputs = await page.$$('.dm-input');
|
||||||
|
log('日常监测', '表单字段', dmInputs.length > 0 ? 'PASS' : 'FAIL', `${dmInputs.length}个.dm-input`);
|
||||||
|
|
||||||
|
// 验证 Zod: 输入超范围值
|
||||||
|
if (dmInputs.length > 0) {
|
||||||
|
await dmInputs[0].input('9999');
|
||||||
|
log('日常监测', 'Zod超范围值', 'PASS', '输入收缩压9999(应被Zod拦截)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitBtn = await page.$('.dm-submit');
|
||||||
|
log('日常监测', '提交按钮', submitBtn ? 'PASS' : 'FAIL', submitBtn ? '找到' : '未找到');
|
||||||
|
|
||||||
|
const resetBtn = await page.$('.dm-reset');
|
||||||
|
log('日常监测', '重置按钮', resetBtn ? 'PASS' : 'WARN', resetBtn ? '找到' : '未找到');
|
||||||
|
|
||||||
|
await back();
|
||||||
|
} catch (e) {
|
||||||
|
log('日常监测', '操作异常', 'FAIL', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 链路5: 积分商城
|
||||||
|
// ========================================
|
||||||
|
console.log('\n--- 链路5: 积分商城 ---');
|
||||||
|
try {
|
||||||
|
await nav('/pages/mall/index');
|
||||||
|
const mPath = await curPath();
|
||||||
|
log('积分商城', '页面导航', mPath.includes('mall') ? 'PASS' : 'FAIL', `路径: ${mPath}`);
|
||||||
|
|
||||||
|
const page = await curPage();
|
||||||
|
const pointsCard = await page.$('.points-card');
|
||||||
|
log('积分商城', '积分卡片', pointsCard ? 'PASS' : 'WARN', pointsCard ? '显示积分' : '未显示(可能无档案)');
|
||||||
|
|
||||||
|
const checkinBtn = await page.$('.checkin-btn');
|
||||||
|
log('积分商城', '签到按钮', checkinBtn ? 'PASS' : 'WARN', checkinBtn ? '找到' : '未找到');
|
||||||
|
|
||||||
|
const products = await page.$$('.product-card');
|
||||||
|
log('积分商城', '商品列表', 'PASS', `${products.length}个商品`);
|
||||||
|
|
||||||
|
// F2 修复: 无档案降级 UI
|
||||||
|
const emptyEl = await page.$('.empty-state');
|
||||||
|
if (emptyEl && !pointsCard) {
|
||||||
|
log('积分商城', '无档案降级(F2)', 'PASS', '显示降级引导UI');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 积分商城是 tabbar 页面,切回首页
|
||||||
|
await mini.switchTab('/pages/index/index');
|
||||||
|
await sleep(2000);
|
||||||
|
} catch (e) {
|
||||||
|
log('积分商城', '操作异常', 'FAIL', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 链路6: 预约挂号
|
||||||
|
// ========================================
|
||||||
|
console.log('\n--- 链路6: 预约挂号 ---');
|
||||||
|
try {
|
||||||
|
await nav('/pages/appointment/index');
|
||||||
|
const aPath = await curPath();
|
||||||
|
log('预约', '页面导航', aPath.includes('appointment') ? 'PASS' : 'FAIL', `路径: ${aPath}`);
|
||||||
|
await back();
|
||||||
|
} catch (e) {
|
||||||
|
log('预约', '操作异常', 'FAIL', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 链路7: 家庭成员
|
||||||
|
// ========================================
|
||||||
|
console.log('\n--- 链路7: 家庭成员 ---');
|
||||||
|
try {
|
||||||
|
await nav('/pages/profile/family/index');
|
||||||
|
const fPath = await curPath();
|
||||||
|
log('家庭成员', '页面导航', fPath.includes('family') ? 'PASS' : 'FAIL', `路径: ${fPath}`);
|
||||||
|
|
||||||
|
const page = await curPage();
|
||||||
|
const memberEls = await page.$$('[class*="card"], [class*="member"], [class*="patient"]');
|
||||||
|
log('家庭成员', '列表渲染', memberEls.length > 0 ? 'PASS' : 'WARN', `${memberEls.length}个元素`);
|
||||||
|
await back();
|
||||||
|
} catch (e) {
|
||||||
|
log('家庭成员', '操作异常', 'FAIL', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 链路8: 咨询
|
||||||
|
// ========================================
|
||||||
|
console.log('\n--- 链路8: 咨询 ---');
|
||||||
|
try {
|
||||||
|
await nav('/pages/consultation/index');
|
||||||
|
const cPath = await curPath();
|
||||||
|
log('咨询', '页面导航', cPath.includes('consultation') ? 'PASS' : 'FAIL', `路径: ${cPath}`);
|
||||||
|
// 咨询页是 tabbar 页面,不能用 navigateBack,切回首页
|
||||||
|
await mini.switchTab('/pages/index/index');
|
||||||
|
await sleep(2000);
|
||||||
|
} catch (e) {
|
||||||
|
log('咨询', '操作异常', 'FAIL', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 链路9: 文章
|
||||||
|
// ========================================
|
||||||
|
console.log('\n--- 链路9: 文章 ---');
|
||||||
|
try {
|
||||||
|
await nav('/pages/article/index');
|
||||||
|
const arPath = await curPath();
|
||||||
|
log('文章', '页面导航', arPath.includes('article') ? 'PASS' : 'FAIL', `路径: ${arPath}`);
|
||||||
|
await back();
|
||||||
|
} catch (e) {
|
||||||
|
log('文章', '操作异常', 'FAIL', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 链路10: 趋势
|
||||||
|
// ========================================
|
||||||
|
console.log('\n--- 链路10: 趋势 ---');
|
||||||
|
try {
|
||||||
|
await nav('/pages/health/trend/index');
|
||||||
|
const trPath = await curPath();
|
||||||
|
log('趋势', '页面导航', trPath.includes('trend') ? 'PASS' : 'FAIL', `路径: ${trPath}`);
|
||||||
|
await back();
|
||||||
|
} catch (e) {
|
||||||
|
log('趋势', '操作异常', 'FAIL', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 链路11: 报告
|
||||||
|
// ========================================
|
||||||
|
console.log('\n--- 链路11: 报告 ---');
|
||||||
|
try {
|
||||||
|
await nav('/pages/profile/reports/index');
|
||||||
|
const rpPath = await curPath();
|
||||||
|
log('报告', '页面导航', rpPath.includes('report') ? 'PASS' : 'FAIL', `路径: ${rpPath}`);
|
||||||
|
await back();
|
||||||
|
} catch (e) {
|
||||||
|
log('报告', '操作异常', 'FAIL', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// API 数据闭环验证
|
||||||
|
// ========================================
|
||||||
|
console.log('\n--- API 数据闭环 ---');
|
||||||
|
if (token && patientId) {
|
||||||
|
const checks = [
|
||||||
|
['患者详情', 'GET', `/health/patients/${patientId}`],
|
||||||
|
['预约列表', 'GET', '/health/appointments?page=1&page_size=5'],
|
||||||
|
['咨询列表', 'GET', '/health/consultation-sessions?page=1&page_size=5'],
|
||||||
|
['日常监测', 'GET', `/health/patients/${patientId}/daily-monitoring?page=1&page_size=5`],
|
||||||
|
['积分账户', 'GET', '/health/points/account', 404], // admin 无患者档案, 404 预期
|
||||||
|
['签到状态', 'GET', '/health/points/checkin/status', 404], // admin 无患者档案, 404 预期
|
||||||
|
['商品列表', 'GET', '/health/points/products?page=1&page_size=5'],
|
||||||
|
['医生列表', 'GET', '/health/doctors?page=1&page_size=20'],
|
||||||
|
['文章列表', 'GET', '/health/articles?page=1&page_size=5&status=published'],
|
||||||
|
['随访任务', 'GET', '/health/follow-up-tasks?page=1&page_size=5'],
|
||||||
|
];
|
||||||
|
for (const check of checks) {
|
||||||
|
const [label, method, path, expected404] = Array.isArray(check) ? check : [check];
|
||||||
|
try {
|
||||||
|
const r = await api(method, path, null, token);
|
||||||
|
let detail = `status=${r.status}`;
|
||||||
|
if (r.data?.data?.name) detail += `, name=${r.data.data.name}`;
|
||||||
|
if (r.data?.data?.total !== undefined) detail += `, total=${r.data.data.total}`;
|
||||||
|
if (r.data?.data?.data?.length !== undefined) detail += `, items=${r.data.data.data.length}`;
|
||||||
|
if (r.status === 200) {
|
||||||
|
log('API闭环', label, 'PASS', detail);
|
||||||
|
} else if (r.status === 404 && expected404) {
|
||||||
|
log('API闭环', label, 'WARN', `${detail} (预期:无档案/无路由)`);
|
||||||
|
} else {
|
||||||
|
log('API闭环', label, 'FAIL', detail);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log('API闭环', label, 'FAIL', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 断开 ======
|
||||||
|
await mini.disconnect();
|
||||||
|
|
||||||
|
// ====== 汇总 ======
|
||||||
|
console.log('\n\n========================================');
|
||||||
|
console.log(' HMS 小程序端到端链路验证报告');
|
||||||
|
console.log('========================================\n');
|
||||||
|
|
||||||
|
const chains = [...new Set(results.map(r => r.chain))];
|
||||||
|
for (const chain of chains) {
|
||||||
|
const items = results.filter(r => r.chain === chain);
|
||||||
|
const p = items.filter(r => r.status === 'PASS').length;
|
||||||
|
const f = items.filter(r => r.status === 'FAIL').length;
|
||||||
|
const w = items.filter(r => r.status === 'WARN').length;
|
||||||
|
const icon = f > 0 ? '❌' : w > 0 ? '⚠️' : '✅';
|
||||||
|
console.log(`${icon} ${chain}: ${p}通过/${f}失败/${w}警告`);
|
||||||
|
for (const item of items.filter(i => i.status !== 'PASS')) {
|
||||||
|
console.log(` ${item.status === 'FAIL' ? '❌' : '⚠️'} ${item.step}: ${item.detail}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tp = results.filter(r => r.status === 'PASS').length;
|
||||||
|
const tf = results.filter(r => r.status === 'FAIL').length;
|
||||||
|
const tw = results.filter(r => r.status === 'WARN').length;
|
||||||
|
console.log(`\n总计: ${results.length}项 — ✅${tp}通过 / ❌${tf}失败 / ⚠️${tw}警告`);
|
||||||
|
console.log('========================================\n');
|
||||||
|
|
||||||
|
process.exit(tf > 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => { console.error('致命错误:', e); process.exit(1); });
|
||||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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'],
|
||||||
|
},
|
||||||
|
});
|
||||||
84
apps/miniprogram/inject-auth.cjs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* 重建后注入明文 token(无加密密钥)
|
||||||
|
*/
|
||||||
|
const automator = require('miniprogram-automator');
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
function getFreshToken() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const data = JSON.stringify({ username: 'admin', password: 'Admin@2026' });
|
||||||
|
const req = http.request({
|
||||||
|
hostname: 'localhost', port: 3000,
|
||||||
|
path: '/api/v1/auth/login', method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }
|
||||||
|
}, res => {
|
||||||
|
let body = '';
|
||||||
|
res.on('data', d => body += d);
|
||||||
|
res.on('end', () => {
|
||||||
|
try { const j = JSON.parse(body); resolve(j.data); } catch (e) { reject(e); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.write(data);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('1. 获取 token...');
|
||||||
|
const loginData = await getFreshToken();
|
||||||
|
console.log(` access: ${loginData.access_token.length} chars`);
|
||||||
|
|
||||||
|
console.log('2. 连接 DevTools...');
|
||||||
|
const mp = await automator.connect({ wsEndpoint: 'ws://localhost:9420' });
|
||||||
|
|
||||||
|
console.log('3. 写入 storage (明文模式)...');
|
||||||
|
const result = await mp.evaluate((at, rt, ud, ur, tid, pid) => {
|
||||||
|
try {
|
||||||
|
// 无加密密钥时 secureSet 走明文
|
||||||
|
// 但我们直接用 wx.setStorageSync 确保
|
||||||
|
wx.setStorageSync('access_token', at);
|
||||||
|
wx.setStorageSync('refresh_token', rt);
|
||||||
|
wx.setStorageSync('user_data', ud);
|
||||||
|
wx.setStorageSync('user_roles', ur);
|
||||||
|
wx.setStorageSync('tenant_id', tid);
|
||||||
|
wx.setStorageSync('current_patient_id', pid);
|
||||||
|
wx.setStorageSync('current_patient', {
|
||||||
|
id: pid, name: 'TestPatient', gender: 'male',
|
||||||
|
birth_date: '1990-01-15', status: 'active'
|
||||||
|
});
|
||||||
|
const v = wx.getStorageSync('access_token');
|
||||||
|
return 'ok:' + v.length;
|
||||||
|
} catch(e) { return 'err:' + e.message; }
|
||||||
|
},
|
||||||
|
loginData.access_token,
|
||||||
|
loginData.refresh_token,
|
||||||
|
JSON.stringify({
|
||||||
|
id: loginData.user.id,
|
||||||
|
username: loginData.user.username,
|
||||||
|
display_name: loginData.user.display_name,
|
||||||
|
tenant_id: '019d80da-7a2c-7820-b0a3-3d5266a3a324'
|
||||||
|
}),
|
||||||
|
JSON.stringify(['admin']),
|
||||||
|
'019d80da-7a2c-7820-b0a3-3d5266a3a324',
|
||||||
|
'019dcd34-bc4d-72c1-8c19-77ce1f4839d6'
|
||||||
|
);
|
||||||
|
console.log(` 结果: ${result}`);
|
||||||
|
|
||||||
|
console.log('4. reLaunch 首页...');
|
||||||
|
await mp.reLaunch('/pages/index/index');
|
||||||
|
await new Promise(r => setTimeout(r, 3000));
|
||||||
|
|
||||||
|
const page = await mp.currentPage();
|
||||||
|
console.log(`5. 当前页面: ${page.path}`);
|
||||||
|
|
||||||
|
if (page.path === 'pages/index/index') {
|
||||||
|
console.log('SUCCESS!');
|
||||||
|
} else {
|
||||||
|
console.log('FAILED - redirected to:', page.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
await mp.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => { console.error(e); process.exit(1); });
|
||||||
@@ -5,7 +5,8 @@
|
|||||||
"description": "HMS 健康管理平台患者小程序",
|
"description": "HMS 健康管理平台患者小程序",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:weapp": "taro build --type weapp",
|
"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": [
|
"browserslist": [
|
||||||
"last 3 versions",
|
"last 3 versions",
|
||||||
@@ -25,8 +26,8 @@
|
|||||||
"@tarojs/shared": "4.2.0",
|
"@tarojs/shared": "4.2.0",
|
||||||
"@tarojs/taro": "4.2.0",
|
"@tarojs/taro": "4.2.0",
|
||||||
"babel-preset-taro": "^4.2.0",
|
"babel-preset-taro": "^4.2.0",
|
||||||
|
"crypto-js": "^4.2.0",
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"echarts-taro3-react": "^1.0.13",
|
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
@@ -36,9 +37,13 @@
|
|||||||
"@babel/runtime": "^7.27.0",
|
"@babel/runtime": "^7.27.0",
|
||||||
"@tarojs/cli": "4.2.0",
|
"@tarojs/cli": "4.2.0",
|
||||||
"@tarojs/webpack5-runner": "4.2.0",
|
"@tarojs/webpack5-runner": "4.2.0",
|
||||||
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/react": "^18.3.0",
|
"@types/react": "^18.3.0",
|
||||||
|
"miniprogram-automator": "^0.12.1",
|
||||||
"sass": "^1.87.0",
|
"sass": "^1.87.0",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
|
"vite": "^8.0.10",
|
||||||
|
"vitest": "^4.1.5",
|
||||||
"webpack": "~5.95.0"
|
"webpack": "~5.95.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1935
apps/miniprogram/pnpm-lock.yaml
generated
@@ -3,12 +3,18 @@
|
|||||||
"miniprogramRoot": "dist/",
|
"miniprogramRoot": "dist/",
|
||||||
"compileType": "miniprogram",
|
"compileType": "miniprogram",
|
||||||
"setting": {
|
"setting": {
|
||||||
"autoAudits": false,
|
|
||||||
"urlCheck": false,
|
"urlCheck": false,
|
||||||
"es6": true,
|
"automationAudits": true,
|
||||||
"enhance": true,
|
"es6": false,
|
||||||
|
"enhance": false,
|
||||||
"compileHotReLoad": true,
|
"compileHotReLoad": true,
|
||||||
"postcss": true,
|
"postcss": false,
|
||||||
"minified": true
|
"minified": true,
|
||||||
}
|
"bundle": false,
|
||||||
}
|
"minifyWXML": true,
|
||||||
|
"packNpmManually": false,
|
||||||
|
"packNpmRelationList": [],
|
||||||
|
"ignoreUploadUnusedFiles": true
|
||||||
|
},
|
||||||
|
"condition": {}
|
||||||
|
}
|
||||||
21
apps/miniprogram/project.private.config.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"libVersion": "3.15.2",
|
||||||
|
"projectname": "hsm",
|
||||||
|
"setting": {
|
||||||
|
"urlCheck": false,
|
||||||
|
"coverView": false,
|
||||||
|
"lazyloadPlaceholderEnable": false,
|
||||||
|
"skylineRenderEnable": false,
|
||||||
|
"preloadBackgroundData": false,
|
||||||
|
"autoAudits": true,
|
||||||
|
"useApiHook": true,
|
||||||
|
"showShadowRootInWxmlPanel": false,
|
||||||
|
"useStaticServer": false,
|
||||||
|
"useLanDebug": false,
|
||||||
|
"showES6CompileOption": false,
|
||||||
|
"compileHotReLoad": true,
|
||||||
|
"checkInvalidKey": true,
|
||||||
|
"ignoreDevUnusedFiles": true,
|
||||||
|
"bigPackageSizeSupport": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,61 +3,92 @@ export default defineAppConfig({
|
|||||||
'pages/index/index',
|
'pages/index/index',
|
||||||
'pages/login/index',
|
'pages/login/index',
|
||||||
'pages/health/index',
|
'pages/health/index',
|
||||||
'pages/health/input/index',
|
'pages/messages/index',
|
||||||
'pages/health/trend/index',
|
|
||||||
'pages/health/daily-monitoring/index',
|
|
||||||
'pages/appointment/index',
|
|
||||||
'pages/appointment/create/index',
|
|
||||||
'pages/appointment/detail/index',
|
|
||||||
'pages/article/index',
|
|
||||||
'pages/article/detail/index',
|
|
||||||
'pages/report/detail/index',
|
|
||||||
'pages/ai-report/list/index',
|
|
||||||
'pages/ai-report/detail/index',
|
|
||||||
'pages/followup/detail/index',
|
|
||||||
'pages/consultation/index',
|
'pages/consultation/index',
|
||||||
'pages/consultation/detail/index',
|
'pages/consultation/detail/index',
|
||||||
'pages/mall/index',
|
'pages/mall/index',
|
||||||
'pages/mall/exchange/index',
|
|
||||||
'pages/mall/orders/index',
|
|
||||||
'pages/mall/detail/index',
|
|
||||||
'pages/profile/index',
|
'pages/profile/index',
|
||||||
'pages/profile/family/index',
|
'pages/appointment/index',
|
||||||
'pages/profile/family-add/index',
|
'pages/appointment/create/index',
|
||||||
'pages/profile/reports/index',
|
'pages/appointment/detail/index',
|
||||||
'pages/profile/followups/index',
|
|
||||||
'pages/profile/medication/index',
|
|
||||||
'pages/profile/settings/index',
|
|
||||||
'pages/legal/user-agreement',
|
'pages/legal/user-agreement',
|
||||||
'pages/legal/privacy-policy',
|
'pages/legal/privacy-policy',
|
||||||
'pages/doctor/index',
|
],
|
||||||
'pages/doctor/patients/index',
|
subPackages: [
|
||||||
'pages/doctor/patients/detail/index',
|
{
|
||||||
'pages/doctor/consultation/index',
|
root: 'pages/pkg-health',
|
||||||
'pages/doctor/consultation/detail/index',
|
pages: ['trend/index', 'input/index', 'daily-monitoring/index', 'alerts/index'],
|
||||||
'pages/doctor/followup/index',
|
},
|
||||||
'pages/doctor/followup/detail/index',
|
{
|
||||||
'pages/doctor/report/index',
|
root: 'pages/doctor',
|
||||||
'pages/doctor/report/detail/index',
|
pages: [
|
||||||
'pages/events/index',
|
'index', 'patients/index', 'patients/detail/index',
|
||||||
|
'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/pkg-mall',
|
||||||
|
pages: ['exchange/index', 'orders/index', 'detail/index'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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/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: {
|
tabBar: {
|
||||||
color: '#94A3B8',
|
color: '#A8A29E',
|
||||||
selectedColor: '#0891B2',
|
selectedColor: '#C4623A',
|
||||||
backgroundColor: '#FFFFFF',
|
backgroundColor: '#FFFFFF',
|
||||||
borderStyle: 'white',
|
borderStyle: 'white',
|
||||||
list: [
|
list: [
|
||||||
{ pagePath: 'pages/index/index', text: '首页', iconPath: 'assets/tabbar/home.png', selectedIconPath: 'assets/tabbar/home-active.png' },
|
{ 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/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/messages/index', text: '消息', iconPath: 'assets/tabbar/message.png', selectedIconPath: 'assets/tabbar/message-active.png' },
|
||||||
{ pagePath: 'pages/mall/index', text: '商城', iconPath: 'assets/tabbar/article.png', selectedIconPath: 'assets/tabbar/article-active.png' },
|
|
||||||
{ pagePath: 'pages/profile/index', text: '我的', iconPath: 'assets/tabbar/profile.png', selectedIconPath: 'assets/tabbar/profile-active.png' },
|
{ pagePath: 'pages/profile/index', text: '我的', iconPath: 'assets/tabbar/profile.png', selectedIconPath: 'assets/tabbar/profile-active.png' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
window: {
|
window: {
|
||||||
backgroundTextStyle: 'light',
|
backgroundTextStyle: 'dark',
|
||||||
navigationBarBackgroundColor: '#0891B2',
|
navigationBarBackgroundColor: '#FFFFFF',
|
||||||
navigationBarTitleText: '健康管理',
|
navigationBarTitleText: '健康管理',
|
||||||
navigationBarTextStyle: 'white',
|
navigationBarTextStyle: 'black',
|
||||||
|
enablePullDownRefresh: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
@import './styles/variables.scss';
|
@import './styles/variables.scss';
|
||||||
|
@import './styles/tokens.scss';
|
||||||
|
@import './styles/elder-mode.scss';
|
||||||
|
|
||||||
page {
|
page {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica, 'PingFang SC',
|
font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica, 'PingFang SC',
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
import { useEffect, PropsWithChildren } from 'react';
|
import { useEffect, PropsWithChildren } from 'react';
|
||||||
import Taro from '@tarojs/taro';
|
import Taro, { useDidShow } from '@tarojs/taro';
|
||||||
import ErrorBoundary from './components/ErrorBoundary';
|
import ErrorBoundary from './components/ErrorBoundary';
|
||||||
import { flushEvents } from './services/analytics';
|
import { flushEvents } from './services/analytics';
|
||||||
|
import { useAuthStore } from './stores/auth';
|
||||||
|
import { useUIStore } from './stores/ui';
|
||||||
import './app.scss';
|
import './app.scss';
|
||||||
|
|
||||||
function App({ children }: PropsWithChildren<Record<string, unknown>>) {
|
function App({ children }: PropsWithChildren<Record<string, unknown>>) {
|
||||||
|
const restoreAuth = useAuthStore((s) => s.restore);
|
||||||
|
const restoreUI = useUIStore((s) => s.restore);
|
||||||
|
|
||||||
|
useDidShow(() => {
|
||||||
|
restoreAuth();
|
||||||
|
restoreUI();
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
flushEvents();
|
flushEvents();
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 232 B After Width: | Height: | Size: 333 B |
|
Before Width: | Height: | Size: 233 B After Width: | Height: | Size: 334 B |
|
Before Width: | Height: | Size: 232 B After Width: | Height: | Size: 333 B |
|
Before Width: | Height: | Size: 233 B After Width: | Height: | Size: 334 B |
|
Before Width: | Height: | Size: 232 B After Width: | Height: | Size: 333 B |
|
Before Width: | Height: | Size: 233 B After Width: | Height: | Size: 334 B |
|
Before Width: | Height: | Size: 232 B After Width: | Height: | Size: 333 B |
|
Before Width: | Height: | Size: 233 B After Width: | Height: | Size: 334 B |
BIN
apps/miniprogram/src/assets/tabbar/message-active.png
Normal file
|
After Width: | Height: | Size: 333 B |
BIN
apps/miniprogram/src/assets/tabbar/message.png
Normal file
|
After Width: | Height: | Size: 334 B |
|
Before Width: | Height: | Size: 232 B After Width: | Height: | Size: 333 B |
|
Before Width: | Height: | Size: 233 B After Width: | Height: | Size: 334 B |
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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
apps/miniprogram/src/components/EcCanvas/index.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import React, { useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
|
||||||
|
import { Canvas, View } from '@tarojs/components';
|
||||||
|
import Taro from '@tarojs/taro';
|
||||||
|
import * as echarts from 'echarts/core';
|
||||||
|
import { LineChart } from 'echarts/charts';
|
||||||
|
import {
|
||||||
|
GridComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
MarkAreaComponent,
|
||||||
|
MarkPointComponent,
|
||||||
|
} from 'echarts/components';
|
||||||
|
import { CanvasRenderer } from 'echarts/renderers';
|
||||||
|
|
||||||
|
echarts.use([
|
||||||
|
LineChart,
|
||||||
|
GridComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
MarkAreaComponent,
|
||||||
|
MarkPointComponent,
|
||||||
|
CanvasRenderer,
|
||||||
|
]);
|
||||||
|
|
||||||
|
interface EcCanvasProps {
|
||||||
|
canvasId: string;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EcCanvasRef {
|
||||||
|
setOption: (option: echarts.EChartsOption) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EcCanvas = React.memo(React.forwardRef<EcCanvasRef, EcCanvasProps>(
|
||||||
|
({ canvasId, height = 300 }, ref) => {
|
||||||
|
const chartInstance = useRef<echarts.ECharts | null>(null);
|
||||||
|
const canvasNode = useRef<any>(null);
|
||||||
|
|
||||||
|
const initChart = async () => {
|
||||||
|
try {
|
||||||
|
const query = Taro.createSelectorQuery();
|
||||||
|
query
|
||||||
|
.select(`#${canvasId}`)
|
||||||
|
.node()
|
||||||
|
.exec((res) => {
|
||||||
|
const node = res[0]?.node;
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
canvasNode.current = node;
|
||||||
|
const dpr = Taro.getSystemInfoSync().pixelRatio;
|
||||||
|
const width = node.width || 350;
|
||||||
|
const heightVal = node.height || height;
|
||||||
|
|
||||||
|
node.width = width * dpr;
|
||||||
|
node.height = heightVal * dpr;
|
||||||
|
|
||||||
|
const ctx = node.getContext('2d');
|
||||||
|
|
||||||
|
chartInstance.current = echarts.init(ctx as any, undefined, {
|
||||||
|
renderer: 'canvas',
|
||||||
|
width,
|
||||||
|
height: heightVal,
|
||||||
|
devicePixelRatio: dpr,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('EcCanvas init failed:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initChart();
|
||||||
|
return () => {
|
||||||
|
chartInstance.current?.dispose();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
setOption: (option: echarts.EChartsOption) => {
|
||||||
|
if (chartInstance.current) {
|
||||||
|
chartInstance.current.setOption(option);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ width: '100%', height: `${height}rpx` }}>
|
||||||
|
<Canvas
|
||||||
|
type='2d'
|
||||||
|
id={canvasId}
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
EcCanvas.displayName = 'EcCanvas';
|
||||||
|
|
||||||
|
export default EcCanvas;
|
||||||
@@ -8,20 +8,33 @@
|
|||||||
padding: 120px 40px;
|
padding: 120px 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state-icon {
|
.empty-state-icon-wrap {
|
||||||
font-size: 80px;
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $surface-alt;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
margin-bottom: 24px;
|
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 {
|
.empty-state-text {
|
||||||
font-size: 30px;
|
font-size: var(--tk-font-num);
|
||||||
color: $tx2;
|
color: $tx2;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state-hint {
|
.empty-state-hint {
|
||||||
font-size: 24px;
|
font-size: var(--tk-font-h2);
|
||||||
color: $tx3;
|
color: var(--tk-text-secondary);
|
||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +45,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.empty-state-action-text {
|
.empty-state-action-text {
|
||||||
font-size: 28px;
|
font-size: var(--tk-font-body-lg);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,16 +10,19 @@ interface EmptyStateProps {
|
|||||||
onAction?: () => void;
|
onAction?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EmptyState({
|
export default React.memo(function EmptyState({
|
||||||
icon = '📭',
|
icon,
|
||||||
text,
|
text,
|
||||||
hint,
|
hint,
|
||||||
actionText,
|
actionText,
|
||||||
onAction,
|
onAction,
|
||||||
}: EmptyStateProps) {
|
}: EmptyStateProps) {
|
||||||
|
const displayChar = icon || text.charAt(0);
|
||||||
return (
|
return (
|
||||||
<View className='empty-state'>
|
<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>
|
<Text className='empty-state-text'>{text}</Text>
|
||||||
{hint && <Text className='empty-state-hint'>{hint}</Text>}
|
{hint && <Text className='empty-state-hint'>{hint}</Text>}
|
||||||
{actionText && onAction && (
|
{actionText && onAction && (
|
||||||
@@ -29,4 +32,4 @@ export default function EmptyState({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -23,13 +23,25 @@ export default class ErrorBoundary extends Component<Props, State> {
|
|||||||
console.error('[ErrorBoundary]', error, info.componentStack);
|
console.error('[ErrorBoundary]', error, info.componentStack);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleRetry = () => {
|
||||||
|
this.setState({ hasError: false });
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
return (
|
return (
|
||||||
<View style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '60vh', padding: '40px' }}>
|
<View style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '60vh', padding: '40px 24px' }}>
|
||||||
<Text style={{ fontSize: '48px', marginBottom: '20px' }}>😵</Text>
|
<View style={{ width: '64px', height: '64px', borderRadius: '32px', background: '#F0DDD4', display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: '20px' }}>
|
||||||
<Text style={{ fontSize: '32px', color: '#134E4A', marginBottom: '12px' }}>页面出了点问题</Text>
|
<Text style={{ fontFamily: 'Georgia, serif', fontSize: '28px', fontWeight: 600, color: '#8B3E1F' }}>!</Text>
|
||||||
<Text style={{ fontSize: '24px', color: '#94A3B8', marginBottom: '24px' }}>请返回重试</Text>
|
</View>
|
||||||
|
<Text style={{ fontSize: '32px', color: '#2D2A26', marginBottom: '12px', fontWeight: 600 }}>页面出了点问题</Text>
|
||||||
|
<Text style={{ fontSize: '24px', color: '#78716C', marginBottom: '32px' }}>请返回重试</Text>
|
||||||
|
<View
|
||||||
|
onClick={this.handleRetry}
|
||||||
|
style={{ background: '#C4623A', borderRadius: '12px', padding: '14px 48px' }}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#FFFFFF', fontSize: '28px' }}>重新加载</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.error-state-icon {
|
.error-state-icon {
|
||||||
font-size: 80px;
|
font-size: 80px; /* hero icon — kept as-is */
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-state-text {
|
.error-state-text {
|
||||||
font-size: 28px;
|
font-size: var(--tk-font-body-lg);
|
||||||
color: $tx2;
|
color: $tx2;
|
||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -27,6 +27,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.error-state-retry-text {
|
.error-state-retry-text {
|
||||||
font-size: 28px;
|
font-size: var(--tk-font-body-lg);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ interface ErrorStateProps {
|
|||||||
onRetry?: () => void;
|
onRetry?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ErrorState({
|
export default React.memo(function ErrorState({
|
||||||
text = '加载失败,请稍后重试',
|
text = '加载失败,请稍后重试',
|
||||||
onRetry,
|
onRetry,
|
||||||
}: ErrorStateProps) {
|
}: ErrorStateProps) {
|
||||||
@@ -22,4 +22,4 @@ export default function ErrorState({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
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
@@ -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 {
|
.loading-state-text {
|
||||||
font-size: 26px;
|
font-size: var(--tk-font-h1);
|
||||||
color: $tx3;
|
color: var(--tk-text-secondary);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ interface LoadingProps {
|
|||||||
text?: string;
|
text?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Loading({ text = '加载中...' }: LoadingProps) {
|
export default React.memo(function Loading({ text = '加载中...' }: LoadingProps) {
|
||||||
return (
|
return (
|
||||||
<View className='loading-state'>
|
<View className='loading-state'>
|
||||||
<View className='loading-spinner' />
|
<View className='loading-spinner' />
|
||||||
<Text className='loading-state-text'>{text}</Text>
|
<Text className='loading-state-text'>{text}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
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
@@ -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;
|
justify-content: center;
|
||||||
background: $bd-l;
|
background: $bd-l;
|
||||||
color: $tx3;
|
color: $tx3;
|
||||||
font-size: 24px;
|
font-size: var(--tk-font-h2);
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
|
||||||
@@ -55,8 +55,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.step-label {
|
.step-label {
|
||||||
font-size: 22px;
|
font-size: var(--tk-font-body);
|
||||||
color: $tx3;
|
color: var(--tk-text-secondary);
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ interface StepIndicatorProps {
|
|||||||
onChange?: (index: number) => void;
|
onChange?: (index: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StepIndicator({ steps, current, onChange }: StepIndicatorProps) {
|
export default React.memo(function StepIndicator({ steps, current, onChange }: StepIndicatorProps) {
|
||||||
return (
|
return (
|
||||||
<View className='step-indicator'>
|
<View className='step-indicator'>
|
||||||
{steps.map((step, idx) => {
|
{steps.map((step, idx) => {
|
||||||
@@ -39,4 +39,4 @@ export default function StepIndicator({ steps, current, onChange }: StepIndicato
|
|||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
.trend-chart {
|
.trend-chart {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.trend-chart-empty {
|
.trend-chart-empty {
|
||||||
@@ -12,6 +13,35 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.trend-chart-empty-text {
|
.trend-chart-empty-text {
|
||||||
font-size: 28px;
|
font-size: var(--tk-font-body-lg);
|
||||||
color: $tx3;
|
color: var(--tk-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-chart-skeleton {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
left: 45px;
|
||||||
|
right: 15px;
|
||||||
|
bottom: 30px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-around;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line {
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: linear-gradient(90deg, #e2e8f0 25%, #f1f5f9 50%, #e2e8f0 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line-1 { width: 70%; }
|
||||||
|
.skeleton-line-2 { width: 90%; }
|
||||||
|
.skeleton-line-3 { width: 60%; }
|
||||||
|
|
||||||
|
@keyframes skeleton-pulse {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useRef, useCallback } from 'react';
|
import React, { useEffect, useRef, useCallback } from 'react';
|
||||||
import { View, Text } from '@tarojs/components';
|
import { Canvas, View, Text } from '@tarojs/components';
|
||||||
import { EChart } from 'echarts-taro3-react';
|
import Taro from '@tarojs/taro';
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
interface TrendChartProps {
|
interface TrendChartProps {
|
||||||
@@ -11,76 +11,157 @@ interface TrendChartProps {
|
|||||||
height?: number;
|
height?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TrendChart({ data, referenceMin, referenceMax, unit = '', height = 500 }: TrendChartProps) {
|
const DPR = Taro.getSystemInfoSync().pixelRatio || 2;
|
||||||
const chartRef = useRef<any>(null);
|
|
||||||
|
|
||||||
const getOption = useCallback(() => {
|
function drawLine(
|
||||||
if (!data || data.length === 0) return null;
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
const series: any[] = [];
|
export default React.memo(function TrendChart({
|
||||||
const markArea: any = {};
|
data,
|
||||||
|
referenceMin,
|
||||||
|
referenceMax,
|
||||||
|
unit = '',
|
||||||
|
height = 500,
|
||||||
|
}: TrendChartProps) {
|
||||||
|
const canvasRef = useRef<any>(null);
|
||||||
|
|
||||||
|
const draw = useCallback(() => {
|
||||||
|
const node = canvasRef.current;
|
||||||
|
if (!node || !data || data.length === 0) return;
|
||||||
|
|
||||||
|
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) {
|
if (referenceMin != null && referenceMax != null) {
|
||||||
markArea.data = [[
|
const ry1 = toY(referenceMax);
|
||||||
{ yAxis: referenceMin, itemStyle: { color: 'rgba(5,150,105,0.08)' } },
|
const ry2 = toY(referenceMin);
|
||||||
{ yAxis: referenceMax },
|
ctx.fillStyle = 'rgba(5,150,105,0.08)';
|
||||||
]];
|
ctx.fillRect(pad.left, ry1, cw, ry2 - ry1);
|
||||||
}
|
}
|
||||||
|
|
||||||
series.push({
|
// Grid lines
|
||||||
type: 'line',
|
ctx.strokeStyle = '#F3F4F6';
|
||||||
data: data.map((d) => d.value),
|
ctx.lineWidth = 1;
|
||||||
smooth: true,
|
const gridLines = 4;
|
||||||
symbol: 'circle',
|
for (let i = 0; i <= gridLines; i++) {
|
||||||
symbolSize: 6,
|
const gy = pad.top + (ch / gridLines) * i;
|
||||||
lineStyle: { color: '#0891B2', width: 2 },
|
ctx.beginPath();
|
||||||
itemStyle: { color: '#0891B2' },
|
ctx.moveTo(pad.left, gy);
|
||||||
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)' }] } },
|
ctx.lineTo(pad.left + cw, gy);
|
||||||
markArea: markArea.data ? { silent: true, data: markArea.data } : undefined,
|
ctx.stroke();
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
// Y-axis labels
|
||||||
grid: { left: 45, right: 15, top: 20, bottom: 30 },
|
ctx.fillStyle = '#94A3B8';
|
||||||
xAxis: {
|
ctx.font = '10px sans-serif';
|
||||||
type: 'category',
|
ctx.textAlign = 'right';
|
||||||
data: data.map((d) => d.date.slice(5)),
|
for (let i = 0; i <= gridLines; i++) {
|
||||||
axisLabel: { fontSize: 10, color: '#94A3B8' },
|
const val = yMax - (yTotal / gridLines) * i;
|
||||||
axisLine: { lineStyle: { color: '#E5E7EB' } },
|
const gy = pad.top + (ch / gridLines) * i;
|
||||||
},
|
ctx.fillText(val.toFixed(1), pad.left - 6, gy + 3);
|
||||||
yAxis: {
|
}
|
||||||
type: 'value',
|
|
||||||
axisLabel: { fontSize: 10, color: '#94A3B8' },
|
// X-axis labels
|
||||||
splitLine: { lineStyle: { color: '#F3F4F6' } },
|
ctx.textAlign = 'center';
|
||||||
},
|
const step = Math.max(1, Math.floor(data.length / 5));
|
||||||
tooltip: {
|
for (let i = 0; i < data.length; i += step) {
|
||||||
trigger: 'axis',
|
const lx = toX(i);
|
||||||
formatter: (params: any) => {
|
ctx.fillText(data[i].date.slice(5), lx, h - 8);
|
||||||
const p = params[0];
|
}
|
||||||
const idx = p.dataIndex;
|
|
||||||
return `${data[idx]?.date || ''}\n${p.value}${unit ? ' ' + unit : ''}`;
|
// Area fill
|
||||||
},
|
const chartPoints = data.map((d, i) => ({ x: toX(i), y: toY(d.value) }));
|
||||||
},
|
ctx.beginPath();
|
||||||
series,
|
ctx.moveTo(chartPoints[0].x, toY(yMin));
|
||||||
};
|
ctx.lineTo(chartPoints[0].x, chartPoints[0].y);
|
||||||
}, [data, referenceMin, referenceMax, unit]);
|
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(() => {
|
useEffect(() => {
|
||||||
if (chartRef.current && data && data.length > 0) {
|
const query = Taro.createSelectorQuery();
|
||||||
const option = getOption();
|
query
|
||||||
if (option) {
|
.select('#trend-chart-canvas')
|
||||||
chartRef.current.refresh(option);
|
.node()
|
||||||
}
|
.exec((res) => {
|
||||||
}
|
const node = res[0]?.node;
|
||||||
}, [data, getOption]);
|
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) {
|
if (!data || data.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -92,7 +173,11 @@ export default function TrendChart({ data, referenceMin, referenceMax, unit = ''
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='trend-chart' style={{ height: `${height}rpx` }}>
|
<View className='trend-chart' style={{ height: `${height}rpx` }}>
|
||||||
<EChart canvasId='trend-chart-canvas' ref={chartRef} />
|
<Canvas
|
||||||
|
type='2d'
|
||||||
|
id='trend-chart-canvas'
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -14,13 +14,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.week-arrow {
|
.week-arrow {
|
||||||
font-size: 28px;
|
font-size: var(--tk-font-body-lg);
|
||||||
color: $pri;
|
color: $pri;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.week-label {
|
.week-label {
|
||||||
font-size: 24px;
|
font-size: var(--tk-font-h2);
|
||||||
color: $tx;
|
color: $tx;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
@@ -39,13 +39,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cell-weekday {
|
.cell-weekday {
|
||||||
font-size: 20px;
|
font-size: var(--tk-font-body);
|
||||||
color: $tx3;
|
color: var(--tk-text-secondary);
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell-date {
|
.cell-date {
|
||||||
font-size: 26px;
|
font-size: var(--tk-font-h1);
|
||||||
color: $tx;
|
color: $tx;
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function getWeekDates(offset: number): string[] {
|
|||||||
|
|
||||||
const WEEKDAYS = ['一', '二', '三', '四', '五', '六', '日'];
|
const WEEKDAYS = ['一', '二', '三', '四', '五', '六', '日'];
|
||||||
|
|
||||||
export default function WeekCalendar({ scheduledDates, selectedDate, onSelectDate }: WeekCalendarProps) {
|
export default React.memo(function WeekCalendar({ scheduledDates, selectedDate, onSelectDate }: WeekCalendarProps) {
|
||||||
const [weekOffset, setWeekOffset] = useState(0);
|
const [weekOffset, setWeekOffset] = useState(0);
|
||||||
const dates = getWeekDates(weekOffset);
|
const dates = getWeekDates(weekOffset);
|
||||||
const today = (() => {
|
const today = (() => {
|
||||||
@@ -60,4 +60,4 @@ export default function WeekCalendar({ scheduledDates, selectedDate, onSelectDat
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
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,24 @@
|
|||||||
|
@import '../../../styles/variables.scss';
|
||||||
|
@import '../../../styles/mixins.scss';
|
||||||
|
|
||||||
.detail-page {
|
.detail-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #f1f5f9;
|
background: $bg;
|
||||||
padding: 16px;
|
padding: 24px;
|
||||||
|
padding-bottom: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-card {
|
.detail-card {
|
||||||
background: #fff;
|
background: $card;
|
||||||
border-radius: 12px;
|
border-radius: $r;
|
||||||
padding: 16px;
|
padding: 28px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 20px;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
box-shadow: $shadow-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-type {
|
.detail-type {
|
||||||
font-size: 18px;
|
@include section-title;
|
||||||
font-weight: 600;
|
margin-bottom: 12px;
|
||||||
color: #0f172a;
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-meta {
|
.detail-meta {
|
||||||
@@ -26,27 +27,86 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.meta-item {
|
.meta-item {
|
||||||
font-size: 12px;
|
font-size: var(--tk-font-body);
|
||||||
color: #94a3b8;
|
color: $tx3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-card {
|
.content-card {
|
||||||
background: #fff;
|
background: $card;
|
||||||
border-radius: 12px;
|
border-radius: $r;
|
||||||
padding: 16px;
|
padding: 28px;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
box-shadow: $shadow-sm;
|
||||||
|
|
||||||
|
// RichText 内部样式
|
||||||
|
h1, h2, h3 {
|
||||||
|
font-weight: bold;
|
||||||
|
color: $tx;
|
||||||
|
margin: 24px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: var(--tk-font-body-lg);
|
||||||
|
color: $tx;
|
||||||
|
line-height: 1.8;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
padding-left: 32px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
font-size: var(--tk-font-body-lg);
|
||||||
|
color: $tx;
|
||||||
|
line-height: 1.8;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: $pri-d;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-content {
|
.report-content {
|
||||||
font-size: 14px;
|
font-size: var(--tk-font-body-lg);
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
color: #334155;
|
color: $tx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-text {
|
.empty-text {
|
||||||
display: block;
|
display: block;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 60px 0;
|
padding: 120px 0;
|
||||||
color: #94a3b8;
|
color: var(--tk-text-secondary);
|
||||||
font-size: 14px;
|
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 Taro, { useRouter } from '@tarojs/taro';
|
||||||
import { getAiAnalysisDetail, type AiAnalysisItem } from '@/services/ai-analysis';
|
import { getAiAnalysisDetail, type AiAnalysisItem } from '@/services/ai-analysis';
|
||||||
import Loading from '@/components/Loading';
|
import Loading from '@/components/Loading';
|
||||||
|
import { useElderClass } from '../../../hooks/useElderClass';
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
const TYPE_LABELS: Record<string, string> = {
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
@@ -12,8 +13,26 @@ const TYPE_LABELS: Record<string, string> = {
|
|||||||
report_summary_generation: '报告摘要',
|
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 {
|
function markdownToHtml(md: string): string {
|
||||||
return md
|
// 先转义 markdown 中可能存在的原始 HTML 标签
|
||||||
|
const escaped = sanitizeHtml(md);
|
||||||
|
return escaped
|
||||||
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
||||||
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
||||||
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
||||||
@@ -26,6 +45,7 @@ function markdownToHtml(md: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AiReportDetail() {
|
export default function AiReportDetail() {
|
||||||
|
const modeClass = useElderClass();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const id = router.params.id || '';
|
const id = router.params.id || '';
|
||||||
|
|
||||||
@@ -45,7 +65,7 @@ export default function AiReportDetail() {
|
|||||||
|
|
||||||
if (!analysis) {
|
if (!analysis) {
|
||||||
return (
|
return (
|
||||||
<View className='detail-page'>
|
<View className={`detail-page ${modeClass}`}>
|
||||||
<Text className='empty-text'>报告不存在</Text>
|
<Text className='empty-text'>报告不存在</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -55,16 +75,32 @@ export default function AiReportDetail() {
|
|||||||
? markdownToHtml(analysis.result_content)
|
? markdownToHtml(analysis.result_content)
|
||||||
: '<p>暂无分析结果</p>';
|
: '<p>暂无分析结果</p>';
|
||||||
|
|
||||||
|
const isTrendAnalysis = analysis.analysis_type === 'trend';
|
||||||
|
const isAutoAnalysis = (analysis.result_metadata as Record<string, unknown>)?.auto_analysis === true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='detail-page'>
|
<View className={`detail-page ${modeClass}`}>
|
||||||
<View className='detail-card'>
|
<View className='detail-card'>
|
||||||
<Text className='detail-type'>{TYPE_LABELS[analysis.analysis_type] || analysis.analysis_type}</Text>
|
<Text className='detail-type'>{TYPE_LABELS[analysis.analysis_type] || analysis.analysis_type}</Text>
|
||||||
<View className='detail-meta'>
|
<View className='detail-meta'>
|
||||||
<Text className='meta-item'>模型: {analysis.model_used}</Text>
|
<Text className='meta-item'>模型: {analysis.model_used}</Text>
|
||||||
<Text className='meta-item'>{new Date(analysis.created_at).toLocaleString('zh-CN')}</Text>
|
<Text className='meta-item'>{new Date(analysis.created_at).toLocaleString('zh-CN')}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
{isAutoAnalysis && (
|
||||||
|
<View className='auto-badge'>
|
||||||
|
<Text className='auto-badge-text'>系统自动分析</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{isTrendAnalysis && (
|
||||||
|
<View className='trend-tip-card'>
|
||||||
|
<Text className='trend-tip-text'>
|
||||||
|
趋势分析基于最小二乘法线性回归和 2 倍标准差异常检测。R² 越接近 1 表示趋势拟合越好。斜率为正表示上升趋势,斜率为负表示下降趋势。
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<View className='content-card'>
|
<View className='content-card'>
|
||||||
<RichText className='report-content' nodes={htmlContent} />
|
<RichText className='report-content' nodes={htmlContent} />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,65 +1,60 @@
|
|||||||
|
@import '../../../styles/variables.scss';
|
||||||
|
@import '../../../styles/mixins.scss';
|
||||||
|
|
||||||
.ai-report-page {
|
.ai-report-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #f1f5f9;
|
background: $bg;
|
||||||
padding: 16px;
|
padding: 24px;
|
||||||
|
padding-bottom: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
font-size: 20px;
|
@include section-title;
|
||||||
font-weight: 600;
|
|
||||||
color: #0f172a;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-scroll {
|
.report-scroll {
|
||||||
height: calc(100vh - 80px);
|
height: calc(100vh - 100px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-card {
|
.report-card {
|
||||||
background: #fff;
|
background: $card;
|
||||||
border-radius: 12px;
|
border-radius: $r;
|
||||||
padding: 16px;
|
padding: 28px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 20px;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
box-shadow: $shadow-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-type {
|
.card-type {
|
||||||
font-size: 15px;
|
font-size: var(--tk-font-body-lg);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #1e293b;
|
color: $tx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-status {
|
.card-status {
|
||||||
font-size: 12px;
|
@include tag($bd-l, $tx2);
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-completed {
|
.status-completed {
|
||||||
color: #16a34a;
|
@include tag($acc-l, $acc);
|
||||||
background: #dcfce7;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-streaming {
|
.status-streaming {
|
||||||
color: #2563eb;
|
@include tag($pri-l, $pri);
|
||||||
background: #dbeafe;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-failed {
|
.status-failed {
|
||||||
color: #dc2626;
|
@include tag($dan-l, $dan);
|
||||||
background: #fee2e2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-pending {
|
.status-pending {
|
||||||
color: #d97706;
|
@include tag($wrn-l, $wrn);
|
||||||
background: #fef3c7;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-footer {
|
.card-footer {
|
||||||
@@ -69,19 +64,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-time {
|
.card-time {
|
||||||
font-size: 12px;
|
font-size: var(--tk-font-body);
|
||||||
color: #94a3b8;
|
color: $tx3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-model {
|
.card-model {
|
||||||
font-size: 11px;
|
font-size: var(--tk-font-body);
|
||||||
color: #cbd5e1;
|
color: $tx3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-more {
|
.no-more {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 12px;
|
font-size: var(--tk-font-h2);
|
||||||
color: #94a3b8;
|
color: var(--tk-text-secondary);
|
||||||
padding: 16px 0;
|
padding: 24px 0;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Taro from '@tarojs/taro';
|
|||||||
import { listAiAnalysis, type AiAnalysisItem } from '@/services/ai-analysis';
|
import { listAiAnalysis, type AiAnalysisItem } from '@/services/ai-analysis';
|
||||||
import Loading from '@/components/Loading';
|
import Loading from '@/components/Loading';
|
||||||
import EmptyState from '@/components/EmptyState';
|
import EmptyState from '@/components/EmptyState';
|
||||||
|
import { useElderClass } from '../../../hooks/useElderClass';
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
const TYPE_LABELS: Record<string, string> = {
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
@@ -21,6 +22,7 @@ const STATUS_MAP: Record<string, { text: string; className: string }> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function AiReportList() {
|
export default function AiReportList() {
|
||||||
|
const modeClass = useElderClass();
|
||||||
const [list, setList] = useState<AiAnalysisItem[]>([]);
|
const [list, setList] = useState<AiAnalysisItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
@@ -60,14 +62,14 @@ export default function AiReportList() {
|
|||||||
|
|
||||||
if (list.length === 0) {
|
if (list.length === 0) {
|
||||||
return (
|
return (
|
||||||
<View className='ai-report-page'>
|
<View className={`ai-report-page ${modeClass}`}>
|
||||||
<EmptyState text='暂无 AI 分析报告' />
|
<EmptyState text='暂无 AI 分析报告' />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='ai-report-page'>
|
<View className={`ai-report-page ${modeClass}`}>
|
||||||
<View className='page-title'>AI 分析报告</View>
|
<View className='page-title'>AI 分析报告</View>
|
||||||
<ScrollView scrollY className='report-scroll' onScrollToLower={loadMore}>
|
<ScrollView scrollY className='report-scroll' onScrollToLower={loadMore}>
|
||||||
{list.map((item) => {
|
{list.map((item) => {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
@import '../../../styles/variables.scss';
|
@import '../../../styles/variables.scss';
|
||||||
|
@import '../../../styles/mixins.scss';
|
||||||
|
|
||||||
.create-page {
|
.create-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: $bg;
|
background: $bg;
|
||||||
padding-bottom: 140px;
|
padding-bottom: 160px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 步骤内容 */
|
/* 步骤内容 */
|
||||||
@@ -11,6 +12,10 @@
|
|||||||
padding: 32px 24px;
|
padding: 32px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.step-title {
|
||||||
|
@include section-title;
|
||||||
|
}
|
||||||
|
|
||||||
/* 科室宫格 */
|
/* 科室宫格 */
|
||||||
.dept-grid {
|
.dept-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -21,33 +26,62 @@
|
|||||||
.dept-card {
|
.dept-card {
|
||||||
background: $card;
|
background: $card;
|
||||||
border-radius: $r;
|
border-radius: $r;
|
||||||
padding: 24px 12px;
|
padding: 28px 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
box-shadow: $shadow-sm;
|
||||||
|
|
||||||
|
&.dept-selected {
|
||||||
|
border-color: $pri;
|
||||||
|
background: $pri-l;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dept-card.dept-selected {
|
.dept-initial-circle {
|
||||||
border-color: $pri;
|
width: 64px;
|
||||||
background: $pri-surface;
|
height: 64px;
|
||||||
|
border-radius: $r;
|
||||||
|
background: $pri-l;
|
||||||
|
@include flex-center;
|
||||||
|
|
||||||
|
.dept-selected & {
|
||||||
|
background: $pri;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dept-icon {
|
.dept-initial-text {
|
||||||
font-size: 40px;
|
font-family: 'Georgia', 'Times New Roman', serif;
|
||||||
|
font-size: var(--tk-font-num);
|
||||||
|
font-weight: bold;
|
||||||
|
color: $pri;
|
||||||
|
|
||||||
|
.dept-selected & {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dept-label {
|
.dept-label {
|
||||||
font-size: 26px;
|
font-size: var(--tk-font-h2);
|
||||||
color: $tx;
|
color: $tx;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 时段卡片 */
|
/* 时段 */
|
||||||
.slot-section {
|
.slot-section {
|
||||||
margin-top: 24px;
|
margin-top: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-section-title {
|
||||||
|
font-family: 'Georgia', 'Times New Roman', serif;
|
||||||
|
font-size: var(--tk-font-body-lg);
|
||||||
|
font-weight: bold;
|
||||||
|
color: $tx;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slot-grid {
|
.slot-grid {
|
||||||
@@ -59,63 +93,101 @@
|
|||||||
.slot-card {
|
.slot-card {
|
||||||
background: $card;
|
background: $card;
|
||||||
border-radius: $r-sm;
|
border-radius: $r-sm;
|
||||||
padding: 16px 20px;
|
padding: 20px 24px;
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
|
||||||
&.slot-few { border-color: $wrn; }
|
&.slot-few {
|
||||||
&.slot-full { opacity: 0.5; background: $bd-l; }
|
border-color: $wrn;
|
||||||
&.slot-selected { border-color: $pri; background: $pri-surface; }
|
}
|
||||||
|
&.slot-full {
|
||||||
|
opacity: 0.4;
|
||||||
|
background: $bd-l;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
&.slot-selected {
|
||||||
|
border-color: $pri;
|
||||||
|
background: $pri-l;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.slot-time {
|
.slot-time {
|
||||||
font-size: 28px;
|
@include serif-number;
|
||||||
|
font-size: var(--tk-font-body-lg);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: $tx;
|
color: $tx;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slot-count {
|
.slot-count {
|
||||||
font-size: 22px;
|
font-size: var(--tk-font-body);
|
||||||
color: $tx3;
|
color: $tx3;
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 4px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slot-few .slot-count { color: $wrn; }
|
.slot-few .slot-count { color: $wrn; }
|
||||||
.slot-full .slot-count { color: $dan; }
|
.slot-full .slot-count { color: $dan; }
|
||||||
|
|
||||||
.step-title {
|
/* 确认卡片 (step 3 医生信息) */
|
||||||
font-size: 32px;
|
.confirm-card {
|
||||||
font-weight: bold;
|
|
||||||
color: $tx;
|
|
||||||
margin-bottom: 28px;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 选择器卡片 */
|
|
||||||
.picker-card {
|
|
||||||
background: $card;
|
background: $card;
|
||||||
border-radius: $r;
|
border-radius: $r;
|
||||||
padding: 24px 28px;
|
padding: 24px 28px;
|
||||||
display: flex;
|
margin-bottom: 24px;
|
||||||
justify-content: space-between;
|
box-shadow: $shadow-sm;
|
||||||
align-items: center;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.picker-value {
|
.confirm-row {
|
||||||
font-size: 28px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-icon-wrap {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $pri-l;
|
||||||
|
@include flex-center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-icon-serif {
|
||||||
|
font-family: 'Georgia', 'Times New Roman', serif;
|
||||||
|
font-size: var(--tk-font-h2);
|
||||||
|
font-weight: bold;
|
||||||
|
color: $pri;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-label {
|
||||||
|
font-size: var(--tk-font-body);
|
||||||
|
color: $tx3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-value {
|
||||||
|
font-size: var(--tk-font-body-lg);
|
||||||
|
font-weight: bold;
|
||||||
color: $tx;
|
color: $tx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.picker-value.placeholder {
|
.confirm-dept-tag {
|
||||||
color: $tx3;
|
@include tag($pri-l, $pri);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.picker-arrow {
|
.confirm-dept-text {
|
||||||
font-size: 24px;
|
font-size: var(--tk-font-body);
|
||||||
color: $tx3;
|
font-weight: 500;
|
||||||
|
color: $pri;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 医生列表 */
|
/* 医生列表 */
|
||||||
@@ -132,29 +204,28 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
box-shadow: $shadow-sm;
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
}
|
|
||||||
|
|
||||||
.doctor-card.doctor-selected {
|
&.doctor-selected {
|
||||||
border-color: $pri;
|
border-color: $pri;
|
||||||
background: $pri-surface;
|
background: $pri-l;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.doctor-avatar {
|
.doctor-avatar {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
height: 80px;
|
height: 80px;
|
||||||
border-radius: 50%;
|
border-radius: $r;
|
||||||
background: $pri-l;
|
background: $pri-l;
|
||||||
display: flex;
|
@include flex-center;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.doctor-avatar-text {
|
.doctor-avatar-text {
|
||||||
font-size: 32px;
|
font-family: 'Georgia', 'Times New Roman', serif;
|
||||||
|
font-size: var(--tk-font-num);
|
||||||
color: $pri;
|
color: $pri;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
@@ -167,69 +238,68 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.doctor-name {
|
.doctor-name {
|
||||||
font-size: 30px;
|
font-size: var(--tk-font-num);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: $tx;
|
color: $tx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.doctor-title {
|
.doctor-title {
|
||||||
font-size: 24px;
|
font-size: var(--tk-font-h2);
|
||||||
color: $tx2;
|
color: $tx2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.doctor-specialty {
|
.doctor-specialty {
|
||||||
font-size: 22px;
|
font-size: var(--tk-font-body);
|
||||||
color: $pri;
|
color: $pri;
|
||||||
}
|
}
|
||||||
|
|
||||||
.doctor-check {
|
.doctor-check {
|
||||||
font-size: 32px;
|
width: 44px;
|
||||||
color: $pri;
|
height: 44px;
|
||||||
|
border-radius: $r-pill;
|
||||||
|
background: $pri;
|
||||||
|
@include flex-center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-check-text {
|
||||||
|
font-size: var(--tk-font-h2);
|
||||||
|
color: white;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 表单 */
|
/* 表单 */
|
||||||
.form-group {
|
.form-group {
|
||||||
margin-bottom: 28px;
|
margin-top: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-label {
|
.form-label {
|
||||||
font-size: 26px;
|
font-size: var(--tk-font-h1);
|
||||||
color: $tx2;
|
color: $tx2;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-static {
|
|
||||||
background: $card;
|
|
||||||
border-radius: $r-sm;
|
|
||||||
padding: 24px 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-static-text {
|
|
||||||
font-size: 28px;
|
|
||||||
color: $tx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input {
|
.form-input {
|
||||||
background: $card;
|
background: $card;
|
||||||
border-radius: $r-sm;
|
border-radius: $r-sm;
|
||||||
padding: 24px 28px;
|
padding: 24px 28px;
|
||||||
font-size: 28px;
|
font-size: var(--tk-font-body-lg);
|
||||||
color: $tx;
|
color: $tx;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 空状态 */
|
/* 空状态 */
|
||||||
.empty-state {
|
.empty-hint {
|
||||||
padding: 80px 0;
|
padding: 80px 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-text {
|
.empty-text {
|
||||||
font-size: 28px;
|
font-size: var(--tk-font-body-lg);
|
||||||
color: $tx3;
|
color: var(--tk-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 底部操作栏 */
|
/* 底部操作栏 */
|
||||||
@@ -244,7 +314,7 @@
|
|||||||
padding-bottom: constant(safe-area-inset-bottom);
|
padding-bottom: constant(safe-area-inset-bottom);
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
background: $card;
|
background: $card;
|
||||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
|
box-shadow: $shadow-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
@@ -261,7 +331,7 @@
|
|||||||
|
|
||||||
.btn-next,
|
.btn-next,
|
||||||
.btn-submit {
|
.btn-submit {
|
||||||
background: linear-gradient(135deg, $pri 0%, $pri-d 100%);
|
background: $pri;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-disabled {
|
.btn-disabled {
|
||||||
@@ -269,7 +339,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-text {
|
.btn-text {
|
||||||
font-size: 30px;
|
font-size: var(--tk-font-num);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: $tx2;
|
color: $tx2;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,15 +7,16 @@ import { TEMPLATE_IDS } from '@/services/wechat-templates';
|
|||||||
import { trackEvent } from '@/services/analytics';
|
import { trackEvent } from '@/services/analytics';
|
||||||
import StepIndicator from '../../../components/StepIndicator';
|
import StepIndicator from '../../../components/StepIndicator';
|
||||||
import WeekCalendar from '../../../components/WeekCalendar';
|
import WeekCalendar from '../../../components/WeekCalendar';
|
||||||
|
import { useElderClass } from '../../../hooks/useElderClass';
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
const DEPARTMENTS = [
|
const DEPARTMENTS = [
|
||||||
{ label: '内科', icon: '🫀' },
|
{ label: '内科', initial: '内' },
|
||||||
{ label: '外科', icon: '🔪' },
|
{ label: '外科', initial: '外' },
|
||||||
{ label: '妇科', icon: '👩⚕️' },
|
{ label: '妇科', initial: '妇' },
|
||||||
{ label: '儿科', icon: '👶' },
|
{ label: '儿科', initial: '儿' },
|
||||||
{ label: '体检中心', icon: '🏥' },
|
{ label: '体检中心', initial: '检' },
|
||||||
{ label: '中医科', icon: '🌿' },
|
{ label: '中医科', initial: '中' },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface DoctorItem {
|
interface DoctorItem {
|
||||||
@@ -44,6 +45,7 @@ export default function AppointmentCreate() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [schedules, setSchedules] = useState<any[]>([]);
|
const [schedules, setSchedules] = useState<any[]>([]);
|
||||||
const [timeSlots, setTimeSlots] = useState<TimeSlot[]>([]);
|
const [timeSlots, setTimeSlots] = useState<TimeSlot[]>([]);
|
||||||
|
const modeClass = useElderClass();
|
||||||
|
|
||||||
const currentPatient = useAuthStore((s) => s.currentPatient);
|
const currentPatient = useAuthStore((s) => s.currentPatient);
|
||||||
|
|
||||||
@@ -83,14 +85,13 @@ export default function AppointmentCreate() {
|
|||||||
const onSelectDate = useCallback((date: string) => {
|
const onSelectDate = useCallback((date: string) => {
|
||||||
setAppointmentDate(date);
|
setAppointmentDate(date);
|
||||||
setTimeSlot('');
|
setTimeSlot('');
|
||||||
// 从排班数据中提取时段
|
|
||||||
const daySlots = schedules
|
const daySlots = schedules
|
||||||
.filter((s: any) => (s.date || s.appointment_date) === date)
|
.filter((s: any) => (s.date || s.appointment_date) === date)
|
||||||
.map((s: any) => ({
|
.map((s: any) => ({
|
||||||
start_time: s.start_time || '',
|
start_time: s.start_time || '',
|
||||||
end_time: s.end_time || '',
|
end_time: s.end_time || '',
|
||||||
label: `${s.start_time || ''}-${s.end_time || ''}`,
|
label: `${s.start_time || ''}-${s.end_time || ''}`,
|
||||||
available_count: s.available_count ?? (s.max_patients ?? 10),
|
available_count: s.available_count ?? (s.max_appointments - (s.current_appointments || 0)),
|
||||||
}));
|
}));
|
||||||
setTimeSlots(daySlots);
|
setTimeSlots(daySlots);
|
||||||
}, [schedules]);
|
}, [schedules]);
|
||||||
@@ -120,7 +121,6 @@ export default function AppointmentCreate() {
|
|||||||
});
|
});
|
||||||
Taro.showToast({ title: '预约成功', icon: 'success' });
|
Taro.showToast({ title: '预约成功', icon: 'success' });
|
||||||
trackEvent('appointment_create', { doctor_id: selectedDoctor.id, date: appointmentDate });
|
trackEvent('appointment_create', { doctor_id: selectedDoctor.id, date: appointmentDate });
|
||||||
// 订阅消息引导
|
|
||||||
const tmplId = TEMPLATE_IDS.APPOINTMENT_REMINDER;
|
const tmplId = TEMPLATE_IDS.APPOINTMENT_REMINDER;
|
||||||
if (tmplId) {
|
if (tmplId) {
|
||||||
try {
|
try {
|
||||||
@@ -150,7 +150,7 @@ export default function AppointmentCreate() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='create-page'>
|
<View className={`create-page ${modeClass}`}>
|
||||||
<StepIndicator
|
<StepIndicator
|
||||||
steps={[{ label: '选科室' }, { label: '选医生' }, { label: '选时段' }]}
|
steps={[{ label: '选科室' }, { label: '选医生' }, { label: '选时段' }]}
|
||||||
current={currentStep}
|
current={currentStep}
|
||||||
@@ -168,7 +168,9 @@ export default function AppointmentCreate() {
|
|||||||
key={dept.label}
|
key={dept.label}
|
||||||
onClick={() => onSelectDept(dept.label)}
|
onClick={() => onSelectDept(dept.label)}
|
||||||
>
|
>
|
||||||
<Text className='dept-icon'>{dept.icon}</Text>
|
<View className='dept-initial-circle'>
|
||||||
|
<Text className='dept-initial-text'>{dept.initial}</Text>
|
||||||
|
</View>
|
||||||
<Text className='dept-label'>{dept.label}</Text>
|
<Text className='dept-label'>{dept.label}</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
@@ -181,7 +183,9 @@ export default function AppointmentCreate() {
|
|||||||
<View className='step-content'>
|
<View className='step-content'>
|
||||||
<Text className='step-title'>{department} - 请选择医生</Text>
|
<Text className='step-title'>{department} - 请选择医生</Text>
|
||||||
{doctors.length === 0 ? (
|
{doctors.length === 0 ? (
|
||||||
<View className='empty-hint'><Text className='empty-text'>暂无可选医生</Text></View>
|
<View className='empty-hint'>
|
||||||
|
<Text className='empty-text'>暂无可选医生</Text>
|
||||||
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View className='doctor-list'>
|
<View className='doctor-list'>
|
||||||
{doctors.map((doc) => (
|
{doctors.map((doc) => (
|
||||||
@@ -190,13 +194,19 @@ export default function AppointmentCreate() {
|
|||||||
key={doc.id}
|
key={doc.id}
|
||||||
onClick={() => onSelectDoctor(doc)}
|
onClick={() => onSelectDoctor(doc)}
|
||||||
>
|
>
|
||||||
<View className='doctor-avatar'><Text className='doctor-avatar-text'>{doc.name.charAt(0)}</Text></View>
|
<View className='doctor-avatar'>
|
||||||
|
<Text className='doctor-avatar-text'>{doc.name.charAt(0)}</Text>
|
||||||
|
</View>
|
||||||
<View className='doctor-detail'>
|
<View className='doctor-detail'>
|
||||||
<Text className='doctor-name'>{doc.name}</Text>
|
<Text className='doctor-name'>{doc.name}</Text>
|
||||||
<Text className='doctor-title'>{doc.title || '医生'}</Text>
|
<Text className='doctor-title'>{doc.title || '医生'}</Text>
|
||||||
{doc.specialty && <Text className='doctor-specialty'>{doc.specialty}</Text>}
|
{doc.specialty && <Text className='doctor-specialty'>{doc.specialty}</Text>}
|
||||||
</View>
|
</View>
|
||||||
{selectedDoctor?.id === doc.id && <Text className='doctor-check'>✓</Text>}
|
{selectedDoctor?.id === doc.id && (
|
||||||
|
<View className='doctor-check'>
|
||||||
|
<Text className='doctor-check-text'>✓</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
@@ -208,9 +218,20 @@ export default function AppointmentCreate() {
|
|||||||
{currentStep === 2 && (
|
{currentStep === 2 && (
|
||||||
<View className='step-content'>
|
<View className='step-content'>
|
||||||
<Text className='step-title'>选择就诊时间</Text>
|
<Text className='step-title'>选择就诊时间</Text>
|
||||||
<View className='form-group'>
|
|
||||||
<Text className='form-label'>医生</Text>
|
<View className='confirm-card'>
|
||||||
<View className='form-static'><Text className='form-static-text'>{selectedDoctor?.name} - {department}</Text></View>
|
<View className='confirm-row'>
|
||||||
|
<View className='confirm-icon-wrap'>
|
||||||
|
<Text className='confirm-icon-serif'>医</Text>
|
||||||
|
</View>
|
||||||
|
<View className='confirm-info'>
|
||||||
|
<Text className='confirm-label'>主治医生</Text>
|
||||||
|
<Text className='confirm-value'>{selectedDoctor?.name}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='confirm-dept-tag'>
|
||||||
|
<Text className='confirm-dept-text'>{department}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<WeekCalendar
|
<WeekCalendar
|
||||||
@@ -221,7 +242,7 @@ export default function AppointmentCreate() {
|
|||||||
|
|
||||||
{appointmentDate && timeSlots.length > 0 && (
|
{appointmentDate && timeSlots.length > 0 && (
|
||||||
<View className='slot-section'>
|
<View className='slot-section'>
|
||||||
<Text className='form-label'>选择时段</Text>
|
<Text className='slot-section-title'>选择时段</Text>
|
||||||
<View className='slot-grid'>
|
<View className='slot-grid'>
|
||||||
{timeSlots.map((slot) => (
|
{timeSlots.map((slot) => (
|
||||||
<View
|
<View
|
||||||
@@ -230,7 +251,9 @@ export default function AppointmentCreate() {
|
|||||||
onClick={slot.available_count > 0 ? () => setTimeSlot(slot.label) : undefined}
|
onClick={slot.available_count > 0 ? () => setTimeSlot(slot.label) : undefined}
|
||||||
>
|
>
|
||||||
<Text className='slot-time'>{slot.label}</Text>
|
<Text className='slot-time'>{slot.label}</Text>
|
||||||
<Text className='slot-count'>{slot.available_count > 0 ? `剩余 ${slot.available_count} 位` : '已满'}</Text>
|
<Text className='slot-count'>
|
||||||
|
{slot.available_count > 0 ? `剩余 ${slot.available_count} 位` : '已满'}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
@@ -239,7 +262,12 @@ export default function AppointmentCreate() {
|
|||||||
|
|
||||||
<View className='form-group'>
|
<View className='form-group'>
|
||||||
<Text className='form-label'>备注(选填)</Text>
|
<Text className='form-label'>备注(选填)</Text>
|
||||||
<Input className='form-input' placeholder='请简要描述症状' value={reason} onInput={(e) => setReason(e.detail.value)} />
|
<Input
|
||||||
|
className='form-input'
|
||||||
|
placeholder='请简要描述症状'
|
||||||
|
value={reason}
|
||||||
|
onInput={(e) => setReason(e.detail.value)}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -256,7 +284,10 @@ export default function AppointmentCreate() {
|
|||||||
<Text className='btn-text btn-text-white'>下一步</Text>
|
<Text className='btn-text btn-text-white'>下一步</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View className={`btn btn-submit ${loading ? 'btn-disabled' : ''}`} onClick={loading ? undefined : handleSubmit}>
|
<View
|
||||||
|
className={`btn btn-submit ${loading ? 'btn-disabled' : ''}`}
|
||||||
|
onClick={loading ? undefined : handleSubmit}
|
||||||
|
>
|
||||||
<Text className='btn-text btn-text-white'>{loading ? '提交中...' : '确认预约'}</Text>
|
<Text className='btn-text btn-text-white'>{loading ? '提交中...' : '确认预约'}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
@import '../../../styles/variables.scss';
|
@import '../../../styles/variables.scss';
|
||||||
|
@import '../../../styles/mixins.scss';
|
||||||
|
|
||||||
.detail-page {
|
.detail-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: $bg;
|
background: $bg;
|
||||||
padding-bottom: 140px;
|
padding-bottom: 160px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 顶部导航 */
|
/* 顶部导航 */
|
||||||
@@ -13,7 +14,8 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
padding-top: 48px;
|
padding-top: 48px;
|
||||||
background: linear-gradient(135deg, $pri 0%, $pri-d 100%);
|
background: $card;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-btn {
|
.back-btn {
|
||||||
@@ -21,14 +23,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.back-text {
|
.back-text {
|
||||||
font-size: 28px;
|
font-size: var(--tk-font-body-lg);
|
||||||
color: white;
|
color: $pri;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-title {
|
.header-title {
|
||||||
font-size: 34px;
|
font-family: 'Georgia', 'Times New Roman', serif;
|
||||||
|
font-size: var(--tk-font-num-lg);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: white;
|
color: $tx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-placeholder {
|
.header-placeholder {
|
||||||
@@ -40,53 +44,55 @@
|
|||||||
background: $card;
|
background: $card;
|
||||||
border-radius: $r-lg;
|
border-radius: $r-lg;
|
||||||
padding: 40px 32px;
|
padding: 40px 32px;
|
||||||
margin: -20px 24px 24px;
|
margin: 20px 24px 24px;
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
box-shadow: $shadow-md;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge {
|
.status-tag {
|
||||||
|
@include tag($bd-l, $tx3);
|
||||||
|
margin-bottom: 8px;
|
||||||
padding: 8px 32px;
|
padding: 8px 32px;
|
||||||
border-radius: 24px;
|
border-radius: $r-pill;
|
||||||
margin-bottom: 12px;
|
|
||||||
|
|
||||||
.status-badge-text {
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.tag-pending {
|
&.tag-pending {
|
||||||
background: $wrn-l;
|
background: $wrn-l;
|
||||||
.status-badge-text { color: $wrn; }
|
.status-tag-text { color: $wrn; }
|
||||||
}
|
}
|
||||||
|
|
||||||
&.tag-confirmed {
|
&.tag-confirmed {
|
||||||
background: $acc-l;
|
background: $acc-l;
|
||||||
.status-badge-text { color: $acc; }
|
.status-tag-text { color: $acc; }
|
||||||
}
|
}
|
||||||
|
|
||||||
&.tag-cancelled {
|
&.tag-cancelled {
|
||||||
background: $bd-l;
|
background: $bd-l;
|
||||||
.status-badge-text { color: $tx3; }
|
.status-tag-text { color: $tx3; }
|
||||||
}
|
}
|
||||||
|
|
||||||
&.tag-completed {
|
&.tag-completed {
|
||||||
background: $pri-l;
|
background: $pri-l;
|
||||||
.status-badge-text { color: $pri; }
|
.status-tag-text { color: $pri; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-tag-text {
|
||||||
|
font-size: var(--tk-font-body-lg);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
.status-doctor {
|
.status-doctor {
|
||||||
font-size: 36px;
|
font-family: 'Georgia', 'Times New Roman', serif;
|
||||||
|
font-size: var(--tk-font-num-lg);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: $tx;
|
color: $tx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dept {
|
.status-dept {
|
||||||
font-size: 26px;
|
font-size: var(--tk-font-h1);
|
||||||
color: $tx2;
|
color: $tx2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,22 +102,19 @@
|
|||||||
background: $card;
|
background: $card;
|
||||||
border-radius: $r;
|
border-radius: $r;
|
||||||
padding: 28px;
|
padding: 28px;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
box-shadow: $shadow-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 30px;
|
@include section-title;
|
||||||
font-weight: bold;
|
|
||||||
color: $tx;
|
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-item {
|
.info-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 16px 0;
|
padding: 18px 0;
|
||||||
border-bottom: 1px solid $bd-l;
|
border-bottom: 1px solid $bd-l;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,20 +122,49 @@
|
|||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.info-label-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-icon-serif {
|
||||||
|
font-family: 'Georgia', 'Times New Roman', serif;
|
||||||
|
font-size: var(--tk-font-body);
|
||||||
|
color: $pri;
|
||||||
|
background: $pri-l;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
.info-label {
|
.info-label {
|
||||||
font-size: 26px;
|
font-size: var(--tk-font-h1);
|
||||||
color: $tx2;
|
color: $tx2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-value {
|
.info-value {
|
||||||
font-size: 26px;
|
font-size: var(--tk-font-h1);
|
||||||
color: $tx;
|
color: $tx;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.info-date {
|
||||||
|
@include serif-number;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-time {
|
||||||
|
@include serif-number;
|
||||||
|
}
|
||||||
|
|
||||||
.info-id {
|
.info-id {
|
||||||
font-size: 22px;
|
@include serif-number;
|
||||||
color: $tx3;
|
font-size: var(--tk-font-body);
|
||||||
|
color: var(--tk-text-secondary);
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
@@ -147,7 +179,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tips-title {
|
.tips-title {
|
||||||
font-size: 26px;
|
font-family: 'Georgia', 'Times New Roman', serif;
|
||||||
|
font-size: var(--tk-font-body-lg);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: $wrn;
|
color: $wrn;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
@@ -155,7 +188,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tips-text {
|
.tips-text {
|
||||||
font-size: 24px;
|
font-size: var(--tk-font-h2);
|
||||||
color: $tx2;
|
color: $tx2;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
@@ -170,7 +203,7 @@
|
|||||||
padding-bottom: constant(safe-area-inset-bottom);
|
padding-bottom: constant(safe-area-inset-bottom);
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
background: $card;
|
background: $card;
|
||||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
|
box-shadow: $shadow-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cancel-btn {
|
.cancel-btn {
|
||||||
@@ -186,31 +219,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cancel-text {
|
.cancel-text {
|
||||||
font-size: 30px;
|
font-size: var(--tk-font-num);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: $dan;
|
color: $dan;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 空状态 */
|
|
||||||
.empty-state {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: 120px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-icon {
|
|
||||||
font-size: 80px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-text {
|
|
||||||
font-size: 30px;
|
|
||||||
color: $tx2;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-hint {
|
|
||||||
font-size: 24px;
|
|
||||||
color: $tx3;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { getAppointment, cancelAppointment } from '../../../services/appointment
|
|||||||
import type { Appointment } from '../../../services/appointment';
|
import type { Appointment } from '../../../services/appointment';
|
||||||
import Loading from '../../../components/Loading';
|
import Loading from '../../../components/Loading';
|
||||||
import ErrorState from '../../../components/ErrorState';
|
import ErrorState from '../../../components/ErrorState';
|
||||||
|
import { useElderClass } from '../../../hooks/useElderClass';
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
const STATUS_MAP: Record<string, { label: string; className: string }> = {
|
const STATUS_MAP: Record<string, { label: string; className: string }> = {
|
||||||
@@ -22,6 +23,7 @@ export default function AppointmentDetail() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const [cancelling, setCancelling] = useState(false);
|
const [cancelling, setCancelling] = useState(false);
|
||||||
|
const modeClass = useElderClass();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -65,7 +67,7 @@ export default function AppointmentDetail() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<View className='detail-page'>
|
<View className={`detail-page ${modeClass}`}>
|
||||||
<View className='detail-header'>
|
<View className='detail-header'>
|
||||||
<View className='back-btn' onClick={goBack}><Text className='back-text'>返回</Text></View>
|
<View className='back-btn' onClick={goBack}><Text className='back-text'>返回</Text></View>
|
||||||
<Text className='header-title'>预约详情</Text>
|
<Text className='header-title'>预约详情</Text>
|
||||||
@@ -78,7 +80,7 @@ export default function AppointmentDetail() {
|
|||||||
|
|
||||||
if (error || !appointment) {
|
if (error || !appointment) {
|
||||||
return (
|
return (
|
||||||
<View className='detail-page'>
|
<View className={`detail-page ${modeClass}`}>
|
||||||
<View className='detail-header'>
|
<View className='detail-header'>
|
||||||
<View className='back-btn' onClick={goBack}><Text className='back-text'>返回</Text></View>
|
<View className='back-btn' onClick={goBack}><Text className='back-text'>返回</Text></View>
|
||||||
<Text className='header-title'>预约详情</Text>
|
<Text className='header-title'>预约详情</Text>
|
||||||
@@ -90,41 +92,60 @@ export default function AppointmentDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='detail-page'>
|
<View className={`detail-page ${modeClass}`}>
|
||||||
<View className='detail-header'>
|
<View className='detail-header'>
|
||||||
<View className='back-btn' onClick={goBack}><Text className='back-text'>返回</Text></View>
|
<View className='back-btn' onClick={goBack}><Text className='back-text'>返回</Text></View>
|
||||||
<Text className='header-title'>预约详情</Text>
|
<Text className='header-title'>预约详情</Text>
|
||||||
<View className='header-placeholder' />
|
<View className='header-placeholder' />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* 状态卡片 */}
|
||||||
<View className='status-card'>
|
<View className='status-card'>
|
||||||
<View className={`status-badge ${status.className}`}>
|
<View className={`status-tag ${status.className}`}>
|
||||||
<Text className='status-badge-text'>{status.label}</Text>
|
<Text className='status-tag-text'>{status.label}</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text className='status-doctor'>{appointment.doctor_name}</Text>
|
<Text className='status-doctor'>{appointment.doctor_name}</Text>
|
||||||
<Text className='status-dept'>{appointment.department}</Text>
|
<Text className='status-dept'>{appointment.department || ''}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* 预约信息 */}
|
||||||
<View className='info-section'>
|
<View className='info-section'>
|
||||||
<Text className='section-title'>预约信息</Text>
|
<Text className='section-title'>预约信息</Text>
|
||||||
|
|
||||||
<View className='info-item'>
|
<View className='info-item'>
|
||||||
<Text className='info-label'>就诊人</Text>
|
<View className='info-label-wrap'>
|
||||||
|
<Text className='info-icon-serif'>患</Text>
|
||||||
|
<Text className='info-label'>就诊人</Text>
|
||||||
|
</View>
|
||||||
<Text className='info-value'>{appointment.patient_name}</Text>
|
<Text className='info-value'>{appointment.patient_name}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='info-item'>
|
<View className='info-item'>
|
||||||
<Text className='info-label'>就诊日期</Text>
|
<View className='info-label-wrap'>
|
||||||
<Text className='info-value'>{appointment.appointment_date}</Text>
|
<Text className='info-icon-serif'>日</Text>
|
||||||
|
<Text className='info-label'>就诊日期</Text>
|
||||||
|
</View>
|
||||||
|
<Text className='info-value info-date'>{appointment.appointment_date}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='info-item'>
|
<View className='info-item'>
|
||||||
<Text className='info-label'>就诊时段</Text>
|
<View className='info-label-wrap'>
|
||||||
<Text className='info-value'>{appointment.start_time} - {appointment.end_time}</Text>
|
<Text className='info-icon-serif'>时</Text>
|
||||||
|
<Text className='info-label'>就诊时段</Text>
|
||||||
|
</View>
|
||||||
|
<Text className='info-value info-time'>{appointment.start_time} - {appointment.end_time}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='info-item'>
|
<View className='info-item'>
|
||||||
<Text className='info-label'>预约单号</Text>
|
<View className='info-label-wrap'>
|
||||||
|
<Text className='info-icon-serif'>号</Text>
|
||||||
|
<Text className='info-label'>预约单号</Text>
|
||||||
|
</View>
|
||||||
<Text className='info-value info-id'>{appointment.id}</Text>
|
<Text className='info-value info-id'>{appointment.id}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* 温馨提示 */}
|
||||||
{(appointment.status === 'pending' || appointment.status === 'confirmed') && (
|
{(appointment.status === 'pending' || appointment.status === 'confirmed') && (
|
||||||
<View className='tips-card'>
|
<View className='tips-card'>
|
||||||
<Text className='tips-title'>温馨提示</Text>
|
<Text className='tips-title'>温馨提示</Text>
|
||||||
@@ -132,6 +153,7 @@ export default function AppointmentDetail() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 取消按钮 */}
|
||||||
{canCancel && (
|
{canCancel && (
|
||||||
<View className='bottom-bar'>
|
<View className='bottom-bar'>
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -1,26 +1,36 @@
|
|||||||
@import '../../styles/variables.scss';
|
@import '../../styles/variables.scss';
|
||||||
|
@import '../../styles/mixins.scss';
|
||||||
|
|
||||||
.appointment-page {
|
.appointment-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: $bg;
|
background: $bg;
|
||||||
padding-bottom: 140px;
|
padding-bottom: 160px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 页头 */
|
||||||
.page-header {
|
.page-header {
|
||||||
background: linear-gradient(135deg, $pri 0%, $pri-d 100%);
|
background: $card;
|
||||||
padding: 32px;
|
padding: 48px 32px 36px;
|
||||||
padding-top: 48px;
|
box-shadow: $shadow-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
font-size: 36px;
|
@include section-title;
|
||||||
font-weight: bold;
|
margin-bottom: 4px;
|
||||||
color: white;
|
font-size: var(--tk-font-num-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
font-family: 'Georgia', 'Times New Roman', serif;
|
||||||
|
font-size: var(--tk-font-body);
|
||||||
|
color: var(--tk-text-secondary);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 预约列表 */
|
||||||
.appointment-list {
|
.appointment-list {
|
||||||
padding: 0 24px;
|
padding: 0 24px;
|
||||||
margin-top: -16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.appointment-card {
|
.appointment-card {
|
||||||
@@ -28,7 +38,7 @@
|
|||||||
border-radius: $r;
|
border-radius: $r;
|
||||||
padding: 28px;
|
padding: 28px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
box-shadow: $shadow-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-top {
|
.card-top {
|
||||||
@@ -38,139 +48,146 @@
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.doctor-info {
|
.doctor-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 16px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dept-initial {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: $r;
|
||||||
|
background: $pri-l;
|
||||||
|
@include flex-center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dept-initial-text {
|
||||||
|
font-family: 'Georgia', 'Times New Roman', serif;
|
||||||
|
font-size: var(--tk-font-num);
|
||||||
|
font-weight: bold;
|
||||||
|
color: $pri;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.doctor-name {
|
.doctor-name {
|
||||||
font-size: 32px;
|
font-size: var(--tk-font-num);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: $tx;
|
color: $tx;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.department {
|
.dept-tag {
|
||||||
font-size: 24px;
|
@include tag($pri-l, $pri);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dept-tag-text {
|
||||||
|
font-size: var(--tk-font-body);
|
||||||
|
font-weight: 500;
|
||||||
color: $pri;
|
color: $pri;
|
||||||
background: $pri-l;
|
|
||||||
padding: 4px 16px;
|
|
||||||
border-radius: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-tag {
|
.status-tag {
|
||||||
padding: 6px 20px;
|
@include tag($bd-l, $tx3);
|
||||||
border-radius: 20px;
|
flex-shrink: 0;
|
||||||
|
|
||||||
.status-tag-text {
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.tag-pending {
|
&.tag-pending {
|
||||||
background: $wrn-l;
|
background: $wrn-l;
|
||||||
|
.status-tag-text { color: $wrn; }
|
||||||
.status-tag-text {
|
|
||||||
color: $wrn;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.tag-confirmed {
|
&.tag-confirmed {
|
||||||
background: $acc-l;
|
background: $acc-l;
|
||||||
|
.status-tag-text { color: $acc; }
|
||||||
.status-tag-text {
|
|
||||||
color: $acc;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.tag-cancelled {
|
&.tag-cancelled {
|
||||||
background: $bd-l;
|
background: $bd-l;
|
||||||
|
.status-tag-text { color: $tx3; }
|
||||||
.status-tag-text {
|
|
||||||
color: $tx3;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.tag-completed {
|
&.tag-completed {
|
||||||
background: $pri-l;
|
background: $pri-l;
|
||||||
|
.status-tag-text { color: $pri; }
|
||||||
.status-tag-text {
|
|
||||||
color: $pri;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-tag-text {
|
||||||
|
font-size: var(--tk-font-body);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: $bd-l;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.card-bottom {
|
.card-bottom {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 14px;
|
||||||
padding-top: 20px;
|
|
||||||
border-top: 1px solid $bd-l;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-row {
|
.info-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-icon {
|
.info-icon-wrap {
|
||||||
font-size: 26px;
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $bd-l;
|
||||||
|
@include flex-center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-icon-serif {
|
||||||
|
font-family: 'Georgia', 'Times New Roman', serif;
|
||||||
|
font-size: var(--tk-font-body);
|
||||||
|
color: $tx2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-text {
|
.info-text {
|
||||||
font-size: 26px;
|
font-size: var(--tk-font-h1);
|
||||||
color: $tx2;
|
color: $tx2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.info-time {
|
||||||
display: flex;
|
@include serif-number;
|
||||||
flex-direction: column;
|
color: $tx;
|
||||||
align-items: center;
|
font-weight: 500;
|
||||||
justify-content: center;
|
|
||||||
padding: 120px 0 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-icon {
|
|
||||||
font-size: 80px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-text {
|
|
||||||
font-size: 30px;
|
|
||||||
color: $tx2;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-hint {
|
|
||||||
font-size: 24px;
|
|
||||||
color: $tx3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-tip {
|
|
||||||
text-align: center;
|
|
||||||
padding: 24px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-text {
|
|
||||||
font-size: 24px;
|
|
||||||
color: $tx3;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 底部悬浮按钮 */
|
||||||
.fab-btn {
|
.fab-btn {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 60px;
|
bottom: 60px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
background: linear-gradient(135deg, $pri 0%, $pri-d 100%);
|
background: $pri;
|
||||||
padding: 24px 64px;
|
padding: 24px 72px;
|
||||||
border-radius: 48px;
|
border-radius: $r-pill;
|
||||||
box-shadow: 0 8px 24px rgba($pri, 0.4);
|
box-shadow: 0 8px 24px rgba($pri, 0.3);
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fab-text {
|
.fab-text {
|
||||||
font-size: 30px;
|
font-size: var(--tk-font-num);
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
letter-spacing: 2px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { listAppointments } from '../../services/appointment';
|
|||||||
import type { Appointment } from '../../services/appointment';
|
import type { Appointment } from '../../services/appointment';
|
||||||
import EmptyState from '../../components/EmptyState';
|
import EmptyState from '../../components/EmptyState';
|
||||||
import Loading from '../../components/Loading';
|
import Loading from '../../components/Loading';
|
||||||
|
import { useElderClass } from '../../hooks/useElderClass';
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
const STATUS_MAP: Record<string, { label: string; className: string }> = {
|
const STATUS_MAP: Record<string, { label: string; className: string }> = {
|
||||||
@@ -14,12 +15,23 @@ const STATUS_MAP: Record<string, { label: string; className: string }> = {
|
|||||||
completed: { label: '已完成', className: 'tag-completed' },
|
completed: { label: '已完成', className: 'tag-completed' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 科室首字映射(用于衬线图标)
|
||||||
|
const DEPT_INITIAL: Record<string, string> = {
|
||||||
|
'内科': '内',
|
||||||
|
'外科': '外',
|
||||||
|
'妇科': '妇',
|
||||||
|
'儿科': '儿',
|
||||||
|
'体检中心': '检',
|
||||||
|
'中医科': '中',
|
||||||
|
};
|
||||||
|
|
||||||
export default function AppointmentList() {
|
export default function AppointmentList() {
|
||||||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const loadingRef = useRef(false);
|
const loadingRef = useRef(false);
|
||||||
|
const modeClass = useElderClass();
|
||||||
|
|
||||||
const fetchData = useCallback(async (pageNum: number, isRefresh = false) => {
|
const fetchData = useCallback(async (pageNum: number, isRefresh = false) => {
|
||||||
if (loadingRef.current) return;
|
if (loadingRef.current) return;
|
||||||
@@ -71,8 +83,12 @@ export default function AppointmentList() {
|
|||||||
return STATUS_MAP[status] || { label: status, className: 'tag-pending' };
|
return STATUS_MAP[status] || { label: status, className: 'tag-pending' };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDeptInitial = (dept: string) => {
|
||||||
|
return DEPT_INITIAL[dept] || dept.charAt(0);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='appointment-page'>
|
<View className={`appointment-page ${modeClass}`}>
|
||||||
{/* 页面标题 */}
|
{/* 页面标题 */}
|
||||||
<View className='page-header'>
|
<View className='page-header'>
|
||||||
<Text className='page-title'>预约挂号</Text>
|
<Text className='page-title'>预约挂号</Text>
|
||||||
@@ -80,7 +96,7 @@ export default function AppointmentList() {
|
|||||||
|
|
||||||
{/* 预约列表 */}
|
{/* 预约列表 */}
|
||||||
{appointments.length === 0 && !loading ? (
|
{appointments.length === 0 && !loading ? (
|
||||||
<EmptyState icon='📋' text='暂无预约记录' hint='点击下方按钮新建预约' />
|
<EmptyState text='暂无预约记录' hint='点击下方按钮新建预约' />
|
||||||
) : (
|
) : (
|
||||||
<View className='appointment-list'>
|
<View className='appointment-list'>
|
||||||
{appointments.map((item) => {
|
{appointments.map((item) => {
|
||||||
@@ -92,22 +108,34 @@ export default function AppointmentList() {
|
|||||||
onClick={() => goDetail(item.id)}
|
onClick={() => goDetail(item.id)}
|
||||||
>
|
>
|
||||||
<View className='card-top'>
|
<View className='card-top'>
|
||||||
<View className='doctor-info'>
|
<View className='doctor-section'>
|
||||||
<Text className='doctor-name'>{item.doctor_name}</Text>
|
<View className='dept-initial'>
|
||||||
<Text className='department'>{item.department}</Text>
|
<Text className='dept-initial-text'>{getDeptInitial(item.department || '')}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='doctor-info'>
|
||||||
|
<Text className='doctor-name'>{item.doctor_name}</Text>
|
||||||
|
<View className='dept-tag'>
|
||||||
|
<Text className='dept-tag-text'>{item.department || ''}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View className={`status-tag ${tag.className}`}>
|
<View className={`status-tag ${tag.className}`}>
|
||||||
<Text className='status-tag-text'>{tag.label}</Text>
|
<Text className='status-tag-text'>{tag.label}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
<View className='card-divider' />
|
||||||
<View className='card-bottom'>
|
<View className='card-bottom'>
|
||||||
<View className='info-row'>
|
<View className='info-row'>
|
||||||
<Text className='info-icon'>📅</Text>
|
<View className='info-icon-wrap'>
|
||||||
|
<Text className='info-icon-serif'>日</Text>
|
||||||
|
</View>
|
||||||
<Text className='info-text'>{item.appointment_date}</Text>
|
<Text className='info-text'>{item.appointment_date}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='info-row'>
|
<View className='info-row'>
|
||||||
<Text className='info-icon'>🕐</Text>
|
<View className='info-icon-wrap'>
|
||||||
<Text className='info-text'>{item.start_time} - {item.end_time}</Text>
|
<Text className='info-icon-serif'>时</Text>
|
||||||
|
</View>
|
||||||
|
<Text className='info-text info-time'>{item.start_time} - {item.end_time}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -124,7 +152,7 @@ export default function AppointmentList() {
|
|||||||
|
|
||||||
{/* 底部悬浮按钮 */}
|
{/* 底部悬浮按钮 */}
|
||||||
<View className='fab-btn' onClick={goCreate}>
|
<View className='fab-btn' onClick={goCreate}>
|
||||||
<Text className='fab-text'>+ 新建预约</Text>
|
<Text className='fab-text'>新建预约</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.article-title {
|
.article-title {
|
||||||
font-size: 38px;
|
font-size: var(--tk-font-hero);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: $tx;
|
color: $tx;
|
||||||
display: block;
|
display: block;
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.article-category {
|
.article-category {
|
||||||
font-size: 22px;
|
font-size: var(--tk-font-body);
|
||||||
color: $pri;
|
color: $pri;
|
||||||
background: $pri-l;
|
background: $pri-l;
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
@@ -37,13 +37,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.article-author {
|
.article-author {
|
||||||
font-size: 24px;
|
font-size: var(--tk-font-h2);
|
||||||
color: $tx2;
|
color: $tx2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-date {
|
.article-date {
|
||||||
font-size: 24px;
|
font-size: var(--tk-font-h2);
|
||||||
color: $tx3;
|
color: var(--tk-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-summary {
|
.article-summary {
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.summary-text {
|
.summary-text {
|
||||||
font-size: 26px;
|
font-size: var(--tk-font-h1);
|
||||||
color: $tx2;
|
color: $tx2;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
@@ -65,20 +65,20 @@
|
|||||||
// RichText 内部样式优化
|
// RichText 内部样式优化
|
||||||
h1, h2, h3 {
|
h1, h2, h3 {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #134E4A;
|
color: $tx;
|
||||||
margin: 24px 0 12px;
|
margin: 24px 0 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
font-size: 28px;
|
font-size: var(--tk-font-body-lg);
|
||||||
color: #134E4A;
|
color: $tx;
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
border-radius: 8px;
|
border-radius: $r-sm;
|
||||||
margin: 12px 0;
|
margin: 12px 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,6 +93,6 @@
|
|||||||
|
|
||||||
.loading-text,
|
.loading-text,
|
||||||
.empty-text {
|
.empty-text {
|
||||||
font-size: 28px;
|
font-size: var(--tk-font-body-lg);
|
||||||
color: $tx3;
|
color: var(--tk-text-secondary);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import { View, Text, RichText } from '@tarojs/components';
|
|||||||
import Taro, { useRouter, useShareAppMessage } from '@tarojs/taro';
|
import Taro, { useRouter, useShareAppMessage } from '@tarojs/taro';
|
||||||
import { getArticleDetail, Article } from '../../../services/article';
|
import { getArticleDetail, Article } from '../../../services/article';
|
||||||
import { trackEvent } from '@/services/analytics';
|
import { trackEvent } from '@/services/analytics';
|
||||||
|
import { useElderClass } from '../../../hooks/useElderClass';
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
export default function ArticleDetail() {
|
export default function ArticleDetail() {
|
||||||
|
const modeClass = useElderClass();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const id = router.params.id || '';
|
const id = router.params.id || '';
|
||||||
|
|
||||||
@@ -31,7 +33,7 @@ export default function ArticleDetail() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<View className='article-detail-page'>
|
<View className={`article-detail-page ${modeClass}`}>
|
||||||
<View className='loading-state'>
|
<View className='loading-state'>
|
||||||
<Text className='loading-text'>加载中...</Text>
|
<Text className='loading-text'>加载中...</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -41,7 +43,7 @@ export default function ArticleDetail() {
|
|||||||
|
|
||||||
if (!article) {
|
if (!article) {
|
||||||
return (
|
return (
|
||||||
<View className='article-detail-page'>
|
<View className={`article-detail-page ${modeClass}`}>
|
||||||
<View className='empty-state'>
|
<View className='empty-state'>
|
||||||
<Text className='empty-text'>文章不存在</Text>
|
<Text className='empty-text'>文章不存在</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -50,7 +52,7 @@ export default function ArticleDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='article-detail-page'>
|
<View className={`article-detail-page ${modeClass}`}>
|
||||||
{/* 文章头部 */}
|
{/* 文章头部 */}
|
||||||
<View className='article-header'>
|
<View className='article-header'>
|
||||||
<Text className='article-title'>{article.title}</Text>
|
<Text className='article-title'>{article.title}</Text>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 12px 28px;
|
padding: 12px 28px;
|
||||||
margin-right: 12px;
|
margin-right: 12px;
|
||||||
font-size: 26px;
|
font-size: var(--tk-font-h1);
|
||||||
color: $tx2;
|
color: $tx2;
|
||||||
background: $card;
|
background: $card;
|
||||||
border-radius: 32px;
|
border-radius: 32px;
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
background: $card;
|
background: $card;
|
||||||
border-radius: $r;
|
border-radius: $r;
|
||||||
padding: 28px;
|
padding: 28px;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
box-shadow: $shadow-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-card-body {
|
.article-card-body {
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.article-card-title {
|
.article-card-title {
|
||||||
font-size: 30px;
|
font-size: var(--tk-font-num);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: $tx;
|
color: $tx;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.article-card-summary {
|
.article-card-summary {
|
||||||
font-size: 26px;
|
font-size: var(--tk-font-h1);
|
||||||
color: $tx2;
|
color: $tx2;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
display: block;
|
display: block;
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.article-card-tag {
|
.article-card-tag {
|
||||||
font-size: 22px;
|
font-size: var(--tk-font-body);
|
||||||
color: $pri;
|
color: $pri;
|
||||||
background: $pri-l;
|
background: $pri-l;
|
||||||
padding: 2px 12px;
|
padding: 2px 12px;
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.article-card-date {
|
.article-card-date {
|
||||||
font-size: 22px;
|
font-size: var(--tk-font-body);
|
||||||
color: $tx3;
|
color: $tx3;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,8 +116,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.empty-text {
|
.empty-text {
|
||||||
font-size: 28px;
|
font-size: var(--tk-font-body-lg);
|
||||||
color: $tx3;
|
color: var(--tk-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-hint {
|
.loading-hint {
|
||||||
@@ -126,6 +126,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.loading-text {
|
.loading-text {
|
||||||
font-size: 24px;
|
font-size: var(--tk-font-h2);
|
||||||
color: $tx3;
|
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 { listArticles, listCategories, Article, ArticleCategory } from '../../services/article';
|
||||||
import EmptyState from '../../components/EmptyState';
|
import EmptyState from '../../components/EmptyState';
|
||||||
import Loading from '../../components/Loading';
|
import Loading from '../../components/Loading';
|
||||||
|
import { useElderClass } from '../../hooks/useElderClass';
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
export default function ArticleList() {
|
export default function ArticleList() {
|
||||||
|
const modeClass = useElderClass();
|
||||||
const [articles, setArticles] = useState<Article[]>([]);
|
const [articles, setArticles] = useState<Article[]>([]);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
@@ -72,7 +74,7 @@ export default function ArticleList() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='article-page'>
|
<View className={`article-page ${modeClass}`}>
|
||||||
{/* 分类筛选 */}
|
{/* 分类筛选 */}
|
||||||
{categories.length > 0 && (
|
{categories.length > 0 && (
|
||||||
<ScrollView scrollX className='article-categories'>
|
<ScrollView scrollX className='article-categories'>
|
||||||
@@ -119,7 +121,7 @@ export default function ArticleList() {
|
|||||||
</View>
|
</View>
|
||||||
{a.cover_image && (
|
{a.cover_image && (
|
||||||
<View className='article-card-cover'>
|
<View className='article-card-cover'>
|
||||||
<Image className='cover-img' src={a.cover_image} mode='aspectFill' />
|
<Image className='cover-img' src={a.cover_image} mode='aspectFill' lazyLoad />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||