修复项目配置草稿原子保存逻辑
This commit is contained in:
@@ -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
|
||||
@@ -242,6 +242,7 @@ def _build_default_setup_config_from_study(study, sites: list) -> StudySetupConf
|
||||
if total_target is None:
|
||||
total_target = 0
|
||||
return StudySetupConfigData(
|
||||
projectInfo=_build_project_publish_snapshot(study),
|
||||
projectMilestones=[],
|
||||
enrollmentPlan={
|
||||
"totalTarget": total_target,
|
||||
@@ -263,13 +264,6 @@ def _to_date_text(value: date | None) -> str:
|
||||
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:
|
||||
return ProjectPublishSnapshot(
|
||||
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:
|
||||
if _project_snapshot_has_draft_values(setup_data.projectInfo):
|
||||
return setup_data.projectInfo
|
||||
return _build_project_publish_snapshot(study)
|
||||
return setup_data.projectInfo
|
||||
|
||||
|
||||
def _parse_optional_snapshot_date(value: str) -> date | None:
|
||||
|
||||
@@ -3,7 +3,12 @@ from types import SimpleNamespace
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -172,6 +177,78 @@ def test_project_info_is_part_of_setup_config_payload():
|
||||
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():
|
||||
study = SimpleNamespace(
|
||||
code="PRJ-001",
|
||||
|
||||
@@ -118,7 +118,17 @@ EOF
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
### 4. 检查容器状态
|
||||
### 4. 执行数据库迁移
|
||||
|
||||
已有数据库升级到新代码时,启动后执行一次迁移:
|
||||
|
||||
```bash
|
||||
docker compose run --rm backend python -m alembic upgrade head
|
||||
```
|
||||
|
||||
首次空库安装也可以执行该命令;如果已是最新版本,Alembic 不会重复应用迁移。
|
||||
|
||||
### 5. 检查容器状态
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
@@ -126,7 +136,7 @@ docker compose ps
|
||||
|
||||
确认 `ctms_db`、`ctms_backend`、`ctms_nginx` 都是 `Up`,且 `ctms_db` 为 `healthy`。
|
||||
|
||||
### 5. 检查后端环境
|
||||
### 6. 检查后端环境
|
||||
|
||||
```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)))"
|
||||
@@ -140,7 +150,7 @@ JWT_SECRET_IS_DEV=True
|
||||
LOGIN_RSA_PRIVATE_KEY_SET=False
|
||||
```
|
||||
|
||||
### 6. 检查接口
|
||||
### 7. 检查接口
|
||||
|
||||
```bash
|
||||
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`。
|
||||
|
||||
### 7. 打开系统
|
||||
### 8. 打开系统
|
||||
|
||||
```text
|
||||
http://localhost:8888
|
||||
```
|
||||
|
||||
### 8. 运行验证
|
||||
### 9. 运行验证
|
||||
|
||||
后端:
|
||||
|
||||
@@ -243,7 +253,17 @@ docker compose run --rm backend-init
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
### 7. 检查容器状态
|
||||
### 7. 执行数据库迁移
|
||||
|
||||
已有数据库升级到新代码时,启动后执行一次迁移:
|
||||
|
||||
```bash
|
||||
docker compose run --rm backend python -m alembic upgrade head
|
||||
```
|
||||
|
||||
`backend-init` 会在初始化时处理空库和已有库迁移;这里单独执行迁移用于确认部署代码已落到最新数据库版本。
|
||||
|
||||
### 8. 检查容器状态
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
@@ -251,7 +271,7 @@ docker compose ps
|
||||
|
||||
确认 `ctms_db`、`ctms_backend`、`ctms_nginx` 都是 `Up`,且 `ctms_db` 为 `healthy`。
|
||||
|
||||
### 8. 检查后端环境
|
||||
### 9. 检查后端环境
|
||||
|
||||
```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)"
|
||||
@@ -266,7 +286,7 @@ LOGIN_RSA_PRIVATE_KEY_SET=True
|
||||
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`。
|
||||
|
||||
### 10. 打开系统
|
||||
### 11. 打开系统
|
||||
|
||||
本机:
|
||||
|
||||
@@ -300,7 +320,7 @@ https://<main-domain>
|
||||
|
||||
远程访问必须使用 HTTPS。
|
||||
|
||||
### 11. 运行验证
|
||||
### 12. 运行验证
|
||||
|
||||
后端:
|
||||
|
||||
@@ -417,7 +437,17 @@ docker compose run --rm backend-init
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
### 8. 检查容器状态
|
||||
### 8. 执行数据库迁移
|
||||
|
||||
已有数据库升级到新代码时,启动后执行一次迁移:
|
||||
|
||||
```bash
|
||||
docker compose run --rm backend python -m alembic upgrade head
|
||||
```
|
||||
|
||||
`backend-init` 会在初始化时处理空库和已有库迁移;这里单独执行迁移用于确认部署代码已落到最新数据库版本。
|
||||
|
||||
### 9. 检查容器状态
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
@@ -425,7 +455,7 @@ docker compose ps
|
||||
|
||||
确认 `ctms_db`、`ctms_backend`、`ctms_nginx` 都是 `Up`,且 `ctms_db` 为 `healthy`。
|
||||
|
||||
### 9. 检查后端环境
|
||||
### 10. 检查后端环境
|
||||
|
||||
```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)"
|
||||
@@ -440,7 +470,7 @@ LOGIN_RSA_PRIVATE_KEY_SET=True
|
||||
LOGIN_RSA_KEY_ID=release-YYYYMMDD
|
||||
```
|
||||
|
||||
### 10. 检查生产接口
|
||||
### 11. 检查生产接口
|
||||
|
||||
```bash
|
||||
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`。
|
||||
|
||||
### 11. 打开系统并登录
|
||||
### 12. 打开系统并登录
|
||||
|
||||
```text
|
||||
https://<production-domain>
|
||||
@@ -463,7 +493,7 @@ admin@huapont.cn / admin123
|
||||
|
||||
首次登录后立即修改初始密码。
|
||||
|
||||
### 12. 发布后检查
|
||||
### 13. 发布后检查
|
||||
|
||||
查看容器:
|
||||
|
||||
|
||||
@@ -50,15 +50,28 @@ describe("ProjectDetail setup draft publish workflow", () => {
|
||||
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 functionIndex = source.indexOf("const applySetupDraft = (draft: SetupConfigDraft) => {");
|
||||
const emptyGuardIndex = source.indexOf("!isProjectPublishSnapshotEmpty(projectInfo) || isSetupDraftEffectivelyEmpty(setupDraft)", functionIndex);
|
||||
const fallbackIndex = source.indexOf("setupDraft.projectInfo = buildProjectPublishSnapshot();", emptyGuardIndex);
|
||||
const nextFunctionIndex = source.indexOf("const loadLocalSetupDraft = ", functionIndex);
|
||||
const body = source.slice(functionIndex, nextFunctionIndex);
|
||||
|
||||
expect(functionIndex).toBeGreaterThan(-1);
|
||||
expect(emptyGuardIndex).toBeGreaterThan(functionIndex);
|
||||
expect(fallbackIndex).toBeGreaterThan(emptyGuardIndex);
|
||||
expect(body).toContain("setupDraft.projectInfo = projectInfo;");
|
||||
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", () => {
|
||||
|
||||
@@ -2672,6 +2672,7 @@ const setupWorkflowTagMeta = computed<SetupWorkflowTagMeta>(() => SETUP_WORKFLOW
|
||||
const setupWorkflowTagLabel = computed(() => setupWorkflowTagMeta.value.label);
|
||||
const setupWorkflowTagType = computed<"success" | "warning" | "info">(() => setupWorkflowTagMeta.value.type);
|
||||
const isSetupDraftEffectivelyEmpty = (draft: SetupConfigDraft): boolean => {
|
||||
if (!isProjectPublishSnapshotEmpty(draft.projectInfo)) return false;
|
||||
if (draft.projectMilestones.length > 0) return false;
|
||||
if (draft.siteMilestones.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;
|
||||
return true;
|
||||
};
|
||||
const isAtomicSetupDraft = (draft: SetupConfigDraft): boolean =>
|
||||
!isProjectPublishSnapshotEmpty(draft.projectInfo) || isSetupDraftEffectivelyEmpty(draft);
|
||||
const shouldShowEmptyDraftBaseInfo = computed(() => isSetupDraftEffectivelyEmpty(setupDraft));
|
||||
const setupDraftBaseVersionText = computed(() => {
|
||||
const explicit = (setupActiveDraftBaseVersion.value || "").trim();
|
||||
@@ -3988,7 +3991,7 @@ const isSetupDraftShape = (value: unknown): value is SetupConfigDraft => {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const data = value as Record<string, unknown>;
|
||||
return (
|
||||
(!Object.prototype.hasOwnProperty.call(data, "projectInfo") || isProjectPublishSnapshotShape(data.projectInfo)) &&
|
||||
isProjectPublishSnapshotShape(data.projectInfo) &&
|
||||
Array.isArray(data.projectMilestones) &&
|
||||
!!data.enrollmentPlan &&
|
||||
Array.isArray(data.siteMilestones) &&
|
||||
@@ -4009,8 +4012,9 @@ const isProjectPublishSnapshotShape = (value: unknown): value is ProjectPublishS
|
||||
);
|
||||
};
|
||||
|
||||
const isProjectPublishSnapshotEmpty = (snapshot: ProjectPublishSnapshot): boolean =>
|
||||
JSON.stringify(snapshot) === JSON.stringify(emptyProjectInfoDraft());
|
||||
function isProjectPublishSnapshotEmpty(snapshot: ProjectPublishSnapshot): boolean {
|
||||
return JSON.stringify(snapshot) === JSON.stringify(emptyProjectInfoDraft());
|
||||
}
|
||||
|
||||
const applyProjectInfoDraft = (snapshot: ProjectPublishSnapshot) => {
|
||||
suppressEnrollmentFieldSync.value = true;
|
||||
@@ -4047,11 +4051,7 @@ const applySetupDraft = (draft: SetupConfigDraft) => {
|
||||
setupDraft.siteEnrollmentPlans = normalizeSiteEnrollmentPlans(clone(draft.siteEnrollmentPlans));
|
||||
setupDraft.monitoringStrategies = clone(draft.monitoringStrategies);
|
||||
setupDraft.centerConfirm = clone(draft.centerConfirm);
|
||||
if (!isProjectPublishSnapshotEmpty(projectInfo) || isSetupDraftEffectivelyEmpty(setupDraft)) {
|
||||
applyProjectInfoDraft(projectInfo);
|
||||
} else {
|
||||
setupDraft.projectInfo = buildProjectPublishSnapshot();
|
||||
}
|
||||
applyProjectInfoDraft(projectInfo);
|
||||
formBaselineSnapshot.value = serializeFormForCompare();
|
||||
suppressDraftWatch.value = false;
|
||||
};
|
||||
@@ -4160,6 +4160,10 @@ const loadSetupDraftFromServer = async (): Promise<SetupConfigDraft | null> => {
|
||||
try {
|
||||
const { data } = await fetchSetupConfig(project.value.id);
|
||||
if (!isSetupDraftShape(data?.data)) return null;
|
||||
const draft = {
|
||||
...clone(data.data),
|
||||
projectInfo: isProjectPublishSnapshotShape(data.data.projectInfo) ? clone(data.data.projectInfo) : buildProjectPublishSnapshot(),
|
||||
};
|
||||
applySetupResponseMeta(data);
|
||||
updateLastServerSaveMeta(data.updated_at, data.saved_by_name || resolveSavedByDisplay(data.saved_by));
|
||||
setupSaveMeta.value = {
|
||||
@@ -4167,10 +4171,7 @@ const loadSetupDraftFromServer = async (): Promise<SetupConfigDraft | null> => {
|
||||
savedBy: data.saved_by_name || resolveSavedByDisplay(data.saved_by),
|
||||
serverSynced: true,
|
||||
};
|
||||
return {
|
||||
...clone(data.data),
|
||||
projectInfo: isProjectPublishSnapshotShape(data.data.projectInfo) ? clone(data.data.projectInfo) : buildProjectPublishSnapshot(),
|
||||
};
|
||||
return draft;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -4512,23 +4513,27 @@ const initializeSetupDraft = async () => {
|
||||
|
||||
const localDraft = loadLocalSetupDraft();
|
||||
if (localDraft) {
|
||||
const shouldRestore = await confirmRestoreLocalDraft("恢复立项配置草稿", localDraft.savedAt, {
|
||||
expired: localDraft.expired,
|
||||
conflict: localDraft.conflict,
|
||||
});
|
||||
if (shouldRestore) {
|
||||
applySetupDraft(localDraft.data);
|
||||
setupDirtySinceLastPersist.value = true;
|
||||
setupLocalDraftSavedAt.value = localDraft.savedAt;
|
||||
setupSaveMeta.value = {
|
||||
updatedAt: localDraft.savedAt || nowString(),
|
||||
savedBy: authStore.user?.full_name || authStore.user?.username || authStore.user?.email || "当前用户",
|
||||
serverSynced: false,
|
||||
};
|
||||
draftReady.value = true;
|
||||
return;
|
||||
if (!isAtomicSetupDraft(localDraft.data)) {
|
||||
clearLocalSetupDraft();
|
||||
} else {
|
||||
const shouldRestore = await confirmRestoreLocalDraft("恢复立项配置草稿", localDraft.savedAt, {
|
||||
expired: localDraft.expired,
|
||||
conflict: localDraft.conflict,
|
||||
});
|
||||
if (shouldRestore) {
|
||||
applySetupDraft(localDraft.data);
|
||||
setupDirtySinceLastPersist.value = true;
|
||||
setupLocalDraftSavedAt.value = localDraft.savedAt;
|
||||
setupSaveMeta.value = {
|
||||
updatedAt: localDraft.savedAt || nowString(),
|
||||
savedBy: authStore.user?.full_name || authStore.user?.username || authStore.user?.email || "当前用户",
|
||||
serverSynced: false,
|
||||
};
|
||||
draftReady.value = true;
|
||||
return;
|
||||
}
|
||||
clearLocalSetupDraft();
|
||||
}
|
||||
clearLocalSetupDraft();
|
||||
}
|
||||
|
||||
applySetupDraft(createDefaultSetupDraft(siteOptions.value));
|
||||
|
||||
Reference in New Issue
Block a user