新增工程师“取消接收”能力,任务可从 ACCEPTED 回退到 PENDING。 发布任务不再要求 engineerId,并增加同设备存在未结束任务时的重复发布拦截。 完成任务新增 completionMaterials 必填校验,仅允许图片/视频凭证,并在完成时落库。 植入物目录新增 isValve,区分阀门与管子;非阀门不维护压力挡位,阀门至少 1 个挡位。 患者设备与任务查询返回新增字段,前端任务页支持接收/取消接收/上传凭证后完成。 增补 Prisma 迁移、接口文档、E2E 用例与夹具修复逻辑。
518 lines
13 KiB
Vue
518 lines
13 KiB
Vue
<template>
|
||
<div class="devices-container">
|
||
<el-card class="panel-card">
|
||
<template #header>
|
||
<div class="panel-head">
|
||
<div>
|
||
<div class="panel-title">植入物目录</div>
|
||
<div class="panel-subtitle">
|
||
这里维护患者手术里可选的全局植入物目录,不再按医院或患者单独建档
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<el-alert
|
||
type="info"
|
||
:closable="false"
|
||
class="page-alert"
|
||
title="一个目录项可被多个患者手术重复绑定;患者手术里形成的是患者自己的植入记录,不会占用或锁定目录。"
|
||
/>
|
||
|
||
<div class="toolbar">
|
||
<el-form :inline="true" :model="searchForm">
|
||
<el-form-item label="关键词">
|
||
<el-input
|
||
v-model="searchForm.keyword"
|
||
clearable
|
||
placeholder="型号编码 / 厂家 / 名称"
|
||
style="width: 280px"
|
||
/>
|
||
</el-form-item>
|
||
|
||
<el-form-item>
|
||
<el-button type="primary" @click="fetchData" icon="Search">
|
||
查询
|
||
</el-button>
|
||
<el-button @click="resetSearch" icon="Refresh">重置</el-button>
|
||
<el-button type="success" @click="openCreateDialog" icon="Plus">
|
||
新增植入物
|
||
</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
</div>
|
||
|
||
<el-table
|
||
:data="tableData"
|
||
v-loading="loading"
|
||
border
|
||
stripe
|
||
style="width: 100%"
|
||
>
|
||
<el-table-column prop="id" label="ID" width="90" align="center" />
|
||
<el-table-column prop="modelCode" label="型号编码" min-width="180" />
|
||
<el-table-column prop="manufacturer" label="厂家" min-width="180" />
|
||
<el-table-column prop="name" label="名称" min-width="180" />
|
||
<el-table-column label="类型" width="110" align="center">
|
||
<template #default="{ row }">
|
||
<el-tag :type="row.isValve === false ? 'info' : 'success'">
|
||
{{ row.isValve === false ? '管子' : '阀门' }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="挡位" min-width="220">
|
||
<template #default="{ row }">
|
||
<div
|
||
v-if="row.isValve !== false && row.pressureLevels?.length"
|
||
class="pressure-tag-list"
|
||
>
|
||
<el-tag
|
||
v-for="level in row.pressureLevels"
|
||
:key="`${row.id}-${level}`"
|
||
size="small"
|
||
type="warning"
|
||
>
|
||
{{ level }}
|
||
</el-tag>
|
||
</div>
|
||
<span v-else>-</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="备注" min-width="220">
|
||
<template #default="{ row }">
|
||
{{ row.notes || '-' }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="更新时间" min-width="180">
|
||
<template #default="{ row }">
|
||
{{ formatDateTime(row.updatedAt) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="180" fixed="right" align="center">
|
||
<template #default="{ row }">
|
||
<el-button size="small" type="primary" @click="openEditDialog(row)">
|
||
编辑
|
||
</el-button>
|
||
<el-button size="small" type="danger" @click="handleDelete(row)">
|
||
删除
|
||
</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</el-card>
|
||
|
||
<el-dialog
|
||
:title="isEdit ? '编辑植入物目录' : '新增植入物目录'"
|
||
v-model="dialogVisible"
|
||
width="760px"
|
||
destroy-on-close
|
||
@close="resetForm"
|
||
>
|
||
<el-form :model="form" :rules="rules" ref="formRef" label-width="110px">
|
||
<el-row :gutter="16">
|
||
<el-col :xs="24" :md="12">
|
||
<el-form-item label="型号编码" prop="modelCode">
|
||
<el-input
|
||
v-model="form.modelCode"
|
||
maxlength="64"
|
||
placeholder="请输入型号编码"
|
||
/>
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :xs="24" :md="12">
|
||
<el-form-item label="厂家" prop="manufacturer">
|
||
<el-input
|
||
v-model="form.manufacturer"
|
||
maxlength="100"
|
||
placeholder="请输入厂家"
|
||
/>
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :xs="24" :md="12">
|
||
<el-form-item label="名称" prop="name">
|
||
<el-input
|
||
v-model="form.name"
|
||
maxlength="100"
|
||
placeholder="请输入植入物名称"
|
||
/>
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :xs="24" :md="12">
|
||
<el-form-item label="阀门">
|
||
<el-switch
|
||
v-model="form.isValve"
|
||
active-text="是"
|
||
inactive-text="否"
|
||
@change="handleValveToggle"
|
||
/>
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
|
||
<el-form-item v-if="form.isValve" label="压力挡位">
|
||
<div class="pressure-level-panel">
|
||
<div
|
||
v-for="(level, index) in form.pressureLevels"
|
||
:key="`pressure-level-${index}`"
|
||
class="pressure-level-row"
|
||
>
|
||
<el-input
|
||
v-model="form.pressureLevels[index]"
|
||
placeholder="请输入挡位标签,例如 0.5 / 1 / 1.5"
|
||
style="width: 180px"
|
||
/>
|
||
<el-button
|
||
type="danger"
|
||
plain
|
||
@click="removePressureLevel(index)"
|
||
:disabled="form.pressureLevels.length === 1"
|
||
>
|
||
删除
|
||
</el-button>
|
||
</div>
|
||
|
||
<el-button type="primary" plain @click="addPressureLevel">
|
||
新增挡位
|
||
</el-button>
|
||
<div class="field-hint">
|
||
每个挡位填一个标签值,保存时会自动标准化、去重并按从小到大排序
|
||
</div>
|
||
</div>
|
||
</el-form-item>
|
||
<el-alert
|
||
v-else
|
||
type="info"
|
||
:closable="false"
|
||
title="当前目录项为管子或附件,不需要维护压力挡位。"
|
||
/>
|
||
|
||
<el-form-item label="备注">
|
||
<el-input
|
||
v-model="form.notes"
|
||
type="textarea"
|
||
:rows="3"
|
||
maxlength="200"
|
||
show-word-limit
|
||
placeholder="可填写适用说明、材质、适配场景等"
|
||
/>
|
||
</el-form-item>
|
||
</el-form>
|
||
|
||
<template #footer>
|
||
<div class="dialog-footer">
|
||
<el-button @click="dialogVisible = false">取消</el-button>
|
||
<el-button
|
||
type="primary"
|
||
:loading="submitLoading"
|
||
@click="handleSubmit"
|
||
>
|
||
{{ isEdit ? '保存修改' : '创建目录' }}
|
||
</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { onMounted, reactive, ref } from 'vue';
|
||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||
import {
|
||
createImplantCatalog,
|
||
deleteImplantCatalog,
|
||
getImplantCatalogs,
|
||
updateImplantCatalog,
|
||
} from '../../api/devices';
|
||
|
||
const loading = ref(false);
|
||
const submitLoading = ref(false);
|
||
const dialogVisible = ref(false);
|
||
const isEdit = ref(false);
|
||
const formRef = ref(null);
|
||
const currentId = ref(null);
|
||
const tableData = ref([]);
|
||
|
||
const searchForm = reactive({
|
||
keyword: '',
|
||
});
|
||
|
||
const form = reactive(createDefaultForm());
|
||
|
||
const rules = {
|
||
modelCode: [{ required: true, message: '请输入型号编码', trigger: 'blur' }],
|
||
manufacturer: [{ required: true, message: '请输入厂家', trigger: 'blur' }],
|
||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||
};
|
||
|
||
function createDefaultForm() {
|
||
return {
|
||
modelCode: '',
|
||
manufacturer: '',
|
||
name: '',
|
||
isValve: true,
|
||
pressureLevels: [''],
|
||
notes: '',
|
||
};
|
||
}
|
||
|
||
function formatDateTime(value) {
|
||
if (!value) {
|
||
return '-';
|
||
}
|
||
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) {
|
||
return '-';
|
||
}
|
||
|
||
return date.toLocaleString('zh-CN', { hour12: false });
|
||
}
|
||
|
||
function normalizePressureLevels(levels) {
|
||
const normalized = Array.from(
|
||
new Set(
|
||
(Array.isArray(levels) ? levels : [])
|
||
.map((level) => normalizePressureLabel(level))
|
||
.filter(Boolean),
|
||
),
|
||
);
|
||
|
||
return normalized.sort((left, right) => {
|
||
const leftNumber = Number(left);
|
||
const rightNumber = Number(right);
|
||
if (leftNumber !== rightNumber) {
|
||
return leftNumber - rightNumber;
|
||
}
|
||
return String(left).localeCompare(String(right), 'en');
|
||
});
|
||
}
|
||
|
||
function normalizePressureLabel(value) {
|
||
const raw = String(value ?? '').trim();
|
||
if (!raw || !/^\d+(\.\d+)?$/.test(raw)) {
|
||
return '';
|
||
}
|
||
|
||
const [integerPart, fractionPart = ''] = raw.split('.');
|
||
const normalizedInteger = integerPart.replace(/^0+(?=\d)/, '') || '0';
|
||
const normalizedFraction = fractionPart.replace(/0+$/, '');
|
||
|
||
return normalizedFraction
|
||
? `${normalizedInteger}.${normalizedFraction}`
|
||
: normalizedInteger;
|
||
}
|
||
|
||
async function fetchData() {
|
||
loading.value = true;
|
||
try {
|
||
const res = await getImplantCatalogs({
|
||
keyword: searchForm.keyword || undefined,
|
||
});
|
||
tableData.value = Array.isArray(res) ? res : [];
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
async function resetSearch() {
|
||
searchForm.keyword = '';
|
||
await fetchData();
|
||
}
|
||
|
||
function resetForm() {
|
||
formRef.value?.clearValidate?.();
|
||
const next = createDefaultForm();
|
||
form.modelCode = next.modelCode;
|
||
form.manufacturer = next.manufacturer;
|
||
form.name = next.name;
|
||
form.isValve = next.isValve;
|
||
form.pressureLevels = next.pressureLevels;
|
||
form.notes = next.notes;
|
||
currentId.value = null;
|
||
}
|
||
|
||
function handleValveToggle(value) {
|
||
if (!value) {
|
||
form.pressureLevels = [''];
|
||
return;
|
||
}
|
||
|
||
if (!Array.isArray(form.pressureLevels) || form.pressureLevels.length === 0) {
|
||
form.pressureLevels = [''];
|
||
}
|
||
}
|
||
|
||
function addPressureLevel() {
|
||
form.pressureLevels.push('');
|
||
}
|
||
|
||
function removePressureLevel(index) {
|
||
if (form.pressureLevels.length === 1) {
|
||
form.pressureLevels.splice(index, 1, '');
|
||
return;
|
||
}
|
||
form.pressureLevels.splice(index, 1);
|
||
}
|
||
|
||
function openCreateDialog() {
|
||
isEdit.value = false;
|
||
resetForm();
|
||
dialogVisible.value = true;
|
||
}
|
||
|
||
function openEditDialog(row) {
|
||
isEdit.value = true;
|
||
formRef.value?.clearValidate?.();
|
||
currentId.value = row.id;
|
||
form.modelCode = row.modelCode || '';
|
||
form.manufacturer = row.manufacturer || '';
|
||
form.name = row.name || '';
|
||
form.isValve = row.isValve !== false;
|
||
form.pressureLevels =
|
||
row.isValve !== false &&
|
||
Array.isArray(row.pressureLevels) &&
|
||
row.pressureLevels.length > 0
|
||
? [...row.pressureLevels]
|
||
: [''];
|
||
form.notes = row.notes || '';
|
||
dialogVisible.value = true;
|
||
}
|
||
|
||
async function handleSubmit() {
|
||
if (!formRef.value) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await formRef.value.validate();
|
||
} catch {
|
||
return;
|
||
}
|
||
|
||
const normalizedLevels = normalizePressureLevels(form.pressureLevels);
|
||
if (
|
||
form.isValve &&
|
||
normalizedLevels.length !==
|
||
form.pressureLevels.filter((level) => String(level ?? '').trim()).length
|
||
) {
|
||
ElMessage.warning('挡位格式不合法,请输入数字或一位小数字符串');
|
||
return;
|
||
}
|
||
if (form.isValve && normalizedLevels.length === 0) {
|
||
ElMessage.warning('请至少录入一个挡位');
|
||
return;
|
||
}
|
||
|
||
submitLoading.value = true;
|
||
try {
|
||
const payload = {
|
||
modelCode: form.modelCode,
|
||
manufacturer: form.manufacturer,
|
||
name: form.name,
|
||
isValve: form.isValve,
|
||
pressureLevels: form.isValve ? normalizedLevels : [],
|
||
notes: form.notes || undefined,
|
||
};
|
||
|
||
if (isEdit.value) {
|
||
await updateImplantCatalog(currentId.value, payload);
|
||
ElMessage.success('植入物目录已更新');
|
||
} else {
|
||
await createImplantCatalog(payload);
|
||
ElMessage.success('植入物目录已创建');
|
||
}
|
||
|
||
dialogVisible.value = false;
|
||
await fetchData();
|
||
} finally {
|
||
submitLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function handleDelete(row) {
|
||
await ElMessageBox.confirm(
|
||
`确认删除植入物目录「${row.name}」吗?已绑定到患者手术的目录项将无法删除。`,
|
||
'删除确认',
|
||
{
|
||
type: 'warning',
|
||
confirmButtonText: '删除',
|
||
cancelButtonText: '取消',
|
||
},
|
||
);
|
||
|
||
await deleteImplantCatalog(row.id);
|
||
ElMessage.success('植入物目录已删除');
|
||
await fetchData();
|
||
}
|
||
|
||
onMounted(() => {
|
||
fetchData();
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.devices-container {
|
||
padding: 0;
|
||
}
|
||
|
||
.panel-card {
|
||
border-radius: 18px;
|
||
}
|
||
|
||
.panel-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.panel-title {
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
color: #1f2937;
|
||
}
|
||
|
||
.panel-subtitle {
|
||
margin-top: 6px;
|
||
font-size: 13px;
|
||
color: #6b7280;
|
||
}
|
||
|
||
.page-alert {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.toolbar {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.pressure-tag-list {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
}
|
||
|
||
.pressure-level-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
width: 100%;
|
||
}
|
||
|
||
.pressure-level-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.field-hint {
|
||
font-size: 12px;
|
||
color: #6b7280;
|
||
}
|
||
|
||
.dialog-footer {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 12px;
|
||
}
|
||
</style>
|