1
This commit is contained in:
+101
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 图标:设计稿字符 → RemixIcon 名称 映射
|
||||
* ------------------------------------------------------------
|
||||
* 设计稿里用「▦ ✓ ⚑」等字符当图标,真机渲染效果差且不统一。
|
||||
* 这里统一转成 RemixIcon(https://remixicon.com/)名称,由 x-icon 渲染。
|
||||
*
|
||||
* 用法:`<x-icon :name="toRemixIcon(glyph)" />`
|
||||
* 若传入的本身就是 RemixIcon 名(含 '-'),原样返回,可直接透传。
|
||||
*
|
||||
* ⚠⚠ 本文件导出的函数**不要直接写在 .uvue 的 template 里**!
|
||||
* uvue 编译器会把模板里的「import 函数」编译成 `unref(toRemixIcon)(x)`,
|
||||
* App 端 Kotlin 报:找不到名称"invoke" / Function invocation 'toRemixIcon(...)' expected。
|
||||
* 正确做法:在页面 script 里包一层本文件内定义的函数再给模板用:
|
||||
* function entryIcon(g : string) : string { return toRemixIcon(g) }
|
||||
* (hr-lrow / hr-tip / hr-empty 是在 computed 里调用的,没问题。)
|
||||
*/
|
||||
const ICON_MAP : Map<string, string> = new Map<string, string>([
|
||||
// 列表 / 文件 / 数据
|
||||
['▦', 'layout-grid-line'],
|
||||
['▤', 'file-list-3-line'],
|
||||
['▣', 'monitor-line'],
|
||||
['▢', 'clipboard-line'],
|
||||
['▥', 'bar-chart-2-line'],
|
||||
// 状态
|
||||
['✓', 'check-line'],
|
||||
['✗', 'close-line'],
|
||||
['✕', 'close-line'],
|
||||
['×', 'close-line'],
|
||||
['!', 'error-warning-line'],
|
||||
['⚠', 'alarm-warning-line'],
|
||||
['ℹ', 'information-line'],
|
||||
['?', 'question-line'],
|
||||
['? ', 'question-line'],
|
||||
// 操作
|
||||
['⚑', 'flag-line'],
|
||||
['+', 'add-line'],
|
||||
['+', 'add-line'],
|
||||
['⇅', 'arrow-up-down-line'],
|
||||
['⇄', 'arrow-left-right-line'],
|
||||
['↦', 'route-line'],
|
||||
['✎', 'edit-line'],
|
||||
['⌕', 'search-line'],
|
||||
['⋯', 'more-fill'],
|
||||
['…', 'more-fill'],
|
||||
['⟨', 'more-fill'],
|
||||
// 导航
|
||||
['⌂', 'home-3-line'],
|
||||
['⌗', 'qr-scan-line'],
|
||||
['☺', 'user-3-line'],
|
||||
['✉', 'mail-line'],
|
||||
['☎', 'phone-line'],
|
||||
['⚙', 'settings-3-line'],
|
||||
['›', 'arrow-right-s-line'],
|
||||
['‹', 'arrow-left-s-line'],
|
||||
['⌄', 'arrow-down-s-line'],
|
||||
['⌃', 'arrow-up-s-line'],
|
||||
// 业务
|
||||
['♡', 'heart-pulse-line'],
|
||||
['★', 'star-line'],
|
||||
['☆', 'star-line'],
|
||||
['◔', 'time-line'],
|
||||
['◍', 'bubble-chart-line'],
|
||||
['⚡', 'flashlight-line'],
|
||||
['◈', 'apps-2-line'],
|
||||
['☣', 'biohazard-line'],
|
||||
['✦', 'sparkling-line'],
|
||||
// 播放器
|
||||
['▶', 'play-fill'],
|
||||
['▸', 'play-line'],
|
||||
['⏸', 'pause-line'],
|
||||
['⤢', 'fullscreen-line'],
|
||||
['⏮', 'skip-back-line'],
|
||||
['⏭', 'skip-forward-line'],
|
||||
// 分隔 / 其它
|
||||
['—', 'subtract-line'],
|
||||
['·', 'more-line']
|
||||
])
|
||||
|
||||
/** 字符图标 → RemixIcon 名;已是 RemixIcon 名则原样返回 */
|
||||
export function toRemixIcon(glyph : string) : string {
|
||||
if (glyph == null || glyph == '') { return '' }
|
||||
const mapped = ICON_MAP.get(glyph)
|
||||
if (mapped != null) { return mapped }
|
||||
// 含 '-' 视为已经是 remixicon 名称,直接透传
|
||||
if (glyph.indexOf('-') > -1) { return glyph }
|
||||
return 'checkbox-blank-circle-line'
|
||||
}
|
||||
|
||||
/** 主题 → 图标底色 / 图标色(保持设计稿的四色语义) */
|
||||
export type HrThemeColor = {
|
||||
bg : string
|
||||
color : string
|
||||
}
|
||||
|
||||
export function themeColor(theme : string) : HrThemeColor {
|
||||
if (theme == 'pri') { return { bg: '#EFF6FF', color: '#2563EB' } as HrThemeColor }
|
||||
if (theme == 'red') { return { bg: '#FEF2F2', color: '#DC2626' } as HrThemeColor }
|
||||
if (theme == 'amb') { return { bg: '#FEF3C7', color: '#D97706' } as HrThemeColor }
|
||||
if (theme == 'grn') { return { bg: '#ECFDF5', color: '#059669' } as HrThemeColor }
|
||||
return { bg: '#F3F4F6', color: '#6B7280' } as HrThemeColor
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* 伤情标记(2D 人体图鉴)数据模型 + JSON 编解码
|
||||
* ------------------------------------------------------------------
|
||||
* 设计要点:
|
||||
* 1. 部位热区坐标全部是「相对人体图宽/高的百分比」,与屏宽无关,
|
||||
* 所以 APP 与 H5、不同机型都能落在同一处(人体图本身固定 192×368rpx)。
|
||||
* 2. id 按「视野」命名(armVL = 图左边那条胳膊),name 按「伤员自身」命名。
|
||||
* 左侧卧位/仰卧位会影响左右判断,因此正面:图的左侧 = 伤员右侧。
|
||||
* 这一点对临床很重要(「左前臂骨折」标到右手是事故级错误)。
|
||||
* 3. HrInjury 的字段全是基础类型(string / number),可直接 JSON 序列化,
|
||||
* 后端按同结构返回即可回显。
|
||||
*/
|
||||
|
||||
/* ============================ 类型 ============================ */
|
||||
|
||||
/** 人体图鉴的一个可点部位(热区) */
|
||||
export type HrBodyPart = {
|
||||
/** 部位 id(按人体图视野命名,同一 id 在正/背面几何位置相同) */
|
||||
id : string
|
||||
/** 部位名(按伤员自身左右命名,正面与背面不同) */
|
||||
name : string
|
||||
/** 所属视野:front 正面 / back 背面 */
|
||||
side : string
|
||||
/** 热区左上角 x(占人体图宽 %) */
|
||||
x : number
|
||||
/** 热区左上角 y(占人体图高 %) */
|
||||
y : number
|
||||
/** 热区宽(%) */
|
||||
w : number
|
||||
/** 热区高(%) */
|
||||
h : number
|
||||
/** 默认标记锚点 x(%) */
|
||||
mx : number
|
||||
/** 默认标记锚点 y(%) */
|
||||
my : number
|
||||
}
|
||||
|
||||
/** 一处伤情标记(字段均为基础类型,可直接 JSON 序列化) */
|
||||
export type HrInjury = {
|
||||
/** 唯一 id */
|
||||
id : string
|
||||
/** 图上显示的编号,从 1 开始,随增删自动重排 */
|
||||
no : number
|
||||
/** front 正面 / back 背面 */
|
||||
side : string
|
||||
/** 部位 id */
|
||||
partId : string
|
||||
/** 部位名(伤员自身左右) */
|
||||
partName : string
|
||||
/** 标记点 x(%) */
|
||||
x : number
|
||||
/** 标记点 y(%) */
|
||||
y : number
|
||||
/** 伤口长轴方向,0~179 度(0 横、90 竖) */
|
||||
rot : number
|
||||
/** 性质:开放性外伤 / 裂伤 / 骨折 … */
|
||||
nature : string
|
||||
/** 尺寸:如 4×2cm */
|
||||
size : string
|
||||
/** 出血情况:活动性出血 / 渗血 / 已止血 / 无出血 */
|
||||
bleeding : string
|
||||
/** 详情描述 */
|
||||
desc : string
|
||||
}
|
||||
|
||||
/* ============================ 部位热区 ============================ */
|
||||
|
||||
/**
|
||||
* 构建一侧的人体部位热区。
|
||||
* 热区用「不重叠的竖条划分」覆盖人体轮廓,避免点到夹缝没反应、
|
||||
* 也避免两个热区重叠导致点到哪个不确定。
|
||||
* 正面(观察者视角):图的左侧对应伤员的右侧。
|
||||
*/
|
||||
function buildParts(side : string) : HrBodyPart[] {
|
||||
const flip = side == 'front'
|
||||
const armViewLeft = flip ? '右上肢' : '左上肢'
|
||||
const armViewRight = flip ? '左上肢' : '右上肢'
|
||||
const legViewLeft = flip ? '右下肢' : '左下肢'
|
||||
const legViewRight = flip ? '左下肢' : '右下肢'
|
||||
|
||||
return [
|
||||
{ id: 'head', name: '头部', side: side, x: 33, y: 0, w: 34, h: 15, mx: 50, my: 8 } as HrBodyPart,
|
||||
{ id: 'neck', name: '颈部', side: side, x: 38, y: 15, w: 24, h: 8, mx: 50, my: 19 } as HrBodyPart,
|
||||
{ id: 'chest', name: '胸部', side: side, x: 26, y: 23, w: 48, h: 15, mx: 50, my: 30 } as HrBodyPart,
|
||||
{ id: 'abdomen', name: '腹部', side: side, x: 26, y: 38, w: 48, h: 10, mx: 50, my: 43 } as HrBodyPart,
|
||||
{ id: 'pelvis', name: '骨盆', side: side, x: 26, y: 48, w: 48, h: 10, mx: 50, my: 53 } as HrBodyPart,
|
||||
{ id: 'armVL', name: armViewLeft, side: side, x: 5, y: 18, w: 19, h: 36, mx: 14, my: 33 } as HrBodyPart,
|
||||
{ id: 'armVR', name: armViewRight, side: side, x: 76, y: 18, w: 19, h: 36, mx: 86, my: 33 } as HrBodyPart,
|
||||
{ id: 'legVL', name: legViewLeft, side: side, x: 26, y: 58, w: 19, h: 42, mx: 35, my: 79 } as HrBodyPart,
|
||||
{ id: 'legVR', name: legViewRight, side: side, x: 55, y: 58, w: 19, h: 42, mx: 65, my: 79 } as HrBodyPart
|
||||
]
|
||||
}
|
||||
|
||||
/** 正面部位热区 */
|
||||
export const HR_FRONT_PARTS : HrBodyPart[] = buildParts('front')
|
||||
/** 背面部位热区 */
|
||||
export const HR_BACK_PARTS : HrBodyPart[] = buildParts('back')
|
||||
|
||||
/** 按视野取部位表 */
|
||||
export function partsOf(side : string) : HrBodyPart[] {
|
||||
if (side == 'back') { return HR_BACK_PARTS }
|
||||
return HR_FRONT_PARTS
|
||||
}
|
||||
|
||||
/** 按 id 取部位;取不到返回 null */
|
||||
export function findPart(side : string, partId : string) : HrBodyPart | null {
|
||||
const list = partsOf(side)
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (list[i].id == partId) { return list[i] }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 按部位名取部位(用户手动改部位时用);取不到返回 null */
|
||||
export function findPartByName(side : string, name : string) : HrBodyPart | null {
|
||||
const list = partsOf(side)
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (list[i].name == name) { return list[i] }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 取部位名,取不到时回退为「待确认」 */
|
||||
export function partNameOf(side : string, partId : string) : string {
|
||||
const p = findPart(side, partId)
|
||||
if (p == null) { return '待确认部位' }
|
||||
return p!.name
|
||||
}
|
||||
|
||||
/* ============================ 选项 / 默认值 ============================ */
|
||||
|
||||
/** 伤口性质 */
|
||||
export const HR_NATURE_OPTIONS : string[] = [
|
||||
'开放性外伤', '裂伤', '擦伤', '挫伤', '刺伤', '切割伤',
|
||||
'烧伤', '挤压伤', '闭合性骨折', '开放性骨折', '关节脱位', '贯通伤', '动物咬伤'
|
||||
]
|
||||
|
||||
/** 出血情况(同时决定标记颜色) */
|
||||
export const HR_BLEEDING_OPTIONS : string[] = ['活动性出血', '渗血', '已止血', '无出血']
|
||||
|
||||
/** 尺寸常用值(点选即填,也可手输) */
|
||||
export const HR_SIZE_PRESETS : string[] = ['1×1cm', '2×2cm', '4×2cm', '5×3cm', '8×4cm', '>10cm']
|
||||
|
||||
/** 伤口走向常用角度(长轴无方向,180 度 = 0 度,故只需 0~179) */
|
||||
export const HR_ROT_PRESETS : number[] = [0, 45, 90, 135]
|
||||
|
||||
/** 新增标记时的默认出血情况 */
|
||||
export const HR_DEFAULT_BLEEDING : string = '渗血'
|
||||
/** 新增标记时的默认尺寸 */
|
||||
export const HR_DEFAULT_SIZE : string = '4×2cm'
|
||||
|
||||
/** 性质 → 该性质常见的描述补语(用于自动生成默认描述,方便快速填写) */
|
||||
function natureTail(nature : string) : string {
|
||||
if (nature == '开放性外伤') { return '创缘不整,未见异物' }
|
||||
if (nature == '裂伤') { return '创缘整齐,深及皮下' }
|
||||
if (nature == '擦伤') { return '表皮剥脱,创面污染' }
|
||||
if (nature == '挫伤') { return '局部肿胀压痛,无开放伤口' }
|
||||
if (nature == '刺伤') { return '创口小、伤道深,需探查异物' }
|
||||
if (nature == '切割伤') { return '创缘整齐,注意肌腱神经损伤' }
|
||||
if (nature == '烧伤') { return '创面潮红,需记录面积与深度' }
|
||||
if (nature == '挤压伤') { return '肢体肿胀张力高,警惕骨筋膜室综合征' }
|
||||
if (nature == '闭合性骨折') { return '畸形、反常活动、骨擦感' }
|
||||
if (nature == '开放性骨折') { return '骨折端外露,已覆盖并固定' }
|
||||
if (nature == '关节脱位') { return '关节畸形、弹性固定、活动受限' }
|
||||
if (nature == '贯通伤') { return '可见入口与出口,警惕深部脏器损伤' }
|
||||
if (nature == '动物咬伤') { return '创面污染重,需清创并评估狂犬病暴露' }
|
||||
return '待补充伤情描述'
|
||||
}
|
||||
|
||||
/** 由出血情况推荐一个性质(默认填充用) */
|
||||
export function defaultNature(bleeding : string) : string {
|
||||
if (bleeding == '活动性出血' || bleeding == '渗血') { return '开放性外伤' }
|
||||
return '挫伤'
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动生成默认描述。
|
||||
* 例:左上肢 开放性外伤(4×2cm),渗血,创缘不整,未见异物。
|
||||
*/
|
||||
export function defaultInjuryDesc(partName : string, nature : string, size : string, bleeding : string) : string {
|
||||
let s = partName + ' ' + nature
|
||||
if (size != '') { s += '(' + size + ')' }
|
||||
if (bleeding != '') { s += ',' + bleeding }
|
||||
return s + ',' + natureTail(nature) + '。'
|
||||
}
|
||||
|
||||
/* ============================ 颜色 / 尺寸换算 ============================ */
|
||||
|
||||
/** 出血情况 → 标记编号底色(沿用全站四色语义:红=危重、橙=重伤、绿=轻伤、灰=无) */
|
||||
export function bleedingColor(bleeding : string) : string {
|
||||
if (bleeding == '活动性出血') { return '#DC2626' }
|
||||
if (bleeding == '渗血') { return '#F59E0B' }
|
||||
if (bleeding == '已止血') { return '#10B981' }
|
||||
return '#9CA3AF'
|
||||
}
|
||||
|
||||
/** 出血情况 → 伤口长轴条的填充色(比底色浅,避免压住编号) */
|
||||
export function bleedingBarColor(bleeding : string) : string {
|
||||
if (bleeding == '活动性出血') { return '#FCA5A5' }
|
||||
if (bleeding == '渗血') { return '#FCD34D' }
|
||||
if (bleeding == '已止血') { return '#6EE7B7' }
|
||||
return '#E5E7EB'
|
||||
}
|
||||
|
||||
/** 出血情况 → 全站主题名(hr-lrow 等组件用) */
|
||||
export function bleedingTheme(bleeding : string) : string {
|
||||
if (bleeding == '活动性出血') { return 'red' }
|
||||
if (bleeding == '渗血') { return 'amb' }
|
||||
if (bleeding == '已止血') { return 'grn' }
|
||||
return 'default'
|
||||
}
|
||||
|
||||
/** 取字符串开头连续的整数(「4×2cm」→ 4,「>10cm」→ 0) */
|
||||
function leadingInt(s : string) : number {
|
||||
let v = 0
|
||||
let seen = false
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const c = s.charAt(i)
|
||||
// ⚠ UTS 里 charCodeAt 返回的是可空 Number?,必须先判空再比较,否则编译报错
|
||||
const code = c.charCodeAt(0)
|
||||
if (code == null) { break }
|
||||
const n = code!
|
||||
if (n >= 48 && n <= 57) {
|
||||
v = v * 10 + (n - 48)
|
||||
seen = true
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return seen ? v : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 尺寸 → 人体图上伤口长轴条的显示长度(rpx)。
|
||||
* 约定 1cm ≈ 9rpx(人体图 192rpx 宽 ≈ 40cm 实际体宽,比例接近),夹在 44~132rpx。
|
||||
*/
|
||||
export function sizeBarWidth(size : string) : number {
|
||||
const n = leadingInt(size)
|
||||
if (n <= 0) { return 56 }
|
||||
let w = 40 + n * 9
|
||||
if (w < 44) { w = 44 }
|
||||
if (w > 132) { w = 132 }
|
||||
return Math.round(w)
|
||||
}
|
||||
|
||||
/* ============================ 增删改辅助 ============================ */
|
||||
|
||||
let hrInjurySeq : number = 0
|
||||
|
||||
/** 生成唯一 id(时间戳 + 自增序号,避免同一毫秒内重复) */
|
||||
export function newInjuryId() : string {
|
||||
hrInjurySeq += 1
|
||||
return 'inj' + Date.now().toString() + '_' + hrInjurySeq.toString()
|
||||
}
|
||||
|
||||
/** 复制一处标记(保证 draft / 列表 / 原对象互不共享引用) */
|
||||
export function copyInjury(m : HrInjury) : HrInjury {
|
||||
return {
|
||||
id: m.id, no: m.no, side: m.side, partId: m.partId, partName: m.partName,
|
||||
x: m.x, y: m.y, rot: m.rot, nature: m.nature, size: m.size,
|
||||
bleeding: m.bleeding, desc: m.desc
|
||||
} as HrInjury
|
||||
}
|
||||
|
||||
/**
|
||||
* 重排编号(1 起)并返回**新数组**。
|
||||
* 必须返回新数组:uvue 里原地改数组元素不会触发刷新,
|
||||
* 而编号要和人体图上的圆点严格一致,所以统一走「重建数组 + 重新赋值」。
|
||||
*/
|
||||
export function renumberInjuries(list : HrInjury[]) : HrInjury[] {
|
||||
const out : HrInjury[] = []
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const m = copyInjury(list[i])
|
||||
m.no = i + 1
|
||||
out.push(m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 过滤掉指定 id,返回新数组(excludeId 传空串表示不过滤) */
|
||||
export function excludeInjury(list : HrInjury[], excludeId : string) : HrInjury[] {
|
||||
const out : HrInjury[] = []
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (excludeId == '' || list[i].id != excludeId) { out.push(list[i]) }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 按 id 取标记;取不到返回 null(点标记进编辑态时用) */
|
||||
export function findInjury(list : HrInjury[], id : string) : HrInjury | null {
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (list[i].id == id) { return list[i] }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 按视野过滤标记(正面图 / 背面图各取各的) */
|
||||
export function injuriesOfSide(list : HrInjury[], side : string) : HrInjury[] {
|
||||
const out : HrInjury[] = []
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (list[i].side == side) { out.push(list[i]) }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算新标记的落点。
|
||||
* 同一部位反复标记时按固定环序错开,避免编号圆点完全重叠看不出有几个。
|
||||
* @returns [x, y](百分比)
|
||||
*/
|
||||
export function nextMarkAnchor(side : string, partId : string, existing : HrInjury[], excludeId : string) : number[] {
|
||||
const p = findPart(side, partId)
|
||||
let bx = 50
|
||||
let by = 30
|
||||
if (p != null) {
|
||||
bx = p!.mx
|
||||
by = p!.my
|
||||
}
|
||||
|
||||
let n = 0
|
||||
for (let i = 0; i < existing.length; i++) {
|
||||
const m = existing[i]
|
||||
if (m.side == side && m.partId == partId && (excludeId == '' || m.id != excludeId)) { n += 1 }
|
||||
}
|
||||
|
||||
const ox : number[] = [0, 8, -8, 0, 0, 7, -7, 5, -5]
|
||||
const oy : number[] = [0, 0, 0, 6, -6, 6, -6, -5, 5]
|
||||
const k = n % 9
|
||||
|
||||
let x = bx + ox[k]
|
||||
let y = by + oy[k]
|
||||
if (x < 4) { x = 4 }
|
||||
if (x > 96) { x = 96 }
|
||||
if (y < 3) { y = 3 }
|
||||
if (y > 97) { y = 97 }
|
||||
return [x, y]
|
||||
}
|
||||
|
||||
/* ============================ JSON 编解码 ============================ */
|
||||
|
||||
/** 标记列表 → JSON 字符串(保存草稿 / 提交后端都用它) */
|
||||
export function injuriesToJson(list : HrInjury[]) : string {
|
||||
const s = JSON.stringify(list)
|
||||
if (s == null) { return '[]' }
|
||||
return s
|
||||
}
|
||||
|
||||
function objStr(o : UTSJSONObject, key : string, def : string) : string {
|
||||
const v = o.getString(key)
|
||||
if (v == null) { return def }
|
||||
return v
|
||||
}
|
||||
|
||||
function objNum(o : UTSJSONObject, key : string, def : number) : number {
|
||||
const v = o.getNumber(key)
|
||||
if (v == null) { return def }
|
||||
return v
|
||||
}
|
||||
|
||||
function clamp(v : number, min : number, max : number) : number {
|
||||
if (v < min) { return min }
|
||||
if (v > max) { return max }
|
||||
return v
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 字符串 → 标记列表(回显)。
|
||||
* 逐字段带默认值兜底:后端字段缺失、手工粘贴的残缺 JSON 都不会让页面崩。
|
||||
*/
|
||||
export function injuriesFromJson(json : string) : HrInjury[] {
|
||||
const out : HrInjury[] = []
|
||||
if (json == null) { return out }
|
||||
if (json.trim() == '') { return out }
|
||||
|
||||
const arr = JSON.parseArray<UTSJSONObject>(json)
|
||||
if (arr == null) { return out }
|
||||
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const o = arr[i]
|
||||
let side = objStr(o, 'side', 'front')
|
||||
if (side != 'front' && side != 'back') { side = 'front' }
|
||||
|
||||
let partId = objStr(o, 'partId', '')
|
||||
let partName = objStr(o, 'partName', '')
|
||||
// 允许只存部位名(老数据 / 手工 JSON):反查回 id
|
||||
if (partId == '' && partName != '') {
|
||||
const byName = findPartByName(side, partName)
|
||||
if (byName != null) { partId = byName!.id }
|
||||
}
|
||||
if (partId == '') { partId = 'head' }
|
||||
if (partName == '') { partName = partNameOf(side, partId) }
|
||||
|
||||
const m = {
|
||||
id: objStr(o, 'id', ''),
|
||||
no: objNum(o, 'no', i + 1),
|
||||
side: side,
|
||||
partId: partId,
|
||||
partName: partName,
|
||||
x: clamp(objNum(o, 'x', 50), 2, 98),
|
||||
y: clamp(objNum(o, 'y', 30), 2, 98),
|
||||
rot: clamp(objNum(o, 'rot', 0), 0, 179),
|
||||
nature: objStr(o, 'nature', '开放性外伤'),
|
||||
size: objStr(o, 'size', ''),
|
||||
bleeding: objStr(o, 'bleeding', HR_DEFAULT_BLEEDING),
|
||||
desc: objStr(o, 'desc', '')
|
||||
} as HrInjury
|
||||
|
||||
if (m.id == '') { m.id = newInjuryId() }
|
||||
if (m.desc == '') { m.desc = defaultInjuryDesc(m.partName, m.nature, m.size, m.bleeding) }
|
||||
out.push(m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 标记列表 → 按视野分组统计,用于概览文案 */
|
||||
export function injurySummary(list : HrInjury[]) : string {
|
||||
if (list.length == 0) { return '暂无伤情标记' }
|
||||
let active = 0
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (list[i].bleeding == '活动性出血') { active += 1 }
|
||||
}
|
||||
if (active > 0) {
|
||||
return '共 ' + list.length.toString() + ' 处标记,其中 ' + active.toString() + ' 处活动性出血'
|
||||
}
|
||||
return '共 ' + list.length.toString() + ' 处标记'
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 页面导航封装
|
||||
* 所有跳转统一走这里,便于集中做「角色权限拦截 / 埋点 / 登录校验」。
|
||||
*/
|
||||
import { canAccess } from './role.uts'
|
||||
|
||||
/** 普通跳转(保留当前页) */
|
||||
export function navTo(url : string) : void {
|
||||
if (!canAccess(url)) {
|
||||
uni.showToast({ title: '当前角色无权访问该页面', icon: 'none' })
|
||||
return
|
||||
}
|
||||
uni.navigateTo({ url: url })
|
||||
}
|
||||
|
||||
/** 替换当前页(不可返回) */
|
||||
export function redirectTo(url : string) : void {
|
||||
uni.redirectTo({ url: url })
|
||||
}
|
||||
|
||||
/** 关闭所有页面并打开 */
|
||||
export function relaunchTo(url : string) : void {
|
||||
uni.reLaunch({ url: url })
|
||||
}
|
||||
|
||||
/** 返回上一页 */
|
||||
export function navBack() : void {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
/** Tab 切换(uni-app x 无原生 tabBar,用 reLaunch 保证栈干净) */
|
||||
export function switchTab(url : string) : void {
|
||||
uni.reLaunch({ url: url })
|
||||
}
|
||||
|
||||
/** uni.showToast 支持的 icon 取值 */
|
||||
export type HrToastIcon = 'success' | 'error' | 'fail' | 'exception' | 'loading' | 'none'
|
||||
|
||||
/** 轻提示(统一出口,避免页面直接散落 uni.showToast) */
|
||||
export function toast(title : string, icon : HrToastIcon = 'none') : void {
|
||||
uni.showToast({ title: title, icon: icon, duration: 1600 })
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 答题状态(顺序练习 / 答题卡共用)
|
||||
* 用 reassign 方式更新数组,保证 uvue 下响应式可靠触发。
|
||||
*/
|
||||
import { reactive } from 'vue'
|
||||
import { quizQuestions } from '@/mock/index.uts'
|
||||
|
||||
export type HrQuizState = {
|
||||
/** 每题已选选项下标,-1 表示未作答 */
|
||||
answers : number[]
|
||||
/** 是否标记 */
|
||||
marked : boolean[]
|
||||
/** 当前题号(从 0 开始) */
|
||||
current : number
|
||||
/** 是否已交卷 */
|
||||
submitted : boolean
|
||||
}
|
||||
|
||||
function emptyAnswers() : number[] {
|
||||
const arr : number[] = []
|
||||
for (let i = 0; i < quizQuestions.length; i++) { arr.push(-1) }
|
||||
return arr
|
||||
}
|
||||
|
||||
function emptyMarks() : boolean[] {
|
||||
const arr : boolean[] = []
|
||||
for (let i = 0; i < quizQuestions.length; i++) { arr.push(false) }
|
||||
return arr
|
||||
}
|
||||
|
||||
export const quizState = reactive({
|
||||
answers: emptyAnswers(),
|
||||
marked: emptyMarks(),
|
||||
current: 0,
|
||||
submitted: false
|
||||
} as HrQuizState)
|
||||
|
||||
/** 设置某题答案 */
|
||||
export function setAnswer(index : number, option : number) : void {
|
||||
const arr : number[] = []
|
||||
for (let i = 0; i < quizState.answers.length; i++) {
|
||||
arr.push(i == index ? option : quizState.answers[i])
|
||||
}
|
||||
quizState.answers = arr
|
||||
}
|
||||
|
||||
/** 切换标记 */
|
||||
export function toggleMark(index : number) : void {
|
||||
const arr : boolean[] = []
|
||||
for (let i = 0; i < quizState.marked.length; i++) {
|
||||
arr.push(i == index ? !quizState.marked[i] : quizState.marked[i])
|
||||
}
|
||||
quizState.marked = arr
|
||||
}
|
||||
|
||||
/** 已答数量 */
|
||||
export function answeredCount() : number {
|
||||
let n = 0
|
||||
for (let i = 0; i < quizState.answers.length; i++) {
|
||||
if (quizState.answers[i] >= 0) { n++ }
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
/** 标记数量 */
|
||||
export function markedCount() : number {
|
||||
let n = 0
|
||||
for (let i = 0; i < quizState.marked.length; i++) {
|
||||
if (quizState.marked[i]) { n++ }
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
/** 重置 */
|
||||
export function resetQuiz() : void {
|
||||
quizState.answers = emptyAnswers()
|
||||
quizState.marked = emptyMarks()
|
||||
quizState.current = 0
|
||||
quizState.submitted = false
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 数据请求层(当前为 mock)
|
||||
* ------------------------------------------------------------
|
||||
* 全局统一模拟 1s 的请求耗时;请求返回前列表页应展示 xSkeleton 骨架屏。
|
||||
* 后续对接真实接口时,只需把 mockRequest 的实现换成 uni.request 封装,
|
||||
* 页面层(loading 开关 + 骨架屏)完全不用改。
|
||||
*/
|
||||
|
||||
/** 统一模拟请求时长(ms) */
|
||||
export const MOCK_REQUEST_DELAY : number = 1000
|
||||
|
||||
/** 模拟一次数据请求:delay 后回调 onDone */
|
||||
export function mockRequest(onDone : () => void, delay : number = MOCK_REQUEST_DELAY) : void {
|
||||
setTimeout(() => {
|
||||
onDone()
|
||||
}, delay)
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 角色 / 会话 / TabBar 配置
|
||||
* ------------------------------------------------------------
|
||||
* 双角色设计核心:
|
||||
* - 救援人员端(rescuer):首页(日常学习) / 事件(执行态) / 我的 —— 按「任务状态」分 Tab
|
||||
* - 仓管人员端(keeper):扫码 / 仓库 / 设备 / 消息 / 我 —— 按「工作对象」分 Tab
|
||||
* 不同角色的 TabBar 与可用操作页面由本文件统一裁决,页面内不要硬编码角色判断。
|
||||
*/
|
||||
import { reactive } from 'vue'
|
||||
import { xStore } from "@/uni_modules/tmx-ui/index.uts"
|
||||
import { TABBAR_ITEM_INFO } from "@/uni_modules/tmx-ui/interface.uts"
|
||||
import { rescuerUser, keeperUser } from "@/mock/index.uts"
|
||||
import { HrRole, HrUser } from "@/mock/types.uts"
|
||||
import { hrUnread, badgeText } from "@/utils/unread.uts"
|
||||
|
||||
/** 当前登录会话 */
|
||||
export type HrSession = {
|
||||
/** 角色 */
|
||||
role : HrRole
|
||||
/** 姓名 */
|
||||
name : string
|
||||
/** 头像文字 */
|
||||
avatar : string
|
||||
/** 职称 */
|
||||
title : string
|
||||
/** 科室 */
|
||||
dept : string
|
||||
/** 工号 */
|
||||
jobNo : string
|
||||
/** 角色中文名 */
|
||||
roleLabel : string
|
||||
/** 能力标签 */
|
||||
tags : string[]
|
||||
/** 累计学时 */
|
||||
hours : number
|
||||
/** 完成课程 */
|
||||
courseCount : number
|
||||
/** 有效证书 */
|
||||
certCount : number
|
||||
/** 是否已登录 */
|
||||
logged : boolean
|
||||
}
|
||||
|
||||
const guest : HrSession = {
|
||||
role: 'rescuer', name: '未登录', avatar: '医', title: '', dept: '', jobNo: '',
|
||||
roleLabel: '', tags: [] as string[], hours: 0, courseCount: 0, certCount: 0, logged: false
|
||||
} as HrSession
|
||||
|
||||
/** 全局会话(页面统一从这里取用户信息) */
|
||||
export const hrSession = reactive(guest as HrSession)
|
||||
|
||||
/** 按角色登录 */
|
||||
export function loginAs(role : HrRole) : void {
|
||||
const u : HrUser = role == 'keeper' ? keeperUser : rescuerUser
|
||||
hrSession.role = u.role
|
||||
hrSession.name = u.name
|
||||
hrSession.avatar = u.avatar
|
||||
hrSession.title = u.title
|
||||
hrSession.dept = u.dept
|
||||
hrSession.jobNo = u.jobNo
|
||||
hrSession.roleLabel = u.roleLabel
|
||||
hrSession.tags = u.tags
|
||||
hrSession.hours = u.hours
|
||||
hrSession.courseCount = u.courseCount
|
||||
hrSession.certCount = u.certCount
|
||||
hrSession.logged = true
|
||||
}
|
||||
|
||||
/** 退出登录 */
|
||||
export function logout() : void {
|
||||
hrSession.role = 'rescuer'
|
||||
hrSession.name = '未登录'
|
||||
hrSession.avatar = '医'
|
||||
hrSession.logged = false
|
||||
}
|
||||
|
||||
/* ============================ TabBar ============================ */
|
||||
|
||||
/**
|
||||
* 救援人员端 3 Tab:首页 / 事件 / 我的
|
||||
* 事件 Tab 的角标取自全局未读数(查看事件列表后清零 → 角标隐藏)。
|
||||
*/
|
||||
export function rescuerTabs() : TABBAR_ITEM_INFO[] {
|
||||
return [
|
||||
{ title: '首页', icon: 'home-3-line', selectedIcon: 'home-3-fill', color: '#9CA3AF', selectedColor: '#2563EB' } as TABBAR_ITEM_INFO,
|
||||
{ title: '事件', icon: 'alarm-warning-line', selectedIcon: 'alarm-warning-fill', color: '#9CA3AF', selectedColor: '#2563EB', dotLabel: badgeText(hrUnread.events) } as TABBAR_ITEM_INFO,
|
||||
{ title: '我的', icon: 'user-3-line', selectedIcon: 'user-3-fill', color: '#9CA3AF', selectedColor: '#2563EB' } as TABBAR_ITEM_INFO
|
||||
]
|
||||
}
|
||||
|
||||
/** 仓管人员端 5 Tab:扫码 / 仓库 / 设备 / 消息 / 我 */
|
||||
export function keeperTabs() : TABBAR_ITEM_INFO[] {
|
||||
return [
|
||||
{ title: '扫码', icon: 'qr-scan-line', selectedIcon: 'qr-scan-fill', color: '#9CA3AF', selectedColor: '#2563EB' } as TABBAR_ITEM_INFO,
|
||||
{ title: '仓库', icon: 'archive-line', selectedIcon: 'archive-fill', color: '#9CA3AF', selectedColor: '#2563EB' } as TABBAR_ITEM_INFO,
|
||||
{ title: '设备', icon: 'settings-3-line', selectedIcon: 'settings-3-fill', color: '#9CA3AF', selectedColor: '#2563EB' } as TABBAR_ITEM_INFO,
|
||||
{ title: '消息', icon: 'mail-line', selectedIcon: 'mail-fill', color: '#9CA3AF', selectedColor: '#2563EB', dotLabel: badgeText(hrUnread.messages) } as TABBAR_ITEM_INFO,
|
||||
{ title: '我', icon: 'user-3-line', selectedIcon: 'user-3-fill', color: '#9CA3AF', selectedColor: '#2563EB' } as TABBAR_ITEM_INFO
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* 取当前角色的 TabBar 项
|
||||
* 每次调用重新构造数组 → 未读数变化时调用方的 computed 会重新求值(角标随之隐藏)。
|
||||
*/
|
||||
export function currentTabs() : TABBAR_ITEM_INFO[] {
|
||||
return hrSession.role == 'keeper' ? keeperTabs() : rescuerTabs()
|
||||
}
|
||||
|
||||
/** 当前角色 Tab 对应的页面路径(顺序与 currentTabs 一致) */
|
||||
export function currentTabPages() : string[] {
|
||||
if (hrSession.role == 'keeper') {
|
||||
return [
|
||||
'/pages/warehouse/scan',
|
||||
'/pages/warehouse/stock',
|
||||
'/pages/warehouse/devices',
|
||||
'/pages/message/index',
|
||||
'/pages/mine/index'
|
||||
]
|
||||
}
|
||||
return [
|
||||
'/pages/home/index',
|
||||
'/pages/event/index',
|
||||
'/pages/mine/index'
|
||||
]
|
||||
}
|
||||
|
||||
/** 同步 TabBar 配置进 tmx-ui 全局状态(x-tabbar 会读取) */
|
||||
export function syncTabbar(activeIndex : number) : void {
|
||||
xStore.xTabbarConfig.list = currentTabs()
|
||||
xStore.xTabbarConfig.tabbarActiveIndex = activeIndex
|
||||
}
|
||||
|
||||
function rescueHas(arr : string[], p : string) : boolean {
|
||||
let hit = false
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
if (arr[i] == p) { hit = true; break }
|
||||
}
|
||||
return hit
|
||||
}
|
||||
|
||||
function keeperHas(arr : string[], p : string) : boolean {
|
||||
let hit = false
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
if (arr[i] == p) { hit = true; break }
|
||||
}
|
||||
return hit
|
||||
}
|
||||
|
||||
/**
|
||||
* 某个角色是否可访问某页面
|
||||
* 用于「可用操作页面的区分」:仓管端不出现检伤/事件工作台,救援端不出现出入库。
|
||||
*/
|
||||
export function canAccess(path : string) : boolean {
|
||||
const keeperOnly : string[] = ['/pages/warehouse/scan', '/pages/warehouse/stock', '/pages/warehouse/goods-detail', '/pages/warehouse/devices', '/pages/warehouse/device-detail', '/pages/message/index']
|
||||
const rescuerOnly : string[] = ['/pages/home/index', '/pages/event/index', '/pages/event/detail', '/pages/triage/victims', '/pages/triage/create', '/pages/triage/detail', '/pages/triage/batch-treat', '/pages/triage/batch-transfer', '/pages/triage/transfer', '/pages/triage/treat-records', '/pages/triage/offline-sync', '/pages/course/library', '/pages/course/subject', '/pages/course/media', '/pages/course/practice', '/pages/course/quiz', '/pages/course/answer-card', '/pages/study/exams', '/pages/study/certificates', '/pages/study/wrong-book', '/pages/study/center', '/pages/message/list', '/pages/message/contacts']
|
||||
const p = path.split('?')[0]
|
||||
if (hrSession.role == 'keeper') {
|
||||
return rescueHas(rescuerOnly, p) ? false : true
|
||||
}
|
||||
return keeperHas(keeperOnly, p) ? false : true
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 未读角标状态(全站唯一来源)
|
||||
* ------------------------------------------------------------
|
||||
* 角标 = 未读数。对应内容被查看后置 0 → 角标自动隐藏。
|
||||
*
|
||||
* 使用:
|
||||
* - TabBar:`utils/role.uts` 里用 `badgeText(hrUnread.events)` 生成 dotLabel
|
||||
* - 列表行:`<view v-if="hrUnread.msgCenter > 0" class="hr-badge">`
|
||||
* - 页面「查看后」:onPageShow 里调 markEventsRead() / markMessagesRead()
|
||||
*/
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export type HrUnread = {
|
||||
/** 事件 TabBar 角标(救援人员端 · 事件 Tab) */
|
||||
events : number
|
||||
/** 消息 TabBar 角标(仓管人员端 · 消息 Tab) */
|
||||
messages : number
|
||||
/** 「我的」页消息中心入口 / 消息中心分组角标 */
|
||||
msgCenter : number
|
||||
}
|
||||
|
||||
/** 全局未读数 */
|
||||
export const hrUnread = reactive({
|
||||
events: 4,
|
||||
messages: 4,
|
||||
msgCenter: 4
|
||||
} as HrUnread)
|
||||
|
||||
/** 角标文案:0 时返回空串 —— x-badge 收到空 label 会自行隐藏,无需额外判断 */
|
||||
export function badgeText(n : number) : string {
|
||||
return n > 0 ? n.toString() : ''
|
||||
}
|
||||
|
||||
/** 查看事件列表后调用(救援端事件 Tab 页 onPageShow) */
|
||||
export function markEventsRead() : void {
|
||||
hrUnread.events = 0
|
||||
}
|
||||
|
||||
/** 查看消息中心后调用(消息页 onPageShow) */
|
||||
export function markMessagesRead() : void {
|
||||
hrUnread.messages = 0
|
||||
hrUnread.msgCenter = 0
|
||||
}
|
||||
Reference in New Issue
Block a user