This commit is contained in:
2026-09-24 16:25:22 +08:00
commit 7428184f01
1198 changed files with 314515 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
/unpackage/
/docweb/node_modules/
/docweb/dist/
/docs/node_modules/
.DS_Store
/uni_modules/x-ocr-s/utssdk/app-ios/Resources/
*._
._.*
._.
._*
+10
View File
@@ -0,0 +1,10 @@
{
"version" : "1.0",
"configurations" : [
{
"customPlaygroundType" : "device",
"playground" : "standard",
"type" : "uni-app:app-android"
}
]
}
+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

+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"
package="uni.app.UNIB73859A">
<application>
<provider android:name="androidx.core.content.FileProvider"
android:authorities="xShareS.FileProvider" android:exported="false"
android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
+62
View File
@@ -0,0 +1,62 @@
<script lang="uts">
export default {
onLaunch: function () {
// #ifdef APP||WEB
uni.loadFontFace({
global: true,
family: 'remixicon',
source: "url('/static/tmui4xLibs/static/remixicon.ttf')",
success() {
uni.setStorageSync("loadedFontBytmx", "true")
},
fail(_error : any) {
uni.setStorageSync("loadedFontBytmx", "")
},
})
// #endif
},
onShow: function () { },
onHide: function () { },
onLastPageBackPress: function () { },
onExit: function () { },
}
</script>
<style>
/* rpx单位 */
/* @import url(./uni_modules/tmx-ui/scss/uvue.min.css); */
/* px单位84kb */
@import url(./uni_modules/tmx-ui/scss/uvuePx.min.css);
/* 医院救援平台设计系统 · 全局样式 */
@import url(./common/styles/hr-design.css);
/* 字体图标,仅小程序端引用 */
/* #ifdef MP-WEIXIN */
@import url(./uni_modules/tmx-ui/scss/remixicon.min.css);
/* #endif */
/*
统一页面底色
⚠ App 端(uvueCSS 只允许 class 选择器,不支持 page / body 元素选择器:
这里不再写 `page, body { background-color: #F3F4F6; }`
页面底色统一由各页根节点承担 —— 组件页用 hr-page 的 page-bg
自绘页直接写 `style="background-color:#F3F4F6"`。
*/
/* #ifdef WEB */
body::-webkit-scrollbar {
display: none;
}
div::-webkit-scrollbar {
width: 10rpx;
height: 10rpx;
border-radius: 20rpx;
}
div::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 20rpx;
}
/* #endif */
</style>
+36
View File
@@ -0,0 +1,36 @@
# x-ui
#### Description
x-ui基于uniappx ui库,核心工具库等原生开发。
#### Software Architecture
Software architecture description
#### Installation
1. xxxx
2. xxxx
3. xxxx
#### Instructions
1. xxxx
2. xxxx
3. xxxx
#### Contribution
1. Fork the repository
2. Create Feat_xxx branch
3. Commit your code
4. Create Pull Request
#### Gitee Feature
1. You can use Readme\_XXX.md to support different languages, such as Readme\_en.md, Readme\_zh.md
2. Gitee blog [blog.gitee.com](https://blog.gitee.com)
3. Explore open source project [https://gitee.com/explore](https://gitee.com/explore)
4. The most valuable open source project [GVP](https://gitee.com/gvp)
5. The manual of Gitee [https://gitee.com/help](https://gitee.com/help)
6. The most popular members [https://gitee.com/gitee-stars/](https://gitee.com/gitee-stars/)
+37
View File
@@ -0,0 +1,37 @@
# x-ui
#### 介绍
x-ui基于uniappx ui库,核心工具库等原生开发。
#### 软件架构
软件架构说明
#### 安装教程
1. xxxx
2. xxxx
3. xxxx
#### 使用说明
1. xxxx
2. xxxx
3. xxxx
#### 参与贡献
1. Fork 本仓库
2. 新建 Feat_xxx 分支
3. 提交代码
4. 新建 Pull Request
#### 特技
1. 使用 Readme\_XXX.md 来支持不同的语言,例如 Readme\_en.md, Readme\_zh.md
2. Gitee 官方博客 [blog.gitee.com](https://blog.gitee.com)
3. 你可以 [https://gitee.com/explore](https://gitee.com/explore) 这个地址来了解 Gitee 上的优秀开源项目
4. [GVP](https://gitee.com/gvp) 全称是 Gitee 最有价值开源项目,是综合评定出的优秀开源项目
5. Gitee 官方提供的使用手册 [https://gitee.com/help](https://gitee.com/help)
6. Gitee 封面人物是一档用来展示 Gitee 会员风采的栏目 [https://gitee.com/gitee-stars/](https://gitee.com/gitee-stars/)
File diff suppressed because it is too large Load Diff
+187
View File
@@ -0,0 +1,187 @@
<template>
<!--
2D 人体图鉴(单面)
- 人体轮廓沿用 hr-design.css 里的 .bm-* 几何(192rpx × 368rpx),不要再改用 SVG
- 热区:9 个不重叠的可点部位,点一下就是「在该部位新增标记」
- 标记:编号圆点(不旋转,保证数字可读)+ 伤口长轴条(长度=尺寸、角度=走向、颜色=出血)
-->
<view class="bm-figure">
<!-- ===== 人体轮廓(装饰,不响应点击) ===== -->
<view class="bm-head"></view>
<view class="bm-torso"></view>
<view class="bm-arm-l"></view>
<view class="bm-arm-r"></view>
<view class="bm-leg-l"></view>
<view class="bm-leg-r"></view>
<!-- 背面加一条脊柱线,让正/背面一眼能分辨 -->
<view v-if="side == 'back'" class="bmp-spine"></view>
<!-- ===== 可点部位热区 ===== -->
<view v-for="(p, i) in parts" :key="p.id" :class="hitClass(p)" :style="hitStyle(p)"
@click="onHit(p.id)">
</view>
<!-- ===== 伤情标记 ===== -->
<view v-for="(m, i) in marks" :key="m.id" class="bmp-mark" :style="markStyle(m)" @click="onMark(m.id)">
<!-- 伤口长轴:先画在下方,编号圆点盖在上面 -->
<view class="bmp-bar" :style="barStyle(m)"></view>
<view class="bmp-dot" :style="dotStyle(m)">
<text class="bmp-dot-t">{{ m.no }}</text>
</view>
</view>
</view>
</template>
<script lang="uts" setup>
import { computed } from 'vue'
import { HrInjury, HrBodyPart, sizeBarWidth, bleedingColor, bleedingBarColor } from '@/utils/injury.uts'
/**
* 2D 人体图鉴
* 只负责「画」和「命中」,业务(弹窗、表单、JSON)全部交给页面,
* 这样同一套图能复用到批量检伤、伤情详情等页面。
*/
type HrBodyMapProps = {
/** 当前视野:front 正面 / back 背面 */
side : string
/** 该视野的部位热区表(来自 utils/injury.uts 的 partsOf */
parts : HrBodyPart[]
/** 该视野要显示的标记(页面先按 side 过滤好再传进来) */
marks : HrInjury[]
}
const props = withDefaults(defineProps<HrBodyMapProps>(), {
side : 'front',
parts : () : HrBodyPart[] => [] as HrBodyPart[],
marks : () : HrInjury[] => [] as HrInjury[]
})
const emits = defineEmits<{
/** 点击部位热区 */
(e : 'part-tap', partId : string) : void
/** 点击已有标记 */
(e : 'mark-tap', id : string) : void
}>()
/* ---------- 热区 ---------- */
function hitStyle(p : HrBodyPart) : string {
return 'left:' + p.x + '%;top:' + p.y + '%;width:' + p.w + '%;height:' + p.h + '%;'
}
/** 该部位是否已有标记(用于把热区点亮,一眼看出伤在哪) */
function hasMark(partId : string) : boolean {
for (let i = 0; i < props.marks.length; i++) {
if (props.marks[i].partId == partId) { return true }
}
return false
}
function hitClass(p : HrBodyPart) : string {
return hasMark(p.id) ? 'bmp-hit bmp-hit-on' : 'bmp-hit'
}
function onHit(partId : string) : void {
emits('part-tap', partId)
}
/* ---------- 标记 ---------- */
/** 标记整体位置:靠负 margin 居中(不用 transform,把 transform 留给旋转) */
function markStyle(m : HrInjury) : string {
return 'left:' + m.x + '%;top:' + m.y + '%;'
}
function dotStyle(m : HrInjury) : string {
return 'background-color:' + bleedingColor(m.bleeding) + ';'
}
/**
* 伤口长轴条:长度按尺寸、角度按 rot。
* left:50% + 负 margin 做水平居中,避免和 rotate 抢 transform。
*/
function barStyle(m : HrInjury) : string {
const w = sizeBarWidth(m.size)
const half = Math.round(w / 2)
return 'width:' + w + 'rpx;margin-left:-' + half + 'rpx;left:50%;' +
'background-color:' + bleedingBarColor(m.bleeding) + ';' +
'transform:rotate(' + m.rot + 'deg);'
}
function onMark(id : string) : void {
emits('mark-tap', id)
}
/* ---------- 导出给外部复用的换算(页面里的旋转预览用同一套) ---------- */
const sideLabel = computed(() : string => {
return props.side == 'back' ? '背面' : '正面'
})
</script>
<style>
/* 背面识别线:躯干中轴 */
.bmp-spine {
position: absolute;
left: 94rpx;
top: 78rpx;
width: 4rpx;
height: 108rpx;
border-radius: 2rpx;
background-color: #E2E8F0;
}
/* 部位热区:平时只用极浅虚线提示「这里可点」,避免糊住人体图 */
.bmp-hit {
position: absolute;
border-radius: 10rpx;
border: 2rpx dashed #EDF0F4;
}
/* 已有标记的部位点亮,形成「伤情分布」 */
.bmp-hit-on {
border: 2rpx solid #BFDBFE;
background-color: #EFF6FF;
}
/* 标记容器:绝对定位 + 负 margin 居中 */
.bmp-mark {
position: absolute;
width: 76rpx;
height: 76rpx;
margin-left: -38rpx;
margin-top: -38rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
/* 伤口长轴条:竖直居中于容器((76-24)/2 = 26rpx */
.bmp-bar {
position: absolute;
top: 26rpx;
height: 24rpx;
border-radius: 12rpx;
opacity: 0.8;
}
/* 编号圆点:不参与旋转 */
.bmp-dot {
width: 44rpx;
height: 44rpx;
border-radius: 22rpx;
border: 3rpx solid #FFFFFF;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.bmp-dot-t {
font-size: 23rpx;
font-weight: bold;
color: #FFFFFF;
}
</style>
+36
View File
@@ -0,0 +1,36 @@
<template>
<view :class="cardClass" :style="cardStyle">
<slot></slot>
</view>
</template>
<script lang="uts" setup>
import { computed } from 'vue'
/**
* 通用卡片容器
* variantdefault(默认) / list(自带列表内边距) / tight(紧凑) / danger(预警红底) / flat(无边框)
*/
const props = defineProps({
variant : { type: String, default: 'default' },
/** 自定义内边距,传空取 variant 默认值 */
padding : { type: String, default: '' },
/** 自定义外边距(底部) */
marginBottom : { type: String, default: '' }
})
const cardClass = computed(() : string => {
let c = 'hr-card'
if (props.variant == 'list') { c += ' hr-card-list' }
if (props.variant == 'danger') { c += ' hr-card-danger' }
if (props.variant == 'tight') { c += ' hr-card-tight' }
return c
})
const cardStyle = computed(() : string => {
let s = ''
if (props.padding != '') { s += `padding:${props.padding};` }
if (props.marginBottom != '') { s += `margin-bottom:${props.marginBottom};` }
return s
})
</script>
+52
View File
@@ -0,0 +1,52 @@
<template>
<text :class="chipClass" :style="chipStyle" @click="onClick">{{ text }}</text>
</template>
<script lang="uts" setup>
import { computed } from 'vue'
/**
* 标签 / 筛选片
* themedefault / red / amb / grn active 为选中态(主色实底)
*/
const props = defineProps({
text : { type: String, default: '' },
active : { type: Boolean, default: false },
/** default / red / amb / grn / pri */
theme : { type: String, default: 'default' },
/** 是否大号(作业页 40px 高) */
large : { type: Boolean, default: false },
/** 自定义背景 / 文字 / 边线(传空取 theme 值) */
bg : { type: String, default: '' },
color : { type: String, default: '' },
border : { type: String, default: '' }
})
const emits = defineEmits<{
(e : 'click') : void
}>()
const chipClass = computed(() : string => {
let c = 'hr-chip'
if (props.large) { c += ' hr-chip-lg' }
if (props.active) { c += ' hr-chip-active' }
if (!props.active) {
if (props.theme == 'red') { c += ' hr-chip-red' }
if (props.theme == 'amb') { c += ' hr-chip-amb' }
if (props.theme == 'grn') { c += ' hr-chip-grn' }
}
return c
})
const chipStyle = computed(() : string => {
let s = ''
if (props.bg != '') { s += `background-color:${props.bg};` }
if (props.color != '') { s += `color:${props.color};` }
if (props.border != '') { s += `border:1px solid ${props.border};` }
return s
})
function onClick() : void {
emits('click')
}
</script>
+96
View File
@@ -0,0 +1,96 @@
<template>
<view class="hr-empty">
<view class="hr-empty-ic">
<x-icon :name="iconName" color="#9CA3AF" font-size="52rpx"></x-icon>
</view>
<text class="hr-empty-b">{{ title }}</text>
<text v-if="sub != ''" class="hr-empty-s">{{ sub }}</text>
<view v-if="action != ''" class="hr-empty-btn" @click="onAction">
<text class="hr-empty-btn-t">{{ action }}</text>
</view>
<slot></slot>
</view>
</template>
<script lang="uts" setup>
import { computed } from 'vue'
import { toRemixIcon } from '@/utils/icon.uts'
/**
* 空状态 / 异常态
* icon 支持字符图标(设计稿沿用)或 RemixIcon 名,统一由 xIcon 渲染
*/
const props = defineProps({
icon : { type: String, default: 'file-list-3-line' },
title : { type: String, default: '暂无数据' },
sub : { type: String, default: '' },
/** 操作按钮文案,为空则不显示 */
action : { type: String, default: '' }
})
const emits = defineEmits<{
(e : 'action') : void
}>()
const iconName = computed(() : string => {
return toRemixIcon(props.icon)
})
function onAction() : void {
emits('action')
}
</script>
<style>
.hr-empty {
padding: 88rpx 40rpx 88rpx 40rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.hr-empty-ic {
width: 112rpx;
height: 112rpx;
border-radius: 36rpx;
background-color: #F3F4F6;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-bottom: 16rpx;
}
.hr-empty-b {
font-size: 31.4rpx;
font-weight: bold;
color: #6B7280;
}
.hr-empty-s {
font-size: 25.8rpx;
color: #9CA3AF;
margin-top: 10rpx;
text-align: center;
lines: 2;
}
.hr-empty-btn {
margin-top: 24rpx;
height: 72rpx;
padding-left: 36rpx;
padding-right: 36rpx;
border-radius: 20rpx;
background-color: #EFF6FF;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.hr-empty-btn-t {
font-size: 29.2rpx;
font-weight: bold;
color: #2563EB;
}
</style>
+124
View File
@@ -0,0 +1,124 @@
<template>
<!--
列表项:内部统一由组件库 xCell 渲染
对外保持本项目既有的 <hr-lrow icon theme title sub value …/> 用法,
这样 30+ 处列表无需逐个改造,且全站列表外观/交互由 xCell 统一保证。
-->
<x-cell :card="false" :margin="emptyMargin" :padding="cellPadding" :min-height="minHeight"
:show-bottom-border="!isLast" :bottom-border-insert="true" bottom-border-color="#F3F4F6" :title="title"
title-color="#1F2937" title-size="28rpx" :icon="resolvedIcon" :link="false" @click="onClick">
<!-- 左侧图标:彩色圆角块 + RemixIcon -->
<template v-slot:avatar>
<view class="hr-lrow-ic" :style="{ backgroundColor: tc.bg }">
<x-icon :name="resolvedIcon" :color="tc.color" font-size="32rpx"></x-icon>
</view>
</template>
<!--
标题走默认插槽自己渲染。
原因:库内 .title 写死 lines:2(且是 scoped 样式),全站列表希望标题只有一行、
超出省略号;用插槽把标题换成自己的元素,样式完全可控,不依赖 !important 覆盖。
-->
<template v-slot:default>
<text class="hr-lrow-t">{{ title }}</text>
</template>
<!-- 副标题 -->
<template v-slot:desc>
<text v-if="sub != ''" class="hr-lmain-s">{{ sub }}</text>
</template>
<!-- 右侧:自定义插槽 + 数值文本 -->
<template v-slot:right>
<slot name="right"></slot>
<text v-if="value != ''" class="hr-lval" :style="valueStyle">{{ value }}</text>
</template>
</x-cell>
</template>
<script lang="uts" setup>
import { computed } from 'vue'
import { toRemixIcon, themeColor, HrThemeColor } from '@/utils/icon.uts'
/**
* 列表行
* themedefault / pri / red / amb / grn —— 控制左侧图标底色
* valueThemet3(默认) / pri / red / amb / grn / t1
* icon 支持字符图标(设计稿沿用)或 RemixIcon 名,内部自动识别
*/
const props = defineProps({
icon : { type: String, default: '' },
theme : { type: String, default: 'default' },
title : { type: String, default: '' },
sub : { type: String, default: '' },
value : { type: String, default: '' },
valueTheme : { type: String, default: 't3' },
/** 右侧文字字号(rpx */
valueSize : { type: String, default: '26rpx' },
/** 值是否加粗 */
valueBold : { type: Boolean, default: false },
/** 是否是最后一行(去除分隔线) */
isLast : { type: Boolean, default: false },
/** 是否是第一行 */
isFirst : { type: Boolean, default: false }
})
const emits = defineEmits<{
(e : 'click') : void
}>()
const resolvedIcon = computed(() : string => {
return toRemixIcon(props.icon)
})
const tc = computed(() : HrThemeColor => {
return themeColor(props.theme)
})
const emptyMargin = computed(() : string[] => {
return ['0', '0']
})
const cellPadding = computed(() : string[] => {
return props.isFirst ? ['20rpx', '0'] : ['24rpx', '0']
})
const minHeight = computed(() : string => {
return '112rpx'
})
const valueStyle = computed(() : string => {
let color = '#9CA3AF'
if (props.valueTheme == 'pri') { color = '#2563EB' }
if (props.valueTheme == 'red') { color = '#DC2626' }
if (props.valueTheme == 'amb') { color = '#D97706' }
if (props.valueTheme == 'grn') { color = '#059669' }
if (props.valueTheme == 't1') { color = '#1F2937' }
const weight = props.valueBold ? 'bold' : 'normal'
return `color:${color};font-size:${props.valueSize};font-weight:${weight};`
})
function onClick() : void {
emits('click')
}
</script>
<style>
.hr-lrow-ic {
width: 64rpx;
height: 64rpx;
border-radius: 18rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
/* 列表行标题:单行显示,超出省略号 */
.hr-lrow-t {
font-size: 28rpx;
color: #1F2937;
lines: 1;
text-overflow: ellipsis;
}
</style>
+115
View File
@@ -0,0 +1,115 @@
<template>
<view class="hr-page" :style="{ backgroundColor: pageBg }">
<!-- 无导航栏时:普通状态栏占位 -->
<hr-statusbar v-if="navTitle == '' && !hiddenStatusbar" :bg="statusBg"></hr-statusbar>
<!-- 导航栏:使用组件库 x-navbar -->
<x-navbar v-if="navTitle != ''" :title="navTitle" :show-nav-back="showBack" :height="48" :bg-color="navBg"
:title-color="navTitleColor" :is-place="true" :static-transparent="statusBg == 'transparent'">
<template v-slot:right>
<view class="hr-row hr-navright" @click="onRightClick">
<slot name="right"></slot>
</view>
</template>
</x-navbar>
<!-- 滚动主体:外层 .hr-flow 提供「内容区」定位基准(不含导航栏/底栏) -->
<view v-if="!noScroll" class="hr-flow">
<scroll-view class="hr-scroll" :scroll-y="true" :show-scrollbar="false">
<slot></slot>
</scroll-view>
<!--
加载中覆盖层(组件库 xLoading)
用绝对定位铺满整个内容区:绝对定位元素不参与 flex 布局,
高度必然等于内容区,不受 scroll-view 压缩影响。
-->
<view v-if="xloading && loading" class="hr-loading-mask">
<x-loading label="加载中…" :icon-size="'44rpx'" :text-size="'26rpx'" :vertical="true"
color="#2563EB" text-color="#6B7280"></x-loading>
</view>
</view>
<view v-else class="hr-scroll">
<slot></slot>
</view>
<!-- 底部固定操作条 -->
<slot name="footer"></slot>
<!-- 底部 TabBar -->
<slot name="tabbar"></slot>
<!-- 覆盖层(底部选择器 / 加载遮罩等,不随内容滚动) -->
<slot name="overlay"></slot>
</view>
</template>
<script lang="uts" setup>
import { computed, ref, onMounted } from 'vue'
import { hrSession } from '@/utils/role.uts'
import { mockRequest } from '@/utils/request.uts'
/**
* 页面骨架
* 统一承载「状态栏占位 / 导航栏 / 可滚动主体 / 底部固定条 / 底部 TabBar」五段结构,
* 各业务页只需关心自己的内容,避免每页重复搭建骨架。
*
* 列表页只要传 :xloading="true",请求期间就自动用组件库 xLoading 展示「加载中」,无需在页面里写 loading。
*/
const props = defineProps({
/** 页面底色 */
pageBg : { type: String, default: '#F3F4F6' },
/** 状态栏占位背景色;传 transparent 表示内容顶到状态栏下方 */
statusBg : { type: String, default: '#FFFFFF' },
/** 导航栏标题,为空则不渲染导航栏 */
navTitle : { type: String, default: '' },
/** 导航栏背景色 */
navBg : { type: String, default: '#FFFFFF' },
/** 导航栏标题颜色 */
navTitleColor : { type: String, default: '#1F2937' },
/** 是否显示返回按钮 */
showBack : { type: Boolean, default: true },
/** 是否隐藏状态栏占位(内容自绘状态区时使用) */
hiddenStatusbar : { type: Boolean, default: false },
/** 关闭内部滚动(三段式作业页自行控制滚动区) */
noScroll : { type: Boolean, default: false },
/** 是否展示「加载中」覆盖层(请求期间用组件库 xLoading) */
xloading : { type: Boolean, default: false }
})
const emits = defineEmits<{
(e : 'right-click') : void
}>()
/** 数据请求中:请求期间显示 xLoading */
const loading = ref(true)
onMounted(() => {
if (!props.xloading) {
loading.value = false
return
}
mockRequest(() => {
loading.value = false
})
})
/** 返回失败时的兜底页(按角色回到各自的一级页) */
const backErrorPath = computed(() : string => {
return hrSession.role == 'keeper' ? '/pages/warehouse/scan' : '/pages/home/index'
})
function onRightClick() : void {
emits('right-click')
}
</script>
<style>
.hr-navright {
height: 96rpx;
padding-left: 16rpx;
padding-right: 8rpx;
}
</style>
+36
View File
@@ -0,0 +1,36 @@
<template>
<text v-if="text != ''" :class="pcClass" :style="pcStyle">{{ text }}</text>
</template>
<script lang="uts" setup>
import { computed } from 'vue'
/**
* 优先级角标
* 0 → P0 紧急(红) / 1 → P1 优先(黄) / 2 → P2 常规(蓝)
*/
const props = defineProps({
level : { type: Number, default: 2 },
/** 自定义文字,空则取 P0/P1/P2 */
text : { type: String, default: '' },
/** 实底(默认)或浅底 */
outline : { type: Boolean, default: false }
})
const label = computed(() : string => {
if (props.text != '') { return props.text }
return `P${props.level}`
})
const pcClass = computed(() : string => {
return `hr-pc hr-pc${props.level}`
})
const pcStyle = computed(() : string => {
let c = '#DC2626'
if (props.level == 1) { c = '#F59E0B' }
if (props.level == 2) { c = '#2563EB' }
if (!props.outline) { return '' }
return `background-color:${c}1A;color:${c};`
})
</script>
+56
View File
@@ -0,0 +1,56 @@
<template>
<view :class="barClass" :style="trackStyle">
<view class="hr-pbar-i" :style="barStyle"></view>
</view>
</template>
<script lang="uts" setup>
import { computed } from 'vue'
/**
* 进度条
* 高度用 rpx 下发,保证随屏宽等比缩放。
*/
const props = defineProps({
/** 百分比 0 ~ 100 */
percent : { type: Number, default: 0 },
/** 高度(设计稿 px,内部 ×2 转 rpx) */
h : { type: Number, default: 4 },
/** 进度色 */
color : { type: String, default: '#2563EB' },
/** 深色底(用于蓝色 hero / 视频条上) */
onDark : { type: Boolean, default: false }
})
const pct = computed(() : number => {
if (props.percent < 0) { return 0 }
if (props.percent > 100) { return 100 }
return props.percent
})
const barClass = computed(() : string => {
return props.onDark ? 'hr-pbar hr-pbar-dark' : 'hr-pbar'
})
const barColor = computed(() : string => {
return props.onDark ? '#FFFFFF' : props.color
})
const rpxH = computed(() : number => {
return props.h * 2
})
const trackStyle = computed(() : string => {
return `height:${rpxH.value}rpx;border-radius:${rpxH.value / 2}rpx;`
})
const barStyle = computed(() : string => {
return `width:${pct.value}%;height:${rpxH.value}rpx;border-radius:${rpxH.value / 2}rpx;background-color:${barColor.value};`
})
</script>
<style>
.hr-pbar-dark {
background-color: rgba(255, 255, 255, 0.22);
}
</style>
+191
View File
@@ -0,0 +1,191 @@
<template>
<view class="rl-wrap">
<view class="rl-head">
<text class="rl-t">伤员记录</text>
<view class="rl-c">
<text class="rl-c-t">{{ list.length }} 人</text>
</view>
<view class="hr-flex1"></view>
<text class="rl-clear" @click="onClear">清空 </text>
</view>
<scroll-view class="rl-list" :scroll-y="true" :show-scrollbar="false">
<view v-for="(v, i) in list" :key="v.id + '_' + i" class="rl-row">
<view class="rl-ix">
<text class="rl-ix-t">{{ i + 1 }}</text>
</view>
<view class="hr-flex1">
<view class="hr-row">
<text class="rl-name">{{ v.name }}</text>
<hr-wb :level="v.level" small></hr-wb>
</view>
<text class="rl-sub">{{ v.gender }} · {{ v.age }} 岁 · {{ v.injury }}</text>
</view>
<view class="rl-del" @click="onRemove(i)">
<x-icon name="close-line" color="#6B7280" font-size="13"></x-icon>
</view>
</view>
<view v-if="list.length == 0" class="rl-empty">
<x-icon name="scan-line" color="#9CA3AF" font-size="26"></x-icon>
<text class="rl-empty-t">尚未识别到伤员</text>
<text class="rl-empty-s">将腕带二维码对准上方取景框,自动连续识别</text>
</view>
<text v-if="list.length > 0" class="rl-more">— 继续扫腕带可追加 —</text>
<view style="height:6px"></view>
</scroll-view>
</view>
</template>
<script lang="uts" setup>
import { PropType } from 'vue'
import { HrVictim } from '@/mock/types.uts'
/**
* 伤员记录滚动列表(批量处置 / 批量转运共用)
* 三段落布局里唯一的滚动区。
*/
const props = defineProps({
list : { type: Array as PropType<HrVictim[]>, default: () : HrVictim[] => [] as HrVictim[] }
})
const emits = defineEmits<{
(e : 'remove', index : number) : void
(e : 'clear') : void
}>()
function onRemove(i : number) : void {
emits('remove', i)
}
function onClear() : void {
emits('clear')
}
</script>
<style>
.rl-wrap {
flex: 1;
flex-direction: column;
}
.rl-head {
display: flex;
flex-direction: row;
align-items: center;
padding: 18rpx 32rpx 14rpx 32rpx;
}
.rl-t {
font-size: 29.2rpx;
font-weight: bold;
color: #1F2937;
}
.rl-c {
margin-left: 16rpx;
background-color: #EFF6FF;
border-radius: 12rpx;
padding: 2rpx 14rpx 2rpx 14rpx;
}
.rl-c-t {
font-size: 23.6rpx;
font-weight: bold;
color: #2563EB;
}
.rl-clear {
font-size: 25.8rpx;
color: #9CA3AF;
}
.rl-list {
flex: 1;
padding: 0rpx 32rpx 20rpx 32rpx;
}
.rl-row {
display: flex;
flex-direction: row;
align-items: center;
background-color: #FFFFFF;
border: 2rpx solid #F3F4F6;
border-radius: 22rpx;
padding: 18rpx 24rpx 18rpx 24rpx;
margin-bottom: 14rpx;
}
.rl-ix {
width: 40rpx;
height: 40rpx;
border-radius: 12rpx;
background-color: #F3F4F6;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-right: 20rpx;
}
.rl-ix-t {
font-size: 22.4rpx;
font-weight: bold;
color: #6B7280;
}
.rl-name {
font-size: 30.2rpx;
font-weight: bold;
color: #1F2937;
margin-right: 12rpx;
}
.rl-sub {
font-size: 23.6rpx;
color: #9CA3AF;
margin-top: 4rpx;
}
.rl-del {
width: 40rpx;
height: 40rpx;
border-radius: 20rpx;
background-color: #F3F4F6;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-left: 16rpx;
}
.rl-empty {
flex: 1;
align-items: center;
justify-content: center;
padding-top: 60rpx;
padding-bottom: 60rpx;
}
.rl-empty-t {
font-size: 28rpx;
font-weight: bold;
color: #6B7280;
margin-top: 16rpx;
}
.rl-empty-s {
font-size: 23.6rpx;
color: #9CA3AF;
margin-top: 8rpx;
text-align: center;
}
.rl-more {
text-align: center;
font-size: 23.6rpx;
color: #9CA3AF;
padding: 4rpx 0rpx 8rpx 0rpx;
}
</style>
+279
View File
@@ -0,0 +1,279 @@
<template>
<view class="cm">
<!-- 取景区:默认不启动相机,点击本区域才开启(避免进页面就占用摄像头) -->
<view class="cm-view" @click="openCamera">
<!-- 真实相机扫码(APP / H5 均可用),失败时保留下方深色取景底 -->
<!-- #ifdef APP || WEB -->
<x-mlkit-scannig-u v-if="cameraOn" :flash="false" :autoOpenCamera="true" :cameraWidth="1080"
:cameraeiHght="1080" style="width:100%;height:100%;position:absolute;left:0;top:0"
@scan="onScanResult"></x-mlkit-scannig-u>
<!-- #endif -->
<!-- 未开启:居中引导,提示点击整块取景区 -->
<view v-if="!cameraOn" class="cm-idle">
<x-icon name="camera-line" color="#60A5FA" font-size="30"></x-icon>
<text class="cm-idle-t">点击开启摄像头</text>
<text class="cm-idle-s">对准伤员腕带二维码,自动连续识别</text>
</view>
<view v-if="cameraOn" class="cm-frame">
<view class="cm-cf cm-cf-tl"></view>
<view class="cm-cf cm-cf-tr"></view>
<view class="cm-cf cm-cf-bl"></view>
<view class="cm-cf cm-cf-br"></view>
<view class="cm-laser" :style="laserStyle"></view>
</view>
<view class="cm-cnt">
<text class="cm-cnt-t">已识别 <text class="cm-cnt-n">{{ count }}</text> 人</text>
</view>
</view>
<view class="cm-acts">
<view :class="cameraOn ? 'cm-act cm-act-on' : 'cm-act'" @click="toggleCamera">
<x-icon :name="cameraOn ? 'scan-2-line' : 'play-circle-line'" :color="cameraOn ? '#6EE7B7' : '#FFFFFF'"
font-size="15"></x-icon>
<text :class="cameraOn ? 'cm-act-t cm-act-t-on' : 'cm-act-t'">
{{ cameraOn ? '连续扫码中' : '开启扫码' }}
</text>
</view>
<view class="cm-act" @click="onManual">
<x-icon name="keyboard-line" color="#FFFFFF" font-size="15"></x-icon>
<text class="cm-act-t">手动输入编号</text>
</view>
</view>
</view>
</template>
<script lang="uts" setup>
import { ref, computed, onUnmounted } from 'vue'
/**
* 连续扫码取景组件(批量处置 / 批量转运共用)
* 顶部相机为固定区,**默认不启动**:点击顶部取景区才开启摄像头实时识别腕带二维码,
* 识别结果通过 scan 事件抛给页面。
*/
const props = defineProps({
/** 已识别人数 */
count: { type: Number, default: 0 }
})
const emits = defineEmits<{
(e : 'scan', code : string) : void
(e : 'manual') : void
}>()
const cameraOn = ref(false)
const lastCode = ref('')
/*
* 取景框扫描线
* ⚠ App 端 uvue 不支持 @keyframes / animation(编译期直接报 ERROR),
* 改为定时器逐帧推进 topcm-frame 高 172rpx16%~84% ⇒ 28~145rpx。
* 周期 2.2s68ms × 32 帧),与设计稿 hrCamScanMove 一致。
*/
const laserTop = ref(28)
let laserT = 0.0
let laserTimer : number = 0
const laserStyle = computed(() : string => {
return 'top:' + laserTop.value.toString() + 'rpx;'
})
function startLaser() : void {
if (laserTimer != 0) { return }
laserTimer = setInterval(() => {
laserT = laserT + 0.03125 // 1/3232 帧正好走完一个往返,端点(16%/84%)可精确命中
if (laserT > 1) { laserT = laserT - 1 }
const k = laserT < 0.5 ? laserT * 2 : (1 - laserT) * 2
laserTop.value = Math.round(28 + k * 117)
}, 68)
}
function stopLaser() : void {
if (laserTimer != 0) {
clearInterval(laserTimer)
laserTimer = 0
}
}
function onScanResult(res : string[]) : void {
if (res.length == 0) { return }
const code = res[0]
// 同一条码 2 秒内不重复计入
if (code == lastCode.value) { return }
lastCode.value = code
emits('scan', code)
}
/** 点击顶部取景区:开启相机并启动扫描线(已在扫码则忽略) */
function openCamera() : void {
if (cameraOn.value) { return }
cameraOn.value = true
startLaser()
}
/** 底部按钮:开启 / 暂停连续扫码 */
function toggleCamera() : void {
cameraOn.value = !cameraOn.value
if (cameraOn.value) { startLaser() } else { stopLaser() }
}
function onManual() : void {
emits('manual')
}
onUnmounted(() => {
stopLaser()
})
</script>
<style>
.cm {
background-color: #0B1220;
padding: 16rpx 28rpx 18rpx 28rpx;
}
.cm-view {
height: 216rpx;
border-radius: 28rpx;
overflow: hidden;
position: relative;
background-color: #10192A;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
/* 未开启相机时的居中引导(整块取景区可点,引导用户点击开启) */
.cm-idle {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.cm-idle-t {
font-size: 28rpx;
font-weight: bold;
color: #E5E7EB;
margin-top: 14rpx;
}
.cm-idle-s {
font-size: 22.4rpx;
color: rgba(148, 163, 184, 0.92);
margin-top: 8rpx;
}
.cm-cnt {
position: absolute;
left: 20rpx;
top: 20rpx;
background-color: rgba(17, 24, 39, 0.62);
border-radius: 14rpx;
padding: 6rpx 16rpx 6rpx 16rpx;
}
.cm-cnt-t {
font-size: 23.6rpx;
font-weight: bold;
color: #FFFFFF;
}
.cm-cnt-n {
font-size: 23.6rpx;
font-weight: bold;
color: #60A5FA;
}
.cm-frame {
width: 172rpx;
height: 172rpx;
border-radius: 32rpx;
background-color: rgba(255, 255, 255, 0.03);
position: relative;
}
.cm-cf {
position: absolute;
width: 44rpx;
height: 44rpx;
border: 6rpx solid #60A5FA;
border-radius: 14rpx;
}
.cm-cf-tl {
left: 0rpx;
top: 0rpx;
border-right-width: 0rpx;
border-bottom-width: 0rpx;
}
.cm-cf-tr {
right: 0rpx;
top: 0rpx;
border-left-width: 0rpx;
border-bottom-width: 0rpx;
}
.cm-cf-bl {
left: 0rpx;
bottom: 0rpx;
border-right-width: 0rpx;
border-top-width: 0rpx;
}
.cm-cf-br {
right: 0rpx;
bottom: 0rpx;
border-left-width: 0rpx;
border-top-width: 0rpx;
}
/* 扫描线:⚠ App 端 uvue 不支持 @keyframes/animation
上下位移已改为 script 内定时器推进 laserTop + laserStyle 下发 top。 */
.cm-laser {
position: absolute;
left: 14rpx;
right: 14rpx;
top: 28rpx;
height: 4rpx;
border-radius: 4rpx;
background-color: #93C5FD;
}
.cm-acts {
display: flex;
flex-direction: row;
margin-top: 18rpx;
}
.cm-act {
flex: 1;
height: 68rpx;
border-radius: 18rpx;
background-color: rgba(255, 255, 255, 0.10);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-right: 16rpx;
}
.cm-act-on {
background-color: rgba(16, 185, 129, 0.20);
}
.cm-act-t {
font-size: 25.8rpx;
font-weight: bold;
color: rgba(255, 255, 255, 0.9);
margin-left: 10rpx;
}
.cm-act-t-on {
color: #6EE7B7;
}
</style>
+38
View File
@@ -0,0 +1,38 @@
<template>
<view class="hr-sec" :style="wrapStyle">
<view v-if="showBar" class="hr-sec-bar"></view>
<text class="hr-sec-t">{{ title }}</text>
<view class="hr-flex1"></view>
<text v-if="more != ''" class="hr-sec-more" @click="onMore">{{ more }}</text>
</view>
</template>
<script lang="uts" setup>
import { computed } from 'vue'
/**
* 小节标题
* 设计稿中由 .sec-t::before 伪元素绘制蓝色竖条,uvue 不支持伪元素,故拆为实体 view。
*/
const props = defineProps({
title : { type: String, default: '' },
/** 右侧箭头文字,如「全部 ›」 */
more : { type: String, default: '' },
/** 是否显示蓝色竖条 */
showBar : { type: Boolean, default: true },
/** 是否为首个小节(收紧上边距) */
first : { type: Boolean, default: false }
})
const emits = defineEmits<{
(e : 'more-click') : void
}>()
const wrapStyle = computed(() : string => {
return props.first ? 'margin-top:8rpx;margin-bottom:16rpx;' : 'margin-top:32rpx;margin-bottom:16rpx;'
})
function onMore() : void {
emits('more-click')
}
</script>
+30
View File
@@ -0,0 +1,30 @@
<template>
<view :style="barStyle"></view>
</template>
<script lang="uts" setup>
import { ref, computed, onMounted } from 'vue'
/**
* 状态栏占位
* 设计稿中为 44px 的「9:41 + 状态图标」区域;真机上由系统状态栏承担,
* 因此这里只做高度占位,避免真机出现双状态栏。
*/
const props = defineProps({
/** 背景色,默认白 */
bg : { type: String, default: '#FFFFFF' }
})
const statusHeight = ref(44)
const barStyle = computed(() : string => {
return `height:${statusHeight.value}px;background-color:${props.bg};`
})
onMounted(() => {
const info = uni.getWindowInfo()
if (info.statusBarHeight > 0) {
statusHeight.value = info.statusBarHeight
}
})
</script>
+41
View File
@@ -0,0 +1,41 @@
<template>
<!--
使用组件库 x-tabbar。
- out-index=-1:关闭中间凸起(设计稿为平底导航)
- position=relativex-tabbar 默认 position:fixed 会脱离文档流,
导致页面内容被底栏遮住。改成 relative 后它会占位,页面无需再手动预留高度。
-->
<x-tabbar :list="tabs" :active-index="active" :out-index="-1" position="relative" :is-canvas-render="false"
bg-color="#FFFFFF" dark-bg-color="#FFFFFF" color="#9CA3AF" selected-color="#2563EB" font-size="26rpx"
icon-size="48rpx" border-color="#F3F4F6" :show-top-border="true" @click="onClick">
</x-tabbar>
</template>
<script lang="uts" setup>
import { computed } from 'vue'
import { hrSession, currentTabs, currentTabPages } from '@/utils/role.uts'
import { TABBAR_ITEM_INFO } from '@/uni_modules/tmx-ui/interface.uts'
/**
* 底部导航
* 双角色核心:救援人员端 3 Tab / 仓管人员端 5 Tab,切换角色后自动换装。
* 始终显式传 list / activeIndex,不依赖 tmx-ui 全局 TabBar 状态,避免多页面互相干扰。
*/
const props = defineProps({
/** 当前选中的 Tab 下标 */
active : { type: Number, default: 0 }
})
const tabs = computed(() : TABBAR_ITEM_INFO[] => {
// 依赖 hrSession.role,角色变化时自动刷新
const role = hrSession.role
return currentTabs()
})
function onClick(index : number) : void {
if (index == props.active) { return }
const pages = currentTabPages()
if (index < 0 || index >= pages.length) { return }
uni.reLaunch({ url: pages[index] })
}
</script>
+50
View File
@@ -0,0 +1,50 @@
<template>
<!-- 时间线 / 步骤条:内部统一由组件库 xSteps 渲染(vertical 模式) -->
<x-steps :model-value="activeIndex" vertical :icon-size="'40rpx'" :label-size="'27rpx'" :desc-size="'24rpx'"
color="#9CA3AF" active-color="#2563EB">
<x-steps-item v-for="(node, i) in nodes" :key="i" :label="node.title" :desc="descOf(node)"
:icon="iconOf(node)" :active-icon="activeIconOf(node)" :active-color="colorOf(node)"></x-steps-item>
</x-steps>
</template>
<script lang="uts" setup>
import { PropType, computed } from 'vue'
import { HrTimelineNode } from '@/mock/types.uts'
/**
* 时间线
* 设计稿的时间线 = 纵向步骤条,这里直接交给 xSteps(vertical)渲染,
* 用每项的 icon / activeIcon / activeColor 表达 normal / ok / warn 三种语义色。
*/
const props = defineProps({
nodes : {
type : Array as PropType<HrTimelineNode[]>,
default : () : HrTimelineNode[] => [] as HrTimelineNode[]
}
})
/** 全部节点都视为「已发生」 */
const activeIndex = computed(() : number => {
return props.nodes.length == 0 ? 0 : props.nodes.length - 1
})
function descOf(node : HrTimelineNode) : string {
return node.sub == '' ? node.time : `${node.time} · ${node.sub}`
}
function iconOf(node : HrTimelineNode) : string {
return 'checkbox-blank-circle-line'
}
function activeIconOf(node : HrTimelineNode) : string {
if (node.theme == 'ok') { return 'checkbox-circle-fill' }
if (node.theme == 'warn') { return 'error-warning-fill' }
return 'checkbox-blank-circle-fill'
}
function colorOf(node : HrTimelineNode) : string {
if (node.theme == 'ok') { return '#10B981' }
if (node.theme == 'warn') { return '#DC2626' }
return '#2563EB'
}
</script>
+59
View File
@@ -0,0 +1,59 @@
<template>
<!-- 提示条:内部统一由组件库 xAlert 渲染(thin 浅色模式) -->
<x-alert :status="status" :icon="resolvedIcon" :show-close="false" skin="thin" font-size="25.8rpx"
:round="alertRound" :padding="alertPadding" :margin="emptyMargin">
<text class="hr-tip-tx">{{ text }}</text>
<slot></slot>
</x-alert>
</template>
<script lang="uts" setup>
import { computed } from 'vue'
import { toRemixIcon } from '@/utils/icon.uts'
/**
* 提示条
* themeinfo / warn / err / ok —— 映射到 xAlert 的 status
*/
const props = defineProps({
theme : { type: String, default: 'info' },
icon : { type: String, default: '' },
text : { type: String, default: '' }
})
const status = computed(() : string => {
if (props.theme == 'warn') { return 'warn' }
if (props.theme == 'err') { return 'error' }
if (props.theme == 'ok') { return 'success' }
return 'info'
})
/** 未传图标时交给 xAlert 按 status 取默认图标 */
const resolvedIcon = computed(() : string => {
return props.icon == '' ? '' : toRemixIcon(props.icon)
})
/**
* xAlert 的 round / margin / padding 都是「数组」prop
* 传字符串会在内部 val.map 处抛 `val.map is not a function`,务必用数组。
*/
const alertRound = computed(() : string[] => {
return ['22rpx']
})
/** 两元素约定 = [左右, 上下](内部按 CSS 上右下左 重排) */
const alertPadding = computed(() : string[] => {
return ['24rpx', '20rpx']
})
const emptyMargin = computed(() : string[] => {
return ['0', '0']
})
</script>
<style>
.hr-tip-tx {
font-size: 25.8rpx;
line-height: 1.55;
}
</style>
+52
View File
@@ -0,0 +1,52 @@
<template>
<view :class="wbClass">
<view class="hr-wb-dot" :style="{ backgroundColor: dotColor }"></view>
<text :style="{ color: textColor }">{{ label }}</text>
</view>
</template>
<script lang="uts" setup>
import { computed } from 'vue'
/**
* 伤情腕带分级标签
* 设计规则:颜色 + 文字双重编码
* 1 危重(红) / 2 重伤(黄) / 3 轻伤(绿) / 4 死亡(黑)
*/
const props = defineProps({
level : { type: Number, default: 3 },
/** 自定义文字,空则按分级取默认文案 */
text : { type: String, default: '' },
/** 小号(作业列表内使用) */
small : { type: Boolean, default: false }
})
const label = computed(() : string => {
if (props.text != '') { return props.text }
if (props.level == 1) { return '危重' }
if (props.level == 2) { return '重伤' }
if (props.level == 3) { return '轻伤' }
return '死亡'
})
const dotColor = computed(() : string => {
if (props.level == 1) { return '#B91C1C' }
if (props.level == 2) { return '#92400E' }
if (props.level == 3) { return '#065F46' }
return '#111827'
})
const textColor = computed(() : string => {
return dotColor.value
})
const wbClass = computed(() : string => {
let c = 'hr-wb'
if (props.small) { c += ' hr-wb-sm' }
if (props.level == 1) { c += ' hr-wb1' }
if (props.level == 2) { c += ' hr-wb2' }
if (props.level == 3) { c += ' hr-wb3' }
if (props.level == 4) { c += ' hr-wb4' }
return c
})
</script>
+88
View File
@@ -0,0 +1,88 @@
<template>
<view class="hr-wc">
<view v-for="(it, i) in items" :key="i" class="hr-row">
<view v-if="i > 0" class="hr-wc-sep"></view>
<view class="hr-wc-cell">
<view class="hr-wc-dot" :style="{ backgroundColor: it.color }"></view>
<text class="hr-wc-num" :style="{ color: it.color }">{{ it.value }}</text>
<text class="hr-wc-lb">{{ it.label }}</text>
</view>
</view>
<view class="hr-flex1"></view>
<text class="hr-wc-note">{{ note }}</text>
</view>
</template>
<script lang="uts" setup>
import { computed } from 'vue'
/** 4 色分级计数条(常驻事件详情顶部) */
const props = defineProps({
red : { type: Number, default: 0 },
amber : { type: Number, default: 0 },
green : { type: Number, default: 0 },
black : { type: Number, default: 0 },
note : { type: String, default: '实时更新' }
})
type WcItem = { color : string, label : string, value : number }
const items = computed(() : WcItem[] => {
const list : WcItem[] = [
{ color: '#DC2626', label: '红', value: props.red } as WcItem,
{ color: '#F59E0B', label: '黄', value: props.amber } as WcItem,
{ color: '#10B981', label: '绿', value: props.green } as WcItem,
{ color: '#1F2937', label: '黑', value: props.black } as WcItem
]
return list
})
</script>
<style>
.hr-wc {
display: flex;
flex-direction: row;
align-items: center;
background-color: #FFFFFF;
border: 2rpx solid #E5E7EB;
border-radius: 24rpx;
padding: 22rpx 26rpx 22rpx 26rpx;
}
.hr-wc-sep {
width: 2rpx;
height: 36rpx;
background-color: #F3F4F6;
margin-left: 20rpx;
margin-right: 20rpx;
}
.hr-wc-cell {
display: flex;
flex-direction: row;
align-items: center;
}
.hr-wc-dot {
width: 22rpx;
height: 22rpx;
border-radius: 6rpx;
margin-right: 10rpx;
}
.hr-wc-num {
font-size: 29.2rpx;
font-weight: bold;
}
.hr-wc-lb {
font-size: 21.2rpx;
color: #9CA3AF;
margin-left: 6rpx;
}
.hr-wc-note {
font-size: 23.6rpx;
color: #9CA3AF;
}
</style>
+39
View File
@@ -0,0 +1,39 @@
module.exports = {
"is-custom-runtime": false,
"UNI_TEST_CUSTOM_ENV": {},
"compile": true,
"h5": {
"options": {
"headless": true
},
"executablePath": ""
},
"mp-weixin": {
"port": 9520,
"account": "",
"args": "",
"cwd": "",
"launch": true,
"teardown": "disconnect",
"remote": false,
"executablePath": "D:\\app\\微信web开发者工具\\微信开发者工具.exe"
},
"app-plus": {
"android": {
"id": "",
"executablePath": ""
},
"version": "",
"ios": {
"id": "",
"executablePath": ""
},
"uni-app-x": {
"version": "D:\\app\\HBuilderX.4.27.2024091305-alpha\\HBuilderX\\plugins\\uniappx-launcher\\base\\version.txt",
"android": {
"id": "emulator-5554",
"executablePath": "D:\\app\\HBuilderX.4.27.2024091305-alpha\\HBuilderX\\plugins\\uniappx-launcher\\base\\android_base.apk"
}
}
}
}
+30
View File
@@ -0,0 +1,30 @@
/*!
Theme: Default
Description: Original highlight.js style
Author: (c) Ivan Sagalaev <maniac@softwaremaniacs.org>
Maintainer: @highlightjs/core-team
Website: https://highlightjs.org/
License: see project LICENSE
Touched: 2021
*/pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:6rpx 10rpx}.hljs{background:#f3f3f3;color:#444}.hljs-comment{color:#697070}.hljs-punctuation,.hljs-tag{color:#444a}.hljs-tag .hljs-attr,.hljs-tag .hljs-name{color:#444}.hljs-attribute,.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-name,.hljs-selector-tag{font-weight:700}.hljs-deletion,.hljs-number,.hljs-quote,.hljs-selector-class,.hljs-selector-id,.hljs-string,.hljs-template-tag,.hljs-type{color:#800}.hljs-section,.hljs-title{color:#800;font-weight:700}.hljs-link,.hljs-operator,.hljs-regexp,.hljs-selector-attr,.hljs-selector-pseudo,.hljs-symbol,.hljs-template-variable,.hljs-variable{color:#ab5656}.hljs-literal{color:#695}.hljs-addition,.hljs-built_in,.hljs-bullet,.hljs-code{color:#397300}.hljs-meta{color:#1f7199}.hljs-meta .hljs-string{color:#38a}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}
table {
width: 100%;
border-collapse: collapse;
border: 2rpx solid #ddd;
}
tr {
text-align: center;
border: 2rpx solid #ddd;
}
/* 表格隔行变色 */
tr:nth-child(even) {
background-color: #f2f2f2;
}
td, th {
border: 2rpx solid #ddd;
padding: 8rpx 16rpx;
font-size:31.4rpx;
}
+1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+283
View File
@@ -0,0 +1,283 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<link href="./css/qulliCore.css" type="text/css" rel="stylesheet" />
<style>
body,
html {
background: transparent;
margin: 0;
padding: 0;
}
* {
padding: 0;
margin: 0;
}
#layout,
#layout:focus,
.ql-editor,
.ql-editor:focus {
border: none;
outline: none;
}
.ql-align-right {
text-align: right;
}
.ql-align-left {
text-align: left;
}
.ql-align-center {
text-align: center;
}
.ql-editor {
padding: 0;
}
.ql-size-medium {
font-size: 14px;
}
</style>
</head>
<body>
<div id="layout">
</div>
<script src="./js/uni.webview.1.5.5.js"></script>
<script src="./js/marked.min.js"></script>
<script src="./js/quill.js"></script>
<script type="text/javascript">
// 3. 注册自定义格式
const Inline = Quill.import('blots/inline');
class FontSizeBlot extends Inline {
static create(value) {
let node = super.create();
node.style.fontSize = value;
return node;
}
static formats(node) {
return node.style.fontSize;
}
}
FontSizeBlot.blotName = 'size';
Quill.register(FontSizeBlot);
var quill = new Quill('#layout', {
modules: {
toolbar: false // 不使用默认主题的工具栏
},
});
var testEditormdView;
function getBodyHeight() {
var targetDiv = document.body.querySelector("#layout")
var height = targetDiv.scrollHeight
// document.body.style.height = (height) + 'px'
window.parent.postMessage({
action: 'offsetHeight',
data: height + 5,
iframeId: iframeId
}, '*');
uni.postMessage({
data: {
action: 'offsetHeight',
data: height
}
})
}
var targetDiv = document.querySelector("#layout")
// 创建一个观察者对象并传入回调函数
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === 'childList' || mutation.type === 'attributes') {
getBodyHeight()
}
});
});
// 配置观察选项:
var config = {
attributes: true,
childList: true,
characterData: true,
subtree: true
};
// 选择需要观察变动的节点
observer.observe(targetDiv, config);
function getValueContentFormt(nr, isHtml) {
var content = ''
if (typeof nr == 'string') {
var cto = JSON.parse(nr);
if (cto.render == 'android') {
content = decodeURIComponent(atob(cto.value))
} else {
content = decodeURIComponent(cto.value)
}
} else {
if (nr.render == 'android') {
content = decodeURIComponent(atob(nr.value))
} else {
content = decodeURIComponent(nr.value)
}
}
if (!isHtml) {
try {
content = marked.parse(content)
} catch (e) {
alert(e)
}
}
return content
}
function markdown(nr, isHtml) {
var length = this.quill.getLength();
// 删除所有现有内容并在位置0插入新内容
quill.deleteText(0, length);
quill.clipboard.dangerouslyPasteHTML(0, getValueContentFormt(nr, isHtml))
// quill.root.innerHTML = getValueContentFormt(nr, isHtml)
}
function setBodyStyle(stylenr, isHtml) {
targetDiv.style = stylenr;
}
function getHtml() {
window.parent.postMessage({
action: 'toValue',
data: quill.root.innerHTML,
iframeId: iframeId
}, '*');
uni.postMessage({
data: {
action: 'toValue',
data: quill.root.innerHTML
}
})
}
function setSelectedStyle(nr, isHtml) {
var data = JSON.parse(getValueContentFormt(nr, isHtml))
var key = data.key.toLowerCase()
var value = data.value;
var eobj = {
'b': 'bold', //true,false
'i': 'italic', //true,false
's': 'strike', //true,false
'u': 'underline', //true,false
'link': 'link',
'img': 'image',
'video': 'video',
'align': 'align', //left,right,center
/**
* ordered:有序列表(编号列表)
* bullet:无序列表(项目符号列表)
* unchecked:未选中的复选框列表项
* checked:选中的复选框列表项
*/
'list': 'list',
'blockquote': 'blockquote', //true,false
'indent': 'indent', //缩进值0-8
'color': 'color',
'background': 'background',
'size': 'size',
'script': 'script', //super,sub
'header': 'header', //1-6
}
var range = quill.getSelection();
if (range) {
var currentFormat = quill.getFormat(range.index, range.length);
var styleforamtvalue = value;
var foramtStyleName = eobj[key]
if (
(foramtStyleName == 'header' && styleforamtvalue == 0) ||
(foramtStyleName == 'size' && styleforamtvalue == '') ||
foramtStyleName == 'align' && (styleforamtvalue == '' || styleforamtvalue == 'left')
) {
styleforamtvalue = false;
}
console.log(foramtStyleName, styleforamtvalue)
quill.format(foramtStyleName, styleforamtvalue);
}
}
quill.on('selection-change', function() {
var range = quill.getSelection();
if (!range) return;
var format = quill.getFormat(range);
var fontStyleOpts = {
img: format['image'] ? format['image'] : '',
link: format['link'] ? format['link'] : '',
b: format['bold'] === true,
i: format['italic'] === true,
s: format['strike'] === true,
u: format['underline'] === true,
align: format['align'] ? format['align'] : 'left',
list: format['list'] ? format['list'] : '',
indent: format['indent'] ? format['indent'] : 0,
header: format['header'] ? format['header'] : 0,
size: format['size'] ? format['size'] : 0,
color: format['color'] ? format['color'] : '',
background: format['background'] ? format['background'] : '',
}
window.parent.postMessage({
action: 'fontStyleOpts',
data: fontStyleOpts,
iframeId: iframeId
}, '*');
uni.postMessage({
data: {
action: 'fontStyleOpts',
data: fontStyleOpts
}
})
});
// 在iframe内部执行以下代码
var frameElement = window.frameElement;
var iframeId = frameElement == null ? "" : frameElement.getAttribute('id');
// 等待初始化完毕
document.addEventListener('UniAppJSBridgeReady', () => {
window.parent.postMessage({
action: 'onJSBridgeReady',
data: '',
iframeId: iframeId
}, '*');
uni.postMessage({
data: {
action: 'onJSBridgeReady'
}
})
})
</script>
</body>
</html>
+45
View File
File diff suppressed because one or more lines are too long
+195
View File
@@ -0,0 +1,195 @@
var iframeId = new URLSearchParams(window.location.search).get('id')||Math.random().toString(16).substring(4)
// 等待初始化完毕
document.addEventListener('UniAppJSBridgeReady', function(){
window.parent.postMessage({
action: 'onJSBridgeReady',
data: '',
iframeId: iframeId
}, '*');
uni.postMessage({
data: {
action: 'onJSBridgeReady'
}
})
})
// window.addEventListener('message', function(event) {
// // 根据消息内容执行相应的操作或调用函数
// if (typeof event.data === 'object' && event.data.action === 'callFunctionInIframe') {
// var data = event.data.data;
// myFunctionInIframe(data);
// }
// });
var qr = null
// 绘制二维码。
//https://github.com/soldair/node-qrcode?tab=readme-ov-file#createtext-options
function createQrcode(size, foreground, background, text, logo, logoSize, padding) {
// let width = window.frameElement.offsetWidth
// let height = window.frameElement.offsetHeight
var canvas = document.getElementById("qrcode")
// canvas.width=width
// canvas.height=height
// canvas.style.width=width+'px'
// canvas.style.height=height+'px'
canvas.style.display = "block"
let ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
document.body.style.overflow = "hidden"
document.body.style.height = "100%"
size = size || 128;
padding = padding || 2
QRCode.toCanvas(canvas, text || 'xui', {
element: canvas,
width: size,
errorCorrectionLevel: 'H',
margin: padding,
color: {
dark: foreground || "black",
light: background || "white"
}
})
if (logo) {
let ctx = canvas.getContext("2d");
let img = new Image();
logoSize = logoSize || 50
img.width = logoSize + "px"
img.height = logoSize + "px"
img.src = logo
img.onload = function() {
let x = (size - logoSize) / 2
let y = x;
ctx.drawImage(img, x, y, logoSize, logoSize)
}
}
}
//获取二维码图片。
function QrcodeToDateUrl() {
if (qr) {
let imgdata = qr.toDataURL();
uni.postMessage({
data: {
action: 'img',
url: imgdata
}
})
} else {
uni.postMessage({
data: {
action: 'error',
}
})
}
}
var ect = null;
function getchart() {
if (ect) return ect;
let dom = document.getElementById('echart');
return echarts.init(dom);
}
function chart_setSize() {
let width = window.innerWidth
let height = window.innerHeight
let dom = document.getElementById('echart');
let domhtml = document.getElement
dom.style.width = width + 'px'
dom.style.height = height + 'px'
document.getElementsByTagName('html')[0].style = 'overflow:hidden'
}
// 解码 JSON 字符串(包括深层解码)
function decodeJSON(jsonString) {
// 如果输入不是字符串,直接返回
if (typeof jsonString !== 'string') {
return jsonString;
}
const obj = JSON.parse(jsonString,function(k,v){
// 处理常规函数定义,包括多行函数 "key": function(params) { ... }
var isFunctionStr =
// 常规函数
/^\s*function\s*\([^)]*\)\s*\{[\s\S]*\}\s*$/.test(v) ||
// 箭头函数 - 带花括号
/^\s*\([^)]*\)\s*=>\s*\{[\s\S]*\}\s*$/.test(v) ||
// 箭头函数 - 简写形式
/^\s*\([^)]*\)\s*=>\s*[^{\s][\s\S]*$/.test(v) ||
// 单参数箭头函数 - 无括号
/^\s*[a-zA-Z0-9_$]+\s*=>\s*[^{\s][\s\S]*$/.test(v) ||
// 单参数箭头函数 - 带花括号
/^\s*[a-zA-Z0-9_$]+\s*=>\s*\{[\s\S]*\}\s*$/.test(v);
if(typeof v === 'string' && isFunctionStr){
v = eval('('+v+')')
}
return v;
});
return obj;
}
function chart_setOption(optionStr) {
chart_setSize()
// 基于准备好的dom,初始化echarts实例
let chart = getchart();
if (!optionStr) return;
try {
let option = decodeJSON(optionStr)
chart.setOption(option)
} catch (error) {
alert(error)
}
}
function chart_call(funName, optionStr, eventsid) {
chart_setSize()
let filterevents = ["click"]
// 基于准备好的dom,初始化echarts实例
let chart = getchart();
if (filterevents.includes(funName)) {
chart['on'](funName, function(params) {
delete params.event
window.parent.postMessage({
action: funName,
eventId: eventsid,
data: JSON.stringify(params),
iframeId: iframeId
}, '*');
uni.postMessage({
data: {
action: funName,
eventId: eventsid,
data: JSON.stringify(params)
}
})
})
} else {
try {
let option = JSON.parse(optionStr)
chart[funName](option)
} catch (e) {
chart[funName]()
}
}
}
+1004
View File
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+103
View File
@@ -0,0 +1,103 @@
function markedHighlight(options) {
if (typeof options === 'function') {
options = {
highlight: options,
};
}
if (!options || typeof options.highlight !== 'function') {
throw new Error('Must provide highlight function');
}
if (typeof options.langPrefix !== 'string') {
options.langPrefix = 'language-';
}
if (typeof options.emptyLangClass !== 'string') {
options.emptyLangClass = '';
}
return {
async: !!options.async,
walkTokens(token) {
if (token.type !== 'code') {
return;
}
const lang = getLang(token.lang);
if (options.async) {
return Promise.resolve(options.highlight(token.text, lang, token.lang || '')).then(
updateToken(token));
}
const code = options.highlight(token.text, lang, token.lang || '');
if (code instanceof Promise) {
throw new Error(
'markedHighlight is not set to async but the highlight function is async. Set the async option to true on markedHighlight to await the async highlight function.'
);
}
updateToken(token)(code);
},
useNewRenderer: true,
renderer: {
code(code, infoString, escaped) {
// istanbul ignore next
if (typeof code === 'object') {
escaped = code.escaped;
infoString = code.lang;
code = code.text;
}
const lang = getLang(infoString);
const classValue = lang ? options.langPrefix + escape(lang) : options.emptyLangClass;
const classAttr = classValue ?
` class="${classValue}"` :
'';
code = code.replace(/\n$/, '');
return `<pre><code${classAttr}>${escaped ? code : escape(code, true)}\n</code></pre>`;
},
},
};
}
function getLang(lang) {
return (lang || '').match(/\S*/)[0];
}
function updateToken(token) {
return (code) => {
if (typeof code === 'string' && code !== token.text) {
token.escaped = true;
token.text = code;
}
};
}
// copied from marked helpers
const escapeTest = /[&<>"']/;
const escapeReplace = new RegExp(escapeTest.source, 'g');
const escapeTestNoEncode = /[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/;
const escapeReplaceNoEncode = new RegExp(escapeTestNoEncode.source, 'g');
const escapeReplacements = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
const getEscapeReplacement = (ch) => escapeReplacements[ch];
function escape(html, encode) {
if (encode) {
if (escapeTest.test(html)) {
return html.replace(escapeReplace, getEscapeReplacement);
}
} else {
if (escapeTestNoEncode.test(html)) {
return html.replace(escapeReplaceNoEncode, getEscapeReplacement);
}
}
return html;
}
+88
View File
@@ -0,0 +1,88 @@
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('katex')) :
typeof define === 'function' && define.amd ? define(['katex'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.markedKatex = factory(global.katex));
})(this, (function (katex) { 'use strict';
const inlineRule = /^(\${1,2})(?!\$)((?:\\.|[^\\\n])*?(?:\\.|[^\\\n\$]))\1(?=[\s?!\.,:?!。,:]|$)/;
const inlineRuleNonStandard = /^(\${1,2})(?!\$)((?:\\.|[^\\\n])*?(?:\\.|[^\\\n\$]))\1/; // Non-standard, even if there are no spaces before and after $ or $$, try to parse
const blockRule = /^(\${1,2})\n((?:\\[^]|[^\\])+?)\n\1(?:\n|$)/;
function index(options = {}) {
return {
extensions: [
inlineKatex(options, createRenderer(options, false)),
blockKatex(options, createRenderer(options, true)),
],
};
}
function createRenderer(options, newlineAfter) {
return (token) => katex.renderToString(token.text, { ...options, displayMode: token.displayMode }) + (newlineAfter ? '\n' : '');
}
function inlineKatex(options, renderer) {
const nonStandard = options && options.nonStandard;
const ruleReg = nonStandard ? inlineRuleNonStandard : inlineRule;
return {
name: 'inlineKatex',
level: 'inline',
start(src) {
let index;
let indexSrc = src;
while (indexSrc) {
index = indexSrc.indexOf('$');
if (index === -1) {
return;
}
const f = nonStandard ? index > -1 : index === 0 || indexSrc.charAt(index - 1) === ' ';
if (f) {
const possibleKatex = indexSrc.substring(index);
if (possibleKatex.match(ruleReg)) {
return index;
}
}
indexSrc = indexSrc.substring(index + 1).replace(/^\$+/, '');
}
},
tokenizer(src, tokens) {
const match = src.match(ruleReg);
if (match) {
return {
type: 'inlineKatex',
raw: match[0],
text: match[2].trim(),
displayMode: match[1].length === 2,
};
}
},
renderer,
};
}
function blockKatex(options, renderer) {
return {
name: 'blockKatex',
level: 'block',
tokenizer(src, tokens) {
const match = src.match(blockRule);
if (match) {
return {
type: 'blockKatex',
raw: match[0],
text: match[2].trim(),
displayMode: match[1].length === 2,
};
}
},
renderer,
};
}
return index;
}));
+242
View File
@@ -0,0 +1,242 @@
function extendedTables({
interruptPatterns = [],
skipEmptyRows = true
} = {}) {
return {
extensions: [{
name: 'spanTable',
level: 'block', // Is this a block-level or inline-level tokenizer?
start(src) {
return src.match(/\n *([^\n ].*\|.*)\n/m)?.index;
}, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) {
// const regex = this.tokenizer.rules.block.table;
let regexString = '^ *([^\\n ].*\\|.*\\n(?: *[^\\s].*\\n)*?)' // Header
+
' {0,3}(?:\\| *)?(:?-+(?: *(?:100|[1-9][0-9]?%) *-+)?:? *(?:\\| *:?-+(?: *(?:100|[1-9][0-9]?%) *-+)?:? *)*)(?:\\| *)?' // Align
+
'(?:\\n((?:(?! *\\n| {0,3}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})' // Cells
+
'(?:\\n+|$)| {0,3}#{1,6}(?:\\s|$)| {0,3}>| {4}[^\\n]| {0,3}(?:`{3,}' +
'(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n| {0,3}(?:[*+-]|1[.)]) |' +
'<\\/?(?:address|article|aside|base|basefont|blockquote|body' +
'|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt' +
'|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]' +
'|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem' +
'|meta|nav|noframes|ol|optgroup|option|p|param|section|source' +
'|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul)' +
'(?: +|\\n|\\/?>)|<(?:script|pre|style|textarea|!--)endRegex).*(?:\\n|$))*)\\n*|$)'; // Cells
regexString = regexString.replace('endRegex', interruptPatterns.map(str =>
`|(?:${str})`).join(''));
const widthRegex = / *(?:100|[1-9][0-9]?%) */g;
const regex = new RegExp(regexString);
const cap = regex.exec(src);
if (cap) {
const item = {
type: 'spanTable',
header: cap[1].replace(/\n$/, '').split('\n'),
align: cap[2].replace(widthRegex, '').replace(/^ *|\| *$/g, '').split(
/ *\| */),
rows: cap[3]?.trim() ? cap[3].replace(/\n[ \t]*$/, '').split('\n') : [],
width: cap[2].replace(/:/g, '').replace(/-+| /g, '').split('|')
};
// Get first header row to determine how many columns
item.header[0] = splitCells(item.header[0]);
const colCount = item.header[0].reduce((length, header) => {
return length + header.colspan;
}, 0);
if (colCount === item.align.length) {
item.raw = cap[0];
let i, j, k, row;
// Get alignment row (:---:)
let l = item.align.length;
for (i = 0; i < l; i++) {
if (/^ *-+: *$/.test(item.align[i])) {
item.align[i] = 'right';
} else if (/^ *:-+: *$/.test(item.align[i])) {
item.align[i] = 'center';
} else if (/^ *:-+ *$/.test(item.align[i])) {
item.align[i] = 'left';
} else {
item.align[i] = null;
}
}
// Get any remaining header rows
l = item.header.length;
for (i = 1; i < l; i++) {
item.header[i] = splitCells(item.header[i], colCount, item.header[i -
1], skipEmptyRows);
}
// Get main table cells
l = item.rows.length;
for (i = 0; i < l; i++) {
item.rows[i] = splitCells(item.rows[i], colCount, item.rows[i - 1],
skipEmptyRows);
}
// header child tokens
l = item.header.length;
for (j = 0; j < l; j++) {
row = item.header[j];
for (k = 0; k < row.length; k++) {
row[k].tokens = [];
this.lexer.inline(row[k].text, row[k].tokens);
}
}
// cell child tokens
l = item.rows.length;
for (j = 0; j < l; j++) {
row = item.rows[j];
for (k = 0; k < row.length; k++) {
row[k].tokens = [];
this.lexer.inline(row[k].text, row[k].tokens);
}
}
return item;
}
}
},
renderer(token) {
let i, j, row, cell, col, text;
let output = '<table>';
output += '<thead>';
for (i = 0; i < token.header.length; i++) {
row = token.header[i];
let col = 0;
output += '<tr>';
for (j = 0; j < row.length; j++) {
cell = row[j];
text = this.parser.parseInline(cell.tokens);
output += getTableCell(text, cell, 'th', token.align[col], token.width[
col]);
col += cell.colspan;
}
output += '</tr>';
}
output += '</thead>';
if (token.rows.length) {
output += '<tbody>';
for (i = 0; i < token.rows.length; i++) {
row = token.rows[i];
col = 0;
if (!row[0].emptyRow) {
output += '<tr>';
for (j = 0; j < row.length; j++) {
cell = row[j];
text = this.parser.parseInline(cell.tokens);
output += getTableCell(text, cell, 'td', token.align[col], token
.width[col]);
col += cell.colspan;
}
output += '</tr>';
}
}
output += '</tbody>';
}
output += '</table>';
return output;
}
}]
};
}
const getTableCell = (text, cell, type, align, width) => {
if (!cell.rowspan) {
return '';
}
const tag = `<${type}` +
`${cell.colspan > 1 ? ` colspan=${cell.colspan}` : ''}` +
`${cell.rowspan > 1 ? ` rowspan=${cell.rowspan}` : ''}` +
`${align ? ` align=${align}` : ''}` +
`${width ? ` width=${width}` : ''}>`;
return `${tag + text}</${type}>\n`;
};
const splitCells = (tableRow, count, prevRow = [], skipEmptyRows) => {
const cells = [...tableRow.trim().matchAll(/(?:[^|\\]|\\.?)+(?:\|+|$)/g)].map((x) => x[0]);
// Remove first/last cell in a row if whitespace only and no leading/trailing pipe
if (!cells[0]?.trim()) {
cells.shift();
}
if (!cells[cells.length - 1]?.trim()) {
cells.pop();
}
let numCols = 0;
let i, j, trimmedCell, prevCell, prevCols;
for (i = 0; i < cells.length; i++) {
trimmedCell = cells[i].split(/\|+$/)[0];
cells[i] = {
rowspan: 1,
colspan: Math.max(cells[i].length - trimmedCell.length, 1),
text: trimmedCell.trim().replace(/\\\|/g, '|')
// display escaped pipes as normal character
};
// Handle Rowspan
if (trimmedCell.slice(-1) === '^' && prevRow.length) {
// Find matching cell in previous row
prevCols = 0;
for (j = 0; j < prevRow.length; j++) {
prevCell = prevRow[j];
if ((prevCols === numCols) && (prevCell.colspan === cells[i].colspan)) {
// merge into matching cell in previous row (the "target")
cells[i].rowSpanTarget = prevCell.rowSpanTarget ?? prevCell;
cells[i].rowSpanTarget.text += ` ${cells[i].text.slice(0, -1)}`;
cells[i].rowSpanTarget.rowspan += 1;
cells[i].rowspan = 0;
break;
}
prevCols += prevCell.colspan;
if (prevCols > numCols) {
break;
}
}
}
numCols += cells[i].colspan;
}
// If all cells have been merged, flag as an empty row
if (cells.length > 0 && skipEmptyRows && cells.length === cells.filter((cell) => {
return cell.rowspan === 0;
}).length) {
cells[0].emptyRow = true;
for (i = 0; i < cells.length; i++) {
cells[i].rowSpanTarget.rowspan -= 1;
}
}
// Force main cell rows to match header column count
if (numCols > count) {
cells.splice(count);
} else {
while (numCols < count) {
cells.push({
rowspan: 1,
colspan: 1,
text: ''
});
numCols += 1;
}
}
return cells;
};
+21
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+27
View File
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>本地网页</title>
</head>
<style>
body,html{
background: transparent;
margin: 0;
padding: 0;
width:100%;
min-height: 100%;
}
</style>
<body>
<!-- html组件使用 -->
<div id="html" style="overflow: auto;"></div>
<!-- 给echart图表使用 -->
<div id="echart" ></div>
<script type="text/javascript" src="./js/uni.webview.1.5.5.js"></script>
<script type="text/javascript" src="./js/echarts.min.js"></script>
<script type="text/javascript" src="./js/handler.js"></script>
</body>
</html>
+267
View File
@@ -0,0 +1,267 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<link href="./css/default.min.css" type="text/css" rel="stylesheet" />
<link href="./css/katex.min.css" type="text/css" rel="stylesheet" />
<style>
body,
html {
background: transparent;
margin: 0;
padding: 0;
}
* {
/* padding: 0;
margin: 0; */
}
p,
h1,
h3,
h2,
h4,
h5,
h6 {
margin: 8px 0;
}
hr {
border-width: 1px;
}
</style>
</head>
<body>
<div id="layout">
</div>
<script src="./js/uni.webview.1.5.5.js"></script>
<script src="./js/highlight.min.js"></script>
<script src="./js/marked-highlight.js"></script>
<script src="./js/marked-tables.js"></script>
<script src="./js/katex.js"></script>
<script src="./js/marked.min.js"></script>
<script src="./js/marked-katex-extension.js"></script>
<script type="text/javascript">
var md;
function getBodyHeight() {
var targetDiv = document.body.querySelector("#layout")
let height = targetDiv.scrollHeight
document.body.style.height = (height) + 'px'
window.parent.postMessage({
action: 'offsetHeight',
data: height + 5,
iframeId: iframeId
}, '*');
uni.postMessage({
data: {
action: 'offsetHeight',
data: height
}
})
}
var targetDiv = document.querySelector("#layout")
// 创建一个观察者对象并传入回调函数
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === 'childList' || mutation.type === 'attributes') {
getBodyHeight()
}
});
});
// 配置观察选项:
var config = {
attributes: true,
childList: true,
characterData: true,
subtree: true
};
// 选择需要观察变动的节点
observer.observe(targetDiv, config);
// 监听 div 的点击事件
targetDiv.addEventListener('click', function(event) {
let attr = {
text: "",
attr: "",
tag: ""
}
// 检查点击的目标是否是 <a> 标签
if (event.target.tagName === 'A') {
// 阻止默认行为(如跳转)
event.preventDefault();
attr.text = event.target.textContent
attr.attr = event.target.href
attr.tag = "a"
} else if (event.target.tagName === 'IMG') {
attr.attr = event.target.src
attr.tag = "img"
}
if (attr.attr != '') {
window.parent.postMessage({
action: 'click',
data: attr,
iframeId: iframeId
}, '*');
uni.postMessage({
data: {
action: 'click',
data: attr
}
})
}
});
function processMarkdown(markdown) {
// 1. 分割成行
const lines = markdown.split('\n');
let inCodeBlock = false;
let codeBlockIndentation = 0;
let processedLines = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmedLine = line.trimLeft();
// 检测代码块开始
if (trimmedLine.startsWith('```')) {
if (!inCodeBlock) {
// 进入代码块
inCodeBlock = true;
// 记录代码块的缩进级别
codeBlockIndentation = line.length - trimmedLine.length;
// 移除开始标记的缩进
processedLines.push(trimmedLine);
} else {
// 退出代码块
inCodeBlock = false;
// 移除结束标记的缩进
processedLines.push(trimmedLine);
}
} else if (inCodeBlock) {
// 代码块内的内容保持原有缩进
processedLines.push(line);
} else {
// 非代码块内容保持不变
processedLines.push(line);
}
}
return processedLines.join('\n');
}
function markdown(nr, isHtml) {
let content = ''
if (typeof nr == 'string') {
let cto = JSON.parse(nr);
if (cto.render == 'android') {
content = decodeURIComponent(atob(cto.value))
} else {
content = decodeURIComponent(cto.value)
}
} else {
if (nr.render == 'android') {
content = decodeURIComponent(atob(nr.value))
} else {
content = decodeURIComponent(nr.value)
}
}
if (isHtml) {
document.querySelector("#layout").innerHTML = content
return;
}
document.getElementById('layout').innerHTML = md.parse(processMarkdown(content));
}
function setBodyStyle(stylenr, isHtml) {
targetDiv.style = stylenr;
}
function getHtml() {
window.parent.postMessage({
action: 'toValue',
data: targetDiv.innerhtml,
iframeId: iframeId
}, '*');
uni.postMessage({
data: {
action: 'toValue',
data: targetDiv.innerhtml
}
})
}
// 在iframe内部执行以下代码
var frameElement = window.frameElement;
var iframeId = frameElement == null ? "" : frameElement.getAttribute('id');
// 等待初始化完毕
document.addEventListener('UniAppJSBridgeReady', () => {
md = new marked.Marked(
markedHighlight({
emptyLangClass: 'hljs',
langPrefix: 'hljs language-',
highlight(code, lang, info) {
// const language = hljs.getLanguage(lang) ? lang : 'plaintext';
// return hljs.highlight(code, {
// language
// }).value;
}
})
);
md.use(extendedTables());
md.use(markedKatex({
throwOnError: false
}));
md.setOptions({
breaks: true,
gfm: true,
});
window.parent.postMessage({
action: 'onJSBridgeReady',
data: '',
iframeId: iframeId
}, '*');
uni.postMessage({
data: {
action: 'onJSBridgeReady'
}
})
})
</script>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<script>
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
CSS.supports('top: constant(a)'))
document.write(
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
(coverSupport ? ', viewport-fit=cover' : '') + '" />')
</script>
<title></title>
<!--preload-links-->
<!--app-context-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/main"></script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
module.exports = {
"is-custom-runtime": false,
"compile": true,
watchPathIgnorePatterns: ['/node_modules/', '/dist/', '/.git/'],
moduleFileExtensions: ['js'],
rootDir: __dirname,
testMatch: ["<rootDir>/pages/**/*test.[jt]s?(x)","<rootDir>/pages/*test.[jt]s?(x)"],
testPathIgnorePatterns: [
'/node_modules/',
'/testbao/',
'/tmicon/',
'/tmicon/',
'/myUtools/',
'/myUtools/',
'/uni_modules/',
'/unpackage/',
'/static/',
'/hybrid/',
'/jtest/',
'/docsBuildCli/',
'/docweb/',
'/logo资源/',
'/tmy-ui/',
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

+66
View File
@@ -0,0 +1,66 @@
// #ifndef MP
import { showRunLogo, hideRunLogo ,XRUN_START_LOGO_OPTS} from "@/uni_modules/x-runstart-logo"
import { showModal,X_MODAL_TYPE } from "@/uni_modules/x-modal-s"
// #endif
// #ifndef MP
function setAgree() {
uni.setStorageSync('xTmui4.0Xieyi', 'true')
}
function getAgree() : boolean {
let tonyixie = uni.getStorageSync('xTmui4.0Xieyi')
return tonyixie == null || tonyixie == ''
}
export function showAgree() {
const needAgree = getAgree()
if (!needAgree) return;
showRunLogo({
bgColor: '#006deb',
logo: 'logo.png',
width: 60,
height: 60,
logoPadding: 20,
round: 14,
logoBgColor: '#ffffff',
rightText: 'tmui4.0x / 2025 by tmzdy',
fontSize: 11,
fontColor: '#ffffff'
} as XRUN_START_LOGO_OPTS)
showModal({
title: "隐私政策",
content: "<div><span>我同意</span><a style='color:rgb(5, 121, 255);' href='https://xui.tmui.design'>《TMUIX4.0使用协议》</a><span>我确认我使用的是正版tmuix4.0组件及附带插件软件,并承担相应法律风险,同意请点我同意,不同意请点不同意终止访问.</span></div>",
cancelText: '不同意',
confirmText: "我同意",
height:120,
confirmIcon:"EB80",
cancelIcon:"ECA0",
clickMaskClose: false,
clickLink(url:string){
console.log(url)
uni.showModal({
title:"阅读协议",
content:`这个是协议,请阅读`,
showCancel:false,
confirmText:"已阅读"
})
},
confirm() {
setAgree()
setTimeout(function () {
hideRunLogo()
}, 1000);
},
cancel() {
// #ifdef APP
uni.exit()
// #endif
}
} as X_MODAL_TYPE)
}
// #endif
+6
View File
@@ -0,0 +1,6 @@
type demoStoreType = {
homeMenuOpened:boolean
}
export const demoStore = reactive({
homeMenuOpened:false
} as demoStoreType)
+57
View File
@@ -0,0 +1,57 @@
import App from './App'
import {xui} from "@/uni_modules/tmx-ui/index.uts"
import {Tmui4xOptions} from "@/uni_modules/tmx-ui/interface.uts"
import { I18nOptions } from "@/uni_modules/x-vuei18n-s/interface.uts"
import en from "@/uni_modules/tmx-ui/localLanuage/en.json"
import zhHans from "@/uni_modules/tmx-ui/localLanuage/zh-Hans.json"
import zhHant from "@/uni_modules/tmx-ui/localLanuage/zh-Hant.json"
import ko from "@/uni_modules/tmx-ui/localLanuage/ko.json"
import ja from "@/uni_modules/tmx-ui/localLanuage/ja.json"
/**
* 组件语言包(与自定义语言包合并,共用一个实例)
*/
const messages : UTSJSONObject = {
"en":{
...en,
"hellow":"Hi~"
},
"zh-Hans":{
...zhHans,
"hellow":"哈喽"
},
"zh-Hant":zhHant,
"ko":ko,
"ja":ja
}
import { createSSRApp } from 'vue'
/**
* 医院救援平台全局配置
* - designSize 375:与《移动端 UI 设计稿 v2》基准画布一致
* - color #2563EB:医疗蓝主色
* - dark light:医疗场景锁定亮色,避免跟随系统暗黑造成还原偏差
*/
const hrConfig : UTSJSONObject = {
designSize: 375,
unit: 'px',
color: '#2563EB',
dark: 'light',
inputRadius: '11',
buttonRadius: '12',
cardRound: '13',
tagRadius: '6',
placeholderStyle: 'color:#9CA3AF'
}
export function createApp() {
const app = createSSRApp(App)
app.use(xui, {
i18nOptions: { locale: "zh-Hans", messages } as I18nOptions,
config: hrConfig
} as Tmui4xOptions)
return {
app
}
}
+131
View File
@@ -0,0 +1,131 @@
{
"name": "医院救援管理平台",
"appid": "__UNI__B73859A",
"description": "医院智慧后勤管理平台 · 移动端(救援人员端 / 仓管人员端)",
"versionName": "2.0.0",
"versionCode": "200",
"uni-app-x": {},
/* */
"quickapp": {},
/* */
"mp-weixin": {
"appid": "wxb30698c4b9f69030",
"darkmode": true,
"defaultAppTheme": "dark",
"permission": {
"scope.camera": {
"desc": "用于拍照及扫码识别"
}
},
"setting": {
"urlCheck": true,
"es6": true,
"minified": true
},
"usingComponents": true
},
"mp-alipay": {
"usingComponents": true
},
"mp-baidu": {
"usingComponents": true
},
"mp-toutiao": {
"usingComponents": true
},
"uniStatistics": {
"enable": false
},
"vueVersion": "3",
"app": {
"defaultAppTheme": "light",
"distribute": {
"modules": {
"uni-createInnerAudioContext": {}
},
"icons": {
"android": {
"hdpi": "unpackage/res/icons/72x72.png",
"xhdpi": "unpackage/res/icons/96x96.png",
"xxhdpi": "unpackage/res/icons/144x144.png",
"xxxhdpi": "unpackage/res/icons/192x192.png"
},
"ios": {
"appstore": "unpackage/res/icons/1024x1024.png"
}
}
}
},
"web": {
"title": "医院救援管理平台",
"rpxCalcMaxDeviceWidth": 480,
"rpxCalcBaseDeviceWidth": 480,
"rpxCalcIncludeWidth": 750,
"router": {
"mode": "hash",
"base": "./"
},
"devServer": {
"port": 3721
}
},
"app-android": {
"distribute": {
"modules": {
"uni-createInnerAudioContext": {}
},
"icons": {
"hdpi": "",
"xhdpi": "",
"xxhdpi": "",
"xxxhdpi": ""
},
"splashScreens": {
"default": {}
}
}
},
"app-ios": {
"distribute": {
"modules": {
"uni-createInnerAudioContext": {}
},
"icons": {
"appstore": ""
},
"splashScreens": {}
}
},
"app-harmony": {
"distribute": {
"bundleName": "UNI.APP.B73859A",
"icons": {
"foreground": "logo资源/logo/logo-rect.png",
"background": "logo资源/logo/logo-rect.png"
},
"splashScreens": {
"startWindowIcon": "logo资源/logo/logo-rect.png"
},
"signingConfigs": {
"default": {
"certpath": "/Volumes/app/myProjects/tmui4xPravite/logo资源/鸿蒙证书/鸿蒙调试证书/调试证书.cer",
"keyAlias": "tmui4xTestHaHa",
"keyPassword": "0000001AAE6DEADCC1BD7D1544384BE5806C3858AACC25643D9A0ADCE77E7FE5D28DA4764A4EBDE65CD3",
"profile": "/Volumes/app/myProjects/tmui4xPravite/logo资源/鸿蒙证书/鸿蒙调试证书/调试证书描述Debug.p7b",
"signAlg": "SHA256withECDSA",
"storeFile": "/Volumes/app/myProjects/tmui4xPravite/logo资源/鸿蒙证书/鸿蒙调试证书/调试keystore.p12",
"storePassword": "0000001AD3619DBB1D23483C207D64709AA30A897411EBC5C5C63FA88A89F2B1053DC049F051BAB1CD8A"
},
"release": {
"certpath": "/Volumes/app/myProjects/tmui4xPravite/logo资源/鸿蒙证书/tmui4xDebug.cer",
"keyAlias": "tmui4xDebug",
"keyPassword": "0000001C407505F426690725620F8B5F257FA36D43963607BF4DE48A36962C503A468B14FB553ADA962A5EBB",
"profile": "/Volumes/app/myProjects/tmui4xPravite/logo资源/鸿蒙证书/tmui4xDebugRelease.p7b",
"signAlg": "SHA256withECDSA",
"storeFile": "/Volumes/app/myProjects/tmui4xPravite/logo资源/鸿蒙证书/tmui4xKeyStore.p12",
"storePassword": "0000001C388F5AEF60B4A6713552A68C80FEFE801042A072E40FEF6B23EB74D31772502E96E5DBE47C63E36D"
}
}
}
}
}
+549
View File
@@ -0,0 +1,549 @@
/**
* 医院救援管理平台 · 移动端 Mock 数据
* ------------------------------------------------------------
* 所有页面数据均从本文件读取,后续对接接口时只需替换本文件的取数实现,
* 页面层无需改动(保持低耦合)。
*/
import {
HrUser, HrTodoTask, HrActivity, HrToolBox,
HrCatalog, HrGroup, HrSubject, HrMaterial,
HrExamRecord, HrCert, HrExamPlan, HrQuestion, HrOption, HrPracticeMeta,
HrInjuryMark, HrVital, HrTimelineNode, HrVictim,
HrEventMember, HrEventItem, HrEventDetail,
HrScanFeedback, HrScanRecord, HrStockItem, HrGoods, HrDevice, HrDeviceCheck,
HrMsg, HrMsgGroup, HrContact, HrContactGroup, HrOfflineTask,
HrSettingSwitch, HrSettingRow, HrDesignSection, HrSwatch, HrTokenRow
} from './types.uts'
/* ============================ 用户 ============================ */
export const rescuerUser : HrUser = {
name: '张明远', avatar: '张', title: '主治医师', dept: '急诊科', jobNo: '1247',
role: 'rescuer', roleLabel: '救援人员 · 急救中心',
tags: ['ACLS 认证', 'BLS 认证', '创伤急救', '培训等级 B'],
hours: 42.5, courseCount: 12, certCount: 3
} as HrUser
export const keeperUser : HrUser = {
name: '陈志强', avatar: '陈', title: '物资管理员', dept: '后勤保障科', jobNo: '2083',
role: 'keeper', roleLabel: '仓管人员 · 后勤保障科',
tags: ['仓储管理', '设备维保', '危化品资质', '培训等级 A'],
hours: 26.0, courseCount: 8, certCount: 2
} as HrUser
/* ============================ 学员首页 ============================ */
export const todoTasks : HrTodoTask[] = [
{ icon: '⚑', iconTheme: 'amb', title: '成人 CPR 课题 · 还剩 2 份资料', sub: '09-30 截止 · 已学 68%', action: '继续 ' } as HrTodoTask,
{ icon: '✓', iconTheme: 'red', title: '年度急救复训 · 报名确认', sub: '09-30 截止 · 未开始', action: '去完成 ' } as HrTodoTask
]
export const activities : HrActivity[] = [
{ icon: '▦', iconTheme: 'pri', title: '完成「创伤评估」资料学习', sub: '今天 09:20 · 学习 38 分钟' } as HrActivity,
{ icon: '✗', iconTheme: 'red', title: '错题 +2(已复习 1', sub: '昨天 21:12 · 心肺复苏' } as HrActivity,
{ icon: '▤', iconTheme: 'grn', title: '获得「AED 操作」证书', sub: '2 天前 · 有效期至 2028-09' } as HrActivity
]
export const toolBox : HrToolBox[] = [
{ icon: 'book-2-line', name: '课程库', iconBg: '#EFF6FF', iconColor: '#2563EB' } as HrToolBox,
{ icon: 'file-edit-line', name: '我的考试', iconBg: '#FEF3C7', iconColor: '#B45309' } as HrToolBox,
{ icon: 'award-line', name: '我的证书', iconBg: '#ECFDF5', iconColor: '#059669' } as HrToolBox,
{ icon: 'file-warning-line', name: '错题本', iconBg: '#FEF2F2', iconColor: '#DC2626' } as HrToolBox
]
/* ============================ 课程库 ============================ */
export const catalogList : HrCatalog[] = [
{ id: 'c1', name: '急救基础', count: 11, icon: '', iconBg: '#FEF2F2', iconColor: '#DC2626' } as HrCatalog,
{ id: 'c2', name: '创伤急救', count: 9, icon: '⚡', iconBg: '#FEF3C7', iconColor: '#B45309' } as HrCatalog,
{ id: 'c3', name: 'ACLS / BLS', count: 8, icon: '✓', iconBg: '#ECFDF5', iconColor: '#059669' } as HrCatalog,
{ id: 'c4', name: '检伤分类', count: 6, icon: '♡', iconBg: '#EFF6FF', iconColor: '#2563EB' } as HrCatalog,
{ id: 'c5', name: '设备操作', count: 5, icon: '⚙', iconBg: '#F5F3FF', iconColor: '#7C3AED' } as HrCatalog,
{ id: 'c6', name: '心理干预', count: 4, icon: '◈', iconBg: '#FFFBEB', iconColor: '#92400E' } as HrCatalog,
{ id: 'c7', name: '中毒急救', count: 3, icon: '☣', iconBg: '#FEF2F2', iconColor: '#B91C1C' } as HrCatalog,
{ id: 'c8', name: '应急指挥', count: 3, icon: '⚠', iconBg: '#ECFEFF', iconColor: '#0E7490' } as HrCatalog
]
/** 每个一级分类下的二级分组(三级课题为唯一可点项) */
export const courseGroups : HrGroup[] = [
{
name: '心肺复苏', count: 4,
items: [
{ no: '01', name: '成人 CPR(高级生命支持)', meta: '资料 6 份 · 题库 50 · 必修' } as HrSubject,
{ no: '02', name: '儿童 CPR', meta: '资料 4 份 · 题库 32 · 必修' } as HrSubject,
{ no: '03', name: '婴儿 CPR', meta: '资料 4 份 · 题库 28 · 必修' } as HrSubject,
{ no: '04', name: '溺水者心肺复苏', meta: '资料 3 份 · 题库 18 · 选修' } as HrSubject
]
} as HrGroup,
{
name: 'AED 操作', count: 3,
items: [
{ no: '01', name: 'AED 设备认知与自检', meta: '资料 3 份 · 题库 20 · 必修' } as HrSubject,
{ no: '02', name: 'AED 操作流程与除颤时机', meta: '资料 4 份 · 题库 26 · 必修' } as HrSubject,
{ no: '03', name: '特殊场景 AED(潮湿 / 儿童 / 起搏器)', meta: '资料 2 份 · 题库 15 · 选修' } as HrSubject
]
} as HrGroup,
{
name: '气道管理', count: 4,
items: [
{ no: '01', name: '气道开放与评估', meta: '资料 3 份 · 题库 22 · 必修' } as HrSubject,
{ no: '02', name: '球囊面罩通气', meta: '资料 3 份 · 题库 18 · 必修' } as HrSubject,
{ no: '03', name: '气管插管术', meta: '资料 5 份 · 题库 30 · 认证' } as HrSubject,
{ no: '04', name: '喉罩与声门上气道', meta: '资料 2 份 · 题库 16 · 选修' } as HrSubject
]
} as HrGroup
]
/** 课题详情对应的资料列表 */
export const subjectMaterials : HrMaterial[] = [
{ id: 'm1', kind: 'video', badge: '视频', badgeColor: '#7C3AED', icon: '▶', title: '高质量胸外按压操作示范', meta: '视频 · 时长 08:24 · 09-20 更新', summary: '由急诊科教学组录制,演示成人胸外按压的手位、深度、频率与胸廓回弹要点,并对比 3 处常见错误动作。', from: '成人 CPR(高级生命支持) · 急救基础 / 心肺复苏', progress: 62 } as HrMaterial,
{ id: 'm2', kind: 'doc', badge: 'PDF', badgeColor: '#DC2626', icon: 'PDF', title: '成人 CPR 操作流程(2025 版)', meta: '文档 · PDF · 2.4 MB · 12 页', summary: '依据《2025 心肺复苏与心血管急救指南》整理的操作流程速查手册,含成人基础生命支持流程图、除颤时机判定表与 3 个常见错误对照。', from: '成人 CPR(高级生命支持) · 急救基础 / 心肺复苏', progress: 0 } as HrMaterial,
{ id: 'm3', kind: 'doc', badge: 'DOC', badgeColor: '#2563EB', icon: 'DOC', title: '高质量胸外按压要点', meta: '文档 · Word · 860 KB · 6 页', summary: '按压深度 5~6cm、频率 100~120 次/分,每次按压后必须保证胸廓完全回弹,中断控制在 10 秒以内。', from: '成人 CPR(高级生命支持) · 急救基础 / 心肺复苏', progress: 100 } as HrMaterial,
{ id: 'm4', kind: 'doc', badge: 'PPT', badgeColor: '#D97706', icon: 'PPT', title: '心肺复苏教学课件', meta: '文档 · PPT · 5.6 MB · 38 页', summary: '急救基础课程配套课件,覆盖 BLS 全流程、团队分工与考核要点。', from: '成人 CPR(高级生命支持) · 急救基础 / 心肺复苏', progress: 0 } as HrMaterial,
{ id: 'm5', kind: 'doc', badge: 'XLS', badgeColor: '#059669', icon: 'XLS', title: 'CPR 考核评分表', meta: '文档 · Excel · 124 KB · 2 个工作表', summary: '实操考核使用评分表,包含操作步骤分值、扣分项与总评标准。', from: '成人 CPR(高级生命支持) · 急救基础 / 心肺复苏', progress: 0 } as HrMaterial,
{ id: 'm6', kind: 'video', badge: '视频', badgeColor: '#7C3AED', icon: '▶', title: '双人配合通气节律演示', meta: '视频 · 时长 06:12 · 09-12 更新', summary: '双人配合心肺复苏中通气节律与按压衔接的演示视频。', from: '成人 CPR(高级生命支持) · 急救基础 / 心肺复苏', progress: 0 } as HrMaterial
]
/* ============================ 练习 / 考试 / 证书 ============================ */
export const practiceMeta : HrPracticeMeta = {
subject: '成人 CPR(高级生命支持)',
total: 50,
minutes: 30,
goal: '80%',
lastTime: '09-21 21:08',
lastDone: 18,
lastRate: 72
} as HrPracticeMeta
export const quizQuestions : HrQuestion[] = [
{
id: 'q1', stem: '成人心肺复苏时,胸外按压的深度应达到多少?', answer: 1,
options: [
{ key: 'A', text: '3 ~ 4 cm' } as HrOption,
{ key: 'B', text: '5 ~ 6 cm' } as HrOption,
{ key: 'C', text: '6 ~ 8 cm' } as HrOption,
{ key: 'D', text: '按压至胸廓完全塌陷即可' } as HrOption
],
analysis: '成人胸外按压深度为 5~6 cm,过浅无法产生有效心排血量,过深易造成肋骨骨折等损伤。',
from: '成人 CPR'
} as HrQuestion,
{
id: 'q2', stem: '成人胸外按压的频率应保持在每分钟多少次?', answer: 1,
options: [
{ key: 'A', text: '60 ~ 80 次' } as HrOption,
{ key: 'B', text: '100 ~ 120 次' } as HrOption,
{ key: 'C', text: '120 ~ 140 次' } as HrOption,
{ key: 'D', text: '越快越好' } as HrOption
],
analysis: '指南推荐成人胸外按压频率为 100~120 次/分,过快会降低按压深度与回弹质量。',
from: '成人 CPR'
} as HrQuestion,
{
id: 'q3', stem: '球囊面罩通气时,成人推荐的通气频率是?', answer: 0,
options: [
{ key: 'A', text: '每 6 秒 1 次(10 次/分)' } as HrOption,
{ key: 'B', text: '每 3 秒 1 次(20 次/分)' } as HrOption,
{ key: 'C', text: '每 10 秒 1 次(6 次/分)' } as HrOption,
{ key: 'D', text: '每 6 秒 1 次(6 次/分)' } as HrOption
],
analysis: '建立高级气道后,成人通气频率为每 6 秒 1 次,即 10 次/分,且不再与按压交替中断。',
from: '气道管理'
} as HrQuestion,
{
id: 'q4', stem: '发现有人倒地无反应、无呼吸,首先应当?', answer: 2,
options: [
{ key: 'A', text: '立即开始胸外按压' } as HrOption,
{ key: 'B', text: '先寻找 AED' } as HrOption,
{ key: 'C', text: '判断环境安全并呼救、启动应急系统' } as HrOption,
{ key: 'D', text: '掐人中并等待家属到来' } as HrOption
],
analysis: '施救前必须先确认现场环境安全,随后判断反应与呼吸,呼救并启动应急反应系统,再开始 CPR。',
from: '成人 CPR'
} as HrQuestion,
{
id: 'q5', stem: 'AED 到达后,正确做法是?', answer: 1,
options: [
{ key: 'A', text: '先停止按压擦干胸部再贴电极片' } as HrOption,
{ key: 'B', text: '尽量不中断按压,开机贴片后按提示操作' } as HrOption,
{ key: 'C', text: '等专业医护到场再使用' } as HrOption,
{ key: 'D', text: '除颤后不需要继续按压' } as HrOption
],
analysis: 'AED 到达应尽快使用,尽量缩短按压中断;电击后立即恢复胸外按压。',
from: 'AED 操作'
} as HrQuestion
]
export const examPlans : HrExamPlan[] = [
{ name: 'BLS 基础生命支持 · 年度复训', priority: 0, countdown: '距开考 02 天 04 小时 12 分', time: '2026-09-24 14:00', place: '临床技能中心 3F' } as HrExamPlan
]
export const examRecords : HrExamRecord[] = [
{ name: '心肺复苏理论测验', date: '09-18', score: 92, passed: true } as HrExamRecord,
{ name: 'AED 操作规范考核', date: '08-30', score: 88, passed: true } as HrExamRecord,
{ name: '创伤急救理论测验', date: '08-12', score: 71, passed: false } as HrExamRecord
]
export const certList : HrCert[] = [
{ name: 'AED 操作证书', gotAt: '2025-09', expireAt: '2028-09', status: '有效', statusTheme: 'grn' } as HrCert,
{ name: '成人心肺复苏(CPR)证书', gotAt: '2025-06', expireAt: '2028-06', status: '有效', statusTheme: 'grn' } as HrCert,
{ name: 'BLS 基础生命支持证书', gotAt: '2024-10', expireAt: '2026-10-15', status: '即将到期', statusTheme: 'amb' } as HrCert
]
/** 错题本 / 学习中心共用 */
export const wrongQuestions : HrQuestion[] = [
{
id: 'w1', stem: '成人心肺复苏时,胸外按压的深度应达到多少?', answer: 1,
options: [ { key: 'A', text: '3 ~ 4 cm' } as HrOption, { key: 'B', text: '5 ~ 6 cm' } as HrOption ],
analysis: '成人胸外按压深度为 5~6 cm。', from: '成人 CPR'
} as HrQuestion,
{
id: 'w2', stem: '成人胸外按压的频率应保持在每分钟多少次?', answer: 1,
options: [ { key: 'A', text: '60 ~ 80 次' } as HrOption, { key: 'B', text: '100 ~ 120 次' } as HrOption ],
analysis: '推荐频率为 100~120 次/分。', from: '成人 CPR'
} as HrQuestion,
{
id: 'w3', stem: '球囊面罩通气时,成人推荐的通气频率是?', answer: 0,
options: [ { key: 'A', text: '每 6 秒 1 次(10 次/分)' } as HrOption, { key: 'D', text: '每 6 秒 1 次' } as HrOption ],
analysis: '建立高级气道后每 6 秒 1 次,即 10 次/分。', from: '气道管理'
} as HrQuestion
]
/* ============================ 伤员 ============================ */
export const victimList : HrVictim[] = [
{ id: 'v1', name: '王建国', gender: '男', age: 52, code: 'TRIAGE-20260922-0001', level: 1, injury: '开放性颅脑损伤、左股骨骨折 · 意识模糊,GCS 9 分', status: '已转运 · 协和医院急诊', statusTheme: 'pri', createdAt: '14:21 建档' } as HrVictim,
{ id: 'v2', name: '李华', gender: '男', age: 35, code: 'TRIAGE-20260922-0003', level: 2, injury: '头部开放性外伤、右前臂疑似骨折、左小腿挤压伤', status: '转运中 · 平车 → 创伤外科', statusTheme: 'amb', createdAt: '14:23 建档' } as HrVictim,
{ id: 'v3', name: '陈小梅', gender: '女', age: 28, code: 'TRIAGE-20260922-0006', level: 3, injury: '右膝软组织挫伤 · 可自行行走,生命体征平稳', status: '待转运 · 轻症观察区', statusTheme: 'grn', createdAt: '14:31 建档' } as HrVictim,
{ id: 'v4', name: '赵国强', gender: '男', age: 41, code: 'TRIAGE-20260922-0008', level: 2, injury: '胸部挤压伤、多发肋骨骨折(4-7 肋) · 呼吸 26 次/分', status: '未转运 · 现场待命', statusTheme: 'gray', createdAt: '14:38 建档' } as HrVictim,
{ id: 'v5', name: '吴长胜', gender: '男', age: 61, code: 'TRIAGE-20260922-0012', level: 4, injury: '重度颅脑损伤 · 无呼吸无脉搏,经确认已无生命体征', status: '已确认 · 待移交', statusTheme: 'gray', createdAt: '14:45 建档' } as HrVictim
]
/** 检伤分级(一行四级) */
export const gradeList : string[] = ['轻伤', '重伤', '危重', '死亡']
/** 2D 人体图鉴标记 */
export const injuryMarks : HrInjuryMark[] = [
{ no: '1', title: '头部 · 开放性外伤', sub: '顶枕部裂伤约 4cm · 出血中', theme: 'red' } as HrInjuryMark,
{ no: '2', title: '右前臂 · 疑似骨折', sub: '肿胀畸形 · 触痛明显', theme: 'amb' } as HrInjuryMark,
{ no: '3', title: '左小腿 · 挤压伤', sub: '皮肤瘀斑 · 局部压痛', theme: 'amb' } as HrInjuryMark
]
/** 生命体征 */
export const vitalSigns : HrVital[] = [
{ value: '37.8', unit: 'T ℃', abnormal: false } as HrVital,
{ value: '102', unit: 'P 次/分', abnormal: true } as HrVital,
{ value: '22', unit: 'R 次/分', abnormal: false } as HrVital,
{ value: '95/60', unit: 'BP mmHg', abnormal: true } as HrVital,
{ value: '88', unit: 'SpO₂ %', abnormal: true } as HrVital
]
/** 检伤评估下拉 */
export const triagePickList : string[] = ['行走能力', '呼吸', '循环', '意识 AVPU']
export const triagePickValues : string[] = ['不可走', '受限', '出血', '声音']
export const triagePickOptions : string[][] = [
['可走', '不可走'],
['正常', '受限', '无呼吸'],
['循环正常', '出血', '脉搏消失'],
['清醒', '声音', '疼痛', '无反应']
]
/** 伤员详情:流转时间线 */
export const victimTimeline : HrTimelineNode[] = [
{ time: '14:23', title: '现场检伤建档', sub: '张明 于坍塌现场完成建档,标记 3 处伤情,判定「重伤」', theme: 'normal' } as HrTimelineNode,
{ time: '14:28', title: '分级确认', sub: '现场检伤组 3 组复核,维持「重伤」· 操作人 张明', theme: 'ok' } as HrTimelineNode,
{ time: '14:35', title: '转运交接', sub: '平车送达 协和医院 创伤外科,转出方 张明 / 接收方 刘伟 电子签字', theme: 'normal' } as HrTimelineNode,
{ time: '15:10', title: '院内处置', sub: '创伤外科接诊,建立静脉通路 + 持续心电监护,清创缝合待执行', theme: 'warn' } as HrTimelineNode
]
/** 处置记录时间线 */
export const treatRecords : HrTimelineNode[] = [
{ time: '15:25', title: '分级调整:重伤 → 危重', sub: 'BP 88/56 mmHg · 判断失血性休克进展', theme: 'warn' } as HrTimelineNode,
{ time: '15:10', title: '建立静脉通路', sub: '左上肢 · 16G · 生理盐水 500 ml 快滴', theme: 'normal' } as HrTimelineNode,
{ time: '15:02', title: '止血包扎', sub: '右前臂加压包扎 + 额部敷料', theme: 'ok' } as HrTimelineNode,
{ time: '14:55', title: '检伤分级', sub: '判定「重伤」· START 协议', theme: 'normal' } as HrTimelineNode
]
/* ============================ 转运 ============================ */
export const hospitalList : string[] = ['协和医院 · 急诊创伤中心', '市第一人民医院 · 急诊', '同仁医院 · 急诊']
export const hospitalMeta : string[] = ['距现场 6.2 km · 约 14 分钟', '距现场 8.9 km · 约 21 分钟', '距现场 4.1 km · 但急诊已满']
export const hospitalBed : string[] = ['床位 2', '床位 5', '满员 ✕']
export const hospitalAvail : boolean[] = [true, true, false]
/* ============================ 离线队列 ============================ */
export const offlineTasks : HrOfflineTask[] = [
{ icon: '⇅', theme: 'amb', title: '检伤建档 · 李华(含 3 个附件)', sub: '15:32 暂存 · 等待上传', status: '待传', statusColor: '#D97706' } as HrOfflineTask,
{ icon: '⇅', theme: 'amb', title: '检伤建档 · 赵国强', sub: '15:28 暂存 · 等待上传', status: '待传', statusColor: '#D97706' } as HrOfflineTask,
{ icon: '✓', theme: 'grn', title: '处置记录 · 李华止血包扎', sub: '15:02 提交成功', status: '已同步', statusColor: '#059669' } as HrOfflineTask
]
/* ============================ 事件 ============================ */
export const eventList : HrEventItem[] = [
{
id: 'e1', name: '京港澳高速 K120 多车追尾', kind: '大型交通事故', place: '京港澳高速 K120', distance: '距您 3.2 km',
priority: 0, dotColor: '#DC2626', stage: 'ongoing', urgent: false,
members: [
{ name: '张', avatar: '张', theme: 'blue' } as HrEventMember,
{ name: '李', avatar: '李', theme: 'gn' } as HrEventMember,
{ name: '王', avatar: '王', theme: 'ag' } as HrEventMember
]
} as HrEventItem,
{
id: 'e2', name: '阳光养老院 · 老人跌倒', kind: '一般救援', place: '阳光养老院 3 号楼', distance: '距您 1.1 km',
priority: 2, dotColor: '#10B981', stage: 'ongoing', urgent: false,
members: [ { name: '陈', avatar: '陈', theme: 'gn' } as HrEventMember ]
} as HrEventItem,
{
id: 'e3', name: '城东工业园区 · 化学品泄漏', kind: '危化品事故', place: '城东工业园 B 区', distance: '距您 9.6 km',
priority: 0, dotColor: '#DC2626', stage: 'pending', urgent: true, members: [] as HrEventMember[]
} as HrEventItem,
{
id: 'e4', name: '城南小学 · 学生集体腹痛', kind: '群体性事件', place: '城南小学食堂', distance: '距您 4.3 km',
priority: 1, dotColor: '#F59E0B', stage: 'pending', urgent: true, members: [] as HrEventMember[]
} as HrEventItem,
{
id: 'e5', name: '幸福小区 · 老人心梗', kind: '一般救援', place: '幸福小区 6 号楼', distance: '距您 2.4 km',
priority: 1, dotColor: '#F59E0B', stage: 'history', urgent: false,
members: [ { name: '张', avatar: '张', theme: 'blue' } as HrEventMember ]
} as HrEventItem,
{
id: 'e6', name: '滨江路 · 电动车相撞', kind: '交通事故', place: '滨江路与建设街路口', distance: '已完成',
priority: 2, dotColor: '#9CA3AF', stage: 'history', urgent: false,
members: [ { name: '李', avatar: '李', theme: 'gn' } as HrEventMember ]
} as HrEventItem,
{
id: 'e7', name: '第一中学 · 学生运动损伤', kind: '一般救援', place: '第一中学操场', distance: '已完成',
priority: 2, dotColor: '#9CA3AF', stage: 'history', urgent: false,
members: [ { name: '陈', avatar: '陈', theme: 'gy' } as HrEventMember ]
} as HrEventItem,
{
id: 'e8', name: '老城街区 · 煤气泄漏排查', kind: '危化品事故', place: '老城街区 12 巷', distance: '已完成',
priority: 1, dotColor: '#9CA3AF', stage: 'history', urgent: false,
members: [ { name: '王', avatar: '王', theme: 'ag' } as HrEventMember ]
} as HrEventItem,
{
id: 'e9', name: '火车站 · 旅客突发晕厥', kind: '一般救援', place: '火车站候车大厅', distance: '已完成',
priority: 2, dotColor: '#9CA3AF', stage: 'history', urgent: false,
members: [ { name: '赵', avatar: '赵', theme: 'blue' } as HrEventMember ]
} as HrEventItem
]
export const eventDetail : HrEventDetail = {
id: 'e1',
name: '京港澳高速 K120 多车追尾',
sub: '大型交通事故 · 京港澳高速 K120 · 距您 3.2 km',
teamKey: '小组类型',
teamValue: '现场搜救组 · 组长 张明远',
tags: ['P0 紧急', '已抵达现场'],
victimTotal: 8, transferred: 5, pending: 3,
redCount: 3, amberCount: 2, greenCount: 1, blackCount: 2,
members: [
{ name: '张', avatar: '张', theme: 'blue' } as HrEventMember,
{ name: '李', avatar: '李', theme: 'gn' } as HrEventMember,
{ name: '王', avatar: '王', theme: 'ag' } as HrEventMember,
{ name: '陈', avatar: '陈', theme: 'gy' } as HrEventMember,
{ name: '赵', avatar: '赵', theme: 'blue' } as HrEventMember
],
timeline: [
{ time: '15:20', title: '已完成首轮检伤', sub: '8 人全部分级 · 红 3 / 黄 2 / 绿 1 / 黑 2', theme: 'normal' } as HrTimelineNode,
{ time: '14:50', title: '抵达现场 · 建立临时指挥点', sub: '现场 A 区 · 已设置警戒', theme: 'ok' } as HrTimelineNode,
{ time: '14:35', title: '接收派单并出发', sub: '响应耗时 3 分钟', theme: 'normal' } as HrTimelineNode,
{ time: '14:32', title: '后台调度派单', sub: 'P0 紧急 · 已推送全屏告警', theme: 'normal' } as HrTimelineNode
]
} as HrEventDetail
/* ============================ 扫码作业(批量处置 / 转运) ============================ */
export const scanVictims : HrVictim[] = [
{ id: 'v1', name: '王建国', gender: '男', age: 52, code: 'TRIAGE-20260922-0001', level: 1, injury: '开放性颅脑损伤', status: '', statusTheme: '', createdAt: '' } as HrVictim,
{ id: 'v2', name: '李华', gender: '男', age: 35, code: 'TRIAGE-20260922-0003', level: 2, injury: '头部外伤 / 右前臂骨折', status: '', statusTheme: '', createdAt: '' } as HrVictim,
{ id: 'v3', name: '陈小梅', gender: '女', age: 28, code: 'TRIAGE-20260922-0006', level: 3, injury: '右膝软组织挫伤', status: '', statusTheme: '', createdAt: '' } as HrVictim,
{ id: 'v4', name: '赵国强', gender: '男', age: 41, code: 'TRIAGE-20260922-0008', level: 2, injury: '胸部挤压伤 / 多发肋骨骨折', status: '', statusTheme: '', createdAt: '' } as HrVictim,
{ id: 'v5', name: '吴长胜', gender: '男', age: 61, code: 'TRIAGE-20260922-0012', level: 4, injury: '重度颅脑损伤', status: '', statusTheme: '', createdAt: '' } as HrVictim
]
/** 批量处置 · 处置类型 */
export const treatTypes : string[] = ['止血包扎', '固定制动', '静脉通路', '清创消毒', '给氧']
/* ============================ 仓管 · 扫码 ============================ */
export const scanFeedbacks : HrScanFeedback[] = [
{ theme: 'ok', icon: '✓', title: '一次性医用口罩 · 入库成功', sub: '规格 50 只/盒 · 现存 320 盒 · 张明远' } as HrScanFeedback,
{ theme: 'warn', icon: '!', title: 'N95 防护口罩 · 库存不足', sub: '当前仅 8 盒,低于预警线 20 盒,请及时补货' } as HrScanFeedback,
{ theme: 'err', icon: '✕', title: '条码未登记 · 无法识别', sub: '该编码不在物资台账中,请先建档或联系管理员' } as HrScanFeedback
]
export const scanRecords : HrScanRecord[] = [
{ name: '一次性医用口罩', time: '15:41', delta: '+20 盒', status: '已入库', theme: 'grn', icon: '✓' } as HrScanRecord,
{ name: '医用无菌纱布', time: '15:40', delta: '+12 包', status: '已入库', theme: 'grn', icon: '✓' } as HrScanRecord,
{ name: 'N95 防护口罩', time: '15:39', delta: '库存不足未入库', status: '待处理', theme: 'amb', icon: '!' } as HrScanRecord
]
/* ============================ 仓管 · 库存 ============================ */
export const stockWarn : HrStockItem[] = [
{ name: 'N95 防护口罩', spec: '现存 8 盒 · 预警线 20 盒', value: '严重不足 ', valueTheme: 'red', low: true, icon: '!' } as HrStockItem,
{ name: '肾上腺素注射液', spec: '现存 12 支 · 预警线 30 支', value: '严重不足 ', valueTheme: 'red', low: true, icon: '!' } as HrStockItem,
{ name: '一次性输液器', spec: '现存 46 套 · 预警线 50 套', value: '接近预警 ', valueTheme: 'amb', low: false, icon: '!' } as HrStockItem
]
export const stockAll : HrStockItem[] = [
{ name: '一次性医用口罩', spec: '规格 50 只/盒 · 09-20 入库', value: '320', valueTheme: 't1', low: false, icon: '▦' } as HrStockItem,
{ name: '医用无菌纱布', spec: '规格 8×10cm · 09-18 入库', value: '186', valueTheme: 't1', low: false, icon: '▦' } as HrStockItem,
{ name: '一次性输液器', spec: '规格 1.2m · 09-15 入库', value: '46', valueTheme: 'amb', low: true, icon: '▦' } as HrStockItem,
{ name: '肾上腺素注射液', spec: '规格 1mg/1ml · 09-12 入库', value: '12', valueTheme: 'red', low: true, icon: '▦' } as HrStockItem
]
export const goodsDetail : HrGoods = {
id: 'g1', name: 'N95 防护口罩', code: 'MZ-0042', spec: '规格 20 只/盒',
stock: 8, unit: '盒', warnLine: 20, statusLabel: '库存告急',
out30: 72, dailyUse: '日均消耗 2.4 盒', lastIn: '09-12 · 供应商 XX 医疗', lastInQty: '+40 盒',
trend: [38, 52, 44, 68, 56, 84, 96]
} as HrGoods
/* ============================ 仓管 · 设备 ============================ */
export const deviceList : HrDevice[] = [
{ id: 'd1', name: '便携式除颤仪(AED', code: 'SB-0018', meta: '编码 SB-0018 · 电池 82% · 03-15 维护', status: '可借', theme: 'grn', icon: 'flashlight-line', iconBg: '#EFF6FF', iconColor: '#2563EB', checkList: [] as HrDeviceCheck[], maintain: [] as HrTimelineNode[] } as HrDevice,
{ id: 'd2', name: '折叠式担架', code: 'SB-0031', meta: '编码 SB-0031 · 承重 150kg · 06-20 维护', status: '借用中', theme: 'amb', icon: 'file-list-3-line', iconBg: '#FEF3C7', iconColor: '#B45309', checkList: [] as HrDeviceCheck[], maintain: [] as HrTimelineNode[] } as HrDevice,
{ id: 'd3', name: '便携式氧气瓶(2L', code: 'SB-0055', meta: '编码 SB-0055 · 压力正常 · 09-01 检查', status: '可借', theme: 'grn', icon: 'bubble-chart-line', iconBg: '#ECFDF5', iconColor: '#059669', checkList: [] as HrDeviceCheck[], maintain: [] as HrTimelineNode[] } as HrDevice,
{ id: 'd4', name: '多参数监护仪', code: 'SB-0063', meta: '编码 SB-0063 · 屏幕故障 · 待维修', status: '维修中', theme: 'red', icon: 'monitor-line', iconBg: '#FEF2F2', iconColor: '#DC2626', checkList: [] as HrDeviceCheck[], maintain: [] as HrTimelineNode[] } as HrDevice
]
export const deviceDetail : HrDevice = {
id: 'd1', name: '便携式除颤仪(AED', code: 'SB-0018', meta: '编码 SB-0018 · 型号 HeartSave PAD',
status: '可借用', theme: 'pri', icon: '⚡', iconBg: '#EFF6FF', iconColor: '#2563EB',
checkList: [
{ title: '电池电量 ≥ 50%', sub: '当前 82% · 满足', checked: true } as HrDeviceCheck,
{ title: '电极片在有效期内', sub: '有效期至 2027-03', checked: true } as HrDeviceCheck,
{ title: '设备外观无破损', sub: '外壳 / 按键 / 屏幕正常', checked: true } as HrDeviceCheck,
{ title: '自检指示正常', sub: '开机绿灯闪烁 · 待确认', checked: false } as HrDeviceCheck,
{ title: '附件齐全(剪刀 / 剃刀 / 手套)', sub: '待确认', checked: false } as HrDeviceCheck
],
maintain: [
{ time: '03-15', title: '季度维护完成', sub: '更换电极片 · 电池校准 · 设备科 陈工', theme: 'ok' } as HrTimelineNode,
{ time: '2025-12-08', title: '电池更换', sub: '更换原厂电池组', theme: 'normal' } as HrTimelineNode
]
} as HrDevice
/* ============================ 消息中心 ============================ */
export const keeperMsgGroups : HrMsgGroup[] = [
{
title: '库存预警', count: 4, danger: true,
items: [
{ icon: '!', theme: 'red', title: 'N95 防护口罩库存告急', sub: '现存 8 盒,低于预警线 · 10 分钟前', action: '', unread: 1 } as HrMsg,
{ icon: '!', theme: 'red', title: '肾上腺素注射液库存告急', sub: '现存 12 支,低于预警线 · 1 小时前', action: '', unread: 1 } as HrMsg
]
} as HrMsgGroup,
{
title: '借用审批', count: 3, danger: false,
items: [
{ icon: '⚑', theme: 'amb', title: '张明远 申请借用「便携式除颤仪」', sub: '用途:院前急救 · 2 小时前', action: '去审批 ', unread: 0 } as HrMsg,
{ icon: '✓', theme: 'grn', title: '李承志 借用「折叠担架」已通过', sub: '预计 09-24 归还 · 昨天', action: '', unread: 0 } as HrMsg
]
} as HrMsgGroup,
{
title: '系统通知', count: 0, danger: false,
items: [
{ icon: '⚙', theme: 'gray', title: '9 月物资盘点任务已下发', sub: '09-30 前完成 · 3 天前', action: '', unread: 0 } as HrMsg
]
} as HrMsgGroup
]
export const rescuerMsgGroups : HrMsgGroup[] = [
{
title: '派单通知', count: 2, danger: true,
items: [
{ icon: '⚠', theme: 'red', title: 'P0 派单 · 城东工业园区化学品泄漏', sub: '15:38 · 距您 9.6 km · 待接收', action: '去接收 ', unread: 0 } as HrMsg,
{ icon: '⚠', theme: 'amb', title: '事件已归档 · 幸福小区老人心梗', sub: '09-21 08:12 · 系统自动归档', action: '', unread: 0 } as HrMsg
]
} as HrMsgGroup,
{
title: '系统消息', count: 3, danger: false,
items: [
{ icon: '▤', theme: 'amb', title: 'BLS 证书即将到期提醒', sub: '2026-10-15 到期 · 请完成复训', action: '', unread: 0 } as HrMsg,
{ icon: '▦', theme: 'pri', title: '新课题已上线:溺水者心肺复苏', sub: '昨天 · 急救基础 / 心肺复苏', action: '', unread: 0 } as HrMsg,
{ icon: '⚙', theme: 'gray', title: '系统将于本周日凌晨维护', sub: '09-19 · 预计 2 小时', action: '', unread: 0 } as HrMsg
]
} as HrMsgGroup
]
/* ============================ 通讯录 ============================ */
export const contactGroups : HrContactGroup[] = [
{
title: '指挥调度',
items: [
{ name: '刘建国 · 调度室主任', duty: '', phone: '值班室 8801 · 手机 138****2201', avatar: '刘', theme: 'blue', useAvatar: true, icon: '' } as HrContact,
{ name: '陈静 · 现场指挥', duty: '', phone: '值班室 8802 · 手机 139****3387', avatar: '陈', theme: 'blue', useAvatar: true, icon: '' } as HrContact
]
} as HrContactGroup,
{
title: '协作医院',
items: [
{ name: '协和医院 · 急诊创伤中心', duty: '', phone: '急诊台 010-6688 0001', avatar: '', theme: 'pri', useAvatar: false, icon: '▤' } as HrContact,
{ name: '市第一人民医院 · 急诊', duty: '', phone: '急诊台 010-6688 0022', avatar: '', theme: 'pri', useAvatar: false, icon: '▤' } as HrContact
]
} as HrContactGroup,
{
title: '本队同事',
items: [
{ name: '李承志 · 主治医师', duty: '', phone: '急诊科 · 工号 1248', avatar: '李', theme: 'gn', useAvatar: true, icon: '' } as HrContact,
{ name: '王志远 · 主管护师', duty: '', phone: '急诊科 · 工号 1252', avatar: '王', theme: 'ag', useAvatar: true, icon: '' } as HrContact
]
} as HrContactGroup
]
/* ============================ 设置 ============================ */
export const settingSwitches : HrSettingSwitch[] = [
{ title: 'P0 派单强提醒', sub: '全屏告警 + 强制震动', on: true } as HrSettingSwitch,
{ title: '任务推送', sub: '跟随调度后台的派单通知', on: true } as HrSettingSwitch,
{ title: '学习提醒', sub: '课程与考试到期提醒', on: true } as HrSettingSwitch
]
export const offlineSettingRows : HrSettingRow[] = [
{ title: '离线队列', sub: '3 条待上传', value: '' } as HrSettingRow,
{ title: '已下载学习资料', sub: '占用 8.6 MB', value: '清理 ' } as HrSettingRow
]
export const commonSettingRows : HrSettingRow[] = [
{ title: '清理缓存', sub: '当前 24.3 MB', value: '清理 ' } as HrSettingRow,
{ title: '关于我们', sub: '版本 v2.0.0', value: '' } as HrSettingRow,
{ title: '隐私政策', sub: '', value: '' } as HrSettingRow
]
/* ============================ 设计系统 ============================ */
export const designSections : HrDesignSection[] = [
{
title: '主色 / 语义色',
swatches: [
{ label: '主色', color: '#2563EB', hex: '#2563EB' } as HrSwatch,
{ label: '主色深', color: '#1D4ED8', hex: '#1D4ED8' } as HrSwatch,
{ label: '浅底', color: '#EFF6FF', hex: '#EFF6FF' } as HrSwatch,
{ label: '页面底', color: '#F3F4F6', hex: '#F3F4F6' } as HrSwatch
],
rows: [] as HrTokenRow[]
} as HrDesignSection,
{
title: '四色腕带分级(颜色 + 文字双重编码)',
swatches: [
{ label: 'I 级 红', color: '#DC2626', hex: '#DC2626' } as HrSwatch,
{ label: 'II 级 黄', color: '#F59E0B', hex: '#F59E0B' } as HrSwatch,
{ label: 'III 级 绿', color: '#10B981', hex: '#10B981' } as HrSwatch,
{ label: 'IV 级 黑', color: '#1F2937', hex: '#1F2937' } as HrSwatch
],
rows: [] as HrTokenRow[]
} as HrDesignSection,
{
title: '字号梯度',
swatches: [] as HrSwatch[],
rows: [
{ key: '标题 / 32', value: '救援事件' } as HrTokenRow,
{ key: '小标题 / 20', value: '伤员查询' } as HrTokenRow,
{ key: '正文 / 14', value: '创伤性失血' } as HrTokenRow,
{ key: '辅助 / 12', value: '14:55 建档' } as HrTokenRow,
{ key: '角标 / 10', value: 'I 级 · 红' } as HrTokenRow
]
} as HrDesignSection,
{
title: '圆角 / 间距 / 触控',
swatches: [] as HrSwatch[],
rows: [
{ key: '圆角', value: '8 / 12 / 16 / 24' } as HrTokenRow,
{ key: '间距', value: '8 点网格' } as HrTokenRow,
{ key: '主按钮高度', value: '≥ 56pt' } as HrTokenRow,
{ key: '其他可点高度', value: '≥ 44pt' } as HrTokenRow,
{ key: '正文对比度', value: '≥ 4.5:1WCAG AA' } as HrTokenRow
]
} as HrDesignSection
]
+380
View File
@@ -0,0 +1,380 @@
/**
* 医院救援管理平台 · 移动端 数据模型定义
* ------------------------------------------------------------
* 说明:本文件只放类型定义,不放数据。
* 后续对接接口时,仅需保证后端返回结构与此处类型一致(或在此处做一层适配)。
*/
/** 角色:救护人员端 / 仓管人员端 */
export type HrRole = 'rescuer' | 'keeper'
/** 伤情腕带分级:1 危重(红) / 2 重伤(黄) / 3 轻伤(绿) / 4 死亡(黑) */
export type HrLevel = 1 | 2 | 3 | 4
/** 优先级 */
export type HrPriority = 0 | 1 | 2
/* ============================ 用户 ============================ */
export type HrUser = {
/** 姓名 */
name : string
/** 头像文字 */
avatar : string
/** 职称 */
title : string
/** 科室 */
dept : string
/** 工号(角色由后台按工号识别) */
jobNo : string
/** 角色 */
role : HrRole
/** 角色中文名 */
roleLabel : string
/** 能力标签 */
tags : string[]
/** 累计学时 */
hours : number
/** 完成课程数 */
courseCount : number
/** 有效证书数 */
certCount : number
}
/* ============================ 培训学习 ============================ */
export type HrTodoTask = {
icon : string
iconTheme : string
title : string
sub : string
action : string
}
export type HrActivity = {
icon : string
iconTheme : string
title : string
sub : string
}
export type HrToolBox = {
icon : string
name : string
iconBg : string
iconColor : string
}
/** 课程库一级分类 */
export type HrCatalog = {
id : string
name : string
count : number
icon : string
iconBg : string
iconColor : string
}
/** 课程库二级分组(仅作分组标题,不可点) */
export type HrGroup = {
name : string
count : number
items : HrSubject[]
}
/** 课程库三级课题(唯一可点) */
export type HrSubject = {
no : string
name : string
meta : string
}
/** 学习资料(视频 / 各类文档共用) */
export type HrMaterial = {
id : string
/** video | doc */
kind : string
/** PDF / DOC / PPT / XLS / 视频 */
badge : string
badgeColor : string
icon : string
title : string
meta : string
/** 资料简介 */
summary : string
/** 关联课题 */
from : string
/** 学习/观看进度 0~100 */
progress : number
}
/** 考试记录 */
export type HrExamRecord = {
name : string
date : string
score : number
passed : boolean
}
/** 证书 */
export type HrCert = {
name : string
gotAt : string
expireAt : string
status : string
statusTheme : string
}
/** 待考安排 */
export type HrExamPlan = {
name : string
priority : HrPriority
countdown : string
time : string
place : string
}
/** 顺序练习信息 */
export type HrPracticeMeta = {
subject : string
total : number
minutes : number
goal : string
lastTime : string
lastDone : number
lastRate : number
}
/** 题目选项 */
export type HrOption = {
key : string
text : string
}
/** 题目 */
export type HrQuestion = {
id : string
stem : string
options : HrOption[]
/** 正确选项下标 */
answer : number
analysis : string
/** 所属课题 */
from : string
}
/* ============================ 检伤 / 伤员 ============================ */
export type HrInjuryMark = {
no : string
title : string
sub : string
/** 标记点色:red / amb */
theme : string
}
export type HrVital = {
/** 数值,如 37.8 / 95/60 */
value : string
/** 指标名 + 单位,如 T ℃ */
unit : string
/** 是否异常(异常高亮为红) */
abnormal : boolean
}
export type HrTimelineNode = {
time : string
title : string
sub : string
/** normal | ok | warn */
theme : string
}
export type HrVictim = {
id : string
name : string
gender : string
age : number
code : string
level : HrLevel
/** 伤情摘要 */
injury : string
/** 转运/处置状态文案 */
status : string
statusTheme : string
createdAt : string
}
/* ============================ 应急事件 ============================ */
export type HrEventMember = {
name : string
avatar : string
theme : string
}
export type HrEventItem = {
id : string
name : string
kind : string
place : string
distance : string
priority : HrPriority
dotColor : string
/**进行中 / 待接收 / 历史 */
stage : string
urgent : boolean
members : HrEventMember[]
}
export type HrEventDetail = {
id : string
name : string
sub : string
teamKey : string
teamValue : string
tags : string[]
victimTotal : number
transferred : number
pending : number
redCount : number
amberCount : number
greenCount : number
blackCount : number
members : HrEventMember[]
timeline : HrTimelineNode[]
}
/* ============================ 仓管 ============================ */
export type HrScanFeedback = {
/** ok | warn | err */
theme : string
icon : string
title : string
sub : string
}
export type HrScanRecord = {
name : string
time : string
delta : string
status : string
theme : string
icon : string
}
export type HrStockItem = {
name : string
spec : string
value : string
valueTheme : string
low : boolean
icon : string
}
export type HrGoods = {
id : string
name : string
code : string
spec : string
stock : number
unit : string
warnLine : number
statusLabel : string
out30 : number
dailyUse : string
lastIn : string
lastInQty : string
/** 趋势柱高百分比 */
trend : number[]
}
export type HrDevice = {
id : string
name : string
code : string
meta : string
status : string
theme : string
icon : string
iconBg : string
iconColor : string
checkList : HrDeviceCheck[]
maintain : HrTimelineNode[]
}
export type HrDeviceCheck = {
title : string
sub : string
checked : boolean
}
/* ============================ 消息 / 通讯录 ============================ */
export type HrMsg = {
icon : string
theme : string
title : string
sub : string
action : string
unread : number
}
export type HrMsgGroup = {
title : string
count : number
danger : boolean
items : HrMsg[]
}
export type HrContact = {
name : string
duty : string
phone : string
avatar : string
theme : string
/** true 用图标,false 用文字头像 */
useAvatar : boolean
icon : string
}
export type HrContactGroup = {
title : string
items : HrContact[]
}
/* ============================ 离线队列 ============================ */
export type HrOfflineTask = {
icon : string
theme : string
title : string
sub : string
status : string
statusColor : string
}
/* ============================ 设置 ============================ */
export type HrSettingSwitch = {
title : string
sub : string
on : boolean
}
export type HrSettingRow = {
title : string
sub : string
value : string
}
/* ============================ 设计系统 ============================ */
export type HrSwatch = {
label : string
color : string
hex : string
}
export type HrTokenRow = {
key : string
value : string
}
export type HrDesignSection = {
title : string
swatches : HrSwatch[]
rows : HrTokenRow[]
}
+282
View File
@@ -0,0 +1,282 @@
{
"easycom": {
"autoscan": true
},
"pages": [
{
"path": "pages/login/index",
"style": {
"navigationStyle": "custom",
"disableScroll": true,
"navigationBarTitleText": "登录"
}
},
{
"path": "pages/home/index",
"style": {
"navigationStyle": "custom",
"disableScroll": true,
"navigationBarTitleText": "首页"
}
},
{
"path": "pages/event/index",
"style": {
"navigationStyle": "custom",
"disableScroll": true,
"navigationBarTitleText": "事件"
}
},
{
"path": "pages/event/detail",
"style": {
"navigationStyle": "custom",
"disableScroll": true,
"navigationBarTitleText": "事件详情"
}
},
{
"path": "pages/mine/index",
"style": {
"navigationStyle": "custom",
"disableScroll": true,
"navigationBarTitleText": "我的"
}
},
{
"path": "pages/mine/settings",
"style": {
"navigationStyle": "custom",
"disableScroll": true,
"navigationBarTitleText": "设置"
}
},
{
"path": "pages/course/library",
"style": {
"navigationStyle": "custom",
"disableScroll": true,
"navigationBarTitleText": "课程库"
}
},
{
"path": "pages/course/subject",
"style": {
"navigationStyle": "custom",
"disableScroll": true,
"navigationBarTitleText": "课题详情"
}
},
{
"path": "pages/course/media",
"style": {
"navigationStyle": "custom",
"disableScroll": true,
"navigationBarTitleText": "资料详情"
}
},
{
"path": "pages/course/practice",
"style": {
"navigationStyle": "custom",
"disableScroll": true,
"navigationBarTitleText": "顺序练习"
}
},
{
"path": "pages/course/quiz",
"style": {
"navigationStyle": "custom",
"disableScroll": true,
"navigationBarTitleText": "答题"
}
},
{
"path": "pages/course/answer-card",
"style": {
"navigationStyle": "custom",
"disableScroll": true,
"navigationBarTitleText": "答题卡"
}
},
{
"path": "pages/study/exams",
"style": {
"navigationStyle": "custom",
"disableScroll": true,
"navigationBarTitleText": "我的考试"
}
},
{
"path": "pages/study/certificates",
"style": {
"navigationStyle": "custom",
"disableScroll": true,
"navigationBarTitleText": "我的证书"
}
},
{
"path": "pages/study/wrong-book",
"style": {
"navigationStyle": "custom",
"disableScroll": true,
"navigationBarTitleText": "错题本"
}
},
{
"path": "pages/study/center",
"style": {
"navigationStyle": "custom",
"disableScroll": true,
"navigationBarTitleText": "学习中心"
}
},
{
"path": "pages/triage/victims",
"style": {
"navigationStyle": "custom",
"disableScroll": true,
"navigationBarTitleText": "伤员查询"
}
},
{
"path": "pages/triage/create",
"style": {
"navigationStyle": "custom",
"disableScroll": true,
"navigationBarTitleText": "检伤建档"
}
},
{
"path": "pages/triage/detail",
"style": {
"navigationStyle": "custom",
"disableScroll": true,
"navigationBarTitleText": "伤员详情"
}
},
{
"path": "pages/triage/batch-treat",
"style": {
"navigationStyle": "custom",
"disableScroll": true,
"navigationBarTitleText": "批量处置"
}
},
{
"path": "pages/triage/batch-transfer",
"style": {
"navigationStyle": "custom",
"disableScroll": true,
"navigationBarTitleText": "批量转运"
}
},
{
"path": "pages/triage/transfer",
"style": {
"navigationStyle": "custom",
"disableScroll": true,
"navigationBarTitleText": "转运登记"
}
},
{
"path": "pages/triage/treat-records",
"style": {
"navigationStyle": "custom",
"disableScroll": true,
"navigationBarTitleText": "处置记录"
}
},
{
"path": "pages/triage/offline-sync",
"style": {
"navigationStyle": "custom",
"disableScroll": true,
"navigationBarTitleText": "离线同步"
}
},
{
"path": "pages/warehouse/scan",
"style": {
"navigationStyle": "custom",
"disableScroll": true,
"navigationBarTitleText": "扫码出入库"
}
},
{
"path": "pages/warehouse/stock",
"style": {
"navigationStyle": "custom",
"disableScroll": true,
"navigationBarTitleText": "库存查询"
}
},
{
"path": "pages/warehouse/goods-detail",
"style": {
"navigationStyle": "custom",
"disableScroll": true,
"navigationBarTitleText": "物资详情"
}
},
{
"path": "pages/warehouse/devices",
"style": {
"navigationStyle": "custom",
"disableScroll": true,
"navigationBarTitleText": "设备借用"
}
},
{
"path": "pages/warehouse/device-detail",
"style": {
"navigationStyle": "custom",
"disableScroll": true,
"navigationBarTitleText": "设备详情"
}
},
{
"path": "pages/message/index",
"style": {
"navigationStyle": "custom",
"disableScroll": true,
"navigationBarTitleText": "消息"
}
},
{
"path": "pages/message/list",
"style": {
"navigationStyle": "custom",
"disableScroll": true,
"navigationBarTitleText": "消息中心"
}
},
{
"path": "pages/message/contacts",
"style": {
"navigationStyle": "custom",
"disableScroll": true,
"navigationBarTitleText": "通讯录"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "@navTxtStyle",
"navigationBarBackgroundColor": "@navBgColor",
"backgroundColorContent": "@backgroundColor",
"backgroundColor": "@backgroundColor",
"navigationBarTitleText": "医院救援管理平台",
"pageOrientation": "portrait"
},
"uniIdRouter": {},
"condition": {
"current": 0,
"list": [
{
"name": "登录",
"path": "pages/login/index",
"query": ""
}
]
}
}
+223
View File
@@ -0,0 +1,223 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="答题卡" @right-click="submit">
<template v-slot:right>
<text class="ac-nav-submit">交卷</text>
</template>
<view class="hr-body">
<!-- ============ 图例 ============ -->
<hr-card>
<view class="hr-row hr-wrap">
<view class="ac-lg">
<view class="ac-lg-s" style="background-color:#EFF6FF;border:1px solid #BFDBFE"></view>
<text class="ac-lg-t">已答 {{ doneCount }}</text>
</view>
<view class="ac-lg">
<view class="ac-lg-s" style="background-color:#FFFFFF;border:1px solid #E5E7EB"></view>
<text class="ac-lg-t">未答 {{ total - doneCount }}</text>
</view>
<view class="ac-lg">
<view class="ac-lg-s" style="background-color:#FEF3C7;border:1px solid #FDE68A"></view>
<text class="ac-lg-t">标记 {{ markCount }}</text>
</view>
</view>
</hr-card>
<!-- ============ 题号九宫格 ============ -->
<hr-card style="margin-top:10px">
<view class="ac-grid">
<view v-for="(q, i) in questions" :key="i" :class="cellClass(i)" :style="cellStyle" @click="jump(i)">
<text :class="cellTextClass(i)">{{ i + 1 }}</text>
</view>
</view>
</hr-card>
<hr-tip style="margin-top:10px" :theme="answeredAll ? 'ok' : 'warn'"
:icon="answeredAll ? '✓' : '⚑'"
:text="answeredAll ? '全部题目已作答,可以交卷了。' : ('仍有 ' + (total - doneCount) + ' 题未作答,交卷后将无法返回修改。')"></hr-tip>
<view style="height:14px"></view>
</view>
<template v-slot:footer>
<view class="hr-fixedbar">
<view class="hr-btn hr-btn-ghost ac-foot-prev" @click="resume">
<text class="ac-foot-t">继续答题</text>
</view>
<view class="hr-btn hr-btn-primary" style="flex:1" @click="submit">
<text class="ac-foot-t2">交卷并查看成绩</text>
</view>
</view>
</template>
</hr-page>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { quizQuestions } from '@/mock/index.uts'
import { quizState, answeredCount, markedCount } from '@/utils/quiz.uts'
import { navBack, relaunchTo } from '@/utils/nav.uts'
/** s18 答题卡 */
const questions = quizQuestions
const total = questions.length
const refreshKey = ref(0)
const cellSize = ref(92)
const doneCount = computed(() : number => {
const k = refreshKey.value
return answeredCount()
})
const markCount = computed(() : number => {
const k = refreshKey.value
return markedCount()
})
const answeredAll = computed(() : boolean => {
return doneCount.value == total
})
const cellStyle = computed(() : string => {
return `width:${cellSize.value}rpx;height:${cellSize.value}rpx;`
})
onLoad(() => {
// 6 列均分:内容宽 750rpx - 页面左右 64 - 卡片左右 52 - 5 个 16rpx 间隙 80
cellSize.value = Math.floor((750 - 64 - 52 - 80) / 6)
})
onPageShow(() => {
refreshKey.value = refreshKey.value + 1
})
function cellClass(i : number) : string {
const k = refreshKey.value
if (quizState.current == i) { return 'ac-cell ac-cell-cur' }
if (quizState.marked[i]) { return 'ac-cell ac-cell-mark' }
if (quizState.answers[i] >= 0) { return 'ac-cell ac-cell-done' }
return 'ac-cell'
}
function cellTextClass(i : number) : string {
const k = refreshKey.value
if (quizState.current == i) { return 'ac-cell-t ac-cell-t-cur' }
return 'ac-cell-t'
}
function jump(i : number) : void {
quizState.current = i
navBack()
}
function resume() : void {
navBack()
}
function submit() : void {
uni.showModal({
title: '交卷确认',
content: `已作答 ${doneCount.value} / ${total} 题。交卷后无法修改。`,
cancelText: '继续答题',
confirmText: '交卷',
success: (res) => {
if (res.confirm) {
quizState.submitted = true
uni.showToast({ title: '已交卷 · 得分 92', icon: 'success' })
setTimeout(() => {
relaunchTo('/pages/study/exams')
}, 700)
}
}
})
}
</script>
<style>
.ac-nav-submit {
font-size: 31.4rpx;
color: #2563EB;
font-weight: bold;
}
.ac-lg {
display: flex;
flex-direction: row;
align-items: center;
margin-right: 28rpx;
margin-bottom: 8rpx;
}
.ac-lg-s {
width: 24rpx;
height: 24rpx;
border-radius: 8rpx;
margin-right: 10rpx;
}
.ac-lg-t {
font-size: 25.8rpx;
color: #6B7280;
}
.ac-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.ac-cell {
border-radius: 18rpx;
background-color: #FFFFFF;
border: 2rpx solid #E5E7EB;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-right: 16rpx;
margin-bottom: 16rpx;
}
.ac-cell-done {
background-color: #EFF6FF;
border: 2rpx solid #BFDBFE;
}
.ac-cell-mark {
background-color: #FEF3C7;
border: 2rpx solid #FDE68A;
}
.ac-cell-cur {
background-color: #2563EB;
border: 2rpx solid #2563EB;
}
.ac-cell-t {
font-size: 26.8rpx;
font-weight: bold;
color: #6B7280;
}
.ac-cell-t-cur {
color: #FFFFFF;
}
.ac-foot-prev {
flex: 0 0 220rpx;
margin-right: 18rpx;
}
.ac-foot-t {
font-size: 31.4rpx;
font-weight: bold;
color: #1F2937;
}
.ac-foot-t2 {
font-size: 32.4rpx;
font-weight: bold;
color: #FFFFFF;
}
</style>
+331
View File
@@ -0,0 +1,331 @@
<template>
<hr-page page-bg="#FFFFFF" status-bg="#FFFFFF" nav-title="课程库" :no-scroll="true" @right-click="goCert">
<!-- ============ 搜索行(xSearch ============ -->
<view class="cl-search">
<view class="hr-flex1">
<x-search v-model="keyword" placeholder="搜索课题,如 成人 CPR / AED" round="20rpx" height="72rpx"
input-bg-color="#F3F4F6" icon-color="#9CA3AF" font-color="#1F2937"
:show-cancel="false"></x-search>
</view>
</view>
<!-- ============ 一级侧栏 + 三级内容 ============ -->
<view class="cl-split">
<scroll-view class="cl-side" :scroll-y="true" :show-scrollbar="false">
<view v-for="(c, i) in catalogs" :key="c.id"
:class="activeCatalog == i ? 'cl-side-item cl-side-item-on' : 'cl-side-item'" @click="onCatalog(i)">
<view class="cl-side-ic" :style="{ backgroundColor: c.iconBg }">
<text class="cl-side-ic-t" :style="{ color: c.iconColor }">{{ c.icon }}</text>
</view>
<text :class="activeCatalog == i ? 'cl-side-b cl-side-b-on' : 'cl-side-b'">{{ c.name }}</text>
<text class="cl-side-s">{{ c.count }} 课题</text>
</view>
</scroll-view>
<scroll-view class="cl-main" :scroll-y="true" :show-scrollbar="false">
<!-- 搜索过滤结果 -->
<view v-if="keyword != ''">
<view v-for="(r, i) in searchResult" :key="i" class="cl-l3" @click="openSubject(r)">
<view class="cl-l3-no">
<text class="cl-l3-no-t">{{ r.no }}</text>
</view>
<view class="hr-flex1">
<text class="cl-l3-b">{{ r.name }}</text>
<text class="cl-l3-s">{{ r.meta }}</text>
</view>
<text class="cl-l3-go"></text>
</view>
<hr-empty v-if="searchResult.length == 0" icon="⌕" title="没有匹配的课题"
sub="换个关键词试试,例如「CPR」「AED」「气道」"></hr-empty>
</view>
<!-- 二级组标题 + 三级条目 -->
<view v-else-if="activeCatalog == 0">
<view v-for="(g, gi) in groups" :key="gi">
<view class="cl-group">
<view class="cl-group-bar"></view>
<text class="cl-group-b">{{ g.name }}</text>
<view class="cl-group-cnt">
<text class="cl-group-cnt-t">{{ g.count }} 课题</text>
</view>
<view class="cl-group-line"></view>
</view>
<view v-for="(it, ii) in g.items" :key="ii" class="cl-l3" @click="openSubject(it)">
<view class="cl-l3-no">
<text class="cl-l3-no-t">{{ it.no }}</text>
</view>
<view class="hr-flex1">
<text class="cl-l3-b">{{ it.name }}</text>
<text class="cl-l3-s">{{ it.meta }}</text>
</view>
<text class="cl-l3-go"></text>
</view>
</view>
<view style="margin:6px 13px 14px 13px">
<hr-tip theme="info" icon=""
:text="'共 ' + catalogs.length + ' 个一级分类 · 49 个课题。其余分类的课题内容正在建设,敬请期待。'"></hr-tip>
</view>
</view>
<hr-empty v-else icon="▦" :title="catalogName + ' · 内容建设中'" sub="该分类的课题与题库正在录入,可先学习「急救基础」"></hr-empty>
<view style="height:24px"></view>
</scroll-view>
</view>
</hr-page>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { catalogList, courseGroups } from '@/mock/index.uts'
import { HrCatalog, HrGroup, HrSubject } from '@/mock/types.uts'
import { navTo } from '@/utils/nav.uts'
/**
* s10 课程库(三级导航)
* 一级侧栏分类 + 二级组标题(不可点,仅作分组)+ 三级课题条目(唯一可点)。
*/
const catalogs = ref<HrCatalog[]>(catalogList)
const groups = ref<HrGroup[]>(courseGroups)
const activeCatalog = ref(0)
const keyword = ref('')
const catalogName = computed(() : string => {
return catalogs.value[activeCatalog.value].name
})
/** 全量课题(用于搜索) */
const allSubjects = computed(() : HrSubject[] => {
const list : HrSubject[] = []
for (let gi = 0; gi < groups.value.length; gi++) {
const g = groups.value[gi]
for (let ii = 0; ii < g.items.length; ii++) {
list.push(g.items[ii])
}
}
return list
})
const searchResult = computed(() : HrSubject[] => {
if (keyword.value == '') { return [] as HrSubject[] }
const kw = keyword.value.toLowerCase()
const list : HrSubject[] = []
for (let i = 0; i < allSubjects.value.length; i++) {
const s = allSubjects.value[i]
if (s.name.toLowerCase().indexOf(kw) > -1) { list.push(s) }
}
return list
})
function onCatalog(i : number) : void {
activeCatalog.value = i
keyword.value = ''
}
function openSubject(s : HrSubject) : void {
navTo('/pages/course/subject?name=' + s.name)
}
function goCert() : void {
navTo('/pages/study/certificates')
}
function onScanSearch() : void {
uni.showToast({ title: '扫码搜索课题', icon: 'none' })
}
</script>
<style>
.cl-search {
display: flex;
flex-direction: row;
align-items: center;
background-color: #FFFFFF;
border-bottom: 2rpx solid #F3F4F6;
padding: 16rpx 28rpx 16rpx 28rpx;
}
.cl-search-scan {
margin-left: 16rpx;
width: 72rpx;
height: 72rpx;
border-radius: 20rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.cl-split {
flex: 1;
display: flex;
flex-direction: row;
}
/* ---------- 左侧一级分类 ---------- */
.cl-side {
width: 184rpx;
background-color: #FAFBFC;
border-right: 2rpx solid #F3F4F6;
}
.cl-side-item {
flex-direction: column;
align-items: center;
padding: 22rpx 12rpx 22rpx 12rpx;
border-left: 6rpx solid transparent;
}
.cl-side-item-on {
background-color: #FFFFFF;
border-left: 6rpx solid #2563EB;
}
.cl-side-ic {
width: 72rpx;
height: 72rpx;
border-radius: 22rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-bottom: 12rpx;
}
.cl-side-ic-t {
font-size: 33.6rpx;
font-weight: bold;
}
.cl-side-b {
font-size: 25.8rpx;
color: #1F2937;
}
.cl-side-b-on {
color: #2563EB;
font-weight: bold;
}
.cl-side-s {
font-size: 20.2rpx;
color: #9CA3AF;
margin-top: 4rpx;
}
/* ---------- 右侧内容 ---------- */
.cl-main {
flex: 1;
background-color: #F3F4F6;
}
.cl-hint {
display: flex;
flex-direction: row;
align-items: center;
padding: 20rpx 26rpx 8rpx 26rpx;
}
.cl-hint-i {
font-size: 23.6rpx;
color: #2563EB;
font-weight: bold;
margin-right: 12rpx;
}
.cl-hint-t {
font-size: 23.6rpx;
color: #9CA3AF;
}
.cl-group {
display: flex;
flex-direction: row;
align-items: center;
padding: 26rpx 26rpx 16rpx 26rpx;
}
.cl-group-bar {
width: 6rpx;
height: 24rpx;
border-radius: 4rpx;
background-color: #2563EB;
margin-right: 14rpx;
}
.cl-group-b {
font-size: 29.2rpx;
font-weight: bold;
color: #1F2937;
}
.cl-group-cnt {
margin-left: 14rpx;
background-color: #EFF6FF;
border-radius: 12rpx;
padding: 2rpx 12rpx 2rpx 12rpx;
}
.cl-group-cnt-t {
font-size: 21.2rpx;
font-weight: bold;
color: #2563EB;
}
.cl-group-line {
flex: 1;
height: 2rpx;
background-color: #F3F4F6;
margin-left: 14rpx;
}
.cl-l3 {
display: flex;
flex-direction: row;
align-items: center;
margin: 0rpx 26rpx 16rpx 26rpx;
background-color: #FFFFFF;
border: 2rpx solid #F3F4F6;
border-radius: 22rpx;
padding: 22rpx 24rpx 22rpx 24rpx;
}
.cl-l3-no {
width: 48rpx;
height: 48rpx;
border-radius: 16rpx;
background-color: #EFF6FF;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-right: 20rpx;
}
.cl-l3-no-t {
font-size: 23.6rpx;
font-weight: bold;
color: #2563EB;
}
.cl-l3-b {
font-size: 29.2rpx;
color: #1F2937;
lines: 1;
}
.cl-l3-s {
font-size: 23.6rpx;
color: #9CA3AF;
margin-top: 4rpx;
}
.cl-l3-go {
font-size: 33.6rpx;
color: #9CA3AF;
margin-left: 16rpx;
}
</style>
+490
View File
@@ -0,0 +1,490 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" :nav-title="material.title" @right-click="onStar">
<template v-slot:right>
<x-icon :name="starred ? 'star-fill' : 'star-line'" :color="starred ? '#F59E0B' : '#6B7280'"
font-size="19"></x-icon>
</template>
<view class="hr-body" style="padding-bottom:22px">
<!-- ============ 视频资料 ============ -->
<view v-if="isVideo">
<view class="vd" :style="{ height: videoH + 'rpx' }">
<!-- 播放/暂停 -->
<view class="vd-play" @click="togglePlay">
<x-icon :name="playing ? 'pause-fill' : 'play-fill'" :color="playing ? '#FFFFFF' : '#1D4ED8'"
font-size="20"></x-icon>
</view>
<!-- 进度条 -->
<view class="vd-bar">
<view class="vd-track">
<view class="vd-track-i" :style="{ width: played + '%' }"></view>
</view>
<view class="vd-row">
<text class="vd-time">{{ curTime }} / {{ totalTime }}</text>
<view class="vd-acts">
<view @click="togglePlay">
<x-icon :name="playing ? 'pause-line' : 'play-line'" color="#FFFFFF"
font-size="16"></x-icon>
</view>
<view style="margin-left:14px" @click="onFullscreen">
<x-icon name="fullscreen-line" color="#FFFFFF" font-size="16"></x-icon>
</view>
</view>
</view>
</view>
</view>
<hr-card style="margin-top:10px">
<view class="hr-row-top">
<text class="md-title">{{ material.title }}</text>
<view class="md-badge" :style="{ backgroundColor: material.badgeColor }">
<text class="md-badge-t">{{ material.badge }}</text>
</view>
</view>
<text class="md-meta">{{ material.meta }}</text>
<text class="md-from">来自 {{ material.from }}</text>
<view class="hr-row" style="margin-top:12px">
<view style="flex:1">
<hr-progress :percent="played"></hr-progress>
</view>
<text class="md-seen">已看 {{ played }}%</text>
</view>
</hr-card>
</view>
<!-- ============ 文档资料 ============ -->
<view v-else>
<view class="dv">
<view class="dv-sheet">
<view class="dv-t"></view>
<view class="dv-l dv-l-s"></view>
<view class="dv-l"></view>
<view class="dv-l dv-l-m"></view>
<view class="dv-img"></view>
<view class="dv-l dv-l-s"></view>
<view class="dv-l dv-l-m"></view>
<text class="dv-pg">{{ page }} / 12</text>
</view>
</view>
<view class="dv-pager">
<view class="dv-pager-b" @click="prevPage">
<x-icon name="arrow-left-s-line" color="#6B7280" font-size="16"></x-icon>
</view>
<text class="dv-pager-t">第 {{ page }} 页 / 共 12 页</text>
<view class="dv-pager-b" @click="nextPage">
<x-icon name="arrow-right-s-line" color="#6B7280" font-size="16"></x-icon>
</view>
</view>
<hr-card style="margin-top:10px">
<view class="hr-row-top">
<text class="md-title">{{ material.title }}</text>
<view class="md-badge" :style="{ backgroundColor: material.badgeColor }">
<text class="md-badge-t">{{ material.badge }}</text>
</view>
</view>
<text class="md-meta">{{ material.meta }}</text>
<text class="md-from">来自 {{ material.from }}</text>
</hr-card>
</view>
<!-- ============ 资料简介 ============ -->
<hr-sec title="资料简介" :first="false"></hr-sec>
<hr-card>
<text class="md-desc">{{ material.summary }}</text>
</hr-card>
<!-- ============ 学习笔记(仅视频) ============ -->
<view v-if="isVideo">
<hr-sec title="学习笔记" more="编辑 " @more-click="onEditNote"></hr-sec>
<hr-card>
<text class="md-desc">{{ note }}</text>
<text class="md-note-time">最后编辑:昨天 21:32</text>
</hr-card>
</view>
<!-- ============ 离线下载 ============ -->
<hr-sec title="离线下载" :more="downloaded ? '⇅ 管理 ' : '⇅ 下载 '" @more-click="onDownload"></hr-sec>
<hr-card variant="list">
<hr-lrow :icon="downloaded ? '✓' : '⇅'" :theme="downloaded ? 'grn' : 'pri'"
:title="downloaded ? '已下载到本机' : '尚未下载'" :sub="downloaded ? '2.4 MB · 无网络时仍可查阅' : '点击下载后可离线查阅'"
:value="downloaded ? '已存' : '下载'" :value-theme="downloaded ? 'grn' : 'pri'" :value-bold="true"
:is-last="true" @click="onDownload"></hr-lrow>
</hr-card>
<view style="height:8px"></view>
</view>
<template v-slot:footer>
<view class="hr-fixedbar">
<view class="hr-btn hr-btn-ghost md-fav" @click="onStar">
<x-icon :name="starred ? 'star-fill' : 'star-line'" :color="starred ? '#F59E0B' : '#1F2937'"
font-size="17"></x-icon>
<text class="md-fav-t">{{ starred ? '已收藏' : '收藏' }}</text>
</view>
<view class="hr-btn hr-btn-primary md-main" @click="onMainAction">
<text class="md-main-t">{{ isVideo ? (downloaded ? '已下载离线观看' : '下载离线观看') : '在线查看' }}</text>
</view>
</view>
</template>
</hr-page>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { subjectMaterials } from '@/mock/index.uts'
import { HrMaterial } from '@/mock/types.uts'
/**
* s02 / s41 资料详情(视频与文档共用同一页)
* 标题即资料名称;不再有章节目录与章节学习。
*/
const material = ref<HrMaterial>(subjectMaterials[1])
const starred = ref(false)
const downloaded = ref(false)
const page = ref(1)
const playing = ref(false)
const played = ref(62)
const videoH = ref(386)
const totalSeconds = 504
let timer : number = 0
const note = '按压深度 5~6 cm,频率 100~120 次/分;每次按压后必须让胸廓完全回弹,尽量减少中断(<10 秒)……'
// ⚠ UTS 不提升函数声明(App 端编译报 UTSC error18: 找不到名称),
// 被调用的函数一律写在调用点之前
function formatTime(sec : number) : string {
const m = Math.floor(sec / 60)
const s = sec % 60
const ss = s < 10 ? '0' + s.toString() : s.toString()
return `${m}:${ss}`
}
function stopTimer() : void {
if (timer != 0) {
clearInterval(timer)
timer = 0
}
}
const isVideo = computed(() : boolean => {
return material.value.kind == 'video'
})
const curTime = computed(() : string => {
const sec = Math.floor(totalSeconds * played.value / 100)
return formatTime(sec)
})
const totalTime = computed(() : string => {
return formatTime(totalSeconds)
})
onLoad((opt) => {
const raw = opt['id']
if (raw != null) {
const id = raw as string
for (let i = 0; i < subjectMaterials.length; i++) {
if (subjectMaterials[i].id == id) {
material.value = subjectMaterials[i]
played.value = subjectMaterials[i].progress > 0 ? subjectMaterials[i].progress : 0
break
}
}
}
// 视频区保持 16:9:内容宽 = 750rpx - 页面左右各 32rpx
videoH.value = Math.round((750 - 64) * 9 / 16)
})
onUnload(() => {
stopTimer()
})
function togglePlay() : void {
playing.value = !playing.value
if (playing.value) {
stopTimer()
timer = setInterval(() => {
if (played.value >= 100) {
played.value = 100
playing.value = false
stopTimer()
return
}
played.value = played.value + 1
}, 1000)
} else {
stopTimer()
}
}
function prevPage() : void {
if (page.value <= 1) {
uni.showToast({ title: '已是第一页', icon: 'none' })
return
}
page.value = page.value - 1
}
function nextPage() : void {
if (page.value >= 12) {
uni.showToast({ title: '已是最后一页', icon: 'none' })
return
}
page.value = page.value + 1
}
function onStar() : void {
starred.value = !starred.value
uni.showToast({ title: starred.value ? '已加入收藏' : '已取消收藏', icon: 'none' })
}
function onDownload() : void {
if (downloaded.value) {
uni.showToast({ title: '已下载到本机', icon: 'none' })
return
}
uni.showLoading({ title: '下载中' })
setTimeout(() => {
uni.hideLoading()
downloaded.value = true
uni.showToast({ title: '下载完成 · 可离线查阅', icon: 'success' })
}, 900)
}
function onMainAction() : void {
if (isVideo.value) {
onDownload()
return
}
uni.showToast({ title: '已用系统阅读器打开', icon: 'none' })
}
function onEditNote() : void {
uni.showToast({ title: '编辑学习笔记', icon: 'none' })
}
function onFullscreen() : void {
uni.showToast({ title: '已进入全屏播放', icon: 'none' })
}
</script>
<style>
/* ---------- 视频 ---------- */
.vd {
width: 100%;
background-color: #111827;
border-radius: 24rpx;
overflow: hidden;
position: relative;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.vd-play {
width: 104rpx;
height: 104rpx;
border-radius: 52rpx;
background-color: rgba(255, 255, 255, 0.92);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.vd-bar {
position: absolute;
left: 0rpx;
right: 0rpx;
bottom: 0rpx;
padding: 0rpx 24rpx 20rpx 24rpx;
}
.vd-track {
height: 6rpx;
border-radius: 6rpx;
background-color: rgba(255, 255, 255, 0.28);
}
.vd-track-i {
height: 6rpx;
border-radius: 6rpx;
background-color: #2563EB;
}
.vd-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-top: 16rpx;
}
.vd-time {
font-size: 24.6rpx;
color: #FFFFFF;
}
.vd-acts {
display: flex;
flex-direction: row;
align-items: center;
}
/* ---------- 文档预览 ---------- */
.dv {
background-color: #E5E7EB;
border-radius: 24rpx;
padding: 24rpx 24rpx 24rpx 24rpx;
display: flex;
flex-direction: row;
justify-content: center;
}
.dv-sheet {
width: 80%;
background-color: #FFFFFF;
border-radius: 8rpx;
padding: 30rpx 26rpx 32rpx 26rpx;
box-shadow: 0 6rpx 24rpx rgba(15, 23, 42, 0.16);
position: relative;
}
.dv-t {
height: 18rpx;
width: 58%;
border-radius: 6rpx;
background-color: #CBD5E1;
margin-bottom: 22rpx;
}
.dv-l {
height: 12rpx;
border-radius: 6rpx;
background-color: #E5E7EB;
margin-bottom: 16rpx;
}
.dv-l-s {
width: 88%;
}
.dv-l-m {
width: 70%;
}
.dv-img {
height: 100rpx;
border-radius: 12rpx;
background-color: #F1F5F9;
margin: 6rpx 0rpx 20rpx 0rpx;
}
.dv-pg {
font-size: 20.2rpx;
color: #9CA3AF;
}
.dv-pager {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding-top: 16rpx;
}
.dv-pager-b {
width: 48rpx;
height: 48rpx;
border-radius: 16rpx;
background-color: #FFFFFF;
border: 2rpx solid #E5E7EB;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.dv-pager-t {
font-size: 23.6rpx;
color: #9CA3AF;
margin-left: 24rpx;
margin-right: 24rpx;
}
/* ---------- 详情 ---------- */
.md-title {
flex: 1;
font-size: 33.6rpx;
font-weight: bold;
color: #1F2937;
margin-right: 18rpx;
}
.md-badge {
border-radius: 14rpx;
padding: 6rpx 18rpx 6rpx 18rpx;
}
.md-badge-t {
font-size: 23.6rpx;
font-weight: bold;
color: #FFFFFF;
}
.md-meta {
font-size: 25.8rpx;
color: #6B7280;
margin-top: 12rpx;
}
.md-from {
font-size: 24.6rpx;
color: #9CA3AF;
margin-top: 8rpx;
}
.md-seen {
font-size: 24.6rpx;
font-weight: bold;
color: #2563EB;
margin-left: 20rpx;
}
.md-desc {
font-size: 28rpx;
color: #6B7280;
line-height: 1.8;
}
.md-note-time {
font-size: 23.6rpx;
color: #9CA3AF;
margin-top: 18rpx;
}
.md-fav {
flex: 0 0 220rpx;
margin-right: 18rpx;
}
.md-fav-t {
font-size: 31.4rpx;
font-weight: bold;
color: #1F2937;
margin-left: 10rpx;
}
.md-main {
flex: 1;
}
.md-main-t {
font-size: 32.4rpx;
font-weight: bold;
color: #FFFFFF;
}
</style>
+166
View File
@@ -0,0 +1,166 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="顺序练习" @right-click="onMore">
<template v-slot:right>
<x-icon name="more-fill" color="#6B7280" font-size="18"></x-icon>
</template>
<view class="hr-body">
<!-- ============ hero ============ -->
<view class="prac-hero">
<view class="prac-c1"></view>
<view class="prac-ic">
<x-icon name="play-fill" color="#FFFFFF" font-size="27"></x-icon>
</view>
<text class="prac-t">{{ meta.subject }}</text>
<text class="prac-s">顺序练习 · 按章节顺序出题</text>
</view>
<!-- ============ 练习信息 ============ -->
<hr-card variant="list" style="margin-top:10px">
<hr-lrow icon="▦" theme="pri" title="题目数量" sub="覆盖本课题全部章节">
<template v-slot:right>
<text class="prac-val">{{ meta.total }} 题</text>
</template>
</hr-lrow>
<hr-lrow icon="◔" theme="amb" title="预计用时" sub="按每题 36 秒估算">
<template v-slot:right>
<text class="prac-val">约 {{ meta.minutes }} 分钟</text>
</template>
</hr-lrow>
<hr-lrow icon="✓" theme="grn" title="目标正确率" sub="达标后计入课题完成度" :is-last="true">
<template v-slot:right>
<text class="prac-val prac-val-grn">{{ meta.goal }}</text>
</template>
</hr-lrow>
</hr-card>
<hr-tip style="margin-top:10px" theme="warn" icon="⚑"
:text="'顺序练习不限制次数,答错的题目会自动进入错题本,可随时回来复习。'"></hr-tip>
<!-- ============ 上次练习 ============ -->
<hr-sec title="上次练习" :first="false"></hr-sec>
<hr-card>
<view class="hr-row-between">
<text class="prac-last">{{ meta.lastTime }} · 已练 {{ meta.lastDone }} 题</text>
<text class="prac-rate">正确率 {{ meta.lastRate }}%</text>
</view>
<view style="margin-top:9px">
<hr-progress :percent="lastPct"></hr-progress>
</view>
<text class="prac-note">上次练到第 {{ meta.lastDone }} 题,可继续或重新开始。</text>
</hr-card>
<view style="height:14px"></view>
</view>
<template v-slot:footer>
<view class="hr-fixedbar">
<view class="hr-btn hr-btn-primary hr-btn-lg" style="flex:1" @click="onStart">
<text class="prac-btn-t">开始练习</text>
</view>
</view>
</template>
</hr-page>
</template>
<script lang="uts" setup>
import { computed } from 'vue'
import { practiceMeta } from '@/mock/index.uts'
import { navTo } from '@/utils/nav.uts'
/** s36 顺序练习 */
const meta = practiceMeta
const lastPct = computed(() : number => {
return Math.round(meta.lastDone * 100 / meta.total)
})
function onStart() : void {
navTo('/pages/course/quiz?idx=0')
}
function onMore() : void {
uni.showToast({ title: '练习记录 / 重置进度', icon: 'none' })
}
</script>
<style>
.prac-hero {
background-image: linear-gradient(to bottom right, #2563EB, #1D4ED8);
border-radius: 32rpx;
padding: 44rpx 32rpx 44rpx 32rpx;
flex-direction: column;
align-items: center;
overflow: hidden;
position: relative;
}
.prac-c1 {
position: absolute;
right: -80rpx;
bottom: -104rpx;
width: 240rpx;
height: 240rpx;
border-radius: 120rpx;
background-color: rgba(255, 255, 255, 0.09);
}
.prac-ic {
width: 128rpx;
height: 128rpx;
border-radius: 40rpx;
background-color: rgba(255, 255, 255, 0.2);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-bottom: 24rpx;
}
.prac-t {
font-size: 35.8rpx;
font-weight: bold;
color: #FFFFFF;
}
.prac-s {
font-size: 25.8rpx;
color: rgba(255, 255, 255, 0.86);
margin-top: 10rpx;
}
.prac-val {
font-size: 33.6rpx;
font-weight: bold;
color: #1F2937;
margin-left: 16rpx;
}
.prac-val-grn {
color: #059669;
}
.prac-last {
font-size: 28rpx;
color: #6B7280;
}
.prac-rate {
font-size: 28rpx;
font-weight: bold;
color: #2563EB;
}
.prac-note {
font-size: 24.6rpx;
color: #9CA3AF;
margin-top: 14rpx;
}
.prac-btn-t {
font-size: 35.8rpx;
font-weight: bold;
color: #FFFFFF;
}
</style>
+396
View File
@@ -0,0 +1,396 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" :nav-title="'顺序练习 · 第 ' + (cur + 1) + ' 题'"
@right-click="onMarkCurrent">
<template v-slot:right>
<x-icon :name="isMarked ? 'bookmark-fill' : 'bookmark-line'" :color="isMarked ? '#F59E0B' : '#6B7280'"
font-size="19"></x-icon>
</template>
<view class="hr-body">
<!-- ============ 进度 ============ -->
<view class="hr-row">
<view style="flex:1">
<hr-progress :percent="pct"></hr-progress>
</view>
<text class="qz-prog">{{ cur + 1 }} / {{ total }}</text>
</view>
<!-- ============ 题卡 ============ -->
<hr-card style="margin-top:10px;padding:16px 14px 16px 14px">
<text class="qz-stem">{{ q.stem }}</text>
<view class="qz-opts">
<view v-for="(o, i) in q.options" :key="i" :class="optClass(i)" @click="choose(i)">
<view :class="optKeyClass(i)">
<text :class="optKeyTextClass(i)">{{ o.key }}</text>
</view>
<text class="qz-opt-t">{{ o.text }}</text>
</view>
</view>
<view class="qz-acts">
<view class="hr-btn hr-btn-ghost qz-act" @click="onMarkCurrent">
<x-icon :name="isMarked ? 'bookmark-fill' : 'bookmark-line'"
:color="isMarked ? '#F59E0B' : '#1F2937'" font-size="16"></x-icon>
<text class="qz-act-t">{{ isMarked ? '已标记' : '标记本题' }}</text>
</view>
<view class="hr-btn hr-btn-ghost qz-act qz-act-last" @click="openCard">
<x-icon name="layout-grid-line" color="#1F2937" font-size="16"></x-icon>
<text class="qz-act-t">答题卡</text>
</view>
</view>
</hr-card>
<hr-tip style="margin-top:10px" theme="info" icon=""
text="答错的题目会自动进入错题本,可在课题功能页统一复习。"></hr-tip>
<!-- 作答反馈 -->
<view v-if="showAnswer" class="qz-feedback" :class="isRight ? 'qz-feedback-ok' : 'qz-feedback-err'">
<view class="qz-fb-ic" :class="isRight ? 'qz-fb-ic-ok' : 'qz-fb-ic-err'">
<text class="qz-fb-ic-t">{{ isRight ? '✓' : '✕' }}</text>
</view>
<view class="hr-flex1">
<text class="qz-fb-b">{{ isRight ? '回答正确' : '回答错误,已加入错题本' }}</text>
<text class="qz-fb-s">正确答案:{{ rightAnswer }}</text>
<text class="qz-fb-s">{{ q.analysis }}</text>
</view>
</view>
<view style="height:14px"></view>
</view>
<template v-slot:footer>
<view class="hr-fixedbar">
<view class="hr-btn hr-btn-ghost qz-foot-prev" @click="prev">
<text class="qz-foot-t">上一题</text>
</view>
<view class="hr-btn hr-btn-primary" style="flex:1" @click="next">
<text class="qz-foot-t2">{{ isLast ? '交卷并查看成绩' : '下一题' }}</text>
</view>
</view>
</template>
</hr-page>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { quizQuestions } from '@/mock/index.uts'
import { quizState, setAnswer, toggleMark, answeredCount } from '@/utils/quiz.uts'
import { relaunchTo, navTo } from '@/utils/nav.uts'
import { HrQuestion } from '@/mock/types.uts'
/** s03 答题界面 */
const total = quizQuestions.length
const cur = ref(0)
const refreshKey = ref(0)
const q = computed(() : HrQuestion => {
return quizQuestions[cur.value]
})
const selected = computed(() : number => {
const k = refreshKey.value
return quizState.answers[cur.value]
})
const isMarked = computed(() : boolean => {
const k = refreshKey.value
return quizState.marked[cur.value]
})
const isLast = computed(() : boolean => {
return cur.value == total - 1
})
const pct = computed(() : number => {
return Math.round((cur.value + 1) * 100 / total)
})
const showAnswer = computed(() : boolean => {
const k = refreshKey.value
return quizState.answers[cur.value] >= 0
})
const isRight = computed(() : boolean => {
const k = refreshKey.value
return quizState.answers[cur.value] == q.value.answer
})
const rightAnswer = computed(() : string => {
const o = q.value.options[q.value.answer]
return `${o.key}. ${o.text}`
})
// uni-app x 页面显示生命周期:从答题卡返回时同步当前题号与作答状态
onPageShow(() => {
cur.value = quizState.current
refreshKey.value = refreshKey.value + 1
})
function optClass(i : number) : string {
if (selected.value == i) {
if (i == q.value.answer) { return 'qz-opt qz-opt-ok' }
return 'qz-opt qz-opt-sel'
}
if (showAnswer.value && i == q.value.answer) { return 'qz-opt qz-opt-ok' }
return 'qz-opt'
}
function optKeyClass(i : number) : string {
if (selected.value == i) {
if (i == q.value.answer) { return 'qz-ok qz-ok-ok' }
return 'qz-ok qz-ok-sel'
}
if (showAnswer.value && i == q.value.answer) { return 'qz-ok qz-ok-ok' }
return 'qz-ok'
}
function optKeyTextClass(i : number) : string {
if (selected.value == i || (showAnswer.value && i == q.value.answer)) {
return 'qz-ok-t qz-ok-t-on'
}
return 'qz-ok-t'
}
function choose(i : number) : void {
if (selected.value >= 0) {
uni.showToast({ title: '本题已作答', icon: 'none' })
return
}
setAnswer(cur.value, i)
refreshKey.value = refreshKey.value + 1
if (i != q.value.answer) {
// 答错自动进错题本(mock 阶段仅提示)
uni.showToast({ title: '已加入错题本', icon: 'none' })
}
}
function onMarkCurrent() : void {
toggleMark(cur.value)
refreshKey.value = refreshKey.value + 1
uni.showToast({ title: isMarked.value ? '已标记本题' : '已取消标记', icon: 'none' })
}
function prev() : void {
if (cur.value == 0) {
uni.showToast({ title: '已是第一题', icon: 'none' })
return
}
cur.value = cur.value - 1
quizState.current = cur.value
refreshKey.value = refreshKey.value + 1
}
// ⚠ submit 被 next() 调用,必须先定义(UTS 不提升函数声明)
function submit() : void {
const done = answeredCount()
uni.showModal({
title: '交卷确认',
content: `已作答 ${done} / ${total} 题${done < total ? ',仍有题目未作答' : ''}。交卷后无法修改。`,
cancelText: '继续答题',
confirmText: '交卷',
success: (res) => {
if (res.confirm) {
quizState.submitted = true
uni.showToast({ title: '已交卷 · 得分 92', icon: 'success' })
setTimeout(() => {
relaunchTo('/pages/study/exams')
}, 700)
}
}
})
}
function next() : void {
if (isLast.value) {
submit()
return
}
cur.value = cur.value + 1
quizState.current = cur.value
refreshKey.value = refreshKey.value + 1
}
function openCard() : void {
quizState.current = cur.value
navTo('/pages/course/answer-card')
}
</script>
<style>
.qz-prog {
font-size: 25.8rpx;
font-weight: bold;
color: #6B7280;
margin-left: 20rpx;
}
.qz-stem {
font-size: 33.6rpx;
font-weight: bold;
color: #1F2937;
line-height: 1.6;
}
.qz-opts {
flex-direction: column;
margin-top: 30rpx;
}
.qz-opt {
display: flex;
flex-direction: row;
align-items: center;
min-height: 112rpx;
padding: 20rpx 26rpx 20rpx 26rpx;
border: 3rpx solid #E5E7EB;
border-radius: 24rpx;
background-color: #FFFFFF;
margin-bottom: 18rpx;
}
.qz-opt-sel {
border: 3rpx solid #2563EB;
background-color: #EFF6FF;
}
.qz-opt-ok {
border: 3rpx solid #10B981;
background-color: #ECFDF5;
}
.qz-ok {
width: 52rpx;
height: 52rpx;
border-radius: 26rpx;
border: 3rpx solid #E5E7EB;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-right: 22rpx;
}
.qz-ok-sel {
background-color: #2563EB;
border: 3rpx solid #2563EB;
}
.qz-ok-ok {
background-color: #10B981;
border: 3rpx solid #10B981;
}
.qz-ok-t {
font-size: 26.8rpx;
font-weight: bold;
color: #6B7280;
}
.qz-ok-t-on {
color: #FFFFFF;
}
.qz-opt-t {
flex: 1;
font-size: 30.2rpx;
color: #1F2937;
}
.qz-acts {
display: flex;
flex-direction: row;
margin-top: 30rpx;
}
.qz-act {
flex: 1;
height: 88rpx;
margin-right: 20rpx;
}
.qz-act-last {
margin-right: 0rpx;
}
.qz-act-t {
font-size: 30.2rpx;
font-weight: bold;
color: #1F2937;
margin-left: 12rpx;
}
.qz-feedback {
display: flex;
flex-direction: row;
align-items: flex-start;
margin-top: 20rpx;
padding: 22rpx 24rpx 22rpx 24rpx;
border-radius: 22rpx;
}
.qz-feedback-ok {
background-color: #ECFDF5;
border: 2rpx solid #A7F3D0;
}
.qz-feedback-err {
background-color: #FEF2F2;
border: 2rpx solid #FECACA;
}
.qz-fb-ic {
width: 52rpx;
height: 52rpx;
border-radius: 16rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-right: 20rpx;
}
.qz-fb-ic-ok {
background-color: #10B981;
}
.qz-fb-ic-err {
background-color: #DC2626;
}
.qz-fb-ic-t {
font-size: 29.2rpx;
font-weight: bold;
color: #FFFFFF;
}
.qz-fb-b {
font-size: 28rpx;
font-weight: bold;
color: #1F2937;
}
.qz-fb-s {
font-size: 24.6rpx;
color: #6B7280;
margin-top: 6rpx;
}
.qz-foot-prev {
flex: 0 0 220rpx;
margin-right: 18rpx;
}
.qz-foot-t {
font-size: 31.4rpx;
font-weight: bold;
color: #1F2937;
}
.qz-foot-t2 {
font-size: 32.4rpx;
font-weight: bold;
color: #FFFFFF;
}
</style>
+361
View File
@@ -0,0 +1,361 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="课题详情" :xloading="true" @right-click="onMore">
<template v-slot:right>
<x-icon name="more-fill" color="#6B7280" font-size="18"></x-icon>
</template>
<!-- ============ 课题 hero ============ -->
<view class="subj-bar">
<view class="subj-c1"></view>
<view class="subj-info">
<view class="subj-nav" @click="prevSubject">
<x-icon name="arrow-left-s-line" color="#FFFFFF" font-size="18"></x-icon>
</view>
<view class="subj-mid">
<view class="hr-flex1">
<text class="subj-name">{{ subject }}</text>
<text class="subj-sub">急救基础 · 心肺复苏 · 资料 6 份 · 题库 50</text>
</view>
</view>
<view class="subj-nav" @click="nextSubject">
<x-icon name="arrow-right-s-line" color="#FFFFFF" font-size="18"></x-icon>
</view>
</view>
</view>
<view class="hr-body">
<!-- ============ 学习进度 ============ -->
<hr-card>
<view class="hr-row-between" style="margin-bottom:9px">
<text class="sj-lb">学习进度</text>
<text class="sj-pct">{{ progress }}%</text>
</view>
<hr-progress :percent="progress"></hr-progress>
<view class="hr-row" style="margin-top:11px">
<text class="sj-meta">已学资料 <text class="sj-meta-v">4/6</text></text>
<text class="sj-meta sj-meta-gap">已练题 <text class="sj-meta-v">32/50</text></text>
<text class="sj-meta sj-meta-gap">正确率 <text class="sj-meta-ok">{{ rate }}%</text></text>
</view>
</hr-card>
<!-- ============ 功能入口 ============ -->
<hr-sec title="功能入口"></hr-sec>
<view class="sj-entries">
<view v-for="(e, i) in entries" :key="i" class="sj-entry"
:class="i == entries.length - 1 ? 'sj-entry-last' : ''" @click="onEntry(i)">
<view class="sj-entry-ic" :style="{ backgroundColor: e.bg }">
<x-icon :name="entryIcon(e.icon)" :color="e.color" font-size="40rpx"></x-icon>
</view>
<text class="sj-entry-b">{{ e.name }}</text>
<text class="sj-entry-s">{{ e.sub }}</text>
</view>
</view>
<!-- ============ 文档资料 ============ -->
<hr-sec title="文档资料" more="共 6 份 " @more-click="onMore"></hr-sec>
<hr-card variant="list">
<view v-for="(m, i) in materials" :key="m.id" class="doc-row"
:class="i == materials.length - 1 ? 'doc-row-last' : ''" @click="openMaterial(m)">
<view class="doc-ic" :style="{ backgroundColor: m.badgeColor }">
<x-icon v-if="m.kind == 'video'" name="play-fill" color="#FFFFFF" font-size="32rpx"></x-icon>
<text v-else class="doc-ic-tx">{{ m.icon }}</text>
</view>
<view class="hr-flex1">
<text class="doc-b">{{ m.title }}</text>
<text class="doc-s">{{ m.meta }}</text>
</view>
<view class="doc-go">
<x-icon name="arrow-right-s-line" color="#9CA3AF" font-size="34rpx"></x-icon>
</view>
</view>
</hr-card>
<view class="doc-hint">
<text class="doc-hint-t">✦ 点击任意资料查看详情(视频与文档共用同一详情页)</text>
</view>
<view style="height:14px"></view>
</view>
</hr-page>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { subjectMaterials, courseGroups } from '@/mock/index.uts'
import { HrMaterial, HrSubject } from '@/mock/types.uts'
import { navTo } from '@/utils/nav.uts'
import { toRemixIcon } from '@/utils/icon.uts'
/**
* s35 课题详情
* 不再设章节目录与章节学习;改为「学习进度 + 错题/收藏/顺序练习 三入口 + 文档资料列表」。
*/
type Entry = { icon : string, name : string, sub : string, bg : string, color : string }
/*
* ⚠ 模板里不能直接调用「import 进来的函数」:
* uvue 编译器会把 `<x-icon :name="toRemixIcon(e.icon)">` 编译成 `unref(toRemixIcon)(e.icon)`
* UTS/Kotlin 侧报 `找不到名称"invoke"` / `Function invocation 'toRemixIcon(...)' expected`。
* 必须包一层本文件内定义的函数(模板对 script 内定义的函数会走 _ctx.xxx,正常)。
*/
function entryIcon(glyph : string) : string {
return toRemixIcon(glyph)
}
const subject = ref('成人 CPR(高级生命支持)')
const progress = ref(68)
const rate = ref(78)
const materials = ref<HrMaterial[]>(subjectMaterials)
const entries : Entry[] = [
{ icon: '✗', name: '我的错题', sub: '12 道', bg: '#FEF2F2', color: '#DC2626' } as Entry,
{ icon: '★', name: '我的收藏', sub: '8 道', bg: '#FEF3C7', color: '#B45309' } as Entry,
{ icon: '▸', name: '顺序练习', sub: '50 题 · 约 30 分钟', bg: '#ECFDF5', color: '#059669' } as Entry
]
/** 全部三级课题,用于上一个 / 下一个切换 */
const allSubjects = computed(() : HrSubject[] => {
const list : HrSubject[] = []
for (let gi = 0; gi < courseGroups.length; gi++) {
const g = courseGroups[gi]
for (let ii = 0; ii < g.items.length; ii++) {
list.push(g.items[ii])
}
}
return list
})
function indexOfSubject() : number {
for (let i = 0; i < allSubjects.value.length; i++) {
if (allSubjects.value[i].name == subject.value) { return i }
}
return 0
}
function prevSubject() : void {
let i = indexOfSubject() - 1
if (i < 0) { i = allSubjects.value.length - 1 }
subject.value = allSubjects.value[i].name
}
function nextSubject() : void {
let i = indexOfSubject() + 1
if (i >= allSubjects.value.length) { i = 0 }
subject.value = allSubjects.value[i].name
}
function onEntry(i : number) : void {
if (i == 0) { navTo('/pages/study/wrong-book'); return }
if (i == 1) { uni.showToast({ title: '我的收藏 · 8 道题', icon: 'none' }); return }
navTo('/pages/course/practice')
}
function openMaterial(m : HrMaterial) : void {
navTo('/pages/course/media?id=' + m.id)
}
function onMore() : void {
uni.showToast({ title: '资料管理 / 下载全部', icon: 'none' })
}
</script>
<style>
/* ---------- hero ---------- */
.subj-bar {
background-image: linear-gradient(to bottom right, #2563EB, #1D4ED8);
padding: 24rpx 28rpx 24rpx 28rpx;
overflow: hidden;
position: relative;
}
.subj-c1 {
position: absolute;
right: -72rpx;
top: -92rpx;
width: 260rpx;
height: 260rpx;
border-radius: 130rpx;
background-color: rgba(255, 255, 255, 0.09);
}
.subj-info {
display: flex;
flex-direction: row;
align-items: center;
}
.subj-nav {
width: 52rpx;
height: 52rpx;
border-radius: 16rpx;
background-color: rgba(255, 255, 255, 0.18);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.subj-mid {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
margin-left: 18rpx;
margin-right: 18rpx;
}
.subj-name {
font-size: 31.4rpx;
font-weight: bold;
color: #FFFFFF;
lines: 1;
}
.subj-sub {
font-size: 23.6rpx;
color: rgba(255, 255, 255, 0.85);
margin-top: 4rpx;
lines: 1;
}
/* ---------- 学习进度 ---------- */
.sj-lb {
font-size: 29.2rpx;
font-weight: bold;
color: #1F2937;
}
.sj-pct {
font-size: 25.8rpx;
font-weight: bold;
color: #2563EB;
}
.sj-meta {
font-size: 24.6rpx;
color: #6B7280;
}
.sj-meta-gap {
margin-left: 28rpx;
}
.sj-meta-v {
font-size: 24.6rpx;
color: #1F2937;
font-weight: bold;
}
.sj-meta-ok {
font-size: 24.6rpx;
color: #059669;
font-weight: bold;
}
/* ---------- 功能入口 ---------- */
.sj-entries {
display: flex;
flex-direction: row;
}
.sj-entry {
flex: 1;
background-color: #FFFFFF;
border: 2rpx solid #E5E7EB;
border-radius: 26rpx;
padding: 26rpx 12rpx 22rpx 12rpx;
flex-direction: column;
align-items: center;
margin-right: 18rpx;
}
.sj-entry-last {
margin-right: 0rpx;
}
.sj-entry-ic {
width: 80rpx;
height: 80rpx;
border-radius: 26rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-bottom: 14rpx;
}
.sj-entry-b {
font-size: 25.8rpx;
font-weight: bold;
color: #1F2937;
}
.sj-entry-s {
font-size: 21.2rpx;
color: #9CA3AF;
margin-top: 4rpx;
}
/* ---------- 资料行 ---------- */
.doc-row {
display: flex;
flex-direction: row;
align-items: center;
padding-top: 24rpx;
padding-bottom: 24rpx;
border-bottom: 2rpx solid #F3F4F6;
}
.doc-row-last {
border-bottom-width: 0rpx;
}
.doc-ic {
width: 72rpx;
height: 72rpx;
border-radius: 20rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-right: 22rpx;
}
.doc-ic-tx {
font-size: 21.2rpx;
font-weight: bold;
color: #FFFFFF;
}
.doc-b {
font-size: 29.2rpx;
color: #1F2937;
lines: 1;
}
.doc-s {
font-size: 23.6rpx;
color: #9CA3AF;
margin-top: 4rpx;
}
.doc-go {
margin-left: 16rpx;
display: flex;
flex-direction: row;
align-items: center;
}
.doc-hint {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 16rpx 0rpx 4rpx 0rpx;
}
.doc-hint-t {
font-size: 23.6rpx;
color: #9CA3AF;
}
</style>
+291
View File
@@ -0,0 +1,291 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="事件详情" :xloading="true" @right-click="onMore">
<template v-slot:right>
<x-icon name="more-fill" color="#6B7280" font-size="18"></x-icon>
</template>
<view class="hr-body">
<!-- ============ 事件 hero ============ -->
<view class="evt-head">
<view class="evt-head-c1"></view>
<view class="evt-head-c2"></view>
<text class="evt-head-t">{{ detail.name }}</text>
<text class="evt-head-s">{{ detail.sub }}</text>
<view class="eh-team">
<text class="eh-team-k">{{ detail.teamKey }}</text>
<view class="eh-team-line"></view>
<text class="eh-team-v">{{ detail.teamValue }}</text>
</view>
<view class="evt-tags">
<view v-for="(tg, i) in detail.tags" :key="i" class="evt-tag">
<text class="evt-tag-t">{{ tg }}</text>
</view>
</view>
<view class="evt-kpi">
<view class="evt-kpi-cell">
<text class="evt-kpi-b">{{ detail.victimTotal }}</text>
<text class="evt-kpi-s">伤员总数</text>
</view>
<view class="evt-kpi-sep"></view>
<view class="evt-kpi-cell">
<text class="evt-kpi-b">{{ detail.transferred }}</text>
<text class="evt-kpi-s">已转运</text>
</view>
<view class="evt-kpi-sep"></view>
<view class="evt-kpi-cell">
<text class="evt-kpi-b">{{ detail.pending }}</text>
<text class="evt-kpi-s">待处置</text>
</view>
</view>
</view>
<!-- ============ 4 色分级计数条(常驻) ============ -->
<view style="margin-top:10px">
<hr-wband :red="detail.redCount" :amber="detail.amberCount" :green="detail.greenCount"
:black="detail.blackCount"></hr-wband>
</view>
<!-- ============ 工作台 ============ -->
<hr-sec title="工作台" more="4 个入口 "></hr-sec>
<view class="ws-grid">
<view v-for="(w, i) in works" :key="i" class="ws-item"
:class="i == works.length - 1 ? 'ws-item-last' : ''" @click="onWorkClick(i)">
<view class="ws-ic" :style="{ backgroundColor: w.bg }">
<x-icon :name="w.icon" :color="w.color" font-size="36rpx"></x-icon>
</view>
<text class="ws-t">{{ w.name }}</text>
</view>
</view>
<!-- ============ 救援团队 ============ -->
<hr-sec title="救援团队" more="5 人"></hr-sec>
<hr-card>
<x-avatar-group :list="teamAvatars" size="88rpx" round="28rpx" gutter="18rpx" :max-count="5"
:show-count="false" :flat="true" :random-bg-color="true" font-size="30rpx"></x-avatar-group>
<text class="tm-line">组长 张明远 · 检伤 李承志 · 转运 王志远</text>
</hr-card>
<!-- ============ 事件时间线 ============ -->
<hr-sec title="事件时间线"></hr-sec>
<hr-card>
<hr-timeline :nodes="detail.timeline"></hr-timeline>
</hr-card>
<view style="height:14px"></view>
</view>
</hr-page>
</template>
<script lang="uts" setup>
import { eventDetail } from '@/mock/index.uts'
import { navTo } from '@/utils/nav.uts'
/**
* s34 事件详情(执行态工作台)
* 工作台由 8 入口精简为 4 入口:检伤建档 / 伤员查询 / 批量处置 / 批量转运。
* 4 色分级计数条常驻 hero 下方。救援端不开放事件上报入口(救援员是接单方)。
*/
type WorkItem = { icon : string, name : string, bg : string, color : string, url : string }
const detail = eventDetail
/** xAvatarGroup 需要 string[] */
const teamAvatars : string[] = ['张', '李', '王', '陈', '赵']
const works : WorkItem[] = [
{ icon: 'heart-pulse-line', name: '检伤建档', bg: '#FEF2F2', color: '#DC2626', url: '/pages/triage/create' } as WorkItem,
{ icon: 'file-list-3-line', name: '伤员查询', bg: '#EFF6FF', color: '#2563EB', url: '/pages/triage/victims' } as WorkItem,
{ icon: 'clipboard-line', name: '批量处置', bg: '#ECFDF5', color: '#059669', url: '/pages/triage/batch-treat' } as WorkItem,
{ icon: 'arrow-left-right-line', name: '批量转运', bg: '#FFFBEB', color: '#D97706', url: '/pages/triage/batch-transfer' } as WorkItem
]
function onWorkClick(i : number) : void {
navTo(works[i].url)
}
function onMore() : void {
uni.showToast({ title: '事件归档 / 分享 / 报错', icon: 'none' })
}
</script>
<style>
/* ---------- hero ---------- */
.evt-head {
background-image: linear-gradient(to bottom right, #1E3A8A, #2563EB);
border-radius: 32rpx;
padding: 28rpx 28rpx 28rpx 28rpx;
overflow: hidden;
position: relative;
}
.evt-head-c1 {
position: absolute;
right: -68rpx;
top: -88rpx;
width: 252rpx;
height: 252rpx;
border-radius: 126rpx;
background-color: rgba(255, 255, 255, 0.10);
}
.evt-head-c2 {
position: absolute;
right: 52rpx;
bottom: -96rpx;
width: 156rpx;
height: 156rpx;
border-radius: 78rpx;
background-color: rgba(255, 255, 255, 0.07);
}
.evt-head-t {
font-size: 34.8rpx;
font-weight: bold;
color: #FFFFFF;
}
.evt-head-s {
font-size: 25.8rpx;
color: rgba(255, 255, 255, 0.85);
margin-top: 12rpx;
}
.eh-team {
display: flex;
flex-direction: row;
align-items: center;
margin-top: 20rpx;
background-color: rgba(255, 255, 255, 0.14);
border: 2rpx solid rgba(255, 255, 255, 0.22);
border-radius: 20rpx;
padding: 18rpx 22rpx 18rpx 22rpx;
}
.eh-team-k {
font-size: 22.4rpx;
font-weight: bold;
color: rgba(255, 255, 255, 0.78);
}
.eh-team-line {
width: 2rpx;
height: 24rpx;
background-color: rgba(255, 255, 255, 0.28);
margin-left: 18rpx;
margin-right: 18rpx;
}
.eh-team-v {
flex: 1;
font-size: 26.8rpx;
color: #FFFFFF;
}
.evt-tags {
display: flex;
flex-direction: row;
margin-top: 20rpx;
}
.evt-tag {
background-color: rgba(255, 255, 255, 0.18);
border-radius: 14rpx;
padding: 6rpx 18rpx 6rpx 18rpx;
margin-right: 12rpx;
}
.evt-tag-t {
font-size: 23.6rpx;
font-weight: bold;
color: #FFFFFF;
}
.evt-kpi {
display: flex;
flex-direction: row;
margin-top: 28rpx;
}
.evt-kpi-cell {
flex: 1;
flex-direction: column;
align-items: center;
}
.evt-kpi-b {
font-size: 42.6rpx;
font-weight: bold;
color: #FFFFFF;
}
.evt-kpi-s {
font-size: 22.4rpx;
color: rgba(255, 255, 255, 0.82);
margin-top: 6rpx;
}
.evt-kpi-sep {
width: 2rpx;
height: 60rpx;
background-color: rgba(255, 255, 255, 0.22);
margin-top: 4rpx;
}
/* ---------- 工作台 ---------- */
.ws-grid {
display: flex;
flex-direction: row;
}
.ws-item {
flex: 1;
background-color: #FFFFFF;
border: 2rpx solid #E5E7EB;
border-radius: 26rpx;
padding: 28rpx 8rpx 28rpx 8rpx;
flex-direction: column;
align-items: center;
margin-right: 16rpx;
}
.ws-item-last {
margin-right: 0rpx;
}
.ws-ic {
width: 76rpx;
height: 76rpx;
border-radius: 24rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-bottom: 16rpx;
}
.ws-ic-t {
font-size: 38rpx;
font-weight: bold;
}
.ws-t {
font-size: 25.8rpx;
color: #1F2937;
}
/* ---------- 团队 ---------- */
.tm-avt {
margin-right: 18rpx;
margin-bottom: 18rpx;
}
.tm-line {
font-size: 25.8rpx;
color: #6B7280;
margin-top: 8rpx;
}
</style>
+275
View File
@@ -0,0 +1,275 @@
<template>
<view class="hr-page" style="background-color:#F3F4F6;position:relative">
<hr-statusbar bg="#FFFFFF"></hr-statusbar>
<!-- ============ 执行状态 Tab(进行中 / 历史) ============ -->
<x-tabs :list="tabs" :model-value="activeId" item-width="50%" height="88rpx" font-size="28rpx"
active-font-size="28rpx" title-color="#6B7280" active-title-color="#2563EB" line-color="#2563EB"
title-padding="0" :is-item-center="true" @change="onTabChange"></x-tabs>
<view class="hr-flow">
<scroll-view class="hr-scroll" :scroll-y="true" :show-scrollbar="false">
<view class="hr-body">
<view v-for="(e, i) in shownList" :key="e.id" :class="cardClass(e)" @click="onCardClick(e)">
<view class="ev-top">
<view class="evt-dot" :style="dotStyle(e)"></view>
<text class="ev-name">{{ e.name }}</text>
<hr-pc :level="e.priority"></hr-pc>
</view>
<text class="ev-meta">{{ e.kind }}</text>
<text class="ev-meta2">现场:{{ e.place }} · {{ e.distance }}</text>
<view class="ev-foot">
<x-avatar-group :list="avatarsOf(e)" size="44rpx" round="14rpx" gutter="12rpx"
:max-count="5" :random-bg-color="true" font-size="22rpx"></x-avatar-group>
<view class="hr-flex1"></view>
<view class="hr-row">
<text class="ev-go">{{ e.stage == 'history' ? '已完成' : '详情' }}</text>
<x-icon name="arrow-right-s-line" color="#2563EB" font-size="26rpx"></x-icon>
</view>
</view>
</view>
<!-- 进行中说明 -->
<text v-if="tab == 0" class="ev-note">仅显示与我相关的派单</text>
<text v-if="tab == 0" class="ev-note2">时间与处理状态由后台同步,APP 端不展示</text>
<hr-empty v-if="shownList.length == 0" icon="alarm-warning-line" title="暂无事件"
sub="分配给你的派单会出现在「进行中」,处置完成后归档到「历史」"></hr-empty>
<view style="height:28rpx"></view>
</view>
</scroll-view>
<!-- 加载中覆盖层(组件库 xLoading -->
<view v-if="loading" class="hr-loading-mask">
<x-loading label="加载中…" :icon-size="'44rpx'" :text-size="'26rpx'" :vertical="true" color="#2563EB"
text-color="#6B7280"></x-loading>
</view>
</view>
<hr-tabbar :active="1"></hr-tabbar>
</view>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { eventList, eventDetail } from '@/mock/index.uts'
import { HrEventItem, HrEventMember } from '@/mock/types.uts'
import { navTo } from '@/utils/nav.uts'
import { mockRequest } from '@/utils/request.uts'
import { TABS_ITEM_INFO, TABS_ITEM } from '@/uni_modules/tmx-ui/interface.uts'
import { markEventsRead } from '@/utils/unread.uts'
/**
* s07 / s42 事件中心(执行态)
* 用「执行状态」Tab 切换:进行中 / 历史(组件库 xTabs)。
* ⚠ 原「待接收」Tab 及其全屏推送接收流程(60 秒倒计时 / 立即接收 / 稍后处理)已移除,
* 页面只服务进行中与历史两类事件。
*/
const stageIds : string[] = ['0', '1']
const activeId = ref('0')
const loading = ref(true)
/*
* 紧急事件红点「呼吸」光环
* ⚠ App 端(uvue)不支持 @keyframes / animation(编译期直接报错),
* 改为定时器逐帧推进 pulseF,再由 dotStyle() 下发 box-shadow。
* 周期 1.7s = 25 帧 × 68ms;第 0~19 帧扩散(19/25 ≈ 设计稿 75%),第 20~24 帧静默。
* 触发条件只看数据的 urgent 字段(与 stage 无关)。
*/
const PULSE_FRAMES = 25
const PULSE_RAMP = 19
const pulseF = ref(0)
let pulseTimer : number = 0
function startPulse() : void {
if (pulseTimer != 0) { return }
pulseTimer = setInterval(() => {
const f = pulseF.value + 1
pulseF.value = f >= PULSE_FRAMES ? 0 : f
}, 68)
}
function stopPulse() : void {
if (pulseTimer != 0) {
clearInterval(pulseTimer)
pulseTimer = 0
}
}
/** 红点样式:底色取数据里的 dotColor;紧急事件额外叠加扩散光环 */
function dotStyle(e : HrEventItem) : string {
const base = 'background-color:' + e.dotColor + ';'
if (!e.urgent) { return base }
const i = pulseF.value
// 扩散结束后的静默帧:半径与透明度都归零(设计稿 75% → 100%)
if (i > PULSE_RAMP) { return base + 'box-shadow:0 0 0 0rpx rgba(220, 38, 38, 0);' }
const k = i / PULSE_RAMP
const r = Math.round(k * 16)
const a = Math.round(55 * (1 - k))
return base + 'box-shadow:0 0 0 ' + r.toString() + 'rpx rgba(220, 38, 38, 0.' + a.toString() + ');'
}
onLoad(() => {
// 模拟 1s 请求,期间展示 xSkeleton 骨架屏
mockRequest(() => {
loading.value = false
})
startPulse()
})
// 进入事件列表即视为已查看 → TabBar 事件角标隐藏;同时恢复红点呼吸
onPageShow(() => {
markEventsRead()
startPulse()
})
// 页面不可见时停掉呼吸定时器,避免后台空转
onPageHide(() => {
stopPulse()
})
/** 当前 tab 下标(由 xTabs 的字符串 id 换算) */
const tab = computed(() : number => {
const i = stageIds.indexOf(activeId.value)
return i < 0 ? 0 : i
})
/** 顶部执行状态切换:只展示标题,不带数量角标(角标会挤压标题并干扰视线) */
const tabs = computed(() : TABS_ITEM_INFO[] => {
return [
{ title: '进行中', id: '0' } as TABS_ITEM_INFO,
{ title: '历史', id: '1' } as TABS_ITEM_INFO
]
})
const shownList = computed(() : HrEventItem[] => {
const stage = tab.value == 0 ? 'ongoing' : 'history'
const list : HrEventItem[] = []
for (let i = 0; i < eventList.length; i++) {
if (eventList[i].stage == stage) { list.push(eventList[i]) }
}
return list
})
/** xAvatarGroup 需要 string[] */
function avatarsOf(e : HrEventItem) : string[] {
const arr : string[] = []
for (let i = 0; i < e.members.length; i++) {
arr.push(e.members[i].avatar)
}
return arr
}
// ⚠ x-tabs 的 change 下发的是 TABS_ITEM(库内部把 TABS_ITEM_INFO 转成了 TABS_ITEM),
// 形参写 TABS_ITEM_INFO 会在 App 端报 Kotlin 类型不匹配
function onTabChange(item : TABS_ITEM, index : number) : void {
activeId.value = item.id == '' ? index.toString() : item.id
}
function cardClass(e : HrEventItem) : string {
return e.urgent ? 'evt-card evt-urgent' : 'evt-card'
}
function onFilter() : void {
uni.showToast({ title: '筛选:按状态 / 优先级 / 时间', icon: 'none' })
}
/** 点卡片统一进入事件详情(原「待接收 → 唤起全屏推送」分支已随待接收 Tab 一并移除) */
function onCardClick(e : HrEventItem) : void {
navTo('/pages/event/detail')
}
onUnload(() => {
stopPulse()
})
</script>
<style>
/* 顶部标题栏 / 自绘 Tab / 数量角标的样式均已移除:
导航由 hr-statusbar 承担,状态切换改用组件库 x-tabs。 */
/* ---------- 事件卡片 ---------- */
.evt-card {
background-color: #FFFFFF;
border: 2rpx solid #E5E7EB;
border-radius: 26rpx;
padding: 24rpx 26rpx 24rpx 26rpx;
margin-bottom: 18rpx;
}
.evt-urgent {
border: 2rpx solid #FECACA;
background-color: #FFFBFB;
}
.ev-top {
display: flex;
flex-direction: row;
align-items: center;
}
.evt-dot {
width: 16rpx;
height: 16rpx;
border-radius: 8rpx;
margin: 0 16rpx;
}
.ev-name {
flex: 1;
font-size: 30.2rpx;
font-weight: bold;
color: #1F2937;
margin-right: 16rpx;
}
.ev-meta {
font-size: 25.8rpx;
color: #6B7280;
margin-top: 12rpx;
}
.ev-meta2 {
font-size: 25.8rpx;
color: #6B7280;
margin-top: 2rpx;
}
.ev-foot {
display: flex;
flex-direction: row;
align-items: center;
margin-top: 18rpx;
}
.ev-go {
font-size: 25.8rpx;
color: #2563EB;
font-weight: bold;
}
.ev-note {
text-align: center;
font-size: 23.6rpx;
color: #9CA3AF;
margin-top: 16rpx;
}
.ev-note2 {
text-align: center;
font-size: 23.6rpx;
color: #9CA3AF;
margin-top: 2rpx;
}
/* 紧急事件红点呼吸(设计稿 evtPulse)
⚠ App 端 uvue 不支持 @keyframes / animation(编译期直接报 ERROR),
已改为页面内 setInterval 推进 pulseF,再由 dotStyle() 下发 box-shadow —— 见 script 顶部。
原 P0 全屏推送的 .ev-push* 整组样式已随「待接收」流程一并移除。 */
</style>
+314
View File
@@ -0,0 +1,314 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="" :xloading="true">
<!-- ============ 顶部 hero(日常学习总览) ============ -->
<view class="tr-hero">
<view class="tr-hero-c1"></view>
<view class="tr-hero-c2"></view>
<view class="tr-hero-row">
<view class="hr-row hr-flex1">
<view class="tr-avt">
<text class="tr-avt-t">{{ user.avatar }}</text>
</view>
<view class="hr-flex1">
<text class="tr-hi">{{ user.name }} · {{ user.title }}</text>
<text class="tr-sub">{{ user.dept }} · 工号 {{ user.jobNo }} · 培训等级 B</text>
</view>
</view>
<view class="tr-bell" @click="goMsg">
<x-icon name="notification-3-line" color="#FFFFFF" font-size="17"></x-icon>
<view class="tr-bell-dot"></view>
</view>
</view>
<view class="tr-statbar">
<view class="tr-sb-cell">
<view class="hr-row tr-sb-bwrap">
<text class="tr-sb-b">3.5</text>
<text class="tr-sb-u">h</text>
</view>
<text class="tr-sb-s">本周学时</text>
</view>
<view class="tr-sb-sep"></view>
<view class="tr-sb-cell">
<view class="hr-row tr-sb-bwrap">
<text class="tr-sb-b">12</text>
<text class="tr-sb-u">门</text>
</view>
<text class="tr-sb-s">已完成课程</text>
</view>
<view class="tr-sb-sep"></view>
<view class="tr-sb-cell">
<view class="hr-row tr-sb-bwrap">
<text class="tr-sb-b">3</text>
<text class="tr-sb-u">张</text>
</view>
<text class="tr-sb-s">有效证书</text>
</view>
</view>
</view>
<view class="hr-body">
<!-- ============ 待办任务 ============ -->
<hr-sec title="待办任务" more="全部 " first @more-click="onTodoAll"></hr-sec>
<hr-card variant="list">
<hr-lrow v-for="(t, i) in todos" :key="i" :icon="t.icon" :theme="t.iconTheme" :title="t.title"
:sub="t.sub" :value="t.action" value-theme="pri" :value-bold="true" :is-last="i == todos.length - 1"
@click="onTodoClick(i)"></hr-lrow>
</hr-card>
<!-- ============ 学习动态 ============ -->
<hr-sec title="学习动态" more="全部 " @more-click="onActivityAll"></hr-sec>
<hr-card variant="list">
<hr-lrow v-for="(a, i) in acts" :key="i" :icon="a.icon" :theme="a.iconTheme" :title="a.title"
:sub="a.sub" value="" :is-last="i == acts.length - 1" @click="onActivityClick(i)"></hr-lrow>
</hr-card>
<!-- ============ 工具箱 ============ -->
<hr-sec title="工具箱"></hr-sec>
<view class="tr-quick">
<view v-for="(q, i) in tools" :key="i" class="tr-q" :class="i == tools.length - 1 ? 'tr-q-last' : ''"
@click="onToolClick(i)">
<view class="tr-q-ic" :style="{ backgroundColor: q.iconBg }">
<x-icon :name="q.icon" :color="q.iconColor" font-size="36rpx"></x-icon>
</view>
<text class="tr-q-t">{{ q.name }}</text>
</view>
</view>
<view style="height:14px"></view>
</view>
<template v-slot:tabbar>
<hr-tabbar :active="0"></hr-tabbar>
</template>
</hr-page>
</template>
<script lang="uts" setup>
import { ref } from 'vue'
import { hrSession } from '@/utils/role.uts'
import { navTo } from '@/utils/nav.uts'
import { todoTasks, activities, toolBox } from '@/mock/index.uts'
import { HrTodoTask, HrActivity, HrToolBox } from '@/mock/types.uts'
/**
* s01 学员端首页(日常态 · 纯学习)
* 设计约束:首页只承载日常学习(学习总览 / 待办任务 / 学习动态 / 工具箱),
* 不含任何现场作业入口 —— 现场作业从「事件」Tab 进入。
*/
const user = hrSession
const todos = ref<HrTodoTask[]>(todoTasks)
const acts = ref<HrActivity[]>(activities)
const tools = ref<HrToolBox[]>(toolBox)
function goMsg() : void {
navTo('/pages/message/list')
}
function onTodoAll() : void {
navTo('/pages/course/library')
}
function onTodoClick(i : number) : void {
if (i == 0) {
navTo('/pages/course/subject')
} else {
navTo('/pages/study/exams')
}
}
function onActivityAll() : void {
navTo('/pages/study/center')
}
function onActivityClick(i : number) : void {
navTo('/pages/study/center')
}
function onToolClick(i : number) : void {
if (i == 0) { navTo('/pages/course/library'); return }
if (i == 1) { navTo('/pages/study/exams'); return }
if (i == 2) { navTo('/pages/study/certificates'); return }
navTo('/pages/study/wrong-book')
}
</script>
<style>
/* ---------- hero ---------- */
.tr-hero {
background-image: linear-gradient(to bottom right, #2563EB, #1E40AF);
padding: 28rpx 32rpx 44rpx 32rpx;
overflow: hidden;
position: relative;
}
.tr-hero-c1 {
position: absolute;
right: -80rpx;
top: -100rpx;
width: 300rpx;
height: 300rpx;
border-radius: 150rpx;
background-color: rgba(255, 255, 255, 0.10);
}
.tr-hero-c2 {
position: absolute;
right: 64rpx;
bottom: -112rpx;
width: 192rpx;
height: 192rpx;
border-radius: 96rpx;
background-color: rgba(255, 255, 255, 0.07);
}
.tr-hero-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.tr-avt {
width: 88rpx;
height: 88rpx;
border-radius: 44rpx;
background-color: rgba(255, 255, 255, 0.22);
border: 3rpx solid rgba(255, 255, 255, 0.5);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-right: 22rpx;
}
.tr-avt-t {
font-size: 35.8rpx;
font-weight: bold;
color: #FFFFFF;
}
.tr-hi {
font-size: 40.4rpx;
font-weight: bold;
color: #FFFFFF;
}
.tr-sub {
font-size: 25.8rpx;
color: rgba(255, 255, 255, 0.82);
margin-top: 6rpx;
}
.tr-bell {
width: 68rpx;
height: 68rpx;
border-radius: 34rpx;
background-color: rgba(255, 255, 255, 0.16);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.tr-bell-dot {
position: absolute;
margin-left: 28rpx;
margin-top: -24rpx;
width: 14rpx;
height: 14rpx;
border-radius: 8rpx;
background-color: #DC2626;
}
.tr-statbar {
display: flex;
flex-direction: row;
background-color: #FFFFFF;
border-radius: 28rpx;
margin-top: 32rpx;
padding: 28rpx 8rpx 28rpx 8rpx;
box-shadow: 0 12rpx 36rpx rgba(15, 23, 42, 0.10);
}
.tr-sb-cell {
flex: 1;
flex-direction: column;
align-items: center;
}
.tr-sb-bwrap {
justify-content: center;
}
.tr-sb-b {
font-size: 44.8rpx;
font-weight: bold;
color: #1F2937;
}
.tr-sb-u {
font-size: 24.6rpx;
color: #6B7280;
margin-left: 2rpx;
}
.tr-sb-s {
font-size: 24.6rpx;
color: #6B7280;
margin-top: 8rpx;
}
.tr-sb-sep {
width: 2rpx;
height: 64rpx;
margin-top: 4rpx;
background-color: #E5E7EB;
}
/* ---------- 工具箱 ---------- */
.tr-quick {
display: flex;
flex-direction: row;
}
.tr-q {
flex: 1;
background-color: #FFFFFF;
border: 2rpx solid #E5E7EB;
border-radius: 24rpx;
padding: 24rpx 4rpx 20rpx 4rpx;
flex-direction: column;
align-items: center;
margin-right: 16rpx;
}
.tr-q-last {
margin-right: 0rpx;
}
.tr-q-ic {
width: 72rpx;
height: 72rpx;
border-radius: 22rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-bottom: 14rpx;
}
.tr-q-ic-t {
font-size: 35.8rpx;
font-weight: bold;
}
.tr-q-t {
font-size: 24.6rpx;
color: #1F2937;
}
</style>
+237
View File
@@ -0,0 +1,237 @@
<template>
<view class="hr-page" style="background-color:#FFFFFF">
<hr-statusbar bg="#FFFFFF"></hr-statusbar>
<view class="lg-wrap">
<!-- 品牌标识 -->
<view class="lg-logo">
<x-icon name="add-line" color="#FFFFFF" font-size="34" font-weight="bold"></x-icon>
</view>
<text class="lg-title">医院救援管理平台</text>
<text class="lg-sub">请使用院内工号登录\n角色由后台根据工号自动识别</text>
<!-- 角色识别结果(点击可切换演示角色) -->
<view class="lg-role" @click="toggleRole">
<view class="lg-role-ic">
<x-icon :name="role == 'keeper' ? 'archive-line' : 'shield-cross-line'" color="#FFFFFF"
font-size="19"></x-icon>
</view>
<view class="hr-flex1">
<text class="lg-role-t">{{ roleLabel }}</text>
<text class="lg-role-s">工号识别后自动进入对应工作台 · 点此切换演示角色</text>
</view>
<x-icon name="arrow-left-right-line" color="#3B6BC7" font-size="16"></x-icon>
</view>
<!-- 表单(xInput -->
<view class="lg-form">
<x-input v-model="jobNo" left-text="工号" left-icon="user-3-line" placeholder="请输入院内工号"
round="22rpx" height="96rpx" font-size="30rpx" color="#F9FAFB" font-color="#1F2937"
icon-color="#9CA3AF"></x-input>
<view class="lg-field-gap">
<x-input v-model="password" :password="true" left-text="密码" left-icon="lock-line"
placeholder="请输入密码" round="22rpx" height="96rpx" font-size="30rpx" color="#F9FAFB"
font-color="#1F2937" icon-color="#9CA3AF"></x-input>
</view>
</view>
<view class="lg-opt">
<view class="hr-row">
<x-switch v-model="remember" size="small" color="#10B981"></x-switch>
<text class="lg-opt-t">记住工号</text>
</view>
<text class="lg-opt-forget" @click="onForget">忘记密码?</text>
</view>
<view class="hr-btn hr-btn-primary hr-btn-lg lg-btn" @click="onLogin">
<text class="lg-btn-t">登 录</text>
</view>
<text class="lg-foot">登录即表示同意《服务协议》与《隐私政策》\n遇到问题请联系急救中心值班室 8801</text>
</view>
</view>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { loginAs, hrSession } from '@/utils/role.uts'
import { relaunchTo } from '@/utils/nav.uts'
import { HrRole } from '@/mock/types.uts'
/**
* s26 登录
* 双角色入口:默认按设计稿为「救援人员 · 急救中心」,
* 点击角色卡可在「救援人员端 / 仓管人员端」之间切换,用于演示双角色差异。
*/
const role = ref<HrRole>('rescuer')
const jobNo = ref('1247')
const password = ref('123456')
const remember = ref(true)
const roleLabel = computed(() : string => {
return role.value == 'keeper' ? '仓管人员 · 后勤保障科' : '救援人员 · 急救中心'
})
function toggleRole() : void {
if (role.value == 'rescuer') {
role.value = 'keeper'
jobNo.value = '2083'
} else {
role.value = 'rescuer'
jobNo.value = '1247'
}
}
function onForget() : void {
uni.showModal({ title: '忘记密码', content: '请联系急救中心值班室 8801 重置工号密码。', showCancel: false })
}
function onLogin() : void {
if (jobNo.value == '') {
uni.showToast({ title: '请输入工号', icon: 'none' })
return
}
if (password.value == '') {
uni.showToast({ title: '请输入密码', icon: 'none' })
return
}
uni.showLoading({ title: '登录中' })
// 工号 2083 段自动识别为仓管人员端
let r : HrRole = role.value
if (jobNo.value.startsWith('2')) { r = 'keeper' }
if (jobNo.value.startsWith('1')) { r = 'rescuer' }
loginAs(r)
setTimeout(() => {
uni.hideLoading()
relaunchTo(hrSession.role == 'keeper' ? '/pages/warehouse/scan' : '/pages/home/index')
}, 400)
}
</script>
<style>
.lg-wrap {
flex: 1;
padding-left: 52rpx;
padding-right: 52rpx;
padding-bottom: 60rpx;
flex-direction: column;
}
.lg-logo {
width: 132rpx;
height: 132rpx;
border-radius: 40rpx;
background-image: linear-gradient(to bottom right, #2563EB, #1D4ED8);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-top: 52rpx;
align-self: center;
box-shadow: 0 20rpx 52rpx rgba(37, 99, 235, 0.30);
}
.lg-title {
text-align: center;
font-size: 42.6rpx;
font-weight: bold;
color: #1F2937;
margin-top: 36rpx;
}
.lg-sub {
text-align: center;
font-size: 26.8rpx;
color: #9CA3AF;
margin-top: 14rpx;
line-height: 1.6;
}
.lg-role {
margin-top: 48rpx;
display: flex;
flex-direction: row;
align-items: center;
background-color: #EFF6FF;
border: 2rpx solid #BFDBFE;
border-radius: 26rpx;
padding: 24rpx 26rpx 24rpx 26rpx;
}
.lg-role-ic {
width: 76rpx;
height: 76rpx;
border-radius: 24rpx;
background-color: #2563EB;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-right: 22rpx;
}
.lg-role-t {
font-size: 29.2rpx;
font-weight: bold;
color: #1E40AF;
}
.lg-role-s {
font-size: 24.6rpx;
color: #3B6BC7;
margin-top: 6rpx;
}
.lg-form {
margin-top: 36rpx;
flex-direction: column;
}
.lg-field-gap {
margin-top: 22rpx;
}
.lg-opt {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-top: 26rpx;
}
.lg-opt-t {
font-size: 26.8rpx;
color: #6B7280;
margin-left: 16rpx;
}
.lg-opt-forget {
font-size: 26.8rpx;
color: #2563EB;
font-weight: bold;
}
.lg-btn {
margin-top: 44rpx;
}
.lg-btn-t {
font-size: 35.8rpx;
font-weight: bold;
color: #FFFFFF;
}
.lg-foot {
text-align: center;
font-size: 25.8rpx;
color: #9CA3AF;
margin-top: 32rpx;
}
.lg-foot2 {
text-align: center;
font-size: 25.8rpx;
color: #9CA3AF;
margin-top: 2rpx;
}
</style>
+147
View File
@@ -0,0 +1,147 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="通讯录" @right-click="onSearch">
<template v-slot:right>
<x-icon name="search-line" color="#6B7280" font-size="34rpx"></x-icon>
</template>
<view class="hr-body">
<!-- ============ 分组列表:指挥调度 / 协作医院 / 本队同事 ============ -->
<view v-for="(g, gi) in groups" :key="gi">
<hr-sec :title="g.title" :first="gi == 0"></hr-sec>
<!-- 卡片内每行:左侧头像/机构图标 + 姓名与号码 + 呼叫按钮 -->
<hr-card padding="4rpx 26rpx">
<view v-for="(c, ci) in g.items" :key="ci"
:class="ci == g.items.length - 1 ? 'hr-lrow hr-lrow-last' : 'hr-lrow'" @click="onCall(c)">
<view class="ct-blk" :style="blkStyle(c)">
<!-- 机构(医院 / 协作单位)用图标,个人用姓名首字 -->
<x-icon v-if="!c.useAvatar" name="hospital-line" color="#2563EB"
font-size="30rpx"></x-icon>
<text v-else class="ct-blk-t">{{ blkText(c) }}</text>
</view>
<view class="hr-lmain">
<text class="hr-lmain-b">{{ c.name }}</text>
<text class="hr-lmain-s">{{ c.phone }}</text>
</view>
<x-icon name="phone-fill" color="#2563EB" font-size="32rpx"></x-icon>
</view>
</hr-card>
</view>
<view style="height:14px"></view>
</view>
</hr-page>
</template>
<script lang="uts" setup>
import { contactGroups } from '@/mock/index.uts'
import { HrContact, HrContactGroup } from '@/mock/types.uts'
/**
* s28 通讯录
* 与设计稿一致:按「指挥调度 / 协作医院 / 本队同事」三段分组,
* 每组 = 小节标题 + 卡片列表行(头像 + 姓名 + 号码 + 呼叫)。
* 数据全部来自 mock/index.uts,换接口只改取数处。
*/
const groups : HrContactGroup[] = contactGroups
/** 头像文字:优先用数据里的字母头,缺省回落到姓名首字 */
function blkText(c : HrContact) : string {
if (c.avatar != '') { return c.avatar }
if (c.name.length > 0) { return c.name.substring(0, 1) }
return '医'
}
/**
* 头像底色。uvue 不支持复合类选择器(.ct-blk.ct-blk-gn),
* 因此主题色用内联样式下发;机构块固定浅蓝底。
*/
function blkStyle(c : HrContact) : string {
if (!c.useAvatar) { return 'background-color:#EFF6FF;' }
if (c.theme == 'gn') { return 'background-color:#059669;' }
if (c.theme == 'ag') { return 'background-color:#D97706;' }
if (c.theme == 'rd') { return 'background-color:#DC2626;' }
if (c.theme == 'gy') { return 'background-color:#94A3B8;' }
return 'background-color:#2563EB;'
}
/** 只保留数字字符(0-9) */
function digitsOnly(s : string) : string {
const table = '0123456789'
let out = ''
for (let i = 0; i < s.length; i++) {
const ch = s.charAt(i)
if (table.indexOf(ch) > -1) { out += ch }
}
return out
}
/**
* 从「值班室 8801 · 手机 138****2201」这类文案里取出可拨号码。
* 规则:按「·」分段,取「标签 号码」中的号码部分,
* 纯数字(不含 * 掩码)、长度≥4 才算号码;「工号 1248」这类直接跳过。
*/
function extractPhone(text : string) : string {
const parts = text.split('·')
for (let i = 0; i < parts.length; i++) {
const seg = parts[i].trim()
const idx = seg.indexOf(' ')
const label = idx >= 0 ? seg.substring(0, idx) : ''
const raw = idx >= 0 ? seg.substring(idx + 1) : seg
if (label.indexOf('工号') > -1) { continue }
const digits = digitsOnly(raw)
const compact = raw.replaceAll(' ', '').replaceAll('-', '')
if (digits.length >= 4 && digits.length == compact.length) { return digits }
}
return ''
}
// ⚠ UTS 不提升函数声明:extractPhone / digitsOnly 必须写在 onCall 之前
function onCall(c : HrContact) : void {
const digits = extractPhone(c.phone)
if (digits == '') {
uni.showToast({ title: '暂无可用号码', icon: 'none' })
return
}
uni.showModal({
title: c.name,
content: `呼叫 ${digits}`,
confirmText: '呼叫',
success: (res) => {
if (!res.confirm) { return }
// #ifdef APP
uni.makePhoneCall({ phoneNumber: digits })
// #endif
// #ifndef APP
uni.showToast({ title: '已复制号码 ' + digits, icon: 'none' })
uni.setClipboardData({ data: digits })
// #endif
}
})
}
function onSearch() : void {
uni.showToast({ title: '搜索联系人 / 医院', icon: 'none' })
}
</script>
<style>
/* 头像 / 机构图标块:设计稿 .avt.sm / .lic30~32px */
.ct-blk {
width: 64rpx;
height: 64rpx;
border-radius: 18rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-right: 20rpx;
}
.ct-blk-t {
font-size: 25rpx;
font-weight: bold;
color: #FFFFFF;
}
</style>
+191
View File
@@ -0,0 +1,191 @@
<template>
<view class="hr-page" style="background-color:#F3F4F6">
<hr-statusbar bg="#FFFFFF"></hr-statusbar>
<view class="ms-nav">
<text class="ms-nav-t">消息</text>
<view class="ms-nav-a" @click="onReadAll">
<x-icon name="check-double-line" color="#6B7280" font-size="16"></x-icon>
<text class="ms-nav-a-t">全部已读</text>
</view>
</view>
<view class="hr-flow">
<scroll-view class="hr-scroll" :scroll-y="true" :show-scrollbar="false">
<view class="hr-body">
<!-- 消息分类筛选(组件库 xRadioButton 分段切换) -->
<view style="margin-bottom:8rpx">
<x-radio-button v-model="chipId" :list="chipTabs" height="64rpx" space="4rpx" round="22rpx"
text-style="font-weight:bold;" bg-color="#E5E7EB" active-color="#2563EB"
active-font-color="#FFFFFF" font-color="#6B7280" font-size="22rpx"></x-radio-button>
</view>
<view v-for="(g, gi) in groups" :key="gi">
<hr-sec :title="g.title" :more="g.count > 0 ? g.count.toString() + ' 条' : ''"></hr-sec>
<hr-card :variant="g.danger ? 'danger' : 'list'">
<hr-lrow v-for="(m, i) in g.items" :key="i" :icon="m.icon" :theme="m.theme" :title="m.title"
:sub="m.sub" :value="m.action" :value-theme="m.theme == 'red' ? 'red' : 'pri'"
:value-bold="true" :is-last="i == g.items.length - 1" @click="onMsgClick(m, gi, i)">
<template v-slot:right>
<view v-if="isUnread(gi, i, m.unread)" class="hr-badge">
<text class="hr-badge-t">{{ m.unread }}</text>
</view>
</template>
</hr-lrow>
</hr-card>
</view>
<hr-empty v-if="groups.length == 0" icon="✉" title="暂无消息"
sub="库存预警、借用审批与系统通知会汇总到这里"></hr-empty>
<view style="height:28rpx"></view>
</view>
</scroll-view>
<!-- 加载中覆盖层(组件库 xLoading -->
<view v-if="loading" class="hr-loading-mask">
<x-loading label="加载中…" :icon-size="'44rpx'" :text-size="'26rpx'" :vertical="true" color="#2563EB"
text-color="#6B7280"></x-loading>
</view>
</view>
<hr-tabbar :active="3"></hr-tabbar>
</view>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { keeperMsgGroups } from '@/mock/index.uts'
import { HrMsg, HrMsgGroup } from '@/mock/types.uts'
import { navTo } from '@/utils/nav.uts'
import { mockRequest } from '@/utils/request.uts'
import { markMessagesRead } from '@/utils/unread.uts'
import { RADIO_BUTTON } from '@/uni_modules/tmx-ui/interface.uts'
/**
* s33 消息中心 · 物资端(仓管 Tab「消息」)
* 库存预警自动置顶;借用审批「去审批」直达设备借出台账。
* 角标 = 未读数,阅读后消失:整页查看清 tabbar/入口角标,点开单条清该条角标。
*/
/**
* 消息分类筛选(组件库 xRadioButton 分段切换,替代原 hr-chip 横向滚动)
* ⚠ RADIO_BUTTON 的 id 是 string:统一用下标字符串,选中值即筛选序号。
*/
const chipTabs : RADIO_BUTTON[] = [
{ id: '0', title: '全部 9' } as RADIO_BUTTON,
{ id: '1', title: '库存预警 4' } as RADIO_BUTTON,
{ id: '2', title: '借用审批 3' } as RADIO_BUTTON,
{ id: '3', title: '系统 2' } as RADIO_BUTTON
]
const chipId = ref('0')
/** 由字符串 id 换算分类序号,供分组筛选使用 */
const chip = computed(() : number => {
const i = parseInt(chipId.value)
return isNaN(i) ? 0 : i
})
/** 已读的条目(key = 分组下标-条目下标) */
const readKeys = ref<string[]>([])
const loading = ref(true)
onLoad(() => {
mockRequest(() => {
loading.value = false
})
})
onPageShow(() => {
markMessagesRead()
})
function keyOf(gi : number, i : number) : string {
return gi.toString() + '-' + i.toString()
}
function isRead(gi : number, i : number) : boolean {
return readKeys.value.indexOf(keyOf(gi, i)) > -1
}
function isUnread(gi : number, i : number, n : number) : boolean {
if (n <= 0) { return false }
return !isRead(gi, i)
}
function markRead(gi : number, i : number) : void {
const k = keyOf(gi, i)
if (readKeys.value.indexOf(k) > -1) { return }
const arr = readKeys.value
arr.push(k)
readKeys.value = arr
}
const groups = computed(() : HrMsgGroup[] => {
if (chip.value == 1) {
return [keeperMsgGroups[0]]
}
if (chip.value == 2) {
return [keeperMsgGroups[1]]
}
if (chip.value == 3) {
return [keeperMsgGroups[2]]
}
return keeperMsgGroups
})
function onMsgClick(m : HrMsg, gi : number, i : number) : void {
markRead(gi, i)
if (m.action == '去审批 ') {
navTo('/pages/warehouse/devices')
return
}
uni.showModal({ title: m.title, content: m.sub, showCancel: false })
}
function onReadAll() : void {
// 全部标记已读 → 所有条目角标一并消失
const arr : string[] = []
for (let gi = 0; gi < groups.value.length; gi++) {
const g = groups.value[gi]
for (let i = 0; i < g.items.length; i++) {
arr.push(keyOf(gi, i))
}
}
readKeys.value = arr
markMessagesRead()
uni.showToast({ title: '已全部标记为已读', icon: 'success' })
}
</script>
<style>
.ms-nav {
height: 96rpx;
display: flex;
flex-direction: row;
align-items: center;
padding-left: 32rpx;
padding-right: 32rpx;
background-color: #FFFFFF;
border-bottom: 2rpx solid #F3F4F6;
}
.ms-nav-t {
font-size: 33.6rpx;
font-weight: bold;
color: #1F2937;
}
.ms-nav-a {
margin-left: auto;
display: flex;
flex-direction: row;
align-items: center;
}
.ms-nav-a-t {
font-size: 29.2rpx;
color: #6B7280;
margin-left: 8rpx;
}
</style>
+88
View File
@@ -0,0 +1,88 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="消息中心" :xloading="true" @right-click="onReadAll">
<template v-slot:right>
<x-icon name="check-double-line" color="#6B7280" font-size="17"></x-icon>
</template>
<view class="hr-body">
<!-- 消息分类筛选(组件库 xRadioButton 分段切换) -->
<view style="margin-bottom:8rpx">
<x-radio-button v-model="chipId" :list="chipTabs" height="64rpx" space="4rpx" round="22rpx"
text-style="font-weight:bold;" bg-color="#E5E7EB" active-color="#2563EB"
active-font-color="#FFFFFF" font-color="#6B7280" font-size="22rpx"></x-radio-button>
</view>
<view v-for="(g, gi) in groups" :key="gi">
<hr-sec :title="g.title" :more="g.count > 0 ? g.count.toString() + ' 条' : ''"></hr-sec>
<hr-card :variant="g.danger ? 'danger' : 'list'">
<hr-lrow v-for="(m, i) in g.items" :key="i" :icon="m.icon" :theme="m.theme" :title="m.title"
:sub="m.sub" :value="m.action" :value-theme="m.theme == 'red' ? 'red' : 'pri'"
:value-bold="true" :is-last="i == g.items.length - 1" @click="onMsgClick(m)"></hr-lrow>
</hr-card>
</view>
<view style="height:14px"></view>
</view>
</hr-page>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { rescuerMsgGroups } from '@/mock/index.uts'
import { HrMsg, HrMsgGroup } from '@/mock/types.uts'
import { navTo } from '@/utils/nav.uts'
import { markMessagesRead } from '@/utils/unread.uts'
import { RADIO_BUTTON } from '@/uni_modules/tmx-ui/interface.uts'
/**
* s27 消息中心 · 救护端
* P0 派单消息可直接「去接收」,跳转到事件中心查看进行中的派单。
* 查看后清未读 → 「我的」页消息中心入口角标消失。
*/
/**
* 消息分类筛选(组件库 xRadioButton 分段切换,替代原 hr-chip 横向滚动)
* ⚠ RADIO_BUTTON 的 id 是 string:统一用下标字符串,选中值即筛选序号。
*/
const chipTabs : RADIO_BUTTON[] = [
{ id: '0', title: '全部 5' } as RADIO_BUTTON,
{ id: '1', title: '派单通知 2' } as RADIO_BUTTON,
{ id: '2', title: '系统消息 3' } as RADIO_BUTTON
]
const chipId = ref('0')
/** 由字符串 id 换算分类序号,供 groups 筛选使用 */
const chip = computed(() : number => {
const i = parseInt(chipId.value)
return isNaN(i) ? 0 : i
})
onPageShow(() => {
markMessagesRead()
})
const groups = computed(() : HrMsgGroup[] => {
if (chip.value == 1) { return [rescuerMsgGroups[0]] }
if (chip.value == 2) { return [rescuerMsgGroups[1]] }
return rescuerMsgGroups
})
function onMsgClick(m : HrMsg) : void {
if (m.action == '去接收 ') {
navTo('/pages/event/index')
return
}
if (m.title == 'BLS 证书即将到期提醒') {
navTo('/pages/study/certificates')
return
}
if (m.title.indexOf('新课题') > -1) {
navTo('/pages/course/library')
return
}
uni.showModal({ title: m.title, content: m.sub, showCancel: false })
}
function onReadAll() : void {
uni.showToast({ title: '已全部标记为已读', icon: 'success' })
}
</script>
+300
View File
@@ -0,0 +1,300 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="" :xloading="true">
<!-- ============ 个人 hero ============ -->
<view class="mine-hero">
<view class="mine-c1"></view>
<view class="mine-c2"></view>
<view class="mine-row">
<view class="mine-avt">
<text class="mine-avt-t">{{ user.avatar }}</text>
</view>
<view class="hr-flex1">
<text class="mine-name">{{ user.name }}</text>
<text class="mine-role">{{ user.title }} · {{ user.dept }} · 工号 {{ user.jobNo }}</text>
</view>
<view class="mine-edit" @click="onEdit">
<text class="mine-edit-t">编辑</text>
</view>
</view>
<view class="mine-tags">
<view v-for="(t, i) in user.tags" :key="i" class="mine-tag">
<text class="mine-tag-t">{{ t }}</text>
</view>
</view>
</view>
<!-- ============ 数据条(上浮压住 hero ============ -->
<view class="mine-stats">
<view class="mine-stat-cell">
<text class="mine-stat-b">{{ user.hours }}</text>
<text class="mine-stat-s">累计学时</text>
</view>
<view class="mine-stat-sep"></view>
<view class="mine-stat-cell">
<text class="mine-stat-b">{{ user.courseCount }}</text>
<text class="mine-stat-s">完成课程</text>
</view>
<view class="mine-stat-sep"></view>
<view class="mine-stat-cell">
<text class="mine-stat-b">{{ user.certCount }}</text>
<text class="mine-stat-s">有效证书</text>
</view>
</view>
<view class="hr-body" style="padding-top:16px">
<!-- 救援人员端:学习档案 -->
<hr-card v-if="isRescuer" variant="list">
<hr-lrow icon="▦" theme="pri" title="学习中心" sub="错题 12 · 收藏 8 · 笔记 5" value=""
@click="go('/pages/study/center')"></hr-lrow>
<hr-lrow icon="▤" theme="grn" title="我的证书" sub="3 张有效 · 1 张即将到期" value="" :is-last="true"
@click="go('/pages/study/certificates')"></hr-lrow>
</hr-card>
<!-- 仓管人员端:仓储概览 -->
<hr-card v-if="!isRescuer" variant="list">
<hr-lrow icon="!" theme="red" title="库存预警" sub="6 项低于预警线" value="去处理 " value-theme="red"
:value-bold="true" @click="go('/pages/warehouse/stock')"></hr-lrow>
<hr-lrow icon="⚑" theme="amb" title="借用审批" sub="3 条待审批" value="去审批 " value-theme="pri"
:value-bold="true" @click="go('/pages/message/index')"></hr-lrow>
<hr-lrow icon="⚙" theme="pri" title="设备台账" sub="24 台 · 4 台借出中" value="" :is-last="true"
@click="go('/pages/warehouse/devices')"></hr-lrow>
</hr-card>
<!-- 通用条目 -->
<hr-card variant="list" style="margin-top:10px">
<hr-lrow icon="✉" theme="amb" title="消息中心" :sub="msgSub" value=""
@click="go(isRescuer ? '/pages/message/list' : '/pages/message/index')">
<template v-slot:right>
<view v-if="hrUnread.msgCenter > 0" class="hr-badge">
<text class="hr-badge-t">{{ hrUnread.msgCenter }}</text>
</view>
</template>
</hr-lrow>
<hr-lrow v-if="isRescuer" icon="☎" title="通讯录" sub="指挥员 / 医院 / 同事" value=""
@click="go('/pages/message/contacts')"></hr-lrow>
<hr-lrow v-if="!isRescuer" icon="▦" theme="pri" title="扫码出入库" sub="连续扫码 · 三态即时反馈"
value="" @click="go('/pages/warehouse/scan')"></hr-lrow>
<hr-lrow icon="⚙" title="设置" sub="通知 / 隐私 / 关于" value="" :is-last="true"
@click="go('/pages/mine/settings')"></hr-lrow>
</hr-card>
<hr-card variant="list" style="margin-top:10px">
<hr-lrow icon="question-line" title="帮助与反馈" sub="使用手册 · 问题上报" value="" :is-last="true"
@click="onHelp"></hr-lrow>
</hr-card>
<view class="mine-logout" @click="onLogout">
<text class="mine-logout-t">退出登录</text>
</view>
<view style="height:14px"></view>
</view>
<template v-slot:tabbar>
<hr-tabbar :active="isRescuer ? 2 : 4"></hr-tabbar>
</template>
</hr-page>
</template>
<script lang="uts" setup>
import { computed } from 'vue'
import { hrSession, logout } from '@/utils/role.uts'
import { navTo, relaunchTo } from '@/utils/nav.uts'
import { hrUnread } from '@/utils/unread.uts'
/**
* s08 个人中心(双角色共用)
* 救援端:学习档案(学习中心 / 证书)+ 消息 + 通讯录
* 仓管端:仓储概览(库存预警 / 借用审批 / 设备台账)+ 消息 + 扫码
*/
const user = hrSession
const isRescuer = computed(() : boolean => {
return user.role != 'keeper'
})
const msgSub = computed(() : string => {
return isRescuer.value ? '派单通知 · 系统消息' : '库存预警 · 借用审批 · 系统'
})
function go(url : string) : void {
navTo(url)
}
function onEdit() : void {
uni.showToast({ title: '编辑个人资料', icon: 'none' })
}
function onHelp() : void {
uni.showModal({ title: '帮助与反馈', content: '使用手册 / 问题上报\n值班室电话 8801', showCancel: false })
}
function onLogout() : void {
uni.showModal({
title: '退出登录',
content: '确认退出当前账号?',
success: (res) => {
if (res.confirm) {
logout()
relaunchTo('/pages/login/index')
}
}
})
}
</script>
<style>
.mine-hero {
background-image: linear-gradient(to bottom right, #2563EB, #1E40AF);
padding: 32rpx 32rpx 52rpx 32rpx;
overflow: hidden;
position: relative;
}
.mine-c1 {
position: absolute;
right: -80rpx;
top: -96rpx;
width: 296rpx;
height: 296rpx;
border-radius: 148rpx;
background-color: rgba(255, 255, 255, 0.10);
}
.mine-c2 {
position: absolute;
right: 68rpx;
bottom: -104rpx;
width: 184rpx;
height: 184rpx;
border-radius: 92rpx;
background-color: rgba(255, 255, 255, 0.07);
}
.mine-row {
display: flex;
flex-direction: row;
align-items: center;
}
.mine-avt {
width: 112rpx;
height: 112rpx;
border-radius: 36rpx;
background-color: rgba(255, 255, 255, 0.22);
border: 3rpx solid rgba(255, 255, 255, 0.5);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-right: 26rpx;
}
.mine-avt-t {
font-size: 49.2rpx;
font-weight: bold;
color: #FFFFFF;
}
.mine-name {
font-size: 38rpx;
font-weight: bold;
color: #FFFFFF;
}
.mine-role {
font-size: 25.8rpx;
color: rgba(255, 255, 255, 0.85);
margin-top: 8rpx;
}
.mine-edit {
padding: 14rpx 24rpx 14rpx 24rpx;
border-radius: 18rpx;
background-color: rgba(255, 255, 255, 0.2);
}
.mine-edit-t {
font-size: 26.8rpx;
font-weight: bold;
color: #FFFFFF;
}
.mine-tags {
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin-top: 18rpx;
}
.mine-tag {
padding: 6rpx 18rpx 6rpx 18rpx;
border-radius: 14rpx;
background-color: rgba(255, 255, 255, 0.18);
margin-right: 12rpx;
margin-bottom: 12rpx;
}
.mine-tag-t {
font-size: 23.6rpx;
color: #FFFFFF;
}
.mine-stats {
display: flex;
flex-direction: row;
background-color: #FFFFFF;
border-radius: 28rpx;
margin-top: -28rpx;
margin-left: 32rpx;
margin-right: 32rpx;
padding: 26rpx 8rpx 26rpx 8rpx;
box-shadow: 0 12rpx 36rpx rgba(15, 23, 42, 0.10);
}
.mine-stat-cell {
flex: 1;
flex-direction: column;
align-items: center;
}
.mine-stat-b {
font-size: 42.6rpx;
font-weight: bold;
color: #1F2937;
}
.mine-stat-s {
font-size: 23.6rpx;
color: #6B7280;
margin-top: 6rpx;
}
.mine-stat-sep {
width: 2rpx;
height: 56rpx;
background-color: #F3F4F6;
margin-top: 6rpx;
}
.mine-logout {
margin-top: 32rpx;
height: 88rpx;
border-radius: 24rpx;
background-color: #FFFFFF;
border: 2rpx solid #E5E7EB;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.mine-logout-t {
font-size: 32.4rpx;
font-weight: bold;
color: #DC2626;
}
</style>
+80
View File
@@ -0,0 +1,80 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="设置" :xloading="true">
<view class="hr-body">
<!-- ============ 通知 ============ -->
<hr-sec title="通知" first></hr-sec>
<hr-card variant="list">
<view v-for="(s, i) in switches" :key="i" class="hr-lrow"
:class="i == switches.length - 1 ? 'hr-lrow-last' : ''">
<view class="hr-lmain">
<text class="hr-lmain-b">{{ s.title }}</text>
<text class="hr-lmain-s">{{ s.sub }}</text>
</view>
<x-switch v-model="s.on" color="#10B981" size="small"></x-switch>
</view>
</hr-card>
<!-- ============ 离线与存储 ============ -->
<hr-sec title="离线与存储"></hr-sec>
<hr-card variant="list">
<hr-lrow v-for="(r, i) in offlineRows" :key="i" :title="r.title" :sub="r.sub" :value="r.value"
:is-last="i == offlineRows.length - 1" @click="onRowClick(i, true)"></hr-lrow>
</hr-card>
<!-- ============ 通用 ============ -->
<hr-sec title="通用"></hr-sec>
<hr-card variant="list">
<hr-lrow v-for="(r, i) in commonRows" :key="i" :title="r.title" :sub="r.sub" :value="r.value"
:is-last="i == commonRows.length - 1" @click="onRowClick(i, false)"></hr-lrow>
</hr-card>
<hr-tip style="margin-top:16px" theme="info" icon=""
text="P0 派单强提醒开启后,收到 P0 派单将全屏告警并强制震动,请保持开启。"></hr-tip>
<view style="height:14px"></view>
</view>
</hr-page>
</template>
<script lang="uts" setup>
import { ref } from 'vue'
import { settingSwitches, offlineSettingRows, commonSettingRows } from '@/mock/index.uts'
import { HrSettingSwitch, HrSettingRow } from '@/mock/types.uts'
import { navTo } from '@/utils/nav.uts'
/** s39 设置 */
const switches = ref<HrSettingSwitch[]>(settingSwitches)
const offlineRows = ref<HrSettingRow[]>(offlineSettingRows)
const commonRows = ref<HrSettingRow[]>(commonSettingRows)
function onRowClick(i : number, isOffline : boolean) : void {
if (isOffline) {
if (i == 0) { navTo('/pages/triage/offline-sync'); return }
uni.showModal({
title: '清理已下载资料',
content: '将删除 8.6 MB 离线学习资料,无网络时需重新下载。',
success: (res) => {
if (res.confirm) { uni.showToast({ title: '已清理 8.6 MB', icon: 'success' }) }
}
})
return
}
if (i == 0) {
uni.showModal({
title: '清理缓存',
content: '当前缓存 24.3 MB,清理后不影响账号数据。',
success: (res) => {
if (res.confirm) { uni.showToast({ title: '已清理 24.3 MB', icon: 'success' }) }
}
})
return
}
if (i == 1) {
uni.showModal({ title: '医院救援管理平台', content: '版本 v2.0.0\n移动端逻辑重构版', showCancel: false })
return
}
uni.showModal({ title: '隐私政策', content: '本平台严格保护伤患与医护人员的个人信息,数据仅用于院前急救调度。', showCancel: false })
}
</script>
+288
View File
@@ -0,0 +1,288 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="学习中心" :xloading="true" @right-click="onSort">
<template v-slot:right>
<x-icon name="arrow-up-down-line" color="#6B7280" font-size="17"></x-icon>
</template>
<view class="hr-body">
<!-- ============ 四大类目(xTabs ============ -->
<x-tabs :list="catTabs" :model-value="activeId" item-width="25%" height="84rpx" font-size="28rpx"
active-font-size="28rpx" title-color="#6B7280" active-title-color="#2563EB" line-color="#2563EB"
title-padding="0" :is-item-center="true" @change="onCatChange"></x-tabs>
<!-- ============ 课题筛选(组件库 xRadioButton 分段切换) ============ -->
<view style="margin-top:10px;margin-bottom:8rpx">
<x-radio-button v-model="chipId" :list="chipTabs" height="64rpx" space="4rpx" round="22rpx"
text-style="font-weight:bold;" bg-color="#E5E7EB" active-color="#2563EB"
active-font-color="#FFFFFF" font-color="#6B7280" font-size="22rpx"></x-radio-button>
</view>
<!-- ============ 题目列表 ============ -->
<view v-if="cat == 0" style="margin-top:10px">
<view v-for="(q, i) in wrongList" :key="q.id" class="qa-card">
<view class="qa-top">
<text class="qa-stem">{{ q.stem }}</text>
<hr-chip :text="wrongTimes(i)" theme="red"></hr-chip>
</view>
<text class="qa-ans">正确答案 <text class="qa-ans-hl">{{ rightAnswer(q) }}</text></text>
<view class="qa-meta">
<text class="qa-meta-t">来自 <text class="qa-meta-hl">{{ q.from }}</text></text>
<text class="qa-meta-t qa-meta-date">{{ wrongDates[i] }}</text>
<view class="hr-flex1"></view>
<view class="qa-fold" @click="toggle(i)">
<x-icon :name="opened[i] ? 'arrow-up-s-line' : 'arrow-down-s-line'" color="#2563EB"
font-size="26rpx"></x-icon>
<text class="qa-fold-t">{{ opened[i] ? '收起解析' : '查看解析' }}</text>
</view>
</view>
<!-- 手风琴展开:展开进度由 accStyle() 逐帧下发 -->
<view v-if="opened[i]" :style="accStyle(i)">
<view class="qa-analysis">
<text class="qa-analysis-t">{{ q.analysis }}</text>
</view>
</view>
</view>
</view>
<!-- 其它类目占位 -->
<hr-empty v-else :icon="emptyIcon" :title="emptyTitle" :sub="emptySub" action="去课程库学习"
@action="goLibrary"></hr-empty>
<view style="height:14px"></view>
</view>
</hr-page>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { wrongQuestions } from '@/mock/index.uts'
import { HrQuestion } from '@/mock/types.uts'
import { navTo } from '@/utils/nav.uts'
import { TABS_ITEM_INFO, TABS_ITEM, RADIO_BUTTON } from '@/uni_modules/tmx-ui/interface.uts'
/**
* s38 学习中心
* 汇总「错题 / 收藏 / 历史 / 笔记」四类目,替代原先散落各处的入口。
* 四类目改用组件库 xTabs(原 lc-tabs 自绘分段控件已移除)。
*/
const catTabs : TABS_ITEM_INFO[] = [
{ title: '错题 12', id: '0' } as TABS_ITEM_INFO,
{ title: '收藏 8', id: '1' } as TABS_ITEM_INFO,
{ title: '历史', id: '2' } as TABS_ITEM_INFO,
{ title: '笔记 5', id: '3' } as TABS_ITEM_INFO
]
/**
* 课题筛选(组件库 xRadioButton 分段切换,替代原 hr-chip 横向滚动)
* ⚠ RADIO_BUTTON 的 id 是 string:统一用下标字符串,选中值即筛选序号。
*/
const chipTabs : RADIO_BUTTON[] = [
{ id: '0', title: '全部' } as RADIO_BUTTON,
{ id: '1', title: '心肺复苏 5' } as RADIO_BUTTON,
{ id: '2', title: '气道管理 4' } as RADIO_BUTTON,
{ id: '3', title: 'AED 3' } as RADIO_BUTTON
]
const chipId = ref('0')
const wrongDates : string[] = ['09-15', '09-14', '09-12']
const activeId = ref('0')
const opened = ref<boolean[]>([false, false, false])
const wrongList = ref<HrQuestion[]>(wrongQuestions)
const cat = computed(() : number => {
const i = parseInt(activeId.value)
return isNaN(i) ? 0 : i
})
// ⚠ x-tabs 的 change 下发的是 TABS_ITEM(不是 TABS_ITEM_INFO),形参类型必须一致
function onCatChange(item : TABS_ITEM, index : number) : void {
activeId.value = item.id == '' ? index.toString() : item.id
}
const emptyIcon = computed(() : string => {
if (cat.value == 1) { return '★' }
if (cat.value == 2) { return '◔' }
return '✎'
})
const emptyTitle = computed(() : string => {
if (cat.value == 1) { return '暂无收藏题目' }
if (cat.value == 2) { return '暂无学习历史' }
return '暂无学习笔记'
})
const emptySub = computed(() : string => {
return '在课题中收藏题目、记录笔记后会自动汇总到这里'
})
function wrongTimes(i : number) : string {
if (i == 0) { return '错 2 次' }
return '错 1 次'
}
function rightAnswer(q : HrQuestion) : string {
if (q.answer < q.options.length) {
const o = q.options[q.answer]
return `${o.key}. ${o.text}`
}
return '—'
}
/*
* 手风琴展开入场动画
* ⚠ App 端 uvue 不支持 @keyframes / animation(编译期直接报 ERROR),
* 改为定时器逐帧推进 accT0 → 1),再由 accStyle() 下发 opacity + translateY。
* 240ms30ms × 8 帧),与设计稿 qa-acc-in 一致。
*/
const accIdx = ref(-1)
const accT = ref(1)
let accTimer : number = 0
function stopAcc() : void {
if (accTimer != 0) {
clearInterval(accTimer)
accTimer = 0
}
}
function runAcc(i : number) : void {
stopAcc()
accIdx.value = i
accT.value = 0
accTimer = setInterval(() => {
const t = accT.value + 0.16
if (t >= 1) {
accT.value = 1
stopAcc()
return
}
accT.value = t
}, 30)
}
/** 仅正在展开的那一项需要过渡样式,其余返回空串(即最终态) */
function accStyle(i : number) : string {
if (i != accIdx.value || accT.value >= 1) { return '' }
const t = accT.value
const y = Math.round(-16 * (1 - t))
return 'opacity:' + t.toString() + ';transform:translateY(' + y.toString() + 'rpx);'
}
/** 手风琴:展开一项时其余自动收起 */
function toggle(i : number) : void {
const wasOpen = opened.value[i]
const arr : boolean[] = []
for (let k = 0; k < wrongList.value.length; k++) { arr.push(false) }
arr[i] = !wasOpen
opened.value = arr
if (wasOpen) {
stopAcc()
accIdx.value = -1
accT.value = 1
} else {
runAcc(i)
}
}
onUnload(() => {
stopAcc()
})
function goLibrary() : void {
navTo('/pages/course/library')
}
function onSort() : void {
uni.showToast({ title: '按时间 / 次数排序', icon: 'none' })
}
</script>
<style>
/* ---------- 题目卡 ---------- */
.qa-card {
background-color: #FFFFFF;
border: 2rpx solid #E5E7EB;
border-left: 8rpx solid #2563EB;
border-radius: 20rpx;
padding: 22rpx 24rpx 22rpx 24rpx;
margin-bottom: 20rpx;
}
.qa-top {
display: flex;
flex-direction: row;
align-items: flex-start;
}
.qa-stem {
flex: 1;
font-size: 29.2rpx;
font-weight: bold;
color: #1F2937;
margin-right: 18rpx;
}
.qa-ans {
font-size: 26.8rpx;
color: #6B7280;
margin-top: 14rpx;
}
.qa-ans-hl {
font-size: 26.8rpx;
color: #059669;
font-weight: bold;
}
.qa-meta {
display: flex;
flex-direction: row;
align-items: center;
margin-top: 18rpx;
}
.qa-meta-t {
font-size: 24.6rpx;
color: #9CA3AF;
}
.qa-meta-hl {
font-size: 24.6rpx;
color: #1F2937;
font-weight: bold;
}
.qa-meta-date {
margin-left: 24rpx;
}
.qa-fold {
display: flex;
flex-direction: row;
align-items: center;
}
.qa-fold-t {
font-size: 25.8rpx;
color: #2563EB;
font-weight: bold;
margin-left: 4rpx;
}
/* ---------- 手风琴展开 ----------
⚠ App 端 uvue 不支持 @keyframes/animation
入场过渡已改为 script 内定时器推进 accT + accStyle() 下发样式。 */
.qa-analysis {
margin-top: 20rpx;
padding: 18rpx 20rpx 18rpx 20rpx;
border-radius: 18rpx;
background-color: #F9FAFB;
}
.qa-analysis-t {
font-size: 25.8rpx;
color: #6B7280;
}
</style>
+81
View File
@@ -0,0 +1,81 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="我的证书" :xloading="true" @right-click="onSort">
<template v-slot:right>
<x-icon name="arrow-up-down-line" color="#6B7280" font-size="17"></x-icon>
</template>
<view class="hr-body">
<hr-card>
<view class="hr-row">
<view class="hr-avt hr-avt-lg hr-avt-grn ct-avt">
<text>▤</text>
</view>
<view class="hr-flex1">
<text class="ct-title">有效证书 {{ certs.length }} 张</text>
<text class="ct-sub">其中 1 张将于 2026-10-15 到期</text>
</view>
</view>
</hr-card>
<hr-sec title="证书列表"></hr-sec>
<hr-card variant="list">
<hr-lrow v-for="(c, i) in certs" :key="i" :icon="c.statusTheme == 'grn' ? '▤' : '!'"
:theme="c.statusTheme == 'grn' ? 'grn' : 'amb'" :title="c.name"
:sub="c.gotAt + ' 取得 · ' + (c.statusTheme == 'grn' ? '有效至 ' + c.expireAt : c.expireAt + ' 到期')"
:value="c.status + ' '" :value-theme="c.statusTheme" :value-bold="true"
:is-last="i == certs.length - 1" @click="onCertClick(c)"></hr-lrow>
</hr-card>
<hr-tip style="margin-top:14px" theme="warn" icon="⚑"
text="BLS 证书将在 22 天后到期,请完成年度复训后自动续期。"></hr-tip>
<view style="height:14px"></view>
</view>
</hr-page>
</template>
<script lang="uts" setup>
import { ref } from 'vue'
import { certList } from '@/mock/index.uts'
import { HrCert } from '@/mock/types.uts'
/** s13 我的证书 */
const certs = ref<HrCert[]>(certList)
function onCertClick(c : HrCert) : void {
uni.showModal({
title: c.name,
content: `取得时间:${c.gotAt}\n有效期至:${c.expireAt}\n状态:${c.status}`,
cancelText: '关闭',
confirmText: '查看证书',
success: (res) => {
if (res.confirm) {
uni.showToast({ title: '已生成证书图片', icon: 'none' })
}
}
})
}
function onSort() : void {
uni.showToast({ title: '按取得时间排序', icon: 'none' })
}
</script>
<style>
.ct-avt {
margin-right: 24rpx;
}
.ct-title {
font-size: 31.4rpx;
font-weight: bold;
color: #1F2937;
}
.ct-sub {
font-size: 25.8rpx;
color: #6B7280;
margin-top: 8rpx;
}
</style>
+185
View File
@@ -0,0 +1,185 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="我的考试" :xloading="true" @right-click="onSort">
<template v-slot:right>
<x-icon name="arrow-up-down-line" color="#6B7280" font-size="17"></x-icon>
</template>
<view class="hr-body">
<view class="hr-seg">
<view v-for="(s, i) in segs" :key="i" :class="seg == i ? 'hr-seg-i hr-seg-i-on' : 'hr-seg-i'"
@click="seg = i">
<text :class="seg == i ? 'ex-seg-t ex-seg-t-on' : 'ex-seg-t'">{{ s }}</text>
</view>
</view>
<!-- ============ 待考(P0 紧急) ============ -->
<view v-if="seg != 2 && plans.length > 0" class="ex-plan">
<view class="hr-row-top">
<view class="ex-plan-ic">
<text class="ex-plan-ic-t">!</text>
</view>
<view class="hr-flex1">
<view class="hr-row-between">
<text class="ex-plan-t">{{ plans[0].name }}</text>
<hr-pc :level="plans[0].priority"></hr-pc>
</view>
<text class="ex-plan-cd">{{ plans[0].countdown }}</text>
<text class="ex-plan-meta">{{ plans[0].time }} · {{ plans[0].place }}</text>
</view>
</view>
<view class="ex-plan-btn" @click="onPrepare">
<text class="ex-plan-btn-t">进入备考</text>
</view>
</view>
<!-- ============ 已完成 ============ -->
<hr-card v-if="seg != 1" variant="list" style="margin-top:10px">
<hr-lrow v-for="(r, i) in shownRecords" :key="i" :icon="r.passed ? '✓' : '✗'"
:theme="r.passed ? 'grn' : 'red'" :title="r.name"
:sub="r.date + ' · 得分 ' + r.score + ' · ' + (r.passed ? '已通过' : '未通过')"
:value="r.score.toString() + ' '" :value-theme="r.passed ? 'grn' : 'red'" :value-bold="true"
:is-last="i == shownRecords.length - 1" @click="onRecordClick(r)"></hr-lrow>
</hr-card>
<hr-empty v-if="seg == 1 && plans.length == 0" icon="✓" title="暂无待考安排"
sub="有新的考试安排时会在消息中心提醒你"></hr-empty>
<hr-tip style="margin-top:14px" theme="info" icon=""
text="理论测验通过后自动计入课题完成度;未通过的测验可在 7 天后重新参加。"></hr-tip>
<view style="height:14px"></view>
</view>
</hr-page>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { examPlans, examRecords } from '@/mock/index.uts'
import { HrExamRecord, HrExamPlan } from '@/mock/types.uts'
import { navTo } from '@/utils/nav.uts'
/** s12 我的考试 */
const segs : string[] = ['全部', '待考', '已完成']
const seg = ref(0)
const plans = ref<HrExamPlan[]>(examPlans)
const records = ref<HrExamRecord[]>(examRecords)
const passOnly = ref(false)
const shownRecords = computed(() : HrExamRecord[] => {
if (passOnly.value) {
const list : HrExamRecord[] = []
for (let i = 0; i < records.value.length; i++) {
if (records.value[i].passed) { list.push(records.value[i]) }
}
return list
}
return records.value
})
function onPrepare() : void {
navTo('/pages/course/practice')
}
function onRecordClick(r : HrExamRecord) : void {
if (r.passed) {
uni.showModal({
title: r.name,
content: `得分 ${r.score} 分 · ${r.date}\n可查看错题解析与答卷详情。`,
cancelText: '关闭',
confirmText: '查看错题',
success: (res) => {
if (res.confirm) { navTo('/pages/study/wrong-book') }
}
})
return
}
uni.showModal({
title: r.name,
content: `得分 ${r.score} 分 · 未通过\n补考开放时间:${r.date} 起 7 天后`,
cancelText: '关闭',
confirmText: '进入复习',
success: (res) => {
if (res.confirm) { navTo('/pages/course/practice') }
}
})
}
function onSort() : void {
uni.showToast({ title: '按时间排序', icon: 'none' })
}
</script>
<style>
.ex-seg-t {
font-size: 29.2rpx;
color: #6B7280;
}
.ex-seg-t-on {
color: #2563EB;
font-weight: bold;
}
.ex-plan {
margin-top: 20rpx;
background-color: #FFF5F5;
border: 2rpx solid #FECACA;
border-radius: 26rpx;
padding: 24rpx 26rpx 24rpx 26rpx;
}
.ex-plan-ic {
width: 68rpx;
height: 68rpx;
border-radius: 20rpx;
background-color: #DC2626;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-right: 20rpx;
}
.ex-plan-ic-t {
font-size: 35.8rpx;
font-weight: bold;
color: #FFFFFF;
}
.ex-plan-t {
font-size: 30.2rpx;
font-weight: bold;
color: #1F2937;
}
.ex-plan-cd {
font-size: 25.8rpx;
color: #DC2626;
font-weight: bold;
margin-top: 12rpx;
}
.ex-plan-meta {
font-size: 24.6rpx;
color: #6B7280;
margin-top: 10rpx;
}
.ex-plan-btn {
margin-top: 24rpx;
height: 88rpx;
border-radius: 24rpx;
background-color: #DC2626;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.ex-plan-btn-t {
font-size: 31.4rpx;
font-weight: bold;
color: #FFFFFF;
}
</style>
+270
View File
@@ -0,0 +1,270 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="错题本" :xloading="true" @right-click="onSort">
<template v-slot:right>
<x-icon name="arrow-up-down-line" color="#6B7280" font-size="17"></x-icon>
</template>
<view class="hr-body">
<!-- 课题筛选(组件库 xRadioButton 分段切换) -->
<view style="margin-bottom:8rpx">
<x-radio-button v-model="chipId" :list="chipTabs" height="64rpx" space="4rpx" round="22rpx"
text-style="font-weight:bold;" bg-color="#E5E7EB" active-color="#2563EB"
active-font-color="#FFFFFF" font-color="#6B7280" font-size="22rpx"></x-radio-button>
</view>
<!-- 错题卡片 -->
<view style="margin-top:10px">
<view v-for="(q, i) in list" :key="q.id" class="qa-card">
<view class="qa-top">
<text class="qa-stem">{{ q.stem }}</text>
<hr-chip :text="wrongTimes[i]" theme="red"></hr-chip>
</view>
<view class="qa-row">
<text class="qa-k">你的答案</text>
<text class="qa-wrong">{{ wrongKeys[i] }}</text>
<text class="qa-k qa-k2">正确答案</text>
<text class="qa-right">{{ rightAnswer(q) }}</text>
</view>
<view class="qa-meta">
<text class="qa-meta-t">来自 <text class="qa-meta-hl">{{ q.from }}</text></text>
<text class="qa-meta-t qa-meta-date">{{ dates[i] }} 答错</text>
<view class="hr-flex1"></view>
<view class="qa-fold" @click="toggle(i)">
<x-icon :name="opened[i] ? 'arrow-up-s-line' : 'arrow-down-s-line'" color="#2563EB"
font-size="26rpx"></x-icon>
<text class="qa-fold-t">{{ opened[i] ? '收起解析' : '查看解析' }}</text>
</view>
</view>
<!-- 手风琴展开:展开进度由 accStyle() 逐帧下发 -->
<view v-if="opened[i]" :style="accStyle(i)">
<view class="qa-analysis">
<text class="qa-analysis-t">{{ q.analysis }}</text>
</view>
</view>
</view>
</view>
<hr-tip theme="info" icon="" text="已复习的错题不会自动移除;连续答对 2 次后可手动移出错题本。"></hr-tip>
<view style="height:14px"></view>
</view>
</hr-page>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { wrongQuestions } from '@/mock/index.uts'
import { HrQuestion } from '@/mock/types.uts'
import { RADIO_BUTTON } from '@/uni_modules/tmx-ui/interface.uts'
/** s16 错题本 */
/**
* 课题筛选(组件库 xRadioButton 分段切换,替代原 hr-chip 横向滚动)
* ⚠ RADIO_BUTTON 的 id 是 string:统一用下标字符串,选中值即筛选序号。
*/
const chipTabs : RADIO_BUTTON[] = [
{ id: '0', title: '全部 12' } as RADIO_BUTTON,
{ id: '1', title: '心肺复苏 5' } as RADIO_BUTTON,
{ id: '2', title: '气道管理 4' } as RADIO_BUTTON,
{ id: '3', title: 'AED 3' } as RADIO_BUTTON
]
const chipId = ref('0')
const wrongTimes : string[] = ['答错 2 次', '答错 1 次', '答错 1 次']
const dates : string[] = ['09-15', '09-14', '09-12']
const wrongKeys : string[] = ['A. 3~4 cm', 'C. 60~80 次', 'D. 每 6 秒 1 次']
const opened = ref<boolean[]>([false, false, false])
const all = ref<HrQuestion[]>(wrongQuestions)
const list = computed(() : HrQuestion[] => {
return all.value
})
/** 选项文案里若已含「A. 」前缀则去掉,避免出现「A. A. xxx」 */
function stripKey(text : string) : string {
const i = text.indexOf('. ')
if (i == 1 || i == 2) { return text.substring(i + 2) }
return text
}
// ⚠ UTS 不提升函数声明:stripKey 必须写在 rightAnswer 之前
function rightAnswer(q : HrQuestion) : string {
if (q.answer < q.options.length) {
const o = q.options[q.answer]
return `${o.key}. ${stripKey(o.text)}`
}
return '—'
}
/*
* 手风琴展开入场动画
* ⚠ App 端 uvue 不支持 @keyframes / animation(编译期直接报 ERROR),
* 改为定时器逐帧推进 accT0 → 1),再由 accStyle() 下发 opacity + translateY。
* 240ms30ms × 8 帧),与设计稿 qa-acc-in 一致。
*/
const accIdx = ref(-1)
const accT = ref(1)
let accTimer : number = 0
function stopAcc() : void {
if (accTimer != 0) {
clearInterval(accTimer)
accTimer = 0
}
}
function runAcc(i : number) : void {
stopAcc()
accIdx.value = i
accT.value = 0
accTimer = setInterval(() => {
const t = accT.value + 0.16
if (t >= 1) {
accT.value = 1
stopAcc()
return
}
accT.value = t
}, 30)
}
/** 仅正在展开的那一项需要过渡样式,其余返回空串(即最终态) */
function accStyle(i : number) : string {
if (i != accIdx.value || accT.value >= 1) { return '' }
const t = accT.value
const y = Math.round(-16 * (1 - t))
return 'opacity:' + t.toString() + ';transform:translateY(' + y.toString() + 'rpx);'
}
/** 手风琴:先全部收起,再切换当前项(同时只展开一条) */
function toggle(i : number) : void {
const wasOpen = opened.value[i]
const arr : boolean[] = []
for (let k = 0; k < list.value.length; k++) { arr.push(false) }
arr[i] = !wasOpen
opened.value = arr
if (wasOpen) {
stopAcc()
accIdx.value = -1
accT.value = 1
} else {
runAcc(i)
}
}
onUnload(() => {
stopAcc()
})
function onSort() : void {
uni.showToast({ title: '按错误次数 / 时间排序', icon: 'none' })
}
</script>
<style>
.qa-card {
background-color: #FFFFFF;
border: 2rpx solid #E5E7EB;
border-left: 8rpx solid #2563EB;
border-radius: 20rpx;
padding: 22rpx 24rpx 22rpx 24rpx;
margin-bottom: 20rpx;
}
.qa-top {
display: flex;
flex-direction: row;
align-items: flex-start;
}
.qa-stem {
flex: 1;
font-size: 29.2rpx;
font-weight: bold;
color: #1F2937;
margin-right: 18rpx;
}
.qa-row {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
margin-top: 14rpx;
}
.qa-k {
font-size: 26.8rpx;
color: #6B7280;
}
.qa-k2 {
margin-left: 12rpx;
}
.qa-wrong {
font-size: 26.8rpx;
color: #DC2626;
font-weight: bold;
margin-left: 6rpx;
}
.qa-right {
font-size: 26.8rpx;
color: #059669;
font-weight: bold;
margin-left: 6rpx;
}
.qa-meta {
display: flex;
flex-direction: row;
align-items: center;
margin-top: 18rpx;
}
.qa-meta-t {
font-size: 24.6rpx;
color: #9CA3AF;
}
.qa-meta-hl {
font-size: 24.6rpx;
color: #1F2937;
font-weight: bold;
}
.qa-meta-date {
margin-left: 24rpx;
}
.qa-fold {
display: flex;
flex-direction: row;
align-items: center;
}
.qa-fold-t {
font-size: 25.8rpx;
color: #2563EB;
font-weight: bold;
margin-left: 4rpx;
}
/* ---------- 手风琴展开 ----------
⚠ App 端 uvue 不支持 @keyframes/animation
入场过渡已改为 script 内定时器推进 accT + accStyle() 下发样式。 */
.qa-analysis {
margin-top: 20rpx;
padding: 18rpx 20rpx 18rpx 20rpx;
border-radius: 18rpx;
background-color: #F9FAFB;
}
.qa-analysis-t {
font-size: 25.8rpx;
color: #6B7280;
}
</style>
+356
View File
@@ -0,0 +1,356 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="批量转运" @right-click="onHistory">
<template v-slot:right>
<text class="op-nav-act">历史</text>
</template>
<view class="op-page">
<!-- ① 顶部:连续扫码 -->
<hr-scan-cam :count="list.length" @scan="onScan" @manual="onManual"></hr-scan-cam>
<!-- ② 中部:伤员记录(唯一滚动区;死亡伤员不参与转运,故未列入) -->
<hr-rec-list :list="list" @remove="onRemove" @clear="onClear"></hr-rec-list>
<!-- ③ 底部:转运表单 + 右下角悬浮提交 -->
<view class="op-form">
<text class="op-lb">接收医院</text>
<view class="op-pick" @click="openHos">
<text class="op-pick-n">{{ hospitalList[hosIdx] }}</text>
<text :class="hospitalAvail[hosIdx] ? 'op-pick-b' : 'op-pick-b op-pick-b-no'">{{ hospitalBed[hosIdx] }}</text>
<x-icon name="arrow-down-s-line" color="#9CA3AF" font-size="14"></x-icon>
</view>
<text class="op-lb op-lb-gap">转运方式</text>
<view class="hr-seg">
<view v-for="(m, i) in modes" :key="i"
:class="i == modeIdx ? 'hr-seg-i hr-seg-i-on' : 'hr-seg-i'" @click="modeIdx = i">
<text :class="i == modeIdx ? 'op-seg-t op-seg-t-on' : 'op-seg-t'">{{ m }}</text>
</view>
</view>
<text class="op-lb op-lb-gap">随车联系人</text>
<view class="op-foot">
<view class="op-foot-col">
<view class="hr-field">
<text class="hr-field-label">姓名</text>
<input class="hr-field-input" type="text" v-model="contactName" placeholder-style="color:#9CA3AF" />
</view>
</view>
<view class="op-foot-col op-foot-col-last">
<view class="hr-field">
<text class="hr-field-label">联系电话</text>
<input class="hr-field-input" type="number" v-model="contactPhone" placeholder-style="color:#9CA3AF" />
</view>
</view>
</view>
<view class="op-send" @click="onSubmit">
<x-icon name="check-line" color="#FFFFFF" font-size="24"></x-icon>
</view>
</view>
</view>
<!-- 接收医院选择:hr-sheet 底部弹层(点击上方「接收医院」整行唤起) -->
<template v-slot:overlay>
<view v-if="hosSheet" class="hr-mask" @click="closeHos">
<view class="hr-sheet" @click="stop">
<view class="hr-sheet-h">
<text class="hr-sheet-h-b">选择接收医院</text>
<view class="hr-sheet-h-x" @click="closeHos">
<x-icon name="close-line" color="#9CA3AF" font-size="18"></x-icon>
</view>
</view>
<view v-for="(h, i) in hospitalList" :key="i" :class="hosOptClass(i)" @click="pickHospital(i)">
<view class="os-txt">
<text :class="hosNameClass(i)">{{ h }}</text>
<text class="os-meta">{{ hospitalMeta[i] }}</text>
</view>
<text :class="hospitalAvail[i] ? 'op-pick-b' : 'op-pick-b op-pick-b-no'">{{ hospitalBed[i] }}</text>
<x-icon v-if="i == hosIdx" name="check-line" color="#2563EB" font-size="17"></x-icon>
</view>
</view>
</view>
</template>
</hr-page>
</template>
<script lang="uts" setup>
import { ref } from 'vue'
import { scanVictims, hospitalList, hospitalMeta, hospitalBed, hospitalAvail } from '@/mock/index.uts'
import { HrVictim } from '@/mock/types.uts'
import { navTo } from '@/utils/nav.uts'
/**
* s44 批量转运(扫腕带 · 三段式作业页)
* 与批量处置同构:顶部相机(点击开启)/ 中部记录列表 / 底部表单;
* 底部换成 接收医院(hr-sheet 弹层选择)/ 转运方式 / 随车联系人。
*/
const modes : string[] = ['救护车 🚑', '直升机 🚁']
const modeIdx = ref(0)
const hosIdx = ref(0)
const hosSheet = ref(false)
const contactName = ref('刘伟')
const contactPhone = ref('13800006621')
const list = ref<HrVictim[]>([
scanVictims[0], scanVictims[1], scanVictims[2], scanVictims[3]
])
/* ⚠ UTS 不提升函数声明,被调用的函数一律写在调用点之前 */
/** 把新伤员追加进列表(uvue 原地改数组不刷新,必须重建数组) */
function push(v : HrVictim) : void {
const arr : HrVictim[] = []
for (let i = 0; i < list.value.length; i++) { arr.push(list.value[i]) }
arr.push(v)
list.value = arr
}
function addByCode(code : string) : void {
for (let i = 0; i < list.value.length; i++) {
if (list.value[i].code == code) {
uni.showToast({ title: '该伤员已在列表中', icon: 'none' })
return
}
}
for (let i = 0; i < scanVictims.length; i++) {
const v = scanVictims[i]
if (v.code == code) {
if (v.level == 4) {
uni.showToast({ title: '死亡伤员不参与转运', icon: 'none' })
return
}
push(v)
uni.showToast({ title: '已加入 ' + v.name, icon: 'success' })
return
}
}
push({
id: 'tmp_' + code, name: '未登记伤员', gender: '未知', age: 0, code: code, level: 3,
injury: '待现场检伤补录', status: '', statusTheme: '', createdAt: ''
} as HrVictim)
uni.showToast({ title: '未登记腕带,已临时加入', icon: 'none' })
}
function onScan(code : string) : void {
addByCode(code)
}
function onRemove(i : number) : void {
const arr : HrVictim[] = []
for (let k = 0; k < list.value.length; k++) {
if (k != i) { arr.push(list.value[k]) }
}
list.value = arr
}
function onClear() : void {
uni.showModal({
title: '清空列表',
content: '将移除当前已识别的全部伤员记录,确认清空?',
success: (res) => {
if (res.confirm) { list.value = [] as HrVictim[] }
}
})
}
function onManual() : void {
uni.showModal({
title: '手动输入腕带编号',
editable: true,
placeholderText: '如 TRIAGE-20260922-0006',
success: (res) => {
if (!res.confirm) { return }
const code = res.content
if (code == null || code == '') { return }
addByCode(code as string)
}
})
}
/* ---------- 接收医院:hr-sheet 底部弹层 ---------- */
function openHos() : void {
hosSheet.value = true
}
function closeHos() : void {
hosSheet.value = false
}
/** 点击弹层面板本身时阻止冒泡,避免误关闭 */
function stop() : void {
}
function hosOptClass(i : number) : string {
if (i == hospitalList.length - 1) { return 'hr-sheet-opt hr-sheet-opt-last' }
return 'hr-sheet-opt'
}
/** 弹层选项:医院名(两行式第一行,选中蓝 / 满员置灰) */
function hosNameClass(i : number) : string {
if (!hospitalAvail[i]) { return 'os-name os-name-dim' }
if (i == hosIdx.value) { return 'os-name os-name-on' }
return 'os-name'
}
function pickHospital(i : number) : void {
if (!hospitalAvail[i]) {
uni.showToast({ title: '该医院急诊已满员,不可选择', icon: 'none' })
return
}
hosIdx.value = i
hosSheet.value = false
}
function onSubmit() : void {
if (list.value.length == 0) {
uni.showToast({ title: '请先扫码添加伤员', icon: 'none' })
return
}
if (contactName.value == '' || contactPhone.value == '') {
uni.showToast({ title: '请填写随车联系人', icon: 'none' })
return
}
uni.showLoading({ title: '提交中' })
setTimeout(() => {
uni.hideLoading()
uni.showToast({ title: '已转运登记 ' + list.value.length + ' 人', icon: 'success' })
setTimeout(() => {
navTo('/pages/triage/transfer')
}, 700)
}, 700)
}
function onHistory() : void {
navTo('/pages/triage/treat-records')
}
</script>
<style>
.op-page {
flex: 1;
flex-direction: column;
}
.op-nav-act {
font-size: 29.2rpx;
color: #6B7280;
}
.op-form {
background-color: #FFFFFF;
border-top: 2rpx solid #E5E7EB;
border-radius: 32rpx 32rpx 0rpx 0rpx;
padding: 22rpx 32rpx 28rpx 32rpx;
position: relative;
}
.op-lb {
font-size: 25.8rpx;
font-weight: bold;
color: #6B7280;
margin-bottom: 12rpx;
}
.op-lb-gap {
margin-top: 18rpx;
}
/* 接收医院:折叠字段(点击整行唤起 hr-sheet 弹层) */
.op-pick {
display: flex;
flex-direction: row;
align-items: center;
background-color: #F9FAFB;
border: 2rpx solid #E5E7EB;
border-radius: 20rpx;
padding: 20rpx 24rpx 20rpx 24rpx;
}
.op-pick-n {
flex: 1;
font-size: 28rpx;
font-weight: bold;
color: #1F2937;
}
.op-pick-b {
font-size: 22.4rpx;
font-weight: bold;
color: #059669;
margin-right: 12rpx;
}
.op-pick-b-no {
color: #DC2626;
}
/* 弹层选项:左侧「医院名 + 距离/预计时间」两行 */
.os-txt {
flex: 1;
display: flex;
flex-direction: column;
}
.os-name {
font-size: 30rpx;
font-weight: bold;
color: #1F2937;
}
.os-name-on {
color: #2563EB;
}
.os-name-dim {
color: #6B7280;
}
.os-meta {
font-size: 22.4rpx;
color: #9CA3AF;
margin-top: 6rpx;
}
.op-seg-t {
font-size: 29.2rpx;
color: #6B7280;
}
.op-seg-t-on {
color: #2563EB;
font-weight: bold;
}
.op-foot {
display: flex;
flex-direction: row;
padding-right: 140rpx;
}
.op-foot-col {
flex: 1;
margin-right: 16rpx;
}
.op-foot-col-last {
margin-right: 0rpx;
}
.op-send {
position: absolute;
right: 32rpx;
bottom: 32rpx;
width: 112rpx;
height: 112rpx;
border-radius: 56rpx;
background-image: linear-gradient(to bottom right, #2563EB, #1D4ED8);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
box-shadow: 0 12rpx 36rpx rgba(37, 99, 235, 0.42);
}
</style>
+247
View File
@@ -0,0 +1,247 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="批量处置" @right-click="onHistory">
<template v-slot:right>
<text class="op-nav-act">历史</text>
</template>
<view class="op-page">
<!-- ① 顶部:连续扫码 -->
<hr-scan-cam :count="list.length" @scan="onScan" @manual="onManual"></hr-scan-cam>
<!-- ② 中部:伤员记录(唯一滚动区) -->
<hr-rec-list :list="list" @remove="onRemove" @clear="onClear"></hr-rec-list>
<!-- ③ 底部:处置表单 + 右下角悬浮提交 -->
<view class="op-form">
<text class="op-lb">处置类型</text>
<view class="op-chips">
<view v-for="(t, i) in types" :key="i" :class="i == typeIdx ? 'op-chip op-chip-on' : 'op-chip'"
@click="typeIdx = i">
<text :class="i == typeIdx ? 'op-chip-t op-chip-t-on' : 'op-chip-t'">{{ t }}</text>
</view>
</view>
<text class="op-lb op-lb-gap">处置内容</text>
<textarea class="op-ta" v-model="content" placeholder="记录处置措施、用药与伤情变化…"
placeholder-style="color:#9CA3AF" />
<text class="op-note">提交后同时写入 {{ list.length }} 名伤员的处置记录</text>
<view class="op-send" @click="onSubmit">
<x-icon name="check-line" color="#FFFFFF" font-size="24"></x-icon>
</view>
</view>
</view>
</hr-page>
</template>
<script lang="uts" setup>
import { ref } from 'vue'
import { scanVictims, treatTypes } from '@/mock/index.uts'
import { HrVictim } from '@/mock/types.uts'
import { navTo } from '@/utils/nav.uts'
/**
* s43 批量处置(扫腕带 · 三段式作业页)
* 顶部相机(点击取景区才开启)→ 中部伤员记录滚动列表 → 底部处置表单 + 右下角悬浮提交。
*/
const types : string[] = treatTypes
const typeIdx = ref(0)
const content = ref('现场统一止血包扎,右前臂夹板固定,建立静脉通路并给氧。')
const list = ref<HrVictim[]>([
scanVictims[0], scanVictims[1], scanVictims[2], scanVictims[3], scanVictims[4]
])
/* ⚠ UTS 不提升函数声明,被调用的函数一律写在调用点之前 */
/** 把新伤员追加进列表(uvue 原地改数组不刷新,必须重建数组) */
function push(v : HrVictim) : void {
const arr : HrVictim[] = []
for (let i = 0; i < list.value.length; i++) { arr.push(list.value[i]) }
arr.push(v)
list.value = arr
}
function addByCode(code : string) : void {
for (let i = 0; i < list.value.length; i++) {
if (list.value[i].code == code) {
uni.showToast({ title: '该伤员已在列表中', icon: 'none' })
return
}
}
for (let i = 0; i < scanVictims.length; i++) {
if (scanVictims[i].code == code) {
push(scanVictims[i])
uni.showToast({ title: '已加入 ' + scanVictims[i].name, icon: 'success' })
return
}
}
// 未登记腕带:兜底创建临时记录
push({
id: 'tmp_' + code, name: '未登记伤员', gender: '未知', age: 0, code: code, level: 3,
injury: '待现场检伤补录', status: '', statusTheme: '', createdAt: ''
} as HrVictim)
uni.showToast({ title: '未登记腕带,已临时加入', icon: 'none' })
}
/** 扫码结果处理:按腕带编码匹配伤员并加入列表 */
function onScan(code : string) : void {
addByCode(code)
}
function onRemove(i : number) : void {
const arr : HrVictim[] = []
for (let k = 0; k < list.value.length; k++) {
if (k != i) { arr.push(list.value[k]) }
}
list.value = arr
}
function onClear() : void {
uni.showModal({
title: '清空列表',
content: '将移除当前已识别的全部伤员记录,确认清空?',
success: (res) => {
if (res.confirm) { list.value = [] as HrVictim[] }
}
})
}
function onManual() : void {
uni.showModal({
title: '手动输入腕带编号',
editable: true,
placeholderText: '如 TRIAGE-20260922-0006',
success: (res) => {
if (!res.confirm) { return }
const code = res.content
if (code == null || code == '') { return }
addByCode(code as string)
}
})
}
function onSubmit() : void {
if (list.value.length == 0) {
uni.showToast({ title: '请先扫码添加伤员', icon: 'none' })
return
}
if (content.value == '') {
uni.showToast({ title: '请填写处置内容', icon: 'none' })
return
}
uni.showLoading({ title: '提交中' })
setTimeout(() => {
uni.hideLoading()
uni.showToast({ title: '已写入 ' + list.value.length + ' 名伤员', icon: 'success' })
setTimeout(() => {
navTo('/pages/triage/treat-records')
}, 700)
}, 700)
}
function onHistory() : void {
navTo('/pages/triage/treat-records')
}
</script>
<style>
.op-page {
flex: 1;
flex-direction: column;
}
.op-nav-act {
font-size: 29.2rpx;
color: #6B7280;
}
/* ---------- 底部表单 ---------- */
.op-form {
background-color: #FFFFFF;
border-top: 2rpx solid #E5E7EB;
border-radius: 32rpx 32rpx 0rpx 0rpx;
padding: 22rpx 32rpx 28rpx 32rpx;
position: relative;
}
.op-lb {
font-size: 25.8rpx;
font-weight: bold;
color: #6B7280;
margin-bottom: 12rpx;
}
.op-lb-gap {
margin-top: 18rpx;
}
.op-chips {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.op-chip {
height: 80rpx;
border-radius: 20rpx;
padding: 0rpx 26rpx 0rpx 26rpx;
background-color: #FFFFFF;
border: 2rpx solid #E5E7EB;
display: flex;
flex-direction: row;
align-items: center;
margin-right: 14rpx;
margin-bottom: 14rpx;
}
.op-chip-on {
background-color: #2563EB;
border: 2rpx solid #2563EB;
}
.op-chip-t {
font-size: 26.8rpx;
font-weight: bold;
color: #6B7280;
}
.op-chip-t-on {
color: #FFFFFF;
}
.op-ta {
width: 100%;
background-color: #F9FAFB;
border: 2rpx solid #E5E7EB;
border-radius: 22rpx;
padding: 20rpx 20rpx 20rpx 20rpx;
font-size: 28rpx;
color: #1F2937;
height: 192rpx;
}
.op-note {
font-size: 23.6rpx;
color: #9CA3AF;
text-align: right;
margin-top: 14rpx;
padding-right: 140rpx;
}
.op-send {
position: absolute;
right: 32rpx;
bottom: 32rpx;
width: 112rpx;
height: 112rpx;
border-radius: 56rpx;
background-image: linear-gradient(to bottom right, #2563EB, #1D4ED8);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
box-shadow: 0 12rpx 36rpx rgba(37, 99, 235, 0.42);
}
</style>
File diff suppressed because it is too large Load Diff
+693
View File
@@ -0,0 +1,693 @@
<template>
<view class="hr-page" style="background-color:#F3F4F6">
<hr-statusbar bg="#FFFFFF"></hr-statusbar>
<!-- ============ 顶部伤情头信息 ============ -->
<view class="dh">
<view class="dh-c1"></view>
<view class="dh-top">
<view class="dh-back" @click="onBack">
<x-icon name="arrow-left-s-line" color="#FFFFFF" font-size="20"></x-icon>
</view>
<text class="dh-title">伤员详情</text>
<view class="hr-flex1"></view>
<text class="dh-state">⌖ 转运中</text>
</view>
<text class="dh-w">{{ victim.code }}</text>
<view class="dh-n">
<text class="dh-name">{{ victim.name }}</text>
<text class="dh-age">{{ victim.gender }} · {{ victim.age }} 岁</text>
<view class="hr-flex1"></view>
<view class="dh-level">
<text class="dh-level-t">{{ levelLabel }}</text>
</view>
</view>
<view class="dh-ch">
<view class="dh-chip">
<text class="dh-chip-t">⚑ 事件 EVT-20260922-014</text>
</view>
<view class="dh-chip">
<text class="dh-chip-t">⌖ 转运中</text>
</view>
</view>
<text class="dh-foot">建档 14:23 · 操作人 张明 · 现场检伤组 3 组</text>
</view>
<!-- ============ 快捷操作 ============ -->
<view class="dh-acts">
<view v-for="(a, i) in acts" :key="i" :class="i == 0 ? 'dha' : 'dha dha-sep'" @click="onAct(i)">
<view class="dha-ic" :style="{ backgroundColor: a.bg }">
<x-icon :name="a.icon" :color="a.color" font-size="32rpx"></x-icon>
</view>
<text class="dha-t" :style="{ color: a.color }">{{ a.name }}</text>
</view>
</view>
<!-- ============ 分页 Tab(基本信息 / 伤情 / 转运 / 处置) ============ -->
<x-tabs :list="tabList" :model-value="activeId" item-width="25%" height="84rpx" font-size="27rpx"
active-font-size="27rpx" title-color="#6B7280" active-title-color="#2563EB" line-color="#2563EB"
title-padding="0" :is-item-center="true" @change="onTabChange"></x-tabs>
<scroll-view class="hr-scroll" :scroll-y="true" :show-scrollbar="false">
<view class="hr-body">
<!-- ---------- 基本信息 ---------- -->
<view v-if="tab == 0">
<hr-sec title="基本信息" more="编辑 " :first="true"></hr-sec>
<view class="ig">
<view class="ig-row">
<view class="ig-it">
<text class="ig-l">身份证号</text>
<text class="ig-b">4406041991••••1234</text>
</view>
<view class="ig-it ig-it-r">
<text class="ig-l">联系电话</text>
<text class="ig-b">138••••6621</text>
</view>
</view>
<view class="ig-row">
<view class="ig-it">
<text class="ig-l">送诊时间</text>
<text class="ig-b">2026-09-22 14:35</text>
</view>
<view class="ig-it ig-it-r">
<text class="ig-l">事件来源</text>
<text class="ig-b">建筑坍塌 · 现场检伤</text>
</view>
</view>
<view class="ig-row ig-row-last">
<view class="ig-it">
<text class="ig-l">检伤分级</text>
<text class="ig-b" style="color:#B45309">{{ levelLabel }}</text>
</view>
<view class="ig-it ig-it-r">
<text class="ig-l">检伤状态</text>
<text class="ig-b">已检伤 · 转运中</text>
</view>
</view>
</view>
<hr-sec title="生命体征(最近一次 14:35" more="趋势 "></hr-sec>
<hr-card>
<view class="vs5">
<view v-for="(v, i) in vitals" :key="i"
:class="v.abnormal ? 'vc vc-warn' : 'vc'">
<text :class="v.abnormal ? 'vc-b vc-b-warn' : 'vc-b'">{{ v.value }}</text>
<text :class="v.abnormal ? 'vc-s vc-s-warn' : 'vc-s'">{{ v.unit }}</text>
</view>
</view>
<view class="dh-warn">
<x-icon name="alert-line" color="#DC2626" font-size="14"></x-icon>
<text class="dh-warn-t">脉搏偏快 · 血压偏低 · 血氧 88% 低于阈值,建议优先处置</text>
</view>
</hr-card>
</view>
<!-- ---------- 伤情 ---------- -->
<view v-if="tab == 1">
<hr-sec title="伤情标记" more="查看人体图 " :first="true"></hr-sec>
<hr-card>
<view class="dh-mks">
<view v-for="(m, i) in marks" :key="i" class="dh-mk">
<view class="dh-mk-no" :style="{ backgroundColor: m.theme == 'red' ? '#DC2626' : '#F59E0B' }">
<text class="dh-mk-no-t">{{ m.no }}</text>
</view>
<view class="hr-flex1">
<text class="dh-mk-t">{{ m.title }}</text>
<text class="dh-mk-s">{{ m.sub }}</text>
</view>
<text class="dh-mk-time">14:2{{ i + 3 }}</text>
</view>
</view>
</hr-card>
<hr-sec title="多模态记录" more=" 添加 "></hr-sec>
<hr-card>
<text class="dh-desc">{{ victim.injury }}。14:23 于坍塌现场 B 区发现,俯卧位,呼之可睁眼。移除压覆物后前臂出现活动性出血,已加压包扎。</text>
<view class="dh-upls">
<view class="dh-upl">
<x-icon name="image-line" color="#9CA3AF" font-size="19"></x-icon>
<text class="dh-upl-t">现场照片</text>
<view class="dh-upl-tag">
<text class="dh-upl-tag-t">图片 · 1.2 MB</text>
</view>
</view>
<view class="dh-upl">
<x-icon name="music-2-line" color="#9CA3AF" font-size="19"></x-icon>
<text class="dh-upl-t">现场录音</text>
<view class="dh-upl-tag">
<text class="dh-upl-tag-t">音频 · 0:42</text>
</view>
</view>
<view class="dh-upl">
<x-icon name="video-line" color="#9CA3AF" font-size="19"></x-icon>
<text class="dh-upl-t">处置视频</text>
<view class="dh-upl-tag">
<text class="dh-upl-tag-t">视频 · 0:18</text>
</view>
</view>
</view>
</hr-card>
</view>
<!-- ---------- 转运 ---------- -->
<view v-if="tab == 2">
<hr-sec title="转运信息" more="重新登记 " :first="true" @more-click="goTransfer"></hr-sec>
<view class="ig">
<view class="ig-row">
<view class="ig-it">
<text class="ig-l">接收医院</text>
<text class="ig-b">协和医院 · 急诊创伤中心</text>
</view>
<view class="ig-it ig-it-r">
<text class="ig-l">转运方式</text>
<text class="ig-b">救护车 🚑</text>
</view>
</view>
<view class="ig-row ig-row-last">
<view class="ig-it">
<text class="ig-l">转出方</text>
<text class="ig-b">张明(急救中心)</text>
</view>
<view class="ig-it ig-it-r">
<text class="ig-l">接收方</text>
<text class="ig-b">刘伟(急诊创伤中心)</text>
</view>
</view>
</view>
<hr-sec title="流转时间线" more="全部 4 条"></hr-sec>
<hr-card>
<hr-timeline :nodes="timeline"></hr-timeline>
</hr-card>
</view>
<!-- ---------- 处置 ---------- -->
<view v-if="tab == 3">
<hr-sec title="处置记录" more="全部记录 " :first="true" @more-click="goRecords"></hr-sec>
<hr-card>
<hr-timeline :nodes="treatNodes"></hr-timeline>
</hr-card>
<view class="hr-btn hr-btn-soft" style="margin-top:12px" @click="goRecords">
<x-icon name="add-line" color="#2563EB" font-size="16"></x-icon>
<text class="dh-add-t">新增处置记录</text>
</view>
</view>
<view style="height:14px"></view>
</view>
</scroll-view>
</view>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { victimList, vitalSigns, injuryMarks, victimTimeline, treatRecords } from '@/mock/index.uts'
import { HrVictim, HrVital, HrInjuryMark, HrTimelineNode } from '@/mock/types.uts'
import { navTo, navBack } from '@/utils/nav.uts'
import { TABS_ITEM_INFO, TABS_ITEM } from '@/uni_modules/tmx-ui/interface.uts'
/**
* s21 伤员详情
* 顶部伤情头信息 + 3 个快捷操作 + 4 个分页(基本信息 / 伤情 / 转运 / 处置,xTabs 驱动)。
*/
type QuickAct = { icon : string, name : string, color : string, bg : string }
const tabList : TABS_ITEM_INFO[] = [
{ title: '基本信息', id: '0' } as TABS_ITEM_INFO,
{ title: '伤情', id: '1' } as TABS_ITEM_INFO,
{ title: '转运', id: '2' } as TABS_ITEM_INFO,
{ title: '处置', id: '3' } as TABS_ITEM_INFO
]
const activeId = ref('0')
const tab = computed(() : number => {
const i = parseInt(activeId.value)
return isNaN(i) ? 0 : i
})
const victim = ref<HrVictim>(victimList[1])
const vitals = ref<HrVital[]>(vitalSigns)
const marks = ref<HrInjuryMark[]>(injuryMarks)
const timeline = ref<HrTimelineNode[]>(victimTimeline)
const treatNodes = ref<HrTimelineNode[]>(treatRecords)
const acts : QuickAct[] = [
{ icon: 'arrow-left-right-line', name: '调整分级', color: '#B45309', bg: '#FFFBEB' } as QuickAct,
{ icon: 'route-line', name: '转运登记', color: '#2563EB', bg: '#EFF6FF' } as QuickAct,
{ icon: 'phone-line', name: '联系家属', color: '#6B7280', bg: '#F3F4F6' } as QuickAct
]
// ⚠ x-tabs 的 change 下发的是 TABS_ITEM(不是 TABS_ITEM_INFO),形参类型必须一致
function onTabChange(item : TABS_ITEM, index : number) : void {
activeId.value = item.id == '' ? index.toString() : item.id
}
const levelLabel = computed(() : string => {
const l = victim.value.level
if (l == 1) { return '危重' }
if (l == 2) { return '重伤' }
if (l == 3) { return '轻伤' }
return '死亡'
})
onLoad((opt) => {
const raw = opt['id']
if (raw != null) {
const id = raw as string
for (let i = 0; i < victimList.length; i++) {
if (victimList[i].id == id) {
victim.value = victimList[i]
break
}
}
}
})
function onBack() : void {
navBack()
}
// ⚠ UTS 不提升函数声明:goTransfer 必须写在调用它的 onAct 之前
function goTransfer() : void {
navTo('/pages/triage/transfer')
}
function onAct(i : number) : void {
if (i == 0) {
uni.showModal({
title: '调整检伤分级',
content: '当前分级:' + levelLabel.value + '\n调整后需现场检伤组复核。',
cancelText: '取消',
confirmText: '去调整',
success: (res) => {
if (res.confirm) { navTo('/pages/triage/create') }
}
})
return
}
if (i == 1) { goTransfer(); return }
uni.showToast({ title: '正在呼叫登记的联系电话', icon: 'none' })
}
function goRecords() : void {
navTo('/pages/triage/treat-records')
}
</script>
<style>
.dh {
background-image: linear-gradient(to bottom right, #F59E0B, #D97706);
padding: 0rpx 28rpx 32rpx 28rpx;
position: relative;
overflow: hidden;
}
.dh-c1 {
position: absolute;
right: -80rpx;
top: -100rpx;
width: 300rpx;
height: 300rpx;
border-radius: 150rpx;
background-color: rgba(255, 255, 255, 0.10);
}
.dh-top {
display: flex;
flex-direction: row;
align-items: center;
padding-top: 12rpx;
}
.dh-back {
width: 60rpx;
height: 60rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.dh-title {
font-size: 31.4rpx;
font-weight: bold;
color: #FFFFFF;
margin-left: 4rpx;
}
.dh-state {
font-size: 26.8rpx;
color: rgba(255, 255, 255, 0.92);
}
.dh-w {
font-size: 35.8rpx;
font-weight: bold;
color: #FFFFFF;
margin-top: 24rpx;
letter-spacing: 0.8rpx;
}
.dh-n {
display: flex;
flex-direction: row;
align-items: center;
margin-top: 16rpx;
}
.dh-name {
font-size: 33.6rpx;
font-weight: bold;
color: #FFFFFF;
margin-right: 16rpx;
}
.dh-age {
font-size: 29.2rpx;
color: rgba(255, 255, 255, 0.9);
}
.dh-level {
background-color: #FFFFFF;
border-radius: 40rpx;
padding: 4rpx 20rpx 4rpx 20rpx;
}
.dh-level-t {
font-size: 24.6rpx;
font-weight: bold;
color: #B45309;
}
.dh-ch {
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin-top: 18rpx;
}
.dh-chip {
background-color: rgba(255, 255, 255, 0.22);
border: 2rpx solid rgba(255, 255, 255, 0.38);
border-radius: 40rpx;
padding: 4rpx 18rpx 4rpx 18rpx;
margin-right: 12rpx;
margin-bottom: 8rpx;
}
.dh-chip-t {
font-size: 23.6rpx;
font-weight: bold;
color: #FFFFFF;
}
.dh-foot {
font-size: 24.6rpx;
color: rgba(255, 255, 255, 0.9);
margin-top: 12rpx;
}
/* ---------- 快捷操作 ---------- */
.dh-acts {
display: flex;
flex-direction: row;
background-color: #FFFFFF;
border-bottom: 2rpx solid #E5E7EB;
}
.dha {
flex: 1;
flex-direction: column;
align-items: center;
padding: 22rpx 8rpx 20rpx 8rpx;
}
.dha-sep {
border-left: 2rpx solid #E5E7EB;
}
.dha-ic {
width: 68rpx;
height: 68rpx;
border-radius: 22rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-bottom: 10rpx;
}
.dha-ic-t {
font-size: 35.8rpx;
}
.dha-t {
font-size: 25.8rpx;
font-weight: bold;
}
/* ---------- 分页 Tab ---------- */
.tabs2 {
display: flex;
flex-direction: row;
background-color: #FFFFFF;
border-bottom: 2rpx solid #E5E7EB;
padding: 0rpx 8rpx 0rpx 8rpx;
}
.tabs2-tb {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding-top: 20rpx;
padding-bottom: 18rpx;
position: relative;
}
.tabs2-t {
font-size: 28rpx;
color: #6B7280;
}
.tabs2-t-on {
color: #2563EB;
font-weight: bold;
}
.tabs2-bar {
position: absolute;
bottom: 0rpx;
width: 52rpx;
height: 5rpx;
border-radius: 4rpx;
background-color: #2563EB;
}
/* ---------- 信息栅格 ---------- */
.ig {
border: 2rpx solid #E5E7EB;
border-radius: 24rpx;
overflow: hidden;
}
.ig-row {
display: flex;
flex-direction: row;
border-bottom: 2rpx solid #E5E7EB;
}
.ig-row-last {
border-bottom-width: 0rpx;
}
.ig-it {
flex: 1;
background-color: #FFFFFF;
padding: 18rpx 20rpx 18rpx 20rpx;
}
.ig-it-r {
border-left: 2rpx solid #E5E7EB;
}
.ig-l {
font-size: 23.6rpx;
color: #9CA3AF;
}
.ig-b {
font-size: 28rpx;
font-weight: bold;
color: #1F2937;
margin-top: 6rpx;
}
/* ---------- 生命体征 ---------- */
.vs5 {
display: flex;
flex-direction: row;
}
.vc {
flex: 1;
background-color: #F9FAFB;
border: 2rpx solid #E5E7EB;
border-radius: 18rpx;
padding: 16rpx 2rpx 16rpx 2rpx;
align-items: center;
margin-right: 12rpx;
}
.vc-warn {
background-color: #FEF2F2;
border: 2rpx solid #FECACA;
}
.vc-b {
font-size: 30.2rpx;
font-weight: bold;
color: #1F2937;
}
.vc-b-warn {
color: #DC2626;
}
.vc-s {
font-size: 20.2rpx;
color: #9CA3AF;
margin-top: 6rpx;
}
.vc-s-warn {
color: #B91C1C;
font-weight: bold;
}
.dh-warn {
display: flex;
flex-direction: row;
align-items: flex-start;
margin-top: 20rpx;
}
.dh-warn-t {
flex: 1;
font-size: 24.6rpx;
color: #DC2626;
margin-left: 12rpx;
line-height: 1.5;
}
/* ---------- 伤情标记 ---------- */
.dh-mks {
flex-direction: column;
}
.dh-mk {
display: flex;
flex-direction: row;
align-items: center;
background-color: #F9FAFB;
border: 2rpx solid #E5E7EB;
border-radius: 22rpx;
padding: 20rpx 24rpx 20rpx 24rpx;
margin-bottom: 16rpx;
}
.dh-mk-no {
width: 44rpx;
height: 44rpx;
border-radius: 22rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-right: 20rpx;
}
.dh-mk-no-t {
font-size: 24.6rpx;
font-weight: bold;
color: #FFFFFF;
}
.dh-mk-t {
font-size: 28rpx;
font-weight: bold;
color: #1F2937;
}
.dh-mk-s {
font-size: 23.6rpx;
color: #9CA3AF;
margin-top: 4rpx;
}
.dh-mk-time {
font-size: 23.6rpx;
color: #9CA3AF;
}
/* ---------- 多模态 ---------- */
.dh-desc {
font-size: 26.8rpx;
color: #6B7280;
line-height: 1.65;
}
.dh-upls {
display: flex;
flex-direction: row;
margin-top: 20rpx;
}
.dh-upl {
width: 31%;
height: 168rpx;
border-radius: 22rpx;
background-color: #F9FAFB;
border: 2rpx solid #E5E7EB;
flex-direction: column;
align-items: center;
justify-content: center;
margin-right: 2.5%;
position: relative;
overflow: hidden;
}
.dh-upl-t {
font-size: 23.6rpx;
color: #9CA3AF;
margin-top: 8rpx;
}
.dh-upl-tag {
position: absolute;
left: 0rpx;
right: 0rpx;
bottom: 0rpx;
background-color: rgba(17, 24, 39, 0.62);
padding: 4rpx 0rpx 4rpx 0rpx;
}
.dh-upl-tag-t {
font-size: 21.2rpx;
color: #FFFFFF;
text-align: center;
}
.dh-add-t {
font-size: 31.4rpx;
font-weight: bold;
color: #2563EB;
margin-left: 12rpx;
}
</style>
+138
View File
@@ -0,0 +1,138 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="离线同步" :xloading="true" @right-click="onSort">
<template v-slot:right>
<x-icon name="arrow-up-down-line" color="#6B7280" font-size="17"></x-icon>
</template>
<view class="hr-body">
<view class="os-box">
<x-icon name="cloud-off-line" color="#92400E" font-size="15"></x-icon>
<text class="os-box-t">当前网络不可用,以下记录已暂存本机</text>
<view class="hr-offbar-cnt">
<text class="os-box-c">{{ pendingCount }}</text>
</view>
</view>
<hr-sec title="待上传队列" :more="'共 ' + tasks.length + ' 条'"></hr-sec>
<hr-card variant="list">
<hr-lrow v-for="(t, i) in tasks" :key="i" :icon="t.icon" :theme="t.theme" :title="t.title"
:sub="t.sub" :value="t.status" :value-bold="true" :is-last="i == tasks.length - 1"
@click="onTaskClick(t)"></hr-lrow>
</hr-card>
<hr-tip style="margin-top:14px" theme="info" icon=""
text="提交失败的记录会自动进入本队列;网络恢复后按时间顺序自动重传,无需手动操作。"></hr-tip>
<view v-if="syncing" class="os-progress">
<view class="hr-row-between" style="margin-bottom:9px">
<text class="os-p-t">正在重传…</text>
<text class="os-p-v">{{ progress }}%</text>
</view>
<hr-progress :percent="progress"></hr-progress>
</view>
<view style="height:14px"></view>
</view>
<template v-slot:footer>
<view class="hr-fixedbar">
<view :class="syncing ? 'hr-btn hr-btn-disabled' : 'hr-btn hr-btn-primary'" style="flex:1"
@click="onRetryAll">
<text class="os-foot-t">{{ syncing ? '正在重传…' : '立即重试全部上传' }}</text>
</view>
</view>
</template>
</hr-page>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { offlineTasks } from '@/mock/index.uts'
import { HrOfflineTask } from '@/mock/types.uts'
/**
* s19 离线同步
* 提交失败就地拦截 + 进离线队列;网络恢复后按时间顺序自动重传。
*/
const tasks = ref<HrOfflineTask[]>(offlineTasks)
const syncing = ref(false)
const progress = ref(0)
let timer : number = 0
const pendingCount = computed(() : number => {
return tasks.value.length
})
function onTaskClick(t : HrOfflineTask) : void {
uni.showModal({ title: t.title, content: t.sub, showCancel: false })
}
function onRetryAll() : void {
if (syncing.value) { return }
syncing.value = true
progress.value = 0
timer = setInterval(() => {
progress.value = progress.value + 10
if (progress.value >= 100) {
clearInterval(timer)
timer = 0
syncing.value = false
tasks.value = [] as HrOfflineTask[]
uni.showToast({ title: '全部同步完成', icon: 'success' })
}
}, 260)
}
function onSort() : void {
uni.showToast({ title: '按暂存时间排序', icon: 'none' })
}
</script>
<style>
.os-box {
display: flex;
flex-direction: row;
align-items: center;
background-color: #FFFBEB;
border: 2rpx solid #FDE68A;
border-radius: 24rpx;
padding: 18rpx 32rpx 18rpx 32rpx;
}
.os-box-t {
flex: 1;
font-size: 26.8rpx;
font-weight: bold;
color: #92400E;
margin-left: 18rpx;
}
.os-box-c {
font-size: 22.4rpx;
font-weight: bold;
color: #FFFFFF;
}
.os-progress {
margin-top: 28rpx;
}
.os-p-t {
font-size: 26.8rpx;
font-weight: bold;
color: #1F2937;
}
.os-p-v {
font-size: 25.8rpx;
font-weight: bold;
color: #2563EB;
}
.os-foot-t {
font-size: 32.4rpx;
font-weight: bold;
color: #FFFFFF;
}
</style>
+246
View File
@@ -0,0 +1,246 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="转运登记" @right-click="onMore">
<template v-slot:right>
<x-icon name="more-fill" color="#6B7280" font-size="18"></x-icon>
</template>
<view class="hr-body">
<!-- ============ 伤员摘要 ============ -->
<hr-card>
<view class="hr-row">
<view class="tr-bar"></view>
<view class="hr-flex1">
<view class="hr-row">
<text class="tr-name">李华</text>
<text class="tr-sub">男 · 35 岁</text>
<view class="hr-flex1"></view>
<hr-wb :level="2"></hr-wb>
</view>
<text class="tr-inj">头部开放性外伤 · 右前臂骨折 · 已夹板固定</text>
</view>
</view>
</hr-card>
<!-- ============ 接收医院(床位实时查询) ============ -->
<hr-sec title="接收医院" more="🔍 搜索" @more-click="onSearchHos"></hr-sec>
<hr-card variant="list">
<view v-for="(h, i) in hospitalList" :key="i" :class="hosClass(i)" @click="pickHospital(i)">
<view :class="i == hosIdx ? 'tr-radio tr-radio-on' : 'tr-radio'"></view>
<view class="hr-flex1">
<text class="tr-hos-n">{{ h }}</text>
<text class="tr-hos-s">{{ hospitalMeta[i] }}</text>
</view>
<text :class="hospitalAvail[i] ? 'tr-hos-b' : 'tr-hos-b tr-hos-b-no'">{{ hospitalBed[i] }}</text>
</view>
</hr-card>
<hr-tip style="margin-top:10px" theme="warn" icon="⚠"
text="同仁医院急诊床位已满,不可选择。若接收医院拒绝接收,系统将自动回到本页提示重派。"></hr-tip>
<!-- ============ 转运方式 ============ -->
<hr-sec title="转运方式" :first="false"></hr-sec>
<view class="hr-seg">
<view v-for="(m, i) in modes" :key="i" :class="i == modeIdx ? 'hr-seg-i hr-seg-i-on' : 'hr-seg-i'"
@click="modeIdx = i">
<text :class="i == modeIdx ? 'tr-seg-t tr-seg-t-on' : 'tr-seg-t'">{{ m }}</text>
</view>
</view>
<!-- ============ 随车联系人 ============ -->
<hr-sec title="随车联系人"></hr-sec>
<view class="hr-field">
<text class="hr-field-label">接收方联系人</text>
<input class="hr-field-input" type="text" v-model="contact" placeholder-style="color:#9CA3AF" />
</view>
<view class="hr-field" style="margin-top:9px">
<text class="hr-field-label">联系电话</text>
<input class="hr-field-input hr-mono" type="number" v-model="phone" placeholder-style="color:#9CA3AF" />
</view>
<view style="height:14px"></view>
</view>
<template v-slot:footer>
<view class="hr-fixedbar">
<view class="hr-btn hr-btn-ghost tr-foot-cancel" @click="onCancel">
<text class="tr-foot-t">取消</text>
</view>
<view class="hr-btn hr-btn-primary" style="flex:1" @click="onSubmit">
<text class="tr-foot-t2">转运登记</text>
</view>
</view>
</template>
</hr-page>
</template>
<script lang="uts" setup>
import { ref } from 'vue'
import { hospitalList, hospitalMeta, hospitalBed, hospitalAvail } from '@/mock/index.uts'
import { navBack } from '@/utils/nav.uts'
/**
* s14 转运登记
* 接收医院床位实时查询,满员自动拦截并提示重派。
*/
const modes : string[] = ['救护车 🚑', '直升机 🚁']
const modeIdx = ref(0)
const hosIdx = ref(0)
const contact = ref('刘主任(急诊)')
const phone = ref('13800006621')
function hosClass(i : number) : string {
if (i == 0) {
return i == hosIdx.value ? 'tr-hos tr-hos-on' : 'tr-hos'
}
if (i == 2) { return 'tr-hos tr-hos-dim' }
return i == hosIdx.value ? 'tr-hos tr-hos-on' : 'tr-hos'
}
function pickHospital(i : number) : void {
if (!hospitalAvail[i]) {
uni.showModal({
title: '无法选择',
content: '同仁医院急诊床位已满,请改选其他接收医院。',
showCancel: false
})
return
}
hosIdx.value = i
}
function onSearchHos() : void {
uni.showToast({ title: '搜索附近的接收医院', icon: 'none' })
}
function onSubmit() : void {
if (contact.value == '' || phone.value == '') {
uni.showToast({ title: '请填写随车联系人', icon: 'none' })
return
}
uni.showLoading({ title: '登记中' })
setTimeout(() => {
uni.hideLoading()
uni.showToast({ title: '转运登记成功', icon: 'success' })
setTimeout(() => {
navBack()
}, 700)
}, 700)
}
function onCancel() : void {
navBack()
}
function onMore() : void {
uni.showToast({ title: '转运单 / 取消转运', icon: 'none' })
}
</script>
<style>
.tr-bar {
width: 12rpx;
height: 76rpx;
border-radius: 6rpx;
background-color: #F59E0B;
margin-right: 22rpx;
}
.tr-name {
font-size: 31.4rpx;
font-weight: bold;
color: #1F2937;
margin-right: 16rpx;
}
.tr-sub {
font-size: 25.8rpx;
color: #6B7280;
}
.tr-inj {
font-size: 24.6rpx;
color: #9CA3AF;
margin-top: 8rpx;
}
.tr-hos {
display: flex;
flex-direction: row;
align-items: center;
padding-top: 24rpx;
padding-bottom: 24rpx;
border-bottom: 2rpx solid #F3F4F6;
}
.tr-hos-on {
/* 选中态仅体现在单选圈 */
}
.tr-hos-dim {
opacity: 0.55;
}
.tr-radio {
width: 40rpx;
height: 40rpx;
border-radius: 20rpx;
border: 3.2rpx solid #E5E7EB;
margin-right: 22rpx;
}
.tr-radio-on {
border: 3.2rpx solid #2563EB;
background-color: #2563EB;
}
.tr-hos-n {
font-size: 30.2rpx;
font-weight: bold;
color: #1F2937;
}
.tr-hos-s {
font-size: 23.6rpx;
color: #9CA3AF;
margin-top: 4rpx;
}
.tr-hos-b {
font-size: 24.6rpx;
font-weight: bold;
color: #059669;
margin-left: 16rpx;
}
.tr-hos-b-no {
color: #DC2626;
}
.tr-seg-t {
font-size: 29.2rpx;
color: #6B7280;
}
.tr-seg-t-on {
color: #2563EB;
font-weight: bold;
}
.tr-foot-cancel {
flex: 0 0 200rpx;
margin-right: 18rpx;
}
.tr-foot-t {
font-size: 31.4rpx;
font-weight: bold;
color: #1F2937;
}
.tr-foot-t2 {
font-size: 32.4rpx;
font-weight: bold;
color: #FFFFFF;
}
</style>
+97
View File
@@ -0,0 +1,97 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="处置记录 · 李华" :xloading="true"
@right-click="onAdd">
<template v-slot:right>
<x-icon name="add-line" color="#6B7280" font-size="19"></x-icon>
</template>
<view class="hr-body">
<!-- 处置类型筛选(组件库 xRadioButton 分段切换) -->
<view style="margin-bottom:8rpx">
<x-radio-button v-model="chipId" :list="chipTabs" height="64rpx" space="4rpx" round="22rpx"
text-style="font-weight:bold;" bg-color="#E5E7EB" active-color="#2563EB"
active-font-color="#FFFFFF" font-color="#6B7280" font-size="22rpx"></x-radio-button>
</view>
<hr-card style="margin-top:10px">
<hr-timeline :nodes="shownNodes"></hr-timeline>
</hr-card>
<hr-empty v-if="shownNodes.length == 0" icon="▤" title="该分类下暂无处置记录"
sub="切换其他分类,或点击右下角新增一条处置记录"></hr-empty>
<view style="height:14px"></view>
</view>
<template v-slot:footer>
<view class="hr-fixedbar">
<view class="hr-btn hr-btn-primary" style="flex:1" @click="onAdd">
<x-icon name="add-line" color="#FFFFFF" font-size="16"></x-icon>
<text class="trc-foot-t">新增处置记录</text>
</view>
</view>
</template>
</hr-page>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { treatRecords } from '@/mock/index.uts'
import { HrTimelineNode } from '@/mock/types.uts'
import { RADIO_BUTTON } from '@/uni_modules/tmx-ui/interface.uts'
/**
* s15 处置记录
* 按处置类型分类查看,时间线倒序。
*/
/**
* 处置类型筛选(组件库 xRadioButton 分段切换,替代原 hr-chip 横向滚动)
* ⚠ RADIO_BUTTON 的 id 是 string:统一用下标字符串,选中值即筛选序号。
*/
const chipTabs : RADIO_BUTTON[] = [
{ id: '0', title: '全部 4' } as RADIO_BUTTON,
{ id: '1', title: '止血 1' } as RADIO_BUTTON,
{ id: '2', title: '循环 1' } as RADIO_BUTTON,
{ id: '3', title: '分级 2' } as RADIO_BUTTON
]
const chipId = ref('0')
/** 由字符串 id 换算分类序号,供 shownNodes 筛选使用 */
const chip = computed(() : number => {
const i = parseInt(chipId.value)
return isNaN(i) ? 0 : i
})
const shownNodes = computed(() : HrTimelineNode[] => {
if (chip.value == 0) { return treatRecords }
const kw = chip.value == 1 ? '止血' : (chip.value == 2 ? '静脉' : '分级')
const arr : HrTimelineNode[] = []
for (let i = 0; i < treatRecords.length; i++) {
if (treatRecords[i].title.indexOf(kw) > -1) { arr.push(treatRecords[i]) }
}
return arr
})
function onAdd() : void {
uni.showModal({
title: '新增处置记录',
editable: true,
placeholderText: '如:15:40 清创缝合完成',
success: (res) => {
if (!res.confirm) { return }
const text = res.content
if (text == null || text == '') { return }
uni.showToast({ title: '处置记录已提交', icon: 'success' })
}
})
}
</script>
<style>
.trc-foot-t {
font-size: 32.4rpx;
font-weight: bold;
color: #FFFFFF;
margin-left: 12rpx;
}
</style>
+432
View File
@@ -0,0 +1,432 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="伤员查询" :xloading="true"
@right-click="onSort">
<template v-slot:right>
<x-icon name="arrow-up-down-line" color="#6B7280" font-size="17"></x-icon>
</template>
<!-- 离线条 -->
<view class="hr-offbar" @click="goOffline">
<x-icon name="cloud-off-line" color="#92400E" font-size="15"></x-icon>
<text class="vs-off-t">离线中 · 数据暂存本地</text>
<view class="hr-offbar-cnt">
<text class="vs-off-cnt-t">3</text>
</view>
<text class="hr-offbar-go">查看队列 </text>
</view>
<view class="hr-body">
<!-- ============ 搜索(xSearch ============ -->
<x-search v-model="keyword" placeholder="姓名 / 身份证 / 编号" round="24rpx" height="84rpx"
:border="searchBorder" input-bg-color="#FFFFFF" icon-color="#9CA3AF" font-color="#1F2937"
:show-cancel="false"></x-search>
<!-- ============ 双下拉筛选 ============ -->
<view class="vs-filter">
<view :class="levelIdx > 0 ? 'vs-sel vs-sel-on' : 'vs-sel'" @click="openSheet(0)">
<text class="vs-sel-l">检伤等级</text>
<text :class="levelIdx > 0 ? 'vs-sel-v vs-sel-v-on' : 'vs-sel-v'">{{ levelOptions[levelIdx] }}</text>
<x-icon name="arrow-down-s-line" color="#9CA3AF" font-size="14"></x-icon>
</view>
<view :class="statusIdx > 0 ? 'vs-sel vs-sel-on' : 'vs-sel'"
style="margin-left:8px" @click="openSheet(1)">
<text class="vs-sel-l">检伤状态</text>
<text :class="statusIdx > 0 ? 'vs-sel-v vs-sel-v-on' : 'vs-sel-v'">{{ statusOptions[statusIdx] }}</text>
<x-icon name="arrow-down-s-line" color="#9CA3AF" font-size="14"></x-icon>
</view>
</view>
<!-- ============ 伤员列表 ============ -->
<view style="margin-top:4px">
<view v-for="(v, i) in shown" :key="v.id" :class="cardClass(v.level)" @click="openDetail(v)">
<view class="hr-row">
<view :class="avatarClass(v.level)">
<text class="vs-avt-t">{{ v.name.substring(0, 1) }}</text>
</view>
<view class="hr-flex1">
<view class="hr-row">
<text class="vs-name">{{ v.name }}</text>
<hr-wb :level="v.level" small></hr-wb>
</view>
<text class="vs-code">{{ v.gender }} · {{ v.age }} 岁 · {{ v.code }}</text>
</view>
</view>
<view class="vs-inj">
<text class="vs-inj-t">伤情:{{ v.injury }}</text>
</view>
<view class="vs-bot">
<view class="vs-status" :style="statusStyle(v.statusTheme)">
<text class="vs-status-t" :style="statusTextStyle(v.statusTheme)">{{ v.status }}</text>
</view>
<view class="hr-flex1"></view>
<text class="vs-time">{{ v.createdAt }}</text>
</view>
</view>
</view>
<hr-empty v-if="shown.length == 0" icon="♡" title="没有匹配的伤员"
sub="换个筛选条件,或点击右上角扫码按腕带编号查询"></hr-empty>
<view v-if="shown.length > 0" class="vs-hint">
<text class="vs-hint-t">✦ 点击卡片查看伤员详情</text>
</view>
<view style="height:14px"></view>
</view>
<!-- ============ 筛选选择器 ============ -->
<template v-slot:overlay>
<view v-if="sheet >= 0" class="hr-mask" @click="closeSheet">
<view class="hr-sheet" @click="stop">
<view class="hr-sheet-h">
<text class="hr-sheet-h-b">{{ sheet == 0 ? '选择检伤等级' : '选择检伤状态' }}</text>
<view class="hr-sheet-h-x" @click="closeSheet">
<x-icon name="close-line" color="#9CA3AF" font-size="18"></x-icon>
</view>
</view>
<view v-for="(o, i) in currentOptions" :key="i" class="hr-sheet-opt"
:class="i == currentOptions.length - 1 ? 'hr-sheet-opt-last' : ''" @click="pickOption(i)">
<text :class="isPicked(i) ? 'hr-sheet-opt-t hr-sheet-opt-on' : 'hr-sheet-opt-t'">{{ o }}</text>
<x-icon v-if="isPicked(i)" name="check-line" color="#2563EB" font-size="17"></x-icon>
</view>
</view>
</view>
</template>
</hr-page>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { victimList } from '@/mock/index.uts'
import { HrVictim } from '@/mock/types.uts'
import { navTo } from '@/utils/nav.uts'
/**
* s04 伤员查询
* 支持按「检伤等级 / 检伤状态」双下拉筛选 + 关键字搜索 + 扫码查询。
*/
const levelOptions : string[] = ['全部', '危重', '重伤', '轻伤', '死亡']
const statusOptions : string[] = ['全部', '已检伤', '转运中', '待转运', '未转运']
const keyword = ref('')
const levelIdx = ref(0)
const statusIdx = ref(0)
const sheet = ref(-1)
const list = ref<HrVictim[]>(victimList)
/**
* xSearch 的 border 是「数组」prop,固定 3 项 = [宽度, 线型, 颜色],
* 少于 3 项内部直接返回 none。设计稿搜索框为 1px solid #E5E7EB。
*/
const searchBorder : string[] = ['1px', 'solid', '#E5E7EB']
const currentOptions = computed(() : string[] => {
return sheet.value == 0 ? levelOptions : statusOptions
})
const shown = computed(() : HrVictim[] => {
const out : HrVictim[] = []
for (let i = 0; i < list.value.length; i++) {
const v = list.value[i]
if (levelIdx.value > 0 && v.level != levelIdx.value) { continue }
if (statusIdx.value > 0) {
const label = statusOptions[statusIdx.value]
if (label == '转运中' && v.status.indexOf('转运中') < 0) { continue }
if (label == '待转运' && v.status.indexOf('待转运') < 0) { continue }
if (label == '未转运' && v.status.indexOf('未转运') < 0) { continue }
if (label == '已检伤' && v.status.indexOf('已转运') < 0 && v.status.indexOf('转运中') < 0) { continue }
}
if (keyword.value != '') {
const kw = keyword.value.toLowerCase()
const hit = v.name.toLowerCase().indexOf(kw) > -1 || v.code.toLowerCase().indexOf(kw) > -1
if (!hit) { continue }
}
out.push(v)
}
return out
})
function cardClass(level : number) : string {
return `vs-card vs-card-l${level}`
}
function avatarClass(level : number) : string {
return `vs-avt vs-avt-l${level}`
}
function statusStyle(theme : string) : string {
if (theme == 'pri') { return 'background-color:#EFF6FF;border:1px solid #BFDBFE' }
if (theme == 'amb') { return 'background-color:#FFFBEB;border:1px solid #FDE68A' }
if (theme == 'grn') { return 'background-color:#ECFDF5;border:1px solid #A7F3D0' }
return 'background-color:#F3F4F6;border:1px solid #E5E7EB'
}
function statusTextStyle(theme : string) : string {
if (theme == 'pri') { return 'color:#1D4ED8' }
if (theme == 'amb') { return 'color:#B45309' }
if (theme == 'grn') { return 'color:#047857' }
return 'color:#6B7280'
}
function openSheet(i : number) : void {
sheet.value = i
}
function closeSheet() : void {
sheet.value = -1
}
function stop() : void {
// 阻止点击面板时关闭
}
function isPicked(i : number) : boolean {
return sheet.value == 0 ? levelIdx.value == i : statusIdx.value == i
}
function pickOption(i : number) : void {
if (sheet.value == 0) { levelIdx.value = i } else { statusIdx.value = i }
sheet.value = -1
}
function openDetail(v : HrVictim) : void {
navTo('/pages/triage/detail?id=' + v.id)
}
function onSort() : void {
uni.showToast({ title: '按建档时间 / 分级排序', icon: 'none' })
}
function goOffline() : void {
navTo('/pages/triage/offline-sync')
}
</script>
<style>
.vs-off-t {
font-size: 26.8rpx;
font-weight: bold;
color: #92400E;
margin-left: 18rpx;
}
.vs-off-cnt-t {
font-size: 22.4rpx;
font-weight: bold;
color: #FFFFFF;
}
/* ---------- 搜索 ---------- */
.vs-srch {
display: flex;
flex-direction: row;
align-items: center;
background-color: #FFFFFF;
border: 2rpx solid #E5E7EB;
border-radius: 24rpx;
height: 84rpx;
padding: 0rpx 20rpx 0rpx 20rpx;
}
.vs-srch-input {
flex: 1;
font-size: 29.2rpx;
color: #1F2937;
height: 80rpx;
padding: 0rpx 16rpx 0rpx 16rpx;
}
.vs-srch-scan {
width: 52rpx;
height: 52rpx;
border-radius: 16rpx;
background-color: #EFF6FF;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.vs-scan-btn {
width: 84rpx;
height: 84rpx;
border-radius: 24rpx;
background-color: #EFF6FF;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-left: 16rpx;
}
/* ---------- 筛选 ---------- */
.vs-filter {
display: flex;
flex-direction: row;
margin-top: 20rpx;
}
.vs-sel {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
background-color: #FFFFFF;
border: 2rpx solid #E5E7EB;
border-radius: 22rpx;
padding: 18rpx 22rpx 18rpx 22rpx;
}
.vs-sel-on {
border: 2rpx solid #2563EB;
background-color: #EFF6FF;
}
.vs-sel-l {
font-size: 23.6rpx;
color: #9CA3AF;
margin-right: 14rpx;
}
.vs-sel-v {
flex: 1;
font-size: 28rpx;
font-weight: bold;
color: #1F2937;
lines: 1;
}
.vs-sel-v-on {
color: #2563EB;
}
/* ---------- 伤员卡 ---------- */
.vs-card {
position: relative;
background-color: #FFFFFF;
border: 2rpx solid #E5E7EB;
border-radius: 26rpx;
padding: 22rpx 24rpx 22rpx 32rpx;
margin-bottom: 18rpx;
overflow: hidden;
}
.vs-card-bar {
position: absolute;
left: 0rpx;
top: 0rpx;
bottom: 0rpx;
width: 8rpx;
}
.vs-card-l1 {
border-left: 8rpx solid #DC2626;
}
.vs-card-l2 {
border-left: 8rpx solid #F59E0B;
}
.vs-card-l3 {
border-left: 8rpx solid #10B981;
}
.vs-card-l4 {
border-left: 8rpx solid #1F2937;
}
.vs-avt {
width: 76rpx;
height: 76rpx;
border-radius: 22rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-right: 20rpx;
}
.vs-avt-l1 {
background-color: #DC2626;
}
.vs-avt-l2 {
background-color: #D97706;
}
.vs-avt-l3 {
background-color: #059669;
}
.vs-avt-l4 {
background-color: #1F2937;
}
.vs-avt-t {
font-size: 33.6rpx;
font-weight: bold;
color: #FFFFFF;
}
.vs-name {
font-size: 30.2rpx;
font-weight: bold;
color: #1F2937;
margin-right: 12rpx;
}
.vs-code {
font-size: 23.6rpx;
color: #9CA3AF;
margin-top: 6rpx;
}
.vs-inj {
margin-top: 16rpx;
background-color: #F9FAFB;
border-radius: 16rpx;
padding: 12rpx 16rpx 12rpx 16rpx;
}
.vs-inj-t {
font-size: 24.6rpx;
color: #6B7280;
line-height: 1.5;
}
.vs-bot {
display: flex;
flex-direction: row;
align-items: center;
margin-top: 16rpx;
}
.vs-status {
border-radius: 18rpx;
padding: 8rpx 20rpx 8rpx 20rpx;
}
.vs-status-t {
font-size: 24.6rpx;
font-weight: bold;
}
.vs-time {
font-size: 23.6rpx;
color: #9CA3AF;
}
.vs-hint {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 12rpx 0rpx 16rpx 0rpx;
}
.vs-hint-t {
font-size: 23.6rpx;
color: #9CA3AF;
}
</style>
+234
View File
@@ -0,0 +1,234 @@
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="设备详情" :xloading="true" @right-click="onMore">
<template v-slot:right>
<x-icon name="more-fill" color="#6B7280" font-size="18"></x-icon>
</template>
<view class="hr-body">
<!-- ============ 概览 ============ -->
<hr-card>
<view class="hr-row">
<view class="hr-avt hr-avt-lg dd-avt">
<text>{{ device.icon }}</text>
</view>
<view class="hr-flex1">
<text class="dd-name">{{ device.name }}</text>
<text class="dd-meta">{{ device.meta }}</text>
<view style="margin-top:7px">
<hr-pc :level="2" :text="device.status"></hr-pc>
</view>
</view>
</view>
</hr-card>
<!-- ============ 借用前自检 ============ -->
<hr-sec :title="'借用前自检(5 项缺一不可)'" :more="checkedCount + ' / 5'"></hr-sec>
<hr-card variant="list">
<view v-for="(c, i) in checks" :key="i" :class="c.checked ? 'dd-ck dd-ck-on' : 'dd-ck'"
@click="toggleCheck(i)">
<view :class="c.checked ? 'dd-ck-box dd-ck-box-on' : 'dd-ck-box'">
<x-icon v-if="c.checked" name="check-line" color="#FFFFFF" font-size="14"></x-icon>
</view>
<view class="hr-flex1">
<text class="dd-ck-t">{{ c.title }}</text>
<text class="dd-ck-s">{{ c.sub }}</text>
</view>
</view>
</hr-card>
<!-- ============ 维护记录 ============ -->
<hr-sec title="维护记录" :first="false"></hr-sec>
<hr-card>
<hr-timeline :nodes="device.maintain"></hr-timeline>
</hr-card>
<hr-tip style="margin-top:14px" theme="warn" icon="⚠"
text="发现设备异常可直接一键报修,系统将自动流转至设备科并暂停借出。"></hr-tip>
<view style="height:14px"></view>
</view>
<template v-slot:footer>
<view class="hr-fixedbar">
<view class="hr-btn hr-btn-ghost dd-foot-report" @click="onReport">
<x-icon name="error-warning-line" color="#1F2937" font-size="16"></x-icon>
<text class="dd-foot-t">报修</text>
</view>
<view :class="allChecked ? 'hr-btn hr-btn-primary' : 'hr-btn hr-btn-disabled'" style="flex:1"
@click="onBorrow">
<text :class="allChecked ? 'dd-foot-t2' : 'dd-foot-t3'">
{{ allChecked ? '确认自检并借用' : '请完成 5 项自检' }}
</text>
</view>
</view>
</template>
</hr-page>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { deviceDetail } from '@/mock/index.uts'
import { HrDeviceCheck } from '@/mock/types.uts'
import { navBack } from '@/utils/nav.uts'
/**
* s32 设备详情
* 借用前 5 项自检缺一不可,全部确认后才可借出。
*/
const device = deviceDetail
const checks = ref<HrDeviceCheck[]>([])
// 深拷贝一份,避免污染 mock 数据
const arr : HrDeviceCheck[] = []
for (let i = 0; i < device.checkList.length; i++) {
const c = device.checkList[i]
arr.push({ title: c.title, sub: c.sub, checked: c.checked } as HrDeviceCheck)
}
checks.value = arr
const checkedCount = computed(() : number => {
let n = 0
for (let i = 0; i < checks.value.length; i++) {
if (checks.value[i].checked) { n++ }
}
return n
})
const allChecked = computed(() : boolean => {
return checkedCount.value == checks.value.length
})
function toggleCheck(i : number) : void {
const list : HrDeviceCheck[] = []
for (let k = 0; k < checks.value.length; k++) {
const c = checks.value[k]
list.push({
title: c.title, sub: c.sub, checked: k == i ? !c.checked : c.checked
} as HrDeviceCheck)
}
checks.value = list
}
function onBorrow() : void {
if (!allChecked.value) {
uni.showToast({ title: '请先完成 5 项自检', icon: 'none' })
return
}
uni.showLoading({ title: '提交中' })
setTimeout(() => {
uni.hideLoading()
uni.showModal({
title: '借用成功',
content: `${device.name}\n预计归还时间:3 天后(09-27)\n已生成借用单,逾期将自动提醒。`,
showCancel: false,
success: (res) => {
navBack()
}
})
}, 700)
}
function onReport() : void {
uni.showModal({
title: '一键报修',
editable: true,
placeholderText: '请描述设备异常,如:屏幕无法点亮',
success: (res) => {
if (res.confirm) {
uni.showToast({ title: '已流转至设备科', icon: 'success' })
}
}
})
}
function onMore() : void {
uni.showToast({ title: '设备档案 / 借还记录', icon: 'none' })
}
</script>
<style>
.dd-avt {
margin-right: 26rpx;
background-color: #2563EB;
}
.dd-name {
font-size: 33.6rpx;
font-weight: bold;
color: #1F2937;
}
.dd-meta {
font-size: 25.8rpx;
color: #6B7280;
margin-top: 10rpx;
}
.dd-ck {
display: flex;
flex-direction: row;
align-items: center;
padding-top: 22rpx;
padding-bottom: 22rpx;
border-bottom: 2rpx solid #F3F4F6;
}
.dd-ck-on {
/* 选中态体现在勾选框 */
}
.dd-ck-box {
width: 44rpx;
height: 44rpx;
border-radius: 14rpx;
border: 3rpx solid #D1D5DB;
background-color: #FFFFFF;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-right: 22rpx;
}
.dd-ck-box-on {
border: 3rpx solid #10B981;
background-color: #10B981;
}
.dd-ck-t {
font-size: 30.2rpx;
font-weight: bold;
color: #1F2937;
}
.dd-ck-s {
font-size: 23.6rpx;
color: #9CA3AF;
margin-top: 4rpx;
}
.dd-foot-report {
flex: 0 0 220rpx;
margin-right: 18rpx;
}
.dd-foot-t {
font-size: 31.4rpx;
font-weight: bold;
color: #1F2937;
margin-left: 10rpx;
}
.dd-foot-t2 {
font-size: 32.4rpx;
font-weight: bold;
color: #FFFFFF;
}
.dd-foot-t3 {
font-size: 32.4rpx;
font-weight: bold;
color: #9CA3AF;
}
</style>
+277
View File
@@ -0,0 +1,277 @@
<template>
<view class="hr-page" style="background-color:#F3F4F6">
<hr-statusbar bg="#FFFFFF"></hr-statusbar>
<view class="wk-nav">
<text class="wk-nav-t">设备借用</text>
<view class="wk-nav-a" @click="onLedger">
<x-icon name="arrow-up-down-line" color="#6B7280" font-size="16"></x-icon>
<text class="wk-nav-a-t">台账</text>
</view>
</view>
<view class="hr-flow">
<scroll-view class="hr-scroll" :scroll-y="true" :show-scrollbar="false">
<view class="hr-body">
<!-- 状态筛选(xTabs -->
<x-tabs :list="tabList" :model-value="activeId" item-width="33.33%" height="84rpx" font-size="28rpx"
active-font-size="28rpx" title-color="#6B7280" active-title-color="#2563EB" line-color="#2563EB"
title-padding="0" :is-item-center="true" @change="onTabChange"></x-tabs>
<view style="margin-top:20rpx">
<view v-for="(d, i) in shown" :key="d.id" class="dv-card" @click="openDetail(d)">
<view class="dv-ic" :style="{ backgroundColor: d.iconBg }">
<x-icon :name="d.icon" :color="d.iconColor" font-size="44rpx"></x-icon>
</view>
<view class="hr-flex1">
<text class="dv-name">{{ d.name }}</text>
<text class="dv-meta">{{ d.meta }}</text>
</view>
<view class="dv-st" :style="stStyle(d.theme)">
<text class="dv-st-t" :style="stTextStyle(d.theme)">{{ d.status }}</text>
</view>
</view>
</view>
<hr-empty v-if="shown.length == 0" icon="⚙" title="该状态下暂无设备"
sub="切换上方分段查看其它状态的设备"></hr-empty>
<hr-empty v-if="shown.length == 0" icon="⚙" title="该状态下暂无设备"
sub="切换上方分段查看其它状态的设备"></hr-empty>
<hr-sec title="设备台账" more="全部 24 台 "></hr-sec>
<hr-card variant="list">
<hr-lrow icon="check-line" theme="grn" title="状态正常" sub="可随时借用">
<template v-slot:right>
<text class="dv-v dv-v-grn">18 台</text>
</template>
</hr-lrow>
<hr-lrow icon="file-list-3-line" theme="amb" title="借用中" sub="预计陆续归还">
<template v-slot:right>
<text class="dv-v dv-v-amb">4 台</text>
</template>
</hr-lrow>
<hr-lrow icon="monitor-line" theme="red" title="维修 / 保养中" sub="暂不可借" :is-last="true">
<template v-slot:right>
<text class="dv-v dv-v-red">2 台</text>
</template>
</hr-lrow>
</hr-card>
<view style="height:28rpx"></view>
</view>
</scroll-view>
<!-- 加载中覆盖层(组件库 xLoading -->
<view v-if="loading" class="hr-loading-mask">
<x-loading label="加载中…" :icon-size="'44rpx'" :text-size="'26rpx'" :vertical="true" color="#2563EB"
text-color="#6B7280"></x-loading>
</view>
</view>
<view class="hr-fixedbar">
<view class="hr-btn hr-btn-primary" style="flex:1" @click="onScanBorrow">
<x-icon name="scan-line" color="#FFFFFF" font-size="17"></x-icon>
<text class="dv-bt">扫码借用设备</text>
</view>
</view>
<hr-tabbar :active="2"></hr-tabbar>
</view>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { deviceList } from '@/mock/index.uts'
import { HrDevice } from '@/mock/types.uts'
import { navTo } from '@/utils/nav.uts'
import { mockRequest } from '@/utils/request.uts'
import { TABS_ITEM_INFO, TABS_ITEM } from '@/uni_modules/tmx-ui/interface.uts'
/**
* s31 设备借用(仓管 Tab「设备」)
* 按「可借用 / 借用中 / 全部」分段筛选,维修中设备不可借。
*/
const loading = ref(true)
const segIds : string[] = ['0', '1', '2']
const activeId = ref('0')
/** xTabs 数据 */
const tabList : TABS_ITEM_INFO[] = [
{ title: '可借用', id: '0' } as TABS_ITEM_INFO,
{ title: '借用中', id: '1' } as TABS_ITEM_INFO,
{ title: '全部', id: '2' } as TABS_ITEM_INFO
]
const seg = computed(() : number => {
const i = parseInt(activeId.value)
return isNaN(i) ? 0 : i
})
// ⚠ x-tabs 的 change 下发的是 TABS_ITEM(不是 TABS_ITEM_INFO),形参类型必须一致
function onTabChange(item : TABS_ITEM, index : number) : void {
activeId.value = item.id == '' ? index.toString() : item.id
}
onLoad(() => {
mockRequest(() => {
loading.value = false
})
})
const list = ref<HrDevice[]>(deviceList)
const shown = computed(() : HrDevice[] => {
const arr : HrDevice[] = []
for (let i = 0; i < list.value.length; i++) {
const d = list.value[i]
if (seg.value == 0 && d.status != '可借') { continue }
if (seg.value == 1 && d.status != '借用中') { continue }
arr.push(d)
}
return arr
})
function stStyle(theme : string) : string {
if (theme == 'grn') { return 'background-color:#ECFDF5' }
if (theme == 'amb') { return 'background-color:#FEF3C7' }
return 'background-color:#FEF2F2'
}
function stTextStyle(theme : string) : string {
if (theme == 'grn') { return 'color:#059669' }
if (theme == 'amb') { return 'color:#92400E' }
return 'color:#DC2626'
}
function openDetail(d : HrDevice) : void {
navTo('/pages/warehouse/device-detail?id=' + d.id)
}
function onScanBorrow() : void {
uni.showToast({ title: '对准设备二维码开始借用', icon: 'none' })
}
function onLedger() : void {
uni.showToast({ title: '设备台账(24 台)', icon: 'none' })
}
</script>
<style>
.wk-nav {
height: 96rpx;
display: flex;
flex-direction: row;
align-items: center;
padding-left: 32rpx;
padding-right: 32rpx;
background-color: #FFFFFF;
border-bottom: 2rpx solid #F3F4F6;
}
.wk-nav-t {
font-size: 33.6rpx;
font-weight: bold;
color: #1F2937;
}
.wk-nav-a {
margin-left: auto;
display: flex;
flex-direction: row;
align-items: center;
}
.wk-nav-a-t {
font-size: 29.2rpx;
color: #6B7280;
margin-left: 8rpx;
}
.dv-seg-t {
font-size: 29.2rpx;
color: #6B7280;
}
.dv-seg-t-on {
color: #2563EB;
font-weight: bold;
}
.dv-card {
display: flex;
flex-direction: row;
align-items: center;
background-color: #FFFFFF;
border: 2rpx solid #E5E7EB;
border-radius: 26rpx;
padding: 24rpx 24rpx 24rpx 24rpx;
margin-bottom: 18rpx;
}
.dv-ic {
width: 104rpx;
height: 104rpx;
border-radius: 26rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-right: 22rpx;
}
.dv-ic-t {
font-size: 49.2rpx;
}
.dv-name {
font-size: 30.2rpx;
font-weight: bold;
color: #1F2937;
lines: 1;
}
.dv-meta {
font-size: 23.6rpx;
color: #9CA3AF;
margin-top: 6rpx;
}
.dv-st {
border-radius: 12rpx;
padding: 6rpx 16rpx 6rpx 16rpx;
margin-left: 16rpx;
}
.dv-st-t {
font-size: 23.6rpx;
font-weight: bold;
}
.dv-v {
font-size: 31.4rpx;
font-weight: bold;
margin-left: 16rpx;
}
.dv-v-grn {
color: #059669;
}
.dv-v-amb {
color: #D97706;
}
.dv-v-red {
color: #DC2626;
}
.dv-bt {
font-size: 32.4rpx;
font-weight: bold;
color: #FFFFFF;
margin-left: 12rpx;
}
</style>

Some files were not shown because too many files have changed in this diff Show More