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