修复项目配置草稿原子保存逻辑

This commit is contained in:
Cheng Zhou
2026-05-11 11:03:06 +08:00
parent 8d061a1520
commit 20d45cfdfe
6 changed files with 333 additions and 59 deletions
@@ -0,0 +1,157 @@
"""backfill project info in setup drafts
Revision ID: 20260511_01
Revises: 20260509_03
Create Date: 2026-05-11 10:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260511_01"
down_revision: Union[str, None] = "20260509_03"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
EMPTY_PROJECT_INFO_JSON = """{
"code": "",
"name": "",
"project_full_name": "",
"sponsor": "",
"protocol_no": "",
"lead_unit": "",
"principal_investigator": "",
"main_pm": "",
"research_analysis": "",
"research_product": "",
"control_product": "",
"indication": "",
"research_population": "",
"research_design": "",
"plan_start_date": "",
"plan_end_date": "",
"planned_site_count": null,
"planned_enrollment_count": null,
"status": "",
"visit_schedule": []
}"""
def _has_table(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in set(inspector.get_table_names())
def _has_columns(inspector: sa.Inspector, table_name: str, column_names: set[str]) -> bool:
existing = {col["name"] for col in inspector.get_columns(table_name)}
return column_names.issubset(existing)
def _backfill_config_column(table_name: str, config_column: str, *, require_existing_setup_content: bool) -> None:
content_filter = ""
if require_existing_setup_content:
content_filter = f"""
AND (
jsonb_array_length(COALESCE(setup.{config_column}->'projectMilestones', '[]'::jsonb)) > 0
OR COALESCE(setup.{config_column}->'enrollmentPlan', '{{}}'::jsonb) <> '{{"totalTarget": 0, "startDate": "", "endDate": "", "monthlyGoalNote": "", "stageBreakdown": ""}}'::jsonb
OR jsonb_array_length(COALESCE(setup.{config_column}->'siteMilestones', '[]'::jsonb)) > 0
OR jsonb_array_length(COALESCE(setup.{config_column}->'siteEnrollmentPlans', '[]'::jsonb)) > 0
OR jsonb_array_length(COALESCE(setup.{config_column}->'monitoringStrategies', '[]'::jsonb)) > 0
OR jsonb_array_length(COALESCE(setup.{config_column}->'centerConfirm', '[]'::jsonb)) > 0
)
"""
op.execute(
sa.text(
f"""
UPDATE {table_name} AS setup
SET {config_column} = jsonb_set(
setup.{config_column},
'{{projectInfo}}',
jsonb_build_object(
'code', COALESCE(studies.code, ''),
'name', COALESCE(studies.name, ''),
'project_full_name', COALESCE(studies.project_full_name, ''),
'sponsor', COALESCE(studies.sponsor, ''),
'protocol_no', COALESCE(studies.protocol_no, ''),
'lead_unit', COALESCE(studies.lead_unit, ''),
'principal_investigator', COALESCE(studies.principal_investigator, ''),
'main_pm', COALESCE(studies.main_pm, ''),
'research_analysis', COALESCE(studies.research_analysis, ''),
'research_product', COALESCE(studies.research_product, ''),
'control_product', COALESCE(studies.control_product, ''),
'indication', COALESCE(studies.indication, ''),
'research_population', COALESCE(studies.research_population, ''),
'research_design', COALESCE(studies.research_design, ''),
'plan_start_date', COALESCE(to_char(studies.plan_start_date, 'YYYY-MM-DD'), ''),
'plan_end_date', COALESCE(to_char(studies.plan_end_date, 'YYYY-MM-DD'), ''),
'planned_site_count', to_jsonb(studies.planned_site_count),
'planned_enrollment_count', to_jsonb(studies.planned_enrollment_count),
'status', COALESCE(studies.status, ''),
'visit_schedule', COALESCE(studies.visit_schedule::jsonb, '[]'::jsonb)
),
true
)
FROM studies
WHERE setup.study_id = studies.id
AND setup.{config_column} IS NOT NULL
AND (
NOT setup.{config_column} ? 'projectInfo'
OR setup.{config_column}->'projectInfo' = CAST(:empty_project_info AS jsonb)
)
{content_filter}
"""
).bindparams(empty_project_info=EMPTY_PROJECT_INFO_JSON)
)
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
required_study_columns = {
"id",
"code",
"name",
"project_full_name",
"sponsor",
"protocol_no",
"lead_unit",
"principal_investigator",
"main_pm",
"research_analysis",
"research_product",
"control_product",
"indication",
"research_population",
"research_design",
"plan_start_date",
"plan_end_date",
"planned_site_count",
"planned_enrollment_count",
"status",
"visit_schedule",
}
if not _has_table(inspector, "studies") or not _has_columns(inspector, "studies", required_study_columns):
return
if _has_table(inspector, "study_setup_configs") and _has_columns(
inspector, "study_setup_configs", {"study_id", "config"}
):
_backfill_config_column("study_setup_configs", "config", require_existing_setup_content=True)
if _has_table(inspector, "study_setup_configs") and _has_columns(
inspector, "study_setup_configs", {"study_id", "published_config"}
):
_backfill_config_column("study_setup_configs", "published_config", require_existing_setup_content=False)
if _has_table(inspector, "study_setup_config_versions") and _has_columns(
inspector, "study_setup_config_versions", {"study_id", "config"}
):
_backfill_config_column("study_setup_config_versions", "config", require_existing_setup_content=False)
def downgrade() -> None:
pass
+2 -10
View File
@@ -242,6 +242,7 @@ def _build_default_setup_config_from_study(study, sites: list) -> StudySetupConf
if total_target is None: if total_target is None:
total_target = 0 total_target = 0
return StudySetupConfigData( return StudySetupConfigData(
projectInfo=_build_project_publish_snapshot(study),
projectMilestones=[], projectMilestones=[],
enrollmentPlan={ enrollmentPlan={
"totalTarget": total_target, "totalTarget": total_target,
@@ -263,13 +264,6 @@ def _to_date_text(value: date | None) -> str:
return value.isoformat() return value.isoformat()
def _project_snapshot_has_draft_values(snapshot: ProjectPublishSnapshot | None) -> bool:
if not snapshot:
return False
data = snapshot.model_dump(mode="json")
return any(value not in ("", None, []) for value in data.values())
def _build_project_publish_snapshot(study) -> ProjectPublishSnapshot: def _build_project_publish_snapshot(study) -> ProjectPublishSnapshot:
return ProjectPublishSnapshot( return ProjectPublishSnapshot(
code=getattr(study, "code", None) or "", code=getattr(study, "code", None) or "",
@@ -296,9 +290,7 @@ def _build_project_publish_snapshot(study) -> ProjectPublishSnapshot:
def _resolve_setup_project_snapshot(setup_data: StudySetupConfigData, study) -> ProjectPublishSnapshot: def _resolve_setup_project_snapshot(setup_data: StudySetupConfigData, study) -> ProjectPublishSnapshot:
if _project_snapshot_has_draft_values(setup_data.projectInfo): return setup_data.projectInfo
return setup_data.projectInfo
return _build_project_publish_snapshot(study)
def _parse_optional_snapshot_date(value: str) -> date | None: def _parse_optional_snapshot_date(value: str) -> date | None:
@@ -3,7 +3,12 @@ from types import SimpleNamespace
from fastapi import HTTPException from fastapi import HTTPException
from app.api.v1.studies import _apply_project_publish_snapshot_to_study, _validate_setup_data from app.api.v1.studies import (
_apply_project_publish_snapshot_to_study,
_build_default_setup_config_from_study,
_resolve_setup_project_snapshot,
_validate_setup_data,
)
from app.schemas.study_setup_config import ProjectPublishSnapshot, StudySetupConfigData from app.schemas.study_setup_config import ProjectPublishSnapshot, StudySetupConfigData
@@ -172,6 +177,78 @@ def test_project_info_is_part_of_setup_config_payload():
assert payload["enrollmentPlan"]["totalTarget"] == 120 assert payload["enrollmentPlan"]["totalTarget"] == 120
def test_default_setup_config_includes_project_info_snapshot():
study = SimpleNamespace(
code="PRJ-DEFAULT",
name="默认项目",
project_full_name="默认项目全称",
sponsor="申办方",
protocol_no="PROTO-DEFAULT",
lead_unit="组长单位",
principal_investigator="PI",
main_pm="PM",
research_analysis="III期",
research_product="研究药物",
control_product="对照药物",
indication="适应症",
research_population="研究人群",
research_design="随机双盲",
plan_start_date=date(2026, 6, 1),
plan_end_date=date(2026, 12, 31),
planned_site_count=8,
planned_enrollment_count=120,
enrollment_monthly_goal_note="",
enrollment_stage_breakdown="",
status="ACTIVE",
visit_schedule=[
{
"visit_code": "V1",
"baseline_offset_days": 7,
"window_before_days": 1,
"window_after_days": 2,
}
],
)
draft = _build_default_setup_config_from_study(study, [])
assert draft.projectInfo.code == "PRJ-DEFAULT"
assert draft.projectInfo.name == "默认项目"
assert draft.projectInfo.planned_enrollment_count == 120
assert draft.projectInfo.visit_schedule[0].visit_code == "V1"
def test_empty_project_info_does_not_fallback_to_study_snapshot():
study = SimpleNamespace(
code="PRJ-MASTER",
name="主表项目",
project_full_name="",
sponsor="",
protocol_no="",
lead_unit="",
principal_investigator="",
main_pm="",
research_analysis="",
research_product="",
control_product="",
indication="",
research_population="",
research_design="",
plan_start_date=None,
plan_end_date=None,
planned_site_count=None,
planned_enrollment_count=None,
status="DRAFT",
visit_schedule=[],
)
draft = StudySetupConfigData()
snapshot = _resolve_setup_project_snapshot(draft, study)
assert snapshot.code == ""
assert snapshot.name == ""
def test_apply_project_publish_snapshot_updates_formal_study_fields(): def test_apply_project_publish_snapshot_updates_formal_study_fields():
study = SimpleNamespace( study = SimpleNamespace(
code="PRJ-001", code="PRJ-001",
+45 -15
View File
@@ -118,7 +118,17 @@ EOF
docker compose up -d --build docker compose up -d --build
``` ```
### 4. 检查容器状态 ### 4. 执行数据库迁移
已有数据库升级到新代码时,启动后执行一次迁移:
```bash
docker compose run --rm backend python -m alembic upgrade head
```
首次空库安装也可以执行该命令;如果已是最新版本,Alembic 不会重复应用迁移。
### 5. 检查容器状态
```bash ```bash
docker compose ps docker compose ps
@@ -126,7 +136,7 @@ docker compose ps
确认 `ctms_db``ctms_backend``ctms_nginx` 都是 `Up`,且 `ctms_db``healthy` 确认 `ctms_db``ctms_backend``ctms_nginx` 都是 `Up`,且 `ctms_db``healthy`
### 5. 检查后端环境 ### 6. 检查后端环境
```bash ```bash
docker compose exec backend python -c "from app.core.config import settings; print('ENV=' + settings.ENV); print('JWT_SECRET_IS_DEV=' + str(settings.JWT_SECRET_KEY == 'dev-secret')); print('LOGIN_RSA_PRIVATE_KEY_SET=' + str(bool(settings.LOGIN_RSA_PRIVATE_KEY)))" docker compose exec backend python -c "from app.core.config import settings; print('ENV=' + settings.ENV); print('JWT_SECRET_IS_DEV=' + str(settings.JWT_SECRET_KEY == 'dev-secret')); print('LOGIN_RSA_PRIVATE_KEY_SET=' + str(bool(settings.LOGIN_RSA_PRIVATE_KEY)))"
@@ -140,7 +150,7 @@ JWT_SECRET_IS_DEV=True
LOGIN_RSA_PRIVATE_KEY_SET=False LOGIN_RSA_PRIVATE_KEY_SET=False
``` ```
### 6. 检查接口 ### 7. 检查接口
```bash ```bash
curl -i http://127.0.0.1:8888/health curl -i http://127.0.0.1:8888/health
@@ -149,13 +159,13 @@ curl -i http://127.0.0.1:8888/api/v1/auth/login-key
两个接口都应返回 `HTTP/1.1 200 OK` 两个接口都应返回 `HTTP/1.1 200 OK`
### 7. 打开系统 ### 8. 打开系统
```text ```text
http://localhost:8888 http://localhost:8888
``` ```
### 8. 运行验证 ### 9. 运行验证
后端: 后端:
@@ -243,7 +253,17 @@ docker compose run --rm backend-init
docker compose up -d --build docker compose up -d --build
``` ```
### 7. 检查容器状态 ### 7. 执行数据库迁移
已有数据库升级到新代码时,启动后执行一次迁移:
```bash
docker compose run --rm backend python -m alembic upgrade head
```
`backend-init` 会在初始化时处理空库和已有库迁移;这里单独执行迁移用于确认部署代码已落到最新数据库版本。
### 8. 检查容器状态
```bash ```bash
docker compose ps docker compose ps
@@ -251,7 +271,7 @@ docker compose ps
确认 `ctms_db``ctms_backend``ctms_nginx` 都是 `Up`,且 `ctms_db``healthy` 确认 `ctms_db``ctms_backend``ctms_nginx` 都是 `Up`,且 `ctms_db``healthy`
### 8. 检查后端环境 ### 9. 检查后端环境
```bash ```bash
docker compose exec backend python -c "from app.core.config import settings; print('ENV=' + settings.ENV); print('JWT_SECRET_IS_DEV=' + str(settings.JWT_SECRET_KEY == 'dev-secret')); print('LOGIN_RSA_PRIVATE_KEY_SET=' + str(bool(settings.LOGIN_RSA_PRIVATE_KEY))); print('LOGIN_RSA_KEY_ID=' + settings.LOGIN_RSA_KEY_ID)" docker compose exec backend python -c "from app.core.config import settings; print('ENV=' + settings.ENV); print('JWT_SECRET_IS_DEV=' + str(settings.JWT_SECRET_KEY == 'dev-secret')); print('LOGIN_RSA_PRIVATE_KEY_SET=' + str(bool(settings.LOGIN_RSA_PRIVATE_KEY))); print('LOGIN_RSA_KEY_ID=' + settings.LOGIN_RSA_KEY_ID)"
@@ -266,7 +286,7 @@ LOGIN_RSA_PRIVATE_KEY_SET=True
LOGIN_RSA_KEY_ID=main-YYYYMMDD LOGIN_RSA_KEY_ID=main-YYYYMMDD
``` ```
### 9. 检查接口 ### 10. 检查接口
本机: 本机:
@@ -284,7 +304,7 @@ curl -i https://<main-domain>/api/v1/auth/login-key
接口应返回 `HTTP/1.1 200 OK` 接口应返回 `HTTP/1.1 200 OK`
### 10. 打开系统 ### 11. 打开系统
本机: 本机:
@@ -300,7 +320,7 @@ https://<main-domain>
远程访问必须使用 HTTPS。 远程访问必须使用 HTTPS。
### 11. 运行验证 ### 12. 运行验证
后端: 后端:
@@ -417,7 +437,17 @@ docker compose run --rm backend-init
docker compose up -d --build docker compose up -d --build
``` ```
### 8. 检查容器状态 ### 8. 执行数据库迁移
已有数据库升级到新代码时,启动后执行一次迁移:
```bash
docker compose run --rm backend python -m alembic upgrade head
```
`backend-init` 会在初始化时处理空库和已有库迁移;这里单独执行迁移用于确认部署代码已落到最新数据库版本。
### 9. 检查容器状态
```bash ```bash
docker compose ps docker compose ps
@@ -425,7 +455,7 @@ docker compose ps
确认 `ctms_db``ctms_backend``ctms_nginx` 都是 `Up`,且 `ctms_db``healthy` 确认 `ctms_db``ctms_backend``ctms_nginx` 都是 `Up`,且 `ctms_db``healthy`
### 9. 检查后端环境 ### 10. 检查后端环境
```bash ```bash
docker compose exec backend python -c "from app.core.config import settings; print('ENV=' + settings.ENV); print('JWT_SECRET_IS_DEV=' + str(settings.JWT_SECRET_KEY == 'dev-secret')); print('LOGIN_RSA_PRIVATE_KEY_SET=' + str(bool(settings.LOGIN_RSA_PRIVATE_KEY))); print('LOGIN_RSA_KEY_ID=' + settings.LOGIN_RSA_KEY_ID)" docker compose exec backend python -c "from app.core.config import settings; print('ENV=' + settings.ENV); print('JWT_SECRET_IS_DEV=' + str(settings.JWT_SECRET_KEY == 'dev-secret')); print('LOGIN_RSA_PRIVATE_KEY_SET=' + str(bool(settings.LOGIN_RSA_PRIVATE_KEY))); print('LOGIN_RSA_KEY_ID=' + settings.LOGIN_RSA_KEY_ID)"
@@ -440,7 +470,7 @@ LOGIN_RSA_PRIVATE_KEY_SET=True
LOGIN_RSA_KEY_ID=release-YYYYMMDD LOGIN_RSA_KEY_ID=release-YYYYMMDD
``` ```
### 10. 检查生产接口 ### 11. 检查生产接口
```bash ```bash
curl -i https://<production-domain>/health curl -i https://<production-domain>/health
@@ -449,7 +479,7 @@ curl -i https://<production-domain>/api/v1/auth/login-key
两个接口都应返回 `HTTP/1.1 200 OK` 两个接口都应返回 `HTTP/1.1 200 OK`
### 11. 打开系统并登录 ### 12. 打开系统并登录
```text ```text
https://<production-domain> https://<production-domain>
@@ -463,7 +493,7 @@ admin@huapont.cn / admin123
首次登录后立即修改初始密码。 首次登录后立即修改初始密码。
### 12. 发布后检查 ### 13. 发布后检查
查看容器: 查看容器:
+18 -5
View File
@@ -50,15 +50,28 @@ describe("ProjectDetail setup draft publish workflow", () => {
expect(source.indexOf("applySetupDraft(createDefaultSetupDraft(siteOptions.value));", functionIndex)).toBe(-1); expect(source.indexOf("applySetupDraft(createDefaultSetupDraft(siteOptions.value));", functionIndex)).toBe(-1);
}); });
it("does not let legacy empty projectInfo overwrite project master data", () => { it("applies setup drafts atomically including empty project info", () => {
const source = readProjectDetail(); const source = readProjectDetail();
const functionIndex = source.indexOf("const applySetupDraft = (draft: SetupConfigDraft) => {"); const functionIndex = source.indexOf("const applySetupDraft = (draft: SetupConfigDraft) => {");
const emptyGuardIndex = source.indexOf("!isProjectPublishSnapshotEmpty(projectInfo) || isSetupDraftEffectivelyEmpty(setupDraft)", functionIndex); const nextFunctionIndex = source.indexOf("const loadLocalSetupDraft = ", functionIndex);
const fallbackIndex = source.indexOf("setupDraft.projectInfo = buildProjectPublishSnapshot();", emptyGuardIndex); const body = source.slice(functionIndex, nextFunctionIndex);
expect(functionIndex).toBeGreaterThan(-1); expect(functionIndex).toBeGreaterThan(-1);
expect(emptyGuardIndex).toBeGreaterThan(functionIndex); expect(body).toContain("setupDraft.projectInfo = projectInfo;");
expect(fallbackIndex).toBeGreaterThan(emptyGuardIndex); expect(body).toContain("applyProjectInfoDraft(projectInfo);");
expect(body).not.toContain("shouldApplyProjectInfo");
expect(body).not.toContain("isSetupDraftEffectivelyEmpty(setupDraft)");
});
it("requires projectInfo in setup draft shape", () => {
const source = readProjectDetail();
const functionIndex = source.indexOf("const isSetupDraftShape = (value: unknown): value is SetupConfigDraft => {");
const nextFunctionIndex = source.indexOf("const isProjectPublishSnapshotShape = ", functionIndex);
const body = source.slice(functionIndex, nextFunctionIndex);
expect(functionIndex).toBeGreaterThan(-1);
expect(body).toContain("isProjectPublishSnapshotShape(data.projectInfo)");
expect(body).not.toContain("!Object.prototype.hasOwnProperty.call(data, \"projectInfo\")");
}); });
it("keeps early termination out of regular visit schedule options", () => { it("keeps early termination out of regular visit schedule options", () => {
+33 -28
View File
@@ -2672,6 +2672,7 @@ const setupWorkflowTagMeta = computed<SetupWorkflowTagMeta>(() => SETUP_WORKFLOW
const setupWorkflowTagLabel = computed(() => setupWorkflowTagMeta.value.label); const setupWorkflowTagLabel = computed(() => setupWorkflowTagMeta.value.label);
const setupWorkflowTagType = computed<"success" | "warning" | "info">(() => setupWorkflowTagMeta.value.type); const setupWorkflowTagType = computed<"success" | "warning" | "info">(() => setupWorkflowTagMeta.value.type);
const isSetupDraftEffectivelyEmpty = (draft: SetupConfigDraft): boolean => { const isSetupDraftEffectivelyEmpty = (draft: SetupConfigDraft): boolean => {
if (!isProjectPublishSnapshotEmpty(draft.projectInfo)) return false;
if (draft.projectMilestones.length > 0) return false; if (draft.projectMilestones.length > 0) return false;
if (draft.siteMilestones.length > 0) return false; if (draft.siteMilestones.length > 0) return false;
if (draft.siteEnrollmentPlans.length > 0) return false; if (draft.siteEnrollmentPlans.length > 0) return false;
@@ -2686,6 +2687,8 @@ const isSetupDraftEffectivelyEmpty = (draft: SetupConfigDraft): boolean => {
if ((plan.stageBreakdown || "").trim()) return false; if ((plan.stageBreakdown || "").trim()) return false;
return true; return true;
}; };
const isAtomicSetupDraft = (draft: SetupConfigDraft): boolean =>
!isProjectPublishSnapshotEmpty(draft.projectInfo) || isSetupDraftEffectivelyEmpty(draft);
const shouldShowEmptyDraftBaseInfo = computed(() => isSetupDraftEffectivelyEmpty(setupDraft)); const shouldShowEmptyDraftBaseInfo = computed(() => isSetupDraftEffectivelyEmpty(setupDraft));
const setupDraftBaseVersionText = computed(() => { const setupDraftBaseVersionText = computed(() => {
const explicit = (setupActiveDraftBaseVersion.value || "").trim(); const explicit = (setupActiveDraftBaseVersion.value || "").trim();
@@ -3988,7 +3991,7 @@ const isSetupDraftShape = (value: unknown): value is SetupConfigDraft => {
if (!value || typeof value !== "object") return false; if (!value || typeof value !== "object") return false;
const data = value as Record<string, unknown>; const data = value as Record<string, unknown>;
return ( return (
(!Object.prototype.hasOwnProperty.call(data, "projectInfo") || isProjectPublishSnapshotShape(data.projectInfo)) && isProjectPublishSnapshotShape(data.projectInfo) &&
Array.isArray(data.projectMilestones) && Array.isArray(data.projectMilestones) &&
!!data.enrollmentPlan && !!data.enrollmentPlan &&
Array.isArray(data.siteMilestones) && Array.isArray(data.siteMilestones) &&
@@ -4009,8 +4012,9 @@ const isProjectPublishSnapshotShape = (value: unknown): value is ProjectPublishS
); );
}; };
const isProjectPublishSnapshotEmpty = (snapshot: ProjectPublishSnapshot): boolean => function isProjectPublishSnapshotEmpty(snapshot: ProjectPublishSnapshot): boolean {
JSON.stringify(snapshot) === JSON.stringify(emptyProjectInfoDraft()); return JSON.stringify(snapshot) === JSON.stringify(emptyProjectInfoDraft());
}
const applyProjectInfoDraft = (snapshot: ProjectPublishSnapshot) => { const applyProjectInfoDraft = (snapshot: ProjectPublishSnapshot) => {
suppressEnrollmentFieldSync.value = true; suppressEnrollmentFieldSync.value = true;
@@ -4047,11 +4051,7 @@ const applySetupDraft = (draft: SetupConfigDraft) => {
setupDraft.siteEnrollmentPlans = normalizeSiteEnrollmentPlans(clone(draft.siteEnrollmentPlans)); setupDraft.siteEnrollmentPlans = normalizeSiteEnrollmentPlans(clone(draft.siteEnrollmentPlans));
setupDraft.monitoringStrategies = clone(draft.monitoringStrategies); setupDraft.monitoringStrategies = clone(draft.monitoringStrategies);
setupDraft.centerConfirm = clone(draft.centerConfirm); setupDraft.centerConfirm = clone(draft.centerConfirm);
if (!isProjectPublishSnapshotEmpty(projectInfo) || isSetupDraftEffectivelyEmpty(setupDraft)) { applyProjectInfoDraft(projectInfo);
applyProjectInfoDraft(projectInfo);
} else {
setupDraft.projectInfo = buildProjectPublishSnapshot();
}
formBaselineSnapshot.value = serializeFormForCompare(); formBaselineSnapshot.value = serializeFormForCompare();
suppressDraftWatch.value = false; suppressDraftWatch.value = false;
}; };
@@ -4160,6 +4160,10 @@ const loadSetupDraftFromServer = async (): Promise<SetupConfigDraft | null> => {
try { try {
const { data } = await fetchSetupConfig(project.value.id); const { data } = await fetchSetupConfig(project.value.id);
if (!isSetupDraftShape(data?.data)) return null; if (!isSetupDraftShape(data?.data)) return null;
const draft = {
...clone(data.data),
projectInfo: isProjectPublishSnapshotShape(data.data.projectInfo) ? clone(data.data.projectInfo) : buildProjectPublishSnapshot(),
};
applySetupResponseMeta(data); applySetupResponseMeta(data);
updateLastServerSaveMeta(data.updated_at, data.saved_by_name || resolveSavedByDisplay(data.saved_by)); updateLastServerSaveMeta(data.updated_at, data.saved_by_name || resolveSavedByDisplay(data.saved_by));
setupSaveMeta.value = { setupSaveMeta.value = {
@@ -4167,10 +4171,7 @@ const loadSetupDraftFromServer = async (): Promise<SetupConfigDraft | null> => {
savedBy: data.saved_by_name || resolveSavedByDisplay(data.saved_by), savedBy: data.saved_by_name || resolveSavedByDisplay(data.saved_by),
serverSynced: true, serverSynced: true,
}; };
return { return draft;
...clone(data.data),
projectInfo: isProjectPublishSnapshotShape(data.data.projectInfo) ? clone(data.data.projectInfo) : buildProjectPublishSnapshot(),
};
} catch { } catch {
return null; return null;
} }
@@ -4512,23 +4513,27 @@ const initializeSetupDraft = async () => {
const localDraft = loadLocalSetupDraft(); const localDraft = loadLocalSetupDraft();
if (localDraft) { if (localDraft) {
const shouldRestore = await confirmRestoreLocalDraft("恢复立项配置草稿", localDraft.savedAt, { if (!isAtomicSetupDraft(localDraft.data)) {
expired: localDraft.expired, clearLocalSetupDraft();
conflict: localDraft.conflict, } else {
}); const shouldRestore = await confirmRestoreLocalDraft("恢复立项配置草稿", localDraft.savedAt, {
if (shouldRestore) { expired: localDraft.expired,
applySetupDraft(localDraft.data); conflict: localDraft.conflict,
setupDirtySinceLastPersist.value = true; });
setupLocalDraftSavedAt.value = localDraft.savedAt; if (shouldRestore) {
setupSaveMeta.value = { applySetupDraft(localDraft.data);
updatedAt: localDraft.savedAt || nowString(), setupDirtySinceLastPersist.value = true;
savedBy: authStore.user?.full_name || authStore.user?.username || authStore.user?.email || "当前用户", setupLocalDraftSavedAt.value = localDraft.savedAt;
serverSynced: false, setupSaveMeta.value = {
}; updatedAt: localDraft.savedAt || nowString(),
draftReady.value = true; savedBy: authStore.user?.full_name || authStore.user?.username || authStore.user?.email || "当前用户",
return; serverSynced: false,
};
draftReady.value = true;
return;
}
clearLocalSetupDraft();
} }
clearLocalSetupDraft();
} }
applySetupDraft(createDefaultSetupDraft(siteOptions.value)); applySetupDraft(createDefaultSetupDraft(siteOptions.value));