立项配置数据入库、审计日志数据入库、编辑页UI界面美化、设备管理内容补充、审计日志显示优化
This commit is contained in:
@@ -0,0 +1,24 @@
|
|||||||
|
name: Storage Persistence Guard
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
storage-persistence-audit:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
|
||||||
|
- name: Run storage persistence guard
|
||||||
|
run: |
|
||||||
|
python backend/scripts/storage_persistence_audit.py --format markdown --output docs/storage-persistence-audit.md --fail-on-banned
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""add material_equipments table
|
||||||
|
|
||||||
|
Revision ID: 20260227_01
|
||||||
|
Revises: 20260213_03
|
||||||
|
Create Date: 2026-02-27 10: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 = "20260227_01"
|
||||||
|
down_revision: Union[str, None] = "20260213_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)
|
||||||
|
|
||||||
|
if "material_equipments" not in inspector.get_table_names():
|
||||||
|
op.create_table(
|
||||||
|
"material_equipments",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("study_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("spec_model", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("unit", sa.String(length=50), nullable=True),
|
||||||
|
sa.Column("brand", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("origin", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("production_permit_file_name", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("tech_index_file_name", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("need_calibration", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
sa.Column("calibration_cycle_days", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_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(["study_id"], ["studies.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["created_by"], ["users.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
indexes = {idx["name"] for idx in inspector.get_indexes("material_equipments")}
|
||||||
|
index_name = op.f("ix_material_equipments_study_id")
|
||||||
|
if index_name not in indexes:
|
||||||
|
op.create_index(index_name, "material_equipments", ["study_id"], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
|
||||||
|
if "material_equipments" in inspector.get_table_names():
|
||||||
|
indexes = {idx["name"] for idx in inspector.get_indexes("material_equipments")}
|
||||||
|
index_name = op.f("ix_material_equipments_study_id")
|
||||||
|
if index_name in indexes:
|
||||||
|
op.drop_index(index_name, table_name="material_equipments")
|
||||||
|
op.drop_table("material_equipments")
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""add milestone editor fields
|
||||||
|
|
||||||
|
Revision ID: 20260227_02
|
||||||
|
Revises: 20260227_01
|
||||||
|
Create Date: 2026-02-27 11:20:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "20260227_02"
|
||||||
|
down_revision: Union[str, None] = "20260227_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("milestones")}
|
||||||
|
|
||||||
|
if "adjusted_start_date" not in columns:
|
||||||
|
op.add_column("milestones", sa.Column("adjusted_start_date", sa.Date(), nullable=True))
|
||||||
|
if "adjusted_end_date" not in columns:
|
||||||
|
op.add_column("milestones", sa.Column("adjusted_end_date", sa.Date(), nullable=True))
|
||||||
|
if "actual_start_date" not in columns:
|
||||||
|
op.add_column("milestones", sa.Column("actual_start_date", sa.Date(), nullable=True))
|
||||||
|
if "actual_end_date" not in columns:
|
||||||
|
op.add_column("milestones", sa.Column("actual_end_date", sa.Date(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
columns = {col["name"] for col in inspector.get_columns("milestones")}
|
||||||
|
|
||||||
|
if "actual_end_date" in columns:
|
||||||
|
op.drop_column("milestones", "actual_end_date")
|
||||||
|
if "actual_start_date" in columns:
|
||||||
|
op.drop_column("milestones", "actual_start_date")
|
||||||
|
if "adjusted_end_date" in columns:
|
||||||
|
op.drop_column("milestones", "adjusted_end_date")
|
||||||
|
if "adjusted_start_date" in columns:
|
||||||
|
op.drop_column("milestones", "adjusted_start_date")
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""add published project snapshot to study_setup_configs
|
||||||
|
|
||||||
|
Revision ID: 20260227_03
|
||||||
|
Revises: 20260227_02
|
||||||
|
Create Date: 2026-02-27 12: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 = "20260227_03"
|
||||||
|
down_revision: Union[str, None] = "20260227_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_configs")}
|
||||||
|
if "published_project_snapshot" not in columns:
|
||||||
|
op.add_column(
|
||||||
|
"study_setup_configs",
|
||||||
|
sa.Column("published_project_snapshot", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
columns = {col["name"] for col in inspector.get_columns("study_setup_configs")}
|
||||||
|
if "published_project_snapshot" in columns:
|
||||||
|
op.drop_column("study_setup_configs", "published_project_snapshot")
|
||||||
@@ -3,9 +3,9 @@ import uuid
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.deps import get_db_session, require_roles, require_study_member
|
from app.core.deps import get_current_user, get_db_session, require_roles, require_study_member
|
||||||
from app.crud import audit as audit_crud
|
from app.crud import audit as audit_crud
|
||||||
from app.schemas.audit import AuditLogRead
|
from app.schemas.audit import AuditEventCreate, AuditLogRead
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -52,3 +52,30 @@ async def delete_audit_log(
|
|||||||
if not log or log.study_id != study_id:
|
if not log or log.study_id != study_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="审计日志不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="审计日志不存在")
|
||||||
await audit_crud.delete_log(db, log)
|
await audit_crud.delete_log(db, log)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/events",
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
dependencies=[Depends(require_study_member())],
|
||||||
|
)
|
||||||
|
async def create_audit_event(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
payload: AuditEventCreate,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
allowed_actions = {"AUDIT_EXPORT_SYSTEM", "AUDIT_EXPORT_PROJECT"}
|
||||||
|
if payload.action not in allowed_actions:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不支持的审计事件")
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type=payload.entity_type or "audit_log",
|
||||||
|
entity_id=payload.entity_id,
|
||||||
|
action=payload.action,
|
||||||
|
detail=payload.detail,
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
|
return {"ok": True}
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_not_locked, require_study_roles
|
||||||
|
from app.crud import audit as audit_crud
|
||||||
|
from app.crud import material_equipment as equipment_crud
|
||||||
|
from app.crud import study as study_crud
|
||||||
|
from app.schemas.material_equipment import MaterialEquipmentCreate, MaterialEquipmentRead, MaterialEquipmentUpdate
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
||||||
|
study = await study_crud.get(db, study_id)
|
||||||
|
if not study:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||||||
|
return study
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_calibration(need_calibration: bool, calibration_cycle_days: int | None):
|
||||||
|
if need_calibration and (calibration_cycle_days is None or calibration_cycle_days < 1):
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="需要校准时校准周期必须大于0")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/equipment",
|
||||||
|
response_model=MaterialEquipmentRead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
|
||||||
|
)
|
||||||
|
async def create_equipment(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
equipment_in: MaterialEquipmentCreate,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> MaterialEquipmentRead:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
_validate_calibration(equipment_in.need_calibration, equipment_in.calibration_cycle_days)
|
||||||
|
if not equipment_in.need_calibration:
|
||||||
|
equipment_in = equipment_in.model_copy(update={"calibration_cycle_days": None})
|
||||||
|
item = await equipment_crud.create_equipment(db, study_id, equipment_in, created_by=current_user.id)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="material_equipment",
|
||||||
|
entity_id=item.id,
|
||||||
|
action="CREATE_MATERIAL_EQUIPMENT",
|
||||||
|
detail=f"设备 {item.id} 已创建",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
|
return MaterialEquipmentRead.model_validate(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/equipment",
|
||||||
|
response_model=list[MaterialEquipmentRead],
|
||||||
|
dependencies=[Depends(require_study_member())],
|
||||||
|
)
|
||||||
|
async def list_equipments(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
name: str | None = None,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> list[MaterialEquipmentRead]:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
items = await equipment_crud.list_equipments(db, study_id, name=name, skip=skip, limit=limit)
|
||||||
|
return [MaterialEquipmentRead.model_validate(item) for item in items]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/equipment/{equipment_id}",
|
||||||
|
response_model=MaterialEquipmentRead,
|
||||||
|
dependencies=[Depends(require_study_member())],
|
||||||
|
)
|
||||||
|
async def get_equipment(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
equipment_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> MaterialEquipmentRead:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
item = await equipment_crud.get_equipment(db, equipment_id)
|
||||||
|
if not item or item.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="设备记录不存在")
|
||||||
|
return MaterialEquipmentRead.model_validate(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/equipment/{equipment_id}",
|
||||||
|
response_model=MaterialEquipmentRead,
|
||||||
|
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
|
||||||
|
)
|
||||||
|
async def update_equipment(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
equipment_id: uuid.UUID,
|
||||||
|
equipment_in: MaterialEquipmentUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> MaterialEquipmentRead:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
item = await equipment_crud.get_equipment(db, equipment_id)
|
||||||
|
if not item or item.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="设备记录不存在")
|
||||||
|
|
||||||
|
next_need_calibration = equipment_in.need_calibration if equipment_in.need_calibration is not None else item.need_calibration
|
||||||
|
next_cycle = equipment_in.calibration_cycle_days if equipment_in.calibration_cycle_days is not None else item.calibration_cycle_days
|
||||||
|
_validate_calibration(next_need_calibration, next_cycle)
|
||||||
|
if not next_need_calibration:
|
||||||
|
equipment_in = equipment_in.model_copy(update={"calibration_cycle_days": None})
|
||||||
|
|
||||||
|
item = await equipment_crud.update_equipment(db, item, equipment_in)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="material_equipment",
|
||||||
|
entity_id=equipment_id,
|
||||||
|
action="UPDATE_MATERIAL_EQUIPMENT",
|
||||||
|
detail=f"设备 {equipment_id} 已更新",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
|
return MaterialEquipmentRead.model_validate(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/equipment/{equipment_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
|
||||||
|
)
|
||||||
|
async def delete_equipment(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
equipment_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> None:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
item = await equipment_crud.get_equipment(db, equipment_id)
|
||||||
|
if not item or item.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="设备记录不存在")
|
||||||
|
await equipment_crud.delete_equipment(db, item)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="material_equipment",
|
||||||
|
entity_id=equipment_id,
|
||||||
|
action="DELETE_MATERIAL_EQUIPMENT",
|
||||||
|
detail=f"设备 {equipment_id} 已删除",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
import json
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.deps import get_db_session, require_study_member, require_study_roles, require_study_not_locked
|
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
|
||||||
|
from app.crud import audit as audit_crud
|
||||||
from app.crud import member as member_crud
|
from app.crud import member as member_crud
|
||||||
from app.crud import study as study_crud
|
from app.crud import study as study_crud
|
||||||
from app.crud import user as user_crud
|
from app.crud import user as user_crud
|
||||||
@@ -30,6 +32,7 @@ async def add_member(
|
|||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
member_in: StudyMemberCreate,
|
member_in: StudyMemberCreate,
|
||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
) -> StudyMemberRead:
|
) -> StudyMemberRead:
|
||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
existing = await member_crud.get_member(db, study_id, member_in.user_id)
|
existing = await member_crud.get_member(db, study_id, member_in.user_id)
|
||||||
@@ -40,9 +43,33 @@ async def add_member(
|
|||||||
existing,
|
existing,
|
||||||
StudyMemberUpdate(is_active=True, role_in_study=member_in.role_in_study),
|
StudyMemberUpdate(is_active=True, role_in_study=member_in.role_in_study),
|
||||||
)
|
)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="study_member",
|
||||||
|
entity_id=updated.id,
|
||||||
|
action="PROJECT_MEMBER_UPDATED",
|
||||||
|
detail=json.dumps({
|
||||||
|
"targetName": str(updated.user_id),
|
||||||
|
"before": {"is_active": existing.is_active, "role_in_study": existing.role_in_study},
|
||||||
|
"after": {"is_active": updated.is_active, "role_in_study": updated.role_in_study},
|
||||||
|
}, ensure_ascii=False),
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
return updated
|
return updated
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="成员已存在")
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="成员已存在")
|
||||||
member = await member_crud.add_member(db, study_id, member_in)
|
member = await member_crud.add_member(db, study_id, member_in)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="study_member",
|
||||||
|
entity_id=member.id,
|
||||||
|
action="PROJECT_MEMBER_ADDED",
|
||||||
|
detail=json.dumps({"targetName": str(member.user_id), "after": {"role_in_study": member.role_in_study, "is_active": member.is_active}}, ensure_ascii=False),
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
return member
|
return member
|
||||||
|
|
||||||
|
|
||||||
@@ -95,12 +122,25 @@ async def update_member(
|
|||||||
member_id: uuid.UUID,
|
member_id: uuid.UUID,
|
||||||
member_in: StudyMemberUpdate,
|
member_in: StudyMemberUpdate,
|
||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
) -> StudyMemberRead:
|
) -> StudyMemberRead:
|
||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
member = await member_crud.get_member_by_id(db, member_id)
|
member = await member_crud.get_member_by_id(db, member_id)
|
||||||
if not member or member.study_id != study_id:
|
if not member or member.study_id != study_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="成员不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="成员不存在")
|
||||||
|
before_data = {"role_in_study": member.role_in_study, "is_active": member.is_active}
|
||||||
updated = await member_crud.update_member(db, member, member_in)
|
updated = await member_crud.update_member(db, member, member_in)
|
||||||
|
after_data = {"role_in_study": updated.role_in_study, "is_active": updated.is_active}
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="study_member",
|
||||||
|
entity_id=updated.id,
|
||||||
|
action="PROJECT_MEMBER_UPDATED",
|
||||||
|
detail=json.dumps({"targetName": str(updated.user_id), "before": before_data, "after": after_data}, ensure_ascii=False),
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
|
|
||||||
@@ -113,10 +153,22 @@ async def remove_member(
|
|||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
member_id: uuid.UUID,
|
member_id: uuid.UUID,
|
||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
) -> StudyMemberRead:
|
) -> StudyMemberRead:
|
||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
member = await member_crud.get_member_by_id(db, member_id)
|
member = await member_crud.get_member_by_id(db, member_id)
|
||||||
if not member or member.study_id != study_id:
|
if not member or member.study_id != study_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="成员不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="成员不存在")
|
||||||
|
before_data = {"role_in_study": member.role_in_study, "is_active": member.is_active}
|
||||||
removed = await member_crud.remove_member(db, member)
|
removed = await member_crud.remove_member(db, member)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="study_member",
|
||||||
|
entity_id=removed.id,
|
||||||
|
action="PROJECT_MEMBER_REMOVED",
|
||||||
|
detail=json.dumps({"targetName": str(removed.user_id), "before": before_data, "after": {"is_active": removed.is_active}}, ensure_ascii=False),
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
return removed
|
return removed
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_not_locked, require_study_roles
|
||||||
|
from app.crud import audit as audit_crud
|
||||||
|
from app.crud import project_milestone as milestone_crud
|
||||||
|
from app.crud import study as study_crud
|
||||||
|
from app.schemas.project_milestone import ProjectMilestoneRead, ProjectMilestoneUpdate
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
||||||
|
study = await study_crud.get(db, study_id)
|
||||||
|
if not study:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||||||
|
return study
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_date_range(start, end, label: str):
|
||||||
|
if (start and not end) or (not start and end):
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"{label}开始和结束日期需同时填写")
|
||||||
|
if start and end and end < start:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"{label}结束日期不能早于开始日期")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/milestones",
|
||||||
|
response_model=list[ProjectMilestoneRead],
|
||||||
|
dependencies=[Depends(require_study_member())],
|
||||||
|
)
|
||||||
|
async def list_project_milestones(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> list[ProjectMilestoneRead]:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
rows = await milestone_crud.list_project_milestones(db, study_id)
|
||||||
|
return [ProjectMilestoneRead.model_validate(item) for item in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/milestones/{milestone_id}",
|
||||||
|
response_model=ProjectMilestoneRead,
|
||||||
|
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
|
||||||
|
)
|
||||||
|
async def update_project_milestone(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
milestone_id: uuid.UUID,
|
||||||
|
payload: ProjectMilestoneUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> ProjectMilestoneRead:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
row = await milestone_crud.get_project_milestone(db, study_id, milestone_id)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目里程碑不存在")
|
||||||
|
|
||||||
|
_validate_date_range(payload.adjusted_start_date, payload.adjusted_end_date, "调整计划时间")
|
||||||
|
_validate_date_range(payload.actual_start_date, payload.actual_end_date, "实际时间")
|
||||||
|
|
||||||
|
item = await milestone_crud.update_project_milestone(db, row, payload)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="milestone",
|
||||||
|
entity_id=milestone_id,
|
||||||
|
action="UPDATE_PROJECT_MILESTONE",
|
||||||
|
detail=f"项目里程碑 {milestone_id} 已更新",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
|
return ProjectMilestoneRead.model_validate(item)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, finance_contracts, finance_specials, fees_contracts, fees_specials, fees_attachments, drug_shipments, startup, knowledge_notes, subject_histories, faq_categories, faqs, documents, overview, notifications
|
from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, finance_contracts, finance_specials, fees_contracts, fees_specials, fees_attachments, drug_shipments, material_equipments, project_milestones, startup, knowledge_notes, subject_histories, faq_categories, faqs, documents, overview, notifications
|
||||||
|
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
@@ -26,6 +26,8 @@ api_router.include_router(fees_contracts.router, prefix="/fees", tags=["fees-con
|
|||||||
api_router.include_router(fees_specials.router, prefix="/fees", tags=["fees-specials"])
|
api_router.include_router(fees_specials.router, prefix="/fees", tags=["fees-specials"])
|
||||||
api_router.include_router(fees_attachments.router, prefix="/fees", tags=["fees-attachments"])
|
api_router.include_router(fees_attachments.router, prefix="/fees", tags=["fees-attachments"])
|
||||||
api_router.include_router(drug_shipments.router, prefix="/studies/{study_id}/drug", tags=["drug-shipments"])
|
api_router.include_router(drug_shipments.router, prefix="/studies/{study_id}/drug", tags=["drug-shipments"])
|
||||||
|
api_router.include_router(material_equipments.router, prefix="/studies/{study_id}/materials", tags=["material-equipments"])
|
||||||
|
api_router.include_router(project_milestones.router, prefix="/studies/{study_id}/project", tags=["project-milestones"])
|
||||||
api_router.include_router(startup.router, prefix="/studies/{study_id}/startup", tags=["startup"])
|
api_router.include_router(startup.router, prefix="/studies/{study_id}/startup", tags=["startup"])
|
||||||
api_router.include_router(knowledge_notes.router, prefix="/studies/{study_id}/knowledge", tags=["knowledge"])
|
api_router.include_router(knowledge_notes.router, prefix="/studies/{study_id}/knowledge", tags=["knowledge"])
|
||||||
api_router.include_router(subject_histories.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-histories"])
|
api_router.include_router(subject_histories.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-histories"])
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
import json
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
|
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
|
||||||
|
from app.crud import audit as audit_crud
|
||||||
from app.crud import site as site_crud
|
from app.crud import site as site_crud
|
||||||
from app.crud import startup as startup_crud
|
from app.crud import startup as startup_crud
|
||||||
from app.crud import study as study_crud
|
from app.crud import study as study_crud
|
||||||
@@ -46,6 +48,16 @@ async def create_site(
|
|||||||
StartupEthicsCreate(site_id=site.id),
|
StartupEthicsCreate(site_id=site.id),
|
||||||
created_by=getattr(current_user, "id", None),
|
created_by=getattr(current_user, "id", None),
|
||||||
)
|
)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="site",
|
||||||
|
entity_id=site.id,
|
||||||
|
action="SITE_CREATED",
|
||||||
|
detail=json.dumps({"targetName": site.name, "after": {"name": site.name, "is_active": site.is_active}}, ensure_ascii=False),
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
return site
|
return site
|
||||||
|
|
||||||
|
|
||||||
@@ -86,12 +98,38 @@ async def update_site(
|
|||||||
site_id: uuid.UUID,
|
site_id: uuid.UUID,
|
||||||
site_in: SiteUpdate,
|
site_in: SiteUpdate,
|
||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
) -> SiteRead:
|
) -> SiteRead:
|
||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
site = await site_crud.get_site(db, site_id)
|
site = await site_crud.get_site(db, site_id)
|
||||||
if not site or site.study_id != study_id:
|
if not site or site.study_id != study_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分中心不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分中心不存在")
|
||||||
|
before_data = {
|
||||||
|
"name": site.name,
|
||||||
|
"city": site.city,
|
||||||
|
"pi_name": site.pi_name,
|
||||||
|
"contact": site.contact,
|
||||||
|
"is_active": site.is_active,
|
||||||
|
}
|
||||||
updated = await site_crud.update_site(db, site, site_in)
|
updated = await site_crud.update_site(db, site, site_in)
|
||||||
|
after_data = {
|
||||||
|
"name": updated.name,
|
||||||
|
"city": updated.city,
|
||||||
|
"pi_name": updated.pi_name,
|
||||||
|
"contact": updated.contact,
|
||||||
|
"is_active": updated.is_active,
|
||||||
|
}
|
||||||
|
action = "SITE_STATUS_CHANGED" if before_data.get("is_active") != after_data.get("is_active") else "SITE_UPDATED"
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="site",
|
||||||
|
entity_id=updated.id,
|
||||||
|
action=action,
|
||||||
|
detail=json.dumps({"targetName": updated.name, "before": before_data, "after": after_data}, ensure_ascii=False),
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
|
|
||||||
@@ -113,5 +151,22 @@ async def delete_site(
|
|||||||
site = await site_crud.get_site(db, site_id)
|
site = await site_crud.get_site(db, site_id)
|
||||||
if not site or site.study_id != study_id:
|
if not site or site.study_id != study_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分中心不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分中心不存在")
|
||||||
|
before_data = {
|
||||||
|
"name": site.name,
|
||||||
|
"city": site.city,
|
||||||
|
"pi_name": site.pi_name,
|
||||||
|
"contact": site.contact,
|
||||||
|
"is_active": site.is_active,
|
||||||
|
}
|
||||||
await site_crud.delete_site_and_related(db, site)
|
await site_crud.delete_site_and_related(db, site)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="site",
|
||||||
|
entity_id=site_id,
|
||||||
|
action="SITE_DELETED",
|
||||||
|
detail=json.dumps({"targetName": site.name, "before": before_data, "after": {"deleted": True}}, ensure_ascii=False),
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from app.schemas.common import PaginatedResponse
|
|||||||
from app.schemas.study import StudyCreate, StudyRead, StudyUpdate
|
from app.schemas.study import StudyCreate, StudyRead, StudyUpdate
|
||||||
from app.schemas.member import StudyMemberCreate
|
from app.schemas.member import StudyMemberCreate
|
||||||
from app.schemas.study_setup_config import (
|
from app.schemas.study_setup_config import (
|
||||||
|
ProjectPublishSnapshot,
|
||||||
SetupProjectionSummary,
|
SetupProjectionSummary,
|
||||||
StudySetupConfigData,
|
StudySetupConfigData,
|
||||||
StudySetupConfigPublish,
|
StudySetupConfigPublish,
|
||||||
@@ -253,6 +254,42 @@ def _build_default_setup_config_from_study(study, sites: list) -> StudySetupConf
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_date_text(value: date | None) -> str:
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
return value.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_project_publish_snapshot(study) -> ProjectPublishSnapshot:
|
||||||
|
return ProjectPublishSnapshot(
|
||||||
|
code=getattr(study, "code", None) or "",
|
||||||
|
name=getattr(study, "name", None) or "",
|
||||||
|
project_full_name=getattr(study, "project_full_name", None) or "",
|
||||||
|
sponsor=getattr(study, "sponsor", None) or "",
|
||||||
|
protocol_no=getattr(study, "protocol_no", None) or "",
|
||||||
|
lead_unit=getattr(study, "lead_unit", None) or "",
|
||||||
|
principal_investigator=getattr(study, "principal_investigator", None) or "",
|
||||||
|
main_pm=getattr(study, "main_pm", None) or "",
|
||||||
|
research_analysis=getattr(study, "research_analysis", None) or "",
|
||||||
|
research_product=getattr(study, "research_product", None) or "",
|
||||||
|
control_product=getattr(study, "control_product", None) or "",
|
||||||
|
indication=getattr(study, "indication", None) or "",
|
||||||
|
research_population=getattr(study, "research_population", None) or "",
|
||||||
|
research_design=getattr(study, "research_design", None) or "",
|
||||||
|
plan_start_date=_to_date_text(getattr(study, "plan_start_date", None)),
|
||||||
|
plan_end_date=_to_date_text(getattr(study, "plan_end_date", None)),
|
||||||
|
planned_site_count=getattr(study, "planned_site_count", None),
|
||||||
|
planned_enrollment_count=getattr(study, "planned_enrollment_count", None),
|
||||||
|
summary_note=getattr(study, "summary_note", None) or "",
|
||||||
|
objective_note=getattr(study, "objective_note", None) or "",
|
||||||
|
status=getattr(study, "status", None) or "",
|
||||||
|
visit_interval_days=getattr(study, "visit_interval_days", None),
|
||||||
|
visit_total=getattr(study, "visit_total", None),
|
||||||
|
visit_window_start_offset=getattr(study, "visit_window_start_offset", None),
|
||||||
|
visit_window_end_offset=getattr(study, "visit_window_end_offset", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _to_setup_config_read(
|
def _to_setup_config_read(
|
||||||
record,
|
record,
|
||||||
*,
|
*,
|
||||||
@@ -268,6 +305,11 @@ def _to_setup_config_read(
|
|||||||
data=StudySetupConfigData.model_validate(record.config or {}),
|
data=StudySetupConfigData.model_validate(record.config or {}),
|
||||||
publish_status=record.publish_status or "DRAFT",
|
publish_status=record.publish_status or "DRAFT",
|
||||||
published_data=StudySetupConfigData.model_validate(record.published_config) if record.published_config else None,
|
published_data=StudySetupConfigData.model_validate(record.published_config) if record.published_config else None,
|
||||||
|
published_project_snapshot=(
|
||||||
|
ProjectPublishSnapshot.model_validate(record.published_project_snapshot)
|
||||||
|
if record.published_project_snapshot
|
||||||
|
else None
|
||||||
|
),
|
||||||
published_by=record.published_by,
|
published_by=record.published_by,
|
||||||
published_by_name=published_by_name,
|
published_by_name=published_by_name,
|
||||||
published_at=record.published_at,
|
published_at=record.published_at,
|
||||||
@@ -764,12 +806,18 @@ async def upsert_study_setup_config(
|
|||||||
|
|
||||||
existing = await study_setup_config_crud.get_by_study(db, study_id)
|
existing = await study_setup_config_crud.get_by_study(db, study_id)
|
||||||
old_config = dict(existing.config or {}) if existing else {}
|
old_config = dict(existing.config or {}) if existing else {}
|
||||||
|
force_draft = bool(payload.force_draft)
|
||||||
|
if existing and existing.publish_status == "PUBLISHED" and existing.published_project_snapshot:
|
||||||
|
current_project_snapshot = _build_project_publish_snapshot(study).model_dump(mode="json")
|
||||||
|
if current_project_snapshot != dict(existing.published_project_snapshot or {}):
|
||||||
|
force_draft = True
|
||||||
record, conflict = await study_setup_config_crud.upsert(
|
record, conflict = await study_setup_config_crud.upsert(
|
||||||
db,
|
db,
|
||||||
study_id,
|
study_id,
|
||||||
expected_version=payload.expected_version,
|
expected_version=payload.expected_version,
|
||||||
data=payload.data,
|
data=payload.data,
|
||||||
saved_by=current_user.id,
|
saved_by=current_user.id,
|
||||||
|
force_draft=force_draft,
|
||||||
)
|
)
|
||||||
if conflict:
|
if conflict:
|
||||||
_raise_conflict_error()
|
_raise_conflict_error()
|
||||||
@@ -857,6 +905,9 @@ async def publish_study_setup_config(
|
|||||||
for item in projection.skipped_items
|
for item in projection.skipped_items
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
study_after_publish = await study_crud.get(db, study_id)
|
||||||
|
if study_after_publish:
|
||||||
|
published.published_project_snapshot = _build_project_publish_snapshot(study_after_publish).model_dump(mode="json")
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
study_id=study_id,
|
study_id=study_id,
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import uuid
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.material_equipment import MaterialEquipment
|
||||||
|
from app.schemas.material_equipment import MaterialEquipmentCreate, MaterialEquipmentUpdate
|
||||||
|
|
||||||
|
|
||||||
|
async def create_equipment(
|
||||||
|
db: AsyncSession,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
equipment_in: MaterialEquipmentCreate,
|
||||||
|
created_by: uuid.UUID | None,
|
||||||
|
) -> MaterialEquipment:
|
||||||
|
item = MaterialEquipment(
|
||||||
|
study_id=study_id,
|
||||||
|
name=equipment_in.name,
|
||||||
|
spec_model=equipment_in.spec_model,
|
||||||
|
unit=equipment_in.unit,
|
||||||
|
brand=equipment_in.brand,
|
||||||
|
origin=equipment_in.origin,
|
||||||
|
production_permit_file_name=equipment_in.production_permit_file_name,
|
||||||
|
tech_index_file_name=equipment_in.tech_index_file_name,
|
||||||
|
need_calibration=equipment_in.need_calibration,
|
||||||
|
calibration_cycle_days=equipment_in.calibration_cycle_days,
|
||||||
|
created_by=created_by,
|
||||||
|
)
|
||||||
|
db.add(item)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(item)
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
async def get_equipment(db: AsyncSession, equipment_id: uuid.UUID) -> MaterialEquipment | None:
|
||||||
|
result = await db.execute(select(MaterialEquipment).where(MaterialEquipment.id == equipment_id))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_equipments(
|
||||||
|
db: AsyncSession,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
name: str | None = None,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> Sequence[MaterialEquipment]:
|
||||||
|
stmt = select(MaterialEquipment).where(MaterialEquipment.study_id == study_id)
|
||||||
|
if name:
|
||||||
|
stmt = stmt.where(MaterialEquipment.name.ilike(f"%{name}%"))
|
||||||
|
stmt = stmt.order_by(MaterialEquipment.created_at.desc()).offset(skip).limit(limit)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_equipment(
|
||||||
|
db: AsyncSession,
|
||||||
|
equipment: MaterialEquipment,
|
||||||
|
equipment_in: MaterialEquipmentUpdate,
|
||||||
|
) -> MaterialEquipment:
|
||||||
|
update_data = equipment_in.model_dump(exclude_unset=True)
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(equipment, key, value)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(equipment)
|
||||||
|
return equipment
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_equipment(db: AsyncSession, equipment: MaterialEquipment) -> None:
|
||||||
|
await db.delete(equipment)
|
||||||
|
await db.commit()
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import uuid
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.milestone import Milestone
|
||||||
|
from app.schemas.project_milestone import ProjectMilestoneUpdate
|
||||||
|
|
||||||
|
PROJECT_MILESTONE_TYPE = "SETUP_PROJECT_MILESTONE"
|
||||||
|
|
||||||
|
|
||||||
|
async def list_project_milestones(db: AsyncSession, study_id: uuid.UUID) -> Sequence[Milestone]:
|
||||||
|
stmt = (
|
||||||
|
select(Milestone)
|
||||||
|
.where(
|
||||||
|
Milestone.study_id == study_id,
|
||||||
|
Milestone.type == PROJECT_MILESTONE_TYPE,
|
||||||
|
)
|
||||||
|
.order_by(Milestone.planned_date.asc().nulls_last(), Milestone.created_at.asc())
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_project_milestone(db: AsyncSession, study_id: uuid.UUID, milestone_id: uuid.UUID) -> Milestone | None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Milestone).where(
|
||||||
|
Milestone.id == milestone_id,
|
||||||
|
Milestone.study_id == study_id,
|
||||||
|
Milestone.type == PROJECT_MILESTONE_TYPE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_project_milestone(db: AsyncSession, milestone: Milestone, data: ProjectMilestoneUpdate) -> Milestone:
|
||||||
|
milestone.adjusted_start_date = data.adjusted_start_date
|
||||||
|
milestone.adjusted_end_date = data.adjusted_end_date
|
||||||
|
milestone.actual_start_date = data.actual_start_date
|
||||||
|
milestone.actual_end_date = data.actual_end_date
|
||||||
|
milestone.actual_date = data.actual_end_date
|
||||||
|
milestone.notes = data.notes
|
||||||
|
|
||||||
|
if milestone.actual_end_date:
|
||||||
|
milestone.status = "DONE"
|
||||||
|
elif milestone.actual_start_date:
|
||||||
|
milestone.status = "IN_PROGRESS"
|
||||||
|
else:
|
||||||
|
milestone.status = "NOT_STARTED"
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(milestone)
|
||||||
|
return milestone
|
||||||
@@ -112,6 +112,7 @@ async def delete(db: AsyncSession, study_id: uuid.UUID) -> None:
|
|||||||
from app.models.document import Document
|
from app.models.document import Document
|
||||||
from app.models.attachment import Attachment
|
from app.models.attachment import Attachment
|
||||||
from app.models.drug_shipment import DrugShipment
|
from app.models.drug_shipment import DrugShipment
|
||||||
|
from app.models.material_equipment import MaterialEquipment
|
||||||
from app.models.training_authorization import TrainingAuthorization
|
from app.models.training_authorization import TrainingAuthorization
|
||||||
from app.models.startup_feasibility import StartupFeasibility
|
from app.models.startup_feasibility import StartupFeasibility
|
||||||
from app.models.startup_ethics import StartupEthics
|
from app.models.startup_ethics import StartupEthics
|
||||||
@@ -145,6 +146,7 @@ async def delete(db: AsyncSession, study_id: uuid.UUID) -> None:
|
|||||||
|
|
||||||
# 6. 删除药物配送
|
# 6. 删除药物配送
|
||||||
await db.execute(sa_delete(DrugShipment).where(DrugShipment.study_id == study_id))
|
await db.execute(sa_delete(DrugShipment).where(DrugShipment.study_id == study_id))
|
||||||
|
await db.execute(sa_delete(MaterialEquipment).where(MaterialEquipment.study_id == study_id))
|
||||||
|
|
||||||
# 7. 删除附件和文档
|
# 7. 删除附件和文档
|
||||||
await db.execute(sa_delete(Attachment).where(Attachment.study_id == study_id))
|
await db.execute(sa_delete(Attachment).where(Attachment.study_id == study_id))
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ async def upsert(
|
|||||||
expected_version: int | None,
|
expected_version: int | None,
|
||||||
data: StudySetupConfigData | dict,
|
data: StudySetupConfigData | dict,
|
||||||
saved_by: uuid.UUID | None,
|
saved_by: uuid.UUID | None,
|
||||||
|
force_draft: bool = False,
|
||||||
) -> tuple[StudySetupConfig | None, bool]:
|
) -> tuple[StudySetupConfig | None, bool]:
|
||||||
existing = await get_by_study(db, study_id)
|
existing = await get_by_study(db, study_id)
|
||||||
payload = _to_storage(data)
|
payload = _to_storage(data)
|
||||||
@@ -37,6 +38,12 @@ async def upsert(
|
|||||||
existing.version = existing.version + 1
|
existing.version = existing.version + 1
|
||||||
existing.config = payload
|
existing.config = payload
|
||||||
existing.saved_by = saved_by
|
existing.saved_by = saved_by
|
||||||
|
if force_draft:
|
||||||
|
existing.publish_status = "DRAFT"
|
||||||
|
elif existing.publish_status == "DRAFT":
|
||||||
|
# Keep draft state sticky until explicit publish API is called.
|
||||||
|
existing.publish_status = "DRAFT"
|
||||||
|
else:
|
||||||
existing.publish_status = "PUBLISHED" if existing.published_config == payload else "DRAFT"
|
existing.publish_status = "PUBLISHED" if existing.published_config == payload else "DRAFT"
|
||||||
existing.updated_at = datetime.utcnow()
|
existing.updated_at = datetime.utcnow()
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from app.models.contract_fee_payment import ContractFeePayment # noqa: F401
|
|||||||
from app.models.special_expense import SpecialExpense # noqa: F401
|
from app.models.special_expense import SpecialExpense # noqa: F401
|
||||||
from app.models.fee_attachment import FeeAttachment # noqa: F401
|
from app.models.fee_attachment import FeeAttachment # noqa: F401
|
||||||
from app.models.drug_shipment import DrugShipment # noqa: F401
|
from app.models.drug_shipment import DrugShipment # noqa: F401
|
||||||
|
from app.models.material_equipment import MaterialEquipment # noqa: F401
|
||||||
from app.models.startup_feasibility import StartupFeasibility # noqa: F401
|
from app.models.startup_feasibility import StartupFeasibility # noqa: F401
|
||||||
from app.models.startup_ethics import StartupEthics # noqa: F401
|
from app.models.startup_ethics import StartupEthics # noqa: F401
|
||||||
from app.models.kickoff_meeting import KickoffMeeting # noqa: F401
|
from app.models.kickoff_meeting import KickoffMeeting # noqa: F401
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base_class import Base
|
||||||
|
|
||||||
|
|
||||||
|
class MaterialEquipment(Base):
|
||||||
|
__tablename__ = "material_equipments"
|
||||||
|
|
||||||
|
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)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
spec_model: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
unit: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
brand: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
origin: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
production_permit_file_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
tech_index_file_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
need_calibration: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
|
||||||
|
calibration_cycle_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
created_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()
|
||||||
|
)
|
||||||
@@ -16,6 +16,10 @@ class Milestone(Base):
|
|||||||
type: Mapped[str] = mapped_column(String(50), nullable=False)
|
type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
planned_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
planned_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
adjusted_start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
adjusted_end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
actual_start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
actual_end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
actual_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
actual_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="NOT_STARTED")
|
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)
|
site_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), nullable=True)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class StudySetupConfig(Base):
|
|||||||
config: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
config: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
||||||
publish_status: Mapped[str] = mapped_column(default="DRAFT", nullable=False)
|
publish_status: Mapped[str] = mapped_column(default="DRAFT", nullable=False)
|
||||||
published_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
published_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
published_project_snapshot: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
published_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
published_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
saved_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
saved_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
|
|||||||
@@ -14,3 +14,10 @@ class AuditLogRead(BaseModel):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AuditEventCreate(BaseModel):
|
||||||
|
action: str
|
||||||
|
detail: Optional[str] = None
|
||||||
|
entity_type: Optional[str] = None
|
||||||
|
entity_id: Optional[uuid.UUID] = None
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class MaterialEquipmentCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
spec_model: str
|
||||||
|
unit: str | None = None
|
||||||
|
brand: str
|
||||||
|
origin: str | None = None
|
||||||
|
production_permit_file_name: str | None = None
|
||||||
|
tech_index_file_name: str | None = None
|
||||||
|
need_calibration: bool
|
||||||
|
calibration_cycle_days: int | None = None
|
||||||
|
|
||||||
|
@field_validator("name", "spec_model", "brand", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _strip_required(cls, value: str) -> str:
|
||||||
|
text = (value or "").strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError("不能为空")
|
||||||
|
return text
|
||||||
|
|
||||||
|
@field_validator("unit", "origin", "production_permit_file_name", "tech_index_file_name", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _strip_optional(cls, value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = value.strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
class MaterialEquipmentUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
spec_model: str | None = None
|
||||||
|
unit: str | None = None
|
||||||
|
brand: str | None = None
|
||||||
|
origin: str | None = None
|
||||||
|
production_permit_file_name: str | None = None
|
||||||
|
tech_index_file_name: str | None = None
|
||||||
|
need_calibration: bool | None = None
|
||||||
|
calibration_cycle_days: int | None = None
|
||||||
|
|
||||||
|
@field_validator("name", "spec_model", "brand", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _strip_required(cls, value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = value.strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError("不能为空")
|
||||||
|
return text
|
||||||
|
|
||||||
|
@field_validator("unit", "origin", "production_permit_file_name", "tech_index_file_name", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _strip_optional(cls, value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = value.strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
class MaterialEquipmentRead(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
study_id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
spec_model: str
|
||||||
|
unit: str | None
|
||||||
|
brand: str
|
||||||
|
origin: str | None
|
||||||
|
production_permit_file_name: str | None
|
||||||
|
tech_index_file_name: str | None
|
||||||
|
need_calibration: bool
|
||||||
|
calibration_cycle_days: int | None
|
||||||
|
created_by: uuid.UUID | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectMilestoneRead(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
study_id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
planned_date: date | None
|
||||||
|
adjusted_start_date: date | None
|
||||||
|
adjusted_end_date: date | None
|
||||||
|
actual_start_date: date | None
|
||||||
|
actual_end_date: date | None
|
||||||
|
status: str
|
||||||
|
notes: str | None
|
||||||
|
owner_name: str | None
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectMilestoneUpdate(BaseModel):
|
||||||
|
adjusted_start_date: date | None = None
|
||||||
|
adjusted_end_date: date | None = None
|
||||||
|
actual_start_date: date | None = None
|
||||||
|
actual_end_date: date | None = None
|
||||||
|
notes: str | None = None
|
||||||
@@ -80,6 +80,7 @@ class StudySetupConfigData(BaseModel):
|
|||||||
class StudySetupConfigUpsert(BaseModel):
|
class StudySetupConfigUpsert(BaseModel):
|
||||||
expected_version: int | None = None
|
expected_version: int | None = None
|
||||||
data: StudySetupConfigData
|
data: StudySetupConfigData
|
||||||
|
force_draft: bool = False
|
||||||
|
|
||||||
|
|
||||||
class StudySetupConfigPublish(BaseModel):
|
class StudySetupConfigPublish(BaseModel):
|
||||||
@@ -120,6 +121,34 @@ class SetupProjectionSummary(BaseModel):
|
|||||||
skipped_items: list[SetupProjectionSkippedItem] = Field(default_factory=list)
|
skipped_items: list[SetupProjectionSkippedItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectPublishSnapshot(BaseModel):
|
||||||
|
code: str = ""
|
||||||
|
name: str = ""
|
||||||
|
project_full_name: str = ""
|
||||||
|
sponsor: str = ""
|
||||||
|
protocol_no: str = ""
|
||||||
|
lead_unit: str = ""
|
||||||
|
principal_investigator: str = ""
|
||||||
|
main_pm: str = ""
|
||||||
|
research_analysis: str = ""
|
||||||
|
research_product: str = ""
|
||||||
|
control_product: str = ""
|
||||||
|
indication: str = ""
|
||||||
|
research_population: str = ""
|
||||||
|
research_design: str = ""
|
||||||
|
plan_start_date: str = ""
|
||||||
|
plan_end_date: str = ""
|
||||||
|
planned_site_count: int | None = None
|
||||||
|
planned_enrollment_count: int | None = None
|
||||||
|
summary_note: str = ""
|
||||||
|
objective_note: str = ""
|
||||||
|
status: str = ""
|
||||||
|
visit_interval_days: int | None = None
|
||||||
|
visit_total: int | None = None
|
||||||
|
visit_window_start_offset: int | None = None
|
||||||
|
visit_window_end_offset: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class StudySetupConfigRead(BaseModel):
|
class StudySetupConfigRead(BaseModel):
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
study_id: uuid.UUID
|
study_id: uuid.UUID
|
||||||
@@ -127,6 +156,7 @@ class StudySetupConfigRead(BaseModel):
|
|||||||
data: StudySetupConfigData
|
data: StudySetupConfigData
|
||||||
publish_status: str
|
publish_status: str
|
||||||
published_data: StudySetupConfigData | None = None
|
published_data: StudySetupConfigData | None = None
|
||||||
|
published_project_snapshot: ProjectPublishSnapshot | None = None
|
||||||
published_by: uuid.UUID | None = None
|
published_by: uuid.UUID | None = None
|
||||||
published_by_name: str | None = None
|
published_by_name: str | None = None
|
||||||
published_at: datetime | None = None
|
published_at: datetime | None = None
|
||||||
|
|||||||
@@ -143,6 +143,11 @@ async def _replace_project_milestones(
|
|||||||
type=PROJECT_MILESTONE_TYPE,
|
type=PROJECT_MILESTONE_TYPE,
|
||||||
name=name,
|
name=name,
|
||||||
planned_date=planned_date,
|
planned_date=planned_date,
|
||||||
|
adjusted_start_date=_parse_date((getattr(row, "adjustedStartDate", "") or "").strip()),
|
||||||
|
adjusted_end_date=_parse_date((getattr(row, "adjustedEndDate", "") or "").strip()),
|
||||||
|
actual_start_date=_parse_date((getattr(row, "actualStartDate", "") or "").strip()),
|
||||||
|
actual_end_date=_parse_date((getattr(row, "actualEndDate", "") or "").strip()),
|
||||||
|
actual_date=_parse_date((getattr(row, "actualEndDate", "") or "").strip()),
|
||||||
status=mapped_status,
|
status=mapped_status,
|
||||||
owner_id=owner_id,
|
owner_id=owner_id,
|
||||||
owner_name=owner_name,
|
owner_name=owner_name,
|
||||||
|
|||||||
Executable
+172
@@ -0,0 +1,172 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
FRONTEND_SRC = REPO_ROOT / "frontend" / "src"
|
||||||
|
|
||||||
|
FILE_GLOBS = ("**/*.ts", "**/*.tsx", "**/*.vue")
|
||||||
|
CALL_PATTERNS = {
|
||||||
|
"localStorage.getItem": re.compile(r"localStorage\.getItem\(([^)]+)\)"),
|
||||||
|
"localStorage.setItem": re.compile(r"localStorage\.setItem\(([^,)]+)"),
|
||||||
|
"localStorage.removeItem": re.compile(r"localStorage\.removeItem\(([^)]+)\)"),
|
||||||
|
"sessionStorage.getItem": re.compile(r"sessionStorage\.getItem\(([^)]+)\)"),
|
||||||
|
"sessionStorage.setItem": re.compile(r"sessionStorage\.setItem\(([^,)]+)"),
|
||||||
|
"sessionStorage.removeItem": re.compile(r"sessionStorage\.removeItem\(([^)]+)\)"),
|
||||||
|
}
|
||||||
|
|
||||||
|
BANNED_KEY_MARKERS = ("audit_local_",)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Finding:
|
||||||
|
file: str
|
||||||
|
line: int
|
||||||
|
call: str
|
||||||
|
key_expr: str
|
||||||
|
severity: str
|
||||||
|
category: str
|
||||||
|
note: str
|
||||||
|
|
||||||
|
|
||||||
|
def iter_source_files() -> Iterable[Path]:
|
||||||
|
for pattern in FILE_GLOBS:
|
||||||
|
for path in FRONTEND_SRC.glob(pattern):
|
||||||
|
if path.is_file():
|
||||||
|
yield path
|
||||||
|
|
||||||
|
|
||||||
|
def classify(file_path: str, key_expr: str) -> tuple[str, str, str]:
|
||||||
|
normalized = key_expr.replace('"', "").replace("'", "").strip()
|
||||||
|
lowered = normalized.lower()
|
||||||
|
file_lower = file_path.lower()
|
||||||
|
|
||||||
|
if "projectdetail.vue" in file_lower:
|
||||||
|
if "publishedprojectsignaturekey" in lowered:
|
||||||
|
return ("medium", "publish-helper", "发布比对签名缓存,属于辅助数据")
|
||||||
|
if "storagekey" in lowered or "projectdraftstoragekey" in lowered:
|
||||||
|
return ("high", "business-draft", "立项配置草稿本地兜底,需显式提示未落库")
|
||||||
|
|
||||||
|
if "audit_local_" in lowered:
|
||||||
|
return ("high", "audit-data", "审计日志本地缓存,不能作为正式审计数据源")
|
||||||
|
if "ctms_setup_config_draft_" in lowered or "ctms_setup_project_draft_" in lowered:
|
||||||
|
return ("high", "business-draft", "立项配置草稿本地兜底,需显式提示未落库")
|
||||||
|
if "ctms_setup_published_project_sig_" in lowered:
|
||||||
|
return ("medium", "publish-helper", "发布比对签名缓存,属于辅助数据")
|
||||||
|
|
||||||
|
if any(k in lowered for k in ("token_key", "last_login_email_key", "logout_reason_storage_key")):
|
||||||
|
return ("low", "auth-session", "认证/会话数据(非业务主数据)")
|
||||||
|
if any(k in lowered for k in ("ctms_token", "ctms_last_login_email", "credential", "auth")):
|
||||||
|
return ("low", "auth-session", "认证/会话数据(非业务主数据)")
|
||||||
|
if any(k in lowered for k in ("ctms_sidebar_collapsed", "study", "site", "role")):
|
||||||
|
return ("low", "ui-preference", "UI偏好/上下文缓存")
|
||||||
|
|
||||||
|
if "personaltodo" in file_path.lower():
|
||||||
|
return ("low", "feature-cache", "工作台本地待办缓存(非核心主数据)")
|
||||||
|
|
||||||
|
return ("medium", "unknown", "未命中规则,建议人工复核是否为业务关键数据")
|
||||||
|
|
||||||
|
|
||||||
|
def scan_findings() -> list[Finding]:
|
||||||
|
findings: list[Finding] = []
|
||||||
|
for path in iter_source_files():
|
||||||
|
try:
|
||||||
|
content = path.read_text(encoding="utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
content = path.read_text(encoding="utf-8", errors="ignore")
|
||||||
|
lines = content.splitlines()
|
||||||
|
rel = str(path.relative_to(REPO_ROOT))
|
||||||
|
for idx, line in enumerate(lines, start=1):
|
||||||
|
for call_name, pattern in CALL_PATTERNS.items():
|
||||||
|
for match in pattern.finditer(line):
|
||||||
|
key_expr = match.group(1).strip()
|
||||||
|
severity, category, note = classify(rel, key_expr)
|
||||||
|
findings.append(
|
||||||
|
Finding(
|
||||||
|
file=rel,
|
||||||
|
line=idx,
|
||||||
|
call=call_name,
|
||||||
|
key_expr=key_expr,
|
||||||
|
severity=severity,
|
||||||
|
category=category,
|
||||||
|
note=note,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def markdown_report(findings: list[Finding]) -> str:
|
||||||
|
counts: dict[str, int] = {"high": 0, "medium": 0, "low": 0}
|
||||||
|
for item in findings:
|
||||||
|
counts[item.severity] = counts.get(item.severity, 0) + 1
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"# 存储落库核查报告",
|
||||||
|
"",
|
||||||
|
f"- 扫描目录: `{FRONTEND_SRC}`",
|
||||||
|
f"- 总发现数: `{len(findings)}`",
|
||||||
|
f"- 高风险: `{counts['high']}` / 中风险: `{counts['medium']}` / 低风险: `{counts['low']}`",
|
||||||
|
"",
|
||||||
|
"## 明细",
|
||||||
|
"",
|
||||||
|
"| 风险 | 分类 | 文件 | 行号 | 调用 | key表达式 | 说明 |",
|
||||||
|
"| --- | --- | --- | --- | --- | --- | --- |",
|
||||||
|
]
|
||||||
|
for item in sorted(findings, key=lambda x: (x.severity, x.file, x.line)):
|
||||||
|
lines.append(
|
||||||
|
f"| {item.severity} | {item.category} | `{item.file}` | {item.line} | `{item.call}` | `{item.key_expr}` | {item.note} |"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Scan frontend storage usage and classify persistence risks.")
|
||||||
|
parser.add_argument("--format", choices=("markdown", "json"), default="markdown")
|
||||||
|
parser.add_argument("--output", default="", help="Optional output file path.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--fail-on-banned",
|
||||||
|
action="store_true",
|
||||||
|
help="Exit with code 2 when banned local key markers are detected.",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
findings = scan_findings()
|
||||||
|
|
||||||
|
banned_hits = []
|
||||||
|
if args.fail_on_banned:
|
||||||
|
for item in findings:
|
||||||
|
expr = item.key_expr.lower().replace('"', "").replace("'", "")
|
||||||
|
if any(marker in expr for marker in BANNED_KEY_MARKERS):
|
||||||
|
banned_hits.append(item)
|
||||||
|
|
||||||
|
if args.format == "json":
|
||||||
|
output_text = json.dumps([asdict(item) for item in findings], ensure_ascii=False, indent=2)
|
||||||
|
else:
|
||||||
|
output_text = markdown_report(findings)
|
||||||
|
|
||||||
|
if args.output:
|
||||||
|
out = Path(args.output)
|
||||||
|
if not out.is_absolute():
|
||||||
|
out = REPO_ROOT / out
|
||||||
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out.write_text(output_text + "\n", encoding="utf-8")
|
||||||
|
else:
|
||||||
|
print(output_text)
|
||||||
|
|
||||||
|
if banned_hits:
|
||||||
|
return 2
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -87,6 +87,10 @@ CREATE TABLE IF NOT EXISTS public.milestones (
|
|||||||
type character varying(50) NOT NULL,
|
type character varying(50) NOT NULL,
|
||||||
name character varying(200) NOT NULL,
|
name character varying(200) NOT NULL,
|
||||||
planned_date date,
|
planned_date date,
|
||||||
|
adjusted_start_date date,
|
||||||
|
adjusted_end_date date,
|
||||||
|
actual_start_date date,
|
||||||
|
actual_end_date date,
|
||||||
actual_date date,
|
actual_date date,
|
||||||
status character varying(20) NOT NULL DEFAULT 'NOT_STARTED',
|
status character varying(20) NOT NULL DEFAULT 'NOT_STARTED',
|
||||||
site_id uuid,
|
site_id uuid,
|
||||||
@@ -329,6 +333,25 @@ CREATE TABLE IF NOT EXISTS public.drug_shipments (
|
|||||||
CONSTRAINT fk_drug_shipments_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT
|
CONSTRAINT fk_drug_shipments_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.material_equipments (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
study_id uuid NOT NULL,
|
||||||
|
name character varying(255) NOT NULL,
|
||||||
|
spec_model character varying(255) NOT NULL,
|
||||||
|
unit character varying(50),
|
||||||
|
brand character varying(100) NOT NULL,
|
||||||
|
origin character varying(255),
|
||||||
|
production_permit_file_name character varying(255),
|
||||||
|
tech_index_file_name character varying(255),
|
||||||
|
need_calibration boolean NOT NULL DEFAULT false,
|
||||||
|
calibration_cycle_days integer,
|
||||||
|
created_by uuid,
|
||||||
|
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT fk_material_equipments_study_id FOREIGN KEY (study_id) REFERENCES public.studies(id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_material_equipments_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS public.startup_feasibility (
|
CREATE TABLE IF NOT EXISTS public.startup_feasibility (
|
||||||
id uuid PRIMARY KEY,
|
id uuid PRIMARY KEY,
|
||||||
study_id uuid NOT NULL,
|
study_id uuid NOT NULL,
|
||||||
@@ -515,6 +538,7 @@ CREATE INDEX IF NOT EXISTS ix_finance_items_site_id ON public.finance_items (sit
|
|||||||
CREATE INDEX IF NOT EXISTS ix_finance_items_subject_id ON public.finance_items (subject_id);
|
CREATE INDEX IF NOT EXISTS ix_finance_items_subject_id ON public.finance_items (subject_id);
|
||||||
CREATE INDEX IF NOT EXISTS ix_drug_shipments_study_id ON public.drug_shipments (study_id);
|
CREATE INDEX IF NOT EXISTS ix_drug_shipments_study_id ON public.drug_shipments (study_id);
|
||||||
CREATE INDEX IF NOT EXISTS ix_drug_shipments_center_id ON public.drug_shipments (center_id);
|
CREATE INDEX IF NOT EXISTS ix_drug_shipments_center_id ON public.drug_shipments (center_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_material_equipments_study_id ON public.material_equipments (study_id);
|
||||||
CREATE INDEX IF NOT EXISTS ix_startup_feasibility_study_id ON public.startup_feasibility (study_id);
|
CREATE INDEX IF NOT EXISTS ix_startup_feasibility_study_id ON public.startup_feasibility (study_id);
|
||||||
CREATE INDEX IF NOT EXISTS ix_startup_ethics_study_id ON public.startup_ethics (study_id);
|
CREATE INDEX IF NOT EXISTS ix_startup_ethics_study_id ON public.startup_ethics (study_id);
|
||||||
CREATE INDEX IF NOT EXISTS ix_kickoff_meetings_study_id ON public.kickoff_meetings (study_id);
|
CREATE INDEX IF NOT EXISTS ix_kickoff_meetings_study_id ON public.kickoff_meetings (study_id);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
- `草稿视图`: 当前草稿可编辑
|
- `草稿视图`: 当前草稿可编辑
|
||||||
- `发布预览`: 当前草稿只读预览(不直接读取 `published_data`)
|
- `发布预览`: 当前草稿只读预览(不直接读取 `published_data`)
|
||||||
- `published_data`: 用于发布快照、差异比较与版本能力
|
- `published_data`: 用于发布快照、差异比较与版本能力
|
||||||
|
- `published_project_snapshot`: 发布时的项目基础信息快照(用于“项目信息差异”判定)
|
||||||
|
|
||||||
## 2. 权限矩阵
|
## 2. 权限矩阵
|
||||||
- `GET /setup-config`
|
- `GET /setup-config`
|
||||||
@@ -80,6 +81,33 @@
|
|||||||
"centerConfirm": []
|
"centerConfirm": []
|
||||||
},
|
},
|
||||||
"published_data": null,
|
"published_data": null,
|
||||||
|
"published_project_snapshot": {
|
||||||
|
"code": "DEMO-CTMS",
|
||||||
|
"name": "演示项目",
|
||||||
|
"project_full_name": "演示项目全称",
|
||||||
|
"sponsor": "",
|
||||||
|
"protocol_no": "",
|
||||||
|
"lead_unit": "",
|
||||||
|
"principal_investigator": "",
|
||||||
|
"main_pm": "",
|
||||||
|
"research_analysis": "",
|
||||||
|
"research_product": "",
|
||||||
|
"control_product": "",
|
||||||
|
"indication": "",
|
||||||
|
"research_population": "",
|
||||||
|
"research_design": "",
|
||||||
|
"plan_start_date": "2026-02-01",
|
||||||
|
"plan_end_date": "2026-12-31",
|
||||||
|
"planned_site_count": 12,
|
||||||
|
"planned_enrollment_count": 120,
|
||||||
|
"summary_note": "",
|
||||||
|
"objective_note": "",
|
||||||
|
"status": "DRAFT",
|
||||||
|
"visit_interval_days": null,
|
||||||
|
"visit_total": null,
|
||||||
|
"visit_window_start_offset": null,
|
||||||
|
"visit_window_end_offset": null
|
||||||
|
},
|
||||||
"saved_by": "11111111-1111-1111-1111-111111111111",
|
"saved_by": "11111111-1111-1111-1111-111111111111",
|
||||||
"saved_by_name": "System Admin",
|
"saved_by_name": "System Admin",
|
||||||
"published_by": null,
|
"published_by": null,
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# 存储落库核查报告
|
||||||
|
|
||||||
|
- 扫描目录: `/Users/zcc/MyCTMS/ctms-project/frontend/src`
|
||||||
|
- 总发现数: `46`
|
||||||
|
- 高风险: `4` / 中风险: `0` / 低风险: `42`
|
||||||
|
|
||||||
|
## 明细
|
||||||
|
|
||||||
|
| 风险 | 分类 | 文件 | 行号 | 调用 | key表达式 | 说明 |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| high | business-draft | `frontend/src/views/admin/ProjectDetail.vue` | 2849 | `localStorage.getItem` | `storageKey.value` | 立项配置草稿本地兜底,需显式提示未落库 |
|
||||||
|
| high | business-draft | `frontend/src/views/admin/ProjectDetail.vue` | 2908 | `localStorage.getItem` | `projectDraftStorageKey.value` | 立项配置草稿本地兜底,需显式提示未落库 |
|
||||||
|
| high | business-draft | `frontend/src/views/admin/ProjectDetail.vue` | 2976 | `localStorage.removeItem` | `projectDraftStorageKey.value` | 立项配置草稿本地兜底,需显式提示未落库 |
|
||||||
|
| high | business-draft | `frontend/src/views/admin/ProjectDetail.vue` | 2984 | `localStorage.removeItem` | `storageKey.value` | 立项配置草稿本地兜底,需显式提示未落库 |
|
||||||
|
| low | ui-preference | `frontend/src/components/Layout.vue` | 227 | `localStorage.getItem` | `"ctms_sidebar_collapsed"` | UI偏好/上下文缓存 |
|
||||||
|
| low | ui-preference | `frontend/src/components/Layout.vue` | 390 | `localStorage.setItem` | `"ctms_sidebar_collapsed"` | UI偏好/上下文缓存 |
|
||||||
|
| low | auth-session | `frontend/src/components/LockScreenModal.vue` | 92 | `localStorage.getItem` | `"ctms_last_login_email"` | 认证/会话数据(非业务主数据) |
|
||||||
|
| low | auth-session | `frontend/src/components/ThreadList.vue` | 72 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) |
|
||||||
|
| low | auth-session | `frontend/src/components/attachments/AttachmentList.vue` | 132 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) |
|
||||||
|
| low | auth-session | `frontend/src/components/attachments/AttachmentList.vue` | 137 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) |
|
||||||
|
| low | auth-session | `frontend/src/components/attachments/AttachmentList.vue` | 168 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) |
|
||||||
|
| low | auth-session | `frontend/src/components/fees/FeeAttachmentPanel.vue` | 169 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) |
|
||||||
|
| low | auth-session | `frontend/src/components/fees/FeeAttachmentPanel.vue` | 174 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) |
|
||||||
|
| low | auth-session | `frontend/src/components/fees/FeeAttachmentPanel.vue` | 223 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) |
|
||||||
|
| low | auth-session | `frontend/src/session/sessionManager.ts` | 33 | `localStorage.setItem` | `"ctms_auth_broadcast"` | 认证/会话数据(非业务主数据) |
|
||||||
|
| low | auth-session | `frontend/src/session/sessionManager.ts` | 181 | `sessionStorage.removeItem` | `LOGOUT_REASON_STORAGE_KEY` | 认证/会话数据(非业务主数据) |
|
||||||
|
| low | auth-session | `frontend/src/session/sessionManager.ts` | 184 | `sessionStorage.setItem` | `LOGOUT_REASON_STORAGE_KEY` | 认证/会话数据(非业务主数据) |
|
||||||
|
| low | auth-session | `frontend/src/session/sessionManager.ts` | 192 | `sessionStorage.getItem` | `LOGOUT_REASON_STORAGE_KEY` | 认证/会话数据(非业务主数据) |
|
||||||
|
| low | auth-session | `frontend/src/session/sessionManager.ts` | 193 | `sessionStorage.removeItem` | `LOGOUT_REASON_STORAGE_KEY` | 认证/会话数据(非业务主数据) |
|
||||||
|
| low | auth-session | `frontend/src/store/auth.ts` | 24 | `localStorage.setItem` | `LAST_LOGIN_EMAIL_KEY` | 认证/会话数据(非业务主数据) |
|
||||||
|
| low | auth-session | `frontend/src/store/auth.ts` | 39 | `localStorage.setItem` | `LAST_LOGIN_EMAIL_KEY` | 认证/会话数据(非业务主数据) |
|
||||||
|
| low | auth-session | `frontend/src/store/auth.ts` | 46 | `localStorage.getItem` | `LAST_LOGIN_EMAIL_KEY` | 认证/会话数据(非业务主数据) |
|
||||||
|
| low | ui-preference | `frontend/src/store/study.ts` | 20 | `localStorage.getItem` | `LAST_STUDY_BY_USER_KEY` | UI偏好/上下文缓存 |
|
||||||
|
| low | ui-preference | `frontend/src/store/study.ts` | 30 | `localStorage.setItem` | `LAST_STUDY_BY_USER_KEY` | UI偏好/上下文缓存 |
|
||||||
|
| low | ui-preference | `frontend/src/store/study.ts` | 44 | `localStorage.removeItem` | `SITE_KEY` | UI偏好/上下文缓存 |
|
||||||
|
| low | ui-preference | `frontend/src/store/study.ts` | 48 | `localStorage.setItem` | `STUDY_KEY` | UI偏好/上下文缓存 |
|
||||||
|
| low | ui-preference | `frontend/src/store/study.ts` | 50 | `localStorage.setItem` | `STUDY_ROLE_KEY` | UI偏好/上下文缓存 |
|
||||||
|
| low | auth-session | `frontend/src/store/study.ts` | 52 | `localStorage.getItem` | `"ctms_last_login_email"` | 认证/会话数据(非业务主数据) |
|
||||||
|
| low | ui-preference | `frontend/src/store/study.ts` | 59 | `localStorage.getItem` | `STUDY_KEY` | UI偏好/上下文缓存 |
|
||||||
|
| low | ui-preference | `frontend/src/store/study.ts` | 67 | `localStorage.getItem` | `STUDY_ROLE_KEY` | UI偏好/上下文缓存 |
|
||||||
|
| low | ui-preference | `frontend/src/store/study.ts` | 70 | `localStorage.getItem` | `SITE_KEY` | UI偏好/上下文缓存 |
|
||||||
|
| low | ui-preference | `frontend/src/store/study.ts` | 116 | `localStorage.removeItem` | `STUDY_KEY` | UI偏好/上下文缓存 |
|
||||||
|
| low | ui-preference | `frontend/src/store/study.ts` | 117 | `localStorage.removeItem` | `STUDY_ROLE_KEY` | UI偏好/上下文缓存 |
|
||||||
|
| low | ui-preference | `frontend/src/store/study.ts` | 118 | `localStorage.removeItem` | `SITE_KEY` | UI偏好/上下文缓存 |
|
||||||
|
| low | ui-preference | `frontend/src/store/study.ts` | 165 | `localStorage.setItem` | `STUDY_ROLE_KEY` | UI偏好/上下文缓存 |
|
||||||
|
| low | ui-preference | `frontend/src/store/study.ts` | 167 | `localStorage.removeItem` | `STUDY_ROLE_KEY` | UI偏好/上下文缓存 |
|
||||||
|
| low | ui-preference | `frontend/src/store/study.ts` | 174 | `localStorage.setItem` | `SITE_KEY` | UI偏好/上下文缓存 |
|
||||||
|
| low | ui-preference | `frontend/src/store/study.ts` | 176 | `localStorage.removeItem` | `SITE_KEY` | UI偏好/上下文缓存 |
|
||||||
|
| low | auth-session | `frontend/src/utils/auth.ts` | 4 | `localStorage.getItem` | `TOKEN_KEY` | 认证/会话数据(非业务主数据) |
|
||||||
|
| low | auth-session | `frontend/src/utils/auth.ts` | 7 | `localStorage.setItem` | `TOKEN_KEY` | 认证/会话数据(非业务主数据) |
|
||||||
|
| low | auth-session | `frontend/src/utils/auth.ts` | 11 | `localStorage.removeItem` | `TOKEN_KEY` | 认证/会话数据(非业务主数据) |
|
||||||
|
| low | auth-session | `frontend/src/utils/auth.ts` | 17 | `localStorage.setItem` | `CREDENTIAL_KEY` | 认证/会话数据(非业务主数据) |
|
||||||
|
| low | auth-session | `frontend/src/utils/auth.ts` | 24 | `localStorage.getItem` | `CREDENTIAL_KEY` | 认证/会话数据(非业务主数据) |
|
||||||
|
| low | auth-session | `frontend/src/utils/auth.ts` | 38 | `localStorage.removeItem` | `CREDENTIAL_KEY` | 认证/会话数据(非业务主数据) |
|
||||||
|
| low | feature-cache | `frontend/src/views/workbench/components/PersonalTodo.vue` | 79 | `localStorage.getItem` | `props.storageKey` | 工作台本地待办缓存(非核心主数据) |
|
||||||
|
| low | feature-cache | `frontend/src/views/workbench/components/PersonalTodo.vue` | 91 | `localStorage.setItem` | `props.storageKey` | 工作台本地待办缓存(非核心主数据) |
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# 全项目重要数据落库治理基线(2026-02-27)
|
||||||
|
|
||||||
|
## 1. 口径
|
||||||
|
|
||||||
|
- 纳入范围:业务主数据 + 审计数据
|
||||||
|
- 排除范围:纯 UI 偏好、登录态会话、跨标签广播等非业务主数据
|
||||||
|
|
||||||
|
## 2. 自动核查入口
|
||||||
|
|
||||||
|
```bash
|
||||||
|
backend/scripts/storage_persistence_audit.py --format markdown --output docs/storage-persistence-audit.md
|
||||||
|
backend/scripts/storage_persistence_audit.py --fail-on-banned
|
||||||
|
```
|
||||||
|
|
||||||
|
- `docs/storage-persistence-audit.md`:当前扫描快照
|
||||||
|
- `--fail-on-banned`:用于阻断禁止项(当前默认禁止 `audit_local_*`)
|
||||||
|
|
||||||
|
## 3. 当前结论(基于 2026-02-27 扫描)
|
||||||
|
|
||||||
|
- 高风险:4 条(均为 `ProjectDetail` 本地草稿兜底读写)
|
||||||
|
- 中风险:0 条
|
||||||
|
- 低风险:42 条(会话、偏好、工作台本地待办等)
|
||||||
|
|
||||||
|
## 4. 已完成整改
|
||||||
|
|
||||||
|
### 4.1 审计链路
|
||||||
|
|
||||||
|
- 关键管理动作改为后端落库(站点、项目成员)
|
||||||
|
- 审计页改为服务端唯一数据源
|
||||||
|
- 前端本地审计日志通道已下线(不再写 `audit_local_*`)
|
||||||
|
- 审计导出事件改为后端写入审计表
|
||||||
|
|
||||||
|
### 4.2 业务链路
|
||||||
|
|
||||||
|
- 设备管理已接入后端并落库
|
||||||
|
- 项目里程碑编辑保存已改为调用后端接口并落库
|
||||||
|
- 立项配置草稿保留本地兜底,但已增加:
|
||||||
|
- `SYNCED / LOCAL_ONLY / DIRTY_UNSAVED` 状态识别
|
||||||
|
- 本地草稿恢复确认(含过期/冲突提示)
|
||||||
|
- 发布前 `LOCAL_ONLY` 阻断
|
||||||
|
- 保存失败重试与成功后自动清理本地草稿
|
||||||
|
- 发布比对签名已改为后端返回的 `published_project_snapshot`,不再依赖 localStorage
|
||||||
|
|
||||||
|
### 4.3 门禁与流程
|
||||||
|
|
||||||
|
- 已新增 CI 工作流:`.github/workflows/storage-persistence-guard.yml`
|
||||||
|
- PR / push 到主干时会执行 `storage_persistence_audit.py --fail-on-banned`
|
||||||
|
|
||||||
|
## 5. 剩余优化项(可选)
|
||||||
|
|
||||||
|
1. 将“高风险清单”阈值从固定 key 规则升级为白名单机制(白名单外一律告警)。
|
||||||
|
2. 评估是否把 `ProjectDetail` 本地草稿从 localStorage 迁移为后端草稿(可选能力,非当前口径强制项)。
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { apiDelete, apiGet } from "./axios";
|
import { apiDelete, apiGet, apiPost } from "./axios";
|
||||||
import type { ApiListResponse } from "../types/api";
|
import type { ApiListResponse } from "../types/api";
|
||||||
|
|
||||||
export const fetchAuditLogs = (studyId: string, params?: Record<string, any>) =>
|
export const fetchAuditLogs = (studyId: string, params?: Record<string, any>) =>
|
||||||
@@ -6,3 +6,6 @@ export const fetchAuditLogs = (studyId: string, params?: Record<string, any>) =>
|
|||||||
|
|
||||||
export const deleteAuditLog = (studyId: string, logId: string) =>
|
export const deleteAuditLog = (studyId: string, logId: string) =>
|
||||||
apiDelete(`/api/v1/studies/${studyId}/audit-logs/${logId}`);
|
apiDelete(`/api/v1/studies/${studyId}/audit-logs/${logId}`);
|
||||||
|
|
||||||
|
export const createAuditEvent = (studyId: string, payload: Record<string, any>) =>
|
||||||
|
apiPost(`/api/v1/studies/${studyId}/audit-logs/events`, payload);
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
|
||||||
|
|
||||||
|
export const listMaterialEquipments = (studyId: string, params?: Record<string, any>) =>
|
||||||
|
apiGet(`/api/v1/studies/${studyId}/materials/equipment`, { params });
|
||||||
|
|
||||||
|
export const getMaterialEquipment = (studyId: string, equipmentId: string) =>
|
||||||
|
apiGet(`/api/v1/studies/${studyId}/materials/equipment/${equipmentId}`);
|
||||||
|
|
||||||
|
export const createMaterialEquipment = (studyId: string, payload: Record<string, any>) =>
|
||||||
|
apiPost(`/api/v1/studies/${studyId}/materials/equipment`, payload);
|
||||||
|
|
||||||
|
export const updateMaterialEquipment = (studyId: string, equipmentId: string, payload: Record<string, any>) =>
|
||||||
|
apiPatch(`/api/v1/studies/${studyId}/materials/equipment/${equipmentId}`, payload);
|
||||||
|
|
||||||
|
export const deleteMaterialEquipment = (studyId: string, equipmentId: string) =>
|
||||||
|
apiDelete(`/api/v1/studies/${studyId}/materials/equipment/${equipmentId}`);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { apiGet, apiPatch } from "./axios";
|
||||||
|
|
||||||
|
export const listProjectMilestones = (studyId: string) =>
|
||||||
|
apiGet(`/api/v1/studies/${studyId}/project/milestones`);
|
||||||
|
|
||||||
|
export const updateProjectMilestone = (
|
||||||
|
studyId: string,
|
||||||
|
milestoneId: string,
|
||||||
|
payload: Record<string, any>
|
||||||
|
) => apiPatch(`/api/v1/studies/${studyId}/project/milestones/${milestoneId}`, payload);
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { auditDict } from "./auditDict";
|
import { auditDict } from "./auditDict";
|
||||||
import { roleDict, getDictLabel, statusDict } from "../dictionaries";
|
import { roleDict, getDictLabel, statusDict } from "../dictionaries";
|
||||||
import { useAuthStore } from "../store/auth";
|
|
||||||
import { useStudyStore } from "../store/study";
|
|
||||||
import { TEXT } from "../locales";
|
import { TEXT } from "../locales";
|
||||||
|
|
||||||
export interface AuditEvent {
|
export interface AuditEvent {
|
||||||
@@ -250,47 +248,9 @@ export const normalizeAuditEvent = (raw: any, userMap: Record<string, string>):
|
|||||||
|
|
||||||
export { auditDict };
|
export { auditDict };
|
||||||
|
|
||||||
const LOCAL_KEY_PREFIX = "audit_local_";
|
// Deprecated: keep API-compatible no-op to avoid accidental reliance on browser local storage.
|
||||||
const getLocalKey = (studyId?: string | null) => `${LOCAL_KEY_PREFIX}${studyId || "global"}`;
|
|
||||||
|
|
||||||
const appendLocalAudit = (studyId: string | null, log: any) => {
|
|
||||||
try {
|
|
||||||
const key = getLocalKey(studyId);
|
|
||||||
const existingRaw = localStorage.getItem(key);
|
|
||||||
const existing = existingRaw ? JSON.parse(existingRaw) : [];
|
|
||||||
existing.unshift(log);
|
|
||||||
localStorage.setItem(key, JSON.stringify(existing.slice(0, 200)));
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 前端占位的 logAudit:不修改后端接口,落地到本地存储,失败不影响主流程
|
|
||||||
export const logAudit = async (eventType: string, payload: Record<string, any>) => {
|
export const logAudit = async (eventType: string, payload: Record<string, any>) => {
|
||||||
try {
|
void eventType;
|
||||||
const auth = useAuthStore();
|
void payload;
|
||||||
const study = useStudyStore();
|
|
||||||
const user = auth.user;
|
|
||||||
const studyId = study.currentStudy?.id || null;
|
|
||||||
const log = {
|
|
||||||
action: eventType,
|
|
||||||
operator_id: user?.id || user?.username || TEXT.audit.currentUser,
|
|
||||||
operator_role: user?.role || "",
|
|
||||||
entity_type: payload.targetType || TEXT.audit.localClient,
|
|
||||||
entity_id: payload.targetId || "",
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
detail: JSON.stringify({
|
|
||||||
targetName: payload.targetName,
|
|
||||||
before: payload.before,
|
|
||||||
after: payload.after,
|
|
||||||
reason: payload.reason,
|
|
||||||
severity: payload.severity,
|
|
||||||
result: payload.severity === "normal" ? "SUCCESS" : "FAIL",
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
appendLocalAudit(studyId, log);
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -58,7 +58,6 @@
|
|||||||
</template>
|
</template>
|
||||||
<el-menu-item index="/drug/shipments">{{ TEXT.menu.drugShipments }}</el-menu-item>
|
<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/equipment">{{ TEXT.menu.materialEquipment }}</el-menu-item>
|
||||||
<el-menu-item index="/materials/others">{{ TEXT.menu.materialOthers }}</el-menu-item>
|
|
||||||
</el-sub-menu>
|
</el-sub-menu>
|
||||||
<el-menu-item index="/file-versions">
|
<el-menu-item index="/file-versions">
|
||||||
<el-icon><Files /></el-icon>
|
<el-icon><Files /></el-icon>
|
||||||
@@ -206,7 +205,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from "vue";
|
import { computed, onMounted, ref, watch, watchEffect } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
@@ -226,6 +225,19 @@ const route = useRoute();
|
|||||||
const isAdmin = computed(() => auth.user?.role === "ADMIN");
|
const isAdmin = computed(() => auth.user?.role === "ADMIN");
|
||||||
const isPm = computed(() => auth.user?.role === "PM" || study.currentStudyRole === "PM");
|
const isPm = computed(() => auth.user?.role === "PM" || study.currentStudyRole === "PM");
|
||||||
const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1");
|
const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1");
|
||||||
|
|
||||||
|
/* 动态设置 body 上的侧边栏宽度 CSS 变量,用于全局弹窗定位偏移 */
|
||||||
|
const hasSidebar = computed(() => isAdmin.value || !!study.currentStudy);
|
||||||
|
watchEffect(() => {
|
||||||
|
const width = !hasSidebar.value ? '0px' : (isCollapsed.value ? '68px' : '200px');
|
||||||
|
document.body.style.setProperty('--ctms-sidebar-width', width);
|
||||||
|
// 在 body 上添加/移除标记类,以便全局 CSS :has() 降级使用
|
||||||
|
if (hasSidebar.value) {
|
||||||
|
document.body.classList.add('has-sidebar');
|
||||||
|
} else {
|
||||||
|
document.body.classList.remove('has-sidebar');
|
||||||
|
}
|
||||||
|
});
|
||||||
const userDisplayName = computed(
|
const userDisplayName = computed(
|
||||||
() => auth.user?.full_name || auth.user?.username || auth.user?.email || TEXT.common.labels.userFallback
|
() => auth.user?.full_name || auth.user?.username || auth.user?.email || TEXT.common.labels.userFallback
|
||||||
);
|
);
|
||||||
@@ -243,7 +255,6 @@ const activeMenu = computed(() => {
|
|||||||
if (path.startsWith("/finance/special")) return "/fees/special";
|
if (path.startsWith("/finance/special")) return "/fees/special";
|
||||||
if (path.startsWith("/drug/shipments")) return "/drug/shipments";
|
if (path.startsWith("/drug/shipments")) return "/drug/shipments";
|
||||||
if (path.startsWith("/materials/equipment")) return "/materials/equipment";
|
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("/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/feasibility") || path.startsWith("/startup/ethics")) return "/startup/feasibility-ethics";
|
||||||
if (path.startsWith("/startup/meeting-auth") || path.startsWith("/startup/kickoff") || path.startsWith("/startup/training")) {
|
if (path.startsWith("/startup/meeting-auth") || path.startsWith("/startup/kickoff") || path.startsWith("/startup/training")) {
|
||||||
@@ -319,7 +330,6 @@ const breadcrumbs = computed(() => {
|
|||||||
"/project/milestones": { label: TEXT.menu.projectMilestones, path: "/project/milestones" },
|
"/project/milestones": { label: TEXT.menu.projectMilestones, path: "/project/milestones" },
|
||||||
"/drug/shipments": { label: TEXT.menu.drugShipments, path: "/drug/shipments" },
|
"/drug/shipments": { label: TEXT.menu.drugShipments, path: "/drug/shipments" },
|
||||||
"/materials/equipment": { label: TEXT.menu.materialEquipment, path: "/materials/equipment" },
|
"/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" },
|
"/subjects": { label: TEXT.menu.subjects, path: "/subjects" },
|
||||||
"/risk-issues/sae": { label: TEXT.menu.riskIssueSae, path: "/risk-issues/sae" },
|
"/risk-issues/sae": { label: TEXT.menu.riskIssueSae, path: "/risk-issues/sae" },
|
||||||
"/risk-issues/pd": { label: TEXT.menu.riskIssuePd, path: "/risk-issues/pd" },
|
"/risk-issues/pd": { label: TEXT.menu.riskIssuePd, path: "/risk-issues/pd" },
|
||||||
|
|||||||
@@ -263,7 +263,6 @@ export const TEXT = {
|
|||||||
materialManagement: "物资管理",
|
materialManagement: "物资管理",
|
||||||
drugShipments: "药品流向管理",
|
drugShipments: "药品流向管理",
|
||||||
materialEquipment: "设备管理",
|
materialEquipment: "设备管理",
|
||||||
materialOthers: "其他",
|
|
||||||
fileVersionManagement: "文件版本管理",
|
fileVersionManagement: "文件版本管理",
|
||||||
startupFeasibilityEthics: "立项与伦理",
|
startupFeasibilityEthics: "立项与伦理",
|
||||||
startupMeetingAuth: "启动与授权",
|
startupMeetingAuth: "启动与授权",
|
||||||
@@ -488,12 +487,6 @@ export const TEXT = {
|
|||||||
listTitle: "设备列表",
|
listTitle: "设备列表",
|
||||||
emptyDescription: "设备管理模块正在建设中,敬请期待。",
|
emptyDescription: "设备管理模块正在建设中,敬请期待。",
|
||||||
},
|
},
|
||||||
materialOthers: {
|
|
||||||
title: "其他物资",
|
|
||||||
subtitle: "其他物资登记与管理",
|
|
||||||
listTitle: "物资列表",
|
|
||||||
emptyDescription: "其他物资模块正在建设中,敬请期待。",
|
|
||||||
},
|
|
||||||
fileVersionManagement: {
|
fileVersionManagement: {
|
||||||
title: "文件版本管理",
|
title: "文件版本管理",
|
||||||
subtitle: "管理试验文档与版本审批",
|
subtitle: "管理试验文档与版本审批",
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import FeeSpecialForm from "../views/fees/SpecialExpenseForm.vue";
|
|||||||
import FeeSpecialDetail from "../views/fees/SpecialExpenseDetail.vue";
|
import FeeSpecialDetail from "../views/fees/SpecialExpenseDetail.vue";
|
||||||
import DrugShipments from "../views/ia/DrugShipments.vue";
|
import DrugShipments from "../views/ia/DrugShipments.vue";
|
||||||
import MaterialEquipment from "../views/ia/MaterialEquipment.vue";
|
import MaterialEquipment from "../views/ia/MaterialEquipment.vue";
|
||||||
import MaterialOthers from "../views/ia/MaterialOthers.vue";
|
|
||||||
import FileVersionManagement from "../views/ia/FileVersionManagement.vue";
|
import FileVersionManagement from "../views/ia/FileVersionManagement.vue";
|
||||||
import DocumentList from "../views/documents/DocumentList.vue";
|
import DocumentList from "../views/documents/DocumentList.vue";
|
||||||
import DocumentDetail from "../views/documents/DocumentDetail.vue";
|
import DocumentDetail from "../views/documents/DocumentDetail.vue";
|
||||||
@@ -220,12 +219,6 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: MaterialEquipment,
|
component: MaterialEquipment,
|
||||||
meta: { title: TEXT.menu.materialEquipment, requiresStudy: true },
|
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",
|
path: "drug/shipments/new",
|
||||||
name: "DrugShipmentNew",
|
name: "DrugShipmentNew",
|
||||||
|
|||||||
@@ -464,3 +464,20 @@ body {
|
|||||||
.text-sm {
|
.text-sm {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 全局弹窗定位修复:弹窗仅显示在主内容区域,不覆盖左侧菜单栏
|
||||||
|
* --ctms-sidebar-width 由 Layout.vue 在 body 上动态设置
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
/* ElMessage 提示消息:按“主内容区域”居中(而非全窗口/左上角) */
|
||||||
|
.el-message {
|
||||||
|
left: calc((100% + var(--ctms-sidebar-width, 0px)) / 2) !important;
|
||||||
|
margin-left: 0 !important;
|
||||||
|
transform: translateX(-50%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ElMessageBox / ElDialog 遮罩层:左侧偏移侧边栏宽度,只覆盖主内容区域 */
|
||||||
|
.el-overlay {
|
||||||
|
left: var(--ctms-sidebar-width, 0px) !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,6 +66,34 @@ export interface SetupConfigDraft {
|
|||||||
centerConfirm: CenterConfirmDraft[];
|
centerConfirm: CenterConfirmDraft[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProjectPublishSnapshot {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
project_full_name: string;
|
||||||
|
sponsor: string;
|
||||||
|
protocol_no: string;
|
||||||
|
lead_unit: string;
|
||||||
|
principal_investigator: string;
|
||||||
|
main_pm: string;
|
||||||
|
research_analysis: string;
|
||||||
|
research_product: string;
|
||||||
|
control_product: string;
|
||||||
|
indication: string;
|
||||||
|
research_population: string;
|
||||||
|
research_design: string;
|
||||||
|
plan_start_date: string;
|
||||||
|
plan_end_date: string;
|
||||||
|
planned_site_count: number | null;
|
||||||
|
planned_enrollment_count: number | null;
|
||||||
|
summary_note: string;
|
||||||
|
objective_note: string;
|
||||||
|
status: string;
|
||||||
|
visit_interval_days: number | null;
|
||||||
|
visit_total: number | null;
|
||||||
|
visit_window_start_offset: number | null;
|
||||||
|
visit_window_end_offset: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StudySetupConfigResponse {
|
export interface StudySetupConfigResponse {
|
||||||
id: string;
|
id: string;
|
||||||
study_id: string;
|
study_id: string;
|
||||||
@@ -73,6 +101,7 @@ export interface StudySetupConfigResponse {
|
|||||||
data: SetupConfigDraft;
|
data: SetupConfigDraft;
|
||||||
publish_status: "DRAFT" | "PUBLISHED" | string;
|
publish_status: "DRAFT" | "PUBLISHED" | string;
|
||||||
published_data?: SetupConfigDraft | null;
|
published_data?: SetupConfigDraft | null;
|
||||||
|
published_project_snapshot?: ProjectPublishSnapshot | null;
|
||||||
published_by?: string | null;
|
published_by?: string | null;
|
||||||
published_by_name?: string | null;
|
published_by_name?: string | null;
|
||||||
published_at?: string | null;
|
published_at?: string | null;
|
||||||
@@ -93,6 +122,7 @@ export interface StudySetupConfigResponse {
|
|||||||
export interface StudySetupConfigUpsertPayload {
|
export interface StudySetupConfigUpsertPayload {
|
||||||
expected_version?: number | null;
|
expected_version?: number | null;
|
||||||
data: SetupConfigDraft;
|
data: SetupConfigDraft;
|
||||||
|
force_draft?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StudySetupConfigPublishPayload {
|
export interface StudySetupConfigPublishPayload {
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import type { ProjectPublishSnapshot, SetupConfigDraft } from "../types/setupConfig";
|
||||||
|
import type { SetupDiffRow } from "./setupDiffRows";
|
||||||
|
|
||||||
|
export type DraftSyncStatus = "SYNCED" | "LOCAL_ONLY" | "DIRTY_UNSAVED";
|
||||||
|
export type SetupWorkflowStatus = "UNSAVED_DRAFT" | "SAVED_DRAFT" | "PUBLISHED";
|
||||||
|
export type SetupWorkflowTagMeta = { label: string; type: "success" | "warning" | "info" };
|
||||||
|
|
||||||
|
export const SETUP_WORKFLOW_TAG_META: Record<SetupWorkflowStatus, SetupWorkflowTagMeta> = {
|
||||||
|
UNSAVED_DRAFT: { label: "草稿未保存", type: "warning" },
|
||||||
|
SAVED_DRAFT: { label: "草稿已保存(未发布)", type: "info" },
|
||||||
|
PUBLISHED: { label: "已发布", type: "success" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveSetupWorkflowStatus = (
|
||||||
|
draftSyncStatus: DraftSyncStatus,
|
||||||
|
options: {
|
||||||
|
canStrictlyDetectNoDiff: boolean;
|
||||||
|
hasPublishableDiff: boolean;
|
||||||
|
}
|
||||||
|
): SetupWorkflowStatus => {
|
||||||
|
const { canStrictlyDetectNoDiff, hasPublishableDiff } = options;
|
||||||
|
if (draftSyncStatus !== "SYNCED") return "UNSAVED_DRAFT";
|
||||||
|
if (canStrictlyDetectNoDiff && !hasPublishableDiff) return "PUBLISHED";
|
||||||
|
return "SAVED_DRAFT";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildSetupModuleDiffLines = (
|
||||||
|
localDraft: SetupConfigDraft | null,
|
||||||
|
compareBase: SetupConfigDraft | null,
|
||||||
|
moduleLabels: Array<{ key: keyof SetupConfigDraft; label: string }>
|
||||||
|
): string[] => {
|
||||||
|
if (!localDraft || !compareBase) return [];
|
||||||
|
const lines: string[] = [];
|
||||||
|
moduleLabels.forEach((item) => {
|
||||||
|
const localChunk = JSON.stringify(localDraft[item.key] ?? null);
|
||||||
|
const baseChunk = JSON.stringify(compareBase[item.key] ?? null);
|
||||||
|
if (localChunk !== baseChunk) {
|
||||||
|
lines.push(`${item.label} 存在差异`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return lines;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveProjectPublishCompareBase = (
|
||||||
|
publishedSnapshot: ProjectPublishSnapshot | null,
|
||||||
|
fallbackSnapshot: ProjectPublishSnapshot | null
|
||||||
|
): ProjectPublishSnapshot | null => {
|
||||||
|
return publishedSnapshot || fallbackSnapshot || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hasProjectSnapshotDiff = (
|
||||||
|
currentSnapshot: ProjectPublishSnapshot,
|
||||||
|
baseSnapshot: ProjectPublishSnapshot | null
|
||||||
|
): boolean => {
|
||||||
|
if (!baseSnapshot) return false;
|
||||||
|
return JSON.stringify(currentSnapshot) !== JSON.stringify(baseSnapshot);
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResolvePublishedProjectSnapshotInput = {
|
||||||
|
publishStatus: string;
|
||||||
|
apiProjectSnapshot: ProjectPublishSnapshot | null;
|
||||||
|
previousSnapshot: ProjectPublishSnapshot | null;
|
||||||
|
projectSnapshotAtLoad: ProjectPublishSnapshot | null;
|
||||||
|
currentProjectSnapshot: ProjectPublishSnapshot;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResolvePublishedProjectSnapshotResult = {
|
||||||
|
snapshot: ProjectPublishSnapshot | null;
|
||||||
|
clearFallback: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolvePublishedProjectSnapshot = (
|
||||||
|
input: ResolvePublishedProjectSnapshotInput
|
||||||
|
): ResolvePublishedProjectSnapshotResult => {
|
||||||
|
if (input.apiProjectSnapshot) {
|
||||||
|
return { snapshot: input.apiProjectSnapshot, clearFallback: true };
|
||||||
|
}
|
||||||
|
if (input.publishStatus === "PUBLISHED") {
|
||||||
|
return {
|
||||||
|
snapshot: input.previousSnapshot || input.projectSnapshotAtLoad || input.currentProjectSnapshot,
|
||||||
|
clearFallback: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
// Draft state keeps the previous published baseline when available.
|
||||||
|
snapshot: input.previousSnapshot,
|
||||||
|
clearFallback: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const shouldCaptureProjectCompareFallback = (params: {
|
||||||
|
hasProjectPendingChanges: boolean;
|
||||||
|
hasPublishedProjectSnapshot: boolean;
|
||||||
|
isFirstPublish: boolean;
|
||||||
|
}): boolean => {
|
||||||
|
if (!params.hasProjectPendingChanges) return false;
|
||||||
|
if (params.hasPublishedProjectSnapshot) return false;
|
||||||
|
if (params.isFirstPublish) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROJECT_FIELD_LABEL_MAP: Record<keyof ProjectPublishSnapshot, string> = {
|
||||||
|
code: "项目编号",
|
||||||
|
name: "项目名称",
|
||||||
|
project_full_name: "项目全称",
|
||||||
|
sponsor: "申办方",
|
||||||
|
protocol_no: "方案号",
|
||||||
|
lead_unit: "组长单位",
|
||||||
|
principal_investigator: "主要研究者",
|
||||||
|
main_pm: "主PM",
|
||||||
|
research_analysis: "研究分期",
|
||||||
|
research_product: "研究产品",
|
||||||
|
control_product: "对照产品",
|
||||||
|
indication: "适应症",
|
||||||
|
research_population: "研究人群",
|
||||||
|
research_design: "研究设计",
|
||||||
|
plan_start_date: "计划开始日期",
|
||||||
|
plan_end_date: "计划结束日期",
|
||||||
|
planned_site_count: "计划中心数",
|
||||||
|
planned_enrollment_count: "计划入组数",
|
||||||
|
summary_note: "方案摘要说明",
|
||||||
|
objective_note: "研究目标摘要",
|
||||||
|
status: "项目状态",
|
||||||
|
visit_interval_days: "访视间隔(天)",
|
||||||
|
visit_total: "访视总数",
|
||||||
|
visit_window_start_offset: "窗口期起始偏移",
|
||||||
|
visit_window_end_offset: "窗口期结束偏移",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildProjectDiffRows = (
|
||||||
|
currentSnapshot: ProjectPublishSnapshot,
|
||||||
|
baseSnapshot: ProjectPublishSnapshot | null,
|
||||||
|
serializeValue: (value: unknown) => string
|
||||||
|
): SetupDiffRow[] => {
|
||||||
|
if (!baseSnapshot) return [];
|
||||||
|
const rows: SetupDiffRow[] = [];
|
||||||
|
(Object.keys(PROJECT_FIELD_LABEL_MAP) as Array<keyof ProjectPublishSnapshot>).forEach((key) => {
|
||||||
|
if (currentSnapshot[key] === baseSnapshot[key]) return;
|
||||||
|
rows.push({
|
||||||
|
moduleLabel: "项目信息",
|
||||||
|
path: PROJECT_FIELD_LABEL_MAP[key],
|
||||||
|
changeType: "修改",
|
||||||
|
localValue: serializeValue(currentSnapshot[key]),
|
||||||
|
serverValue: serializeValue(baseSnapshot[key]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
@@ -148,7 +148,7 @@ import { computed, onMounted, ref } from "vue";
|
|||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { ArrowDown } from "@element-plus/icons-vue";
|
import { ArrowDown } from "@element-plus/icons-vue";
|
||||||
import { fetchAuditLogs } from "../../api/auditLogs";
|
import { createAuditEvent, fetchAuditLogs } from "../../api/auditLogs";
|
||||||
import { fetchUsers } from "../../api/users";
|
import { fetchUsers } from "../../api/users";
|
||||||
import { listMembers } from "../../api/members";
|
import { listMembers } from "../../api/members";
|
||||||
import { auditDict, normalizeAuditEvent } from "../../audit";
|
import { auditDict, normalizeAuditEvent } from "../../audit";
|
||||||
@@ -156,27 +156,9 @@ import { useStudyStore } from "../../store/study";
|
|||||||
import { useAuthStore } from "../../store/auth";
|
import { useAuthStore } from "../../store/auth";
|
||||||
import { roleDict, getDictLabel } from "../../dictionaries";
|
import { roleDict, getDictLabel } from "../../dictionaries";
|
||||||
import { exportAuditCsv } from "../../audit/export/auditExportService";
|
import { exportAuditCsv } from "../../audit/export/auditExportService";
|
||||||
import { logAudit } from "../../audit";
|
|
||||||
import { displayDateTime } from "../../utils/display";
|
import { displayDateTime } from "../../utils/display";
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
|
|
||||||
const buildLogKey = (log: any) => {
|
|
||||||
if (log.id) return `id:${log.id}`;
|
|
||||||
return `${log.action || log.eventType || "event"}-${log.entity_id || log.entityId || ""}-${log.created_at || log.timestamp || ""}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const dedupeLogs = (list: any[]) => {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const result: any[] = [];
|
|
||||||
list.forEach((log) => {
|
|
||||||
const key = buildLogKey(log);
|
|
||||||
if (seen.has(key)) return;
|
|
||||||
seen.add(key);
|
|
||||||
result.push(log);
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -250,10 +232,8 @@ const loadLogs = async () => {
|
|||||||
};
|
};
|
||||||
const { data } = await fetchAuditLogs(study.currentStudy.id, params);
|
const { data } = await fetchAuditLogs(study.currentStudy.id, params);
|
||||||
const items = Array.isArray(data) ? data : (data as any).items || [];
|
const items = Array.isArray(data) ? data : (data as any).items || [];
|
||||||
const local = loadLocalLogs();
|
rawLogs.value = items;
|
||||||
const merged = dedupeLogs([...items, ...local]);
|
total.value = (data as any).total || items.length;
|
||||||
rawLogs.value = merged;
|
|
||||||
total.value = (data as any).total || merged.length;
|
|
||||||
enrichLogs();
|
enrichLogs();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminAuditLogs.loadFailed);
|
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminAuditLogs.loadFailed);
|
||||||
@@ -262,18 +242,6 @@ const loadLogs = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadLocalLogs = () => {
|
|
||||||
try {
|
|
||||||
const key = `audit_local_${study.currentStudy?.id || "global"}`;
|
|
||||||
const raw = localStorage.getItem(key);
|
|
||||||
if (!raw) return [];
|
|
||||||
const list = JSON.parse(raw);
|
|
||||||
return Array.isArray(list) ? list : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const enrichLogs = () => {
|
const enrichLogs = () => {
|
||||||
const userMap = users.value.reduce<Record<string, string>>((acc, cur) => {
|
const userMap = users.value.reduce<Record<string, string>>((acc, cur) => {
|
||||||
acc[cur.id] = resolveUserDisplayName(cur);
|
acc[cur.id] = resolveUserDisplayName(cur);
|
||||||
@@ -333,13 +301,11 @@ const fetchAllForExport = async () => {
|
|||||||
};
|
};
|
||||||
const { data } = await fetchAuditLogs(study.currentStudy.id, params);
|
const { data } = await fetchAuditLogs(study.currentStudy.id, params);
|
||||||
const items = Array.isArray(data) ? data : (data as any).items || [];
|
const items = Array.isArray(data) ? data : (data as any).items || [];
|
||||||
const local = loadLocalLogs();
|
|
||||||
const merged = dedupeLogs([...items, ...local]);
|
|
||||||
const userMap = users.value.reduce<Record<string, string>>((acc, cur) => {
|
const userMap = users.value.reduce<Record<string, string>>((acc, cur) => {
|
||||||
acc[cur.id] = resolveUserDisplayName(cur);
|
acc[cur.id] = resolveUserDisplayName(cur);
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
const filtered = merged.filter((log) => {
|
const filtered = items.filter((log: any) => {
|
||||||
if (filters.value.range?.length === 2) {
|
if (filters.value.range?.length === 2) {
|
||||||
const ts = new Date(log.created_at);
|
const ts = new Date(log.created_at);
|
||||||
const start = new Date(filters.value.range[0]);
|
const start = new Date(filters.value.range[0]);
|
||||||
@@ -348,7 +314,7 @@ const fetchAllForExport = async () => {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
return filtered.map((log) => normalizeAuditEvent(log, userMap));
|
return filtered.map((log: any) => normalizeAuditEvent(log, userMap));
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminAuditLogs.exportLoadFailed);
|
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminAuditLogs.exportLoadFailed);
|
||||||
return [];
|
return [];
|
||||||
@@ -362,6 +328,8 @@ const handleExportCommand = (command: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const confirmExport = async (scope: "system" | "project") => {
|
const confirmExport = async (scope: "system" | "project") => {
|
||||||
|
const currentStudy = study.currentStudy;
|
||||||
|
if (!currentStudy) return;
|
||||||
const ok = await ElMessageBox.confirm(
|
const ok = await ElMessageBox.confirm(
|
||||||
TEXT.modules.adminAuditLogs.exportConfirm,
|
TEXT.modules.adminAuditLogs.exportConfirm,
|
||||||
TEXT.modules.adminAuditLogs.exportConfirmTitle,
|
TEXT.modules.adminAuditLogs.exportConfirmTitle,
|
||||||
@@ -376,13 +344,22 @@ const confirmExport = async (scope: "system" | "project") => {
|
|||||||
const fileName =
|
const fileName =
|
||||||
scope === "system"
|
scope === "system"
|
||||||
? `${TEXT.modules.adminAuditLogs.exportSystemName}_${new Date().toISOString().slice(0, 10)}`
|
? `${TEXT.modules.adminAuditLogs.exportSystemName}_${new Date().toISOString().slice(0, 10)}`
|
||||||
: `${TEXT.modules.adminAuditLogs.exportProjectName}_${study.currentStudy?.code || ""}_${new Date().toISOString().slice(0, 10)}`;
|
: `${TEXT.modules.adminAuditLogs.exportProjectName}_${currentStudy.code || ""}_${new Date().toISOString().slice(0, 10)}`;
|
||||||
exportAuditCsv(events, { fileName });
|
exportAuditCsv(events, { fileName });
|
||||||
logAudit(scope === "system" ? "AUDIT_EXPORT_SYSTEM" : "AUDIT_EXPORT_PROJECT", {
|
await createAuditEvent(currentStudy.id, {
|
||||||
targetId: study.currentStudy?.id,
|
action: scope === "system" ? "AUDIT_EXPORT_SYSTEM" : "AUDIT_EXPORT_PROJECT",
|
||||||
targetName: study.currentStudy?.name,
|
entity_type: "audit_log",
|
||||||
severity: "normal",
|
entity_id: null,
|
||||||
});
|
detail: JSON.stringify(
|
||||||
|
{
|
||||||
|
targetName: currentStudy.name,
|
||||||
|
scope,
|
||||||
|
exportedCount: events.length,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
}).catch(() => null);
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -105,7 +105,6 @@ import { fetchStudyDetail } from "../../api/studies";
|
|||||||
import type { Study, StudyMember, UserInfo } from "../../types/api";
|
import type { Study, StudyMember, UserInfo } from "../../types/api";
|
||||||
import { useAuthStore } from "../../store/auth";
|
import { useAuthStore } from "../../store/auth";
|
||||||
import { evaluateAction } from "../../guards/actionGuard";
|
import { evaluateAction } from "../../guards/actionGuard";
|
||||||
import { logAudit } from "../../audit";
|
|
||||||
import { displayDateTime, displayEnum } from "../../utils/display";
|
import { displayDateTime, displayEnum } from "../../utils/display";
|
||||||
import { TEXT, requiredMessage } from "../../locales";
|
import { TEXT, requiredMessage } from "../../locales";
|
||||||
|
|
||||||
@@ -189,12 +188,6 @@ const submitAdd = async () => {
|
|||||||
});
|
});
|
||||||
if (!decision.allowed) {
|
if (!decision.allowed) {
|
||||||
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
|
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
|
||||||
logAudit(decision.auditType, {
|
|
||||||
targetId: projectId.value,
|
|
||||||
targetName: project.value?.name,
|
|
||||||
reason: decision.reason,
|
|
||||||
severity: decision.severity,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await addFormRef.value?.validate();
|
await addFormRef.value?.validate();
|
||||||
@@ -204,21 +197,8 @@ const submitAdd = async () => {
|
|||||||
ElMessage.success(TEXT.modules.adminProjectMembers.addSuccess);
|
ElMessage.success(TEXT.modules.adminProjectMembers.addSuccess);
|
||||||
addVisible.value = false;
|
addVisible.value = false;
|
||||||
loadMembers();
|
loadMembers();
|
||||||
logAudit("PROJECT_MEMBER_UPDATED", {
|
|
||||||
targetId: projectId.value,
|
|
||||||
targetName: project.value?.name,
|
|
||||||
after: { user_id: newMember.user_id, role_in_study: newMember.role_in_study },
|
|
||||||
severity: "normal",
|
|
||||||
});
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjectMembers.addFailed);
|
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjectMembers.addFailed);
|
||||||
logAudit("PROJECT_MEMBER_UPDATED", {
|
|
||||||
targetId: projectId.value,
|
|
||||||
targetName: project.value?.name,
|
|
||||||
after: { user_id: newMember.user_id, role_in_study: newMember.role_in_study },
|
|
||||||
severity: "warning",
|
|
||||||
reason: e?.response?.data?.message,
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
adding.value = false;
|
adding.value = false;
|
||||||
}
|
}
|
||||||
@@ -233,34 +213,15 @@ const updateRole = async (memberId: string, role: string) => {
|
|||||||
});
|
});
|
||||||
if (!decision.allowed) {
|
if (!decision.allowed) {
|
||||||
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
|
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
|
||||||
logAudit(decision.auditType, {
|
|
||||||
targetId: projectId.value,
|
|
||||||
targetName: project.value?.name,
|
|
||||||
reason: decision.reason,
|
|
||||||
severity: decision.severity,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await updateMember(projectId.value, memberId, { role_in_study: role });
|
await updateMember(projectId.value, memberId, { role_in_study: role });
|
||||||
ElMessage.success(TEXT.modules.adminProjectMembers.roleUpdated);
|
ElMessage.success(TEXT.modules.adminProjectMembers.roleUpdated);
|
||||||
loadMembers();
|
loadMembers();
|
||||||
logAudit("PROJECT_MEMBER_UPDATED", {
|
|
||||||
targetId: projectId.value,
|
|
||||||
targetName: project.value?.name,
|
|
||||||
after: { member_id: memberId, role_in_study: role },
|
|
||||||
severity: "normal",
|
|
||||||
});
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.updateFailed);
|
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.updateFailed);
|
||||||
loadMembers();
|
loadMembers();
|
||||||
logAudit("PROJECT_MEMBER_UPDATED", {
|
|
||||||
targetId: projectId.value,
|
|
||||||
targetName: project.value?.name,
|
|
||||||
after: { member_id: memberId, role_in_study: role },
|
|
||||||
severity: "warning",
|
|
||||||
reason: e?.response?.data?.message,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -273,12 +234,6 @@ const toggleActive = async (row: StudyMember) => {
|
|||||||
});
|
});
|
||||||
if (!decision.allowed) {
|
if (!decision.allowed) {
|
||||||
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
|
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
|
||||||
logAudit(decision.auditType, {
|
|
||||||
targetId: projectId.value,
|
|
||||||
targetName: project.value?.name,
|
|
||||||
reason: decision.reason,
|
|
||||||
severity: decision.severity,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (row.is_active) {
|
if (row.is_active) {
|
||||||
@@ -291,46 +246,16 @@ const toggleActive = async (row: StudyMember) => {
|
|||||||
await updateMember(projectId.value, row.id, { is_active: false });
|
await updateMember(projectId.value, row.id, { is_active: false });
|
||||||
ElMessage.success(TEXT.modules.adminProjectMembers.disableSuccess);
|
ElMessage.success(TEXT.modules.adminProjectMembers.disableSuccess);
|
||||||
loadMembers();
|
loadMembers();
|
||||||
logAudit("PROJECT_MEMBER_UPDATED", {
|
|
||||||
targetId: projectId.value,
|
|
||||||
targetName: project.value?.name,
|
|
||||||
before: { is_active: row.is_active },
|
|
||||||
after: { is_active: false },
|
|
||||||
severity: "normal",
|
|
||||||
});
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.actionFailed);
|
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.actionFailed);
|
||||||
logAudit("PROJECT_MEMBER_UPDATED", {
|
|
||||||
targetId: projectId.value,
|
|
||||||
targetName: project.value?.name,
|
|
||||||
before: { is_active: row.is_active },
|
|
||||||
after: { is_active: false },
|
|
||||||
severity: "warning",
|
|
||||||
reason: e?.response?.data?.message,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
await updateMember(projectId.value, row.id, { is_active: true });
|
await updateMember(projectId.value, row.id, { is_active: true });
|
||||||
ElMessage.success(TEXT.modules.adminProjectMembers.enableSuccess);
|
ElMessage.success(TEXT.modules.adminProjectMembers.enableSuccess);
|
||||||
loadMembers();
|
loadMembers();
|
||||||
logAudit("PROJECT_MEMBER_UPDATED", {
|
|
||||||
targetId: projectId.value,
|
|
||||||
targetName: project.value?.name,
|
|
||||||
before: { is_active: row.is_active },
|
|
||||||
after: { is_active: true },
|
|
||||||
severity: "normal",
|
|
||||||
});
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.actionFailed);
|
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.actionFailed);
|
||||||
logAudit("PROJECT_MEMBER_UPDATED", {
|
|
||||||
targetId: projectId.value,
|
|
||||||
targetName: project.value?.name,
|
|
||||||
before: { is_active: row.is_active },
|
|
||||||
after: { is_active: true },
|
|
||||||
severity: "warning",
|
|
||||||
reason: e?.response?.data?.message,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -344,12 +269,6 @@ const onDelete = async (row: StudyMember) => {
|
|||||||
});
|
});
|
||||||
if (!decision.allowed) {
|
if (!decision.allowed) {
|
||||||
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
|
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
|
||||||
logAudit(decision.auditType, {
|
|
||||||
targetId: projectId.value,
|
|
||||||
targetName: project.value?.name,
|
|
||||||
reason: decision.reason,
|
|
||||||
severity: decision.severity,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ok = await ElMessageBox.confirm(TEXT.modules.adminProjectMembers.removeConfirm, TEXT.modules.adminProjectMembers.removeTitle, {
|
const ok = await ElMessageBox.confirm(TEXT.modules.adminProjectMembers.removeConfirm, TEXT.modules.adminProjectMembers.removeTitle, {
|
||||||
@@ -362,23 +281,8 @@ const onDelete = async (row: StudyMember) => {
|
|||||||
await removeMember(projectId.value, row.id);
|
await removeMember(projectId.value, row.id);
|
||||||
members.value = members.value.filter((m) => m.id !== row.id);
|
members.value = members.value.filter((m) => m.id !== row.id);
|
||||||
ElMessage.success(TEXT.modules.adminProjectMembers.removeSuccess);
|
ElMessage.success(TEXT.modules.adminProjectMembers.removeSuccess);
|
||||||
logAudit("PROJECT_MEMBER_UPDATED", {
|
|
||||||
targetId: projectId.value,
|
|
||||||
targetName: project.value?.name,
|
|
||||||
before: { is_active: row.is_active },
|
|
||||||
after: { removed_member_id: row.id },
|
|
||||||
severity: "normal",
|
|
||||||
});
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
|
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
|
||||||
logAudit("PROJECT_MEMBER_UPDATED", {
|
|
||||||
targetId: projectId.value,
|
|
||||||
targetName: project.value?.name,
|
|
||||||
before: { is_active: row.is_active },
|
|
||||||
after: { removed_member_id: row.id },
|
|
||||||
severity: "warning",
|
|
||||||
reason: e?.response?.data?.message,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import { updateSite } from "../../api/sites";
|
|||||||
import type { Site, UserInfo } from "../../types/api";
|
import type { Site, UserInfo } from "../../types/api";
|
||||||
import { useAuthStore } from "../../store/auth";
|
import { useAuthStore } from "../../store/auth";
|
||||||
import { evaluateAction } from "../../guards/actionGuard";
|
import { evaluateAction } from "../../guards/actionGuard";
|
||||||
import { logAudit } from "../../audit";
|
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -85,12 +84,6 @@ const onSave = async () => {
|
|||||||
});
|
});
|
||||||
if (!decision.allowed) {
|
if (!decision.allowed) {
|
||||||
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
|
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
|
||||||
logAudit(decision.auditType, {
|
|
||||||
targetId: props.site.id,
|
|
||||||
targetName: props.site.name,
|
|
||||||
reason: decision.reason,
|
|
||||||
severity: decision.severity,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
@@ -102,23 +95,8 @@ const onSave = async () => {
|
|||||||
ElMessage.success(TEXT.modules.adminSites.craSaveSuccess);
|
ElMessage.success(TEXT.modules.adminSites.craSaveSuccess);
|
||||||
emit("saved");
|
emit("saved");
|
||||||
visibleProxy.value = false;
|
visibleProxy.value = false;
|
||||||
logAudit("SITE_CRA_BOUND", {
|
|
||||||
targetId: props.site.id,
|
|
||||||
targetName: props.site.name,
|
|
||||||
before: { contact: props.site.contact },
|
|
||||||
after: { contact: names.join(",") },
|
|
||||||
severity: "normal",
|
|
||||||
});
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
||||||
logAudit("SITE_CRA_BOUND", {
|
|
||||||
targetId: props.site.id,
|
|
||||||
targetName: props.site.name,
|
|
||||||
before: { contact: props.site.contact },
|
|
||||||
after: { contact: selectedCras.value.join(",") },
|
|
||||||
severity: "warning",
|
|
||||||
reason: e?.response?.data?.message,
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false;
|
saving.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ import { createSite, updateSite } from "../../api/sites";
|
|||||||
import type { Site } from "../../types/api";
|
import type { Site } from "../../types/api";
|
||||||
import { useAuthStore } from "../../store/auth";
|
import { useAuthStore } from "../../store/auth";
|
||||||
import { evaluateAction } from "../../guards/actionGuard";
|
import { evaluateAction } from "../../guards/actionGuard";
|
||||||
import { logAudit } from "../../audit";
|
|
||||||
import { TEXT, requiredMessage } from "../../locales";
|
import { TEXT, requiredMessage } from "../../locales";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -137,12 +136,6 @@ const onSubmit = async () => {
|
|||||||
});
|
});
|
||||||
if (!decision.allowed) {
|
if (!decision.allowed) {
|
||||||
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
|
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
|
||||||
logAudit(decision.auditType, {
|
|
||||||
targetId: props.site?.id || props.studyId,
|
|
||||||
targetName: props.site?.name,
|
|
||||||
reason: decision.reason,
|
|
||||||
severity: decision.severity,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await formRef.value.validate();
|
await formRef.value.validate();
|
||||||
@@ -158,12 +151,6 @@ const onSubmit = async () => {
|
|||||||
is_active: form.is_active,
|
is_active: form.is_active,
|
||||||
});
|
});
|
||||||
ElMessage.success(TEXT.modules.adminSites.updateSuccess);
|
ElMessage.success(TEXT.modules.adminSites.updateSuccess);
|
||||||
logAudit("SITE_STATUS_CHANGED", {
|
|
||||||
targetId: props.site.id,
|
|
||||||
targetName: props.site.name,
|
|
||||||
after: { name: form.name, is_active: form.is_active },
|
|
||||||
severity: "normal",
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
await createSite(props.studyId, {
|
await createSite(props.studyId, {
|
||||||
name: form.name,
|
name: form.name,
|
||||||
@@ -172,25 +159,12 @@ const onSubmit = async () => {
|
|||||||
contact,
|
contact,
|
||||||
});
|
});
|
||||||
ElMessage.success(TEXT.modules.adminSites.createSuccess);
|
ElMessage.success(TEXT.modules.adminSites.createSuccess);
|
||||||
logAudit("SITE_STATUS_CHANGED", {
|
|
||||||
targetId: props.studyId,
|
|
||||||
targetName: form.name,
|
|
||||||
after: { name: form.name },
|
|
||||||
severity: "normal",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
emit("saved");
|
emit("saved");
|
||||||
visibleProxy.value = false;
|
visibleProxy.value = false;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
||||||
logAudit("SITE_STATUS_CHANGED", {
|
|
||||||
targetId: props.site?.id || props.studyId,
|
|
||||||
targetName: props.site?.name || form.name,
|
|
||||||
after: { name: form.name },
|
|
||||||
severity: "warning",
|
|
||||||
reason: e?.response?.data?.message,
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false;
|
submitting.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ import SiteForm from "./SiteForm.vue";
|
|||||||
import type { Site, Study, UserInfo } from "../../types/api";
|
import type { Site, Study, UserInfo } from "../../types/api";
|
||||||
import { useAuthStore } from "../../store/auth";
|
import { useAuthStore } from "../../store/auth";
|
||||||
import { evaluateAction } from "../../guards/actionGuard";
|
import { evaluateAction } from "../../guards/actionGuard";
|
||||||
import { logAudit } from "../../audit";
|
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -217,12 +216,6 @@ const toggleSite = async (row: Site) => {
|
|||||||
});
|
});
|
||||||
if (!decision.allowed) {
|
if (!decision.allowed) {
|
||||||
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
|
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
|
||||||
logAudit(decision.auditType, {
|
|
||||||
targetId: row.id,
|
|
||||||
targetName: row.name,
|
|
||||||
reason: decision.reason,
|
|
||||||
severity: decision.severity,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (row.is_active) {
|
if (row.is_active) {
|
||||||
@@ -237,23 +230,8 @@ const toggleSite = async (row: Site) => {
|
|||||||
await updateSite(projectId.value, row.id, { is_active: !row.is_active });
|
await updateSite(projectId.value, row.id, { is_active: !row.is_active });
|
||||||
ElMessage.success(row.is_active ? "已锁定" : "已解锁");
|
ElMessage.success(row.is_active ? "已锁定" : "已解锁");
|
||||||
loadSites();
|
loadSites();
|
||||||
logAudit("SITE_STATUS_CHANGED", {
|
|
||||||
targetId: row.id,
|
|
||||||
targetName: row.name,
|
|
||||||
before: { is_active: row.is_active },
|
|
||||||
after: { is_active: !row.is_active },
|
|
||||||
severity: "normal",
|
|
||||||
});
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.actionFailed);
|
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.actionFailed);
|
||||||
logAudit("SITE_STATUS_CHANGED", {
|
|
||||||
targetId: row.id,
|
|
||||||
targetName: row.name,
|
|
||||||
before: { is_active: row.is_active },
|
|
||||||
after: { is_active: !row.is_active },
|
|
||||||
severity: "warning",
|
|
||||||
reason: e?.response?.data?.message,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -277,19 +255,8 @@ const confirmDelete = async (row: Site) => {
|
|||||||
await deleteSite(projectId.value, row.id);
|
await deleteSite(projectId.value, row.id);
|
||||||
ElMessage.success(TEXT.common.messages.deleteSuccess);
|
ElMessage.success(TEXT.common.messages.deleteSuccess);
|
||||||
loadSites();
|
loadSites();
|
||||||
logAudit("SITE_DELETED", {
|
|
||||||
targetId: row.id,
|
|
||||||
targetName: row.name,
|
|
||||||
severity: "high",
|
|
||||||
});
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
|
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
|
||||||
logAudit("SITE_DELETED", {
|
|
||||||
targetId: row.id,
|
|
||||||
targetName: row.name,
|
|
||||||
severity: "warning",
|
|
||||||
reason: e?.response?.data?.message,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,601 @@
|
|||||||
<template>
|
<template>
|
||||||
<ModulePlaceholder
|
<div class="page">
|
||||||
:title="TEXT.modules.materialEquipment.title"
|
<div v-if="study.currentStudy" class="unified-shell">
|
||||||
:subtitle="TEXT.modules.materialEquipment.subtitle"
|
<section class="unified-section equipment-section">
|
||||||
:list-title="TEXT.modules.materialEquipment.listTitle"
|
<div class="filter-row">
|
||||||
:empty-description="TEXT.modules.materialEquipment.emptyDescription"
|
<el-form :inline="true" :model="filters">
|
||||||
/>
|
<el-form-item label="设备名称">
|
||||||
|
<el-input v-model="filters.name" clearable placeholder="请输入" class="name-filter" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||||
|
<el-button @click="resetFilters">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<el-button type="primary" @click="openCreate">新建</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table v-loading="loading" :data="rows" class="ctms-table" style="width: 100%" table-layout="fixed">
|
||||||
|
<el-table-column prop="name" label="设备名称" />
|
||||||
|
<el-table-column prop="specModel" label="规格型号" />
|
||||||
|
<el-table-column prop="unit" label="单位" />
|
||||||
|
<el-table-column prop="brand" label="品牌" />
|
||||||
|
<el-table-column label="是否需要校准">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.needCalibration ? "是" : "否" }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||||
|
<el-button link type="danger" @click="removeRow(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<StateEmpty v-if="!loading && rows.length === 0" description="暂无设备数据" />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
|
||||||
|
|
||||||
|
<el-drawer
|
||||||
|
v-model="drawerVisible"
|
||||||
|
direction="rtl"
|
||||||
|
size="620px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
:show-close="false"
|
||||||
|
class="equipment-editor-drawer"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="editor-header">
|
||||||
|
<div class="editor-title">{{ editingId ? "编辑设备" : "新建设备" }}</div>
|
||||||
|
<div class="editor-subtitle">{{ editingId ? "修改设备基本信息与校准配置" : "添加新的设备记录到设备台账" }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="equipment-form">
|
||||||
|
<!-- 基本信息分组 -->
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="form-group-title">
|
||||||
|
<span class="group-dot group-dot-basic"></span>
|
||||||
|
基本信息
|
||||||
|
</div>
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="设备名称" prop="name">
|
||||||
|
<el-input v-model="form.name" placeholder="请输入设备名称" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="规格型号" prop="specModel">
|
||||||
|
<el-input v-model="form.specModel" placeholder="请输入规格型号" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="单位" prop="unit">
|
||||||
|
<el-input v-model="form.unit" placeholder="请输入" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="品牌" prop="brand">
|
||||||
|
<el-input v-model="form.brand" placeholder="请输入品牌" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="产地" prop="origin">
|
||||||
|
<el-input v-model="form.origin" placeholder="请输入产地" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 资质文件分组 -->
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="form-group-title">
|
||||||
|
<span class="group-dot group-dot-file"></span>
|
||||||
|
资质文件
|
||||||
|
</div>
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12">
|
||||||
|
<div class="upload-card">
|
||||||
|
<div class="upload-card-label">生产许可证</div>
|
||||||
|
<el-upload :auto-upload="false" :show-file-list="false" :on-change="onProductionPermitChange">
|
||||||
|
<div v-if="!form.productionPermitFileName" class="upload-trigger">
|
||||||
|
<span class="upload-icon">📄</span>
|
||||||
|
<span class="upload-text">点击上传文件</span>
|
||||||
|
</div>
|
||||||
|
</el-upload>
|
||||||
|
<div v-if="form.productionPermitFileName" class="upload-result">
|
||||||
|
<span class="upload-result-icon">✅</span>
|
||||||
|
<span class="upload-result-name">{{ form.productionPermitFileName }}</span>
|
||||||
|
<el-button link type="danger" size="small" @click="form.productionPermitFileName = ''">移除</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<div class="upload-card">
|
||||||
|
<div class="upload-card-label">技术指标</div>
|
||||||
|
<el-upload :auto-upload="false" :show-file-list="false" :on-change="onTechIndexChange">
|
||||||
|
<div v-if="!form.techIndexFileName" class="upload-trigger">
|
||||||
|
<span class="upload-icon">📄</span>
|
||||||
|
<span class="upload-text">点击上传文件</span>
|
||||||
|
</div>
|
||||||
|
</el-upload>
|
||||||
|
<div v-if="form.techIndexFileName" class="upload-result">
|
||||||
|
<span class="upload-result-icon">✅</span>
|
||||||
|
<span class="upload-result-name">{{ form.techIndexFileName }}</span>
|
||||||
|
<el-button link type="danger" size="small" @click="form.techIndexFileName = ''">移除</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 校准设置分组 -->
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="form-group-title">
|
||||||
|
<span class="group-dot group-dot-calibration"></span>
|
||||||
|
校准设置
|
||||||
|
</div>
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="是否需要校准" prop="needCalibration">
|
||||||
|
<el-select v-model="form.needCalibration" placeholder="请选择" class="full-width">
|
||||||
|
<el-option label="是" :value="true" />
|
||||||
|
<el-option label="否" :value="false" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col v-if="form.needCalibration" :span="12">
|
||||||
|
<el-form-item label="校准周期(天)" prop="calibrationCycleDays">
|
||||||
|
<el-input-number v-model="form.calibrationCycleDays" :min="1" :precision="0" class="full-width" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<div v-if="!form.needCalibration" class="calibration-hint">
|
||||||
|
<span class="hint-icon">ℹ️</span>
|
||||||
|
<span>该设备无需定期校准</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="drawer-footer">
|
||||||
|
<el-button @click="drawerVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="saveForm">保存</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-drawer>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import ModulePlaceholder from "../../components/ModulePlaceholder.vue";
|
import { reactive, ref, watch } from "vue";
|
||||||
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules, type UploadFile } from "element-plus";
|
||||||
|
import {
|
||||||
|
createMaterialEquipment,
|
||||||
|
deleteMaterialEquipment,
|
||||||
|
listMaterialEquipments,
|
||||||
|
updateMaterialEquipment,
|
||||||
|
} from "../../api/materialEquipments";
|
||||||
|
import StateEmpty from "../../components/StateEmpty.vue";
|
||||||
|
import { useStudyStore } from "../../store/study";
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
|
|
||||||
|
interface EquipmentRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
specModel: string;
|
||||||
|
unit: string;
|
||||||
|
brand: string;
|
||||||
|
origin: string;
|
||||||
|
productionPermitFileName: string;
|
||||||
|
techIndexFileName: string;
|
||||||
|
needCalibration: boolean;
|
||||||
|
calibrationCycleDays: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormModel = Omit<EquipmentRow, "id">;
|
||||||
|
|
||||||
|
const study = useStudyStore();
|
||||||
|
const filters = reactive({ name: "" });
|
||||||
|
const rows = ref<EquipmentRow[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const drawerVisible = ref(false);
|
||||||
|
const editingId = ref("");
|
||||||
|
const formRef = ref<FormInstance>();
|
||||||
|
|
||||||
|
const defaultForm: FormModel = {
|
||||||
|
name: "",
|
||||||
|
specModel: "",
|
||||||
|
unit: "",
|
||||||
|
brand: "",
|
||||||
|
origin: "",
|
||||||
|
productionPermitFileName: "",
|
||||||
|
techIndexFileName: "",
|
||||||
|
needCalibration: true,
|
||||||
|
calibrationCycleDays: 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
const form = reactive<FormModel>({ ...defaultForm });
|
||||||
|
|
||||||
|
const rules: FormRules<FormModel> = {
|
||||||
|
name: [{ required: true, message: "请输入设备名称", trigger: "blur" }],
|
||||||
|
specModel: [{ required: true, message: "请输入规格型号", trigger: "blur" }],
|
||||||
|
brand: [{ required: true, message: "请输入品牌", trigger: "blur" }],
|
||||||
|
needCalibration: [{ required: true, message: "请选择是否需要校准", trigger: "change" }],
|
||||||
|
calibrationCycleDays: [
|
||||||
|
{
|
||||||
|
validator: (_rule, value, callback) => {
|
||||||
|
if (form.needCalibration && (!value || value < 1)) {
|
||||||
|
callback(new Error("请填写校准周期"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
trigger: "change",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadRows = async () => {
|
||||||
|
const studyId = study.currentStudy?.id;
|
||||||
|
if (!studyId) {
|
||||||
|
rows.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = (await listMaterialEquipments(studyId, {
|
||||||
|
name: filters.name.trim() || undefined,
|
||||||
|
limit: 500,
|
||||||
|
})) as any;
|
||||||
|
const list = Array.isArray(data) ? data : data?.items || [];
|
||||||
|
rows.value = list.map((item: any) => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name || "",
|
||||||
|
specModel: item.spec_model || "",
|
||||||
|
unit: item.unit || "",
|
||||||
|
brand: item.brand || "",
|
||||||
|
origin: item.origin || "",
|
||||||
|
productionPermitFileName: item.production_permit_file_name || "",
|
||||||
|
techIndexFileName: item.tech_index_file_name || "",
|
||||||
|
needCalibration: !!item.need_calibration,
|
||||||
|
calibrationCycleDays: item.calibration_cycle_days ?? null,
|
||||||
|
}));
|
||||||
|
} catch (e: any) {
|
||||||
|
rows.value = [];
|
||||||
|
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.loadFailed);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
Object.assign(form, defaultForm);
|
||||||
|
formRef.value?.clearValidate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
editingId.value = "";
|
||||||
|
resetForm();
|
||||||
|
drawerVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (row: EquipmentRow) => {
|
||||||
|
editingId.value = row.id;
|
||||||
|
Object.assign(form, {
|
||||||
|
name: row.name,
|
||||||
|
specModel: row.specModel,
|
||||||
|
unit: row.unit,
|
||||||
|
brand: row.brand,
|
||||||
|
origin: row.origin,
|
||||||
|
productionPermitFileName: row.productionPermitFileName,
|
||||||
|
techIndexFileName: row.techIndexFileName,
|
||||||
|
needCalibration: row.needCalibration,
|
||||||
|
calibrationCycleDays: row.calibrationCycleDays,
|
||||||
|
});
|
||||||
|
formRef.value?.clearValidate();
|
||||||
|
drawerVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveForm = async () => {
|
||||||
|
const studyId = study.currentStudy?.id;
|
||||||
|
if (!studyId) return;
|
||||||
|
const ok = await formRef.value?.validate().catch(() => false);
|
||||||
|
if (!ok) return;
|
||||||
|
const payload = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
spec_model: form.specModel.trim(),
|
||||||
|
unit: form.unit.trim() || null,
|
||||||
|
brand: form.brand.trim(),
|
||||||
|
origin: form.origin.trim() || null,
|
||||||
|
production_permit_file_name: form.productionPermitFileName || null,
|
||||||
|
tech_index_file_name: form.techIndexFileName || null,
|
||||||
|
need_calibration: !!form.needCalibration,
|
||||||
|
calibration_cycle_days: form.needCalibration ? form.calibrationCycleDays : null,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (editingId.value) {
|
||||||
|
await updateMaterialEquipment(studyId, editingId.value, payload);
|
||||||
|
} else {
|
||||||
|
await createMaterialEquipment(studyId, payload);
|
||||||
|
}
|
||||||
|
await loadRows();
|
||||||
|
drawerVisible.value = false;
|
||||||
|
ElMessage.success("保存成功");
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.saveFailed);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeRow = async (row: EquipmentRow) => {
|
||||||
|
const studyId = study.currentStudy?.id;
|
||||||
|
if (!studyId) return;
|
||||||
|
const ok = await ElMessageBox.confirm("确认删除该条设备记录吗?", "提示", { type: "warning" }).catch(() => null);
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await deleteMaterialEquipment(studyId, row.id);
|
||||||
|
await loadRows();
|
||||||
|
ElMessage.success("删除成功");
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.deleteFailed);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUploadChange = (file: UploadFile, field: "productionPermitFileName" | "techIndexFileName") => {
|
||||||
|
form[field] = file.name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onProductionPermitChange = (file: UploadFile) => {
|
||||||
|
handleUploadChange(file, "productionPermitFileName");
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTechIndexChange = (file: UploadFile) => {
|
||||||
|
handleUploadChange(file, "techIndexFileName");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
loadRows();
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
filters.name = "";
|
||||||
|
loadRows();
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => study.currentStudy?.id,
|
||||||
|
() => {
|
||||||
|
filters.name = "";
|
||||||
|
loadRows();
|
||||||
|
drawerVisible.value = false;
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => form.needCalibration,
|
||||||
|
(need) => {
|
||||||
|
if (!need) form.calibrationCycleDays = null;
|
||||||
|
if (need && !form.calibrationCycleDays) form.calibrationCycleDays = 30;
|
||||||
|
}
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equipment-section {
|
||||||
|
padding-top: 14px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-filter {
|
||||||
|
width: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 抽屉头部 ========== */
|
||||||
|
.editor-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ctms-text-main);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-subtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ctms-text-secondary);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 表单整体 ========== */
|
||||||
|
.equipment-form {
|
||||||
|
padding: 4px 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 分组卡片 ========== */
|
||||||
|
.form-group {
|
||||||
|
border: 1px solid #e8eef6;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px 18px 8px;
|
||||||
|
background: #fbfcfe;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group:hover {
|
||||||
|
border-color: #d0dced;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group + .form-group {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
color: #1a3560;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 基本信息 - 蓝色 */
|
||||||
|
.group-dot-basic {
|
||||||
|
background: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 资质文件 - 琥珀色 */
|
||||||
|
.group-dot-file {
|
||||||
|
background: #f0ad2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 校准设置 - 绿色 */
|
||||||
|
.group-dot-calibration {
|
||||||
|
background: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 上传卡片 ========== */
|
||||||
|
.upload-card {
|
||||||
|
border: 1px dashed #d0dced;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #ffffff;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
min-height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-card:hover {
|
||||||
|
border-color: var(--ctms-primary);
|
||||||
|
background: #f8faff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-card-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4a6283;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ctms-text-secondary);
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-trigger:hover .upload-text {
|
||||||
|
color: var(--ctms-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-result {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-result-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-result-name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ctms-text-regular);
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 校准提示 ========== */
|
||||||
|
.calibration-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #f0fdf4;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #166534;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 表单元素细节 ========== */
|
||||||
|
.full-width {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 底部按钮 ========== */
|
||||||
|
.drawer-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 表单元素微调 ========== */
|
||||||
|
.equipment-form :deep(.el-form-item) {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equipment-form :deep(.el-form-item__label) {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4a6283;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 表格居中 ========== */
|
||||||
|
:deep(.ctms-table th.el-table__cell .cell),
|
||||||
|
:deep(.ctms-table td.el-table__cell .cell) {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -196,11 +196,10 @@
|
|||||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import { useStudyStore } from "../../store/study";
|
import { useStudyStore } from "../../store/study";
|
||||||
import { fetchStudySetupConfig } from "../../api/studies";
|
import { listProjectMilestones, updateProjectMilestone } from "../../api/projectMilestones";
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
import StateEmpty from "../../components/StateEmpty.vue";
|
import StateEmpty from "../../components/StateEmpty.vue";
|
||||||
import { displayDateTime } from "../../utils/display";
|
import { displayDateTime } from "../../utils/display";
|
||||||
import type { ProjectMilestoneDraft, StudySetupConfigResponse } from "../../types/setupConfig";
|
|
||||||
|
|
||||||
interface MilestoneRow {
|
interface MilestoneRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -234,11 +233,10 @@ type MilestoneLocalEdit = {
|
|||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
const rows = ref<MilestoneRow[]>([]);
|
const rows = ref<MilestoneRow[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const dataSourceLabel = ref<string>(TEXT.modules.projectMilestones.sourceDraft);
|
const dataSourceLabel = ref<string>(TEXT.modules.projectMilestones.sourcePublished);
|
||||||
const updatedAtLabel = ref<string>(TEXT.common.fallback);
|
const updatedAtLabel = ref<string>(TEXT.common.fallback);
|
||||||
const editorVisible = ref(false);
|
const editorVisible = ref(false);
|
||||||
const editingRowId = ref("");
|
const editingRowId = ref("");
|
||||||
const localEdits = ref<Record<string, MilestoneLocalEdit>>({});
|
|
||||||
const editorForm = reactive<MilestoneLocalEdit>({
|
const editorForm = reactive<MilestoneLocalEdit>({
|
||||||
adjustedStartDate: "",
|
adjustedStartDate: "",
|
||||||
adjustedEndDate: "",
|
adjustedEndDate: "",
|
||||||
@@ -249,8 +247,6 @@ const editorForm = reactive<MilestoneLocalEdit>({
|
|||||||
|
|
||||||
const doneCount = computed(() => rows.value.filter((item) => getStatusView(item).completed).length);
|
const doneCount = computed(() => rows.value.filter((item) => getStatusView(item).completed).length);
|
||||||
|
|
||||||
const getLocalEditKey = (studyId: string) => `ctms_project_milestones_edits_${studyId}`;
|
|
||||||
|
|
||||||
const toDateOnly = (value?: string): Date | null => {
|
const toDateOnly = (value?: string): Date | null => {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const d = new Date(`${value}T00:00:00`);
|
const d = new Date(`${value}T00:00:00`);
|
||||||
@@ -267,22 +263,24 @@ const calcDurationDays = (start?: string, end?: string): number | null => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toDurationText = (days: number | null) => (days && days > 0 ? `${days}天` : TEXT.common.fallback);
|
const toDurationText = (days: number | null) => (days && days > 0 ? `${days}天` : TEXT.common.fallback);
|
||||||
const toPositiveInt = (value: unknown): number | null => {
|
|
||||||
const n = Number(value);
|
|
||||||
if (!Number.isFinite(n) || n <= 0) return null;
|
|
||||||
return Math.floor(n);
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyEditToRow = (row: MilestoneRow): MilestoneRow => {
|
const normalizeRow = (row: any): MilestoneRow => {
|
||||||
const patch = localEdits.value[row.id] || {};
|
const startDate = String(row?.planned_date || "").trim();
|
||||||
const adjustedStartDate = String(patch.adjustedStartDate ?? row.adjustedStartDate ?? "").trim();
|
const endDate = String(row?.planned_date || "").trim();
|
||||||
const adjustedEndDate = String(patch.adjustedEndDate ?? row.adjustedEndDate ?? "").trim();
|
const adjustedStartDate = String(row?.adjusted_start_date || "").trim();
|
||||||
const actualStartDate = String(patch.actualStartDate ?? row.actualStartDate ?? "").trim();
|
const adjustedEndDate = String(row?.adjusted_end_date || "").trim();
|
||||||
const actualEndDate = String(patch.actualEndDate ?? row.actualEndDate ?? "").trim();
|
const actualStartDate = String(row?.actual_start_date || "").trim();
|
||||||
|
const actualEndDate = String(row?.actual_end_date || "").trim();
|
||||||
const adjustedDurationDays = calcDurationDays(adjustedStartDate, adjustedEndDate);
|
const adjustedDurationDays = calcDurationDays(adjustedStartDate, adjustedEndDate);
|
||||||
const actualDurationDays = calcDurationDays(actualStartDate, actualEndDate);
|
const actualDurationDays = calcDurationDays(actualStartDate, actualEndDate);
|
||||||
return {
|
return {
|
||||||
...row,
|
id: row?.id || "",
|
||||||
|
name: String(row?.name || "").trim(),
|
||||||
|
planDate: startDate,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
durationDays: startDate ? 1 : null,
|
||||||
|
durationText: startDate ? "1天" : TEXT.common.fallback,
|
||||||
adjustedStartDate,
|
adjustedStartDate,
|
||||||
adjustedEndDate,
|
adjustedEndDate,
|
||||||
adjustedDurationDays,
|
adjustedDurationDays,
|
||||||
@@ -291,85 +289,26 @@ const applyEditToRow = (row: MilestoneRow): MilestoneRow => {
|
|||||||
actualEndDate,
|
actualEndDate,
|
||||||
actualDurationDays,
|
actualDurationDays,
|
||||||
actualDurationText: toDurationText(actualDurationDays),
|
actualDurationText: toDurationText(actualDurationDays),
|
||||||
remark: String(patch.remark ?? row.remark ?? "").trim(),
|
owner: String(row?.owner_name || "").trim(),
|
||||||
|
status: String(row?.status || "NOT_STARTED").trim(),
|
||||||
|
remark: String(row?.notes || "").trim(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadLocalEdits = () => {
|
|
||||||
const studyId = study.currentStudy?.id;
|
|
||||||
if (!studyId) return;
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(getLocalEditKey(studyId));
|
|
||||||
localEdits.value = raw ? (JSON.parse(raw) as Record<string, MilestoneLocalEdit>) : {};
|
|
||||||
} catch {
|
|
||||||
localEdits.value = {};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const persistLocalEdits = () => {
|
|
||||||
const studyId = study.currentStudy?.id;
|
|
||||||
if (!studyId) return;
|
|
||||||
localStorage.setItem(getLocalEditKey(studyId), JSON.stringify(localEdits.value));
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeRows = (items: ProjectMilestoneDraft[]) =>
|
|
||||||
items
|
|
||||||
.map((item, index) => {
|
|
||||||
const planDate = String(item.planDate || "").trim();
|
|
||||||
const startDate = String(item.startDate || planDate).trim();
|
|
||||||
const endDate = String(item.endDate || planDate).trim();
|
|
||||||
const adjustedStartDate = String((item as any).adjustedStartDate || "").trim();
|
|
||||||
const adjustedEndDate = String((item as any).adjustedEndDate || "").trim();
|
|
||||||
const actualStartDate = String((item as any).actualStartDate || "").trim();
|
|
||||||
const actualEndDate = String((item as any).actualEndDate || "").trim();
|
|
||||||
const rawDays = toPositiveInt(item.durationDays);
|
|
||||||
const adjustedRawDays = toPositiveInt((item as any).adjustedDurationDays) ?? calcDurationDays(adjustedStartDate, adjustedEndDate);
|
|
||||||
const actualRawDays = toPositiveInt((item as any).actualDurationDays) ?? calcDurationDays(actualStartDate, actualEndDate);
|
|
||||||
const durationDays = rawDays ?? (startDate || endDate ? 1 : null);
|
|
||||||
const adjustedDurationDays = adjustedRawDays ?? (adjustedStartDate || adjustedEndDate ? 1 : null);
|
|
||||||
const actualDurationDays = actualRawDays ?? (actualStartDate || actualEndDate ? 1 : null);
|
|
||||||
return {
|
|
||||||
id: item.id || `setup-project-milestone-${index + 1}`,
|
|
||||||
name: String(item.name || "").trim(),
|
|
||||||
planDate,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
durationDays,
|
|
||||||
durationText: durationDays ? `${durationDays}天` : TEXT.common.fallback,
|
|
||||||
adjustedStartDate,
|
|
||||||
adjustedEndDate,
|
|
||||||
adjustedDurationDays,
|
|
||||||
adjustedDurationText: adjustedDurationDays ? `${adjustedDurationDays}天` : TEXT.common.fallback,
|
|
||||||
actualStartDate,
|
|
||||||
actualEndDate,
|
|
||||||
actualDurationDays,
|
|
||||||
actualDurationText: actualDurationDays ? `${actualDurationDays}天` : TEXT.common.fallback,
|
|
||||||
owner: String(item.owner || "").trim(),
|
|
||||||
status: String(item.status || "未开始").trim(),
|
|
||||||
remark: String(item.remark || "").trim(),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((item) => item.name || item.startDate || item.endDate || item.owner || item.remark);
|
|
||||||
|
|
||||||
const pickProjectMilestones = (setup: StudySetupConfigResponse): ProjectMilestoneDraft[] => {
|
|
||||||
const publishedRows = normalizeRows(setup.published_data?.projectMilestones || []);
|
|
||||||
if (publishedRows.length > 0) {
|
|
||||||
dataSourceLabel.value = TEXT.modules.projectMilestones.sourcePublished;
|
|
||||||
return setup.published_data?.projectMilestones || [];
|
|
||||||
}
|
|
||||||
dataSourceLabel.value = setup.published_data ? TEXT.modules.projectMilestones.sourceDraftFallback : TEXT.modules.projectMilestones.sourceDraft;
|
|
||||||
return setup.data?.projectMilestones || [];
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadMilestones = async () => {
|
const loadMilestones = async () => {
|
||||||
const studyId = study.currentStudy?.id;
|
const studyId = study.currentStudy?.id;
|
||||||
if (!studyId) return;
|
if (!studyId) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const { data } = await fetchStudySetupConfig(studyId);
|
const { data } = (await listProjectMilestones(studyId)) as any;
|
||||||
loadLocalEdits();
|
const list = Array.isArray(data) ? data : data?.items || [];
|
||||||
rows.value = normalizeRows(pickProjectMilestones(data)).map(applyEditToRow);
|
rows.value = list.map(normalizeRow);
|
||||||
updatedAtLabel.value = displayDateTime(data.published_at || data.updated_at);
|
const updatedAt = list
|
||||||
|
.map((item: any) => String(item?.updated_at || ""))
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort()
|
||||||
|
.pop();
|
||||||
|
updatedAtLabel.value = updatedAt ? displayDateTime(updatedAt) : TEXT.common.fallback;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
rows.value = [];
|
rows.value = [];
|
||||||
updatedAtLabel.value = TEXT.common.fallback;
|
updatedAtLabel.value = TEXT.common.fallback;
|
||||||
@@ -468,25 +407,26 @@ const openEditor = (row: MilestoneRow) => {
|
|||||||
editorVisible.value = true;
|
editorVisible.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveEditor = () => {
|
const saveEditor = async () => {
|
||||||
if (!editingRowId.value) return;
|
const studyId = study.currentStudy?.id;
|
||||||
|
if (!studyId || !editingRowId.value) return;
|
||||||
if (!validateDateRange(editorForm.adjustedStartDate, editorForm.adjustedEndDate, "调整计划时间")) return;
|
if (!validateDateRange(editorForm.adjustedStartDate, editorForm.adjustedEndDate, "调整计划时间")) return;
|
||||||
if (!validateDateRange(editorForm.actualStartDate, editorForm.actualEndDate, "实际时间")) return;
|
if (!validateDateRange(editorForm.actualStartDate, editorForm.actualEndDate, "实际时间")) return;
|
||||||
const patch: MilestoneLocalEdit = {
|
const patch = {
|
||||||
adjustedStartDate: editorForm.adjustedStartDate || "",
|
adjusted_start_date: editorForm.adjustedStartDate || null,
|
||||||
adjustedEndDate: editorForm.adjustedEndDate || "",
|
adjusted_end_date: editorForm.adjustedEndDate || null,
|
||||||
actualStartDate: editorForm.actualStartDate || "",
|
actual_start_date: editorForm.actualStartDate || null,
|
||||||
actualEndDate: editorForm.actualEndDate || "",
|
actual_end_date: editorForm.actualEndDate || null,
|
||||||
remark: (editorForm.remark || "").trim(),
|
notes: (editorForm.remark || "").trim() || null,
|
||||||
};
|
};
|
||||||
localEdits.value = {
|
try {
|
||||||
...localEdits.value,
|
await updateProjectMilestone(studyId, editingRowId.value, patch);
|
||||||
[editingRowId.value]: patch,
|
await loadMilestones();
|
||||||
};
|
|
||||||
persistLocalEdits();
|
|
||||||
rows.value = rows.value.map((item) => (item.id === editingRowId.value ? applyEditToRow(item) : item));
|
|
||||||
editorVisible.value = false;
|
editorVisible.value = false;
|
||||||
ElMessage.success("里程碑时间已更新");
|
ElMessage.success("里程碑时间已更新");
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error?.response?.data?.detail || TEXT.common.messages.saveFailed);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -497,7 +437,6 @@ watch(
|
|||||||
() => study.currentStudy?.id,
|
() => study.currentStudy?.id,
|
||||||
() => {
|
() => {
|
||||||
rows.value = [];
|
rows.value = [];
|
||||||
localEdits.value = {};
|
|
||||||
updatedAtLabel.value = TEXT.common.fallback;
|
updatedAtLabel.value = TEXT.common.fallback;
|
||||||
loadMilestones();
|
loadMilestones();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user