项目概览、参与者管理初步优化(未接入真实数据)

This commit is contained in:
Cheng Zhou
2026-01-13 10:10:28 +08:00
parent 1338fa7e2b
commit b3cd8e03f2
37 changed files with 1785 additions and 118 deletions
+217 -41
View File
@@ -6,8 +6,14 @@
<p class="page-subtitle">{{ TEXT.modules.subjectDetail.subtitle }}</p>
</div>
<div class="actions">
<el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
<template v-if="subjectEditing">
<el-button type="primary" :loading="subjectSaving" @click="saveSubjectEdit">{{ TEXT.common.actions.save }}</el-button>
<el-button @click="cancelSubjectEdit">{{ TEXT.common.actions.cancel }}</el-button>
</template>
<template v-else>
<el-button type="primary" @click="startSubjectEdit">{{ TEXT.common.actions.edit }}</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</template>
</div>
</div>
@@ -15,11 +21,50 @@
<el-descriptions :column="2" border>
<el-descriptions-item :label="TEXT.common.fields.subjectNo">{{ detail.subject_no || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.site">{{ siteMap[detail.site_id] || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.status">{{ displayEnum(TEXT.enums.subjectStatus, detail.status) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.status">
<el-select v-if="subjectEditing" v-model="subjectForm.status" size="small">
<el-option :label="TEXT.enums.subjectStatus.SCREENING" value="SCREENING" />
<el-option :label="TEXT.enums.subjectStatus.ENROLLED" value="ENROLLED" />
<el-option :label="TEXT.enums.subjectStatus.COMPLETED" value="COMPLETED" />
<el-option :label="TEXT.enums.subjectStatus.DROPPED" value="DROPPED" />
</el-select>
<span v-else>{{ displayEnum(TEXT.enums.subjectStatus, detail.status) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.screeningDate">{{ displayDate(detail.screening_date) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.enrollmentDate">{{ displayDate(detail.enrollment_date) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.completionDate">{{ displayDate(detail.completion_date) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.dropReason" :span="2">{{ detail.drop_reason || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.consentDate">
<el-date-picker
v-if="subjectEditing"
v-model="subjectForm.consent_date"
type="date"
value-format="YYYY-MM-DD"
size="small"
/>
<span v-else>{{ displayDate(detail.consent_date) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.enrollmentDate">
<el-date-picker
v-if="subjectEditing"
v-model="subjectForm.enrollment_date"
type="date"
value-format="YYYY-MM-DD"
size="small"
/>
<span v-else>{{ displayDate(detail.enrollment_date) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.completionDate">
<el-date-picker
v-if="subjectEditing"
v-model="subjectForm.completion_date"
type="date"
value-format="YYYY-MM-DD"
size="small"
/>
<span v-else>{{ displayDate(detail.completion_date) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.dropReason" :span="2">
<el-input v-if="subjectEditing" v-model="subjectForm.drop_reason" type="textarea" :rows="2" />
<span v-else>{{ detail.drop_reason || TEXT.common.fallback }}</span>
</el-descriptions-item>
</el-descriptions>
</el-card>
@@ -50,24 +95,53 @@
</div>
<el-table :data="visitItems" v-loading="loadingVisits" style="width: 100%">
<el-table-column prop="visit_code" :label="TEXT.common.fields.visitCode" width="120" />
<el-table-column prop="visit_name" :label="TEXT.common.fields.visitName" min-width="140">
<template #default="scope">
{{ displayText(scope.row.visit_name, TEXT.enums.visitName) }}
</template>
</el-table-column>
<el-table-column prop="planned_date" :label="TEXT.common.fields.plannedDate" width="140">
<el-table-column prop="planned_date" :label="TEXT.common.fields.plannedDate" min-width="180">
<template #default="scope">{{ displayDate(scope.row.planned_date) }}</template>
</el-table-column>
<el-table-column prop="actual_date" :label="TEXT.common.fields.actualDate" width="140">
<template #default="scope">{{ displayDate(scope.row.actual_date) }}</template>
</el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status" width="120">
<template #default="scope">{{ displayEnum(TEXT.enums.visitStatus, scope.row.status) }}</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="200">
<el-table-column prop="actual_date" :label="TEXT.common.fields.actualDate" min-width="180">
<template #default="scope">
<el-button link type="primary" size="small" @click="openVisitDialog(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
<el-button link type="danger" size="small" @click="removeVisit(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
<el-date-picker
v-if="visitEditingRowId === scope.row.id"
v-model="visitRowDraft.actual_date"
type="date"
value-format="YYYY-MM-DD"
size="small"
/>
<span v-else>{{ displayDate(scope.row.actual_date) }}</span>
</template>
</el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status" min-width="120">
<template #default="scope">
<el-tag
:type="getVisitStatusTagType(getVisitStatus(scope.row, visitEditingRowId === scope.row.id ? visitRowDraft.actual_date : undefined))"
effect="light"
>
{{ displayEnum(TEXT.enums.visitStatus, getVisitStatus(scope.row, visitEditingRowId === scope.row.id ? visitRowDraft.actual_date : undefined)) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="notes" :label="TEXT.common.fields.notes" min-width="200">
<template #default="scope">
<el-input
v-if="visitEditingRowId === scope.row.id"
v-model="visitRowDraft.notes"
size="small"
type="textarea"
:rows="1"
/>
<span v-else>{{ scope.row.notes || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" min-width="160">
<template #default="scope">
<template v-if="visitEditingRowId === scope.row.id">
<el-button link type="primary" size="small" @click="saveVisitRow(scope.row)">{{ TEXT.common.actions.save }}</el-button>
<el-button link size="small" @click="cancelVisitRow">{{ TEXT.common.actions.cancel }}</el-button>
</template>
<template v-else>
<el-button link type="primary" size="small" @click="startVisitRowEdit(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
<el-button link type="danger" size="small" @click="removeVisit(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
</template>
</template>
</el-table-column>
</el-table>
@@ -122,9 +196,6 @@
<el-form-item :label="TEXT.common.fields.visitCode" required>
<el-input v-model="visitForm.visit_code" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.visitName" required>
<el-input v-model="visitForm.visit_name" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.plannedDate">
<el-date-picker v-model="visitForm.planned_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item>
@@ -139,13 +210,6 @@
<el-form-item :label="TEXT.common.fields.actualDate">
<el-date-picker v-model="visitForm.actual_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.status">
<el-select v-model="visitForm.status">
<el-option :label="TEXT.enums.visitStatus.PLANNED" value="PLANNED" />
<el-option :label="TEXT.enums.visitStatus.DONE" value="DONE" />
<el-option :label="TEXT.enums.visitStatus.MISSED" value="MISSED" />
</el-select>
</el-form-item>
<el-form-item :label="TEXT.common.fields.remark">
<el-input v-model="visitForm.notes" type="textarea" :rows="3" />
</el-form-item>
@@ -201,12 +265,12 @@ 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 { getSubject, updateSubject } 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, displayEnum, displayText } from "../../utils/display";
import { displayDate, displayEnum } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
@@ -222,11 +286,22 @@ const detail = reactive<any>({
site_id: "",
status: "",
screening_date: "",
consent_date: "",
enrollment_date: "",
completion_date: "",
drop_reason: "",
});
const subjectEditing = ref(false);
const subjectSaving = ref(false);
const subjectForm = reactive({
status: "SCREENING",
enrollment_date: "",
consent_date: "",
completion_date: "",
drop_reason: "",
});
const siteMap = ref<Record<string, string>>({});
const activeTab = ref("history");
@@ -243,14 +318,17 @@ const visitItems = ref<any[]>([]);
const loadingVisits = ref(false);
const visitDialogVisible = ref(false);
const visitEditingId = ref<string | null>(null);
const visitEditingRowId = ref<string | null>(null);
const visitRowDraft = reactive({
actual_date: "",
notes: "",
});
const visitForm = reactive<any>({
visit_code: "",
visit_name: "",
planned_date: "",
window_start: "",
window_end: "",
actual_date: "",
status: "PLANNED",
notes: "",
});
@@ -295,6 +373,41 @@ const loadSubject = async () => {
}
};
const startSubjectEdit = () => {
subjectForm.status = detail.status || "SCREENING";
subjectForm.consent_date = detail.consent_date || "";
subjectForm.enrollment_date = detail.enrollment_date || "";
subjectForm.completion_date = detail.completion_date || "";
subjectForm.drop_reason = detail.drop_reason || "";
subjectEditing.value = true;
};
const cancelSubjectEdit = () => {
subjectEditing.value = false;
};
const saveSubjectEdit = async () => {
if (!studyId || !subjectId) return;
subjectSaving.value = true;
try {
const payload = {
status: subjectForm.status || null,
consent_date: subjectForm.consent_date || null,
enrollment_date: subjectForm.enrollment_date || null,
completion_date: subjectForm.completion_date || null,
drop_reason: subjectForm.drop_reason || null,
};
await updateSubject(studyId, subjectId, payload);
ElMessage.success(TEXT.common.messages.saveSuccess);
subjectEditing.value = false;
loadSubject();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
subjectSaving.value = false;
}
};
const loadHistories = async () => {
if (!studyId || !subjectId) return;
loadingHistory.value = true;
@@ -364,28 +477,93 @@ 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 startVisitRowEdit = (row: any) => {
visitEditingRowId.value = row?.id || null;
visitRowDraft.actual_date = row?.actual_date || "";
visitRowDraft.notes = row?.notes || "";
};
const cancelVisitRow = () => {
visitEditingRowId.value = null;
};
const saveVisitRow = async (row: any) => {
if (!studyId || !subjectId || !row?.id) return;
try {
const payload = {
actual_date: visitRowDraft.actual_date || null,
notes: visitRowDraft.notes || null,
};
await updateVisit(studyId, subjectId, row.id, payload);
visitEditingRowId.value = null;
loadVisits();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
}
};
const parseDate = (value?: string | null) => {
if (!value) return null;
const date = new Date(value);
if (isNaN(date.getTime())) return null;
return date;
};
const getVisitStatus = (row: any, actualDateOverride?: string) => {
if (row?.status === "CANCELLED") return "CANCELLED";
const actualDate = parseDate(actualDateOverride ?? row?.actual_date);
const windowStart = parseDate(row?.window_start);
const windowEnd = parseDate(row?.window_end);
const plannedDate = parseDate(row?.planned_date);
if (actualDate) {
if ((windowStart && actualDate < windowStart) || (windowEnd && actualDate > windowEnd)) {
return "OVERDUE";
}
return "DONE";
}
const today = new Date();
const deadline = windowEnd || plannedDate;
if (deadline && today > deadline) {
return "LOST";
}
return "PLANNED";
};
const getVisitStatusTagType = (status: string) => {
switch (status) {
case "DONE":
return "success";
case "OVERDUE":
return "warning";
case "LOST":
return "danger";
case "CANCELLED":
return "info";
default:
return "info";
}
};
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);
@@ -393,7 +571,6 @@ const saveVisit = async () => {
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,
@@ -484,7 +661,6 @@ const removeAe = async (row: any) => {
}
};
const goEdit = () => router.push(`/subjects/${subjectId}/edit`);
const goBack = () => router.push("/subjects");
onMounted(async () => {