细节优化——1

This commit is contained in:
Cheng Zhou
2025-12-30 14:07:57 +08:00
parent 71db309e12
commit 0c1fc49f17
40 changed files with 1610 additions and 389 deletions
+21 -4
View File
@@ -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,
) )
+20 -2
View File
@@ -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)
+76 -3
View File
@@ -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,
)
+18 -19
View File
@@ -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
View File
@@ -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)
+42
View File
@@ -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
+15
View File
@@ -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(
+4 -2
View File
@@ -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)
+13
View File
@@ -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()
+33
View File
@@ -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
+6 -1
View File
@@ -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()
+9
View File
@@ -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]:
+1
View File
@@ -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")
+4
View File
@@ -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
+14
View File
@@ -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)
+1
View File
@@ -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
+4 -1
View File
@@ -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}`);
+4 -1
View File
@@ -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}`);
+4 -1
View File
@@ -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}`);
+32 -9
View File
@@ -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 {
+110 -31
View File
@@ -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>
+17 -3
View File
@@ -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;
+1 -1
View File
@@ -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 },
]; ];
+116
View File
@@ -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>
+197
View File
@@ -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>
+10 -5
View File
@@ -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);
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+36 -6
View File
@@ -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>
+1 -1
View File
@@ -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) {
+77 -50
View File
@@ -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();
}); });
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
+215 -12
View File
@@ -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);
+29 -8
View File
@@ -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(() => {
+1 -1
View File
@@ -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;
+13 -1
View File
@@ -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) {
+16 -2
View File
@@ -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>