小优化-立项配置-预览模式下“更新”显示

This commit is contained in:
Cheng Zhou
2026-02-28 16:41:20 +08:00
parent 9b36a42e6f
commit 6c5e5770c6
2 changed files with 697 additions and 89 deletions
+34 -19
View File
@@ -1,4 +1,5 @@
import uuid
from copy import deepcopy
from datetime import datetime
from sqlalchemy import delete as sa_delete, select
@@ -13,7 +14,7 @@ from app.schemas.study_setup_config import StudySetupConfigData
def _to_storage(data: StudySetupConfigData | dict) -> dict:
if isinstance(data, StudySetupConfigData):
return data.model_dump(mode="json")
return data
return deepcopy(data)
async def get_by_study(db: AsyncSession, study_id: uuid.UUID) -> StudySetupConfig | None:
@@ -76,31 +77,45 @@ async def publish(
return None, False
if expected_version is not None and expected_version != existing.version:
return None, True
publish_payload = _to_storage(existing.config or {})
next_version = existing.version + 1
next_display_version_stmt = select(func.max(StudySetupConfigVersion.display_version)).where(
StudySetupConfigVersion.study_id == study_id
latest_snapshot_stmt = (
select(StudySetupConfigVersion)
.where(StudySetupConfigVersion.study_id == study_id)
.order_by(StudySetupConfigVersion.version.desc(), StudySetupConfigVersion.published_at.desc())
.limit(1)
)
display_version_result = await db.execute(next_display_version_stmt)
current_max_display_version = display_version_result.scalar_one_or_none() or 0
next_display_version = current_max_display_version + 1
latest_snapshot_result = await db.execute(latest_snapshot_stmt)
latest_snapshot = latest_snapshot_result.scalar_one_or_none()
should_create_snapshot = not latest_snapshot or (latest_snapshot.config or {}) != publish_payload
existing.version = next_version
existing.publish_status = "PUBLISHED"
existing.published_config = existing.config
existing.published_config = _to_storage(publish_payload)
existing.published_by = published_by
existing.published_at = datetime.utcnow()
existing.saved_by = published_by
existing.updated_at = datetime.utcnow()
snapshot = StudySetupConfigVersion(
study_setup_config_id=existing.id,
study_id=study_id,
version=next_version,
display_version=next_display_version,
source_version=next_version - 1,
config=existing.config,
published_by=published_by,
published_at=existing.published_at,
)
db.add(snapshot)
if should_create_snapshot:
next_display_version_stmt = select(func.max(StudySetupConfigVersion.display_version)).where(
StudySetupConfigVersion.study_id == study_id
)
display_version_result = await db.execute(next_display_version_stmt)
current_max_display_version = display_version_result.scalar_one_or_none() or 0
next_display_version = current_max_display_version + 1
snapshot = StudySetupConfigVersion(
study_setup_config_id=existing.id,
study_id=study_id,
version=next_version,
display_version=next_display_version,
source_version=next_version - 1,
config=_to_storage(publish_payload),
published_by=published_by,
published_at=existing.published_at,
)
db.add(snapshot)
if auto_commit:
await db.commit()
await db.refresh(existing)
@@ -143,7 +158,7 @@ async def rollback_to_version(
return None, False, True
existing.version = existing.version + 1
existing.config = target.config
existing.config = _to_storage(target.config or {})
existing.saved_by = saved_by
existing.publish_status = "DRAFT"
existing.updated_at = datetime.utcnow()
+663 -70
View File
@@ -43,7 +43,7 @@
</div>
<div class="setup-toolbar-actions">
<el-button type="success" plain :loading="primarySaveLoading" @click="handlePrimarySaveConfig">保存配置</el-button>
<el-button type="success" plain :loading="primarySaveLoading" :disabled="isPublishedView" @click="handlePrimarySaveConfig">保存配置</el-button>
<el-button type="warning" plain :loading="publishConfirmLoading" @click="publishConfigNow">发布版本</el-button>
<el-button
type="info"
@@ -358,9 +358,25 @@
</el-button>
</div>
<div class="info-grid">
<div v-for="item in projectInfoItems" :key="`project-${item.label}`" class="info-item">
<div
v-for="item in projectInfoItems"
:key="`project-${item.label}`"
class="info-item"
:class="{ 'is-updated': item.updated && shouldShowPreviewUpdateMark }"
>
<span class="info-label">{{ item.label }}</span>
<span class="info-value">{{ item.value || "-" }}</span>
<span class="info-value-wrap">
<span class="info-value">{{ item.value || "-" }}</span>
<el-tag
v-if="item.updated && shouldShowPreviewUpdateMark"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</div>
</div>
</div>
@@ -378,9 +394,25 @@
</el-button>
</div>
<div class="info-grid">
<div v-for="item in researchInfoItems" :key="`research-${item.label}`" class="info-item">
<div
v-for="item in researchInfoItems"
:key="`research-${item.label}`"
class="info-item"
:class="{ 'is-updated': item.updated && shouldShowPreviewUpdateMark }"
>
<span class="info-label">{{ item.label }}</span>
<span class="info-value">{{ item.value || "-" }}</span>
<span class="info-value-wrap">
<span class="info-value">{{ item.value || "-" }}</span>
<el-tag
v-if="item.updated && shouldShowPreviewUpdateMark"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</div>
</div>
</div>
@@ -398,9 +430,25 @@
</el-button>
</div>
<div class="info-grid">
<div v-for="item in executionInfoItems" :key="`execution-${item.label}`" class="info-item">
<div
v-for="item in executionInfoItems"
:key="`execution-${item.label}`"
class="info-item"
:class="{ 'is-updated': item.updated && shouldShowPreviewUpdateMark }"
>
<span class="info-label">{{ item.label }}</span>
<span class="info-value">{{ item.value || "-" }}</span>
<span class="info-value-wrap">
<span class="info-value">{{ item.value || "-" }}</span>
<el-tag
v-if="item.updated && shouldShowPreviewUpdateMark"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</div>
</div>
</div>
@@ -446,9 +494,25 @@
</el-form>
<div v-else class="info-grid">
<div v-for="item in summaryInfoItems" :key="item.label" class="info-item">
<div
v-for="item in summaryInfoItems"
:key="item.label"
class="info-item"
:class="{ 'is-updated': item.updated && shouldShowPreviewUpdateMark }"
>
<span class="info-label">{{ item.label }}</span>
<span class="info-value">{{ item.value || "-" }}</span>
<span class="info-value-wrap">
<span class="info-value">{{ item.value || "-" }}</span>
<el-tag
v-if="item.updated && shouldShowPreviewUpdateMark"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</div>
</div>
</el-tab-pane>
@@ -462,7 +526,18 @@
<el-table :data="currentSetupDraft.projectMilestones" class="ctms-table setup-milestone-table">
<el-table-column label="里程碑" min-width="180">
<template #default="scope">
<span class="milestone-cell-text">{{ scope.row.name || "-" }}</span>
<span class="updated-value-wrap">
<span class="milestone-cell-text">{{ scope.row.name || "-" }}</span>
<el-tag
v-if="isProjectMilestoneFieldUpdated(scope.row, scope.$index, 'name')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column label="计划时间" width="240">
@@ -471,34 +546,100 @@
<div class="milestone-plan-line">
<span class="milestone-plan-dot" />
<span class="milestone-plan-label">开始:</span>
<span>{{ resolveProjectMilestoneStart(scope.row) || "-" }}</span>
<span class="updated-value-wrap">
<span>{{ resolveProjectMilestoneStart(scope.row) || "-" }}</span>
<el-tag
v-if="isProjectMilestoneFieldUpdated(scope.row, scope.$index, 'startDate')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</div>
<div class="milestone-plan-line">
<span class="milestone-plan-dot" />
<span class="milestone-plan-label">结束:</span>
<span>{{ resolveProjectMilestoneEnd(scope.row) || "-" }}</span>
<span class="updated-value-wrap">
<span>{{ resolveProjectMilestoneEnd(scope.row) || "-" }}</span>
<el-tag
v-if="isProjectMilestoneFieldUpdated(scope.row, scope.$index, 'endDate')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</div>
<div class="milestone-plan-line">
<span class="milestone-plan-dot" />
<span class="milestone-plan-label">耗时:</span>
<span>{{ formatProjectMilestoneDuration(scope.row) }}</span>
<span class="updated-value-wrap">
<span>{{ formatProjectMilestoneDuration(scope.row) }}</span>
<el-tag
v-if="isProjectMilestoneFieldUpdated(scope.row, scope.$index, 'duration')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="负责人" min-width="160">
<template #default="scope">
<span class="milestone-cell-text">{{ normalizeOwnerDisplay(scope.row.owner) }}</span>
<span class="updated-value-wrap">
<span class="milestone-cell-text">{{ normalizeOwnerDisplay(scope.row.owner) }}</span>
<el-tag
v-if="isProjectMilestoneFieldUpdated(scope.row, scope.$index, 'owner')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column label="状态" width="150">
<template #default="scope">
<span class="milestone-cell-text">{{ scope.row.status || "-" }}</span>
<span class="updated-value-wrap">
<span class="milestone-cell-text">{{ scope.row.status || "-" }}</span>
<el-tag
v-if="isProjectMilestoneFieldUpdated(scope.row, scope.$index, 'status')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column label="备注" min-width="220">
<template #default="scope">
<span class="milestone-cell-text">{{ scope.row.remark || "-" }}</span>
<span class="updated-value-wrap">
<span class="milestone-cell-text">{{ scope.row.remark || "-" }}</span>
<el-tag
v-if="isProjectMilestoneFieldUpdated(scope.row, scope.$index, 'remark')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column v-if="!isPublishedView" label="操作" width="130">
@@ -525,7 +666,18 @@
:disabled="!canEditSetup"
class="w-full"
/>
<span v-else class="enrollment-inline-value">{{ currentSetupDraft.enrollmentPlan.startDate || "-" }}</span>
<span v-else class="updated-value-wrap">
<span class="enrollment-inline-value">{{ currentSetupDraft.enrollmentPlan.startDate || "-" }}</span>
<el-tag
v-if="isEnrollmentPlanFieldUpdated('startDate')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
<div v-if="getFieldError('enrollmentPlan.startDate')" class="field-error">{{ getFieldError("enrollmentPlan.startDate") }}</div>
</div>
<span class="enrollment-sep">~</span>
@@ -538,7 +690,18 @@
:disabled="!canEditSetup"
class="w-full"
/>
<span v-else class="enrollment-inline-value">{{ currentSetupDraft.enrollmentPlan.endDate || "-" }}</span>
<span v-else class="updated-value-wrap">
<span class="enrollment-inline-value">{{ currentSetupDraft.enrollmentPlan.endDate || "-" }}</span>
<el-tag
v-if="isEnrollmentPlanFieldUpdated('endDate')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
<div v-if="getFieldError('enrollmentPlan.endDate')" class="field-error">{{ getFieldError("enrollmentPlan.endDate") }}</div>
</div>
</div>
@@ -553,7 +716,18 @@
<el-radio-button label="month"></el-radio-button>
<el-radio-button label="quarter">季度</el-radio-button>
</el-radio-group>
<span v-else class="enrollment-inline-value">{{ enrollmentPlanCycleLabel }}</span>
<span v-else class="updated-value-wrap">
<span class="enrollment-inline-value">{{ enrollmentPlanCycleLabel }}</span>
<el-tag
v-if="isEnrollmentPlanFieldUpdated('cycle')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</div>
<div class="enrollment-inline-group total-target-group">
<span class="enrollment-inline-label">计划入组总数</span>
@@ -566,7 +740,18 @@
controls-position="right"
class="w-full"
/>
<span v-else class="enrollment-inline-value">{{ toDisplayNumber(currentSetupDraft.enrollmentPlan.totalTarget) }}</span>
<span v-else class="updated-value-wrap">
<span class="enrollment-inline-value">{{ toDisplayNumber(currentSetupDraft.enrollmentPlan.totalTarget) }}</span>
<el-tag
v-if="isEnrollmentPlanFieldUpdated('totalTarget')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
<div v-if="getFieldError('enrollmentPlan.totalTarget')" class="field-error">{{ getFieldError("enrollmentPlan.totalTarget") }}</div>
</div>
</div>
@@ -620,7 +805,18 @@
class="month-target-input"
@update:model-value="setEnrollmentPeriodTarget(scope.row.cells[month - 1].key, $event as number | null)"
/>
<span v-else class="milestone-cell-text">{{ displayEnrollmentPeriodTarget(scope.row.cells[month - 1].key) }}</span>
<span v-else class="updated-value-wrap">
<span class="milestone-cell-text">{{ displayEnrollmentPeriodTarget(scope.row.cells[month - 1].key) }}</span>
<el-tag
v-if="isProjectEnrollmentPeriodUpdated(scope.row.cells[month - 1].key)"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
<span v-else class="month-empty">-</span>
</template>
@@ -656,7 +852,18 @@
class="month-target-input"
@update:model-value="setEnrollmentPeriodTarget(scope.row.cells[columnIndex].key, $event as number | null)"
/>
<span v-else class="milestone-cell-text">{{ displayEnrollmentPeriodTarget(scope.row.cells[columnIndex].key) }}</span>
<span v-else class="updated-value-wrap">
<span class="milestone-cell-text">{{ displayEnrollmentPeriodTarget(scope.row.cells[columnIndex].key) }}</span>
<el-tag
v-if="isProjectEnrollmentPeriodUpdated(scope.row.cells[columnIndex].key)"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
<span v-else class="month-empty">-</span>
</template>
@@ -668,27 +875,82 @@
<el-table :data="currentSetupDraft.siteMilestones" class="ctms-table">
<el-table-column label="里程碑" min-width="220">
<template #default="scope">
<span class="milestone-cell-text">{{ scope.row.milestone || "-" }}</span>
<span class="updated-value-wrap">
<span class="milestone-cell-text">{{ scope.row.milestone || "-" }}</span>
<el-tag
v-if="isSiteMilestoneFieldUpdated(scope.row, scope.$index, 'milestone')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column label="计划日期" width="180">
<template #default="scope">
<span class="milestone-cell-text">{{ scope.row.planDate || "-" }}</span>
<span class="updated-value-wrap">
<span class="milestone-cell-text">{{ scope.row.planDate || "-" }}</span>
<el-tag
v-if="isSiteMilestoneFieldUpdated(scope.row, scope.$index, 'planDate')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column label="负责人" min-width="160">
<template #default="scope">
<span class="milestone-cell-text">{{ normalizeOwnerDisplay(scope.row.owner) }}</span>
<span class="updated-value-wrap">
<span class="milestone-cell-text">{{ normalizeOwnerDisplay(scope.row.owner) }}</span>
<el-tag
v-if="isSiteMilestoneFieldUpdated(scope.row, scope.$index, 'owner')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column label="状态" width="150">
<template #default="scope">
<span class="milestone-cell-text">{{ scope.row.status || "未开始" }}</span>
<span class="updated-value-wrap">
<span class="milestone-cell-text">{{ scope.row.status || "未开始" }}</span>
<el-tag
v-if="isSiteMilestoneFieldUpdated(scope.row, scope.$index, 'status')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column label="备注" min-width="180">
<template #default="scope">
<span class="milestone-cell-text">{{ scope.row.remark || "-" }}</span>
<span class="updated-value-wrap">
<span class="milestone-cell-text">{{ scope.row.remark || "-" }}</span>
<el-tag
v-if="isSiteMilestoneFieldUpdated(scope.row, scope.$index, 'remark')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column v-if="!isPublishedView" label="操作" width="130">
@@ -735,7 +997,18 @@
:disabled="!canEditSetup"
class="w-full"
/>
<span v-else class="enrollment-inline-value">{{ selectedSiteEnrollmentPlan?.startDate || "-" }}</span>
<span v-else class="updated-value-wrap">
<span class="enrollment-inline-value">{{ selectedSiteEnrollmentPlan?.startDate || "-" }}</span>
<el-tag
v-if="isSelectedSiteEnrollmentFieldUpdated('startDate')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</div>
<span class="enrollment-sep">~</span>
<div class="field-cell enrollment-date-cell">
@@ -747,7 +1020,18 @@
:disabled="!canEditSetup"
class="w-full"
/>
<span v-else class="enrollment-inline-value">{{ selectedSiteEnrollmentPlan?.endDate || "-" }}</span>
<span v-else class="updated-value-wrap">
<span class="enrollment-inline-value">{{ selectedSiteEnrollmentPlan?.endDate || "-" }}</span>
<el-tag
v-if="isSelectedSiteEnrollmentFieldUpdated('endDate')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</div>
</div>
<div class="enrollment-inline-group">
@@ -761,7 +1045,18 @@
<el-radio-button label="month"></el-radio-button>
<el-radio-button label="quarter">季度</el-radio-button>
</el-radio-group>
<span v-else class="enrollment-inline-value">{{ enrollmentPlanCycleLabel }}</span>
<span v-else class="updated-value-wrap">
<span class="enrollment-inline-value">{{ enrollmentPlanCycleLabel }}</span>
<el-tag
v-if="isSelectedSiteEnrollmentFieldUpdated('cycle')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</div>
<div class="enrollment-inline-group total-target-group">
<span class="enrollment-inline-label">中心计划总数</span>
@@ -774,7 +1069,18 @@
controls-position="right"
class="w-full"
/>
<span v-else class="enrollment-inline-value">{{ toDisplayNumber(selectedSiteEnrollmentPlan?.target) }}</span>
<span v-else class="updated-value-wrap">
<span class="enrollment-inline-value">{{ toDisplayNumber(selectedSiteEnrollmentPlan?.target) }}</span>
<el-tag
v-if="isSelectedSiteEnrollmentFieldUpdated('target')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</div>
</div>
</div>
@@ -841,7 +1147,18 @@
class="month-target-input"
@update:model-value="setSiteEnrollmentPeriodTarget(selectedSiteEnrollmentPlanIndex, scope.row.cells[month - 1].key, $event as number | null)"
/>
<span v-else class="milestone-cell-text">{{ displaySiteEnrollmentPeriodTarget(selectedSiteEnrollmentPlan, scope.row.cells[month - 1].key) }}</span>
<span v-else class="updated-value-wrap">
<span class="milestone-cell-text">{{ displaySiteEnrollmentPeriodTarget(selectedSiteEnrollmentPlan, scope.row.cells[month - 1].key) }}</span>
<el-tag
v-if="isSelectedSiteEnrollmentPeriodUpdated(scope.row.cells[month - 1].key)"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
<span v-else class="month-empty">-</span>
</template>
@@ -877,7 +1194,18 @@
class="month-target-input"
@update:model-value="setSiteEnrollmentPeriodTarget(selectedSiteEnrollmentPlanIndex, scope.row.cells[columnIndex].key, $event as number | null)"
/>
<span v-else class="milestone-cell-text">{{ displaySiteEnrollmentPeriodTarget(selectedSiteEnrollmentPlan, scope.row.cells[columnIndex].key) }}</span>
<span v-else class="updated-value-wrap">
<span class="milestone-cell-text">{{ displaySiteEnrollmentPeriodTarget(selectedSiteEnrollmentPlan, scope.row.cells[columnIndex].key) }}</span>
<el-tag
v-if="isSelectedSiteEnrollmentPeriodUpdated(scope.row.cells[columnIndex].key)"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
<span v-else class="month-empty">-</span>
</template>
@@ -890,22 +1218,66 @@
<el-table :data="currentSetupDraft.monitoringStrategies" class="ctms-table">
<el-table-column label="监查类型" min-width="180">
<template #default="scope">
<span class="milestone-cell-text">{{ scope.row.strategyType || "-" }}</span>
<span class="updated-value-wrap">
<span class="milestone-cell-text">{{ scope.row.strategyType || "-" }}</span>
<el-tag
v-if="isMonitoringStrategyFieldUpdated(scope.row, scope.$index, 'strategyType')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column label="策略详情" min-width="430">
<template #default="scope">
<span class="milestone-cell-text">{{ scope.row.detail || "-" }}</span>
<span class="updated-value-wrap">
<span class="milestone-cell-text">{{ scope.row.detail || "-" }}</span>
<el-tag
v-if="isMonitoringStrategyFieldUpdated(scope.row, scope.$index, 'detail')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column label="监查次数" width="140">
<template #default="scope">
<span class="milestone-cell-text">{{ scope.row.frequency || "-" }}</span>
<span class="updated-value-wrap">
<span class="milestone-cell-text">{{ scope.row.frequency || "-" }}</span>
<el-tag
v-if="isMonitoringStrategyFieldUpdated(scope.row, scope.$index, 'frequency')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column label="更新时间" width="180">
<template #default="scope">
<span class="milestone-cell-text">{{ scope.row.updatedAt || "-" }}</span>
<span class="updated-value-wrap">
<span class="milestone-cell-text">{{ scope.row.updatedAt || "-" }}</span>
<el-tag
v-if="isMonitoringStrategyFieldUpdated(scope.row, scope.$index, 'updatedAt')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column v-if="!isPublishedView" label="操作" width="130">
@@ -943,7 +1315,18 @@
</el-table-column>
<el-table-column label="计划入组数" width="140">
<template #default="scope">
<span class="milestone-cell-text">{{ toDisplayNumber(scope.row.target) }}</span>
<span class="updated-value-wrap">
<span class="milestone-cell-text">{{ toDisplayNumber(scope.row.target) }}</span>
<el-tag
v-if="isCenterConfirmPlanFieldUpdated(scope.row.siteId, 'target')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column label="状态" width="120">
@@ -955,12 +1338,34 @@
</el-table-column>
<el-table-column label="入组开始时间" width="150">
<template #default="scope">
<span class="milestone-cell-text">{{ scope.row.startDate || "-" }}</span>
<span class="updated-value-wrap">
<span class="milestone-cell-text">{{ scope.row.startDate || "-" }}</span>
<el-tag
v-if="isCenterConfirmPlanFieldUpdated(scope.row.siteId, 'startDate')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column label="入组结束时间" width="150">
<template #default="scope">
<span class="milestone-cell-text">{{ scope.row.endDate || "-" }}</span>
<span class="updated-value-wrap">
<span class="milestone-cell-text">{{ scope.row.endDate || "-" }}</span>
<el-tag
v-if="isCenterConfirmPlanFieldUpdated(scope.row.siteId, 'endDate')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
</el-table-column>
</el-table>
@@ -1949,41 +2354,183 @@ const currentStepErrors = computed(() => {
return grouped[section] || [];
});
const projectInfoItems = computed(() => [
{ label: "项目名称", value: form.value.name || project.value?.name || "-" },
{ label: "项目全称", value: form.value.project_full_name || project.value?.project_full_name || "-" },
{ label: "申办方", value: form.value.sponsor || project.value?.sponsor || "-" },
{ label: "项目编号", value: form.value.code || project.value?.code || "-" },
{ label: "方案号", value: form.value.protocol_no || project.value?.protocol_no || "-" },
{ label: "组长单位", value: form.value.lead_unit || project.value?.lead_unit || "-" },
{ label: "主要研究者", value: form.value.principal_investigator || project.value?.principal_investigator || "-" },
{ label: "主PM", value: form.value.main_pm || project.value?.main_pm || "-" },
type DisplayInfoItem = {
label: string;
value: string | number | null | undefined;
updated?: boolean;
};
const shouldShowPreviewUpdateMark = computed(() => isPublishedView.value && !isFirstPublish.value);
const currentProjectPublishSnapshot = computed<ProjectPublishSnapshot>(() => buildProjectPublishSnapshot());
const normalizeCompareValue = (value: unknown): string => serializeDiffValue(value);
const isPreviewValueUpdated = (currentValue: unknown, previousValue: unknown): boolean => {
if (!shouldShowPreviewUpdateMark.value) return false;
return normalizeCompareValue(currentValue) !== normalizeCompareValue(previousValue);
};
const normalizeIdentityText = (value: unknown): string => String(value ?? "").trim();
const resolveRowCompareTarget = <T extends { id?: string }>(
rows: T[] | null | undefined,
row: T | null | undefined,
index: number,
identityBuilder: (item: T) => string
): T | null => {
const sourceRows = Array.isArray(rows) ? rows : [];
if (!row || !sourceRows.length) return null;
const rowId = normalizeIdentityText(row.id);
if (rowId) {
const matchedById = sourceRows.find((item) => normalizeIdentityText(item.id) === rowId);
if (matchedById) return matchedById;
}
const rowIdentity = identityBuilder(row);
if (rowIdentity) {
const matchedByIdentity = sourceRows.filter((item) => identityBuilder(item) === rowIdentity);
if (matchedByIdentity.length === 1) return matchedByIdentity[0];
if (matchedByIdentity.length > 1) {
return matchedByIdentity[index] || matchedByIdentity[0];
}
}
return sourceRows[index] || null;
};
const isProjectSnapshotFieldUpdated = (field: keyof ProjectPublishSnapshot): boolean =>
isPreviewValueUpdated(currentProjectPublishSnapshot.value[field], projectPublishCompareBase.value?.[field]);
const isProjectMilestoneFieldUpdated = (
row: ProjectMilestoneDraft,
index: number,
field: "name" | "owner" | "status" | "remark" | "startDate" | "endDate" | "duration"
): boolean => {
const baseRow = resolveRowCompareTarget(
publishCompareBase.value.projectMilestones,
row,
index,
(item) => `${normalizeIdentityText(item.name)}|${normalizeIdentityText(item.owner)}`
);
if (field === "startDate") return isPreviewValueUpdated(resolveProjectMilestoneStart(row), resolveProjectMilestoneStart(baseRow || undefined));
if (field === "endDate") return isPreviewValueUpdated(resolveProjectMilestoneEnd(row), resolveProjectMilestoneEnd(baseRow || undefined));
if (field === "duration") {
return isPreviewValueUpdated(
resolveProjectMilestoneDurationDays(row),
resolveProjectMilestoneDurationDays(baseRow || undefined)
);
}
return isPreviewValueUpdated(row[field], baseRow?.[field]);
};
const publishedEnrollmentPlanParsed = computed(() =>
parseEnrollmentStageBreakdown(publishCompareBase.value.enrollmentPlan.stageBreakdown)
);
const isEnrollmentPlanFieldUpdated = (field: "startDate" | "endDate" | "totalTarget" | "cycle"): boolean => {
if (field === "cycle") {
return isPreviewValueUpdated(enrollmentPlanCycle.value, publishedEnrollmentPlanParsed.value.cycle);
}
return isPreviewValueUpdated(setupDraft.enrollmentPlan[field], publishCompareBase.value.enrollmentPlan[field]);
};
const isProjectEnrollmentPeriodUpdated = (key: string | null): boolean => {
if (!key) return false;
const cycle = enrollmentPlanCycle.value;
const localValue = enrollmentTargetsByCycle.value[cycle]?.[key];
const serverValue = publishedEnrollmentPlanParsed.value.targetsByCycle[cycle]?.[key];
return isPreviewValueUpdated(localValue, serverValue);
};
const isSiteMilestoneFieldUpdated = (
row: SiteMilestoneDraft,
index: number,
field: "milestone" | "planDate" | "owner" | "status" | "remark"
): boolean => {
const baseRow = resolveRowCompareTarget(
publishCompareBase.value.siteMilestones,
row,
index,
(item) => `${normalizeIdentityText(item.milestone)}|${normalizeIdentityText(item.owner)}`
);
return isPreviewValueUpdated(row[field], baseRow?.[field]);
};
const isMonitoringStrategyFieldUpdated = (
row: MonitoringStrategyDraft,
index: number,
field: "strategyType" | "detail" | "frequency" | "updatedAt"
): boolean => {
const baseRow = resolveRowCompareTarget(
publishCompareBase.value.monitoringStrategies,
row,
index,
(item) =>
`${normalizeIdentityText(item.strategyType)}|${normalizeIdentityText(item.detail)}|${normalizeIdentityText(item.frequency)}`
);
return isPreviewValueUpdated(row[field], baseRow?.[field]);
};
const publishedSiteEnrollmentPlanMap = computed(() => {
const map = new Map<string, SiteEnrollmentPlanDraft>();
publishCompareBase.value.siteEnrollmentPlans.forEach((row) => {
const siteId = normalizeSiteId(row.siteId);
if (!siteId) return;
map.set(siteId, row);
});
return map;
});
const getPublishedSiteEnrollmentPlan = (siteId: string | null | undefined): SiteEnrollmentPlanDraft | null => {
const normalizedSiteId = normalizeSiteId(siteId || "");
if (!normalizedSiteId) return null;
return publishedSiteEnrollmentPlanMap.value.get(normalizedSiteId) || null;
};
const isSelectedSiteEnrollmentFieldUpdated = (field: "startDate" | "endDate" | "target" | "cycle"): boolean => {
if (field === "cycle") return isEnrollmentPlanFieldUpdated("cycle");
const currentPlan = selectedSiteEnrollmentPlan.value;
if (!currentPlan) return false;
const basePlan = getPublishedSiteEnrollmentPlan(currentPlan.siteId);
return isPreviewValueUpdated(currentPlan[field], basePlan?.[field]);
};
const isSelectedSiteEnrollmentPeriodUpdated = (key: string | null): boolean => {
if (!key || !selectedSiteEnrollmentPlan.value) return false;
const cycle = enrollmentPlanCycle.value;
const currentTargets = getSiteEnrollmentTargetsByCycle(selectedSiteEnrollmentPlan.value);
const basePlan = getPublishedSiteEnrollmentPlan(selectedSiteEnrollmentPlan.value.siteId);
const baseTargets = basePlan ? parseSiteEnrollmentStageBreakdown(basePlan.stageBreakdown) : createEmptyEnrollmentTargetsByCycle();
return isPreviewValueUpdated(currentTargets[cycle]?.[key], baseTargets[cycle]?.[key]);
};
const isCenterConfirmPlanFieldUpdated = (siteId: string, field: "target" | "startDate" | "endDate"): boolean => {
const currentPlan = siteEnrollmentPlanMap.value.get(normalizeSiteId(siteId));
const basePlan = getPublishedSiteEnrollmentPlan(siteId);
return isPreviewValueUpdated(currentPlan?.[field], basePlan?.[field]);
};
const projectInfoItems = computed<DisplayInfoItem[]>(() => [
{ label: "项目名称", value: form.value.name || project.value?.name || "-", updated: isProjectSnapshotFieldUpdated("name") },
{ label: "项目全称", value: form.value.project_full_name || project.value?.project_full_name || "-", updated: isProjectSnapshotFieldUpdated("project_full_name") },
{ label: "申办方", value: form.value.sponsor || project.value?.sponsor || "-", updated: isProjectSnapshotFieldUpdated("sponsor") },
{ label: "项目编号", value: form.value.code || project.value?.code || "-", updated: isProjectSnapshotFieldUpdated("code") },
{ label: "方案号", value: form.value.protocol_no || project.value?.protocol_no || "-", updated: isProjectSnapshotFieldUpdated("protocol_no") },
{ label: "组长单位", value: form.value.lead_unit || project.value?.lead_unit || "-", updated: isProjectSnapshotFieldUpdated("lead_unit") },
{ label: "主要研究者", value: form.value.principal_investigator || project.value?.principal_investigator || "-", updated: isProjectSnapshotFieldUpdated("principal_investigator") },
{ label: "主PM", value: form.value.main_pm || project.value?.main_pm || "-", updated: isProjectSnapshotFieldUpdated("main_pm") },
]);
const researchInfoItems = computed(() => [
{ label: "研究分期", value: form.value.research_analysis || project.value?.research_analysis || "-" },
{ label: "研究产品", value: form.value.research_product || project.value?.research_product || "-" },
{ label: "对照产品", value: form.value.control_product || project.value?.control_product || "-" },
{ label: "适应症", value: form.value.indication || project.value?.indication || "-" },
{ label: "研究人群", value: form.value.research_population || project.value?.research_population || "-" },
{ label: "研究设计", value: form.value.research_design || project.value?.research_design || "-" },
const researchInfoItems = computed<DisplayInfoItem[]>(() => [
{ label: "研究分期", value: form.value.research_analysis || project.value?.research_analysis || "-", updated: isProjectSnapshotFieldUpdated("research_analysis") },
{ label: "研究产品", value: form.value.research_product || project.value?.research_product || "-", updated: isProjectSnapshotFieldUpdated("research_product") },
{ label: "对照产品", value: form.value.control_product || project.value?.control_product || "-", updated: isProjectSnapshotFieldUpdated("control_product") },
{ label: "适应症", value: form.value.indication || project.value?.indication || "-", updated: isProjectSnapshotFieldUpdated("indication") },
{ label: "研究人群", value: form.value.research_population || project.value?.research_population || "-", updated: isProjectSnapshotFieldUpdated("research_population") },
{ label: "研究设计", value: form.value.research_design || project.value?.research_design || "-", updated: isProjectSnapshotFieldUpdated("research_design") },
]);
const executionInfoItems = computed(() => [
{ label: "计划开始日期", value: form.value.plan_start_date || project.value?.plan_start_date || "-" },
{ label: "计划结束日期", value: form.value.plan_end_date || project.value?.plan_end_date || "-" },
{ label: "项目状态", value: statusLabel(form.value.status || project.value?.status || "DRAFT") },
{ label: "计划中心数", value: toDisplayNumber(form.value.planned_site_count) },
{ label: "计划入组数", value: toDisplayNumber(setupDraft.enrollmentPlan.totalTarget) },
const executionInfoItems = computed<DisplayInfoItem[]>(() => [
{ label: "计划开始日期", value: form.value.plan_start_date || project.value?.plan_start_date || "-", updated: isProjectSnapshotFieldUpdated("plan_start_date") },
{ label: "计划结束日期", value: form.value.plan_end_date || project.value?.plan_end_date || "-", updated: isProjectSnapshotFieldUpdated("plan_end_date") },
{ label: "项目状态", value: statusLabel(form.value.status || project.value?.status || "DRAFT"), updated: isProjectSnapshotFieldUpdated("status") },
{ label: "计划中心数", value: toDisplayNumber(form.value.planned_site_count), updated: isProjectSnapshotFieldUpdated("planned_site_count") },
{
label: "计划入组数",
value: toDisplayNumber(setupDraft.enrollmentPlan.totalTarget),
updated: isEnrollmentPlanFieldUpdated("totalTarget"),
},
]);
const summaryInfoItems = computed(() => [
{ label: "访视总数", value: toDisplayNumber(form.value.visit_total) },
{ label: "访视间隔(天)", value: toDisplayNumber(form.value.visit_interval_days) },
{ label: "窗口期起始偏移", value: toDisplayNumber(form.value.visit_window_start_offset) },
{ label: "窗口期结束偏移", value: toDisplayNumber(form.value.visit_window_end_offset) },
{ label: "方案摘要说明", value: form.value.summary_note || "-" },
{ label: "研究目标摘要", value: form.value.objective_note || "-" },
const summaryInfoItems = computed<DisplayInfoItem[]>(() => [
{ label: "访视总数", value: toDisplayNumber(form.value.visit_total), updated: isProjectSnapshotFieldUpdated("visit_total") },
{ label: "访视间隔(天)", value: toDisplayNumber(form.value.visit_interval_days), updated: isProjectSnapshotFieldUpdated("visit_interval_days") },
{ label: "窗口期起始偏移", value: toDisplayNumber(form.value.visit_window_start_offset), updated: isProjectSnapshotFieldUpdated("visit_window_start_offset") },
{ label: "窗口期结束偏移", value: toDisplayNumber(form.value.visit_window_end_offset), updated: isProjectSnapshotFieldUpdated("visit_window_end_offset") },
{ label: "方案摘要说明", value: form.value.summary_note || "-", updated: isProjectSnapshotFieldUpdated("summary_note") },
{ label: "研究目标摘要", value: form.value.objective_note || "-", updated: isProjectSnapshotFieldUpdated("objective_note") },
]);
const rules = ref<FormRules>({
@@ -4027,8 +4574,28 @@ const removeVersion = async (targetVersion: number) => {
}
};
const ensureCanSaveDraftAction = (): boolean => {
if (!project.value) {
ElMessage.warning("项目信息尚未加载完成,请稍后重试");
return false;
}
if (!canManageSetup.value) {
ElMessage.warning("当前角色无权保存立项配置");
return false;
}
if (project.value.is_locked) {
ElMessage.warning("项目已锁定,无法保存配置");
return false;
}
if (isPublishedView.value) {
ElMessage.info("当前为预览视图,请先切换到草稿后再保存");
return false;
}
return true;
};
const handlePrimarySaveConfig = async () => {
if (!canSaveDraftAction.value) return;
if (!ensureCanSaveDraftAction()) return;
if (incompleteSteps.value.length > 0) {
try {
await ElMessageBox.confirm(
@@ -4820,6 +5387,26 @@ onBeforeUnmount(() => {
color: #11264d;
}
.updated-value-wrap {
display: inline-block;
position: relative;
vertical-align: middle;
}
.field-updated-tag {
position: absolute;
left: calc(100% + 6px);
top: 50%;
transform: translateY(-50%);
margin: 0 !important;
z-index: 1;
pointer-events: none;
--el-tag-bg-color: #fff8eb;
--el-tag-border-color: #ecd2a3;
--el-tag-text-color: #9a6b0d;
font-weight: 600;
}
.enrollment-sep {
color: #8da3c7;
font-weight: 600;
@@ -5307,6 +5894,12 @@ onBeforeUnmount(() => {
padding-top: 4px;
}
.info-value-wrap {
display: inline-block;
position: relative;
vertical-align: middle;
}
.setup-info-tabs {
margin-top: -8px;
}