This commit is contained in:
2026-09-24 16:25:22 +08:00
commit 7428184f01
1198 changed files with 314515 additions and 0 deletions
+426
View File
@@ -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() + ' 处标记'
}