diff --git a/backend/app/crud/study_setup_config.py b/backend/app/crud/study_setup_config.py
index 4007c7b0..8f31a877 100644
--- a/backend/app/crud/study_setup_config.py
+++ b/backend/app/crud/study_setup_config.py
@@ -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()
diff --git a/frontend/src/views/admin/ProjectDetail.vue b/frontend/src/views/admin/ProjectDetail.vue
index 3cf6c942..7866909b 100644
--- a/frontend/src/views/admin/ProjectDetail.vue
+++ b/frontend/src/views/admin/ProjectDetail.vue
@@ -43,7 +43,7 @@
- 保存配置
+ 保存配置
发布版本
-
+
{{ item.label }}
- {{ item.value || "-" }}
+
+ {{ item.value || "-" }}
+
+ 已更新
+
+
@@ -378,9 +394,25 @@
-
+
{{ item.label }}
- {{ item.value || "-" }}
+
+ {{ item.value || "-" }}
+
+ 已更新
+
+
@@ -398,9 +430,25 @@
-
+
{{ item.label }}
- {{ item.value || "-" }}
+
+ {{ item.value || "-" }}
+
+ 已更新
+
+
@@ -446,9 +494,25 @@
-
+
{{ item.label }}
- {{ item.value || "-" }}
+
+ {{ item.value || "-" }}
+
+ 已更新
+
+
@@ -462,7 +526,18 @@
- {{ scope.row.name || "-" }}
+
+ {{ scope.row.name || "-" }}
+
+ 已更新
+
+
@@ -471,34 +546,100 @@
开始:
- {{ resolveProjectMilestoneStart(scope.row) || "-" }}
+
+ {{ resolveProjectMilestoneStart(scope.row) || "-" }}
+
+ 已更新
+
+
结束:
- {{ resolveProjectMilestoneEnd(scope.row) || "-" }}
+
+ {{ resolveProjectMilestoneEnd(scope.row) || "-" }}
+
+ 已更新
+
+
耗时:
- {{ formatProjectMilestoneDuration(scope.row) }}
+
+ {{ formatProjectMilestoneDuration(scope.row) }}
+
+ 已更新
+
+
- {{ normalizeOwnerDisplay(scope.row.owner) }}
+
+ {{ normalizeOwnerDisplay(scope.row.owner) }}
+
+ 已更新
+
+
- {{ scope.row.status || "-" }}
+
+ {{ scope.row.status || "-" }}
+
+ 已更新
+
+
- {{ scope.row.remark || "-" }}
+
+ {{ scope.row.remark || "-" }}
+
+ 已更新
+
+
@@ -525,7 +666,18 @@
:disabled="!canEditSetup"
class="w-full"
/>
- {{ currentSetupDraft.enrollmentPlan.startDate || "-" }}
+
+ {{ currentSetupDraft.enrollmentPlan.startDate || "-" }}
+
+ 已更新
+
+
{{ getFieldError("enrollmentPlan.startDate") }}
~
@@ -538,7 +690,18 @@
:disabled="!canEditSetup"
class="w-full"
/>
- {{ currentSetupDraft.enrollmentPlan.endDate || "-" }}
+
+ {{ currentSetupDraft.enrollmentPlan.endDate || "-" }}
+
+ 已更新
+
+
{{ getFieldError("enrollmentPlan.endDate") }}
@@ -553,7 +716,18 @@
月
季度
- {{ enrollmentPlanCycleLabel }}
+
+ {{ enrollmentPlanCycleLabel }}
+
+ 已更新
+
+
计划入组总数
@@ -566,7 +740,18 @@
controls-position="right"
class="w-full"
/>
-
{{ toDisplayNumber(currentSetupDraft.enrollmentPlan.totalTarget) }}
+
+ {{ toDisplayNumber(currentSetupDraft.enrollmentPlan.totalTarget) }}
+
+ 已更新
+
+
{{ getFieldError("enrollmentPlan.totalTarget") }}
@@ -620,7 +805,18 @@
class="month-target-input"
@update:model-value="setEnrollmentPeriodTarget(scope.row.cells[month - 1].key, $event as number | null)"
/>
- {{ displayEnrollmentPeriodTarget(scope.row.cells[month - 1].key) }}
+
+ {{ displayEnrollmentPeriodTarget(scope.row.cells[month - 1].key) }}
+
+ 已更新
+
+
-
@@ -656,7 +852,18 @@
class="month-target-input"
@update:model-value="setEnrollmentPeriodTarget(scope.row.cells[columnIndex].key, $event as number | null)"
/>
- {{ displayEnrollmentPeriodTarget(scope.row.cells[columnIndex].key) }}
+
+ {{ displayEnrollmentPeriodTarget(scope.row.cells[columnIndex].key) }}
+
+ 已更新
+
+
-
@@ -668,27 +875,82 @@
- {{ scope.row.milestone || "-" }}
+
+ {{ scope.row.milestone || "-" }}
+
+ 已更新
+
+
- {{ scope.row.planDate || "-" }}
+
+ {{ scope.row.planDate || "-" }}
+
+ 已更新
+
+
- {{ normalizeOwnerDisplay(scope.row.owner) }}
+
+ {{ normalizeOwnerDisplay(scope.row.owner) }}
+
+ 已更新
+
+
- {{ scope.row.status || "未开始" }}
+
+ {{ scope.row.status || "未开始" }}
+
+ 已更新
+
+
- {{ scope.row.remark || "-" }}
+
+ {{ scope.row.remark || "-" }}
+
+ 已更新
+
+
@@ -735,7 +997,18 @@
:disabled="!canEditSetup"
class="w-full"
/>
- {{ selectedSiteEnrollmentPlan?.startDate || "-" }}
+
+ {{ selectedSiteEnrollmentPlan?.startDate || "-" }}
+
+ 已更新
+
+
~
@@ -747,7 +1020,18 @@
:disabled="!canEditSetup"
class="w-full"
/>
- {{ selectedSiteEnrollmentPlan?.endDate || "-" }}
+
+ {{ selectedSiteEnrollmentPlan?.endDate || "-" }}
+
+ 已更新
+
+
@@ -761,7 +1045,18 @@
月
季度
- {{ enrollmentPlanCycleLabel }}
+
+ {{ enrollmentPlanCycleLabel }}
+
+ 已更新
+
+
中心计划总数
@@ -774,7 +1069,18 @@
controls-position="right"
class="w-full"
/>
- {{ toDisplayNumber(selectedSiteEnrollmentPlan?.target) }}
+
+ {{ toDisplayNumber(selectedSiteEnrollmentPlan?.target) }}
+
+ 已更新
+
+
@@ -841,7 +1147,18 @@
class="month-target-input"
@update:model-value="setSiteEnrollmentPeriodTarget(selectedSiteEnrollmentPlanIndex, scope.row.cells[month - 1].key, $event as number | null)"
/>
- {{ displaySiteEnrollmentPeriodTarget(selectedSiteEnrollmentPlan, scope.row.cells[month - 1].key) }}
+
+ {{ displaySiteEnrollmentPeriodTarget(selectedSiteEnrollmentPlan, scope.row.cells[month - 1].key) }}
+
+ 已更新
+
+
-
@@ -877,7 +1194,18 @@
class="month-target-input"
@update:model-value="setSiteEnrollmentPeriodTarget(selectedSiteEnrollmentPlanIndex, scope.row.cells[columnIndex].key, $event as number | null)"
/>
- {{ displaySiteEnrollmentPeriodTarget(selectedSiteEnrollmentPlan, scope.row.cells[columnIndex].key) }}
+
+ {{ displaySiteEnrollmentPeriodTarget(selectedSiteEnrollmentPlan, scope.row.cells[columnIndex].key) }}
+
+ 已更新
+
+
-
@@ -890,22 +1218,66 @@
- {{ scope.row.strategyType || "-" }}
+
+ {{ scope.row.strategyType || "-" }}
+
+ 已更新
+
+
- {{ scope.row.detail || "-" }}
+
+ {{ scope.row.detail || "-" }}
+
+ 已更新
+
+
- {{ scope.row.frequency || "-" }}
+
+ {{ scope.row.frequency || "-" }}
+
+ 已更新
+
+
- {{ scope.row.updatedAt || "-" }}
+
+ {{ scope.row.updatedAt || "-" }}
+
+ 已更新
+
+
@@ -943,7 +1315,18 @@
- {{ toDisplayNumber(scope.row.target) }}
+
+ {{ toDisplayNumber(scope.row.target) }}
+
+ 已更新
+
+
@@ -955,12 +1338,34 @@
- {{ scope.row.startDate || "-" }}
+
+ {{ scope.row.startDate || "-" }}
+
+ 已更新
+
+
- {{ scope.row.endDate || "-" }}
+
+ {{ scope.row.endDate || "-" }}
+
+ 已更新
+
+
@@ -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(() => 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 = (
+ 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();
+ 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(() => [
+ { 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(() => [
+ { 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(() => [
+ { 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(() => [
+ { 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({
@@ -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;
}