fix(mp): DevTools 卡死 + 主包 2MB→766KB + 代码质量 4 项全通过

根因:主包 2MB 全量组件注入导致 DevTools 渲染引擎内存渐增,
叠加离线时固定 3s 抑制期后的请求洪泛。

修复:
- app.config.ts 添加 lazyCodeLoading: requiredComponents
  主包 2.0MB→766KB,taro.js 526→131KB,vendors.js 230→28KB
- request.ts 离线抑制改为指数退避(3s→6s→12s→30s cap)
  后端不可达时自动延长抑制,防止请求风暴
- SegmentTabs Tab 接口改为 readonly,修复 TS 编译错误
- AbortController polyfill 补齐小程序运行时缺失
- 健康首页/设备同步/健康档案/报告/设置页 UI 重构
- 文章页公开端点适配游客访问
- 健康首页 Swiper 间隔优化 4s→5s,动画 500→300ms
This commit is contained in:
iven
2026-05-24 11:32:40 +08:00
parent 675f8a4b10
commit 1e59007bd5
58 changed files with 4950 additions and 494 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

View File

@@ -0,0 +1,754 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HMS 小程序 — 设备同步(重新设计)</title>
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #1a1a1a; font-family: -apple-system, 'PingFang SC', sans-serif; display: flex; flex-direction: column; align-items: center; padding: 40px 20px; gap: 24px; }
.page-title { color: #999; font-size: 13px; letter-spacing: 0.15em; }
.note { color: #666; font-size: 12px; max-width: 1200px; text-align: center; line-height: 1.8; }
.screens { display: flex; gap: 32px; flex-wrap: wrap; justify-content: center; align-items: flex-start; }
.screen-wrap { display: flex; flex-direction: column; align-items: center; gap: 12px; }
.screen-label { color: #888; font-size: 12px; font-style: italic; }
/* 蓝牙脉冲动画 */
@keyframes pulse-ring {
0% { transform: scale(0.8); opacity: 0.6; }
50% { transform: scale(1.3); opacity: 0; }
100% { transform: scale(0.8); opacity: 0; }
}
@keyframes pulse-dot {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
@keyframes connect-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes fade-in-up {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
</head>
<body>
<div class="page-title">HMS 小程序 · 设备同步(重新设计)</div>
<div class="note">7 个状态屏幕:空闲 → 扫描中 → 设备列表 → 连接中 → 已连接(实时数据)→ 同步完成 → 错误状态</div>
<div id="root"></div>
<script type="text/babel">
// ─── iOS 设备框 ───
const iosFrameStyles = {
wrapper: { display: 'inline-block', padding: 12, background: '#000', borderRadius: 60, boxShadow: '0 0 0 2px #1f2937, 0 20px 60px rgba(0,0,0,0.3)', position: 'relative' },
screen: { position: 'relative', borderRadius: 48, overflow: 'hidden', background: '#fff' },
statusBar: { position: 'absolute', top: 0, left: 0, right: 0, height: 54, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 32px', fontSize: 16, fontWeight: 600, fontFamily: '-apple-system, "SF Pro Text", sans-serif', zIndex: 20, pointerEvents: 'none' },
dynamicIsland: { position: 'absolute', top: 12, left: '50%', transform: 'translateX(-50%)', width: 124, height: 36, background: '#000', borderRadius: 999, zIndex: 30 },
content: { position: 'absolute', top: 54, left: 0, right: 0, bottom: 34, overflow: 'auto' },
homeIndicator: { position: 'absolute', bottom: 10, left: '50%', transform: 'translateX(-50%)', width: 140, height: 5, background: 'rgba(0,0,0,0.3)', borderRadius: 999, zIndex: 10 },
};
function IosFrame({ children, width = 360, height = 780, time = '9:41', battery = 85, darkStatus = false }) {
const c = darkStatus ? '#fff' : '#000';
return (
<div style={iosFrameStyles.wrapper}>
<div style={{ ...iosFrameStyles.screen, width, height }}>
<div style={{ ...iosFrameStyles.statusBar, color: c }}>
<span>{time}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<svg width="16" height="12" viewBox="0 0 16 12" fill="none"><path d="M8 11.5a1 1 0 100-2 1 1 0 000 2z" fill={c}/><path d="M3 7.5a7 7 0 0110 0" stroke={c} strokeWidth="1.3" fill="none" strokeLinecap="round"/><path d="M1 4.5a11 11 0 0114 0" stroke={c} strokeWidth="1.3" fill="none" strokeLinecap="round" opacity="0.7"/></svg>
<div style={{ width: 26, height: 12, border: `1.5px solid ${c}`, borderRadius: 3, padding: 1, position: 'relative' }}>
<div style={{ width: `${battery}%`, height: '100%', background: c, borderRadius: 1 }} />
</div>
</div>
</div>
<div style={iosFrameStyles.dynamicIsland} />
<div style={iosFrameStyles.content}>{children}</div>
<div style={iosFrameStyles.homeIndicator} />
</div>
</div>
);
}
// ─── 设计 Token ───
const T = {
pri: '#C4623A', priL: '#F0DDD4', priD: '#8B3E1F',
bg: '#F5F0EB', card: '#FFFFFF', surface: '#EDE8E2',
tx: '#2D2A26', tx2: '#5A554F', tx3: '#78716C',
bd: '#E8E2DC', bdL: '#F0EBE5',
acc: '#5B7A5E', accL: '#E8F0E8',
wrn: '#C4873A', wrnL: '#FFF3E0',
dan: '#B54A4A', danL: '#FDEAEA',
serif: "Georgia, 'Times New Roman', serif",
sans: "-apple-system, 'PingFang SC', sans-serif",
r: 16, rSm: 12, rXs: 8,
};
// ─── SVG 图标 ───
function BluetoothIcon({ size = 24, color = T.pri }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<path d="M6 7l8 8-4 4V3l4 4-8 8" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
}
function HeartIcon({ size = 20, color = T.dan }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z" fill={color} opacity="0.15"/>
<path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z" stroke={color} strokeWidth="1.5" fill="none"/>
</svg>
);
}
function CheckIcon({ size = 32, color = T.acc }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="11" fill={color} opacity="0.12"/>
<path d="M8 12.5l2.5 2.5 5.5-5.5" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
}
function ErrorIcon({ size = 32, color = T.dan }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="11" fill={color} opacity="0.12"/>
<path d="M15 9l-6 6M9 9l6 6" stroke={color} strokeWidth="2" strokeLinecap="round"/>
</svg>
);
}
// ─── 信号强度条 ───
function SignalBars({ level = 3 }) {
const bars = [4, 7, 10, 13];
return (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 2, height: 16 }}>
{bars.map((h, i) => (
<div key={i} style={{
width: 3, height: h, borderRadius: 1,
background: i < level ? T.acc : T.bd,
}} />
))}
</div>
);
}
// ─── 导航栏 ───
function NavBar({ title, dark = false }) {
return (
<div style={{
height: 44, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: dark ? T.pri : T.bg, position: 'relative',
}}>
<svg style={{ position: 'absolute', left: 16, top: '50%', transform: 'translateY(-50%)' }}
width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M15 19l-7-7 7-7" stroke={dark ? '#fff' : T.tx} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<span style={{ fontFamily: T.serif, fontSize: 18, fontWeight: 700, color: dark ? '#fff' : T.tx }}>{title}</span>
</div>
);
}
// ─── 设备类型标签 ───
function DeviceTypeTag({ icon, label }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
background: T.card, border: `1px solid ${T.bdL}`,
borderRadius: T.rXs, padding: '8px 12px',
}}>
{icon}
<span style={{ fontSize: 13, color: T.tx2 }}>{label}</span>
</div>
);
}
// ─── 屏幕一:空闲态 ───
function IdleScreen() {
return (
<div style={{ height: '100%', background: T.bg, display: 'flex', flexDirection: 'column' }}>
<NavBar title="设备同步" dark />
{/* Hero 区域 */}
<div style={{
background: `linear-gradient(135deg, ${T.pri} 0%, ${T.priD} 100%)`,
padding: '32px 20px 28px',
display: 'flex', flexDirection: 'column', alignItems: 'center',
}}>
{/* 蓝牙设备插图 */}
<div style={{
width: 72, height: 72, borderRadius: '50%',
background: 'rgba(255,255,255,0.15)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
marginBottom: 16,
}}>
<BluetoothIcon size={36} color="#fff" />
</div>
<div style={{ fontFamily: T.serif, fontSize: 22, fontWeight: 700, color: '#fff', marginBottom: 6 }}>
智能设备同步
</div>
<div style={{ fontSize: 14, color: 'rgba(255,255,255,0.75)', textAlign: 'center', lineHeight: 1.5 }}>
连接蓝牙设备自动采集健康数据
</div>
</div>
<div style={{ flex: 1, padding: '16px 16px 100px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* 支持的设备类型 */}
<div>
<div style={{ fontSize: 14, fontWeight: 600, color: T.tx, marginBottom: 10, paddingLeft: 2 }}>
支持的设备
</div>
<div style={{ display: 'flex', gap: 8 }}>
<DeviceTypeTag icon={<HeartIcon size={16} color={T.dan} />} label="心率手环" />
<DeviceTypeTag icon={
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M12 21V3M8 6l4-3 4 3M8 18l4 3 4-3" stroke={T.pri} strokeWidth="1.5" strokeLinecap="round"/>
<circle cx="12" cy="12" r="3" stroke={T.pri} strokeWidth="1.5"/>
</svg>
} label="血压计" />
<DeviceTypeTag icon={
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M12 2v6M12 22v-4M4.93 4.93l4.24 4.24M14.83 14.83l4.24 4.24M2 12h6M16 12h6" stroke={T.wrn} strokeWidth="1.5" strokeLinecap="round"/>
<circle cx="12" cy="12" r="4" stroke={T.wrn} strokeWidth="1.5"/>
</svg>
} label="血糖仪" />
</div>
</div>
{/* 上次同步信息 */}
<div style={{
background: T.card, borderRadius: T.rSm,
padding: '14px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
border: `1px solid ${T.bdL}`,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 36, height: 36, borderRadius: '50%', background: T.accL, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<CheckIcon size={20} color={T.acc} />
</div>
<div>
<div style={{ fontSize: 14, fontWeight: 500, color: T.tx }}>上次同步</div>
<div style={{ fontSize: 12, color: T.tx3, marginTop: 2 }}>今天 08:30</div>
</div>
</div>
<div style={{
background: T.accL, borderRadius: T.rXs,
padding: '4px 10px', fontSize: 12, color: T.acc, fontWeight: 500,
}}>
12 条数据
</div>
</div>
{/* 待上传提示 */}
<div style={{
background: T.wrnL, borderRadius: T.rSm,
padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 10,
}}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M12 9v4M12 17h.01M12 2L2 22h20L12 2z" stroke={T.wrn} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<span style={{ fontSize: 13, color: T.wrn, fontWeight: 500 }}>3 条数据待上传</span>
</div>
{/* 扫描按钮 */}
<div style={{
background: T.pri, borderRadius: T.rSm,
padding: '16px', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
boxShadow: `0 4px 16px rgba(196, 98, 58, 0.3)`,
marginTop: 'auto',
}}>
<BluetoothIcon size={20} color="#fff" />
<span style={{ color: '#fff', fontSize: 17, fontWeight: 600 }}>扫描附近设备</span>
</div>
</div>
</div>
);
}
// ─── 屏幕二:扫描中 ───
function ScanningScreen() {
return (
<div style={{ height: '100%', background: T.bg, display: 'flex', flexDirection: 'column' }}>
<NavBar title="设备同步" dark />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 40 }}>
{/* 脉冲圆环 */}
<div style={{ position: 'relative', width: 140, height: 140, marginBottom: 32 }}>
<div style={{
position: 'absolute', top: 0, left: 0, width: 140, height: 140,
borderRadius: '50%', border: `2px solid ${T.priL}`,
animation: 'pulse-ring 2s ease-out infinite',
}} />
<div style={{
position: 'absolute', top: 15, left: 15, width: 110, height: 110,
borderRadius: '50%', border: `2px solid ${T.priL}`,
animation: 'pulse-ring 2s ease-out infinite 0.5s',
}} />
<div style={{
position: 'absolute', top: 30, left: 30, width: 80, height: 80,
borderRadius: '50%', background: T.priL,
display: 'flex', alignItems: 'center', justifyContent: 'center',
animation: 'pulse-dot 2s ease-in-out infinite',
}}>
<BluetoothIcon size={36} color={T.pri} />
</div>
</div>
<div style={{ fontFamily: T.serif, fontSize: 20, fontWeight: 700, color: T.tx, marginBottom: 8, textAlign: 'center' }}>
正在搜索设备...
</div>
<div style={{ fontSize: 14, color: T.tx3, textAlign: 'center', lineHeight: 1.6 }}>
请确保设备已开启蓝牙并靠近手机
</div>
{/* 进度条 */}
<div style={{
width: 180, height: 3, borderRadius: 2, background: T.bdL,
marginTop: 24, overflow: 'hidden',
}}>
<div style={{
width: '60%', height: '100%', borderRadius: 2,
background: `linear-gradient(90deg, ${T.priL}, ${T.pri})`,
}} />
</div>
<div style={{ fontSize: 12, color: T.tx3, marginTop: 8 }}>已用时 6 </div>
</div>
</div>
);
}
// ─── 屏幕三:设备列表 ───
function DeviceListScreen() {
const devices = [
{ name: 'Mi Band 8', type: '小米手环适配器', signal: 4, color: T.pri },
{ name: 'AND UA-651', type: '血压计适配器', signal: 3, color: T.pri },
{ name: 'Accu-Chek', type: '血糖仪适配器', signal: 2, color: T.wrn },
];
return (
<div style={{ height: '100%', background: T.bg, display: 'flex', flexDirection: 'column' }}>
<NavBar title="设备同步" dark />
<div style={{ flex: 1, padding: '16px 16px 100px' }}>
{/* 结果标题 */}
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
marginBottom: 16,
}}>
<div>
<div style={{ fontSize: 14, fontWeight: 600, color: T.tx }}>发现 {devices.length} 台设备</div>
<div style={{ fontSize: 12, color: T.tx3, marginTop: 2 }}>点击设备名称开始连接</div>
</div>
<div style={{
fontSize: 13, color: T.pri, fontWeight: 500, cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 4,
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M23 4v6h-6M1 20v-6h6" stroke={T.pri} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15" stroke={T.pri} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
重新扫描
</div>
</div>
{/* 设备卡片 */}
{devices.map((d, i) => (
<div key={i} style={{
background: T.card, borderRadius: T.rSm,
padding: '16px', marginBottom: 10,
border: `1px solid ${T.bdL}`,
display: 'flex', alignItems: 'center', gap: 14,
}}>
{/* 设备图标 */}
<div style={{
width: 44, height: 44, borderRadius: T.rSm,
background: T.priL, display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
<BluetoothIcon size={22} color={T.pri} />
</div>
{/* 设备信息 */}
<div style={{ flex: 1 }}>
<div style={{ fontSize: 16, fontWeight: 600, color: T.tx }}>{d.name}</div>
<div style={{ fontSize: 12, color: T.tx3, marginTop: 3 }}>{d.type}</div>
</div>
{/* 信号 + 箭头 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<SignalBars level={d.signal} />
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M9 18l6-6-6-6" stroke={T.tx3} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
</div>
))}
{/* 未发现设备提示 */}
<div style={{
marginTop: 16, background: T.card, borderRadius: T.rSm,
padding: '14px 16px', border: `1px dashed ${T.bd}`,
display: 'flex', alignItems: 'center', gap: 10,
}}>
<div style={{
width: 32, height: 32, borderRadius: '50%', background: T.surface,
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<circle cx="11" cy="11" r="8" stroke={T.tx3} strokeWidth="1.5"/>
<path d="M21 21l-4.35-4.35" stroke={T.tx3} strokeWidth="1.5" strokeLinecap="round"/>
</svg>
</div>
<div>
<div style={{ fontSize: 13, color: T.tx2 }}>没有找到你的设备</div>
<div style={{ fontSize: 12, color: T.tx3, marginTop: 2 }}>确保设备已开机且蓝牙已开启</div>
</div>
</div>
</div>
</div>
);
}
// ─── 屏幕四:连接中 ───
function ConnectingScreen() {
return (
<div style={{ height: '100%', background: T.bg, display: 'flex', flexDirection: 'column' }}>
<NavBar title="设备同步" dark />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 40 }}>
{/* 连接动画 */}
<div style={{ position: 'relative', width: 100, height: 100, marginBottom: 28 }}>
{/* 旋转环 */}
<div style={{
position: 'absolute', top: 0, left: 0, width: 100, height: 100,
borderRadius: '50%', border: `3px solid ${T.bdL}`,
borderTopColor: T.pri,
animation: 'connect-spin 1s linear infinite',
}} />
{/* 中心图标 */}
<div style={{
position: 'absolute', top: 20, left: 20, width: 60, height: 60,
borderRadius: '50%', background: T.priL,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<BluetoothIcon size={28} color={T.pri} />
</div>
</div>
<div style={{ fontFamily: T.serif, fontSize: 18, fontWeight: 700, color: T.tx, marginBottom: 6, textAlign: 'center' }}>
正在连接 Mi Band 8
</div>
<div style={{ fontSize: 14, color: T.tx3, textAlign: 'center' }}>
正在进行蓝牙配对...
</div>
{/* 步骤指示 */}
<div style={{
display: 'flex', alignItems: 'center', gap: 8, marginTop: 24,
}}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: T.acc }} />
<span style={{ fontSize: 12, color: T.tx3 }}>发现设备</span>
<div style={{ width: 24, height: 1, background: T.pri }} />
<div style={{ width: 8, height: 8, borderRadius: '50%', background: T.pri, animation: 'pulse-dot 1s ease-in-out infinite' }} />
<span style={{ fontSize: 12, color: T.pri, fontWeight: 500 }}>连接中</span>
<div style={{ width: 24, height: 1, background: T.bd }} />
<div style={{ width: 8, height: 8, borderRadius: '50%', background: T.bd }} />
<span style={{ fontSize: 12, color: T.tx3 }}>同步数据</span>
</div>
</div>
</div>
);
}
// ─── 屏幕五:已连接 + 实时数据 ───
function ConnectedScreen() {
const readings = [
{ type: '心率', value: '72', unit: 'bpm', color: T.dan, time: '刚刚' },
{ type: '收缩压', value: '128', unit: 'mmHg', color: T.pri, time: '2分钟前' },
{ type: '舒张压', value: '82', unit: 'mmHg', color: T.pri, time: '2分钟前' },
{ type: '心率', value: '68', unit: 'bpm', color: T.dan, time: '5分钟前' },
{ type: '心率', value: '74', unit: 'bpm', color: T.dan, time: '8分钟前' },
];
return (
<div style={{ height: '100%', background: T.bg, display: 'flex', flexDirection: 'column' }}>
<NavBar title="设备同步" dark />
<div style={{ flex: 1, padding: '16px 16px 100px', overflow: 'auto' }}>
{/* 连接状态卡片 */}
<div style={{
background: `linear-gradient(135deg, ${T.acc} 0%, #4A6B4E 100%)`,
borderRadius: T.r, padding: '16px',
display: 'flex', alignItems: 'center', gap: 12,
marginBottom: 16,
}}>
<div style={{
width: 40, height: 40, borderRadius: '50%',
background: 'rgba(255,255,255,0.2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<BluetoothIcon size={22} color="#fff" />
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 16, fontWeight: 600, color: '#fff' }}>Mi Band 8</div>
<div style={{ fontSize: 12, color: 'rgba(255,255,255,0.7)', marginTop: 2 }}>已连接 · 正在采集数据</div>
</div>
<div style={{
background: 'rgba(255,255,255,0.2)', borderRadius: T.rXs,
padding: '4px 10px', fontSize: 12, color: '#fff',
}}>
实时
</div>
</div>
{/* 最新读数高亮 */}
<div style={{
background: T.card, borderRadius: T.r, padding: '20px',
display: 'flex', alignItems: 'center', gap: 16,
marginBottom: 16, boxShadow: '0 2px 12px rgba(45,42,38,0.08)',
}}>
<div style={{
width: 52, height: 52, borderRadius: T.rSm,
background: `${T.dan}10`, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<HeartIcon size={28} color={T.dan} />
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, color: T.tx3 }}>心率 · 刚刚</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, marginTop: 4 }}>
<span style={{ fontFamily: T.serif, fontSize: 36, fontWeight: 700, color: T.tx }}>72</span>
<span style={{ fontSize: 14, color: T.tx3 }}>bpm</span>
</div>
</div>
</div>
{/* 历史读数列表 */}
<div style={{
fontSize: 14, fontWeight: 600, color: T.tx, marginBottom: 10, paddingLeft: 2,
}}>
历史读数
</div>
<div style={{
background: T.card, borderRadius: T.rSm, overflow: 'hidden',
boxShadow: '0 1px 4px rgba(45,42,38,0.06)',
}}>
{readings.slice(1).map((r, i) => (
<div key={i} style={{
display: 'flex', alignItems: 'center', padding: '12px 16px',
borderBottom: i < readings.length - 2 ? `1px solid ${T.bdL}` : 'none',
}}>
<div style={{ width: 90, fontSize: 14, color: T.tx2 }}>{r.type}</div>
<div style={{ flex: 1, display: 'flex', alignItems: 'baseline', gap: 3 }}>
<span style={{ fontFamily: T.serif, fontSize: 20, fontWeight: 700, color: T.tx }}>{r.value}</span>
<span style={{ fontSize: 12, color: T.tx3 }}>{r.unit}</span>
</div>
<div style={{ fontSize: 12, color: T.tx3 }}>{r.time}</div>
</div>
))}
</div>
<div style={{
textAlign: 'center', marginTop: 12, fontSize: 12, color: T.tx3,
}}>
已采集 {readings.length} 条数据
</div>
{/* 操作按钮 */}
<div style={{ display: 'flex', gap: 10, marginTop: 20 }}>
<div style={{
flex: 1, background: T.pri, borderRadius: T.rSm,
padding: '14px', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
boxShadow: `0 4px 16px rgba(196, 98, 58, 0.3)`,
}}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<span style={{ color: '#fff', fontSize: 16, fontWeight: 600 }}>上传数据</span>
</div>
<div style={{
width: 52, background: T.danL, borderRadius: T.rSm,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M18 6L6 18M6 6l12 12" stroke={T.dan} strokeWidth="2" strokeLinecap="round"/>
</svg>
</div>
</div>
</div>
</div>
);
}
// ─── 屏幕六:同步完成 ───
function DoneScreen() {
return (
<div style={{ height: '100%', background: T.bg, display: 'flex', flexDirection: 'column' }}>
<NavBar title="设备同步" dark />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '24px 20px 100px' }}>
{/* 成功图标 */}
<div style={{
width: 80, height: 80, borderRadius: '50%', background: T.accL,
display: 'flex', alignItems: 'center', justifyContent: 'center',
marginBottom: 24,
}}>
<CheckIcon size={44} color={T.acc} />
</div>
<div style={{ fontFamily: T.serif, fontSize: 24, fontWeight: 700, color: T.tx, marginBottom: 8 }}>
同步完成
</div>
<div style={{ fontSize: 15, color: T.tx3, textAlign: 'center', lineHeight: 1.6 }}>
数据已安全上传至健康管理平台
</div>
{/* 统计卡片 */}
<div style={{
display: 'flex', gap: 12, marginTop: 24, width: '100%',
}}>
<div style={{
flex: 1, background: T.card, borderRadius: T.rSm, padding: '16px',
textAlign: 'center', border: `1px solid ${T.bdL}`,
}}>
<div style={{ fontFamily: T.serif, fontSize: 28, fontWeight: 700, color: T.pri }}>5</div>
<div style={{ fontSize: 12, color: T.tx3, marginTop: 4 }}>上传条数</div>
</div>
<div style={{
flex: 1, background: T.card, borderRadius: T.rSm, padding: '16px',
textAlign: 'center', border: `1px solid ${T.bdL}`,
}}>
<div style={{ fontFamily: T.serif, fontSize: 28, fontWeight: 700, color: T.tx }}>3</div>
<div style={{ fontSize: 12, color: T.tx3, marginTop: 4 }}>数据类型</div>
</div>
<div style={{
flex: 1, background: T.card, borderRadius: T.rSm, padding: '16px',
textAlign: 'center', border: `1px solid ${T.bdL}`,
}}>
<div style={{ fontFamily: T.serif, fontSize: 22, fontWeight: 700, color: T.acc }}>100%</div>
<div style={{ fontSize: 12, color: T.tx3, marginTop: 4 }}>成功率</div>
</div>
</div>
{/* 完成按钮 */}
<div style={{
width: '100%', background: T.pri, borderRadius: T.rSm,
padding: '16px', display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: `0 4px 16px rgba(196, 98, 58, 0.3)`,
marginTop: 32,
}}>
<span style={{ color: '#fff', fontSize: 17, fontWeight: 600 }}>完成</span>
</div>
</div>
</div>
);
}
// ─── 屏幕七:错误状态 ───
function ErrorScreen() {
return (
<div style={{ height: '100%', background: T.bg, display: 'flex', flexDirection: 'column' }}>
<NavBar title="设备同步" dark />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '24px 20px 100px' }}>
{/* 错误图标 */}
<div style={{
width: 80, height: 80, borderRadius: '50%', background: T.danL,
display: 'flex', alignItems: 'center', justifyContent: 'center',
marginBottom: 24,
}}>
<ErrorIcon size={44} color={T.dan} />
</div>
<div style={{ fontFamily: T.serif, fontSize: 22, fontWeight: 700, color: T.tx, marginBottom: 8 }}>
连接失败
</div>
<div style={{ fontSize: 14, color: T.tx3, textAlign: 'center', lineHeight: 1.6, maxWidth: 260 }}>
无法连接到 Mi Band 8请检查设备是否在范围内并重试
</div>
{/* 错误详情卡片 */}
<div style={{
width: '100%', background: T.card, borderRadius: T.rSm,
padding: '16px', marginTop: 24, border: `1px solid ${T.bdL}`,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke={T.tx3} strokeWidth="1.5"/>
<path d="M12 16v.01M12 8v4" stroke={T.tx3} strokeWidth="1.5" strokeLinecap="round"/>
</svg>
<span style={{ fontSize: 13, fontWeight: 500, color: T.tx }}>错误详情</span>
</div>
<div style={{ fontSize: 13, color: T.tx3, lineHeight: 1.7 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span>错误码</span><span style={{ color: T.tx }}>BLE_TIMEOUT</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span>设备</span><span style={{ color: T.tx }}>Mi Band 8</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>时间</span><span style={{ color: T.tx }}>09:15:32</span>
</div>
</div>
</div>
{/* 重试按钮 */}
<div style={{
width: '100%', background: T.pri, borderRadius: T.rSm,
padding: '16px', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
boxShadow: `0 4px 16px rgba(196, 98, 58, 0.3)`,
marginTop: 24,
}}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M23 4v6h-6M1 20v-6h6" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<span style={{ color: '#fff', fontSize: 17, fontWeight: 600 }}>重新扫描</span>
</div>
{/* 返回按钮 */}
<div style={{
width: '100%', borderRadius: T.rSm,
padding: '14px', display: 'flex', alignItems: 'center', justifyContent: 'center',
marginTop: 10, border: `1px solid ${T.bd}`,
}}>
<span style={{ color: T.tx2, fontSize: 16, fontWeight: 500 }}>返回</span>
</div>
</div>
</div>
);
}
// ─── 主渲染 ───
const screens = [
{ label: '空闲态', Component: IdleScreen },
{ label: '扫描中', Component: ScanningScreen },
{ label: '设备列表', Component: DeviceListScreen },
{ label: '连接中', Component: ConnectingScreen },
{ label: '已连接', Component: ConnectedScreen },
{ label: '同步完成', Component: DoneScreen },
{ label: '错误状态', Component: ErrorScreen },
];
function App() {
return (
<div className="screens">
{screens.map(({ label, Component }) => (
<div className="screen-wrap" key={label}>
<div className="screen-label">{label}</div>
<IosFrame width={360} height={780}>
<Component />
</IosFrame>
</div>
))}
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>

View File

@@ -0,0 +1,12 @@
prototype: mp-device-sync-redesign.html
source: docs/design/mp-device-sync-redesign.html
variant: patient
generated_at: "2026-05-23T12:00:00+08:00"
tokens:
matched: 23
unmatched: 2
components:
total: 12
mapped: 8
new: 2
interactions: 9

View File

@@ -0,0 +1,246 @@
# 设备同步页面 设计规格
> 来源: mp-device-sync-redesign.html | 平台: 小程序(患者端) | 页面数: 7 | 生成: 2026-05-23
## 页面索引
| 页面 | 截图 | 路由 |
|------|------|------|
| 空闲态 | ![空闲态](./screenshots/screen-1.png) | pages/pkg-health/device-sync/index |
| 扫描中 | ![扫描中](./screenshots/screen-2.png) | pages/pkg-health/device-sync/index |
| 设备列表 | ![设备列表](./screenshots/list.png) | pages/pkg-health/device-sync/index |
| 连接中 | ![连接中](./screenshots/screen-4.png) | pages/pkg-health/device-sync/index |
| 已连接 | ![已连接](./screenshots/screen-5.png) | pages/pkg-health/device-sync/index |
| 同步完成 | ![同步完成](./screenshots/screen-6.png) | pages/pkg-health/device-sync/index |
| 错误状态 | ![错误状态](./screenshots/screen-7.png) | pages/pkg-health/device-sync/index |
## 一、Token 映射
| 原型值 | 项目 Token | 状态 |
|--------|-----------|------|
| T.pri (#C4623A) | --tk-pri | ✅ |
| T.priL (#F0DDD4) | --tk-pri-l | ✅ |
| T.priD (#8B3E1F) | --tk-pri-d | ✅ |
| T.bg (#F5F0EB) | $bg SCSS 变量 | ⚠️ 无 CSS Token直接用 $bg |
| T.card (#FFFFFF) | --tk-card-bg ($card) | ✅ |
| T.surface (#EDE8E2) | --tk-card-bg (≈) | ⚠️ 近似,用 $surface-alt SCSS 变量 |
| T.tx (#2D2A26) | $tx SCSS 变量 | ⚠️ 无 CSS Token直接用 $tx |
| T.tx2 (#5A554F) | $tx2 SCSS 变量 | ⚠️ 无 CSS Token直接用 $tx2 |
| T.tx3 (#78716C) | --tk-text-secondary ($tx3) | ✅ |
| T.bd (#E8E2DC) | $bd SCSS 变量 | ⚠️ 无 CSS Token直接用 $bd |
| T.bdL (#F0EBE5) | $bd-l SCSS 变量 | ⚠️ 无 CSS Token |
| T.acc (#5B7A5E) | $acc SCSS 变量 | ⚠️ 无 CSS Token |
| T.accL (#E8F0E8) | $acc-l SCSS 变量 | ⚠️ 无 CSS Token |
| T.wrn (#C4873A) | $wrn SCSS 变量 | ⚠️ 无 CSS Token |
| T.wrnL (#FFF3E0) | $wrn-l SCSS 变量 | ⚠️ 无 CSS Token |
| T.dan (#B54A4A) | $dan SCSS 变量 | ⚠️ 无 CSS Token |
| T.danL (#FDEAEA) | $dan-l SCSS 变量 | ⚠️ 无 CSS Token |
| T.r (16) | --tk-card-radius ($r) | ✅ |
| T.rSm (12) | $r-sm SCSS 变量 | ⚠️ 无 CSS Token |
| T.rXs (8) | $r-xs SCSS 变量 | ⚠️ 无 CSS Token |
| T.serif (Georgia...) | 字体栈 | ❌ 不映射,直接硬编码 |
| T.sans (-apple-system...) | 字体栈 | ❌ 不映射,直接硬编码 |
> 状态标记: ✅ confirmed 直接使用 | ⚠️ pending 需 SCSS 变量 | ❌ unmatched 需硬编码
## 二、页面结构
### 1. 空闲态idle
![空闲态](./screenshots/screen-1.png)
布局层级(从上到下):
- **NavBar** — 深色主色背景,标题"设备同步"
- **Hero 区域** — 主色渐变背景135deg pri→priD包含
- 蓝牙图标72px 圆形,半透明白底)
- 标题"智能设备同步"serif 22px 700
- 副标题14px 0.75 白色透明度)
- **支持设备** — 三列标签(心率手环/血压计/血糖仪),每个含 SVG 图标
- **上次同步卡片** — ContentCard 样式,左侧绿色勾选图标 + 时间 + 右侧数据量 badge
- **待上传提示** — 黄色背景警告条($wrnL三角感叹号图标
- **扫描按钮** — 全宽主色按钮,蓝牙图标 + "扫描附近设备"
### 2. 扫描中scanning
![扫描中](./screenshots/screen-2.png)
布局层级:
- **NavBar** — 同上
- **居中脉冲区域**
- 三层脉冲圆环CSS animation: pulse-ring外层→中层→内层递进
- 中心 80px 圆形蓝牙图标($priL 底色)
- **标题** — serif 20px "正在搜索设备..."
- **副文本** — 14px $tx3 提示文字
- **进度条** — 180px 宽,渐变填充 $priL→$pri
- **计时文字** — 12px "已用时 6 秒"
### 3. 设备列表found
![设备列表](./screenshots/list.png)
布局层级:
- **NavBar** — 同上
- **结果头部** — 左侧"发现 N 台设备"标题 + 右侧"重新扫描"链接(含刷新图标)
- **设备卡片列表**×3— 每张卡片含:
-44px 圆角方块图标($priL 底色 + 蓝牙 SVG
-设备名16px 600+ 适配器类型12px $tx3
-信号强度条4 级竖条) + 箭头
- **未发现设备提示** — 虚线边框卡片,搜索图标 + 提示文字
### 4. 连接中connecting
![连接中](./screenshots/screen-4.png)
布局层级:
- **NavBar** — 同上
- **居中动画区域**
- 100px 旋转环border-top-color: $priCSS animation: connect-spin
- 60px 中心圆形蓝牙图标
- **标题** — serif 18px "正在连接 {设备名}"
- **副文本** — "正在进行蓝牙配对..."
- **步骤指示器** — 三点一线:发现设备(✓) → 连接中(●脉冲) → 同步数据(○)
### 5. 已连接connected
![已连接](./screenshots/screen-5.png)
布局层级:
- **NavBar** — 同上
- **连接状态卡片** — 绿色渐变背景acc→#4A6B4E),蓝牙图标 + 设备名 + "实时" badge
- **最新读数高亮卡片** — 大卡片r=16 圆角 + shadow
- 52px 心形图标
- 类型+时间小字
- 数值serif 36px 700+ 单位
- **历史读数列表** — 标题 + 表格行(类型/数值/时间),每行 12px 分隔线
- **采集计数** — 居中小字
- **操作按钮行** — 左侧全宽"上传数据"主色按钮 + 右侧 52px 红色断开按钮
### 6. 同步完成done
![同步完成](./screenshots/screen-6.png)
布局层级:
- **NavBar** — 同上
- **居中成功区域**
- 80px 绿色圆形勾选图标
- 标题"同步完成"serif 24px 700
- 副文本"数据已安全上传至健康管理平台"
- **三列统计卡片** — 上传条数(5)/数据类型(3)/成功率(100%)
- **完成按钮** — 全宽主色按钮
### 7. 错误状态error
![错误状态](./screenshots/screen-7.png)
布局层级:
- **NavBar** — 同上
- **居中错误区域**
- 80px 红色圆形叉号图标
- 标题"连接失败"serif 22px 700
- 错误描述文字
- **错误详情卡片** — 含错误码/设备/时间三行键值对
- **重试按钮** — 全宽主色按钮,含刷新图标
- **返回按钮** — 描边按钮
## 三、组件映射
| 原型元素 | 推荐组件 | 来源 | 备注 |
|----------|---------|------|------|
| 页面外壳 | PageShell | @components/ui/PageShell | padding="none"NavBar 自带 |
| 连接状态卡片 | ContentCard | @components/ui/ContentCard | variant="elevated",绿色渐变背景自定义 |
| 成功结果卡片 | ContentCard | @components/ui/ContentCard | variant="elevated",居中布局 |
| 错误详情卡片 | ContentCard | @components/ui/ContentCard | variant="outlined" |
| 扫描按钮/上传按钮 | PrimaryButton | @components/ui/PrimaryButton | size="large"full width |
| 断开连接按钮 | — | 自定义 | 红色小方块图标按钮 |
| 返回按钮 | SecondaryButton | @components/ui/SecondaryButton | — |
| 设备类型标签 | — | 自定义 DeviceTypeTag | 小图标+文字,$bdL 边框 |
| 信号强度 | — | 自定义 SignalBars | 4 级竖条 |
| 上次同步信息 | ListItem | @components/ui/ListItem | leftIcon + title + subtitle + extra |
| 历史读数行 | InfoRow | @components/ui/InfoRow | label + value + last |
| 待上传警告 | AlertCard | @components/ui/AlertCard | variant="bordered",黄色 |
> ⚠️ **需新建**: SignalBars — 4 级竖条信号强度指示器20 行以内小组件)
> ⚠️ **需新建**: DeviceTypeTag — 设备类型标签(图标+文字,已非常简单,可直接内联)
## 四、交互规格
| 元素 | 交互 | 触发 | 反馈 | 备注 |
|------|------|------|------|------|
| 扫描按钮 | 调用 handleScan | onClick | 按钮变灰+loading状态→scanning | 触发 BLE 扫描 |
| 设备卡片 | 调用 handleConnect | onClick | 状态→connecting显示旋转动画 | 传递选中的 BLEDevice |
| 重新扫描链接 | 调用 handleScan | onClick | 同扫描按钮 | 刷新设备列表 |
| 上传数据按钮 | 调用 handleSync | onClick | 状态→syncing → done/error | 上传采集数据到后端 |
| 断开连接按钮 | 调用 handleDisconnect | onClick | 断开 BLE状态→idle | 清空 liveReadings |
| 完成按钮 | handleDisconnect + navigateBack | onClick | 返回上一页 | 如果 returnTo=input 则回填 Storage |
| 重试按钮 | handleScan | onClick | 重新扫描 | 从 error 恢复 |
| 返回按钮 | Taro.navigateBack | onClick | 返回上一页 | 错误状态 |
| 实时数据面板 | 被动更新 | BLE 通知 | 新数据插入列表顶部,数值动画 | useBLEManager hook 驱动 |
## 五、状态变体
- **idle**: 默认状态,展示 Hero + 设备类型 + 上次同步 + 扫描按钮
- **scanning**: 脉冲动画 + 进度条 + 计时,不可操作(无按钮)
- **found**: 设备列表 + 重新扫描链接,点击设备进入 connecting
- **connecting**: 旋转环动画 + 步骤指示器,不可操作
- **connected**: 绿色连接状态卡 + 实时数据面板 + 上传/断开按钮
- **done**: 成功图标 + 统计卡片 + 完成按钮
- **error**: 错误图标 + 错误详情 + 重试/返回按钮
- **syncing**: 复用 scanning 的加载态样式,文字改为"正在上传数据..."
## 六、样式清单
### 关键样式参数
```
/* Hero 渐变 */
background: linear-gradient(135deg, $pri 0%, $pri-d 100%)
padding: 32px 20px 28px
/* 脉冲圆环 */
animation: pulse-ring 2s ease-out infinite
三层: 140px / 110px / 80px (center)
/* 旋转环 */
animation: connect-spin 1s linear infinite
border-top-color: $pri
/* 最新读数数值 */
font-family: $serif; font-size: 36px; font-weight: 700
/* 连接状态卡片渐变 */
background: linear-gradient(135deg, $acc 0%, #4A6B4E 100%)
/* 信号条 */
4 根竖条: height [4, 7, 10, 13]px, width: 3px, gap: 2px
活跃色: $acc, 非活跃: $bd
/* 主按钮 */
background: $pri; border-radius: $r-sm; padding: 16px;
box-shadow: 0 4px 16px rgba(196, 98, 58, 0.3)
```
### 字号映射
| 原型字号 | Token | 用途 |
|---------|-------|------|
| 36px | 超大数值,直接用 serif bold | 最新读数数值 |
| 28px | --tk-font-h1 | 统计卡片数值 |
| 24px | — | 成功/错误标题 |
| 22px | --tk-font-h2 | Hero 标题、连接中标题 |
| 20px | — | 历史读数数值 |
| 18px | --tk-font-body-lg | NavBar 标题、按钮文字 |
| 17px | — | 主按钮文字 |
| 16px | --tk-font-body | 设备名、按钮文字 |
| 15px | — | 完成页副文本 |
| 14px | --tk-font-body-sm | 副文本、描述、列表类型 |
| 13px | --tk-font-cap | 标签文字、小字 |
| 12px | — | 时间、提示 |
---
> 此规格由 design-handoff skill 自动生成。LLM 实施时请:
> 1. 先阅读截图建立视觉印象
> 2. 按 Token 映射表使用项目 Token✅ 标记的直接用,⚠️ 用 SCSS 变量)
> 3. 优先使用"组件映射"中列出的已有组件
> 4. 参考"交互规格"实现对应的交互逻辑
> 5. "需新建"的组件参考截图和布局描述从头实现