立项配置版本管理优化(回滚逻辑、UI、下载功能)

This commit is contained in:
Cheng Zhou
2026-03-04 14:49:59 +08:00
parent 6c5e5770c6
commit 0cd03eefa8
16 changed files with 3528 additions and 418 deletions
@@ -0,0 +1,44 @@
"""add published project snapshot to study_setup_config_versions
Revision ID: 20260228_02
Revises: 20260228_01
Create Date: 2026-02-28 18:20:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "20260228_02"
down_revision: Union[str, None] = "20260228_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = set(inspector.get_table_names())
if "study_setup_config_versions" not in tables:
return
columns = {col["name"] for col in inspector.get_columns("study_setup_config_versions")}
if "published_project_snapshot" not in columns:
op.add_column(
"study_setup_config_versions",
sa.Column("published_project_snapshot", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = set(inspector.get_table_names())
if "study_setup_config_versions" not in tables:
return
columns = {col["name"] for col in inspector.get_columns("study_setup_config_versions")}
if "published_project_snapshot" in columns:
op.drop_column("study_setup_config_versions", "published_project_snapshot")
@@ -0,0 +1,235 @@
"""add setup config version branching metadata
Revision ID: 20260302_01
Revises: 20260228_02
Create Date: 2026-03-02 16:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "20260302_01"
down_revision: Union[str, None] = "20260228_02"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _has_table(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in set(inspector.get_table_names())
def _has_column(inspector: sa.Inspector, table_name: str, column_name: str) -> bool:
return column_name in {col["name"] for col in inspector.get_columns(table_name)}
def _has_unique_constraint(inspector: sa.Inspector, table_name: str, constraint_name: str) -> bool:
return constraint_name in {item["name"] for item in inspector.get_unique_constraints(table_name)}
def _has_foreign_key(inspector: sa.Inspector, table_name: str, fk_name: str) -> bool:
return fk_name in {item["name"] for item in inspector.get_foreign_keys(table_name)}
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _has_table(inspector, "study_setup_config_versions"):
if not _has_column(inspector, "study_setup_config_versions", "branch_name"):
op.add_column("study_setup_config_versions", sa.Column("branch_name", sa.String(), nullable=True))
if not _has_column(inspector, "study_setup_config_versions", "branch_seq"):
op.add_column("study_setup_config_versions", sa.Column("branch_seq", sa.Integer(), nullable=True))
if not _has_column(inspector, "study_setup_config_versions", "version_label"):
op.add_column("study_setup_config_versions", sa.Column("version_label", sa.String(), nullable=True))
if not _has_column(inspector, "study_setup_config_versions", "parent_version_id"):
op.add_column("study_setup_config_versions", sa.Column("parent_version_id", postgresql.UUID(as_uuid=True), nullable=True))
if not _has_column(inspector, "study_setup_config_versions", "merged_from_version_id"):
op.add_column(
"study_setup_config_versions",
sa.Column("merged_from_version_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.execute("UPDATE study_setup_config_versions SET branch_name = 'main' WHERE branch_name IS NULL")
op.execute(
"UPDATE study_setup_config_versions SET branch_seq = COALESCE(display_version, version, 1) WHERE branch_seq IS NULL"
)
op.execute(
"UPDATE study_setup_config_versions SET version_label = ('v' || COALESCE(display_version, version, 1)::text) WHERE version_label IS NULL"
)
op.alter_column("study_setup_config_versions", "branch_name", nullable=False)
op.alter_column("study_setup_config_versions", "branch_seq", nullable=False)
op.alter_column("study_setup_config_versions", "version_label", nullable=False)
inspector = sa.inspect(bind)
if not _has_foreign_key(inspector, "study_setup_config_versions", "fk_setup_config_versions_parent_version"):
op.create_foreign_key(
"fk_setup_config_versions_parent_version",
"study_setup_config_versions",
"study_setup_config_versions",
["parent_version_id"],
["id"],
ondelete="SET NULL",
)
if not _has_foreign_key(inspector, "study_setup_config_versions", "fk_setup_config_versions_merged_from_version"):
op.create_foreign_key(
"fk_setup_config_versions_merged_from_version",
"study_setup_config_versions",
"study_setup_config_versions",
["merged_from_version_id"],
["id"],
ondelete="SET NULL",
)
if _has_unique_constraint(inspector, "study_setup_config_versions", "uq_setup_config_versions_study_display_version"):
op.drop_constraint(
"uq_setup_config_versions_study_display_version",
"study_setup_config_versions",
type_="unique",
)
inspector = sa.inspect(bind)
if not _has_unique_constraint(inspector, "study_setup_config_versions", "uq_setup_config_versions_study_branch_seq"):
op.create_unique_constraint(
"uq_setup_config_versions_study_branch_seq",
"study_setup_config_versions",
["study_id", "branch_name", "branch_seq"],
)
if not _has_unique_constraint(inspector, "study_setup_config_versions", "uq_setup_config_versions_study_version_label"):
op.create_unique_constraint(
"uq_setup_config_versions_study_version_label",
"study_setup_config_versions",
["study_id", "version_label"],
)
if _has_table(inspector, "study_setup_configs"):
if not _has_column(inspector, "study_setup_configs", "current_branch_name"):
op.add_column("study_setup_configs", sa.Column("current_branch_name", sa.String(), nullable=True))
if not _has_column(inspector, "study_setup_configs", "active_branch_base_version_id"):
op.add_column(
"study_setup_configs",
sa.Column("active_branch_base_version_id", postgresql.UUID(as_uuid=True), nullable=True),
)
if not _has_column(inspector, "study_setup_configs", "current_published_version_id"):
op.add_column(
"study_setup_configs",
sa.Column("current_published_version_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.execute("UPDATE study_setup_configs SET current_branch_name = 'main' WHERE current_branch_name IS NULL")
op.alter_column("study_setup_configs", "current_branch_name", nullable=False)
if _has_table(inspector, "study_setup_config_versions"):
op.execute(
"""
UPDATE study_setup_configs
SET current_published_version_id = (
SELECT v.id
FROM study_setup_config_versions AS v
WHERE v.study_id = study_setup_configs.study_id
AND study_setup_configs.published_config IS NOT NULL
AND v.config = study_setup_configs.published_config
ORDER BY
CASE
WHEN study_setup_configs.published_at IS NOT NULL
AND v.published_at = study_setup_configs.published_at THEN 0
ELSE 1
END,
v.version DESC
LIMIT 1
)
WHERE current_published_version_id IS NULL
"""
)
inspector = sa.inspect(bind)
if not _has_foreign_key(inspector, "study_setup_configs", "fk_setup_configs_active_branch_base_version"):
op.create_foreign_key(
"fk_setup_configs_active_branch_base_version",
"study_setup_configs",
"study_setup_config_versions",
["active_branch_base_version_id"],
["id"],
ondelete="SET NULL",
)
if not _has_foreign_key(inspector, "study_setup_configs", "fk_setup_configs_current_published_version"):
op.create_foreign_key(
"fk_setup_configs_current_published_version",
"study_setup_configs",
"study_setup_config_versions",
["current_published_version_id"],
["id"],
ondelete="SET NULL",
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _has_table(inspector, "study_setup_configs"):
if _has_foreign_key(inspector, "study_setup_configs", "fk_setup_configs_current_published_version"):
op.drop_constraint(
"fk_setup_configs_current_published_version",
"study_setup_configs",
type_="foreignkey",
)
if _has_foreign_key(inspector, "study_setup_configs", "fk_setup_configs_active_branch_base_version"):
op.drop_constraint(
"fk_setup_configs_active_branch_base_version",
"study_setup_configs",
type_="foreignkey",
)
if _has_column(inspector, "study_setup_configs", "current_published_version_id"):
op.drop_column("study_setup_configs", "current_published_version_id")
if _has_column(inspector, "study_setup_configs", "active_branch_base_version_id"):
op.drop_column("study_setup_configs", "active_branch_base_version_id")
if _has_column(inspector, "study_setup_configs", "current_branch_name"):
op.drop_column("study_setup_configs", "current_branch_name")
inspector = sa.inspect(bind)
if _has_table(inspector, "study_setup_config_versions"):
if _has_unique_constraint(inspector, "study_setup_config_versions", "uq_setup_config_versions_study_version_label"):
op.drop_constraint(
"uq_setup_config_versions_study_version_label",
"study_setup_config_versions",
type_="unique",
)
if _has_unique_constraint(inspector, "study_setup_config_versions", "uq_setup_config_versions_study_branch_seq"):
op.drop_constraint(
"uq_setup_config_versions_study_branch_seq",
"study_setup_config_versions",
type_="unique",
)
if not _has_unique_constraint(inspector, "study_setup_config_versions", "uq_setup_config_versions_study_display_version"):
op.create_unique_constraint(
"uq_setup_config_versions_study_display_version",
"study_setup_config_versions",
["study_id", "display_version"],
)
if _has_foreign_key(inspector, "study_setup_config_versions", "fk_setup_config_versions_merged_from_version"):
op.drop_constraint(
"fk_setup_config_versions_merged_from_version",
"study_setup_config_versions",
type_="foreignkey",
)
if _has_foreign_key(inspector, "study_setup_config_versions", "fk_setup_config_versions_parent_version"):
op.drop_constraint(
"fk_setup_config_versions_parent_version",
"study_setup_config_versions",
type_="foreignkey",
)
if _has_column(inspector, "study_setup_config_versions", "merged_from_version_id"):
op.drop_column("study_setup_config_versions", "merged_from_version_id")
if _has_column(inspector, "study_setup_config_versions", "parent_version_id"):
op.drop_column("study_setup_config_versions", "parent_version_id")
if _has_column(inspector, "study_setup_config_versions", "version_label"):
op.drop_column("study_setup_config_versions", "version_label")
if _has_column(inspector, "study_setup_config_versions", "branch_seq"):
op.drop_column("study_setup_config_versions", "branch_seq")
if _has_column(inspector, "study_setup_config_versions", "branch_name"):
op.drop_column("study_setup_config_versions", "branch_name")
@@ -0,0 +1,71 @@
"""drop unique constraint on setup config version label
Revision ID: 20260302_02
Revises: 20260302_01
Create Date: 2026-03-02 20:20:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "20260302_02"
down_revision: Union[str, None] = "20260302_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _has_table(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in set(inspector.get_table_names())
def _has_unique_constraint(inspector: sa.Inspector, table_name: str, constraint_name: str) -> bool:
return constraint_name in {item["name"] for item in inspector.get_unique_constraints(table_name)}
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
table_name = "study_setup_config_versions"
constraint_name = "uq_setup_config_versions_study_version_label"
if _has_table(inspector, table_name) and _has_unique_constraint(inspector, table_name, constraint_name):
op.drop_constraint(constraint_name, table_name, type_="unique")
if _has_table(inspector, table_name):
op.execute(
"""
WITH ranked AS (
SELECT
id,
display_version,
ROW_NUMBER() OVER (
PARTITION BY study_id, branch_name, display_version
ORDER BY published_at ASC, version ASC, created_at ASC
) AS rev_seq
FROM study_setup_config_versions
WHERE branch_name = 'main'
)
UPDATE study_setup_config_versions AS v
SET version_label = CASE
WHEN ranked.rev_seq <= 1 THEN ('v' || ranked.display_version::text)
ELSE ('v' || ranked.display_version::text || '-r' || ranked.rev_seq::text)
END
FROM ranked
WHERE v.id = ranked.id
"""
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
table_name = "study_setup_config_versions"
constraint_name = "uq_setup_config_versions_study_version_label"
if _has_table(inspector, table_name) and not _has_unique_constraint(inspector, table_name, constraint_name):
op.create_unique_constraint(
constraint_name,
table_name,
["study_id", "version_label"],
)
+445 -12
View File
@@ -1,6 +1,6 @@
import uuid import uuid
import re import re
from datetime import date from datetime import date, datetime
from collections import Counter from collections import Counter
from typing import Any from typing import Any
@@ -27,7 +27,10 @@ from app.schemas.member import StudyMemberCreate
from app.schemas.study_setup_config import ( from app.schemas.study_setup_config import (
ProjectPublishSnapshot, ProjectPublishSnapshot,
SetupProjectionSummary, SetupProjectionSummary,
StudySetupConfigCheckoutBranch,
StudySetupConfigData, StudySetupConfigData,
StudySetupConfigDraftAction,
StudySetupConfigMergeToMain,
StudySetupConfigPublish, StudySetupConfigPublish,
StudySetupConfigRead, StudySetupConfigRead,
StudySetupConfigRollback, StudySetupConfigRollback,
@@ -295,6 +298,8 @@ def _to_setup_config_read(
*, *,
saved_by_name: str | None = None, saved_by_name: str | None = None,
published_by_name: str | None = None, published_by_name: str | None = None,
current_published_version_label: str | None = None,
active_branch_base_version_label: str | None = None,
projection_status: str | None = None, projection_status: str | None = None,
projection_summary: SetupProjectionSummary | None = None, projection_summary: SetupProjectionSummary | None = None,
) -> StudySetupConfigRead: ) -> StudySetupConfigRead:
@@ -302,6 +307,11 @@ def _to_setup_config_read(
id=record.id, id=record.id,
study_id=record.study_id, study_id=record.study_id,
version=record.version, version=record.version,
current_branch_name=(getattr(record, "current_branch_name", None) or "main"),
current_published_version_id=getattr(record, "current_published_version_id", None),
current_published_version_label=current_published_version_label,
active_branch_base_version_id=getattr(record, "active_branch_base_version_id", None),
active_branch_base_version_label=active_branch_base_version_label,
data=StudySetupConfigData.model_validate(record.config or {}), data=StudySetupConfigData.model_validate(record.config or {}),
publish_status=record.publish_status or "DRAFT", publish_status=record.publish_status or "DRAFT",
published_data=StudySetupConfigData.model_validate(record.published_config) if record.published_config else None, published_data=StudySetupConfigData.model_validate(record.published_config) if record.published_config else None,
@@ -363,15 +373,42 @@ def _ensure_study_timeline_valid(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="访视窗口开始偏移不能晚于结束偏移") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="访视窗口开始偏移不能晚于结束偏移")
def _to_setup_config_version_read(record, *, published_by_name: str | None = None) -> StudySetupConfigVersionRead: def _resolve_version_label(record: Any) -> str:
branch_name = (getattr(record, "branch_name", None) or "main").strip() or "main"
display_version = int(getattr(record, "display_version", 0) or 0)
branch_seq = int(getattr(record, "branch_seq", 1) or 1)
if branch_name == "main":
return getattr(record, "version_label", None) or f"v{display_version}"
return getattr(record, "version_label", None) or f"v{display_version}.{branch_seq}"
def _to_setup_config_version_read(
record,
*,
published_by_name: str | None = None,
is_current_published: bool = False,
is_active_draft_base: bool = False,
) -> StudySetupConfigVersionRead:
return StudySetupConfigVersionRead( return StudySetupConfigVersionRead(
id=record.id, id=record.id,
study_id=record.study_id, study_id=record.study_id,
study_setup_config_id=record.study_setup_config_id, study_setup_config_id=record.study_setup_config_id,
version=record.version, version=record.version,
display_version=record.display_version, display_version=record.display_version,
branch_name=(record.branch_name or "main"),
branch_seq=record.branch_seq or 1,
version_label=_resolve_version_label(record),
source_version=record.source_version, source_version=record.source_version,
parent_version_id=record.parent_version_id,
merged_from_version_id=record.merged_from_version_id,
config=StudySetupConfigData.model_validate(record.config or {}), config=StudySetupConfigData.model_validate(record.config or {}),
published_project_snapshot=(
ProjectPublishSnapshot.model_validate(record.published_project_snapshot)
if record.published_project_snapshot
else None
),
is_current_published=is_current_published,
is_active_draft_base=is_active_draft_base,
published_by=record.published_by, published_by=record.published_by,
published_by_name=published_by_name, published_by_name=published_by_name,
published_at=record.published_at, published_at=record.published_at,
@@ -379,6 +416,89 @@ def _to_setup_config_version_read(record, *, published_by_name: str | None = Non
) )
def _resolve_current_published_snapshot_version(
versions: list[Any],
setup_record: Any | None,
) -> int | None:
if not setup_record:
return None
current_published_snapshot_id = getattr(setup_record, "current_published_version_id", None)
if current_published_snapshot_id:
matched = next((item for item in versions if item.id == current_published_snapshot_id), None)
if matched:
return matched.version
published_config = setup_record.published_config or {}
if not published_config:
return None
best_score = -1
best_version: int | None = None
for item in versions:
if (item.config or {}) != published_config:
continue
score = 1
if setup_record.published_at and item.published_at == setup_record.published_at:
score += 2
if setup_record.published_by and item.published_by == setup_record.published_by:
score += 1
if score > best_score or (score == best_score and (best_version is None or item.version > best_version)):
best_score = score
best_version = item.version
return best_version
def _resolve_current_published_snapshot_label(
versions: list[Any],
setup_record: Any | None,
) -> str | None:
if not setup_record:
return None
current_published_snapshot_id = getattr(setup_record, "current_published_version_id", None)
if current_published_snapshot_id:
matched = next((item for item in versions if item.id == current_published_snapshot_id), None)
if matched:
return _resolve_version_label(matched)
version = _resolve_current_published_snapshot_version(versions, setup_record)
if version is None:
return None
matched = next((item for item in versions if item.version == version), None)
if not matched:
return None
return _resolve_version_label(matched)
def _resolve_active_branch_base_snapshot_version(
versions: list[Any],
setup_record: Any | None,
) -> int | None:
if not setup_record:
return None
base_snapshot_id = getattr(setup_record, "active_branch_base_version_id", None)
if not base_snapshot_id:
return None
matched = next((item for item in versions if item.id == base_snapshot_id), None)
if not matched:
return None
return matched.version
def _resolve_active_branch_base_snapshot_label(
versions: list[Any],
setup_record: Any | None,
) -> str | None:
if not setup_record:
return None
base_snapshot_id = getattr(setup_record, "active_branch_base_version_id", None)
if not base_snapshot_id:
return None
matched = next((item for item in versions if item.id == base_snapshot_id), None)
if not matched:
return None
return _resolve_version_label(matched)
_SETUP_MODULE_KEYS = ( _SETUP_MODULE_KEYS = (
"projectMilestones", "projectMilestones",
"enrollmentPlan", "enrollmentPlan",
@@ -777,7 +897,14 @@ async def get_study_setup_config(
if user: if user:
published_by_name = user.full_name or user.username or user.email published_by_name = user.full_name or user.username or user.email
return _to_setup_config_read(record, saved_by_name=saved_by_name, published_by_name=published_by_name) versions = await study_setup_config_crud.list_versions(db, study_id)
return _to_setup_config_read(
record,
saved_by_name=saved_by_name,
published_by_name=published_by_name,
current_published_version_label=_resolve_current_published_snapshot_label(versions, record),
active_branch_base_version_label=_resolve_active_branch_base_snapshot_label(versions, record),
)
@router.put( @router.put(
@@ -843,7 +970,14 @@ async def upsert_study_setup_config(
user = await user_crud.get_by_id(db, record.published_by) user = await user_crud.get_by_id(db, record.published_by)
if user: if user:
published_by_name = user.full_name or user.username or user.email published_by_name = user.full_name or user.username or user.email
return _to_setup_config_read(record, saved_by_name=saved_by_name, published_by_name=published_by_name) versions = await study_setup_config_crud.list_versions(db, study_id)
return _to_setup_config_read(
record,
saved_by_name=saved_by_name,
published_by_name=published_by_name,
current_published_version_label=_resolve_current_published_snapshot_label(versions, record),
active_branch_base_version_label=_resolve_active_branch_base_snapshot_label(versions, record),
)
@router.post( @router.post(
@@ -864,6 +998,10 @@ async def publish_study_setup_config(
record = await study_setup_config_crud.get_by_study(db, study_id) record = await study_setup_config_crud.get_by_study(db, study_id)
if not record: if not record:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="请先保存立项配置草稿") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="请先保存立项配置草稿")
current_project_snapshot_for_publish = _build_project_publish_snapshot(study).model_dump(mode="json")
force_create_snapshot = bool(
(record.published_project_snapshot or {}) != current_project_snapshot_for_publish
)
sites = await site_crud.list_by_study(db, study_id, skip=0, limit=1000, include_inactive=True) sites = await site_crud.list_by_study(db, study_id, skip=0, limit=1000, include_inactive=True)
site_lookup = {str(site.id): site.name or "" for site in sites} site_lookup = {str(site.id): site.name or "" for site in sites}
@@ -879,6 +1017,7 @@ async def publish_study_setup_config(
study_id, study_id,
expected_version=payload.expected_version, expected_version=payload.expected_version,
published_by=current_user.id, published_by=current_user.id,
force_create_snapshot=force_create_snapshot,
auto_commit=False, auto_commit=False,
) )
if conflict: if conflict:
@@ -892,7 +1031,7 @@ async def publish_study_setup_config(
projection = await apply_setup_projection_on_publish( projection = await apply_setup_projection_on_publish(
db, db,
study_id=study_id, study_id=study_id,
setup_data=StudySetupConfigData.model_validate(published.config or {}), setup_data=StudySetupConfigData.model_validate(published.published_config or {}),
operator_id=current_user.id, operator_id=current_user.id,
) )
projection_summary = SetupProjectionSummary( projection_summary = SetupProjectionSummary(
@@ -907,7 +1046,14 @@ async def publish_study_setup_config(
) )
study_after_publish = await study_crud.get(db, study_id) study_after_publish = await study_crud.get(db, study_id)
if study_after_publish: if study_after_publish:
published.published_project_snapshot = _build_project_publish_snapshot(study_after_publish).model_dump(mode="json") project_snapshot = _build_project_publish_snapshot(study_after_publish).model_dump(mode="json")
published.published_project_snapshot = project_snapshot
await study_setup_config_crud.set_version_project_snapshot(
db,
study_id,
version=published.version,
project_snapshot=project_snapshot,
)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
study_id=study_id, study_id=study_id,
@@ -926,6 +1072,10 @@ async def publish_study_setup_config(
operator_role=current_user.role, operator_role=current_user.role,
auto_commit=False, auto_commit=False,
) )
published.config = study_setup_config_crud.empty_draft_payload()
published.active_branch_base_version_id = None
published.saved_by = current_user.id
published.updated_at = datetime.utcnow()
await db.commit() await db.commit()
except Exception: except Exception:
await db.rollback() await db.rollback()
@@ -933,10 +1083,13 @@ async def publish_study_setup_config(
operator_name = current_user.full_name or current_user.username or current_user.email operator_name = current_user.full_name or current_user.username or current_user.email
await db.refresh(published) await db.refresh(published)
versions = await study_setup_config_crud.list_versions(db, study_id)
return _to_setup_config_read( return _to_setup_config_read(
published, published,
saved_by_name=operator_name, saved_by_name=operator_name,
published_by_name=operator_name, published_by_name=operator_name,
current_published_version_label=_resolve_current_published_snapshot_label(versions, published),
active_branch_base_version_label=_resolve_active_branch_base_snapshot_label(versions, published),
projection_status=projection.status, projection_status=projection.status,
projection_summary=projection_summary, projection_summary=projection_summary,
) )
@@ -956,6 +1109,9 @@ async def list_study_setup_config_versions(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
versions = await study_setup_config_crud.list_versions(db, study_id) versions = await study_setup_config_crud.list_versions(db, study_id)
current_setup = await study_setup_config_crud.get_by_study(db, study_id)
current_published_version = _resolve_current_published_snapshot_version(versions, current_setup)
active_draft_base_version = _resolve_active_branch_base_snapshot_version(versions, current_setup)
user_ids = {item.published_by for item in versions if item.published_by} user_ids = {item.published_by for item in versions if item.published_by}
name_map: dict[uuid.UUID, str] = {} name_map: dict[uuid.UUID, str] = {}
for user_id in user_ids: for user_id in user_ids:
@@ -963,7 +1119,12 @@ async def list_study_setup_config_versions(
if user: if user:
name_map[user_id] = user.full_name or user.username or user.email name_map[user_id] = user.full_name or user.username or user.email
return [ return [
_to_setup_config_version_read(item, published_by_name=name_map.get(item.published_by) if item.published_by else None) _to_setup_config_version_read(
item,
published_by_name=name_map.get(item.published_by) if item.published_by else None,
is_current_published=item.version == current_published_version,
is_active_draft_base=item.version == active_draft_base_version,
)
for item in versions for item in versions
] ]
@@ -982,6 +1143,9 @@ async def rollback_study_setup_config(
study = await study_crud.get(db, study_id) study = await study_crud.get(db, study_id)
if not study: if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
versions_before = await study_setup_config_crud.list_versions(db, study_id)
target_label_map = {item.version: (item.version_label or f"v{item.display_version}") for item in versions_before}
target_label = target_label_map.get(payload.target_version, f"v{payload.target_version}")
record, conflict, target_missing = await study_setup_config_crud.rollback_to_version( record, conflict, target_missing = await study_setup_config_crud.rollback_to_version(
db, db,
@@ -1003,7 +1167,7 @@ async def rollback_study_setup_config(
entity_type="study_setup_config", entity_type="study_setup_config",
entity_id=record.id, entity_id=record.id,
action="ROLLBACK_SETUP_CONFIG", action="ROLLBACK_SETUP_CONFIG",
detail=f"立项配置已回滚到发布版本 v{payload.target_version}", detail=f"立项配置已回滚并替换当前发布为 {target_label},草稿分支已切换到对应分支基线",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
@@ -1013,7 +1177,273 @@ async def rollback_study_setup_config(
user = await user_crud.get_by_id(db, record.published_by) user = await user_crud.get_by_id(db, record.published_by)
if user: if user:
published_by_name = user.full_name or user.username or user.email published_by_name = user.full_name or user.username or user.email
return _to_setup_config_read(record, saved_by_name=operator_name, published_by_name=published_by_name) versions = await study_setup_config_crud.list_versions(db, study_id)
return _to_setup_config_read(
record,
saved_by_name=operator_name,
published_by_name=published_by_name,
current_published_version_label=_resolve_current_published_snapshot_label(versions, record),
active_branch_base_version_label=_resolve_active_branch_base_snapshot_label(versions, record),
)
@router.post(
"/{study_id}/setup-config/draft/checkout-branch",
response_model=StudySetupConfigRead,
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
)
async def checkout_study_setup_config_branch_draft(
study_id: uuid.UUID,
payload: StudySetupConfigCheckoutBranch,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> StudySetupConfigRead:
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
versions_before = await study_setup_config_crud.list_versions(db, study_id)
target_label_map = {item.version: (item.version_label or f"v{item.display_version}") for item in versions_before}
target_label = target_label_map.get(payload.target_version, f"v{payload.target_version}")
record, conflict, target_missing = await study_setup_config_crud.checkout_branch_draft_from_version(
db,
study_id,
expected_version=payload.expected_version,
target_version=payload.target_version,
saved_by=current_user.id,
)
if conflict:
_raise_conflict_error()
if target_missing:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标版本不存在")
if not record:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="切换分支草稿失败")
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="study_setup_config",
entity_id=record.id,
action="CHECKOUT_SETUP_CONFIG_BRANCH_DRAFT",
detail=f"立项配置草稿已切换到 {target_label} 对应分支",
operator_id=current_user.id,
operator_role=current_user.role,
)
operator_name = current_user.full_name or current_user.username or current_user.email
published_by_name = None
if record.published_by:
user = await user_crud.get_by_id(db, record.published_by)
if user:
published_by_name = user.full_name or user.username or user.email
versions = await study_setup_config_crud.list_versions(db, study_id)
return _to_setup_config_read(
record,
saved_by_name=operator_name,
published_by_name=published_by_name,
current_published_version_label=_resolve_current_published_snapshot_label(versions, record),
active_branch_base_version_label=_resolve_active_branch_base_snapshot_label(versions, record),
)
@router.post(
"/{study_id}/setup-config/draft/clear",
response_model=StudySetupConfigRead,
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
)
async def clear_study_setup_config_draft(
study_id: uuid.UUID,
payload: StudySetupConfigDraftAction,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> StudySetupConfigRead:
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
record, conflict = await study_setup_config_crud.clear_draft(
db,
study_id,
expected_version=payload.expected_version,
saved_by=current_user.id,
)
if conflict:
_raise_conflict_error()
if not record:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="清空草稿失败")
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="study_setup_config",
entity_id=record.id,
action="CLEAR_SETUP_CONFIG_DRAFT",
detail="立项配置草稿已清空",
operator_id=current_user.id,
operator_role=current_user.role,
)
operator_name = current_user.full_name or current_user.username or current_user.email
published_by_name = None
if record.published_by:
user = await user_crud.get_by_id(db, record.published_by)
if user:
published_by_name = user.full_name or user.username or user.email
versions = await study_setup_config_crud.list_versions(db, study_id)
return _to_setup_config_read(
record,
saved_by_name=operator_name,
published_by_name=published_by_name,
current_published_version_label=_resolve_current_published_snapshot_label(versions, record),
active_branch_base_version_label=_resolve_active_branch_base_snapshot_label(versions, record),
)
@router.post(
"/{study_id}/setup-config/draft/refill",
response_model=StudySetupConfigRead,
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
)
async def refill_study_setup_config_draft(
study_id: uuid.UUID,
payload: StudySetupConfigDraftAction,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> StudySetupConfigRead:
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
record, conflict, no_published = await study_setup_config_crud.refill_draft_from_published(
db,
study_id,
expected_version=payload.expected_version,
saved_by=current_user.id,
)
if conflict:
_raise_conflict_error()
if no_published:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前无已发布版本,无法回填草稿")
if not record:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="回填草稿失败")
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="study_setup_config",
entity_id=record.id,
action="REFILL_SETUP_CONFIG_DRAFT",
detail="立项配置草稿已从当前发布版本一键回填",
operator_id=current_user.id,
operator_role=current_user.role,
)
operator_name = current_user.full_name or current_user.username or current_user.email
published_by_name = None
if record.published_by:
user = await user_crud.get_by_id(db, record.published_by)
if user:
published_by_name = user.full_name or user.username or user.email
versions = await study_setup_config_crud.list_versions(db, study_id)
return _to_setup_config_read(
record,
saved_by_name=operator_name,
published_by_name=published_by_name,
current_published_version_label=_resolve_current_published_snapshot_label(versions, record),
active_branch_base_version_label=_resolve_active_branch_base_snapshot_label(versions, record),
)
@router.post(
"/{study_id}/setup-config/merge-main",
response_model=StudySetupConfigRead,
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
)
async def merge_study_setup_config_to_main(
study_id: uuid.UUID,
payload: StudySetupConfigMergeToMain,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> StudySetupConfigRead:
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
versions_before = await study_setup_config_crud.list_versions(db, study_id)
source_label_map = {item.version: (item.version_label or f"v{item.display_version}") for item in versions_before}
source_label = source_label_map.get(payload.source_version, f"v{payload.source_version}")
merged, conflict, source_missing = await study_setup_config_crud.merge_to_main(
db,
study_id,
expected_version=payload.expected_version,
source_version=payload.source_version,
merged_by=current_user.id,
auto_commit=False,
)
if conflict:
await db.rollback()
_raise_conflict_error()
if source_missing:
await db.rollback()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标版本不存在")
if not merged:
await db.rollback()
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="合并主分支失败")
try:
projection = await apply_setup_projection_on_publish(
db,
study_id=study_id,
setup_data=StudySetupConfigData.model_validate(merged.published_config or {}),
operator_id=current_user.id,
)
projection_summary = SetupProjectionSummary(
study_updated=projection.study_updated,
site_updated_count=projection.site_updated_count,
site_skipped_count=projection.site_skipped_count,
warnings=projection.warnings,
skipped_items=[{"site_id": item.site_id, "reason": item.reason} for item in projection.skipped_items],
)
study_after_publish = await study_crud.get(db, study_id)
if study_after_publish:
project_snapshot = _build_project_publish_snapshot(study_after_publish).model_dump(mode="json")
merged.published_project_snapshot = project_snapshot
await study_setup_config_crud.set_version_project_snapshot(
db,
study_id,
version=merged.version,
project_snapshot=project_snapshot,
)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="study_setup_config",
entity_id=merged.id,
action="MERGE_SETUP_CONFIG_TO_MAIN",
detail=f"已将发布版本 {source_label} 合并到主分支并生成新主版本",
operator_id=current_user.id,
operator_role=current_user.role,
auto_commit=False,
)
merged.config = study_setup_config_crud.empty_draft_payload()
merged.active_branch_base_version_id = None
merged.saved_by = current_user.id
merged.updated_at = datetime.utcnow()
await db.commit()
except Exception:
await db.rollback()
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="合并主分支失败:联动同步异常")
operator_name = current_user.full_name or current_user.username or current_user.email
await db.refresh(merged)
versions = await study_setup_config_crud.list_versions(db, study_id)
return _to_setup_config_read(
merged,
saved_by_name=operator_name,
published_by_name=operator_name,
current_published_version_label=_resolve_current_published_snapshot_label(versions, merged),
active_branch_base_version_label=_resolve_active_branch_base_snapshot_label(versions, merged),
projection_status=projection.status,
projection_summary=projection_summary,
)
@router.delete( @router.delete(
@@ -1034,9 +1464,12 @@ async def delete_study_setup_config_version(
versions = await study_setup_config_crud.list_versions(db, study_id) versions = await study_setup_config_crud.list_versions(db, study_id)
if not versions: if not versions:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标版本不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标版本不存在")
latest_version = max(item.version for item in versions) current_setup = await study_setup_config_crud.get_by_study(db, study_id)
if target_version == latest_version: current_published_version = _resolve_current_published_snapshot_version(versions, current_setup)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="最新发布版本不能删除") if current_published_version is None:
current_published_version = max(item.version for item in versions)
if target_version == current_published_version:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前发布版本不能删除")
deleted = await study_setup_config_crud.delete_version( deleted = await study_setup_config_crud.delete_version(
db, db,
+390 -31
View File
@@ -2,7 +2,7 @@ import uuid
from copy import deepcopy 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, update as sa_update
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -10,6 +10,8 @@ from app.models.study_setup_config import StudySetupConfig
from app.models.study_setup_config_version import StudySetupConfigVersion from app.models.study_setup_config_version import StudySetupConfigVersion
from app.schemas.study_setup_config import StudySetupConfigData from app.schemas.study_setup_config import StudySetupConfigData
MAIN_BRANCH = "main"
def _to_storage(data: StudySetupConfigData | dict) -> dict: def _to_storage(data: StudySetupConfigData | dict) -> dict:
if isinstance(data, StudySetupConfigData): if isinstance(data, StudySetupConfigData):
@@ -17,6 +19,130 @@ def _to_storage(data: StudySetupConfigData | dict) -> dict:
return deepcopy(data) return deepcopy(data)
def empty_draft_payload() -> dict:
return StudySetupConfigData().model_dump(mode="json")
def _build_release_branch_name(base_display_version: int) -> str:
return f"release/v{base_display_version}"
def _parse_release_branch_base_display_version(branch_name: str) -> int | None:
prefix = "release/v"
if not branch_name.startswith(prefix):
return None
suffix = branch_name[len(prefix) :]
if not suffix.isdigit():
return None
return int(suffix)
def _build_version_label(branch_name: str, *, display_version: int, branch_seq: int) -> str:
if branch_name == MAIN_BRANCH:
return f"v{display_version}"
return f"v{display_version}.{branch_seq}"
async def _get_snapshot_by_version(
db: AsyncSession,
*,
study_id: uuid.UUID,
version: int,
) -> StudySetupConfigVersion | None:
result = await db.execute(
select(StudySetupConfigVersion).where(
StudySetupConfigVersion.study_id == study_id,
StudySetupConfigVersion.version == version,
)
)
return result.scalar_one_or_none()
async def _get_snapshot_by_id(
db: AsyncSession,
*,
snapshot_id: uuid.UUID,
) -> StudySetupConfigVersion | None:
result = await db.execute(select(StudySetupConfigVersion).where(StudySetupConfigVersion.id == snapshot_id))
return result.scalar_one_or_none()
async def _get_latest_snapshot_in_branch(
db: AsyncSession,
*,
study_id: uuid.UUID,
branch_name: str,
) -> StudySetupConfigVersion | None:
result = await db.execute(
select(StudySetupConfigVersion)
.where(
StudySetupConfigVersion.study_id == study_id,
StudySetupConfigVersion.branch_name == branch_name,
)
.order_by(
StudySetupConfigVersion.branch_seq.desc(),
StudySetupConfigVersion.version.desc(),
StudySetupConfigVersion.published_at.desc(),
)
.limit(1)
)
return result.scalar_one_or_none()
async def _get_main_next_display_version(db: AsyncSession, *, study_id: uuid.UUID) -> int:
result = await db.execute(
select(func.max(StudySetupConfigVersion.display_version)).where(
StudySetupConfigVersion.study_id == study_id,
StudySetupConfigVersion.branch_name == MAIN_BRANCH,
)
)
current_max = result.scalar_one_or_none() or 0
return current_max + 1
async def _get_branch_next_seq(
db: AsyncSession,
*,
study_id: uuid.UUID,
branch_name: str,
) -> int:
result = await db.execute(
select(func.max(StudySetupConfigVersion.branch_seq)).where(
StudySetupConfigVersion.study_id == study_id,
StudySetupConfigVersion.branch_name == branch_name,
)
)
current_max = result.scalar_one_or_none() or 0
return current_max + 1
async def _resolve_current_published_snapshot_id(
db: AsyncSession,
*,
study_id: uuid.UUID,
published_config: dict | None,
published_by: uuid.UUID | None,
published_at: datetime | None,
) -> uuid.UUID | None:
if not published_config:
return None
rows = await list_versions(db, study_id)
best_score = -1
best_id: uuid.UUID | None = None
for item in rows:
if (item.config or {}) != (published_config or {}):
continue
score = 1
if published_at and item.published_at == published_at:
score += 2
if published_by and item.published_by == published_by:
score += 1
if score > best_score:
best_score = score
best_id = item.id
return best_id
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:
result = await db.execute(select(StudySetupConfig).where(StudySetupConfig.study_id == study_id)) result = await db.execute(select(StudySetupConfig).where(StudySetupConfig.study_id == study_id))
return result.scalar_one_or_none() return result.scalar_one_or_none()
@@ -56,6 +182,7 @@ async def upsert(
version=1, version=1,
config=payload, config=payload,
publish_status="DRAFT", publish_status="DRAFT",
current_branch_name=MAIN_BRANCH,
saved_by=saved_by, saved_by=saved_by,
) )
db.add(record) db.add(record)
@@ -70,6 +197,7 @@ async def publish(
*, *,
expected_version: int | None, expected_version: int | None,
published_by: uuid.UUID | None, published_by: uuid.UUID | None,
force_create_snapshot: bool = False,
auto_commit: bool = True, auto_commit: bool = True,
) -> tuple[StudySetupConfig | None, bool]: ) -> tuple[StudySetupConfig | None, bool]:
existing = await get_by_study(db, study_id) existing = await get_by_study(db, study_id)
@@ -79,15 +207,15 @@ async def publish(
return None, True return None, True
publish_payload = _to_storage(existing.config or {}) publish_payload = _to_storage(existing.config or {})
next_version = existing.version + 1 next_version = existing.version + 1
latest_snapshot_stmt = ( branch_name = (existing.current_branch_name or MAIN_BRANCH).strip() or MAIN_BRANCH
select(StudySetupConfigVersion) current_published_snapshot: StudySetupConfigVersion | None = None
.where(StudySetupConfigVersion.study_id == study_id) current_published_branch = ""
.order_by(StudySetupConfigVersion.version.desc(), StudySetupConfigVersion.published_at.desc()) if existing.current_published_version_id:
.limit(1) current_published_snapshot = await _get_snapshot_by_id(db, snapshot_id=existing.current_published_version_id)
) if current_published_snapshot:
latest_snapshot_result = await db.execute(latest_snapshot_stmt) current_published_branch = (current_published_snapshot.branch_name or MAIN_BRANCH).strip() or MAIN_BRANCH
latest_snapshot = latest_snapshot_result.scalar_one_or_none() branch_changed = bool(current_published_branch and current_published_branch != branch_name)
should_create_snapshot = not latest_snapshot or (latest_snapshot.config or {}) != publish_payload should_create_snapshot = force_create_snapshot or (existing.published_config or {}) != publish_payload or branch_changed
existing.version = next_version existing.version = next_version
existing.publish_status = "PUBLISHED" existing.publish_status = "PUBLISHED"
@@ -98,23 +226,58 @@ async def publish(
existing.updated_at = datetime.utcnow() existing.updated_at = datetime.utcnow()
if should_create_snapshot: if should_create_snapshot:
next_display_version_stmt = select(func.max(StudySetupConfigVersion.display_version)).where( parent_snapshot_id = existing.active_branch_base_version_id
StudySetupConfigVersion.study_id == study_id if parent_snapshot_id is None:
) latest_branch_snapshot = await _get_latest_snapshot_in_branch(db, study_id=study_id, branch_name=branch_name)
display_version_result = await db.execute(next_display_version_stmt) parent_snapshot_id = latest_branch_snapshot.id if latest_branch_snapshot else None
current_max_display_version = display_version_result.scalar_one_or_none() or 0
next_display_version = current_max_display_version + 1 if branch_name == MAIN_BRANCH:
next_display_version = await _get_main_next_display_version(db, study_id=study_id)
next_branch_seq = await _get_branch_next_seq(db, study_id=study_id, branch_name=branch_name)
else:
next_branch_seq = await _get_branch_next_seq(db, study_id=study_id, branch_name=branch_name)
branch_base = _parse_release_branch_base_display_version(branch_name)
if branch_base is None:
# Fall back to parent/main latest when branch name is customized.
if parent_snapshot_id:
parent_result = await db.execute(
select(StudySetupConfigVersion.display_version).where(StudySetupConfigVersion.id == parent_snapshot_id)
)
branch_base = parent_result.scalar_one_or_none()
if branch_base is None:
branch_base = await _get_main_next_display_version(db, study_id=study_id) - 1
next_display_version = branch_base
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,
branch_name=branch_name,
branch_seq=next_branch_seq,
version_label=_build_version_label(
branch_name,
display_version=next_display_version,
branch_seq=next_branch_seq,
),
source_version=next_version - 1, source_version=next_version - 1,
parent_version_id=parent_snapshot_id,
config=_to_storage(publish_payload), 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)
existing.current_published_version_id = snapshot.id
existing.active_branch_base_version_id = snapshot.id
else:
if not existing.current_published_version_id:
existing.current_published_version_id = await _resolve_current_published_snapshot_id(
db,
study_id=study_id,
published_config=existing.published_config,
published_by=existing.published_by,
published_at=existing.published_at,
)
if auto_commit: if auto_commit:
await db.commit() await db.commit()
@@ -133,6 +296,21 @@ async def list_versions(db: AsyncSession, study_id: uuid.UUID) -> list[StudySetu
return list(result.scalars().all()) return list(result.scalars().all())
async def set_version_project_snapshot(
db: AsyncSession,
study_id: uuid.UUID,
*,
version: int,
project_snapshot: dict | None,
) -> bool:
target = await _get_snapshot_by_version(db, study_id=study_id, version=version)
if not target:
return False
target.published_project_snapshot = _to_storage(project_snapshot) if project_snapshot else None
await db.flush()
return True
async def rollback_to_version( async def rollback_to_version(
db: AsyncSession, db: AsyncSession,
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -147,23 +325,200 @@ async def rollback_to_version(
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, False return None, True, False
result = await db.execute( target = await _get_snapshot_by_version(db, study_id=study_id, version=target_version)
select(StudySetupConfigVersion).where(
StudySetupConfigVersion.study_id == study_id,
StudySetupConfigVersion.version == target_version,
)
)
target = result.scalar_one_or_none()
if not target: if not target:
return None, False, True return None, False, True
target_display_version = target.display_version or 0
target_branch = (target.branch_name or MAIN_BRANCH).strip() or MAIN_BRANCH
if target_branch == MAIN_BRANCH:
branch_name = _build_release_branch_name(target_display_version)
else:
branch_name = target_branch
now = datetime.utcnow()
existing.version = existing.version + 1
target_config = _to_storage(target.config or {})
existing.config = target_config
existing.published_config = _to_storage(target.config or {})
existing.published_project_snapshot = (
_to_storage(target.published_project_snapshot) if target.published_project_snapshot else None
)
existing.current_branch_name = branch_name
existing.active_branch_base_version_id = target.id
existing.current_published_version_id = target.id
existing.saved_by = saved_by
existing.published_by = saved_by
existing.published_at = now
existing.publish_status = "PUBLISHED"
existing.updated_at = now
await db.commit()
await db.refresh(existing)
return existing, False, False
async def checkout_branch_draft_from_version(
db: AsyncSession,
study_id: uuid.UUID,
*,
expected_version: int | None,
target_version: int,
saved_by: uuid.UUID | None,
) -> tuple[StudySetupConfig | None, bool, bool]:
existing = await get_by_study(db, study_id)
if not existing:
return None, False, False
if expected_version is not None and expected_version != existing.version:
return None, True, False
target = await _get_snapshot_by_version(db, study_id=study_id, version=target_version)
if not target:
return None, False, True
target_display_version = target.display_version or 0
target_branch = (target.branch_name or MAIN_BRANCH).strip() or MAIN_BRANCH
if target_branch == MAIN_BRANCH:
checkout_branch = _build_release_branch_name(target_display_version)
else:
checkout_branch = target_branch
now = datetime.utcnow()
existing.version = existing.version + 1 existing.version = existing.version + 1
existing.config = _to_storage(target.config or {}) existing.config = _to_storage(target.config or {})
existing.current_branch_name = checkout_branch
existing.active_branch_base_version_id = target.id
existing.saved_by = saved_by existing.saved_by = saved_by
existing.publish_status = "PUBLISHED" if (existing.published_config or {}) == (existing.config or {}) else "DRAFT"
existing.updated_at = now
await db.commit()
await db.refresh(existing)
return existing, False, False
async def clear_draft(
db: AsyncSession,
study_id: uuid.UUID,
*,
expected_version: int | None,
saved_by: uuid.UUID | None,
) -> tuple[StudySetupConfig | None, bool]:
existing = await get_by_study(db, study_id)
if not existing:
return None, False
if expected_version is not None and expected_version != existing.version:
return None, True
existing.version = existing.version + 1
existing.config = empty_draft_payload()
existing.saved_by = saved_by
existing.active_branch_base_version_id = None
existing.publish_status = "DRAFT" existing.publish_status = "DRAFT"
existing.updated_at = datetime.utcnow() existing.updated_at = datetime.utcnow()
await db.commit() await db.commit()
await db.refresh(existing) await db.refresh(existing)
return existing, False
async def refill_draft_from_published(
db: AsyncSession,
study_id: uuid.UUID,
*,
expected_version: int | None,
saved_by: uuid.UUID | None,
) -> tuple[StudySetupConfig | None, bool, bool]:
existing = await get_by_study(db, study_id)
if not existing:
return None, False, False
if expected_version is not None and expected_version != existing.version:
return None, True, False
if not existing.published_config:
return None, False, True
existing.version = existing.version + 1
existing.config = _to_storage(existing.published_config or {})
existing.saved_by = saved_by
if existing.current_published_version_id:
snapshot = await _get_snapshot_by_id(db, snapshot_id=existing.current_published_version_id)
if snapshot:
if not (existing.current_branch_name or "").strip():
existing.current_branch_name = snapshot.branch_name or MAIN_BRANCH
if existing.active_branch_base_version_id is None:
existing.active_branch_base_version_id = snapshot.id
existing.publish_status = "PUBLISHED" if (existing.published_config or {}) == (existing.config or {}) else "DRAFT"
existing.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(existing)
return existing, False, False
async def merge_to_main(
db: AsyncSession,
study_id: uuid.UUID,
*,
expected_version: int | None,
source_version: int,
merged_by: uuid.UUID | None,
auto_commit: bool = True,
) -> tuple[StudySetupConfig | None, bool, bool]:
existing = await get_by_study(db, study_id)
if not existing:
return None, False, False
if expected_version is not None and expected_version != existing.version:
return None, True, False
source = await _get_snapshot_by_version(db, study_id=study_id, version=source_version)
if not source:
return None, False, True
payload = _to_storage(source.config or {})
next_version = existing.version + 1
now = datetime.utcnow()
latest_main_snapshot = await _get_latest_snapshot_in_branch(db, study_id=study_id, branch_name=MAIN_BRANCH)
parent_snapshot_id = latest_main_snapshot.id if latest_main_snapshot else None
next_main_display_version = await _get_main_next_display_version(db, study_id=study_id)
next_main_branch_seq = await _get_branch_next_seq(db, study_id=study_id, branch_name=MAIN_BRANCH)
snapshot = StudySetupConfigVersion(
study_setup_config_id=existing.id,
study_id=study_id,
version=next_version,
display_version=next_main_display_version,
branch_name=MAIN_BRANCH,
branch_seq=next_main_branch_seq,
version_label=_build_version_label(
MAIN_BRANCH,
display_version=next_main_display_version,
branch_seq=next_main_branch_seq,
),
source_version=source.version,
parent_version_id=parent_snapshot_id,
merged_from_version_id=source.id,
config=payload,
published_project_snapshot=_to_storage(source.published_project_snapshot) if source.published_project_snapshot else None,
published_by=merged_by,
published_at=now,
)
db.add(snapshot)
existing.version = next_version
existing.config = payload
existing.publish_status = "PUBLISHED"
existing.published_config = _to_storage(payload)
existing.published_project_snapshot = (
_to_storage(source.published_project_snapshot) if source.published_project_snapshot else None
)
existing.published_by = merged_by
existing.published_at = now
existing.saved_by = merged_by
existing.current_branch_name = MAIN_BRANCH
existing.active_branch_base_version_id = snapshot.id
existing.current_published_version_id = snapshot.id
existing.updated_at = now
if auto_commit:
await db.commit()
await db.refresh(existing)
else:
await db.flush()
return existing, False, False return existing, False, False
@@ -173,21 +528,25 @@ async def delete_version(
*, *,
target_version: int, target_version: int,
) -> bool: ) -> bool:
result = await db.execute( target = await _get_snapshot_by_version(db, study_id=study_id, version=target_version)
select(StudySetupConfigVersion).where(
StudySetupConfigVersion.study_id == study_id,
StudySetupConfigVersion.version == target_version,
)
)
target = result.scalar_one_or_none()
if not target: if not target:
return False return False
await db.execute(
sa_update(StudySetupConfig)
.where(StudySetupConfig.study_id == study_id, StudySetupConfig.active_branch_base_version_id == target.id)
.values(active_branch_base_version_id=None)
)
await db.execute(
sa_update(StudySetupConfig)
.where(StudySetupConfig.study_id == study_id, StudySetupConfig.current_published_version_id == target.id)
.values(current_published_version_id=None)
)
await db.delete(target) await db.delete(target)
await db.commit() await db.commit()
return True return True
async def delete_by_study(db: AsyncSession, study_id: uuid.UUID) -> None: async def delete_by_study(db: AsyncSession, study_id: uuid.UUID) -> None:
await db.execute(sa_delete(StudySetupConfigVersion).where(StudySetupConfigVersion.study_id == study_id))
await db.execute(sa_delete(StudySetupConfig).where(StudySetupConfig.study_id == study_id)) await db.execute(sa_delete(StudySetupConfig).where(StudySetupConfig.study_id == study_id))
await db.execute(sa_delete(StudySetupConfigVersion).where(StudySetupConfigVersion.study_id == study_id))
await db.commit() await db.commit()
+7
View File
@@ -19,6 +19,13 @@ class StudySetupConfig(Base):
publish_status: Mapped[str] = mapped_column(default="DRAFT", nullable=False) publish_status: Mapped[str] = mapped_column(default="DRAFT", nullable=False)
published_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True) published_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
published_project_snapshot: Mapped[dict | None] = mapped_column(JSONB, nullable=True) published_project_snapshot: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
current_branch_name: Mapped[str] = mapped_column(nullable=False, default="main")
active_branch_base_version_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("study_setup_config_versions.id", ondelete="SET NULL"), nullable=True
)
current_published_version_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("study_setup_config_versions.id", ondelete="SET NULL"), nullable=True
)
published_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) published_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
saved_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) saved_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
@@ -12,7 +12,7 @@ class StudySetupConfigVersion(Base):
__tablename__ = "study_setup_config_versions" __tablename__ = "study_setup_config_versions"
__table_args__ = ( __table_args__ = (
UniqueConstraint("study_id", "version", name="uq_setup_config_versions_study_version"), UniqueConstraint("study_id", "version", name="uq_setup_config_versions_study_version"),
UniqueConstraint("study_id", "display_version", name="uq_setup_config_versions_study_display_version"), UniqueConstraint("study_id", "branch_name", "branch_seq", name="uq_setup_config_versions_study_branch_seq"),
) )
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
@@ -22,8 +22,18 @@ class StudySetupConfigVersion(Base):
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=False, index=True) study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=False, index=True)
version: Mapped[int] = mapped_column(Integer, nullable=False) version: Mapped[int] = mapped_column(Integer, nullable=False)
display_version: Mapped[int] = mapped_column(Integer, nullable=False) display_version: Mapped[int] = mapped_column(Integer, nullable=False)
branch_name: Mapped[str] = mapped_column(nullable=False, default="main")
branch_seq: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
version_label: Mapped[str] = mapped_column(nullable=False, default="")
source_version: Mapped[int | None] = mapped_column(Integer, nullable=True) source_version: Mapped[int | None] = mapped_column(Integer, nullable=True)
parent_version_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("study_setup_config_versions.id", ondelete="SET NULL"), nullable=True
)
merged_from_version_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("study_setup_config_versions.id", ondelete="SET NULL"), nullable=True
)
config: Mapped[dict] = mapped_column(JSONB, nullable=False) config: Mapped[dict] = mapped_column(JSONB, nullable=False)
published_project_snapshot: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
published_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) published_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
published_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) published_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+27
View File
@@ -92,14 +92,36 @@ class StudySetupConfigRollback(BaseModel):
target_version: int target_version: int
class StudySetupConfigCheckoutBranch(BaseModel):
expected_version: int | None = None
target_version: int
class StudySetupConfigDraftAction(BaseModel):
expected_version: int | None = None
class StudySetupConfigMergeToMain(BaseModel):
expected_version: int | None = None
source_version: int
class StudySetupConfigVersionRead(BaseModel): class StudySetupConfigVersionRead(BaseModel):
id: uuid.UUID id: uuid.UUID
study_id: uuid.UUID study_id: uuid.UUID
study_setup_config_id: uuid.UUID study_setup_config_id: uuid.UUID
version: int version: int
display_version: int display_version: int
branch_name: str = "main"
branch_seq: int = 1
version_label: str = ""
source_version: int | None = None source_version: int | None = None
parent_version_id: uuid.UUID | None = None
merged_from_version_id: uuid.UUID | None = None
config: StudySetupConfigData config: StudySetupConfigData
published_project_snapshot: "ProjectPublishSnapshot | None" = None
is_current_published: bool = False
is_active_draft_base: bool = False
published_by: uuid.UUID | None = None published_by: uuid.UUID | None = None
published_by_name: str | None = None published_by_name: str | None = None
published_at: datetime published_at: datetime
@@ -153,6 +175,11 @@ class StudySetupConfigRead(BaseModel):
id: uuid.UUID id: uuid.UUID
study_id: uuid.UUID study_id: uuid.UUID
version: int version: int
current_branch_name: str = "main"
current_published_version_id: uuid.UUID | None = None
current_published_version_label: str | None = None
active_branch_base_version_id: uuid.UUID | None = None
active_branch_base_version_label: str | None = None
data: StudySetupConfigData data: StudySetupConfigData
publish_status: str publish_status: str
published_data: StudySetupConfigData | None = None published_data: StudySetupConfigData | None = None
+1 -1
View File
@@ -370,7 +370,7 @@ def main() -> int:
) )
assert_or_exit(status == 200, f"回滚失败 status={status} body={rolled}") assert_or_exit(status == 200, f"回滚失败 status={status} body={rolled}")
assert_or_exit( assert_or_exit(
rolled.get("publish_status") == "DRAFT" rolled.get("publish_status") == "PUBLISHED"
and (rolled.get("data") or {}).get("enrollmentPlan", {}).get("totalTarget") == payload["data"]["enrollmentPlan"]["totalTarget"], and (rolled.get("data") or {}).get("enrollmentPlan", {}).get("totalTarget") == payload["data"]["enrollmentPlan"]["totalTarget"],
f"回滚结果异常 body={rolled}", f"回滚结果异常 body={rolled}",
) )
+2
View File
@@ -53,6 +53,7 @@ services:
context: ./frontend context: ./frontend
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: ctms_frontend container_name: ctms_frontend
restart: always
# 开启 Host 模式以支持 Vite 热更新 # 开启 Host 模式以支持 Vite 热更新
command: npm run dev -- --host command: npm run dev -- --host
volumes: volumes:
@@ -63,6 +64,7 @@ services:
- "5173:5173" - "5173:5173"
environment: environment:
VITE_API_BASE_URL: / VITE_API_BASE_URL: /
NODE_OPTIONS: --max-old-space-size=4096
depends_on: depends_on:
- backend - backend
networks: networks:
+17
View File
@@ -1,7 +1,10 @@
import { apiGet, apiPatch, apiPost, apiDelete, apiPut } from "./axios"; import { apiGet, apiPatch, apiPost, apiDelete, apiPut } from "./axios";
import type { ApiListResponse, Study } from "../types/api"; import type { ApiListResponse, Study } from "../types/api";
import type { import type {
StudySetupConfigCheckoutBranchPayload,
StudySetupConfigDraftActionPayload,
StudySetupConfigPublishPayload, StudySetupConfigPublishPayload,
StudySetupConfigMergeMainPayload,
StudySetupConfigResponse, StudySetupConfigResponse,
StudySetupConfigRollbackPayload, StudySetupConfigRollbackPayload,
StudySetupConfigUpsertPayload, StudySetupConfigUpsertPayload,
@@ -42,3 +45,17 @@ export const deleteStudySetupConfigVersion = (studyId: string, targetVersion: nu
export const rollbackStudySetupConfig = (studyId: string, payload: StudySetupConfigRollbackPayload) => export const rollbackStudySetupConfig = (studyId: string, payload: StudySetupConfigRollbackPayload) =>
apiPost<StudySetupConfigResponse>(`/api/v1/studies/${studyId}/setup-config/rollback`, payload, { suppressErrorMessage: true }); apiPost<StudySetupConfigResponse>(`/api/v1/studies/${studyId}/setup-config/rollback`, payload, { suppressErrorMessage: true });
export const checkoutStudySetupConfigBranchDraft = (studyId: string, payload: StudySetupConfigCheckoutBranchPayload) =>
apiPost<StudySetupConfigResponse>(`/api/v1/studies/${studyId}/setup-config/draft/checkout-branch`, payload, {
suppressErrorMessage: true,
});
export const clearStudySetupConfigDraft = (studyId: string, payload: StudySetupConfigDraftActionPayload) =>
apiPost<StudySetupConfigResponse>(`/api/v1/studies/${studyId}/setup-config/draft/clear`, payload, { suppressErrorMessage: true });
export const refillStudySetupConfigDraft = (studyId: string, payload: StudySetupConfigDraftActionPayload) =>
apiPost<StudySetupConfigResponse>(`/api/v1/studies/${studyId}/setup-config/draft/refill`, payload, { suppressErrorMessage: true });
export const mergeStudySetupConfigToMain = (studyId: string, payload: StudySetupConfigMergeMainPayload) =>
apiPost<StudySetupConfigResponse>(`/api/v1/studies/${studyId}/setup-config/merge-main`, payload, { suppressErrorMessage: true });
@@ -1,12 +1,19 @@
import { import {
checkoutStudySetupConfigBranchDraft,
clearStudySetupConfigDraft,
deleteStudySetupConfigVersion, deleteStudySetupConfigVersion,
fetchStudySetupConfig, fetchStudySetupConfig,
fetchStudySetupConfigVersions, fetchStudySetupConfigVersions,
mergeStudySetupConfigToMain,
publishStudySetupConfig, publishStudySetupConfig,
refillStudySetupConfigDraft,
rollbackStudySetupConfig, rollbackStudySetupConfig,
saveStudySetupConfig, saveStudySetupConfig,
} from "../api/studies"; } from "../api/studies";
import type { import type {
StudySetupConfigCheckoutBranchPayload,
StudySetupConfigDraftActionPayload,
StudySetupConfigMergeMainPayload,
StudySetupConfigPublishPayload, StudySetupConfigPublishPayload,
StudySetupConfigRollbackPayload, StudySetupConfigRollbackPayload,
StudySetupConfigUpsertPayload, StudySetupConfigUpsertPayload,
@@ -16,16 +23,25 @@ export const useSetupConfig = () => {
const getConfig = (studyId: string) => fetchStudySetupConfig(studyId); const getConfig = (studyId: string) => fetchStudySetupConfig(studyId);
const saveConfig = (studyId: string, payload: StudySetupConfigUpsertPayload) => saveStudySetupConfig(studyId, payload); const saveConfig = (studyId: string, payload: StudySetupConfigUpsertPayload) => saveStudySetupConfig(studyId, payload);
const publishConfig = (studyId: string, payload: StudySetupConfigPublishPayload) => publishStudySetupConfig(studyId, payload); const publishConfig = (studyId: string, payload: StudySetupConfigPublishPayload) => publishStudySetupConfig(studyId, payload);
const mergeToMain = (studyId: string, payload: StudySetupConfigMergeMainPayload) => mergeStudySetupConfigToMain(studyId, payload);
const listVersions = (studyId: string) => fetchStudySetupConfigVersions(studyId); const listVersions = (studyId: string) => fetchStudySetupConfigVersions(studyId);
const rollbackConfig = (studyId: string, payload: StudySetupConfigRollbackPayload) => rollbackStudySetupConfig(studyId, payload); const rollbackConfig = (studyId: string, payload: StudySetupConfigRollbackPayload) => rollbackStudySetupConfig(studyId, payload);
const checkoutBranchDraft = (studyId: string, payload: StudySetupConfigCheckoutBranchPayload) =>
checkoutStudySetupConfigBranchDraft(studyId, payload);
const clearDraft = (studyId: string, payload: StudySetupConfigDraftActionPayload) => clearStudySetupConfigDraft(studyId, payload);
const refillDraft = (studyId: string, payload: StudySetupConfigDraftActionPayload) => refillStudySetupConfigDraft(studyId, payload);
const deleteVersion = (studyId: string, targetVersion: number) => deleteStudySetupConfigVersion(studyId, targetVersion); const deleteVersion = (studyId: string, targetVersion: number) => deleteStudySetupConfigVersion(studyId, targetVersion);
return { return {
getConfig, getConfig,
saveConfig, saveConfig,
publishConfig, publishConfig,
mergeToMain,
listVersions, listVersions,
rollbackConfig, rollbackConfig,
checkoutBranchDraft,
clearDraft,
refillDraft,
deleteVersion, deleteVersion,
}; };
}; };
+27
View File
@@ -98,6 +98,11 @@ export interface StudySetupConfigResponse {
id: string; id: string;
study_id: string; study_id: string;
version: number; version: number;
current_branch_name?: string;
current_published_version_id?: string | null;
current_published_version_label?: string | null;
active_branch_base_version_id?: string | null;
active_branch_base_version_label?: string | null;
data: SetupConfigDraft; data: SetupConfigDraft;
publish_status: "DRAFT" | "PUBLISHED" | string; publish_status: "DRAFT" | "PUBLISHED" | string;
published_data?: SetupConfigDraft | null; published_data?: SetupConfigDraft | null;
@@ -134,14 +139,36 @@ export interface StudySetupConfigRollbackPayload {
target_version: number; target_version: number;
} }
export interface StudySetupConfigCheckoutBranchPayload {
expected_version?: number | null;
target_version: number;
}
export interface StudySetupConfigMergeMainPayload {
expected_version?: number | null;
source_version: number;
}
export interface StudySetupConfigDraftActionPayload {
expected_version?: number | null;
}
export interface StudySetupConfigVersionItem { export interface StudySetupConfigVersionItem {
id: string; id: string;
study_id: string; study_id: string;
study_setup_config_id: string; study_setup_config_id: string;
version: number; version: number;
display_version: number; display_version: number;
branch_name?: string;
branch_seq?: number;
version_label?: string;
source_version?: number | null; source_version?: number | null;
parent_version_id?: string | null;
merged_from_version_id?: string | null;
config: SetupConfigDraft; config: SetupConfigDraft;
published_project_snapshot?: ProjectPublishSnapshot | null;
is_current_published?: boolean;
is_active_draft_base?: boolean;
published_by?: string | null; published_by?: string | null;
published_by_name?: string | null; published_by_name?: string | null;
published_at: string; published_at: string;
+73 -5
View File
@@ -107,6 +107,38 @@ const getArrayRowIdentity = (moduleKey: keyof SetupConfigDraft, value: unknown):
return ""; return "";
}; };
type ArrayRowEntry = {
key: string;
value: unknown;
rowIdentity: string;
};
const buildArrayRowEntry = (moduleKey: keyof SetupConfigDraft, value: unknown, index: number): ArrayRowEntry => {
const rowIdentity = getArrayRowIdentity(moduleKey, value);
if (value && typeof value === "object" && !Array.isArray(value)) {
const id = String((value as Record<string, unknown>).id || "").trim();
if (id) {
return {
key: `id:${id}`,
value,
rowIdentity,
};
}
}
if (rowIdentity) {
return {
key: `identity:${rowIdentity}`,
value,
rowIdentity,
};
}
return {
key: `index:${index}`,
value,
rowIdentity: "",
};
};
const formatReadablePath = (moduleKey: keyof SetupConfigDraft, path: string): string => { const formatReadablePath = (moduleKey: keyof SetupConfigDraft, path: string): string => {
let normalized = path; let normalized = path;
if (path === moduleKey) { if (path === moduleKey) {
@@ -247,11 +279,47 @@ const collectDiffRows = (
if (Array.isArray(localValue) || Array.isArray(serverValue)) { if (Array.isArray(localValue) || Array.isArray(serverValue)) {
const localArr = Array.isArray(localValue) ? localValue : []; const localArr = Array.isArray(localValue) ? localValue : [];
const serverArr = Array.isArray(serverValue) ? serverValue : []; const serverArr = Array.isArray(serverValue) ? serverValue : [];
const maxLen = Math.max(localArr.length, serverArr.length);
for (let i = 0; i < maxLen; i += 1) { const localEntryMap = new Map<string, ArrayRowEntry[]>();
const rowIdentity = getArrayRowIdentity(moduleKey, localArr[i] ?? serverArr[i]); localArr.forEach((item, index) => {
const indexLabel = rowIdentity ? `${i}|${rowIdentity}` : `${i}`; const entry = buildArrayRowEntry(moduleKey, item, index);
collectDiffRows(moduleKey, moduleLabel, localArr[i], serverArr[i], `${path}[${indexLabel}]`, rows); const list = localEntryMap.get(entry.key);
if (list) {
list.push(entry);
} else {
localEntryMap.set(entry.key, [entry]);
}
});
const consumeLocalEntry = (key: string): ArrayRowEntry | null => {
const list = localEntryMap.get(key);
if (!list || list.length === 0) return null;
const entry = list.shift() || null;
if (list.length === 0) localEntryMap.delete(key);
return entry;
};
let pairIndex = 0;
serverArr.forEach((item, index) => {
const serverEntry = buildArrayRowEntry(moduleKey, item, index);
const localEntry = consumeLocalEntry(serverEntry.key);
const rowIdentity = (localEntry?.rowIdentity || serverEntry.rowIdentity || "").trim();
const indexLabel = rowIdentity ? `${pairIndex}|${rowIdentity}` : `${pairIndex}`;
collectDiffRows(moduleKey, moduleLabel, localEntry?.value, serverEntry.value, `${path}[${indexLabel}]`, rows);
pairIndex += 1;
});
localEntryMap.forEach((list) => {
list.forEach((localEntry) => {
const rowIdentity = localEntry.rowIdentity.trim();
const indexLabel = rowIdentity ? `${pairIndex}|${rowIdentity}` : `${pairIndex}`;
collectDiffRows(moduleKey, moduleLabel, localEntry.value, undefined, `${path}[${indexLabel}]`, rows);
pairIndex += 1;
});
});
if (localArr.length === 0 && serverArr.length === 0) {
// Keep behavior explicit for empty array pair to avoid extra recursion.
return;
} }
return; return;
} }
+2 -2
View File
@@ -6,8 +6,8 @@ export type SetupWorkflowStatus = "UNSAVED_DRAFT" | "SAVED_DRAFT" | "PUBLISHED";
export type SetupWorkflowTagMeta = { label: string; type: "success" | "warning" | "info" }; export type SetupWorkflowTagMeta = { label: string; type: "success" | "warning" | "info" };
export const SETUP_WORKFLOW_TAG_META: Record<SetupWorkflowStatus, SetupWorkflowTagMeta> = { export const SETUP_WORKFLOW_TAG_META: Record<SetupWorkflowStatus, SetupWorkflowTagMeta> = {
UNSAVED_DRAFT: { label: "草稿未保存", type: "warning" }, UNSAVED_DRAFT: { label: "未保存", type: "warning" },
SAVED_DRAFT: { label: "草稿已保存(未发布)", type: "info" }, SAVED_DRAFT: { label: "已保存", type: "info" },
PUBLISHED: { label: "已发布", type: "success" }, PUBLISHED: { label: "已发布", type: "success" },
}; };
File diff suppressed because it is too large Load Diff