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:
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '设备同步',
|
||||
});
|
||||
256
apps/miniprogram/src/pages/pkg-health/device-sync/index.scss
Normal file
256
apps/miniprogram/src/pages/pkg-health/device-sync/index.scss
Normal 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;
|
||||
}
|
||||
322
apps/miniprogram/src/pages/pkg-health/device-sync/index.tsx
Normal file
322
apps/miniprogram/src/pages/pkg-health/device-sync/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user