diff --git a/backend/app/api/v1/aes.py b/backend/app/api/v1/aes.py index 6f81d04b..bcb6ab2e 100644 --- a/backend/app/api/v1/aes.py +++ b/backend/app/api/v1/aes.py @@ -1,3 +1,4 @@ +import json import uuid from datetime import date @@ -46,6 +47,8 @@ async def create_ae( current_user=Depends(get_current_user), ) -> AERead: 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) if current_user.role != "ADMIN" and member_role not in ALLOWED_CREATE_ROLES: 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), ) -> AERead: 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) if not ae or ae.study_id != study_id: 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") 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" 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" await audit_crud.log_action( db, @@ -155,7 +172,7 @@ async def update_ae( entity_type="ae", entity_id=ae_id, 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_role=current_user.role, ) diff --git a/backend/app/api/v1/audit_logs.py b/backend/app/api/v1/audit_logs.py index cd61e238..eec9b725 100644 --- a/backend/app/api/v1/audit_logs.py +++ b/backend/app/api/v1/audit_logs.py @@ -1,9 +1,9 @@ import uuid -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, status 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.schemas.audit import AuditLogRead @@ -20,6 +20,7 @@ async def list_audit_logs( entity_type: str | None = None, entity_id: uuid.UUID | None = None, action: str | None = None, + operator_id: uuid.UUID | None = None, skip: int = 0, limit: int = 100, db: AsyncSession = Depends(get_db_session), @@ -30,7 +31,24 @@ async def list_audit_logs( entity_type=entity_type, entity_id=entity_id, action=action, + operator_id=operator_id, skip=skip, limit=limit, ) 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) diff --git a/backend/app/api/v1/comments.py b/backend/app/api/v1/comments.py index 53ac90a9..ea173965 100644 --- a/backend/app/api/v1/comments.py +++ b/backend/app/api/v1/comments.py @@ -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 audit as audit_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() @@ -34,6 +34,18 @@ async def create_comment( current_user=Depends(get_current_user), ) -> CommentRead: 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( db, study_id=study_id, @@ -52,7 +64,15 @@ async def create_comment( operator_id=current_user.id, 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( @@ -68,4 +88,57 @@ async def list_comments( ) -> list[CommentRead]: await _ensure_study_exists(db, study_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, + ) diff --git a/backend/app/api/v1/faq_categories.py b/backend/app/api/v1/faq_categories.py index c10c65d8..38d86fec 100644 --- a/backend/app/api/v1/faq_categories.py +++ b/backend/app/api/v1/faq_categories.py @@ -15,13 +15,9 @@ from app.utils.pagination import paginate router = APIRouter() -def _check_permission_for_scope(study_id: uuid.UUID | None, current_user, member_role: str | None): - if study_id is None: - if current_user.role != "ADMIN": - 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_permission_for_scope(study_id: uuid.UUID, current_user, member_role: str | None): + if current_user.role != "ADMIN" and member_role != "PM": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") @router.post( @@ -36,15 +32,16 @@ async def create_category( db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> 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) if existing: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类名称已存在") member_role = None - if payload.study_id: - member = await member_crud.get_member(db, payload.study_id, current_user.id) - if not member: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a study member") - member_role = member.role_in_study + member = await member_crud.get_member(db, payload.study_id, current_user.id) + if not member and current_user.role != "ADMIN": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a study member") + member_role = member.role_in_study if member else None _check_permission_for_scope(payload.study_id, current_user, member_role) category = await category_crud.create_category(db, payload) await audit_crud.log_action( @@ -68,16 +65,17 @@ async def create_category( ) async def list_categories( study_id: uuid.UUID | None = None, - include_global: bool = True, is_active: bool | None = True, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> list[CategoryRead]: + if not study_id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="study_id is required") if study_id: member = await member_crud.get_member(db, study_id, current_user.id) if not member and current_user.role != "ADMIN": 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)) @@ -105,11 +103,12 @@ async def update_category( existing = await category_crud.get_category_by_name(db, target_study_id, target_name) if existing and existing.id != category.id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类名称已存在") - if target_study_id: - member = await member_crud.get_member(db, target_study_id, current_user.id) - member_role = member.role_in_study if member else None - if not member and current_user.role != "ADMIN": - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a study member") + if not target_study_id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="study_id is required") + member = await member_crud.get_member(db, target_study_id, current_user.id) + member_role = member.role_in_study if member else None + 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) updated = await category_crud.update_category(db, category, payload) await audit_crud.log_action( diff --git a/backend/app/api/v1/faqs.py b/backend/app/api/v1/faqs.py index 15d2eb3f..72ba660e 100644 --- a/backend/app/api/v1/faqs.py +++ b/backend/app/api/v1/faqs.py @@ -25,22 +25,14 @@ from app.utils.pagination import paginate router = APIRouter() -def _check_write_permission(study_id: uuid.UUID | None, current_user, member_role: str | None): - if study_id is None: - if current_user.role != "ADMIN": - 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_write_permission(study_id: uuid.UUID, current_user, member_role: str | None): + 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): - if study_id is None: - if current_user.role != "ADMIN": - 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") +def _check_create_permission(current_user, is_member: bool): + if current_user.role != "ADMIN" and not is_member: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a study member") @router.post( @@ -55,6 +47,8 @@ async def create_faq( db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> 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) if not cat: 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") member_role = None is_member = False - if payload.study_id: - member = await member_crud.get_member(db, payload.study_id, current_user.id) - member_role = member.role_in_study if member else None - is_member = member is not None - _check_create_permission(payload.study_id, current_user, is_member) + member = await member_crud.get_member(db, payload.study_id, current_user.id) + member_role = member.role_in_study if member else None + is_member = member is not None + _check_create_permission(current_user, is_member) try: item = await faq_crud.create_item(db, payload, created_by=current_user.id) except ValueError as exc: @@ -104,7 +97,6 @@ async def list_faqs( category_id: uuid.UUID | None = None, keyword: str | None = None, is_active: bool | None = None, - study_scope: str | None = "project", db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> list[FaqRead]: @@ -118,16 +110,15 @@ async def list_faqs( membership_cache[sid] = role return role - if study_id: - if current_user.role != "ADMIN": - role = await _get_member_role(study_id) - if not role: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a study member") + if not study_id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="study_id is required") + if current_user.role != "ADMIN": + role = await _get_member_role(study_id) + 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": - role = None - if study_id: - role = await _get_member_role(study_id) + role = await _get_member_role(study_id) if role != "PM": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") @@ -137,17 +128,17 @@ async def list_faqs( category_id=category_id, keyword=keyword, is_active=is_active, - study_scope=study_scope, + study_scope="project", ) visible: list[FaqRead] = [] 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) if not role: continue 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": continue visible.append(FaqRead.model_validate(it)) @@ -168,15 +159,15 @@ async def get_faq( item = await faq_crud.get_item(db, item_id) if not item: 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) if not 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"}: - member_role = None - if item.study_id: - member = await member_crud.get_member(db, item.study_id, current_user.id) - member_role = member.role_in_study if member else None + member = 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": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Inactive FAQ") return FaqRead.model_validate(item) @@ -267,11 +258,12 @@ async def set_best_reply( item = await faq_crud.get_item(db, item_id) if not item: 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 - if item.study_id: - member = await member_crud.get_member(db, item.study_id, current_user.id) - is_member = member is not None - _check_create_permission(item.study_id, current_user, is_member) + member = await member_crud.get_member(db, item.study_id, current_user.id) + is_member = member is not None + _check_create_permission(current_user, is_member) if payload.best_reply_id: reply = await reply_crud.get_reply(db, payload.best_reply_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) if not item: 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(): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reply content is required") is_member = False - if item.study_id: - member = await member_crud.get_member(db, item.study_id, current_user.id) - is_member = member is not None - _check_create_permission(item.study_id, current_user, is_member) + member = await member_crud.get_member(db, item.study_id, current_user.id) + is_member = member is not None + _check_create_permission(current_user, is_member) quote = None if payload.quote_reply_id: quote = await reply_crud.get_reply(db, payload.quote_reply_id) diff --git a/backend/app/api/v1/milestones.py b/backend/app/api/v1/milestones.py index a69860b8..b280a23a 100644 --- a/backend/app/api/v1/milestones.py +++ b/backend/app/api/v1/milestones.py @@ -1,10 +1,12 @@ import uuid +from datetime import date from fastapi import APIRouter, Depends, HTTPException, status 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.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 study as study_crud from app.crud import user as user_crud @@ -36,6 +38,8 @@ async def create_milestone( current_user=Depends(get_current_user), ) -> MilestoneRead: 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) owner = None site = None @@ -127,6 +131,15 @@ async def update_milestone( 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") + 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 updated = await milestone_crud.update(db, milestone, milestone_in) owner = None @@ -164,3 +177,32 @@ async def update_milestone( created_at=updated.created_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 diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py index e110d1ff..4bff6e46 100644 --- a/backend/app/api/v1/users.py +++ b/backend/app/api/v1/users.py @@ -52,6 +52,17 @@ async def update_user( db_user = await user_crud.get_by_id(db, user_id) if not db_user: 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) return user @@ -71,6 +82,10 @@ async def delete_user( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") if db_user.id == current_user.id: 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) if has_membership: raise HTTPException( diff --git a/backend/app/crud/ae.py b/backend/app/crud/ae.py index ca4a1433..b613f907 100644 --- a/backend/app/crud/ae.py +++ b/backend/app/crud/ae.py @@ -52,7 +52,7 @@ async def create_ae( severity=ae_in.severity, causality=ae_in.causality, action_taken=None, - outcome=None, + outcome=ae_in.outcome, reported_to_sponsor=False, report_due_date=due_date, status="NEW", @@ -96,8 +96,10 @@ async def list_ae( 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) + 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: onset = update_data.get("onset_date", ae.onset_date) seriousness = update_data.get("seriousness", ae.seriousness) diff --git a/backend/app/crud/audit.py b/backend/app/crud/audit.py index b85022ab..47772be1 100644 --- a/backend/app/crud/audit.py +++ b/backend/app/crud/audit.py @@ -40,6 +40,7 @@ async def list_logs( entity_type: str | None = None, entity_id: uuid.UUID | None = None, action: str | None = None, + operator_id: uuid.UUID | None = None, skip: int = 0, limit: int = 100, ) -> Sequence[AuditLog]: @@ -50,6 +51,18 @@ async def list_logs( stmt = stmt.where(AuditLog.entity_id == entity_id) if 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) result = await db.execute(stmt) 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() diff --git a/backend/app/crud/comment.py b/backend/app/crud/comment.py index 7e5d0f01..93d31e25 100644 --- a/backend/app/crud/comment.py +++ b/backend/app/crud/comment.py @@ -21,6 +21,7 @@ async def create_comment( entity_type=entity_type, entity_id=entity_id, content=comment_in.content, + quote_comment_id=comment_in.quote_comment_id, created_by=created_by, ) db.add(comment) @@ -46,3 +47,35 @@ async def list_comments( .order_by(Comment.created_at.asc()) ) 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 diff --git a/backend/app/crud/milestone.py b/backend/app/crud/milestone.py index 0ee1b593..9b065a1a 100644 --- a/backend/app/crud/milestone.py +++ b/backend/app/crud/milestone.py @@ -1,7 +1,7 @@ import uuid 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 app.models.milestone import Milestone @@ -47,3 +47,8 @@ async def update(db: AsyncSession, milestone: Milestone, milestone_in: Milestone await db.commit() await db.refresh(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() diff --git a/backend/app/crud/user.py b/backend/app/crud/user.py index ffad8764..b9fb6256 100644 --- a/backend/app/crud/user.py +++ b/backend/app/crud/user.py @@ -71,6 +71,15 @@ async def list_users(db: AsyncSession, skip: int = 0, limit: int = 100) -> Seque 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( db: AsyncSession, status: UserStatus | None = None, skip: int = 0, limit: int = 100 ) -> Sequence[User]: diff --git a/backend/app/models/comment.py b/backend/app/models/comment.py index 8d650b59..e28c9019 100644 --- a/backend/app/models/comment.py +++ b/backend/app/models/comment.py @@ -16,6 +16,7 @@ class Comment(Base): entity_type: Mapped[str] = mapped_column(String(50), nullable=False) entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), 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_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") diff --git a/backend/app/schemas/ae.py b/backend/app/schemas/ae.py index 3e963fd7..0124a4c9 100644 --- a/backend/app/schemas/ae.py +++ b/backend/app/schemas/ae.py @@ -15,9 +15,13 @@ class AECreate(BaseModel): severity: str causality: Optional[str] = None description: Optional[str] = None + outcome: Optional[str] = None class AEUpdate(BaseModel): + subject_id: Optional[uuid.UUID] = None + term: Optional[str] = None + onset_date: Optional[date] = None resolution_date: Optional[date] = None seriousness: Optional[str] = None severity: Optional[str] = None diff --git a/backend/app/schemas/comment.py b/backend/app/schemas/comment.py index d665cd35..b44ee2f4 100644 --- a/backend/app/schemas/comment.py +++ b/backend/app/schemas/comment.py @@ -6,6 +6,17 @@ from pydantic import BaseModel, ConfigDict, Field class CommentCreate(BaseModel): 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): @@ -13,5 +24,8 @@ class CommentRead(BaseModel): content: str created_by: uuid.UUID created_at: datetime + quote_comment_id: uuid.UUID | None = None + quote: CommentQuote | None = None + is_deleted: bool = False model_config = ConfigDict(from_attributes=True) diff --git a/database/init.sql b/database/init.sql index e461cb75..d81e0c2e 100644 --- a/database/init.sql +++ b/database/init.sql @@ -100,6 +100,7 @@ CREATE TABLE public.comments ( entity_type character varying(50) NOT NULL, entity_id uuid NOT NULL, content text NOT NULL, + quote_comment_id uuid, created_by uuid NOT NULL, created_at timestamp with time zone DEFAULT now() NOT NULL, is_deleted boolean DEFAULT false NOT NULL diff --git a/frontend/src/api/auditLogs.ts b/frontend/src/api/auditLogs.ts index 1b145eeb..70181edf 100644 --- a/frontend/src/api/auditLogs.ts +++ b/frontend/src/api/auditLogs.ts @@ -1,5 +1,8 @@ -import { apiGet } from "./axios"; +import { apiDelete, apiGet } from "./axios"; import type { ApiListResponse } from "../types/api"; export const fetchAuditLogs = (studyId: string, params?: Record) => apiGet>(`/api/v1/studies/${studyId}/audit-logs/`, { params }); + +export const deleteAuditLog = (studyId: string, logId: string) => + apiDelete(`/api/v1/studies/${studyId}/audit-logs/${logId}`); diff --git a/frontend/src/api/comments.ts b/frontend/src/api/comments.ts index 05952929..88ed790f 100644 --- a/frontend/src/api/comments.ts +++ b/frontend/src/api/comments.ts @@ -1,4 +1,4 @@ -import { apiGet, apiPost } from "./axios"; +import { apiGet, apiPost, apiDelete } from "./axios"; export const fetchComments = (studyId: string, entityType: string, entityId: string) => apiGet(`/api/v1/studies/${studyId}/${entityType}/${entityId}/comments`); @@ -9,3 +9,6 @@ export const createComment = ( entityId: string, payload: Record ) => 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}`); diff --git a/frontend/src/api/milestones.ts b/frontend/src/api/milestones.ts index 4a03f8ed..867e4ac1 100644 --- a/frontend/src/api/milestones.ts +++ b/frontend/src/api/milestones.ts @@ -1,4 +1,4 @@ -import { apiGet, apiPost, apiPatch } from "./axios"; +import { apiGet, apiPost, apiPatch, apiDelete } from "./axios"; import type { ApiListResponse } from "../types/api"; export const fetchMilestones = (studyId: string) => @@ -9,3 +9,6 @@ export const createMilestone = (studyId: string, payload: Record) = export const updateMilestone = (studyId: string, milestoneId: string, payload: Record) => apiPatch(`/api/v1/studies/${studyId}/milestones/${milestoneId}`, payload); + +export const deleteMilestone = (studyId: string, milestoneId: string) => + apiDelete(`/api/v1/studies/${studyId}/milestones/${milestoneId}`); diff --git a/frontend/src/components/AeForm.vue b/frontend/src/components/AeForm.vue index 310dd98e..f304182a 100644 --- a/frontend/src/components/AeForm.vue +++ b/frontend/src/components/AeForm.vue @@ -8,12 +8,17 @@ - + - + - - + + @@ -24,6 +29,11 @@ + + + + + -
- -
- 取消 - 提交 -
-
- - -
- {{ displayUser(c.created_by, { users: userMap, members: memberMap }) }} -

{{ c.content }}

-
-
-
+ + + + diff --git a/frontend/src/components/ThreadList.vue b/frontend/src/components/ThreadList.vue new file mode 100644 index 00000000..cb5c7668 --- /dev/null +++ b/frontend/src/components/ThreadList.vue @@ -0,0 +1,197 @@ + + + + + diff --git a/frontend/src/dictionaries/ae.dict.ts b/frontend/src/dictionaries/ae.dict.ts index f12b16f6..59bd6667 100644 --- a/frontend/src/dictionaries/ae.dict.ts +++ b/frontend/src/dictionaries/ae.dict.ts @@ -2,14 +2,19 @@ import type { Dict } from "./types"; import { createDict } from "./utils"; const seriousnessItems = [ - { value: "NON_SERIOUS", label: "非严重", order: 1 }, - { value: "SERIOUS", label: "严重", order: 2, color: "danger" }, + { value: "NON_SERIOUS", label: "否", order: 1 }, + { value: "SERIOUS", label: "是", order: 2, color: "danger" }, ]; const severityItems = [ - { value: "MILD", label: "轻度", order: 1 }, - { value: "MODERATE", label: "中度", order: 2 }, - { value: "SEVERE", label: "重度", order: 3, color: "danger" }, + { value: "G1", label: "I级", order: 1 }, + { value: "G2", label: "II级", order: 2 }, + { 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); diff --git a/frontend/src/dictionaries/status.dict.ts b/frontend/src/dictionaries/status.dict.ts index fb9cc0a4..ab6e269d 100644 --- a/frontend/src/dictionaries/status.dict.ts +++ b/frontend/src/dictionaries/status.dict.ts @@ -13,6 +13,7 @@ const items = [ { value: "PAID", label: "已支付", color: "success", order: 14 }, { value: "CLOSED", label: "已关闭", color: "success", order: 20 }, { value: "OPEN", label: "开放", color: "info", order: 19 }, + { value: "NEW", label: "新建", color: "info", order: 17 }, { value: "FOLLOW_UP", label: "随访中", color: "warning", order: 18 }, { value: "OVERDUE", label: "已逾期", color: "danger", order: 30 }, { value: "SCREENING", label: "筛选中", color: "info", order: 40 }, diff --git a/frontend/src/views/AeDetail.vue b/frontend/src/views/AeDetail.vue index 8aacdbf6..dc8b608a 100644 --- a/frontend/src/views/AeDetail.vue +++ b/frontend/src/views/AeDetail.vue @@ -5,17 +5,10 @@

不良事件

- - {{ subjectName(ae.subject_id) }} - {{ ae.seriousness }} - {{ ae.severity }} - {{ ae.onset_date }} - - {{ stateLabel(aeState) }} + + + + + {{ subjectName(ae.subject_id) }} + + + + {{ ae.term || "—" }} + + + + + + + {{ seriousnessLabel(ae.seriousness) }} + + + + + + {{ severityLabel(ae.severity) }} + + + + {{ ae.onset_date || "—" }} + + + + + + {{ stateLabel(aeState) }} + + + + {{ ae.description || "—" }} + + + + + + {{ ae.outcome || "—" }} - {{ ae.description }} @@ -56,6 +91,57 @@ :show-uploader="true" /> + + + +
+
+ {{ displayUser(log.operator_id, { members: memberMap }) }} + {{ log.action }} +
+ + 删除 + +
+
+
+ 事件 + {{ formatAuditField(log._detail, "event") }} +
+
+ 发生日期 + + {{ formatAuditField(log._detail, "onset_date") }} + +
+
+ 严重程度 + + {{ formatAuditField(log._detail, "severity", severityLabel) }} + +
+
+ 状态 + + {{ formatAuditField(log._detail, "status", stateLabel) }} + +
+
+ 描述 + {{ formatAuditField(log._detail, "description") }} +
+
+
{{ log.detail || "—" }}
+
+
+
暂无变更记录
+
@@ -65,23 +151,21 @@ @@ -220,4 +445,53 @@ onMounted(async () => { .detail-tabs { 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%; +} diff --git a/frontend/src/views/Aes.vue b/frontend/src/views/Aes.vue index 129ef62c..69d16d77 100644 --- a/frontend/src/views/Aes.vue +++ b/frontend/src/views/Aes.vue @@ -18,7 +18,7 @@
- 严重性 + SAE @@ -39,31 +39,27 @@ {{ subjectMap[scope.row.subject_id] || scope.row.subject_id || "-" }} - + + + + + + + - - - - import { computed, onMounted, ref } from "vue"; import { useRouter } from "vue-router"; -import { ElMessage, ElMessageBox } from "element-plus"; -import { fetchAes, updateAe } from "../api/aes"; +import { ElMessage } from "element-plus"; +import { fetchAes } from "../api/aes"; import { fetchSubjects } from "../api/subjects"; +import { fetchSites } from "../api/sites"; import { useStudyStore } from "../store/study"; import { useAuthStore } from "../store/auth"; import AeForm from "../components/AeForm.vue"; -import { aeMachine, getAvailableActions } from "../state-machine"; -import { evaluateAction } from "../guards/actionGuard"; -import { logAudit } from "../audit"; +import { aeSeriousnessDict, aeSeverityDict, getDictLabel } from "../dictionaries"; const study = useStudyStore(); const auth = useAuthStore(); @@ -102,14 +97,27 @@ const total = ref(0); const page = ref(1); const pageSize = 10; const subjects = ref([]); +const sites = ref([]); const subjectMap = computed(() => subjects.value.reduce>((acc, cur) => { acc[cur.id] = cur.subject_no || cur.id; return acc; }, {}) ); +const subjectSiteMap = computed(() => + subjects.value.reduce>((acc, cur) => { + if (cur?.id) acc[cur.id] = cur.site_id || ""; + return acc; + }, {}) +); +const siteMap = computed(() => + sites.value.reduce>((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 filters = ref({ @@ -130,7 +138,7 @@ const loadAes = async () => { loading.value = true; try { const params: Record = { 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.overdue) params.overdue = true; 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) => { router.push(`/study/aes/${row.id}`); }; +const siteName = (id?: string) => { + if (!id) return "-"; + return siteMap.value[id] || id; +}; + const statusLabel = (v: string) => ({ - NEW: "新增", FOLLOW_UP: "随访中", - CLOSED: "已关闭", + CLOSED: "结束", + NEW: "随访中", }[v] || v); -const seriousnessLabel = (v: string) => - ({ - SERIOUS: "严重", - NON_SERIOUS: "非严重", - }[v] || v); +const seriousnessLabel = (v: string) => getDictLabel(aeSeriousnessDict, v) || v; +const severityLabel = (v: string) => getDictLabel(aeSeverityDict, 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 () => { if (!auth.user && auth.token) { await auth.fetchMe().catch(() => {}); } - await loadSubjects(); + await Promise.all([loadSubjects(), loadSites()]); loadAes(); }); diff --git a/frontend/src/views/DataQueries.vue b/frontend/src/views/DataQueries.vue index ba4ad230..e079eef4 100644 --- a/frontend/src/views/DataQueries.vue +++ b/frontend/src/views/DataQueries.vue @@ -19,7 +19,9 @@
负责人 - + + +
仅逾期 @@ -86,12 +88,14 @@ import { useRouter } from "vue-router"; import { ElMessage, ElMessageBox } from "element-plus"; import { fetchDataQueries, updateDataQuery } from "../api/dataQueries"; import { fetchSubjects } from "../api/subjects"; +import { listMembers } from "../api/members"; import { useStudyStore } from "../store/study"; import { useAuthStore } from "../store/auth"; import DataQueryForm from "../components/DataQueryForm.vue"; import { evaluateAction } from "../guards/actionGuard"; import { logAudit } from "../audit"; import { getAvailableActions, type StateMachine } from "../state-machine"; +import { getMemberDisplayName } from "../utils/display"; const study = useStudyStore(); const auth = useAuthStore(); @@ -103,12 +107,21 @@ const total = ref(0); const page = ref(1); const pageSize = 10; const subjects = ref([]); +const members = ref([]); const subjectMap = computed(() => subjects.value.reduce>((acc, cur) => { acc[cur.id] = cur.subject_no || cur.id; 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 categories = ["MISSING", "INCONSISTENT", "OUT_OF_RANGE", "OTHER"]; @@ -150,7 +163,7 @@ const loadQueries = async () => { loading.value = true; try { const params: Record = { 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.overdue) params.overdue = true; 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) => { page.value = p; loadQueries(); @@ -272,10 +305,7 @@ onMounted(async () => { if (!auth.user && auth.token) { await auth.fetchMe().catch(() => {}); } - if (study.currentStudy) { - const { data } = await fetchSubjects(study.currentStudy.id, { skip: 0, limit: 500 }); - subjects.value = data.items || data || []; - } + await Promise.all([loadSubjects(), loadMembers()]); loadQueries(); }); diff --git a/frontend/src/views/Faq.vue b/frontend/src/views/Faq.vue index 2c5878a7..099a88f6 100644 --- a/frontend/src/views/Faq.vue +++ b/frontend/src/views/Faq.vue @@ -80,9 +80,9 @@ const canEdit = computed(() => can("faq.edit")); const loadCategories = async () => { try { + if (!study.currentStudy) return; const params: Record = {}; if (study.currentStudy) params.study_id = study.currentStudy.id; - params.include_global = false; const { data } = await fetchFaqCategories(params); categories.value = data.items || data || []; } catch (e: any) { diff --git a/frontend/src/views/FaqDetail.vue b/frontend/src/views/FaqDetail.vue index db242dcb..24d3e50d 100644 --- a/frontend/src/views/FaqDetail.vue +++ b/frontend/src/views/FaqDetail.vue @@ -44,53 +44,39 @@
-
-
- 引用 {{ displayUser(quoteReply.created_by, { members: memberMap, users: userMap }) }} - · {{ displayDateTime(quoteReply.created_at) }} - 取消引用 -
-
{{ quoteReply.content }}
-
- -
- 提交回复 -
+
当前角色无权限回复
- - -
-
- {{ displayUser(r.created_by, { members: memberMap, users: userMap }) }} -
- 最佳答案 - 引用 - - {{ item?.best_reply_id === r.id ? "取消最佳" : "设为最佳" }} - - - 删除 - -
-
-
-
- {{ displayUser(r.quote.created_by, { members: memberMap, users: userMap }) }} - · {{ displayDateTime(r.quote.created_at) }} -
-
{{ r.quote.content }}
-
-
{{ r.is_deleted ? "回复已删除" : r.content }}
-
-
-
+ + +
暂无回复
@@ -107,6 +93,7 @@ import { computed, onMounted, ref } from "vue"; import { useRoute } from "vue-router"; import { ElMessage, ElMessageBox } from "element-plus"; +import type { UploadUserFile } from "element-plus"; import { createFaqReply, deleteFaqReply, @@ -117,10 +104,13 @@ import { setFaqStatus, } from "../api/faqs"; import { listMembers } from "../api/members"; +import { fetchAttachments, uploadAttachment } from "../api/attachments"; import { displayDateTime, displayUser, getMemberDisplayName, getUserDisplayName } from "../utils/display"; import { useAuthStore } from "../store/auth"; import { usePermission } from "../utils/permission"; import FaqItemForm from "../components/FaqItemForm.vue"; +import ThreadComposer from "../components/ThreadComposer.vue"; +import ThreadList from "../components/ThreadList.vue"; const route = useRoute(); const auth = useAuthStore(); @@ -135,12 +125,13 @@ const quoteReply = ref(null); const members = ref([]); const showForm = ref(false); const editing = ref(null); +const replyFiles = ref([]); +const replyAttachmentsMap = ref>({}); const { can } = usePermission(); const canReply = computed(() => { if (!item.value) return false; - if (!item.value.study_id) return auth.user?.role === "ADMIN"; return can("faq.reply"); }); @@ -153,7 +144,6 @@ const canDeleteReply = (reply: any) => { const canSelectBest = computed(() => { if (!item.value) return false; - if (!item.value.study_id) return auth.user?.role === "ADMIN"; return can("faq.reply"); }); @@ -161,7 +151,7 @@ const canEditQuestion = computed(() => { if (!item.value) return false; if (auth.user?.role === "ADMIN") 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(() => { @@ -169,7 +159,7 @@ const canConfirmResolved = computed(() => { if (item.value.status === "RESOLVED") return false; if (auth.user?.role === "ADMIN") 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(() => { @@ -182,6 +172,7 @@ const bestReply = computed(() => { if (!item.value?.best_reply_id) return 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(() => members.value.reduce>((acc, cur) => { @@ -214,6 +205,7 @@ const categoryName = computed(() => { return cat ? cat.name : item.value?.category_id; }); + const loadMembers = async (studyId?: string | null) => { if (!studyId) { members.value = []; @@ -233,6 +225,7 @@ const loadReplies = async () => { try { const { data } = await fetchFaqReplies(id); replies.value = Array.isArray(data) ? data : data.items || []; + await loadReplyAttachments(); } catch (e: any) { ElMessage.error(e?.response?.data?.message || "回复加载失败"); } finally { @@ -249,7 +242,6 @@ const loadData = async () => { const categoryParams: Record = {}; if (faqData.study_id) { categoryParams.study_id = faqData.study_id; - categoryParams.include_global = false; } const { data: catData } = await fetchFaqCategories(categoryParams); categories.value = catData.items || catData || []; @@ -277,13 +269,25 @@ const submitReply = async () => { if (!item.value) return; submitting.value = true; try { - await createFaqReply(item.value.id, { + const { data } = await createFaqReply(item.value.id, { content: replyContent.value, 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("回复已提交"); replyContent.value = ""; quoteReply.value = null; + replyFiles.value = []; loadReplies(); } catch (e: any) { ElMessage.error(e?.response?.data?.message || "回复失败"); @@ -342,6 +346,29 @@ const onEditSuccess = () => { 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 = {}; + entries.forEach(([id, items]) => { + next[id] = items; + }); + replyAttachmentsMap.value = next; +}; + onMounted(() => { loadData(); }); diff --git a/frontend/src/views/Finance.vue b/frontend/src/views/Finance.vue index cfcdf8ee..622e4395 100644 --- a/frontend/src/views/Finance.vue +++ b/frontend/src/views/Finance.vue @@ -301,7 +301,7 @@ const loadList = async () => { loading.value = true; try { const params: Record = { 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.date_from) params.date_from = filters.value.date_from; if (filters.value.date_to) params.date_to = filters.value.date_to; diff --git a/frontend/src/views/ImpBatches.vue b/frontend/src/views/ImpBatches.vue index 5c2d5800..99d39b1a 100644 --- a/frontend/src/views/ImpBatches.vue +++ b/frontend/src/views/ImpBatches.vue @@ -96,7 +96,7 @@ const load = async () => { try { const params: Record = { skip: (page.value - 1) * pageSize, limit: pageSize }; 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); if (Array.isArray(data)) { batches.value = data; diff --git a/frontend/src/views/Issues.vue b/frontend/src/views/Issues.vue index f6c0fd22..d576f2af 100644 --- a/frontend/src/views/Issues.vue +++ b/frontend/src/views/Issues.vue @@ -113,7 +113,7 @@ const loadIssues = async () => { loading.value = true; try { const params: Record = { 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.category) params.category = filters.value.category; if (filters.value.overdue) params.overdue = true; diff --git a/frontend/src/views/MilestoneDetail.vue b/frontend/src/views/MilestoneDetail.vue index dbf25409..31a35047 100644 --- a/frontend/src/views/MilestoneDetail.vue +++ b/frontend/src/views/MilestoneDetail.vue @@ -4,6 +4,13 @@

节点详情

+
+ 编辑 + +
@@ -13,16 +20,62 @@ - {{ milestone.name || "—" }} - {{ typeLabel(milestone.type) }} - {{ siteName(milestone) }} - {{ ownerName(milestone.owner_id) }} - {{ displayDate(milestone.planned_date) }} - {{ displayDate(milestone.actual_date) }} - - {{ statusLabel(milestone.status) }} + + + {{ milestone.name || "—" }} + + + + + + {{ typeLabel(milestone.type) }} + + + + + + {{ siteName(milestone) }} + + + + + + {{ ownerName(milestone.owner_id) }} + + + + {{ displayDate(milestone.planned_date) }} + + + + {{ displayDate(milestone.actual_date) }} + + + + + + {{ statusLabel(milestone.status) }} + + + + {{ milestone.notes || "—" }} - {{ milestone.notes || "—" }} @@ -63,10 +116,11 @@