管理后台前后端逻辑、UI美化

This commit is contained in:
Cheng Zhou
2026-01-16 13:50:08 +08:00
parent 05c1f9579a
commit 7fdcfdaadd
82 changed files with 3210 additions and 549 deletions
@@ -0,0 +1,28 @@
"""add is_locked to studies
Revision ID: 20260116_01
Revises: 20250310_02
Create Date: 2026-01-16 11:41:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '20260116_01'
down_revision: Union[str, None] = '20250310_02'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 添加 is_locked 字段到 studies 表
op.add_column('studies', sa.Column('is_locked', sa.Boolean(), nullable=False, server_default='false'))
def downgrade() -> None:
# 删除 is_locked 字段
op.drop_column('studies', 'is_locked')
+24 -4
View File
@@ -5,10 +5,12 @@ from datetime import date
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_current_user, get_db_session, require_study_member from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_not_locked
from app.crud import ae as ae_crud from app.crud import ae as ae_crud
from app.crud import audit as audit_crud 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 site as site_crud
from app.crud import subject as subject_crud
from app.crud import study as study_crud from app.crud import study as study_crud
from app.schemas.ae import AECreate, AERead, AEUpdate from app.schemas.ae import AECreate, AERead, AEUpdate
@@ -25,6 +27,22 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
return study return study
async def _ensure_ae_visible(db: AsyncSession, study_id: uuid.UUID, ae: AERead | None):
if not ae:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在")
if ae.site_id:
site = await site_crud.get_site(db, ae.site_id)
if not site or site.study_id != study_id or not site.is_active:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在")
if ae.subject_id:
subject = await subject_crud.get_subject(db, ae.subject_id)
if not subject or subject.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在")
site = await site_crud.get_site(db, subject.site_id)
if not site or not site.is_active:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在")
async def _get_member_role(db: AsyncSession, study_id: uuid.UUID, user_id: uuid.UUID) -> str | None: async def _get_member_role(db: AsyncSession, study_id: uuid.UUID, user_id: uuid.UUID) -> str | None:
member = await member_crud.get_member(db, study_id, user_id) member = await member_crud.get_member(db, study_id, user_id)
return member.role_in_study if member else None return member.role_in_study if member else None
@@ -38,7 +56,7 @@ def _is_overdue(ae: AERead) -> bool:
"/", "/",
response_model=AERead, response_model=AERead,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_member())], dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())],
) )
async def create_ae( async def create_ae(
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -117,7 +135,7 @@ async def get_ae(
@router.patch( @router.patch(
"/{ae_id}", "/{ae_id}",
response_model=AERead, response_model=AERead,
dependencies=[Depends(require_study_member())], dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())],
) )
async def update_ae( async def update_ae(
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -132,6 +150,7 @@ async def update_ae(
ae = await ae_crud.get_ae(db, ae_id) ae = await ae_crud.get_ae(db, ae_id)
if not ae or ae.study_id != study_id: if not ae or ae.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在")
await _ensure_ae_visible(db, study_id, AERead.model_validate(ae))
member_role = await _get_member_role(db, study_id, current_user.id) member_role = await _get_member_role(db, study_id, current_user.id)
if current_user.role == "ADMIN": if current_user.role == "ADMIN":
@@ -184,7 +203,7 @@ async def update_ae(
@router.delete( @router.delete(
"/{ae_id}", "/{ae_id}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_member())], dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())],
) )
async def delete_ae( async def delete_ae(
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -196,6 +215,7 @@ async def delete_ae(
ae = await ae_crud.get_ae(db, ae_id) ae = await ae_crud.get_ae(db, ae_id)
if not ae or ae.study_id != study_id: if not ae or ae.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在")
await _ensure_ae_visible(db, study_id, AERead.model_validate(ae))
member_role = await _get_member_role(db, study_id, current_user.id) member_role = await _get_member_role(db, study_id, current_user.id)
if current_user.role != "ADMIN" and member_role not in {"PM", "PV"}: if current_user.role != "ADMIN" and member_role not in {"PM", "PV"}:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
+3 -3
View File
@@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status,
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_member, get_study_member from app.core.deps import get_current_user, get_db_session, require_study_member, get_study_member, require_study_not_locked
from app.crud import attachment as attachment_crud from app.crud import attachment as attachment_crud
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import study as study_crud from app.crud import study as study_crud
@@ -41,7 +41,7 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
"/", "/",
response_model=AttachmentRead, response_model=AttachmentRead,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_member())], dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())],
) )
async def upload_attachment( async def upload_attachment(
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -281,7 +281,7 @@ async def global_delete_attachment(
@router.delete( @router.delete(
"/{attachment_id}", "/{attachment_id}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_member())], dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())],
) )
async def delete_attachment( async def delete_attachment(
study_id: uuid.UUID, study_id: uuid.UUID,
+18 -10
View File
@@ -3,7 +3,7 @@ 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_current_user, get_db_session, require_study_member, require_study_roles 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 audit as audit_crud
from app.crud import drug_shipment as shipment_crud from app.crud import drug_shipment as shipment_crud
from app.crud import site as site_crud from app.crud import site as site_crud
@@ -20,11 +20,20 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
return study return study
async def _ensure_center_active(db: AsyncSession, study_id: uuid.UUID, center_id: uuid.UUID | None):
if not center_id:
return None
site = await site_crud.get_site(db, center_id)
if not site or site.study_id != study_id or not site.is_active:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
return site
@router.post( @router.post(
"/shipments", "/shipments",
response_model=DrugShipmentRead, response_model=DrugShipmentRead,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))], dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
) )
async def create_shipment( async def create_shipment(
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -33,9 +42,7 @@ async def create_shipment(
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
) -> DrugShipmentRead: ) -> DrugShipmentRead:
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
site = await site_crud.get_site(db, shipment_in.center_id) site = await _ensure_center_active(db, study_id, shipment_in.center_id)
if not site or site.study_id != study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分中心不存在")
shipment_in = shipment_in.model_copy(update={"site_name": site.name}) shipment_in = shipment_in.model_copy(update={"site_name": site.name})
shipment = await shipment_crud.create_shipment(db, study_id, shipment_in, created_by=current_user.id) shipment = await shipment_crud.create_shipment(db, study_id, shipment_in, created_by=current_user.id)
await audit_crud.log_action( await audit_crud.log_action(
@@ -102,7 +109,7 @@ async def get_shipment(
@router.patch( @router.patch(
"/shipments/{shipment_id}", "/shipments/{shipment_id}",
response_model=DrugShipmentRead, response_model=DrugShipmentRead,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))], dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
) )
async def update_shipment( async def update_shipment(
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -116,10 +123,10 @@ async def update_shipment(
if not shipment or shipment.study_id != study_id: if not shipment or shipment.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="运输记录不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="运输记录不存在")
if shipment_in.center_id: if shipment_in.center_id:
site = await site_crud.get_site(db, shipment_in.center_id) site = await _ensure_center_active(db, study_id, shipment_in.center_id)
if not site or site.study_id != study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分中心不存在")
shipment_in = shipment_in.model_copy(update={"site_name": site.name}) shipment_in = shipment_in.model_copy(update={"site_name": site.name})
else:
await _ensure_center_active(db, study_id, shipment.center_id)
shipment = await shipment_crud.update_shipment(db, shipment, shipment_in) shipment = await shipment_crud.update_shipment(db, shipment, shipment_in)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -137,7 +144,7 @@ async def update_shipment(
@router.delete( @router.delete(
"/shipments/{shipment_id}", "/shipments/{shipment_id}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))], dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
) )
async def delete_shipment( async def delete_shipment(
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -149,6 +156,7 @@ async def delete_shipment(
shipment = await shipment_crud.get_shipment(db, shipment_id) shipment = await shipment_crud.get_shipment(db, shipment_id)
if not shipment or shipment.study_id != study_id: if not shipment or shipment.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 _ensure_center_active(db, study_id, shipment.center_id)
await shipment_crud.delete_shipment(db, shipment) await shipment_crud.delete_shipment(db, shipment)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
+4 -1
View File
@@ -3,7 +3,7 @@ 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_current_user, get_db_session from app.core.deps import get_current_user, get_db_session, require_study_not_locked
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import faq_category as category_crud from app.crud import faq_category as category_crud
from app.crud import faq_item as item_crud from app.crud import faq_item as item_crud
@@ -26,6 +26,7 @@ def _check_permission_for_scope(study_id: uuid.UUID, current_user, member_role:
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
summary="创建 FAQ 分类", summary="创建 FAQ 分类",
description="创建全局或项目内 FAQ 分类,项目 PM/ADMIN 可用,全局仅 ADMIN。", description="创建全局或项目内 FAQ 分类,项目 PM/ADMIN 可用,全局仅 ADMIN。",
dependencies=[Depends(require_study_not_locked())],
) )
async def create_category( async def create_category(
payload: CategoryCreate, payload: CategoryCreate,
@@ -84,6 +85,7 @@ async def list_categories(
response_model=CategoryRead, response_model=CategoryRead,
summary="更新 FAQ 分类", summary="更新 FAQ 分类",
description="更新分类名称或启停状态,项目 PM/ADMIN 或全局 ADMIN。", description="更新分类名称或启停状态,项目 PM/ADMIN 或全局 ADMIN。",
dependencies=[Depends(require_study_not_locked())],
) )
async def update_category( async def update_category(
category_id: uuid.UUID, category_id: uuid.UUID,
@@ -129,6 +131,7 @@ async def update_category(
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
summary="删除 FAQ 分类", summary="删除 FAQ 分类",
description="仅 ADMIN 可删除分类,分类下存在 FAQ 时不可删除。", description="仅 ADMIN 可删除分类,分类下存在 FAQ 时不可删除。",
dependencies=[Depends(require_study_not_locked())],
) )
async def delete_category( async def delete_category(
category_id: uuid.UUID, category_id: uuid.UUID,
+8 -1
View File
@@ -3,7 +3,7 @@ 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_current_user, get_db_session from app.core.deps import get_current_user, get_db_session, require_study_not_locked
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import faq_category as category_crud from app.crud import faq_category as category_crud
from app.crud import faq_item as faq_crud from app.crud import faq_item as faq_crud
@@ -41,6 +41,7 @@ def _check_create_permission(current_user, is_member: bool):
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
summary="创建 FAQ", summary="创建 FAQ",
description="创建全局或项目内 FAQ,项目 FAQ 需项目 PM/ADMIN 权限。", description="创建全局或项目内 FAQ,项目 FAQ 需项目 PM/ADMIN 权限。",
dependencies=[Depends(require_study_not_locked())],
) )
async def create_faq( async def create_faq(
payload: FaqCreate, payload: FaqCreate,
@@ -178,6 +179,7 @@ async def get_faq(
response_model=FaqRead, response_model=FaqRead,
summary="更新 FAQ", summary="更新 FAQ",
description="更新 FAQ 内容或启停状态,项目内需 PM/ADMIN 权限。", description="更新 FAQ 内容或启停状态,项目内需 PM/ADMIN 权限。",
dependencies=[Depends(require_study_not_locked())],
) )
async def update_faq( async def update_faq(
item_id: uuid.UUID, item_id: uuid.UUID,
@@ -219,6 +221,7 @@ async def update_faq(
response_model=FaqRead, response_model=FaqRead,
summary="更新 FAQ 状态", summary="更新 FAQ 状态",
description="提问者或项目 PM/ADMIN 可确认已解决。", description="提问者或项目 PM/ADMIN 可确认已解决。",
dependencies=[Depends(require_study_not_locked())],
) )
async def update_faq_status( async def update_faq_status(
item_id: uuid.UUID, item_id: uuid.UUID,
@@ -248,6 +251,7 @@ async def update_faq_status(
response_model=FaqRead, response_model=FaqRead,
summary="设置最佳回复", summary="设置最佳回复",
description="项目成员可设置最佳回复,全局仅 ADMIN。", description="项目成员可设置最佳回复,全局仅 ADMIN。",
dependencies=[Depends(require_study_not_locked())],
) )
async def set_best_reply( async def set_best_reply(
item_id: uuid.UUID, item_id: uuid.UUID,
@@ -330,6 +334,7 @@ async def list_replies(
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
summary="创建 FAQ 回复", summary="创建 FAQ 回复",
description="回复 FAQ,项目内成员可回复,全局仅 ADMIN。", description="回复 FAQ,项目内成员可回复,全局仅 ADMIN。",
dependencies=[Depends(require_study_not_locked())],
) )
async def create_reply( async def create_reply(
item_id: uuid.UUID, item_id: uuid.UUID,
@@ -392,6 +397,7 @@ async def create_reply(
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
summary="删除 FAQ", summary="删除 FAQ",
description="删除 FAQ,项目内需 PM/ADMIN 权限,全局仅 ADMIN。", description="删除 FAQ,项目内需 PM/ADMIN 权限,全局仅 ADMIN。",
dependencies=[Depends(require_study_not_locked())],
) )
async def delete_faq( async def delete_faq(
item_id: uuid.UUID, item_id: uuid.UUID,
@@ -427,6 +433,7 @@ async def delete_faq(
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
summary="删除 FAQ 回复", summary="删除 FAQ 回复",
description="删除 FAQ 回复,管理员、项目 PM 或回复者可删除。", description="删除 FAQ 回复,管理员、项目 PM 或回复者可删除。",
dependencies=[Depends(require_study_not_locked())],
) )
async def delete_reply( async def delete_reply(
item_id: uuid.UUID, item_id: uuid.UUID,
+3 -3
View File
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status,
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session from app.core.deps import get_current_user, get_db_session, require_study_not_locked
from app.core.security import decode_token from app.core.security import decode_token
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import contract_fee as contract_fee_crud from app.crud import contract_fee as contract_fee_crud
@@ -86,7 +86,7 @@ async def _authorize_download_user(request: Request, db: AsyncSession):
"/attachments", "/attachments",
response_model=FeeApiResponse[FeeAttachmentRead], response_model=FeeApiResponse[FeeAttachmentRead],
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(get_current_user)], dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
) )
async def upload_fee_attachment( async def upload_fee_attachment(
entity_type: str = Form(...), entity_type: str = Form(...),
@@ -220,7 +220,7 @@ async def download_fee_attachment(
@router.delete( @router.delete(
"/attachments/{attachment_id}", "/attachments/{attachment_id}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(get_current_user)], dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
) )
async def delete_fee_attachment( async def delete_fee_attachment(
attachment_id: uuid.UUID, attachment_id: uuid.UUID,
+19 -9
View File
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from app.core.deps import get_current_user, get_db_session from app.core.deps import get_current_user, get_db_session, require_study_not_locked
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import contract_fee as contract_fee_crud from app.crud import contract_fee as contract_fee_crud
from app.crud import contract_fee_payment as payment_crud from app.crud import contract_fee_payment as payment_crud
@@ -43,6 +43,14 @@ async def _ensure_project_access(db: AsyncSession, project_id: uuid.UUID, curren
return membership return membership
async def _ensure_center_active(db: AsyncSession, project_id: uuid.UUID, center_id: uuid.UUID | None):
if not center_id:
return
site = await site_crud.get_site(db, center_id)
if not site or site.study_id != project_id or not site.is_active:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
def _to_decimal(value: Any) -> Decimal: def _to_decimal(value: Any) -> Decimal:
if isinstance(value, Decimal): if isinstance(value, Decimal):
return value return value
@@ -186,7 +194,7 @@ async def get_contract_fee(
"/contracts", "/contracts",
response_model=FeeApiResponse[ContractFeeRead], response_model=FeeApiResponse[ContractFeeRead],
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(get_current_user)], dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
) )
async def create_contract_fee( async def create_contract_fee(
contract_in: ContractFeeCreate, contract_in: ContractFeeCreate,
@@ -201,8 +209,8 @@ async def create_contract_fee(
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该中心已存在合同费用") raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该中心已存在合同费用")
site = await site_crud.get_site(db, contract_in.center_id) site = await site_crud.get_site(db, contract_in.center_id)
if not site or site.study_id != contract_in.project_id: if not site or site.study_id != contract_in.project_id or not site.is_active:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或不属于该项目") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或已停用")
contract = await contract_fee_crud.create_contract_fee(db, contract_in) contract = await contract_fee_crud.create_contract_fee(db, contract_in)
await audit_crud.log_action( await audit_crud.log_action(
@@ -221,7 +229,7 @@ async def create_contract_fee(
@router.patch( @router.patch(
"/contracts/{contract_id}", "/contracts/{contract_id}",
response_model=FeeApiResponse[ContractFeeRead], response_model=FeeApiResponse[ContractFeeRead],
dependencies=[Depends(get_current_user)], dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
) )
async def update_contract_fee( async def update_contract_fee(
contract_id: uuid.UUID, contract_id: uuid.UUID,
@@ -233,6 +241,7 @@ async def update_contract_fee(
if not contract: if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
await _ensure_project_access(db, contract.project_id, current_user, write=True) await _ensure_project_access(db, contract.project_id, current_user, write=True)
await _ensure_center_active(db, contract.project_id, contract.center_id)
contract = await contract_fee_crud.update_contract_fee(db, contract, contract_in) contract = await contract_fee_crud.update_contract_fee(db, contract, contract_in)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -250,7 +259,7 @@ async def update_contract_fee(
@router.delete( @router.delete(
"/contracts/{contract_id}", "/contracts/{contract_id}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(get_current_user)], dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
) )
async def delete_contract_fee( async def delete_contract_fee(
contract_id: uuid.UUID, contract_id: uuid.UUID,
@@ -261,6 +270,7 @@ async def delete_contract_fee(
if not contract: if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
await _ensure_project_access(db, contract.project_id, current_user, write=True) await _ensure_project_access(db, contract.project_id, current_user, write=True)
await _ensure_center_active(db, contract.project_id, contract.center_id)
await contract_fee_crud.delete_contract_fee(db, contract) await contract_fee_crud.delete_contract_fee(db, contract)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -278,7 +288,7 @@ async def delete_contract_fee(
"/contracts/{contract_id}/payments", "/contracts/{contract_id}/payments",
response_model=FeeApiResponse[ContractFeePaymentRead], response_model=FeeApiResponse[ContractFeePaymentRead],
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(get_current_user)], dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
) )
async def create_contract_payment( async def create_contract_payment(
contract_id: uuid.UUID, contract_id: uuid.UUID,
@@ -308,7 +318,7 @@ async def create_contract_payment(
@router.patch( @router.patch(
"/payments/{payment_id}", "/payments/{payment_id}",
response_model=FeeApiResponse[ContractFeePaymentRead], response_model=FeeApiResponse[ContractFeePaymentRead],
dependencies=[Depends(get_current_user)], dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
) )
async def update_contract_payment( async def update_contract_payment(
payment_id: uuid.UUID, payment_id: uuid.UUID,
@@ -349,7 +359,7 @@ async def update_contract_payment(
@router.delete( @router.delete(
"/payments/{payment_id}", "/payments/{payment_id}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(get_current_user)], dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
) )
async def delete_contract_payment( async def delete_contract_payment(
payment_id: uuid.UUID, payment_id: uuid.UUID,
+21 -8
View File
@@ -4,7 +4,7 @@ from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session from app.core.deps import get_current_user, get_db_session, require_study_not_locked
from app.crud import audit as audit_crud 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 special_expense as special_crud from app.crud import special_expense as special_crud
@@ -52,6 +52,14 @@ def _validate_category(value: str | None):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="费用类别无效") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="费用类别无效")
async def _ensure_center_active(db: AsyncSession, project_id: uuid.UUID, center_id: uuid.UUID | None):
if not center_id:
return
site = await site_crud.get_site(db, center_id)
if not site or site.study_id != project_id or not site.is_active:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
@router.get( @router.get(
"/special", "/special",
response_model=FeeApiResponse[list[SpecialExpenseListItem]], response_model=FeeApiResponse[list[SpecialExpenseListItem]],
@@ -122,7 +130,7 @@ async def get_special_expense(
"/special", "/special",
response_model=FeeApiResponse[SpecialExpenseRead], response_model=FeeApiResponse[SpecialExpenseRead],
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(get_current_user)], dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
) )
async def create_special_expense( async def create_special_expense(
expense_in: SpecialExpenseCreate, expense_in: SpecialExpenseCreate,
@@ -134,8 +142,9 @@ async def create_special_expense(
_validate_special_rules(expense_in) _validate_special_rules(expense_in)
if expense_in.center_id: if expense_in.center_id:
site = await site_crud.get_site(db, expense_in.center_id) site = await site_crud.get_site(db, expense_in.center_id)
if not site or site.study_id != expense_in.project_id: if not site or site.study_id != expense_in.project_id or not site.is_active:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或不属于该项目") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或已停用")
await _ensure_center_active(db, expense_in.project_id, expense_in.center_id)
expense = await special_crud.create_special_expense(db, expense_in, created_by=current_user.id) expense = await special_crud.create_special_expense(db, expense_in, created_by=current_user.id)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -153,7 +162,7 @@ async def create_special_expense(
@router.patch( @router.patch(
"/special/{expense_id}", "/special/{expense_id}",
response_model=FeeApiResponse[SpecialExpenseRead], response_model=FeeApiResponse[SpecialExpenseRead],
dependencies=[Depends(get_current_user)], dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
) )
async def update_special_expense( async def update_special_expense(
expense_id: uuid.UUID, expense_id: uuid.UUID,
@@ -181,8 +190,11 @@ async def update_special_expense(
_validate_special_rules(merged) _validate_special_rules(merged)
if expense_in.center_id: if expense_in.center_id:
site = await site_crud.get_site(db, expense_in.center_id) site = await site_crud.get_site(db, expense_in.center_id)
if not site or site.study_id != expense.project_id: if not site or site.study_id != expense.project_id or not site.is_active:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或不属于该项目") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或已停用")
await _ensure_center_active(db, expense.project_id, expense_in.center_id)
else:
await _ensure_center_active(db, expense.project_id, expense.center_id)
expense = await special_crud.update_special_expense(db, expense, expense_in) expense = await special_crud.update_special_expense(db, expense, expense_in)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -200,7 +212,7 @@ async def update_special_expense(
@router.delete( @router.delete(
"/special/{expense_id}", "/special/{expense_id}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(get_current_user)], dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
) )
async def delete_special_expense( async def delete_special_expense(
expense_id: uuid.UUID, expense_id: uuid.UUID,
@@ -211,6 +223,7 @@ async def delete_special_expense(
if not expense: if not expense:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
await _ensure_project_access(db, expense.project_id, current_user, write=True) await _ensure_project_access(db, expense.project_id, current_user, write=True)
await _ensure_center_active(db, expense.project_id, expense.center_id)
await special_crud.delete_special_expense(db, expense) await special_crud.delete_special_expense(db, expense)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
+13 -1
View File
@@ -3,9 +3,10 @@ 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_current_user, get_db_session, require_study_member, require_study_roles 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 audit as audit_crud
from app.crud import finance_contract as contract_crud from app.crud import finance_contract as contract_crud
from app.crud import site as site_crud
from app.crud import study as study_crud from app.crud import study as study_crud
from app.schemas.finance_contract import FinanceContractCreate, FinanceContractRead, FinanceContractUpdate from app.schemas.finance_contract import FinanceContractCreate, FinanceContractRead, FinanceContractUpdate
@@ -19,6 +20,14 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
return study return study
async def _ensure_site_name_active(db: AsyncSession, study_id: uuid.UUID, site_name: str | None):
if not site_name:
return
active_names = await site_crud.list_active_names(db, study_id)
if site_name not in active_names:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
@router.post( @router.post(
"/contracts", "/contracts",
response_model=FinanceContractRead, response_model=FinanceContractRead,
@@ -32,6 +41,7 @@ async def create_contract(
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
) -> FinanceContractRead: ) -> FinanceContractRead:
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
await _ensure_site_name_active(db, study_id, contract_in.site_name)
contract = await contract_crud.create_contract(db, study_id, contract_in, created_by=current_user.id) contract = await contract_crud.create_contract(db, study_id, contract_in, created_by=current_user.id)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -97,6 +107,7 @@ async def update_contract(
contract = await contract_crud.get_contract(db, contract_id) contract = await contract_crud.get_contract(db, contract_id)
if not contract or contract.study_id != study_id: if not contract or contract.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 _ensure_site_name_active(db, study_id, contract.site_name)
contract = await contract_crud.update_contract(db, contract, contract_in) contract = await contract_crud.update_contract(db, contract, contract_in)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -126,6 +137,7 @@ async def delete_contract(
contract = await contract_crud.get_contract(db, contract_id) contract = await contract_crud.get_contract(db, contract_id)
if not contract or contract.study_id != study_id: if not contract or contract.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 _ensure_site_name_active(db, study_id, contract.site_name)
await contract_crud.delete_contract(db, contract) await contract_crud.delete_contract(db, contract)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
+13 -1
View File
@@ -3,9 +3,10 @@ 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_current_user, get_db_session, require_study_member, require_study_roles 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 audit as audit_crud
from app.crud import finance_special as special_crud from app.crud import finance_special as special_crud
from app.crud import site as site_crud
from app.crud import study as study_crud from app.crud import study as study_crud
from app.schemas.finance_special import FinanceSpecialCreate, FinanceSpecialRead, FinanceSpecialUpdate from app.schemas.finance_special import FinanceSpecialCreate, FinanceSpecialRead, FinanceSpecialUpdate
@@ -19,6 +20,14 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
return study return study
async def _ensure_site_name_active(db: AsyncSession, study_id: uuid.UUID, site_name: str | None):
if not site_name:
return
active_names = await site_crud.list_active_names(db, study_id)
if site_name not in active_names:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
@router.post( @router.post(
"/specials", "/specials",
response_model=FinanceSpecialRead, response_model=FinanceSpecialRead,
@@ -32,6 +41,7 @@ async def create_special(
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
) -> FinanceSpecialRead: ) -> FinanceSpecialRead:
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
await _ensure_site_name_active(db, study_id, special_in.site_name)
item = await special_crud.create_special(db, study_id, special_in, created_by=current_user.id) item = await special_crud.create_special(db, study_id, special_in, created_by=current_user.id)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -97,6 +107,7 @@ async def update_special(
item = await special_crud.get_special(db, special_id) item = await special_crud.get_special(db, special_id)
if not item or item.study_id != study_id: if not item or item.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 _ensure_site_name_active(db, study_id, item.site_name)
item = await special_crud.update_special(db, item, special_in) item = await special_crud.update_special(db, item, special_in)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -126,6 +137,7 @@ async def delete_special(
item = await special_crud.get_special(db, special_id) item = await special_crud.get_special(db, special_id)
if not item or item.study_id != study_id: if not item or item.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 _ensure_site_name_active(db, study_id, item.site_name)
await special_crud.delete_special(db, item) await special_crud.delete_special(db, item)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
+13 -1
View File
@@ -3,9 +3,10 @@ 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_current_user, get_db_session, require_study_member, require_study_roles 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 audit as audit_crud
from app.crud import knowledge_note as note_crud from app.crud import knowledge_note as note_crud
from app.crud import site as site_crud
from app.crud import study as study_crud from app.crud import study as study_crud
from app.schemas.knowledge_note import KnowledgeNoteCreate, KnowledgeNoteRead, KnowledgeNoteUpdate from app.schemas.knowledge_note import KnowledgeNoteCreate, KnowledgeNoteRead, KnowledgeNoteUpdate
@@ -19,6 +20,14 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
return study return study
async def _ensure_site_name_active(db: AsyncSession, study_id: uuid.UUID, site_name: str | None):
if not site_name:
return
active_names = await site_crud.list_active_names(db, study_id)
if site_name not in active_names:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
@router.post( @router.post(
"/notes", "/notes",
response_model=KnowledgeNoteRead, response_model=KnowledgeNoteRead,
@@ -32,6 +41,7 @@ async def create_note(
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
) -> KnowledgeNoteRead: ) -> KnowledgeNoteRead:
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
await _ensure_site_name_active(db, study_id, note_in.site_name)
note = await note_crud.create_note(db, study_id, note_in, created_by=current_user.id) note = await note_crud.create_note(db, study_id, note_in, created_by=current_user.id)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -97,6 +107,7 @@ async def update_note(
note = await note_crud.get_note(db, note_id) note = await note_crud.get_note(db, note_id)
if not note or note.study_id != study_id: if not note or note.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 _ensure_site_name_active(db, study_id, note.site_name)
note = await note_crud.update_note(db, note, note_in) note = await note_crud.update_note(db, note, note_in)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -126,6 +137,7 @@ async def delete_note(
note = await note_crud.get_note(db, note_id) note = await note_crud.get_note(db, note_id)
if not note or note.study_id != study_id: if not note or note.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 _ensure_site_name_active(db, study_id, note.site_name)
await note_crud.delete_note(db, note) await note_crud.delete_note(db, note)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
+18 -9
View File
@@ -3,7 +3,7 @@ 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_study_member, require_study_roles from app.core.deps import get_db_session, require_study_member, require_study_roles, require_study_not_locked
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
@@ -24,7 +24,7 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
"/", "/",
response_model=StudyMemberRead, response_model=StudyMemberRead,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM"]))], dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
) )
async def add_member( async def add_member(
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -34,6 +34,13 @@ async def add_member(
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)
if existing: if existing:
if not existing.is_active:
updated = await member_crud.update_member(
db,
existing,
StudyMemberUpdate(is_active=True, role_in_study=member_in.role_in_study),
)
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)
return member return member
@@ -48,6 +55,7 @@ async def list_members(
study_id: uuid.UUID, study_id: uuid.UUID,
skip: int = 0, skip: int = 0,
limit: int = 100, limit: int = 100,
include_inactive: bool = False,
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
) -> list[StudyMemberReadWithUser]: ) -> list[StudyMemberReadWithUser]:
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
@@ -56,12 +64,13 @@ async def list_members(
users_map = await user_crud.get_users_by_ids(db, user_ids) users_map = await user_crud.get_users_by_ids(db, user_ids)
result: list[StudyMemberReadWithUser] = [] result: list[StudyMemberReadWithUser] = []
for m in members: for m in members:
# 仅返回项目内启用的成员 + 账号启用的用户
if not m.is_active:
continue
user = users_map.get(m.user_id) user = users_map.get(m.user_id)
if not user or not user.is_active: if not include_inactive:
continue # 仅返回项目内启用的成员 + 账号启用的用户
if not m.is_active:
continue
if not user or not user.is_active:
continue
result.append( result.append(
StudyMemberReadWithUser( StudyMemberReadWithUser(
id=m.id, id=m.id,
@@ -79,7 +88,7 @@ async def list_members(
@router.patch( @router.patch(
"/{member_id}", "/{member_id}",
response_model=StudyMemberRead, response_model=StudyMemberRead,
dependencies=[Depends(require_study_roles(["PM"]))], dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
) )
async def update_member( async def update_member(
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -98,7 +107,7 @@ async def update_member(
@router.delete( @router.delete(
"/{member_id}", "/{member_id}",
response_model=StudyMemberRead, response_model=StudyMemberRead,
dependencies=[Depends(require_study_roles(["PM"]))], dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
) )
async def remove_member( async def remove_member(
study_id: uuid.UUID, study_id: uuid.UUID,
+48 -4
View File
@@ -3,10 +3,12 @@ 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_study_member, require_study_roles 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 site as site_crud from app.crud import site as site_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
from app.schemas.site import SiteCreate, SiteRead, SiteUpdate from app.schemas.site import SiteCreate, SiteRead, SiteUpdate
from app.schemas.startup import StartupEthicsCreate, StartupFeasibilityCreate
router = APIRouter() router = APIRouter()
@@ -22,15 +24,28 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
"/", "/",
response_model=SiteRead, response_model=SiteRead,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM"]))], dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
) )
async def create_site( async def create_site(
study_id: uuid.UUID, study_id: uuid.UUID,
site_in: SiteCreate, site_in: SiteCreate,
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.create_site(db, study_id, site_in) site = await site_crud.create_site(db, study_id, site_in)
await startup_crud.create_feasibility(
db,
study_id,
StartupFeasibilityCreate(site_id=site.id),
created_by=getattr(current_user, "id", None),
)
await startup_crud.create_ethics(
db,
study_id,
StartupEthicsCreate(site_id=site.id),
created_by=getattr(current_user, "id", None),
)
return site return site
@@ -43,17 +58,24 @@ async def list_sites(
study_id: uuid.UUID, study_id: uuid.UUID,
skip: int = 0, skip: int = 0,
limit: int = 100, limit: int = 100,
include_inactive: bool = False,
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
) -> list[SiteRead]: ) -> list[SiteRead]:
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
sites = await site_crud.list_by_study(db, study_id, skip=skip, limit=limit) sites = await site_crud.list_by_study(
db,
study_id,
skip=skip,
limit=limit,
include_inactive=include_inactive,
)
return list(sites) return list(sites)
@router.patch( @router.patch(
"/{site_id}", "/{site_id}",
response_model=SiteRead, response_model=SiteRead,
dependencies=[Depends(require_study_roles(["PM"]))], dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
) )
async def update_site( async def update_site(
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -67,3 +89,25 @@ async def update_site(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分中心不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分中心不存在")
updated = await site_crud.update_site(db, site, site_in) updated = await site_crud.update_site(db, site, site_in)
return updated return updated
@router.delete(
"/{site_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_not_locked())],
)
async def delete_site(
study_id: uuid.UUID,
site_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> None:
await _ensure_study_exists(db, study_id)
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value != "ADMIN":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅管理员可删除分中心")
site = await site_crud.get_site(db, site_id)
if not site or site.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分中心不存在")
await site_crud.delete_site_and_related(db, site)
return None
+29 -1
View File
@@ -3,8 +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_current_user, get_db_session, require_study_member, require_study_roles 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 audit as audit_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
from app.schemas.startup import ( from app.schemas.startup import (
@@ -32,6 +33,22 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
return study return study
async def _ensure_site_name_active(db: AsyncSession, study_id: uuid.UUID, site_name: str | None):
if not site_name:
return
active_names = await site_crud.list_active_names(db, study_id)
if site_name not in active_names:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
async def _ensure_site_active(db: AsyncSession, site_id: uuid.UUID | None):
if not site_id:
return
site = await site_crud.get_site(db, site_id)
if not site or not site.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="中心已停用")
@router.post( @router.post(
"/feasibility", "/feasibility",
response_model=StartupFeasibilityRead, response_model=StartupFeasibilityRead,
@@ -45,6 +62,7 @@ async def create_feasibility(
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
) -> StartupFeasibilityRead: ) -> StartupFeasibilityRead:
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
await _ensure_site_active(db, record_in.site_id)
record = await startup_crud.create_feasibility(db, study_id, record_in, created_by=current_user.id) record = await startup_crud.create_feasibility(db, study_id, record_in, created_by=current_user.id)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -108,6 +126,7 @@ async def update_feasibility(
record = await startup_crud.get_feasibility(db, record_id) record = await startup_crud.get_feasibility(db, record_id)
if not record or record.study_id != study_id: if not record or record.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 _ensure_site_active(db, record.site_id)
record = await startup_crud.update_feasibility(db, record, record_in) record = await startup_crud.update_feasibility(db, record, record_in)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -137,6 +156,7 @@ async def delete_feasibility(
record = await startup_crud.get_feasibility(db, record_id) record = await startup_crud.get_feasibility(db, record_id)
if not record or record.study_id != study_id: if not record or record.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 _ensure_site_active(db, record.site_id)
await startup_crud.delete_feasibility(db, record) await startup_crud.delete_feasibility(db, record)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -163,6 +183,7 @@ async def create_ethics(
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
) -> StartupEthicsRead: ) -> StartupEthicsRead:
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
await _ensure_site_active(db, record_in.site_id)
record = await startup_crud.create_ethics(db, study_id, record_in, created_by=current_user.id) record = await startup_crud.create_ethics(db, study_id, record_in, created_by=current_user.id)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -226,6 +247,7 @@ async def update_ethics(
record = await startup_crud.get_ethics(db, record_id) record = await startup_crud.get_ethics(db, record_id)
if not record or record.study_id != study_id: if not record or record.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 _ensure_site_active(db, record.site_id)
record = await startup_crud.update_ethics(db, record, record_in) record = await startup_crud.update_ethics(db, record, record_in)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -255,6 +277,7 @@ async def delete_ethics(
record = await startup_crud.get_ethics(db, record_id) record = await startup_crud.get_ethics(db, record_id)
if not record or record.study_id != study_id: if not record or record.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 _ensure_site_active(db, record.site_id)
await startup_crud.delete_ethics(db, record) await startup_crud.delete_ethics(db, record)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -281,6 +304,7 @@ async def create_kickoff(
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
) -> KickoffMeetingRead: ) -> KickoffMeetingRead:
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
await _ensure_site_active(db, meeting_in.site_id)
meeting = await startup_crud.create_kickoff(db, study_id, meeting_in, created_by=current_user.id) meeting = await startup_crud.create_kickoff(db, study_id, meeting_in, created_by=current_user.id)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -344,6 +368,7 @@ async def update_kickoff(
meeting = await startup_crud.get_kickoff(db, meeting_id) meeting = await startup_crud.get_kickoff(db, meeting_id)
if not meeting or meeting.study_id != study_id: if not meeting or meeting.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 _ensure_site_active(db, meeting.site_id)
meeting = await startup_crud.update_kickoff(db, meeting, meeting_in) meeting = await startup_crud.update_kickoff(db, meeting, meeting_in)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -371,6 +396,7 @@ async def create_training_authorization(
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
) -> TrainingAuthorizationRead: ) -> TrainingAuthorizationRead:
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
await _ensure_site_name_active(db, study_id, record_in.site_name)
record = await startup_crud.create_training_authorization(db, study_id, record_in, created_by=current_user.id) record = await startup_crud.create_training_authorization(db, study_id, record_in, created_by=current_user.id)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -434,6 +460,7 @@ async def update_training_authorization(
record = await startup_crud.get_training_authorization(db, record_id) record = await startup_crud.get_training_authorization(db, record_id)
if not record or record.study_id != study_id: if not record or record.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 _ensure_site_name_active(db, study_id, record.site_name)
record = await startup_crud.update_training_authorization(db, record, record_in) record = await startup_crud.update_training_authorization(db, record, record_in)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -463,6 +490,7 @@ async def delete_training_authorization(
record = await startup_crud.get_training_authorization(db, record_id) record = await startup_crud.get_training_authorization(db, record_id)
if not record or record.study_id != study_id: if not record or record.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 _ensure_site_name_active(db, study_id, record.site_name)
await startup_crud.delete_training_authorization(db, record) await startup_crud.delete_training_authorization(db, record)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
+103
View File
@@ -5,8 +5,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_roles, require_study_member, require_study_roles from app.core.deps import get_current_user, get_db_session, require_roles, require_study_member, require_study_roles
from app.crud import study as study_crud from app.crud import study as study_crud
from app.crud import member as member_crud
from app.crud import audit as audit_crud
from app.schemas.common import PaginatedResponse 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.utils.pagination import paginate from app.utils.pagination import paginate
router = APIRouter() router = APIRouter()
@@ -22,6 +25,15 @@ async def create_study(
if existing: if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="项目编号已存在") raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="项目编号已存在")
study = await study_crud.create(db, study_in, created_by=current_user.id) study = await study_crud.create(db, study_in, created_by=current_user.id)
# 自动将创建者添加为项目成员,角色为PM
member_in = StudyMemberCreate(
user_id=current_user.id,
role_in_study="PM",
is_active=True,
)
await member_crud.add_member(db, study.id, member_in)
return study return study
@@ -65,5 +77,96 @@ async def update_study(
study = await study_crud.get(db, study_id) study = await study_crud.get(db, study_id)
if not study: if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
# 检查项目是否已锁定(除非是解锁操作)
if study.is_locked and not (study_in.is_locked is False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="项目已锁定,无法编辑。请先解锁项目。"
)
updated = await study_crud.update(db, study, study_in) updated = await study_crud.update(db, study, study_in)
return updated return updated
@router.delete(
"/{study_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_roles(["ADMIN"]))],
)
async def delete_study(
study_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> None:
"""硬删除项目及其所有关联数据(不可逆操作)"""
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
# 删除项目及其所有关联数据(包括审计日志)
await study_crud.delete(db, study_id)
@router.patch(
"/{study_id}/lock",
response_model=StudyRead,
dependencies=[Depends(require_roles(["ADMIN"]))],
)
async def lock_study(
study_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> StudyRead:
"""锁定项目(仅管理员)"""
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
locked_study = await study_crud.lock(db, study_id)
# 记录审计日志
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="study",
entity_id=study_id,
action="LOCK_STUDY",
detail=f"项目已锁定:{study.name} ({study.code})",
operator_id=current_user.id,
operator_role=current_user.role,
)
return locked_study
@router.patch(
"/{study_id}/unlock",
response_model=StudyRead,
dependencies=[Depends(require_roles(["ADMIN"]))],
)
async def unlock_study(
study_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> StudyRead:
"""解锁项目(仅管理员)"""
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
unlocked_study = await study_crud.unlock(db, study_id)
# 记录审计日志
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="study",
entity_id=study_id,
action="UNLOCK_STUDY",
detail=f"项目已解锁:{study.name} ({study.code})",
operator_id=current_user.id,
operator_role=current_user.role,
)
return unlocked_study
+27 -1
View File
@@ -3,8 +3,10 @@ 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_current_user, get_db_session, require_study_member, require_study_roles 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 audit as audit_crud
from app.crud import site as site_crud
from app.crud import subject as subject_crud
from app.crud import subject_history as history_crud from app.crud import subject_history as history_crud
from app.crud import study as study_crud from app.crud import study as study_crud
from app.schemas.subject_history import SubjectHistoryCreate, SubjectHistoryRead, SubjectHistoryUpdate from app.schemas.subject_history import SubjectHistoryCreate, SubjectHistoryRead, SubjectHistoryUpdate
@@ -19,6 +21,14 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
return study return study
async def _ensure_subject_active(db: AsyncSession, subject) -> None:
if not subject or not subject.site_id:
return
site = await site_crud.get_site(db, subject.site_id)
if not site or not site.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="中心已停用")
@router.post( @router.post(
"/histories", "/histories",
response_model=SubjectHistoryRead, response_model=SubjectHistoryRead,
@@ -33,6 +43,10 @@ async def create_history(
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
) -> SubjectHistoryRead: ) -> SubjectHistoryRead:
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
subject = await subject_crud.get_subject(db, subject_id)
if not subject or subject.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="参与者不存在")
await _ensure_subject_active(db, subject)
if history_in.subject_id != subject_id: if history_in.subject_id != subject_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="参与者不匹配") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="参与者不匹配")
history = await history_crud.create_history(db, study_id, history_in, created_by=current_user.id) history = await history_crud.create_history(db, study_id, history_in, created_by=current_user.id)
@@ -62,6 +76,9 @@ async def list_histories(
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
) -> list[SubjectHistoryRead]: ) -> list[SubjectHistoryRead]:
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
subject = await subject_crud.get_subject(db, subject_id)
if not subject or subject.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="参与者不存在")
items = await history_crud.list_histories(db, study_id, subject_id, skip=skip, limit=limit) items = await history_crud.list_histories(db, study_id, subject_id, skip=skip, limit=limit)
return [SubjectHistoryRead.model_validate(item) for item in items] return [SubjectHistoryRead.model_validate(item) for item in items]
@@ -78,6 +95,9 @@ async def get_history(
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
) -> SubjectHistoryRead: ) -> SubjectHistoryRead:
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
subject = await subject_crud.get_subject(db, subject_id)
if not subject or subject.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="参与者不存在")
history = await history_crud.get_history(db, history_id) history = await history_crud.get_history(db, history_id)
if not history or history.study_id != study_id or history.subject_id != subject_id: if not history or history.study_id != study_id or history.subject_id != subject_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="病史记录不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="病史记录不存在")
@@ -98,6 +118,9 @@ async def update_history(
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
) -> SubjectHistoryRead: ) -> SubjectHistoryRead:
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
subject = await subject_crud.get_subject(db, subject_id)
if not subject or subject.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="参与者不存在")
history = await history_crud.get_history(db, history_id) history = await history_crud.get_history(db, history_id)
if not history or history.study_id != study_id or history.subject_id != subject_id: if not history or history.study_id != study_id or history.subject_id != subject_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="病史记录不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="病史记录不存在")
@@ -128,6 +151,9 @@ async def delete_history(
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
) -> None: ) -> None:
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
subject = await subject_crud.get_subject(db, subject_id)
if not subject or subject.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="参与者不存在")
history = await history_crud.get_history(db, history_id) history = await history_crud.get_history(db, history_id)
if not history or history.study_id != study_id or history.subject_id != subject_id: if not history or history.study_id != study_id or history.subject_id != subject_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="病史记录不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="病史记录不存在")
+16 -9
View File
@@ -3,8 +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_current_user, get_db_session, require_study_member, require_study_roles 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 audit as audit_crud
from app.crud import site as site_crud
from app.crud import subject as subject_crud from app.crud import subject as subject_crud
from app.crud import study as study_crud from app.crud import study as study_crud
from app.schemas.subject import SubjectCreate, SubjectRead, SubjectUpdate from app.schemas.subject import SubjectCreate, SubjectRead, SubjectUpdate
@@ -19,11 +20,19 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
return study return study
async def _ensure_subject_active(db: AsyncSession, subject) -> None:
if not subject or not subject.site_id:
return
site = await site_crud.get_site(db, subject.site_id)
if not site or not site.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="中心已停用")
@router.post( @router.post(
"/", "/",
response_model=SubjectRead, response_model=SubjectRead,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))], dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
) )
async def create_subject( async def create_subject(
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -57,14 +66,10 @@ async def create_subject(
async def list_subjects( async def list_subjects(
study_id: uuid.UUID, study_id: uuid.UUID,
site_id: uuid.UUID | None = None, site_id: uuid.UUID | None = None,
status_filter: str | None = None,
subject_no: str | None = None,
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
) -> list[SubjectRead]: ) -> list[SubjectRead]:
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
subjects = await subject_crud.list_subjects( subjects = await subject_crud.list_subjects(db, study_id, site_id=site_id)
db, study_id, site_id=site_id, status=status_filter, subject_no=subject_no
)
return list(subjects) return list(subjects)
@@ -82,13 +87,14 @@ async def get_subject(
subject = await subject_crud.get_subject(db, subject_id) subject = await subject_crud.get_subject(db, subject_id)
if not subject or subject.study_id != study_id: if not subject or subject.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 _ensure_subject_active(db, subject)
return subject return subject
@router.patch( @router.patch(
"/{subject_id}", "/{subject_id}",
response_model=SubjectRead, response_model=SubjectRead,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))], dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
) )
async def update_subject( async def update_subject(
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -101,6 +107,7 @@ async def update_subject(
subject = await subject_crud.get_subject(db, subject_id) subject = await subject_crud.get_subject(db, subject_id)
if not subject or subject.study_id != study_id: if not subject or subject.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 _ensure_subject_active(db, subject)
old_status = subject.status old_status = subject.status
updated = await subject_crud.update_subject(db, subject, subject_in) updated = await subject_crud.update_subject(db, subject, subject_in)
# auto-generate visits when enrolled # auto-generate visits when enrolled
@@ -125,7 +132,7 @@ async def update_subject(
@router.delete( @router.delete(
"/{subject_id}", "/{subject_id}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))], dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
) )
async def delete_subject( async def delete_subject(
study_id: uuid.UUID, study_id: uuid.UUID,
-5
View File
@@ -4,7 +4,6 @@ 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 from app.core.deps import get_db_session, require_roles
from app.core.security import verify_password
from app.schemas.common import PaginatedResponse from app.schemas.common import PaginatedResponse
from app.crud import user as user_crud from app.crud import user as user_crud
from app.crud import member as member_crud from app.crud import member as member_crud
@@ -70,13 +69,9 @@ async def update_user(
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user( async def delete_user(
user_id: uuid.UUID, user_id: uuid.UUID,
payload: dict,
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles(["ADMIN"])), current_user=Depends(require_roles(["ADMIN"])),
): ):
admin_password = payload.get("admin_password")
if not admin_password or not verify_password(admin_password, current_user.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="管理员密码错误")
db_user = await user_crud.get_by_id(db, user_id) db_user = await user_crud.get_by_id(db, user_id)
if not db_user: if not db_user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
+18 -6
View File
@@ -3,8 +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_current_user, get_db_session, require_study_member, require_study_roles 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 audit as audit_crud
from app.crud import site as site_crud
from app.crud import subject as subject_crud from app.crud import subject as subject_crud
from app.crud import study as study_crud from app.crud import study as study_crud
from app.crud import visit as visit_crud from app.crud import visit as visit_crud
@@ -20,6 +21,14 @@ async def _ensure_subject(db: AsyncSession, study_id: uuid.UUID, subject_id: uui
return subject return subject
async def _ensure_subject_active(db: AsyncSession, subject) -> None:
if not subject or not subject.site_id:
return
site = await site_crud.get_site(db, subject.site_id)
if not site or not site.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="中心已停用")
@router.get( @router.get(
"/", "/",
response_model=list[VisitRead], response_model=list[VisitRead],
@@ -39,7 +48,7 @@ async def list_visits(
"/", "/",
response_model=VisitRead, response_model=VisitRead,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))], dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
) )
async def create_visit( async def create_visit(
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -49,6 +58,7 @@ async def create_visit(
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
) -> VisitRead: ) -> VisitRead:
subject = await _ensure_subject(db, study_id, subject_id) subject = await _ensure_subject(db, study_id, subject_id)
await _ensure_subject_active(db, subject)
if visit_in.subject_id != subject_id: if visit_in.subject_id != subject_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="参与者不匹配") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="参与者不匹配")
visit = await visit_crud.create_visit( visit = await visit_crud.create_visit(
@@ -88,7 +98,7 @@ async def create_visit(
@router.patch( @router.patch(
"/{visit_id}", "/{visit_id}",
response_model=VisitRead, response_model=VisitRead,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))], dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
) )
async def update_visit( async def update_visit(
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -98,7 +108,8 @@ async def update_visit(
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
) -> VisitRead: ) -> VisitRead:
await _ensure_subject(db, study_id, subject_id) subject = await _ensure_subject(db, study_id, subject_id)
await _ensure_subject_active(db, subject)
visit = await visit_crud.get_visit(db, visit_id) visit = await visit_crud.get_visit(db, visit_id)
if not visit or visit.subject_id != subject_id or visit.study_id != study_id: if not visit or visit.subject_id != subject_id or visit.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="访视不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="访视不存在")
@@ -124,7 +135,7 @@ async def update_visit(
@router.delete( @router.delete(
"/{visit_id}", "/{visit_id}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))], dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
) )
async def delete_visit( async def delete_visit(
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -133,7 +144,8 @@ async def delete_visit(
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
) -> None: ) -> None:
await _ensure_subject(db, study_id, subject_id) subject = await _ensure_subject(db, study_id, subject_id)
await _ensure_subject_active(db, subject)
visit = await visit_crud.get_visit(db, visit_id) visit = await visit_crud.get_visit(db, visit_id)
if not visit or visit.subject_id != subject_id or visit.study_id != study_id: if not visit or visit.subject_id != subject_id or visit.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="访视不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="访视不存在")
+24
View File
@@ -115,3 +115,27 @@ def require_study_roles(roles: Iterable[str]):
return current_user return current_user
return dependency return dependency
def require_study_not_locked():
"""检查项目是否被锁定,如果锁定则拒绝所有修改请求"""
from app.crud import study as study_crud
async def dependency(
study_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
):
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="项目不存在",
)
if study.is_locked:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="项目已锁定,无法进行任何修改操作",
)
return study
return dependency
+6
View File
@@ -24,11 +24,17 @@ async def _validate_site_subject(db: AsyncSession, study_id: uuid.UUID, site_id:
site = result.scalar_one_or_none() site = result.scalar_one_or_none()
if not site or site.study_id != study_id: if not site or site.study_id != study_id:
raise ValueError("分中心不属于当前项目") raise ValueError("分中心不属于当前项目")
if not site.is_active:
raise ValueError("中心已停用")
if subject_id: if subject_id:
result = await db.execute(select(Subject).where(Subject.id == subject_id)) result = await db.execute(select(Subject).where(Subject.id == subject_id))
subj = result.scalar_one_or_none() subj = result.scalar_one_or_none()
if not subj or subj.study_id != study_id: if not subj or subj.study_id != study_id:
raise ValueError("参与者不属于当前项目") raise ValueError("参与者不属于当前项目")
result = await db.execute(select(Site).where(Site.id == subj.site_id))
subj_site = result.scalar_one_or_none()
if subj_site and not subj_site.is_active:
raise ValueError("中心已停用")
async def create_ae( async def create_ae(
+4 -1
View File
@@ -43,7 +43,10 @@ async def list_contracts(
skip: int = 0, skip: int = 0,
limit: int = 100, limit: int = 100,
) -> Sequence[FinanceContract]: ) -> Sequence[FinanceContract]:
stmt = select(FinanceContract).where(FinanceContract.study_id == study_id) stmt = (
select(FinanceContract)
.where(FinanceContract.study_id == study_id)
)
if site_name: if site_name:
stmt = stmt.where(FinanceContract.site_name.ilike(f"%{site_name}%")) stmt = stmt.where(FinanceContract.site_name.ilike(f"%{site_name}%"))
if contract_no: if contract_no:
+4 -1
View File
@@ -43,7 +43,10 @@ async def list_specials(
skip: int = 0, skip: int = 0,
limit: int = 100, limit: int = 100,
) -> Sequence[FinanceSpecial]: ) -> Sequence[FinanceSpecial]:
stmt = select(FinanceSpecial).where(FinanceSpecial.study_id == study_id) stmt = (
select(FinanceSpecial)
.where(FinanceSpecial.study_id == study_id)
)
if site_name: if site_name:
stmt = stmt.where(FinanceSpecial.site_name.ilike(f"%{site_name}%")) stmt = stmt.where(FinanceSpecial.site_name.ilike(f"%{site_name}%"))
if fee_type: if fee_type:
+4 -1
View File
@@ -41,7 +41,10 @@ async def list_notes(
skip: int = 0, skip: int = 0,
limit: int = 100, limit: int = 100,
) -> Sequence[KnowledgeNote]: ) -> Sequence[KnowledgeNote]:
stmt = select(KnowledgeNote).where(KnowledgeNote.study_id == study_id) stmt = (
select(KnowledgeNote)
.where(KnowledgeNote.study_id == study_id)
)
if site_name: if site_name:
stmt = stmt.where(KnowledgeNote.site_name.ilike(f"%{site_name}%")) stmt = stmt.where(KnowledgeNote.site_name.ilike(f"%{site_name}%"))
if keyword: if keyword:
+2 -7
View File
@@ -1,7 +1,7 @@
import uuid import uuid
from typing import Sequence from typing import Sequence
from sqlalchemy import select, update from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.study_member import StudyMember from app.models.study_member import StudyMember
@@ -54,13 +54,8 @@ async def update_member(db: AsyncSession, member: StudyMember, member_in: StudyM
async def remove_member(db: AsyncSession, member: StudyMember) -> StudyMember: async def remove_member(db: AsyncSession, member: StudyMember) -> StudyMember:
await db.execute( await db.execute(delete(StudyMember).where(StudyMember.id == member.id))
update(StudyMember)
.where(StudyMember.id == member.id)
.values(is_active=False)
)
await db.commit() await db.commit()
await db.refresh(member)
return member return member
+312 -6
View File
@@ -1,12 +1,42 @@
import uuid import uuid
from typing import Sequence from pathlib import Path
from typing import Iterable, Sequence
from sqlalchemy import select, update from sqlalchemy import delete, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.acknowledgement import Acknowledgement
from app.models.ae import AdverseEvent
from app.models.attachment import Attachment
from app.models.contract_fee import ContractFee
from app.models.contract_fee_payment import ContractFeePayment
from app.models.document import Document
from app.models.document_version import DocumentVersion
from app.models.distribution import Distribution
from app.models.drug_shipment import DrugShipment
from app.models.fee_attachment import FeeAttachment
from app.models.finance import FinanceItem
from app.models.finance_contract import FinanceContract
from app.models.finance_special import FinanceSpecial
from app.models.kickoff_meeting import KickoffMeeting
from app.models.knowledge_note import KnowledgeNote
from app.models.milestone import Milestone
from app.models.site import Site from app.models.site import Site
from app.models.special_expense import SpecialExpense
from app.models.startup_ethics import StartupEthics
from app.models.startup_feasibility import StartupFeasibility
from app.models.subject import Subject
from app.models.subject_history import SubjectHistory
from app.models.training_authorization import TrainingAuthorization
from app.models.version_workflow import VersionWorkflow
from app.models.visit import Visit
from app.models.workflow_action import WorkflowAction
from app.schemas.site import SiteCreate, SiteUpdate from app.schemas.site import SiteCreate, SiteUpdate
ATTACHMENT_ROOT = Path(__file__).resolve().parent.parent / "uploads"
FEE_ATTACHMENT_ROOT = ATTACHMENT_ROOT / "fees"
DOCUMENT_ATTACHMENT_ROOT = ATTACHMENT_ROOT / "documents"
async def create_site(db: AsyncSession, study_id: uuid.UUID, site_in: SiteCreate) -> Site: async def create_site(db: AsyncSession, study_id: uuid.UUID, site_in: SiteCreate) -> Site:
site = Site( site = Site(
@@ -41,10 +71,17 @@ async def update_site(db: AsyncSession, site: Site, site_in: SiteUpdate) -> Site
return site return site
async def list_by_study(db: AsyncSession, study_id: uuid.UUID, skip: int = 0, limit: int = 100) -> Sequence[Site]: async def list_by_study(
result = await db.execute( db: AsyncSession,
select(Site).where(Site.study_id == study_id).offset(skip).limit(limit) study_id: uuid.UUID,
) skip: int = 0,
limit: int = 100,
include_inactive: bool = False,
) -> Sequence[Site]:
stmt = select(Site).where(Site.study_id == study_id)
if not include_inactive:
stmt = stmt.where(Site.is_active.is_(True))
result = await db.execute(stmt.offset(skip).limit(limit))
return result.scalars().all() return result.scalars().all()
@@ -54,3 +91,272 @@ async def get_sites_by_ids(db: AsyncSession, ids: set[uuid.UUID]) -> dict[uuid.U
result = await db.execute(select(Site).where(Site.id.in_(ids))) result = await db.execute(select(Site).where(Site.id.in_(ids)))
sites = result.scalars().all() sites = result.scalars().all()
return {s.id: s for s in sites} return {s.id: s for s in sites}
async def list_active_names(db: AsyncSession, study_id: uuid.UUID) -> set[str]:
result = await db.execute(
select(Site.name).where(Site.study_id == study_id, Site.is_active.is_(True))
)
return {row[0] for row in result.all() if row[0]}
async def delete_site_and_related(db: AsyncSession, site: Site) -> None:
site_id = site.id
study_id = site.study_id
site_name = site.name
subject_ids = (
await db.execute(select(Subject.id).where(Subject.site_id == site_id))
).scalars().all()
contract_fee_ids = (
await db.execute(select(ContractFee.id).where(ContractFee.center_id == site_id))
).scalars().all()
special_expense_ids = (
await db.execute(select(SpecialExpense.id).where(SpecialExpense.center_id == site_id))
).scalars().all()
drug_shipment_ids = (
await db.execute(select(DrugShipment.id).where(DrugShipment.center_id == site_id))
).scalars().all()
kickoff_ids = (
await db.execute(select(KickoffMeeting.id).where(KickoffMeeting.site_id == site_id))
).scalars().all()
feasibility_ids = (
await db.execute(select(StartupFeasibility.id).where(StartupFeasibility.site_id == site_id))
).scalars().all()
ethics_ids = (
await db.execute(select(StartupEthics.id).where(StartupEthics.site_id == site_id))
).scalars().all()
training_ids = (
await db.execute(
select(TrainingAuthorization.id).where(
TrainingAuthorization.study_id == study_id,
TrainingAuthorization.site_name == site_name,
)
)
).scalars().all()
finance_contract_ids = (
await db.execute(
select(FinanceContract.id).where(
FinanceContract.study_id == study_id,
FinanceContract.site_name == site_name,
)
)
).scalars().all()
finance_special_ids = (
await db.execute(
select(FinanceSpecial.id).where(
FinanceSpecial.study_id == study_id,
FinanceSpecial.site_name == site_name,
)
)
).scalars().all()
knowledge_note_ids = (
await db.execute(
select(KnowledgeNote.id).where(
KnowledgeNote.study_id == study_id,
KnowledgeNote.site_name == site_name,
)
)
).scalars().all()
document_ids = (
await db.execute(select(Document.id).where(Document.site_id == site_id))
).scalars().all()
version_ids = []
if document_ids:
version_ids = (
await db.execute(select(DocumentVersion.id).where(DocumentVersion.document_id.in_(document_ids)))
).scalars().all()
workflow_ids = []
workflow_conditions = []
if document_ids:
workflow_conditions.append(VersionWorkflow.document_id.in_(document_ids))
if version_ids:
workflow_conditions.append(VersionWorkflow.version_id.in_(version_ids))
if workflow_conditions:
workflow_ids = (
await db.execute(select(VersionWorkflow.id).where(or_(*workflow_conditions)))
).scalars().all()
distribution_conditions = []
if document_ids:
distribution_conditions.append(Distribution.document_id.in_(document_ids))
if version_ids:
distribution_conditions.append(Distribution.version_id.in_(version_ids))
distribution_conditions.append((Distribution.target_type == "SITE") & (Distribution.target_id == str(site_id)))
distribution_ids = (
await db.execute(select(Distribution.id).where(or_(*distribution_conditions)))
).scalars().all()
attachment_groups: dict[str, list[uuid.UUID]] = {
"startup_feasibility": feasibility_ids,
"startup_ethics": ethics_ids,
"startup_kickoff": kickoff_ids,
"startup_kickoff_minutes": kickoff_ids,
"startup_kickoff_signin": kickoff_ids,
"startup_kickoff_ppt": kickoff_ids,
"training_authorization": training_ids,
"finance_contract": finance_contract_ids,
"finance_special": finance_special_ids,
"knowledge_note": knowledge_note_ids,
"drug_shipment": drug_shipment_ids,
}
attachment_paths: list[str] = []
for entity_type, ids in attachment_groups.items():
if not ids:
continue
attachment_paths.extend(
(
await db.execute(
select(Attachment.file_path).where(
Attachment.study_id == study_id,
Attachment.entity_type == entity_type,
Attachment.entity_id.in_(ids),
)
)
).scalars().all()
)
await db.execute(
delete(Attachment).where(
Attachment.study_id == study_id,
Attachment.entity_type == entity_type,
Attachment.entity_id.in_(ids),
)
)
fee_attachment_paths: list[str] = []
if contract_fee_ids:
fee_attachment_paths.extend(
(
await db.execute(
select(FeeAttachment.storage_key).where(
FeeAttachment.entity_type == "contract_fee",
FeeAttachment.entity_id.in_(contract_fee_ids),
)
)
).scalars().all()
)
await db.execute(
delete(FeeAttachment).where(
FeeAttachment.entity_type == "contract_fee",
FeeAttachment.entity_id.in_(contract_fee_ids),
)
)
await db.execute(delete(ContractFeePayment).where(ContractFeePayment.contract_fee_id.in_(contract_fee_ids)))
if special_expense_ids:
fee_attachment_paths.extend(
(
await db.execute(
select(FeeAttachment.storage_key).where(
FeeAttachment.entity_type == "special_expense",
FeeAttachment.entity_id.in_(special_expense_ids),
)
)
).scalars().all()
)
await db.execute(
delete(FeeAttachment).where(
FeeAttachment.entity_type == "special_expense",
FeeAttachment.entity_id.in_(special_expense_ids),
)
)
if workflow_ids:
await db.execute(delete(WorkflowAction).where(WorkflowAction.workflow_id.in_(workflow_ids)))
await db.execute(delete(VersionWorkflow).where(VersionWorkflow.id.in_(workflow_ids)))
if distribution_ids:
await db.execute(delete(Acknowledgement).where(Acknowledgement.distribution_id.in_(distribution_ids)))
await db.execute(delete(Distribution).where(Distribution.id.in_(distribution_ids)))
await db.execute(delete(Acknowledgement).where(Acknowledgement.site_id == site_id))
version_file_paths: list[str] = []
if version_ids:
version_file_paths = (
await db.execute(select(DocumentVersion.file_uri).where(DocumentVersion.id.in_(version_ids)))
).scalars().all()
await db.execute(delete(DocumentVersion).where(DocumentVersion.id.in_(version_ids)))
if document_ids:
await db.execute(delete(Document).where(Document.id.in_(document_ids)))
if subject_ids:
await db.execute(delete(SubjectHistory).where(SubjectHistory.subject_id.in_(subject_ids)))
await db.execute(delete(Visit).where(Visit.subject_id.in_(subject_ids)))
await db.execute(delete(AdverseEvent).where(AdverseEvent.subject_id.in_(subject_ids)))
await db.execute(delete(FinanceItem).where(FinanceItem.subject_id.in_(subject_ids)))
await db.execute(delete(AdverseEvent).where(AdverseEvent.site_id == site_id))
await db.execute(delete(FinanceItem).where(FinanceItem.site_id == site_id))
await db.execute(delete(Subject).where(Subject.site_id == site_id))
await db.execute(delete(Milestone).where(Milestone.site_id == site_id))
await db.execute(delete(KickoffMeeting).where(KickoffMeeting.site_id == site_id))
await db.execute(delete(StartupFeasibility).where(StartupFeasibility.site_id == site_id))
await db.execute(delete(StartupEthics).where(StartupEthics.site_id == site_id))
await db.execute(delete(ContractFee).where(ContractFee.center_id == site_id))
await db.execute(delete(SpecialExpense).where(SpecialExpense.center_id == site_id))
await db.execute(delete(DrugShipment).where(DrugShipment.center_id == site_id))
await db.execute(
delete(TrainingAuthorization).where(
TrainingAuthorization.study_id == study_id,
TrainingAuthorization.site_name == site_name,
)
)
await db.execute(
delete(FinanceContract).where(
FinanceContract.study_id == study_id,
FinanceContract.site_name == site_name,
)
)
await db.execute(
delete(FinanceSpecial).where(
FinanceSpecial.study_id == study_id,
FinanceSpecial.site_name == site_name,
)
)
await db.execute(
delete(KnowledgeNote).where(
KnowledgeNote.study_id == study_id,
KnowledgeNote.site_name == site_name,
)
)
await db.execute(delete(Site).where(Site.id == site_id))
await _remove_files(attachment_paths, ATTACHMENT_ROOT)
await _remove_files(fee_attachment_paths, FEE_ATTACHMENT_ROOT)
await _remove_files(version_file_paths, DOCUMENT_ATTACHMENT_ROOT)
await db.commit()
async def _remove_files(paths: Iterable[str | None], root: Path) -> None:
for raw in paths:
if not raw:
continue
file_path = Path(str(raw))
try:
if file_path.exists():
file_path.unlink()
except OSError:
pass
_prune_empty_parents(file_path.parent, root)
def _prune_empty_parents(start: Path, root: Path) -> None:
try:
root_resolved = root.resolve()
except OSError:
return
try:
current = start.resolve()
except OSError:
return
if root_resolved not in current.parents and current != root_resolved:
return
while True:
if current == root_resolved:
break
try:
current.rmdir()
except OSError:
break
current = current.parent
+132 -3
View File
@@ -1,7 +1,7 @@
import uuid import uuid
from typing import Sequence from typing import Sequence
from sqlalchemy import select, update as sa_update from sqlalchemy import delete as sa_delete, select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.study import Study from app.models.study import Study
@@ -9,10 +9,22 @@ from app.schemas.study import StudyCreate, StudyUpdate
async def create(db: AsyncSession, study_in: StudyCreate, *, created_by: uuid.UUID | None = None) -> Study: async def create(db: AsyncSession, study_in: StudyCreate, *, created_by: uuid.UUID | None = None) -> Study:
import uuid as uuid_lib # Import uuid to generate unique code if needed
code = study_in.code
if not code:
# Generate a unique code if not provided
# Use first 8 chars of a new UUID, uppercase
code = f"P-{str(uuid_lib.uuid4())[:8].upper()}"
study = Study( study = Study(
code=study_in.code, code=code,
name=study_in.name, name=study_in.name,
sponsor=study_in.sponsor, # sponsor is removed from schema, but model has it. Set to None/empty or keep existing value if schema had it (which it doesn't now)
# If we removed it from schema, study_in.sponsor will fail if we try to access it via .sponsor attribute directly if it's not in the pydantic model anymore.
# Since we removed `sponsor` field from StudyCreate, `study_in` object won't have `sponsor` attribute unless we access dict or use default.
# However, the Study MODEL still has sponsor. We should set it to None.
sponsor=None,
protocol_no=study_in.protocol_no, protocol_no=study_in.protocol_no,
phase=study_in.phase, phase=study_in.phase,
status=study_in.status, status=study_in.status,
@@ -73,3 +85,120 @@ async def list_studies_for_user(
) )
result = await db.execute(stmt) result = await db.execute(stmt)
return result.scalars().all() return result.scalars().all()
async def delete(db: AsyncSession, study_id: uuid.UUID) -> None:
"""硬删除项目及其所有关联数据"""
# 导入所有关联的模型
from app.models.study_member import StudyMember
from app.models.audit_log import AuditLog
from app.models.site import Site
from app.models.subject import Subject
from app.models.subject_history import SubjectHistory
from app.models.visit import Visit
from app.models.ae import AdverseEvent
from app.models.finance import FinanceItem
from app.models.finance_contract import FinanceContract
from app.models.finance_special import FinanceSpecial
from app.models.contract_fee import ContractFee
from app.models.special_expense import SpecialExpense
from app.models.milestone import Milestone
from app.models.document import Document
from app.models.attachment import Attachment
from app.models.drug_shipment import DrugShipment
from app.models.training_authorization import TrainingAuthorization
from app.models.startup_feasibility import StartupFeasibility
from app.models.startup_ethics import StartupEthics
from app.models.kickoff_meeting import KickoffMeeting
from app.models.knowledge_note import KnowledgeNote
from app.models.faq_category import FaqCategory
from app.models.faq_item import FaqItem
from app.models.faq_reply import FaqReply
from app.models.workflow_template import WorkflowTemplate
# 按依赖关系顺序删除关联数据
# 1. 删除审计日志
await db.execute(sa_delete(AuditLog).where(AuditLog.study_id == study_id))
# 2. 删除FAQ相关(可能有依赖关系)
await db.execute(sa_delete(FaqReply).where(FaqReply.study_id == study_id))
await db.execute(sa_delete(FaqItem).where(FaqItem.study_id == study_id))
await db.execute(sa_delete(FaqCategory).where(FaqCategory.study_id == study_id))
# 3. 删除知识库
await db.execute(sa_delete(KnowledgeNote).where(KnowledgeNote.study_id == study_id))
# 4. 删除工作流模板
await db.execute(sa_delete(WorkflowTemplate).where(WorkflowTemplate.trial_id == study_id))
# 5. 删除启动相关
await db.execute(sa_delete(KickoffMeeting).where(KickoffMeeting.study_id == study_id))
await db.execute(sa_delete(StartupEthics).where(StartupEthics.study_id == study_id))
await db.execute(sa_delete(StartupFeasibility).where(StartupFeasibility.study_id == study_id))
await db.execute(sa_delete(TrainingAuthorization).where(TrainingAuthorization.study_id == study_id))
# 6. 删除药物配送
await db.execute(sa_delete(DrugShipment).where(DrugShipment.study_id == study_id))
# 7. 删除附件和文档
await db.execute(sa_delete(Attachment).where(Attachment.study_id == study_id))
await db.execute(sa_delete(Document).where(Document.trial_id == study_id))
# 8. 删除里程碑
await db.execute(sa_delete(Milestone).where(Milestone.study_id == study_id))
# 9. 删除财务相关
await db.execute(sa_delete(SpecialExpense).where(SpecialExpense.project_id == study_id))
await db.execute(sa_delete(ContractFee).where(ContractFee.project_id == study_id))
await db.execute(sa_delete(FinanceSpecial).where(FinanceSpecial.study_id == study_id))
await db.execute(sa_delete(FinanceContract).where(FinanceContract.study_id == study_id))
await db.execute(sa_delete(FinanceItem).where(FinanceItem.study_id == study_id))
# 10. 删除不良事件
await db.execute(sa_delete(AdverseEvent).where(AdverseEvent.study_id == study_id))
# 11. 删除访视
await db.execute(sa_delete(Visit).where(Visit.study_id == study_id))
# 12. 删除受试者相关
await db.execute(sa_delete(SubjectHistory).where(SubjectHistory.study_id == study_id))
await db.execute(sa_delete(Subject).where(Subject.study_id == study_id))
# 13. 删除站点
await db.execute(sa_delete(Site).where(Site.study_id == study_id))
# 14. 删除成员
await db.execute(sa_delete(StudyMember).where(StudyMember.study_id == study_id))
# 15. 最后删除项目本身
await db.execute(sa_delete(Study).where(Study.id == study_id))
await db.commit()
async def lock(db: AsyncSession, study_id: uuid.UUID) -> Study | None:
"""锁定项目"""
await db.execute(
sa_update(Study)
.where(Study.id == study_id)
.values(is_locked=True)
)
await db.commit()
return await get(db, study_id)
async def unlock(db: AsyncSession, study_id: uuid.UUID) -> Study | None:
"""解锁项目"""
await db.execute(
sa_update(Study)
.where(Study.id == study_id)
.values(is_locked=False)
)
await db.commit()
return await get(db, study_id)
async def is_locked(db: AsyncSession, study_id: uuid.UUID) -> bool:
"""检查项目是否已锁定"""
study = await get(db, study_id)
return study.is_locked if study else False
+2 -6
View File
@@ -18,6 +18,8 @@ async def _validate_site(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UU
site = result.scalar_one_or_none() site = result.scalar_one_or_none()
if not site or site.study_id != study_id: if not site or site.study_id != study_id:
raise ValueError("分中心不属于当前项目") raise ValueError("分中心不属于当前项目")
if not site.is_active:
raise ValueError("中心已停用")
async def create_subject(db: AsyncSession, study_id: uuid.UUID, subject_in: SubjectCreate) -> Subject: async def create_subject(db: AsyncSession, study_id: uuid.UUID, subject_in: SubjectCreate) -> Subject:
@@ -58,16 +60,10 @@ async def list_subjects(
db: AsyncSession, db: AsyncSession,
study_id: uuid.UUID, study_id: uuid.UUID,
site_id: uuid.UUID | None = None, site_id: uuid.UUID | None = None,
status: str | None = None,
subject_no: str | None = None,
) -> Sequence[Subject]: ) -> Sequence[Subject]:
stmt = select(Subject).where(Subject.study_id == study_id) stmt = select(Subject).where(Subject.study_id == study_id)
if site_id: if site_id:
stmt = stmt.where(Subject.site_id == site_id) stmt = stmt.where(Subject.site_id == site_id)
if status:
stmt = stmt.where(Subject.status == status)
if subject_no:
stmt = stmt.where(Subject.subject_no.ilike(f"%{subject_no}%"))
result = await db.execute(stmt) result = await db.execute(stmt)
return result.scalars().all() return result.scalars().all()
+2 -1
View File
@@ -1,7 +1,7 @@
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, func from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -18,6 +18,7 @@ class Study(Base):
protocol_no: Mapped[str | None] = mapped_column(String(100), nullable=True) protocol_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
phase: Mapped[str | None] = mapped_column(String(50), nullable=True) phase: Mapped[str | None] = mapped_column(String(50), nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="DRAFT") status: Mapped[str] = mapped_column(String(20), nullable=False, default="DRAFT")
is_locked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
visit_interval_days: Mapped[int | None] = mapped_column(Integer, nullable=True) visit_interval_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
visit_total: Mapped[int | None] = mapped_column(Integer, nullable=True) visit_total: Mapped[int | None] = mapped_column(Integer, nullable=True)
visit_window_start_offset: Mapped[int | None] = mapped_column(Integer, nullable=True) visit_window_start_offset: Mapped[int | None] = mapped_column(Integer, nullable=True)
+4 -6
View File
@@ -8,9 +8,8 @@ StudyStatus = Literal["DRAFT", "ACTIVE", "CLOSED"]
class StudyCreate(BaseModel): class StudyCreate(BaseModel):
code: str = Field(min_length=1)
name: str = Field(min_length=1) name: str = Field(min_length=1)
sponsor: Optional[str] = None code: Optional[str] = None
protocol_no: Optional[str] = None protocol_no: Optional[str] = None
phase: Optional[str] = None phase: Optional[str] = None
status: StudyStatus = "DRAFT" status: StudyStatus = "DRAFT"
@@ -21,12 +20,11 @@ class StudyCreate(BaseModel):
class StudyUpdate(BaseModel): class StudyUpdate(BaseModel):
code: Optional[str] = None
name: Optional[str] = None name: Optional[str] = None
sponsor: Optional[str] = None
protocol_no: Optional[str] = None protocol_no: Optional[str] = None
phase: Optional[str] = None phase: Optional[str] = None
status: Optional[StudyStatus] = None status: Optional[StudyStatus] = None
is_locked: Optional[bool] = None
visit_interval_days: Optional[int] = None visit_interval_days: Optional[int] = None
visit_total: Optional[int] = None visit_total: Optional[int] = None
visit_window_start_offset: Optional[int] = None visit_window_start_offset: Optional[int] = None
@@ -35,12 +33,12 @@ class StudyUpdate(BaseModel):
class StudyRead(BaseModel): class StudyRead(BaseModel):
id: uuid.UUID id: uuid.UUID
code: str
name: str name: str
sponsor: Optional[str] protocol_no: Optional[str]
protocol_no: Optional[str] protocol_no: Optional[str]
phase: Optional[str] phase: Optional[str]
status: StudyStatus status: StudyStatus
is_locked: bool
visit_interval_days: Optional[int] visit_interval_days: Optional[int]
visit_total: Optional[int] visit_total: Optional[int]
visit_window_start_offset: Optional[int] visit_window_start_offset: Optional[int]
+5
View File
@@ -17,6 +17,7 @@ from app.crud import distribution as distribution_crud
from app.crud import document as document_crud from app.crud import document as document_crud
from app.crud import document_version as version_crud from app.crud import document_version as version_crud
from app.crud import member as member_crud from app.crud import member as member_crud
from app.crud import site as site_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
from app.crud import version_workflow as workflow_crud from app.crud import version_workflow as workflow_crud
@@ -91,6 +92,10 @@ async def create_document(
if doc_in.scope_type == DocumentScopeType.SITE and not doc_in.site_id: if doc_in.scope_type == DocumentScopeType.SITE and not doc_in.site_id:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="分中心文件需指定分中心") raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="分中心文件需指定分中心")
site_id = doc_in.site_id if doc_in.scope_type == DocumentScopeType.SITE else None site_id = doc_in.site_id if doc_in.scope_type == DocumentScopeType.SITE else None
if site_id:
site = await site_crud.get_site(db, site_id)
if not site or not site.is_active:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心已停用")
existing = await document_crud.get_by_trial_doc_no(db, doc_in.trial_id, doc_in.doc_no) existing = await document_crud.get_by_trial_doc_no(db, doc_in.trial_id, doc_in.doc_no)
if existing: if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="文档编号已存在") raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="文档编号已存在")
+7 -2
View File
@@ -1,11 +1,16 @@
import { apiGet, apiPatch, apiPost } from "./axios"; import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
import type { ApiListResponse, Site } from "../types/api"; import type { ApiListResponse, Site } from "../types/api";
export const fetchSites = (studyId: string, params?: Record<string, any>) => export const fetchSites = (studyId: string, params?: Record<string, any>) =>
apiGet<ApiListResponse<Site> | Site[]>(`/api/v1/studies/${studyId}/sites/`, { params }); apiGet<ApiListResponse<Site> | Site[]>(`/api/v1/studies/${studyId}/sites/`, {
params: { include_inactive: true, ...(params || {}) },
});
export const createSite = (studyId: string, payload: Partial<Site> & { name: string }) => export const createSite = (studyId: string, payload: Partial<Site> & { name: string }) =>
apiPost<Site>(`/api/v1/studies/${studyId}/sites/`, payload); apiPost<Site>(`/api/v1/studies/${studyId}/sites/`, payload);
export const updateSite = (studyId: string, siteId: string, payload: Partial<Site>) => export const updateSite = (studyId: string, siteId: string, payload: Partial<Site>) =>
apiPatch<Site>(`/api/v1/studies/${studyId}/sites/${siteId}`, payload); apiPatch<Site>(`/api/v1/studies/${studyId}/sites/${siteId}`, payload);
export const deleteSite = (studyId: string, siteId: string) =>
apiDelete<void>(`/api/v1/studies/${studyId}/sites/${siteId}`);
+9 -2
View File
@@ -1,13 +1,20 @@
import { apiGet, apiPatch, apiPost } from "./axios"; import { apiGet, apiPatch, apiPost, apiDelete } from "./axios";
import type { ApiListResponse, Study } from "../types/api"; import type { ApiListResponse, Study } from "../types/api";
// Backend expects trailing slash to avoid 307 redirect // Backend expects trailing slash to avoid 307 redirect
export const fetchStudies = () => apiGet<ApiListResponse<Study>>("/api/v1/studies/"); export const fetchStudies = () => apiGet<ApiListResponse<Study>>("/api/v1/studies/");
export const createStudy = (payload: Partial<Study> & { code: string; name: string }) => export const createStudy = (payload: Partial<Study> & { name: string }) =>
apiPost<Study>("/api/v1/studies/", payload); apiPost<Study>("/api/v1/studies/", payload);
export const updateStudy = (studyId: string, payload: Partial<Study>) => export const updateStudy = (studyId: string, payload: Partial<Study>) =>
apiPatch<Study>(`/api/v1/studies/${studyId}`, payload); apiPatch<Study>(`/api/v1/studies/${studyId}`, payload);
export const fetchStudyDetail = (studyId: string) => apiGet<Study>(`/api/v1/studies/${studyId}`); export const fetchStudyDetail = (studyId: string) => apiGet<Study>(`/api/v1/studies/${studyId}`);
export const deleteStudy = (studyId: string) =>
apiDelete(`/api/v1/studies/${studyId}`);
export const lockStudy = (studyId: string) =>
apiPatch<Study>(`/api/v1/studies/${studyId}/lock`, {});
+2 -2
View File
@@ -20,5 +20,5 @@ export const updateUser = (
) => ) =>
apiPatch<UserInfo>(`/api/v1/users/${userId}`, payload); apiPatch<UserInfo>(`/api/v1/users/${userId}`, payload);
export const deleteUser = (userId: string, payload: { admin_password: string }) => export const deleteUser = (userId: string) =>
apiDelete<void>(`/api/v1/users/${userId}`, { data: payload }); apiDelete<void>(`/api/v1/users/${userId}`);
@@ -3,6 +3,7 @@
<div class="header"> <div class="header">
<span>{{ headerTitle }}</span> <span>{{ headerTitle }}</span>
<AttachmentUploader <AttachmentUploader
v-if="!readonly"
:study-id="studyId" :study-id="studyId"
:entity-type="entityType" :entity-type="entityType"
:entity-id="entityId" :entity-id="entityId"
@@ -75,6 +76,7 @@ const props = defineProps<{
entityType: string; entityType: string;
entityId: string; entityId: string;
title?: string; title?: string;
readonly?: boolean;
}>(); }>();
const attachments = ref<any[]>([]); const attachments = ref<any[]>([]);
@@ -199,6 +201,7 @@ const preview = (row: any) => {
}; };
const canDelete = (row: any) => { const canDelete = (row: any) => {
if (props.readonly) return false;
const userId = auth.user?.id; const userId = auth.user?.id;
const role = auth.user?.role; const role = auth.user?.role;
const projectRole = study.currentStudyRole || (study.currentStudy as any)?.role_in_study; const projectRole = study.currentStudyRole || (study.currentStudy as any)?.role_in_study;
+5 -4
View File
@@ -716,10 +716,10 @@ export const TEXT = {
loadFailed: "用户列表加载失败", loadFailed: "用户列表加载失败",
updateSuccess: "用户已更新", updateSuccess: "用户已更新",
createSuccess: "用户已创建", createSuccess: "用户已创建",
deleteConfirm: "删除后账号将无法登录且无法恢复,需先在各项目成员配置中移除该账号。请输入当前管理员密码确认删除。", deleteConfirm: "删除后不可恢复,请输入“确认删除”继续。",
deleteTitle: "危险操作:删除账号", deleteTitle: "危险操作:删除账号",
deletePasswordPlaceholder: "请输入 admin 密码", deleteConfirmPlaceholder: "确认删除",
deleteConfirmButton: "确认删除", deleteConfirmInputError: "请输入“确认删除",
deleteSuccess: "账号已删除", deleteSuccess: "账号已删除",
deleteFailed: "删除失败", deleteFailed: "删除失败",
enableConfirm: "确认启用该用户账号?", enableConfirm: "确认启用该用户账号?",
@@ -752,7 +752,7 @@ export const TEXT = {
resetFailed: "重置失败", resetFailed: "重置失败",
}, },
adminProjects: { adminProjects: {
title: "项目理", title: "项目理",
subtitle: "项目是独立业务实体,账号加入项目后才成为项目成员(角色在成员配置中设置)", subtitle: "项目是独立业务实体,账号加入项目后才成为项目成员(角色在成员配置中设置)",
projectLabel: "项目", projectLabel: "项目",
members: "项目账号/成员配置", members: "项目账号/成员配置",
@@ -827,6 +827,7 @@ export const TEXT = {
craSaveSuccess: "绑定已保存", craSaveSuccess: "绑定已保存",
}, },
adminAuditLogs: { adminAuditLogs: {
title: "审计日志",
filterEvent: "操作类型", filterEvent: "操作类型",
filterOperator: "操作人", filterOperator: "操作人",
filterResult: "结果", filterResult: "结果",
+1
View File
@@ -65,6 +65,7 @@ export interface Study {
protocol_no?: string | null; protocol_no?: string | null;
phase?: string | null; phase?: string | null;
status: string; status: string;
is_locked?: boolean;
visit_interval_days?: number | null; visit_interval_days?: number | null;
visit_total?: number | null; visit_total?: number | null;
visit_window_start_offset?: number | null; visit_window_start_offset?: number | null;
+72 -17
View File
@@ -1,15 +1,20 @@
<template> <template>
<div class="page"> <div class="page">
<el-card> <el-card>
<div class="header">
<div>
<h3>{{ TEXT.modules.adminAuditLogs.title }}</h3>
</div>
</div>
<div class="ctms-table-toolbar"> <div class="ctms-table-toolbar">
<div class="ctms-filter-group"> <div class="ctms-filter-group">
<el-select v-model="filters.eventType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterEvent" @change="loadLogs" class="ctms-filter-item"> <el-select v-model="filters.eventType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterEvent" @change="loadLogs" class="ctms-filter-item w-40">
<el-option v-for="opt in eventTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" /> <el-option v-for="opt in eventTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select> </el-select>
<el-select v-model="filters.operatorId" clearable :placeholder="TEXT.modules.adminAuditLogs.filterOperator" filterable @change="loadLogs" class="ctms-filter-item"> <el-select v-model="filters.operatorId" clearable :placeholder="TEXT.modules.adminAuditLogs.filterOperator" filterable @change="loadLogs" class="ctms-filter-item w-40">
<el-option v-for="u in userOptions" :key="u.value" :label="u.label" :value="u.value" /> <el-option v-for="u in userOptions" :key="u.value" :label="u.label" :value="u.value" />
</el-select> </el-select>
<el-select v-model="filters.result" clearable :placeholder="TEXT.modules.adminAuditLogs.filterResult" @change="loadLogs" style="width: 100px"> <el-select v-model="filters.result" clearable :placeholder="TEXT.modules.adminAuditLogs.filterResult" @change="loadLogs" class="ctms-filter-item w-32">
<el-option :label="TEXT.audit.resultSuccess" value="SUCCESS" /> <el-option :label="TEXT.audit.resultSuccess" value="SUCCESS" />
<el-option :label="TEXT.audit.resultFail" value="FAIL" /> <el-option :label="TEXT.audit.resultFail" value="FAIL" />
</el-select> </el-select>
@@ -21,7 +26,7 @@
:end-placeholder="TEXT.modules.adminAuditLogs.rangeEnd" :end-placeholder="TEXT.modules.adminAuditLogs.rangeEnd"
format="YYYY-MM-DD" format="YYYY-MM-DD"
value-format="YYYY-MM-DD" value-format="YYYY-MM-DD"
style="width: 240px" class="ctms-filter-item date-range-picker"
@change="loadLogs" @change="loadLogs"
/> />
</div> </div>
@@ -49,33 +54,40 @@
</div> </div>
</div> </div>
<el-table :data="logs" v-loading="loading" style="width: 100%"> <el-table :data="logs" v-loading="loading" style="width: 100%" stripe>
<el-table-column prop="timestamp" :label="TEXT.modules.adminAuditLogs.columns.time" width="180"> <el-table-column prop="timestamp" :label="TEXT.modules.adminAuditLogs.columns.time" width="180" align="center">
<template #default="scope">{{ displayDateTime(scope.row.timestamp) }}</template> <template #default="scope">
<span class="text-gray-500">{{ displayDateTime(scope.row.timestamp) }}</span>
</template>
</el-table-column> </el-table-column>
<el-table-column prop="actorName" :label="TEXT.modules.adminAuditLogs.columns.actor" width="140" /> <el-table-column prop="actorName" :label="TEXT.modules.adminAuditLogs.columns.actor" width="140" />
<el-table-column prop="actorRoleLabel" :label="TEXT.common.labels.role" width="120" /> <el-table-column prop="actorRoleLabel" :label="TEXT.common.labels.role" width="120" />
<el-table-column prop="eventLabel" :label="TEXT.modules.adminAuditLogs.columns.event" min-width="160" /> <el-table-column prop="eventLabel" :label="TEXT.modules.adminAuditLogs.columns.event" width="160">
<template #default="scope">
<el-tag effect="plain">{{ scope.row.eventLabel }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="actionText" :label="TEXT.modules.adminAuditLogs.columns.action" min-width="180" /> <el-table-column prop="actionText" :label="TEXT.modules.adminAuditLogs.columns.action" min-width="180" />
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.target" min-width="180"> <el-table-column :label="TEXT.modules.adminAuditLogs.columns.target" min-width="180">
<template #default="scope"> <template #default="scope">
<span v-if="scope.row.targetTypeLabel"> <span v-if="scope.row.targetTypeLabel">
{{ scope.row.targetTypeLabel }}{{ scope.row.targetName || TEXT.common.fallback }} <strong>{{ scope.row.targetTypeLabel }}</strong>
<span class="text-gray-500" v-if="scope.row.targetName">{{ scope.row.targetName }}</span>
</span> </span>
<span v-else>{{ TEXT.common.fallback }}</span> <span v-else>{{ TEXT.common.fallback }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.diff" min-width="220"> <el-table-column :label="TEXT.modules.adminAuditLogs.columns.diff" min-width="260">
<template #default="scope"> <template #default="scope">
<div v-if="scope.row.diffText?.length"> <div v-if="scope.row.diffText?.length" class="diff-container">
<div v-for="(line, idx) in scope.row.diffText" :key="idx">{{ line }}</div> <div v-for="(line, idx) in scope.row.diffText" :key="idx" class="diff-line">{{ line }}</div>
</div> </div>
<span v-else>{{ TEXT.audit.emptyValue }}</span> <span v-else class="text-gray-400">{{ TEXT.audit.emptyValue }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.result" width="120"> <el-table-column :label="TEXT.modules.adminAuditLogs.columns.result" width="100" align="center">
<template #default="scope"> <template #default="scope">
<el-tag :type="scope.row.result === 'SUCCESS' ? 'success' : 'danger'"> <el-tag :type="scope.row.result === 'SUCCESS' ? 'success' : 'danger'" effect="dark" size="small">
{{ scope.row.resultLabel }} {{ scope.row.resultLabel }}
</el-tag> </el-tag>
</template> </template>
@@ -322,10 +334,53 @@ onMounted(async () => {
<style scoped> <style scoped>
.page { .page {
padding: 16px; padding: 24px;
}
.header {
display: flex;
align-items: center;
margin-bottom: 24px;
}
.header h3 {
font-size: 20px;
font-weight: 600;
color: #1f2937;
margin: 0;
}
.ctms-table-toolbar {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
gap: 16px;
flex-wrap: wrap;
}
.ctms-filter-group {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.ctms-filter-item {
width: auto;
}
.w-40 { width: 160px; }
.w-32 { width: 130px; }
.w-64 { width: 260px; }
.diff-container {
font-size: 13px;
color: #4b5563;
line-height: 1.5;
}
.diff-line {
margin-bottom: 2px;
word-break: break-all;
}
.date-range-picker {
width: auto;
min-width: 320px;
} }
.pagination { .pagination {
margin-top: 12px; margin-top: 24px;
text-align: right; text-align: right;
} }
</style> </style>
+443 -40
View File
@@ -1,79 +1,320 @@
<template> <template>
<div class="ctms-page"> <div class="ctms-page">
<div class="ctms-page-header"> <div class="page-header-section">
<div> <div class="header-top">
<h1 class="ctms-page-title">{{ TEXT.modules.adminProjects.detailTitle }}</h1> <h1 class="page-title">{{ TEXT.modules.adminProjects.detailTitle }}</h1>
<el-tag v-if="project?.is_locked" type="warning" size="large" effect="dark">
<el-icon><Lock /></el-icon>
已锁定
</el-tag>
</div> </div>
<div class="ctms-page-actions"> <div class="quick-actions">
<el-button type="primary" @click="enterProject" :disabled="!project">{{ TEXT.modules.adminProjects.enter }}</el-button> <div class="action-card" @click="enterProject" :class="{ disabled: !project }">
<el-button @click="goMembers" :disabled="!project">{{ TEXT.modules.adminProjects.members }}</el-button> <el-icon class="action-icon"><Promotion /></el-icon>
<el-button @click="goSites" :disabled="!project">{{ TEXT.modules.adminProjects.sites }}</el-button> <span class="action-text">{{ TEXT.modules.adminProjects.enter }}</span>
</div>
<div class="action-card" @click="goMembers" :class="{ disabled: !project }">
<el-icon class="action-icon"><User /></el-icon>
<span class="action-text">{{ TEXT.modules.adminProjects.members }}</span>
</div>
<div class="action-card" @click="goSites" :class="{ disabled: !project }">
<el-icon class="action-icon"><OfficeBuilding /></el-icon>
<span class="action-text">{{ TEXT.modules.adminProjects.sites }}</span>
</div>
</div> </div>
</div> </div>
<el-card class="ctms-section-card"> <el-card class="ctms-section-card">
<template #header> <template #header>
<div> <div class="ctms-section-header">
<div class="ctms-section-title">{{ TEXT.modules.adminProjects.basicInfo }}</div> <div class="ctms-section-title">{{ TEXT.modules.adminProjects.basicInfo }}</div>
<div class="ctms-section-actions">
<template v-if="!isEditing">
<el-button type="primary" @click="startEdit" :disabled="!project || project.is_locked">
{{ TEXT.common.actions.edit }}
</el-button>
<el-button
v-if="!project?.is_locked"
type="info"
@click="handleLock"
:disabled="!project"
>
<el-icon><Lock /></el-icon>
锁定项目
</el-button>
<el-button @click="goBack">
<el-icon><ArrowLeft /></el-icon>
{{ TEXT.common.actions.back }}
</el-button>
<el-button type="danger" @click="handleDelete" :disabled="!project">
<el-icon><Delete /></el-icon>
{{ TEXT.common.actions.delete }}
</el-button>
</template>
<template v-else>
<el-button @click="cancelEdit">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="submitting" @click="saveEdit">
{{ TEXT.common.actions.save }}
</el-button>
<el-button @click="goBack">
<el-icon><ArrowLeft /></el-icon>
{{ TEXT.common.actions.back }}
</el-button>
<el-button type="danger" @click="handleDelete" :disabled="!project">
<el-icon><Delete /></el-icon>
{{ TEXT.common.actions.delete }}
</el-button>
</template>
</div>
</div> </div>
</template> </template>
<el-descriptions v-if="project" :column="2" border class="detail-descriptions"> <el-form v-if="project" ref="formRef" :model="form" :rules="rules" label-width="120px" class="detail-form" label-position="top">
<el-descriptions-item :label="TEXT.common.fields.projectCode">{{ project.code }}</el-descriptions-item> <el-row :gutter="24">
<el-descriptions-item :label="TEXT.common.fields.projectName">{{ project.name }}</el-descriptions-item> <el-col :span="16">
<el-descriptions-item :label="TEXT.common.fields.sponsor">{{ project.sponsor || TEXT.common.fallback }}</el-descriptions-item> <el-form-item :label="TEXT.common.fields.projectName" prop="name">
<el-descriptions-item :label="TEXT.common.fields.status">{{ statusLabel(project.status) }}</el-descriptions-item> <el-input v-model="form.name" :disabled="!isEditing" />
<el-descriptions-item :label="TEXT.modules.adminProjects.createdAt">{{ displayDateTime(project.created_at) }}</el-descriptions-item> </el-form-item>
</el-descriptions> </el-col>
<StateLoading v-else :rows="4" /> <el-col :span="8">
</el-card> <el-form-item :label="TEXT.common.fields.projectCode" prop="code">
<el-input v-model="form.code" :disabled="!isEditing" />
</el-form-item>
</el-col>
</el-row>
<el-card class="ctms-section-card"> <el-row :gutter="24">
<template #header> <el-col :span="8">
<div> <el-form-item :label="TEXT.common.fields.status" prop="status">
<div class="ctms-section-title">{{ TEXT.modules.adminProjects.filesTitle }}</div> <el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" :disabled="!isEditing" class="w-full">
</div> <el-option :label="TEXT.enums.projectStatus.DRAFT" value="DRAFT" />
</template> <el-option :label="TEXT.enums.projectStatus.ACTIVE" value="ACTIVE" />
<AttachmentList <el-option :label="TEXT.enums.projectStatus.CLOSED" value="CLOSED" />
v-if="project" </el-select>
:study-id="project.id" </el-form-item>
entity-type="project" </el-col>
:entity-id="project.id" <el-col :span="8">
:show-uploader="true" <el-form-item :label="TEXT.common.fields.protocolNo" prop="protocol_no">
/> <el-input v-model="form.protocol_no" :placeholder="TEXT.modules.adminProjects.protocolPlaceholder" :disabled="!isEditing" />
<StateLoading v-else :rows="3" /> </el-form-item>
</el-col>
<el-col :span="8">
<el-form-item :label="TEXT.common.fields.phase" prop="phase">
<el-input v-model="form.phase" :placeholder="TEXT.modules.adminProjects.phasePlaceholder" :disabled="!isEditing" />
</el-form-item>
</el-col>
</el-row>
<el-divider content-position="left" class="form-divider">{{ TEXT.modules.adminProjects.visitTemplate }}</el-divider>
<el-row :gutter="24">
<el-col :span="6">
<el-form-item :label="TEXT.common.fields.visitTotal">
<el-input-number v-model="form.visit_total" :min="1" :max="50" :step="1" :disabled="!isEditing" class="w-full" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item :label="TEXT.common.fields.visitIntervalDays">
<el-input-number v-model="form.visit_interval_days" :min="1" :max="365" :step="1" :disabled="!isEditing" class="w-full" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item :label="TEXT.common.fields.visitWindowStartOffset">
<el-input-number v-model="form.visit_window_start_offset" :min="-365" :max="365" :step="1" :disabled="!isEditing" class="w-full" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item :label="TEXT.common.fields.visitWindowEndOffset">
<el-input-number v-model="form.visit_window_end_offset" :min="-365" :max="365" :step="1" :disabled="!isEditing" class="w-full" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24" class="mt-4">
<el-col :span="24">
<el-form-item :label="TEXT.modules.adminProjects.createdAt">
<span class="info-text">{{ displayDateTime(project.created_at) }}</span>
</el-form-item>
</el-col>
</el-row>
</el-form>
<StateLoading v-else :rows="4" />
</el-card> </el-card>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from "vue"; import { onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import { fetchStudyDetail } from "../../api/studies"; import { ArrowLeft, Promotion, User, OfficeBuilding, Delete, Lock } from "@element-plus/icons-vue";
import { fetchStudyDetail, updateStudy, deleteStudy, lockStudy } from "../../api/studies";
import type { Study } from "../../types/api"; import type { Study } from "../../types/api";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateLoading from "../../components/StateLoading.vue"; import StateLoading from "../../components/StateLoading.vue";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { displayDateTime } from "../../utils/display"; import { displayDateTime } from "../../utils/display";
import { TEXT } from "../../locales"; import { TEXT, requiredMessage } from "../../locales";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const studyStore = useStudyStore(); const studyStore = useStudyStore();
const project = ref<Study | null>(null); const project = ref<Study | null>(null);
const isEditing = ref(false);
const submitting = ref(false);
const formRef = ref<FormInstance>();
const form = ref({
code: "",
name: "",
protocol_no: "",
phase: "",
status: "DRAFT",
visit_interval_days: null as number | null,
visit_total: null as number | null,
visit_window_start_offset: null as number | null,
visit_window_end_offset: null as number | null,
});
const rules = ref<FormRules>({
code: [{ required: true, message: requiredMessage(TEXT.common.fields.projectCode), trigger: "blur" }],
name: [{ required: true, message: requiredMessage(TEXT.common.fields.projectName), trigger: "blur" }],
status: [{ required: true, message: requiredMessage(TEXT.common.fields.status), trigger: "change" }],
});
const syncForm = (data: Study | null) => {
form.value.code = data?.code || "";
form.value.name = data?.name || "";
form.value.protocol_no = data?.protocol_no || "";
form.value.phase = data?.phase || "";
form.value.status = data?.status || "DRAFT";
form.value.visit_interval_days = data?.visit_interval_days ?? null;
form.value.visit_total = data?.visit_total ?? null;
form.value.visit_window_start_offset = data?.visit_window_start_offset ?? null;
form.value.visit_window_end_offset = data?.visit_window_end_offset ?? null;
};
const loadProject = async () => { const loadProject = async () => {
try { try {
const { data } = await fetchStudyDetail(route.params.projectId as string); const { data } = await fetchStudyDetail(route.params.projectId as string);
project.value = data as Study; project.value = data as Study;
syncForm(project.value);
isEditing.value = false;
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjects.loadFailed); ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjects.loadFailed);
} }
}; };
const statusLabel = (status: string) => const startEdit = () => {
TEXT.enums.projectStatus[status as keyof typeof TEXT.enums.projectStatus] || status; if (!project.value) return;
if (project.value.is_locked) {
ElMessage.warning("项目已锁定,无法编辑。请先解锁项目。");
return;
}
isEditing.value = true;
};
const cancelEdit = () => {
syncForm(project.value);
isEditing.value = false;
};
const saveEdit = async () => {
if (!project.value || !formRef.value) return;
await formRef.value.validate();
submitting.value = true;
try {
const { data } = await updateStudy(project.value.id, {
code: form.value.code,
name: form.value.name,
protocol_no: form.value.protocol_no,
phase: form.value.phase,
status: form.value.status,
visit_interval_days: form.value.visit_interval_days,
visit_total: form.value.visit_total,
visit_window_start_offset: form.value.visit_window_start_offset,
visit_window_end_offset: form.value.visit_window_end_offset,
});
project.value = data as Study;
syncForm(project.value);
isEditing.value = false;
ElMessage.success(TEXT.modules.adminProjects.updateSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
submitting.value = false;
}
};
const handleDelete = async () => {
if (!project.value) return;
try {
//
await ElMessageBox.confirm(
`确定要删除项目"${project.value.name}"吗?该操作将永久删除项目及其所有关联数据(成员、站点等),且不可恢复!`,
TEXT.common.actions.delete,
{
confirmButtonText: "继续",
cancelButtonText: TEXT.common.actions.cancel,
type: "error",
}
);
//
const { value } = await ElMessageBox.prompt(
`请输入项目名称 "${project.value.name}" 以确认删除`,
"删除确认",
{
confirmButtonText: TEXT.common.actions.confirm,
cancelButtonText: TEXT.common.actions.cancel,
inputPattern: new RegExp(`^${project.value.name.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}$`),
inputErrorMessage: "项目名称不匹配",
inputPlaceholder: "请输入项目名称",
type: "error",
}
);
if (value !== project.value.name) {
ElMessage.warning("项目名称不匹配,已取消删除");
return;
}
await deleteStudy(project.value.id);
ElMessage.success("项目已删除");
router.push("/admin/projects");
} catch (err) {
if (err !== "cancel") {
ElMessage.error("删除项目失败");
}
}
};
const handleLock = async () => {
if (!project.value) return;
try {
await ElMessageBox.confirm(
`确定要锁定项目"${project.value.name}"吗?锁定后项目内数据将无法编辑,且不可解锁!`,
"锁定项目",
{
confirmButtonText: TEXT.common.actions.confirm,
cancelButtonText: TEXT.common.actions.cancel,
type: "warning",
}
);
const { data } = await lockStudy(project.value.id);
project.value = data as Study;
syncForm(project.value);
ElMessage.success("项目已锁定");
} catch (err) {
if (err !== "cancel") {
ElMessage.error("锁定项目失败");
}
}
};
const goBack = () => {
router.back();
};
const enterProject = () => { const enterProject = () => {
if (!project.value) return; if (!project.value) return;
@@ -97,7 +338,169 @@ onMounted(() => {
</script> </script>
<style scoped> <style scoped>
.detail-descriptions :deep(.el-descriptions__label) { .page-header-section {
color: var(--ctms-text-secondary); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 32px 40px;
border-radius: 16px;
margin-bottom: 24px;
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.25);
}
.page-title {
color: white;
font-size: 28px;
font-weight: 600;
margin: 0;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.header-top {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
}
.quick-actions {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.action-card {
flex: 1;
min-width: 200px;
background: rgba(255, 255, 255, 0.95);
border-radius: 12px;
padding: 20px 24px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border: 2px solid transparent;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
position: relative;
overflow: hidden;
}
.action-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%);
opacity: 0;
transition: opacity 0.3s ease;
}
.action-card:hover:not(.disabled) {
transform: translateY(-4px) scale(1.02);
box-shadow: 0 8px 24px rgba(247, 107, 28, 0.4);
border-color: #f76b1c;
}
.action-card:hover:not(.disabled)::before {
opacity: 1;
}
.action-card.primary {
background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%);
box-shadow: 0 2px 8px rgba(252, 182, 159, 0.3);
}
.action-card.primary::before {
background: linear-gradient(135deg, rgba(255, 107, 28, 0.15) 0%, rgba(252, 107, 79, 0.15) 100%);
}
.action-card.primary:hover:not(.disabled) {
transform: translateY(-4px) scale(1.02);
box-shadow: 0 8px 24px rgba(247, 107, 28, 0.4);
border-color: #f76b1c;
background: linear-gradient(135deg, #fff0e0 0%, #ffd0b8 100%);
}
.action-card.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.action-icon {
font-size: 28px;
color: #667eea;
transition: all 0.3s ease;
position: relative;
z-index: 1;
}
.action-card:hover:not(.disabled) .action-icon {
transform: scale(1.15);
color: #f76b1c;
}
.action-card.primary .action-icon {
color: #f76b1c;
}
.action-card.primary:hover:not(.disabled) .action-icon {
color: #e05a0a;
transform: scale(1.15) rotate(5deg);
}
.action-text {
font-size: 16px;
font-weight: 500;
color: #2d3748;
transition: color 0.3s ease;
position: relative;
z-index: 1;
text-align: center;
}
.action-card:hover:not(.disabled) .action-text {
color: #1a202c;
font-weight: 600;
}
.ctms-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.ctms-section-actions {
display: flex;
align-items: center;
gap: 8px;
}
.detail-form :deep(.el-form-item) {
margin-bottom: 0; /* Let rows handle internal spacing if needed, or keep minimal */
}
.w-full {
width: 100%;
}
.form-divider {
margin: 32px 0 24px;
}
.mt-4 {
margin-top: 16px;
}
.info-text {
color: #6b7280;
font-size: 14px;
}
/* Ensure inputs are full width in grid */
.detail-form :deep(.el-input),
.detail-form :deep(.el-select),
.detail-form :deep(.el-input-number) {
width: 100%;
} }
</style> </style>
-16
View File
@@ -1,15 +1,9 @@
<template> <template>
<el-dialog :title="project ? TEXT.modules.adminProjects.editTitle : TEXT.modules.adminProjects.newTitle" width="560px" v-model="visibleProxy" :close-on-click-modal="false"> <el-dialog :title="project ? TEXT.modules.adminProjects.editTitle : TEXT.modules.adminProjects.newTitle" width="560px" v-model="visibleProxy" :close-on-click-modal="false">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px"> <el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form-item :label="TEXT.common.fields.projectCode" prop="code">
<el-input v-model="form.code" :disabled="!!project" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.projectCode" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.projectName" prop="name"> <el-form-item :label="TEXT.common.fields.projectName" prop="name">
<el-input v-model="form.name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.projectName" /> <el-input v-model="form.name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.projectName" />
</el-form-item> </el-form-item>
<el-form-item :label="TEXT.common.fields.sponsor" prop="sponsor">
<el-input v-model="form.sponsor" :placeholder="TEXT.modules.adminProjects.sponsorPlaceholder" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.protocolNo" prop="protocol_no"> <el-form-item :label="TEXT.common.fields.protocolNo" prop="protocol_no">
<el-input v-model="form.protocol_no" :placeholder="TEXT.modules.adminProjects.protocolPlaceholder" /> <el-input v-model="form.protocol_no" :placeholder="TEXT.modules.adminProjects.protocolPlaceholder" />
</el-form-item> </el-form-item>
@@ -69,9 +63,7 @@ const visibleProxy = computed({
const formRef = ref<FormInstance>(); const formRef = ref<FormInstance>();
const submitting = ref(false); const submitting = ref(false);
const form = reactive({ const form = reactive({
code: "",
name: "", name: "",
sponsor: "",
protocol_no: "", protocol_no: "",
phase: "", phase: "",
status: "DRAFT", status: "DRAFT",
@@ -82,15 +74,12 @@ const form = reactive({
}); });
const rules = reactive<FormRules>({ const rules = reactive<FormRules>({
code: [{ required: true, message: requiredMessage(TEXT.common.fields.projectCode), trigger: "blur" }],
name: [{ required: true, message: requiredMessage(TEXT.common.fields.projectName), trigger: "blur" }], name: [{ required: true, message: requiredMessage(TEXT.common.fields.projectName), trigger: "blur" }],
status: [{ required: true, message: requiredMessage(TEXT.common.fields.status), trigger: "change" }], status: [{ required: true, message: requiredMessage(TEXT.common.fields.status), trigger: "change" }],
}); });
const resetForm = () => { const resetForm = () => {
form.code = "";
form.name = ""; form.name = "";
form.sponsor = "";
form.protocol_no = ""; form.protocol_no = "";
form.phase = ""; form.phase = "";
form.status = "DRAFT"; form.status = "DRAFT";
@@ -106,9 +95,7 @@ watch(
if (val) { if (val) {
resetForm(); resetForm();
if (props.project) { if (props.project) {
form.code = props.project.code;
form.name = props.project.name; form.name = props.project.name;
form.sponsor = props.project.sponsor || "";
form.protocol_no = props.project.protocol_no || ""; form.protocol_no = props.project.protocol_no || "";
form.phase = props.project.phase || ""; form.phase = props.project.phase || "";
form.status = props.project.status || "DRAFT"; form.status = props.project.status || "DRAFT";
@@ -129,7 +116,6 @@ const onSubmit = async () => {
if (props.project) { if (props.project) {
await updateStudy(props.project.id, { await updateStudy(props.project.id, {
name: form.name, name: form.name,
sponsor: form.sponsor,
protocol_no: form.protocol_no, protocol_no: form.protocol_no,
phase: form.phase, phase: form.phase,
status: form.status, status: form.status,
@@ -141,9 +127,7 @@ const onSubmit = async () => {
ElMessage.success(TEXT.modules.adminProjects.updateSuccess); ElMessage.success(TEXT.modules.adminProjects.updateSuccess);
} else { } else {
await createStudy({ await createStudy({
code: form.code,
name: form.name, name: form.name,
sponsor: form.sponsor,
protocol_no: form.protocol_no, protocol_no: form.protocol_no,
phase: form.phase, phase: form.phase,
status: form.status, status: form.status,
+6 -4
View File
@@ -5,7 +5,6 @@
<div> <div>
<h3>{{ TEXT.modules.adminProjectMembers.title }}</h3> <h3>{{ TEXT.modules.adminProjectMembers.title }}</h3>
<div class="sub" v-if="project">{{ TEXT.modules.adminProjectMembers.projectPrefix }}{{ project.code }} - {{ project.name }}</div> <div class="sub" v-if="project">{{ TEXT.modules.adminProjectMembers.projectPrefix }}{{ project.code }} - {{ project.name }}</div>
<div class="sub">{{ TEXT.modules.adminProjectMembers.subtitle }}</div>
</div> </div>
<el-button type="primary" @click="openAdd">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjectMembers.memberLabel }}</el-button> <el-button type="primary" @click="openAdd">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjectMembers.memberLabel }}</el-button>
</div> </div>
@@ -39,7 +38,7 @@
v-model="scope.row.role_in_study" v-model="scope.row.role_in_study"
size="small" size="small"
style="width: 120px" style="width: 120px"
@change="(val) => updateRole(scope.row.id, val)" @change="(val: string) => updateRole(scope.row.id, val)"
:disabled="!scope.row.is_active || scope.row.effectiveStatus === 'DISABLED_GLOBAL'" :disabled="!scope.row.is_active || scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
> >
<el-option :label="TEXT.enums.userRole.PM" value="PM" /> <el-option :label="TEXT.enums.userRole.PM" value="PM" />
@@ -144,7 +143,7 @@ const loadMembers = async () => {
if (!projectId.value) return; if (!projectId.value) return;
loading.value = true; loading.value = true;
try { try {
const { data } = await listMembers(projectId.value, { limit: 500 }); const { data } = await listMembers(projectId.value, { limit: 500, include_inactive: true });
members.value = Array.isArray(data) ? data : data.items || []; members.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjectMembers.loadFailed); ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjectMembers.loadFailed);
@@ -382,7 +381,10 @@ const onDelete = async (row: StudyMember) => {
} }
}; };
const availableUsers = computed(() => users.value); const availableUsers = computed(() => {
const memberUserIds = new Set(members.value.map((m) => m.user_id));
return users.value.filter((u) => !memberUserIds.has(u.id));
});
onMounted(async () => { onMounted(async () => {
await Promise.all([loadProject(), loadUsers()]); await Promise.all([loadProject(), loadUsers()]);
+162 -27
View File
@@ -4,36 +4,54 @@
<div class="header"> <div class="header">
<div> <div>
<h3>{{ TEXT.modules.adminProjects.title }}</h3> <h3>{{ TEXT.modules.adminProjects.title }}</h3>
<p class="sub">{{ TEXT.modules.adminProjects.subtitle }}</p>
</div> </div>
<el-button type="primary" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjects.projectLabel }}</el-button> <el-button type="primary" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjects.projectLabel }}</el-button>
</div> </div>
<el-table :data="projects" v-loading="loading" stripe> <el-table :data="projects" v-loading="loading" stripe>
<el-table-column prop="code" :label="TEXT.modules.adminProjects.projectCode" width="140"> <el-table-column prop="name" :label="TEXT.common.fields.projectName" min-width="300">
<template #default="scope"> <template #default="scope">
<el-link type="primary" @click="goDetail(scope.row)">{{ scope.row.code }}</el-link> <el-link type="primary" @click="goDetail(scope.row)" class="font-medium">{{ scope.row.name }}</el-link>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="name" :label="TEXT.common.fields.projectName" min-width="200"> <el-table-column prop="status" :label="TEXT.common.fields.status" min-width="120" align="center">
<template #default="scope"> <template #default="scope">
<el-link type="primary" @click="goDetail(scope.row)">{{ scope.row.name }}</el-link> <el-tag :type="statusTag(scope.row.status)" effect="plain" round>{{ statusLabel(scope.row.status) }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="sponsor" :label="TEXT.common.fields.sponsor" min-width="160" /> <el-table-column prop="is_locked" label="锁定状态" min-width="100" align="center">
<el-table-column prop="status" :label="TEXT.common.fields.status" width="120">
<template #default="scope"> <template #default="scope">
<el-tag :type="statusTag(scope.row.status)">{{ statusLabel(scope.row.status) }}</el-tag> <el-tag v-if="scope.row.is_locked" type="warning" effect="plain" round>已锁定</el-tag>
<el-tag v-else type="success" effect="plain" round>未锁定</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="created_at" :label="TEXT.modules.adminUsers.createdAt" width="200"> <el-table-column prop="created_at" :label="TEXT.modules.adminUsers.createdAt" min-width="180" align="center">
<template #default="scope">{{ displayDateTime(scope.row.created_at) }}</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="320" fixed="right">
<template #default="scope"> <template #default="scope">
<el-button link type="primary" size="small" @click="openEdit(scope.row)">{{ TEXT.common.actions.edit }}</el-button> <span class="text-gray-500">{{ displayDateTime(scope.row.created_at) }}</span>
<el-button link type="primary" size="small" @click="goMembers(scope.row)">{{ TEXT.modules.adminProjects.members }}</el-button> </template>
<el-button link type="primary" size="small" @click="goSites(scope.row)">{{ TEXT.modules.adminProjects.sites }}</el-button> </el-table-column>
<el-button link type="success" size="small" @click="enterStudy(scope.row)">{{ TEXT.modules.adminProjects.enter }}</el-button> <el-table-column :label="TEXT.common.labels.actions" min-width="280" align="center">
<template #default="scope">
<el-tooltip :content="TEXT.modules.adminProjects.members" placement="top">
<el-button link type="primary" :icon="User" class="action-btn" @click="goMembers(scope.row)" />
</el-tooltip>
<el-tooltip :content="TEXT.modules.adminProjects.sites" placement="top">
<el-button link type="primary" :icon="OfficeBuilding" class="action-btn" @click="goSites(scope.row)" />
</el-tooltip>
<el-tooltip :content="TEXT.modules.adminProjects.enter" placement="top">
<el-button link type="success" :icon="ArrowRight" class="action-btn enter-btn" @click="enterStudy(scope.row)" />
</el-tooltip>
<el-tooltip v-if="!scope.row.is_locked" content="锁定项目" placement="top">
<el-button
link
type="info"
:icon="Lock"
class="action-btn"
@click="handleLockToggle(scope.row)"
/>
</el-tooltip>
<el-tooltip :content="TEXT.common.actions.delete" placement="top">
<el-button link type="danger" :icon="Delete" class="action-btn" @click="handleDelete(scope.row)" />
</el-tooltip>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -45,8 +63,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from "vue"; import { onMounted, ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { fetchStudies } from "../../api/studies"; import { User, OfficeBuilding, ArrowRight, Delete, Lock } from "@element-plus/icons-vue";
import { fetchStudies, deleteStudy, lockStudy } from "../../api/studies";
import type { Study } from "../../types/api"; import type { Study } from "../../types/api";
import ProjectForm from "./ProjectForm.vue"; import ProjectForm from "./ProjectForm.vue";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
@@ -78,11 +97,6 @@ const openCreate = () => {
formVisible.value = true; formVisible.value = true;
}; };
const openEdit = (row: Study) => {
editingProject.value = row;
formVisible.value = true;
};
const goMembers = (row: Study) => { const goMembers = (row: Study) => {
router.push(`/admin/projects/${row.id}/members`); router.push(`/admin/projects/${row.id}/members`);
}; };
@@ -91,6 +105,79 @@ const goSites = (row: Study) => {
router.push(`/admin/projects/${row.id}/sites`); router.push(`/admin/projects/${row.id}/sites`);
}; };
const handleDelete = async (study: Study) => {
try {
//
await ElMessageBox.confirm(
`确定要删除项目\"${study.name}\"吗?该操作将永久删除项目及其所有关联数据(成员、站点等),且不可恢复!`,
TEXT.common.actions.delete,
{
confirmButtonText: "继续",
cancelButtonText: TEXT.common.actions.cancel,
type: "error",
dangerouslyUseHTMLString: false,
}
);
//
const { value } = await ElMessageBox.prompt(
`请输入项目名称 "${study.name}" 以确认删除`,
"删除确认",
{
confirmButtonText: TEXT.common.actions.confirm,
cancelButtonText: TEXT.common.actions.cancel,
inputPattern: new RegExp(`^${study.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`),
inputErrorMessage: "项目名称不匹配",
inputPlaceholder: "请输入项目名称",
type: "error",
}
);
if (value !== study.name) {
ElMessage.warning("项目名称不匹配,已取消删除");
return;
}
await deleteStudy(study.id);
ElMessage.success("项目已删除");
await loadProjects();
} catch (err: any) {
if (err !== "cancel") {
const errorMsg = err?.response?.data?.detail || err?.message || "删除项目失败";
console.error("删除项目错误:", err);
ElMessage.error(errorMsg);
}
}
};
const handleLockToggle = async (study: Study) => {
//
if (study.is_locked) {
ElMessage.warning("项目已锁定,不可解锁");
return;
}
try {
await ElMessageBox.confirm(
`确定要锁定项目\"${study.name}\"吗?锁定后项目内数据将无法编辑,且不可解锁!`,
"锁定项目",
{
confirmButtonText: TEXT.common.actions.confirm,
cancelButtonText: TEXT.common.actions.cancel,
type: "warning",
}
);
await lockStudy(study.id);
ElMessage.success("项目已锁定");
await loadProjects();
} catch (err) {
if (err !== "cancel") {
ElMessage.error("锁定项目失败");
}
}
};
const enterStudy = (row: Study) => { const enterStudy = (row: Study) => {
studyStore.setCurrentStudy({ ...row, role_in_study: "PM" } as Study); studyStore.setCurrentStudy({ ...row, role_in_study: "PM" } as Study);
router.push("/project/overview"); router.push("/project/overview");
@@ -117,16 +204,64 @@ onMounted(() => {
<style scoped> <style scoped>
.page { .page {
padding: 16px; padding: 24px;
} }
.header { .header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
margin-bottom: 12px; margin-bottom: 24px;
} }
.header h3 {
font-size: 20px;
font-weight: 600;
color: #1f2937;
margin: 0;
}
.sub { .sub {
color: #666; color: #6b7280;
margin-top: 4px; margin-top: 8px;
font-size: 14px;
}
/* 表格样式美化 */
:deep(.el-table) {
--el-table-header-bg-color: #f9fafb;
--el-table-header-text-color: #374151;
border-radius: 8px;
overflow: hidden;
}
:deep(.el-table th.el-table__cell) {
background-color: #f9fafb;
font-weight: 600;
height: 50px;
}
/* 增加单元格间距 */
:deep(.el-table .el-table__cell) {
padding: 16px 0;
}
/* 操作按钮美化 */
.action-btn {
font-size: 20px; /* 图标变更大 */
padding: 6px;
margin: 0 4px;
transition: all 0.3s;
}
.action-btn:hover {
transform: scale(1.1);
background-color: #f3f4f6;
border-radius: 4px;
}
.enter-btn {
font-size: 20px;
font-weight: bold;
} }
</style> </style>
+70 -9
View File
@@ -8,7 +8,7 @@
</div> </div>
<el-button type="primary" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminSites.siteLabel }}</el-button> <el-button type="primary" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminSites.siteLabel }}</el-button>
</div> </div>
<el-table :data="sites" v-loading="loading" stripe> <el-table :data="sites" v-loading="loading" stripe :row-class-name="siteRowClass">
<el-table-column prop="name" :label="TEXT.common.fields.siteName" min-width="180" /> <el-table-column prop="name" :label="TEXT.common.fields.siteName" min-width="180" />
<el-table-column prop="city" :label="TEXT.common.fields.city" width="140" /> <el-table-column prop="city" :label="TEXT.common.fields.city" width="140" />
<el-table-column prop="pi_name" :label="TEXT.modules.adminSites.piLabel" width="160" /> <el-table-column prop="pi_name" :label="TEXT.modules.adminSites.piLabel" width="160" />
@@ -24,11 +24,11 @@
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.common.fields.status" width="120"> <el-table-column :label="TEXT.common.fields.status" width="120">
<template #default="scope"> <template #default="scope">
<el-tag type="success" v-if="scope.row.is_active">{{ TEXT.common.actions.enable }}</el-tag> <el-tag type="success" v-if="scope.row.is_active">已解锁</el-tag>
<el-tag type="danger" v-else>{{ TEXT.common.actions.disable }}</el-tag> <el-tag type="danger" v-else>已锁定</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="220" fixed="right"> <el-table-column :label="TEXT.common.labels.actions" width="260" fixed="right">
<template #default="scope"> <template #default="scope">
<el-button link type="primary" size="small" @click="openEdit(scope.row)">{{ TEXT.common.actions.edit }}</el-button> <el-button link type="primary" size="small" @click="openEdit(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
<el-button <el-button
@@ -37,7 +37,16 @@
size="small" size="small"
@click="toggleSite(scope.row)" @click="toggleSite(scope.row)"
> >
{{ scope.row.is_active ? TEXT.common.actions.disable : TEXT.common.actions.enable }} {{ scope.row.is_active ? "锁定" : "解锁" }}
</el-button>
<el-button
v-if="isAdmin"
link
type="danger"
size="small"
@click="confirmDelete(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button> </el-button>
</template> </template>
</el-table-column> </el-table-column>
@@ -60,7 +69,7 @@ import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { fetchStudyDetail } from "../../api/studies"; import { fetchStudyDetail } from "../../api/studies";
import { fetchSites, updateSite } from "../../api/sites"; import { deleteSite, fetchSites, updateSite } from "../../api/sites";
import { fetchUsers } from "../../api/users"; import { fetchUsers } from "../../api/users";
import { listMembers } from "../../api/members"; import { listMembers } from "../../api/members";
import SiteForm from "./SiteForm.vue"; import SiteForm from "./SiteForm.vue";
@@ -81,6 +90,7 @@ const members = ref<any[]>([]);
const loading = ref(false); const loading = ref(false);
const formVisible = ref(false); const formVisible = ref(false);
const editingSite = ref<Site | null>(null); const editingSite = ref<Site | null>(null);
const isAdmin = computed(() => auth.user?.role === "ADMIN");
const loadProject = async () => { const loadProject = async () => {
if (!projectId.value) return; if (!projectId.value) return;
@@ -96,7 +106,7 @@ const loadSites = async () => {
if (!projectId.value) return; if (!projectId.value) return;
loading.value = true; loading.value = true;
try { try {
const { data } = await fetchSites(projectId.value); const { data } = await fetchSites(projectId.value, { include_inactive: true });
sites.value = Array.isArray(data) ? data : (data as any).items || []; sites.value = Array.isArray(data) ? data : (data as any).items || [];
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminSites.loadFailed); ElMessage.error(e?.response?.data?.message || TEXT.modules.adminSites.loadFailed);
@@ -147,6 +157,8 @@ const contactLabel = (row: any) => {
const phoneText = (row: any) => row?.phone || row?.contact_phone || TEXT.common.fallback; const phoneText = (row: any) => row?.phone || row?.contact_phone || TEXT.common.fallback;
const siteRowClass = ({ row }: { row: Site }) => (row.is_active ? "" : "row-inactive");
const openCreate = () => { const openCreate = () => {
loadUsers(); loadUsers();
loadMembers(); loadMembers();
@@ -179,15 +191,16 @@ const toggleSite = async (row: Site) => {
return; return;
} }
if (row.is_active) { if (row.is_active) {
const ok = await ElMessageBox.confirm(TEXT.modules.adminSites.disableConfirm, TEXT.modules.adminSites.disableTitle, { const ok = await ElMessageBox.confirm("锁定后该中心将不可编辑,但数据会保留,确认锁定?", "锁定中心", {
type: "warning", type: "warning",
confirmButtonText: TEXT.common.actions.confirm, confirmButtonText: TEXT.common.actions.confirm,
cancelButtonText: TEXT.common.actions.cancel,
}).catch(() => null); }).catch(() => null);
if (!ok) return; if (!ok) return;
} }
try { try {
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 ? TEXT.modules.adminSites.disableSuccess : TEXT.modules.adminSites.enableSuccess); ElMessage.success(row.is_active ? "已锁定" : "已解锁");
loadSites(); loadSites();
logAudit("SITE_STATUS_CHANGED", { logAudit("SITE_STATUS_CHANGED", {
targetId: row.id, targetId: row.id,
@@ -209,6 +222,42 @@ const toggleSite = async (row: Site) => {
} }
}; };
const confirmDelete = async (row: Site) => {
if (!projectId.value) return;
if (!isAdmin.value) {
ElMessage.warning("仅管理员可删除中心");
return;
}
const message = "删除该中心会删除中心所有数据,请谨慎操作。输入“确认删除”继续。";
const result = await ElMessageBox.prompt(message, "危险操作", {
type: "warning",
confirmButtonText: TEXT.common.actions.confirm,
cancelButtonText: TEXT.common.actions.cancel,
inputPlaceholder: "确认删除",
inputPattern: /^确认删除$/,
inputErrorMessage: "请输入“确认删除”",
}).catch(() => null);
if (!result) return;
try {
await deleteSite(projectId.value, row.id);
ElMessage.success(TEXT.common.messages.deleteSuccess);
loadSites();
logAudit("SITE_DELETED", {
targetId: row.id,
targetName: row.name,
severity: "high",
});
} catch (e: any) {
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,
});
}
};
onMounted(async () => { onMounted(async () => {
await Promise.all([loadProject(), loadUsers(), loadMembers()]); await Promise.all([loadProject(), loadUsers(), loadMembers()]);
loadSites(); loadSites();
@@ -229,4 +278,16 @@ onMounted(async () => {
color: #666; color: #666;
margin-top: 4px; margin-top: 4px;
} }
</style>
<style>
.row-inactive td {
color: var(--ctms-text-secondary);
background-color: #fff7d6 !important;
}
.row-inactive .el-tag {
opacity: 0.6;
}
</style> </style>
+100 -59
View File
@@ -4,7 +4,6 @@
<div class="header"> <div class="header">
<div> <div>
<h3>{{ TEXT.modules.adminUsers.title }}</h3> <h3>{{ TEXT.modules.adminUsers.title }}</h3>
<p class="sub">{{ TEXT.modules.adminUsers.subtitle }}</p>
</div> </div>
<el-button type="primary" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminUsers.userLabel }}</el-button> <el-button type="primary" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminUsers.userLabel }}</el-button>
</div> </div>
@@ -22,28 +21,32 @@
<el-table-column prop="created_at" :label="TEXT.modules.adminUsers.createdAt" width="200"> <el-table-column prop="created_at" :label="TEXT.modules.adminUsers.createdAt" width="200">
<template #default="scope">{{ displayDateTime(scope.row.created_at) }}</template> <template #default="scope">{{ displayDateTime(scope.row.created_at) }}</template>
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="320" fixed="right"> <el-table-column :label="TEXT.common.labels.actions" min-width="200" align="center">
<template #default="scope"> <template #default="scope">
<el-button link type="primary" size="small" @click="openEdit(scope.row)">{{ TEXT.common.actions.edit }}</el-button> <el-tooltip :content="TEXT.common.actions.edit" placement="top">
<el-button <el-button link type="primary" :icon="Edit" @click="openEdit(scope.row)" />
link </el-tooltip>
:type="scope.row.status === 'ACTIVE' ? 'danger' : 'primary'" <el-tooltip :content="scope.row.status === 'ACTIVE' ? TEXT.common.actions.disable : TEXT.common.actions.enable" placement="top">
size="small" <el-button
:disabled="isLastAdmin(scope.row)" link
@click="toggleStatus(scope.row)" :type="scope.row.status === 'ACTIVE' ? 'warning' : 'success'"
> :icon="scope.row.status === 'ACTIVE' ? Lock : Unlock"
{{ scope.row.status === 'ACTIVE' ? TEXT.common.actions.disable : TEXT.common.actions.enable }} :disabled="isLastAdmin(scope.row)"
</el-button> @click="toggleStatus(scope.row)"
<el-button link type="danger" size="small" @click="openReset(scope.row)">{{ TEXT.modules.adminUsers.resetPassword }}</el-button> />
<el-button </el-tooltip>
link <el-tooltip :content="TEXT.modules.adminUsers.resetPassword" placement="top">
type="danger" <el-button link type="warning" :icon="Key" @click="openReset(scope.row)" />
size="small" </el-tooltip>
:disabled="scope.row.id === auth.user?.id" <el-tooltip :content="TEXT.common.actions.delete" placement="top">
@click="onDelete(scope.row)" <el-button
> link
{{ TEXT.common.actions.delete }} type="danger"
</el-button> :icon="Delete"
:disabled="scope.row.id === auth.user?.id"
@click="onDelete(scope.row)"
/>
</el-tooltip>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -66,7 +69,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from "vue"; import { onMounted, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { Edit, Delete, Key, Lock, Unlock } from "@element-plus/icons-vue";
import { fetchUsers, updateUser, deleteUser } from "../../api/users"; import { fetchUsers, updateUser, deleteUser } from "../../api/users";
import { fetchStudies } from "../../api/studies";
import { listMembers } from "../../api/members";
import type { UserInfo } from "../../types/api"; import type { UserInfo } from "../../types/api";
import UserForm from "./UserForm.vue"; import UserForm from "./UserForm.vue";
import UserResetPassword from "./UserResetPassword.vue"; import UserResetPassword from "./UserResetPassword.vue";
@@ -86,6 +92,37 @@ const editingUser = ref<UserInfo | null>(null);
const resetUser = ref<UserInfo | null>(null); const resetUser = ref<UserInfo | null>(null);
const auth = useAuthStore(); const auth = useAuthStore();
const statusType = (status: string) => {
switch (status) {
case "ACTIVE":
return "success";
case "PENDING":
return "warning";
case "REJECTED":
return "danger";
default:
return "info";
}
};
const statusLabel = (status: string) => {
switch (status) {
case "ACTIVE":
return TEXT.enums.userStatus.ACTIVE;
case "PENDING":
return TEXT.enums.userStatus.PENDING;
case "REJECTED":
return TEXT.enums.userStatus.REJECTED;
case "DISABLED":
return TEXT.enums.userStatus.DISABLED;
default:
return status || TEXT.common.fallback;
}
};
const isLastAdmin = (row: UserInfo) =>
row.role === "ADMIN" && row.status === "ACTIVE" && activeAdminCount.value <= 1;
const loadUsers = async () => { const loadUsers = async () => {
loading.value = true; loading.value = true;
try { try {
@@ -146,67 +183,71 @@ const openReset = (row: UserInfo) => {
}; };
const onDelete = async (row: UserInfo) => { const onDelete = async (row: UserInfo) => {
const { value, action } = await ElMessageBox.prompt( const { action } = await ElMessageBox.prompt(
TEXT.modules.adminUsers.deleteConfirm, TEXT.modules.adminUsers.deleteConfirm,
TEXT.modules.adminUsers.deleteTitle, TEXT.modules.adminUsers.deleteTitle,
{ {
inputType: "password", inputPlaceholder: TEXT.modules.adminUsers.deleteConfirmPlaceholder,
inputPlaceholder: TEXT.modules.adminUsers.deletePasswordPlaceholder, confirmButtonText: TEXT.common.actions.confirm,
confirmButtonText: TEXT.modules.adminUsers.deleteConfirmButton,
confirmButtonClass: "el-button--danger", confirmButtonClass: "el-button--danger",
cancelButtonText: TEXT.common.actions.cancel, cancelButtonText: TEXT.common.actions.cancel,
inputPattern: /^确认删除$/,
inputErrorMessage: TEXT.modules.adminUsers.deleteConfirmInputError,
} }
).catch(() => ({ action: "cancel", value: "" })); ).catch(() => ({ action: "cancel" }));
if (action !== "confirm" || !value) return; if (action !== "confirm") return;
try { try {
await deleteUser(row.id, { admin_password: value }); await deleteUser(row.id);
ElMessage.success(TEXT.modules.adminUsers.deleteSuccess); ElMessage.success(TEXT.modules.adminUsers.deleteSuccess);
loadUsers(); loadUsers();
} catch (e: any) { } catch (e: any) {
const detail = e?.response?.data?.detail || e?.response?.data?.message; const detail = e?.response?.data?.detail || e?.response?.data?.message;
if (detail && detail.includes(TEXT.modules.adminProjectMembers.memberLabel)) { if (detail && detail.includes(TEXT.modules.adminProjectMembers.memberLabel)) {
ElMessage.closeAll(); ElMessage.closeAll();
ElMessage.error(detail); const blockingProjects = await findUserProjects(row.id);
if (blockingProjects.length > 0) {
ElMessage.error(`${detail},该用户仍在以下项目中:${blockingProjects.join("、")}`);
} else {
ElMessage.error(detail);
}
} else { } else {
ElMessage.error(detail || TEXT.modules.adminUsers.deleteFailed); ElMessage.error(detail || TEXT.modules.adminUsers.deleteFailed);
} }
} }
}; };
const findUserProjects = async (userId: string) => {
try {
const { data } = await fetchStudies();
const studies = Array.isArray(data) ? data : (data as any).items || [];
const blocking: string[] = [];
// Check each study for membership
// We run these in parallel but limited batch might be better if many studies.
// For now assuming reasonable number of studies.
await Promise.all(studies.map(async (study: any) => {
try {
const { data: membersData } = await listMembers(study.id, { limit: 1000, include_inactive: true }); // Large limit to catch all
const members = Array.isArray(membersData) ? membersData : (membersData as any).items || [];
if (members.some((m: any) => m.user_id === userId)) {
blocking.push(study.name);
}
} catch {
// Ignore errors checking specific study
}
}));
return blocking;
} catch {
return [];
}
};
onMounted(() => { onMounted(() => {
loadUsers(); loadUsers();
}); });
const statusType = (status: string) => {
switch (status) {
case "ACTIVE":
return "success";
case "PENDING":
return "warning";
case "REJECTED":
return "danger";
default:
return "info";
}
};
const statusLabel = (status: string) => {
switch (status) {
case "ACTIVE":
return TEXT.enums.userStatus.ACTIVE;
case "PENDING":
return TEXT.enums.userStatus.PENDING;
case "REJECTED":
return TEXT.enums.userStatus.REJECTED;
case "DISABLED":
return TEXT.enums.userStatus.DISABLED;
default:
return status || TEXT.common.fallback;
}
};
const isLastAdmin = (row: UserInfo) =>
row.role === "ADMIN" && row.status === "ACTIVE" && activeAdminCount.value <= 1;
</script> </script>
<style scoped> <style scoped>
@@ -10,6 +10,7 @@
<p class="ctms-page-subtitle"> <p class="ctms-page-subtitle">
<el-tag size="small" effect="plain" type="info" class="mr-2">{{ displayText(detail.doc_type, TEXT.enums.documentType) }}</el-tag> <el-tag size="small" effect="plain" type="info" class="mr-2">{{ displayText(detail.doc_type, TEXT.enums.documentType) }}</el-tag>
<el-tag size="small" effect="plain" type="warning" class="mr-2">{{ displaySite(detail.site_id) }}</el-tag> <el-tag size="small" effect="plain" type="warning" class="mr-2">{{ displaySite(detail.site_id) }}</el-tag>
<el-tag v-if="isReadOnly" size="small" effect="plain" type="info">中心已停用</el-tag>
<span class="text-secondary text-sm"> <span class="text-secondary text-sm">
{{ TEXT.modules.fileVersionManagement.labels.currentEffective }} {{ TEXT.modules.fileVersionManagement.labels.currentEffective }}
<span class="font-medium text-main">{{ detail.current_effective_version?.version_no || TEXT.common.fallback }}</span> <span class="font-medium text-main">{{ detail.current_effective_version?.version_no || TEXT.common.fallback }}</span>
@@ -373,6 +374,15 @@ const sites = ref<Site[]>([]);
const members = ref<StudyMember[]>([]); const members = ref<StudyMember[]>([]);
const workflowTemplates = ref<WorkflowTemplate[]>([]); const workflowTemplates = ref<WorkflowTemplate[]>([]);
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.id) map[site.id] = !!site.is_active;
});
return map;
});
const isReadOnly = computed(() => !!detail.site_id && siteActiveMap.value[detail.site_id] === false);
const userMap = computed(() => const userMap = computed(() =>
users.value.reduce<Record<string, string>>((acc, user) => { users.value.reduce<Record<string, string>>((acc, user) => {
acc[user.id] = getUserDisplayName(user) || user.email || user.username || user.id; acc[user.id] = getUserDisplayName(user) || user.email || user.username || user.id;
@@ -468,6 +478,7 @@ const createTarget = () => ({
}); });
const canAction = (action: string) => { const canAction = (action: string) => {
if (isReadOnly.value && action !== "view") return false;
const list = detail.can_actions || []; const list = detail.can_actions || [];
if (list.length === 0) return true; if (list.length === 0) return true;
return list.includes(action); return list.includes(action);
+40 -5
View File
@@ -3,7 +3,6 @@
<div class="ctms-page-header"> <div class="ctms-page-header">
<div> <div>
<h1 class="ctms-page-title">{{ TEXT.modules.fileVersionManagement.title }}</h1> <h1 class="ctms-page-title">{{ TEXT.modules.fileVersionManagement.title }}</h1>
<p class="page-subtitle">{{ TEXT.modules.fileVersionManagement.subtitle }}</p>
</div> </div>
<div class="ctms-page-actions"> <div class="ctms-page-actions">
<el-button type="primary" @click="openCreate"> <el-button type="primary" @click="openCreate">
@@ -48,7 +47,13 @@
</el-card> </el-card>
<el-card class="ctms-table-card"> <el-card class="ctms-table-card">
<el-table :data="items" v-loading="loading" @row-click="handleRowClick" row-class-name="clickable-row" class="ctms-table"> <el-table
:data="sortedItems"
v-loading="loading"
@row-click="handleRowClick"
:row-class-name="documentRowClass"
class="ctms-table"
>
<el-table-column prop="doc_no" :label="TEXT.modules.fileVersionManagement.columns.docNo" width="160"> <el-table-column prop="doc_no" :label="TEXT.modules.fileVersionManagement.columns.docNo" width="160">
<template #default="{ row }"> <template #default="{ row }">
<span class="font-mono font-medium">{{ row.doc_no }}</span> <span class="font-mono font-medium">{{ row.doc_no }}</span>
@@ -104,7 +109,7 @@
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="100" fixed="right"> <el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="100" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="danger" @click.stop="confirmDelete(row)"> <el-button link type="danger" :disabled="isInactiveSite(row.site_id)" @click.stop="confirmDelete(row)">
{{ TEXT.common.actions.delete }} {{ TEXT.common.actions.delete }}
</el-button> </el-button>
</template> </template>
@@ -130,7 +135,7 @@
</el-form-item> </el-form-item>
<el-form-item v-if="createForm.scope_type === 'SITE'" :label="TEXT.modules.fileVersionManagement.fields.site" prop="site_id"> <el-form-item v-if="createForm.scope_type === 'SITE'" :label="TEXT.modules.fileVersionManagement.fields.site" prop="site_id">
<el-select v-model="createForm.site_id" filterable style="width: 100%"> <el-select v-model="createForm.site_id" filterable style="width: 100%">
<el-option v-for="item in siteOptions" :key="item.value" :label="item.label" :value="item.value" /> <el-option v-for="item in siteOptions" :key="item.value" :label="item.label" :value="item.value" :disabled="item.disabled" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.docType" prop="doc_type"> <el-form-item :label="TEXT.modules.fileVersionManagement.fields.docType" prop="doc_type">
@@ -208,7 +213,20 @@ const scopeOptions = [
{ value: "SITE", label: TEXT.enums.scopeType.SITE }, { value: "SITE", label: TEXT.enums.scopeType.SITE },
]; ];
const siteOptions = computed(() => const siteOptions = computed(() =>
sites.value.map((site) => ({ value: site.id, label: site.name })) sites.value.map((site) => ({ value: site.id, label: site.name, disabled: !site.is_active }))
);
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.id) map[site.id] = !!site.is_active;
});
return map;
});
const isInactiveSite = (siteId?: string | null) => !!siteId && siteActiveMap.value[siteId] === false;
const documentRowClass = ({ row }: { row: DocumentSummary }) =>
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_id) ? " row-inactive" : ""}`.trim();
const sortedItems = computed(() =>
[...items.value].sort((a, b) => Number(isInactiveSite(a?.site_id)) - Number(isInactiveSite(b?.site_id)))
); );
const createForm = reactive({ const createForm = reactive({
@@ -304,6 +322,11 @@ const submitCreate = async () => {
if (!valid || !trialId.value) return; if (!valid || !trialId.value) return;
creating.value = true; creating.value = true;
try { try {
if (createForm.scope_type === "SITE" && isInactiveSite(createForm.site_id)) {
ElMessage.warning("中心已停用");
creating.value = false;
return;
}
await createDocument({ await createDocument({
trial_id: trialId.value, trial_id: trialId.value,
site_id: createForm.scope_type === "SITE" ? createForm.site_id : undefined, site_id: createForm.scope_type === "SITE" ? createForm.site_id : undefined,
@@ -385,4 +408,16 @@ watch(
.font-semibold { .font-semibold {
font-weight: 600; font-weight: 600;
} }
</style>
<style>
.row-inactive td {
color: var(--ctms-text-secondary);
background-color: #fff7d6 !important;
}
.row-inactive .el-tag {
opacity: 0.6;
}
</style> </style>
+26 -11
View File
@@ -14,19 +14,20 @@
<el-row :gutter="24"> <el-row :gutter="24">
<el-col :span="12"> <el-col :span="12">
<el-form-item :label="TEXT.common.fields.site" prop="center_id"> <el-form-item :label="TEXT.common.fields.site" prop="center_id">
<el-select v-model="form.center_id" :placeholder="TEXT.common.placeholders.select" style="width: 100%"> <el-select v-model="form.center_id" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
<el-option <el-option
v-for="site in sites" v-for="site in sites"
:key="site.id" :key="site.id"
:label="site.name || TEXT.common.fallback" :label="site.name || TEXT.common.fallback"
:value="site.id" :value="site.id"
:disabled="!site.is_active"
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item :label="TEXT.common.fields.direction" prop="direction"> <el-form-item :label="TEXT.common.fields.direction" prop="direction">
<el-select v-model="form.direction" :placeholder="TEXT.common.placeholders.select" style="width: 100%"> <el-select v-model="form.direction" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
<el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" /> <el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" />
<el-option :label="TEXT.enums.shipmentDirection.RETURN" value="RETURN" /> <el-option :label="TEXT.enums.shipmentDirection.RETURN" value="RETURN" />
</el-select> </el-select>
@@ -34,7 +35,7 @@
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item :label="TEXT.common.fields.status" prop="status"> <el-form-item :label="TEXT.common.fields.status" prop="status">
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" style="width: 100%"> <el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
<el-option :label="TEXT.enums.shipmentStatus.PENDING" value="PENDING" /> <el-option :label="TEXT.enums.shipmentStatus.PENDING" value="PENDING" />
<el-option :label="TEXT.enums.shipmentStatus.IN_TRANSIT" value="IN_TRANSIT" /> <el-option :label="TEXT.enums.shipmentStatus.IN_TRANSIT" value="IN_TRANSIT" />
<el-option :label="TEXT.enums.shipmentStatus.SIGNED" value="SIGNED" /> <el-option :label="TEXT.enums.shipmentStatus.SIGNED" value="SIGNED" />
@@ -49,43 +50,45 @@
<el-row :gutter="24"> <el-row :gutter="24">
<el-col :span="12"> <el-col :span="12">
<el-form-item :label="TEXT.common.fields.shipDate" prop="ship_date"> <el-form-item :label="TEXT.common.fields.shipDate" prop="ship_date">
<el-date-picker v-model="form.ship_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" /> <el-date-picker v-model="form.ship_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item :label="TEXT.common.fields.receiveDate" prop="receive_date"> <el-form-item :label="TEXT.common.fields.receiveDate" prop="receive_date">
<el-date-picker v-model="form.receive_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" /> <el-date-picker v-model="form.receive_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item :label="TEXT.common.fields.quantity" prop="quantity"> <el-form-item :label="TEXT.common.fields.quantity" prop="quantity">
<el-input-number v-model="form.quantity" :min="0" :controls="false" :placeholder="TEXT.common.placeholders.input" style="width: 100%" /> <el-input-number v-model="form.quantity" :min="0" :controls="false" :placeholder="TEXT.common.placeholders.input" style="width: 100%" :disabled="isReadOnly" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item :label="TEXT.common.fields.batchNo" prop="batch_no"> <el-form-item :label="TEXT.common.fields.batchNo" prop="batch_no">
<el-input v-model="form.batch_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.batchNo" /> <el-input v-model="form.batch_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.batchNo" :disabled="isReadOnly" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item :label="TEXT.common.fields.carrier" prop="carrier"> <el-form-item :label="TEXT.common.fields.carrier" prop="carrier">
<el-input v-model="form.carrier" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.carrier" /> <el-input v-model="form.carrier" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.carrier" :disabled="isReadOnly" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item :label="TEXT.common.fields.trackingNo" prop="tracking_no"> <el-form-item :label="TEXT.common.fields.trackingNo" prop="tracking_no">
<el-input v-model="form.tracking_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.trackingNo" /> <el-input v-model="form.tracking_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.trackingNo" :disabled="isReadOnly" />
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
<el-form-item :label="TEXT.common.fields.remark" prop="remark"> <el-form-item :label="TEXT.common.fields.remark" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" /> <el-input v-model="form.remark" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" :disabled="isReadOnly" />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<div class="actions"> <div class="actions">
<el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button> <el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">
{{ TEXT.common.actions.save }}
</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button> <el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
</div> </div>
</el-form-item> </el-form-item>
@@ -121,10 +124,18 @@ const study = useStudyStore();
const saving = ref(false); const saving = ref(false);
const formRef = ref(); const formRef = ref();
const sites = ref<any[]>([]); const sites = ref<any[]>([]);
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.id) map[site.id] = !!site.is_active;
});
return map;
});
const shipmentId = computed(() => route.params.shipmentId as string | undefined); const shipmentId = computed(() => route.params.shipmentId as string | undefined);
const isEdit = computed(() => !!shipmentId.value); const isEdit = computed(() => !!shipmentId.value);
const studyId = computed(() => study.currentStudy?.id || ""); const studyId = computed(() => study.currentStudy?.id || "");
const isReadOnly = computed(() => isEdit.value && !!form.center_id && siteActiveMap.value[form.center_id] === false);
const form = reactive({ const form = reactive({
center_id: "", center_id: "",
@@ -185,6 +196,10 @@ const load = async () => {
}; };
const submit = async () => { const submit = async () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId.value) return; if (!studyId.value) return;
const valid = await formRef.value?.validate().catch(() => false); const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return; if (!valid) return;
+18 -2
View File
@@ -6,7 +6,7 @@
<p class="page-subtitle">{{ TEXT.modules.feeContracts.detailSubtitle }}</p> <p class="page-subtitle">{{ TEXT.modules.feeContracts.detailSubtitle }}</p>
</div> </div>
<div class="actions"> <div class="actions">
<el-button type="primary" :disabled="!canWrite" @click="goEdit" class="header-action-btn copy-btn"> <el-button type="primary" :disabled="!canWrite || isReadOnly" @click="goEdit" class="header-action-btn copy-btn">
<el-icon class="el-icon--left"><Edit /></el-icon> <el-icon class="el-icon--left"><Edit /></el-icon>
{{ TEXT.common.actions.edit }} {{ TEXT.common.actions.edit }}
</el-button> </el-button>
@@ -106,6 +106,7 @@
:entity-type="'contract_fee'" :entity-type="'contract_fee'"
:entity-id="contractId" :entity-id="contractId"
:groups="attachmentGroups" :groups="attachmentGroups"
:readonly="isReadOnly"
/> />
</el-card> </el-card>
</template> </template>
@@ -115,6 +116,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { Edit } from "@element-plus/icons-vue"; import { Edit } from "@element-plus/icons-vue";
import { getContractFee } from "../../api/feeContracts"; import { getContractFee } from "../../api/feeContracts";
import { fetchSites } from "../../api/sites"; import { fetchSites } from "../../api/sites";
@@ -145,6 +147,14 @@ const detail = reactive<any>({
payments: [], payments: [],
}); });
const sites = ref<any[]>([]); const sites = ref<any[]>([]);
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.id) map[site.id] = !!site.is_active;
});
return map;
});
const isReadOnly = computed(() => !!detail.center_id && siteActiveMap.value[detail.center_id] === false);
const attachmentGroups = [ const attachmentGroups = [
{ {
@@ -204,7 +214,13 @@ const formatAmountWan = (value: any) => {
return (numberValue / 10000).toFixed(2); return (numberValue / 10000).toFixed(2);
}; };
const goEdit = () => router.push(`/fees/contracts/${contractId}/edit`); const goEdit = () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
router.push(`/fees/contracts/${contractId}/edit`);
};
const goBack = () => router.push("/fees/contracts"); const goBack = () => router.push("/fees/contracts");
onMounted(async () => { onMounted(async () => {
+72 -13
View File
@@ -29,39 +29,69 @@
<el-row :gutter="24"> <el-row :gutter="24">
<el-col :span="12"> <el-col :span="12">
<el-form-item :label="TEXT.common.fields.site" prop="center_id" required> <el-form-item :label="TEXT.common.fields.site" prop="center_id" required>
<el-select v-model="form.center_id" :disabled="isEdit" :placeholder="TEXT.common.placeholders.select" class="full-width"> <el-select
v-model="form.center_id"
:disabled="isEdit || isReadOnly"
:placeholder="TEXT.common.placeholders.select"
class="full-width"
>
<el-option <el-option
v-for="site in sites" v-for="site in sites"
:key="site.id" :key="site.id"
:label="site.name || TEXT.common.fallback" :label="site.name || TEXT.common.fallback"
:value="site.id" :value="site.id"
:disabled="!site.is_active"
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item :label="TEXT.modules.feeContracts.contractAmount" prop="contract_amount" required> <el-form-item :label="TEXT.modules.feeContracts.contractAmount" prop="contract_amount" required>
<el-input-number v-model="form.contract_amount" :min="0" :precision="2" :step="1000" class="full-width" controls-position="right" /> <el-input-number
v-model="form.contract_amount"
:disabled="isReadOnly"
:min="0"
:precision="2"
:step="1000"
class="full-width"
controls-position="right"
/>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item :label="TEXT.modules.feeContracts.contractCases" prop="contract_cases" required> <el-form-item :label="TEXT.modules.feeContracts.contractCases" prop="contract_cases" required>
<el-input-number v-model="form.contract_cases" :min="0" :step="1" class="full-width" controls-position="right" /> <el-input-number v-model="form.contract_cases" :disabled="isReadOnly" :min="0" :step="1" class="full-width" controls-position="right" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item :label="TEXT.modules.feeContracts.actualCases" prop="actual_cases"> <el-form-item :label="TEXT.modules.feeContracts.actualCases" prop="actual_cases">
<el-input-number v-model="form.actual_cases" :min="0" :step="1" class="full-width" controls-position="right" /> <el-input-number v-model="form.actual_cases" :disabled="isReadOnly" :min="0" :step="1" class="full-width" controls-position="right" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item :label="TEXT.modules.feeContracts.settlementAmount" prop="settlement_amount"> <el-form-item :label="TEXT.modules.feeContracts.settlementAmount" prop="settlement_amount">
<el-input-number v-model="form.settlement_amount" :min="0" :precision="2" :step="1000" class="full-width" controls-position="right" /> <el-input-number
v-model="form.settlement_amount"
:disabled="isReadOnly"
:min="0"
:precision="2"
:step="1000"
class="full-width"
controls-position="right"
/>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item :label="TEXT.modules.feeContracts.finalPaymentAmount" prop="final_payment_amount"> <el-form-item :label="TEXT.modules.feeContracts.finalPaymentAmount" prop="final_payment_amount">
<el-input-number v-model="form.final_payment_amount" :min="0" :precision="2" :step="1000" class="full-width" controls-position="right" /> <el-input-number
v-model="form.final_payment_amount"
:disabled="isReadOnly"
:min="0"
:precision="2"
:step="1000"
class="full-width"
controls-position="right"
/>
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
@@ -71,7 +101,7 @@
<template #header> <template #header>
<div class="card-header actions-header"> <div class="card-header actions-header">
<span class="header-title">{{ TEXT.modules.feeContracts.paymentTitle }}</span> <span class="header-title">{{ TEXT.modules.feeContracts.paymentTitle }}</span>
<el-button type="primary" @click="addPayment"> <el-button type="primary" :disabled="isReadOnly" @click="addPayment">
<el-icon class="el-icon--left"><Plus /></el-icon> <el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.common.actions.add }} {{ TEXT.common.actions.add }}
</el-button> </el-button>
@@ -90,7 +120,7 @@
<span class="seq-circle">{{ index + 1 }}</span> <span class="seq-circle">{{ index + 1 }}</span>
<span class="seq-text">{{ TEXT.modules.feeContracts.paymentSeqUnit }}</span> <span class="seq-text">{{ TEXT.modules.feeContracts.paymentSeqUnit }}</span>
</div> </div>
<el-button link type="danger" @click="removePayment(index)"> <el-button link type="danger" :disabled="isReadOnly" @click="removePayment(index)">
<el-icon><Delete /></el-icon> <el-icon><Delete /></el-icon>
</el-button> </el-button>
</div> </div>
@@ -98,13 +128,13 @@
<el-row :gutter="16"> <el-row :gutter="16">
<el-col :span="8"> <el-col :span="8">
<el-form-item :label="TEXT.common.fields.amount" :error="paymentErrors[index]?.amount"> <el-form-item :label="TEXT.common.fields.amount" :error="paymentErrors[index]?.amount">
<el-input-number v-model="payment.amount" :min="0" :precision="2" class="full-width" controls-position="right" /> <el-input-number v-model="payment.amount" :disabled="isReadOnly" :min="0" :precision="2" class="full-width" controls-position="right" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="8"> <el-col :span="8">
<el-form-item :label="TEXT.modules.feeContracts.paidFlag" :error="paymentErrors[index]?.paid_date"> <el-form-item :label="TEXT.modules.feeContracts.paidFlag" :error="paymentErrors[index]?.paid_date">
<div class="status-control"> <div class="status-control">
<el-checkbox v-model="payment.is_paid" @change="() => onPaidToggle(payment)"> <el-checkbox v-model="payment.is_paid" :disabled="isReadOnly" @change="() => onPaidToggle(payment)">
{{ TEXT.modules.feeContracts.isPaid }} {{ TEXT.modules.feeContracts.isPaid }}
</el-checkbox> </el-checkbox>
<el-date-picker <el-date-picker
@@ -113,6 +143,7 @@
type="date" type="date"
value-format="YYYY-MM-DD" value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select" :placeholder="TEXT.common.placeholders.select"
:disabled="isReadOnly"
class="status-picker" class="status-picker"
size="small" size="small"
/> />
@@ -122,7 +153,7 @@
<el-col :span="8"> <el-col :span="8">
<el-form-item :label="TEXT.modules.feeContracts.verifiedFlag" :error="paymentErrors[index]?.verified_date"> <el-form-item :label="TEXT.modules.feeContracts.verifiedFlag" :error="paymentErrors[index]?.verified_date">
<div class="status-control"> <div class="status-control">
<el-checkbox v-model="payment.is_verified" @change="() => onVerifiedToggle(payment)"> <el-checkbox v-model="payment.is_verified" :disabled="isReadOnly" @change="() => onVerifiedToggle(payment)">
{{ TEXT.modules.feeContracts.isVerified }} {{ TEXT.modules.feeContracts.isVerified }}
</el-checkbox> </el-checkbox>
<el-date-picker <el-date-picker
@@ -131,6 +162,7 @@
type="date" type="date"
value-format="YYYY-MM-DD" value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select" :placeholder="TEXT.common.placeholders.select"
:disabled="isReadOnly"
class="status-picker" class="status-picker"
size="small" size="small"
/> />
@@ -140,7 +172,7 @@
</el-row> </el-row>
<el-form-item :label="TEXT.common.fields.remark" class="mb-0"> <el-form-item :label="TEXT.common.fields.remark" class="mb-0">
<el-input v-model="payment.remark" type="textarea" :rows="2" :placeholder="TEXT.common.placeholders.input" /> <el-input v-model="payment.remark" :disabled="isReadOnly" type="textarea" :rows="2" :placeholder="TEXT.common.placeholders.input" />
</el-form-item> </el-form-item>
<div v-if="paymentErrors[index]?.verification" class="payment-error"> <div v-if="paymentErrors[index]?.verification" class="payment-error">
@@ -162,6 +194,7 @@
:entity-type="'contract_fee'" :entity-type="'contract_fee'"
:entity-id="form.id" :entity-id="form.id"
:groups="attachmentGroups" :groups="attachmentGroups"
:readonly="isReadOnly"
/> />
<StateEmpty v-else :description="TEXT.modules.feeContracts.uploadHint" class="compact-empty" /> <StateEmpty v-else :description="TEXT.modules.feeContracts.uploadHint" class="compact-empty" />
@@ -169,7 +202,7 @@
<div class="footer-actions"> <div class="footer-actions">
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button> <el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="saving" @click="submit" class="save-btn"> <el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit" class="save-btn">
{{ TEXT.common.actions.save }} {{ TEXT.common.actions.save }}
</el-button> </el-button>
</div> </div>
@@ -207,6 +240,14 @@ const loading = ref(false);
const saving = ref(false); const saving = ref(false);
const errorMessage = ref(""); const errorMessage = ref("");
const sites = ref<any[]>([]); const sites = ref<any[]>([]);
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.id) map[site.id] = !!site.is_active;
});
return map;
});
const isReadOnly = computed(() => isEdit.value && !!form.center_id && siteActiveMap.value[form.center_id] === false);
const formRef = ref(); const formRef = ref();
const form = reactive({ const form = reactive({
@@ -302,6 +343,10 @@ const loadDetail = async () => {
}; };
const addPayment = () => { const addPayment = () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
payments.value.push({ payments.value.push({
tempId: `${Date.now()}-${Math.random()}`, tempId: `${Date.now()}-${Math.random()}`,
amount: 0, amount: 0,
@@ -314,6 +359,10 @@ const addPayment = () => {
}; };
const removePayment = (index: number) => { const removePayment = (index: number) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
const payment = payments.value[index]; const payment = payments.value[index];
if (payment?.id) { if (payment?.id) {
removedPaymentIds.value.push(payment.id); removedPaymentIds.value.push(payment.id);
@@ -322,6 +371,7 @@ const removePayment = (index: number) => {
}; };
const onPaidToggle = (payment: any) => { const onPaidToggle = (payment: any) => {
if (isReadOnly.value) return;
if (!payment.is_paid) { if (!payment.is_paid) {
payment.paid_date = ""; payment.paid_date = "";
payment.is_verified = false; payment.is_verified = false;
@@ -330,6 +380,7 @@ const onPaidToggle = (payment: any) => {
}; };
const onVerifiedToggle = (payment: any) => { const onVerifiedToggle = (payment: any) => {
if (isReadOnly.value) return;
if (payment.is_verified && !payment.is_paid) { if (payment.is_verified && !payment.is_paid) {
payment.is_paid = true; payment.is_paid = true;
} }
@@ -367,6 +418,14 @@ const validatePayments = () => {
const submit = async () => { const submit = async () => {
if (!study.currentStudy?.id) return; if (!study.currentStudy?.id) return;
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (form.center_id && siteActiveMap.value[form.center_id] === false) {
ElMessage.warning("中心已停用");
return;
}
const formOk = await formRef.value?.validate?.().catch(() => false); const formOk = await formRef.value?.validate?.().catch(() => false);
if (!formOk) return; if (!formOk) return;
if (!validatePayments()) { if (!validatePayments()) {
+38 -4
View File
@@ -3,7 +3,6 @@
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">{{ TEXT.modules.feeContracts.title }}</h1> <h1 class="page-title">{{ TEXT.modules.feeContracts.title }}</h1>
<p class="page-subtitle">{{ TEXT.modules.feeContracts.subtitle }}</p>
</div> </div>
<el-button type="primary" :disabled="!canWrite" @click="goNew" class="header-action-btn"> <el-button type="primary" :disabled="!canWrite" @click="goNew" class="header-action-btn">
<el-icon class="el-icon--left"><Plus /></el-icon> <el-icon class="el-icon--left"><Plus /></el-icon>
@@ -94,7 +93,13 @@
<StateLoading v-else-if="loading" :rows="6" /> <StateLoading v-else-if="loading" :rows="6" />
<el-card v-else class="table-card" shadow="never"> <el-card v-else class="table-card" shadow="never">
<el-table :data="contracts" style="width: 100%" @row-click="onRowClick" :header-cell-style="{ background: '#f8f9fb' }"> <el-table
:data="sortedContracts"
style="width: 100%"
@row-click="onRowClick"
:row-class-name="contractRowClass"
:header-cell-style="{ background: '#f8f9fb' }"
>
<el-table-column prop="center_name" :label="TEXT.common.fields.site" min-width="180"> <el-table-column prop="center_name" :label="TEXT.common.fields.site" min-width="180">
<template #default="scope"> <template #default="scope">
<div class="site-cell"> <div class="site-cell">
@@ -163,7 +168,7 @@
link link
type="danger" type="danger"
size="small" size="small"
:disabled="!canWrite" :disabled="!canWrite || isInactiveSite(scope.row.center_id)"
@click.stop="remove(scope.row)" @click.stop="remove(scope.row)"
> >
{{ TEXT.common.actions.delete }} {{ TEXT.common.actions.delete }}
@@ -171,7 +176,7 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<StateEmpty v-if="contracts.length === 0" :description="TEXT.modules.feeContracts.empty" /> <StateEmpty v-if="sortedContracts.length === 0" :description="TEXT.modules.feeContracts.empty" />
</el-card> </el-card>
</div> </div>
</template> </template>
@@ -201,6 +206,19 @@ const loading = ref(false);
const errorMessage = ref(""); const errorMessage = ref("");
const contracts = ref<any[]>([]); const contracts = ref<any[]>([]);
const sites = ref<any[]>([]); const sites = ref<any[]>([]);
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.id) map[site.id] = !!site.is_active;
});
return map;
});
const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false;
const sortedContracts = computed(() =>
[...contracts.value].sort((a, b) => Number(isInactiveSite(a?.center_id)) - Number(isInactiveSite(b?.center_id)))
);
const contractRowClass = ({ row }: { row: any }) =>
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.center_id) ? " row-inactive" : ""}`.trim();
const filters = reactive({ const filters = reactive({
centerId: "", centerId: "",
@@ -279,6 +297,10 @@ const onRowClick = (row: any) => {
const remove = async (row: any) => { const remove = async (row: any) => {
if (!row?.id || !canWrite.value) return; if (!row?.id || !canWrite.value) return;
if (isInactiveSite(row?.center_id)) {
ElMessage.warning("中心已停用");
return;
}
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null); const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return; if (!ok) return;
try { try {
@@ -438,4 +460,16 @@ onMounted(async () => {
color: var(--ctms-text-regular); color: var(--ctms-text-regular);
font-family: var(--el-font-family); font-family: var(--el-font-family);
} }
</style>
<style>
.row-inactive td {
color: var(--ctms-text-secondary);
background-color: #fff7d6 !important;
}
.row-inactive .el-tag {
opacity: 0.6;
}
</style> </style>
@@ -6,7 +6,7 @@
<p class="page-subtitle">{{ TEXT.modules.feeSpecials.detailSubtitle }}</p> <p class="page-subtitle">{{ TEXT.modules.feeSpecials.detailSubtitle }}</p>
</div> </div>
<div class="actions"> <div class="actions">
<el-button type="primary" :disabled="!canWrite" @click="goEdit" class="header-action-btn copy-btn"> <el-button type="primary" :disabled="!canWrite || isReadOnly" @click="goEdit" class="header-action-btn copy-btn">
<el-icon class="el-icon--left"><Edit /></el-icon> <el-icon class="el-icon--left"><Edit /></el-icon>
{{ TEXT.common.actions.edit }} {{ TEXT.common.actions.edit }}
</el-button> </el-button>
@@ -82,6 +82,7 @@
:entity-type="'special_expense'" :entity-type="'special_expense'"
:entity-id="expenseId" :entity-id="expenseId"
:groups="attachmentGroups" :groups="attachmentGroups"
:readonly="isReadOnly"
/> />
</el-card> </el-card>
</template> </template>
@@ -91,6 +92,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { Edit } from "@element-plus/icons-vue"; import { Edit } from "@element-plus/icons-vue";
import { getSpecialExpense } from "../../api/feeSpecials"; import { getSpecialExpense } from "../../api/feeSpecials";
import { fetchSites } from "../../api/sites"; import { fetchSites } from "../../api/sites";
@@ -123,6 +125,14 @@ const detail = reactive<any>({
verified_date: "", verified_date: "",
}); });
const sites = ref<any[]>([]); const sites = ref<any[]>([]);
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.id) map[site.id] = !!site.is_active;
});
return map;
});
const isReadOnly = computed(() => !!detail.center_id && siteActiveMap.value[detail.center_id] === false);
const attachmentGroups = [ const attachmentGroups = [
{ {
@@ -188,7 +198,13 @@ const getCategoryType = (category: string) => {
return 'info'; return 'info';
}; };
const goEdit = () => router.push(`/fees/special/${expenseId}/edit`); const goEdit = () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
router.push(`/fees/special/${expenseId}/edit`);
};
const goBack = () => router.push("/fees/special"); const goBack = () => router.push("/fees/special");
onMounted(async () => { onMounted(async () => {
+28 -10
View File
@@ -29,36 +29,37 @@
<el-row :gutter="24"> <el-row :gutter="24">
<el-col :span="12"> <el-col :span="12">
<el-form-item :label="TEXT.common.fields.site"> <el-form-item :label="TEXT.common.fields.site">
<el-select v-model="form.center_id" clearable :placeholder="TEXT.common.placeholders.select" class="full-width"> <el-select v-model="form.center_id" clearable :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select" class="full-width">
<el-option <el-option
v-for="site in sites" v-for="site in sites"
:key="site.id" :key="site.id"
:label="site.name || TEXT.common.fallback" :label="site.name || TEXT.common.fallback"
:value="site.id" :value="site.id"
:disabled="!site.is_active"
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item :label="TEXT.common.fields.occurDate" prop="happen_date"> <el-form-item :label="TEXT.common.fields.occurDate" prop="happen_date">
<el-date-picker v-model="form.happen_date" type="date" value-format="YYYY-MM-DD" class="full-width" /> <el-date-picker v-model="form.happen_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" class="full-width" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item :label="TEXT.common.fields.category" prop="category" required> <el-form-item :label="TEXT.common.fields.category" prop="category" required>
<el-select v-model="form.category" :placeholder="TEXT.common.placeholders.select" class="full-width"> <el-select v-model="form.category" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select" class="full-width">
<el-option v-for="option in categoryOptions" :key="option.value" :label="option.label" :value="option.value" /> <el-option v-for="option in categoryOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item :label="TEXT.common.fields.amount" prop="amount" required> <el-form-item :label="TEXT.common.fields.amount" prop="amount" required>
<el-input-number v-model="form.amount" :min="0" :precision="2" class="full-width" controls-position="right" /> <el-input-number v-model="form.amount" :disabled="isReadOnly" :min="0" :precision="2" class="full-width" controls-position="right" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="24"> <el-col :span="24">
<el-form-item :label="TEXT.common.fields.description"> <el-form-item :label="TEXT.common.fields.description">
<el-input v-model="form.description" type="textarea" :rows="3" /> <el-input v-model="form.description" :disabled="isReadOnly" type="textarea" :rows="3" />
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
@@ -76,7 +77,7 @@
<div class="status-box"> <div class="status-box">
<div class="status-header"> <div class="status-header">
<span class="status-title">{{ TEXT.modules.feeSpecials.paidFlag }}</span> <span class="status-title">{{ TEXT.modules.feeSpecials.paidFlag }}</span>
<el-switch v-model="form.is_paid" @change="onPaidToggle" /> <el-switch v-model="form.is_paid" :disabled="isReadOnly" @change="onPaidToggle" />
</div> </div>
<el-form-item :label="TEXT.common.fields.occurDate" :error="specialErrors.paid_date" class="status-form-item"> <el-form-item :label="TEXT.common.fields.occurDate" :error="specialErrors.paid_date" class="status-form-item">
<el-date-picker <el-date-picker
@@ -84,7 +85,7 @@
type="date" type="date"
value-format="YYYY-MM-DD" value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select" :placeholder="TEXT.common.placeholders.select"
:disabled="!form.is_paid" :disabled="isReadOnly || !form.is_paid"
class="full-width" class="full-width"
/> />
</el-form-item> </el-form-item>
@@ -94,7 +95,7 @@
<div class="status-box"> <div class="status-box">
<div class="status-header"> <div class="status-header">
<span class="status-title">{{ TEXT.modules.feeSpecials.verifiedFlag }}</span> <span class="status-title">{{ TEXT.modules.feeSpecials.verifiedFlag }}</span>
<el-switch v-model="form.is_verified" @change="onVerifiedToggle" /> <el-switch v-model="form.is_verified" :disabled="isReadOnly" @change="onVerifiedToggle" />
</div> </div>
<el-form-item :label="TEXT.common.fields.occurDate" :error="specialErrors.verified_date" class="status-form-item"> <el-form-item :label="TEXT.common.fields.occurDate" :error="specialErrors.verified_date" class="status-form-item">
<el-date-picker <el-date-picker
@@ -102,7 +103,7 @@
type="date" type="date"
value-format="YYYY-MM-DD" value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select" :placeholder="TEXT.common.placeholders.select"
:disabled="!form.is_verified" :disabled="isReadOnly || !form.is_verified"
class="full-width" class="full-width"
/> />
</el-form-item> </el-form-item>
@@ -126,6 +127,7 @@
:entity-type="'special_expense'" :entity-type="'special_expense'"
:entity-id="form.id" :entity-id="form.id"
:groups="attachmentGroups" :groups="attachmentGroups"
:readonly="isReadOnly"
/> />
<StateEmpty v-else :description="TEXT.modules.feeSpecials.uploadHint" class="compact-empty" /> <StateEmpty v-else :description="TEXT.modules.feeSpecials.uploadHint" class="compact-empty" />
@@ -133,7 +135,7 @@
<div class="footer-actions"> <div class="footer-actions">
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button> <el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="saving" @click="submit" class="save-btn"> <el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit" class="save-btn">
{{ TEXT.common.actions.save }} {{ TEXT.common.actions.save }}
</el-button> </el-button>
</div> </div>
@@ -163,6 +165,14 @@ const loading = ref(false);
const saving = ref(false); const saving = ref(false);
const errorMessage = ref(""); const errorMessage = ref("");
const sites = ref<any[]>([]); const sites = ref<any[]>([]);
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.id) map[site.id] = !!site.is_active;
});
return map;
});
const isReadOnly = computed(() => isEdit.value && !!form.center_id && siteActiveMap.value[form.center_id] === false);
const formRef = ref(); const formRef = ref();
const form = reactive({ const form = reactive({
@@ -301,6 +311,14 @@ const validateSpecial = () => {
const submit = async () => { const submit = async () => {
if (!study.currentStudy?.id) return; if (!study.currentStudy?.id) return;
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (form.center_id && siteActiveMap.value[form.center_id] === false) {
ElMessage.warning("中心已停用");
return;
}
const formOk = await formRef.value?.validate?.().catch(() => false); const formOk = await formRef.value?.validate?.().catch(() => false);
if (!formOk) return; if (!formOk) return;
if (!validateSpecial()) { if (!validateSpecial()) {
+38 -4
View File
@@ -3,7 +3,6 @@
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">{{ TEXT.modules.feeSpecials.title }}</h1> <h1 class="page-title">{{ TEXT.modules.feeSpecials.title }}</h1>
<p class="page-subtitle">{{ TEXT.modules.feeSpecials.subtitle }}</p>
</div> </div>
<el-button type="primary" :disabled="!canWrite" @click="goNew" class="header-action-btn"> <el-button type="primary" :disabled="!canWrite" @click="goNew" class="header-action-btn">
<el-icon class="el-icon--left"><Plus /></el-icon> <el-icon class="el-icon--left"><Plus /></el-icon>
@@ -109,7 +108,13 @@
<StateLoading v-else-if="loading" :rows="6" /> <StateLoading v-else-if="loading" :rows="6" />
<el-card v-else class="table-card" shadow="never"> <el-card v-else class="table-card" shadow="never">
<el-table :data="expenses" style="width: 100%" @row-click="onRowClick" :header-cell-style="{ background: '#f8f9fb' }"> <el-table
:data="sortedExpenses"
style="width: 100%"
@row-click="onRowClick"
:header-cell-style="{ background: '#f8f9fb' }"
:row-class-name="expenseRowClass"
>
<el-table-column :label="TEXT.common.fields.occurDate" width="140"> <el-table-column :label="TEXT.common.fields.occurDate" width="140">
<template #default="scope"> <template #default="scope">
<span class="date-text">{{ displayDate(scope.row.happen_date) }}</span> <span class="date-text">{{ displayDate(scope.row.happen_date) }}</span>
@@ -161,7 +166,7 @@
link link
type="danger" type="danger"
size="small" size="small"
:disabled="!canWrite" :disabled="!canWrite || isInactiveSite(scope.row.center_id)"
@click.stop="remove(scope.row)" @click.stop="remove(scope.row)"
> >
{{ TEXT.common.actions.delete }} {{ TEXT.common.actions.delete }}
@@ -169,7 +174,7 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<StateEmpty v-if="expenses.length === 0" :description="TEXT.modules.feeSpecials.empty" /> <StateEmpty v-if="sortedExpenses.length === 0" :description="TEXT.modules.feeSpecials.empty" />
</el-card> </el-card>
</div> </div>
</template> </template>
@@ -199,6 +204,19 @@ const loading = ref(false);
const errorMessage = ref(""); const errorMessage = ref("");
const expenses = ref<any[]>([]); const expenses = ref<any[]>([]);
const sites = ref<any[]>([]); const sites = ref<any[]>([]);
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.id) map[site.id] = !!site.is_active;
});
return map;
});
const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false;
const expenseRowClass = ({ row }: { row: any }) =>
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.center_id) ? " row-inactive" : ""}`.trim();
const sortedExpenses = computed(() =>
[...expenses.value].sort((a, b) => Number(isInactiveSite(a?.center_id)) - Number(isInactiveSite(b?.center_id)))
);
const filters = reactive({ const filters = reactive({
centerId: "", centerId: "",
@@ -287,6 +305,10 @@ const onRowClick = (row: any) => {
const remove = async (row: any) => { const remove = async (row: any) => {
if (!row?.id || !canWrite.value) return; if (!row?.id || !canWrite.value) return;
if (isInactiveSite(row?.center_id)) {
ElMessage.warning("中心已停用");
return;
}
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null); const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return; if (!ok) return;
try { try {
@@ -444,4 +466,16 @@ onMounted(async () => {
.text-muted { .text-muted {
color: var(--ctms-text-placeholder); color: var(--ctms-text-placeholder);
} }
</style>
<style>
.row-inactive td {
color: var(--ctms-text-secondary);
background-color: #fff7d6 !important;
}
.row-inactive .el-tag {
opacity: 0.6;
}
</style> </style>
+35 -4
View File
@@ -6,7 +6,7 @@
<p class="page-subtitle">{{ TEXT.modules.financeContracts.detailSubtitle }}</p> <p class="page-subtitle">{{ TEXT.modules.financeContracts.detailSubtitle }}</p>
</div> </div>
<div class="actions"> <div class="actions">
<el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button> <el-button type="primary" :disabled="isReadOnly" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button> <el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div> </div>
</div> </div>
@@ -28,16 +28,18 @@
:study-id="studyId" :study-id="studyId"
entity-type="finance_contract" entity-type="finance_contract"
:entity-id="contractId" :entity-id="contractId"
:readonly="isReadOnly"
/> />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { getFinanceContract } from "../../api/financeContracts"; import { getFinanceContract } from "../../api/financeContracts";
import { fetchSites } from "../../api/sites";
import { displayDate } from "../../utils/display"; import { displayDate } from "../../utils/display";
import AttachmentList from "../../components/attachments/AttachmentList.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
@@ -48,6 +50,7 @@ const study = useStudyStore();
const loading = ref(false); const loading = ref(false);
const contractId = route.params.contractId as string; const contractId = route.params.contractId as string;
const studyId = study.currentStudy?.id || ""; const studyId = study.currentStudy?.id || "";
const sites = ref<any[]>([]);
const detail = reactive<any>({ const detail = reactive<any>({
site_name: "", site_name: "",
@@ -58,6 +61,25 @@ const detail = reactive<any>({
remark: "", remark: "",
}); });
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.name) map[site.name] = !!site.is_active;
});
return map;
});
const isReadOnly = computed(() => !!detail.site_name && siteActiveMap.value[detail.site_name] === false);
const loadSites = async () => {
if (!studyId) return;
try {
const { data } = await fetchSites(studyId, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch {
sites.value = [];
}
};
const load = async () => { const load = async () => {
if (!studyId || !contractId) return; if (!studyId || !contractId) return;
loading.value = true; loading.value = true;
@@ -71,10 +93,19 @@ const load = async () => {
} }
}; };
const goEdit = () => router.push(`/finance/contracts/${contractId}/edit`); const goEdit = () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
router.push(`/finance/contracts/${contractId}/edit`);
};
const goBack = () => router.push("/finance/contracts"); const goBack = () => router.push("/finance/contracts");
onMounted(load); onMounted(async () => {
await loadSites();
load();
});
</script> </script>
<style scoped> <style scoped>
+45 -8
View File
@@ -11,29 +11,29 @@
<el-card> <el-card>
<el-form ref="formRef" :model="form" label-width="110px"> <el-form ref="formRef" :model="form" label-width="110px">
<el-form-item :label="TEXT.common.fields.site" prop="site_name" required> <el-form-item :label="TEXT.common.fields.site" prop="site_name" required>
<el-input v-model="form.site_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" /> <el-input v-model="form.site_name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
</el-form-item> </el-form-item>
<el-form-item :label="TEXT.common.fields.contractNo" prop="contract_no" required> <el-form-item :label="TEXT.common.fields.contractNo" prop="contract_no" required>
<el-input v-model="form.contract_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.contractNo" /> <el-input v-model="form.contract_no" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.contractNo" />
</el-form-item> </el-form-item>
<el-form-item :label="TEXT.common.fields.signedDate"> <el-form-item :label="TEXT.common.fields.signedDate">
<el-date-picker v-model="form.signed_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" /> <el-date-picker v-model="form.signed_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
</el-form-item> </el-form-item>
<el-form-item :label="TEXT.common.fields.amount" prop="amount" required> <el-form-item :label="TEXT.common.fields.amount" prop="amount" required>
<el-input-number v-model="form.amount" :min="0" :precision="2" :step="1000" /> <el-input-number v-model="form.amount" :disabled="isReadOnly" :min="0" :precision="2" :step="1000" />
</el-form-item> </el-form-item>
<el-form-item :label="TEXT.common.fields.currency"> <el-form-item :label="TEXT.common.fields.currency">
<el-select v-model="form.currency" :placeholder="TEXT.common.placeholders.select"> <el-select v-model="form.currency" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select">
<el-option label="CNY" value="CNY" /> <el-option label="CNY" value="CNY" />
<el-option label="USD" value="USD" /> <el-option label="USD" value="USD" />
<el-option label="EUR" value="EUR" /> <el-option label="EUR" value="EUR" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item :label="TEXT.common.fields.remark"> <el-form-item :label="TEXT.common.fields.remark">
<el-input v-model="form.remark" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" /> <el-input v-model="form.remark" :disabled="isReadOnly" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button> <el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">{{ TEXT.common.actions.save }}</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button> <el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
@@ -44,6 +44,7 @@
:study-id="studyId" :study-id="studyId"
entity-type="finance_contract" entity-type="finance_contract"
:entity-id="contractId" :entity-id="contractId"
:readonly="isReadOnly"
/> />
<el-card v-else class="hint-card"> <el-card v-else class="hint-card">
<StateEmpty :description="TEXT.modules.financeContracts.uploadHint" /> <StateEmpty :description="TEXT.modules.financeContracts.uploadHint" />
@@ -57,6 +58,7 @@ import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { createFinanceContract, getFinanceContract, updateFinanceContract } from "../../api/financeContracts"; import { createFinanceContract, getFinanceContract, updateFinanceContract } from "../../api/financeContracts";
import { fetchSites } from "../../api/sites";
import AttachmentList from "../../components/attachments/AttachmentList.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
@@ -65,6 +67,7 @@ const route = useRoute();
const router = useRouter(); const router = useRouter();
const study = useStudyStore(); const study = useStudyStore();
const saving = ref(false); const saving = ref(false);
const sites = ref<any[]>([]);
const contractId = computed(() => route.params.contractId as string | undefined); const contractId = computed(() => route.params.contractId as string | undefined);
const isEdit = computed(() => !!contractId.value); const isEdit = computed(() => !!contractId.value);
@@ -79,6 +82,25 @@ const form = reactive({
remark: "", remark: "",
}); });
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.name) map[site.name] = !!site.is_active;
});
return map;
});
const isReadOnly = computed(() => isEdit.value && !!form.site_name && siteActiveMap.value[form.site_name] === false);
const loadSites = async () => {
if (!studyId.value) return;
try {
const { data } = await fetchSites(studyId.value, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch {
sites.value = [];
}
};
const load = async () => { const load = async () => {
if (!isEdit.value || !studyId.value || !contractId.value) return; if (!isEdit.value || !studyId.value || !contractId.value) return;
try { try {
@@ -98,6 +120,18 @@ const load = async () => {
const submit = async () => { const submit = async () => {
if (!studyId.value) return; if (!studyId.value) return;
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!form.site_name) {
ElMessage.warning(TEXT.common.messages.required);
return;
}
if (siteActiveMap.value[form.site_name] === false) {
ElMessage.warning("中心已停用");
return;
}
saving.value = true; saving.value = true;
try { try {
const payload = { const payload = {
@@ -126,7 +160,10 @@ const submit = async () => {
const goBack = () => router.push("/finance/contracts"); const goBack = () => router.push("/finance/contracts");
onMounted(load); onMounted(async () => {
await loadSites();
load();
});
</script> </script>
<style scoped> <style scoped>
+35 -4
View File
@@ -6,7 +6,7 @@
<p class="page-subtitle">{{ TEXT.modules.financeSpecials.detailSubtitle }}</p> <p class="page-subtitle">{{ TEXT.modules.financeSpecials.detailSubtitle }}</p>
</div> </div>
<div class="actions"> <div class="actions">
<el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button> <el-button type="primary" :disabled="isReadOnly" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button> <el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div> </div>
</div> </div>
@@ -27,16 +27,18 @@
:study-id="studyId" :study-id="studyId"
entity-type="finance_special" entity-type="finance_special"
:entity-id="specialId" :entity-id="specialId"
:readonly="isReadOnly"
/> />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { getFinanceSpecial } from "../../api/financeSpecials"; import { getFinanceSpecial } from "../../api/financeSpecials";
import { fetchSites } from "../../api/sites";
import { displayDate, displayEnum } from "../../utils/display"; import { displayDate, displayEnum } from "../../utils/display";
import AttachmentList from "../../components/attachments/AttachmentList.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
@@ -47,6 +49,7 @@ const study = useStudyStore();
const loading = ref(false); const loading = ref(false);
const specialId = route.params.specialId as string; const specialId = route.params.specialId as string;
const studyId = study.currentStudy?.id || ""; const studyId = study.currentStudy?.id || "";
const sites = ref<any[]>([]);
const detail = reactive<any>({ const detail = reactive<any>({
site_name: "", site_name: "",
@@ -57,6 +60,25 @@ const detail = reactive<any>({
remark: "", remark: "",
}); });
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.name) map[site.name] = !!site.is_active;
});
return map;
});
const isReadOnly = computed(() => !!detail.site_name && siteActiveMap.value[detail.site_name] === false);
const loadSites = async () => {
if (!studyId) return;
try {
const { data } = await fetchSites(studyId, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch {
sites.value = [];
}
};
const load = async () => { const load = async () => {
if (!studyId || !specialId) return; if (!studyId || !specialId) return;
loading.value = true; loading.value = true;
@@ -70,10 +92,19 @@ const load = async () => {
} }
}; };
const goEdit = () => router.push(`/finance/special/${specialId}/edit`); const goEdit = () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
router.push(`/finance/special/${specialId}/edit`);
};
const goBack = () => router.push("/finance/special"); const goBack = () => router.push("/finance/special");
onMounted(load); onMounted(async () => {
await loadSites();
load();
});
</script> </script>
<style scoped> <style scoped>
+45 -8
View File
@@ -11,10 +11,10 @@
<el-card> <el-card>
<el-form :model="form" label-width="110px"> <el-form :model="form" label-width="110px">
<el-form-item :label="TEXT.common.fields.site" required> <el-form-item :label="TEXT.common.fields.site" required>
<el-input v-model="form.site_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" /> <el-input v-model="form.site_name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
</el-form-item> </el-form-item>
<el-form-item :label="TEXT.common.fields.type" required> <el-form-item :label="TEXT.common.fields.type" required>
<el-select v-model="form.fee_type" :placeholder="TEXT.common.placeholders.select"> <el-select v-model="form.fee_type" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select">
<el-option :label="TEXT.enums.financeSpecialType.TRAVEL" value="TRAVEL" /> <el-option :label="TEXT.enums.financeSpecialType.TRAVEL" value="TRAVEL" />
<el-option :label="TEXT.enums.financeSpecialType.HOTEL" value="HOTEL" /> <el-option :label="TEXT.enums.financeSpecialType.HOTEL" value="HOTEL" />
<el-option :label="TEXT.enums.financeSpecialType.TRANSPORT" value="TRANSPORT" /> <el-option :label="TEXT.enums.financeSpecialType.TRANSPORT" value="TRANSPORT" />
@@ -22,19 +22,19 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item :label="TEXT.common.fields.amount" required> <el-form-item :label="TEXT.common.fields.amount" required>
<el-input-number v-model="form.amount" :min="0" :precision="2" :step="100" /> <el-input-number v-model="form.amount" :disabled="isReadOnly" :min="0" :precision="2" :step="100" />
</el-form-item> </el-form-item>
<el-form-item :label="TEXT.common.fields.occurDate"> <el-form-item :label="TEXT.common.fields.occurDate">
<el-date-picker v-model="form.occur_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" /> <el-date-picker v-model="form.occur_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
</el-form-item> </el-form-item>
<el-form-item :label="TEXT.common.fields.staff"> <el-form-item :label="TEXT.common.fields.staff">
<el-input v-model="form.staff_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.staff" /> <el-input v-model="form.staff_name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.staff" />
</el-form-item> </el-form-item>
<el-form-item :label="TEXT.common.fields.remark"> <el-form-item :label="TEXT.common.fields.remark">
<el-input v-model="form.remark" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" /> <el-input v-model="form.remark" :disabled="isReadOnly" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button> <el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">{{ TEXT.common.actions.save }}</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button> <el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
@@ -45,6 +45,7 @@
:study-id="studyId" :study-id="studyId"
entity-type="finance_special" entity-type="finance_special"
:entity-id="specialId" :entity-id="specialId"
:readonly="isReadOnly"
/> />
<el-card v-else class="hint-card"> <el-card v-else class="hint-card">
<StateEmpty :description="TEXT.modules.financeSpecials.uploadHint" /> <StateEmpty :description="TEXT.modules.financeSpecials.uploadHint" />
@@ -58,6 +59,7 @@ import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { createFinanceSpecial, getFinanceSpecial, updateFinanceSpecial } from "../../api/financeSpecials"; import { createFinanceSpecial, getFinanceSpecial, updateFinanceSpecial } from "../../api/financeSpecials";
import { fetchSites } from "../../api/sites";
import AttachmentList from "../../components/attachments/AttachmentList.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
@@ -66,6 +68,7 @@ const route = useRoute();
const router = useRouter(); const router = useRouter();
const study = useStudyStore(); const study = useStudyStore();
const saving = ref(false); const saving = ref(false);
const sites = ref<any[]>([]);
const specialId = computed(() => route.params.specialId as string | undefined); const specialId = computed(() => route.params.specialId as string | undefined);
const isEdit = computed(() => !!specialId.value); const isEdit = computed(() => !!specialId.value);
@@ -80,6 +83,25 @@ const form = reactive({
remark: "", remark: "",
}); });
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.name) map[site.name] = !!site.is_active;
});
return map;
});
const isReadOnly = computed(() => isEdit.value && !!form.site_name && siteActiveMap.value[form.site_name] === false);
const loadSites = async () => {
if (!studyId.value) return;
try {
const { data } = await fetchSites(studyId.value, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch {
sites.value = [];
}
};
const load = async () => { const load = async () => {
if (!isEdit.value || !studyId.value || !specialId.value) return; if (!isEdit.value || !studyId.value || !specialId.value) return;
try { try {
@@ -99,6 +121,18 @@ const load = async () => {
const submit = async () => { const submit = async () => {
if (!studyId.value) return; if (!studyId.value) return;
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!form.site_name) {
ElMessage.warning(TEXT.common.messages.required);
return;
}
if (siteActiveMap.value[form.site_name] === false) {
ElMessage.warning("中心已停用");
return;
}
saving.value = true; saving.value = true;
try { try {
const payload = { const payload = {
@@ -127,7 +161,10 @@ const submit = async () => {
const goBack = () => router.push("/finance/special"); const goBack = () => router.push("/finance/special");
onMounted(load); onMounted(async () => {
await loadSites();
load();
});
</script> </script>
<style scoped> <style scoped>
+46 -5
View File
@@ -3,7 +3,6 @@
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">{{ TEXT.modules.drugShipments.title }}</h1> <h1 class="page-title">{{ TEXT.modules.drugShipments.title }}</h1>
<p class="page-subtitle">{{ TEXT.modules.drugShipments.subtitle }}</p>
</div> </div>
<el-button type="primary" @click="goNew">{{ TEXT.modules.drugShipments.newTitle }}</el-button> <el-button type="primary" @click="goNew">{{ TEXT.modules.drugShipments.newTitle }}</el-button>
</div> </div>
@@ -43,7 +42,13 @@
</el-card> </el-card>
<el-card> <el-card>
<el-table :data="items" v-loading="loading" style="width: 100%" @row-click="onRowClick"> <el-table
:data="sortedItems"
v-loading="loading"
style="width: 100%"
:row-class-name="shipmentRowClass"
@row-click="onRowClick"
>
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160"> <el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160">
<template #default="scope">{{ scope.row.site_name || TEXT.common.fallback }}</template> <template #default="scope">{{ scope.row.site_name || TEXT.common.fallback }}</template>
</el-table-column> </el-table-column>
@@ -78,17 +83,25 @@
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120" fixed="right"> <el-table-column :label="TEXT.common.labels.actions" width="120" fixed="right">
<template #default="scope"> <template #default="scope">
<el-button link type="danger" size="small" @click.stop="remove(scope.row)">{{ TEXT.common.actions.delete }}</el-button> <el-button
link
type="danger"
size="small"
:disabled="isInactiveSite(scope.row.center_id)"
@click.stop="remove(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<StateEmpty v-if="!loading && items.length === 0" :description="TEXT.modules.drugShipments.empty" /> <StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.drugShipments.empty" />
</el-card> </el-card>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, 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 { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
@@ -103,6 +116,7 @@ const study = useStudyStore();
const loading = ref(false); const loading = ref(false);
const items = ref<any[]>([]); const items = ref<any[]>([]);
const sites = ref<any[]>([]); const sites = ref<any[]>([]);
const siteActiveMap = ref<Record<string, boolean>>({});
const filters = reactive({ const filters = reactive({
center_id: "", center_id: "",
direction: "", direction: "",
@@ -115,8 +129,13 @@ const loadSites = async () => {
try { try {
const { data } = await fetchSites(studyId, { limit: 500 }); const { data } = await fetchSites(studyId, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || []; sites.value = Array.isArray(data) ? data : data.items || [];
siteActiveMap.value = sites.value.reduce((acc: Record<string, boolean>, site: any) => {
acc[site.id] = !!site.is_active;
return acc;
}, {});
} catch { } catch {
sites.value = []; sites.value = [];
siteActiveMap.value = {};
} }
}; };
@@ -151,6 +170,12 @@ const onRowClick = (row: any) => {
if (!row?.id) return; if (!row?.id) return;
goDetail(row.id); goDetail(row.id);
}; };
const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false;
const shipmentRowClass = ({ row }: { row: any }) =>
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.center_id) ? " row-inactive" : ""}`.trim();
const sortedItems = computed(() =>
[...items.value].sort((a, b) => Number(isInactiveSite(a?.center_id)) - Number(isInactiveSite(b?.center_id)))
);
const statusType = (status: string) => { const statusType = (status: string) => {
switch (status) { switch (status) {
@@ -172,6 +197,10 @@ const statusType = (status: string) => {
const remove = async (row: any) => { const remove = async (row: any) => {
const studyId = study.currentStudy?.id; const studyId = study.currentStudy?.id;
if (!studyId) return; if (!studyId) return;
if (isInactiveSite(row?.center_id)) {
ElMessage.warning("中心已停用");
return;
}
const ok = await ElMessageBox.confirm(TEXT.modules.drugShipments.confirmDelete, TEXT.common.labels.tips).catch(() => null); const ok = await ElMessageBox.confirm(TEXT.modules.drugShipments.confirmDelete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return; if (!ok) return;
try { try {
@@ -196,6 +225,7 @@ onMounted(async () => {
gap: 16px; gap: 16px;
} }
.page-header { .page-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -218,3 +248,14 @@ onMounted(async () => {
margin-bottom: 0; margin-bottom: 0;
} }
</style> </style>
<style>
.row-inactive td {
color: var(--ctms-text-secondary);
background-color: #fff7d6 !important;
}
.row-inactive .el-tag {
opacity: 0.6;
}
</style>
+67 -5
View File
@@ -24,7 +24,14 @@
</el-card> </el-card>
<el-card> <el-card>
<el-table :data="items" v-loading="loading" style="width: 100%" class="ctms-table" @row-click="onRowClick"> <el-table
:data="sortedItems"
v-loading="loading"
style="width: 100%"
class="ctms-table"
@row-click="onRowClick"
:row-class-name="contractRowClass"
>
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" /> <el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" />
<el-table-column prop="contract_no" :label="TEXT.common.fields.contractNo" min-width="160" /> <el-table-column prop="contract_no" :label="TEXT.common.fields.contractNo" min-width="160" />
<el-table-column prop="signed_date" :label="TEXT.common.fields.signedDate" width="140"> <el-table-column prop="signed_date" :label="TEXT.common.fields.signedDate" width="140">
@@ -44,21 +51,30 @@
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120"> <el-table-column :label="TEXT.common.labels.actions" width="120">
<template #default="scope"> <template #default="scope">
<el-button link type="danger" size="small" @click.stop="remove(scope.row)">{{ TEXT.common.actions.delete }}</el-button> <el-button
link
type="danger"
size="small"
:disabled="isInactiveSite(scope.row.site_name)"
@click.stop="remove(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<StateEmpty v-if="!loading && items.length === 0" :description="TEXT.modules.financeContracts.empty" /> <StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.financeContracts.empty" />
</el-card> </el-card>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, 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 { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { listFinanceContracts, deleteFinanceContract } from "../../api/financeContracts"; import { listFinanceContracts, deleteFinanceContract } from "../../api/financeContracts";
import { fetchSites } from "../../api/sites";
import { displayDate, displayDateTime } from "../../utils/display"; import { displayDate, displayDateTime } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
@@ -67,11 +83,37 @@ const router = useRouter();
const study = useStudyStore(); const study = useStudyStore();
const loading = ref(false); const loading = ref(false);
const items = ref<any[]>([]); const items = ref<any[]>([]);
const sites = ref<any[]>([]);
const filters = reactive({ const filters = reactive({
site_name: "", site_name: "",
contract_no: "", contract_no: "",
}); });
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.name) map[site.name] = !!site.is_active;
});
return map;
});
const isInactiveSite = (siteName?: string) => !!siteName && siteActiveMap.value[siteName] === false;
const contractRowClass = ({ row }: { row: any }) =>
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_name) ? " row-inactive" : ""}`.trim();
const sortedItems = computed(() =>
[...items.value].sort((a, b) => Number(isInactiveSite(a?.site_name)) - Number(isInactiveSite(b?.site_name)))
);
const loadSites = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
try {
const { data } = await fetchSites(studyId, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch {
sites.value = [];
}
};
const load = async () => { const load = async () => {
const studyId = study.currentStudy?.id; const studyId = study.currentStudy?.id;
if (!studyId) return; if (!studyId) return;
@@ -105,6 +147,10 @@ const onRowClick = (row: any) => {
const remove = async (row: any) => { const remove = async (row: any) => {
const studyId = study.currentStudy?.id; const studyId = study.currentStudy?.id;
if (!studyId) return; if (!studyId) return;
if (isInactiveSite(row?.site_name)) {
ElMessage.warning("中心已停用");
return;
}
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null); const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return; if (!ok) return;
try { try {
@@ -116,7 +162,10 @@ const remove = async (row: any) => {
} }
}; };
onMounted(load); onMounted(async () => {
await loadSites();
load();
});
</script> </script>
<style scoped> <style scoped>
@@ -147,4 +196,17 @@ onMounted(load);
.filter-card :deep(.el-form-item) { .filter-card :deep(.el-form-item) {
margin-bottom: 0; margin-bottom: 0;
} }
</style>
<style>
.row-inactive td {
color: var(--ctms-text-secondary);
background-color: #fff7d6 !important;
}
.row-inactive .el-tag {
opacity: 0.6;
}
</style> </style>
+66 -5
View File
@@ -29,7 +29,14 @@
</el-card> </el-card>
<el-card> <el-card>
<el-table :data="items" v-loading="loading" style="width: 100%" class="ctms-table" @row-click="onRowClick"> <el-table
:data="sortedItems"
v-loading="loading"
style="width: 100%"
class="ctms-table"
@row-click="onRowClick"
:row-class-name="specialRowClass"
>
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" /> <el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" />
<el-table-column prop="fee_type" :label="TEXT.common.fields.type" width="120"> <el-table-column prop="fee_type" :label="TEXT.common.fields.type" width="120">
<template #default="scope"> <template #default="scope">
@@ -54,21 +61,30 @@
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120"> <el-table-column :label="TEXT.common.labels.actions" width="120">
<template #default="scope"> <template #default="scope">
<el-button link type="danger" size="small" @click.stop="remove(scope.row)">{{ TEXT.common.actions.delete }}</el-button> <el-button
link
type="danger"
size="small"
:disabled="isInactiveSite(scope.row.site_name)"
@click.stop="remove(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<StateEmpty v-if="!loading && items.length === 0" :description="TEXT.modules.financeSpecials.empty" /> <StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.financeSpecials.empty" />
</el-card> </el-card>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, 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 { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { listFinanceSpecials, deleteFinanceSpecial } from "../../api/financeSpecials"; import { listFinanceSpecials, deleteFinanceSpecial } from "../../api/financeSpecials";
import { fetchSites } from "../../api/sites";
import { displayDate, displayDateTime, displayEnum } from "../../utils/display"; import { displayDate, displayDateTime, displayEnum } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
@@ -77,11 +93,37 @@ const router = useRouter();
const study = useStudyStore(); const study = useStudyStore();
const loading = ref(false); const loading = ref(false);
const items = ref<any[]>([]); const items = ref<any[]>([]);
const sites = ref<any[]>([]);
const filters = reactive({ const filters = reactive({
site_name: "", site_name: "",
fee_type: "", fee_type: "",
}); });
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.name) map[site.name] = !!site.is_active;
});
return map;
});
const isInactiveSite = (siteName?: string) => !!siteName && siteActiveMap.value[siteName] === false;
const specialRowClass = ({ row }: { row: any }) =>
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_name) ? " row-inactive" : ""}`.trim();
const sortedItems = computed(() =>
[...items.value].sort((a, b) => Number(isInactiveSite(a?.site_name)) - Number(isInactiveSite(b?.site_name)))
);
const loadSites = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
try {
const { data } = await fetchSites(studyId, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch {
sites.value = [];
}
};
const load = async () => { const load = async () => {
const studyId = study.currentStudy?.id; const studyId = study.currentStudy?.id;
if (!studyId) return; if (!studyId) return;
@@ -115,6 +157,10 @@ const onRowClick = (row: any) => {
const remove = async (row: any) => { const remove = async (row: any) => {
const studyId = study.currentStudy?.id; const studyId = study.currentStudy?.id;
if (!studyId) return; if (!studyId) return;
if (isInactiveSite(row?.site_name)) {
ElMessage.warning("中心已停用");
return;
}
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null); const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return; if (!ok) return;
try { try {
@@ -126,7 +172,10 @@ const remove = async (row: any) => {
} }
}; };
onMounted(load); onMounted(async () => {
await loadSites();
load();
});
</script> </script>
<style scoped> <style scoped>
@@ -157,4 +206,16 @@ onMounted(load);
.filter-card :deep(.el-form-item) { .filter-card :deep(.el-form-item) {
margin-bottom: 0; margin-bottom: 0;
} }
</style>
<style>
.row-inactive td {
color: var(--ctms-text-secondary);
background-color: #fff7d6 !important;
}
.row-inactive .el-tag {
opacity: 0.6;
}
</style> </style>
@@ -1,7 +1,6 @@
<template> <template>
<ModulePlaceholder <ModulePlaceholder
:title="TEXT.modules.knowledgeInstructionFiles.title" :title="TEXT.modules.knowledgeInstructionFiles.title"
:subtitle="TEXT.modules.knowledgeInstructionFiles.subtitle"
:list-title="TEXT.modules.knowledgeInstructionFiles.listTitle" :list-title="TEXT.modules.knowledgeInstructionFiles.listTitle"
:empty-description="TEXT.modules.knowledgeInstructionFiles.emptyDescription" :empty-description="TEXT.modules.knowledgeInstructionFiles.emptyDescription"
/> />
+66 -6
View File
@@ -3,7 +3,6 @@
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">{{ TEXT.modules.knowledgeNotes.title }}</h1> <h1 class="page-title">{{ TEXT.modules.knowledgeNotes.title }}</h1>
<p class="page-subtitle">{{ TEXT.modules.knowledgeNotes.subtitle }}</p>
</div> </div>
<el-button type="primary" @click="goNew">{{ TEXT.modules.knowledgeNotes.newTitle }}</el-button> <el-button type="primary" @click="goNew">{{ TEXT.modules.knowledgeNotes.newTitle }}</el-button>
</div> </div>
@@ -24,7 +23,14 @@
</el-card> </el-card>
<el-card> <el-card>
<el-table :data="items" v-loading="loading" style="width: 100%" class="ctms-table" @row-click="onRowClick"> <el-table
:data="sortedItems"
v-loading="loading"
style="width: 100%"
class="ctms-table"
@row-click="onRowClick"
:row-class-name="noteRowClass"
>
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" /> <el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" />
<el-table-column prop="title" :label="TEXT.common.fields.title" min-width="200" /> <el-table-column prop="title" :label="TEXT.common.fields.title" min-width="200" />
<el-table-column prop="level" :label="TEXT.common.fields.level" width="120" /> <el-table-column prop="level" :label="TEXT.common.fields.level" width="120" />
@@ -33,21 +39,30 @@
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120"> <el-table-column :label="TEXT.common.labels.actions" width="120">
<template #default="scope"> <template #default="scope">
<el-button link type="danger" size="small" @click.stop="remove(scope.row)">{{ TEXT.common.actions.delete }}</el-button> <el-button
link
type="danger"
size="small"
:disabled="isInactiveSite(scope.row.site_name)"
@click.stop="remove(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<StateEmpty v-if="!loading && items.length === 0" :description="TEXT.modules.knowledgeNotes.empty" /> <StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.knowledgeNotes.empty" />
</el-card> </el-card>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, 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 { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { listKnowledgeNotes, deleteKnowledgeNote } from "../../api/knowledgeNotes"; import { listKnowledgeNotes, deleteKnowledgeNote } from "../../api/knowledgeNotes";
import { fetchSites } from "../../api/sites";
import { displayDateTime } from "../../utils/display"; import { displayDateTime } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
@@ -56,11 +71,37 @@ const router = useRouter();
const study = useStudyStore(); const study = useStudyStore();
const loading = ref(false); const loading = ref(false);
const items = ref<any[]>([]); const items = ref<any[]>([]);
const sites = ref<any[]>([]);
const filters = reactive({ const filters = reactive({
site_name: "", site_name: "",
keyword: "", keyword: "",
}); });
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.name) map[site.name] = !!site.is_active;
});
return map;
});
const isInactiveSite = (siteName?: string) => !!siteName && siteActiveMap.value[siteName] === false;
const noteRowClass = ({ row }: { row: any }) =>
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_name) ? " row-inactive" : ""}`.trim();
const sortedItems = computed(() =>
[...items.value].sort((a, b) => Number(isInactiveSite(a?.site_name)) - Number(isInactiveSite(b?.site_name)))
);
const loadSites = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
try {
const { data } = await fetchSites(studyId, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch {
sites.value = [];
}
};
const load = async () => { const load = async () => {
const studyId = study.currentStudy?.id; const studyId = study.currentStudy?.id;
if (!studyId) return; if (!studyId) return;
@@ -94,6 +135,10 @@ const onRowClick = (row: any) => {
const remove = async (row: any) => { const remove = async (row: any) => {
const studyId = study.currentStudy?.id; const studyId = study.currentStudy?.id;
if (!studyId) return; if (!studyId) return;
if (isInactiveSite(row?.site_name)) {
ElMessage.warning("中心已停用");
return;
}
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null); const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return; if (!ok) return;
try { try {
@@ -105,7 +150,10 @@ const remove = async (row: any) => {
} }
}; };
onMounted(load); onMounted(async () => {
await loadSites();
load();
});
</script> </script>
<style scoped> <style scoped>
@@ -136,4 +184,16 @@ onMounted(load);
.filter-card :deep(.el-form-item) { .filter-card :deep(.el-form-item) {
margin-bottom: 0; margin-bottom: 0;
} }
</style>
<style>
.row-inactive td {
color: var(--ctms-text-secondary);
background-color: #fff7d6 !important;
}
.row-inactive .el-tag {
opacity: 0.6;
}
</style> </style>
@@ -1,7 +1,6 @@
<template> <template>
<ModulePlaceholder <ModulePlaceholder
:title="TEXT.modules.knowledgeSupportFiles.title" :title="TEXT.modules.knowledgeSupportFiles.title"
:subtitle="TEXT.modules.knowledgeSupportFiles.subtitle"
:list-title="TEXT.modules.knowledgeSupportFiles.listTitle" :list-title="TEXT.modules.knowledgeSupportFiles.listTitle"
:empty-description="TEXT.modules.knowledgeSupportFiles.emptyDescription" :empty-description="TEXT.modules.knowledgeSupportFiles.emptyDescription"
/> />
+18 -4
View File
@@ -4,7 +4,6 @@
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">项目概览</h1> <h1 class="page-title">项目概览</h1>
<p class="page-subtitle">多中心项目整体进度与入组情况</p>
<p class="study-meta"> <p class="study-meta">
<span class="study-name">{{ study.currentStudy.name }}</span> <span class="study-name">{{ study.currentStudy.name }}</span>
<span class="study-divider">/</span> <span class="study-divider">/</span>
@@ -81,6 +80,7 @@ import { useStudyStore } from "../../store/study";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
import { displayDateTime } from "../../utils/display"; import { displayDateTime } from "../../utils/display";
import { fetchProjectOverview } from "../../api/overview"; import { fetchProjectOverview } from "../../api/overview";
import { fetchSites } from "../../api/sites";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import StateLoading from "../../components/StateLoading.vue"; import StateLoading from "../../components/StateLoading.vue";
import CenterProgressRow from "./project-overview/CenterProgressRow.vue"; import CenterProgressRow from "./project-overview/CenterProgressRow.vue";
@@ -146,14 +146,28 @@ const loadOverview = async () => {
if (!studyId) return; if (!studyId) return;
loading.value = true; loading.value = true;
try { try {
const sitesResp = await fetchSites(studyId, { limit: 500 });
const siteList = Array.isArray(sitesResp.data) ? sitesResp.data : (sitesResp.data as any).items || [];
const siteActiveMap = new Map<string, boolean>(siteList.map((s: any) => [s.id, !!s.is_active]));
if (preferApi) { if (preferApi) {
const { data } = await fetchProjectOverview(studyId); const { data } = await fetchProjectOverview(studyId);
overview.value = adaptProjectOverview(data); overview.value = adaptProjectOverview(data);
usingDemo.value = false; usingDemo.value = false;
return; } else {
overview.value = adaptProjectOverview(overviewMock);
usingDemo.value = true;
}
if (overview.value) {
overview.value.centers.forEach(c => {
if (siteActiveMap.has(c.center_id)) {
c.is_active = siteActiveMap.get(c.center_id);
}
});
// Sort: Inactive last
overview.value.centers.sort((a, b) => Number(b.is_active) - Number(a.is_active));
} }
overview.value = adaptProjectOverview(overviewMock);
usingDemo.value = true;
} catch { } catch {
overview.value = adaptProjectOverview(overviewMock); overview.value = adaptProjectOverview(overviewMock);
usingDemo.value = true; usingDemo.value = true;
@@ -3,7 +3,6 @@
<div class="ctms-page-header"> <div class="ctms-page-header">
<div> <div>
<h1 class="ctms-page-title">{{ TEXT.modules.startupFeasibilityEthics.title }}</h1> <h1 class="ctms-page-title">{{ TEXT.modules.startupFeasibilityEthics.title }}</h1>
<p class="ctms-page-subtitle">{{ TEXT.modules.startupFeasibilityEthics.subtitle }}</p>
</div> </div>
</div> </div>
@@ -21,11 +20,11 @@
</div> </div>
</div> </div>
<el-table <el-table
:data="feasibilityItems" :data="sortedFeasibilityItems"
v-loading="loadingFeasibility" v-loading="loadingFeasibility"
class="ctms-table" class="ctms-table"
:row-class-name="feasibilityRowClass"
@row-click="onFeasibilityRowClick" @row-click="onFeasibilityRowClick"
row-class-name="clickable-row"
> >
<el-table-column :label="TEXT.common.fields.site" width="180"> <el-table-column :label="TEXT.common.fields.site" width="180">
<template #default="scope"> <template #default="scope">
@@ -55,11 +54,18 @@
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120" fixed="right"> <el-table-column :label="TEXT.common.labels.actions" width="120" fixed="right">
<template #default="scope"> <template #default="scope">
<el-button link type="danger" @click.stop="removeFeasibility(scope.row)">{{ TEXT.common.actions.delete }}</el-button> <el-button
link
type="danger"
:disabled="isInactiveSite(scope.row.site_id)"
@click.stop="removeFeasibility(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<StateEmpty v-if="!loadingFeasibility && feasibilityItems.length === 0" :description="TEXT.modules.startupFeasibilityEthics.emptyFeasibility" /> <StateEmpty v-if="!loadingFeasibility && sortedFeasibilityItems.length === 0" :description="TEXT.modules.startupFeasibilityEthics.emptyFeasibility" />
</div> </div>
</el-tab-pane> </el-tab-pane>
<el-tab-pane :label="TEXT.modules.startupFeasibilityEthics.ethicsTab" name="ethics"> <el-tab-pane :label="TEXT.modules.startupFeasibilityEthics.ethicsTab" name="ethics">
@@ -74,11 +80,11 @@
</div> </div>
</div> </div>
<el-table <el-table
:data="ethicsItems" :data="sortedEthicsItems"
v-loading="loadingEthics" v-loading="loadingEthics"
class="ctms-table" class="ctms-table"
:row-class-name="ethicsRowClass"
@row-click="onEthicsRowClick" @row-click="onEthicsRowClick"
row-class-name="clickable-row"
> >
<el-table-column :label="TEXT.common.fields.site" width="180"> <el-table-column :label="TEXT.common.fields.site" width="180">
<template #default="scope"> <template #default="scope">
@@ -111,11 +117,18 @@
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120" fixed="right"> <el-table-column :label="TEXT.common.labels.actions" width="120" fixed="right">
<template #default="scope"> <template #default="scope">
<el-button link type="danger" @click.stop="removeEthics(scope.row)">{{ TEXT.common.actions.delete }}</el-button> <el-button
link
type="danger"
:disabled="isInactiveSite(scope.row.site_id)"
@click.stop="removeEthics(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<StateEmpty v-if="!loadingEthics && ethicsItems.length === 0" :description="TEXT.modules.startupFeasibilityEthics.emptyEthics" /> <StateEmpty v-if="!loadingEthics && sortedEthicsItems.length === 0" :description="TEXT.modules.startupFeasibilityEthics.emptyEthics" />
</div> </div>
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
@@ -148,7 +161,15 @@ const loadingEthics = ref(false);
const sites = ref<Site[]>([]); const sites = ref<Site[]>([]);
const siteMap = computed(() => new Map(sites.value.map((site) => [site.id, site.name]))); const siteMap = computed(() => new Map(sites.value.map((site) => [site.id, site.name])));
const siteActiveMap = computed(() => new Map(sites.value.map((site) => [site.id, !!site.is_active])));
const displaySite = (siteId?: string) => siteMap.value.get(siteId || "") || TEXT.common.fallback; const displaySite = (siteId?: string) => siteMap.value.get(siteId || "") || TEXT.common.fallback;
const isInactiveSite = (siteId?: string) => siteId && siteActiveMap.value.get(siteId) === false;
const sortedFeasibilityItems = computed(() =>
[...feasibilityItems.value].sort((a, b) => Number(isInactiveSite(a?.site_id)) - Number(isInactiveSite(b?.site_id)))
);
const sortedEthicsItems = computed(() =>
[...ethicsItems.value].sort((a, b) => Number(isInactiveSite(a?.site_id)) - Number(isInactiveSite(b?.site_id)))
);
const statusLabelMap: Record<StageStatus, string> = { const statusLabelMap: Record<StageStatus, string> = {
COMPLETED: "已完成", COMPLETED: "已完成",
@@ -249,6 +270,10 @@ const goNewEthics = () => router.push("/startup/ethics/new");
const goEthicsDetail = (id: string) => router.push(`/startup/ethics/${id}`); const goEthicsDetail = (id: string) => router.push(`/startup/ethics/${id}`);
const onFeasibilityRowClick = (row: any) => goFeasibilityDetail(row.id); const onFeasibilityRowClick = (row: any) => goFeasibilityDetail(row.id);
const onEthicsRowClick = (row: any) => goEthicsDetail(row.id); const onEthicsRowClick = (row: any) => goEthicsDetail(row.id);
const feasibilityRowClass = ({ row }: { row: any }) =>
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_id) ? " row-inactive" : ""}`.trim();
const ethicsRowClass = ({ row }: { row: any }) =>
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_id) ? " row-inactive" : ""}`.trim();
const removeFeasibility = async (row: any) => { const removeFeasibility = async (row: any) => {
const studyId = study.currentStudy?.id; const studyId = study.currentStudy?.id;
@@ -291,3 +316,14 @@ onMounted(() => {
} }
</style> </style>
<style>
.row-inactive td {
color: var(--ctms-text-secondary);
background-color: #fff7d6 !important;
}
.row-inactive .el-tag {
opacity: 0.6;
}
</style>
+47 -13
View File
@@ -3,12 +3,18 @@
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">{{ TEXT.modules.startupMeetingAuth.title }}</h1> <h1 class="page-title">{{ TEXT.modules.startupMeetingAuth.title }}</h1>
<p class="page-subtitle">{{ TEXT.modules.startupMeetingAuth.subtitle }}</p>
</div> </div>
</div> </div>
<el-card> <el-card>
<el-table :data="kickoffRows" v-loading="loading" style="width: 100%" class="ctms-table" @row-click="onKickoffRowClick"> <el-table
:data="kickoffRows"
v-loading="loading"
style="width: 100%"
class="ctms-table"
:row-class-name="kickoffRowClass"
@row-click="onKickoffRowClick"
>
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="180" /> <el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="180" />
<el-table-column prop="kickoff_date" :label="TEXT.common.fields.kickoffDate" width="160"> <el-table-column prop="kickoff_date" :label="TEXT.common.fields.kickoffDate" width="160">
<template #default="scope">{{ displayDate(scope.row.kickoff_date) }}</template> <template #default="scope">{{ displayDate(scope.row.kickoff_date) }}</template>
@@ -61,6 +67,14 @@ const memberNameMap = computed(() => {
return map; return map;
}); });
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.id) map[site.id] = !!site.is_active;
});
return map;
});
const contactLabel = (row: any) => { const contactLabel = (row: any) => {
if (!row?.contact) return TEXT.common.fallback; if (!row?.contact) return TEXT.common.fallback;
return String(row.contact) return String(row.contact)
@@ -76,17 +90,20 @@ const kickoffRows = computed(() => {
if (item.site_id) acc[item.site_id] = item; if (item.site_id) acc[item.site_id] = item;
return acc; return acc;
}, {}); }, {});
return sites.value.map((site) => { return sites.value
const meeting = kickoffMap[site.id]; .map((site) => {
return { const meeting = kickoffMap[site.id];
site_id: site.id, return {
site_name: site.name || TEXT.common.fallback, site_id: site.id,
contact: site.contact, site_name: site.name || TEXT.common.fallback,
kickoff_date: meeting?.kickoff_date || null, contact: site.contact,
meeting_id: meeting?.id, kickoff_date: meeting?.kickoff_date || null,
status: meeting?.kickoff_date ? "COMPLETED" : "PENDING", meeting_id: meeting?.id,
}; status: meeting?.kickoff_date ? "COMPLETED" : "PENDING",
}); is_active: !!site.is_active,
};
})
.sort((a, b) => Number(a.is_active === false) - Number(b.is_active === false));
}); });
const loadKickoffs = async () => { const loadKickoffs = async () => {
@@ -115,6 +132,10 @@ const goKickoffDetail = (id: string) => router.push(`/startup/kickoff/${id}`);
const onKickoffRowClick = async (row: any) => { const onKickoffRowClick = async (row: any) => {
const studyId = study.currentStudy?.id; const studyId = study.currentStudy?.id;
if (!studyId || !row?.site_id) return; if (!studyId || !row?.site_id) return;
if (row.is_active === false && !row.meeting_id) {
ElMessage.warning("中心已停用");
return;
}
if (row.meeting_id) { if (row.meeting_id) {
goKickoffDetail(row.meeting_id); goKickoffDetail(row.meeting_id);
return; return;
@@ -126,6 +147,8 @@ const onKickoffRowClick = async (row: any) => {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.createFailed); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.createFailed);
} }
}; };
const kickoffRowClass = ({ row }: { row: any }) => (row?.is_active === false ? "row-inactive" : "");
onMounted(() => { onMounted(() => {
loadKickoffs(); loadKickoffs();
}); });
@@ -157,3 +180,14 @@ onMounted(() => {
} }
</style> </style>
<style>
.row-inactive td {
color: var(--ctms-text-secondary);
background-color: #fff7d6 !important;
}
.row-inactive .el-tag {
opacity: 0.6;
}
</style>
+47 -42
View File
@@ -3,33 +3,19 @@
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">{{ TEXT.modules.subjectManagement.title }}</h1> <h1 class="page-title">{{ TEXT.modules.subjectManagement.title }}</h1>
<p class="page-subtitle">{{ TEXT.modules.subjectManagement.subtitle }}</p>
</div> </div>
<el-button type="primary" @click="goNew">{{ TEXT.common.actions.add }}{{ TEXT.modules.subjectManagement.subjectLabel }}</el-button> <el-button type="primary" @click="goNew">{{ TEXT.common.actions.add }}{{ TEXT.modules.subjectManagement.subjectLabel }}</el-button>
</div> </div>
<el-card class="filter-card">
<el-form :inline="true" :model="filters">
<el-form-item :label="TEXT.common.fields.subjectNo">
<el-input v-model="filters.subject_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.subjectNo" clearable />
</el-form-item>
<el-form-item :label="TEXT.common.fields.status">
<el-select v-model="filters.status" :placeholder="TEXT.common.placeholders.select" clearable>
<el-option :label="TEXT.enums.subjectStatus.SCREENING" value="SCREENING" />
<el-option :label="TEXT.enums.subjectStatus.ENROLLED" value="ENROLLED" />
<el-option :label="TEXT.enums.subjectStatus.COMPLETED" value="COMPLETED" />
<el-option :label="TEXT.enums.subjectStatus.DROPPED" value="DROPPED" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="load">{{ TEXT.common.actions.search }}</el-button>
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card> <el-card>
<el-table :data="items" v-loading="loading" style="width: 100%" class="ctms-table" @row-click="onRowClick"> <el-table
:data="sortedItems"
v-loading="loading"
style="width: 100%"
class="ctms-table"
:row-class-name="subjectRowClass"
@row-click="onRowClick"
>
<el-table-column prop="subject_no" :label="TEXT.common.fields.subjectNo" min-width="140" /> <el-table-column prop="subject_no" :label="TEXT.common.fields.subjectNo" min-width="140" />
<el-table-column :label="TEXT.common.fields.site" min-width="160"> <el-table-column :label="TEXT.common.fields.site" min-width="160">
<template #default="scope">{{ siteMap[scope.row.site_id] || TEXT.common.fallback }}</template> <template #default="scope">{{ siteMap[scope.row.site_id] || TEXT.common.fallback }}</template>
@@ -42,17 +28,25 @@
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120"> <el-table-column :label="TEXT.common.labels.actions" width="120">
<template #default="scope"> <template #default="scope">
<el-button link type="danger" size="small" @click.stop="remove(scope.row)">{{ TEXT.common.actions.delete }}</el-button> <el-button
link
type="danger"
size="small"
:disabled="isInactiveSite(scope.row.site_id)"
@click.stop="remove(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<StateEmpty v-if="!loading && items.length === 0" :description="TEXT.modules.subjectManagement.empty" /> <StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.subjectManagement.empty" />
</el-card> </el-card>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from "vue"; 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 { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
@@ -67,11 +61,7 @@ const study = useStudyStore();
const loading = ref(false); const loading = ref(false);
const items = ref<any[]>([]); const items = ref<any[]>([]);
const siteMap = ref<Record<string, string>>({}); const siteMap = ref<Record<string, string>>({});
const filters = reactive({ const siteActiveMap = ref<Record<string, boolean>>({});
subject_no: "",
status: "",
});
const loadSites = async () => { const loadSites = async () => {
const studyId = study.currentStudy?.id; const studyId = study.currentStudy?.id;
if (!studyId) return; if (!studyId) return;
@@ -82,8 +72,13 @@ const loadSites = async () => {
acc[site.id] = site.name; acc[site.id] = site.name;
return acc; return acc;
}, {}); }, {});
siteActiveMap.value = list.reduce((acc: Record<string, boolean>, site: any) => {
acc[site.id] = !!site.is_active;
return acc;
}, {});
} catch { } catch {
siteMap.value = {}; siteMap.value = {};
siteActiveMap.value = {};
} }
}; };
@@ -92,10 +87,7 @@ const load = async () => {
if (!studyId) return; if (!studyId) return;
loading.value = true; loading.value = true;
try { try {
const { data } = await fetchSubjects(studyId, { const { data } = await fetchSubjects(studyId);
subject_no: filters.subject_no || undefined,
status_filter: filters.status || undefined,
});
items.value = Array.isArray(data) ? data : data.items || []; items.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
@@ -104,12 +96,6 @@ const load = async () => {
} }
}; };
const resetFilters = () => {
filters.subject_no = "";
filters.status = "";
load();
};
const goNew = () => router.push("/subjects/new"); const goNew = () => router.push("/subjects/new");
const goDetail = (id: string) => router.push(`/subjects/${id}`); const goDetail = (id: string) => router.push(`/subjects/${id}`);
const onRowClick = (row: any) => { const onRowClick = (row: any) => {
@@ -119,6 +105,10 @@ const onRowClick = (row: any) => {
const remove = async (row: any) => { const remove = async (row: any) => {
const studyId = study.currentStudy?.id; const studyId = study.currentStudy?.id;
if (!studyId) return; if (!studyId) return;
if (isInactiveSite(row?.site_id)) {
ElMessage.warning("中心已停用");
return;
}
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null); const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return; if (!ok) return;
try { try {
@@ -130,6 +120,13 @@ const remove = async (row: any) => {
} }
}; };
const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false;
const sortedItems = computed(() =>
[...items.value].sort((a, b) => Number(isInactiveSite(a?.site_id)) - Number(isInactiveSite(b?.site_id)))
);
const subjectRowClass = ({ row }: { row: any }) =>
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_id) ? " row-inactive" : ""}`.trim();
onMounted(async () => { onMounted(async () => {
await loadSites(); await loadSites();
load(); load();
@@ -161,7 +158,15 @@ onMounted(async () => {
color: var(--ctms-text-secondary); color: var(--ctms-text-secondary);
} }
.filter-card :deep(.el-form-item) { </style>
margin-bottom: 0;
<style>
.row-inactive td {
color: var(--ctms-text-secondary);
background-color: #fff7d6 !important;
}
.row-inactive .el-tag {
opacity: 0.6;
} }
</style> </style>
@@ -1,5 +1,5 @@
<template> <template>
<div class="center-row"> <div class="center-row" :class="{ 'row-inactive': isInactive }">
<div class="center-meta"> <div class="center-meta">
<div class="center-name">{{ centerName }}</div> <div class="center-name">{{ centerName }}</div>
<div class="center-enrollment"> <div class="center-enrollment">
@@ -46,6 +46,7 @@ const stages = computed(() =>
completedAt: props.center[stage.completedKey], completedAt: props.center[stage.completedKey],
})) }))
); );
const isInactive = computed(() => props.center.is_active === false);
const connectorClass = (status: StageStatus) => `connector-${status.toLowerCase()}`; const connectorClass = (status: StageStatus) => `connector-${status.toLowerCase()}`;
</script> </script>
@@ -174,4 +175,17 @@ const connectorClass = (status: StageStatus) => `connector-${status.toLowerCase(
align-items: center; align-items: center;
} }
} }
.row-inactive {
background-color: #fff7d6;
}
.row-inactive .center-name {
color: var(--ctms-text-secondary);
}
.row-inactive .stage-connector,
.row-inactive .center-timeline {
opacity: 0.6;
}
</style> </style>
@@ -37,6 +37,7 @@ export interface CenterOverview {
enrollment_completed_at?: string; enrollment_completed_at?: string;
inspection_completed_at?: string; inspection_completed_at?: string;
closeout_completed_at?: string; closeout_completed_at?: string;
is_active?: boolean;
} }
export interface EnrollmentByMonth { export interface EnrollmentByMonth {
@@ -67,14 +68,14 @@ export const STAGE_ORDER: Array<{
label: string; label: string;
completedKey: StageCompletionKey; completedKey: StageCompletionKey;
}> = [ }> = [
{ key: "institution_initiation_status", label: "机构立项", completedKey: "institution_initiation_completed_at" }, { key: "institution_initiation_status", label: "机构立项", completedKey: "institution_initiation_completed_at" },
{ key: "ethics_status", label: "伦理审批", completedKey: "ethics_completed_at" }, { key: "ethics_status", label: "伦理审批", completedKey: "ethics_completed_at" },
{ key: "contract_sign_status", label: "合同签署", completedKey: "contract_sign_completed_at" }, { key: "contract_sign_status", label: "合同签署", completedKey: "contract_sign_completed_at" },
{ key: "startup_status", label: "启动", completedKey: "startup_completed_at" }, { key: "startup_status", label: "启动", completedKey: "startup_completed_at" },
{ key: "enrollment_status", label: "入组", completedKey: "enrollment_completed_at" }, { key: "enrollment_status", label: "入组", completedKey: "enrollment_completed_at" },
{ key: "inspection_status", label: "末次稽查", completedKey: "inspection_completed_at" }, { key: "inspection_status", label: "末次稽查", completedKey: "inspection_completed_at" },
{ key: "closeout_status", label: "关中心", completedKey: "closeout_completed_at" }, { key: "closeout_status", label: "关中心", completedKey: "closeout_completed_at" },
]; ];
const STATUS_ALIAS: Record<string, StageStatus> = { const STATUS_ALIAS: Record<string, StageStatus> = {
NOT_STARTED: "NOT_STARTED", NOT_STARTED: "NOT_STARTED",
@@ -130,6 +131,7 @@ export const adaptProjectOverview = (raw: unknown): ProjectOverviewViewModel =>
enrollment_completed_at: toString((center as any).enrollment_completed_at), enrollment_completed_at: toString((center as any).enrollment_completed_at),
inspection_completed_at: toString((center as any).inspection_completed_at), inspection_completed_at: toString((center as any).inspection_completed_at),
closeout_completed_at: toString((center as any).closeout_completed_at), closeout_completed_at: toString((center as any).closeout_completed_at),
is_active: true, // Will be updated by view
}; };
}); });
+35 -4
View File
@@ -6,7 +6,7 @@
<p class="page-subtitle">{{ TEXT.modules.knowledgeNotes.detailSubtitle }}</p> <p class="page-subtitle">{{ TEXT.modules.knowledgeNotes.detailSubtitle }}</p>
</div> </div>
<div class="actions"> <div class="actions">
<el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button> <el-button type="primary" :disabled="isReadOnly" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button> <el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div> </div>
</div> </div>
@@ -25,16 +25,18 @@
:study-id="studyId" :study-id="studyId"
entity-type="knowledge_note" entity-type="knowledge_note"
:entity-id="noteId" :entity-id="noteId"
:readonly="isReadOnly"
/> />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { getKnowledgeNote } from "../../api/knowledgeNotes"; import { getKnowledgeNote } from "../../api/knowledgeNotes";
import { fetchSites } from "../../api/sites";
import AttachmentList from "../../components/attachments/AttachmentList.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
@@ -44,6 +46,7 @@ const study = useStudyStore();
const loading = ref(false); const loading = ref(false);
const noteId = route.params.noteId as string; const noteId = route.params.noteId as string;
const studyId = study.currentStudy?.id || ""; const studyId = study.currentStudy?.id || "";
const sites = ref<any[]>([]);
const detail = reactive<any>({ const detail = reactive<any>({
site_name: "", site_name: "",
@@ -52,6 +55,25 @@ const detail = reactive<any>({
content: "", content: "",
}); });
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.name) map[site.name] = !!site.is_active;
});
return map;
});
const isReadOnly = computed(() => !!detail.site_name && siteActiveMap.value[detail.site_name] === false);
const loadSites = async () => {
if (!studyId) return;
try {
const { data } = await fetchSites(studyId, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch {
sites.value = [];
}
};
const load = async () => { const load = async () => {
if (!studyId || !noteId) return; if (!studyId || !noteId) return;
loading.value = true; loading.value = true;
@@ -65,10 +87,19 @@ const load = async () => {
} }
}; };
const goEdit = () => router.push(`/knowledge/notes/${noteId}/edit`); const goEdit = () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
router.push(`/knowledge/notes/${noteId}/edit`);
};
const goBack = () => router.push("/knowledge/notes"); const goBack = () => router.push("/knowledge/notes");
onMounted(load); onMounted(async () => {
await loadSites();
load();
});
</script> </script>
<style scoped> <style scoped>
+43 -6
View File
@@ -11,19 +11,19 @@
<el-card> <el-card>
<el-form :model="form" label-width="120px"> <el-form :model="form" label-width="120px">
<el-form-item :label="TEXT.common.fields.site" required> <el-form-item :label="TEXT.common.fields.site" required>
<el-input v-model="form.site_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" /> <el-input v-model="form.site_name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
</el-form-item> </el-form-item>
<el-form-item :label="TEXT.common.fields.title" required> <el-form-item :label="TEXT.common.fields.title" required>
<el-input v-model="form.title" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.title" /> <el-input v-model="form.title" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.title" />
</el-form-item> </el-form-item>
<el-form-item :label="TEXT.common.fields.level"> <el-form-item :label="TEXT.common.fields.level">
<el-input v-model="form.level" :placeholder="TEXT.common.placeholders.input" /> <el-input v-model="form.level" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input" />
</el-form-item> </el-form-item>
<el-form-item :label="TEXT.common.fields.content" required> <el-form-item :label="TEXT.common.fields.content" required>
<el-input v-model="form.content" type="textarea" :rows="4" :placeholder="TEXT.common.placeholders.input" /> <el-input v-model="form.content" :disabled="isReadOnly" type="textarea" :rows="4" :placeholder="TEXT.common.placeholders.input" />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button> <el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">{{ TEXT.common.actions.save }}</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button> <el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
@@ -34,6 +34,7 @@
:study-id="studyId" :study-id="studyId"
entity-type="knowledge_note" entity-type="knowledge_note"
:entity-id="noteId" :entity-id="noteId"
:readonly="isReadOnly"
/> />
<el-card v-else class="hint-card"> <el-card v-else class="hint-card">
<StateEmpty :description="TEXT.modules.knowledgeNotes.uploadHint" /> <StateEmpty :description="TEXT.modules.knowledgeNotes.uploadHint" />
@@ -47,6 +48,7 @@ import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { createKnowledgeNote, getKnowledgeNote, updateKnowledgeNote } from "../../api/knowledgeNotes"; import { createKnowledgeNote, getKnowledgeNote, updateKnowledgeNote } from "../../api/knowledgeNotes";
import { fetchSites } from "../../api/sites";
import AttachmentList from "../../components/attachments/AttachmentList.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
@@ -55,6 +57,7 @@ const route = useRoute();
const router = useRouter(); const router = useRouter();
const study = useStudyStore(); const study = useStudyStore();
const saving = ref(false); const saving = ref(false);
const sites = ref<any[]>([]);
const noteId = computed(() => route.params.noteId as string | undefined); const noteId = computed(() => route.params.noteId as string | undefined);
const isEdit = computed(() => !!noteId.value); const isEdit = computed(() => !!noteId.value);
@@ -67,6 +70,25 @@ const form = reactive({
content: "", content: "",
}); });
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.name) map[site.name] = !!site.is_active;
});
return map;
});
const isReadOnly = computed(() => isEdit.value && !!form.site_name && siteActiveMap.value[form.site_name] === false);
const loadSites = async () => {
if (!studyId.value) return;
try {
const { data } = await fetchSites(studyId.value, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch {
sites.value = [];
}
};
const load = async () => { const load = async () => {
if (!isEdit.value || !studyId.value || !noteId.value) return; if (!isEdit.value || !studyId.value || !noteId.value) return;
try { try {
@@ -84,6 +106,18 @@ const load = async () => {
const submit = async () => { const submit = async () => {
if (!studyId.value) return; if (!studyId.value) return;
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!form.site_name) {
ElMessage.warning(TEXT.common.messages.required);
return;
}
if (siteActiveMap.value[form.site_name] === false) {
ElMessage.warning("中心已停用");
return;
}
saving.value = true; saving.value = true;
try { try {
const payload = { const payload = {
@@ -110,7 +144,10 @@ const submit = async () => {
const goBack = () => router.push("/knowledge/notes"); const goBack = () => router.push("/knowledge/notes");
onMounted(load); onMounted(async () => {
await loadSites();
load();
});
</script> </script>
<style scoped> <style scoped>
+13 -2
View File
@@ -6,7 +6,9 @@
<p class="ctms-page-subtitle">{{ TEXT.modules.startupFeasibilityEthics.ethicsDetailSubtitle }}</p> <p class="ctms-page-subtitle">{{ TEXT.modules.startupFeasibilityEthics.ethicsDetailSubtitle }}</p>
</div> </div>
<div class="ctms-page-actions"> <div class="ctms-page-actions">
<el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button> <el-button type="primary" :disabled="isInactiveSite(detail.site_id)" @click="goEdit">
{{ TEXT.common.actions.edit }}
</el-button>
<el-button @click="goBack"> <el-button @click="goBack">
<el-icon class="el-icon--left"><ArrowLeft /></el-icon> <el-icon class="el-icon--left"><ArrowLeft /></el-icon>
{{ TEXT.common.actions.back }} {{ TEXT.common.actions.back }}
@@ -33,6 +35,7 @@
:study-id="studyId" :study-id="studyId"
entity-type="startup_ethics" entity-type="startup_ethics"
:entity-id="recordId" :entity-id="recordId"
:readonly="isInactiveSite(detail.site_id)"
/> />
</div> </div>
</template> </template>
@@ -61,7 +64,9 @@ const studyId = study.currentStudy?.id || "";
const sites = ref<Site[]>([]); const sites = ref<Site[]>([]);
const siteMap = computed(() => new Map(sites.value.map((site) => [site.id, site.name]))); const siteMap = computed(() => new Map(sites.value.map((site) => [site.id, site.name])));
const siteActiveMap = computed(() => new Map(sites.value.map((site) => [site.id, !!site.is_active])));
const displaySite = (siteId?: string) => siteMap.value.get(siteId || "") || TEXT.common.fallback; const displaySite = (siteId?: string) => siteMap.value.get(siteId || "") || TEXT.common.fallback;
const isInactiveSite = (siteId?: string) => siteId && siteActiveMap.value.get(siteId) === false;
const detail = reactive<any>({ const detail = reactive<any>({
site_id: "", site_id: "",
@@ -137,7 +142,13 @@ const load = async () => {
} }
}; };
const goEdit = () => router.push(`/startup/ethics/${recordId}/edit`); const goEdit = () => {
if (isInactiveSite(detail.site_id)) {
ElMessage.warning("中心已停用");
return;
}
router.push(`/startup/ethics/${recordId}/edit`);
};
const goBack = () => router.push("/startup/feasibility-ethics"); const goBack = () => router.push("/startup/feasibility-ethics");
onMounted(() => { onMounted(() => {
+54 -9
View File
@@ -18,14 +18,14 @@
<el-row :gutter="24"> <el-row :gutter="24">
<el-col :span="12"> <el-col :span="12">
<el-form-item :label="TEXT.common.fields.site"> <el-form-item :label="TEXT.common.fields.site">
<el-select v-model="form.site_id" :placeholder="TEXT.common.placeholders.select" clearable style="width: 100%"> <el-select v-model="form.site_id" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select" clearable style="width: 100%">
<el-option v-for="site in siteOptions" :key="site.value" :label="site.label" :value="site.value" /> <el-option v-for="site in siteOptions" :key="site.value" :label="site.label" :value="site.value" :disabled="site.disabled" />
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item :label="TEXT.common.fields.approvalNo"> <el-form-item :label="TEXT.common.fields.approvalNo">
<el-input v-model="form.approval_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.approvalNo" /> <el-input v-model="form.approval_no" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.approvalNo" />
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
@@ -33,28 +33,56 @@
<el-row :gutter="24"> <el-row :gutter="24">
<el-col :span="6"> <el-col :span="6">
<el-form-item :label="TEXT.common.fields.submitDate"> <el-form-item :label="TEXT.common.fields.submitDate">
<el-date-picker v-model="form.submit_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" /> <el-date-picker
v-model="form.submit_date"
:disabled="isReadOnly"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
style="width: 100%"
/>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="6"> <el-col :span="6">
<el-form-item :label="TEXT.common.fields.acceptDate"> <el-form-item :label="TEXT.common.fields.acceptDate">
<el-date-picker v-model="form.accept_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" /> <el-date-picker
v-model="form.accept_date"
:disabled="isReadOnly"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
style="width: 100%"
/>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="6"> <el-col :span="6">
<el-form-item :label="TEXT.common.fields.meetingDate"> <el-form-item :label="TEXT.common.fields.meetingDate">
<el-date-picker v-model="form.meeting_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" /> <el-date-picker
v-model="form.meeting_date"
:disabled="isReadOnly"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
style="width: 100%"
/>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="6"> <el-col :span="6">
<el-form-item :label="TEXT.common.fields.approvedDate"> <el-form-item :label="TEXT.common.fields.approvedDate">
<el-date-picker v-model="form.approved_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" /> <el-date-picker
v-model="form.approved_date"
:disabled="isReadOnly"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
style="width: 100%"
/>
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
<el-form-item class="form-actions"> <el-form-item class="form-actions">
<el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button> <el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">{{ TEXT.common.actions.save }}</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button> <el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
@@ -68,6 +96,7 @@
:study-id="studyId" :study-id="studyId"
entity-type="startup_ethics" entity-type="startup_ethics"
:entity-id="recordId" :entity-id="recordId"
:readonly="isReadOnly"
/> />
</div> </div>
<el-card v-else class="hint-card"> <el-card v-else class="hint-card">
@@ -100,8 +129,16 @@ const studyId = computed(() => study.currentStudy?.id || "");
const sites = ref<Site[]>([]); const sites = ref<Site[]>([]);
const siteOptions = computed(() => const siteOptions = computed(() =>
sites.value.map((site) => ({ value: site.id, label: site.name || TEXT.common.fallback })) sites.value.map((site) => ({ value: site.id, label: site.name || TEXT.common.fallback, disabled: !site.is_active }))
); );
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.id) map[site.id] = !!site.is_active;
});
return map;
});
const isReadOnly = computed(() => isEdit.value && !!form.site_id && siteActiveMap.value[form.site_id] === false);
const form = reactive({ const form = reactive({
site_id: "", site_id: "",
@@ -141,6 +178,14 @@ const load = async () => {
const submit = async () => { const submit = async () => {
if (!studyId.value) return; if (!studyId.value) return;
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (form.site_id && siteActiveMap.value[form.site_id] === false) {
ElMessage.warning("中心已停用");
return;
}
saving.value = true; saving.value = true;
try { try {
const payload = { const payload = {
@@ -6,7 +6,9 @@
<p class="ctms-page-subtitle">{{ TEXT.modules.startupFeasibilityEthics.feasibilityDetailSubtitle }}</p> <p class="ctms-page-subtitle">{{ TEXT.modules.startupFeasibilityEthics.feasibilityDetailSubtitle }}</p>
</div> </div>
<div class="ctms-page-actions"> <div class="ctms-page-actions">
<el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button> <el-button type="primary" :disabled="isInactiveSite(detail.site_id)" @click="goEdit">
{{ TEXT.common.actions.edit }}
</el-button>
<el-button @click="goBack"> <el-button @click="goBack">
<el-icon class="el-icon--left"><ArrowLeft /></el-icon> <el-icon class="el-icon--left"><ArrowLeft /></el-icon>
{{ TEXT.common.actions.back }} {{ TEXT.common.actions.back }}
@@ -32,6 +34,7 @@
:study-id="studyId" :study-id="studyId"
entity-type="startup_feasibility" entity-type="startup_feasibility"
:entity-id="recordId" :entity-id="recordId"
:readonly="isInactiveSite(detail.site_id)"
/> />
</div> </div>
</template> </template>
@@ -60,7 +63,9 @@ const studyId = study.currentStudy?.id || "";
const sites = ref<Site[]>([]); const sites = ref<Site[]>([]);
const siteMap = computed(() => new Map(sites.value.map((site) => [site.id, site.name]))); const siteMap = computed(() => new Map(sites.value.map((site) => [site.id, site.name])));
const siteActiveMap = computed(() => new Map(sites.value.map((site) => [site.id, !!site.is_active])));
const displaySite = (siteId?: string) => siteMap.value.get(siteId || "") || TEXT.common.fallback; const displaySite = (siteId?: string) => siteMap.value.get(siteId || "") || TEXT.common.fallback;
const isInactiveSite = (siteId?: string) => siteId && siteActiveMap.value.get(siteId) === false;
const detail = reactive<any>({ const detail = reactive<any>({
site_id: "", site_id: "",
@@ -135,7 +140,13 @@ const load = async () => {
} }
}; };
const goEdit = () => router.push(`/startup/feasibility/${recordId}/edit`); const goEdit = () => {
if (isInactiveSite(detail.site_id)) {
ElMessage.warning("中心已停用");
return;
}
router.push(`/startup/feasibility/${recordId}/edit`);
};
const goBack = () => router.push("/startup/feasibility-ethics"); const goBack = () => router.push("/startup/feasibility-ethics");
onMounted(() => { onMounted(() => {
+46 -8
View File
@@ -18,14 +18,14 @@
<el-row :gutter="24"> <el-row :gutter="24">
<el-col :span="12"> <el-col :span="12">
<el-form-item :label="TEXT.common.fields.site"> <el-form-item :label="TEXT.common.fields.site">
<el-select v-model="form.site_id" :placeholder="TEXT.common.placeholders.select" clearable style="width: 100%"> <el-select v-model="form.site_id" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select" clearable style="width: 100%">
<el-option v-for="site in siteOptions" :key="site.value" :label="site.label" :value="site.value" /> <el-option v-for="site in siteOptions" :key="site.value" :label="site.label" :value="site.value" :disabled="site.disabled" />
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item :label="TEXT.common.fields.projectNo"> <el-form-item :label="TEXT.common.fields.projectNo">
<el-input v-model="form.project_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.projectNo" /> <el-input v-model="form.project_no" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.projectNo" />
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
@@ -33,23 +33,44 @@
<el-row :gutter="24"> <el-row :gutter="24">
<el-col :span="8"> <el-col :span="8">
<el-form-item :label="TEXT.common.fields.submitDate"> <el-form-item :label="TEXT.common.fields.submitDate">
<el-date-picker v-model="form.submit_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" /> <el-date-picker
v-model="form.submit_date"
:disabled="isReadOnly"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
style="width: 100%"
/>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="8"> <el-col :span="8">
<el-form-item :label="TEXT.common.fields.acceptDate"> <el-form-item :label="TEXT.common.fields.acceptDate">
<el-date-picker v-model="form.accept_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" /> <el-date-picker
v-model="form.accept_date"
:disabled="isReadOnly"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
style="width: 100%"
/>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="8"> <el-col :span="8">
<el-form-item :label="TEXT.common.fields.approvedDate"> <el-form-item :label="TEXT.common.fields.approvedDate">
<el-date-picker v-model="form.approved_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" /> <el-date-picker
v-model="form.approved_date"
:disabled="isReadOnly"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
style="width: 100%"
/>
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
<el-form-item class="form-actions"> <el-form-item class="form-actions">
<el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button> <el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">{{ TEXT.common.actions.save }}</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button> <el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
@@ -63,6 +84,7 @@
:study-id="studyId" :study-id="studyId"
entity-type="startup_feasibility" entity-type="startup_feasibility"
:entity-id="recordId" :entity-id="recordId"
:readonly="isReadOnly"
/> />
</div> </div>
<el-card v-else class="hint-card"> <el-card v-else class="hint-card">
@@ -95,8 +117,16 @@ const studyId = computed(() => study.currentStudy?.id || "");
const sites = ref<Site[]>([]); const sites = ref<Site[]>([]);
const siteOptions = computed(() => const siteOptions = computed(() =>
sites.value.map((site) => ({ value: site.id, label: site.name || TEXT.common.fallback })) sites.value.map((site) => ({ value: site.id, label: site.name || TEXT.common.fallback, disabled: !site.is_active }))
); );
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.id) map[site.id] = !!site.is_active;
});
return map;
});
const isReadOnly = computed(() => isEdit.value && !!form.site_id && siteActiveMap.value[form.site_id] === false);
const form = reactive({ const form = reactive({
site_id: "", site_id: "",
@@ -134,6 +164,14 @@ const load = async () => {
const submit = async () => { const submit = async () => {
if (!studyId.value) return; if (!studyId.value) return;
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (form.site_id && siteActiveMap.value[form.site_id] === false) {
ElMessage.warning("中心已停用");
return;
}
saving.value = true; saving.value = true;
try { try {
const payload = { const payload = {
+53 -9
View File
@@ -6,7 +6,14 @@
<p class="page-subtitle">{{ TEXT.modules.startupMeetingAuth.kickoffDetailSubtitle }}</p> <p class="page-subtitle">{{ TEXT.modules.startupMeetingAuth.kickoffDetailSubtitle }}</p>
</div> </div>
<div class="actions"> <div class="actions">
<el-button v-if="!editMode" type="primary" :disabled="loading" @click="startEdit">{{ TEXT.common.actions.edit }}</el-button> <el-button
v-if="!editMode"
type="primary"
:disabled="loading || isReadOnly"
@click="startEdit"
>
{{ TEXT.common.actions.edit }}
</el-button>
<el-button v-else type="primary" :loading="saving" @click="saveEdit">{{ TEXT.common.actions.save }}</el-button> <el-button v-else type="primary" :loading="saving" @click="saveEdit">{{ TEXT.common.actions.save }}</el-button>
<el-button v-if="editMode" :disabled="saving" @click="cancelEdit">{{ TEXT.common.actions.cancel }}</el-button> <el-button v-if="editMode" :disabled="saving" @click="cancelEdit">{{ TEXT.common.actions.cancel }}</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button> <el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
@@ -59,9 +66,16 @@
<el-checkbox <el-checkbox
v-if="isTrainingEditing(scope.row)" v-if="isTrainingEditing(scope.row)"
v-model="trainingRowDraft.trained" v-model="trainingRowDraft.trained"
:disabled="isReadOnly"
@click.stop
/>
<el-checkbox
v-else
v-model="scope.row.trained"
:disabled="isReadOnly"
@change="toggleTraining(scope.row)"
@click.stop @click.stop
/> />
<el-checkbox v-else v-model="scope.row.trained" @change="toggleTraining(scope.row)" @click.stop />
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.common.fields.authorized" width="120"> <el-table-column :label="TEXT.common.fields.authorized" width="120">
@@ -69,9 +83,16 @@
<el-checkbox <el-checkbox
v-if="isTrainingEditing(scope.row)" v-if="isTrainingEditing(scope.row)"
v-model="trainingRowDraft.authorized" v-model="trainingRowDraft.authorized"
:disabled="isReadOnly"
@click.stop
/>
<el-checkbox
v-else
v-model="scope.row.authorized"
:disabled="isReadOnly"
@change="toggleTraining(scope.row)"
@click.stop @click.stop
/> />
<el-checkbox v-else v-model="scope.row.authorized" @change="toggleTraining(scope.row)" @click.stop />
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120"> <el-table-column :label="TEXT.common.labels.actions" width="120">
@@ -85,17 +106,19 @@
</el-button> </el-button>
</template> </template>
<template v-else> <template v-else>
<el-button link type="primary" size="small" @click.stop="startTrainingEdit(scope.row)"> <el-button link type="primary" size="small" :disabled="isReadOnly" @click.stop="startTrainingEdit(scope.row)">
{{ TEXT.common.actions.edit }} {{ TEXT.common.actions.edit }}
</el-button> </el-button>
<el-button link type="danger" size="small" @click.stop="removeTraining(scope.row)">{{ TEXT.common.actions.delete }}</el-button> <el-button link type="danger" size="small" :disabled="isReadOnly" @click.stop="removeTraining(scope.row)">
{{ TEXT.common.actions.delete }}
</el-button>
</template> </template>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<div <div
class="training-add-row" class="training-add-row"
:class="{ disabled: newTrainingActive || savingTraining }" :class="{ disabled: newTrainingActive || savingTraining || isReadOnly }"
@click="startTrainingAdd" @click="startTrainingAdd"
> >
<span class="training-add-icon">+</span> <span class="training-add-icon">+</span>
@@ -149,7 +172,7 @@ const saving = ref(false);
const editMode = ref(false); const editMode = ref(false);
const meetingId = route.params.meetingId as string; const meetingId = route.params.meetingId as string;
const studyId = study.currentStudy?.id || ""; const studyId = study.currentStudy?.id || "";
const siteInfo = reactive<{ name: string; contact?: string | null }>({ name: "" }); const siteInfo = reactive<{ name: string; contact?: string | null; is_active?: boolean }>({ name: "" });
const users = ref<any[]>([]); const users = ref<any[]>([]);
const members = ref<any[]>([]); const members = ref<any[]>([]);
const kickoffAttachmentGroups = [ const kickoffAttachmentGroups = [
@@ -217,6 +240,7 @@ const statusLabel = computed(() =>
detail.kickoff_date ? TEXT.modules.startupMeetingAuth.statusCompleted : TEXT.modules.startupMeetingAuth.statusPending detail.kickoff_date ? TEXT.modules.startupMeetingAuth.statusCompleted : TEXT.modules.startupMeetingAuth.statusPending
); );
const statusTagType = computed(() => (detail.kickoff_date ? "success" : "info")); const statusTagType = computed(() => (detail.kickoff_date ? "success" : "info"));
const isReadOnly = computed(() => siteInfo.is_active === false);
const load = async () => { const load = async () => {
if (!studyId || !meetingId) return; if (!studyId || !meetingId) return;
@@ -256,6 +280,10 @@ const loadTraining = async () => {
}; };
const toggleTraining = async (row: any) => { const toggleTraining = async (row: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId) return; if (!studyId) return;
try { try {
await updateTrainingAuthorization(studyId, row.id, { await updateTrainingAuthorization(studyId, row.id, {
@@ -282,14 +310,14 @@ const isTrainingEditing = (row: any) => {
}; };
const startTrainingEdit = (row: any) => { const startTrainingEdit = (row: any) => {
if (savingTraining.value) return; if (savingTraining.value || isReadOnly.value) return;
newTrainingActive.value = false; newTrainingActive.value = false;
trainingEditingRowId.value = row?.id || null; trainingEditingRowId.value = row?.id || null;
resetTrainingDraft(row); resetTrainingDraft(row);
}; };
const startTrainingAdd = () => { const startTrainingAdd = () => {
if (newTrainingActive.value || savingTraining.value) return; if (newTrainingActive.value || savingTraining.value || isReadOnly.value) return;
trainingEditingRowId.value = null; trainingEditingRowId.value = null;
newTrainingActive.value = true; newTrainingActive.value = true;
resetTrainingDraft(); resetTrainingDraft();
@@ -302,6 +330,10 @@ const cancelTrainingEdit = () => {
}; };
const saveTraining = async (row: any) => { const saveTraining = async (row: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId) return; if (!studyId) return;
if (!trainingRowDraft.name.trim()) { if (!trainingRowDraft.name.trim()) {
ElMessage.error(requiredMessage(TEXT.common.fields.name)); ElMessage.error(requiredMessage(TEXT.common.fields.name));
@@ -332,6 +364,10 @@ const saveTraining = async (row: any) => {
}; };
const removeTraining = async (row: any) => { const removeTraining = async (row: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId) return; if (!studyId) return;
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null); const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return; if (!ok) return;
@@ -345,6 +381,10 @@ const removeTraining = async (row: any) => {
}; };
const startEdit = () => { const startEdit = () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
editMode.value = true; editMode.value = true;
draft.kickoff_date = detail.kickoff_date || ""; draft.kickoff_date = detail.kickoff_date || "";
}; };
@@ -355,6 +395,10 @@ const cancelEdit = () => {
}; };
const saveEdit = async () => { const saveEdit = async () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId || !meetingId) return; if (!studyId || !meetingId) return;
saving.value = true; saving.value = true;
try { try {
+11 -4
View File
@@ -17,18 +17,19 @@
<el-input :model-value="ownerLabel" disabled /> <el-input :model-value="ownerLabel" disabled />
</el-form-item> </el-form-item>
<el-form-item :label="TEXT.common.fields.kickoffDate"> <el-form-item :label="TEXT.common.fields.kickoffDate">
<el-date-picker v-model="form.kickoff_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" /> <el-date-picker v-model="form.kickoff_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
</el-form-item> </el-form-item>
<el-form-item :label="TEXT.common.fields.attendees"> <el-form-item :label="TEXT.common.fields.attendees">
<el-input <el-input
v-model="form.attendees" v-model="form.attendees"
:disabled="isReadOnly"
type="textarea" type="textarea"
:rows="4" :rows="4"
:placeholder="TEXT.modules.startupMeetingAuth.attendeesPlaceholder" :placeholder="TEXT.modules.startupMeetingAuth.attendeesPlaceholder"
/> />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button> <el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">{{ TEXT.common.actions.save }}</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button> <el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
@@ -42,6 +43,7 @@
:entity-type="group.entityType" :entity-type="group.entityType"
:entity-id="meetingId" :entity-id="meetingId"
:title="group.title" :title="group.title"
:readonly="isReadOnly"
/> />
</div> </div>
<el-card v-else class="hint-card"> <el-card v-else class="hint-card">
@@ -71,7 +73,7 @@ const saving = ref(false);
const meetingId = computed(() => route.params.meetingId as string | undefined); const meetingId = computed(() => route.params.meetingId as string | undefined);
const isEdit = computed(() => !!meetingId.value); const isEdit = computed(() => !!meetingId.value);
const studyId = computed(() => study.currentStudy?.id || ""); const studyId = computed(() => study.currentStudy?.id || "");
const siteInfo = reactive<{ name: string; contact?: string | null }>({ name: "" }); const siteInfo = reactive<{ name: string; contact?: string | null; is_active?: boolean }>({ name: "", is_active: true });
const siteId = ref(""); const siteId = ref("");
const users = ref<any[]>([]); const users = ref<any[]>([]);
const members = ref<any[]>([]); const members = ref<any[]>([]);
@@ -96,6 +98,7 @@ const ownerLabel = computed(() => {
.map((c) => memberNameMap.value[c] || c) .map((c) => memberNameMap.value[c] || c)
.join("、") || TEXT.common.fallback; .join("、") || TEXT.common.fallback;
}); });
const isReadOnly = computed(() => siteInfo.is_active === false);
const kickoffAttachmentGroups = [ const kickoffAttachmentGroups = [
{ entityType: "startup_kickoff_minutes", title: TEXT.modules.startupMeetingAuth.kickoffMinutesLabel }, { entityType: "startup_kickoff_minutes", title: TEXT.modules.startupMeetingAuth.kickoffMinutesLabel },
{ entityType: "startup_kickoff_signin", title: TEXT.modules.startupMeetingAuth.kickoffSignInLabel }, { entityType: "startup_kickoff_signin", title: TEXT.modules.startupMeetingAuth.kickoffSignInLabel },
@@ -132,7 +135,7 @@ const load = async () => {
const { data: sitesData } = await fetchSites(studyId.value, { limit: 500 }); const { data: sitesData } = await fetchSites(studyId.value, { limit: 500 });
const list = Array.isArray(sitesData) ? sitesData : sitesData.items || []; const list = Array.isArray(sitesData) ? sitesData : sitesData.items || [];
const matched = list.find((site: any) => site.id === siteId.value); const matched = list.find((site: any) => site.id === siteId.value);
Object.assign(siteInfo, matched || { name: "", contact: "" }); Object.assign(siteInfo, matched || { name: "", contact: "", is_active: true });
} }
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
@@ -141,6 +144,10 @@ const load = async () => {
const submit = async () => { const submit = async () => {
if (!studyId.value) return; if (!studyId.value) return;
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
saving.value = true; saving.value = true;
try { try {
const payload = { const payload = {
+35 -4
View File
@@ -6,7 +6,7 @@
<p class="page-subtitle">{{ TEXT.modules.startupMeetingAuth.trainingDetailSubtitle }}</p> <p class="page-subtitle">{{ TEXT.modules.startupMeetingAuth.trainingDetailSubtitle }}</p>
</div> </div>
<div class="actions"> <div class="actions">
<el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button> <el-button type="primary" :disabled="isReadOnly" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button> <el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div> </div>
</div> </div>
@@ -29,16 +29,18 @@
:study-id="studyId" :study-id="studyId"
entity-type="training_authorization" entity-type="training_authorization"
:entity-id="recordId" :entity-id="recordId"
:readonly="isReadOnly"
/> />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { getTrainingAuthorization } from "../../api/startup"; import { getTrainingAuthorization } from "../../api/startup";
import { fetchSites } from "../../api/sites";
import { displayDate, displayEnum } from "../../utils/display"; import { displayDate, displayEnum } from "../../utils/display";
import AttachmentList from "../../components/attachments/AttachmentList.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
@@ -49,6 +51,7 @@ const study = useStudyStore();
const loading = ref(false); const loading = ref(false);
const recordId = route.params.recordId as string; const recordId = route.params.recordId as string;
const studyId = study.currentStudy?.id || ""; const studyId = study.currentStudy?.id || "";
const sites = ref<any[]>([]);
const detail = reactive<any>({ const detail = reactive<any>({
name: "", name: "",
@@ -61,6 +64,25 @@ const detail = reactive<any>({
remark: "", remark: "",
}); });
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.name) map[site.name] = !!site.is_active;
});
return map;
});
const isReadOnly = computed(() => !!detail.site_name && siteActiveMap.value[detail.site_name] === false);
const loadSites = async () => {
if (!studyId) return;
try {
const { data } = await fetchSites(studyId, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch {
sites.value = [];
}
};
const load = async () => { const load = async () => {
if (!studyId || !recordId) return; if (!studyId || !recordId) return;
loading.value = true; loading.value = true;
@@ -74,10 +96,19 @@ const load = async () => {
} }
}; };
const goEdit = () => router.push(`/startup/training/${recordId}/edit`); const goEdit = () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
router.push(`/startup/training/${recordId}/edit`);
};
const goBack = () => router.push("/startup/meeting-auth"); const goBack = () => router.push("/startup/meeting-auth");
onMounted(load); onMounted(async () => {
await loadSites();
load();
});
</script> </script>
<style scoped> <style scoped>
+43 -10
View File
@@ -11,31 +11,31 @@
<el-card> <el-card>
<el-form :model="form" label-width="120px"> <el-form :model="form" label-width="120px">
<el-form-item :label="TEXT.common.fields.name" required> <el-form-item :label="TEXT.common.fields.name" required>
<el-input v-model="form.name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.name" /> <el-input v-model="form.name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.name" />
</el-form-item> </el-form-item>
<el-form-item :label="TEXT.common.labels.role"> <el-form-item :label="TEXT.common.labels.role">
<el-input v-model="form.role" :placeholder="TEXT.common.placeholders.input + TEXT.common.labels.role" /> <el-input v-model="form.role" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.labels.role" />
</el-form-item> </el-form-item>
<el-form-item :label="TEXT.common.fields.site"> <el-form-item :label="TEXT.common.fields.site">
<el-input v-model="form.site_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" /> <el-input v-model="form.site_name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
</el-form-item> </el-form-item>
<el-form-item :label="TEXT.common.fields.trained"> <el-form-item :label="TEXT.common.fields.trained">
<el-switch v-model="form.trained" /> <el-switch v-model="form.trained" :disabled="isReadOnly" />
</el-form-item> </el-form-item>
<el-form-item :label="TEXT.common.fields.trainedDate"> <el-form-item :label="TEXT.common.fields.trainedDate">
<el-date-picker v-model="form.trained_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" /> <el-date-picker v-model="form.trained_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
</el-form-item> </el-form-item>
<el-form-item :label="TEXT.common.fields.authorized"> <el-form-item :label="TEXT.common.fields.authorized">
<el-switch v-model="form.authorized" /> <el-switch v-model="form.authorized" :disabled="isReadOnly" />
</el-form-item> </el-form-item>
<el-form-item :label="TEXT.common.fields.authorizedDate"> <el-form-item :label="TEXT.common.fields.authorizedDate">
<el-date-picker v-model="form.authorized_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" /> <el-date-picker v-model="form.authorized_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
</el-form-item> </el-form-item>
<el-form-item :label="TEXT.common.fields.remark"> <el-form-item :label="TEXT.common.fields.remark">
<el-input v-model="form.remark" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" /> <el-input v-model="form.remark" :disabled="isReadOnly" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button> <el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">{{ TEXT.common.actions.save }}</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button> <el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
@@ -46,6 +46,7 @@
:study-id="studyId" :study-id="studyId"
entity-type="training_authorization" entity-type="training_authorization"
:entity-id="recordId" :entity-id="recordId"
:readonly="isReadOnly"
/> />
<el-card v-else class="hint-card"> <el-card v-else class="hint-card">
<StateEmpty :description="TEXT.modules.startupMeetingAuth.trainingUploadHint" /> <StateEmpty :description="TEXT.modules.startupMeetingAuth.trainingUploadHint" />
@@ -59,6 +60,7 @@ import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { createTrainingAuthorization, getTrainingAuthorization, updateTrainingAuthorization } from "../../api/startup"; import { createTrainingAuthorization, getTrainingAuthorization, updateTrainingAuthorization } from "../../api/startup";
import { fetchSites } from "../../api/sites";
import AttachmentList from "../../components/attachments/AttachmentList.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
@@ -67,6 +69,7 @@ const route = useRoute();
const router = useRouter(); const router = useRouter();
const study = useStudyStore(); const study = useStudyStore();
const saving = ref(false); const saving = ref(false);
const sites = ref<any[]>([]);
const recordId = computed(() => route.params.recordId as string | undefined); const recordId = computed(() => route.params.recordId as string | undefined);
const isEdit = computed(() => !!recordId.value); const isEdit = computed(() => !!recordId.value);
@@ -83,6 +86,25 @@ const form = reactive({
remark: "", remark: "",
}); });
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.name) map[site.name] = !!site.is_active;
});
return map;
});
const isReadOnly = computed(() => isEdit.value && !!form.site_name && siteActiveMap.value[form.site_name] === false);
const loadSites = async () => {
if (!studyId.value) return;
try {
const { data } = await fetchSites(studyId.value, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch {
sites.value = [];
}
};
const load = async () => { const load = async () => {
if (!isEdit.value || !studyId.value || !recordId.value) return; if (!isEdit.value || !studyId.value || !recordId.value) return;
try { try {
@@ -104,6 +126,14 @@ const load = async () => {
const submit = async () => { const submit = async () => {
if (!studyId.value) return; if (!studyId.value) return;
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (form.site_name && siteActiveMap.value[form.site_name] === false) {
ElMessage.warning("中心已停用");
return;
}
saving.value = true; saving.value = true;
try { try {
const payload = { const payload = {
@@ -134,7 +164,10 @@ const submit = async () => {
const goBack = () => router.push("/startup/meeting-auth"); const goBack = () => router.push("/startup/meeting-auth");
onMounted(load); onMounted(async () => {
await loadSites();
load();
});
</script> </script>
<style scoped> <style scoped>
+135 -16
View File
@@ -11,7 +11,9 @@
<el-button @click="cancelSubjectEdit">{{ TEXT.common.actions.cancel }}</el-button> <el-button @click="cancelSubjectEdit">{{ TEXT.common.actions.cancel }}</el-button>
</template> </template>
<template v-else> <template v-else>
<el-button type="primary" @click="startSubjectEdit">{{ TEXT.common.actions.edit }}</el-button> <el-button type="primary" :disabled="isReadOnly" @click="startSubjectEdit">
{{ TEXT.common.actions.edit }}
</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button> <el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</template> </template>
</div> </div>
@@ -72,7 +74,9 @@
<el-tabs v-model="activeTab"> <el-tabs v-model="activeTab">
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.history" name="history"> <el-tab-pane :label="TEXT.modules.subjectDetail.tabs.history" name="history">
<div class="tab-actions"> <div class="tab-actions">
<el-button type="primary" @click="openHistoryDialog()">{{ TEXT.common.actions.newHistory }}</el-button> <el-button type="primary" :disabled="isReadOnly" @click="openHistoryDialog()">
{{ TEXT.common.actions.newHistory }}
</el-button>
</div> </div>
<el-table :data="historyItems" v-loading="loadingHistory" style="width: 100%"> <el-table :data="historyItems" v-loading="loadingHistory" style="width: 100%">
<el-table-column prop="record_date" :label="TEXT.common.fields.recordDate" width="140"> <el-table-column prop="record_date" :label="TEXT.common.fields.recordDate" width="140">
@@ -81,8 +85,24 @@
<el-table-column prop="content" :label="TEXT.common.fields.content" min-width="240" /> <el-table-column prop="content" :label="TEXT.common.fields.content" min-width="240" />
<el-table-column :label="TEXT.common.labels.actions" width="200"> <el-table-column :label="TEXT.common.labels.actions" width="200">
<template #default="scope"> <template #default="scope">
<el-button link type="primary" size="small" @click="openHistoryDialog(scope.row)">{{ TEXT.common.actions.edit }}</el-button> <el-button
<el-button link type="danger" size="small" @click="removeHistory(scope.row)">{{ TEXT.common.actions.delete }}</el-button> link
type="primary"
size="small"
:disabled="isReadOnly"
@click="openHistoryDialog(scope.row)"
>
{{ TEXT.common.actions.edit }}
</el-button>
<el-button
link
type="danger"
size="small"
:disabled="isReadOnly"
@click="removeHistory(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -91,7 +111,9 @@
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.visits" name="visits"> <el-tab-pane :label="TEXT.modules.subjectDetail.tabs.visits" name="visits">
<div class="tab-actions"> <div class="tab-actions">
<el-button type="primary" @click="openVisitDialog()">{{ TEXT.common.actions.newVisit }}</el-button> <el-button type="primary" :disabled="isReadOnly" @click="openVisitDialog()">
{{ TEXT.common.actions.newVisit }}
</el-button>
</div> </div>
<el-table :data="visitItems" v-loading="loadingVisits" style="width: 100%"> <el-table :data="visitItems" v-loading="loadingVisits" style="width: 100%">
<el-table-column prop="visit_code" :label="TEXT.common.fields.visitCode" width="120" /> <el-table-column prop="visit_code" :label="TEXT.common.fields.visitCode" width="120" />
@@ -135,12 +157,32 @@
<el-table-column :label="TEXT.common.labels.actions" min-width="160"> <el-table-column :label="TEXT.common.labels.actions" min-width="160">
<template #default="scope"> <template #default="scope">
<template v-if="visitEditingRowId === scope.row.id"> <template v-if="visitEditingRowId === scope.row.id">
<el-button link type="primary" size="small" @click="saveVisitRow(scope.row)">{{ TEXT.common.actions.save }}</el-button> <el-button link type="primary" size="small" :disabled="isReadOnly" @click="saveVisitRow(scope.row)">
<el-button link size="small" @click="cancelVisitRow">{{ TEXT.common.actions.cancel }}</el-button> {{ TEXT.common.actions.save }}
</el-button>
<el-button link size="small" :disabled="isReadOnly" @click="cancelVisitRow">
{{ TEXT.common.actions.cancel }}
</el-button>
</template> </template>
<template v-else> <template v-else>
<el-button link type="primary" size="small" @click="startVisitRowEdit(scope.row)">{{ TEXT.common.actions.edit }}</el-button> <el-button
<el-button link type="danger" size="small" @click="removeVisit(scope.row)">{{ TEXT.common.actions.delete }}</el-button> link
type="primary"
size="small"
:disabled="isReadOnly"
@click="startVisitRowEdit(scope.row)"
>
{{ TEXT.common.actions.edit }}
</el-button>
<el-button
link
type="danger"
size="small"
:disabled="isReadOnly"
@click="removeVisit(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template> </template>
</template> </template>
</el-table-column> </el-table-column>
@@ -150,7 +192,9 @@
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.ae" name="ae"> <el-tab-pane :label="TEXT.modules.subjectDetail.tabs.ae" name="ae">
<div class="tab-actions"> <div class="tab-actions">
<el-button type="primary" @click="openAeDialog()">{{ TEXT.common.actions.newAe }}</el-button> <el-button type="primary" :disabled="isReadOnly" @click="openAeDialog()">
{{ TEXT.common.actions.newAe }}
</el-button>
</div> </div>
<el-table :data="aeItems" v-loading="loadingAes" style="width: 100%"> <el-table :data="aeItems" v-loading="loadingAes" style="width: 100%">
<el-table-column prop="onset_date" :label="TEXT.common.fields.occurDate" width="140"> <el-table-column prop="onset_date" :label="TEXT.common.fields.occurDate" width="140">
@@ -165,8 +209,24 @@
<el-table-column prop="description" :label="TEXT.common.fields.remark" min-width="200" /> <el-table-column prop="description" :label="TEXT.common.fields.remark" min-width="200" />
<el-table-column :label="TEXT.common.labels.actions" width="200"> <el-table-column :label="TEXT.common.labels.actions" width="200">
<template #default="scope"> <template #default="scope">
<el-button link type="primary" size="small" @click="openAeDialog(scope.row)">{{ TEXT.common.actions.edit }}</el-button> <el-button
<el-button link type="danger" size="small" @click="removeAe(scope.row)">{{ TEXT.common.actions.delete }}</el-button> link
type="primary"
size="small"
:disabled="isReadOnly"
@click="openAeDialog(scope.row)"
>
{{ TEXT.common.actions.edit }}
</el-button>
<el-button
link
type="danger"
size="small"
:disabled="isReadOnly"
@click="removeAe(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -186,7 +246,7 @@
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="historyDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button> <el-button @click="historyDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" @click="saveHistory">{{ TEXT.common.actions.save }}</el-button> <el-button type="primary" :disabled="isReadOnly" @click="saveHistory">{{ TEXT.common.actions.save }}</el-button>
</template> </template>
</el-dialog> </el-dialog>
@@ -217,7 +277,7 @@
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="visitDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button> <el-button @click="visitDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" @click="saveVisit">{{ TEXT.common.actions.save }}</el-button> <el-button type="primary" :disabled="isReadOnly" @click="saveVisit">{{ TEXT.common.actions.save }}</el-button>
</template> </template>
</el-dialog> </el-dialog>
@@ -254,14 +314,14 @@
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="aeDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button> <el-button @click="aeDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" @click="saveAe">{{ TEXT.common.actions.save }}</el-button> <el-button type="primary" :disabled="isReadOnly" @click="saveAe">{{ TEXT.common.actions.save }}</el-button>
</template> </template>
</el-dialog> </el-dialog>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
@@ -303,6 +363,8 @@ const subjectForm = reactive({
}); });
const siteMap = ref<Record<string, string>>({}); const siteMap = ref<Record<string, string>>({});
const siteActiveMap = ref<Record<string, boolean>>({});
const isReadOnly = computed(() => !!detail.site_id && siteActiveMap.value[detail.site_id] === false);
const activeTab = ref("history"); const activeTab = ref("history");
const historyItems = ref<any[]>([]); const historyItems = ref<any[]>([]);
@@ -355,8 +417,13 @@ const loadSites = async () => {
acc[site.id] = site.name; acc[site.id] = site.name;
return acc; return acc;
}, {}); }, {});
siteActiveMap.value = list.reduce((acc: Record<string, boolean>, site: any) => {
acc[site.id] = !!site.is_active;
return acc;
}, {});
} catch { } catch {
siteMap.value = {}; siteMap.value = {};
siteActiveMap.value = {};
} }
}; };
@@ -374,6 +441,10 @@ const loadSubject = async () => {
}; };
const startSubjectEdit = () => { const startSubjectEdit = () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
subjectForm.status = detail.status || "SCREENING"; subjectForm.status = detail.status || "SCREENING";
subjectForm.consent_date = detail.consent_date || ""; subjectForm.consent_date = detail.consent_date || "";
subjectForm.enrollment_date = detail.enrollment_date || ""; subjectForm.enrollment_date = detail.enrollment_date || "";
@@ -387,6 +458,10 @@ const cancelSubjectEdit = () => {
}; };
const saveSubjectEdit = async () => { const saveSubjectEdit = async () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId || !subjectId) return; if (!studyId || !subjectId) return;
subjectSaving.value = true; subjectSaving.value = true;
try { try {
@@ -422,6 +497,10 @@ const loadHistories = async () => {
}; };
const openHistoryDialog = (row?: any) => { const openHistoryDialog = (row?: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
historyEditingId.value = row?.id || null; historyEditingId.value = row?.id || null;
historyForm.record_date = row?.record_date || ""; historyForm.record_date = row?.record_date || "";
historyForm.content = row?.content || ""; historyForm.content = row?.content || "";
@@ -429,6 +508,10 @@ const openHistoryDialog = (row?: any) => {
}; };
const saveHistory = async () => { const saveHistory = async () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId || !subjectId) return; if (!studyId || !subjectId) return;
try { try {
const payload = { const payload = {
@@ -449,6 +532,10 @@ const saveHistory = async () => {
}; };
const removeHistory = async (row: any) => { const removeHistory = async (row: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId || !subjectId) return; if (!studyId || !subjectId) return;
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null); const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return; if (!ok) return;
@@ -474,6 +561,10 @@ const loadVisits = async () => {
}; };
const openVisitDialog = (row?: any) => { const openVisitDialog = (row?: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
visitEditingId.value = row?.id || null; visitEditingId.value = row?.id || null;
if (row) { if (row) {
visitForm.actual_date = row.actual_date || ""; visitForm.actual_date = row.actual_date || "";
@@ -490,6 +581,10 @@ const openVisitDialog = (row?: any) => {
}; };
const startVisitRowEdit = (row: any) => { const startVisitRowEdit = (row: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
visitEditingRowId.value = row?.id || null; visitEditingRowId.value = row?.id || null;
visitRowDraft.actual_date = row?.actual_date || ""; visitRowDraft.actual_date = row?.actual_date || "";
visitRowDraft.notes = row?.notes || ""; visitRowDraft.notes = row?.notes || "";
@@ -500,6 +595,10 @@ const cancelVisitRow = () => {
}; };
const saveVisitRow = async (row: any) => { const saveVisitRow = async (row: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId || !subjectId || !row?.id) return; if (!studyId || !subjectId || !row?.id) return;
try { try {
const payload = { const payload = {
@@ -559,6 +658,10 @@ const getVisitStatusTagType = (status: string) => {
}; };
const saveVisit = async () => { const saveVisit = async () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId || !subjectId) return; if (!studyId || !subjectId) return;
try { try {
if (visitEditingId.value) { if (visitEditingId.value) {
@@ -585,6 +688,10 @@ const saveVisit = async () => {
}; };
const removeVisit = async (row: any) => { const removeVisit = async (row: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId || !subjectId) return; if (!studyId || !subjectId) return;
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null); const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return; if (!ok) return;
@@ -610,6 +717,10 @@ const loadAes = async () => {
}; };
const openAeDialog = (row?: any) => { const openAeDialog = (row?: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
aeEditingId.value = row?.id || null; aeEditingId.value = row?.id || null;
Object.assign(aeForm, { Object.assign(aeForm, {
term: row?.term || "", term: row?.term || "",
@@ -624,6 +735,10 @@ const openAeDialog = (row?: any) => {
}; };
const saveAe = async () => { const saveAe = async () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId || !subjectId) return; if (!studyId || !subjectId) return;
try { try {
const payload = { const payload = {
@@ -650,6 +765,10 @@ const saveAe = async () => {
}; };
const removeAe = async (row: any) => { const removeAe = async (row: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId) return; if (!studyId) return;
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null); const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return; if (!ok) return;
+46 -9
View File
@@ -11,11 +11,15 @@
<el-card> <el-card>
<el-form :model="form" label-width="120px"> <el-form :model="form" label-width="120px">
<el-form-item :label="TEXT.common.fields.subjectNo" required> <el-form-item :label="TEXT.common.fields.subjectNo" required>
<el-input v-model="form.subject_no" :disabled="isEdit" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.subjectNo" /> <el-input
v-model="form.subject_no"
:disabled="isEdit || isReadOnly"
:placeholder="TEXT.common.placeholders.input + TEXT.common.fields.subjectNo"
/>
</el-form-item> </el-form-item>
<el-form-item :label="TEXT.common.fields.site" required> <el-form-item :label="TEXT.common.fields.site" required>
<el-select v-model="form.site_id" :disabled="isEdit" :placeholder="TEXT.common.placeholders.select"> <el-select v-model="form.site_id" :disabled="isEdit || isReadOnly" :placeholder="TEXT.common.placeholders.select">
<el-option v-for="site in sites" :key="site.id" :label="site.name" :value="site.id" /> <el-option v-for="site in sites" :key="site.id" :label="site.name" :value="site.id" :disabled="!site.is_active" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item :label="TEXT.common.fields.screeningDate"> <el-form-item :label="TEXT.common.fields.screeningDate">
@@ -24,7 +28,7 @@
type="date" type="date"
value-format="YYYY-MM-DD" value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select" :placeholder="TEXT.common.placeholders.select"
:disabled="isEdit" :disabled="isEdit || isReadOnly"
/> />
</el-form-item> </el-form-item>
<el-form-item :label="TEXT.common.fields.consentDate"> <el-form-item :label="TEXT.common.fields.consentDate">
@@ -33,10 +37,11 @@
type="date" type="date"
value-format="YYYY-MM-DD" value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select" :placeholder="TEXT.common.placeholders.select"
:disabled="isReadOnly"
/> />
</el-form-item> </el-form-item>
<el-form-item v-if="isEdit" :label="TEXT.common.fields.status"> <el-form-item v-if="isEdit" :label="TEXT.common.fields.status">
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select"> <el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" :disabled="isReadOnly">
<el-option :label="TEXT.enums.subjectStatus.SCREENING" value="SCREENING" /> <el-option :label="TEXT.enums.subjectStatus.SCREENING" value="SCREENING" />
<el-option :label="TEXT.enums.subjectStatus.ENROLLED" value="ENROLLED" /> <el-option :label="TEXT.enums.subjectStatus.ENROLLED" value="ENROLLED" />
<el-option :label="TEXT.enums.subjectStatus.COMPLETED" value="COMPLETED" /> <el-option :label="TEXT.enums.subjectStatus.COMPLETED" value="COMPLETED" />
@@ -44,16 +49,36 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item v-if="isEdit" :label="TEXT.common.fields.enrollmentDate"> <el-form-item v-if="isEdit" :label="TEXT.common.fields.enrollmentDate">
<el-date-picker v-model="form.enrollment_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" /> <el-date-picker
v-model="form.enrollment_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
:disabled="isReadOnly"
/>
</el-form-item> </el-form-item>
<el-form-item v-if="isEdit" :label="TEXT.common.fields.completionDate"> <el-form-item v-if="isEdit" :label="TEXT.common.fields.completionDate">
<el-date-picker v-model="form.completion_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" /> <el-date-picker
v-model="form.completion_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
:disabled="isReadOnly"
/>
</el-form-item> </el-form-item>
<el-form-item v-if="isEdit" :label="TEXT.common.fields.dropReason"> <el-form-item v-if="isEdit" :label="TEXT.common.fields.dropReason">
<el-input v-model="form.drop_reason" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" /> <el-input
v-model="form.drop_reason"
type="textarea"
:rows="3"
:placeholder="TEXT.common.placeholders.input"
:disabled="isReadOnly"
/>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button> <el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">
{{ TEXT.common.actions.save }}
</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button> <el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
@@ -75,10 +100,18 @@ const router = useRouter();
const study = useStudyStore(); const study = useStudyStore();
const saving = ref(false); const saving = ref(false);
const sites = ref<any[]>([]); const sites = ref<any[]>([]);
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.id) map[site.id] = !!site.is_active;
});
return map;
});
const subjectId = computed(() => route.params.subjectId as string | undefined); const subjectId = computed(() => route.params.subjectId as string | undefined);
const isEdit = computed(() => !!subjectId.value); const isEdit = computed(() => !!subjectId.value);
const studyId = computed(() => study.currentStudy?.id || ""); const studyId = computed(() => study.currentStudy?.id || "");
const isReadOnly = computed(() => isEdit.value && !!form.site_id && siteActiveMap.value[form.site_id] === false);
const form = reactive({ const form = reactive({
subject_no: "", subject_no: "",
@@ -121,6 +154,10 @@ const load = async () => {
}; };
const submit = async () => { const submit = async () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId.value) return; if (!studyId.value) return;
saving.value = true; saving.value = true;
try { try {