鉴权改为登录态回库校验,新增 tokenValidAfter 失效时间,支持密码变更与 seed 重置后旧 token 立即失效 患者字段由 idCardHash 统一迁移为 idCard,新增身份证标准化逻辑并同步 C 端生命周期查询参数 组织模块增加小组删除限制(有成员时返回 409)并补充中文错误消息 任务取消接口支持可选 reason 字段(先透传事件层) 补齐 Prisma 迁移、文档说明和 E2E 用例(含设备模块与 token 失效场景)
532 lines
14 KiB
Vue
532 lines
14 KiB
Vue
<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>
|