EL 0b5640a977 调压任务流程从“发布即指派”改为“发布待接收(PENDING) -> 工程师接收(ACCEPTED) -> 完成(COMPLETED)”。
新增工程师“取消接收”能力,任务可从 ACCEPTED 回退到 PENDING。
发布任务不再要求 engineerId,并增加同设备存在未结束任务时的重复发布拦截。
完成任务新增 completionMaterials 必填校验,仅允许图片/视频凭证,并在完成时落库。
植入物目录新增 isValve,区分阀门与管子;非阀门不维护压力挡位,阀门至少 1 个挡位。
患者设备与任务查询返回新增字段,前端任务页支持接收/取消接收/上传凭证后完成。
增补 Prisma 迁移、接口文档、E2E 用例与夹具修复逻辑。
2026-03-20 06:03:09 +08:00

518 lines
13 KiB
Vue
Raw 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>
<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>