This commit is contained in:
2026-09-24 16:25:22 +08:00
commit 7428184f01
1198 changed files with 314515 additions and 0 deletions
+262
View File
@@ -0,0 +1,262 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>tabbar 角标遮挡 icon · 方案对照</title>
<style>
/* 375 逻辑宽:1rpx = 0.5px(浏览器不认 rpx,必须折算)。
不设 box-sizing,与 uvue view 的默认(content-box)一致 ——
这一点很关键:min-width 是「内容盒」最小值,角标实际宽 = 18 + 10 = 28px。 */
body {
margin: 0;
padding: 12px;
background: #F3F4F6;
color: #1F2937;
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
}
/* ===== 库组件 x-tabbar / x-badge 真实样式(抄自 uni_modules 源码) ===== */
.xTabbar {
width: 100%;
}
.xTabbarWrap {
display: flex;
flex-direction: row;
justify-content: space-around;
align-items: flex-start;
width: 100%;
}
.xTabbarItem {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
flex: 1;
}
/* view 在 uvue 上默认是 flex column */
.xBadge {
display: flex;
flex-direction: column;
align-items: center;
overflow: visible;
}
.xBadgeWrap {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
overflow: visible;
position: relative;
}
.xBadge-countAndLabel {
position: absolute;
z-index: 3;
border-radius: 100px;
padding: 0rpx 4px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
/* 角标尺寸 = hr-design.css 的 !important 覆盖值(36rpx 高 / 22.4rpx 字 / padding 10rpx */
.xBadge-countAndLabel {
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
}
.xBadge-countAndLabelText {
font-size: 11.2px;
line-height: 1;
font-weight: bold;
}
/* hr-design.css.xBadge { padding: 0 !important } —— 去掉库为「半出血」预留的内边距 */
.xBadge {
padding: 0;
}
/* ---- A:库默认 + 取消出血(= 本轮修复前的状态,right:0 ---- */
.A .xBadge-countAndLabel {
top: 0;
right: 0;
left: auto;
transform: none;
}
/* ---- B:上一轮的错解(右移 20rpx)—— 假设角标宽 18px 才会成立,实际 28px ---- */
.B .xBadge-countAndLabel {
top: -2px;
right: -10px;
left: auto;
transform: none;
}
/* ---- C:本轮定稿(左锚定 44rpx = 22px,宽度无关) ---- */
.C .xBadge-countAndLabel {
top: -2px;
left: 22px;
right: auto;
transform: none;
}
/* 库默认「半出血」(仅供对照,需要上方留白) */
.D .xBadge-countAndLabel {
top: 5px;
right: 0;
left: auto;
transform: translate(50%, -50%);
}
.frame {
width: 375px;
background: #fff;
border-radius: 10px;
box-shadow: 0 1px 3px rgba(15, 23, 42, .1);
margin-bottom: 8px;
}
.frame .tip {
font-size: 10.5px;
color: #374151;
padding: 6px 12px 0;
line-height: 1.5;
}
.sim {
width: 375px;
height: 76px;
background: #F9FAFB;
display: flex;
align-items: center;
}
/* item 框辅助线:用于判断角标是否被裁 */
.sim .xTabbarItem {
outline: 1px dashed #93C5FD;
outline-offset: -1px;
}
h2 {
font-size: 13px;
margin: 0 0 7px;
}
pre {
background: #111827;
color: #A7F3D0;
font-size: 10.5px;
padding: 8px 10px;
border-radius: 8px;
margin: 0 0 12px;
line-height: 1.55;
white-space: pre-wrap;
}
</style>
</head>
<body>
<h2>底部 tabbar 角标 · 定位方案对照(375 逻辑宽,1rpx = 0.5px</h2>
<pre id="out"></pre>
<div id="host"></div>
<script>
var ICON_PX = 24; /* hr-tabbar 传 icon-size="48rpx" = 24px */
var FS = 13; /* hr-tabbar 传 font-size="26rpx" = 13px */
/* 按 x-tabbar 的真实结构生成一屏 tabbar:
.xTabbarItem > .xBadge > .xBadgeWrap > [角标, icon盒(30px,padding 0 4px), 文字] */
function tabbar(v) {
var items = v.count === 3
? [['首页', '#9CA3AF'], ['事件', '#2563EB'], ['我的', '#9CA3AF']]
: [['扫码', '#9CA3AF'], ['仓库', '#9CA3AF'], ['事件', '#2563EB'], ['消息', '#9CA3AF'], ['我', '#9CA3AF']];
var badgeIdx = v.count === 3 ? 1 : 2;
var out = [];
items.forEach(function(it, i) {
var badge = i === badgeIdx
? '<div class="xBadge-countAndLabel" style="background:#DC2626">' +
'<span class="xBadge-countAndLabelText" style="color:#fff">' + v.label + '</span></div>'
: '';
out.push(
'<div class="xTabbarItem" style="height:60px">' +
'<div class="xBadge">' +
'<div class="xBadgeWrap">' + badge +
/* icon 盒:库内写死 height:30px + padding:0px 4px */
'<div style="height:30px;padding:0px 4px">' +
'<span class="ic" style="display:block;width:' + ICON_PX + 'px;height:' + ICON_PX +
'px;background:' + it[1] + ';border-radius:6px"></span>' +
'</div>' +
/* 文字:库内内联 fontSize=26rpx + paddingTop:4px */
'<span class="lb" style="font-size:' + FS + 'px;color:' + it[1] + ';padding-top:4px">' +
it[0] + '</span>' +
'</div>' +
'</div>' +
'</div>');
});
return '<div class="xTabbar ' + v.cls + '"><div class="xTabbarWrap">' + out.join('') + '</div></div>';
}
var variants = [
{ cls: 'A', count: 3, label: '4', title: 'A 本轮修复前(right:0 + 取消出血)' },
{ cls: 'B', count: 3, label: '4', title: 'B 上一轮的错解(right:-20rpx,按角标宽 18px 推算)' },
{ cls: 'C', count: 3, label: '4', title: 'C 本轮定稿(left:44rpx3 Tab' },
{ cls: 'C', count: 5, label: '4', title: 'C 本轮定稿(left:44rpx5 Tab 仓管端)' },
{ cls: 'C', count: 5, label: '99', title: 'C 本轮定稿(两位数角标,验证向右生长不回头)' },
{ cls: 'D', count: 3, label: '4', title: 'D 库默认「半出血」(对照:需要上方留白)' }
];
var html = '';
variants.forEach(function(v, i) {
html += '<div class="frame"><div class="tip">' + v.title + '</div><div class="sim">' +
tabbar(v).replace('class="xTabbar ' + v.cls + '"', 'class="xTabbar ' + v.cls + '" id="tb' + i + '"') +
'</div></div>';
});
document.getElementById('host').innerHTML = html;
function rect(el) {
var r = el.getBoundingClientRect();
return { l: +r.left.toFixed(1), t: +r.top.toFixed(1), r: +r.right.toFixed(1), b: +r.bottom.toFixed(1), w: +r.width.toFixed(1), h: +r.height.toFixed(1) };
}
function report(v, i) {
var root = document.getElementById('tb' + i);
var badgeIdx = v.count === 3 ? 1 : 2;
var item = root.querySelectorAll('.xTabbarItem')[badgeIdx];
var wrap = rect(item.querySelector('.xBadgeWrap'));
var bx = rect(item.querySelector('.xBadge'));
var ic = rect(item.querySelector('.ic'));
var bd = rect(item.querySelector('.xBadge-countAndLabel'));
var it = rect(item);
var ox = Math.max(0, Math.min(ic.r, bd.r) - Math.max(ic.l, bd.l));
var oy = Math.max(0, Math.min(ic.b, bd.b) - Math.max(ic.t, bd.t));
var clipTop = Math.max(0, it.t - bd.t);
var clipBot = Math.max(0, bd.b - it.b);
var clipR = Math.max(0, bd.r - it.r);
return v.title + '\n' +
' item(60px) ' + JSON.stringify(it) + '\n' +
' .xBadgeWrap ' + JSON.stringify(wrap) + ' ← 角标的定位基准(半宽 ' + (wrap.w / 2).toFixed(1) + 'px\n' +
' .xBadge ' + JSON.stringify(bx) + (bx.t < it.t - 0.5 || bx.b > it.b + 0.5 ? ' ⚠ 超出 item' : ' ✓ 在 item 内') + '\n' +
' icon ' + JSON.stringify(ic) + '\n' +
' 角标 ' + JSON.stringify(bd) + ' ← 实际宽 ' + bd.w + 'px\n' +
' ⇒ 角标压住 icon' + ox.toFixed(1) + ' × ' + oy.toFixed(1) + ' px' +
'(横向占 icon 的 ' + (ox / ic.w * 100).toFixed(0) + '%\n' +
' ⇒ 裁切检查:上溢 ' + clipTop.toFixed(1) + ' / 下溢 ' + clipBot.toFixed(1) + ' / 右溢 ' + clipR.toFixed(1) + ' px' +
((clipTop === 0 && clipBot === 0 && clipR === 0) ? ' ✓ 不会被裁' : ' ⚠ 有被裁风险');
}
var out = [];
variants.forEach(function(v, i) { out.push(report(v, i)); });
document.getElementById('out').textContent = out.join('\n\n');
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

+143
View File
@@ -0,0 +1,143 @@
<!DOCTYPE html>
<html><head><meta charset="utf-8"><style>
/* 375 逻辑宽:1rpx = 0.5px */
* { box-sizing: border-box; }
body { margin:0; padding:16px; width:375px; background:#F3F4F6;
font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif; color:#1F2937; }
h1 { font-size:15px; margin:0 0 4px; }
.sub { font-size:10px; color:#6B7280; margin-bottom:14px; line-height:1.6; }
.card { background:#fff; border-radius:9px; padding:14px 14px 10px; margin-bottom:14px;
box-shadow:0 1px 3px rgba(15,23,42,.08); }
.card h2 { font-size:12px; margin:0 0 12px; color:#374151; }
.tag { display:inline-block; font-size:10px; padding:1px 6px; border-radius:5px; margin-left:6px; }
.tag-bad { background:#FEF2F2; color:#DC2626; }
.tag-ok { background:#EFF6FF; color:#2563EB; }
/* ---- 库组件 x-badge 自身样式 ---- */
.xBadge-countAndLabel { position:absolute; z-index:3; border-radius:100px; padding:0px 4px;
display:flex; flex-direction:row; align-items:center; justify-content:center; }
.xBadge-countAndLabelText { line-height:1.5; text-align:center; }
/* ---- 改后:与基准一致(rpx 已折算)---- */
.xBadge-countAndLabel.fixed { min-width:18px; height:18px; padding:0 5px; border-radius:9px; }
.xBadge-countAndLabel.fixed .xBadge-countAndLabelText { font-size:11.2px; line-height:1; font-weight:bold; }
/* ---- 自写基准角标 .hr-badge ---- */
.hr-badge { min-width:18px; height:18px; padding-left:5px; padding-right:5px; border-radius:9px;
background-color:#DC2626; display:flex; flex-direction:row; align-items:center; justify-content:center; }
.hr-badge-t { font-size:11.2px; font-weight:bold; color:#fff; }
.row { display:flex; flex-direction:row; align-items:center; }
.grow { flex:1; }
.cmp { display:flex; flex-direction:row; align-items:flex-end; gap:34px; padding:6px 4px 2px; }
.cmp-item { display:flex; flex-direction:column; align-items:center; }
.cmp-item .lbl { font-size:10px; color:#6B7280; margin-top:8px; text-align:center; line-height:1.5; }
.stage { width:64px; height:40px; display:flex; align-items:center; justify-content:center; }
/* 列表行 */
.lrow { display:flex; flex-direction:row; align-items:center; padding:11px 0; border-bottom:1px solid #F3F4F6; }
.lrow:last-child { border-bottom:0; }
.lic { width:30px; height:30px; border-radius:8px; background:#FFF7ED; color:#D97706;
display:flex; align-items:center; justify-content:center; font-size:15px; margin-right:9px; }
.lt { font-size:14px; }
.ls { font-size:11px; color:#9CA3AF; margin-top:2px; }
.lval { font-size:15px; color:#D1D5DB; margin-left:6px; }
/* 底部 tabbar 预览 */
.tb { display:flex; flex-direction:row; background:#fff; border-top:1px solid #F3F4F6; border-radius:0 0 9px 9px; }
.tb-i { flex:1; display:flex; flex-direction:column; align-items:center; padding:8px 0 6px; position:relative; }
.tb-ic { width:17px; height:17px; border-radius:5px; background:#D1D5DB; }
.tb-ic.on { background:#2563EB; }
.tb-l { font-size:13px; color:#9CA3AF; margin-top:3px; }
.tb-l.on { color:#2563EB; }
.tb-b { position:absolute; top:2px; left:50%; margin-left:2px; }
.tb-badge-wrap { position:relative; }
.tb-badge-wrap .b { position:absolute; top:0; right:0; transform:translate(50%,-50%); }
/* 顶部 tabs 预览 */
.tabs { display:flex; flex-direction:row; background:#fff; border-radius:9px 9px 0 0; border-bottom:1px solid #F3F4F6; }
.tab-i { flex:1; display:flex; align-items:center; justify-content:center; height:44px; position:relative; }
.tab-t { font-size:14px; color:#6B7280; }
.tab-t.on { color:#2563EB; font-weight:bold; }
.tab-b { position:absolute; top:9px; left:50%; margin-left:16px; }
.tab-b .b { position:absolute; top:0; left:0; transform:translate(0,-50%); }
</style></head>
<body>
<h1>角标尺寸统一 · 效果对照</h1>
<div class="sub">基准 = 「我的」页 · 消息中心 item 右侧角标(高 36rpx / 圆角 18rpx / 字号 22.4rpx)· 375 逻辑宽按 1rpx = 0.5px 渲染</div>
<div class="card">
<h2>① 角标特写:改前 / 改后 / 基准</h2>
<div class="cmp">
<div class="cmp-item">
<div class="stage">
<div style="position:relative">
<div class="xBadge-countAndLabel" style="background:#DC2626;left:0;top:0;position:relative">
<span class="xBadge-countAndLabelText" style="color:#fff;font-size:9px">4</span>
</div>
</div>
</div>
<div class="lbl">改前 · xBadge 默认<span class="tag tag-bad">偏小</span><br>26 × 13.5 px</div>
</div>
<div class="cmp-item">
<div class="stage">
<div style="position:relative">
<div class="xBadge-countAndLabel fixed" style="background:#DC2626;left:0;top:0;position:relative">
<span class="xBadge-countAndLabelText" style="color:#fff">4</span>
</div>
</div>
</div>
<div class="lbl">改后 · 组件库角标<span class="tag tag-ok">已统一</span><br>28 × 18 px</div>
</div>
<div class="cmp-item">
<div class="stage"><div class="hr-badge"><span class="hr-badge-t">4</span></div></div>
<div class="lbl">基准 · 列表行自写角标<br>28 × 18 px</div>
</div>
</div>
</div>
<div class="card" style="padding:0;overflow:hidden">
<div class="tabs">
<div class="tab-i"><span class="tab-t on">进行中</span>
<div class="tab-b"><div class="xBadge-countAndLabel fixed b" style="background:#6B7280"><span class="xBadge-countAndLabelText" style="color:#fff">2</span></div></div>
</div>
<div class="tab-i"><span class="tab-t">待接收</span>
<div class="tab-b"><div class="xBadge-countAndLabel fixed b" style="background:#DC2626"><span class="xBadge-countAndLabelText" style="color:#fff">4</span></div></div>
</div>
<div class="tab-i"><span class="tab-t">历史</span>
<div class="tab-b"><div class="xBadge-countAndLabel fixed b" style="background:#6B7280"><span class="xBadge-countAndLabelText" style="color:#fff">7</span></div></div>
</div>
</div>
<div style="padding:10px 14px 4px;font-size:12px;color:#374151">② 事件列表页 · 顶部切换栏(x-tabs 角标)<span class="tag tag-ok">已统一</span></div>
<div class="tb" style="border-radius:0">
<div class="tb-i"><div class="tb-ic"></div><span class="tb-l">首页</span></div>
<div class="tb-i">
<div class="tb-badge-wrap"><div class="tb-ic on"></div>
<div class="xBadge-countAndLabel fixed b" style="background:#DC2626"><span class="xBadge-countAndLabelText" style="color:#fff">4</span></div>
</div>
<span class="tb-l on">事件</span>
</div>
<div class="tb-i"><div class="tb-ic"></div><span class="tb-l">我的</span></div>
</div>
<div style="padding:8px 14px 12px;font-size:12px;color:#374151">③ 事件页 · 底部 tabbarx-tabbar 角标)<span class="tag tag-ok">已统一</span></div>
</div>
<div class="card">
<h2>④ 与基准同源的两处列表行角标</h2>
<div class="lrow">
<div class="lic"></div>
<div class="grow"><div class="lt">消息中心</div><div class="ls">4 条未读</div></div>
<div class="hr-badge"><span class="hr-badge-t">4</span></div>
<span class="lval"></span>
</div>
<div class="lrow">
<div class="lic"></div>
<div class="grow"><div class="lt">事件告警</div><div class="ls">新的派单会推送给你</div></div>
<div class="hr-badge"><span class="hr-badge-t">4</span></div>
<span class="lval"></span>
</div>
</div>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

+291
View File
@@ -0,0 +1,291 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>批量转运 · 点击开启相机 / 接收医院 hr-sheet 预览</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #EEF2F7; font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
color: #1F2937; padding: 28px 24px 40px;
}
h1 { font-size: 18px; font-weight: 700; margin-bottom: 6px; }
.sub { font-size: 13px; color: #6B7280; margin-bottom: 22px; }
.row { display: flex; gap: 28px; align-items: flex-start; flex-wrap: wrap; }
.col { display: flex; flex-direction: column; gap: 10px; }
.cap { font-size: 12.5px; color: #475569; font-weight: 600; }
.cap span { color: #2563EB; }
.phone {
width: 375px; height: 812px; background: #F3F4F6; border-radius: 26px;
overflow: hidden; position: relative; box-shadow: 0 14px 40px rgba(15,23,42,.16);
display: flex; flex-direction: column;
}
.nav {
height: 88px; background: #fff; display: flex; align-items: flex-end;
padding: 0 16px 12px; position: relative; flex: none;
}
.nav-t { position: absolute; left: 0; right: 0; bottom: 12px; text-align: center; font-size: 16px; font-weight: 700; }
.nav-r { margin-left: auto; font-size: 14.6px; color: #6B7280; }
/* ---------- 顶部取景区 ---------- */
.cm { background: #0B1220; padding: 8px 14px 9px; flex: none; }
.cm-view {
height: 108px; border-radius: 14px; overflow: hidden; position: relative;
background: #10192A; display: flex; align-items: center; justify-content: center;
}
.cm-idle { display: flex; flex-direction: column; align-items: center; justify-content: center; }
.cm-idle svg { display: block; }
.cm-idle-t { font-size: 14px; font-weight: 700; color: #E5E7EB; margin-top: 7px; }
.cm-idle-s { font-size: 11.2px; color: rgba(148,163,184,.92); margin-top: 4px; }
.cm-cnt {
position: absolute; left: 10px; top: 10px; background: rgba(17,24,39,.62);
border-radius: 7px; padding: 3px 8px; font-size: 11.8px; font-weight: 700; color: #fff;
}
.cm-cnt b { color: #60A5FA; }
.cm-frame { width: 86px; height: 86px; border-radius: 16px; background: rgba(255,255,255,.03); position: relative; }
.cm-cf { position: absolute; width: 22px; height: 22px; border: 3px solid #60A5FA; border-radius: 7px; }
.cm-cf.tl { left: 0; top: 0; border-right-width: 0; border-bottom-width: 0; }
.cm-cf.tr { right: 0; top: 0; border-left-width: 0; border-bottom-width: 0; }
.cm-cf.bl { left: 0; bottom: 0; border-right-width: 0; border-top-width: 0; }
.cm-cf.br { right: 0; bottom: 0; border-left-width: 0; border-top-width: 0; }
.cm-laser { position: absolute; left: 7px; right: 7px; top: 60px; height: 2px; border-radius: 2px; background: #93C5FD; }
.cm-acts { display: flex; margin-top: 9px; }
.cm-act {
flex: 1; height: 34px; border-radius: 9px; background: rgba(255,255,255,.10);
display: flex; align-items: center; justify-content: center; margin-right: 8px;
}
.cm-act:last-child { margin-right: 0; }
.cm-act.on { background: rgba(16,185,129,.20); }
.cm-act-t { font-size: 12.9px; font-weight: 700; color: rgba(255,255,255,.9); margin-left: 5px; }
.cm-act-t.on { color: #6EE7B7; }
/* ---------- 中部记录列表 ---------- */
.rec { flex: 1; padding: 8px 14px 0; overflow: hidden; }
.rec-h { display: flex; align-items: center; margin-bottom: 7px; }
.rec-h-t { font-size: 13.4px; font-weight: 700; }
.rec-h-n { font-size: 11.8px; color: #6B7280; margin-left: 6px; }
.rec-h-c { margin-left: auto; font-size: 11.8px; font-weight: 700; color: #DC2626; }
.rec-i {
background: #fff; border-radius: 12px; padding: 9px 12px; margin-bottom: 7px;
display: flex; align-items: center;
}
.wb { width: 4px; height: 26px; border-radius: 2px; margin-right: 9px; }
.rec-n { font-size: 13.4px; font-weight: 700; margin-right: 6px; }
.rec-c { font-size: 10.8px; color: #9CA3AF; margin-top: 2px; }
.lv { font-size: 10px; font-weight: 700; color: #fff; padding: 1px 6px; border-radius: 8px; }
.rec-x { margin-left: auto; font-size: 15px; color: #D1D5DB; }
/* ---------- 底部表单 ---------- */
.form { background: #fff; border-top: 1px solid #E5E7EB; border-radius: 16px 16px 0 0; padding: 11px 16px 14px; position: relative; flex: none; }
.lb { font-size: 12.9px; font-weight: 700; color: #6B7280; margin-bottom: 6px; }
.lb.g { margin-top: 9px; }
.pick {
display: flex; align-items: center; background: #F9FAFB; border: 1px solid #E5E7EB;
border-radius: 10px; padding: 10px 12px;
}
.pick-n { flex: 1; font-size: 14px; font-weight: 700; }
.pick-b { font-size: 11.2px; font-weight: 700; color: #059669; margin-right: 6px; }
.pick-b.no { color: #DC2626; }
.seg { display: flex; background: #E5E7EB; border-radius: 10px; padding: 2px; }
.seg-i { flex: 1; height: 34px; display: flex; align-items: center; justify-content: center; border-radius: 8px; }
.seg-i.on { background: #fff; }
.seg-t { font-size: 14.6px; color: #6B7280; }
.seg-t.on { color: #2563EB; font-weight: 700; }
.foot { display: flex; padding-right: 70px; }
.foot-c { flex: 1; margin-right: 8px; }
.foot-c:last-child { margin-right: 0; }
.field { background: #F9FAFB; border: 1px solid #E5E7EB; border-radius: 10px; padding: 8px 12px; }
.field-l { font-size: 10.6px; color: #9CA3AF; }
.field-i { font-size: 13.4px; font-weight: 600; margin-top: 2px; }
.send {
position: absolute; right: 16px; bottom: 16px; width: 56px; height: 56px; border-radius: 28px;
background: linear-gradient(to bottom right, #2563EB, #1D4ED8); display: flex; align-items: center;
justify-content: center; box-shadow: 0 6px 18px rgba(37,99,235,.42);
}
/* ---------- hr-sheet ---------- */
.mask { position: absolute; inset: 0; background: rgba(0,0,0,.45); }
.sheet {
position: absolute; left: 0; right: 0; bottom: 0; background: #fff;
border-radius: 18px 18px 0 0; padding: 14px 16px 20px;
}
.sheet-h { display: flex; align-items: center; margin-bottom: 10px; }
.sheet-h-b { font-size: 16.8px; font-weight: 700; }
.sheet-h-x { margin-left: auto; font-size: 18px; color: #9CA3AF; }
.opt { display: flex; align-items: center; padding: 13px 0; border-bottom: 1px solid #F3F4F6; }
.opt.last { border-bottom: 0; }
.opt-txt { flex: 1; display: flex; flex-direction: column; }
.opt-n { font-size: 15px; font-weight: 700; color: #1F2937; }
.opt-n.on { color: #2563EB; }
.opt-n.dim { color: #6B7280; }
.opt-m { font-size: 11.2px; color: #9CA3AF; margin-top: 3px; }
.opt-b { font-size: 11.2px; font-weight: 700; color: #059669; margin-right: 6px; }
.opt-b.no { color: #DC2626; }
.tick { color: #2563EB; font-size: 15px; font-weight: 700; }
.notes { margin-top: 24px; max-width: 780px; }
.notes h2 { font-size: 14px; margin-bottom: 8px; }
.notes ul { font-size: 13px; color: #475569; line-height: 1.9; padding-left: 18px; }
.notes code { background: #E2E8F0; padding: 1px 5px; border-radius: 4px; font-size: 12px; }
</style>
</head>
<body>
<h1>批量转运 · 顶部点击开启相机 + 接收医院 hr-sheet 弹层</h1>
<div class="sub">设计基准 375 × 812。左:进入页面(相机未启动,接收医院为折叠字段);右:点击「接收医院」整行后唤起底部弹层。</div>
<div class="row">
<!-- ============ 状态 A:相机未开启 ============ -->
<div class="col">
<div class="cap">状态 A · 初始态 — 取景区显示 <span>「点击开启摄像头」</span>,不再自动占有摄像头</div>
<div class="phone">
<div class="nav"><span class="nav-r">历史</span><span class="nav-t">批量转运</span></div>
<div class="cm">
<div class="cm-view">
<div class="cm-idle">
<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="#60A5FA" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 3h6l1.5 2H20a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h3.5L9 3z"/>
<circle cx="12" cy="12" r="3.4"/>
</svg>
<div class="cm-idle-t">点击开启摄像头</div>
<div class="cm-idle-s">对准伤员腕带二维码,自动连续识别</div>
</div>
<div class="cm-cnt">已识别 <b>4</b></div>
</div>
<div class="cm-acts">
<div class="cm-act">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M10 8.5l6 3.5-6 3.5z" fill="#fff" stroke="none"/></svg>
<span class="cm-act-t">开启扫码</span>
</div>
<div class="cm-act">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M7 9h.01M11 9h.01M15 9h.01M7 13h.01M11 13h.01M15 13h.01M7 17h10"/></svg>
<span class="cm-act-t">手动输入编号</span>
</div>
</div>
</div>
<div class="rec">
<div class="rec-h">
<span class="rec-h-t">已扫码伤员</span><span class="rec-h-n">4 / 12</span>
<span class="rec-h-c">清空</span>
</div>
<div class="rec-i"><div class="wb" style="background:#DC2626"></div><div><div style="display:flex;align-items:center"><span class="rec-n">王建国</span><span class="lv" style="background:#DC2626">危重</span></div><div class="rec-c">男 · 52 岁 · TRIAGE-20260922-0001</div></div><span class="rec-x"></span></div>
<div class="rec-i"><div class="wb" style="background:#F59E0B"></div><div><div style="display:flex;align-items:center"><span class="rec-n">李华</span><span class="lv" style="background:#F59E0B">重伤</span></div><div class="rec-c">男 · 35 岁 · TRIAGE-20260922-0003</div></div><span class="rec-x"></span></div>
<div class="rec-i"><div class="wb" style="background:#10B981"></div><div><div style="display:flex;align-items:center"><span class="rec-n">陈小梅</span><span class="lv" style="background:#10B981">轻伤</span></div><div class="rec-c">女 · 28 岁 · TRIAGE-20260922-0006</div></div><span class="rec-x"></span></div>
<div class="rec-i"><div class="wb" style="background:#F59E0B"></div><div><div style="display:flex;align-items:center"><span class="rec-n">赵国强</span><span class="lv" style="background:#F59E0B">重伤</span></div><div class="rec-c">男 · 41 岁 · TRIAGE-20260922-0008</div></div><span class="rec-x"></span></div>
</div>
<div class="form">
<div class="lb">接收医院</div>
<div class="pick">
<span class="pick-n">协和医院 · 急诊创伤中心</span>
<span class="pick-b">床位 2</span>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#9CA3AF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>
</div>
<div class="lb g">转运方式</div>
<div class="seg">
<div class="seg-i on"><span class="seg-t on">救护车 🚑</span></div>
<div class="seg-i"><span class="seg-t">直升机 🚁</span></div>
</div>
<div class="lb g">随车联系人</div>
<div class="foot">
<div class="foot-c"><div class="field"><div class="field-l">姓名</div><div class="field-i">刘伟</div></div></div>
<div class="foot-c"><div class="field"><div class="field-l">联系电话</div><div class="field-i">13800006621</div></div></div>
</div>
<div class="send"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M5 13l4 4L19 7"/></svg></div>
</div>
</div>
</div>
<!-- ============ 状态 B:弹层展开 ============ -->
<div class="col">
<div class="cap">状态 B · 点击「接收医院」— 唤起 <span>hr-sheet</span>(名称 + 距离,满员置灰不可选)</div>
<div class="phone">
<div class="nav"><span class="nav-r">历史</span><span class="nav-t">批量转运</span></div>
<div class="cm">
<div class="cm-view">
<div class="cm-frame">
<div class="cm-cf tl"></div><div class="cm-cf tr"></div><div class="cm-cf bl"></div><div class="cm-cf br"></div>
<div class="cm-laser"></div>
</div>
<div class="cm-cnt">已识别 <b>6</b></div>
</div>
<div class="cm-acts">
<div class="cm-act on">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#6EE7B7" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 8V6a2 2 0 0 1 2-2h2M16 4h2a2 2 0 0 1 2 2v2M20 16v2a2 2 0 0 1-2 2h-2M8 20H6a2 2 0 0 1-2-2v-2"/><path d="M4 12h16"/></svg>
<span class="cm-act-t on">连续扫码中</span>
</div>
<div class="cm-act">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M7 9h.01M11 9h.01M15 9h.01M7 13h.01M11 13h.01M15 13h.01M7 17h10"/></svg>
<span class="cm-act-t">手动输入编号</span>
</div>
</div>
</div>
<div class="rec">
<div class="rec-h">
<span class="rec-h-t">已扫码伤员</span><span class="rec-h-n">6 / 12</span>
<span class="rec-h-c">清空</span>
</div>
<div class="rec-i"><div class="wb" style="background:#DC2626"></div><div><div style="display:flex;align-items:center"><span class="rec-n">王建国</span><span class="lv" style="background:#DC2626">危重</span></div><div class="rec-c">男 · 52 岁 · TRIAGE-20260922-0001</div></div><span class="rec-x"></span></div>
<div class="rec-i"><div class="wb" style="background:#F59E0B"></div><div><div style="display:flex;align-items:center"><span class="rec-n">李华</span><span class="lv" style="background:#F59E0B">重伤</span></div><div class="rec-c">男 · 35 岁 · TRIAGE-20260922-0003</div></div><span class="rec-x"></span></div>
<div class="rec-i"><div class="wb" style="background:#10B981"></div><div><div style="display:flex;align-items:center"><span class="rec-n">陈小梅</span><span class="lv" style="background:#10B981">轻伤</span></div><div class="rec-c">女 · 28 岁 · TRIAGE-20260922-0006</div></div><span class="rec-x"></span></div>
</div>
<div class="form">
<div class="lb">接收医院</div>
<div class="pick">
<span class="pick-n">协和医院 · 急诊创伤中心</span>
<span class="pick-b">床位 2</span>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#9CA3AF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>
</div>
<div class="lb g">转运方式</div>
<div class="seg">
<div class="seg-i on"><span class="seg-t on">救护车 🚑</span></div>
<div class="seg-i"><span class="seg-t">直升机 🚁</span></div>
</div>
<div class="lb g">随车联系人</div>
<div class="foot">
<div class="foot-c"><div class="field"><div class="field-l">姓名</div><div class="field-i">刘伟</div></div></div>
<div class="foot-c"><div class="field"><div class="field-l">联系电话</div><div class="field-i">13800006621</div></div></div>
</div>
<div class="send"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M5 13l4 4L19 7"/></svg></div>
</div>
<!-- 弹层 -->
<div class="mask"></div>
<div class="sheet">
<div class="sheet-h">
<span class="sheet-h-b">选择接收医院</span>
<span class="sheet-h-x"></span>
</div>
<div class="opt">
<div class="opt-txt"><span class="opt-n on">协和医院 · 急诊创伤中心</span><span class="opt-m">距现场 6.2 km · 约 14 分钟</span></div>
<span class="opt-b">床位 2</span><span class="tick"></span>
</div>
<div class="opt">
<div class="opt-txt"><span class="opt-n">市第一人民医院 · 急诊</span><span class="opt-m">距现场 8.9 km · 约 21 分钟</span></div>
<span class="opt-b">床位 5</span>
</div>
<div class="opt last">
<div class="opt-txt"><span class="opt-n dim">同仁医院 · 急诊</span><span class="opt-m">距现场 4.1 km · 但急诊已满</span></div>
<span class="opt-b no">满员 ✕</span>
</div>
</div>
</div>
</div>
</div>
<div class="notes">
<h2>改动说明</h2>
<ul>
<li><code>hr-scan-cam</code><code>cameraOn</code> 默认 <code>false</code>,取景区新增 <code>@click="openCamera"</code>;未开启时展示居中引导「点击开启摄像头」,不再进页面即占用摄像头。</li>
<li>底部按钮文案由「已暂停扫码」改为「开启扫码」(图标 <code>play-circle-line</code>),点击即可开启/暂停。</li>
<li>扫描线定时器改为随相机开关启停(<code>openCamera</code> / <code>toggleCamera</code><code>startLaser</code> / <code>stopLaser</code>),不再 <code>onMounted</code> 空跑。</li>
<li><code>op-hos</code> 内联单选列表 → 折叠字段「医院名 + 床位数 + ⌄」,点击整行唤起 <code>hr-mask</code> + <code>hr-sheet</code>(与 伤员查询 / 检伤建档 同一套类)。</li>
<li>弹层选项补上 <code>hospitalMeta</code>(距现场km · 约N分钟),满员医院名置灰、床位红字,点击弹 toast 且不关闭弹层。</li>
</ul>
</div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

+271
View File
@@ -0,0 +1,271 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>检伤建档 · 2D 人体图鉴 & 伤情抽屉(静态预览)</title>
<!--
目的:无构建环境下预览 create.uvue 的伤情标记区与 xDrawer 表单。
尺寸换算:rpx → px 按 0.5(375 逻辑宽),即 1:1 还原真机视觉。
数据/公式与 utils/injury.uts、components/hr-bodymap 一一对应。
-->
<style>
* { box-sizing: border-box; }
body { margin: 0; background: #F3F4F6; font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; color: #1F2937; }
.stage { width: 375px; padding: 12px 11px 24px; }
.card { background: #fff; border-radius: 11px; padding: 12px; margin-bottom: 14px; }
.sec { display: flex; flex-direction: row; align-items: center; font-size: 14px; font-weight: bold; margin: 4px 0 8px; }
.sec-bar { width: 3px; height: 13px; border-radius: 2px; background: #2563EB; margin-right: 6px; }
.sec-more { margin-left: auto; font-size: 11.5px; color: #6B7280; font-weight: normal; }
/* ---------- 2D 人体图鉴(几何值 = hr-design.css / hr-bodymap 的一半) ---------- */
.bm-row { display: flex; flex-direction: row; }
.bm-col { flex: 1; background: #FAFBFC; border: 1px dashed #E5E7EB; border-radius: 12px; padding: 10px 6px 8px; align-items: center; position: relative; margin-right: 10px; display: flex; flex-direction: column; }
.bm-col-last { margin-right: 0; }
.bm-figure { width: 96px; height: 184px; position: relative; }
.bm-head { position: absolute; left: 33px; top: 0; width: 30px; height: 30px; border-radius: 15px; border: 2.5px solid #CBD5E1; }
.bm-torso { position: absolute; left: 26px; top: 33px; width: 44px; height: 62px; border-radius: 14px; border: 2.5px solid #CBD5E1; }
.bm-arm-l { position: absolute; left: 8px; top: 36px; width: 12px; height: 58px; border-radius: 6px; border: 2.5px solid #CBD5E1; }
.bm-arm-r { position: absolute; left: 76px; top: 36px; width: 12px; height: 58px; border-radius: 6px; border: 2.5px solid #CBD5E1; }
.bm-leg-l { position: absolute; left: 28px; top: 96px; width: 13px; height: 84px; border-radius: 7px; border: 2.5px solid #CBD5E1; }
.bm-leg-r { position: absolute; left: 55px; top: 96px; width: 13px; height: 84px; border-radius: 7px; border: 2.5px solid #CBD5E1; }
.bmp-spine { position: absolute; left: 47px; top: 39px; width: 2px; height: 54px; border-radius: 1px; background: #E2E8F0; }
.bmp-hit { position: absolute; border-radius: 5px; border: 1px dashed #EDF0F4; }
.bmp-hit-on { border: 1px solid #BFDBFE; background: #EFF6FF; }
.bmp-mark { position: absolute; width: 38px; height: 38px; margin-left: -19px; margin-top: -19px; display: flex; flex-direction: row; align-items: center; justify-content: center; }
.bmp-bar { position: absolute; top: 13px; height: 12px; border-radius: 6px; opacity: .8; }
.bmp-dot { width: 22px; height: 22px; border-radius: 11px; border: 1.5px solid #fff; display: flex; align-items: center; justify-content: center; }
.bmp-dot-t { font-size: 11.5px; font-weight: bold; color: #fff; }
.bm-tag { font-size: 11.8px; color: #9CA3AF; margin-top: 7px; }
.c-sum { display: flex; align-items: center; justify-content: space-between; margin-top: 11px; font-size: 11.8px; }
.c-sum-t { color: #6B7280; }
.c-sum-json { color: #2563EB; }
.c-mklist { margin-top: 8px; }
.c-mk { display: flex; align-items: center; background: #F9FAFB; border: 1px solid #E5E7EB; border-radius: 11px; padding: 10px 12px; margin-bottom: 8px; }
.c-mk-main { display: flex; align-items: center; flex: 1; }
.c-mk-no { width: 22px; height: 22px; border-radius: 11px; display: flex; align-items: center; justify-content: center; margin-right: 10px; flex: 0 0 22px; }
.c-mk-no-t { font-size: 12.3px; font-weight: bold; color: #fff; }
.c-mk-t { font-size: 14px; font-weight: bold; color: #1F2937; }
.c-mk-s { font-size: 11.8px; color: #9CA3AF; margin-top: 2px; }
.c-mk-side { border-radius: 5px; padding: 2px 7px; margin-left: 6px; }
.c-mk-side-t { font-size: 10.6px; color: #6B7280; }
.c-mk-del { width: 20px; height: 20px; border-radius: 10px; background: #F3F4F6; display: flex; align-items: center; justify-content: center; margin-left: 8px; color: #6B7280; font-size: 11px; }
.c-addmark { display: flex; align-items: center; justify-content: center; height: 40px; border: 1.5px dashed #C7CDD6; border-radius: 11px; background: #fff; font-size: 12.9px; color: #6B7280; }
/* ---------- 伤情录入抽屉 ---------- */
.drawer { background: #fff; border-radius: 12px 12px 0 0; padding: 0 16px; }
.d-head { display: flex; align-items: center; width: 100%; padding-top: 3px; margin-bottom: 4px; }
.d-head-dot { width: 22px; height: 22px; border-radius: 11px; display: flex; align-items: center; justify-content: center; margin-right: 8px; flex: 0 0 22px; }
.d-head-dot-t { font-size: 11.5px; font-weight: bold; color: #fff; }
.d-head-t { font-size: 15px; font-weight: bold; }
.d-head-s { font-size: 10.6px; color: #9CA3AF; margin-top: 2px; }
.d-blk { margin-bottom: 12px; }
.d-blk-h { display: flex; align-items: center; justify-content: space-between; }
.d-fl { font-size: 12.9px; font-weight: bold; }
.d-regen { font-size: 11.4px; color: #2563EB; }
.d-chips { display: flex; flex-wrap: wrap; margin-top: 7px; }
.d-chip { display: flex; align-items: center; border-radius: 7px; padding: 6px 10px; margin-right: 7px; margin-bottom: 7px; font-size: 12px; }
.d-chip-z { border: 1px solid #E5E7EB; background: #fff; color: #4B5563; }
.d-chip-on { border: 1px solid #2563EB; background: #2563EB; color: #fff; font-weight: bold; }
.d-chip-dot { width: 8px; height: 8px; border-radius: 4px; margin-right: 5px; }
.d-side { display: flex; border: 1px solid #E5E7EB; border-radius: 7px; overflow: hidden; }
.d-side-i { padding: 4px 11px; font-size: 12px; }
.d-rot { display: flex; align-items: center; }
.d-rot-b { width: 40px; height: 26px; border: 1px solid #E5E7EB; border-radius: 7px; background: #fff; display: flex; align-items: center; justify-content: center; font-size: 11.4px; color: #4B5563; }
.d-rot-v { width: 42px; font-size: 12.9px; font-weight: bold; color: #2563EB; text-align: center; }
.d-prev { width: 100%; height: 66px; background: #FAFBFC; border: 1px dashed #E5E7EB; border-radius: 9px; position: relative; margin-top: 8px; }
.d-prev-bar { position: absolute; top: 27px; height: 12px; border-radius: 6px; opacity: .85; }
.d-prev-dot { position: absolute; left: 50%; top: 50%; width: 22px; height: 22px; border-radius: 11px; margin-left: -11px; margin-top: -11px; border: 1.5px solid #fff; display: flex; align-items: center; justify-content: center; }
.d-input { width: 100%; height: 36px; background: #F9FAFB; border: 1px solid #E5E7EB; border-radius: 8px; padding: 0 10px; font-size: 13.2px; color: #1F2937; }
.d-ta { width: 100%; height: 88px; background: #F9FAFB; border: 1px solid #E5E7EB; border-radius: 9px; padding: 9px 10px; font-size: 13.2px; color: #1F2937; margin-top: 7px; }
.d-foot { display: flex; align-items: center; padding: 10px 0 14px; }
.d-foot-cancel { flex: 0 0 110px; margin-right: 9px; }
.d-btn { height: 44px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 16px; font-weight: bold; }
.d-b-ghost { background: #fff; border: 1px solid #E5E7EB; color: #1F2937; }
.d-b-pri { background: #2563EB; color: #fff; flex: 1; }
.lbl { font-size: 11px; color: #9CA3AF; margin: 10px 0 4px; }
</style>
</head>
<body>
<div class="stage">
<div class="sec"><view class="sec-bar"></view>伤情标记(2D 人体图鉴)<span class="sec-more"> 手动添加</span></div>
<div class="card">
<div class="bm-row">
<div class="bm-col">
<div class="bm-figure" id="figFront"></div>
<div class="bm-tag">正面 · 点部位即标记</div>
</div>
<div class="bm-col bm-col-last">
<div class="bm-figure" id="figBack"></div>
<div class="bm-tag">背面 · 点部位即标记</div>
</div>
</div>
<div class="c-sum">
<span class="c-sum-t" id="sum"></span>
<span class="c-sum-json">查看 JSON </span>
</div>
<div class="c-mklist" id="mklist"></div>
</div>
<div class="sec"><view class="sec-bar"></view>伤情录入抽屉(xDrawer · 底部弹出)</div>
<div class="drawer">
<div class="d-head">
<div class="d-head-dot" id="dDot"><span class="d-head-dot-t" id="dNo">4</span></div>
<div style="flex:1">
<div class="d-head-t">新增伤情</div>
<div class="d-head-s">部位已自动填入,其余按默认值预填,可直接保存</div>
</div>
</div>
<div id="dBody"></div>
<div class="d-foot">
<div class="d-btn d-b-ghost d-foot-cancel">取消</div>
<div class="d-btn d-b-pri">保存标记</div>
</div>
</div>
<div class="lbl">※ 预览为 create.uvue 的静态还原:绿色虚线框是热区(实际运行不可见,这里画出来核对是否覆盖人体轮廓)</div>
</div>
<script>
/* ============ 与 utils/injury.uts 保持一致的数据与公式 ============ */
function buildParts(side) {
const flip = side === 'front';
const armViewLeft = flip ? '右上肢' : '左上肢';
const armViewRight = flip ? '左上肢' : '右上肢';
const legViewLeft = flip ? '右下肢' : '左下肢';
const legViewRight = flip ? '左下肢' : '右下肢';
return [
{ id: 'head', name: '头部', x: 33, y: 0, w: 34, h: 15, mx: 50, my: 8 },
{ id: 'neck', name: '颈部', x: 38, y: 15, w: 24, h: 8, mx: 50, my: 19 },
{ id: 'chest', name: '胸部', x: 26, y: 23, w: 48, h: 15, mx: 50, my: 30 },
{ id: 'abdomen', name: '腹部', x: 26, y: 38, w: 48, h: 10, mx: 50, my: 43 },
{ id: 'pelvis', name: '骨盆', x: 26, y: 48, w: 48, h: 10, mx: 50, my: 53 },
{ id: 'armVL', name: armViewLeft, x: 5, y: 18, w: 19, h: 36, mx: 14, my: 33 },
{ id: 'armVR', name: armViewRight, x: 76, y: 18, w: 19, h: 36, mx: 86, my: 33 },
{ id: 'legVL', name: legViewLeft, x: 26, y: 58, w: 19, h: 42, mx: 35, my: 79 },
{ id: 'legVR', name: legViewRight, x: 55, y: 58, w: 19, h: 42, mx: 65, my: 79 }
];
}
const EDGE = { 活动性出血: ['#DC2626', '#FCA5A5'], 渗血: ['#F59E0B', '#FCD34D'], 已止血: ['#10B981', '#6EE7B7'], 无出血: ['#9CA3AF', '#E5E7EB'] };
function sizeBarWidth(size) { // 与 injury.uts 同算法
const m = /^(\d+)/.exec(size);
if (!m) return 56;
let w = 40 + parseInt(m[1], 10) * 9;
return Math.round(Math.max(44, Math.min(132, w)));
}
function defaultDesc(part, nature, size, bleeding) {
const tail = { 开放性外伤: '创缘不整,未见异物', 裂伤: '创缘整齐,深及皮下', 擦伤: '表皮剥脱,创面污染',
挫伤: '局部肿胀压痛,无开放伤口', 刺伤: '创口小、伤道深,需探查异物', 切割伤: '创缘整齐,注意肌腱神经损伤',
烧伤: '创面潮红,需记录面积与深度', 挤压伤: '肢体肿胀张力高,警惕骨筋膜室综合征', 闭合性骨折: '畸形、反常活动、骨擦感',
开放性骨折: '骨折端外露,已覆盖并固定', 关节脱位: '关节畸形、弹性固定、活动受限', 贯通伤: '可见入口与出口,警惕深部脏器损伤',
动物咬伤: '创面污染重,需清创并评估狂犬病暴露' }[nature] || '待补充伤情描述';
return part + ' ' + nature + (size ? '' + size + '' : '') + '' + bleeding + '' + tail + '。';
}
/* 与页面示例数据一致(demoMark 三处) */
let marks = [
{ id: 'd1', no: 1, side: 'front', partId: 'head', partName: '头部', rot: 0, nature: '开放性外伤', size: '4×2cm', bleeding: '活动性出血' },
{ id: 'd2', no: 2, side: 'front', partId: 'armVL', partName: '右上肢', rot: 90, nature: '闭合性骨折', size: '5×3cm', bleeding: '已止血' },
{ id: 'd3', no: 3, side: 'back', partId: 'legVL', partName: '左下肢', rot: 45, nature: '挤压伤', size: '8×4cm', bleeding: '渗血' }
];
const partsOf = (s) => buildParts(s);
marks.forEach(m => {
const p = partsOf(m.side).find(x => x.id === m.partId);
m.x = p.mx; m.y = p.my;
m.desc = defaultDesc(m.partName, m.nature, m.size, m.bleeding);
});
/* ============ 渲染 ============ */
const SKELETON = '<div class="bm-head"></div><div class="bm-torso"></div><div class="bm-arm-l"></div><div class="bm-arm-r"></div><div class="bm-leg-l"></div><div class="bm-leg-r"></div>';
function renderFigure(el, side, showHit) {
const parts = partsOf(side);
const ms = marks.filter(m => m.side === side);
let h = SKELETON;
if (side === 'back') h += '<div class="bmp-spine"></div>';
if (showHit) {
parts.forEach(p => {
const on = ms.some(m => m.partId === p.id);
h += `<div class="bmp-hit${on ? ' bmp-hit-on' : ''}" title="${p.name}" style="left:${p.x}%;top:${p.y}%;width:${p.w}%;height:${p.h}%;border-color:${on ? '' : '#22C55E'};border-style:${on ? 'solid' : 'dashed'};"></div>`;
});
}
ms.forEach(m => {
const w = sizeBarWidth(m.size), half = Math.round(w / 2);
const [dot, bar] = EDGE[m.bleeding];
h += `<div class="bmp-mark" style="left:${m.x}%;top:${m.y}%;">
<div class="bmp-bar" style="width:${w / 2}px;margin-left:-${half / 2}px;left:50%;background:${bar};transform:rotate(${m.rot}deg)"></div>
<div class="bmp-dot" style="background:${dot}"><span class="bmp-dot-t">${m.no}</span></div>
</div>`;
});
el.innerHTML = h;
}
renderFigure(document.getElementById('figFront'), 'front', true);
renderFigure(document.getElementById('figBack'), 'back', true);
document.getElementById('sum').textContent =
'共 ' + marks.length + ' 处标记,其中 ' + marks.filter(m => m.bleeding === '活动性出血').length + ' 处活动性出血';
document.getElementById('mklist').innerHTML = marks.map(m => `
<div class="c-mk">
<div class="c-mk-main">
<div class="c-mk-no" style="background:${EDGE[m.bleeding][0]}"><span class="c-mk-no-t">${m.no}</span></div>
<div style="flex:1">
<div class="c-mk-t">${m.partName} · ${m.nature}</div>
<div class="c-mk-s">${m.size} · ${m.bleeding} · 走向 ${m.rot}°</div>
</div>
<div class="c-mk-side" style="background:${m.side === 'back' ? '#F3F4F6' : '#EFF6FF'}"><span class="c-mk-side-t">${m.side === 'back' ? '背面' : '正面'}</span></div>
</div>
<div class="c-mk-del">✕</div>
</div>`).join('') + `
<div class="c-addmark"> 点人体图部位快速添加,或点此手动添加</div>`;
/* ---- 抽屉:模拟「点了正面图左臂(伤员左上肢)」后的自动填充态 ---- */
const dSide = 'front', dPartId = 'armVR', dNature = '开放性外伤', dBleeding = '渗血', dSize = '4×2cm', dRot = 135;
const partName = partsOf(dSide).find(p => p.id === dPartId).name;
const chip = (t, on, dot) => `<span class="d-chip ${on ? 'd-chip-on' : 'd-chip-z'}">${dot ? `<i class="d-chip-dot" style="background:${dot}"></i>` : ''}${t}</span>`;
const natures = ['开放性外伤', '裂伤', '擦伤', '挫伤', '刺伤', '切割伤', '烧伤', '挤压伤', '闭合性骨折', '开放性骨折', '关节脱位', '贯通伤', '动物咬伤'];
const bleedings = ['活动性出血', '渗血', '已止血', '无出血'];
const sizes = ['1×1cm', '2×2cm', '4×2cm', '5×3cm', '8×4cm', '>10cm'];
const bw = sizeBarWidth(dSize), bhalf = Math.round(bw / 2);
document.getElementById('dDot').style.background = EDGE[dBleeding][0];
document.getElementById('dBody').innerHTML = `
<div class="d-blk">
<div class="d-blk-h">
<span class="d-fl">部位</span>
<span class="d-side">
<span class="d-side-i" style="background:#2563EB;color:#fff">正面</span>
<span class="d-side-i" style="background:#fff;color:#6B7280">背面</span>
</span>
</div>
<div class="d-chips">${partsOf(dSide).map(p => chip(p.name, p.id === dPartId)).join('')}</div>
</div>
<div class="d-blk">
<span class="d-fl">伤口性质</span>
<div class="d-chips">${natures.map(n => chip(n, n === dNature)).join('')}</div>
</div>
<div class="d-blk">
<span class="d-fl">出血情况(决定标记颜色)</span>
<div class="d-chips">${bleedings.map(b => chip(b, b === dBleeding, EDGE[b][0])).join('')}</div>
</div>
<div class="d-blk">
<span class="d-fl">尺寸 / 面积</span>
<div class="d-chips">${sizes.map(s => chip(s, s === dSize)).join('')}</div>
<input class="d-input" value="${dSize}">
</div>
<div class="d-blk">
<div class="d-blk-h">
<span class="d-fl">伤口走向</span>
<span class="d-rot"><span class="d-rot-b">45°</span><span class="d-rot-v">${dRot}°</span><span class="d-rot-b">45°</span></span>
</div>
<div class="d-prev">
<div class="d-prev-bar" style="width:${bw / 2}px;margin-left:-${bhalf / 2}px;left:50%;background:${EDGE[dBleeding][1]};transform:rotate(${dRot}deg)"></div>
<div class="d-prev-dot" style="background:${EDGE[dBleeding][0]}"><span class="bmp-dot-t">4</span></div>
</div>
<div class="d-chips">${[0, 45, 90, 135].map(r => chip(r + '°', r === dRot)).join('')}</div>
</div>
<div class="d-blk">
<div class="d-blk-h"><span class="d-fl">详情描述</span><span class="d-regen">↻ 按当前选项重生成</span></div>
<textarea class="d-ta">${defaultDesc(partName, dNature, dSize, dBleeding)}</textarea>
</div>`;
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+109
View File
@@ -0,0 +1,109 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>事件中心 · 进行中 / 历史</title>
<style>
* { box-sizing: border-box; }
body { margin:0; padding:20px 16px 28px; background:#EDEFF3;
font-family:-apple-system,"PingFang SC","Microsoft YaHei","Helvetica Neue",sans-serif; }
h1 { font-size:15px; font-weight:600; color:#1F2937; margin:0 0 4px; }
.sub { font-size:12px; color:#6B7280; margin:0 0 8px; }
.removed { font-size:11.5px; color:#A32D2D; background:#FCEBEB; border:1px solid #F09595;
border-radius:8px; padding:8px 10px; margin:0 0 16px; line-height:1.7; }
.phone { width:375px; background:#F3F4F6; border-radius:18px; overflow:hidden;
box-shadow:0 6px 22px rgba(15,23,42,.14); }
.sbar { height:20px; background:#FFFFFF; }
.tabs { display:flex; flex-direction:row; background:#FFFFFF; height:44px; }
.tab { flex:1; display:flex; align-items:center; justify-content:center; position:relative;
font-size:14px; color:#6B7280; cursor:pointer; user-select:none; }
.tab.on { color:#2563EB; font-weight:bold; }
.tab.on::after { content:""; position:absolute; bottom:6px; width:30px; height:2.5px;
border-radius:2px; background:#2563EB; }
.body { padding:12px 16px 22px 16px; }
.card { background:#FFFFFF; border:1px solid #E5E7EB; border-radius:13px;
padding:12px 13px; margin-bottom:9px; }
.top { display:flex; flex-direction:row; align-items:center; }
.dot { width:8px; height:8px; border-radius:4px; margin:0 8px; flex-shrink:0; }
.name { flex:1; font-size:15.1px; font-weight:bold; color:#1F2937; margin-right:8px; }
.pc { height:21px; padding:0 8px; border-radius:6px; font-size:11.8px; font-weight:bold;
color:#FFFFFF; display:flex; align-items:center; }
.meta { font-size:12.9px; color:#6B7280; margin-top:6px; }
.meta2 { font-size:12.9px; color:#6B7280; margin-top:1px; }
.foot { display:flex; flex-direction:row; align-items:center; margin-top:9px; }
.avts { display:flex; flex-direction:row; }
.avt { width:22px; height:22px; border-radius:7px; border:1.5px solid #FFFFFF;
display:flex; align-items:center; justify-content:center; color:#FFFFFF;
font-size:10px; margin-left:-6px; }
.avt:first-child { margin-left:0; }
.sp { flex:1; }
.go { display:flex; align-items:center; font-size:12.9px; color:#2563EB; font-weight:bold; }
.chev { width:6px; height:6px; border-right:1.5px solid #2563EB; border-top:1.5px solid #2563EB;
transform:rotate(45deg); margin-left:3px; }
.note { text-align:center; font-size:11.8px; color:#9CA3AF; margin-top:8px; }
.note2 { text-align:center; font-size:11.8px; color:#9CA3AF; margin-top:1px; }
</style>
</head>
<body>
<h1>事件中心 · 只保留「进行中 / 历史」</h1>
<p class="sub">Tab 由 3 个变 2 个(各占 50%),点标签可切换查看</p>
<p class="removed">已移除:「待接收」Tab | P0 全屏推送整块(60 秒倒计时 / 立即接收 / 稍后处理)| 卡片 pending 分支与 footer 守卫 | 空态与注释里对「全屏告警推送」的描述</p>
<div class="phone">
<div class="sbar"></div>
<div class="tabs">
<div class="tab on" id="t0">进行中</div>
<div class="tab" id="t1">历史</div>
</div>
<div class="body" id="list"></div>
</div>
<script>
var data = [
{ tab:0, name:'京港澳高速 K120 多车追尾', kind:'大型交通事故', place:'京港澳高速 K120 · 距您 3.2 km',
pc:'P0', pcc:'#DC2626', dot:'#DC2626', go:'详情', members:[['张','#2563EB'],['李','#059669'],['王','#D97706']] },
{ tab:0, name:'阳光养老院 · 老人跌倒', kind:'一般救援', place:'阳光养老院 3 号楼 · 距您 1.1 km',
pc:'P2', pcc:'#2563EB', dot:'#10B981', go:'详情', members:[['陈','#059669']] },
{ tab:1, name:'幸福小区 · 老人心梗', kind:'一般救援', place:'幸福小区 6 号楼 · 距您 2.4 km',
pc:'P1', pcc:'#F59E0B', dot:'#F59E0B', go:'已完成', members:[['张','#2563EB']] },
{ tab:1, name:'滨江路 · 电动车相撞', kind:'交通事故', place:'滨江路与建设街路口 · 已完成',
pc:'P2', pcc:'#2563EB', dot:'#9CA3AF', go:'已完成', members:[['李','#059669']] },
{ tab:1, name:'第一中学 · 学生运动损伤', kind:'一般救援', place:'第一中学操场 · 已完成',
pc:'P2', pcc:'#2563EB', dot:'#9CA3AF', go:'已完成', members:[['陈','#94A3B8']] },
{ tab:1, name:'老城街区 · 煤气泄漏排查', kind:'危化品事故', place:'老城街区 12 巷 · 已完成',
pc:'P1', pcc:'#F59E0B', dot:'#9CA3AF', go:'已完成', members:[['王','#D97706']] },
{ tab:1, name:'火车站 · 旅客突发晕厥', kind:'一般救援', place:'火车站候车大厅 · 已完成',
pc:'P2', pcc:'#2563EB', dot:'#9CA3AF', go:'已完成', members:[['张','#2563EB']] }
];
var cur = 0;
function esc(s){ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;'); }
function render(){
var html = '';
data.filter(function(d){ return d.tab === cur; }).forEach(function(d){
var av = d.members.map(function(m){
return '<div class="avt" style="background-color:'+m[1]+'">'+esc(m[0])+'</div>';
}).join('');
html += '<div class="card">'
+ '<div class="top"><div class="dot" style="background-color:'+d.dot+'"></div>'
+ '<div class="name">'+esc(d.name)+'</div>'
+ '<div class="pc" style="background-color:'+d.pcc+'">'+d.pc+'</div></div>'
+ '<div class="meta">'+esc(d.kind)+'</div>'
+ '<div class="meta2">现场:'+esc(d.place)+'</div>'
+ '<div class="foot"><div class="avts">'+av+'</div><div class="sp"></div>'
+ '<div class="go">'+esc(d.go)+'<div class="chev"></div></div></div>'
+ '</div>';
});
if (cur === 0) {
html += '<div class="note">仅显示与我相关的派单</div>'
+ '<div class="note2">时间与处理状态由后台同步,APP 端不展示</div>';
}
document.getElementById('list').innerHTML = html;
document.getElementById('t0').className = 'tab' + (cur === 0 ? ' on' : '');
document.getElementById('t1').className = 'tab' + (cur === 1 ? ' on' : '');
}
document.getElementById('t0').onclick = function(){ cur = 0; render(); };
document.getElementById('t1').onclick = function(){ cur = 1; render(); };
render();
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

+695
View File
@@ -0,0 +1,695 @@
# 2026-09-24 医院救援 APPuniappx + Tmui4UI 全量重构
## 目标
`mobile-design/v2/hospital-rescue-ui-v2.html`35 屏设计稿)在 uniappx 项目中 1:1 还原 UI,并实现全部交互,兼容 APP + H5,双角色(救援端 / 仓管端)。
## 已完成
- **清空 pages/** 并重建:33 个页面(35 屏中 s07/s42 合并为事件中心同一页的执行状态 Tab)
- **设计令牌落地**:新增 `common/styles/hr-design.css`App.uvue 全局 @import),因 uvue 限制做了三类适配:
1. 不支持 `::before/::after` → 全部改为实体元素(如 `.hr-sec-bar``.bm-*` 人体图鉴)
2. 不支持 CSS 变量 `var()` → 颜色全部写实
3. 不用 `grid` / `gap` → 全部 flex + margin;只使用 class 选择器(避免复合/后代选择器风险)
- **全局配置**main.uts 设 `designSize:375 / unit:'px' / color:'#2563EB' / dark:'light'`;manifest 改为「医院救援管理平台 v2.0.0」
- **mock 数据层**`mock/types.uts`(类型)+ `mock/index.uts`(数据),页面只依赖类型,后续换接口只改 mock 层
- **角色体系**`utils/role.uts`hrSession / 双角色 TabBar / canAccess 权限拦截)、`utils/nav.uts`(统一跳转+权限拦截)、`utils/quiz.uts`(答题态,供答题页与答题卡共享)
- **16 个通用组件**components/easycom):hr-page / hr-statusbar / hr-card / hr-sec / hr-lrow / hr-chip / hr-wb / hr-pc / hr-timeline / hr-tip / hr-empty / hr-progress / hr-wband / hr-tabbar / hr-scan-cam / hr-rec-list
- **复杂交互**:真实相机连续扫码(x-mlkit-scannig-u,批量处置/批量转运/出入库)、P0 全屏推送 60s 倒计时接单、2D 人体图鉴伤情标记增删、多模态上传(chooseImage)、答题卡联动、离线队列模拟重传、设备 5 项自检门禁、接收医院床位满员拦截
- **双角色 TabBar**:救援端 3 Tab(首页/事件/我的)、仓管端 5 Tab(扫码/仓库/设备/消息/我),登录页可切换角色演示
## 关键坑(务必记住)
- uvue 语法:`computed() : T => {}` 是**非法**写法,必须 `computed(() : T => { ... })`——本次批量修了 17 处
- Vue API 需要**显式** `import { ref, computed } from 'vue'`,不能依赖自动注入
- `onLoad` 参数用下标访问:`opt['id']`(官方示例 `event["post_id"] ?? ""`),不要用 `getString`
- 视频/取景框不能用 `aspect-ratio` → 用 `uni.getWindowInfo().windowWidth` 计算像素高度
- 覆盖层(底部选择器/全屏推送)通过 hr-page 的 `overlay` 具名插槽渲染,且在根节点上需 `position:relative`
- 三段式作业页(批量处置/转运)用 hr-page 的 `no-scroll`,内部自管滚动区
- 图片/图表类设计元素(圆环→x-circle-progress、人体图→几何 view 拼装)不能照搬 SVG
## 静态校验方法(可复用)
用 esbuild 的 `transformSync(code,{loader:'ts'})` 校验 `<script>` 语法 + 正则校验模板标签配平 + 比对 pages.json 路由与文件 + 校验组件标签是否存在于 components/ 或 uni_modules。
已固化为技能 `~/.workbuddy/skills/uvue-design-port/scripts/check-uvue.js`(含「模板调用了未声明函数」检查)。
---
## 2026-09-24 第二轮:H5 运行期报错修复
用户跑 H5 报错,逐条定位修复:
### 崩溃(必须修)
- **`_ctx.gradeStyle is not a function` @ create.uvue:51** —— 模板写了 `:style="gradeStyle(i)"`
但 script 里只定义了 `gradeClass(i)`,漏写 gradeStyle。
→ 补 `gradeStyle(i)`(内联下发边框/底色),删掉 `gradeClass``.c-grade-sX.c-grade-on` 复合选择器。
- **连带报错 `Cannot read properties of null (reading 'attachVmProps')` @ nav.uts:13**
是上面渲染崩溃导致的 H5 导航状态损坏,**不是独立 bug**,页面修好后自动消失。
经验:H5 下页面 render 抛错会连带污染 `uni.navigateTo`,先修渲染错误再看后续栈。
### 顺带修的问题
- `gradeClass` 之外还发现 `pickClass(i)` 逻辑写死 `i==0` 恒为选中 —— 改为四项统一用 `.c-pick`
(设计稿本就是四个浅蓝填充的已选外观),删掉 pickClass。
- `computed` 实现有 bug`pickValues` 初值改为按索引逐个取,避免依赖数组展开。
### 编译警告修复
- `nav.uts``toast(title, icon: string)` → 形参改字面量联合类型 `HrToastIcon`
否则 `uni.showToast``Type 'string' is not assignable to type 'Icon | null | undefined'`
- 5 个文件误从 `@dcloudio/uni-app` import 页面生命周期 → 全部删除 import,
改用 uvue 全局函数;并纠正 quiz/answer-card 的 `onShow`**`onPageShow`**uvue 组合式是 onPageShow/onPageHide)。
- `res.content` 类型是 `string|null|undefined`3 处 editable showModal)→ 先判空再 `as string`
- `hr-tabbar` 去掉 setup 期间写全局 `xTabbarConfig` 的调用,避免多页面互相干扰 / 响应式风暴。
### 保真度回补
- 补回 CSS 动效:出入库/扫腕带的**扫描激光线**(@keyframes 上下往复)、事件中心**待接收红点呼吸**。
修复后 49 个 .uvue 静态校验 0 错误。
---
## 2026-09-24 第三轮:全局尺寸升级 + 全面改用 tmx-ui 组件
用户提出 13 项全局改造,已完成主体。
### ① 字号/尺寸偏小 → 全量 px 转 rpx
- **根因**tmx-ui 本版本的 `checkIsCssUnit()` 是**空实现**(只补单位,不做 designSize 缩放),
所以库组件与我的样式都渲染成「设计稿原始 px」,在宽屏(H5 桌面)上就显得又小又拉伸。
- **方案**:全量 `px → rpx`(设计稿 px × 2),375 基准下 1:1 不变,宽屏按比例放大且不溢出。
脚本批量转换 **1922 处 / 41 个文件**`common/styles/hr-design.css` + 所有 `<style>` 块)。
字号额外再 ×1.1210.5→11.8、14→15.7、21→23.5 等)。
- 手工处理 6 处 JS 动态尺寸:视频区高 386rpx、答题卡格子 92rpx、趋势柱 176rpx、进度条 h×2 rpx、
hr-sec 内联 margin、扫码取景框 686rpx(保持正方形)。
- `manifest.json → web` 增加 `rpxCalcMaxDeviceWidth/rpxCalcBaseDeviceWidth = 480`
避免 H5 桌面端 rpx 无限放大(若该字段被忽略则回退默认行为,无副作用)。
- **注意**`hr-statusbar` 的高度必须保持 px(来自 `uni.getWindowInfo().statusBarHeight`)。
### ② 底部 tabbar「我的」凸起
- 真因:`x-tabbar``outIndex` **默认值是 2**,救援端第 3 个 Tab(我的)正好被当成凸起项。
→ 传 `:out-index="-1"` 关闭凸起,并把 `font-size/icon-size` 改为 rpx26rpx / 48rpx)。
### ③⑤⑥⑦⑨ 用「包装组件」一处改造全站生效(关键手法)
| 需求 | 做法 | 覆盖范围 |
|---|---|---|
| xCell | `hr-lrow` 内部改为 `<x-cell>`card=false + avatar 插槽 + right 插槽) | 30+ 处列表 |
| xAlert | `hr-tip` 内部改为 `<x-alert skin="thin">` | 15 处提示条 |
| xSteps | `hr-timeline` 内部改为 `<x-steps vertical>` + `<x-steps-item>` | 6 处时间线 |
| xIcon | 新增 `utils/icon.uts``toRemixIcon()` 字符→RemixIcon 映射,组件内统一解析 | 所有图标 |
> 好处:页面调用方式不变,30+ 调用点零改动即换成库组件;新页面可直接写 `<x-cell>` / RemixIcon 名。
### 新增/替换的库组件
- **xSearch**`triage/victims``course/library``warehouse/stock` 的搜索框
- **xTabs**`event/index`3 执行状态)、`triage/detail`4 分页)
- **xAvatarGroup**`event/index` 卡片头像组、`event/detail` 救援团队
- **xIndexbar**`message/contacts` 整页重写为索引列表
- **xUploadFile**`triage/create` 多模态记录附件
- **xInput**`login` 工号/密码
- **xSkeleton**:新增 `components/hr-skeleton`(基于 xSkeleton),`event/index` 已接入
### 待办(未完成,下次继续)
- xTabs 还剩 4 处:`study/center``warehouse/scan``warehouse/devices``study/exams` 的 seg
- xInput 还剩:`triage/create` 表单字段、批量处置/转运的联系人字段
- xSkeleton 还剩:`home``triage/victims``warehouse/stock` 等列表页加载态
- 首页/详情页里仍有少量 `{{ glyph }}` 形式的独立字符图标(tr-quick / lc-tabs 等)待换 x-icon
### 复用的排查手法(已验证)
静态校验脚本 `check-uvue.js` 已能拦住:`computed() : T =>` 写法、模板调用未声明函数、组件标签不存在、路由未注册。
---
## 2026-09-24 第四轮:8 项 UI 缺陷修复
| # | 问题 | 根因 / 解法 |
|---|---|---|
| 1 | tabbar 页面底部没预留高度 | **`x-tabbar``position` 默认是 `'fixed'`** → 脱离文档流,其内部占位高度不生效,内容被底栏遮住。改传 `position="relative"` 即可自动占位。 |
| 2 | 首页列表行文字比图标高、字偏大 | `x-cell``.xCellWrap` **写死 `padding:12px 0`**(且不随 rpx 缩放)导致行虚高。用全局 `!important` 覆盖收 0;同时 title 31.4→28rpx、desc 25.8→24rpx(两行合计≈64rpx,与左侧图标同高);hr-lrow 的 padding 22→16rpx。 |
| 3 | 首页工具箱图标没换 | 工具箱是独立 `{{ q.icon }}` 字符渲染,没走 `toRemixIcon`。改为 `<x-icon>` 并把 mock `toolBox` 改成 remixicon 名;`deviceList` 同步改造。 |
| 4 | 通讯录样式异常 | x-indexbar 的行是绝对定位 + `translateY`,行高必须与 `cell-height` 严格一致。原样式带圆角+左右外边距+104rpx 高(cell 120rpx)导致错位。改为满宽白底行、高 120rpx、底部 2rpx 分隔线。 |
| 5 | 去掉 design/tokens | 删除页面 + pages.json 条目 + 「我的」入口(顺带把帮助与反馈的字符图标换成 remixicon) |
| 6 | 事件页头像组 & 顶部栏 | `:max-count="5"`;移除 ev-nav(标题栏) |
| 7 | 伤员查询扫码按钮 | 移除 `vs-scan-btn`(x-search 不支持自定义右侧图标,故改成搜索占满整行) |
| 8 | 全局 1s 请求 + 骨架屏 | 新增 `utils/request.uts``MOCK_REQUEST_DELAY=1000` + `mockRequest(onDone)`);**把骨架屏做进 `hr-page`**:传 `:skeleton="N"` 即在请求期间用 xSkeleton 占位,页面零改造。已接入 15 个页面;手写的 stock / devices / message-index / event-index 单独接。 |
### 关键经验
- 包一层「骨架屏能力」到页面骨架组件(`hr-page`)比每页写 loading 高效得多:新增页面只要加 `:skeleton="4"`
- 覆盖库组件内部写死样式时,scoped 样式带 `[data-v-xxx]` 属性选择器,优先级高于普通 class → **必须用 `!important`**(本项目 hr-design.css 在 App.uvue 里后引入)。
- **x-cell 的 padding 参数是「左右在前、上下在后」**:`fillArrayCssValue` 对 2 元素数组返回 `[ar1,ar0,ar1,ar0]`
(按 CSS 上右下左展开)⇒ 第一个元素是左右、第二个才是上下,所以 `['24rpx','0']` = 左右 24rpx、上下 0。
⚠️ 第六轮已纠正:此前的记录写反了。
- 卡片内列表行间距调宽:`hr-lrow` 上下 padding 16→24rpx(相邻两行内容间隔 48rpx),行 min-height 96→112rpx。
- **骨架屏要「整页」而非几行**`hr-skeleton` 改为「N 张卡片 × M 行」结构(默认 5 张卡 × 3 行,≈800px+),
卡片用白底圆角 + 边框,视觉上就是「页面本身在加载」;再加 `.hr-sk{min-height:100%}` 兜底。
只渲染几行会在下方露出大片底色,看起来就是「显示不全」。
- **⚠️ uvue 头号布局坑:scroll-view 是 flex 容器,直接子项默认 `flex-shrink:1`**
内容高于容器时会被「压缩到刚好装下」,表现就是**页面/骨架屏只显示顶部一小截、且滑不动**。
已在 `.hr-body` 与骨架屏内部(`.hr-sk` / `.hr-sk-card` / `.hr-sk-row`)显式加
`flex-shrink:0; flex-grow:0;` + `min-height` 兜底。**今后任何写进 scroll-view 直接子级的容器都要加。**
### 骨架屏最终方案:绝对定位覆盖层(此前的 flex 修复还不够)
即使加了 `flex-shrink:0`,真机上骨架屏仍只显示顶部一小截。
**排查方法(很有用)**:把结构与 CSS 复刻成纯 HTML,用本机 Chrome 无头截图看真实渲染 ——
`chrome.exe --headless=new --hide-scrollbars --window-size=375,812 --screenshot=out.png file:///xxx.html`
结果 HTML 复现**完全正常**,说明 CSS 结构没问题,问题出在 uvue 的渲染差异上。
**最终解法**:不把骨架屏当 scroll-view 的 flex 子项,而是**绝对定位覆盖层**:
```
.hr-page(relative, flex column)
├ statusbar / x-navbar
├ .hr-flow ← 新增,flex:1 + min-height:0 + position:relative(内容区定位基准)
│ ├ .hr-scroll > <slot/> 真实内容
│ └ .hr-sk-mask (absolute inset:0) 骨架屏覆盖层(不参与 flex,永不被压缩)
│ └ .hr-scroll > .hr-body > hr-skeleton
└ footer / tabbar slots
```
绝对定位元素完全脱离 flex 布局,高度必然等于内容区 → 稳定铺满整页;覆盖层自带 scroll-view
内容超高时可滚。手写根节点的 4 个页面(stock / devices / message-index / event-index)也已同步改成此结构。
### 最终定为:不用骨架屏,统一用 xLoading(用户决定)
骨架屏在真机上始终显示不全,用户决定**去掉全部骨架屏,加载中改用组件库 `xLoading`**。
- `hr-page` 的 prop 由 `:skeleton="N"` 改为 **`:xloading="true"`**Boolean);15 个页面已批量替换。
- 覆盖层容器复用已验证可行的绝对定位方案:`.hr-loading-mask`absolute inset:0 + flex 居中)。
- 手写根节点的 4 个页面同步改为 `<view v-if="loading" class="hr-loading-mask"><x-loading label="加载中…"/></view>`
- **`components/hr-skeleton` 已删除**`hr-design.css` 里的 `.hr-sk*` 样式已移除。
- `x-loading` 组件用法:`<x-loading label="加载中…" :icon-size="'44rpx'" :text-size="'26rpx'" :vertical="true" color="#2563EB" text-color="#6B7280" />`
propscolor/textColor/textSize/iconSize/vertical/icon/hideText/label;默认 icon 为 `loader-line`、vertical 默认 true
### 经验(写给以后的自己)
布局问题别靠读代码猜 —— **先把结构+CSS 复刻成 HTML,用本机 Chrome 无头截图看真实渲染**
一眼就能判断是「CSS 结构问题」还是「uvue 渲染差异」,避免连续几轮盲改。
### 仍未完成
- `study/center``warehouse/scan``study/exams` 的分段控件待换 xTabs
- `triage/create` 表单字段待换 xInput
- `course/library`(noScroll)等少数页面未接骨架屏
---
## 2026-09-24 第五轮:全站角标尺寸统一
用户要求:事件页底部 tabbar 角标、事件列表页顶部切换栏(xTabs)角标、以及其他页面角标,
**尺寸统一按「我的」页消息中心 item 右侧角标**(基准 = 高/圆角 36rpx、字号 22.4rpx、红底白字)。
### 角标的两条来源(关键认知)
全站角标其实分两类,必须分别处理:
| 类型 | 出现在哪 | 怎么改 |
|---|---|---|
| **自写角标** | `hr-lrow` 右侧插槽(我的页消息中心、消息中心各分组) | 统一用一个全局 class `.hr-badge` / `.hr-badge-t` |
| **组件库角标 xBadge** | `x-tabbar``dotLabel`)、`x-tabs``dotType:'label'`+`dotText` | 只能靠全局 `!important` 覆盖 `.xBadge-countAndLabel` / `.xBadge-countAndLabelText` |
- 自写角标:`hr-badge`/`hr-badge-t` 落在 `hr-design.css``pages/mine/index.uvue`(基准)与
`pages/message/index.uvue` 都改用它;原先各自的 `.mine-dot`/`.mine-dot-t``.ms-dot`/`.ms-dot-t` 已删除。
- xBadge`x-tabbar.uvue` 里是 `<x-badge :dot="item.dotType=='dot'" :label="item.dotLabel">`
`x-tabs.uvue` 里是 `<x-badge :bg-color="item.dotColor" :dot="..." :label="item.dotText">`
**两处都没传 `fontSize`,所以用的是组件默认 `fontSize:"9"`9px**,比基准的 22.4rpx 明显小 —— 这就是用户看到的不一致。
- xBadge 的尺寸来源:盒子靠自身 `.xBadge-countAndLabel{ padding:0rpx 4px }`,字号靠**内联 style**
(`:style='{color:_fontColor,fontSize:_fontSize}'`)。**内联样式只能用 `!important` 压**CSS 规定
`!important` 声明 > 内联 style)。颜色(`dotColor`)不要动,各 Tab 语义色要保留。
### 验证手法(这次用来「不再猜」)
`rpx` 浏览器不认!第一次复刻 HTML 时所有 `36rpx` 都被判为非法值整条丢弃,测出来还是 9px,差点误判。
**正确做法**:按 375 逻辑宽折算 `1rpx = 0.5px` 再写进 HTML,然后用
```
chrome.exe --headless=new --virtual-time-budget=1500 --dump-dom file:///xxx.html
```
在页面里用 `getComputedStyle()` 把高度/圆角/内边距/字号打到 `<pre>`,直接读到硬数字。
实测结果:xBadge 与自写 `hr-badge` 都是 **28.0 × 18.0 px**(即 56×36rpx),完全一致 —— 证明
全局 `!important` 确实压得住库组件的内联 `font-size:9px`
### 明确没动的「长得像角标但不是数字角标」的元素(避免误伤)
- `event/index``.evt-dot`16rpx 优先级状态圆点)、`.ev-push-badge`P0 派单白色胶囊)
- `home/index``.tr-bell-dot`14rpx 红点提醒)、`hr-rec-list``.rl-c`(浅蓝「N 人」信息 chip
- `hr-wb` / `hr-wband` 的状态小圆点、`triage/create``.c-grade-dot`44rpx 分级选择圈)
- `course/media``.md-badge`(按类型着色的分类标签,不是计数角标)
这些是「状态点 / 标签 / 胶囊」,与计数角标语义不同;若也要统一需先确认。
---
## 2026-09-24 第六轮:5 项修复(崩溃 / 角标裁切 / 图标 / 学习中心 / 未读隐藏)
### ① H5 崩溃:`val.map is not a function` @ x-alert.uvue:266(★ 重要通用坑)
**根因**`x-alert``round` prop 类型是 **`string[]`(数组)**,而 `hr-tip` 传了 `:round="'22rpx'"`(字符串)。
`_round` computed 里 `props.round.length == 0` 对字符串成立("22rpx".length == 5),于是走到
`fillArrayCssValueByround(props.round)``val.map` → 字符串没有 `.map` → 崩。
**通用教训**:tmx-ui 里凡是「数组型几何 prop」——
`round / padding / margin / border / borderColor / darkBorderColor / radius` ——
**必须传数组**,传字符串/布尔会在 `fillArrayCssValue*``val.map` 处直接抛异常。
两元素约定恒为 **`[左右, 上下]`**`fillArrayCssValue` 对 2 元素返回 `[ar1, ar0, ar1, ar0]`
按 CSS 上右下左展开 ⇒ 第二个元素才是上下)。
顺带修正第四轮记错的一条:x-cell / x-alert 的 2 元素都是 `[左右, 上下]`,不是「上下在前」。
`hr-tip` 原来的 `['20rpx','24rpx']` 因此被理解成左右 20 / 上下 24,已一并纠正为 `['24rpx','20rpx']`。)
**现存同类隐患**(没崩但类型不对,无视觉效果,先记着):
`stock.uvue` / `victims.uvue``<x-search :border="true">`,而 `x-search.border``string[]`
内部 `props.border.length != 3 → "none"`,所以现在等于「无边框」(与库默认一致)。
### ② 角标尺寸统一(承接第五轮)→ tabbar 角标被裁
把 xBadge 放大到 36rpx 后,tabbar 上角标出现「超出部分被隐藏」。
**根因**x-badge 默认把角标**半出血**挂在右上角(`transform: translate(50%,-50%)`),
需要容器上方留出半个角标高度;而 **x-tabbar 的 Tab 项高度写死 `height = ref(60)`px,且不可配)**
icon(30px 固定) + 文字已占满,没有出血空间 → 超出的部分被裁。
**解法**`hr-design.css`):
```css
.xBadge-countAndLabel { top: 0 !important; transform: none !important; } /* 取消出血,贴容器内右上角 */
.xBadge, .xBadgeWrap, .xTabbarBox, .xTabbarWrap, .xTabbarItem { overflow: visible !important; }
```
取消出血后角标任意屏宽都完整落在 `item` 内,不再依赖上方空间。
(用 Chrome 无头 + 1:1 复刻 x-tabbar 结构验证过:角标完整落在虚线 item 框内。)
### ③ 课程详情页(`pages/course/subject.uvue`)图标替换
- 三入口 `✗ / ★ / ▸``<x-icon :name="toRemixIcon(e.icon)" :color="e.color">`
- 资料行 `▶``<x-icon name="play-fill">`PDF/DOC/PPT/XLS 是文件格式标签,**保留文字**,不要转图标)
- 资料行右侧 ```<x-icon name="arrow-right-s-line">``.doc-go` 由文字样式改为 flex 容器)
- 删除失效样式 `.sj-entry-ic-t` / `.doc-ic-play`
### ④ 学习中心(`pages/study/center.uvue`
- 自绘分段控件 `lc-tabs`**`<x-tabs>`**`item-width="25%"`,写法抄 `triage/detail.uvue`);
`cat``ref(0)` 改为由 `activeId`(字符串 id)换算的 `computed`
- 「查看解析」`⌃ / ⌄` 字符 → `<x-icon name="arrow-up/down-s-line">`
- 折叠框改手风琴:新增 `.qa-acc` 容器 + `@keyframes qa-acc-in`opacity + translateY 240ms);
`toggle()` 改为**同时只展开一项**(真正的手风琴语义,原为多项可同时展开)
### ⑤ 未读角标「查看后隐藏」
新增 `utils/unread.uts`(唯一来源,`reactive`):
```ts
export const hrUnread = reactive({ events: 4, messages: 4, msgCenter: 4 })
export function badgeText(n) { return n > 0 ? n.toString() : '' } // 空串 → xBadge 自动隐藏
export function markEventsRead() { hrUnread.events = 0 }
export function markMessagesRead() { hrUnread.messages = 0; hrUnread.msgCenter = 0 }
```
- `utils/role.uts``rescuerTabs` / `keeperTabs`**const 数组改为函数**(每次重建数组,
`badgeText(hrUnread.xxx)`)→ 未读数变化时 hr-tabbar 的 computed 重新求值,角标自动消失。
- 「我的」页消息中心入口:`v-if="hrUnread.msgCenter > 0"`
- `event/index` `onPageShow``markEventsRead()``message/index``message/list` `onPageShow``markMessagesRead()`
- `message/index` 单条角标:新增本地 `readKeys`key = `分组下标-条目下标`),点开单条即消失,
「全部已读」清空全部;`onMsgClick` 增加 `gi, i` 两个参数。
### 校验
48 个 .uvue 静态校验 **0 错误**;另用 esbuild 单独校验了 3 个改动的 `.uts`check-uvue.js 不覆盖 .uts)。
### 仍未做(用户没提,待确认)
- `pages/study/wrong-book.uvue` 有与学习中心**完全相同**的 `qa-fold` 字符图标 + 折叠逻辑,未同步改造
- `lc-tabs``pages/message/index.uvue` 还剩一处(`.lc-tabs span.active` 样式块,模板可能已不用,待清理)
---
## 第七轮:收尾清理 + 数组型 prop 全站审计
### ① 补齐第六轮遗留
- `pages/study/wrong-book.uvue`:「查看解析」`⌃ / ⌄``<x-icon>`,折叠框改手风琴
`.qa-acc` + `@keyframes qa-acc-in``toggle()` 先全收起再切换),与 `study/center.uvue` 完全对齐
- `pages/message/index.uvue`:删除孤儿样式块 `.lc-tabs span.active`(模板早已不用)
### ② 修复 `x-search.border` 误传(真实设计偏差)
`x-search.border``string[]`,且内部守卫是 `if (borderstr.length != 3) return "none"` ——
**恰好要 3 项 `[宽度, 线型, 颜色]`**,不做单位换算,原样拼进 inline style。
- `warehouse/stock.uvue``triage/victims.uvue`` :border="true"``:border="searchBorder"`
`const searchBorder : string[] = ['1px', 'solid', '#E5E7EB']`)。
原先布尔传参**不崩但永远无边框**,而设计稿 `.field` / `.srch` 都是 `1px solid #E5E7EB` → 属静默还原偏差。
同时删掉死属性 `border-color`(颜色是数组第 3 项,不是 prop)。
- `course/library.uvue`:删掉无效的 `:border="false"`(默认 `[]` 即 "none",等价且更诚实)。
- ⚠️ 别把 `round` 一起改:`x-search.round`**String**`round="24rpx"` 正确)。
- 顺带核对:`x-search._border` 不做 `fillArrayCssValue`,所以布尔**不会**触发 `val.map` 崩溃;
`x-alert.round/padding/margin``fillArrayCssValue*`,传布尔照样崩——_不同组件行为不同_。
### ③ 新增两个可复用校验脚本(放在隔离 node 工作区,不进仓库)
`C:/Users/Hyj61/.workbuddy/binaries/node/workspace/`
- `check-uvue-hr.js <项目根>`.uvue script 语法(esbuild+ 标签配平 + pages.json 路由 + **.uts 语法**
- `audit-array-props.js <项目根>`:读 tmx-ui 各组件声明的 `prop: string[],`,反查 pages/components
实际传参,命中布尔/数字/字符串即报出(本次扫出并修完 3 处)
⚠️ **校验脚本自身两个坑(踩过)**
1. `/<template>([\s\S]*?)<\/template>/` 惰性匹配会被内层 `<template v-slot:right>` 的闭合标签
**提前截断** → 满屏「未闭合标签 hr-page, template」假报错。
正确:**先抠掉 `<script>` / `<style>` 整块,再对剩余全文做标签配平**。
2. 遍历必须排除 `uni_modules/`,否则 49 个业务 .uvue 被算成 194 个,还会扫到
`x-svg-u` 等库内非常规写法 → 必然假报错。
### ④ 校验结果(基线更新)
`.uvue 49 / 路由 32 / .uts 11 / 错误 0``audit-array-props.js` → 未发现数组型 prop 误用。
> 上一轮记的「48 个 .uvue」是因为脚本漏扫了 `components/` 目录,**49 才是正确基线**。
### ⑤ 环境备注
本会话 shell 工具的 PATH 异常,直接跑 `ls/grep/node``command not found`
规避:命令前加
`export PATH="/c/Users/Hyj61/.workbuddy/binaries/PortableGit/versions/1.2.0/usr/bin:/c/Users/Hyj61/.workbuddy/binaries/PortableGit/versions/1.2.0/mingw64/bin:$PATH"`
(或直接用 Glob / Grep / Read 专用工具)。
另:同一文件连续两次 Edit 时,第一次的改动可能被第二次覆盖,**改完要回读确认**。
---
## 第八轮:5 项 UI 缺陷修复(XCell 单行省略 / 顶部角标 / tabbar 角标 / 通讯录 / 上传误报)
### ① 所有 XCell 的 title 超出一行显示省略号
根因:库 `x-cell``.title` 写死 `lines:2`scoped 样式,`!important` 压字号压不掉行数)。
解法:`hr-lrow` 改用**默认插槽自渲染**标题 `<text class="hr-lrow-t">``lines:1` + `text-overflow:ellipsis`)。
已核对 x-cell 模板:默认插槽的 fallback 才是库自己的 `<text class="title">`,传入插槽即整体替换,不会双渲染。
x-cell 上的 `:title="title"` 保留无害,只是不再被渲染。)全站 30+ 处 hr-lrow 零改动生效。
### ② pages/event/index 顶部切换栏的角标去掉
根因:`tabs` computed 给 x-tabs 传了 `dotType:'label'`+`dotText`(数量)。
解法:tabs 只留标题;顺带删除 `.ev-tab-c*`/`.ev-tab-ct*` 整组角标胶囊样式。
### ③ 底部 tabbar 角标遮挡 icon(★ 本轮最有价值,连错两轮才修对)
**两个算错的前提:**
1. **角标实际宽 28px,不是 18px** —— `min-width:36rpx` 是「内容盒」最小值,还要加左右各
10rpx 内边距 ⇒ 18 + 10 = 28px。按 18px 推算定位必然偏左(上一轮 `right:-20rpx` 因此只压 58%)。
2. **库默认「半出血」本身就会盖住 icon 42%** —— x-tabbar 给 x-badge 传 `:offset="[0,5]"`
叠加 `translate(50%,-50%)` = 「角标中心 = 容器右上角」,28px 宽的角标有 10px 压在 icon 上。
用户最初反馈的「遮挡」正是这个;第六轮改成 `top:0;transform:none` 反而变 100% 全盖,是倒退。
**定稿(`hr-design.css`)=改「左锚定」,复刻设计稿 `.dot` 的 `left:calc(50% + 6px)`**
```css
.xBadge-countAndLabel { top:-4rpx !important; left:44rpx !important; right:auto !important; transform:none !important; }
.xBadge { padding:0 !important; } /* 去掉库为半出血预留的内边距 */
.xBadge,.xBadgeWrap,.xTabbarBox,.xTabbarWrap,.xTabbarItem { overflow:visible !important; }
```
- `left:44rpx = 22px`:定位基准 `.xBadgeWrap` 宽 = icon 盒宽(icon 24px + 左右各 4px = 32px,半宽 16),
其水平中心与 Tab 项中心重合(.xBadge/.xBadgeWrap/.xTabbarItem 三层都 align-items:center)⇒ 16+6=22px。
- 左锚定**与角标自身宽度无关**(两位数/三位数只向右生长,不会回头盖 icon)。
- `top:-4rpx`Tab 项 60px、内容约 53px、上下余量 ~3.5px,取 2px 后顶边仍在项内 ~1.5px。
**Chrome 无头实测(复刻真实 DOM + .xBadge padding:0375 逻辑宽):**
| 方案 | 角标压住 icon | 裁切 |
|---|---|---|
| 修复前 right:0 | 24.0×18.0**100%** | 无 |
| 错解 right:-20rpx | 14.0×16.058% | 无 |
| **定稿 left:44rpx3 Tab / 5 Tab / 两位数)** | **6.0×16.025%** | **无** |
| 库默认半出血 | 10.0×14.042% | 无 |
验证页 `.workbuddy/badge-fix-preview.html` + 截图 `badge-fix-preview.png`(都未进仓库)。
⚠️ 复刻页第一版有个 bug`tabbar(v).replace('class="xTabbar '+cls+'"', ...)` 把方案类名拼丢了,
A/B/C 测出来完全一样。**「三个方案结果完全一致」本身就是没区分度的信号**,先查脚本再下结论。
另外复刻页要补 `.xTabbar{width:100%}`,否则 item 收缩成 32px,裁切检查全失真。
### ④ pages/message/contacts 显示异常
根因:x-indexbar 的行是绝对定位 + `translateY`,行高必须与 `cell-height` 严格一致;
原实现 120rpx cell + 104rpx 行 + 圆角/外边距 → 错位。
解法:整页重写为静态分组卡片列表(指挥调度/协作医院/本队同事),弃用 x-indexbar
`blkText/blkStyle`(头像块主题色,内联下发避开复合选择器)、`extractPhone`(按「·」分段、
跳过「工号」、取纯数字≥4 位为可拨号)、`onCall`。对齐设计稿 s28。
### ⑤ pages/triage/create 选择文件无论多大都提示「超过大小」
根因:`:max-file-size="50"` 被 x-upload-file 当成 **50 字节**(该 prop 单位是字节,默认 30MB)。
解法:`const maxFileSize = 50*1024*1024` + `:max-file-size="maxFileSize"`
`onFileChange` 只 toast**不回写 fileList**(回写会触发库内 watch→covertList 把附件
重置成「已上传无大小」,列表错乱)。
### 校验
静态校验 49 .uvue / 32 路由 / 11 .uts / **0 错误**hr-design.css 花括号 171/171 平衡、注释未失配。
### 环境(本会话续)
- PATH 异常升级:`ls/sed/head/tail/which/grep/find` 都没有,但 **`cd/echo/pwd` 内建可用**、
重定向 `>` 可用、Chrome/node 可用绝对路径跑。
→ 测 DOM 用 `chrome --dump-dom > 文件`,再用 Read/Grep 读文件,**绕开 sed/grep 管道**。
- 无头 Chrome `--screenshot` 只截 `--window-size` 给的视口,看全页要把高给够(本次 420×1780)。
- node `-e` 调试脚本里别混用「原串下标」去切「去注释后的串」,下标会错位(本次踩过)。
---
## 第九轮:检伤建档 2D 人体图伤情标记(完整落地)
需求:点 2D 人体图部位 → xDrawer 弹窗(部位自动填、其余默认填充)→ 多点标记 + 走向可旋转 → 存 JSON 可回显。
### 三个落点
1. **`utils/injury.uts`(数据层,新建)**HrBodyPart / HrInjury(纯基础类型,可直接 JSON)、
9 个正/背热区(buildParts,互不重叠竖条)、13 性质 / 4 出血 / 6 尺寸 / 4 走向 选项与默认值、
`defaultInjuryDesc` 自动描述、`bleedingColor/bleedingBarColor`(红/橙/绿/灰四色语义)、
`sizeBarWidth`1cm≈9rpx,夹 44~132)、`newInjuryId/copyInjury/renumberInjuries/excludeInjury/
findInjury/injuriesOfSide/nextMarkAnchor`(同部位环序错开 8px)、
`injuriesToJson/injuriesFromJson`(逐字段 getString/getNumber 兜底,脏 JSON 不崩)。
2. **`components/hr-bodymap/hr-bodymap.uvue`(展示组件,新建)**props `side/parts/marks`
emit `part-tap(partId)/mark-tap(id)`;热区点亮 `.bmp-hit-on`;标记 = 76rpx 容器
(负 margin 居中,transform 留给旋转)+ `.bmp-bar` 长轴条(长=尺寸、rotate=走向)+ `.bmp-dot` 编号点(不旋转)。
3. **`pages/triage/create.uvue`(重写伤情标记区)**:两个 hr-bodymap(正/背)+ 概览行
`injurySummary` + 复制 JSON 到剪贴板)+ 标记列表(点行=编辑、✕ 走 showModal 确认删除);
x-drawer 表单:正/背分段切换、部位/性质/出血/尺寸/走向 chips、±45° 步进、走向实时预览条、
描述 textarea + 「按当前选项重生成」;编辑态多「删除这处标记」。
- 表单字段**全用顶层 ref**(uvue 嵌套对象响应式不可靠)。
- 自动描述用 `lastAutoDesc` 对比判断「用户是否手改过」:描述为空或等于上次自动值才覆盖。
- 存草稿 = `uni.setStorageSync(HR_DRAFT_KEY, injuriesToJson(marks))`onLoad 里 `injuriesFromJson` 回显。
- 编辑时部位没变则沿用原落点(防止「编辑一下位置就跳走」);变了才按 nextMarkAnchor 重算。
### 验证(三条腿,全部通过)
- 静态校验 `check-uvue-hr.js`**50 .uvue / 32 路由 / 12 .uts / 0 错误**(新增 hr-bodymap + injury.uts)。
- **数据层行为断言 `hr-injury-test.js`(新手法,很值)**esbuild 把 .uts 转 CJS 直接在 node 里跑。
关键是 mock `JSON.parseArray` 成「包了 getString/getNumber 的对象」(UTSJSONObject 语义:
取不到或类型不符返回 null)——直接 JSON.parse 的普通对象没有这些方法会假崩。
44 条断言全过:正背面命名方向、热区不重叠不越界、JSON 无损往返、缺字段/脏 JSON/越界坐标兜底、
同部位 9 点落点互异、重排返回新数组不改原数组。脚本在 `~/.workbuddy/binaries/node/workspace/`
- **视觉预览 `.workbuddy/bodymap-preview.html`+png**rpx 按 0.5 折算 px 复刻,
核对热区覆盖轮廓、标记横/竖/45° 长轴居中旋转、抽屉 chips 与走向预览,截图确认无误。
### x-drawer 使用要点(本次确认)
- `v-model:show` 可用(库内 x-picker 也这么用);bottom 时 `size` 就是内容高(默认 50%,本次 72%)。
- 自定义 footer**必须 `:show-footer="true"`** 再给 `<template v-slot:footer>`;此时确认按钮自己
`@click` 处理并自己把 show 置 false(用内置 footer 时 confirm 事件后会自动关)。
- H5 下 teleport 到 uni-page、APP 内联渲染,都安全;标题可用 `<template v-slot:title>` 自定义。
- `.hr-mask`absolute)与 drawerfixed zIndex 1100)同放 hr-page 的 overlay 插槽互不干扰。
- `:key="p.id + dSide"`:正/背面切换时 chips 强制重建,避免同 key 只改文本不刷新。
---
## 第十轮:App 端编译错误批量修复(CSS / UTS / 类型三类)
### ① App uvue CSS`@keyframes`/`animation`/`page`/`body` 全部不可用(★ 推翻旧结论)
构建日志:`[plugin:uni:app-uvue-css] ERROR: Selector '0%'/'75%'/'from'/'to'/'page'/'body' is not supported` +
`WARNING: 'animation' is not a standard property`
**第六/七轮记的「@keyframes + animation 可用」是错的**App 端 keyframe 选择器直接 ERROR。
- 5 处动效全部改为 **setInterval + ref 驱动内联样式**
| 动效 | 文件 | 实现 |
|---|---|---|
| 待接收红点呼吸 | `pages/event/index.uvue` | `pulseF` 25 帧×68ms=1.7s,前 19 帧扩散(0→16rpx、0.55→0),后 6 帧静默;`dotStyle(e)` 下发 box-shadow |
| 扫描线 ×2 | `pages/warehouse/scan.uvue``components/hr-scan-cam` | `laserT` 步长 **0.03125(=1/32)**32 帧×68ms≈2.2s 三角波;`laserStyle` 下发 top(rpx 定值,不用 %) |
| 手风琴展开 ×2 | `pages/study/center.uvue``pages/study/wrong-book.uvue` | `accT` 30ms/帧 0→1`accStyle(i)` 下发 opacity+translateY,完成后返回空串 |
- **步长必须取 1/帧数**:用 0.035 这类值永远差几 rpx 打不到端点(`hr-anim-test.js` 断言抓出来的)。
红点扩散边界是 `i > PULSE_RAMP`= 号要让峰值帧留在扩散段)。
- `App.uvue` 删掉 `page, body { background-color }`(页面底色由 `.hr-page`/根节点承担);
`#ifdef WEB` 里的 `body::-webkit-scrollbar` 保留(条件编译会剔除,不进 App 编译)。
### ② UTS「先使用后定义」收尾(24 处全清)
UTS **不提升函数声明**,调用点必须在定义之后。最后两处:`warehouse/scan.uvue`shortCode/nowTime/handleCode
提到 onScan 前)、`utils/role.uts`rescueHas/keeperHas 提到 canAccess 前)。扫描脚本 `check-uts-order.js` → 0。
### ③ 模板里直接调用 import 函数 → App 端崩(★ 新坑)
`pages/course/subject.uvue:48` `<x-icon :name="toRemixIcon(e.icon)">`
编译产物是 **`unref(toRemixIcon)(e.icon)`**(编译器分不清 import 的是 ref 还是函数),
Kotlin 报 `找不到名称"invoke"` / `Function invocation 'toRemixIcon(...)' expected`
**script 内定义的函数走 `_ctx.xxx` 没事;只有 import 的会这样。**
解法:页面里包一层 `function entryIcon(g : string) : string { return toRemixIcon(g) }`
已在 `utils/icon.uts` 头注释、`MEMORY.md`、skill 里三处记录;扫描脚本 `check-uts-tpl-import.js` → 0。
### ④ x-tabs 的 change 事件类型:下发 TABS_ITEM,不是 TABS_ITEM_INFO(★ 新坑)
`IllegalArgumentException: ...argument 1 has type TABS_ITEM_INFO, got TABS_ITEM`
库内部 `_list()` 把传入的 `TABS_ITEM_INFO[]` 转成 `TABS_ITEM[]``$emit('change', item, index)`
四个页面同错(event/index、warehouse/devices、triage/detail、study/center),
形参改 `item : TABS_ITEM`,判空改 `item.id == ''`(字段全是非可选,别再 `!` 断言)。
**教训**tmx-ui 凡「XXX_ITEM_INFO 传入、XXX_ITEM 下发」的组件,回调形参要用下发的类型,先读源码确认。
### 新增校验脚本(`~/.workbuddy/binaries/node/workspace/`
- `check-uvue-css.js`App 端 CSS 兼容性(识别 `#ifdef` 跳过非 App 分支,注释剥离要在条件编译判定**之后**)
- `check-uts-tpl-import.js`:模板调用 import 函数
- `hr-anim-test.js`:动效数值断言(端点命中 / 区间 / 周期 / 样式串格式),29 条全过
- 基线:50 .uvue / 32 路由 / 12 .uts / CSS ERROR 0 / 顺序 0 / 模板 import 0
### 环境备注
- Edit 偶发 `EBUSY: resource busy or locked`(同轮并行改多个文件时)→ 重试即可成功。
- Bash 里 `ls/tail` 仍不可用,管道尾接 `tail` 会把整条命令拖成 127 → 直接跑命令不加管道。
---
## 第十一轮:`background-image` App 端兼容(用户提问 → 全站修正)
用户问:**「APP 中不支持 background-image,如何解决?」** 查官方文档
`doc.dcloud.net.cn/uni-app-x/css/background-image` 后结论如下。
### 规则(两条完全不同的路)
| 场景 | App 端 | 解法 |
|---|---|---|
| `background-image: url(...)` 照片 | **完全不支持**(原生视图没有背景图概念) | ① `<image>` 组件 `position:absolute` 铺满容器(推荐);② `onReady` 后用 `DrawableContext``uniElement.getDrawableContext()`)原生绘制 |
| `linear-gradient(...)` 渐变 | **支持,但只有一种合法形态** | `linear-gradient(<关键字方向>, <色1>, <色2>)` |
渐变的三条硬约束:
1. **恰好 3 个参数**(方向 + 起色 + 止色);
2. **方向必须是关键字**`to right/left/top/bottom` 或四向对角线 `to bottom right`…(**`135deg`/`140deg`/`160deg` 在 App 端无效**,H5 能跑、App 静默失效);
3. **恰好 2 个颜色**,不能带 `%` 色标(`#fff 0%`/`#fff 55%` 非法)、不能 3 色。
另:`background-image` 优先级 > `background-color`**flatten/vapor 模式下 `background-image` 被完全禁用**(本项目未用 vapor,已 grep 确认)。
### 本项目实际情况:10 处「渐变」全部违规 → 已全改
原本全是角度 + 多色 / 百分比色标(H5 好看、App 不生效),统一改成 **`to bottom right` + 首尾两色**
| 文件 | 原 | 现 |
|---|---|---|
| `pages/login/index.uvue` | `140deg, #2563EB, #1D4ED8` | `to bottom right, #2563EB, #1D4ED8` |
| `pages/home/index.uvue` | `135deg, #2563EB 0%, #1D4ED8 55%, #1E40AF 100%` | `to bottom right, #2563EB, #1E40AF` |
| `pages/event/index.uvue` | `160deg, #7F1D1D 0%, #B91C1C 55%, #DC2626 100%` | `to bottom, #7F1D1D, #DC2626` |
| `pages/mine/index.uvue` | 角度+多色 | `to bottom right, #2563EB, #1E40AF` |
| `pages/course/practice.uvue` | `135deg, #2563EB, #1D4ED8` | `to bottom right, #2563EB, #1D4ED8` |
| `pages/course/subject.uvue` | 同上 | 同上 |
| `pages/event/detail.uvue` | `135deg, #1E3A8A 0%, #2563EB 72%` | `to bottom right, #1E3A8A, #2563EB` |
| `pages/triage/detail.uvue` | 角度+多色 | `to bottom right, #F59E0B, #D97706` |
| `pages/triage/batch-treat.uvue` | 角度 | `to bottom right, #2563EB, #1D4ED8` |
| `pages/triage/batch-transfer.uvue` | 角度 | `to bottom right, #2563EB, #1D4ED8` |
> 项目**没有**用照片背景,全部是渐变 → 无需 `<image>` 兜底,只需改渐变语法。
### 新增校验脚本
`~/.workbuddy/binaries/node/workspace/check-uvue-bg.js <项目根>`:扫 `<style>` 与内联 `:style` 里的
`background`/`background-image`,报四类问题(`url()` / 角度 / 非关键字方向 / 颜色数≠2 或带 `%`),跳过 `uni_modules`
**结果:52 文件 / ERROR 0**
---
## 第十二轮:hr-row + hr-chip 筛选 → xRadioButton 分段切换
用户要求「将页面的 hr-row 换成 xRadioButton 进行切换」。全站排查后确认这种「切换」共 **5 处**
且是同一套写法(`<scroll-view :scroll-x>``<view class="hr-row">` + `hr-chip v-for`):
| 页面 | 文案 |
|---|---|
| `pages/study/center.uvue` | 全部 / 心肺复苏 5 / 气道管理 4 / AED 3 |
| `pages/study/wrong-book.uvue` | 全部 12 / 心肺复苏 5 / 气道管理 4 / AED 3 |
| `pages/message/list.uvue` | 全部 5 / 派单通知 2 / 系统消息 3 |
| `pages/message/index.uvue` | 全部 9 / 库存预警 4 / 借用审批 3 / 系统 2 |
| `pages/triage/treat-records.uvue` | 全部 4 / 止血 1 / 循环 1 / 分级 2 |
`hr-chip` 另有 2 处是「错 N 次」红色标签,非切换,保留。)
### 改造要点
1. **`RADIO_BUTTON`interface.uts= `{ id:string, icon?, title, disabled? }`**change 下发 `(string, number)`
与 x-tabs 不同(那边是 TABS_ITEM 对象),**没有形参类型坑**;库自身 `x-color-view` 就是 `v-model` 用法,照抄安全。
2. 列表直接写字面量:`chipTabs : RADIO_BUTTON[] = [{ id:'0', title:'全部' } as RADIO_BUTTON, ...]`
`chipId = ref('0')` + `v-model`。原 `chip = ref(0)` 换成
`computed(() : number => { const i = parseInt(chipId.value); return isNaN(i) ? 0 : i })`
(3 处筛选逻辑依赖序号;center/wrong-book 本来就没用到 `chip`,直接删掉避免 unused)。
3. **删掉外层 `<scroll-view :scroll-x>`**xRadioButton 是等宽 `flex:1` 分段,塞进横向滚动容器会收窄、
滑块 `createSelectorQuery` 定位错。用 `<view style="margin-bottom:8rpx">` 包一层保间距。
4. **字号实测必须 22rpx**:等宽 4 段时每段文本可用宽 60.75px(375 基准:343 内容宽 轨道 4px 内距,
÷4,再减每段左右 12px padding)。无头 Chrome 实测 26rpx 加粗「心肺复苏 5」= 63.9px → **截断**
24rpx = 59.0px97%,跨机型太险);**22rpx = 54.1px89%)安全**。
测量页 `node-workspace/radiobtn-fit-test.html`canvas measureText + DOM scrollWidth 双重验证)。
5. 圆角单位坑:滑块圆角 = `parseInt(round) - parseInt(space)` 后**按 px 下发**(外层才走 rpx),
`round=22rpx / space=4rpx` → 内 18px、外 11px,滑块呈胶囊、轨道 22rpx 圆角,视觉成立。
6. 样式基线(延续设计稿「选中=蓝底白字」语义):`bg-color #E5E7EB / active-color #2563EB /
active-font-color #FFFFFF / font-color #6B7280 / text-style font-weight:bold`。
### 校验
`check-uvue-hr`50 .uvue / 32 路由 / 12 .uts)、`check-uts-order`、`check-uts-tpl-import`、
`check-uvue-css`、`check-uvue-bg`、`audit-array-props` 全部 0 错误。
预览 `.workbuddy/radiobutton-preview.html` + `.png`(改造前/后对比 + 5 组文案可点击滑动)。
---
## 第十三轮:事件中心只保留「进行中 / 历史」
用户要求 `/pages/event/index` 只保留进行中和历史 Tab,并删除「待接收」相关部分。
用户在选项中确认:**删待接收 Tab + 全部待接收相关逻辑**。
### 删除清单(pages/event/index.uvue
| 项 | 内容 |
|---|---|
| Tab | 3 → 2(进行中 / 历史),`item-width 33.33% → 50%``stageIds ['0','1','2'] → ['0','1']` |
| P0 全屏推送 | 整块模板(`ev-push`,60 秒倒计时 / 立即接收 / 稍后处理)+ `pushing`/`countdown`/`timer` 状态 + `stopTimer`/`onLater`/`openPush`/`onAccept` 四个函数 |
| 卡片 pending 分支 | `onCardClick` 里的 `stage=='pending' → openPush` 判断(现在统一 `navTo('/pages/event/detail')`);`ev-foot` 的 `v-if="e.stage != 'pending'"` 守卫 |
| 死样式 | `.ev-push*`12 条)+ 早前遗留的 `.ev-nav*` / `.ev-tabs*` / `.ev-tab*` / `.ev-avt*`(共 30+ 条),`{}` 11/11 平衡,模板与样式 100% 对应 |
| 文案修正 | 空态 sub 由「新的派单会通过全屏告警推送给你」→「分配给你的派单会出现在「进行中」,处置完成后归档到「历史」」 |
### 保留(用户未要求删)
- **紧急红点呼吸**`dotStyle` / `startPulse`setInterval 25 帧):触发只看 `urgent` 字段,与 stage 无关;
注释已从「待接收红点」改写为「紧急事件红点」,并注明触发条件(还顺手改了注释里 `pulseK`→`pulseF` 的笔误)。
- 「进行中」下方两行灰字说明(仅显示与我相关的派单 / 时间与处理状态由后台同步)。
- `.evt-urgent`urgent 卡片描边)。
### 连带修正(跨页,仅注释/文案)
- `pages/message/list.uvue` 文件头注释:「与事件中心的待接收流程同源」→「跳转到事件中心查看进行中的派单」。
### ⚠ 遗留(已告知,待用户决定)
1. `mock/index.uts` 里 e3(城东工业园化学品泄漏)/ e4(城南小学集体腹痛)仍是 `stage:'pending'`
两个 Tab 都不会显示它们;
2. `pages/message/list.uvue` 的 P0 消息 `action:'去接收 '` 仍 `navTo('/pages/event/index')`
跳过去后进行中 Tab 看不到该事件 —— **可选解法:把 e3/e4 改成 'ongoing'(消息跳转闭环)或删掉这两条 mock**。
### 校验
全量静态校验 0 错误(50 .uvue / 32 路由 / 12 .uts / CSS / 顺序 / 模板 import / background / 数组 prop)。
预览 `.workbuddy/event-index-preview.html` + `.png`Tab 可切换,复刻 2 Tab + 卡片 + 灰字说明)。
---
## 第十四轮 · 批量处置/批量转运:点击开启相机 + 接收医院 hr-sheet
### 1. hr-scan-cam:相机改为「点击开启」(两页共用,仅此两个消费方)
- `cameraOn` 默认 `true` → `false`:进页面**不再自动占用摄像头**。
- 整块取景区 `.cm-view` 加 `@click="openCamera"`;未开启时渲染 `.cm-idle` 居中引导
camera-line 图标 + 「点击开启摄像头」+ 副文案),取景框/扫描线随 `v-if="cameraOn"` 隐藏。
- 底部按钮文案「已暂停扫码」→「开启扫码」,图标 pause-line → play-circle-line(已核对 remixicon.uts 存在)。
- **扫描线定时器随相机启停**startLaser/stopLaser 改在 openCamera/toggleCamera 内调用,
删除 onMounted 里的 startLaser(不再空跑);import 同步去掉 onMounted。
- 脚本重排:激光相关函数移到 openCamera/toggleCamera 之前(UTS 不提升)。
### 2. batch-transferop-hos 内联单选 → hr-sheet 弹层
- 底部表单的医院 v-for 单选(op-hos* 7 条样式)→ 折叠字段 `.op-pick`(医院名 + 床位 + ⌄,点击整行 openHos)。
- hr-page overlay 插槽:`hr-mask` + `hr-sheet`(沿用 victims/create 同一套全局类;
`.hr-sheet-mask` 虽在 hr-design.css 里但全项目没人用,实际惯例是 hr-mask)。
- 弹层选项为两行式:医院名(os-name,选中蓝/满员灰)+ hospitalMeta(距现场 km · 约N分钟)+ 床位/满员红字 + ✓;
mock 新导入 hospitalMeta。
- pickHospital:满员 → toast「该医院急诊已满员,不可选择」且**不关弹层**;可用 → 选中并关弹层。
- 删除 hosClass 与 op-hos* 死样式(中途误复制过一份 hosOptTextClass,已清理);
batch-treat 仅改头部注释(组件级改动自动生效)。
### 校验 & 预览
- 全量静态校验 0 错误(50 .uvue / 62 uts 顺序 / CSS / 模板 import / background / 数组 prop)。
- 预览 `.workbuddy/batch-transfer-preview.html` + `.png`(无头 Chrome 880x2080 截图:
状态A 相机未开启 + 折叠字段 / 状态B 弹层展开,均正确)。
---
## 第十五轮 · 修 x-upload-file 运行期 NPEdefaultList 缺 request
### 报错
```
error: java.lang.NullPointerException: null cannot be cast to non-null type kotlin.Any
at uni_modules/tmx-ui/components/x-upload-file/x-upload-file.uvue:234:5 (return { ... })
```
触发点:`pages/triage/create.uvue` 的 `:default-list="fileList"`(项为 `{ name, url }`**没有 `request`**)。
### 根因
- `x-file-s/utssdk/interface.uts` 里 `xFileSListType.request?: any` 是**可选字段**
模板从头到尾也没读过 `request`(只原样透传给宿主),所以缺它是合法的。
- 但 `covertList()` 里写的是 `let requestdata = el.getAny("request")` →
`request: requestdata as any`。UTS 的 `any` = Kotlin **非空** `Any`
`as any` 就是 `as Any`,值为 null 时直接抛。
- 同一段里 `type`L228-229)、`name`L230-231)都做了 `== null` 兜底,**只有 `request` 漏了**
属于组件自身缺陷,不是调用方传错。
- 佐证 `getAny` 本身不抛:`tmx-ui/config/xConfig.uts:198` 就是 `if(cfg.getAny('theme')!=null)`
且栈指向 `return {` 字面量那一行而非 `let x = getAny(...)` 那行 → 是字面量里的 `as` 强转炸的。
### 改法(就地补丁,两个条件编译分支都改)
`uni_modules/tmx-ui/components/x-upload-file/x-upload-file.uvue` `covertList()`
```uts
// 原:let requestdata = el.getAny("request") → request: requestdata as any
let requestdata : any | null = el.getAny("request") // 类型留可空
// 字面量里:request: requestdata, // 去掉 as any 强转
```
补丁处已加「⚠ 本地补丁(2026-09-24)」标记注释。`#ifdef APP || MP-WEIXIN` 与 `#ifdef WEB`
两个 return 分支都改了(只改一个会剩下一个平台崩)。
### 校验
- `{ } / ( ) / [ ]` 计数配平(137/137、221/221、37/37);
- 全量静态校验 0 错误(check-uvue-hr / check-uts-order / check-uvue-css /
check-uts-tpl-import / check-uvue-bg)。
- ⚠ **本环境没有 HBuilderX,跑不起来真机**,需要用户重新编译验证。
### 顺带登记的隐患(未改,同类写法)
- `x-picker-selected.uvue:250/341/346` `el.getAny(props.idKey)!` —— 项缺 idKey 时会崩(`!` 断言)。
- `x-table.uvue` 模板里 `{{item.getAny(item2.key)}}` —— 列 key 与数据不匹配时可能崩。
- 这两处本项目当前未触发,先记着;用到时按同一套改法处理。
### 沉淀
- 技能 `uvue-design-port`:第三步新增第 18 条(`background-image` 三参两色关键字渐变,
之前只写在脚本表里、正文漏了)+ 第 19 条(`any` 非空 / `as any` 崩 / getAny 语义 / 栈定位技巧);
第四步新增「库组件运行期崩溃:就地打补丁的标准流程」(标记注释 + 双分支 + 补丁清单 + 只做结构自检);
description 补上「运行期 NPE」触发词。
- 项目 MEMORY.mduvue 硬约束加第 19 条 + 新增「uni_modules 本地补丁清单」表。
+132
View File
@@ -0,0 +1,132 @@
# 医院救援 APPhospital-rescue-app-v2)· 项目长期约定
## 技术栈
- **uni-app xuvue + uts**,不是 uni-appvue)。页面后缀 `.uvue`,脚本 `<script lang="uts" setup>`
- UI 组件库 **Tmui4uni_modules/tmx-ui**,全局配置在 `main.uts`designSize 375 / unit px / 主色 #2563EB / dark light)。
- easycom 自动扫描 `components/组件名/组件名.uvue`(本项目自研组件统一 `hr-` 前缀)。
## 设计规范
- 设计稿基准 **375 × 812**`mobile-design/v2/hospital-rescue-ui-v2.html` 为唯一视觉依据(35 屏)。
- 设计稿 px = uvue pxuni-app x px 为逻辑像素),不要乘 2。
- 主色 #2563EB;四色腕带分级:危重 #DC2626 / 重伤 #F59E0B / 轻伤 #10B981 / 死亡 #1F2937
-#F3F4F6、卡片 #FFF、分割线 #F3F4F6 / #E5E7EB、文本 #1F2937 / #6B7280 / #9CA3AF
## 角标(xBadge)几何 — 已定稿,别再改尺寸
- 全站角标基准(用户指定):高/圆角 36rpx、字号 22.4rpx、红底白字;`.xBadge-countAndLabel``!important` 覆盖。
- **角标实际宽 28px,不是 18px**`min-width:36rpx` 是内容盒最小值,还要加左右各 10rpx 内边距。
- 定位走 `hr-design.css` 的「左锚定」:`top:-4rpx; left:44rpx; right:auto; transform:none`(复刻设计稿
`left:calc(50%+6px)`),配 `.xBadge{padding:0!important}` + 全链 `overflow:visible!important`
实测只压 icon 6px25%),3/5 Tab 与两位数都成立;**别改回 right 定位或库默认半出血**(会盖住 icon 42%~100%)。
- `x-tabbar``:offset="[0,5]"`,Tab 项高度写死 60px(内容约 53px,上下余量仅 ~3.5px)。
## uvue 硬约束(写样式前必读)
1. **不支持** `::before` / `::after` → 装饰一律用实体元素
2. **不支持** CSS 变量 `var()` → 颜色写实
3. **不使用** `display:grid``gap` → flex + margin
4. **只使用** class 选择器(不写后代选择器);**复合选择器 `.a.b` 也不要用** → 选中态改用 JS 计算 style 字符串内联下发
5. **Vue API 必须显式 import**`import { ref, computed } from 'vue'`
6. `computed` 有返回类型时必须写 `computed(() : T => {})`,不能写 `computed() : T => {}`
7. **模板里调用的函数必须在 script 中定义**,否则运行期崩 `_ctx.xxx is not a function`(编译期不一定报错)
8. **页面生命周期是 uvue 全局函数,不要从 `@dcloudio/uni-app` import**
显示/隐藏要用 `onPageShow` / `onPageHide`(不是 onShow/onHide
9. `onLoad` 取参用下标 `opt['id']``res.content``string|null|undefined` 要先判空
10. `uni.showToast``icon` 是字面量联合类型('none'|'success'|'error'|'loading'…),封装时形参别写 `string`
11. 不用 `aspect-ratio`;尺寸用 windowWidth 计算或用固定值
12. 不用内联 `<svg>`;图形用 view/border-radius/x-circle-progress 等替代
13. **不支持** `@keyframes` / `animation`(App 端编译期直接 ERROR,之前记的「可用」是错的);
也不支持 `page` / `body` 元素选择器(页面底色由 `.hr-page` 或页面根节点承担)
→ 动效一律改 `setInterval` + `ref` 驱动内联样式(参见 event/index 红点、warehouse/scan 与
hr-scan-cam 扫描线、study/center 与 wrong-book 手风琴);扫描步长取 1/帧数 才能精确命中端点
14. **模板里不能直接调用 import 进来的函数**:编译成 `unref(fn)(x)`App 端 Kotlin 报
`找不到名称"invoke"` / `Function invocation 'xxx(...)' expected`#error18
→ 在页面 script 里包一层本文件定义的 wrapper 再给模板用
15. **UTS 不提升函数声明**:被调用的函数必须写在调用点之前(computed 回调里最容易踩)
→ 扫描脚本 `check-uts-order.js`
16. **`background-image` 在 App 端**`url(...)` 图片**完全不支持**(原生视图无背景图概念)
→ 铺底图/照片用 `<image>` 绝对定位铺满,或 `DrawableContext` 原生绘制;
`linear-gradient` **支持但只有一种合法形态**`linear-gradient(<关键字方向>, <色1>, <色2>)`
—— **必须 3 参数、方向必须是关键字(`to bottom right` 等,不是 `135deg`)、恰好 2 个颜色
(不能带 `%` 色标、不能 3 色)**flatten/vapor 模式下 `background-image` 直接失效。
→ 扫描脚本 `check-uvue-bg.js`
19. **UTS 里 `any` = Kotlin 非空 `Any`**null 强转必崩:
`java.lang.NullPointerException: null cannot be cast to non-null type kotlin.Any`
→ 想表达可空必须写 `any | null`**不要对可空值写 `as any`**(等于 `as Any`);
`UTSJSONObject.getAny(k)` 键不存在返回 null(不抛),调用方要自己兜;
结构体可选字段(`field?: any`)本就是可空,赋值时别强转。
排查技巧:栈指向哪个字面量 `return {` 那一行 → 就是字面量里某个 `as` 强转炸了。
## uni_modules 本地补丁清单(升级插件会被覆盖,务必回归)
| 文件 | 位置 | 现象 / 改法 |
|---|---|---|
| `uni_modules/tmx-ui/components/x-upload-file/x-upload-file.uvue` | `covertList()`(标记「⚠ 本地补丁(2026-09-24)」) | `defaultList` 项不带 `request``el.getAny("request") as any``null cannot be cast to non-null type kotlin.Any`;改为 `let requestdata : any | null = el.getAny("request")` + 去掉 `as any`APP/MP 与 WEB 两个 return 分支都改) |
## 分段切换(xRadioButton)— 全站 5 处已统一
- 原「`hr-row` + `hr-chip` 横向滚动」筛选全部换成 `x-radio-button`study/center、study/wrong-book、
message/list、message/index、triage/treat-records。
- `RADIO_BUTTON = { id:string, icon?, title, disabled? }`**change 下发 `(string, number)`**
与 x-tabs 不同、无类型坑。`v-model` 绑 string id,业务要序号时用
`chip = computed(() : number => { const i = parseInt(chipId.value); return isNaN(i) ? 0 : i })`
- 列表直接写字面量数组(项目惯例 `{ id:'0', title:'x' } as RADIO_BUTTON`),别再用 string[] + v-for 拼。
- **必须去掉外层 `<scroll-view :scroll-x>`**:组件是等宽 `flex:1` 分段,放进横向 scroll-view 会收窄、滑块定位错。
- 圆角坑:滑块圆角 = `parseInt(round) - parseInt(space)` 且**按 px 下发**(只有外层 round 跟 rpx),
round/space 传 rpx 时内外单位不一致 → 现配 `height=64rpx space=4rpx round=22rpx`(滑块呈胶囊,视觉 OK)。
- **字号必须 ≤22rpx**:等宽 4 段每段文本可用宽仅 60.75px,26rpx 加粗「心肺复苏 5」=63.9px 会截断
(无头 Chrome 实测),22rpx=54.1px89%)安全;改文案先跑 `node-workspace/radiobtn-fit-test.html`
- 样式基线:`bg-color #E5E7EB / active-color #2563EB / active-font-color #FFFFFF / font-color #6B7280`
## 事件中心(pages/event/index
- 用户定稿:**只保留「进行中 / 历史」两个 Tab(各占 50%)**,原「待接收」Tab 与 P0 全屏推送
(60 秒倒计时 / 立即接收 / 稍后处理 / 卡片 pending 分支)已整体移除——别按旧设计稿加回来。
- 紧急红点呼吸(dotStyle / startPulse)保留,触发只看数据的 `urgent` 字段(与 stage 无关)。
- ⚠ 遗留(已告知用户,待决定):mock 里 e3/e4 仍是 `stage:'pending'`(页面上不可见);
message/list 的「去接收 ›」仍跳事件中心,但进行中 Tab 看不到对应事件 —— 需把这两条改 'ongoing' 或删除。
## 三段式扫码作业页(批量处置 / 批量转运)
- `hr-scan-cam` **默认不启动相机**cameraOn=false):点击整块取景区 `openCamera()` 才开启,
未开启时显示居中引导「点击开启摄像头」;底部按钮文案 开启扫码 ⇄ 连续扫码中。
扫描线定时器随相机开关启停(openCamera/toggleCamera 里 startLaser/stopLaser),不要 onMounted 空跑。
- 批量转运「接收医院」是折叠字段 `.op-pick`(医院名+床位+⌄),点击整行唤起
`hr-mask + hr-sheet` 弹层(与 victims/create 同一套类);选项为「医院名 + hospitalMeta 距离」两行,
满员医院名置灰(os-name-dim)+ 床位红字,点它弹 toast 且**不关闭弹层**,选可用医院才关。
## 目录约定
```
components/ 自研通用组件(hr- 前缀,easycom
common/styles/hr-design.css 全局设计系统(App.uvue 里 @import
mock/types.uts 数据模型类型
mock/index.uts mock 数据(换接口只改这里)
utils/role.uts 会话 + 双角色 TabBar + 页面权限
utils/nav.uts 统一跳转(含权限拦截)
utils/quiz.uts 答题状态(跨页共享)
pages/ 页面(按模块分子目录:home/event/mine/course/study/triage/warehouse/message/design
```
## 双角色
- 救援人员端 rescuer:Tab 首页 / 事件 / 我的;工号以 `1` 开头
- 仓管人员端 keeper:Tab 扫码 / 仓库 / 设备 / 消息 / 我;工号以 `2` 开头
- 新增页面务必在 `utils/role.uts``canAccess` 里登记角色归属
## 页面骨架用法
```vue
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="标题">
<template v-slot:right>导航右侧</template>
<view class="hr-body">内容</view>
<template v-slot:footer>底部固定条</template>
<template v-slot:tabbar><hr-tabbar :active="0"></hr-tabbar></template>
<template v-slot:overlay>全屏/底部弹层</template>
</hr-page>
```
- 三段式作业页(相机+列表+表单)用 `:no-scroll="true"`
- 需要自定义头部的页面(伤员详情)直接手写根 view + hr-statusbar
## 数据来源
当前全部走 `mock/index.uts`;对接接口时替换 mock 取数实现,页面层不改。
## 伤情标记(2D 人体图鉴)
- 数据层 `utils/injury.uts`:HrInjury 纯基础类型可直接 JSON`injuriesToJson/injuriesFromJson` 存草稿与回显(接后端只换这两个)。
- 部位 id 按视野命名(armVL=图左臂)、name 按伤员左右(正面 armVL→右上肢);9 个热区互不重叠。
- 展示组件 `components/hr-bodymap`(正/背面,背面有脊柱线);业务/弹窗在 `pages/triage/create.uvue`(x-drawer 表单:部位自动填 + 其余默认填充 + 描述自动生成)。
- 标记 = 编号圆点(不旋转,色=出血情况)+ 长轴条(长=尺寸、rotate=走向);改数组必须重建再赋值。
- x-drawer 要点:`v-model:show`bottom 时 `size`=内容高;自定义 footer 需 `:show-footer="true"` + `<template v-slot:footer>`(自己关弹窗)。
+100
View File
@@ -0,0 +1,100 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>xRadioButton 分段切换预览</title>
<style>
* { box-sizing: border-box; }
body { margin:0; padding:22px 16px 30px; background:#F3F4F6;
font-family:-apple-system,"PingFang SC","Microsoft YaHei","Helvetica Neue",sans-serif; }
h1 { font-size:15px; font-weight:600; color:#1F2937; margin:0 0 4px; }
.sub { font-size:12px; color:#6B7280; margin:0 0 18px; }
.case { margin-bottom:18px; }
.case-t { font-size:12px; color:#6B7280; margin-bottom:7px; }
.phone { width:375px; padding:0 16px; }
.track { display:flex; flex-direction:row; height:32px; padding:2px; border-radius:11px;
background:#E5E7EB; overflow:visible; }
.wrap { position:relative; flex:1; height:100%; }
.btns { position:absolute; z-index:5; left:0; top:0; width:100%; height:100%; display:flex; flex-direction:row; }
.item { flex:1; display:flex; align-items:center; justify-content:center; padding:0 12px;
font-size:11px; font-weight:bold; white-space:nowrap; overflow:hidden;
text-overflow:ellipsis; cursor:pointer; user-select:none; position:relative; z-index:2;
color:#6B7280; }
.item.on { color:#FFFFFF; }
.bg { position:absolute; left:0; top:0; height:100%; background:#2563EB; border-radius:18px;
transition:transform .35s cubic-bezier(.18,.89,.32,1); z-index:1; }
.old { display:flex; flex-direction:row; padding-bottom:2px; }
.chip { display:flex; align-items:center; height:28px; padding:0 11px; border-radius:9px;
font-size:13.4px; font-weight:bold; color:#6B7280; background:#FFFFFF;
border:1px solid #E5E7EB; margin-right:7px; }
.chip.on { background:#2563EB; border-color:#2563EB; color:#FFFFFF; }
.note { font-size:11px; color:#9CA3AF; margin-top:6px; }
</style>
</head>
<body>
<h1>xRadioButton 分段切换(替换 hr-row + hr-chip</h1>
<p class="sub">5 处筛选控件已改造:学习中心 / 错题本 / 消息中心(救护端) / 消息中心(物资端) / 处置记录</p>
<div class="case">
<div class="case-t">改造前 · hr-row + hr-chip(横向滚动,可左右拖)</div>
<div class="phone">
<div class="old">
<div class="chip on">全部 12</div>
<div class="chip">心肺复苏 5</div>
<div class="chip">气道管理 4</div>
<div class="chip">AED 3</div>
</div>
</div>
</div>
<div class="case">
<div class="case-t">改造后 · x-radio-button(等宽分段,滑块跟随;点击可试)</div>
<div class="phone" id="ph"></div>
<div class="note">轨道 64rpx / 内间隙 4rpx / 圆角 22rpx / 字号 22rpx 加粗 · 底色 #E5E7EB ↦ 选中 #2563EB</div>
</div>
<div class="case">
<div class="case-t">其它四处(默认选中第 1 项,点击可试)</div>
<div class="phone" id="ph2"></div>
</div>
<script>
var sets = [
['全部 12','心肺复苏 5','气道管理 4','AED 3'],
['全部 5','派单通知 2','系统消息 3'],
['全部 9','库存预警 4','借用审批 3','系统 2'],
['全部 4','止血 1','循环 1','分级 2']
];
function build(host, labels, i0){
var t = document.createElement('div'); t.className = 'track';
var w = document.createElement('div'); w.className = 'wrap';
var b = document.createElement('div'); b.className = 'btns';
var bg = document.createElement('div'); bg.className = 'bg';
bg.style.width = (100/labels.length) + '%';
labels.forEach(function(s, i){
var it = document.createElement('div');
it.className = 'item' + (i === i0 ? ' on' : '');
it.textContent = s;
it.onclick = function(){ select(i); };
b.appendChild(it);
});
function select(i){
bg.style.transform = 'translateX(' + (i*100) + '%)';
var items = b.querySelectorAll('.item');
for (var k=0;k<items.length;k++){
items[k].className = 'item' + (k === i ? ' on' : '');
}
}
w.appendChild(b); w.appendChild(bg); t.appendChild(w); host.appendChild(t);
select(i0);
}
build(document.getElementById('ph'), sets[0], 0);
sets.slice(1).forEach(function(s){
var c = document.createElement('div');
c.style.marginTop = '10px';
document.getElementById('ph2').appendChild(c);
build(c, s, 0);
});
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+391
View File
@@ -0,0 +1,391 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>uvue 动效改写验证 · 红点呼吸 / 扫描线 / 手风琴</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 32px 24px 64px;
background: #F3F4F6;
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
color: #1F2937;
}
.wrap {
max-width: 1040px;
margin: 0 auto;
}
h1 {
font-size: 22px;
margin: 0 0 6px;
}
.sub {
font-size: 13px;
color: #6B7280;
margin: 0 0 8px;
line-height: 1.7;
}
.warn {
background: #FFFBEB;
border: 1px solid #FDE68A;
color: #92400E;
border-radius: 12px;
padding: 12px 16px;
font-size: 13px;
line-height: 1.7;
margin-bottom: 26px;
}
.grid {
display: flex;
flex-wrap: wrap;
gap: 20px;
}
.card {
flex: 1 1 300px;
min-width: 300px;
background: #FFF;
border: 1px solid #E5E7EB;
border-radius: 16px;
padding: 18px 20px 20px;
}
.card h2 {
font-size: 15px;
margin: 0 0 4px;
}
.src {
font-size: 11px;
color: #9CA3AF;
font-family: ui-monospace, Menlo, Consolas, monospace;
margin: 0 0 16px;
}
.stage {
height: 210px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
/* ---- 1. 红点 ---- */
.evt-card {
width: 210px;
background: #FFFBFB;
border: 1px solid #FECACA;
border-radius: 13px;
padding: 12px 13px;
}
.evt-top {
display: flex;
align-items: center;
}
.evt-dot {
width: 8px;
height: 8px;
border-radius: 4px;
margin: 0 8px;
background-color: #DC2626;
}
.evt-name {
flex: 1;
font-size: 15px;
font-weight: 700;
}
.pri {
background: #DC2626;
color: #FFF;
font-size: 11px;
font-weight: 700;
border-radius: 6px;
padding: 2px 8px;
}
/* ---- 2. 扫描线 ---- */
.sc-box {
width: 100%;
height: 178px;
background: #0F172A;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.sc-frame {
width: 93px;
height: 93px;
position: relative;
}
.cf {
position: absolute;
width: 13px;
height: 13px;
border: 3px solid #10B981;
border-radius: 4px;
}
.cf-tl {
top: 0;
left: 0;
border-right-width: 0;
border-bottom-width: 0;
}
.cf-tr {
top: 0;
right: 0;
border-left-width: 0;
border-bottom-width: 0;
}
.cf-bl {
bottom: 0;
left: 0;
border-right-width: 0;
border-top-width: 0;
}
.cf-br {
bottom: 0;
right: 0;
border-left-width: 0;
border-top-width: 0;
}
.sc-line {
position: absolute;
left: 8.4px;
right: 8.4px;
height: 2px;
background: #10B981;
box-shadow: 0 0 8px rgba(16, 185, 129, .9);
}
.sc-tip {
position: absolute;
bottom: 12px;
left: 0;
right: 0;
text-align: center;
font-size: 11px;
color: rgba(255, 255, 255, .78);
}
/* ---- 3. 手风琴 ---- */
.qa-card {
width: 250px;
background: #FFF;
border: 1px solid #E5E7EB;
border-left: 4px solid #2563EB;
border-radius: 10px;
padding: 11px 12px;
box-shadow: 0 2px 10px rgba(15, 23, 42, .06);
}
.qa-stem {
font-size: 13px;
font-weight: 700;
}
.qa-fold {
margin-top: 8px;
display: flex;
justify-content: flex-end;
}
.qa-btn {
font-size: 12px;
color: #2563EB;
font-weight: 700;
cursor: pointer;
user-select: none;
}
.qa-analysis {
margin-top: 10px;
padding: 9px 10px;
border-radius: 9px;
background: #F9FAFB;
font-size: 12px;
color: #6B7280;
line-height: 1.6;
}
.readout {
margin-top: 12px;
font-size: 11px;
color: #9CA3AF;
font-family: ui-monospace, Menlo, Consolas, monospace;
}
</style>
</head>
<body>
<div class="wrap">
<h1>uvue App 端动效改写 · 效果验证</h1>
<p class="sub">下方三个动效与 App 里 <b>完全相同</b> 的算法在跑(定时器逐帧推进 → 下发内联 style),
只是把 rpx 折算成 px1rpx = 0.5px @375)。</p>
<div class="warn">
<b>背景</b>App 端 uvue 的 CSS 编译器不接受 <code>@keyframes</code>0%/75%/from/to 选择器直接报
ERROR)与 <code>animation</code> 属性,
因此这 3 处动效已全部改为 <code>setInterval</code> + <code>ref</code> 驱动内联样式的等价实现。
</div>
<div class="grid">
<!-- 1 -->
<div class="card">
<h2>① 待接收红点「呼吸」</h2>
<p class="src">pages/event/index.uvue · evtPulse</p>
<div class="stage" style="background:#F9FAFB">
<div class="evt-card">
<div class="evt-top">
<div class="evt-dot" id="dot"></div>
<span class="evt-name">车祸伤 · 多人受伤</span>
<span class="pri">P0</span>
</div>
</div>
</div>
<div class="readout" id="dotOut">半径 0rpx · 透明度 0.55</div>
</div>
<!-- 2 -->
<div class="card">
<h2>② 取景框扫描线</h2>
<p class="src">pages/warehouse/scan.uvue · hrScanMove</p>
<div class="stage">
<div class="sc-box">
<div class="sc-frame">
<div class="cf cf-tl"></div>
<div class="cf cf-tr"></div>
<div class="cf cf-bl"></div>
<div class="cf cf-br"></div>
<div class="sc-line" id="line"></div>
</div>
<div class="sc-tip">将条码对准框内 · 自动识别</div>
</div>
</div>
<div class="readout" id="lineOut">top 60rpx → 313rpx · 32 帧 × 68ms = 2.18s</div>
</div>
<!-- 3 -->
<div class="card">
<h2>③ 手风琴展开入场</h2>
<p class="src">pages/study/center.uvue · qa-acc-in</p>
<div class="stage" style="background:#F9FAFB">
<div class="qa-card">
<div class="qa-stem">成人胸外按压的深度应为?</div>
<div class="qa-fold"><span class="qa-btn" id="accBtn">点击展开解析</span></div>
<div id="accHost"></div>
</div>
</div>
<div class="readout" id="accOut">opacity 0 → 1 · translateY -16rpx → 0 · 240ms</div>
</div>
</div>
</div>
<script>
/* ============ ① 红点呼吸:与 pages/event/index.uvue 完全同算法 ============ */
const PULSE_FRAMES = 25
const PULSE_RAMP = 19
let pulseF = 0
const dotEl = document.getElementById('dot')
const dotOut = document.getElementById('dotOut')
/* 改写前的 CSS 版本,用作「应该长什么样」的参照 */
const RING_MAX_PX = 16 * 0.5 // 16rpx
function renderPulse() {
const i = pulseF
let r = 0, a = 0
if (i > PULSE_RAMP) { r = 0; a = 0 } else {
const k = i / PULSE_RAMP
r = Math.round(k * 16)
a = Math.round(55 * (1 - k))
}
dotEl.style.boxShadow = '0 0 0 ' + (r * 0.5).toFixed(1) + 'px rgba(220,38,38,0.' + a + ')'
dotOut.textContent = '半径 ' + r + 'rpx · 透明度 0.' + a + ' · 第 ' + i + '/25 帧'
const nf = pulseF + 1
pulseF = nf >= PULSE_FRAMES ? 0 : nf
}
setInterval(renderPulse, 68)
renderPulse()
/* ============ ② 扫描线:与 pages/warehouse/scan.uvue 完全同算法 ============ */
const LASER_MIN = 60, LASER_SPAN = 253
let laserT = 0.0
const lineEl = document.getElementById('line')
const lineOut = document.getElementById('lineOut')
function renderLaser() {
laserT = laserT + 0.03125
if (laserT > 1) { laserT = laserT - 1 }
const k = laserT < 0.5 ? laserT * 2 : (1 - laserT) * 2
const top = Math.round(LASER_MIN + k * LASER_SPAN)
lineEl.style.top = (top * 0.5).toFixed(1) + 'px'
lineOut.textContent = 'top ' + top + 'rpx (区间 60 → 313) · 32 帧 × 68ms = 2.18s'
}
setInterval(renderLaser, 68)
renderLaser()
/* ============ ③ 手风琴:与 pages/study/center.uvue 完全同算法 ============ */
let accIdx = -1, accT = 1, accTimer = 0, accOpen = false
const accHost = document.getElementById('accHost')
const accBtn = document.getElementById('accBtn')
const accOut = document.getElementById('accOut')
function stopAcc() {
if (accTimer != 0) { clearInterval(accTimer); accTimer = 0 }
}
function runAcc() {
stopAcc()
accIdx = 0
accT = 0
accTimer = setInterval(() => {
const t = accT + 0.16
if (t >= 1) { accT = 1; stopAcc(); renderAcc(); return }
accT = t
renderAcc()
}, 30)
}
function renderAcc() {
if (!accOpen) { accHost.innerHTML = ''; accOut.textContent = '已收起'; return }
if (accT >= 1) {
accHost.innerHTML = '<div class="qa-analysis">按压深度 5~6 cm,频率 100~120 次/分;每次按压后让胸廓完全回弹。</div>'
accOut.textContent = 'opacity 1 · translateY 0 · 动画完成'
return
}
const t = accT
const y = Math.round(-16 * (1 - t))
accHost.innerHTML = '<div class="qa-analysis" style="opacity:' + t +
';transform:translateY(' + (y * 0.5).toFixed(1) + 'px)">按压深度 5~6 cm,频率 100~120 次/分;每次按压后让胸廓完全回弹。</div>'
accOut.textContent = 'opacity ' + t.toFixed(2) + ' · translateY ' + y + 'rpx'
}
accBtn.addEventListener('click', () => {
accOpen = !accOpen
accBtn.textContent = accOpen ? '收起解析' : '点击展开解析'
if (accOpen) { runAcc() } else { stopAcc(); accIdx = -1; accT = 1; renderAcc() }
})
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB