立项配置数据入库、审计日志数据入库、编辑页UI界面美化、设备管理内容补充、审计日志显示优化

This commit is contained in:
Cheng Zhou
2026-02-27 16:16:26 +08:00
parent fd7e3fc948
commit db2d38edbc
48 changed files with 2936 additions and 909 deletions
@@ -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")
+29 -2
View File
@@ -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}
+153
View File
@@ -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,
)
+53 -1
View File
@@ -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
+74
View File
@@ -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)
+3 -1
View File
@@ -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"])
+55
View File
@@ -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
+51
View File
@@ -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,
+71
View File
@@ -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()
+54
View File
@@ -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
+2
View File
@@ -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))
+7
View File
@@ -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()
+1
View File
@@ -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
+29
View File
@@ -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()
)
+4
View File
@@ -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)
+1
View File
@@ -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)
+7
View File
@@ -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
+81
View File
@@ -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)
+29
View File
@@ -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
+30
View File
@@ -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,
+172
View File
@@ -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())
+24
View File
@@ -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);
+28
View File
@@ -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,
+56
View File
@@ -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` | 工作台本地待办缓存(非核心主数据) |
+52
View File
@@ -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 迁移为后端草稿(可选能力,非当前口径强制项)。
+4 -1
View File
@@ -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);
+16
View File
@@ -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}`);
+10
View File
@@ -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);
+3 -43
View File
@@ -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();
}; };
+14 -4
View File
@@ -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" },
-7
View File
@@ -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: "管理试验文档与版本审批",
-7
View File
@@ -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",
+17
View File
@@ -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;
}
+30
View File
@@ -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 {
+148
View File
@@ -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;
};
+22 -45
View File
@@ -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;
} }
-26
View File
@@ -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;
} }
-33
View File
@@ -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,
});
} }
}; };
+595 -7
View File
@@ -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>
-13
View File
@@ -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>
+43 -104
View File
@@ -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();
} }