This commit is contained in:
2026-09-24 16:25:22 +08:00
commit 7428184f01
1198 changed files with 314515 additions and 0 deletions
+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>