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

1486 lines
41 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.
<template>
<hr-page page-bg="#F3F4F6" status-bg="#FFFFFF" nav-title="检伤建档" @right-click="onSaveDraft">
<template v-slot:right>
<text class="c-nav-draft">存草稿</text>
</template>
<view class="hr-body" style="padding-bottom:22px">
<!-- ============ 基本信息 ============ -->
<hr-sec title="基本信息" more="扫描身份证 " :first="true" @more-click="onScanId"></hr-sec>
<hr-card>
<view class="c-row">
<view class="c-col">
<view class="hr-field">
<text class="hr-field-label">姓名</text>
<input class="hr-field-input" type="text" v-model="form.name" placeholder="必填"
placeholder-style="color:#9CA3AF" />
</view>
</view>
<view class="c-col c-col-last">
<view class="hr-field">
<text class="hr-field-label">性别</text>
<input class="hr-field-input" type="text" v-model="form.gender" placeholder="男 / 女"
placeholder-style="color:#9CA3AF" />
</view>
</view>
</view>
<view class="c-row c-row-last">
<view class="c-col">
<view class="hr-field">
<text class="hr-field-label">年龄</text>
<input class="hr-field-input" type="number" v-model="form.age" placeholder="岁"
placeholder-style="color:#9CA3AF" />
</view>
</view>
<view class="c-col c-col-last">
<view class="hr-field">
<text class="hr-field-label">身份证</text>
<input class="hr-field-input" type="text" v-model="form.idCard"
placeholder="4406••••••••1234" placeholder-style="color:#9CA3AF" />
</view>
</view>
</view>
</hr-card>
<!-- ============ 检伤分级 ============ -->
<hr-sec title="检伤分级" more="START 标准 " @more-click="onStartStandard"></hr-sec>
<hr-card style="padding:11px">
<view class="c-grades">
<view v-for="(g, i) in grades" :key="i"
:class="i == grades.length - 1 ? 'c-grade c-grade-last' : 'c-grade'" :style="gradeStyle(i)"
@click="pickGrade(i)">
<view class="c-grade-dot" :style="{ backgroundColor: gradeDotColor(i) }">
<text class="c-grade-dot-t">✓</text>
</view>
<text :class="gradeVal == i ? 'c-grade-t c-grade-t-on' : 'c-grade-t'">{{ g }}</text>
</view>
</view>
</hr-card>
<!-- ============ 伤情标记(2D 人体图鉴) ============ -->
<hr-sec title="伤情标记(2D 人体图鉴)" more=" 手动添加" @more-click="onAddByHand"></hr-sec>
<hr-card>
<view class="bm-row">
<view class="bm-col">
<hr-bodymap side="front" :parts="frontParts" :marks="frontMarks" @part-tap="onFrontPartTap"
@mark-tap="onMarkTap"></hr-bodymap>
<text class="bm-tag">正面 · 点部位即标记</text>
</view>
<view class="bm-col bm-col-last">
<hr-bodymap side="back" :parts="backParts" :marks="backMarks" @part-tap="onBackPartTap"
@mark-tap="onMarkTap"></hr-bodymap>
<text class="bm-tag">背面 · 点部位即标记</text>
</view>
</view>
<view class="c-sum">
<text class="c-sum-t">{{ markSummary }}</text>
<text class="c-sum-json" @click="onCopyJson">查看 JSON </text>
</view>
<view class="c-mklist">
<view v-for="(m, i) in marks" :key="m.id" class="c-mk">
<!-- 主体可点 → 进编辑态;删除按钮做成同级元素,避免点击冒泡冲突 -->
<view class="c-mk-main" @click="onMarkTap(m.id)">
<view class="c-mk-no" :style="mkNoStyle(m)">
<text class="c-mk-no-t">{{ m.no }}</text>
</view>
<view class="hr-flex1">
<text class="c-mk-t">{{ m.partName }} · {{ m.nature }}</text>
<text class="c-mk-s">{{ mkSub(m) }}</text>
</view>
<view class="c-mk-side" :style="mkSideStyle(m)">
<text class="c-mk-side-t">{{ mkSideText(m) }}</text>
</view>
</view>
<view class="c-mk-del" @click="askDelete(m.id)">
<x-icon name="close-line" color="#6B7280" font-size="13"></x-icon>
</view>
</view>
<view class="c-addmark" @click="onAddByHand">
<x-icon name="add-line" color="#2563EB" font-size="15"></x-icon>
<text class="c-addmark-t">点人体图部位快速添加,或点此手动添加</text>
</view>
</view>
</hr-card>
<!-- ============ 生命体征 ============ -->
<hr-sec title="生命体征" more="重新测量 " @more-click="onReMeasure"></hr-sec>
<hr-card>
<view class="c-row">
<view class="c-col c-col-3">
<view class="hr-field">
<text class="hr-field-label">T(℃)</text>
<input class="hr-field-input" type="digit" v-model="vital.t"
placeholder-style="color:#9CA3AF" />
</view>
</view>
<view class="c-col c-col-3">
<view class="hr-field">
<text class="hr-field-label">P(次/分)</text>
<input class="hr-field-input" type="number" v-model="vital.p"
placeholder-style="color:#9CA3AF" />
</view>
</view>
<view class="c-col c-col-3 c-col-last">
<view class="hr-field">
<text class="hr-field-label">R(次/分)</text>
<input class="hr-field-input" type="number" v-model="vital.r"
placeholder-style="color:#9CA3AF" />
</view>
</view>
</view>
<view class="c-row c-row-last">
<view class="c-col">
<view class="hr-field">
<text class="hr-field-label">BP(mmHg)</text>
<input class="hr-field-input" type="text" v-model="vital.bp"
placeholder-style="color:#9CA3AF" />
</view>
</view>
<view class="c-col c-col-last">
<view class="hr-field">
<text class="hr-field-label">SpO₂(%)</text>
<input class="hr-field-input" type="number" v-model="vital.spo2"
placeholder-style="color:#9CA3AF" />
</view>
</view>
</view>
<view class="c-warn">
<x-icon name="alert-line" color="#DC2626" font-size="14"></x-icon>
<text class="c-warn-t">脉搏偏快 · 血压偏低 · 血氧 88% 低于 95% 阈值,建议优先处置</text>
</view>
</hr-card>
<!-- ============ 检伤评估 ============ -->
<hr-sec title="检伤评估" more="START 标准 " @more-click="onStartStandard"></hr-sec>
<hr-card>
<view class="c-row">
<view class="c-col">
<view class="c-pick" @click="openPickSheet(0)">
<view class="hr-flex1">
<text class="c-pick-l">{{ pickLabels[0] }}</text>
<text :class="pickVal(0)">{{ pickValues[0] }}</text>
</view>
<x-icon name="arrow-down-s-line" color="#9CA3AF" font-size="14"></x-icon>
</view>
</view>
<view class="c-col c-col-last">
<view class="c-pick" @click="openPickSheet(1)">
<view class="hr-flex1">
<text class="c-pick-l">{{ pickLabels[1] }}</text>
<text :class="pickVal(1)">{{ pickValues[1] }}</text>
</view>
<x-icon name="arrow-down-s-line" color="#9CA3AF" font-size="14"></x-icon>
</view>
</view>
</view>
<view class="c-row c-row-last">
<view class="c-col">
<view class="c-pick" @click="openPickSheet(2)">
<view class="hr-flex1">
<text class="c-pick-l">{{ pickLabels[2] }}</text>
<text :class="pickVal(2)">{{ pickValues[2] }}</text>
</view>
<x-icon name="arrow-down-s-line" color="#9CA3AF" font-size="14"></x-icon>
</view>
</view>
<view class="c-col c-col-last">
<view class="c-pick" @click="openPickSheet(3)">
<view class="hr-flex1">
<text class="c-pick-l">{{ pickLabels[3] }}</text>
<text :class="pickVal(3)">{{ pickValues[3] }}</text>
</view>
<x-icon name="arrow-down-s-line" color="#9CA3AF" font-size="14"></x-icon>
</view>
</view>
</view>
<view style="margin-top:9px">
<view class="hr-field">
<text class="hr-field-label">关键体征</text>
<input class="hr-field-input" type="text" v-model="form.keySign" placeholder="如 面色苍白、四肢湿冷"
placeholder-style="color:#9CA3AF" />
</view>
</view>
</hr-card>
<!-- ============ 多模态记录 ============ -->
<hr-sec title="多模态记录" more=" 添加 " @more-click="onAddRecord"></hr-sec>
<hr-card>
<textarea class="c-ta" v-model="form.record" placeholder="记录现场情况、处置措施、伤情变化…"
placeholder-style="color:#9CA3AF" />
<text class="c-count">{{ form.record.length }} / 500</text>
<!-- 图片 / 音视频 / 文档选择(组件库 xUploadFile -->
<x-upload-file :default-list="fileList" :max-count="6" :max-file-size="maxFileSize" :multiple="true"
:auto-start="false" :disabled="false" @change="onFileChange"></x-upload-file>
<text class="c-upl-hint">支持图片 / 音频 / 视频 / 文档,单个不超过 50 MB</text>
</hr-card>
<view style="height:14px"></view>
</view>
<template v-slot:footer>
<view class="hr-fixedbar">
<view class="hr-btn hr-btn-ghost c-foot-draft" @click="onSaveDraft">
<text class="c-foot-t">保存草稿</text>
</view>
<view class="hr-btn hr-btn-primary" style="flex:1" @click="onSubmit">
<text class="c-foot-t2">提交建档</text>
</view>
</view>
</template>
<template v-slot:overlay>
<!-- ============ 评估项选择器(原底部选择器) ============ -->
<view v-if="sheetType != ''" class="hr-mask" @click="closeSheet">
<view class="hr-sheet" @click="stop">
<view class="hr-sheet-h">
<text class="hr-sheet-h-b">{{ sheetTitle }}</text>
<view class="hr-sheet-h-x" @click="closeSheet">
<x-icon name="close-line" color="#9CA3AF" font-size="18"></x-icon>
</view>
</view>
<view v-for="(o, i) in sheetOptions" :key="i" class="hr-sheet-opt"
:class="i == sheetOptions.length - 1 ? 'hr-sheet-opt-last' : ''" @click="pickSheetOption(i)">
<text class="hr-sheet-opt-t">{{ o }}</text>
</view>
</view>
</view>
<!-- ============================================================
伤情录入抽屉(xDrawer
点 2D 人体图任一部位 → 弹窗,部位自动填入,其余字段给默认值并自动
生成描述,救援人员微调后即可保存,全程不用手打字。
============================================================ -->
<x-drawer v-model:show="drawerShow" position="bottom" size="72%" round="12px" :show-footer="true"
:content-margin="'16'">
<template v-slot:title>
<view class="d-head">
<view class="d-head-dot" :style="dHeadDotStyle">
<text class="d-head-dot-t">{{ dNoLabel }}</text>
</view>
<view class="hr-flex1">
<text class="d-head-t">{{ drawerTitle }}</text>
<text class="d-head-s">部位已自动填入,其余按默认值预填,可直接保存</text>
</view>
</view>
</template>
<!-- ---------- 部位 ---------- -->
<view class="d-blk">
<view class="d-blk-h">
<text class="d-fl">部位</text>
<view class="d-side">
<view class="d-side-i" :style="segStyle(dSide == 'front')" @click="pickDside('front')">
<text :style="segTextStyle(dSide == 'front')">正面</text>
</view>
<view class="d-side-i" :style="segStyle(dSide == 'back')" @click="pickDside('back')">
<text :style="segTextStyle(dSide == 'back')">背面</text>
</view>
</view>
</view>
<view class="d-chips">
<view v-for="(p, i) in dParts" :key="p.id + dSide" class="d-chip"
:style="chipStyle(dPartId == p.id)" @click="pickDpart(p.id)">
<text :style="chipTextStyle(dPartId == p.id)">{{ p.name }}</text>
</view>
</view>
</view>
<!-- ---------- 性质 ---------- -->
<view class="d-blk">
<text class="d-fl">伤口性质</text>
<view class="d-chips">
<view v-for="(n, i) in natureOptions" :key="i" class="d-chip" :style="chipStyle(dNature == n)"
@click="pickDnature(n)">
<text :style="chipTextStyle(dNature == n)">{{ n }}</text>
</view>
</view>
</view>
<!-- ---------- 出血情况 ---------- -->
<view class="d-blk">
<text class="d-fl">出血情况(决定标记颜色)</text>
<view class="d-chips">
<view v-for="(b, i) in bleedingOptions" :key="i" class="d-chip"
:style="chipStyle(dBleeding == b)" @click="pickDbleeding(b)">
<view class="d-chip-dot" :style="bleedDotStyle(b)"></view>
<text :style="chipTextStyle(dBleeding == b)">{{ b }}</text>
</view>
</view>
</view>
<!-- ---------- 尺寸 ---------- -->
<view class="d-blk">
<text class="d-fl">尺寸 / 面积</text>
<view class="d-chips">
<view v-for="(s, i) in sizeOptions" :key="i" class="d-chip" :style="chipStyle(dSize == s)"
@click="pickDsize(s)">
<text :style="chipTextStyle(dSize == s)">{{ s }}</text>
</view>
</view>
<input class="d-input" type="text" v-model="dSize" placeholder="也可手输,如 6×3cm"
placeholder-style="color:#9CA3AF" />
</view>
<!-- ---------- 伤口走向(可旋转) ---------- -->
<view class="d-blk">
<view class="d-blk-h">
<text class="d-fl">伤口走向</text>
<view class="d-rot">
<view class="d-rot-b" @click="stepRot(-45)">
<text class="d-rot-b-t">45°</text>
</view>
<text class="d-rot-v">{{ dRot }}°</text>
<view class="d-rot-b" @click="stepRot(45)">
<text class="d-rot-b-t">45°</text>
</view>
</view>
</view>
<!-- 走向实时预览:和人体图上的长轴条同一套换算 -->
<view class="d-prev">
<view class="d-prev-bar" :style="previewBarStyle"></view>
<view class="d-prev-dot" :style="dHeadDotStyle">
<text class="d-prev-dot-t">{{ dNoLabel }}</text>
</view>
</view>
<view class="d-chips">
<view v-for="(r, i) in rotOptions" :key="i" class="d-chip" :style="chipStyle(dRot == r)"
@click="pickDrot(r)">
<text :style="chipTextStyle(dRot == r)">{{ r }}°</text>
</view>
</view>
</view>
<!-- ---------- 详情描述 ---------- -->
<view class="d-blk">
<view class="d-blk-h">
<text class="d-fl">详情描述</text>
<text class="d-regen" @click="regenDesc">↻ 按当前选项重生成</text>
</view>
<textarea class="d-ta" v-model="dDesc" placeholder="伤情描述" placeholder-style="color:#9CA3AF" />
</view>
<!-- ---------- 编辑态:删除 ---------- -->
<view v-if="drawerMode == 'edit'" class="d-del" @click="askDeleteEditing">
<text class="d-del-t">删除这处标记</text>
</view>
<view style="height:8px"></view>
<!-- 底部操作:用页面统一按钮,不用组件库默认按钮 -->
<template v-slot:footer>
<view class="d-foot">
<view class="hr-btn hr-btn-ghost d-foot-cancel" @click="closeDrawer">
<text class="d-foot-t">取消</text>
</view>
<view class="hr-btn hr-btn-primary d-foot-ok" @click="onInjuryConfirm">
<text class="d-foot-t2">保存标记</text>
</view>
</view>
</template>
</x-drawer>
</template>
</hr-page>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue'
import { gradeList, triagePickList, triagePickValues, triagePickOptions } from '@/mock/index.uts'
import { navBack, relaunchTo, navTo } from '@/utils/nav.uts'
import {
HrInjury, HrBodyPart,
HR_FRONT_PARTS, HR_BACK_PARTS,
HR_NATURE_OPTIONS, HR_BLEEDING_OPTIONS, HR_SIZE_PRESETS, HR_ROT_PRESETS,
HR_DEFAULT_BLEEDING, HR_DEFAULT_SIZE,
partsOf, findPart, findInjury, injuriesOfSide, partNameOf,
defaultNature, defaultInjuryDesc,
bleedingColor, bleedingBarColor, sizeBarWidth,
newInjuryId, copyInjury, renumberInjuries, excludeInjury, nextMarkAnchor,
injuriesToJson, injuriesFromJson, injurySummary
} from '@/utils/injury.uts'
/**
* s05 检伤建档
* 基本信息 → 一行四级检伤分级 → 2D 人体图鉴伤情标记 → 五项生命体征 → 检伤评估 → 多模态记录
*
* 伤情标记交互(本页核心):
* 点 2D 人体图任一部位 → 底部 xDrawer 弹窗(部位自动填入、其余字段默认填充、
* 描述自动生成)→ 微调后保存 → 人体图上出现「编号圆点 + 伤口长轴条」,
* 长轴条长度 = 尺寸、角度 = 走向(可旋转)、颜色 = 出血情况。
* 数据层全部在 utils/injury.uts:标记对象是纯基础类型,injuriesToJson /
* injuriesFromJson 一对函数负责存草稿与回显,接后端时替换这两个函数的实现即可。
*/
type TriageForm = {
name : string
gender : string
age : string
idCard : string
keySign : string
record : string
}
type TriageVital = { t : string, p : string, r : string, bp : string, spo2 : string }
const grades : string[] = gradeList
const pickLabels : string[] = triagePickList
const form = ref<TriageForm>({
name: '李华', gender: '男', age: '35', idCard: '4406••••••••1234',
keySign: '面色苍白、四肢湿冷、桡动脉细弱',
record: '14:23 于坍塌现场 B 区发现,俯卧位,呼之可睁眼。移除压覆物后前臂出现活动性出血,已加压包扎。'
} as TriageForm)
const vital = ref<TriageVital>({ t: '37.8', p: '102', r: '22', bp: '95/60', spo2: '88' } as TriageVital)
const gradeVal = ref(1)
const pickValues = ref<string[]>([
triagePickValues[0], triagePickValues[1], triagePickValues[2], triagePickValues[3]
])
/* ============================ 伤情标记 ============================ */
/** 草稿存储键(存的是 injuriesToJson 出来的 JSON 串) */
const HR_DRAFT_KEY : string = 'hr_triage_draft_injuries'
/** 正/背面的部位热区表与选项表,直接来自数据层,页面不重复定义 */
const frontParts : HrBodyPart[] = HR_FRONT_PARTS
const backParts : HrBodyPart[] = HR_BACK_PARTS
const natureOptions : string[] = HR_NATURE_OPTIONS
const bleedingOptions : string[] = HR_BLEEDING_OPTIONS
const sizeOptions : string[] = HR_SIZE_PRESETS
const rotOptions : number[] = HR_ROT_PRESETS
/** 造一条示例标记(打开页面即有内容;接后端后用接口数据替换) */
function demoMark(no : number, side : string, partId : string, rot : number, nature : string, size : string, bleeding : string) : HrInjury {
const p = findPart(side, partId)
const name = p == null ? partNameOf(side, partId) : p!.name
const mx = p == null ? 50 : p!.mx
const my = p == null ? 30 : p!.my
return {
id: 'inj_demo_' + no.toString(), no: no, side: side, partId: partId, partName: name,
x: mx, y: my, rot: rot, nature: nature, size: size, bleeding: bleeding,
desc: defaultInjuryDesc(name, nature, size, bleeding)
} as HrInjury
}
const marks = ref<HrInjury[]>([
demoMark(1, 'front', 'head', 0, '开放性外伤', '4×2cm', '活动性出血'),
demoMark(2, 'front', 'armVL', 90, '闭合性骨折', '5×3cm', '已止血'),
demoMark(3, 'back', 'legVL', 45, '挤压伤', '8×4cm', '渗血')
])
/**
* 附件初始列表(xUploadFile 的 defaultList,只为带出已有附件)
* ⚠ 项里没有 `request`(服务端返回的成功数据)是合法的,组件已打补丁支持缺省;
* 若哪天报 `null cannot be cast to non-null type kotlin.Any`,先看
* `uni_modules/tmx-ui/components/x-upload-file/x-upload-file.uvue` 的 covertList()
* 补丁是否被插件升级覆盖(见 MEMORY.md「uni_modules 本地补丁清单」)。
*/
const fileList = ref<UTSJSONObject[]>([
{ name: '现场照片.jpg', url: '' } as UTSJSONObject,
{ name: '现场录音.m4a', url: '' } as UTSJSONObject
])
/**
* 单个附件大小上限(字节)。
* ⚠ xUploadFile 的 maxFileSize 单位是「字节」,不是 MB。
* 之前传 50 相当于 50 字节,导致任何文件都被判为「超过大小」。
*/
const maxFileSize : number = 50 * 1024 * 1024
const frontMarks = computed(() : HrInjury[] => injuriesOfSide(marks.value, 'front'))
const backMarks = computed(() : HrInjury[] => injuriesOfSide(marks.value, 'back'))
const markSummary = computed(() : string => injurySummary(marks.value))
/* ---------- 人体图 / 列表上的样式下发 ---------- */
/* uvue 不支持复合类选择器(.a.b),选中态一律用内联 style 字符串下发 */
/** 编号圆点底色 = 出血情况色(红/橙/绿/灰),与人体图上的圆点同一套 */
function mkNoStyle(m : HrInjury) : string {
return 'background-color:' + bleedingColor(m.bleeding) + ';'
}
/** 列表副标题:尺寸 · 出血 · 走向 */
function mkSub(m : HrInjury) : string {
return m.size + ' · ' + m.bleeding + ' · 走向 ' + m.rot.toString() + '°'
}
function mkSideText(m : HrInjury) : string {
return m.side == 'back' ? '背面' : '正面'
}
function mkSideStyle(m : HrInjury) : string {
if (m.side == 'back') { return 'background-color:#F3F4F6;' }
return 'background-color:#EFF6FF;'
}
/* ============================ 伤情录入抽屉 ============================ */
const drawerShow = ref(false)
const drawerMode = ref('add')
/** 编辑态下的目标标记 id;新增态为空串 */
const editId = ref('')
/* 表单字段全部用顶层 ref:uvue 里嵌套对象/数组的原地修改不触发刷新,
顶层 ref 最稳,同时让「默认填充」逻辑一眼可读 */
const dSide = ref('front')
const dPartId = ref('chest')
const dNature = ref('开放性外伤')
const dBleeding = ref(HR_DEFAULT_BLEEDING)
const dSize = ref(HR_DEFAULT_SIZE)
const dRot = ref(0)
const dDesc = ref('')
const dNo = ref(1)
/** 上一次自动生成的描述:用来判断用户是否手改过描述 */
const lastAutoDesc = ref('')
const dPartName = computed(() : string => partNameOf(dSide.value, dPartId.value))
const dParts = computed(() : HrBodyPart[] => partsOf(dSide.value))
const drawerTitle = computed(() : string => drawerMode.value == 'edit' ? '编辑伤情' : '新增伤情')
const dNoLabel = computed(() : string => dNo.value.toString())
const dHeadDotStyle = computed(() : string => 'background-color:' + bleedingColor(dBleeding.value) + ';')
/** 走向实时预览条:长度 = 尺寸、角度 = 走向、颜色 = 出血(与人体图同一套换算) */
const previewBarStyle = computed(() : string => {
const w = sizeBarWidth(dSize.value)
const half = Math.round(w / 2)
return 'width:' + w + 'rpx;margin-left:-' + half + 'rpx;left:50%;' +
'background-color:' + bleedingBarColor(dBleeding.value) + ';' +
'transform:rotate(' + dRot.value + 'deg);'
})
function chipStyle(on : boolean) : string {
if (on) { return 'border:2rpx solid #2563EB;background-color:#2563EB;' }
return 'border:2rpx solid #E5E7EB;background-color:#FFFFFF;'
}
function chipTextStyle(on : boolean) : string {
if (on) { return 'color:#FFFFFF;font-weight:bold;' }
return 'color:#4B5563;'
}
function bleedDotStyle(b : string) : string {
return 'background-color:' + bleedingColor(b) + ';'
}
function segStyle(on : boolean) : string {
if (on) { return 'background-color:#2563EB;' }
return 'background-color:#FFFFFF;'
}
function segTextStyle(on : boolean) : string {
if (on) { return 'color:#FFFFFF;' }
return 'color:#6B7280;'
}
/* ---------- 默认填充 / 自动描述 ---------- */
/**
* 同步自动描述。
* force=true 强制覆盖(用户点「重生成」);
* 否则只在描述为空、或与上一次自动生成的一致时才覆盖 —— 用户手改过就不动。
*/
function syncAutoDesc(force : boolean) : void {
const auto = defaultInjuryDesc(dPartName.value, dNature.value, dSize.value, dBleeding.value)
if (force || dDesc.value == '' || dDesc.value == lastAutoDesc.value) {
dDesc.value = auto
}
lastAutoDesc.value = auto
}
function regenDesc() : void {
syncAutoDesc(true)
uni.showToast({ title: '描述已按当前选项重生成', icon: 'none' })
}
/* ---------- 打开:新增 / 编辑 ---------- */
/** 点人体图部位 → 新增态,部位自动填入 + 其余字段默认值 */
function openForPart(side : string, partId : string) : void {
drawerMode.value = 'add'
editId.value = ''
dSide.value = side
dPartId.value = partId
dBleeding.value = HR_DEFAULT_BLEEDING
dNature.value = defaultNature(HR_DEFAULT_BLEEDING)
dSize.value = HR_DEFAULT_SIZE
dRot.value = 0
dNo.value = marks.value.length + 1
syncAutoDesc(true)
drawerShow.value = true
}
function onFrontPartTap(partId : string) : void {
openForPart('front', partId)
}
function onBackPartTap(partId : string) : void {
openForPart('back', partId)
}
/** 不点人体图时的手动入口:默认正面胸部(弹窗内可改) */
function onAddByHand() : void {
openForPart('front', 'chest')
}
/** 点已有标记(人体图上的圆点 或 列表行)→ 编辑态 */
function onMarkTap(id : string) : void {
const found = findInjury(marks.value, id)
if (found == null) { return }
const m = found!
drawerMode.value = 'edit'
editId.value = m.id
dSide.value = m.side
dPartId.value = m.partId
dNature.value = m.nature
dBleeding.value = m.bleeding
dSize.value = m.size
dRot.value = m.rot
dNo.value = m.no
dDesc.value = m.desc
lastAutoDesc.value = defaultInjuryDesc(m.partName, m.nature, m.size, m.bleeding)
drawerShow.value = true
}
function closeDrawer() : void {
drawerShow.value = false
}
function pickDside(s : string) : void {
dSide.value = s
syncAutoDesc(false)
}
function pickDpart(id : string) : void {
dPartId.value = id
syncAutoDesc(false)
}
function pickDnature(v : string) : void {
dNature.value = v
syncAutoDesc(false)
}
function pickDbleeding(v : string) : void {
dBleeding.value = v
syncAutoDesc(false)
}
function pickDsize(v : string) : void {
dSize.value = v
syncAutoDesc(false)
}
function pickDrot(v : number) : void {
dRot.value = v
}
/** 走向 ±45°,180° 即 0°(伤口长轴无方向),所以取模到 0~179 */
function stepRot(delta : number) : void {
let r = (dRot.value + delta) % 180
if (r < 0) { r += 180 }
dRot.value = r
}
/* ---------- 保存 / 删除 ---------- */
function onInjuryConfirm() : void {
if (dPartId.value == '') {
uni.showToast({ title: '请先选择部位', icon: 'none' })
return
}
// 落点:同部位已有标记时按环序错开,避免编号圆点完全重叠
const base = excludeInjury(marks.value, editId.value)
const anchor = nextMarkAnchor(dSide.value, dPartId.value, base, '')
const m = {
id: editId.value == '' ? newInjuryId() : editId.value,
no: dNo.value,
side: dSide.value,
partId: dPartId.value,
partName: dPartName.value,
x: anchor[0],
y: anchor[1],
rot: dRot.value,
nature: dNature.value,
size: dSize.value,
bleeding: dBleeding.value,
desc: dDesc.value
} as HrInjury
const out : HrInjury[] = []
let replaced = false
for (let i = 0; i < marks.value.length; i++) {
const old = marks.value[i]
if (old.id == editId.value) {
// 部位没变 → 沿用原落点,避免「编辑一下位置就跳走」
if (old.side == dSide.value && old.partId == dPartId.value) {
m.x = old.x
m.y = old.y
}
out.push(m)
replaced = true
} else {
out.push(copyInjury(old))
}
}
if (!replaced) { out.push(m) }
// 编号重排必须整体重建数组:uvue 原地改元素不触发刷新
marks.value = renumberInjuries(out)
drawerShow.value = false
uni.showToast({ title: drawerMode.value == 'edit' ? '已更新标记' : '已添加标记', icon: 'none' })
}
function removeMark(id : string) : void {
marks.value = renumberInjuries(excludeInjury(marks.value, id))
uni.showToast({ title: '已删除标记', icon: 'none' })
}
function askDelete(id : string) : void {
uni.showModal({
title: '删除标记',
content: '确定删除这处伤情标记?',
cancelText: '取消',
confirmText: '删除',
success: (res) => {
if (res.confirm) {
removeMark(id)
drawerShow.value = false
}
}
})
}
function askDeleteEditing() : void {
if (editId.value != '') { askDelete(editId.value) }
}
/* ---------- 草稿(JSON 存取 / 回显) ---------- */
/** 查看当前标记的 JSON(接后端时直接把这个串作为 body 字段) */
function onCopyJson() : void {
const json = injuriesToJson(marks.value)
uni.setClipboardData({
data: json,
success: () => {
uni.showToast({ title: '标记 JSON 已复制', icon: 'none' })
}
})
}
/** 页面加载时回显上次存的草稿(存的就是 JSON,后端返回同结构即可) */
function loadInjuryDraft() : void {
try {
const raw = uni.getStorageSync(HR_DRAFT_KEY)
if (raw == null) { return }
const s = raw as string
if (s == '') { return }
const list = injuriesFromJson(s)
if (list.length > 0) { marks.value = renumberInjuries(list) }
} catch (e) {
// 草稿损坏时静默跳过,保持页面默认的示例标记
}
}
onLoad(() => {
loadInjuryDraft()
})
/* ---------- 检伤评估选择器 ---------- */
const sheetType = ref('')
const sheetIndex = ref(0)
const sheetTitle = computed(() : string => {
return '选择' + pickLabels[sheetIndex.value]
})
const sheetOptions = computed(() : string[] => {
return triagePickOptions[sheetIndex.value]
})
function openPickSheet(i : number) : void {
sheetType.value = 'pick'
sheetIndex.value = i
}
function closeSheet() : void {
sheetType.value = ''
}
function stop() : void {
}
function pickSheetOption(i : number) : void {
const arr : string[] = []
for (let k = 0; k < pickValues.value.length; k++) {
arr.push(k == sheetIndex.value ? sheetOptions.value[i] : pickValues.value[k])
}
pickValues.value = arr
sheetType.value = ''
}
/* ---------- 分级 / 评估 ---------- */
/**
* 分级选中态用内联样式下发。
* 原因:uvue 对复合类选择器(.a.b)支持不稳定,用内联样式最稳且跨端一致。
*/
function gradeStyle(i : number) : string {
const base = 'border-radius:12px;padding-top:11px;padding-bottom:10px;'
if (gradeVal.value != i) {
return base + 'border:1.5px solid #E5E7EB;background-color:#FFFFFF;'
}
if (i == 0) { return base + 'border:1.5px solid #10B981;background-color:#F0FDF4;' }
if (i == 1) { return base + 'border:1.5px solid #F59E0B;background-color:#FFFBEB;' }
if (i == 2) { return base + 'border:1.5px solid #DC2626;background-color:#FEF2F2;' }
return base + 'border:1.5px solid #1F2937;background-color:#F3F4F6;'
}
function gradeDotColor(i : number) : string {
if (i == 0) { return '#10B981' }
if (i == 1) { return '#F59E0B' }
if (i == 2) { return '#DC2626' }
return '#1F2937'
}
function pickGrade(i : number) : void {
gradeVal.value = i
uni.showToast({ title: '已选择「' + grades[i] + '」', icon: 'none' })
}
/** 下拉当前值的文字样式(空值走灰色提示态) */
function pickVal(i : number) : string {
return pickValues.value[i] == '' ? 'c-pick-v c-pick-v-empty' : 'c-pick-v'
}
/* ---------- 多模态上传(xUploadFile 接管选择与列表) ---------- */
/**
* 选择 / 删除附件后的回调。
* ⚠ 不要把这个列表写回 fileListxUploadFile 内部 watch 了 defaultList
* 一旦父级回写就会触发它的 covertList(),把每个附件重置成
* 「已上传、无大小」(realFilePath 清空),列表显示随之错乱。
* 附件状态由组件自己维护即可,这里只做提示。
*/
function onFileChange(list : UTSJSONObject[]) : void {
uni.showToast({ title: '已添加附件 ' + list.length + ' 个', icon: 'none' })
}
/* ---------- 提交 ---------- */
function onSaveDraft() : void {
uni.setStorageSync(HR_DRAFT_KEY, injuriesToJson(marks.value))
uni.showToast({ title: '草稿已保存(含 ' + marks.value.length.toString() + ' 处标记)', icon: 'success' })
}
function onSubmit() : void {
if (form.value.name == '') {
uni.showToast({ title: '请填写姓名', icon: 'none' })
return
}
uni.showLoading({ title: '提交中' })
setTimeout(() => {
uni.hideLoading()
uni.showToast({ title: '建档成功', icon: 'success' })
setTimeout(() => {
navTo('/pages/triage/detail')
}, 600)
}, 700)
}
function onScanId() : void {
uni.showToast({ title: '对准身份证自动识别', icon: 'none' })
}
function onStartStandard() : void {
uni.showModal({
title: 'START 检伤分类标准',
content: '可走 → 轻伤(绿)\n不可走 + 有呼吸 + 有脉搏 → 重伤(黄)\n呼吸 > 30 或 脉搏 > 120 或 意识障碍 → 危重(红)\n无呼吸无脉搏 → 死亡(黑)',
showCancel: false
})
}
function onReMeasure() : void {
uni.showToast({ title: '正在连接监护设备…', icon: 'none' })
}
function onAddRecord() : void {
uni.showToast({ title: '可继续追加语音 / 视频记录', icon: 'none' })
}
</script>
<style>
.c-nav-draft {
font-size: 29.2rpx;
color: #6B7280;
}
.c-row {
display: flex;
flex-direction: row;
margin-bottom: 18rpx;
}
.c-row-last {
margin-bottom: 0rpx;
}
.c-col {
flex: 1;
margin-right: 18rpx;
}
.c-col-last {
margin-right: 0rpx;
}
/* ---------- 分级 ---------- */
.c-grades {
display: flex;
flex-direction: row;
}
.c-grade {
flex: 1;
border: 3rpx solid #E5E7EB;
border-radius: 24rpx;
background-color: #FFFFFF;
padding: 22rpx 4rpx 20rpx 4rpx;
flex-direction: column;
align-items: center;
margin-right: 16rpx;
}
.c-grade-last {
margin-right: 0rpx;
}
.c-grade-dot {
width: 44rpx;
height: 44rpx;
border-radius: 22rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-bottom: 14rpx;
}
.c-grade-dot-t {
font-size: 24.6rpx;
font-weight: bold;
color: #FFFFFF;
}
.c-grade-t {
font-size: 28rpx;
font-weight: bold;
color: #1F2937;
}
.c-grade-t-on {
color: #1F2937;
}
/* 选中态的边框/底色由 gradeStyle() 内联下发,此处不再用复合选择器 */
/* ---------- 伤情标记概览 ---------- */
.c-sum {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-top: 22rpx;
}
.c-sum-t {
font-size: 23.6rpx;
color: #6B7280;
}
.c-sum-json {
font-size: 23.6rpx;
color: #2563EB;
}
/* ---------- 标记列表 ---------- */
.c-mklist {
flex-direction: column;
margin-top: 16rpx;
}
.c-mk {
display: flex;
flex-direction: row;
align-items: center;
background-color: #F9FAFB;
border: 2rpx solid #E5E7EB;
border-radius: 22rpx;
padding: 20rpx 24rpx 20rpx 24rpx;
margin-bottom: 16rpx;
}
/* 主体整块可点(进编辑态),删除按钮做成它的同级兄弟,避免点击冒泡打架 */
.c-mk-main {
display: flex;
flex-direction: row;
align-items: center;
flex: 1;
}
.c-mk-no {
width: 44rpx;
height: 44rpx;
border-radius: 22rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-right: 20rpx;
}
.c-mk-no-t {
font-size: 24.6rpx;
font-weight: bold;
color: #FFFFFF;
}
.c-mk-t {
font-size: 28rpx;
font-weight: bold;
color: #1F2937;
}
.c-mk-s {
font-size: 23.6rpx;
color: #9CA3AF;
margin-top: 4rpx;
}
.c-mk-side {
border-radius: 10rpx;
padding: 4rpx 14rpx 4rpx 14rpx;
margin-left: 12rpx;
}
.c-mk-side-t {
font-size: 21.2rpx;
color: #6B7280;
}
.c-mk-del {
width: 40rpx;
height: 40rpx;
border-radius: 20rpx;
background-color: #F3F4F6;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-left: 16rpx;
}
.c-addmark {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
height: 80rpx;
border: 3rpx dashed #C7CDD6;
border-radius: 22rpx;
background-color: #FFFFFF;
}
.c-addmark-t {
font-size: 25.8rpx;
color: #6B7280;
margin-left: 12rpx;
}
/* ---------- 伤情录入抽屉 ---------- */
.d-head {
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
margin: 5px 16px;
padding-top: 6rpx;
}
.d-head-dot {
width: 44rpx;
height: 44rpx;
border-radius: 22rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-right: 16rpx;
}
.d-head-dot-t {
font-size: 23rpx;
font-weight: bold;
color: #FFFFFF;
}
.d-head-t {
font-size: 30rpx;
font-weight: bold;
color: #1F2937;
}
.d-head-s {
font-size: 21.2rpx;
color: #9CA3AF;
margin-top: 4rpx;
}
.d-blk {
margin-bottom: 24rpx;
}
.d-blk-h {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.d-fl {
font-size: 25.8rpx;
font-weight: bold;
color: #1F2937;
}
.d-regen {
font-size: 22.8rpx;
color: #2563EB;
}
.d-chips {
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin-top: 14rpx;
}
/* 选中态(边框/底色/文字色)一律由 chipStyle / chipTextStyle 内联下发 */
.d-chip {
display: flex;
flex-direction: row;
align-items: center;
border-radius: 14rpx;
padding: 12rpx 20rpx 12rpx 20rpx;
margin-right: 14rpx;
margin-bottom: 14rpx;
}
.d-chip-dot {
width: 16rpx;
height: 16rpx;
border-radius: 8rpx;
margin-right: 10rpx;
}
.d-side {
display: flex;
flex-direction: row;
border: 2rpx solid #E5E7EB;
border-radius: 14rpx;
overflow: hidden;
}
.d-side-i {
padding: 8rpx 22rpx 8rpx 22rpx;
}
.d-rot {
display: flex;
flex-direction: row;
align-items: center;
}
.d-rot-b {
width: 80rpx;
height: 52rpx;
border: 2rpx solid #E5E7EB;
border-radius: 14rpx;
background-color: #FFFFFF;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.d-rot-b-t {
font-size: 22.8rpx;
color: #4B5563;
}
.d-rot-v {
width: 84rpx;
font-size: 25.8rpx;
font-weight: bold;
color: #2563EB;
text-align: center;
}
/* 走向实时预览:容器高 132rpx,长轴条 24rpx → 竖直居中 top = 54rpx */
.d-prev {
width: 100%;
height: 132rpx;
background-color: #FAFBFC;
border: 2rpx dashed #E5E7EB;
border-radius: 18rpx;
position: relative;
margin-top: 16rpx;
}
.d-prev-bar {
position: absolute;
top: 54rpx;
height: 24rpx;
border-radius: 12rpx;
opacity: 0.85;
}
.d-prev-dot {
position: absolute;
left: 50%;
top: 50%;
width: 44rpx;
height: 44rpx;
border-radius: 22rpx;
margin-left: -22rpx;
margin-top: -22rpx;
border: 3rpx solid #FFFFFF;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.d-prev-dot-t {
font-size: 23rpx;
font-weight: bold;
color: #FFFFFF;
}
.d-input {
width: 100%;
height: 72rpx;
background-color: #F9FAFB;
border: 2rpx solid #E5E7EB;
border-radius: 16rpx;
padding: 0rpx 20rpx 0rpx 20rpx;
font-size: 26.4rpx;
color: #1F2937;
}
.d-ta {
width: 100%;
height: 176rpx;
background-color: #F9FAFB;
border: 2rpx solid #E5E7EB;
border-radius: 18rpx;
padding: 18rpx 20rpx 18rpx 20rpx;
font-size: 26.4rpx;
color: #1F2937;
margin-top: 14rpx;
}
.d-del {
height: 80rpx;
background-color: #FEF2F2;
border: 2rpx solid #FCA5A5;
border-radius: 18rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.d-del-t {
font-size: 25.8rpx;
color: #DC2626;
}
.d-foot {
display: flex;
flex-direction: row;
align-items: center;
}
.d-foot-cancel {
flex: 0 0 220rpx;
margin-right: 18rpx;
}
.d-foot-ok {
flex: 1;
}
.d-foot-t {
font-size: 31.4rpx;
font-weight: bold;
color: #1F2937;
}
.d-foot-t2 {
font-size: 32.4rpx;
font-weight: bold;
color: #FFFFFF;
}
/* ---------- 生命体征 ---------- */
.c-warn {
display: flex;
flex-direction: row;
align-items: flex-start;
margin-top: 20rpx;
}
.c-warn-t {
flex: 1;
font-size: 24.6rpx;
color: #DC2626;
margin-left: 12rpx;
line-height: 1.5;
}
/* ---------- 评估下拉 ---------- */
/* 检伤评估四项下拉:设计稿中均为浅蓝填充的「已选」外观 */
.c-pick {
display: flex;
flex-direction: row;
align-items: center;
background-color: #EFF6FF;
border: 2rpx solid #BFDBFE;
border-radius: 22rpx;
padding: 20rpx 22rpx 20rpx 22rpx;
}
.c-pick-l {
font-size: 23.6rpx;
color: #3B6BC7;
}
.c-pick-v {
font-size: 28rpx;
font-weight: bold;
color: #1F2937;
margin-top: 4rpx;
}
.c-pick-v-empty {
color: #9CA3AF;
}
/* ---------- 多模态 ---------- */
.c-ta {
width: 100%;
background-color: #F9FAFB;
border: 2rpx solid #E5E7EB;
border-radius: 22rpx;
padding: 20rpx 20rpx 20rpx 20rpx;
font-size: 28rpx;
color: #1F2937;
height: 192rpx;
}
.c-count {
text-align: right;
font-size: 23.6rpx;
color: #9CA3AF;
margin: 8rpx 0;
}
.c-upls {
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin-top: 20rpx;
}
.c-upl {
width: 31%;
height: 168rpx;
border-radius: 22rpx;
background-color: #F9FAFB;
border: 2rpx solid #E5E7EB;
flex-direction: column;
align-items: center;
justify-content: center;
margin-right: 2.5%;
margin-bottom: 16rpx;
position: relative;
overflow: hidden;
}
.c-upl-add {
border: 3rpx dashed #C7CDD6;
background-color: #FFFFFF;
}
.c-upl-t {
font-size: 23.6rpx;
color: #9CA3AF;
margin-top: 8rpx;
}
.c-upl-tag {
position: absolute;
left: 0rpx;
right: 0rpx;
bottom: 0rpx;
padding: 4rpx 0rpx 4rpx 0rpx;
}
.c-upl-tag-t {
font-size: 21.2rpx;
color: #FFFFFF;
text-align: center;
}
.c-upl-del {
position: absolute;
right: 8rpx;
top: 8rpx;
width: 34rpx;
height: 34rpx;
border-radius: 18rpx;
background-color: rgba(17, 24, 39, 0.6);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.c-upl-hint {
font-size: 23.6rpx;
color: #9CA3AF;
margin-top: 4rpx;
}
/* ---------- 底部 ---------- */
.c-foot-draft {
flex: 0 0 220rpx;
margin-right: 18rpx;
}
.c-foot-t {
font-size: 31.4rpx;
font-weight: bold;
color: #1F2937;
}
.c-foot-t2 {
font-size: 32.4rpx;
font-weight: bold;
color: #FFFFFF;
}
</style>