Files
ctms/frontend/src/views/subjects/SubjectDetail.vue
T
2026-05-28 10:56:28 +08:00

1702 lines
60 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="page ctms-page-shell page--flush">
<div class="page-header unified-action-bar bar--flush">
<div>
<h1 class="page-title">{{ detail.subject_no || TEXT.modules.subjectManagement.screeningNo }}</h1>
<p class="page-subtitle">{{ TEXT.modules.subjectManagement.screeningNo }}</p>
</div>
<div class="actions">
<el-button
v-if="subjectEditing"
type="primary"
:loading="subjectSaving"
:disabled="isReadOnly || !canUpdateSubject"
@click="saveSubjectEdit"
>
{{ TEXT.common.actions.save }}
</el-button>
<el-button v-if="subjectEditing" :disabled="isReadOnly" @click="cancelSubjectEdit">
{{ TEXT.common.actions.cancel }}
</el-button>
<el-button v-else-if="canUpdateSubject" type="primary" :disabled="isReadOnly" @click="startSubjectEdit">
{{ TEXT.common.actions.edit }}
</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div>
</div>
<el-card class="unified-shell subject-shell" v-loading="loading">
<el-descriptions class="subject-overview-descriptions" :column="2" border>
<el-descriptions-item :label="TEXT.modules.subjectManagement.screeningNo">{{ 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">
<span>{{ displaySubjectStatus(detail) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.screeningDate">
<el-date-picker
v-if="subjectEditing"
v-model="subjectForm.screening_date"
type="date"
value-format="YYYY-MM-DD"
size="small"
/>
<span v-else>{{ displayDate(detail.screening_date) }}</span>
</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.baselineDate">
<el-date-picker
v-if="subjectEditing"
v-model="subjectForm.baseline_date"
type="date"
value-format="YYYY-MM-DD"
size="small"
/>
<span v-else>{{ displayDate(detail.baseline_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>
<el-card class="unified-shell subject-shell subject-tabs-shell">
<div class="subject-tabs-action">
<el-button
v-if="currentTabAction.allowed"
type="primary"
:loading="currentTabAction.loading"
:disabled="isReadOnly"
@click="currentTabAction.onClick"
>
{{ currentTabAction.label }}
</el-button>
<el-button
v-if="activeTab === 'visits' && canCreateVisit"
type="warning"
plain
:disabled="isReadOnly"
@click="openEarlyTerminationDialog"
>
提前终止
</el-button>
<el-button v-if="adherenceEditing && activeTab === 'medicationAdherence'" :disabled="isReadOnly" @click="adherenceEditing = false">
{{ TEXT.common.actions.cancel }}
</el-button>
</div>
<el-tabs v-model="activeTab">
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.history" name="history">
<el-table :data="historyItems" v-loading="loadingHistory" style="width: 100%" class="subject-detail-table" table-layout="fixed">
<el-table-column prop="record_date" :label="TEXT.common.fields.recordDate" show-overflow-tooltip>
<template #default="scope">{{ displayDate(scope.row.record_date) }}</template>
</el-table-column>
<el-table-column prop="content" :label="TEXT.common.fields.content" show-overflow-tooltip />
<el-table-column v-if="canUpdateHistory || canDeleteHistory" :label="TEXT.common.labels.actions" width="140">
<template #default="scope">
<el-button
v-if="canUpdateHistory"
link
type="primary"
size="small"
:disabled="isReadOnly"
@click="openHistoryDialog(scope.row)"
>
{{ TEXT.common.actions.edit }}
</el-button>
<el-button
v-if="canDeleteHistory"
link
type="danger"
size="small"
:disabled="isReadOnly"
@click="removeHistory(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template>
</el-table-column>
<template #empty>
<div v-if="!loadingHistory" class="table-empty">{{ TEXT.modules.subjectDetail.emptyHistory }}</div>
</template>
</el-table>
</el-tab-pane>
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.visits" name="visits">
<el-table :data="visitItems" v-loading="loadingVisits" style="width: 100%" class="subject-detail-table" table-layout="fixed">
<el-table-column prop="visit_code" :label="TEXT.common.fields.visitCode" show-overflow-tooltip />
<el-table-column prop="planned_date" :label="TEXT.common.fields.plannedDate" show-overflow-tooltip>
<template #default="scope">
<div class="visit-planned-cell">
<span>{{ getVisitPlannedDateLabel(scope.row) }}</span>
<span v-if="getVisitWindowRangeLabel(scope.row)" class="visit-window-range">
{{ getVisitWindowRangeLabel(scope.row) }}
</span>
</div>
</template>
</el-table-column>
<el-table-column prop="actual_date" :label="TEXT.common.fields.actualDate" show-overflow-tooltip>
<template #default="scope">
<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">
<template #default="scope">
<el-tag
:type="getVisitStatusTagType(getVisitStatus(scope.row, visitEditingRowId === scope.row.id ? visitRowDraft.actual_date : undefined))"
effect="light"
>
{{ getVisitStatusLabel(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" show-overflow-tooltip>
<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 v-if="canUpdateVisit || canDeleteVisit" :label="TEXT.common.labels.actions" width="140">
<template #default="scope">
<template v-if="visitEditingRowId === scope.row.id">
<el-button v-if="canUpdateVisit" link type="primary" size="small" :disabled="isReadOnly" @click="saveVisitRow(scope.row)">
{{ TEXT.common.actions.save }}
</el-button>
<el-button link size="small" :disabled="isReadOnly" @click="cancelVisitRow">
{{ TEXT.common.actions.cancel }}
</el-button>
</template>
<template v-else>
<el-button
v-if="canUpdateVisit"
link
type="primary"
size="small"
:disabled="isReadOnly"
@click="startVisitRowEdit(scope.row)"
>
{{ TEXT.common.actions.edit }}
</el-button>
<el-button
v-if="canDeleteVisit"
link
type="danger"
size="small"
:disabled="isReadOnly"
@click="removeVisit(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template>
</template>
</el-table-column>
<template #empty>
<div v-if="!loadingVisits" class="table-empty">{{ TEXT.modules.subjectDetail.emptyVisits }}</div>
</template>
</el-table>
</el-tab-pane>
<el-tab-pane label="用药依从性" name="medicationAdherence">
<div class="adherence-panel">
<el-descriptions :column="2" border>
<el-descriptions-item label="开始用药日期">
{{ displayDate(medicationStartDate) }}
</el-descriptions-item>
<el-descriptions-item label="结束用药日期">
{{ displayDate(medicationEndDate) }}
</el-descriptions-item>
<el-descriptions-item label="结束日期来源">
{{ medicationEndVisitLabel }}
</el-descriptions-item>
<el-descriptions-item label="理论用药次数">
{{ expectedMedicationCountText }}
</el-descriptions-item>
<el-descriptions-item label="实际用药次数">
<el-input-number
v-if="adherenceEditing"
v-model="adherenceForm.actual_medication_count"
:min="0"
:controls="false"
size="small"
class="adherence-count-input"
:disabled="isReadOnly || !canUpdateSubject"
/>
<span v-else>{{ actualMedicationCountText }}</span>
</el-descriptions-item>
<el-descriptions-item label="用药依从性">
<el-tag :type="adherenceRateTagType" effect="light">{{ medicationAdherenceRateText }}</el-tag>
</el-descriptions-item>
</el-descriptions>
<div class="adherence-hint">
用药依从性 = 实际用药次数 ÷ 理论用药次数 × 100%实际用药次数为试验期间实际药物暴露次数理论用药次数 =结束用药日期 - 开始用药日期 + 1)× 1/试验期间的用药依从性原则上要求在 75%~125% 范围内包含边界值)。
</div>
</div>
</el-tab-pane>
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.ae" name="ae">
<el-table :data="aeItems" v-loading="loadingAes" style="width: 100%" class="subject-detail-table" table-layout="fixed">
<el-table-column prop="onset_date" :label="TEXT.common.fields.occurDate" show-overflow-tooltip>
<template #default="scope">{{ displayDate(scope.row.onset_date) }}</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.aeType">
<template #default="scope">
<el-tag size="small" effect="light" :class="getAeTypeClass(scope.row)">
{{ getAeTypeLabel(scope.row) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="term" :label="TEXT.common.fields.title" show-overflow-tooltip />
<el-table-column prop="seriousness" :label="TEXT.common.fields.seriousness">
<template #default="scope">
{{ displayEnum(TEXT.enums.aeSeriousness, normalizeSeriousness(scope.row.seriousness)) }}
</template>
</el-table-column>
<el-table-column prop="causality" :label="TEXT.common.fields.causality">
<template #default="scope">{{ displayText(scope.row.causality, TEXT.enums.aeCausality) }}</template>
</el-table-column>
<el-table-column prop="outcome" :label="TEXT.common.fields.outcome">
<template #default="scope">{{ displayText(scope.row.outcome, TEXT.enums.aeOutcome) }}</template>
</el-table-column>
<el-table-column prop="description" :label="TEXT.common.fields.remark" show-overflow-tooltip />
<el-table-column v-if="canUpdateAe || canDeleteAe" :label="TEXT.common.labels.actions" width="140">
<template #default="scope">
<el-button
v-if="canUpdateAe"
link
type="primary"
size="small"
:disabled="isReadOnly"
@click="openAeDialog(scope.row)"
>
{{ TEXT.common.actions.edit }}
</el-button>
<el-button
v-if="canDeleteAe"
link
type="danger"
size="small"
:disabled="isReadOnly"
@click="removeAe(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template>
</el-table-column>
<template #empty>
<div v-if="!loadingAes" class="table-empty">{{ TEXT.modules.subjectDetail.emptyAe }}</div>
</template>
</el-table>
</el-tab-pane>
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.pd" name="pd">
<el-table :data="pdItems" v-loading="loadingPds" style="width: 100%" class="subject-detail-table" table-layout="fixed">
<el-table-column prop="pd_no" :label="TEXT.common.fields.pdNo" show-overflow-tooltip />
<el-table-column prop="pd_type" :label="TEXT.common.fields.pdType" show-overflow-tooltip />
<el-table-column prop="pd_level" :label="TEXT.common.fields.pdLevel">
<template #default="scope">{{ displayEnum(TEXT.enums.pdLevel, scope.row.pd_level) }}</template>
</el-table-column>
<el-table-column prop="description" :label="TEXT.common.fields.pdDescription" show-overflow-tooltip>
<template #default="scope">{{ scope.row.description || TEXT.common.fallback }}</template>
</el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status">
<template #default="scope">{{ displayEnum(TEXT.enums.pdStatus, scope.row.status) }}</template>
</el-table-column>
<el-table-column v-if="canUpdatePd || canDeletePd" :label="TEXT.common.labels.actions" width="140">
<template #default="scope">
<el-button
v-if="canUpdatePd"
link
type="primary"
size="small"
:disabled="isReadOnly"
@click="openPdDialog(scope.row)"
>
{{ TEXT.common.actions.edit }}
</el-button>
<el-button
v-if="canDeletePd"
link
type="danger"
size="small"
:disabled="isReadOnly"
@click="removePd(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template>
</el-table-column>
<template #empty>
<div v-if="!loadingPds" class="table-empty">{{ TEXT.modules.subjectDetail.emptyPd }}</div>
</template>
</el-table>
</el-tab-pane>
</el-tabs>
</el-card>
<el-dialog v-if="historyDialogVisible" append-to=".layout-main .content-wrapper" v-model="historyDialogVisible" :title="TEXT.modules.subjectDetail.dialogHistory" width="520px">
<el-form :model="historyForm" label-width="90px">
<el-form-item :label="TEXT.common.fields.recordDate">
<el-date-picker v-model="historyForm.record_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.content" required>
<el-input v-model="historyForm.content" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="historyDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :disabled="isReadOnly || !canSaveHistory" @click="saveHistory">{{ TEXT.common.actions.save }}</el-button>
</template>
</el-dialog>
<el-dialog v-if="visitDialogVisible" append-to=".layout-main .content-wrapper" v-model="visitDialogVisible" :title="TEXT.modules.subjectDetail.dialogVisit" width="560px">
<el-form :model="visitForm" label-width="100px">
<template v-if="!visitEditingId">
<el-form-item :label="TEXT.common.fields.visitCode" required>
<el-input v-model="visitForm.visit_code" disabled />
</el-form-item>
<el-form-item :label="TEXT.common.fields.plannedDate" :required="isFirstVisit">
<el-date-picker v-model="visitForm.planned_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.windowStart">
<el-date-picker v-model="visitForm.window_start" type="date" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.windowEnd">
<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="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.remark">
<el-input v-model="visitForm.notes" type="textarea" :rows="3" />
</el-form-item>
</template>
</el-form>
<template #footer>
<el-button @click="visitDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :disabled="isReadOnly || !canSaveVisit" @click="saveVisit">{{ TEXT.common.actions.save }}</el-button>
</template>
</el-dialog>
<el-dialog
v-if="earlyTerminationDialogVisible"
append-to=".layout-main .content-wrapper"
v-model="earlyTerminationDialogVisible"
title="提前终止"
width="560px"
>
<el-form :model="earlyTerminationForm" label-width="110px">
<el-form-item label="提前终止日期" required>
<el-date-picker v-model="earlyTerminationForm.termination_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="提前终止原因" required>
<el-input v-model="earlyTerminationForm.reason" type="textarea" :rows="2" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.remark">
<el-input v-model="earlyTerminationForm.notes" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<div class="early-termination-hint">
提前终止表示受试者在方案最后一个计划访视窗口期前停止治疗日期进入最后访视窗口期后应录入正常终止治疗访视
</div>
<template #footer>
<el-button @click="earlyTerminationDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="earlyTerminationSaving" :disabled="isReadOnly || !canCreateVisit" @click="saveEarlyTermination">
{{ TEXT.common.actions.save }}
</el-button>
</template>
</el-dialog>
<el-dialog v-if="aeDialogVisible" append-to=".layout-main .content-wrapper" v-model="aeDialogVisible" :title="TEXT.modules.subjectDetail.dialogAe" width="560px">
<el-form :model="aeForm" label-width="100px">
<el-form-item :label="TEXT.common.fields.title" required>
<el-input v-model="aeForm.term" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.occurDate">
<el-date-picker v-model="aeForm.onset_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.seriousness" required>
<el-select v-model="aeForm.seriousness">
<el-option v-for="option in aeSeriousnessOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</el-form-item>
<el-form-item :label="TEXT.common.fields.isSae">
<el-switch v-model="aeForm.is_sae" @change="onSaeChanged" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.isSusar">
<el-switch v-model="aeForm.is_susar" @change="onSusarChanged" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.causality">
<el-select v-model="aeForm.causality" clearable>
<el-option v-for="option in aeCausalityOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</el-form-item>
<el-form-item :label="TEXT.common.fields.outcome">
<el-select v-model="aeForm.outcome" clearable>
<el-option v-for="option in aeOutcomeOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</el-form-item>
<el-form-item :label="TEXT.common.fields.remark">
<el-input v-model="aeForm.description" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="aeDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :disabled="isReadOnly || !canSaveAe" @click="saveAe">{{ TEXT.common.actions.save }}</el-button>
</template>
</el-dialog>
<el-dialog v-if="pdDialogVisible" append-to=".layout-main .content-wrapper" v-model="pdDialogVisible" :title="TEXT.modules.subjectDetail.dialogPd" width="560px">
<el-form :model="pdForm" label-width="100px">
<el-form-item :label="TEXT.common.fields.pdNo">
<el-input v-model="pdForm.pd_no" disabled :placeholder="TEXT.modules.subjectDetail.pdNoAuto" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.pdType" required>
<el-select v-model="pdForm.pd_type" filterable allow-create default-first-option clearable>
<el-option v-for="item in pdTypeOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
<el-form-item :label="TEXT.common.fields.pdLevel" required>
<el-select v-model="pdForm.pd_level">
<el-option v-for="option in pdLevelOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</el-form-item>
<el-form-item :label="TEXT.common.fields.status" required>
<el-select v-model="pdForm.status">
<el-option v-for="option in pdStatusOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</el-form-item>
<el-form-item :label="TEXT.common.fields.pdDescription">
<el-input v-model="pdForm.description" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="pdDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :disabled="isReadOnly || !canSavePd" @click="savePd">{{ TEXT.common.actions.save }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import { getSubject, updateSubject } from "../../api/subjects";
import { fetchSites } from "../../api/sites";
import { listSubjectHistories, createSubjectHistory, updateSubjectHistory, deleteSubjectHistory } from "../../api/subjectHistories";
import { fetchVisits, createEarlyTerminationVisit, createVisit, updateVisit, deleteVisit } from "../../api/visits";
import { fetchAes, createAe, updateAe, deleteAe } from "../../api/aes";
import { listSubjectPds, createSubjectPd, updateSubjectPd, deleteSubjectPd } from "../../api/subjectPds";
import { displayDate, displayEnum, displayText } from "../../utils/display";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { getProjectRole, isSystemAdmin } from "../../utils/roles";
import { TEXT } from "../../locales";
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const study = useStudyStore();
const subjectId = route.params.subjectId as string;
const studyId = study.currentStudy?.id || "";
const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole));
const canUseApiPermission = (operationKey: string) => {
if (isSystemAdmin(auth.user)) return true;
const role = projectRole.value;
if (!role) return false;
return isApiPermissionAllowed(study.currentPermissions?.[role]?.[operationKey]);
};
const resolveTabFromQuery = () => {
const tab = typeof route.query.tab === "string" ? route.query.tab : "";
return ["history", "visits", "medicationAdherence", "ae", "pd"].includes(tab) ? tab : "history";
};
const loading = ref(false);
const detail = reactive<any>({
subject_no: "",
site_id: "",
status: "",
screening_date: "",
consent_date: "",
enrollment_date: "",
baseline_date: "",
completion_date: "",
actual_medication_count: null as number | null,
drop_reason: "",
});
const subjectEditing = ref(false);
const subjectSaving = ref(false);
const subjectForm = reactive({
screening_date: "",
enrollment_date: "",
baseline_date: "",
consent_date: "",
completion_date: "",
drop_reason: "",
});
const siteMap = ref<Record<string, string>>({});
const siteActiveMap = ref<Record<string, boolean>>({});
const isReadOnly = computed(() => !!detail.site_id && siteActiveMap.value[detail.site_id] === false);
const canUpdateSubject = computed(() => canUseApiPermission("subjects:update"));
const activeTab = ref(resolveTabFromQuery());
const historyItems = ref<any[]>([]);
const loadingHistory = ref(false);
const historyDialogVisible = ref(false);
const historyEditingId = ref<string | null>(null);
const canCreateHistory = computed(() => canUseApiPermission("subject_histories:create"));
const canUpdateHistory = computed(() => canUseApiPermission("subject_histories:update"));
const canDeleteHistory = computed(() => canUseApiPermission("subject_histories:delete"));
const canSaveHistory = computed(() => (historyEditingId.value ? canUpdateHistory.value : canCreateHistory.value));
const historyForm = reactive({
record_date: "",
content: "",
});
const visitItems = ref<any[]>([]);
const loadingVisits = ref(false);
const visitDialogVisible = ref(false);
const earlyTerminationDialogVisible = ref(false);
const earlyTerminationSaving = ref(false);
const visitEditingId = ref<string | null>(null);
const visitEditingRowId = ref<string | null>(null);
const canCreateVisit = computed(() => canUseApiPermission("visits:create"));
const canUpdateVisit = computed(() => canUseApiPermission("visits:update"));
const canDeleteVisit = computed(() => canUseApiPermission("visits:delete"));
const canSaveVisit = computed(() => (visitEditingId.value ? canUpdateVisit.value : canCreateVisit.value));
const visitRowDraft = reactive({
actual_date: "",
notes: "",
});
const visitForm = reactive<any>({
visit_code: "",
planned_date: "",
window_start: "",
window_end: "",
actual_date: "",
notes: "",
});
const earlyTerminationForm = reactive({
termination_date: "",
reason: "",
notes: "",
});
const isFirstVisit = computed(() => visitForm.visit_code === "V1");
const adherenceEditing = ref(false);
const adherenceSaving = ref(false);
const canEditAdherence = computed(() => canUpdateSubject.value);
const adherenceForm = reactive({
actual_medication_count: null as number | null,
});
const aeItems = ref<any[]>([]);
const loadingAes = ref(false);
const aeDialogVisible = ref(false);
const aeEditingId = ref<string | null>(null);
const canListAe = computed(() => canUseApiPermission("subject_aes:list"));
const canCreateAe = computed(() => canUseApiPermission("subject_aes:create"));
const canUpdateAe = computed(() => canUseApiPermission("subject_aes:update"));
const canDeleteAe = computed(() => canUseApiPermission("subject_aes:delete"));
const canSaveAe = computed(() => (aeEditingId.value ? canUpdateAe.value : canCreateAe.value));
const aeCausalityOptions = [
{ value: "肯定有关", label: TEXT.enums.aeCausality["肯定有关"] },
{ value: "很可能有关", label: TEXT.enums.aeCausality["很可能有关"] },
{ value: "可能有关", label: TEXT.enums.aeCausality["可能有关"] },
{ value: "可能无关", label: TEXT.enums.aeCausality["可能无关"] },
{ value: "无关", label: TEXT.enums.aeCausality["无关"] },
{ value: "待评估", label: TEXT.enums.aeCausality["待评估"] },
];
const aeOutcomeOptions = [
{ value: "痊愈", label: TEXT.enums.aeOutcome["痊愈"] },
{ value: "好转", label: TEXT.enums.aeOutcome["好转"] },
{ value: "持续", label: TEXT.enums.aeOutcome["持续"] },
{ value: "恶化", label: TEXT.enums.aeOutcome["恶化"] },
{ value: "死亡", label: TEXT.enums.aeOutcome["死亡"] },
{ value: "失访", label: TEXT.enums.aeOutcome["失访"] },
];
const aeForm = reactive({
term: "",
onset_date: "",
seriousness: "I",
is_sae: false,
is_susar: false,
causality: "",
outcome: "",
description: "",
});
const aeSeriousnessOptions = [
{ value: "I", label: TEXT.enums.aeSeriousness.I },
{ value: "II", label: TEXT.enums.aeSeriousness.II },
{ value: "III", label: TEXT.enums.aeSeriousness.III },
{ value: "IV", label: TEXT.enums.aeSeriousness.IV },
{ value: "V", label: TEXT.enums.aeSeriousness.V },
];
const pdItems = ref<any[]>([]);
const loadingPds = ref(false);
const pdDialogVisible = ref(false);
const pdEditingId = ref<string | null>(null);
const canListPd = computed(() => canUseApiPermission("subject_pds:list"));
const canCreatePd = computed(() => canUseApiPermission("subject_pds:create"));
const canUpdatePd = computed(() => canUseApiPermission("subject_pds:update"));
const canDeletePd = computed(() => canUseApiPermission("subject_pds:delete"));
const canSavePd = computed(() => (pdEditingId.value ? canUpdatePd.value : canCreatePd.value));
const pdTypeOptions = ["知情同意", "排除标准", "药物使用", "入排标准", "访视流程", "检验检查", "其他"];
const pdLevelOptions = Object.entries(TEXT.enums.pdLevel).map(([value, label]) => ({ value, label }));
const pdStatusOptions = Object.entries(TEXT.enums.pdStatus).map(([value, label]) => ({ value, label }));
const pdForm = reactive({
pd_no: "",
pd_type: "",
pd_level: "GENERAL",
description: "",
status: "OPEN",
});
const normalizeSeriousness = (value?: string | null) => {
if (!value) return "I";
const normalized = String(value).trim().toUpperCase();
const digitMap: Record<string, string> = {
"1": "I",
"2": "II",
"3": "III",
"4": "IV",
"5": "V",
};
const legacyMap: Record<string, string> = {
SERIOUS: "IV",
NON_SERIOUS: "II",
};
if (digitMap[normalized]) return digitMap[normalized];
if (legacyMap[normalized]) return legacyMap[normalized];
if (["I", "II", "III", "IV", "V"].includes(normalized)) return normalized;
return "I";
};
const getErrorMessage = (error: any, fallback: string) => {
const detail = error?.response?.data?.detail;
if (typeof error?.response?.data?.message === "string") return error.response.data.message;
if (typeof detail === "string") return detail;
if (typeof detail?.message === "string") return detail.message;
return fallback;
};
const displaySubjectStatus = (subject: any) => {
if (!subject?.screening_date) return TEXT.common.fallback;
return displayEnum(TEXT.enums.subjectStatus, subject.status);
};
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;
}, {});
siteActiveMap.value = list.reduce((acc: Record<string, boolean>, site: any) => {
acc[site.id] = !!site.is_active;
return acc;
}, {});
} catch {
siteMap.value = {};
siteActiveMap.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 || TEXT.common.messages.loadFailed);
} finally {
loading.value = false;
}
};
const startSubjectEdit = () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!canUpdateSubject.value) {
ElMessage.warning(TEXT.common.messages.noPermission);
return;
}
subjectForm.screening_date = detail.screening_date || "";
subjectForm.consent_date = detail.consent_date || "";
subjectForm.enrollment_date = detail.enrollment_date || "";
subjectForm.baseline_date = detail.baseline_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 (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!canUpdateSubject.value) {
ElMessage.warning(TEXT.common.messages.noPermission);
return;
}
if (!studyId || !subjectId) return;
subjectSaving.value = true;
try {
const payload = {
screening_date: subjectForm.screening_date || null,
consent_date: subjectForm.consent_date || null,
enrollment_date: subjectForm.enrollment_date || null,
baseline_date: subjectForm.baseline_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;
await loadSubject();
await loadVisits();
} 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;
try {
const { data } = (await listSubjectHistories(studyId, subjectId)) as any;
historyItems.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
loadingHistory.value = false;
}
};
const openHistoryDialog = (row?: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
const allowed = row?.id ? canUpdateHistory.value : canCreateHistory.value;
if (!allowed) {
ElMessage.warning(TEXT.common.messages.noPermission);
return;
}
historyEditingId.value = row?.id || null;
historyForm.record_date = row?.record_date || "";
historyForm.content = row?.content || "";
historyDialogVisible.value = true;
};
const saveHistory = async () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!canSaveHistory.value) {
ElMessage.warning(TEXT.common.messages.noPermission);
return;
}
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 || TEXT.common.messages.saveFailed);
}
};
const removeHistory = async (row: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!canDeleteHistory.value) {
ElMessage.warning(TEXT.common.messages.noPermission);
return;
}
if (!studyId || !subjectId) return;
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return;
try {
await deleteSubjectHistory(studyId, subjectId, row.id);
loadHistories();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
}
};
const loadVisits = async () => {
if (!studyId || !subjectId) return;
loadingVisits.value = true;
try {
const { data } = (await fetchVisits(studyId, subjectId)) as any;
const items = Array.isArray(data) ? data : data.items || [];
visitItems.value = items;
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
loadingVisits.value = false;
}
};
const getNextVisitCode = () => {
const used = new Set<number>();
visitItems.value.forEach((item) => {
const code = String(item?.visit_code || "");
if (!code.startsWith("V")) return;
const num = Number(code.slice(1));
if (Number.isFinite(num) && num > 0) {
used.add(num);
}
});
let next = 1;
while (used.has(next)) next += 1;
return `V${next}`;
};
const openVisitDialog = (row?: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
const allowed = row?.id ? canUpdateVisit.value : canCreateVisit.value;
if (!allowed) {
ElMessage.warning(TEXT.common.messages.noPermission);
return;
}
visitEditingId.value = row?.id || null;
if (row) {
visitForm.actual_date = row.actual_date || "";
visitForm.notes = row.notes || "";
} else {
visitForm.visit_code = getNextVisitCode();
visitForm.planned_date = "";
visitForm.window_start = "";
visitForm.window_end = "";
visitForm.actual_date = "";
visitForm.notes = "";
}
visitDialogVisible.value = true;
};
const openEarlyTerminationDialog = () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!canCreateVisit.value) {
ElMessage.warning(TEXT.common.messages.noPermission);
return;
}
earlyTerminationForm.termination_date = "";
earlyTerminationForm.reason = "";
earlyTerminationForm.notes = "";
earlyTerminationDialogVisible.value = true;
};
const startVisitRowEdit = (row: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!canUpdateVisit.value) {
ElMessage.warning(TEXT.common.messages.noPermission);
return;
}
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 (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!canUpdateVisit.value) {
ElMessage.warning(TEXT.common.messages.noPermission);
return;
}
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 parseDateAtNoon = (value?: string | null) => {
if (!value) return null;
const date = new Date(`${value}T12:00:00`);
if (isNaN(date.getTime())) return null;
return date;
};
const getVisitWindowStartDateText = (row: any) => row?.window_start || row?.planned_date || "";
const getVisitPlannedDateLabel = (row: any) => {
if (String(row?.visit_code || "").trim() === "提前终止") return "不适用";
return displayDate(row?.planned_date);
};
const getVisitWindowRangeLabel = (row: any) => {
if (String(row?.visit_code || "").trim() === "提前终止") return "";
if (!row?.window_start || !row?.window_end) return "";
if (row.window_start === row.planned_date && row.window_end === row.planned_date) return "";
return `${displayDate(row.window_start)} ${displayDate(row.window_end)}`;
};
const lastPlannedVisitWindowStartText = computed(() => {
const excludedCodes = new Set(["筛选访视", "基线访视", "提前终止"]);
const candidates = visitItems.value
.filter((item) => !excludedCodes.has(String(item?.visit_code || "").trim()))
.map((item) => getVisitWindowStartDateText(item))
.filter(Boolean)
.sort();
return candidates[candidates.length - 1] || "";
});
const validateEarlyTerminationDateInput = () => {
const boundary = lastPlannedVisitWindowStartText.value;
if (!boundary || !earlyTerminationForm.termination_date) return true;
if (earlyTerminationForm.termination_date >= boundary) {
ElMessage.error(`提前终止日期必须早于最后计划访视窗口期开始日(${boundary}`);
return false;
}
return true;
};
const formatCount = (value: number | null | undefined) => (value === null || value === undefined ? TEXT.common.fallback : String(value));
const medicationStartDate = computed(() => detail.baseline_date || "");
const medicationEndVisit = computed(() => {
const targetLabels = new Set(["终止治疗", "提前终止"]);
return (
visitItems.value.find((item) => targetLabels.has(String(item?.visit_code || "").trim()) && item?.actual_date) || null
);
});
const medicationEndDate = computed(() => medicationEndVisit.value?.actual_date || "");
const medicationEndVisitLabel = computed(() => medicationEndVisit.value?.visit_code || "未记录终止治疗/提前终止实际日期");
const expectedMedicationCount = computed(() => {
const start = parseDateAtNoon(medicationStartDate.value);
const end = parseDateAtNoon(medicationEndDate.value);
if (!start || !end || end < start) return null;
return Math.floor((end.getTime() - start.getTime()) / 86400000) + 1;
});
const expectedMedicationCountText = computed(() => formatCount(expectedMedicationCount.value));
const actualMedicationCount = computed(() => detail.actual_medication_count ?? null);
const actualMedicationCountText = computed(() => formatCount(actualMedicationCount.value));
const medicationAdherenceRate = computed(() => {
const expected = expectedMedicationCount.value;
const actual = actualMedicationCount.value;
if (!expected || actual === null || actual === undefined) return null;
return (actual / expected) * 100;
});
const medicationAdherenceRateText = computed(() => {
const rate = medicationAdherenceRate.value;
if (rate === null) return "无法计算";
return `${rate.toFixed(1)}%`;
});
const adherenceRateTagType = computed(() => {
const rate = medicationAdherenceRate.value;
if (rate === null) return "info";
if (rate < 75 || rate > 125) return "danger";
return "success";
});
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 getVisitStatusLabel = (row: any, actualDateOverride?: string) => {
const status = getVisitStatus(row, actualDateOverride);
if (status === "CANCELLED" && String(row?.notes || "").includes("提前终止")) {
return "不适用";
}
return displayEnum(TEXT.enums.visitStatus, status);
};
const getAeTypeKey = (row: any) => {
if (row?.is_susar) return "susar";
if (row?.is_sae) return "sae";
return "ae";
};
const getAeTypeLabel = (row: any) => TEXT.modules.subjectDetail.aeType[getAeTypeKey(row)];
const getAeTypeClass = (row: any) => `ae-type-tag ae-type-${getAeTypeKey(row)}`;
const onSaeChanged = (value: boolean) => {
if (!value) {
aeForm.is_susar = false;
}
};
const onSusarChanged = (value: boolean) => {
if (value) {
aeForm.is_sae = true;
}
};
const saveVisit = async () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!canSaveVisit.value) {
ElMessage.warning(TEXT.common.messages.noPermission);
return;
}
if (!studyId || !subjectId) return;
try {
if (visitEditingId.value) {
const payload = {
actual_date: visitForm.actual_date || null,
notes: visitForm.notes || null,
};
await updateVisit(studyId, subjectId, visitEditingId.value, payload);
} else {
if (visitForm.visit_code === "V1" && !visitForm.planned_date) {
ElMessage.error("首次新增访视必须填写计划访视日期");
return;
}
const payload = {
subject_id: subjectId,
visit_code: visitForm.visit_code,
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 || TEXT.common.messages.saveFailed);
}
};
const saveEarlyTermination = async () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!canCreateVisit.value) {
ElMessage.warning(TEXT.common.messages.noPermission);
return;
}
if (!studyId || !subjectId) return;
if (!earlyTerminationForm.termination_date) {
ElMessage.error("请填写提前终止日期");
return;
}
if (!earlyTerminationForm.reason.trim()) {
ElMessage.error("请填写提前终止原因");
return;
}
if (!validateEarlyTerminationDateInput()) return;
earlyTerminationSaving.value = true;
try {
await createEarlyTerminationVisit(studyId, subjectId, {
termination_date: earlyTerminationForm.termination_date,
reason: earlyTerminationForm.reason.trim(),
notes: earlyTerminationForm.notes || null,
}, {
suppressErrorMessage: true,
});
earlyTerminationDialogVisible.value = false;
ElMessage.success(TEXT.common.messages.saveSuccess);
} catch (e: any) {
ElMessage.error(getErrorMessage(e, TEXT.common.messages.saveFailed));
earlyTerminationSaving.value = false;
return;
}
try {
await Promise.all([loadSubject(), loadVisits()]);
} catch {
ElMessage.warning("提前终止已保存,刷新数据失败,请手动刷新页面");
} finally {
earlyTerminationSaving.value = false;
}
};
const removeVisit = async (row: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!canDeleteVisit.value) {
ElMessage.warning(TEXT.common.messages.noPermission);
return;
}
if (!studyId || !subjectId) return;
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return;
try {
await deleteVisit(studyId, subjectId, row.id);
loadVisits();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
}
};
const loadAes = async () => {
if (!studyId || !subjectId) return;
if (!canListAe.value) {
aeItems.value = [];
return;
}
loadingAes.value = true;
try {
const { data } = (await fetchAes(studyId, { subject_id: subjectId })) as any;
aeItems.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
loadingAes.value = false;
}
};
const openAeDialog = (row?: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
const allowed = row?.id ? canUpdateAe.value : canCreateAe.value;
if (!allowed) {
ElMessage.warning(TEXT.common.messages.noPermission);
return;
}
aeEditingId.value = row?.id || null;
Object.assign(aeForm, {
term: row?.term || "",
onset_date: row?.onset_date || "",
seriousness: normalizeSeriousness(row?.seriousness),
is_sae: !!row?.is_sae || !!row?.is_susar,
is_susar: !!row?.is_susar,
causality: row?.causality || "",
outcome: row?.outcome || "",
description: row?.description || "",
});
aeDialogVisible.value = true;
};
const saveAe = async () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!canSaveAe.value) {
ElMessage.warning(TEXT.common.messages.noPermission);
return;
}
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,
is_sae: !!aeForm.is_sae || !!aeForm.is_susar,
is_susar: !!aeForm.is_susar,
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 || TEXT.common.messages.saveFailed);
}
};
const removeAe = async (row: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!canDeleteAe.value) {
ElMessage.warning(TEXT.common.messages.noPermission);
return;
}
if (!studyId) return;
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return;
try {
await deleteAe(studyId, row.id);
loadAes();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
}
};
const loadPds = async () => {
if (!studyId || !subjectId) return;
if (!canListPd.value) {
pdItems.value = [];
return;
}
loadingPds.value = true;
try {
const { data } = (await listSubjectPds(studyId, subjectId)) as any;
const list = Array.isArray(data) ? data : data.items || [];
const getPdSeq = (pdNo: string) => {
const text = String(pdNo || "");
const matched = text.match(/^PD(\d+)$/i);
if (!matched) return Number.POSITIVE_INFINITY;
const num = Number(matched[1]);
return Number.isFinite(num) ? num : Number.POSITIVE_INFINITY;
};
pdItems.value = list.sort((a: any, b: any) => getPdSeq(a?.pd_no) - getPdSeq(b?.pd_no));
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
loadingPds.value = false;
}
};
const openPdDialog = (row?: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
const allowed = row?.id ? canUpdatePd.value : canCreatePd.value;
if (!allowed) {
ElMessage.warning(TEXT.common.messages.noPermission);
return;
}
pdEditingId.value = row?.id || null;
Object.assign(pdForm, {
pd_no: row?.pd_no || "",
pd_type: row?.pd_type || "",
pd_level: row?.pd_level || "GENERAL",
description: row?.description || "",
status: row?.status || "OPEN",
});
pdDialogVisible.value = true;
};
const currentTabAction = computed(() => {
switch (activeTab.value) {
case "visits":
return { label: TEXT.common.actions.newVisit, loading: false, allowed: canCreateVisit.value, onClick: () => openVisitDialog() };
case "medicationAdherence":
if (adherenceEditing.value) {
return { label: "保存用药次数", loading: adherenceSaving.value, allowed: canEditAdherence.value, onClick: () => saveMedicationAdherence() };
}
return { label: "编辑", loading: false, allowed: canEditAdherence.value, onClick: () => startMedicationAdherenceEdit() };
case "ae":
return { label: TEXT.common.actions.newAe, loading: false, allowed: canCreateAe.value, onClick: () => openAeDialog() };
case "pd":
return { label: TEXT.modules.subjectDetail.newPd, loading: false, allowed: canCreatePd.value, onClick: () => openPdDialog() };
case "history":
default:
return { label: TEXT.common.actions.newHistory, loading: false, allowed: canCreateHistory.value, onClick: () => openHistoryDialog() };
}
});
const startMedicationAdherenceEdit = () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!canEditAdherence.value) {
ElMessage.warning(TEXT.common.messages.noPermission);
return;
}
adherenceForm.actual_medication_count = detail.actual_medication_count ?? null;
adherenceEditing.value = true;
};
const saveMedicationAdherence = async () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!canEditAdherence.value) {
ElMessage.warning(TEXT.common.messages.noPermission);
return;
}
if (!studyId || !subjectId) return;
adherenceSaving.value = true;
try {
await updateSubject(studyId, subjectId, {
actual_medication_count: adherenceForm.actual_medication_count ?? null,
});
adherenceEditing.value = false;
ElMessage.success(TEXT.common.messages.saveSuccess);
await loadSubject();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
adherenceSaving.value = false;
}
};
const savePd = async () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!canSavePd.value) {
ElMessage.warning(TEXT.common.messages.noPermission);
return;
}
if (!studyId || !subjectId) return;
if (!pdForm.pd_type?.trim()) {
ElMessage.error(TEXT.common.messages.required);
return;
}
try {
const basePayload = {
pd_type: pdForm.pd_type.trim(),
pd_level: pdForm.pd_level,
description: pdForm.description?.trim() || null,
status: pdForm.status,
};
if (pdEditingId.value) {
await updateSubjectPd(studyId, subjectId, pdEditingId.value, basePayload);
} else {
await createSubjectPd(studyId, subjectId, { subject_id: subjectId, ...basePayload });
}
pdDialogVisible.value = false;
loadPds();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
}
};
const removePd = async (row: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!canDeletePd.value) {
ElMessage.warning(TEXT.common.messages.noPermission);
return;
}
if (!studyId || !subjectId) return;
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return;
try {
await deleteSubjectPd(studyId, subjectId, row.id);
loadPds();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
}
};
const goBack = () => router.push("/subjects");
watch(
() => route.query.tab,
() => {
activeTab.value = resolveTabFromQuery();
}
);
onMounted(async () => {
await loadSites();
await loadSubject();
loadHistories();
loadVisits();
loadAes();
loadPds();
});
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.subject-shell {
border: 0;
border-radius: 0;
box-shadow: none;
}
.subject-shell :deep(.el-card__body) {
padding: 0;
}
.subject-tabs-shell {
margin-top: 12px;
border-top: 8px solid #f3f6fb;
}
.subject-tabs-shell {
position: relative;
}
.subject-tabs-shell :deep(.el-tabs__header) {
margin: 0;
padding: 0 160px 0 0;
background: #ffffff;
border-top: 1px solid #e5edf6;
border-bottom: 1px solid #e5edf6;
}
.subject-tabs-shell :deep(.el-tabs__nav-wrap) {
padding-left: 0;
}
.subject-tabs-shell :deep(.el-tabs__item) {
height: 48px;
line-height: 48px;
font-weight: 700;
}
.subject-tabs-shell :deep(.el-tabs__content) {
padding-top: 0;
background: #ffffff;
}
.subject-tabs-action {
position: absolute;
top: 8px;
right: 0;
z-index: 2;
display: flex;
align-items: center;
gap: 8px;
}
.subject-overview-descriptions :deep(.el-descriptions__table) {
table-layout: fixed;
}
.visit-planned-cell {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
line-height: 1.35;
}
.visit-window-range {
color: #8a97ab;
font-size: 12px;
}
.adherence-panel {
padding: 16px 0 0;
}
.adherence-count-input {
width: 160px;
}
.adherence-hint {
margin-top: 12px;
color: #6b778c;
font-size: 13px;
}
.early-termination-hint {
margin-top: 10px;
color: #6b778c;
font-size: 13px;
line-height: 1.6;
}
.subject-overview-descriptions :deep(.el-descriptions__label) {
width: 140px;
min-width: 140px;
color: #344258;
font-weight: 700;
}
.subject-overview-descriptions :deep(.el-descriptions__content) {
width: calc(50% - 140px);
min-width: 0;
}
.subject-overview-descriptions :deep(.el-descriptions__cell) {
height: 42px;
vertical-align: middle;
}
.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;
}
.subject-detail-table :deep(.el-table__inner-wrapper::before) {
display: none;
}
.ae-type-tag {
border: none;
}
.ae-type-ae {
color: #ffffff;
background-color: #3b82f6;
}
.ae-type-sae {
color: #ffffff;
background-color: #ef4444;
}
.ae-type-susar {
color: #ffffff;
background-color: #8b5cf6;
}
.table-empty {
min-height: 220px;
display: flex;
align-items: center;
justify-content: center;
color: #8a97ab;
font-size: 14px;
font-weight: 500;
letter-spacing: 0.02em;
}
</style>