立项配置页初步优化
This commit is contained in:
+1
-1
@@ -43,7 +43,7 @@ pyrightconfig.json
|
||||
|
||||
# Node/Vite frontend
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
#frontend/dist/
|
||||
frontend/.vite/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""add is_sae and is_susar flags to adverse events
|
||||
|
||||
Revision ID: 20260206_01
|
||||
Revises: 20260116_08
|
||||
Create Date: 2026-02-06 10:20:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "20260206_01"
|
||||
down_revision: Union[str, None] = "20260116_08"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"adverse_events",
|
||||
sa.Column("is_sae", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
op.add_column(
|
||||
"adverse_events",
|
||||
sa.Column("is_susar", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
op.alter_column("adverse_events", "is_sae", server_default=None)
|
||||
op.alter_column("adverse_events", "is_susar", server_default=None)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("adverse_events", "is_susar")
|
||||
op.drop_column("adverse_events", "is_sae")
|
||||
@@ -0,0 +1,56 @@
|
||||
"""add study_setup_configs table
|
||||
|
||||
Revision ID: 20260206_02
|
||||
Revises: 20260206_01
|
||||
Create Date: 2026-02-06 19:10: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 = "20260206_02"
|
||||
down_revision: Union[str, None] = "20260206_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)
|
||||
|
||||
if "study_setup_configs" not in inspector.get_table_names():
|
||||
op.create_table(
|
||||
"study_setup_configs",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("study_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("config", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column("saved_by", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["saved_by"], ["users.id"]),
|
||||
sa.ForeignKeyConstraint(["study_id"], ["studies.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("study_id", name="uq_study_setup_config_study"),
|
||||
)
|
||||
|
||||
indexes = {idx["name"] for idx in inspector.get_indexes("study_setup_configs")}
|
||||
index_name = op.f("ix_study_setup_configs_study_id")
|
||||
if index_name not in indexes:
|
||||
op.create_index(index_name, "study_setup_configs", ["study_id"], unique=False)
|
||||
|
||||
uniques = {uq["name"] for uq in inspector.get_unique_constraints("study_setup_configs") if uq.get("name")}
|
||||
if "uq_study_setup_config_study" not in uniques:
|
||||
op.create_unique_constraint("uq_study_setup_config_study", "study_setup_configs", ["study_id"])
|
||||
|
||||
op.execute("ALTER TABLE study_setup_configs ALTER COLUMN version DROP DEFAULT")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_study_setup_configs_study_id"), table_name="study_setup_configs")
|
||||
op.drop_table("study_setup_configs")
|
||||
@@ -0,0 +1,66 @@
|
||||
"""add setup config publish fields
|
||||
|
||||
Revision ID: 20260206_03
|
||||
Revises: 20260206_02
|
||||
Create Date: 2026-02-06 19:40: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 = "20260206_03"
|
||||
down_revision: Union[str, None] = "20260206_02"
|
||||
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)
|
||||
columns = {c["name"] for c in inspector.get_columns("study_setup_configs")}
|
||||
|
||||
if "publish_status" not in columns:
|
||||
op.add_column(
|
||||
"study_setup_configs",
|
||||
sa.Column("publish_status", sa.String(length=20), nullable=False, server_default="DRAFT"),
|
||||
)
|
||||
op.alter_column("study_setup_configs", "publish_status", server_default=None)
|
||||
|
||||
if "published_config" not in columns:
|
||||
op.add_column(
|
||||
"study_setup_configs",
|
||||
sa.Column("published_config", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
)
|
||||
|
||||
if "published_by" not in columns:
|
||||
op.add_column(
|
||||
"study_setup_configs",
|
||||
sa.Column("published_by", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"study_setup_configs_published_by_fkey",
|
||||
"study_setup_configs",
|
||||
"users",
|
||||
["published_by"],
|
||||
["id"],
|
||||
)
|
||||
|
||||
if "published_at" not in columns:
|
||||
op.add_column(
|
||||
"study_setup_configs",
|
||||
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("study_setup_configs", "published_at")
|
||||
op.drop_constraint("study_setup_configs_published_by_fkey", "study_setup_configs", type_="foreignkey")
|
||||
op.drop_column("study_setup_configs", "published_by")
|
||||
op.drop_column("study_setup_configs", "published_config")
|
||||
op.drop_column("study_setup_configs", "publish_status")
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""add study setup config versions table
|
||||
|
||||
Revision ID: 20260211_01
|
||||
Revises: 20260206_03
|
||||
Create Date: 2026-02-11 11: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 = "20260211_01"
|
||||
down_revision: Union[str, None] = "20260206_03"
|
||||
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:
|
||||
op.create_table(
|
||||
"study_setup_config_versions",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("study_setup_config_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("study_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("version", sa.Integer(), nullable=False),
|
||||
sa.Column("source_version", sa.Integer(), nullable=True),
|
||||
sa.Column("config", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column("published_by", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("published_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.ForeignKeyConstraint(["published_by"], ["users.id"]),
|
||||
sa.ForeignKeyConstraint(["study_id"], ["studies.id"]),
|
||||
sa.ForeignKeyConstraint(["study_setup_config_id"], ["study_setup_configs.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("study_id", "version", name="uq_setup_config_versions_study_version"),
|
||||
)
|
||||
|
||||
existing_indexes = {idx["name"] for idx in inspector.get_indexes("study_setup_config_versions")}
|
||||
if "ix_study_setup_config_versions_study_setup_config_id" not in existing_indexes:
|
||||
op.create_index(
|
||||
"ix_study_setup_config_versions_study_setup_config_id",
|
||||
"study_setup_config_versions",
|
||||
["study_setup_config_id"],
|
||||
unique=False,
|
||||
)
|
||||
if "ix_study_setup_config_versions_study_id" not in existing_indexes:
|
||||
op.create_index(
|
||||
"ix_study_setup_config_versions_study_id",
|
||||
"study_setup_config_versions",
|
||||
["study_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
indexes = {idx["name"] for idx in inspector.get_indexes("study_setup_config_versions")}
|
||||
if "ix_study_setup_config_versions_study_setup_config_id" in indexes:
|
||||
op.drop_index("ix_study_setup_config_versions_study_setup_config_id", table_name="study_setup_config_versions")
|
||||
if "ix_study_setup_config_versions_study_id" in indexes:
|
||||
op.drop_index("ix_study_setup_config_versions_study_id", table_name="study_setup_config_versions")
|
||||
op.drop_table("study_setup_config_versions")
|
||||
@@ -0,0 +1,88 @@
|
||||
"""add extended profile fields to studies
|
||||
|
||||
Revision ID: 20260211_02
|
||||
Revises: 20260211_01
|
||||
Create Date: 2026-02-11 14:20:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "20260211_02"
|
||||
down_revision: Union[str, None] = "20260211_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)
|
||||
columns = {col["name"] for col in inspector.get_columns("studies")}
|
||||
|
||||
if "project_full_name" not in columns:
|
||||
op.add_column("studies", sa.Column("project_full_name", sa.String(length=500), nullable=True))
|
||||
if "lead_unit" not in columns:
|
||||
op.add_column("studies", sa.Column("lead_unit", sa.String(length=255), nullable=True))
|
||||
if "principal_investigator" not in columns:
|
||||
op.add_column("studies", sa.Column("principal_investigator", sa.String(length=255), nullable=True))
|
||||
if "main_pm" not in columns:
|
||||
op.add_column("studies", sa.Column("main_pm", sa.String(length=255), nullable=True))
|
||||
if "research_analysis" not in columns:
|
||||
op.add_column("studies", sa.Column("research_analysis", sa.Text(), nullable=True))
|
||||
if "research_product" not in columns:
|
||||
op.add_column("studies", sa.Column("research_product", sa.String(length=255), nullable=True))
|
||||
if "control_product" not in columns:
|
||||
op.add_column("studies", sa.Column("control_product", sa.String(length=255), nullable=True))
|
||||
if "indication" not in columns:
|
||||
op.add_column("studies", sa.Column("indication", sa.String(length=255), nullable=True))
|
||||
if "research_population" not in columns:
|
||||
op.add_column("studies", sa.Column("research_population", sa.String(length=255), nullable=True))
|
||||
if "research_design" not in columns:
|
||||
op.add_column("studies", sa.Column("research_design", sa.Text(), nullable=True))
|
||||
if "plan_start_date" not in columns:
|
||||
op.add_column("studies", sa.Column("plan_start_date", sa.Date(), nullable=True))
|
||||
if "plan_end_date" not in columns:
|
||||
op.add_column("studies", sa.Column("plan_end_date", sa.Date(), nullable=True))
|
||||
if "planned_site_count" not in columns:
|
||||
op.add_column("studies", sa.Column("planned_site_count", sa.Integer(), nullable=True))
|
||||
if "planned_enrollment_count" not in columns:
|
||||
op.add_column("studies", sa.Column("planned_enrollment_count", sa.Integer(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
columns = {col["name"] for col in inspector.get_columns("studies")}
|
||||
|
||||
if "planned_enrollment_count" in columns:
|
||||
op.drop_column("studies", "planned_enrollment_count")
|
||||
if "planned_site_count" in columns:
|
||||
op.drop_column("studies", "planned_site_count")
|
||||
if "plan_end_date" in columns:
|
||||
op.drop_column("studies", "plan_end_date")
|
||||
if "plan_start_date" in columns:
|
||||
op.drop_column("studies", "plan_start_date")
|
||||
if "research_design" in columns:
|
||||
op.drop_column("studies", "research_design")
|
||||
if "research_population" in columns:
|
||||
op.drop_column("studies", "research_population")
|
||||
if "indication" in columns:
|
||||
op.drop_column("studies", "indication")
|
||||
if "control_product" in columns:
|
||||
op.drop_column("studies", "control_product")
|
||||
if "research_product" in columns:
|
||||
op.drop_column("studies", "research_product")
|
||||
if "research_analysis" in columns:
|
||||
op.drop_column("studies", "research_analysis")
|
||||
if "main_pm" in columns:
|
||||
op.drop_column("studies", "main_pm")
|
||||
if "principal_investigator" in columns:
|
||||
op.drop_column("studies", "principal_investigator")
|
||||
if "lead_unit" in columns:
|
||||
op.drop_column("studies", "lead_unit")
|
||||
if "project_full_name" in columns:
|
||||
op.drop_column("studies", "project_full_name")
|
||||
@@ -0,0 +1,77 @@
|
||||
"""add display version to setup config versions
|
||||
|
||||
Revision ID: 20260213_01
|
||||
Revises: 20260211_02
|
||||
Create Date: 2026-02-13 12:10:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "20260213_01"
|
||||
down_revision: Union[str, None] = "20260211_02"
|
||||
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)
|
||||
columns = {col["name"] for col in inspector.get_columns("study_setup_config_versions")}
|
||||
|
||||
if "display_version" not in columns:
|
||||
op.add_column(
|
||||
"study_setup_config_versions",
|
||||
sa.Column("display_version", sa.Integer(), nullable=True),
|
||||
)
|
||||
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY study_id
|
||||
ORDER BY published_at ASC, version ASC
|
||||
) AS rn
|
||||
FROM study_setup_config_versions
|
||||
)
|
||||
UPDATE study_setup_config_versions v
|
||||
SET display_version = ranked.rn
|
||||
FROM ranked
|
||||
WHERE v.id = ranked.id
|
||||
AND v.display_version IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
op.alter_column("study_setup_config_versions", "display_version", nullable=False)
|
||||
|
||||
uniques = {item["name"] for item in inspector.get_unique_constraints("study_setup_config_versions")}
|
||||
if "uq_setup_config_versions_study_display_version" not in uniques:
|
||||
op.create_unique_constraint(
|
||||
"uq_setup_config_versions_study_display_version",
|
||||
"study_setup_config_versions",
|
||||
["study_id", "display_version"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
uniques = {item["name"] for item in inspector.get_unique_constraints("study_setup_config_versions")}
|
||||
if "uq_setup_config_versions_study_display_version" in uniques:
|
||||
op.drop_constraint(
|
||||
"uq_setup_config_versions_study_display_version",
|
||||
"study_setup_config_versions",
|
||||
type_="unique",
|
||||
)
|
||||
|
||||
columns = {col["name"] for col in inspector.get_columns("study_setup_config_versions")}
|
||||
if "display_version" in columns:
|
||||
op.drop_column("study_setup_config_versions", "display_version")
|
||||
@@ -0,0 +1,64 @@
|
||||
"""add setup projection fields to studies and sites
|
||||
|
||||
Revision ID: 20260213_02
|
||||
Revises: 20260213_01
|
||||
Create Date: 2026-02-13 16:30:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "20260213_02"
|
||||
down_revision: Union[str, None] = "20260213_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)
|
||||
|
||||
study_columns = {col["name"] for col in inspector.get_columns("studies")}
|
||||
if "enrollment_monthly_goal_note" not in study_columns:
|
||||
op.add_column("studies", sa.Column("enrollment_monthly_goal_note", sa.Text(), nullable=True))
|
||||
if "enrollment_stage_breakdown" not in study_columns:
|
||||
op.add_column("studies", sa.Column("enrollment_stage_breakdown", sa.Text(), nullable=True))
|
||||
if "summary_note" not in study_columns:
|
||||
op.add_column("studies", sa.Column("summary_note", sa.Text(), nullable=True))
|
||||
if "objective_note" not in study_columns:
|
||||
op.add_column("studies", sa.Column("objective_note", sa.Text(), nullable=True))
|
||||
|
||||
site_columns = {col["name"] for col in inspector.get_columns("sites")}
|
||||
if "enrollment_plan_start_date" not in site_columns:
|
||||
op.add_column("sites", sa.Column("enrollment_plan_start_date", sa.Date(), nullable=True))
|
||||
if "enrollment_plan_end_date" not in site_columns:
|
||||
op.add_column("sites", sa.Column("enrollment_plan_end_date", sa.Date(), nullable=True))
|
||||
if "enrollment_plan_note" not in site_columns:
|
||||
op.add_column("sites", sa.Column("enrollment_plan_note", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
site_columns = {col["name"] for col in inspector.get_columns("sites")}
|
||||
if "enrollment_plan_note" in site_columns:
|
||||
op.drop_column("sites", "enrollment_plan_note")
|
||||
if "enrollment_plan_end_date" in site_columns:
|
||||
op.drop_column("sites", "enrollment_plan_end_date")
|
||||
if "enrollment_plan_start_date" in site_columns:
|
||||
op.drop_column("sites", "enrollment_plan_start_date")
|
||||
|
||||
study_columns = {col["name"] for col in inspector.get_columns("studies")}
|
||||
if "objective_note" in study_columns:
|
||||
op.drop_column("studies", "objective_note")
|
||||
if "summary_note" in study_columns:
|
||||
op.drop_column("studies", "summary_note")
|
||||
if "enrollment_stage_breakdown" in study_columns:
|
||||
op.drop_column("studies", "enrollment_stage_breakdown")
|
||||
if "enrollment_monthly_goal_note" in study_columns:
|
||||
op.drop_column("studies", "enrollment_monthly_goal_note")
|
||||
@@ -0,0 +1,88 @@
|
||||
"""add setup projection entities for monitoring and center confirm
|
||||
|
||||
Revision ID: 20260213_03
|
||||
Revises: 20260213_02
|
||||
Create Date: 2026-02-13 11: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 = "20260213_03"
|
||||
down_revision: Union[str, None] = "20260213_02"
|
||||
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)
|
||||
|
||||
milestone_columns = {col["name"] for col in inspector.get_columns("milestones")}
|
||||
if "owner_name" not in milestone_columns:
|
||||
op.add_column("milestones", sa.Column("owner_name", sa.String(length=255), nullable=True))
|
||||
|
||||
if "study_monitoring_strategies" not in inspector.get_table_names():
|
||||
op.create_table(
|
||||
"study_monitoring_strategies",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("study_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("source_setup_item_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("strategy_type", sa.String(length=50), nullable=False),
|
||||
sa.Column("detail", sa.Text(), nullable=False),
|
||||
sa.Column("frequency", sa.String(length=50), nullable=False),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.ForeignKeyConstraint(["study_id"], ["studies.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_study_monitoring_strategies_study_id",
|
||||
"study_monitoring_strategies",
|
||||
["study_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
if "study_center_confirms" not in inspector.get_table_names():
|
||||
op.create_table(
|
||||
"study_center_confirms",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("study_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("site_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("source_setup_item_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("confirmer", sa.String(length=255), nullable=True),
|
||||
sa.Column("confirm_status", sa.String(length=20), nullable=False),
|
||||
sa.Column("confirm_date", sa.Date(), nullable=True),
|
||||
sa.Column("note", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.ForeignKeyConstraint(["site_id"], ["sites.id"]),
|
||||
sa.ForeignKeyConstraint(["study_id"], ["studies.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_study_center_confirms_site_id", "study_center_confirms", ["site_id"], unique=False)
|
||||
op.create_index("ix_study_center_confirms_study_id", "study_center_confirms", ["study_id"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if "study_center_confirms" in inspector.get_table_names():
|
||||
op.drop_index("ix_study_center_confirms_study_id", table_name="study_center_confirms")
|
||||
op.drop_index("ix_study_center_confirms_site_id", table_name="study_center_confirms")
|
||||
op.drop_table("study_center_confirms")
|
||||
|
||||
if "study_monitoring_strategies" in inspector.get_table_names():
|
||||
op.drop_index("ix_study_monitoring_strategies_study_id", table_name="study_monitoring_strategies")
|
||||
op.drop_table("study_monitoring_strategies")
|
||||
|
||||
milestone_columns = {col["name"] for col in inspector.get_columns("milestones")}
|
||||
if "owner_name" in milestone_columns:
|
||||
op.drop_column("milestones", "owner_name")
|
||||
@@ -1,19 +1,340 @@
|
||||
import uuid
|
||||
import re
|
||||
from io import BytesIO
|
||||
from datetime import date
|
||||
from collections import Counter
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session, require_roles, require_study_member, require_study_roles
|
||||
from app.core.deps import (
|
||||
get_current_user,
|
||||
get_db_session,
|
||||
require_roles,
|
||||
require_study_member,
|
||||
require_study_not_locked,
|
||||
require_study_roles,
|
||||
)
|
||||
from app.crud import study as study_crud
|
||||
from app.crud import member as member_crud
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import site as site_crud
|
||||
from app.crud import study_setup_config as study_setup_config_crud
|
||||
from app.crud import user as user_crud
|
||||
from app.services.setup_config_excel import export_setup_config_excel, import_setup_config_excel
|
||||
from app.schemas.common import PaginatedResponse
|
||||
from app.schemas.study import StudyCreate, StudyRead, StudyUpdate
|
||||
from app.schemas.member import StudyMemberCreate
|
||||
from app.schemas.study_setup_config import (
|
||||
SetupProjectionSummary,
|
||||
StudySetupConfigData,
|
||||
StudySetupConfigPublish,
|
||||
StudySetupConfigRead,
|
||||
StudySetupConfigRollback,
|
||||
StudySetupConfigUpsert,
|
||||
StudySetupConfigVersionRead,
|
||||
)
|
||||
from app.services.setup_config_projection import apply_setup_projection_on_publish
|
||||
from app.utils.pagination import paginate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
def _raise_validation_error(errors: list[dict[str, str]]) -> None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "校验失败",
|
||||
"errors": errors,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _raise_conflict_error() -> None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={
|
||||
"code": "SETUP_CONFIG_VERSION_CONFLICT",
|
||||
"message": "配置版本冲突,请刷新后重试",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _parse_date_str(value: str, field: str, errors: list[dict[str, str]]) -> date | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(value)
|
||||
except ValueError:
|
||||
errors.append({"field": field, "message": "日期格式应为 YYYY-MM-DD"})
|
||||
return None
|
||||
|
||||
|
||||
def _is_empty_row(values: list[str | None]) -> bool:
|
||||
return all(not (item or "").strip() for item in values)
|
||||
|
||||
|
||||
def _validate_setup_data(
|
||||
payload: StudySetupConfigData,
|
||||
study_sites: dict[str, str],
|
||||
*,
|
||||
project_plan_start: date | None = None,
|
||||
project_plan_end: date | None = None,
|
||||
) -> None:
|
||||
errors: list[dict[str, str]] = []
|
||||
allowed_milestone_status = {"未开始", "进行中", "已完成", "延期"}
|
||||
allowed_strategy_types = {
|
||||
"启动访视",
|
||||
"筛选访视",
|
||||
"监查访视",
|
||||
"协同访视",
|
||||
"风险访视",
|
||||
"末次访视",
|
||||
}
|
||||
allowed_confirm_status = {"待确认", "已确认", "退回"}
|
||||
|
||||
for index, row in enumerate(payload.projectMilestones):
|
||||
row_prefix = f"projectMilestones[{index}]"
|
||||
if not row.id:
|
||||
errors.append({"field": f"{row_prefix}.id", "message": "ID不能为空"})
|
||||
if _is_empty_row([row.name, row.owner, row.remark, row.planDate]):
|
||||
continue
|
||||
if not row.name.strip():
|
||||
errors.append({"field": f"{row_prefix}.name", "message": "里程碑名称不能为空"})
|
||||
_parse_date_str(row.planDate, f"{row_prefix}.planDate", errors)
|
||||
if row.status not in allowed_milestone_status:
|
||||
errors.append({"field": f"{row_prefix}.status", "message": "里程碑状态不合法"})
|
||||
|
||||
plan = payload.enrollmentPlan
|
||||
if plan.totalTarget < 0:
|
||||
errors.append({"field": "enrollmentPlan.totalTarget", "message": "计划总入组例数不能小于0"})
|
||||
enrollment_plan_start = _parse_date_str(plan.startDate, "enrollmentPlan.startDate", errors)
|
||||
enrollment_plan_end = _parse_date_str(plan.endDate, "enrollmentPlan.endDate", errors)
|
||||
if not plan.startDate:
|
||||
errors.append({"field": "enrollmentPlan.startDate", "message": "请填写计划开始日期"})
|
||||
if not plan.endDate:
|
||||
errors.append({"field": "enrollmentPlan.endDate", "message": "请填写计划结束日期"})
|
||||
if enrollment_plan_start and enrollment_plan_end and enrollment_plan_start > enrollment_plan_end:
|
||||
errors.append({"field": "enrollmentPlan.endDate", "message": "结束日期不能早于开始日期"})
|
||||
if project_plan_start and enrollment_plan_start and enrollment_plan_start < project_plan_start:
|
||||
errors.append({"field": "enrollmentPlan.startDate", "message": "项目入组计划开始日期不能早于项目计划开始日期"})
|
||||
if project_plan_end and enrollment_plan_start and enrollment_plan_start > project_plan_end:
|
||||
errors.append({"field": "enrollmentPlan.startDate", "message": "项目入组计划开始日期不能晚于项目计划结束日期"})
|
||||
if project_plan_start and enrollment_plan_end and enrollment_plan_end < project_plan_start:
|
||||
errors.append({"field": "enrollmentPlan.endDate", "message": "项目入组计划结束日期不能早于项目计划开始日期"})
|
||||
if project_plan_end and enrollment_plan_end and enrollment_plan_end > project_plan_end:
|
||||
errors.append({"field": "enrollmentPlan.endDate", "message": "项目入组计划结束日期不能晚于项目计划结束日期"})
|
||||
|
||||
total_site_target = 0
|
||||
for index, row in enumerate(payload.siteMilestones):
|
||||
row_prefix = f"siteMilestones[{index}]"
|
||||
if not row.id:
|
||||
errors.append({"field": f"{row_prefix}.id", "message": "ID不能为空"})
|
||||
if _is_empty_row([row.milestone, row.owner, row.remark, row.planDate]):
|
||||
continue
|
||||
if not row.milestone.strip():
|
||||
errors.append({"field": f"{row_prefix}.milestone", "message": "中心里程碑不能为空"})
|
||||
if row.status not in allowed_milestone_status:
|
||||
errors.append({"field": f"{row_prefix}.status", "message": "里程碑状态不合法"})
|
||||
plan_date = _parse_date_str(row.planDate, f"{row_prefix}.planDate", errors)
|
||||
if project_plan_start and plan_date and plan_date < project_plan_start:
|
||||
errors.append({"field": f"{row_prefix}.planDate", "message": "中心里程碑日期不能早于项目计划开始日期"})
|
||||
if project_plan_end and plan_date and plan_date > project_plan_end:
|
||||
errors.append({"field": f"{row_prefix}.planDate", "message": "中心里程碑日期不能晚于项目计划结束日期"})
|
||||
|
||||
seen_site_enrollment_site_ids: set[str] = set()
|
||||
for index, row in enumerate(payload.siteEnrollmentPlans):
|
||||
row_prefix = f"siteEnrollmentPlans[{index}]"
|
||||
if not row.id:
|
||||
errors.append({"field": f"{row_prefix}.id", "message": "ID不能为空"})
|
||||
if _is_empty_row([row.siteId, row.note, row.startDate, row.endDate]):
|
||||
continue
|
||||
site_id = (row.siteId or "").strip()
|
||||
if not site_id:
|
||||
errors.append({"field": f"{row_prefix}.siteId", "message": "请选择中心"})
|
||||
elif site_id not in study_sites:
|
||||
errors.append({"field": f"{row_prefix}.siteId", "message": "中心不属于当前项目"})
|
||||
elif site_id in seen_site_enrollment_site_ids:
|
||||
errors.append({"field": f"{row_prefix}.siteId", "message": "中心入组计划不允许重复配置同一中心"})
|
||||
else:
|
||||
seen_site_enrollment_site_ids.add(site_id)
|
||||
if row.target < 0:
|
||||
errors.append({"field": f"{row_prefix}.target", "message": "目标入组人数不能小于0"})
|
||||
total_site_target += max(row.target, 0)
|
||||
start = _parse_date_str(row.startDate, f"{row_prefix}.startDate", errors)
|
||||
end = _parse_date_str(row.endDate, f"{row_prefix}.endDate", errors)
|
||||
if not row.startDate:
|
||||
errors.append({"field": f"{row_prefix}.startDate", "message": "请填写启动日期"})
|
||||
if not row.endDate:
|
||||
errors.append({"field": f"{row_prefix}.endDate", "message": "请填写完成日期"})
|
||||
if start and end and start > end:
|
||||
errors.append({"field": f"{row_prefix}.endDate", "message": "完成日期不能早于启动日期"})
|
||||
if project_plan_start and start and start < project_plan_start:
|
||||
errors.append({"field": f"{row_prefix}.startDate", "message": "中心启动日期不能早于项目计划开始日期"})
|
||||
if project_plan_end and start and start > project_plan_end:
|
||||
errors.append({"field": f"{row_prefix}.startDate", "message": "中心启动日期不能晚于项目计划结束日期"})
|
||||
if project_plan_start and end and end < project_plan_start:
|
||||
errors.append({"field": f"{row_prefix}.endDate", "message": "中心完成日期不能早于项目计划开始日期"})
|
||||
if project_plan_end and end and end > project_plan_end:
|
||||
errors.append({"field": f"{row_prefix}.endDate", "message": "中心完成日期不能晚于项目计划结束日期"})
|
||||
|
||||
if total_site_target > plan.totalTarget:
|
||||
errors.append({"field": "siteEnrollmentPlans", "message": "中心计划总例数不能超过项目总入组例数"})
|
||||
|
||||
for index, row in enumerate(payload.monitoringStrategies):
|
||||
row_prefix = f"monitoringStrategies[{index}]"
|
||||
if not row.id:
|
||||
errors.append({"field": f"{row_prefix}.id", "message": "ID不能为空"})
|
||||
if _is_empty_row([row.strategyType, row.detail, row.frequency]):
|
||||
continue
|
||||
if row.strategyType not in allowed_strategy_types:
|
||||
errors.append({"field": f"{row_prefix}.strategyType", "message": "监查类型不合法"})
|
||||
if not row.detail.strip():
|
||||
errors.append({"field": f"{row_prefix}.detail", "message": "策略详情不能为空"})
|
||||
if row.frequency not in {"不限", "按触发", "每月1次"} and re.fullmatch(r"\d+次", row.frequency) is None:
|
||||
errors.append({"field": f"{row_prefix}.frequency", "message": "监查次数格式应为“不限”/“按触发”/“每月1次”或“N次”"})
|
||||
|
||||
for index, row in enumerate(payload.centerConfirm):
|
||||
row_prefix = f"centerConfirm[{index}]"
|
||||
if not row.id:
|
||||
errors.append({"field": f"{row_prefix}.id", "message": "ID不能为空"})
|
||||
if _is_empty_row([row.siteId, row.confirmer, row.note, row.confirmDate]):
|
||||
continue
|
||||
if not row.siteId:
|
||||
errors.append({"field": f"{row_prefix}.siteId", "message": "请选择中心"})
|
||||
elif row.siteId not in study_sites:
|
||||
errors.append({"field": f"{row_prefix}.siteId", "message": "中心不属于当前项目"})
|
||||
if row.confirmStatus not in allowed_confirm_status:
|
||||
errors.append({"field": f"{row_prefix}.confirmStatus", "message": "确认状态不合法"})
|
||||
parsed_confirm_date = _parse_date_str(row.confirmDate, f"{row_prefix}.confirmDate", errors)
|
||||
if row.confirmStatus == "已确认" and not parsed_confirm_date:
|
||||
errors.append({"field": f"{row_prefix}.confirmDate", "message": "已确认时必须填写确认日期"})
|
||||
if row.confirmStatus == "退回" and not row.note.strip():
|
||||
errors.append({"field": f"{row_prefix}.note", "message": "退回时必须填写备注"})
|
||||
if project_plan_start and parsed_confirm_date and parsed_confirm_date < project_plan_start:
|
||||
errors.append({"field": f"{row_prefix}.confirmDate", "message": "中心确认日期不能早于项目计划开始日期"})
|
||||
if project_plan_end and parsed_confirm_date and parsed_confirm_date > project_plan_end:
|
||||
errors.append({"field": f"{row_prefix}.confirmDate", "message": "中心确认日期不能晚于项目计划结束日期"})
|
||||
|
||||
if errors:
|
||||
_raise_validation_error(errors)
|
||||
|
||||
|
||||
def _build_default_setup_config_from_study(study, sites: list) -> StudySetupConfigData:
|
||||
plan_start = study.plan_start_date.isoformat() if getattr(study, "plan_start_date", None) else ""
|
||||
plan_end = study.plan_end_date.isoformat() if getattr(study, "plan_end_date", None) else ""
|
||||
total_target = getattr(study, "planned_enrollment_count", None)
|
||||
if total_target is None:
|
||||
total_target = 0
|
||||
return StudySetupConfigData(
|
||||
projectMilestones=[],
|
||||
enrollmentPlan={
|
||||
"totalTarget": total_target,
|
||||
"startDate": plan_start,
|
||||
"endDate": plan_end,
|
||||
"monthlyGoalNote": getattr(study, "enrollment_monthly_goal_note", None) or "",
|
||||
"stageBreakdown": getattr(study, "enrollment_stage_breakdown", None) or "",
|
||||
},
|
||||
siteMilestones=[],
|
||||
siteEnrollmentPlans=[],
|
||||
monitoringStrategies=[],
|
||||
centerConfirm=[],
|
||||
)
|
||||
|
||||
|
||||
def _to_setup_config_read(
|
||||
record,
|
||||
*,
|
||||
saved_by_name: str | None = None,
|
||||
published_by_name: str | None = None,
|
||||
projection_status: str | None = None,
|
||||
projection_summary: SetupProjectionSummary | None = None,
|
||||
) -> StudySetupConfigRead:
|
||||
return StudySetupConfigRead(
|
||||
id=record.id,
|
||||
study_id=record.study_id,
|
||||
version=record.version,
|
||||
data=StudySetupConfigData.model_validate(record.config or {}),
|
||||
publish_status=record.publish_status or "DRAFT",
|
||||
published_data=StudySetupConfigData.model_validate(record.published_config) if record.published_config else None,
|
||||
published_by=record.published_by,
|
||||
published_by_name=published_by_name,
|
||||
published_at=record.published_at,
|
||||
saved_by=record.saved_by,
|
||||
saved_by_name=saved_by_name,
|
||||
projection_status=projection_status,
|
||||
projection_summary=projection_summary,
|
||||
created_at=record.created_at,
|
||||
updated_at=record.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _build_projection_audit_detail(
|
||||
*,
|
||||
projection_status: str,
|
||||
study_updated: bool,
|
||||
site_updated_count: int,
|
||||
site_skipped_count: int,
|
||||
warnings: list[str],
|
||||
skipped_items: list[dict[str, str]],
|
||||
) -> str:
|
||||
warning_part = " | ".join(warnings[:5]) if warnings else "-"
|
||||
reason_counter = Counter(item.get("reason") for item in skipped_items if item.get("reason"))
|
||||
reason_summary = ", ".join(f"{reason}:{count}" for reason, count in sorted(reason_counter.items())) if reason_counter else "-"
|
||||
return (
|
||||
"立项配置已发布"
|
||||
f"; projection_status={projection_status}"
|
||||
f"; study_updated={study_updated}"
|
||||
f"; site_updated={site_updated_count}"
|
||||
f"; site_skipped={site_skipped_count}"
|
||||
f"; warnings={warning_part}"
|
||||
f"; skipped_reason_counts={reason_summary}"
|
||||
)
|
||||
|
||||
|
||||
def _ensure_study_timeline_valid(
|
||||
*,
|
||||
plan_start_date: date | None,
|
||||
plan_end_date: date | None,
|
||||
visit_window_start_offset: int | None,
|
||||
visit_window_end_offset: int | None,
|
||||
) -> None:
|
||||
if plan_start_date and plan_end_date and plan_start_date > plan_end_date:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="项目计划结束日期不能早于开始日期")
|
||||
if (
|
||||
visit_window_start_offset is not None
|
||||
and visit_window_end_offset is not None
|
||||
and visit_window_start_offset > visit_window_end_offset
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="访视窗口开始偏移不能晚于结束偏移")
|
||||
|
||||
|
||||
def _to_setup_config_version_read(record, *, published_by_name: str | None = None) -> StudySetupConfigVersionRead:
|
||||
return StudySetupConfigVersionRead(
|
||||
id=record.id,
|
||||
study_id=record.study_id,
|
||||
study_setup_config_id=record.study_setup_config_id,
|
||||
version=record.version,
|
||||
display_version=record.display_version,
|
||||
source_version=record.source_version,
|
||||
config=StudySetupConfigData.model_validate(record.config or {}),
|
||||
published_by=record.published_by,
|
||||
published_by_name=published_by_name,
|
||||
published_at=record.published_at,
|
||||
created_at=record.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _top_level_diff_summary(old: dict | None, new: dict | None) -> str:
|
||||
old = old or {}
|
||||
new = new or {}
|
||||
changed = []
|
||||
for key in ("projectMilestones", "enrollmentPlan", "siteMilestones", "siteEnrollmentPlans", "monitoringStrategies", "centerConfirm"):
|
||||
if old.get(key) != new.get(key):
|
||||
changed.append(key)
|
||||
return "变更模块:" + (", ".join(changed) if changed else "无")
|
||||
|
||||
|
||||
@router.post("/", response_model=StudyRead, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_roles(["ADMIN"]))])
|
||||
async def create_study(
|
||||
@@ -21,6 +342,15 @@ async def create_study(
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> StudyRead:
|
||||
_ensure_study_timeline_valid(
|
||||
plan_start_date=study_in.plan_start_date,
|
||||
plan_end_date=study_in.plan_end_date,
|
||||
visit_window_start_offset=study_in.visit_window_start_offset,
|
||||
visit_window_end_offset=study_in.visit_window_end_offset,
|
||||
)
|
||||
study_in.code = study_in.code.strip()
|
||||
if not study_in.code:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="项目编号不能为空")
|
||||
existing = await study_crud.get_by_code(db, study_in.code)
|
||||
if existing:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="项目编号已存在")
|
||||
@@ -34,6 +364,26 @@ async def create_study(
|
||||
)
|
||||
await member_crud.add_member(db, study.id, member_in)
|
||||
|
||||
# 初始化立项配置草稿,并回填可映射的项目信息字段
|
||||
default_setup_data = _build_default_setup_config_from_study(study, [])
|
||||
setup_record, _ = await study_setup_config_crud.upsert(
|
||||
db,
|
||||
study.id,
|
||||
expected_version=None,
|
||||
data=default_setup_data,
|
||||
saved_by=current_user.id,
|
||||
)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study.id,
|
||||
entity_type="study_setup_config",
|
||||
entity_id=setup_record.id,
|
||||
action="CREATE_SETUP_CONFIG",
|
||||
detail="初始化立项配置(创建项目时回填基础信息)",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
|
||||
return study
|
||||
|
||||
|
||||
@@ -90,6 +440,25 @@ async def update_study(
|
||||
if existing and existing.id != study.id:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="项目编号已存在")
|
||||
|
||||
next_plan_start = study_in.plan_start_date if "plan_start_date" in study_in.model_fields_set else study.plan_start_date
|
||||
next_plan_end = study_in.plan_end_date if "plan_end_date" in study_in.model_fields_set else study.plan_end_date
|
||||
next_window_start = (
|
||||
study_in.visit_window_start_offset
|
||||
if "visit_window_start_offset" in study_in.model_fields_set
|
||||
else study.visit_window_start_offset
|
||||
)
|
||||
next_window_end = (
|
||||
study_in.visit_window_end_offset
|
||||
if "visit_window_end_offset" in study_in.model_fields_set
|
||||
else study.visit_window_end_offset
|
||||
)
|
||||
_ensure_study_timeline_valid(
|
||||
plan_start_date=next_plan_start,
|
||||
plan_end_date=next_plan_end,
|
||||
visit_window_start_offset=next_window_start,
|
||||
visit_window_end_offset=next_window_end,
|
||||
)
|
||||
|
||||
updated = await study_crud.update(db, study, study_in)
|
||||
return updated
|
||||
|
||||
@@ -175,3 +544,407 @@ async def unlock_study(
|
||||
)
|
||||
|
||||
return unlocked_study
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{study_id}/setup-config",
|
||||
response_model=StudySetupConfigRead,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def get_study_setup_config(
|
||||
study_id: uuid.UUID,
|
||||
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 = await study_setup_config_crud.get_by_study(db, study_id)
|
||||
if not record:
|
||||
sites = await site_crud.list_by_study(db, study_id, skip=0, limit=500, include_inactive=True)
|
||||
default_data = _build_default_setup_config_from_study(study, list(sites))
|
||||
record, _ = await study_setup_config_crud.upsert(
|
||||
db,
|
||||
study_id,
|
||||
expected_version=None,
|
||||
data=default_data,
|
||||
saved_by=current_user.id,
|
||||
)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="study_setup_config",
|
||||
entity_id=record.id,
|
||||
action="CREATE_SETUP_CONFIG",
|
||||
detail="初始化立项配置",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
|
||||
saved_by_name = None
|
||||
published_by_name = None
|
||||
if record.saved_by:
|
||||
user = await user_crud.get_by_id(db, record.saved_by)
|
||||
if user:
|
||||
saved_by_name = user.full_name or user.username or user.email
|
||||
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
|
||||
|
||||
return _to_setup_config_read(record, saved_by_name=saved_by_name, published_by_name=published_by_name)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{study_id}/setup-config",
|
||||
response_model=StudySetupConfigRead,
|
||||
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
|
||||
)
|
||||
async def upsert_study_setup_config(
|
||||
study_id: uuid.UUID,
|
||||
payload: StudySetupConfigUpsert,
|
||||
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="项目不存在")
|
||||
|
||||
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}
|
||||
_validate_setup_data(
|
||||
payload.data,
|
||||
site_lookup,
|
||||
project_plan_start=getattr(study, "plan_start_date", None),
|
||||
project_plan_end=getattr(study, "plan_end_date", None),
|
||||
)
|
||||
|
||||
existing = await study_setup_config_crud.get_by_study(db, study_id)
|
||||
old_config = dict(existing.config or {}) if existing else {}
|
||||
record, conflict = await study_setup_config_crud.upsert(
|
||||
db,
|
||||
study_id,
|
||||
expected_version=payload.expected_version,
|
||||
data=payload.data,
|
||||
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="UPDATE_SETUP_CONFIG",
|
||||
detail=_top_level_diff_summary(old_config, record.config),
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
|
||||
saved_by_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
|
||||
return _to_setup_config_read(record, saved_by_name=saved_by_name, published_by_name=published_by_name)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{study_id}/setup-config/publish",
|
||||
response_model=StudySetupConfigRead,
|
||||
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
|
||||
)
|
||||
async def publish_study_setup_config(
|
||||
study_id: uuid.UUID,
|
||||
payload: StudySetupConfigPublish,
|
||||
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 = await study_setup_config_crud.get_by_study(db, study_id)
|
||||
if not record:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="请先保存立项配置草稿")
|
||||
|
||||
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}
|
||||
_validate_setup_data(
|
||||
StudySetupConfigData.model_validate(record.config or {}),
|
||||
site_lookup,
|
||||
project_plan_start=getattr(study, "plan_start_date", None),
|
||||
project_plan_end=getattr(study, "plan_end_date", None),
|
||||
)
|
||||
|
||||
published, conflict = await study_setup_config_crud.publish(
|
||||
db,
|
||||
study_id,
|
||||
expected_version=payload.expected_version,
|
||||
published_by=current_user.id,
|
||||
auto_commit=False,
|
||||
)
|
||||
if conflict:
|
||||
await db.rollback()
|
||||
_raise_conflict_error()
|
||||
if not published:
|
||||
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(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
|
||||
],
|
||||
)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="study_setup_config",
|
||||
entity_id=published.id,
|
||||
action="PUBLISH_SETUP_CONFIG",
|
||||
detail=_build_projection_audit_detail(
|
||||
projection_status=projection.status,
|
||||
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],
|
||||
),
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
auto_commit=False,
|
||||
)
|
||||
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(published)
|
||||
return _to_setup_config_read(
|
||||
published,
|
||||
saved_by_name=operator_name,
|
||||
published_by_name=operator_name,
|
||||
projection_status=projection.status,
|
||||
projection_summary=projection_summary,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{study_id}/setup-config/versions",
|
||||
response_model=list[StudySetupConfigVersionRead],
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def list_study_setup_config_versions(
|
||||
study_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> list[StudySetupConfigVersionRead]:
|
||||
study = await study_crud.get(db, study_id)
|
||||
if not study:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||||
|
||||
versions = await study_setup_config_crud.list_versions(db, study_id)
|
||||
user_ids = {item.published_by for item in versions if item.published_by}
|
||||
name_map: dict[uuid.UUID, str] = {}
|
||||
for user_id in user_ids:
|
||||
user = await user_crud.get_by_id(db, user_id)
|
||||
if user:
|
||||
name_map[user_id] = user.full_name or user.username or user.email
|
||||
return [
|
||||
_to_setup_config_version_read(item, published_by_name=name_map.get(item.published_by) if item.published_by else None)
|
||||
for item in versions
|
||||
]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{study_id}/setup-config/rollback",
|
||||
response_model=StudySetupConfigRead,
|
||||
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
|
||||
)
|
||||
async def rollback_study_setup_config(
|
||||
study_id: uuid.UUID,
|
||||
payload: StudySetupConfigRollback,
|
||||
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, target_missing = await study_setup_config_crud.rollback_to_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="ROLLBACK_SETUP_CONFIG",
|
||||
detail=f"立项配置已回滚到发布版本 v{payload.target_version}",
|
||||
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
|
||||
return _to_setup_config_read(record, saved_by_name=operator_name, published_by_name=published_by_name)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{study_id}/setup-config/versions/{target_version}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
|
||||
)
|
||||
async def delete_study_setup_config_version(
|
||||
study_id: uuid.UUID,
|
||||
target_version: int,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> None:
|
||||
study = await study_crud.get(db, study_id)
|
||||
if not study:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||||
|
||||
versions = await study_setup_config_crud.list_versions(db, study_id)
|
||||
if not versions:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标版本不存在")
|
||||
latest_version = max(item.version for item in versions)
|
||||
if target_version == latest_version:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="最新发布版本不能删除")
|
||||
|
||||
deleted = await study_setup_config_crud.delete_version(
|
||||
db,
|
||||
study_id,
|
||||
target_version=target_version,
|
||||
)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标版本不存在")
|
||||
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="study_setup_config",
|
||||
entity_id=study_id,
|
||||
action="DELETE_SETUP_CONFIG_VERSION",
|
||||
detail=f"删除发布版本快照 v{target_version}",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{study_id}/setup-config/export-excel",
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def export_study_setup_config_excel(
|
||||
study_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> StreamingResponse:
|
||||
config = await get_study_setup_config(study_id=study_id, db=db, current_user=current_user)
|
||||
study = await study_crud.get(db, study_id)
|
||||
file_bytes = export_setup_config_excel(config.data)
|
||||
filename = f"setup-config-{study.code if study else study_id}.xlsx"
|
||||
return StreamingResponse(
|
||||
BytesIO(file_bytes),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{study_id}/setup-config/import-excel",
|
||||
response_model=StudySetupConfigRead,
|
||||
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
|
||||
)
|
||||
async def import_study_setup_config_excel(
|
||||
study_id: uuid.UUID,
|
||||
file: UploadFile = File(...),
|
||||
expected_version: int | None = Form(default=None),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> StudySetupConfigRead:
|
||||
if not (file.filename or "").lower().endswith(".xlsx"):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请上传 .xlsx 格式文件")
|
||||
|
||||
study = await study_crud.get(db, study_id)
|
||||
if not study:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||||
|
||||
try:
|
||||
payload_data = import_setup_config_excel(await file.read())
|
||||
except Exception:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Excel解析失败,请检查模板格式")
|
||||
|
||||
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}
|
||||
_validate_setup_data(
|
||||
payload_data,
|
||||
site_lookup,
|
||||
project_plan_start=getattr(study, "plan_start_date", None),
|
||||
project_plan_end=getattr(study, "plan_end_date", None),
|
||||
)
|
||||
|
||||
existing = await study_setup_config_crud.get_by_study(db, study_id)
|
||||
old_config = dict(existing.config or {}) if existing else {}
|
||||
record, conflict = await study_setup_config_crud.upsert(
|
||||
db,
|
||||
study_id,
|
||||
expected_version=expected_version,
|
||||
data=payload_data,
|
||||
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="IMPORT_SETUP_CONFIG_EXCEL",
|
||||
detail=_top_level_diff_summary(old_config, record.config),
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
|
||||
saved_by_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
|
||||
return _to_setup_config_read(record, saved_by_name=saved_by_name, published_by_name=published_by_name)
|
||||
|
||||
@@ -85,16 +85,29 @@ async def list_subjects(
|
||||
return []
|
||||
subject_ids = [subject.id for subject in subject_list]
|
||||
ae_rows = await db.execute(
|
||||
select(AdverseEvent.subject_id).where(
|
||||
select(AdverseEvent.subject_id, AdverseEvent.is_sae, AdverseEvent.is_susar).where(
|
||||
AdverseEvent.study_id == study_id,
|
||||
AdverseEvent.subject_id.in_(subject_ids),
|
||||
)
|
||||
)
|
||||
ae_subject_ids = {row[0] for row in ae_rows.all() if row[0]}
|
||||
ae_flags: dict[uuid.UUID, dict[str, bool]] = {}
|
||||
for row in ae_rows.all():
|
||||
sid = row[0]
|
||||
if not sid:
|
||||
continue
|
||||
if sid not in ae_flags:
|
||||
ae_flags[sid] = {"has_ae": True, "has_sae": False, "has_susar": False}
|
||||
if row[1]:
|
||||
ae_flags[sid]["has_sae"] = True
|
||||
if row[2]:
|
||||
ae_flags[sid]["has_susar"] = True
|
||||
result: list[SubjectRead] = []
|
||||
for subject in subject_list:
|
||||
data = SubjectRead.model_validate(subject)
|
||||
data.has_ae = subject.id in ae_subject_ids
|
||||
flags = ae_flags.get(subject.id, {"has_ae": False, "has_sae": False, "has_susar": False})
|
||||
data.has_ae = flags["has_ae"]
|
||||
data.has_sae = flags["has_sae"]
|
||||
data.has_susar = flags["has_susar"]
|
||||
result.append(data)
|
||||
return result
|
||||
|
||||
@@ -120,12 +133,21 @@ async def get_subject(
|
||||
await _ensure_subject_active(db, subject)
|
||||
data = SubjectRead.model_validate(subject)
|
||||
ae_row = await db.execute(
|
||||
select(AdverseEvent.id).where(
|
||||
select(AdverseEvent.is_sae, AdverseEvent.is_susar).where(
|
||||
AdverseEvent.study_id == study_id,
|
||||
AdverseEvent.subject_id == subject.id,
|
||||
).limit(1)
|
||||
)
|
||||
data.has_ae = ae_row.scalar_one_or_none() is not None
|
||||
)
|
||||
ae_flags = {"has_ae": False, "has_sae": False, "has_susar": False}
|
||||
for row in ae_row.all():
|
||||
ae_flags["has_ae"] = True
|
||||
if row[0]:
|
||||
ae_flags["has_sae"] = True
|
||||
if row[1]:
|
||||
ae_flags["has_susar"] = True
|
||||
data.has_ae = ae_flags["has_ae"]
|
||||
data.has_sae = ae_flags["has_sae"]
|
||||
data.has_susar = ae_flags["has_susar"]
|
||||
return data
|
||||
|
||||
|
||||
|
||||
@@ -62,6 +62,20 @@ async def create_visit(
|
||||
await _ensure_subject_active(db, subject)
|
||||
if visit_in.subject_id != subject_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="参与者不匹配")
|
||||
if visit_in.window_start and visit_in.window_end and visit_in.window_start > visit_in.window_end:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="访视窗口结束日期不能早于开始日期")
|
||||
if (
|
||||
visit_in.planned_date
|
||||
and visit_in.window_start
|
||||
and visit_in.planned_date < visit_in.window_start
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="计划访视日期不能早于访视窗口开始日期")
|
||||
if (
|
||||
visit_in.planned_date
|
||||
and visit_in.window_end
|
||||
and visit_in.planned_date > visit_in.window_end
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="计划访视日期不能晚于访视窗口结束日期")
|
||||
next_visit_code = await visit_crud.get_next_visit_code(db, subject_id)
|
||||
if next_visit_code == "V1" and not visit_in.planned_date:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="首次新增访视必须填写计划访视日期")
|
||||
@@ -119,6 +133,12 @@ async def update_visit(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="访视不存在")
|
||||
if visit_in.status == "DONE" and not visit_in.actual_date:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="状态为已完成时必须填写实际日期")
|
||||
if visit.window_start and visit.window_end and visit.window_start > visit.window_end:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前访视窗口配置异常,请先修复窗口日期")
|
||||
if visit_in.actual_date and visit.window_start and visit_in.actual_date < visit.window_start:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="实际访视日期不能早于访视窗口开始日期")
|
||||
if visit_in.actual_date and visit.window_end and visit_in.actual_date > visit.window_end:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="实际访视日期不能晚于访视窗口结束日期")
|
||||
updated = await visit_crud.update_visit(db, visit, visit_in)
|
||||
detail = None
|
||||
if visit_in.status:
|
||||
|
||||
+11
-3
@@ -64,12 +64,14 @@ async def create_ae(
|
||||
term=ae_in.term,
|
||||
onset_date=ae_in.onset_date,
|
||||
resolution_date=None,
|
||||
seriousness=str(ae_in.seriousness),
|
||||
severity=str(ae_in.seriousness),
|
||||
seriousness=ae_in.seriousness.value,
|
||||
severity=ae_in.seriousness.value,
|
||||
causality=ae_in.causality,
|
||||
action_taken=None,
|
||||
outcome=ae_in.outcome,
|
||||
reported_to_sponsor=False,
|
||||
is_sae=bool(ae_in.is_sae or ae_in.is_susar),
|
||||
is_susar=bool(ae_in.is_susar),
|
||||
report_due_date=due_date,
|
||||
status="NEW",
|
||||
description=ae_in.description,
|
||||
@@ -125,8 +127,14 @@ async def list_ae(
|
||||
|
||||
async def update_ae(db: AsyncSession, study_id: uuid.UUID, ae: AdverseEvent, ae_in: AEUpdate) -> AdverseEvent:
|
||||
update_data = ae_in.model_dump(exclude_unset=True)
|
||||
if "is_susar" in update_data and update_data["is_susar"]:
|
||||
update_data["is_sae"] = True
|
||||
if "is_sae" in update_data and update_data["is_sae"] is False:
|
||||
update_data["is_susar"] = False
|
||||
if "seriousness" in update_data:
|
||||
update_data["seriousness"] = str(update_data["seriousness"])
|
||||
severity_value = update_data["seriousness"].value if hasattr(update_data["seriousness"], "value") else str(update_data["seriousness"])
|
||||
update_data["seriousness"] = severity_value
|
||||
update_data["severity"] = severity_value
|
||||
if "subject_id" in update_data:
|
||||
await _validate_site_subject(db, study_id, None, update_data["subject_id"])
|
||||
if "onset_date" in update_data or "seriousness" in update_data:
|
||||
|
||||
@@ -17,6 +17,7 @@ async def log_action(
|
||||
detail: str | None,
|
||||
operator_id: uuid.UUID,
|
||||
operator_role: str,
|
||||
auto_commit: bool = True,
|
||||
) -> AuditLog:
|
||||
log = AuditLog(
|
||||
study_id=study_id,
|
||||
@@ -28,8 +29,11 @@ async def log_action(
|
||||
operator_role=operator_role,
|
||||
)
|
||||
db.add(log)
|
||||
if auto_commit:
|
||||
await db.commit()
|
||||
await db.refresh(log)
|
||||
else:
|
||||
await db.flush()
|
||||
return log
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ from app.models.site import Site
|
||||
from app.models.special_expense import SpecialExpense
|
||||
from app.models.startup_ethics import StartupEthics
|
||||
from app.models.startup_feasibility import StartupFeasibility
|
||||
from app.models.study_center_confirm import StudyCenterConfirm
|
||||
from app.models.subject import Subject
|
||||
from app.models.subject_history import SubjectHistory
|
||||
from app.models.training_authorization import TrainingAuthorization
|
||||
@@ -44,6 +45,9 @@ async def create_site(db: AsyncSession, study_id: uuid.UUID, site_in: SiteCreate
|
||||
pi_name=site_in.pi_name,
|
||||
contact=site_in.contact,
|
||||
is_active=site_in.is_active,
|
||||
enrollment_plan_start_date=site_in.enrollment_plan_start_date,
|
||||
enrollment_plan_end_date=site_in.enrollment_plan_end_date,
|
||||
enrollment_plan_note=site_in.enrollment_plan_note,
|
||||
)
|
||||
db.add(site)
|
||||
await db.commit()
|
||||
@@ -314,6 +318,7 @@ async def delete_site_and_related(db: AsyncSession, site: Site) -> None:
|
||||
await db.execute(delete(AdverseEvent).where(AdverseEvent.site_id == site_id))
|
||||
await db.execute(delete(FinanceItem).where(FinanceItem.site_id == site_id))
|
||||
await db.execute(delete(Subject).where(Subject.site_id == site_id))
|
||||
await db.execute(delete(StudyCenterConfirm).where(StudyCenterConfirm.site_id == site_id))
|
||||
|
||||
await db.execute(delete(Milestone).where(Milestone.site_id == site_id))
|
||||
await db.execute(delete(KickoffMeeting).where(KickoffMeeting.site_id == site_id))
|
||||
|
||||
+35
-17
@@ -9,23 +9,29 @@ from app.schemas.study import StudyCreate, StudyUpdate
|
||||
|
||||
|
||||
async def create(db: AsyncSession, study_in: StudyCreate, *, created_by: uuid.UUID | None = None) -> Study:
|
||||
import uuid as uuid_lib # Import uuid to generate unique code if needed
|
||||
|
||||
code = study_in.code
|
||||
if not code:
|
||||
# Generate a unique code if not provided
|
||||
# Use first 8 chars of a new UUID, uppercase
|
||||
code = f"P-{str(uuid_lib.uuid4())[:8].upper()}"
|
||||
|
||||
study = Study(
|
||||
code=code,
|
||||
code=study_in.code.strip(),
|
||||
name=study_in.name,
|
||||
# sponsor is removed from schema, but model has it. Set to None/empty or keep existing value if schema had it (which it doesn't now)
|
||||
# If we removed it from schema, study_in.sponsor will fail if we try to access it via .sponsor attribute directly if it's not in the pydantic model anymore.
|
||||
# Since we removed `sponsor` field from StudyCreate, `study_in` object won't have `sponsor` attribute unless we access dict or use default.
|
||||
# However, the Study MODEL still has sponsor. We should set it to None.
|
||||
sponsor=None,
|
||||
project_full_name=study_in.project_full_name,
|
||||
sponsor=study_in.sponsor,
|
||||
protocol_no=study_in.protocol_no,
|
||||
lead_unit=study_in.lead_unit,
|
||||
principal_investigator=study_in.principal_investigator,
|
||||
main_pm=study_in.main_pm,
|
||||
research_analysis=study_in.research_analysis,
|
||||
research_product=study_in.research_product,
|
||||
control_product=study_in.control_product,
|
||||
indication=study_in.indication,
|
||||
research_population=study_in.research_population,
|
||||
research_design=study_in.research_design,
|
||||
plan_start_date=study_in.plan_start_date,
|
||||
plan_end_date=study_in.plan_end_date,
|
||||
planned_site_count=study_in.planned_site_count,
|
||||
planned_enrollment_count=study_in.planned_enrollment_count,
|
||||
enrollment_monthly_goal_note=study_in.enrollment_monthly_goal_note,
|
||||
enrollment_stage_breakdown=study_in.enrollment_stage_breakdown,
|
||||
summary_note=study_in.summary_note,
|
||||
objective_note=study_in.objective_note,
|
||||
phase=study_in.phase,
|
||||
status=study_in.status,
|
||||
visit_interval_days=study_in.visit_interval_days,
|
||||
@@ -114,6 +120,10 @@ async def delete(db: AsyncSession, study_id: uuid.UUID) -> None:
|
||||
from app.models.faq_category import FaqCategory
|
||||
from app.models.faq_item import FaqItem
|
||||
from app.models.faq_reply import FaqReply
|
||||
from app.models.study_setup_config import StudySetupConfig
|
||||
from app.models.study_setup_config_version import StudySetupConfigVersion
|
||||
from app.models.study_monitoring_strategy import StudyMonitoringStrategy
|
||||
from app.models.study_center_confirm import StudyCenterConfirm
|
||||
|
||||
# 按依赖关系顺序删除关联数据
|
||||
# 1. 删除审计日志
|
||||
@@ -160,13 +170,21 @@ async def delete(db: AsyncSession, study_id: uuid.UUID) -> None:
|
||||
await db.execute(sa_delete(SubjectHistory).where(SubjectHistory.study_id == study_id))
|
||||
await db.execute(sa_delete(Subject).where(Subject.study_id == study_id))
|
||||
|
||||
# 13. 删除站点
|
||||
# 13. 删除立项联动实体(需先于站点删除,避免外键约束)
|
||||
await db.execute(sa_delete(StudyMonitoringStrategy).where(StudyMonitoringStrategy.study_id == study_id))
|
||||
await db.execute(sa_delete(StudyCenterConfirm).where(StudyCenterConfirm.study_id == study_id))
|
||||
|
||||
# 14. 删除站点
|
||||
await db.execute(sa_delete(Site).where(Site.study_id == study_id))
|
||||
|
||||
# 14. 删除成员
|
||||
# 15. 删除成员
|
||||
await db.execute(sa_delete(StudyMember).where(StudyMember.study_id == study_id))
|
||||
|
||||
# 15. 最后删除项目本身
|
||||
# 16. 删除立项配置
|
||||
await db.execute(sa_delete(StudySetupConfigVersion).where(StudySetupConfigVersion.study_id == study_id))
|
||||
await db.execute(sa_delete(StudySetupConfig).where(StudySetupConfig.study_id == study_id))
|
||||
|
||||
# 17. 最后删除项目本身
|
||||
await db.execute(sa_delete(Study).where(Study.id == study_id))
|
||||
|
||||
await db.commit()
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import delete as sa_delete, select
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.study_setup_config import StudySetupConfig
|
||||
from app.models.study_setup_config_version import StudySetupConfigVersion
|
||||
from app.schemas.study_setup_config import StudySetupConfigData
|
||||
|
||||
|
||||
def _to_storage(data: StudySetupConfigData | dict) -> dict:
|
||||
if isinstance(data, StudySetupConfigData):
|
||||
return data.model_dump(mode="json")
|
||||
return data
|
||||
|
||||
|
||||
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))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def upsert(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
*,
|
||||
expected_version: int | None,
|
||||
data: StudySetupConfigData | dict,
|
||||
saved_by: uuid.UUID | None,
|
||||
) -> tuple[StudySetupConfig | None, bool]:
|
||||
existing = await get_by_study(db, study_id)
|
||||
payload = _to_storage(data)
|
||||
if existing:
|
||||
if expected_version is not None and expected_version != existing.version:
|
||||
return None, True
|
||||
existing.version = existing.version + 1
|
||||
existing.config = payload
|
||||
existing.saved_by = saved_by
|
||||
existing.publish_status = "PUBLISHED" if existing.published_config == payload else "DRAFT"
|
||||
existing.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(existing)
|
||||
return existing, False
|
||||
|
||||
record = StudySetupConfig(
|
||||
study_id=study_id,
|
||||
version=1,
|
||||
config=payload,
|
||||
publish_status="DRAFT",
|
||||
saved_by=saved_by,
|
||||
)
|
||||
db.add(record)
|
||||
await db.commit()
|
||||
await db.refresh(record)
|
||||
return record, False
|
||||
|
||||
|
||||
async def publish(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
*,
|
||||
expected_version: int | None,
|
||||
published_by: uuid.UUID | None,
|
||||
auto_commit: bool = True,
|
||||
) -> 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
|
||||
next_version = existing.version + 1
|
||||
next_display_version_stmt = select(func.max(StudySetupConfigVersion.display_version)).where(
|
||||
StudySetupConfigVersion.study_id == study_id
|
||||
)
|
||||
display_version_result = await db.execute(next_display_version_stmt)
|
||||
current_max_display_version = display_version_result.scalar_one_or_none() or 0
|
||||
next_display_version = current_max_display_version + 1
|
||||
existing.version = next_version
|
||||
existing.publish_status = "PUBLISHED"
|
||||
existing.published_config = existing.config
|
||||
existing.published_by = published_by
|
||||
existing.published_at = datetime.utcnow()
|
||||
existing.saved_by = published_by
|
||||
existing.updated_at = datetime.utcnow()
|
||||
snapshot = StudySetupConfigVersion(
|
||||
study_setup_config_id=existing.id,
|
||||
study_id=study_id,
|
||||
version=next_version,
|
||||
display_version=next_display_version,
|
||||
source_version=next_version - 1,
|
||||
config=existing.config,
|
||||
published_by=published_by,
|
||||
published_at=existing.published_at,
|
||||
)
|
||||
db.add(snapshot)
|
||||
if auto_commit:
|
||||
await db.commit()
|
||||
await db.refresh(existing)
|
||||
else:
|
||||
await db.flush()
|
||||
return existing, False
|
||||
|
||||
|
||||
async def list_versions(db: AsyncSession, study_id: uuid.UUID) -> list[StudySetupConfigVersion]:
|
||||
result = await db.execute(
|
||||
select(StudySetupConfigVersion)
|
||||
.where(StudySetupConfigVersion.study_id == study_id)
|
||||
.order_by(StudySetupConfigVersion.version.desc(), StudySetupConfigVersion.published_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def rollback_to_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
|
||||
|
||||
result = await db.execute(
|
||||
select(StudySetupConfigVersion).where(
|
||||
StudySetupConfigVersion.study_id == study_id,
|
||||
StudySetupConfigVersion.version == target_version,
|
||||
)
|
||||
)
|
||||
target = result.scalar_one_or_none()
|
||||
if not target:
|
||||
return None, False, True
|
||||
|
||||
existing.version = existing.version + 1
|
||||
existing.config = target.config
|
||||
existing.saved_by = saved_by
|
||||
existing.publish_status = "DRAFT"
|
||||
existing.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(existing)
|
||||
return existing, False, False
|
||||
|
||||
|
||||
async def delete_version(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
*,
|
||||
target_version: int,
|
||||
) -> bool:
|
||||
result = await db.execute(
|
||||
select(StudySetupConfigVersion).where(
|
||||
StudySetupConfigVersion.study_id == study_id,
|
||||
StudySetupConfigVersion.version == target_version,
|
||||
)
|
||||
)
|
||||
target = result.scalar_one_or_none()
|
||||
if not target:
|
||||
return False
|
||||
await db.delete(target)
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
|
||||
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.commit()
|
||||
@@ -16,6 +16,27 @@ from app.models.subject import Subject
|
||||
from app.schemas.subject import SubjectCreate, SubjectUpdate
|
||||
|
||||
|
||||
def _validate_subject_date_chain(
|
||||
*,
|
||||
screening_date: date | None,
|
||||
consent_date: date | None,
|
||||
enrollment_date: date | None,
|
||||
completion_date: date | None,
|
||||
) -> None:
|
||||
if screening_date and consent_date and consent_date < screening_date:
|
||||
raise ValueError("知情同意日期不能早于筛选日期")
|
||||
if screening_date and enrollment_date and enrollment_date < screening_date:
|
||||
raise ValueError("入组日期不能早于筛选日期")
|
||||
if consent_date and enrollment_date and enrollment_date < consent_date:
|
||||
raise ValueError("入组日期不能早于知情同意日期")
|
||||
if screening_date and completion_date and completion_date < screening_date:
|
||||
raise ValueError("完成日期不能早于筛选日期")
|
||||
if consent_date and completion_date and completion_date < consent_date:
|
||||
raise ValueError("完成日期不能早于知情同意日期")
|
||||
if enrollment_date and completion_date and completion_date < enrollment_date:
|
||||
raise ValueError("完成日期不能早于入组日期")
|
||||
|
||||
|
||||
async def _validate_site(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID) -> None:
|
||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
||||
site = result.scalar_one_or_none()
|
||||
@@ -27,6 +48,12 @@ async def _validate_site(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UU
|
||||
|
||||
async def create_subject(db: AsyncSession, study_id: uuid.UUID, subject_in: SubjectCreate) -> Subject:
|
||||
await _validate_site(db, study_id, subject_in.site_id)
|
||||
_validate_subject_date_chain(
|
||||
screening_date=subject_in.screening_date,
|
||||
consent_date=subject_in.consent_date,
|
||||
enrollment_date=None,
|
||||
completion_date=None,
|
||||
)
|
||||
subject = Subject(
|
||||
study_id=study_id,
|
||||
site_id=subject_in.site_id,
|
||||
@@ -121,6 +148,16 @@ async def generate_default_visits(db: AsyncSession, subject: Subject) -> None:
|
||||
|
||||
async def update_subject(db: AsyncSession, subject: Subject, subject_in: SubjectUpdate) -> Subject:
|
||||
update_data = subject_in.model_dump(exclude_unset=True)
|
||||
next_screening_date = subject.screening_date
|
||||
next_consent_date = update_data.get("consent_date", subject.consent_date)
|
||||
next_enrollment_date = update_data.get("enrollment_date", subject.enrollment_date)
|
||||
next_completion_date = update_data.get("completion_date", subject.completion_date)
|
||||
_validate_subject_date_chain(
|
||||
screening_date=next_screening_date,
|
||||
consent_date=next_consent_date,
|
||||
enrollment_date=next_enrollment_date,
|
||||
completion_date=next_completion_date,
|
||||
)
|
||||
if update_data:
|
||||
await db.execute(
|
||||
sa_update(Subject)
|
||||
|
||||
@@ -32,3 +32,7 @@ from app.models.subject_history import SubjectHistory # noqa: F401
|
||||
from app.models.faq_category import FaqCategory # noqa: F401
|
||||
from app.models.faq_item import FaqItem # noqa: F401
|
||||
from app.models.faq_reply import FaqReply # noqa: F401
|
||||
from app.models.study_setup_config import StudySetupConfig # noqa: F401
|
||||
from app.models.study_setup_config_version import StudySetupConfigVersion # noqa: F401
|
||||
from app.models.study_monitoring_strategy import StudyMonitoringStrategy # noqa: F401
|
||||
from app.models.study_center_confirm import StudyCenterConfirm # noqa: F401
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -14,6 +18,12 @@ from app.db.session import SessionLocal, engine
|
||||
from app.services.fee_seed import seed_demo_fees
|
||||
from app.services.visit_scheduler import run_daily_lost_visit_job
|
||||
|
||||
logger = logging.getLogger("ctms.setup_config")
|
||||
UUID_RE = re.compile(
|
||||
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
|
||||
)
|
||||
setup_config_stats: dict[str, int] = defaultdict(int)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
@@ -100,6 +110,62 @@ def create_app() -> FastAPI:
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@app.middleware("http")
|
||||
async def setup_config_monitoring_middleware(request, call_next):
|
||||
path = request.url.path
|
||||
is_setup_config_path = "/api/v1/studies/" in path and "/setup-config" in path
|
||||
started_at = time.perf_counter()
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception:
|
||||
if is_setup_config_path:
|
||||
normalized_path = UUID_RE.sub("{study_id}", path)
|
||||
duration_ms = int((time.perf_counter() - started_at) * 1000)
|
||||
key = f"{request.method} {normalized_path} 5xx"
|
||||
setup_config_stats[key] += 1
|
||||
logger.exception(
|
||||
"setup_config_request_error method=%s path=%s status=%s duration_ms=%s",
|
||||
request.method,
|
||||
normalized_path,
|
||||
500,
|
||||
duration_ms,
|
||||
)
|
||||
raise
|
||||
|
||||
if is_setup_config_path:
|
||||
normalized_path = UUID_RE.sub("{study_id}", path)
|
||||
duration_ms = int((time.perf_counter() - started_at) * 1000)
|
||||
status_code = int(response.status_code)
|
||||
status_bucket = f"{status_code // 100}xx"
|
||||
key = f"{request.method} {normalized_path} {status_bucket}"
|
||||
setup_config_stats[key] += 1
|
||||
if status_code >= 500:
|
||||
logger.error(
|
||||
"setup_config_request method=%s path=%s status=%s duration_ms=%s",
|
||||
request.method,
|
||||
normalized_path,
|
||||
status_code,
|
||||
duration_ms,
|
||||
)
|
||||
elif status_code >= 400:
|
||||
logger.warning(
|
||||
"setup_config_request method=%s path=%s status=%s duration_ms=%s",
|
||||
request.method,
|
||||
normalized_path,
|
||||
status_code,
|
||||
duration_ms,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"setup_config_request method=%s path=%s status=%s duration_ms=%s",
|
||||
request.method,
|
||||
normalized_path,
|
||||
status_code,
|
||||
duration_ms,
|
||||
)
|
||||
return response
|
||||
|
||||
register_exception_handlers(app)
|
||||
|
||||
@app.get(
|
||||
@@ -111,6 +177,21 @@ def create_app() -> FastAPI:
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.get(
|
||||
"/health/setup-config-stats",
|
||||
tags=["health"],
|
||||
summary="立项配置接口监控统计",
|
||||
description="返回当前进程内立项配置接口访问计数(按接口+状态段汇总)。",
|
||||
)
|
||||
async def setup_config_health_stats() -> dict[str, dict[str, int]]:
|
||||
summary: dict[str, int] = dict(setup_config_stats)
|
||||
totals = {
|
||||
"2xx": sum(v for k, v in summary.items() if k.endswith(" 2xx")),
|
||||
"4xx": sum(v for k, v in summary.items() if k.endswith(" 4xx")),
|
||||
"5xx": sum(v for k, v in summary.items() if k.endswith(" 5xx")),
|
||||
}
|
||||
return {"totals": totals, "by_endpoint": summary}
|
||||
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
return app
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ class AdverseEvent(Base):
|
||||
action_taken: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
outcome: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
reported_to_sponsor: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_sae: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_susar: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
report_due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="NEW")
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
@@ -20,6 +20,7 @@ class Milestone(Base):
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="NOT_STARTED")
|
||||
site_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), nullable=True)
|
||||
owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
owner_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -19,5 +19,7 @@ class Site(Base):
|
||||
contact: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true")
|
||||
enrollment_target: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
enrollment_plan_start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
enrollment_plan_end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
enrollment_plan_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -14,8 +14,26 @@ class Study(Base):
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
project_full_name: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
sponsor: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
protocol_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
lead_unit: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
principal_investigator: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
main_pm: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
research_analysis: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
research_product: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
control_product: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
indication: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
research_population: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
research_design: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
plan_start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
plan_end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
planned_site_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
planned_enrollment_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
enrollment_monthly_goal_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
enrollment_stage_breakdown: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
summary_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
objective_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
phase: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="DRAFT")
|
||||
is_locked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class StudyCenterConfirm(Base):
|
||||
__tablename__ = "study_center_confirms"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
||||
site_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=False)
|
||||
source_setup_item_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
confirmer: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
confirm_status: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
confirm_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class StudyMonitoringStrategy(Base):
|
||||
__tablename__ = "study_monitoring_strategies"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
||||
source_setup_item_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
strategy_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
detail: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
frequency: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class StudySetupConfig(Base):
|
||||
__tablename__ = "study_setup_configs"
|
||||
__table_args__ = (UniqueConstraint("study_id", name="uq_study_setup_config_study"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
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, default=1)
|
||||
config: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
||||
publish_status: Mapped[str] = mapped_column(default="DRAFT", nullable=False)
|
||||
published_config: 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_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)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class StudySetupConfigVersion(Base):
|
||||
__tablename__ = "study_setup_config_versions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("study_id", "version", name="uq_setup_config_versions_study_version"),
|
||||
UniqueConstraint("study_id", "display_version", name="uq_setup_config_versions_study_display_version"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
study_setup_config_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("study_setup_configs.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)
|
||||
display_version: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
source_version: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
config: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
||||
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())
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
@@ -24,6 +24,8 @@ class AECreate(BaseModel):
|
||||
causality: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
outcome: Optional[str] = None
|
||||
is_sae: bool = False
|
||||
is_susar: bool = False
|
||||
|
||||
|
||||
class AEUpdate(BaseModel):
|
||||
@@ -36,6 +38,8 @@ class AEUpdate(BaseModel):
|
||||
action_taken: Optional[str] = None
|
||||
outcome: Optional[str] = None
|
||||
reported_to_sponsor: Optional[bool] = None
|
||||
is_sae: Optional[bool] = None
|
||||
is_susar: Optional[bool] = None
|
||||
status: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
@@ -54,6 +58,8 @@ class AERead(BaseModel):
|
||||
action_taken: Optional[str]
|
||||
outcome: Optional[str]
|
||||
reported_to_sponsor: bool
|
||||
is_sae: bool
|
||||
is_susar: bool
|
||||
report_due_date: Optional[date]
|
||||
status: str
|
||||
description: Optional[str]
|
||||
@@ -70,6 +76,8 @@ class AERead(BaseModel):
|
||||
if value is None:
|
||||
return AESeriousness.I
|
||||
normalized = str(value).strip().upper()
|
||||
if normalized.startswith("AESERIOUSNESS."):
|
||||
normalized = normalized.split(".", 1)[1]
|
||||
digit_map = {
|
||||
"1": "I",
|
||||
"2": "II",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
@@ -11,7 +11,9 @@ class SiteCreate(BaseModel):
|
||||
pi_name: Optional[str] = None
|
||||
contact: Optional[str] = None
|
||||
is_active: bool = True
|
||||
enrollment_target: Optional[int] = None
|
||||
enrollment_plan_start_date: Optional[date] = None
|
||||
enrollment_plan_end_date: Optional[date] = None
|
||||
enrollment_plan_note: Optional[str] = None
|
||||
|
||||
|
||||
class SiteUpdate(BaseModel):
|
||||
@@ -20,7 +22,9 @@ class SiteUpdate(BaseModel):
|
||||
pi_name: Optional[str] = None
|
||||
contact: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
enrollment_target: Optional[int] = None
|
||||
enrollment_plan_start_date: Optional[date] = None
|
||||
enrollment_plan_end_date: Optional[date] = None
|
||||
enrollment_plan_note: Optional[str] = None
|
||||
|
||||
|
||||
class SiteRead(BaseModel):
|
||||
@@ -32,6 +36,9 @@ class SiteRead(BaseModel):
|
||||
contact: Optional[str]
|
||||
is_active: bool
|
||||
enrollment_target: Optional[int]
|
||||
enrollment_plan_start_date: Optional[date]
|
||||
enrollment_plan_end_date: Optional[date]
|
||||
enrollment_plan_note: Optional[str]
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
@@ -9,8 +9,27 @@ StudyStatus = Literal["DRAFT", "ACTIVE", "CLOSED"]
|
||||
|
||||
class StudyCreate(BaseModel):
|
||||
name: str = Field(min_length=1)
|
||||
code: Optional[str] = None
|
||||
code: str = Field(min_length=1)
|
||||
project_full_name: Optional[str] = None
|
||||
sponsor: Optional[str] = None
|
||||
protocol_no: Optional[str] = None
|
||||
lead_unit: Optional[str] = None
|
||||
principal_investigator: Optional[str] = None
|
||||
main_pm: Optional[str] = None
|
||||
research_analysis: Optional[str] = None
|
||||
research_product: Optional[str] = None
|
||||
control_product: Optional[str] = None
|
||||
indication: Optional[str] = None
|
||||
research_population: Optional[str] = None
|
||||
research_design: Optional[str] = None
|
||||
plan_start_date: Optional[date] = None
|
||||
plan_end_date: Optional[date] = None
|
||||
planned_site_count: Optional[int] = None
|
||||
planned_enrollment_count: Optional[int] = None
|
||||
enrollment_monthly_goal_note: Optional[str] = None
|
||||
enrollment_stage_breakdown: Optional[str] = None
|
||||
summary_note: Optional[str] = None
|
||||
objective_note: Optional[str] = None
|
||||
phase: Optional[str] = None
|
||||
status: StudyStatus = "DRAFT"
|
||||
visit_interval_days: Optional[int] = None
|
||||
@@ -22,7 +41,26 @@ class StudyCreate(BaseModel):
|
||||
class StudyUpdate(BaseModel):
|
||||
code: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
project_full_name: Optional[str] = None
|
||||
sponsor: Optional[str] = None
|
||||
protocol_no: Optional[str] = None
|
||||
lead_unit: Optional[str] = None
|
||||
principal_investigator: Optional[str] = None
|
||||
main_pm: Optional[str] = None
|
||||
research_analysis: Optional[str] = None
|
||||
research_product: Optional[str] = None
|
||||
control_product: Optional[str] = None
|
||||
indication: Optional[str] = None
|
||||
research_population: Optional[str] = None
|
||||
research_design: Optional[str] = None
|
||||
plan_start_date: Optional[date] = None
|
||||
plan_end_date: Optional[date] = None
|
||||
planned_site_count: Optional[int] = None
|
||||
planned_enrollment_count: Optional[int] = None
|
||||
enrollment_monthly_goal_note: Optional[str] = None
|
||||
enrollment_stage_breakdown: Optional[str] = None
|
||||
summary_note: Optional[str] = None
|
||||
objective_note: Optional[str] = None
|
||||
phase: Optional[str] = None
|
||||
status: Optional[StudyStatus] = None
|
||||
is_locked: Optional[bool] = None
|
||||
@@ -36,7 +74,26 @@ class StudyRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
code: str
|
||||
name: str
|
||||
project_full_name: Optional[str]
|
||||
sponsor: Optional[str]
|
||||
protocol_no: Optional[str]
|
||||
lead_unit: Optional[str]
|
||||
principal_investigator: Optional[str]
|
||||
main_pm: Optional[str]
|
||||
research_analysis: Optional[str]
|
||||
research_product: Optional[str]
|
||||
control_product: Optional[str]
|
||||
indication: Optional[str]
|
||||
research_population: Optional[str]
|
||||
research_design: Optional[str]
|
||||
plan_start_date: Optional[date]
|
||||
plan_end_date: Optional[date]
|
||||
planned_site_count: Optional[int]
|
||||
planned_enrollment_count: Optional[int]
|
||||
enrollment_monthly_goal_note: Optional[str]
|
||||
enrollment_stage_breakdown: Optional[str]
|
||||
summary_note: Optional[str]
|
||||
objective_note: Optional[str]
|
||||
phase: Optional[str]
|
||||
status: StudyStatus
|
||||
is_locked: bool
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
ConfirmStatus = Literal["待确认", "已确认", "退回"]
|
||||
MilestoneStatus = Literal["未开始", "进行中", "已完成", "延期"]
|
||||
|
||||
|
||||
class ProjectMilestoneItem(BaseModel):
|
||||
id: str
|
||||
name: str = ""
|
||||
planDate: str = ""
|
||||
owner: str = ""
|
||||
remark: str = ""
|
||||
status: MilestoneStatus = "未开始"
|
||||
|
||||
|
||||
class EnrollmentPlanItem(BaseModel):
|
||||
totalTarget: int = 0
|
||||
startDate: str = ""
|
||||
endDate: str = ""
|
||||
monthlyGoalNote: str = ""
|
||||
stageBreakdown: str = ""
|
||||
|
||||
|
||||
class SiteMilestoneItem(BaseModel):
|
||||
id: str
|
||||
milestone: str = ""
|
||||
planDate: str = ""
|
||||
owner: str = ""
|
||||
remark: str = ""
|
||||
status: MilestoneStatus = "未开始"
|
||||
|
||||
|
||||
class SiteEnrollmentPlanItem(BaseModel):
|
||||
id: str
|
||||
siteId: str = ""
|
||||
siteName: str = ""
|
||||
target: int = 0
|
||||
startDate: str = ""
|
||||
endDate: str = ""
|
||||
note: str = ""
|
||||
stageBreakdown: str = ""
|
||||
|
||||
|
||||
class MonitoringStrategyItem(BaseModel):
|
||||
id: str
|
||||
strategyType: str = ""
|
||||
detail: str = ""
|
||||
frequency: str = ""
|
||||
updatedAt: str = ""
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class CenterConfirmItem(BaseModel):
|
||||
id: str
|
||||
siteId: str = ""
|
||||
siteName: str = ""
|
||||
confirmer: str = ""
|
||||
confirmStatus: ConfirmStatus = "待确认"
|
||||
confirmDate: str = ""
|
||||
note: str = ""
|
||||
|
||||
|
||||
class StudySetupConfigData(BaseModel):
|
||||
projectMilestones: list[ProjectMilestoneItem] = Field(default_factory=list)
|
||||
enrollmentPlan: EnrollmentPlanItem = Field(default_factory=EnrollmentPlanItem)
|
||||
siteMilestones: list[SiteMilestoneItem] = Field(default_factory=list)
|
||||
siteEnrollmentPlans: list[SiteEnrollmentPlanItem] = Field(default_factory=list)
|
||||
monitoringStrategies: list[MonitoringStrategyItem] = Field(default_factory=list)
|
||||
centerConfirm: list[CenterConfirmItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class StudySetupConfigUpsert(BaseModel):
|
||||
expected_version: int | None = None
|
||||
data: StudySetupConfigData
|
||||
|
||||
|
||||
class StudySetupConfigPublish(BaseModel):
|
||||
expected_version: int | None = None
|
||||
|
||||
|
||||
class StudySetupConfigRollback(BaseModel):
|
||||
expected_version: int | None = None
|
||||
target_version: int
|
||||
|
||||
|
||||
class StudySetupConfigVersionRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
study_id: uuid.UUID
|
||||
study_setup_config_id: uuid.UUID
|
||||
version: int
|
||||
display_version: int
|
||||
source_version: int | None = None
|
||||
config: StudySetupConfigData
|
||||
published_by: uuid.UUID | None = None
|
||||
published_by_name: str | None = None
|
||||
published_at: datetime
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class SetupProjectionSkippedItem(BaseModel):
|
||||
site_id: str
|
||||
reason: str
|
||||
|
||||
|
||||
class SetupProjectionSummary(BaseModel):
|
||||
study_updated: bool = False
|
||||
site_updated_count: int = 0
|
||||
site_skipped_count: int = 0
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
skipped_items: list[SetupProjectionSkippedItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class StudySetupConfigRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
study_id: uuid.UUID
|
||||
version: int
|
||||
data: StudySetupConfigData
|
||||
publish_status: str
|
||||
published_data: StudySetupConfigData | None = None
|
||||
published_by: uuid.UUID | None = None
|
||||
published_by_name: str | None = None
|
||||
published_at: datetime | None = None
|
||||
saved_by: uuid.UUID | None
|
||||
saved_by_name: str | None = None
|
||||
projection_status: str | None = None
|
||||
projection_summary: SetupProjectionSummary | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -34,5 +34,7 @@ class SubjectRead(BaseModel):
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
has_ae: Optional[bool] = None
|
||||
has_sae: Optional[bool] = None
|
||||
has_susar: Optional[bool] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from io import BytesIO
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
from openpyxl import Workbook, load_workbook
|
||||
|
||||
from app.schemas.study_setup_config import StudySetupConfigData
|
||||
|
||||
|
||||
def _make_row_id() -> str:
|
||||
return f"{int(datetime.now().timestamp() * 1000)}_{random.randint(1000, 9999)}"
|
||||
|
||||
|
||||
def _is_empty(*values: Any) -> bool:
|
||||
return all(v is None or str(v).strip() == "" for v in values)
|
||||
|
||||
|
||||
def _sheet_headers(sheet, headers: list[str]) -> None:
|
||||
sheet.append(headers)
|
||||
for idx, title in enumerate(headers, start=1):
|
||||
cell = sheet.cell(row=1, column=idx)
|
||||
cell.value = title
|
||||
|
||||
|
||||
def export_setup_config_excel(data: StudySetupConfigData) -> bytes:
|
||||
wb = Workbook()
|
||||
|
||||
ws_guide = wb.active
|
||||
ws_guide.title = "说明"
|
||||
ws_guide.append(["立项配置Excel模板说明"])
|
||||
ws_guide.append(["1. 各业务Sheet首行是表头,不要修改列名。"])
|
||||
ws_guide.append(["2. 可在现有行基础上新增/删除行。"])
|
||||
ws_guide.append(["3. 日期格式建议:YYYY-MM-DD。"])
|
||||
ws_guide.append(["4. 导入后系统会自动校验并回填到立项配置草稿。"])
|
||||
|
||||
ws_pm = wb.create_sheet("项目里程碑")
|
||||
_sheet_headers(ws_pm, ["ID", "里程碑", "计划日期", "负责人", "状态", "备注"])
|
||||
for row in data.projectMilestones:
|
||||
ws_pm.append([row.id, row.name, row.planDate, row.owner, row.status, row.remark])
|
||||
|
||||
ws_plan = wb.create_sheet("项目入组计划")
|
||||
_sheet_headers(ws_plan, ["计划总入组例数", "计划开始日期", "计划结束日期", "月度目标说明", "分阶段计划"])
|
||||
p = data.enrollmentPlan
|
||||
ws_plan.append([p.totalTarget, p.startDate, p.endDate, p.monthlyGoalNote, p.stageBreakdown])
|
||||
|
||||
ws_sm = wb.create_sheet("中心里程碑")
|
||||
_sheet_headers(ws_sm, ["ID", "里程碑", "计划日期", "负责人", "状态", "备注"])
|
||||
for row in data.siteMilestones:
|
||||
ws_sm.append([row.id, row.milestone, row.planDate, row.owner, row.status, row.remark])
|
||||
|
||||
ws_sep = wb.create_sheet("中心入组计划")
|
||||
_sheet_headers(ws_sep, ["ID", "中心ID", "中心名称", "计划例数", "启动日期", "完成日期", "备注", "分阶段计划"])
|
||||
for row in data.siteEnrollmentPlans:
|
||||
ws_sep.append([row.id, row.siteId, row.siteName, row.target, row.startDate, row.endDate, row.note, row.stageBreakdown])
|
||||
|
||||
ws_ms = wb.create_sheet("监查计划策略")
|
||||
_sheet_headers(ws_ms, ["ID", "监查类型", "策略详情", "监查次数", "更新时间", "是否启用"])
|
||||
for row in data.monitoringStrategies:
|
||||
ws_ms.append([row.id, row.strategyType, row.detail, row.frequency, row.updatedAt, "是" if row.enabled else "否"])
|
||||
|
||||
ws_cc = wb.create_sheet("中心确认")
|
||||
_sheet_headers(ws_cc, ["ID", "中心ID", "中心名称", "确认人", "确认状态", "确认日期", "备注"])
|
||||
for row in data.centerConfirm:
|
||||
ws_cc.append([row.id, row.siteId, row.siteName, row.confirmer, row.confirmStatus, row.confirmDate, row.note])
|
||||
|
||||
bio = BytesIO()
|
||||
wb.save(bio)
|
||||
return bio.getvalue()
|
||||
|
||||
|
||||
def _to_int(value: Any, default: int = 0) -> int:
|
||||
if value is None or str(value).strip() == "":
|
||||
return default
|
||||
try:
|
||||
return int(float(str(value)))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _to_date_str(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, datetime):
|
||||
return value.date().isoformat()
|
||||
if isinstance(value, date):
|
||||
return value.isoformat()
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _to_datetime_str(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, datetime):
|
||||
return value.strftime("%Y-%m-%d %H:%M:%S")
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _to_bool(value: Any, default: bool = True) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
v = str(value).strip().lower()
|
||||
if v in {"1", "true", "yes", "y", "是", "启用"}:
|
||||
return True
|
||||
if v in {"0", "false", "no", "n", "否", "禁用"}:
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def import_setup_config_excel(file_bytes: bytes) -> StudySetupConfigData:
|
||||
wb = load_workbook(filename=BytesIO(file_bytes), data_only=True)
|
||||
|
||||
project_milestones = []
|
||||
ws = wb["项目里程碑"] if "项目里程碑" in wb.sheetnames else None
|
||||
if ws:
|
||||
for row in ws.iter_rows(min_row=2, values_only=True):
|
||||
rid, name, plan_date, owner, status, remark = row[:6]
|
||||
if _is_empty(rid, name, plan_date, owner, status, remark):
|
||||
continue
|
||||
project_milestones.append(
|
||||
{
|
||||
"id": str(rid).strip() if rid else _make_row_id(),
|
||||
"name": str(name or "").strip(),
|
||||
"planDate": _to_date_str(plan_date),
|
||||
"owner": str(owner or "").strip(),
|
||||
"status": str(status or "未开始").strip() or "未开始",
|
||||
"remark": str(remark or "").strip(),
|
||||
}
|
||||
)
|
||||
|
||||
enrollment_plan = {
|
||||
"totalTarget": 0,
|
||||
"startDate": "",
|
||||
"endDate": "",
|
||||
"monthlyGoalNote": "",
|
||||
"stageBreakdown": "",
|
||||
}
|
||||
ws = wb["项目入组计划"] if "项目入组计划" in wb.sheetnames else None
|
||||
if ws:
|
||||
first = next(ws.iter_rows(min_row=2, values_only=True), None)
|
||||
if first:
|
||||
enrollment_plan = {
|
||||
"totalTarget": _to_int(first[0], 0),
|
||||
"startDate": _to_date_str(first[1]),
|
||||
"endDate": _to_date_str(first[2]),
|
||||
"monthlyGoalNote": str(first[3] or "").strip(),
|
||||
"stageBreakdown": str(first[4] or "").strip(),
|
||||
}
|
||||
|
||||
site_milestones = []
|
||||
ws = wb["中心里程碑"] if "中心里程碑" in wb.sheetnames else None
|
||||
if ws:
|
||||
for row in ws.iter_rows(min_row=2, values_only=True):
|
||||
raw_status = None
|
||||
if len(row) >= 8:
|
||||
rid, site_id, site_name, milestone, plan_date, owner, raw_status, remark = row[:8]
|
||||
_ = (site_id, site_name)
|
||||
elif len(row) == 7:
|
||||
# Backward compatibility: old template has no status column and includes center columns.
|
||||
rid, site_id, site_name, milestone, plan_date, owner, remark = row[:7]
|
||||
_ = (site_id, site_name)
|
||||
else:
|
||||
rid, milestone, plan_date, owner, raw_status, remark = row[:6]
|
||||
if _is_empty(rid, milestone, plan_date, owner, raw_status, remark):
|
||||
continue
|
||||
status = str(raw_status or "未开始").strip() or "未开始"
|
||||
site_milestones.append(
|
||||
{
|
||||
"id": str(rid).strip() if rid else _make_row_id(),
|
||||
"milestone": str(milestone or "").strip(),
|
||||
"planDate": _to_date_str(plan_date),
|
||||
"owner": str(owner or "").strip(),
|
||||
"status": status,
|
||||
"remark": str(remark or "").strip(),
|
||||
}
|
||||
)
|
||||
|
||||
site_enrollment_plans = []
|
||||
ws = wb["中心入组计划"] if "中心入组计划" in wb.sheetnames else None
|
||||
if ws:
|
||||
for row in ws.iter_rows(min_row=2, values_only=True):
|
||||
rid, site_id, site_name, target, start_date, end_date, note, stage_breakdown = (list(row[:8]) + [None] * 8)[:8]
|
||||
if _is_empty(rid, site_id, site_name, target, start_date, end_date, note, stage_breakdown):
|
||||
continue
|
||||
site_enrollment_plans.append(
|
||||
{
|
||||
"id": str(rid).strip() if rid else _make_row_id(),
|
||||
"siteId": str(site_id or "").strip(),
|
||||
"siteName": str(site_name or "").strip(),
|
||||
"target": _to_int(target, 0),
|
||||
"startDate": _to_date_str(start_date),
|
||||
"endDate": _to_date_str(end_date),
|
||||
"note": str(note or "").strip(),
|
||||
"stageBreakdown": str(stage_breakdown or "").strip(),
|
||||
}
|
||||
)
|
||||
|
||||
monitoring_strategies = []
|
||||
ws = wb["监查计划策略"] if "监查计划策略" in wb.sheetnames else None
|
||||
if ws:
|
||||
for row in ws.iter_rows(min_row=2, values_only=True):
|
||||
rid, strategy_type, detail, frequency, updated_at, enabled = row[:6]
|
||||
if _is_empty(rid, strategy_type, detail, frequency, updated_at, enabled):
|
||||
continue
|
||||
monitoring_strategies.append(
|
||||
{
|
||||
"id": str(rid).strip() if rid else _make_row_id(),
|
||||
"strategyType": str(strategy_type or "").strip(),
|
||||
"detail": str(detail or "").strip(),
|
||||
"frequency": str(frequency or "").strip(),
|
||||
"updatedAt": _to_datetime_str(updated_at) or datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"enabled": _to_bool(enabled, True),
|
||||
}
|
||||
)
|
||||
|
||||
center_confirm = []
|
||||
ws = wb["中心确认"] if "中心确认" in wb.sheetnames else None
|
||||
if ws:
|
||||
for row in ws.iter_rows(min_row=2, values_only=True):
|
||||
rid, site_id, site_name, confirmer, confirm_status, confirm_date, note = row[:7]
|
||||
if _is_empty(rid, site_id, site_name, confirmer, confirm_status, confirm_date, note):
|
||||
continue
|
||||
center_confirm.append(
|
||||
{
|
||||
"id": str(rid).strip() if rid else _make_row_id(),
|
||||
"siteId": str(site_id or "").strip(),
|
||||
"siteName": str(site_name or "").strip(),
|
||||
"confirmer": str(confirmer or "").strip(),
|
||||
"confirmStatus": str(confirm_status or "待确认").strip() or "待确认",
|
||||
"confirmDate": _to_date_str(confirm_date),
|
||||
"note": str(note or "").strip(),
|
||||
}
|
||||
)
|
||||
|
||||
return StudySetupConfigData.model_validate(
|
||||
{
|
||||
"projectMilestones": project_milestones,
|
||||
"enrollmentPlan": enrollment_plan,
|
||||
"siteMilestones": site_milestones,
|
||||
"siteEnrollmentPlans": site_enrollment_plans,
|
||||
"monitoringStrategies": monitoring_strategies,
|
||||
"centerConfirm": center_confirm,
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,387 @@
|
||||
import uuid
|
||||
from datetime import date
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import delete, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.milestone import Milestone
|
||||
from app.models.site import Site
|
||||
from app.models.study import Study
|
||||
from app.models.study_center_confirm import StudyCenterConfirm
|
||||
from app.models.study_monitoring_strategy import StudyMonitoringStrategy
|
||||
from app.models.user import User
|
||||
from app.schemas.study_setup_config import StudySetupConfigData
|
||||
|
||||
PROJECT_MILESTONE_TYPE = "SETUP_PROJECT_MILESTONE"
|
||||
SITE_MILESTONE_TYPE = "SETUP_SITE_MILESTONE"
|
||||
MILESTONE_STATUS_MAP = {
|
||||
"未开始": "NOT_STARTED",
|
||||
"进行中": "IN_PROGRESS",
|
||||
"已完成": "DONE",
|
||||
"延期": "BLOCKED",
|
||||
}
|
||||
|
||||
|
||||
def _map_milestone_status(raw_status: str | None) -> str | None:
|
||||
normalized = (raw_status or "").strip()
|
||||
if not normalized:
|
||||
return "NOT_STARTED"
|
||||
return MILESTONE_STATUS_MAP.get(normalized)
|
||||
|
||||
|
||||
class SetupProjectionSkippedItem(BaseModel):
|
||||
site_id: str
|
||||
reason: str
|
||||
|
||||
|
||||
class SetupProjectionResult(BaseModel):
|
||||
status: Literal["success", "partial_success", "failed"]
|
||||
study_updated: bool = False
|
||||
site_updated_count: int = 0
|
||||
site_skipped_count: int = 0
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
skipped_items: list[SetupProjectionSkippedItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
def _parse_date(value: str) -> date | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_text(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value.strip()
|
||||
return normalized or None
|
||||
|
||||
|
||||
async def _build_owner_lookup(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
setup_data: StudySetupConfigData,
|
||||
result: SetupProjectionResult,
|
||||
) -> dict[str, uuid.UUID]:
|
||||
owners: set[str] = set()
|
||||
for row in setup_data.projectMilestones:
|
||||
owner = _normalize_text(row.owner)
|
||||
if owner:
|
||||
owners.add(owner)
|
||||
for row in setup_data.siteMilestones:
|
||||
owner = _normalize_text(row.owner)
|
||||
if owner:
|
||||
owners.add(owner)
|
||||
|
||||
if not owners:
|
||||
return {}
|
||||
|
||||
users_result = await db.execute(
|
||||
select(User.id, User.full_name, User.email).where(
|
||||
or_(User.full_name.in_(owners), User.email.in_(owners))
|
||||
)
|
||||
)
|
||||
rows = users_result.all()
|
||||
|
||||
owner_candidates: dict[str, set[uuid.UUID]] = {}
|
||||
for user_id, full_name, email in rows:
|
||||
full_name_key = _normalize_text(full_name)
|
||||
email_key = _normalize_text(email)
|
||||
if full_name_key and full_name_key in owners:
|
||||
owner_candidates.setdefault(full_name_key, set()).add(user_id)
|
||||
if email_key and email_key in owners:
|
||||
owner_candidates.setdefault(email_key, set()).add(user_id)
|
||||
|
||||
resolved: dict[str, uuid.UUID] = {}
|
||||
for owner in sorted(owners):
|
||||
candidates = owner_candidates.get(owner, set())
|
||||
if len(candidates) == 1:
|
||||
resolved[owner] = next(iter(candidates))
|
||||
elif len(candidates) > 1:
|
||||
result.warnings.append(f"milestone_owner_ambiguous:{owner}")
|
||||
return resolved
|
||||
|
||||
|
||||
async def _replace_project_milestones(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
study_id: uuid.UUID,
|
||||
setup_data: StudySetupConfigData,
|
||||
owner_lookup: dict[str, uuid.UUID],
|
||||
result: SetupProjectionResult,
|
||||
) -> None:
|
||||
await db.execute(
|
||||
delete(Milestone).where(
|
||||
Milestone.study_id == study_id,
|
||||
Milestone.type == PROJECT_MILESTONE_TYPE,
|
||||
)
|
||||
)
|
||||
for row in setup_data.projectMilestones:
|
||||
if not any([(row.name or "").strip(), (row.owner or "").strip(), (row.remark or "").strip(), (row.planDate or "").strip()]):
|
||||
continue
|
||||
name = (row.name or "").strip()
|
||||
if not name:
|
||||
result.warnings.append(f"project_milestone_name_empty:{row.id}")
|
||||
continue
|
||||
planned_date = _parse_date(row.planDate)
|
||||
if row.planDate and not planned_date:
|
||||
result.warnings.append(f"project_milestone_plan_date_invalid:{row.id}")
|
||||
raw_status = (row.status or "").strip()
|
||||
mapped_status = MILESTONE_STATUS_MAP.get(raw_status, "NOT_STARTED")
|
||||
if raw_status and raw_status not in MILESTONE_STATUS_MAP:
|
||||
result.warnings.append(f"project_milestone_status_unknown:{row.id}")
|
||||
owner_name = _normalize_text(row.owner)
|
||||
owner_id = owner_lookup.get(owner_name, None) if owner_name else None
|
||||
db.add(
|
||||
Milestone(
|
||||
study_id=study_id,
|
||||
type=PROJECT_MILESTONE_TYPE,
|
||||
name=name,
|
||||
planned_date=planned_date,
|
||||
status=mapped_status,
|
||||
owner_id=owner_id,
|
||||
owner_name=owner_name,
|
||||
notes=_normalize_text(row.remark),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _replace_site_milestones(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
study_id: uuid.UUID,
|
||||
setup_data: StudySetupConfigData,
|
||||
owner_lookup: dict[str, uuid.UUID],
|
||||
result: SetupProjectionResult,
|
||||
) -> None:
|
||||
await db.execute(
|
||||
delete(Milestone).where(
|
||||
Milestone.study_id == study_id,
|
||||
Milestone.type == SITE_MILESTONE_TYPE,
|
||||
)
|
||||
)
|
||||
for row in setup_data.siteMilestones:
|
||||
if not any(
|
||||
[
|
||||
(row.milestone or "").strip(),
|
||||
(row.owner or "").strip(),
|
||||
(row.remark or "").strip(),
|
||||
(row.planDate or "").strip(),
|
||||
]
|
||||
):
|
||||
continue
|
||||
milestone_name = (row.milestone or "").strip()
|
||||
if not milestone_name:
|
||||
result.warnings.append(f"site_milestone_name_empty:{row.id}")
|
||||
continue
|
||||
planned_date = _parse_date(row.planDate)
|
||||
if row.planDate and not planned_date:
|
||||
result.warnings.append(f"site_milestone_plan_date_invalid:{row.id}")
|
||||
mapped_status = _map_milestone_status(row.status)
|
||||
if mapped_status is None:
|
||||
result.warnings.append(f"site_milestone_status_unknown:{row.id}")
|
||||
mapped_status = "NOT_STARTED"
|
||||
owner_name = _normalize_text(row.owner)
|
||||
owner_id = owner_lookup.get(owner_name, None) if owner_name else None
|
||||
db.add(
|
||||
Milestone(
|
||||
study_id=study_id,
|
||||
type=SITE_MILESTONE_TYPE,
|
||||
name=milestone_name,
|
||||
planned_date=planned_date,
|
||||
status=mapped_status,
|
||||
owner_id=owner_id,
|
||||
owner_name=owner_name,
|
||||
notes=_normalize_text(row.remark),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _replace_monitoring_strategies(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
study_id: uuid.UUID,
|
||||
setup_data: StudySetupConfigData,
|
||||
result: SetupProjectionResult,
|
||||
) -> None:
|
||||
await db.execute(delete(StudyMonitoringStrategy).where(StudyMonitoringStrategy.study_id == study_id))
|
||||
for row in setup_data.monitoringStrategies:
|
||||
strategy_type = _normalize_text(row.strategyType)
|
||||
detail = _normalize_text(row.detail)
|
||||
frequency = _normalize_text(row.frequency)
|
||||
if not any([strategy_type, detail, frequency]):
|
||||
continue
|
||||
if not strategy_type or not detail or not frequency:
|
||||
result.warnings.append(f"monitoring_strategy_incomplete:{row.id}")
|
||||
continue
|
||||
db.add(
|
||||
StudyMonitoringStrategy(
|
||||
study_id=study_id,
|
||||
source_setup_item_id=_normalize_text(row.id),
|
||||
strategy_type=strategy_type,
|
||||
detail=detail,
|
||||
frequency=frequency,
|
||||
enabled=bool(row.enabled),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _replace_center_confirms(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
study_id: uuid.UUID,
|
||||
setup_data: StudySetupConfigData,
|
||||
site_map: dict[str, Site],
|
||||
result: SetupProjectionResult,
|
||||
) -> None:
|
||||
await db.execute(delete(StudyCenterConfirm).where(StudyCenterConfirm.study_id == study_id))
|
||||
for row in setup_data.centerConfirm:
|
||||
site_id = (row.siteId or "").strip()
|
||||
confirmer = _normalize_text(row.confirmer)
|
||||
note = _normalize_text(row.note)
|
||||
raw_confirm_date = row.confirmDate or ""
|
||||
parsed_confirm_date = _parse_date(raw_confirm_date)
|
||||
if not any([site_id, confirmer, note, raw_confirm_date]):
|
||||
continue
|
||||
if not site_id:
|
||||
result.skipped_items.append(SetupProjectionSkippedItem(site_id="", reason="center_confirm_site_id_empty"))
|
||||
continue
|
||||
site = site_map.get(site_id)
|
||||
if not site:
|
||||
result.skipped_items.append(SetupProjectionSkippedItem(site_id=site_id, reason="center_confirm_site_not_found"))
|
||||
continue
|
||||
if not site.is_active:
|
||||
result.skipped_items.append(SetupProjectionSkippedItem(site_id=site_id, reason="center_confirm_site_inactive"))
|
||||
continue
|
||||
if raw_confirm_date and not parsed_confirm_date:
|
||||
result.skipped_items.append(SetupProjectionSkippedItem(site_id=site_id, reason="center_confirm_date_invalid"))
|
||||
result.warnings.append(f"center_confirm_date_invalid:{site_id}")
|
||||
continue
|
||||
db.add(
|
||||
StudyCenterConfirm(
|
||||
study_id=study_id,
|
||||
site_id=site.id,
|
||||
source_setup_item_id=_normalize_text(row.id),
|
||||
confirmer=confirmer,
|
||||
confirm_status=_normalize_text(row.confirmStatus) or "待确认",
|
||||
confirm_date=parsed_confirm_date,
|
||||
note=note,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def apply_setup_projection_on_publish(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
study_id: uuid.UUID,
|
||||
setup_data: StudySetupConfigData,
|
||||
operator_id: uuid.UUID | None,
|
||||
) -> SetupProjectionResult:
|
||||
_ = operator_id
|
||||
result = SetupProjectionResult(status="success")
|
||||
|
||||
study_result = await db.execute(select(Study).where(Study.id == study_id))
|
||||
study = study_result.scalar_one_or_none()
|
||||
if not study:
|
||||
return SetupProjectionResult(
|
||||
status="failed",
|
||||
warnings=["study_not_found"],
|
||||
site_skipped_count=0,
|
||||
)
|
||||
|
||||
if study.is_locked:
|
||||
result.status = "partial_success"
|
||||
result.warnings.append("study_locked_skipped")
|
||||
result.skipped_items.append(SetupProjectionSkippedItem(site_id=str(study_id), reason="study_locked"))
|
||||
result.site_skipped_count = 1
|
||||
return result
|
||||
|
||||
plan = setup_data.enrollmentPlan
|
||||
new_monthly_goal_note = _normalize_text(plan.monthlyGoalNote)
|
||||
new_stage_breakdown = _normalize_text(plan.stageBreakdown)
|
||||
study_changed = (
|
||||
study.planned_enrollment_count != plan.totalTarget
|
||||
or study.enrollment_monthly_goal_note != new_monthly_goal_note
|
||||
or study.enrollment_stage_breakdown != new_stage_breakdown
|
||||
)
|
||||
study.planned_enrollment_count = plan.totalTarget
|
||||
study.enrollment_monthly_goal_note = new_monthly_goal_note
|
||||
study.enrollment_stage_breakdown = new_stage_breakdown
|
||||
result.study_updated = study_changed
|
||||
|
||||
site_result = await db.execute(select(Site).where(Site.study_id == study_id))
|
||||
site_map = {str(site.id): site for site in site_result.scalars().all()}
|
||||
|
||||
deduped_plans: dict[str, tuple[int, str, str, str]] = {}
|
||||
duplicate_ids: set[str] = set()
|
||||
for row in setup_data.siteEnrollmentPlans:
|
||||
site_id = (row.siteId or "").strip()
|
||||
if not site_id:
|
||||
result.skipped_items.append(SetupProjectionSkippedItem(site_id="", reason="site_id_empty"))
|
||||
continue
|
||||
if site_id in deduped_plans:
|
||||
duplicate_ids.add(site_id)
|
||||
deduped_plans[site_id] = (
|
||||
row.target,
|
||||
row.startDate or "",
|
||||
row.endDate or "",
|
||||
row.note or "",
|
||||
)
|
||||
|
||||
for site_id in sorted(duplicate_ids):
|
||||
result.warnings.append(f"duplicate_site_enrollment_plan:{site_id}")
|
||||
|
||||
for site_id, (target, raw_start_date, raw_end_date, raw_note) in deduped_plans.items():
|
||||
site = site_map.get(site_id)
|
||||
if not site:
|
||||
result.skipped_items.append(SetupProjectionSkippedItem(site_id=site_id, reason="site_not_found"))
|
||||
continue
|
||||
if not site.is_active:
|
||||
result.skipped_items.append(SetupProjectionSkippedItem(site_id=site_id, reason="site_inactive"))
|
||||
continue
|
||||
if target < 0:
|
||||
result.skipped_items.append(SetupProjectionSkippedItem(site_id=site_id, reason="target_invalid"))
|
||||
result.warnings.append(f"target_invalid:{site_id}")
|
||||
continue
|
||||
start_date = _parse_date(raw_start_date)
|
||||
end_date = _parse_date(raw_end_date)
|
||||
if raw_start_date and not start_date:
|
||||
result.skipped_items.append(SetupProjectionSkippedItem(site_id=site_id, reason="site_plan_start_date_invalid"))
|
||||
result.warnings.append(f"site_plan_start_date_invalid:{site_id}")
|
||||
continue
|
||||
if raw_end_date and not end_date:
|
||||
result.skipped_items.append(SetupProjectionSkippedItem(site_id=site_id, reason="site_plan_end_date_invalid"))
|
||||
result.warnings.append(f"site_plan_end_date_invalid:{site_id}")
|
||||
continue
|
||||
site.enrollment_target = target
|
||||
site.enrollment_plan_start_date = start_date
|
||||
site.enrollment_plan_end_date = end_date
|
||||
site.enrollment_plan_note = _normalize_text(raw_note)
|
||||
result.site_updated_count += 1
|
||||
|
||||
owner_lookup = await _build_owner_lookup(db, setup_data=setup_data, result=result)
|
||||
await _replace_project_milestones(
|
||||
db,
|
||||
study_id=study_id,
|
||||
setup_data=setup_data,
|
||||
owner_lookup=owner_lookup,
|
||||
result=result,
|
||||
)
|
||||
await _replace_site_milestones(
|
||||
db,
|
||||
study_id=study_id,
|
||||
setup_data=setup_data,
|
||||
owner_lookup=owner_lookup,
|
||||
result=result,
|
||||
)
|
||||
await _replace_monitoring_strategies(db, study_id=study_id, setup_data=setup_data, result=result)
|
||||
await _replace_center_confirms(db, study_id=study_id, setup_data=setup_data, site_map=site_map, result=result)
|
||||
|
||||
result.site_skipped_count = len(result.skipped_items)
|
||||
if result.site_skipped_count > 0 and result.status != "failed":
|
||||
result.status = "partial_success"
|
||||
return result
|
||||
@@ -14,3 +14,4 @@ pytest-asyncio==0.23.5
|
||||
httpx==0.25.2
|
||||
email-validator==2.1.1
|
||||
alembic==1.13.1
|
||||
openpyxl==3.1.5
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
import asyncpg
|
||||
|
||||
|
||||
BASE = os.getenv("BASE_URL", "http://localhost:8000")
|
||||
ADMIN_EMAIL = os.getenv("EMAIL", "admin@example.com")
|
||||
ADMIN_PASSWORD = os.getenv("PASSWORD", "admin123")
|
||||
STUDY_ID = os.getenv("STUDY_ID", "").strip()
|
||||
DATABASE_URL = (os.getenv("DATABASE_URL") or "postgresql://ctms_user:secret_password@db/ctms_db").replace("+asyncpg", "")
|
||||
|
||||
|
||||
def request_json(path: str, *, method: str = "GET", token: str | None = None, payload: dict | None = None) -> tuple[int, dict]:
|
||||
url = f"{BASE}{path}"
|
||||
headers = {}
|
||||
body = None
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
if payload is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
body = json.dumps(payload).encode()
|
||||
req = urllib.request.Request(url, method=method, headers=headers, data=body)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||
text = resp.read().decode() or "{}"
|
||||
return resp.getcode(), json.loads(text)
|
||||
except urllib.error.HTTPError as exc:
|
||||
text = exc.read().decode() or "{}"
|
||||
try:
|
||||
body_json = json.loads(text)
|
||||
except Exception:
|
||||
body_json = {"raw": text}
|
||||
return exc.code, body_json
|
||||
|
||||
|
||||
def request_bytes(path: str, *, method: str = "GET", token: str | None = None) -> tuple[int, bytes]:
|
||||
url = f"{BASE}{path}"
|
||||
headers = {}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
req = urllib.request.Request(url, method=method, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return resp.getcode(), resp.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, exc.read()
|
||||
|
||||
|
||||
def request_multipart(
|
||||
path: str,
|
||||
*,
|
||||
token: str,
|
||||
file_field: str,
|
||||
file_name: str,
|
||||
file_content: bytes,
|
||||
expected_version: int,
|
||||
) -> tuple[int, dict]:
|
||||
boundary = f"----ctms-boundary-{uuid.uuid4().hex}"
|
||||
chunks: list[bytes] = []
|
||||
chunks.append(
|
||||
(
|
||||
f"--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="expected_version"\r\n\r\n'
|
||||
f"{expected_version}\r\n"
|
||||
).encode("utf-8")
|
||||
)
|
||||
chunks.append(
|
||||
(
|
||||
f"--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="{file_field}"; filename="{file_name}"\r\n'
|
||||
f"Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\r\n\r\n"
|
||||
).encode("utf-8")
|
||||
)
|
||||
chunks.append(file_content)
|
||||
chunks.append(b"\r\n")
|
||||
chunks.append(f"--{boundary}--\r\n".encode("utf-8"))
|
||||
body = b"".join(chunks)
|
||||
|
||||
url = f"{BASE}{path}"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||
}
|
||||
req = urllib.request.Request(url, method="POST", headers=headers, data=body)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
text = resp.read().decode() or "{}"
|
||||
return resp.getcode(), json.loads(text)
|
||||
except urllib.error.HTTPError as exc:
|
||||
text = exc.read().decode() or "{}"
|
||||
try:
|
||||
body_json = json.loads(text)
|
||||
except Exception:
|
||||
body_json = {"raw": text}
|
||||
return exc.code, body_json
|
||||
|
||||
|
||||
def assert_or_exit(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
print(f"[FAIL] {message}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def extract_items(payload: dict) -> list[dict]:
|
||||
if isinstance(payload, list):
|
||||
return payload
|
||||
items = payload.get("items")
|
||||
if isinstance(items, list):
|
||||
return items
|
||||
return []
|
||||
|
||||
|
||||
async def _db_fetch(sql: str, *args):
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
rows = await conn.fetch(sql, *args)
|
||||
return [dict(row) for row in rows]
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
def db_fetch(sql: str, *args) -> list[dict]:
|
||||
return asyncio.run(_db_fetch(sql, *args))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print(f"[config] BASE={BASE} EMAIL={ADMIN_EMAIL}")
|
||||
status, login = request_json(
|
||||
"/api/v1/auth/login",
|
||||
method="POST",
|
||||
payload={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
|
||||
)
|
||||
assert_or_exit(status == 200, f"登录失败 status={status} body={login}")
|
||||
token = login["access_token"]
|
||||
|
||||
if STUDY_ID:
|
||||
study_id = STUDY_ID
|
||||
else:
|
||||
status, studies = request_json("/api/v1/studies/?skip=0&limit=1", token=token)
|
||||
assert_or_exit(status == 200, f"获取项目列表失败 status={status} body={studies}")
|
||||
items = studies.get("items") or []
|
||||
assert_or_exit(bool(items), "未找到可用项目,请设置环境变量 STUDY_ID")
|
||||
study_id = items[0]["id"]
|
||||
print(f"[1/12] study_id={study_id}")
|
||||
|
||||
status, study_before = request_json(f"/api/v1/studies/{study_id}", token=token)
|
||||
assert_or_exit(status == 200, f"获取项目详情失败 status={status} body={study_before}")
|
||||
|
||||
status, sites_resp = request_json(f"/api/v1/studies/{study_id}/sites/?include_inactive=true&skip=0&limit=200", token=token)
|
||||
assert_or_exit(status == 200, f"获取中心列表失败 status={status} body={sites_resp}")
|
||||
sites = extract_items(sites_resp if isinstance(sites_resp, dict) else {"items": sites_resp})
|
||||
active_sites = [item for item in sites if item.get("is_active") is True]
|
||||
assert_or_exit(len(active_sites) >= 1, "至少需要1个活跃中心以验证中心目标联动")
|
||||
|
||||
status, cfg = request_json(f"/api/v1/studies/{study_id}/setup-config", token=token)
|
||||
assert_or_exit(status == 200, f"获取配置失败 status={status} body={cfg}")
|
||||
version = cfg["version"]
|
||||
print(f"[2/12] get_ok version={version}")
|
||||
|
||||
payload = {"expected_version": version, "data": cfg["data"]}
|
||||
payload["data"]["projectMilestones"] = [
|
||||
{
|
||||
"id": "smoke-project-ms-1",
|
||||
"name": "立项启动",
|
||||
"planDate": "2026-02-20",
|
||||
"owner": ADMIN_EMAIL,
|
||||
"remark": "project-ms-remark",
|
||||
"status": "进行中",
|
||||
}
|
||||
]
|
||||
payload["data"]["enrollmentPlan"]["monthlyGoalNote"] = f"smoke-test-note-v{version}"
|
||||
payload["data"]["enrollmentPlan"]["stageBreakdown"] = f"smoke-stage-v{version}"
|
||||
payload["data"]["enrollmentPlan"]["totalTarget"] = max(1, int(study_before.get("planned_enrollment_count") or 0) + 7)
|
||||
payload["data"]["enrollmentPlan"]["startDate"] = "2026-03-01"
|
||||
payload["data"]["enrollmentPlan"]["endDate"] = "2026-09-30"
|
||||
payload["data"]["siteMilestones"] = [
|
||||
{
|
||||
"id": "smoke-site-ms-1",
|
||||
"siteId": active_sites[0]["id"],
|
||||
"siteName": active_sites[0].get("name") or "",
|
||||
"milestone": "中心启动",
|
||||
"planDate": "2026-03-05",
|
||||
"owner": ADMIN_EMAIL,
|
||||
"remark": "site-ms-remark",
|
||||
}
|
||||
]
|
||||
payload["data"]["siteEnrollmentPlans"] = [
|
||||
{
|
||||
"id": f"smoke-plan-{idx+1}",
|
||||
"siteId": site["id"],
|
||||
"siteName": site.get("name") or "",
|
||||
"target": 10 if idx == 0 else 8,
|
||||
"startDate": "2026-03-01",
|
||||
"endDate": "2026-09-30",
|
||||
"note": "smoke-site-plan",
|
||||
}
|
||||
for idx, site in enumerate(active_sites[:2])
|
||||
]
|
||||
payload["data"]["monitoringStrategies"] = [
|
||||
{
|
||||
"id": "smoke-monitoring-1",
|
||||
"strategyType": "风险监查",
|
||||
"detail": "基于关键风险触发",
|
||||
"frequency": "按触发",
|
||||
"updatedAt": "",
|
||||
"enabled": True,
|
||||
}
|
||||
]
|
||||
payload["data"]["centerConfirm"] = [
|
||||
{
|
||||
"id": "smoke-center-confirm-1",
|
||||
"siteId": active_sites[0]["id"],
|
||||
"siteName": active_sites[0].get("name") or "",
|
||||
"confirmer": "System Admin",
|
||||
"confirmStatus": "已确认",
|
||||
"confirmDate": "2026-03-15",
|
||||
"note": "smoke-center-confirm",
|
||||
}
|
||||
]
|
||||
|
||||
targeted_site_ids = {item["siteId"] for item in payload["data"]["siteEnrollmentPlans"]}
|
||||
before_site_map = {str(item.get("id")): item for item in sites if str(item.get("id")) in targeted_site_ids}
|
||||
|
||||
status, updated = request_json(f"/api/v1/studies/{study_id}/setup-config", token=token, method="PUT", payload=payload)
|
||||
assert_or_exit(status == 200, f"保存草稿失败 status={status} body={updated}")
|
||||
latest_version = updated["version"]
|
||||
print(f"[3/12] put_ok version={latest_version}")
|
||||
|
||||
status, study_after_save = request_json(f"/api/v1/studies/{study_id}", token=token)
|
||||
assert_or_exit(status == 200, f"保存后获取项目详情失败 status={status} body={study_after_save}")
|
||||
assert_or_exit(
|
||||
study_after_save.get("planned_enrollment_count") == study_before.get("planned_enrollment_count")
|
||||
and study_after_save.get("plan_start_date") == study_before.get("plan_start_date")
|
||||
and study_after_save.get("plan_end_date") == study_before.get("plan_end_date")
|
||||
and study_after_save.get("enrollment_monthly_goal_note") == study_before.get("enrollment_monthly_goal_note")
|
||||
and study_after_save.get("enrollment_stage_breakdown") == study_before.get("enrollment_stage_breakdown"),
|
||||
"保存草稿不应写入项目业务表字段",
|
||||
)
|
||||
|
||||
status, sites_after_save_resp = request_json(
|
||||
f"/api/v1/studies/{study_id}/sites/?include_inactive=true&skip=0&limit=200", token=token
|
||||
)
|
||||
assert_or_exit(status == 200, f"保存后获取中心失败 status={status} body={sites_after_save_resp}")
|
||||
sites_after_save = extract_items(sites_after_save_resp if isinstance(sites_after_save_resp, dict) else {"items": sites_after_save_resp})
|
||||
after_save_site_map = {str(item.get("id")): item for item in sites_after_save if str(item.get("id")) in targeted_site_ids}
|
||||
for site_id in targeted_site_ids:
|
||||
before_item = before_site_map.get(site_id)
|
||||
after_item = after_save_site_map.get(site_id)
|
||||
if not before_item or not after_item:
|
||||
continue
|
||||
assert_or_exit(
|
||||
after_item.get("enrollment_target") == before_item.get("enrollment_target")
|
||||
and after_item.get("enrollment_plan_start_date") == before_item.get("enrollment_plan_start_date")
|
||||
and after_item.get("enrollment_plan_end_date") == before_item.get("enrollment_plan_end_date")
|
||||
and after_item.get("enrollment_plan_note") == before_item.get("enrollment_plan_note"),
|
||||
f"保存草稿不应写入中心业务表字段 site={site_id}",
|
||||
)
|
||||
print("[4/12] save_no_projection_ok")
|
||||
|
||||
status, stale_result = request_json(
|
||||
f"/api/v1/studies/{study_id}/setup-config",
|
||||
token=token,
|
||||
method="PUT",
|
||||
payload={"expected_version": version, "data": updated["data"]},
|
||||
)
|
||||
stale_code = (stale_result.get("detail") or {}).get("code") if isinstance(stale_result.get("detail"), dict) else ""
|
||||
assert_or_exit(status == 409 and stale_code == "SETUP_CONFIG_VERSION_CONFLICT", f"冲突校验失败 status={status} body={stale_result}")
|
||||
print("[5/12] conflict_409_ok")
|
||||
|
||||
invalid_payload = json.loads(json.dumps(updated))
|
||||
invalid_payload["expected_version"] = latest_version
|
||||
invalid_payload["data"]["enrollmentPlan"]["startDate"] = "2026-12-31"
|
||||
invalid_payload["data"]["enrollmentPlan"]["endDate"] = "2026-01-01"
|
||||
status, invalid_result = request_json(
|
||||
f"/api/v1/studies/{study_id}/setup-config",
|
||||
token=token,
|
||||
method="PUT",
|
||||
payload={"expected_version": invalid_payload["expected_version"], "data": invalid_payload["data"]},
|
||||
)
|
||||
detail = invalid_result.get("detail") if isinstance(invalid_result, dict) else None
|
||||
err_code = detail.get("code") if isinstance(detail, dict) else ""
|
||||
err_list = detail.get("errors") if isinstance(detail, dict) else []
|
||||
has_enrollment_date_error = any((item.get("field") == "enrollmentPlan.endDate") for item in (err_list or []))
|
||||
assert_or_exit(status == 422 and err_code == "VALIDATION_ERROR" and has_enrollment_date_error, f"422校验失败 status={status} body={invalid_result}")
|
||||
print("[6/12] validation_422_ok")
|
||||
|
||||
status, published = request_json(
|
||||
f"/api/v1/studies/{study_id}/setup-config/publish",
|
||||
token=token,
|
||||
method="POST",
|
||||
payload={"expected_version": latest_version},
|
||||
)
|
||||
assert_or_exit(status == 200 and published.get("publish_status") == "PUBLISHED", f"发布失败 status={status} body={published}")
|
||||
projection_status = str(published.get("projection_status") or "").lower()
|
||||
assert_or_exit(
|
||||
projection_status in {"success", "partial_success"},
|
||||
f"发布联动状态异常 projection_status={published.get('projection_status')} body={published}",
|
||||
)
|
||||
first_published_version = published["version"]
|
||||
print(f"[7/12] first_publish_ok version={first_published_version}")
|
||||
|
||||
status, study_detail = request_json(f"/api/v1/studies/{study_id}", token=token)
|
||||
assert_or_exit(status == 200, f"获取项目详情失败 status={status} body={study_detail}")
|
||||
assert_or_exit(
|
||||
study_detail.get("planned_enrollment_count") == payload["data"]["enrollmentPlan"]["totalTarget"],
|
||||
f"项目计划入组联动失败 expected={payload['data']['enrollmentPlan']['totalTarget']} actual={study_detail.get('planned_enrollment_count')}",
|
||||
)
|
||||
assert_or_exit(
|
||||
study_detail.get("plan_start_date") == payload["data"]["enrollmentPlan"]["startDate"]
|
||||
and study_detail.get("plan_end_date") == payload["data"]["enrollmentPlan"]["endDate"],
|
||||
f"项目计划日期联动失败 expected=({payload['data']['enrollmentPlan']['startDate']},{payload['data']['enrollmentPlan']['endDate']}) actual=({study_detail.get('plan_start_date')},{study_detail.get('plan_end_date')})",
|
||||
)
|
||||
assert_or_exit(
|
||||
study_detail.get("enrollment_monthly_goal_note") == payload["data"]["enrollmentPlan"]["monthlyGoalNote"]
|
||||
and study_detail.get("enrollment_stage_breakdown") == payload["data"]["enrollmentPlan"]["stageBreakdown"],
|
||||
"项目入组补充说明联动失败",
|
||||
)
|
||||
|
||||
status, sites_after_resp = request_json(f"/api/v1/studies/{study_id}/sites/?include_inactive=true&skip=0&limit=200", token=token)
|
||||
assert_or_exit(status == 200, f"发布后获取中心失败 status={status} body={sites_after_resp}")
|
||||
sites_after = extract_items(sites_after_resp if isinstance(sites_after_resp, dict) else {"items": sites_after_resp})
|
||||
site_target_map = {str(item.get("id")): item.get("enrollment_target") for item in sites_after}
|
||||
site_plan_start_map = {str(item.get("id")): item.get("enrollment_plan_start_date") for item in sites_after}
|
||||
site_plan_end_map = {str(item.get("id")): item.get("enrollment_plan_end_date") for item in sites_after}
|
||||
site_plan_note_map = {str(item.get("id")): item.get("enrollment_plan_note") for item in sites_after}
|
||||
for plan in payload["data"]["siteEnrollmentPlans"]:
|
||||
actual_target = site_target_map.get(str(plan["siteId"]))
|
||||
assert_or_exit(
|
||||
actual_target == plan["target"],
|
||||
f"中心目标联动失败 site={plan['siteId']} expected={plan['target']} actual={actual_target}",
|
||||
)
|
||||
assert_or_exit(
|
||||
site_plan_start_map.get(str(plan["siteId"])) == plan["startDate"]
|
||||
and site_plan_end_map.get(str(plan["siteId"])) == plan["endDate"]
|
||||
and site_plan_note_map.get(str(plan["siteId"])) == plan["note"],
|
||||
f"中心入组计划附加字段联动失败 site={plan['siteId']}",
|
||||
)
|
||||
|
||||
monitoring_rows = db_fetch(
|
||||
"""
|
||||
SELECT strategy_type, detail, frequency, enabled
|
||||
FROM study_monitoring_strategies
|
||||
WHERE study_id = $1::uuid
|
||||
""",
|
||||
study_id,
|
||||
)
|
||||
assert_or_exit(len(monitoring_rows) == 1, f"监查策略联动失败 rows={monitoring_rows}")
|
||||
assert_or_exit(
|
||||
monitoring_rows[0]["strategy_type"] == "风险监查"
|
||||
and monitoring_rows[0]["detail"] == "基于关键风险触发"
|
||||
and monitoring_rows[0]["frequency"] == "按触发"
|
||||
and monitoring_rows[0]["enabled"] is True,
|
||||
f"监查策略字段联动失败 rows={monitoring_rows}",
|
||||
)
|
||||
|
||||
center_confirm_rows = db_fetch(
|
||||
"""
|
||||
SELECT site_id::text AS site_id, confirmer, confirm_status, confirm_date::text AS confirm_date, note
|
||||
FROM study_center_confirms
|
||||
WHERE study_id = $1::uuid
|
||||
""",
|
||||
study_id,
|
||||
)
|
||||
assert_or_exit(len(center_confirm_rows) == 1, f"中心确认联动失败 rows={center_confirm_rows}")
|
||||
assert_or_exit(
|
||||
center_confirm_rows[0]["site_id"] == active_sites[0]["id"]
|
||||
and center_confirm_rows[0]["confirm_status"] == "已确认"
|
||||
and center_confirm_rows[0]["confirm_date"] == "2026-03-15"
|
||||
and center_confirm_rows[0]["note"] == "smoke-center-confirm",
|
||||
f"中心确认字段联动失败 rows={center_confirm_rows}",
|
||||
)
|
||||
|
||||
milestone_rows = db_fetch(
|
||||
"""
|
||||
SELECT type, name, owner_id::text AS owner_id, owner_name, notes
|
||||
FROM milestones
|
||||
WHERE study_id = $1::uuid
|
||||
AND type IN ('SETUP_PROJECT_MILESTONE', 'SETUP_SITE_MILESTONE')
|
||||
ORDER BY type, name
|
||||
""",
|
||||
study_id,
|
||||
)
|
||||
milestone_map = {(row["type"], row["name"]): row for row in milestone_rows}
|
||||
project_row = milestone_map.get(("SETUP_PROJECT_MILESTONE", "立项启动"))
|
||||
site_row = milestone_map.get(("SETUP_SITE_MILESTONE", "中心启动"))
|
||||
assert_or_exit(project_row is not None and site_row is not None, f"里程碑联动失败 rows={milestone_rows}")
|
||||
assert_or_exit(
|
||||
project_row.get("owner_name") == ADMIN_EMAIL
|
||||
and bool(project_row.get("owner_id"))
|
||||
and project_row.get("notes") == "project-ms-remark",
|
||||
f"项目里程碑owner结构化失败 row={project_row}",
|
||||
)
|
||||
assert_or_exit(
|
||||
site_row.get("owner_name") == ADMIN_EMAIL
|
||||
and bool(site_row.get("owner_id"))
|
||||
and site_row.get("notes") == "site-ms-remark",
|
||||
f"中心里程碑owner结构化失败 row={site_row}",
|
||||
)
|
||||
print("[8/12] publish_projection_db_assert_ok")
|
||||
|
||||
payload_v2 = {"expected_version": published["version"], "data": published["data"]}
|
||||
payload_v2["data"]["enrollmentPlan"]["totalTarget"] = payload["data"]["enrollmentPlan"]["totalTarget"] + 3
|
||||
payload_v2["data"]["enrollmentPlan"]["monthlyGoalNote"] = payload["data"]["enrollmentPlan"]["monthlyGoalNote"] + "-v2"
|
||||
if payload_v2["data"]["siteEnrollmentPlans"]:
|
||||
payload_v2["data"]["siteEnrollmentPlans"][0]["target"] = payload_v2["data"]["siteEnrollmentPlans"][0]["target"] + 2
|
||||
|
||||
status, updated_v2 = request_json(
|
||||
f"/api/v1/studies/{study_id}/setup-config",
|
||||
token=token,
|
||||
method="PUT",
|
||||
payload=payload_v2,
|
||||
)
|
||||
assert_or_exit(status == 200, f"二次保存草稿失败 status={status} body={updated_v2}")
|
||||
status, published_v2 = request_json(
|
||||
f"/api/v1/studies/{study_id}/setup-config/publish",
|
||||
token=token,
|
||||
method="POST",
|
||||
payload={"expected_version": updated_v2["version"]},
|
||||
)
|
||||
assert_or_exit(status == 200 and published_v2.get("publish_status") == "PUBLISHED", f"二次发布失败 status={status} body={published_v2}")
|
||||
|
||||
status, rolled = request_json(
|
||||
f"/api/v1/studies/{study_id}/setup-config/rollback",
|
||||
token=token,
|
||||
method="POST",
|
||||
payload={"expected_version": published_v2["version"], "target_version": first_published_version},
|
||||
)
|
||||
assert_or_exit(status == 200, f"回滚失败 status={status} body={rolled}")
|
||||
assert_or_exit(
|
||||
rolled.get("publish_status") == "DRAFT"
|
||||
and (rolled.get("data") or {}).get("enrollmentPlan", {}).get("totalTarget") == payload["data"]["enrollmentPlan"]["totalTarget"],
|
||||
f"回滚结果异常 body={rolled}",
|
||||
)
|
||||
print("[9/12] rollback_ok")
|
||||
|
||||
status, exported_bytes = request_bytes(f"/api/v1/studies/{study_id}/setup-config/export-excel", token=token)
|
||||
assert_or_exit(status == 200 and len(exported_bytes) > 0, f"导出Excel失败 status={status} bytes={len(exported_bytes)}")
|
||||
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
|
||||
tmp.write(exported_bytes)
|
||||
tmp_path = tmp.name
|
||||
print(f"[10/12] export_excel_ok file={tmp_path}")
|
||||
|
||||
status, imported = request_multipart(
|
||||
f"/api/v1/studies/{study_id}/setup-config/import-excel",
|
||||
token=token,
|
||||
file_field="file",
|
||||
file_name="setup-config-smoke.xlsx",
|
||||
file_content=exported_bytes,
|
||||
expected_version=rolled["version"],
|
||||
)
|
||||
assert_or_exit(status == 200 and isinstance(imported.get("version"), int), f"导入失败 status={status} body={imported}")
|
||||
print(f"[11/12] import_excel_ok version={imported['version']}")
|
||||
|
||||
status, _ = request_json(f"/api/v1/studies/{study_id}/lock", token=token, method="PATCH", payload={})
|
||||
assert_or_exit(status == 200, f"锁定失败 status={status}")
|
||||
try:
|
||||
status, locked_result = request_json(
|
||||
f"/api/v1/studies/{study_id}/setup-config",
|
||||
token=token,
|
||||
method="PUT",
|
||||
payload={"expected_version": imported["version"], "data": imported["data"]},
|
||||
)
|
||||
assert_or_exit(status == 403, f"锁定后写入应403,实际 status={status} body={locked_result}")
|
||||
print("[12/12] lock_403_check_ok")
|
||||
finally:
|
||||
unlock_status, unlock_body = request_json(f"/api/v1/studies/{study_id}/unlock", token=token, method="PATCH", payload={})
|
||||
assert_or_exit(unlock_status == 200, f"解锁失败 status={unlock_status} body={unlock_body}")
|
||||
|
||||
print("setup-config smoke test passed")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -163,6 +163,8 @@ CREATE TABLE IF NOT EXISTS public.adverse_events (
|
||||
action_taken text,
|
||||
outcome character varying(50),
|
||||
reported_to_sponsor boolean NOT NULL DEFAULT false,
|
||||
is_sae boolean NOT NULL DEFAULT false,
|
||||
is_susar boolean NOT NULL DEFAULT false,
|
||||
report_due_date date,
|
||||
status character varying(20) NOT NULL DEFAULT 'NEW',
|
||||
description text,
|
||||
|
||||
@@ -104,6 +104,7 @@ services:
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./nginx/certs:/etc/nginx/certs:ro
|
||||
- ./frontend/public/favicon.ico:/usr/share/nginx/html/favicon.ico:ro
|
||||
depends_on:
|
||||
- backend
|
||||
- frontend
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"id": "9f9bb57a-6de8-4e31-a7a4-e0c0e7c0d119",
|
||||
"name": "CTMS Local",
|
||||
"values": [
|
||||
{ "key": "base_url", "value": "http://localhost", "type": "default", "enabled": true },
|
||||
{ "key": "email", "value": "admin@example.com", "type": "default", "enabled": true },
|
||||
{ "key": "password", "value": "admin123", "type": "secret", "enabled": true },
|
||||
{ "key": "study_id", "value": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "type": "default", "enabled": true },
|
||||
{ "key": "token", "value": "", "type": "default", "enabled": true },
|
||||
{ "key": "setup_version", "value": "1", "type": "default", "enabled": true },
|
||||
{ "key": "target_total", "value": "123", "type": "default", "enabled": true },
|
||||
{ "key": "plan_start_date", "value": "2026-02-01", "type": "default", "enabled": true },
|
||||
{ "key": "plan_end_date", "value": "2026-12-31", "type": "default", "enabled": true },
|
||||
{
|
||||
"key": "exported_data",
|
||||
"value": "{\"projectMilestones\":[],\"enrollmentPlan\":{\"totalTarget\":0,\"startDate\":\"\",\"endDate\":\"\",\"monthlyGoalNote\":\"\",\"stageBreakdown\":\"\"},\"siteMilestones\":[],\"siteEnrollmentPlans\":[],\"monitoringStrategies\":[],\"centerConfirm\":[]}",
|
||||
"type": "default",
|
||||
"enabled": true
|
||||
}
|
||||
],
|
||||
"_postman_variable_scope": "environment",
|
||||
"_postman_exported_at": "2026-02-09T00:00:00.000Z",
|
||||
"_postman_exported_using": "Codex"
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
{
|
||||
"info": {
|
||||
"name": "CTMS Setup Config API (Excel)",
|
||||
"description": "立项配置接口联调集合(登录 -> 获取 -> 保存 -> 发布 -> 导出Excel -> 导入Excel)",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "1. 登录(获取 Token)",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{ "key": "Content-Type", "value": "application/json" }
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"email\": \"{{email}}\",\n \"password\": \"{{password}}\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/v1/auth/login",
|
||||
"host": ["{{base_url}}"],
|
||||
"path": ["api", "v1", "auth", "login"]
|
||||
}
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('status is 200', function () { pm.response.to.have.status(200); });",
|
||||
"var json = pm.response.json();",
|
||||
"pm.collectionVariables.set('token', json.access_token || '');"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "2. 获取立项配置",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{ "key": "Authorization", "value": "Bearer {{token}}" }
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/v1/studies/{{study_id}}/setup-config",
|
||||
"host": ["{{base_url}}"],
|
||||
"path": ["api", "v1", "studies", "{{study_id}}", "setup-config"]
|
||||
}
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('status is 200', function () { pm.response.to.have.status(200); });",
|
||||
"var json = pm.response.json();",
|
||||
"pm.collectionVariables.set('setup_version', String(json.version || 1));"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "3. 保存草稿",
|
||||
"request": {
|
||||
"method": "PUT",
|
||||
"header": [
|
||||
{ "key": "Authorization", "value": "Bearer {{token}}" },
|
||||
{ "key": "Content-Type", "value": "application/json" }
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"expected_version\": {{setup_version}},\n \"data\": {\n \"projectMilestones\": [],\n \"enrollmentPlan\": {\n \"totalTarget\": {{target_total}},\n \"startDate\": \"{{plan_start_date}}\",\n \"endDate\": \"{{plan_end_date}}\",\n \"monthlyGoalNote\": \"Postman save draft\",\n \"stageBreakdown\": \"phase-1\"\n },\n \"siteMilestones\": [],\n \"siteEnrollmentPlans\": [],\n \"monitoringStrategies\": [],\n \"centerConfirm\": []\n }\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/v1/studies/{{study_id}}/setup-config",
|
||||
"host": ["{{base_url}}"],
|
||||
"path": ["api", "v1", "studies", "{{study_id}}", "setup-config"]
|
||||
}
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('status is 200', function () { pm.response.to.have.status(200); });",
|
||||
"var json = pm.response.json();",
|
||||
"pm.collectionVariables.set('setup_version', String(json.version || pm.collectionVariables.get('setup_version') || '1'));"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "4. 发布配置",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{ "key": "Authorization", "value": "Bearer {{token}}" },
|
||||
{ "key": "Content-Type", "value": "application/json" }
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"expected_version\": {{setup_version}}\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/v1/studies/{{study_id}}/setup-config/publish",
|
||||
"host": ["{{base_url}}"],
|
||||
"path": ["api", "v1", "studies", "{{study_id}}", "setup-config", "publish"]
|
||||
}
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('status is 200', function () { pm.response.to.have.status(200); });",
|
||||
"var json = pm.response.json();",
|
||||
"pm.test('publish status is PUBLISHED', function () { pm.expect(json.publish_status).to.eql('PUBLISHED'); });",
|
||||
"pm.collectionVariables.set('setup_version', String(json.version || pm.collectionVariables.get('setup_version') || '1'));"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "5. 导出配置 Excel",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{ "key": "Authorization", "value": "Bearer {{token}}" }
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/v1/studies/{{study_id}}/setup-config/export-excel",
|
||||
"host": ["{{base_url}}"],
|
||||
"path": ["api", "v1", "studies", "{{study_id}}", "setup-config", "export-excel"]
|
||||
}
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('status is 200', function () { pm.response.to.have.status(200); });"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "6. 导入配置 Excel",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{ "key": "Authorization", "value": "Bearer {{token}}" }
|
||||
],
|
||||
"body": {
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{ "key": "expected_version", "value": "{{setup_version}}", "type": "text" },
|
||||
{ "key": "file", "type": "file", "src": "{{excel_file_path}}" }
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/v1/studies/{{study_id}}/setup-config/import-excel",
|
||||
"host": ["{{base_url}}"],
|
||||
"path": ["api", "v1", "studies", "{{study_id}}", "setup-config", "import-excel"]
|
||||
}
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('status is 200', function () { pm.response.to.have.status(200); });",
|
||||
"var json = pm.response.json();",
|
||||
"pm.collectionVariables.set('setup_version', String(json.version || pm.collectionVariables.get('setup_version') || '1'));"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"variable": [
|
||||
{ "key": "base_url", "value": "http://localhost" },
|
||||
{ "key": "email", "value": "admin@example.com" },
|
||||
{ "key": "password", "value": "admin123" },
|
||||
{ "key": "study_id", "value": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" },
|
||||
{ "key": "token", "value": "" },
|
||||
{ "key": "setup_version", "value": "1" },
|
||||
{ "key": "target_total", "value": "123" },
|
||||
{ "key": "plan_start_date", "value": "2026-02-01" },
|
||||
{ "key": "plan_end_date", "value": "2026-12-31" },
|
||||
{ "key": "excel_file_path", "value": "" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
# Release Checklist(立项配置)
|
||||
|
||||
## 1. 构建与迁移
|
||||
- [ ] `cd backend && docker compose run --rm backend python -m alembic upgrade head`
|
||||
- [ ] `cd backend && python3 -m compileall app scripts`
|
||||
- [ ] `cd frontend && npm run build`
|
||||
|
||||
## 2. 接口与权限回归
|
||||
- [ ] ADMIN 账号可 `GET/PUT/PUBLISH/IMPORT-EXCEL/EXPORT-EXCEL`
|
||||
- [ ] PM 账号可 `GET/PUT/PUBLISH/IMPORT-EXCEL/EXPORT-EXCEL`
|
||||
- [ ] CRA 账号可 `GET/EXPORT-EXCEL`,`PUT/PUBLISH/IMPORT-EXCEL` 返回 `403`
|
||||
- [ ] 非项目成员访问返回 `403`
|
||||
|
||||
## 3. 并发冲突回归
|
||||
- [ ] 双开两个窗口同项目配置
|
||||
- [ ] A 保存后,B 再保存触发 `409`
|
||||
- [ ] 前端显示冲突对比弹窗(可读 diff + JSON)
|
||||
- [ ] 选择“使用服务器版本”后版本同步
|
||||
- [ ] 选择“用本地草稿覆盖”后保存成功且版本递增
|
||||
- [ ] `409` 响应结构包含 `detail.code=SETUP_CONFIG_VERSION_CONFLICT`
|
||||
|
||||
## 3.1 业务校验回归(422)
|
||||
- [ ] `enrollmentPlan.startDate > endDate` 返回 `422`
|
||||
- [ ] 非本项目 `siteId` 返回 `422`
|
||||
- [ ] `centerConfirm` 状态为“已确认”但无 `confirmDate` 返回 `422`
|
||||
- [ ] `422` 响应包含 `detail.errors[].field` 与 `detail.errors[].message`
|
||||
|
||||
## 4. 发布快照回归
|
||||
- [ ] 草稿保存后 `publish_status=DRAFT`
|
||||
- [ ] 发布后 `publish_status=PUBLISHED`
|
||||
- [ ] 发布后可切换“已发布快照”并只读
|
||||
- [ ] 再修改草稿后状态回到 `DRAFT`
|
||||
|
||||
## 5. 导入导出回归
|
||||
- [ ] 导出 Excel 文件可打开且结构完整
|
||||
- [ ] 导入有效 Excel 成功,页面数据刷新
|
||||
- [ ] 导入非法 Excel 给出错误提示
|
||||
- [ ] 导入冲突时触发 `409` 提示并刷新版本
|
||||
|
||||
## 6. 锁定与审计回归
|
||||
- [ ] 项目锁定后 `PUT/PUBLISH/IMPORT-EXCEL` 返回 `403`
|
||||
- [ ] 页面进入只读态,编辑按钮不可用
|
||||
- [ ] 审计日志可按对象类型筛选 `立项配置`
|
||||
- [ ] 审计事件包含:
|
||||
- [ ] `CREATE_SETUP_CONFIG`
|
||||
- [ ] `UPDATE_SETUP_CONFIG`
|
||||
- [ ] `PUBLISH_SETUP_CONFIG`
|
||||
- [ ] `IMPORT_SETUP_CONFIG_EXCEL`
|
||||
|
||||
## 7. 监控埋点回归
|
||||
- [ ] 访问 `GET /health/setup-config-stats` 可返回 JSON
|
||||
- [ ] `by_endpoint` 中可看到 `setup-config` 各接口 `2xx/4xx/5xx` 计数
|
||||
- [ ] 人为制造一次冲突或锁定写入后,`4xx` 计数增加
|
||||
|
||||
## 8. 冒烟脚本
|
||||
- [ ] `cd backend && docker compose exec -T backend python scripts/smoke_setup_config.py`
|
||||
- [ ] 使用 `BASE_URL/EMAIL/PASSWORD/STUDY_ID` 环境变量执行一次(覆盖指定项目)
|
||||
- [ ] `BASE_URL=http://localhost EMAIL=... PASSWORD=... STUDY_ID=... bash docs/setup-config-curl-smoke.sh`
|
||||
- [ ] 导入并执行 Postman 集合 `docs/postman/setup-config.postman_collection.json`
|
||||
|
||||
## 9. 发布前确认
|
||||
- [ ] 文档已更新:`docs/setup-config-api.md`
|
||||
- [ ] 本清单已全量勾选
|
||||
- [ ] 发布说明已包含新增接口与权限规则
|
||||
@@ -0,0 +1,180 @@
|
||||
# 立项配置接口说明(Excel 导入导出版)
|
||||
|
||||
## 1. 概览
|
||||
- 基础路径: `/api/v1/studies/{study_id}/setup-config`
|
||||
- 数据作用域: 单项目唯一配置(`study_setup_configs.study_id` 唯一约束)
|
||||
- 存储格式: JSONB(字段结构与前端 `SetupConfigDraft` 对齐)
|
||||
- 并发控制: 乐观锁(`expected_version`)
|
||||
- 发布机制: 草稿/已发布(`publish_status`)
|
||||
- 前端视图语义:
|
||||
- `草稿视图`: 当前草稿可编辑
|
||||
- `发布预览`: 当前草稿只读预览(不直接读取 `published_data`)
|
||||
- `published_data`: 用于发布快照、差异比较与版本能力
|
||||
|
||||
## 2. 权限矩阵
|
||||
- `GET /setup-config`
|
||||
- 允许: 项目成员、管理员
|
||||
- `PUT /setup-config`
|
||||
- 允许: PM、ADMIN
|
||||
- 限制: 项目锁定时返回 `403`
|
||||
- `POST /setup-config/publish`
|
||||
- 允许: PM、ADMIN
|
||||
- 限制: 项目锁定时返回 `403`
|
||||
- `GET /setup-config/export-excel`
|
||||
- 允许: 项目成员、管理员
|
||||
- `POST /setup-config/import-excel`
|
||||
- 允许: PM、ADMIN
|
||||
- 限制: 项目锁定时返回 `403`
|
||||
|
||||
## 3. 数据结构
|
||||
|
||||
### 3.1 请求体(保存)
|
||||
```json
|
||||
{
|
||||
"expected_version": 3,
|
||||
"data": {
|
||||
"projectMilestones": [
|
||||
{
|
||||
"id": "m1",
|
||||
"name": "首例入组",
|
||||
"planDate": "2026-03-01",
|
||||
"owner": "项目经理",
|
||||
"remark": "",
|
||||
"status": "未开始"
|
||||
}
|
||||
],
|
||||
"enrollmentPlan": {
|
||||
"totalTarget": 100,
|
||||
"startDate": "2026-02-01",
|
||||
"endDate": "2026-12-31",
|
||||
"monthlyGoalNote": "按月拆解",
|
||||
"stageBreakdown": "阶段1/2/3"
|
||||
},
|
||||
"siteMilestones": [],
|
||||
"siteEnrollmentPlans": [],
|
||||
"monitoringStrategies": [],
|
||||
"centerConfirm": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 响应体(GET/PUT/PUBLISH/IMPORT-EXCEL)
|
||||
```json
|
||||
{
|
||||
"id": "f0ac2fca-5fd3-470e-8e7e-2ded2b8f0f77",
|
||||
"study_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
"version": 6,
|
||||
"publish_status": "PUBLISHED",
|
||||
"data": {
|
||||
"projectMilestones": [],
|
||||
"enrollmentPlan": {
|
||||
"totalTarget": 120,
|
||||
"startDate": "2026-02-01",
|
||||
"endDate": "2026-12-31",
|
||||
"monthlyGoalNote": "",
|
||||
"stageBreakdown": ""
|
||||
},
|
||||
"siteMilestones": [],
|
||||
"siteEnrollmentPlans": [],
|
||||
"monitoringStrategies": [],
|
||||
"centerConfirm": []
|
||||
},
|
||||
"published_data": null,
|
||||
"saved_by": "11111111-1111-1111-1111-111111111111",
|
||||
"saved_by_name": "System Admin",
|
||||
"published_by": null,
|
||||
"published_by_name": null,
|
||||
"published_at": null,
|
||||
"created_at": "2026-02-09T01:30:00.000000Z",
|
||||
"updated_at": "2026-02-09T02:11:13.000000Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 接口定义
|
||||
|
||||
### 4.1 GET 获取配置
|
||||
```http
|
||||
GET /api/v1/studies/{study_id}/setup-config
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
### 4.2 PUT 保存配置(草稿)
|
||||
```http
|
||||
PUT /api/v1/studies/{study_id}/setup-config
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
### 4.3 POST 发布配置
|
||||
```http
|
||||
POST /api/v1/studies/{study_id}/setup-config/publish
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
### 4.4 GET 导出 Excel
|
||||
```http
|
||||
GET /api/v1/studies/{study_id}/setup-config/export-excel
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
说明:
|
||||
- 返回 `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`
|
||||
- 文件可直接作为导入模板继续回灌
|
||||
|
||||
### 4.5 POST 导入 Excel
|
||||
```http
|
||||
POST /api/v1/studies/{study_id}/setup-config/import-excel
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
表单字段:
|
||||
- `file`: `.xlsx` 文件(必填)
|
||||
- `expected_version`: 当前配置版本(可选,建议携带)
|
||||
|
||||
说明:
|
||||
- 导入会覆盖当前草稿并自增版本。
|
||||
- 写入审计事件: `IMPORT_SETUP_CONFIG_EXCEL`。
|
||||
|
||||
## 5. 错误码与典型场景
|
||||
- `400 BAD REQUEST`
|
||||
- 上传文件非 `.xlsx`
|
||||
- Excel 解析失败
|
||||
- `403 FORBIDDEN`
|
||||
- 非 PM/ADMIN 导入/保存/发布
|
||||
- 项目已锁定
|
||||
- `404 NOT FOUND`
|
||||
- study 不存在
|
||||
- `409 CONFLICT`
|
||||
- `expected_version` 与服务端不一致
|
||||
- `422 UNPROCESSABLE ENTITY`
|
||||
- Excel 数据通过解析后,业务字段校验失败
|
||||
|
||||
## 6. 联调脚本(curl)
|
||||
- 脚本路径: `docs/setup-config-curl-smoke.sh`
|
||||
- 使用方式:
|
||||
```bash
|
||||
chmod +x docs/setup-config-curl-smoke.sh
|
||||
BASE_URL=http://localhost \
|
||||
EMAIL=admin@example.com \
|
||||
PASSWORD=admin123 \
|
||||
STUDY_ID=aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa \
|
||||
bash docs/setup-config-curl-smoke.sh
|
||||
```
|
||||
|
||||
## 7. Postman 联调集合
|
||||
- Collection: `docs/postman/setup-config.postman_collection.json`
|
||||
- Environment: `docs/postman/local.postman_environment.json`
|
||||
|
||||
## 8. 后端烟雾测试(非 pytest)
|
||||
```bash
|
||||
docker compose up -d db backend
|
||||
docker compose exec -T backend python scripts/smoke_setup_config.py
|
||||
```
|
||||
|
||||
## 9. 监控埋点(4xx/5xx)
|
||||
- 中间件统计以下接口:
|
||||
- `GET /api/v1/studies/{study_id}/setup-config`
|
||||
- `PUT /api/v1/studies/{study_id}/setup-config`
|
||||
- `POST /api/v1/studies/{study_id}/setup-config/publish`
|
||||
- `GET /api/v1/studies/{study_id}/setup-config/export-excel`
|
||||
- `POST /api/v1/studies/{study_id}/setup-config/import-excel`
|
||||
@@ -0,0 +1,74 @@
|
||||
# 立项配置代码审计报告(2026-02)
|
||||
|
||||
## 范围
|
||||
- 前端:`frontend/src/views/admin/ProjectDetail.vue`、`frontend/src/api/studies.ts`、`frontend/src/types/setupConfig.ts`、`frontend/src/utils/setupFieldLocator.ts`
|
||||
- 后端:`backend/app/api/v1/studies.py`、`backend/app/crud/study_setup_config.py`、`backend/app/schemas/study_setup_config.py`、`backend/app/services/setup_config_excel.py`
|
||||
- 文档:`docs/setup-config-api.md`、`docs/setup-config-curl-smoke.sh`、`docs/postman/setup-config.postman_collection.json`、`backend/scripts/smoke_setup_config.py`
|
||||
|
||||
## 发现清单
|
||||
|
||||
### 严重(会导致错误或明显行为偏差)
|
||||
- 无新增严重问题。
|
||||
|
||||
### 中等(会导致交互不一致或维护风险)
|
||||
1. `ProjectDetail.vue` 中存在大量 `v-if="false"` 历史分支(Step4-7),增加阅读成本并容易引发误改。
|
||||
2. 步骤表格从“输入态表格”转“展示态 + 抽屉编辑”后,存在未清理的旧方法与旧样式残留,导致逻辑路径复杂。
|
||||
3. “发布预览”语义已切换为“当前草稿只读预览”,但文档未明确,容易与后端 `published_data` 概念混淆。
|
||||
|
||||
### 低(代码异味/重复/可读性)
|
||||
1. `ProjectDetail.vue` 文件过长(约 3800+ 行),后续改动冲突概率高。
|
||||
2. 多个步骤编辑抽屉逻辑重复,适合后续抽象为通用 `RowEditorDrawer`。
|
||||
3. 表格列模板重复,适合后续改为列配置驱动。
|
||||
|
||||
## 本轮已处理
|
||||
1. 删除 Step4-7 全部 `v-if="false"` 模板分支。
|
||||
2. 删除上述分支遗留的无用方法:
|
||||
- `handleSiteMilestoneSiteChange`
|
||||
- `handleSiteEnrollmentSiteChange`
|
||||
- `handleCenterConfirmSiteChange`
|
||||
- `touchStrategy`
|
||||
- `applyStrategy`
|
||||
3. 清理无用样式:
|
||||
- `.section-toolbar`
|
||||
- `.setup-content.is-published-preview .section-toolbar` 相关规则
|
||||
4. 保持现有交互行为不变并完成构建验证。
|
||||
5. 修复步骤标题行按钮保护不一致问题:
|
||||
- 第2/4/5/6/7步标题行功能键增加 `:disabled="!canEditSetup"`,避免锁定项目或无权限时误触发。
|
||||
- 对应新增/删除/抽屉打开与保存方法统一加守卫,防止外部调用绕过前端可见状态。
|
||||
6. 收敛重复的草稿可编辑判定,新增 `canMutateDraft()` 供步骤行编辑方法复用(不改变界面行为)。
|
||||
7. 抽取步骤行编辑通用控制器(`IndexedEditorController`)与通用函数:
|
||||
- `openIndexedEditor`
|
||||
- `getEditingIndex`
|
||||
- `closeIndexedEditor`
|
||||
复用到 Step2/4/5/6/7 的单行抽屉编辑逻辑,减少重复代码。
|
||||
8. 合并重复 `onMounted` 注册逻辑,统一在单一入口完成 `loadProject` 与 `beforeunload` 监听初始化。
|
||||
9. 新增 `frontend/src/composables/useSetupConfig.ts`:
|
||||
- 将 setup-config 相关 API 编排从页面中收口到 composable(获取/保存/发布/版本列表/回滚/版本删除/Excel 导入导出)。
|
||||
- `ProjectDetail.vue` 改为依赖 composable 暴露的方法,降低页面对 API 层的直接耦合。
|
||||
10. 新增 `frontend/src/utils/setupDiffRows.ts`:
|
||||
- 将“步骤差异明细”计算与字段路径可读化逻辑从 `ProjectDetail.vue` 抽离为工具函数。
|
||||
- 页面保留项目主字段差异拼装,步骤差异统一由工具输出,降低页面认知负担。
|
||||
11. 将步骤标题行按钮显隐规则收敛为 `stepActionMode` 计算属性:
|
||||
- 去除模板中多段 `activeStep === n && !isPublishedView` 分支。
|
||||
- 保持按钮行为与权限控制不变,降低后续步骤扩展的修改面。
|
||||
|
||||
## 验证结果
|
||||
1. `frontend npm run build` 通过。
|
||||
2. 后端 setup-config 核心文件通过 AST 语法解析检查。
|
||||
3. 当前环境 `npx tsc --noEmit` 失败主要来自项目既有依赖类型版本不匹配(`vue`/`element-plus`/`csstype`),非本轮立项配置改动引入。
|
||||
|
||||
## 一致性核对结果
|
||||
1. 前后端 setup-config 路由一致:
|
||||
- `GET/PUT /setup-config`
|
||||
- `POST /setup-config/publish`
|
||||
- `GET /setup-config/versions`
|
||||
- `POST /setup-config/rollback`
|
||||
- `DELETE /setup-config/versions/{target_version}`
|
||||
- `GET /setup-config/export-excel`
|
||||
- `POST /setup-config/import-excel`
|
||||
2. 前端 `setupConfig` 类型与后端 schema 字段一致(`published_data` 仍保留用于发布快照与差异比较)。
|
||||
|
||||
## 下一步建议(不改行为)
|
||||
1. 拆分 `ProjectDetail.vue` 为步骤子组件(先拆 Step4-7)。
|
||||
2. 抽象通用行编辑抽屉组件,统一校验和保存流程。
|
||||
3. 使用列配置对象复用 `el-table-column` 渲染,降低重复模板。
|
||||
Executable
+97
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BASE_URL:-http://localhost}"
|
||||
EMAIL="${EMAIL:-admin@example.com}"
|
||||
PASSWORD="${PASSWORD:-admin123}"
|
||||
STUDY_ID="${STUDY_ID:-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa}"
|
||||
|
||||
echo "[1/6] login: $EMAIL"
|
||||
TOKEN=$(curl -sS -X POST "$BASE_URL/api/v1/auth/login" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\"}" | \
|
||||
python3 -c 'import json,sys; print(json.load(sys.stdin).get("access_token",""))')
|
||||
|
||||
if [[ -z "$TOKEN" ]]; then
|
||||
echo "login failed: access_token empty"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
AUTH=(-H "Authorization: Bearer $TOKEN")
|
||||
|
||||
echo "[2/6] get setup-config"
|
||||
GET_RESP=$(curl -sS "$BASE_URL/api/v1/studies/$STUDY_ID/setup-config" "${AUTH[@]}")
|
||||
VERSION=$(echo "$GET_RESP" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("version",""))')
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
echo "get setup-config failed"
|
||||
echo "$GET_RESP"
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$VERSION"
|
||||
|
||||
echo "[3/6] save draft"
|
||||
PUT_PAYLOAD=$(cat <<JSON
|
||||
{
|
||||
"expected_version": $VERSION,
|
||||
"data": {
|
||||
"projectMilestones": [],
|
||||
"enrollmentPlan": {
|
||||
"totalTarget": 123,
|
||||
"startDate": "2026-02-01",
|
||||
"endDate": "2026-12-31",
|
||||
"monthlyGoalNote": "curl smoke",
|
||||
"stageBreakdown": "phase-1"
|
||||
},
|
||||
"siteMilestones": [],
|
||||
"siteEnrollmentPlans": [],
|
||||
"monitoringStrategies": [],
|
||||
"centerConfirm": []
|
||||
}
|
||||
}
|
||||
JSON
|
||||
)
|
||||
PUT_RESP=$(curl -sS -X PUT "$BASE_URL/api/v1/studies/$STUDY_ID/setup-config" \
|
||||
"${AUTH[@]}" -H 'Content-Type: application/json' -d "$PUT_PAYLOAD")
|
||||
VERSION=$(echo "$PUT_RESP" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("version",""))')
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
echo "save draft failed"
|
||||
echo "$PUT_RESP"
|
||||
exit 1
|
||||
fi
|
||||
echo "saved version=$VERSION"
|
||||
|
||||
echo "[4/6] publish"
|
||||
PUB_RESP=$(curl -sS -X POST "$BASE_URL/api/v1/studies/$STUDY_ID/setup-config/publish" \
|
||||
"${AUTH[@]}" -H 'Content-Type: application/json' -d "{\"expected_version\":$VERSION}")
|
||||
PUB_STATUS=$(echo "$PUB_RESP" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("publish_status",""))')
|
||||
VERSION=$(echo "$PUB_RESP" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("version",""))')
|
||||
if [[ "$PUB_STATUS" != "PUBLISHED" ]]; then
|
||||
echo "publish failed"
|
||||
echo "$PUB_RESP"
|
||||
exit 1
|
||||
fi
|
||||
echo "publish_status=$PUB_STATUS version=$VERSION"
|
||||
|
||||
echo "[5/6] export excel"
|
||||
EXCEL_FILE="/tmp/setup-config-export.xlsx"
|
||||
curl -sS "$BASE_URL/api/v1/studies/$STUDY_ID/setup-config/export-excel" "${AUTH[@]}" -o "$EXCEL_FILE"
|
||||
if [[ ! -s "$EXCEL_FILE" ]]; then
|
||||
echo "export excel failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "export saved: $EXCEL_FILE"
|
||||
|
||||
echo "[6/6] import excel (re-import export file)"
|
||||
IMP_RESP=$(curl -sS -X POST "$BASE_URL/api/v1/studies/$STUDY_ID/setup-config/import-excel" \
|
||||
"${AUTH[@]}" \
|
||||
-F "expected_version=$VERSION" \
|
||||
-F "file=@$EXCEL_FILE;type=application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
IMP_VER=$(echo "$IMP_RESP" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("version",""))')
|
||||
if [[ -z "$IMP_VER" ]]; then
|
||||
echo "import failed"
|
||||
echo "$IMP_RESP"
|
||||
exit 1
|
||||
fi
|
||||
echo "import version=$IMP_VER"
|
||||
|
||||
echo "setup-config curl smoke passed"
|
||||
Vendored
+3
-2
@@ -3,9 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="data:," />
|
||||
<title>CTMS</title>
|
||||
<script type="module" crossorigin src="/assets/index-CsWv41CQ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B7Ge_Ohp.css">
|
||||
<script type="module" crossorigin src="/assets/index-DNEAoWP5.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-J1iXJzyy.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="data:," />
|
||||
<title>CTMS</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -16,6 +16,7 @@ const instance: AxiosInstance = axios.create({
|
||||
export type ApiRequestConfig = AxiosRequestConfig & {
|
||||
ignoreIdle?: boolean;
|
||||
allowWhileLocked?: boolean;
|
||||
suppressErrorMessage?: boolean;
|
||||
_retry?: boolean;
|
||||
};
|
||||
|
||||
@@ -52,7 +53,10 @@ instance.interceptors.response.use(
|
||||
(response: AxiosResponse) => response,
|
||||
async (error: AxiosError<ApiError>) => {
|
||||
if (error instanceof LockedError) {
|
||||
const lockedConfig = error.config as ApiRequestConfig | undefined;
|
||||
if (!lockedConfig?.suppressErrorMessage) {
|
||||
ElMessage.warning(TEXT.common.messages.sessionLocked || "请解锁后继续操作");
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
const status = error.response?.status;
|
||||
@@ -93,11 +97,19 @@ instance.interceptors.response.use(
|
||||
}
|
||||
} else if (status === 403 && (data as any)?.detail === "账号已停用") {
|
||||
forceLogout();
|
||||
} else if (data?.message || (data as any)?.detail) {
|
||||
ElMessage.error((data as any)?.message || (data as any)?.detail);
|
||||
} else {
|
||||
const suppressErrorMessage = (error.config as ApiRequestConfig | undefined)?.suppressErrorMessage;
|
||||
if (!suppressErrorMessage) {
|
||||
if (data?.message || (data as any)?.detail) {
|
||||
const detail = (data as any)?.detail;
|
||||
const detailMessage =
|
||||
typeof detail === "string" ? detail : typeof detail?.message === "string" ? detail.message : undefined;
|
||||
ElMessage.error((data as any)?.message || detailMessage || TEXT.common.messages.requestFailed);
|
||||
} else {
|
||||
ElMessage.error(TEXT.common.messages.requestFailed);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
@@ -107,6 +119,8 @@ export const apiPost = <T = unknown>(url: string, data?: unknown, config?: ApiRe
|
||||
instance.post<T>(url, data, config);
|
||||
export const apiPatch = <T = unknown>(url: string, data?: unknown, config?: ApiRequestConfig) =>
|
||||
instance.patch<T>(url, data, config);
|
||||
export const apiPut = <T = unknown>(url: string, data?: unknown, config?: ApiRequestConfig) =>
|
||||
instance.put<T>(url, data, config);
|
||||
export const apiDelete = <T = unknown>(url: string, config?: ApiRequestConfig) =>
|
||||
instance.delete<T>(url, config);
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { apiGet, apiPatch, apiPost, apiDelete } from "./axios";
|
||||
import { apiGet, apiPatch, apiPost, apiDelete, apiPut } from "./axios";
|
||||
import type { ApiListResponse, Study } from "../types/api";
|
||||
import type {
|
||||
StudySetupConfigPublishPayload,
|
||||
StudySetupConfigResponse,
|
||||
StudySetupConfigRollbackPayload,
|
||||
StudySetupConfigUpsertPayload,
|
||||
StudySetupConfigVersionItem,
|
||||
} from "../types/setupConfig";
|
||||
|
||||
// Backend expects trailing slash to avoid 307 redirect
|
||||
export const fetchStudies = () => apiGet<ApiListResponse<Study>>("/api/v1/studies/");
|
||||
@@ -18,3 +25,26 @@ export const deleteStudy = (studyId: string) =>
|
||||
export const lockStudy = (studyId: string) =>
|
||||
apiPatch<Study>(`/api/v1/studies/${studyId}/lock`, {});
|
||||
|
||||
export const fetchStudySetupConfig = (studyId: string) =>
|
||||
apiGet<StudySetupConfigResponse>(`/api/v1/studies/${studyId}/setup-config`, { suppressErrorMessage: true });
|
||||
|
||||
export const saveStudySetupConfig = (studyId: string, payload: StudySetupConfigUpsertPayload) =>
|
||||
apiPut<StudySetupConfigResponse>(`/api/v1/studies/${studyId}/setup-config`, payload, { suppressErrorMessage: true });
|
||||
|
||||
export const publishStudySetupConfig = (studyId: string, payload: StudySetupConfigPublishPayload) =>
|
||||
apiPost<StudySetupConfigResponse>(`/api/v1/studies/${studyId}/setup-config/publish`, payload, { suppressErrorMessage: true });
|
||||
|
||||
export const fetchStudySetupConfigVersions = (studyId: string) =>
|
||||
apiGet<StudySetupConfigVersionItem[]>(`/api/v1/studies/${studyId}/setup-config/versions`, { suppressErrorMessage: true });
|
||||
|
||||
export const deleteStudySetupConfigVersion = (studyId: string, targetVersion: number) =>
|
||||
apiDelete(`/api/v1/studies/${studyId}/setup-config/versions/${targetVersion}`, { suppressErrorMessage: true });
|
||||
|
||||
export const rollbackStudySetupConfig = (studyId: string, payload: StudySetupConfigRollbackPayload) =>
|
||||
apiPost<StudySetupConfigResponse>(`/api/v1/studies/${studyId}/setup-config/rollback`, payload, { suppressErrorMessage: true });
|
||||
|
||||
export const exportStudySetupConfigExcel = (studyId: string) =>
|
||||
apiGet<Blob>(`/api/v1/studies/${studyId}/setup-config/export-excel`, { responseType: "blob", suppressErrorMessage: true });
|
||||
|
||||
export const importStudySetupConfigExcel = (studyId: string, payload: FormData) =>
|
||||
apiPost<StudySetupConfigResponse>(`/api/v1/studies/${studyId}/setup-config/import-excel`, payload, { suppressErrorMessage: true });
|
||||
|
||||
@@ -39,6 +39,10 @@
|
||||
<el-icon><House /></el-icon>
|
||||
<span>{{ TEXT.menu.projectOverview }}</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/project/milestones">
|
||||
<el-icon><Calendar /></el-icon>
|
||||
<span>{{ TEXT.menu.projectMilestones }}</span>
|
||||
</el-menu-item>
|
||||
<el-sub-menu index="fees">
|
||||
<template #title>
|
||||
<el-icon><Coin /></el-icon>
|
||||
@@ -47,10 +51,15 @@
|
||||
<el-menu-item index="/fees/contracts">{{ TEXT.menu.feeContracts }}</el-menu-item>
|
||||
<el-menu-item index="/fees/special">{{ TEXT.menu.feeSpecials }}</el-menu-item>
|
||||
</el-sub-menu>
|
||||
<el-menu-item index="/drug/shipments">
|
||||
<el-sub-menu index="materials">
|
||||
<template #title>
|
||||
<el-icon><Box /></el-icon>
|
||||
<span>{{ TEXT.menu.drugShipments }}</span>
|
||||
</el-menu-item>
|
||||
<span>{{ TEXT.menu.materialManagement }}</span>
|
||||
</template>
|
||||
<el-menu-item index="/drug/shipments">{{ TEXT.menu.drugShipments }}</el-menu-item>
|
||||
<el-menu-item index="/materials/equipment">{{ TEXT.menu.materialEquipment }}</el-menu-item>
|
||||
<el-menu-item index="/materials/others">{{ TEXT.menu.materialOthers }}</el-menu-item>
|
||||
</el-sub-menu>
|
||||
<el-menu-item index="/file-versions">
|
||||
<el-icon><Files /></el-icon>
|
||||
<span>{{ TEXT.menu.fileVersionManagement }}</span>
|
||||
@@ -67,13 +76,22 @@
|
||||
<el-icon><User /></el-icon>
|
||||
<span>{{ TEXT.menu.subjects }}</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/monitoring">
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
<span>{{ TEXT.menu.monitoring }}</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/audit">
|
||||
<el-sub-menu index="risk-issues">
|
||||
<template #title>
|
||||
<el-icon><Flag /></el-icon>
|
||||
<span>{{ TEXT.menu.audit }}</span>
|
||||
<span>{{ TEXT.menu.riskIssues }}</span>
|
||||
</template>
|
||||
<el-menu-item index="/risk-issues/sae">{{ TEXT.menu.riskIssueSae }}</el-menu-item>
|
||||
<el-menu-item index="/risk-issues/pd">{{ TEXT.menu.riskIssuePd }}</el-menu-item>
|
||||
<el-menu-item index="/risk-issues/monitoring-visits">{{ TEXT.menu.riskIssueMonitoringVisits }}</el-menu-item>
|
||||
</el-sub-menu>
|
||||
<el-menu-item index="/monitoring-audit">
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
<span>{{ TEXT.menu.monitoringAudit }}</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/etmf">
|
||||
<el-icon><Flag /></el-icon>
|
||||
<span>{{ TEXT.menu.etmf }}</span>
|
||||
</el-menu-item>
|
||||
<el-sub-menu index="knowledge">
|
||||
<template #title>
|
||||
@@ -152,9 +170,9 @@
|
||||
shape="square"
|
||||
class="user-avatar-comp"
|
||||
>
|
||||
{{ (auth.user?.username || auth.user?.email || TEXT.common.labels.userInitialFallback).charAt(0).toUpperCase() }}
|
||||
{{ userDisplayInitial }}
|
||||
</el-avatar>
|
||||
<span class="username">{{ auth.user?.username || auth.user?.email || TEXT.common.labels.userFallback }}</span>
|
||||
<span class="username">{{ userDisplayName }}</span>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
@@ -208,22 +226,36 @@ const route = useRoute();
|
||||
const isAdmin = computed(() => auth.user?.role === "ADMIN");
|
||||
const isPm = computed(() => auth.user?.role === "PM" || study.currentStudyRole === "PM");
|
||||
const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1");
|
||||
const userDisplayName = computed(
|
||||
() => auth.user?.full_name || auth.user?.username || auth.user?.email || TEXT.common.labels.userFallback
|
||||
);
|
||||
const userDisplayInitial = computed(() => {
|
||||
const source = auth.user?.full_name || auth.user?.username || auth.user?.email || TEXT.common.labels.userInitialFallback;
|
||||
return source.charAt(0).toUpperCase();
|
||||
});
|
||||
const activeMenu = computed(() => {
|
||||
const path = route.path;
|
||||
if (path.startsWith("/project/milestones")) return "/project/milestones";
|
||||
if (path.startsWith("/project/")) return "/project/overview";
|
||||
if (path.startsWith("/fees/contracts")) return "/fees/contracts";
|
||||
if (path.startsWith("/fees/special")) return "/fees/special";
|
||||
if (path.startsWith("/finance/contracts")) return "/fees/contracts";
|
||||
if (path.startsWith("/finance/special")) return "/fees/special";
|
||||
if (path.startsWith("/drug/shipments")) return "/drug/shipments";
|
||||
if (path.startsWith("/materials/equipment")) return "/materials/equipment";
|
||||
if (path.startsWith("/materials/others")) return "/materials/others";
|
||||
if (path.startsWith("/file-versions") || path.startsWith("/trial/") || path.startsWith("/documents/")) return "/file-versions";
|
||||
if (path.startsWith("/startup/feasibility") || path.startsWith("/startup/ethics")) return "/startup/feasibility-ethics";
|
||||
if (path.startsWith("/startup/meeting-auth") || path.startsWith("/startup/kickoff") || path.startsWith("/startup/training")) {
|
||||
return "/startup/meeting-auth";
|
||||
}
|
||||
if (path.startsWith("/subjects")) return "/subjects";
|
||||
if (path.startsWith("/monitoring")) return "/monitoring";
|
||||
if (path.startsWith("/audit")) return "/audit";
|
||||
if (path.startsWith("/risk-issues/sae")) return "/risk-issues/sae";
|
||||
if (path.startsWith("/risk-issues/pd")) return "/risk-issues/pd";
|
||||
if (path.startsWith("/risk-issues/monitoring-visits")) return "/risk-issues/monitoring-visits";
|
||||
if (path.startsWith("/risk-issues")) return "/risk-issues/sae";
|
||||
if (path.startsWith("/monitoring-audit") || path.startsWith("/monitoring") || path.startsWith("/audit")) return "/monitoring-audit";
|
||||
if (path.startsWith("/etmf")) return "/etmf";
|
||||
if (path.startsWith("/knowledge/medical-consult")) return "/knowledge/medical-consult";
|
||||
if (path.startsWith("/knowledge/notes")) return "/knowledge/notes";
|
||||
if (path.startsWith("/knowledge/support-files")) return "/knowledge/support-files";
|
||||
@@ -284,8 +316,16 @@ const breadcrumbs = computed(() => {
|
||||
const moduleMap: Record<string, { label: string, path: string }> = {
|
||||
"/fees/contracts": { label: TEXT.menu.feeContracts, path: "/fees/contracts" },
|
||||
"/fees/special": { label: TEXT.menu.feeSpecials, path: "/fees/special" },
|
||||
"/project/milestones": { label: TEXT.menu.projectMilestones, path: "/project/milestones" },
|
||||
"/drug/shipments": { label: TEXT.menu.drugShipments, path: "/drug/shipments" },
|
||||
"/materials/equipment": { label: TEXT.menu.materialEquipment, path: "/materials/equipment" },
|
||||
"/materials/others": { label: TEXT.menu.materialOthers, path: "/materials/others" },
|
||||
"/subjects": { label: TEXT.menu.subjects, path: "/subjects" },
|
||||
"/risk-issues/sae": { label: TEXT.menu.riskIssueSae, path: "/risk-issues/sae" },
|
||||
"/risk-issues/pd": { label: TEXT.menu.riskIssuePd, path: "/risk-issues/pd" },
|
||||
"/risk-issues/monitoring-visits": { label: TEXT.menu.riskIssueMonitoringVisits, path: "/risk-issues/monitoring-visits" },
|
||||
"/monitoring-audit": { label: TEXT.menu.monitoringAudit, path: "/monitoring-audit" },
|
||||
"/etmf": { label: TEXT.menu.etmf, path: "/etmf" },
|
||||
"/startup/feasibility": { label: TEXT.menu.startupFeasibilityEthics, path: "/startup/feasibility-ethics" },
|
||||
"/startup/ethics": { label: TEXT.menu.startupFeasibilityEthics, path: "/startup/feasibility-ethics" },
|
||||
"/startup/kickoff": { label: TEXT.menu.startupMeetingAuth, path: "/startup/meeting-auth" },
|
||||
@@ -359,7 +399,15 @@ const onLogout = () => {
|
||||
router.replace("/login");
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
if (auth.token && !auth.user) {
|
||||
try {
|
||||
await auth.fetchMe();
|
||||
} catch {
|
||||
onLogout();
|
||||
return;
|
||||
}
|
||||
}
|
||||
loadStudies();
|
||||
});
|
||||
|
||||
@@ -382,7 +430,7 @@ const onCommand = (cmd: string) => {
|
||||
background-color: #2c3e50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 0 15px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 0 12px rgba(0, 0, 0, 0.08);
|
||||
z-index: 10;
|
||||
border-right: none;
|
||||
transition: width 0.18s ease;
|
||||
@@ -409,10 +457,10 @@ const onCommand = (cmd: string) => {
|
||||
}
|
||||
|
||||
.aside-logo {
|
||||
height: 64px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
padding: 0 14px;
|
||||
background-color: transparent;
|
||||
transition: padding 0.18s ease;
|
||||
}
|
||||
@@ -438,12 +486,12 @@ const onCommand = (cmd: string) => {
|
||||
border-right: none;
|
||||
background-color: transparent;
|
||||
flex: 1;
|
||||
padding: 12px 0;
|
||||
padding: 4px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.menu-divider {
|
||||
padding: 16px 16px 8px;
|
||||
padding: 8px 14px 2px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
@@ -452,10 +500,10 @@ const onCommand = (cmd: string) => {
|
||||
}
|
||||
|
||||
.layout-aside :deep(.el-menu-item) {
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
margin: 4px 10px;
|
||||
border-radius: 8px;
|
||||
height: 34px;
|
||||
line-height: 34px;
|
||||
margin: 1px 8px;
|
||||
border-radius: 7px;
|
||||
color: #f1f5f9 !important;
|
||||
transition: var(--ctms-transition);
|
||||
}
|
||||
@@ -475,19 +523,19 @@ const onCommand = (cmd: string) => {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 10px;
|
||||
bottom: 10px;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 3px;
|
||||
background-color: #3b82f6;
|
||||
background-color: #94b8d1;
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
.layout-aside :deep(.el-sub-menu__title) {
|
||||
color: #f1f5f9 !important;
|
||||
margin: 4px 10px;
|
||||
border-radius: 8px;
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
margin: 1px 8px;
|
||||
border-radius: 7px;
|
||||
height: 34px;
|
||||
line-height: 34px;
|
||||
}
|
||||
|
||||
.layout-aside :deep(.el-sub-menu__title:hover) {
|
||||
@@ -505,8 +553,8 @@ const onCommand = (cmd: string) => {
|
||||
}
|
||||
|
||||
.layout-aside :deep(.el-menu--inline .el-menu-item) {
|
||||
padding-left: 48px !important;
|
||||
margin: 2px 12px;
|
||||
padding-left: 40px !important;
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
.layout-header {
|
||||
@@ -514,20 +562,24 @@ const onCommand = (cmd: string) => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
padding: 0 10px;
|
||||
box-shadow: none;
|
||||
height: 64px !important;
|
||||
height: 52px !important;
|
||||
z-index: 9;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
background: #f7f8fb;
|
||||
}
|
||||
|
||||
.header-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
color: var(--ctms-text-regular);
|
||||
font-size: 14px;
|
||||
margin-left: 20px;
|
||||
font-size: 13px;
|
||||
margin-left: 14px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@@ -570,7 +622,7 @@ const onCommand = (cmd: string) => {
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
@@ -603,7 +655,7 @@ const onCommand = (cmd: string) => {
|
||||
border: 1px solid var(--ctms-border-color);
|
||||
color: var(--ctms-text-regular);
|
||||
transition: var(--ctms-transition);
|
||||
height: 40px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.dropdown-trigger.compact:hover {
|
||||
@@ -633,11 +685,13 @@ const onCommand = (cmd: string) => {
|
||||
.layout-main {
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
background: #f7f8fb;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
padding: 16px;
|
||||
min-height: calc(100vh - 64px);
|
||||
padding: 6px 8px;
|
||||
min-height: calc(100vh - 52px);
|
||||
background: #f7f8fb;
|
||||
}
|
||||
|
||||
/* 过渡动画 */
|
||||
|
||||
@@ -4,11 +4,14 @@
|
||||
<div class="lock-backdrop" />
|
||||
<div class="lock-panel">
|
||||
<h2 class="lock-title">已锁定</h2>
|
||||
<p class="lock-desc">30 分钟无操作,请输入密码解锁</p>
|
||||
<p class="lock-desc">30 分钟无操作将锁定,2 小时无操作将自动退出登录</p>
|
||||
<p class="lock-countdown">剩余自动登出:{{ autoLogoutCountdown }}</p>
|
||||
<el-input
|
||||
ref="passwordInput"
|
||||
v-model="password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
name="lock-password"
|
||||
placeholder="请输入登录密码"
|
||||
show-password
|
||||
@keyup.enter="onUnlock"
|
||||
@@ -24,11 +27,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, ref, watch } from "vue";
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import { useSessionStore } from "../store/session";
|
||||
import { UNLOCK_MAX_ATTEMPTS, unlockWithPassword, forceLogout } from "../session/sessionManager";
|
||||
import {
|
||||
AUTO_LOGOUT_TIMEOUT_HOURS,
|
||||
UNLOCK_MAX_ATTEMPTS,
|
||||
unlockWithPassword,
|
||||
forceLogout,
|
||||
} from "../session/sessionManager";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const session = useSessionStore();
|
||||
@@ -36,6 +44,41 @@ const password = ref("");
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const passwordInput = ref<{ focus?: () => void } | null>(null);
|
||||
const nowTs = ref(Date.now());
|
||||
let countdownTimer: number | null = null;
|
||||
|
||||
const pad2 = (value: number) => String(value).padStart(2, "0");
|
||||
|
||||
const autoLogoutCountdown = computed(() => {
|
||||
const fallbackDeadlineAt =
|
||||
Math.min(session.lastUserActiveAt, session.lastNetworkActiveAt) + AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000;
|
||||
const deadlineAt = session.lockAutoLogoutDeadlineAt || fallbackDeadlineAt;
|
||||
const remainMs = Math.max(0, deadlineAt - nowTs.value);
|
||||
const remainSec = Math.floor(remainMs / 1000);
|
||||
const hours = Math.floor(remainSec / 3600);
|
||||
const minutes = Math.floor((remainSec % 3600) / 60);
|
||||
const seconds = remainSec % 60;
|
||||
if (hours > 0) {
|
||||
return `${pad2(hours)}:${pad2(minutes)}:${pad2(seconds)}`;
|
||||
}
|
||||
return `${pad2(minutes)}:${pad2(seconds)}`;
|
||||
});
|
||||
|
||||
const startCountdown = () => {
|
||||
if (countdownTimer) {
|
||||
window.clearInterval(countdownTimer);
|
||||
}
|
||||
nowTs.value = Date.now();
|
||||
countdownTimer = window.setInterval(() => {
|
||||
nowTs.value = Date.now();
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const stopCountdown = () => {
|
||||
if (!countdownTimer) return;
|
||||
window.clearInterval(countdownTimer);
|
||||
countdownTimer = null;
|
||||
};
|
||||
|
||||
const focusPassword = () => {
|
||||
nextTick(() => {
|
||||
@@ -43,10 +86,23 @@ const focusPassword = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const resolveUnlockEmail = async () => {
|
||||
if (session.lockEmail) return session.lockEmail;
|
||||
if (auth.user?.email) return auth.user.email;
|
||||
const cachedEmail = localStorage.getItem("ctms_last_login_email");
|
||||
if (cachedEmail) return cachedEmail;
|
||||
try {
|
||||
const me = await auth.fetchMe();
|
||||
return me?.email || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const onUnlock = async () => {
|
||||
if (loading.value) return;
|
||||
errorMessage.value = "";
|
||||
const email = auth.user?.email;
|
||||
const email = await resolveUnlockEmail();
|
||||
if (!email) {
|
||||
ElMessage.error("无法获取用户信息,请重新登录");
|
||||
forceLogout();
|
||||
@@ -63,7 +119,7 @@ const onUnlock = async () => {
|
||||
password.value = "";
|
||||
} catch (err: any) {
|
||||
session.incrementUnlockAttempt();
|
||||
const message = err?.response?.data?.detail || err?.response?.data?.message || "密码错误";
|
||||
const message = err?.response?.data?.detail || err?.response?.data?.message || err?.message || "密码错误";
|
||||
errorMessage.value = message;
|
||||
if (session.unlockAttempts >= UNLOCK_MAX_ATTEMPTS) {
|
||||
ElMessage.error("错误次数过多,请重新登录");
|
||||
@@ -80,17 +136,33 @@ const onLogout = () => {
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
password.value = "";
|
||||
errorMessage.value = "";
|
||||
focusPassword();
|
||||
if (session.locked) {
|
||||
startCountdown();
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => session.locked,
|
||||
(value) => {
|
||||
if (value) {
|
||||
password.value = "";
|
||||
errorMessage.value = "";
|
||||
focusPassword();
|
||||
startCountdown();
|
||||
return;
|
||||
}
|
||||
password.value = "";
|
||||
errorMessage.value = "";
|
||||
stopCountdown();
|
||||
}
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
stopCountdown();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -135,6 +207,13 @@ watch(
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.lock-countdown {
|
||||
margin: -4px 0 0;
|
||||
color: #1d4ed8;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lock-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
deleteStudySetupConfigVersion,
|
||||
exportStudySetupConfigExcel,
|
||||
fetchStudySetupConfig,
|
||||
fetchStudySetupConfigVersions,
|
||||
importStudySetupConfigExcel,
|
||||
publishStudySetupConfig,
|
||||
rollbackStudySetupConfig,
|
||||
saveStudySetupConfig,
|
||||
} from "../api/studies";
|
||||
import type {
|
||||
StudySetupConfigPublishPayload,
|
||||
StudySetupConfigRollbackPayload,
|
||||
StudySetupConfigUpsertPayload,
|
||||
} from "../types/setupConfig";
|
||||
|
||||
export const useSetupConfig = () => {
|
||||
const getConfig = (studyId: string) => fetchStudySetupConfig(studyId);
|
||||
const saveConfig = (studyId: string, payload: StudySetupConfigUpsertPayload) => saveStudySetupConfig(studyId, payload);
|
||||
const publishConfig = (studyId: string, payload: StudySetupConfigPublishPayload) => publishStudySetupConfig(studyId, payload);
|
||||
const listVersions = (studyId: string) => fetchStudySetupConfigVersions(studyId);
|
||||
const rollbackConfig = (studyId: string, payload: StudySetupConfigRollbackPayload) => rollbackStudySetupConfig(studyId, payload);
|
||||
const deleteVersion = (studyId: string, targetVersion: number) => deleteStudySetupConfigVersion(studyId, targetVersion);
|
||||
const exportExcel = (studyId: string) => exportStudySetupConfigExcel(studyId);
|
||||
const importExcel = (studyId: string, payload: FormData) => importStudySetupConfigExcel(studyId, payload);
|
||||
|
||||
return {
|
||||
getConfig,
|
||||
saveConfig,
|
||||
publishConfig,
|
||||
listVersions,
|
||||
rollbackConfig,
|
||||
deleteVersion,
|
||||
exportExcel,
|
||||
importExcel,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -141,6 +141,9 @@ export const TEXT = {
|
||||
severity: "严重等级",
|
||||
causality: "关联性",
|
||||
outcome: "结局",
|
||||
aeType: "AE 类型",
|
||||
isSae: "是否 SAE",
|
||||
isSusar: "是否 SUSAR",
|
||||
submitDate: "递交日期",
|
||||
acceptDate: "受理日期",
|
||||
approvedDate: "批准日期",
|
||||
@@ -236,6 +239,11 @@ export const TEXT = {
|
||||
FINANCE_REJECTED: { label: "费用驳回", actionText: "驳回了费用", targetLabel: "费用" },
|
||||
IMP_CONFIRMED: { label: "药品出入库确认", actionText: "确认了药品出入库记录", targetLabel: "药品交易" },
|
||||
QUERY_RESOLVED: { label: "数据问题解决", actionText: "标记数据问题已解决", targetLabel: "数据问题" },
|
||||
CREATE_SETUP_CONFIG: { label: "初始化立项配置", actionText: "初始化了立项配置", targetLabel: "立项配置" },
|
||||
UPDATE_SETUP_CONFIG: { label: "保存立项配置", actionText: "更新了立项配置草稿", targetLabel: "立项配置" },
|
||||
PUBLISH_SETUP_CONFIG: { label: "发布立项配置", actionText: "发布了立项配置", targetLabel: "立项配置" },
|
||||
IMPORT_SETUP_CONFIG: { label: "导入立项配置", actionText: "导入了立项配置", targetLabel: "立项配置" },
|
||||
IMPORT_SETUP_CONFIG_EXCEL: { label: "导入立项配置(Excel)", actionText: "通过 Excel 导入了立项配置", targetLabel: "立项配置" },
|
||||
},
|
||||
},
|
||||
menu: {
|
||||
@@ -246,17 +254,27 @@ export const TEXT = {
|
||||
auditLogs: "审计日志",
|
||||
currentProject: "当前项目",
|
||||
projectOverview: "项目总览",
|
||||
projectMilestones: "项目里程碑",
|
||||
finance: "费用管理",
|
||||
financeContracts: "合同管理",
|
||||
financeSpecials: "特殊费用管理",
|
||||
feeContracts: "合同管理",
|
||||
feeSpecials: "特殊费用管理",
|
||||
drug: "药品管理",
|
||||
materialManagement: "物资管理",
|
||||
drugShipments: "药品流向管理",
|
||||
materialEquipment: "设备管理",
|
||||
materialOthers: "其他",
|
||||
fileVersionManagement: "文件版本管理",
|
||||
startupFeasibilityEthics: "立项与伦理",
|
||||
startupMeetingAuth: "启动与授权",
|
||||
subjects: "参与者管理",
|
||||
riskIssues: "风险问题",
|
||||
riskIssueSae: "AE/SAE",
|
||||
riskIssuePd: "PD",
|
||||
riskIssueMonitoringVisits: "监查访视问题",
|
||||
monitoringAudit: "监查稽查",
|
||||
etmf: "eTMF",
|
||||
monitoring: "监查",
|
||||
audit: "稽查",
|
||||
sharedLibrary: "共享库",
|
||||
@@ -316,6 +334,12 @@ export const TEXT = {
|
||||
notificationsSubtitle: "文件版本更新将推送在此处",
|
||||
notificationsEmpty: "暂无通知",
|
||||
},
|
||||
projectMilestones: {
|
||||
title: "项目里程碑",
|
||||
subtitle: "里程碑计划与执行跟踪",
|
||||
listTitle: "里程碑列表",
|
||||
emptyDescription: "项目里程碑模块正在建设中,敬请期待。",
|
||||
},
|
||||
financeContracts: {
|
||||
title: "合同费用",
|
||||
subtitle: "维护分中心合同费用与附件",
|
||||
@@ -443,6 +467,18 @@ export const TEXT = {
|
||||
detailTitle: "运输记录详情",
|
||||
detailSubtitle: "查看药品流向信息与附件",
|
||||
},
|
||||
materialEquipment: {
|
||||
title: "设备管理",
|
||||
subtitle: "设备台账与状态跟踪",
|
||||
listTitle: "设备列表",
|
||||
emptyDescription: "设备管理模块正在建设中,敬请期待。",
|
||||
},
|
||||
materialOthers: {
|
||||
title: "其他物资",
|
||||
subtitle: "其他物资登记与管理",
|
||||
listTitle: "物资列表",
|
||||
emptyDescription: "其他物资模块正在建设中,敬请期待。",
|
||||
},
|
||||
fileVersionManagement: {
|
||||
title: "文件版本管理",
|
||||
subtitle: "管理试验文档与版本审批",
|
||||
@@ -610,19 +646,55 @@ export const TEXT = {
|
||||
},
|
||||
subjectDetail: {
|
||||
title: "参与者详情",
|
||||
subtitle: "查看病史、访视与 AE 信息",
|
||||
subtitle: "查看病史、访视、AE 与 PD 信息",
|
||||
tabs: {
|
||||
history: "病史",
|
||||
visits: "访视",
|
||||
ae: "AE 信息",
|
||||
pd: "PD",
|
||||
},
|
||||
aeType: {
|
||||
ae: "AE",
|
||||
sae: "SAE",
|
||||
susar: "SUSAR",
|
||||
},
|
||||
emptyHistory: "暂无病史记录",
|
||||
emptyVisits: "暂无访视记录",
|
||||
emptyAe: "暂无 AE 记录",
|
||||
emptyPd: "暂无 PD 记录",
|
||||
dialogHistory: "病史记录",
|
||||
dialogVisit: "访视记录",
|
||||
dialogAe: "AE 记录",
|
||||
},
|
||||
riskIssues: {
|
||||
title: "风险问题",
|
||||
subtitle: "项目风险与问题跟踪管理",
|
||||
listTitle: "风险问题列表",
|
||||
emptyDescription: "风险问题模块正在建设中,敬请期待。",
|
||||
},
|
||||
riskIssueSae: {
|
||||
title: "AE/SAE",
|
||||
subtitle: "同步参与者 AE/SAE 信息列表",
|
||||
listTitle: "AE/SAE 列表",
|
||||
emptyDescription: "暂无 AE/SAE 记录",
|
||||
subjectNo: "参与者编号",
|
||||
toSubject: "查看参与者",
|
||||
},
|
||||
riskIssuePd: {
|
||||
title: "PD",
|
||||
subtitle: "同步参与者 PD 相关信息列表",
|
||||
listTitle: "PD 列表",
|
||||
emptyDescription: "暂无参与者记录",
|
||||
syncStatus: "同步状态",
|
||||
synced: "已同步",
|
||||
toSubject: "查看参与者",
|
||||
},
|
||||
riskIssueMonitoringVisits: {
|
||||
title: "监查访视问题",
|
||||
subtitle: "功能建设中",
|
||||
listTitle: "监查访视问题列表",
|
||||
emptyDescription: "监查访视问题模块正在建设中,敬请期待。",
|
||||
},
|
||||
knowledgeMedicalConsult: {
|
||||
title: "医学咨询",
|
||||
subtitle: "常见问题与答复",
|
||||
@@ -695,6 +767,18 @@ export const TEXT = {
|
||||
listTitle: "稽查记录列表",
|
||||
emptyDescription: "稽查模块正在准备基础框架,敬请期待。",
|
||||
},
|
||||
monitoringAudit: {
|
||||
title: "监查稽查",
|
||||
subtitle: "功能建设中",
|
||||
listTitle: "监查稽查列表",
|
||||
emptyDescription: "监查稽查模块正在建设中,敬请期待。",
|
||||
},
|
||||
etmf: {
|
||||
title: "eTMF",
|
||||
subtitle: "功能建设中",
|
||||
listTitle: "eTMF 列表",
|
||||
emptyDescription: "eTMF 模块正在建设中,敬请期待。",
|
||||
},
|
||||
adminUserApproval: {
|
||||
title: "注册审核",
|
||||
subtitle: "仅显示待审核账号,审核通过后即可登录",
|
||||
@@ -770,7 +854,6 @@ export const TEXT = {
|
||||
createdAt: "创建时间",
|
||||
newTitle: "新建项目",
|
||||
editTitle: "编辑项目",
|
||||
visitTemplate: "访视模板",
|
||||
sponsorPlaceholder: "申办方(可选)",
|
||||
protocolPlaceholder: "方案号(可选)",
|
||||
phasePlaceholder: "研究分期(可选)",
|
||||
@@ -780,6 +863,9 @@ export const TEXT = {
|
||||
detailTitle: "项目详情",
|
||||
basicInfo: "项目基本信息",
|
||||
filesTitle: "项目通用文件",
|
||||
setupStepCenterConfirm: "中心确认",
|
||||
setupSaveSuccess: "配置草稿已保存",
|
||||
setupSaveFailed: "配置草稿保存失败",
|
||||
lockConfirmPrompt: "请输入“确认锁定”以继续",
|
||||
lockConfirmTitle: "二次确认",
|
||||
lockConfirmPlaceholder: "请输入确认锁定",
|
||||
@@ -828,6 +914,7 @@ export const TEXT = {
|
||||
contactPlaceholder: "选择项目成员作为负责人,可多选",
|
||||
updateSuccess: "中心已更新",
|
||||
createSuccess: "中心已创建",
|
||||
leadUnitTag: "组长单位",
|
||||
disableConfirm: "该操作将影响中心数据,请确认是否继续",
|
||||
disableTitle: "停用中心",
|
||||
disableSuccess: "已停用",
|
||||
@@ -842,6 +929,7 @@ export const TEXT = {
|
||||
},
|
||||
adminAuditLogs: {
|
||||
title: "审计日志",
|
||||
filterEntityType: "对象类型",
|
||||
filterEvent: "操作类型",
|
||||
filterOperator: "操作人",
|
||||
filterResult: "结果",
|
||||
|
||||
@@ -6,6 +6,7 @@ import dayjs from "dayjs";
|
||||
import "dayjs/locale/zh-cn";
|
||||
import "element-plus/dist/index.css";
|
||||
import "./styles/main.css";
|
||||
import "./styles/unified-page.css";
|
||||
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
|
||||
@@ -17,6 +17,7 @@ import AdminSites from "../views/admin/Sites.vue";
|
||||
import ProjectDetail from "../views/admin/ProjectDetail.vue";
|
||||
import ProfileSettings from "../views/ProfileSettings.vue";
|
||||
import ProjectOverview from "../views/ia/ProjectOverview.vue";
|
||||
import ProjectMilestones from "../views/ia/ProjectMilestones.vue";
|
||||
import FinanceContracts from "../views/ia/FinanceContracts.vue";
|
||||
import FinanceSpecial from "../views/ia/FinanceSpecial.vue";
|
||||
import FeeContracts from "../views/fees/ContractFees.vue";
|
||||
@@ -26,14 +27,19 @@ import FeeSpecials from "../views/fees/SpecialExpenses.vue";
|
||||
import FeeSpecialForm from "../views/fees/SpecialExpenseForm.vue";
|
||||
import FeeSpecialDetail from "../views/fees/SpecialExpenseDetail.vue";
|
||||
import DrugShipments from "../views/ia/DrugShipments.vue";
|
||||
import MaterialEquipment from "../views/ia/MaterialEquipment.vue";
|
||||
import MaterialOthers from "../views/ia/MaterialOthers.vue";
|
||||
import FileVersionManagement from "../views/ia/FileVersionManagement.vue";
|
||||
import DocumentList from "../views/documents/DocumentList.vue";
|
||||
import DocumentDetail from "../views/documents/DocumentDetail.vue";
|
||||
import StartupFeasibilityEthics from "../views/ia/StartupFeasibilityEthics.vue";
|
||||
import StartupMeetingAuth from "../views/ia/StartupMeetingAuth.vue";
|
||||
import SubjectManagement from "../views/ia/SubjectManagement.vue";
|
||||
import MonitoringPlaceholder from "../views/ia/MonitoringPlaceholder.vue";
|
||||
import AuditPlaceholder from "../views/ia/AuditPlaceholder.vue";
|
||||
import RiskIssueSae from "../views/ia/RiskIssueSae.vue";
|
||||
import RiskIssuePd from "../views/ia/RiskIssuePd.vue";
|
||||
import RiskIssueMonitoringVisits from "../views/ia/RiskIssueMonitoringVisits.vue";
|
||||
import MonitoringAuditPlaceholder from "../views/ia/MonitoringAuditPlaceholder.vue";
|
||||
import EtmfPlaceholder from "../views/ia/EtmfPlaceholder.vue";
|
||||
import KnowledgeMedicalConsult from "../views/ia/KnowledgeMedicalConsult.vue";
|
||||
import KnowledgeNotes from "../views/ia/KnowledgeNotes.vue";
|
||||
import KnowledgeSupportFiles from "../views/ia/KnowledgeSupportFiles.vue";
|
||||
@@ -100,6 +106,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: ProjectOverview,
|
||||
meta: { title: TEXT.menu.projectOverview, requiresStudy: true },
|
||||
},
|
||||
{
|
||||
path: "project/milestones",
|
||||
name: "ProjectMilestones",
|
||||
component: ProjectMilestones,
|
||||
meta: { title: TEXT.menu.projectMilestones, requiresStudy: true },
|
||||
},
|
||||
{
|
||||
path: "finance/contracts",
|
||||
name: "FinanceContracts",
|
||||
@@ -202,6 +214,18 @@ const routes: RouteRecordRaw[] = [
|
||||
component: DrugShipments,
|
||||
meta: { title: TEXT.menu.drugShipments, requiresStudy: true },
|
||||
},
|
||||
{
|
||||
path: "materials/equipment",
|
||||
name: "MaterialEquipment",
|
||||
component: MaterialEquipment,
|
||||
meta: { title: TEXT.menu.materialEquipment, requiresStudy: true },
|
||||
},
|
||||
{
|
||||
path: "materials/others",
|
||||
name: "MaterialOthers",
|
||||
component: MaterialOthers,
|
||||
meta: { title: TEXT.menu.materialOthers, requiresStudy: true },
|
||||
},
|
||||
{
|
||||
path: "drug/shipments/new",
|
||||
name: "DrugShipmentNew",
|
||||
@@ -346,17 +370,47 @@ const routes: RouteRecordRaw[] = [
|
||||
component: SubjectForm,
|
||||
meta: { title: TEXT.common.actions.edit + TEXT.menu.subjects, requiresStudy: true },
|
||||
},
|
||||
{
|
||||
path: "risk-issues",
|
||||
redirect: "/risk-issues/sae",
|
||||
},
|
||||
{
|
||||
path: "risk-issues/sae",
|
||||
name: "RiskIssueSae",
|
||||
component: RiskIssueSae,
|
||||
meta: { title: TEXT.menu.riskIssueSae, requiresStudy: true },
|
||||
},
|
||||
{
|
||||
path: "risk-issues/pd",
|
||||
name: "RiskIssuePd",
|
||||
component: RiskIssuePd,
|
||||
meta: { title: TEXT.menu.riskIssuePd, requiresStudy: true },
|
||||
},
|
||||
{
|
||||
path: "risk-issues/monitoring-visits",
|
||||
name: "RiskIssueMonitoringVisits",
|
||||
component: RiskIssueMonitoringVisits,
|
||||
meta: { title: TEXT.menu.riskIssueMonitoringVisits, requiresStudy: true },
|
||||
},
|
||||
{
|
||||
path: "monitoring-audit",
|
||||
name: "MonitoringAuditPlaceholder",
|
||||
component: MonitoringAuditPlaceholder,
|
||||
meta: { title: TEXT.menu.monitoringAudit, requiresStudy: true },
|
||||
},
|
||||
{
|
||||
path: "monitoring",
|
||||
name: "MonitoringPlaceholder",
|
||||
component: MonitoringPlaceholder,
|
||||
meta: { title: TEXT.menu.monitoring, requiresStudy: true },
|
||||
redirect: "/monitoring-audit",
|
||||
},
|
||||
{
|
||||
path: "audit",
|
||||
name: "AuditPlaceholder",
|
||||
component: AuditPlaceholder,
|
||||
meta: { title: TEXT.menu.audit, requiresStudy: true },
|
||||
redirect: "/monitoring-audit",
|
||||
},
|
||||
{
|
||||
path: "etmf",
|
||||
name: "EtmfPlaceholder",
|
||||
component: EtmfPlaceholder,
|
||||
meta: { title: TEXT.menu.etmf, requiresStudy: true },
|
||||
},
|
||||
{
|
||||
path: "knowledge/medical-consult",
|
||||
@@ -431,6 +485,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: AdminProjects,
|
||||
meta: { title: TEXT.menu.projectManagement, requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "projects/:projectId",
|
||||
name: "AdminProjectDetail",
|
||||
component: ProjectDetail,
|
||||
meta: { title: TEXT.modules.adminProjects.detailTitle, requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "projects/:projectId/members",
|
||||
name: "AdminProjectMembers",
|
||||
@@ -453,9 +513,7 @@ const routes: RouteRecordRaw[] = [
|
||||
},
|
||||
{
|
||||
path: "/projects/:projectId",
|
||||
name: "ProjectDetail",
|
||||
component: ProjectDetail,
|
||||
meta: { title: TEXT.modules.adminProjects.detailTitle, requiresAdmin: true },
|
||||
redirect: (to) => `/admin/projects/${to.params.projectId}`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -468,14 +526,18 @@ router.beforeEach(async (to, _from, next) => {
|
||||
const auth = useAuthStore();
|
||||
const studyStore = useStudyStore();
|
||||
const getToken = () => auth.token;
|
||||
let token = getToken();
|
||||
if (!auth.user && getToken()) {
|
||||
try {
|
||||
await auth.fetchMe();
|
||||
} catch {
|
||||
// 由统一的认证处理流程处理 401/403
|
||||
// 有 token 但无法获取用户,强制回登录,避免进入“无用户上下文”页面
|
||||
auth.logout();
|
||||
next({ path: "/login" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const token = getToken();
|
||||
token = getToken();
|
||||
const isAdmin = auth.user?.role === "ADMIN";
|
||||
if (token && !studyStore.currentStudy) {
|
||||
if (isAdmin) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ElMessage } from "element-plus";
|
||||
import router from "../router";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import { useSessionStore } from "../store/session";
|
||||
@@ -7,16 +6,19 @@ import { extendToken, unlockSession } from "../api/authClient";
|
||||
import { parseJwtExp } from "./jwt";
|
||||
|
||||
export const IDLE_TIMEOUT_MINUTES = 30;
|
||||
export const AUTO_LOGOUT_TIMEOUT_HOURS = 2;
|
||||
export const EXTEND_EARLY_SECONDS = 120;
|
||||
export const EXTEND_MIN_INTERVAL_SECONDS = 60;
|
||||
export const UNLOCK_MAX_ATTEMPTS = 5;
|
||||
export const LOGOUT_REASON_TIMEOUT = "timeout";
|
||||
const LOGOUT_REASON_STORAGE_KEY = "ctms_logout_reason";
|
||||
|
||||
type BroadcastMessage =
|
||||
| { type: "ACTIVE"; at: number }
|
||||
| { type: "NETWORK"; at: number }
|
||||
| { type: "LOCK"; reason: string }
|
||||
| { type: "LOCK"; reason: string; email?: string; deadlineAt?: number }
|
||||
| { type: "TOKEN_UPDATED"; token: string }
|
||||
| { type: "LOGOUT" };
|
||||
| { type: "LOGOUT"; reason?: string };
|
||||
|
||||
let idleTimer: number | null = null;
|
||||
let extendPromise: Promise<{ token: string | null; authFailed: boolean }> | null = null;
|
||||
@@ -38,16 +40,19 @@ const handleBroadcast = (message: BroadcastMessage) => {
|
||||
const session = useSessionStore();
|
||||
const auth = useAuthStore();
|
||||
if (message.type === "ACTIVE") {
|
||||
if (session.locked) return;
|
||||
session.recordUserActivity(message.at);
|
||||
scheduleIdleCheck();
|
||||
}
|
||||
if (message.type === "NETWORK") {
|
||||
if (session.locked) return;
|
||||
session.recordNetworkActivity(message.at);
|
||||
scheduleIdleCheck();
|
||||
}
|
||||
if (message.type === "LOCK") {
|
||||
const reason = message.reason as "idle" | "extend_failed" | "token_invalid";
|
||||
session.lock(reason || "idle");
|
||||
session.lock(reason || "idle", message.email, message.deadlineAt);
|
||||
scheduleIdleCheck();
|
||||
}
|
||||
if (message.type === "TOKEN_UPDATED") {
|
||||
auth.setToken(message.token);
|
||||
@@ -55,7 +60,7 @@ const handleBroadcast = (message: BroadcastMessage) => {
|
||||
session.unlock();
|
||||
}
|
||||
if (message.type === "LOGOUT") {
|
||||
forceLogout();
|
||||
performLogout(message.reason);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -64,9 +69,22 @@ const scheduleIdleCheck = () => {
|
||||
window.clearTimeout(idleTimer);
|
||||
}
|
||||
const session = useSessionStore();
|
||||
const timeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000;
|
||||
const nextCheck =
|
||||
Math.min(session.lastUserActiveAt, session.lastNetworkActiveAt) + timeoutMs - Date.now();
|
||||
const now = Date.now();
|
||||
if (session.locked) {
|
||||
const remain = (session.lockAutoLogoutDeadlineAt || 0) - now;
|
||||
if (remain <= 0) {
|
||||
checkIdle();
|
||||
return;
|
||||
}
|
||||
idleTimer = window.setTimeout(checkIdle, remain);
|
||||
return;
|
||||
}
|
||||
const idleTimeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000;
|
||||
const autoLogoutMs = AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000;
|
||||
const baseActiveAt = Math.min(session.lastUserActiveAt, session.lastNetworkActiveAt);
|
||||
const idleRemain = baseActiveAt + idleTimeoutMs - now;
|
||||
const logoutRemain = baseActiveAt + autoLogoutMs - now;
|
||||
const nextCheck = Math.min(idleRemain, logoutRemain);
|
||||
if (nextCheck <= 0) {
|
||||
checkIdle();
|
||||
return;
|
||||
@@ -76,13 +94,29 @@ const scheduleIdleCheck = () => {
|
||||
|
||||
const checkIdle = () => {
|
||||
const session = useSessionStore();
|
||||
const timeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000;
|
||||
const now = Date.now();
|
||||
if (now - session.lastUserActiveAt > timeoutMs && now - session.lastNetworkActiveAt > timeoutMs) {
|
||||
lockSession("idle");
|
||||
if (session.locked) {
|
||||
const deadlineAt = session.lockAutoLogoutDeadlineAt || 0;
|
||||
if (deadlineAt > 0 && Date.now() >= deadlineAt) {
|
||||
forceLogout(LOGOUT_REASON_TIMEOUT);
|
||||
return;
|
||||
}
|
||||
scheduleIdleCheck();
|
||||
return;
|
||||
}
|
||||
const idleTimeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000;
|
||||
const autoLogoutMs = AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
const idleElapsed = now - session.lastUserActiveAt > idleTimeoutMs && now - session.lastNetworkActiveAt > idleTimeoutMs;
|
||||
const autoLogoutElapsed =
|
||||
now - session.lastUserActiveAt > autoLogoutMs && now - session.lastNetworkActiveAt > autoLogoutMs;
|
||||
if (autoLogoutElapsed) {
|
||||
forceLogout(LOGOUT_REASON_TIMEOUT);
|
||||
return;
|
||||
}
|
||||
if (idleElapsed) {
|
||||
lockSession("idle");
|
||||
}
|
||||
scheduleIdleCheck();
|
||||
};
|
||||
|
||||
export const initSessionManager = () => {
|
||||
@@ -115,6 +149,7 @@ export const initSessionManager = () => {
|
||||
|
||||
export const markUserActive = (at: number = Date.now()) => {
|
||||
const session = useSessionStore();
|
||||
if (session.locked) return;
|
||||
session.recordUserActivity(at);
|
||||
broadcast({ type: "ACTIVE", at });
|
||||
scheduleIdleCheck();
|
||||
@@ -122,6 +157,7 @@ export const markUserActive = (at: number = Date.now()) => {
|
||||
|
||||
export const markNetworkActive = (at: number = Date.now()) => {
|
||||
const session = useSessionStore();
|
||||
if (session.locked) return;
|
||||
session.recordNetworkActivity(at);
|
||||
broadcast({ type: "NETWORK", at });
|
||||
scheduleIdleCheck();
|
||||
@@ -129,21 +165,53 @@ export const markNetworkActive = (at: number = Date.now()) => {
|
||||
|
||||
export const lockSession = (reason: "idle" | "extend_failed" | "token_invalid") => {
|
||||
const session = useSessionStore();
|
||||
const auth = useAuthStore();
|
||||
if (session.locked) return;
|
||||
session.lock(reason);
|
||||
broadcast({ type: "LOCK", reason });
|
||||
const email = auth.user?.email || "";
|
||||
const autoLogoutMs = AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000;
|
||||
const deadlineAt = Math.min(session.lastUserActiveAt, session.lastNetworkActiveAt) + autoLogoutMs;
|
||||
session.lock(reason, email, deadlineAt);
|
||||
broadcast({ type: "LOCK", reason, email, deadlineAt });
|
||||
scheduleIdleCheck();
|
||||
};
|
||||
|
||||
export const forceLogout = () => {
|
||||
const setLogoutReason = (reason?: string) => {
|
||||
try {
|
||||
if (!reason) {
|
||||
sessionStorage.removeItem(LOGOUT_REASON_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
sessionStorage.setItem(LOGOUT_REASON_STORAGE_KEY, reason);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
export const consumeLogoutReason = () => {
|
||||
try {
|
||||
const reason = sessionStorage.getItem(LOGOUT_REASON_STORAGE_KEY);
|
||||
sessionStorage.removeItem(LOGOUT_REASON_STORAGE_KEY);
|
||||
return reason;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const performLogout = (reason?: string) => {
|
||||
setLogoutReason(reason);
|
||||
const auth = useAuthStore();
|
||||
const session = useSessionStore();
|
||||
auth.logout();
|
||||
session.unlock();
|
||||
clearToken();
|
||||
broadcast({ type: "LOGOUT" });
|
||||
router.replace("/login");
|
||||
};
|
||||
|
||||
export const forceLogout = (reason?: string) => {
|
||||
performLogout(reason);
|
||||
broadcast({ type: "LOGOUT", reason });
|
||||
};
|
||||
|
||||
const updateToken = (token: string) => {
|
||||
const auth = useAuthStore();
|
||||
auth.setToken(token);
|
||||
@@ -205,8 +273,16 @@ export const startTokenKeepAlive = () => {
|
||||
|
||||
export const unlockWithPassword = async (email: string, password: string) => {
|
||||
const session = useSessionStore();
|
||||
const auth = useAuthStore();
|
||||
const { data } = await unlockSession({ email, password });
|
||||
updateToken(data.accessToken);
|
||||
session.unlock();
|
||||
try {
|
||||
await auth.fetchMe();
|
||||
} catch {
|
||||
// token已更新但用户信息拉取失败时,避免界面进入无角色状态
|
||||
forceLogout();
|
||||
throw new Error("无法获取用户信息,请重新登录");
|
||||
}
|
||||
markUserActive();
|
||||
};
|
||||
|
||||
@@ -4,8 +4,10 @@ import { login as apiLogin, fetchMe } from "../api/auth";
|
||||
import { setToken, clearToken, getToken } from "../utils/auth";
|
||||
import type { UserInfo } from "../types/api";
|
||||
import { useStudyStore } from "./study";
|
||||
import { useSessionStore } from "./session";
|
||||
|
||||
export const useAuthStore = defineStore("auth", () => {
|
||||
const LAST_LOGIN_EMAIL_KEY = "ctms_last_login_email";
|
||||
const token = ref<string | null>(getToken());
|
||||
const user = ref<UserInfo | null>(null);
|
||||
const loading = ref(false);
|
||||
@@ -17,6 +19,9 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
const { data } = await apiLogin({ email, password });
|
||||
token.value = data.access_token;
|
||||
setToken(data.access_token);
|
||||
// 避免锁屏态下 fetchMe 被请求拦截
|
||||
useSessionStore().unlock();
|
||||
localStorage.setItem(LAST_LOGIN_EMAIL_KEY, email);
|
||||
await fetchMeAction();
|
||||
forceLogin.value = false;
|
||||
} finally {
|
||||
@@ -27,6 +32,9 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
const fetchMeAction = async () => {
|
||||
const { data } = await fetchMe();
|
||||
user.value = data;
|
||||
if (data?.email) {
|
||||
localStorage.setItem(LAST_LOGIN_EMAIL_KEY, data.email);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ export type LockReason = "idle" | "extend_failed" | "token_invalid";
|
||||
export const useSessionStore = defineStore("session", () => {
|
||||
const locked = ref(false);
|
||||
const lockReason = ref<LockReason | null>(null);
|
||||
const lockEmail = ref<string>("");
|
||||
const unlockAttempts = ref(0);
|
||||
const lockAutoLogoutDeadlineAt = ref(0);
|
||||
const lastUserActiveAt = ref(Date.now());
|
||||
const lastNetworkActiveAt = ref(Date.now());
|
||||
const lastExtendAt = ref(0);
|
||||
@@ -19,14 +21,18 @@ export const useSessionStore = defineStore("session", () => {
|
||||
lastNetworkActiveAt.value = ts;
|
||||
};
|
||||
|
||||
const lock = (reason: LockReason) => {
|
||||
const lock = (reason: LockReason, email?: string, autoLogoutDeadlineAt?: number) => {
|
||||
locked.value = true;
|
||||
lockReason.value = reason;
|
||||
lockEmail.value = (email || "").trim();
|
||||
lockAutoLogoutDeadlineAt.value = autoLogoutDeadlineAt || 0;
|
||||
};
|
||||
|
||||
const unlock = () => {
|
||||
locked.value = false;
|
||||
lockReason.value = null;
|
||||
lockEmail.value = "";
|
||||
lockAutoLogoutDeadlineAt.value = 0;
|
||||
unlockAttempts.value = 0;
|
||||
};
|
||||
|
||||
@@ -41,6 +47,8 @@ export const useSessionStore = defineStore("session", () => {
|
||||
return {
|
||||
locked,
|
||||
lockReason,
|
||||
lockEmail,
|
||||
lockAutoLogoutDeadlineAt,
|
||||
unlockAttempts,
|
||||
lastUserActiveAt,
|
||||
lastNetworkActiveAt,
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
:root {
|
||||
--unified-shell-bg: #ffffff;
|
||||
--unified-shell-border: #d8e2ef;
|
||||
--unified-shell-divider: #eaf0f8;
|
||||
--unified-shell-radius: 16px;
|
||||
--unified-shell-padding-x: 20px;
|
||||
--unified-shell-padding-y: 14px;
|
||||
--unified-title-color: #0f2345;
|
||||
--unified-muted-color: #6f84a8;
|
||||
}
|
||||
|
||||
.unified-shell {
|
||||
background: var(--unified-shell-bg);
|
||||
border: 0;
|
||||
border-radius: var(--unified-shell-radius);
|
||||
overflow: hidden;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.el-card.unified-shell {
|
||||
margin-bottom: 0;
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.main-content-card.unified-shell {
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.el-card.unified-shell > .el-card__body {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.unified-action-bar {
|
||||
padding: var(--unified-shell-padding-y) var(--unified-shell-padding-x);
|
||||
border-bottom: 1px solid var(--unified-shell-divider);
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.ctms-page > .ctms-page-header.unified-action-bar,
|
||||
.page > .page-header.unified-action-bar {
|
||||
border: 0;
|
||||
border-radius: var(--unified-shell-radius);
|
||||
background: #ffffff;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.unified-section {
|
||||
padding: var(--unified-shell-padding-y) var(--unified-shell-padding-x);
|
||||
border-bottom: 1px solid var(--unified-shell-divider);
|
||||
}
|
||||
|
||||
.unified-section:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.unified-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.unified-shell .ctms-section-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.unified-action-bar .ctms-page-actions,
|
||||
.unified-action-bar .actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.unified-action-bar .ctms-page-title,
|
||||
.unified-action-bar .page-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--unified-title-color);
|
||||
}
|
||||
|
||||
.unified-action-bar .ctms-page-subtitle,
|
||||
.unified-action-bar .page-subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--unified-muted-color);
|
||||
}
|
||||
|
||||
.filter-container.unified-action-bar .filter-form,
|
||||
.filter-container.unified-action-bar .ctms-filter-row {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.unified-shell .el-table th.el-table__cell {
|
||||
background-color: #f8fbff;
|
||||
color: #35527d;
|
||||
font-weight: 600;
|
||||
height: 42px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.unified-shell .el-table td.el-table__cell {
|
||||
border-bottom-color: #edf2f8;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.unified-shell .el-button,
|
||||
.unified-action-bar .el-button {
|
||||
border-radius: 10px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.unified-shell .el-tabs__nav-wrap::after {
|
||||
background-color: #edf2f8;
|
||||
}
|
||||
|
||||
.unified-shell .el-tabs__item {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.unified-shell .el-form-item {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.unified-shell .el-form-item__label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--unified-muted-color);
|
||||
}
|
||||
|
||||
.unified-shell .filter-form,
|
||||
.unified-shell .ctms-filter-row {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.unified-shell .ctms-table-card,
|
||||
.unified-shell .ctms-section-card,
|
||||
.unified-shell .detail-card,
|
||||
.unified-shell .section-card,
|
||||
.unified-shell .form-card {
|
||||
margin-bottom: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.unified-shell .ctms-table-card + .ctms-table-card,
|
||||
.unified-shell .ctms-section-card + .ctms-section-card,
|
||||
.unified-shell .detail-card + .section-card,
|
||||
.unified-shell .section-card + .section-card,
|
||||
.unified-shell .form-card + .form-card {
|
||||
border-top: 1px solid var(--unified-shell-divider);
|
||||
}
|
||||
|
||||
.unified-shell .ctms-section-title,
|
||||
.unified-shell .section-title,
|
||||
.unified-shell .header-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--unified-title-color);
|
||||
}
|
||||
|
||||
.unified-shell .text-secondary,
|
||||
.unified-shell .info-label {
|
||||
color: var(--unified-muted-color);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.unified-action-bar,
|
||||
.unified-section {
|
||||
padding-left: 14px;
|
||||
padding-right: 14px;
|
||||
}
|
||||
}
|
||||
@@ -61,8 +61,26 @@ export interface Study {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
project_full_name?: string | null;
|
||||
sponsor?: string | null;
|
||||
protocol_no?: string | null;
|
||||
lead_unit?: string | null;
|
||||
principal_investigator?: string | null;
|
||||
main_pm?: string | null;
|
||||
research_analysis?: string | null;
|
||||
research_product?: string | null;
|
||||
control_product?: string | null;
|
||||
indication?: string | null;
|
||||
research_population?: string | null;
|
||||
research_design?: string | null;
|
||||
plan_start_date?: string | null;
|
||||
plan_end_date?: string | null;
|
||||
planned_site_count?: number | null;
|
||||
planned_enrollment_count?: number | null;
|
||||
enrollment_monthly_goal_note?: string | null;
|
||||
enrollment_stage_breakdown?: string | null;
|
||||
summary_note?: string | null;
|
||||
objective_note?: string | null;
|
||||
phase?: string | null;
|
||||
status: string;
|
||||
is_locked?: boolean;
|
||||
@@ -92,6 +110,9 @@ export interface Site {
|
||||
pi_name?: string | null;
|
||||
contact?: string | null;
|
||||
enrollment_target?: number | null;
|
||||
enrollment_plan_start_date?: string | null;
|
||||
enrollment_plan_end_date?: string | null;
|
||||
enrollment_plan_note?: string | null;
|
||||
is_active: boolean;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
export interface ProjectMilestoneDraft {
|
||||
id: string;
|
||||
name: string;
|
||||
planDate: string;
|
||||
owner: string;
|
||||
remark: string;
|
||||
status: "未开始" | "进行中" | "已完成" | string;
|
||||
}
|
||||
|
||||
export interface EnrollmentPlanDraft {
|
||||
totalTarget: number;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
monthlyGoalNote: string;
|
||||
stageBreakdown: string;
|
||||
}
|
||||
|
||||
export interface SiteMilestoneDraft {
|
||||
id: string;
|
||||
milestone: string;
|
||||
planDate: string;
|
||||
owner: string;
|
||||
remark: string;
|
||||
status: "未开始" | "进行中" | "已完成" | string;
|
||||
}
|
||||
|
||||
export interface SiteEnrollmentPlanDraft {
|
||||
id: string;
|
||||
siteId: string;
|
||||
siteName: string;
|
||||
target: number;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
note: string;
|
||||
stageBreakdown: string;
|
||||
}
|
||||
|
||||
export interface MonitoringStrategyDraft {
|
||||
id: string;
|
||||
strategyType: string;
|
||||
detail: string;
|
||||
frequency: string;
|
||||
updatedAt: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface CenterConfirmDraft {
|
||||
id: string;
|
||||
siteId: string;
|
||||
siteName: string;
|
||||
confirmer: string;
|
||||
confirmStatus: "待确认" | "已确认" | "退回" | string;
|
||||
confirmDate: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface SetupConfigDraft {
|
||||
projectMilestones: ProjectMilestoneDraft[];
|
||||
enrollmentPlan: EnrollmentPlanDraft;
|
||||
siteMilestones: SiteMilestoneDraft[];
|
||||
siteEnrollmentPlans: SiteEnrollmentPlanDraft[];
|
||||
monitoringStrategies: MonitoringStrategyDraft[];
|
||||
centerConfirm: CenterConfirmDraft[];
|
||||
}
|
||||
|
||||
export interface StudySetupConfigResponse {
|
||||
id: string;
|
||||
study_id: string;
|
||||
version: number;
|
||||
data: SetupConfigDraft;
|
||||
publish_status: "DRAFT" | "PUBLISHED" | string;
|
||||
published_data?: SetupConfigDraft | null;
|
||||
published_by?: string | null;
|
||||
published_by_name?: string | null;
|
||||
published_at?: string | null;
|
||||
saved_by?: string | null;
|
||||
saved_by_name?: string | null;
|
||||
projection_status?: "success" | "partial_success" | "failed" | string | null;
|
||||
projection_summary?: {
|
||||
study_updated?: boolean;
|
||||
site_updated_count?: number;
|
||||
site_skipped_count?: number;
|
||||
warnings?: string[];
|
||||
skipped_items?: Array<{ site_id: string; reason: string }>;
|
||||
} | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface StudySetupConfigUpsertPayload {
|
||||
expected_version?: number | null;
|
||||
data: SetupConfigDraft;
|
||||
}
|
||||
|
||||
export interface StudySetupConfigPublishPayload {
|
||||
expected_version?: number | null;
|
||||
}
|
||||
|
||||
export interface StudySetupConfigRollbackPayload {
|
||||
expected_version?: number | null;
|
||||
target_version: number;
|
||||
}
|
||||
|
||||
export interface StudySetupConfigVersionItem {
|
||||
id: string;
|
||||
study_id: string;
|
||||
study_setup_config_id: string;
|
||||
version: number;
|
||||
display_version: number;
|
||||
source_version?: number | null;
|
||||
config: SetupConfigDraft;
|
||||
published_by?: string | null;
|
||||
published_by_name?: string | null;
|
||||
published_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { SetupConfigDraft } from "../types/setupConfig";
|
||||
|
||||
export type SetupDiffRow = {
|
||||
moduleLabel: string;
|
||||
path: string;
|
||||
changeType: "新增" | "删除" | "修改";
|
||||
localValue: string;
|
||||
serverValue: string;
|
||||
};
|
||||
|
||||
export type SetupModuleLabel = {
|
||||
key: keyof SetupConfigDraft;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const setupFieldLabelMap: Record<keyof SetupConfigDraft, Record<string, string>> = {
|
||||
projectMilestones: {
|
||||
id: "ID",
|
||||
name: "里程碑",
|
||||
planDate: "计划日期",
|
||||
owner: "负责人",
|
||||
remark: "备注",
|
||||
status: "状态",
|
||||
},
|
||||
enrollmentPlan: {
|
||||
totalTarget: "计划总入组例数",
|
||||
startDate: "计划开始日期",
|
||||
endDate: "计划结束日期",
|
||||
monthlyGoalNote: "月度目标说明",
|
||||
stageBreakdown: "分阶段计划",
|
||||
},
|
||||
siteMilestones: {
|
||||
id: "ID",
|
||||
milestone: "里程碑",
|
||||
planDate: "计划日期",
|
||||
owner: "负责人",
|
||||
remark: "备注",
|
||||
},
|
||||
siteEnrollmentPlans: {
|
||||
id: "ID",
|
||||
siteId: "中心ID",
|
||||
siteName: "中心名称",
|
||||
target: "计划例数",
|
||||
startDate: "启动日期",
|
||||
endDate: "完成日期",
|
||||
note: "备注",
|
||||
stageBreakdown: "分阶段计划",
|
||||
},
|
||||
monitoringStrategies: {
|
||||
id: "ID",
|
||||
strategyType: "监查类型",
|
||||
detail: "策略详情",
|
||||
frequency: "监查次数",
|
||||
updatedAt: "更新时间",
|
||||
enabled: "是否启用",
|
||||
},
|
||||
centerConfirm: {
|
||||
id: "ID",
|
||||
siteId: "中心ID",
|
||||
siteName: "中心名称",
|
||||
confirmer: "确认人",
|
||||
confirmStatus: "确认状态",
|
||||
confirmDate: "确认日期",
|
||||
note: "备注",
|
||||
},
|
||||
};
|
||||
|
||||
const formatReadablePath = (moduleKey: keyof SetupConfigDraft, path: string): string => {
|
||||
const normalized = path.startsWith(`${moduleKey}.`) ? path.slice(moduleKey.length + 1) : path;
|
||||
if (!normalized || normalized === moduleKey) return "根节点";
|
||||
const parts = normalized.split(".");
|
||||
const labels = parts.map((part, idx) => {
|
||||
const match = part.match(/^([^\[]+)\[(\d+)\]$/);
|
||||
if (match) {
|
||||
const fieldKey = match[1];
|
||||
const rowNo = Number(match[2]) + 1;
|
||||
const label = setupFieldLabelMap[moduleKey][fieldKey] || fieldKey;
|
||||
return idx === 0 ? `${label} 第${rowNo}行` : `第${rowNo}行`;
|
||||
}
|
||||
return setupFieldLabelMap[moduleKey][part] || part;
|
||||
});
|
||||
return labels.join(" / ");
|
||||
};
|
||||
|
||||
export const serializeDiffValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined) return "-";
|
||||
if (typeof value === "string") return value || '""';
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return JSON.stringify(value);
|
||||
};
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
|
||||
const collectDiffRows = (
|
||||
moduleLabel: string,
|
||||
localValue: unknown,
|
||||
serverValue: unknown,
|
||||
path: string,
|
||||
rows: SetupDiffRow[]
|
||||
) => {
|
||||
if (Array.isArray(localValue) || Array.isArray(serverValue)) {
|
||||
const localArr = Array.isArray(localValue) ? localValue : [];
|
||||
const serverArr = Array.isArray(serverValue) ? serverValue : [];
|
||||
const maxLen = Math.max(localArr.length, serverArr.length);
|
||||
for (let i = 0; i < maxLen; i += 1) {
|
||||
collectDiffRows(moduleLabel, localArr[i], serverArr[i], `${path}[${i}]`, rows);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlainObject(localValue) || isPlainObject(serverValue)) {
|
||||
const localObj = isPlainObject(localValue) ? localValue : {};
|
||||
const serverObj = isPlainObject(serverValue) ? serverValue : {};
|
||||
const keys = Array.from(new Set([...Object.keys(localObj), ...Object.keys(serverObj)]));
|
||||
keys.forEach((key) => {
|
||||
const childPath = path ? `${path}.${key}` : key;
|
||||
collectDiffRows(moduleLabel, localObj[key], serverObj[key], childPath, rows);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (localValue === serverValue) return;
|
||||
const hasLocal = localValue !== undefined;
|
||||
const hasServer = serverValue !== undefined;
|
||||
const changeType: SetupDiffRow["changeType"] = hasLocal && hasServer ? "修改" : hasLocal ? "新增" : "删除";
|
||||
rows.push({
|
||||
moduleLabel,
|
||||
path: path || "(root)",
|
||||
changeType,
|
||||
localValue: serializeDiffValue(localValue),
|
||||
serverValue: serializeDiffValue(serverValue),
|
||||
});
|
||||
};
|
||||
|
||||
export const buildSetupReadableDiffRows = (
|
||||
localDraft: SetupConfigDraft | null,
|
||||
serverDraft: SetupConfigDraft | null,
|
||||
moduleLabels: SetupModuleLabel[],
|
||||
limit = 300
|
||||
): SetupDiffRow[] => {
|
||||
if (!localDraft || !serverDraft) return [];
|
||||
const rows: SetupDiffRow[] = [];
|
||||
moduleLabels.forEach((item) => {
|
||||
collectDiffRows(item.label, localDraft[item.key], serverDraft[item.key], String(item.key), rows);
|
||||
});
|
||||
return rows.slice(0, limit).map((row) => {
|
||||
const moduleKey = moduleLabels.find((item) => item.label === row.moduleLabel)?.key;
|
||||
if (!moduleKey) return row;
|
||||
return {
|
||||
...row,
|
||||
path: formatReadablePath(moduleKey, row.path),
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
export type SetupValidationError = {
|
||||
field: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ParsedFieldPath = {
|
||||
section: string;
|
||||
index: number;
|
||||
field: string;
|
||||
};
|
||||
|
||||
export const parseFieldPath = (path: string): ParsedFieldPath | null => {
|
||||
const matched = path.match(/^([a-zA-Z0-9_]+)\[(\d+)\]\.([a-zA-Z0-9_]+)$/);
|
||||
if (!matched) return null;
|
||||
return {
|
||||
section: matched[1],
|
||||
index: Number(matched[2]),
|
||||
field: matched[3],
|
||||
};
|
||||
};
|
||||
|
||||
export const groupErrorsBySection = (errors: SetupValidationError[]): Record<string, SetupValidationError[]> => {
|
||||
const grouped: Record<string, SetupValidationError[]> = {};
|
||||
errors.forEach((error) => {
|
||||
const parsed = parseFieldPath(error.field);
|
||||
const section = parsed?.section || error.field.split(".")[0] || "global";
|
||||
if (!grouped[section]) {
|
||||
grouped[section] = [];
|
||||
}
|
||||
grouped[section].push(error);
|
||||
});
|
||||
return grouped;
|
||||
};
|
||||
@@ -9,8 +9,8 @@
|
||||
/>
|
||||
</el-col>
|
||||
<el-col :span="19">
|
||||
<el-card class="mb-12">
|
||||
<div class="filters">
|
||||
<div class="faq-main unified-shell">
|
||||
<div class="filters unified-action-bar">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
:placeholder="TEXT.common.placeholders.keyword"
|
||||
@@ -24,7 +24,6 @@
|
||||
<el-button type="primary" @click="openForm()">{{ TEXT.modules.knowledgeMedicalConsult.newItem }}</el-button>
|
||||
</PermissionAction>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<FaqList
|
||||
:items="faqs"
|
||||
@@ -41,6 +40,7 @@
|
||||
:total="total"
|
||||
@current-change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
@@ -146,10 +146,17 @@ onMounted(async () => {
|
||||
.page {
|
||||
padding: 16px;
|
||||
}
|
||||
.faq-main {
|
||||
background: #fff;
|
||||
border: 1px solid var(--ctms-border-color);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
}
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
@@ -158,7 +165,4 @@ onMounted(async () => {
|
||||
margin-top: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
.mb-12 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card v-loading="loading">
|
||||
<div class="page unified-shell">
|
||||
<el-card class="unified-shell" v-loading="loading">
|
||||
<div class="content">
|
||||
<div class="question-header">
|
||||
<div class="question-label">{{ TEXT.modules.knowledgeMedicalConsult.questionDesc }}</div>
|
||||
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="mt-12" v-loading="repliesLoading">
|
||||
<el-card class="mt-12 unified-shell" v-loading="repliesLoading">
|
||||
<template #header>
|
||||
<div class="reply-header">
|
||||
<span>{{ TEXT.modules.knowledgeMedicalConsult.replies }}</span>
|
||||
|
||||
@@ -118,6 +118,7 @@ import { useAuthStore } from "../store/auth";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import { getCachedCredential, setCachedCredential, clearCachedCredential } from "../utils/auth";
|
||||
import { TEXT, requiredMessage } from "../locales";
|
||||
import { consumeLogoutReason, LOGOUT_REASON_TIMEOUT } from "../session/sessionManager";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
@@ -143,6 +144,10 @@ let vantaEffect: any = null;
|
||||
|
||||
// 初始化 Vanta.js 效果
|
||||
onMounted(() => {
|
||||
const logoutReason = consumeLogoutReason();
|
||||
if (logoutReason === LOGOUT_REASON_TIMEOUT) {
|
||||
ElMessage.warning("长时间未操作,已自动退出登录,请重新登录");
|
||||
}
|
||||
const cached = getCachedCredential();
|
||||
if (cached) {
|
||||
form.email = cached.email;
|
||||
@@ -228,8 +233,13 @@ const onSubmit = async () => {
|
||||
router.push("/");
|
||||
}
|
||||
} catch (error: any) {
|
||||
const status = error?.response?.status;
|
||||
const detail = error?.response?.data?.detail || error?.response?.data?.message;
|
||||
if (!status || status >= 500) {
|
||||
ElMessage.error("服务暂时不可用,请稍后重试");
|
||||
} else {
|
||||
ElMessage.error(detail || TEXT.modules.auth.authFailed);
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card>
|
||||
<el-card class="unified-shell">
|
||||
<h3 class="title">{{ TEXT.modules.profile.title }}</h3>
|
||||
<p class="subtitle">{{ TEXT.modules.profile.subtitle }}</p>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" class="form">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="study-home-container" v-if="study.currentStudy">
|
||||
<div class="study-home-container unified-shell" v-if="study.currentStudy">
|
||||
<!-- 概览头部 -->
|
||||
<div class="home-header">
|
||||
<div class="header-content">
|
||||
@@ -68,7 +68,7 @@
|
||||
/>
|
||||
|
||||
<!-- 通知 -->
|
||||
<el-card class="notifications-card">
|
||||
<el-card class="notifications-card unified-shell">
|
||||
<div class="notifications-header">
|
||||
<span class="notifications-title">{{ TEXT.modules.projectOverview.notificationsTitle }}</span>
|
||||
<span class="notifications-subtitle">{{ TEXT.modules.projectOverview.notificationsSubtitle }}</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card shadow="never" class="main-content-card">
|
||||
<div class="filter-container">
|
||||
<el-card shadow="never" class="main-content-card unified-shell">
|
||||
<div class="filter-container unified-action-bar">
|
||||
<div class="filter-form">
|
||||
<el-tag type="info" effect="plain" class="filter-label-tag">{{ TEXT.common.fields.status }}</el-tag>
|
||||
<el-select v-model="status" style="width: 140px" @change="loadUsers" class="filter-select-comp">
|
||||
@@ -13,6 +13,7 @@
|
||||
<div class="filter-spacer"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="unified-section approval-table-section">
|
||||
<el-table :data="users" v-loading="loading" style="width: 100%">
|
||||
<el-table-column prop="email" :label="TEXT.common.fields.email" min-width="200" />
|
||||
<el-table-column prop="full_name" :label="TEXT.common.fields.name" min-width="140" />
|
||||
@@ -52,6 +53,7 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -124,14 +126,6 @@ onMounted(() => {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-content-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
@@ -150,4 +144,9 @@ onMounted(() => {
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.approval-table-section {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card shadow="never" class="main-content-card">
|
||||
<div class="filter-container">
|
||||
<el-card shadow="never" class="main-content-card unified-shell">
|
||||
<div class="filter-container unified-action-bar">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-select v-model="filters.entityType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterEntityType" @change="loadLogs" class="filter-select-comp">
|
||||
<el-option v-for="opt in entityTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-select v-model="filters.eventType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterEvent" @change="loadLogs" class="filter-select-comp">
|
||||
<el-option v-for="opt in eventTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
@@ -43,6 +48,7 @@
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="unified-section table-section">
|
||||
<el-table :data="logs" v-loading="loading" style="width: 100%" stripe>
|
||||
<el-table-column prop="timestamp" :label="TEXT.modules.adminAuditLogs.columns.time" width="180" align="center">
|
||||
<template #default="scope">
|
||||
@@ -90,6 +96,7 @@
|
||||
:total="total"
|
||||
@current-change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -141,6 +148,7 @@ const pageSize = 20;
|
||||
const total = ref(0);
|
||||
|
||||
const filters = ref({
|
||||
entityType: "",
|
||||
eventType: "",
|
||||
operatorId: "",
|
||||
result: "",
|
||||
@@ -151,6 +159,15 @@ const eventTypeOptions = Object.entries(auditDict).map(([value, cfg]) => ({
|
||||
value,
|
||||
label: cfg.label,
|
||||
}));
|
||||
const entityTypeOptions = [
|
||||
{ value: "study_setup_config", label: "立项配置" },
|
||||
{ value: "study", label: "项目" },
|
||||
{ value: "subject", label: "参与者" },
|
||||
{ value: "site", label: "中心" },
|
||||
{ value: "finance", label: "费用" },
|
||||
{ value: "drug_shipment", label: "药品流向" },
|
||||
{ value: "audit_log", label: "审计日志" },
|
||||
];
|
||||
const userOptions = computed(() => users.value.map((u: any) => ({ label: u.username, value: u.id })));
|
||||
const isAdmin = computed(() => auth.user?.role === "ADMIN");
|
||||
const canProjectExport = computed(() => !!study.currentStudy && auth.user?.role === "ADMIN");
|
||||
@@ -188,6 +205,7 @@ const loadLogs = async () => {
|
||||
const params: Record<string, any> = {
|
||||
skip: (page.value - 1) * pageSize,
|
||||
limit: pageSize,
|
||||
entity_type: filters.value.entityType || undefined,
|
||||
action: filters.value.eventType || undefined,
|
||||
operator_id: filters.value.operatorId || undefined,
|
||||
};
|
||||
@@ -258,6 +276,7 @@ const fetchAllForExport = async () => {
|
||||
const params: Record<string, any> = {
|
||||
skip: 0,
|
||||
limit: 2000,
|
||||
entity_type: filters.value.entityType || undefined,
|
||||
action: filters.value.eventType || undefined,
|
||||
operator_id: filters.value.operatorId || undefined,
|
||||
};
|
||||
@@ -327,12 +346,9 @@ onMounted(async () => {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-content-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
.table-section {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,19 +20,6 @@
|
||||
<el-option :label="TEXT.enums.projectStatus.CLOSED" value="CLOSED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">{{ TEXT.modules.adminProjects.visitTemplate }}</el-divider>
|
||||
<el-form-item :label="TEXT.common.fields.visitTotal">
|
||||
<el-input-number v-model="form.visit_total" :min="1" :max="50" :step="1" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.visitIntervalDays">
|
||||
<el-input-number v-model="form.visit_interval_days" :min="1" :max="365" :step="1" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.visitWindowStartOffset">
|
||||
<el-input-number v-model="form.visit_window_start_offset" :min="-365" :max="365" :step="1" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.visitWindowEndOffset">
|
||||
<el-input-number v-model="form.visit_window_end_offset" :min="-365" :max="365" :step="1" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visibleProxy = false">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
@@ -71,14 +58,11 @@ const form = reactive({
|
||||
protocol_no: "",
|
||||
phase: "",
|
||||
status: "DRAFT",
|
||||
visit_interval_days: null as number | null,
|
||||
visit_total: null as number | null,
|
||||
visit_window_start_offset: null as number | null,
|
||||
visit_window_end_offset: null as number | null,
|
||||
});
|
||||
|
||||
const rules = reactive<FormRules>({
|
||||
name: [{ required: true, message: requiredMessage(TEXT.common.fields.projectName), trigger: "blur" }],
|
||||
code: [{ required: true, message: requiredMessage(TEXT.common.fields.projectCode), trigger: "blur" }],
|
||||
status: [{ required: true, message: requiredMessage(TEXT.common.fields.status), trigger: "change" }],
|
||||
});
|
||||
|
||||
@@ -88,10 +72,6 @@ const resetForm = () => {
|
||||
form.protocol_no = "";
|
||||
form.phase = "";
|
||||
form.status = "DRAFT";
|
||||
form.visit_interval_days = null;
|
||||
form.visit_total = null;
|
||||
form.visit_window_start_offset = null;
|
||||
form.visit_window_end_offset = null;
|
||||
};
|
||||
|
||||
watch(
|
||||
@@ -105,10 +85,6 @@ watch(
|
||||
form.protocol_no = props.project.protocol_no || "";
|
||||
form.phase = props.project.phase || "";
|
||||
form.status = props.project.status || "DRAFT";
|
||||
form.visit_interval_days = props.project.visit_interval_days ?? null;
|
||||
form.visit_total = props.project.visit_total ?? null;
|
||||
form.visit_window_start_offset = props.project.visit_window_start_offset ?? null;
|
||||
form.visit_window_end_offset = props.project.visit_window_end_offset ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,27 +98,19 @@ const onSubmit = async () => {
|
||||
if (props.project) {
|
||||
await updateStudy(props.project.id, {
|
||||
name: form.name,
|
||||
code: form.code,
|
||||
code: form.code.trim(),
|
||||
protocol_no: form.protocol_no,
|
||||
phase: form.phase,
|
||||
status: form.status,
|
||||
visit_interval_days: form.visit_interval_days,
|
||||
visit_total: form.visit_total,
|
||||
visit_window_start_offset: form.visit_window_start_offset,
|
||||
visit_window_end_offset: form.visit_window_end_offset,
|
||||
});
|
||||
ElMessage.success(TEXT.modules.adminProjects.updateSuccess);
|
||||
} else {
|
||||
await createStudy({
|
||||
name: form.name,
|
||||
code: form.code,
|
||||
code: form.code.trim(),
|
||||
protocol_no: form.protocol_no,
|
||||
phase: form.phase,
|
||||
status: form.status,
|
||||
visit_interval_days: form.visit_interval_days,
|
||||
visit_total: form.visit_total,
|
||||
visit_window_start_offset: form.visit_window_start_offset,
|
||||
visit_window_end_offset: form.visit_window_end_offset,
|
||||
});
|
||||
ElMessage.success(TEXT.modules.adminProjects.createSuccess);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card shadow="never" class="main-content-card">
|
||||
<div class="filter-container">
|
||||
<div class="filter-form">
|
||||
<el-card shadow="never" class="main-content-card unified-shell">
|
||||
<div class="unified-action-bar actions-only-bar">
|
||||
<div class="filter-spacer"></div>
|
||||
<el-button type="primary" @click="openAdd" class="header-action-btn">
|
||||
<el-button type="primary" @click="openAdd">
|
||||
{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjectMembers.memberLabel }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="unified-section member-table-section">
|
||||
<el-table :data="memberRows" v-loading="loading" stripe>
|
||||
<el-table-column prop="full_name" :label="TEXT.modules.adminProjectMembers.username" min-width="160" />
|
||||
<el-table-column prop="role_in_study" :label="TEXT.modules.adminProjectMembers.projectRole" min-width="140">
|
||||
@@ -62,6 +61,7 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog :title="TEXT.modules.adminProjectMembers.newTitle" width="520px" v-model="addVisible" :close-on-click-modal="false">
|
||||
@@ -399,29 +399,15 @@ onMounted(async () => {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-content-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
.actions-only-bar {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-radius: 8px;
|
||||
.member-table-section {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card shadow="never" class="main-content-card">
|
||||
<div class="filter-container">
|
||||
<div class="filter-form">
|
||||
<el-card shadow="never" class="main-content-card unified-shell">
|
||||
<div class="unified-action-bar actions-only-bar">
|
||||
<div class="filter-spacer"></div>
|
||||
<el-button type="primary" @click="openCreate" class="header-action-btn">
|
||||
<el-button type="primary" @click="openCreate">
|
||||
{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjects.projectLabel }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="unified-section">
|
||||
<el-table :data="projects" v-loading="loading" stripe>
|
||||
<el-table-column prop="name" :label="TEXT.common.fields.projectName" min-width="300">
|
||||
<template #default="scope">
|
||||
@@ -57,6 +56,7 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
<ProjectForm v-model:visible="formVisible" :project="editingProject" @saved="loadProjects" />
|
||||
</div>
|
||||
@@ -197,7 +197,7 @@ const enterStudy = (row: Study) => {
|
||||
};
|
||||
|
||||
const goDetail = (row: Study) => {
|
||||
router.push(`/projects/${row.id}`);
|
||||
router.push(`/admin/projects/${row.id}`);
|
||||
};
|
||||
|
||||
const statusLabel = (status: string) =>
|
||||
@@ -221,52 +221,19 @@ onMounted(() => {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-content-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
.actions-only-bar {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
.unified-section {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* 表格样式美化 */
|
||||
:deep(.el-table) {
|
||||
--el-table-header-bg-color: #f9fafb;
|
||||
--el-table-header-text-color: #374151;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.el-table th.el-table__cell) {
|
||||
background-color: #f9fafb;
|
||||
font-weight: 600;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
:deep(.el-table .el-table__cell) {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* 操作按钮美化 */
|
||||
.action-btn {
|
||||
font-size: 18px; /* 图标变更大 */
|
||||
font-size: 18px;
|
||||
padding: 4px;
|
||||
margin: 0 2px;
|
||||
transition: all 0.3s;
|
||||
|
||||
@@ -28,9 +28,6 @@
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="入组目标" prop="enrollment_target">
|
||||
<el-input-number v-model="form.enrollment_target" :min="0" :step="1" :placeholder="'请输入入组目标人数'" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visibleProxy = false">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
@@ -77,7 +74,6 @@ const form = reactive({
|
||||
phone: "",
|
||||
contact: "",
|
||||
craSelections: [] as string[],
|
||||
enrollment_target: 0,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
@@ -103,7 +99,6 @@ const resetForm = () => {
|
||||
form.phone = "";
|
||||
form.contact = "";
|
||||
form.craSelections = [];
|
||||
form.enrollment_target = 0;
|
||||
form.is_active = true;
|
||||
};
|
||||
|
||||
@@ -127,7 +122,6 @@ watch(
|
||||
optionByLabel[opt.label] = opt.value;
|
||||
});
|
||||
form.craSelections = rawSelections.map((s) => optionByLabel[s] || s);
|
||||
form.enrollment_target = (props.site as any)?.enrollment_target || 0;
|
||||
form.is_active = props.site.is_active;
|
||||
}
|
||||
}
|
||||
@@ -161,7 +155,6 @@ const onSubmit = async () => {
|
||||
city: form.city,
|
||||
pi_name: form.pi_name,
|
||||
contact,
|
||||
enrollment_target: form.enrollment_target,
|
||||
is_active: form.is_active,
|
||||
});
|
||||
ElMessage.success(TEXT.modules.adminSites.updateSuccess);
|
||||
@@ -177,7 +170,6 @@ const onSubmit = async () => {
|
||||
city: form.city,
|
||||
pi_name: form.pi_name,
|
||||
contact,
|
||||
enrollment_target: form.enrollment_target,
|
||||
});
|
||||
ElMessage.success(TEXT.modules.adminSites.createSuccess);
|
||||
logAudit("SITE_STATUS_CHANGED", {
|
||||
@@ -187,6 +179,7 @@ const onSubmit = async () => {
|
||||
severity: "normal",
|
||||
});
|
||||
}
|
||||
|
||||
emit("saved");
|
||||
visibleProxy.value = false;
|
||||
} catch (e: any) {
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card shadow="never" class="main-content-card">
|
||||
<div class="filter-container">
|
||||
<div class="filter-form">
|
||||
<el-card shadow="never" class="main-content-card unified-shell">
|
||||
<div class="unified-action-bar actions-only-bar">
|
||||
<div class="filter-spacer"></div>
|
||||
<el-button type="primary" @click="openCreate" class="header-action-btn">
|
||||
<el-button type="primary" @click="openCreate">
|
||||
{{ TEXT.common.actions.add }}{{ TEXT.modules.adminSites.siteLabel }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="unified-section site-table-section">
|
||||
<el-table :data="displaySites" v-loading="loading" stripe :row-class-name="siteRowClass">
|
||||
<el-table-column prop="name" :label="TEXT.common.fields.siteName" width="220">
|
||||
<template #default="scope">
|
||||
<div class="site-name-cell">
|
||||
<span>{{ scope.row.name }}</span>
|
||||
<el-tag v-if="isLeadUnitSite(scope.row)" type="warning" effect="light" size="small">
|
||||
{{ TEXT.modules.adminSites.leadUnitTag }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-table :data="sites" v-loading="loading" stripe :row-class-name="siteRowClass">
|
||||
<el-table-column prop="name" :label="TEXT.common.fields.siteName" width="180" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="city" :label="TEXT.common.fields.city" width="100" />
|
||||
<el-table-column prop="pi_name" :label="TEXT.modules.adminSites.piLabel" width="120" />
|
||||
<el-table-column :label="TEXT.common.fields.phone" width="140">
|
||||
@@ -52,6 +60,7 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
<SiteForm
|
||||
v-if="projectId"
|
||||
@@ -158,7 +167,32 @@ const contactLabel = (row: any) => {
|
||||
|
||||
const phoneText = (row: any) => row?.phone || row?.contact_phone || TEXT.common.fallback;
|
||||
|
||||
const siteRowClass = ({ row }: { row: Site }) => (row.is_active ? "" : "row-inactive");
|
||||
const normalizeSiteName = (value?: string | null) => String(value || "").trim().replace(/\s+/g, "").toLowerCase();
|
||||
const isLeadUnitSite = (row: Site): boolean => {
|
||||
const leadUnitName = normalizeSiteName(project.value?.lead_unit);
|
||||
const siteName = normalizeSiteName(row.name);
|
||||
if (!leadUnitName || !siteName) return false;
|
||||
return siteName === leadUnitName;
|
||||
};
|
||||
|
||||
const displaySites = computed(() => {
|
||||
return sites.value
|
||||
.map((item, index) => ({ item, index }))
|
||||
.sort((a, b) => {
|
||||
const aLead = isLeadUnitSite(a.item) ? 1 : 0;
|
||||
const bLead = isLeadUnitSite(b.item) ? 1 : 0;
|
||||
if (aLead !== bLead) return bLead - aLead;
|
||||
return a.index - b.index;
|
||||
})
|
||||
.map((entry) => entry.item);
|
||||
});
|
||||
|
||||
const siteRowClass = ({ row }: { row: Site }) => {
|
||||
const classes: string[] = [];
|
||||
if (!row.is_active) classes.push("row-inactive");
|
||||
if (isLeadUnitSite(row)) classes.push("row-lead-unit");
|
||||
return classes.join(" ");
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
loadUsers();
|
||||
@@ -271,29 +305,21 @@ onMounted(async () => {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-content-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
.actions-only-bar {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
.site-table-section {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-radius: 8px;
|
||||
.site-name-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -308,6 +334,14 @@ onMounted(async () => {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.row-lead-unit td {
|
||||
background-color: #f3f8ff !important;
|
||||
}
|
||||
|
||||
.row-lead-unit.row-inactive td {
|
||||
background-color: #edf2fb !important;
|
||||
}
|
||||
|
||||
.el-table .action-column .cell {
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card shadow="never" class="main-content-card">
|
||||
<div class="filter-container">
|
||||
<el-card shadow="never" class="main-content-card unified-shell">
|
||||
<div class="filter-container unified-action-bar">
|
||||
<div class="filter-form">
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-input
|
||||
@@ -23,10 +23,11 @@
|
||||
<el-button @click="loadUsers">{{ TEXT.common.actions.search }}</el-button>
|
||||
</el-form-item>
|
||||
<div class="filter-spacer"></div>
|
||||
<el-button type="primary" @click="openCreate" class="header-action-btn">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminUsers.userLabel }}</el-button>
|
||||
<el-button type="primary" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminUsers.userLabel }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="unified-section user-table-section">
|
||||
<el-table :data="users" stripe v-loading="loading" style="width: 100%">
|
||||
<el-table-column prop="email" :label="TEXT.common.fields.email" min-width="200" />
|
||||
<el-table-column prop="full_name" :label="TEXT.common.fields.name" min-width="140" />
|
||||
@@ -79,6 +80,7 @@
|
||||
@current-change="loadUsers"
|
||||
class="pagination"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
<UserForm v-model:visible="formVisible" :user="editingUser" :admin-count="activeAdminCount" @saved="loadUsers" />
|
||||
<UserResetPassword v-model:visible="resetVisible" :user="resetUser" @reset="loadUsers" />
|
||||
@@ -282,14 +284,6 @@ onMounted(() => {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-content-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
@@ -310,10 +304,9 @@ onMounted(() => {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-radius: 8px;
|
||||
.user-table-section {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<template>
|
||||
<div class="ctms-page">
|
||||
<div class="ctms-page-header">
|
||||
<div class="page">
|
||||
<div class="unified-shell">
|
||||
<section class="unified-section" v-loading="loading">
|
||||
<div class="unified-section-header">
|
||||
<div>
|
||||
<h1 class="ctms-page-title">
|
||||
<div class="ctms-section-title">
|
||||
{{ detail.title || TEXT.modules.fileVersionManagement.title }}
|
||||
</h1>
|
||||
<p class="ctms-page-subtitle">
|
||||
</div>
|
||||
<div class="doc-meta-row mt-1">
|
||||
<el-tag size="small" effect="plain" type="info" class="mr-2">{{ displayText(detail.doc_type, TEXT.enums.documentType) }}</el-tag>
|
||||
<el-tag size="small" effect="plain" type="warning" class="mr-2">{{ displaySite(detail.site_id) }}</el-tag>
|
||||
<el-tag v-if="isReadOnly" size="small" effect="plain" type="info">中心已停用</el-tag>
|
||||
@@ -13,17 +15,15 @@
|
||||
{{ TEXT.modules.fileVersionManagement.labels.currentEffective }}:
|
||||
<span class="font-medium text-main">{{ detail.current_effective_version?.version_no || TEXT.common.fallback }}</span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="ctms-page-actions">
|
||||
</div>
|
||||
<div class="ctms-section-actions">
|
||||
<el-button @click="goBack">
|
||||
<el-icon class="el-icon--left"><ArrowLeft /></el-icon>
|
||||
{{ TEXT.common.actions.back }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="ctms-section-card" v-loading="loading">
|
||||
<el-descriptions :column="3" border class="ctms-descriptions">
|
||||
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.fields.docType">{{ displayText(detail.doc_type, TEXT.enums.documentType) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.fields.site">{{ displaySite(detail.site_id) }}</el-descriptions-item>
|
||||
@@ -35,8 +35,9 @@
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.columns.updatedAt">{{ formatDate(detail.updated_at) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</section>
|
||||
|
||||
<section class="unified-section">
|
||||
<el-tabs v-model="activeTab" @tab-change="handleTabChange" class="ctms-tabs">
|
||||
<el-tab-pane :label="TEXT.modules.fileVersionManagement.tabs.versions" name="versions">
|
||||
<div class="ctms-section-header">
|
||||
@@ -48,7 +49,7 @@
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-card class="ctms-table-card">
|
||||
<el-card class="ctms-table-card unified-shell">
|
||||
<el-table :data="detail.version_timeline" v-loading="versionLoading" class="ctms-table">
|
||||
<el-table-column prop="version_no" :label="TEXT.modules.fileVersionManagement.fields.versionNo" width="120">
|
||||
<template #default="{ row }">
|
||||
@@ -106,7 +107,7 @@
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-card class="ctms-table-card">
|
||||
<el-card class="ctms-table-card unified-shell">
|
||||
<el-table :data="distributions" v-loading="distributionLoading" class="ctms-table">
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.fields.targetType" width="120">
|
||||
<template #default="{ row }">
|
||||
@@ -128,6 +129,8 @@
|
||||
</el-tab-pane>
|
||||
|
||||
</el-tabs>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="uploadVisible" :title="TEXT.modules.fileVersionManagement.dialog.uploadTitle" width="520px" destroy-on-close class="ctms-dialog">
|
||||
<el-form ref="uploadFormRef" :model="uploadForm" :rules="uploadRules" label-width="110px" class="ctms-form">
|
||||
@@ -671,6 +674,19 @@ watch(
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.doc-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<template>
|
||||
<div class="ctms-page">
|
||||
|
||||
<el-card shadow="never" class="main-content-card">
|
||||
<div class="filter-container">
|
||||
<div class="page">
|
||||
<el-card shadow="never" class="main-content-card unified-shell">
|
||||
<div class="filter-container unified-action-bar">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-select v-model="filters.scope_type" :placeholder="TEXT.modules.fileVersionManagement.filters.scope" clearable class="filter-select-comp">
|
||||
@@ -29,6 +28,7 @@
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
<section class="unified-section">
|
||||
<el-table
|
||||
:data="sortedItems"
|
||||
v-loading="loading"
|
||||
@@ -83,6 +83,7 @@
|
||||
<el-empty :description="TEXT.modules.fileVersionManagement.emptyDescription" />
|
||||
</template>
|
||||
</el-table>
|
||||
</section>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="createVisible" :title="TEXT.modules.fileVersionManagement.dialog.createTitle" width="520px" destroy-on-close class="ctms-dialog">
|
||||
@@ -339,17 +340,18 @@ watch(
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ctms-page {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.main-content-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
@@ -365,7 +367,7 @@ watch(
|
||||
}
|
||||
|
||||
.filter-select-comp {
|
||||
width: 140px;
|
||||
width: 132px;
|
||||
}
|
||||
|
||||
.filter-spacer {
|
||||
@@ -373,9 +375,9 @@ watch(
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-radius: 8px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,26 +1,20 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ TEXT.modules.drugShipments.detailTitle }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.drugShipments.detailSubtitle }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="unified-shell">
|
||||
<section class="unified-section">
|
||||
<div class="unified-section-header">
|
||||
<div class="ctms-section-title">{{ TEXT.modules.drugShipments.detailTitle }}</div>
|
||||
<div class="ctms-section-actions">
|
||||
<el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>{{ TEXT.common.labels.basicInfo }}</span>
|
||||
<div class="section-title-row">
|
||||
<span class="ctms-section-title">{{ TEXT.common.labels.basicInfo }}</span>
|
||||
<el-tag :type="detail.status === 'PENDING' ? 'info' : detail.status === 'SIGNED' ? 'success' : 'primary'">
|
||||
{{ displayEnum(TEXT.enums.shipmentStatus, detail.status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item :label="TEXT.common.fields.site">{{ detail.site_name || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.direction">
|
||||
@@ -29,9 +23,9 @@
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-card :header="TEXT.modules.drugShipments.recordLabel">
|
||||
</section>
|
||||
<section class="unified-section">
|
||||
<div class="ctms-section-title section-title-inline">{{ TEXT.modules.drugShipments.recordLabel }}</div>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item :label="TEXT.common.fields.trackingNo">{{ detail.tracking_no || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.carrier">{{ detail.carrier || TEXT.common.fallback }}</el-descriptions-item>
|
||||
@@ -41,15 +35,16 @@
|
||||
<el-descriptions-item :label="TEXT.common.fields.batchNo">{{ detail.batch_no || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.remark" :span="2">{{ detail.remark || TEXT.common.fallback }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<section class="unified-section">
|
||||
<AttachmentList
|
||||
v-if="shipmentId"
|
||||
:study-id="studyId"
|
||||
entity-type="drug_shipment"
|
||||
:entity-id="shipmentId"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -111,41 +106,17 @@ onMounted(load);
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
.section-title-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
.section-title-inline {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ isEdit ? TEXT.modules.drugShipments.editTitle : TEXT.modules.drugShipments.newTitle }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.drugShipments.formSubtitle }}</p>
|
||||
</div>
|
||||
<div class="unified-shell">
|
||||
<section class="unified-section">
|
||||
<div class="unified-section-header">
|
||||
<div class="ctms-section-title">{{ isEdit ? TEXT.modules.drugShipments.editTitle : TEXT.modules.drugShipments.newTitle }}</div>
|
||||
<div class="ctms-section-actions">
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
|
||||
<el-card>
|
||||
</div>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" label-position="top">
|
||||
<h3 class="section-title">{{ TEXT.common.labels.basicInfo }}</h3>
|
||||
<el-row :gutter="24">
|
||||
@@ -93,17 +92,17 @@
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
</section>
|
||||
<section class="unified-section">
|
||||
<AttachmentList
|
||||
v-if="isEdit && shipmentId"
|
||||
:study-id="studyId"
|
||||
entity-type="drug_shipment"
|
||||
:entity-id="shipmentId"
|
||||
/>
|
||||
<el-card v-else class="hint-card">
|
||||
<StateEmpty :description="TEXT.modules.drugShipments.uploadHint" />
|
||||
</el-card>
|
||||
<StateEmpty v-else :description="TEXT.modules.drugShipments.uploadHint" />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -245,29 +244,7 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.hint-card {
|
||||
padding: 12px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="actions">
|
||||
<el-button type="primary" :disabled="!canWrite || isReadOnly" @click="goEdit" class="header-action-btn copy-btn">
|
||||
<el-icon class="el-icon--left"><Edit /></el-icon>
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
<el-button @click="goBack" class="header-action-btn">
|
||||
{{ TEXT.common.actions.back }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<StateError v-if="errorMessage" :description="errorMessage">
|
||||
<template #action>
|
||||
<el-button type="primary" @click="load">{{ TEXT.common.actions.retry }}</el-button>
|
||||
@@ -19,10 +9,19 @@
|
||||
<StateLoading v-else-if="loading" :rows="6" />
|
||||
|
||||
<template v-else>
|
||||
<el-card class="detail-card" shadow="never">
|
||||
<el-card class="detail-card unified-shell" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="card-header actions-header">
|
||||
<span class="header-title">{{ TEXT.common.labels.basicInfo }}</span>
|
||||
<div class="actions">
|
||||
<el-button type="primary" :disabled="!canWrite || isReadOnly" @click="goEdit" class="header-action-btn copy-btn">
|
||||
<el-icon class="el-icon--left"><Edit /></el-icon>
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
<el-button @click="goBack" class="header-action-btn">
|
||||
{{ TEXT.common.actions.back }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-descriptions :column="2" border class="custom-descriptions">
|
||||
@@ -47,7 +46,7 @@
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-card class="section-card" shadow="never">
|
||||
<el-card class="section-card unified-shell" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="header-title">{{ TEXT.modules.feeContracts.paymentTitle }}</span>
|
||||
@@ -89,7 +88,7 @@
|
||||
<StateEmpty v-if="detail.payments.length === 0" :description="TEXT.modules.feeContracts.paymentEmpty" />
|
||||
</el-card>
|
||||
|
||||
<el-card class="section-card" shadow="never">
|
||||
<el-card class="section-card unified-shell" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="header-title">{{ TEXT.modules.feeContracts.attachmentTitle }}</span>
|
||||
@@ -232,26 +231,7 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--ctms-text-secondary);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -275,6 +255,12 @@ onMounted(async () => {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.actions-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ isEdit ? TEXT.modules.feeContracts.editTitle : TEXT.modules.feeContracts.newTitle }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.feeContracts.formSubtitle }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button @click="goBack" class="header-action-btn">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StateError v-if="errorMessage" :description="errorMessage">
|
||||
<template #action>
|
||||
<el-button type="primary" @click="loadDetail">{{ TEXT.common.actions.retry }}</el-button>
|
||||
@@ -20,10 +10,11 @@
|
||||
|
||||
<template v-else>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="140px" label-position="top">
|
||||
<el-card class="form-card" shadow="never">
|
||||
<el-card class="form-card unified-shell" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="card-header actions-header">
|
||||
<span class="header-title">{{ TEXT.common.labels.basicInfo }}</span>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-row :gutter="24">
|
||||
@@ -97,7 +88,7 @@
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<el-card class="form-card section-margin" shadow="never">
|
||||
<el-card class="form-card section-margin unified-shell" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header actions-header">
|
||||
<span class="header-title">{{ TEXT.modules.feeContracts.paymentTitle }}</span>
|
||||
@@ -183,7 +174,7 @@
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="form-card section-margin" shadow="never">
|
||||
<el-card class="form-card section-margin unified-shell" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="header-title">{{ TEXT.modules.feeContracts.attachmentTitle }}</span>
|
||||
@@ -512,36 +503,10 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--ctms-border-color);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
|
||||
<div class="overview">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
@@ -46,8 +45,16 @@
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="main-content-card" v-if="!errorMessage && !loading">
|
||||
<div class="filter-container">
|
||||
<StateError v-if="errorMessage" :description="errorMessage">
|
||||
<template #action>
|
||||
<el-button type="primary" @click="load">{{ TEXT.common.actions.retry }}</el-button>
|
||||
</template>
|
||||
</StateError>
|
||||
|
||||
<StateLoading v-else-if="loading" :rows="6" />
|
||||
|
||||
<el-card shadow="never" class="main-content-card unified-shell" v-else>
|
||||
<div class="filter-container unified-action-bar">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="" class="filter-item">
|
||||
<el-select v-model="filters.centerId" clearable :placeholder="TEXT.common.fields.site" class="filter-select">
|
||||
@@ -79,15 +86,7 @@
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<StateError v-if="errorMessage" :description="errorMessage">
|
||||
<template #action>
|
||||
<el-button type="primary" @click="load">{{ TEXT.common.actions.retry }}</el-button>
|
||||
</template>
|
||||
</StateError>
|
||||
|
||||
<StateLoading v-else-if="loading" :rows="6" />
|
||||
|
||||
<section class="unified-section">
|
||||
<el-table
|
||||
:data="sortedContracts"
|
||||
style="width: 100%"
|
||||
@@ -167,6 +166,7 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<StateEmpty v-if="sortedContracts.length === 0" :description="TEXT.modules.feeContracts.empty" />
|
||||
</section>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -347,14 +347,15 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.main-content-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="actions">
|
||||
<el-button type="primary" :disabled="!canWrite || isReadOnly" @click="goEdit" class="header-action-btn copy-btn">
|
||||
<el-icon class="el-icon--left"><Edit /></el-icon>
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
<el-button @click="goBack" class="header-action-btn">
|
||||
{{ TEXT.common.actions.back }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<StateError v-if="errorMessage" :description="errorMessage">
|
||||
<template #action>
|
||||
<el-button type="primary" @click="load">{{ TEXT.common.actions.retry }}</el-button>
|
||||
@@ -19,16 +9,23 @@
|
||||
<StateLoading v-else-if="loading" :rows="6" />
|
||||
|
||||
<template v-else>
|
||||
<el-card class="detail-card" shadow="never">
|
||||
<el-card class="detail-card unified-shell" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="header-title">{{ TEXT.common.labels.basicInfo }}</span>
|
||||
<div class="status-badges">
|
||||
<div class="status-badges actions">
|
||||
<el-tag v-if="detail.is_paid" type="success" effect="dark" size="default">{{ TEXT.modules.feeSpecials.isPaid }}</el-tag>
|
||||
<el-tag v-else type="info" effect="plain" size="default">{{ TEXT.modules.feeSpecials.unpaid }}</el-tag>
|
||||
|
||||
<el-tag v-if="detail.is_verified" type="info" effect="dark" size="default">{{ TEXT.modules.feeSpecials.isVerified }}</el-tag>
|
||||
<el-tag v-else type="info" effect="plain" size="default">{{ TEXT.modules.feeSpecials.unverified }}</el-tag>
|
||||
<el-button type="primary" :disabled="!canWrite || isReadOnly" @click="goEdit" class="header-action-btn copy-btn">
|
||||
<el-icon class="el-icon--left"><Edit /></el-icon>
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
<el-button @click="goBack" class="header-action-btn">
|
||||
{{ TEXT.common.actions.back }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -65,7 +62,7 @@
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-card class="section-card" shadow="never">
|
||||
<el-card class="section-card unified-shell" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="header-title">{{ TEXT.modules.feeSpecials.attachmentTitle }}</span>
|
||||
@@ -216,26 +213,7 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--ctms-text-secondary);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ isEdit ? TEXT.modules.feeSpecials.editTitle : TEXT.modules.feeSpecials.newTitle }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.feeSpecials.formSubtitle }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button @click="goBack" class="header-action-btn">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StateError v-if="errorMessage" :description="errorMessage">
|
||||
<template #action>
|
||||
<el-button type="primary" @click="loadDetail">{{ TEXT.common.actions.retry }}</el-button>
|
||||
@@ -20,10 +10,11 @@
|
||||
|
||||
<template v-else>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" label-position="top">
|
||||
<el-card class="form-card" shadow="never">
|
||||
<el-card class="form-card unified-shell" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="card-header header-actions-row">
|
||||
<span class="header-title">{{ TEXT.common.labels.basicInfo }}</span>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-row :gutter="24">
|
||||
@@ -65,7 +56,7 @@
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<el-card class="form-card section-margin" shadow="never">
|
||||
<el-card class="form-card section-margin unified-shell" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="header-title">{{ TEXT.common.fields.status }}</span>
|
||||
@@ -116,7 +107,7 @@
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="form-card section-margin" shadow="never">
|
||||
<el-card class="form-card section-margin unified-shell" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="header-title">{{ TEXT.modules.feeSpecials.attachmentTitle }}</span>
|
||||
@@ -389,37 +380,10 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--ctms-border-color);
|
||||
@@ -434,6 +398,12 @@ onMounted(async () => {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.header-actions-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
|
||||
<div class="overview">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
@@ -45,8 +44,16 @@
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="main-content-card" v-if="!errorMessage && !loading">
|
||||
<div class="filter-container">
|
||||
<StateError v-if="errorMessage" :description="errorMessage">
|
||||
<template #action>
|
||||
<el-button type="primary" @click="load">{{ TEXT.common.actions.retry }}</el-button>
|
||||
</template>
|
||||
</StateError>
|
||||
|
||||
<StateLoading v-else-if="loading" :rows="6" />
|
||||
|
||||
<el-card shadow="never" class="main-content-card unified-shell" v-else>
|
||||
<div class="filter-container unified-action-bar">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="" class="filter-item">
|
||||
<el-select v-model="filters.centerId" clearable :placeholder="TEXT.common.fields.site" class="filter-select">
|
||||
@@ -90,6 +97,7 @@
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
<section class="unified-section">
|
||||
<el-table
|
||||
:data="sortedExpenses"
|
||||
style="width: 100%"
|
||||
@@ -157,6 +165,7 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<StateEmpty v-if="sortedExpenses.length === 0" :description="TEXT.modules.feeSpecials.empty" />
|
||||
</section>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -343,14 +352,15 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.main-content-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ TEXT.modules.financeContracts.detailTitle }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.financeContracts.detailSubtitle }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="unified-shell">
|
||||
<section class="unified-section" v-loading="loading">
|
||||
<div class="unified-section-header">
|
||||
<div class="ctms-section-title">{{ TEXT.modules.financeContracts.detailTitle }}</div>
|
||||
<div class="ctms-section-actions">
|
||||
<el-button type="primary" :disabled="isReadOnly" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card v-loading="loading">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item :label="TEXT.common.fields.site">{{ detail.site_name || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.contractNo">{{ detail.contract_no || TEXT.common.fallback }}</el-descriptions-item>
|
||||
@@ -21,8 +18,8 @@
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.remark" :span="2">{{ detail.remark || TEXT.common.fallback }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
</section>
|
||||
<section class="unified-section">
|
||||
<AttachmentList
|
||||
v-if="contractId"
|
||||
:study-id="studyId"
|
||||
@@ -30,6 +27,8 @@
|
||||
:entity-id="contractId"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -112,29 +111,6 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ isEdit ? TEXT.modules.financeContracts.editTitle : TEXT.modules.financeContracts.newTitle }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.financeContracts.formSubtitle }}</p>
|
||||
</div>
|
||||
<div class="unified-shell">
|
||||
<section class="unified-section">
|
||||
<div class="unified-section-header">
|
||||
<div class="ctms-section-title">{{ isEdit ? TEXT.modules.financeContracts.editTitle : TEXT.modules.financeContracts.newTitle }}</div>
|
||||
<div class="ctms-section-actions">
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
|
||||
<el-card>
|
||||
</div>
|
||||
<el-form ref="formRef" :model="form" label-width="110px">
|
||||
<el-form-item :label="TEXT.common.fields.site" prop="site_name" required>
|
||||
<el-input v-model="form.site_name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
|
||||
@@ -37,8 +36,8 @@
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
</section>
|
||||
<section class="unified-section">
|
||||
<AttachmentList
|
||||
v-if="isEdit && contractId"
|
||||
:study-id="studyId"
|
||||
@@ -46,9 +45,9 @@
|
||||
:entity-id="contractId"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
<el-card v-else class="hint-card">
|
||||
<StateEmpty :description="TEXT.modules.financeContracts.uploadHint" />
|
||||
</el-card>
|
||||
<StateEmpty v-else :description="TEXT.modules.financeContracts.uploadHint" />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -170,28 +169,6 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.hint-card {
|
||||
padding: 12px;
|
||||
gap: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ TEXT.modules.financeSpecials.detailTitle }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.financeSpecials.detailSubtitle }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="unified-shell">
|
||||
<section class="unified-section" v-loading="loading">
|
||||
<div class="unified-section-header">
|
||||
<div class="ctms-section-title">{{ TEXT.modules.financeSpecials.detailTitle }}</div>
|
||||
<div class="ctms-section-actions">
|
||||
<el-button type="primary" :disabled="isReadOnly" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card v-loading="loading">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item :label="TEXT.common.fields.site">{{ detail.site_name || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.type">{{ displayEnum(TEXT.enums.financeSpecialType, detail.fee_type) }}</el-descriptions-item>
|
||||
@@ -20,8 +17,8 @@
|
||||
<el-descriptions-item :label="TEXT.common.fields.amount">{{ detail.amount || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.remark" :span="2">{{ detail.remark || TEXT.common.fallback }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
</section>
|
||||
<section class="unified-section">
|
||||
<AttachmentList
|
||||
v-if="specialId"
|
||||
:study-id="studyId"
|
||||
@@ -29,6 +26,8 @@
|
||||
:entity-id="specialId"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -111,29 +110,6 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ isEdit ? TEXT.modules.financeSpecials.editTitle : TEXT.modules.financeSpecials.newTitle }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.financeSpecials.subtitle }}</p>
|
||||
</div>
|
||||
<div class="unified-shell">
|
||||
<section class="unified-section">
|
||||
<div class="unified-section-header">
|
||||
<div class="ctms-section-title">{{ isEdit ? TEXT.modules.financeSpecials.editTitle : TEXT.modules.financeSpecials.newTitle }}</div>
|
||||
<div class="ctms-section-actions">
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
|
||||
<el-card>
|
||||
</div>
|
||||
<el-form :model="form" label-width="110px">
|
||||
<el-form-item :label="TEXT.common.fields.site" required>
|
||||
<el-input v-model="form.site_name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
|
||||
@@ -38,8 +37,8 @@
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
</section>
|
||||
<section class="unified-section">
|
||||
<AttachmentList
|
||||
v-if="isEdit && specialId"
|
||||
:study-id="studyId"
|
||||
@@ -47,9 +46,9 @@
|
||||
:entity-id="specialId"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
<el-card v-else class="hint-card">
|
||||
<StateEmpty :description="TEXT.modules.financeSpecials.uploadHint" />
|
||||
</el-card>
|
||||
<StateEmpty v-else :description="TEXT.modules.financeSpecials.uploadHint" />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -171,28 +170,6 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.hint-card {
|
||||
padding: 12px;
|
||||
gap: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
|
||||
<el-card shadow="never" class="main-content-card">
|
||||
<div class="filter-container">
|
||||
<el-card shadow="never" class="main-content-card unified-shell">
|
||||
<div class="filter-container unified-action-bar">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="" class="filter-item">
|
||||
<el-select v-model="filters.center_id" clearable :placeholder="TEXT.common.fields.site" class="filter-select">
|
||||
@@ -42,12 +42,13 @@
|
||||
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
|
||||
</el-form-item>
|
||||
<div class="filter-spacer"></div>
|
||||
<el-button type="primary" @click="goNew" class="header-action-btn">
|
||||
<el-button type="primary" @click="goNew">
|
||||
<el-icon class="el-icon--left"><Plus /></el-icon>
|
||||
{{ TEXT.modules.drugShipments.newTitle }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="unified-section table-section">
|
||||
<el-table
|
||||
:data="sortedItems"
|
||||
v-loading="loading"
|
||||
@@ -102,6 +103,7 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.drugShipments.empty" />
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -247,14 +249,6 @@ onMounted(async () => {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-content-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
@@ -291,10 +285,9 @@ onMounted(async () => {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-radius: 8px;
|
||||
.table-section {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.type-tag {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<ModulePlaceholder
|
||||
:title="TEXT.modules.etmf.title"
|
||||
:subtitle="TEXT.modules.etmf.subtitle"
|
||||
:list-title="TEXT.modules.etmf.listTitle"
|
||||
:empty-description="TEXT.modules.etmf.emptyDescription"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ModulePlaceholder from "../../components/ModulePlaceholder.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
</script>
|
||||
@@ -1,8 +1,7 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
|
||||
<el-card class="filter-card" shadow="never">
|
||||
<div class="filter-row">
|
||||
<el-card shadow="never" class="main-content-card unified-shell">
|
||||
<div class="filter-container unified-action-bar">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-input
|
||||
@@ -24,14 +23,12 @@
|
||||
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
|
||||
</el-form-item>
|
||||
<div class="filter-spacer"></div>
|
||||
<el-button type="primary" @click="goNew" class="header-action-btn">
|
||||
<el-button type="primary" @click="goNew">
|
||||
{{ TEXT.modules.financeContracts.newTitle }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card>
|
||||
<div class="unified-section table-section">
|
||||
<el-table
|
||||
:data="sortedItems"
|
||||
v-loading="loading"
|
||||
@@ -72,6 +69,7 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.financeContracts.empty" />
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -185,17 +183,6 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.filter-card :deep(.el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
@@ -203,18 +190,6 @@ onMounted(async () => {
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
background: white;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--ctms-border-color);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-item-form {
|
||||
@@ -230,10 +205,9 @@ onMounted(async () => {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-radius: 8px;
|
||||
.table-section {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
|
||||
<el-card class="filter-card" shadow="never">
|
||||
<div class="filter-row">
|
||||
<el-card shadow="never" class="main-content-card unified-shell">
|
||||
<div class="filter-container unified-action-bar">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-input
|
||||
@@ -29,14 +28,12 @@
|
||||
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
|
||||
</el-form-item>
|
||||
<div class="filter-spacer"></div>
|
||||
<el-button type="primary" @click="goNew" class="header-action-btn">
|
||||
<el-button type="primary" @click="goNew">
|
||||
{{ TEXT.modules.financeSpecials.newTitle }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card>
|
||||
<div class="unified-section table-section">
|
||||
<el-table
|
||||
:data="sortedItems"
|
||||
v-loading="loading"
|
||||
@@ -82,6 +79,7 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.financeSpecials.empty" />
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -194,17 +192,6 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.filter-card :deep(.el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
@@ -212,18 +199,6 @@ onMounted(async () => {
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
background: white;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--ctms-border-color);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-item-form {
|
||||
@@ -243,10 +218,9 @@ onMounted(async () => {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-radius: 8px;
|
||||
.table-section {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
|
||||
<el-card shadow="never" class="main-content-card">
|
||||
<div class="filter-container">
|
||||
<el-card shadow="never" class="main-content-card unified-shell">
|
||||
<div class="filter-container unified-action-bar">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-input
|
||||
@@ -24,11 +24,12 @@
|
||||
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
|
||||
</el-form-item>
|
||||
<div class="filter-spacer"></div>
|
||||
<el-button type="primary" @click="goNew" class="header-action-btn">
|
||||
<el-button type="primary" @click="goNew">
|
||||
{{ TEXT.modules.knowledgeNotes.newTitle }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="unified-section table-section">
|
||||
<el-table
|
||||
:data="sortedItems"
|
||||
v-loading="loading"
|
||||
@@ -58,6 +59,7 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.knowledgeNotes.empty" />
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -173,14 +175,6 @@ onMounted(async () => {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-content-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
@@ -201,10 +195,9 @@ onMounted(async () => {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-radius: 8px;
|
||||
.table-section {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<ModulePlaceholder
|
||||
:title="TEXT.modules.materialEquipment.title"
|
||||
:subtitle="TEXT.modules.materialEquipment.subtitle"
|
||||
:list-title="TEXT.modules.materialEquipment.listTitle"
|
||||
:empty-description="TEXT.modules.materialEquipment.emptyDescription"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ModulePlaceholder from "../../components/ModulePlaceholder.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<ModulePlaceholder
|
||||
:title="TEXT.modules.materialOthers.title"
|
||||
:subtitle="TEXT.modules.materialOthers.subtitle"
|
||||
:list-title="TEXT.modules.materialOthers.listTitle"
|
||||
:empty-description="TEXT.modules.materialOthers.emptyDescription"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ModulePlaceholder from "../../components/ModulePlaceholder.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<ModulePlaceholder
|
||||
:title="TEXT.modules.monitoringAudit.title"
|
||||
:subtitle="TEXT.modules.monitoringAudit.subtitle"
|
||||
:list-title="TEXT.modules.monitoringAudit.listTitle"
|
||||
:empty-description="TEXT.modules.monitoringAudit.emptyDescription"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ModulePlaceholder from "../../components/ModulePlaceholder.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<ModulePlaceholder
|
||||
:title="TEXT.modules.projectMilestones.title"
|
||||
:subtitle="TEXT.modules.projectMilestones.subtitle"
|
||||
:list-title="TEXT.modules.projectMilestones.listTitle"
|
||||
:empty-description="TEXT.modules.projectMilestones.emptyDescription"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ModulePlaceholder from "../../components/ModulePlaceholder.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user