立项配置页初步优化

This commit is contained in:
Cheng Zhou
2026-02-24 16:53:22 +08:00
parent 0693f09b1d
commit 8f3f717e48
124 changed files with 12519 additions and 2954 deletions
+1 -1
View File
@@ -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")
+775 -2
View File
@@ -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="项目编号已存在")
@@ -33,6 +363,26 @@ async def create_study(
is_active=True,
)
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
@@ -89,6 +439,25 @@ async def update_study(
existing = await study_crud.get_by_code(db, study_in.code)
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)
+28 -6
View File
@@ -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
+20
View File
@@ -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
View File
@@ -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:
+6 -2
View File
@@ -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)
await db.commit()
await db.refresh(log)
if auto_commit:
await db.commit()
await db.refresh(log)
else:
await db.flush()
return log
+5
View File
@@ -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))
+37 -19
View File
@@ -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()
+171
View File
@@ -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()
+37
View File
@@ -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)
+4
View File
@@ -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
+81
View File
@@ -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
+2
View File
@@ -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)
+1
View File
@@ -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(
+5 -3
View File
@@ -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())
+20 -2
View File
@@ -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()
)
+27
View File
@@ -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())
+8
View File
@@ -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",
+10 -3
View File
@@ -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)
+59 -2
View File
@@ -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
+137
View File
@@ -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)
+2
View File
@@ -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)
+246
View File
@@ -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
+1
View File
@@ -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
+481
View File
@@ -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())
+2
View File
@@ -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,
+1
View File
@@ -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": "" }
]
}
+64
View File
@@ -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`
- [ ] 本清单已全量勾选
- [ ] 发布说明已包含新增接口与权限规则
+180
View File
@@ -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`
+74
View File
@@ -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` 渲染,降低重复模板。
+97
View File
@@ -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"
+3 -2
View File
@@ -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>
+1
View File
@@ -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>
+18 -4
View File
@@ -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) {
ElMessage.warning(TEXT.common.messages.sessionLocked || "请解锁后继续操作");
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,10 +97,18 @@ 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 {
ElMessage.error(TEXT.common.messages.requestFailed);
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);
+31 -1
View File
@@ -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 });
+94 -40
View File
@@ -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-icon><Box /></el-icon>
<span>{{ TEXT.menu.drugShipments }}</span>
</el-menu-item>
<el-sub-menu index="materials">
<template #title>
<el-icon><Box /></el-icon>
<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-sub-menu index="risk-issues">
<template #title>
<el-icon><Flag /></el-icon>
<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.monitoring }}</span>
<span>{{ TEXT.menu.monitoringAudit }}</span>
</el-menu-item>
<el-menu-item index="/audit">
<el-menu-item index="/etmf">
<el-icon><Flag /></el-icon>
<span>{{ TEXT.menu.audit }}</span>
<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;
}
/* 过渡动画 */
+84 -5
View File
@@ -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,
};
};
+90 -2
View File
@@ -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: "结果",
+1
View File
@@ -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";
+75 -13
View File
@@ -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) {
+92 -16
View File
@@ -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,12 +94,28 @@ 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();
};
@@ -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();
};
+8
View File
@@ -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;
};
+9 -1
View File
@@ -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,
+181
View File
@@ -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;
}
}
+21
View File
@@ -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;
}
+116
View File
@@ -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;
}
+155
View File
@@ -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),
};
});
};
+33
View File
@@ -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;
};
+25 -21
View File
@@ -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,23 +24,23 @@
<el-button type="primary" @click="openForm()">{{ TEXT.modules.knowledgeMedicalConsult.newItem }}</el-button>
</PermissionAction>
</div>
</el-card>
<FaqList
:items="faqs"
:categories="categories"
:loading="loading"
:can-edit="canEdit"
@refresh="loadFaqs"
/>
<el-pagination
class="pagination"
layout="prev, pager, next"
:page-size="pageSize"
:current-page="page"
:total="total"
@current-change="onPageChange"
/>
<FaqList
:items="faqs"
:categories="categories"
:loading="loading"
:can-edit="canEdit"
@refresh="loadFaqs"
/>
<el-pagination
class="pagination"
layout="prev, pager, next"
:page-size="pageSize"
:current-page="page"
: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>
+3 -3
View File
@@ -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>
+11 -1
View File
@@ -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;
ElMessage.error(detail || TEXT.modules.auth.authFailed);
if (!status || status >= 500) {
ElMessage.error("服务暂时不可用,请稍后重试");
} else {
ElMessage.error(detail || TEXT.modules.auth.authFailed);
}
} finally {
loading.value = false;
}
+1 -1
View File
@@ -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">
+2 -2
View File
@@ -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>
+48 -49
View File
@@ -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,45 +13,47 @@
<div class="filter-spacer"></div>
</div>
</div>
<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" />
<el-table-column prop="role" :label="TEXT.common.labels.role" width="120">
<template #default="scope">{{ displayEnum(TEXT.enums.userRole, scope.row.role) }}</template>
</el-table-column>
<el-table-column prop="department" :label="TEXT.common.fields.department" min-width="160" />
<el-table-column prop="created_at" :label="TEXT.modules.adminUserApproval.createdAt" width="200">
<template #default="scope">{{ displayDateTime(scope.row.created_at) }}</template>
</el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status" width="120">
<template #default="scope">
<el-tag :type="statusType(scope.row.status)">{{ displayEnum(TEXT.enums.userStatus, scope.row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="220" align="center">
<template #default="scope">
<el-button
v-if="scope.row.status === 'PENDING'"
type="success"
link
size="small"
@click="onApprove(scope.row.id)"
>
{{ TEXT.modules.adminUserApproval.approve }}
</el-button>
<el-button
v-if="scope.row.status === 'PENDING'"
type="danger"
link
size="small"
@click="onReject(scope.row.id)"
>
{{ TEXT.modules.adminUserApproval.reject }}
</el-button>
<span v-else>{{ TEXT.common.fallback }}</span>
</template>
</el-table-column>
</el-table>
<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" />
<el-table-column prop="role" :label="TEXT.common.labels.role" width="120">
<template #default="scope">{{ displayEnum(TEXT.enums.userRole, scope.row.role) }}</template>
</el-table-column>
<el-table-column prop="department" :label="TEXT.common.fields.department" min-width="160" />
<el-table-column prop="created_at" :label="TEXT.modules.adminUserApproval.createdAt" width="200">
<template #default="scope">{{ displayDateTime(scope.row.created_at) }}</template>
</el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status" width="120">
<template #default="scope">
<el-tag :type="statusType(scope.row.status)">{{ displayEnum(TEXT.enums.userStatus, scope.row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="220" align="center">
<template #default="scope">
<el-button
v-if="scope.row.status === 'PENDING'"
type="success"
link
size="small"
@click="onApprove(scope.row.id)"
>
{{ TEXT.modules.adminUserApproval.approve }}
</el-button>
<el-button
v-if="scope.row.status === 'PENDING'"
type="danger"
link
size="small"
@click="onReject(scope.row.id)"
>
{{ TEXT.modules.adminUserApproval.reject }}
</el-button>
<span v-else>{{ TEXT.common.fallback }}</span>
</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>
+71 -55
View File
@@ -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,53 +48,55 @@
</div>
</el-form>
</div>
<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">
<span class="text-gray-500">{{ displayDateTime(scope.row.timestamp) }}</span>
</template>
</el-table-column>
<el-table-column prop="actorName" :label="TEXT.modules.adminAuditLogs.columns.actor" width="140" />
<el-table-column prop="actorRoleLabel" :label="TEXT.common.labels.role" width="120" />
<el-table-column prop="eventLabel" :label="TEXT.modules.adminAuditLogs.columns.event" width="160">
<template #default="scope">
<el-tag effect="plain">{{ scope.row.eventLabel }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="actionText" :label="TEXT.modules.adminAuditLogs.columns.action" min-width="180" />
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.target" min-width="180">
<template #default="scope">
<span v-if="scope.row.targetTypeLabel">
<strong>{{ scope.row.targetTypeLabel }}</strong>
<span class="text-gray-500" v-if="scope.row.targetName">{{ scope.row.targetName }}</span>
</span>
<span v-else>{{ TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.diff" min-width="260">
<template #default="scope">
<div v-if="scope.row.diffText?.length" class="diff-container">
<div v-for="(line, idx) in scope.row.diffText" :key="idx" class="diff-line">{{ line }}</div>
</div>
<span v-else class="text-gray-400">{{ TEXT.audit.emptyValue }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.result" width="100" align="center">
<template #default="scope">
<el-tag :type="scope.row.result === 'SUCCESS' ? 'success' : 'danger'" effect="dark" size="small">
{{ scope.row.resultLabel }}
</el-tag>
</template>
</el-table-column>
</el-table>
<el-pagination
class="pagination"
layout="prev, pager, next"
:page-size="pageSize"
:current-page="page"
:total="total"
@current-change="onPageChange"
/>
<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">
<span class="text-gray-500">{{ displayDateTime(scope.row.timestamp) }}</span>
</template>
</el-table-column>
<el-table-column prop="actorName" :label="TEXT.modules.adminAuditLogs.columns.actor" width="140" />
<el-table-column prop="actorRoleLabel" :label="TEXT.common.labels.role" width="120" />
<el-table-column prop="eventLabel" :label="TEXT.modules.adminAuditLogs.columns.event" width="160">
<template #default="scope">
<el-tag effect="plain">{{ scope.row.eventLabel }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="actionText" :label="TEXT.modules.adminAuditLogs.columns.action" min-width="180" />
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.target" min-width="180">
<template #default="scope">
<span v-if="scope.row.targetTypeLabel">
<strong>{{ scope.row.targetTypeLabel }}</strong>
<span class="text-gray-500" v-if="scope.row.targetName">{{ scope.row.targetName }}</span>
</span>
<span v-else>{{ TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.diff" min-width="260">
<template #default="scope">
<div v-if="scope.row.diffText?.length" class="diff-container">
<div v-for="(line, idx) in scope.row.diffText" :key="idx" class="diff-line">{{ line }}</div>
</div>
<span v-else class="text-gray-400">{{ TEXT.audit.emptyValue }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.result" width="100" align="center">
<template #default="scope">
<el-tag :type="scope.row.result === 'SUCCESS' ? 'success' : 'danger'" effect="dark" size="small">
{{ scope.row.resultLabel }}
</el-tag>
</template>
</el-table-column>
</el-table>
<el-pagination
class="pagination"
layout="prev, pager, next"
:page-size="pageSize"
:current-page="page"
: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
+3 -35
View File
@@ -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);
}
+66 -80
View File
@@ -1,67 +1,67 @@
<template>
<div class="page">
<el-card shadow="never" class="main-content-card">
<div class="filter-container">
<div class="filter-form">
<div class="filter-spacer"></div>
<el-button type="primary" @click="openAdd" class="header-action-btn">
{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjectMembers.memberLabel }}
</el-button>
</div>
<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">
{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjectMembers.memberLabel }}
</el-button>
</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">
<template #default="scope">
<el-tag>{{ displayEnum(TEXT.enums.userRole, scope.row.role_in_study) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.status" width="100">
<template #default="scope">
<el-tooltip
v-if="scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
:content="TEXT.modules.adminProjectMembers.disabledGlobalHint"
placement="top"
>
<el-tag type="danger">{{ TEXT.modules.adminProjectMembers.disabledGlobal }}</el-tag>
</el-tooltip>
<el-tag v-else :type="scope.row.is_active ? 'success' : 'danger'">
{{ scope.row.is_active ? TEXT.common.actions.enable : TEXT.modules.adminProjectMembers.disabled }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="added_at" :label="TEXT.modules.adminProjectMembers.addedAt" min-width="180">
<template #default="scope">{{ displayDateTime(scope.row.added_at) }}</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="300" align="center" class-name="action-column">
<template #default="scope">
<el-select
v-model="scope.row.role_in_study"
size="small"
style="width: 120px"
@change="(val: string) => updateRole(scope.row.id, val)"
:disabled="!scope.row.is_active || scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
>
<el-option :label="TEXT.enums.userRole.PM" value="PM" />
<el-option :label="TEXT.enums.userRole.CRA" value="CRA" />
<el-option :label="TEXT.enums.userRole.PV" value="PV" />
<el-option :label="TEXT.enums.userRole.IMP" value="IMP" />
<el-option :label="TEXT.enums.userRole.ADMIN" value="ADMIN" />
</el-select>
<span v-if="scope.row.effectiveStatus === 'DISABLED_GLOBAL'" class="hint">{{ TEXT.modules.adminProjectMembers.disabledGlobalDesc }}</span>
<el-button link type="danger" size="small" @click="onDelete(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
<el-button
link
:type="scope.row.is_active ? 'danger' : 'primary'"
size="small"
:disabled="scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
@click="toggleActive(scope.row)"
>
{{ scope.row.is_active ? TEXT.common.actions.disable : TEXT.common.actions.enable }}
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<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">
<template #default="scope">
<el-tag>{{ displayEnum(TEXT.enums.userRole, scope.row.role_in_study) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.status" width="100">
<template #default="scope">
<el-tooltip
v-if="scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
:content="TEXT.modules.adminProjectMembers.disabledGlobalHint"
placement="top"
>
<el-tag type="danger">{{ TEXT.modules.adminProjectMembers.disabledGlobal }}</el-tag>
</el-tooltip>
<el-tag v-else :type="scope.row.is_active ? 'success' : 'danger'">
{{ scope.row.is_active ? TEXT.common.actions.enable : TEXT.modules.adminProjectMembers.disabled }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="added_at" :label="TEXT.modules.adminProjectMembers.addedAt" min-width="180">
<template #default="scope">{{ displayDateTime(scope.row.added_at) }}</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="300" align="center" class-name="action-column">
<template #default="scope">
<el-select
v-model="scope.row.role_in_study"
size="small"
style="width: 120px"
@change="(val: string) => updateRole(scope.row.id, val)"
:disabled="!scope.row.is_active || scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
>
<el-option :label="TEXT.enums.userRole.PM" value="PM" />
<el-option :label="TEXT.enums.userRole.CRA" value="CRA" />
<el-option :label="TEXT.enums.userRole.PV" value="PV" />
<el-option :label="TEXT.enums.userRole.IMP" value="IMP" />
<el-option :label="TEXT.enums.userRole.ADMIN" value="ADMIN" />
</el-select>
<span v-if="scope.row.effectiveStatus === 'DISABLED_GLOBAL'" class="hint">{{ TEXT.modules.adminProjectMembers.disabledGlobalDesc }}</span>
<el-button link type="danger" size="small" @click="onDelete(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
<el-button
link
:type="scope.row.is_active ? 'danger' : 'primary'"
size="small"
:disabled="scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
@click="toggleActive(scope.row)"
>
{{ scope.row.is_active ? TEXT.common.actions.disable : TEXT.common.actions.enable }}
</el-button>
</template>
</el-table-column>
</el-table>
</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 {
+63 -96
View File
@@ -1,62 +1,62 @@
<template>
<div class="page">
<el-card shadow="never" class="main-content-card">
<div class="filter-container">
<div class="filter-form">
<div class="filter-spacer"></div>
<el-button type="primary" @click="openCreate" class="header-action-btn">
{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjects.projectLabel }}
</el-button>
</div>
<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">
{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjects.projectLabel }}
</el-button>
</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">
<el-link type="primary" @click="goDetail(scope.row)" class="font-medium">{{ scope.row.name }}</el-link>
</template>
</el-table-column>
<el-table-column prop="code" :label="TEXT.common.fields.projectCode" min-width="160">
<template #default="scope">
<span class="text-gray-500">{{ scope.row.code || "-" }}</span>
</template>
</el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status" min-width="120" align="center">
<template #default="scope">
<el-tag :type="statusTag(scope.row.status)" effect="plain" round>{{ statusLabel(scope.row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="is_locked" label="锁定状态" min-width="100" align="center">
<template #default="scope">
<el-tag v-if="scope.row.is_locked" type="warning" effect="plain" round>已锁定</el-tag>
<el-tag v-else type="success" effect="plain" round>未锁定</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" min-width="280" align="center">
<template #default="scope">
<el-tooltip :content="TEXT.modules.adminProjects.members" placement="top">
<el-button link type="primary" :icon="User" class="action-btn" @click="goMembers(scope.row)" />
</el-tooltip>
<el-tooltip :content="TEXT.modules.adminProjects.sites" placement="top">
<el-button link type="primary" :icon="OfficeBuilding" class="action-btn" @click="goSites(scope.row)" />
</el-tooltip>
<el-tooltip :content="TEXT.modules.adminProjects.enter" placement="top">
<el-button link type="success" :icon="ArrowRight" class="action-btn enter-btn" @click="enterStudy(scope.row)" />
</el-tooltip>
<el-tooltip v-if="!scope.row.is_locked" content="锁定项目" placement="top">
<el-button
link
type="info"
:icon="Lock"
class="action-btn"
@click="handleLockToggle(scope.row)"
/>
</el-tooltip>
<el-tooltip :content="TEXT.common.actions.delete" placement="top">
<el-button link type="danger" :icon="Delete" class="action-btn" @click="handleDelete(scope.row)" />
</el-tooltip>
</template>
</el-table-column>
</el-table>
</div>
<el-table :data="projects" v-loading="loading" stripe>
<el-table-column prop="name" :label="TEXT.common.fields.projectName" min-width="300">
<template #default="scope">
<el-link type="primary" @click="goDetail(scope.row)" class="font-medium">{{ scope.row.name }}</el-link>
</template>
</el-table-column>
<el-table-column prop="code" :label="TEXT.common.fields.projectCode" min-width="160">
<template #default="scope">
<span class="text-gray-500">{{ scope.row.code || "-" }}</span>
</template>
</el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status" min-width="120" align="center">
<template #default="scope">
<el-tag :type="statusTag(scope.row.status)" effect="plain" round>{{ statusLabel(scope.row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="is_locked" label="锁定状态" min-width="100" align="center">
<template #default="scope">
<el-tag v-if="scope.row.is_locked" type="warning" effect="plain" round>已锁定</el-tag>
<el-tag v-else type="success" effect="plain" round>未锁定</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" min-width="280" align="center">
<template #default="scope">
<el-tooltip :content="TEXT.modules.adminProjects.members" placement="top">
<el-button link type="primary" :icon="User" class="action-btn" @click="goMembers(scope.row)" />
</el-tooltip>
<el-tooltip :content="TEXT.modules.adminProjects.sites" placement="top">
<el-button link type="primary" :icon="OfficeBuilding" class="action-btn" @click="goSites(scope.row)" />
</el-tooltip>
<el-tooltip :content="TEXT.modules.adminProjects.enter" placement="top">
<el-button link type="success" :icon="ArrowRight" class="action-btn enter-btn" @click="enterStudy(scope.row)" />
</el-tooltip>
<el-tooltip v-if="!scope.row.is_locked" content="锁定项目" placement="top">
<el-button
link
type="info"
:icon="Lock"
class="action-btn"
@click="handleLockToggle(scope.row)"
/>
</el-tooltip>
<el-tooltip :content="TEXT.common.actions.delete" placement="top">
<el-button link type="danger" :icon="Delete" class="action-btn" @click="handleDelete(scope.row)" />
</el-tooltip>
</template>
</el-table-column>
</el-table>
</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;
+1 -8
View File
@@ -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) {
+103 -69
View File
@@ -1,57 +1,66 @@
<template>
<div class="page">
<el-card shadow="never" class="main-content-card">
<div class="filter-container">
<div class="filter-form">
<div class="filter-spacer"></div>
<el-button type="primary" @click="openCreate" class="header-action-btn">
{{ TEXT.common.actions.add }}{{ TEXT.modules.adminSites.siteLabel }}
</el-button>
</div>
<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">
{{ 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>
</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">
<template #default="scope">
{{ phoneText(scope.row) }}
</template>
</el-table-column>
<el-table-column prop="contact" :label="TEXT.common.fields.contact" width="160">
<template #default="scope">
{{ contactLabel(scope.row) }}
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.status" width="100">
<template #default="scope">
<el-tag type="success" v-if="scope.row.is_active">已解锁</el-tag>
<el-tag type="danger" v-else>已锁定</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="200" align="center" class-name="action-column">
<template #default="scope">
<el-button link type="primary" size="small" @click="openEdit(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
<el-button
link
:type="scope.row.is_active ? 'danger' : 'primary'"
size="small"
@click="toggleSite(scope.row)"
>
{{ scope.row.is_active ? "锁定" : "解锁" }}
</el-button>
<el-button
v-if="isAdmin"
link
type="danger"
size="small"
@click="confirmDelete(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template>
</el-table-column>
</el-table>
</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" />
<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">
<template #default="scope">
{{ phoneText(scope.row) }}
</template>
</el-table-column>
<el-table-column prop="contact" :label="TEXT.common.fields.contact" width="160">
<template #default="scope">
{{ contactLabel(scope.row) }}
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.status" width="100">
<template #default="scope">
<el-tag type="success" v-if="scope.row.is_active">已解锁</el-tag>
<el-tag type="danger" v-else>已锁定</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="200" align="center" class-name="action-column">
<template #default="scope">
<el-button link type="primary" size="small" @click="openEdit(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
<el-button
link
:type="scope.row.is_active ? 'danger' : 'primary'"
size="small"
@click="toggleSite(scope.row)"
>
{{ scope.row.is_active ? "锁定" : "解锁" }}
</el-button>
<el-button
v-if="isAdmin"
link
type="danger"
size="small"
@click="confirmDelete(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template>
</el-table-column>
</el-table>
</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;
+60 -67
View File
@@ -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,62 +23,64 @@
<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>
<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" />
<el-table-column prop="department" :label="TEXT.common.fields.department" min-width="140" />
<el-table-column :label="TEXT.common.fields.status" width="140">
<template #default="scope">
<el-tag :type="statusType(scope.row.status)">
{{ statusLabel(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" :label="TEXT.modules.adminUsers.createdAt" width="200">
<template #default="scope">{{ displayDateTime(scope.row.created_at) }}</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" min-width="200" align="center">
<template #default="scope">
<el-tooltip :content="TEXT.common.actions.edit" placement="top">
<el-button link type="primary" :icon="Edit" @click="openEdit(scope.row)" />
</el-tooltip>
<el-tooltip :content="scope.row.status === 'ACTIVE' ? TEXT.common.actions.disable : TEXT.common.actions.enable" placement="top">
<el-button
link
:type="scope.row.status === 'ACTIVE' ? 'warning' : 'success'"
:icon="scope.row.status === 'ACTIVE' ? Lock : Unlock"
:disabled="isLastAdmin(scope.row)"
@click="toggleStatus(scope.row)"
/>
</el-tooltip>
<el-tooltip :content="TEXT.modules.adminUsers.resetPassword" placement="top">
<el-button link type="warning" :icon="Key" @click="openReset(scope.row)" />
</el-tooltip>
<el-tooltip :content="TEXT.common.actions.delete" placement="top">
<el-button
link
type="danger"
:icon="Delete"
:disabled="scope.row.id === auth.user?.id"
@click="onDelete(scope.row)"
/>
</el-tooltip>
</template>
</el-table-column>
</el-table>
<el-pagination
background
layout="prev, pager, next, total"
:page-size="pageSize"
:total="total"
v-model:current-page="page"
@current-change="loadUsers"
class="pagination"
/>
<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" />
<el-table-column prop="department" :label="TEXT.common.fields.department" min-width="140" />
<el-table-column :label="TEXT.common.fields.status" width="140">
<template #default="scope">
<el-tag :type="statusType(scope.row.status)">
{{ statusLabel(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" :label="TEXT.modules.adminUsers.createdAt" width="200">
<template #default="scope">{{ displayDateTime(scope.row.created_at) }}</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" min-width="200" align="center">
<template #default="scope">
<el-tooltip :content="TEXT.common.actions.edit" placement="top">
<el-button link type="primary" :icon="Edit" @click="openEdit(scope.row)" />
</el-tooltip>
<el-tooltip :content="scope.row.status === 'ACTIVE' ? TEXT.common.actions.disable : TEXT.common.actions.enable" placement="top">
<el-button
link
:type="scope.row.status === 'ACTIVE' ? 'warning' : 'success'"
:icon="scope.row.status === 'ACTIVE' ? Lock : Unlock"
:disabled="isLastAdmin(scope.row)"
@click="toggleStatus(scope.row)"
/>
</el-tooltip>
<el-tooltip :content="TEXT.modules.adminUsers.resetPassword" placement="top">
<el-button link type="warning" :icon="Key" @click="openReset(scope.row)" />
</el-tooltip>
<el-tooltip :content="TEXT.common.actions.delete" placement="top">
<el-button
link
type="danger"
:icon="Delete"
:disabled="scope.row.id === auth.user?.id"
@click="onDelete(scope.row)"
/>
</el-tooltip>
</template>
</el-table-column>
</el-table>
<el-pagination
background
layout="prev, pager, next, total"
:page-size="pageSize"
:total="total"
v-model:current-page="page"
@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 {
+57 -41
View File
@@ -1,43 +1,44 @@
<template>
<div class="ctms-page">
<div class="ctms-page-header">
<div>
<h1 class="ctms-page-title">
{{ detail.title || TEXT.modules.fileVersionManagement.title }}
</h1>
<p class="ctms-page-subtitle">
<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>
<span class="text-secondary text-sm">
{{ 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">
<el-button @click="goBack">
<el-icon class="el-icon--left"><ArrowLeft /></el-icon>
{{ TEXT.common.actions.back }}
</el-button>
</div>
</div>
<div class="page">
<div class="unified-shell">
<section class="unified-section" v-loading="loading">
<div class="unified-section-header">
<div>
<div class="ctms-section-title">
{{ detail.title || TEXT.modules.fileVersionManagement.title }}
</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>
<span class="text-secondary text-sm">
{{ TEXT.modules.fileVersionManagement.labels.currentEffective }}
<span class="font-medium text-main">{{ detail.current_effective_version?.version_no || TEXT.common.fallback }}</span>
</span>
</div>
</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-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>
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.labels.currentEffective">
{{ currentEffectiveVersion?.version_no || TEXT.common.fallback }}
</el-descriptions-item>
<el-descriptions-item label="版本日期">
{{ formatDateOnly(currentEffectiveVersion?.effective_at || currentEffectiveVersion?.created_at || null) }}
</el-descriptions-item>
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.columns.updatedAt">{{ formatDate(detail.updated_at) }}</el-descriptions-item>
</el-descriptions>
</section>
<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>
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.labels.currentEffective">
{{ currentEffectiveVersion?.version_no || TEXT.common.fallback }}
</el-descriptions-item>
<el-descriptions-item label="版本日期">
{{ formatDateOnly(currentEffectiveVersion?.effective_at || currentEffectiveVersion?.created_at || null) }}
</el-descriptions-item>
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.columns.updatedAt">{{ formatDate(detail.updated_at) }}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-tabs v-model="activeTab" @tab-change="handleTabChange" class="ctms-tabs">
<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">
<div class="ctms-section-title">&nbsp;</div>
@@ -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 }">
@@ -127,7 +128,9 @@
</el-card>
</el-tab-pane>
</el-tabs>
</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);
}
+66 -64
View File
@@ -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,60 +28,62 @@
</el-button>
</el-form>
</div>
<el-table
:data="sortedItems"
v-loading="loading"
@row-click="handleRowClick"
:row-class-name="documentRowClass"
class="ctms-table"
>
<el-table-column prop="title" :label="TEXT.modules.fileVersionManagement.columns.title" min-width="200">
<template #default="{ row }">
<span class="font-semibold">{{ row.title }}</span>
<section class="unified-section">
<el-table
:data="sortedItems"
v-loading="loading"
@row-click="handleRowClick"
:row-class-name="documentRowClass"
class="ctms-table"
>
<el-table-column prop="title" :label="TEXT.modules.fileVersionManagement.columns.title" min-width="200">
<template #default="{ row }">
<span class="font-semibold">{{ row.title }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.scope" width="120">
<template #default="{ row }">
<el-tag
effect="plain"
:type="row.scope_type === 'SITE' ? 'warning' : 'success'"
>
{{ displayEnum(TEXT.enums.scopeType, row.scope_type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.site" min-width="160">
<template #default="{ row }">
<span>{{ displaySite(row.site_id) }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.docType" width="140">
<template #default="{ row }">
<el-tag effect="plain" type="info">{{ displayText(row.doc_type, TEXT.enums.documentType) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.currentVersion" width="140">
<template #default="{ row }">
<span v-if="row.current_effective_version">{{ row.current_effective_version.version_no }}</span>
<span v-else class="text-secondary">-</span>
</template>
</el-table-column>
<el-table-column prop="updated_at" :label="TEXT.modules.fileVersionManagement.columns.updatedAt" width="180">
<template #default="{ row }">
<span class="text-secondary">{{ formatDate(row.updated_at) }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="100" align="center">
<template #default="{ row }">
<el-button v-if="isAdmin" link type="danger" :disabled="isInactiveSite(row.site_id)" @click.stop="confirmDelete(row)">
{{ TEXT.common.actions.delete }}
</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty :description="TEXT.modules.fileVersionManagement.emptyDescription" />
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.scope" width="120">
<template #default="{ row }">
<el-tag
effect="plain"
:type="row.scope_type === 'SITE' ? 'warning' : 'success'"
>
{{ displayEnum(TEXT.enums.scopeType, row.scope_type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.site" min-width="160">
<template #default="{ row }">
<span>{{ displaySite(row.site_id) }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.docType" width="140">
<template #default="{ row }">
<el-tag effect="plain" type="info">{{ displayText(row.doc_type, TEXT.enums.documentType) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.currentVersion" width="140">
<template #default="{ row }">
<span v-if="row.current_effective_version">{{ row.current_effective_version.version_no }}</span>
<span v-else class="text-secondary">-</span>
</template>
</el-table-column>
<el-table-column prop="updated_at" :label="TEXT.modules.fileVersionManagement.columns.updatedAt" width="180">
<template #default="{ row }">
<span class="text-secondary">{{ formatDate(row.updated_at) }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="100" align="center">
<template #default="{ row }">
<el-button v-if="isAdmin" link type="danger" :disabled="isInactiveSite(row.site_id)" @click.stop="confirmDelete(row)">
{{ TEXT.common.actions.delete }}
</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty :description="TEXT.modules.fileVersionManagement.emptyDescription" />
</template>
</el-table>
</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>
+31 -60
View File
@@ -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">
<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>
<el-tag :type="detail.status === 'PENDING' ? 'info' : detail.status === 'SIGNED' ? 'success' : 'primary'">
{{ displayEnum(TEXT.enums.shipmentStatus, detail.status) }}
</el-tag>
<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>
</template>
</div>
<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>
<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>
</section>
<section class="unified-section">
<AttachmentList
v-if="shipmentId"
:study-id="studyId"
entity-type="drug_shipment"
:entity-id="shipmentId"
/>
</section>
</div>
<AttachmentList
v-if="shipmentId"
:study-id="studyId"
entity-type="drug_shipment"
:entity-id="shipmentId"
/>
</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>
+102 -125
View File
@@ -1,109 +1,108 @@
<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>
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div>
<el-card>
<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">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.site" prop="center_id">
<el-select v-model="form.center_id" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
<el-option
v-for="site in sites"
:key="site.id"
:label="site.name || TEXT.common.fallback"
:value="site.id"
:disabled="!site.is_active"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.direction" prop="direction">
<el-select v-model="form.direction" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
<el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" />
<el-option :label="TEXT.enums.shipmentDirection.RETURN" value="RETURN" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.status" prop="status">
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
<el-option :label="TEXT.enums.shipmentStatus.PENDING" value="PENDING" />
<el-option :label="TEXT.enums.shipmentStatus.IN_TRANSIT" value="IN_TRANSIT" />
<el-option :label="TEXT.enums.shipmentStatus.SIGNED" value="SIGNED" />
<el-option :label="TEXT.enums.shipmentStatus.RETURNED" value="RETURNED" />
<el-option :label="TEXT.enums.shipmentStatus.EXCEPTION" value="EXCEPTION" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<h3 class="section-title">{{ TEXT.modules.drugShipments.recordLabel }}</h3>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.shipDate" prop="ship_date">
<el-date-picker v-model="form.ship_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.receiveDate" prop="receive_date">
<el-date-picker v-model="form.receive_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.quantity" prop="quantity">
<el-input-number v-model="form.quantity" :min="0" :controls="false" :placeholder="TEXT.common.placeholders.input" style="width: 100%" :disabled="isReadOnly" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.batchNo" prop="batch_no">
<el-input v-model="form.batch_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.batchNo" :disabled="isReadOnly" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.carrier" prop="carrier">
<el-input v-model="form.carrier" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.carrier" :disabled="isReadOnly" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.trackingNo" prop="tracking_no">
<el-input v-model="form.tracking_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.trackingNo" :disabled="isReadOnly" />
</el-form-item>
</el-col>
</el-row>
<el-form-item :label="TEXT.common.fields.remark" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" :disabled="isReadOnly" />
</el-form-item>
<el-form-item>
<div class="actions">
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">
{{ TEXT.common.actions.save }}
</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
<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-form-item>
</el-form>
</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">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.site" prop="center_id">
<el-select v-model="form.center_id" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
<el-option
v-for="site in sites"
:key="site.id"
:label="site.name || TEXT.common.fallback"
:value="site.id"
:disabled="!site.is_active"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.direction" prop="direction">
<el-select v-model="form.direction" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
<el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" />
<el-option :label="TEXT.enums.shipmentDirection.RETURN" value="RETURN" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.status" prop="status">
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
<el-option :label="TEXT.enums.shipmentStatus.PENDING" value="PENDING" />
<el-option :label="TEXT.enums.shipmentStatus.IN_TRANSIT" value="IN_TRANSIT" />
<el-option :label="TEXT.enums.shipmentStatus.SIGNED" value="SIGNED" />
<el-option :label="TEXT.enums.shipmentStatus.RETURNED" value="RETURNED" />
<el-option :label="TEXT.enums.shipmentStatus.EXCEPTION" value="EXCEPTION" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<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>
<h3 class="section-title">{{ TEXT.modules.drugShipments.recordLabel }}</h3>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.shipDate" prop="ship_date">
<el-date-picker v-model="form.ship_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.receiveDate" prop="receive_date">
<el-date-picker v-model="form.receive_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.quantity" prop="quantity">
<el-input-number v-model="form.quantity" :min="0" :controls="false" :placeholder="TEXT.common.placeholders.input" style="width: 100%" :disabled="isReadOnly" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.batchNo" prop="batch_no">
<el-input v-model="form.batch_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.batchNo" :disabled="isReadOnly" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.carrier" prop="carrier">
<el-input v-model="form.carrier" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.carrier" :disabled="isReadOnly" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.trackingNo" prop="tracking_no">
<el-input v-model="form.tracking_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.trackingNo" :disabled="isReadOnly" />
</el-form-item>
</el-col>
</el-row>
<el-form-item :label="TEXT.common.fields.remark" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" :disabled="isReadOnly" />
</el-form-item>
<el-form-item>
<div class="actions">
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">
{{ TEXT.common.actions.save }}
</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
</div>
</el-form-item>
</el-form>
</section>
<section class="unified-section">
<AttachmentList
v-if="isEdit && shipmentId"
:study-id="studyId"
entity-type="drug_shipment"
:entity-id="shipmentId"
/>
<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 {
+20 -34
View File
@@ -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;
+7 -42
View File
@@ -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>
@@ -19,11 +9,12 @@
<StateLoading v-else-if="loading" :rows="6" />
<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-form ref="formRef" :model="form" :rules="rules" label-width="140px" label-position="top">
<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);
+94 -93
View File
@@ -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,94 +86,87 @@
</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" />
<el-table
:data="sortedContracts"
style="width: 100%"
@row-click="onRowClick"
:row-class-name="contractRowClass"
:header-cell-style="{ background: '#f8f9fb' }"
>
<el-table-column prop="center_name" :label="TEXT.common.fields.site" min-width="160">
<template #default="scope">
<div class="site-cell">
<span class="site-name">{{ scope.row.center_name || TEXT.common.fallback }}</span>
</div>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.feeContracts.contractAmount" width="140" align="right">
<template #default="scope">
<span class="amount-text">{{ formatAmountWan(scope.row.contract_amount) }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.feeContracts.caseProgress" min-width="140" align="center">
<template #default="scope">
<el-tag effect="plain" type="info">
{{ displayCases(scope.row.contract_cases, scope.row.actual_cases) }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.feeContracts.paidSummary" min-width="200">
<template #default="scope">
<div class="summary-line">
<span>{{ TEXT.modules.feeContracts.paidTotal }}</span>
<span class="amount-highlight success">{{ formatAmountWan(scope.row.paid_total) }}</span>
</div>
<div class="summary-line">
<span>{{ TEXT.modules.feeContracts.unpaidBalance }}</span>
<span class="amount-highlight warning" v-if="scope.row.unpaid_balance > 0">{{ formatAmountWan(scope.row.unpaid_balance) }}</span>
<span class="amount-muted" v-else>0.00</span>
</div>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.feeContracts.verifySummary" min-width="200">
<template #default="scope">
<div class="summary-line">
<span>{{ TEXT.modules.feeContracts.verifiedTotal }}</span>
<span class="amount-highlight info">{{ formatAmountWan(scope.row.verified_total) }}</span>
</div>
<div class="summary-line">
<span>{{ TEXT.modules.feeContracts.unverifiedBalance }}</span>
<span class="amount-highlight warning" v-if="scope.row.unverified_balance > 0">{{ formatAmountWan(scope.row.unverified_balance) }}</span>
<span class="amount-muted" v-else>0.00</span>
</div>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.feeContracts.recentDates" min-width="180">
<template #default="scope">
<div class="date-line">
<span class="date-label">{{ TEXT.modules.feeContracts.lastPaid }}</span>
<span class="date-value">{{ displayDate(scope.row.last_paid_date) }}</span>
</div>
<div class="date-line">
<span class="date-label">{{ TEXT.modules.feeContracts.lastVerified }}</span>
<span class="date-value">{{ displayDate(scope.row.last_verified_date) }}</span>
</div>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="100">
<template #default="scope">
<el-button
link
type="danger"
size="small"
:disabled="!canWrite || isInactiveSite(scope.row.center_id)"
@click.stop="remove(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template>
</el-table-column>
</el-table>
<StateEmpty v-if="sortedContracts.length === 0" :description="TEXT.modules.feeContracts.empty" />
<section class="unified-section">
<el-table
:data="sortedContracts"
style="width: 100%"
@row-click="onRowClick"
:row-class-name="contractRowClass"
:header-cell-style="{ background: '#f8f9fb' }"
>
<el-table-column prop="center_name" :label="TEXT.common.fields.site" min-width="160">
<template #default="scope">
<div class="site-cell">
<span class="site-name">{{ scope.row.center_name || TEXT.common.fallback }}</span>
</div>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.feeContracts.contractAmount" width="140" align="right">
<template #default="scope">
<span class="amount-text">{{ formatAmountWan(scope.row.contract_amount) }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.feeContracts.caseProgress" min-width="140" align="center">
<template #default="scope">
<el-tag effect="plain" type="info">
{{ displayCases(scope.row.contract_cases, scope.row.actual_cases) }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.feeContracts.paidSummary" min-width="200">
<template #default="scope">
<div class="summary-line">
<span>{{ TEXT.modules.feeContracts.paidTotal }}</span>
<span class="amount-highlight success">{{ formatAmountWan(scope.row.paid_total) }}</span>
</div>
<div class="summary-line">
<span>{{ TEXT.modules.feeContracts.unpaidBalance }}</span>
<span class="amount-highlight warning" v-if="scope.row.unpaid_balance > 0">{{ formatAmountWan(scope.row.unpaid_balance) }}</span>
<span class="amount-muted" v-else>0.00</span>
</div>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.feeContracts.verifySummary" min-width="200">
<template #default="scope">
<div class="summary-line">
<span>{{ TEXT.modules.feeContracts.verifiedTotal }}</span>
<span class="amount-highlight info">{{ formatAmountWan(scope.row.verified_total) }}</span>
</div>
<div class="summary-line">
<span>{{ TEXT.modules.feeContracts.unverifiedBalance }}</span>
<span class="amount-highlight warning" v-if="scope.row.unverified_balance > 0">{{ formatAmountWan(scope.row.unverified_balance) }}</span>
<span class="amount-muted" v-else>0.00</span>
</div>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.feeContracts.recentDates" min-width="180">
<template #default="scope">
<div class="date-line">
<span class="date-label">{{ TEXT.modules.feeContracts.lastPaid }}</span>
<span class="date-value">{{ displayDate(scope.row.last_paid_date) }}</span>
</div>
<div class="date-line">
<span class="date-label">{{ TEXT.modules.feeContracts.lastVerified }}</span>
<span class="date-value">{{ displayDate(scope.row.last_verified_date) }}</span>
</div>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="100">
<template #default="scope">
<el-button
link
type="danger"
size="small"
:disabled="!canWrite || isInactiveSite(scope.row.center_id)"
@click.stop="remove(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template>
</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 {
+12 -42
View File
@@ -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;
+83 -73
View File
@@ -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">
@@ -69,7 +76,7 @@
<el-option v-for="option in categoryOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</el-form-item>
<el-form-item label="" class="filter-item">
<el-form-item label="" class="filter-item">
<el-date-picker
v-model="filters.dateRange"
type="daterange"
@@ -90,73 +97,75 @@
</el-button>
</el-form>
</div>
<el-table
:data="sortedExpenses"
style="width: 100%"
@row-click="onRowClick"
:header-cell-style="{ background: '#f8f9fb' }"
:row-class-name="expenseRowClass"
>
<el-table-column :label="TEXT.common.fields.occurDate" width="140">
<template #default="scope">
<span class="date-text">{{ displayDate(scope.row.happen_date) }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.site" min-width="160">
<template #default="scope">
<span class="site-text">{{ scope.row.center_name || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.category" width="140">
<template #default="scope">
<el-tag size="small" :type="getCategoryType(scope.row.category)" effect="light" class="category-tag">
{{ displayEnum(TEXT.enums.feeSpecialCategory, scope.row.category) }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.amount" width="110" align="right">
<template #default="scope">
<span class="amount-text">{{ formatAmount(scope.row.amount) }}</span>
</template>
</el-table-column>
<el-table-column prop="description" :label="TEXT.common.fields.remark" min-width="200" show-overflow-tooltip>
<template #default="scope">
{{ scope.row.description || TEXT.common.fallback }}
</template>
</el-table-column>
<el-table-column width="140" align="center" :label="TEXT.common.fields.status">
<template #default="scope">
<el-tooltip :content="scope.row.is_paid ? `${TEXT.modules.feeSpecials.isPaid} (${displayDate(scope.row.paid_date)})` : TEXT.modules.feeSpecials.unpaid" placement="top">
<el-icon :class="scope.row.is_paid ? 'status-icon success' : 'status-icon muted'"><Wallet /></el-icon>
</el-tooltip>
<el-tooltip :content="scope.row.is_verified ? `${TEXT.modules.feeSpecials.isVerified} (${displayDate(scope.row.verified_date)})` : TEXT.modules.feeSpecials.unverified" placement="top">
<el-icon :class="scope.row.is_verified ? 'status-icon info' : 'status-icon muted'"><CircleCheck /></el-icon>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.feeSpecials.attachmentCount" width="120" align="center">
<template #default="scope">
<el-tag v-if="scope.row.attachments_count > 0" effect="plain" type="info" round size="small">
{{ scope.row.attachments_count }}
</el-tag>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120">
<template #default="scope">
<el-button
link
type="danger"
size="small"
:disabled="!canWrite || isInactiveSite(scope.row.center_id)"
@click.stop="remove(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template>
</el-table-column>
</el-table>
<StateEmpty v-if="sortedExpenses.length === 0" :description="TEXT.modules.feeSpecials.empty" />
<section class="unified-section">
<el-table
:data="sortedExpenses"
style="width: 100%"
@row-click="onRowClick"
:header-cell-style="{ background: '#f8f9fb' }"
:row-class-name="expenseRowClass"
>
<el-table-column :label="TEXT.common.fields.occurDate" width="140">
<template #default="scope">
<span class="date-text">{{ displayDate(scope.row.happen_date) }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.site" min-width="160">
<template #default="scope">
<span class="site-text">{{ scope.row.center_name || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.category" width="140">
<template #default="scope">
<el-tag size="small" :type="getCategoryType(scope.row.category)" effect="light" class="category-tag">
{{ displayEnum(TEXT.enums.feeSpecialCategory, scope.row.category) }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.amount" width="110" align="right">
<template #default="scope">
<span class="amount-text">{{ formatAmount(scope.row.amount) }}</span>
</template>
</el-table-column>
<el-table-column prop="description" :label="TEXT.common.fields.remark" min-width="200" show-overflow-tooltip>
<template #default="scope">
{{ scope.row.description || TEXT.common.fallback }}
</template>
</el-table-column>
<el-table-column width="140" align="center" :label="TEXT.common.fields.status">
<template #default="scope">
<el-tooltip :content="scope.row.is_paid ? `${TEXT.modules.feeSpecials.isPaid} (${displayDate(scope.row.paid_date)})` : TEXT.modules.feeSpecials.unpaid" placement="top">
<el-icon :class="scope.row.is_paid ? 'status-icon success' : 'status-icon muted'"><Wallet /></el-icon>
</el-tooltip>
<el-tooltip :content="scope.row.is_verified ? `${TEXT.modules.feeSpecials.isVerified} (${displayDate(scope.row.verified_date)})` : TEXT.modules.feeSpecials.unverified" placement="top">
<el-icon :class="scope.row.is_verified ? 'status-icon info' : 'status-icon muted'"><CircleCheck /></el-icon>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.feeSpecials.attachmentCount" width="120" align="center">
<template #default="scope">
<el-tag v-if="scope.row.attachments_count > 0" effect="plain" type="info" round size="small">
{{ scope.row.attachments_count }}
</el-tag>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120">
<template #default="scope">
<el-button
link
type="danger"
size="small"
:disabled="!canWrite || isInactiveSite(scope.row.center_id)"
@click.stop="remove(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template>
</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 {
+29 -53
View File
@@ -1,35 +1,34 @@
<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">
<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 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-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>
<el-descriptions-item :label="TEXT.common.fields.signedDate">{{ displayDate(detail.signed_date) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.amount">
{{ detail.amount }} {{ detail.currency || TEXT.common.fields.currencyDefault }}
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.remark" :span="2">{{ detail.remark || TEXT.common.fallback }}</el-descriptions-item>
</el-descriptions>
</section>
<section class="unified-section">
<AttachmentList
v-if="contractId"
:study-id="studyId"
entity-type="finance_contract"
:entity-id="contractId"
:readonly="isReadOnly"
/>
</section>
</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>
<el-descriptions-item :label="TEXT.common.fields.signedDate">{{ displayDate(detail.signed_date) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.amount">
{{ detail.amount }} {{ detail.currency || TEXT.common.fields.currencyDefault }}
</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>
<AttachmentList
v-if="contractId"
:study-id="studyId"
entity-type="finance_contract"
:entity-id="contractId"
:readonly="isReadOnly"
/>
</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>
+48 -71
View File
@@ -1,54 +1,53 @@
<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>
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
<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>
</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" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.contractNo" prop="contract_no" required>
<el-input v-model="form.contract_no" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.contractNo" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.signedDate">
<el-date-picker v-model="form.signed_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.amount" prop="amount" required>
<el-input-number v-model="form.amount" :disabled="isReadOnly" :min="0" :precision="2" :step="1000" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.currency">
<el-select v-model="form.currency" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select">
<el-option label="CNY" value="CNY" />
<el-option label="USD" value="USD" />
<el-option label="EUR" value="EUR" />
</el-select>
</el-form-item>
<el-form-item :label="TEXT.common.fields.remark">
<el-input v-model="form.remark" :disabled="isReadOnly" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">{{ TEXT.common.actions.save }}</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
</el-form-item>
</el-form>
</section>
<section class="unified-section">
<AttachmentList
v-if="isEdit && contractId"
:study-id="studyId"
entity-type="finance_contract"
:entity-id="contractId"
:readonly="isReadOnly"
/>
<StateEmpty v-else :description="TEXT.modules.financeContracts.uploadHint" />
</section>
</div>
<el-card>
<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" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.contractNo" prop="contract_no" required>
<el-input v-model="form.contract_no" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.contractNo" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.signedDate">
<el-date-picker v-model="form.signed_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.amount" prop="amount" required>
<el-input-number v-model="form.amount" :disabled="isReadOnly" :min="0" :precision="2" :step="1000" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.currency">
<el-select v-model="form.currency" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select">
<el-option label="CNY" value="CNY" />
<el-option label="USD" value="USD" />
<el-option label="EUR" value="EUR" />
</el-select>
</el-form-item>
<el-form-item :label="TEXT.common.fields.remark">
<el-input v-model="form.remark" :disabled="isReadOnly" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">{{ TEXT.common.actions.save }}</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
</el-form-item>
</el-form>
</el-card>
<AttachmentList
v-if="isEdit && contractId"
:study-id="studyId"
entity-type="finance_contract"
:entity-id="contractId"
:readonly="isReadOnly"
/>
<el-card v-else class="hint-card">
<StateEmpty :description="TEXT.modules.financeContracts.uploadHint" />
</el-card>
</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>
+28 -52
View File
@@ -1,34 +1,33 @@
<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">
<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 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-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>
<el-descriptions-item :label="TEXT.common.fields.occurDate">{{ displayDate(detail.occur_date) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.staff">{{ detail.staff_name || TEXT.common.fallback }}</el-descriptions-item>
<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>
</section>
<section class="unified-section">
<AttachmentList
v-if="specialId"
:study-id="studyId"
entity-type="finance_special"
:entity-id="specialId"
:readonly="isReadOnly"
/>
</section>
</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>
<el-descriptions-item :label="TEXT.common.fields.occurDate">{{ displayDate(detail.occur_date) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.staff">{{ detail.staff_name || TEXT.common.fallback }}</el-descriptions-item>
<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>
<AttachmentList
v-if="specialId"
:study-id="studyId"
entity-type="finance_special"
:entity-id="specialId"
:readonly="isReadOnly"
/>
</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>
+49 -72
View File
@@ -1,55 +1,54 @@
<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>
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
<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>
</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" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.type" required>
<el-select v-model="form.fee_type" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select">
<el-option :label="TEXT.enums.financeSpecialType.TRAVEL" value="TRAVEL" />
<el-option :label="TEXT.enums.financeSpecialType.HOTEL" value="HOTEL" />
<el-option :label="TEXT.enums.financeSpecialType.TRANSPORT" value="TRANSPORT" />
<el-option :label="TEXT.enums.financeSpecialType.OTHER" value="OTHER" />
</el-select>
</el-form-item>
<el-form-item :label="TEXT.common.fields.amount" required>
<el-input-number v-model="form.amount" :disabled="isReadOnly" :min="0" :precision="2" :step="100" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.occurDate">
<el-date-picker v-model="form.occur_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.staff">
<el-input v-model="form.staff_name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.staff" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.remark">
<el-input v-model="form.remark" :disabled="isReadOnly" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">{{ TEXT.common.actions.save }}</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
</el-form-item>
</el-form>
</section>
<section class="unified-section">
<AttachmentList
v-if="isEdit && specialId"
:study-id="studyId"
entity-type="finance_special"
:entity-id="specialId"
:readonly="isReadOnly"
/>
<StateEmpty v-else :description="TEXT.modules.financeSpecials.uploadHint" />
</section>
</div>
<el-card>
<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" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.type" required>
<el-select v-model="form.fee_type" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select">
<el-option :label="TEXT.enums.financeSpecialType.TRAVEL" value="TRAVEL" />
<el-option :label="TEXT.enums.financeSpecialType.HOTEL" value="HOTEL" />
<el-option :label="TEXT.enums.financeSpecialType.TRANSPORT" value="TRANSPORT" />
<el-option :label="TEXT.enums.financeSpecialType.OTHER" value="OTHER" />
</el-select>
</el-form-item>
<el-form-item :label="TEXT.common.fields.amount" required>
<el-input-number v-model="form.amount" :disabled="isReadOnly" :min="0" :precision="2" :step="100" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.occurDate">
<el-date-picker v-model="form.occur_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.staff">
<el-input v-model="form.staff_name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.staff" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.remark">
<el-input v-model="form.remark" :disabled="isReadOnly" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">{{ TEXT.common.actions.save }}</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
</el-form-item>
</el-form>
</el-card>
<AttachmentList
v-if="isEdit && specialId"
:study-id="studyId"
entity-type="finance_special"
:entity-id="specialId"
:readonly="isReadOnly"
/>
<el-card v-else class="hint-card">
<StateEmpty :description="TEXT.modules.financeSpecials.uploadHint" />
</el-card>
</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>
+62 -69
View File
@@ -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,66 +42,68 @@
<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>
<el-table
:data="sortedItems"
v-loading="loading"
style="width: 100%"
:row-class-name="shipmentRowClass"
@row-click="onRowClick"
>
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160">
<template #default="scope">{{ scope.row.site_name || TEXT.common.fallback }}</template>
</el-table-column>
<el-table-column prop="direction" :label="TEXT.common.fields.direction" width="120">
<template #default="scope">
<el-tag :class="['type-tag', scope.row.direction === 'SEND' ? 'type-send' : 'type-return']" effect="light">
{{ displayEnum(TEXT.enums.shipmentDirection, scope.row.direction) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="ship_date" :label="TEXT.common.fields.shipDate" width="140">
<template #default="scope">{{ displayDate(scope.row.ship_date) }}</template>
</el-table-column>
<el-table-column prop="receive_date" :label="TEXT.common.fields.receiveDate" width="140">
<template #default="scope">{{ displayDate(scope.row.receive_date) }}</template>
</el-table-column>
<el-table-column prop="quantity" :label="TEXT.common.fields.quantity" width="120" align="right">
<template #default="scope">{{ typeof scope.row.quantity === "number" ? scope.row.quantity : TEXT.common.fallback }}</template>
</el-table-column>
<el-table-column prop="batch_no" :label="TEXT.common.fields.batchNo" min-width="140">
<template #default="scope">{{ scope.row.batch_no || TEXT.common.fallback }}</template>
</el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status" width="140">
<template #default="scope">
<el-tag :type="statusType(scope.row.status)" effect="plain" round size="small" class="status-tag">
{{ displayEnum(TEXT.enums.shipmentStatus, scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="remark" :label="TEXT.common.fields.remark" min-width="160" show-overflow-tooltip>
<template #default="scope">{{ scope.row.remark || TEXT.common.fallback }}</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120" align="center">
<template #default="scope">
<el-button
link
type="danger"
size="small"
:disabled="isInactiveSite(scope.row.center_id)"
@click.stop="remove(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template>
</el-table-column>
</el-table>
<StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.drugShipments.empty" />
<div class="unified-section table-section">
<el-table
:data="sortedItems"
v-loading="loading"
style="width: 100%"
:row-class-name="shipmentRowClass"
@row-click="onRowClick"
>
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160">
<template #default="scope">{{ scope.row.site_name || TEXT.common.fallback }}</template>
</el-table-column>
<el-table-column prop="direction" :label="TEXT.common.fields.direction" width="120">
<template #default="scope">
<el-tag :class="['type-tag', scope.row.direction === 'SEND' ? 'type-send' : 'type-return']" effect="light">
{{ displayEnum(TEXT.enums.shipmentDirection, scope.row.direction) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="ship_date" :label="TEXT.common.fields.shipDate" width="140">
<template #default="scope">{{ displayDate(scope.row.ship_date) }}</template>
</el-table-column>
<el-table-column prop="receive_date" :label="TEXT.common.fields.receiveDate" width="140">
<template #default="scope">{{ displayDate(scope.row.receive_date) }}</template>
</el-table-column>
<el-table-column prop="quantity" :label="TEXT.common.fields.quantity" width="120" align="right">
<template #default="scope">{{ typeof scope.row.quantity === "number" ? scope.row.quantity : TEXT.common.fallback }}</template>
</el-table-column>
<el-table-column prop="batch_no" :label="TEXT.common.fields.batchNo" min-width="140">
<template #default="scope">{{ scope.row.batch_no || TEXT.common.fallback }}</template>
</el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status" width="140">
<template #default="scope">
<el-tag :type="statusType(scope.row.status)" effect="plain" round size="small" class="status-tag">
{{ displayEnum(TEXT.enums.shipmentStatus, scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="remark" :label="TEXT.common.fields.remark" min-width="160" show-overflow-tooltip>
<template #default="scope">{{ scope.row.remark || TEXT.common.fallback }}</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120" align="center">
<template #default="scope">
<el-button
link
type="danger"
size="small"
:disabled="isInactiveSite(scope.row.center_id)"
@click.stop="remove(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template>
</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 {
+13
View File
@@ -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>
+48 -74
View File
@@ -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,54 +23,53 @@
<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>
<el-table
:data="sortedItems"
v-loading="loading"
style="width: 100%"
class="ctms-table"
@row-click="onRowClick"
:row-class-name="contractRowClass"
>
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" />
<el-table-column prop="contract_no" :label="TEXT.common.fields.contractNo" min-width="160" />
<el-table-column prop="signed_date" :label="TEXT.common.fields.signedDate" width="140">
<template #default="scope">
{{ displayDate(scope.row.signed_date) }}
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.amount" width="160">
<template #default="scope">
{{ scope.row.amount }} {{ scope.row.currency || TEXT.common.fields.currencyDefault }}
</template>
</el-table-column>
<el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" width="180">
<template #default="scope">
{{ displayDateTime(scope.row.updated_at) }}
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120">
<template #default="scope">
<el-button
link
type="danger"
size="small"
:disabled="isInactiveSite(scope.row.site_name)"
@click.stop="remove(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template>
</el-table-column>
</el-table>
<StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.financeContracts.empty" />
<div class="unified-section table-section">
<el-table
:data="sortedItems"
v-loading="loading"
style="width: 100%"
class="ctms-table"
@row-click="onRowClick"
:row-class-name="contractRowClass"
>
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" />
<el-table-column prop="contract_no" :label="TEXT.common.fields.contractNo" min-width="160" />
<el-table-column prop="signed_date" :label="TEXT.common.fields.signedDate" width="140">
<template #default="scope">
{{ displayDate(scope.row.signed_date) }}
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.amount" width="160">
<template #default="scope">
{{ scope.row.amount }} {{ scope.row.currency || TEXT.common.fields.currencyDefault }}
</template>
</el-table-column>
<el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" width="180">
<template #default="scope">
{{ displayDateTime(scope.row.updated_at) }}
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120">
<template #default="scope">
<el-button
link
type="danger"
size="small"
:disabled="isInactiveSite(scope.row.site_name)"
@click.stop="remove(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template>
</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;
}
+53 -79
View File
@@ -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,59 +28,58 @@
<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>
<el-table
:data="sortedItems"
v-loading="loading"
style="width: 100%"
class="ctms-table"
@row-click="onRowClick"
:row-class-name="specialRowClass"
>
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" />
<el-table-column prop="fee_type" :label="TEXT.common.fields.type" width="120">
<template #default="scope">
{{ displayEnum(TEXT.enums.financeSpecialType, scope.row.fee_type) }}
</template>
</el-table-column>
<el-table-column prop="occur_date" :label="TEXT.common.fields.occurDate" width="140">
<template #default="scope">
{{ displayDate(scope.row.occur_date) }}
</template>
</el-table-column>
<el-table-column prop="staff_name" :label="TEXT.common.fields.staff" min-width="120" />
<el-table-column :label="TEXT.common.fields.amount" width="140">
<template #default="scope">
{{ scope.row.amount }}
</template>
</el-table-column>
<el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" width="180">
<template #default="scope">
{{ displayDateTime(scope.row.updated_at) }}
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120">
<template #default="scope">
<el-button
link
type="danger"
size="small"
:disabled="isInactiveSite(scope.row.site_name)"
@click.stop="remove(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template>
</el-table-column>
</el-table>
<StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.financeSpecials.empty" />
<div class="unified-section table-section">
<el-table
:data="sortedItems"
v-loading="loading"
style="width: 100%"
class="ctms-table"
@row-click="onRowClick"
:row-class-name="specialRowClass"
>
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" />
<el-table-column prop="fee_type" :label="TEXT.common.fields.type" width="120">
<template #default="scope">
{{ displayEnum(TEXT.enums.financeSpecialType, scope.row.fee_type) }}
</template>
</el-table-column>
<el-table-column prop="occur_date" :label="TEXT.common.fields.occurDate" width="140">
<template #default="scope">
{{ displayDate(scope.row.occur_date) }}
</template>
</el-table-column>
<el-table-column prop="staff_name" :label="TEXT.common.fields.staff" min-width="120" />
<el-table-column :label="TEXT.common.fields.amount" width="140">
<template #default="scope">
{{ scope.row.amount }}
</template>
</el-table-column>
<el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" width="180">
<template #default="scope">
{{ displayDateTime(scope.row.updated_at) }}
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120">
<template #default="scope">
<el-button
link
type="danger"
size="small"
:disabled="isInactiveSite(scope.row.site_name)"
@click.stop="remove(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template>
</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>
+37 -44
View File
@@ -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,40 +24,42 @@
<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>
<el-table
:data="sortedItems"
v-loading="loading"
style="width: 100%"
class="ctms-table"
@row-click="onRowClick"
:row-class-name="noteRowClass"
>
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" />
<el-table-column prop="title" :label="TEXT.common.fields.title" min-width="200" />
<el-table-column prop="level" :label="TEXT.common.fields.level" width="120" />
<el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" width="180">
<template #default="scope">{{ displayDateTime(scope.row.updated_at) }}</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120">
<template #default="scope">
<el-button
link
type="danger"
size="small"
:disabled="isInactiveSite(scope.row.site_name)"
@click.stop="remove(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template>
</el-table-column>
</el-table>
<StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.knowledgeNotes.empty" />
<div class="unified-section table-section">
<el-table
:data="sortedItems"
v-loading="loading"
style="width: 100%"
class="ctms-table"
@row-click="onRowClick"
:row-class-name="noteRowClass"
>
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" />
<el-table-column prop="title" :label="TEXT.common.fields.title" min-width="200" />
<el-table-column prop="level" :label="TEXT.common.fields.level" width="120" />
<el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" width="180">
<template #default="scope">{{ displayDateTime(scope.row.updated_at) }}</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120">
<template #default="scope">
<el-button
link
type="danger"
size="small"
:disabled="isInactiveSite(scope.row.site_name)"
@click.stop="remove(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template>
</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>
+13
View File
@@ -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