refactor(mp): 分包策略优化 — 合并单页分包 + doctor 拆包 + consultation 移出主包

- 合并 4 个单页分包:report→pkg-profile/reports, followup→pkg-profile/followups,
  events→pkg-profile/events, device-sync→pkg-health
- consultation/detail 移出主包到 pkg-consultation 分包(减少主包体积)
- doctor 18 页拆分为 pkg-doctor-core(8页) + pkg-doctor-clinical(10页)
- 全部导航路径和 import 路径同步更新
- 分包 10→8 个,主包页面 13→12
This commit is contained in:
iven
2026-05-15 07:53:00 +08:00
parent 5baa518516
commit 4c38fcd89d
58 changed files with 71 additions and 78 deletions

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '设备同步',
});

View File

@@ -0,0 +1,256 @@
@import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
.device-sync-page {
min-height: 100vh;
background: $bg;
padding-bottom: env(safe-area-inset-bottom);
}
.sync-header {
background: $pri;
padding: 48px 32px 32px;
color: $card;
}
.sync-header-title {
@include section-title;
color: $card;
}
.sync-section {
padding: 24px;
}
.sync-hero {
display: flex;
flex-direction: column;
align-items: center;
padding: 48px 24px;
background: $card;
border-radius: $r;
margin-bottom: 24px;
box-shadow: $shadow-sm;
}
.sync-hero-icon {
width: 80px;
height: 80px;
border-radius: 50%;
background: $pri-l;
@include flex-center;
margin-bottom: 20px;
color: $pri;
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-num-lg);
font-weight: bold;
}
.sync-hero-title {
@include section-title;
margin-bottom: 8px;
}
.sync-hero-desc {
font-size: var(--tk-font-h1);
color: $tx2;
}
.sync-action {
@include flex-center;
background: $pri;
border-radius: $r-sm;
padding: 20px 40px;
margin: 12px 0;
&--primary {
flex: 1;
background: $pri;
}
&--danger {
flex: 1;
background: $dan;
margin-left: 16px;
}
}
.sync-action-text {
color: $card;
font-size: var(--tk-font-body-lg);
font-weight: 500;
}
.sync-device-list {
margin-top: 16px;
}
.sync-section-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-body-lg);
font-weight: bold;
color: $tx;
margin-bottom: 12px;
display: block;
}
.sync-device-item {
display: flex;
justify-content: space-between;
align-items: center;
background: $card;
border-radius: $r-sm;
padding: 24px;
margin-bottom: 12px;
box-shadow: $shadow-sm;
}
.sync-device-info {
display: flex;
flex-direction: column;
}
.sync-device-name {
font-size: var(--tk-font-body-lg);
font-weight: 500;
color: $tx;
}
.sync-device-adapter {
font-size: var(--tk-font-body);
color: var(--tk-text-secondary);
margin-top: 4px;
}
.sync-device-rssi {
font-size: var(--tk-font-body);
color: $tx2;
}
.sync-status-card {
display: flex;
align-items: center;
background: $card;
border-radius: $r-sm;
padding: 24px;
margin-bottom: 16px;
box-shadow: $shadow-sm;
}
.sync-status-dot {
width: 16px;
height: 16px;
border-radius: 50%;
margin-right: 16px;
background: $tx3;
&--connected {
background: $acc;
}
}
.sync-status-text {
font-size: var(--tk-font-body-lg);
color: $tx;
}
.sync-readings-panel {
background: $card;
border-radius: $r-sm;
padding: 24px;
margin-bottom: 16px;
box-shadow: $shadow-sm;
}
.sync-reading-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid $bd-l;
&:last-child {
border-bottom: none;
}
}
.sync-reading-type {
font-size: var(--tk-font-h1);
color: $tx2;
}
.sync-reading-value {
font-size: var(--tk-font-body-lg);
font-weight: bold;
color: $pri;
@include serif-number;
}
.sync-readings-count {
display: block;
margin-top: 12px;
font-size: var(--tk-font-body);
color: var(--tk-text-secondary);
text-align: center;
}
.sync-actions-row {
display: flex;
gap: 12px;
}
.sync-error {
margin: 24px;
padding: 20px 24px;
background: $dan-l;
border-radius: $r-sm;
}
.sync-error-text {
font-size: var(--tk-font-h1);
color: $dan;
}
.sync-loading {
@include flex-center;
padding: 64px 24px;
}
.sync-loading-text {
font-size: var(--tk-font-body-lg);
color: $tx2;
}
.sync-result-card {
display: flex;
flex-direction: column;
align-items: center;
background: $card;
border-radius: $r;
padding: 48px 24px;
margin-bottom: 24px;
box-shadow: $shadow-sm;
}
.sync-result-icon {
width: 80px;
height: 80px;
border-radius: 50%;
background: $acc-l;
@include flex-center;
color: $acc;
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-num-lg);
font-weight: bold;
margin-bottom: 16px;
}
.sync-result-title {
@include section-title;
margin-bottom: 8px;
}
.sync-result-count {
font-size: var(--tk-font-h1);
color: $tx2;
}

