完善审计日志展示与导出权限

This commit is contained in:
Cheng Zhou
2026-06-10 10:20:04 +08:00
parent 1fef12cd86
commit ea3f19e241
33 changed files with 1862 additions and 240 deletions
+22 -3
View File
@@ -44,6 +44,15 @@ def _is_overdue(ae: AERead) -> bool:
return bool(ae.report_due_date and date.today() > ae.report_due_date and ae.status != "CLOSED") return bool(ae.report_due_date and date.today() > ae.report_due_date and ae.status != "CLOSED")
def _ae_audit_name(ae) -> str:
return str(getattr(ae, "term", "") or "").strip() or "AE 记录"
def _ae_audit_detail(action: str, ae) -> str:
name = _ae_audit_name(ae)
return json.dumps({"targetName": name, "description": f"{action}AE“{name}"}, ensure_ascii=False)
@router.post( @router.post(
"/", "/",
response_model=AERead, response_model=AERead,
@@ -77,7 +86,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} 已创建", detail=_ae_audit_detail("创建", ae),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, study_id, current_user),
) )
@@ -247,7 +256,16 @@ async def update_ae(
entity_type="ae", entity_type="ae",
entity_id=ae_id, entity_id=ae_id,
action=action, action=action,
detail=json.dumps({"before": detail_before, "after": detail_after}, ensure_ascii=False, default=str), detail=json.dumps(
{
"targetName": _ae_audit_name(updated),
"description": f"{'变更' if action == 'AE_STATUS_CHANGE' else '更新'}AE“{_ae_audit_name(updated)}",
"before": detail_before,
"after": detail_after,
},
ensure_ascii=False,
default=str,
),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, study_id, current_user),
) )
@@ -272,6 +290,7 @@ async def delete_ae(
if not ae or ae.study_id != study_id: if not ae or ae.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在")
await _ensure_ae_visible(db, study_id, AERead.model_validate(ae)) await _ensure_ae_visible(db, study_id, AERead.model_validate(ae))
ae_detail = _ae_audit_detail("删除", ae)
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,
@@ -279,7 +298,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} 已删除", detail=ae_detail,
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, study_id, current_user),
) )
+23 -20
View File
@@ -1,15 +1,34 @@
import json
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_roles, require_api_permission from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_api_permission
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import study as study_crud
from app.schemas.audit import AuditEventCreate, AuditLogRead from app.schemas.audit import AuditEventCreate, AuditLogRead
router = APIRouter() router = APIRouter()
async def _build_export_audit_detail(db: AsyncSession, study_id: uuid.UUID, action: str) -> str:
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
scope = "system" if action == "AUDIT_EXPORT_SYSTEM" else "project"
description = "导出了系统审计日志" if scope == "system" else f"导出了项目审计日志:{study.name}"
return json.dumps(
{
"targetName": "系统审计日志" if scope == "system" else study.name,
"scope": scope,
"description": description,
"result": "SUCCESS",
},
ensure_ascii=False,
)
@router.get( @router.get(
"/", "/",
response_model=list[AuditLogRead], response_model=list[AuditLogRead],
@@ -38,22 +57,6 @@ async def list_audit_logs(
return list(logs) return list(logs)
@router.delete(
"/{log_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_roles(["ADMIN"]))],
)
async def delete_audit_log(
study_id: uuid.UUID,
log_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
):
log = await audit_crud.get_log(db, log_id)
if not log or log.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="审计日志不存在")
await audit_crud.delete_log(db, log)
@router.post( @router.post(
"/events", "/events",
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
@@ -71,10 +74,10 @@ async def create_audit_event(
await audit_crud.log_action( await audit_crud.log_action(
db, db,
study_id=study_id, study_id=study_id,
entity_type=payload.entity_type or "audit_log", entity_type="audit_log",
entity_id=payload.entity_id, entity_id=None,
action=payload.action, action=payload.action,
detail=payload.detail, detail=await _build_export_audit_detail(db, study_id, payload.action),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, study_id, current_user),
) )
+8 -3
View File
@@ -23,6 +23,11 @@ def _shipment_audit_name(shipment) -> str:
return shipment.tracking_no or shipment.batch_no or shipment.site_name or "药品运输记录" return shipment.tracking_no or shipment.batch_no or shipment.site_name or "药品运输记录"
def _shipment_audit_description(action: str, shipment) -> str:
name = _shipment_audit_name(shipment)
return f"{action}药品流向“{name}"
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:
@@ -65,7 +70,7 @@ async def create_shipment(
entity_id=shipment.id, entity_id=shipment.id,
action="CREATE_DRUG_SHIPMENT", action="CREATE_DRUG_SHIPMENT",
detail=json.dumps( detail=json.dumps(
{"targetName": _shipment_audit_name(shipment), "description": f"药品运输 {_shipment_audit_name(shipment)} 已创建"}, {"targetName": _shipment_audit_name(shipment), "description": _shipment_audit_description("创建", shipment)},
ensure_ascii=False, ensure_ascii=False,
), ),
operator_id=current_user.id, operator_id=current_user.id,
@@ -178,7 +183,7 @@ async def update_shipment(
entity_id=shipment_id, entity_id=shipment_id,
action="UPDATE_DRUG_SHIPMENT", action="UPDATE_DRUG_SHIPMENT",
detail=json.dumps( detail=json.dumps(
{"targetName": _shipment_audit_name(shipment), "description": f"药品运输 {_shipment_audit_name(shipment)} 已更新"}, {"targetName": _shipment_audit_name(shipment), "description": _shipment_audit_description("更新", shipment)},
ensure_ascii=False, ensure_ascii=False,
), ),
operator_id=current_user.id, operator_id=current_user.id,
@@ -214,7 +219,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=json.dumps({"targetName": shipment_name, "description": f"药品运输 {shipment_name} 已删除"}, ensure_ascii=False), detail=json.dumps({"targetName": shipment_name, "description": f"删除药品流向“{shipment_name}"}, ensure_ascii=False),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, study_id, current_user),
) )
+3 -3
View File
@@ -53,7 +53,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=json.dumps({"targetName": category.name, "description": f"FAQ 分类 {category.name} 已创建"}, ensure_ascii=False), detail=json.dumps({"targetName": category.name, "description": f"创建“{category.name}”分类"}, ensure_ascii=False),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, payload.study_id, current_user), operator_role=await get_operator_role_label(db, payload.study_id, current_user),
) )
@@ -118,7 +118,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=json.dumps({"targetName": updated.name, "description": f"FAQ 分类 {updated.name} 已更新"}, ensure_ascii=False), detail=json.dumps({"targetName": updated.name, "description": f"更新“{updated.name}”分类"}, ensure_ascii=False),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, updated.study_id, current_user), operator_role=await get_operator_role_label(db, updated.study_id, current_user),
) )
@@ -157,7 +157,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=json.dumps({"targetName": category_name, "description": f"FAQ 分类 {category_name} 已删除"}, ensure_ascii=False), detail=json.dumps({"targetName": category_name, "description": f"删除“{category_name}”分类"}, ensure_ascii=False),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, category.study_id, current_user), operator_role=await get_operator_role_label(db, category.study_id, current_user),
) )
+18 -5
View File
@@ -1,3 +1,4 @@
import json
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
@@ -30,6 +31,13 @@ def _is_system_admin(current_user) -> bool:
return is_system_admin(current_user) return is_system_admin(current_user)
def _compact_text(value: str | None, max_length: int = 40) -> str:
text = " ".join(str(value or "").split())
if len(text) <= max_length:
return text
return f"{text[:max_length]}..."
@router.post( @router.post(
"/", "/",
response_model=FaqRead, response_model=FaqRead,
@@ -66,13 +74,14 @@ async def create_faq(
reply_in=FaqReplyCreate(content=payload.answer), reply_in=FaqReplyCreate(content=payload.answer),
) )
await faq_crud.set_status(db, item.id, "PROCESSING") await faq_crud.set_status(db, item.id, "PROCESSING")
question_name = _compact_text(item.question)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
study_id=payload.study_id, study_id=payload.study_id,
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 已创建", detail=json.dumps({"targetName": question_name, "description": f"创建医学咨询问题“{question_name}"}, ensure_ascii=False),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, payload.study_id, current_user), operator_role=await get_operator_role_label(db, payload.study_id, current_user),
) )
@@ -170,7 +179,8 @@ async def update_faq(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
updated = await faq_crud.update_item(db, item, payload) updated = await faq_crud.update_item(db, item, payload)
action = "UPDATE_FAQ_ITEM" action = "UPDATE_FAQ_ITEM"
detail = "FAQ updated" question_name = _compact_text(updated.question)
detail = json.dumps({"targetName": question_name, "description": f"更新医学咨询问题“{question_name}"}, ensure_ascii=False)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
study_id=item.study_id, study_id=item.study_id,
@@ -327,13 +337,14 @@ async def create_reply(
if item.status != "RESOLVED": if item.status != "RESOLVED":
await faq_crud.set_status(db, item.id, "PROCESSING") await faq_crud.set_status(db, item.id, "PROCESSING")
await faq_crud.touch_item(db, item.id) await faq_crud.touch_item(db, item.id)
question_name = _compact_text(item.question)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
study_id=item.study_id, study_id=item.study_id,
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 已回复", detail=json.dumps({"targetName": question_name, "description": f"回复医学咨询问题“{question_name}"}, ensure_ascii=False),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, item.study_id, current_user), operator_role=await get_operator_role_label(db, item.study_id, current_user),
) )
@@ -369,6 +380,7 @@ async def delete_faq(
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 不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
question_name = _compact_text(item.question)
await reply_crud.delete_replies_by_faq_id(db, item.id) await reply_crud.delete_replies_by_faq_id(db, item.id)
await db.delete(item) await db.delete(item)
await db.commit() await db.commit()
@@ -378,7 +390,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 已删除", detail=json.dumps({"targetName": question_name, "description": f"删除医学咨询问题“{question_name}"}, ensure_ascii=False),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, item.study_id, current_user), operator_role=await get_operator_role_label(db, item.study_id, current_user),
) )
@@ -403,6 +415,7 @@ async def delete_reply(
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 不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
question_name = _compact_text(item.question)
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="回复不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="回复不存在")
@@ -431,7 +444,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 回复已删除", detail=json.dumps({"targetName": question_name, "description": f"删除医学咨询问题“{question_name}”的回复"}, ensure_ascii=False),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, item.study_id, current_user), operator_role=await get_operator_role_label(db, item.study_id, current_user),
) )
+37 -6
View File
@@ -1,4 +1,5 @@
import uuid import uuid
import json
from decimal import Decimal from decimal import Decimal
from typing import Any from typing import Any
@@ -52,6 +53,34 @@ async def _ensure_center_active(db: AsyncSession, study_id: uuid.UUID, center_id
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
async def _contract_audit_name(db: AsyncSession, contract) -> str:
site = await site_crud.get_site(db, contract.center_id)
center_name = site.name if site else "中心"
contract_no = str(contract.contract_no or "").strip()
return f"{center_name} / {contract_no}" if contract_no else center_name
async def _contract_audit_detail(db: AsyncSession, action: str, contract) -> str:
name = await _contract_audit_name(db, contract)
return json.dumps({"targetName": name, "description": f"{action}合同费用“{name}"}, ensure_ascii=False)
async def _payment_audit_name(db: AsyncSession, contract, payment) -> str:
contract_name = await _contract_audit_name(db, contract)
seq = getattr(payment, "seq", None)
amount = getattr(payment, "amount", None)
if seq:
return f"{contract_name} / 第{seq}"
if amount is not None:
return f"{contract_name} / {amount}"
return contract_name
async def _payment_audit_detail(db: AsyncSession, action: str, contract, payment) -> str:
name = await _payment_audit_name(db, contract, payment)
return json.dumps({"targetName": name, "description": f"{action}合同费用分期“{name}"}, ensure_ascii=False)
def _parse_optional_uuid(value: str | None, detail: str) -> uuid.UUID | None: def _parse_optional_uuid(value: str | None, detail: str) -> uuid.UUID | None:
if not value: if not value:
return None return None
@@ -261,7 +290,7 @@ async def create_contract_fee(
entity_type="contract_fee", entity_type="contract_fee",
entity_id=contract.id, entity_id=contract.id,
action="CREATE_CONTRACT_FEE", action="CREATE_CONTRACT_FEE",
detail="合同费用已创建", detail=await _contract_audit_detail(db, "创建", contract),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, contract.study_id, current_user), operator_role=await get_operator_role_label(db, contract.study_id, current_user),
) )
@@ -291,7 +320,7 @@ async def update_contract_fee(
entity_type="contract_fee", entity_type="contract_fee",
entity_id=contract_id, entity_id=contract_id,
action="UPDATE_CONTRACT_FEE", action="UPDATE_CONTRACT_FEE",
detail="合同费用已更新", detail=await _contract_audit_detail(db, "更新", contract),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, contract.study_id, current_user), operator_role=await get_operator_role_label(db, contract.study_id, current_user),
) )
@@ -313,6 +342,7 @@ async def delete_contract_fee(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
await _ensure_study_access(db, contract.study_id, current_user, "fees_contracts:delete") await _ensure_study_access(db, contract.study_id, current_user, "fees_contracts:delete")
await _ensure_center_active(db, contract.study_id, contract.center_id) await _ensure_center_active(db, contract.study_id, contract.center_id)
contract_detail = await _contract_audit_detail(db, "删除", contract)
await contract_fee_crud.delete_contract_fee(db, contract) await contract_fee_crud.delete_contract_fee(db, contract)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -320,7 +350,7 @@ async def delete_contract_fee(
entity_type="contract_fee", entity_type="contract_fee",
entity_id=contract_id, entity_id=contract_id,
action="DELETE_CONTRACT_FEE", action="DELETE_CONTRACT_FEE",
detail="合同费用已删除", detail=contract_detail,
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, contract.study_id, current_user), operator_role=await get_operator_role_label(db, contract.study_id, current_user),
) )
@@ -350,7 +380,7 @@ async def create_contract_payment(
entity_type="contract_fee_payment", entity_type="contract_fee_payment",
entity_id=payment.id, entity_id=payment.id,
action="CREATE_CONTRACT_FEE_PAYMENT", action="CREATE_CONTRACT_FEE_PAYMENT",
detail="合同费用分期已创建", detail=await _payment_audit_detail(db, "创建", contract, payment),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, contract.study_id, current_user), operator_role=await get_operator_role_label(db, contract.study_id, current_user),
) )
@@ -391,7 +421,7 @@ async def update_contract_payment(
entity_type="contract_fee_payment", entity_type="contract_fee_payment",
entity_id=payment_id, entity_id=payment_id,
action="UPDATE_CONTRACT_FEE_PAYMENT", action="UPDATE_CONTRACT_FEE_PAYMENT",
detail="合同费用分期已更新", detail=await _payment_audit_detail(db, "更新", contract, payment),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, contract.study_id, current_user), operator_role=await get_operator_role_label(db, contract.study_id, current_user),
) )
@@ -415,6 +445,7 @@ async def delete_contract_payment(
if not contract: if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
await _ensure_study_access(db, contract.study_id, current_user, "fees_contracts:update") await _ensure_study_access(db, contract.study_id, current_user, "fees_contracts:update")
payment_detail = await _payment_audit_detail(db, "删除", contract, payment)
await payment_crud.delete_payment(db, payment) await payment_crud.delete_payment(db, payment)
await payment_crud.resequence_payments(db, contract.id) await payment_crud.resequence_payments(db, contract.id)
await audit_crud.log_action( await audit_crud.log_action(
@@ -423,7 +454,7 @@ async def delete_contract_payment(
entity_type="contract_fee_payment", entity_type="contract_fee_payment",
entity_id=payment_id, entity_id=payment_id,
action="DELETE_CONTRACT_FEE_PAYMENT", action="DELETE_CONTRACT_FEE_PAYMENT",
detail="合同费用分期已删除", detail=payment_detail,
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, contract.study_id, current_user), operator_role=await get_operator_role_label(db, contract.study_id, current_user),
) )
+17 -3
View File
@@ -1,4 +1,5 @@
import uuid import uuid
import json
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -24,6 +25,18 @@ def _validate_calibration(need_calibration: bool, calibration_cycle_days: int |
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="需要校准时校准周期必须大于0") raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="需要校准时校准周期必须大于0")
def _equipment_audit_name(item) -> str:
parts = [item.name, item.spec_model, item.brand]
return " / ".join(str(part).strip() for part in parts if str(part or "").strip()) or "设备记录"
def _equipment_audit_detail(action: str, item) -> str:
return json.dumps(
{"targetName": _equipment_audit_name(item), "description": f"{action}设备“{_equipment_audit_name(item)}"},
ensure_ascii=False,
)
@router.post( @router.post(
"/equipment", "/equipment",
response_model=MaterialEquipmentRead, response_model=MaterialEquipmentRead,
@@ -47,7 +60,7 @@ async def create_equipment(
entity_type="material_equipment", entity_type="material_equipment",
entity_id=item.id, entity_id=item.id,
action="CREATE_MATERIAL_EQUIPMENT", action="CREATE_MATERIAL_EQUIPMENT",
detail=f"设备 {item.id} 已创建", detail=_equipment_audit_detail("创建", item),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, study_id, current_user),
) )
@@ -118,7 +131,7 @@ async def update_equipment(
entity_type="material_equipment", entity_type="material_equipment",
entity_id=equipment_id, entity_id=equipment_id,
action="UPDATE_MATERIAL_EQUIPMENT", action="UPDATE_MATERIAL_EQUIPMENT",
detail=f"设备 {equipment_id} 已更新", detail=_equipment_audit_detail("更新", item),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, study_id, current_user),
) )
@@ -140,6 +153,7 @@ async def delete_equipment(
item = await equipment_crud.get_equipment(db, equipment_id) item = await equipment_crud.get_equipment(db, equipment_id)
if not item or item.study_id != study_id: if not item or item.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="设备记录不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="设备记录不存在")
equipment_detail = _equipment_audit_detail("删除", item)
await equipment_crud.delete_equipment(db, item) await equipment_crud.delete_equipment(db, item)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -147,7 +161,7 @@ async def delete_equipment(
entity_type="material_equipment", entity_type="material_equipment",
entity_id=equipment_id, entity_id=equipment_id,
action="DELETE_MATERIAL_EQUIPMENT", action="DELETE_MATERIAL_EQUIPMENT",
detail=f"设备 {equipment_id} 已删除", detail=equipment_detail,
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, study_id, current_user),
) )
+29 -4
View File
@@ -43,6 +43,20 @@ def _role_rank(role: str | None) -> int:
return PROJECT_ROLE_RANK.get(role or "", 0) return PROJECT_ROLE_RANK.get(role or "", 0)
def _user_audit_name(user, user_id: uuid.UUID) -> str:
if user:
for attr in ("full_name", "username", "email"):
value = getattr(user, attr, None)
if value:
return str(value)
return str(user_id)
async def _member_audit_name(db: AsyncSession, user_id: uuid.UUID) -> str:
user = await user_crud.get_by_id(db, user_id)
return _user_audit_name(user, user_id)
async def _ensure_member_mutation_allowed( async def _ensure_member_mutation_allowed(
db: AsyncSession, db: AsyncSession,
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -116,7 +130,7 @@ async def add_member(
entity_id=updated.id, entity_id=updated.id,
action="PROJECT_MEMBER_UPDATED", action="PROJECT_MEMBER_UPDATED",
detail=json.dumps({ detail=json.dumps({
"targetName": str(updated.user_id), "targetName": await _member_audit_name(db, updated.user_id),
"before": {"is_active": existing.is_active, "role_in_study": existing.role_in_study}, "before": {"is_active": existing.is_active, "role_in_study": existing.role_in_study},
"after": {"is_active": updated.is_active, "role_in_study": updated.role_in_study}, "after": {"is_active": updated.is_active, "role_in_study": updated.role_in_study},
}, ensure_ascii=False), }, ensure_ascii=False),
@@ -132,7 +146,10 @@ async def add_member(
entity_type="study_member", entity_type="study_member",
entity_id=member.id, entity_id=member.id,
action="PROJECT_MEMBER_ADDED", action="PROJECT_MEMBER_ADDED",
detail=json.dumps({"targetName": str(member.user_id), "after": {"role_in_study": member.role_in_study, "is_active": member.is_active}}, ensure_ascii=False), detail=json.dumps({
"targetName": await _member_audit_name(db, member.user_id),
"after": {"role_in_study": member.role_in_study, "is_active": member.is_active},
}, ensure_ascii=False),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await _operator_project_role(db, study_id, current_user), operator_role=await _operator_project_role(db, study_id, current_user),
) )
@@ -247,7 +264,11 @@ async def update_member(
entity_type="study_member", entity_type="study_member",
entity_id=updated.id, entity_id=updated.id,
action="PROJECT_MEMBER_UPDATED", action="PROJECT_MEMBER_UPDATED",
detail=json.dumps({"targetName": str(updated.user_id), "before": before_data, "after": after_data}, ensure_ascii=False), detail=json.dumps({
"targetName": await _member_audit_name(db, updated.user_id),
"before": before_data,
"after": after_data,
}, ensure_ascii=False),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await _operator_project_role(db, study_id, current_user), operator_role=await _operator_project_role(db, study_id, current_user),
) )
@@ -288,7 +309,11 @@ async def remove_member(
entity_type="study_member", entity_type="study_member",
entity_id=removed.id, entity_id=removed.id,
action="PROJECT_MEMBER_REMOVED", action="PROJECT_MEMBER_REMOVED",
detail=json.dumps({"targetName": str(removed.user_id), "before": before_data, "after": {"is_active": removed.is_active}}, ensure_ascii=False), detail=json.dumps({
"targetName": _user_audit_name(target_user, removed.user_id),
"before": before_data,
"after": {"is_active": removed.is_active},
}, ensure_ascii=False),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await _operator_project_role(db, study_id, current_user), operator_role=await _operator_project_role(db, study_id, current_user),
) )
+10 -3
View File
@@ -1,4 +1,5 @@
import uuid import uuid
import json
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -28,6 +29,11 @@ async def _ensure_site_name_active(db: AsyncSession, study_id: uuid.UUID, site_n
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
def _precaution_audit_detail(action: str, precaution) -> str:
title = str(precaution.title or "").strip() or "注意事项"
return json.dumps({"targetName": title, "description": f"{action}注意事项“{title}"}, ensure_ascii=False)
@router.post( @router.post(
"/precautions", "/precautions",
response_model=PrecautionRead, response_model=PrecautionRead,
@@ -49,7 +55,7 @@ async def create_precaution(
entity_type="precaution", entity_type="precaution",
entity_id=precaution.id, entity_id=precaution.id,
action="CREATE_PRECAUTION", action="CREATE_PRECAUTION",
detail=f"注意事项 {precaution.title} 已创建", detail=_precaution_audit_detail("创建", precaution),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, study_id, current_user),
) )
@@ -115,7 +121,7 @@ async def update_precaution(
entity_type="precaution", entity_type="precaution",
entity_id=precaution_id, entity_id=precaution_id,
action="UPDATE_PRECAUTION", action="UPDATE_PRECAUTION",
detail=f"注意事项 {precaution_id} 已更新", detail=_precaution_audit_detail("更新", precaution),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, study_id, current_user),
) )
@@ -138,6 +144,7 @@ async def delete_precaution(
if not precaution or precaution.study_id != study_id: if not precaution or precaution.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
await _ensure_site_name_active(db, study_id, precaution.site_name) await _ensure_site_name_active(db, study_id, precaution.site_name)
precaution_detail = _precaution_audit_detail("删除", precaution)
await precaution_crud.delete_precaution(db, precaution) await precaution_crud.delete_precaution(db, precaution)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -145,7 +152,7 @@ async def delete_precaution(
entity_type="precaution", entity_type="precaution",
entity_id=precaution_id, entity_id=precaution_id,
action="DELETE_PRECAUTION", action="DELETE_PRECAUTION",
detail=f"注意事项 {precaution_id} 已删除", detail=precaution_detail,
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, study_id, current_user),
) )
+50 -11
View File
@@ -1,4 +1,5 @@
import uuid import uuid
import json
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -49,6 +50,41 @@ async def _ensure_site_active(db: AsyncSession, site_id: uuid.UUID | None):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="中心已停用") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="中心已停用")
async def _site_label(db: AsyncSession, site_id: uuid.UUID | None, fallback: str = "项目级") -> str:
if not site_id:
return fallback
site = await site_crud.get_site(db, site_id)
return site.name if site else fallback
async def _feasibility_audit_detail(db: AsyncSession, action: str, record) -> str:
site_name = await _site_label(db, record.site_id)
project_no = str(record.project_no or "").strip()
name = f"{site_name} / {project_no}" if project_no else site_name
return json.dumps({"targetName": name, "description": f"{action}立项记录“{name}"}, ensure_ascii=False)
async def _ethics_audit_detail(db: AsyncSession, action: str, record) -> str:
site_name = await _site_label(db, record.site_id)
approval_no = str(record.approval_no or "").strip()
name = f"{site_name} / {approval_no}" if approval_no else site_name
return json.dumps({"targetName": name, "description": f"{action}伦理记录“{name}"}, ensure_ascii=False)
async def _kickoff_audit_detail(db: AsyncSession, action: str, meeting) -> str:
site_name = await _site_label(db, meeting.site_id)
date_text = meeting.kickoff_date.isoformat() if meeting.kickoff_date else ""
name = f"{site_name} / {date_text}" if date_text else site_name
return json.dumps({"targetName": name, "description": f"{action}启动会记录“{name}"}, ensure_ascii=False)
def _training_audit_detail(action: str, record) -> str:
name = str(record.name or "").strip() or "培训授权人员"
site_name = str(record.site_name or "").strip()
target_name = f"{name} / {site_name}" if site_name else name
return json.dumps({"targetName": target_name, "description": f"{action}培训授权人员“{target_name}"}, ensure_ascii=False)
@router.post( @router.post(
"/feasibility", "/feasibility",
response_model=StartupFeasibilityRead, response_model=StartupFeasibilityRead,
@@ -73,7 +109,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="立项记录已创建", detail=await _feasibility_audit_detail(db, "创建", record),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, study_id, current_user),
) )
@@ -147,7 +183,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="立项记录已更新", detail=await _feasibility_audit_detail(db, "更新", record),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, study_id, current_user),
) )
@@ -173,6 +209,7 @@ async def delete_feasibility(
if cra_scope and record.site_id not in cra_scope[0]: if cra_scope and record.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_active(db, record.site_id) await _ensure_site_active(db, record.site_id)
record_detail = await _feasibility_audit_detail(db, "删除", record)
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,
@@ -180,7 +217,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="立项记录已删除", detail=record_detail,
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, study_id, current_user),
) )
@@ -210,7 +247,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="伦理记录已创建", detail=await _ethics_audit_detail(db, "创建", record),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, study_id, current_user),
) )
@@ -284,7 +321,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="伦理记录已更新", detail=await _ethics_audit_detail(db, "更新", record),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, study_id, current_user),
) )
@@ -310,6 +347,7 @@ async def delete_ethics(
if cra_scope and record.site_id not in cra_scope[0]: if cra_scope and record.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_active(db, record.site_id) await _ensure_site_active(db, record.site_id)
record_detail = await _ethics_audit_detail(db, "删除", record)
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,
@@ -317,7 +355,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="伦理记录已删除", detail=record_detail,
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, study_id, current_user),
) )
@@ -347,7 +385,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="启动会记录已创建", detail=await _kickoff_audit_detail(db, "创建", meeting),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, study_id, current_user),
) )
@@ -421,7 +459,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="启动会记录已更新", detail=await _kickoff_audit_detail(db, "更新", meeting),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, study_id, current_user),
) )
@@ -452,7 +490,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="培训授权人员已创建", detail=_training_audit_detail("创建", record),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, study_id, current_user),
) )
@@ -527,7 +565,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="培训授权人员已更新", detail=_training_audit_detail("更新", record),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, study_id, current_user),
) )
@@ -553,6 +591,7 @@ async def delete_training_authorization(
if cra_scope and record.site_name not in cra_scope[1]: if cra_scope and record.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_name_active(db, study_id, record.site_name) await _ensure_site_name_active(db, study_id, record.site_name)
record_detail = _training_audit_detail("删除", record)
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,
@@ -560,7 +599,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="培训授权人员已删除", detail=record_detail,
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, study_id, current_user),
) )
+5 -6
View File
@@ -610,7 +610,6 @@ _SETUP_MODULE_LABELS = {
} }
_SETUP_FIELD_LABELS = { _SETUP_FIELD_LABELS = {
"id": "ID",
"name": "名称", "name": "名称",
"milestone": "里程碑", "milestone": "里程碑",
"planDate": "计划日期", "planDate": "计划日期",
@@ -634,6 +633,8 @@ _SETUP_FIELD_LABELS = {
"confirmDate": "确认日期", "confirmDate": "确认日期",
} }
_SETUP_TECHNICAL_FIELDS = {"id"}
def _setup_value_text(value: Any) -> str: def _setup_value_text(value: Any) -> str:
if value is None: if value is None:
@@ -690,6 +691,8 @@ def _setup_collect_diff_lines(
new_dict = new_value if isinstance(new_value, dict) else {} new_dict = new_value if isinstance(new_value, dict) else {}
keys = sorted(set(old_dict.keys()) | set(new_dict.keys())) keys = sorted(set(old_dict.keys()) | set(new_dict.keys()))
for key in keys: for key in keys:
if key in _SETUP_TECHNICAL_FIELDS:
continue
_setup_collect_diff_lines(module_key, old_dict.get(key), new_dict.get(key), [*path, key], lines) _setup_collect_diff_lines(module_key, old_dict.get(key), new_dict.get(key), [*path, key], lines)
return return
@@ -718,11 +721,7 @@ def _top_level_diff_summary(old: dict | None, new: dict | None) -> str:
if not detail_lines: if not detail_lines:
return module_summary return module_summary
max_lines = 20 return f"{module_summary}; 变更明细:" + "".join(detail_lines)
visible_lines = detail_lines[:max_lines]
if len(detail_lines) > max_lines:
visible_lines.append(f"其余 {len(detail_lines) - max_lines} 项变更已省略")
return f"{module_summary}; 变更明细:" + "".join(visible_lines)
@router.post("/", response_model=StudyRead, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_roles(["ADMIN"]))]) @router.post("/", response_model=StudyRead, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_roles(["ADMIN"]))])
+25 -3
View File
@@ -1,4 +1,5 @@
import uuid import uuid
import json
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -14,6 +15,26 @@ from app.schemas.subject_history import SubjectHistoryCreate, SubjectHistoryRead
router = APIRouter() router = APIRouter()
def _compact_text(value: str | None, max_length: int = 30) -> str:
text = " ".join(str(value or "").split())
if len(text) <= max_length:
return text
return f"{text[:max_length]}..."
def _history_audit_name(subject, history) -> str:
subject_no = getattr(subject, "subject_no", None) or "参与者"
record_date = getattr(history, "record_date", None)
content = _compact_text(getattr(history, "content", None))
suffix = record_date.isoformat() if record_date else content
return f"{subject_no} / {suffix}" if suffix else subject_no
def _history_audit_detail(action: str, subject, history) -> str:
name = _history_audit_name(subject, history)
return json.dumps({"targetName": name, "description": f"{action}病史记录“{name}"}, ensure_ascii=False)
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:
@@ -56,7 +77,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="病史记录已创建", detail=_history_audit_detail("创建", subject, history),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, study_id, current_user),
) )
@@ -131,7 +152,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="病史记录已更新", detail=_history_audit_detail("更新", subject, history),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, study_id, current_user),
) )
@@ -157,6 +178,7 @@ async def delete_history(
history = await history_crud.get_history(db, history_id) history = await history_crud.get_history(db, history_id)
if not history or history.study_id != study_id or history.subject_id != subject_id: if not history or history.study_id != study_id or history.subject_id != subject_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="病史记录不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="病史记录不存在")
history_detail = _history_audit_detail("删除", subject, history)
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,
@@ -164,7 +186,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="病史记录已删除", detail=history_detail,
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, study_id, current_user),
) )
+58 -7
View File
@@ -1,3 +1,4 @@
import json
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
@@ -15,6 +16,29 @@ from app.schemas.subject import SubjectCreate, SubjectRead, SubjectUpdate
router = APIRouter() router = APIRouter()
def _format_date(value):
return value.isoformat() if value else None
async def _subject_audit_snapshot(db: AsyncSession, subject) -> dict:
site_name = None
if subject.site_id:
site = await site_crud.get_site(db, subject.site_id)
site_name = site.name if site else None
return {
"subject_no": subject.subject_no,
"site_name": site_name,
"status": subject.status,
"screening_date": _format_date(subject.screening_date),
"consent_date": _format_date(subject.consent_date),
"enrollment_date": _format_date(subject.enrollment_date),
"baseline_date": _format_date(subject.baseline_date),
"completion_date": _format_date(subject.completion_date),
"actual_medication_count": subject.actual_medication_count,
"drop_reason": subject.drop_reason,
}
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:
@@ -50,13 +74,22 @@ async def create_subject(
subject = await subject_crud.create_subject(db, study_id, subject_in) subject = await subject_crud.create_subject(db, study_id, subject_in)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
after_snapshot = await _subject_audit_snapshot(db, subject)
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="CREATE_SUBJECT", action="CREATE_SUBJECT",
detail=f"参与者 {subject.subject_no} 已创建", detail=json.dumps(
{
"targetName": subject.subject_no,
"description": f"创建参与者 {subject.subject_no}",
"before": None,
"after": after_snapshot,
},
ensure_ascii=False,
),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, study_id, current_user),
) )
@@ -172,21 +205,29 @@ async def update_subject(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_subject_active(db, subject) await _ensure_subject_active(db, subject)
old_status = subject.status old_status = subject.status
before_snapshot = await _subject_audit_snapshot(db, subject)
updated = await subject_crud.update_subject(db, subject, subject_in) updated = await subject_crud.update_subject(db, subject, subject_in)
# 基线/治疗日期是访视计划的唯一推算基准,不能用入组日期替代。 # 基线/治疗日期是访视计划的唯一推算基准,不能用入组日期替代。
if updated.baseline_date: if updated.baseline_date:
await subject_crud.sync_visits_from_baseline(db, updated) await subject_crud.sync_visits_from_baseline(db, updated)
updated = await subject_crud.sync_subject_status(db, updated) updated = await subject_crud.sync_subject_status(db, updated)
detail = None after_snapshot = await _subject_audit_snapshot(db, updated)
if updated.status != old_status: description = f"变更参与者 {updated.subject_no} 状态" if updated.status != old_status else f"更新参与者 {updated.subject_no}"
detail = f"参与者 {updated.subject_no} 状态 {old_status} -> {updated.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 updated.status != old_status else "UPDATE_SUBJECT",
detail=detail or "参与者已更新", detail=json.dumps(
{
"targetName": updated.subject_no,
"description": description,
"before": before_snapshot,
"after": after_snapshot,
},
ensure_ascii=False,
),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, study_id, current_user),
) )
@@ -211,6 +252,8 @@ async def delete_subject(
cra_scope = await get_cra_site_scope(db, study_id, current_user) cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and subject.site_id not in cra_scope[0]: if cra_scope and subject.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
before_snapshot = await _subject_audit_snapshot(db, subject)
target_name = subject.subject_no
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,
@@ -218,7 +261,15 @@ 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_id} 已删除", detail=json.dumps(
{
"targetName": target_name,
"description": f"删除参与者 {target_name}",
"before": before_snapshot,
"after": None,
},
ensure_ascii=False,
),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, study_id, current_user),
) )
+30 -5
View File
@@ -1,4 +1,5 @@
import uuid import uuid
import json
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -15,6 +16,17 @@ from app.schemas.visit import EarlyTerminationCreate, VisitCreate, VisitRead, Vi
router = APIRouter() router = APIRouter()
def _visit_audit_name(subject, visit) -> str:
subject_no = getattr(subject, "subject_no", None) or "参与者"
visit_code = getattr(visit, "visit_code", None) or "访视"
return f"{subject_no} / {visit_code}"
def _visit_audit_detail(action: str, subject, visit) -> str:
name = _visit_audit_name(subject, visit)
return json.dumps({"targetName": name, "description": f"{action}访视“{name}"}, ensure_ascii=False)
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:
@@ -105,7 +117,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_code} 已创建", detail=_visit_audit_detail("创建", subject, visit),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, study_id, current_user),
) )
@@ -158,7 +170,10 @@ async def create_early_termination(
entity_type="visit", entity_type="visit",
entity_id=visit.id, entity_id=visit.id,
action="CREATE_EARLY_TERMINATION", action="CREATE_EARLY_TERMINATION",
detail=f"参与者 {subject.subject_no} 已提前终止", detail=json.dumps(
{"targetName": subject.subject_no, "description": f"创建参与者 {subject.subject_no} 提前终止访视"},
ensure_ascii=False,
),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, study_id, current_user),
) )
@@ -191,18 +206,27 @@ async def update_visit(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="实际访视日期不能早于访视窗口开始日期") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="实际访视日期不能早于访视窗口开始日期")
if visit_in.actual_date and visit.window_end and visit_in.actual_date > visit.window_end: if visit_in.actual_date and visit.window_end and visit_in.actual_date > visit.window_end:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="实际访视日期不能晚于访视窗口结束日期") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="实际访视日期不能晚于访视窗口结束日期")
old_status = visit.status
updated = await visit_crud.update_visit(db, visit, visit_in) updated = await visit_crud.update_visit(db, visit, visit_in)
await subject_crud.sync_subject_status(db, subject) await subject_crud.sync_subject_status(db, subject)
detail = None detail = None
if visit_in.status: if visit_in.status:
detail = f"访视 {visit.visit_code} {visit.status} -> {visit_in.status}" detail = json.dumps(
{
"targetName": _visit_audit_name(subject, updated),
"description": f"变更访视“{_visit_audit_name(subject, updated)}”状态",
"before": {"status": old_status},
"after": {"status": visit_in.status},
},
ensure_ascii=False,
)
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 "访视已更新", detail=detail or _visit_audit_detail("更新", subject, updated),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, study_id, current_user),
) )
@@ -226,6 +250,7 @@ async def delete_visit(
visit = await visit_crud.get_visit(db, visit_id) visit = await visit_crud.get_visit(db, visit_id)
if not visit or visit.subject_id != subject_id or visit.study_id != study_id: if not visit or visit.subject_id != subject_id or visit.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="访视不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="访视不存在")
visit_detail = _visit_audit_detail("删除", subject, visit)
await visit_crud.delete_visit(db, visit) await visit_crud.delete_visit(db, visit)
await subject_crud.sync_subject_status(db, subject) await subject_crud.sync_subject_status(db, subject)
await audit_crud.log_action( await audit_crud.log_action(
@@ -234,7 +259,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_id} 已删除", detail=visit_detail,
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, study_id, current_user),
) )
+2 -5
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import uuid import uuid
from typing import Sequence from typing import Sequence
@@ -65,8 +67,3 @@ async def list_logs(
async def get_log(db: AsyncSession, log_id: uuid.UUID) -> AuditLog | None: async def get_log(db: AsyncSession, log_id: uuid.UUID) -> AuditLog | None:
result = await db.execute(select(AuditLog).where(AuditLog.id == log_id)) result = await db.execute(select(AuditLog).where(AuditLog.id == log_id))
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def delete_log(db: AsyncSession, log: AuditLog) -> None:
await db.delete(log)
await db.commit()
+3 -4
View File
@@ -121,11 +121,10 @@ async def delete(db: AsyncSession, study_id: uuid.UUID) -> None:
from app.models.study_monitoring_strategy import StudyMonitoringStrategy from app.models.study_monitoring_strategy import StudyMonitoringStrategy
from app.models.study_center_confirm import StudyCenterConfirm from app.models.study_center_confirm import StudyCenterConfirm
# 按依赖关系顺序删除关联数据 # 按依赖关系顺序删除关联数据。审计日志必须保留,只解除项目外键,避免硬删除项目时破坏审计链。
# 1. 删除审计日志 await db.execute(sa_update(AuditLog).where(AuditLog.study_id == study_id).values(study_id=None))
await db.execute(sa_delete(AuditLog).where(AuditLog.study_id == study_id))
# 2. 删除FAQ相关(可能有依赖关系) # 1. 删除FAQ相关(可能有依赖关系)
await db.execute(sa_delete(FaqReply).where(FaqReply.study_id == study_id)) await db.execute(sa_delete(FaqReply).where(FaqReply.study_id == study_id))
await db.execute(sa_delete(FaqItem).where(FaqItem.study_id == study_id)) await db.execute(sa_delete(FaqItem).where(FaqItem.study_id == study_id))
await db.execute(sa_delete(FaqCategory).where(FaqCategory.study_id == study_id)) await db.execute(sa_delete(FaqCategory).where(FaqCategory.study_id == study_id))
+4 -2
View File
@@ -56,23 +56,25 @@ def _audit_detail(before: dict | None, after: dict | None) -> str:
def _doc_snapshot(doc: Document) -> dict: def _doc_snapshot(doc: Document) -> dict:
status = doc.status.value if hasattr(doc.status, "value") else str(doc.status)
return { return {
"id": str(doc.id), "id": str(doc.id),
"trial_id": str(doc.trial_id), "trial_id": str(doc.trial_id),
"site_id": str(doc.site_id) if doc.site_id else None, "site_id": str(doc.site_id) if doc.site_id else None,
"etmf_node_id": str(doc.etmf_node_id) if doc.etmf_node_id else None, "etmf_node_id": str(doc.etmf_node_id) if doc.etmf_node_id else None,
"doc_no": doc.doc_no, "doc_no": doc.doc_no,
"status": str(doc.status), "status": status,
"current_effective_version_id": str(doc.current_effective_version_id) if doc.current_effective_version_id else None, "current_effective_version_id": str(doc.current_effective_version_id) if doc.current_effective_version_id else None,
} }
def _version_snapshot(version: DocumentVersion) -> dict: def _version_snapshot(version: DocumentVersion) -> dict:
status = version.status.value if hasattr(version.status, "value") else str(version.status)
return { return {
"id": str(version.id), "id": str(version.id),
"document_id": str(version.document_id), "document_id": str(version.document_id),
"version_no": version.version_no, "version_no": version.version_no,
"status": str(version.status), "status": status,
"file_hash": version.file_hash, "file_hash": version.file_hash,
} }
+23 -2
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import uuid import uuid
import json
from collections import defaultdict from collections import defaultdict
from typing import Iterable from typing import Iterable
@@ -18,6 +19,17 @@ from app.schemas.document import DocumentCreate, DocumentSummary
from app.schemas.etmf import EtmfNodeCreate, EtmfNodeRead, EtmfNodeStatus, EtmfTreeNode, EtmfNodeUpdate from app.schemas.etmf import EtmfNodeCreate, EtmfNodeRead, EtmfNodeStatus, EtmfTreeNode, EtmfNodeUpdate
def _node_audit_snapshot(node: EtmfNode) -> dict[str, object | None]:
return {
"code": node.code,
"name": node.name,
"parent_id": str(node.parent_id) if node.parent_id else None,
"scope_type": node.scope_type,
"required": node.required,
"is_active": node.is_active,
}
def calculate_node_status(node: EtmfNode, documents: Iterable[Document]) -> EtmfNodeStatus: def calculate_node_status(node: EtmfNode, documents: Iterable[Document]) -> EtmfNodeStatus:
docs = list(documents) docs = list(documents)
if not node.is_active: if not node.is_active:
@@ -102,7 +114,10 @@ async def create_node(db: AsyncSession, payload: EtmfNodeCreate, current_user) -
entity_type="ETMF_NODE", entity_type="ETMF_NODE",
entity_id=node.id, entity_id=node.id,
action="ETMF_NODE_CREATED", action="ETMF_NODE_CREATED",
detail=f'{{"code":"{node.code}","name":"{node.name}"}}', detail=json.dumps(
{"targetName": node.name, "after": _node_audit_snapshot(node)},
ensure_ascii=False,
),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, payload.study_id, current_user), operator_role=await get_operator_role_label(db, payload.study_id, current_user),
) )
@@ -124,14 +139,20 @@ async def update_node(db: AsyncSession, node_id: uuid.UUID, payload: EtmfNodeUpd
parent = await etmf_crud.get_node(db, values["parent_id"]) parent = await etmf_crud.get_node(db, values["parent_id"])
if not parent or parent.study_id != node.study_id or parent.id == node.id: if not parent or parent.study_id != node.study_id or parent.id == node.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="父级eTMF目录不合法") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="父级eTMF目录不合法")
before = _node_audit_snapshot(node)
await etmf_crud.update_node(db, node_id, values, commit=False) await etmf_crud.update_node(db, node_id, values, commit=False)
updated_snapshot = {**before, **{key: value for key, value in values.items() if key in before}}
db.add( db.add(
AuditLog( AuditLog(
study_id=node.study_id, study_id=node.study_id,
entity_type="ETMF_NODE", entity_type="ETMF_NODE",
entity_id=node.id, entity_id=node.id,
action="ETMF_NODE_UPDATED", action="ETMF_NODE_UPDATED",
detail="{}", detail=json.dumps(
{"targetName": updated_snapshot.get("name") or before.get("name"), "before": before, "after": updated_snapshot},
ensure_ascii=False,
default=str,
),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, node.study_id, current_user), operator_role=await get_operator_role_label(db, node.study_id, current_user),
) )
+97 -2
View File
@@ -1,4 +1,5 @@
import uuid import uuid
import json
from dataclasses import dataclass from dataclasses import dataclass
import pytest import pytest
@@ -7,11 +8,11 @@ from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.api_permissions import get_my_study_api_permissions, update_study_api_permissions from app.api.v1.api_permissions import get_my_study_api_permissions, update_study_api_permissions
from app.api.v1.members import update_member from app.api.v1.members import add_member, update_member
from app.api.v1.permission_monitoring import resolve_monitoring_scope from app.api.v1.permission_monitoring import resolve_monitoring_scope
from app.api.v1.system_permissions import list_system_permissions from app.api.v1.system_permissions import list_system_permissions
from app.core.deps import require_admin_or_any_project_pm, require_system_permission from app.core.deps import require_admin_or_any_project_pm, require_system_permission
from app.schemas.member import StudyMemberUpdate from app.schemas.member import StudyMemberCreate, StudyMemberUpdate
@dataclass @dataclass
@@ -40,6 +41,26 @@ async def _seed_user(db: AsyncSession, user_id: uuid.UUID, *, is_admin: bool = F
) )
async def _seed_named_user(db: AsyncSession, user_id: uuid.UUID, full_name: str, *, is_admin: bool = False) -> None:
await db.execute(
text(
"""
INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status)
VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status)
"""
),
{
"id": str(user_id),
"email": f"{user_id.hex}@example.com",
"password_hash": "hash",
"full_name": full_name,
"clinical_department": "Clinical",
"is_admin": is_admin,
"status": "ACTIVE",
},
)
async def _seed_study(db: AsyncSession, study_id: uuid.UUID, code: str) -> None: async def _seed_study(db: AsyncSession, study_id: uuid.UUID, code: str) -> None:
await db.execute( await db.execute(
text( text(
@@ -289,3 +310,77 @@ async def test_project_pm_cannot_grant_peer_pm_role(db_session: AsyncSession):
) )
assert exc_info.value.status_code == 403 assert exc_info.value.status_code == 403
@pytest.mark.asyncio
async def test_project_member_add_audit_uses_member_display_name(db_session: AsyncSession):
study_id = uuid.uuid4()
admin_id = uuid.uuid4()
target_user_id = uuid.uuid4()
await _seed_study(db_session, study_id, "MEMBER-AUDIT-NAME")
await _seed_user(db_session, admin_id, is_admin=True)
await _seed_named_user(db_session, target_user_id, "张三")
await db_session.commit()
await add_member(
study_id=study_id,
member_in=StudyMemberCreate(user_id=target_user_id, role_in_study="CRA"),
current_user=UserStub(id=admin_id, is_admin=True),
db=db_session,
)
row = (
await db_session.execute(
text(
"""
SELECT detail
FROM audit_logs
WHERE study_id = :study_id AND action = 'PROJECT_MEMBER_ADDED'
ORDER BY created_at DESC
LIMIT 1
"""
),
{"study_id": str(study_id)},
)
).one()
detail = json.loads(row.detail)
assert detail["targetName"] == "张三"
assert str(target_user_id) not in detail["targetName"]
@pytest.mark.asyncio
async def test_project_member_update_audit_uses_member_display_name(db_session: AsyncSession):
study_id = uuid.uuid4()
admin_id = uuid.uuid4()
target_user_id = uuid.uuid4()
await _seed_study(db_session, study_id, "MEMBER-UPDATE-AUDIT-NAME")
await _seed_user(db_session, admin_id, is_admin=True)
await _seed_named_user(db_session, target_user_id, "李四")
member_id = await _seed_member_return_id(db_session, study_id, target_user_id, "CRA")
await db_session.commit()
await update_member(
study_id=study_id,
member_id=member_id,
member_in=StudyMemberUpdate(role_in_study="CTA"),
current_user=UserStub(id=admin_id, is_admin=True),
db=db_session,
)
row = (
await db_session.execute(
text(
"""
SELECT detail
FROM audit_logs
WHERE study_id = :study_id AND action = 'PROJECT_MEMBER_UPDATED'
ORDER BY created_at DESC
LIMIT 1
"""
),
{"study_id": str(study_id)},
)
).one()
detail = json.loads(row.detail)
assert detail["targetName"] == "李四"
assert str(target_user_id) not in detail["targetName"]
+149
View File
@@ -588,6 +588,155 @@ def test_audit_export_event_uses_export_permission():
assert "require_study_member()" not in event_chunk assert "require_study_member()" not in event_chunk
def test_audit_export_event_does_not_trust_client_detail():
"""导出审计事件的业务描述应由后端生成,不能信任客户端 detail。"""
route_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "audit_logs.py"
source = route_path.read_text()
event_chunk = source[source.index("async def create_audit_event") :]
assert "detail=payload.detail" not in event_chunk
assert "entity_type=payload.entity_type" not in event_chunk
assert "entity_id=payload.entity_id" not in event_chunk
assert "_build_export_audit_detail" in event_chunk
def test_audit_logs_do_not_expose_delete_endpoint():
"""审计日志不能提供单条删除接口,避免破坏审计不可篡改性。"""
route_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "audit_logs.py"
source = route_path.read_text()
assert "@router.delete" not in source
assert "delete_audit_log" not in source
def test_etmf_audit_details_are_structured_json():
"""eTMF 审计详情不能手拼 JSON 或写空对象。"""
service_path = Path(__file__).resolve().parents[1] / "app" / "services" / "etmf_service.py"
source = service_path.read_text()
assert "json.dumps" in source
assert "detail=f'{{" not in source
assert 'detail="{}"' not in source
def test_setup_config_diff_summary_keeps_all_business_changes_and_skips_ids():
"""立项配置审计明细应完整保留业务字段,跳过行内技术 ID。"""
route_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "studies.py"
source = route_path.read_text()
summary_chunk = source[source.index("def _setup_collect_diff_lines") : source.index("@router.post")]
assert '_SETUP_TECHNICAL_FIELDS = {"id"}' in source
assert "if key in _SETUP_TECHNICAL_FIELDS:" in summary_chunk
assert "已省略" not in summary_chunk
assert "max_lines" not in summary_chunk
def test_subject_audit_uses_structured_business_snapshot():
"""参与者审计应把编号放入对象,把业务变化放入详情。"""
route_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "subjects.py"
source = route_path.read_text()
create_subject_source = source[source.index("async def create_subject") : source.index("@router.get")]
update_subject_source = source[source.index("async def update_subject") : source.index("@router.delete")]
delete_subject_source = source[source.index("async def delete_subject") :]
assert "_subject_audit_snapshot" in source
assert '"targetName": subject.subject_no' in create_subject_source
assert '"description": f"创建参与者 {subject.subject_no}"' in create_subject_source
assert '"targetName": updated.subject_no' in update_subject_source
assert '"description": description' in update_subject_source
assert '"targetName": target_name' in delete_subject_source
assert '"description": f"删除参与者 {target_name}"' in delete_subject_source
assert '"before": before_snapshot' in delete_subject_source
assert 'detail=f"参与者 {subject.subject_no} 已创建"' not in source
assert 'f"参与者 {updated.subject_no} 状态' not in source
assert 'detail=f"参与者 {subject_id} 已删除"' not in delete_subject_source
def test_faq_audit_uses_business_descriptions():
"""医学咨询审计详情应使用业务描述,避免 FAQ 技术文案。"""
api_dir = Path(__file__).resolve().parents[1] / "app" / "api" / "v1"
category_source = (api_dir / "faq_categories.py").read_text()
item_source = (api_dir / "faqs.py").read_text()
assert 'description": f"创建“{category.name}”分类"' in category_source
assert 'description": f"更新“{updated.name}”分类"' in category_source
assert 'description": f"删除“{category_name}”分类"' in category_source
assert 'f"创建医学咨询问题“{question_name}"' in item_source
assert 'f"更新医学咨询问题“{question_name}"' in item_source
assert 'f"回复医学咨询问题“{question_name}"' in item_source
assert 'f"删除医学咨询问题“{question_name}"' in item_source
assert 'f"删除医学咨询问题“{question_name}”的回复"' in item_source
assert "FAQ 分类 {category.name} 已创建" not in category_source
assert 'detail="FAQ 已创建"' not in item_source
assert 'detail = "FAQ updated"' not in item_source
assert '"description": "创建医学咨询问题"' not in item_source
def test_domain_audits_use_business_object_names():
"""药品、设备、访视、AE、费用、立项等审计应记录具体业务对象。"""
api_dir = Path(__file__).resolve().parents[1] / "app" / "api" / "v1"
sources = {
name: (api_dir / name).read_text()
for name in [
"drug_shipments.py",
"material_equipments.py",
"visits.py",
"aes.py",
"precautions.py",
"fees_contracts.py",
"startup.py",
"subject_histories.py",
]
}
assert "_shipment_audit_description" in sources["drug_shipments.py"]
assert "_equipment_audit_detail" in sources["material_equipments.py"]
assert "_visit_audit_detail" in sources["visits.py"]
assert "_ae_audit_detail" in sources["aes.py"]
assert "_precaution_audit_detail" in sources["precautions.py"]
assert "_contract_audit_detail" in sources["fees_contracts.py"]
assert "_payment_audit_detail" in sources["fees_contracts.py"]
assert "_feasibility_audit_detail" in sources["startup.py"]
assert "_ethics_audit_detail" in sources["startup.py"]
assert "_kickoff_audit_detail" in sources["startup.py"]
assert "_training_audit_detail" in sources["startup.py"]
assert "_history_audit_detail" in sources["subject_histories.py"]
forbidden_fragments = [
'detail=f"访视 {visit_id} 已删除"',
'detail=f"AE {ae.id} 已创建"',
'detail=f"AE {ae_id} 已删除"',
'detail=f"设备 {item.id} 已创建"',
'detail=f"设备 {equipment_id} 已更新"',
'detail=f"设备 {equipment_id} 已删除"',
'detail=f"注意事项 {precaution_id} 已更新"',
'detail=f"注意事项 {precaution_id} 已删除"',
'detail="合同费用已创建"',
'detail="合同费用已更新"',
'detail="合同费用已删除"',
'detail="合同费用分期已创建"',
'detail="合同费用分期已更新"',
'detail="合同费用分期已删除"',
'detail="立项记录已创建"',
'detail="立项记录已更新"',
'detail="立项记录已删除"',
'detail="伦理记录已创建"',
'detail="伦理记录已更新"',
'detail="伦理记录已删除"',
'detail="启动会记录已创建"',
'detail="启动会记录已更新"',
'detail="培训授权人员已创建"',
'detail="培训授权人员已更新"',
'detail="培训授权人员已删除"',
'detail="病史记录已创建"',
'detail="病史记录已更新"',
'detail="病史记录已删除"',
]
combined_source = "\n".join(sources.values())
for fragment in forbidden_fragments:
assert fragment not in combined_source
def test_user_role_contains_current_project_roles(): def test_user_role_contains_current_project_roles():
assert {"QA", "CTA"} <= set(PROJECT_PERMISSION_ROLES) assert {"QA", "CTA"} <= set(PROJECT_PERMISSION_ROLES)
+12
View File
@@ -8,12 +8,14 @@ from app.crud import study as study_crud
class _FakeDb: class _FakeDb:
def __init__(self): def __init__(self):
self.tables = [] self.tables = []
self.operations = []
self.committed = False self.committed = False
async def execute(self, stmt): async def execute(self, stmt):
table = getattr(getattr(stmt, "table", None), "name", None) table = getattr(getattr(stmt, "table", None), "name", None)
if table: if table:
self.tables.append(table) self.tables.append(table)
self.operations.append((getattr(stmt, "__visit_name__", ""), table))
async def commit(self): async def commit(self):
self.committed = True self.committed = True
@@ -29,3 +31,13 @@ async def test_delete_study_removes_contract_fee_payments_before_contract_fees()
assert "contract_fees" in db.tables assert "contract_fees" in db.tables
assert db.tables.index("contract_fee_payments") < db.tables.index("contract_fees") assert db.tables.index("contract_fee_payments") < db.tables.index("contract_fees")
assert db.committed is True assert db.committed is True
@pytest.mark.asyncio
async def test_delete_study_preserves_audit_logs():
db = _FakeDb()
await study_crud.delete(db, uuid.uuid4())
assert ("delete", "audit_logs") not in db.operations
assert ("update", "audit_logs") in db.operations
+1 -4
View File
@@ -1,11 +1,8 @@
import { apiDelete, apiGet, apiPost } from "./axios"; import { apiGet, apiPost } from "./axios";
import type { ApiListResponse } from "../types/api"; import type { ApiListResponse } from "../types/api";
export const fetchAuditLogs = (studyId: string, params?: Record<string, any>) => export const fetchAuditLogs = (studyId: string, params?: Record<string, any>) =>
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/audit-logs/`, { params }); apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/audit-logs/`, { params });
export const deleteAuditLog = (studyId: string, logId: string) =>
apiDelete(`/api/v1/studies/${studyId}/audit-logs/${logId}`);
export const createAuditEvent = (studyId: string, payload: Record<string, any>) => export const createAuditEvent = (studyId: string, payload: Record<string, any>) =>
apiPost(`/api/v1/studies/${studyId}/audit-logs/events`, payload); apiPost(`/api/v1/studies/${studyId}/audit-logs/events`, payload);
+455 -2
View File
@@ -16,10 +16,34 @@ describe("normalizeAuditEvent", () => {
{} {}
); );
expect(event.diffText).toEqual(["FAQ 分类已删除"]); expect(event.diffText).toEqual(["删除分类(历史日志未记录分类名称)"]);
expect(event.targetName).toBe(""); expect(event.targetName).toBe("");
}); });
it("renders legacy FAQ category actions as business descriptions", () => {
const event = normalizeAuditEvent(
{
action: "CREATE_FAQ_CATEGORY",
detail: JSON.stringify({
targetName: "入排标准",
description: "FAQ 分类 入排标准 已创建",
}),
operator_id: "operator-1",
operator_role: "ADMIN",
entity_type: "faq_category",
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
created_at: "2026-06-04T11:16:04Z",
},
{}
);
expect(event.eventLabel).toBe("新增医学咨询分类");
expect(event.targetTypeLabel).toBe("医学咨询分类");
expect(event.targetName).toBe("入排标准");
expect(event.diffText).toEqual(["创建“入排标准”分类"]);
expect(event.actionText).toBe("创建“入排标准”分类");
});
it("keeps readable shipment identifiers but hides UUID-only shipment identifiers", () => { it("keeps readable shipment identifiers but hides UUID-only shipment identifiers", () => {
const event = normalizeAuditEvent( const event = normalizeAuditEvent(
{ {
@@ -34,7 +58,436 @@ describe("normalizeAuditEvent", () => {
{} {}
); );
expect(event.diffText).toEqual(["药品运输已创建"]); expect(event.diffText).toEqual(["创建药品流向(历史日志未记录对象信息)"]);
expect(event.targetName).toBe(""); expect(event.targetName).toBe("");
}); });
it("moves legacy subject numbers into the target column", () => {
const event = normalizeAuditEvent(
{
action: "CREATE_SUBJECT",
detail: "参与者 S001 已创建",
operator_id: "operator-1",
operator_role: "ADMIN",
entity_type: "subject",
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
created_at: "2026-05-08T15:44:48Z",
},
{}
);
expect(event.eventLabel).toBe("新增参与者");
expect(event.targetTypeLabel).toBe("参与者");
expect(event.targetName).toBe("S001");
expect(event.diffText).toEqual(["创建参与者 S001"]);
expect(event.actionText).toBe("创建参与者 S001");
});
it("maps legacy project member target UUIDs to user display names", () => {
const userId = "169ede81-f2cb-4b44-84c5-c11111111111";
const event = normalizeAuditEvent(
{
action: "PROJECT_MEMBER_UPDATED",
detail: JSON.stringify({
targetName: userId,
before: { role_in_study: "CRA" },
after: { role_in_study: "PM" },
}),
operator_id: "operator-1",
operator_role: "ADMIN",
entity_type: "study_member",
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
created_at: "2026-06-04T10:27:02Z",
},
{ [userId]: "张三" }
);
expect(event.targetName).toBe("张三");
expect(event.targetName).not.toContain(userId);
});
it("maps user UUIDs inside audit detail lines to display names", () => {
const userId = "94dccdc4-5592-4e59-b743-c33333333333";
const event = normalizeAuditEvent(
{
action: "SITE_UPDATED",
detail: JSON.stringify({
targetName: "重庆市璧山区人民医院",
before: { contact: userId },
after: { contact: "汤成泳" },
}),
operator_id: "operator-1",
operator_role: "ADMIN",
entity_type: "site",
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
created_at: "2026-06-04T10:27:02Z",
},
{ [userId]: "李四" }
);
expect(event.diffText).toEqual(["负责人:李四 -> 汤成泳"]);
});
it("does not expose unmapped UUID-only target names", () => {
const event = normalizeAuditEvent(
{
action: "PROJECT_MEMBER_ADDED",
detail: JSON.stringify({
targetName: "307e9a37-530c-4894-8d19-f22222222222",
after: { role_in_study: "PM" },
}),
operator_id: "operator-1",
operator_role: "ADMIN",
entity_type: "study_member",
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
created_at: "2026-06-04T10:27:02Z",
},
{}
);
expect(event.targetName).toBe("");
});
it("translates unknown CRUD action names into readable Chinese labels", () => {
const event = normalizeAuditEvent(
{
action: "CREATE_FAQ_ITEM",
detail: JSON.stringify({ targetName: "药物保存咨询" }),
operator_id: "operator-1",
operator_role: "ADMIN",
entity_type: "faq_item",
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
created_at: "2026-06-04T10:27:02Z",
},
{}
);
expect(event.eventLabel).toBe("新增医学咨询问题");
expect(event.actionText).toBe("新增了医学咨询问题");
expect(event.eventLabel).not.toContain("CREATE_FAQ_ITEM");
expect(event.actionText).not.toContain("CREATE_FAQ_ITEM");
});
it("uses FAQ question titles in generic structured FAQ descriptions", () => {
const event = normalizeAuditEvent(
{
action: "CREATE_FAQ_ITEM",
detail: JSON.stringify({
targetName: "试验期间是否允许接种疫苗?",
description: "创建医学咨询问题",
}),
operator_id: "operator-1",
operator_role: "ADMIN",
entity_type: "faq_item",
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
created_at: "2026-06-09T08:55:44Z",
},
{}
);
expect(event.targetName).toBe("试验期间是否允许接种疫苗?");
expect(event.diffText).toEqual(["创建医学咨询问题“试验期间是否允许接种疫苗?”"]);
expect(event.actionText).toBe("创建医学咨询问题“试验期间是否允许接种疫苗?”");
});
it("keeps action text focused on the business operation when target names exist", () => {
const event = normalizeAuditEvent(
{
action: "CREATE_FAQ_ITEM",
detail: JSON.stringify({ targetName: "药物保存咨询" }),
operator_id: "operator-1",
operator_role: "ADMIN",
entity_type: "faq_item",
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
created_at: "2026-06-04T10:27:02Z",
},
{}
);
expect(event.eventLabel).toBe("新增医学咨询问题");
expect(event.actionText).toBe("新增了医学咨询问题");
expect(event.targetName).toBe("药物保存咨询");
});
it("uses changed fields as action text when the target name duplicates the operation", () => {
const event = normalizeAuditEvent(
{
action: "UPDATE_FAQ_CATEGORY",
detail: JSON.stringify({
targetName: "医学咨询分类",
before: { name: "旧分类" },
after: { name: "新分类" },
}),
operator_id: "operator-1",
operator_role: "ADMIN",
entity_type: "faq_category",
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
created_at: "2026-06-04T10:27:02Z",
},
{}
);
expect(event.eventLabel).toBe("更新医学咨询分类");
expect(event.actionText).toBe("名称:旧分类 -> 新分类");
});
it("keeps setup module summary out of change detail lines", () => {
const event = normalizeAuditEvent(
{
action: "SAVE_STUDY_SETUP_CONFIG",
detail: JSON.stringify({
description:
"变更模块:项目里程碑; 变更明细:项目里程碑 / 参研中心调研+方案讨论会 / 耗时(天):30 -> 28;项目里程碑 / 参研中心调研+方案讨论会 / 计划日期:2026-06-01 -> 2026-06-03;项目里程碑 / 参研中心调研+方案讨论会 / 开始日期:2026-06-01 -> 2026-06-03",
}),
operator_id: "operator-1",
operator_role: "ADMIN",
entity_type: "study_setup_config",
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
created_at: "2026-05-28T09:15:23Z",
},
{}
);
expect(event.actionText).toBe("变更模块:项目里程碑");
expect(event.diffText).toEqual([
"项目里程碑 / 参研中心调研+方案讨论会 / 耗时(天):30 -> 28",
"项目里程碑 / 参研中心调研+方案讨论会 / 计划日期:2026-06-01 -> 2026-06-03",
"项目里程碑 / 参研中心调研+方案讨论会 / 开始日期:2026-06-01 -> 2026-06-03",
]);
});
it("hides legacy setup technical ID lines and omission placeholders", () => {
const event = normalizeAuditEvent(
{
action: "UPDATE_SETUP_CONFIG",
detail:
"变更模块:项目里程碑; 变更明细:项目里程碑 / 参研中心调研+方案讨论会 / ID:未填写 -> 1778477970865_08roj;项目里程碑 / 参研中心调研+方案讨论会 / 名称:未填写 -> 参研中心调研+方案讨论会;其余 115 项变更已省略",
operator_id: "operator-1",
operator_role: "ADMIN",
entity_type: "study_setup_config",
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
created_at: "2026-05-28T09:15:23Z",
},
{}
);
expect(event.diffText).toEqual([
"项目里程碑 / 参研中心调研+方案讨论会 / 名称:未填写 -> 参研中心调研+方案讨论会",
]);
expect((event.diffText || []).join(" ")).not.toContain("ID");
expect((event.diffText || []).join(" ")).not.toContain("已省略");
});
it("renders document version audit details in business language", () => {
const event = normalizeAuditEvent(
{
action: "VERSION_CREATED",
detail: JSON.stringify({
before: null,
after: {
id: "307e9a37-530c-4894-8d19-f22222222222",
document_id: "169ede81-f2cb-4b44-84c5-c11111111111",
version_no: "1.0",
status: "EFFECTIVE",
file_hash: "95ac0061c81793d533906155921871182076c2dd5bbdd1be0b0e09ebdff17894",
},
}),
operator_id: "operator-1",
operator_role: "PM",
entity_type: "DOCUMENT_VERSION",
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
created_at: "2026-05-26T09:42:21Z",
},
{}
);
expect(event.eventLabel).toBe("新增文档版本");
expect(event.targetTypeLabel).toBe("文档版本");
expect(event.actionText).toBe("记录标识:未填写 -> 已生成");
expect(event.diffText).toEqual([
"记录标识:未填写 -> 已生成",
"所属文档:未填写 -> 已关联文档",
"版本号:未填写 -> V1.0",
"版本状态:未填写 -> 已生效",
"文件校验值:未填写 -> 已记录校验值",
]);
const detailText = (event.diffText || []).join(" ");
expect(detailText).not.toContain("字段(id");
expect(detailText).not.toContain("未识别对象");
expect(detailText).not.toContain("DocumentVersionStatus");
expect(detailText).not.toContain("95ac0061");
});
it("renders enum class-prefixed document version statuses in business language", () => {
const event = normalizeAuditEvent(
{
action: "VERSION_CREATED",
detail: JSON.stringify({
before: null,
after: {
status: "DocumentVersionStatus.EFFECTIVE",
},
}),
operator_id: "operator-1",
operator_role: "PM",
entity_type: "DOCUMENT_VERSION",
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
created_at: "2026-05-26T09:42:21Z",
},
{}
);
expect(event.diffText).toEqual(["版本状态:未填写 -> 已生效"]);
expect((event.diffText || []).join(" ")).not.toContain("DocumentVersionStatus");
});
it("renders structured subject delete audits with subject business context", () => {
const event = normalizeAuditEvent(
{
action: "DELETE_SUBJECT",
detail: JSON.stringify({
targetName: "SUBJ-001",
description: "删除参与者 SUBJ-001",
before: {
subject_no: "SUBJ-001",
site_name: "北京协和医院",
status: "SCREENING",
consent_date: "2026-05-01",
baseline_date: null,
},
after: null,
}),
operator_id: "operator-1",
operator_role: "PM",
entity_type: "subject",
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
created_at: "2026-05-26T09:42:21Z",
},
{}
);
expect(event.eventLabel).toBe("删除参与者");
expect(event.targetName).toBe("SUBJ-001");
expect(event.actionText).toBe("删除参与者 SUBJ-001");
expect(event.diffText).toContain("参与者编号:SUBJ-001 -> 未填写");
expect(event.diffText).toContain("中心名称:北京协和医院 -> 未填写");
expect(event.diffText).toContain("知情同意日期:2026-05-01 -> 未填写");
expect((event.diffText || []).join(" ")).not.toContain("字段(subject_no");
});
it("explains legacy subject delete logs that only stored UUIDs", () => {
const event = normalizeAuditEvent(
{
action: "DELETE_SUBJECT",
detail: "参与者 307e9a37-530c-4894-8d19-f22222222222 已删除",
operator_id: "operator-1",
operator_role: "PM",
entity_type: "subject",
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
created_at: "2026-05-26T09:42:21Z",
},
{}
);
expect(event.diffText).toEqual(["删除参与者(历史日志未记录参与者编号)"]);
expect(event.actionText).toBe("删除参与者(历史日志未记录参与者编号)");
});
it("renders legacy drug and equipment audit details with business context", () => {
const shipmentEvent = normalizeAuditEvent(
{
action: "CREATE_DRUG_SHIPMENT",
detail: JSON.stringify({
targetName: "TRACK-001",
description: "药品运输 TRACK-001 已创建",
}),
operator_id: "operator-1",
operator_role: "PM",
entity_type: "drug_shipment",
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
created_at: "2026-05-26T09:37:58Z",
},
{}
);
const equipmentEvent = normalizeAuditEvent(
{
action: "CREATE_MATERIAL_EQUIPMENT",
detail: "设备 307e9a37-530c-4894-8d19-f22222222222 已创建",
operator_id: "operator-1",
operator_role: "PM",
entity_type: "material_equipment",
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
created_at: "2026-05-26T09:36:19Z",
},
{}
);
expect(shipmentEvent.targetName).toBe("TRACK-001");
expect(shipmentEvent.actionText).toBe("创建药品流向“TRACK-001”");
expect(equipmentEvent.actionText).toBe("创建设备(历史日志未记录设备名称)");
expect(equipmentEvent.targetName).toBe("");
});
it("marks generic legacy fee and startup audit details as missing object context", () => {
const feeEvent = normalizeAuditEvent(
{
action: "CREATE_CONTRACT_FEE",
detail: "合同费用已创建",
operator_id: "operator-1",
operator_role: "PM",
entity_type: "contract_fee",
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
created_at: "2026-05-26T09:36:19Z",
},
{}
);
const startupEvent = normalizeAuditEvent(
{
action: "UPDATE_STARTUP_FEASIBILITY",
detail: "立项记录已更新",
operator_id: "operator-1",
operator_role: "PM",
entity_type: "startup_feasibility",
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
created_at: "2026-05-26T09:33:41Z",
},
{}
);
expect(feeEvent.actionText).toBe("创建合同费用(历史日志未记录对象信息)");
expect(startupEvent.actionText).toBe("更新立项记录(历史日志未记录对象信息)");
});
it("renders high-value non-CRUD setup and lock actions in business language", () => {
const lockEvent = normalizeAuditEvent(
{
action: "LOCK_STUDY",
detail: "项目已锁定:真实世界研究 (RWS-001)",
operator_id: "operator-1",
operator_role: "ADMIN",
entity_type: "study",
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
created_at: "2026-06-04T10:27:02Z",
},
{}
);
const rollbackEvent = normalizeAuditEvent(
{
action: "ROLLBACK_SETUP_CONFIG",
detail: "立项配置已回滚并替换当前发布为 v3,草稿分支已切换到对应分支基线",
operator_id: "operator-1",
operator_role: "PM",
entity_type: "study_setup_config",
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
created_at: "2026-06-04T10:27:02Z",
},
{}
);
expect(lockEvent.eventLabel).toBe("锁定项目");
expect(lockEvent.actionText).toBe("锁定了项目");
expect(rollbackEvent.eventLabel).toBe("回滚立项配置");
expect(rollbackEvent.actionText).toBe("回滚了立项配置");
});
}); });
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { formatAuditRow } from "./auditExportFormatter";
import type { AuditEvent } from "..";
const baseEvent: AuditEvent = {
eventType: "DOCUMENT_UPDATED",
eventLabel: "更新文档",
actorId: "operator-1",
actorName: "张三",
actorRole: "PM",
actorRoleLabel: "项目经理",
targetType: "DOCUMENT",
targetTypeLabel: "文档",
targetId: "doc-001",
actionText: "更新了文档",
result: "SUCCESS",
resultLabel: "成功",
timestamp: "2026-06-04T10:27:02Z",
};
describe("formatAuditRow", () => {
it("keeps target identifiers inside a complete Chinese parenthesis pair", () => {
const row = formatAuditRow(baseEvent);
expect(row.description).toContain("对象:文档(doc-001");
expect(row.description).not.toContain("doc-001)");
});
});
@@ -19,9 +19,11 @@ export const formatAuditRow = (event: AuditEvent): AuditExportRow => {
const descriptionParts: string[] = []; const descriptionParts: string[] = [];
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) {
const targetIdentifier = event.targetName || event.targetId;
descriptionParts.push( descriptionParts.push(
`${TEXT.audit.objectLabel}${event.targetTypeLabel || ""}${ `${TEXT.audit.objectLabel}${event.targetTypeLabel || ""}${
event.targetName ? `${event.targetName}` : `${event.targetId}`})` targetIdentifier ? `${targetIdentifier}` : ""
}`
); );
} }
if (event.diffText?.length) { if (event.diffText?.length) {
+407 -25
View File
@@ -1,4 +1,4 @@
import { auditDict } from "./auditDict"; import { auditDict as localizedAuditDict } from "./auditDict";
import { roleDict, getDictLabel, statusDict } from "../dictionaries"; import { roleDict, getDictLabel, statusDict } from "../dictionaries";
import { TEXT } from "../locales"; import { TEXT } from "../locales";
@@ -45,6 +45,94 @@ const entityTypeLabelMap: Record<string, string> = {
DOCUMENT_VERSION: "文档版本", DOCUMENT_VERSION: "文档版本",
DISTRIBUTION: "文档分发", DISTRIBUTION: "文档分发",
ACKNOWLEDGEMENT: "文档签收", ACKNOWLEDGEMENT: "文档签收",
ETMF_NODE: "eTMF 目录",
};
const auditActionVerbMap: Record<string, { labelPrefix: string; actionPrefix: string }> = {
CREATE: { labelPrefix: "新增", actionPrefix: "新增了" },
UPDATE: { labelPrefix: "更新", actionPrefix: "更新了" },
DELETE: { labelPrefix: "删除", actionPrefix: "删除了" },
};
const auditActionTargetMap: Record<string, string> = {
FAQ_CATEGORY: "医学咨询分类",
FAQ_ITEM: "医学咨询问题",
FAQ_REPLY: "医学咨询回复",
PRECAUTION: "注意事项",
STARTUP_FEASIBILITY: "立项记录",
STARTUP_ETHICS: "伦理记录",
KICKOFF_MEETING: "启动会",
TRAINING_AUTH: "培训授权",
SITE: "中心",
SUBJECT: "参与者",
VISIT: "访视",
AE: "不良事件",
SUBJECT_PD: "PD 记录",
SUBJECT_HISTORY: "病史记录",
DRUG_SHIPMENT: "药品流向",
MATERIAL_EQUIPMENT: "设备",
MONITORING_VISIT_ISSUE: "监查访视问题",
CONTRACT_FEE: "合同费用条目",
CONTRACT_FEE_PAYMENT: "合同费用回款",
ATTACHMENT: "附件",
SETUP_CONFIG_VERSION: "立项配置版本",
PROJECT_MILESTONE: "项目里程碑",
DOCUMENT: "文档",
DOCUMENT_VERSION: "文档版本",
DISTRIBUTION: "文档分发",
ACKNOWLEDGEMENT: "文档签收",
};
const explicitAuditActionMap: Record<string, { label: string; actionText: string; targetLabel: string }> = {
LOCK_STUDY: { label: "锁定项目", actionText: "锁定了项目", targetLabel: "项目" },
UNLOCK_STUDY: { label: "解锁项目", actionText: "解锁了项目", targetLabel: "项目" },
ROLLBACK_SETUP_CONFIG: { label: "回滚立项配置", actionText: "回滚了立项配置", targetLabel: "立项配置" },
CHECKOUT_SETUP_CONFIG_BRANCH_DRAFT: { label: "切换立项配置草稿分支", actionText: "切换了立项配置草稿分支", targetLabel: "立项配置" },
CLEAR_SETUP_CONFIG_DRAFT: { label: "清空立项配置草稿", actionText: "清空了立项配置草稿", targetLabel: "立项配置" },
REFILL_SETUP_CONFIG_DRAFT: { label: "回填立项配置草稿", actionText: "从当前发布版本回填了立项配置草稿", targetLabel: "立项配置" },
MERGE_SETUP_CONFIG_TO_MAIN: { label: "合并立项配置主版本", actionText: "合并了立项配置主版本", targetLabel: "立项配置" },
DELETE_SETUP_CONFIG_VERSION: { label: "删除立项配置版本", actionText: "删除了立项配置版本", targetLabel: "立项配置版本" },
CREATE_EARLY_TERMINATION: { label: "创建提前终止访视", actionText: "创建了提前终止访视", targetLabel: "访视" },
VISIT_STATUS_CHANGE: { label: "访视状态变更", actionText: "变更了访视状态", targetLabel: "访视" },
SUBJECT_STATUS_CHANGE: { label: "参与者状态变更", actionText: "变更了参与者状态", targetLabel: "参与者" },
AE_STATUS_CHANGE: { label: "AE 状态变更", actionText: "变更了 AE 状态", targetLabel: "不良事件" },
SITE_STATUS_CHANGED: { label: "中心状态变更", actionText: "变更了中心状态", targetLabel: "中心" },
UPLOAD_FILE: { label: "上传附件", actionText: "上传了附件", targetLabel: "附件" },
IMPORT_MONITORING_VISIT_ISSUE: { label: "导入监查访视问题", actionText: "导入了监查访视问题", targetLabel: "监查访视问题" },
DOCUMENT_CREATED: { label: "新增文档", actionText: "新增了文档", targetLabel: "文档" },
DOCUMENT_UPDATED: { label: "更新文档", actionText: "更新了文档", targetLabel: "文档" },
DOCUMENT_ARCHIVED: { label: "归档文档", actionText: "归档了文档", targetLabel: "文档" },
VERSION_CREATED: { label: "新增文档版本", actionText: "新增了文档版本", targetLabel: "文档版本" },
VERSION_DELETED: { label: "删除文档版本", actionText: "删除了文档版本", targetLabel: "文档版本" },
DISTRIBUTION_CREATED: { label: "发起文档分发", actionText: "发起了文档分发", targetLabel: "文档分发" },
ACK_CREATED: { label: "提交文档回执", actionText: "提交了文档回执", targetLabel: "文档签收" },
ETMF_NODE_CREATED: { label: "新增 eTMF 目录", actionText: "新增了 eTMF 目录", targetLabel: "eTMF 目录" },
ETMF_NODE_UPDATED: { label: "更新 eTMF 目录", actionText: "更新了 eTMF 目录", targetLabel: "eTMF 目录" },
};
export const auditDict: Record<string, { label: string; actionText: string; targetLabel: string }> = {
...localizedAuditDict,
...explicitAuditActionMap,
};
const buildReadableAuditDict = (action?: string, entityType?: string) => {
const actionKey = String(action || "").trim();
if (explicitAuditActionMap[actionKey]) return explicitAuditActionMap[actionKey];
const match = actionKey.match(/^(CREATE|UPDATE|DELETE)_(.+)$/);
if (match) {
const verb = auditActionVerbMap[match[1]];
const target = auditActionTargetMap[match[2]] || toReadableEntityType(entityType);
return {
label: `${verb.labelPrefix}${target}`,
actionText: `${verb.actionPrefix}${target}`,
targetLabel: target,
};
}
return {
label: actionKey ? TEXT.audit.eventFallback : TEXT.audit.eventFallback,
actionText: TEXT.audit.actionFallback,
targetLabel: toReadableEntityType(entityType),
};
}; };
const setupModuleLabelMap: Record<string, string> = { const setupModuleLabelMap: Record<string, string> = {
@@ -75,6 +163,86 @@ const normalizeSetupModuleText = (raw: string): string => {
const normalizeLegacyDetailLine = (line: string): string => { const normalizeLegacyDetailLine = (line: string): string => {
const text = String(line || "").trim(); const text = String(line || "").trim();
if (!text) return ""; if (!text) return "";
if (/^其余\s+\d+\s+项变更已省略$/.test(text)) return "";
if (/\/\s*ID[:]/.test(text) || /^ID[:]/.test(text)) return "";
const legacyFaqCategoryActionMatch = text.match(/^FAQ\s*分类\s+(.+?)\s+已(创建|更新|删除)$/i);
if (legacyFaqCategoryActionMatch) {
const categoryName = legacyFaqCategoryActionMatch[1].trim();
const verb = legacyFaqCategoryActionMatch[2];
const actionVerb = verb === "创建" ? "创建" : verb === "更新" ? "更新" : "删除";
return isUuidText(categoryName) ? `${actionVerb}分类(历史日志未记录分类名称)` : `${actionVerb}${categoryName}”分类`;
}
if (/^FAQ\s*已创建$/i.test(text)) return "创建医学咨询问题";
if (/^FAQ\s*updated$/i.test(text) || /^FAQ\s*已更新$/i.test(text)) return "更新医学咨询问题";
if (/^FAQ\s*已删除$/i.test(text)) return "删除医学咨询问题";
if (/^FAQ\s*已回复$/i.test(text)) return "回复医学咨询问题";
if (/^FAQ\s*回复已删除$/i.test(text)) return "删除医学咨询回复";
const legacyDrugShipmentMatch = text.match(/^药品运输\s+(.+?)\s+已(创建|更新|删除)$/);
if (legacyDrugShipmentMatch) {
const name = legacyDrugShipmentMatch[1].trim();
const verb = legacyDrugShipmentMatch[2];
return isUuidText(name) ? `${verb}药品流向(历史日志未记录对象信息)` : `${verb}药品流向“${name}`;
}
const legacyEquipmentMatch = text.match(/^设备\s+(.+?)\s+已(创建|更新|删除)$/);
if (legacyEquipmentMatch) {
const name = legacyEquipmentMatch[1].trim();
const verb = legacyEquipmentMatch[2];
return isUuidText(name) ? `${verb}设备(历史日志未记录设备名称)` : `${verb}设备“${name}`;
}
const legacyPrecautionMatch = text.match(/^注意事项\s+(.+?)\s+已(创建|更新|删除)$/);
if (legacyPrecautionMatch) {
const name = legacyPrecautionMatch[1].trim();
const verb = legacyPrecautionMatch[2];
return isUuidText(name) ? `${verb}注意事项(历史日志未记录标题)` : `${verb}注意事项“${name}`;
}
const legacyVisitMatch = text.match(/^访视\s+(.+?)\s+已(创建|更新|删除)$/);
if (legacyVisitMatch) {
const name = legacyVisitMatch[1].trim();
const verb = legacyVisitMatch[2];
return isUuidText(name) ? `${verb}访视(历史日志未记录访视号)` : `${verb}访视“${name}`;
}
const legacyAeMatch = text.match(/^AE\s+(.+?)\s+已(创建|删除)$/i);
if (legacyAeMatch) {
const name = legacyAeMatch[1].trim();
const verb = legacyAeMatch[2];
return isUuidText(name) ? `${verb}AE(历史日志未记录 AE 术语)` : `${verb}AE“${name}`;
}
const genericLegacyActionMap: Record<string, string> = {
: "创建药品流向(历史日志未记录对象信息)",
: "更新药品流向(历史日志未记录对象信息)",
: "删除药品流向(历史日志未记录对象信息)",
: "创建设备(历史日志未记录设备名称)",
: "更新设备(历史日志未记录设备名称)",
: "删除设备(历史日志未记录设备名称)",
: "创建合同费用(历史日志未记录对象信息)",
: "更新合同费用(历史日志未记录对象信息)",
: "删除合同费用(历史日志未记录对象信息)",
: "创建合同费用分期(历史日志未记录对象信息)",
: "更新合同费用分期(历史日志未记录对象信息)",
: "删除合同费用分期(历史日志未记录对象信息)",
: "创建立项记录(历史日志未记录对象信息)",
: "更新立项记录(历史日志未记录对象信息)",
: "删除立项记录(历史日志未记录对象信息)",
: "创建伦理记录(历史日志未记录对象信息)",
: "更新伦理记录(历史日志未记录对象信息)",
: "删除伦理记录(历史日志未记录对象信息)",
: "创建启动会记录(历史日志未记录对象信息)",
: "更新启动会记录(历史日志未记录对象信息)",
: "创建培训授权人员(历史日志未记录人员姓名)",
: "更新培训授权人员(历史日志未记录人员姓名)",
: "删除培训授权人员(历史日志未记录人员姓名)",
: "创建病史记录(历史日志未记录参与者)",
: "更新病史记录(历史日志未记录参与者)",
: "删除病史记录(历史日志未记录参与者)",
访: "更新访视(历史日志未记录访视号)",
};
if (genericLegacyActionMap[text]) return genericLegacyActionMap[text];
const legacySubjectActionMatch = text.match(/^参与者\s+(.+?)\s+已(创建|更新|删除)$/);
if (legacySubjectActionMatch) {
const subjectNo = legacySubjectActionMatch[1].trim();
const verb = legacySubjectActionMatch[2];
return isUuidText(subjectNo) ? `${verb}参与者(历史日志未记录参与者编号)` : `${verb}参与者 ${subjectNo}`;
}
const projectionStatusMatch = text.match(/^projection_status=(.+)$/i); const projectionStatusMatch = text.match(/^projection_status=(.+)$/i);
if (projectionStatusMatch) { if (projectionStatusMatch) {
const status = projectionStatusMatch[1].trim().toLowerCase(); const status = projectionStatusMatch[1].trim().toLowerCase();
@@ -107,7 +275,7 @@ const normalizeLegacyDetailLine = (line: string): string => {
const detailMatch = text.match(/^变更明细[:]\s*(.*)$/); const detailMatch = text.match(/^变更明细[:]\s*(.*)$/);
if (detailMatch) { if (detailMatch) {
const detailText = detailMatch[1].trim(); const detailText = detailMatch[1].trim();
return `变更明细:${detailText || "无"}`; return detailText;
} }
if (text === "立项配置已发布") return "立项配置已发布"; if (text === "立项配置已发布") return "立项配置已发布";
if (text.includes("siteEnrollmentPlans")) return text.replaceAll("siteEnrollmentPlans", "中心入组计划"); if (text.includes("siteEnrollmentPlans")) return text.replaceAll("siteEnrollmentPlans", "中心入组计划");
@@ -124,6 +292,89 @@ const isUuidText = (value: unknown): boolean => {
return new RegExp(`^${uuidTextPattern}$`).test(String(value || "").trim()); return new RegExp(`^${uuidTextPattern}$`).test(String(value || "").trim());
}; };
const replaceUuidReferences = (text: string, userMap: Record<string, string>): string => {
return String(text || "").replace(new RegExp(uuidTextPattern, "g"), (uuid) => userMap[uuid] || "未识别对象");
};
const extractLegacySubjectTargetName = (description: string): string => {
const match = String(description || "").trim().match(/^参与者\s+(.+?)\s+已(?:创建|更新|删除)$/);
if (!match) return "";
const subjectNo = match[1].trim();
return isUuidText(subjectNo) ? "" : subjectNo;
};
const extractLegacyFaqCategoryTargetName = (description: string): string => {
const match = String(description || "").trim().match(/^FAQ\s*分类\s+(.+?)\s+已(?:创建|更新|删除)$/i);
if (!match) return "";
const categoryName = match[1].trim();
return isUuidText(categoryName) ? "" : categoryName;
};
const enrichStructuredDescription = (action: string, description: string, targetName: string): string => {
const desc = String(description || "").trim();
const target = String(targetName || "").trim();
if (!desc || !target || desc.includes("“")) return desc;
if (action === "CREATE_FAQ_ITEM" && desc === "创建医学咨询问题") return `创建医学咨询问题“${target}`;
if (action === "UPDATE_FAQ_ITEM" && desc === "更新医学咨询问题") return `更新医学咨询问题“${target}`;
if (action === "DELETE_FAQ_ITEM" && desc === "删除医学咨询问题") return `删除医学咨询问题“${target}`;
if (action === "CREATE_FAQ_REPLY" && desc === "回复医学咨询问题") return `回复医学咨询问题“${target}`;
if (action === "DELETE_FAQ_REPLY" && desc === "删除医学咨询回复") return `删除医学咨询问题“${target}”的回复`;
const genericStructuredActionMap: Record<string, string> = {
CREATE_DRUG_SHIPMENT: "创建药品流向",
UPDATE_DRUG_SHIPMENT: "更新药品流向",
DELETE_DRUG_SHIPMENT: "删除药品流向",
CREATE_MATERIAL_EQUIPMENT: "创建设备",
UPDATE_MATERIAL_EQUIPMENT: "更新设备",
DELETE_MATERIAL_EQUIPMENT: "删除设备",
CREATE_PRECAUTION: "创建注意事项",
UPDATE_PRECAUTION: "更新注意事项",
DELETE_PRECAUTION: "删除注意事项",
CREATE_VISIT: "创建访视",
UPDATE_VISIT: "更新访视",
DELETE_VISIT: "删除访视",
CREATE_AE: "创建AE",
UPDATE_AE: "更新AE",
DELETE_AE: "删除AE",
CREATE_CONTRACT_FEE: "创建合同费用",
UPDATE_CONTRACT_FEE: "更新合同费用",
DELETE_CONTRACT_FEE: "删除合同费用",
CREATE_CONTRACT_FEE_PAYMENT: "创建合同费用分期",
UPDATE_CONTRACT_FEE_PAYMENT: "更新合同费用分期",
DELETE_CONTRACT_FEE_PAYMENT: "删除合同费用分期",
CREATE_STARTUP_FEASIBILITY: "创建立项记录",
UPDATE_STARTUP_FEASIBILITY: "更新立项记录",
DELETE_STARTUP_FEASIBILITY: "删除立项记录",
CREATE_STARTUP_ETHICS: "创建伦理记录",
UPDATE_STARTUP_ETHICS: "更新伦理记录",
DELETE_STARTUP_ETHICS: "删除伦理记录",
CREATE_KICKOFF_MEETING: "创建启动会记录",
UPDATE_KICKOFF_MEETING: "更新启动会记录",
CREATE_TRAINING_AUTH: "创建培训授权人员",
UPDATE_TRAINING_AUTH: "更新培训授权人员",
DELETE_TRAINING_AUTH: "删除培训授权人员",
CREATE_SUBJECT_HISTORY: "创建病史记录",
UPDATE_SUBJECT_HISTORY: "更新病史记录",
DELETE_SUBJECT_HISTORY: "删除病史记录",
};
const structuredPrefix = genericStructuredActionMap[action];
if (structuredPrefix && desc === structuredPrefix) return `${structuredPrefix}${target}`;
return desc;
};
const isSetupModuleSummaryLine = (line: string): boolean => /^变更模块[:]/.test(String(line || "").trim());
const buildActionText = (
dictActionText: string,
diffLines: string[],
summaryText = "",
preferDictAction = false
): string => {
if (summaryText) return summaryText;
if (preferDictAction) return dictActionText;
if (diffLines.length > 0) return diffLines[0];
return dictActionText;
};
const splitAuditDetailSegments = (line: string): string[] => { const splitAuditDetailSegments = (line: string): string[] => {
const text = String(line || "").trim(); const text = String(line || "").trim();
if (!text) return []; if (!text) return [];
@@ -134,6 +385,19 @@ const splitAuditDetailSegments = (line: string): string[] => {
}; };
const auditFieldLabelMap: Record<string, string> = { const auditFieldLabelMap: Record<string, string> = {
id: "记录标识",
trial_id: "所属项目",
study_id: "所属项目",
document_id: "所属文档",
version_id: "文档版本",
distribution_id: "分发记录",
target_id: "分发对象",
site_id: "中心",
etmf_node_id: "eTMF 目录",
current_effective_version_id: "当前生效版本",
parent_version_id: "上一版本",
derived_from_version_id: "来源版本",
owner_id: "负责人",
issue_no: "问题编号", issue_no: "问题编号",
source: "问题来源", source: "问题来源",
monitor_type: "监查类型", monitor_type: "监查类型",
@@ -142,6 +406,7 @@ const auditFieldLabelMap: Record<string, string> = {
recommendation: "建议措施", recommendation: "建议措施",
subject_name: "受试者", subject_name: "受试者",
subject_code: "受试者缩写号", subject_code: "受试者缩写号",
subject_no: "参与者编号",
description: "问题描述", description: "问题描述",
action_taken: "采取措施", action_taken: "采取措施",
follow_up_progress: "跟进计划及进展", follow_up_progress: "跟进计划及进展",
@@ -166,8 +431,75 @@ const auditFieldLabelMap: Record<string, string> = {
city: "城市", city: "城市",
contact: "负责人", contact: "负责人",
phone: "电话", phone: "电话",
doc_no: "文档编号",
doc_type: "文档类型",
title: "文档标题",
scope_type: "文档范围",
version_no: "版本号",
file_hash: "文件校验值",
file_uri: "文件位置",
file_size: "文件大小",
mime_type: "文件类型",
change_summary: "变更说明",
effective_at: "生效时间",
superseded_at: "替代时间",
submitted_at: "提交时间",
approved_at: "批准时间",
withdrawn_at: "撤回时间",
created_at: "创建时间",
created_by: "创建人",
target_type: "分发范围",
ack_type: "回执类型",
acked_at: "回执时间",
note: "备注",
plan_start_date: "计划开始日期", plan_start_date: "计划开始日期",
plan_end_date: "计划结束日期", plan_end_date: "计划结束日期",
screening_date: "筛选日期",
consent_date: "知情同意日期",
enrollment_date: "入组日期",
baseline_date: "基线日期",
completion_date: "完成日期",
actual_medication_count: "实际给药次数",
drop_reason: "脱落原因",
};
const documentVersionStatusLabelMap: Record<string, string> = {
DRAFT: "草稿",
SUBMITTED: "已提交",
APPROVED: "已批准",
EFFECTIVE: "已生效",
SUPERSEDED: "已被替代",
ARCHIVED: "已归档",
WITHDRAWN: "已撤回",
};
const documentStatusLabelMap: Record<string, string> = {
ACTIVE: "使用中",
ARCHIVED: "已归档",
};
const documentScopeTypeLabelMap: Record<string, string> = {
GLOBAL: "项目级文档",
SITE: "中心级文档",
DERIVED: "派生文档",
};
const distributionTargetTypeLabelMap: Record<string, string> = {
SITE: "中心",
ROLE: "角色",
USER: "指定人员",
};
const acknowledgementTypeLabelMap: Record<string, string> = {
RECEIVED: "已接收",
READ: "已阅读",
TRAINED: "已培训",
};
const normalizeEnumToken = (text: string): string => {
const trimmed = String(text || "").trim();
const parts = trimmed.split(".");
return parts[parts.length - 1] || trimmed;
}; };
const stableSerialize = (value: any): string => { const stableSerialize = (value: any): string => {
@@ -202,14 +534,44 @@ const compactObjectValue = (value: Record<string, any>): string => {
return entries.length > 3 ? `${rendered}${entries.length}` : rendered; return entries.length > 3 ? `${rendered}${entries.length}` : rendered;
}; };
const toReadableFieldLabel = (key: string): string => { const toReadableFieldLabel = (key: string, entityType = ""): string => {
const normalized = String(key || "").trim(); const normalized = String(key || "").trim();
if (!normalized) return "字段"; if (!normalized) return "字段";
if (normalized === "status" && entityType === "DOCUMENT_VERSION") return "版本状态";
if (normalized === "status" && entityType === "DOCUMENT") return "文档状态";
if (normalized === "due_at" && (entityType === "DISTRIBUTION" || entityType === "ACKNOWLEDGEMENT")) return "截止日期";
if (auditFieldLabelMap[normalized]) return auditFieldLabelMap[normalized]; if (auditFieldLabelMap[normalized]) return auditFieldLabelMap[normalized];
return `字段(${normalized}`; return `字段(${normalized}`;
}; };
const toReadableValue = (key: string, value: any): string => { const readableUuidPlaceholder = (key: string): string => {
const normalized = String(key || "").trim();
if (normalized === "id") return "已生成";
if (normalized === "trial_id" || normalized === "study_id") return "已关联项目";
if (normalized === "document_id") return "已关联文档";
if (normalized === "version_id" || normalized.endsWith("_version_id")) return "已关联版本";
if (normalized === "distribution_id") return "已关联分发记录";
if (normalized === "site_id") return "已关联中心";
if (normalized === "owner_id" || normalized === "created_by" || normalized.endsWith("_user_id")) return "已关联人员";
if (normalized.endsWith("_id")) return "已关联对象";
return "系统记录";
};
const toReadableEnumValue = (key: string, text: string): string => {
const normalizedText = normalizeEnumToken(text);
if (key === "status") {
return documentVersionStatusLabelMap[normalizedText] || documentStatusLabelMap[normalizedText] || getDictLabel(statusDict, normalizedText) || text;
}
if (key === "scope_type") return documentScopeTypeLabelMap[normalizedText] || text;
if (key === "target_type") return distributionTargetTypeLabelMap[normalizedText] || text;
if (key === "ack_type") return acknowledgementTypeLabelMap[normalizedText] || text;
if (key.toLowerCase().includes("status")) {
return documentVersionStatusLabelMap[normalizedText] || documentStatusLabelMap[normalizedText] || getDictLabel(statusDict, normalizedText) || text;
}
return text;
};
const toReadableValue = (key: string, value: any, userMap: Record<string, string>): string => {
if (value === null || value === undefined || value === "") return "未填写"; if (value === null || value === undefined || value === "") return "未填写";
if (typeof value === "boolean") { if (typeof value === "boolean") {
if (key === "is_active") return value ? "启用" : "停用"; if (key === "is_active") return value ? "启用" : "停用";
@@ -219,14 +581,14 @@ const toReadableValue = (key: string, value: any): string => {
if (typeof value === "string") { if (typeof value === "string") {
const text = value.trim(); const text = value.trim();
if (!text) return "未填写"; if (!text) return "未填写";
if (key.toLowerCase().includes("status")) { if (key === "file_hash") return "已记录校验值";
return getDictLabel(statusDict, text) || text; if (isUuidText(text)) return userMap[text] || readableUuidPlaceholder(key);
} if (key === "version_no") return text.startsWith("V") ? text : `V${text}`;
return text; return toReadableEnumValue(key, text);
} }
if (Array.isArray(value)) { if (Array.isArray(value)) {
if (value.length === 0) return "未填写"; if (value.length === 0) return "未填写";
const rendered = value.slice(0, 3).map((item) => toReadableValue(key, item)).join("、"); const rendered = value.slice(0, 3).map((item) => toReadableValue(key, item, userMap)).join("、");
return value.length > 3 ? `${rendered}${value.length}` : rendered; return value.length > 3 ? `${rendered}${value.length}` : rendered;
} }
if (typeof value === "object") { if (typeof value === "object") {
@@ -235,7 +597,12 @@ const toReadableValue = (key: string, value: any): string => {
return String(value); return String(value);
}; };
const buildDiffText = (before?: Record<string, any>, after?: Record<string, any>) => { const buildDiffText = (
before: Record<string, any> | undefined,
after: Record<string, any> | undefined,
userMap: Record<string, string>,
entityType = ""
) => {
if (!before && !after) return []; if (!before && !after) return [];
const lines: string[] = []; const lines: string[] = [];
const prevObj = before || {}; const prevObj = before || {};
@@ -244,20 +611,16 @@ const buildDiffText = (before?: Record<string, any>, after?: Record<string, any>
const prev = prevObj[key]; const prev = prevObj[key];
const next = nextObj[key]; const next = nextObj[key];
if (isSameValue(prev, next)) return; if (isSameValue(prev, next)) return;
const label = toReadableFieldLabel(key); const label = toReadableFieldLabel(key, entityType);
const prevText = toReadableValue(key, prev); const prevText = toReadableValue(key, prev, userMap);
const nextText = toReadableValue(key, next); const nextText = toReadableValue(key, next, userMap);
lines.push(`${label}${prevText} -> ${nextText}`); lines.push(`${label}${prevText} -> ${nextText}`);
}); });
return lines; return lines;
}; };
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] || buildReadableAuditDict(raw.action, raw.entity_type);
label: raw.action ? `${TEXT.audit.eventFallback}${raw.action}` : TEXT.audit.eventFallback,
actionText: raw.action ? `执行了${raw.action}` : TEXT.audit.actionFallback,
targetLabel: toReadableEntityType(raw.entity_type),
};
let detailObj: any = {}; let detailObj: any = {};
if (raw.detail) { if (raw.detail) {
try { try {
@@ -268,18 +631,39 @@ export const normalizeAuditEvent = (raw: any, userMap: Record<string, string>):
} }
const before = detailObj.before; const before = detailObj.before;
const after = detailObj.after; const after = detailObj.after;
const fallbackDescription = typeof detailObj.description === "string" ? detailObj.description.trim() : ""; const detailTargetName = String(detailObj.targetName || "").trim();
let diffText = detailObj.diffText || buildDiffText(before, after); const rawFallbackDescription = typeof detailObj.description === "string" ? detailObj.description.trim() : "";
const fallbackDescription = enrichStructuredDescription(raw.action, rawFallbackDescription, isUuidText(detailTargetName) ? "" : detailTargetName);
let diffText = detailObj.diffText || buildDiffText(before, after, userMap, raw.entity_type || "");
if ((!Array.isArray(diffText) || diffText.length === 0) && fallbackDescription) { if ((!Array.isArray(diffText) || diffText.length === 0) && fallbackDescription) {
diffText = fallbackDescription diffText = fallbackDescription
.split(/[;\n]/) .split(/[;\n]/)
.map((item: string) => item.trim()) .map((item: string) => item.trim())
.filter(Boolean); .filter(Boolean);
} }
const readableDiffText = (Array.isArray(diffText) ? diffText : diffText ? [String(diffText)] : []) const normalizedDiffText = (Array.isArray(diffText) ? diffText : diffText ? [String(diffText)] : [])
.flatMap((line) => splitAuditDetailSegments(String(line))) .flatMap((line) => splitAuditDetailSegments(String(line)))
.map((line) => normalizeLegacyDetailLine(line)) .map((line) => normalizeLegacyDetailLine(line))
.map((line) => replaceUuidReferences(line, userMap))
.filter(Boolean); .filter(Boolean);
const setupModuleSummary = normalizedDiffText.find((line) => isSetupModuleSummaryLine(line)) || "";
const readableDiffText = normalizedDiffText.filter((line) => !isSetupModuleSummaryLine(line));
const legacySubjectTargetName = extractLegacySubjectTargetName(fallbackDescription);
const legacyFaqCategoryTargetName = extractLegacyFaqCategoryTargetName(fallbackDescription);
const targetName = isUuidText(detailTargetName)
? userMap[detailTargetName] || ""
: detailObj.targetName || legacySubjectTargetName || legacyFaqCategoryTargetName || (isUuidText(raw.entity_id) ? "" : raw.entity_id || "");
const preferDictAction = Boolean(explicitAuditActionMap[raw.action] && fallbackDescription);
const hasStructuredDetail = typeof raw.detail === "string" && raw.detail.trim().startsWith("{");
const actionText =
raw.action === "DELETE_SUBJECT" && hasStructuredDetail && fallbackDescription
? fallbackDescription
: buildActionText(
dict.actionText,
readableDiffText,
setupModuleSummary,
preferDictAction
);
return { return {
eventType: raw.action, eventType: raw.action,
eventLabel: dict.label, eventLabel: dict.label,
@@ -290,8 +674,8 @@ export const normalizeAuditEvent = (raw: any, userMap: Record<string, string>):
targetType: raw.entity_type || "", targetType: raw.entity_type || "",
targetTypeLabel: dict.targetLabel, targetTypeLabel: dict.targetLabel,
targetId: raw.entity_id || "", targetId: raw.entity_id || "",
targetName: detailObj.targetName || (isUuidText(raw.entity_id) ? "" : raw.entity_id || ""), targetName,
actionText: dict.actionText, actionText,
before, before,
after, after,
diffText: readableDiffText, diffText: readableDiffText,
@@ -302,8 +686,6 @@ export const normalizeAuditEvent = (raw: any, userMap: Record<string, string>):
}; };
}; };
export { auditDict };
// Deprecated: keep API-compatible no-op to avoid accidental reliance on browser local storage. // Deprecated: keep API-compatible no-op to avoid accidental reliance on browser local storage.
export const logAudit = async (eventType: string, payload: Record<string, any>) => { export const logAudit = async (eventType: string, payload: Record<string, any>) => {
void eventType; void eventType;
+4
View File
@@ -238,8 +238,12 @@ export const TEXT = {
ISSUE_STATUS_CHANGED: { label: "风险/问题状态变更", actionText: "更新了风险/问题状态", targetLabel: "风险/问题" }, ISSUE_STATUS_CHANGED: { label: "风险/问题状态变更", actionText: "更新了风险/问题状态", targetLabel: "风险/问题" },
UNAUTHORIZED_ACTION_ATTEMPT: { label: "越权操作尝试", actionText: "尝试执行无权限操作", targetLabel: "系统资源" }, UNAUTHORIZED_ACTION_ATTEMPT: { label: "越权操作尝试", actionText: "尝试执行无权限操作", targetLabel: "系统资源" },
INVALID_STATE_TRANSITION_ATTEMPT: { label: "非法状态流转尝试", actionText: "尝试执行非法状态流转", targetLabel: "业务对象" }, INVALID_STATE_TRANSITION_ATTEMPT: { label: "非法状态流转尝试", actionText: "尝试执行非法状态流转", targetLabel: "业务对象" },
PROJECT_MEMBER_ADDED: { label: "项目成员新增", actionText: "添加了项目成员", targetLabel: "项目成员" },
PROJECT_MEMBER_UPDATED: { label: "项目成员变更", actionText: "调整了项目成员", targetLabel: "项目成员" }, PROJECT_MEMBER_UPDATED: { label: "项目成员变更", actionText: "调整了项目成员", targetLabel: "项目成员" },
PROJECT_MEMBER_REMOVED: { label: "项目成员移除", actionText: "移除了项目成员", targetLabel: "项目成员" },
SITE_CRA_BOUND: { label: "中心 CRA 绑定", actionText: "调整了中心与 CRA 的绑定", targetLabel: "中心" }, SITE_CRA_BOUND: { label: "中心 CRA 绑定", actionText: "调整了中心与 CRA 的绑定", targetLabel: "中心" },
SITE_CREATED: { label: "新增中心", actionText: "新增了中心", targetLabel: "中心" },
SITE_UPDATED: { label: "更新中心", actionText: "更新了中心", targetLabel: "中心" },
SITE_STATUS_CHANGED: { label: "中心状态变更", actionText: "更新了中心信息", targetLabel: "中心" }, SITE_STATUS_CHANGED: { label: "中心状态变更", actionText: "更新了中心信息", targetLabel: "中心" },
AUDIT_EXPORT_SYSTEM: { label: "导出系统审计", actionText: "导出了系统审计日志", targetLabel: "审计日志" }, AUDIT_EXPORT_SYSTEM: { label: "导出系统审计", actionText: "导出了系统审计日志", targetLabel: "审计日志" },
AUDIT_EXPORT_PROJECT: { label: "导出项目审计", actionText: "导出了项目审计日志", targetLabel: "审计日志" }, AUDIT_EXPORT_PROJECT: { label: "导出项目审计", actionText: "导出了项目审计日志", targetLabel: "审计日志" },
+13
View File
@@ -29,6 +29,19 @@ describe("admin project route permissions", () => {
expect(source).toContain("studyStore.currentPermissions?.[role]?.[operationKey]"); expect(source).toContain("studyStore.currentPermissions?.[role]?.[operationKey]");
}); });
it("checks admin project permissions against query project id when present", () => {
const source = readRouter();
const accessStart = source.indexOf("const ensureAdminProjectAccess");
const accessEnd = source.indexOf("const ensureRouteProjectMember", accessStart);
const accessBlock = source.slice(accessStart, accessEnd);
expect(accessBlock).toContain("const targetProjectId = getTargetProjectId(to);");
expect(accessBlock).toContain("studyStore.currentStudy?.id !== targetProjectId");
expect(accessBlock).toContain("fetchStudyDetail(targetProjectId)");
expect(accessBlock).toContain("fetchApiEndpointPermissions(targetProjectId)");
expect(accessBlock).not.toContain("typeof to.params.projectId");
});
it("guards project permission configuration with the system-level project config permission", () => { it("guards project permission configuration with the system-level project config permission", () => {
const source = readRouter(); const source = readRouter();
const projectPermissionRouteStart = source.indexOf('name: "AdminPermissionsProject"'); const projectPermissionRouteStart = source.indexOf('name: "AdminPermissionsProject"');
+4 -4
View File
@@ -454,11 +454,11 @@ const ensureAdminProjectAccess = async (to: any, isAdmin: boolean) => {
const permission = to.meta.adminProjectPermission as { module: string; action: "read" | "write" } | undefined; const permission = to.meta.adminProjectPermission as { module: string; action: "read" | "write" } | undefined;
if (!permission) return true; if (!permission) return true;
const routeProjectId = typeof to.params.projectId === "string" ? to.params.projectId : ""; const targetProjectId = getTargetProjectId(to);
if (routeProjectId && studyStore.currentStudy?.id !== routeProjectId) { if (targetProjectId && studyStore.currentStudy?.id !== targetProjectId) {
const [{ data: project }, { data: permissions }] = await Promise.all([ const [{ data: project }, { data: permissions }] = await Promise.all([
fetchStudyDetail(routeProjectId), fetchStudyDetail(targetProjectId),
fetchApiEndpointPermissions(routeProjectId), fetchApiEndpointPermissions(targetProjectId),
]); ]);
studyStore.setCurrentStudy(project); studyStore.setCurrentStudy(project);
studyStore.currentPermissions = permissions; studyStore.currentPermissions = permissions;
+88 -1
View File
@@ -5,6 +5,29 @@ import { resolve } from "node:path";
const readAuditLogsView = () => readFileSync(resolve(__dirname, "./AuditLogs.vue"), "utf8"); const readAuditLogsView = () => readFileSync(resolve(__dirname, "./AuditLogs.vue"), "utf8");
describe("audit logs access", () => { describe("audit logs access", () => {
it("loads audit logs from the selected project context", () => {
const source = readAuditLogsView();
expect(source).toContain("const selectedStudy = ref<Study | null>(null)");
expect(source).toContain("const selectedStudyId = computed({");
expect(source).toContain("get: () => selectedStudy.value?.id || \"\"");
expect(source).toContain("set: (studyId: string) => {");
expect(source).toContain("await fetchAuditLogs(selectedStudyId.value, params)");
expect(source).toContain("await fetchApiEndpointPermissions(selectedStudyId.value)");
expect(source).toContain("await createAuditEvent(selectedStudyId.value,");
expect(source).not.toContain("await fetchAuditLogs(study.currentStudy.id, params)");
});
it("limits non-admin project choices to PM-owned projects", () => {
const source = readAuditLogsView();
expect(source).toContain('const availableItems = isAdmin.value ? items : items.filter((item) => item.role_in_study === "PM");');
expect(source).toContain("studies.value = availableItems;");
expect(source).toContain("availableItems.find((item) => item.id === queryProjectId)");
expect(source).toContain("availableItems.find((item) => item.id === study.currentStudy?.id)");
expect(source).toContain("availableItems[0]");
});
it("allows only admins or authorized PMs to use audit export", () => { it("allows only admins or authorized PMs to use audit export", () => {
const source = readAuditLogsView(); const source = readAuditLogsView();
@@ -16,11 +39,75 @@ describe("audit logs access", () => {
expect(source).toContain("isApiPermissionAllowed"); expect(source).toContain("isApiPermissionAllowed");
expect(source).toContain('permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]'); expect(source).toContain('permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]');
expect(source).toContain('permissionMatrix.value?.[projectRole.value]?.["audit_logs:export"]'); expect(source).toContain('permissionMatrix.value?.[projectRole.value]?.["audit_logs:export"]');
expect(source).toContain('if (scope === "project" && !canProjectExport.value) return;'); expect(source).toContain("if (!canProjectExport.value) return;");
expect(source).toContain('action: "AUDIT_EXPORT_PROJECT"');
expect(source).not.toContain('command="system"');
expect(source).not.toMatch(/auth\.user\?\.role/); expect(source).not.toMatch(/auth\.user\?\.role/);
expect(source).not.toContain('!== "ADMIN"'); expect(source).not.toContain('!== "ADMIN"');
}); });
it("exports all matching audit log pages instead of a fixed first page", () => {
const source = readAuditLogsView();
expect(source).toContain("const fetchAuditLogPages = async");
expect(source).toContain("while (true)");
expect(source).toContain("if (items.length < batchLimit) break;");
expect(source).not.toContain("limit: 2000");
});
it("keeps the audit table compact by removing the duplicate content column", () => {
const source = readAuditLogsView();
expect(source).not.toContain('prop="actionText"');
expect(source).not.toContain("TEXT.modules.adminAuditLogs.columns.action");
expect(source).toContain("TEXT.modules.adminAuditLogs.columns.target");
expect(source).toContain("TEXT.modules.adminAuditLogs.columns.diff");
});
it("shows actor names without avatar icons in the audit table", () => {
const source = readAuditLogsView();
expect(source).toContain('<span class="actor-name">{{ scope.row.actorName }}</span>');
expect(source).not.toContain("actor-avatar");
expect(source).not.toContain("getInitials(scope.row.actorName)");
});
it("lets target and detail columns absorb remaining table width", () => {
const source = readAuditLogsView();
expect(source).toContain('<el-table-column :label="TEXT.modules.adminAuditLogs.columns.target" min-width="240">');
expect(source).toContain('<el-table-column :label="TEXT.modules.adminAuditLogs.columns.diff" min-width="360">');
expect(source).not.toContain(':label="TEXT.modules.adminAuditLogs.columns.target" width="240"');
expect(source).not.toContain(':label="TEXT.modules.adminAuditLogs.columns.diff" width="360"');
});
it("parses diff lines by the last field separator before the arrow", () => {
const source = readAuditLogsView();
expect(source).toMatch(/line\.match\(\/\^\(\.\+\)\[:\]\\s\*\(\.\*\?\)\\s\*->\\s\*\(\.\+\)\$\/\)/);
expect(source).not.toMatch(/line\.match\(\/\^\(\.\+\?\)\(\.\+\?\)\\s\*->\\s\*\(\.\+\)\$\/\)/);
});
it("renders detail object and action as two cards in one row", () => {
const source = readAuditLogsView();
expect(source).toContain('<span class="meta-label">对象</span>');
expect(source).toContain('<span class="meta-label">动作</span>');
expect(source).not.toContain("detail-meta-full");
expect(source).not.toContain("meta-card-full");
});
it("groups setup diff detail rows by business item path", () => {
const source = readAuditLogsView();
expect(source).toContain("const buildDetailDiffGroups = (lines: any[]): DetailDiffGroup[] => {");
expect(source).toContain('const groupedIndex = new Map<string, DetailDiffGroup>();');
expect(source).toContain('title: parts.slice(0, -1).join(" / "),');
expect(source).toContain("field: parts[parts.length - 1],");
expect(source).toContain('class="diff-group-title"');
expect(source).toContain("{{ selectedDiffGroups.length }} 项");
});
it("does not expose legacy startup ethics business labels", () => { it("does not expose legacy startup ethics business labels", () => {
const auditSource = readFileSync(resolve(__dirname, "../../audit/index.ts"), "utf8"); const auditSource = readFileSync(resolve(__dirname, "../../audit/index.ts"), "utf8");
const overviewSource = readFileSync(resolve(__dirname, "../ia/project-overview/overview.adapter.ts"), "utf8"); const overviewSource = readFileSync(resolve(__dirname, "../ia/project-overview/overview.adapter.ts"), "utf8");
+227 -105
View File
@@ -44,6 +44,11 @@
<!-- 筛选栏 --> <!-- 筛选栏 -->
<div class="audit-toolbar unified-action-bar"> <div class="audit-toolbar unified-action-bar">
<el-form :inline="true" :model="filters" class="filter-form"> <el-form :inline="true" :model="filters" class="filter-form">
<div class="filter-item-form">
<el-select v-model="selectedStudyId" filterable :placeholder="TEXT.common.fields.projectName" @change="onProjectChange" class="filter-select-project">
<el-option v-for="project in studies" :key="project.id" :label="project.name" :value="project.id" />
</el-select>
</div>
<div class="filter-item-form"> <div class="filter-item-form">
<el-select v-model="filters.eventType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterEvent" @change="onServerFilterChange" class="filter-select-comp"> <el-select v-model="filters.eventType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterEvent" @change="onServerFilterChange" class="filter-select-comp">
<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" />
@@ -81,7 +86,6 @@
</el-button> </el-button>
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu>
<el-dropdown-item v-if="isAdmin" command="system">{{ TEXT.modules.adminAuditLogs.exportSystem }}</el-dropdown-item>
<el-dropdown-item v-if="canProjectExport" command="project">{{ TEXT.modules.adminAuditLogs.exportProject }}</el-dropdown-item> <el-dropdown-item v-if="canProjectExport" command="project">{{ TEXT.modules.adminAuditLogs.exportProject }}</el-dropdown-item>
</el-dropdown-menu> </el-dropdown-menu>
</template> </template>
@@ -92,26 +96,24 @@
<!-- 日志表格 --> <!-- 日志表格 -->
<div class="unified-section table-section"> <div class="unified-section table-section">
<el-table :data="logs" v-loading="loading" class="audit-table" style="width: 100%" table-layout="fixed"> <el-table :data="logs" v-loading="loading" class="audit-table" style="width: 100%" table-layout="fixed">
<el-table-column prop="timestamp" :label="TEXT.modules.adminAuditLogs.columns.time" width="155"> <el-table-column prop="timestamp" :label="TEXT.modules.adminAuditLogs.columns.time" width="150">
<template #default="scope"> <template #default="scope">
<span class="time-text">{{ displayDateTime(scope.row.timestamp) }}</span> <span class="time-text">{{ displayDateTime(scope.row.timestamp) }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="actorName" :label="TEXT.modules.adminAuditLogs.columns.actor" width="130"> <el-table-column prop="actorName" :label="TEXT.modules.adminAuditLogs.columns.actor" width="120">
<template #default="scope"> <template #default="scope">
<div class="actor-cell"> <div class="actor-cell">
<div class="actor-avatar">{{ getInitials(scope.row.actorName) }}</div>
<span class="actor-name">{{ scope.row.actorName }}</span> <span class="actor-name">{{ scope.row.actorName }}</span>
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="eventLabel" :label="TEXT.modules.adminAuditLogs.columns.event" width="140"> <el-table-column prop="eventLabel" :label="TEXT.modules.adminAuditLogs.columns.event" width="145">
<template #default="scope"> <template #default="scope">
<span class="event-tag">{{ scope.row.eventLabel }}</span> <span class="event-tag">{{ scope.row.eventLabel }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="actionText" :label="TEXT.modules.adminAuditLogs.columns.action" show-overflow-tooltip /> <el-table-column :label="TEXT.modules.adminAuditLogs.columns.target" min-width="240">
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.target">
<template #default="scope"> <template #default="scope">
<div class="target-cell"> <div class="target-cell">
<span v-if="scope.row.targetTypeLabel" class="target-text"> <span v-if="scope.row.targetTypeLabel" class="target-text">
@@ -121,7 +123,7 @@
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.diff"> <el-table-column :label="TEXT.modules.adminAuditLogs.columns.diff" min-width="360">
<template #default="scope"> <template #default="scope">
<div v-if="scope.row.diffText?.length" class="diff-container"> <div v-if="scope.row.diffText?.length" class="diff-container">
<div v-for="(line, idx) in getDiffPreview(scope.row)" :key="idx" class="diff-line">{{ formatDiffLine(line) }}</div> <div v-for="(line, idx) in getDiffPreview(scope.row)" :key="idx" class="diff-line">{{ formatDiffLine(line) }}</div>
@@ -133,7 +135,7 @@
<span v-else class="text-muted">{{ TEXT.audit.emptyValue }}</span> <span v-else class="text-muted">{{ TEXT.audit.emptyValue }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.result" width="80" align="center"> <el-table-column :label="TEXT.modules.adminAuditLogs.columns.result" width="76" align="center">
<template #default="scope"> <template #default="scope">
<span class="result-badge" :class="scope.row.result === 'SUCCESS' ? 'result--success' : 'result--fail'"> <span class="result-badge" :class="scope.row.result === 'SUCCESS' ? 'result--success' : 'result--fail'">
{{ scope.row.resultLabel }} {{ scope.row.resultLabel }}
@@ -199,12 +201,12 @@
</span> </span>
</div> </div>
</div> </div>
<div class="detail-meta-grid detail-meta-full"> <div class="detail-meta-grid">
<div class="meta-card meta-card-full"> <div class="meta-card">
<span class="meta-label">对象</span> <span class="meta-label">对象</span>
<span class="meta-value">{{ selectedLog.targetTypeLabel || TEXT.common.fallback }}<span v-if="selectedLog.targetName">{{ selectedLog.targetName }}</span></span> <span class="meta-value">{{ selectedLog.targetTypeLabel || TEXT.common.fallback }}<span v-if="selectedLog.targetName">{{ selectedLog.targetName }}</span></span>
</div> </div>
<div class="meta-card meta-card-full"> <div class="meta-card">
<span class="meta-label">动作</span> <span class="meta-label">动作</span>
<span class="meta-value">{{ selectedLog.actionText || TEXT.common.fallback }}</span> <span class="meta-value">{{ selectedLog.actionText || TEXT.common.fallback }}</span>
</div> </div>
@@ -215,22 +217,27 @@
<div class="detail-section-title"> <div class="detail-section-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
变更明细 变更明细
<span class="diff-count" v-if="selectedLog.diffText?.length">{{ selectedLog.diffText.length }} </span> <span class="diff-count" v-if="selectedDiffGroups.length">{{ selectedDiffGroups.length }} </span>
</div> </div>
<el-scrollbar max-height="55vh"> <el-scrollbar max-height="55vh">
<div v-if="selectedLog.diffText?.length" class="detail-lines"> <div v-if="selectedDiffGroups.length" class="detail-lines">
<div v-for="(line, idx) in selectedLog.diffText" :key="idx" class="detail-line-row"> <div v-for="(group, idx) in selectedDiffGroups" :key="`${group.title}-${idx}`" class="detail-line-row">
<span class="detail-line-index">{{ Number(idx) + 1 }}</span> <span class="detail-line-index">{{ Number(idx) + 1 }}</span>
<div class="detail-line-content"> <div class="detail-line-content">
<template v-if="parseDiffLine(formatDiffLine(line))"> <span v-if="group.title" class="diff-group-title">{{ group.title }}</span>
<span class="diff-field">{{ parseDiffLine(formatDiffLine(line))!.field }}</span> <div class="diff-group-lines">
<div class="diff-values"> <template v-for="(item, itemIdx) in group.items" :key="itemIdx">
<span class="diff-old">{{ parseDiffLine(formatDiffLine(line))!.oldVal }}</span> <div v-if="item.parsed" class="diff-item-row">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12" class="diff-arrow"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> <span class="diff-field">{{ item.parsed.field }}</span>
<span class="diff-new">{{ parseDiffLine(formatDiffLine(line))!.newVal }}</span> <div class="diff-values">
</div> <span class="diff-old">{{ item.parsed.oldVal }}</span>
</template> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12" class="diff-arrow"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
<span v-else class="detail-line-text">{{ formatDiffLine(line) }}</span> <span class="diff-new">{{ item.parsed.newVal }}</span>
</div>
</div>
<span v-else class="detail-line-text">{{ item.text }}</span>
</template>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -247,10 +254,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { ArrowDown } from "@element-plus/icons-vue"; import { ArrowDown } from "@element-plus/icons-vue";
import { createAuditEvent, fetchAuditLogs } from "../../api/auditLogs"; import { createAuditEvent, fetchAuditLogs } from "../../api/auditLogs";
import { fetchStudies } from "../../api/studies";
import { fetchApiEndpointPermissions } from "../../api/projectPermissions"; import { fetchApiEndpointPermissions } from "../../api/projectPermissions";
import { fetchUsers } from "../../api/users"; import { fetchUsers } from "../../api/users";
import { listMembers } from "../../api/members"; import { listMembers } from "../../api/members";
@@ -263,15 +271,19 @@ import { displayDateTime } from "../../utils/display";
import { getProjectRole, isSystemAdmin } from "../../utils/roles"; import { getProjectRole, isSystemAdmin } from "../../utils/roles";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue"; import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
import type { Study } from "../../types/api";
const study = useStudyStore(); const study = useStudyStore();
const auth = useAuthStore(); const auth = useAuthStore();
const router = useRouter(); const router = useRouter();
const route = useRoute();
const loading = ref(false); const loading = ref(false);
const exportLoading = ref(false); const exportLoading = ref(false);
const logs = ref<any[]>([]); const logs = ref<any[]>([]);
const users = ref<any[]>([]); const users = ref<any[]>([]);
const studies = ref<Study[]>([]);
const selectedStudy = ref<Study | null>(null);
const allLogs = ref<any[]>([]); const allLogs = ref<any[]>([]);
const permissionMatrix = ref<any | null>(null); const permissionMatrix = ref<any | null>(null);
const page = ref(1); const page = ref(1);
@@ -292,13 +304,6 @@ const successCount = computed(() => allLogs.value.filter(l => (l.result || 'SUCC
const failCount = computed(() => allLogs.value.filter(l => l.result === 'FAIL').length); const failCount = computed(() => allLogs.value.filter(l => l.result === 'FAIL').length);
const operatorCount = computed(() => new Set(allLogs.value.map(l => l.actorId || l.actorName)).size); const operatorCount = computed(() => new Set(allLogs.value.map(l => l.actorId || l.actorName)).size);
const getInitials = (name: string) => {
if (!name) return '?';
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
return name.slice(0, 2).toUpperCase();
};
const formatDiffLine = (line: any): string => { const formatDiffLine = (line: any): string => {
if (typeof line === 'string') return line; if (typeof line === 'string') return line;
if (line === null || line === undefined) return '-'; if (line === null || line === undefined) return '-';
@@ -310,11 +315,62 @@ const formatDiffLine = (line: any): string => {
}; };
const parseDiffLine = (line: string): { field: string; oldVal: string; newVal: string } | null => { const parseDiffLine = (line: string): { field: string; oldVal: string; newVal: string } | null => {
const match = line.match(/^(.+?)(.+?)\s*->\s*(.+)$/); const match = line.match(/^(.+)[:]\s*(.*?)\s*->\s*(.+)$/);
if (!match) return null; if (!match) return null;
return { field: match[1], oldVal: match[2], newVal: match[3] }; return { field: match[1], oldVal: match[2], newVal: match[3] };
}; };
type DetailDiffItem = {
text: string;
parsed: { field: string; oldVal: string; newVal: string } | null;
};
type DetailDiffGroup = {
title: string;
items: DetailDiffItem[];
};
const splitGroupedDiffField = (field: string): { title: string; field: string } | null => {
const parts = String(field || "")
.split("/")
.map((item) => item.trim())
.filter(Boolean);
if (parts.length < 3) return null;
return {
title: parts.slice(0, -1).join(" / "),
field: parts[parts.length - 1],
};
};
const buildDetailDiffGroups = (lines: any[]): DetailDiffGroup[] => {
const groups: DetailDiffGroup[] = [];
const groupedIndex = new Map<string, DetailDiffGroup>();
lines.forEach((line) => {
const text = formatDiffLine(line);
const parsed = parseDiffLine(text);
const groupedField = parsed ? splitGroupedDiffField(parsed.field) : null;
if (!parsed || !groupedField) {
groups.push({ title: "", items: [{ text, parsed }] });
return;
}
let group = groupedIndex.get(groupedField.title);
if (!group) {
group = { title: groupedField.title, items: [] };
groupedIndex.set(groupedField.title, group);
groups.push(group);
}
group.items.push({
text,
parsed: { ...parsed, field: groupedField.field },
});
});
return groups;
};
const eventTypeOptions = Object.entries(auditDict).map(([value, cfg]) => ({ const eventTypeOptions = Object.entries(auditDict).map(([value, cfg]) => ({
value, value,
label: cfg.label, label: cfg.label,
@@ -324,8 +380,20 @@ const resolveUserDisplayName = (u: any): string => {
}; };
const userOptions = computed(() => users.value.map((u: any) => ({ label: resolveUserDisplayName(u), value: u.id }))); const userOptions = computed(() => users.value.map((u: any) => ({ label: resolveUserDisplayName(u), value: u.id })));
const selectedDiffGroups = computed(() => buildDetailDiffGroups(selectedLog.value?.diffText || []));
const selectedStudyId = computed({
get: () => selectedStudy.value?.id || "",
set: (studyId: string) => {
selectedStudy.value = studies.value.find((item) => item.id === studyId) || null;
},
});
const isAdmin = computed(() => isSystemAdmin(auth.user)); const isAdmin = computed(() => isSystemAdmin(auth.user));
const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole)); const projectRole = computed(() => {
const role = selectedStudy.value?.id === study.currentStudy?.id
? study.currentStudyRole
: (selectedStudy.value as any)?.role_in_study || null;
return getProjectRole(selectedStudy.value, role);
});
const canProjectExport = computed(() => { const canProjectExport = computed(() => {
if (isAdmin.value) return true; if (isAdmin.value) return true;
return projectRole.value === "PM" && isApiPermissionAllowed(permissionMatrix.value?.[projectRole.value]?.["audit_logs:export"]); return projectRole.value === "PM" && isApiPermissionAllowed(permissionMatrix.value?.[projectRole.value]?.["audit_logs:export"]);
@@ -336,14 +404,31 @@ const canAccessAuditLogs = computed(() => {
}); });
const ensureAccess = () => { const ensureAccess = () => {
if (!selectedStudy.value) {
router.replace("/admin/projects");
return;
}
if (!canAccessAuditLogs.value) { if (!canAccessAuditLogs.value) {
router.replace(study.currentStudy ? "/project/overview" : "/admin/projects"); router.replace(study.currentStudy ? "/project/overview" : "/admin/projects");
} }
}; };
const loadAvailableStudies = async () => {
const { data } = await fetchStudies();
const items = ((data as any).items || []) as Study[];
const availableItems = isAdmin.value ? items : items.filter((item) => item.role_in_study === "PM");
studies.value = availableItems;
const queryProjectId = typeof route.query.projectId === "string" ? route.query.projectId : "";
selectedStudy.value =
availableItems.find((item) => item.id === queryProjectId) ||
availableItems.find((item) => item.id === study.currentStudy?.id) ||
availableItems[0] ||
null;
};
const loadPermissionMatrix = async () => { const loadPermissionMatrix = async () => {
if (!study.currentStudy) return; if (!selectedStudyId.value) return;
const { data } = await fetchApiEndpointPermissions(study.currentStudy.id); const { data } = await fetchApiEndpointPermissions(selectedStudyId.value);
permissionMatrix.value = data; permissionMatrix.value = data;
}; };
@@ -352,8 +437,8 @@ const loadUsers = async () => {
if (isAdmin.value) { if (isAdmin.value) {
const { data } = await fetchUsers({ limit: 500 }); const { data } = await fetchUsers({ limit: 500 });
users.value = Array.isArray(data) ? data : (data as any).items || []; users.value = Array.isArray(data) ? data : (data as any).items || [];
} else if (study.currentStudy) { } else if (selectedStudyId.value) {
const { data } = await listMembers(study.currentStudy.id, { limit: 500 }); const { data } = await listMembers(selectedStudyId.value, { limit: 500 });
const items = Array.isArray(data) ? data : (data as any).items || []; const items = Array.isArray(data) ? data : (data as any).items || [];
users.value = items.map((m: any) => ({ users.value = items.map((m: any) => ({
id: m.user_id, id: m.user_id,
@@ -365,30 +450,37 @@ const loadUsers = async () => {
} }
}; };
const fetchAuditLogPages = async (baseParams: Record<string, any> = {}) => {
const batchLimit = 500;
let skip = 0;
const allItems: any[] = [];
const params: Record<string, any> = {
...baseParams,
skip,
limit: batchLimit,
};
while (true) {
params.skip = skip;
const { data } = await fetchAuditLogs(selectedStudyId.value, params);
const items = Array.isArray(data) ? data : (data as any).items || [];
allItems.push(...items);
if (items.length < batchLimit) break;
skip += items.length;
}
return allItems;
};
const loadLogs = async () => { const loadLogs = async () => {
if (!study.currentStudy) return; if (!selectedStudyId.value) return;
loading.value = true; loading.value = true;
try { try {
if (!permissionMatrix.value) { if (!permissionMatrix.value) {
await loadPermissionMatrix(); await loadPermissionMatrix();
} }
const batchLimit = 500; const allItems = await fetchAuditLogPages({
let skip = 0;
const allItems: any[] = [];
const params: Record<string, any> = {
skip,
limit: batchLimit,
action: filters.value.eventType || undefined, action: filters.value.eventType || undefined,
operator_id: filters.value.operatorId || undefined, operator_id: filters.value.operatorId || undefined,
}; });
while (true) {
params.skip = skip;
const { data } = await fetchAuditLogs(study.currentStudy.id, params);
const items = Array.isArray(data) ? data : (data as any).items || [];
allItems.push(...items);
if (items.length < batchLimit) break;
skip += items.length;
}
enrichLogs(allItems); enrichLogs(allItems);
refreshPagedLogs(); refreshPagedLogs();
} catch (e: any) { } catch (e: any) {
@@ -448,6 +540,23 @@ const onLocalFilterChange = () => {
refreshPagedLogs(); refreshPagedLogs();
}; };
const onProjectChange = async () => {
permissionMatrix.value = null;
users.value = [];
allLogs.value = [];
logs.value = [];
total.value = 0;
page.value = 1;
await router.replace({
path: "/admin/audit-logs",
query: { ...route.query, projectId: selectedStudyId.value || undefined },
});
await loadPermissionMatrix().catch(() => {});
ensureAccess();
await loadUsers();
await loadLogs();
};
const onPageChange = (p: number) => { const onPageChange = (p: number) => {
page.value = p; page.value = p;
refreshPagedLogs(); refreshPagedLogs();
@@ -470,17 +579,13 @@ const openDetail = (log: any) => {
}; };
const fetchAllForExport = async () => { const fetchAllForExport = async () => {
if (!study.currentStudy) return []; if (!selectedStudyId.value) return [];
exportLoading.value = true; exportLoading.value = true;
try { try {
const params: Record<string, any> = { const items = await fetchAuditLogPages({
skip: 0,
limit: 2000,
action: filters.value.eventType || undefined, action: filters.value.eventType || undefined,
operator_id: filters.value.operatorId || undefined, operator_id: filters.value.operatorId || undefined,
}; });
const { data } = await fetchAuditLogs(study.currentStudy.id, params);
const items = Array.isArray(data) ? data : (data as any).items || [];
const userMap = users.value.reduce<Record<string, string>>((acc, cur) => { const userMap = users.value.reduce<Record<string, string>>((acc, cur) => {
acc[cur.id] = resolveUserDisplayName(cur); acc[cur.id] = resolveUserDisplayName(cur);
return acc; return acc;
@@ -504,13 +609,13 @@ const fetchAllForExport = async () => {
}; };
const handleExportCommand = (command: string) => { const handleExportCommand = (command: string) => {
confirmExport(command as "system" | "project"); if (command === "project") confirmExport();
}; };
const confirmExport = async (scope: "system" | "project") => { const confirmExport = async () => {
const currentStudy = study.currentStudy; const currentStudy = selectedStudy.value;
if (!currentStudy) return; if (!currentStudy) return;
if (scope === "project" && !canProjectExport.value) return; if (!canProjectExport.value) return;
const ok = await ElMessageBox.confirm( const ok = await ElMessageBox.confirm(
TEXT.modules.adminAuditLogs.exportConfirm, TEXT.modules.adminAuditLogs.exportConfirm,
TEXT.modules.adminAuditLogs.exportConfirmTitle, TEXT.modules.adminAuditLogs.exportConfirmTitle,
@@ -522,20 +627,10 @@ const confirmExport = async (scope: "system" | "project") => {
ElMessage.warning(TEXT.modules.adminAuditLogs.exportEmpty); ElMessage.warning(TEXT.modules.adminAuditLogs.exportEmpty);
return; return;
} }
const fileName = const fileName = `${TEXT.modules.adminAuditLogs.exportProjectName}_${currentStudy.code || ""}_${new Date().toISOString().slice(0, 10)}`;
scope === "system"
? `${TEXT.modules.adminAuditLogs.exportSystemName}_${new Date().toISOString().slice(0, 10)}`
: `${TEXT.modules.adminAuditLogs.exportProjectName}_${currentStudy.code || ""}_${new Date().toISOString().slice(0, 10)}`;
exportAuditCsv(events, { fileName }); exportAuditCsv(events, { fileName });
await createAuditEvent(currentStudy.id, { await createAuditEvent(selectedStudyId.value, {
action: scope === "system" ? "AUDIT_EXPORT_SYSTEM" : "AUDIT_EXPORT_PROJECT", action: "AUDIT_EXPORT_PROJECT",
entity_type: "audit_log",
entity_id: null,
detail: JSON.stringify(
{ targetName: currentStudy.name, scope, exportedCount: events.length },
null,
0
),
}).catch(() => null); }).catch(() => null);
}; };
@@ -543,6 +638,7 @@ onMounted(async () => {
if (!auth.user && auth.token) { if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {}); await auth.fetchMe().catch(() => {});
} }
await loadAvailableStudies().catch(() => {});
await loadPermissionMatrix().catch(() => {}); await loadPermissionMatrix().catch(() => {});
ensureAccess(); ensureAccess();
await loadUsers(); await loadUsers();
@@ -615,6 +711,7 @@ onMounted(async () => {
} }
.filter-select-comp { width: 132px; } .filter-select-comp { width: 132px; }
.filter-select-project { width: 180px; }
.filter-select-result { width: 96px; } .filter-select-result { width: 96px; }
.date-range-picker-comp { width: 248px !important; } .date-range-picker-comp { width: 248px !important; }
.filter-spacer { flex: 1; } .filter-spacer { flex: 1; }
@@ -637,6 +734,12 @@ onMounted(async () => {
/* 表格 */ /* 表格 */
.table-section { padding: 0; } .table-section { padding: 0; }
.audit-table :deep(.el-table__inner-wrapper::before) { display: none; } .audit-table :deep(.el-table__inner-wrapper::before) { display: none; }
.audit-table :deep(.el-table__cell) {
padding: 8px 0;
}
.audit-table :deep(.cell) {
padding: 0 12px;
}
.time-text { .time-text {
font-size: 12px; font-size: 12px;
@@ -645,26 +748,11 @@ onMounted(async () => {
} }
.actor-cell { .actor-cell {
display: flex; min-width: 0;
align-items: center;
gap: 8px;
}
.actor-avatar {
width: 26px;
height: 26px;
border-radius: 7px;
background: linear-gradient(135deg, var(--ctms-primary), var(--ctms-primary-active));
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
flex-shrink: 0;
} }
.actor-name { .actor-name {
display: block;
font-size: 13px; font-size: 13px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@@ -692,6 +780,23 @@ onMounted(async () => {
font-size: 13px; font-size: 13px;
} }
.target-text {
display: inline-flex;
align-items: baseline;
max-width: 100%;
min-width: 0;
}
.target-text strong {
flex-shrink: 0;
}
.target-text .text-muted {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.text-muted { color: var(--ctms-text-secondary); font-size: 12px; } .text-muted { color: var(--ctms-text-secondary); font-size: 12px; }
.result-badge { .result-badge {
@@ -712,6 +817,7 @@ onMounted(async () => {
color: var(--ctms-text-regular); color: var(--ctms-text-regular);
line-height: 1.5; line-height: 1.5;
overflow: hidden; overflow: hidden;
max-width: 100%;
} }
.diff-line { .diff-line {
@@ -775,10 +881,6 @@ onMounted(async () => {
margin-bottom: 10px; margin-bottom: 10px;
} }
.detail-meta-full {
grid-template-columns: 1fr;
}
.meta-card { .meta-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -789,10 +891,6 @@ onMounted(async () => {
background: var(--ctms-neutral-100); background: var(--ctms-neutral-100);
} }
.meta-card-full {
grid-column: 1 / -1;
}
.meta-label { .meta-label {
font-size: 11px; font-size: 11px;
color: var(--ctms-text-secondary); color: var(--ctms-text-secondary);
@@ -875,12 +973,35 @@ onMounted(async () => {
min-width: 0; min-width: 0;
} }
.diff-group-title {
display: block;
font-size: 13px;
font-weight: 700;
color: var(--ctms-primary);
margin-bottom: 8px;
word-break: break-word;
}
.diff-group-lines {
display: flex;
flex-direction: column;
gap: 8px;
}
.diff-item-row {
display: grid;
grid-template-columns: minmax(72px, 128px) minmax(0, 1fr);
gap: 10px;
align-items: start;
}
.diff-field { .diff-field {
display: inline-block; display: inline-block;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: var(--ctms-primary); color: var(--ctms-text-regular);
margin-bottom: 4px; line-height: 1.8;
word-break: break-word;
} }
.diff-values { .diff-values {
@@ -936,5 +1057,6 @@ onMounted(async () => {
@media (max-width: 768px) { @media (max-width: 768px) {
.stats-row { grid-template-columns: repeat(2, 1fr); } .stats-row { grid-template-columns: repeat(2, 1fr); }
.filter-form { flex-wrap: wrap; } .filter-form { flex-wrap: wrap; }
.diff-item-row { grid-template-columns: 1fr; gap: 4px; }
} }
</style> </style>
@@ -120,4 +120,10 @@ describe("project management access", () => {
expect(projects).not.toContain('audit_export: { read: "audit_logs:read", write: "audit_logs:read" }'); expect(projects).not.toContain('audit_export: { read: "audit_logs:read", write: "audit_logs:read" }');
expect(router).not.toContain('audit_export: { read: "audit_logs:read", write: "audit_logs:read" }'); expect(router).not.toContain('audit_export: { read: "audit_logs:read", write: "audit_logs:read" }');
}); });
it("opens audit logs with an explicit project id", () => {
const projects = readProjects();
expect(projects).toContain('await enterStudy(row, `/admin/audit-logs?projectId=${row.id}`)');
});
}); });
+1 -1
View File
@@ -258,7 +258,7 @@ const goPermissions = (row: Study) => {
}; };
const goAuditLogs = async (row: Study) => { const goAuditLogs = async (row: Study) => {
await enterStudy(row, "/admin/audit-logs"); await enterStudy(row, `/admin/audit-logs?projectId=${row.id}`);
}; };
const handleDelete = async (study: Study) => { const handleDelete = async (study: Study) => {