管理后台前后端逻辑、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 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 audit as audit_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.schemas.ae import AECreate, AERead, AEUpdate
@@ -25,6 +27,22 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
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:
member = await member_crud.get_member(db, study_id, user_id)
return member.role_in_study if member else None
@@ -38,7 +56,7 @@ def _is_overdue(ae: AERead) -> bool:
"/",
response_model=AERead,
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(
study_id: uuid.UUID,
@@ -117,7 +135,7 @@ async def get_ae(
@router.patch(
"/{ae_id}",
response_model=AERead,
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())],
)
async def update_ae(
study_id: uuid.UUID,
@@ -132,6 +150,7 @@ async def update_ae(
ae = await ae_crud.get_ae(db, ae_id)
if not ae or ae.study_id != study_id:
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)
if current_user.role == "ADMIN":
@@ -184,7 +203,7 @@ async def update_ae(
@router.delete(
"/{ae_id}",
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(
study_id: uuid.UUID,
@@ -196,6 +215,7 @@ async def delete_ae(
ae = await ae_crud.get_ae(db, ae_id)
if not ae or ae.study_id != study_id:
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)
if current_user.role != "ADMIN" and member_role not in {"PM", "PV"}:
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 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 audit as audit_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,
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(
study_id: uuid.UUID,
@@ -281,7 +281,7 @@ async def global_delete_attachment(
@router.delete(
"/{attachment_id}",
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(
study_id: uuid.UUID,
+18 -10
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_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 drug_shipment as shipment_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
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(
"/shipments",
response_model=DrugShipmentRead,
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(
study_id: uuid.UUID,
@@ -33,9 +42,7 @@ async def create_shipment(
current_user=Depends(get_current_user),
) -> DrugShipmentRead:
await _ensure_study_exists(db, study_id)
site = await site_crud.get_site(db, shipment_in.center_id)
if not site or site.study_id != study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分中心不存在")
site = await _ensure_center_active(db, study_id, shipment_in.center_id)
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)
await audit_crud.log_action(
@@ -102,7 +109,7 @@ async def get_shipment(
@router.patch(
"/shipments/{shipment_id}",
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(
study_id: uuid.UUID,
@@ -116,10 +123,10 @@ async def update_shipment(
if not shipment or shipment.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="运输记录不存在")
if shipment_in.center_id:
site = await site_crud.get_site(db, shipment_in.center_id)
if not site or site.study_id != study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分中心不存在")
site = await _ensure_center_active(db, study_id, shipment_in.center_id)
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)
await audit_crud.log_action(
db,
@@ -137,7 +144,7 @@ async def update_shipment(
@router.delete(
"/shipments/{shipment_id}",
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(
study_id: uuid.UUID,
@@ -149,6 +156,7 @@ async def delete_shipment(
shipment = await shipment_crud.get_shipment(db, shipment_id)
if not shipment or shipment.study_id != study_id:
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 audit_crud.log_action(
db,
+4 -1
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session
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 faq_category as category_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,
summary="创建 FAQ 分类",
description="创建全局或项目内 FAQ 分类,项目 PM/ADMIN 可用,全局仅 ADMIN。",
dependencies=[Depends(require_study_not_locked())],
)
async def create_category(
payload: CategoryCreate,
@@ -84,6 +85,7 @@ async def list_categories(
response_model=CategoryRead,
summary="更新 FAQ 分类",
description="更新分类名称或启停状态,项目 PM/ADMIN 或全局 ADMIN。",
dependencies=[Depends(require_study_not_locked())],
)
async def update_category(
category_id: uuid.UUID,
@@ -129,6 +131,7 @@ async def update_category(
status_code=status.HTTP_204_NO_CONTENT,
summary="删除 FAQ 分类",
description="仅 ADMIN 可删除分类,分类下存在 FAQ 时不可删除。",
dependencies=[Depends(require_study_not_locked())],
)
async def delete_category(
category_id: uuid.UUID,
+8 -1
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session
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 faq_category as category_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,
summary="创建 FAQ",
description="创建全局或项目内 FAQ,项目 FAQ 需项目 PM/ADMIN 权限。",
dependencies=[Depends(require_study_not_locked())],
)
async def create_faq(
payload: FaqCreate,
@@ -178,6 +179,7 @@ async def get_faq(
response_model=FaqRead,
summary="更新 FAQ",
description="更新 FAQ 内容或启停状态,项目内需 PM/ADMIN 权限。",
dependencies=[Depends(require_study_not_locked())],
)
async def update_faq(
item_id: uuid.UUID,
@@ -219,6 +221,7 @@ async def update_faq(
response_model=FaqRead,
summary="更新 FAQ 状态",
description="提问者或项目 PM/ADMIN 可确认已解决。",
dependencies=[Depends(require_study_not_locked())],
)
async def update_faq_status(
item_id: uuid.UUID,
@@ -248,6 +251,7 @@ async def update_faq_status(
response_model=FaqRead,
summary="设置最佳回复",
description="项目成员可设置最佳回复,全局仅 ADMIN。",
dependencies=[Depends(require_study_not_locked())],
)
async def set_best_reply(
item_id: uuid.UUID,
@@ -330,6 +334,7 @@ async def list_replies(
status_code=status.HTTP_201_CREATED,
summary="创建 FAQ 回复",
description="回复 FAQ,项目内成员可回复,全局仅 ADMIN。",
dependencies=[Depends(require_study_not_locked())],
)
async def create_reply(
item_id: uuid.UUID,
@@ -392,6 +397,7 @@ async def create_reply(
status_code=status.HTTP_204_NO_CONTENT,
summary="删除 FAQ",
description="删除 FAQ,项目内需 PM/ADMIN 权限,全局仅 ADMIN。",
dependencies=[Depends(require_study_not_locked())],
)
async def delete_faq(
item_id: uuid.UUID,
@@ -427,6 +433,7 @@ async def delete_faq(
status_code=status.HTTP_204_NO_CONTENT,
summary="删除 FAQ 回复",
description="删除 FAQ 回复,管理员、项目 PM 或回复者可删除。",
dependencies=[Depends(require_study_not_locked())],
)
async def delete_reply(
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 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.crud import audit as audit_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",
response_model=FeeApiResponse[FeeAttachmentRead],
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(
entity_type: str = Form(...),
@@ -220,7 +220,7 @@ async def download_fee_attachment(
@router.delete(
"/attachments/{attachment_id}",
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(
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 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 contract_fee as contract_fee_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
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:
if isinstance(value, Decimal):
return value
@@ -186,7 +194,7 @@ async def get_contract_fee(
"/contracts",
response_model=FeeApiResponse[ContractFeeRead],
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(
contract_in: ContractFeeCreate,
@@ -201,8 +209,8 @@ async def create_contract_fee(
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该中心已存在合同费用")
site = await site_crud.get_site(db, contract_in.center_id)
if not site or site.study_id != contract_in.project_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或不属于该项目")
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="中心不存在或已停用")
contract = await contract_fee_crud.create_contract_fee(db, contract_in)
await audit_crud.log_action(
@@ -221,7 +229,7 @@ async def create_contract_fee(
@router.patch(
"/contracts/{contract_id}",
response_model=FeeApiResponse[ContractFeeRead],
dependencies=[Depends(get_current_user)],
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
)
async def update_contract_fee(
contract_id: uuid.UUID,
@@ -233,6 +241,7 @@ async def update_contract_fee(
if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
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)
await audit_crud.log_action(
db,
@@ -250,7 +259,7 @@ async def update_contract_fee(
@router.delete(
"/contracts/{contract_id}",
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(
contract_id: uuid.UUID,
@@ -261,6 +270,7 @@ async def delete_contract_fee(
if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
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 audit_crud.log_action(
db,
@@ -278,7 +288,7 @@ async def delete_contract_fee(
"/contracts/{contract_id}/payments",
response_model=FeeApiResponse[ContractFeePaymentRead],
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(
contract_id: uuid.UUID,
@@ -308,7 +318,7 @@ async def create_contract_payment(
@router.patch(
"/payments/{payment_id}",
response_model=FeeApiResponse[ContractFeePaymentRead],
dependencies=[Depends(get_current_user)],
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
)
async def update_contract_payment(
payment_id: uuid.UUID,
@@ -349,7 +359,7 @@ async def update_contract_payment(
@router.delete(
"/payments/{payment_id}",
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(
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 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 member as member_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="费用类别无效")
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(
"/special",
response_model=FeeApiResponse[list[SpecialExpenseListItem]],
@@ -122,7 +130,7 @@ async def get_special_expense(
"/special",
response_model=FeeApiResponse[SpecialExpenseRead],
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(
expense_in: SpecialExpenseCreate,
@@ -134,8 +142,9 @@ async def create_special_expense(
_validate_special_rules(expense_in)
if 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:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或不属于该项目")
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="中心不存在或已停用")
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)
await audit_crud.log_action(
db,
@@ -153,7 +162,7 @@ async def create_special_expense(
@router.patch(
"/special/{expense_id}",
response_model=FeeApiResponse[SpecialExpenseRead],
dependencies=[Depends(get_current_user)],
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
)
async def update_special_expense(
expense_id: uuid.UUID,
@@ -181,8 +190,11 @@ async def update_special_expense(
_validate_special_rules(merged)
if 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:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或不属于该项目")
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="中心不存在或已停用")
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)
await audit_crud.log_action(
db,
@@ -200,7 +212,7 @@ async def update_special_expense(
@router.delete(
"/special/{expense_id}",
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(
expense_id: uuid.UUID,
@@ -211,6 +223,7 @@ async def delete_special_expense(
if not expense:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
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 audit_crud.log_action(
db,
+13 -1
View File
@@ -3,9 +3,10 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_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 finance_contract as contract_crud
from app.crud import site as site_crud
from app.crud import study as study_crud
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
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(
"/contracts",
response_model=FinanceContractRead,
@@ -32,6 +41,7 @@ async def create_contract(
current_user=Depends(get_current_user),
) -> FinanceContractRead:
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)
await audit_crud.log_action(
db,
@@ -97,6 +107,7 @@ async def update_contract(
contract = await contract_crud.get_contract(db, contract_id)
if not contract or contract.study_id != study_id:
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)
await audit_crud.log_action(
db,
@@ -126,6 +137,7 @@ async def delete_contract(
contract = await contract_crud.get_contract(db, contract_id)
if not contract or contract.study_id != study_id:
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 audit_crud.log_action(
db,
+13 -1
View File
@@ -3,9 +3,10 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_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 finance_special as special_crud
from app.crud import site as site_crud
from app.crud import study as study_crud
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
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(
"/specials",
response_model=FinanceSpecialRead,
@@ -32,6 +41,7 @@ async def create_special(
current_user=Depends(get_current_user),
) -> FinanceSpecialRead:
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)
await audit_crud.log_action(
db,
@@ -97,6 +107,7 @@ async def update_special(
item = await special_crud.get_special(db, special_id)
if not item or item.study_id != study_id:
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)
await audit_crud.log_action(
db,
@@ -126,6 +137,7 @@ async def delete_special(
item = await special_crud.get_special(db, special_id)
if not item or item.study_id != study_id:
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 audit_crud.log_action(
db,
+13 -1
View File
@@ -3,9 +3,10 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_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 knowledge_note as note_crud
from app.crud import site as site_crud
from app.crud import study as study_crud
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
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(
"/notes",
response_model=KnowledgeNoteRead,
@@ -32,6 +41,7 @@ async def create_note(
current_user=Depends(get_current_user),
) -> KnowledgeNoteRead:
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)
await audit_crud.log_action(
db,
@@ -97,6 +107,7 @@ async def update_note(
note = await note_crud.get_note(db, note_id)
if not note or note.study_id != study_id:
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)
await audit_crud.log_action(
db,
@@ -126,6 +137,7 @@ async def delete_note(
note = await note_crud.get_note(db, note_id)
if not note or note.study_id != study_id:
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 audit_crud.log_action(
db,
+18 -9
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status
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 study as study_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,
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(
study_id: uuid.UUID,
@@ -34,6 +34,13 @@ async def add_member(
await _ensure_study_exists(db, study_id)
existing = await member_crud.get_member(db, study_id, member_in.user_id)
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="成员已存在")
member = await member_crud.add_member(db, study_id, member_in)
return member
@@ -48,6 +55,7 @@ async def list_members(
study_id: uuid.UUID,
skip: int = 0,
limit: int = 100,
include_inactive: bool = False,
db: AsyncSession = Depends(get_db_session),
) -> list[StudyMemberReadWithUser]:
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)
result: list[StudyMemberReadWithUser] = []
for m in members:
# 仅返回项目内启用的成员 + 账号启用的用户
if not m.is_active:
continue
user = users_map.get(m.user_id)
if not user or not user.is_active:
continue
if not include_inactive:
# 仅返回项目内启用的成员 + 账号启用的用户
if not m.is_active:
continue
if not user or not user.is_active:
continue
result.append(
StudyMemberReadWithUser(
id=m.id,
@@ -79,7 +88,7 @@ async def list_members(
@router.patch(
"/{member_id}",
response_model=StudyMemberRead,
dependencies=[Depends(require_study_roles(["PM"]))],
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
)
async def update_member(
study_id: uuid.UUID,
@@ -98,7 +107,7 @@ async def update_member(
@router.delete(
"/{member_id}",
response_model=StudyMemberRead,
dependencies=[Depends(require_study_roles(["PM"]))],
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
)
async def remove_member(
study_id: uuid.UUID,
+48 -4
View File
@@ -3,10 +3,12 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status
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 startup as startup_crud
from app.crud import study as study_crud
from app.schemas.site import SiteCreate, SiteRead, SiteUpdate
from app.schemas.startup import StartupEthicsCreate, StartupFeasibilityCreate
router = APIRouter()
@@ -22,15 +24,28 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
"/",
response_model=SiteRead,
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(
study_id: uuid.UUID,
site_in: SiteCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> SiteRead:
await _ensure_study_exists(db, study_id)
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
@@ -43,17 +58,24 @@ async def list_sites(
study_id: uuid.UUID,
skip: int = 0,
limit: int = 100,
include_inactive: bool = False,
db: AsyncSession = Depends(get_db_session),
) -> list[SiteRead]:
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)
@router.patch(
"/{site_id}",
response_model=SiteRead,
dependencies=[Depends(require_study_roles(["PM"]))],
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
)
async def update_site(
study_id: uuid.UUID,
@@ -67,3 +89,25 @@ async def update_site(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分中心不存在")
updated = await site_crud.update_site(db, site, site_in)
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 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 site as site_crud
from app.crud import startup as startup_crud
from app.crud import study as study_crud
from app.schemas.startup import (
@@ -32,6 +33,22 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
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(
"/feasibility",
response_model=StartupFeasibilityRead,
@@ -45,6 +62,7 @@ async def create_feasibility(
current_user=Depends(get_current_user),
) -> StartupFeasibilityRead:
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)
await audit_crud.log_action(
db,
@@ -108,6 +126,7 @@ async def update_feasibility(
record = await startup_crud.get_feasibility(db, record_id)
if not record or record.study_id != study_id:
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)
await audit_crud.log_action(
db,
@@ -137,6 +156,7 @@ async def delete_feasibility(
record = await startup_crud.get_feasibility(db, record_id)
if not record or record.study_id != study_id:
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 audit_crud.log_action(
db,
@@ -163,6 +183,7 @@ async def create_ethics(
current_user=Depends(get_current_user),
) -> StartupEthicsRead:
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)
await audit_crud.log_action(
db,
@@ -226,6 +247,7 @@ async def update_ethics(
record = await startup_crud.get_ethics(db, record_id)
if not record or record.study_id != study_id:
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)
await audit_crud.log_action(
db,
@@ -255,6 +277,7 @@ async def delete_ethics(
record = await startup_crud.get_ethics(db, record_id)
if not record or record.study_id != study_id:
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 audit_crud.log_action(
db,
@@ -281,6 +304,7 @@ async def create_kickoff(
current_user=Depends(get_current_user),
) -> KickoffMeetingRead:
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)
await audit_crud.log_action(
db,
@@ -344,6 +368,7 @@ async def update_kickoff(
meeting = await startup_crud.get_kickoff(db, meeting_id)
if not meeting or meeting.study_id != study_id:
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)
await audit_crud.log_action(
db,
@@ -371,6 +396,7 @@ async def create_training_authorization(
current_user=Depends(get_current_user),
) -> TrainingAuthorizationRead:
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)
await audit_crud.log_action(
db,
@@ -434,6 +460,7 @@ async def update_training_authorization(
record = await startup_crud.get_training_authorization(db, record_id)
if not record or record.study_id != study_id:
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)
await audit_crud.log_action(
db,
@@ -463,6 +490,7 @@ async def delete_training_authorization(
record = await startup_crud.get_training_authorization(db, record_id)
if not record or record.study_id != study_id:
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 audit_crud.log_action(
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.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.study import StudyCreate, StudyRead, StudyUpdate
from app.schemas.member import StudyMemberCreate
from app.utils.pagination import paginate
router = APIRouter()
@@ -22,6 +25,15 @@ async def create_study(
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="项目编号已存在")
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
@@ -65,5 +77,96 @@ async def update_study(
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 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)
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 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 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 study as study_crud
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
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(
"/histories",
response_model=SubjectHistoryRead,
@@ -33,6 +43,10 @@ async def create_history(
current_user=Depends(get_current_user),
) -> SubjectHistoryRead:
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:
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)
@@ -62,6 +76,9 @@ async def list_histories(
db: AsyncSession = Depends(get_db_session),
) -> list[SubjectHistoryRead]:
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)
return [SubjectHistoryRead.model_validate(item) for item in items]
@@ -78,6 +95,9 @@ async def get_history(
db: AsyncSession = Depends(get_db_session),
) -> SubjectHistoryRead:
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)
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="病史记录不存在")
@@ -98,6 +118,9 @@ async def update_history(
current_user=Depends(get_current_user),
) -> SubjectHistoryRead:
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)
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="病史记录不存在")
@@ -128,6 +151,9 @@ async def delete_history(
current_user=Depends(get_current_user),
) -> None:
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)
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="病史记录不存在")
+16 -9
View File
@@ -3,8 +3,9 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_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 site as site_crud
from app.crud import subject as subject_crud
from app.crud import study as study_crud
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
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(
"/",
response_model=SubjectRead,
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(
study_id: uuid.UUID,
@@ -57,14 +66,10 @@ async def create_subject(
async def list_subjects(
study_id: uuid.UUID,
site_id: uuid.UUID | None = None,
status_filter: str | None = None,
subject_no: str | None = None,
db: AsyncSession = Depends(get_db_session),
) -> list[SubjectRead]:
await _ensure_study_exists(db, study_id)
subjects = await subject_crud.list_subjects(
db, study_id, site_id=site_id, status=status_filter, subject_no=subject_no
)
subjects = await subject_crud.list_subjects(db, study_id, site_id=site_id)
return list(subjects)
@@ -82,13 +87,14 @@ async def get_subject(
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)
return subject
@router.patch(
"/{subject_id}",
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(
study_id: uuid.UUID,
@@ -101,6 +107,7 @@ async def update_subject(
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)
old_status = subject.status
updated = await subject_crud.update_subject(db, subject, subject_in)
# auto-generate visits when enrolled
@@ -125,7 +132,7 @@ async def update_subject(
@router.delete(
"/{subject_id}",
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(
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 app.core.deps import get_db_session, require_roles
from app.core.security import verify_password
from app.schemas.common import PaginatedResponse
from app.crud import user as user_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)
async def delete_user(
user_id: uuid.UUID,
payload: dict,
db: AsyncSession = Depends(get_db_session),
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)
if not db_user:
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 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 site as site_crud
from app.crud import subject as subject_crud
from app.crud import study as study_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
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(
"/",
response_model=list[VisitRead],
@@ -39,7 +48,7 @@ async def list_visits(
"/",
response_model=VisitRead,
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(
study_id: uuid.UUID,
@@ -49,6 +58,7 @@ async def create_visit(
current_user=Depends(get_current_user),
) -> VisitRead:
subject = await _ensure_subject(db, study_id, subject_id)
await _ensure_subject_active(db, subject)
if visit_in.subject_id != subject_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="参与者不匹配")
visit = await visit_crud.create_visit(
@@ -88,7 +98,7 @@ async def create_visit(
@router.patch(
"/{visit_id}",
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(
study_id: uuid.UUID,
@@ -98,7 +108,8 @@ async def update_visit(
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> 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)
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="访视不存在")
@@ -124,7 +135,7 @@ async def update_visit(
@router.delete(
"/{visit_id}",
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(
study_id: uuid.UUID,
@@ -133,7 +144,8 @@ async def delete_visit(
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> 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)
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="访视不存在")
+24
View File
@@ -115,3 +115,27 @@ def require_study_roles(roles: Iterable[str]):
return current_user
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()
if not site or site.study_id != study_id:
raise ValueError("分中心不属于当前项目")
if not site.is_active:
raise ValueError("中心已停用")
if subject_id:
result = await db.execute(select(Subject).where(Subject.id == subject_id))
subj = result.scalar_one_or_none()
if not subj or subj.study_id != study_id:
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(
+4 -1
View File
@@ -43,7 +43,10 @@ async def list_contracts(
skip: int = 0,
limit: int = 100,
) -> Sequence[FinanceContract]:
stmt = select(FinanceContract).where(FinanceContract.study_id == study_id)
stmt = (
select(FinanceContract)
.where(FinanceContract.study_id == study_id)
)
if site_name:
stmt = stmt.where(FinanceContract.site_name.ilike(f"%{site_name}%"))
if contract_no:
+4 -1
View File
@@ -43,7 +43,10 @@ async def list_specials(
skip: int = 0,
limit: int = 100,
) -> Sequence[FinanceSpecial]:
stmt = select(FinanceSpecial).where(FinanceSpecial.study_id == study_id)
stmt = (
select(FinanceSpecial)
.where(FinanceSpecial.study_id == study_id)
)
if site_name:
stmt = stmt.where(FinanceSpecial.site_name.ilike(f"%{site_name}%"))
if fee_type:
+4 -1
View File
@@ -41,7 +41,10 @@ async def list_notes(
skip: int = 0,
limit: int = 100,
) -> Sequence[KnowledgeNote]:
stmt = select(KnowledgeNote).where(KnowledgeNote.study_id == study_id)
stmt = (
select(KnowledgeNote)
.where(KnowledgeNote.study_id == study_id)
)
if site_name:
stmt = stmt.where(KnowledgeNote.site_name.ilike(f"%{site_name}%"))
if keyword:
+2 -7
View File
@@ -1,7 +1,7 @@
import uuid
from typing import Sequence
from sqlalchemy import select, update
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
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:
await db.execute(
update(StudyMember)
.where(StudyMember.id == member.id)
.values(is_active=False)
)
await db.execute(delete(StudyMember).where(StudyMember.id == member.id))
await db.commit()
await db.refresh(member)
return member
+312 -6
View File
@@ -1,12 +1,42 @@
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 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.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
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:
site = Site(
@@ -41,10 +71,17 @@ async def update_site(db: AsyncSession, site: Site, site_in: SiteUpdate) -> Site
return site
async def list_by_study(db: AsyncSession, study_id: uuid.UUID, skip: int = 0, limit: int = 100) -> Sequence[Site]:
result = await db.execute(
select(Site).where(Site.study_id == study_id).offset(skip).limit(limit)
)
async def list_by_study(
db: AsyncSession,
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()
@@ -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)))
sites = result.scalars().all()
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
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 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:
import uuid as uuid_lib # Import uuid to generate unique code if needed
code = study_in.code
if not code:
# Generate a unique code if not provided
# Use first 8 chars of a new UUID, uppercase
code = f"P-{str(uuid_lib.uuid4())[:8].upper()}"
study = Study(
code=study_in.code,
code=code,
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,
phase=study_in.phase,
status=study_in.status,
@@ -73,3 +85,120 @@ async def list_studies_for_user(
)
result = await db.execute(stmt)
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()
if not site or site.study_id != study_id:
raise ValueError("分中心不属于当前项目")
if not site.is_active:
raise ValueError("中心已停用")
async def create_subject(db: AsyncSession, study_id: uuid.UUID, subject_in: SubjectCreate) -> Subject:
@@ -58,16 +60,10 @@ async def list_subjects(
db: AsyncSession,
study_id: uuid.UUID,
site_id: uuid.UUID | None = None,
status: str | None = None,
subject_no: str | None = None,
) -> Sequence[Subject]:
stmt = select(Subject).where(Subject.study_id == study_id)
if 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)
return result.scalars().all()
+2 -1
View File
@@ -1,7 +1,7 @@
import uuid
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.orm import Mapped, mapped_column
@@ -18,6 +18,7 @@ class Study(Base):
protocol_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
phase: Mapped[str | None] = mapped_column(String(50), nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="DRAFT")
is_locked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
visit_interval_days: 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)
+4 -6
View File
@@ -8,9 +8,8 @@ StudyStatus = Literal["DRAFT", "ACTIVE", "CLOSED"]
class StudyCreate(BaseModel):
code: str = Field(min_length=1)
name: str = Field(min_length=1)
sponsor: Optional[str] = None
code: Optional[str] = None
protocol_no: Optional[str] = None
phase: Optional[str] = None
status: StudyStatus = "DRAFT"
@@ -21,12 +20,11 @@ class StudyCreate(BaseModel):
class StudyUpdate(BaseModel):
code: Optional[str] = None
name: Optional[str] = None
sponsor: Optional[str] = None
protocol_no: Optional[str] = None
phase: Optional[str] = None
status: Optional[StudyStatus] = None
is_locked: Optional[bool] = None
visit_interval_days: Optional[int] = None
visit_total: Optional[int] = None
visit_window_start_offset: Optional[int] = None
@@ -35,12 +33,12 @@ class StudyUpdate(BaseModel):
class StudyRead(BaseModel):
id: uuid.UUID
code: str
name: str
sponsor: Optional[str]
protocol_no: Optional[str]
protocol_no: Optional[str]
phase: Optional[str]
status: StudyStatus
is_locked: bool
visit_interval_days: Optional[int]
visit_total: 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_version as version_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 user as user_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:
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
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)
if existing:
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";
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 }) =>
apiPost<Site>(`/api/v1/studies/${studyId}/sites/`, payload);
export const updateSite = (studyId: string, siteId: string, payload: Partial<Site>) =>
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";
// Backend expects trailing slash to avoid 307 redirect
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);
export const updateStudy = (studyId: string, payload: Partial<Study>) =>
apiPatch<Study>(`/api/v1/studies/${studyId}`, payload);
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);
export const deleteUser = (userId: string, payload: { admin_password: string }) =>
apiDelete<void>(`/api/v1/users/${userId}`, { data: payload });
export const deleteUser = (userId: string) =>
apiDelete<void>(`/api/v1/users/${userId}`);
@@ -3,6 +3,7 @@
<div class="header">
<span>{{ headerTitle }}</span>
<AttachmentUploader
v-if="!readonly"
:study-id="studyId"
:entity-type="entityType"
:entity-id="entityId"
@@ -75,6 +76,7 @@ const props = defineProps<{
entityType: string;
entityId: string;
title?: string;
readonly?: boolean;
}>();
const attachments = ref<any[]>([]);
@@ -199,6 +201,7 @@ const preview = (row: any) => {
};
const canDelete = (row: any) => {
if (props.readonly) return false;
const userId = auth.user?.id;
const role = auth.user?.role;
const projectRole = study.currentStudyRole || (study.currentStudy as any)?.role_in_study;
+5 -4
View File
@@ -716,10 +716,10 @@ export const TEXT = {
loadFailed: "用户列表加载失败",
updateSuccess: "用户已更新",
createSuccess: "用户已创建",
deleteConfirm: "删除后账号将无法登录且无法恢复,需先在各项目成员配置中移除该账号。请输入当前管理员密码确认删除。",
deleteConfirm: "删除后不可恢复,请输入“确认删除”继续。",
deleteTitle: "危险操作:删除账号",
deletePasswordPlaceholder: "请输入 admin 密码",
deleteConfirmButton: "确认删除",
deleteConfirmPlaceholder: "确认删除",
deleteConfirmInputError: "请输入“确认删除",
deleteSuccess: "账号已删除",
deleteFailed: "删除失败",
enableConfirm: "确认启用该用户账号?",
@@ -752,7 +752,7 @@ export const TEXT = {
resetFailed: "重置失败",
},
adminProjects: {
title: "项目理",
title: "项目理",
subtitle: "项目是独立业务实体,账号加入项目后才成为项目成员(角色在成员配置中设置)",
projectLabel: "项目",
members: "项目账号/成员配置",
@@ -827,6 +827,7 @@ export const TEXT = {
craSaveSuccess: "绑定已保存",
},
adminAuditLogs: {
title: "审计日志",
filterEvent: "操作类型",
filterOperator: "操作人",
filterResult: "结果",
+1
View File
@@ -65,6 +65,7 @@ export interface Study {
protocol_no?: string | null;
phase?: string | null;
status: string;
is_locked?: boolean;
visit_interval_days?: number | null;
visit_total?: number | null;
visit_window_start_offset?: number | null;
+72 -17
View File
@@ -1,15 +1,20 @@
<template>
<div class="page">
<el-card>
<div class="header">
<div>
<h3>{{ TEXT.modules.adminAuditLogs.title }}</h3>
</div>
</div>
<div class="ctms-table-toolbar">
<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-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-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.resultFail" value="FAIL" />
</el-select>
@@ -21,7 +26,7 @@
:end-placeholder="TEXT.modules.adminAuditLogs.rangeEnd"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
style="width: 240px"
class="ctms-filter-item date-range-picker"
@change="loadLogs"
/>
</div>
@@ -49,33 +54,40 @@
</div>
</div>
<el-table :data="logs" v-loading="loading" style="width: 100%">
<el-table-column prop="timestamp" :label="TEXT.modules.adminAuditLogs.columns.time" width="180">
<template #default="scope">{{ displayDateTime(scope.row.timestamp) }}</template>
<el-table :data="logs" v-loading="loading" style="width: 100%" stripe>
<el-table-column prop="timestamp" :label="TEXT.modules.adminAuditLogs.columns.time" width="180" align="center">
<template #default="scope">
<span class="text-gray-500">{{ displayDateTime(scope.row.timestamp) }}</span>
</template>
</el-table-column>
<el-table-column prop="actorName" :label="TEXT.modules.adminAuditLogs.columns.actor" width="140" />
<el-table-column prop="actorRoleLabel" :label="TEXT.common.labels.role" width="120" />
<el-table-column prop="eventLabel" :label="TEXT.modules.adminAuditLogs.columns.event" 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 :label="TEXT.modules.adminAuditLogs.columns.target" min-width="180">
<template #default="scope">
<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 v-else>{{ TEXT.common.fallback }}</span>
</template>
</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">
<div v-if="scope.row.diffText?.length">
<div v-for="(line, idx) in scope.row.diffText" :key="idx">{{ line }}</div>
<div v-if="scope.row.diffText?.length" class="diff-container">
<div v-for="(line, idx) in scope.row.diffText" :key="idx" class="diff-line">{{ line }}</div>
</div>
<span v-else>{{ TEXT.audit.emptyValue }}</span>
<span v-else class="text-gray-400">{{ TEXT.audit.emptyValue }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.result" width="120">
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.result" width="100" align="center">
<template #default="scope">
<el-tag :type="scope.row.result === 'SUCCESS' ? 'success' : 'danger'">
<el-tag :type="scope.row.result === 'SUCCESS' ? 'success' : 'danger'" effect="dark" size="small">
{{ scope.row.resultLabel }}
</el-tag>
</template>
@@ -322,10 +334,53 @@ onMounted(async () => {
<style scoped>
.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 {
margin-top: 12px;
margin-top: 24px;
text-align: right;
}
</style>
+443 -40
View File
@@ -1,79 +1,320 @@
<template>
<div class="ctms-page">
<div class="ctms-page-header">
<div>
<h1 class="ctms-page-title">{{ TEXT.modules.adminProjects.detailTitle }}</h1>
<div class="page-header-section">
<div class="header-top">
<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 class="ctms-page-actions">
<el-button type="primary" @click="enterProject" :disabled="!project">{{ TEXT.modules.adminProjects.enter }}</el-button>
<el-button @click="goMembers" :disabled="!project">{{ TEXT.modules.adminProjects.members }}</el-button>
<el-button @click="goSites" :disabled="!project">{{ TEXT.modules.adminProjects.sites }}</el-button>
<div class="quick-actions">
<div class="action-card" @click="enterProject" :class="{ disabled: !project }">
<el-icon class="action-icon"><Promotion /></el-icon>
<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>
<el-card class="ctms-section-card">
<template #header>
<div>
<div class="ctms-section-header">
<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>
</template>
<el-descriptions v-if="project" :column="2" border class="detail-descriptions">
<el-descriptions-item :label="TEXT.common.fields.projectCode">{{ project.code }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.projectName">{{ project.name }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.sponsor">{{ project.sponsor || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.status">{{ statusLabel(project.status) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.modules.adminProjects.createdAt">{{ displayDateTime(project.created_at) }}</el-descriptions-item>
</el-descriptions>
<StateLoading v-else :rows="4" />
</el-card>
<el-form v-if="project" ref="formRef" :model="form" :rules="rules" label-width="120px" class="detail-form" label-position="top">
<el-row :gutter="24">
<el-col :span="16">
<el-form-item :label="TEXT.common.fields.projectName" prop="name">
<el-input v-model="form.name" :disabled="!isEditing" />
</el-form-item>
</el-col>
<el-col :span="8">
<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">
<template #header>
<div>
<div class="ctms-section-title">{{ TEXT.modules.adminProjects.filesTitle }}</div>
</div>
</template>
<AttachmentList
v-if="project"
:study-id="project.id"
entity-type="project"
:entity-id="project.id"
:show-uploader="true"
/>
<StateLoading v-else :rows="3" />
<el-row :gutter="24">
<el-col :span="8">
<el-form-item :label="TEXT.common.fields.status" prop="status">
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" :disabled="!isEditing" class="w-full">
<el-option :label="TEXT.enums.projectStatus.DRAFT" value="DRAFT" />
<el-option :label="TEXT.enums.projectStatus.ACTIVE" value="ACTIVE" />
<el-option :label="TEXT.enums.projectStatus.CLOSED" value="CLOSED" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<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" />
</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>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { fetchStudyDetail } from "../../api/studies";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
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 AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateLoading from "../../components/StateLoading.vue";
import { useStudyStore } from "../../store/study";
import { displayDateTime } from "../../utils/display";
import { TEXT } from "../../locales";
import { TEXT, requiredMessage } from "../../locales";
const route = useRoute();
const router = useRouter();
const studyStore = useStudyStore();
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 () => {
try {
const { data } = await fetchStudyDetail(route.params.projectId as string);
project.value = data as Study;
syncForm(project.value);
isEditing.value = false;
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjects.loadFailed);
}
};
const statusLabel = (status: string) =>
TEXT.enums.projectStatus[status as keyof typeof TEXT.enums.projectStatus] || status;
const startEdit = () => {
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 = () => {
if (!project.value) return;
@@ -97,7 +338,169 @@ onMounted(() => {
</script>
<style scoped>
.detail-descriptions :deep(.el-descriptions__label) {
color: var(--ctms-text-secondary);
.page-header-section {
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>
-16
View File
@@ -1,15 +1,9 @@
<template>
<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-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-input v-model="form.name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.projectName" />
</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-input v-model="form.protocol_no" :placeholder="TEXT.modules.adminProjects.protocolPlaceholder" />
</el-form-item>
@@ -69,9 +63,7 @@ const visibleProxy = computed({
const formRef = ref<FormInstance>();
const submitting = ref(false);
const form = reactive({
code: "",
name: "",
sponsor: "",
protocol_no: "",
phase: "",
status: "DRAFT",
@@ -82,15 +74,12 @@ const form = reactive({
});
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" }],
status: [{ required: true, message: requiredMessage(TEXT.common.fields.status), trigger: "change" }],
});
const resetForm = () => {
form.code = "";
form.name = "";
form.sponsor = "";
form.protocol_no = "";
form.phase = "";
form.status = "DRAFT";
@@ -106,9 +95,7 @@ watch(
if (val) {
resetForm();
if (props.project) {
form.code = props.project.code;
form.name = props.project.name;
form.sponsor = props.project.sponsor || "";
form.protocol_no = props.project.protocol_no || "";
form.phase = props.project.phase || "";
form.status = props.project.status || "DRAFT";
@@ -129,7 +116,6 @@ const onSubmit = async () => {
if (props.project) {
await updateStudy(props.project.id, {
name: form.name,
sponsor: form.sponsor,
protocol_no: form.protocol_no,
phase: form.phase,
status: form.status,
@@ -141,9 +127,7 @@ const onSubmit = async () => {
ElMessage.success(TEXT.modules.adminProjects.updateSuccess);
} else {
await createStudy({
code: form.code,
name: form.name,
sponsor: form.sponsor,
protocol_no: form.protocol_no,
phase: form.phase,
status: form.status,
+6 -4
View File
@@ -5,7 +5,6 @@
<div>
<h3>{{ TEXT.modules.adminProjectMembers.title }}</h3>
<div class="sub" v-if="project">{{ TEXT.modules.adminProjectMembers.projectPrefix }}{{ project.code }} - {{ project.name }}</div>
<div class="sub">{{ TEXT.modules.adminProjectMembers.subtitle }}</div>
</div>
<el-button type="primary" @click="openAdd">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjectMembers.memberLabel }}</el-button>
</div>
@@ -39,7 +38,7 @@
v-model="scope.row.role_in_study"
size="small"
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'"
>
<el-option :label="TEXT.enums.userRole.PM" value="PM" />
@@ -144,7 +143,7 @@ const loadMembers = async () => {
if (!projectId.value) return;
loading.value = true;
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 || [];
} catch (e: any) {
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 () => {
await Promise.all([loadProject(), loadUsers()]);
+162 -27
View File
@@ -4,36 +4,54 @@
<div class="header">
<div>
<h3>{{ TEXT.modules.adminProjects.title }}</h3>
<p class="sub">{{ TEXT.modules.adminProjects.subtitle }}</p>
</div>
<el-button type="primary" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjects.projectLabel }}</el-button>
</div>
<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">
<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>
</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">
<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>
</el-table-column>
<el-table-column prop="sponsor" :label="TEXT.common.fields.sponsor" min-width="160" />
<el-table-column prop="status" :label="TEXT.common.fields.status" width="120">
<el-table-column prop="is_locked" label="锁定状态" min-width="100" align="center">
<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>
</el-table-column>
<el-table-column prop="created_at" :label="TEXT.modules.adminUsers.createdAt" width="200">
<template #default="scope">{{ displayDateTime(scope.row.created_at) }}</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="320" fixed="right">
<el-table-column prop="created_at" :label="TEXT.modules.adminUsers.createdAt" min-width="180" align="center">
<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="goMembers(scope.row)">{{ TEXT.modules.adminProjects.members }}</el-button>
<el-button link type="primary" size="small" @click="goSites(scope.row)">{{ TEXT.modules.adminProjects.sites }}</el-button>
<el-button link type="success" size="small" @click="enterStudy(scope.row)">{{ TEXT.modules.adminProjects.enter }}</el-button>
<span class="text-gray-500">{{ displayDateTime(scope.row.created_at) }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" min-width="280" align="center">
<template #default="scope">
<el-tooltip :content="TEXT.modules.adminProjects.members" placement="top">
<el-button link type="primary" :icon="User" class="action-btn" @click="goMembers(scope.row)" />
</el-tooltip>
<el-tooltip :content="TEXT.modules.adminProjects.sites" placement="top">
<el-button link type="primary" :icon="OfficeBuilding" class="action-btn" @click="goSites(scope.row)" />
</el-tooltip>
<el-tooltip :content="TEXT.modules.adminProjects.enter" placement="top">
<el-button link type="success" :icon="ArrowRight" class="action-btn enter-btn" @click="enterStudy(scope.row)" />
</el-tooltip>
<el-tooltip v-if="!scope.row.is_locked" content="锁定项目" placement="top">
<el-button
link
type="info"
:icon="Lock"
class="action-btn"
@click="handleLockToggle(scope.row)"
/>
</el-tooltip>
<el-tooltip :content="TEXT.common.actions.delete" placement="top">
<el-button link type="danger" :icon="Delete" class="action-btn" @click="handleDelete(scope.row)" />
</el-tooltip>
</template>
</el-table-column>
</el-table>
@@ -45,8 +63,9 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { fetchStudies } from "../../api/studies";
import { ElMessage, ElMessageBox } from "element-plus";
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 ProjectForm from "./ProjectForm.vue";
import { useStudyStore } from "../../store/study";
@@ -78,11 +97,6 @@ const openCreate = () => {
formVisible.value = true;
};
const openEdit = (row: Study) => {
editingProject.value = row;
formVisible.value = true;
};
const goMembers = (row: Study) => {
router.push(`/admin/projects/${row.id}/members`);
};
@@ -91,6 +105,79 @@ const goSites = (row: Study) => {
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) => {
studyStore.setCurrentStudy({ ...row, role_in_study: "PM" } as Study);
router.push("/project/overview");
@@ -117,16 +204,64 @@ onMounted(() => {
<style scoped>
.page {
padding: 16px;
padding: 24px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
margin-bottom: 24px;
}
.header h3 {
font-size: 20px;
font-weight: 600;
color: #1f2937;
margin: 0;
}
.sub {
color: #666;
margin-top: 4px;
color: #6b7280;
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>
+70 -9
View File
@@ -8,7 +8,7 @@
</div>
<el-button type="primary" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminSites.siteLabel }}</el-button>
</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="city" :label="TEXT.common.fields.city" width="140" />
<el-table-column prop="pi_name" :label="TEXT.modules.adminSites.piLabel" width="160" />
@@ -24,11 +24,11 @@
</el-table-column>
<el-table-column :label="TEXT.common.fields.status" width="120">
<template #default="scope">
<el-tag type="success" v-if="scope.row.is_active">{{ TEXT.common.actions.enable }}</el-tag>
<el-tag type="danger" v-else>{{ TEXT.common.actions.disable }}</el-tag>
<el-tag type="success" v-if="scope.row.is_active">已解锁</el-tag>
<el-tag type="danger" v-else>已锁定</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="220" fixed="right">
<el-table-column :label="TEXT.common.labels.actions" width="260" fixed="right">
<template #default="scope">
<el-button link type="primary" size="small" @click="openEdit(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
<el-button
@@ -37,7 +37,16 @@
size="small"
@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>
</template>
</el-table-column>
@@ -60,7 +69,7 @@ import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { fetchStudyDetail } from "../../api/studies";
import { fetchSites, updateSite } from "../../api/sites";
import { deleteSite, fetchSites, updateSite } from "../../api/sites";
import { fetchUsers } from "../../api/users";
import { listMembers } from "../../api/members";
import SiteForm from "./SiteForm.vue";
@@ -81,6 +90,7 @@ const members = ref<any[]>([]);
const loading = ref(false);
const formVisible = ref(false);
const editingSite = ref<Site | null>(null);
const isAdmin = computed(() => auth.user?.role === "ADMIN");
const loadProject = async () => {
if (!projectId.value) return;
@@ -96,7 +106,7 @@ const loadSites = async () => {
if (!projectId.value) return;
loading.value = true;
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 || [];
} catch (e: any) {
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 siteRowClass = ({ row }: { row: Site }) => (row.is_active ? "" : "row-inactive");
const openCreate = () => {
loadUsers();
loadMembers();
@@ -179,15 +191,16 @@ const toggleSite = async (row: Site) => {
return;
}
if (row.is_active) {
const ok = await ElMessageBox.confirm(TEXT.modules.adminSites.disableConfirm, TEXT.modules.adminSites.disableTitle, {
const ok = await ElMessageBox.confirm("锁定后该中心将不可编辑,但数据会保留,确认锁定?", "锁定中心", {
type: "warning",
confirmButtonText: TEXT.common.actions.confirm,
cancelButtonText: TEXT.common.actions.cancel,
}).catch(() => null);
if (!ok) return;
}
try {
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();
logAudit("SITE_STATUS_CHANGED", {
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 () => {
await Promise.all([loadProject(), loadUsers(), loadMembers()]);
loadSites();
@@ -229,4 +278,16 @@ onMounted(async () => {
color: #666;
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>
+100 -59
View File
@@ -4,7 +4,6 @@
<div class="header">
<div>
<h3>{{ TEXT.modules.adminUsers.title }}</h3>
<p class="sub">{{ TEXT.modules.adminUsers.subtitle }}</p>
</div>
<el-button type="primary" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminUsers.userLabel }}</el-button>
</div>
@@ -22,28 +21,32 @@
<el-table-column prop="created_at" :label="TEXT.modules.adminUsers.createdAt" width="200">
<template #default="scope">{{ displayDateTime(scope.row.created_at) }}</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="320" fixed="right">
<el-table-column :label="TEXT.common.labels.actions" min-width="200" align="center">
<template #default="scope">
<el-button link type="primary" size="small" @click="openEdit(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
<el-button
link
:type="scope.row.status === 'ACTIVE' ? 'danger' : 'primary'"
size="small"
:disabled="isLastAdmin(scope.row)"
@click="toggleStatus(scope.row)"
>
{{ scope.row.status === 'ACTIVE' ? TEXT.common.actions.disable : TEXT.common.actions.enable }}
</el-button>
<el-button link type="danger" size="small" @click="openReset(scope.row)">{{ TEXT.modules.adminUsers.resetPassword }}</el-button>
<el-button
link
type="danger"
size="small"
:disabled="scope.row.id === auth.user?.id"
@click="onDelete(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
<el-tooltip :content="TEXT.common.actions.edit" placement="top">
<el-button link type="primary" :icon="Edit" @click="openEdit(scope.row)" />
</el-tooltip>
<el-tooltip :content="scope.row.status === 'ACTIVE' ? TEXT.common.actions.disable : TEXT.common.actions.enable" placement="top">
<el-button
link
:type="scope.row.status === 'ACTIVE' ? 'warning' : 'success'"
:icon="scope.row.status === 'ACTIVE' ? Lock : Unlock"
:disabled="isLastAdmin(scope.row)"
@click="toggleStatus(scope.row)"
/>
</el-tooltip>
<el-tooltip :content="TEXT.modules.adminUsers.resetPassword" placement="top">
<el-button link type="warning" :icon="Key" @click="openReset(scope.row)" />
</el-tooltip>
<el-tooltip :content="TEXT.common.actions.delete" placement="top">
<el-button
link
type="danger"
:icon="Delete"
:disabled="scope.row.id === auth.user?.id"
@click="onDelete(scope.row)"
/>
</el-tooltip>
</template>
</el-table-column>
</el-table>
@@ -66,7 +69,10 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
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 { fetchStudies } from "../../api/studies";
import { listMembers } from "../../api/members";
import type { UserInfo } from "../../types/api";
import UserForm from "./UserForm.vue";
import UserResetPassword from "./UserResetPassword.vue";
@@ -86,6 +92,37 @@ const editingUser = ref<UserInfo | null>(null);
const resetUser = ref<UserInfo | null>(null);
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 () => {
loading.value = true;
try {
@@ -146,67 +183,71 @@ const openReset = (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.deleteTitle,
{
inputType: "password",
inputPlaceholder: TEXT.modules.adminUsers.deletePasswordPlaceholder,
confirmButtonText: TEXT.modules.adminUsers.deleteConfirmButton,
inputPlaceholder: TEXT.modules.adminUsers.deleteConfirmPlaceholder,
confirmButtonText: TEXT.common.actions.confirm,
confirmButtonClass: "el-button--danger",
cancelButtonText: TEXT.common.actions.cancel,
inputPattern: /^确认删除$/,
inputErrorMessage: TEXT.modules.adminUsers.deleteConfirmInputError,
}
).catch(() => ({ action: "cancel", value: "" }));
if (action !== "confirm" || !value) return;
).catch(() => ({ action: "cancel" }));
if (action !== "confirm") return;
try {
await deleteUser(row.id, { admin_password: value });
await deleteUser(row.id);
ElMessage.success(TEXT.modules.adminUsers.deleteSuccess);
loadUsers();
} catch (e: any) {
const detail = e?.response?.data?.detail || e?.response?.data?.message;
if (detail && detail.includes(TEXT.modules.adminProjectMembers.memberLabel)) {
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 {
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(() => {
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>
<style scoped>
@@ -10,6 +10,7 @@
<p class="ctms-page-subtitle">
<el-tag size="small" effect="plain" type="info" class="mr-2">{{ displayText(detail.doc_type, TEXT.enums.documentType) }}</el-tag>
<el-tag size="small" effect="plain" type="warning" class="mr-2">{{ displaySite(detail.site_id) }}</el-tag>
<el-tag v-if="isReadOnly" size="small" effect="plain" type="info">中心已停用</el-tag>
<span class="text-secondary text-sm">
{{ TEXT.modules.fileVersionManagement.labels.currentEffective }}
<span class="font-medium text-main">{{ detail.current_effective_version?.version_no || TEXT.common.fallback }}</span>
@@ -373,6 +374,15 @@ const sites = ref<Site[]>([]);
const members = ref<StudyMember[]>([]);
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(() =>
users.value.reduce<Record<string, string>>((acc, user) => {
acc[user.id] = getUserDisplayName(user) || user.email || user.username || user.id;
@@ -468,6 +478,7 @@ const createTarget = () => ({
});
const canAction = (action: string) => {
if (isReadOnly.value && action !== "view") return false;
const list = detail.can_actions || [];
if (list.length === 0) return true;
return list.includes(action);
+40 -5
View File
@@ -3,7 +3,6 @@
<div class="ctms-page-header">
<div>
<h1 class="ctms-page-title">{{ TEXT.modules.fileVersionManagement.title }}</h1>
<p class="page-subtitle">{{ TEXT.modules.fileVersionManagement.subtitle }}</p>
</div>
<div class="ctms-page-actions">
<el-button type="primary" @click="openCreate">
@@ -48,7 +47,13 @@
</el-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">
<template #default="{ row }">
<span class="font-mono font-medium">{{ row.doc_no }}</span>
@@ -104,7 +109,7 @@
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="100" fixed="right">
<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 }}
</el-button>
</template>
@@ -130,7 +135,7 @@
</el-form-item>
<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-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-form-item>
<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 },
];
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({
@@ -304,6 +322,11 @@ const submitCreate = async () => {
if (!valid || !trialId.value) return;
creating.value = true;
try {
if (createForm.scope_type === "SITE" && isInactiveSite(createForm.site_id)) {
ElMessage.warning("中心已停用");
creating.value = false;
return;
}
await createDocument({
trial_id: trialId.value,
site_id: createForm.scope_type === "SITE" ? createForm.site_id : undefined,
@@ -385,4 +408,16 @@ watch(
.font-semibold {
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>
+26 -11
View File
@@ -14,19 +14,20 @@
<el-row :gutter="24">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.site" prop="center_id">
<el-select v-model="form.center_id" :placeholder="TEXT.common.placeholders.select" style="width: 100%">
<el-select v-model="form.center_id" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
<el-option
v-for="site in sites"
:key="site.id"
:label="site.name || TEXT.common.fallback"
:value="site.id"
:disabled="!site.is_active"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.direction" prop="direction">
<el-select v-model="form.direction" :placeholder="TEXT.common.placeholders.select" style="width: 100%">
<el-select v-model="form.direction" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
<el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" />
<el-option :label="TEXT.enums.shipmentDirection.RETURN" value="RETURN" />
</el-select>
@@ -34,7 +35,7 @@
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.status" prop="status">
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" style="width: 100%">
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
<el-option :label="TEXT.enums.shipmentStatus.PENDING" value="PENDING" />
<el-option :label="TEXT.enums.shipmentStatus.IN_TRANSIT" value="IN_TRANSIT" />
<el-option :label="TEXT.enums.shipmentStatus.SIGNED" value="SIGNED" />
@@ -49,43 +50,45 @@
<el-row :gutter="24">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.shipDate" prop="ship_date">
<el-date-picker v-model="form.ship_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" />
<el-date-picker v-model="form.ship_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.receiveDate" prop="receive_date">
<el-date-picker v-model="form.receive_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" />
<el-date-picker v-model="form.receive_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.quantity" prop="quantity">
<el-input-number v-model="form.quantity" :min="0" :controls="false" :placeholder="TEXT.common.placeholders.input" style="width: 100%" />
<el-input-number v-model="form.quantity" :min="0" :controls="false" :placeholder="TEXT.common.placeholders.input" style="width: 100%" :disabled="isReadOnly" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.batchNo" prop="batch_no">
<el-input v-model="form.batch_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.batchNo" />
<el-input v-model="form.batch_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.batchNo" :disabled="isReadOnly" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.carrier" prop="carrier">
<el-input v-model="form.carrier" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.carrier" />
<el-input v-model="form.carrier" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.carrier" :disabled="isReadOnly" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.trackingNo" prop="tracking_no">
<el-input v-model="form.tracking_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.trackingNo" />
<el-input v-model="form.tracking_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.trackingNo" :disabled="isReadOnly" />
</el-form-item>
</el-col>
</el-row>
<el-form-item :label="TEXT.common.fields.remark" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
<el-input v-model="form.remark" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" :disabled="isReadOnly" />
</el-form-item>
<el-form-item>
<div class="actions">
<el-button type="primary" :loading="saving" @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>
</div>
</el-form-item>
@@ -121,10 +124,18 @@ const study = useStudyStore();
const saving = ref(false);
const formRef = ref();
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 isEdit = computed(() => !!shipmentId.value);
const studyId = computed(() => study.currentStudy?.id || "");
const isReadOnly = computed(() => isEdit.value && !!form.center_id && siteActiveMap.value[form.center_id] === false);
const form = reactive({
center_id: "",
@@ -185,6 +196,10 @@ const load = async () => {
};
const submit = async () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId.value) return;
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
+18 -2
View File
@@ -6,7 +6,7 @@
<p class="page-subtitle">{{ TEXT.modules.feeContracts.detailSubtitle }}</p>
</div>
<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>
{{ TEXT.common.actions.edit }}
</el-button>
@@ -106,6 +106,7 @@
:entity-type="'contract_fee'"
:entity-id="contractId"
:groups="attachmentGroups"
:readonly="isReadOnly"
/>
</el-card>
</template>
@@ -115,6 +116,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { Edit } from "@element-plus/icons-vue";
import { getContractFee } from "../../api/feeContracts";
import { fetchSites } from "../../api/sites";
@@ -145,6 +147,14 @@ const detail = reactive<any>({
payments: [],
});
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 = [
{
@@ -204,7 +214,13 @@ const formatAmountWan = (value: any) => {
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");
onMounted(async () => {
+72 -13
View File
@@ -29,39 +29,69 @@
<el-row :gutter="24">
<el-col :span="12">
<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
v-for="site in sites"
:key="site.id"
:label="site.name || TEXT.common.fallback"
:value="site.id"
:disabled="!site.is_active"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.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-col>
<el-col :span="12">
<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-col>
<el-col :span="12">
<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-col>
<el-col :span="12">
<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-col>
<el-col :span="12">
<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-col>
</el-row>
@@ -71,7 +101,7 @@
<template #header>
<div class="card-header actions-header">
<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>
{{ TEXT.common.actions.add }}
</el-button>
@@ -90,7 +120,7 @@
<span class="seq-circle">{{ index + 1 }}</span>
<span class="seq-text">{{ TEXT.modules.feeContracts.paymentSeqUnit }}</span>
</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-button>
</div>
@@ -98,13 +128,13 @@
<el-row :gutter="16">
<el-col :span="8">
<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-col>
<el-col :span="8">
<el-form-item :label="TEXT.modules.feeContracts.paidFlag" :error="paymentErrors[index]?.paid_date">
<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 }}
</el-checkbox>
<el-date-picker
@@ -113,6 +143,7 @@
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
:disabled="isReadOnly"
class="status-picker"
size="small"
/>
@@ -122,7 +153,7 @@
<el-col :span="8">
<el-form-item :label="TEXT.modules.feeContracts.verifiedFlag" :error="paymentErrors[index]?.verified_date">
<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 }}
</el-checkbox>
<el-date-picker
@@ -131,6 +162,7 @@
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
:disabled="isReadOnly"
class="status-picker"
size="small"
/>
@@ -140,7 +172,7 @@
</el-row>
<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>
<div v-if="paymentErrors[index]?.verification" class="payment-error">
@@ -162,6 +194,7 @@
:entity-type="'contract_fee'"
:entity-id="form.id"
:groups="attachmentGroups"
:readonly="isReadOnly"
/>
<StateEmpty v-else :description="TEXT.modules.feeContracts.uploadHint" class="compact-empty" />
@@ -169,7 +202,7 @@
<div class="footer-actions">
<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 }}
</el-button>
</div>
@@ -207,6 +240,14 @@ const loading = ref(false);
const saving = ref(false);
const errorMessage = ref("");
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 form = reactive({
@@ -302,6 +343,10 @@ const loadDetail = async () => {
};
const addPayment = () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
payments.value.push({
tempId: `${Date.now()}-${Math.random()}`,
amount: 0,
@@ -314,6 +359,10 @@ const addPayment = () => {
};
const removePayment = (index: number) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
const payment = payments.value[index];
if (payment?.id) {
removedPaymentIds.value.push(payment.id);
@@ -322,6 +371,7 @@ const removePayment = (index: number) => {
};
const onPaidToggle = (payment: any) => {
if (isReadOnly.value) return;
if (!payment.is_paid) {
payment.paid_date = "";
payment.is_verified = false;
@@ -330,6 +380,7 @@ const onPaidToggle = (payment: any) => {
};
const onVerifiedToggle = (payment: any) => {
if (isReadOnly.value) return;
if (payment.is_verified && !payment.is_paid) {
payment.is_paid = true;
}
@@ -367,6 +418,14 @@ const validatePayments = () => {
const submit = async () => {
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);
if (!formOk) return;
if (!validatePayments()) {
+38 -4
View File
@@ -3,7 +3,6 @@
<div class="page-header">
<div>
<h1 class="page-title">{{ TEXT.modules.feeContracts.title }}</h1>
<p class="page-subtitle">{{ TEXT.modules.feeContracts.subtitle }}</p>
</div>
<el-button type="primary" :disabled="!canWrite" @click="goNew" class="header-action-btn">
<el-icon class="el-icon--left"><Plus /></el-icon>
@@ -94,7 +93,13 @@
<StateLoading v-else-if="loading" :rows="6" />
<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">
<template #default="scope">
<div class="site-cell">
@@ -163,7 +168,7 @@
link
type="danger"
size="small"
:disabled="!canWrite"
:disabled="!canWrite || isInactiveSite(scope.row.center_id)"
@click.stop="remove(scope.row)"
>
{{ TEXT.common.actions.delete }}
@@ -171,7 +176,7 @@
</template>
</el-table-column>
</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>
</div>
</template>
@@ -201,6 +206,19 @@ const loading = ref(false);
const errorMessage = ref("");
const contracts = 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({
centerId: "",
@@ -279,6 +297,10 @@ const onRowClick = (row: any) => {
const remove = async (row: any) => {
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);
if (!ok) return;
try {
@@ -438,4 +460,16 @@ onMounted(async () => {
color: var(--ctms-text-regular);
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>
@@ -6,7 +6,7 @@
<p class="page-subtitle">{{ TEXT.modules.feeSpecials.detailSubtitle }}</p>
</div>
<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>
{{ TEXT.common.actions.edit }}
</el-button>
@@ -82,6 +82,7 @@
:entity-type="'special_expense'"
:entity-id="expenseId"
:groups="attachmentGroups"
:readonly="isReadOnly"
/>
</el-card>
</template>
@@ -91,6 +92,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { Edit } from "@element-plus/icons-vue";
import { getSpecialExpense } from "../../api/feeSpecials";
import { fetchSites } from "../../api/sites";
@@ -123,6 +125,14 @@ const detail = reactive<any>({
verified_date: "",
});
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 = [
{
@@ -188,7 +198,13 @@ const getCategoryType = (category: string) => {
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");
onMounted(async () => {
+28 -10
View File
@@ -29,36 +29,37 @@
<el-row :gutter="24">
<el-col :span="12">
<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
v-for="site in sites"
:key="site.id"
:label="site.name || TEXT.common.fallback"
:value="site.id"
:disabled="!site.is_active"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.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-col>
<el-col :span="12">
<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-select>
</el-form-item>
</el-col>
<el-col :span="12">
<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-col>
<el-col :span="24">
<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-col>
</el-row>
@@ -76,7 +77,7 @@
<div class="status-box">
<div class="status-header">
<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>
<el-form-item :label="TEXT.common.fields.occurDate" :error="specialErrors.paid_date" class="status-form-item">
<el-date-picker
@@ -84,7 +85,7 @@
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
:disabled="!form.is_paid"
:disabled="isReadOnly || !form.is_paid"
class="full-width"
/>
</el-form-item>
@@ -94,7 +95,7 @@
<div class="status-box">
<div class="status-header">
<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>
<el-form-item :label="TEXT.common.fields.occurDate" :error="specialErrors.verified_date" class="status-form-item">
<el-date-picker
@@ -102,7 +103,7 @@
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
:disabled="!form.is_verified"
:disabled="isReadOnly || !form.is_verified"
class="full-width"
/>
</el-form-item>
@@ -126,6 +127,7 @@
:entity-type="'special_expense'"
:entity-id="form.id"
:groups="attachmentGroups"
:readonly="isReadOnly"
/>
<StateEmpty v-else :description="TEXT.modules.feeSpecials.uploadHint" class="compact-empty" />
@@ -133,7 +135,7 @@
<div class="footer-actions">
<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 }}
</el-button>
</div>
@@ -163,6 +165,14 @@ const loading = ref(false);
const saving = ref(false);
const errorMessage = ref("");
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 form = reactive({
@@ -301,6 +311,14 @@ const validateSpecial = () => {
const submit = async () => {
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);
if (!formOk) return;
if (!validateSpecial()) {
+38 -4
View File
@@ -3,7 +3,6 @@
<div class="page-header">
<div>
<h1 class="page-title">{{ TEXT.modules.feeSpecials.title }}</h1>
<p class="page-subtitle">{{ TEXT.modules.feeSpecials.subtitle }}</p>
</div>
<el-button type="primary" :disabled="!canWrite" @click="goNew" class="header-action-btn">
<el-icon class="el-icon--left"><Plus /></el-icon>
@@ -109,7 +108,13 @@
<StateLoading v-else-if="loading" :rows="6" />
<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">
<template #default="scope">
<span class="date-text">{{ displayDate(scope.row.happen_date) }}</span>
@@ -161,7 +166,7 @@
link
type="danger"
size="small"
:disabled="!canWrite"
:disabled="!canWrite || isInactiveSite(scope.row.center_id)"
@click.stop="remove(scope.row)"
>
{{ TEXT.common.actions.delete }}
@@ -169,7 +174,7 @@
</template>
</el-table-column>
</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>
</div>
</template>
@@ -199,6 +204,19 @@ const loading = ref(false);
const errorMessage = ref("");
const expenses = 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({
centerId: "",
@@ -287,6 +305,10 @@ const onRowClick = (row: any) => {
const remove = async (row: any) => {
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);
if (!ok) return;
try {
@@ -444,4 +466,16 @@ onMounted(async () => {
.text-muted {
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>
+35 -4
View File
@@ -6,7 +6,7 @@
<p class="page-subtitle">{{ TEXT.modules.financeContracts.detailSubtitle }}</p>
</div>
<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>
</div>
</div>
@@ -28,16 +28,18 @@
:study-id="studyId"
entity-type="finance_contract"
:entity-id="contractId"
:readonly="isReadOnly"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study";
import { getFinanceContract } from "../../api/financeContracts";
import { fetchSites } from "../../api/sites";
import { displayDate } from "../../utils/display";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import { TEXT } from "../../locales";
@@ -48,6 +50,7 @@ const study = useStudyStore();
const loading = ref(false);
const contractId = route.params.contractId as string;
const studyId = study.currentStudy?.id || "";
const sites = ref<any[]>([]);
const detail = reactive<any>({
site_name: "",
@@ -58,6 +61,25 @@ const detail = reactive<any>({
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 () => {
if (!studyId || !contractId) return;
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");
onMounted(load);
onMounted(async () => {
await loadSites();
load();
});
</script>
<style scoped>
+45 -8
View File
@@ -11,29 +11,29 @@
<el-card>
<el-form ref="formRef" :model="form" label-width="110px">
<el-form-item :label="TEXT.common.fields.site" prop="site_name" required>
<el-input v-model="form.site_name" :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 :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 :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 :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 :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="USD" value="USD" />
<el-option label="EUR" value="EUR" />
</el-select>
</el-form-item>
<el-form-item :label="TEXT.common.fields.remark">
<el-input v-model="form.remark" 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-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-form-item>
</el-form>
@@ -44,6 +44,7 @@
:study-id="studyId"
entity-type="finance_contract"
:entity-id="contractId"
:readonly="isReadOnly"
/>
<el-card v-else class="hint-card">
<StateEmpty :description="TEXT.modules.financeContracts.uploadHint" />
@@ -57,6 +58,7 @@ import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study";
import { createFinanceContract, getFinanceContract, updateFinanceContract } from "../../api/financeContracts";
import { fetchSites } from "../../api/sites";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
@@ -65,6 +67,7 @@ const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const saving = ref(false);
const sites = ref<any[]>([]);
const contractId = computed(() => route.params.contractId as string | undefined);
const isEdit = computed(() => !!contractId.value);
@@ -79,6 +82,25 @@ const form = reactive({
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 () => {
if (!isEdit.value || !studyId.value || !contractId.value) return;
try {
@@ -98,6 +120,18 @@ const load = async () => {
const submit = async () => {
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;
try {
const payload = {
@@ -126,7 +160,10 @@ const submit = async () => {
const goBack = () => router.push("/finance/contracts");
onMounted(load);
onMounted(async () => {
await loadSites();
load();
});
</script>
<style scoped>
+35 -4
View File
@@ -6,7 +6,7 @@
<p class="page-subtitle">{{ TEXT.modules.financeSpecials.detailSubtitle }}</p>
</div>
<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>
</div>
</div>
@@ -27,16 +27,18 @@
:study-id="studyId"
entity-type="finance_special"
:entity-id="specialId"
:readonly="isReadOnly"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study";
import { getFinanceSpecial } from "../../api/financeSpecials";
import { fetchSites } from "../../api/sites";
import { displayDate, displayEnum } from "../../utils/display";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import { TEXT } from "../../locales";
@@ -47,6 +49,7 @@ const study = useStudyStore();
const loading = ref(false);
const specialId = route.params.specialId as string;
const studyId = study.currentStudy?.id || "";
const sites = ref<any[]>([]);
const detail = reactive<any>({
site_name: "",
@@ -57,6 +60,25 @@ const detail = reactive<any>({
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 () => {
if (!studyId || !specialId) return;
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");
onMounted(load);
onMounted(async () => {
await loadSites();
load();
});
</script>
<style scoped>
+45 -8
View File
@@ -11,10 +11,10 @@
<el-card>
<el-form :model="form" label-width="110px">
<el-form-item :label="TEXT.common.fields.site" required>
<el-input v-model="form.site_name" :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 :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.HOTEL" value="HOTEL" />
<el-option :label="TEXT.enums.financeSpecialType.TRANSPORT" value="TRANSPORT" />
@@ -22,19 +22,19 @@
</el-select>
</el-form-item>
<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 :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 :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 :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-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-form-item>
</el-form>
@@ -45,6 +45,7 @@
:study-id="studyId"
entity-type="finance_special"
:entity-id="specialId"
:readonly="isReadOnly"
/>
<el-card v-else class="hint-card">
<StateEmpty :description="TEXT.modules.financeSpecials.uploadHint" />
@@ -58,6 +59,7 @@ import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study";
import { createFinanceSpecial, getFinanceSpecial, updateFinanceSpecial } from "../../api/financeSpecials";
import { fetchSites } from "../../api/sites";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
@@ -66,6 +68,7 @@ const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const saving = ref(false);
const sites = ref<any[]>([]);
const specialId = computed(() => route.params.specialId as string | undefined);
const isEdit = computed(() => !!specialId.value);
@@ -80,6 +83,25 @@ const form = reactive({
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 () => {
if (!isEdit.value || !studyId.value || !specialId.value) return;
try {
@@ -99,6 +121,18 @@ const load = async () => {
const submit = async () => {
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;
try {
const payload = {
@@ -127,7 +161,10 @@ const submit = async () => {
const goBack = () => router.push("/finance/special");
onMounted(load);
onMounted(async () => {
await loadSites();
load();
});
</script>
<style scoped>
+46 -5
View File
@@ -3,7 +3,6 @@
<div class="page-header">
<div>
<h1 class="page-title">{{ TEXT.modules.drugShipments.title }}</h1>
<p class="page-subtitle">{{ TEXT.modules.drugShipments.subtitle }}</p>
</div>
<el-button type="primary" @click="goNew">{{ TEXT.modules.drugShipments.newTitle }}</el-button>
</div>
@@ -43,7 +42,13 @@
</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">
<template #default="scope">{{ scope.row.site_name || TEXT.common.fallback }}</template>
</el-table-column>
@@ -78,17 +83,25 @@
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120" fixed="right">
<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>
</el-table-column>
</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>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { computed, onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { useStudyStore } from "../../store/study";
@@ -103,6 +116,7 @@ const study = useStudyStore();
const loading = ref(false);
const items = ref<any[]>([]);
const sites = ref<any[]>([]);
const siteActiveMap = ref<Record<string, boolean>>({});
const filters = reactive({
center_id: "",
direction: "",
@@ -115,8 +129,13 @@ const loadSites = async () => {
try {
const { data } = await fetchSites(studyId, { limit: 500 });
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 {
sites.value = [];
siteActiveMap.value = {};
}
};
@@ -151,6 +170,12 @@ const onRowClick = (row: any) => {
if (!row?.id) return;
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) => {
switch (status) {
@@ -172,6 +197,10 @@ const statusType = (status: string) => {
const remove = async (row: any) => {
const studyId = study.currentStudy?.id;
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);
if (!ok) return;
try {
@@ -196,6 +225,7 @@ onMounted(async () => {
gap: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
@@ -218,3 +248,14 @@ onMounted(async () => {
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>
+67 -5
View File
@@ -24,7 +24,14 @@
</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="contract_no" :label="TEXT.common.fields.contractNo" min-width="160" />
<el-table-column prop="signed_date" :label="TEXT.common.fields.signedDate" width="140">
@@ -44,21 +51,30 @@
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120">
<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>
</el-table-column>
</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>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { computed, onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { useStudyStore } from "../../store/study";
import { listFinanceContracts, deleteFinanceContract } from "../../api/financeContracts";
import { fetchSites } from "../../api/sites";
import { displayDate, displayDateTime } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
@@ -67,11 +83,37 @@ const router = useRouter();
const study = useStudyStore();
const loading = ref(false);
const items = ref<any[]>([]);
const sites = ref<any[]>([]);
const filters = reactive({
site_name: "",
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 studyId = study.currentStudy?.id;
if (!studyId) return;
@@ -105,6 +147,10 @@ const onRowClick = (row: any) => {
const remove = async (row: any) => {
const studyId = study.currentStudy?.id;
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);
if (!ok) return;
try {
@@ -116,7 +162,10 @@ const remove = async (row: any) => {
}
};
onMounted(load);
onMounted(async () => {
await loadSites();
load();
});
</script>
<style scoped>
@@ -147,4 +196,17 @@ onMounted(load);
.filter-card :deep(.el-form-item) {
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>
+66 -5
View File
@@ -29,7 +29,14 @@
</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="fee_type" :label="TEXT.common.fields.type" width="120">
<template #default="scope">
@@ -54,21 +61,30 @@
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120">
<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>
</el-table-column>
</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>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { computed, onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { useStudyStore } from "../../store/study";
import { listFinanceSpecials, deleteFinanceSpecial } from "../../api/financeSpecials";
import { fetchSites } from "../../api/sites";
import { displayDate, displayDateTime, displayEnum } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
@@ -77,11 +93,37 @@ const router = useRouter();
const study = useStudyStore();
const loading = ref(false);
const items = ref<any[]>([]);
const sites = ref<any[]>([]);
const filters = reactive({
site_name: "",
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 studyId = study.currentStudy?.id;
if (!studyId) return;
@@ -115,6 +157,10 @@ const onRowClick = (row: any) => {
const remove = async (row: any) => {
const studyId = study.currentStudy?.id;
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);
if (!ok) return;
try {
@@ -126,7 +172,10 @@ const remove = async (row: any) => {
}
};
onMounted(load);
onMounted(async () => {
await loadSites();
load();
});
</script>
<style scoped>
@@ -157,4 +206,16 @@ onMounted(load);
.filter-card :deep(.el-form-item) {
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>
@@ -1,7 +1,6 @@
<template>
<ModulePlaceholder
:title="TEXT.modules.knowledgeInstructionFiles.title"
:subtitle="TEXT.modules.knowledgeInstructionFiles.subtitle"
:list-title="TEXT.modules.knowledgeInstructionFiles.listTitle"
:empty-description="TEXT.modules.knowledgeInstructionFiles.emptyDescription"
/>
+66 -6
View File
@@ -3,7 +3,6 @@
<div class="page-header">
<div>
<h1 class="page-title">{{ TEXT.modules.knowledgeNotes.title }}</h1>
<p class="page-subtitle">{{ TEXT.modules.knowledgeNotes.subtitle }}</p>
</div>
<el-button type="primary" @click="goNew">{{ TEXT.modules.knowledgeNotes.newTitle }}</el-button>
</div>
@@ -24,7 +23,14 @@
</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="title" :label="TEXT.common.fields.title" min-width="200" />
<el-table-column prop="level" :label="TEXT.common.fields.level" width="120" />
@@ -33,21 +39,30 @@
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120">
<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>
</el-table-column>
</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>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { computed, onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { useStudyStore } from "../../store/study";
import { listKnowledgeNotes, deleteKnowledgeNote } from "../../api/knowledgeNotes";
import { fetchSites } from "../../api/sites";
import { displayDateTime } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
@@ -56,11 +71,37 @@ const router = useRouter();
const study = useStudyStore();
const loading = ref(false);
const items = ref<any[]>([]);
const sites = ref<any[]>([]);
const filters = reactive({
site_name: "",
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 studyId = study.currentStudy?.id;
if (!studyId) return;
@@ -94,6 +135,10 @@ const onRowClick = (row: any) => {
const remove = async (row: any) => {
const studyId = study.currentStudy?.id;
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);
if (!ok) return;
try {
@@ -105,7 +150,10 @@ const remove = async (row: any) => {
}
};
onMounted(load);
onMounted(async () => {
await loadSites();
load();
});
</script>
<style scoped>
@@ -136,4 +184,16 @@ onMounted(load);
.filter-card :deep(.el-form-item) {
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>
@@ -1,7 +1,6 @@
<template>
<ModulePlaceholder
:title="TEXT.modules.knowledgeSupportFiles.title"
:subtitle="TEXT.modules.knowledgeSupportFiles.subtitle"
:list-title="TEXT.modules.knowledgeSupportFiles.listTitle"
:empty-description="TEXT.modules.knowledgeSupportFiles.emptyDescription"
/>
+18 -4
View File
@@ -4,7 +4,6 @@
<div class="page-header">
<div>
<h1 class="page-title">项目概览</h1>
<p class="page-subtitle">多中心项目整体进度与入组情况</p>
<p class="study-meta">
<span class="study-name">{{ study.currentStudy.name }}</span>
<span class="study-divider">/</span>
@@ -81,6 +80,7 @@ import { useStudyStore } from "../../store/study";
import { TEXT } from "../../locales";
import { displayDateTime } from "../../utils/display";
import { fetchProjectOverview } from "../../api/overview";
import { fetchSites } from "../../api/sites";
import StateEmpty from "../../components/StateEmpty.vue";
import StateLoading from "../../components/StateLoading.vue";
import CenterProgressRow from "./project-overview/CenterProgressRow.vue";
@@ -146,14 +146,28 @@ const loadOverview = async () => {
if (!studyId) return;
loading.value = true;
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) {
const { data } = await fetchProjectOverview(studyId);
overview.value = adaptProjectOverview(data);
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 {
overview.value = adaptProjectOverview(overviewMock);
usingDemo.value = true;
@@ -3,7 +3,6 @@
<div class="ctms-page-header">
<div>
<h1 class="ctms-page-title">{{ TEXT.modules.startupFeasibilityEthics.title }}</h1>
<p class="ctms-page-subtitle">{{ TEXT.modules.startupFeasibilityEthics.subtitle }}</p>
</div>
</div>
@@ -21,11 +20,11 @@
</div>
</div>
<el-table
:data="feasibilityItems"
:data="sortedFeasibilityItems"
v-loading="loadingFeasibility"
class="ctms-table"
:row-class-name="feasibilityRowClass"
@row-click="onFeasibilityRowClick"
row-class-name="clickable-row"
>
<el-table-column :label="TEXT.common.fields.site" width="180">
<template #default="scope">
@@ -55,11 +54,18 @@
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120" fixed="right">
<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>
</el-table-column>
</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>
</el-tab-pane>
<el-tab-pane :label="TEXT.modules.startupFeasibilityEthics.ethicsTab" name="ethics">
@@ -74,11 +80,11 @@
</div>
</div>
<el-table
:data="ethicsItems"
:data="sortedEthicsItems"
v-loading="loadingEthics"
class="ctms-table"
:row-class-name="ethicsRowClass"
@row-click="onEthicsRowClick"
row-class-name="clickable-row"
>
<el-table-column :label="TEXT.common.fields.site" width="180">
<template #default="scope">
@@ -111,11 +117,18 @@
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120" fixed="right">
<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>
</el-table-column>
</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>
</el-tab-pane>
</el-tabs>
@@ -148,7 +161,15 @@ const loadingEthics = ref(false);
const sites = ref<Site[]>([]);
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 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> = {
COMPLETED: "已完成",
@@ -249,6 +270,10 @@ const goNewEthics = () => router.push("/startup/ethics/new");
const goEthicsDetail = (id: string) => router.push(`/startup/ethics/${id}`);
const onFeasibilityRowClick = (row: any) => goFeasibilityDetail(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 studyId = study.currentStudy?.id;
@@ -291,3 +316,14 @@ onMounted(() => {
}
</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>
<h1 class="page-title">{{ TEXT.modules.startupMeetingAuth.title }}</h1>
<p class="page-subtitle">{{ TEXT.modules.startupMeetingAuth.subtitle }}</p>
</div>
</div>
<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="kickoff_date" :label="TEXT.common.fields.kickoffDate" width="160">
<template #default="scope">{{ displayDate(scope.row.kickoff_date) }}</template>
@@ -61,6 +67,14 @@ const memberNameMap = computed(() => {
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) => {
if (!row?.contact) return TEXT.common.fallback;
return String(row.contact)
@@ -76,17 +90,20 @@ const kickoffRows = computed(() => {
if (item.site_id) acc[item.site_id] = item;
return acc;
}, {});
return sites.value.map((site) => {
const meeting = kickoffMap[site.id];
return {
site_id: site.id,
site_name: site.name || TEXT.common.fallback,
contact: site.contact,
kickoff_date: meeting?.kickoff_date || null,
meeting_id: meeting?.id,
status: meeting?.kickoff_date ? "COMPLETED" : "PENDING",
};
});
return sites.value
.map((site) => {
const meeting = kickoffMap[site.id];
return {
site_id: site.id,
site_name: site.name || TEXT.common.fallback,
contact: site.contact,
kickoff_date: meeting?.kickoff_date || null,
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 () => {
@@ -115,6 +132,10 @@ const goKickoffDetail = (id: string) => router.push(`/startup/kickoff/${id}`);
const onKickoffRowClick = async (row: any) => {
const studyId = study.currentStudy?.id;
if (!studyId || !row?.site_id) return;
if (row.is_active === false && !row.meeting_id) {
ElMessage.warning("中心已停用");
return;
}
if (row.meeting_id) {
goKickoffDetail(row.meeting_id);
return;
@@ -126,6 +147,8 @@ const onKickoffRowClick = async (row: any) => {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.createFailed);
}
};
const kickoffRowClass = ({ row }: { row: any }) => (row?.is_active === false ? "row-inactive" : "");
onMounted(() => {
loadKickoffs();
});
@@ -157,3 +180,14 @@ onMounted(() => {
}
</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>
<h1 class="page-title">{{ TEXT.modules.subjectManagement.title }}</h1>
<p class="page-subtitle">{{ TEXT.modules.subjectManagement.subtitle }}</p>
</div>
<el-button type="primary" @click="goNew">{{ TEXT.common.actions.add }}{{ TEXT.modules.subjectManagement.subjectLabel }}</el-button>
</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-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 :label="TEXT.common.fields.site" min-width="160">
<template #default="scope">{{ siteMap[scope.row.site_id] || TEXT.common.fallback }}</template>
@@ -42,17 +28,25 @@
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120">
<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>
</el-table-column>
</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>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { useStudyStore } from "../../store/study";
@@ -67,11 +61,7 @@ const study = useStudyStore();
const loading = ref(false);
const items = ref<any[]>([]);
const siteMap = ref<Record<string, string>>({});
const filters = reactive({
subject_no: "",
status: "",
});
const siteActiveMap = ref<Record<string, boolean>>({});
const loadSites = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
@@ -82,8 +72,13 @@ const loadSites = async () => {
acc[site.id] = site.name;
return acc;
}, {});
siteActiveMap.value = list.reduce((acc: Record<string, boolean>, site: any) => {
acc[site.id] = !!site.is_active;
return acc;
}, {});
} catch {
siteMap.value = {};
siteActiveMap.value = {};
}
};
@@ -92,10 +87,7 @@ const load = async () => {
if (!studyId) return;
loading.value = true;
try {
const { data } = await fetchSubjects(studyId, {
subject_no: filters.subject_no || undefined,
status_filter: filters.status || undefined,
});
const { data } = await fetchSubjects(studyId);
items.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) {
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 goDetail = (id: string) => router.push(`/subjects/${id}`);
const onRowClick = (row: any) => {
@@ -119,6 +105,10 @@ const onRowClick = (row: any) => {
const remove = async (row: any) => {
const studyId = study.currentStudy?.id;
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);
if (!ok) return;
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 () => {
await loadSites();
load();
@@ -161,7 +158,15 @@ onMounted(async () => {
color: var(--ctms-text-secondary);
}
.filter-card :deep(.el-form-item) {
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>
@@ -1,5 +1,5 @@
<template>
<div class="center-row">
<div class="center-row" :class="{ 'row-inactive': isInactive }">
<div class="center-meta">
<div class="center-name">{{ centerName }}</div>
<div class="center-enrollment">
@@ -46,6 +46,7 @@ const stages = computed(() =>
completedAt: props.center[stage.completedKey],
}))
);
const isInactive = computed(() => props.center.is_active === false);
const connectorClass = (status: StageStatus) => `connector-${status.toLowerCase()}`;
</script>
@@ -174,4 +175,17 @@ const connectorClass = (status: StageStatus) => `connector-${status.toLowerCase(
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>
@@ -37,6 +37,7 @@ export interface CenterOverview {
enrollment_completed_at?: string;
inspection_completed_at?: string;
closeout_completed_at?: string;
is_active?: boolean;
}
export interface EnrollmentByMonth {
@@ -67,14 +68,14 @@ export const STAGE_ORDER: Array<{
label: string;
completedKey: StageCompletionKey;
}> = [
{ key: "institution_initiation_status", label: "机构立项", completedKey: "institution_initiation_completed_at" },
{ key: "ethics_status", label: "伦理审批", completedKey: "ethics_completed_at" },
{ key: "contract_sign_status", label: "合同签署", completedKey: "contract_sign_completed_at" },
{ key: "startup_status", label: "启动", completedKey: "startup_completed_at" },
{ key: "enrollment_status", label: "入组", completedKey: "enrollment_completed_at" },
{ key: "inspection_status", label: "末次稽查", completedKey: "inspection_completed_at" },
{ key: "closeout_status", label: "关中心", completedKey: "closeout_completed_at" },
];
{ key: "institution_initiation_status", label: "机构立项", completedKey: "institution_initiation_completed_at" },
{ key: "ethics_status", label: "伦理审批", completedKey: "ethics_completed_at" },
{ key: "contract_sign_status", label: "合同签署", completedKey: "contract_sign_completed_at" },
{ key: "startup_status", label: "启动", completedKey: "startup_completed_at" },
{ key: "enrollment_status", label: "入组", completedKey: "enrollment_completed_at" },
{ key: "inspection_status", label: "末次稽查", completedKey: "inspection_completed_at" },
{ key: "closeout_status", label: "关中心", completedKey: "closeout_completed_at" },
];
const STATUS_ALIAS: Record<string, StageStatus> = {
NOT_STARTED: "NOT_STARTED",
@@ -130,6 +131,7 @@ export const adaptProjectOverview = (raw: unknown): ProjectOverviewViewModel =>
enrollment_completed_at: toString((center as any).enrollment_completed_at),
inspection_completed_at: toString((center as any).inspection_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>
</div>
<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>
</div>
</div>
@@ -25,16 +25,18 @@
:study-id="studyId"
entity-type="knowledge_note"
:entity-id="noteId"
:readonly="isReadOnly"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study";
import { getKnowledgeNote } from "../../api/knowledgeNotes";
import { fetchSites } from "../../api/sites";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import { TEXT } from "../../locales";
@@ -44,6 +46,7 @@ const study = useStudyStore();
const loading = ref(false);
const noteId = route.params.noteId as string;
const studyId = study.currentStudy?.id || "";
const sites = ref<any[]>([]);
const detail = reactive<any>({
site_name: "",
@@ -52,6 +55,25 @@ const detail = reactive<any>({
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 () => {
if (!studyId || !noteId) return;
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");
onMounted(load);
onMounted(async () => {
await loadSites();
load();
});
</script>
<style scoped>
+43 -6
View File
@@ -11,19 +11,19 @@
<el-card>
<el-form :model="form" label-width="120px">
<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 :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 :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 :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-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-form-item>
</el-form>
@@ -34,6 +34,7 @@
:study-id="studyId"
entity-type="knowledge_note"
:entity-id="noteId"
:readonly="isReadOnly"
/>
<el-card v-else class="hint-card">
<StateEmpty :description="TEXT.modules.knowledgeNotes.uploadHint" />
@@ -47,6 +48,7 @@ import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study";
import { createKnowledgeNote, getKnowledgeNote, updateKnowledgeNote } from "../../api/knowledgeNotes";
import { fetchSites } from "../../api/sites";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
@@ -55,6 +57,7 @@ const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const saving = ref(false);
const sites = ref<any[]>([]);
const noteId = computed(() => route.params.noteId as string | undefined);
const isEdit = computed(() => !!noteId.value);
@@ -67,6 +70,25 @@ const form = reactive({
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 () => {
if (!isEdit.value || !studyId.value || !noteId.value) return;
try {
@@ -84,6 +106,18 @@ const load = async () => {
const submit = async () => {
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;
try {
const payload = {
@@ -110,7 +144,10 @@ const submit = async () => {
const goBack = () => router.push("/knowledge/notes");
onMounted(load);
onMounted(async () => {
await loadSites();
load();
});
</script>
<style scoped>
+13 -2
View File
@@ -6,7 +6,9 @@
<p class="ctms-page-subtitle">{{ TEXT.modules.startupFeasibilityEthics.ethicsDetailSubtitle }}</p>
</div>
<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-icon class="el-icon--left"><ArrowLeft /></el-icon>
{{ TEXT.common.actions.back }}
@@ -33,6 +35,7 @@
:study-id="studyId"
entity-type="startup_ethics"
:entity-id="recordId"
:readonly="isInactiveSite(detail.site_id)"
/>
</div>
</template>
@@ -61,7 +64,9 @@ const studyId = study.currentStudy?.id || "";
const sites = ref<Site[]>([]);
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 isInactiveSite = (siteId?: string) => siteId && siteActiveMap.value.get(siteId) === false;
const detail = reactive<any>({
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");
onMounted(() => {
+54 -9
View File
@@ -18,14 +18,14 @@
<el-row :gutter="24">
<el-col :span="12">
<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-option v-for="site in siteOptions" :key="site.value" :label="site.label" :value="site.value" />
<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" :disabled="site.disabled" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<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-col>
</el-row>
@@ -33,28 +33,56 @@
<el-row :gutter="24">
<el-col :span="6">
<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-col>
<el-col :span="6">
<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-col>
<el-col :span="6">
<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-col>
<el-col :span="6">
<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-col>
</el-row>
<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-form-item>
</el-form>
@@ -68,6 +96,7 @@
:study-id="studyId"
entity-type="startup_ethics"
:entity-id="recordId"
:readonly="isReadOnly"
/>
</div>
<el-card v-else class="hint-card">
@@ -100,8 +129,16 @@ const studyId = computed(() => study.currentStudy?.id || "");
const sites = ref<Site[]>([]);
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({
site_id: "",
@@ -141,6 +178,14 @@ const load = async () => {
const submit = async () => {
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;
try {
const payload = {
@@ -6,7 +6,9 @@
<p class="ctms-page-subtitle">{{ TEXT.modules.startupFeasibilityEthics.feasibilityDetailSubtitle }}</p>
</div>
<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-icon class="el-icon--left"><ArrowLeft /></el-icon>
{{ TEXT.common.actions.back }}
@@ -32,6 +34,7 @@
:study-id="studyId"
entity-type="startup_feasibility"
:entity-id="recordId"
:readonly="isInactiveSite(detail.site_id)"
/>
</div>
</template>
@@ -60,7 +63,9 @@ const studyId = study.currentStudy?.id || "";
const sites = ref<Site[]>([]);
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 isInactiveSite = (siteId?: string) => siteId && siteActiveMap.value.get(siteId) === false;
const detail = reactive<any>({
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");
onMounted(() => {
+46 -8
View File
@@ -18,14 +18,14 @@
<el-row :gutter="24">
<el-col :span="12">
<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-option v-for="site in siteOptions" :key="site.value" :label="site.label" :value="site.value" />
<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" :disabled="site.disabled" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<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-col>
</el-row>
@@ -33,23 +33,44 @@
<el-row :gutter="24">
<el-col :span="8">
<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-col>
<el-col :span="8">
<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-col>
<el-col :span="8">
<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-col>
</el-row>
<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-form-item>
</el-form>
@@ -63,6 +84,7 @@
:study-id="studyId"
entity-type="startup_feasibility"
:entity-id="recordId"
:readonly="isReadOnly"
/>
</div>
<el-card v-else class="hint-card">
@@ -95,8 +117,16 @@ const studyId = computed(() => study.currentStudy?.id || "");
const sites = ref<Site[]>([]);
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({
site_id: "",
@@ -134,6 +164,14 @@ const load = async () => {
const submit = async () => {
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;
try {
const payload = {
+53 -9
View File
@@ -6,7 +6,14 @@
<p class="page-subtitle">{{ TEXT.modules.startupMeetingAuth.kickoffDetailSubtitle }}</p>
</div>
<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-if="editMode" :disabled="saving" @click="cancelEdit">{{ TEXT.common.actions.cancel }}</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
@@ -59,9 +66,16 @@
<el-checkbox
v-if="isTrainingEditing(scope.row)"
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
/>
<el-checkbox v-else v-model="scope.row.trained" @change="toggleTraining(scope.row)" @click.stop />
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.authorized" width="120">
@@ -69,9 +83,16 @@
<el-checkbox
v-if="isTrainingEditing(scope.row)"
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
/>
<el-checkbox v-else v-model="scope.row.authorized" @change="toggleTraining(scope.row)" @click.stop />
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120">
@@ -85,17 +106,19 @@
</el-button>
</template>
<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 }}
</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>
</el-table-column>
</el-table>
<div
class="training-add-row"
:class="{ disabled: newTrainingActive || savingTraining }"
:class="{ disabled: newTrainingActive || savingTraining || isReadOnly }"
@click="startTrainingAdd"
>
<span class="training-add-icon">+</span>
@@ -149,7 +172,7 @@ const saving = ref(false);
const editMode = ref(false);
const meetingId = route.params.meetingId as string;
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 members = ref<any[]>([]);
const kickoffAttachmentGroups = [
@@ -217,6 +240,7 @@ const statusLabel = computed(() =>
detail.kickoff_date ? TEXT.modules.startupMeetingAuth.statusCompleted : TEXT.modules.startupMeetingAuth.statusPending
);
const statusTagType = computed(() => (detail.kickoff_date ? "success" : "info"));
const isReadOnly = computed(() => siteInfo.is_active === false);
const load = async () => {
if (!studyId || !meetingId) return;
@@ -256,6 +280,10 @@ const loadTraining = async () => {
};
const toggleTraining = async (row: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId) return;
try {
await updateTrainingAuthorization(studyId, row.id, {
@@ -282,14 +310,14 @@ const isTrainingEditing = (row: any) => {
};
const startTrainingEdit = (row: any) => {
if (savingTraining.value) return;
if (savingTraining.value || isReadOnly.value) return;
newTrainingActive.value = false;
trainingEditingRowId.value = row?.id || null;
resetTrainingDraft(row);
};
const startTrainingAdd = () => {
if (newTrainingActive.value || savingTraining.value) return;
if (newTrainingActive.value || savingTraining.value || isReadOnly.value) return;
trainingEditingRowId.value = null;
newTrainingActive.value = true;
resetTrainingDraft();
@@ -302,6 +330,10 @@ const cancelTrainingEdit = () => {
};
const saveTraining = async (row: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId) return;
if (!trainingRowDraft.name.trim()) {
ElMessage.error(requiredMessage(TEXT.common.fields.name));
@@ -332,6 +364,10 @@ const saveTraining = async (row: any) => {
};
const removeTraining = async (row: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId) return;
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return;
@@ -345,6 +381,10 @@ const removeTraining = async (row: any) => {
};
const startEdit = () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
editMode.value = true;
draft.kickoff_date = detail.kickoff_date || "";
};
@@ -355,6 +395,10 @@ const cancelEdit = () => {
};
const saveEdit = async () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId || !meetingId) return;
saving.value = true;
try {
+11 -4
View File
@@ -17,18 +17,19 @@
<el-input :model-value="ownerLabel" disabled />
</el-form-item>
<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 :label="TEXT.common.fields.attendees">
<el-input
v-model="form.attendees"
:disabled="isReadOnly"
type="textarea"
:rows="4"
:placeholder="TEXT.modules.startupMeetingAuth.attendeesPlaceholder"
/>
</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-form-item>
</el-form>
@@ -42,6 +43,7 @@
:entity-type="group.entityType"
:entity-id="meetingId"
:title="group.title"
:readonly="isReadOnly"
/>
</div>
<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 isEdit = computed(() => !!meetingId.value);
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 users = ref<any[]>([]);
const members = ref<any[]>([]);
@@ -96,6 +98,7 @@ const ownerLabel = computed(() => {
.map((c) => memberNameMap.value[c] || c)
.join("、") || TEXT.common.fallback;
});
const isReadOnly = computed(() => siteInfo.is_active === false);
const kickoffAttachmentGroups = [
{ entityType: "startup_kickoff_minutes", title: TEXT.modules.startupMeetingAuth.kickoffMinutesLabel },
{ 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 list = Array.isArray(sitesData) ? sitesData : sitesData.items || [];
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) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
@@ -141,6 +144,10 @@ const load = async () => {
const submit = async () => {
if (!studyId.value) return;
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
saving.value = true;
try {
const payload = {
+35 -4
View File
@@ -6,7 +6,7 @@
<p class="page-subtitle">{{ TEXT.modules.startupMeetingAuth.trainingDetailSubtitle }}</p>
</div>
<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>
</div>
</div>
@@ -29,16 +29,18 @@
:study-id="studyId"
entity-type="training_authorization"
:entity-id="recordId"
:readonly="isReadOnly"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study";
import { getTrainingAuthorization } from "../../api/startup";
import { fetchSites } from "../../api/sites";
import { displayDate, displayEnum } from "../../utils/display";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import { TEXT } from "../../locales";
@@ -49,6 +51,7 @@ const study = useStudyStore();
const loading = ref(false);
const recordId = route.params.recordId as string;
const studyId = study.currentStudy?.id || "";
const sites = ref<any[]>([]);
const detail = reactive<any>({
name: "",
@@ -61,6 +64,25 @@ const detail = reactive<any>({
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 () => {
if (!studyId || !recordId) return;
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");
onMounted(load);
onMounted(async () => {
await loadSites();
load();
});
</script>
<style scoped>
+43 -10
View File
@@ -11,31 +11,31 @@
<el-card>
<el-form :model="form" label-width="120px">
<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 :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 :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 :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 :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 :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 :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 :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-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-form-item>
</el-form>
@@ -46,6 +46,7 @@
:study-id="studyId"
entity-type="training_authorization"
:entity-id="recordId"
:readonly="isReadOnly"
/>
<el-card v-else class="hint-card">
<StateEmpty :description="TEXT.modules.startupMeetingAuth.trainingUploadHint" />
@@ -59,6 +60,7 @@ import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study";
import { createTrainingAuthorization, getTrainingAuthorization, updateTrainingAuthorization } from "../../api/startup";
import { fetchSites } from "../../api/sites";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
@@ -67,6 +69,7 @@ const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const saving = ref(false);
const sites = ref<any[]>([]);
const recordId = computed(() => route.params.recordId as string | undefined);
const isEdit = computed(() => !!recordId.value);
@@ -83,6 +86,25 @@ const form = reactive({
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 () => {
if (!isEdit.value || !studyId.value || !recordId.value) return;
try {
@@ -104,6 +126,14 @@ const load = async () => {
const submit = async () => {
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;
try {
const payload = {
@@ -134,7 +164,10 @@ const submit = async () => {
const goBack = () => router.push("/startup/meeting-auth");
onMounted(load);
onMounted(async () => {
await loadSites();
load();
});
</script>
<style scoped>
+135 -16
View File
@@ -11,7 +11,9 @@
<el-button @click="cancelSubjectEdit">{{ TEXT.common.actions.cancel }}</el-button>
</template>
<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>
</template>
</div>
@@ -72,7 +74,9 @@
<el-tabs v-model="activeTab">
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.history" name="history">
<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>
<el-table :data="historyItems" v-loading="loadingHistory" style="width: 100%">
<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 :label="TEXT.common.labels.actions" width="200">
<template #default="scope">
<el-button link type="primary" size="small" @click="openHistoryDialog(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
<el-button link type="danger" size="small" @click="removeHistory(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
<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>
</el-table-column>
</el-table>
@@ -91,7 +111,9 @@
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.visits" name="visits">
<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>
<el-table :data="visitItems" v-loading="loadingVisits" style="width: 100%">
<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">
<template #default="scope">
<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 size="small" @click="cancelVisitRow">{{ TEXT.common.actions.cancel }}</el-button>
<el-button link type="primary" size="small" :disabled="isReadOnly" @click="saveVisitRow(scope.row)">
{{ TEXT.common.actions.save }}
</el-button>
<el-button link size="small" :disabled="isReadOnly" @click="cancelVisitRow">
{{ TEXT.common.actions.cancel }}
</el-button>
</template>
<template v-else>
<el-button link type="primary" size="small" @click="startVisitRowEdit(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
<el-button link type="danger" size="small" @click="removeVisit(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
<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>
</el-table-column>
@@ -150,7 +192,9 @@
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.ae" name="ae">
<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>
<el-table :data="aeItems" v-loading="loadingAes" style="width: 100%">
<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 :label="TEXT.common.labels.actions" width="200">
<template #default="scope">
<el-button link type="primary" size="small" @click="openAeDialog(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
<el-button link type="danger" size="small" @click="removeAe(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
<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>
</el-table-column>
</el-table>
@@ -186,7 +246,7 @@
</el-form>
<template #footer>
<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>
</el-dialog>
@@ -217,7 +277,7 @@
</el-form>
<template #footer>
<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>
</el-dialog>
@@ -254,14 +314,14 @@
</el-form>
<template #footer>
<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>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { useStudyStore } from "../../store/study";
@@ -303,6 +363,8 @@ const subjectForm = reactive({
});
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 historyItems = ref<any[]>([]);
@@ -355,8 +417,13 @@ const loadSites = async () => {
acc[site.id] = site.name;
return acc;
}, {});
siteActiveMap.value = list.reduce((acc: Record<string, boolean>, site: any) => {
acc[site.id] = !!site.is_active;
return acc;
}, {});
} catch {
siteMap.value = {};
siteActiveMap.value = {};
}
};
@@ -374,6 +441,10 @@ const loadSubject = async () => {
};
const startSubjectEdit = () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
subjectForm.status = detail.status || "SCREENING";
subjectForm.consent_date = detail.consent_date || "";
subjectForm.enrollment_date = detail.enrollment_date || "";
@@ -387,6 +458,10 @@ const cancelSubjectEdit = () => {
};
const saveSubjectEdit = async () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId || !subjectId) return;
subjectSaving.value = true;
try {
@@ -422,6 +497,10 @@ const loadHistories = async () => {
};
const openHistoryDialog = (row?: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
historyEditingId.value = row?.id || null;
historyForm.record_date = row?.record_date || "";
historyForm.content = row?.content || "";
@@ -429,6 +508,10 @@ const openHistoryDialog = (row?: any) => {
};
const saveHistory = async () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId || !subjectId) return;
try {
const payload = {
@@ -449,6 +532,10 @@ const saveHistory = async () => {
};
const removeHistory = async (row: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId || !subjectId) return;
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return;
@@ -474,6 +561,10 @@ const loadVisits = async () => {
};
const openVisitDialog = (row?: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
visitEditingId.value = row?.id || null;
if (row) {
visitForm.actual_date = row.actual_date || "";
@@ -490,6 +581,10 @@ const openVisitDialog = (row?: any) => {
};
const startVisitRowEdit = (row: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
visitEditingRowId.value = row?.id || null;
visitRowDraft.actual_date = row?.actual_date || "";
visitRowDraft.notes = row?.notes || "";
@@ -500,6 +595,10 @@ const cancelVisitRow = () => {
};
const saveVisitRow = async (row: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId || !subjectId || !row?.id) return;
try {
const payload = {
@@ -559,6 +658,10 @@ const getVisitStatusTagType = (status: string) => {
};
const saveVisit = async () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId || !subjectId) return;
try {
if (visitEditingId.value) {
@@ -585,6 +688,10 @@ const saveVisit = async () => {
};
const removeVisit = async (row: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId || !subjectId) return;
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return;
@@ -610,6 +717,10 @@ const loadAes = async () => {
};
const openAeDialog = (row?: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
aeEditingId.value = row?.id || null;
Object.assign(aeForm, {
term: row?.term || "",
@@ -624,6 +735,10 @@ const openAeDialog = (row?: any) => {
};
const saveAe = async () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId || !subjectId) return;
try {
const payload = {
@@ -650,6 +765,10 @@ const saveAe = async () => {
};
const removeAe = async (row: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId) return;
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return;
+46 -9
View File
@@ -11,11 +11,15 @@
<el-card>
<el-form :model="form" label-width="120px">
<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 :label="TEXT.common.fields.site" required>
<el-select v-model="form.site_id" :disabled="isEdit" :placeholder="TEXT.common.placeholders.select">
<el-option v-for="site in sites" :key="site.id" :label="site.name" :value="site.id" />
<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" :disabled="!site.is_active" />
</el-select>
</el-form-item>
<el-form-item :label="TEXT.common.fields.screeningDate">
@@ -24,7 +28,7 @@
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
:disabled="isEdit"
:disabled="isEdit || isReadOnly"
/>
</el-form-item>
<el-form-item :label="TEXT.common.fields.consentDate">
@@ -33,10 +37,11 @@
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
:disabled="isReadOnly"
/>
</el-form-item>
<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.ENROLLED" value="ENROLLED" />
<el-option :label="TEXT.enums.subjectStatus.COMPLETED" value="COMPLETED" />
@@ -44,16 +49,36 @@
</el-select>
</el-form-item>
<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 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 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-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-form-item>
</el-form>
@@ -75,10 +100,18 @@ const router = useRouter();
const study = useStudyStore();
const saving = ref(false);
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 isEdit = computed(() => !!subjectId.value);
const studyId = computed(() => study.currentStudy?.id || "");
const isReadOnly = computed(() => isEdit.value && !!form.site_id && siteActiveMap.value[form.site_id] === false);
const form = reactive({
subject_no: "",
@@ -121,6 +154,10 @@ const load = async () => {
};
const submit = async () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId.value) return;
saving.value = true;
try {