1542 lines
54 KiB
Vue
1542 lines
54 KiB
Vue
<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"
|
||
@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 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>{{ 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.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 type="primary" :loading="currentTabAction.loading" :disabled="isReadOnly" @click="currentTabAction.onClick">
|
||
{{ currentTabAction.label }}
|
||
</el-button>
|
||
<el-button v-if="activeTab === 'visits'" 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 :label="TEXT.common.labels.actions" width="140">
|
||
<template #default="scope">
|
||
<el-button
|
||
link
|
||
type="primary"
|
||
size="small"
|
||
:disabled="isReadOnly"
|
||
@click="openHistoryDialog(scope.row)"
|
||
>
|
||
{{ TEXT.common.actions.edit }}
|
||
</el-button>
|
||
<el-button
|
||
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 :label="TEXT.common.labels.actions" width="140">
|
||
<template #default="scope">
|
||
<template v-if="visitEditingRowId === scope.row.id">
|
||
<el-button 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
|
||
link
|
||
type="primary"
|
||
size="small"
|
||
:disabled="isReadOnly"
|
||
@click="startVisitRowEdit(scope.row)"
|
||
>
|
||
{{ TEXT.common.actions.edit }}
|
||
</el-button>
|
||
<el-button
|
||
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"
|
||
/>
|
||
<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 :label="TEXT.common.labels.actions" width="140">
|
||
<template #default="scope">
|
||
<el-button
|
||
link
|
||
type="primary"
|
||
size="small"
|
||
:disabled="isReadOnly"
|
||
@click="openAeDialog(scope.row)"
|
||
>
|
||
{{ TEXT.common.actions.edit }}
|
||
</el-button>
|
||
<el-button
|
||
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 :label="TEXT.common.labels.actions" width="140">
|
||
<template #default="scope">
|
||
<el-button
|
||
link
|
||
type="primary"
|
||
size="small"
|
||
:disabled="isReadOnly"
|
||
@click="openPdDialog(scope.row)"
|
||
>
|
||
{{ TEXT.common.actions.edit }}
|
||
</el-button>
|
||
<el-button
|
||
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" @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" @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" @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" @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" @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 { 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 { TEXT } from "../../locales";
|
||
|
||
const route = useRoute();
|
||
const router = useRouter();
|
||
const study = useStudyStore();
|
||
const subjectId = route.params.subjectId as string;
|
||
const studyId = study.currentStudy?.id || "";
|
||
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({
|
||
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 activeTab = ref(resolveTabFromQuery());
|
||
|
||
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 earlyTerminationDialogVisible = ref(false);
|
||
const earlyTerminationSaving = 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: "",
|
||
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 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 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 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 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;
|
||
}
|
||
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 (!studyId || !subjectId) return;
|
||
subjectSaving.value = true;
|
||
try {
|
||
const payload = {
|
||
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;
|
||
}
|
||
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 (!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 (!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;
|
||
}
|
||
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;
|
||
}
|
||
earlyTerminationForm.termination_date = "";
|
||
earlyTerminationForm.reason = "";
|
||
earlyTerminationForm.notes = "";
|
||
earlyTerminationDialogVisible.value = true;
|
||
};
|
||
|
||
const startVisitRowEdit = (row: any) => {
|
||
if (isReadOnly.value) {
|
||
ElMessage.warning("中心已停用");
|
||
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 (!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 (!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 (!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 (!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;
|
||
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;
|
||
}
|
||
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 (!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 (!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;
|
||
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;
|
||
}
|
||
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, onClick: () => openVisitDialog() };
|
||
case "medicationAdherence":
|
||
if (adherenceEditing.value) {
|
||
return { label: "保存用药次数", loading: adherenceSaving.value, onClick: () => saveMedicationAdherence() };
|
||
}
|
||
return { label: "编辑", loading: false, onClick: () => startMedicationAdherenceEdit() };
|
||
case "ae":
|
||
return { label: TEXT.common.actions.newAe, onClick: () => openAeDialog() };
|
||
case "pd":
|
||
return { label: TEXT.modules.subjectDetail.newPd, onClick: () => openPdDialog() };
|
||
case "history":
|
||
default:
|
||
return { label: TEXT.common.actions.newHistory, onClick: () => openHistoryDialog() };
|
||
}
|
||
});
|
||
|
||
const startMedicationAdherenceEdit = () => {
|
||
if (isReadOnly.value) {
|
||
ElMessage.warning("中心已停用");
|
||
return;
|
||
}
|
||
adherenceForm.actual_medication_count = detail.actual_medication_count ?? null;
|
||
adherenceEditing.value = true;
|
||
};
|
||
|
||
const saveMedicationAdherence = async () => {
|
||
if (isReadOnly.value) {
|
||
ElMessage.warning("中心已停用");
|
||
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 (!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 (!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>
|