信息架构/菜单框架大重构-20260109

This commit is contained in:
Cheng Zhou
2026-01-09 11:19:04 +08:00
parent ba3cf95b8a
commit 9021c7fe2b
188 changed files with 7632 additions and 11658 deletions
@@ -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>