Files
2026-09-24 16:25:22 +08:00

427 lines
15 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 伤情标记(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() + ' 处标记'
}