信息架构/菜单框架大重构-20260109
This commit is contained in:
@@ -0,0 +1,525 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">受试者详情</h1>
|
||||
<p class="page-subtitle">查看病史、访视与 AE 信息</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button type="primary" @click="goEdit">编辑</el-button>
|
||||
<el-button @click="goBack">返回</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card v-loading="loading">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="受试者编号">{{ detail.subject_no || "—" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="分中心">{{ siteMap[detail.site_id] || "—" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ detail.status || "—" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="筛选日期">{{ displayDate(detail.screening_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="入组日期">{{ displayDate(detail.enrollment_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="完成日期">{{ displayDate(detail.completion_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="退出原因" :span="2">{{ detail.drop_reason || "—" }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-card>
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="病史" name="history">
|
||||
<div class="tab-actions">
|
||||
<el-button type="primary" @click="openHistoryDialog()">新增病史</el-button>
|
||||
</div>
|
||||
<el-table :data="historyItems" v-loading="loadingHistory" style="width: 100%">
|
||||
<el-table-column prop="record_date" label="日期" width="140">
|
||||
<template #default="scope">{{ displayDate(scope.row.record_date) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="content" label="内容" min-width="240" />
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="scope">
|
||||
<el-button link type="primary" size="small" @click="openHistoryDialog(scope.row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="removeHistory(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<StateEmpty v-if="!loadingHistory && historyItems.length === 0" description="暂无病史记录" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="访视" name="visits">
|
||||
<div class="tab-actions">
|
||||
<el-button type="primary" @click="openVisitDialog()">新增访视</el-button>
|
||||
</div>
|
||||
<el-table :data="visitItems" v-loading="loadingVisits" style="width: 100%">
|
||||
<el-table-column prop="visit_code" label="访视编号" width="120" />
|
||||
<el-table-column prop="visit_name" label="访视名称" min-width="140" />
|
||||
<el-table-column prop="planned_date" label="计划日期" width="140">
|
||||
<template #default="scope">{{ displayDate(scope.row.planned_date) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="actual_date" label="实际日期" width="140">
|
||||
<template #default="scope">{{ displayDate(scope.row.actual_date) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="120" />
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="scope">
|
||||
<el-button link type="primary" size="small" @click="openVisitDialog(scope.row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="removeVisit(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<StateEmpty v-if="!loadingVisits && visitItems.length === 0" description="暂无访视记录" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="AE 信息" name="ae">
|
||||
<div class="tab-actions">
|
||||
<el-button type="primary" @click="openAeDialog()">新增 AE</el-button>
|
||||
</div>
|
||||
<el-table :data="aeItems" v-loading="loadingAes" style="width: 100%">
|
||||
<el-table-column prop="onset_date" label="发生日期" width="140">
|
||||
<template #default="scope">{{ displayDate(scope.row.onset_date) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="term" label="事件" min-width="140" />
|
||||
<el-table-column prop="seriousness" label="严重程度" width="120" />
|
||||
<el-table-column prop="causality" label="关联性" width="120" />
|
||||
<el-table-column prop="outcome" label="结局" width="120" />
|
||||
<el-table-column prop="description" label="备注" min-width="200" />
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="scope">
|
||||
<el-button link type="primary" size="small" @click="openAeDialog(scope.row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="removeAe(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<StateEmpty v-if="!loadingAes && aeItems.length === 0" description="暂无 AE 记录" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="historyDialogVisible" title="病史记录" width="520px">
|
||||
<el-form :model="historyForm" label-width="90px">
|
||||
<el-form-item label="日期">
|
||||
<el-date-picker v-model="historyForm.record_date" type="date" value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
<el-form-item label="内容" required>
|
||||
<el-input v-model="historyForm.content" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="historyDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveHistory">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="visitDialogVisible" title="访视记录" width="560px">
|
||||
<el-form :model="visitForm" label-width="100px">
|
||||
<template v-if="!visitEditingId">
|
||||
<el-form-item label="访视编号" required>
|
||||
<el-input v-model="visitForm.visit_code" />
|
||||
</el-form-item>
|
||||
<el-form-item label="访视名称" required>
|
||||
<el-input v-model="visitForm.visit_name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="计划日期">
|
||||
<el-date-picker v-model="visitForm.planned_date" type="date" value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
<el-form-item label="窗口开始">
|
||||
<el-date-picker v-model="visitForm.window_start" type="date" value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
<el-form-item label="窗口结束">
|
||||
<el-date-picker v-model="visitForm.window_end" type="date" value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-form-item label="实际日期">
|
||||
<el-date-picker v-model="visitForm.actual_date" type="date" value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="visitForm.status">
|
||||
<el-option label="计划中" value="PLANNED" />
|
||||
<el-option label="已完成" value="DONE" />
|
||||
<el-option label="未完成" value="MISSED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="visitForm.notes" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visitDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveVisit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="aeDialogVisible" title="AE 记录" width="560px">
|
||||
<el-form :model="aeForm" label-width="100px">
|
||||
<el-form-item label="事件" required>
|
||||
<el-input v-model="aeForm.term" />
|
||||
</el-form-item>
|
||||
<el-form-item label="发生日期">
|
||||
<el-date-picker v-model="aeForm.onset_date" type="date" value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
<el-form-item label="严重程度" required>
|
||||
<el-select v-model="aeForm.seriousness">
|
||||
<el-option label="严重" value="SERIOUS" />
|
||||
<el-option label="一般" value="NON_SERIOUS" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="严重等级" required>
|
||||
<el-select v-model="aeForm.severity">
|
||||
<el-option label="轻度" value="MILD" />
|
||||
<el-option label="中度" value="MODERATE" />
|
||||
<el-option label="重度" value="SEVERE" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关联性">
|
||||
<el-input v-model="aeForm.causality" />
|
||||
</el-form-item>
|
||||
<el-form-item label="结局">
|
||||
<el-input v-model="aeForm.outcome" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="aeForm.description" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="aeDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveAe">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { getSubject } from "../../api/subjects";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import { listSubjectHistories, createSubjectHistory, updateSubjectHistory, deleteSubjectHistory } from "../../api/subjectHistories";
|
||||
import { fetchVisits, createVisit, updateVisit, deleteVisit } from "../../api/visits";
|
||||
import { fetchAes, createAe, updateAe, deleteAe } from "../../api/aes";
|
||||
import { displayDate } from "../../utils/display";
|
||||
import StateEmpty from "../../components/StateEmpty.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const study = useStudyStore();
|
||||
const subjectId = route.params.subjectId as string;
|
||||
const studyId = study.currentStudy?.id || "";
|
||||
|
||||
const loading = ref(false);
|
||||
const detail = reactive<any>({
|
||||
subject_no: "",
|
||||
site_id: "",
|
||||
status: "",
|
||||
screening_date: "",
|
||||
enrollment_date: "",
|
||||
completion_date: "",
|
||||
drop_reason: "",
|
||||
});
|
||||
|
||||
const siteMap = ref<Record<string, string>>({});
|
||||
const activeTab = ref("history");
|
||||
|
||||
const historyItems = ref<any[]>([]);
|
||||
const loadingHistory = ref(false);
|
||||
const historyDialogVisible = ref(false);
|
||||
const historyEditingId = ref<string | null>(null);
|
||||
const historyForm = reactive({
|
||||
record_date: "",
|
||||
content: "",
|
||||
});
|
||||
|
||||
const visitItems = ref<any[]>([]);
|
||||
const loadingVisits = ref(false);
|
||||
const visitDialogVisible = ref(false);
|
||||
const visitEditingId = ref<string | null>(null);
|
||||
const visitForm = reactive<any>({
|
||||
visit_code: "",
|
||||
visit_name: "",
|
||||
planned_date: "",
|
||||
window_start: "",
|
||||
window_end: "",
|
||||
actual_date: "",
|
||||
status: "PLANNED",
|
||||
notes: "",
|
||||
});
|
||||
|
||||
const aeItems = ref<any[]>([]);
|
||||
const loadingAes = ref(false);
|
||||
const aeDialogVisible = ref(false);
|
||||
const aeEditingId = ref<string | null>(null);
|
||||
const aeForm = reactive({
|
||||
term: "",
|
||||
onset_date: "",
|
||||
seriousness: "NON_SERIOUS",
|
||||
severity: "MILD",
|
||||
causality: "",
|
||||
outcome: "",
|
||||
description: "",
|
||||
});
|
||||
|
||||
const loadSites = async () => {
|
||||
if (!studyId) return;
|
||||
try {
|
||||
const { data } = await fetchSites(studyId, { limit: 500 });
|
||||
const list = Array.isArray(data) ? data : data.items || [];
|
||||
siteMap.value = list.reduce((acc: Record<string, string>, site: any) => {
|
||||
acc[site.id] = site.name;
|
||||
return acc;
|
||||
}, {});
|
||||
} catch {
|
||||
siteMap.value = {};
|
||||
}
|
||||
};
|
||||
|
||||
const loadSubject = async () => {
|
||||
if (!studyId || !subjectId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await getSubject(studyId, subjectId);
|
||||
Object.assign(detail, data);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadHistories = async () => {
|
||||
if (!studyId || !subjectId) return;
|
||||
loadingHistory.value = true;
|
||||
try {
|
||||
const { data } = await listSubjectHistories(studyId, subjectId);
|
||||
historyItems.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "病史加载失败");
|
||||
} finally {
|
||||
loadingHistory.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openHistoryDialog = (row?: any) => {
|
||||
historyEditingId.value = row?.id || null;
|
||||
historyForm.record_date = row?.record_date || "";
|
||||
historyForm.content = row?.content || "";
|
||||
historyDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const saveHistory = async () => {
|
||||
if (!studyId || !subjectId) return;
|
||||
try {
|
||||
const payload = {
|
||||
subject_id: subjectId,
|
||||
record_date: historyForm.record_date || null,
|
||||
content: historyForm.content,
|
||||
};
|
||||
if (historyEditingId.value) {
|
||||
await updateSubjectHistory(studyId, subjectId, historyEditingId.value, payload);
|
||||
} else {
|
||||
await createSubjectHistory(studyId, subjectId, payload);
|
||||
}
|
||||
historyDialogVisible.value = false;
|
||||
loadHistories();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
const removeHistory = async (row: any) => {
|
||||
if (!studyId || !subjectId) return;
|
||||
const ok = await ElMessageBox.confirm("确认删除该病史记录?", "提示").catch(() => null);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deleteSubjectHistory(studyId, subjectId, row.id);
|
||||
loadHistories();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
const loadVisits = async () => {
|
||||
if (!studyId || !subjectId) return;
|
||||
loadingVisits.value = true;
|
||||
try {
|
||||
const { data } = await fetchVisits(studyId, subjectId);
|
||||
visitItems.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "访视加载失败");
|
||||
} finally {
|
||||
loadingVisits.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openVisitDialog = (row?: any) => {
|
||||
visitEditingId.value = row?.id || null;
|
||||
if (row) {
|
||||
visitForm.actual_date = row.actual_date || "";
|
||||
visitForm.status = row.status || "PLANNED";
|
||||
visitForm.notes = row.notes || "";
|
||||
} else {
|
||||
visitForm.visit_code = "";
|
||||
visitForm.visit_name = "";
|
||||
visitForm.planned_date = "";
|
||||
visitForm.window_start = "";
|
||||
visitForm.window_end = "";
|
||||
visitForm.actual_date = "";
|
||||
visitForm.status = "PLANNED";
|
||||
visitForm.notes = "";
|
||||
}
|
||||
visitDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const saveVisit = async () => {
|
||||
if (!studyId || !subjectId) return;
|
||||
try {
|
||||
if (visitEditingId.value) {
|
||||
const payload = {
|
||||
actual_date: visitForm.actual_date || null,
|
||||
status: visitForm.status || null,
|
||||
notes: visitForm.notes || null,
|
||||
};
|
||||
await updateVisit(studyId, subjectId, visitEditingId.value, payload);
|
||||
} else {
|
||||
const payload = {
|
||||
subject_id: subjectId,
|
||||
visit_code: visitForm.visit_code,
|
||||
visit_name: visitForm.visit_name,
|
||||
planned_date: visitForm.planned_date || null,
|
||||
window_start: visitForm.window_start || null,
|
||||
window_end: visitForm.window_end || null,
|
||||
};
|
||||
await createVisit(studyId, subjectId, payload);
|
||||
}
|
||||
visitDialogVisible.value = false;
|
||||
loadVisits();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
const removeVisit = async (row: any) => {
|
||||
if (!studyId || !subjectId) return;
|
||||
const ok = await ElMessageBox.confirm("确认删除该访视记录?", "提示").catch(() => null);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deleteVisit(studyId, subjectId, row.id);
|
||||
loadVisits();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
const loadAes = async () => {
|
||||
if (!studyId || !subjectId) return;
|
||||
loadingAes.value = true;
|
||||
try {
|
||||
const { data } = await fetchAes(studyId, { subject_id: subjectId });
|
||||
aeItems.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "AE 加载失败");
|
||||
} finally {
|
||||
loadingAes.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openAeDialog = (row?: any) => {
|
||||
aeEditingId.value = row?.id || null;
|
||||
Object.assign(aeForm, {
|
||||
term: row?.term || "",
|
||||
onset_date: row?.onset_date || "",
|
||||
seriousness: row?.seriousness || "NON_SERIOUS",
|
||||
severity: row?.severity || "MILD",
|
||||
causality: row?.causality || "",
|
||||
outcome: row?.outcome || "",
|
||||
description: row?.description || "",
|
||||
});
|
||||
aeDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const saveAe = async () => {
|
||||
if (!studyId || !subjectId) return;
|
||||
try {
|
||||
const payload = {
|
||||
subject_id: subjectId,
|
||||
site_id: detail.site_id || null,
|
||||
term: aeForm.term,
|
||||
onset_date: aeForm.onset_date || null,
|
||||
seriousness: aeForm.seriousness,
|
||||
severity: aeForm.severity,
|
||||
causality: aeForm.causality || null,
|
||||
outcome: aeForm.outcome || null,
|
||||
description: aeForm.description || null,
|
||||
};
|
||||
if (aeEditingId.value) {
|
||||
await updateAe(studyId, aeEditingId.value, payload);
|
||||
} else {
|
||||
await createAe(studyId, payload);
|
||||
}
|
||||
aeDialogVisible.value = false;
|
||||
loadAes();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
const removeAe = async (row: any) => {
|
||||
if (!studyId) return;
|
||||
const ok = await ElMessageBox.confirm("确认删除该 AE 记录?", "提示").catch(() => null);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deleteAe(studyId, row.id);
|
||||
loadAes();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
const goEdit = () => router.push(`/subjects/${subjectId}/edit`);
|
||||
const goBack = () => router.push("/subjects");
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSites();
|
||||
await loadSubject();
|
||||
loadHistories();
|
||||
loadVisits();
|
||||
loadAes();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tab-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user