小优化-立项配置-预览模式下“更新”显示
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
from copy import deepcopy
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import delete as sa_delete, select
|
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:
|
def _to_storage(data: StudySetupConfigData | dict) -> dict:
|
||||||
if isinstance(data, StudySetupConfigData):
|
if isinstance(data, StudySetupConfigData):
|
||||||
return data.model_dump(mode="json")
|
return data.model_dump(mode="json")
|
||||||
return data
|
return deepcopy(data)
|
||||||
|
|
||||||
|
|
||||||
async def get_by_study(db: AsyncSession, study_id: uuid.UUID) -> StudySetupConfig | None:
|
async def get_by_study(db: AsyncSession, study_id: uuid.UUID) -> StudySetupConfig | None:
|
||||||
@@ -76,31 +77,45 @@ async def publish(
|
|||||||
return None, False
|
return None, False
|
||||||
if expected_version is not None and expected_version != existing.version:
|
if expected_version is not None and expected_version != existing.version:
|
||||||
return None, True
|
return None, True
|
||||||
|
publish_payload = _to_storage(existing.config or {})
|
||||||
next_version = existing.version + 1
|
next_version = existing.version + 1
|
||||||
|
latest_snapshot_stmt = (
|
||||||
|
select(StudySetupConfigVersion)
|
||||||
|
.where(StudySetupConfigVersion.study_id == study_id)
|
||||||
|
.order_by(StudySetupConfigVersion.version.desc(), StudySetupConfigVersion.published_at.desc())
|
||||||
|
.limit(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 = _to_storage(publish_payload)
|
||||||
|
existing.published_by = published_by
|
||||||
|
existing.published_at = datetime.utcnow()
|
||||||
|
existing.saved_by = published_by
|
||||||
|
existing.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
if should_create_snapshot:
|
||||||
next_display_version_stmt = select(func.max(StudySetupConfigVersion.display_version)).where(
|
next_display_version_stmt = select(func.max(StudySetupConfigVersion.display_version)).where(
|
||||||
StudySetupConfigVersion.study_id == study_id
|
StudySetupConfigVersion.study_id == study_id
|
||||||
)
|
)
|
||||||
display_version_result = await db.execute(next_display_version_stmt)
|
display_version_result = await db.execute(next_display_version_stmt)
|
||||||
current_max_display_version = display_version_result.scalar_one_or_none() or 0
|
current_max_display_version = display_version_result.scalar_one_or_none() or 0
|
||||||
next_display_version = current_max_display_version + 1
|
next_display_version = current_max_display_version + 1
|
||||||
existing.version = next_version
|
|
||||||
existing.publish_status = "PUBLISHED"
|
|
||||||
existing.published_config = existing.config
|
|
||||||
existing.published_by = published_by
|
|
||||||
existing.published_at = datetime.utcnow()
|
|
||||||
existing.saved_by = published_by
|
|
||||||
existing.updated_at = datetime.utcnow()
|
|
||||||
snapshot = StudySetupConfigVersion(
|
snapshot = StudySetupConfigVersion(
|
||||||
study_setup_config_id=existing.id,
|
study_setup_config_id=existing.id,
|
||||||
study_id=study_id,
|
study_id=study_id,
|
||||||
version=next_version,
|
version=next_version,
|
||||||
display_version=next_display_version,
|
display_version=next_display_version,
|
||||||
source_version=next_version - 1,
|
source_version=next_version - 1,
|
||||||
config=existing.config,
|
config=_to_storage(publish_payload),
|
||||||
published_by=published_by,
|
published_by=published_by,
|
||||||
published_at=existing.published_at,
|
published_at=existing.published_at,
|
||||||
)
|
)
|
||||||
db.add(snapshot)
|
db.add(snapshot)
|
||||||
|
|
||||||
if auto_commit:
|
if auto_commit:
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(existing)
|
await db.refresh(existing)
|
||||||
@@ -143,7 +158,7 @@ async def rollback_to_version(
|
|||||||
return None, False, True
|
return None, False, True
|
||||||
|
|
||||||
existing.version = existing.version + 1
|
existing.version = existing.version + 1
|
||||||
existing.config = target.config
|
existing.config = _to_storage(target.config or {})
|
||||||
existing.saved_by = saved_by
|
existing.saved_by = saved_by
|
||||||
existing.publish_status = "DRAFT"
|
existing.publish_status = "DRAFT"
|
||||||
existing.updated_at = datetime.utcnow()
|
existing.updated_at = datetime.utcnow()
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="setup-toolbar-actions">
|
<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="warning" plain :loading="publishConfirmLoading" @click="publishConfigNow">发布版本</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
type="info"
|
type="info"
|
||||||
@@ -358,9 +358,25 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-grid">
|
<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-label">{{ item.label }}</span>
|
||||||
|
<span class="info-value-wrap">
|
||||||
<span class="info-value">{{ item.value || "-" }}</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -378,9 +394,25 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-grid">
|
<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-label">{{ item.label }}</span>
|
||||||
|
<span class="info-value-wrap">
|
||||||
<span class="info-value">{{ item.value || "-" }}</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -398,9 +430,25 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-grid">
|
<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-label">{{ item.label }}</span>
|
||||||
|
<span class="info-value-wrap">
|
||||||
<span class="info-value">{{ item.value || "-" }}</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -446,9 +494,25 @@
|
|||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<div v-else class="info-grid">
|
<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-label">{{ item.label }}</span>
|
||||||
|
<span class="info-value-wrap">
|
||||||
<span class="info-value">{{ item.value || "-" }}</span>
|
<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>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
@@ -462,7 +526,18 @@
|
|||||||
<el-table :data="currentSetupDraft.projectMilestones" class="ctms-table setup-milestone-table">
|
<el-table :data="currentSetupDraft.projectMilestones" class="ctms-table setup-milestone-table">
|
||||||
<el-table-column label="里程碑" min-width="180">
|
<el-table-column label="里程碑" min-width="180">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
|
<span class="updated-value-wrap">
|
||||||
<span class="milestone-cell-text">{{ scope.row.name || "-" }}</span>
|
<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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="计划时间" width="240">
|
<el-table-column label="计划时间" width="240">
|
||||||
@@ -471,34 +546,100 @@
|
|||||||
<div class="milestone-plan-line">
|
<div class="milestone-plan-line">
|
||||||
<span class="milestone-plan-dot" />
|
<span class="milestone-plan-dot" />
|
||||||
<span class="milestone-plan-label">开始:</span>
|
<span class="milestone-plan-label">开始:</span>
|
||||||
|
<span class="updated-value-wrap">
|
||||||
<span>{{ resolveProjectMilestoneStart(scope.row) || "-" }}</span>
|
<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>
|
||||||
<div class="milestone-plan-line">
|
<div class="milestone-plan-line">
|
||||||
<span class="milestone-plan-dot" />
|
<span class="milestone-plan-dot" />
|
||||||
<span class="milestone-plan-label">结束:</span>
|
<span class="milestone-plan-label">结束:</span>
|
||||||
|
<span class="updated-value-wrap">
|
||||||
<span>{{ resolveProjectMilestoneEnd(scope.row) || "-" }}</span>
|
<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>
|
||||||
<div class="milestone-plan-line">
|
<div class="milestone-plan-line">
|
||||||
<span class="milestone-plan-dot" />
|
<span class="milestone-plan-dot" />
|
||||||
<span class="milestone-plan-label">耗时:</span>
|
<span class="milestone-plan-label">耗时:</span>
|
||||||
|
<span class="updated-value-wrap">
|
||||||
<span>{{ formatProjectMilestoneDuration(scope.row) }}</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="负责人" min-width="160">
|
<el-table-column label="负责人" min-width="160">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
|
<span class="updated-value-wrap">
|
||||||
<span class="milestone-cell-text">{{ normalizeOwnerDisplay(scope.row.owner) }}</span>
|
<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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="150">
|
<el-table-column label="状态" width="150">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
|
<span class="updated-value-wrap">
|
||||||
<span class="milestone-cell-text">{{ scope.row.status || "-" }}</span>
|
<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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="备注" min-width="220">
|
<el-table-column label="备注" min-width="220">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
|
<span class="updated-value-wrap">
|
||||||
<span class="milestone-cell-text">{{ scope.row.remark || "-" }}</span>
|
<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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column v-if="!isPublishedView" label="操作" width="130">
|
<el-table-column v-if="!isPublishedView" label="操作" width="130">
|
||||||
@@ -525,7 +666,18 @@
|
|||||||
:disabled="!canEditSetup"
|
:disabled="!canEditSetup"
|
||||||
class="w-full"
|
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 v-if="getFieldError('enrollmentPlan.startDate')" class="field-error">{{ getFieldError("enrollmentPlan.startDate") }}</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="enrollment-sep">~</span>
|
<span class="enrollment-sep">~</span>
|
||||||
@@ -538,7 +690,18 @@
|
|||||||
:disabled="!canEditSetup"
|
:disabled="!canEditSetup"
|
||||||
class="w-full"
|
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 v-if="getFieldError('enrollmentPlan.endDate')" class="field-error">{{ getFieldError("enrollmentPlan.endDate") }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -553,7 +716,18 @@
|
|||||||
<el-radio-button label="month">月</el-radio-button>
|
<el-radio-button label="month">月</el-radio-button>
|
||||||
<el-radio-button label="quarter">季度</el-radio-button>
|
<el-radio-button label="quarter">季度</el-radio-button>
|
||||||
</el-radio-group>
|
</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>
|
||||||
<div class="enrollment-inline-group total-target-group">
|
<div class="enrollment-inline-group total-target-group">
|
||||||
<span class="enrollment-inline-label">计划入组总数</span>
|
<span class="enrollment-inline-label">计划入组总数</span>
|
||||||
@@ -566,7 +740,18 @@
|
|||||||
controls-position="right"
|
controls-position="right"
|
||||||
class="w-full"
|
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 v-if="getFieldError('enrollmentPlan.totalTarget')" class="field-error">{{ getFieldError("enrollmentPlan.totalTarget") }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -620,7 +805,18 @@
|
|||||||
class="month-target-input"
|
class="month-target-input"
|
||||||
@update:model-value="setEnrollmentPeriodTarget(scope.row.cells[month - 1].key, $event as number | null)"
|
@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>
|
</template>
|
||||||
<span v-else class="month-empty">-</span>
|
<span v-else class="month-empty">-</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -656,7 +852,18 @@
|
|||||||
class="month-target-input"
|
class="month-target-input"
|
||||||
@update:model-value="setEnrollmentPeriodTarget(scope.row.cells[columnIndex].key, $event as number | null)"
|
@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>
|
</template>
|
||||||
<span v-else class="month-empty">-</span>
|
<span v-else class="month-empty">-</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -668,27 +875,82 @@
|
|||||||
<el-table :data="currentSetupDraft.siteMilestones" class="ctms-table">
|
<el-table :data="currentSetupDraft.siteMilestones" class="ctms-table">
|
||||||
<el-table-column label="里程碑" min-width="220">
|
<el-table-column label="里程碑" min-width="220">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
|
<span class="updated-value-wrap">
|
||||||
<span class="milestone-cell-text">{{ scope.row.milestone || "-" }}</span>
|
<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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="计划日期" width="180">
|
<el-table-column label="计划日期" width="180">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
|
<span class="updated-value-wrap">
|
||||||
<span class="milestone-cell-text">{{ scope.row.planDate || "-" }}</span>
|
<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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="负责人" min-width="160">
|
<el-table-column label="负责人" min-width="160">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
|
<span class="updated-value-wrap">
|
||||||
<span class="milestone-cell-text">{{ normalizeOwnerDisplay(scope.row.owner) }}</span>
|
<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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="150">
|
<el-table-column label="状态" width="150">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
|
<span class="updated-value-wrap">
|
||||||
<span class="milestone-cell-text">{{ scope.row.status || "未开始" }}</span>
|
<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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="备注" min-width="180">
|
<el-table-column label="备注" min-width="180">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
|
<span class="updated-value-wrap">
|
||||||
<span class="milestone-cell-text">{{ scope.row.remark || "-" }}</span>
|
<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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column v-if="!isPublishedView" label="操作" width="130">
|
<el-table-column v-if="!isPublishedView" label="操作" width="130">
|
||||||
@@ -735,7 +997,18 @@
|
|||||||
:disabled="!canEditSetup"
|
:disabled="!canEditSetup"
|
||||||
class="w-full"
|
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>
|
</div>
|
||||||
<span class="enrollment-sep">~</span>
|
<span class="enrollment-sep">~</span>
|
||||||
<div class="field-cell enrollment-date-cell">
|
<div class="field-cell enrollment-date-cell">
|
||||||
@@ -747,7 +1020,18 @@
|
|||||||
:disabled="!canEditSetup"
|
:disabled="!canEditSetup"
|
||||||
class="w-full"
|
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>
|
</div>
|
||||||
<div class="enrollment-inline-group">
|
<div class="enrollment-inline-group">
|
||||||
@@ -761,7 +1045,18 @@
|
|||||||
<el-radio-button label="month">月</el-radio-button>
|
<el-radio-button label="month">月</el-radio-button>
|
||||||
<el-radio-button label="quarter">季度</el-radio-button>
|
<el-radio-button label="quarter">季度</el-radio-button>
|
||||||
</el-radio-group>
|
</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>
|
||||||
<div class="enrollment-inline-group total-target-group">
|
<div class="enrollment-inline-group total-target-group">
|
||||||
<span class="enrollment-inline-label">中心计划总数</span>
|
<span class="enrollment-inline-label">中心计划总数</span>
|
||||||
@@ -774,7 +1069,18 @@
|
|||||||
controls-position="right"
|
controls-position="right"
|
||||||
class="w-full"
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -841,7 +1147,18 @@
|
|||||||
class="month-target-input"
|
class="month-target-input"
|
||||||
@update:model-value="setSiteEnrollmentPeriodTarget(selectedSiteEnrollmentPlanIndex, scope.row.cells[month - 1].key, $event as number | null)"
|
@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>
|
</template>
|
||||||
<span v-else class="month-empty">-</span>
|
<span v-else class="month-empty">-</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -877,7 +1194,18 @@
|
|||||||
class="month-target-input"
|
class="month-target-input"
|
||||||
@update:model-value="setSiteEnrollmentPeriodTarget(selectedSiteEnrollmentPlanIndex, scope.row.cells[columnIndex].key, $event as number | null)"
|
@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>
|
</template>
|
||||||
<span v-else class="month-empty">-</span>
|
<span v-else class="month-empty">-</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -890,22 +1218,66 @@
|
|||||||
<el-table :data="currentSetupDraft.monitoringStrategies" class="ctms-table">
|
<el-table :data="currentSetupDraft.monitoringStrategies" class="ctms-table">
|
||||||
<el-table-column label="监查类型" min-width="180">
|
<el-table-column label="监查类型" min-width="180">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
|
<span class="updated-value-wrap">
|
||||||
<span class="milestone-cell-text">{{ scope.row.strategyType || "-" }}</span>
|
<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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="策略详情" min-width="430">
|
<el-table-column label="策略详情" min-width="430">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
|
<span class="updated-value-wrap">
|
||||||
<span class="milestone-cell-text">{{ scope.row.detail || "-" }}</span>
|
<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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="监查次数" width="140">
|
<el-table-column label="监查次数" width="140">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
|
<span class="updated-value-wrap">
|
||||||
<span class="milestone-cell-text">{{ scope.row.frequency || "-" }}</span>
|
<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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="更新时间" width="180">
|
<el-table-column label="更新时间" width="180">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
|
<span class="updated-value-wrap">
|
||||||
<span class="milestone-cell-text">{{ scope.row.updatedAt || "-" }}</span>
|
<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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column v-if="!isPublishedView" label="操作" width="130">
|
<el-table-column v-if="!isPublishedView" label="操作" width="130">
|
||||||
@@ -943,7 +1315,18 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="计划入组数" width="140">
|
<el-table-column label="计划入组数" width="140">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
|
<span class="updated-value-wrap">
|
||||||
<span class="milestone-cell-text">{{ toDisplayNumber(scope.row.target) }}</span>
|
<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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="120">
|
<el-table-column label="状态" width="120">
|
||||||
@@ -955,12 +1338,34 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="入组开始时间" width="150">
|
<el-table-column label="入组开始时间" width="150">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
|
<span class="updated-value-wrap">
|
||||||
<span class="milestone-cell-text">{{ scope.row.startDate || "-" }}</span>
|
<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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="入组结束时间" width="150">
|
<el-table-column label="入组结束时间" width="150">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
|
<span class="updated-value-wrap">
|
||||||
<span class="milestone-cell-text">{{ scope.row.endDate || "-" }}</span>
|
<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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -1949,41 +2354,183 @@ const currentStepErrors = computed(() => {
|
|||||||
return grouped[section] || [];
|
return grouped[section] || [];
|
||||||
});
|
});
|
||||||
|
|
||||||
const projectInfoItems = computed(() => [
|
type DisplayInfoItem = {
|
||||||
{ label: "项目名称", value: form.value.name || project.value?.name || "-" },
|
label: string;
|
||||||
{ label: "项目全称", value: form.value.project_full_name || project.value?.project_full_name || "-" },
|
value: string | number | null | undefined;
|
||||||
{ label: "申办方", value: form.value.sponsor || project.value?.sponsor || "-" },
|
updated?: boolean;
|
||||||
{ 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 || "-" },
|
const shouldShowPreviewUpdateMark = computed(() => isPublishedView.value && !isFirstPublish.value);
|
||||||
{ label: "主要研究者", value: form.value.principal_investigator || project.value?.principal_investigator || "-" },
|
const currentProjectPublishSnapshot = computed<ProjectPublishSnapshot>(() => buildProjectPublishSnapshot());
|
||||||
{ label: "主PM", value: form.value.main_pm || project.value?.main_pm || "-" },
|
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(() => [
|
const researchInfoItems = computed<DisplayInfoItem[]>(() => [
|
||||||
{ label: "研究分期", value: form.value.research_analysis || project.value?.research_analysis || "-" },
|
{ label: "研究分期", value: form.value.research_analysis || project.value?.research_analysis || "-", updated: isProjectSnapshotFieldUpdated("research_analysis") },
|
||||||
{ label: "研究产品", value: form.value.research_product || project.value?.research_product || "-" },
|
{ label: "研究产品", value: form.value.research_product || project.value?.research_product || "-", updated: isProjectSnapshotFieldUpdated("research_product") },
|
||||||
{ label: "对照产品", value: form.value.control_product || project.value?.control_product || "-" },
|
{ label: "对照产品", value: form.value.control_product || project.value?.control_product || "-", updated: isProjectSnapshotFieldUpdated("control_product") },
|
||||||
{ label: "适应症", value: form.value.indication || project.value?.indication || "-" },
|
{ label: "适应症", value: form.value.indication || project.value?.indication || "-", updated: isProjectSnapshotFieldUpdated("indication") },
|
||||||
{ label: "研究人群", value: form.value.research_population || project.value?.research_population || "-" },
|
{ label: "研究人群", value: form.value.research_population || project.value?.research_population || "-", updated: isProjectSnapshotFieldUpdated("research_population") },
|
||||||
{ label: "研究设计", value: form.value.research_design || project.value?.research_design || "-" },
|
{ label: "研究设计", value: form.value.research_design || project.value?.research_design || "-", updated: isProjectSnapshotFieldUpdated("research_design") },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const executionInfoItems = computed(() => [
|
const executionInfoItems = computed<DisplayInfoItem[]>(() => [
|
||||||
{ label: "计划开始日期", value: form.value.plan_start_date || project.value?.plan_start_date || "-" },
|
{ 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 || "-" },
|
{ 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") },
|
{ label: "项目状态", value: statusLabel(form.value.status || project.value?.status || "DRAFT"), updated: isProjectSnapshotFieldUpdated("status") },
|
||||||
{ label: "计划中心数", value: toDisplayNumber(form.value.planned_site_count) },
|
{ label: "计划中心数", value: toDisplayNumber(form.value.planned_site_count), updated: isProjectSnapshotFieldUpdated("planned_site_count") },
|
||||||
{ label: "计划入组数", value: toDisplayNumber(setupDraft.enrollmentPlan.totalTarget) },
|
{
|
||||||
|
label: "计划入组数",
|
||||||
|
value: toDisplayNumber(setupDraft.enrollmentPlan.totalTarget),
|
||||||
|
updated: isEnrollmentPlanFieldUpdated("totalTarget"),
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const summaryInfoItems = computed(() => [
|
const summaryInfoItems = computed<DisplayInfoItem[]>(() => [
|
||||||
{ label: "访视总数", value: toDisplayNumber(form.value.visit_total) },
|
{ label: "访视总数", value: toDisplayNumber(form.value.visit_total), updated: isProjectSnapshotFieldUpdated("visit_total") },
|
||||||
{ label: "访视间隔(天)", value: toDisplayNumber(form.value.visit_interval_days) },
|
{ label: "访视间隔(天)", value: toDisplayNumber(form.value.visit_interval_days), updated: isProjectSnapshotFieldUpdated("visit_interval_days") },
|
||||||
{ label: "窗口期起始偏移", value: toDisplayNumber(form.value.visit_window_start_offset) },
|
{ label: "窗口期起始偏移", value: toDisplayNumber(form.value.visit_window_start_offset), updated: isProjectSnapshotFieldUpdated("visit_window_start_offset") },
|
||||||
{ label: "窗口期结束偏移", value: toDisplayNumber(form.value.visit_window_end_offset) },
|
{ label: "窗口期结束偏移", value: toDisplayNumber(form.value.visit_window_end_offset), updated: isProjectSnapshotFieldUpdated("visit_window_end_offset") },
|
||||||
{ label: "方案摘要说明", value: form.value.summary_note || "-" },
|
{ label: "方案摘要说明", value: form.value.summary_note || "-", updated: isProjectSnapshotFieldUpdated("summary_note") },
|
||||||
{ label: "研究目标摘要", value: form.value.objective_note || "-" },
|
{ label: "研究目标摘要", value: form.value.objective_note || "-", updated: isProjectSnapshotFieldUpdated("objective_note") },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const rules = ref<FormRules>({
|
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 () => {
|
const handlePrimarySaveConfig = async () => {
|
||||||
if (!canSaveDraftAction.value) return;
|
if (!ensureCanSaveDraftAction()) return;
|
||||||
if (incompleteSteps.value.length > 0) {
|
if (incompleteSteps.value.length > 0) {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
@@ -4820,6 +5387,26 @@ onBeforeUnmount(() => {
|
|||||||
color: #11264d;
|
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 {
|
.enrollment-sep {
|
||||||
color: #8da3c7;
|
color: #8da3c7;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -5307,6 +5894,12 @@ onBeforeUnmount(() => {
|
|||||||
padding-top: 4px;
|
padding-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.info-value-wrap {
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
.setup-info-tabs {
|
.setup-info-tabs {
|
||||||
margin-top: -8px;
|
margin-top: -8px;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user