/** * 伤情标记(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(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() + ' 处标记' }