完善审计日志展示与导出权限
This commit is contained in:
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"]))])
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,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);
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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: "审计日志" },
|
||||||
|
|||||||
@@ -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"');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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}`)');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user