Compare commits
255 Commits
32eef5ecf1
...
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 |
11
.lintstagedrc.js
Normal file
11
.lintstagedrc.js
Normal file
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
'*.rs': [
|
||||
'cargo fmt --check --',
|
||||
() => 'cargo clippy -p erp-health -p erp-server -- -D warnings',
|
||||
],
|
||||
'apps/web/src/**/*.{ts,tsx}': (filenames) =>
|
||||
`npx eslint --fix ${filenames.join(' ')}`,
|
||||
'apps/web/src/**/*.test.{ts,tsx}': [
|
||||
'cd apps/web && npx vitest run --reporter=verbose',
|
||||
],
|
||||
};
|
||||
28
Cargo.lock
generated
28
Cargo.lock
generated
@@ -1411,10 +1411,12 @@ dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"erp-core",
|
||||
"futures",
|
||||
"handlebars",
|
||||
"hex",
|
||||
"redis",
|
||||
"reqwest",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
@@ -1527,6 +1529,7 @@ name = "erp-health"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
@@ -1534,7 +1537,9 @@ dependencies = [
|
||||
"erp-core",
|
||||
"hex",
|
||||
"hmac",
|
||||
"jsonwebtoken",
|
||||
"num-traits",
|
||||
"rand_core 0.6.4",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1662,30 +1667,12 @@ dependencies = [
|
||||
"wit-bindgen 0.55.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "erp-points"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"chrono",
|
||||
"erp-core",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"utoipa",
|
||||
"uuid",
|
||||
"validator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "erp-server"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"chrono",
|
||||
"config",
|
||||
@@ -1699,6 +1686,8 @@ dependencies = [
|
||||
"erp-plugin",
|
||||
"erp-server-migration",
|
||||
"erp-workflow",
|
||||
"futures",
|
||||
"hex",
|
||||
"metrics",
|
||||
"metrics-exporter-prometheus",
|
||||
"moka",
|
||||
@@ -1706,6 +1695,7 @@ dependencies = [
|
||||
"sea-orm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"tokio",
|
||||
"tower",
|
||||
|
||||
@@ -111,6 +111,7 @@ erp-dialysis = { path = "crates/erp-dialysis" }
|
||||
futures = "0.3"
|
||||
tokio-stream = "0.1"
|
||||
async-stream = "0.3"
|
||||
dashmap = "6"
|
||||
|
||||
# Template engine
|
||||
handlebars = "6"
|
||||
|
||||
69
apps/miniprogram/__tests__/services/ble/BLEManager.test.ts
Normal file
69
apps/miniprogram/__tests__/services/ble/BLEManager.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
// 使用 vi.hoisted 确保 storage 在 mock 提升前可用
|
||||
const { storage } = vi.hoisted(() => ({
|
||||
storage: new Map<string, string>(),
|
||||
}));
|
||||
|
||||
vi.mock('@tarojs/taro', () => ({
|
||||
default: {
|
||||
openBluetoothAdapter: vi.fn().mockResolvedValue({}),
|
||||
closeBluetoothAdapter: vi.fn().mockResolvedValue({}),
|
||||
startBluetoothDevicesDiscovery: vi.fn().mockResolvedValue({}),
|
||||
stopBluetoothDevicesDiscovery: vi.fn().mockResolvedValue({}),
|
||||
onBluetoothDeviceFound: vi.fn(),
|
||||
offBluetoothDeviceFound: vi.fn(),
|
||||
createBLEConnection: vi.fn().mockResolvedValue({}),
|
||||
closeBLEConnection: vi.fn().mockResolvedValue({}),
|
||||
getBLEDeviceServices: vi.fn().mockResolvedValue({ services: [] }),
|
||||
getBLEDeviceCharacteristics: vi.fn().mockResolvedValue({ characteristics: [] }),
|
||||
notifyBLECharacteristicValueChange: vi.fn().mockResolvedValue({}),
|
||||
onBLECharacteristicValueChange: vi.fn(),
|
||||
onBLEConnectionStateChange: vi.fn(),
|
||||
getStorageSync: vi.fn((key: string) => storage.get(key) || ''),
|
||||
setStorageSync: vi.fn((key: string, value: string) => { storage.set(key, value); }),
|
||||
removeStorageSync: vi.fn((key: string) => { storage.delete(key); }),
|
||||
},
|
||||
}));
|
||||
|
||||
import { BLEManager } from '@/services/ble/BLEManager';
|
||||
import { XiaomiBandAdapter } from '@/services/ble/adapters/XiaomiBandAdapter';
|
||||
|
||||
describe('BLEManager DataBuffer 集成', () => {
|
||||
let manager: BLEManager;
|
||||
|
||||
beforeEach(() => {
|
||||
storage.clear();
|
||||
manager = new BLEManager();
|
||||
manager.registerAdapter(XiaomiBandAdapter);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await manager.destroy();
|
||||
});
|
||||
|
||||
it('registerAdapter 添加适配器', () => {
|
||||
const count = (manager as any).adapters.length;
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('getCachedReadings 返回空数组(未连接时)', () => {
|
||||
const readings = manager.getCachedReadings();
|
||||
expect(readings).toEqual([]);
|
||||
});
|
||||
|
||||
it('flushPendingReadings 无缓存时返回 0', async () => {
|
||||
const uploadFn = vi.fn().mockResolvedValue(0);
|
||||
const count = await manager.flushPendingReadings(uploadFn);
|
||||
expect(count).toBe(0);
|
||||
expect(uploadFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('DataBuffer 实例已初始化', () => {
|
||||
const buffer = (manager as any).dataBuffer;
|
||||
expect(buffer).toBeDefined();
|
||||
expect(typeof buffer.push).toBe('function');
|
||||
expect(typeof buffer.flush).toBe('function');
|
||||
expect(typeof buffer.restore).toBe('function');
|
||||
});
|
||||
});
|
||||
89
apps/miniprogram/__tests__/services/ble/DataBuffer.test.ts
Normal file
89
apps/miniprogram/__tests__/services/ble/DataBuffer.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { DataBuffer } from '@/services/ble/DataBuffer';
|
||||
import type { NormalizedReading } from '@/services/ble/types';
|
||||
|
||||
// Mock Taro Storage
|
||||
const storage = new Map<string, string>();
|
||||
vi.mock('@tarojs/taro', () => ({
|
||||
default: {
|
||||
getStorageSync: vi.fn((key: string) => storage.get(key) || ''),
|
||||
setStorageSync: vi.fn((key: string, value: string) => { storage.set(key, value); }),
|
||||
removeStorageSync: vi.fn((key: string) => { storage.delete(key); }),
|
||||
getStorageInfoSync: vi.fn(() => ({ keys: Array.from(storage.keys()), limitSize: 10240, currentSize: storage.size })),
|
||||
},
|
||||
}));
|
||||
|
||||
function makeReading(overrides: Partial<NormalizedReading> = {}): NormalizedReading {
|
||||
return {
|
||||
device_type: 'heart_rate',
|
||||
values: { heart_rate: 72 },
|
||||
measured_at: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('DataBuffer', () => {
|
||||
let buffer: DataBuffer;
|
||||
|
||||
beforeEach(() => {
|
||||
storage.clear();
|
||||
buffer = new DataBuffer({ bucketSize: 100 });
|
||||
});
|
||||
|
||||
it('push 添加读数并持久化', () => {
|
||||
const reading = makeReading();
|
||||
buffer.push(reading);
|
||||
expect(buffer.size()).toBe(1);
|
||||
});
|
||||
|
||||
it('push 批量添加读数', () => {
|
||||
const readings = Array.from({ length: 10 }, (_, i) =>
|
||||
makeReading({ measured_at: new Date(Date.now() + i * 1000).toISOString() }),
|
||||
);
|
||||
buffer.push(readings);
|
||||
expect(buffer.size()).toBe(10);
|
||||
});
|
||||
|
||||
it('flush 返回并清空缓冲区', () => {
|
||||
buffer.push([
|
||||
makeReading({ measured_at: '2026-05-04T10:00:00.000Z' }),
|
||||
makeReading({ measured_at: '2026-05-04T10:00:01.000Z' }),
|
||||
]);
|
||||
const flushed = buffer.flush();
|
||||
expect(flushed.length).toBe(2);
|
||||
expect(buffer.size()).toBe(0);
|
||||
});
|
||||
|
||||
it('超过 maxTotal 时丢弃最旧数据', () => {
|
||||
const smallBuffer = new DataBuffer({ bucketSize: 5, maxTotal: 10 });
|
||||
for (let i = 0; i < 15; i++) {
|
||||
smallBuffer.push(makeReading({ measured_at: new Date(i * 1000).toISOString() }));
|
||||
}
|
||||
expect(smallBuffer.size()).toBe(10);
|
||||
});
|
||||
|
||||
it('去重:相同 measured_at + device_type 不重复存储', () => {
|
||||
const ts = '2026-05-04T10:00:00.000Z';
|
||||
buffer.push(makeReading({ measured_at: ts }));
|
||||
buffer.push(makeReading({ measured_at: ts }));
|
||||
expect(buffer.size()).toBe(1);
|
||||
});
|
||||
|
||||
it('restore 从 Storage 恢复未上传数据', () => {
|
||||
buffer.push([
|
||||
makeReading({ measured_at: '2026-05-04T10:00:00.000Z' }),
|
||||
makeReading({ measured_at: '2026-05-04T10:00:01.000Z' }),
|
||||
]);
|
||||
// 模拟重启:新建 DataBuffer 并 restore
|
||||
const restored = new DataBuffer({ bucketSize: 100 });
|
||||
const count = restored.restore();
|
||||
expect(count).toBe(2);
|
||||
expect(restored.size()).toBe(2);
|
||||
});
|
||||
|
||||
it('clear 清空缓冲区和 Storage', () => {
|
||||
buffer.push(makeReading());
|
||||
buffer.clear();
|
||||
expect(buffer.size()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { DataSyncScheduler } from '@/services/ble/DataSyncScheduler';
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
vi.mock('@tarojs/taro', () => ({
|
||||
default: {
|
||||
getStorageSync: vi.fn((key: string) => storage.get(key) || ''),
|
||||
setStorageSync: vi.fn((key: string, value: string) => { storage.set(key, value); }),
|
||||
removeStorageSync: vi.fn((key: string) => { storage.delete(key); }),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('DataSyncScheduler', () => {
|
||||
let scheduler: DataSyncScheduler;
|
||||
let syncFn: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
storage.clear();
|
||||
syncFn = vi.fn().mockResolvedValue({ success: true, uploadedCount: 5 });
|
||||
scheduler = new DataSyncScheduler({
|
||||
intervalMs: 60 * 60 * 1000,
|
||||
storageKey: 'last_ble_sync',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
scheduler.destroy();
|
||||
});
|
||||
|
||||
it('首次同步:无记录时立即需要同步', () => {
|
||||
expect(scheduler.needsSync()).toBe(true);
|
||||
});
|
||||
|
||||
it('同步后记录时间戳', async () => {
|
||||
await scheduler.recordSync(syncFn);
|
||||
expect(storage.has('last_ble_sync')).toBe(true);
|
||||
expect(syncFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('同步后不需要再次同步', async () => {
|
||||
await scheduler.recordSync(syncFn);
|
||||
expect(scheduler.needsSync()).toBe(false);
|
||||
});
|
||||
|
||||
it('超过间隔后需要再次同步', async () => {
|
||||
const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
|
||||
storage.set('last_ble_sync', JSON.stringify({ lastSyncAt: twoHoursAgo }));
|
||||
scheduler = new DataSyncScheduler({ intervalMs: 60 * 60 * 1000, storageKey: 'last_ble_sync' });
|
||||
|
||||
expect(scheduler.needsSync()).toBe(true);
|
||||
});
|
||||
|
||||
it('同步失败不更新时间戳', async () => {
|
||||
const failFn = vi.fn().mockRejectedValue(new Error('network error'));
|
||||
const oneHourAgo = Date.now() - 60 * 60 * 1000;
|
||||
storage.set('last_ble_sync', JSON.stringify({ lastSyncAt: oneHourAgo }));
|
||||
|
||||
await scheduler.recordSync(failFn);
|
||||
const stored = JSON.parse(storage.get('last_ble_sync') || '{}');
|
||||
expect(stored.lastSyncAt).toBe(oneHourAgo);
|
||||
});
|
||||
|
||||
it('tryAutoSync 首次时触发同步', async () => {
|
||||
const result = await scheduler.tryAutoSync(syncFn);
|
||||
expect(result).toBe(true);
|
||||
expect(syncFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('tryAutoSync 未超时不触发', async () => {
|
||||
await scheduler.recordSync(syncFn);
|
||||
syncFn.mockClear();
|
||||
const result = await scheduler.tryAutoSync(syncFn);
|
||||
expect(result).toBe(false);
|
||||
expect(syncFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('destroy 清理定时器', () => {
|
||||
const clearIntervalSpy = vi.spyOn(global, 'clearInterval');
|
||||
scheduler.startPeriodicCheck(syncFn, 30000);
|
||||
scheduler.destroy();
|
||||
expect(clearIntervalSpy).toHaveBeenCalled();
|
||||
clearIntervalSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('getLastSyncAt 返回上次同步时间', async () => {
|
||||
await scheduler.recordSync(syncFn);
|
||||
const lastSync = scheduler.getLastSyncAt();
|
||||
expect(lastSync).toBeTruthy();
|
||||
expect(typeof lastSync).toBe('number');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createGenericBleAdapter } from '@/services/ble/adapters/GenericBleAdapter';
|
||||
import type { GenericBLEProfile } from '@/services/ble/types';
|
||||
|
||||
// ---- Heart Rate (0x180D / 0x2A37) ----
|
||||
// Flag byte=0x00 (UINT8), HR=75
|
||||
function makeHeartRateData(hr: number, isUint16 = false): ArrayBuffer {
|
||||
const buf = new ArrayBuffer(isUint16 ? 3 : 2);
|
||||
const view = new DataView(buf);
|
||||
view.setUint8(0, isUint16 ? 0x01 : 0x00);
|
||||
if (isUint16) {
|
||||
view.setUint16(1, hr, true);
|
||||
} else {
|
||||
view.setUint8(1, hr);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
// ---- Health Thermometer (0x1809 / 0x2A1C) ----
|
||||
// IEEE 11073 FLOAT: 32-bit — mantissa (24-bit) + exponent (8-bit)
|
||||
function makeTemperatureData(tempCelsius: number): ArrayBuffer {
|
||||
const buf = new ArrayBuffer(4);
|
||||
const view = new DataView(buf);
|
||||
// flags byte: 0x00 = Celsius, no timestamp, no type
|
||||
view.setUint8(0, 0x00);
|
||||
// 11073 FLOAT: mantissa * 10^exponent
|
||||
// For 36.5: mantissa=365, exponent=-1
|
||||
const mantissa = Math.round(tempCelsius * 10);
|
||||
const exponent = -1;
|
||||
view.setInt16(1, mantissa, true);
|
||||
view.setInt8(3, exponent);
|
||||
return buf;
|
||||
}
|
||||
|
||||
describe('GenericBleAdapter', () => {
|
||||
describe('心率解析', () => {
|
||||
const adapter = createGenericBleAdapter({
|
||||
name: 'Test Wristband',
|
||||
supportedModels: ['TestBand'],
|
||||
profiles: ['heart_rate'],
|
||||
});
|
||||
|
||||
it('解析 UINT8 心率', () => {
|
||||
const data = makeHeartRateData(75);
|
||||
const results = adapter.parseNotification(
|
||||
'0000180D-0000-1000-8000-00805f9b34fb',
|
||||
'00002A37-0000-1000-8000-00805f9b34fb',
|
||||
data,
|
||||
);
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].device_type).toBe('heart_rate');
|
||||
expect(results[0].values.heart_rate).toBe(75);
|
||||
});
|
||||
|
||||
it('解析 UINT16 心率', () => {
|
||||
const data = makeHeartRateData(200, true);
|
||||
const results = adapter.parseNotification(
|
||||
'0000180D-0000-1000-8000-00805f9b34fb',
|
||||
'00002A37-0000-1000-8000-00805f9b34fb',
|
||||
data,
|
||||
);
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].values.heart_rate).toBe(200);
|
||||
});
|
||||
|
||||
it('忽略非目标 Characteristic', () => {
|
||||
const data = makeHeartRateData(75);
|
||||
const results = adapter.parseNotification(
|
||||
'0000180D-0000-1000-8000-00805f9b34fb',
|
||||
'00002A38-0000-1000-8000-00805f9b34fb', // Body Sensor Location
|
||||
data,
|
||||
);
|
||||
expect(results.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('体温解析', () => {
|
||||
const adapter = createGenericBleAdapter({
|
||||
name: 'Test Thermometer',
|
||||
supportedModels: ['TestThermo'],
|
||||
profiles: ['health_thermometer'],
|
||||
});
|
||||
|
||||
it('解析体温读数', () => {
|
||||
const data = makeTemperatureData(36.5);
|
||||
const results = adapter.parseNotification(
|
||||
'00001809-0000-1000-8000-00805f9b34fb',
|
||||
'00002A1C-0000-1000-8000-00805f9b34fb',
|
||||
data,
|
||||
);
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].device_type).toBe('temperature');
|
||||
expect(results[0].values.value).toBeCloseTo(36.5, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('多 Profile 适配器', () => {
|
||||
const adapter = createGenericBleAdapter({
|
||||
name: 'Multi-Profile Band',
|
||||
supportedModels: ['CustomBand', 'MedicalBand'],
|
||||
profiles: ['heart_rate', 'health_thermometer'],
|
||||
});
|
||||
|
||||
it('包含两个 Service UUID', () => {
|
||||
expect(adapter.serviceUUIDs.length).toBe(2);
|
||||
});
|
||||
|
||||
it('包含两个 Profile 的 Characteristic', () => {
|
||||
expect(adapter.notifyCharacteristics.length).toBe(2);
|
||||
});
|
||||
|
||||
it('supportedModels 配置正确', () => {
|
||||
expect(adapter.supportedModels).toEqual(['CustomBand', 'MedicalBand']);
|
||||
});
|
||||
|
||||
it('解析心率 + 体温', () => {
|
||||
const hrResults = adapter.parseNotification(
|
||||
'0000180D-0000-1000-8000-00805f9b34fb',
|
||||
'00002A37-0000-1000-8000-00805f9b34fb',
|
||||
makeHeartRateData(80),
|
||||
);
|
||||
expect(hrResults[0].device_type).toBe('heart_rate');
|
||||
|
||||
const tempResults = adapter.parseNotification(
|
||||
'00001809-0000-1000-8000-00805f9b34fb',
|
||||
'00002A1C-0000-1000-8000-00805f9b34fb',
|
||||
makeTemperatureData(37.2),
|
||||
);
|
||||
expect(tempResults[0].device_type).toBe('temperature');
|
||||
});
|
||||
});
|
||||
|
||||
describe('边界情况', () => {
|
||||
const adapter = createGenericBleAdapter({
|
||||
name: 'Edge Case Band',
|
||||
supportedModels: ['Edge'],
|
||||
profiles: ['heart_rate'],
|
||||
});
|
||||
|
||||
it('空数据返回空数组', () => {
|
||||
const results = adapter.parseNotification(
|
||||
'0000180D-0000-1000-8000-00805f9b34fb',
|
||||
'00002A37-0000-1000-8000-00805f9b34fb',
|
||||
new ArrayBuffer(0),
|
||||
);
|
||||
expect(results.length).toBe(0);
|
||||
});
|
||||
|
||||
it('心率超范围 (>300) 返回空数组', () => {
|
||||
const results = adapter.parseNotification(
|
||||
'0000180D-0000-1000-8000-00805f9b34fb',
|
||||
'00002A37-0000-1000-8000-00805f9b34fb',
|
||||
makeHeartRateData(0),
|
||||
);
|
||||
expect(results.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,10 @@ import type { UserConfigExport } from '@tarojs/cli';
|
||||
|
||||
export default {
|
||||
logger: { quiet: false },
|
||||
mini: {},
|
||||
mini: {
|
||||
miniCssExtractPluginOption: {
|
||||
ignoreOrder: true,
|
||||
},
|
||||
},
|
||||
h5: {},
|
||||
} satisfies UserConfigExport;
|
||||
|
||||
@@ -5,7 +5,7 @@ export default defineConfig(async (merge) => {
|
||||
const baseConfig = {
|
||||
projectName: 'hms-miniprogram',
|
||||
date: '2026-4-23',
|
||||
designWidth: 750,
|
||||
designWidth: 375,
|
||||
deviceRatio: { 640: 2.34 / 2, 750: 1, 375: 2, 828: 1.81 / 2 },
|
||||
sourceRoot: 'src',
|
||||
outputRoot: 'dist',
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"miniprogram-automator": "^0.12.1",
|
||||
"sass": "^1.87.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vite": "^8.0.10",
|
||||
"vitest": "^4.1.5",
|
||||
"webpack": "~5.95.0"
|
||||
}
|
||||
|
||||
456
apps/miniprogram/pnpm-lock.yaml
generated
456
apps/miniprogram/pnpm-lock.yaml
generated
@@ -19,13 +19,13 @@ importers:
|
||||
version: 7.28.5(@babel/core@7.29.0)
|
||||
'@tarojs/components':
|
||||
specifier: 4.2.0
|
||||
version: 4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.28)(html-webpack-plugin@5.6.7(webpack@5.95.0(@swc/core@1.3.96)))(postcss@8.5.12)(rollup@3.30.0)(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.95.0(@swc/core@1.3.96)))(webpack@5.95.0(@swc/core@1.3.96))
|
||||
version: 4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.28)(html-webpack-plugin@5.6.7(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(postcss@8.5.12)(rollup@3.30.0)(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
|
||||
'@tarojs/helper':
|
||||
specifier: 4.2.0
|
||||
version: 4.2.0
|
||||
'@tarojs/plugin-framework-react':
|
||||
specifier: 4.2.0
|
||||
version: 4.2.0(@tarojs/helper@4.2.0)(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(react@18.3.1)(webpack@5.95.0(@swc/core@1.3.96))
|
||||
version: 4.2.0(@tarojs/helper@4.2.0)(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(react@18.3.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@3.13.1)(sass@1.99.0)(stylus@0.64.0)(terser@5.46.2))(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
|
||||
'@tarojs/plugin-platform-weapp':
|
||||
specifier: 4.2.0
|
||||
version: 4.2.0(@tarojs/service@4.2.0)(@tarojs/shared@4.2.0)
|
||||
@@ -40,7 +40,7 @@ importers:
|
||||
version: 4.2.0
|
||||
'@tarojs/taro':
|
||||
specifier: 4.2.0
|
||||
version: 4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.28)(html-webpack-plugin@5.6.7(webpack@5.95.0(@swc/core@1.3.96)))(postcss@8.5.12)(rollup@3.30.0)(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.95.0(@swc/core@1.3.96)))(webpack@5.95.0(@swc/core@1.3.96)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.28)(html-webpack-plugin@5.6.7(webpack@5.95.0(@swc/core@1.3.96)))(postcss@8.5.12)(rollup@3.30.0)(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.95.0(@swc/core@1.3.96)))(webpack@5.95.0(@swc/core@1.3.96))
|
||||
version: 4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.28)(html-webpack-plugin@5.6.7(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(postcss@8.5.12)(rollup@3.30.0)(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.28)(html-webpack-plugin@5.6.7(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(postcss@8.5.12)(rollup@3.30.0)(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
|
||||
babel-preset-taro:
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0(@babel/core@7.29.0)(@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0))(@babel/preset-react@7.28.5(@babel/core@7.29.0))
|
||||
@@ -71,7 +71,7 @@ importers:
|
||||
version: 4.2.0(@types/node@25.6.0)
|
||||
'@tarojs/webpack5-runner':
|
||||
specifier: 4.2.0
|
||||
version: 4.2.0(@babel/core@7.29.0)(@swc/core@1.3.96)(@tarojs/runtime@4.2.0)(less@3.13.1)(postcss@8.5.12)(sass@1.99.0)(stylus@0.64.0)(typescript@5.9.3)(webpack@5.95.0(@swc/core@1.3.96))
|
||||
version: 4.2.0(@babel/core@7.29.0)(@swc/core@1.3.96)(@tarojs/runtime@4.2.0)(less@3.13.1)(postcss@8.5.12)(sass@1.99.0)(stylus@0.64.0)(typescript@5.9.3)(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
|
||||
'@types/crypto-js':
|
||||
specifier: ^4.2.2
|
||||
version: 4.2.2
|
||||
@@ -87,12 +87,15 @@ importers:
|
||||
typescript:
|
||||
specifier: ^5.8.0
|
||||
version: 5.9.3
|
||||
vite:
|
||||
specifier: ^8.0.10
|
||||
version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@3.13.1)(sass@1.99.0)(stylus@0.64.0)(terser@5.46.2)
|
||||
vitest:
|
||||
specifier: ^4.1.5
|
||||
version: 4.1.5(@types/node@25.6.0)(jsdom@24.1.3)
|
||||
version: 4.1.5(@types/node@25.6.0)(jsdom@24.1.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@3.13.1)(sass@1.99.0)(stylus@0.64.0)(terser@5.46.2))
|
||||
webpack:
|
||||
specifier: ~5.95.0
|
||||
version: 5.95.0(@swc/core@1.3.96)
|
||||
version: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -705,6 +708,15 @@ packages:
|
||||
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@emnapi/core@1.10.0':
|
||||
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
|
||||
|
||||
'@emnapi/runtime@1.10.0':
|
||||
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
|
||||
|
||||
'@emnapi/wasi-threads@1.2.1':
|
||||
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
|
||||
|
||||
'@esbuild/aix-ppc64@0.21.5':
|
||||
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -1227,6 +1239,12 @@ packages:
|
||||
'@napi-rs/triples@1.2.0':
|
||||
resolution: {integrity: sha512-HAPjR3bnCsdXBsATpDIP5WCrw0JcACwhhrwIAQhiR46n+jm+a2F8kBsfseAuWtSyQ+H3Yebt2k43B5dy+04yMA==}
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.4':
|
||||
resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
|
||||
peerDependencies:
|
||||
'@emnapi/core': ^1.7.1
|
||||
'@emnapi/runtime': ^1.7.1
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -1239,6 +1257,9 @@ packages:
|
||||
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
'@oxc-project/types@0.127.0':
|
||||
resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==}
|
||||
|
||||
'@parcel/watcher-android-arm64@2.5.6':
|
||||
resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
@@ -1350,6 +1371,104 @@ packages:
|
||||
'@rnx-kit/console@1.1.0':
|
||||
resolution: {integrity: sha512-N+zFhTSXroiK4eL26vs61Pmtl7wzTPAKLd4JKw9/fk5cNAHUscCXF/uclzuYN61Ye5AwygIvcwbm9wv4Jfa92A==}
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rolldown/binding-darwin-arm64@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rolldown/binding-darwin-x64@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rolldown/binding-freebsd-x64@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@rolldown/binding-wasm32-wasi@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [wasm32]
|
||||
|
||||
'@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rolldown/binding-win32-x64-msvc@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==}
|
||||
|
||||
'@sideway/address@4.1.5':
|
||||
resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==}
|
||||
|
||||
@@ -1726,6 +1845,9 @@ packages:
|
||||
stylus:
|
||||
optional: true
|
||||
|
||||
'@tybys/wasm-util@0.10.1':
|
||||
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||
|
||||
'@types/archy@0.0.31':
|
||||
resolution: {integrity: sha512-v+dxizsFVyXgD3EpFuqT9YjdEjbJmPxNf1QIX9ohZOhxh1ZF2yhqv3vYaeum9lg3VghhxS5S0a6yldN9J9lPEQ==}
|
||||
|
||||
@@ -4804,6 +4926,11 @@ packages:
|
||||
deprecated: Rimraf versions prior to v4 are no longer supported
|
||||
hasBin: true
|
||||
|
||||
rolldown@1.0.0-rc.17:
|
||||
resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
rollup@3.30.0:
|
||||
resolution: {integrity: sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA==}
|
||||
engines: {node: '>=14.18.0', npm: '>=8.0.0'}
|
||||
@@ -5386,6 +5513,49 @@ packages:
|
||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
vite@8.0.10:
|
||||
resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@types/node': ^20.19.0 || >=22.12.0
|
||||
'@vitejs/devtools': ^0.1.0
|
||||
esbuild: ^0.27.0 || ^0.28.0
|
||||
jiti: '>=1.21.0'
|
||||
less: ^4.0.0
|
||||
sass: ^1.70.0
|
||||
sass-embedded: ^1.70.0
|
||||
stylus: '>=0.54.8'
|
||||
sugarss: ^5.0.0
|
||||
terser: ^5.16.0
|
||||
tsx: ^4.8.1
|
||||
yaml: ^2.4.2
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
'@vitejs/devtools':
|
||||
optional: true
|
||||
esbuild:
|
||||
optional: true
|
||||
jiti:
|
||||
optional: true
|
||||
less:
|
||||
optional: true
|
||||
sass:
|
||||
optional: true
|
||||
sass-embedded:
|
||||
optional: true
|
||||
stylus:
|
||||
optional: true
|
||||
sugarss:
|
||||
optional: true
|
||||
terser:
|
||||
optional: true
|
||||
tsx:
|
||||
optional: true
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
vitest@4.1.5:
|
||||
resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==}
|
||||
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
@@ -6452,6 +6622,22 @@ snapshots:
|
||||
|
||||
'@csstools/css-tokenizer@3.0.4': {}
|
||||
|
||||
'@emnapi/core@1.10.0':
|
||||
dependencies:
|
||||
'@emnapi/wasi-threads': 1.2.1
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@emnapi/runtime@1.10.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@emnapi/wasi-threads@1.2.1':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@esbuild/aix-ppc64@0.21.5':
|
||||
optional: true
|
||||
|
||||
@@ -6920,6 +7106,13 @@ snapshots:
|
||||
|
||||
'@napi-rs/triples@1.2.0': {}
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.10.0
|
||||
'@emnapi/runtime': 1.10.0
|
||||
'@tybys/wasm-util': 0.10.1
|
||||
optional: true
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
@@ -6932,6 +7125,8 @@ snapshots:
|
||||
'@nodelib/fs.scandir': 2.1.5
|
||||
fastq: 1.20.1
|
||||
|
||||
'@oxc-project/types@0.127.0': {}
|
||||
|
||||
'@parcel/watcher-android-arm64@2.5.6':
|
||||
optional: true
|
||||
|
||||
@@ -7009,6 +7204,57 @@ snapshots:
|
||||
|
||||
'@rnx-kit/console@1.1.0': {}
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.0-rc.17':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-darwin-arm64@1.0.0-rc.17':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-darwin-x64@1.0.0-rc.17':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-freebsd-x64@1.0.0-rc.17':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.17':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.17':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.0.0-rc.17':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.0.0-rc.17':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-wasm32-wasi@1.0.0-rc.17':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.10.0
|
||||
'@emnapi/runtime': 1.10.0
|
||||
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-win32-x64-msvc@1.0.0-rc.17':
|
||||
optional: true
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-rc.17': {}
|
||||
|
||||
'@sideway/address@4.1.5':
|
||||
dependencies:
|
||||
'@hapi/hoek': 9.3.0
|
||||
@@ -7146,12 +7392,12 @@ snapshots:
|
||||
- debug
|
||||
- supports-color
|
||||
|
||||
'@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.28)(html-webpack-plugin@5.6.7(webpack@5.95.0(@swc/core@1.3.96)))(postcss@8.5.12)(rollup@3.30.0)(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.95.0(@swc/core@1.3.96)))(webpack@5.95.0(@swc/core@1.3.96))':
|
||||
'@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.28)(html-webpack-plugin@5.6.7(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(postcss@8.5.12)(rollup@3.30.0)(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))':
|
||||
dependencies:
|
||||
'@stencil/core': 2.22.3
|
||||
'@tarojs/runtime': 4.2.0
|
||||
'@tarojs/shared': 4.2.0
|
||||
'@tarojs/taro': 4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.28)(html-webpack-plugin@5.6.7(webpack@5.95.0(@swc/core@1.3.96)))(postcss@8.5.12)(rollup@3.30.0)(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.95.0(@swc/core@1.3.96)))(webpack@5.95.0(@swc/core@1.3.96)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.28)(html-webpack-plugin@5.6.7(webpack@5.95.0(@swc/core@1.3.96)))(postcss@8.5.12)(rollup@3.30.0)(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.95.0(@swc/core@1.3.96)))(webpack@5.95.0(@swc/core@1.3.96))
|
||||
'@tarojs/taro': 4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.28)(html-webpack-plugin@5.6.7(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(postcss@8.5.12)(rollup@3.30.0)(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.28)(html-webpack-plugin@5.6.7(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(postcss@8.5.12)(rollup@3.30.0)(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
|
||||
classnames: 2.5.1
|
||||
hammerjs: 2.0.8
|
||||
hls.js: 1.6.16
|
||||
@@ -7244,7 +7490,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@tarojs/plugin-framework-react@4.2.0(@tarojs/helper@4.2.0)(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(react@18.3.1)(webpack@5.95.0(@swc/core@1.3.96))':
|
||||
'@tarojs/plugin-framework-react@4.2.0(@tarojs/helper@4.2.0)(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(react@18.3.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@3.13.1)(sass@1.99.0)(stylus@0.64.0)(terser@5.46.2))(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))':
|
||||
dependencies:
|
||||
'@tarojs/helper': 4.2.0
|
||||
'@tarojs/runtime': 4.2.0
|
||||
@@ -7255,7 +7501,8 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
react: 18.3.1
|
||||
webpack: 5.95.0(@swc/core@1.3.96)
|
||||
vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@3.13.1)(sass@1.99.0)(stylus@0.64.0)(terser@5.46.2)
|
||||
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
|
||||
|
||||
'@tarojs/plugin-platform-weapp@4.2.0(@tarojs/service@4.2.0)(@tarojs/shared@4.2.0)':
|
||||
dependencies:
|
||||
@@ -7300,19 +7547,19 @@ snapshots:
|
||||
|
||||
'@tarojs/shared@4.2.0': {}
|
||||
|
||||
'@tarojs/taro-loader@4.2.0(webpack@5.95.0(@swc/core@1.3.96))':
|
||||
'@tarojs/taro-loader@4.2.0(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))':
|
||||
dependencies:
|
||||
'@tarojs/helper': 4.2.0
|
||||
'@tarojs/shared': 4.2.0
|
||||
webpack: 5.95.0(@swc/core@1.3.96)
|
||||
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
|
||||
transitivePeerDependencies:
|
||||
- '@swc/helpers'
|
||||
- supports-color
|
||||
|
||||
'@tarojs/taro@4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.28)(html-webpack-plugin@5.6.7(webpack@5.95.0(@swc/core@1.3.96)))(postcss@8.5.12)(rollup@3.30.0)(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.95.0(@swc/core@1.3.96)))(webpack@5.95.0(@swc/core@1.3.96)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.28)(html-webpack-plugin@5.6.7(webpack@5.95.0(@swc/core@1.3.96)))(postcss@8.5.12)(rollup@3.30.0)(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.95.0(@swc/core@1.3.96)))(webpack@5.95.0(@swc/core@1.3.96))':
|
||||
'@tarojs/taro@4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.28)(html-webpack-plugin@5.6.7(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(postcss@8.5.12)(rollup@3.30.0)(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.28)(html-webpack-plugin@5.6.7(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(postcss@8.5.12)(rollup@3.30.0)(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))':
|
||||
dependencies:
|
||||
'@tarojs/api': 4.2.0(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)
|
||||
'@tarojs/components': 4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.28)(html-webpack-plugin@5.6.7(webpack@5.95.0(@swc/core@1.3.96)))(postcss@8.5.12)(rollup@3.30.0)(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.95.0(@swc/core@1.3.96)))(webpack@5.95.0(@swc/core@1.3.96))
|
||||
'@tarojs/components': 4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.28)(html-webpack-plugin@5.6.7(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(postcss@8.5.12)(rollup@3.30.0)(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
|
||||
'@tarojs/helper': 4.2.0
|
||||
'@tarojs/runtime': 4.2.0
|
||||
'@tarojs/shared': 4.2.0
|
||||
@@ -7320,77 +7567,77 @@ snapshots:
|
||||
postcss: 8.5.12
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.28
|
||||
html-webpack-plugin: 5.6.7(webpack@5.95.0(@swc/core@1.3.96))
|
||||
html-webpack-plugin: 5.6.7(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
|
||||
rollup: 3.30.0
|
||||
webpack: 5.95.0(@swc/core@1.3.96)
|
||||
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
|
||||
webpack-chain: 6.5.1
|
||||
webpack-dev-server: 4.15.2(webpack@5.95.0(@swc/core@1.3.96))
|
||||
webpack-dev-server: 4.15.2(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
|
||||
|
||||
'@tarojs/webpack5-prebundle@4.2.0(webpack@5.95.0(@swc/core@1.3.96))':
|
||||
'@tarojs/webpack5-prebundle@4.2.0(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))':
|
||||
dependencies:
|
||||
'@tarojs/helper': 4.2.0
|
||||
'@tarojs/shared': 4.2.0
|
||||
enhanced-resolve: 5.21.0
|
||||
es-module-lexer: 0.10.5
|
||||
lodash: 4.18.1
|
||||
webpack: 5.95.0(@swc/core@1.3.96)
|
||||
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
|
||||
webpack-virtual-modules: 0.6.2
|
||||
transitivePeerDependencies:
|
||||
- '@swc/helpers'
|
||||
- supports-color
|
||||
|
||||
'@tarojs/webpack5-runner@4.2.0(@babel/core@7.29.0)(@swc/core@1.3.96)(@tarojs/runtime@4.2.0)(less@3.13.1)(postcss@8.5.12)(sass@1.99.0)(stylus@0.64.0)(typescript@5.9.3)(webpack@5.95.0(@swc/core@1.3.96))':
|
||||
'@tarojs/webpack5-runner@4.2.0(@babel/core@7.29.0)(@swc/core@1.3.96)(@tarojs/runtime@4.2.0)(less@3.13.1)(postcss@8.5.12)(sass@1.99.0)(stylus@0.64.0)(typescript@5.9.3)(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@tarojs/helper': 4.2.0
|
||||
'@tarojs/runner-utils': 4.2.0
|
||||
'@tarojs/runtime': 4.2.0
|
||||
'@tarojs/shared': 4.2.0
|
||||
'@tarojs/taro-loader': 4.2.0(webpack@5.95.0(@swc/core@1.3.96))
|
||||
'@tarojs/webpack5-prebundle': 4.2.0(webpack@5.95.0(@swc/core@1.3.96))
|
||||
'@tarojs/taro-loader': 4.2.0(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
|
||||
'@tarojs/webpack5-prebundle': 4.2.0(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
|
||||
acorn: 8.16.0
|
||||
acorn-walk: 8.3.5
|
||||
autoprefixer: 10.5.0(postcss@8.5.12)
|
||||
babel-loader: 8.2.1(@babel/core@7.29.0)(webpack@5.95.0(@swc/core@1.3.96))
|
||||
copy-webpack-plugin: 12.0.2(webpack@5.95.0(@swc/core@1.3.96))
|
||||
css-loader: 7.1.4(webpack@5.95.0(@swc/core@1.3.96))
|
||||
css-minimizer-webpack-plugin: 6.0.0(esbuild@0.21.5)(lightningcss@1.32.0)(webpack@5.95.0(@swc/core@1.3.96))
|
||||
babel-loader: 8.2.1(@babel/core@7.29.0)(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
|
||||
copy-webpack-plugin: 12.0.2(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
|
||||
css-loader: 7.1.4(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
|
||||
css-minimizer-webpack-plugin: 6.0.0(esbuild@0.21.5)(lightningcss@1.32.0)(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
|
||||
detect-port: 1.6.1
|
||||
esbuild: 0.21.5
|
||||
esbuild-loader: 4.4.3(webpack@5.95.0(@swc/core@1.3.96))
|
||||
esbuild-loader: 4.4.3(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
|
||||
html-minifier: 4.0.0
|
||||
html-webpack-plugin: 5.6.7(webpack@5.95.0(@swc/core@1.3.96))
|
||||
html-webpack-plugin: 5.6.7(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
|
||||
jsdom: 24.1.3
|
||||
less-loader: 12.3.2(less@3.13.1)(webpack@5.95.0(@swc/core@1.3.96))
|
||||
less-loader: 12.3.2(less@3.13.1)(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
|
||||
lightningcss: 1.32.0
|
||||
loader-utils: 3.3.1
|
||||
lodash: 4.18.1
|
||||
md5: 2.3.0
|
||||
mini-css-extract-plugin: 2.10.2(webpack@5.95.0(@swc/core@1.3.96))
|
||||
mini-css-extract-plugin: 2.10.2(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
|
||||
miniprogram-simulate: 1.6.1
|
||||
ora: 5.4.1
|
||||
picomatch: 4.0.4
|
||||
postcss: 8.5.12
|
||||
postcss-html-transform: 4.2.0(postcss@8.5.12)
|
||||
postcss-import: 16.1.1(postcss@8.5.12)
|
||||
postcss-loader: 8.2.1(postcss@8.5.12)(typescript@5.9.3)(webpack@5.95.0(@swc/core@1.3.96))
|
||||
postcss-loader: 8.2.1(postcss@8.5.12)(typescript@5.9.3)(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
|
||||
postcss-plugin-constparse: 4.2.0(postcss@8.5.12)
|
||||
postcss-pxtransform: 4.2.0(postcss@8.5.12)
|
||||
postcss-url: 10.1.3(postcss@8.5.12)
|
||||
regenerator-runtime: 0.11.1
|
||||
resolve-url-loader: 5.0.0
|
||||
sass-loader: 14.2.1(sass@1.99.0)(webpack@5.95.0(@swc/core@1.3.96))
|
||||
sass-loader: 14.2.1(sass@1.99.0)(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
|
||||
sax: 1.2.4
|
||||
style-loader: 3.3.4(webpack@5.95.0(@swc/core@1.3.96))
|
||||
stylus-loader: 8.1.3(stylus@0.64.0)(webpack@5.95.0(@swc/core@1.3.96))
|
||||
terser-webpack-plugin: 5.5.0(@swc/core@1.3.96)(esbuild@0.21.5)(webpack@5.95.0(@swc/core@1.3.96))
|
||||
style-loader: 3.3.4(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
|
||||
stylus-loader: 8.1.3(stylus@0.64.0)(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
|
||||
terser-webpack-plugin: 5.5.0(@swc/core@1.3.96)(esbuild@0.21.5)(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
|
||||
vm2: 3.10.5
|
||||
webpack: 5.95.0(@swc/core@1.3.96)
|
||||
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
|
||||
webpack-chain: 6.5.1
|
||||
webpack-dev-server: 4.15.2(webpack@5.95.0(@swc/core@1.3.96))
|
||||
webpack-dev-server: 4.15.2(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
|
||||
webpack-format-messages: 3.0.1
|
||||
webpack-virtual-modules: 0.6.2
|
||||
webpackbar: 5.0.2(webpack@5.95.0(@swc/core@1.3.96))
|
||||
webpackbar: 5.0.2(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
|
||||
optionalDependencies:
|
||||
less: 3.13.1
|
||||
sass: 1.99.0
|
||||
@@ -7414,6 +7661,11 @@ snapshots:
|
||||
- utf-8-validate
|
||||
- webpack-cli
|
||||
|
||||
'@tybys/wasm-util@0.10.1':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@types/archy@0.0.31': {}
|
||||
|
||||
'@types/body-parser@1.19.6':
|
||||
@@ -7597,11 +7849,13 @@ snapshots:
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/mocker@4.1.5':
|
||||
'@vitest/mocker@4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@3.13.1)(sass@1.99.0)(stylus@0.64.0)(terser@5.46.2))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.5
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@3.13.1)(sass@1.99.0)(stylus@0.64.0)(terser@5.46.2)
|
||||
|
||||
'@vitest/pretty-format@4.1.5':
|
||||
dependencies:
|
||||
@@ -7826,7 +8080,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
babel-loader@8.2.1(@babel/core@7.29.0)(webpack@5.95.0(@swc/core@1.3.96)):
|
||||
babel-loader@8.2.1(@babel/core@7.29.0)(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
find-cache-dir: 2.1.0
|
||||
@@ -7834,7 +8088,7 @@ snapshots:
|
||||
make-dir: 2.1.0
|
||||
pify: 4.0.1
|
||||
schema-utils: 2.7.1
|
||||
webpack: 5.95.0(@swc/core@1.3.96)
|
||||
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
|
||||
|
||||
babel-plugin-const-enum@1.2.0(@babel/core@7.29.0):
|
||||
dependencies:
|
||||
@@ -8243,7 +8497,7 @@ snapshots:
|
||||
dependencies:
|
||||
is-what: 3.14.1
|
||||
|
||||
copy-webpack-plugin@12.0.2(webpack@5.95.0(@swc/core@1.3.96)):
|
||||
copy-webpack-plugin@12.0.2(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)):
|
||||
dependencies:
|
||||
fast-glob: 3.3.3
|
||||
glob-parent: 6.0.2
|
||||
@@ -8251,7 +8505,7 @@ snapshots:
|
||||
normalize-path: 3.0.0
|
||||
schema-utils: 4.3.3
|
||||
serialize-javascript: 6.0.2
|
||||
webpack: 5.95.0(@swc/core@1.3.96)
|
||||
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
|
||||
|
||||
core-js-compat@3.49.0:
|
||||
dependencies:
|
||||
@@ -8288,7 +8542,7 @@ snapshots:
|
||||
dependencies:
|
||||
postcss: 8.5.12
|
||||
|
||||
css-loader@7.1.4(webpack@5.95.0(@swc/core@1.3.96)):
|
||||
css-loader@7.1.4(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)):
|
||||
dependencies:
|
||||
icss-utils: 5.1.0(postcss@8.5.12)
|
||||
postcss: 8.5.12
|
||||
@@ -8299,9 +8553,9 @@ snapshots:
|
||||
postcss-value-parser: 4.2.0
|
||||
semver: 7.7.4
|
||||
optionalDependencies:
|
||||
webpack: 5.95.0(@swc/core@1.3.96)
|
||||
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
|
||||
|
||||
css-minimizer-webpack-plugin@6.0.0(esbuild@0.21.5)(lightningcss@1.32.0)(webpack@5.95.0(@swc/core@1.3.96)):
|
||||
css-minimizer-webpack-plugin@6.0.0(esbuild@0.21.5)(lightningcss@1.32.0)(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)):
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
cssnano: 6.1.2(postcss@8.5.12)
|
||||
@@ -8309,7 +8563,7 @@ snapshots:
|
||||
postcss: 8.5.12
|
||||
schema-utils: 4.3.3
|
||||
serialize-javascript: 6.0.2
|
||||
webpack: 5.95.0(@swc/core@1.3.96)
|
||||
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
|
||||
optionalDependencies:
|
||||
esbuild: 0.21.5
|
||||
lightningcss: 1.32.0
|
||||
@@ -8674,12 +8928,12 @@ snapshots:
|
||||
has-tostringtag: 1.0.2
|
||||
hasown: 2.0.3
|
||||
|
||||
esbuild-loader@4.4.3(webpack@5.95.0(@swc/core@1.3.96)):
|
||||
esbuild-loader@4.4.3(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)):
|
||||
dependencies:
|
||||
esbuild: 0.27.7
|
||||
get-tsconfig: 4.14.0
|
||||
loader-utils: 2.0.4
|
||||
webpack: 5.95.0(@swc/core@1.3.96)
|
||||
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
|
||||
webpack-sources: 3.4.0
|
||||
|
||||
esbuild@0.21.5:
|
||||
@@ -9272,7 +9526,7 @@ snapshots:
|
||||
relateurl: 0.2.7
|
||||
uglify-js: 3.19.3
|
||||
|
||||
html-webpack-plugin@5.6.7(webpack@5.95.0(@swc/core@1.3.96)):
|
||||
html-webpack-plugin@5.6.7(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)):
|
||||
dependencies:
|
||||
'@types/html-minifier-terser': 6.1.0
|
||||
html-minifier-terser: 6.1.0
|
||||
@@ -9280,7 +9534,7 @@ snapshots:
|
||||
pretty-error: 4.0.0
|
||||
tapable: 2.3.3
|
||||
optionalDependencies:
|
||||
webpack: 5.95.0(@swc/core@1.3.96)
|
||||
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
|
||||
|
||||
htmlparser2@6.1.0:
|
||||
dependencies:
|
||||
@@ -9653,11 +9907,11 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
shell-quote: 1.8.3
|
||||
|
||||
less-loader@12.3.2(less@3.13.1)(webpack@5.95.0(@swc/core@1.3.96)):
|
||||
less-loader@12.3.2(less@3.13.1)(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)):
|
||||
dependencies:
|
||||
less: 3.13.1
|
||||
optionalDependencies:
|
||||
webpack: 5.95.0(@swc/core@1.3.96)
|
||||
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
|
||||
|
||||
less@3.13.1:
|
||||
dependencies:
|
||||
@@ -9883,11 +10137,11 @@ snapshots:
|
||||
dependencies:
|
||||
dom-walk: 0.1.2
|
||||
|
||||
mini-css-extract-plugin@2.10.2(webpack@5.95.0(@swc/core@1.3.96)):
|
||||
mini-css-extract-plugin@2.10.2(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)):
|
||||
dependencies:
|
||||
schema-utils: 4.3.3
|
||||
tapable: 2.3.3
|
||||
webpack: 5.95.0(@swc/core@1.3.96)
|
||||
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
|
||||
|
||||
minimalistic-assert@1.0.1: {}
|
||||
|
||||
@@ -10295,14 +10549,14 @@ snapshots:
|
||||
read-cache: 1.0.0
|
||||
resolve: 1.22.12
|
||||
|
||||
postcss-loader@8.2.1(postcss@8.5.12)(typescript@5.9.3)(webpack@5.95.0(@swc/core@1.3.96)):
|
||||
postcss-loader@8.2.1(postcss@8.5.12)(typescript@5.9.3)(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)):
|
||||
dependencies:
|
||||
cosmiconfig: 9.0.1(typescript@5.9.3)
|
||||
jiti: 2.6.1
|
||||
postcss: 8.5.12
|
||||
semver: 7.7.4
|
||||
optionalDependencies:
|
||||
webpack: 5.95.0(@swc/core@1.3.96)
|
||||
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
@@ -10699,6 +10953,27 @@ snapshots:
|
||||
dependencies:
|
||||
glob: 7.2.3
|
||||
|
||||
rolldown@1.0.0-rc.17:
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.127.0
|
||||
'@rolldown/pluginutils': 1.0.0-rc.17
|
||||
optionalDependencies:
|
||||
'@rolldown/binding-android-arm64': 1.0.0-rc.17
|
||||
'@rolldown/binding-darwin-arm64': 1.0.0-rc.17
|
||||
'@rolldown/binding-darwin-x64': 1.0.0-rc.17
|
||||
'@rolldown/binding-freebsd-x64': 1.0.0-rc.17
|
||||
'@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.17
|
||||
'@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.17
|
||||
'@rolldown/binding-linux-arm64-musl': 1.0.0-rc.17
|
||||
'@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.17
|
||||
'@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.17
|
||||
'@rolldown/binding-linux-x64-gnu': 1.0.0-rc.17
|
||||
'@rolldown/binding-linux-x64-musl': 1.0.0-rc.17
|
||||
'@rolldown/binding-openharmony-arm64': 1.0.0-rc.17
|
||||
'@rolldown/binding-wasm32-wasi': 1.0.0-rc.17
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17
|
||||
'@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17
|
||||
|
||||
rollup@3.30.0:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
@@ -10723,12 +10998,12 @@ snapshots:
|
||||
|
||||
safer-buffer@2.1.2: {}
|
||||
|
||||
sass-loader@14.2.1(sass@1.99.0)(webpack@5.95.0(@swc/core@1.3.96)):
|
||||
sass-loader@14.2.1(sass@1.99.0)(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)):
|
||||
dependencies:
|
||||
neo-async: 2.6.2
|
||||
optionalDependencies:
|
||||
sass: 1.99.0
|
||||
webpack: 5.95.0(@swc/core@1.3.96)
|
||||
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
|
||||
|
||||
sass@1.99.0:
|
||||
dependencies:
|
||||
@@ -11019,9 +11294,9 @@ snapshots:
|
||||
dependencies:
|
||||
escape-string-regexp: 1.0.5
|
||||
|
||||
style-loader@3.3.4(webpack@5.95.0(@swc/core@1.3.96)):
|
||||
style-loader@3.3.4(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)):
|
||||
dependencies:
|
||||
webpack: 5.95.0(@swc/core@1.3.96)
|
||||
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
|
||||
|
||||
stylehacks@6.1.1(postcss@8.5.12):
|
||||
dependencies:
|
||||
@@ -11029,13 +11304,13 @@ snapshots:
|
||||
postcss: 8.5.12
|
||||
postcss-selector-parser: 6.1.2
|
||||
|
||||
stylus-loader@8.1.3(stylus@0.64.0)(webpack@5.95.0(@swc/core@1.3.96)):
|
||||
stylus-loader@8.1.3(stylus@0.64.0)(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)):
|
||||
dependencies:
|
||||
fast-glob: 3.3.3
|
||||
normalize-path: 3.0.0
|
||||
stylus: 0.64.0
|
||||
optionalDependencies:
|
||||
webpack: 5.95.0(@swc/core@1.3.96)
|
||||
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
|
||||
|
||||
stylus@0.64.0:
|
||||
dependencies:
|
||||
@@ -11088,17 +11363,28 @@ snapshots:
|
||||
to-buffer: 1.2.2
|
||||
xtend: 4.0.2
|
||||
|
||||
terser-webpack-plugin@5.5.0(@swc/core@1.3.96)(esbuild@0.21.5)(webpack@5.95.0(@swc/core@1.3.96)):
|
||||
terser-webpack-plugin@5.5.0(@swc/core@1.3.96)(esbuild@0.21.5)(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)):
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
jest-worker: 27.5.1
|
||||
schema-utils: 4.3.3
|
||||
terser: 5.46.2
|
||||
webpack: 5.95.0(@swc/core@1.3.96)
|
||||
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
|
||||
optionalDependencies:
|
||||
'@swc/core': 1.3.96
|
||||
esbuild: 0.21.5
|
||||
|
||||
terser-webpack-plugin@5.5.0(@swc/core@1.3.96)(esbuild@0.27.7)(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)):
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
jest-worker: 27.5.1
|
||||
schema-utils: 4.3.3
|
||||
terser: 5.46.2
|
||||
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
|
||||
optionalDependencies:
|
||||
'@swc/core': 1.3.96
|
||||
esbuild: 0.27.7
|
||||
|
||||
terser@5.46.2:
|
||||
dependencies:
|
||||
'@jridgewell/source-map': 0.3.11
|
||||
@@ -11272,10 +11558,27 @@ snapshots:
|
||||
|
||||
vary@1.1.2: {}
|
||||
|
||||
vitest@4.1.5(@types/node@25.6.0)(jsdom@24.1.3):
|
||||
vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@3.13.1)(sass@1.99.0)(stylus@0.64.0)(terser@5.46.2):
|
||||
dependencies:
|
||||
lightningcss: 1.32.0
|
||||
picomatch: 4.0.4
|
||||
postcss: 8.5.12
|
||||
rolldown: 1.0.0-rc.17
|
||||
tinyglobby: 0.2.16
|
||||
optionalDependencies:
|
||||
'@types/node': 25.6.0
|
||||
esbuild: 0.27.7
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.6.1
|
||||
less: 3.13.1
|
||||
sass: 1.99.0
|
||||
stylus: 0.64.0
|
||||
terser: 5.46.2
|
||||
|
||||
vitest@4.1.5(@types/node@25.6.0)(jsdom@24.1.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@3.13.1)(sass@1.99.0)(stylus@0.64.0)(terser@5.46.2)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.5
|
||||
'@vitest/mocker': 4.1.5
|
||||
'@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@3.13.1)(sass@1.99.0)(stylus@0.64.0)(terser@5.46.2))
|
||||
'@vitest/pretty-format': 4.1.5
|
||||
'@vitest/runner': 4.1.5
|
||||
'@vitest/snapshot': 4.1.5
|
||||
@@ -11292,6 +11595,7 @@ snapshots:
|
||||
tinyexec: 1.1.1
|
||||
tinyglobby: 0.2.16
|
||||
tinyrainbow: 3.1.0
|
||||
vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@3.13.1)(sass@1.99.0)(stylus@0.64.0)(terser@5.46.2)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 25.6.0
|
||||
@@ -11328,16 +11632,16 @@ snapshots:
|
||||
deepmerge: 1.5.2
|
||||
javascript-stringify: 2.1.0
|
||||
|
||||
webpack-dev-middleware@5.3.4(webpack@5.95.0(@swc/core@1.3.96)):
|
||||
webpack-dev-middleware@5.3.4(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)):
|
||||
dependencies:
|
||||
colorette: 2.0.20
|
||||
memfs: 3.5.3
|
||||
mime-types: 2.1.35
|
||||
range-parser: 1.2.1
|
||||
schema-utils: 4.3.3
|
||||
webpack: 5.95.0(@swc/core@1.3.96)
|
||||
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
|
||||
|
||||
webpack-dev-server@4.15.2(webpack@5.95.0(@swc/core@1.3.96)):
|
||||
webpack-dev-server@4.15.2(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)):
|
||||
dependencies:
|
||||
'@types/bonjour': 3.5.13
|
||||
'@types/connect-history-api-fallback': 1.5.4
|
||||
@@ -11367,10 +11671,10 @@ snapshots:
|
||||
serve-index: 1.9.2
|
||||
sockjs: 0.3.24
|
||||
spdy: 4.0.2
|
||||
webpack-dev-middleware: 5.3.4(webpack@5.95.0(@swc/core@1.3.96))
|
||||
webpack-dev-middleware: 5.3.4(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
|
||||
ws: 8.20.0
|
||||
optionalDependencies:
|
||||
webpack: 5.95.0(@swc/core@1.3.96)
|
||||
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- debug
|
||||
@@ -11391,7 +11695,7 @@ snapshots:
|
||||
|
||||
webpack-virtual-modules@0.6.2: {}
|
||||
|
||||
webpack@5.95.0(@swc/core@1.3.96):
|
||||
webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7):
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
'@webassemblyjs/ast': 1.14.1
|
||||
@@ -11413,7 +11717,7 @@ snapshots:
|
||||
neo-async: 2.6.2
|
||||
schema-utils: 3.3.0
|
||||
tapable: 2.3.3
|
||||
terser-webpack-plugin: 5.5.0(@swc/core@1.3.96)(esbuild@0.21.5)(webpack@5.95.0(@swc/core@1.3.96))
|
||||
terser-webpack-plugin: 5.5.0(@swc/core@1.3.96)(esbuild@0.27.7)(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
|
||||
watchpack: 2.5.1
|
||||
webpack-sources: 3.4.0
|
||||
transitivePeerDependencies:
|
||||
@@ -11421,13 +11725,13 @@ snapshots:
|
||||
- esbuild
|
||||
- uglify-js
|
||||
|
||||
webpackbar@5.0.2(webpack@5.95.0(@swc/core@1.3.96)):
|
||||
webpackbar@5.0.2(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)):
|
||||
dependencies:
|
||||
chalk: 4.1.2
|
||||
consola: 2.15.3
|
||||
pretty-time: 1.1.0
|
||||
std-env: 3.10.0
|
||||
webpack: 5.95.0(@swc/core@1.3.96)
|
||||
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
|
||||
|
||||
websocket-driver@0.7.4:
|
||||
dependencies:
|
||||
|
||||
@@ -9,8 +9,12 @@
|
||||
"enhance": false,
|
||||
"compileHotReLoad": true,
|
||||
"postcss": false,
|
||||
"minified": false,
|
||||
"minified": true,
|
||||
"bundle": false,
|
||||
"minifyWXML": true
|
||||
}
|
||||
"minifyWXML": true,
|
||||
"packNpmManually": false,
|
||||
"packNpmRelationList": [],
|
||||
"ignoreUploadUnusedFiles": true
|
||||
},
|
||||
"condition": {}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
"lazyloadPlaceholderEnable": false,
|
||||
"skylineRenderEnable": false,
|
||||
"preloadBackgroundData": false,
|
||||
"autoAudits": false,
|
||||
"autoAudits": true,
|
||||
"useApiHook": true,
|
||||
"showShadowRootInWxmlPanel": false,
|
||||
"useStaticServer": false,
|
||||
|
||||
@@ -3,6 +3,7 @@ export default defineAppConfig({
|
||||
'pages/index/index',
|
||||
'pages/login/index',
|
||||
'pages/health/index',
|
||||
'pages/messages/index',
|
||||
'pages/consultation/index',
|
||||
'pages/consultation/detail/index',
|
||||
'pages/mall/index',
|
||||
@@ -26,6 +27,9 @@ export default defineAppConfig({
|
||||
'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',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -37,6 +41,10 @@ export default defineAppConfig({
|
||||
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',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -71,9 +79,8 @@ export default defineAppConfig({
|
||||
borderStyle: 'white',
|
||||
list: [
|
||||
{ pagePath: 'pages/index/index', text: '首页', iconPath: 'assets/tabbar/home.png', selectedIconPath: 'assets/tabbar/home-active.png' },
|
||||
{ pagePath: 'pages/health/index', text: '上报', iconPath: 'assets/tabbar/health.png', selectedIconPath: 'assets/tabbar/health-active.png' },
|
||||
{ pagePath: 'pages/consultation/index', text: '咨询', iconPath: 'assets/tabbar/appointment.png', selectedIconPath: 'assets/tabbar/appointment-active.png' },
|
||||
{ pagePath: 'pages/mall/index', text: '商城', iconPath: 'assets/tabbar/article.png', selectedIconPath: 'assets/tabbar/article-active.png' },
|
||||
{ pagePath: 'pages/health/index', text: '健康', iconPath: 'assets/tabbar/health.png', selectedIconPath: 'assets/tabbar/health-active.png' },
|
||||
{ pagePath: 'pages/messages/index', text: '消息', iconPath: 'assets/tabbar/message.png', selectedIconPath: 'assets/tabbar/message-active.png' },
|
||||
{ pagePath: 'pages/profile/index', text: '我的', iconPath: 'assets/tabbar/profile.png', selectedIconPath: 'assets/tabbar/profile-active.png' },
|
||||
],
|
||||
},
|
||||
@@ -82,5 +89,6 @@ export default defineAppConfig({
|
||||
navigationBarBackgroundColor: '#FFFFFF',
|
||||
navigationBarTitleText: '健康管理',
|
||||
navigationBarTextStyle: 'black',
|
||||
enablePullDownRefresh: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
@import './styles/variables.scss';
|
||||
@import './styles/tokens.scss';
|
||||
@import './styles/elder-mode.scss';
|
||||
|
||||
page {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica, 'PingFang SC',
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { useEffect, PropsWithChildren } from 'react';
|
||||
import Taro from '@tarojs/taro';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import { flushEvents } from './services/analytics';
|
||||
import { useAuthStore } from './stores/auth';
|
||||
import { useUIStore } from './stores/ui';
|
||||
import './app.scss';
|
||||
|
||||
function App({ children }: PropsWithChildren<Record<string, unknown>>) {
|
||||
const restoreAuth = useAuthStore((s) => s.restore);
|
||||
const restoreUI = useUIStore((s) => s.restore);
|
||||
|
||||
useDidShow(() => {
|
||||
restoreAuth();
|
||||
restoreUI();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
restoreAuth();
|
||||
const timer = setInterval(() => {
|
||||
flushEvents();
|
||||
}, 30000);
|
||||
|
||||
BIN
apps/miniprogram/src/assets/tabbar/message-active.png
Normal file
BIN
apps/miniprogram/src/assets/tabbar/message-active.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 333 B |
BIN
apps/miniprogram/src/assets/tabbar/message.png
Normal file
BIN
apps/miniprogram/src/assets/tabbar/message.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 334 B |
@@ -10,7 +10,7 @@
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
.device-icon {
|
||||
font-size: 48rpx;
|
||||
font-size: var(--tk-font-h2);
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
@@ -18,14 +18,14 @@
|
||||
flex: 1;
|
||||
|
||||
.device-name {
|
||||
font-size: 28rpx;
|
||||
font-size: var(--tk-font-cap);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.device-status {
|
||||
font-size: 24rpx;
|
||||
font-size: var(--tk-font-micro);
|
||||
margin-top: 4rpx;
|
||||
display: block;
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
}
|
||||
|
||||
.last-sync {
|
||||
font-size: 22rpx;
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx3;
|
||||
margin-top: 4rpx;
|
||||
display: block;
|
||||
@@ -46,6 +46,6 @@
|
||||
background: $pri;
|
||||
color: #fff;
|
||||
border-radius: $r-pill;
|
||||
font-size: 24rpx;
|
||||
font-size: var(--tk-font-micro);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,20 +8,33 @@
|
||||
padding: 120px 40px;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 80px;
|
||||
.empty-state-icon-wrap {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
background: $surface-alt;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.empty-state-icon-char {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-hero);
|
||||
font-weight: 600;
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.empty-state-text {
|
||||
font-size: 30px;
|
||||
font-size: var(--tk-font-num);
|
||||
color: $tx2;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-state-hint {
|
||||
font-size: 24px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: var(--tk-text-secondary);
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
@@ -32,6 +45,6 @@
|
||||
}
|
||||
|
||||
.empty-state-action-text {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -11,15 +11,18 @@ interface EmptyStateProps {
|
||||
}
|
||||
|
||||
export default React.memo(function EmptyState({
|
||||
icon = '📭',
|
||||
icon,
|
||||
text,
|
||||
hint,
|
||||
actionText,
|
||||
onAction,
|
||||
}: EmptyStateProps) {
|
||||
const displayChar = icon || text.charAt(0);
|
||||
return (
|
||||
<View className='empty-state'>
|
||||
<Text className='empty-state-icon'>{icon}</Text>
|
||||
<View className='empty-state-icon-wrap'>
|
||||
<Text className='empty-state-icon-char'>{displayChar}</Text>
|
||||
</View>
|
||||
<Text className='empty-state-text'>{text}</Text>
|
||||
{hint && <Text className='empty-state-hint'>{hint}</Text>}
|
||||
{actionText && onAction && (
|
||||
|
||||
@@ -23,13 +23,25 @@ export default class ErrorBoundary extends Component<Props, State> {
|
||||
console.error('[ErrorBoundary]', error, info.componentStack);
|
||||
}
|
||||
|
||||
handleRetry = () => {
|
||||
this.setState({ hasError: false });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<View style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '60vh', padding: '40px' }}>
|
||||
<Text style={{ fontSize: '48px', marginBottom: '20px' }}>😵</Text>
|
||||
<Text style={{ fontSize: '32px', color: '#134E4A', marginBottom: '12px' }}>页面出了点问题</Text>
|
||||
<Text style={{ fontSize: '24px', color: '#94A3B8', marginBottom: '24px' }}>请返回重试</Text>
|
||||
<View style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '60vh', padding: '40px 24px' }}>
|
||||
<View style={{ width: '64px', height: '64px', borderRadius: '32px', background: '#F0DDD4', display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: '20px' }}>
|
||||
<Text style={{ fontFamily: 'Georgia, serif', fontSize: '28px', fontWeight: 600, color: '#8B3E1F' }}>!</Text>
|
||||
</View>
|
||||
<Text style={{ fontSize: '32px', color: '#2D2A26', marginBottom: '12px', fontWeight: 600 }}>页面出了点问题</Text>
|
||||
<Text style={{ fontSize: '24px', color: '#78716C', marginBottom: '32px' }}>请返回重试</Text>
|
||||
<View
|
||||
onClick={this.handleRetry}
|
||||
style={{ background: '#C4623A', borderRadius: '12px', padding: '14px 48px' }}
|
||||
>
|
||||
<Text style={{ color: '#FFFFFF', fontSize: '28px' }}>重新加载</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,12 +9,12 @@
|
||||
}
|
||||
|
||||
.error-state-icon {
|
||||
font-size: 80px;
|
||||
font-size: 80px; /* hero icon — kept as-is */
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.error-state-text {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx2;
|
||||
margin-bottom: 32px;
|
||||
text-align: center;
|
||||
@@ -27,6 +27,6 @@
|
||||
}
|
||||
|
||||
.error-state-retry-text {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
64
apps/miniprogram/src/components/GuestGuard/index.scss
Normal file
64
apps/miniprogram/src/components/GuestGuard/index.scss
Normal file
@@ -0,0 +1,64 @@
|
||||
@import '../../styles/variables.scss';
|
||||
@import '../../styles/mixins.scss';
|
||||
|
||||
.guard-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 24px;
|
||||
}
|
||||
|
||||
.guard-card {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.guard-icon-wrap {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 40px;
|
||||
background: $surface-alt;
|
||||
@include flex-center;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
.guard-icon {
|
||||
font-size: var(--tk-font-num);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.guard-title {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.guard-desc {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: var(--tk-text-secondary);
|
||||
display: block;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.guard-btn {
|
||||
display: inline-block;
|
||||
height: 48px;
|
||||
padding: 0 32px;
|
||||
background: $pri;
|
||||
border-radius: $r-pill;
|
||||
@include flex-center;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.guard-btn-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
28
apps/miniprogram/src/components/GuestGuard/index.tsx
Normal file
28
apps/miniprogram/src/components/GuestGuard/index.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import { navigateToLogin } from '../../utils/navigate';
|
||||
import './index.scss';
|
||||
|
||||
interface GuestGuardProps {
|
||||
title: string;
|
||||
desc?: string;
|
||||
}
|
||||
|
||||
export default function GuestGuard({ title, desc }: GuestGuardProps) {
|
||||
return (
|
||||
<View className='guard-page'>
|
||||
<View className='guard-card'>
|
||||
<View className='guard-icon-wrap'>
|
||||
<Text className='guard-icon'>锁</Text>
|
||||
</View>
|
||||
<Text className='guard-title'>{title}</Text>
|
||||
{desc && <Text className='guard-desc'>{desc}</Text>}
|
||||
<View
|
||||
className='guard-btn'
|
||||
onClick={navigateToLogin}
|
||||
>
|
||||
<Text className='guard-btn-text'>立即登录</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,6 @@
|
||||
}
|
||||
|
||||
.loading-state-text {
|
||||
font-size: 26px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: var(--tk-text-secondary);
|
||||
}
|
||||
|
||||
29
apps/miniprogram/src/components/ProgressRing.scss
Normal file
29
apps/miniprogram/src/components/ProgressRing.scss
Normal file
@@ -0,0 +1,29 @@
|
||||
@import '../styles/variables.scss';
|
||||
|
||||
.progress-ring {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.progress-ring-inner {
|
||||
background: $card;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.progress-ring-percent {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.progress-ring-unit {
|
||||
font-size: var(--tk-font-micro);
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
40
apps/miniprogram/src/components/ProgressRing.tsx
Normal file
40
apps/miniprogram/src/components/ProgressRing.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import './ProgressRing.scss';
|
||||
|
||||
interface ProgressRingProps {
|
||||
percent: number;
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
color?: string;
|
||||
trackColor?: string;
|
||||
}
|
||||
|
||||
export default function ProgressRing({
|
||||
percent,
|
||||
size = 72,
|
||||
strokeWidth = 7,
|
||||
color = '#C4623A',
|
||||
trackColor = '#E8E2DC',
|
||||
}: ProgressRingProps) {
|
||||
const clamped = Math.max(0, Math.min(100, percent));
|
||||
const innerSize = size - strokeWidth * 2;
|
||||
|
||||
return (
|
||||
<View
|
||||
className='progress-ring'
|
||||
style={`width:${size}px;height:${size}px;background:conic-gradient(${color} ${clamped}%, ${trackColor} ${clamped}%);border-radius:50%;padding:${strokeWidth}px;`}
|
||||
>
|
||||
<View
|
||||
className='progress-ring-inner'
|
||||
style={`width:${innerSize}px;height:${innerSize}px;`}
|
||||
>
|
||||
<Text className='progress-ring-percent' style={`color:${color};`}>
|
||||
{clamped}
|
||||
</Text>
|
||||
<Text className='progress-ring-unit' style={`color:${color};`}>
|
||||
%
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -39,7 +39,7 @@
|
||||
justify-content: center;
|
||||
background: $bd-l;
|
||||
color: $tx3;
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
transition: all 0.3s ease;
|
||||
z-index: 1;
|
||||
|
||||
@@ -55,8 +55,8 @@
|
||||
}
|
||||
|
||||
.step-label {
|
||||
font-size: 22px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-body);
|
||||
color: var(--tk-text-secondary);
|
||||
margin-top: 8px;
|
||||
text-align: center;
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
}
|
||||
|
||||
.trend-chart-empty-text {
|
||||
font-size: 28px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: var(--tk-text-secondary);
|
||||
}
|
||||
|
||||
.trend-chart-skeleton {
|
||||
|
||||
@@ -14,13 +14,13 @@
|
||||
}
|
||||
|
||||
.week-arrow {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $pri;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.week-label {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx;
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -39,13 +39,13 @@
|
||||
}
|
||||
|
||||
.cell-weekday {
|
||||
font-size: 20px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-body);
|
||||
color: var(--tk-text-secondary);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cell-date {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
|
||||
6
apps/miniprogram/src/hooks/useElderClass.ts
Normal file
6
apps/miniprogram/src/hooks/useElderClass.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { useUIStore } from '../stores/ui';
|
||||
|
||||
export function useElderClass(): string {
|
||||
const mode = useUIStore((s) => s.mode);
|
||||
return mode === 'elder' ? 'elder-mode' : '';
|
||||
}
|
||||
@@ -1,23 +1,5 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
|
||||
@mixin section-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
margin-bottom: 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@mixin tag($bg, $color) {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
background: $bg;
|
||||
color: $color;
|
||||
}
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
.detail-page {
|
||||
min-height: 100vh;
|
||||
@@ -45,7 +27,7 @@
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
@@ -63,7 +45,7 @@
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
line-height: 1.8;
|
||||
margin-bottom: 16px;
|
||||
@@ -75,7 +57,7 @@
|
||||
}
|
||||
|
||||
li {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
line-height: 1.8;
|
||||
margin-bottom: 8px;
|
||||
@@ -87,7 +69,7 @@
|
||||
}
|
||||
|
||||
.report-content {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
line-height: 1.8;
|
||||
color: $tx;
|
||||
}
|
||||
@@ -96,8 +78,8 @@
|
||||
display: block;
|
||||
text-align: center;
|
||||
padding: 120px 0;
|
||||
color: $tx3;
|
||||
font-size: 28px;
|
||||
color: var(--tk-text-secondary);
|
||||
font-size: var(--tk-font-body-lg);
|
||||
}
|
||||
|
||||
.auto-badge {
|
||||
@@ -109,7 +91,7 @@
|
||||
display: inline-block;
|
||||
padding: 4px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 20px;
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 500;
|
||||
background: #f0e6ff;
|
||||
color: #7c3aed;
|
||||
@@ -124,7 +106,7 @@
|
||||
}
|
||||
|
||||
.trend-tip-text {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: #92400e;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { View, Text, RichText } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { getAiAnalysisDetail, type AiAnalysisItem } from '@/services/ai-analysis';
|
||||
import Loading from '@/components/Loading';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
@@ -44,6 +45,7 @@ function markdownToHtml(md: string): string {
|
||||
}
|
||||
|
||||
export default function AiReportDetail() {
|
||||
const modeClass = useElderClass();
|
||||
const router = useRouter();
|
||||
const id = router.params.id || '';
|
||||
|
||||
@@ -63,7 +65,7 @@ export default function AiReportDetail() {
|
||||
|
||||
if (!analysis) {
|
||||
return (
|
||||
<View className='detail-page'>
|
||||
<View className={`detail-page ${modeClass}`}>
|
||||
<Text className='empty-text'>报告不存在</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -77,7 +79,7 @@ export default function AiReportDetail() {
|
||||
const isAutoAnalysis = (analysis.result_metadata as Record<string, unknown>)?.auto_analysis === true;
|
||||
|
||||
return (
|
||||
<View className='detail-page'>
|
||||
<View className={`detail-page ${modeClass}`}>
|
||||
<View className='detail-card'>
|
||||
<Text className='detail-type'>{TYPE_LABELS[analysis.analysis_type] || analysis.analysis_type}</Text>
|
||||
<View className='detail-meta'>
|
||||
|
||||
@@ -1,28 +1,5 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
|
||||
@mixin serif-number {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@mixin section-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
margin-bottom: 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@mixin tag($bg, $color) {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
background: $bg;
|
||||
color: $color;
|
||||
}
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
.ai-report-page {
|
||||
min-height: 100vh;
|
||||
@@ -55,7 +32,7 @@
|
||||
}
|
||||
|
||||
.card-type {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 500;
|
||||
color: $tx;
|
||||
}
|
||||
@@ -87,19 +64,19 @@
|
||||
}
|
||||
|
||||
.card-time {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.card-model {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.no-more {
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: var(--tk-text-secondary);
|
||||
padding: 24px 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import Taro from '@tarojs/taro';
|
||||
import { listAiAnalysis, type AiAnalysisItem } from '@/services/ai-analysis';
|
||||
import Loading from '@/components/Loading';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
@@ -21,6 +22,7 @@ const STATUS_MAP: Record<string, { text: string; className: string }> = {
|
||||
};
|
||||
|
||||
export default function AiReportList() {
|
||||
const modeClass = useElderClass();
|
||||
const [list, setList] = useState<AiAnalysisItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -60,14 +62,14 @@ export default function AiReportList() {
|
||||
|
||||
if (list.length === 0) {
|
||||
return (
|
||||
<View className='ai-report-page'>
|
||||
<View className={`ai-report-page ${modeClass}`}>
|
||||
<EmptyState text='暂无 AI 分析报告' />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='ai-report-page'>
|
||||
<View className={`ai-report-page ${modeClass}`}>
|
||||
<View className='page-title'>AI 分析报告</View>
|
||||
<ScrollView scrollY className='report-scroll' onScrollToLower={loadMore}>
|
||||
{list.map((item) => {
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
|
||||
.dept-initial-text {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 30px;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $pri;
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
}
|
||||
|
||||
.dept-label {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -77,7 +77,7 @@
|
||||
|
||||
.slot-section-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
margin-bottom: 16px;
|
||||
@@ -114,14 +114,14 @@
|
||||
|
||||
.slot-time {
|
||||
@include serif-number;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.slot-count {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
@@ -156,7 +156,7 @@
|
||||
|
||||
.confirm-icon-serif {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
font-weight: bold;
|
||||
color: $pri;
|
||||
}
|
||||
@@ -169,12 +169,12 @@
|
||||
}
|
||||
|
||||
.confirm-label {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.confirm-value {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
}
|
||||
@@ -185,7 +185,7 @@
|
||||
}
|
||||
|
||||
.confirm-dept-text {
|
||||
font-size: 20px;
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 500;
|
||||
color: $pri;
|
||||
}
|
||||
@@ -225,7 +225,7 @@
|
||||
|
||||
.doctor-avatar-text {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 32px;
|
||||
font-size: var(--tk-font-num);
|
||||
color: $pri;
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -238,18 +238,18 @@
|
||||
}
|
||||
|
||||
.doctor-name {
|
||||
font-size: 30px;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.doctor-title {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.doctor-specialty {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
@@ -263,7 +263,7 @@
|
||||
}
|
||||
|
||||
.doctor-check-text {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -274,7 +274,7 @@
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
margin-bottom: 12px;
|
||||
display: block;
|
||||
@@ -284,7 +284,7 @@
|
||||
background: $card;
|
||||
border-radius: $r-sm;
|
||||
padding: 24px 28px;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
@@ -298,8 +298,8 @@
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 28px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: var(--tk-text-secondary);
|
||||
}
|
||||
|
||||
/* 底部操作栏 */
|
||||
@@ -339,7 +339,7 @@
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
font-size: 30px;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { TEMPLATE_IDS } from '@/services/wechat-templates';
|
||||
import { trackEvent } from '@/services/analytics';
|
||||
import StepIndicator from '../../../components/StepIndicator';
|
||||
import WeekCalendar from '../../../components/WeekCalendar';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
const DEPARTMENTS = [
|
||||
@@ -44,6 +45,7 @@ export default function AppointmentCreate() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [schedules, setSchedules] = useState<any[]>([]);
|
||||
const [timeSlots, setTimeSlots] = useState<TimeSlot[]>([]);
|
||||
const modeClass = useElderClass();
|
||||
|
||||
const currentPatient = useAuthStore((s) => s.currentPatient);
|
||||
|
||||
@@ -148,7 +150,7 @@ export default function AppointmentCreate() {
|
||||
};
|
||||
|
||||
return (
|
||||
<View className='create-page'>
|
||||
<View className={`create-page ${modeClass}`}>
|
||||
<StepIndicator
|
||||
steps={[{ label: '选科室' }, { label: '选医生' }, { label: '选时段' }]}
|
||||
current={currentStep}
|
||||
|
||||
@@ -23,14 +23,14 @@
|
||||
}
|
||||
|
||||
.back-text {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $pri;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 34px;
|
||||
font-size: var(--tk-font-num-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
}
|
||||
@@ -80,19 +80,19 @@
|
||||
}
|
||||
|
||||
.status-tag-text {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-doctor {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 36px;
|
||||
font-size: var(--tk-font-num-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.status-dept {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
|
||||
.info-icon-serif {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 20px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $pri;
|
||||
background: $pri-l;
|
||||
width: 36px;
|
||||
@@ -143,12 +143,12 @@
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -163,8 +163,8 @@
|
||||
|
||||
.info-id {
|
||||
@include serif-number;
|
||||
font-size: 22px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-body);
|
||||
color: var(--tk-text-secondary);
|
||||
word-break: break-all;
|
||||
max-width: 400px;
|
||||
text-align: right;
|
||||
@@ -180,7 +180,7 @@
|
||||
|
||||
.tips-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $wrn;
|
||||
margin-bottom: 12px;
|
||||
@@ -188,7 +188,7 @@
|
||||
}
|
||||
|
||||
.tips-text {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -219,7 +219,7 @@
|
||||
}
|
||||
|
||||
.cancel-text {
|
||||
font-size: 30px;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $dan;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getAppointment, cancelAppointment } from '../../../services/appointment
|
||||
import type { Appointment } from '../../../services/appointment';
|
||||
import Loading from '../../../components/Loading';
|
||||
import ErrorState from '../../../components/ErrorState';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; className: string }> = {
|
||||
@@ -22,6 +23,7 @@ export default function AppointmentDetail() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const modeClass = useElderClass();
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
@@ -65,7 +67,7 @@ export default function AppointmentDetail() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View className='detail-page'>
|
||||
<View className={`detail-page ${modeClass}`}>
|
||||
<View className='detail-header'>
|
||||
<View className='back-btn' onClick={goBack}><Text className='back-text'>返回</Text></View>
|
||||
<Text className='header-title'>预约详情</Text>
|
||||
@@ -78,7 +80,7 @@ export default function AppointmentDetail() {
|
||||
|
||||
if (error || !appointment) {
|
||||
return (
|
||||
<View className='detail-page'>
|
||||
<View className={`detail-page ${modeClass}`}>
|
||||
<View className='detail-header'>
|
||||
<View className='back-btn' onClick={goBack}><Text className='back-text'>返回</Text></View>
|
||||
<Text className='header-title'>预约详情</Text>
|
||||
@@ -90,7 +92,7 @@ export default function AppointmentDetail() {
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='detail-page'>
|
||||
<View className={`detail-page ${modeClass}`}>
|
||||
<View className='detail-header'>
|
||||
<View className='back-btn' onClick={goBack}><Text className='back-text'>返回</Text></View>
|
||||
<Text className='header-title'>预约详情</Text>
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
.page-title {
|
||||
@include section-title;
|
||||
margin-bottom: 4px;
|
||||
font-size: 36px;
|
||||
font-size: var(--tk-font-num-lg);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 22px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-body);
|
||||
color: var(--tk-text-secondary);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
|
||||
.dept-initial-text {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 32px;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $pri;
|
||||
}
|
||||
@@ -80,7 +80,7 @@
|
||||
}
|
||||
|
||||
.doctor-name {
|
||||
font-size: 30px;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
overflow: hidden;
|
||||
@@ -93,7 +93,7 @@
|
||||
}
|
||||
|
||||
.dept-tag-text {
|
||||
font-size: 20px;
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 500;
|
||||
color: $pri;
|
||||
}
|
||||
@@ -124,7 +124,7 @@
|
||||
}
|
||||
|
||||
.status-tag-text {
|
||||
font-size: 20px;
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -157,12 +157,12 @@
|
||||
|
||||
.info-icon-serif {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
}
|
||||
|
||||
.fab-text {
|
||||
font-size: 30px;
|
||||
font-size: var(--tk-font-num);
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
letter-spacing: 2px;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { listAppointments } from '../../services/appointment';
|
||||
import type { Appointment } from '../../services/appointment';
|
||||
import EmptyState from '../../components/EmptyState';
|
||||
import Loading from '../../components/Loading';
|
||||
import { useElderClass } from '../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; className: string }> = {
|
||||
@@ -30,6 +31,7 @@ export default function AppointmentList() {
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const loadingRef = useRef(false);
|
||||
const modeClass = useElderClass();
|
||||
|
||||
const fetchData = useCallback(async (pageNum: number, isRefresh = false) => {
|
||||
if (loadingRef.current) return;
|
||||
@@ -86,11 +88,10 @@ export default function AppointmentList() {
|
||||
};
|
||||
|
||||
return (
|
||||
<View className='appointment-page'>
|
||||
<View className={`appointment-page ${modeClass}`}>
|
||||
{/* 页面标题 */}
|
||||
<View className='page-header'>
|
||||
<Text className='page-title'>预约挂号</Text>
|
||||
<Text className='page-subtitle'>Appointment</Text>
|
||||
</View>
|
||||
|
||||
{/* 预约列表 */}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
}
|
||||
|
||||
.article-title {
|
||||
font-size: 38px;
|
||||
font-size: var(--tk-font-hero);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
display: block;
|
||||
@@ -29,7 +29,7 @@
|
||||
}
|
||||
|
||||
.article-category {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $pri;
|
||||
background: $pri-l;
|
||||
padding: 4px 12px;
|
||||
@@ -37,13 +37,13 @@
|
||||
}
|
||||
|
||||
.article-author {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.article-date {
|
||||
font-size: 24px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: var(--tk-text-secondary);
|
||||
}
|
||||
|
||||
.article-summary {
|
||||
@@ -53,7 +53,7 @@
|
||||
}
|
||||
|
||||
.summary-text {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -70,7 +70,7 @@
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
line-height: 1.8;
|
||||
margin-bottom: 16px;
|
||||
@@ -93,6 +93,6 @@
|
||||
|
||||
.loading-text,
|
||||
.empty-text {
|
||||
font-size: 28px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: var(--tk-text-secondary);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ import { View, Text, RichText } from '@tarojs/components';
|
||||
import Taro, { useRouter, useShareAppMessage } from '@tarojs/taro';
|
||||
import { getArticleDetail, Article } from '../../../services/article';
|
||||
import { trackEvent } from '@/services/analytics';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
export default function ArticleDetail() {
|
||||
const modeClass = useElderClass();
|
||||
const router = useRouter();
|
||||
const id = router.params.id || '';
|
||||
|
||||
@@ -31,7 +33,7 @@ export default function ArticleDetail() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View className='article-detail-page'>
|
||||
<View className={`article-detail-page ${modeClass}`}>
|
||||
<View className='loading-state'>
|
||||
<Text className='loading-text'>加载中...</Text>
|
||||
</View>
|
||||
@@ -41,7 +43,7 @@ export default function ArticleDetail() {
|
||||
|
||||
if (!article) {
|
||||
return (
|
||||
<View className='article-detail-page'>
|
||||
<View className={`article-detail-page ${modeClass}`}>
|
||||
<View className='empty-state'>
|
||||
<Text className='empty-text'>文章不存在</Text>
|
||||
</View>
|
||||
@@ -50,7 +52,7 @@ export default function ArticleDetail() {
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='article-detail-page'>
|
||||
<View className={`article-detail-page ${modeClass}`}>
|
||||
{/* 文章头部 */}
|
||||
<View className='article-header'>
|
||||
<Text className='article-title'>{article.title}</Text>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
display: inline-block;
|
||||
padding: 12px 28px;
|
||||
margin-right: 12px;
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
background: $card;
|
||||
border-radius: 32px;
|
||||
@@ -52,7 +52,7 @@
|
||||
}
|
||||
|
||||
.article-card-title {
|
||||
font-size: 30px;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
line-height: 1.4;
|
||||
@@ -66,7 +66,7 @@
|
||||
}
|
||||
|
||||
.article-card-summary {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
line-height: 1.4;
|
||||
display: block;
|
||||
@@ -83,7 +83,7 @@
|
||||
}
|
||||
|
||||
.article-card-tag {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $pri;
|
||||
background: $pri-l;
|
||||
padding: 2px 12px;
|
||||
@@ -91,7 +91,7 @@
|
||||
}
|
||||
|
||||
.article-card-date {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
@@ -116,8 +116,8 @@
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 28px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: var(--tk-text-secondary);
|
||||
}
|
||||
|
||||
.loading-hint {
|
||||
@@ -126,6 +126,6 @@
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 24px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: var(--tk-text-secondary);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ import Taro, { useDidShow, usePullDownRefresh, useReachBottom } from '@tarojs/ta
|
||||
import { listArticles, listCategories, Article, ArticleCategory } from '../../services/article';
|
||||
import EmptyState from '../../components/EmptyState';
|
||||
import Loading from '../../components/Loading';
|
||||
import { useElderClass } from '../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
export default function ArticleList() {
|
||||
const modeClass = useElderClass();
|
||||
const [articles, setArticles] = useState<Article[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -72,7 +74,7 @@ export default function ArticleList() {
|
||||
};
|
||||
|
||||
return (
|
||||
<View className='article-page'>
|
||||
<View className={`article-page ${modeClass}`}>
|
||||
{/* 分类筛选 */}
|
||||
{categories.length > 0 && (
|
||||
<ScrollView scrollX className='article-categories'>
|
||||
|
||||
@@ -8,61 +8,108 @@
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
/* ─── 导航栏 ─── */
|
||||
.chat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px 32px;
|
||||
padding: 12px 16px;
|
||||
background: $card;
|
||||
border-bottom: 1px solid $bd;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 30px;
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
.chat-header__back {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
z-index: 1;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
&__status {
|
||||
font-size: 24px;
|
||||
.chat-header__back-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
.chat-header__center {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chat-header__title {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.chat-header__status {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $acc;
|
||||
margin-top: 2px;
|
||||
|
||||
&--closed {
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 消息区 ─── */
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
padding: 16px 16px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.msg-row {
|
||||
display: flex;
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 16px;
|
||||
gap: 8px;
|
||||
|
||||
&--self {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 医生头像 ─── */
|
||||
.msg-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 16px;
|
||||
background: $pri-l;
|
||||
@include flex-center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.msg-avatar-char {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-cap);
|
||||
font-weight: 700;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
/* ─── 消息气泡 ─── */
|
||||
.msg-bubble {
|
||||
max-width: 70%;
|
||||
padding: 20px 24px;
|
||||
border-radius: $r-lg;
|
||||
position: relative;
|
||||
padding: 12px 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&--other {
|
||||
background: $card;
|
||||
border-top-left-radius: $r-sm;
|
||||
border-radius: $r $r $r $r-xs;
|
||||
}
|
||||
|
||||
&--self {
|
||||
background: $pri;
|
||||
border-top-right-radius: $r-sm;
|
||||
border-radius: $r $r $r-xs $r;
|
||||
}
|
||||
}
|
||||
|
||||
.msg-text {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx;
|
||||
display: block;
|
||||
line-height: 1.6;
|
||||
@@ -76,88 +123,95 @@
|
||||
.msg-date-divider {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px 0 12px;
|
||||
padding: 12px 0;
|
||||
|
||||
&__text {
|
||||
font-size: 22px;
|
||||
color: #94A3B8;
|
||||
background: #F1F5F9;
|
||||
padding: 4px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: var(--tk-font-micro);
|
||||
color: var(--tk-text-secondary);
|
||||
background: $surface-alt;
|
||||
padding: 2px 12px;
|
||||
border-radius: $r-pill;
|
||||
}
|
||||
}
|
||||
|
||||
.msg-image {
|
||||
width: 320px;
|
||||
border-radius: 12px;
|
||||
width: 200px;
|
||||
border-radius: $r-sm;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.msg-time {
|
||||
font-size: 20px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-micro);
|
||||
color: var(--tk-text-secondary);
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
text-align: right;
|
||||
margin-top: 4px;
|
||||
|
||||
.msg-bubble--self & {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-empty {
|
||||
text-align: center;
|
||||
padding: 120px 32px;
|
||||
padding: 80px 24px;
|
||||
|
||||
&__text {
|
||||
font-size: 26px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-cap);
|
||||
color: var(--tk-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 输入栏 ─── */
|
||||
.chat-input-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
gap: 10px;
|
||||
padding: 10px 16px 38px;
|
||||
background: $card;
|
||||
border-top: 1px solid $bd;
|
||||
padding-bottom: calc(16px + env(safe-area-inset-bottom));
|
||||
border-top: 1px solid $bd-l;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
height: 40px;
|
||||
background: $bg;
|
||||
border-radius: $r;
|
||||
padding: 16px 20px;
|
||||
font-size: 28px;
|
||||
margin-right: 16px;
|
||||
border: 1.5px solid $bd;
|
||||
border-radius: 20px;
|
||||
padding: 0 14px;
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.chat-send-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 20px;
|
||||
background: $pri;
|
||||
border-radius: $r;
|
||||
padding: 16px 28px;
|
||||
@include flex-center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 6px rgba(196, 98, 58, 0.3);
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: 28px;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
.chat-send-btn__icon {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chat-closed-bar {
|
||||
padding: 24px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
background: $card;
|
||||
border-top: 1px solid $bd;
|
||||
border-top: 1px solid $bd-l;
|
||||
|
||||
&__text {
|
||||
font-size: 26px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-cap);
|
||||
color: var(--tk-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,14 @@ import {
|
||||
listMessages,
|
||||
sendMessage,
|
||||
markSessionRead,
|
||||
pollMessages,
|
||||
type ConsultationSession,
|
||||
type ConsultationMessage,
|
||||
} from '@/services/consultation';
|
||||
import Loading from '@/components/Loading';
|
||||
import { useElderClass } from '@/hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
const POLL_INTERVAL = 8000;
|
||||
|
||||
export default function ConsultationDetail() {
|
||||
const router = useRouter();
|
||||
const sessionId = router.params.id || '';
|
||||
@@ -23,43 +23,35 @@ export default function ConsultationDetail() {
|
||||
const [sending, setSending] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const scrollViewRef = useRef('');
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const pollingRef = useRef(false);
|
||||
const modeClass = useElderClass();
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionId) {
|
||||
loadData();
|
||||
markRead();
|
||||
startPolling();
|
||||
startLongPolling();
|
||||
}
|
||||
return () => stopPolling();
|
||||
return () => { pollingRef.current = false; };
|
||||
}, [sessionId]);
|
||||
|
||||
const startPolling = () => {
|
||||
stopPolling();
|
||||
pollTimerRef.current = setInterval(pollNewMessages, POLL_INTERVAL);
|
||||
useEffect(() => {
|
||||
if (session?.status === 'closed') {
|
||||
pollingRef.current = false;
|
||||
}
|
||||
}, [session?.status]);
|
||||
|
||||
const startLongPolling = () => {
|
||||
pollingRef.current = true;
|
||||
longPoll();
|
||||
};
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollTimerRef.current) {
|
||||
clearInterval(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const pollNewMessages = async () => {
|
||||
if (!session || session.status === 'closed') {
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
const longPoll = async () => {
|
||||
if (!pollingRef.current) return;
|
||||
try {
|
||||
const lastId = messages.length > 0 ? messages[messages.length - 1].id : undefined;
|
||||
const m = await listMessages(sessionId, {
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
after_id: lastId,
|
||||
});
|
||||
const newMsgs = m.data || [];
|
||||
if (newMsgs.length > 0) {
|
||||
const newMsgs = await pollMessages(sessionId, lastId);
|
||||
if (newMsgs && newMsgs.length > 0) {
|
||||
setMessages((prev) => {
|
||||
const existing = new Set(prev.map((msg) => msg.id));
|
||||
const fresh = newMsgs.filter((msg) => !existing.has(msg.id));
|
||||
@@ -67,7 +59,12 @@ export default function ConsultationDetail() {
|
||||
});
|
||||
scrollViewRef.current = `msg-${messages.length + newMsgs.length}`;
|
||||
}
|
||||
} catch { /* 轮询失败静默忽略 */ }
|
||||
} catch {
|
||||
// 超时或网络错误,静默重试
|
||||
}
|
||||
if (pollingRef.current) {
|
||||
longPoll();
|
||||
}
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
@@ -80,7 +77,7 @@ export default function ConsultationDetail() {
|
||||
setSession(s);
|
||||
setMessages(m.data || []);
|
||||
scrollViewRef.current = `msg-${(m.data || []).length}`;
|
||||
if (s.status === 'closed') stopPolling();
|
||||
if (s.status === 'closed') pollingRef.current = false;
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
@@ -137,14 +134,24 @@ export default function ConsultationDetail() {
|
||||
if (loading) return <Loading />;
|
||||
|
||||
const isOpen = session?.status !== 'closed';
|
||||
const doctorInitial = (session?.subject || '医').charAt(0);
|
||||
const statusLabel = session?.status === 'active' ? '进行中'
|
||||
: session?.status === 'pending' ? '等待接诊'
|
||||
: '已结束';
|
||||
|
||||
return (
|
||||
<View className='chat-page'>
|
||||
<View className={`chat-page ${modeClass}`}>
|
||||
{/* 导航栏 — 对齐设计稿:返回 + 标题 + 副标题 */}
|
||||
<View className='chat-header'>
|
||||
<Text className='chat-header__title'>{session?.subject || '在线咨询'}</Text>
|
||||
{!isOpen && (
|
||||
<Text className='chat-header__status'>已结束</Text>
|
||||
)}
|
||||
<View className='chat-header__back' onClick={() => Taro.navigateBack()}>
|
||||
<Text className='chat-header__back-text'>‹ 返回</Text>
|
||||
</View>
|
||||
<View className='chat-header__center'>
|
||||
<Text className='chat-header__title'>{session?.subject || '在线咨询'}</Text>
|
||||
<Text className={`chat-header__status ${isOpen ? '' : 'chat-header__status--closed'}`}>
|
||||
{statusLabel}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
@@ -164,6 +171,11 @@ export default function ConsultationDetail() {
|
||||
</View>
|
||||
)}
|
||||
<View id={`msg-${idx + 1}`} className={`msg-row ${isSelf ? 'msg-row--self' : ''}`}>
|
||||
{!isSelf && (
|
||||
<View className='msg-avatar'>
|
||||
<Text className='msg-avatar-char'>{doctorInitial}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className={`msg-bubble ${isSelf ? 'msg-bubble--self' : 'msg-bubble--other'}`}>
|
||||
{isImageUrl(msg.content) ? (
|
||||
<Image
|
||||
@@ -203,7 +215,7 @@ export default function ConsultationDetail() {
|
||||
className={`chat-send-btn ${(!inputText.trim() || sending) ? 'chat-send-btn--disabled' : ''}`}
|
||||
onClick={handleSend}
|
||||
>
|
||||
<Text className='chat-send-btn__text'>{sending ? '...' : '发送'}</Text>
|
||||
<Text className='chat-send-btn__icon'>发</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
|
||||
@@ -6,26 +6,36 @@
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
/* ─── 页头 ─── */
|
||||
.consultation-header {
|
||||
background: linear-gradient(135deg, $pri 0%, $pri-d 100%);
|
||||
padding: 48px 32px 36px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.consultation-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 40px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
.consultation-body {
|
||||
padding: 12px 24px 24px;
|
||||
}
|
||||
|
||||
/* ─── 副标题 ─── */
|
||||
.consultation-subtitle {
|
||||
font-size: 24px;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
font-size: var(--tk-font-cap);
|
||||
color: var(--tk-text-secondary);
|
||||
display: block;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* ─── 发起咨询按钮 — 实心主色 ─── */
|
||||
.consultation-create-btn {
|
||||
height: 48px;
|
||||
border-radius: $r;
|
||||
background: $pri;
|
||||
@include flex-center;
|
||||
box-shadow: 0 2px 8px rgba(196, 98, 58, 0.25);
|
||||
margin-bottom: 20px;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.consultation-create-btn-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ─── 居中容器 ─── */
|
||||
@@ -37,7 +47,7 @@
|
||||
}
|
||||
|
||||
.consultation-error {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $dan;
|
||||
}
|
||||
|
||||
@@ -47,51 +57,52 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 160px 40px;
|
||||
padding: 120px 40px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: $pri-l;
|
||||
@include flex-center;
|
||||
margin-bottom: 32px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.empty-char {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 52px;
|
||||
font-weight: bold;
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: 700;
|
||||
color: $pri;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 32px;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 26px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-cap);
|
||||
color: var(--tk-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ─── 会话列表 ─── */
|
||||
.session-list {
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.session-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
margin-bottom: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&:active {
|
||||
@@ -99,7 +110,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
.session-main {
|
||||
.session-card-closed {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.session-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 18px;
|
||||
background: $pri-l;
|
||||
@include flex-center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-avatar-char {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 700;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
.session-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -108,56 +139,73 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.session-subject {
|
||||
font-size: 28px;
|
||||
color: $tx;
|
||||
font-size: var(--tk-font-cap);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.session-tag {
|
||||
&.tag-ok { @include tag($acc-l, $acc); }
|
||||
&.tag-warn { @include tag($wrn-l, $wrn); }
|
||||
&.tag-default { @include tag($bd-l, $tx2); }
|
||||
}
|
||||
|
||||
.session-message {
|
||||
font-size: 26px;
|
||||
color: $tx2;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 8px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.session-time {
|
||||
font-size: 22px;
|
||||
color: $tx3;
|
||||
display: block;
|
||||
font-size: var(--tk-font-micro);
|
||||
color: var(--tk-text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-tag {
|
||||
font-size: var(--tk-font-micro);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
display: inline-block;
|
||||
|
||||
&.tag-ok { background: $acc-l; color: $acc; }
|
||||
&.tag-warn { background: $wrn-l; color: $wrn; }
|
||||
&.tag-default { background: $surface-alt; color: $tx3; }
|
||||
}
|
||||
|
||||
.session-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.session-message-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.session-message {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* ─── 未读角标 ─── */
|
||||
.session-badge {
|
||||
background: $dan;
|
||||
border-radius: $r-pill;
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
@include flex-center;
|
||||
padding: 0 10px;
|
||||
margin-left: 12px;
|
||||
padding: 0 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-badge-text {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-micro);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
|
||||
import Taro, { useDidShow, usePullDownRefresh, useReachBottom } from '@tarojs/taro';
|
||||
import { listConsultations, ConsultationSession } from '@/services/consultation';
|
||||
import Loading from '../../components/Loading';
|
||||
import { useElderClass } from '../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
function getStatusTag(status: string) {
|
||||
@@ -33,99 +34,137 @@ export default function Consultation() {
|
||||
const [sessions, setSessions] = useState<ConsultationSession[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const modeClass = useElderClass();
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
const loadSessions = async () => {
|
||||
setLoading(true);
|
||||
const loadSessions = async (pageNum: number, isRefresh = false) => {
|
||||
if (loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
if (isRefresh) setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const resp = await listConsultations({ page: 1, page_size: 20 });
|
||||
setSessions(resp.data || []);
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '加载失败';
|
||||
setError(msg);
|
||||
const resp = await listConsultations({ page: pageNum, page_size: 20 });
|
||||
const list = resp.data || [];
|
||||
if (isRefresh) {
|
||||
setSessions(list);
|
||||
} else {
|
||||
setSessions((prev) => [...prev, ...list]);
|
||||
}
|
||||
setTotal(resp.total || 0);
|
||||
setPage(pageNum);
|
||||
} catch {
|
||||
if (isRefresh) {
|
||||
setSessions([]);
|
||||
setTotal(0);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
loadingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
useDidShow(() => {
|
||||
Taro.setNavigationBarTitle({ title: '在线咨询' });
|
||||
loadSessions();
|
||||
loadSessions(1, true);
|
||||
});
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
loadSessions().finally(() => {
|
||||
loadSessions(1, true).finally(() => {
|
||||
Taro.stopPullDownRefresh();
|
||||
});
|
||||
});
|
||||
|
||||
useReachBottom(() => {
|
||||
if (!loading && sessions.length < total) {
|
||||
loadSessions(page + 1);
|
||||
}
|
||||
});
|
||||
|
||||
const handleTapSession = (session: ConsultationSession) => {
|
||||
Taro.navigateTo({ url: `/pages/consultation/detail/index?id=${session.id}` });
|
||||
};
|
||||
|
||||
return (
|
||||
<View className='consultation-page'>
|
||||
{/* 页头 */}
|
||||
<View className='consultation-header'>
|
||||
<Text className='consultation-title'>在线咨询</Text>
|
||||
<View className={`consultation-page ${modeClass}`}>
|
||||
<View className='consultation-body'>
|
||||
{/* 副标题 */}
|
||||
<Text className='consultation-subtitle'>随时随地,连接专业医生</Text>
|
||||
</View>
|
||||
|
||||
{/* 内容区 */}
|
||||
{loading ? (
|
||||
<View className='consultation-center'>
|
||||
<Loading text='加载中...' />
|
||||
{/* 发起咨询按钮 — 实心主色 */}
|
||||
<View
|
||||
className='consultation-create-btn'
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/consultation/create/index' })}
|
||||
>
|
||||
<Text className='consultation-create-btn-text'>发起咨询</Text>
|
||||
</View>
|
||||
) : error ? (
|
||||
<View className='consultation-center'>
|
||||
<Text className='consultation-error'>{error}</Text>
|
||||
</View>
|
||||
) : sessions.length === 0 ? (
|
||||
<View className='consultation-empty'>
|
||||
<View className='empty-icon'>
|
||||
<Text className='empty-char'>问</Text>
|
||||
|
||||
{/* 内容区 */}
|
||||
{loading ? (
|
||||
<View className='consultation-center'>
|
||||
<Loading text='加载中...' />
|
||||
</View>
|
||||
<Text className='empty-title'>暂无咨询记录</Text>
|
||||
<Text className='empty-hint'>发起咨询后即可在这里与医生交流</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className='session-list'>
|
||||
{sessions.map((session) => {
|
||||
const tag = getStatusTag(session.status);
|
||||
return (
|
||||
<View
|
||||
key={session.id}
|
||||
className='session-card'
|
||||
onClick={() => handleTapSession(session)}
|
||||
>
|
||||
<View className='session-main'>
|
||||
<View className='session-top'>
|
||||
<Text className='session-subject'>
|
||||
{session.subject || '在线咨询'}
|
||||
</Text>
|
||||
<Text className={`session-tag ${tag.cls}`}>{tag.label}</Text>
|
||||
) : error ? (
|
||||
<View className='consultation-center'>
|
||||
<Text className='consultation-error'>{error}</Text>
|
||||
</View>
|
||||
) : sessions.length === 0 ? (
|
||||
<View className='consultation-empty'>
|
||||
<View className='empty-icon'>
|
||||
<Text className='empty-char'>问</Text>
|
||||
</View>
|
||||
<Text className='empty-title'>暂无咨询记录</Text>
|
||||
<Text className='empty-hint'>发起咨询后即可在这里与医生交流</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className='session-list'>
|
||||
{sessions.map((session) => {
|
||||
const tag = getStatusTag(session.status);
|
||||
const initial = (session.subject || '咨').charAt(0);
|
||||
const isClosed = session.status === 'closed' || session.status === 'cancelled';
|
||||
return (
|
||||
<View
|
||||
key={session.id}
|
||||
className={`session-card ${isClosed ? 'session-card-closed' : ''}`}
|
||||
onClick={() => handleTapSession(session)}
|
||||
>
|
||||
<View className='session-avatar'>
|
||||
<Text className='session-avatar-char'>{initial}</Text>
|
||||
</View>
|
||||
<View className='session-body'>
|
||||
<View className='session-top'>
|
||||
<Text className='session-subject'>
|
||||
{session.subject || '在线咨询'}
|
||||
</Text>
|
||||
<Text className='session-time'>
|
||||
{session.last_message_at
|
||||
? formatTime(session.last_message_at)
|
||||
: formatTime(session.created_at)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='session-meta'>
|
||||
<Text className={`session-tag ${tag.cls}`}>{tag.label}</Text>
|
||||
</View>
|
||||
<View className='session-message-row'>
|
||||
<Text className='session-message'>
|
||||
{session.last_message || '暂无消息'}
|
||||
</Text>
|
||||
{session.unread_count_patient > 0 && (
|
||||
<View className='session-badge'>
|
||||
<Text className='session-badge-text'>
|
||||
{session.unread_count_patient > 99 ? '99+' : session.unread_count_patient}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<Text className='session-message'>
|
||||
{session.last_message || '暂无消息'}
|
||||
</Text>
|
||||
<Text className='session-time'>
|
||||
{session.last_message_at
|
||||
? formatTime(session.last_message_at)
|
||||
: formatTime(session.created_at)}
|
||||
</Text>
|
||||
</View>
|
||||
{session.unread_count_patient > 0 && (
|
||||
<View className='session-badge'>
|
||||
<Text className='session-badge-text'>
|
||||
{session.unread_count_patient > 99 ? '99+' : session.unread_count_patient}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,5 @@
|
||||
@import '../../styles/variables.scss';
|
||||
|
||||
@mixin serif-number {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@mixin section-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
margin-bottom: 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@mixin tag($bg, $color) {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
background: $bg;
|
||||
color: $color;
|
||||
}
|
||||
|
||||
@mixin flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@import '../../styles/mixins.scss';
|
||||
|
||||
.device-sync-page {
|
||||
min-height: 100vh;
|
||||
@@ -43,9 +14,8 @@
|
||||
}
|
||||
|
||||
.sync-header-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 40px;
|
||||
font-weight: bold;
|
||||
@include section-title;
|
||||
color: $card;
|
||||
}
|
||||
|
||||
.sync-section {
|
||||
@@ -72,20 +42,17 @@
|
||||
margin-bottom: 20px;
|
||||
color: $pri;
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 36px;
|
||||
font-size: var(--tk-font-num-lg);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.sync-hero-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 34px;
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
@include section-title;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sync-hero-desc {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
@@ -110,7 +77,7 @@
|
||||
|
||||
.sync-action-text {
|
||||
color: $card;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -120,7 +87,7 @@
|
||||
|
||||
.sync-section-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
margin-bottom: 12px;
|
||||
@@ -144,19 +111,19 @@
|
||||
}
|
||||
|
||||
.sync-device-name {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 500;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.sync-device-adapter {
|
||||
font-size: 22px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-body);
|
||||
color: var(--tk-text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.sync-device-rssi {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
@@ -183,7 +150,7 @@
|
||||
}
|
||||
|
||||
.sync-status-text {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
@@ -208,12 +175,12 @@
|
||||
}
|
||||
|
||||
.sync-reading-type {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.sync-reading-value {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $pri;
|
||||
@include serif-number;
|
||||
@@ -222,8 +189,8 @@
|
||||
.sync-readings-count {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
font-size: 22px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-body);
|
||||
color: var(--tk-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -240,7 +207,7 @@
|
||||
}
|
||||
|
||||
.sync-error-text {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $dan;
|
||||
}
|
||||
|
||||
@@ -250,7 +217,7 @@
|
||||
}
|
||||
|
||||
.sync-loading-text {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
@@ -273,20 +240,17 @@
|
||||
@include flex-center;
|
||||
color: $acc;
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 36px;
|
||||
font-size: var(--tk-font-num-lg);
|
||||
font-weight: bold;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.sync-result-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 34px;
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
@include section-title;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sync-result-count {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useDidShow, useRouter } from '@tarojs/taro';
|
||||
import { BLEManager } from '@/services/ble/BLEManager';
|
||||
import { XiaomiBandAdapter } from '@/services/ble/adapters/XiaomiBandAdapter';
|
||||
import { BloodPressureAdapter } from '@/services/ble/adapters/BloodPressureAdapter';
|
||||
import { GlucoseMeterAdapter } from '@/services/ble/adapters/GlucoseMeterAdapter';
|
||||
import { CustomBandAdapter } from '@/services/ble/adapters/GenericBleAdapter';
|
||||
import { DataSyncScheduler } from '@/services/ble/DataSyncScheduler';
|
||||
import { uploadReadings } from '@/services/device-sync';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import type { BLEDevice, NormalizedReading } from '@/services/ble/types';
|
||||
import { useElderClass } from '../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
const bleManager = new BLEManager({ scanTimeout: 10000, retryCount: 3 });
|
||||
bleManager.registerAdapter(XiaomiBandAdapter);
|
||||
bleManager.registerAdapter(BloodPressureAdapter);
|
||||
bleManager.registerAdapter(GlucoseMeterAdapter);
|
||||
bleManager.registerAdapter(CustomBandAdapter);
|
||||
|
||||
type PageState = 'idle' | 'scanning' | 'connecting' | 'connected' | 'syncing' | 'done' | 'error';
|
||||
|
||||
export default function DeviceSync() {
|
||||
const modeClass = useElderClass();
|
||||
const { currentPatient } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const returnTo = router.params.returnTo || '';
|
||||
@@ -27,6 +32,12 @@ export default function DeviceSync() {
|
||||
const [liveReadings, setLiveReadings] = useState<NormalizedReading[]>([]);
|
||||
const [syncCount, setSyncCount] = useState(0);
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
const [lastSyncAt, setLastSyncAt] = useState<number | null>(null);
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
|
||||
const scheduler = useMemo(() => new DataSyncScheduler({
|
||||
intervalMs: 60 * 60 * 1000,
|
||||
}), []);
|
||||
|
||||
useDidShow(() => {
|
||||
bleManager.setOnConnectionChange(() => {});
|
||||
@@ -34,7 +45,29 @@ export default function DeviceSync() {
|
||||
setLiveReadings((prev) => [...prev, ...readings]);
|
||||
});
|
||||
|
||||
// 显示上次同步时间
|
||||
setLastSyncAt(scheduler.getLastSyncAt());
|
||||
|
||||
// 检查是否有未上传的缓冲数据
|
||||
const buffer = (bleManager as any).dataBuffer;
|
||||
if (buffer) {
|
||||
setPendingCount(buffer.size());
|
||||
}
|
||||
|
||||
// 自动同步:超过间隔时尝试上传缓冲数据
|
||||
if (currentPatient && scheduler.needsSync()) {
|
||||
scheduler.tryAutoSync(async () => {
|
||||
const count = await bleManager.flushPendingReadings(async (readings) => {
|
||||
return uploadReadings(currentPatient.id, 'buffered', undefined, readings);
|
||||
});
|
||||
setLastSyncAt(Date.now());
|
||||
setPendingCount(0);
|
||||
return { success: count > 0, uploadedCount: count };
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
scheduler.destroy();
|
||||
bleManager.destroy();
|
||||
};
|
||||
});
|
||||
@@ -87,6 +120,7 @@ export default function DeviceSync() {
|
||||
|
||||
if (result.success) {
|
||||
setSyncCount(result.uploadedCount);
|
||||
setLastSyncAt(Date.now());
|
||||
setPageState('done');
|
||||
|
||||
// 如果从体征录入页跳转而来,将最新读数写入 storage 供回填
|
||||
@@ -136,6 +170,21 @@ export default function DeviceSync() {
|
||||
<Text className="sync-hero-desc">连接智能手环、血压计、血糖仪,自动采集健康数据</Text>
|
||||
</View>
|
||||
|
||||
{(lastSyncAt || pendingCount > 0) && (
|
||||
<View className="sync-status-info">
|
||||
{lastSyncAt && (
|
||||
<Text className="sync-status-time">
|
||||
上次同步: {new Date(lastSyncAt).toLocaleTimeString()}
|
||||
</Text>
|
||||
)}
|
||||
{pendingCount > 0 && (
|
||||
<Text className="sync-status-pending">
|
||||
{pendingCount} 条数据待上传
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className="sync-action" onClick={handleScan}>
|
||||
<Text className="sync-action-text">扫描设备</Text>
|
||||
</View>
|
||||
@@ -224,7 +273,7 @@ export default function DeviceSync() {
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="device-sync-page">
|
||||
<View className={`device-sync-page ${modeClass}`}>
|
||||
<View className="sync-header">
|
||||
<Text className="sync-header-title">设备同步</Text>
|
||||
</View>
|
||||
|
||||
189
apps/miniprogram/src/pages/doctor/action-inbox/index.scss
Normal file
189
apps/miniprogram/src/pages/doctor/action-inbox/index.scss
Normal file
@@ -0,0 +1,189 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
.action-inbox-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.inbox-tabs {
|
||||
display: flex;
|
||||
background: $card;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid $bd;
|
||||
|
||||
.inbox-tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 12px 0;
|
||||
|
||||
&.active {
|
||||
.inbox-tab-text {
|
||||
color: $pri;
|
||||
font-weight: 600;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -12px;
|
||||
left: 30%;
|
||||
right: 30%;
|
||||
height: 3px;
|
||||
background: $pri;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.inbox-tab-text {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
}
|
||||
}
|
||||
|
||||
.inbox-list {
|
||||
height: calc(100vh - 50px);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.inbox-card {
|
||||
background: $card;
|
||||
border-radius: $r-sm;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 10px;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
.inbox-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.inbox-type-tag {
|
||||
color: $card;
|
||||
font-size: var(--tk-font-micro);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.inbox-card-title {
|
||||
font-size: var(--tk-font-cap);
|
||||
font-weight: 500;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.inbox-card-desc {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.inbox-empty {
|
||||
text-align: center;
|
||||
padding: 80px 0;
|
||||
|
||||
.inbox-empty-text {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.half-screen-dialog {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 70vh;
|
||||
background: $card;
|
||||
border-radius: $r-lg $r-lg 0 0;
|
||||
z-index: 1000;
|
||||
overflow-y: auto;
|
||||
box-shadow: $shadow-lg;
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
.dialog-title {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.dialog-close {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.dialog-patient {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.thread-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.thread-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-top: 4px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.completed { background: $acc; }
|
||||
&.in_progress { background: $wrn; }
|
||||
&.pending { background: $tx3; }
|
||||
&.dismissed { background: $dan; }
|
||||
}
|
||||
|
||||
.thread-content {
|
||||
.thread-label {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.thread-time {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px 20px 20px;
|
||||
border-top: 1px solid $bd-l;
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
border-radius: $r-sm;
|
||||
font-size: var(--tk-font-cap);
|
||||
font-weight: 500;
|
||||
|
||||
&.primary { background: $pri; color: $card; }
|
||||
&.danger { background: $dan; color: $card; }
|
||||
&.default { background: $surface-alt; color: $tx2; }
|
||||
}
|
||||
}
|
||||
}
|
||||
231
apps/miniprogram/src/pages/doctor/action-inbox/index.tsx
Normal file
231
apps/miniprogram/src/pages/doctor/action-inbox/index.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
|
||||
import {
|
||||
listActionItems,
|
||||
getActionThread,
|
||||
type ActionItem,
|
||||
type ThreadResponse,
|
||||
} from '@/services/action-inbox';
|
||||
import Loading from '@/components/Loading';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag';
|
||||
import './index.scss';
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
ai_suggestion: 'AI建议',
|
||||
alert: '告警',
|
||||
followup: '随访',
|
||||
data_anomaly: '异常',
|
||||
};
|
||||
|
||||
const TYPE_COLOR: Record<string, string> = {
|
||||
ai_suggestion: '#722ed1',
|
||||
alert: '#f5222d',
|
||||
followup: '#1890ff',
|
||||
data_anomaly: '#fa8c16',
|
||||
};
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'pending', label: '待处理' },
|
||||
{ key: 'in_progress', label: '进行中' },
|
||||
{ key: 'completed', label: '已完成' },
|
||||
];
|
||||
|
||||
export default function ActionInboxPage() {
|
||||
const modeClass = useElderClass();
|
||||
const [items, setItems] = useState<ActionItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [_page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('');
|
||||
const [threadData, setThreadData] = useState<ThreadResponse | null>(null);
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
const fetchItems = useCallback(
|
||||
async (pageNum: number, status: string, isRefresh = false) => {
|
||||
if (loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const resp = await listActionItems({
|
||||
page: pageNum,
|
||||
page_size: 20,
|
||||
status: status || undefined,
|
||||
});
|
||||
const list = resp.data || [];
|
||||
if (isRefresh) {
|
||||
setItems(list);
|
||||
} else {
|
||||
setItems((prev) => [...prev, ...list]);
|
||||
}
|
||||
setTotal(resp.total);
|
||||
setPage(pageNum);
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
loadingRef.current = false;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useDidShow(() => {
|
||||
Taro.setNavigationBarTitle({ title: '待办事项' });
|
||||
fetchItems(1, activeTab, true);
|
||||
});
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
fetchItems(1, activeTab, true).then(() =>
|
||||
Taro.stopPullDownRefresh(),
|
||||
);
|
||||
});
|
||||
|
||||
const handleTabChange = (key: string) => {
|
||||
setActiveTab(key);
|
||||
fetchItems(1, key, true);
|
||||
};
|
||||
|
||||
const handleItemClick = async (item: ActionItem) => {
|
||||
try {
|
||||
const data = await getActionThread(item.source_ref);
|
||||
setThreadData(data);
|
||||
setShowDetail(true);
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = async (action: {
|
||||
key: string;
|
||||
api_endpoint?: string;
|
||||
}) => {
|
||||
if (!action.api_endpoint || !threadData) return;
|
||||
try {
|
||||
await Taro.request({
|
||||
url: `${process.env.TARO_APP_API_URL}${action.api_endpoint}`,
|
||||
method: 'POST',
|
||||
header: { 'Content-Type': 'application/json' },
|
||||
data: { action: action.key },
|
||||
});
|
||||
Taro.showToast({ title: '操作成功', icon: 'success' });
|
||||
setShowDetail(false);
|
||||
fetchItems(1, activeTab, true);
|
||||
} catch {
|
||||
Taro.showToast({ title: '操作失败', icon: 'none' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View className={`action-inbox-page ${modeClass}`}>
|
||||
<View className="inbox-tabs">
|
||||
{STATUS_TABS.map((tab) => (
|
||||
<View
|
||||
key={tab.key}
|
||||
className={`inbox-tab ${activeTab === tab.key ? 'active' : ''}`}
|
||||
onClick={() => handleTabChange(tab.key)}
|
||||
>
|
||||
<Text
|
||||
className={`inbox-tab-text ${activeTab === tab.key ? 'active' : ''}`}
|
||||
>
|
||||
{tab.label}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{items.length === 0 && !loading ? (
|
||||
<View className="inbox-empty">
|
||||
<Text className="inbox-empty-text">暂无待办事项</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView scrollY className="inbox-list">
|
||||
{items.map((item) => (
|
||||
<View
|
||||
key={item.id}
|
||||
className="inbox-card"
|
||||
onClick={() => handleItemClick(item)}
|
||||
>
|
||||
<View className="inbox-card-header">
|
||||
<Text
|
||||
className="inbox-type-tag"
|
||||
style={{
|
||||
background: TYPE_COLOR[item.action_type] || '#999',
|
||||
}}
|
||||
>
|
||||
{TYPE_LABEL[item.action_type] || '未知'}
|
||||
</Text>
|
||||
<Text className="inbox-card-title">{item.title}</Text>
|
||||
</View>
|
||||
<Text className="inbox-card-desc">
|
||||
{item.patient_name} ·{' '}
|
||||
{new Date(item.created_at).toLocaleDateString('zh-CN')}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
{loading && <Loading />}
|
||||
{!loading && items.length >= total && total > 0 && (
|
||||
<Loading text="没有更多了" />
|
||||
)}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{showDetail && threadData && (
|
||||
<View className="half-screen-dialog">
|
||||
<View className="dialog-header">
|
||||
<Text className="dialog-title">
|
||||
{threadData.action_item.title}
|
||||
</Text>
|
||||
<Text
|
||||
className="dialog-close"
|
||||
onClick={() => setShowDetail(false)}
|
||||
>
|
||||
收起
|
||||
</Text>
|
||||
</View>
|
||||
<View className="dialog-body">
|
||||
<Text className="dialog-patient">
|
||||
{threadData.action_item.patient_name} ·{' '}
|
||||
{threadData.action_item.priority === 'urgent'
|
||||
? '紧急'
|
||||
: threadData.action_item.priority === 'high'
|
||||
? '高'
|
||||
: '中'}
|
||||
</Text>
|
||||
<View className="thread-timeline">
|
||||
{threadData.thread.map((evt, idx) => (
|
||||
<View key={idx} className="thread-item">
|
||||
<View className={`thread-dot ${evt.status}`} />
|
||||
<View className="thread-content">
|
||||
<Text className="thread-label">{evt.label}</Text>
|
||||
{evt.timestamp && (
|
||||
<Text className="thread-time">
|
||||
{new Date(evt.timestamp).toLocaleDateString('zh-CN')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
{threadData.available_actions.length > 0 && (
|
||||
<View className="dialog-actions">
|
||||
{threadData.available_actions.map((action) => (
|
||||
<View
|
||||
key={action.key}
|
||||
className={`action-btn ${action.variant}`}
|
||||
onClick={() => handleAction(action)}
|
||||
>
|
||||
{action.label}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -18,13 +18,13 @@
|
||||
}
|
||||
|
||||
&__time {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-severity {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
font-weight: 600;
|
||||
padding: 6px 16px;
|
||||
border-radius: $r-sm;
|
||||
@@ -51,7 +51,7 @@
|
||||
}
|
||||
|
||||
.detail-status {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
padding: 6px 16px;
|
||||
border-radius: $r-sm;
|
||||
|
||||
@@ -84,24 +84,24 @@
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&__label {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
word-break: break-all;
|
||||
|
||||
&--id {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
&--detail {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx2;
|
||||
font-family: monospace;
|
||||
line-height: 1.6;
|
||||
@@ -125,7 +125,7 @@
|
||||
flex: 1;
|
||||
height: 88px;
|
||||
line-height: 88px;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
border-radius: $r-lg;
|
||||
text-align: center;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { View, Text, ScrollView, Button } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import * as doctorApi from '@/services/doctor';
|
||||
import Loading from '@/components/Loading';
|
||||
import { useElderClass } from '../../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
const SEVERITY_MAP: Record<string, { label: string; className: string }> = {
|
||||
@@ -20,6 +21,7 @@ const STATUS_MAP: Record<string, { label: string; className: string }> = {
|
||||
};
|
||||
|
||||
export default function AlertDetail() {
|
||||
const modeClass = useElderClass();
|
||||
const [alert, setAlert] = useState<doctorApi.Alert | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
@@ -93,7 +95,7 @@ export default function AlertDetail() {
|
||||
if (loading) return <Loading />;
|
||||
if (!alert) {
|
||||
return (
|
||||
<View className='alert-detail-page'>
|
||||
<View className={`alert-detail-page ${modeClass}`}>
|
||||
<Text>告警不存在</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -105,7 +107,7 @@ export default function AlertDetail() {
|
||||
const isAcknowledged = alert.status === 'acknowledged';
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className='alert-detail-page'>
|
||||
<ScrollView scrollY className={`alert-detail-page ${modeClass}`}>
|
||||
{/* 顶部状态 */}
|
||||
<View className='alert-detail-header'>
|
||||
<View className='alert-detail-header__tags'>
|
||||
|
||||
@@ -16,13 +16,13 @@
|
||||
}
|
||||
|
||||
.alert-list-title {
|
||||
font-size: 36px;
|
||||
font-size: var(--tk-font-num-lg);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.alert-list-count {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
padding: 10px 24px;
|
||||
border-radius: $r-pill;
|
||||
background: $bd-l;
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
transition: all 0.2s;
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 500;
|
||||
color: $tx;
|
||||
margin-bottom: 8px;
|
||||
@@ -92,13 +92,13 @@
|
||||
}
|
||||
|
||||
&__time {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-severity {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
padding: 4px 12px;
|
||||
border-radius: $r-sm;
|
||||
@@ -125,7 +125,7 @@
|
||||
}
|
||||
|
||||
.alert-status {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
padding: 4px 12px;
|
||||
border-radius: $r-sm;
|
||||
|
||||
@@ -158,7 +158,7 @@
|
||||
margin-top: 32px;
|
||||
|
||||
&__btn {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $pri;
|
||||
padding: 12px 24px;
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
}
|
||||
|
||||
&__info {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import * as doctorApi from '@/services/doctor';
|
||||
import Loading from '@/components/Loading';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
const SEVERITY_MAP: Record<string, { label: string; className: string }> = {
|
||||
@@ -28,12 +29,15 @@ const STATUS_TABS = [
|
||||
];
|
||||
|
||||
export default function AlertList() {
|
||||
const modeClass = useElderClass();
|
||||
const [alerts, setAlerts] = useState<doctorApi.Alert[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState('');
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const totalPages = useMemo(() => Math.ceil(total / 20), [total]);
|
||||
|
||||
useEffect(() => {
|
||||
loadAlerts();
|
||||
}, [page, activeTab]);
|
||||
@@ -79,7 +83,7 @@ export default function AlertList() {
|
||||
if (loading && alerts.length === 0) return <Loading />;
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className='alert-list-page'>
|
||||
<ScrollView scrollY className={`alert-list-page ${modeClass}`}>
|
||||
<View className='alert-list-header'>
|
||||
<Text className='alert-list-title'>告警列表</Text>
|
||||
<Text className='alert-list-count'>共 {total} 条</Text>
|
||||
@@ -137,11 +141,11 @@ export default function AlertList() {
|
||||
上一页
|
||||
</Text>
|
||||
<Text className='alert-pagination__info'>
|
||||
{page} / {Math.ceil(total / 20)}
|
||||
{page} / {totalPages}
|
||||
</Text>
|
||||
<Text
|
||||
className={`alert-pagination__btn ${page >= Math.ceil(total / 20) ? 'disabled' : ''}`}
|
||||
onClick={() => page < Math.ceil(total / 20) && setPage(page + 1)}
|
||||
className={`alert-pagination__btn ${page >= totalPages ? 'disabled' : ''}`}
|
||||
onClick={() => page < totalPages && setPage(page + 1)}
|
||||
>
|
||||
下一页
|
||||
</Text>
|
||||
|
||||
@@ -18,13 +18,13 @@
|
||||
|
||||
&__title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 30px;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
&__close {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $dan;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
@@ -63,7 +63,7 @@
|
||||
}
|
||||
|
||||
.msg-text {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
display: block;
|
||||
line-height: 1.6;
|
||||
@@ -76,7 +76,7 @@
|
||||
|
||||
.msg-time {
|
||||
@include serif-number;
|
||||
font-size: 20px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
@@ -92,7 +92,7 @@
|
||||
padding: 120px 32px;
|
||||
|
||||
&__text {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
@@ -111,7 +111,7 @@
|
||||
background: $bd-l;
|
||||
border-radius: $r;
|
||||
padding: 16px 20px;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $card;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -139,7 +139,7 @@
|
||||
border-top: 1px solid $bd;
|
||||
|
||||
&__text {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,56 +3,47 @@ import { View, Text, Input, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import * as doctorApi from '@/services/doctor';
|
||||
import Loading from '@/components/Loading';
|
||||
import { useElderClass } from '../../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
const POLL_INTERVAL = 8000;
|
||||
|
||||
export default function ConsultationDetail() {
|
||||
const router = useRouter();
|
||||
const sessionId = router.params.id || '';
|
||||
const modeClass = useElderClass();
|
||||
const [session, setSession] = useState<doctorApi.ConsultationSession | null>(null);
|
||||
const [messages, setMessages] = useState<doctorApi.ConsultationMessage[]>([]);
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const scrollViewRef = useRef('');
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const pollingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionId) {
|
||||
loadData();
|
||||
markRead();
|
||||
startPolling();
|
||||
startLongPolling();
|
||||
}
|
||||
return () => stopPolling();
|
||||
return () => { pollingRef.current = false; };
|
||||
}, [sessionId]);
|
||||
|
||||
const startPolling = () => {
|
||||
stopPolling();
|
||||
pollTimerRef.current = setInterval(pollNewMessages, POLL_INTERVAL);
|
||||
useEffect(() => {
|
||||
if (session?.status === 'closed') {
|
||||
pollingRef.current = false;
|
||||
}
|
||||
}, [session?.status]);
|
||||
|
||||
const startLongPolling = () => {
|
||||
pollingRef.current = true;
|
||||
longPoll();
|
||||
};
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollTimerRef.current) {
|
||||
clearInterval(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const pollNewMessages = async () => {
|
||||
if (!session || session.status === 'closed') {
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
const longPoll = async () => {
|
||||
if (!pollingRef.current) return;
|
||||
try {
|
||||
const lastId = messages.length > 0 ? messages[messages.length - 1].id : undefined;
|
||||
const m = await doctorApi.listMessages(sessionId, {
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
after_id: lastId,
|
||||
});
|
||||
const newMsgs = m.data || [];
|
||||
if (newMsgs.length > 0) {
|
||||
const newMsgs = await doctorApi.pollMessages(sessionId, lastId);
|
||||
if (newMsgs && newMsgs.length > 0) {
|
||||
setMessages((prev) => {
|
||||
const existing = new Set(prev.map((msg) => msg.id));
|
||||
const fresh = newMsgs.filter((msg) => !existing.has(msg.id));
|
||||
@@ -60,7 +51,12 @@ export default function ConsultationDetail() {
|
||||
});
|
||||
scrollViewRef.current = `msg-${messages.length + newMsgs.length}`;
|
||||
}
|
||||
} catch { /* 轮询失败静默忽略 */ }
|
||||
} catch {
|
||||
// 超时或网络错误,静默重试
|
||||
}
|
||||
if (pollingRef.current) {
|
||||
longPoll();
|
||||
}
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
@@ -73,7 +69,7 @@ export default function ConsultationDetail() {
|
||||
setSession(s);
|
||||
setMessages(m.data || []);
|
||||
scrollViewRef.current = `msg-${(m.data || []).length}`;
|
||||
if (s.status === 'closed') stopPolling();
|
||||
if (s.status === 'closed') pollingRef.current = false;
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
@@ -132,7 +128,7 @@ export default function ConsultationDetail() {
|
||||
const isOpen = session?.status !== 'closed';
|
||||
|
||||
return (
|
||||
<View className='chat-page'>
|
||||
<View className={`chat-page ${modeClass}`}>
|
||||
{/* Header */}
|
||||
<View className='chat-header'>
|
||||
<Text className='chat-header__title'>{session?.subject || '在线咨询'}</Text>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx2;
|
||||
position: relative;
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
}
|
||||
|
||||
&__subject {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
flex: 1;
|
||||
@@ -80,7 +80,7 @@
|
||||
}
|
||||
|
||||
&__status-text {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -96,12 +96,12 @@
|
||||
}
|
||||
|
||||
&__time {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&__preview {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -123,7 +123,7 @@
|
||||
|
||||
&__badge-text {
|
||||
@include serif-number;
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $card;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -137,7 +137,7 @@
|
||||
padding: 24px;
|
||||
|
||||
&__btn {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $pri;
|
||||
padding: 12px 24px;
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
}
|
||||
|
||||
&__info {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import * as doctorApi from '@/services/doctor';
|
||||
import Loading from '@/components/Loading';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag';
|
||||
import { formatDateTime } from '@/utils/date';
|
||||
import './index.scss';
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
||||
waiting: { label: '等待中', color: '#f59e0b' },
|
||||
active: { label: '进行中', color: '#10b981' },
|
||||
closed: { label: '已关闭', color: '#94a3b8' },
|
||||
};
|
||||
|
||||
const TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'active', label: '进行中' },
|
||||
@@ -20,12 +17,15 @@ const TABS = [
|
||||
];
|
||||
|
||||
export default function ConsultationList() {
|
||||
const modeClass = useElderClass();
|
||||
const [sessions, setSessions] = useState<doctorApi.ConsultationSession[]>([]);
|
||||
const [activeTab, setActiveTab] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const totalPages = useMemo(() => Math.ceil(total / 20), [total]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
}, [page, activeTab]);
|
||||
@@ -54,18 +54,13 @@ export default function ConsultationList() {
|
||||
|
||||
const formatTime = (dateStr?: string | null) => {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
if (d.toDateString() === now.toDateString()) {
|
||||
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
return d.toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' });
|
||||
return formatDateTime(dateStr);
|
||||
};
|
||||
|
||||
if (loading && sessions.length === 0) return <Loading />;
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className='consultation-page'>
|
||||
<ScrollView scrollY className={`consultation-page ${modeClass}`}>
|
||||
<View className='tabs'>
|
||||
{TABS.map((t) => (
|
||||
<View
|
||||
@@ -83,7 +78,6 @@ export default function ConsultationList() {
|
||||
) : (
|
||||
<View className='session-list'>
|
||||
{sessions.map((s) => {
|
||||
const st = STATUS_MAP[s.status] || { label: s.status, color: '#94a3b8' };
|
||||
return (
|
||||
<View
|
||||
key={s.id}
|
||||
@@ -92,8 +86,8 @@ export default function ConsultationList() {
|
||||
>
|
||||
<View className='session-card__top'>
|
||||
<Text className='session-card__subject'>{s.subject || '在线咨询'}</Text>
|
||||
<View className='session-card__status' style={`background: ${st.color}20; color: ${st.color}`}>
|
||||
<Text className='session-card__status-text'>{st.label}</Text>
|
||||
<View className='session-card__status' style={getStatusInlineStyle(s.status)}>
|
||||
<Text className='session-card__status-text'>{getStatusLabel(s.status)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className='session-card__info'>
|
||||
@@ -122,10 +116,10 @@ export default function ConsultationList() {
|
||||
className={`pagination__btn ${page <= 1 ? 'disabled' : ''}`}
|
||||
onClick={() => page > 1 && setPage(page - 1)}
|
||||
>上一页</Text>
|
||||
<Text className='pagination__info'>{page} / {Math.ceil(total / 20)}</Text>
|
||||
<Text className='pagination__info'>{page} / {totalPages}</Text>
|
||||
<Text
|
||||
className={`pagination__btn ${page >= Math.ceil(total / 20) ? 'disabled' : ''}`}
|
||||
onClick={() => page < Math.ceil(total / 20) && setPage(page + 1)}
|
||||
className={`pagination__btn ${page >= totalPages ? 'disabled' : ''}`}
|
||||
onClick={() => page < totalPages && setPage(page + 1)}
|
||||
>下一页</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
98
apps/miniprogram/src/pages/doctor/dialysis/create/index.scss
Normal file
98
apps/miniprogram/src/pages/doctor/dialysis/create/index.scss
Normal file
@@ -0,0 +1,98 @@
|
||||
@import '../../../../styles/variables.scss';
|
||||
@import '../../../../styles/mixins.scss';
|
||||
|
||||
.create-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding: 24px;
|
||||
padding-bottom: 200px;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&--textarea {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
flex-shrink: 0;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.form-value {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
|
||||
&.placeholder {
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
min-height: 120px;
|
||||
background: $bg;
|
||||
border-radius: $r-sm;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
background: $pri;
|
||||
border-radius: $r-sm;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
|
||||
&:active {
|
||||
background: $pri-d;
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.submit-btn__text {
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
}
|
||||
246
apps/miniprogram/src/pages/doctor/dialysis/create/index.tsx
Normal file
246
apps/miniprogram/src/pages/doctor/dialysis/create/index.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { View, Text, Input, Textarea, Picker, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import * as doctorApi from '@/services/doctor';
|
||||
import Loading from '@/components/Loading';
|
||||
import { useElderClass } from '../../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
const DIALYSIS_TYPES = ['HD', 'HDF', 'HF'];
|
||||
|
||||
interface FormState {
|
||||
patient_id: string;
|
||||
dialysis_date: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
dialysis_type: string;
|
||||
dialysis_duration: string;
|
||||
blood_flow_rate: string;
|
||||
dry_weight: string;
|
||||
pre_weight: string;
|
||||
post_weight: string;
|
||||
pre_bp_systolic: string;
|
||||
pre_bp_diastolic: string;
|
||||
post_bp_systolic: string;
|
||||
post_bp_diastolic: string;
|
||||
pre_heart_rate: string;
|
||||
post_heart_rate: string;
|
||||
ultrafiltration_volume: string;
|
||||
complication_notes: string;
|
||||
}
|
||||
|
||||
const initialForm: FormState = {
|
||||
patient_id: '',
|
||||
dialysis_date: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
dialysis_type: 'HD',
|
||||
dialysis_duration: '',
|
||||
blood_flow_rate: '',
|
||||
dry_weight: '',
|
||||
pre_weight: '',
|
||||
post_weight: '',
|
||||
pre_bp_systolic: '',
|
||||
pre_bp_diastolic: '',
|
||||
post_bp_systolic: '',
|
||||
post_bp_diastolic: '',
|
||||
pre_heart_rate: '',
|
||||
post_heart_rate: '',
|
||||
ultrafiltration_volume: '',
|
||||
complication_notes: '',
|
||||
};
|
||||
|
||||
export default function DialysisCreate() {
|
||||
const router = useRouter();
|
||||
const id = router.params.id || '';
|
||||
const version = router.params.version ? Number(router.params.version) : 0;
|
||||
const patientIdFromRoute = router.params.patientId || '';
|
||||
const isEdit = !!id;
|
||||
const modeClass = useElderClass();
|
||||
|
||||
const [form, setForm] = useState<FormState>({ ...initialForm, patient_id: patientIdFromRoute });
|
||||
const [loading, setLoading] = useState(isEdit);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && id) loadRecord();
|
||||
}, [id]);
|
||||
|
||||
const loadRecord = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await doctorApi.getDialysisRecord(id);
|
||||
setForm({
|
||||
patient_id: r.patient_id,
|
||||
dialysis_date: r.dialysis_date || '',
|
||||
start_time: r.start_time || '',
|
||||
end_time: r.end_time || '',
|
||||
dialysis_type: r.dialysis_type || 'HD',
|
||||
dialysis_duration: r.dialysis_duration != null ? String(r.dialysis_duration) : '',
|
||||
blood_flow_rate: r.blood_flow_rate != null ? String(r.blood_flow_rate) : '',
|
||||
dry_weight: r.dry_weight != null ? String(r.dry_weight) : '',
|
||||
pre_weight: r.pre_weight != null ? String(r.pre_weight) : '',
|
||||
post_weight: r.post_weight != null ? String(r.post_weight) : '',
|
||||
pre_bp_systolic: r.pre_bp_systolic != null ? String(r.pre_bp_systolic) : '',
|
||||
pre_bp_diastolic: r.pre_bp_diastolic != null ? String(r.pre_bp_diastolic) : '',
|
||||
post_bp_systolic: r.post_bp_systolic != null ? String(r.post_bp_systolic) : '',
|
||||
post_bp_diastolic: r.post_bp_diastolic != null ? String(r.post_bp_diastolic) : '',
|
||||
pre_heart_rate: r.pre_heart_rate != null ? String(r.pre_heart_rate) : '',
|
||||
post_heart_rate: r.post_heart_rate != null ? String(r.post_heart_rate) : '',
|
||||
ultrafiltration_volume: r.ultrafiltration_volume != null ? String(r.ultrafiltration_volume) : '',
|
||||
complication_notes: r.complication_notes || '',
|
||||
});
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateField = (key: keyof FormState, value: string) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.dialysis_date) {
|
||||
Taro.showToast({ title: '请选择透析日期', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (!form.patient_id) {
|
||||
Taro.showToast({ title: '缺少患者信息', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
const num = (v: string) => v ? Number(v) : undefined;
|
||||
const payload = {
|
||||
patient_id: form.patient_id,
|
||||
dialysis_date: form.dialysis_date,
|
||||
start_time: form.start_time || undefined,
|
||||
end_time: form.end_time || undefined,
|
||||
dialysis_type: form.dialysis_type,
|
||||
dialysis_duration: num(form.dialysis_duration),
|
||||
blood_flow_rate: num(form.blood_flow_rate),
|
||||
dry_weight: num(form.dry_weight),
|
||||
pre_weight: num(form.pre_weight),
|
||||
post_weight: num(form.post_weight),
|
||||
pre_bp_systolic: num(form.pre_bp_systolic),
|
||||
pre_bp_diastolic: num(form.pre_bp_diastolic),
|
||||
post_bp_systolic: num(form.post_bp_systolic),
|
||||
post_bp_diastolic: num(form.post_bp_diastolic),
|
||||
pre_heart_rate: num(form.pre_heart_rate),
|
||||
post_heart_rate: num(form.post_heart_rate),
|
||||
ultrafiltration_volume: num(form.ultrafiltration_volume),
|
||||
complication_notes: form.complication_notes || undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
const { patient_id, ...updateData } = payload;
|
||||
await doctorApi.updateDialysisRecord(id, updateData, version);
|
||||
Taro.showToast({ title: '更新成功', icon: 'success' });
|
||||
} else {
|
||||
await doctorApi.createDialysisRecord(payload);
|
||||
Taro.showToast({ title: '创建成功', icon: 'success' });
|
||||
}
|
||||
setTimeout(() => Taro.navigateBack(), 1000);
|
||||
} catch {
|
||||
Taro.showToast({ title: isEdit ? '更新失败' : '创建失败', icon: 'none' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Loading />;
|
||||
|
||||
const InputField = ({ label, field, placeholder, type = 'digit' }: {
|
||||
label: string; field: keyof FormState; placeholder: string; type?: string;
|
||||
}) => (
|
||||
<View className='form-row'>
|
||||
<Text className='form-label'>{label}</Text>
|
||||
<Input
|
||||
className='form-input'
|
||||
type={type as any}
|
||||
placeholder={placeholder}
|
||||
value={form[field]}
|
||||
onInput={(e) => updateField(field, e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className={`create-page ${modeClass}`}>
|
||||
<View className='section'>
|
||||
<Text className='section-title'>基本信息</Text>
|
||||
<View className='form-row'>
|
||||
<Text className='form-label'>透析日期</Text>
|
||||
<Picker mode='date' value={form.dialysis_date} onChange={(e) => updateField('dialysis_date', e.detail.value)}>
|
||||
<Text className={`form-value ${!form.dialysis_date ? 'placeholder' : ''}`}>
|
||||
{form.dialysis_date || '请选择日期'}
|
||||
</Text>
|
||||
</Picker>
|
||||
</View>
|
||||
<View className='form-row'>
|
||||
<Text className='form-label'>开始时间</Text>
|
||||
<Picker mode='time' value={form.start_time} onChange={(e) => updateField('start_time', e.detail.value)}>
|
||||
<Text className={`form-value ${!form.start_time ? 'placeholder' : ''}`}>
|
||||
{form.start_time || '请选择时间'}
|
||||
</Text>
|
||||
</Picker>
|
||||
</View>
|
||||
<View className='form-row'>
|
||||
<Text className='form-label'>结束时间</Text>
|
||||
<Picker mode='time' value={form.end_time} onChange={(e) => updateField('end_time', e.detail.value)}>
|
||||
<Text className={`form-value ${!form.end_time ? 'placeholder' : ''}`}>
|
||||
{form.end_time || '请选择时间'}
|
||||
</Text>
|
||||
</Picker>
|
||||
</View>
|
||||
<View className='form-row'>
|
||||
<Text className='form-label'>透析类型</Text>
|
||||
<Picker mode='selector' range={DIALYSIS_TYPES} value={DIALYSIS_TYPES.indexOf(form.dialysis_type)} onChange={(e) => updateField('dialysis_type', DIALYSIS_TYPES[Number(e.detail.value)])}>
|
||||
<Text className='form-value'>{form.dialysis_type}</Text>
|
||||
</Picker>
|
||||
</View>
|
||||
<InputField label='透析时长' field='dialysis_duration' placeholder='分钟' type='number' />
|
||||
<InputField label='血流速' field='blood_flow_rate' placeholder='ml/min' type='number' />
|
||||
</View>
|
||||
|
||||
<View className='section'>
|
||||
<Text className='section-title'>体重</Text>
|
||||
<InputField label='干体重' field='dry_weight' placeholder='kg' />
|
||||
<InputField label='透前体重' field='pre_weight' placeholder='kg' />
|
||||
<InputField label='透后体重' field='post_weight' placeholder='kg' />
|
||||
</View>
|
||||
|
||||
<View className='section'>
|
||||
<Text className='section-title'>血压与心率</Text>
|
||||
<InputField label='透前收缩压' field='pre_bp_systolic' placeholder='mmHg' type='number' />
|
||||
<InputField label='透前舒张压' field='pre_bp_diastolic' placeholder='mmHg' type='number' />
|
||||
<InputField label='透后收缩压' field='post_bp_systolic' placeholder='mmHg' type='number' />
|
||||
<InputField label='透后舒张压' field='post_bp_diastolic' placeholder='mmHg' type='number' />
|
||||
<InputField label='透前心率' field='pre_heart_rate' placeholder='bpm' type='number' />
|
||||
<InputField label='透后心率' field='post_heart_rate' placeholder='bpm' type='number' />
|
||||
</View>
|
||||
|
||||
<View className='section'>
|
||||
<Text className='section-title'>超滤与备注</Text>
|
||||
<InputField label='超滤量' field='ultrafiltration_volume' placeholder='ml' type='number' />
|
||||
<View className='form-row form-row--textarea'>
|
||||
<Text className='form-label'>并发症备注</Text>
|
||||
<Textarea
|
||||
className='form-textarea'
|
||||
placeholder='请输入...'
|
||||
value={form.complication_notes}
|
||||
onInput={(e) => updateField('complication_notes', e.detail.value)}
|
||||
maxlength={500}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className={`submit-btn ${submitting ? 'submit-btn--disabled' : ''}`} onClick={handleSubmit}>
|
||||
<Text className='submit-btn__text'>{submitting ? '提交中...' : isEdit ? '更新记录' : '创建记录'}</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
156
apps/miniprogram/src/pages/doctor/dialysis/detail/index.scss
Normal file
156
apps/miniprogram/src/pages/doctor/dialysis/detail/index.scss
Normal file
@@ -0,0 +1,156 @@
|
||||
@import '../../../../styles/variables.scss';
|
||||
@import '../../../../styles/mixins.scss';
|
||||
|
||||
.dialysis-detail {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding: 24px;
|
||||
padding-bottom: 200px;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.record-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.record-header__title {
|
||||
font-size: var(--tk-font-num-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.record-header__status {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: var(--tk-font-body);
|
||||
background: $bd-l;
|
||||
color: $tx3;
|
||||
|
||||
&--completed {
|
||||
background: $pri-l;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
&--reviewed {
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
}
|
||||
}
|
||||
|
||||
.record-sub {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.review-info {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
text-align: right;
|
||||
flex: 1;
|
||||
margin-left: 24px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
text-align: center;
|
||||
padding: 120px 0;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
border-radius: $r-sm;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
|
||||
&--primary {
|
||||
background: $pri;
|
||||
|
||||
.action-btn__text {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: $pri-d;
|
||||
}
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
background: $card;
|
||||
border: 1px solid $bd;
|
||||
|
||||
.action-btn__text {
|
||||
color: $pri;
|
||||
}
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background: $card;
|
||||
border: 1px solid $dan;
|
||||
|
||||
.action-btn__text {
|
||||
color: $dan;
|
||||
}
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn__text {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 500;
|
||||
}
|
||||
174
apps/miniprogram/src/pages/doctor/dialysis/detail/index.tsx
Normal file
174
apps/miniprogram/src/pages/doctor/dialysis/detail/index.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import * as doctorApi from '@/services/doctor';
|
||||
import Loading from '@/components/Loading';
|
||||
import { useElderClass } from '../../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
export default function DialysisDetail() {
|
||||
const router = useRouter();
|
||||
const id = router.params.id || '';
|
||||
const modeClass = useElderClass();
|
||||
const [record, setRecord] = useState<doctorApi.DialysisRecord | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) loadRecord();
|
||||
}, [id]);
|
||||
|
||||
const loadRecord = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await doctorApi.getDialysisRecord(id);
|
||||
setRecord(r);
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReview = async () => {
|
||||
if (!record) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const updated = await doctorApi.reviewDialysisRecord(id, record.version);
|
||||
setRecord(updated);
|
||||
Taro.showToast({ title: '审核完成', icon: 'success' });
|
||||
} catch {
|
||||
Taro.showToast({ title: '审核失败', icon: 'none' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = async () => {
|
||||
if (!record) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const updated = await doctorApi.updateDialysisRecord(id, { status: 'completed' }, record.version);
|
||||
setRecord(updated);
|
||||
Taro.showToast({ title: '已标记完成', icon: 'success' });
|
||||
} catch {
|
||||
Taro.showToast({ title: '操作失败', icon: 'none' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!record) return;
|
||||
const { confirm } = await Taro.showModal({
|
||||
title: '确认删除',
|
||||
content: '删除后不可恢复,确定要删除这条记录吗?',
|
||||
});
|
||||
if (!confirm) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await doctorApi.deleteDialysisRecord(id, record.version);
|
||||
Taro.showToast({ title: '已删除', icon: 'success' });
|
||||
setTimeout(() => Taro.navigateBack(), 1000);
|
||||
} catch {
|
||||
Taro.showToast({ title: '删除失败', icon: 'none' });
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const Row = ({ label, value, unit }: { label: string; value?: string | number | null; unit?: string }) => {
|
||||
if (value == null) return null;
|
||||
return (
|
||||
<View className='detail-row'>
|
||||
<Text className='detail-label'>{label}</Text>
|
||||
<Text className='detail-value'>{value}{unit || ''}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) return <Loading />;
|
||||
if (!record) return <View className={`error-text ${modeClass}`}><Text>记录加载失败</Text></View>;
|
||||
|
||||
const canComplete = record.status === 'draft';
|
||||
const canReview = record.status === 'completed';
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className={`dialysis-detail ${modeClass}`}>
|
||||
{/* 状态头部 */}
|
||||
<View className='section'>
|
||||
<View className='record-header'>
|
||||
<Text className='record-header__title'>{record.dialysis_date}</Text>
|
||||
<Text className={`record-header__status record-header__status--${record.status}`}>
|
||||
{record.status === 'draft' ? '草稿' : record.status === 'completed' ? '已完成' : '已审核'}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className='record-sub'>
|
||||
{(record.dialysis_type === 'HD' ? '血液透析' : record.dialysis_type === 'HDF' ? '血液透析滤过' : record.dialysis_type === 'HF' ? '血液滤过' : record.dialysis_type)}
|
||||
</Text>
|
||||
{record.reviewed_at && <Text className='review-info'>审核于 {record.reviewed_at}</Text>}
|
||||
</View>
|
||||
|
||||
{/* 基本信息 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>基本信息</Text>
|
||||
<Row label='透析日期' value={record.dialysis_date} />
|
||||
<Row label='开始时间' value={record.start_time} />
|
||||
<Row label='结束时间' value={record.end_time} />
|
||||
<Row label='透析时长' value={record.dialysis_duration} unit=' 分钟' />
|
||||
<Row label='血流速' value={record.blood_flow_rate} unit=' ml/min' />
|
||||
<Row label='超滤量' value={record.ultrafiltration_volume} unit=' ml' />
|
||||
</View>
|
||||
|
||||
{/* 体重与血压 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>体重与血压</Text>
|
||||
<Row label='干体重' value={record.dry_weight} unit=' kg' />
|
||||
<Row label='透前体重' value={record.pre_weight} unit=' kg' />
|
||||
<Row label='透后体重' value={record.post_weight} unit=' kg' />
|
||||
{record.pre_bp_systolic != null && record.pre_bp_diastolic != null && (
|
||||
<Row label='透前血压' value={`${record.pre_bp_systolic}/${record.pre_bp_diastolic}`} unit=' mmHg' />
|
||||
)}
|
||||
{record.post_bp_systolic != null && record.post_bp_diastolic != null && (
|
||||
<Row label='透后血压' value={`${record.post_bp_systolic}/${record.post_bp_diastolic}`} unit=' mmHg' />
|
||||
)}
|
||||
<Row label='透前心率' value={record.pre_heart_rate} unit=' bpm' />
|
||||
<Row label='透后心率' value={record.post_heart_rate} unit=' bpm' />
|
||||
</View>
|
||||
|
||||
{/* 症状与并发症 */}
|
||||
{(record.symptoms || record.complication_notes) && (
|
||||
<View className='section'>
|
||||
<Text className='section-title'>症状与并发症</Text>
|
||||
{record.symptoms && (
|
||||
<Row label='症状' value={JSON.stringify(record.symptoms)} />
|
||||
)}
|
||||
<Row label='并发症备注' value={record.complication_notes} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<View className='actions'>
|
||||
{canComplete && (
|
||||
<View className={`action-btn action-btn--primary ${submitting ? 'action-btn--disabled' : ''}`} onClick={handleComplete}>
|
||||
<Text className='action-btn__text'>{submitting ? '处理中...' : '标记完成'}</Text>
|
||||
</View>
|
||||
)}
|
||||
{canReview && (
|
||||
<View className={`action-btn action-btn--primary ${submitting ? 'action-btn--disabled' : ''}`} onClick={handleReview}>
|
||||
<Text className='action-btn__text'>{submitting ? '审核中...' : '确认审核'}</Text>
|
||||
</View>
|
||||
)}
|
||||
{record.status === 'draft' && (
|
||||
<View className='action-btn action-btn--secondary' onClick={() => Taro.navigateTo({
|
||||
url: `/pages/doctor/dialysis/create/index?id=${id}&version=${record.version}`,
|
||||
})}>
|
||||
<Text className='action-btn__text'>编辑</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className='action-btn action-btn--danger' onClick={handleDelete}>
|
||||
<Text className='action-btn__text'>删除</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
194
apps/miniprogram/src/pages/doctor/dialysis/index.scss
Normal file
194
apps/miniprogram/src/pages/doctor/dialysis/index.scss
Normal file
@@ -0,0 +1,194 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
.dialysis-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
padding: 16px 24px;
|
||||
background: $card;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
background: $bg;
|
||||
border-radius: $r-sm;
|
||||
padding: 16px 20px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
padding: 0 24px;
|
||||
background: $card;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
position: relative;
|
||||
|
||||
&--active {
|
||||
.tab-text {
|
||||
color: $pri;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 25%;
|
||||
right: 25%;
|
||||
height: 4px;
|
||||
background: $pri;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-text {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.record-list {
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.record-count {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
padding: 8px 0 16px;
|
||||
}
|
||||
|
||||
.record-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&:active {
|
||||
box-shadow: $shadow-md;
|
||||
}
|
||||
}
|
||||
|
||||
.record-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.type-tag {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
background: $pri-l;
|
||||
color: $pri-d;
|
||||
|
||||
&--hdf {
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
}
|
||||
|
||||
&--hf {
|
||||
background: $wrn-l;
|
||||
color: $wrn;
|
||||
}
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: var(--tk-font-body);
|
||||
background: $bd-l;
|
||||
color: $tx3;
|
||||
|
||||
&--completed {
|
||||
background: $pri-l;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
&--reviewed {
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
}
|
||||
}
|
||||
|
||||
.record-card__body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.record-card__date {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.record-card__meta {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
padding: 12px 24px;
|
||||
background: $card;
|
||||
border-radius: $r-sm;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $pri;
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 32px;
|
||||
bottom: 120px;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 48px;
|
||||
background: $pri;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: $shadow-md;
|
||||
z-index: 10;
|
||||
|
||||
&:active {
|
||||
background: $pri-d;
|
||||
}
|
||||
}
|
||||
|
||||
.fab-text {
|
||||
font-size: var(--tk-font-hero);
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
}
|
||||
171
apps/miniprogram/src/pages/doctor/dialysis/index.tsx
Normal file
171
apps/miniprogram/src/pages/doctor/dialysis/index.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { View, Text, Input, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import * as doctorApi from '@/services/doctor';
|
||||
import Loading from '@/components/Loading';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
const TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'draft', label: '草稿' },
|
||||
{ key: 'completed', label: '已完成' },
|
||||
{ key: 'reviewed', label: '已审核' },
|
||||
];
|
||||
|
||||
const TYPE_MAP: Record<string, string> = { HD: 'HD', HDF: 'HDF', HF: 'HF' };
|
||||
|
||||
export default function DialysisList() {
|
||||
const router = useRouter();
|
||||
const patientId = router.params.patientId || '';
|
||||
const modeClass = useElderClass();
|
||||
const [searchPatient, setSearchPatient] = useState('');
|
||||
const [currentPatientId, setCurrentPatientId] = useState(patientId);
|
||||
const [activeTab, setActiveTab] = useState('');
|
||||
const [records, setRecords] = useState<doctorApi.DialysisRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPatientId) loadRecords(1);
|
||||
}, [currentPatientId, activeTab]);
|
||||
|
||||
const loadRecords = async (p: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await doctorApi.listDialysisRecords(currentPatientId, { page: p, page_size: 20 });
|
||||
setRecords(res.data || []);
|
||||
setTotal(res.total || 0);
|
||||
setPage(p);
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchPatient.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await doctorApi.listPatients({ search: searchPatient.trim(), page: 1, page_size: 1 });
|
||||
if (res.data && res.data.length > 0) {
|
||||
setCurrentPatientId(res.data[0].id);
|
||||
Taro.setNavigationBarTitle({ title: res.data[0].name + '的透析记录' });
|
||||
} else {
|
||||
Taro.showToast({ title: '未找到患者', icon: 'none' });
|
||||
}
|
||||
} catch {
|
||||
Taro.showToast({ title: '搜索失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTab = (key: string) => {
|
||||
setActiveTab(key);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const filtered = activeTab ? records.filter((r) => r.status === activeTab) : records;
|
||||
|
||||
if (loading && records.length === 0) return <Loading />;
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className={`dialysis-page ${modeClass}`}>
|
||||
{!patientId && (
|
||||
<View className='search-bar'>
|
||||
<Input
|
||||
className='search-input'
|
||||
placeholder='搜索患者姓名'
|
||||
value={searchPatient}
|
||||
onInput={(e) => setSearchPatient(e.detail.value)}
|
||||
confirmType='search'
|
||||
onConfirm={handleSearch}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className='tabs'>
|
||||
{TABS.map((t) => (
|
||||
<View
|
||||
key={t.key}
|
||||
className={`tab ${activeTab === t.key ? 'tab--active' : ''}`}
|
||||
onClick={() => handleTab(t.key)}
|
||||
>
|
||||
<Text className='tab-text'>{t.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{!currentPatientId ? (
|
||||
<EmptyState text='请搜索并选择患者' />
|
||||
) : filtered.length === 0 ? (
|
||||
<EmptyState text='暂无透析记录' />
|
||||
) : (
|
||||
<View className='record-list'>
|
||||
<View className='record-count'><Text>共 {total} 条记录</Text></View>
|
||||
{filtered.map((r) => (
|
||||
<View
|
||||
key={r.id}
|
||||
className='record-card'
|
||||
onClick={() => Taro.navigateTo({
|
||||
url: `/pages/doctor/dialysis/detail/index?id=${r.id}`,
|
||||
})}
|
||||
>
|
||||
<View className='record-card__header'>
|
||||
<Text className={`type-tag type-tag--${(r.dialysis_type || 'hd').toLowerCase()}`}>
|
||||
{TYPE_MAP[r.dialysis_type] || r.dialysis_type}
|
||||
</Text>
|
||||
<Text className={`status-tag status-tag--${r.status}`}>
|
||||
{r.status === 'draft' ? '草稿' : r.status === 'completed' ? '已完成' : '已审核'}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='record-card__body'>
|
||||
<Text className='record-card__date'>{r.dialysis_date}</Text>
|
||||
{r.dialysis_duration != null && (
|
||||
<Text className='record-card__meta'>时长 {r.dialysis_duration}分钟</Text>
|
||||
)}
|
||||
{r.ultrafiltration_volume != null && (
|
||||
<Text className='record-card__meta'>超滤 {r.ultrafiltration_volume}ml</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
{total > 20 && (
|
||||
<View className='pagination'>
|
||||
<View
|
||||
className={`page-btn ${page <= 1 ? 'page-btn--disabled' : ''}`}
|
||||
onClick={() => page > 1 && loadRecords(page - 1)}
|
||||
>
|
||||
<Text>上一页</Text>
|
||||
</View>
|
||||
<Text className='page-info'>{page} / {Math.ceil(total / 20)}</Text>
|
||||
<View
|
||||
className={`page-btn ${page * 20 >= total ? 'page-btn--disabled' : ''}`}
|
||||
onClick={() => page * 20 < total && loadRecords(page + 1)}
|
||||
>
|
||||
<Text>下一页</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View
|
||||
className='fab'
|
||||
onClick={() => {
|
||||
if (!currentPatientId) {
|
||||
Taro.showToast({ title: '请先选择患者', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
Taro.navigateTo({ url: `/pages/doctor/dialysis/create/index?patientId=${currentPatientId}` });
|
||||
}}
|
||||
>
|
||||
<Text className='fab-text'>+</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -28,13 +28,13 @@
|
||||
|
||||
&__title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 30px;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
&__status {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
padding: 6px 16px;
|
||||
border-radius: $r;
|
||||
font-weight: 500;
|
||||
@@ -60,12 +60,12 @@
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -77,14 +77,14 @@
|
||||
border-radius: $r;
|
||||
|
||||
&__label {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx2;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -99,14 +99,14 @@
|
||||
}
|
||||
|
||||
&__date {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
@@ -121,7 +121,7 @@
|
||||
border-radius: $r;
|
||||
margin-bottom: 24px;
|
||||
color: $card;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
@@ -143,7 +143,7 @@
|
||||
background: $bd-l;
|
||||
border-radius: $r;
|
||||
padding: 16px 20px;
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
box-sizing: border-box;
|
||||
line-height: 1.6;
|
||||
@@ -154,7 +154,7 @@
|
||||
padding: 16px 20px;
|
||||
background: $bd-l;
|
||||
border-radius: $r;
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -171,7 +171,7 @@
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $card;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -181,5 +181,5 @@
|
||||
text-align: center;
|
||||
padding: 80px 32px;
|
||||
color: $tx3;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { View, Text, Textarea, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import * as doctorApi from '@/services/doctor';
|
||||
import Loading from '@/components/Loading';
|
||||
import { useElderClass } from '../../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
@@ -16,6 +17,7 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
export default function FollowUpDetail() {
|
||||
const router = useRouter();
|
||||
const taskId = router.params.id || '';
|
||||
const modeClass = useElderClass();
|
||||
const [task, setTask] = useState<doctorApi.FollowUpTask | null>(null);
|
||||
const [records, setRecords] = useState<doctorApi.FollowUpRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -87,12 +89,12 @@ export default function FollowUpDetail() {
|
||||
const formatDate = (d: string) => new Date(d).toLocaleDateString('zh-CN');
|
||||
|
||||
if (loading) return <Loading />;
|
||||
if (!task) return <View className='error-text'><Text>任务加载失败</Text></View>;
|
||||
if (!task) return <View className={`error-text ${modeClass}`}><Text>任务加载失败</Text></View>;
|
||||
|
||||
const canSubmit = task.status === 'in_progress' || task.status === 'pending' || task.status === 'overdue';
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className='followup-detail'>
|
||||
<ScrollView scrollY className={`followup-detail ${modeClass}`}>
|
||||
<View className='section'>
|
||||
<View className='task-header'>
|
||||
<Text className='task-header__title'>随访详情</Text>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
.tab {
|
||||
display: inline-block;
|
||||
padding: 24px 16px;
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
@@ -44,7 +44,7 @@
|
||||
padding: 20px 28px;
|
||||
|
||||
text {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
@@ -75,19 +75,19 @@
|
||||
|
||||
&__type {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
&__status {
|
||||
@include tag(transparent, $tx2);
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__patient {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
@@ -99,7 +99,7 @@
|
||||
}
|
||||
|
||||
&__date {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,16 +4,10 @@ import Taro, { useRouter } from '@tarojs/taro';
|
||||
import * as doctorApi from '@/services/doctor';
|
||||
import Loading from '@/components/Loading';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag';
|
||||
import './index.scss';
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
||||
pending: { label: '待处理', color: '#f59e0b' },
|
||||
in_progress: { label: '进行中', color: '#0891b2' },
|
||||
completed: { label: '已完成', color: '#10b981' },
|
||||
overdue: { label: '已逾期', color: '#ef4444' },
|
||||
cancelled: { label: '已取消', color: '#94a3b8' },
|
||||
};
|
||||
|
||||
const TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'pending', label: '待处理' },
|
||||
@@ -25,6 +19,7 @@ const TABS = [
|
||||
export default function FollowUpList() {
|
||||
const router = useRouter();
|
||||
const patientId = router.params.patientId || '';
|
||||
const modeClass = useElderClass();
|
||||
const [tasks, setTasks] = useState<doctorApi.FollowUpTask[]>([]);
|
||||
const [activeTab, setActiveTab] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -69,7 +64,7 @@ export default function FollowUpList() {
|
||||
if (loading && tasks.length === 0) return <Loading />;
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className='followup-page'>
|
||||
<ScrollView scrollY className={`followup-page ${modeClass}`}>
|
||||
<View className='tabs'>
|
||||
{TABS.map((t) => (
|
||||
<View
|
||||
@@ -91,7 +86,6 @@ export default function FollowUpList() {
|
||||
) : (
|
||||
<View className='task-list'>
|
||||
{tasks.map((task) => {
|
||||
const st = STATUS_MAP[task.status] || { label: task.status, color: '#94a3b8' };
|
||||
return (
|
||||
<View
|
||||
key={task.id}
|
||||
@@ -100,8 +94,8 @@ export default function FollowUpList() {
|
||||
>
|
||||
<View className='task-card__header'>
|
||||
<Text className='task-card__type'>{getTypeLabel(task.follow_up_type)}</Text>
|
||||
<View className='task-card__status' style={`background: ${st.color}20; color: ${st.color}`}>
|
||||
<Text>{st.label}</Text>
|
||||
<View className='task-card__status' style={getStatusInlineStyle(task.status)}>
|
||||
<Text>{getStatusLabel(task.status)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className='task-card__patient'>{task.patient_name || '未知患者'}</Text>
|
||||
|
||||
@@ -13,19 +13,18 @@
|
||||
|
||||
&__title {
|
||||
@include section-title;
|
||||
font-size: 40px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__greeting {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__date {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
@@ -34,34 +33,34 @@
|
||||
align-items: center;
|
||||
margin: 16px 24px;
|
||||
padding: 16px 20px;
|
||||
background: #FEF2F2;
|
||||
background: $dan-l;
|
||||
border-radius: $r;
|
||||
border-left: 4px solid #EF4444;
|
||||
border-left: 4px solid $dan;
|
||||
}
|
||||
|
||||
&__alert-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: #EF4444;
|
||||
background: $dan;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
line-height: 36px;
|
||||
font-weight: bold;
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__alert-text {
|
||||
flex: 1;
|
||||
font-size: 26px;
|
||||
color: #991B1B;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $dan;
|
||||
}
|
||||
|
||||
&__alert-link {
|
||||
font-size: 24px;
|
||||
color: #EF4444;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $dan;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -70,11 +69,11 @@
|
||||
}
|
||||
|
||||
&__search-input {
|
||||
background: #F1F5F9;
|
||||
background: $surface-alt;
|
||||
border-radius: $r;
|
||||
padding: 16px 20px;
|
||||
font-size: 26px;
|
||||
color: #94A3B8;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&__section {
|
||||
@@ -113,14 +112,14 @@
|
||||
background: $pri-l;
|
||||
color: $pri;
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__card-num {
|
||||
@include serif-number;
|
||||
font-size: 48px;
|
||||
font-size: var(--tk-font-hero);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
display: block;
|
||||
@@ -128,7 +127,7 @@
|
||||
}
|
||||
|
||||
&__card-label {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
@@ -145,7 +144,7 @@
|
||||
|
||||
&__logout {
|
||||
color: $dan;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-h2);
|
||||
padding: 16px 48px;
|
||||
display: inline-block;
|
||||
}
|
||||
@@ -172,7 +171,7 @@
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -192,14 +191,14 @@
|
||||
text-align: center;
|
||||
background: $dan;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 700;
|
||||
border-radius: $r-pill;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { View, Text, Input, ScrollView } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useElderClass } from '../../hooks/useElderClass';
|
||||
import * as doctorApi from '@/services/doctor';
|
||||
import Loading from '@/components/Loading';
|
||||
import './index.scss';
|
||||
@@ -11,33 +12,67 @@ interface CardConfig {
|
||||
label: string;
|
||||
initial: string;
|
||||
route: string;
|
||||
roles?: string[];
|
||||
}
|
||||
|
||||
const CARDS: CardConfig[] = [
|
||||
const ALL_CARDS: CardConfig[] = [
|
||||
{ key: 'total_patients', label: '我的患者', initial: '患', route: '/pages/doctor/patients/index' },
|
||||
{ key: 'unread_messages', label: '未读消息', initial: '消', route: '/pages/doctor/consultation/index' },
|
||||
{ key: 'pending_follow_ups', label: '待处理随访', initial: '随', route: '/pages/doctor/followup/index' },
|
||||
{ key: 'today_consultations', label: '今日咨询', initial: '诊', route: '/pages/doctor/consultation/index' },
|
||||
{ key: 'pending_follow_ups', label: '待处理随访', initial: '随', route: '/pages/doctor/followup/index', roles: ['doctor', 'nurse', 'health_manager'] },
|
||||
{ key: 'today_consultations', label: '今日咨询', initial: '诊', route: '/pages/doctor/consultation/index', roles: ['doctor', 'health_manager'] },
|
||||
];
|
||||
|
||||
const HEALTH_CARDS: CardConfig[] = [
|
||||
{ key: 'pending_lab_review', label: '待审化验', initial: '化', route: '/pages/doctor/report/index' },
|
||||
const ALL_HEALTH_CARDS: CardConfig[] = [
|
||||
{ key: 'pending_lab_review', label: '待审化验', initial: '化', route: '/pages/doctor/report/index', roles: ['doctor'] },
|
||||
{ key: 'today_appointments', label: '今日预约', initial: '约', route: '/pages/doctor/patients/index' },
|
||||
];
|
||||
|
||||
const QUICK_ACTIONS = [
|
||||
{ label: '化验审核', initial: '审', route: '/pages/doctor/report/index' },
|
||||
{ label: '患者查询', initial: '查', route: '/pages/doctor/patients/index' },
|
||||
{ label: '随访记录', initial: '随', route: '/pages/doctor/followup/index' },
|
||||
{ label: '告警中心', initial: '警', route: '/pages/doctor/alerts/index' },
|
||||
interface QuickAction {
|
||||
label: string;
|
||||
initial: string;
|
||||
route: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
const ALL_QUICK_ACTIONS: QuickAction[] = [
|
||||
{ label: '化验审核', initial: '审', route: '/pages/doctor/report/index', roles: ['doctor'] },
|
||||
{ label: '患者查询', initial: '查', route: '/pages/doctor/patients/index', roles: ['doctor', 'nurse', 'health_manager'] },
|
||||
{ label: '随访记录', initial: '随', route: '/pages/doctor/followup/index', roles: ['doctor', 'nurse', 'health_manager'] },
|
||||
{ label: '告警中心', initial: '警', route: '/pages/doctor/alerts/index', roles: ['doctor', 'nurse', 'health_manager'] },
|
||||
{ label: '透析管理', initial: '透', route: '/pages/doctor/dialysis/index', roles: ['doctor'] },
|
||||
{ label: '处方管理', initial: '方', route: '/pages/doctor/prescription/index', roles: ['doctor'] },
|
||||
{ label: '行动收件箱', initial: '行', route: '/pages/doctor/action-inbox/index', roles: ['doctor', 'nurse', 'health_manager'] },
|
||||
];
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
doctor: '医生',
|
||||
nurse: '护士',
|
||||
health_manager: '健康管理师',
|
||||
admin: '管理员',
|
||||
operator: '运营',
|
||||
};
|
||||
|
||||
export default function DoctorHome() {
|
||||
const { user, logout } = useAuthStore();
|
||||
const { user, logout, roles } = useAuthStore();
|
||||
const modeClass = useElderClass();
|
||||
const [dashboard, setDashboard] = useState<doctorApi.DoctorDashboard | null>(null);
|
||||
const [alertCount, setAlertCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const hasRole = (allowed: string[] | undefined) => {
|
||||
if (!allowed) return true;
|
||||
return roles.some((r) => r === 'admin' || allowed.includes(r));
|
||||
};
|
||||
|
||||
const cards = useMemo(() => ALL_CARDS.filter((c) => hasRole(c.roles)), [roles]);
|
||||
const healthCards = useMemo(() => ALL_HEALTH_CARDS.filter((c) => hasRole(c.roles)), [roles]);
|
||||
const quickActions = useMemo(() => ALL_QUICK_ACTIONS.filter((a) => hasRole(a.roles)), [roles]);
|
||||
|
||||
const roleLabel = useMemo(() => {
|
||||
const primary = roles.find((r) => r !== 'admin');
|
||||
return primary ? (ROLE_LABELS[primary] || primary) : '医护';
|
||||
}, [roles]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboard();
|
||||
}, []);
|
||||
@@ -72,11 +107,11 @@ export default function DoctorHome() {
|
||||
if (loading) return <Loading />;
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className='doctor-home'>
|
||||
<ScrollView scrollY className={`doctor-home ${modeClass}`}>
|
||||
<View className='doctor-home__header'>
|
||||
<Text className='doctor-home__title'>医护工作台</Text>
|
||||
<Text className='doctor-home__greeting'>
|
||||
{user?.display_name || user?.username || '医生'},您好
|
||||
{user?.display_name || user?.username || roleLabel},您好
|
||||
</Text>
|
||||
<Text className='doctor-home__date'>
|
||||
{new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' })}
|
||||
@@ -102,7 +137,7 @@ export default function DoctorHome() {
|
||||
<View className='doctor-home__section'>
|
||||
<Text className='doctor-home__section-title'>工作概览</Text>
|
||||
<View className='doctor-home__grid'>
|
||||
{CARDS.map((card) => (
|
||||
{cards.map((card) => (
|
||||
<View
|
||||
key={card.key}
|
||||
className='doctor-home__card'
|
||||
@@ -116,10 +151,10 @@ export default function DoctorHome() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='doctor-home__section'>
|
||||
{healthCards.length > 0 && (<View className='doctor-home__section'>
|
||||
<Text className='doctor-home__section-title'>健康审核</Text>
|
||||
<View className='doctor-home__grid'>
|
||||
{HEALTH_CARDS.map((card) => (
|
||||
{healthCards.map((card) => (
|
||||
<View
|
||||
key={card.key}
|
||||
className='doctor-home__card'
|
||||
@@ -131,12 +166,12 @@ export default function DoctorHome() {
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>)}
|
||||
|
||||
<View className='doctor-home__section'>
|
||||
<Text className='doctor-home__section-title'>快捷操作</Text>
|
||||
<View className='doctor-home__quick-actions'>
|
||||
{QUICK_ACTIONS.map((action) => (
|
||||
{quickActions.map((action) => (
|
||||
<View
|
||||
key={action.route}
|
||||
className='quick-action'
|
||||
|
||||
@@ -33,12 +33,12 @@
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -51,7 +51,7 @@
|
||||
}
|
||||
|
||||
.warning-label {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $wrn;
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
@@ -59,7 +59,7 @@
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $pri-d;
|
||||
}
|
||||
|
||||
@@ -68,14 +68,14 @@
|
||||
}
|
||||
|
||||
.info-block-label {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.info-block-text {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -96,7 +96,7 @@
|
||||
|
||||
.vital-value {
|
||||
@include serif-number;
|
||||
font-size: 36px;
|
||||
font-size: var(--tk-font-num-lg);
|
||||
font-weight: 700;
|
||||
color: $pri;
|
||||
display: block;
|
||||
@@ -104,7 +104,7 @@
|
||||
}
|
||||
|
||||
.vital-label {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
@@ -116,13 +116,13 @@
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
@include serif-number;
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
|
||||
@@ -151,18 +151,18 @@
|
||||
}
|
||||
|
||||
&__type {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: 500;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
&__date {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&__abnormal {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $dan;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -180,7 +180,7 @@
|
||||
border-radius: $r;
|
||||
background: $pri;
|
||||
color: $card;
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: 500;
|
||||
|
||||
&:active {
|
||||
@@ -196,5 +196,5 @@
|
||||
text-align: center;
|
||||
padding: 80px 32px;
|
||||
color: $tx3;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@ import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import * as doctorApi from '@/services/doctor';
|
||||
import Loading from '@/components/Loading';
|
||||
import { useElderClass } from '../../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
export default function PatientDetail() {
|
||||
const router = useRouter();
|
||||
const patientId = router.params.id || '';
|
||||
const modeClass = useElderClass();
|
||||
const [patient, setPatient] = useState<doctorApi.PatientDetail | null>(null);
|
||||
const [summary, setSummary] = useState<doctorApi.HealthSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -40,10 +42,10 @@ export default function PatientDetail() {
|
||||
};
|
||||
|
||||
if (loading) return <Loading />;
|
||||
if (!patient) return <View className='error-text'><Text>患者信息加载失败</Text></View>;
|
||||
if (!patient) return <View className={`error-text ${modeClass}`}><Text>患者信息加载失败</Text></View>;
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className='patient-detail'>
|
||||
<ScrollView scrollY className={`patient-detail ${modeClass}`}>
|
||||
{/* 基本信息 */}
|
||||
<View className='section'>
|
||||
<View className='section-header'>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px 24px;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
box-shadow: $shadow-sm;
|
||||
@@ -33,7 +33,7 @@
|
||||
padding: 10px 24px;
|
||||
border-radius: $r-pill;
|
||||
background: $bd-l;
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
margin-right: 16px;
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
margin-bottom: 16px;
|
||||
|
||||
text {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
@@ -75,14 +75,14 @@
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 30px;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@
|
||||
background: $pri-l;
|
||||
|
||||
&__text {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
margin-top: 32px;
|
||||
|
||||
&__btn {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $pri;
|
||||
padding: 12px 24px;
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
}
|
||||
|
||||
&__info {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { View, Text, Input, ScrollView } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import Taro, { usePullDownRefresh, useReachBottom } from '@tarojs/taro';
|
||||
import * as doctorApi from '@/services/doctor';
|
||||
import Loading from '@/components/Loading';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
export default function PatientList() {
|
||||
const modeClass = useElderClass();
|
||||
const [patients, setPatients] = useState<doctorApi.PatientItem[]>([]);
|
||||
const [tags, setTags] = useState<doctorApi.PatientTag[]>([]);
|
||||
const [activeTag, setActiveTag] = useState<string>('');
|
||||
@@ -14,14 +16,15 @@ export default function PatientList() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadTags();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadPatients();
|
||||
}, [page, activeTag]);
|
||||
loadPatients(1, true);
|
||||
}, [activeTag]);
|
||||
|
||||
const loadTags = async () => {
|
||||
try {
|
||||
@@ -30,32 +33,51 @@ export default function PatientList() {
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const loadPatients = async () => {
|
||||
setLoading(true);
|
||||
const loadPatients = async (pageNum: number, isRefresh = false) => {
|
||||
if (loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
if (isRefresh) setLoading(true);
|
||||
try {
|
||||
const res = await doctorApi.listPatients({
|
||||
page,
|
||||
page: pageNum,
|
||||
page_size: 20,
|
||||
search: search || undefined,
|
||||
tag_id: activeTag || undefined,
|
||||
});
|
||||
setPatients(res.data || []);
|
||||
const list = res.data || [];
|
||||
if (isRefresh) {
|
||||
setPatients(list);
|
||||
} else {
|
||||
setPatients((prev) => [...prev, ...list]);
|
||||
}
|
||||
setTotal(res.total || 0);
|
||||
setPage(pageNum);
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
loadingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
loadPatients(1, true).finally(() => {
|
||||
Taro.stopPullDownRefresh();
|
||||
});
|
||||
});
|
||||
|
||||
useReachBottom(() => {
|
||||
if (!loading && patients.length < total) {
|
||||
loadPatients(page + 1);
|
||||
}
|
||||
});
|
||||
|
||||
const handleSearch = () => {
|
||||
setPage(1);
|
||||
loadPatients();
|
||||
loadPatients(1, true);
|
||||
};
|
||||
|
||||
const handleTagFilter = (tagId: string) => {
|
||||
setActiveTag(tagId === activeTag ? '' : tagId);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const getGenderLabel = (gender?: string) => {
|
||||
@@ -78,7 +100,7 @@ export default function PatientList() {
|
||||
if (loading && patients.length === 0) return <Loading />;
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className='patient-list-page'>
|
||||
<ScrollView scrollY className={`patient-list-page ${modeClass}`}>
|
||||
<View className='search-bar'>
|
||||
<Input
|
||||
className='search-input'
|
||||
@@ -154,23 +176,12 @@ export default function PatientList() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{total > 20 && (
|
||||
<View className='pagination'>
|
||||
<Text
|
||||
className={`pagination__btn ${page <= 1 ? 'disabled' : ''}`}
|
||||
onClick={() => page > 1 && setPage(page - 1)}
|
||||
>
|
||||
上一页
|
||||
</Text>
|
||||
<Text className='pagination__info'>{page} / {Math.ceil(total / 20)}</Text>
|
||||
<Text
|
||||
className={`pagination__btn ${page >= Math.ceil(total / 20) ? 'disabled' : ''}`}
|
||||
onClick={() => page < Math.ceil(total / 20) && setPage(page + 1)}
|
||||
>
|
||||
下一页
|
||||
</Text>
|
||||
{!loading && patients.length >= total && total > 0 && (
|
||||
<View style={{ textAlign: 'center', padding: '20px' }}>
|
||||
<Text style={{ fontSize: '24px', color: '#78716C' }}>没有更多了</Text>
|
||||
</View>
|
||||
)}
|
||||
{loading && patients.length > 0 && <Loading />}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
@import '../../../../styles/variables.scss';
|
||||
@import '../../../../styles/mixins.scss';
|
||||
|
||||
.create-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding: 24px;
|
||||
padding-bottom: 200px;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
flex-shrink: 0;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.form-value {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
|
||||
&.placeholder {
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
min-height: 120px;
|
||||
background: $bg;
|
||||
border-radius: $r-sm;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
background: $pri;
|
||||
border-radius: $r-sm;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
|
||||
&:active {
|
||||
background: $pri-d;
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.submit-btn__text {
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
}
|
||||
198
apps/miniprogram/src/pages/doctor/prescription/create/index.tsx
Normal file
198
apps/miniprogram/src/pages/doctor/prescription/create/index.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import { useState } from 'react';
|
||||
import { View, Text, Input, Textarea, Picker, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import * as doctorApi from '@/services/doctor';
|
||||
import Loading from '@/components/Loading';
|
||||
import { useElderClass } from '../../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
interface FormState {
|
||||
dialyzer_model: string;
|
||||
membrane_area: string;
|
||||
dialysate_potassium: string;
|
||||
dialysate_calcium: string;
|
||||
dialysate_bicarbonate: string;
|
||||
anticoagulation_type: string;
|
||||
anticoagulation_dose: string;
|
||||
target_ultrafiltration_ml: string;
|
||||
target_dry_weight: string;
|
||||
blood_flow_rate: string;
|
||||
dialysate_flow_rate: string;
|
||||
frequency_per_week: string;
|
||||
duration_minutes: string;
|
||||
vascular_access_type: string;
|
||||
vascular_access_location: string;
|
||||
effective_from: string;
|
||||
effective_to: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
const initialForm: FormState = {
|
||||
dialyzer_model: '',
|
||||
membrane_area: '',
|
||||
dialysate_potassium: '',
|
||||
dialysate_calcium: '',
|
||||
dialysate_bicarbonate: '',
|
||||
anticoagulation_type: '',
|
||||
anticoagulation_dose: '',
|
||||
target_ultrafiltration_ml: '',
|
||||
target_dry_weight: '',
|
||||
blood_flow_rate: '',
|
||||
dialysate_flow_rate: '',
|
||||
frequency_per_week: '',
|
||||
duration_minutes: '',
|
||||
vascular_access_type: '',
|
||||
vascular_access_location: '',
|
||||
effective_from: '',
|
||||
effective_to: '',
|
||||
notes: '',
|
||||
};
|
||||
|
||||
export default function PrescriptionCreate() {
|
||||
const router = useRouter();
|
||||
const patientId = router.params.patientId || '';
|
||||
const modeClass = useElderClass();
|
||||
const [form, setForm] = useState<FormState>(initialForm);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const updateField = (key: keyof FormState, value: string) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!patientId) {
|
||||
Taro.showToast({ title: '缺少患者信息', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
const num = (v: string) => v ? Number(v) : undefined;
|
||||
const payload = {
|
||||
patient_id: patientId,
|
||||
dialyzer_model: form.dialyzer_model || undefined,
|
||||
membrane_area: num(form.membrane_area),
|
||||
dialysate_potassium: num(form.dialysate_potassium),
|
||||
dialysate_calcium: num(form.dialysate_calcium),
|
||||
dialysate_bicarbonate: num(form.dialysate_bicarbonate),
|
||||
anticoagulation_type: form.anticoagulation_type || undefined,
|
||||
anticoagulation_dose: form.anticoagulation_dose || undefined,
|
||||
target_ultrafiltration_ml: num(form.target_ultrafiltration_ml),
|
||||
target_dry_weight: num(form.target_dry_weight),
|
||||
blood_flow_rate: num(form.blood_flow_rate),
|
||||
dialysate_flow_rate: num(form.dialysate_flow_rate),
|
||||
frequency_per_week: num(form.frequency_per_week),
|
||||
duration_minutes: num(form.duration_minutes),
|
||||
vascular_access_type: form.vascular_access_type || undefined,
|
||||
vascular_access_location: form.vascular_access_location || undefined,
|
||||
effective_from: form.effective_from || undefined,
|
||||
effective_to: form.effective_to || undefined,
|
||||
notes: form.notes || undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
await doctorApi.createDialysisPrescription(payload);
|
||||
Taro.showToast({ title: '创建成功', icon: 'success' });
|
||||
setTimeout(() => Taro.navigateBack(), 1000);
|
||||
} catch {
|
||||
Taro.showToast({ title: '创建失败', icon: 'none' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const InputField = ({ label, field, placeholder, type = 'digit' }: {
|
||||
label: string; field: keyof FormState; placeholder: string; type?: string;
|
||||
}) => (
|
||||
<View className='form-row'>
|
||||
<Text className='form-label'>{label}</Text>
|
||||
<Input
|
||||
className='form-input'
|
||||
type={type as any}
|
||||
placeholder={placeholder}
|
||||
value={form[field]}
|
||||
onInput={(e) => updateField(field, e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className={`create-page ${modeClass}`}>
|
||||
{/* 透析器 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>透析器</Text>
|
||||
<InputField label='透析器型号' field='dialyzer_model' placeholder='请输入型号' type='text' />
|
||||
<InputField label='膜面积' field='membrane_area' placeholder='m²' />
|
||||
</View>
|
||||
|
||||
{/* 透析液 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>透析液配比</Text>
|
||||
<InputField label='钾浓度' field='dialysate_potassium' placeholder='mmol/L' />
|
||||
<InputField label='钙浓度' field='dialysate_calcium' placeholder='mmol/L' />
|
||||
<InputField label='碳酸氢盐' field='dialysate_bicarbonate' placeholder='mmol/L' />
|
||||
</View>
|
||||
|
||||
{/* 抗凝 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>抗凝方案</Text>
|
||||
<InputField label='抗凝类型' field='anticoagulation_type' placeholder='请输入' type='text' />
|
||||
<InputField label='抗凝剂量' field='anticoagulation_dose' placeholder='请输入' type='text' />
|
||||
</View>
|
||||
|
||||
{/* 参数 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>参数设置</Text>
|
||||
<InputField label='目标超滤量' field='target_ultrafiltration_ml' placeholder='ml' type='number' />
|
||||
<InputField label='目标干体重' field='target_dry_weight' placeholder='kg' />
|
||||
<InputField label='血流速' field='blood_flow_rate' placeholder='ml/min' type='number' />
|
||||
<InputField label='透析液流量' field='dialysate_flow_rate' placeholder='ml/min' type='number' />
|
||||
<InputField label='每周频次' field='frequency_per_week' placeholder='次/周' type='number' />
|
||||
<InputField label='每次时长' field='duration_minutes' placeholder='分钟' type='number' />
|
||||
</View>
|
||||
|
||||
{/* 血管通路 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>血管通路</Text>
|
||||
<InputField label='通路类型' field='vascular_access_type' placeholder='请输入' type='text' />
|
||||
<InputField label='通路位置' field='vascular_access_location' placeholder='请输入' type='text' />
|
||||
</View>
|
||||
|
||||
{/* 生效日期 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>生效日期</Text>
|
||||
<View className='form-row'>
|
||||
<Text className='form-label'>生效日期</Text>
|
||||
<Picker mode='date' value={form.effective_from} onChange={(e) => updateField('effective_from', e.detail.value)}>
|
||||
<Text className={`form-value ${!form.effective_from ? 'placeholder' : ''}`}>
|
||||
{form.effective_from || '请选择'}
|
||||
</Text>
|
||||
</Picker>
|
||||
</View>
|
||||
<View className='form-row'>
|
||||
<Text className='form-label'>失效日期</Text>
|
||||
<Picker mode='date' value={form.effective_to} onChange={(e) => updateField('effective_to', e.detail.value)}>
|
||||
<Text className={`form-value ${!form.effective_to ? 'placeholder' : ''}`}>
|
||||
{form.effective_to || '请选择'}
|
||||
</Text>
|
||||
</Picker>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 备注 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>备注</Text>
|
||||
<Textarea
|
||||
className='form-textarea'
|
||||
placeholder='请输入备注...'
|
||||
value={form.notes}
|
||||
onInput={(e) => updateField('notes', e.detail.value)}
|
||||
maxlength={500}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className={`submit-btn ${submitting ? 'submit-btn--disabled' : ''}`} onClick={handleSubmit}>
|
||||
<Text className='submit-btn__text'>{submitting ? '提交中...' : '创建处方'}</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
137
apps/miniprogram/src/pages/doctor/prescription/detail/index.scss
Normal file
137
apps/miniprogram/src/pages/doctor/prescription/detail/index.scss
Normal file
@@ -0,0 +1,137 @@
|
||||
@import '../../../../styles/variables.scss';
|
||||
@import '../../../../styles/mixins.scss';
|
||||
|
||||
.prescription-detail {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding: 24px;
|
||||
padding-bottom: 200px;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.rx-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.rx-header__title {
|
||||
font-size: var(--tk-font-num-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.rx-header__status {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: var(--tk-font-body);
|
||||
background: $bd-l;
|
||||
color: $tx3;
|
||||
|
||||
&--active {
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
}
|
||||
}
|
||||
|
||||
.rx-sub {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
display: block;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
text-align: right;
|
||||
flex: 1;
|
||||
margin-left: 24px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.notes-text {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
text-align: center;
|
||||
padding: 120px 0;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
border-radius: $r-sm;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
|
||||
&--secondary {
|
||||
background: $card;
|
||||
border: 1px solid $bd;
|
||||
|
||||
.action-btn__text {
|
||||
color: $pri;
|
||||
}
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background: $card;
|
||||
border: 1px solid $dan;
|
||||
|
||||
.action-btn__text {
|
||||
color: $dan;
|
||||
}
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn__text {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 500;
|
||||
}
|
||||
163
apps/miniprogram/src/pages/doctor/prescription/detail/index.tsx
Normal file
163
apps/miniprogram/src/pages/doctor/prescription/detail/index.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import * as doctorApi from '@/services/doctor';
|
||||
import Loading from '@/components/Loading';
|
||||
import { useElderClass } from '../../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
export default function PrescriptionDetail() {
|
||||
const router = useRouter();
|
||||
const id = router.params.id || '';
|
||||
const modeClass = useElderClass();
|
||||
const [rx, setRx] = useState<doctorApi.DialysisPrescription | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) loadRx();
|
||||
}, [id]);
|
||||
|
||||
const loadRx = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await doctorApi.getDialysisPrescription(id);
|
||||
setRx(data);
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeactivate = async () => {
|
||||
if (!rx) return;
|
||||
const { confirm } = await Taro.showModal({
|
||||
title: '确认停用',
|
||||
content: '停用后该处方将不再生效,确定停用吗?',
|
||||
});
|
||||
if (!confirm) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const updated = await doctorApi.updateDialysisPrescription(id, { status: 'inactive' }, rx.version);
|
||||
setRx(updated);
|
||||
Taro.showToast({ title: '已停用', icon: 'success' });
|
||||
} catch {
|
||||
Taro.showToast({ title: '操作失败', icon: 'none' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!rx) return;
|
||||
const { confirm } = await Taro.showModal({
|
||||
title: '确认删除',
|
||||
content: '删除后不可恢复,确定要删除这条处方吗?',
|
||||
});
|
||||
if (!confirm) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await doctorApi.deleteDialysisPrescription(id, rx.version);
|
||||
Taro.showToast({ title: '已删除', icon: 'success' });
|
||||
setTimeout(() => Taro.navigateBack(), 1000);
|
||||
} catch {
|
||||
Taro.showToast({ title: '删除失败', icon: 'none' });
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const Row = ({ label, value, unit }: { label: string; value?: string | number | null; unit?: string }) => {
|
||||
if (value == null) return null;
|
||||
return (
|
||||
<View className='detail-row'>
|
||||
<Text className='detail-label'>{label}</Text>
|
||||
<Text className='detail-value'>{value}{unit || ''}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) return <Loading />;
|
||||
if (!rx) return <View className={`error-text ${modeClass}`}><Text>处方加载失败</Text></View>;
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className={`prescription-detail ${modeClass}`}>
|
||||
{/* 状态头部 */}
|
||||
<View className='section'>
|
||||
<View className='rx-header'>
|
||||
<Text className='rx-header__title'>{rx.dialyzer_model || '透析处方'}</Text>
|
||||
<Text className={`rx-header__status rx-header__status--${rx.status}`}>
|
||||
{rx.status === 'active' ? '生效中' : rx.status === 'inactive' ? '已停用' : rx.status}
|
||||
</Text>
|
||||
</View>
|
||||
{(rx.effective_from || rx.effective_to) && (
|
||||
<Text className='rx-sub'>{rx.effective_from || '...'} ~ {rx.effective_to || '...'}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 基本参数 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>基本参数</Text>
|
||||
<Row label='透析器型号' value={rx.dialyzer_model} />
|
||||
<Row label='膜面积' value={rx.membrane_area != null ? `${rx.membrane_area}` : null} unit=' m²' />
|
||||
<Row label='血流速' value={rx.blood_flow_rate} unit=' ml/min' />
|
||||
<Row label='透析液流量' value={rx.dialysate_flow_rate} unit=' ml/min' />
|
||||
<Row label='频率' value={rx.frequency_per_week != null ? `${rx.frequency_per_week} 次/周` : null} />
|
||||
<Row label='每次时长' value={rx.duration_minutes} unit=' 分钟' />
|
||||
</View>
|
||||
|
||||
{/* 透析液配比 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>透析液配比</Text>
|
||||
<Row label='钾浓度' value={rx.dialysate_potassium} unit=' mmol/L' />
|
||||
<Row label='钙浓度' value={rx.dialysate_calcium} unit=' mmol/L' />
|
||||
<Row label='碳酸氢盐' value={rx.dialysate_bicarbonate} unit=' mmol/L' />
|
||||
</View>
|
||||
|
||||
{/* 抗凝方案 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>抗凝方案</Text>
|
||||
<Row label='抗凝类型' value={rx.anticoagulation_type} />
|
||||
<Row label='抗凝剂量' value={rx.anticoagulation_dose} />
|
||||
</View>
|
||||
|
||||
{/* 血管通路 */}
|
||||
{(rx.vascular_access_type || rx.vascular_access_location) && (
|
||||
<View className='section'>
|
||||
<Text className='section-title'>血管通路</Text>
|
||||
<Row label='通路类型' value={rx.vascular_access_type} />
|
||||
<Row label='通路位置' value={rx.vascular_access_location} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 超滤目标 */}
|
||||
{(rx.target_ultrafiltration_ml != null || rx.target_dry_weight != null) && (
|
||||
<View className='section'>
|
||||
<Text className='section-title'>超滤目标</Text>
|
||||
<Row label='目标超滤量' value={rx.target_ultrafiltration_ml} unit=' ml' />
|
||||
<Row label='目标干体重' value={rx.target_dry_weight} unit=' kg' />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 备注 */}
|
||||
{rx.notes && (
|
||||
<View className='section'>
|
||||
<Text className='section-title'>备注</Text>
|
||||
<Text className='notes-text'>{rx.notes}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<View className='actions'>
|
||||
{rx.status === 'active' && (
|
||||
<View className={`action-btn action-btn--secondary ${submitting ? 'action-btn--disabled' : ''}`} onClick={handleDeactivate}>
|
||||
<Text className='action-btn__text'>停用处方</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className='action-btn action-btn--danger' onClick={handleDelete}>
|
||||
<Text className='action-btn__text'>删除</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
176
apps/miniprogram/src/pages/doctor/prescription/index.scss
Normal file
176
apps/miniprogram/src/pages/doctor/prescription/index.scss
Normal file
@@ -0,0 +1,176 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
.prescription-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
padding: 16px 24px;
|
||||
background: $card;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
background: $bg;
|
||||
border-radius: $r-sm;
|
||||
padding: 16px 20px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
padding: 0 24px;
|
||||
background: $card;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
position: relative;
|
||||
|
||||
&--active {
|
||||
.tab-text {
|
||||
color: $pri;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 25%;
|
||||
right: 25%;
|
||||
height: 4px;
|
||||
background: $pri;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-text {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.prescription-list {
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.prescription-count {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
padding: 8px 0 16px;
|
||||
}
|
||||
|
||||
.prescription-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&:active {
|
||||
box-shadow: $shadow-md;
|
||||
}
|
||||
}
|
||||
|
||||
.prescription-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.prescription-card__model {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: var(--tk-font-body);
|
||||
background: $bd-l;
|
||||
color: $tx3;
|
||||
|
||||
&--active {
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
}
|
||||
}
|
||||
|
||||
.prescription-card__body {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.prescription-card__meta {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.prescription-card__date {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
display: block;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
padding: 12px 24px;
|
||||
background: $card;
|
||||
border-radius: $r-sm;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $pri;
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 32px;
|
||||
bottom: 120px;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 48px;
|
||||
background: $pri;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: $shadow-md;
|
||||
z-index: 10;
|
||||
|
||||
&:active {
|
||||
background: $pri-d;
|
||||
}
|
||||
}
|
||||
|
||||
.fab-text {
|
||||
font-size: var(--tk-font-hero);
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
}
|
||||
165
apps/miniprogram/src/pages/doctor/prescription/index.tsx
Normal file
165
apps/miniprogram/src/pages/doctor/prescription/index.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { View, Text, Input, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import * as doctorApi from '@/services/doctor';
|
||||
import Loading from '@/components/Loading';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
const TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'active', label: '生效中' },
|
||||
{ key: 'inactive', label: '已停用' },
|
||||
];
|
||||
|
||||
export default function PrescriptionList() {
|
||||
const router = useRouter();
|
||||
const patientId = router.params.patientId || '';
|
||||
const modeClass = useElderClass();
|
||||
const [searchPatient, setSearchPatient] = useState('');
|
||||
const [currentPatientId, setCurrentPatientId] = useState(patientId);
|
||||
const [activeTab, setActiveTab] = useState('');
|
||||
const [prescriptions, setPrescriptions] = useState<doctorApi.DialysisPrescription[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
loadData(1);
|
||||
}, [currentPatientId, activeTab]);
|
||||
|
||||
const loadData = async (p: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await doctorApi.listDialysisPrescriptions({
|
||||
patient_id: currentPatientId || undefined,
|
||||
status: activeTab || undefined,
|
||||
page: p,
|
||||
page_size: 20,
|
||||
});
|
||||
setPrescriptions(res.data || []);
|
||||
setTotal(res.total || 0);
|
||||
setPage(p);
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchPatient.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await doctorApi.listPatients({ search: searchPatient.trim(), page: 1, page_size: 1 });
|
||||
if (res.data && res.data.length > 0) {
|
||||
setCurrentPatientId(res.data[0].id);
|
||||
} else {
|
||||
Taro.showToast({ title: '未找到患者', icon: 'none' });
|
||||
}
|
||||
} catch {
|
||||
Taro.showToast({ title: '搜索失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && prescriptions.length === 0) return <Loading />;
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className={`prescription-page ${modeClass}`}>
|
||||
{!patientId && (
|
||||
<View className='search-bar'>
|
||||
<Input
|
||||
className='search-input'
|
||||
placeholder='搜索患者姓名'
|
||||
value={searchPatient}
|
||||
onInput={(e) => setSearchPatient(e.detail.value)}
|
||||
confirmType='search'
|
||||
onConfirm={handleSearch}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className='tabs'>
|
||||
{TABS.map((t) => (
|
||||
<View
|
||||
key={t.key}
|
||||
className={`tab ${activeTab === t.key ? 'tab--active' : ''}`}
|
||||
onClick={() => { setActiveTab(t.key); setPage(1); }}
|
||||
>
|
||||
<Text className='tab-text'>{t.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{prescriptions.length === 0 ? (
|
||||
<EmptyState text='暂无透析处方' />
|
||||
) : (
|
||||
<View className='prescription-list'>
|
||||
<View className='prescription-count'><Text>共 {total} 条处方</Text></View>
|
||||
{prescriptions.map((p) => (
|
||||
<View
|
||||
key={p.id}
|
||||
className='prescription-card'
|
||||
onClick={() => Taro.navigateTo({
|
||||
url: `/pages/doctor/prescription/detail/index?id=${p.id}`,
|
||||
})}
|
||||
>
|
||||
<View className='prescription-card__header'>
|
||||
<Text className='prescription-card__model'>{p.dialyzer_model || '透析处方'}</Text>
|
||||
<Text className={`status-tag status-tag--${p.status}`}>
|
||||
{p.status === 'active' ? '生效中' : p.status === 'inactive' ? '已停用' : p.status}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='prescription-card__body'>
|
||||
{p.frequency_per_week != null && (
|
||||
<Text className='prescription-card__meta'>{p.frequency_per_week}次/周</Text>
|
||||
)}
|
||||
{p.duration_minutes != null && (
|
||||
<Text className='prescription-card__meta'>每次{p.duration_minutes}分钟</Text>
|
||||
)}
|
||||
</View>
|
||||
{(p.effective_from || p.effective_to) && (
|
||||
<Text className='prescription-card__date'>
|
||||
{p.effective_from || '...'} ~ {p.effective_to || '...'}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
{total > 20 && (
|
||||
<View className='pagination'>
|
||||
<View
|
||||
className={`page-btn ${page <= 1 ? 'page-btn--disabled' : ''}`}
|
||||
onClick={() => page > 1 && loadData(page - 1)}
|
||||
>
|
||||
<Text>上一页</Text>
|
||||
</View>
|
||||
<Text className='page-info'>{page} / {Math.ceil(total / 20)}</Text>
|
||||
<View
|
||||
className={`page-btn ${page * 20 >= total ? 'page-btn--disabled' : ''}`}
|
||||
onClick={() => page * 20 < total && loadData(page + 1)}
|
||||
>
|
||||
<Text>下一页</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View
|
||||
className='fab'
|
||||
onClick={() => {
|
||||
if (!currentPatientId) {
|
||||
Taro.showToast({ title: '请先选择患者', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
Taro.navigateTo({ url: `/pages/doctor/prescription/create/index?patientId=${currentPatientId}` });
|
||||
}}
|
||||
>
|
||||
<Text className='fab-text'>+</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -28,13 +28,13 @@
|
||||
|
||||
&__type {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 32px;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
&__status {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
padding: 6px 16px;
|
||||
border-radius: $r;
|
||||
font-weight: 500;
|
||||
@@ -45,13 +45,13 @@
|
||||
}
|
||||
|
||||
.report-date {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.review-info {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $acc;
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
@@ -81,7 +81,7 @@
|
||||
}
|
||||
|
||||
.indicator-cell {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
|
||||
&--name {
|
||||
flex: 2;
|
||||
@@ -116,7 +116,7 @@
|
||||
}
|
||||
|
||||
.indicator-row--header & {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
font-weight: 400;
|
||||
}
|
||||
@@ -129,7 +129,7 @@
|
||||
}
|
||||
|
||||
.notes-text {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -140,7 +140,7 @@
|
||||
background: $bd-l;
|
||||
border-radius: $r;
|
||||
padding: 20px;
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
box-sizing: border-box;
|
||||
line-height: 1.6;
|
||||
@@ -158,7 +158,7 @@
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $card;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -168,5 +168,5 @@
|
||||
text-align: center;
|
||||
padding: 80px 32px;
|
||||
color: $tx3;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
}
|
||||
|
||||
@@ -3,12 +3,14 @@ import { View, Text, Textarea, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import * as doctorApi from '@/services/doctor';
|
||||
import Loading from '@/components/Loading';
|
||||
import { useElderClass } from '../../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
export default function ReportDetail() {
|
||||
const router = useRouter();
|
||||
const patientId = router.params.patientId || '';
|
||||
const reportId = router.params.id || '';
|
||||
const modeClass = useElderClass();
|
||||
const [report, setReport] = useState<doctorApi.LabReportDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [doctorNotes, setDoctorNotes] = useState('');
|
||||
@@ -51,10 +53,10 @@ export default function ReportDetail() {
|
||||
const formatDate = (d: string) => new Date(d).toLocaleDateString('zh-CN');
|
||||
|
||||
if (loading) return <Loading />;
|
||||
if (!report) return <View className='error-text'><Text>报告加载失败</Text></View>;
|
||||
if (!report) return <View className={`error-text ${modeClass}`}><Text>报告加载失败</Text></View>;
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className='report-detail'>
|
||||
<ScrollView scrollY className={`report-detail ${modeClass}`}>
|
||||
{/* 基本信息 */}
|
||||
<View className='section'>
|
||||
<View className='report-header'>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px 24px;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
box-shadow: $shadow-sm;
|
||||
@@ -26,7 +26,7 @@
|
||||
margin-bottom: 16px;
|
||||
|
||||
text {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
@@ -56,13 +56,13 @@
|
||||
|
||||
&__type {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
&__date {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
@@ -73,13 +73,13 @@
|
||||
}
|
||||
|
||||
&__abnormal {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $dan;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__normal {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $acc;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,13 @@ import Taro, { useRouter } from '@tarojs/taro';
|
||||
import * as doctorApi from '@/services/doctor';
|
||||
import Loading from '@/components/Loading';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
export default function ReportList() {
|
||||
const router = useRouter();
|
||||
const patientId = router.params.patientId || '';
|
||||
const modeClass = useElderClass();
|
||||
const [searchPatient, setSearchPatient] = useState('');
|
||||
const [currentPatientId, setCurrentPatientId] = useState(patientId);
|
||||
const [reports, setReports] = useState<doctorApi.LabReportItem[]>([]);
|
||||
@@ -55,7 +57,7 @@ export default function ReportList() {
|
||||
if (loading && reports.length === 0) return <Loading />;
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className='report-page'>
|
||||
<ScrollView scrollY className={`report-page ${modeClass}`}>
|
||||
{!patientId && (
|
||||
<View className='search-bar'>
|
||||
<Input
|
||||
|
||||
@@ -1,34 +1,5 @@
|
||||
@import '../../styles/variables.scss';
|
||||
|
||||
@mixin serif-number {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@mixin section-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
margin-bottom: 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@mixin tag($bg, $color) {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
background: $bg;
|
||||
color: $color;
|
||||
}
|
||||
|
||||
@mixin flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@import '../../styles/mixins.scss';
|
||||
|
||||
.events-page {
|
||||
min-height: 100vh;
|
||||
@@ -43,14 +14,14 @@
|
||||
|
||||
&__title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 40px;
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
@@ -77,7 +48,7 @@
|
||||
|
||||
&__status {
|
||||
@include tag($bd-l, $tx2);
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
}
|
||||
|
||||
&__status--published {
|
||||
@@ -97,7 +68,7 @@
|
||||
}
|
||||
|
||||
&__points {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $wrn;
|
||||
@include serif-number;
|
||||
@@ -105,7 +76,7 @@
|
||||
|
||||
&__title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 30px;
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
display: block;
|
||||
@@ -113,7 +84,7 @@
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
@@ -128,13 +99,13 @@
|
||||
}
|
||||
|
||||
&__date {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
&__location {
|
||||
font-size: 24px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: var(--tk-text-secondary);
|
||||
}
|
||||
|
||||
&__footer {
|
||||
@@ -146,8 +117,8 @@
|
||||
}
|
||||
|
||||
&__participants {
|
||||
font-size: 24px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: var(--tk-text-secondary);
|
||||
@include serif-number;
|
||||
}
|
||||
|
||||
@@ -161,7 +132,7 @@
|
||||
}
|
||||
|
||||
&-text {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $card;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import Taro from '@tarojs/taro';
|
||||
import * as pointsApi from '@/services/points';
|
||||
import Loading from '@/components/Loading';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { useElderClass } from '../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; className: string }> = {
|
||||
@@ -14,6 +15,7 @@ const STATUS_MAP: Record<string, { label: string; className: string }> = {
|
||||
};
|
||||
|
||||
export default function EventsPage() {
|
||||
const modeClass = useElderClass();
|
||||
const [events, setEvents] = useState<pointsApi.OfflineEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [registering, setRegistering] = useState<string | null>(null);
|
||||
@@ -59,7 +61,7 @@ export default function EventsPage() {
|
||||
if (loading) return <Loading />;
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className='events-page'>
|
||||
<ScrollView scrollY className={`events-page ${modeClass}`}>
|
||||
<View className='events-header'>
|
||||
<Text className='events-header__title'>线下活动</Text>
|
||||
<Text className='events-header__subtitle'>参加活动赢取积分</Text>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
.detail-title {
|
||||
@include section-title;
|
||||
font-size: 34px;
|
||||
font-size: var(--tk-font-num-lg);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@@ -34,12 +34,12 @@
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
|
||||
&.status-completed { color: $acc; }
|
||||
@@ -59,7 +59,7 @@
|
||||
}
|
||||
|
||||
.countdown-text {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $wrn;
|
||||
font-weight: bold;
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
}
|
||||
|
||||
.detail-desc-text {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -94,7 +94,7 @@
|
||||
.submit-textarea {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
background: $bg;
|
||||
border-radius: $r-sm;
|
||||
@@ -121,7 +121,7 @@
|
||||
}
|
||||
|
||||
.submit-btn-text {
|
||||
font-size: 30px;
|
||||
font-size: var(--tk-font-num);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -134,6 +134,6 @@
|
||||
|
||||
.loading-text,
|
||||
.empty-text {
|
||||
font-size: 28px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: var(--tk-text-secondary);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,11 @@ import { TEMPLATE_IDS } from '@/services/wechat-templates';
|
||||
import { trackEvent } from '@/services/analytics';
|
||||
import Loading from '../../../components/Loading';
|
||||
import ErrorState from '../../../components/ErrorState';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
export default function FollowUpDetail() {
|
||||
const modeClass = useElderClass();
|
||||
const router = useRouter();
|
||||
const id = router.params.id || '';
|
||||
|
||||
@@ -82,7 +84,7 @@ export default function FollowUpDetail() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View className='detail-page'>
|
||||
<View className={`detail-page ${modeClass}`}>
|
||||
<Loading />
|
||||
</View>
|
||||
);
|
||||
@@ -90,7 +92,7 @@ export default function FollowUpDetail() {
|
||||
|
||||
if (error || !task) {
|
||||
return (
|
||||
<View className='detail-page'>
|
||||
<View className={`detail-page ${modeClass}`}>
|
||||
<ErrorState text='任务不存在' />
|
||||
</View>
|
||||
);
|
||||
@@ -99,7 +101,7 @@ export default function FollowUpDetail() {
|
||||
const isCompleted = task.status === 'completed';
|
||||
|
||||
return (
|
||||
<View className='detail-page'>
|
||||
<View className={`detail-page ${modeClass}`}>
|
||||
<View className='detail-card'>
|
||||
<Text className='detail-title'>{task.follow_up_type}</Text>
|
||||
<View className='detail-row'>
|
||||
|
||||
@@ -4,266 +4,374 @@
|
||||
.health-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding-bottom: calc(120px + env(safe-area-inset-bottom));
|
||||
padding: 20px 24px 100px;
|
||||
padding-bottom: calc(100px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
/* ─── 页头 ─── */
|
||||
.health-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24px 32px 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.health-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.health-add-btn {
|
||||
background: $pri;
|
||||
padding: 10px 28px;
|
||||
border-radius: $r-sm;
|
||||
/* ─── 类型 Tab ─── */
|
||||
.vital-tabs {
|
||||
display: flex;
|
||||
padding: 0 0 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vital-tab {
|
||||
flex: 1;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
background: $surface-alt;
|
||||
@include flex-center;
|
||||
position: relative;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&.vital-tab-active {
|
||||
background: $pri;
|
||||
box-shadow: 0 2px 8px rgba(196, 98, 58, 0.25);
|
||||
|
||||
.vital-tab-text {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.vital-tab-text {
|
||||
font-size: var(--tk-font-cap);
|
||||
font-weight: 600;
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.vital-tab-dot {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: $wrn;
|
||||
}
|
||||
|
||||
/* ─── 录入区 ─── */
|
||||
.input-section {
|
||||
margin-bottom: 20px;
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px;
|
||||
box-shadow: $shadow-md;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: var(--tk-text-secondary);
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
height: 56px;
|
||||
background: $bg;
|
||||
border: 2px solid $bd;
|
||||
border-radius: 12px;
|
||||
padding: 0 16px;
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.input-ref {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: var(--tk-text-secondary);
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.input-label--secondary {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* ─── 血糖时段选择 ─── */
|
||||
.period-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.period-btn {
|
||||
flex: 1;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: $surface-alt;
|
||||
@include flex-center;
|
||||
|
||||
&.period-active {
|
||||
background: $pri;
|
||||
|
||||
.period-btn-text {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.health-add-text {
|
||||
font-size: 26px;
|
||||
color: #fff;
|
||||
.period-btn-text {
|
||||
font-size: var(--tk-font-cap);
|
||||
font-weight: 600;
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
/* ─── 快捷操作 ─── */
|
||||
.health-actions-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 16px 24px 24px;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-shadow: $shadow-sm;
|
||||
/* ─── 保存按钮 ─── */
|
||||
.save-btn {
|
||||
width: 100%;
|
||||
height: 52px;
|
||||
border-radius: 14px;
|
||||
background: $pri;
|
||||
@include flex-center;
|
||||
margin-top: 20px;
|
||||
box-shadow: 0 2px 8px rgba(196, 98, 58, 0.25);
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
.save-btn-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ─── 趋势图 ─── */
|
||||
.trend-section {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@include section-title;
|
||||
}
|
||||
|
||||
.trend-empty {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.trend-empty-text {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.trend-chart {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.trend-bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
height: 120px;
|
||||
background: $bg;
|
||||
border-radius: 12px;
|
||||
padding: 12px 8px;
|
||||
gap: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.trend-threshold-line {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
border-top: 1.5px dashed $wrn;
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.trend-threshold-label {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: -16px;
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $wrn;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.trend-bar-col {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.trend-bar {
|
||||
width: 28px;
|
||||
border-radius: 6px 6px 0 0;
|
||||
min-height: 8px;
|
||||
opacity: 0.8;
|
||||
|
||||
&.trend-bar-normal {
|
||||
background: $pri;
|
||||
}
|
||||
|
||||
&.trend-bar-warn {
|
||||
background: $wrn;
|
||||
}
|
||||
}
|
||||
|
||||
.trend-bar-label {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: var(--tk-text-secondary);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* ─── BLE 设备卡片 ─── */
|
||||
.device-section {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.device-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.device-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
background: $pri-l;
|
||||
@include flex-center;
|
||||
|
||||
&.icon-primary { background: $pri-l; }
|
||||
&.icon-accent { background: $acc-l; }
|
||||
&.icon-warn { background: $wrn-l; }
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-char {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: $pri;
|
||||
|
||||
.icon-accent & { color: $acc; }
|
||||
.icon-warn & { color: $wrn; }
|
||||
.device-icon-text {
|
||||
font-size: var(--tk-font-body);
|
||||
}
|
||||
|
||||
.action-label {
|
||||
font-size: 24px;
|
||||
.device-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.device-name {
|
||||
font-size: var(--tk-font-cap);
|
||||
font-weight: 500;
|
||||
color: $tx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.device-desc {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $acc;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.device-arrow {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: var(--tk-text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ─── 健康资讯入口 ─── */
|
||||
.article-entry {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.article-entry-text {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ─── 通用 section ─── */
|
||||
.health-section {
|
||||
margin: 0 24px 28px;
|
||||
}
|
||||
|
||||
/* ─── 体征概览 ─── */
|
||||
.vitals-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.vital-card {
|
||||
background: $card;
|
||||
/* ─── AI 建议卡片 ─── */
|
||||
.ai-suggestion-card {
|
||||
background: $acc-l;
|
||||
border-radius: $r;
|
||||
padding: 24px 20px;
|
||||
box-shadow: $shadow-sm;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
padding: 16px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: none;
|
||||
border-left: 4px solid $acc;
|
||||
}
|
||||
|
||||
.vital-label {
|
||||
font-size: 22px;
|
||||
color: $tx2;
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.vital-value {
|
||||
@include serif-number;
|
||||
font-size: 44px;
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.vital-bottom {
|
||||
.ai-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.vital-unit {
|
||||
font-size: 20px;
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.vital-tag {
|
||||
@include tag($acc-l, $acc);
|
||||
|
||||
&.tag-warn {
|
||||
@include tag($wrn-l, $wrn);
|
||||
}
|
||||
}
|
||||
|
||||
.vital-ref {
|
||||
font-size: 20px;
|
||||
color: $tx3;
|
||||
margin-top: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.vital-bar-track {
|
||||
height: 6px;
|
||||
background: $bd-l;
|
||||
border-radius: 3px;
|
||||
margin-top: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vital-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
|
||||
&.bar-green { background: $acc; }
|
||||
&.bar-orange { background: $wrn; }
|
||||
&.bar-red { background: $dan; }
|
||||
}
|
||||
|
||||
/* ─── 趋势入口 — 水平滚动卡片 ─── */
|
||||
.trend-scroll {
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.trend-card {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 200px;
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px 16px;
|
||||
margin-right: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
vertical-align: top;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.trend-card-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: $r;
|
||||
background: $pri-l;
|
||||
@include flex-center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.trend-card-char {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
.trend-card-label {
|
||||
font-size: 26px;
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.trend-card-arrow {
|
||||
font-size: 22px;
|
||||
color: $tx3;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ─── 最近监测 ─── */
|
||||
.record-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.record-date {
|
||||
font-size: 24px;
|
||||
color: $pri;
|
||||
.ai-card-title {
|
||||
font-size: var(--tk-font-cap);
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
color: $acc;
|
||||
}
|
||||
|
||||
.record-data {
|
||||
.ai-card-count {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $acc;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.ai-suggestion-item {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.record-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
.ai-risk-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.record-item-label {
|
||||
font-size: 22px;
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.record-item-value {
|
||||
@include serif-number;
|
||||
font-size: 26px;
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
.ai-suggestion-text {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@@ -1,235 +1,411 @@
|
||||
import { useState } from 'react';
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { View, Text, Input } from '@tarojs/components';
|
||||
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
|
||||
import { useHealthStore } from '../../stores/health';
|
||||
import { listDailyMonitoring, DailyMonitoring } from '../../services/health';
|
||||
import { usePointsStore } from '../../stores/points';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { trackEvent } from '../../services/analytics';
|
||||
import { useElderClass } from '../../hooks/useElderClass';
|
||||
import { inputVitalSign, getTrend, getHealthThresholds, findThreshold, DEFAULT_THRESHOLDS, type HealthThreshold } from '../../services/health';
|
||||
import { listPendingSuggestions, type AiSuggestionItem } from '../../services/ai-analysis';
|
||||
import Loading from '../../components/Loading';
|
||||
import GuestGuard from '../../components/GuestGuard';
|
||||
import './index.scss';
|
||||
|
||||
const QUICK_ACTIONS = [
|
||||
{ label: '日常上报', char: '日', bg: 'icon-primary' },
|
||||
{ label: '体征录入', char: '录', bg: 'icon-accent' },
|
||||
{ label: '查看趋势', char: '势', bg: 'icon-warn' },
|
||||
type VitalType = 'blood_pressure' | 'heart_rate' | 'blood_sugar' | 'weight';
|
||||
|
||||
const VITAL_TABS: { key: VitalType; label: string }[] = [
|
||||
{ key: 'blood_pressure', label: '血压' },
|
||||
{ key: 'heart_rate', label: '心率' },
|
||||
{ key: 'blood_sugar', label: '血糖' },
|
||||
{ key: 'weight', label: '体重' },
|
||||
];
|
||||
|
||||
const TREND_LINKS = [
|
||||
{ label: '血压趋势', indicator: 'blood_pressure_systolic', char: '压' },
|
||||
{ label: '心率趋势', indicator: 'heart_rate', char: '率' },
|
||||
{ label: '血糖趋势', indicator: 'blood_sugar_fasting', char: '糖' },
|
||||
];
|
||||
|
||||
function getStatusTag(status?: string) {
|
||||
if (status === 'high') return { label: '偏高', cls: 'tag-warn' };
|
||||
if (status === 'low') return { label: '偏低', cls: 'tag-warn' };
|
||||
if (status === 'normal') return { label: '正常', cls: 'tag-ok' };
|
||||
return null;
|
||||
/** 根据阈值列表构建参考范围文案 */
|
||||
function buildRefRange(t: HealthThreshold[]): Record<VitalType, string> {
|
||||
const bpSys = findThreshold(t, 'systolic_bp', 'high')?.threshold_value ?? 140;
|
||||
const bpDia = findThreshold(t, 'diastolic_bp', 'high')?.threshold_value ?? 90;
|
||||
const hrHigh = findThreshold(t, 'heart_rate', 'high')?.threshold_value ?? 100;
|
||||
const hrLow = findThreshold(t, 'heart_rate', 'low')?.threshold_value ?? 60;
|
||||
const bsFasting = findThreshold(t, 'blood_sugar_fasting', 'high')?.threshold_value ?? 6.1;
|
||||
const bsPp = findThreshold(t, 'blood_sugar_postprandial', 'high')?.threshold_value ?? 7.8;
|
||||
return {
|
||||
blood_pressure: `收缩压 90-${bpSys} / 舒张压 60-${bpDia} mmHg`,
|
||||
heart_rate: `${hrLow}-${hrHigh} bpm`,
|
||||
blood_sugar: `空腹 3.9-${bsFasting} / 餐后 <${bsPp} mmol/L`,
|
||||
weight: '根据 BMI 18.5-24 计算',
|
||||
};
|
||||
}
|
||||
|
||||
/** 根据 status 计算 sparkline bar 的颜色 */
|
||||
function getBarColor(status?: string): string {
|
||||
if (status === 'normal') return 'bar-green';
|
||||
if (status === 'high' || status === 'low') return 'bar-orange';
|
||||
return 'bar-green';
|
||||
}
|
||||
|
||||
/** 计算数值在参考范围中的位置百分比 (0-100) */
|
||||
function getBarPercent(value: number | undefined, ref?: string): number {
|
||||
if (!value || !ref) return 50;
|
||||
const match = ref.match(/([\d.]+)\s*[-–]\s*([\d.]+)/);
|
||||
if (!match) return 50;
|
||||
const low = parseFloat(match[1]);
|
||||
const high = parseFloat(match[2]);
|
||||
if (high <= low) return 50;
|
||||
// 将值映射到 0-100 范围,参考范围占据中间 70%(15%-85%)
|
||||
const range = high - low;
|
||||
const normalized = (value - low + range * 0.3) / (range * 1.6);
|
||||
return Math.max(5, Math.min(95, normalized * 100));
|
||||
interface TrendPoint {
|
||||
date: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export default function Health() {
|
||||
const { todaySummary, loading, refreshToday } = useHealthStore();
|
||||
const { checkinStatus, refresh: refreshPoints } = usePointsStore();
|
||||
const { currentPatient } = useAuthStore();
|
||||
const [recentRecords, setRecentRecords] = useState<DailyMonitoring[]>([]);
|
||||
const { todaySummary, loading, refreshToday, getTrend: fetchTrend } = useHealthStore();
|
||||
const { user, currentPatient } = useAuthStore();
|
||||
const modeClass = useElderClass();
|
||||
const [activeTab, setActiveTab] = useState<VitalType>('blood_pressure');
|
||||
const [systolic, setSystolic] = useState('');
|
||||
const [diastolic, setDiastolic] = useState('');
|
||||
const [heartRateVal, setHeartRateVal] = useState('');
|
||||
const [sugarVal, setSugarVal] = useState('');
|
||||
const [sugarPeriod, setSugarPeriod] = useState<'fasting' | 'postprandial'>('fasting');
|
||||
const [weightVal, setWeightVal] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
|
||||
const [trendLoading, setTrendLoading] = useState(false);
|
||||
const [aiSuggestions, setAiSuggestions] = useState<AiSuggestionItem[]>([]);
|
||||
const [thresholds, setThresholds] = useState<HealthThreshold[]>(DEFAULT_THRESHOLDS);
|
||||
|
||||
useDidShow(() => {
|
||||
if (!user) return;
|
||||
refreshToday();
|
||||
refreshPoints();
|
||||
loadRecentRecords();
|
||||
loadTrend(activeTab);
|
||||
loadAiSuggestions();
|
||||
getHealthThresholds().then((t) => { if (t.length > 0) setThresholds(t); });
|
||||
});
|
||||
|
||||
const loadRecentRecords = async () => {
|
||||
if (currentPatient) {
|
||||
try {
|
||||
const resp = await listDailyMonitoring(currentPatient.id, { page: 1, page_size: 3 });
|
||||
setRecentRecords(resp.data || []);
|
||||
} catch {
|
||||
// daily monitoring API 可能不可用
|
||||
usePullDownRefresh(() => {
|
||||
if (!user) return;
|
||||
Promise.all([refreshToday(true), loadTrend(activeTab), loadAiSuggestions()]).finally(() => {
|
||||
Taro.stopPullDownRefresh();
|
||||
});
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return <GuestGuard title='请先登录' desc='登录后即可记录和查看健康数据' />;
|
||||
}
|
||||
|
||||
const loadAiSuggestions = async () => {
|
||||
try {
|
||||
const items = await listPendingSuggestions();
|
||||
setAiSuggestions(items.slice(0, 3));
|
||||
} catch {
|
||||
// 静默
|
||||
}
|
||||
};
|
||||
|
||||
const loadTrend = async (type: VitalType) => {
|
||||
setTrendLoading(true);
|
||||
try {
|
||||
const indicatorMap: Record<VitalType, string> = {
|
||||
blood_pressure: 'systolic_bp_morning',
|
||||
heart_rate: 'heart_rate',
|
||||
blood_sugar: 'blood_sugar',
|
||||
weight: 'weight',
|
||||
};
|
||||
const points = await fetchTrend(indicatorMap[type], '7d');
|
||||
setTrendData(points);
|
||||
} catch {
|
||||
setTrendData([]);
|
||||
} finally {
|
||||
setTrendLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabChange = (tab: VitalType) => {
|
||||
setActiveTab(tab);
|
||||
loadTrend(tab);
|
||||
};
|
||||
|
||||
const getWarnStatus = (type: VitalType): string | null => {
|
||||
if (type === 'blood_pressure') {
|
||||
const sys = parseFloat(systolic);
|
||||
const dia = parseFloat(diastolic);
|
||||
const sysMax = findThreshold(thresholds, 'systolic_bp', 'high')?.threshold_value ?? 140;
|
||||
const diaMax = findThreshold(thresholds, 'diastolic_bp', 'high')?.threshold_value ?? 90;
|
||||
if (sys > sysMax || dia > diaMax) return '血压偏高,确认提交?';
|
||||
} else if (type === 'heart_rate') {
|
||||
const val = parseFloat(heartRateVal);
|
||||
const hrHigh = findThreshold(thresholds, 'heart_rate', 'high')?.threshold_value ?? 100;
|
||||
const hrLow = findThreshold(thresholds, 'heart_rate', 'low')?.threshold_value ?? 60;
|
||||
if (val > hrHigh || val < hrLow) return '心率异常,确认提交?';
|
||||
} else if (type === 'blood_sugar') {
|
||||
const val = parseFloat(sugarVal);
|
||||
if (sugarPeriod === 'fasting') {
|
||||
const bsMax = findThreshold(thresholds, 'blood_sugar_fasting', 'high')?.threshold_value ?? 6.1;
|
||||
if (val > bsMax) return '血糖偏高,确认提交?';
|
||||
} else {
|
||||
const bsMax = findThreshold(thresholds, 'blood_sugar_postprandial', 'high')?.threshold_value ?? 7.8;
|
||||
if (val > bsMax) return '血糖偏高,确认提交?';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const goToInput = () => {
|
||||
Taro.navigateTo({ url: '/pages/pkg-health/input/index' });
|
||||
};
|
||||
const refRanges = buildRefRange(thresholds);
|
||||
|
||||
const goToDailyMonitoring = () => {
|
||||
Taro.navigateTo({ url: '/pages/pkg-health/daily-monitoring/index' });
|
||||
};
|
||||
|
||||
const goToTrend = (indicator: string) => {
|
||||
Taro.navigateTo({ url: `/pages/pkg-health/trend/index?indicator=${indicator}` });
|
||||
};
|
||||
|
||||
const goToMall = () => {
|
||||
Taro.switchTab({ url: '/pages/mall/index' });
|
||||
};
|
||||
|
||||
const summary = todaySummary || {};
|
||||
const items = [
|
||||
{ label: '血压', value: summary.blood_pressure ? `${summary.blood_pressure.systolic}/${summary.blood_pressure.diastolic}` : '--/--', unit: 'mmHg', indicator: 'blood_pressure_systolic', status: summary.blood_pressure?.status, ref: summary.blood_pressure?.reference_range, numValue: summary.blood_pressure?.systolic },
|
||||
{ label: '心率', value: summary.heart_rate ? `${summary.heart_rate.value}` : '--', unit: 'bpm', indicator: 'heart_rate', status: summary.heart_rate?.status, ref: summary.heart_rate?.reference_range, numValue: summary.heart_rate?.value },
|
||||
{ label: '血糖', value: summary.blood_sugar ? `${summary.blood_sugar.value}` : '--', unit: 'mmol/L', indicator: 'blood_sugar_fasting', status: summary.blood_sugar?.status, ref: summary.blood_sugar?.reference_range, numValue: summary.blood_sugar?.value },
|
||||
{ label: '体重', value: summary.weight ? `${summary.weight.value}` : '--', unit: 'kg', indicator: 'weight', status: summary.weight?.status, ref: summary.weight?.reference_range, numValue: summary.weight?.value },
|
||||
];
|
||||
|
||||
const quickActions = [
|
||||
{ ...QUICK_ACTIONS[0], action: goToDailyMonitoring },
|
||||
{ ...QUICK_ACTIONS[1], action: goToInput },
|
||||
{ ...QUICK_ACTIONS[2], action: () => goToTrend('blood_pressure_systolic') },
|
||||
];
|
||||
|
||||
const trendLinks = TREND_LINKS;
|
||||
|
||||
const formatBp = (record: DailyMonitoring) => {
|
||||
const parts: string[] = [];
|
||||
if (record.morning_bp_systolic && record.morning_bp_diastolic) {
|
||||
parts.push(`晨 ${record.morning_bp_systolic}/${record.morning_bp_diastolic}`);
|
||||
const handleSave = async () => {
|
||||
const patientId = currentPatient?.id;
|
||||
if (!patientId) {
|
||||
Taro.showToast({ title: '请先登录', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (record.evening_bp_systolic && record.evening_bp_diastolic) {
|
||||
parts.push(`晚 ${record.evening_bp_systolic}/${record.evening_bp_diastolic}`);
|
||||
|
||||
const warnMsg = getWarnStatus(activeTab);
|
||||
if (warnMsg) {
|
||||
const { confirm } = await Taro.showModal({
|
||||
title: '异常提示',
|
||||
content: warnMsg,
|
||||
confirmText: '确认提交',
|
||||
cancelText: '再看看',
|
||||
});
|
||||
if (!confirm) return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
switch (activeTab) {
|
||||
case 'blood_pressure': {
|
||||
const sys = parseFloat(systolic);
|
||||
const dia = parseFloat(diastolic);
|
||||
if (!sys || !dia) { Taro.showToast({ title: '请填写完整', icon: 'none' }); return; }
|
||||
await inputVitalSign(patientId, {
|
||||
indicator_type: 'blood_pressure',
|
||||
value: sys,
|
||||
extra: { systolic: sys, diastolic: dia },
|
||||
});
|
||||
setSystolic('');
|
||||
setDiastolic('');
|
||||
break;
|
||||
}
|
||||
case 'heart_rate': {
|
||||
const val = parseFloat(heartRateVal);
|
||||
if (!val) { Taro.showToast({ title: '请填写心率', icon: 'none' }); return; }
|
||||
await inputVitalSign(patientId, { indicator_type: 'heart_rate', value: val });
|
||||
setHeartRateVal('');
|
||||
break;
|
||||
}
|
||||
case 'blood_sugar': {
|
||||
const val = parseFloat(sugarVal);
|
||||
if (!val) { Taro.showToast({ title: '请填写血糖值', icon: 'none' }); return; }
|
||||
const bsType = sugarPeriod === 'fasting' ? 'blood_sugar_fasting' : 'blood_sugar_postprandial';
|
||||
await inputVitalSign(patientId, { indicator_type: bsType, value: val });
|
||||
setSugarVal('');
|
||||
break;
|
||||
}
|
||||
case 'weight': {
|
||||
const val = parseFloat(weightVal);
|
||||
if (!val) { Taro.showToast({ title: '请填写体重', icon: 'none' }); return; }
|
||||
await inputVitalSign(patientId, { indicator_type: 'weight', value: val });
|
||||
setWeightVal('');
|
||||
break;
|
||||
}
|
||||
}
|
||||
Taro.showToast({ title: '保存成功', icon: 'success' });
|
||||
refreshToday(true);
|
||||
loadTrend(activeTab);
|
||||
} catch {
|
||||
Taro.showToast({ title: '保存失败', icon: 'none' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
return parts.length > 0 ? parts.join(' ') : '--';
|
||||
};
|
||||
|
||||
const maxTrendValue = Math.max(...trendData.map((d) => d.value), 1);
|
||||
|
||||
const getThresholdValue = (type: VitalType, th: HealthThreshold[]): number | null => {
|
||||
if (type === 'blood_pressure') return findThreshold(th, 'systolic_bp', 'high')?.threshold_value ?? 140;
|
||||
if (type === 'heart_rate') return findThreshold(th, 'heart_rate', 'high')?.threshold_value ?? 100;
|
||||
if (type === 'blood_sugar') return findThreshold(th, 'blood_sugar_fasting', 'high')?.threshold_value ?? 6.1;
|
||||
return null;
|
||||
};
|
||||
const dayLabels = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
|
||||
return (
|
||||
<View className='health-page'>
|
||||
<View className={`health-page ${modeClass}`}>
|
||||
{/* 页头 */}
|
||||
<View className='health-header'>
|
||||
<Text className='health-title'>健康数据</Text>
|
||||
<View className='health-add-btn' onClick={goToInput}>
|
||||
<Text className='health-add-text'>录入</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 快捷操作 + 打卡状态紧凑合并 */}
|
||||
<View className='health-actions-row'>
|
||||
{quickActions.map((a) => (
|
||||
<View className='action-item' key={a.label} onClick={a.action}>
|
||||
<View className={`action-icon ${a.bg}`}>
|
||||
<Text className='action-char'>{a.char}</Text>
|
||||
</View>
|
||||
<Text className='action-label'>{a.label}</Text>
|
||||
{/* AI 建议卡片 */}
|
||||
{aiSuggestions.length > 0 && (
|
||||
<View className='ai-suggestion-card' onClick={() => {
|
||||
const first = aiSuggestions[0];
|
||||
if (first?.suggestion_type === 'appointment') {
|
||||
Taro.navigateTo({ url: `/pages/appointment/create/index` });
|
||||
} else if (first?.suggestion_type === 'followup') {
|
||||
Taro.navigateTo({ url: '/pages/pkg-profile/followups/index' });
|
||||
} else {
|
||||
Taro.navigateTo({ url: '/pages/health/index' });
|
||||
}
|
||||
}}>
|
||||
<View className='ai-card-header'>
|
||||
<Text className='ai-card-title'>AI 健康建议</Text>
|
||||
<Text className='ai-card-count'>{aiSuggestions.length} 条待查看</Text>
|
||||
</View>
|
||||
))}
|
||||
{checkinStatus && (
|
||||
<View
|
||||
className='action-item checkin-badge'
|
||||
onClick={!checkinStatus.checked_in_today ? goToMall : undefined}
|
||||
>
|
||||
<View className={`action-icon ${checkinStatus.checked_in_today ? 'icon-accent' : 'icon-warn'}`}>
|
||||
<Text className='action-char'>卡</Text>
|
||||
</View>
|
||||
<Text className='action-label'>
|
||||
{checkinStatus.checked_in_today
|
||||
? (checkinStatus.consecutive_days > 0 ? `已打卡${checkinStatus.consecutive_days}天` : '已打卡')
|
||||
: '去打卡'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 今日体征概览 */}
|
||||
<View className='health-section'>
|
||||
<Text className='section-title'>今日体征</Text>
|
||||
{loading && !todaySummary ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<View className='vitals-grid'>
|
||||
{items.map((item) => {
|
||||
const tag = getStatusTag(item.status);
|
||||
const barColor = getBarColor(item.status);
|
||||
const barPercent = getBarPercent(item.numValue, item.ref);
|
||||
return (
|
||||
<View className='vital-card' key={item.label} onClick={() => goToTrend(item.indicator)}>
|
||||
<Text className='vital-label'>{item.label}</Text>
|
||||
<Text className='vital-value'>{item.value}</Text>
|
||||
<View className='vital-bottom'>
|
||||
<Text className='vital-unit'>{item.unit}</Text>
|
||||
{tag && <Text className={`vital-tag ${tag.cls}`}>{tag.label}</Text>}
|
||||
</View>
|
||||
{/* Sparkline bar */}
|
||||
{item.ref && item.numValue != null && (
|
||||
<View className='vital-bar-track'>
|
||||
<View className={`vital-bar-fill ${barColor}`} style={`width: ${barPercent}%`} />
|
||||
</View>
|
||||
)}
|
||||
{item.ref && <Text className='vital-ref'>参考 {item.ref}</Text>}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 趋势快捷入口 — 水平滚动卡片 */}
|
||||
<View className='health-section'>
|
||||
<Text className='section-title'>健康趋势</Text>
|
||||
<ScrollView className='trend-scroll' scrollX>
|
||||
{trendLinks.map((t) => (
|
||||
<View className='trend-card' key={t.label} onClick={() => goToTrend(t.indicator)}>
|
||||
<View className='trend-card-icon'>
|
||||
<Text className='trend-card-char'>{t.char}</Text>
|
||||
{aiSuggestions.map((s) => {
|
||||
const riskColor = s.risk_level === 'high' ? '#ef4444' : s.risk_level === 'medium' ? '#f59e0b' : '#22c55e';
|
||||
const typeLabel = s.suggestion_type === 'followup' ? '随访' : s.suggestion_type === 'appointment' ? '预约' : '预警';
|
||||
const params = s.params as Record<string, unknown> | null;
|
||||
const reason = (params?.reason as string) || (params?.message as string) || typeLabel;
|
||||
return (
|
||||
<View key={s.id} className='ai-suggestion-item'>
|
||||
<View className='ai-risk-dot' style={{ background: riskColor }} />
|
||||
<Text className='ai-suggestion-text'>{reason.slice(0, 40)}</Text>
|
||||
</View>
|
||||
<Text className='trend-card-label'>{t.label}</Text>
|
||||
<Text className='trend-card-arrow'>查看 ›</Text>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* 最近监测记录 */}
|
||||
{recentRecords.length > 0 && (
|
||||
<View className='health-section'>
|
||||
<Text className='section-title'>最近监测</Text>
|
||||
{recentRecords.map((record) => (
|
||||
<View className='record-card' key={record.id}>
|
||||
<Text className='record-date'>{record.record_date}</Text>
|
||||
<View className='record-data'>
|
||||
<View className='record-item'>
|
||||
<Text className='record-item-label'>血压</Text>
|
||||
<Text className='record-item-value'>{formatBp(record)}</Text>
|
||||
</View>
|
||||
{record.weight != null && (
|
||||
<View className='record-item'>
|
||||
<Text className='record-item-label'>体重</Text>
|
||||
<Text className='record-item-value'>{record.weight} kg</Text>
|
||||
</View>
|
||||
)}
|
||||
{record.blood_sugar != null && (
|
||||
<View className='record-item'>
|
||||
<Text className='record-item-label'>血糖</Text>
|
||||
<Text className='record-item-value'>{record.blood_sugar} mmol/L</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 类型 Tab */}
|
||||
<View className='vital-tabs'>
|
||||
{VITAL_TABS.map((tab) => {
|
||||
const hasData = tab.key === 'blood_pressure' ? !!todaySummary?.blood_pressure
|
||||
: tab.key === 'heart_rate' ? !!todaySummary?.heart_rate
|
||||
: tab.key === 'blood_sugar' ? !!todaySummary?.blood_sugar
|
||||
: !!todaySummary?.weight;
|
||||
return (
|
||||
<View
|
||||
key={tab.key}
|
||||
className={`vital-tab ${activeTab === tab.key ? 'vital-tab-active' : ''}`}
|
||||
onClick={() => handleTabChange(tab.key)}
|
||||
>
|
||||
<Text className='vital-tab-text'>{tab.label}</Text>
|
||||
{!hasData && <View className='vital-tab-dot' />}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* 录入区 */}
|
||||
<View className='input-section'>
|
||||
{activeTab === 'blood_pressure' && (
|
||||
<View className='input-group'>
|
||||
<Text className='input-label'>收缩压(高压)</Text>
|
||||
<Input
|
||||
className='input-field'
|
||||
type='number'
|
||||
placeholder='如 130'
|
||||
value={systolic}
|
||||
onInput={(e) => setSystolic(e.detail.value)}
|
||||
/>
|
||||
<Text className='input-label input-label--secondary'>舒张压(低压)</Text>
|
||||
<Input
|
||||
className='input-field'
|
||||
type='number'
|
||||
placeholder='如 85'
|
||||
value={diastolic}
|
||||
onInput={(e) => setDiastolic(e.detail.value)}
|
||||
/>
|
||||
<Text className='input-ref'>{refRanges.blood_pressure}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{activeTab === 'heart_rate' && (
|
||||
<View className='input-group'>
|
||||
<Text className='input-label'>心率</Text>
|
||||
<Input
|
||||
className='input-field'
|
||||
type='digit'
|
||||
placeholder='如 72'
|
||||
value={heartRateVal}
|
||||
onInput={(e) => setHeartRateVal(e.detail.value)}
|
||||
/>
|
||||
<Text className='input-ref'>{refRanges.heart_rate}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{activeTab === 'blood_sugar' && (
|
||||
<View className='input-group'>
|
||||
<Text className='input-label'>血糖值</Text>
|
||||
<Input
|
||||
className='input-field'
|
||||
type='digit'
|
||||
placeholder='如 5.6'
|
||||
value={sugarVal}
|
||||
onInput={(e) => setSugarVal(e.detail.value)}
|
||||
/>
|
||||
<View className='period-group'>
|
||||
<View
|
||||
className={`period-btn ${sugarPeriod === 'fasting' ? 'period-active' : ''}`}
|
||||
onClick={() => setSugarPeriod('fasting')}
|
||||
>
|
||||
<Text className='period-btn-text'>空腹</Text>
|
||||
</View>
|
||||
<View
|
||||
className={`period-btn ${sugarPeriod === 'postprandial' ? 'period-active' : ''}`}
|
||||
onClick={() => setSugarPeriod('postprandial')}
|
||||
>
|
||||
<Text className='period-btn-text'>餐后 2h</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className='input-ref'>{refRanges.blood_sugar}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{activeTab === 'weight' && (
|
||||
<View className='input-group'>
|
||||
<Text className='input-label'>体重 (kg)</Text>
|
||||
<Input
|
||||
className='input-field'
|
||||
type='digit'
|
||||
placeholder='如 65.5'
|
||||
value={weightVal}
|
||||
onInput={(e) => setWeightVal(e.detail.value)}
|
||||
/>
|
||||
<Text className='input-ref'>{refRanges.weight}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className='save-btn' onClick={handleSave}>
|
||||
<Text className='save-btn-text'>{saving ? '保存中...' : '保存'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 趋势图 */}
|
||||
<View className='trend-section'>
|
||||
<Text className='section-title'>近 7 天趋势</Text>
|
||||
{trendLoading ? (
|
||||
<Loading />
|
||||
) : trendData.length === 0 ? (
|
||||
<View className='trend-empty'>
|
||||
<Text className='trend-empty-text'>暂无趋势数据</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className='trend-chart'>
|
||||
<View className='trend-bars'>
|
||||
{/* 阈值标线 */}
|
||||
{getThresholdValue(activeTab, thresholds) && (() => {
|
||||
const tv = getThresholdValue(activeTab, thresholds)!;
|
||||
const pct = Math.min(95, (tv / maxTrendValue) * 100);
|
||||
return (
|
||||
<View className='trend-threshold-line' style={`bottom:${((12 + pct * 1.08) / 120 * 100).toFixed(1)}%;`}>
|
||||
<Text className='trend-threshold-label'>{tv}</Text>
|
||||
</View>
|
||||
);
|
||||
})()}
|
||||
{trendData.map((point, i) => {
|
||||
const heightPct = Math.max(8, (point.value / maxTrendValue) * 100);
|
||||
const tv = getThresholdValue(activeTab, thresholds);
|
||||
const isAbnormal = tv ? point.value >= tv : false;
|
||||
const dayOfWeek = new Date(point.date).getDay();
|
||||
return (
|
||||
<View className='trend-bar-col' key={i}>
|
||||
<View
|
||||
className={`trend-bar ${isAbnormal ? 'trend-bar-warn' : 'trend-bar-normal'}`}
|
||||
style={`height:${heightPct}%;`}
|
||||
/>
|
||||
<Text className='trend-bar-label'>{dayLabels[dayOfWeek]}</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* BLE 设备同步功能暂缓开放 */}
|
||||
|
||||
{/* 健康资讯入口 */}
|
||||
<View
|
||||
className='article-entry'
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/article/index' })}
|
||||
>
|
||||
<Text className='article-entry-text'>最新健康资讯 ›</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,389 +1,473 @@
|
||||
@import '../../styles/variables.scss';
|
||||
@import '../../styles/mixins.scss';
|
||||
|
||||
/* ═══════════════════════════════════════
|
||||
登录后首页
|
||||
═══════════════════════════════════════ */
|
||||
|
||||
.home-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding-bottom: calc(120px + env(safe-area-inset-bottom));
|
||||
padding: 20px 24px 100px;
|
||||
padding-bottom: calc(100px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
/* ─── 问候区 ─── */
|
||||
.greeting-section {
|
||||
background: linear-gradient(135deg, $pri 0%, $pri-d 100%);
|
||||
padding: 48px 32px 72px;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.greeting-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.greeting-time {
|
||||
font-size: 26px;
|
||||
opacity: 0.85;
|
||||
.greeting-text {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.greeting-name {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 44px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.greeting-date {
|
||||
font-size: 24px;
|
||||
opacity: 0.7;
|
||||
margin-top: 8px;
|
||||
font-size: var(--tk-font-cap);
|
||||
color: var(--tk-text-secondary);
|
||||
}
|
||||
|
||||
/* ─── 今日健康 ─── */
|
||||
.health-section {
|
||||
.greeting-bell {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 22px;
|
||||
background: $pri-l;
|
||||
@include flex-center;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.greeting-bell-icon {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $pri-d;
|
||||
}
|
||||
|
||||
.greeting-bell-dot {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: $dan;
|
||||
}
|
||||
|
||||
/* ─── 今日体征进度 ─── */
|
||||
.checkin-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
box-shadow: $shadow-md;
|
||||
margin: -36px 24px 24px;
|
||||
padding: 28px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
&:active {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.checkin-left {
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.checkin-right {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.checkin-title {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.checkin-capsules {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.capsule {
|
||||
font-size: var(--tk-font-micro);
|
||||
padding: 3px 8px;
|
||||
border-radius: $r-pill;
|
||||
font-weight: 500;
|
||||
|
||||
&.capsule-done {
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
}
|
||||
|
||||
&.capsule-pending {
|
||||
background: $surface-alt;
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 今日体征 2x2 ─── */
|
||||
.vitals-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@include section-title;
|
||||
}
|
||||
|
||||
.health-grid {
|
||||
.vitals-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.health-cell {
|
||||
background: $bg;
|
||||
border-radius: $r-sm;
|
||||
padding: 20px 16px;
|
||||
text-align: center;
|
||||
transition: opacity 0.2s;
|
||||
.vital-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 14px 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.health-cell-label {
|
||||
font-size: 22px;
|
||||
.vital-label {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.health-cell-value {
|
||||
@include serif-number;
|
||||
font-size: 44px;
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
display: block;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.health-cell-bottom {
|
||||
.vital-value-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.vital-value {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.vital-unit {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: var(--tk-text-secondary);
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
.vital-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.vital-tag {
|
||||
font-size: var(--tk-font-micro);
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: $r-pill;
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
|
||||
&.tag-ok {
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
}
|
||||
|
||||
&.tag-warn {
|
||||
background: $wrn-l;
|
||||
color: $wrn;
|
||||
}
|
||||
|
||||
&.tag-empty {
|
||||
background: $surface-alt;
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 智能提醒卡片 ─── */
|
||||
.reminder-card {
|
||||
background: linear-gradient(135deg, $pri 0%, $pri-d 100%);
|
||||
border-radius: $r;
|
||||
padding: 18px;
|
||||
margin-bottom: 16px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.reminder-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.reminder-title {
|
||||
font-size: var(--tk-font-cap);
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.reminder-count {
|
||||
font-size: var(--tk-font-micro);
|
||||
opacity: 0.7;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.reminder-item {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
|
||||
&:active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.reminder-item-border {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.reminder-tag {
|
||||
font-size: var(--tk-font-micro);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reminder-text {
|
||||
font-size: var(--tk-font-cap);
|
||||
flex: 1;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reminder-arrow {
|
||||
opacity: 0.5;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ─── 快捷操作 ─── */
|
||||
.action-section {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.health-cell-unit {
|
||||
font-size: 20px;
|
||||
color: $tx3;
|
||||
}
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
height: 52px;
|
||||
border-radius: 14px;
|
||||
@include flex-center;
|
||||
|
||||
.health-cell-tag {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
padding: 2px 10px;
|
||||
border-radius: $r-sm;
|
||||
display: inline-block;
|
||||
|
||||
&.tag-ok {
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
}
|
||||
|
||||
&.tag-warn {
|
||||
background: $wrn-l;
|
||||
color: $wrn;
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 快捷服务 ─── */
|
||||
.services-section {
|
||||
margin: 0 24px 24px;
|
||||
.action-primary {
|
||||
background: $pri;
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(196, 98, 58, 0.25);
|
||||
}
|
||||
|
||||
.services-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
.action-outline {
|
||||
background: transparent;
|
||||
color: $pri;
|
||||
border: 2px solid $pri;
|
||||
}
|
||||
|
||||
.service-btn {
|
||||
.action-btn-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════
|
||||
访客首页
|
||||
═══════════════════════════════════════ */
|
||||
|
||||
.guest-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding-bottom: calc(120px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
/* ─── 轮播图 ─── */
|
||||
.guest-swiper {
|
||||
width: 100%;
|
||||
height: 360px;
|
||||
}
|
||||
|
||||
.guest-slide {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.guest-slide-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
&--1 {
|
||||
background: linear-gradient(135deg, $pri-d 0%, $pri 60%, $pri-l 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.guest-slide:nth-child(2) .guest-slide-bg {
|
||||
background: linear-gradient(135deg, $acc 0%, #3D5A40 60%, $acc-l 100%);
|
||||
}
|
||||
|
||||
.guest-slide:nth-child(3) .guest-slide-bg {
|
||||
background: linear-gradient(135deg, #8B6F4E 0%, $wrn 60%, $wrn-l 100%);
|
||||
}
|
||||
|
||||
.guest-slide-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.service-icon-wrap {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: $r;
|
||||
background: $pri-l;
|
||||
@include flex-center;
|
||||
}
|
||||
|
||||
.service-icon-text {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
.service-label {
|
||||
font-size: 22px;
|
||||
color: $tx2;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ─── 待办事项 ─── */
|
||||
.upcoming-section {
|
||||
margin: 0 24px;
|
||||
}
|
||||
|
||||
.upcoming-empty {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.upcoming-empty-text {
|
||||
display: block;
|
||||
font-size: 28px;
|
||||
color: $tx2;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.upcoming-empty-hint {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.upcoming-list {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
overflow: hidden;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.upcoming-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 24px 24px;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: $bd-l;
|
||||
}
|
||||
}
|
||||
|
||||
.upcoming-item-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.upcoming-item-title {
|
||||
font-size: 28px;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.upcoming-item-sub {
|
||||
font-size: 22px;
|
||||
color: $tx2;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.upcoming-item-tag {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
padding: 4px 14px;
|
||||
border-radius: $r-sm;
|
||||
flex-shrink: 0;
|
||||
margin-right: 12px;
|
||||
|
||||
&.tag-ok {
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
}
|
||||
|
||||
&.tag-warn {
|
||||
background: $wrn-l;
|
||||
color: $wrn;
|
||||
}
|
||||
|
||||
&.tag-default {
|
||||
background: $bd-l;
|
||||
color: $tx2;
|
||||
}
|
||||
}
|
||||
|
||||
.upcoming-item-arrow {
|
||||
font-size: 32px;
|
||||
color: $tx3;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ─── 健康空状态 ─── */
|
||||
.health-empty {
|
||||
background: $bg;
|
||||
border-radius: $r-sm;
|
||||
padding: 40px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.health-empty-text {
|
||||
display: block;
|
||||
font-size: 28px;
|
||||
color: $tx2;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.health-empty-action {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 24px 0 0;
|
||||
padding: 40px 32px;
|
||||
}
|
||||
|
||||
.health-empty-btn {
|
||||
background: $pri;
|
||||
border-radius: $r;
|
||||
padding: 16px 40px;
|
||||
.guest-slide-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: 700;
|
||||
color: #FFFFFF;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.health-empty-btn-text {
|
||||
color: #fff;
|
||||
font-size: 26px;
|
||||
font-weight: 500;
|
||||
.guest-slide-desc {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ─── 健康资讯 ─── */
|
||||
.articles-section {
|
||||
margin: 0 24px 24px;
|
||||
.guest-section {
|
||||
padding: 24px 24px 0;
|
||||
}
|
||||
|
||||
.article-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.article-card-title {
|
||||
font-size: 28px;
|
||||
.guest-section-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.article-card-meta {
|
||||
font-size: 22px;
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
/* ─── 设备快捷入口 ─── */
|
||||
.device-section {
|
||||
margin: 0 24px 24px;
|
||||
}
|
||||
|
||||
.device-entry {
|
||||
.guest-articles {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.guest-article-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 12px;
|
||||
padding: 16px 18px;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.device-entry-icon-wrap {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: $r;
|
||||
background: $pri-l;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-entry-icon-text {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.device-entry-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.device-entry-name {
|
||||
font-size: 28px;
|
||||
.guest-article-title {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.device-entry-desc {
|
||||
font-size: 22px;
|
||||
color: $tx3;
|
||||
.guest-article-summary {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: var(--tk-text-secondary);
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.device-entry-arrow {
|
||||
font-size: 32px;
|
||||
color: $tx3;
|
||||
flex-shrink: 0;
|
||||
.guest-empty {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.guest-empty-text {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: var(--tk-text-secondary);
|
||||
}
|
||||
|
||||
/* ─── 底部登录引导 ─── */
|
||||
.guest-login-prompt {
|
||||
margin: 24px 24px 0;
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px;
|
||||
box-shadow: $shadow-md;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.guest-login-text {
|
||||
flex: 1;
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.guest-login-btn {
|
||||
height: 56px;
|
||||
padding: 0 28px;
|
||||
background: $pri;
|
||||
border-radius: $r-pill;
|
||||
@include flex-center;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.guest-login-btn-text {
|
||||
font-size: var(--tk-font-h2);
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -1,97 +1,170 @@
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import { useState } from 'react';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import { View, Text, Swiper, SwiperItem } from '@tarojs/components';
|
||||
import { useState, useCallback } from 'react';
|
||||
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { useUIStore } from '../../stores/ui';
|
||||
import { navigateToLogin } from '../../utils/navigate';
|
||||
import { useHealthStore } from '../../stores/health';
|
||||
import ProgressRing from '../../components/ProgressRing';
|
||||
import Loading from '../../components/Loading';
|
||||
import { trackPageView } from '@/services/analytics';
|
||||
import * as appointmentApi from '@/services/appointment';
|
||||
import * as followupApi from '@/services/followup';
|
||||
import * as articleApi from '../../services/article';
|
||||
import { listPendingSuggestions, type AiSuggestionItem } from '@/services/ai-analysis';
|
||||
import { notificationService } from '@/services/notification';
|
||||
import './index.scss';
|
||||
|
||||
const QUICK_SERVICES = [
|
||||
{ label: '预约挂号', char: '约', path: '/pages/appointment/create/index' },
|
||||
{ label: '健康录入', char: '录', path: '/pages/pkg-health/input/index' },
|
||||
{ label: '健康趋势', char: '势', path: '/pages/pkg-health/trend/index' },
|
||||
{ label: '健康告警', char: '警', path: '/pages/pkg-health/alerts/index' },
|
||||
{ label: '资讯文章', char: '文', path: '/pages/article/index' },
|
||||
{ label: 'AI 报告', char: 'AI', path: '/pages/ai-report/list/index' },
|
||||
];
|
||||
|
||||
interface UpcomingItem {
|
||||
interface ReminderItem {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
type: 'appointment' | 'followup';
|
||||
statusLabel: string;
|
||||
statusType: 'ok' | 'warn' | 'default';
|
||||
text: string;
|
||||
type: 'ai' | 'appointment' | 'followup';
|
||||
tag: string;
|
||||
}
|
||||
|
||||
export default function Index() {
|
||||
// ─── 访客首页 ───
|
||||
|
||||
const CAROUSEL_SLIDES = [
|
||||
{ id: 'slide-1', title: '专业血透中心', desc: '三甲级医护团队全程守护' },
|
||||
{ id: 'slide-2', title: '智慧健康管理', desc: 'AI 驱动个性化健康方案' },
|
||||
{ id: 'slide-3', title: '温馨就医环境', desc: '舒适安全的治疗体验' },
|
||||
];
|
||||
|
||||
function GuestHome({ modeClass }: { modeClass: string }) {
|
||||
return (
|
||||
<View className={`guest-page ${modeClass}`}>
|
||||
{/* 轮播图 */}
|
||||
<Swiper
|
||||
className='guest-swiper'
|
||||
indicatorDots
|
||||
indicatorColor='rgba(255,255,255,0.4)'
|
||||
indicatorActiveColor='#FFFFFF'
|
||||
autoplay
|
||||
circular
|
||||
interval={4000}
|
||||
duration={500}
|
||||
>
|
||||
{CAROUSEL_SLIDES.map((slide) => (
|
||||
<SwiperItem key={slide.id}>
|
||||
<View className='guest-slide'>
|
||||
<View className='guest-slide-bg guest-slide-bg--1' />
|
||||
<View className='guest-slide-content'>
|
||||
<Text className='guest-slide-title'>{slide.title}</Text>
|
||||
<Text className='guest-slide-desc'>{slide.desc}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</SwiperItem>
|
||||
))}
|
||||
</Swiper>
|
||||
|
||||
{/* 功能亮点 */}
|
||||
<View className='guest-section'>
|
||||
<Text className='guest-section-title'>核心功能</Text>
|
||||
<View className='guest-articles'>
|
||||
<View className='guest-article-card'>
|
||||
<Text className='guest-article-title'>健康数据管理</Text>
|
||||
<Text className='guest-article-summary'>记录并追踪您的体征数据</Text>
|
||||
</View>
|
||||
<View className='guest-article-card'>
|
||||
<Text className='guest-article-title'>智能预约排班</Text>
|
||||
<Text className='guest-article-summary'>在线预约透析和治疗</Text>
|
||||
</View>
|
||||
<View className='guest-article-card'>
|
||||
<Text className='guest-article-title'>AI 健康分析</Text>
|
||||
<Text className='guest-article-summary'>个性化健康趋势解读</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 底部登录引导 */}
|
||||
<View className='guest-login-prompt'>
|
||||
<Text className='guest-login-text'>登录后即可使用完整健康管理服务</Text>
|
||||
<View
|
||||
className='guest-login-btn'
|
||||
onClick={navigateToLogin}
|
||||
>
|
||||
<Text className='guest-login-btn-text'>立即登录</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 登录后首页 ───
|
||||
|
||||
function HomeDashboard({ modeClass }: { modeClass: string }) {
|
||||
const { user, currentPatient } = useAuthStore();
|
||||
const { todaySummary, loading, refreshToday } = useHealthStore();
|
||||
const [upcomingItems, setUpcomingItems] = useState<UpcomingItem[]>([]);
|
||||
const [upcomingLoading, setUpcomingLoading] = useState(false);
|
||||
const [articles, setArticles] = useState<articleApi.Article[]>([]);
|
||||
const [reminders, setReminders] = useState<ReminderItem[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [remindersLoading, setRemindersLoading] = useState(false);
|
||||
|
||||
useDidShow(() => {
|
||||
refreshToday();
|
||||
loadUpcoming();
|
||||
loadArticles();
|
||||
loadReminders();
|
||||
loadUnread();
|
||||
trackPageView('home');
|
||||
});
|
||||
|
||||
const loadArticles = async () => {
|
||||
usePullDownRefresh(() => {
|
||||
Promise.all([refreshToday(true), loadReminders(), loadUnread()]).finally(() => {
|
||||
Taro.stopPullDownRefresh();
|
||||
});
|
||||
});
|
||||
|
||||
const loadUnread = async () => {
|
||||
try {
|
||||
const res = await articleApi.listArticles({ page: 1, page_size: 2 });
|
||||
setArticles(res.data || []);
|
||||
const res = await notificationService.getUnreadCount() as { data?: { count?: number } | number };
|
||||
const d = res.data;
|
||||
setUnreadCount(typeof d === 'object' && d ? (d.count ?? 0) : 0);
|
||||
} catch {
|
||||
// 文章接口可能不可用
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const loadUpcoming = async () => {
|
||||
const loadReminders = async () => {
|
||||
const patientId = useAuthStore.getState().currentPatient?.id;
|
||||
if (!patientId) return;
|
||||
setUpcomingLoading(true);
|
||||
setRemindersLoading(true);
|
||||
try {
|
||||
const items: UpcomingItem[] = [];
|
||||
const [apptRes, taskRes] = await Promise.allSettled([
|
||||
const items: ReminderItem[] = [];
|
||||
const [apptRes, taskRes, suggestRes] = await Promise.allSettled([
|
||||
appointmentApi.listAppointments(patientId, 1),
|
||||
followupApi.listTasks(patientId, 'pending'),
|
||||
listPendingSuggestions(),
|
||||
]);
|
||||
|
||||
if (suggestRes.status === 'fulfilled') {
|
||||
for (const s of suggestRes.value.data.slice(0, 1)) {
|
||||
items.push({ id: s.id, text: buildSuggestionText(s), type: 'ai', tag: 'AI 建议' });
|
||||
}
|
||||
}
|
||||
if (apptRes.status === 'fulfilled') {
|
||||
for (const a of apptRes.value.data.slice(0, 3)) {
|
||||
for (const a of apptRes.value.data.slice(0, 1)) {
|
||||
if (a.status === 'pending' || a.status === 'confirmed') {
|
||||
items.push({
|
||||
id: a.id,
|
||||
title: `${a.appointment_date} ${a.start_time}`,
|
||||
subtitle: `${a.doctor_name || '医护'} · ${a.department || ''}`,
|
||||
text: `${a.appointment_date} ${a.start_time} — ${a.doctor_name || '医护'} ${a.department || '门诊'}`,
|
||||
type: 'appointment',
|
||||
statusLabel: a.status === 'pending' ? '待确认' : '已确认',
|
||||
statusType: a.status === 'pending' ? 'warn' : 'ok',
|
||||
tag: '预约',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (taskRes.status === 'fulfilled') {
|
||||
for (const t of taskRes.value.data.slice(0, 2)) {
|
||||
for (const t of taskRes.value.data.slice(0, 1)) {
|
||||
items.push({
|
||||
id: t.id,
|
||||
title: t.follow_up_type,
|
||||
subtitle: `${t.content_template?.slice(0, 30) || ''} · 截止 ${t.planned_date}`,
|
||||
text: `${t.follow_up_type} · 截止 ${t.planned_date}`,
|
||||
type: 'followup',
|
||||
statusLabel: '进行中',
|
||||
statusType: 'default',
|
||||
tag: '随访',
|
||||
});
|
||||
}
|
||||
}
|
||||
setUpcomingItems(items);
|
||||
setReminders(items.slice(0, 3));
|
||||
} catch {
|
||||
setUpcomingItems([]);
|
||||
setReminders([]);
|
||||
} finally {
|
||||
setUpcomingLoading(false);
|
||||
setRemindersLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,11 +172,23 @@ export default function Index() {
|
||||
const greeting = hour < 12 ? '上午好' : hour < 18 ? '下午好' : '晚上好';
|
||||
const displayName = user?.display_name || currentPatient?.name || '访客';
|
||||
|
||||
const summary = todaySummary || {};
|
||||
const indicators = [!!summary.blood_pressure, !!summary.heart_rate, !!summary.blood_sugar, !!summary.weight];
|
||||
const completedCount = indicators.filter(Boolean).length;
|
||||
const progressPercent = Math.round((completedCount / 4) * 100);
|
||||
|
||||
const indicatorCapsules = [
|
||||
{ label: '血压', done: !!summary.blood_pressure },
|
||||
{ label: '心率', done: !!summary.heart_rate },
|
||||
{ label: '血糖', done: !!summary.blood_sugar },
|
||||
{ label: '体重', done: !!summary.weight },
|
||||
];
|
||||
|
||||
const healthItems = [
|
||||
{ label: '血压', value: todaySummary?.blood_pressure ? `${todaySummary.blood_pressure.systolic}/${todaySummary.blood_pressure.diastolic}` : '--/--', unit: 'mmHg', status: todaySummary?.blood_pressure?.status },
|
||||
{ label: '心率', value: todaySummary?.heart_rate ? `${todaySummary.heart_rate.value}` : '--', unit: 'bpm', status: todaySummary?.heart_rate?.status },
|
||||
{ label: '血糖', value: todaySummary?.blood_sugar ? `${todaySummary.blood_sugar.value}` : '--', unit: 'mmol/L', status: todaySummary?.blood_sugar?.status },
|
||||
{ label: '体重', value: todaySummary?.weight ? `${todaySummary.weight.value}` : '--', unit: 'kg', status: todaySummary?.weight?.status },
|
||||
{ label: '血压', value: summary.blood_pressure ? `${summary.blood_pressure.systolic}/${summary.blood_pressure.diastolic}` : '—', unit: 'mmHg', status: summary.blood_pressure?.status, indicator: 'systolic_bp_morning' },
|
||||
{ label: '心率', value: summary.heart_rate ? `${summary.heart_rate.value}` : '—', unit: 'bpm', status: summary.heart_rate?.status, indicator: 'heart_rate' },
|
||||
{ label: '血糖', value: summary.blood_sugar ? `${summary.blood_sugar.value}` : '—', unit: 'mmol/L', status: summary.blood_sugar?.status, indicator: 'blood_sugar' },
|
||||
{ label: '体重', value: summary.weight ? `${summary.weight.value}` : '—', unit: 'kg', status: summary.weight?.status, indicator: 'weight' },
|
||||
];
|
||||
|
||||
const getStatusTag = (status?: string) => {
|
||||
@@ -113,65 +198,63 @@ export default function Index() {
|
||||
};
|
||||
|
||||
return (
|
||||
<View className='home-page'>
|
||||
<View className={`home-page ${modeClass}`}>
|
||||
{/* 问候区 */}
|
||||
<View className='greeting-section'>
|
||||
<View className='greeting-left'>
|
||||
<Text className='greeting-time'>{greeting}</Text>
|
||||
<Text className='greeting-name'>{displayName}</Text>
|
||||
<Text className='greeting-text'>{greeting},{displayName}</Text>
|
||||
<Text className='greeting-date'>
|
||||
{new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'short' })}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className='greeting-date'>{new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'short' })}</Text>
|
||||
</View>
|
||||
|
||||
{/* 设备快捷入口 — 点击直接跳转设备同步页面 */}
|
||||
<View className='device-section'>
|
||||
<View className='device-entry' onClick={() => Taro.navigateTo({ url: '/pages/device-sync/index' })}>
|
||||
<View className='device-entry-icon-wrap'>
|
||||
<Text className='device-entry-icon-text'>{'\u{1FA7A}'}</Text>
|
||||
</View>
|
||||
<View className='device-entry-info'>
|
||||
<Text className='device-entry-name'>血压计</Text>
|
||||
<Text className='device-entry-desc'>蓝牙同步 · 自动采集</Text>
|
||||
</View>
|
||||
<Text className='device-entry-arrow'>{'›'}</Text>
|
||||
</View>
|
||||
<View className='device-entry' onClick={() => Taro.navigateTo({ url: '/pages/device-sync/index' })}>
|
||||
<View className='device-entry-icon-wrap'>
|
||||
<Text className='device-entry-icon-text'>{'\u{1FA78}'}</Text>
|
||||
</View>
|
||||
<View className='device-entry-info'>
|
||||
<Text className='device-entry-name'>血糖仪</Text>
|
||||
<Text className='device-entry-desc'>蓝牙同步 · 自动采集</Text>
|
||||
</View>
|
||||
<Text className='device-entry-arrow'>{'›'}</Text>
|
||||
<View className='greeting-bell' onClick={() => Taro.switchTab({ url: '/pages/messages/index' })}>
|
||||
<Text className='greeting-bell-icon'>消</Text>
|
||||
{unreadCount > 0 && <View className='greeting-bell-dot' />}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 今日健康 */}
|
||||
<View className='health-section'>
|
||||
<Text className='section-title'>今日健康</Text>
|
||||
{/* 今日体征进度 */}
|
||||
<View className='checkin-card' onClick={() => Taro.switchTab({ url: '/pages/health/index' })}>
|
||||
<View className='checkin-left'>
|
||||
<ProgressRing percent={progressPercent} />
|
||||
</View>
|
||||
<View className='checkin-right'>
|
||||
<Text className='checkin-title'>
|
||||
{completedCount === 4 ? '今日体征已全部记录' : completedCount === 0 ? '今日尚未记录体征' : `今日已记录 ${completedCount}/4 项`}
|
||||
</Text>
|
||||
<View className='checkin-capsules'>
|
||||
{indicatorCapsules.map((cap) => (
|
||||
<Text key={cap.label} className={`capsule ${cap.done ? 'capsule-done' : 'capsule-pending'}`}>
|
||||
{cap.done ? '✓ ' : ''}{cap.label}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 体征 2x2 */}
|
||||
<View className='vitals-section'>
|
||||
<Text className='section-title'>今日体征</Text>
|
||||
{loading && !todaySummary ? (
|
||||
<Loading />
|
||||
) : !todaySummary || (!todaySummary.blood_pressure && !todaySummary.heart_rate && !todaySummary.blood_sugar && !todaySummary.weight) ? (
|
||||
<View className='health-empty'>
|
||||
<Text className='health-empty-text'>今天还没录入数据</Text>
|
||||
<View className='health-empty-action'>
|
||||
<View className='health-empty-btn' onClick={() => Taro.navigateTo({ url: '/pages/pkg-health/input/index' })}>
|
||||
<Text className='health-empty-btn-text'>点击开始记录</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View className='health-grid'>
|
||||
<View className='vitals-grid'>
|
||||
{healthItems.map((item) => {
|
||||
const tag = getStatusTag(item.status);
|
||||
return (
|
||||
<View className='health-cell' key={item.label} onClick={() => Taro.navigateTo({ url: `/pages/pkg-health/trend/index?indicator=${item.label === '血压' ? 'blood_pressure_systolic' : item.label === '心率' ? 'heart_rate' : item.label === '血糖' ? 'blood_sugar_fasting' : 'weight'}` })}>
|
||||
<Text className='health-cell-label'>{item.label}</Text>
|
||||
<Text className='health-cell-value'>{item.value}</Text>
|
||||
<View className='health-cell-bottom'>
|
||||
<Text className='health-cell-unit'>{item.unit}</Text>
|
||||
{tag && <Text className={`health-cell-tag ${tag.cls}`}>{tag.label}</Text>}
|
||||
<View
|
||||
className='vital-card'
|
||||
key={item.label}
|
||||
onClick={() => Taro.navigateTo({ url: `/pages/pkg-health/trend/index?indicator=${item.indicator}` })}
|
||||
>
|
||||
<Text className='vital-label'>{item.label}</Text>
|
||||
<View className='vital-value-row'>
|
||||
<Text className='vital-value'>{item.value}</Text>
|
||||
<Text className='vital-unit'>{item.unit}</Text>
|
||||
</View>
|
||||
<View className='vital-bottom'>
|
||||
{tag && <Text className={`vital-tag ${tag.cls}`}>{tag.label}</Text>}
|
||||
{!item.status && <Text className='vital-tag tag-empty'>未记录</Text>}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -180,75 +263,65 @@ export default function Index() {
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 快捷服务 */}
|
||||
<View className='services-section'>
|
||||
<Text className='section-title'>快捷服务</Text>
|
||||
<View className='services-row'>
|
||||
{QUICK_SERVICES.map((svc) => (
|
||||
<View className='service-btn' key={svc.label} onClick={() => Taro.navigateTo({ url: svc.path })}>
|
||||
<View className='service-icon-wrap'>
|
||||
<Text className='service-icon-text'>{svc.char}</Text>
|
||||
</View>
|
||||
<Text className='service-label'>{svc.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 待办事项 */}
|
||||
<View className='upcoming-section'>
|
||||
<Text className='section-title'>待办事项</Text>
|
||||
{upcomingLoading ? (
|
||||
<Loading />
|
||||
) : upcomingItems.length === 0 ? (
|
||||
<View className='upcoming-empty'>
|
||||
<Text className='upcoming-empty-text'>暂无待办事项</Text>
|
||||
<Text className='upcoming-empty-hint'>预约挂号后将在此显示</Text>
|
||||
{/* 智能提醒卡片 */}
|
||||
{!remindersLoading && reminders.length > 0 && (
|
||||
<View className='reminder-card'>
|
||||
<View className='reminder-header'>
|
||||
<Text className='reminder-title'>智能提醒</Text>
|
||||
<Text className='reminder-count'>{reminders.length} 条待处理</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className='upcoming-list'>
|
||||
{upcomingItems.map((item) => (
|
||||
<View
|
||||
key={item.id}
|
||||
className='upcoming-item'
|
||||
onClick={() => {
|
||||
if (item.type === 'appointment') {
|
||||
Taro.navigateTo({ url: '/pages/appointment/index' });
|
||||
} else {
|
||||
Taro.navigateTo({ url: `/pages/followup/detail/index?id=${item.id}` });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<View className='upcoming-item-main'>
|
||||
<Text className='upcoming-item-title'>{item.title}</Text>
|
||||
<Text className='upcoming-item-sub'>{item.subtitle}</Text>
|
||||
</View>
|
||||
<Text className={`upcoming-item-tag tag-${item.statusType}`}>{item.statusLabel}</Text>
|
||||
<Text className='upcoming-item-arrow'>›</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 健康资讯 */}
|
||||
{articles.length > 0 && (
|
||||
<View className='articles-section'>
|
||||
<Text className='section-title'>健康资讯</Text>
|
||||
{articles.map((article) => (
|
||||
{reminders.map((r, i) => (
|
||||
<View
|
||||
className='article-card'
|
||||
key={article.id}
|
||||
onClick={() => Taro.navigateTo({ url: `/pages/article/detail/index?id=${article.id}` })}
|
||||
key={r.id}
|
||||
className={`reminder-item ${i > 0 ? 'reminder-item-border' : ''}`}
|
||||
onClick={() => {
|
||||
if (r.type === 'appointment') Taro.navigateTo({ url: '/pages/appointment/index' });
|
||||
else if (r.type === 'followup') Taro.navigateTo({ url: `/pages/followup/detail/index?id=${r.id}` });
|
||||
}}
|
||||
>
|
||||
<Text className='article-card-title'>{article.title}</Text>
|
||||
<Text className='article-card-meta'>
|
||||
{article.category_name || '健康'}{article.published_at ? ` · ${article.published_at.slice(0, 10)}` : ''}
|
||||
</Text>
|
||||
<Text className='reminder-tag'>{r.tag}</Text>
|
||||
<Text className='reminder-text'>{r.text}</Text>
|
||||
<Text className='reminder-arrow'>›</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 快捷操作 */}
|
||||
<View className='action-section'>
|
||||
<View className='action-btn action-primary' onClick={() => Taro.switchTab({ url: '/pages/health/index' })}>
|
||||
<Text className='action-btn-text'>记录体征</Text>
|
||||
</View>
|
||||
<View className='action-btn action-outline' onClick={() => Taro.navigateTo({ url: '/pages/appointment/create/index' })}>
|
||||
<Text className='action-btn-text'>预约挂号</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 首页入口:根据登录状态切换 ───
|
||||
|
||||
export default function Index() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const mode = useUIStore((s) => s.mode);
|
||||
const modeClass = mode === 'elder' ? 'elder-mode' : '';
|
||||
|
||||
if (!user) {
|
||||
return <GuestHome modeClass={modeClass} />;
|
||||
}
|
||||
return <HomeDashboard modeClass={modeClass} />;
|
||||
}
|
||||
|
||||
function buildSuggestionText(s: AiSuggestionItem): string {
|
||||
const riskMap: Record<string, string> = { high: '高风险', medium: '中风险', low: '低风险' };
|
||||
const typeMap: Record<string, string> = {
|
||||
vital_sign_anomaly: '体征异常',
|
||||
lab_result_anomaly: '化验异常',
|
||||
medication_adherence: '用药提醒',
|
||||
lifestyle: '生活建议',
|
||||
};
|
||||
const risk = riskMap[s.risk_level] || '';
|
||||
const type = typeMap[s.suggestion_type] || '健康建议';
|
||||
return `${type}:发现${risk}指标,建议关注`;
|
||||
}
|
||||
|
||||
@@ -8,19 +8,19 @@
|
||||
}
|
||||
|
||||
.legal-content {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
line-height: 1.8;
|
||||
|
||||
h3 {
|
||||
font-size: 34px;
|
||||
font-size: var(--tk-font-num-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 30px;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
margin-top: 24px;
|
||||
@@ -28,7 +28,7 @@
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.8;
|
||||
@@ -41,6 +41,6 @@
|
||||
}
|
||||
|
||||
.legal-footer-text {
|
||||
font-size: 24px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: var(--tk-text-secondary);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 160px 56px 80px;
|
||||
padding: 100px 40px 60px;
|
||||
}
|
||||
|
||||
/* ─── 品牌区 ─── */
|
||||
@@ -19,22 +19,22 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 80px;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: $r-lg;
|
||||
background: $pri;
|
||||
@include flex-center;
|
||||
margin-bottom: 36px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 8px 24px rgba($pri, 0.3);
|
||||
}
|
||||
|
||||
.login-logo-mark {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 64px;
|
||||
font-size: var(--tk-font-hero);
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
@@ -42,14 +42,14 @@
|
||||
|
||||
.login-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 48px;
|
||||
font-size: var(--tk-font-num);
|
||||
color: $tx;
|
||||
font-weight: bold;
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
@@ -57,7 +57,7 @@
|
||||
/* ─── 装饰线 ─── */
|
||||
.login-divider {
|
||||
width: 48px;
|
||||
margin-bottom: 64px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.login-divider-line {
|
||||
@@ -74,16 +74,18 @@
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
height: 96px;
|
||||
height: $btn-primary-h;
|
||||
background: $pri;
|
||||
color: #fff;
|
||||
font-size: 32px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
border-radius: $r;
|
||||
border: none;
|
||||
@include flex-center;
|
||||
letter-spacing: 0.04em;
|
||||
box-shadow: 0 4px 16px rgba($pri, 0.25);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
@@ -98,14 +100,14 @@
|
||||
.agreement-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-top: 40px;
|
||||
gap: 12px;
|
||||
margin-top: 28px;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.agreement-check {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 2px solid $bd;
|
||||
border-radius: $r-sm;
|
||||
@include flex-center;
|
||||
@@ -120,14 +122,14 @@
|
||||
}
|
||||
|
||||
.agreement-check-mark {
|
||||
font-size: 20px;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.agreement-text {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
line-height: 1.7;
|
||||
}
|
||||
@@ -136,3 +138,16 @@
|
||||
color: $pri;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ─── 暂不登录 ─── */
|
||||
.skip-row {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.skip-btn {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: var(--tk-text-secondary);
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
@@ -2,13 +2,18 @@ import { useState } from 'react';
|
||||
import { View, Text, Button, ScrollView } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { useElderClass } from '../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
export default function Login() {
|
||||
const modeClass = useElderClass();
|
||||
const [needBind, setNeedBind] = useState(false);
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
const { login, bindPhone, loading, isMedicalStaff } = useAuthStore();
|
||||
|
||||
// 登录页不应用关怀模式(正常模式尺寸已足够大)
|
||||
const loginClass = '';
|
||||
|
||||
const navigateAfterLogin = () => {
|
||||
if (isMedicalStaff()) {
|
||||
Taro.redirectTo({ url: '/pages/doctor/index' });
|
||||
@@ -47,16 +52,29 @@ export default function Login() {
|
||||
return;
|
||||
}
|
||||
const { encryptedData, iv } = e.detail;
|
||||
const success = await bindPhone(encryptedData, iv);
|
||||
if (success) {
|
||||
navigateAfterLogin();
|
||||
} else {
|
||||
Taro.showToast({ title: '绑定失败,请重试', icon: 'none' });
|
||||
try {
|
||||
const success = await bindPhone(encryptedData, iv);
|
||||
if (success) {
|
||||
navigateAfterLogin();
|
||||
}
|
||||
} catch (err: any) {
|
||||
const msg = err?.message || '绑定失败';
|
||||
Taro.showModal({
|
||||
title: '绑定手机号失败',
|
||||
content: msg,
|
||||
confirmText: '重新登录',
|
||||
cancelText: '取消',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
setNeedBind(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className='login-scroll'>
|
||||
<ScrollView scrollY className={`login-scroll ${loginClass}`}>
|
||||
<View className='login-page'>
|
||||
{/* 品牌区 */}
|
||||
<View className='login-brand'>
|
||||
@@ -102,6 +120,13 @@ export default function Login() {
|
||||
<Text className='agreement-link' onClick={() => Taro.navigateTo({ url: '/pages/legal/privacy-policy' })}>《隐私政策》</Text>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 暂不登录 */}
|
||||
<View className='skip-row'>
|
||||
<Text className='skip-btn' onClick={() => Taro.reLaunch({ url: '/pages/index/index' })}>
|
||||
暂不登录,先看看
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
}
|
||||
|
||||
.points-label {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
}
|
||||
|
||||
.checkin-btn-text {
|
||||
font-size: 24px;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -61,7 +61,7 @@
|
||||
|
||||
.points-balance {
|
||||
@include serif-number;
|
||||
font-size: 72px;
|
||||
font-size: 72px; /* kept as-is: special display value */
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
display: block;
|
||||
@@ -71,7 +71,7 @@
|
||||
}
|
||||
|
||||
.points-streak {
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
display: block;
|
||||
}
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
|
||||
.type-tab-text {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx2;
|
||||
|
||||
&.active {
|
||||
@@ -142,7 +142,7 @@
|
||||
|
||||
.product-image-char {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 56px;
|
||||
font-size: var(--tk-font-hero);
|
||||
font-weight: bold;
|
||||
color: $pri;
|
||||
line-height: 1;
|
||||
@@ -156,7 +156,7 @@
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 26px;
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
display: block;
|
||||
@@ -180,20 +180,20 @@
|
||||
|
||||
.product-points-char {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 22px;
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: bold;
|
||||
color: $wrn;
|
||||
}
|
||||
|
||||
.product-points-value {
|
||||
@include serif-number;
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $wrn;
|
||||
}
|
||||
|
||||
.product-stock {
|
||||
font-size: 20px;
|
||||
font-size: var(--tk-font-body);
|
||||
padding: 2px 10px;
|
||||
border-radius: $r-sm;
|
||||
|
||||
@@ -212,7 +212,7 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 160px 40px;
|
||||
padding: 100px 40px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
@@ -226,22 +226,22 @@
|
||||
|
||||
.empty-char {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 52px;
|
||||
font-size: var(--tk-font-hero);
|
||||
font-weight: bold;
|
||||
color: $pri;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 32px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 26px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: var(--tk-text-secondary);
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
@@ -257,7 +257,7 @@
|
||||
}
|
||||
|
||||
.empty-action-text {
|
||||
font-size: 28px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user