View File

@@ -0,0 +1,322 @@
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import { useThrottledDidShow } from '@/hooks/useThrottledDidShow';
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';
/** liveReadings 最大保留条数,防止内存无限增长 */
const MAX_LIVE_READINGS = 200;
type PageState = 'idle' | 'scanning' | 'connecting' | 'connected' | 'syncing' | 'done' | 'error';
export default function DeviceSync() {
const modeClass = useElderClass();
const currentPatient = useAuthStore((s) => s.currentPatient);
const router = useRouter();
const returnTo = router.params.returnTo || '';
const [pageState, setPageState] = useState<PageState>('idle');
const [devices, setDevices] = useState<BLEDevice[]>([]);
const [selectedDevice, setSelectedDevice] = useState<BLEDevice | null>(null);
const [liveReadings, setLiveReadings] = useState<NormalizedReading[]>([]);
const [syncCount, setSyncCount] = useState(0);
const [errorMsg, setErrorMsg] = useState('');
const [lastSyncAt, setLastSyncAt] = useState<number | null>(null);
const [pendingCount, setPendingCount] = useState(0);
const scheduler = useMemo(() => new DataSyncScheduler({
intervalMs: 60 * 60 * 1000,
}), []);
const bleManagerRef = useRef<BLEManager | null>(null);
const getBleManager = useCallback(() => {
if (!bleManagerRef.current) {
const mgr = new BLEManager({ scanTimeout: 10000, retryCount: 3 });
mgr.registerAdapter(XiaomiBandAdapter);
mgr.registerAdapter(BloodPressureAdapter);
mgr.registerAdapter(GlucoseMeterAdapter);
mgr.registerAdapter(CustomBandAdapter);
bleManagerRef.current = mgr;
}
return bleManagerRef.current;
}, []);
useThrottledDidShow(() => {
const bleManager = getBleManager();
bleManager.setOnConnectionChange(() => {});
bleManager.setOnReadings((readings) => {
setLiveReadings((prev) => {
const merged = [...prev, ...readings];
return merged.length > MAX_LIVE_READINGS ? merged.slice(-MAX_LIVE_READINGS) : merged;
});
});
// 显示上次同步时间
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 };
});
}
}, 10000);
useEffect(() => {
return () => {
scheduler.destroy();
if (bleManagerRef.current) {
bleManagerRef.current.destroy();
bleManagerRef.current = null;
}
};
}, [scheduler]);
const handleScan = useCallback(async () => {
setPageState('scanning');
setDevices([]);
setErrorMsg('');
try {
const found = await getBleManager().scanDevices();
setDevices(found);
if (found.length === 0) {
setErrorMsg('未发现支持的设备,请确认设备已开启蓝牙并靠近手机');
}
setPageState('idle');
} catch (e: any) {
setErrorMsg(e.message || '扫描失败');
setPageState('error');
}
}, []);
const handleConnect = useCallback(async (device: BLEDevice) => {
setSelectedDevice(device);
setPageState('connecting');
setErrorMsg('');
try {
await getBleManager().connect(device);
setPageState('connected');
} catch (e: any) {
setErrorMsg(e.message || '连接失败');
setPageState('error');
}
}, []);
const handleSync = useCallback(async () => {
if (!currentPatient || !selectedDevice) return;
setPageState('syncing');
setErrorMsg('');
try {
const result = await getBleManager().syncToServer(async (readings) => {
return uploadReadings(
currentPatient.id,
selectedDevice.deviceId,
selectedDevice.name,
readings,
);
});
if (result.success) {
setSyncCount(result.uploadedCount);
setLastSyncAt(Date.now());
setPageState('done');
// 如果从体征录入页跳转而来,将最新读数写入 storage 供回填
if (returnTo === 'input' && liveReadings.length > 0) {
const mapped: Record<string, number> = {};
for (const r of liveReadings) {
if (r.device_type === 'blood_pressure') {
if (r.metric === 'systolic' && typeof r.values.value === 'number') mapped.systolic = r.values.value;
if (r.metric === 'diastolic' && typeof r.values.value === 'number') mapped.diastolic = r.values.value;
// 兼容 values 中直接包含 systolic/diastolic 的格式
if (typeof r.values.systolic === 'number') mapped.systolic = r.values.systolic as number;
if (typeof r.values.diastolic === 'number') mapped.diastolic = r.values.diastolic as number;
} else if (r.device_type === 'blood_glucose' && typeof r.values.blood_glucose === 'number') {
mapped.blood_sugar = r.values.blood_glucose as number;
} else if (r.device_type === 'heart_rate' && typeof r.values.heart_rate === 'number') {
mapped.heart_rate = r.values.heart_rate as number;
}
}
if (Object.keys(mapped).length > 0) {
Taro.setStorageSync('device_sync_result', JSON.stringify(mapped));
}
}
} else {
setErrorMsg(result.error || '同步失败');
setPageState('error');
}
} catch (e: any) {
setErrorMsg(e.message || '同步失败');
setPageState('error');
}
}, [currentPatient, selectedDevice, liveReadings, returnTo]);
const handleDisconnect = useCallback(async () => {
await getBleManager().disconnect();
setPageState('idle');
setSelectedDevice(null);
setLiveReadings([]);
setSyncCount(0);
setErrorMsg('');
}, []);
const renderIdle = () => (
<View className="sync-section">
<View className="sync-hero">
<Text className="sync-hero-icon">D</Text>
<Text className="sync-hero-title"></Text>
<Text className="sync-hero-desc"></Text>
</View>
{(lastSyncAt || pendingCount > 0) && (
<View className="sync-status-info">
{lastSyncAt && (
<Text className="sync-status-time">
: {new Date(lastSyncAt).toLocaleTimeString()}
</Text>
)}
{pendingCount > 0 && (
<Text className="sync-status-pending">
{pendingCount}
</Text>
)}
</View>
)}
<View className="sync-action" onClick={handleScan}>
<Text className="sync-action-text"></Text>
</View>
{devices.length > 0 && (
<View className="sync-device-list">
<Text className="sync-section-title"></Text>
{devices.map((d) => (
<View
key={d.deviceId}
className="sync-device-item"
onClick={() => handleConnect(d)}
>
<View className="sync-device-info">
<Text className="sync-device-name">{d.name}</Text>
<Text className="sync-device-adapter">{d.adapter?.name}</Text>
</View>
<Text className="sync-device-rssi"> {d.RSSI > -60 ? '强' : d.RSSI > -80 ? '中' : '弱'}</Text>
</View>
))}
</View>
)}
</View>
);
const renderConnected = () => (
<View className="sync-section">
<View className="sync-status-card">
<Text className="sync-status-dot sync-status-dot--connected" />
<Text className="sync-status-text">: {selectedDevice?.name}</Text>
</View>
{liveReadings.length > 0 && (
<View className="sync-readings-panel">
<Text className="sync-section-title"></Text>
{liveReadings.slice(-5).reverse().map((r, i) => (
<View key={i} className="sync-reading-item">
<Text className="sync-reading-type">
{r.device_type === 'heart_rate' ? '心率'
: r.device_type === 'blood_pressure' ? `血压(${r.metric === 'systolic' ? '收缩压' : r.metric === 'diastolic' ? '舒张压' : 'MAP'})`
: r.device_type === 'blood_glucose' ? '血糖'
: r.device_type}
</Text>
<Text className="sync-reading-value">
{r.device_type === 'heart_rate'
? `${r.values.heart_rate} bpm`
: r.metric
? `${r.values.value} ${r.values.unit}`
: JSON.stringify(r.values)}
</Text>
</View>
))}
<Text className="sync-readings-count">
{liveReadings.length}
</Text>
</View>
)}
<View className="sync-actions-row">
<View className="sync-action sync-action--primary" onClick={handleSync}>
<Text className="sync-action-text"></Text>
</View>
<View className="sync-action sync-action--danger" onClick={handleDisconnect}>
<Text className="sync-action-text"></Text>
</View>
</View>
</View>
);
const renderDone = () => (
<View className="sync-section">
<View className="sync-result-card">
<Text className="sync-result-icon">V</Text>
<Text className="sync-result-title"></Text>
<Text className="sync-result-count"> {syncCount} </Text>
</View>
<View className="sync-action" onClick={() => {
handleDisconnect();
if (returnTo === 'input') {
Taro.navigateBack();
}
}}>
<Text className="sync-action-text">{returnTo === 'input' ? '返回录入' : '完成'}</Text>
</View>
</View>
);
return (
<View className={`device-sync-page ${modeClass}`}>
<View className="sync-header">
<Text className="sync-header-title"></Text>
</View>
{errorMsg && (
<View className="sync-error">
<Text className="sync-error-text">{errorMsg}</Text>
</View>
)}
{(pageState === 'scanning' || pageState === 'connecting' || pageState === 'syncing') && (
<View className="sync-loading">
<Text className="sync-loading-text">
{pageState === 'scanning' && '正在扫描设备...'}
{pageState === 'connecting' && '正在连接设备...'}
{pageState === 'syncing' && '正在上传数据...'}
</Text>
</View>
)}
{(pageState === 'idle' || pageState === 'error') && renderIdle()}
{pageState === 'connected' && renderConnected()}
{pageState === 'done' && renderDone()}
</View>
);
}