EL 6ec2d0b0e0 新增 B 端设备模块(后端 CRUD、分页筛选、权限隔离)并接入前端设备管理页面与路由菜单
鉴权改为登录态回库校验,新增 tokenValidAfter 失效时间,支持密码变更与 seed 重置后旧 token 立即失效
患者字段由 idCardHash 统一迁移为 idCard,新增身份证标准化逻辑并同步 C 端生命周期查询参数
组织模块增加小组删除限制(有成员时返回 409)并补充中文错误消息
任务取消接口支持可选 reason 字段(先透传事件层)
补齐 Prisma 迁移、文档说明和 E2E 用例(含设备模块与 token 失效场景)
2026-03-18 20:23:55 +08:00

532 lines
14 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>
<div class="header-actions">
<el-form :inline="true" :model="searchForm" class="search-form">
<el-form-item label="所属医院" v-if="isSystemAdmin">
<el-select
v-model="searchForm.hospitalId"
clearable
filterable
placeholder="全部医院"
style="width: 220px"
@change="handleSearchHospitalChange"
>
<el-option
v-for="hospital in hospitals"
:key="hospital.id"
:label="hospital.name"
:value="hospital.id"
/>
</el-select>
</el-form-item>
<el-form-item label="归属患者">
<el-select
v-model="searchForm.patientId"
clearable
filterable
placeholder="全部患者"
style="width: 260px"
:disabled="isSystemAdmin && !searchForm.hospitalId"
>
<el-option
v-for="patient in searchPatients"
:key="patient.id"
:label="formatPatientLabel(patient)"
:value="patient.id"
/>
</el-select>
</el-form-item>
<el-form-item label="设备状态">
<el-select
v-model="searchForm.status"
clearable
placeholder="全部状态"
style="width: 160px"
>
<el-option
v-for="item in DEVICE_STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="关键词">
<el-input
v-model="searchForm.keyword"
clearable
placeholder="设备 SN / 患者姓名 / 手机号"
style="width: 260px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch" 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="80" align="center" />
<el-table-column prop="snCode" label="设备 SN" min-width="180" />
<el-table-column
prop="currentPressure"
label="当前压力"
width="120"
align="center"
/>
<el-table-column label="设备状态" width="120" align="center">
<template #default="{ row }">
<el-tag :type="getStatusTagType(row.status)">
{{ getStatusName(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="归属患者" min-width="140">
<template #default="{ row }">
{{ row.patient?.name || '-' }}
</template>
</el-table-column>
<el-table-column label="患者手机号" min-width="150">
<template #default="{ row }">
{{ row.patient?.phone || '-' }}
</template>
</el-table-column>
<el-table-column label="所属医院" min-width="160">
<template #default="{ row }">
{{ row.patient?.hospital?.name || '-' }}
</template>
</el-table-column>
<el-table-column label="归属医生" min-width="140">
<template #default="{ row }">
{{ row.patient?.doctor?.name || '-' }}
</template>
</el-table-column>
<el-table-column label="关联任务数" width="120" align="center">
<template #default="{ row }">
{{ row._count?.taskItems ?? 0 }}
</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>
<div class="pagination-container">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
background
layout="total, sizes, prev, pager, next, jumper"
@size-change="fetchData"
@current-change="fetchData"
/>
</div>
</el-card>
<el-dialog
:title="isEdit ? '编辑设备' : '新增设备'"
v-model="dialogVisible"
width="560px"
@close="resetForm"
>
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
<el-form-item label="所属医院" prop="hospitalId" v-if="isSystemAdmin">
<el-select
v-model="form.hospitalId"
filterable
placeholder="请选择医院"
style="width: 100%"
@change="handleFormHospitalChange"
>
<el-option
v-for="hospital in hospitals"
:key="hospital.id"
:label="hospital.name"
:value="hospital.id"
/>
</el-select>
</el-form-item>
<el-form-item label="归属患者" prop="patientId">
<el-select
v-model="form.patientId"
filterable
placeholder="请选择患者"
style="width: 100%"
:disabled="isSystemAdmin && !form.hospitalId"
>
<el-option
v-for="patient in formPatients"
:key="patient.id"
:label="formatPatientLabel(patient)"
:value="patient.id"
/>
</el-select>
</el-form-item>
<el-form-item label="设备 SN" prop="snCode">
<el-input
v-model="form.snCode"
placeholder="请输入设备 SN"
maxlength="64"
/>
</el-form-item>
<el-form-item label="当前压力" prop="currentPressure">
<el-input-number
v-model="form.currentPressure"
:min="0"
:step="1"
:controls="false"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="设备状态" prop="status">
<el-select
v-model="form.status"
placeholder="请选择状态"
style="width: 100%"
>
<el-option
v-for="item in DEVICE_STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</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"
>
确定
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
getDevices,
createDevice,
updateDevice,
deleteDevice,
} from '../../api/devices';
import { getHospitals } from '../../api/organization';
import { getPatients } from '../../api/patients';
import { useUserStore } from '../../store/user';
const userStore = useUserStore();
const DEVICE_STATUS_OPTIONS = [
{ label: '启用', value: 'ACTIVE' },
{ label: '停用', value: 'INACTIVE' },
];
const isSystemAdmin = computed(() => userStore.role === 'SYSTEM_ADMIN');
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 hospitals = ref([]);
const searchPatients = ref([]);
const formPatients = ref([]);
const tableData = ref([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
const searchForm = reactive({
hospitalId: null,
patientId: null,
status: '',
keyword: '',
});
const form = reactive({
hospitalId: null,
patientId: null,
snCode: '',
currentPressure: 0,
status: 'ACTIVE',
});
const rules = computed(() => ({
hospitalId: isSystemAdmin.value
? [{ required: true, message: '请选择所属医院', trigger: 'change' }]
: [],
patientId: [{ required: true, message: '请选择归属患者', trigger: 'change' }],
snCode: [{ required: true, message: '请输入设备 SN', trigger: 'blur' }],
currentPressure: [
{ required: true, message: '请输入当前压力', trigger: 'blur' },
],
status: [{ required: true, message: '请选择设备状态', trigger: 'change' }],
}));
const getStatusName = (status) => {
return (
DEVICE_STATUS_OPTIONS.find((item) => item.value === status)?.label || status
);
};
const getStatusTagType = (status) => {
return status === 'ACTIVE' ? 'success' : 'info';
};
const formatPatientLabel = (patient) => {
const hospitalName = patient.hospital?.name
? ` / ${patient.hospital.name}`
: '';
return `${patient.name}${patient.phone}${hospitalName}`;
};
const fetchHospitals = async () => {
if (!isSystemAdmin.value) {
return;
}
const res = await getHospitals({ page: 1, pageSize: 100 });
hospitals.value = res.list || [];
};
// 搜索区患者下拉只跟筛选条件联动,避免和弹窗下拉状态互相干扰。
const fetchSearchPatients = async () => {
if (isSystemAdmin.value && !searchForm.hospitalId) {
searchPatients.value = [];
searchForm.patientId = null;
return;
}
const params = {};
if (isSystemAdmin.value) {
params.hospitalId = searchForm.hospitalId;
}
const res = await getPatients(params);
searchPatients.value = Array.isArray(res) ? res : [];
if (!searchPatients.value.some((item) => item.id === searchForm.patientId)) {
searchForm.patientId = null;
}
};
// 表单患者下拉按当前选择的医院动态刷新,确保 patientId 可写且合法。
const fetchFormPatients = async (hospitalId = form.hospitalId) => {
if (isSystemAdmin.value && !hospitalId) {
formPatients.value = [];
form.patientId = null;
return;
}
const params = {};
if (isSystemAdmin.value) {
params.hospitalId = hospitalId;
}
const res = await getPatients(params);
formPatients.value = Array.isArray(res) ? res : [];
if (!formPatients.value.some((item) => item.id === form.patientId)) {
form.patientId = null;
}
};
const fetchData = async () => {
loading.value = true;
try {
const params = {
page: page.value,
pageSize: pageSize.value,
keyword: searchForm.keyword || undefined,
status: searchForm.status || undefined,
patientId: searchForm.patientId || undefined,
};
if (isSystemAdmin.value && searchForm.hospitalId) {
params.hospitalId = searchForm.hospitalId;
}
const res = await getDevices(params);
tableData.value = res.list || [];
total.value = res.total || 0;
} finally {
loading.value = false;
}
};
const handleSearchHospitalChange = async () => {
page.value = 1;
await fetchSearchPatients();
await fetchData();
};
const handleFormHospitalChange = async (hospitalId) => {
form.patientId = null;
await fetchFormPatients(hospitalId);
};
const handleSearch = () => {
page.value = 1;
fetchData();
};
const resetSearch = async () => {
searchForm.hospitalId = null;
searchForm.patientId = null;
searchForm.status = '';
searchForm.keyword = '';
page.value = 1;
await fetchSearchPatients();
await fetchData();
};
const resetForm = () => {
formRef.value?.resetFields();
form.hospitalId = null;
form.patientId = null;
form.snCode = '';
form.currentPressure = 0;
form.status = 'ACTIVE';
currentId.value = null;
formPatients.value = [];
};
const openCreateDialog = async () => {
isEdit.value = false;
resetForm();
// 系统管理员可沿用当前筛选医院,院管则固定为本人医院。
if (isSystemAdmin.value) {
form.hospitalId = searchForm.hospitalId || null;
} else {
form.hospitalId = userStore.userInfo?.hospitalId || null;
}
await fetchFormPatients(form.hospitalId);
dialogVisible.value = true;
};
const openEditDialog = async (row) => {
isEdit.value = true;
currentId.value = row.id;
form.snCode = row.snCode;
form.currentPressure = row.currentPressure;
form.status = row.status;
form.hospitalId =
row.patient?.hospital?.id || row.patient?.hospitalId || null;
await fetchFormPatients(form.hospitalId);
form.patientId = row.patient?.id || null;
dialogVisible.value = true;
};
const handleSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (!valid) return;
submitLoading.value = true;
try {
const payload = {
snCode: form.snCode,
currentPressure: Number(form.currentPressure),
status: form.status,
patientId: form.patientId,
};
if (isEdit.value) {
await updateDevice(currentId.value, payload);
ElMessage.success('更新成功');
} else {
await createDevice(payload);
ElMessage.success('创建成功');
}
dialogVisible.value = false;
await fetchData();
} finally {
submitLoading.value = false;
}
});
};
const handleDelete = (row) => {
ElMessageBox.confirm(`确定要删除设备 "${row.snCode}" 吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(async () => {
await deleteDevice(row.id);
ElMessage.success('删除成功');
await fetchData();
})
.catch(() => {});
};
onMounted(async () => {
await fetchHospitals();
await fetchSearchPatients();
await fetchData();
});
</script>
<style scoped>
.devices-container {
padding: 0;
}
.header-actions {
margin-bottom: 20px;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>