全局中文化

This commit is contained in:
Cheng Zhou
2026-01-12 11:14:04 +08:00
parent 9021c7fe2b
commit 1338fa7e2b
103 changed files with 2443 additions and 1533 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ async def list_users_for_review(
async def _get_review_user(db: AsyncSession, user_id: uuid.UUID) -> User: async def _get_review_user(db: AsyncSession, user_id: uuid.UUID) -> User:
user = await user_crud.get_by_id(db, user_id) user = await user_crud.get_by_id(db, user_id)
if not user: if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
if user.role == UserRole.ADMIN: if user.role == UserRole.ADMIN:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="管理员账号不允许审核") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="管理员账号不允许审核")
return user return user
+13 -13
View File
@@ -21,7 +21,7 @@ ALLOWED_UPDATE_ROLES = {"PM", "PV", "CRA"}
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id) study = await study_crud.get(db, study_id)
if not study: if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
return study return study
@@ -48,10 +48,10 @@ async def create_ae(
) -> AERead: ) -> AERead:
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
if ae_in.onset_date and ae_in.onset_date > date.today(): if ae_in.onset_date and ae_in.onset_date > date.today():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Onset date cannot be in the future") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="发生日期不能晚于今天")
member_role = await _get_member_role(db, study_id, current_user.id) member_role = await _get_member_role(db, study_id, current_user.id)
if current_user.role != "ADMIN" and member_role not in ALLOWED_CREATE_ROLES: if current_user.role != "ADMIN" and member_role not in ALLOWED_CREATE_ROLES:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
try: try:
ae = await ae_crud.create_ae(db, study_id, ae_in, created_by=current_user.id) ae = await ae_crud.create_ae(db, study_id, ae_in, created_by=current_user.id)
except ValueError as exc: except ValueError as exc:
@@ -62,7 +62,7 @@ async def create_ae(
entity_type="ae", entity_type="ae",
entity_id=ae.id, entity_id=ae.id,
action="CREATE_AE", action="CREATE_AE",
detail=f"AE {ae.id} created", detail=f"AE {ae.id} 已创建",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
@@ -108,7 +108,7 @@ async def get_ae(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
ae = await ae_crud.get_ae(db, ae_id) ae = await ae_crud.get_ae(db, ae_id)
if not ae or ae.study_id != study_id: if not ae or ae.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在")
data = AERead.model_validate(ae) data = AERead.model_validate(ae)
data.is_overdue = _is_overdue(data) data.is_overdue = _is_overdue(data)
return data return data
@@ -128,10 +128,10 @@ async def update_ae(
) -> AERead: ) -> AERead:
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
if ae_in.onset_date and ae_in.onset_date > date.today(): if ae_in.onset_date and ae_in.onset_date > date.today():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Onset date cannot be in the future") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="发生日期不能晚于今天")
ae = await ae_crud.get_ae(db, ae_id) ae = await ae_crud.get_ae(db, ae_id)
if not ae or ae.study_id != study_id: if not ae or ae.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在")
member_role = await _get_member_role(db, study_id, current_user.id) member_role = await _get_member_role(db, study_id, current_user.id)
if current_user.role == "ADMIN": if current_user.role == "ADMIN":
@@ -140,11 +140,11 @@ async def update_ae(
pass pass
elif member_role == "CRA": elif member_role == "CRA":
if ae.created_by != current_user.id: if ae.created_by != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only creator can update AE") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅创建人可更新 AE")
if ae_in.status == "CLOSED": if ae_in.status == "CLOSED":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="CRA cannot close AE") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="CRA 无法关闭 AE")
else: else:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
old_status = ae.status old_status = ae.status
detail_before = { detail_before = {
@@ -195,10 +195,10 @@ async def delete_ae(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
ae = await ae_crud.get_ae(db, ae_id) ae = await ae_crud.get_ae(db, ae_id)
if not ae or ae.study_id != study_id: if not ae or ae.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在")
member_role = await _get_member_role(db, study_id, current_user.id) member_role = await _get_member_role(db, study_id, current_user.id)
if current_user.role != "ADMIN" and member_role not in {"PM", "PV"}: if current_user.role != "ADMIN" and member_role not in {"PM", "PV"}:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await ae_crud.delete_ae(db, ae) await ae_crud.delete_ae(db, ae)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -206,7 +206,7 @@ async def delete_ae(
entity_type="ae", entity_type="ae",
entity_id=ae_id, entity_id=ae_id,
action="DELETE_AE", action="DELETE_AE",
detail=f"AE {ae_id} deleted", detail=f"AE {ae_id} 已删除",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
+15 -15
View File
@@ -26,7 +26,7 @@ UPLOAD_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads"
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id) study = await study_crud.get(db, study_id)
if not study: if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
return study return study
@@ -71,7 +71,7 @@ async def upload_attachment(
entity_type=entity_type, entity_type=entity_type,
entity_id=entity_id, entity_id=entity_id,
action="UPLOAD_FILE", action="UPLOAD_FILE",
detail=f"File uploaded: {file.filename}", detail=f"文件已上传:{file.filename}",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
@@ -130,9 +130,9 @@ async def download_attachment(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
attachment = await attachment_crud.get_attachment(db, attachment_id) attachment = await attachment_crud.get_attachment(db, attachment_id)
if not attachment or attachment.study_id != study_id or attachment.entity_id != entity_id or attachment.entity_type != entity_type: if not attachment or attachment.study_id != study_id or attachment.entity_id != entity_id or attachment.entity_type != entity_type:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Attachment not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
if not os.path.exists(attachment.file_path): if not os.path.exists(attachment.file_path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File missing on server") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="服务器未找到文件")
return FileResponse( return FileResponse(
path=attachment.file_path, path=attachment.file_path,
filename=attachment.filename, filename=attachment.filename,
@@ -148,16 +148,16 @@ async def _authorize_global(request: Request, db: AsyncSession, study_id: uuid.U
if not token: if not token:
token = request.query_params.get("token") token = request.query_params.get("token")
if not token: if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未登录")
payload = decode_token(token) payload = decode_token(token)
user = await user_crud.get_by_id(db, uuid.UUID(str(payload.get("sub")))) user = await user_crud.get_by_id(db, uuid.UUID(str(payload.get("sub"))))
if not user or not user.is_active: if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or missing user") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不存在或已停用")
if user.role == "ADMIN": if user.role == "ADMIN":
return user, None return user, None
membership = await member_crud.get_member(db, study_id, user.id) membership = await member_crud.get_member(db, study_id, user.id)
if not membership or not membership.is_active: if not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not project member") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
return user, membership return user, membership
@@ -172,11 +172,11 @@ async def global_download_attachment(
): ):
attachment = await attachment_crud.get_attachment(db, attachment_id) attachment = await attachment_crud.get_attachment(db, attachment_id)
if not attachment: if not attachment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Attachment not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
await _ensure_study_exists(db, attachment.study_id) await _ensure_study_exists(db, attachment.study_id)
user, _ = await _authorize_global(request, db, attachment.study_id) user, _ = await _authorize_global(request, db, attachment.study_id)
if not os.path.exists(attachment.file_path): if not os.path.exists(attachment.file_path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File missing on server") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="服务器未找到文件")
return FileResponse( return FileResponse(
path=attachment.file_path, path=attachment.file_path,
filename=attachment.filename, filename=attachment.filename,
@@ -195,7 +195,7 @@ async def global_delete_attachment(
): ):
attachment = await attachment_crud.get_attachment(db, attachment_id) attachment = await attachment_crud.get_attachment(db, attachment_id)
if not attachment: if not attachment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Attachment not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
await _ensure_study_exists(db, attachment.study_id) await _ensure_study_exists(db, attachment.study_id)
user, membership = await _authorize_global(request, db, attachment.study_id) user, membership = await _authorize_global(request, db, attachment.study_id)
can_delete = ( can_delete = (
@@ -204,7 +204,7 @@ async def global_delete_attachment(
or (membership and getattr(membership, "role_in_study", None) == "PM") or (membership and getattr(membership, "role_in_study", None) == "PM")
) )
if not can_delete: if not can_delete:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No permission to delete attachment") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件")
await attachment_crud.soft_delete_attachment(db, attachment) await attachment_crud.soft_delete_attachment(db, attachment)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -212,7 +212,7 @@ async def global_delete_attachment(
entity_type=attachment.entity_type, entity_type=attachment.entity_type,
entity_id=attachment.entity_id, entity_id=attachment.entity_id,
action="DELETE_ATTACHMENT", action="DELETE_ATTACHMENT",
detail=f"File deleted: {attachment.filename}", detail=f"文件已删除:{attachment.filename}",
operator_id=user.id, operator_id=user.id,
operator_role=user.role, operator_role=user.role,
) )
@@ -240,7 +240,7 @@ async def delete_attachment(
or attachment.entity_type != entity_type or attachment.entity_type != entity_type
or attachment.is_deleted or attachment.is_deleted
): ):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Attachment not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
membership = None membership = None
if current_user.role != "ADMIN": if current_user.role != "ADMIN":
@@ -252,7 +252,7 @@ async def delete_attachment(
or (membership and getattr(membership, "role_in_study", None) == "PM") or (membership and getattr(membership, "role_in_study", None) == "PM")
) )
if not can_delete: if not can_delete:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No permission to delete attachment") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件")
await attachment_crud.soft_delete_attachment(db, attachment) await attachment_crud.soft_delete_attachment(db, attachment)
await audit_crud.log_action( await audit_crud.log_action(
@@ -261,7 +261,7 @@ async def delete_attachment(
entity_type=entity_type, entity_type=entity_type,
entity_id=entity_id, entity_id=entity_id,
action="DELETE_ATTACHMENT", action="DELETE_ATTACHMENT",
detail=f"File deleted: {attachment.filename}", detail=f"文件已删除:{attachment.filename}",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
+1 -1
View File
@@ -50,5 +50,5 @@ async def delete_audit_log(
): ):
log = await audit_crud.get_log(db, log_id) log = await audit_crud.get_log(db, log_id)
if not log or log.study_id != study_id: if not log or log.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Audit log not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="审计日志不存在")
await audit_crud.delete_log(db, log) await audit_crud.delete_log(db, log)
+1 -1
View File
@@ -124,5 +124,5 @@ async def upload_avatar(
async def get_avatar(user_id: str, filename: str): async def get_avatar(user_id: str, filename: str):
file_path = AVATAR_ROOT / user_id / filename file_path = AVATAR_ROOT / user_id / filename
if not file_path.exists(): if not file_path.exists():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Avatar not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="头像不存在")
return FileResponse(file_path) return FileResponse(file_path)
+7 -7
View File
@@ -15,7 +15,7 @@ router = APIRouter()
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id) study = await study_crud.get(db, study_id)
if not study: if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
return study return study
@@ -39,7 +39,7 @@ async def create_shipment(
entity_type="drug_shipment", entity_type="drug_shipment",
entity_id=shipment.id, entity_id=shipment.id,
action="CREATE_DRUG_SHIPMENT", action="CREATE_DRUG_SHIPMENT",
detail=f"Drug shipment {shipment.id} created", detail=f"药品运输 {shipment.id} 已创建",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
@@ -80,7 +80,7 @@ async def get_shipment(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
shipment = await shipment_crud.get_shipment(db, shipment_id) shipment = await shipment_crud.get_shipment(db, shipment_id)
if not shipment or shipment.study_id != study_id: if not shipment or shipment.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Shipment not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="运输记录不存在")
return DrugShipmentRead.model_validate(shipment) return DrugShipmentRead.model_validate(shipment)
@@ -99,7 +99,7 @@ async def update_shipment(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
shipment = await shipment_crud.get_shipment(db, shipment_id) shipment = await shipment_crud.get_shipment(db, shipment_id)
if not shipment or shipment.study_id != study_id: if not shipment or shipment.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Shipment not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="运输记录不存在")
shipment = await shipment_crud.update_shipment(db, shipment, shipment_in) shipment = await shipment_crud.update_shipment(db, shipment, shipment_in)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -107,7 +107,7 @@ async def update_shipment(
entity_type="drug_shipment", entity_type="drug_shipment",
entity_id=shipment_id, entity_id=shipment_id,
action="UPDATE_DRUG_SHIPMENT", action="UPDATE_DRUG_SHIPMENT",
detail=f"Drug shipment {shipment_id} updated", detail=f"药品运输 {shipment_id} 已更新",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
@@ -128,7 +128,7 @@ async def delete_shipment(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
shipment = await shipment_crud.get_shipment(db, shipment_id) shipment = await shipment_crud.get_shipment(db, shipment_id)
if not shipment or shipment.study_id != study_id: if not shipment or shipment.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Shipment not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="运输记录不存在")
await shipment_crud.delete_shipment(db, shipment) await shipment_crud.delete_shipment(db, shipment)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -136,7 +136,7 @@ async def delete_shipment(
entity_type="drug_shipment", entity_type="drug_shipment",
entity_id=shipment_id, entity_id=shipment_id,
action="DELETE_DRUG_SHIPMENT", action="DELETE_DRUG_SHIPMENT",
detail=f"Drug shipment {shipment_id} deleted", detail=f"药品运输 {shipment_id} 已删除",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
+14 -14
View File
@@ -17,7 +17,7 @@ router = APIRouter()
def _check_permission_for_scope(study_id: uuid.UUID, current_user, member_role: str | None): def _check_permission_for_scope(study_id: uuid.UUID, current_user, member_role: str | None):
if current_user.role != "ADMIN" and member_role != "PM": if current_user.role != "ADMIN" and member_role != "PM":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
@router.post( @router.post(
@@ -33,14 +33,14 @@ async def create_category(
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
) -> CategoryRead: ) -> CategoryRead:
if not payload.study_id: if not payload.study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="study_id is required") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID")
existing = await category_crud.get_category_by_name(db, payload.study_id, payload.name) existing = await category_crud.get_category_by_name(db, payload.study_id, payload.name)
if existing: if existing:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类名称已存在") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类名称已存在")
member_role = None member_role = None
member = await member_crud.get_member(db, payload.study_id, current_user.id) member = await member_crud.get_member(db, payload.study_id, current_user.id)
if not member and current_user.role != "ADMIN": if not member and current_user.role != "ADMIN":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a study member") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是该项目成员")
member_role = member.role_in_study if member else None member_role = member.role_in_study if member else None
_check_permission_for_scope(payload.study_id, current_user, member_role) _check_permission_for_scope(payload.study_id, current_user, member_role)
category = await category_crud.create_category(db, payload) category = await category_crud.create_category(db, payload)
@@ -50,7 +50,7 @@ async def create_category(
entity_type="faq_category", entity_type="faq_category",
entity_id=category.id, entity_id=category.id,
action="CREATE_FAQ_CATEGORY", action="CREATE_FAQ_CATEGORY",
detail=f"FAQ category {category.name} created", detail=f"FAQ 分类 {category.name} 已创建",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
@@ -70,11 +70,11 @@ async def list_categories(
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
) -> list[CategoryRead]: ) -> list[CategoryRead]:
if not study_id: if not study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="study_id is required") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID")
if study_id: if study_id:
member = await member_crud.get_member(db, study_id, current_user.id) member = await member_crud.get_member(db, study_id, current_user.id)
if not member and current_user.role != "ADMIN": if not member and current_user.role != "ADMIN":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a study member") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是该项目成员")
categories = await category_crud.list_categories(db, study_id, include_global=False, is_active=is_active) categories = await category_crud.list_categories(db, study_id, include_global=False, is_active=is_active)
return paginate([CategoryRead.model_validate(c) for c in categories], total=len(categories)) return paginate([CategoryRead.model_validate(c) for c in categories], total=len(categories))
@@ -93,22 +93,22 @@ async def update_category(
) -> CategoryRead: ) -> CategoryRead:
category = await category_crud.get_category(db, category_id) category = await category_crud.get_category(db, category_id)
if not category: if not category:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分类不存在")
member_role = None member_role = None
update_data = payload.model_dump(exclude_unset=True) update_data = payload.model_dump(exclude_unset=True)
target_study_id = update_data.get("study_id", category.study_id) target_study_id = update_data.get("study_id", category.study_id)
target_name = update_data.get("name", category.name) target_name = update_data.get("name", category.name)
if "study_id" in update_data and update_data["study_id"] != category.study_id and current_user.role != "ADMIN": if "study_id" in update_data and update_data["study_id"] != category.study_id and current_user.role != "ADMIN":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only admin can change category scope") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅管理员可修改分类范围")
existing = await category_crud.get_category_by_name(db, target_study_id, target_name) existing = await category_crud.get_category_by_name(db, target_study_id, target_name)
if existing and existing.id != category.id: if existing and existing.id != category.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类名称已存在") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类名称已存在")
if not target_study_id: if not target_study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="study_id is required") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID")
member = await member_crud.get_member(db, target_study_id, current_user.id) member = await member_crud.get_member(db, target_study_id, current_user.id)
member_role = member.role_in_study if member else None member_role = member.role_in_study if member else None
if not member and current_user.role != "ADMIN": if not member and current_user.role != "ADMIN":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a study member") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是该项目成员")
_check_permission_for_scope(target_study_id, current_user, member_role) _check_permission_for_scope(target_study_id, current_user, member_role)
updated = await category_crud.update_category(db, category, payload) updated = await category_crud.update_category(db, category, payload)
await audit_crud.log_action( await audit_crud.log_action(
@@ -117,7 +117,7 @@ async def update_category(
entity_type="faq_category", entity_type="faq_category",
entity_id=category_id, entity_id=category_id,
action="UPDATE_FAQ_CATEGORY", action="UPDATE_FAQ_CATEGORY",
detail=f"FAQ category {category_id} updated", detail=f"FAQ 分类 {category_id} 已更新",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
@@ -137,9 +137,9 @@ async def delete_category(
) -> None: ) -> None:
category = await category_crud.get_category(db, category_id) category = await category_crud.get_category(db, category_id)
if not category: if not category:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分类不存在")
if current_user.role != "ADMIN": if current_user.role != "ADMIN":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only admin can delete FAQ category") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅管理员可删除 FAQ 分类")
item_count = await item_crud.count_items_by_category(db, category_id) item_count = await item_crud.count_items_by_category(db, category_id)
if item_count > 0: if item_count > 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类下存在 FAQ,无法删除") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类下存在 FAQ,无法删除")
@@ -151,7 +151,7 @@ async def delete_category(
entity_type="faq_category", entity_type="faq_category",
entity_id=category_id, entity_id=category_id,
action="DELETE_FAQ_CATEGORY", action="DELETE_FAQ_CATEGORY",
detail=f"FAQ category {category_id} deleted", detail=f"FAQ 分类 {category_id} 已删除",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
+34 -34
View File
@@ -27,12 +27,12 @@ router = APIRouter()
def _check_write_permission(study_id: uuid.UUID, current_user, member_role: str | None): def _check_write_permission(study_id: uuid.UUID, current_user, member_role: str | None):
if current_user.role != "ADMIN" and member_role != "PM": if current_user.role != "ADMIN" and member_role != "PM":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
def _check_create_permission(current_user, is_member: bool): def _check_create_permission(current_user, is_member: bool):
if current_user.role != "ADMIN" and not is_member: if current_user.role != "ADMIN" and not is_member:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a study member") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是该项目成员")
@router.post( @router.post(
@@ -48,12 +48,12 @@ async def create_faq(
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
) -> FaqRead: ) -> FaqRead:
if not payload.study_id: if not payload.study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="study_id is required") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID")
cat = await category_crud.get_category(db, payload.category_id) cat = await category_crud.get_category(db, payload.category_id)
if not cat: if not cat:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分类不存在")
if cat.study_id != payload.study_id: if cat.study_id != payload.study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Category scope mismatch") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类范围不匹配")
member_role = None member_role = None
is_member = False is_member = False
member = await member_crud.get_member(db, payload.study_id, current_user.id) member = await member_crud.get_member(db, payload.study_id, current_user.id)
@@ -79,7 +79,7 @@ async def create_faq(
entity_type="faq_item", entity_type="faq_item",
entity_id=item.id, entity_id=item.id,
action="CREATE_FAQ_ITEM", action="CREATE_FAQ_ITEM",
detail="FAQ created", detail="FAQ 已创建",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
@@ -111,16 +111,16 @@ async def list_faqs(
return role return role
if not study_id: if not study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="study_id is required") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID")
if current_user.role != "ADMIN": if current_user.role != "ADMIN":
role = await _get_member_role(study_id) role = await _get_member_role(study_id)
if not role: if not role:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a study member") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是该项目成员")
if is_active is False and current_user.role != "ADMIN": if is_active is False and current_user.role != "ADMIN":
role = await _get_member_role(study_id) role = await _get_member_role(study_id)
if role != "PM": if role != "PM":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
items = await faq_crud.list_items( items = await faq_crud.list_items(
db, db,
@@ -158,18 +158,18 @@ async def get_faq(
) -> FaqRead: ) -> FaqRead:
item = await faq_crud.get_item(db, item_id) item = await faq_crud.get_item(db, item_id)
if not item: if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if not item.study_id: if not item.study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if current_user.role != "ADMIN": if current_user.role != "ADMIN":
member = await member_crud.get_member(db, item.study_id, current_user.id) member = await member_crud.get_member(db, item.study_id, current_user.id)
if not member: if not member:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a study member") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是该项目成员")
if not item.is_active and current_user.role not in {"ADMIN"}: if not item.is_active and current_user.role not in {"ADMIN"}:
member = await member_crud.get_member(db, item.study_id, current_user.id) member = await member_crud.get_member(db, item.study_id, current_user.id)
member_role = member.role_in_study if member else None member_role = member.role_in_study if member else None
if member_role != "PM": if member_role != "PM":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Inactive FAQ") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="FAQ 已停用")
return FaqRead.model_validate(item) return FaqRead.model_validate(item)
@@ -187,7 +187,7 @@ async def update_faq(
) -> FaqRead: ) -> FaqRead:
item = await faq_crud.get_item(db, item_id) item = await faq_crud.get_item(db, item_id)
if not item: if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if item.created_by != current_user.id: if item.created_by != current_user.id:
member_role = None member_role = None
if item.study_id: if item.study_id:
@@ -228,16 +228,16 @@ async def update_faq_status(
) -> FaqRead: ) -> FaqRead:
item = await faq_crud.get_item(db, item_id) item = await faq_crud.get_item(db, item_id)
if not item: if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if payload.status != "RESOLVED": if payload.status != "RESOLVED":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Only RESOLVED is allowed") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅允许设置为已解决")
if item.created_by != current_user.id and current_user.role != "ADMIN": if item.created_by != current_user.id and current_user.role != "ADMIN":
member_role = None member_role = None
if item.study_id: if item.study_id:
member = await member_crud.get_member(db, item.study_id, current_user.id) member = await member_crud.get_member(db, item.study_id, current_user.id)
member_role = member.role_in_study if member else None member_role = member.role_in_study if member else None
if member_role != "PM": if member_role != "PM":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await faq_crud.set_status(db, item.id, "RESOLVED", resolved_by_confirm=True) await faq_crud.set_status(db, item.id, "RESOLVED", resolved_by_confirm=True)
updated = await faq_crud.get_item(db, item_id) updated = await faq_crud.get_item(db, item_id)
return FaqRead.model_validate(updated) return FaqRead.model_validate(updated)
@@ -257,9 +257,9 @@ async def set_best_reply(
) -> FaqRead: ) -> FaqRead:
item = await faq_crud.get_item(db, item_id) item = await faq_crud.get_item(db, item_id)
if not item: if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if not item.study_id: if not item.study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
is_member = False is_member = False
member = await member_crud.get_member(db, item.study_id, current_user.id) member = await member_crud.get_member(db, item.study_id, current_user.id)
is_member = member is not None is_member = member is not None
@@ -267,9 +267,9 @@ async def set_best_reply(
if payload.best_reply_id: if payload.best_reply_id:
reply = await reply_crud.get_reply(db, payload.best_reply_id) reply = await reply_crud.get_reply(db, payload.best_reply_id)
if not reply or reply.faq_id != item.id: if not reply or reply.faq_id != item.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid reply") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="回复无效")
if reply.is_deleted: if reply.is_deleted:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reply deleted") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="回复已删除")
await faq_crud.set_best_reply(db, item.id, reply.id) await faq_crud.set_best_reply(db, item.id, reply.id)
await faq_crud.set_status(db, item.id, "RESOLVED", resolved_by_confirm=item.resolved_by_confirm) await faq_crud.set_status(db, item.id, "RESOLVED", resolved_by_confirm=item.resolved_by_confirm)
else: else:
@@ -299,11 +299,11 @@ async def list_replies(
) -> list[FaqReplyRead]: ) -> list[FaqReplyRead]:
item = await faq_crud.get_item(db, item_id) item = await faq_crud.get_item(db, item_id)
if not item: if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if item.study_id and current_user.role != "ADMIN": if item.study_id and current_user.role != "ADMIN":
member = await member_crud.get_member(db, item.study_id, current_user.id) member = await member_crud.get_member(db, item.study_id, current_user.id)
if not member: if not member:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a study member") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是该项目成员")
replies = await reply_crud.list_replies(db, item_id) replies = await reply_crud.list_replies(db, item_id)
reply_map = {r.id: r for r in replies} reply_map = {r.id: r for r in replies}
result: list[FaqReplyRead] = [] result: list[FaqReplyRead] = []
@@ -339,11 +339,11 @@ async def create_reply(
) -> FaqReplyRead: ) -> FaqReplyRead:
item = await faq_crud.get_item(db, item_id) item = await faq_crud.get_item(db, item_id)
if not item: if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if not item.study_id: if not item.study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if not payload.content.strip(): if not payload.content.strip():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reply content is required") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="回复内容不能为空")
is_member = False is_member = False
member = await member_crud.get_member(db, item.study_id, current_user.id) member = await member_crud.get_member(db, item.study_id, current_user.id)
is_member = member is not None is_member = member is not None
@@ -352,7 +352,7 @@ async def create_reply(
if payload.quote_reply_id: if payload.quote_reply_id:
quote = await reply_crud.get_reply(db, payload.quote_reply_id) quote = await reply_crud.get_reply(db, payload.quote_reply_id)
if not quote or quote.faq_id != item.id: if not quote or quote.faq_id != item.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid quote reply") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="引用回复无效")
reply = await reply_crud.create_reply( reply = await reply_crud.create_reply(
db, db,
faq_id=item.id, faq_id=item.id,
@@ -369,7 +369,7 @@ async def create_reply(
entity_type="faq_reply", entity_type="faq_reply",
entity_id=reply.id, entity_id=reply.id,
action="CREATE_FAQ_REPLY", action="CREATE_FAQ_REPLY",
detail="FAQ replied", detail="FAQ 已回复",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
@@ -400,7 +400,7 @@ async def delete_faq(
) -> None: ) -> None:
item = await faq_crud.get_item(db, item_id) item = await faq_crud.get_item(db, item_id)
if not item: if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if item.created_by != current_user.id: if item.created_by != current_user.id:
member_role = None member_role = None
if item.study_id: if item.study_id:
@@ -416,7 +416,7 @@ async def delete_faq(
entity_type="faq_item", entity_type="faq_item",
entity_id=item_id, entity_id=item_id,
action="DELETE_FAQ_ITEM", action="DELETE_FAQ_ITEM",
detail="FAQ deleted", detail="FAQ 已删除",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
@@ -436,17 +436,17 @@ async def delete_reply(
) -> None: ) -> None:
item = await faq_crud.get_item(db, item_id) item = await faq_crud.get_item(db, item_id)
if not item: if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
reply = await reply_crud.get_reply(db, reply_id) reply = await reply_crud.get_reply(db, reply_id)
if not reply or reply.faq_id != item.id: if not reply or reply.faq_id != item.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reply not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="回复不存在")
if reply.created_by != current_user.id and current_user.role != "ADMIN": if reply.created_by != current_user.id and current_user.role != "ADMIN":
member_role = None member_role = None
if item.study_id: if item.study_id:
member = await member_crud.get_member(db, item.study_id, current_user.id) member = await member_crud.get_member(db, item.study_id, current_user.id)
member_role = member.role_in_study if member else None member_role = member.role_in_study if member else None
if member_role != "PM": if member_role != "PM":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
if item.best_reply_id == reply.id: if item.best_reply_id == reply.id:
await faq_crud.set_best_reply(db, item.id, None) await faq_crud.set_best_reply(db, item.id, None)
ref_count = await reply_crud.count_quote_references(db, reply.id) ref_count = await reply_crud.count_quote_references(db, reply.id)
@@ -472,7 +472,7 @@ async def delete_reply(
entity_type="faq_reply", entity_type="faq_reply",
entity_id=reply_id, entity_id=reply_id,
action="DELETE_FAQ_REPLY", action="DELETE_FAQ_REPLY",
detail="FAQ reply deleted", detail="FAQ 回复已删除",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
+7 -7
View File
@@ -15,7 +15,7 @@ router = APIRouter()
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id) study = await study_crud.get(db, study_id)
if not study: if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
return study return study
@@ -39,7 +39,7 @@ async def create_contract(
entity_type="finance_contract", entity_type="finance_contract",
entity_id=contract.id, entity_id=contract.id,
action="CREATE_FINANCE_CONTRACT", action="CREATE_FINANCE_CONTRACT",
detail=f"Finance contract {contract.contract_no} created", detail=f"合同费用 {contract.contract_no} 已创建",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
@@ -77,7 +77,7 @@ async def get_contract(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
contract = await contract_crud.get_contract(db, contract_id) contract = await contract_crud.get_contract(db, contract_id)
if not contract or contract.study_id != study_id: if not contract or contract.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Contract not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同不存在")
return FinanceContractRead.model_validate(contract) return FinanceContractRead.model_validate(contract)
@@ -96,7 +96,7 @@ async def update_contract(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
contract = await contract_crud.get_contract(db, contract_id) contract = await contract_crud.get_contract(db, contract_id)
if not contract or contract.study_id != study_id: if not contract or contract.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Contract not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同不存在")
contract = await contract_crud.update_contract(db, contract, contract_in) contract = await contract_crud.update_contract(db, contract, contract_in)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -104,7 +104,7 @@ async def update_contract(
entity_type="finance_contract", entity_type="finance_contract",
entity_id=contract_id, entity_id=contract_id,
action="UPDATE_FINANCE_CONTRACT", action="UPDATE_FINANCE_CONTRACT",
detail=f"Finance contract {contract_id} updated", detail=f"合同费用 {contract_id} 已更新",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
@@ -125,7 +125,7 @@ async def delete_contract(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
contract = await contract_crud.get_contract(db, contract_id) contract = await contract_crud.get_contract(db, contract_id)
if not contract or contract.study_id != study_id: if not contract or contract.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Contract not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同不存在")
await contract_crud.delete_contract(db, contract) await contract_crud.delete_contract(db, contract)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -133,7 +133,7 @@ async def delete_contract(
entity_type="finance_contract", entity_type="finance_contract",
entity_id=contract_id, entity_id=contract_id,
action="DELETE_FINANCE_CONTRACT", action="DELETE_FINANCE_CONTRACT",
detail=f"Finance contract {contract_id} deleted", detail=f"合同费用 {contract_id} 已删除",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
+7 -7
View File
@@ -15,7 +15,7 @@ router = APIRouter()
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id) study = await study_crud.get(db, study_id)
if not study: if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
return study return study
@@ -39,7 +39,7 @@ async def create_special(
entity_type="finance_special", entity_type="finance_special",
entity_id=item.id, entity_id=item.id,
action="CREATE_FINANCE_SPECIAL", action="CREATE_FINANCE_SPECIAL",
detail=f"Finance special {item.id} created", detail=f"特殊费用 {item.id} 已创建",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
@@ -77,7 +77,7 @@ async def get_special(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
item = await special_crud.get_special(db, special_id) item = await special_crud.get_special(db, special_id)
if not item or item.study_id != study_id: if not item or item.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Special fee not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
return FinanceSpecialRead.model_validate(item) return FinanceSpecialRead.model_validate(item)
@@ -96,7 +96,7 @@ async def update_special(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
item = await special_crud.get_special(db, special_id) item = await special_crud.get_special(db, special_id)
if not item or item.study_id != study_id: if not item or item.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Special fee not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
item = await special_crud.update_special(db, item, special_in) item = await special_crud.update_special(db, item, special_in)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -104,7 +104,7 @@ async def update_special(
entity_type="finance_special", entity_type="finance_special",
entity_id=special_id, entity_id=special_id,
action="UPDATE_FINANCE_SPECIAL", action="UPDATE_FINANCE_SPECIAL",
detail=f"Finance special {special_id} updated", detail=f"特殊费用 {special_id} 已更新",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
@@ -125,7 +125,7 @@ async def delete_special(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
item = await special_crud.get_special(db, special_id) item = await special_crud.get_special(db, special_id)
if not item or item.study_id != study_id: if not item or item.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Special fee not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
await special_crud.delete_special(db, item) await special_crud.delete_special(db, item)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -133,7 +133,7 @@ async def delete_special(
entity_type="finance_special", entity_type="finance_special",
entity_id=special_id, entity_id=special_id,
action="DELETE_FINANCE_SPECIAL", action="DELETE_FINANCE_SPECIAL",
detail=f"Finance special {special_id} deleted", detail=f"特殊费用 {special_id} 已删除",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
+7 -7
View File
@@ -15,7 +15,7 @@ router = APIRouter()
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id) study = await study_crud.get(db, study_id)
if not study: if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
return study return study
@@ -39,7 +39,7 @@ async def create_note(
entity_type="knowledge_note", entity_type="knowledge_note",
entity_id=note.id, entity_id=note.id,
action="CREATE_KNOWLEDGE_NOTE", action="CREATE_KNOWLEDGE_NOTE",
detail=f"Knowledge note {note.title} created", detail=f"注意事项 {note.title} 已创建",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
@@ -77,7 +77,7 @@ async def get_note(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
note = await note_crud.get_note(db, note_id) note = await note_crud.get_note(db, note_id)
if not note or note.study_id != study_id: if not note or note.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Note not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
return KnowledgeNoteRead.model_validate(note) return KnowledgeNoteRead.model_validate(note)
@@ -96,7 +96,7 @@ async def update_note(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
note = await note_crud.get_note(db, note_id) note = await note_crud.get_note(db, note_id)
if not note or note.study_id != study_id: if not note or note.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Note not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
note = await note_crud.update_note(db, note, note_in) note = await note_crud.update_note(db, note, note_in)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -104,7 +104,7 @@ async def update_note(
entity_type="knowledge_note", entity_type="knowledge_note",
entity_id=note_id, entity_id=note_id,
action="UPDATE_KNOWLEDGE_NOTE", action="UPDATE_KNOWLEDGE_NOTE",
detail=f"Knowledge note {note_id} updated", detail=f"注意事项 {note_id} 已更新",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
@@ -125,7 +125,7 @@ async def delete_note(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
note = await note_crud.get_note(db, note_id) note = await note_crud.get_note(db, note_id)
if not note or note.study_id != study_id: if not note or note.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Note not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
await note_crud.delete_note(db, note) await note_crud.delete_note(db, note)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -133,7 +133,7 @@ async def delete_note(
entity_type="knowledge_note", entity_type="knowledge_note",
entity_id=note_id, entity_id=note_id,
action="DELETE_KNOWLEDGE_NOTE", action="DELETE_KNOWLEDGE_NOTE",
detail=f"Knowledge note {note_id} deleted", detail=f"注意事项 {note_id} 已删除",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
+4 -4
View File
@@ -16,7 +16,7 @@ router = APIRouter()
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id) study = await study_crud.get(db, study_id)
if not study: if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
return study return study
@@ -34,7 +34,7 @@ async def add_member(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
existing = await member_crud.get_member(db, study_id, member_in.user_id) existing = await member_crud.get_member(db, study_id, member_in.user_id)
if existing: if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Member already exists") raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="成员已存在")
member = await member_crud.add_member(db, study_id, member_in) member = await member_crud.add_member(db, study_id, member_in)
return member return member
@@ -90,7 +90,7 @@ async def update_member(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
member = await member_crud.get_member_by_id(db, member_id) member = await member_crud.get_member_by_id(db, member_id)
if not member or member.study_id != study_id: if not member or member.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Member not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="成员不存在")
updated = await member_crud.update_member(db, member, member_in) updated = await member_crud.update_member(db, member, member_in)
return updated return updated
@@ -108,6 +108,6 @@ async def remove_member(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
member = await member_crud.get_member_by_id(db, member_id) member = await member_crud.get_member_by_id(db, member_id)
if not member or member.study_id != study_id: if not member or member.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Member not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="成员不存在")
removed = await member_crud.remove_member(db, member) removed = await member_crud.remove_member(db, member)
return removed return removed
+2 -2
View File
@@ -14,7 +14,7 @@ router = APIRouter()
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id) study = await study_crud.get(db, study_id)
if not study: if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
return study return study
@@ -64,6 +64,6 @@ async def update_site(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
site = await site_crud.get_site(db, site_id) site = await site_crud.get_site(db, site_id)
if not site or site.study_id != study_id: if not site or site.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Site not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分中心不存在")
updated = await site_crud.update_site(db, site, site_in) updated = await site_crud.update_site(db, site, site_in)
return updated return updated
+25 -25
View File
@@ -28,7 +28,7 @@ router = APIRouter()
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id) study = await study_crud.get(db, study_id)
if not study: if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
return study return study
@@ -52,7 +52,7 @@ async def create_feasibility(
entity_type="startup_feasibility", entity_type="startup_feasibility",
entity_id=record.id, entity_id=record.id,
action="CREATE_STARTUP_FEASIBILITY", action="CREATE_STARTUP_FEASIBILITY",
detail="Startup feasibility record created", detail="立项记录已创建",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
@@ -88,7 +88,7 @@ async def get_feasibility(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
record = await startup_crud.get_feasibility(db, record_id) record = await startup_crud.get_feasibility(db, record_id)
if not record or record.study_id != study_id: if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feasibility record not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="立项记录不存在")
return StartupFeasibilityRead.model_validate(record) return StartupFeasibilityRead.model_validate(record)
@@ -107,7 +107,7 @@ async def update_feasibility(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
record = await startup_crud.get_feasibility(db, record_id) record = await startup_crud.get_feasibility(db, record_id)
if not record or record.study_id != study_id: if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feasibility record not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="立项记录不存在")
record = await startup_crud.update_feasibility(db, record, record_in) record = await startup_crud.update_feasibility(db, record, record_in)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -115,7 +115,7 @@ async def update_feasibility(
entity_type="startup_feasibility", entity_type="startup_feasibility",
entity_id=record_id, entity_id=record_id,
action="UPDATE_STARTUP_FEASIBILITY", action="UPDATE_STARTUP_FEASIBILITY",
detail="Startup feasibility record updated", detail="立项记录已更新",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
@@ -136,7 +136,7 @@ async def delete_feasibility(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
record = await startup_crud.get_feasibility(db, record_id) record = await startup_crud.get_feasibility(db, record_id)
if not record or record.study_id != study_id: if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feasibility record not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="立项记录不存在")
await startup_crud.delete_feasibility(db, record) await startup_crud.delete_feasibility(db, record)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -144,7 +144,7 @@ async def delete_feasibility(
entity_type="startup_feasibility", entity_type="startup_feasibility",
entity_id=record_id, entity_id=record_id,
action="DELETE_STARTUP_FEASIBILITY", action="DELETE_STARTUP_FEASIBILITY",
detail="Startup feasibility record deleted", detail="立项记录已删除",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
@@ -170,7 +170,7 @@ async def create_ethics(
entity_type="startup_ethics", entity_type="startup_ethics",
entity_id=record.id, entity_id=record.id,
action="CREATE_STARTUP_ETHICS", action="CREATE_STARTUP_ETHICS",
detail="Startup ethics record created", detail="伦理记录已创建",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
@@ -206,7 +206,7 @@ async def get_ethics(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
record = await startup_crud.get_ethics(db, record_id) record = await startup_crud.get_ethics(db, record_id)
if not record or record.study_id != study_id: if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ethics record not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="伦理记录不存在")
return StartupEthicsRead.model_validate(record) return StartupEthicsRead.model_validate(record)
@@ -225,7 +225,7 @@ async def update_ethics(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
record = await startup_crud.get_ethics(db, record_id) record = await startup_crud.get_ethics(db, record_id)
if not record or record.study_id != study_id: if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ethics record not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="伦理记录不存在")
record = await startup_crud.update_ethics(db, record, record_in) record = await startup_crud.update_ethics(db, record, record_in)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -233,7 +233,7 @@ async def update_ethics(
entity_type="startup_ethics", entity_type="startup_ethics",
entity_id=record_id, entity_id=record_id,
action="UPDATE_STARTUP_ETHICS", action="UPDATE_STARTUP_ETHICS",
detail="Startup ethics record updated", detail="伦理记录已更新",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
@@ -254,7 +254,7 @@ async def delete_ethics(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
record = await startup_crud.get_ethics(db, record_id) record = await startup_crud.get_ethics(db, record_id)
if not record or record.study_id != study_id: if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ethics record not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="伦理记录不存在")
await startup_crud.delete_ethics(db, record) await startup_crud.delete_ethics(db, record)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -262,7 +262,7 @@ async def delete_ethics(
entity_type="startup_ethics", entity_type="startup_ethics",
entity_id=record_id, entity_id=record_id,
action="DELETE_STARTUP_ETHICS", action="DELETE_STARTUP_ETHICS",
detail="Startup ethics record deleted", detail="伦理记录已删除",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
@@ -288,7 +288,7 @@ async def create_kickoff(
entity_type="startup_kickoff", entity_type="startup_kickoff",
entity_id=meeting.id, entity_id=meeting.id,
action="CREATE_KICKOFF_MEETING", action="CREATE_KICKOFF_MEETING",
detail="Kickoff meeting created", detail="启动会记录已创建",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
@@ -324,7 +324,7 @@ async def get_kickoff(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
meeting = await startup_crud.get_kickoff(db, meeting_id) meeting = await startup_crud.get_kickoff(db, meeting_id)
if not meeting or meeting.study_id != study_id: if not meeting or meeting.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kickoff meeting not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="启动会记录不存在")
return KickoffMeetingRead.model_validate(meeting) return KickoffMeetingRead.model_validate(meeting)
@@ -343,7 +343,7 @@ async def update_kickoff(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
meeting = await startup_crud.get_kickoff(db, meeting_id) meeting = await startup_crud.get_kickoff(db, meeting_id)
if not meeting or meeting.study_id != study_id: if not meeting or meeting.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kickoff meeting not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="启动会记录不存在")
meeting = await startup_crud.update_kickoff(db, meeting, meeting_in) meeting = await startup_crud.update_kickoff(db, meeting, meeting_in)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -351,7 +351,7 @@ async def update_kickoff(
entity_type="startup_kickoff", entity_type="startup_kickoff",
entity_id=meeting_id, entity_id=meeting_id,
action="UPDATE_KICKOFF_MEETING", action="UPDATE_KICKOFF_MEETING",
detail="Kickoff meeting updated", detail="启动会记录已更新",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
@@ -372,7 +372,7 @@ async def delete_kickoff(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
meeting = await startup_crud.get_kickoff(db, meeting_id) meeting = await startup_crud.get_kickoff(db, meeting_id)
if not meeting or meeting.study_id != study_id: if not meeting or meeting.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kickoff meeting not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="启动会记录不存在")
await startup_crud.delete_kickoff(db, meeting) await startup_crud.delete_kickoff(db, meeting)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -380,7 +380,7 @@ async def delete_kickoff(
entity_type="startup_kickoff", entity_type="startup_kickoff",
entity_id=meeting_id, entity_id=meeting_id,
action="DELETE_KICKOFF_MEETING", action="DELETE_KICKOFF_MEETING",
detail="Kickoff meeting deleted", detail="启动会记录已删除",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
@@ -406,7 +406,7 @@ async def create_training_authorization(
entity_type="training_authorization", entity_type="training_authorization",
entity_id=record.id, entity_id=record.id,
action="CREATE_TRAINING_AUTH", action="CREATE_TRAINING_AUTH",
detail="Training authorization created", detail="培训授权人员已创建",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
@@ -442,7 +442,7 @@ async def get_training_authorization(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
record = await startup_crud.get_training_authorization(db, record_id) record = await startup_crud.get_training_authorization(db, record_id)
if not record or record.study_id != study_id: if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Training authorization not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="培训授权人员不存在")
return TrainingAuthorizationRead.model_validate(record) return TrainingAuthorizationRead.model_validate(record)
@@ -461,7 +461,7 @@ async def update_training_authorization(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
record = await startup_crud.get_training_authorization(db, record_id) record = await startup_crud.get_training_authorization(db, record_id)
if not record or record.study_id != study_id: if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Training authorization not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="培训授权人员不存在")
record = await startup_crud.update_training_authorization(db, record, record_in) record = await startup_crud.update_training_authorization(db, record, record_in)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -469,7 +469,7 @@ async def update_training_authorization(
entity_type="training_authorization", entity_type="training_authorization",
entity_id=record_id, entity_id=record_id,
action="UPDATE_TRAINING_AUTH", action="UPDATE_TRAINING_AUTH",
detail="Training authorization updated", detail="培训授权人员已更新",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
@@ -490,7 +490,7 @@ async def delete_training_authorization(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
record = await startup_crud.get_training_authorization(db, record_id) record = await startup_crud.get_training_authorization(db, record_id)
if not record or record.study_id != study_id: if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Training authorization not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="培训授权人员不存在")
await startup_crud.delete_training_authorization(db, record) await startup_crud.delete_training_authorization(db, record)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -498,7 +498,7 @@ async def delete_training_authorization(
entity_type="training_authorization", entity_type="training_authorization",
entity_id=record_id, entity_id=record_id,
action="DELETE_TRAINING_AUTH", action="DELETE_TRAINING_AUTH",
detail="Training authorization deleted", detail="培训授权人员已删除",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
+3 -3
View File
@@ -20,7 +20,7 @@ async def create_study(
) -> StudyRead: ) -> StudyRead:
existing = await study_crud.get_by_code(db, study_in.code) existing = await study_crud.get_by_code(db, study_in.code)
if existing: if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Study code already exists") raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="项目编号已存在")
study = await study_crud.create(db, study_in, created_by=current_user.id) study = await study_crud.create(db, study_in, created_by=current_user.id)
return study return study
@@ -48,7 +48,7 @@ async def get_study(
) -> StudyRead: ) -> StudyRead:
study = await study_crud.get(db, study_id) study = await study_crud.get(db, study_id)
if not study: if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
return study return study
@@ -64,6 +64,6 @@ async def update_study(
) -> StudyRead: ) -> StudyRead:
study = await study_crud.get(db, study_id) study = await study_crud.get(db, study_id)
if not study: if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
updated = await study_crud.update(db, study, study_in) updated = await study_crud.update(db, study, study_in)
return updated return updated
+8 -8
View File
@@ -15,7 +15,7 @@ router = APIRouter()
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id) study = await study_crud.get(db, study_id)
if not study: if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
return study return study
@@ -34,7 +34,7 @@ async def create_history(
) -> SubjectHistoryRead: ) -> SubjectHistoryRead:
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
if history_in.subject_id != subject_id: if history_in.subject_id != subject_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Subject mismatch") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="受试者不匹配")
history = await history_crud.create_history(db, study_id, history_in, created_by=current_user.id) history = await history_crud.create_history(db, study_id, history_in, created_by=current_user.id)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -42,7 +42,7 @@ async def create_history(
entity_type="subject_history", entity_type="subject_history",
entity_id=history.id, entity_id=history.id,
action="CREATE_SUBJECT_HISTORY", action="CREATE_SUBJECT_HISTORY",
detail="Subject history created", detail="病史记录已创建",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
@@ -80,7 +80,7 @@ async def get_history(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
history = await history_crud.get_history(db, history_id) history = await history_crud.get_history(db, history_id)
if not history or history.study_id != study_id or history.subject_id != subject_id: if not history or history.study_id != study_id or history.subject_id != subject_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="History not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="病史记录不存在")
return SubjectHistoryRead.model_validate(history) return SubjectHistoryRead.model_validate(history)
@@ -100,7 +100,7 @@ async def update_history(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
history = await history_crud.get_history(db, history_id) history = await history_crud.get_history(db, history_id)
if not history or history.study_id != study_id or history.subject_id != subject_id: if not history or history.study_id != study_id or history.subject_id != subject_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="History not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="病史记录不存在")
history = await history_crud.update_history(db, history, history_in) history = await history_crud.update_history(db, history, history_in)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -108,7 +108,7 @@ async def update_history(
entity_type="subject_history", entity_type="subject_history",
entity_id=history_id, entity_id=history_id,
action="UPDATE_SUBJECT_HISTORY", action="UPDATE_SUBJECT_HISTORY",
detail="Subject history updated", detail="病史记录已更新",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
@@ -130,7 +130,7 @@ async def delete_history(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
history = await history_crud.get_history(db, history_id) history = await history_crud.get_history(db, history_id)
if not history or history.study_id != study_id or history.subject_id != subject_id: if not history or history.study_id != study_id or history.subject_id != subject_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="History not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="病史记录不存在")
await history_crud.delete_history(db, history) await history_crud.delete_history(db, history)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -138,7 +138,7 @@ async def delete_history(
entity_type="subject_history", entity_type="subject_history",
entity_id=history_id, entity_id=history_id,
action="DELETE_SUBJECT_HISTORY", action="DELETE_SUBJECT_HISTORY",
detail="Subject history deleted", detail="病史记录已删除",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
+8 -8
View File
@@ -15,7 +15,7 @@ router = APIRouter()
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id) study = await study_crud.get(db, study_id)
if not study: if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
return study return study
@@ -42,7 +42,7 @@ async def create_subject(
entity_type="subject", entity_type="subject",
entity_id=subject.id, entity_id=subject.id,
action="CREATE_SUBJECT", action="CREATE_SUBJECT",
detail=f"Subject {subject.subject_no} created", detail=f"受试者 {subject.subject_no} 已创建",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
@@ -81,7 +81,7 @@ async def get_subject(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
subject = await subject_crud.get_subject(db, subject_id) subject = await subject_crud.get_subject(db, subject_id)
if not subject or subject.study_id != study_id: if not subject or subject.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Subject not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="受试者不存在")
return subject return subject
@@ -100,7 +100,7 @@ async def update_subject(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
subject = await subject_crud.get_subject(db, subject_id) subject = await subject_crud.get_subject(db, subject_id)
if not subject or subject.study_id != study_id: if not subject or subject.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Subject not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="受试者不存在")
old_status = subject.status old_status = subject.status
updated = await subject_crud.update_subject(db, subject, subject_in) updated = await subject_crud.update_subject(db, subject, subject_in)
# auto-generate visits when enrolled # auto-generate visits when enrolled
@@ -108,14 +108,14 @@ async def update_subject(
await subject_crud.generate_default_visits(db, updated) await subject_crud.generate_default_visits(db, updated)
detail = None detail = None
if subject_in.status and subject_in.status != old_status: if subject_in.status and subject_in.status != old_status:
detail = f"subject {updated.subject_no} status {old_status} -> {subject_in.status}" detail = f"受试者 {updated.subject_no} 状态 {old_status} -> {subject_in.status}"
await audit_crud.log_action( await audit_crud.log_action(
db, db,
study_id=study_id, study_id=study_id,
entity_type="subject", entity_type="subject",
entity_id=subject_id, entity_id=subject_id,
action="SUBJECT_STATUS_CHANGE" if detail else "UPDATE_SUBJECT", action="SUBJECT_STATUS_CHANGE" if detail else "UPDATE_SUBJECT",
detail=detail or "Subject updated", detail=detail or "受试者已更新",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
@@ -136,7 +136,7 @@ async def delete_subject(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
subject = await subject_crud.get_subject(db, subject_id) subject = await subject_crud.get_subject(db, subject_id)
if not subject or subject.study_id != study_id: if not subject or subject.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Subject not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="受试者不存在")
await subject_crud.delete_subject(db, subject) await subject_crud.delete_subject(db, subject)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -144,7 +144,7 @@ async def delete_subject(
entity_type="subject", entity_type="subject",
entity_id=subject_id, entity_id=subject_id,
action="DELETE_SUBJECT", action="DELETE_SUBJECT",
detail=f"Subject {subject_id} deleted", detail=f"受试者 {subject_id} 已删除",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
+3 -3
View File
@@ -36,7 +36,7 @@ async def create_user(
if existing: if existing:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
detail="Email already exists", detail="邮箱已存在",
) )
user = await user_crud.create_user(db, user_in) user = await user_crud.create_user(db, user_in)
return user return user
@@ -51,7 +51,7 @@ async def update_user(
) -> UserRead: ) -> UserRead:
db_user = await user_crud.get_by_id(db, user_id) db_user = await user_crud.get_by_id(db, user_id)
if not db_user: if not db_user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
if db_user.role.value == "ADMIN": if db_user.role.value == "ADMIN":
requested_role = user_in.role requested_role = user_in.role
requested_status = user_in.status requested_status = user_in.status
@@ -79,7 +79,7 @@ async def delete_user(
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="管理员密码错误") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="管理员密码错误")
db_user = await user_crud.get_by_id(db, user_id) db_user = await user_crud.get_by_id(db, user_id)
if not db_user: if not db_user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
if db_user.id == current_user.id: if db_user.id == current_user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不允许删除自己") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不允许删除自己")
if db_user.role.value == "ADMIN" and db_user.status.value == "ACTIVE": if db_user.role.value == "ADMIN" and db_user.status.value == "ACTIVE":
+9 -9
View File
@@ -15,7 +15,7 @@ router = APIRouter()
async def _ensure_subject(db: AsyncSession, study_id: uuid.UUID, subject_id: uuid.UUID): async def _ensure_subject(db: AsyncSession, study_id: uuid.UUID, subject_id: uuid.UUID):
subject = await subject_crud.get_subject(db, subject_id) subject = await subject_crud.get_subject(db, subject_id)
if not subject or subject.study_id != study_id: if not subject or subject.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Subject not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="受试者不存在")
return subject return subject
@@ -49,7 +49,7 @@ async def create_visit(
) -> VisitRead: ) -> VisitRead:
subject = await _ensure_subject(db, study_id, subject_id) subject = await _ensure_subject(db, study_id, subject_id)
if visit_in.subject_id != subject_id: if visit_in.subject_id != subject_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Subject mismatch") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="受试者不匹配")
visit = await visit_crud.create_visit( visit = await visit_crud.create_visit(
db, db,
study_id=study_id, study_id=study_id,
@@ -65,7 +65,7 @@ async def create_visit(
entity_type="visit", entity_type="visit",
entity_id=visit.id, entity_id=visit.id,
action="CREATE_VISIT", action="CREATE_VISIT",
detail=f"Visit {visit.visit_code} created", detail=f"访视 {visit.visit_code} 已创建",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
@@ -88,20 +88,20 @@ async def update_visit(
await _ensure_subject(db, study_id, subject_id) await _ensure_subject(db, study_id, subject_id)
visit = await visit_crud.get_visit(db, visit_id) visit = await visit_crud.get_visit(db, visit_id)
if not visit or visit.subject_id != subject_id or visit.study_id != study_id: if not visit or visit.subject_id != subject_id or visit.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Visit not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="访视不存在")
if visit_in.status == "DONE" and not visit_in.actual_date: if visit_in.status == "DONE" and not visit_in.actual_date:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="actual_date required when DONE") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="状态为已完成时必须填写实际日期")
updated = await visit_crud.update_visit(db, visit, visit_in) updated = await visit_crud.update_visit(db, visit, visit_in)
detail = None detail = None
if visit_in.status: if visit_in.status:
detail = f"visit {visit.visit_code} {visit.status} -> {visit_in.status}" detail = f"访视 {visit.visit_code} {visit.status} -> {visit_in.status}"
await audit_crud.log_action( await audit_crud.log_action(
db, db,
study_id=study_id, study_id=study_id,
entity_type="visit", entity_type="visit",
entity_id=visit_id, entity_id=visit_id,
action="VISIT_STATUS_CHANGE" if detail else "UPDATE_VISIT", action="VISIT_STATUS_CHANGE" if detail else "UPDATE_VISIT",
detail=detail or "Visit updated", detail=detail or "访视已更新",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
@@ -123,7 +123,7 @@ async def delete_visit(
await _ensure_subject(db, study_id, subject_id) await _ensure_subject(db, study_id, subject_id)
visit = await visit_crud.get_visit(db, visit_id) visit = await visit_crud.get_visit(db, visit_id)
if not visit or visit.subject_id != subject_id or visit.study_id != study_id: if not visit or visit.subject_id != subject_id or visit.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Visit not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="访视不存在")
await visit_crud.delete_visit(db, visit) await visit_crud.delete_visit(db, visit)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -131,7 +131,7 @@ async def delete_visit(
entity_type="visit", entity_type="visit",
entity_id=visit_id, entity_id=visit_id,
action="DELETE_VISIT", action="DELETE_VISIT",
detail=f"Visit {visit_id} deleted", detail=f"访视 {visit_id} 已删除",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
+5 -5
View File
@@ -28,7 +28,7 @@ async def get_current_user(
except ValidationError as exc: except ValidationError as exc:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials", detail="无法验证登录凭据",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) from exc ) from exc
@@ -36,7 +36,7 @@ async def get_current_user(
if not user or not user.is_active: if not user or not user.is_active:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Inactive or missing user", detail="账号不存在或已停用",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
return user return user
@@ -50,7 +50,7 @@ def require_roles(roles: Iterable[str]) -> Callable:
if current_role not in roles_set: if current_role not in roles_set:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions", detail="权限不足",
) )
return current_user return current_user
@@ -81,7 +81,7 @@ def require_study_member():
if not membership or not membership.is_active: if not membership or not membership.is_active:
raise AppException( raise AppException(
code="FORBIDDEN", code="FORBIDDEN",
message="Not a member of this study", message="不是该项目成员",
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
) )
return current_user return current_user
@@ -104,7 +104,7 @@ def require_study_roles(roles: Iterable[str]):
if not membership or not membership.is_active or membership.role_in_study not in roles_set: if not membership or not membership.is_active or membership.role_in_study not in roles_set:
raise AppException( raise AppException(
code="FORBIDDEN", code="FORBIDDEN",
message="Insufficient study permissions", message="项目权限不足",
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
) )
return current_user return current_user
+1 -1
View File
@@ -28,7 +28,7 @@ def decode_token(token: str) -> Dict[str, Any]:
except JWTError as exc: except JWTError as exc:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials", detail="无法验证登录凭据",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) from exc ) from exc
return payload return payload
+2 -2
View File
@@ -23,12 +23,12 @@ async def _validate_site_subject(db: AsyncSession, study_id: uuid.UUID, site_id:
result = await db.execute(select(Site).where(Site.id == site_id)) result = await db.execute(select(Site).where(Site.id == site_id))
site = result.scalar_one_or_none() site = result.scalar_one_or_none()
if not site or site.study_id != study_id: if not site or site.study_id != study_id:
raise ValueError("Site not found in study") raise ValueError("分中心不属于当前项目")
if subject_id: if subject_id:
result = await db.execute(select(Subject).where(Subject.id == subject_id)) result = await db.execute(select(Subject).where(Subject.id == subject_id))
subj = result.scalar_one_or_none() subj = result.scalar_one_or_none()
if not subj or subj.study_id != study_id: if not subj or subj.study_id != study_id:
raise ValueError("Subject not found in study") raise ValueError("受试者不属于当前项目")
async def create_ae( async def create_ae(
+2 -2
View File
@@ -13,11 +13,11 @@ async def create_item(db: AsyncSession, item_in: FaqCreate, *, created_by: uuid.
# ensure category exists and matches study scope # ensure category exists and matches study scope
category = await db.get(FaqCategory, item_in.category_id) category = await db.get(FaqCategory, item_in.category_id)
if not category: if not category:
raise ValueError("Category not found") raise ValueError("分类不存在")
if category.study_id != item_in.study_id: if category.study_id != item_in.study_id:
# allow global category if item is global (None) or project if matches # allow global category if item is global (None) or project if matches
if not (category.study_id is None and item_in.study_id is None): if not (category.study_id is None and item_in.study_id is None):
raise ValueError("Category scope mismatch") raise ValueError("分类范围不匹配")
item = FaqItem( item = FaqItem(
study_id=item_in.study_id, study_id=item_in.study_id,
+1 -1
View File
@@ -15,7 +15,7 @@ async def _validate_site(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UU
result = await db.execute(select(Site).where(Site.id == site_id)) result = await db.execute(select(Site).where(Site.id == site_id))
site = result.scalar_one_or_none() site = result.scalar_one_or_none()
if not site or site.study_id != study_id: if not site or site.study_id != study_id:
raise ValueError("Site not found in study") raise ValueError("分中心不属于当前项目")
async def create_subject(db: AsyncSession, study_id: uuid.UUID, subject_in: SubjectCreate) -> Subject: async def create_subject(db: AsyncSession, study_id: uuid.UUID, subject_in: SubjectCreate) -> Subject:
+1 -1
View File
@@ -13,7 +13,7 @@ async def _ensure_subject(db: AsyncSession, study_id: uuid.UUID, subject_id: uui
result = await db.execute(select(Subject).where(Subject.id == subject_id)) result = await db.execute(select(Subject).where(Subject.id == subject_id))
subject = result.scalar_one_or_none() subject = result.scalar_one_or_none()
if not subject or subject.study_id != study_id: if not subject or subject.study_id != study_id:
raise ValueError("Subject not found in study") raise ValueError("受试者不属于当前项目")
async def create_history( async def create_history(
Binary file not shown.

After

Width:  |  Height:  |  Size: 74 B

+3 -2
View File
@@ -5,6 +5,7 @@ import { getToken } from "../utils/auth";
import { useAuthStore } from "../store/auth"; import { useAuthStore } from "../store/auth";
import type { ApiError } from "../types/api"; import type { ApiError } from "../types/api";
import { useStudyStore } from "../store/study"; import { useStudyStore } from "../store/study";
import { TEXT } from "../locales";
let unauthorizedNotified = false; let unauthorizedNotified = false;
@@ -39,7 +40,7 @@ instance.interceptors.response.use(
auth.logout(); auth.logout();
if (!unauthorizedNotified) { if (!unauthorizedNotified) {
unauthorizedNotified = true; unauthorizedNotified = true;
ElMessage.error(data?.message || "登录已过期,请重新登录"); ElMessage.error(data?.message || TEXT.common.messages.sessionExpired);
setTimeout(() => { setTimeout(() => {
unauthorizedNotified = false; unauthorizedNotified = false;
}, 1000); }, 1000);
@@ -61,7 +62,7 @@ instance.interceptors.response.use(
} else if (data?.message) { } else if (data?.message) {
ElMessage.error(data.message); ElMessage.error(data.message);
} else { } else {
ElMessage.error("请求失败,请稍后重试"); ElMessage.error(TEXT.common.messages.requestFailed);
} }
return Promise.reject(error); return Promise.reject(error);
} }
+4 -85
View File
@@ -1,85 +1,4 @@
export const auditDict: Record< import { TEXT } from "../locales";
string,
{ label: string; actionText: string; targetLabel: string } export const auditDict: Record<string, { label: string; actionText: string; targetLabel: string }> =
> = { TEXT.audit.eventDict;
AE_CLOSED: {
label: "关闭 AE(不良事件)",
actionText: "关闭了 AE",
targetLabel: "不良事件",
},
SUBJECT_STATUS_CHANGED: {
label: "受试者状态变更",
actionText: "变更了受试者状态",
targetLabel: "受试者",
},
FINANCE_STATUS_CHANGED: {
label: "费用状态变更",
actionText: "变更了费用状态",
targetLabel: "费用记录",
},
ADMIN_RESET_PASSWORD: {
label: "重置用户密码",
actionText: "重置了用户密码",
targetLabel: "用户账号",
},
ISSUE_STATUS_CHANGED: {
label: "风险/问题状态变更",
actionText: "更新了风险/问题状态",
targetLabel: "风险/问题",
},
UNAUTHORIZED_ACTION_ATTEMPT: {
label: "越权操作尝试",
actionText: "尝试执行无权限操作",
targetLabel: "系统资源",
},
INVALID_STATE_TRANSITION_ATTEMPT: {
label: "非法状态流转尝试",
actionText: "尝试执行非法状态流转",
targetLabel: "业务对象",
},
PROJECT_MEMBER_UPDATED: {
label: "项目成员变更",
actionText: "调整了项目成员",
targetLabel: "项目成员",
},
SITE_CRA_BOUND: {
label: "中心 CRA 绑定",
actionText: "调整了中心与 CRA 的绑定",
targetLabel: "中心",
},
SITE_STATUS_CHANGED: {
label: "中心状态变更",
actionText: "更新了中心信息",
targetLabel: "中心",
},
AUDIT_EXPORT_SYSTEM: {
label: "导出系统审计",
actionText: "导出了系统审计日志",
targetLabel: "审计日志",
},
AUDIT_EXPORT_PROJECT: {
label: "导出项目审计",
actionText: "导出了项目审计日志",
targetLabel: "审计日志",
},
FINANCE_APPROVED: {
label: "费用审批",
actionText: "审批通过费用",
targetLabel: "费用",
},
FINANCE_REJECTED: {
label: "费用驳回",
actionText: "驳回了费用",
targetLabel: "费用",
},
IMP_CONFIRMED: {
label: "药品出入库确认",
actionText: "确认了药品出入库记录",
targetLabel: "药品交易",
},
QUERY_RESOLVED: {
label: "数据问题解决",
actionText: "标记数据问题已解决",
targetLabel: "数据问题",
},
};
+16 -14
View File
@@ -1,21 +1,23 @@
import { TEXT } from "../../locales";
export interface AuditExportColumn { export interface AuditExportColumn {
key: string; key: string;
label: string; label: string;
} }
export const auditExportColumns: AuditExportColumn[] = [ export const auditExportColumns: AuditExportColumn[] = [
{ key: "timestamp", label: "审计时间" }, { key: "timestamp", label: TEXT.audit.exportColumns.timestamp },
{ key: "actorName", label: "操作人姓名" }, { key: "actorName", label: TEXT.audit.exportColumns.actorName },
{ key: "actorId", label: "操作人账号" }, { key: "actorId", label: TEXT.audit.exportColumns.actorId },
{ key: "actorRoleLabel", label: "操作人角色" }, { key: "actorRoleLabel", label: TEXT.audit.exportColumns.actorRoleLabel },
{ key: "eventLabel", label: "操作类型" }, { key: "eventLabel", label: TEXT.audit.exportColumns.eventLabel },
{ key: "targetTypeLabel", label: "操作对象类型" }, { key: "targetTypeLabel", label: TEXT.audit.exportColumns.targetTypeLabel },
{ key: "targetIdentifier", label: "操作对象标识" }, { key: "targetIdentifier", label: TEXT.audit.exportColumns.targetIdentifier },
{ key: "description", label: "操作描述" }, { key: "description", label: TEXT.audit.exportColumns.description },
{ key: "beforeStatus", label: "操作前状态" }, { key: "beforeStatus", label: TEXT.audit.exportColumns.beforeStatus },
{ key: "afterStatus", label: "操作后状态" }, { key: "afterStatus", label: TEXT.audit.exportColumns.afterStatus },
{ key: "resultLabel", label: "操作结果" }, { key: "resultLabel", label: TEXT.audit.exportColumns.resultLabel },
{ key: "reason", label: "拒绝原因" }, { key: "reason", label: TEXT.audit.exportColumns.reason },
{ key: "ip", label: "IP 地址" }, { key: "ip", label: TEXT.audit.exportColumns.ip },
{ key: "remark", label: "备注" }, { key: "remark", label: TEXT.audit.exportColumns.remark },
]; ];
@@ -1,6 +1,7 @@
import { getDictLabel, statusDict } from "../../dictionaries"; import { getDictLabel, statusDict } from "../../dictionaries";
import type { AuditEvent } from ".."; import type { AuditEvent } from "..";
import { auditExportColumns } from "./auditExportColumns"; import { auditExportColumns } from "./auditExportColumns";
import { TEXT } from "../../locales";
export interface AuditExportRow { export interface AuditExportRow {
[key: string]: string; [key: string]: string;
@@ -19,11 +20,12 @@ export const formatAuditRow = (event: AuditEvent): AuditExportRow => {
descriptionParts.push(`${event.actorName} ${event.actionText}`); descriptionParts.push(`${event.actorName} ${event.actionText}`);
if (event.targetTypeLabel || event.targetName || event.targetId) { if (event.targetTypeLabel || event.targetName || event.targetId) {
descriptionParts.push( descriptionParts.push(
`对象:${event.targetTypeLabel || ""}${event.targetName ? `${event.targetName}` : `${event.targetId}`})` `${TEXT.audit.objectLabel}${event.targetTypeLabel || ""}${
event.targetName ? `${event.targetName}` : `${event.targetId}`})`
); );
} }
if (event.diffText?.length) { if (event.diffText?.length) {
descriptionParts.push(`变更:${event.diffText.join("")}`); descriptionParts.push(`${TEXT.audit.changeLabel}${event.diffText.join("")}`);
} }
return { return {
+9 -8
View File
@@ -2,6 +2,7 @@ import { auditDict } from "./auditDict";
import { roleDict, getDictLabel, statusDict } from "../dictionaries"; import { roleDict, getDictLabel, statusDict } from "../dictionaries";
import { useAuthStore } from "../store/auth"; import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study"; import { useStudyStore } from "../store/study";
import { TEXT } from "../locales";
export interface AuditEvent { export interface AuditEvent {
eventType: string; eventType: string;
@@ -32,9 +33,9 @@ const buildDiffText = (before?: Record<string, any>, after?: Record<string, any>
const next = after[key]; const next = after[key];
if (prev === next) return; if (prev === next) return;
if (key.toLowerCase().includes("status")) { if (key.toLowerCase().includes("status")) {
lines.push(`状态:${getDictLabel(statusDict, prev)}${getDictLabel(statusDict, next)}`); lines.push(`${TEXT.audit.statusLabel}${getDictLabel(statusDict, prev)}${getDictLabel(statusDict, next)}`);
} else { } else {
lines.push(`${key}${prev ?? "-"}${next ?? "-"}`); lines.push(`${key}${prev ?? TEXT.audit.emptyValue}${next ?? TEXT.audit.emptyValue}`);
} }
}); });
return lines; return lines;
@@ -42,9 +43,9 @@ const buildDiffText = (before?: Record<string, any>, after?: Record<string, any>
export const normalizeAuditEvent = (raw: any, userMap: Record<string, string>): AuditEvent => { export const normalizeAuditEvent = (raw: any, userMap: Record<string, string>): AuditEvent => {
const dict = auditDict[raw.action] || { const dict = auditDict[raw.action] || {
label: "系统操作", label: TEXT.audit.eventFallback,
actionText: "执行了操作", actionText: TEXT.audit.actionFallback,
targetLabel: raw.entity_type || "对象", targetLabel: raw.entity_type || TEXT.audit.targetFallback,
}; };
let detailObj: any = {}; let detailObj: any = {};
if (raw.detail) { if (raw.detail) {
@@ -74,7 +75,7 @@ export const normalizeAuditEvent = (raw: any, userMap: Record<string, string>):
diffText: Array.isArray(diffText) ? diffText : diffText ? [diffText] : [], diffText: Array.isArray(diffText) ? diffText : diffText ? [diffText] : [],
reason: detailObj.reason, reason: detailObj.reason,
result: detailObj.result || "SUCCESS", result: detailObj.result || "SUCCESS",
resultLabel: detailObj.result === "FAIL" ? "失败" : "成功", resultLabel: detailObj.result === "FAIL" ? TEXT.audit.resultFail : TEXT.audit.resultSuccess,
timestamp: raw.created_at, timestamp: raw.created_at,
}; };
}; };
@@ -105,9 +106,9 @@ export const logAudit = async (eventType: string, payload: Record<string, any>)
const studyId = study.currentStudy?.id || null; const studyId = study.currentStudy?.id || null;
const log = { const log = {
action: eventType, action: eventType,
operator_id: user?.id || user?.username || "current_user", operator_id: user?.id || user?.username || TEXT.audit.currentUser,
operator_role: user?.role || "", operator_role: user?.role || "",
entity_type: payload.targetType || "client", entity_type: payload.targetType || TEXT.audit.localClient,
entity_id: payload.targetId || "", entity_id: payload.targetId || "",
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
detail: JSON.stringify({ detail: JSON.stringify({
+11 -10
View File
@@ -1,22 +1,22 @@
<template> <template>
<el-dialog :model-value="modelValue" :title="isEdit ? '编辑分类' : '新增分类'" width="480px" @close="close"> <el-dialog :model-value="modelValue" :title="isEdit ? TEXT.modules.knowledgeMedicalConsult.editCategory : TEXT.modules.knowledgeMedicalConsult.newCategory" width="480px" @close="close">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px"> <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="名称" prop="name"> <el-form-item :label="TEXT.common.fields.name" prop="name">
<el-input v-model="form.name" /> <el-input v-model="form.name" />
</el-form-item> </el-form-item>
<el-form-item label="描述"> <el-form-item :label="TEXT.common.fields.description">
<el-input v-model="form.description" type="textarea" :rows="2" /> <el-input v-model="form.description" type="textarea" :rows="2" />
</el-form-item> </el-form-item>
<el-form-item label="排序"> <el-form-item :label="TEXT.common.fields.sortOrder">
<el-input-number v-model="form.sort_order" :min="0" :step="1" /> <el-input-number v-model="form.sort_order" :min="0" :step="1" />
</el-form-item> </el-form-item>
<el-form-item label="启用"> <el-form-item :label="TEXT.common.fields.enabled">
<el-switch v-model="form.is_active" /> <el-switch v-model="form.is_active" />
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="close">取消</el-button> <el-button @click="close">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">保存</el-button> <el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
</template> </template>
</el-dialog> </el-dialog>
</template> </template>
@@ -26,6 +26,7 @@ import { computed, reactive, ref, watch } from "vue";
import { ElMessage, FormInstance, FormRules } from "element-plus"; import { ElMessage, FormInstance, FormRules } from "element-plus";
import { createFaqCategory, updateFaqCategory } from "../api/faqs"; import { createFaqCategory, updateFaqCategory } from "../api/faqs";
import { useStudyStore } from "../store/study"; import { useStudyStore } from "../store/study";
import { TEXT, requiredMessage } from "../locales";
const props = defineProps<{ modelValue: boolean; category?: any; isAdmin: boolean }>(); const props = defineProps<{ modelValue: boolean; category?: any; isAdmin: boolean }>();
const emit = defineEmits(["update:modelValue", "success"]); const emit = defineEmits(["update:modelValue", "success"]);
@@ -43,7 +44,7 @@ const form = reactive({
}); });
const rules: FormRules = { const rules: FormRules = {
name: [{ required: true, message: "请输入名称", trigger: "blur" }], name: [{ required: true, message: requiredMessage(TEXT.common.fields.name), trigger: "blur" }],
}; };
const isEdit = computed(() => !!props.category); const isEdit = computed(() => !!props.category);
@@ -100,11 +101,11 @@ const onSubmit = async () => {
} else { } else {
await createFaqCategory(payload); await createFaqCategory(payload);
} }
ElMessage.success("保存成功"); ElMessage.success(TEXT.common.messages.saveSuccess);
emit("success"); emit("success");
close(); close();
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally { } finally {
submitting.value = false; submitting.value = false;
} }
+12 -8
View File
@@ -1,23 +1,23 @@
<template> <template>
<el-card class="panel"> <el-card class="panel">
<div class="header"> <div class="header">
<span>分类</span> <span>{{ TEXT.common.labels.category }}</span>
<div v-if="canMaintain"> <div v-if="canMaintain">
<PermissionAction action="faq.edit"> <PermissionAction action="faq.edit">
<el-button type="primary" size="small" @click="openForm()">新建</el-button> <el-button type="primary" size="small" @click="openForm()">{{ TEXT.modules.knowledgeMedicalConsult.newCategory }}</el-button>
</PermissionAction> </PermissionAction>
</div> </div>
</div> </div>
<el-menu :default-active="activeCategory" @select="onSelect" class="menu"> <el-menu :default-active="activeCategory" @select="onSelect" class="menu">
<el-menu-item index="">全部</el-menu-item> <el-menu-item index="">{{ TEXT.common.labels.all }}</el-menu-item>
<el-menu-item v-for="c in sortedCategories" :key="c.id" :index="c.id"> <el-menu-item v-for="c in sortedCategories" :key="c.id" :index="c.id">
<span class="name" :title="c.name">{{ c.name }}</span> <span class="name" :title="c.name">{{ c.name }}</span>
<div v-if="canEdit(c) || canDelete" class="actions"> <div v-if="canEdit(c) || canDelete" class="actions">
<PermissionAction v-if="canEdit(c)" action="faq.edit"> <PermissionAction v-if="canEdit(c)" action="faq.edit">
<el-button type="text" size="small" @click.stop="openForm(c)">编辑</el-button> <el-button type="text" size="small" @click.stop="openForm(c)">{{ TEXT.common.actions.edit }}</el-button>
</PermissionAction> </PermissionAction>
<PermissionAction v-if="canDelete" action="faq.edit"> <PermissionAction v-if="canDelete" action="faq.edit">
<el-button type="text" size="small" class="danger" @click.stop="onDelete(c)">删除</el-button> <el-button type="text" size="small" class="danger" @click.stop="onDelete(c)">{{ TEXT.common.actions.delete }}</el-button>
</PermissionAction> </PermissionAction>
</div> </div>
</el-menu-item> </el-menu-item>
@@ -36,6 +36,7 @@ import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study"; import { useStudyStore } from "../store/study";
import PermissionAction from "./PermissionAction.vue"; import PermissionAction from "./PermissionAction.vue";
import { usePermission } from "../utils/permission"; import { usePermission } from "../utils/permission";
import { TEXT } from "../locales";
const props = defineProps<{ const props = defineProps<{
categories: FaqCategory[]; categories: FaqCategory[];
@@ -76,14 +77,17 @@ const openForm = (cat?: FaqCategory) => {
}; };
const onDelete = async (cat: FaqCategory) => { const onDelete = async (cat: FaqCategory) => {
const ok = await ElMessageBox.confirm(`确认删除分类「${cat.name}」?`, "提示").catch(() => null); const ok = await ElMessageBox.confirm(
TEXT.modules.knowledgeMedicalConsult.confirmDeleteCategory.replace("{name}", cat.name),
TEXT.common.labels.tips
).catch(() => null);
if (!ok) return; if (!ok) return;
try { try {
await deleteFaqCategory(cat.id); await deleteFaqCategory(cat.id);
ElMessage.success("删除成功"); ElMessage.success(TEXT.common.messages.deleteSuccess);
emit("refresh"); emit("refresh");
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "删除失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
} }
}; };
</script> </script>
+12 -11
View File
@@ -1,21 +1,21 @@
<template> <template>
<el-dialog :model-value="modelValue" :title="isEdit ? '编辑咨询' : '新建咨询'" width="640px" @close="close"> <el-dialog :model-value="modelValue" :title="isEdit ? TEXT.modules.knowledgeMedicalConsult.editItem : TEXT.modules.knowledgeMedicalConsult.newItem" width="640px" @close="close">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px"> <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="分类" prop="category_id"> <el-form-item :label="TEXT.common.labels.category" prop="category_id">
<el-select v-model="form.category_id" placeholder="请选择分类"> <el-select v-model="form.category_id" :placeholder="TEXT.common.placeholders.select">
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" /> <el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="问题" prop="question"> <el-form-item :label="TEXT.common.fields.question" prop="question">
<el-input v-model="form.question" /> <el-input v-model="form.question" />
</el-form-item> </el-form-item>
<el-form-item label="启用"> <el-form-item :label="TEXT.common.fields.enabled">
<el-switch v-model="form.is_active" /> <el-switch v-model="form.is_active" />
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="close">取消</el-button> <el-button @click="close">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">提交</el-button> <el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.submit }}</el-button>
</template> </template>
</el-dialog> </el-dialog>
</template> </template>
@@ -25,6 +25,7 @@ import { computed, reactive, ref, watch } from "vue";
import { ElMessage, FormInstance, FormRules } from "element-plus"; import { ElMessage, FormInstance, FormRules } from "element-plus";
import { createFaqItem, updateFaqItem } from "../api/faqs"; import { createFaqItem, updateFaqItem } from "../api/faqs";
import { useStudyStore } from "../store/study"; import { useStudyStore } from "../store/study";
import { TEXT, requiredMessage } from "../locales";
const props = defineProps<{ modelValue: boolean; item?: any; categories: any[] }>(); const props = defineProps<{ modelValue: boolean; item?: any; categories: any[] }>();
const emit = defineEmits(["update:modelValue", "success"]); const emit = defineEmits(["update:modelValue", "success"]);
@@ -41,8 +42,8 @@ const form = reactive({
}); });
const rules: FormRules = { const rules: FormRules = {
category_id: [{ required: true, message: "请选择分类", trigger: "change" }], category_id: [{ required: true, message: requiredMessage(TEXT.common.labels.category), trigger: "change" }],
question: [{ required: true, message: "请输入问题", trigger: "blur" }], question: [{ required: true, message: requiredMessage(TEXT.common.fields.question), trigger: "blur" }],
}; };
const isEdit = computed(() => !!props.item); const isEdit = computed(() => !!props.item);
@@ -87,11 +88,11 @@ const onSubmit = async () => {
} else { } else {
await createFaqItem(payload); await createFaqItem(payload);
} }
ElMessage.success("保存成功"); ElMessage.success(TEXT.common.messages.saveSuccess);
emit("success"); emit("success");
close(); close();
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally { } finally {
submitting.value = false; submitting.value = false;
} }
+26 -19
View File
@@ -1,34 +1,36 @@
<template> <template>
<el-table :data="items" v-loading="loading" style="width: 100%" @row-click="onRowClick"> <el-table :data="items" v-loading="loading" style="width: 100%" @row-click="onRowClick">
<el-table-column prop="question" label="问题" min-width="200"> <el-table-column prop="question" :label="TEXT.common.fields.question" min-width="200">
<template #default="scope"> <template #default="scope">
<el-link type="primary" :underline="false" class="question-link"> <el-link type="primary" :underline="false" class="question-link">
{{ scope.row.question }} {{ scope.row.question }}
</el-link> </el-link>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="category_id" label="分类" width="140"> <el-table-column prop="category_id" :label="TEXT.common.labels.category" width="140">
<template #default="scope"> <template #default="scope">
{{ categoryMap[scope.row.category_id] || scope.row.category_id }} {{ categoryMap[scope.row.category_id] || scope.row.category_id }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="is_active" label="启用" width="80"> <el-table-column prop="is_active" :label="TEXT.common.fields.enabled" width="80">
<template #default="scope"> <template #default="scope">
<el-tag :type="scope.row.is_active ? 'success' : 'info'">{{ scope.row.is_active ? "是" : "否" }}</el-tag> <el-tag :type="scope.row.is_active ? 'success' : 'info'">
{{ scope.row.is_active ? TEXT.common.boolean.yes : TEXT.common.boolean.no }}
</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="updated_at" label="更新时间" width="180"> <el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" width="180">
<template #default="scope">{{ displayDateTime(scope.row.updated_at) }}</template> <template #default="scope">{{ displayDateTime(scope.row.updated_at) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="status" label="状态" width="120"> <el-table-column prop="status" :label="TEXT.common.fields.status" width="120">
<template #default="scope"> <template #default="scope">
<el-tag :type="statusTagType(scope.row.status)" size="small">{{ statusLabel(scope.row.status) }}</el-tag> <el-tag :type="statusTagType(scope.row.status)" size="small">{{ statusLabel(scope.row.status) }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="180"> <el-table-column :label="TEXT.common.labels.actions" width="180">
<template #default="scope"> <template #default="scope">
<el-button v-if="canDelete(scope.row)" type="text" size="small" class="danger" @click="remove(scope.row)"> <el-button v-if="canDelete(scope.row)" type="text" size="small" class="danger" @click="remove(scope.row)">
删除 {{ TEXT.common.actions.delete }}
</el-button> </el-button>
<PermissionAction v-if="canEdit" action="faq.edit"> <PermissionAction v-if="canEdit" action="faq.edit">
<el-button <el-button
@@ -37,7 +39,7 @@
@click="toggle(scope.row)" @click="toggle(scope.row)"
:style="{ color: scope.row.is_active ? '#f56c6c' : '#67c23a' }" :style="{ color: scope.row.is_active ? '#f56c6c' : '#67c23a' }"
> >
{{ scope.row.is_active ? "停用" : "启用" }} {{ scope.row.is_active ? TEXT.common.actions.disable : TEXT.common.actions.enable }}
</el-button> </el-button>
</PermissionAction> </PermissionAction>
</template> </template>
@@ -52,8 +54,9 @@ import { useRouter } from "vue-router";
import { deleteFaqItem, updateFaqItem } from "../api/faqs"; import { deleteFaqItem, updateFaqItem } from "../api/faqs";
import type { FaqItem, FaqCategory } from "../api/faqs"; import type { FaqItem, FaqCategory } from "../api/faqs";
import PermissionAction from "./PermissionAction.vue"; import PermissionAction from "./PermissionAction.vue";
import { displayDateTime } from "../utils/display"; import { displayDateTime, displayEnum } from "../utils/display";
import { useAuthStore } from "../store/auth"; import { useAuthStore } from "../store/auth";
import { TEXT } from "../locales";
const props = defineProps<{ const props = defineProps<{
items: FaqItem[]; items: FaqItem[];
@@ -83,9 +86,7 @@ const onRowClick = (row: FaqItem, column: any, event: MouseEvent) => {
}; };
const statusLabel = (status?: string) => { const statusLabel = (status?: string) => {
if (status === "RESOLVED") return "已解决"; return displayEnum(TEXT.enums.faqStatus, status || "");
if (status === "PROCESSING") return "处理中";
return "待回答";
}; };
const statusTagType = (status?: string) => { const statusTagType = (status?: string) => {
@@ -99,26 +100,32 @@ const canDelete = (row: FaqItem) => props.canEdit || row.created_by === auth.use
const toggle = async (row: FaqItem) => { const toggle = async (row: FaqItem) => {
const target = !row.is_active; const target = !row.is_active;
const ok = await ElMessageBox.confirm(`确认${target ? "启用" : "停用"}此咨询?`, "提示").catch(() => null); const ok = await ElMessageBox.confirm(
TEXT.modules.knowledgeMedicalConsult.confirmToggleItem.replace("{action}", target ? TEXT.common.actions.enable : TEXT.common.actions.disable),
TEXT.common.labels.tips
).catch(() => null);
if (!ok) return; if (!ok) return;
try { try {
await updateFaqItem(row.id, { is_active: target }); await updateFaqItem(row.id, { is_active: target });
ElMessage.success("操作成功"); ElMessage.success(TEXT.common.messages.updateSuccess);
emit("refresh"); emit("refresh");
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "操作失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.actionFailed);
} }
}; };
const remove = async (row: FaqItem) => { const remove = async (row: FaqItem) => {
const ok = await ElMessageBox.confirm(`确认删除咨询「${row.question}」?`, "提示").catch(() => null); const ok = await ElMessageBox.confirm(
TEXT.modules.knowledgeMedicalConsult.confirmDeleteItem.replace("{name}", row.question),
TEXT.common.labels.tips
).catch(() => null);
if (!ok) return; if (!ok) return;
try { try {
await deleteFaqItem(row.id); await deleteFaqItem(row.id);
ElMessage.success("问题已删除"); ElMessage.success(TEXT.common.messages.deleteSuccess);
emit("refresh"); emit("refresh");
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "删除失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
} }
}; };
</script> </script>
+27 -26
View File
@@ -3,7 +3,7 @@
<el-aside :width="isCollapsed ? '72px' : '240px'" class="layout-aside" :class="{ collapsed: isCollapsed }" v-if="isAdmin || study.currentStudy"> <el-aside :width="isCollapsed ? '72px' : '240px'" class="layout-aside" :class="{ collapsed: isCollapsed }" v-if="isAdmin || study.currentStudy">
<div class="aside-logo"> <div class="aside-logo">
<div class="logo-icon"></div> <div class="logo-icon"></div>
<span class="logo-text">CTMS</span> <span class="logo-text">{{ TEXT.common.appName }}</span>
</div> </div>
<el-menu <el-menu
router router
@@ -14,73 +14,73 @@
> >
<el-menu-item v-if="!isAdmin" index="/workbench"> <el-menu-item v-if="!isAdmin" index="/workbench">
<el-icon><Monitor /></el-icon> <el-icon><Monitor /></el-icon>
<span>我的工作台</span> <span>{{ TEXT.menu.workbench }}</span>
</el-menu-item> </el-menu-item>
<template v-if="isAdmin"> <template v-if="isAdmin">
<div class="menu-divider">管理后台</div> <div class="menu-divider">{{ TEXT.menu.admin }}</div>
<el-menu-item index="/admin/users"> <el-menu-item index="/admin/users">
<el-icon><User /></el-icon> <el-icon><User /></el-icon>
<span>账号管理</span> <span>{{ TEXT.menu.accountManagement }}</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="/admin/projects"> <el-menu-item index="/admin/projects">
<el-icon><Suitcase /></el-icon> <el-icon><Suitcase /></el-icon>
<span>项目管理</span> <span>{{ TEXT.menu.projectManagement }}</span>
</el-menu-item> </el-menu-item>
<el-menu-item v-if="isAdmin" index="/admin/audit-logs"> <el-menu-item v-if="isAdmin" index="/admin/audit-logs">
<el-icon><Document /></el-icon> <el-icon><Document /></el-icon>
<span>审计日志</span> <span>{{ TEXT.menu.auditLogs }}</span>
</el-menu-item> </el-menu-item>
</template> </template>
<template v-if="study.currentStudy"> <template v-if="study.currentStudy">
<div class="menu-divider">当前项目</div> <div class="menu-divider">{{ TEXT.menu.currentProject }}</div>
<el-menu-item index="/project/overview"> <el-menu-item index="/project/overview">
<el-icon><House /></el-icon> <el-icon><House /></el-icon>
<span>项目总览</span> <span>{{ TEXT.menu.projectOverview }}</span>
</el-menu-item> </el-menu-item>
<el-sub-menu index="finance"> <el-sub-menu index="finance">
<template #title> <template #title>
<el-icon><Coin /></el-icon> <el-icon><Coin /></el-icon>
<span>费用管理</span> <span>{{ TEXT.menu.finance }}</span>
</template> </template>
<el-menu-item index="/finance/contracts">合同费用</el-menu-item> <el-menu-item index="/finance/contracts">{{ TEXT.menu.financeContracts }}</el-menu-item>
<el-menu-item index="/finance/special">特殊费用</el-menu-item> <el-menu-item index="/finance/special">{{ TEXT.menu.financeSpecials }}</el-menu-item>
</el-sub-menu> </el-sub-menu>
<el-sub-menu index="drug"> <el-sub-menu index="drug">
<template #title> <template #title>
<el-icon><Box /></el-icon> <el-icon><Box /></el-icon>
<span>药品管理</span> <span>{{ TEXT.menu.drug }}</span>
</template> </template>
<el-menu-item index="/drug/shipments">运输/流向</el-menu-item> <el-menu-item index="/drug/shipments">{{ TEXT.menu.drugShipments }}</el-menu-item>
</el-sub-menu> </el-sub-menu>
<el-menu-item index="/startup/feasibility-ethics"> <el-menu-item index="/startup/feasibility-ethics">
<el-icon><Calendar /></el-icon> <el-icon><Calendar /></el-icon>
<span>立项与伦理</span> <span>{{ TEXT.menu.startupFeasibilityEthics }}</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="/startup/meeting-auth"> <el-menu-item index="/startup/meeting-auth">
<el-icon><CircleCheck /></el-icon> <el-icon><CircleCheck /></el-icon>
<span>启动与授权</span> <span>{{ TEXT.menu.startupMeetingAuth }}</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="/subjects"> <el-menu-item index="/subjects">
<el-icon><User /></el-icon> <el-icon><User /></el-icon>
<span>受试者管理</span> <span>{{ TEXT.menu.subjects }}</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="/monitoring"> <el-menu-item index="/monitoring">
<el-icon><ChatDotRound /></el-icon> <el-icon><ChatDotRound /></el-icon>
<span>监查</span> <span>{{ TEXT.menu.monitoring }}</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="/audit"> <el-menu-item index="/audit">
<el-icon><Flag /></el-icon> <el-icon><Flag /></el-icon>
<span>稽查</span> <span>{{ TEXT.menu.audit }}</span>
</el-menu-item> </el-menu-item>
<el-sub-menu index="knowledge"> <el-sub-menu index="knowledge">
<template #title> <template #title>
<el-icon><Notebook /></el-icon> <el-icon><Notebook /></el-icon>
<span>知识库</span> <span>{{ TEXT.menu.knowledge }}</span>
</template> </template>
<el-menu-item index="/knowledge/medical-consult">医学咨询</el-menu-item> <el-menu-item index="/knowledge/medical-consult">{{ TEXT.menu.knowledgeMedicalConsult }}</el-menu-item>
<el-menu-item index="/knowledge/notes">注意事项</el-menu-item> <el-menu-item index="/knowledge/notes">{{ TEXT.menu.knowledgeNotes }}</el-menu-item>
</el-sub-menu> </el-sub-menu>
</template> </template>
</el-menu> </el-menu>
@@ -89,7 +89,7 @@
<el-container class="main-container"> <el-container class="main-container">
<el-header class="layout-header"> <el-header class="layout-header">
<div class="header-left"> <div class="header-left">
<el-button class="collapse-btn" @click="toggleCollapse" aria-label="收缩侧边栏"> <el-button class="collapse-btn" @click="toggleCollapse" :aria-label="TEXT.common.labels.collapseSidebar">
<el-icon class="collapse-icon"> <el-icon class="collapse-icon">
<svg viewBox="0 0 24 24" aria-hidden="true"> <svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="4" y="5" width="16" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="1.8" /> <rect x="4" y="5" width="16" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="1.8" />
@@ -108,20 +108,20 @@
shape="square" shape="square"
class="user-avatar-comp" class="user-avatar-comp"
> >
{{ (auth.user?.username || auth.user?.email || "U").charAt(0).toUpperCase() }} {{ (auth.user?.username || auth.user?.email || TEXT.common.labels.userInitialFallback).charAt(0).toUpperCase() }}
</el-avatar> </el-avatar>
<span class="username">{{ auth.user?.username || auth.user?.email || "用户" }}</span> <span class="username">{{ auth.user?.username || auth.user?.email || TEXT.common.labels.userFallback }}</span>
<el-icon><ArrowDown /></el-icon> <el-icon><ArrowDown /></el-icon>
</span> </span>
<template #dropdown> <template #dropdown>
<el-dropdown-menu class="user-dropdown-menu"> <el-dropdown-menu class="user-dropdown-menu">
<el-dropdown-item command="profile"> <el-dropdown-item command="profile">
<el-icon><User /></el-icon> <el-icon><User /></el-icon>
<span>个人中心</span> <span>{{ TEXT.menu.profile }}</span>
</el-dropdown-item> </el-dropdown-item>
<el-dropdown-item command="logout"> <el-dropdown-item command="logout">
<el-icon><SwitchButton /></el-icon> <el-icon><SwitchButton /></el-icon>
<span>退出登录</span> <span>{{ TEXT.menu.logout }}</span>
</el-dropdown-item> </el-dropdown-item>
</el-dropdown-menu> </el-dropdown-menu>
</template> </template>
@@ -148,6 +148,7 @@ import { useRoute, useRouter } from "vue-router";
import { useAuthStore } from "../store/auth"; import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study"; import { useStudyStore } from "../store/study";
import StudySelector from "./StudySelector.vue"; import StudySelector from "./StudySelector.vue";
import { TEXT } from "../locales";
import { import {
Monitor, User, Suitcase, House, Calendar, Flag, ChatDotRound, Monitor, User, Suitcase, House, Calendar, Flag, ChatDotRound,
CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton
@@ -11,7 +11,7 @@
<div class="module-list"> <div class="module-list">
<div class="module-list-header"> <div class="module-list-header">
<span class="list-title">{{ listTitle }}</span> <span class="list-title">{{ listTitle }}</span>
<span class="list-note">列表区域预留</span> <span class="list-note">{{ TEXT.common.empty.listPlaceholder }}</span>
</div> </div>
<StateEmpty :title="emptyTitle" :description="emptyDescription" /> <StateEmpty :title="emptyTitle" :description="emptyDescription" />
</div> </div>
@@ -21,6 +21,7 @@
<script setup lang="ts"> <script setup lang="ts">
import StateEmpty from "./StateEmpty.vue"; import StateEmpty from "./StateEmpty.vue";
import { TEXT } from "../locales";
withDefaults( withDefaults(
defineProps<{ defineProps<{
@@ -32,9 +33,9 @@ withDefaults(
}>(), }>(),
{ {
subtitle: "", subtitle: "",
listTitle: "列表", listTitle: TEXT.common.empty.listPlaceholder,
emptyTitle: "功能建设中", emptyTitle: TEXT.common.empty.building,
emptyDescription: "该模块基础骨架已建立,后续将补充完整交互与数据能力。", emptyDescription: TEXT.common.empty.buildingDesc,
} }
); );
</script> </script>
+8 -7
View File
@@ -2,7 +2,7 @@
<div class="quick-actions-section"> <div class="quick-actions-section">
<div class="section-header"> <div class="section-header">
<el-icon class="header-icon"><Pointer /></el-icon> <el-icon class="header-icon"><Pointer /></el-icon>
<span class="header-title">通往业务模块的快捷入口</span> <span class="header-title">{{ TEXT.common.labels.quickActions }}</span>
</div> </div>
<div class="actions-grid"> <div class="actions-grid">
<div <div
@@ -27,16 +27,17 @@ import {
Pointer, ArrowRight, Timer, UserFilled, Pointer, ArrowRight, Timer, UserFilled,
Management, Money, Collection Management, Money, Collection
} from "@element-plus/icons-vue"; } from "@element-plus/icons-vue";
import { TEXT } from "../locales";
const router = useRouter(); const router = useRouter();
const actions = [ const actions = [
{ label: "立项与伦理", path: "/startup/feasibility-ethics", icon: Timer }, { label: TEXT.menu.startupFeasibilityEthics, path: "/startup/feasibility-ethics", icon: Timer },
{ label: "启动与授权", path: "/startup/meeting-auth", icon: Timer }, { label: TEXT.menu.startupMeetingAuth, path: "/startup/meeting-auth", icon: Timer },
{ label: "受试者管理", path: "/subjects", icon: UserFilled }, { label: TEXT.menu.subjects, path: "/subjects", icon: UserFilled },
{ label: "药品流向", path: "/drug/shipments", icon: Management }, { label: TEXT.menu.drugShipments, path: "/drug/shipments", icon: Management },
{ label: "费用管理", path: "/finance/contracts", icon: Money }, { label: TEXT.menu.finance, path: "/finance/contracts", icon: Money },
{ label: "知识库", path: "/knowledge/medical-consult", icon: Collection }, { label: TEXT.menu.knowledge, path: "/knowledge/medical-consult", icon: Collection },
]; ];
const go = (path: string) => router.push(path); const go = (path: string) => router.push(path);
+2 -1
View File
@@ -11,6 +11,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Document } from "@element-plus/icons-vue"; import { Document } from "@element-plus/icons-vue";
import { TEXT } from "../locales";
withDefaults( withDefaults(
defineProps<{ defineProps<{
@@ -18,7 +19,7 @@ withDefaults(
description?: string; description?: string;
}>(), }>(),
{ {
title: "暂无数据", title: TEXT.common.empty.noData,
description: "", description: "",
} }
); );
+3 -2
View File
@@ -11,6 +11,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { CircleCloseFilled } from "@element-plus/icons-vue"; import { CircleCloseFilled } from "@element-plus/icons-vue";
import { TEXT } from "../locales";
withDefaults( withDefaults(
defineProps<{ defineProps<{
@@ -18,8 +19,8 @@ withDefaults(
description?: string; description?: string;
}>(), }>(),
{ {
title: "加载失败", title: TEXT.common.messages.loadFailed,
description: "请稍后重试", description: TEXT.common.messages.requestFailed,
} }
); );
</script> </script>
+6 -5
View File
@@ -2,15 +2,15 @@
<el-dropdown trigger="click" class="study-selector-dropdown" @command="onCommand"> <el-dropdown trigger="click" class="study-selector-dropdown" @command="onCommand">
<div class="selector-trigger"> <div class="selector-trigger">
<el-icon class="trigger-icon"><OfficeBuilding /></el-icon> <el-icon class="trigger-icon"><OfficeBuilding /></el-icon>
<span class="current-study-name">{{ study.currentStudy?.name || "选择项目" }}</span> <span class="current-study-name">{{ study.currentStudy?.name || TEXT.common.empty.selectProject }}</span>
<el-icon class="arrow-icon"><ArrowDown /></el-icon> <el-icon class="arrow-icon"><ArrowDown /></el-icon>
</div> </div>
<template #dropdown> <template #dropdown>
<el-dropdown-menu class="study-dropdown"> <el-dropdown-menu class="study-dropdown">
<el-dropdown-item v-if="loading" disabled> <el-dropdown-item v-if="loading" disabled>
<el-icon><Loading /></el-icon>加载项目中... <el-icon><Loading /></el-icon>{{ TEXT.common.loading }}
</el-dropdown-item> </el-dropdown-item>
<el-dropdown-item v-else-if="!studies.length" disabled>暂无可用项目</el-dropdown-item> <el-dropdown-item v-else-if="!studies.length" disabled>{{ TEXT.common.empty.noData }}</el-dropdown-item>
<el-dropdown-item <el-dropdown-item
v-for="item in studies" v-for="item in studies"
:key="item.id" :key="item.id"
@@ -23,7 +23,7 @@
</div> </div>
</el-dropdown-item> </el-dropdown-item>
<el-dropdown-item divided command="clear" class="danger-item"> <el-dropdown-item divided command="clear" class="danger-item">
<el-icon><Close /></el-icon>退出当前项目 <el-icon><Close /></el-icon>{{ TEXT.common.actions.exitProject }}
</el-dropdown-item> </el-dropdown-item>
</el-dropdown-menu> </el-dropdown-menu>
</template> </template>
@@ -38,6 +38,7 @@ import { useStudyStore } from "../store/study";
import { fetchStudies } from "../api/studies"; import { fetchStudies } from "../api/studies";
import type { Study } from "../types/api"; import type { Study } from "../types/api";
import { OfficeBuilding, ArrowDown, Close, Loading } from "@element-plus/icons-vue"; import { OfficeBuilding, ArrowDown, Close, Loading } from "@element-plus/icons-vue";
import { TEXT } from "../locales";
const router = useRouter(); const router = useRouter();
const study = useStudyStore(); const study = useStudyStore();
@@ -50,7 +51,7 @@ const loadStudies = async () => {
const { data } = await fetchStudies(); const { data } = await fetchStudies();
studies.value = data.items || []; studies.value = data.items || [];
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "项目列表加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally { } finally {
loading.value = false; loading.value = false;
} }
+9 -8
View File
@@ -2,22 +2,22 @@
<div class="thread-composer"> <div class="thread-composer">
<div v-if="quoteItem" class="quote-box"> <div v-if="quoteItem" class="quote-box">
<div class="quote-meta"> <div class="quote-meta">
引用 {{ displayUser(quoteItem.created_by, { users: userMap, members: memberMap }) }} {{ TEXT.common.actions.quote }} {{ displayUser(quoteItem.created_by, { users: userMap, members: memberMap }) }}
· {{ displayDateTime(quoteItem.created_at) }} · {{ displayDateTime(quoteItem.created_at) }}
<el-button type="text" size="small" @click="$emit('clear-quote')">取消引用</el-button> <el-button type="text" size="small" @click="$emit('clear-quote')">{{ TEXT.modules.knowledgeMedicalConsult.clearQuote }}</el-button>
</div> </div>
<div class="quote-content">{{ quoteContent(quoteItem) }}</div> <div class="quote-content">{{ quoteContent(quoteItem) }}</div>
</div> </div>
<el-input v-model="contentProxy" type="textarea" rows="3" placeholder="输入内容" /> <el-input v-model="contentProxy" type="textarea" rows="3" :placeholder="TEXT.common.placeholders.input" />
<div v-if="allowAttachments" class="upload-row"> <div v-if="allowAttachments" class="upload-row">
<div class="upload-label">附件</div> <div class="upload-label">{{ TEXT.common.labels.attachments }}</div>
<el-upload v-model:file-list="fileListProxy" :auto-upload="false" multiple list-type="picture" class="thread-upload"> <el-upload v-model:file-list="fileListProxy" :auto-upload="false" multiple list-type="picture" class="thread-upload">
<el-button size="small" class="upload-button">上传附件</el-button> <el-button size="small" class="upload-button">{{ TEXT.common.actions.upload }}</el-button>
</el-upload> </el-upload>
</div> </div>
<div class="actions"> <div class="actions">
<el-button v-if="showCancel" size="small" @click="$emit('cancel')">取消</el-button> <el-button v-if="showCancel" size="small" @click="$emit('cancel')">{{ TEXT.common.actions.cancel }}</el-button>
<el-button size="small" type="primary" :loading="submitting" @click="$emit('submit')">提交</el-button> <el-button size="small" type="primary" :loading="submitting" @click="$emit('submit')">{{ TEXT.common.actions.submit }}</el-button>
</div> </div>
</div> </div>
</template> </template>
@@ -26,6 +26,7 @@
import { computed } from "vue"; import { computed } from "vue";
import type { UploadUserFile } from "element-plus"; import type { UploadUserFile } from "element-plus";
import { displayDateTime, displayUser } from "../utils/display"; import { displayDateTime, displayUser } from "../utils/display";
import { TEXT } from "../locales";
const props = defineProps<{ const props = defineProps<{
modelValue: string; modelValue: string;
@@ -56,7 +57,7 @@ const fileListProxy = computed({
set: (val: UploadUserFile[]) => emit("update:fileList", val), set: (val: UploadUserFile[]) => emit("update:fileList", val),
}); });
const quoteContent = (item: any) => (item?.is_deleted ? "内容已删除" : item?.content || "—"); const quoteContent = (item: any) => (item?.is_deleted ? TEXT.modules.knowledgeMedicalConsult.quoteDeleted : item?.content || TEXT.common.fallback);
</script> </script>
<style scoped> <style scoped>
+8 -5
View File
@@ -5,9 +5,9 @@
<div class="thread-meta"> <div class="thread-meta">
<strong>{{ displayUser(item.created_by, { users: userMap, members: memberMap }) }}</strong> <strong>{{ displayUser(item.created_by, { users: userMap, members: memberMap }) }}</strong>
<div class="thread-actions"> <div class="thread-actions">
<el-button v-if="canQuote" type="text" size="small" @click="$emit('quote', item)">引用</el-button> <el-button v-if="canQuote" type="text" size="small" @click="$emit('quote', item)">{{ TEXT.common.actions.quote }}</el-button>
<el-button v-if="canDelete?.(item)" type="text" size="small" class="danger" @click="$emit('delete', item)"> <el-button v-if="canDelete?.(item)" type="text" size="small" class="danger" @click="$emit('delete', item)">
删除 {{ TEXT.common.actions.delete }}
</el-button> </el-button>
<slot name="actions" :item="item" /> <slot name="actions" :item="item" />
</div> </div>
@@ -33,7 +33,7 @@
{{ file.filename }} {{ file.filename }}
</div> </div>
<el-link v-else :underline="false" class="file-link" @click="download(file.id)"> <el-link v-else :underline="false" class="file-link" @click="download(file.id)">
<span class="file-badge">文件</span> <span class="file-badge">{{ TEXT.common.labels.file }}</span>
<span class="file-name">{{ file.filename }}</span> <span class="file-name">{{ file.filename }}</span>
</el-link> </el-link>
</div> </div>
@@ -46,6 +46,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { displayDateTime, displayUser } from "../utils/display"; import { displayDateTime, displayUser } from "../utils/display";
import { TEXT } from "../locales";
const props = defineProps<{ const props = defineProps<{
items: any[]; items: any[];
@@ -62,8 +63,10 @@ defineEmits<{
(e: "delete", item: any): void; (e: "delete", item: any): void;
}>(); }>();
const quoteContent = (item: any) => (item?.is_deleted ? "内容已删除" : item?.content || "—"); const quoteContent = (item: any) =>
const contentText = (item: any) => (item?.is_deleted ? "内容已删除" : item?.content || "—"); item?.is_deleted ? TEXT.modules.knowledgeMedicalConsult.quoteDeleted : item?.content || TEXT.common.fallback;
const contentText = (item: any) =>
item?.is_deleted ? TEXT.modules.knowledgeMedicalConsult.quoteDeleted : item?.content || TEXT.common.fallback;
const attachmentUrl = (id: string) => { const attachmentUrl = (id: string) => {
const token = localStorage.getItem("ctms_token"); const token = localStorage.getItem("ctms_token");
@@ -1,7 +1,7 @@
<template> <template>
<el-card> <el-card>
<div class="header"> <div class="header">
<span>附件</span> <span>{{ TEXT.common.labels.attachments }}</span>
<AttachmentUploader <AttachmentUploader
:study-id="studyId" :study-id="studyId"
:entity-type="entityType" :entity-type="entityType"
@@ -10,23 +10,23 @@
/> />
</div> </div>
<el-table :data="attachments" v-loading="loading" style="width: 100%"> <el-table :data="attachments" v-loading="loading" style="width: 100%">
<el-table-column prop="filename" label="文件名" min-width="200" /> <el-table-column prop="filename" :label="TEXT.common.labels.filename" min-width="200" />
<el-table-column label="大小" width="120"> <el-table-column :label="TEXT.common.labels.size" width="120">
<template #default="scope">{{ formatFileSize(scope.row.file_size ?? scope.row.size) }}</template> <template #default="scope">{{ formatFileSize(scope.row.file_size ?? scope.row.size) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="上传人" width="180"> <el-table-column :label="TEXT.common.labels.uploader" width="180">
<template #default="scope"> <template #default="scope">
{{ uploaderLabel(scope.row) }} {{ uploaderLabel(scope.row) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="uploaded_at" label="上传时间" width="180"> <el-table-column prop="uploaded_at" :label="TEXT.common.labels.uploadedAt" width="180">
<template #default="scope"> <template #default="scope">
{{ displayDateTime(scope.row.uploaded_at) }} {{ displayDateTime(scope.row.uploaded_at) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="180"> <el-table-column :label="TEXT.common.labels.actions" width="180">
<template #default="scope"> <template #default="scope">
<el-button link type="primary" size="small" @click="download(scope.row)">下载</el-button> <el-button link type="primary" size="small" @click="download(scope.row)">{{ TEXT.common.actions.download }}</el-button>
<el-button <el-button
v-if="canDelete(scope.row)" v-if="canDelete(scope.row)"
link link
@@ -34,7 +34,7 @@
size="small" size="small"
@click="remove(scope.row)" @click="remove(scope.row)"
> >
删除 {{ TEXT.common.actions.delete }}
</el-button> </el-button>
</template> </template>
</el-table-column> </el-table-column>
@@ -52,6 +52,7 @@ import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { listMembers } from "../../api/members"; import { listMembers } from "../../api/members";
import { displayDateTime, displayUser, getMemberDisplayName, getUserDisplayName } from "../../utils/display"; import { displayDateTime, displayUser, getMemberDisplayName, getUserDisplayName } from "../../utils/display";
import { TEXT } from "../../locales";
const props = defineProps<{ const props = defineProps<{
studyId: string; studyId: string;
@@ -72,7 +73,7 @@ const load = async () => {
const { data } = await fetchAttachments(props.studyId, props.entityType, props.entityId); const { data } = await fetchAttachments(props.studyId, props.entityType, props.entityId);
attachments.value = data.items || data || []; attachments.value = data.items || data || [];
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "附件加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -95,7 +96,7 @@ const uploaderLabel = (row: any) => {
return acc; return acc;
}, {}); }, {});
if (row?.uploaded_by && typeof row.uploaded_by === "object") { if (row?.uploaded_by && typeof row.uploaded_by === "object") {
return getUserDisplayName(row.uploaded_by) || row.uploaded_by.id || "—"; return getUserDisplayName(row.uploaded_by) || row.uploaded_by.id || TEXT.common.fallback;
} }
return displayUser(row.uploaded_by_id || row.uploaded_by, { members: memberMap }); return displayUser(row.uploaded_by_id || row.uploaded_by, { members: memberMap });
}; };
@@ -116,14 +117,14 @@ const canDelete = (row: any) => {
}; };
const remove = async (row: any) => { const remove = async (row: any) => {
const ok = await ElMessageBox.confirm("确认删除该附件?", "提示").catch(() => null); const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return; if (!ok) return;
try { try {
await deleteAttachment(row.id); await deleteAttachment(row.id);
ElMessage.success("已删除"); ElMessage.success(TEXT.common.messages.deleteSuccess);
load(); load();
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "删除失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
} }
}; };
@@ -7,7 +7,7 @@
:disabled="loading" :disabled="loading"
:auto-upload="true" :auto-upload="true"
> >
<el-button type="primary" :loading="loading">上传附件</el-button> <el-button type="primary" :loading="loading">{{ TEXT.common.actions.upload }}</el-button>
</el-upload> </el-upload>
<el-progress v-if="progress > 0 && progress < 100" :percentage="progress" :stroke-width="6" /> <el-progress v-if="progress > 0 && progress < 100" :percentage="progress" :stroke-width="6" />
</div> </div>
@@ -17,6 +17,7 @@
import { ref } from "vue"; import { ref } from "vue";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { uploadAttachment } from "../../api/attachments"; import { uploadAttachment } from "../../api/attachments";
import { TEXT } from "../../locales";
const props = defineProps<{ const props = defineProps<{
studyId: string; studyId: string;
@@ -34,7 +35,7 @@ const doUpload = async (options: any) => {
const file: File = options.file; const file: File = options.file;
if (!file) return; if (!file) return;
if (file.size > maxSize * 1024 * 1024) { if (file.size > maxSize * 1024 * 1024) {
ElMessage.error(`文件大小不能超过 ${maxSize}MB`); ElMessage.error(`${TEXT.common.messages.fileTooLarge} ${maxSize}MB`);
return; return;
} }
progress.value = 0; progress.value = 0;
@@ -47,10 +48,10 @@ const doUpload = async (options: any) => {
} }
}, },
}); });
ElMessage.success("上传成功"); ElMessage.success(TEXT.common.messages.uploadSuccess);
emit("uploaded"); emit("uploaded");
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "上传失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.uploadFailed);
} finally { } finally {
progress.value = 0; progress.value = 0;
loading.value = false; loading.value = false;
@@ -0,0 +1,49 @@
<template>
<el-select
:model-value="modelValue"
:multiple="multiple"
:placeholder="placeholder || TEXT.common.placeholders.select"
:filterable="filterable"
:clearable="clearable"
:loading="loading"
style="width: 100%"
@update:model-value="onUpdate"
>
<el-option v-for="opt in options" :key="String(opt.value)" :label="opt.label" :value="opt.value" />
<slot />
</el-select>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { TEXT } from "../../locales";
type OptionItem = {
label: string;
value: string | number;
};
const props = defineProps<{
modelValue?: string | number | Array<string | number> | null;
options?: OptionItem[];
multiple?: boolean;
placeholder?: string;
filterable?: boolean;
clearable?: boolean;
loading?: boolean;
}>();
const emit = defineEmits<{
(event: "update:modelValue", value: string | number | Array<string | number> | null): void;
}>();
const onUpdate = (value: string | number | Array<string | number> | null) => {
emit("update:modelValue", value);
};
const options = computed(() => props.options ?? []);
const multiple = computed(() => props.multiple ?? false);
const filterable = computed(() => props.filterable ?? true);
const clearable = computed(() => props.clearable ?? true);
const loading = computed(() => props.loading ?? false);
</script>
+6 -5
View File
@@ -1,12 +1,13 @@
import type { Dict } from "./types"; import type { Dict } from "./types";
import { createDict } from "./utils"; import { createDict } from "./utils";
import { TEXT } from "../locales";
const items = [ const items = [
{ value: "ADMIN", label: "管理员", order: 1 }, { value: "ADMIN", label: TEXT.enums.userRole.ADMIN, order: 1 },
{ value: "PM", label: "项目负责人", order: 2 }, { value: "PM", label: TEXT.enums.userRole.PM, order: 2 },
{ value: "CRA", label: "CRA", order: 3 }, { value: "CRA", label: TEXT.enums.userRole.CRA, order: 3 },
{ value: "PV", label: "PV", order: 4 }, { value: "PV", label: TEXT.enums.userRole.PV, order: 4 },
{ value: "IMP", label: "药品管理员", order: 5 }, { value: "IMP", label: TEXT.enums.userRole.IMP, order: 5 },
]; ];
export const roleDict: Dict = createDict(items); export const roleDict: Dict = createDict(items);
+19 -18
View File
@@ -1,25 +1,26 @@
import type { Dict } from "./types"; import type { Dict } from "./types";
import { createDict } from "./utils"; import { createDict } from "./utils";
import { TEXT } from "../locales";
const items = [ const items = [
{ value: "TODO", label: "待处理", color: "info", order: 1 }, { value: "TODO", label: TEXT.enums.generalStatus.TODO, color: "info", order: 1 },
{ value: "DOING", label: "进行中", color: "warning", order: 2 }, { value: "DOING", label: TEXT.enums.generalStatus.DOING, color: "warning", order: 2 },
{ value: "DONE", label: "已完成", color: "success", order: 3 }, { value: "DONE", label: TEXT.enums.generalStatus.DONE, color: "success", order: 3 },
{ value: "BLOCKED", label: "阻塞", color: "danger", order: 4 }, { value: "BLOCKED", label: TEXT.enums.generalStatus.BLOCKED, color: "danger", order: 4 },
{ value: "DRAFT", label: "草稿", color: "info", order: 10 }, { value: "DRAFT", label: TEXT.enums.generalStatus.DRAFT, color: "info", order: 10 },
{ value: "SUBMITTED", label: "已提交", color: "warning", order: 11 }, { value: "SUBMITTED", label: TEXT.enums.generalStatus.SUBMITTED, color: "warning", order: 11 },
{ value: "APPROVED", label: "已审批", color: "success", order: 12 }, { value: "APPROVED", label: TEXT.enums.generalStatus.APPROVED, color: "success", order: 12 },
{ value: "REJECTED", label: "已驳回", color: "danger", order: 13 }, { value: "REJECTED", label: TEXT.enums.generalStatus.REJECTED, color: "danger", order: 13 },
{ value: "PAID", label: "已支付", color: "success", order: 14 }, { value: "PAID", label: TEXT.enums.generalStatus.PAID, color: "success", order: 14 },
{ value: "CLOSED", label: "已关闭", color: "success", order: 20 }, { value: "CLOSED", label: TEXT.enums.generalStatus.CLOSED, color: "success", order: 20 },
{ value: "OPEN", label: "开放", color: "info", order: 19 }, { value: "OPEN", label: TEXT.enums.generalStatus.OPEN, color: "info", order: 19 },
{ value: "NEW", label: "新建", color: "info", order: 17 }, { value: "NEW", label: TEXT.enums.generalStatus.NEW, color: "info", order: 17 },
{ value: "FOLLOW_UP", label: "随访中", color: "warning", order: 18 }, { value: "FOLLOW_UP", label: TEXT.enums.generalStatus.FOLLOW_UP, color: "warning", order: 18 },
{ value: "OVERDUE", label: "已逾期", color: "danger", order: 30 }, { value: "OVERDUE", label: TEXT.enums.generalStatus.OVERDUE, color: "danger", order: 30 },
{ value: "SCREENING", label: "筛选中", color: "info", order: 40 }, { value: "SCREENING", label: TEXT.enums.generalStatus.SCREENING, color: "info", order: 40 },
{ value: "ENROLLED", label: "已入组", color: "success", order: 41 }, { value: "ENROLLED", label: TEXT.enums.generalStatus.ENROLLED, color: "success", order: 41 },
{ value: "COMPLETED", label: "已完成", color: "success", order: 42 }, { value: "COMPLETED", label: TEXT.enums.generalStatus.COMPLETED, color: "success", order: 42 },
{ value: "DROPPED", label: "已脱落", color: "danger", order: 43 }, { value: "DROPPED", label: TEXT.enums.generalStatus.DROPPED, color: "danger", order: 43 },
]; ];
export const statusDict: Dict = createDict(items); export const statusDict: Dict = createDict(items);
+3 -2
View File
@@ -1,5 +1,6 @@
import { usePermission } from "../utils/permission"; import { usePermission } from "../utils/permission";
import { canDoAction, type StateMachine } from "../state-machine"; import { canDoAction, type StateMachine } from "../state-machine";
import { TEXT } from "../locales";
export interface ActionContext { export interface ActionContext {
actorRole?: string | null; actorRole?: string | null;
@@ -27,7 +28,7 @@ export const evaluateAction = (context: ActionContext): ActionDecision => {
return { return {
allowed: false, allowed: false,
severity: "violation", severity: "violation",
reason: "无权限执行该操作", reason: TEXT.common.messages.noPermission,
auditType: "UNAUTHORIZED_ACTION_ATTEMPT", auditType: "UNAUTHORIZED_ACTION_ATTEMPT",
}; };
} }
@@ -36,7 +37,7 @@ export const evaluateAction = (context: ActionContext): ActionDecision => {
return { return {
allowed: false, allowed: false,
severity: "warning", severity: "warning",
reason: "当前状态不允许该操作", reason: TEXT.common.messages.invalidStateAction,
auditType: "INVALID_STATE_TRANSITION_ATTEMPT", auditType: "INVALID_STATE_TRANSITION_ATTEMPT",
}; };
} }
+19
View File
@@ -0,0 +1,19 @@
import { TEXT } from "./zh-CN";
export { TEXT };
export const formatEnum = (
value: string | number | null | undefined,
map: Record<string, string>,
fallback: string = TEXT.common.fallback
) => {
if (value === null || value === undefined || value === "") return fallback;
return map[String(value)] || fallback;
};
export const formatBool = (value: boolean | null | undefined) => {
if (value === null || value === undefined) return TEXT.common.fallback;
return value ? TEXT.common.boolean.yes : TEXT.common.boolean.no;
};
export const requiredMessage = (label: string) => `${label}${TEXT.common.validation.requiredSuffix}`;
+787
View File
@@ -0,0 +1,787 @@
export const TEXT = {
common: {
appName: "CTMS",
fallback: "—",
loading: "加载中",
actions: {
add: "新增",
create: "创建",
edit: "编辑",
save: "保存",
cancel: "取消",
delete: "删除",
remove: "移除",
enable: "启用",
disable: "停用",
upload: "上传",
download: "下载",
back: "返回",
search: "搜索",
reset: "重置",
confirm: "确认",
close: "关闭",
submit: "提交",
view: "查看",
refresh: "刷新",
quote: "引用",
more: "查看更多",
exitProject: "退出当前项目",
login: "登录",
register: "注册",
newHistory: "新增病史",
newVisit: "新增访视",
newAe: "新增 AE",
},
messages: {
loadSuccess: "加载成功",
loadFailed: "加载失败",
saveSuccess: "已保存",
saveFailed: "保存失败",
createSuccess: "已创建",
createFailed: "创建失败",
updateSuccess: "已更新",
updateFailed: "更新失败",
deleteSuccess: "已删除",
deleteFailed: "删除失败",
uploadSuccess: "上传成功",
uploadFailed: "上传失败",
actionFailed: "操作失败",
noPermission: "无权限执行该操作",
networkError: "网络错误,请稍后重试",
fileTooLarge: "文件大小不能超过",
sessionExpired: "登录已过期,请重新登录",
requestFailed: "请求失败,请稍后重试",
invalidStateAction: "当前状态不允许该操作",
},
confirm: {
delete: "确认删除该记录?",
},
placeholders: {
input: "请输入",
select: "请选择",
keyword: "输入关键词",
},
labels: {
attachments: "附件",
size: "大小",
uploader: "上传人",
uploadedAt: "上传时间",
actions: "操作",
filename: "文件名",
role: "角色",
quickActions: "通往业务模块的快捷入口",
updatedAt: "更新时间",
activeOnly: "仅启用",
category: "分类",
all: "全部",
tips: "提示",
file: "文件",
collapseSidebar: "收缩侧边栏",
userInitialFallback: "用",
userFallback: "用户",
},
units: {
case: "例",
},
fields: {
subjectNo: "受试者编号",
site: "分中心",
status: "状态",
keyword: "关键词",
title: "标题",
level: "重要等级",
name: "姓名",
email: "邮箱",
department: "部门",
avatar: "头像",
projectName: "项目名称",
projectCode: "项目编号",
sponsor: "申办方",
protocolNo: "方案号",
phase: "分期",
siteName: "中心名称",
city: "城市",
phone: "电话",
contact: "负责人",
description: "描述",
sortOrder: "排序",
enabled: "启用",
category: "分类",
question: "问题",
screeningDate: "筛选日期",
enrollmentDate: "入组日期",
completionDate: "完成日期",
dropReason: "退出原因",
recordDate: "日期",
content: "内容",
visitCode: "访视编号",
visitName: "访视名称",
plannedDate: "计划日期",
actualDate: "实际日期",
windowStart: "窗口开始",
windowEnd: "窗口结束",
notes: "备注",
event: "事件",
onsetDate: "发生日期",
seriousness: "严重程度",
severity: "严重等级",
causality: "关联性",
outcome: "结局",
submitDate: "递交日期",
acceptDate: "受理日期",
approvedDate: "批准日期",
meetingDate: "会议日期",
approvalNo: "批件号",
projectNo: "项目编号",
kickoffDate: "启动会日期",
attendees: "参训人员",
person: "人员",
trained: "已培训",
authorized: "已授权",
trainedDate: "培训日期",
authorizedDate: "授权日期",
direction: "类型",
trackingNo: "运单号",
shipDate: "日期",
drugDesc: "物品描述",
carrier: "快递公司",
fromParty: "发出方",
toParty: "接收方",
occurDate: "发生日期",
amount: "金额",
type: "类型",
staff: "人员",
contractNo: "合同编号",
signedDate: "签署日期",
currency: "币种",
currencyDefault: "CNY",
remark: "备注",
},
empty: {
noData: "暂无数据",
building: "功能建设中",
buildingDesc: "该模块基础骨架已建立,后续将补充完整交互与数据能力。",
listPlaceholder: "列表区域预留",
selectProject: "请先选择一个项目",
selectSite: "请先选择一个分中心",
},
validation: {
requiredSuffix: "不能为空",
invalidEmail: "邮箱格式不正确",
},
boolean: {
yes: "是",
no: "否",
},
},
audit: {
statusLabel: "状态:",
objectLabel: "对象:",
changeLabel: "变更:",
emptyValue: "-",
resultSuccess: "成功",
resultFail: "失败",
eventFallback: "系统操作",
actionFallback: "执行了操作",
targetFallback: "对象",
currentUser: "当前用户",
localClient: "客户端",
exportColumns: {
timestamp: "审计时间",
actorName: "操作人姓名",
actorId: "操作人账号",
actorRoleLabel: "操作人角色",
eventLabel: "操作类型",
targetTypeLabel: "操作对象类型",
targetIdentifier: "操作对象标识",
description: "操作描述",
beforeStatus: "操作前状态",
afterStatus: "操作后状态",
resultLabel: "操作结果",
reason: "拒绝原因",
ip: "IP 地址",
remark: "备注",
},
eventDict: {
AE_CLOSED: { label: "关闭 AE(不良事件)", actionText: "关闭了 AE", targetLabel: "不良事件" },
SUBJECT_STATUS_CHANGED: { label: "受试者状态变更", actionText: "变更了受试者状态", targetLabel: "受试者" },
FINANCE_STATUS_CHANGED: { label: "费用状态变更", actionText: "变更了费用状态", targetLabel: "费用记录" },
ADMIN_RESET_PASSWORD: { label: "重置用户密码", actionText: "重置了用户密码", targetLabel: "用户账号" },
ISSUE_STATUS_CHANGED: { label: "风险/问题状态变更", actionText: "更新了风险/问题状态", targetLabel: "风险/问题" },
UNAUTHORIZED_ACTION_ATTEMPT: { label: "越权操作尝试", actionText: "尝试执行无权限操作", targetLabel: "系统资源" },
INVALID_STATE_TRANSITION_ATTEMPT: { label: "非法状态流转尝试", actionText: "尝试执行非法状态流转", targetLabel: "业务对象" },
PROJECT_MEMBER_UPDATED: { label: "项目成员变更", actionText: "调整了项目成员", targetLabel: "项目成员" },
SITE_CRA_BOUND: { label: "中心 CRA 绑定", actionText: "调整了中心与 CRA 的绑定", targetLabel: "中心" },
SITE_STATUS_CHANGED: { label: "中心状态变更", actionText: "更新了中心信息", targetLabel: "中心" },
AUDIT_EXPORT_SYSTEM: { label: "导出系统审计", actionText: "导出了系统审计日志", targetLabel: "审计日志" },
AUDIT_EXPORT_PROJECT: { label: "导出项目审计", actionText: "导出了项目审计日志", targetLabel: "审计日志" },
FINANCE_APPROVED: { label: "费用审批", actionText: "审批通过费用", targetLabel: "费用" },
FINANCE_REJECTED: { label: "费用驳回", actionText: "驳回了费用", targetLabel: "费用" },
IMP_CONFIRMED: { label: "药品出入库确认", actionText: "确认了药品出入库记录", targetLabel: "药品交易" },
QUERY_RESOLVED: { label: "数据问题解决", actionText: "标记数据问题已解决", targetLabel: "数据问题" },
},
},
menu: {
workbench: "我的工作台",
admin: "管理后台",
accountManagement: "账号管理",
projectManagement: "项目管理",
auditLogs: "审计日志",
currentProject: "当前项目",
projectOverview: "项目总览",
finance: "费用管理",
financeContracts: "合同费用",
financeSpecials: "特殊费用",
drug: "药品管理",
drugShipments: "运输/流向",
startupFeasibilityEthics: "立项与伦理",
startupMeetingAuth: "启动与授权",
subjects: "受试者管理",
monitoring: "监查",
audit: "稽查",
knowledge: "知识库",
knowledgeMedicalConsult: "医学咨询",
knowledgeNotes: "注意事项",
profile: "个人中心",
logout: "退出登录",
},
modules: {
auth: {
brandSubtitle: "临床试验管理系统",
loginTitle: "欢迎回来",
loginDesc: "请输入您的凭据以访问系统",
emailLabel: "电子邮箱",
passwordLabel: "登录密码",
emailPlaceholder: "name@company.com",
passwordPlaceholder: "最小 8 位字符",
confirmPasswordLabel: "确认密码",
confirmPasswordPlaceholder: "再次输入密码",
fullNameLabel: "姓名",
fullNamePlaceholder: "请输入姓名",
departmentLabel: "部门",
departmentPlaceholder: "请输入部门",
remember: "记住我",
forgot: "忘记密码?",
forgotTitle: "找回密码",
forgotDesc: "请联系系统管理员重置密码",
loginButton: "登录",
registerPrompt: "还没有账号?",
registerNow: "立即注册",
footer: "© 2025 CTMS Enterprise. 专业临床研究团队支持。",
authFailed: "认证失败,请检查您的凭据",
registerTitle: "创建账号",
registerDesc: "提交后需管理员审核通过方可登录",
registerButton: "提交注册",
registerSuccess: "注册成功,等待管理员审核",
backToLogin: "已有账号?返回登录",
passwordRuleMin: "密码至少 8 位",
passwordRuleMix: "需包含字母和数字",
passwordMismatch: "两次输入的密码不一致",
},
projectOverview: {
title: "项目总览",
subtitle: "当前项目运行状态与关键指标",
kpiProgress: "节点完成率",
kpiOverdueAes: "逾期 AE",
kpiOverdueAesHint: "需尽快跟进处理",
kpiFinanceTotal: "费用总额",
kpiFinancePaid: "累计已支付",
alertWarning: "存在逾期未处理事项,请及时关注项目的合规性与进度。",
alertOk: "项目运行状态良好,所有关键指标均在可控范围内。",
noMilestones: "暂无节点",
progressLabel: "总体进度: {rate}",
},
financeContracts: {
title: "合同费用",
subtitle: "维护分中心合同费用与附件",
empty: "暂无合同费用记录",
newTitle: "新增合同费用",
editTitle: "编辑合同费用",
formSubtitle: "填写分中心合同信息并保存",
uploadHint: "保存后可上传合同附件",
detailTitle: "合同费用详情",
detailSubtitle: "查看合同费用与附件",
},
financeSpecials: {
title: "特殊费用",
subtitle: "记录差旅、住宿等特殊费用",
empty: "暂无特殊费用记录",
newTitle: "新增特殊费用",
editTitle: "编辑特殊费用",
uploadHint: "保存后可上传凭证附件",
detailTitle: "特殊费用详情",
detailSubtitle: "查看费用记录与附件",
},
drugShipments: {
title: "药品运输/流向",
subtitle: "登记寄送与回收信息",
recordLabel: "运输记录",
empty: "暂无运输记录",
confirmDelete: "确认删除该运输记录?",
newTitle: "新增运输记录",
editTitle: "编辑运输记录",
formSubtitle: "记录寄送/回收流向",
uploadHint: "保存后可上传交接材料",
detailTitle: "运输记录详情",
detailSubtitle: "查看运输/流向信息与附件",
},
startupFeasibilityEthics: {
title: "立项与伦理",
subtitle: "立项与伦理记录管理",
feasibilityTab: "立项记录",
ethicsTab: "伦理记录",
emptyFeasibility: "暂无立项记录",
emptyEthics: "暂无伦理记录",
feasibilityNewTitle: "新增立项记录",
feasibilityEditTitle: "编辑立项记录",
feasibilityFormSubtitle: "维护立项递交与审批信息",
feasibilityUploadHint: "保存后可上传立项附件",
feasibilityDetailTitle: "立项记录详情",
feasibilityDetailSubtitle: "查看立项记录与附件",
ethicsNewTitle: "新增伦理记录",
ethicsEditTitle: "编辑伦理记录",
ethicsFormSubtitle: "维护伦理递交与审批信息",
ethicsUploadHint: "保存后可上传伦理附件",
ethicsDetailTitle: "伦理记录详情",
ethicsDetailSubtitle: "查看伦理记录与附件",
},
startupMeetingAuth: {
title: "启动与授权",
subtitle: "启动会与培训授权管理",
kickoffTab: "启动会记录",
trainingTab: "培训与授权",
emptyKickoff: "暂无启动会记录",
emptyTraining: "暂无培训授权人员",
kickoffNewTitle: "新增启动会",
kickoffEditTitle: "编辑启动会",
kickoffFormSubtitle: "记录启动会日期与参训人员",
kickoffUploadHint: "保存后可上传会议附件",
kickoffDetailTitle: "启动会详情",
kickoffDetailSubtitle: "查看启动会信息与附件",
attendeesPlaceholder: "每行一个姓名",
trainingNewTitle: "新增人员",
trainingEditTitle: "编辑培训授权",
trainingFormSubtitle: "维护人员培训与授权状态",
trainingUploadHint: "保存后可上传授权附件",
trainingDetailTitle: "培训授权详情",
trainingDetailSubtitle: "查看人员培训与授权信息",
},
subjectManagement: {
title: "受试者管理",
subtitle: "受试者、病史、访视、AE 综合管理",
subjectLabel: "受试者",
empty: "暂无受试者记录",
},
subjectForm: {
titleEdit: "编辑受试者",
titleNew: "新增受试者",
subtitle: "维护受试者基本信息",
},
subjectDetail: {
title: "受试者详情",
subtitle: "查看病史、访视与 AE 信息",
tabs: {
history: "病史",
visits: "访视",
ae: "AE 信息",
},
emptyHistory: "暂无病史记录",
emptyVisits: "暂无访视记录",
emptyAe: "暂无 AE 记录",
dialogHistory: "病史记录",
dialogVisit: "访视记录",
dialogAe: "AE 记录",
},
knowledgeMedicalConsult: {
title: "医学咨询",
subtitle: "常见问题与答复",
detailTitle: "医学咨询详情",
newCategory: "新增分类",
editCategory: "编辑分类",
newItem: "新建咨询",
editItem: "编辑咨询",
confirmDeleteCategory: "确认删除分类「{name}」?",
confirmDeleteItem: "确认删除咨询「{name}」?",
confirmToggleItem: "确认{action}此咨询?",
questionDesc: "问题描述",
editQuestion: "编辑问题",
confirmResolved: "确认解决",
asker: "提问人",
askedAt: "提问时间",
bestAnswer: "最佳答案",
adopted: "已采纳",
replyDeleted: "回复已删除",
replies: "回复",
replyCount: "共 {count} 条",
noReplyPermission: "当前角色无权限回复",
emptyReplies: "暂无回复",
setBest: "设为最佳",
cancelBest: "取消最佳",
confirmBestReply: "设为最佳答案并确认已解决?",
bestReplySet: "已设为最佳答案",
bestReplyCleared: "已取消最佳答案",
resolvedSuccess: "已设置为已解决",
replyRequired: "请输入回复内容",
replySubmitted: "回复已提交",
replyFailed: "回复失败",
replyLoadFailed: "回复加载失败",
clearQuote: "取消引用",
quoteDeleted: "内容已删除",
},
knowledgeNotes: {
title: "注意事项",
subtitle: "分中心特殊要求记录",
empty: "暂无注意事项记录",
confirmDelete: "确认删除该注意事项?",
newTitle: "新增注意事项",
editTitle: "编辑注意事项",
formSubtitle: "记录分中心特殊问题",
uploadHint: "保存后可上传附件",
detailTitle: "注意事项详情",
detailSubtitle: "查看注意事项与附件",
},
monitoring: {
title: "监查",
subtitle: "功能建设中",
listTitle: "监查记录列表",
emptyDescription: "监查功能正在搭建基础能力,敬请期待。",
},
audit: {
title: "稽查",
subtitle: "功能建设中",
listTitle: "稽查记录列表",
emptyDescription: "稽查模块正在准备基础框架,敬请期待。",
},
adminUserApproval: {
title: "注册审核",
subtitle: "仅显示待审核账号,审核通过后即可登录",
createdAt: "注册时间",
approve: "审核通过",
reject: "拒绝",
loadFailed: "加载待审核用户失败",
approveSuccess: "已通过审核",
approveFailed: "审核失败",
rejectSuccess: "已拒绝",
rejectFailed: "拒绝失败",
},
adminUsers: {
title: "账号治理(系统登录账号)",
subtitle: "账号用于登录系统,本身不具备项目或角色权限",
userLabel: "用户",
createdAt: "创建时间",
newTitle: "新建用户",
editTitle: "编辑用户",
resetPassword: "重置密码",
accountStatus: "账号状态",
passwordOptional: "新密码(可选)",
passwordInitial: "初始密码",
passwordOptionalHint: "留空则不修改密码",
passwordInitialHint: "请输入初始密码",
passwordInitialRequired: "请输入初始密码",
keepAdminWarning: "至少保留一个管理员账号,不能禁用该账号",
loadFailed: "用户列表加载失败",
updateSuccess: "用户已更新",
createSuccess: "用户已创建",
deleteConfirm: "删除后账号将无法登录且无法恢复,需先在各项目成员配置中移除该账号。请输入当前管理员密码确认删除。",
deleteTitle: "危险操作:删除账号",
deletePasswordPlaceholder: "请输入 admin 密码",
deleteConfirmButton: "确认删除",
deleteSuccess: "账号已删除",
deleteFailed: "删除失败",
enableConfirm: "确认启用该用户账号?",
disableConfirm: "该操作将影响用户账号,请确认是否继续",
enableTitle: "启用账号",
disableTitle: "禁用账号",
enableSuccess: "已启用",
disableSuccess: "已禁用",
resetTitle: "重置密码",
resetWarningTitle: "该操作将影响用户账号,请确认是否继续",
resetWarningDesc: "系统将生成临时密码,需再次输入用户名才能提交。",
resetTarget: "重置对象:",
confirmUsername: "确认用户名",
confirmUsernameHint: "请输入用户名以确认",
passwordMode: "密码方式",
passwordAuto: "系统生成临时密码",
passwordManual: "手动输入新密码",
tempPassword: "临时密码",
newPassword: "新密码",
regenerate: "重新生成",
manualPasswordHint: "请输入新密码",
passwordHint: "不会明文展示,请通过安全渠道告知用户",
tempPasswordError: "临时密码生成异常",
manualPasswordRequired: "请输入新密码",
confirmMismatch: "确认用户名不匹配,无法提交",
resetConfirmPrompt: "该操作将影响用户账号,请确认是否继续",
resetConfirmTitle: "确认重置密码",
resetConfirm: "确认重置",
resetSuccess: "密码已重置",
resetFailed: "重置失败",
},
adminProjects: {
title: "项目治理",
subtitle: "项目是独立业务实体,账号加入项目后才成为项目成员(角色在成员配置中设置)",
projectLabel: "项目",
members: "项目账号/成员配置",
sites: "中心管理",
enter: "进入项目",
createdAt: "创建时间",
newTitle: "新建项目",
editTitle: "编辑项目",
sponsorPlaceholder: "申办方(可选)",
protocolPlaceholder: "方案号(可选)",
phasePlaceholder: "研究分期(可选)",
loadFailed: "项目列表加载失败",
createSuccess: "项目已创建",
updateSuccess: "项目已更新",
detailTitle: "项目详情",
basicInfo: "项目基本信息",
filesTitle: "项目通用文件",
},
adminProjectMembers: {
title: "项目成员配置(角色仅在当前项目内生效)",
subtitle: "同一账号在不同项目可拥有不同角色,角色授权仅在本页进行",
projectPrefix: "项目:",
memberLabel: "成员",
username: "用户名",
projectRole: "项目角色",
addedAt: "加入时间",
disabledGlobal: "全局已禁用",
disabled: "已禁用",
disabledGlobalHint: "请前往【账号治理】启用该账号",
disabledGlobalDesc: "该账号已在系统级被禁用,无法在任何项目中使用",
newTitle: "新增成员",
user: "用户",
userPlaceholder: "请选择用户",
rolePlaceholder: "请选择角色",
addSuccess: "成员已添加",
addFailed: "添加失败",
loadFailed: "成员列表加载失败",
roleUpdated: "角色已更新",
disableConfirm: "该操作将影响项目成员,请确认是否继续",
disableTitle: "停用成员",
disableSuccess: "成员已停用",
enableSuccess: "成员已启用",
removeConfirm: "确认将该账号从本项目移除?账号本身不会被删除。",
removeTitle: "删除成员",
removeSuccess: "已从项目移除",
},
adminSites: {
title: "中心管理",
projectPrefix: "项目:",
siteLabel: "中心",
piLabel: "PI",
loadFailed: "中心列表加载失败",
newTitle: "新建中心",
editTitle: "编辑中心",
cityPlaceholder: "所在城市(可选)",
piPlaceholder: "PI(可选)",
phonePlaceholder: "电话(可选)",
contactPlaceholder: "选择项目成员作为负责人,可多选",
updateSuccess: "中心已更新",
createSuccess: "中心已创建",
disableConfirm: "该操作将影响中心数据,请确认是否继续",
disableTitle: "停用中心",
disableSuccess: "已停用",
enableSuccess: "已启用",
craBindTitle: "CRA 绑定",
sitePrefix: "中心:",
craSelect: "选择 CRA",
craSelectPlaceholder: "请选择 CRA 账号",
craHint: "支持多选,保存后将以用户名写入中心联系人字段",
craSave: "保存绑定",
craSaveSuccess: "绑定已保存",
},
adminAuditLogs: {
filterEvent: "操作类型",
filterOperator: "操作人",
filterResult: "结果",
rangeStart: "开始",
rangeEnd: "结束",
exportSystem: "导出系统审计",
exportProject: "导出项目审计",
loadFailed: "审计日志加载失败",
exportLoadFailed: "导出数据加载失败",
exportConfirm: "该文件包含敏感审计信息,仅限授权人员使用。确认导出吗?",
exportConfirmTitle: "审计导出确认",
exportEmpty: "无可导出的审计记录",
exportSystemName: "系统审计",
exportProjectName: "项目审计",
columns: {
time: "时间",
actor: "操作人",
event: "操作类型",
action: "操作内容",
target: "操作对象",
diff: "变更详情",
result: "结果",
},
},
workbench: {
title: "我的工作台",
subtitle: "欢迎回来,这是基于您的角色 {role} 聚合的待办事项",
selectProject: "请先在顶部导航选择一个项目,以开启完整的工作台功能。",
todayTitle: "今日待办",
overdueTitle: "已逾期事项",
actionTitle: "需要我处理的事项",
todayEmpty: "今天暂时没有需要紧急处理的业务事项",
overdueEmpty: "太棒了!目前没有任何逾期待处理的事项",
actionEmpty: "暂无需要您处理的事项",
quickEntryTitle: "快捷入口",
enterProject: "进入项目",
quickEmpty: "暂无需要你处理的事项",
aeList: "AE 列表",
contractList: "合同费用",
medicalConsult: "医学咨询",
feasibilityEthics: "立项与伦理",
aeTitleFallback: "不良事件",
contractTitleFallback: "合同费用",
todoTitle: "个人备忘 (本地存储)",
todoCount: "{count} 条记录",
todoPlaceholder: "有什么需要记录的事项吗?(回车添加)",
todoAdd: "添加",
todoEmpty: "暂无待办事项,记录一些备忘吧",
todoStatusFallback: "待处理",
loadFailed: "工作台数据加载失败",
},
permissions: {
subjectEnroll: "仅 PM/CRA 可更新受试者状态",
subjectComplete: "仅 PM/CRA 可更新受试者状态",
subjectDrop: "仅 PM/CRA 可更新受试者状态",
aeClose: "仅 PM/PV/ADMIN 可关闭 AE",
faqEdit: "仅 PM/ADMIN 可维护 FAQ",
faqCreate: "仅项目成员可新建 FAQ",
faqReply: "仅项目成员可回复 FAQ",
projectMembersManage: "仅 ADMIN / PM 可调整项目成员",
siteManage: "仅 ADMIN / PM 可管理中心",
siteCraBind: "仅 ADMIN / PM 可绑定 CRA",
default: "当前角色无权执行该操作",
},
profile: {
title: "个人设置",
subtitle: "更新个人信息或修改登录密码",
uploadAvatar: "上传头像",
currentPassword: "当前密码",
currentPasswordHint: "修改密码时必填",
newPassword: "新密码",
newPasswordHint: "不修改请留空",
confirmPassword: "确认新密码",
confirmPasswordHint: "再次输入新密码",
passwordRequired: "修改密码需输入当前密码",
passwordRule: "至少 8 位且包含字母与数字",
passwordMismatch: "两次输入的密码不一致",
updateSuccess: "已更新",
updateFailed: "更新失败",
avatarUpdated: "头像已更新",
avatarUploadFailed: "头像上传失败",
},
},
enums: {
userRole: {
ADMIN: "管理员",
PM: "项目负责人",
CRA: "CRA",
PV: "PV",
IMP: "药品管理员",
},
userStatus: {
PENDING: "待审核",
ACTIVE: "启用",
REJECTED: "已拒绝",
DISABLED: "已禁用",
},
subjectStatus: {
SCREENING: "筛选中",
ENROLLED: "已入组",
COMPLETED: "已完成",
DROPPED: "已脱落",
},
visitStatus: {
PLANNED: "计划中",
DONE: "已完成",
MISSED: "未完成",
CANCELLED: "已取消",
},
visitName: {
Screening: "筛选访视",
Baseline: "基线访视",
"Follow-up 1": "随访 1",
"Follow-up 2": "随访 2",
},
aeSeriousness: {
SERIOUS: "严重",
NON_SERIOUS: "一般",
},
aeSeverity: {
MILD: "轻度",
MODERATE: "中度",
SEVERE: "重度",
},
aeStatus: {
NEW: "新建",
FOLLOW_UP: "随访中",
CLOSED: "已关闭",
},
shipmentDirection: {
: "寄送",
: "回收",
SEND: "寄送",
RETURN: "回收",
},
shipmentStatus: {
: "待寄出",
: "运输中",
: "已签收",
: "已回收",
: "异常",
PENDING: "待寄出",
IN_TRANSIT: "运输中",
SIGNED: "已签收",
RETURNED: "已回收",
EXCEPTION: "异常",
},
financeSpecialType: {
: "差旅",
宿: "住宿",
: "交通",
: "其他",
TRAVEL: "差旅",
HOTEL: "住宿",
TRANSPORT: "交通",
OTHER: "其他",
},
projectStatus: {
DRAFT: "草稿",
ACTIVE: "进行中",
CLOSED: "已关闭",
},
faqStatus: {
PENDING: "待回答",
PROCESSING: "处理中",
RESOLVED: "已解决",
},
generalStatus: {
TODO: "待处理",
DOING: "进行中",
DONE: "已完成",
BLOCKED: "阻塞",
DRAFT: "草稿",
SUBMITTED: "已提交",
APPROVED: "已审批",
REJECTED: "已驳回",
PAID: "已支付",
CLOSED: "已关闭",
OPEN: "开放",
NEW: "新建",
FOLLOW_UP: "随访中",
OVERDUE: "已逾期",
SCREENING: "筛选中",
ENROLLED: "已入组",
COMPLETED: "已完成",
DROPPED: "已脱落",
},
trainingFlag: {
true: "是",
false: "否",
},
},
} as const;
+61 -52
View File
@@ -4,6 +4,7 @@ import { useStudyStore } from "../store/study";
import Layout from "../components/Layout.vue"; import Layout from "../components/Layout.vue";
import Login from "../views/Login.vue"; import Login from "../views/Login.vue";
import Register from "../views/Register.vue"; import Register from "../views/Register.vue";
import ForgotPassword from "../views/ForgotPassword.vue";
import StudyHome from "../views/StudyHome.vue"; import StudyHome from "../views/StudyHome.vue";
import MyWorkbench from "../views/workbench/MyWorkbench.vue"; import MyWorkbench from "../views/workbench/MyWorkbench.vue";
import FaqDetail from "../views/FaqDetail.vue"; import FaqDetail from "../views/FaqDetail.vue";
@@ -44,19 +45,26 @@ import NoteForm from "../views/knowledge/NoteForm.vue";
import NoteDetail from "../views/knowledge/NoteDetail.vue"; import NoteDetail from "../views/knowledge/NoteDetail.vue";
import SubjectForm from "../views/subjects/SubjectForm.vue"; import SubjectForm from "../views/subjects/SubjectForm.vue";
import SubjectDetail from "../views/subjects/SubjectDetail.vue"; import SubjectDetail from "../views/subjects/SubjectDetail.vue";
import { TEXT } from "../locales";
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
{ {
path: "/login", path: "/login",
name: "Login", name: "Login",
component: Login, component: Login,
meta: { public: true, title: "登录" }, meta: { public: true, title: TEXT.common.actions.login },
}, },
{ {
path: "/register", path: "/register",
name: "Register", name: "Register",
component: Register, component: Register,
meta: { public: true, title: "注册" }, meta: { public: true, title: TEXT.common.actions.register },
},
{
path: "/forgot-password",
name: "ForgotPassword",
component: ForgotPassword,
meta: { public: true, title: TEXT.modules.auth.forgotTitle },
}, },
{ {
path: "/", path: "/",
@@ -67,247 +75,247 @@ const routes: RouteRecordRaw[] = [
path: "workbench", path: "workbench",
name: "MyWorkbench", name: "MyWorkbench",
component: MyWorkbench, component: MyWorkbench,
meta: { title: "我的工作台" }, meta: { title: TEXT.menu.workbench },
}, },
{ {
path: "profile", path: "profile",
name: "ProfileSettings", name: "ProfileSettings",
component: ProfileSettings, component: ProfileSettings,
meta: { title: "个人设置" }, meta: { title: TEXT.menu.profile },
}, },
{ {
path: "project/overview", path: "project/overview",
name: "ProjectOverview", name: "ProjectOverview",
component: ProjectOverview, component: ProjectOverview,
meta: { title: "项目总览", requiresStudy: true }, meta: { title: TEXT.menu.projectOverview, requiresStudy: true },
}, },
{ {
path: "finance/contracts", path: "finance/contracts",
name: "FinanceContracts", name: "FinanceContracts",
component: FinanceContracts, component: FinanceContracts,
meta: { title: "合同费用", requiresStudy: true }, meta: { title: TEXT.menu.financeContracts, requiresStudy: true },
}, },
{ {
path: "finance/contracts/new", path: "finance/contracts/new",
name: "FinanceContractNew", name: "FinanceContractNew",
component: ContractForm, component: ContractForm,
meta: { title: "新增合同费用", requiresStudy: true }, meta: { title: TEXT.common.actions.add + TEXT.menu.financeContracts, requiresStudy: true },
}, },
{ {
path: "finance/contracts/:contractId", path: "finance/contracts/:contractId",
name: "FinanceContractDetail", name: "FinanceContractDetail",
component: ContractDetail, component: ContractDetail,
meta: { title: "合同费用详情", requiresStudy: true }, meta: { title: TEXT.modules.financeContracts.detailTitle, requiresStudy: true },
}, },
{ {
path: "finance/contracts/:contractId/edit", path: "finance/contracts/:contractId/edit",
name: "FinanceContractEdit", name: "FinanceContractEdit",
component: ContractForm, component: ContractForm,
meta: { title: "编辑合同费用", requiresStudy: true }, meta: { title: TEXT.common.actions.edit + TEXT.menu.financeContracts, requiresStudy: true },
}, },
{ {
path: "finance/special", path: "finance/special",
name: "FinanceSpecial", name: "FinanceSpecial",
component: FinanceSpecial, component: FinanceSpecial,
meta: { title: "特殊费用", requiresStudy: true }, meta: { title: TEXT.menu.financeSpecials, requiresStudy: true },
}, },
{ {
path: "finance/special/new", path: "finance/special/new",
name: "FinanceSpecialNew", name: "FinanceSpecialNew",
component: SpecialForm, component: SpecialForm,
meta: { title: "新增特殊费用", requiresStudy: true }, meta: { title: TEXT.common.actions.add + TEXT.menu.financeSpecials, requiresStudy: true },
}, },
{ {
path: "finance/special/:specialId", path: "finance/special/:specialId",
name: "FinanceSpecialDetail", name: "FinanceSpecialDetail",
component: SpecialDetail, component: SpecialDetail,
meta: { title: "特殊费用详情", requiresStudy: true }, meta: { title: TEXT.modules.financeSpecials.detailTitle, requiresStudy: true },
}, },
{ {
path: "finance/special/:specialId/edit", path: "finance/special/:specialId/edit",
name: "FinanceSpecialEdit", name: "FinanceSpecialEdit",
component: SpecialForm, component: SpecialForm,
meta: { title: "编辑特殊费用", requiresStudy: true }, meta: { title: TEXT.common.actions.edit + TEXT.menu.financeSpecials, requiresStudy: true },
}, },
{ {
path: "drug/shipments", path: "drug/shipments",
name: "DrugShipments", name: "DrugShipments",
component: DrugShipments, component: DrugShipments,
meta: { title: "药品流向", requiresStudy: true }, meta: { title: TEXT.menu.drugShipments, requiresStudy: true },
}, },
{ {
path: "drug/shipments/new", path: "drug/shipments/new",
name: "DrugShipmentNew", name: "DrugShipmentNew",
component: ShipmentForm, component: ShipmentForm,
meta: { title: "新增运输记录", requiresStudy: true }, meta: { title: TEXT.common.actions.add + TEXT.menu.drugShipments, requiresStudy: true },
}, },
{ {
path: "drug/shipments/:shipmentId", path: "drug/shipments/:shipmentId",
name: "DrugShipmentDetail", name: "DrugShipmentDetail",
component: ShipmentDetail, component: ShipmentDetail,
meta: { title: "运输记录详情", requiresStudy: true }, meta: { title: TEXT.modules.drugShipments.detailTitle, requiresStudy: true },
}, },
{ {
path: "drug/shipments/:shipmentId/edit", path: "drug/shipments/:shipmentId/edit",
name: "DrugShipmentEdit", name: "DrugShipmentEdit",
component: ShipmentForm, component: ShipmentForm,
meta: { title: "编辑运输记录", requiresStudy: true }, meta: { title: TEXT.common.actions.edit + TEXT.menu.drugShipments, requiresStudy: true },
}, },
{ {
path: "startup/feasibility-ethics", path: "startup/feasibility-ethics",
name: "StartupFeasibilityEthics", name: "StartupFeasibilityEthics",
component: StartupFeasibilityEthics, component: StartupFeasibilityEthics,
meta: { title: "立项与伦理", requiresStudy: true }, meta: { title: TEXT.menu.startupFeasibilityEthics, requiresStudy: true },
}, },
{ {
path: "startup/feasibility/new", path: "startup/feasibility/new",
name: "StartupFeasibilityNew", name: "StartupFeasibilityNew",
component: FeasibilityForm, component: FeasibilityForm,
meta: { title: "新增立项记录", requiresStudy: true }, meta: { title: TEXT.common.actions.add + TEXT.modules.startupFeasibilityEthics.feasibilityTab, requiresStudy: true },
}, },
{ {
path: "startup/feasibility/:recordId", path: "startup/feasibility/:recordId",
name: "StartupFeasibilityDetail", name: "StartupFeasibilityDetail",
component: FeasibilityDetail, component: FeasibilityDetail,
meta: { title: "立项记录详情", requiresStudy: true }, meta: { title: TEXT.modules.startupFeasibilityEthics.feasibilityDetailTitle, requiresStudy: true },
}, },
{ {
path: "startup/feasibility/:recordId/edit", path: "startup/feasibility/:recordId/edit",
name: "StartupFeasibilityEdit", name: "StartupFeasibilityEdit",
component: FeasibilityForm, component: FeasibilityForm,
meta: { title: "编辑立项记录", requiresStudy: true }, meta: { title: TEXT.common.actions.edit + TEXT.modules.startupFeasibilityEthics.feasibilityTab, requiresStudy: true },
}, },
{ {
path: "startup/ethics/new", path: "startup/ethics/new",
name: "StartupEthicsNew", name: "StartupEthicsNew",
component: EthicsForm, component: EthicsForm,
meta: { title: "新增伦理记录", requiresStudy: true }, meta: { title: TEXT.common.actions.add + TEXT.modules.startupFeasibilityEthics.ethicsTab, requiresStudy: true },
}, },
{ {
path: "startup/ethics/:recordId", path: "startup/ethics/:recordId",
name: "StartupEthicsDetail", name: "StartupEthicsDetail",
component: EthicsDetail, component: EthicsDetail,
meta: { title: "伦理记录详情", requiresStudy: true }, meta: { title: TEXT.modules.startupFeasibilityEthics.ethicsDetailTitle, requiresStudy: true },
}, },
{ {
path: "startup/ethics/:recordId/edit", path: "startup/ethics/:recordId/edit",
name: "StartupEthicsEdit", name: "StartupEthicsEdit",
component: EthicsForm, component: EthicsForm,
meta: { title: "编辑伦理记录", requiresStudy: true }, meta: { title: TEXT.common.actions.edit + TEXT.modules.startupFeasibilityEthics.ethicsTab, requiresStudy: true },
}, },
{ {
path: "startup/meeting-auth", path: "startup/meeting-auth",
name: "StartupMeetingAuth", name: "StartupMeetingAuth",
component: StartupMeetingAuth, component: StartupMeetingAuth,
meta: { title: "启动与授权", requiresStudy: true }, meta: { title: TEXT.menu.startupMeetingAuth, requiresStudy: true },
}, },
{ {
path: "startup/kickoff/new", path: "startup/kickoff/new",
name: "StartupKickoffNew", name: "StartupKickoffNew",
component: KickoffForm, component: KickoffForm,
meta: { title: "新增启动会", requiresStudy: true }, meta: { title: TEXT.common.actions.add + TEXT.modules.startupMeetingAuth.kickoffTab, requiresStudy: true },
}, },
{ {
path: "startup/kickoff/:meetingId", path: "startup/kickoff/:meetingId",
name: "StartupKickoffDetail", name: "StartupKickoffDetail",
component: KickoffDetail, component: KickoffDetail,
meta: { title: "启动会详情", requiresStudy: true }, meta: { title: TEXT.modules.startupMeetingAuth.kickoffDetailTitle, requiresStudy: true },
}, },
{ {
path: "startup/kickoff/:meetingId/edit", path: "startup/kickoff/:meetingId/edit",
name: "StartupKickoffEdit", name: "StartupKickoffEdit",
component: KickoffForm, component: KickoffForm,
meta: { title: "编辑启动会", requiresStudy: true }, meta: { title: TEXT.common.actions.edit + TEXT.modules.startupMeetingAuth.kickoffTab, requiresStudy: true },
}, },
{ {
path: "startup/training/new", path: "startup/training/new",
name: "StartupTrainingNew", name: "StartupTrainingNew",
component: TrainingForm, component: TrainingForm,
meta: { title: "新增人员", requiresStudy: true }, meta: { title: TEXT.common.actions.add + TEXT.common.fields.person, requiresStudy: true },
}, },
{ {
path: "startup/training/:recordId", path: "startup/training/:recordId",
name: "StartupTrainingDetail", name: "StartupTrainingDetail",
component: TrainingDetail, component: TrainingDetail,
meta: { title: "培训授权详情", requiresStudy: true }, meta: { title: TEXT.modules.startupMeetingAuth.trainingDetailTitle, requiresStudy: true },
}, },
{ {
path: "startup/training/:recordId/edit", path: "startup/training/:recordId/edit",
name: "StartupTrainingEdit", name: "StartupTrainingEdit",
component: TrainingForm, component: TrainingForm,
meta: { title: "编辑培训授权", requiresStudy: true }, meta: { title: TEXT.common.actions.edit + TEXT.modules.startupMeetingAuth.trainingTab, requiresStudy: true },
}, },
{ {
path: "subjects", path: "subjects",
name: "SubjectManagement", name: "SubjectManagement",
component: SubjectManagement, component: SubjectManagement,
meta: { title: "受试者管理", requiresStudy: true }, meta: { title: TEXT.menu.subjects, requiresStudy: true },
}, },
{ {
path: "subjects/new", path: "subjects/new",
name: "SubjectNew", name: "SubjectNew",
component: SubjectForm, component: SubjectForm,
meta: { title: "新增受试者", requiresStudy: true }, meta: { title: TEXT.common.actions.add + TEXT.menu.subjects, requiresStudy: true },
}, },
{ {
path: "subjects/:subjectId", path: "subjects/:subjectId",
name: "SubjectDetail", name: "SubjectDetail",
component: SubjectDetail, component: SubjectDetail,
meta: { title: "受试者详情", requiresStudy: true }, meta: { title: TEXT.modules.subjectDetail.title, requiresStudy: true },
}, },
{ {
path: "subjects/:subjectId/edit", path: "subjects/:subjectId/edit",
name: "SubjectEdit", name: "SubjectEdit",
component: SubjectForm, component: SubjectForm,
meta: { title: "编辑受试者", requiresStudy: true }, meta: { title: TEXT.common.actions.edit + TEXT.menu.subjects, requiresStudy: true },
}, },
{ {
path: "monitoring", path: "monitoring",
name: "MonitoringPlaceholder", name: "MonitoringPlaceholder",
component: MonitoringPlaceholder, component: MonitoringPlaceholder,
meta: { title: "监查", requiresStudy: true }, meta: { title: TEXT.menu.monitoring, requiresStudy: true },
}, },
{ {
path: "audit", path: "audit",
name: "AuditPlaceholder", name: "AuditPlaceholder",
component: AuditPlaceholder, component: AuditPlaceholder,
meta: { title: "稽查", requiresStudy: true }, meta: { title: TEXT.menu.audit, requiresStudy: true },
}, },
{ {
path: "knowledge/medical-consult", path: "knowledge/medical-consult",
name: "KnowledgeMedicalConsult", name: "KnowledgeMedicalConsult",
component: KnowledgeMedicalConsult, component: KnowledgeMedicalConsult,
meta: { title: "医学咨询", requiresStudy: true }, meta: { title: TEXT.menu.knowledgeMedicalConsult, requiresStudy: true },
}, },
{ {
path: "knowledge/medical-consult/:itemId", path: "knowledge/medical-consult/:itemId",
name: "KnowledgeMedicalConsultDetail", name: "KnowledgeMedicalConsultDetail",
component: FaqDetail, component: FaqDetail,
meta: { title: "医学咨询详情", requiresStudy: true }, meta: { title: TEXT.modules.knowledgeMedicalConsult.detailTitle, requiresStudy: true },
}, },
{ {
path: "knowledge/notes", path: "knowledge/notes",
name: "KnowledgeNotes", name: "KnowledgeNotes",
component: KnowledgeNotes, component: KnowledgeNotes,
meta: { title: "注意事项", requiresStudy: true }, meta: { title: TEXT.menu.knowledgeNotes, requiresStudy: true },
}, },
{ {
path: "knowledge/notes/new", path: "knowledge/notes/new",
name: "KnowledgeNoteNew", name: "KnowledgeNoteNew",
component: NoteForm, component: NoteForm,
meta: { title: "新增注意事项", requiresStudy: true }, meta: { title: TEXT.common.actions.add + TEXT.menu.knowledgeNotes, requiresStudy: true },
}, },
{ {
path: "knowledge/notes/:noteId", path: "knowledge/notes/:noteId",
name: "KnowledgeNoteDetail", name: "KnowledgeNoteDetail",
component: NoteDetail, component: NoteDetail,
meta: { title: "注意事项详情", requiresStudy: true }, meta: { title: TEXT.modules.knowledgeNotes.detailTitle, requiresStudy: true },
}, },
{ {
path: "knowledge/notes/:noteId/edit", path: "knowledge/notes/:noteId/edit",
name: "KnowledgeNoteEdit", name: "KnowledgeNoteEdit",
component: NoteForm, component: NoteForm,
meta: { title: "编辑注意事项", requiresStudy: true }, meta: { title: TEXT.common.actions.edit + TEXT.menu.knowledgeNotes, requiresStudy: true },
}, },
], ],
}, },
@@ -320,37 +328,37 @@ const routes: RouteRecordRaw[] = [
path: "users", path: "users",
name: "AdminUsers", name: "AdminUsers",
component: AdminUsers, component: AdminUsers,
meta: { title: "账号管理", requiresAdmin: true }, meta: { title: TEXT.menu.accountManagement, requiresAdmin: true },
}, },
{ {
path: "user-approval", path: "user-approval",
name: "AdminUserApproval", name: "AdminUserApproval",
component: AdminUserApproval, component: AdminUserApproval,
meta: { title: "注册审核", requiresAdmin: true }, meta: { title: TEXT.modules.adminUserApproval.title, requiresAdmin: true },
}, },
{ {
path: "projects", path: "projects",
name: "AdminProjects", name: "AdminProjects",
component: AdminProjects, component: AdminProjects,
meta: { title: "项目管理", requiresAdmin: true }, meta: { title: TEXT.menu.projectManagement, requiresAdmin: true },
}, },
{ {
path: "projects/:projectId/members", path: "projects/:projectId/members",
name: "AdminProjectMembers", name: "AdminProjectMembers",
component: ProjectMembers, component: ProjectMembers,
meta: { title: "项目成员", requiresAdmin: true }, meta: { title: TEXT.modules.adminProjectMembers.memberLabel, requiresAdmin: true },
}, },
{ {
path: "projects/:projectId/sites", path: "projects/:projectId/sites",
name: "AdminSites", name: "AdminSites",
component: AdminSites, component: AdminSites,
meta: { title: "中心管理", requiresAdmin: true }, meta: { title: TEXT.modules.adminSites.title, requiresAdmin: true },
}, },
{ {
path: "audit-logs", path: "audit-logs",
name: "AdminAuditLogs", name: "AdminAuditLogs",
component: AuditLogs, component: AuditLogs,
meta: { title: "审计日志", requiresAdmin: true }, meta: { title: TEXT.menu.auditLogs, requiresAdmin: true },
}, },
], ],
}, },
@@ -358,7 +366,7 @@ const routes: RouteRecordRaw[] = [
path: "/projects/:projectId", path: "/projects/:projectId",
name: "ProjectDetail", name: "ProjectDetail",
component: ProjectDetail, component: ProjectDetail,
meta: { title: "项目详情", requiresAdmin: true }, meta: { title: TEXT.modules.adminProjects.detailTitle, requiresAdmin: true },
}, },
]; ];
@@ -370,8 +378,8 @@ const router = createRouter({
router.beforeEach(async (to, _from, next) => { router.beforeEach(async (to, _from, next) => {
const auth = useAuthStore(); const auth = useAuthStore();
const studyStore = useStudyStore(); const studyStore = useStudyStore();
const token = auth.token; const getToken = () => auth.token;
if (!auth.user && token) { if (!auth.user && getToken()) {
try { try {
await auth.fetchMe(); await auth.fetchMe();
} catch { } catch {
@@ -379,6 +387,7 @@ router.beforeEach(async (to, _from, next) => {
auth.logout(); auth.logout();
} }
} }
const token = getToken();
if (token && !studyStore.currentStudy) { if (token && !studyStore.currentStudy) {
await studyStore.ensureDefaultStudy(); await studyStore.ensureDefaultStudy();
} }
+6
View File
@@ -9,6 +9,12 @@ export const displayEnum = (enumMap: Record<string, string>, value?: string | nu
return enumMap[value] || displayFallback; return enumMap[value] || displayFallback;
}; };
export const displayText = (value?: string | null, map?: Record<string, string>) => {
if (!value) return displayFallback;
if (map && map[value]) return map[value];
return value;
};
export const getUserDisplayName = (user?: any) => { export const getUserDisplayName = (user?: any) => {
if (!user) return null; if (!user) return null;
return user.display_name || user.full_name || user.username || user.email || null; return user.display_name || user.full_name || user.username || user.email || null;
+12 -11
View File
@@ -1,6 +1,7 @@
import { computed } from "vue"; import { computed } from "vue";
import { useAuthStore } from "../store/auth"; import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study"; import { useStudyStore } from "../store/study";
import { TEXT } from "../locales";
const PERMISSIONS: Record<string, string[]> = { const PERMISSIONS: Record<string, string[]> = {
"subject.create": ["ADMIN", "PM", "CRA"], "subject.create": ["ADMIN", "PM", "CRA"],
@@ -18,16 +19,16 @@ const PERMISSIONS: Record<string, string[]> = {
}; };
const REASONS: Record<string, string> = { const REASONS: Record<string, string> = {
"subject.enroll": "仅 PM/CRA 可更新受试者状态", "subject.enroll": TEXT.modules.permissions.subjectEnroll,
"subject.complete": "仅 PM/CRA 可更新受试者状态", "subject.complete": TEXT.modules.permissions.subjectComplete,
"subject.drop": "仅 PM/CRA 可更新受试者状态", "subject.drop": TEXT.modules.permissions.subjectDrop,
"ae.close": "仅 PM/PV/ADMIN 可关闭 AE", "ae.close": TEXT.modules.permissions.aeClose,
"faq.edit": "仅 PM/ADMIN 可维护 FAQ", "faq.edit": TEXT.modules.permissions.faqEdit,
"faq.create": "仅项目成员可新建 FAQ", "faq.create": TEXT.modules.permissions.faqCreate,
"faq.reply": "仅项目成员可回复 FAQ", "faq.reply": TEXT.modules.permissions.faqReply,
"project.members.manage": "仅 ADMIN / PM 可调整项目成员", "project.members.manage": TEXT.modules.permissions.projectMembersManage,
"site.manage": "仅 ADMIN / PM 可管理中心", "site.manage": TEXT.modules.permissions.siteManage,
"site.cra.bind": "仅 ADMIN / PM 可绑定 CRA", "site.cra.bind": TEXT.modules.permissions.siteCraBind,
}; };
export const usePermission = () => { export const usePermission = () => {
@@ -49,7 +50,7 @@ export const usePermission = () => {
const reason = (action: string): string => { const reason = (action: string): string => {
if (userRole.value === "ADMIN") return ""; if (userRole.value === "ADMIN") return "";
return REASONS[action] || "当前角色无权执行该操作"; return REASONS[action] || TEXT.modules.permissions.default;
}; };
return { return {
+6 -5
View File
@@ -13,15 +13,15 @@
<div class="filters"> <div class="filters">
<el-input <el-input
v-model="keyword" v-model="keyword"
placeholder="搜索关键词" :placeholder="TEXT.common.placeholders.keyword"
clearable clearable
@keyup.enter="loadFaqs" @keyup.enter="loadFaqs"
style="width: 260px" style="width: 260px"
/> />
<el-switch v-model="onlyActive" active-text="仅启用" @change="loadFaqs" /> <el-switch v-model="onlyActive" :active-text="TEXT.common.labels.activeOnly" @change="loadFaqs" />
<div class="spacer" /> <div class="spacer" />
<PermissionAction action="faq.create"> <PermissionAction action="faq.create">
<el-button type="primary" @click="openForm()">新建咨询</el-button> <el-button type="primary" @click="openForm()">{{ TEXT.modules.knowledgeMedicalConsult.newItem }}</el-button>
</PermissionAction> </PermissionAction>
</div> </div>
</el-card> </el-card>
@@ -59,6 +59,7 @@ import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study"; import { useStudyStore } from "../store/study";
import PermissionAction from "../components/PermissionAction.vue"; import PermissionAction from "../components/PermissionAction.vue";
import { usePermission } from "../utils/permission"; import { usePermission } from "../utils/permission";
import { TEXT } from "../locales";
const auth = useAuthStore(); const auth = useAuthStore();
const study = useStudyStore(); const study = useStudyStore();
@@ -86,7 +87,7 @@ const loadCategories = async () => {
const { data } = await fetchFaqCategories(params); const { data } = await fetchFaqCategories(params);
categories.value = data.items || data || []; categories.value = data.items || data || [];
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "分类加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} }
}; };
@@ -111,7 +112,7 @@ const loadFaqs = async () => {
total.value = data.total || 0; total.value = data.total || 0;
} }
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "咨询加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally { } finally {
loading.value = false; loading.value = false;
} }
+32 -33
View File
@@ -3,44 +3,44 @@
<el-card v-loading="loading"> <el-card v-loading="loading">
<div class="content"> <div class="content">
<div class="question-header"> <div class="question-header">
<div class="question-label">问题描述</div> <div class="question-label">{{ TEXT.modules.knowledgeMedicalConsult.questionDesc }}</div>
<div class="question-actions"> <div class="question-actions">
<el-button v-if="canEditQuestion" type="primary" size="small" class="edit-btn" @click="openEdit"> <el-button v-if="canEditQuestion" type="primary" size="small" class="edit-btn" @click="openEdit">
编辑问题 {{ TEXT.modules.knowledgeMedicalConsult.editQuestion }}
</el-button> </el-button>
<el-button v-if="canConfirmResolved" type="success" size="small" @click="confirmResolved"> <el-button v-if="canConfirmResolved" type="success" size="small" @click="confirmResolved">
确认解决 {{ TEXT.modules.knowledgeMedicalConsult.confirmResolved }}
</el-button> </el-button>
</div> </div>
</div> </div>
<div class="question-meta"> <div class="question-meta">
<span class="meta-pill meta-user" <span class="meta-pill meta-user"
>提问人{{ displayUser(item?.created_by, { members: memberMap, users: userMap }) }}</span >{{ TEXT.modules.knowledgeMedicalConsult.asker }}{{ displayUser(item?.created_by, { members: memberMap, users: userMap }) }}</span
> >
<span class="meta-pill meta-time">提问时间{{ displayDateTime(item?.created_at) }}</span> <span class="meta-pill meta-time">{{ TEXT.modules.knowledgeMedicalConsult.askedAt }}{{ displayDateTime(item?.created_at) }}</span>
<span class="meta-pill meta-category">分类{{ categoryName }}</span> <span class="meta-pill meta-category">{{ TEXT.common.labels.category }}{{ categoryName }}</span>
<span class="meta-pill meta-status">状态{{ statusLabel }}</span> <span class="meta-pill meta-status">{{ TEXT.common.fields.status }}{{ statusLabel }}</span>
</div> </div>
<pre class="question-text">{{ item?.question }}</pre> <pre class="question-text">{{ item?.question }}</pre>
</div> </div>
<div v-if="bestReply" class="best-answer"> <div v-if="bestReply" class="best-answer">
<div class="best-title"> <div class="best-title">
<span>最佳答案</span> <span>{{ TEXT.modules.knowledgeMedicalConsult.bestAnswer }}</span>
<el-tag type="success" size="small">已采纳</el-tag> <el-tag type="success" size="small">{{ TEXT.modules.knowledgeMedicalConsult.adopted }}</el-tag>
</div> </div>
<div class="best-meta"> <div class="best-meta">
{{ displayUser(bestReply.created_by, { members: memberMap, users: userMap }) }} {{ displayUser(bestReply.created_by, { members: memberMap, users: userMap }) }}
· {{ displayDateTime(bestReply.created_at) }} · {{ displayDateTime(bestReply.created_at) }}
</div> </div>
<div class="best-content">{{ bestReply.is_deleted ? "回复已删除" : bestReply.content }}</div> <div class="best-content">{{ bestReply.is_deleted ? TEXT.modules.knowledgeMedicalConsult.replyDeleted : bestReply.content }}</div>
</div> </div>
</el-card> </el-card>
<el-card class="mt-12" v-loading="repliesLoading"> <el-card class="mt-12" v-loading="repliesLoading">
<template #header> <template #header>
<div class="reply-header"> <div class="reply-header">
<span>回复</span> <span>{{ TEXT.modules.knowledgeMedicalConsult.replies }}</span>
<span class="reply-count"> {{ replies.length }} </span> <span class="reply-count">{{ TEXT.modules.knowledgeMedicalConsult.replyCount.replace("{count}", String(replies.length)) }}</span>
</div> </div>
</template> </template>
<div v-if="canReply" class="reply-form"> <div v-if="canReply" class="reply-form">
@@ -56,7 +56,7 @@
@clear-quote="clearQuote" @clear-quote="clearQuote"
/> />
</div> </div>
<div v-else class="reply-disabled">当前角色无权限回复</div> <div v-else class="reply-disabled">{{ TEXT.modules.knowledgeMedicalConsult.noReplyPermission }}</div>
<el-divider v-if="replies.length" /> <el-divider v-if="replies.length" />
<ThreadList <ThreadList
v-if="replies.length" v-if="replies.length"
@@ -71,13 +71,13 @@
@delete="removeReply" @delete="removeReply"
> >
<template #actions="{ item: r }"> <template #actions="{ item: r }">
<el-tag v-if="item?.best_reply_id === r.id" type="success" size="small">最佳答案</el-tag> <el-tag v-if="item?.best_reply_id === r.id" type="success" size="small">{{ TEXT.modules.knowledgeMedicalConsult.bestAnswer }}</el-tag>
<el-button v-if="canSelectBest && !r.is_deleted" type="text" size="small" @click="toggleBest(r)"> <el-button v-if="canSelectBest && !r.is_deleted" type="text" size="small" @click="toggleBest(r)">
{{ item?.best_reply_id === r.id ? "取消最佳" : "设为最佳" }} {{ item?.best_reply_id === r.id ? TEXT.modules.knowledgeMedicalConsult.cancelBest : TEXT.modules.knowledgeMedicalConsult.setBest }}
</el-button> </el-button>
</template> </template>
</ThreadList> </ThreadList>
<div v-else class="reply-empty">暂无回复</div> <div v-else class="reply-empty">{{ TEXT.modules.knowledgeMedicalConsult.emptyReplies }}</div>
</el-card> </el-card>
<FaqItemForm <FaqItemForm
@@ -111,6 +111,7 @@ import { usePermission } from "../utils/permission";
import FaqItemForm from "../components/FaqItemForm.vue"; import FaqItemForm from "../components/FaqItemForm.vue";
import ThreadComposer from "../components/ThreadComposer.vue"; import ThreadComposer from "../components/ThreadComposer.vue";
import ThreadList from "../components/ThreadList.vue"; import ThreadList from "../components/ThreadList.vue";
import { TEXT } from "../locales";
const route = useRoute(); const route = useRoute();
const auth = useAuthStore(); const auth = useAuthStore();
@@ -163,9 +164,7 @@ const canConfirmResolved = computed(() => {
}); });
const statusLabel = computed(() => { const statusLabel = computed(() => {
if (item.value?.status === "RESOLVED") return "已解决"; return TEXT.enums.faqStatus[item.value?.status as keyof typeof TEXT.enums.faqStatus] || TEXT.enums.faqStatus.PENDING;
if (item.value?.status === "PROCESSING") return "处理中";
return "待回答";
}); });
const bestReply = computed(() => { const bestReply = computed(() => {
@@ -227,7 +226,7 @@ const loadReplies = async () => {
replies.value = Array.isArray(data) ? data : data.items || []; replies.value = Array.isArray(data) ? data : data.items || [];
await loadReplyAttachments(); await loadReplyAttachments();
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "回复加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.modules.knowledgeMedicalConsult.replyLoadFailed);
} finally { } finally {
repliesLoading.value = false; repliesLoading.value = false;
} }
@@ -247,7 +246,7 @@ const loadData = async () => {
categories.value = catData.items || catData || []; categories.value = catData.items || catData || [];
await Promise.all([loadReplies(), loadMembers(faqData.study_id)]); await Promise.all([loadReplies(), loadMembers(faqData.study_id)]);
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -263,7 +262,7 @@ const clearQuote = () => {
const submitReply = async () => { const submitReply = async () => {
if (!replyContent.value.trim()) { if (!replyContent.value.trim()) {
ElMessage.warning("请输入回复内容"); ElMessage.warning(TEXT.modules.knowledgeMedicalConsult.replyRequired);
return; return;
} }
if (!item.value) return; if (!item.value) return;
@@ -281,16 +280,16 @@ const submitReply = async () => {
await uploadAttachment(item.value.study_id, "faq_replies", created.id, file.raw as File); await uploadAttachment(item.value.study_id, "faq_replies", created.id, file.raw as File);
} }
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "附件上传失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.uploadFailed);
} }
} }
ElMessage.success("回复已提交"); ElMessage.success(TEXT.modules.knowledgeMedicalConsult.replySubmitted);
replyContent.value = ""; replyContent.value = "";
quoteReply.value = null; quoteReply.value = null;
replyFiles.value = []; replyFiles.value = [];
loadReplies(); loadReplies();
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "回复失败"); ElMessage.error(e?.response?.data?.message || TEXT.modules.knowledgeMedicalConsult.replyFailed);
} finally { } finally {
submitting.value = false; submitting.value = false;
} }
@@ -298,14 +297,14 @@ const submitReply = async () => {
const removeReply = async (reply: any) => { const removeReply = async (reply: any) => {
if (!item.value) return; if (!item.value) return;
const ok = await ElMessageBox.confirm("确认删除该回复?", "提示").catch(() => null); const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return; if (!ok) return;
try { try {
await deleteFaqReply(item.value.id, reply.id); await deleteFaqReply(item.value.id, reply.id);
ElMessage.success("回复已删除"); ElMessage.success(TEXT.common.messages.deleteSuccess);
loadReplies(); loadReplies();
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "删除失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
} }
}; };
@@ -314,14 +313,14 @@ const toggleBest = async (reply: any) => {
try { try {
const target = item.value.best_reply_id === reply.id ? null : reply.id; const target = item.value.best_reply_id === reply.id ? null : reply.id;
if (target) { if (target) {
const ok = await ElMessageBox.confirm("设为最佳答案并确认已解决?", "提示").catch(() => null); const ok = await ElMessageBox.confirm(TEXT.modules.knowledgeMedicalConsult.confirmBestReply, TEXT.common.labels.tips).catch(() => null);
if (!ok) return; if (!ok) return;
} }
const { data } = await setFaqBestReply(item.value.id, { best_reply_id: target }); const { data } = await setFaqBestReply(item.value.id, { best_reply_id: target });
item.value = data; item.value = data;
ElMessage.success(target ? "已设为最佳答案" : "已取消最佳答案"); ElMessage.success(target ? TEXT.modules.knowledgeMedicalConsult.bestReplySet : TEXT.modules.knowledgeMedicalConsult.bestReplyCleared);
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "操作失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.actionFailed);
} }
}; };
@@ -330,9 +329,9 @@ const confirmResolved = async () => {
try { try {
const { data } = await setFaqStatus(item.value.id, { status: "RESOLVED" }); const { data } = await setFaqStatus(item.value.id, { status: "RESOLVED" });
item.value = data; item.value = data;
ElMessage.success("已设置为已解决"); ElMessage.success(TEXT.modules.knowledgeMedicalConsult.resolvedSuccess);
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "操作失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.actionFailed);
} }
}; };
+22
View File
@@ -0,0 +1,22 @@
<template>
<div class="forgot-container">
<ModulePlaceholder
:list-title="TEXT.modules.auth.forgotTitle"
:empty-title="TEXT.modules.auth.forgotTitle"
:empty-description="TEXT.modules.auth.forgotDesc"
/>
</div>
</template>
<script setup lang="ts">
import ModulePlaceholder from "../components/ModulePlaceholder.vue";
import { TEXT } from "../locales";
</script>
<style scoped>
.forgot-container {
min-height: 100vh;
background-color: var(--ctms-bg-base);
padding: 32px;
}
</style>
+19 -18
View File
@@ -2,14 +2,14 @@
<div class="login-container"> <div class="login-container">
<div class="login-content"> <div class="login-content">
<div class="login-brand"> <div class="login-brand">
<h1 class="brand-title">CTMS</h1> <h1 class="brand-title">{{ TEXT.common.appName }}</h1>
<p class="brand-subtitle">临床试验管理系统</p> <p class="brand-subtitle">{{ TEXT.modules.auth.brandSubtitle }}</p>
</div> </div>
<el-card class="login-card"> <el-card class="login-card">
<div class="login-header"> <div class="login-header">
<h2 class="form-title">欢迎回来</h2> <h2 class="form-title">{{ TEXT.modules.auth.loginTitle }}</h2>
<p class="form-desc">请输入您的凭据以访问系统</p> <p class="form-desc">{{ TEXT.modules.auth.loginDesc }}</p>
</div> </div>
<el-form <el-form
@@ -20,10 +20,10 @@
@keyup.enter.native="onSubmit" @keyup.enter.native="onSubmit"
class="elegant-form" class="elegant-form"
> >
<el-form-item label="电子邮箱" prop="email"> <el-form-item :label="TEXT.modules.auth.emailLabel" prop="email">
<el-input <el-input
v-model="form.email" v-model="form.email"
placeholder="name@company.com" :placeholder="TEXT.modules.auth.emailPlaceholder"
autocomplete="email" autocomplete="email"
> >
<template #prefix> <template #prefix>
@@ -31,11 +31,11 @@
</template> </template>
</el-input> </el-input>
</el-form-item> </el-form-item>
<el-form-item label="登录密码" prop="password"> <el-form-item :label="TEXT.modules.auth.passwordLabel" prop="password">
<el-input <el-input
v-model="form.password" v-model="form.password"
type="password" type="password"
placeholder="最小 8 位字符" :placeholder="TEXT.modules.auth.passwordPlaceholder"
autocomplete="current-password" autocomplete="current-password"
show-password show-password
> >
@@ -46,8 +46,8 @@
</el-form-item> </el-form-item>
<div class="form-options"> <div class="form-options">
<el-checkbox v-model="form.remember">记住我</el-checkbox> <el-checkbox v-model="form.remember">{{ TEXT.modules.auth.remember }}</el-checkbox>
<RouterLink to="/forgot-password" class="forgot-link">忘记密码</RouterLink> <RouterLink to="/forgot-password" class="forgot-link">{{ TEXT.modules.auth.forgot }}</RouterLink>
</div> </div>
<el-button <el-button
@@ -56,18 +56,18 @@
class="submit-btn" class="submit-btn"
@click="onSubmit" @click="onSubmit"
> >
{{ TEXT.modules.auth.loginButton }}
</el-button> </el-button>
<div class="register-prompt"> <div class="register-prompt">
<span>还没有账号</span> <span>{{ TEXT.modules.auth.registerPrompt }}</span>
<RouterLink to="/register">立即注册</RouterLink> <RouterLink to="/register">{{ TEXT.modules.auth.registerNow }}</RouterLink>
</div> </div>
</el-form> </el-form>
</el-card> </el-card>
<div class="login-footer"> <div class="login-footer">
<p>© 2025 CTMS Enterprise. Powered by Professional Clinical Research.</p> <p>{{ TEXT.modules.auth.footer }}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -80,6 +80,7 @@ import { ElCheckbox, ElMessage, type FormInstance, type FormRules } from "elemen
import { Message, Lock } from "@element-plus/icons-vue"; import { Message, Lock } from "@element-plus/icons-vue";
import { useAuthStore } from "../store/auth"; import { useAuthStore } from "../store/auth";
import { getCachedCredential, setCachedCredential, clearCachedCredential } from "../utils/auth"; import { getCachedCredential, setCachedCredential, clearCachedCredential } from "../utils/auth";
import { TEXT, requiredMessage } from "../locales";
const auth = useAuthStore(); const auth = useAuthStore();
const router = useRouter(); const router = useRouter();
@@ -93,10 +94,10 @@ const form = reactive({
const rules: FormRules<typeof form> = { const rules: FormRules<typeof form> = {
email: [ email: [
{ required: true, message: "请输入有效的邮箱地址", trigger: "blur" }, { required: true, message: requiredMessage(TEXT.modules.auth.emailLabel), trigger: "blur" },
{ type: "email", message: "邮箱格式不正确", trigger: "blur" }, { type: "email", message: TEXT.common.validation.invalidEmail, trigger: "blur" },
], ],
password: [{ required: true, message: "请输入您的密码", trigger: "blur" }], password: [{ required: true, message: requiredMessage(TEXT.modules.auth.passwordLabel), trigger: "blur" }],
}; };
const loading = ref(false); const loading = ref(false);
@@ -125,7 +126,7 @@ const onSubmit = async () => {
router.push("/"); router.push("/");
} catch (error: any) { } catch (error: any) {
const detail = error?.response?.data?.detail || error?.response?.data?.message; const detail = error?.response?.data?.detail || error?.response?.data?.message;
ElMessage.error(detail || "认证失败,请检查您的凭据"); ElMessage.error(detail || TEXT.modules.auth.authFailed);
} finally { } finally {
loading.value = false; loading.value = false;
} }
+26 -25
View File
@@ -1,10 +1,10 @@
<template> <template>
<div class="page"> <div class="page">
<el-card> <el-card>
<h3 class="title">个人设置</h3> <h3 class="title">{{ TEXT.modules.profile.title }}</h3>
<p class="subtitle">更新个人信息或修改登录密码</p> <p class="subtitle">{{ TEXT.modules.profile.subtitle }}</p>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" class="form"> <el-form ref="formRef" :model="form" :rules="rules" label-width="120px" class="form">
<el-form-item label="头像"> <el-form-item :label="TEXT.common.fields.avatar">
<div class="avatar-row"> <div class="avatar-row">
<el-avatar :size="64" :src="avatarPreview" :alt="form.full_name || form.email"> <el-avatar :size="64" :src="avatarPreview" :alt="form.full_name || form.email">
{{ form.full_name?.charAt(0)?.toUpperCase() || form.email?.charAt(0)?.toUpperCase() }} {{ form.full_name?.charAt(0)?.toUpperCase() || form.email?.charAt(0)?.toUpperCase() }}
@@ -18,31 +18,31 @@
:on-success="onAvatarUploaded" :on-success="onAvatarUploaded"
:on-error="onAvatarError" :on-error="onAvatarError"
> >
<el-button>上传头像</el-button> <el-button>{{ TEXT.modules.profile.uploadAvatar }}</el-button>
</el-upload> </el-upload>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="邮箱"> <el-form-item :label="TEXT.common.fields.email">
<el-input v-model="form.email" disabled /> <el-input v-model="form.email" disabled />
</el-form-item> </el-form-item>
<el-form-item label="姓名" prop="full_name"> <el-form-item :label="TEXT.common.fields.name" prop="full_name">
<el-input v-model="form.full_name" placeholder="请输入姓名" /> <el-input v-model="form.full_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.name" />
</el-form-item> </el-form-item>
<el-form-item label="部门" prop="department"> <el-form-item :label="TEXT.common.fields.department" prop="department">
<el-input v-model="form.department" placeholder="请输入部门" /> <el-input v-model="form.department" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.department" />
</el-form-item> </el-form-item>
<el-divider /> <el-divider />
<el-form-item label="当前密码" prop="current_password"> <el-form-item :label="TEXT.modules.profile.currentPassword" prop="current_password">
<el-input v-model="form.current_password" type="password" show-password placeholder="修改密码时必填" /> <el-input v-model="form.current_password" type="password" show-password :placeholder="TEXT.modules.profile.currentPasswordHint" />
</el-form-item> </el-form-item>
<el-form-item label="新密码" prop="password"> <el-form-item :label="TEXT.modules.profile.newPassword" prop="password">
<el-input v-model="form.password" type="password" show-password placeholder="不修改请留空" /> <el-input v-model="form.password" type="password" show-password :placeholder="TEXT.modules.profile.newPasswordHint" />
</el-form-item> </el-form-item>
<el-form-item label="确认新密码" prop="confirmPassword"> <el-form-item :label="TEXT.modules.profile.confirmPassword" prop="confirmPassword">
<el-input v-model="form.confirmPassword" type="password" show-password placeholder="再次输入新密码" /> <el-input v-model="form.confirmPassword" type="password" show-password :placeholder="TEXT.modules.profile.confirmPasswordHint" />
</el-form-item> </el-form-item>
<div class="actions"> <div class="actions">
<el-button type="primary" :loading="submitting" @click="onSubmit">保存</el-button> <el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
</div> </div>
</el-form> </el-form>
</el-card> </el-card>
@@ -56,6 +56,7 @@ import { ElMessage } from "element-plus";
import { updateProfile, fetchMe } from "../api/auth"; import { updateProfile, fetchMe } from "../api/auth";
import { useAuthStore } from "../store/auth"; import { useAuthStore } from "../store/auth";
import { getToken } from "../utils/auth"; import { getToken } from "../utils/auth";
import { TEXT, requiredMessage } from "../locales";
const auth = useAuthStore(); const auth = useAuthStore();
const formRef = ref<FormInstance>(); const formRef = ref<FormInstance>();
@@ -74,13 +75,13 @@ const uploadHeaders = computed<Record<string, string>>(() => ({
})); }));
const rules: FormRules<typeof form> = { const rules: FormRules<typeof form> = {
full_name: [{ required: true, message: "请输入姓名", trigger: "blur" }], full_name: [{ required: true, message: requiredMessage(TEXT.common.fields.name), trigger: "blur" }],
department: [{ required: true, message: "请输入部门", trigger: "blur" }], department: [{ required: true, message: requiredMessage(TEXT.common.fields.department), trigger: "blur" }],
current_password: [ current_password: [
{ {
validator: (_r, value, cb) => { validator: (_r, value, cb) => {
if (form.password && !value) { if (form.password && !value) {
cb(new Error("修改密码需输入当前密码")); cb(new Error(TEXT.modules.profile.passwordRequired));
return; return;
} }
cb(); cb();
@@ -93,7 +94,7 @@ const rules: FormRules<typeof form> = {
validator: (_r, value, cb) => { validator: (_r, value, cb) => {
if (!value) return cb(); if (!value) return cb();
if (value.length < 8 || !/[A-Za-z]/.test(value) || !/[0-9]/.test(value)) { if (value.length < 8 || !/[A-Za-z]/.test(value) || !/[0-9]/.test(value)) {
cb(new Error("至少 8 位且包含字母与数字")); cb(new Error(TEXT.modules.profile.passwordRule));
return; return;
} }
cb(); cb();
@@ -105,7 +106,7 @@ const rules: FormRules<typeof form> = {
{ {
validator: (_r, value, cb) => { validator: (_r, value, cb) => {
if (form.password && value !== form.password) { if (form.password && value !== form.password) {
cb(new Error("两次输入的密码不一致")); cb(new Error(TEXT.modules.profile.passwordMismatch));
return; return;
} }
cb(); cb();
@@ -136,13 +137,13 @@ const onSubmit = async () => {
password: form.password || undefined, password: form.password || undefined,
}); });
await auth.fetchMe(); await auth.fetchMe();
ElMessage.success("已更新"); ElMessage.success(TEXT.modules.profile.updateSuccess);
form.current_password = ""; form.current_password = "";
form.password = ""; form.password = "";
form.confirmPassword = ""; form.confirmPassword = "";
avatarPreview.value = auth.user?.avatar_url || avatarPreview.value; avatarPreview.value = auth.user?.avatar_url || avatarPreview.value;
} catch (error: any) { } catch (error: any) {
ElMessage.error(error?.response?.data?.message || "更新失败"); ElMessage.error(error?.response?.data?.message || TEXT.modules.profile.updateFailed);
} finally { } finally {
submitting.value = false; submitting.value = false;
} }
@@ -152,11 +153,11 @@ const onSubmit = async () => {
const onAvatarUploaded = async () => { const onAvatarUploaded = async () => {
await auth.fetchMe(); await auth.fetchMe();
avatarPreview.value = auth.user?.avatar_url || undefined; avatarPreview.value = auth.user?.avatar_url || undefined;
ElMessage.success("头像已更新"); ElMessage.success(TEXT.modules.profile.avatarUpdated);
}; };
const onAvatarError = (err: any) => { const onAvatarError = (err: any) => {
ElMessage.error(err?.response?.data?.message || "头像上传失败"); ElMessage.error(err?.response?.data?.message || TEXT.modules.profile.avatarUploadFailed);
}; };
onMounted(() => { onMounted(() => {
+25 -24
View File
@@ -2,8 +2,8 @@
<div class="register-container"> <div class="register-container">
<div class="register-card"> <div class="register-card">
<div class="header"> <div class="header">
<h2>创建账号</h2> <h2>{{ TEXT.modules.auth.registerTitle }}</h2>
<p>提交后需管理员审核通过方可登录</p> <p>{{ TEXT.modules.auth.registerDesc }}</p>
</div> </div>
<el-form <el-form
ref="formRef" ref="formRef"
@@ -12,36 +12,36 @@
label-position="top" label-position="top"
@keyup.enter.native="onSubmit" @keyup.enter.native="onSubmit"
> >
<el-form-item label="邮箱" prop="email"> <el-form-item :label="TEXT.modules.auth.emailLabel" prop="email">
<el-input v-model="form.email" placeholder="name@example.com" autocomplete="email" /> <el-input v-model="form.email" :placeholder="TEXT.modules.auth.emailPlaceholder" autocomplete="email" />
</el-form-item> </el-form-item>
<el-form-item label="密码" prop="password"> <el-form-item :label="TEXT.modules.auth.passwordLabel" prop="password">
<el-input <el-input
v-model="form.password" v-model="form.password"
type="password" type="password"
placeholder="至少 8 位且包含字母与数字" :placeholder="TEXT.modules.auth.passwordRuleMix"
show-password show-password
autocomplete="new-password" autocomplete="new-password"
/> />
</el-form-item> </el-form-item>
<el-form-item label="确认密码" prop="confirmPassword"> <el-form-item :label="TEXT.modules.auth.confirmPasswordLabel" prop="confirmPassword">
<el-input <el-input
v-model="form.confirmPassword" v-model="form.confirmPassword"
type="password" type="password"
placeholder="再次输入密码" :placeholder="TEXT.modules.auth.confirmPasswordPlaceholder"
show-password show-password
autocomplete="new-password" autocomplete="new-password"
/> />
</el-form-item> </el-form-item>
<el-form-item label="姓名" prop="full_name"> <el-form-item :label="TEXT.modules.auth.fullNameLabel" prop="full_name">
<el-input v-model="form.full_name" placeholder="请输入姓名" /> <el-input v-model="form.full_name" :placeholder="TEXT.modules.auth.fullNamePlaceholder" />
</el-form-item> </el-form-item>
<el-form-item label="部门" prop="department"> <el-form-item :label="TEXT.modules.auth.departmentLabel" prop="department">
<el-input v-model="form.department" placeholder="请输入部门" /> <el-input v-model="form.department" :placeholder="TEXT.modules.auth.departmentPlaceholder" />
</el-form-item> </el-form-item>
<div class="actions"> <div class="actions">
<el-button type="primary" :loading="submitting" @click="onSubmit">提交注册</el-button> <el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.modules.auth.registerButton }}</el-button>
<RouterLink to="/login">已有账号返回登录</RouterLink> <RouterLink to="/login">{{ TEXT.modules.auth.backToLogin }}</RouterLink>
</div> </div>
</el-form> </el-form>
</div> </div>
@@ -55,6 +55,7 @@ import type { FormInstance, FormRules } from "element-plus";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { register as apiRegister } from "../api/auth"; import { register as apiRegister } from "../api/auth";
import type { RegisterRequest } from "../types/api"; import type { RegisterRequest } from "../types/api";
import { TEXT, requiredMessage } from "../locales";
const router = useRouter(); const router = useRouter();
const defaultRole: RegisterRequest["role"] = "CRA"; const defaultRole: RegisterRequest["role"] = "CRA";
@@ -72,11 +73,11 @@ const submitting = ref(false);
const passwordRule = { const passwordRule = {
validator: (_: any, value: string, callback: (err?: Error) => void) => { validator: (_: any, value: string, callback: (err?: Error) => void) => {
if (!value || value.length < 8) { if (!value || value.length < 8) {
callback(new Error("密码至少 8 位")); callback(new Error(TEXT.modules.auth.passwordRuleMin));
return; return;
} }
if (!/[A-Za-z]/.test(value) || !/[0-9]/.test(value)) { if (!/[A-Za-z]/.test(value) || !/[0-9]/.test(value)) {
callback(new Error("需包含字母和数字")); callback(new Error(TEXT.modules.auth.passwordRuleMix));
return; return;
} }
callback(); callback();
@@ -86,19 +87,19 @@ const passwordRule = {
const rules: FormRules<typeof form> = { const rules: FormRules<typeof form> = {
email: [ email: [
{ required: true, message: "请输入邮箱", trigger: "blur" }, { required: true, message: requiredMessage(TEXT.modules.auth.emailLabel), trigger: "blur" },
{ type: "email", message: "邮箱格式不正确", trigger: "blur" }, { type: "email", message: TEXT.common.validation.invalidEmail, trigger: "blur" },
], ],
password: [ password: [
{ required: true, message: "请输入密码", trigger: "blur" }, { required: true, message: requiredMessage(TEXT.modules.auth.passwordLabel), trigger: "blur" },
passwordRule, passwordRule,
], ],
confirmPassword: [ confirmPassword: [
{ required: true, message: "请确认密码", trigger: "blur" }, { required: true, message: requiredMessage(TEXT.modules.auth.confirmPasswordLabel), trigger: "blur" },
{ {
validator: (_: any, value: string, callback: (err?: Error) => void) => { validator: (_: any, value: string, callback: (err?: Error) => void) => {
if (value !== form.password) { if (value !== form.password) {
callback(new Error("两次输入的密码不一致")); callback(new Error(TEXT.modules.auth.passwordMismatch));
} else { } else {
callback(); callback();
} }
@@ -106,8 +107,8 @@ const rules: FormRules<typeof form> = {
trigger: "blur", trigger: "blur",
}, },
], ],
full_name: [{ required: true, message: "请输入姓名", trigger: "blur" }], full_name: [{ required: true, message: requiredMessage(TEXT.modules.auth.fullNameLabel), trigger: "blur" }],
department: [{ required: true, message: "请输入部门", trigger: "blur" }], department: [{ required: true, message: requiredMessage(TEXT.modules.auth.departmentLabel), trigger: "blur" }],
}; };
const onSubmit = async () => { const onSubmit = async () => {
@@ -123,7 +124,7 @@ const onSubmit = async () => {
role: defaultRole, role: defaultRole,
department: form.department, department: form.department,
}); });
ElMessage.success("注册成功,等待管理员审核"); ElMessage.success(TEXT.modules.auth.registerSuccess);
setTimeout(() => { setTimeout(() => {
router.push("/login"); router.push("/login");
}, 3000); }, 3000);
+18 -14
View File
@@ -3,8 +3,8 @@
<!-- 概览头部 --> <!-- 概览头部 -->
<div class="home-header"> <div class="home-header">
<div class="header-content"> <div class="header-content">
<h1 class="welcome-title">项目总览</h1> <h1 class="welcome-title">{{ TEXT.modules.projectOverview.title }}</h1>
<p class="page-subtitle">当前项目运行状态与关键指标</p> <p class="page-subtitle">{{ TEXT.modules.projectOverview.subtitle }}</p>
<p class="study-info"> <p class="study-info">
<span class="study-name">{{ study.currentStudy.name }}</span> <span class="study-name">{{ study.currentStudy.name }}</span>
<span class="study-divider">/</span> <span class="study-divider">/</span>
@@ -12,7 +12,7 @@
</p> </p>
</div> </div>
<div class="header-meta"> <div class="header-meta">
<el-tag effect="plain" class="role-tag">角色: {{ auth.user?.role }}</el-tag> <el-tag effect="plain" class="role-tag">{{ TEXT.common.labels.role }}: {{ roleLabel }}</el-tag>
</div> </div>
</div> </div>
@@ -20,7 +20,7 @@
<div class="stats-grid"> <div class="stats-grid">
<div class="stats-item"> <div class="stats-item">
<KpiCard <KpiCard
title="节点完成率" :title="TEXT.modules.projectOverview.kpiProgress"
:value="progress?.milestones_done ?? 0" :value="progress?.milestones_done ?? 0"
:unit="` / ${progress?.milestones_total ?? 0}`" :unit="` / ${progress?.milestones_total ?? 0}`"
:subtext="milestoneCompletionRate" :subtext="milestoneCompletionRate"
@@ -30,19 +30,19 @@
</div> </div>
<div class="stats-item"> <div class="stats-item">
<KpiCard <KpiCard
title="逾期 AE" :title="TEXT.modules.projectOverview.kpiOverdueAes"
:value="overdueAes" :value="overdueAes"
unit="" :unit="TEXT.common.units.case"
subtext="需尽快跟进处理" :subtext="TEXT.modules.projectOverview.kpiOverdueAesHint"
:loading="loading.overdueAes" :loading="loading.overdueAes"
:icon="Warning" :icon="Warning"
/> />
</div> </div>
<div class="stats-item"> <div class="stats-item">
<KpiCard <KpiCard
title="费用总额" :title="TEXT.modules.projectOverview.kpiFinanceTotal"
:value="formatAmount(financeSummary?.total_amount ?? 0)" :value="formatAmount(financeSummary?.total_amount ?? 0)"
:subtext="`累计已支付:¥${formatAmount(financeSummary?.paid_amount ?? 0)}`" :subtext="`${TEXT.modules.projectOverview.kpiFinancePaid}:¥${formatAmount(financeSummary?.paid_amount ?? 0)}`"
:loading="loading.finance" :loading="loading.finance"
:icon="Money" :icon="Money"
/> />
@@ -53,7 +53,7 @@
<el-alert <el-alert
v-if="overdueAes > 0" v-if="overdueAes > 0"
type="warning" type="warning"
title="存在逾期未处理事项,请及时关注项目的合规性与进度。" :title="TEXT.modules.projectOverview.alertWarning"
show-icon show-icon
:closable="false" :closable="false"
class="home-alert" class="home-alert"
@@ -61,7 +61,7 @@
<el-alert <el-alert
v-else v-else
type="success" type="success"
title="项目运行状态良好,所有关键指标均在可控范围内。" :title="TEXT.modules.projectOverview.alertOk"
show-icon show-icon
:closable="false" :closable="false"
class="home-alert" class="home-alert"
@@ -74,7 +74,7 @@
</div> </div>
<div v-else class="empty-state-container"> <div v-else class="empty-state-container">
<StateEmpty description="请先在顶部选择一个当前项目" /> <StateEmpty :description="TEXT.common.empty.selectProject" />
</div> </div>
</template> </template>
@@ -87,6 +87,8 @@ import KpiCard from "../components/KpiCard.vue";
import QuickActions from "../components/QuickActions.vue"; import QuickActions from "../components/QuickActions.vue";
import { List, Warning, Money } from "@element-plus/icons-vue"; import { List, Warning, Money } from "@element-plus/icons-vue";
import StateEmpty from "../components/StateEmpty.vue"; import StateEmpty from "../components/StateEmpty.vue";
import { displayEnum } from "../utils/display";
import { TEXT } from "../locales";
const auth = useAuthStore(); const auth = useAuthStore();
const study = useStudyStore(); const study = useStudyStore();
@@ -103,11 +105,13 @@ const loading = ref({
const milestoneCompletionRate = computed(() => { const milestoneCompletionRate = computed(() => {
const total = progress.value?.milestones_total ?? 0; const total = progress.value?.milestones_total ?? 0;
if (!total) return "暂无节点"; if (!total) return TEXT.modules.projectOverview.noMilestones;
const rate = (progress.value?.milestones_done ?? 0) / total; const rate = (progress.value?.milestones_done ?? 0) / total;
return `总体进度: ${(rate * 100).toFixed(0)}%`; return TEXT.modules.projectOverview.progressLabel.replace("{rate}", `${(rate * 100).toFixed(0)}%`);
}); });
const roleLabel = computed(() => displayEnum(TEXT.enums.userRole, auth.user?.role));
const formatAmount = (val: number) => { const formatAmount = (val: number) => {
return new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2 }).format(val); return new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2 }).format(val);
}; };
+26 -23
View File
@@ -3,30 +3,32 @@
<el-card> <el-card>
<div class="header"> <div class="header">
<div> <div>
<h3>注册审核</h3> <h3>{{ TEXT.modules.adminUserApproval.title }}</h3>
<p>仅显示待审核账号审核通过后即可登录</p> <p>{{ TEXT.modules.adminUserApproval.subtitle }}</p>
</div> </div>
<el-select v-model="status" size="small" style="width: 180px" @change="loadUsers"> <el-select v-model="status" size="small" style="width: 180px" @change="loadUsers">
<el-option label="待审核" value="PENDING" /> <el-option :label="TEXT.enums.userStatus.PENDING" value="PENDING" />
<el-option label="已通过" value="ACTIVE" /> <el-option :label="TEXT.enums.userStatus.ACTIVE" value="ACTIVE" />
<el-option label="已拒绝" value="REJECTED" /> <el-option :label="TEXT.enums.userStatus.REJECTED" value="REJECTED" />
<el-option label="已禁用" value="DISABLED" /> <el-option :label="TEXT.enums.userStatus.DISABLED" value="DISABLED" />
</el-select> </el-select>
</div> </div>
<el-table :data="users" v-loading="loading" style="width: 100%"> <el-table :data="users" v-loading="loading" style="width: 100%">
<el-table-column prop="email" label="邮箱" min-width="200" /> <el-table-column prop="email" :label="TEXT.common.fields.email" min-width="200" />
<el-table-column prop="full_name" label="姓名" min-width="140" /> <el-table-column prop="full_name" :label="TEXT.common.fields.name" min-width="140" />
<el-table-column prop="role" label="角色" width="120" /> <el-table-column prop="role" :label="TEXT.common.labels.role" width="120">
<el-table-column prop="department" label="部门" min-width="160" /> <template #default="scope">{{ displayEnum(TEXT.enums.userRole, scope.row.role) }}</template>
<el-table-column prop="created_at" label="注册时间" width="200"> </el-table-column>
<el-table-column prop="department" :label="TEXT.common.fields.department" min-width="160" />
<el-table-column prop="created_at" :label="TEXT.modules.adminUserApproval.createdAt" width="200">
<template #default="scope">{{ displayDateTime(scope.row.created_at) }}</template> <template #default="scope">{{ displayDateTime(scope.row.created_at) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="status" label="状态" width="120"> <el-table-column prop="status" :label="TEXT.common.fields.status" width="120">
<template #default="scope"> <template #default="scope">
<el-tag :type="statusType(scope.row.status)">{{ scope.row.status }}</el-tag> <el-tag :type="statusType(scope.row.status)">{{ displayEnum(TEXT.enums.userStatus, scope.row.status) }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="220" fixed="right"> <el-table-column :label="TEXT.common.labels.actions" width="220" fixed="right">
<template #default="scope"> <template #default="scope">
<el-button <el-button
v-if="scope.row.status === 'PENDING'" v-if="scope.row.status === 'PENDING'"
@@ -35,7 +37,7 @@
size="small" size="small"
@click="onApprove(scope.row.id)" @click="onApprove(scope.row.id)"
> >
审核通过 {{ TEXT.modules.adminUserApproval.approve }}
</el-button> </el-button>
<el-button <el-button
v-if="scope.row.status === 'PENDING'" v-if="scope.row.status === 'PENDING'"
@@ -44,9 +46,9 @@
size="small" size="small"
@click="onReject(scope.row.id)" @click="onReject(scope.row.id)"
> >
拒绝 {{ TEXT.modules.adminUserApproval.reject }}
</el-button> </el-button>
<span v-else></span> <span v-else>{{ TEXT.common.fallback }}</span>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -59,7 +61,8 @@ import { onMounted, ref } from "vue";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { approveUser, listPendingUsers, rejectUser } from "../../api/admin"; import { approveUser, listPendingUsers, rejectUser } from "../../api/admin";
import type { UserInfo, UserStatus } from "../../types/api"; import type { UserInfo, UserStatus } from "../../types/api";
import { displayDateTime } from "../../utils/display"; import { displayDateTime, displayEnum } from "../../utils/display";
import { TEXT } from "../../locales";
const users = ref<UserInfo[]>([]); const users = ref<UserInfo[]>([]);
const loading = ref(false); const loading = ref(false);
@@ -84,7 +87,7 @@ const loadUsers = async () => {
const { data } = await listPendingUsers(status.value); const { data } = await listPendingUsers(status.value);
users.value = data.items || []; users.value = data.items || [];
} catch (error: any) { } catch (error: any) {
ElMessage.error(error?.response?.data?.message || "加载待审核用户失败"); ElMessage.error(error?.response?.data?.message || TEXT.modules.adminUserApproval.loadFailed);
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -93,20 +96,20 @@ const loadUsers = async () => {
const onApprove = async (id: string) => { const onApprove = async (id: string) => {
try { try {
await approveUser(id); await approveUser(id);
ElMessage.success("已通过审核"); ElMessage.success(TEXT.modules.adminUserApproval.approveSuccess);
loadUsers(); loadUsers();
} catch (error: any) { } catch (error: any) {
ElMessage.error(error?.response?.data?.message || "审核失败"); ElMessage.error(error?.response?.data?.message || TEXT.modules.adminUserApproval.approveFailed);
} }
}; };
const onReject = async (id: string) => { const onReject = async (id: string) => {
try { try {
await rejectUser(id); await rejectUser(id);
ElMessage.success("已拒绝"); ElMessage.success(TEXT.modules.adminUserApproval.rejectSuccess);
loadUsers(); loadUsers();
} catch (error: any) { } catch (error: any) {
ElMessage.error(error?.response?.data?.message || "拒绝失败"); ElMessage.error(error?.response?.data?.message || TEXT.modules.adminUserApproval.rejectFailed);
} }
}; };
+29 -28
View File
@@ -3,22 +3,22 @@
<el-card> <el-card>
<div class="ctms-table-toolbar"> <div class="ctms-table-toolbar">
<div class="ctms-filter-group"> <div class="ctms-filter-group">
<el-select v-model="filters.eventType" clearable placeholder="操作类型" @change="loadLogs" class="ctms-filter-item"> <el-select v-model="filters.eventType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterEvent" @change="loadLogs" class="ctms-filter-item">
<el-option v-for="opt in eventTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" /> <el-option v-for="opt in eventTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select> </el-select>
<el-select v-model="filters.operatorId" clearable placeholder="操作人" 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">
<el-option v-for="u in userOptions" :key="u.value" :label="u.label" :value="u.value" /> <el-option v-for="u in userOptions" :key="u.value" :label="u.label" :value="u.value" />
</el-select> </el-select>
<el-select v-model="filters.result" clearable placeholder="结果" @change="loadLogs" style="width: 100px"> <el-select v-model="filters.result" clearable :placeholder="TEXT.modules.adminAuditLogs.filterResult" @change="loadLogs" style="width: 100px">
<el-option label="成功" value="SUCCESS" /> <el-option :label="TEXT.audit.resultSuccess" value="SUCCESS" />
<el-option label="失败" value="FAIL" /> <el-option :label="TEXT.audit.resultFail" value="FAIL" />
</el-select> </el-select>
<el-date-picker <el-date-picker
v-model="filters.range" v-model="filters.range"
type="daterange" type="daterange"
unlink-panels unlink-panels
start-placeholder="开始" :start-placeholder="TEXT.modules.adminAuditLogs.rangeStart"
end-placeholder="结束" :end-placeholder="TEXT.modules.adminAuditLogs.rangeEnd"
format="YYYY-MM-DD" format="YYYY-MM-DD"
value-format="YYYY-MM-DD" value-format="YYYY-MM-DD"
style="width: 240px" style="width: 240px"
@@ -34,7 +34,7 @@
:loading="exportLoading" :loading="exportLoading"
@click="confirmExport('system')" @click="confirmExport('system')"
> >
导出系统审计 {{ TEXT.modules.adminAuditLogs.exportSystem }}
</el-button> </el-button>
<el-button <el-button
v-if="canProjectExport" v-if="canProjectExport"
@@ -44,36 +44,36 @@
:loading="exportLoading" :loading="exportLoading"
@click="confirmExport('project')" @click="confirmExport('project')"
> >
导出项目审计 {{ TEXT.modules.adminAuditLogs.exportProject }}
</el-button> </el-button>
</div> </div>
</div> </div>
<el-table :data="logs" v-loading="loading" style="width: 100%"> <el-table :data="logs" v-loading="loading" style="width: 100%">
<el-table-column prop="timestamp" label="时间" width="180"> <el-table-column prop="timestamp" :label="TEXT.modules.adminAuditLogs.columns.time" width="180">
<template #default="scope">{{ displayDateTime(scope.row.timestamp) }}</template> <template #default="scope">{{ displayDateTime(scope.row.timestamp) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="actorName" label="操作人" width="140" /> <el-table-column prop="actorName" :label="TEXT.modules.adminAuditLogs.columns.actor" width="140" />
<el-table-column prop="actorRoleLabel" label="角色" width="120" /> <el-table-column prop="actorRoleLabel" :label="TEXT.common.labels.role" width="120" />
<el-table-column prop="eventLabel" label="操作类型" min-width="160" /> <el-table-column prop="eventLabel" :label="TEXT.modules.adminAuditLogs.columns.event" min-width="160" />
<el-table-column prop="actionText" label="操作内容" min-width="180" /> <el-table-column prop="actionText" :label="TEXT.modules.adminAuditLogs.columns.action" min-width="180" />
<el-table-column label="操作对象" min-width="180"> <el-table-column :label="TEXT.modules.adminAuditLogs.columns.target" min-width="180">
<template #default="scope"> <template #default="scope">
<span v-if="scope.row.targetTypeLabel"> <span v-if="scope.row.targetTypeLabel">
{{ scope.row.targetTypeLabel }}{{ scope.row.targetName || "—" }} {{ scope.row.targetTypeLabel }}{{ scope.row.targetName || TEXT.common.fallback }}
</span> </span>
<span v-else></span> <span v-else>{{ TEXT.common.fallback }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="变更详情" min-width="220"> <el-table-column :label="TEXT.modules.adminAuditLogs.columns.diff" min-width="220">
<template #default="scope"> <template #default="scope">
<div v-if="scope.row.diffText?.length"> <div v-if="scope.row.diffText?.length">
<div v-for="(line, idx) in scope.row.diffText" :key="idx">{{ line }}</div> <div v-for="(line, idx) in scope.row.diffText" :key="idx">{{ line }}</div>
</div> </div>
<span v-else>-</span> <span v-else>{{ TEXT.audit.emptyValue }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="结果" width="120"> <el-table-column :label="TEXT.modules.adminAuditLogs.columns.result" width="120">
<template #default="scope"> <template #default="scope">
<el-tag :type="scope.row.result === 'SUCCESS' ? 'success' : 'danger'"> <el-tag :type="scope.row.result === 'SUCCESS' ? 'success' : 'danger'">
{{ scope.row.resultLabel }} {{ scope.row.resultLabel }}
@@ -107,6 +107,7 @@ import { roleDict, getDictLabel } from "../../dictionaries";
import { exportAuditCsv } from "../../audit/export/auditExportService"; import { exportAuditCsv } from "../../audit/export/auditExportService";
import { logAudit } from "../../audit"; import { logAudit } from "../../audit";
import { displayDateTime } from "../../utils/display"; import { displayDateTime } from "../../utils/display";
import { TEXT } from "../../locales";
const buildLogKey = (log: any) => { const buildLogKey = (log: any) => {
if (log.id) return `id:${log.id}`; if (log.id) return `id:${log.id}`;
@@ -197,7 +198,7 @@ const loadLogs = async () => {
total.value = (data as any).total || merged.length; total.value = (data as any).total || merged.length;
enrichLogs(); enrichLogs();
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "审计日志加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.modules.adminAuditLogs.loadFailed);
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -278,7 +279,7 @@ const fetchAllForExport = async () => {
}); });
return filtered.map((log) => normalizeAuditEvent(log, userMap)); return filtered.map((log) => normalizeAuditEvent(log, userMap));
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "导出数据加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.modules.adminAuditLogs.exportLoadFailed);
return []; return [];
} finally { } finally {
exportLoading.value = false; exportLoading.value = false;
@@ -287,20 +288,20 @@ const fetchAllForExport = async () => {
const confirmExport = async (scope: "system" | "project") => { const confirmExport = async (scope: "system" | "project") => {
const ok = await ElMessageBox.confirm( const ok = await ElMessageBox.confirm(
"该文件包含敏感审计信息,仅限授权人员使用。确认导出吗?", TEXT.modules.adminAuditLogs.exportConfirm,
"审计导出确认", TEXT.modules.adminAuditLogs.exportConfirmTitle,
{ type: "warning", confirmButtonText: "确认", cancelButtonText: "取消" } { type: "warning", confirmButtonText: TEXT.common.actions.confirm, cancelButtonText: TEXT.common.actions.cancel }
).catch(() => null); ).catch(() => null);
if (!ok) return; if (!ok) return;
const events = await fetchAllForExport(); const events = await fetchAllForExport();
if (!events.length) { if (!events.length) {
ElMessage.warning("无可导出的审计记录"); ElMessage.warning(TEXT.modules.adminAuditLogs.exportEmpty);
return; return;
} }
const fileName = const fileName =
scope === "system" scope === "system"
? `系统审计_${new Date().toISOString().slice(0, 10)}` ? `${TEXT.modules.adminAuditLogs.exportSystemName}_${new Date().toISOString().slice(0, 10)}`
: `项目审计_${study.currentStudy?.code || ""}_${new Date().toISOString().slice(0, 10)}`; : `${TEXT.modules.adminAuditLogs.exportProjectName}_${study.currentStudy?.code || ""}_${new Date().toISOString().slice(0, 10)}`;
exportAuditCsv(events, { fileName }); exportAuditCsv(events, { fileName });
logAudit(scope === "system" ? "AUDIT_EXPORT_SYSTEM" : "AUDIT_EXPORT_PROJECT", { logAudit(scope === "system" ? "AUDIT_EXPORT_SYSTEM" : "AUDIT_EXPORT_PROJECT", {
targetId: study.currentStudy?.id, targetId: study.currentStudy?.id,
+14 -17
View File
@@ -2,27 +2,27 @@
<div class="ctms-page"> <div class="ctms-page">
<div class="ctms-page-header"> <div class="ctms-page-header">
<div> <div>
<h1 class="ctms-page-title">项目详情</h1> <h1 class="ctms-page-title">{{ TEXT.modules.adminProjects.detailTitle }}</h1>
</div> </div>
<div class="ctms-page-actions"> <div class="ctms-page-actions">
<el-button type="primary" @click="enterProject" :disabled="!project">进入项目</el-button> <el-button type="primary" @click="enterProject" :disabled="!project">{{ TEXT.modules.adminProjects.enter }}</el-button>
<el-button @click="goMembers" :disabled="!project">项目成员配置</el-button> <el-button @click="goMembers" :disabled="!project">{{ TEXT.modules.adminProjects.members }}</el-button>
<el-button @click="goSites" :disabled="!project">中心管理</el-button> <el-button @click="goSites" :disabled="!project">{{ TEXT.modules.adminProjects.sites }}</el-button>
</div> </div>
</div> </div>
<el-card class="ctms-section-card"> <el-card class="ctms-section-card">
<template #header> <template #header>
<div> <div>
<div class="ctms-section-title">项目基本信息</div> <div class="ctms-section-title">{{ TEXT.modules.adminProjects.basicInfo }}</div>
</div> </div>
</template> </template>
<el-descriptions v-if="project" :column="2" border class="detail-descriptions"> <el-descriptions v-if="project" :column="2" border class="detail-descriptions">
<el-descriptions-item label="项目编号">{{ project.code }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.projectCode">{{ project.code }}</el-descriptions-item>
<el-descriptions-item label="项目名称">{{ project.name }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.projectName">{{ project.name }}</el-descriptions-item>
<el-descriptions-item label="申办方">{{ project.sponsor || "—" }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.sponsor">{{ project.sponsor || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ statusLabel(project.status) }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.status">{{ statusLabel(project.status) }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ displayDateTime(project.created_at) }}</el-descriptions-item> <el-descriptions-item :label="TEXT.modules.adminProjects.createdAt">{{ displayDateTime(project.created_at) }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
<StateLoading v-else :rows="4" /> <StateLoading v-else :rows="4" />
</el-card> </el-card>
@@ -30,7 +30,7 @@
<el-card class="ctms-section-card"> <el-card class="ctms-section-card">
<template #header> <template #header>
<div> <div>
<div class="ctms-section-title">项目通用文件</div> <div class="ctms-section-title">{{ TEXT.modules.adminProjects.filesTitle }}</div>
</div> </div>
</template> </template>
<AttachmentList <AttachmentList
@@ -55,6 +55,7 @@ import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateLoading from "../../components/StateLoading.vue"; import StateLoading from "../../components/StateLoading.vue";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { displayDateTime } from "../../utils/display"; import { displayDateTime } from "../../utils/display";
import { TEXT } from "../../locales";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -67,16 +68,12 @@ const loadProject = async () => {
const { data } = await fetchStudyDetail(route.params.projectId as string); const { data } = await fetchStudyDetail(route.params.projectId as string);
project.value = data as Study; project.value = data as Study;
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "项目加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjects.loadFailed);
} }
}; };
const statusLabel = (status: string) => const statusLabel = (status: string) =>
({ TEXT.enums.projectStatus[status as keyof typeof TEXT.enums.projectStatus] || status;
DRAFT: "草稿",
ACTIVE: "进行中",
CLOSED: "已关闭",
}[status] || status);
const enterProject = () => { const enterProject = () => {
if (!project.value) return; if (!project.value) return;
+25 -24
View File
@@ -1,32 +1,32 @@
<template> <template>
<el-dialog :title="project ? '编辑项目' : '新建项目'" width="560px" v-model="visibleProxy" :close-on-click-modal="false"> <el-dialog :title="project ? TEXT.modules.adminProjects.editTitle : TEXT.modules.adminProjects.newTitle" width="560px" v-model="visibleProxy" :close-on-click-modal="false">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px"> <el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form-item label="项目编号" prop="code"> <el-form-item :label="TEXT.common.fields.projectCode" prop="code">
<el-input v-model="form.code" :disabled="!!project" placeholder="请输入项目编号" /> <el-input v-model="form.code" :disabled="!!project" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.projectCode" />
</el-form-item> </el-form-item>
<el-form-item label="项目名称" prop="name"> <el-form-item :label="TEXT.common.fields.projectName" prop="name">
<el-input v-model="form.name" placeholder="请输入项目名称" /> <el-input v-model="form.name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.projectName" />
</el-form-item> </el-form-item>
<el-form-item label="申办方" prop="sponsor"> <el-form-item :label="TEXT.common.fields.sponsor" prop="sponsor">
<el-input v-model="form.sponsor" placeholder="申办方(可选)" /> <el-input v-model="form.sponsor" :placeholder="TEXT.modules.adminProjects.sponsorPlaceholder" />
</el-form-item> </el-form-item>
<el-form-item label="方案号" prop="protocol_no"> <el-form-item :label="TEXT.common.fields.protocolNo" prop="protocol_no">
<el-input v-model="form.protocol_no" placeholder="方案号(可选)" /> <el-input v-model="form.protocol_no" :placeholder="TEXT.modules.adminProjects.protocolPlaceholder" />
</el-form-item> </el-form-item>
<el-form-item label="分期" prop="phase"> <el-form-item :label="TEXT.common.fields.phase" prop="phase">
<el-input v-model="form.phase" placeholder="研究分期(可选)" /> <el-input v-model="form.phase" :placeholder="TEXT.modules.adminProjects.phasePlaceholder" />
</el-form-item> </el-form-item>
<el-form-item label="状态" prop="status"> <el-form-item :label="TEXT.common.fields.status" prop="status">
<el-select v-model="form.status" placeholder="请选择状态"> <el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select">
<el-option label="草稿" value="DRAFT" /> <el-option :label="TEXT.enums.projectStatus.DRAFT" value="DRAFT" />
<el-option label="进行中" value="ACTIVE" /> <el-option :label="TEXT.enums.projectStatus.ACTIVE" value="ACTIVE" />
<el-option label="已关闭" value="CLOSED" /> <el-option :label="TEXT.enums.projectStatus.CLOSED" value="CLOSED" />
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="visibleProxy = false">取消</el-button> <el-button @click="visibleProxy = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">保存</el-button> <el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
</template> </template>
</el-dialog> </el-dialog>
</template> </template>
@@ -36,6 +36,7 @@ import { computed, reactive, ref, watch } from "vue";
import { ElMessage, type FormInstance, type FormRules } from "element-plus"; import { ElMessage, type FormInstance, type FormRules } from "element-plus";
import { createStudy, updateStudy } from "../../api/studies"; import { createStudy, updateStudy } from "../../api/studies";
import type { Study } from "../../types/api"; import type { Study } from "../../types/api";
import { TEXT, requiredMessage } from "../../locales";
const props = defineProps<{ const props = defineProps<{
visible: boolean; visible: boolean;
@@ -64,9 +65,9 @@ const form = reactive({
}); });
const rules = reactive<FormRules>({ const rules = reactive<FormRules>({
code: [{ required: true, message: "请输入项目编号", trigger: "blur" }], code: [{ required: true, message: requiredMessage(TEXT.common.fields.projectCode), trigger: "blur" }],
name: [{ required: true, message: "请输入项目名称", trigger: "blur" }], name: [{ required: true, message: requiredMessage(TEXT.common.fields.projectName), trigger: "blur" }],
status: [{ required: true, message: "请选择项目状态", trigger: "change" }], status: [{ required: true, message: requiredMessage(TEXT.common.fields.status), trigger: "change" }],
}); });
const resetForm = () => { const resetForm = () => {
@@ -108,7 +109,7 @@ const onSubmit = async () => {
phase: form.phase, phase: form.phase,
status: form.status, status: form.status,
}); });
ElMessage.success("项目已更新"); ElMessage.success(TEXT.modules.adminProjects.updateSuccess);
} else { } else {
await createStudy({ await createStudy({
code: form.code, code: form.code,
@@ -118,12 +119,12 @@ const onSubmit = async () => {
phase: form.phase, phase: form.phase,
status: form.status, status: form.status,
}); });
ElMessage.success("项目已创建"); ElMessage.success(TEXT.modules.adminProjects.createSuccess);
} }
emit("saved"); emit("saved");
visibleProxy.value = false; visibleProxy.value = false;
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally { } finally {
submitting.value = false; submitting.value = false;
} }
+57 -56
View File
@@ -3,37 +3,37 @@
<el-card> <el-card>
<div class="header"> <div class="header">
<div> <div>
<h3>项目成员配置角色仅在当前项目内生效</h3> <h3>{{ TEXT.modules.adminProjectMembers.title }}</h3>
<div class="sub" v-if="project">项目{{ project.code }} - {{ project.name }}</div> <div class="sub" v-if="project">{{ TEXT.modules.adminProjectMembers.projectPrefix }}{{ project.code }} - {{ project.name }}</div>
<div class="sub">同一账号在不同项目可拥有不同角色角色授权仅在本页进行</div> <div class="sub">{{ TEXT.modules.adminProjectMembers.subtitle }}</div>
</div> </div>
<el-button type="primary" @click="openAdd">新增成员</el-button> <el-button type="primary" @click="openAdd">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjectMembers.memberLabel }}</el-button>
</div> </div>
<el-table :data="memberRows" v-loading="loading" stripe> <el-table :data="memberRows" v-loading="loading" stripe>
<el-table-column prop="username" label="用户名" min-width="160" /> <el-table-column prop="username" :label="TEXT.modules.adminProjectMembers.username" min-width="160" />
<el-table-column prop="role_in_study" label="项目角色" width="140"> <el-table-column prop="role_in_study" :label="TEXT.modules.adminProjectMembers.projectRole" width="140">
<template #default="scope"> <template #default="scope">
<el-tag>{{ scope.row.role_in_study }}</el-tag> <el-tag>{{ displayEnum(TEXT.enums.userRole, scope.row.role_in_study) }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="状态" width="160"> <el-table-column :label="TEXT.common.fields.status" width="160">
<template #default="scope"> <template #default="scope">
<el-tooltip <el-tooltip
v-if="scope.row.effectiveStatus === 'DISABLED_GLOBAL'" v-if="scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
content="请前往【账号治理】启用该账号" :content="TEXT.modules.adminProjectMembers.disabledGlobalHint"
placement="top" placement="top"
> >
<el-tag type="danger">全局已禁用</el-tag> <el-tag type="danger">{{ TEXT.modules.adminProjectMembers.disabledGlobal }}</el-tag>
</el-tooltip> </el-tooltip>
<el-tag v-else :type="scope.row.is_active ? 'success' : 'danger'"> <el-tag v-else :type="scope.row.is_active ? 'success' : 'danger'">
{{ scope.row.is_active ? "启用" : "已禁用" }} {{ scope.row.is_active ? TEXT.common.actions.enable : TEXT.modules.adminProjectMembers.disabled }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="added_at" label="加入时间" width="200"> <el-table-column prop="added_at" :label="TEXT.modules.adminProjectMembers.addedAt" width="200">
<template #default="scope">{{ displayDateTime(scope.row.added_at) }}</template> <template #default="scope">{{ displayDateTime(scope.row.added_at) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="360" fixed="right"> <el-table-column :label="TEXT.common.labels.actions" width="360" fixed="right">
<template #default="scope"> <template #default="scope">
<el-select <el-select
v-model="scope.row.role_in_study" v-model="scope.row.role_in_study"
@@ -42,14 +42,14 @@
@change="(val) => updateRole(scope.row.id, val)" @change="(val) => updateRole(scope.row.id, val)"
:disabled="!scope.row.is_active || scope.row.effectiveStatus === 'DISABLED_GLOBAL'" :disabled="!scope.row.is_active || scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
> >
<el-option label="PM" value="PM" /> <el-option :label="TEXT.enums.userRole.PM" value="PM" />
<el-option label="CRA" value="CRA" /> <el-option :label="TEXT.enums.userRole.CRA" value="CRA" />
<el-option label="PV" value="PV" /> <el-option :label="TEXT.enums.userRole.PV" value="PV" />
<el-option label="IMP" value="IMP" /> <el-option :label="TEXT.enums.userRole.IMP" value="IMP" />
<el-option label="ADMIN" value="ADMIN" /> <el-option :label="TEXT.enums.userRole.ADMIN" value="ADMIN" />
</el-select> </el-select>
<span v-if="scope.row.effectiveStatus === 'DISABLED_GLOBAL'" class="hint">该账号已在系统级被禁用无法在任何项目中使用</span> <span v-if="scope.row.effectiveStatus === 'DISABLED_GLOBAL'" class="hint">{{ TEXT.modules.adminProjectMembers.disabledGlobalDesc }}</span>
<el-button link type="danger" size="small" @click="onDelete(scope.row)">删除</el-button> <el-button link type="danger" size="small" @click="onDelete(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
<el-button <el-button
link link
:type="scope.row.is_active ? 'danger' : 'primary'" :type="scope.row.is_active ? 'danger' : 'primary'"
@@ -57,17 +57,17 @@
:disabled="scope.row.effectiveStatus === 'DISABLED_GLOBAL'" :disabled="scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
@click="toggleActive(scope.row)" @click="toggleActive(scope.row)"
> >
{{ scope.row.is_active ? "停用" : "启用" }} {{ scope.row.is_active ? TEXT.common.actions.disable : TEXT.common.actions.enable }}
</el-button> </el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</el-card> </el-card>
<el-dialog title="新增成员" width="520px" v-model="addVisible" :close-on-click-modal="false"> <el-dialog :title="TEXT.modules.adminProjectMembers.newTitle" width="520px" v-model="addVisible" :close-on-click-modal="false">
<el-form :model="newMember" label-width="120px" ref="addFormRef" :rules="addRules"> <el-form :model="newMember" label-width="120px" ref="addFormRef" :rules="addRules">
<el-form-item label="用户" prop="user_id"> <el-form-item :label="TEXT.modules.adminProjectMembers.user" prop="user_id">
<el-select v-model="newMember.user_id" filterable placeholder="请选择用户"> <el-select v-model="newMember.user_id" filterable :placeholder="TEXT.modules.adminProjectMembers.userPlaceholder">
<el-option <el-option
v-for="user in availableUsers" v-for="user in availableUsers"
:key="user.id" :key="user.id"
@@ -77,19 +77,19 @@
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="角色" prop="role_in_study"> <el-form-item :label="TEXT.modules.adminProjectMembers.projectRole" prop="role_in_study">
<el-select v-model="newMember.role_in_study" placeholder="请选择角色"> <el-select v-model="newMember.role_in_study" :placeholder="TEXT.modules.adminProjectMembers.rolePlaceholder">
<el-option label="PM" value="PM" /> <el-option :label="TEXT.enums.userRole.PM" value="PM" />
<el-option label="CRA" value="CRA" /> <el-option :label="TEXT.enums.userRole.CRA" value="CRA" />
<el-option label="PV" value="PV" /> <el-option :label="TEXT.enums.userRole.PV" value="PV" />
<el-option label="IMP" value="IMP" /> <el-option :label="TEXT.enums.userRole.IMP" value="IMP" />
<el-option label="ADMIN" value="ADMIN" /> <el-option :label="TEXT.enums.userRole.ADMIN" value="ADMIN" />
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="addVisible = false">取消</el-button> <el-button @click="addVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="adding" @click="submitAdd">保存</el-button> <el-button type="primary" :loading="adding" @click="submitAdd">{{ TEXT.common.actions.save }}</el-button>
</template> </template>
</el-dialog> </el-dialog>
</div> </div>
@@ -106,7 +106,8 @@ import type { Study, StudyMember, UserInfo } from "../../types/api";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import { evaluateAction } from "../../guards/actionGuard"; import { evaluateAction } from "../../guards/actionGuard";
import { logAudit } from "../../audit"; import { logAudit } from "../../audit";
import { displayDateTime } from "../../utils/display"; import { displayDateTime, displayEnum } from "../../utils/display";
import { TEXT, requiredMessage } from "../../locales";
const route = useRoute(); const route = useRoute();
const projectId = computed(() => route.params.projectId as string); const projectId = computed(() => route.params.projectId as string);
@@ -125,8 +126,8 @@ const newMember = reactive({
}); });
const addRules = reactive<FormRules>({ const addRules = reactive<FormRules>({
user_id: [{ required: true, message: "请选择用户", trigger: "change" }], user_id: [{ required: true, message: requiredMessage(TEXT.modules.adminProjectMembers.user), trigger: "change" }],
role_in_study: [{ required: true, message: "请选择角色", trigger: "change" }], role_in_study: [{ required: true, message: requiredMessage(TEXT.modules.adminProjectMembers.projectRole), trigger: "change" }],
}); });
const loadProject = async () => { const loadProject = async () => {
@@ -146,7 +147,7 @@ const loadMembers = async () => {
const { data } = await listMembers(projectId.value, { limit: 500 }); const { data } = await listMembers(projectId.value, { limit: 500 });
members.value = Array.isArray(data) ? data : data.items || []; members.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "成员列表加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjectMembers.loadFailed);
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -187,7 +188,7 @@ const submitAdd = async () => {
target: { projectId: projectId.value }, target: { projectId: projectId.value },
}); });
if (!decision.allowed) { if (!decision.allowed) {
ElMessage.warning(decision.reason || "无权限执行该操作"); ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
logAudit(decision.auditType, { logAudit(decision.auditType, {
targetId: projectId.value, targetId: projectId.value,
targetName: project.value?.name, targetName: project.value?.name,
@@ -200,7 +201,7 @@ const submitAdd = async () => {
adding.value = true; adding.value = true;
try { try {
await addMember(projectId.value, newMember); await addMember(projectId.value, newMember);
ElMessage.success("成员已添加"); ElMessage.success(TEXT.modules.adminProjectMembers.addSuccess);
addVisible.value = false; addVisible.value = false;
loadMembers(); loadMembers();
logAudit("PROJECT_MEMBER_UPDATED", { logAudit("PROJECT_MEMBER_UPDATED", {
@@ -210,7 +211,7 @@ const submitAdd = async () => {
severity: "normal", severity: "normal",
}); });
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "添加失败"); ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjectMembers.addFailed);
logAudit("PROJECT_MEMBER_UPDATED", { logAudit("PROJECT_MEMBER_UPDATED", {
targetId: projectId.value, targetId: projectId.value,
targetName: project.value?.name, targetName: project.value?.name,
@@ -231,7 +232,7 @@ const updateRole = async (memberId: string, role: string) => {
target: { projectId: projectId.value, memberId }, target: { projectId: projectId.value, memberId },
}); });
if (!decision.allowed) { if (!decision.allowed) {
ElMessage.warning(decision.reason || "无权限执行该操作"); ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
logAudit(decision.auditType, { logAudit(decision.auditType, {
targetId: projectId.value, targetId: projectId.value,
targetName: project.value?.name, targetName: project.value?.name,
@@ -242,7 +243,7 @@ const updateRole = async (memberId: string, role: string) => {
} }
try { try {
await updateMember(projectId.value, memberId, { role_in_study: role }); await updateMember(projectId.value, memberId, { role_in_study: role });
ElMessage.success("角色已更新"); ElMessage.success(TEXT.modules.adminProjectMembers.roleUpdated);
loadMembers(); loadMembers();
logAudit("PROJECT_MEMBER_UPDATED", { logAudit("PROJECT_MEMBER_UPDATED", {
targetId: projectId.value, targetId: projectId.value,
@@ -251,7 +252,7 @@ const updateRole = async (memberId: string, role: string) => {
severity: "normal", severity: "normal",
}); });
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "更新失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.updateFailed);
loadMembers(); loadMembers();
logAudit("PROJECT_MEMBER_UPDATED", { logAudit("PROJECT_MEMBER_UPDATED", {
targetId: projectId.value, targetId: projectId.value,
@@ -271,7 +272,7 @@ const toggleActive = async (row: StudyMember) => {
target: { projectId: projectId.value, memberId: row.id }, target: { projectId: projectId.value, memberId: row.id },
}); });
if (!decision.allowed) { if (!decision.allowed) {
ElMessage.warning(decision.reason || "无权限执行该操作"); ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
logAudit(decision.auditType, { logAudit(decision.auditType, {
targetId: projectId.value, targetId: projectId.value,
targetName: project.value?.name, targetName: project.value?.name,
@@ -281,14 +282,14 @@ const toggleActive = async (row: StudyMember) => {
return; return;
} }
if (row.is_active) { if (row.is_active) {
const ok = await ElMessageBox.confirm("该操作将影响项目成员,请确认是否继续", "停用成员", { const ok = await ElMessageBox.confirm(TEXT.modules.adminProjectMembers.disableConfirm, TEXT.modules.adminProjectMembers.disableTitle, {
type: "warning", type: "warning",
confirmButtonText: "确认", confirmButtonText: TEXT.common.actions.confirm,
}).catch(() => null); }).catch(() => null);
if (!ok) return; if (!ok) return;
try { try {
await updateMember(projectId.value, row.id, { is_active: false }); await updateMember(projectId.value, row.id, { is_active: false });
ElMessage.success("成员已停用"); ElMessage.success(TEXT.modules.adminProjectMembers.disableSuccess);
loadMembers(); loadMembers();
logAudit("PROJECT_MEMBER_UPDATED", { logAudit("PROJECT_MEMBER_UPDATED", {
targetId: projectId.value, targetId: projectId.value,
@@ -298,7 +299,7 @@ const toggleActive = async (row: StudyMember) => {
severity: "normal", severity: "normal",
}); });
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "操作失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.actionFailed);
logAudit("PROJECT_MEMBER_UPDATED", { logAudit("PROJECT_MEMBER_UPDATED", {
targetId: projectId.value, targetId: projectId.value,
targetName: project.value?.name, targetName: project.value?.name,
@@ -311,7 +312,7 @@ const toggleActive = async (row: StudyMember) => {
} else { } else {
try { try {
await updateMember(projectId.value, row.id, { is_active: true }); await updateMember(projectId.value, row.id, { is_active: true });
ElMessage.success("成员已启用"); ElMessage.success(TEXT.modules.adminProjectMembers.enableSuccess);
loadMembers(); loadMembers();
logAudit("PROJECT_MEMBER_UPDATED", { logAudit("PROJECT_MEMBER_UPDATED", {
targetId: projectId.value, targetId: projectId.value,
@@ -321,7 +322,7 @@ const toggleActive = async (row: StudyMember) => {
severity: "normal", severity: "normal",
}); });
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "操作失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.actionFailed);
logAudit("PROJECT_MEMBER_UPDATED", { logAudit("PROJECT_MEMBER_UPDATED", {
targetId: projectId.value, targetId: projectId.value,
targetName: project.value?.name, targetName: project.value?.name,
@@ -342,7 +343,7 @@ const onDelete = async (row: StudyMember) => {
target: { projectId: projectId.value, memberId: row.id }, target: { projectId: projectId.value, memberId: row.id },
}); });
if (!decision.allowed) { if (!decision.allowed) {
ElMessage.warning(decision.reason || "无权限执行该操作"); ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
logAudit(decision.auditType, { logAudit(decision.auditType, {
targetId: projectId.value, targetId: projectId.value,
targetName: project.value?.name, targetName: project.value?.name,
@@ -351,16 +352,16 @@ const onDelete = async (row: StudyMember) => {
}); });
return; return;
} }
const ok = await ElMessageBox.confirm("确认将该账号从本项目移除?账号本身不会被删除。", "删除成员", { const ok = await ElMessageBox.confirm(TEXT.modules.adminProjectMembers.removeConfirm, TEXT.modules.adminProjectMembers.removeTitle, {
type: "warning", type: "warning",
confirmButtonText: "确认", confirmButtonText: TEXT.common.actions.confirm,
cancelButtonText: "取消", cancelButtonText: TEXT.common.actions.cancel,
}).catch(() => null); }).catch(() => null);
if (!ok) return; if (!ok) return;
try { try {
await removeMember(projectId.value, row.id); await removeMember(projectId.value, row.id);
members.value = members.value.filter((m) => m.id !== row.id); members.value = members.value.filter((m) => m.id !== row.id);
ElMessage.success("已从项目移除"); ElMessage.success(TEXT.modules.adminProjectMembers.removeSuccess);
logAudit("PROJECT_MEMBER_UPDATED", { logAudit("PROJECT_MEMBER_UPDATED", {
targetId: projectId.value, targetId: projectId.value,
targetName: project.value?.name, targetName: project.value?.name,
@@ -369,7 +370,7 @@ const onDelete = async (row: StudyMember) => {
severity: "normal", severity: "normal",
}); });
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "删除失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
logAudit("PROJECT_MEMBER_UPDATED", { logAudit("PROJECT_MEMBER_UPDATED", {
targetId: projectId.value, targetId: projectId.value,
targetName: project.value?.name, targetName: project.value?.name,
+16 -19
View File
@@ -3,37 +3,37 @@
<el-card> <el-card>
<div class="header"> <div class="header">
<div> <div>
<h3>项目治理</h3> <h3>{{ TEXT.modules.adminProjects.title }}</h3>
<p class="sub">项目是独立业务实体账号加入项目后才成为项目成员角色在成员配置中设置</p> <p class="sub">{{ TEXT.modules.adminProjects.subtitle }}</p>
</div> </div>
<el-button type="primary" @click="openCreate">新建项目</el-button> <el-button type="primary" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjects.projectLabel }}</el-button>
</div> </div>
<el-table :data="projects" v-loading="loading" stripe> <el-table :data="projects" v-loading="loading" stripe>
<el-table-column prop="code" label="项目编号" width="140"> <el-table-column prop="code" :label="TEXT.modules.adminProjects.projectCode" width="140">
<template #default="scope"> <template #default="scope">
<el-link type="primary" @click="goDetail(scope.row)">{{ scope.row.code }}</el-link> <el-link type="primary" @click="goDetail(scope.row)">{{ scope.row.code }}</el-link>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="name" label="项目名称" min-width="200"> <el-table-column prop="name" :label="TEXT.common.fields.projectName" min-width="200">
<template #default="scope"> <template #default="scope">
<el-link type="primary" @click="goDetail(scope.row)">{{ scope.row.name }}</el-link> <el-link type="primary" @click="goDetail(scope.row)">{{ scope.row.name }}</el-link>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="sponsor" label="申办方" min-width="160" /> <el-table-column prop="sponsor" :label="TEXT.common.fields.sponsor" min-width="160" />
<el-table-column prop="status" label="状态" width="120"> <el-table-column prop="status" :label="TEXT.common.fields.status" width="120">
<template #default="scope"> <template #default="scope">
<el-tag :type="statusTag(scope.row.status)">{{ statusLabel(scope.row.status) }}</el-tag> <el-tag :type="statusTag(scope.row.status)">{{ statusLabel(scope.row.status) }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="created_at" label="创建时间" width="200"> <el-table-column prop="created_at" :label="TEXT.modules.adminUsers.createdAt" width="200">
<template #default="scope">{{ displayDateTime(scope.row.created_at) }}</template> <template #default="scope">{{ displayDateTime(scope.row.created_at) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="320" fixed="right"> <el-table-column :label="TEXT.common.labels.actions" width="320" fixed="right">
<template #default="scope"> <template #default="scope">
<el-button link type="primary" size="small" @click="openEdit(scope.row)">编辑</el-button> <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)">项目账号/成员配置</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)">中心管理</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)">进入项目</el-button> <el-button link type="success" size="small" @click="enterStudy(scope.row)">{{ TEXT.modules.adminProjects.enter }}</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -51,6 +51,7 @@ import type { Study } from "../../types/api";
import ProjectForm from "./ProjectForm.vue"; import ProjectForm from "./ProjectForm.vue";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { displayDateTime } from "../../utils/display"; import { displayDateTime } from "../../utils/display";
import { TEXT } from "../../locales";
const projects = ref<Study[]>([]); const projects = ref<Study[]>([]);
const loading = ref(false); const loading = ref(false);
@@ -66,7 +67,7 @@ const loadProjects = async () => {
const items = (data as any).items || []; const items = (data as any).items || [];
projects.value = items; projects.value = items;
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "项目列表加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjects.loadFailed);
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -100,11 +101,7 @@ const goDetail = (row: Study) => {
}; };
const statusLabel = (status: string) => const statusLabel = (status: string) =>
({ TEXT.enums.projectStatus[status as keyof typeof TEXT.enums.projectStatus] || status;
DRAFT: "草稿",
ACTIVE: "进行中",
CLOSED: "已关闭",
}[status] || status);
const statusTag = (status: string) => const statusTag = (status: string) =>
({ ({
+11 -10
View File
@@ -1,17 +1,17 @@
<template> <template>
<el-dialog title="CRA 绑定" width="520px" v-model="visibleProxy" :close-on-click-modal="false"> <el-dialog :title="TEXT.modules.adminSites.craBindTitle" width="520px" v-model="visibleProxy" :close-on-click-modal="false">
<div class="tip">中心{{ site?.name }}</div> <div class="tip">{{ TEXT.modules.adminSites.sitePrefix }}{{ site?.name }}</div>
<el-form label-width="120px"> <el-form label-width="120px">
<el-form-item label="选择 CRA"> <el-form-item :label="TEXT.modules.adminSites.craSelect">
<el-select v-model="selectedCras" multiple filterable placeholder="请选择 CRA 账号" style="width: 100%"> <el-select v-model="selectedCras" multiple filterable :placeholder="TEXT.modules.adminSites.craSelectPlaceholder" style="width: 100%">
<el-option v-for="user in craUsers" :key="user.id" :label="user.username" :value="user.id" /> <el-option v-for="user in craUsers" :key="user.id" :label="user.username" :value="user.id" />
</el-select> </el-select>
<div class="hint">支持多选保存后将以用户名写入中心联系人字段</div> <div class="hint">{{ TEXT.modules.adminSites.craHint }}</div>
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="visibleProxy = false">取消</el-button> <el-button @click="visibleProxy = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="saving" @click="onSave">保存绑定</el-button> <el-button type="primary" :loading="saving" @click="onSave">{{ TEXT.modules.adminSites.craSave }}</el-button>
</template> </template>
</el-dialog> </el-dialog>
</template> </template>
@@ -24,6 +24,7 @@ import type { Site, UserInfo } from "../../types/api";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import { evaluateAction } from "../../guards/actionGuard"; import { evaluateAction } from "../../guards/actionGuard";
import { logAudit } from "../../audit"; import { logAudit } from "../../audit";
import { TEXT } from "../../locales";
const props = defineProps<{ const props = defineProps<{
visible: boolean; visible: boolean;
@@ -83,7 +84,7 @@ const onSave = async () => {
target: { siteId: props.site.id, studyId: props.studyId }, target: { siteId: props.site.id, studyId: props.studyId },
}); });
if (!decision.allowed) { if (!decision.allowed) {
ElMessage.warning(decision.reason || "无权限执行该操作"); ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
logAudit(decision.auditType, { logAudit(decision.auditType, {
targetId: props.site.id, targetId: props.site.id,
targetName: props.site.name, targetName: props.site.name,
@@ -98,7 +99,7 @@ const onSave = async () => {
.map((id) => props.craUsers.find((u) => u.id === id)?.username || id) .map((id) => props.craUsers.find((u) => u.id === id)?.username || id)
.filter(Boolean); .filter(Boolean);
await updateSite(props.studyId, props.site.id, { contact: names.join(",") }); await updateSite(props.studyId, props.site.id, { contact: names.join(",") });
ElMessage.success("绑定已保存"); ElMessage.success(TEXT.modules.adminSites.craSaveSuccess);
emit("saved"); emit("saved");
visibleProxy.value = false; visibleProxy.value = false;
logAudit("SITE_CRA_BOUND", { logAudit("SITE_CRA_BOUND", {
@@ -109,7 +110,7 @@ const onSave = async () => {
severity: "normal", severity: "normal",
}); });
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
logAudit("SITE_CRA_BOUND", { logAudit("SITE_CRA_BOUND", {
targetId: props.site.id, targetId: props.site.id,
targetName: props.site.name, targetName: props.site.name,
+21 -20
View File
@@ -1,24 +1,24 @@
<template> <template>
<el-dialog :title="site ? '编辑中心' : '新建中心'" width="520px" v-model="visibleProxy" :close-on-click-modal="false"> <el-dialog :title="site ? TEXT.modules.adminSites.editTitle : TEXT.modules.adminSites.newTitle" width="520px" v-model="visibleProxy" :close-on-click-modal="false">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px"> <el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form-item label="中心名称" prop="name"> <el-form-item :label="TEXT.common.fields.siteName" prop="name">
<el-input v-model="form.name" placeholder="请输入中心名称" /> <el-input v-model="form.name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.siteName" />
</el-form-item> </el-form-item>
<el-form-item label="城市" prop="city"> <el-form-item :label="TEXT.common.fields.city" prop="city">
<el-input v-model="form.city" placeholder="所在城市(可选)" /> <el-input v-model="form.city" :placeholder="TEXT.modules.adminSites.cityPlaceholder" />
</el-form-item> </el-form-item>
<el-form-item label="PI" prop="pi_name"> <el-form-item :label="TEXT.modules.adminSites.piLabel" prop="pi_name">
<el-input v-model="form.pi_name" placeholder="PI(可选)" /> <el-input v-model="form.pi_name" :placeholder="TEXT.modules.adminSites.piPlaceholder" />
</el-form-item> </el-form-item>
<el-form-item label="电话" prop="phone"> <el-form-item :label="TEXT.common.fields.phone" prop="phone">
<el-input v-model="form.phone" placeholder="电话(可选)" /> <el-input v-model="form.phone" :placeholder="TEXT.modules.adminSites.phonePlaceholder" />
</el-form-item> </el-form-item>
<el-form-item label="负责人" prop="contact"> <el-form-item :label="TEXT.common.fields.contact" prop="contact">
<el-select <el-select
v-model="form.craSelections" v-model="form.craSelections"
multiple multiple
filterable filterable
placeholder="选择项目成员作为负责人,可多选" :placeholder="TEXT.modules.adminSites.contactPlaceholder"
> >
<el-option <el-option
v-for="user in craOptions" v-for="user in craOptions"
@@ -28,13 +28,13 @@
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item v-if="site" label="状态" prop="is_active"> <el-form-item v-if="site" :label="TEXT.common.fields.status" prop="is_active">
<el-switch v-model="form.is_active" active-text="启用" inactive-text="停用" /> <el-switch v-model="form.is_active" :active-text="TEXT.common.actions.enable" :inactive-text="TEXT.common.actions.disable" />
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="visibleProxy = false">取消</el-button> <el-button @click="visibleProxy = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">保存</el-button> <el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
</template> </template>
</el-dialog> </el-dialog>
</template> </template>
@@ -47,6 +47,7 @@ import type { Site } from "../../types/api";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import { evaluateAction } from "../../guards/actionGuard"; import { evaluateAction } from "../../guards/actionGuard";
import { logAudit } from "../../audit"; import { logAudit } from "../../audit";
import { TEXT, requiredMessage } from "../../locales";
const props = defineProps<{ const props = defineProps<{
visible: boolean; visible: boolean;
@@ -80,7 +81,7 @@ const form = reactive({
}); });
const rules = reactive<FormRules>({ const rules = reactive<FormRules>({
name: [{ required: true, message: "请输入中心名称", trigger: "blur" }], name: [{ required: true, message: requiredMessage(TEXT.common.fields.siteName), trigger: "blur" }],
}); });
const craOptions = computed(() => { const craOptions = computed(() => {
@@ -138,7 +139,7 @@ const onSubmit = async () => {
target: { siteId: props.site?.id, studyId: props.studyId }, target: { siteId: props.site?.id, studyId: props.studyId },
}); });
if (!decision.allowed) { if (!decision.allowed) {
ElMessage.warning(decision.reason || "无权限执行该操作"); ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
logAudit(decision.auditType, { logAudit(decision.auditType, {
targetId: props.site?.id || props.studyId, targetId: props.site?.id || props.studyId,
targetName: props.site?.name, targetName: props.site?.name,
@@ -159,7 +160,7 @@ const onSubmit = async () => {
contact, contact,
is_active: form.is_active, is_active: form.is_active,
}); });
ElMessage.success("中心已更新"); ElMessage.success(TEXT.modules.adminSites.updateSuccess);
logAudit("SITE_STATUS_CHANGED", { logAudit("SITE_STATUS_CHANGED", {
targetId: props.site.id, targetId: props.site.id,
targetName: props.site.name, targetName: props.site.name,
@@ -173,7 +174,7 @@ const onSubmit = async () => {
pi_name: form.pi_name, pi_name: form.pi_name,
contact, contact,
}); });
ElMessage.success("中心已创建"); ElMessage.success(TEXT.modules.adminSites.createSuccess);
logAudit("SITE_STATUS_CHANGED", { logAudit("SITE_STATUS_CHANGED", {
targetId: props.studyId, targetId: props.studyId,
targetName: form.name, targetName: form.name,
@@ -184,7 +185,7 @@ const onSubmit = async () => {
emit("saved"); emit("saved");
visibleProxy.value = false; visibleProxy.value = false;
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
logAudit("SITE_STATUS_CHANGED", { logAudit("SITE_STATUS_CHANGED", {
targetId: props.site?.id || props.studyId, targetId: props.site?.id || props.studyId,
targetName: props.site?.name || form.name, targetName: props.site?.name || form.name,
+24 -23
View File
@@ -3,41 +3,41 @@
<el-card> <el-card>
<div class="header"> <div class="header">
<div> <div>
<h3>中心管理</h3> <h3>{{ TEXT.modules.adminSites.title }}</h3>
<div class="sub" v-if="project">项目{{ project.code }} - {{ project.name }}</div> <div class="sub" v-if="project">{{ TEXT.modules.adminSites.projectPrefix }}{{ project.code }} - {{ project.name }}</div>
</div> </div>
<el-button type="primary" @click="openCreate">新建中心</el-button> <el-button type="primary" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminSites.siteLabel }}</el-button>
</div> </div>
<el-table :data="sites" v-loading="loading" stripe> <el-table :data="sites" v-loading="loading" stripe>
<el-table-column prop="name" label="中心名称" min-width="180" /> <el-table-column prop="name" :label="TEXT.common.fields.siteName" min-width="180" />
<el-table-column prop="city" label="城市" width="140" /> <el-table-column prop="city" :label="TEXT.common.fields.city" width="140" />
<el-table-column prop="pi_name" label="PI" width="160" /> <el-table-column prop="pi_name" :label="TEXT.modules.adminSites.piLabel" width="160" />
<el-table-column label="电话" width="160"> <el-table-column :label="TEXT.common.fields.phone" width="160">
<template #default="scope"> <template #default="scope">
{{ phoneText(scope.row) }} {{ phoneText(scope.row) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="contact" label="负责人" min-width="180"> <el-table-column prop="contact" :label="TEXT.common.fields.contact" min-width="180">
<template #default="scope"> <template #default="scope">
{{ contactLabel(scope.row) }} {{ contactLabel(scope.row) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="状态" width="120"> <el-table-column :label="TEXT.common.fields.status" width="120">
<template #default="scope"> <template #default="scope">
<el-tag type="success" v-if="scope.row.is_active">启用</el-tag> <el-tag type="success" v-if="scope.row.is_active">{{ TEXT.common.actions.enable }}</el-tag>
<el-tag type="danger" v-else>停用</el-tag> <el-tag type="danger" v-else>{{ TEXT.common.actions.disable }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="220" fixed="right"> <el-table-column :label="TEXT.common.labels.actions" width="220" fixed="right">
<template #default="scope"> <template #default="scope">
<el-button link type="primary" size="small" @click="openEdit(scope.row)">编辑</el-button> <el-button link type="primary" size="small" @click="openEdit(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
<el-button <el-button
link link
:type="scope.row.is_active ? 'danger' : 'primary'" :type="scope.row.is_active ? 'danger' : 'primary'"
size="small" size="small"
@click="toggleSite(scope.row)" @click="toggleSite(scope.row)"
> >
{{ scope.row.is_active ? "停用" : "启用" }} {{ scope.row.is_active ? TEXT.common.actions.disable : TEXT.common.actions.enable }}
</el-button> </el-button>
</template> </template>
</el-table-column> </el-table-column>
@@ -68,6 +68,7 @@ import type { Site, Study, UserInfo } from "../../types/api";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import { evaluateAction } from "../../guards/actionGuard"; import { evaluateAction } from "../../guards/actionGuard";
import { logAudit } from "../../audit"; import { logAudit } from "../../audit";
import { TEXT } from "../../locales";
const route = useRoute(); const route = useRoute();
const projectId = computed(() => route.params.projectId as string); const projectId = computed(() => route.params.projectId as string);
@@ -98,7 +99,7 @@ const loadSites = async () => {
const { data } = await fetchSites(projectId.value); const { data } = await fetchSites(projectId.value);
sites.value = Array.isArray(data) ? data : (data as any).items || []; sites.value = Array.isArray(data) ? data : (data as any).items || [];
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "中心列表加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.modules.adminSites.loadFailed);
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -135,16 +136,16 @@ const memberNameMap = computed(() => {
}); });
const contactLabel = (row: any) => { const contactLabel = (row: any) => {
if (!row?.contact) return "—"; if (!row?.contact) return TEXT.common.fallback;
return String(row.contact) return String(row.contact)
.split(",") .split(",")
.map((c) => c.trim()) .map((c) => c.trim())
.filter(Boolean) .filter(Boolean)
.map((c) => memberNameMap.value[c] || c) .map((c) => memberNameMap.value[c] || c)
.join("、") || "—"; .join("、") || TEXT.common.fallback;
}; };
const phoneText = (row: any) => row?.phone || row?.contact_phone || "—"; const phoneText = (row: any) => row?.phone || row?.contact_phone || TEXT.common.fallback;
const openCreate = () => { const openCreate = () => {
loadUsers(); loadUsers();
@@ -168,7 +169,7 @@ const toggleSite = async (row: Site) => {
target: { siteId: row.id, studyId: projectId.value }, target: { siteId: row.id, studyId: projectId.value },
}); });
if (!decision.allowed) { if (!decision.allowed) {
ElMessage.warning(decision.reason || "无权限执行该操作"); ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
logAudit(decision.auditType, { logAudit(decision.auditType, {
targetId: row.id, targetId: row.id,
targetName: row.name, targetName: row.name,
@@ -178,15 +179,15 @@ const toggleSite = async (row: Site) => {
return; return;
} }
if (row.is_active) { if (row.is_active) {
const ok = await ElMessageBox.confirm("该操作将影响中心数据,请确认是否继续", "停用中心", { const ok = await ElMessageBox.confirm(TEXT.modules.adminSites.disableConfirm, TEXT.modules.adminSites.disableTitle, {
type: "warning", type: "warning",
confirmButtonText: "确认", confirmButtonText: TEXT.common.actions.confirm,
}).catch(() => null); }).catch(() => null);
if (!ok) return; if (!ok) return;
} }
try { try {
await updateSite(projectId.value, row.id, { is_active: !row.is_active }); await updateSite(projectId.value, row.id, { is_active: !row.is_active });
ElMessage.success(row.is_active ? "已停用" : "已启用"); ElMessage.success(row.is_active ? TEXT.modules.adminSites.disableSuccess : TEXT.modules.adminSites.enableSuccess);
loadSites(); loadSites();
logAudit("SITE_STATUS_CHANGED", { logAudit("SITE_STATUS_CHANGED", {
targetId: row.id, targetId: row.id,
@@ -196,7 +197,7 @@ const toggleSite = async (row: Site) => {
severity: "normal", severity: "normal",
}); });
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "操作失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.actionFailed);
logAudit("SITE_STATUS_CHANGED", { logAudit("SITE_STATUS_CHANGED", {
targetId: row.id, targetId: row.id,
targetName: row.name, targetName: row.name,
+23 -22
View File
@@ -1,25 +1,25 @@
<template> <template>
<el-dialog :title="user ? '编辑用户' : '新建用户'" width="520px" v-model="visibleProxy" :close-on-click-modal="false"> <el-dialog :title="user ? TEXT.modules.adminUsers.editTitle : TEXT.modules.adminUsers.newTitle" width="520px" v-model="visibleProxy" :close-on-click-modal="false">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px"> <el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form-item label="邮箱" prop="email"> <el-form-item :label="TEXT.common.fields.email" prop="email">
<el-input v-model="form.email" :disabled="!!user" placeholder="请输入邮箱" /> <el-input v-model="form.email" :disabled="!!user" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.email" />
</el-form-item> </el-form-item>
<el-form-item label="姓名" prop="full_name"> <el-form-item :label="TEXT.common.fields.name" prop="full_name">
<el-input v-model="form.full_name" placeholder="请输入姓名" /> <el-input v-model="form.full_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.name" />
</el-form-item> </el-form-item>
<el-form-item label="部门" prop="department"> <el-form-item :label="TEXT.common.fields.department" prop="department">
<el-input v-model="form.department" placeholder="请输入部门" /> <el-input v-model="form.department" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.department" />
</el-form-item> </el-form-item>
<el-form-item :label="user ? '新密码(可选)' : '初始密码'" prop="password"> <el-form-item :label="user ? TEXT.modules.adminUsers.passwordOptional : TEXT.modules.adminUsers.passwordInitial" prop="password">
<el-input v-model="form.password" type="password" show-password :placeholder="user ? '留空则不修改密码' : '请输入初始密码'" /> <el-input v-model="form.password" type="password" show-password :placeholder="user ? TEXT.modules.adminUsers.passwordOptionalHint : TEXT.modules.adminUsers.passwordInitialHint" />
</el-form-item> </el-form-item>
<el-form-item v-if="user" label="账号状态" prop="is_active"> <el-form-item v-if="user" :label="TEXT.modules.adminUsers.accountStatus" prop="is_active">
<el-switch v-model="form.is_active" active-text="启用" inactive-text="禁用" :disabled="isLastAdmin" /> <el-switch v-model="form.is_active" :active-text="TEXT.common.actions.enable" :inactive-text="TEXT.common.actions.disable" :disabled="isLastAdmin" />
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="visibleProxy = false">取消</el-button> <el-button @click="visibleProxy = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">保存</el-button> <el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
</template> </template>
</el-dialog> </el-dialog>
</template> </template>
@@ -29,6 +29,7 @@ import { computed, reactive, ref, watch } from "vue";
import { ElMessage, type FormInstance, type FormRules } from "element-plus"; import { ElMessage, type FormInstance, type FormRules } from "element-plus";
import { createUser, updateUser } from "../../api/users"; import { createUser, updateUser } from "../../api/users";
import type { UserInfo } from "../../types/api"; import type { UserInfo } from "../../types/api";
import { TEXT, requiredMessage } from "../../locales";
const props = defineProps<{ const props = defineProps<{
visible: boolean; visible: boolean;
@@ -60,16 +61,16 @@ const defaultRole = "PV";
const rules = reactive<FormRules>({ const rules = reactive<FormRules>({
email: [ email: [
{ required: true, message: "请输入邮箱", trigger: "blur" }, { required: true, message: requiredMessage(TEXT.common.fields.email), trigger: "blur" },
{ type: "email", message: "邮箱格式不正确", trigger: "blur" }, { type: "email", message: TEXT.common.validation.invalidEmail, trigger: "blur" },
], ],
full_name: [{ required: true, message: "请输入姓名", trigger: "blur" }], full_name: [{ required: true, message: requiredMessage(TEXT.common.fields.name), trigger: "blur" }],
department: [{ required: true, message: "请输入部门", trigger: "blur" }], department: [{ required: true, message: requiredMessage(TEXT.common.fields.department), trigger: "blur" }],
password: [ password: [
{ {
validator: (_rule, value, callback) => { validator: (_rule, value, callback) => {
if (!props.user && !value) { if (!props.user && !value) {
callback(new Error("请输入初始密码")); callback(new Error(TEXT.modules.adminUsers.passwordInitialRequired));
return; return;
} }
callback(); callback();
@@ -113,7 +114,7 @@ const onSubmit = async () => {
if (!formRef.value) return; if (!formRef.value) return;
await formRef.value.validate(); await formRef.value.validate();
if (props.user?.role === "ADMIN" && (props.adminCount || 0) <= 1 && !form.is_active) { if (props.user?.role === "ADMIN" && (props.adminCount || 0) <= 1 && !form.is_active) {
ElMessage.warning("至少保留一个管理员账号,不能禁用该账号"); ElMessage.warning(TEXT.modules.adminUsers.keepAdminWarning);
return; return;
} }
submitting.value = true; submitting.value = true;
@@ -125,7 +126,7 @@ const onSubmit = async () => {
department: form.department, department: form.department,
password: form.password || undefined, password: form.password || undefined,
}); });
ElMessage.success("用户已更新"); ElMessage.success(TEXT.modules.adminUsers.updateSuccess);
} else { } else {
await createUser({ await createUser({
email: form.email, email: form.email,
@@ -134,12 +135,12 @@ const onSubmit = async () => {
role: defaultRole, role: defaultRole,
password: form.password, password: form.password,
}); });
ElMessage.success("用户已创建"); ElMessage.success(TEXT.modules.adminUsers.createSuccess);
} }
emit("saved"); emit("saved");
visibleProxy.value = false; visibleProxy.value = false;
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally { } finally {
submitting.value = false; submitting.value = false;
} }
+27 -26
View File
@@ -1,24 +1,24 @@
<template> <template>
<el-dialog title="重置密码" width="520px" v-model="visibleProxy" :close-on-click-modal="false"> <el-dialog :title="TEXT.modules.adminUsers.resetTitle" width="520px" v-model="visibleProxy" :close-on-click-modal="false">
<el-alert <el-alert
type="warning" type="warning"
show-icon show-icon
title="该操作将影响用户账号,请确认是否继续" :title="TEXT.modules.adminUsers.resetWarningTitle"
description="系统将生成临时密码,需再次输入用户名才能提交。" :description="TEXT.modules.adminUsers.resetWarningDesc"
class="mb-12" class="mb-12"
/> />
<div class="info">重置对象<strong>{{ user?.username }}</strong></div> <div class="info">{{ TEXT.modules.adminUsers.resetTarget }}<strong>{{ user?.username }}</strong></div>
<el-form ref="formRef" :model="form" label-width="140px" :rules="rules"> <el-form ref="formRef" :model="form" label-width="140px" :rules="rules">
<el-form-item label="确认用户名" prop="confirmUsername"> <el-form-item :label="TEXT.modules.adminUsers.confirmUsername" prop="confirmUsername">
<el-input v-model="form.confirmUsername" placeholder="请输入用户名以确认" /> <el-input v-model="form.confirmUsername" :placeholder="TEXT.modules.adminUsers.confirmUsernameHint" />
</el-form-item> </el-form-item>
<el-form-item label="密码方式" prop="mode"> <el-form-item :label="TEXT.modules.adminUsers.passwordMode" prop="mode">
<el-radio-group v-model="form.mode"> <el-radio-group v-model="form.mode">
<el-radio label="auto">系统生成临时密码</el-radio> <el-radio label="auto">{{ TEXT.modules.adminUsers.passwordAuto }}</el-radio>
<el-radio label="manual">手动输入新密码</el-radio> <el-radio label="manual">{{ TEXT.modules.adminUsers.passwordManual }}</el-radio>
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
<el-form-item :label="form.mode === 'auto' ? '临时密码' : '新密码'" prop="passwordValue"> <el-form-item :label="form.mode === 'auto' ? TEXT.modules.adminUsers.tempPassword : TEXT.modules.adminUsers.newPassword" prop="passwordValue">
<el-input <el-input
v-if="form.mode === 'auto'" v-if="form.mode === 'auto'"
v-model="form.tempPassword" v-model="form.tempPassword"
@@ -27,7 +27,7 @@
readonly readonly
> >
<template #append> <template #append>
<el-button @click="regenerate">重新生成</el-button> <el-button @click="regenerate">{{ TEXT.modules.adminUsers.regenerate }}</el-button>
</template> </template>
</el-input> </el-input>
<el-input <el-input
@@ -35,14 +35,14 @@
v-model="form.manualPassword" v-model="form.manualPassword"
type="password" type="password"
show-password show-password
placeholder="请输入新密码" :placeholder="TEXT.modules.adminUsers.manualPasswordHint"
/> />
<div class="hint">不会明文展示请通过安全渠道告知用户</div> <div class="hint">{{ TEXT.modules.adminUsers.passwordHint }}</div>
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="visibleProxy = false">取消</el-button> <el-button @click="visibleProxy = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="danger" :disabled="!canSubmit" :loading="submitting" @click="onSubmit">确认重置</el-button> <el-button type="danger" :disabled="!canSubmit" :loading="submitting" @click="onSubmit">{{ TEXT.modules.adminUsers.resetConfirm }}</el-button>
</template> </template>
</el-dialog> </el-dialog>
</template> </template>
@@ -52,6 +52,7 @@ import { computed, reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus"; import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import { updateUser } from "../../api/users"; import { updateUser } from "../../api/users";
import type { UserInfo } from "../../types/api"; import type { UserInfo } from "../../types/api";
import { TEXT, requiredMessage } from "../../locales";
const props = defineProps<{ const props = defineProps<{
visible: boolean; visible: boolean;
@@ -78,17 +79,17 @@ const form = reactive({
}); });
const rules = reactive<FormRules>({ const rules = reactive<FormRules>({
confirmUsername: [{ required: true, message: "请再次输入用户名", trigger: "blur" }], confirmUsername: [{ required: true, message: requiredMessage(TEXT.modules.adminUsers.confirmUsername), trigger: "blur" }],
mode: [{ required: true, message: "请选择密码方式", trigger: "change" }], mode: [{ required: true, message: requiredMessage(TEXT.modules.adminUsers.passwordMode), trigger: "change" }],
passwordValue: [ passwordValue: [
{ {
validator: (_rule, _value, callback) => { validator: (_rule, _value, callback) => {
if (form.mode === "auto" && !form.tempPassword) { if (form.mode === "auto" && !form.tempPassword) {
callback(new Error("临时密码生成异常")); callback(new Error(TEXT.modules.adminUsers.tempPasswordError));
return; return;
} }
if (form.mode === "manual" && !form.manualPassword) { if (form.mode === "manual" && !form.manualPassword) {
callback(new Error("请输入新密码")); callback(new Error(TEXT.modules.adminUsers.manualPasswordRequired));
return; return;
} }
callback(); callback();
@@ -127,16 +128,16 @@ const onSubmit = async () => {
if (!props.user) return; if (!props.user) return;
await formRef.value?.validate(); await formRef.value?.validate();
if (form.confirmUsername !== props.user.username) { if (form.confirmUsername !== props.user.username) {
ElMessage.error("确认用户名不匹配,无法提交"); ElMessage.error(TEXT.modules.adminUsers.confirmMismatch);
return; return;
} }
const ok = await ElMessageBox.confirm( const ok = await ElMessageBox.confirm(
"该操作将影响用户账号,请确认是否继续", TEXT.modules.adminUsers.resetConfirmPrompt,
"确认重置密码", TEXT.modules.adminUsers.resetConfirmTitle,
{ {
type: "warning", type: "warning",
confirmButtonText: "确认重置", confirmButtonText: TEXT.modules.adminUsers.resetConfirm,
cancelButtonText: "取消", cancelButtonText: TEXT.common.actions.cancel,
} }
).catch(() => null); ).catch(() => null);
if (!ok) return; if (!ok) return;
@@ -144,11 +145,11 @@ const onSubmit = async () => {
try { try {
const password = form.mode === "manual" ? form.manualPassword : form.tempPassword; const password = form.mode === "manual" ? form.manualPassword : form.tempPassword;
await updateUser(props.user.id, { password }); await updateUser(props.user.id, { password });
ElMessage.success("密码已重置"); ElMessage.success(TEXT.modules.adminUsers.resetSuccess);
emit("reset"); emit("reset");
visibleProxy.value = false; visibleProxy.value = false;
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "重置失败"); ElMessage.error(e?.response?.data?.message || TEXT.modules.adminUsers.resetFailed);
} finally { } finally {
submitting.value = false; submitting.value = false;
} }
+35 -34
View File
@@ -3,28 +3,28 @@
<el-card> <el-card>
<div class="header"> <div class="header">
<div> <div>
<h3>账号治理系统登录账号</h3> <h3>{{ TEXT.modules.adminUsers.title }}</h3>
<p class="sub">账号用于登录系统本身不具备项目或角色权限</p> <p class="sub">{{ TEXT.modules.adminUsers.subtitle }}</p>
</div> </div>
<el-button type="primary" @click="openCreate">新建用户</el-button> <el-button type="primary" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminUsers.userLabel }}</el-button>
</div> </div>
<el-table :data="users" stripe v-loading="loading" style="width: 100%"> <el-table :data="users" stripe v-loading="loading" style="width: 100%">
<el-table-column prop="email" label="邮箱" min-width="200" /> <el-table-column prop="email" :label="TEXT.common.fields.email" min-width="200" />
<el-table-column prop="full_name" label="姓名" min-width="140" /> <el-table-column prop="full_name" :label="TEXT.common.fields.name" min-width="140" />
<el-table-column prop="department" label="部门" min-width="140" /> <el-table-column prop="department" :label="TEXT.common.fields.department" min-width="140" />
<el-table-column label="状态" width="140"> <el-table-column :label="TEXT.common.fields.status" width="140">
<template #default="scope"> <template #default="scope">
<el-tag :type="statusType(scope.row.status)"> <el-tag :type="statusType(scope.row.status)">
{{ statusLabel(scope.row.status) }} {{ statusLabel(scope.row.status) }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="created_at" label="创建时间" width="200"> <el-table-column prop="created_at" :label="TEXT.modules.adminUsers.createdAt" width="200">
<template #default="scope">{{ displayDateTime(scope.row.created_at) }}</template> <template #default="scope">{{ displayDateTime(scope.row.created_at) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="320" fixed="right"> <el-table-column :label="TEXT.common.labels.actions" width="320" fixed="right">
<template #default="scope"> <template #default="scope">
<el-button link type="primary" size="small" @click="openEdit(scope.row)">编辑</el-button> <el-button link type="primary" size="small" @click="openEdit(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
<el-button <el-button
link link
:type="scope.row.status === 'ACTIVE' ? 'danger' : 'primary'" :type="scope.row.status === 'ACTIVE' ? 'danger' : 'primary'"
@@ -32,9 +32,9 @@
:disabled="isLastAdmin(scope.row)" :disabled="isLastAdmin(scope.row)"
@click="toggleStatus(scope.row)" @click="toggleStatus(scope.row)"
> >
{{ scope.row.status === 'ACTIVE' ? "禁用" : "启用" }} {{ scope.row.status === 'ACTIVE' ? TEXT.common.actions.disable : TEXT.common.actions.enable }}
</el-button> </el-button>
<el-button link type="danger" size="small" @click="openReset(scope.row)">重置密码</el-button> <el-button link type="danger" size="small" @click="openReset(scope.row)">{{ TEXT.modules.adminUsers.resetPassword }}</el-button>
<el-button <el-button
link link
type="danger" type="danger"
@@ -42,7 +42,7 @@
:disabled="scope.row.id === auth.user?.id" :disabled="scope.row.id === auth.user?.id"
@click="onDelete(scope.row)" @click="onDelete(scope.row)"
> >
删除 {{ TEXT.common.actions.delete }}
</el-button> </el-button>
</template> </template>
</el-table-column> </el-table-column>
@@ -72,6 +72,7 @@ import UserForm from "./UserForm.vue";
import UserResetPassword from "./UserResetPassword.vue"; import UserResetPassword from "./UserResetPassword.vue";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import { displayDateTime } from "../../utils/display"; import { displayDateTime } from "../../utils/display";
import { TEXT } from "../../locales";
const users = ref<UserInfo[]>([]); const users = ref<UserInfo[]>([]);
const total = ref(0); const total = ref(0);
@@ -98,7 +99,7 @@ const loadUsers = async () => {
total.value = (data as any).total || items.length; total.value = (data as any).total || items.length;
activeAdminCount.value = allItems.filter((u: UserInfo) => u.role === "ADMIN" && u.status === "ACTIVE").length; activeAdminCount.value = allItems.filter((u: UserInfo) => u.role === "ADMIN" && u.status === "ACTIVE").length;
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "用户列表加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.modules.adminUsers.loadFailed);
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -116,26 +117,26 @@ const openEdit = (row: UserInfo) => {
const toggleStatus = async (row: UserInfo) => { const toggleStatus = async (row: UserInfo) => {
if (isLastAdmin(row)) { if (isLastAdmin(row)) {
ElMessage.warning("至少保留一个管理员账号,不能禁用该账号"); ElMessage.warning(TEXT.modules.adminUsers.keepAdminWarning);
return; return;
} }
const disable = row.status === "ACTIVE"; const disable = row.status === "ACTIVE";
const ok = await ElMessageBox.confirm( const ok = await ElMessageBox.confirm(
disable ? "该操作将影响用户账号,请确认是否继续" : "确认启用该用户账号?", disable ? TEXT.modules.adminUsers.disableConfirm : TEXT.modules.adminUsers.enableConfirm,
disable ? "禁用账号" : "启用账号", disable ? TEXT.modules.adminUsers.disableTitle : TEXT.modules.adminUsers.enableTitle,
{ {
type: disable ? "warning" : "info", type: disable ? "warning" : "info",
confirmButtonText: "确认", confirmButtonText: TEXT.common.actions.confirm,
cancelButtonText: "取消", cancelButtonText: TEXT.common.actions.cancel,
} }
).catch(() => null); ).catch(() => null);
if (!ok) return; if (!ok) return;
try { try {
await updateUser(row.id, { status: disable ? "DISABLED" : "ACTIVE", is_active: !disable }); await updateUser(row.id, { status: disable ? "DISABLED" : "ACTIVE", is_active: !disable });
ElMessage.success(disable ? "已禁用" : "已启用"); ElMessage.success(disable ? TEXT.modules.adminUsers.disableSuccess : TEXT.modules.adminUsers.enableSuccess);
loadUsers(); loadUsers();
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "操作失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.actionFailed);
} }
}; };
@@ -146,28 +147,28 @@ const openReset = (row: UserInfo) => {
const onDelete = async (row: UserInfo) => { const onDelete = async (row: UserInfo) => {
const { value, action } = await ElMessageBox.prompt( const { value, action } = await ElMessageBox.prompt(
"删除后账号将无法登录且无法恢复,需先在各项目成员配置中移除该账号。请输入当前管理员密码确认删除。", TEXT.modules.adminUsers.deleteConfirm,
"危险操作:删除账号", TEXT.modules.adminUsers.deleteTitle,
{ {
inputType: "password", inputType: "password",
inputPlaceholder: "请输入 admin 密码", inputPlaceholder: TEXT.modules.adminUsers.deletePasswordPlaceholder,
confirmButtonText: "确认删除", confirmButtonText: TEXT.modules.adminUsers.deleteConfirmButton,
confirmButtonClass: "el-button--danger", confirmButtonClass: "el-button--danger",
cancelButtonText: "取消", cancelButtonText: TEXT.common.actions.cancel,
} }
).catch(() => ({ action: "cancel", value: "" })); ).catch(() => ({ action: "cancel", value: "" }));
if (action !== "confirm" || !value) return; if (action !== "confirm" || !value) return;
try { try {
await deleteUser(row.id, { admin_password: value }); await deleteUser(row.id, { admin_password: value });
ElMessage.success("账号已删除"); ElMessage.success(TEXT.modules.adminUsers.deleteSuccess);
loadUsers(); loadUsers();
} catch (e: any) { } catch (e: any) {
const detail = e?.response?.data?.detail || e?.response?.data?.message; const detail = e?.response?.data?.detail || e?.response?.data?.message;
if (detail && detail.includes("项目成员")) { if (detail && detail.includes(TEXT.modules.adminProjectMembers.memberLabel)) {
ElMessage.closeAll(); ElMessage.closeAll();
ElMessage.error(detail); ElMessage.error(detail);
} else { } else {
ElMessage.error(detail || "删除失败"); ElMessage.error(detail || TEXT.modules.adminUsers.deleteFailed);
} }
} }
}; };
@@ -192,15 +193,15 @@ const statusType = (status: string) => {
const statusLabel = (status: string) => { const statusLabel = (status: string) => {
switch (status) { switch (status) {
case "ACTIVE": case "ACTIVE":
return "启用"; return TEXT.enums.userStatus.ACTIVE;
case "PENDING": case "PENDING":
return "待审核"; return TEXT.enums.userStatus.PENDING;
case "REJECTED": case "REJECTED":
return "已拒绝"; return TEXT.enums.userStatus.REJECTED;
case "DISABLED": case "DISABLED":
return "已停用"; return TEXT.enums.userStatus.DISABLED;
default: default:
return status || "未知"; return status || TEXT.common.fallback;
} }
}; };
+17 -16
View File
@@ -2,27 +2,27 @@
<div class="page"> <div class="page">
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">运输记录详情</h1> <h1 class="page-title">{{ TEXT.modules.drugShipments.detailTitle }}</h1>
<p class="page-subtitle">查看运输/流向信息与附件</p> <p class="page-subtitle">{{ TEXT.modules.drugShipments.detailSubtitle }}</p>
</div> </div>
<div class="actions"> <div class="actions">
<el-button type="primary" @click="goEdit">编辑</el-button> <el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
<el-button @click="goBack">返回</el-button> <el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div> </div>
</div> </div>
<el-card v-loading="loading"> <el-card v-loading="loading">
<el-descriptions :column="2" border> <el-descriptions :column="2" border>
<el-descriptions-item label="类型">{{ detail.direction || "—" }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.direction">{{ displayEnum(TEXT.enums.shipmentDirection, detail.direction) }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ detail.status || "—" }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.status">{{ displayEnum(TEXT.enums.shipmentStatus, detail.status) }}</el-descriptions-item>
<el-descriptions-item label="分中心">{{ detail.site_name || "—" }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.site">{{ detail.site_name || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="日期">{{ displayDate(detail.ship_date) }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.shipDate">{{ displayDate(detail.ship_date) }}</el-descriptions-item>
<el-descriptions-item label="快递公司">{{ detail.carrier || "—" }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.carrier">{{ detail.carrier || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="运单号">{{ detail.tracking_no || "—" }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.trackingNo">{{ detail.tracking_no || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="发出方">{{ detail.from_party || "—" }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.fromParty">{{ detail.from_party || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="接收方">{{ detail.to_party || "—" }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.toParty">{{ detail.to_party || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="物品描述" :span="2">{{ detail.drug_desc || "—" }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.drugDesc" :span="2">{{ detail.drug_desc || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ detail.remark || "—" }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.remark" :span="2">{{ detail.remark || TEXT.common.fallback }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
</el-card> </el-card>
@@ -41,8 +41,9 @@ import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { getDrugShipment } from "../../api/drugShipments"; import { getDrugShipment } from "../../api/drugShipments";
import { displayDate } from "../../utils/display"; import { displayDate, displayEnum } from "../../utils/display";
import AttachmentList from "../../components/attachments/AttachmentList.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import { TEXT } from "../../locales";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -71,7 +72,7 @@ const load = async () => {
const { data } = await getDrugShipment(studyId, shipmentId); const { data } = await getDrugShipment(studyId, shipmentId);
Object.assign(detail, data); Object.assign(detail, data);
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally { } finally {
loading.value = false; loading.value = false;
} }
+42 -41
View File
@@ -2,56 +2,56 @@
<div class="page"> <div class="page">
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">{{ isEdit ? "编辑运输记录" : "新增运输记录" }}</h1> <h1 class="page-title">{{ isEdit ? TEXT.modules.drugShipments.editTitle : TEXT.modules.drugShipments.newTitle }}</h1>
<p class="page-subtitle">记录寄送/回收流向</p> <p class="page-subtitle">{{ TEXT.modules.drugShipments.formSubtitle }}</p>
</div> </div>
<el-button @click="goBack">返回</el-button> <el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div> </div>
<el-card> <el-card>
<el-form :model="form" label-width="120px"> <el-form :model="form" label-width="120px">
<el-form-item label="类型" required> <el-form-item :label="TEXT.common.fields.direction" required>
<el-select v-model="form.direction" placeholder="选择类型"> <el-select v-model="form.direction" :placeholder="TEXT.common.placeholders.select">
<el-option label="寄送" value="寄送" /> <el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" />
<el-option label="回收" value="回收" /> <el-option :label="TEXT.enums.shipmentDirection.RETURN" value="RETURN" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="分中心" required> <el-form-item :label="TEXT.common.fields.site" required>
<el-input v-model="form.site_name" placeholder="输入分中心/机构名称" /> <el-input v-model="form.site_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
</el-form-item> </el-form-item>
<el-form-item label="物品描述" required> <el-form-item :label="TEXT.common.fields.drugDesc" required>
<el-input v-model="form.drug_desc" type="textarea" :rows="3" placeholder="药品名称/规格/数量等" /> <el-input v-model="form.drug_desc" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
</el-form-item> </el-form-item>
<el-form-item label="日期"> <el-form-item :label="TEXT.common.fields.shipDate">
<el-date-picker v-model="form.ship_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" /> <el-date-picker v-model="form.ship_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
</el-form-item> </el-form-item>
<el-form-item label="快递公司"> <el-form-item :label="TEXT.common.fields.carrier">
<el-input v-model="form.carrier" placeholder="输入快递公司" /> <el-input v-model="form.carrier" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.carrier" />
</el-form-item> </el-form-item>
<el-form-item label="运单号"> <el-form-item :label="TEXT.common.fields.trackingNo">
<el-input v-model="form.tracking_no" placeholder="输入运单号" /> <el-input v-model="form.tracking_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.trackingNo" />
</el-form-item> </el-form-item>
<el-form-item label="发出方"> <el-form-item :label="TEXT.common.fields.fromParty">
<el-input v-model="form.from_party" placeholder="输入发出方" /> <el-input v-model="form.from_party" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.fromParty" />
</el-form-item> </el-form-item>
<el-form-item label="接收方"> <el-form-item :label="TEXT.common.fields.toParty">
<el-input v-model="form.to_party" placeholder="输入接收方" /> <el-input v-model="form.to_party" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.toParty" />
</el-form-item> </el-form-item>
<el-form-item label="状态" required> <el-form-item :label="TEXT.common.fields.status" required>
<el-select v-model="form.status" placeholder="选择状态"> <el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select">
<el-option label="待寄出" value="待寄出" /> <el-option :label="TEXT.enums.shipmentStatus.PENDING" value="PENDING" />
<el-option label="运输中" value="运输中" /> <el-option :label="TEXT.enums.shipmentStatus.IN_TRANSIT" value="IN_TRANSIT" />
<el-option label="已签收" value="已签收" /> <el-option :label="TEXT.enums.shipmentStatus.SIGNED" value="SIGNED" />
<el-option label="已回收" value="已回收" /> <el-option :label="TEXT.enums.shipmentStatus.RETURNED" value="RETURNED" />
<el-option label="异常" value="异常" /> <el-option :label="TEXT.enums.shipmentStatus.EXCEPTION" value="EXCEPTION" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="备注"> <el-form-item :label="TEXT.common.fields.remark">
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="补充说明" /> <el-input v-model="form.remark" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" :loading="saving" @click="submit">保存</el-button> <el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button>
<el-button @click="goBack">取消</el-button> <el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </el-card>
@@ -63,7 +63,7 @@
:entity-id="shipmentId" :entity-id="shipmentId"
/> />
<el-card v-else class="hint-card"> <el-card v-else class="hint-card">
<StateEmpty description="保存后可上传交接材料" /> <StateEmpty :description="TEXT.modules.drugShipments.uploadHint" />
</el-card> </el-card>
</div> </div>
</template> </template>
@@ -76,6 +76,7 @@ import { useStudyStore } from "../../store/study";
import { createDrugShipment, getDrugShipment, updateDrugShipment } from "../../api/drugShipments"; import { createDrugShipment, getDrugShipment, updateDrugShipment } from "../../api/drugShipments";
import AttachmentList from "../../components/attachments/AttachmentList.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -87,7 +88,7 @@ const isEdit = computed(() => !!shipmentId.value);
const studyId = computed(() => study.currentStudy?.id || ""); const studyId = computed(() => study.currentStudy?.id || "");
const form = reactive({ const form = reactive({
direction: "寄送", direction: "SEND",
site_name: "", site_name: "",
drug_desc: "", drug_desc: "",
ship_date: "", ship_date: "",
@@ -95,7 +96,7 @@ const form = reactive({
tracking_no: "", tracking_no: "",
from_party: "", from_party: "",
to_party: "", to_party: "",
status: "待寄出", status: "PENDING",
remark: "", remark: "",
}); });
@@ -104,7 +105,7 @@ const load = async () => {
try { try {
const { data } = await getDrugShipment(studyId.value, shipmentId.value); const { data } = await getDrugShipment(studyId.value, shipmentId.value);
Object.assign(form, { Object.assign(form, {
direction: data.direction || "寄送", direction: data.direction || "SEND",
site_name: data.site_name || "", site_name: data.site_name || "",
drug_desc: data.drug_desc || "", drug_desc: data.drug_desc || "",
ship_date: data.ship_date || "", ship_date: data.ship_date || "",
@@ -112,11 +113,11 @@ const load = async () => {
tracking_no: data.tracking_no || "", tracking_no: data.tracking_no || "",
from_party: data.from_party || "", from_party: data.from_party || "",
to_party: data.to_party || "", to_party: data.to_party || "",
status: data.status || "待寄出", status: data.status || "PENDING",
remark: data.remark || "", remark: data.remark || "",
}); });
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} }
}; };
@@ -138,15 +139,15 @@ const submit = async () => {
}; };
if (isEdit.value && shipmentId.value) { if (isEdit.value && shipmentId.value) {
await updateDrugShipment(studyId.value, shipmentId.value, payload); await updateDrugShipment(studyId.value, shipmentId.value, payload);
ElMessage.success("已保存"); ElMessage.success(TEXT.common.messages.saveSuccess);
router.push(`/drug/shipments/${shipmentId.value}`); router.push(`/drug/shipments/${shipmentId.value}`);
} else { } else {
const { data } = await createDrugShipment(studyId.value, payload); const { data } = await createDrugShipment(studyId.value, payload);
ElMessage.success("已创建"); ElMessage.success(TEXT.common.messages.createSuccess);
router.push(`/drug/shipments/${data.id}`); router.push(`/drug/shipments/${data.id}`);
} }
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally { } finally {
saving.value = false; saving.value = false;
} }
+12 -11
View File
@@ -2,24 +2,24 @@
<div class="page"> <div class="page">
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">合同费用详情</h1> <h1 class="page-title">{{ TEXT.modules.financeContracts.detailTitle }}</h1>
<p class="page-subtitle">查看合同费用与附件</p> <p class="page-subtitle">{{ TEXT.modules.financeContracts.detailSubtitle }}</p>
</div> </div>
<div class="actions"> <div class="actions">
<el-button type="primary" @click="goEdit">编辑</el-button> <el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
<el-button @click="goBack">返回</el-button> <el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div> </div>
</div> </div>
<el-card v-loading="loading"> <el-card v-loading="loading">
<el-descriptions :column="2" border> <el-descriptions :column="2" border>
<el-descriptions-item label="分中心">{{ detail.site_name || "—" }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.site">{{ detail.site_name || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="合同编号">{{ detail.contract_no || "—" }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.contractNo">{{ detail.contract_no || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="签署日期">{{ displayDate(detail.signed_date) }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.signedDate">{{ displayDate(detail.signed_date) }}</el-descriptions-item>
<el-descriptions-item label="合同金额"> <el-descriptions-item :label="TEXT.common.fields.amount">
{{ detail.amount }} {{ detail.currency || "CNY" }} {{ detail.amount }} {{ detail.currency || TEXT.common.fields.currencyDefault }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ detail.remark || "—" }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.remark" :span="2">{{ detail.remark || TEXT.common.fallback }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
</el-card> </el-card>
@@ -40,6 +40,7 @@ import { useStudyStore } from "../../store/study";
import { getFinanceContract } from "../../api/financeContracts"; import { getFinanceContract } from "../../api/financeContracts";
import { displayDate } from "../../utils/display"; import { displayDate } from "../../utils/display";
import AttachmentList from "../../components/attachments/AttachmentList.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import { TEXT } from "../../locales";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -64,7 +65,7 @@ const load = async () => {
const { data } = await getFinanceContract(studyId, contractId); const { data } = await getFinanceContract(studyId, contractId);
Object.assign(detail, data); Object.assign(detail, data);
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally { } finally {
loading.value = false; loading.value = false;
} }
+22 -21
View File
@@ -2,39 +2,39 @@
<div class="page"> <div class="page">
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">{{ isEdit ? "编辑合同费用" : "新增合同费用" }}</h1> <h1 class="page-title">{{ isEdit ? TEXT.modules.financeContracts.editTitle : TEXT.modules.financeContracts.newTitle }}</h1>
<p class="page-subtitle">填写分中心合同信息并保存</p> <p class="page-subtitle">{{ TEXT.modules.financeContracts.formSubtitle }}</p>
</div> </div>
<el-button @click="goBack">返回</el-button> <el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div> </div>
<el-card> <el-card>
<el-form ref="formRef" :model="form" label-width="110px"> <el-form ref="formRef" :model="form" label-width="110px">
<el-form-item label="分中心" prop="site_name" required> <el-form-item :label="TEXT.common.fields.site" prop="site_name" required>
<el-input v-model="form.site_name" placeholder="输入分中心/机构名称" /> <el-input v-model="form.site_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
</el-form-item> </el-form-item>
<el-form-item label="合同编号" prop="contract_no" required> <el-form-item :label="TEXT.common.fields.contractNo" prop="contract_no" required>
<el-input v-model="form.contract_no" placeholder="输入合同编号" /> <el-input v-model="form.contract_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.contractNo" />
</el-form-item> </el-form-item>
<el-form-item label="签署日期"> <el-form-item :label="TEXT.common.fields.signedDate">
<el-date-picker v-model="form.signed_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" /> <el-date-picker v-model="form.signed_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
</el-form-item> </el-form-item>
<el-form-item label="合同金额" prop="amount" required> <el-form-item :label="TEXT.common.fields.amount" prop="amount" required>
<el-input-number v-model="form.amount" :min="0" :precision="2" :step="1000" /> <el-input-number v-model="form.amount" :min="0" :precision="2" :step="1000" />
</el-form-item> </el-form-item>
<el-form-item label="币种"> <el-form-item :label="TEXT.common.fields.currency">
<el-select v-model="form.currency" placeholder="选择币种"> <el-select v-model="form.currency" :placeholder="TEXT.common.placeholders.select">
<el-option label="CNY" value="CNY" /> <el-option label="CNY" value="CNY" />
<el-option label="USD" value="USD" /> <el-option label="USD" value="USD" />
<el-option label="EUR" value="EUR" /> <el-option label="EUR" value="EUR" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="备注"> <el-form-item :label="TEXT.common.fields.remark">
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="补充说明" /> <el-input v-model="form.remark" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" :loading="saving" @click="submit">保存</el-button> <el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button>
<el-button @click="goBack">取消</el-button> <el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </el-card>
@@ -46,7 +46,7 @@
:entity-id="contractId" :entity-id="contractId"
/> />
<el-card v-else class="hint-card"> <el-card v-else class="hint-card">
<StateEmpty description="保存后可上传合同附件" /> <StateEmpty :description="TEXT.modules.financeContracts.uploadHint" />
</el-card> </el-card>
</div> </div>
</template> </template>
@@ -59,6 +59,7 @@ import { useStudyStore } from "../../store/study";
import { createFinanceContract, getFinanceContract, updateFinanceContract } from "../../api/financeContracts"; import { createFinanceContract, getFinanceContract, updateFinanceContract } from "../../api/financeContracts";
import AttachmentList from "../../components/attachments/AttachmentList.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -91,7 +92,7 @@ const load = async () => {
remark: data.remark || "", remark: data.remark || "",
}); });
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} }
}; };
@@ -109,15 +110,15 @@ const submit = async () => {
}; };
if (isEdit.value && contractId.value) { if (isEdit.value && contractId.value) {
await updateFinanceContract(studyId.value, contractId.value, payload); await updateFinanceContract(studyId.value, contractId.value, payload);
ElMessage.success("已保存"); ElMessage.success(TEXT.common.messages.saveSuccess);
router.push(`/finance/contracts/${contractId.value}`); router.push(`/finance/contracts/${contractId.value}`);
} else { } else {
const { data } = await createFinanceContract(studyId.value, payload); const { data } = await createFinanceContract(studyId.value, payload);
ElMessage.success("已创建"); ElMessage.success(TEXT.common.messages.createSuccess);
router.push(`/finance/contracts/${data.id}`); router.push(`/finance/contracts/${data.id}`);
} }
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally { } finally {
saving.value = false; saving.value = false;
} }
+13 -12
View File
@@ -2,23 +2,23 @@
<div class="page"> <div class="page">
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">特殊费用详情</h1> <h1 class="page-title">{{ TEXT.modules.financeSpecials.detailTitle }}</h1>
<p class="page-subtitle">查看费用记录与附件</p> <p class="page-subtitle">{{ TEXT.modules.financeSpecials.detailSubtitle }}</p>
</div> </div>
<div class="actions"> <div class="actions">
<el-button type="primary" @click="goEdit">编辑</el-button> <el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
<el-button @click="goBack">返回</el-button> <el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div> </div>
</div> </div>
<el-card v-loading="loading"> <el-card v-loading="loading">
<el-descriptions :column="2" border> <el-descriptions :column="2" border>
<el-descriptions-item label="分中心">{{ detail.site_name || "—" }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.site">{{ detail.site_name || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="费用类型">{{ detail.fee_type || "—" }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.type">{{ displayEnum(TEXT.enums.financeSpecialType, detail.fee_type) }}</el-descriptions-item>
<el-descriptions-item label="发生日期">{{ displayDate(detail.occur_date) }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.occurDate">{{ displayDate(detail.occur_date) }}</el-descriptions-item>
<el-descriptions-item label="人员">{{ detail.staff_name || "—" }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.staff">{{ detail.staff_name || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="金额">{{ detail.amount || "—" }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.amount">{{ detail.amount || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ detail.remark || "—" }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.remark" :span="2">{{ detail.remark || TEXT.common.fallback }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
</el-card> </el-card>
@@ -37,8 +37,9 @@ import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { getFinanceSpecial } from "../../api/financeSpecials"; import { getFinanceSpecial } from "../../api/financeSpecials";
import { displayDate } from "../../utils/display"; import { displayDate, displayEnum } from "../../utils/display";
import AttachmentList from "../../components/attachments/AttachmentList.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import { TEXT } from "../../locales";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -63,7 +64,7 @@ const load = async () => {
const { data } = await getFinanceSpecial(studyId, specialId); const { data } = await getFinanceSpecial(studyId, specialId);
Object.assign(detail, data); Object.assign(detail, data);
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally { } finally {
loading.value = false; loading.value = false;
} }
+28 -27
View File
@@ -2,40 +2,40 @@
<div class="page"> <div class="page">
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">{{ isEdit ? "编辑特殊费用" : "新增特殊费用" }}</h1> <h1 class="page-title">{{ isEdit ? TEXT.modules.financeSpecials.editTitle : TEXT.modules.financeSpecials.newTitle }}</h1>
<p class="page-subtitle">记录差旅住宿等特殊费用</p> <p class="page-subtitle">{{ TEXT.modules.financeSpecials.subtitle }}</p>
</div> </div>
<el-button @click="goBack">返回</el-button> <el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div> </div>
<el-card> <el-card>
<el-form :model="form" label-width="110px"> <el-form :model="form" label-width="110px">
<el-form-item label="分中心" required> <el-form-item :label="TEXT.common.fields.site" required>
<el-input v-model="form.site_name" placeholder="输入分中心/机构名称" /> <el-input v-model="form.site_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
</el-form-item> </el-form-item>
<el-form-item label="费用类型" required> <el-form-item :label="TEXT.common.fields.type" required>
<el-select v-model="form.fee_type" placeholder="选择类型"> <el-select v-model="form.fee_type" :placeholder="TEXT.common.placeholders.select">
<el-option label="差旅" value="差旅" /> <el-option :label="TEXT.enums.financeSpecialType.TRAVEL" value="TRAVEL" />
<el-option label="住宿" value="住宿" /> <el-option :label="TEXT.enums.financeSpecialType.HOTEL" value="HOTEL" />
<el-option label="交通" value="交通" /> <el-option :label="TEXT.enums.financeSpecialType.TRANSPORT" value="TRANSPORT" />
<el-option label="其他" value="其他" /> <el-option :label="TEXT.enums.financeSpecialType.OTHER" value="OTHER" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="金额" required> <el-form-item :label="TEXT.common.fields.amount" required>
<el-input-number v-model="form.amount" :min="0" :precision="2" :step="100" /> <el-input-number v-model="form.amount" :min="0" :precision="2" :step="100" />
</el-form-item> </el-form-item>
<el-form-item label="发生日期"> <el-form-item :label="TEXT.common.fields.occurDate">
<el-date-picker v-model="form.occur_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" /> <el-date-picker v-model="form.occur_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
</el-form-item> </el-form-item>
<el-form-item label="人员"> <el-form-item :label="TEXT.common.fields.staff">
<el-input v-model="form.staff_name" placeholder="输入人员姓名" /> <el-input v-model="form.staff_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.staff" />
</el-form-item> </el-form-item>
<el-form-item label="备注"> <el-form-item :label="TEXT.common.fields.remark">
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="补充说明" /> <el-input v-model="form.remark" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" :loading="saving" @click="submit">保存</el-button> <el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button>
<el-button @click="goBack">取消</el-button> <el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </el-card>
@@ -47,7 +47,7 @@
:entity-id="specialId" :entity-id="specialId"
/> />
<el-card v-else class="hint-card"> <el-card v-else class="hint-card">
<StateEmpty description="保存后可上传凭证附件" /> <StateEmpty :description="TEXT.modules.financeSpecials.uploadHint" />
</el-card> </el-card>
</div> </div>
</template> </template>
@@ -60,6 +60,7 @@ import { useStudyStore } from "../../store/study";
import { createFinanceSpecial, getFinanceSpecial, updateFinanceSpecial } from "../../api/financeSpecials"; import { createFinanceSpecial, getFinanceSpecial, updateFinanceSpecial } from "../../api/financeSpecials";
import AttachmentList from "../../components/attachments/AttachmentList.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -72,7 +73,7 @@ const studyId = computed(() => study.currentStudy?.id || "");
const form = reactive({ const form = reactive({
site_name: "", site_name: "",
fee_type: "差旅", fee_type: "TRAVEL",
amount: 0, amount: 0,
occur_date: "", occur_date: "",
staff_name: "", staff_name: "",
@@ -85,14 +86,14 @@ const load = async () => {
const { data } = await getFinanceSpecial(studyId.value, specialId.value); const { data } = await getFinanceSpecial(studyId.value, specialId.value);
Object.assign(form, { Object.assign(form, {
site_name: data.site_name || "", site_name: data.site_name || "",
fee_type: data.fee_type || "差旅", fee_type: data.fee_type || "TRAVEL",
amount: Number(data.amount || 0), amount: Number(data.amount || 0),
occur_date: data.occur_date || "", occur_date: data.occur_date || "",
staff_name: data.staff_name || "", staff_name: data.staff_name || "",
remark: data.remark || "", remark: data.remark || "",
}); });
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} }
}; };
@@ -110,15 +111,15 @@ const submit = async () => {
}; };
if (isEdit.value && specialId.value) { if (isEdit.value && specialId.value) {
await updateFinanceSpecial(studyId.value, specialId.value, payload); await updateFinanceSpecial(studyId.value, specialId.value, payload);
ElMessage.success("已保存"); ElMessage.success(TEXT.common.messages.saveSuccess);
router.push(`/finance/special/${specialId.value}`); router.push(`/finance/special/${specialId.value}`);
} else { } else {
const { data } = await createFinanceSpecial(studyId.value, payload); const { data } = await createFinanceSpecial(studyId.value, payload);
ElMessage.success("已创建"); ElMessage.success(TEXT.common.messages.createSuccess);
router.push(`/finance/special/${data.id}`); router.push(`/finance/special/${data.id}`);
} }
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally { } finally {
saving.value = false; saving.value = false;
} }
+5 -4
View File
@@ -1,12 +1,13 @@
<template> <template>
<ModulePlaceholder <ModulePlaceholder
title="稽查" :title="TEXT.modules.audit.title"
subtitle="功能建设中" :subtitle="TEXT.modules.audit.subtitle"
list-title="稽查记录列表" :list-title="TEXT.modules.audit.listTitle"
empty-description="稽查模块正在准备基础框架,敬请期待。" :empty-description="TEXT.modules.audit.emptyDescription"
/> />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import ModulePlaceholder from "../../components/ModulePlaceholder.vue"; import ModulePlaceholder from "../../components/ModulePlaceholder.vue";
import { TEXT } from "../../locales";
</script> </script>
+36 -31
View File
@@ -2,54 +2,58 @@
<div class="page"> <div class="page">
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">药品运输/流向</h1> <h1 class="page-title">{{ TEXT.modules.drugShipments.title }}</h1>
<p class="page-subtitle">登记寄送与回收信息</p> <p class="page-subtitle">{{ TEXT.modules.drugShipments.subtitle }}</p>
</div> </div>
<el-button type="primary" @click="goNew">新增运输记录</el-button> <el-button type="primary" @click="goNew">{{ TEXT.modules.drugShipments.newTitle }}</el-button>
</div> </div>
<el-card class="filter-card"> <el-card class="filter-card">
<el-form :inline="true" :model="filters"> <el-form :inline="true" :model="filters">
<el-form-item label="分中心"> <el-form-item :label="TEXT.common.fields.site">
<el-input v-model="filters.site_name" placeholder="输入分中心名称" clearable /> <el-input v-model="filters.site_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" clearable />
</el-form-item> </el-form-item>
<el-form-item label="运单号"> <el-form-item :label="TEXT.common.fields.trackingNo">
<el-input v-model="filters.tracking_no" placeholder="输入运单号" clearable /> <el-input v-model="filters.tracking_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.trackingNo" clearable />
</el-form-item> </el-form-item>
<el-form-item label="状态"> <el-form-item :label="TEXT.common.fields.status">
<el-select v-model="filters.status" placeholder="选择状态" clearable> <el-select v-model="filters.status" :placeholder="TEXT.common.placeholders.select" clearable>
<el-option label="待寄出" value="待寄出" /> <el-option :label="TEXT.enums.shipmentStatus.PENDING" value="PENDING" />
<el-option label="运输中" value="运输中" /> <el-option :label="TEXT.enums.shipmentStatus.IN_TRANSIT" value="IN_TRANSIT" />
<el-option label="已签收" value="已签收" /> <el-option :label="TEXT.enums.shipmentStatus.SIGNED" value="SIGNED" />
<el-option label="已回收" value="已回收" /> <el-option :label="TEXT.enums.shipmentStatus.RETURNED" value="RETURNED" />
<el-option label="异常" value="异常" /> <el-option :label="TEXT.enums.shipmentStatus.EXCEPTION" value="EXCEPTION" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" @click="load">查询</el-button> <el-button type="primary" @click="load">{{ TEXT.common.actions.search }}</el-button>
<el-button @click="resetFilters">重置</el-button> <el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </el-card>
<el-card> <el-card>
<el-table :data="items" v-loading="loading" style="width: 100%"> <el-table :data="items" v-loading="loading" style="width: 100%">
<el-table-column prop="direction" label="类型" width="100" /> <el-table-column prop="direction" :label="TEXT.common.fields.direction" width="100">
<el-table-column prop="site_name" label="分中心" min-width="160" /> <template #default="scope">{{ displayEnum(TEXT.enums.shipmentDirection, scope.row.direction) }}</template>
<el-table-column prop="tracking_no" label="运单号" min-width="160" /> </el-table-column>
<el-table-column prop="ship_date" label="日期" width="140"> <el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" />
<el-table-column prop="tracking_no" :label="TEXT.common.fields.trackingNo" min-width="160" />
<el-table-column prop="ship_date" :label="TEXT.common.fields.shipDate" width="140">
<template #default="scope">{{ displayDate(scope.row.ship_date) }}</template> <template #default="scope">{{ displayDate(scope.row.ship_date) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="status" label="状态" width="120" /> <el-table-column prop="status" :label="TEXT.common.fields.status" width="120">
<el-table-column label="操作" width="200"> <template #default="scope">{{ displayEnum(TEXT.enums.shipmentStatus, scope.row.status) }}</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="200">
<template #default="scope"> <template #default="scope">
<el-button link type="primary" size="small" @click="goDetail(scope.row.id)">详情</el-button> <el-button link type="primary" size="small" @click="goDetail(scope.row.id)">{{ TEXT.common.actions.view }}</el-button>
<el-button link type="primary" size="small" @click="goEdit(scope.row.id)">编辑</el-button> <el-button link type="primary" size="small" @click="goEdit(scope.row.id)">{{ TEXT.common.actions.edit }}</el-button>
<el-button link type="danger" size="small" @click="remove(scope.row)">删除</el-button> <el-button link type="danger" size="small" @click="remove(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<StateEmpty v-if="!loading && items.length === 0" description="暂无运输记录" /> <StateEmpty v-if="!loading && items.length === 0" :description="TEXT.modules.drugShipments.empty" />
</el-card> </el-card>
</div> </div>
</template> </template>
@@ -60,8 +64,9 @@ import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { listDrugShipments, deleteDrugShipment } from "../../api/drugShipments"; import { listDrugShipments, deleteDrugShipment } from "../../api/drugShipments";
import { displayDate } from "../../utils/display"; import { displayDate, displayEnum } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
const router = useRouter(); const router = useRouter();
const study = useStudyStore(); const study = useStudyStore();
@@ -85,7 +90,7 @@ const load = async () => {
}); });
items.value = Array.isArray(data) ? data : data.items || []; items.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -105,14 +110,14 @@ const goEdit = (id: string) => router.push(`/drug/shipments/${id}/edit`);
const remove = async (row: any) => { const remove = async (row: any) => {
const studyId = study.currentStudy?.id; const studyId = study.currentStudy?.id;
if (!studyId) return; if (!studyId) return;
const ok = await ElMessageBox.confirm("确认删除该运输记录?", "提示").catch(() => null); const ok = await ElMessageBox.confirm(TEXT.modules.drugShipments.confirmDelete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return; if (!ok) return;
try { try {
await deleteDrugShipment(studyId, row.id); await deleteDrugShipment(studyId, row.id);
ElMessage.success("已删除"); ElMessage.success(TEXT.common.messages.deleteSuccess);
load(); load();
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "删除失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
} }
}; };
+25 -24
View File
@@ -2,55 +2,55 @@
<div class="page"> <div class="page">
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">合同费用</h1> <h1 class="page-title">{{ TEXT.modules.financeContracts.title }}</h1>
<p class="page-subtitle">维护分中心合同费用与附件</p> <p class="page-subtitle">{{ TEXT.modules.financeContracts.subtitle }}</p>
</div> </div>
<el-button type="primary" @click="goNew">新增合同费用</el-button> <el-button type="primary" @click="goNew">{{ TEXT.modules.financeContracts.newTitle }}</el-button>
</div> </div>
<el-card class="filter-card"> <el-card class="filter-card">
<el-form :inline="true" :model="filters"> <el-form :inline="true" :model="filters">
<el-form-item label="分中心"> <el-form-item :label="TEXT.common.fields.site">
<el-input v-model="filters.site_name" placeholder="输入分中心名称" clearable /> <el-input v-model="filters.site_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" clearable />
</el-form-item> </el-form-item>
<el-form-item label="合同编号"> <el-form-item :label="TEXT.common.fields.contractNo">
<el-input v-model="filters.contract_no" placeholder="输入合同编号" clearable /> <el-input v-model="filters.contract_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.contractNo" clearable />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" @click="load">查询</el-button> <el-button type="primary" @click="load">{{ TEXT.common.actions.search }}</el-button>
<el-button @click="resetFilters">重置</el-button> <el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </el-card>
<el-card> <el-card>
<el-table :data="items" v-loading="loading" style="width: 100%"> <el-table :data="items" v-loading="loading" style="width: 100%">
<el-table-column prop="site_name" label="分中心" min-width="160" /> <el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" />
<el-table-column prop="contract_no" label="合同编号" min-width="160" /> <el-table-column prop="contract_no" :label="TEXT.common.fields.contractNo" min-width="160" />
<el-table-column prop="signed_date" label="签署日期" width="140"> <el-table-column prop="signed_date" :label="TEXT.common.fields.signedDate" width="140">
<template #default="scope"> <template #default="scope">
{{ displayDate(scope.row.signed_date) }} {{ displayDate(scope.row.signed_date) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="金额" width="160"> <el-table-column :label="TEXT.common.fields.amount" width="160">
<template #default="scope"> <template #default="scope">
{{ scope.row.amount }} {{ scope.row.currency || "CNY" }} {{ scope.row.amount }} {{ scope.row.currency || TEXT.common.fields.currencyDefault }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="updated_at" label="更新时间" width="180"> <el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" width="180">
<template #default="scope"> <template #default="scope">
{{ displayDateTime(scope.row.updated_at) }} {{ displayDateTime(scope.row.updated_at) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="200"> <el-table-column :label="TEXT.common.labels.actions" width="200">
<template #default="scope"> <template #default="scope">
<el-button link type="primary" size="small" @click="goDetail(scope.row.id)">详情</el-button> <el-button link type="primary" size="small" @click="goDetail(scope.row.id)">{{ TEXT.common.actions.view }}</el-button>
<el-button link type="primary" size="small" @click="goEdit(scope.row.id)">编辑</el-button> <el-button link type="primary" size="small" @click="goEdit(scope.row.id)">{{ TEXT.common.actions.edit }}</el-button>
<el-button link type="danger" size="small" @click="remove(scope.row)">删除</el-button> <el-button link type="danger" size="small" @click="remove(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<StateEmpty v-if="!loading && items.length === 0" description="暂无合同费用记录" /> <StateEmpty v-if="!loading && items.length === 0" :description="TEXT.modules.financeContracts.empty" />
</el-card> </el-card>
</div> </div>
</template> </template>
@@ -63,6 +63,7 @@ import { useStudyStore } from "../../store/study";
import { listFinanceContracts, deleteFinanceContract } from "../../api/financeContracts"; import { listFinanceContracts, deleteFinanceContract } from "../../api/financeContracts";
import { displayDate, displayDateTime } from "../../utils/display"; import { displayDate, displayDateTime } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
const router = useRouter(); const router = useRouter();
const study = useStudyStore(); const study = useStudyStore();
@@ -84,7 +85,7 @@ const load = async () => {
}); });
items.value = Array.isArray(data) ? data : data.items || []; items.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -103,14 +104,14 @@ const goEdit = (id: string) => router.push(`/finance/contracts/${id}/edit`);
const remove = async (row: any) => { const remove = async (row: any) => {
const studyId = study.currentStudy?.id; const studyId = study.currentStudy?.id;
if (!studyId) return; if (!studyId) return;
const ok = await ElMessageBox.confirm("确认删除该合同费用?", "提示").catch(() => null); const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return; if (!ok) return;
try { try {
await deleteFinanceContract(studyId, row.id); await deleteFinanceContract(studyId, row.id);
ElMessage.success("已删除"); ElMessage.success(TEXT.common.messages.deleteSuccess);
load(); load();
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "删除失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
} }
}; };
+34 -29
View File
@@ -2,61 +2,65 @@
<div class="page"> <div class="page">
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">特殊费用</h1> <h1 class="page-title">{{ TEXT.modules.financeSpecials.title }}</h1>
<p class="page-subtitle">记录差旅住宿等特殊费用</p> <p class="page-subtitle">{{ TEXT.modules.financeSpecials.subtitle }}</p>
</div> </div>
<el-button type="primary" @click="goNew">新增特殊费用</el-button> <el-button type="primary" @click="goNew">{{ TEXT.modules.financeSpecials.newTitle }}</el-button>
</div> </div>
<el-card class="filter-card"> <el-card class="filter-card">
<el-form :inline="true" :model="filters"> <el-form :inline="true" :model="filters">
<el-form-item label="分中心"> <el-form-item :label="TEXT.common.fields.site">
<el-input v-model="filters.site_name" placeholder="输入分中心名称" clearable /> <el-input v-model="filters.site_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" clearable />
</el-form-item> </el-form-item>
<el-form-item label="费用类型"> <el-form-item :label="TEXT.common.fields.type">
<el-select v-model="filters.fee_type" placeholder="选择类型" clearable> <el-select v-model="filters.fee_type" :placeholder="TEXT.common.placeholders.select" clearable>
<el-option label="差旅" value="差旅" /> <el-option :label="TEXT.enums.financeSpecialType.TRAVEL" value="TRAVEL" />
<el-option label="住宿" value="住宿" /> <el-option :label="TEXT.enums.financeSpecialType.HOTEL" value="HOTEL" />
<el-option label="交通" value="交通" /> <el-option :label="TEXT.enums.financeSpecialType.TRANSPORT" value="TRANSPORT" />
<el-option label="其他" value="其他" /> <el-option :label="TEXT.enums.financeSpecialType.OTHER" value="OTHER" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" @click="load">查询</el-button> <el-button type="primary" @click="load">{{ TEXT.common.actions.search }}</el-button>
<el-button @click="resetFilters">重置</el-button> <el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </el-card>
<el-card> <el-card>
<el-table :data="items" v-loading="loading" style="width: 100%"> <el-table :data="items" v-loading="loading" style="width: 100%">
<el-table-column prop="site_name" label="分中心" min-width="160" /> <el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" />
<el-table-column prop="fee_type" label="费用类型" width="120" /> <el-table-column prop="fee_type" :label="TEXT.common.fields.type" width="120">
<el-table-column prop="occur_date" label="发生日期" width="140"> <template #default="scope">
{{ displayEnum(TEXT.enums.financeSpecialType, scope.row.fee_type) }}
</template>
</el-table-column>
<el-table-column prop="occur_date" :label="TEXT.common.fields.occurDate" width="140">
<template #default="scope"> <template #default="scope">
{{ displayDate(scope.row.occur_date) }} {{ displayDate(scope.row.occur_date) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="staff_name" label="人员" min-width="120" /> <el-table-column prop="staff_name" :label="TEXT.common.fields.staff" min-width="120" />
<el-table-column label="金额" width="140"> <el-table-column :label="TEXT.common.fields.amount" width="140">
<template #default="scope"> <template #default="scope">
{{ scope.row.amount }} {{ scope.row.amount }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="updated_at" label="更新时间" width="180"> <el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" width="180">
<template #default="scope"> <template #default="scope">
{{ displayDateTime(scope.row.updated_at) }} {{ displayDateTime(scope.row.updated_at) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="200"> <el-table-column :label="TEXT.common.labels.actions" width="200">
<template #default="scope"> <template #default="scope">
<el-button link type="primary" size="small" @click="goDetail(scope.row.id)">详情</el-button> <el-button link type="primary" size="small" @click="goDetail(scope.row.id)">{{ TEXT.common.actions.view }}</el-button>
<el-button link type="primary" size="small" @click="goEdit(scope.row.id)">编辑</el-button> <el-button link type="primary" size="small" @click="goEdit(scope.row.id)">{{ TEXT.common.actions.edit }}</el-button>
<el-button link type="danger" size="small" @click="remove(scope.row)">删除</el-button> <el-button link type="danger" size="small" @click="remove(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<StateEmpty v-if="!loading && items.length === 0" description="暂无特殊费用记录" /> <StateEmpty v-if="!loading && items.length === 0" :description="TEXT.modules.financeSpecials.empty" />
</el-card> </el-card>
</div> </div>
</template> </template>
@@ -67,8 +71,9 @@ import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { listFinanceSpecials, deleteFinanceSpecial } from "../../api/financeSpecials"; import { listFinanceSpecials, deleteFinanceSpecial } from "../../api/financeSpecials";
import { displayDate, displayDateTime } from "../../utils/display"; import { displayDate, displayDateTime, displayEnum } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
const router = useRouter(); const router = useRouter();
const study = useStudyStore(); const study = useStudyStore();
@@ -90,7 +95,7 @@ const load = async () => {
}); });
items.value = Array.isArray(data) ? data : data.items || []; items.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -109,14 +114,14 @@ const goEdit = (id: string) => router.push(`/finance/special/${id}/edit`);
const remove = async (row: any) => { const remove = async (row: any) => {
const studyId = study.currentStudy?.id; const studyId = study.currentStudy?.id;
if (!studyId) return; if (!studyId) return;
const ok = await ElMessageBox.confirm("确认删除该特殊费用?", "提示").catch(() => null); const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return; if (!ok) return;
try { try {
await deleteFinanceSpecial(studyId, row.id); await deleteFinanceSpecial(studyId, row.id);
ElMessage.success("已删除"); ElMessage.success(TEXT.common.messages.deleteSuccess);
load(); load();
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "删除失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
} }
}; };
+23 -22
View File
@@ -2,44 +2,44 @@
<div class="page"> <div class="page">
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">注意事项</h1> <h1 class="page-title">{{ TEXT.modules.knowledgeNotes.title }}</h1>
<p class="page-subtitle">记录分中心特殊问题与附件</p> <p class="page-subtitle">{{ TEXT.modules.knowledgeNotes.subtitle }}</p>
</div> </div>
<el-button type="primary" @click="goNew">新增注意事项</el-button> <el-button type="primary" @click="goNew">{{ TEXT.modules.knowledgeNotes.newTitle }}</el-button>
</div> </div>
<el-card class="filter-card"> <el-card class="filter-card">
<el-form :inline="true" :model="filters"> <el-form :inline="true" :model="filters">
<el-form-item label="分中心"> <el-form-item :label="TEXT.common.fields.site">
<el-input v-model="filters.site_name" placeholder="输入分中心名称" clearable /> <el-input v-model="filters.site_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" clearable />
</el-form-item> </el-form-item>
<el-form-item label="关键词"> <el-form-item :label="TEXT.common.fields.keyword">
<el-input v-model="filters.keyword" placeholder="标题关键词" clearable /> <el-input v-model="filters.keyword" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.keyword" clearable />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" @click="load">查询</el-button> <el-button type="primary" @click="load">{{ TEXT.common.actions.search }}</el-button>
<el-button @click="resetFilters">重置</el-button> <el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </el-card>
<el-card> <el-card>
<el-table :data="items" v-loading="loading" style="width: 100%"> <el-table :data="items" v-loading="loading" style="width: 100%">
<el-table-column prop="site_name" label="分中心" min-width="160" /> <el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" />
<el-table-column prop="title" label="标题" min-width="200" /> <el-table-column prop="title" :label="TEXT.common.fields.title" min-width="200" />
<el-table-column prop="level" label="重要等级" width="120" /> <el-table-column prop="level" :label="TEXT.common.fields.level" width="120" />
<el-table-column prop="updated_at" label="更新时间" width="180"> <el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" width="180">
<template #default="scope">{{ displayDateTime(scope.row.updated_at) }}</template> <template #default="scope">{{ displayDateTime(scope.row.updated_at) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="200"> <el-table-column :label="TEXT.common.labels.actions" width="200">
<template #default="scope"> <template #default="scope">
<el-button link type="primary" size="small" @click="goDetail(scope.row.id)">详情</el-button> <el-button link type="primary" size="small" @click="goDetail(scope.row.id)">{{ TEXT.common.actions.view }}</el-button>
<el-button link type="primary" size="small" @click="goEdit(scope.row.id)">编辑</el-button> <el-button link type="primary" size="small" @click="goEdit(scope.row.id)">{{ TEXT.common.actions.edit }}</el-button>
<el-button link type="danger" size="small" @click="remove(scope.row)">删除</el-button> <el-button link type="danger" size="small" @click="remove(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<StateEmpty v-if="!loading && items.length === 0" description="暂无注意事项记录" /> <StateEmpty v-if="!loading && items.length === 0" :description="TEXT.modules.knowledgeNotes.empty" />
</el-card> </el-card>
</div> </div>
</template> </template>
@@ -52,6 +52,7 @@ import { useStudyStore } from "../../store/study";
import { listKnowledgeNotes, deleteKnowledgeNote } from "../../api/knowledgeNotes"; import { listKnowledgeNotes, deleteKnowledgeNote } from "../../api/knowledgeNotes";
import { displayDateTime } from "../../utils/display"; import { displayDateTime } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
const router = useRouter(); const router = useRouter();
const study = useStudyStore(); const study = useStudyStore();
@@ -73,7 +74,7 @@ const load = async () => {
}); });
items.value = Array.isArray(data) ? data : data.items || []; items.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -92,14 +93,14 @@ const goEdit = (id: string) => router.push(`/knowledge/notes/${id}/edit`);
const remove = async (row: any) => { const remove = async (row: any) => {
const studyId = study.currentStudy?.id; const studyId = study.currentStudy?.id;
if (!studyId) return; if (!studyId) return;
const ok = await ElMessageBox.confirm("确认删除该注意事项?", "提示").catch(() => null); const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return; if (!ok) return;
try { try {
await deleteKnowledgeNote(studyId, row.id); await deleteKnowledgeNote(studyId, row.id);
ElMessage.success("已删除"); ElMessage.success(TEXT.common.messages.deleteSuccess);
load(); load();
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "删除失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
} }
}; };
@@ -1,12 +1,13 @@
<template> <template>
<ModulePlaceholder <ModulePlaceholder
title="监查" :title="TEXT.modules.monitoring.title"
subtitle="功能建设中" :subtitle="TEXT.modules.monitoring.subtitle"
list-title="监查记录列表" :list-title="TEXT.modules.monitoring.listTitle"
empty-description="监查功能正在搭建基础能力,敬请期待。" :empty-description="TEXT.modules.monitoring.emptyDescription"
/> />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import ModulePlaceholder from "../../components/ModulePlaceholder.vue"; import ModulePlaceholder from "../../components/ModulePlaceholder.vue";
import { TEXT } from "../../locales";
</script> </script>
@@ -2,63 +2,63 @@
<div class="page"> <div class="page">
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">立项与伦理</h1> <h1 class="page-title">{{ TEXT.modules.startupFeasibilityEthics.title }}</h1>
<p class="page-subtitle">记录立项与伦理递交信息</p> <p class="page-subtitle">{{ TEXT.modules.startupFeasibilityEthics.subtitle }}</p>
</div> </div>
</div> </div>
<el-card> <el-card>
<el-tabs v-model="activeTab"> <el-tabs v-model="activeTab">
<el-tab-pane label="立项记录" name="feasibility"> <el-tab-pane :label="TEXT.modules.startupFeasibilityEthics.feasibilityTab" name="feasibility">
<div class="tab-actions"> <div class="tab-actions">
<el-button type="primary" @click="goNewFeasibility">新增立项记录</el-button> <el-button type="primary" @click="goNewFeasibility">{{ TEXT.modules.startupFeasibilityEthics.feasibilityNewTitle }}</el-button>
</div> </div>
<el-table :data="feasibilityItems" v-loading="loadingFeasibility" style="width: 100%"> <el-table :data="feasibilityItems" v-loading="loadingFeasibility" style="width: 100%">
<el-table-column prop="submit_date" label="递交日期" width="140"> <el-table-column prop="submit_date" :label="TEXT.common.fields.submitDate" width="140">
<template #default="scope">{{ displayDate(scope.row.submit_date) }}</template> <template #default="scope">{{ displayDate(scope.row.submit_date) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="accept_date" label="受理日期" width="140"> <el-table-column prop="accept_date" :label="TEXT.common.fields.acceptDate" width="140">
<template #default="scope">{{ displayDate(scope.row.accept_date) }}</template> <template #default="scope">{{ displayDate(scope.row.accept_date) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="approved_date" label="同意日期" width="140"> <el-table-column prop="approved_date" :label="TEXT.common.fields.approvedDate" width="140">
<template #default="scope">{{ displayDate(scope.row.approved_date) }}</template> <template #default="scope">{{ displayDate(scope.row.approved_date) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="project_no" label="项目编号" min-width="160" /> <el-table-column prop="project_no" :label="TEXT.common.fields.projectNo" min-width="160" />
<el-table-column label="操作" width="200"> <el-table-column :label="TEXT.common.labels.actions" width="200">
<template #default="scope"> <template #default="scope">
<el-button link type="primary" size="small" @click="goFeasibilityDetail(scope.row.id)">详情</el-button> <el-button link type="primary" size="small" @click="goFeasibilityDetail(scope.row.id)">{{ TEXT.common.actions.view }}</el-button>
<el-button link type="primary" size="small" @click="goFeasibilityEdit(scope.row.id)">编辑</el-button> <el-button link type="primary" size="small" @click="goFeasibilityEdit(scope.row.id)">{{ TEXT.common.actions.edit }}</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<StateEmpty v-if="!loadingFeasibility && feasibilityItems.length === 0" description="暂无立项记录" /> <StateEmpty v-if="!loadingFeasibility && feasibilityItems.length === 0" :description="TEXT.modules.startupFeasibilityEthics.emptyFeasibility" />
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="伦理记录" name="ethics"> <el-tab-pane :label="TEXT.modules.startupFeasibilityEthics.ethicsTab" name="ethics">
<div class="tab-actions"> <div class="tab-actions">
<el-button type="primary" @click="goNewEthics">新增伦理记录</el-button> <el-button type="primary" @click="goNewEthics">{{ TEXT.modules.startupFeasibilityEthics.ethicsNewTitle }}</el-button>
</div> </div>
<el-table :data="ethicsItems" v-loading="loadingEthics" style="width: 100%"> <el-table :data="ethicsItems" v-loading="loadingEthics" style="width: 100%">
<el-table-column prop="submit_date" label="递交日期" width="140"> <el-table-column prop="submit_date" :label="TEXT.common.fields.submitDate" width="140">
<template #default="scope">{{ displayDate(scope.row.submit_date) }}</template> <template #default="scope">{{ displayDate(scope.row.submit_date) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="accept_date" label="受理日期" width="140"> <el-table-column prop="accept_date" :label="TEXT.common.fields.acceptDate" width="140">
<template #default="scope">{{ displayDate(scope.row.accept_date) }}</template> <template #default="scope">{{ displayDate(scope.row.accept_date) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="meeting_date" label="会议日期" width="140"> <el-table-column prop="meeting_date" :label="TEXT.common.fields.meetingDate" width="140">
<template #default="scope">{{ displayDate(scope.row.meeting_date) }}</template> <template #default="scope">{{ displayDate(scope.row.meeting_date) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="approved_date" label="批准日期" width="140"> <el-table-column prop="approved_date" :label="TEXT.common.fields.approvedDate" width="140">
<template #default="scope">{{ displayDate(scope.row.approved_date) }}</template> <template #default="scope">{{ displayDate(scope.row.approved_date) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="approval_no" label="批件号" min-width="160" /> <el-table-column prop="approval_no" :label="TEXT.common.fields.approvalNo" min-width="160" />
<el-table-column label="操作" width="200"> <el-table-column :label="TEXT.common.labels.actions" width="200">
<template #default="scope"> <template #default="scope">
<el-button link type="primary" size="small" @click="goEthicsDetail(scope.row.id)">详情</el-button> <el-button link type="primary" size="small" @click="goEthicsDetail(scope.row.id)">{{ TEXT.common.actions.view }}</el-button>
<el-button link type="primary" size="small" @click="goEthicsEdit(scope.row.id)">编辑</el-button> <el-button link type="primary" size="small" @click="goEthicsEdit(scope.row.id)">{{ TEXT.common.actions.edit }}</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<StateEmpty v-if="!loadingEthics && ethicsItems.length === 0" description="暂无伦理记录" /> <StateEmpty v-if="!loadingEthics && ethicsItems.length === 0" :description="TEXT.modules.startupFeasibilityEthics.emptyEthics" />
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
</el-card> </el-card>
@@ -73,6 +73,7 @@ import { useStudyStore } from "../../store/study";
import { listFeasibility, listEthics } from "../../api/startup"; import { listFeasibility, listEthics } from "../../api/startup";
import { displayDate } from "../../utils/display"; import { displayDate } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
const router = useRouter(); const router = useRouter();
const study = useStudyStore(); const study = useStudyStore();
@@ -90,7 +91,7 @@ const loadFeasibility = async () => {
const { data } = await listFeasibility(studyId); const { data } = await listFeasibility(studyId);
feasibilityItems.value = Array.isArray(data) ? data : data.items || []; feasibilityItems.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally { } finally {
loadingFeasibility.value = false; loadingFeasibility.value = false;
} }
@@ -104,7 +105,7 @@ const loadEthics = async () => {
const { data } = await listEthics(studyId); const { data } = await listEthics(studyId);
ethicsItems.value = Array.isArray(data) ? data : data.items || []; ethicsItems.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally { } finally {
loadingEthics.value = false; loadingEthics.value = false;
} }
+27 -26
View File
@@ -2,61 +2,61 @@
<div class="page"> <div class="page">
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">启动与授权</h1> <h1 class="page-title">{{ TEXT.modules.startupMeetingAuth.title }}</h1>
<p class="page-subtitle">管理启动会记录与人员培训授权</p> <p class="page-subtitle">{{ TEXT.modules.startupMeetingAuth.subtitle }}</p>
</div> </div>
</div> </div>
<el-card> <el-card>
<el-tabs v-model="activeTab"> <el-tabs v-model="activeTab">
<el-tab-pane label="启动会记录" name="kickoff"> <el-tab-pane :label="TEXT.modules.startupMeetingAuth.kickoffTab" name="kickoff">
<div class="tab-actions"> <div class="tab-actions">
<el-button type="primary" @click="goNewKickoff">新增启动会</el-button> <el-button type="primary" @click="goNewKickoff">{{ TEXT.modules.startupMeetingAuth.kickoffNewTitle }}</el-button>
</div> </div>
<el-table :data="kickoffItems" v-loading="loadingKickoff" style="width: 100%"> <el-table :data="kickoffItems" v-loading="loadingKickoff" style="width: 100%">
<el-table-column prop="kickoff_date" label="启动会日期" width="160"> <el-table-column prop="kickoff_date" :label="TEXT.common.fields.kickoffDate" width="160">
<template #default="scope">{{ displayDate(scope.row.kickoff_date) }}</template> <template #default="scope">{{ displayDate(scope.row.kickoff_date) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="attendees" label="参训人员" min-width="200"> <el-table-column prop="attendees" :label="TEXT.common.fields.attendees" min-width="200">
<template #default="scope"> <template #default="scope">
{{ (scope.row.attendees || []).join("、") || "—" }} {{ (scope.row.attendees || []).join("、") || TEXT.common.fallback }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="200"> <el-table-column :label="TEXT.common.labels.actions" width="200">
<template #default="scope"> <template #default="scope">
<el-button link type="primary" size="small" @click="goKickoffDetail(scope.row.id)">详情</el-button> <el-button link type="primary" size="small" @click="goKickoffDetail(scope.row.id)">{{ TEXT.common.actions.view }}</el-button>
<el-button link type="primary" size="small" @click="goKickoffEdit(scope.row.id)">编辑</el-button> <el-button link type="primary" size="small" @click="goKickoffEdit(scope.row.id)">{{ TEXT.common.actions.edit }}</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<StateEmpty v-if="!loadingKickoff && kickoffItems.length === 0" description="暂无启动会记录" /> <StateEmpty v-if="!loadingKickoff && kickoffItems.length === 0" :description="TEXT.modules.startupMeetingAuth.emptyKickoff" />
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="培训与授权" name="training"> <el-tab-pane :label="TEXT.modules.startupMeetingAuth.trainingTab" name="training">
<div class="tab-actions"> <div class="tab-actions">
<el-button type="primary" @click="goNewTraining">新增人员</el-button> <el-button type="primary" @click="goNewTraining">{{ TEXT.modules.startupMeetingAuth.trainingNewTitle }}</el-button>
</div> </div>
<el-table :data="trainingItems" v-loading="loadingTraining" style="width: 100%"> <el-table :data="trainingItems" v-loading="loadingTraining" style="width: 100%">
<el-table-column prop="name" label="姓名" min-width="140" /> <el-table-column prop="name" :label="TEXT.common.fields.name" min-width="140" />
<el-table-column prop="role" label="角色" min-width="120" /> <el-table-column prop="role" :label="TEXT.common.labels.role" min-width="120" />
<el-table-column prop="site_name" label="分中心" min-width="140" /> <el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="140" />
<el-table-column label="已培训" width="120"> <el-table-column :label="TEXT.common.fields.trained" width="120">
<template #default="scope"> <template #default="scope">
<el-checkbox v-model="scope.row.trained" @change="toggleTraining(scope.row)" /> <el-checkbox v-model="scope.row.trained" @change="toggleTraining(scope.row)" />
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="已授权" width="120"> <el-table-column :label="TEXT.common.fields.authorized" width="120">
<template #default="scope"> <template #default="scope">
<el-checkbox v-model="scope.row.authorized" @change="toggleTraining(scope.row)" /> <el-checkbox v-model="scope.row.authorized" @change="toggleTraining(scope.row)" />
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="200"> <el-table-column :label="TEXT.common.labels.actions" width="200">
<template #default="scope"> <template #default="scope">
<el-button link type="primary" size="small" @click="goTrainingDetail(scope.row.id)">详情</el-button> <el-button link type="primary" size="small" @click="goTrainingDetail(scope.row.id)">{{ TEXT.common.actions.view }}</el-button>
<el-button link type="primary" size="small" @click="goTrainingEdit(scope.row.id)">编辑</el-button> <el-button link type="primary" size="small" @click="goTrainingEdit(scope.row.id)">{{ TEXT.common.actions.edit }}</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<StateEmpty v-if="!loadingTraining && trainingItems.length === 0" description="暂无培训授权人员" /> <StateEmpty v-if="!loadingTraining && trainingItems.length === 0" :description="TEXT.modules.startupMeetingAuth.emptyTraining" />
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
</el-card> </el-card>
@@ -71,6 +71,7 @@ import { useStudyStore } from "../../store/study";
import { listKickoffs, listTrainingAuthorizations, updateTrainingAuthorization } from "../../api/startup"; import { listKickoffs, listTrainingAuthorizations, updateTrainingAuthorization } from "../../api/startup";
import { displayDate } from "../../utils/display"; import { displayDate } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
const router = useRouter(); const router = useRouter();
const study = useStudyStore(); const study = useStudyStore();
@@ -88,7 +89,7 @@ const loadKickoffs = async () => {
const { data } = await listKickoffs(studyId); const { data } = await listKickoffs(studyId);
kickoffItems.value = Array.isArray(data) ? data : data.items || []; kickoffItems.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally { } finally {
loadingKickoff.value = false; loadingKickoff.value = false;
} }
@@ -102,7 +103,7 @@ const loadTraining = async () => {
const { data } = await listTrainingAuthorizations(studyId); const { data } = await listTrainingAuthorizations(studyId);
trainingItems.value = Array.isArray(data) ? data : data.items || []; trainingItems.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally { } finally {
loadingTraining.value = false; loadingTraining.value = false;
} }
@@ -116,9 +117,9 @@ const toggleTraining = async (row: any) => {
trained: row.trained, trained: row.trained,
authorized: row.authorized, authorized: row.authorized,
}); });
ElMessage.success("已保存"); ElMessage.success(TEXT.common.messages.saveSuccess);
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
loadTraining(); loadTraining();
} }
}; };
+27 -24
View File
@@ -2,50 +2,52 @@
<div class="page"> <div class="page">
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">受试者管理</h1> <h1 class="page-title">{{ TEXT.modules.subjectManagement.title }}</h1>
<p class="page-subtitle">受试者访视与 AE 统一管理</p> <p class="page-subtitle">{{ TEXT.modules.subjectManagement.subtitle }}</p>
</div> </div>
<el-button type="primary" @click="goNew">新增受试者</el-button> <el-button type="primary" @click="goNew">{{ TEXT.common.actions.add }}{{ TEXT.modules.subjectManagement.subjectLabel }}</el-button>
</div> </div>
<el-card class="filter-card"> <el-card class="filter-card">
<el-form :inline="true" :model="filters"> <el-form :inline="true" :model="filters">
<el-form-item label="编号"> <el-form-item :label="TEXT.common.fields.subjectNo">
<el-input v-model="filters.subject_no" placeholder="输入受试者编号" clearable /> <el-input v-model="filters.subject_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.subjectNo" clearable />
</el-form-item> </el-form-item>
<el-form-item label="状态"> <el-form-item :label="TEXT.common.fields.status">
<el-select v-model="filters.status" placeholder="选择状态" clearable> <el-select v-model="filters.status" :placeholder="TEXT.common.placeholders.select" clearable>
<el-option label="筛选" value="SCREENING" /> <el-option :label="TEXT.enums.subjectStatus.SCREENING" value="SCREENING" />
<el-option label="入组" value="ENROLLED" /> <el-option :label="TEXT.enums.subjectStatus.ENROLLED" value="ENROLLED" />
<el-option label="完成" value="COMPLETED" /> <el-option :label="TEXT.enums.subjectStatus.COMPLETED" value="COMPLETED" />
<el-option label="退出" value="DROPPED" /> <el-option :label="TEXT.enums.subjectStatus.DROPPED" value="DROPPED" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" @click="load">查询</el-button> <el-button type="primary" @click="load">{{ TEXT.common.actions.search }}</el-button>
<el-button @click="resetFilters">重置</el-button> <el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </el-card>
<el-card> <el-card>
<el-table :data="items" v-loading="loading" style="width: 100%"> <el-table :data="items" v-loading="loading" style="width: 100%">
<el-table-column prop="subject_no" label="受试者编号" min-width="140" /> <el-table-column prop="subject_no" :label="TEXT.common.fields.subjectNo" min-width="140" />
<el-table-column label="分中心" min-width="160"> <el-table-column :label="TEXT.common.fields.site" min-width="160">
<template #default="scope">{{ siteMap[scope.row.site_id] || "—" }}</template> <template #default="scope">{{ siteMap[scope.row.site_id] || TEXT.common.fallback }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="status" label="状态" width="120" /> <el-table-column prop="status" :label="TEXT.common.fields.status" width="120">
<el-table-column prop="enrollment_date" label="入组日期" width="140"> <template #default="scope">{{ displayEnum(TEXT.enums.subjectStatus, scope.row.status) }}</template>
</el-table-column>
<el-table-column prop="enrollment_date" :label="TEXT.common.fields.enrollmentDate" width="140">
<template #default="scope">{{ displayDate(scope.row.enrollment_date) }}</template> <template #default="scope">{{ displayDate(scope.row.enrollment_date) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="160"> <el-table-column :label="TEXT.common.labels.actions" width="160">
<template #default="scope"> <template #default="scope">
<el-button link type="primary" size="small" @click="goDetail(scope.row.id)">详情</el-button> <el-button link type="primary" size="small" @click="goDetail(scope.row.id)">{{ TEXT.common.actions.view }}</el-button>
<el-button link type="primary" size="small" @click="goEdit(scope.row.id)">编辑</el-button> <el-button link type="primary" size="small" @click="goEdit(scope.row.id)">{{ TEXT.common.actions.edit }}</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<StateEmpty v-if="!loading && items.length === 0" description="暂无受试者记录" /> <StateEmpty v-if="!loading && items.length === 0" :description="TEXT.modules.subjectManagement.empty" />
</el-card> </el-card>
</div> </div>
</template> </template>
@@ -57,8 +59,9 @@ import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { fetchSubjects } from "../../api/subjects"; import { fetchSubjects } from "../../api/subjects";
import { fetchSites } from "../../api/sites"; import { fetchSites } from "../../api/sites";
import { displayDate } from "../../utils/display"; import { displayDate, displayEnum } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
const router = useRouter(); const router = useRouter();
const study = useStudyStore(); const study = useStudyStore();
@@ -96,7 +99,7 @@ const load = async () => {
}); });
items.value = Array.isArray(data) ? data : data.items || []; items.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally { } finally {
loading.value = false; loading.value = false;
} }
+10 -9
View File
@@ -2,21 +2,21 @@
<div class="page"> <div class="page">
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">注意事项详情</h1> <h1 class="page-title">{{ TEXT.modules.knowledgeNotes.detailTitle }}</h1>
<p class="page-subtitle">查看注意事项与附件</p> <p class="page-subtitle">{{ TEXT.modules.knowledgeNotes.detailSubtitle }}</p>
</div> </div>
<div class="actions"> <div class="actions">
<el-button type="primary" @click="goEdit">编辑</el-button> <el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
<el-button @click="goBack">返回</el-button> <el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div> </div>
</div> </div>
<el-card v-loading="loading"> <el-card v-loading="loading">
<el-descriptions :column="2" border> <el-descriptions :column="2" border>
<el-descriptions-item label="分中心">{{ detail.site_name || "—" }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.site">{{ detail.site_name || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="重要等级">{{ detail.level || "—" }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.level">{{ detail.level || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="标题" :span="2">{{ detail.title || "—" }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.title" :span="2">{{ detail.title || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="内容" :span="2">{{ detail.content || "—" }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.content" :span="2">{{ detail.content || TEXT.common.fallback }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
</el-card> </el-card>
@@ -36,6 +36,7 @@ import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { getKnowledgeNote } from "../../api/knowledgeNotes"; import { getKnowledgeNote } from "../../api/knowledgeNotes";
import AttachmentList from "../../components/attachments/AttachmentList.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import { TEXT } from "../../locales";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -58,7 +59,7 @@ const load = async () => {
const { data } = await getKnowledgeNote(studyId, noteId); const { data } = await getKnowledgeNote(studyId, noteId);
Object.assign(detail, data); Object.assign(detail, data);
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally { } finally {
loading.value = false; loading.value = false;
} }
+19 -18
View File
@@ -2,29 +2,29 @@
<div class="page"> <div class="page">
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">{{ isEdit ? "编辑注意事项" : "新增注意事项" }}</h1> <h1 class="page-title">{{ isEdit ? TEXT.modules.knowledgeNotes.editTitle : TEXT.modules.knowledgeNotes.newTitle }}</h1>
<p class="page-subtitle">记录分中心特殊问题</p> <p class="page-subtitle">{{ TEXT.modules.knowledgeNotes.formSubtitle }}</p>
</div> </div>
<el-button @click="goBack">返回</el-button> <el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div> </div>
<el-card> <el-card>
<el-form :model="form" label-width="120px"> <el-form :model="form" label-width="120px">
<el-form-item label="分中心" required> <el-form-item :label="TEXT.common.fields.site" required>
<el-input v-model="form.site_name" placeholder="输入分中心/机构名称" /> <el-input v-model="form.site_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
</el-form-item> </el-form-item>
<el-form-item label="标题" required> <el-form-item :label="TEXT.common.fields.title" required>
<el-input v-model="form.title" placeholder="输入标题" /> <el-input v-model="form.title" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.title" />
</el-form-item> </el-form-item>
<el-form-item label="重要等级"> <el-form-item :label="TEXT.common.fields.level">
<el-input v-model="form.level" placeholder="例如 高/中/低" /> <el-input v-model="form.level" :placeholder="TEXT.common.placeholders.input" />
</el-form-item> </el-form-item>
<el-form-item label="内容" required> <el-form-item :label="TEXT.common.fields.content" required>
<el-input v-model="form.content" type="textarea" :rows="4" placeholder="输入内容" /> <el-input v-model="form.content" type="textarea" :rows="4" :placeholder="TEXT.common.placeholders.input" />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" :loading="saving" @click="submit">保存</el-button> <el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button>
<el-button @click="goBack">取消</el-button> <el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </el-card>
@@ -36,7 +36,7 @@
:entity-id="noteId" :entity-id="noteId"
/> />
<el-card v-else class="hint-card"> <el-card v-else class="hint-card">
<StateEmpty description="保存后可上传附件" /> <StateEmpty :description="TEXT.modules.knowledgeNotes.uploadHint" />
</el-card> </el-card>
</div> </div>
</template> </template>
@@ -49,6 +49,7 @@ import { useStudyStore } from "../../store/study";
import { createKnowledgeNote, getKnowledgeNote, updateKnowledgeNote } from "../../api/knowledgeNotes"; import { createKnowledgeNote, getKnowledgeNote, updateKnowledgeNote } from "../../api/knowledgeNotes";
import AttachmentList from "../../components/attachments/AttachmentList.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -77,7 +78,7 @@ const load = async () => {
content: data.content || "", content: data.content || "",
}); });
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} }
}; };
@@ -93,15 +94,15 @@ const submit = async () => {
}; };
if (isEdit.value && noteId.value) { if (isEdit.value && noteId.value) {
await updateKnowledgeNote(studyId.value, noteId.value, payload); await updateKnowledgeNote(studyId.value, noteId.value, payload);
ElMessage.success("已保存"); ElMessage.success(TEXT.common.messages.saveSuccess);
router.push(`/knowledge/notes/${noteId.value}`); router.push(`/knowledge/notes/${noteId.value}`);
} else { } else {
const { data } = await createKnowledgeNote(studyId.value, payload); const { data } = await createKnowledgeNote(studyId.value, payload);
ElMessage.success("已创建"); ElMessage.success(TEXT.common.messages.createSuccess);
router.push(`/knowledge/notes/${data.id}`); router.push(`/knowledge/notes/${data.id}`);
} }
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally { } finally {
saving.value = false; saving.value = false;
} }
+11 -10
View File
@@ -2,22 +2,22 @@
<div class="page"> <div class="page">
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">伦理记录详情</h1> <h1 class="page-title">{{ TEXT.modules.startupFeasibilityEthics.ethicsDetailTitle }}</h1>
<p class="page-subtitle">查看伦理记录与附件</p> <p class="page-subtitle">{{ TEXT.modules.startupFeasibilityEthics.ethicsDetailSubtitle }}</p>
</div> </div>
<div class="actions"> <div class="actions">
<el-button type="primary" @click="goEdit">编辑</el-button> <el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
<el-button @click="goBack">返回</el-button> <el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div> </div>
</div> </div>
<el-card v-loading="loading"> <el-card v-loading="loading">
<el-descriptions :column="2" border> <el-descriptions :column="2" border>
<el-descriptions-item label="递交日期">{{ displayDate(detail.submit_date) }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.submitDate">{{ displayDate(detail.submit_date) }}</el-descriptions-item>
<el-descriptions-item label="受理日期">{{ displayDate(detail.accept_date) }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.acceptDate">{{ displayDate(detail.accept_date) }}</el-descriptions-item>
<el-descriptions-item label="会议日期">{{ displayDate(detail.meeting_date) }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.meetingDate">{{ displayDate(detail.meeting_date) }}</el-descriptions-item>
<el-descriptions-item label="批准日期">{{ displayDate(detail.approved_date) }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.approvedDate">{{ displayDate(detail.approved_date) }}</el-descriptions-item>
<el-descriptions-item label="批件号">{{ detail.approval_no || "—" }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.approvalNo">{{ detail.approval_no || TEXT.common.fallback }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
</el-card> </el-card>
@@ -38,6 +38,7 @@ import { useStudyStore } from "../../store/study";
import { getEthics } from "../../api/startup"; import { getEthics } from "../../api/startup";
import { displayDate } from "../../utils/display"; import { displayDate } from "../../utils/display";
import AttachmentList from "../../components/attachments/AttachmentList.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import { TEXT } from "../../locales";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -61,7 +62,7 @@ const load = async () => {
const { data } = await getEthics(studyId, recordId); const { data } = await getEthics(studyId, recordId);
Object.assign(detail, data); Object.assign(detail, data);
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally { } finally {
loading.value = false; loading.value = false;
} }
+21 -20
View File
@@ -2,32 +2,32 @@
<div class="page"> <div class="page">
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">{{ isEdit ? "编辑伦理记录" : "新增伦理记录" }}</h1> <h1 class="page-title">{{ isEdit ? TEXT.modules.startupFeasibilityEthics.ethicsEditTitle : TEXT.modules.startupFeasibilityEthics.ethicsNewTitle }}</h1>
<p class="page-subtitle">维护伦理递交与审批信息</p> <p class="page-subtitle">{{ TEXT.modules.startupFeasibilityEthics.ethicsFormSubtitle }}</p>
</div> </div>
<el-button @click="goBack">返回</el-button> <el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div> </div>
<el-card> <el-card>
<el-form :model="form" label-width="120px"> <el-form :model="form" label-width="120px">
<el-form-item label="递交日期"> <el-form-item :label="TEXT.common.fields.submitDate">
<el-date-picker v-model="form.submit_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" /> <el-date-picker v-model="form.submit_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
</el-form-item> </el-form-item>
<el-form-item label="受理日期"> <el-form-item :label="TEXT.common.fields.acceptDate">
<el-date-picker v-model="form.accept_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" /> <el-date-picker v-model="form.accept_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
</el-form-item> </el-form-item>
<el-form-item label="会议日期"> <el-form-item :label="TEXT.common.fields.meetingDate">
<el-date-picker v-model="form.meeting_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" /> <el-date-picker v-model="form.meeting_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
</el-form-item> </el-form-item>
<el-form-item label="批准日期"> <el-form-item :label="TEXT.common.fields.approvedDate">
<el-date-picker v-model="form.approved_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" /> <el-date-picker v-model="form.approved_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
</el-form-item> </el-form-item>
<el-form-item label="批件号"> <el-form-item :label="TEXT.common.fields.approvalNo">
<el-input v-model="form.approval_no" placeholder="输入批件号" /> <el-input v-model="form.approval_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.approvalNo" />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" :loading="saving" @click="submit">保存</el-button> <el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button>
<el-button @click="goBack">取消</el-button> <el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </el-card>
@@ -39,7 +39,7 @@
:entity-id="recordId" :entity-id="recordId"
/> />
<el-card v-else class="hint-card"> <el-card v-else class="hint-card">
<StateEmpty description="保存后可上传伦理附件" /> <StateEmpty :description="TEXT.modules.startupFeasibilityEthics.ethicsUploadHint" />
</el-card> </el-card>
</div> </div>
</template> </template>
@@ -52,6 +52,7 @@ import { useStudyStore } from "../../store/study";
import { createEthics, getEthics, updateEthics } from "../../api/startup"; import { createEthics, getEthics, updateEthics } from "../../api/startup";
import AttachmentList from "../../components/attachments/AttachmentList.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -82,7 +83,7 @@ const load = async () => {
approval_no: data.approval_no || "", approval_no: data.approval_no || "",
}); });
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} }
}; };
@@ -99,15 +100,15 @@ const submit = async () => {
}; };
if (isEdit.value && recordId.value) { if (isEdit.value && recordId.value) {
await updateEthics(studyId.value, recordId.value, payload); await updateEthics(studyId.value, recordId.value, payload);
ElMessage.success("已保存"); ElMessage.success(TEXT.common.messages.saveSuccess);
router.push(`/startup/ethics/${recordId.value}`); router.push(`/startup/ethics/${recordId.value}`);
} else { } else {
const { data } = await createEthics(studyId.value, payload); const { data } = await createEthics(studyId.value, payload);
ElMessage.success("已创建"); ElMessage.success(TEXT.common.messages.createSuccess);
router.push(`/startup/ethics/${data.id}`); router.push(`/startup/ethics/${data.id}`);
} }
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally { } finally {
saving.value = false; saving.value = false;
} }
@@ -2,21 +2,21 @@
<div class="page"> <div class="page">
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">立项记录详情</h1> <h1 class="page-title">{{ TEXT.modules.startupFeasibilityEthics.feasibilityDetailTitle }}</h1>
<p class="page-subtitle">查看立项记录与附件</p> <p class="page-subtitle">{{ TEXT.modules.startupFeasibilityEthics.feasibilityDetailSubtitle }}</p>
</div> </div>
<div class="actions"> <div class="actions">
<el-button type="primary" @click="goEdit">编辑</el-button> <el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
<el-button @click="goBack">返回</el-button> <el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div> </div>
</div> </div>
<el-card v-loading="loading"> <el-card v-loading="loading">
<el-descriptions :column="2" border> <el-descriptions :column="2" border>
<el-descriptions-item label="递交日期">{{ displayDate(detail.submit_date) }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.submitDate">{{ displayDate(detail.submit_date) }}</el-descriptions-item>
<el-descriptions-item label="受理日期">{{ displayDate(detail.accept_date) }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.acceptDate">{{ displayDate(detail.accept_date) }}</el-descriptions-item>
<el-descriptions-item label="同意日期">{{ displayDate(detail.approved_date) }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.approvedDate">{{ displayDate(detail.approved_date) }}</el-descriptions-item>
<el-descriptions-item label="项目编号">{{ detail.project_no || "—" }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.projectNo">{{ detail.project_no || TEXT.common.fallback }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
</el-card> </el-card>
@@ -37,6 +37,7 @@ import { useStudyStore } from "../../store/study";
import { getFeasibility } from "../../api/startup"; import { getFeasibility } from "../../api/startup";
import { displayDate } from "../../utils/display"; import { displayDate } from "../../utils/display";
import AttachmentList from "../../components/attachments/AttachmentList.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import { TEXT } from "../../locales";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -59,7 +60,7 @@ const load = async () => {
const { data } = await getFeasibility(studyId, recordId); const { data } = await getFeasibility(studyId, recordId);
Object.assign(detail, data); Object.assign(detail, data);
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally { } finally {
loading.value = false; loading.value = false;
} }
+19 -18
View File
@@ -2,29 +2,29 @@
<div class="page"> <div class="page">
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">{{ isEdit ? "编辑立项记录" : "新增立项记录" }}</h1> <h1 class="page-title">{{ isEdit ? TEXT.modules.startupFeasibilityEthics.feasibilityEditTitle : TEXT.modules.startupFeasibilityEthics.feasibilityNewTitle }}</h1>
<p class="page-subtitle">维护立项递交与审批信息</p> <p class="page-subtitle">{{ TEXT.modules.startupFeasibilityEthics.feasibilityFormSubtitle }}</p>
</div> </div>
<el-button @click="goBack">返回</el-button> <el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div> </div>
<el-card> <el-card>
<el-form :model="form" label-width="120px"> <el-form :model="form" label-width="120px">
<el-form-item label="递交日期"> <el-form-item :label="TEXT.common.fields.submitDate">
<el-date-picker v-model="form.submit_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" /> <el-date-picker v-model="form.submit_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
</el-form-item> </el-form-item>
<el-form-item label="受理日期"> <el-form-item :label="TEXT.common.fields.acceptDate">
<el-date-picker v-model="form.accept_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" /> <el-date-picker v-model="form.accept_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
</el-form-item> </el-form-item>
<el-form-item label="同意日期"> <el-form-item :label="TEXT.common.fields.approvedDate">
<el-date-picker v-model="form.approved_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" /> <el-date-picker v-model="form.approved_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
</el-form-item> </el-form-item>
<el-form-item label="项目编号"> <el-form-item :label="TEXT.common.fields.projectNo">
<el-input v-model="form.project_no" placeholder="输入项目编号" /> <el-input v-model="form.project_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.projectNo" />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" :loading="saving" @click="submit">保存</el-button> <el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button>
<el-button @click="goBack">取消</el-button> <el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </el-card>
@@ -36,7 +36,7 @@
:entity-id="recordId" :entity-id="recordId"
/> />
<el-card v-else class="hint-card"> <el-card v-else class="hint-card">
<StateEmpty description="保存后可上传立项附件" /> <StateEmpty :description="TEXT.modules.startupFeasibilityEthics.feasibilityUploadHint" />
</el-card> </el-card>
</div> </div>
</template> </template>
@@ -49,6 +49,7 @@ import { useStudyStore } from "../../store/study";
import { createFeasibility, getFeasibility, updateFeasibility } from "../../api/startup"; import { createFeasibility, getFeasibility, updateFeasibility } from "../../api/startup";
import AttachmentList from "../../components/attachments/AttachmentList.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -77,7 +78,7 @@ const load = async () => {
project_no: data.project_no || "", project_no: data.project_no || "",
}); });
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} }
}; };
@@ -93,15 +94,15 @@ const submit = async () => {
}; };
if (isEdit.value && recordId.value) { if (isEdit.value && recordId.value) {
await updateFeasibility(studyId.value, recordId.value, payload); await updateFeasibility(studyId.value, recordId.value, payload);
ElMessage.success("已保存"); ElMessage.success(TEXT.common.messages.saveSuccess);
router.push(`/startup/feasibility/${recordId.value}`); router.push(`/startup/feasibility/${recordId.value}`);
} else { } else {
const { data } = await createFeasibility(studyId.value, payload); const { data } = await createFeasibility(studyId.value, payload);
ElMessage.success("已创建"); ElMessage.success(TEXT.common.messages.createSuccess);
router.push(`/startup/feasibility/${data.id}`); router.push(`/startup/feasibility/${data.id}`);
} }
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally { } finally {
saving.value = false; saving.value = false;
} }
+9 -8
View File
@@ -2,20 +2,20 @@
<div class="page"> <div class="page">
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">启动会详情</h1> <h1 class="page-title">{{ TEXT.modules.startupMeetingAuth.kickoffDetailTitle }}</h1>
<p class="page-subtitle">查看启动会信息与附件</p> <p class="page-subtitle">{{ TEXT.modules.startupMeetingAuth.kickoffDetailSubtitle }}</p>
</div> </div>
<div class="actions"> <div class="actions">
<el-button type="primary" @click="goEdit">编辑</el-button> <el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
<el-button @click="goBack">返回</el-button> <el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div> </div>
</div> </div>
<el-card v-loading="loading"> <el-card v-loading="loading">
<el-descriptions :column="2" border> <el-descriptions :column="2" border>
<el-descriptions-item label="启动会日期">{{ displayDate(detail.kickoff_date) }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.kickoffDate">{{ displayDate(detail.kickoff_date) }}</el-descriptions-item>
<el-descriptions-item label="参训人员"> <el-descriptions-item :label="TEXT.common.fields.attendees">
{{ (detail.attendees || []).join("、") || "—" }} {{ (detail.attendees || []).join("、") || TEXT.common.fallback }}
</el-descriptions-item> </el-descriptions-item>
</el-descriptions> </el-descriptions>
</el-card> </el-card>
@@ -37,6 +37,7 @@ import { useStudyStore } from "../../store/study";
import { getKickoff } from "../../api/startup"; import { getKickoff } from "../../api/startup";
import { displayDate } from "../../utils/display"; import { displayDate } from "../../utils/display";
import AttachmentList from "../../components/attachments/AttachmentList.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import { TEXT } from "../../locales";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -57,7 +58,7 @@ const load = async () => {
const { data } = await getKickoff(studyId, meetingId); const { data } = await getKickoff(studyId, meetingId);
Object.assign(detail, data); Object.assign(detail, data);
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally { } finally {
loading.value = false; loading.value = false;
} }
+15 -14
View File
@@ -2,28 +2,28 @@
<div class="page"> <div class="page">
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">{{ isEdit ? "编辑启动会" : "新增启动会" }}</h1> <h1 class="page-title">{{ isEdit ? TEXT.modules.startupMeetingAuth.kickoffEditTitle : TEXT.modules.startupMeetingAuth.kickoffNewTitle }}</h1>
<p class="page-subtitle">记录启动会日期与参训人员</p> <p class="page-subtitle">{{ TEXT.modules.startupMeetingAuth.kickoffFormSubtitle }}</p>
</div> </div>
<el-button @click="goBack">返回</el-button> <el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div> </div>
<el-card> <el-card>
<el-form :model="form" label-width="120px"> <el-form :model="form" label-width="120px">
<el-form-item label="启动会日期"> <el-form-item :label="TEXT.common.fields.kickoffDate">
<el-date-picker v-model="form.kickoff_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" /> <el-date-picker v-model="form.kickoff_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
</el-form-item> </el-form-item>
<el-form-item label="参训人员"> <el-form-item :label="TEXT.common.fields.attendees">
<el-input <el-input
v-model="form.attendees" v-model="form.attendees"
type="textarea" type="textarea"
:rows="4" :rows="4"
placeholder="每行一个姓名" :placeholder="TEXT.modules.startupMeetingAuth.attendeesPlaceholder"
/> />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" :loading="saving" @click="submit">保存</el-button> <el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button>
<el-button @click="goBack">取消</el-button> <el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </el-card>
@@ -35,7 +35,7 @@
:entity-id="meetingId" :entity-id="meetingId"
/> />
<el-card v-else class="hint-card"> <el-card v-else class="hint-card">
<StateEmpty description="保存后可上传会议附件" /> <StateEmpty :description="TEXT.modules.startupMeetingAuth.kickoffUploadHint" />
</el-card> </el-card>
</div> </div>
</template> </template>
@@ -48,6 +48,7 @@ import { useStudyStore } from "../../store/study";
import { createKickoff, getKickoff, updateKickoff } from "../../api/startup"; import { createKickoff, getKickoff, updateKickoff } from "../../api/startup";
import AttachmentList from "../../components/attachments/AttachmentList.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -79,7 +80,7 @@ const load = async () => {
attendees: Array.isArray(data.attendees) ? data.attendees.join("\n") : "", attendees: Array.isArray(data.attendees) ? data.attendees.join("\n") : "",
}); });
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} }
}; };
@@ -93,15 +94,15 @@ const submit = async () => {
}; };
if (isEdit.value && meetingId.value) { if (isEdit.value && meetingId.value) {
await updateKickoff(studyId.value, meetingId.value, payload); await updateKickoff(studyId.value, meetingId.value, payload);
ElMessage.success("已保存"); ElMessage.success(TEXT.common.messages.saveSuccess);
router.push(`/startup/kickoff/${meetingId.value}`); router.push(`/startup/kickoff/${meetingId.value}`);
} else { } else {
const { data } = await createKickoff(studyId.value, payload); const { data } = await createKickoff(studyId.value, payload);
ElMessage.success("已创建"); ElMessage.success(TEXT.common.messages.createSuccess);
router.push(`/startup/kickoff/${data.id}`); router.push(`/startup/kickoff/${data.id}`);
} }
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally { } finally {
saving.value = false; saving.value = false;
} }
+15 -14
View File
@@ -2,25 +2,25 @@
<div class="page"> <div class="page">
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">培训授权详情</h1> <h1 class="page-title">{{ TEXT.modules.startupMeetingAuth.trainingDetailTitle }}</h1>
<p class="page-subtitle">查看人员培训与授权信息</p> <p class="page-subtitle">{{ TEXT.modules.startupMeetingAuth.trainingDetailSubtitle }}</p>
</div> </div>
<div class="actions"> <div class="actions">
<el-button type="primary" @click="goEdit">编辑</el-button> <el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
<el-button @click="goBack">返回</el-button> <el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div> </div>
</div> </div>
<el-card v-loading="loading"> <el-card v-loading="loading">
<el-descriptions :column="2" border> <el-descriptions :column="2" border>
<el-descriptions-item label="姓名">{{ detail.name || "—" }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.name">{{ detail.name || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="角色">{{ detail.role || "—" }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.labels.role">{{ displayEnum(TEXT.enums.userRole, detail.role) }}</el-descriptions-item>
<el-descriptions-item label="分中心">{{ detail.site_name || "—" }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.site">{{ detail.site_name || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="已培训">{{ detail.trained ? "是" : "否" }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.trained">{{ detail.trained ? TEXT.common.boolean.yes : TEXT.common.boolean.no }}</el-descriptions-item>
<el-descriptions-item label="培训日期">{{ displayDate(detail.trained_date) }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.trainedDate">{{ displayDate(detail.trained_date) }}</el-descriptions-item>
<el-descriptions-item label="已授权">{{ detail.authorized ? "是" : "否" }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.authorized">{{ detail.authorized ? TEXT.common.boolean.yes : TEXT.common.boolean.no }}</el-descriptions-item>
<el-descriptions-item label="授权日期">{{ displayDate(detail.authorized_date) }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.authorizedDate">{{ displayDate(detail.authorized_date) }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ detail.remark || "—" }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.remark" :span="2">{{ detail.remark || TEXT.common.fallback }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
</el-card> </el-card>
@@ -39,8 +39,9 @@ import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { getTrainingAuthorization } from "../../api/startup"; import { getTrainingAuthorization } from "../../api/startup";
import { displayDate } from "../../utils/display"; import { displayDate, displayEnum } from "../../utils/display";
import AttachmentList from "../../components/attachments/AttachmentList.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import { TEXT } from "../../locales";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -67,7 +68,7 @@ const load = async () => {
const { data } = await getTrainingAuthorization(studyId, recordId); const { data } = await getTrainingAuthorization(studyId, recordId);
Object.assign(detail, data); Object.assign(detail, data);
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally { } finally {
loading.value = false; loading.value = false;
} }
+25 -24
View File
@@ -2,41 +2,41 @@
<div class="page"> <div class="page">
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">{{ isEdit ? "编辑培训授权" : "新增人员" }}</h1> <h1 class="page-title">{{ isEdit ? TEXT.modules.startupMeetingAuth.trainingEditTitle : TEXT.modules.startupMeetingAuth.trainingNewTitle }}</h1>
<p class="page-subtitle">维护人员培训与授权状态</p> <p class="page-subtitle">{{ TEXT.modules.startupMeetingAuth.trainingFormSubtitle }}</p>
</div> </div>
<el-button @click="goBack">返回</el-button> <el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div> </div>
<el-card> <el-card>
<el-form :model="form" label-width="120px"> <el-form :model="form" label-width="120px">
<el-form-item label="姓名" required> <el-form-item :label="TEXT.common.fields.name" required>
<el-input v-model="form.name" placeholder="输入姓名" /> <el-input v-model="form.name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.name" />
</el-form-item> </el-form-item>
<el-form-item label="角色"> <el-form-item :label="TEXT.common.labels.role">
<el-input v-model="form.role" placeholder="输入角色" /> <el-input v-model="form.role" :placeholder="TEXT.common.placeholders.input + TEXT.common.labels.role" />
</el-form-item> </el-form-item>
<el-form-item label="分中心"> <el-form-item :label="TEXT.common.fields.site">
<el-input v-model="form.site_name" placeholder="输入分中心" /> <el-input v-model="form.site_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
</el-form-item> </el-form-item>
<el-form-item label="已培训"> <el-form-item :label="TEXT.common.fields.trained">
<el-switch v-model="form.trained" /> <el-switch v-model="form.trained" />
</el-form-item> </el-form-item>
<el-form-item label="培训日期"> <el-form-item :label="TEXT.common.fields.trainedDate">
<el-date-picker v-model="form.trained_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" /> <el-date-picker v-model="form.trained_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
</el-form-item> </el-form-item>
<el-form-item label="已授权"> <el-form-item :label="TEXT.common.fields.authorized">
<el-switch v-model="form.authorized" /> <el-switch v-model="form.authorized" />
</el-form-item> </el-form-item>
<el-form-item label="授权日期"> <el-form-item :label="TEXT.common.fields.authorizedDate">
<el-date-picker v-model="form.authorized_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" /> <el-date-picker v-model="form.authorized_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
</el-form-item> </el-form-item>
<el-form-item label="备注"> <el-form-item :label="TEXT.common.fields.remark">
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="补充说明" /> <el-input v-model="form.remark" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" :loading="saving" @click="submit">保存</el-button> <el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button>
<el-button @click="goBack">取消</el-button> <el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </el-card>
@@ -48,7 +48,7 @@
:entity-id="recordId" :entity-id="recordId"
/> />
<el-card v-else class="hint-card"> <el-card v-else class="hint-card">
<StateEmpty description="保存后可上传授权附件" /> <StateEmpty :description="TEXT.modules.startupMeetingAuth.trainingUploadHint" />
</el-card> </el-card>
</div> </div>
</template> </template>
@@ -61,6 +61,7 @@ import { useStudyStore } from "../../store/study";
import { createTrainingAuthorization, getTrainingAuthorization, updateTrainingAuthorization } from "../../api/startup"; import { createTrainingAuthorization, getTrainingAuthorization, updateTrainingAuthorization } from "../../api/startup";
import AttachmentList from "../../components/attachments/AttachmentList.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -97,7 +98,7 @@ const load = async () => {
remark: data.remark || "", remark: data.remark || "",
}); });
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} }
}; };
@@ -117,15 +118,15 @@ const submit = async () => {
}; };
if (isEdit.value && recordId.value) { if (isEdit.value && recordId.value) {
await updateTrainingAuthorization(studyId.value, recordId.value, payload); await updateTrainingAuthorization(studyId.value, recordId.value, payload);
ElMessage.success("已保存"); ElMessage.success(TEXT.common.messages.saveSuccess);
router.push(`/startup/training/${recordId.value}`); router.push(`/startup/training/${recordId.value}`);
} else { } else {
const { data } = await createTrainingAuthorization(studyId.value, payload); const { data } = await createTrainingAuthorization(studyId.value, payload);
ElMessage.success("已创建"); ElMessage.success(TEXT.common.messages.createSuccess);
router.push(`/startup/training/${data.id}`); router.push(`/startup/training/${data.id}`);
} }
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally { } finally {
saving.value = false; saving.value = false;
} }
+99 -90
View File
@@ -2,187 +2,195 @@
<div class="page"> <div class="page">
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">受试者详情</h1> <h1 class="page-title">{{ TEXT.modules.subjectDetail.title }}</h1>
<p class="page-subtitle">查看病史访视与 AE 信息</p> <p class="page-subtitle">{{ TEXT.modules.subjectDetail.subtitle }}</p>
</div> </div>
<div class="actions"> <div class="actions">
<el-button type="primary" @click="goEdit">编辑</el-button> <el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
<el-button @click="goBack">返回</el-button> <el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div> </div>
</div> </div>
<el-card v-loading="loading"> <el-card v-loading="loading">
<el-descriptions :column="2" border> <el-descriptions :column="2" border>
<el-descriptions-item label="受试者编号">{{ detail.subject_no || "—" }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.subjectNo">{{ detail.subject_no || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="分中心">{{ siteMap[detail.site_id] || "—" }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.site">{{ siteMap[detail.site_id] || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ detail.status || "—" }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.status">{{ displayEnum(TEXT.enums.subjectStatus, detail.status) }}</el-descriptions-item>
<el-descriptions-item label="筛选日期">{{ displayDate(detail.screening_date) }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.screeningDate">{{ displayDate(detail.screening_date) }}</el-descriptions-item>
<el-descriptions-item label="入组日期">{{ displayDate(detail.enrollment_date) }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.enrollmentDate">{{ displayDate(detail.enrollment_date) }}</el-descriptions-item>
<el-descriptions-item label="完成日期">{{ displayDate(detail.completion_date) }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.completionDate">{{ displayDate(detail.completion_date) }}</el-descriptions-item>
<el-descriptions-item label="退出原因" :span="2">{{ detail.drop_reason || "—" }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.dropReason" :span="2">{{ detail.drop_reason || TEXT.common.fallback }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
</el-card> </el-card>
<el-card> <el-card>
<el-tabs v-model="activeTab"> <el-tabs v-model="activeTab">
<el-tab-pane label="病史" name="history"> <el-tab-pane :label="TEXT.modules.subjectDetail.tabs.history" name="history">
<div class="tab-actions"> <div class="tab-actions">
<el-button type="primary" @click="openHistoryDialog()">新增病史</el-button> <el-button type="primary" @click="openHistoryDialog()">{{ TEXT.common.actions.newHistory }}</el-button>
</div> </div>
<el-table :data="historyItems" v-loading="loadingHistory" style="width: 100%"> <el-table :data="historyItems" v-loading="loadingHistory" style="width: 100%">
<el-table-column prop="record_date" label="日期" width="140"> <el-table-column prop="record_date" :label="TEXT.common.fields.recordDate" width="140">
<template #default="scope">{{ displayDate(scope.row.record_date) }}</template> <template #default="scope">{{ displayDate(scope.row.record_date) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="content" label="内容" min-width="240" /> <el-table-column prop="content" :label="TEXT.common.fields.content" min-width="240" />
<el-table-column label="操作" width="200"> <el-table-column :label="TEXT.common.labels.actions" width="200">
<template #default="scope"> <template #default="scope">
<el-button link type="primary" size="small" @click="openHistoryDialog(scope.row)">编辑</el-button> <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)">删除</el-button> <el-button link type="danger" size="small" @click="removeHistory(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<StateEmpty v-if="!loadingHistory && historyItems.length === 0" description="暂无病史记录" /> <StateEmpty v-if="!loadingHistory && historyItems.length === 0" :description="TEXT.modules.subjectDetail.emptyHistory" />
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="访视" name="visits"> <el-tab-pane :label="TEXT.modules.subjectDetail.tabs.visits" name="visits">
<div class="tab-actions"> <div class="tab-actions">
<el-button type="primary" @click="openVisitDialog()">新增访视</el-button> <el-button type="primary" @click="openVisitDialog()">{{ TEXT.common.actions.newVisit }}</el-button>
</div> </div>
<el-table :data="visitItems" v-loading="loadingVisits" style="width: 100%"> <el-table :data="visitItems" v-loading="loadingVisits" style="width: 100%">
<el-table-column prop="visit_code" label="访视编号" width="120" /> <el-table-column prop="visit_code" :label="TEXT.common.fields.visitCode" width="120" />
<el-table-column prop="visit_name" label="访视名称" min-width="140" /> <el-table-column prop="visit_name" :label="TEXT.common.fields.visitName" min-width="140">
<el-table-column prop="planned_date" label="计划日期" width="140"> <template #default="scope">
{{ displayText(scope.row.visit_name, TEXT.enums.visitName) }}
</template>
</el-table-column>
<el-table-column prop="planned_date" :label="TEXT.common.fields.plannedDate" width="140">
<template #default="scope">{{ displayDate(scope.row.planned_date) }}</template> <template #default="scope">{{ displayDate(scope.row.planned_date) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="actual_date" label="实际日期" width="140"> <el-table-column prop="actual_date" :label="TEXT.common.fields.actualDate" width="140">
<template #default="scope">{{ displayDate(scope.row.actual_date) }}</template> <template #default="scope">{{ displayDate(scope.row.actual_date) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="status" label="状态" width="120" /> <el-table-column prop="status" :label="TEXT.common.fields.status" width="120">
<el-table-column label="操作" width="200"> <template #default="scope">{{ displayEnum(TEXT.enums.visitStatus, scope.row.status) }}</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="200">
<template #default="scope"> <template #default="scope">
<el-button link type="primary" size="small" @click="openVisitDialog(scope.row)">编辑</el-button> <el-button link type="primary" size="small" @click="openVisitDialog(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
<el-button link type="danger" size="small" @click="removeVisit(scope.row)">删除</el-button> <el-button link type="danger" size="small" @click="removeVisit(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<StateEmpty v-if="!loadingVisits && visitItems.length === 0" description="暂无访视记录" /> <StateEmpty v-if="!loadingVisits && visitItems.length === 0" :description="TEXT.modules.subjectDetail.emptyVisits" />
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="AE 信息" name="ae"> <el-tab-pane :label="TEXT.modules.subjectDetail.tabs.ae" name="ae">
<div class="tab-actions"> <div class="tab-actions">
<el-button type="primary" @click="openAeDialog()">新增 AE</el-button> <el-button type="primary" @click="openAeDialog()">{{ TEXT.common.actions.newAe }}</el-button>
</div> </div>
<el-table :data="aeItems" v-loading="loadingAes" style="width: 100%"> <el-table :data="aeItems" v-loading="loadingAes" style="width: 100%">
<el-table-column prop="onset_date" label="发生日期" width="140"> <el-table-column prop="onset_date" :label="TEXT.common.fields.occurDate" width="140">
<template #default="scope">{{ displayDate(scope.row.onset_date) }}</template> <template #default="scope">{{ displayDate(scope.row.onset_date) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="term" label="事件" min-width="140" /> <el-table-column prop="term" :label="TEXT.common.fields.title" min-width="140" />
<el-table-column prop="seriousness" label="严重程度" width="120" /> <el-table-column prop="seriousness" :label="TEXT.common.fields.seriousness" width="120">
<el-table-column prop="causality" label="关联性" width="120" /> <template #default="scope">{{ displayEnum(TEXT.enums.aeSeriousness, scope.row.seriousness) }}</template>
<el-table-column prop="outcome" label="结局" width="120" /> </el-table-column>
<el-table-column prop="description" label="备注" min-width="200" /> <el-table-column prop="causality" :label="TEXT.common.fields.causality" width="120" />
<el-table-column label="操作" width="200"> <el-table-column prop="outcome" :label="TEXT.common.fields.outcome" width="120" />
<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"> <template #default="scope">
<el-button link type="primary" size="small" @click="openAeDialog(scope.row)">编辑</el-button> <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)">删除</el-button> <el-button link type="danger" size="small" @click="removeAe(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<StateEmpty v-if="!loadingAes && aeItems.length === 0" description="暂无 AE 记录" /> <StateEmpty v-if="!loadingAes && aeItems.length === 0" :description="TEXT.modules.subjectDetail.emptyAe" />
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
</el-card> </el-card>
<el-dialog v-model="historyDialogVisible" title="病史记录" width="520px"> <el-dialog v-model="historyDialogVisible" :title="TEXT.modules.subjectDetail.dialogHistory" width="520px">
<el-form :model="historyForm" label-width="90px"> <el-form :model="historyForm" label-width="90px">
<el-form-item label="日期"> <el-form-item :label="TEXT.common.fields.recordDate">
<el-date-picker v-model="historyForm.record_date" type="date" value-format="YYYY-MM-DD" /> <el-date-picker v-model="historyForm.record_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item> </el-form-item>
<el-form-item label="内容" required> <el-form-item :label="TEXT.common.fields.content" required>
<el-input v-model="historyForm.content" type="textarea" :rows="3" /> <el-input v-model="historyForm.content" type="textarea" :rows="3" />
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="historyDialogVisible = false">取消</el-button> <el-button @click="historyDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" @click="saveHistory">保存</el-button> <el-button type="primary" @click="saveHistory">{{ TEXT.common.actions.save }}</el-button>
</template> </template>
</el-dialog> </el-dialog>
<el-dialog v-model="visitDialogVisible" title="访视记录" width="560px"> <el-dialog v-model="visitDialogVisible" :title="TEXT.modules.subjectDetail.dialogVisit" width="560px">
<el-form :model="visitForm" label-width="100px"> <el-form :model="visitForm" label-width="100px">
<template v-if="!visitEditingId"> <template v-if="!visitEditingId">
<el-form-item label="访视编号" required> <el-form-item :label="TEXT.common.fields.visitCode" required>
<el-input v-model="visitForm.visit_code" /> <el-input v-model="visitForm.visit_code" />
</el-form-item> </el-form-item>
<el-form-item label="访视名称" required> <el-form-item :label="TEXT.common.fields.visitName" required>
<el-input v-model="visitForm.visit_name" /> <el-input v-model="visitForm.visit_name" />
</el-form-item> </el-form-item>
<el-form-item label="计划日期"> <el-form-item :label="TEXT.common.fields.plannedDate">
<el-date-picker v-model="visitForm.planned_date" type="date" value-format="YYYY-MM-DD" /> <el-date-picker v-model="visitForm.planned_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item> </el-form-item>
<el-form-item label="窗口开始"> <el-form-item :label="TEXT.common.fields.windowStart">
<el-date-picker v-model="visitForm.window_start" type="date" value-format="YYYY-MM-DD" /> <el-date-picker v-model="visitForm.window_start" type="date" value-format="YYYY-MM-DD" />
</el-form-item> </el-form-item>
<el-form-item label="窗口结束"> <el-form-item :label="TEXT.common.fields.windowEnd">
<el-date-picker v-model="visitForm.window_end" type="date" value-format="YYYY-MM-DD" /> <el-date-picker v-model="visitForm.window_end" type="date" value-format="YYYY-MM-DD" />
</el-form-item> </el-form-item>
</template> </template>
<template v-else> <template v-else>
<el-form-item label="实际日期"> <el-form-item :label="TEXT.common.fields.actualDate">
<el-date-picker v-model="visitForm.actual_date" type="date" value-format="YYYY-MM-DD" /> <el-date-picker v-model="visitForm.actual_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item> </el-form-item>
<el-form-item label="状态"> <el-form-item :label="TEXT.common.fields.status">
<el-select v-model="visitForm.status"> <el-select v-model="visitForm.status">
<el-option label="计划中" value="PLANNED" /> <el-option :label="TEXT.enums.visitStatus.PLANNED" value="PLANNED" />
<el-option label="已完成" value="DONE" /> <el-option :label="TEXT.enums.visitStatus.DONE" value="DONE" />
<el-option label="未完成" value="MISSED" /> <el-option :label="TEXT.enums.visitStatus.MISSED" value="MISSED" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="备注"> <el-form-item :label="TEXT.common.fields.remark">
<el-input v-model="visitForm.notes" type="textarea" :rows="3" /> <el-input v-model="visitForm.notes" type="textarea" :rows="3" />
</el-form-item> </el-form-item>
</template> </template>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="visitDialogVisible = false">取消</el-button> <el-button @click="visitDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" @click="saveVisit">保存</el-button> <el-button type="primary" @click="saveVisit">{{ TEXT.common.actions.save }}</el-button>
</template> </template>
</el-dialog> </el-dialog>
<el-dialog v-model="aeDialogVisible" title="AE 记录" width="560px"> <el-dialog v-model="aeDialogVisible" :title="TEXT.modules.subjectDetail.dialogAe" width="560px">
<el-form :model="aeForm" label-width="100px"> <el-form :model="aeForm" label-width="100px">
<el-form-item label="事件" required> <el-form-item :label="TEXT.common.fields.title" required>
<el-input v-model="aeForm.term" /> <el-input v-model="aeForm.term" />
</el-form-item> </el-form-item>
<el-form-item label="发生日期"> <el-form-item :label="TEXT.common.fields.occurDate">
<el-date-picker v-model="aeForm.onset_date" type="date" value-format="YYYY-MM-DD" /> <el-date-picker v-model="aeForm.onset_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item> </el-form-item>
<el-form-item label="严重程度" required> <el-form-item :label="TEXT.common.fields.seriousness" required>
<el-select v-model="aeForm.seriousness"> <el-select v-model="aeForm.seriousness">
<el-option label="严重" value="SERIOUS" /> <el-option :label="TEXT.enums.aeSeriousness.SERIOUS" value="SERIOUS" />
<el-option label="一般" value="NON_SERIOUS" /> <el-option :label="TEXT.enums.aeSeriousness.NON_SERIOUS" value="NON_SERIOUS" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="严重等级" required> <el-form-item :label="TEXT.common.fields.severity" required>
<el-select v-model="aeForm.severity"> <el-select v-model="aeForm.severity">
<el-option label="轻度" value="MILD" /> <el-option :label="TEXT.enums.aeSeverity.MILD" value="MILD" />
<el-option label="中度" value="MODERATE" /> <el-option :label="TEXT.enums.aeSeverity.MODERATE" value="MODERATE" />
<el-option label="重度" value="SEVERE" /> <el-option :label="TEXT.enums.aeSeverity.SEVERE" value="SEVERE" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="关联性"> <el-form-item :label="TEXT.common.fields.causality">
<el-input v-model="aeForm.causality" /> <el-input v-model="aeForm.causality" />
</el-form-item> </el-form-item>
<el-form-item label="结局"> <el-form-item :label="TEXT.common.fields.outcome">
<el-input v-model="aeForm.outcome" /> <el-input v-model="aeForm.outcome" />
</el-form-item> </el-form-item>
<el-form-item label="备注"> <el-form-item :label="TEXT.common.fields.remark">
<el-input v-model="aeForm.description" type="textarea" :rows="3" /> <el-input v-model="aeForm.description" type="textarea" :rows="3" />
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="aeDialogVisible = false">取消</el-button> <el-button @click="aeDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" @click="saveAe">保存</el-button> <el-button type="primary" @click="saveAe">{{ TEXT.common.actions.save }}</el-button>
</template> </template>
</el-dialog> </el-dialog>
</div> </div>
@@ -198,8 +206,9 @@ import { fetchSites } from "../../api/sites";
import { listSubjectHistories, createSubjectHistory, updateSubjectHistory, deleteSubjectHistory } from "../../api/subjectHistories"; import { listSubjectHistories, createSubjectHistory, updateSubjectHistory, deleteSubjectHistory } from "../../api/subjectHistories";
import { fetchVisits, createVisit, updateVisit, deleteVisit } from "../../api/visits"; import { fetchVisits, createVisit, updateVisit, deleteVisit } from "../../api/visits";
import { fetchAes, createAe, updateAe, deleteAe } from "../../api/aes"; import { fetchAes, createAe, updateAe, deleteAe } from "../../api/aes";
import { displayDate } from "../../utils/display"; import { displayDate, displayEnum, displayText } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -280,7 +289,7 @@ const loadSubject = async () => {
const { data } = await getSubject(studyId, subjectId); const { data } = await getSubject(studyId, subjectId);
Object.assign(detail, data); Object.assign(detail, data);
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -293,7 +302,7 @@ const loadHistories = async () => {
const { data } = await listSubjectHistories(studyId, subjectId); const { data } = await listSubjectHistories(studyId, subjectId);
historyItems.value = Array.isArray(data) ? data : data.items || []; historyItems.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "病史加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally { } finally {
loadingHistory.value = false; loadingHistory.value = false;
} }
@@ -322,19 +331,19 @@ const saveHistory = async () => {
historyDialogVisible.value = false; historyDialogVisible.value = false;
loadHistories(); loadHistories();
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} }
}; };
const removeHistory = async (row: any) => { const removeHistory = async (row: any) => {
if (!studyId || !subjectId) return; if (!studyId || !subjectId) return;
const ok = await ElMessageBox.confirm("确认删除该病史记录?", "提示").catch(() => null); const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return; if (!ok) return;
try { try {
await deleteSubjectHistory(studyId, subjectId, row.id); await deleteSubjectHistory(studyId, subjectId, row.id);
loadHistories(); loadHistories();
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "删除失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
} }
}; };
@@ -345,7 +354,7 @@ const loadVisits = async () => {
const { data } = await fetchVisits(studyId, subjectId); const { data } = await fetchVisits(studyId, subjectId);
visitItems.value = Array.isArray(data) ? data : data.items || []; visitItems.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "访视加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally { } finally {
loadingVisits.value = false; loadingVisits.value = false;
} }
@@ -394,19 +403,19 @@ const saveVisit = async () => {
visitDialogVisible.value = false; visitDialogVisible.value = false;
loadVisits(); loadVisits();
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} }
}; };
const removeVisit = async (row: any) => { const removeVisit = async (row: any) => {
if (!studyId || !subjectId) return; if (!studyId || !subjectId) return;
const ok = await ElMessageBox.confirm("确认删除该访视记录?", "提示").catch(() => null); const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return; if (!ok) return;
try { try {
await deleteVisit(studyId, subjectId, row.id); await deleteVisit(studyId, subjectId, row.id);
loadVisits(); loadVisits();
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "删除失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
} }
}; };
@@ -417,7 +426,7 @@ const loadAes = async () => {
const { data } = await fetchAes(studyId, { subject_id: subjectId }); const { data } = await fetchAes(studyId, { subject_id: subjectId });
aeItems.value = Array.isArray(data) ? data : data.items || []; aeItems.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "AE 加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally { } finally {
loadingAes.value = false; loadingAes.value = false;
} }
@@ -459,19 +468,19 @@ const saveAe = async () => {
aeDialogVisible.value = false; aeDialogVisible.value = false;
loadAes(); loadAes();
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} }
}; };
const removeAe = async (row: any) => { const removeAe = async (row: any) => {
if (!studyId) return; if (!studyId) return;
const ok = await ElMessageBox.confirm("确认删除该 AE 记录?", "提示").catch(() => null); const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return; if (!ok) return;
try { try {
await deleteAe(studyId, row.id); await deleteAe(studyId, row.id);
loadAes(); loadAes();
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "删除失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
} }
}; };
+28 -27
View File
@@ -2,51 +2,51 @@
<div class="page"> <div class="page">
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">{{ isEdit ? "编辑受试者" : "新增受试者" }}</h1> <h1 class="page-title">{{ isEdit ? TEXT.modules.subjectForm.titleEdit : TEXT.modules.subjectForm.titleNew }}</h1>
<p class="page-subtitle">维护受试者基本信息</p> <p class="page-subtitle">{{ TEXT.modules.subjectForm.subtitle }}</p>
</div> </div>
<el-button @click="goBack">返回</el-button> <el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div> </div>
<el-card> <el-card>
<el-form :model="form" label-width="120px"> <el-form :model="form" label-width="120px">
<el-form-item label="受试者编号" required> <el-form-item :label="TEXT.common.fields.subjectNo" required>
<el-input v-model="form.subject_no" :disabled="isEdit" placeholder="输入受试者编号" /> <el-input v-model="form.subject_no" :disabled="isEdit" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.subjectNo" />
</el-form-item> </el-form-item>
<el-form-item label="分中心" required> <el-form-item :label="TEXT.common.fields.site" required>
<el-select v-model="form.site_id" :disabled="isEdit" placeholder="选择分中心"> <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-option v-for="site in sites" :key="site.id" :label="site.name" :value="site.id" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="筛选日期"> <el-form-item :label="TEXT.common.fields.screeningDate">
<el-date-picker <el-date-picker
v-model="form.screening_date" v-model="form.screening_date"
type="date" type="date"
value-format="YYYY-MM-DD" value-format="YYYY-MM-DD"
placeholder="选择日期" :placeholder="TEXT.common.placeholders.select"
:disabled="isEdit" :disabled="isEdit"
/> />
</el-form-item> </el-form-item>
<el-form-item v-if="isEdit" label="状态"> <el-form-item v-if="isEdit" :label="TEXT.common.fields.status">
<el-select v-model="form.status" placeholder="选择状态"> <el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select">
<el-option label="筛选" value="SCREENING" /> <el-option :label="TEXT.enums.subjectStatus.SCREENING" value="SCREENING" />
<el-option label="入组" value="ENROLLED" /> <el-option :label="TEXT.enums.subjectStatus.ENROLLED" value="ENROLLED" />
<el-option label="完成" value="COMPLETED" /> <el-option :label="TEXT.enums.subjectStatus.COMPLETED" value="COMPLETED" />
<el-option label="退出" value="DROPPED" /> <el-option :label="TEXT.enums.subjectStatus.DROPPED" value="DROPPED" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item v-if="isEdit" label="入组日期"> <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="选择日期" /> <el-date-picker v-model="form.enrollment_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
</el-form-item> </el-form-item>
<el-form-item v-if="isEdit" label="完成日期"> <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="选择日期" /> <el-date-picker v-model="form.completion_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
</el-form-item> </el-form-item>
<el-form-item v-if="isEdit" label="退出原因"> <el-form-item v-if="isEdit" :label="TEXT.common.fields.dropReason">
<el-input v-model="form.drop_reason" type="textarea" :rows="3" placeholder="输入退出原因" /> <el-input v-model="form.drop_reason" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" :loading="saving" @click="submit">保存</el-button> <el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button>
<el-button @click="goBack">取消</el-button> <el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </el-card>
@@ -60,6 +60,7 @@ import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { createSubject, getSubject, updateSubject } from "../../api/subjects"; import { createSubject, getSubject, updateSubject } from "../../api/subjects";
import { fetchSites } from "../../api/sites"; import { fetchSites } from "../../api/sites";
import { TEXT } from "../../locales";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -105,7 +106,7 @@ const load = async () => {
drop_reason: data.drop_reason || "", drop_reason: data.drop_reason || "",
}); });
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} }
}; };
@@ -121,7 +122,7 @@ const submit = async () => {
drop_reason: form.drop_reason || null, drop_reason: form.drop_reason || null,
}; };
await updateSubject(studyId.value, subjectId.value, payload); await updateSubject(studyId.value, subjectId.value, payload);
ElMessage.success("已保存"); ElMessage.success(TEXT.common.messages.saveSuccess);
router.push(`/subjects/${subjectId.value}`); router.push(`/subjects/${subjectId.value}`);
} else { } else {
const payload = { const payload = {
@@ -130,11 +131,11 @@ const submit = async () => {
screening_date: form.screening_date || null, screening_date: form.screening_date || null,
}; };
const { data } = await createSubject(studyId.value, payload); const { data } = await createSubject(studyId.value, payload);
ElMessage.success("已创建"); ElMessage.success(TEXT.common.messages.createSuccess);
router.push(`/subjects/${data.id}`); router.push(`/subjects/${data.id}`);
} }
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败"); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally { } finally {
saving.value = false; saving.value = false;
} }

Some files were not shown because too many files have changed in this diff Show More