细节优化——1
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
@@ -46,6 +47,8 @@ async def create_ae(
|
|||||||
current_user=Depends(get_current_user),
|
current_user=Depends(get_current_user),
|
||||||
) -> AERead:
|
) -> AERead:
|
||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
|
if ae_in.onset_date and ae_in.onset_date > date.today():
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Onset date cannot be in the future")
|
||||||
member_role = await _get_member_role(db, study_id, current_user.id)
|
member_role = await _get_member_role(db, study_id, current_user.id)
|
||||||
if current_user.role != "ADMIN" and member_role not in ALLOWED_CREATE_ROLES:
|
if current_user.role != "ADMIN" and member_role not in ALLOWED_CREATE_ROLES:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
|
||||||
@@ -124,6 +127,8 @@ async def update_ae(
|
|||||||
current_user=Depends(get_current_user),
|
current_user=Depends(get_current_user),
|
||||||
) -> AERead:
|
) -> AERead:
|
||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
|
if ae_in.onset_date and ae_in.onset_date > date.today():
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Onset date cannot be in the future")
|
||||||
ae = await ae_crud.get_ae(db, ae_id)
|
ae = await ae_crud.get_ae(db, ae_id)
|
||||||
if not ae or ae.study_id != study_id:
|
if not ae or ae.study_id != study_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE not found")
|
||||||
@@ -142,12 +147,24 @@ async def update_ae(
|
|||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
|
||||||
|
|
||||||
old_status = ae.status
|
old_status = ae.status
|
||||||
updated = await ae_crud.update_ae(db, ae, ae_in)
|
detail_before = {
|
||||||
|
"event": ae.term,
|
||||||
|
"onset_date": ae.onset_date,
|
||||||
|
"severity": ae.severity,
|
||||||
|
"status": ae.status,
|
||||||
|
"description": ae.description,
|
||||||
|
}
|
||||||
|
updated = await ae_crud.update_ae(db, study_id, ae, ae_in)
|
||||||
|
detail_after = {
|
||||||
|
"event": updated.term,
|
||||||
|
"onset_date": updated.onset_date,
|
||||||
|
"severity": updated.severity,
|
||||||
|
"status": updated.status,
|
||||||
|
"description": updated.description,
|
||||||
|
}
|
||||||
|
|
||||||
detail = None
|
|
||||||
action = "UPDATE_AE"
|
action = "UPDATE_AE"
|
||||||
if ae_in.status and ae_in.status != old_status:
|
if ae_in.status and ae_in.status != old_status:
|
||||||
detail = f"AE {ae_id} status {old_status} -> {ae_in.status}"
|
|
||||||
action = "AE_STATUS_CHANGE"
|
action = "AE_STATUS_CHANGE"
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
@@ -155,7 +172,7 @@ async def update_ae(
|
|||||||
entity_type="ae",
|
entity_type="ae",
|
||||||
entity_id=ae_id,
|
entity_id=ae_id,
|
||||||
action=action,
|
action=action,
|
||||||
detail=detail or "AE updated",
|
detail=json.dumps({"before": detail_before, "after": detail_after}, ensure_ascii=False, default=str),
|
||||||
operator_id=current_user.id,
|
operator_id=current_user.id,
|
||||||
operator_role=current_user.role,
|
operator_role=current_user.role,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.deps import get_db_session, require_study_member
|
from app.core.deps import get_db_session, require_roles, require_study_member
|
||||||
from app.crud import audit as audit_crud
|
from app.crud import audit as audit_crud
|
||||||
from app.schemas.audit import AuditLogRead
|
from app.schemas.audit import AuditLogRead
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ async def list_audit_logs(
|
|||||||
entity_type: str | None = None,
|
entity_type: str | None = None,
|
||||||
entity_id: uuid.UUID | None = None,
|
entity_id: uuid.UUID | None = None,
|
||||||
action: str | None = None,
|
action: str | None = None,
|
||||||
|
operator_id: uuid.UUID | None = None,
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
@@ -30,7 +31,24 @@ async def list_audit_logs(
|
|||||||
entity_type=entity_type,
|
entity_type=entity_type,
|
||||||
entity_id=entity_id,
|
entity_id=entity_id,
|
||||||
action=action,
|
action=action,
|
||||||
|
operator_id=operator_id,
|
||||||
skip=skip,
|
skip=skip,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
)
|
)
|
||||||
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="Audit log not found")
|
||||||
|
await audit_crud.delete_log(db, log)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from app.core.deps import get_current_user, get_db_session, require_study_member
|
|||||||
from app.crud import comment as comment_crud
|
from app.crud import comment as comment_crud
|
||||||
from app.crud import audit as audit_crud
|
from app.crud import audit as audit_crud
|
||||||
from app.crud import study as study_crud
|
from app.crud import study as study_crud
|
||||||
from app.schemas.comment import CommentCreate, CommentRead
|
from app.schemas.comment import CommentCreate, CommentRead, CommentQuote
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -34,6 +34,18 @@ async def create_comment(
|
|||||||
current_user=Depends(get_current_user),
|
current_user=Depends(get_current_user),
|
||||||
) -> CommentRead:
|
) -> CommentRead:
|
||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
|
quote = None
|
||||||
|
if comment_in.quote_comment_id:
|
||||||
|
quote = await comment_crud.get_comment(db, comment_in.quote_comment_id, include_deleted=True)
|
||||||
|
if (
|
||||||
|
not quote
|
||||||
|
or quote.study_id != study_id
|
||||||
|
or quote.entity_type != entity_type
|
||||||
|
or quote.entity_id != entity_id
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="引用评论不存在")
|
||||||
|
if quote.is_deleted:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="引用评论已删除")
|
||||||
comment = await comment_crud.create_comment(
|
comment = await comment_crud.create_comment(
|
||||||
db,
|
db,
|
||||||
study_id=study_id,
|
study_id=study_id,
|
||||||
@@ -52,7 +64,15 @@ async def create_comment(
|
|||||||
operator_id=current_user.id,
|
operator_id=current_user.id,
|
||||||
operator_role=current_user.role,
|
operator_role=current_user.role,
|
||||||
)
|
)
|
||||||
return comment
|
return CommentRead(
|
||||||
|
id=comment.id,
|
||||||
|
content=comment.content,
|
||||||
|
created_by=comment.created_by,
|
||||||
|
created_at=comment.created_at,
|
||||||
|
quote_comment_id=comment.quote_comment_id,
|
||||||
|
quote=CommentQuote.model_validate(quote) if quote else None,
|
||||||
|
is_deleted=comment.is_deleted,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -68,4 +88,57 @@ async def list_comments(
|
|||||||
) -> list[CommentRead]:
|
) -> list[CommentRead]:
|
||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
comments = await comment_crud.list_comments(db, study_id, entity_type, entity_id)
|
comments = await comment_crud.list_comments(db, study_id, entity_type, entity_id)
|
||||||
return list(comments)
|
quote_ids = {c.quote_comment_id for c in comments if c.quote_comment_id}
|
||||||
|
quote_map = await comment_crud.get_comments_by_ids(db, quote_ids, include_deleted=True)
|
||||||
|
return [
|
||||||
|
CommentRead(
|
||||||
|
id=c.id,
|
||||||
|
content=c.content,
|
||||||
|
created_by=c.created_by,
|
||||||
|
created_at=c.created_at,
|
||||||
|
quote_comment_id=c.quote_comment_id,
|
||||||
|
quote=CommentQuote.model_validate(quote_map.get(c.quote_comment_id)) if c.quote_comment_id else None,
|
||||||
|
is_deleted=c.is_deleted,
|
||||||
|
)
|
||||||
|
for c in comments
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{comment_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
dependencies=[Depends(require_study_member())],
|
||||||
|
)
|
||||||
|
async def delete_comment(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
entity_type: str,
|
||||||
|
entity_id: uuid.UUID,
|
||||||
|
comment_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
comment = await comment_crud.get_comment(db, comment_id, include_deleted=True)
|
||||||
|
if (
|
||||||
|
not comment
|
||||||
|
or comment.study_id != study_id
|
||||||
|
or comment.entity_type != entity_type
|
||||||
|
or comment.entity_id != entity_id
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found")
|
||||||
|
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
|
||||||
|
if role_value != "ADMIN" and comment.created_by != current_user.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
|
||||||
|
if comment.is_deleted:
|
||||||
|
return
|
||||||
|
await comment_crud.soft_delete_comment(db, comment)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type=entity_type,
|
||||||
|
entity_id=entity_id,
|
||||||
|
action="DELETE_COMMENT",
|
||||||
|
detail=f"Comment deleted by {current_user.full_name}",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
|
|||||||
@@ -15,13 +15,9 @@ from app.utils.pagination import paginate
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
def _check_permission_for_scope(study_id: uuid.UUID | None, current_user, member_role: str | None):
|
def _check_permission_for_scope(study_id: uuid.UUID, current_user, member_role: str | None):
|
||||||
if study_id is None:
|
if current_user.role != "ADMIN" and member_role != "PM":
|
||||||
if current_user.role != "ADMIN":
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only admin can manage global FAQ")
|
|
||||||
else:
|
|
||||||
if current_user.role != "ADMIN" and member_role != "PM":
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -36,15 +32,16 @@ async def create_category(
|
|||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
current_user=Depends(get_current_user),
|
current_user=Depends(get_current_user),
|
||||||
) -> CategoryRead:
|
) -> CategoryRead:
|
||||||
|
if not payload.study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="study_id is required")
|
||||||
existing = await category_crud.get_category_by_name(db, payload.study_id, payload.name)
|
existing = await category_crud.get_category_by_name(db, payload.study_id, payload.name)
|
||||||
if existing:
|
if existing:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类名称已存在")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类名称已存在")
|
||||||
member_role = None
|
member_role = None
|
||||||
if payload.study_id:
|
member = await member_crud.get_member(db, payload.study_id, current_user.id)
|
||||||
member = await member_crud.get_member(db, payload.study_id, current_user.id)
|
if not member and current_user.role != "ADMIN":
|
||||||
if not member:
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a study member")
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a study member")
|
member_role = member.role_in_study if member else None
|
||||||
member_role = member.role_in_study
|
|
||||||
_check_permission_for_scope(payload.study_id, current_user, member_role)
|
_check_permission_for_scope(payload.study_id, current_user, member_role)
|
||||||
category = await category_crud.create_category(db, payload)
|
category = await category_crud.create_category(db, payload)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
@@ -68,16 +65,17 @@ async def create_category(
|
|||||||
)
|
)
|
||||||
async def list_categories(
|
async def list_categories(
|
||||||
study_id: uuid.UUID | None = None,
|
study_id: uuid.UUID | None = None,
|
||||||
include_global: bool = True,
|
|
||||||
is_active: bool | None = True,
|
is_active: bool | None = True,
|
||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
current_user=Depends(get_current_user),
|
current_user=Depends(get_current_user),
|
||||||
) -> list[CategoryRead]:
|
) -> list[CategoryRead]:
|
||||||
|
if not study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="study_id is required")
|
||||||
if study_id:
|
if study_id:
|
||||||
member = await member_crud.get_member(db, study_id, current_user.id)
|
member = await member_crud.get_member(db, study_id, current_user.id)
|
||||||
if not member and current_user.role != "ADMIN":
|
if not member and current_user.role != "ADMIN":
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a study member")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a study member")
|
||||||
categories = await category_crud.list_categories(db, study_id, include_global=include_global, is_active=is_active)
|
categories = await category_crud.list_categories(db, study_id, include_global=False, is_active=is_active)
|
||||||
return paginate([CategoryRead.model_validate(c) for c in categories], total=len(categories))
|
return paginate([CategoryRead.model_validate(c) for c in categories], total=len(categories))
|
||||||
|
|
||||||
|
|
||||||
@@ -105,11 +103,12 @@ async def update_category(
|
|||||||
existing = await category_crud.get_category_by_name(db, target_study_id, target_name)
|
existing = await category_crud.get_category_by_name(db, target_study_id, target_name)
|
||||||
if existing and existing.id != category.id:
|
if existing and existing.id != category.id:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类名称已存在")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类名称已存在")
|
||||||
if target_study_id:
|
if not target_study_id:
|
||||||
member = await member_crud.get_member(db, target_study_id, current_user.id)
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="study_id is required")
|
||||||
member_role = member.role_in_study if member else None
|
member = await member_crud.get_member(db, target_study_id, current_user.id)
|
||||||
if not member and current_user.role != "ADMIN":
|
member_role = member.role_in_study if member else None
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a study member")
|
if not member and current_user.role != "ADMIN":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a study member")
|
||||||
_check_permission_for_scope(target_study_id, current_user, member_role)
|
_check_permission_for_scope(target_study_id, current_user, member_role)
|
||||||
updated = await category_crud.update_category(db, category, payload)
|
updated = await category_crud.update_category(db, category, payload)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
|
|||||||
+37
-44
@@ -25,22 +25,14 @@ from app.utils.pagination import paginate
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
def _check_write_permission(study_id: uuid.UUID | None, current_user, member_role: str | None):
|
def _check_write_permission(study_id: uuid.UUID, current_user, member_role: str | None):
|
||||||
if study_id is None:
|
if current_user.role != "ADMIN" and member_role != "PM":
|
||||||
if current_user.role != "ADMIN":
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only admin can manage global FAQ")
|
|
||||||
else:
|
|
||||||
if current_user.role != "ADMIN" and member_role != "PM":
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
|
|
||||||
|
|
||||||
|
|
||||||
def _check_create_permission(study_id: uuid.UUID | None, current_user, is_member: bool):
|
def _check_create_permission(current_user, is_member: bool):
|
||||||
if study_id is None:
|
if current_user.role != "ADMIN" and not is_member:
|
||||||
if current_user.role != "ADMIN":
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a study member")
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only admin can manage global FAQ")
|
|
||||||
else:
|
|
||||||
if current_user.role != "ADMIN" and not is_member:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a study member")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -55,6 +47,8 @@ async def create_faq(
|
|||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
current_user=Depends(get_current_user),
|
current_user=Depends(get_current_user),
|
||||||
) -> FaqRead:
|
) -> FaqRead:
|
||||||
|
if not payload.study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="study_id is required")
|
||||||
cat = await category_crud.get_category(db, payload.category_id)
|
cat = await category_crud.get_category(db, payload.category_id)
|
||||||
if not cat:
|
if not cat:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found")
|
||||||
@@ -62,11 +56,10 @@ async def create_faq(
|
|||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Category scope mismatch")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Category scope mismatch")
|
||||||
member_role = None
|
member_role = None
|
||||||
is_member = False
|
is_member = False
|
||||||
if payload.study_id:
|
member = await member_crud.get_member(db, payload.study_id, current_user.id)
|
||||||
member = await member_crud.get_member(db, payload.study_id, current_user.id)
|
member_role = member.role_in_study if member else None
|
||||||
member_role = member.role_in_study if member else None
|
is_member = member is not None
|
||||||
is_member = member is not None
|
_check_create_permission(current_user, is_member)
|
||||||
_check_create_permission(payload.study_id, current_user, is_member)
|
|
||||||
try:
|
try:
|
||||||
item = await faq_crud.create_item(db, payload, created_by=current_user.id)
|
item = await faq_crud.create_item(db, payload, created_by=current_user.id)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
@@ -104,7 +97,6 @@ async def list_faqs(
|
|||||||
category_id: uuid.UUID | None = None,
|
category_id: uuid.UUID | None = None,
|
||||||
keyword: str | None = None,
|
keyword: str | None = None,
|
||||||
is_active: bool | None = None,
|
is_active: bool | None = None,
|
||||||
study_scope: str | None = "project",
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
current_user=Depends(get_current_user),
|
current_user=Depends(get_current_user),
|
||||||
) -> list[FaqRead]:
|
) -> list[FaqRead]:
|
||||||
@@ -118,16 +110,15 @@ async def list_faqs(
|
|||||||
membership_cache[sid] = role
|
membership_cache[sid] = role
|
||||||
return role
|
return role
|
||||||
|
|
||||||
if study_id:
|
if not study_id:
|
||||||
if current_user.role != "ADMIN":
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="study_id is required")
|
||||||
role = await _get_member_role(study_id)
|
if current_user.role != "ADMIN":
|
||||||
if not role:
|
role = await _get_member_role(study_id)
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a study member")
|
if not role:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a study member")
|
||||||
|
|
||||||
if is_active is False and current_user.role != "ADMIN":
|
if is_active is False and current_user.role != "ADMIN":
|
||||||
role = None
|
role = await _get_member_role(study_id)
|
||||||
if study_id:
|
|
||||||
role = await _get_member_role(study_id)
|
|
||||||
if role != "PM":
|
if role != "PM":
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
|
||||||
|
|
||||||
@@ -137,17 +128,17 @@ async def list_faqs(
|
|||||||
category_id=category_id,
|
category_id=category_id,
|
||||||
keyword=keyword,
|
keyword=keyword,
|
||||||
is_active=is_active,
|
is_active=is_active,
|
||||||
study_scope=study_scope,
|
study_scope="project",
|
||||||
)
|
)
|
||||||
|
|
||||||
visible: list[FaqRead] = []
|
visible: list[FaqRead] = []
|
||||||
for it in items:
|
for it in items:
|
||||||
if it.study_id and current_user.role != "ADMIN":
|
if current_user.role != "ADMIN":
|
||||||
role = await _get_member_role(it.study_id)
|
role = await _get_member_role(it.study_id)
|
||||||
if not role:
|
if not role:
|
||||||
continue
|
continue
|
||||||
if not it.is_active and current_user.role != "ADMIN":
|
if not it.is_active and current_user.role != "ADMIN":
|
||||||
role = await _get_member_role(it.study_id) if it.study_id else None
|
role = await _get_member_role(it.study_id)
|
||||||
if role != "PM":
|
if role != "PM":
|
||||||
continue
|
continue
|
||||||
visible.append(FaqRead.model_validate(it))
|
visible.append(FaqRead.model_validate(it))
|
||||||
@@ -168,15 +159,15 @@ async def get_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 not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ not found")
|
||||||
if item.study_id and current_user.role != "ADMIN":
|
if not item.study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ not found")
|
||||||
|
if current_user.role != "ADMIN":
|
||||||
member = await member_crud.get_member(db, item.study_id, current_user.id)
|
member = await member_crud.get_member(db, item.study_id, current_user.id)
|
||||||
if not member:
|
if not member:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a study member")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a study member")
|
||||||
if not item.is_active and current_user.role not in {"ADMIN"}:
|
if not item.is_active and current_user.role not in {"ADMIN"}:
|
||||||
member_role = None
|
member = await member_crud.get_member(db, item.study_id, current_user.id)
|
||||||
if item.study_id:
|
member_role = member.role_in_study if member else None
|
||||||
member = await member_crud.get_member(db, item.study_id, current_user.id)
|
|
||||||
member_role = member.role_in_study if member else None
|
|
||||||
if member_role != "PM":
|
if member_role != "PM":
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Inactive FAQ")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Inactive FAQ")
|
||||||
return FaqRead.model_validate(item)
|
return FaqRead.model_validate(item)
|
||||||
@@ -267,11 +258,12 @@ async def set_best_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 not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ not found")
|
||||||
|
if not item.study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ not found")
|
||||||
is_member = False
|
is_member = False
|
||||||
if item.study_id:
|
member = await member_crud.get_member(db, item.study_id, current_user.id)
|
||||||
member = await member_crud.get_member(db, item.study_id, current_user.id)
|
is_member = member is not None
|
||||||
is_member = member is not None
|
_check_create_permission(current_user, is_member)
|
||||||
_check_create_permission(item.study_id, current_user, is_member)
|
|
||||||
if payload.best_reply_id:
|
if payload.best_reply_id:
|
||||||
reply = await reply_crud.get_reply(db, payload.best_reply_id)
|
reply = await reply_crud.get_reply(db, payload.best_reply_id)
|
||||||
if not reply or reply.faq_id != item.id:
|
if not reply or reply.faq_id != item.id:
|
||||||
@@ -348,13 +340,14 @@ async def create_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 not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ not found")
|
||||||
|
if not item.study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ not found")
|
||||||
if not payload.content.strip():
|
if not payload.content.strip():
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reply content is required")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reply content is required")
|
||||||
is_member = False
|
is_member = False
|
||||||
if item.study_id:
|
member = await member_crud.get_member(db, item.study_id, current_user.id)
|
||||||
member = await member_crud.get_member(db, item.study_id, current_user.id)
|
is_member = member is not None
|
||||||
is_member = member is not None
|
_check_create_permission(current_user, is_member)
|
||||||
_check_create_permission(item.study_id, current_user, is_member)
|
|
||||||
quote = None
|
quote = None
|
||||||
if payload.quote_reply_id:
|
if payload.quote_reply_id:
|
||||||
quote = await reply_crud.get_reply(db, payload.quote_reply_id)
|
quote = await reply_crud.get_reply(db, payload.quote_reply_id)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
|
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
|
||||||
from app.crud import audit as audit_crud
|
from app.crud import audit as audit_crud
|
||||||
|
from app.crud import attachment as attachment_crud
|
||||||
from app.crud import milestone as milestone_crud
|
from app.crud import milestone as milestone_crud
|
||||||
from app.crud import study as study_crud
|
from app.crud import study as study_crud
|
||||||
from app.crud import user as user_crud
|
from app.crud import user as user_crud
|
||||||
@@ -36,6 +38,8 @@ async def create_milestone(
|
|||||||
current_user=Depends(get_current_user),
|
current_user=Depends(get_current_user),
|
||||||
) -> MilestoneRead:
|
) -> MilestoneRead:
|
||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
|
if milestone_in.planned_date and milestone_in.planned_date < date.today():
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="计划日期不得早于今天")
|
||||||
milestone = await milestone_crud.create(db, study_id, milestone_in)
|
milestone = await milestone_crud.create(db, study_id, milestone_in)
|
||||||
owner = None
|
owner = None
|
||||||
site = None
|
site = None
|
||||||
@@ -127,6 +131,15 @@ async def update_milestone(
|
|||||||
milestone = await milestone_crud.get(db, milestone_id)
|
milestone = await milestone_crud.get(db, milestone_id)
|
||||||
if not milestone or milestone.study_id != study_id:
|
if not milestone or milestone.study_id != study_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Milestone not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Milestone not found")
|
||||||
|
if milestone_in.planned_date and milestone_in.planned_date < date.today():
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="计划日期不得早于今天")
|
||||||
|
if milestone_in.actual_date and milestone_in.actual_date < date.today():
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="实际日期不得早于今天")
|
||||||
|
if milestone_in.actual_date:
|
||||||
|
attachments = await attachment_crud.list_attachments(db, study_id, "ethics_node", milestone_id)
|
||||||
|
if not attachments:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请先上传伦理批件后再更新实际日期")
|
||||||
|
milestone_in.status = "DONE"
|
||||||
old_status = milestone.status
|
old_status = milestone.status
|
||||||
updated = await milestone_crud.update(db, milestone, milestone_in)
|
updated = await milestone_crud.update(db, milestone, milestone_in)
|
||||||
owner = None
|
owner = None
|
||||||
@@ -164,3 +177,32 @@ async def update_milestone(
|
|||||||
created_at=updated.created_at,
|
created_at=updated.created_at,
|
||||||
updated_at=updated.updated_at,
|
updated_at=updated.updated_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{milestone_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
dependencies=[Depends(require_study_member())],
|
||||||
|
)
|
||||||
|
async def delete_milestone(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
milestone_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> None:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
milestone = await milestone_crud.get(db, milestone_id)
|
||||||
|
if not milestone or milestone.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Milestone not found")
|
||||||
|
await milestone_crud.delete_milestone(db, milestone)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="milestone",
|
||||||
|
entity_id=milestone_id,
|
||||||
|
action="DELETE_MILESTONE",
|
||||||
|
detail=f"Milestone {milestone_id} deleted",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|||||||
@@ -52,6 +52,17 @@ async def update_user(
|
|||||||
db_user = await user_crud.get_by_id(db, user_id)
|
db_user = await user_crud.get_by_id(db, user_id)
|
||||||
if not db_user:
|
if not db_user:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
|
if db_user.role.value == "ADMIN":
|
||||||
|
requested_role = user_in.role
|
||||||
|
requested_status = user_in.status
|
||||||
|
if user_in.is_active is not None:
|
||||||
|
requested_status = "ACTIVE" if user_in.is_active else "DISABLED"
|
||||||
|
will_leave_admin = requested_role is not None and requested_role != "ADMIN"
|
||||||
|
will_disable = requested_status is not None and requested_status != "ACTIVE"
|
||||||
|
if (will_leave_admin or will_disable) and db_user.status.value == "ACTIVE":
|
||||||
|
active_admins = await user_crud.count_active_admins(db)
|
||||||
|
if active_admins <= 1:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="至少保留一个管理员账号")
|
||||||
user = await user_crud.update_user(db, db_user, user_in)
|
user = await user_crud.update_user(db, db_user, user_in)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
@@ -71,6 +82,10 @@ async def delete_user(
|
|||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
if db_user.id == current_user.id:
|
if db_user.id == current_user.id:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不允许删除自己")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不允许删除自己")
|
||||||
|
if db_user.role.value == "ADMIN" and db_user.status.value == "ACTIVE":
|
||||||
|
active_admins = await user_crud.count_active_admins(db)
|
||||||
|
if active_admins <= 1:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="至少保留一个管理员账号")
|
||||||
has_membership = await member_crud.user_has_memberships(db, db_user.id)
|
has_membership = await member_crud.user_has_memberships(db, db_user.id)
|
||||||
if has_membership:
|
if has_membership:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ async def create_ae(
|
|||||||
severity=ae_in.severity,
|
severity=ae_in.severity,
|
||||||
causality=ae_in.causality,
|
causality=ae_in.causality,
|
||||||
action_taken=None,
|
action_taken=None,
|
||||||
outcome=None,
|
outcome=ae_in.outcome,
|
||||||
reported_to_sponsor=False,
|
reported_to_sponsor=False,
|
||||||
report_due_date=due_date,
|
report_due_date=due_date,
|
||||||
status="NEW",
|
status="NEW",
|
||||||
@@ -96,8 +96,10 @@ async def list_ae(
|
|||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
async def update_ae(db: AsyncSession, ae: AdverseEvent, ae_in: AEUpdate) -> AdverseEvent:
|
async def update_ae(db: AsyncSession, study_id: uuid.UUID, ae: AdverseEvent, ae_in: AEUpdate) -> AdverseEvent:
|
||||||
update_data = ae_in.model_dump(exclude_unset=True)
|
update_data = ae_in.model_dump(exclude_unset=True)
|
||||||
|
if "subject_id" in update_data:
|
||||||
|
await _validate_site_subject(db, study_id, None, update_data["subject_id"])
|
||||||
if "onset_date" in update_data or "seriousness" in update_data:
|
if "onset_date" in update_data or "seriousness" in update_data:
|
||||||
onset = update_data.get("onset_date", ae.onset_date)
|
onset = update_data.get("onset_date", ae.onset_date)
|
||||||
seriousness = update_data.get("seriousness", ae.seriousness)
|
seriousness = update_data.get("seriousness", ae.seriousness)
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ async def list_logs(
|
|||||||
entity_type: str | None = None,
|
entity_type: str | None = None,
|
||||||
entity_id: uuid.UUID | None = None,
|
entity_id: uuid.UUID | None = None,
|
||||||
action: str | None = None,
|
action: str | None = None,
|
||||||
|
operator_id: uuid.UUID | None = None,
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
) -> Sequence[AuditLog]:
|
) -> Sequence[AuditLog]:
|
||||||
@@ -50,6 +51,18 @@ async def list_logs(
|
|||||||
stmt = stmt.where(AuditLog.entity_id == entity_id)
|
stmt = stmt.where(AuditLog.entity_id == entity_id)
|
||||||
if action:
|
if action:
|
||||||
stmt = stmt.where(AuditLog.action == action)
|
stmt = stmt.where(AuditLog.action == action)
|
||||||
|
if operator_id:
|
||||||
|
stmt = stmt.where(AuditLog.operator_id == operator_id)
|
||||||
stmt = stmt.order_by(AuditLog.created_at.desc()).offset(skip).limit(limit)
|
stmt = stmt.order_by(AuditLog.created_at.desc()).offset(skip).limit(limit)
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_log(db: AsyncSession, log_id: uuid.UUID) -> AuditLog | None:
|
||||||
|
result = await db.execute(select(AuditLog).where(AuditLog.id == log_id))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_log(db: AsyncSession, log: AuditLog) -> None:
|
||||||
|
await db.delete(log)
|
||||||
|
await db.commit()
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ async def create_comment(
|
|||||||
entity_type=entity_type,
|
entity_type=entity_type,
|
||||||
entity_id=entity_id,
|
entity_id=entity_id,
|
||||||
content=comment_in.content,
|
content=comment_in.content,
|
||||||
|
quote_comment_id=comment_in.quote_comment_id,
|
||||||
created_by=created_by,
|
created_by=created_by,
|
||||||
)
|
)
|
||||||
db.add(comment)
|
db.add(comment)
|
||||||
@@ -46,3 +47,35 @@ async def list_comments(
|
|||||||
.order_by(Comment.created_at.asc())
|
.order_by(Comment.created_at.asc())
|
||||||
)
|
)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_comment(db: AsyncSession, comment_id: uuid.UUID, *, include_deleted: bool = False) -> Comment | None:
|
||||||
|
stmt = select(Comment).where(Comment.id == comment_id)
|
||||||
|
if not include_deleted:
|
||||||
|
stmt = stmt.where(Comment.is_deleted.is_(False))
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_comments_by_ids(
|
||||||
|
db: AsyncSession,
|
||||||
|
ids: set[uuid.UUID],
|
||||||
|
*,
|
||||||
|
include_deleted: bool = False,
|
||||||
|
) -> dict[uuid.UUID, Comment]:
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
stmt = select(Comment).where(Comment.id.in_(ids))
|
||||||
|
if not include_deleted:
|
||||||
|
stmt = stmt.where(Comment.is_deleted.is_(False))
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
comments = result.scalars().all()
|
||||||
|
return {c.id: c for c in comments}
|
||||||
|
|
||||||
|
|
||||||
|
async def soft_delete_comment(db: AsyncSession, comment: Comment) -> Comment:
|
||||||
|
comment.is_deleted = True
|
||||||
|
db.add(comment)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(comment)
|
||||||
|
return comment
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Sequence
|
from typing import Sequence
|
||||||
|
|
||||||
from sqlalchemy import select, update as sa_update
|
from sqlalchemy import delete, select, update as sa_update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.milestone import Milestone
|
from app.models.milestone import Milestone
|
||||||
@@ -47,3 +47,8 @@ async def update(db: AsyncSession, milestone: Milestone, milestone_in: Milestone
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(milestone)
|
await db.refresh(milestone)
|
||||||
return milestone
|
return milestone
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_milestone(db: AsyncSession, milestone: Milestone) -> None:
|
||||||
|
await db.execute(delete(Milestone).where(Milestone.id == milestone.id))
|
||||||
|
await db.commit()
|
||||||
|
|||||||
@@ -71,6 +71,15 @@ async def list_users(db: AsyncSession, skip: int = 0, limit: int = 100) -> Seque
|
|||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def count_active_admins(db: AsyncSession) -> int:
|
||||||
|
result = await db.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(User)
|
||||||
|
.where(User.role == UserRole.ADMIN, User.status == UserStatus.ACTIVE)
|
||||||
|
)
|
||||||
|
return int(result.scalar_one() or 0)
|
||||||
|
|
||||||
|
|
||||||
async def list_users_by_status(
|
async def list_users_by_status(
|
||||||
db: AsyncSession, status: UserStatus | None = None, skip: int = 0, limit: int = 100
|
db: AsyncSession, status: UserStatus | None = None, skip: int = 0, limit: int = 100
|
||||||
) -> Sequence[User]:
|
) -> Sequence[User]:
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class Comment(Base):
|
|||||||
entity_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
entity_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
||||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
quote_comment_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("comments.id"), nullable=True)
|
||||||
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
|
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
|
||||||
|
|||||||
@@ -15,9 +15,13 @@ class AECreate(BaseModel):
|
|||||||
severity: str
|
severity: str
|
||||||
causality: Optional[str] = None
|
causality: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
outcome: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class AEUpdate(BaseModel):
|
class AEUpdate(BaseModel):
|
||||||
|
subject_id: Optional[uuid.UUID] = None
|
||||||
|
term: Optional[str] = None
|
||||||
|
onset_date: Optional[date] = None
|
||||||
resolution_date: Optional[date] = None
|
resolution_date: Optional[date] = None
|
||||||
seriousness: Optional[str] = None
|
seriousness: Optional[str] = None
|
||||||
severity: Optional[str] = None
|
severity: Optional[str] = None
|
||||||
|
|||||||
@@ -6,6 +6,17 @@ from pydantic import BaseModel, ConfigDict, Field
|
|||||||
|
|
||||||
class CommentCreate(BaseModel):
|
class CommentCreate(BaseModel):
|
||||||
content: str = Field(min_length=1)
|
content: str = Field(min_length=1)
|
||||||
|
quote_comment_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CommentQuote(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
content: str
|
||||||
|
created_by: uuid.UUID
|
||||||
|
created_at: datetime
|
||||||
|
is_deleted: bool = False
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
class CommentRead(BaseModel):
|
class CommentRead(BaseModel):
|
||||||
@@ -13,5 +24,8 @@ class CommentRead(BaseModel):
|
|||||||
content: str
|
content: str
|
||||||
created_by: uuid.UUID
|
created_by: uuid.UUID
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
quote_comment_id: uuid.UUID | None = None
|
||||||
|
quote: CommentQuote | None = None
|
||||||
|
is_deleted: bool = False
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ CREATE TABLE public.comments (
|
|||||||
entity_type character varying(50) NOT NULL,
|
entity_type character varying(50) NOT NULL,
|
||||||
entity_id uuid NOT NULL,
|
entity_id uuid NOT NULL,
|
||||||
content text NOT NULL,
|
content text NOT NULL,
|
||||||
|
quote_comment_id uuid,
|
||||||
created_by uuid NOT NULL,
|
created_by uuid NOT NULL,
|
||||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
is_deleted boolean DEFAULT false NOT NULL
|
is_deleted boolean DEFAULT false NOT NULL
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { apiGet } from "./axios";
|
import { apiDelete, apiGet } 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}`);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { apiGet, apiPost } from "./axios";
|
import { apiGet, apiPost, apiDelete } from "./axios";
|
||||||
|
|
||||||
export const fetchComments = (studyId: string, entityType: string, entityId: string) =>
|
export const fetchComments = (studyId: string, entityType: string, entityId: string) =>
|
||||||
apiGet(`/api/v1/studies/${studyId}/${entityType}/${entityId}/comments`);
|
apiGet(`/api/v1/studies/${studyId}/${entityType}/${entityId}/comments`);
|
||||||
@@ -9,3 +9,6 @@ export const createComment = (
|
|||||||
entityId: string,
|
entityId: string,
|
||||||
payload: Record<string, any>
|
payload: Record<string, any>
|
||||||
) => apiPost(`/api/v1/studies/${studyId}/${entityType}/${entityId}/comments`, payload);
|
) => apiPost(`/api/v1/studies/${studyId}/${entityType}/${entityId}/comments`, payload);
|
||||||
|
|
||||||
|
export const deleteComment = (studyId: string, entityType: string, entityId: string, commentId: string) =>
|
||||||
|
apiDelete(`/api/v1/studies/${studyId}/${entityType}/${entityId}/comments/${commentId}`);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { apiGet, apiPost, apiPatch } from "./axios";
|
import { apiGet, apiPost, apiPatch, apiDelete } from "./axios";
|
||||||
import type { ApiListResponse } from "../types/api";
|
import type { ApiListResponse } from "../types/api";
|
||||||
|
|
||||||
export const fetchMilestones = (studyId: string) =>
|
export const fetchMilestones = (studyId: string) =>
|
||||||
@@ -9,3 +9,6 @@ export const createMilestone = (studyId: string, payload: Record<string, any>) =
|
|||||||
|
|
||||||
export const updateMilestone = (studyId: string, milestoneId: string, payload: Record<string, any>) =>
|
export const updateMilestone = (studyId: string, milestoneId: string, payload: Record<string, any>) =>
|
||||||
apiPatch(`/api/v1/studies/${studyId}/milestones/${milestoneId}`, payload);
|
apiPatch(`/api/v1/studies/${studyId}/milestones/${milestoneId}`, payload);
|
||||||
|
|
||||||
|
export const deleteMilestone = (studyId: string, milestoneId: string) =>
|
||||||
|
apiDelete(`/api/v1/studies/${studyId}/milestones/${milestoneId}`);
|
||||||
|
|||||||
@@ -8,12 +8,17 @@
|
|||||||
<el-input v-model="form.term" />
|
<el-input v-model="form.term" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="发生日期" prop="onset_date">
|
<el-form-item label="发生日期" prop="onset_date">
|
||||||
<el-date-picker v-model="form.onset_date" type="date" value-format="YYYY-MM-DD" />
|
<el-date-picker
|
||||||
|
v-model="form.onset_date"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
:disabled-date="disableFutureDate"
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="严重性" prop="seriousness">
|
<el-form-item label="SAE" prop="seriousness">
|
||||||
<el-select v-model="form.seriousness" placeholder="请选择">
|
<el-select v-model="form.seriousness" placeholder="请选择">
|
||||||
<el-option label="SERIOUS(重)" value="SERIOUS" />
|
<el-option label="是" value="SERIOUS" />
|
||||||
<el-option label="NON_SERIOUS(非重)" value="NON_SERIOUS" />
|
<el-option label="否" value="NON_SERIOUS" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="严重程度" prop="severity">
|
<el-form-item label="严重程度" prop="severity">
|
||||||
@@ -24,6 +29,11 @@
|
|||||||
<el-form-item label="描述" prop="description">
|
<el-form-item label="描述" prop="description">
|
||||||
<el-input v-model="form.description" type="textarea" />
|
<el-input v-model="form.description" type="textarea" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="转归结局" prop="outcome">
|
||||||
|
<el-select v-model="form.outcome" placeholder="可留空" clearable>
|
||||||
|
<el-option v-for="o in outcomes" :key="o" :label="o" :value="o" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="footer-right">
|
<div class="footer-right">
|
||||||
@@ -59,13 +69,14 @@ const submitting = ref(false);
|
|||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
|
|
||||||
const severities = ["G1", "G2", "G3", "G4", "G5"];
|
const severities = ["G1", "G2", "G3", "G4", "G5"];
|
||||||
|
const outcomes = ["痊愈", "好转", "持续", "恶化", "死亡", "其他"];
|
||||||
const severityLabel = (v: string) =>
|
const severityLabel = (v: string) =>
|
||||||
({
|
({
|
||||||
G1: "G1(轻)",
|
G1: "I级",
|
||||||
G2: "G2(中)",
|
G2: "II级",
|
||||||
G3: "G3(重)",
|
G3: "III级",
|
||||||
G4: "G4(危重)",
|
G4: "IV级",
|
||||||
G5: "G5(致死)",
|
G5: "V级",
|
||||||
}[v] || v);
|
}[v] || v);
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
@@ -75,6 +86,7 @@ const form = reactive({
|
|||||||
seriousness: "NON_SERIOUS",
|
seriousness: "NON_SERIOUS",
|
||||||
severity: "G1",
|
severity: "G1",
|
||||||
description: "",
|
description: "",
|
||||||
|
outcome: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const rules: FormRules = {
|
const rules: FormRules = {
|
||||||
@@ -97,6 +109,7 @@ watch(
|
|||||||
seriousness: val.seriousness || "NON_SERIOUS",
|
seriousness: val.seriousness || "NON_SERIOUS",
|
||||||
severity: val.severity || "G1",
|
severity: val.severity || "G1",
|
||||||
description: val.description || "",
|
description: val.description || "",
|
||||||
|
outcome: val.outcome || "",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
@@ -106,6 +119,7 @@ watch(
|
|||||||
seriousness: "NON_SERIOUS",
|
seriousness: "NON_SERIOUS",
|
||||||
severity: "G1",
|
severity: "G1",
|
||||||
description: "",
|
description: "",
|
||||||
|
outcome: "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -116,10 +130,16 @@ const onClose = () => {
|
|||||||
visible.value = false;
|
visible.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const disableFutureDate = (time: Date) => time.getTime() > Date.now();
|
||||||
|
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
if (!formRef.value) return;
|
if (!formRef.value) return;
|
||||||
await formRef.value.validate(async (valid) => {
|
await formRef.value.validate(async (valid) => {
|
||||||
if (!valid) return;
|
if (!valid) return;
|
||||||
|
if (form.onset_date && new Date(form.onset_date) > new Date()) {
|
||||||
|
ElMessage.warning("发生日期不得晚于当前日期");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!study.currentStudy) {
|
if (!study.currentStudy) {
|
||||||
ElMessage.error("未选择项目");
|
ElMessage.error("未选择项目");
|
||||||
return;
|
return;
|
||||||
@@ -132,6 +152,9 @@ const onSubmit = async () => {
|
|||||||
payload[k] = v;
|
payload[k] = v;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if (form.outcome === "" || form.outcome === null || form.outcome === undefined) {
|
||||||
|
payload.outcome = null;
|
||||||
|
}
|
||||||
if (isEdit.value && props.ae) {
|
if (isEdit.value && props.ae) {
|
||||||
await updateAe(study.currentStudy.id, props.ae.id, payload);
|
await updateAe(study.currentStudy.id, props.ae.id, payload);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -6,31 +6,45 @@
|
|||||||
<el-button v-if="canComment" type="primary" size="small" @click="showInput = !showInput">新增</el-button>
|
<el-button v-if="canComment" type="primary" size="small" @click="showInput = !showInput">新增</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="showInput" class="input-area">
|
<ThreadComposer
|
||||||
<el-input v-model="newComment" type="textarea" rows="3" placeholder="输入评论" />
|
v-if="showInput"
|
||||||
<div class="actions">
|
v-model="newComment"
|
||||||
<el-button size="small" @click="showInput = false">取消</el-button>
|
v-model:file-list="fileList"
|
||||||
<el-button size="small" type="primary" :loading="loading" @click="submit">提交</el-button>
|
:submitting="loading || uploading"
|
||||||
</div>
|
:quote-item="quoteComment"
|
||||||
</div>
|
:member-map="memberMap"
|
||||||
<el-timeline>
|
:user-map="userMap"
|
||||||
<el-timeline-item v-for="c in comments" :key="c.id" :timestamp="displayDateTime(c.created_at)">
|
:allow-attachments="true"
|
||||||
<div class="comment-item">
|
show-cancel
|
||||||
<strong>{{ displayUser(c.created_by, { users: userMap, members: memberMap }) }}</strong>
|
@submit="submit"
|
||||||
<p>{{ c.content }}</p>
|
@cancel="showInput = false"
|
||||||
</div>
|
@clear-quote="clearQuote"
|
||||||
</el-timeline-item>
|
/>
|
||||||
</el-timeline>
|
<ThreadList
|
||||||
|
:items="comments"
|
||||||
|
:attachments-map="attachmentsMap"
|
||||||
|
:member-map="memberMap"
|
||||||
|
:user-map="userMap"
|
||||||
|
:can-quote="canComment"
|
||||||
|
:can-delete="canDelete"
|
||||||
|
@quote="setQuote"
|
||||||
|
@delete="remove"
|
||||||
|
/>
|
||||||
</el-card>
|
</el-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { fetchComments, createComment } from "../api/comments";
|
import type { UploadUserFile } from "element-plus";
|
||||||
|
import { fetchComments, createComment, deleteComment } from "../api/comments";
|
||||||
|
import { fetchAttachments, uploadAttachment } from "../api/attachments";
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
|
import { useAuthStore } from "../store/auth";
|
||||||
import { listMembers } from "../api/members";
|
import { listMembers } from "../api/members";
|
||||||
import { displayDateTime, displayUser, getMemberDisplayName } from "../utils/display";
|
import { getMemberDisplayName } from "../utils/display";
|
||||||
|
import ThreadComposer from "./ThreadComposer.vue";
|
||||||
|
import ThreadList from "./ThreadList.vue";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
studyId: string;
|
studyId: string;
|
||||||
@@ -45,7 +59,12 @@ const loading = ref(false);
|
|||||||
const newComment = ref("");
|
const newComment = ref("");
|
||||||
const showInput = ref(false);
|
const showInput = ref(false);
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
|
const auth = useAuthStore();
|
||||||
const members = ref<any[]>([]);
|
const members = ref<any[]>([]);
|
||||||
|
const quoteComment = ref<any | null>(null);
|
||||||
|
const fileList = ref<UploadUserFile[]>([]);
|
||||||
|
const attachmentsMap = ref<Record<string, any[]>>({});
|
||||||
|
const uploading = ref(false);
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
if (!props.studyId) return;
|
if (!props.studyId) return;
|
||||||
@@ -53,6 +72,7 @@ const load = async () => {
|
|||||||
try {
|
try {
|
||||||
const { data } = await fetchComments(props.studyId, props.entityType, props.entityId);
|
const { data } = await fetchComments(props.studyId, props.entityType, props.entityId);
|
||||||
comments.value = data.items || data || [];
|
comments.value = data.items || data || [];
|
||||||
|
await loadAttachments();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e?.response?.data?.message || "评论加载失败");
|
ElMessage.error(e?.response?.data?.message || "评论加载失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -77,6 +97,13 @@ const memberMap = computed(() =>
|
|||||||
return acc;
|
return acc;
|
||||||
}, {})
|
}, {})
|
||||||
);
|
);
|
||||||
|
const userMap = computed(() => {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
if (auth.user?.id) {
|
||||||
|
map[auth.user.id] = auth.user.display_name || auth.user.username || auth.user.email || auth.user.id;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!newComment.value.trim()) {
|
if (!newComment.value.trim()) {
|
||||||
@@ -86,9 +113,26 @@ const submit = async () => {
|
|||||||
if (!study.currentStudy) return;
|
if (!study.currentStudy) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
await createComment(study.currentStudy.id, props.entityType, props.entityId, { content: newComment.value });
|
const { data } = await createComment(study.currentStudy.id, props.entityType, props.entityId, {
|
||||||
|
content: newComment.value,
|
||||||
|
quote_comment_id: quoteComment.value?.id || null,
|
||||||
|
});
|
||||||
|
const created = data as any;
|
||||||
|
if (fileList.value.length && created?.id) {
|
||||||
|
uploading.value = true;
|
||||||
|
try {
|
||||||
|
for (const file of fileList.value) {
|
||||||
|
if (!file.raw) continue;
|
||||||
|
await uploadAttachment(study.currentStudy.id, "comments", created.id, file.raw as File);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
uploading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
ElMessage.success("提交成功");
|
ElMessage.success("提交成功");
|
||||||
newComment.value = "";
|
newComment.value = "";
|
||||||
|
quoteComment.value = null;
|
||||||
|
fileList.value = [];
|
||||||
showInput.value = false;
|
showInput.value = false;
|
||||||
load();
|
load();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -98,6 +142,53 @@ const submit = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setQuote = (comment: any) => {
|
||||||
|
quoteComment.value = comment;
|
||||||
|
showInput.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearQuote = () => {
|
||||||
|
quoteComment.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const canDelete = (comment: any) => auth.user?.role === "ADMIN" || comment?.created_by === auth.user?.id;
|
||||||
|
|
||||||
|
const remove = async (comment: any) => {
|
||||||
|
if (!study.currentStudy || !comment?.id) return;
|
||||||
|
const ok = await ElMessageBox.confirm("确认删除该评论?", "提示").catch(() => null);
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await deleteComment(study.currentStudy.id, props.entityType, props.entityId, comment.id);
|
||||||
|
ElMessage.success("评论已删除");
|
||||||
|
load();
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || "删除失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAttachments = async () => {
|
||||||
|
if (!props.studyId || comments.value.length === 0) {
|
||||||
|
attachmentsMap.value = {};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entries = await Promise.all(
|
||||||
|
comments.value.map(async (c) => {
|
||||||
|
try {
|
||||||
|
const { data } = await fetchAttachments(props.studyId, "comments", c.id);
|
||||||
|
const items = (data as any).items || data || [];
|
||||||
|
return [c.id, items] as const;
|
||||||
|
} catch {
|
||||||
|
return [c.id, []] as const;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const next: Record<string, any[]> = {};
|
||||||
|
entries.forEach(([id, items]) => {
|
||||||
|
next[id] = items;
|
||||||
|
});
|
||||||
|
attachmentsMap.value = next;
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadMembers();
|
loadMembers();
|
||||||
load();
|
load();
|
||||||
@@ -110,16 +201,4 @@ onMounted(() => {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.input-area {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
.actions {
|
|
||||||
margin-top: 8px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.comment-item p {
|
|
||||||
margin: 4px 0 0;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<el-menu
|
<el-menu
|
||||||
router
|
router
|
||||||
:default-active="$route.path"
|
:default-active="activeMenu"
|
||||||
:collapse="isCollapsed"
|
:collapse="isCollapsed"
|
||||||
:collapse-transition="true"
|
:collapse-transition="true"
|
||||||
class="aside-menu"
|
class="aside-menu"
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
</el-sub-menu>
|
</el-sub-menu>
|
||||||
<el-menu-item index="/study/finance">
|
<el-menu-item index="/study/finance">
|
||||||
<el-icon><Coin /></el-icon>
|
<el-icon><Coin /></el-icon>
|
||||||
<span>费用治理</span>
|
<span>费用管理</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
<el-menu-item index="/study/faq">
|
<el-menu-item index="/study/faq">
|
||||||
<el-icon><Notebook /></el-icon>
|
<el-icon><Notebook /></el-icon>
|
||||||
@@ -143,7 +143,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
import StudySelector from "./StudySelector.vue";
|
import StudySelector from "./StudySelector.vue";
|
||||||
@@ -155,9 +155,23 @@ import {
|
|||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
const isAdmin = computed(() => auth.user?.role === "ADMIN");
|
const isAdmin = computed(() => auth.user?.role === "ADMIN");
|
||||||
const isPm = computed(() => auth.user?.role === "PM" || study.currentStudyRole === "PM");
|
const isPm = computed(() => auth.user?.role === "PM" || study.currentStudyRole === "PM");
|
||||||
const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1");
|
const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1");
|
||||||
|
const activeMenu = computed(() => {
|
||||||
|
const path = route.path;
|
||||||
|
if (path.startsWith("/study/milestones/")) return "/study/milestones";
|
||||||
|
if (path.startsWith("/study/subjects/")) return "/study/subjects";
|
||||||
|
if (path.startsWith("/study/aes/")) return "/study/aes";
|
||||||
|
if (path.startsWith("/study/issues/")) return "/study/issues";
|
||||||
|
if (path.startsWith("/study/data-queries/")) return "/study/data-queries";
|
||||||
|
if (path.startsWith("/study/finance/")) return "/study/finance";
|
||||||
|
if (path.startsWith("/study/faq/")) return "/study/faq";
|
||||||
|
if (path.startsWith("/projects/")) return "/admin/projects";
|
||||||
|
if (path.startsWith("/admin/projects/")) return "/admin/projects";
|
||||||
|
return path;
|
||||||
|
});
|
||||||
|
|
||||||
const toggleCollapse = () => {
|
const toggleCollapse = () => {
|
||||||
isCollapsed.value = !isCollapsed.value;
|
isCollapsed.value = !isCollapsed.value;
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const actions = [
|
|||||||
{ label: "不良事件", path: "/study/aes", icon: Warning },
|
{ label: "不良事件", path: "/study/aes", icon: Warning },
|
||||||
{ label: "数据问题", path: "/study/data-queries", icon: QuestionFilled },
|
{ label: "数据问题", path: "/study/data-queries", icon: QuestionFilled },
|
||||||
{ label: "药品库存", path: "/study/imp/inventory", icon: Management },
|
{ label: "药品库存", path: "/study/imp/inventory", icon: Management },
|
||||||
{ label: "费用治理", path: "/study/finance", icon: Money },
|
{ label: "费用管理", path: "/study/finance", icon: Money },
|
||||||
{ label: "项目知识库", path: "/study/faq", icon: Collection },
|
{ label: "项目知识库", path: "/study/faq", icon: Collection },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<template>
|
||||||
|
<div class="thread-composer">
|
||||||
|
<div v-if="quoteItem" class="quote-box">
|
||||||
|
<div class="quote-meta">
|
||||||
|
引用 {{ displayUser(quoteItem.created_by, { users: userMap, members: memberMap }) }}
|
||||||
|
· {{ displayDateTime(quoteItem.created_at) }}
|
||||||
|
<el-button type="text" size="small" @click="$emit('clear-quote')">取消引用</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="quote-content">{{ quoteContent(quoteItem) }}</div>
|
||||||
|
</div>
|
||||||
|
<el-input v-model="contentProxy" type="textarea" rows="3" placeholder="输入内容" />
|
||||||
|
<div v-if="allowAttachments" class="upload-row">
|
||||||
|
<div class="upload-label">附件</div>
|
||||||
|
<el-upload v-model:file-list="fileListProxy" :auto-upload="false" multiple list-type="picture" class="thread-upload">
|
||||||
|
<el-button size="small" class="upload-button">上传附件</el-button>
|
||||||
|
</el-upload>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<el-button v-if="showCancel" size="small" @click="$emit('cancel')">取消</el-button>
|
||||||
|
<el-button size="small" type="primary" :loading="submitting" @click="$emit('submit')">提交</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import type { UploadUserFile } from "element-plus";
|
||||||
|
import { displayDateTime, displayUser } from "../utils/display";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string;
|
||||||
|
fileList: UploadUserFile[];
|
||||||
|
submitting?: boolean;
|
||||||
|
showCancel?: boolean;
|
||||||
|
allowAttachments?: boolean;
|
||||||
|
quoteItem?: any | null;
|
||||||
|
memberMap?: Record<string, string>;
|
||||||
|
userMap?: Record<string, string>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:modelValue", value: string): void;
|
||||||
|
(e: "update:fileList", value: UploadUserFile[]): void;
|
||||||
|
(e: "submit"): void;
|
||||||
|
(e: "cancel"): void;
|
||||||
|
(e: "clear-quote"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const contentProxy = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (val: string) => emit("update:modelValue", val),
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileListProxy = computed({
|
||||||
|
get: () => props.fileList,
|
||||||
|
set: (val: UploadUserFile[]) => emit("update:fileList", val),
|
||||||
|
});
|
||||||
|
|
||||||
|
const quoteContent = (item: any) => (item?.is_deleted ? "内容已删除" : item?.content || "—");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.thread-composer {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.upload-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.upload-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #606266;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f2f4f7;
|
||||||
|
}
|
||||||
|
.thread-upload :deep(.el-upload-list) {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.upload-button {
|
||||||
|
border: 1px dashed #3b82f6;
|
||||||
|
color: #1d4ed8;
|
||||||
|
background: #eff6ff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.upload-button:hover {
|
||||||
|
border-color: #2563eb;
|
||||||
|
color: #1e40af;
|
||||||
|
background: #e0ecff;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.quote-box {
|
||||||
|
background: #f5f7fa;
|
||||||
|
border-left: 3px solid #dcdfe6;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.quote-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.quote-content {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
<template>
|
||||||
|
<el-timeline>
|
||||||
|
<el-timeline-item v-for="item in items" :key="item.id" :timestamp="displayDateTime(item.created_at)">
|
||||||
|
<div class="thread-item" :class="{ 'is-highlighted': isHighlighted(item) }">
|
||||||
|
<div class="thread-meta">
|
||||||
|
<strong>{{ displayUser(item.created_by, { users: userMap, members: memberMap }) }}</strong>
|
||||||
|
<div class="thread-actions">
|
||||||
|
<el-button v-if="canQuote" type="text" size="small" @click="$emit('quote', item)">引用</el-button>
|
||||||
|
<el-button v-if="canDelete?.(item)" type="text" size="small" class="danger" @click="$emit('delete', item)">
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
<slot name="actions" :item="item" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="item.quote" class="quote-block">
|
||||||
|
<div class="quote-meta">
|
||||||
|
{{ displayUser(item.quote.created_by, { users: userMap, members: memberMap }) }}
|
||||||
|
· {{ displayDateTime(item.quote.created_at) }}
|
||||||
|
</div>
|
||||||
|
<div class="quote-content">{{ quoteContent(item.quote) }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="attachmentsMap[item.id]?.length" class="thread-attachments">
|
||||||
|
<div v-for="file in attachmentsMap[item.id]" :key="file.id" class="attachment-item">
|
||||||
|
<el-image
|
||||||
|
v-if="isImage(file)"
|
||||||
|
:src="attachmentUrl(file.id)"
|
||||||
|
:preview-src-list="[attachmentUrl(file.id)]"
|
||||||
|
fit="cover"
|
||||||
|
preview-teleported
|
||||||
|
class="attachment-image"
|
||||||
|
/>
|
||||||
|
<div v-if="isImage(file)" class="attachment-name" :title="file.filename">
|
||||||
|
{{ file.filename }}
|
||||||
|
</div>
|
||||||
|
<el-link v-else :underline="false" class="file-link" @click="download(file.id)">
|
||||||
|
<span class="file-badge">文件</span>
|
||||||
|
<span class="file-name">{{ file.filename }}</span>
|
||||||
|
</el-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p>{{ contentText(item) }}</p>
|
||||||
|
</div>
|
||||||
|
</el-timeline-item>
|
||||||
|
</el-timeline>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { displayDateTime, displayUser } from "../utils/display";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
items: any[];
|
||||||
|
attachmentsMap: Record<string, any[]>;
|
||||||
|
memberMap?: Record<string, string>;
|
||||||
|
userMap?: Record<string, string>;
|
||||||
|
canQuote?: boolean;
|
||||||
|
canDelete?: (item: any) => boolean;
|
||||||
|
highlightIds?: Array<string | number>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: "quote", item: any): void;
|
||||||
|
(e: "delete", item: any): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const quoteContent = (item: any) => (item?.is_deleted ? "内容已删除" : item?.content || "—");
|
||||||
|
const contentText = (item: any) => (item?.is_deleted ? "内容已删除" : item?.content || "—");
|
||||||
|
|
||||||
|
const attachmentUrl = (id: string) => {
|
||||||
|
const token = localStorage.getItem("ctms_token");
|
||||||
|
return token ? `/api/v1/attachments/${id}/download?token=${token}` : `/api/v1/attachments/${id}/download`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const download = (id: string) => {
|
||||||
|
window.open(attachmentUrl(id), "_blank");
|
||||||
|
};
|
||||||
|
|
||||||
|
const isImage = (file: any) => {
|
||||||
|
const type = String(file?.content_type || "").toLowerCase();
|
||||||
|
if (type.startsWith("image/")) return true;
|
||||||
|
const name = String(file?.filename || "").toLowerCase();
|
||||||
|
return [".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"].some((ext) => name.endsWith(ext));
|
||||||
|
};
|
||||||
|
|
||||||
|
const isHighlighted = (item: any) => {
|
||||||
|
if (!props.highlightIds?.length) return false;
|
||||||
|
return props.highlightIds.includes(item?.id);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.thread-item.is-highlighted {
|
||||||
|
background: #fff7ed;
|
||||||
|
border: 1px solid #fed7aa;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.thread-item {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #edf1f7;
|
||||||
|
background: #ffffff;
|
||||||
|
margin: 6px 0;
|
||||||
|
}
|
||||||
|
.thread-item.is-highlighted {
|
||||||
|
border-color: #fed7aa;
|
||||||
|
}
|
||||||
|
.thread-item p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
}
|
||||||
|
.thread-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.thread-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.quote-block {
|
||||||
|
background: #f5f7fa;
|
||||||
|
border-left: 3px solid #dcdfe6;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.quote-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.quote-content {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.thread-attachments {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.attachment-item {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
max-width: 140px;
|
||||||
|
}
|
||||||
|
.attachment-image {
|
||||||
|
width: 128px;
|
||||||
|
height: 96px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
background: #fafafa;
|
||||||
|
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
.attachment-name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #606266;
|
||||||
|
max-width: 128px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.file-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
color: #1f2937;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.file-link:hover {
|
||||||
|
border-color: #cbd5f5;
|
||||||
|
background: #eef2ff;
|
||||||
|
}
|
||||||
|
.file-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #334155;
|
||||||
|
background: #e2e8f0;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.file-name {
|
||||||
|
max-width: 180px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.danger {
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,14 +2,19 @@ import type { Dict } from "./types";
|
|||||||
import { createDict } from "./utils";
|
import { createDict } from "./utils";
|
||||||
|
|
||||||
const seriousnessItems = [
|
const seriousnessItems = [
|
||||||
{ value: "NON_SERIOUS", label: "非严重", order: 1 },
|
{ value: "NON_SERIOUS", label: "否", order: 1 },
|
||||||
{ value: "SERIOUS", label: "严重", order: 2, color: "danger" },
|
{ value: "SERIOUS", label: "是", order: 2, color: "danger" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const severityItems = [
|
const severityItems = [
|
||||||
{ value: "MILD", label: "轻度", order: 1 },
|
{ value: "G1", label: "I级", order: 1 },
|
||||||
{ value: "MODERATE", label: "中度", order: 2 },
|
{ value: "G2", label: "II级", order: 2 },
|
||||||
{ value: "SEVERE", label: "重度", order: 3, color: "danger" },
|
{ value: "G3", label: "III级", order: 3 },
|
||||||
|
{ value: "G4", label: "IV级", order: 4, color: "danger" },
|
||||||
|
{ value: "G5", label: "V级", order: 5, color: "danger" },
|
||||||
|
{ value: "MILD", label: "轻度", order: 6 },
|
||||||
|
{ value: "MODERATE", label: "中度", order: 7 },
|
||||||
|
{ value: "SEVERE", label: "重度", order: 8, color: "danger" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const aeSeriousnessDict: Dict = createDict(seriousnessItems);
|
export const aeSeriousnessDict: Dict = createDict(seriousnessItems);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const items = [
|
|||||||
{ value: "PAID", label: "已支付", color: "success", order: 14 },
|
{ value: "PAID", label: "已支付", color: "success", order: 14 },
|
||||||
{ value: "CLOSED", label: "已关闭", color: "success", order: 20 },
|
{ value: "CLOSED", label: "已关闭", color: "success", order: 20 },
|
||||||
{ value: "OPEN", label: "开放", color: "info", order: 19 },
|
{ value: "OPEN", label: "开放", color: "info", order: 19 },
|
||||||
|
{ value: "NEW", label: "新建", color: "info", order: 17 },
|
||||||
{ value: "FOLLOW_UP", label: "随访中", color: "warning", order: 18 },
|
{ value: "FOLLOW_UP", label: "随访中", color: "warning", order: 18 },
|
||||||
{ value: "OVERDUE", label: "已逾期", color: "danger", order: 30 },
|
{ value: "OVERDUE", label: "已逾期", color: "danger", order: 30 },
|
||||||
{ value: "SCREENING", label: "筛选中", color: "info", order: 40 },
|
{ value: "SCREENING", label: "筛选中", color: "info", order: 40 },
|
||||||
|
|||||||
+358
-84
@@ -5,17 +5,10 @@
|
|||||||
<h1 class="ctms-page-title">不良事件</h1>
|
<h1 class="ctms-page-title">不良事件</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="ctms-page-actions">
|
<div class="ctms-page-actions">
|
||||||
<template v-for="action in availableActions" :key="action.key">
|
<el-button v-if="canEdit && !editing" type="primary" size="small" @click="startEdit">编辑</el-button>
|
||||||
<PermissionAction action="ae.close">
|
<template v-else-if="canEdit && editing">
|
||||||
<el-button
|
<el-button size="small" @click="cancelEdit">取消</el-button>
|
||||||
size="small"
|
<el-button type="primary" size="small" :loading="saving" @click="saveEdit">保存</el-button>
|
||||||
:type="action.danger ? 'danger' : 'primary'"
|
|
||||||
:link="action.danger"
|
|
||||||
@click="onAction(action)"
|
|
||||||
>
|
|
||||||
{{ action.label }}
|
|
||||||
</el-button>
|
|
||||||
</PermissionAction>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -27,14 +20,56 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-descriptions :column="2" border class="detail-descriptions">
|
<el-descriptions :column="2" border class="detail-descriptions">
|
||||||
<el-descriptions-item label="受试者">{{ subjectName(ae.subject_id) }}</el-descriptions-item>
|
<el-descriptions-item label="受试者">
|
||||||
<el-descriptions-item label="严重性">{{ ae.seriousness }}</el-descriptions-item>
|
<el-select v-if="editing" v-model="form.subject_id" placeholder="可留空" filterable class="inline-input">
|
||||||
<el-descriptions-item label="严重程度">{{ ae.severity }}</el-descriptions-item>
|
<el-option v-for="s in subjectOptions" :key="s.value" :label="s.label" :value="s.value" />
|
||||||
<el-descriptions-item label="发生日期">{{ ae.onset_date }}</el-descriptions-item>
|
</el-select>
|
||||||
<el-descriptions-item label="状态">
|
<span v-else>{{ subjectName(ae.subject_id) }}</span>
|
||||||
<el-tag :type="stateColor(aeState)" effect="plain">{{ stateLabel(aeState) }}</el-tag>
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="事件描述">
|
||||||
|
<el-input v-if="editing" v-model="form.term" class="inline-input" />
|
||||||
|
<span v-else>{{ ae.term || "—" }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="SAE">
|
||||||
|
<el-select v-if="editing" v-model="form.seriousness" placeholder="请选择" class="inline-input">
|
||||||
|
<el-option label="是" value="SERIOUS" />
|
||||||
|
<el-option label="否" value="NON_SERIOUS" />
|
||||||
|
</el-select>
|
||||||
|
<span v-else>{{ seriousnessLabel(ae.seriousness) }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="严重程度">
|
||||||
|
<el-select v-if="editing" v-model="form.severity" placeholder="请选择" class="inline-input">
|
||||||
|
<el-option v-for="s in severities" :key="s" :label="severityLabel(s)" :value="s" />
|
||||||
|
</el-select>
|
||||||
|
<span v-else>{{ severityLabel(ae.severity) }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="发生日期">
|
||||||
|
<el-date-picker
|
||||||
|
v-if="editing"
|
||||||
|
v-model="form.onset_date"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
class="inline-input"
|
||||||
|
:disabled-date="disableFutureDate"
|
||||||
|
/>
|
||||||
|
<span v-else>{{ ae.onset_date || "—" }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="状态">
|
||||||
|
<el-select v-if="editing" v-model="form.status" placeholder="请选择" class="inline-input">
|
||||||
|
<el-option v-for="s in statusOptions" :key="s" :label="stateLabel(s)" :value="s" />
|
||||||
|
</el-select>
|
||||||
|
<el-tag v-else :type="stateColor(aeState)" effect="plain">{{ stateLabel(aeState) }}</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="描述" :span="2">
|
||||||
|
<el-input v-if="editing" v-model="form.description" type="textarea" class="inline-input" />
|
||||||
|
<span v-else>{{ ae.description || "—" }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="转归结局" :span="2">
|
||||||
|
<el-select v-if="editing" v-model="form.outcome" placeholder="可留空" clearable class="inline-input">
|
||||||
|
<el-option v-for="o in outcomes" :key="o" :label="o" :value="o" />
|
||||||
|
</el-select>
|
||||||
|
<span v-else>{{ ae.outcome || "—" }}</span>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="描述">{{ ae.description }}</el-descriptions-item>
|
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
@@ -56,6 +91,57 @@
|
|||||||
:show-uploader="true"
|
:show-uploader="true"
|
||||||
/>
|
/>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="变更记录">
|
||||||
|
<el-timeline v-if="auditLogs.length">
|
||||||
|
<el-timeline-item v-for="log in auditLogs" :key="log.id" :timestamp="displayDateTime(log.created_at)">
|
||||||
|
<div class="audit-item">
|
||||||
|
<div class="audit-meta">
|
||||||
|
<strong>{{ displayUser(log.operator_id, { members: memberMap }) }}</strong>
|
||||||
|
<span class="audit-action">{{ log.action }}</span>
|
||||||
|
</div>
|
||||||
|
<el-button
|
||||||
|
v-if="canDeleteAudit"
|
||||||
|
type="danger"
|
||||||
|
text
|
||||||
|
size="small"
|
||||||
|
@click="onDeleteAuditLog(log.id)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<div v-if="log._detail" class="audit-detail-grid">
|
||||||
|
<div class="audit-detail-row">
|
||||||
|
<span class="audit-detail-label">事件</span>
|
||||||
|
<span class="audit-detail-value">{{ formatAuditField(log._detail, "event") }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="audit-detail-row">
|
||||||
|
<span class="audit-detail-label">发生日期</span>
|
||||||
|
<span class="audit-detail-value">
|
||||||
|
{{ formatAuditField(log._detail, "onset_date") }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="audit-detail-row">
|
||||||
|
<span class="audit-detail-label">严重程度</span>
|
||||||
|
<span class="audit-detail-value">
|
||||||
|
{{ formatAuditField(log._detail, "severity", severityLabel) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="audit-detail-row">
|
||||||
|
<span class="audit-detail-label">状态</span>
|
||||||
|
<span class="audit-detail-value">
|
||||||
|
{{ formatAuditField(log._detail, "status", stateLabel) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="audit-detail-row">
|
||||||
|
<span class="audit-detail-label">描述</span>
|
||||||
|
<span class="audit-detail-value">{{ formatAuditField(log._detail, "description") }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="audit-detail">{{ log.detail || "—" }}</div>
|
||||||
|
</el-timeline-item>
|
||||||
|
</el-timeline>
|
||||||
|
<div v-else class="empty">暂无变更记录</div>
|
||||||
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,23 +151,21 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { fetchAes, updateAe } from "../api/aes";
|
import { fetchAes, updateAe } from "../api/aes";
|
||||||
import { fetchSubjects } from "../api/subjects";
|
import { fetchSubjects } from "../api/subjects";
|
||||||
|
import { deleteAuditLog, fetchAuditLogs } from "../api/auditLogs";
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
import CommentList from "../components/CommentList.vue";
|
import CommentList from "../components/CommentList.vue";
|
||||||
import AttachmentList from "../components/attachments/AttachmentList.vue";
|
import AttachmentList from "../components/attachments/AttachmentList.vue";
|
||||||
import PermissionAction from "../components/PermissionAction.vue";
|
|
||||||
import StateLoading from "../components/StateLoading.vue";
|
import StateLoading from "../components/StateLoading.vue";
|
||||||
import { usePermission } from "../utils/permission";
|
import { usePermission } from "../utils/permission";
|
||||||
import { aeMachine, getAvailableActions } from "../state-machine";
|
|
||||||
import type { ActionConfig } from "../state-machine";
|
|
||||||
import { statusDict, getDictColor, getDictLabel } from "../dictionaries";
|
import { statusDict, getDictColor, getDictLabel } from "../dictionaries";
|
||||||
import { evaluateAction } from "../guards/actionGuard";
|
import { listMembers } from "../api/members";
|
||||||
import { logAudit } from "../audit";
|
import { displayDateTime, displayUser, getMemberDisplayName } from "../utils/display";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
@@ -90,12 +174,76 @@ const auth = useAuthStore();
|
|||||||
const ae = ref<any | null>(null);
|
const ae = ref<any | null>(null);
|
||||||
const studyId = computed(() => study.currentStudy?.id || "");
|
const studyId = computed(() => study.currentStudy?.id || "");
|
||||||
const subjects = ref<any[]>([]);
|
const subjects = ref<any[]>([]);
|
||||||
|
const members = ref<any[]>([]);
|
||||||
|
const auditLogs = ref<any[]>([]);
|
||||||
|
const editing = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const form = reactive({
|
||||||
|
subject_id: "",
|
||||||
|
term: "",
|
||||||
|
onset_date: "",
|
||||||
|
seriousness: "NON_SERIOUS",
|
||||||
|
severity: "G1",
|
||||||
|
status: "NEW",
|
||||||
|
description: "",
|
||||||
|
outcome: "",
|
||||||
|
});
|
||||||
|
const severities = ["G1", "G2", "G3", "G4", "G5"];
|
||||||
|
const statusOptions = ["FOLLOW_UP", "CLOSED"];
|
||||||
|
const outcomes = ["痊愈", "好转", "持续", "恶化", "死亡", "其他"];
|
||||||
|
const severityLabel = (v?: string | null) =>
|
||||||
|
({
|
||||||
|
G1: "I级",
|
||||||
|
G2: "II级",
|
||||||
|
G3: "III级",
|
||||||
|
G4: "IV级",
|
||||||
|
G5: "V级",
|
||||||
|
}[v || ""] || v || "—");
|
||||||
|
const seriousnessLabel = (v?: string | null) =>
|
||||||
|
({ SERIOUS: "是", NON_SERIOUS: "否" }[v || ""] || v || "—");
|
||||||
|
|
||||||
const { can } = usePermission();
|
const { can } = usePermission();
|
||||||
|
const canEdit = computed(() => can("ae.create"));
|
||||||
|
const canDeleteAudit = computed(() => auth.user?.role === "ADMIN");
|
||||||
const aeState = computed(() => (ae.value?.status as string) || "NEW");
|
const aeState = computed(() => (ae.value?.status as string) || "NEW");
|
||||||
const availableActions = computed(() => getAvailableActions(aeMachine, aeState.value));
|
const stateLabel = (v?: string | null) =>
|
||||||
const stateLabel = (v?: string | null) => getDictLabel(statusDict, v || "");
|
({ FOLLOW_UP: "随访中", CLOSED: "结束", NEW: "随访中" }[v || ""] || getDictLabel(statusDict, v || ""));
|
||||||
const stateColor = (v?: string | null) => getDictColor(statusDict, v || "") || "info";
|
const stateColor = (v?: string | null) => getDictColor(statusDict, v || "") || "info";
|
||||||
|
const disableFutureDate = (time: Date) => time.getTime() > Date.now();
|
||||||
|
const parseAuditDetail = (detail: any) => {
|
||||||
|
if (!detail) return null;
|
||||||
|
if (typeof detail === "object") return detail;
|
||||||
|
if (typeof detail !== "string") return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(detail);
|
||||||
|
if (parsed && typeof parsed === "object") return parsed;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const formatAuditValue = (value: any, formatter?: (val?: string | null) => string) => {
|
||||||
|
if (value === null || value === undefined || value === "") return "—";
|
||||||
|
return formatter ? formatter(value) : String(value);
|
||||||
|
};
|
||||||
|
const formatAuditField = (
|
||||||
|
detail: any,
|
||||||
|
key: string,
|
||||||
|
formatter?: (val?: string | null) => string
|
||||||
|
) => {
|
||||||
|
const before = detail?.before?.[key];
|
||||||
|
const after = detail?.after?.[key];
|
||||||
|
if (before !== undefined || after !== undefined) {
|
||||||
|
const beforeLabel = formatAuditValue(before, formatter);
|
||||||
|
const afterLabel = formatAuditValue(after, formatter);
|
||||||
|
if (beforeLabel === "—" && afterLabel === "—") return "—";
|
||||||
|
if (beforeLabel === afterLabel) return afterLabel;
|
||||||
|
if (beforeLabel === "—") return afterLabel;
|
||||||
|
if (afterLabel === "—") return beforeLabel;
|
||||||
|
return `${beforeLabel} → ${afterLabel}`;
|
||||||
|
}
|
||||||
|
return formatAuditValue(detail?.[key], formatter);
|
||||||
|
};
|
||||||
|
|
||||||
const enrichOverdue = (item: any) => {
|
const enrichOverdue = (item: any) => {
|
||||||
if (!item) return item;
|
if (!item) return item;
|
||||||
@@ -135,79 +283,156 @@ const subjectLabel = computed(() => {
|
|||||||
}, {});
|
}, {});
|
||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
|
const subjectOptions = computed(() =>
|
||||||
|
subjects.value.map((s: any) => ({ value: s.id, label: s.subject_no || s.id }))
|
||||||
|
);
|
||||||
|
|
||||||
const subjectName = (id?: string | null) => {
|
const subjectName = (id?: string | null) => {
|
||||||
if (!id) return "—";
|
if (!id) return "—";
|
||||||
return subjectLabel.value[id] || id;
|
return subjectLabel.value[id] || id;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const memberMap = computed(() =>
|
||||||
|
members.value.reduce<Record<string, string>>((acc, cur) => {
|
||||||
|
const username = getMemberDisplayName(cur);
|
||||||
|
if (cur?.user_id && username) acc[cur.user_id] = username;
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
|
);
|
||||||
|
|
||||||
const onAction = async (action: ActionConfig) => {
|
const loadMembers = async () => {
|
||||||
if (!study.currentStudy || !ae.value) return;
|
if (!study.currentStudy) return;
|
||||||
const prevStatus = ae.value.status;
|
|
||||||
if (prevStatus === action.to) {
|
|
||||||
ElMessage.warning("当前状态不允许该操作");
|
|
||||||
logAudit("INVALID_STATE_TRANSITION_ATTEMPT", {
|
|
||||||
targetId: ae.value.id,
|
|
||||||
targetName: ae.value.term,
|
|
||||||
before: { status: prevStatus },
|
|
||||||
after: { status: action.to },
|
|
||||||
severity: "warning",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const decision = evaluateAction({
|
|
||||||
actorRole: auth.user?.role || null,
|
|
||||||
requiredPermission: "ae.close",
|
|
||||||
stateMachine: aeMachine,
|
|
||||||
currentState: ae.value.status,
|
|
||||||
actionKey: action.key,
|
|
||||||
target: { id: ae.value.id },
|
|
||||||
});
|
|
||||||
if (!decision.allowed) {
|
|
||||||
ElMessage.warning(decision.reason || "当前不可执行该操作");
|
|
||||||
logAudit(decision.auditType, {
|
|
||||||
targetId: ae.value.id,
|
|
||||||
targetName: ae.value.term,
|
|
||||||
reason: decision.reason,
|
|
||||||
severity: decision.severity,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (action.confirm) {
|
|
||||||
const ok = await ElMessageBox.confirm(action.confirmText || "确认执行该操作?", "提示").catch(() => null);
|
|
||||||
if (!ok) return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const resp = await updateAe(study.currentStudy.id, ae.value.id, { status: action.to });
|
const { data } = await listMembers(study.currentStudy.id, { limit: 500 });
|
||||||
ElMessage.success("状态已更新");
|
members.value = Array.isArray(data) ? data : data.items || [];
|
||||||
ae.value = enrichOverdue(resp.data);
|
} catch {
|
||||||
logAudit("AE_CLOSED", {
|
members.value = [];
|
||||||
targetId: ae.value.id,
|
|
||||||
targetName: ae.value.term,
|
|
||||||
before: { status: prevStatus },
|
|
||||||
after: { status: action.to },
|
|
||||||
severity: "normal",
|
|
||||||
});
|
|
||||||
await load();
|
|
||||||
} catch (e: any) {
|
|
||||||
ElMessage.error(e?.response?.data?.message || "更新失败");
|
|
||||||
logAudit("AE_CLOSED", {
|
|
||||||
targetId: ae.value.id,
|
|
||||||
targetName: ae.value.term,
|
|
||||||
before: { status: prevStatus },
|
|
||||||
after: { status: action.to },
|
|
||||||
severity: "warning",
|
|
||||||
reason: e?.response?.data?.message,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadAuditLogs = async () => {
|
||||||
|
if (!study.currentStudy || !ae.value) return;
|
||||||
|
try {
|
||||||
|
const { data } = await fetchAuditLogs(study.currentStudy.id, {
|
||||||
|
entity_type: "ae",
|
||||||
|
entity_id: ae.value.id,
|
||||||
|
limit: 200,
|
||||||
|
});
|
||||||
|
const logs = Array.isArray(data) ? data : data.items || [];
|
||||||
|
auditLogs.value = logs.map((log: any) => ({
|
||||||
|
...log,
|
||||||
|
_detail: parseAuditDetail(log.detail),
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
auditLogs.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDeleteAuditLog = async (logId: string) => {
|
||||||
|
if (!study.currentStudy) return;
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm("确认删除该变更记录?", "提示", { type: "warning" });
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await deleteAuditLog(study.currentStudy.id, logId);
|
||||||
|
auditLogs.value = auditLogs.value.filter((log) => log.id !== logId);
|
||||||
|
ElMessage.success("已删除");
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || "删除失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncForm = () => {
|
||||||
|
if (!ae.value) return;
|
||||||
|
form.subject_id = ae.value.subject_id || "";
|
||||||
|
form.term = ae.value.term || "";
|
||||||
|
form.onset_date = ae.value.onset_date || "";
|
||||||
|
form.seriousness = ae.value.seriousness || "NON_SERIOUS";
|
||||||
|
form.severity = ae.value.severity || "G1";
|
||||||
|
form.status = statusOptions.includes(ae.value.status) ? ae.value.status : "FOLLOW_UP";
|
||||||
|
form.description = ae.value.description || "";
|
||||||
|
form.outcome = ae.value.outcome || "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEdit = () => {
|
||||||
|
syncForm();
|
||||||
|
editing.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
editing.value = false;
|
||||||
|
syncForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = async () => {
|
||||||
|
if (!study.currentStudy || !ae.value) return;
|
||||||
|
if (!form.term.trim()) {
|
||||||
|
ElMessage.warning("请输入事件描述");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.onset_date) {
|
||||||
|
ElMessage.warning("请选择发生日期");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.onset_date && new Date(form.onset_date) > new Date()) {
|
||||||
|
ElMessage.warning("发生日期不得晚于当前日期");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.seriousness) {
|
||||||
|
ElMessage.warning("请选择 SAE");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.severity) {
|
||||||
|
ElMessage.warning("请选择严重程度");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.status) {
|
||||||
|
ElMessage.warning("请选择状态");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.status === "CLOSED" && !form.outcome) {
|
||||||
|
ElMessage.warning("状态为结束时,请选择转归结局");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
const payload: Record<string, any> = {};
|
||||||
|
Object.entries(form).forEach(([k, v]) => {
|
||||||
|
if (v !== "" && v !== null && v !== undefined) {
|
||||||
|
payload[k] = v;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (form.outcome === "" || form.outcome === null || form.outcome === undefined) {
|
||||||
|
payload.outcome = null;
|
||||||
|
}
|
||||||
|
const resp = await updateAe(study.currentStudy.id, ae.value.id, payload);
|
||||||
|
ae.value = enrichOverdue(resp.data);
|
||||||
|
ElMessage.success("已保存");
|
||||||
|
editing.value = false;
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || "保存失败");
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => ae.value,
|
||||||
|
() => {
|
||||||
|
if (!editing.value) {
|
||||||
|
syncForm();
|
||||||
|
}
|
||||||
|
loadAuditLogs();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!auth.user && auth.token) {
|
if (!auth.user && auth.token) {
|
||||||
await auth.fetchMe().catch(() => {});
|
await auth.fetchMe().catch(() => {});
|
||||||
}
|
}
|
||||||
await loadSubjects();
|
await Promise.all([loadSubjects(), loadMembers()]);
|
||||||
load();
|
load();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -220,4 +445,53 @@ onMounted(async () => {
|
|||||||
.detail-tabs {
|
.detail-tabs {
|
||||||
margin-top: -8px;
|
margin-top: -8px;
|
||||||
}
|
}
|
||||||
|
.audit-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.audit-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.audit-action {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
background: #f1f5f9;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.audit-detail {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.audit-detail-grid {
|
||||||
|
margin-top: 6px;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.audit-detail-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 72px 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.audit-detail-label {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
.audit-detail-value {
|
||||||
|
color: #475569;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.inline-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+51
-94
@@ -18,7 +18,7 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
</div>
|
</div>
|
||||||
<div class="ctms-filter-item">
|
<div class="ctms-filter-item">
|
||||||
<span class="ctms-filter-label">严重性</span>
|
<span class="ctms-filter-label">SAE</span>
|
||||||
<el-select v-model="filters.seriousness" placeholder="全部" clearable @change="loadAes">
|
<el-select v-model="filters.seriousness" placeholder="全部" clearable @change="loadAes">
|
||||||
<el-option v-for="s in seriousnessOptions" :key="s" :label="seriousnessLabel(s)" :value="s" />
|
<el-option v-for="s in seriousnessOptions" :key="s" :label="seriousnessLabel(s)" :value="s" />
|
||||||
</el-select>
|
</el-select>
|
||||||
@@ -39,31 +39,27 @@
|
|||||||
{{ subjectMap[scope.row.subject_id] || scope.row.subject_id || "-" }}
|
{{ subjectMap[scope.row.subject_id] || scope.row.subject_id || "-" }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="seriousness" label="严重性" width="140">
|
<el-table-column label="中心" width="200">
|
||||||
|
<template #default="scope">
|
||||||
|
{{ siteName(subjectSiteMap[scope.row.subject_id]) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="seriousness" label="SAE" width="120">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ seriousnessLabel(scope.row.seriousness) }}
|
{{ seriousnessLabel(scope.row.seriousness) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column prop="severity" label="严重程度" width="140">
|
||||||
|
<template #default="scope">
|
||||||
|
{{ severityLabel(scope.row.severity) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="onset_date" label="发生日期" width="140" />
|
<el-table-column prop="onset_date" label="发生日期" width="140" />
|
||||||
<el-table-column prop="report_due_date" label="报告截止" width="140" />
|
|
||||||
<el-table-column prop="status" label="状态" width="120">
|
<el-table-column prop="status" label="状态" width="120">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-tag :type="scope.row.is_overdue ? 'danger' : 'info'">{{ statusLabel(scope.row.status) }}</el-tag>
|
<el-tag :type="scope.row.is_overdue ? 'danger' : 'info'">{{ statusLabel(scope.row.status) }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="120" align="right">
|
|
||||||
<template #default="scope">
|
|
||||||
<el-button
|
|
||||||
v-if="showClose(scope.row)"
|
|
||||||
type="danger"
|
|
||||||
size="small"
|
|
||||||
link
|
|
||||||
@click.stop="onClose(scope.row)"
|
|
||||||
>
|
|
||||||
关闭 AE
|
|
||||||
</el-button>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
</el-table>
|
||||||
<el-pagination
|
<el-pagination
|
||||||
class="ctms-pagination"
|
class="ctms-pagination"
|
||||||
@@ -82,15 +78,14 @@
|
|||||||
<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 { useRouter } from "vue-router";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import { fetchAes, updateAe } from "../api/aes";
|
import { fetchAes } from "../api/aes";
|
||||||
import { fetchSubjects } from "../api/subjects";
|
import { fetchSubjects } from "../api/subjects";
|
||||||
|
import { fetchSites } from "../api/sites";
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
import AeForm from "../components/AeForm.vue";
|
import AeForm from "../components/AeForm.vue";
|
||||||
import { aeMachine, getAvailableActions } from "../state-machine";
|
import { aeSeriousnessDict, aeSeverityDict, getDictLabel } from "../dictionaries";
|
||||||
import { evaluateAction } from "../guards/actionGuard";
|
|
||||||
import { logAudit } from "../audit";
|
|
||||||
|
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
@@ -102,14 +97,27 @@ const total = ref(0);
|
|||||||
const page = ref(1);
|
const page = ref(1);
|
||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
const subjects = ref<any[]>([]);
|
const subjects = ref<any[]>([]);
|
||||||
|
const sites = ref<any[]>([]);
|
||||||
const subjectMap = computed(() =>
|
const subjectMap = computed(() =>
|
||||||
subjects.value.reduce<Record<string, string>>((acc, cur) => {
|
subjects.value.reduce<Record<string, string>>((acc, cur) => {
|
||||||
acc[cur.id] = cur.subject_no || cur.id;
|
acc[cur.id] = cur.subject_no || cur.id;
|
||||||
return acc;
|
return acc;
|
||||||
}, {})
|
}, {})
|
||||||
);
|
);
|
||||||
|
const subjectSiteMap = computed(() =>
|
||||||
|
subjects.value.reduce<Record<string, string>>((acc, cur) => {
|
||||||
|
if (cur?.id) acc[cur.id] = cur.site_id || "";
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
|
);
|
||||||
|
const siteMap = computed(() =>
|
||||||
|
sites.value.reduce<Record<string, string>>((acc, cur) => {
|
||||||
|
if (cur?.id) acc[cur.id] = cur.name || cur.id;
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
|
);
|
||||||
|
|
||||||
const statuses = ["NEW", "FOLLOW_UP", "CLOSED"];
|
const statuses = ["FOLLOW_UP", "CLOSED"];
|
||||||
const seriousnessOptions = ["SERIOUS", "NON_SERIOUS"];
|
const seriousnessOptions = ["SERIOUS", "NON_SERIOUS"];
|
||||||
|
|
||||||
const filters = ref({
|
const filters = ref({
|
||||||
@@ -130,7 +138,7 @@ const loadAes = async () => {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const params: Record<string, any> = { skip: (page.value - 1) * pageSize, limit: pageSize };
|
const params: Record<string, any> = { skip: (page.value - 1) * pageSize, limit: pageSize };
|
||||||
if (filters.value.status) params.status = filters.value.status;
|
if (filters.value.status) params.status_filter = filters.value.status;
|
||||||
if (filters.value.seriousness) params.seriousness = filters.value.seriousness;
|
if (filters.value.seriousness) params.seriousness = filters.value.seriousness;
|
||||||
if (filters.value.overdue) params.overdue = true;
|
if (filters.value.overdue) params.overdue = true;
|
||||||
const { data } = await fetchAes(study.currentStudy.id, params);
|
const { data } = await fetchAes(study.currentStudy.id, params);
|
||||||
@@ -171,92 +179,41 @@ const loadSubjects = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadSites = async () => {
|
||||||
|
if (!study.currentStudy) return;
|
||||||
|
try {
|
||||||
|
const { data } = await fetchSites(study.currentStudy.id, { skip: 0, limit: 500 });
|
||||||
|
sites.value = data.items || data || [];
|
||||||
|
} catch {
|
||||||
|
sites.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const goDetail = (row: any) => {
|
const goDetail = (row: any) => {
|
||||||
router.push(`/study/aes/${row.id}`);
|
router.push(`/study/aes/${row.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const siteName = (id?: string) => {
|
||||||
|
if (!id) return "-";
|
||||||
|
return siteMap.value[id] || id;
|
||||||
|
};
|
||||||
|
|
||||||
const statusLabel = (v: string) =>
|
const statusLabel = (v: string) =>
|
||||||
({
|
({
|
||||||
NEW: "新增",
|
|
||||||
FOLLOW_UP: "随访中",
|
FOLLOW_UP: "随访中",
|
||||||
CLOSED: "已关闭",
|
CLOSED: "结束",
|
||||||
|
NEW: "随访中",
|
||||||
}[v] || v);
|
}[v] || v);
|
||||||
|
|
||||||
const seriousnessLabel = (v: string) =>
|
const seriousnessLabel = (v: string) => getDictLabel(aeSeriousnessDict, v) || v;
|
||||||
({
|
const severityLabel = (v: string) => getDictLabel(aeSeverityDict, v) || v;
|
||||||
SERIOUS: "严重",
|
|
||||||
NON_SERIOUS: "非严重",
|
|
||||||
}[v] || v);
|
|
||||||
|
|
||||||
const showClose = (row: any) => {
|
|
||||||
const actions = getAvailableActions(aeMachine, row.status);
|
|
||||||
const has = actions.some((a) => a.key === "close");
|
|
||||||
if (!has) return false;
|
|
||||||
const decision = evaluateAction({
|
|
||||||
actorRole: auth.user?.role || null,
|
|
||||||
requiredPermission: "ae.close",
|
|
||||||
stateMachine: aeMachine,
|
|
||||||
currentState: row.status,
|
|
||||||
actionKey: "close",
|
|
||||||
});
|
|
||||||
return decision.allowed;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onClose = async (row: any) => {
|
|
||||||
if (!study.currentStudy) return;
|
|
||||||
const action = getAvailableActions(aeMachine, row.status).find((a) => a.key === "close");
|
|
||||||
const decision = evaluateAction({
|
|
||||||
actorRole: auth.user?.role || null,
|
|
||||||
requiredPermission: "ae.close",
|
|
||||||
stateMachine: aeMachine,
|
|
||||||
currentState: row.status,
|
|
||||||
actionKey: "close",
|
|
||||||
});
|
|
||||||
if (!decision.allowed) {
|
|
||||||
ElMessage.warning(decision.reason || "当前不可执行该操作");
|
|
||||||
logAudit(decision.auditType, {
|
|
||||||
targetId: row.id,
|
|
||||||
targetName: row.term,
|
|
||||||
reason: decision.reason,
|
|
||||||
severity: decision.severity,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (action?.confirm) {
|
|
||||||
const ok = await ElMessageBox.confirm(action.confirmText || "确认关闭?", "提示", {
|
|
||||||
type: action.danger ? "warning" : "info",
|
|
||||||
}).catch(() => null);
|
|
||||||
if (!ok) return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await updateAe(study.currentStudy.id, row.id, { status: action?.to || "CLOSED" });
|
|
||||||
ElMessage.success("已关闭");
|
|
||||||
logAudit("AE_CLOSED", {
|
|
||||||
targetId: row.id,
|
|
||||||
targetName: row.term,
|
|
||||||
before: { status: row.status },
|
|
||||||
after: { status: action?.to || "CLOSED" },
|
|
||||||
severity: "normal",
|
|
||||||
});
|
|
||||||
loadAes();
|
|
||||||
} catch (e: any) {
|
|
||||||
ElMessage.error(e?.response?.data?.message || "操作失败");
|
|
||||||
logAudit("AE_CLOSED", {
|
|
||||||
targetId: row.id,
|
|
||||||
targetName: row.term,
|
|
||||||
before: { status: row.status },
|
|
||||||
after: { status: action?.to || "CLOSED" },
|
|
||||||
severity: "warning",
|
|
||||||
reason: e?.response?.data?.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!auth.user && auth.token) {
|
if (!auth.user && auth.token) {
|
||||||
await auth.fetchMe().catch(() => {});
|
await auth.fetchMe().catch(() => {});
|
||||||
}
|
}
|
||||||
await loadSubjects();
|
await Promise.all([loadSubjects(), loadSites()]);
|
||||||
loadAes();
|
loadAes();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -19,7 +19,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ctms-filter-item">
|
<div class="ctms-filter-item">
|
||||||
<span class="ctms-filter-label">负责人</span>
|
<span class="ctms-filter-label">负责人</span>
|
||||||
<el-input v-model="filters.assigned_to" placeholder="输入邮箱或姓名" @change="loadQueries" />
|
<el-select v-model="filters.assigned_to" placeholder="负责人" clearable filterable @change="loadQueries">
|
||||||
|
<el-option v-for="m in memberOptions" :key="m.value" :label="m.label" :value="m.value" />
|
||||||
|
</el-select>
|
||||||
</div>
|
</div>
|
||||||
<div class="ctms-filter-item">
|
<div class="ctms-filter-item">
|
||||||
<span class="ctms-filter-label">仅逾期</span>
|
<span class="ctms-filter-label">仅逾期</span>
|
||||||
@@ -86,12 +88,14 @@ import { useRouter } from "vue-router";
|
|||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { fetchDataQueries, updateDataQuery } from "../api/dataQueries";
|
import { fetchDataQueries, updateDataQuery } from "../api/dataQueries";
|
||||||
import { fetchSubjects } from "../api/subjects";
|
import { fetchSubjects } from "../api/subjects";
|
||||||
|
import { listMembers } from "../api/members";
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
import DataQueryForm from "../components/DataQueryForm.vue";
|
import DataQueryForm from "../components/DataQueryForm.vue";
|
||||||
import { evaluateAction } from "../guards/actionGuard";
|
import { evaluateAction } from "../guards/actionGuard";
|
||||||
import { logAudit } from "../audit";
|
import { logAudit } from "../audit";
|
||||||
import { getAvailableActions, type StateMachine } from "../state-machine";
|
import { getAvailableActions, type StateMachine } from "../state-machine";
|
||||||
|
import { getMemberDisplayName } from "../utils/display";
|
||||||
|
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
@@ -103,12 +107,21 @@ const total = ref(0);
|
|||||||
const page = ref(1);
|
const page = ref(1);
|
||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
const subjects = ref<any[]>([]);
|
const subjects = ref<any[]>([]);
|
||||||
|
const members = ref<any[]>([]);
|
||||||
const subjectMap = computed(() =>
|
const subjectMap = computed(() =>
|
||||||
subjects.value.reduce<Record<string, string>>((acc, cur) => {
|
subjects.value.reduce<Record<string, string>>((acc, cur) => {
|
||||||
acc[cur.id] = cur.subject_no || cur.id;
|
acc[cur.id] = cur.subject_no || cur.id;
|
||||||
return acc;
|
return acc;
|
||||||
}, {})
|
}, {})
|
||||||
);
|
);
|
||||||
|
const memberOptions = computed(() =>
|
||||||
|
members.value
|
||||||
|
.map((m: any) => ({
|
||||||
|
value: m.user_id,
|
||||||
|
label: getMemberDisplayName(m) || m.user_id,
|
||||||
|
}))
|
||||||
|
.filter((m: any) => m.value)
|
||||||
|
);
|
||||||
|
|
||||||
const statuses = ["OPEN", "IN_PROGRESS", "ANSWERED", "CLOSED"];
|
const statuses = ["OPEN", "IN_PROGRESS", "ANSWERED", "CLOSED"];
|
||||||
const categories = ["MISSING", "INCONSISTENT", "OUT_OF_RANGE", "OTHER"];
|
const categories = ["MISSING", "INCONSISTENT", "OUT_OF_RANGE", "OTHER"];
|
||||||
@@ -150,7 +163,7 @@ const loadQueries = async () => {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const params: Record<string, any> = { skip: (page.value - 1) * pageSize, limit: pageSize };
|
const params: Record<string, any> = { skip: (page.value - 1) * pageSize, limit: pageSize };
|
||||||
if (filters.value.status) params.status = filters.value.status;
|
if (filters.value.status) params.status_filter = filters.value.status;
|
||||||
if (filters.value.assigned_to) params.assigned_to = filters.value.assigned_to;
|
if (filters.value.assigned_to) params.assigned_to = filters.value.assigned_to;
|
||||||
if (filters.value.overdue) params.overdue = true;
|
if (filters.value.overdue) params.overdue = true;
|
||||||
const { data } = await fetchDataQueries(study.currentStudy.id, params);
|
const { data } = await fetchDataQueries(study.currentStudy.id, params);
|
||||||
@@ -175,6 +188,26 @@ const loadQueries = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadSubjects = async () => {
|
||||||
|
if (!study.currentStudy) return;
|
||||||
|
try {
|
||||||
|
const { data } = await fetchSubjects(study.currentStudy.id, { skip: 0, limit: 500 });
|
||||||
|
subjects.value = data.items || data || [];
|
||||||
|
} catch {
|
||||||
|
subjects.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMembers = async () => {
|
||||||
|
if (!study.currentStudy) return;
|
||||||
|
try {
|
||||||
|
const { data } = await listMembers(study.currentStudy.id, { limit: 500 });
|
||||||
|
members.value = Array.isArray(data) ? data : (data as any).items || [];
|
||||||
|
} catch {
|
||||||
|
members.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onPageChange = (p: number) => {
|
const onPageChange = (p: number) => {
|
||||||
page.value = p;
|
page.value = p;
|
||||||
loadQueries();
|
loadQueries();
|
||||||
@@ -272,10 +305,7 @@ onMounted(async () => {
|
|||||||
if (!auth.user && auth.token) {
|
if (!auth.user && auth.token) {
|
||||||
await auth.fetchMe().catch(() => {});
|
await auth.fetchMe().catch(() => {});
|
||||||
}
|
}
|
||||||
if (study.currentStudy) {
|
await Promise.all([loadSubjects(), loadMembers()]);
|
||||||
const { data } = await fetchSubjects(study.currentStudy.id, { skip: 0, limit: 500 });
|
|
||||||
subjects.value = data.items || data || [];
|
|
||||||
}
|
|
||||||
loadQueries();
|
loadQueries();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -80,9 +80,9 @@ const canEdit = computed(() => can("faq.edit"));
|
|||||||
|
|
||||||
const loadCategories = async () => {
|
const loadCategories = async () => {
|
||||||
try {
|
try {
|
||||||
|
if (!study.currentStudy) return;
|
||||||
const params: Record<string, any> = {};
|
const params: Record<string, any> = {};
|
||||||
if (study.currentStudy) params.study_id = study.currentStudy.id;
|
if (study.currentStudy) params.study_id = study.currentStudy.id;
|
||||||
params.include_global = false;
|
|
||||||
const { data } = await fetchFaqCategories(params);
|
const { data } = await fetchFaqCategories(params);
|
||||||
categories.value = data.items || data || [];
|
categories.value = data.items || data || [];
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
@@ -44,53 +44,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="canReply" class="reply-form">
|
<div v-if="canReply" class="reply-form">
|
||||||
<div v-if="quoteReply" class="quote-box">
|
<ThreadComposer
|
||||||
<div class="quote-meta">
|
v-model="replyContent"
|
||||||
引用 {{ displayUser(quoteReply.created_by, { members: memberMap, users: userMap }) }}
|
v-model:file-list="replyFiles"
|
||||||
· {{ displayDateTime(quoteReply.created_at) }}
|
:submitting="submitting"
|
||||||
<el-button type="text" size="small" @click="clearQuote">取消引用</el-button>
|
:quote-item="quoteReply"
|
||||||
</div>
|
:member-map="memberMap"
|
||||||
<div class="quote-content">{{ quoteReply.content }}</div>
|
:user-map="userMap"
|
||||||
</div>
|
:allow-attachments="true"
|
||||||
<el-input v-model="replyContent" type="textarea" :rows="4" placeholder="请输入回复内容" />
|
@submit="submitReply"
|
||||||
<div class="reply-actions">
|
@clear-quote="clearQuote"
|
||||||
<el-button type="primary" :loading="submitting" @click="submitReply">提交回复</el-button>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="reply-disabled">当前角色无权限回复</div>
|
<div v-else class="reply-disabled">当前角色无权限回复</div>
|
||||||
<el-divider v-if="replies.length" />
|
<el-divider v-if="replies.length" />
|
||||||
<el-timeline v-if="replies.length">
|
<ThreadList
|
||||||
<el-timeline-item v-for="r in replies" :key="r.id" :timestamp="displayDateTime(r.created_at)">
|
v-if="replies.length"
|
||||||
<div class="reply-item">
|
:items="replies"
|
||||||
<div class="reply-meta">
|
:attachments-map="replyAttachmentsMap"
|
||||||
<strong>{{ displayUser(r.created_by, { members: memberMap, users: userMap }) }}</strong>
|
:member-map="memberMap"
|
||||||
<div class="reply-actions-inline">
|
:user-map="userMap"
|
||||||
<el-tag v-if="item?.best_reply_id === r.id" type="success" size="small">最佳答案</el-tag>
|
:can-quote="canReply"
|
||||||
<el-button v-if="canReply" type="text" size="small" @click="setQuote(r)">引用</el-button>
|
:can-delete="canDeleteReply"
|
||||||
<el-button
|
:highlight-ids="bestReplyId ? [bestReplyId] : []"
|
||||||
v-if="canSelectBest && !r.is_deleted"
|
@quote="setQuote"
|
||||||
type="text"
|
@delete="removeReply"
|
||||||
size="small"
|
>
|
||||||
@click="toggleBest(r)"
|
<template #actions="{ item: r }">
|
||||||
>
|
<el-tag v-if="item?.best_reply_id === r.id" type="success" size="small">最佳答案</el-tag>
|
||||||
{{ item?.best_reply_id === r.id ? "取消最佳" : "设为最佳" }}
|
<el-button v-if="canSelectBest && !r.is_deleted" type="text" size="small" @click="toggleBest(r)">
|
||||||
</el-button>
|
{{ item?.best_reply_id === r.id ? "取消最佳" : "设为最佳" }}
|
||||||
<el-button v-if="canDeleteReply(r)" type="text" size="small" class="danger" @click="removeReply(r)">
|
</el-button>
|
||||||
删除
|
</template>
|
||||||
</el-button>
|
</ThreadList>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="r.quote" class="quote-block">
|
|
||||||
<div class="quote-meta">
|
|
||||||
{{ displayUser(r.quote.created_by, { members: memberMap, users: userMap }) }}
|
|
||||||
· {{ displayDateTime(r.quote.created_at) }}
|
|
||||||
</div>
|
|
||||||
<div class="quote-content">{{ r.quote.content }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="reply-content">{{ r.is_deleted ? "回复已删除" : r.content }}</div>
|
|
||||||
</div>
|
|
||||||
</el-timeline-item>
|
|
||||||
</el-timeline>
|
|
||||||
<div v-else class="reply-empty">暂无回复</div>
|
<div v-else class="reply-empty">暂无回复</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
@@ -107,6 +93,7 @@
|
|||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
|
import type { UploadUserFile } from "element-plus";
|
||||||
import {
|
import {
|
||||||
createFaqReply,
|
createFaqReply,
|
||||||
deleteFaqReply,
|
deleteFaqReply,
|
||||||
@@ -117,10 +104,13 @@ import {
|
|||||||
setFaqStatus,
|
setFaqStatus,
|
||||||
} from "../api/faqs";
|
} from "../api/faqs";
|
||||||
import { listMembers } from "../api/members";
|
import { listMembers } from "../api/members";
|
||||||
|
import { fetchAttachments, uploadAttachment } from "../api/attachments";
|
||||||
import { displayDateTime, displayUser, getMemberDisplayName, getUserDisplayName } from "../utils/display";
|
import { displayDateTime, displayUser, getMemberDisplayName, getUserDisplayName } from "../utils/display";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
import { usePermission } from "../utils/permission";
|
import { usePermission } from "../utils/permission";
|
||||||
import FaqItemForm from "../components/FaqItemForm.vue";
|
import FaqItemForm from "../components/FaqItemForm.vue";
|
||||||
|
import ThreadComposer from "../components/ThreadComposer.vue";
|
||||||
|
import ThreadList from "../components/ThreadList.vue";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
@@ -135,12 +125,13 @@ const quoteReply = ref<any | null>(null);
|
|||||||
const members = ref<any[]>([]);
|
const members = ref<any[]>([]);
|
||||||
const showForm = ref(false);
|
const showForm = ref(false);
|
||||||
const editing = ref<any | null>(null);
|
const editing = ref<any | null>(null);
|
||||||
|
const replyFiles = ref<UploadUserFile[]>([]);
|
||||||
|
const replyAttachmentsMap = ref<Record<string, any[]>>({});
|
||||||
|
|
||||||
const { can } = usePermission();
|
const { can } = usePermission();
|
||||||
|
|
||||||
const canReply = computed(() => {
|
const canReply = computed(() => {
|
||||||
if (!item.value) return false;
|
if (!item.value) return false;
|
||||||
if (!item.value.study_id) return auth.user?.role === "ADMIN";
|
|
||||||
return can("faq.reply");
|
return can("faq.reply");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -153,7 +144,6 @@ const canDeleteReply = (reply: any) => {
|
|||||||
|
|
||||||
const canSelectBest = computed(() => {
|
const canSelectBest = computed(() => {
|
||||||
if (!item.value) return false;
|
if (!item.value) return false;
|
||||||
if (!item.value.study_id) return auth.user?.role === "ADMIN";
|
|
||||||
return can("faq.reply");
|
return can("faq.reply");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -161,7 +151,7 @@ const canEditQuestion = computed(() => {
|
|||||||
if (!item.value) return false;
|
if (!item.value) return false;
|
||||||
if (auth.user?.role === "ADMIN") return true;
|
if (auth.user?.role === "ADMIN") return true;
|
||||||
if (item.value.created_by === auth.user?.id) return true;
|
if (item.value.created_by === auth.user?.id) return true;
|
||||||
return item.value?.study_id ? can("faq.edit") : false;
|
return can("faq.edit");
|
||||||
});
|
});
|
||||||
|
|
||||||
const canConfirmResolved = computed(() => {
|
const canConfirmResolved = computed(() => {
|
||||||
@@ -169,7 +159,7 @@ const canConfirmResolved = computed(() => {
|
|||||||
if (item.value.status === "RESOLVED") return false;
|
if (item.value.status === "RESOLVED") return false;
|
||||||
if (auth.user?.role === "ADMIN") return true;
|
if (auth.user?.role === "ADMIN") return true;
|
||||||
if (item.value.created_by === auth.user?.id) return true;
|
if (item.value.created_by === auth.user?.id) return true;
|
||||||
return item.value?.study_id ? can("faq.edit") : false;
|
return can("faq.edit");
|
||||||
});
|
});
|
||||||
|
|
||||||
const statusLabel = computed(() => {
|
const statusLabel = computed(() => {
|
||||||
@@ -182,6 +172,7 @@ const bestReply = computed(() => {
|
|||||||
if (!item.value?.best_reply_id) return null;
|
if (!item.value?.best_reply_id) return null;
|
||||||
return replies.value.find((r) => r.id === item.value.best_reply_id) || null;
|
return replies.value.find((r) => r.id === item.value.best_reply_id) || null;
|
||||||
});
|
});
|
||||||
|
const bestReplyId = computed(() => item.value?.best_reply_id || null);
|
||||||
|
|
||||||
const memberMap = computed(() =>
|
const memberMap = computed(() =>
|
||||||
members.value.reduce<Record<string, string>>((acc, cur) => {
|
members.value.reduce<Record<string, string>>((acc, cur) => {
|
||||||
@@ -214,6 +205,7 @@ const categoryName = computed(() => {
|
|||||||
return cat ? cat.name : item.value?.category_id;
|
return cat ? cat.name : item.value?.category_id;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const loadMembers = async (studyId?: string | null) => {
|
const loadMembers = async (studyId?: string | null) => {
|
||||||
if (!studyId) {
|
if (!studyId) {
|
||||||
members.value = [];
|
members.value = [];
|
||||||
@@ -233,6 +225,7 @@ const loadReplies = async () => {
|
|||||||
try {
|
try {
|
||||||
const { data } = await fetchFaqReplies(id);
|
const { data } = await fetchFaqReplies(id);
|
||||||
replies.value = Array.isArray(data) ? data : data.items || [];
|
replies.value = Array.isArray(data) ? data : data.items || [];
|
||||||
|
await loadReplyAttachments();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e?.response?.data?.message || "回复加载失败");
|
ElMessage.error(e?.response?.data?.message || "回复加载失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -249,7 +242,6 @@ const loadData = async () => {
|
|||||||
const categoryParams: Record<string, any> = {};
|
const categoryParams: Record<string, any> = {};
|
||||||
if (faqData.study_id) {
|
if (faqData.study_id) {
|
||||||
categoryParams.study_id = faqData.study_id;
|
categoryParams.study_id = faqData.study_id;
|
||||||
categoryParams.include_global = false;
|
|
||||||
}
|
}
|
||||||
const { data: catData } = await fetchFaqCategories(categoryParams);
|
const { data: catData } = await fetchFaqCategories(categoryParams);
|
||||||
categories.value = catData.items || catData || [];
|
categories.value = catData.items || catData || [];
|
||||||
@@ -277,13 +269,25 @@ const submitReply = async () => {
|
|||||||
if (!item.value) return;
|
if (!item.value) return;
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
try {
|
try {
|
||||||
await createFaqReply(item.value.id, {
|
const { data } = await createFaqReply(item.value.id, {
|
||||||
content: replyContent.value,
|
content: replyContent.value,
|
||||||
quote_reply_id: quoteReply.value?.id || null,
|
quote_reply_id: quoteReply.value?.id || null,
|
||||||
});
|
});
|
||||||
|
const created = data as any;
|
||||||
|
if (replyFiles.value.length && created?.id) {
|
||||||
|
try {
|
||||||
|
for (const file of replyFiles.value) {
|
||||||
|
if (!file.raw) continue;
|
||||||
|
await uploadAttachment(item.value.study_id, "faq_replies", created.id, file.raw as File);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || "附件上传失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
ElMessage.success("回复已提交");
|
ElMessage.success("回复已提交");
|
||||||
replyContent.value = "";
|
replyContent.value = "";
|
||||||
quoteReply.value = null;
|
quoteReply.value = null;
|
||||||
|
replyFiles.value = [];
|
||||||
loadReplies();
|
loadReplies();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e?.response?.data?.message || "回复失败");
|
ElMessage.error(e?.response?.data?.message || "回复失败");
|
||||||
@@ -342,6 +346,29 @@ const onEditSuccess = () => {
|
|||||||
loadData();
|
loadData();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadReplyAttachments = async () => {
|
||||||
|
if (!item.value?.study_id || replies.value.length === 0) {
|
||||||
|
replyAttachmentsMap.value = {};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entries = await Promise.all(
|
||||||
|
replies.value.map(async (r) => {
|
||||||
|
try {
|
||||||
|
const { data } = await fetchAttachments(item.value.study_id, "faq_replies", r.id);
|
||||||
|
const items = (data as any).items || data || [];
|
||||||
|
return [r.id, items] as const;
|
||||||
|
} catch {
|
||||||
|
return [r.id, []] as const;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const next: Record<string, any[]> = {};
|
||||||
|
entries.forEach(([id, items]) => {
|
||||||
|
next[id] = items;
|
||||||
|
});
|
||||||
|
replyAttachmentsMap.value = next;
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadData();
|
loadData();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -301,7 +301,7 @@ const loadList = async () => {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const params: Record<string, any> = { skip: (page.value - 1) * pageSize, limit: pageSize };
|
const params: Record<string, any> = { skip: (page.value - 1) * pageSize, limit: pageSize };
|
||||||
if (filters.value.status) params.status = filters.value.status;
|
if (filters.value.status) params.status_filter = filters.value.status;
|
||||||
if (filters.value.category) params.category = filters.value.category;
|
if (filters.value.category) params.category = filters.value.category;
|
||||||
if (filters.value.date_from) params.date_from = filters.value.date_from;
|
if (filters.value.date_from) params.date_from = filters.value.date_from;
|
||||||
if (filters.value.date_to) params.date_to = filters.value.date_to;
|
if (filters.value.date_to) params.date_to = filters.value.date_to;
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ const load = async () => {
|
|||||||
try {
|
try {
|
||||||
const params: Record<string, any> = { skip: (page.value - 1) * pageSize, limit: pageSize };
|
const params: Record<string, any> = { skip: (page.value - 1) * pageSize, limit: pageSize };
|
||||||
if (filters.value.product_id) params.product_id = filters.value.product_id;
|
if (filters.value.product_id) params.product_id = filters.value.product_id;
|
||||||
if (filters.value.status) params.status = filters.value.status;
|
if (filters.value.status) params.status_filter = filters.value.status;
|
||||||
const { data } = await fetchImpBatches(study.currentStudy.id, params);
|
const { data } = await fetchImpBatches(study.currentStudy.id, params);
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
batches.value = data;
|
batches.value = data;
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ const loadIssues = async () => {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const params: Record<string, any> = { skip: (page.value - 1) * pageSize, limit: pageSize };
|
const params: Record<string, any> = { skip: (page.value - 1) * pageSize, limit: pageSize };
|
||||||
if (filters.value.status) params.status = filters.value.status;
|
if (filters.value.status) params.status_filter = filters.value.status;
|
||||||
if (filters.value.level) params.level = filters.value.level;
|
if (filters.value.level) params.level = filters.value.level;
|
||||||
if (filters.value.category) params.category = filters.value.category;
|
if (filters.value.category) params.category = filters.value.category;
|
||||||
if (filters.value.overdue) params.overdue = true;
|
if (filters.value.overdue) params.overdue = true;
|
||||||
|
|||||||
@@ -4,6 +4,13 @@
|
|||||||
<div>
|
<div>
|
||||||
<h1 class="ctms-page-title">节点详情</h1>
|
<h1 class="ctms-page-title">节点详情</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ctms-page-actions" v-if="canEdit">
|
||||||
|
<el-button v-if="!editing" type="primary" size="small" @click="startEdit">编辑</el-button>
|
||||||
|
<template v-else>
|
||||||
|
<el-button size="small" @click="cancelEdit">取消</el-button>
|
||||||
|
<el-button type="primary" size="small" :loading="saving" @click="saveEdit">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-card class="ctms-section-card">
|
<el-card class="ctms-section-card">
|
||||||
@@ -13,16 +20,62 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-descriptions :column="2" border class="detail-descriptions">
|
<el-descriptions :column="2" border class="detail-descriptions">
|
||||||
<el-descriptions-item label="名称">{{ milestone.name || "—" }}</el-descriptions-item>
|
<el-descriptions-item label="名称">
|
||||||
<el-descriptions-item label="类型">{{ typeLabel(milestone.type) }}</el-descriptions-item>
|
<el-input v-if="editing" v-model="form.name" class="inline-input" />
|
||||||
<el-descriptions-item label="中心">{{ siteName(milestone) }}</el-descriptions-item>
|
<span v-else>{{ milestone.name || "—" }}</span>
|
||||||
<el-descriptions-item label="负责人">{{ ownerName(milestone.owner_id) }}</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="计划日期">{{ displayDate(milestone.planned_date) }}</el-descriptions-item>
|
<el-descriptions-item label="类型">
|
||||||
<el-descriptions-item label="实际日期">{{ displayDate(milestone.actual_date) }}</el-descriptions-item>
|
<el-select v-if="editing" v-model="form.type" placeholder="请选择" class="inline-input">
|
||||||
<el-descriptions-item label="当前状态">
|
<el-option v-for="item in types" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
<el-tag :type="statusColor(milestone.status)" effect="plain">{{ statusLabel(milestone.status) }}</el-tag>
|
</el-select>
|
||||||
|
<span v-else>{{ typeLabel(milestone.type) }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="中心">
|
||||||
|
<el-select v-if="editing" v-model="form.site_id" placeholder="请选择中心" filterable class="inline-input">
|
||||||
|
<el-option v-for="s in siteOptions" :key="s.value" :label="s.label" :value="s.value" />
|
||||||
|
</el-select>
|
||||||
|
<span v-else>{{ siteName(milestone) }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="负责人">
|
||||||
|
<el-select v-if="editing" v-model="form.owner_id" placeholder="请选择负责人" filterable class="inline-input">
|
||||||
|
<el-option v-for="m in memberOptions" :key="m.value" :label="m.label" :value="m.value" />
|
||||||
|
</el-select>
|
||||||
|
<span v-else>{{ ownerName(milestone.owner_id) }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="计划日期">
|
||||||
|
<el-date-picker
|
||||||
|
v-if="editing"
|
||||||
|
v-model="form.planned_date"
|
||||||
|
type="date"
|
||||||
|
placeholder="选择日期"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
:disabled-date="disablePastDates"
|
||||||
|
class="inline-input"
|
||||||
|
/>
|
||||||
|
<span v-else>{{ displayDate(milestone.planned_date) }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="实际日期">
|
||||||
|
<el-date-picker
|
||||||
|
v-if="editing"
|
||||||
|
v-model="form.actual_date"
|
||||||
|
type="date"
|
||||||
|
placeholder="选择日期"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
:disabled-date="disablePastDates"
|
||||||
|
class="inline-input"
|
||||||
|
/>
|
||||||
|
<span v-else>{{ displayDate(milestone.actual_date) }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="当前状态">
|
||||||
|
<el-select v-if="editing" v-model="form.status" placeholder="请选择" class="inline-input">
|
||||||
|
<el-option v-for="item in statuses" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</el-select>
|
||||||
|
<el-tag v-else :type="statusColor(milestone.status)" effect="plain">{{ statusLabel(milestone.status) }}</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="备注" :span="2">
|
||||||
|
<el-input v-if="editing" v-model="form.notes" type="textarea" class="inline-input" />
|
||||||
|
<span v-else>{{ milestone.notes || "—" }}</span>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="备注" :span="2">{{ milestone.notes || "—" }}</el-descriptions-item>
|
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
@@ -63,10 +116,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import { fetchMilestones } from "../api/milestones";
|
import { fetchMilestones, updateMilestone } from "../api/milestones";
|
||||||
|
import { fetchAttachments } from "../api/attachments";
|
||||||
import { fetchSites } from "../api/sites";
|
import { fetchSites } from "../api/sites";
|
||||||
import { listMembers } from "../api/members";
|
import { listMembers } from "../api/members";
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
@@ -78,17 +132,38 @@ import {
|
|||||||
getMilestoneStatusColor,
|
getMilestoneStatusColor,
|
||||||
getMilestoneStatusLabel,
|
getMilestoneStatusLabel,
|
||||||
getMilestoneTypeLabel,
|
getMilestoneTypeLabel,
|
||||||
|
milestoneStatusOptions,
|
||||||
|
milestoneTypeOptions,
|
||||||
} from "../dictionaries/milestone.dict";
|
} from "../dictionaries/milestone.dict";
|
||||||
import { displayDate, displayUser, getMemberDisplayName, getUserDisplayName } from "../utils/display";
|
import { displayDate, getMemberDisplayName, getUserDisplayName } from "../utils/display";
|
||||||
|
import { usePermission } from "../utils/permission";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
const { can } = usePermission();
|
||||||
|
|
||||||
const milestone = ref<any | null>(null);
|
const milestone = ref<any | null>(null);
|
||||||
const studyId = computed(() => study.currentStudy?.id || "");
|
const studyId = computed(() => study.currentStudy?.id || "");
|
||||||
const sites = ref<any[]>([]);
|
const sites = ref<any[]>([]);
|
||||||
const members = ref<any[]>([]);
|
const members = ref<any[]>([]);
|
||||||
|
const editing = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const form = reactive({
|
||||||
|
name: "",
|
||||||
|
type: "",
|
||||||
|
site_id: "",
|
||||||
|
owner_id: "",
|
||||||
|
planned_date: "",
|
||||||
|
actual_date: "",
|
||||||
|
status: "NOT_STARTED",
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
const types = milestoneTypeOptions;
|
||||||
|
const statuses = milestoneStatusOptions;
|
||||||
|
const todayStart = new Date();
|
||||||
|
todayStart.setHours(0, 0, 0, 0);
|
||||||
|
const disablePastDates = (date: Date) => date.getTime() < todayStart.getTime();
|
||||||
|
|
||||||
const loadMilestone = async () => {
|
const loadMilestone = async () => {
|
||||||
if (!study.currentStudy) return;
|
if (!study.currentStudy) return;
|
||||||
@@ -124,6 +199,7 @@ const loadMembers = async () => {
|
|||||||
const typeLabel = (v: string) => getMilestoneTypeLabel(v);
|
const typeLabel = (v: string) => getMilestoneTypeLabel(v);
|
||||||
const statusLabel = (v: string) => getMilestoneStatusLabel(v);
|
const statusLabel = (v: string) => getMilestoneStatusLabel(v);
|
||||||
const statusColor = (v: string) => getMilestoneStatusColor(v);
|
const statusColor = (v: string) => getMilestoneStatusColor(v);
|
||||||
|
const canEdit = computed(() => can("milestone.create"));
|
||||||
|
|
||||||
const siteName = (row: any) => {
|
const siteName = (row: any) => {
|
||||||
if (row?.site?.name) return row.site.name;
|
if (row?.site?.name) return row.site.name;
|
||||||
@@ -147,6 +223,129 @@ const ownerName = (ownerId: string) => {
|
|||||||
return memberMap[ownerId] || "—";
|
return memberMap[ownerId] || "—";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const memberOptions = computed(() => {
|
||||||
|
const memberList = members.value.filter((m: any) => m.is_active !== false);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return memberList
|
||||||
|
.map((m: any) => {
|
||||||
|
const user = m.user || {};
|
||||||
|
const label = user.full_name || user.display_name || user.username || user.email || m.username || m.email || "—";
|
||||||
|
return { value: m.user_id, label };
|
||||||
|
})
|
||||||
|
.filter((opt) => {
|
||||||
|
if (seen.has(opt.value)) return false;
|
||||||
|
seen.add(opt.value);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const siteOptions = computed(() =>
|
||||||
|
sites.value.map((s: any) => ({
|
||||||
|
value: s.id,
|
||||||
|
label: s.name || "—",
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const syncForm = () => {
|
||||||
|
if (!milestone.value) return;
|
||||||
|
form.name = milestone.value.name || "";
|
||||||
|
form.type = milestone.value.type || "";
|
||||||
|
form.site_id = milestone.value.site_id || "";
|
||||||
|
form.owner_id = milestone.value.owner_id || "";
|
||||||
|
form.planned_date = milestone.value.planned_date || "";
|
||||||
|
form.actual_date = milestone.value.actual_date || "";
|
||||||
|
form.status = milestone.value.status || "NOT_STARTED";
|
||||||
|
form.notes = milestone.value.notes || "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEdit = () => {
|
||||||
|
syncForm();
|
||||||
|
editing.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
editing.value = false;
|
||||||
|
syncForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
if (!form.name?.trim()) {
|
||||||
|
ElMessage.warning("请输入名称");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!form.type) {
|
||||||
|
ElMessage.warning("请选择类型");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!form.site_id) {
|
||||||
|
ElMessage.warning("请选择中心");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!form.owner_id) {
|
||||||
|
ElMessage.warning("请选择负责人");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!form.planned_date) {
|
||||||
|
ElMessage.warning("请选择计划日期");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = async () => {
|
||||||
|
if (!study.currentStudy || !milestone.value) return;
|
||||||
|
if (!validateForm()) return;
|
||||||
|
const parseDate = (value: string) => {
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return null;
|
||||||
|
parsed.setHours(0, 0, 0, 0);
|
||||||
|
return parsed;
|
||||||
|
};
|
||||||
|
const planned = form.planned_date ? parseDate(form.planned_date) : null;
|
||||||
|
const actual = form.actual_date ? parseDate(form.actual_date) : null;
|
||||||
|
if (planned && planned.getTime() < todayStart.getTime()) {
|
||||||
|
ElMessage.warning("计划日期不得早于今天");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (actual && actual.getTime() < todayStart.getTime()) {
|
||||||
|
ElMessage.warning("实际日期不得早于今天");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
...form,
|
||||||
|
actual_date: form.actual_date || null,
|
||||||
|
};
|
||||||
|
if (payload.actual_date) {
|
||||||
|
const { data } = await fetchAttachments(study.currentStudy.id, "ethics_node", milestone.value.id);
|
||||||
|
const items = (data as any).items || data || [];
|
||||||
|
if (!items.length) {
|
||||||
|
ElMessage.warning("请先上传伦理批件后再更新实际日期");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
payload.status = "DONE";
|
||||||
|
}
|
||||||
|
await updateMilestone(study.currentStudy.id, milestone.value.id, payload);
|
||||||
|
ElMessage.success("已保存");
|
||||||
|
await loadMilestone();
|
||||||
|
editing.value = false;
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || "保存失败");
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => milestone.value,
|
||||||
|
() => {
|
||||||
|
if (!editing.value) {
|
||||||
|
syncForm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!auth.user && auth.token) {
|
if (!auth.user && auth.token) {
|
||||||
await auth.fetchMe().catch(() => {});
|
await auth.fetchMe().catch(() => {});
|
||||||
@@ -164,6 +363,10 @@ onMounted(async () => {
|
|||||||
margin-top: -8px;
|
margin-top: -8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.tip {
|
.tip {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
color: var(--ctms-text-regular);
|
color: var(--ctms-text-regular);
|
||||||
|
|||||||
@@ -50,9 +50,15 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="120">
|
<el-table-column label="操作" width="120">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<PermissionAction action="milestone.create">
|
<el-button
|
||||||
<el-button type="primary" link size="small" @click.stop="openEdit(scope.row)">编辑</el-button>
|
v-if="canDelete"
|
||||||
</PermissionAction>
|
type="danger"
|
||||||
|
link
|
||||||
|
size="small"
|
||||||
|
@click.stop="onDelete(scope.row)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -71,11 +77,12 @@
|
|||||||
<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 { useRouter } from "vue-router";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { fetchMilestones } from "../api/milestones";
|
import { fetchMilestones, deleteMilestone } from "../api/milestones";
|
||||||
import { fetchSites } from "../api/sites";
|
import { fetchSites } from "../api/sites";
|
||||||
import { listMembers } from "../api/members";
|
import { listMembers } from "../api/members";
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
|
import { useAuthStore } from "../store/auth";
|
||||||
import PermissionAction from "../components/PermissionAction.vue";
|
import PermissionAction from "../components/PermissionAction.vue";
|
||||||
import MilestoneForm from "../components/MilestoneForm.vue";
|
import MilestoneForm from "../components/MilestoneForm.vue";
|
||||||
import {
|
import {
|
||||||
@@ -86,6 +93,7 @@ import {
|
|||||||
import { displayDate, displayUser, getMemberDisplayName } from "../utils/display";
|
import { displayDate, displayUser, getMemberDisplayName } from "../utils/display";
|
||||||
|
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
|
const auth = useAuthStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const milestones = ref<any[]>([]);
|
const milestones = ref<any[]>([]);
|
||||||
@@ -94,6 +102,7 @@ const members = ref<any[]>([]);
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const showForm = ref(false);
|
const showForm = ref(false);
|
||||||
const editingMilestone = ref<any | null>(null);
|
const editingMilestone = ref<any | null>(null);
|
||||||
|
const canDelete = computed(() => auth.user?.role === "ADMIN" || !!study.currentStudyRole);
|
||||||
|
|
||||||
const loadMilestones = async () => {
|
const loadMilestones = async () => {
|
||||||
if (!study.currentStudy) return;
|
if (!study.currentStudy) return;
|
||||||
@@ -133,9 +142,21 @@ const openCreate = () => {
|
|||||||
showForm.value = true;
|
showForm.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (row: any) => {
|
const onDelete = async (row: any) => {
|
||||||
editingMilestone.value = row;
|
if (!study.currentStudy || !row?.id) return;
|
||||||
showForm.value = true;
|
const ok = await ElMessageBox.confirm("确认删除该节点?删除后不可恢复。", "提示", {
|
||||||
|
type: "warning",
|
||||||
|
confirmButtonText: "确认删除",
|
||||||
|
cancelButtonText: "取消",
|
||||||
|
}).catch(() => null);
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await deleteMilestone(study.currentStudy.id, row.id);
|
||||||
|
ElMessage.success("已删除");
|
||||||
|
loadMilestones();
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || "删除失败");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ const loadSubjects = async () => {
|
|||||||
limit: pageSize,
|
limit: pageSize,
|
||||||
};
|
};
|
||||||
if (filters.value.site_id) params.site_id = filters.value.site_id;
|
if (filters.value.site_id) params.site_id = filters.value.site_id;
|
||||||
if (filters.value.status) params.status = filters.value.status;
|
if (filters.value.status) params.status_filter = filters.value.status;
|
||||||
const { data } = await fetchSubjects(study.currentStudy.id, params);
|
const { data } = await fetchSubjects(study.currentStudy.id, params);
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
subjects.value = data;
|
subjects.value = data;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<el-input v-model="form.password" type="password" show-password :placeholder="user ? '留空则不修改密码' : '请输入初始密码'" />
|
<el-input v-model="form.password" type="password" show-password :placeholder="user ? '留空则不修改密码' : '请输入初始密码'" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="user" label="账号状态" prop="is_active">
|
<el-form-item v-if="user" label="账号状态" prop="is_active">
|
||||||
<el-switch v-model="form.is_active" active-text="启用" inactive-text="禁用" />
|
<el-switch v-model="form.is_active" active-text="启用" inactive-text="禁用" :disabled="isLastAdmin" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -33,6 +33,7 @@ import type { UserInfo } from "../../types/api";
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
user?: UserInfo | null;
|
user?: UserInfo | null;
|
||||||
|
adminCount?: number;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -86,6 +87,13 @@ const resetForm = () => {
|
|||||||
form.is_active = true;
|
form.is_active = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isLastAdmin = computed(() => {
|
||||||
|
if (!props.user) return false;
|
||||||
|
if (props.user.role !== "ADMIN") return false;
|
||||||
|
if ((props.adminCount || 0) > 1) return false;
|
||||||
|
return form.is_active;
|
||||||
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.visible,
|
() => props.visible,
|
||||||
(val) => {
|
(val) => {
|
||||||
@@ -104,6 +112,10 @@ watch(
|
|||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
if (!formRef.value) return;
|
if (!formRef.value) return;
|
||||||
await formRef.value.validate();
|
await formRef.value.validate();
|
||||||
|
if (props.user?.role === "ADMIN" && (props.adminCount || 0) <= 1 && !form.is_active) {
|
||||||
|
ElMessage.warning("至少保留一个管理员账号,不能禁用该账号");
|
||||||
|
return;
|
||||||
|
}
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
try {
|
try {
|
||||||
if (props.user) {
|
if (props.user) {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
link
|
link
|
||||||
:type="scope.row.status === 'ACTIVE' ? 'danger' : 'primary'"
|
:type="scope.row.status === 'ACTIVE' ? 'danger' : 'primary'"
|
||||||
size="small"
|
size="small"
|
||||||
|
:disabled="isLastAdmin(scope.row)"
|
||||||
@click="toggleStatus(scope.row)"
|
@click="toggleStatus(scope.row)"
|
||||||
>
|
>
|
||||||
{{ scope.row.status === 'ACTIVE' ? "禁用" : "启用" }}
|
{{ scope.row.status === 'ACTIVE' ? "禁用" : "启用" }}
|
||||||
@@ -57,7 +58,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
<UserForm v-model:visible="formVisible" :user="editingUser" @saved="loadUsers" />
|
<UserForm v-model:visible="formVisible" :user="editingUser" :admin-count="activeAdminCount" @saved="loadUsers" />
|
||||||
<UserResetPassword v-model:visible="resetVisible" :user="resetUser" @reset="loadUsers" />
|
<UserResetPassword v-model:visible="resetVisible" :user="resetUser" @reset="loadUsers" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -77,6 +78,7 @@ const total = ref(0);
|
|||||||
const page = ref(1);
|
const page = ref(1);
|
||||||
const pageSize = ref(10);
|
const pageSize = ref(10);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const activeAdminCount = ref(0);
|
||||||
const formVisible = ref(false);
|
const formVisible = ref(false);
|
||||||
const resetVisible = ref(false);
|
const resetVisible = ref(false);
|
||||||
const editingUser = ref<UserInfo | null>(null);
|
const editingUser = ref<UserInfo | null>(null);
|
||||||
@@ -86,10 +88,15 @@ const auth = useAuthStore();
|
|||||||
const loadUsers = async () => {
|
const loadUsers = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const { data } = await fetchUsers({ skip: (page.value - 1) * pageSize.value, limit: pageSize.value });
|
const [{ data }, { data: adminData }] = await Promise.all([
|
||||||
|
fetchUsers({ skip: (page.value - 1) * pageSize.value, limit: pageSize.value }),
|
||||||
|
fetchUsers({ skip: 0, limit: 10000 }),
|
||||||
|
]);
|
||||||
const items = (data as any).items || [];
|
const items = (data as any).items || [];
|
||||||
|
const allItems = (adminData as any).items || [];
|
||||||
users.value = items;
|
users.value = items;
|
||||||
total.value = (data as any).total || items.length;
|
total.value = (data as any).total || items.length;
|
||||||
|
activeAdminCount.value = allItems.filter((u: UserInfo) => u.role === "ADMIN" && u.status === "ACTIVE").length;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e?.response?.data?.message || "用户列表加载失败");
|
ElMessage.error(e?.response?.data?.message || "用户列表加载失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -108,6 +115,10 @@ const openEdit = (row: UserInfo) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleStatus = async (row: UserInfo) => {
|
const toggleStatus = async (row: UserInfo) => {
|
||||||
|
if (isLastAdmin(row)) {
|
||||||
|
ElMessage.warning("至少保留一个管理员账号,不能禁用该账号");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const disable = row.status === "ACTIVE";
|
const disable = row.status === "ACTIVE";
|
||||||
const ok = await ElMessageBox.confirm(
|
const ok = await ElMessageBox.confirm(
|
||||||
disable ? "该操作将影响用户账号,请确认是否继续" : "确认启用该用户账号?",
|
disable ? "该操作将影响用户账号,请确认是否继续" : "确认启用该用户账号?",
|
||||||
@@ -192,6 +203,9 @@ const statusLabel = (status: string) => {
|
|||||||
return status || "未知";
|
return status || "未知";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isLastAdmin = (row: UserInfo) =>
|
||||||
|
row.role === "ADMIN" && row.status === "ACTIVE" && activeAdminCount.value <= 1;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
Reference in New Issue
Block a user