diff --git a/backend/alembic/versions/20260227_04_add_monitoring_visit_issues.py b/backend/alembic/versions/20260227_04_add_monitoring_visit_issues.py new file mode 100644 index 00000000..d916b6c7 --- /dev/null +++ b/backend/alembic/versions/20260227_04_add_monitoring_visit_issues.py @@ -0,0 +1,67 @@ +"""add monitoring_visit_issues table + +Revision ID: 20260227_04 +Revises: 20260227_03 +Create Date: 2026-02-27 18:30:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = "20260227_04" +down_revision: Union[str, None] = "20260227_03" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if "monitoring_visit_issues" not in inspector.get_table_names(): + op.create_table( + "monitoring_visit_issues", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("study_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("issue_no", sa.String(length=64), nullable=False), + sa.Column("category", sa.String(length=255), nullable=False), + sa.Column("subject_code", sa.String(length=64), nullable=True), + sa.Column("monitor_item", sa.Text(), nullable=True), + sa.Column("monitor_type", sa.String(length=64), nullable=True), + sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'OPEN'")), + sa.Column("source", sa.String(length=64), nullable=True), + sa.Column("creator_name", sa.String(length=100), nullable=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("due_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("closed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("open_duration_text", sa.String(length=64), nullable=True), + sa.Column("created_by", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["study_id"], ["studies.id"]), + sa.ForeignKeyConstraint(["created_by"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("study_id", "issue_no", name="uq_monitoring_visit_issues_study_issue_no"), + ) + + indexes = {idx["name"] for idx in inspector.get_indexes("monitoring_visit_issues")} + study_index_name = op.f("ix_monitoring_visit_issues_study_id") + if study_index_name not in indexes: + op.create_index(study_index_name, "monitoring_visit_issues", ["study_id"], unique=False) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if "monitoring_visit_issues" in inspector.get_table_names(): + indexes = {idx["name"] for idx in inspector.get_indexes("monitoring_visit_issues")} + study_index_name = op.f("ix_monitoring_visit_issues_study_id") + if study_index_name in indexes: + op.drop_index(study_index_name, table_name="monitoring_visit_issues") + op.drop_table("monitoring_visit_issues") diff --git a/backend/alembic/versions/20260227_05_add_monitoring_visit_issue_form_fields.py b/backend/alembic/versions/20260227_05_add_monitoring_visit_issue_form_fields.py new file mode 100644 index 00000000..6dbf7d1d --- /dev/null +++ b/backend/alembic/versions/20260227_05_add_monitoring_visit_issue_form_fields.py @@ -0,0 +1,64 @@ +"""add detail fields to monitoring_visit_issues + +Revision ID: 20260227_05 +Revises: 20260227_04 +Create Date: 2026-02-27 20:10:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "20260227_05" +down_revision: Union[str, None] = "20260227_04" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {col["name"] for col in inspector.get_columns("monitoring_visit_issues")} + + if "recommendation" not in columns: + op.add_column("monitoring_visit_issues", sa.Column("recommendation", sa.Text(), nullable=True)) + if "subject_name" not in columns: + op.add_column("monitoring_visit_issues", sa.Column("subject_name", sa.String(length=100), nullable=True)) + if "action_taken" not in columns: + op.add_column("monitoring_visit_issues", sa.Column("action_taken", sa.Text(), nullable=True)) + if "follow_up_progress" not in columns: + op.add_column("monitoring_visit_issues", sa.Column("follow_up_progress", sa.Text(), nullable=True)) + if "found_date" not in columns: + op.add_column("monitoring_visit_issues", sa.Column("found_date", sa.Date(), nullable=True)) + if "target_resolve_date" not in columns: + op.add_column("monitoring_visit_issues", sa.Column("target_resolve_date", sa.Date(), nullable=True)) + if "actual_resolve_date" not in columns: + op.add_column("monitoring_visit_issues", sa.Column("actual_resolve_date", sa.Date(), nullable=True)) + if "responsible_name" not in columns: + op.add_column("monitoring_visit_issues", sa.Column("responsible_name", sa.String(length=100), nullable=True)) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {col["name"] for col in inspector.get_columns("monitoring_visit_issues")} + + if "responsible_name" in columns: + op.drop_column("monitoring_visit_issues", "responsible_name") + if "actual_resolve_date" in columns: + op.drop_column("monitoring_visit_issues", "actual_resolve_date") + if "target_resolve_date" in columns: + op.drop_column("monitoring_visit_issues", "target_resolve_date") + if "found_date" in columns: + op.drop_column("monitoring_visit_issues", "found_date") + if "follow_up_progress" in columns: + op.drop_column("monitoring_visit_issues", "follow_up_progress") + if "action_taken" in columns: + op.drop_column("monitoring_visit_issues", "action_taken") + if "subject_name" in columns: + op.drop_column("monitoring_visit_issues", "subject_name") + if "recommendation" in columns: + op.drop_column("monitoring_visit_issues", "recommendation") diff --git a/backend/alembic/versions/20260227_06_drop_target_resolve_date_from_monitoring_visit_issues.py b/backend/alembic/versions/20260227_06_drop_target_resolve_date_from_monitoring_visit_issues.py new file mode 100644 index 00000000..a80914de --- /dev/null +++ b/backend/alembic/versions/20260227_06_drop_target_resolve_date_from_monitoring_visit_issues.py @@ -0,0 +1,36 @@ +"""drop target_resolve_date from monitoring_visit_issues + +Revision ID: 20260227_06 +Revises: 20260227_05 +Create Date: 2026-02-27 22:10:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "20260227_06" +down_revision: Union[str, None] = "20260227_05" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {col["name"] for col in inspector.get_columns("monitoring_visit_issues")} + + if "target_resolve_date" in columns: + op.drop_column("monitoring_visit_issues", "target_resolve_date") + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {col["name"] for col in inspector.get_columns("monitoring_visit_issues")} + + if "target_resolve_date" not in columns: + op.add_column("monitoring_visit_issues", sa.Column("target_resolve_date", sa.Date(), nullable=True)) diff --git a/backend/app/api/v1/monitoring_visit_issues.py b/backend/app/api/v1/monitoring_visit_issues.py new file mode 100644 index 00000000..5d3dd929 --- /dev/null +++ b/backend/app/api/v1/monitoring_visit_issues.py @@ -0,0 +1,651 @@ +import csv +import io +import json +import uuid +from datetime import date, datetime, timezone +from urllib.parse import quote + +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status +from fastapi.responses import StreamingResponse +from openpyxl import Workbook, load_workbook +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_not_locked, require_study_roles +from app.crud import audit as audit_crud +from app.crud import monitoring_visit_issue as issue_crud +from app.crud import study as study_crud +from app.schemas.monitoring_visit_issue import ( + MonitoringVisitIssueCreate, + MonitoringVisitIssueImportSummary, + MonitoringVisitIssueRead, + MonitoringVisitIssueUpdate, +) + +router = APIRouter() + +_HEADER_ALIASES: dict[str, list[str]] = { + "issue_no": ["问题编号", "问题编码", "issue_no", "IssueNo"], + "open_duration_text": ["开放时长", "open_duration"], + "category": ["问题分类", "category"], + "subject_code": ["受试者", "受试者编号", "subject_code"], + "subject_name": ["受试者姓名", "subject_name"], + "monitor_item": ["监查项", "monitor_item"], + "monitor_type": ["监查类型", "monitor_type"], + "status": ["进度", "状态", "status", "progress"], + "source": ["问题来源", "source"], + "creator_name": ["创建者", "creator", "creator_name"], + "created_at": ["创建时间", "created_at", "创建日期"], + "recommendation": ["建议措施", "recommendation"], + "description": ["问题描述", "description"], + "action_taken": ["采取措施", "action_taken"], + "follow_up_progress": ["跟进计划及进展", "follow_up_progress"], + "found_date": ["发现时间", "found_date"], + "due_at": ["截止时间", "截止日期", "超期截止时间", "due_at"], + "actual_resolve_date": ["实际解决日期", "actual_resolve_date"], + "closed_at": ["关闭日期", "closed_at"], + "responsible_name": ["责任人", "responsible_name"], +} + +_AUDIT_FIELDS: tuple[str, ...] = ( + "issue_no", + "source", + "monitor_type", + "monitor_item", + "category", + "recommendation", + "subject_name", + "subject_code", + "description", + "action_taken", + "follow_up_progress", + "found_date", + "due_at", + "actual_resolve_date", + "closed_at", + "responsible_name", + "status", +) + + +async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): + study = await study_crud.get(db, study_id) + if not study: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") + return study + + +def _normalize_status(value: str | None) -> str: + text = (value or "").strip().upper() + zh = { + "已关闭": "CLOSED", + "关闭": "CLOSED", + "开放中": "OPEN", + "开启": "OPEN", + "OPEN": "OPEN", + "CLOSED": "CLOSED", + } + mapped = zh.get(text) + if mapped: + return mapped + return "OPEN" + + +def _pick(row: dict[str, object], key: str) -> str | None: + for alias in _HEADER_ALIASES[key]: + if alias not in row: + continue + value = row.get(alias) + if value is None: + continue + text = str(value).strip() + if text: + return text + return None + + +def _parse_datetime(value: object | None) -> datetime | None: + if value is None: + return None + if isinstance(value, datetime): + return value if value.tzinfo else value.replace(tzinfo=timezone.utc) + if isinstance(value, date): + return datetime(value.year, value.month, value.day, tzinfo=timezone.utc) + + text = str(value).strip() + if not text: + return None + + formats = [ + "%Y-%m-%d %H:%M:%S", + "%Y/%m/%d %H:%M:%S", + "%Y-%m-%d %H:%M", + "%Y/%m/%d %H:%M", + "%Y-%m-%d", + "%Y/%m/%d", + ] + for fmt in formats: + try: + parsed = datetime.strptime(text, fmt) + return parsed.replace(tzinfo=timezone.utc) + except ValueError: + continue + + try: + parsed = datetime.fromisoformat(text) + return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) + except ValueError: + return None + + +def _parse_date(value: object | None) -> date | None: + parsed = _parse_datetime(value) + if parsed is None: + return None + return parsed.date() + + +def _calc_open_duration(created_at: datetime, status_text: str, closed_at: datetime | None, override_text: str | None) -> str: + if override_text: + return override_text + start = created_at if created_at.tzinfo else created_at.replace(tzinfo=timezone.utc) + if status_text == "CLOSED" and closed_at is not None: + end = closed_at if closed_at.tzinfo else closed_at.replace(tzinfo=timezone.utc) + else: + end = datetime.now(timezone.utc) + delta = end - start + if delta.total_seconds() < 0: + return "0天0小时" + days = delta.days + hours = (delta.seconds // 3600) % 24 + return f"{days}天{hours}小时" + + +def _to_read(item) -> MonitoringVisitIssueRead: + now = datetime.now(timezone.utc) + overdue = item.status == "OPEN" and item.due_at is not None and item.due_at < now + return MonitoringVisitIssueRead( + id=item.id, + study_id=item.study_id, + issue_no=item.issue_no, + open_duration=_calc_open_duration(item.created_at, item.status, item.closed_at, item.open_duration_text), + category=item.category, + subject_code=item.subject_code, + monitor_item=item.monitor_item, + monitor_type=item.monitor_type, + progress="已关闭" if item.status == "CLOSED" else "开放中", + status=item.status, + source=item.source, + creator_name=item.creator_name, + recommendation=item.recommendation, + subject_name=item.subject_name, + created_at=item.created_at, + description=item.description, + action_taken=item.action_taken, + follow_up_progress=item.follow_up_progress, + found_date=item.found_date, + overdue=overdue, + due_at=item.due_at, + actual_resolve_date=item.actual_resolve_date, + closed_at=item.closed_at, + responsible_name=item.responsible_name, + ) + + +def _format_date(value: date | None) -> str: + if not value: + return "" + return value.isoformat() + + +def _format_datetime(value: datetime | None) -> str: + if not value: + return "" + parsed = value if value.tzinfo else value.replace(tzinfo=timezone.utc) + return parsed.strftime("%Y-%m-%d %H:%M:%S") + + +def _to_audit_value(value: object | None): + if isinstance(value, datetime): + parsed = value if value.tzinfo else value.replace(tzinfo=timezone.utc) + return parsed.isoformat() + if isinstance(value, date): + return value.isoformat() + return value + + +def _issue_audit_snapshot(item) -> dict[str, object | None]: + snapshot: dict[str, object | None] = {} + for field in _AUDIT_FIELDS: + snapshot[field] = _to_audit_value(getattr(item, field, None)) + return snapshot + + +def _build_audit_diff(before: dict[str, object | None], after: dict[str, object | None]): + before_changed: dict[str, object | None] = {} + after_changed: dict[str, object | None] = {} + for field in _AUDIT_FIELDS: + prev = before.get(field) + next_value = after.get(field) + if prev == next_value: + continue + before_changed[field] = prev + after_changed[field] = next_value + return before_changed, after_changed + + +def _read_xlsx_rows(content: bytes) -> list[dict[str, object]]: + workbook = load_workbook(filename=io.BytesIO(content), data_only=True) + worksheet = workbook.active + rows_iter = worksheet.iter_rows(values_only=True) + headers_row = next(rows_iter, None) + if not headers_row: + return [] + headers = [str(item).strip() if item is not None else "" for item in headers_row] + rows: list[dict[str, object]] = [] + for values in rows_iter: + if values is None: + continue + row_dict: dict[str, object] = {} + for idx, value in enumerate(values): + if idx >= len(headers): + continue + header = headers[idx] + if not header: + continue + row_dict[header] = value + if row_dict: + rows.append(row_dict) + return rows + + +def _read_csv_rows(content: bytes) -> list[dict[str, object]]: + text = None + for encoding in ("utf-8-sig", "utf-8", "gbk"): + try: + text = content.decode(encoding) + break + except UnicodeDecodeError: + continue + if text is None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="导入文件编码不支持") + reader = csv.DictReader(io.StringIO(text)) + return [dict(row) for row in reader if row] + + +def _normalize_file_rows(filename: str, content: bytes) -> list[dict[str, object]]: + lower = filename.lower() + if lower.endswith(".xlsx"): + return _read_xlsx_rows(content) + if lower.endswith(".csv"): + return _read_csv_rows(content) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 .xlsx 或 .csv 文件") + + +@router.get( + "/issues", + response_model=list[MonitoringVisitIssueRead], + dependencies=[Depends(require_study_member())], +) +async def list_monitoring_visit_issues( + study_id: uuid.UUID, + category: str | None = None, + status_value: str | None = Query(default=None, alias="status"), + overdue: bool | None = None, + keyword: str | None = None, + skip: int = 0, + limit: int = 500, + db: AsyncSession = Depends(get_db_session), +) -> list[MonitoringVisitIssueRead]: + await _ensure_study_exists(db, study_id) + normalized_status = _normalize_status(status_value) if status_value else None + items = await issue_crud.list_issues( + db, + study_id, + category=(category or None), + status=normalized_status, + overdue=overdue, + keyword=(keyword or None), + skip=skip, + limit=min(limit, 2000), + ) + return [_to_read(item) for item in items] + + +@router.post( + "/issues", + response_model=MonitoringVisitIssueRead, + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())], +) +async def create_monitoring_visit_issue( + study_id: uuid.UUID, + payload: MonitoringVisitIssueCreate, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> MonitoringVisitIssueRead: + await _ensure_study_exists(db, study_id) + + if payload.issue_no: + existing = await issue_crud.get_issue_by_issue_no(db, study_id, payload.issue_no) + if existing: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="问题编号已存在") + + creator_name = payload.creator_name or getattr(current_user, "full_name", None) + payload_to_save = payload.model_copy(update={"creator_name": creator_name}) + try: + item = await issue_crud.create_issue(db, study_id, payload_to_save, created_by=current_user.id) + except IntegrityError: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="问题编号已存在") + + await audit_crud.log_action( + db, + study_id=study_id, + entity_type="monitoring_visit_issue", + entity_id=item.id, + action="CREATE_MONITORING_VISIT_ISSUE", + detail=f"监查访视问题 {item.issue_no} 已创建", + operator_id=current_user.id, + operator_role=current_user.role, + ) + return _to_read(item) + + +@router.get( + "/issues/export", + response_class=StreamingResponse, + dependencies=[Depends(require_study_member())], +) +async def export_monitoring_visit_issues( + study_id: uuid.UUID, + category: str | None = None, + status_value: str | None = Query(default=None, alias="status"), + overdue: bool | None = None, + keyword: str | None = None, + db: AsyncSession = Depends(get_db_session), +) -> StreamingResponse: + await _ensure_study_exists(db, study_id) + normalized_status = _normalize_status(status_value) if status_value else None + items = await issue_crud.list_issues( + db, + study_id, + category=(category or None), + status=normalized_status, + overdue=overdue, + keyword=(keyword or None), + skip=0, + limit=50000, + ) + + wb = Workbook() + ws = wb.active + ws.title = "监查访视问题" + headers = [ + "问题编号", + "问题来源", + "监查类型", + "监查项", + "问题分类", + "建议措施", + "受试者", + "受试者缩写号", + "问题描述", + "采取措施", + "跟进计划及进展", + "发现时间", + "预计解决日期", + "实际解决日期", + "关闭日期", + "责任人", + "进度", + "开放时长", + "创建者", + "创建时间", + ] + ws.append(headers) + + for item in items: + view = _to_read(item) + ws.append( + [ + view.issue_no or "", + view.source or "", + view.monitor_type or "", + view.monitor_item or "", + view.category or "", + view.recommendation or "", + view.subject_name or "", + view.subject_code or "", + view.description or "", + view.action_taken or "", + view.follow_up_progress or "", + _format_date(view.found_date), + _format_datetime(view.due_at), + _format_date(view.actual_resolve_date), + _format_datetime(view.closed_at), + view.responsible_name or "", + view.progress or "", + view.open_duration or "", + view.creator_name or "", + _format_datetime(view.created_at), + ] + ) + + stream = io.BytesIO() + wb.save(stream) + stream.seek(0) + filename = f"monitoring_visit_issues_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + fallback = "".join((ch if ord(ch) < 128 else "_") for ch in filename) or "download.xlsx" + quoted = quote(filename, safe="") + headers = {"Content-Disposition": f"attachment; filename=\"{fallback}\"; filename*=UTF-8''{quoted}"} + return StreamingResponse( + stream, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers=headers, + ) + + +@router.get( + "/issues/{issue_id}", + response_model=MonitoringVisitIssueRead, + dependencies=[Depends(require_study_member())], +) +async def get_monitoring_visit_issue( + study_id: uuid.UUID, + issue_id: uuid.UUID, + db: AsyncSession = Depends(get_db_session), +) -> MonitoringVisitIssueRead: + await _ensure_study_exists(db, study_id) + item = await issue_crud.get_issue(db, issue_id) + if not item or item.study_id != study_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="问题记录不存在") + return _to_read(item) + + +@router.patch( + "/issues/{issue_id}", + response_model=MonitoringVisitIssueRead, + dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())], +) +async def update_monitoring_visit_issue( + study_id: uuid.UUID, + issue_id: uuid.UUID, + payload: MonitoringVisitIssueUpdate, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> MonitoringVisitIssueRead: + await _ensure_study_exists(db, study_id) + item = await issue_crud.get_issue(db, issue_id) + if not item or item.study_id != study_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="问题记录不存在") + + if payload.issue_no and payload.issue_no != item.issue_no: + existing = await issue_crud.get_issue_by_issue_no(db, study_id, payload.issue_no) + if existing and existing.id != item.id: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="问题编号已存在") + + if not payload.model_fields_set: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="至少提供一个更新字段") + + before_snapshot = _issue_audit_snapshot(item) + try: + updated = await issue_crud.update_issue(db, item, payload, created_by=current_user.id) + except IntegrityError: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="问题编号已存在") + + after_snapshot = _issue_audit_snapshot(updated) + before_changed, after_changed = _build_audit_diff(before_snapshot, after_snapshot) + detail_payload: dict[str, object] = {"targetName": updated.issue_no} + if before_changed or after_changed: + detail_payload["before"] = before_changed + detail_payload["after"] = after_changed + else: + detail_payload["description"] = f"监查访视问题 {updated.issue_no} 已更新" + + await audit_crud.log_action( + db, + study_id=study_id, + entity_type="monitoring_visit_issue", + entity_id=item.id, + action="UPDATE_MONITORING_VISIT_ISSUE", + detail=json.dumps(detail_payload, ensure_ascii=False), + operator_id=current_user.id, + operator_role=current_user.role, + ) + return _to_read(updated) + + +@router.delete( + "/issues/{issue_id}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())], +) +async def delete_monitoring_visit_issue( + study_id: uuid.UUID, + issue_id: uuid.UUID, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> None: + await _ensure_study_exists(db, study_id) + item = await issue_crud.get_issue(db, issue_id) + if not item or item.study_id != study_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="问题记录不存在") + + issue_no = item.issue_no + await issue_crud.delete_issue(db, item) + await audit_crud.log_action( + db, + study_id=study_id, + entity_type="monitoring_visit_issue", + entity_id=issue_id, + action="DELETE_MONITORING_VISIT_ISSUE", + detail=f"监查访视问题 {issue_no} 已删除", + operator_id=current_user.id, + operator_role=current_user.role, + ) + + +@router.post( + "/issues/import", + response_model=MonitoringVisitIssueImportSummary, + dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())], +) +async def import_monitoring_visit_issues( + study_id: uuid.UUID, + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> MonitoringVisitIssueImportSummary: + await _ensure_study_exists(db, study_id) + if not file.filename: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文件名不能为空") + + content = await file.read() + rows = _normalize_file_rows(file.filename, content) + if not rows: + return MonitoringVisitIssueImportSummary(created_count=0, updated_count=0, skipped_count=0, skipped_rows=[]) + + created_count = 0 + updated_count = 0 + skipped_rows: list[str] = [] + + for idx, row in enumerate(rows, start=2): + issue_no = _pick(row, "issue_no") + category = _pick(row, "category") + if not issue_no or not category: + skipped_rows.append(f"第{idx}行:问题编号或问题分类为空") + continue + + status_text = _normalize_status(_pick(row, "status")) + creator_name = _pick(row, "creator_name") or getattr(current_user, "full_name", None) + created_at = _parse_datetime(row.get("创建时间") or row.get("created_at") or _pick(row, "created_at")) + due_at = _parse_datetime(row.get("截止时间") or row.get("due_at") or _pick(row, "due_at")) + closed_at = _parse_datetime(row.get("关闭日期") or row.get("closed_at") or _pick(row, "closed_at")) + open_duration_text = _pick(row, "open_duration_text") + + try: + payload = MonitoringVisitIssueCreate( + issue_no=issue_no, + category=category, + subject_code=_pick(row, "subject_code"), + subject_name=_pick(row, "subject_name"), + monitor_item=_pick(row, "monitor_item"), + monitor_type=_pick(row, "monitor_type") or "监查访视", + status=status_text, + source=_pick(row, "source") or "监查", + creator_name=creator_name, + recommendation=_pick(row, "recommendation"), + description=_pick(row, "description"), + action_taken=_pick(row, "action_taken"), + follow_up_progress=_pick(row, "follow_up_progress"), + found_date=_parse_date(row.get("发现时间") or row.get("found_date") or _pick(row, "found_date")), + due_at=due_at, + actual_resolve_date=_parse_date( + row.get("实际解决日期") or row.get("actual_resolve_date") or _pick(row, "actual_resolve_date") + ), + closed_at=closed_at, + responsible_name=_pick(row, "responsible_name"), + ) + except Exception as exc: + skipped_rows.append(f"第{idx}行:{exc}") + continue + + existing = await issue_crud.get_issue_by_issue_no(db, study_id, payload.issue_no) + if existing: + await issue_crud.update_issue( + db, + existing, + MonitoringVisitIssueUpdate(**payload.model_dump()), + created_by=current_user.id, + created_at=created_at, + open_duration_text=open_duration_text, + ) + updated_count += 1 + else: + await issue_crud.create_issue( + db, + study_id, + payload, + created_by=current_user.id, + created_at=created_at, + open_duration_text=open_duration_text, + ) + created_count += 1 + + await audit_crud.log_action( + db, + study_id=study_id, + entity_type="monitoring_visit_issue", + entity_id=None, + action="IMPORT_MONITORING_VISIT_ISSUE", + detail=f"导入监查访视问题:新增 {created_count} 条,更新 {updated_count} 条,跳过 {len(skipped_rows)} 条", + operator_id=current_user.id, + operator_role=current_user.role, + ) + + return MonitoringVisitIssueImportSummary( + created_count=created_count, + updated_count=updated_count, + skipped_count=len(skipped_rows), + skipped_rows=skipped_rows[:20], + ) diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 533704ed..e05e8fc0 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, finance_contracts, finance_specials, fees_contracts, fees_specials, fees_attachments, drug_shipments, material_equipments, project_milestones, startup, knowledge_notes, subject_histories, faq_categories, faqs, documents, overview, notifications +from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, finance_contracts, finance_specials, fees_contracts, fees_specials, fees_attachments, drug_shipments, material_equipments, project_milestones, startup, knowledge_notes, subject_histories, faq_categories, faqs, documents, overview, notifications, monitoring_visit_issues api_router = APIRouter() @@ -30,6 +30,7 @@ api_router.include_router(material_equipments.router, prefix="/studies/{study_id api_router.include_router(project_milestones.router, prefix="/studies/{study_id}/project", tags=["project-milestones"]) api_router.include_router(startup.router, prefix="/studies/{study_id}/startup", tags=["startup"]) api_router.include_router(knowledge_notes.router, prefix="/studies/{study_id}/knowledge", tags=["knowledge"]) +api_router.include_router(monitoring_visit_issues.router, prefix="/studies/{study_id}/monitoring", tags=["monitoring-visit-issues"]) api_router.include_router(subject_histories.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-histories"]) api_router.include_router(faq_categories.router, prefix="/faqs/categories", tags=["faq-categories"]) api_router.include_router(faqs.router, prefix="/faqs/items", tags=["faqs"]) diff --git a/backend/app/crud/monitoring_visit_issue.py b/backend/app/crud/monitoring_visit_issue.py new file mode 100644 index 00000000..6df2bebc --- /dev/null +++ b/backend/app/crud/monitoring_visit_issue.py @@ -0,0 +1,174 @@ +import uuid +from datetime import datetime, timezone +from typing import Sequence + +from sqlalchemy import and_, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.monitoring_visit_issue import MonitoringVisitIssue +from app.schemas.monitoring_visit_issue import MonitoringVisitIssueCreate, MonitoringVisitIssueUpdate + + +async def get_issue(db: AsyncSession, issue_id: uuid.UUID) -> MonitoringVisitIssue | None: + result = await db.execute(select(MonitoringVisitIssue).where(MonitoringVisitIssue.id == issue_id)) + return result.scalar_one_or_none() + + +async def get_issue_by_issue_no( + db: AsyncSession, + study_id: uuid.UUID, + issue_no: str, +) -> MonitoringVisitIssue | None: + result = await db.execute( + select(MonitoringVisitIssue).where( + MonitoringVisitIssue.study_id == study_id, + MonitoringVisitIssue.issue_no == issue_no, + ) + ) + return result.scalar_one_or_none() + + +async def generate_next_issue_no(db: AsyncSession, study_id: uuid.UUID) -> str: + result = await db.execute(select(MonitoringVisitIssue.issue_no).where(MonitoringVisitIssue.study_id == study_id)) + issue_nos = [str(item or "").strip() for item in result.scalars().all()] + max_seq = 0 + for issue_no in issue_nos: + if "M" not in issue_no: + continue + seq_text = issue_no.split("M")[-1] + if seq_text.isdigit(): + max_seq = max(max_seq, int(seq_text)) + next_seq = max_seq + 1 + return f"001M{next_seq:05d}" + + +async def list_issues( + db: AsyncSession, + study_id: uuid.UUID, + *, + category: str | None = None, + status: str | None = None, + overdue: bool | None = None, + keyword: str | None = None, + skip: int = 0, + limit: int = 500, +) -> Sequence[MonitoringVisitIssue]: + stmt = select(MonitoringVisitIssue).where(MonitoringVisitIssue.study_id == study_id) + + if category: + stmt = stmt.where(MonitoringVisitIssue.category == category) + if status: + stmt = stmt.where(MonitoringVisitIssue.status == status) + if keyword: + term = f"%{keyword}%" + stmt = stmt.where( + or_( + MonitoringVisitIssue.issue_no.ilike(term), + MonitoringVisitIssue.category.ilike(term), + MonitoringVisitIssue.subject_code.ilike(term), + MonitoringVisitIssue.subject_name.ilike(term), + MonitoringVisitIssue.monitor_item.ilike(term), + MonitoringVisitIssue.description.ilike(term), + MonitoringVisitIssue.recommendation.ilike(term), + MonitoringVisitIssue.action_taken.ilike(term), + MonitoringVisitIssue.follow_up_progress.ilike(term), + MonitoringVisitIssue.responsible_name.ilike(term), + ) + ) + + now = datetime.now(timezone.utc) + overdue_expr = and_( + MonitoringVisitIssue.status == "OPEN", + MonitoringVisitIssue.due_at.is_not(None), + MonitoringVisitIssue.due_at < now, + ) + if overdue is True: + stmt = stmt.where(overdue_expr) + elif overdue is False: + stmt = stmt.where(or_(MonitoringVisitIssue.due_at.is_(None), MonitoringVisitIssue.due_at >= now, MonitoringVisitIssue.status == "CLOSED")) + + stmt = stmt.order_by(MonitoringVisitIssue.created_at.desc()).offset(skip).limit(limit) + result = await db.execute(stmt) + return result.scalars().all() + + +async def create_issue( + db: AsyncSession, + study_id: uuid.UUID, + issue_in: MonitoringVisitIssueCreate, + *, + created_by: uuid.UUID | None, + created_at: datetime | None = None, + open_duration_text: str | None = None, +) -> MonitoringVisitIssue: + issue_no = issue_in.issue_no or await generate_next_issue_no(db, study_id) + closed_at = issue_in.closed_at if issue_in.status == "CLOSED" else None + if issue_in.status == "CLOSED" and closed_at is None: + closed_at = datetime.now(timezone.utc) + item = MonitoringVisitIssue( + study_id=study_id, + issue_no=issue_no, + category=issue_in.category, + subject_code=issue_in.subject_code, + monitor_item=issue_in.monitor_item, + monitor_type=issue_in.monitor_type, + status=issue_in.status, + source=issue_in.source, + creator_name=issue_in.creator_name, + recommendation=issue_in.recommendation, + subject_name=issue_in.subject_name, + description=issue_in.description, + action_taken=issue_in.action_taken, + follow_up_progress=issue_in.follow_up_progress, + found_date=issue_in.found_date, + due_at=issue_in.due_at, + actual_resolve_date=issue_in.actual_resolve_date, + closed_at=closed_at, + responsible_name=issue_in.responsible_name, + created_by=created_by, + open_duration_text=open_duration_text, + ) + if created_at is not None: + item.created_at = created_at + + db.add(item) + await db.commit() + await db.refresh(item) + return item + + +async def update_issue( + db: AsyncSession, + issue: MonitoringVisitIssue, + issue_in: MonitoringVisitIssueUpdate, + *, + created_by: uuid.UUID | None = None, + created_at: datetime | None = None, + open_duration_text: str | None = None, +) -> MonitoringVisitIssue: + prev_status = issue.status + update_data = issue_in.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(issue, key, value) + + next_status = update_data.get("status", issue.status) + if created_by is not None: + issue.created_by = created_by + if created_at is not None: + issue.created_at = created_at + if open_duration_text is not None: + issue.open_duration_text = open_duration_text + + if prev_status != "CLOSED" and next_status == "CLOSED" and "closed_at" not in update_data: + issue.closed_at = datetime.now(timezone.utc) + elif prev_status == "CLOSED" and next_status == "OPEN": + issue.closed_at = None + + await db.commit() + await db.refresh(issue) + return issue + + +async def delete_issue(db: AsyncSession, issue: MonitoringVisitIssue) -> None: + await db.delete(issue) + await db.commit() diff --git a/backend/app/db/base.py b/backend/app/db/base.py index e12e4949..0a0c47c7 100644 --- a/backend/app/db/base.py +++ b/backend/app/db/base.py @@ -24,6 +24,7 @@ from app.models.special_expense import SpecialExpense # noqa: F401 from app.models.fee_attachment import FeeAttachment # noqa: F401 from app.models.drug_shipment import DrugShipment # noqa: F401 from app.models.material_equipment import MaterialEquipment # noqa: F401 +from app.models.monitoring_visit_issue import MonitoringVisitIssue # noqa: F401 from app.models.startup_feasibility import StartupFeasibility # noqa: F401 from app.models.startup_ethics import StartupEthics # noqa: F401 from app.models.kickoff_meeting import KickoffMeeting # noqa: F401 diff --git a/backend/app/models/monitoring_visit_issue.py b/backend/app/models/monitoring_visit_issue.py new file mode 100644 index 00000000..baf4ea24 --- /dev/null +++ b/backend/app/models/monitoring_visit_issue.py @@ -0,0 +1,40 @@ +import uuid +from datetime import date, datetime + +from sqlalchemy import Date, DateTime, ForeignKey, String, Text, UniqueConstraint, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base_class import Base + + +class MonitoringVisitIssue(Base): + __tablename__ = "monitoring_visit_issues" + __table_args__ = (UniqueConstraint("study_id", "issue_no", name="uq_monitoring_visit_issues_study_issue_no"),) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False) + issue_no: Mapped[str] = mapped_column(String(64), nullable=False) + category: Mapped[str] = mapped_column(String(255), nullable=False) + subject_code: Mapped[str | None] = mapped_column(String(64), nullable=True) + monitor_item: Mapped[str | None] = mapped_column(Text, nullable=True) + monitor_type: Mapped[str | None] = mapped_column(String(64), nullable=True) + status: Mapped[str] = mapped_column(String(20), nullable=False, server_default="OPEN") + source: Mapped[str | None] = mapped_column(String(64), nullable=True) + creator_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + recommendation: Mapped[str | None] = mapped_column(Text, nullable=True) + subject_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + action_taken: Mapped[str | None] = mapped_column(Text, nullable=True) + follow_up_progress: Mapped[str | None] = mapped_column(Text, nullable=True) + found_date: Mapped[date | None] = mapped_column(Date, nullable=True) + due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + actual_resolve_date: Mapped[date | None] = mapped_column(Date, nullable=True) + closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + responsible_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + open_duration_text: Mapped[str | None] = mapped_column(String(64), nullable=True) + created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() + ) diff --git a/backend/app/schemas/monitoring_visit_issue.py b/backend/app/schemas/monitoring_visit_issue.py new file mode 100644 index 00000000..417ebb62 --- /dev/null +++ b/backend/app/schemas/monitoring_visit_issue.py @@ -0,0 +1,182 @@ +import uuid +from datetime import date, datetime, timezone + +from pydantic import BaseModel, ConfigDict, field_validator + + +class MonitoringVisitIssueCreate(BaseModel): + issue_no: str | None = None + category: str + subject_code: str | None = None + monitor_item: str | None = None + monitor_type: str | None = None + status: str = "OPEN" + source: str | None = None + creator_name: str | None = None + recommendation: str | None = None + subject_name: str | None = None + description: str | None = None + action_taken: str | None = None + follow_up_progress: str | None = None + found_date: date | None = None + due_at: datetime | None = None + actual_resolve_date: date | None = None + closed_at: datetime | None = None + responsible_name: str | None = None + + @field_validator("category", mode="before") + @classmethod + def _strip_required(cls, value: str) -> str: + text = (value or "").strip() + if not text: + raise ValueError("不能为空") + return text + + @field_validator("issue_no", mode="before") + @classmethod + def _strip_issue_no(cls, value: str | None) -> str | None: + if value is None: + return None + text = value.strip() + return text or None + + @field_validator( + "subject_code", + "monitor_item", + "monitor_type", + "source", + "creator_name", + "recommendation", + "subject_name", + "description", + "action_taken", + "follow_up_progress", + "responsible_name", + mode="before", + ) + @classmethod + def _strip_optional(cls, value: str | None) -> str | None: + if value is None: + return None + text = value.strip() + return text or None + + @field_validator("status", mode="before") + @classmethod + def _normalize_status(cls, value: str | None) -> str: + normalized = (value or "OPEN").strip().upper() + if normalized not in {"OPEN", "CLOSED"}: + raise ValueError("状态必须为 OPEN 或 CLOSED") + return normalized + + @field_validator("due_at", "closed_at") + @classmethod + def _normalize_due_at(cls, value: datetime | None) -> datetime | None: + if value is None: + return None + return value if value.tzinfo else value.replace(tzinfo=timezone.utc) + + +class MonitoringVisitIssueRead(BaseModel): + id: uuid.UUID + study_id: uuid.UUID + issue_no: str + open_duration: str + category: str + subject_code: str | None + monitor_item: str | None + monitor_type: str | None + progress: str + status: str + source: str | None + creator_name: str | None + recommendation: str | None + subject_name: str | None + created_at: datetime + description: str | None + action_taken: str | None + follow_up_progress: str | None + found_date: date | None + overdue: bool + due_at: datetime | None + actual_resolve_date: date | None + closed_at: datetime | None + responsible_name: str | None + + model_config = ConfigDict(from_attributes=True) + + +class MonitoringVisitIssueImportSummary(BaseModel): + created_count: int + updated_count: int + skipped_count: int + skipped_rows: list[str] + + +class MonitoringVisitIssueUpdate(BaseModel): + issue_no: str | None = None + category: str | None = None + subject_code: str | None = None + monitor_item: str | None = None + monitor_type: str | None = None + status: str | None = None + source: str | None = None + creator_name: str | None = None + recommendation: str | None = None + subject_name: str | None = None + description: str | None = None + action_taken: str | None = None + follow_up_progress: str | None = None + found_date: date | None = None + due_at: datetime | None = None + actual_resolve_date: date | None = None + closed_at: datetime | None = None + responsible_name: str | None = None + + @field_validator("issue_no", "category", mode="before") + @classmethod + def _strip_required(cls, value: str | None) -> str | None: + if value is None: + return None + text = value.strip() + if not text: + raise ValueError("不能为空") + return text + + @field_validator( + "subject_code", + "monitor_item", + "monitor_type", + "source", + "creator_name", + "recommendation", + "subject_name", + "description", + "action_taken", + "follow_up_progress", + "responsible_name", + mode="before", + ) + @classmethod + def _strip_optional(cls, value: str | None) -> str | None: + if value is None: + return None + text = value.strip() + return text or None + + @field_validator("status", mode="before") + @classmethod + def _normalize_status(cls, value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip().upper() + if normalized not in {"OPEN", "CLOSED"}: + raise ValueError("状态必须为 OPEN 或 CLOSED") + return normalized + + @field_validator("due_at", "closed_at") + @classmethod + def _normalize_due_at(cls, value: datetime | None) -> datetime | None: + if value is None: + return None + return value if value.tzinfo else value.replace(tzinfo=timezone.utc) diff --git a/frontend/src/api/monitoringVisitIssues.ts b/frontend/src/api/monitoringVisitIssues.ts new file mode 100644 index 00000000..63cffc0f --- /dev/null +++ b/frontend/src/api/monitoringVisitIssues.ts @@ -0,0 +1,32 @@ +import { apiDelete, apiGet, apiPatch, apiPost } from "./axios"; + +export const listMonitoringVisitIssues = (studyId: string, params?: Record) => + apiGet(`/api/v1/studies/${studyId}/monitoring/issues`, { params }); + +export const createMonitoringVisitIssue = (studyId: string, payload: Record) => + apiPost(`/api/v1/studies/${studyId}/monitoring/issues`, payload); + +export const getMonitoringVisitIssue = (studyId: string, issueId: string) => + apiGet(`/api/v1/studies/${studyId}/monitoring/issues/${issueId}`); + +export const updateMonitoringVisitIssue = (studyId: string, issueId: string, payload: Record) => + apiPatch(`/api/v1/studies/${studyId}/monitoring/issues/${issueId}`, payload); + +export const deleteMonitoringVisitIssue = (studyId: string, issueId: string) => + apiDelete(`/api/v1/studies/${studyId}/monitoring/issues/${issueId}`); + +export const importMonitoringVisitIssues = (studyId: string, file: File) => { + const formData = new FormData(); + formData.append("file", file); + return apiPost(`/api/v1/studies/${studyId}/monitoring/issues/import`, formData, { + headers: { "Content-Type": "multipart/form-data" }, + timeout: 120000, + }); +}; + +export const exportMonitoringVisitIssues = (studyId: string, params?: Record) => + apiGet(`/api/v1/studies/${studyId}/monitoring/issues/export`, { + params, + responseType: "blob", + timeout: 120000, + }); diff --git a/frontend/src/audit/index.ts b/frontend/src/audit/index.ts index b1f23c5d..4c03684d 100644 --- a/frontend/src/audit/index.ts +++ b/frontend/src/audit/index.ts @@ -129,6 +129,23 @@ const splitAuditDetailSegments = (line: string): string[] => { }; const auditFieldLabelMap: Record = { + issue_no: "问题编号", + source: "问题来源", + monitor_type: "监查类型", + monitor_item: "监查项", + category: "问题分类", + recommendation: "建议措施", + subject_name: "受试者", + subject_code: "受试者缩写号", + description: "问题描述", + action_taken: "采取措施", + follow_up_progress: "跟进计划及进展", + found_date: "发现时间", + due_at: "预计解决日期", + actual_resolve_date: "实际解决日期", + closed_at: "关闭日期", + responsible_name: "责任人", + creator_name: "创建者", status: "状态", is_active: "启用状态", role: "角色", @@ -148,6 +165,35 @@ const auditFieldLabelMap: Record = { plan_end_date: "计划结束日期", }; +const stableSerialize = (value: any): string => { + if (value === null || value === undefined) return ""; + if (Array.isArray(value)) return `[${value.map((item) => stableSerialize(item)).join(",")}]`; + if (typeof value === "object") { + const entries = Object.entries(value as Record) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => `${k}:${stableSerialize(v)}`); + return `{${entries.join(",")}}`; + } + return String(value); +}; + +const isSameValue = (prev: any, next: any): boolean => stableSerialize(prev) === stableSerialize(next); + +const compactObjectValue = (value: Record): string => { + const preferredKeys = ["name", "label", "title", "code", "id", "value"]; + for (const key of preferredKeys) { + const text = String(value[key] ?? "").trim(); + if (text) return text; + } + const entries = Object.entries(value).filter(([, v]) => v !== null && v !== undefined && String(v).trim() !== ""); + if (entries.length === 0) return "未填写"; + const rendered = entries + .slice(0, 3) + .map(([k, v]) => `${toReadableFieldLabel(k)}=${String(v)}`) + .join(","); + return entries.length > 3 ? `${rendered} 等${entries.length}项` : rendered; +}; + const toReadableFieldLabel = (key: string): string => { const normalized = String(key || "").trim(); if (!normalized) return "字段"; @@ -170,11 +216,13 @@ const toReadableValue = (key: string, value: any): string => { } return text; } - if (Array.isArray(value)) return `共${value.length}项`; + if (Array.isArray(value)) { + if (value.length === 0) return "未填写"; + const rendered = value.slice(0, 3).map((item) => toReadableValue(key, item)).join("、"); + return value.length > 3 ? `${rendered} 等${value.length}项` : rendered; + } if (typeof value === "object") { - if (typeof value.name === "string" && value.name.trim()) return value.name.trim(); - if (typeof value.label === "string" && value.label.trim()) return value.label.trim(); - return "已配置"; + return compactObjectValue(value as Record); } return String(value); }; @@ -187,7 +235,7 @@ const buildDiffText = (before?: Record, after?: Record Object.keys({ ...prevObj, ...nextObj }).forEach((key) => { const prev = prevObj[key]; const next = nextObj[key]; - if (prev === next) return; + if (isSameValue(prev, next)) return; const label = toReadableFieldLabel(key); const prevText = toReadableValue(key, prev); const nextText = toReadableValue(key, next); diff --git a/frontend/src/views/Faq.vue b/frontend/src/views/Faq.vue index 1b9bc4c8..c21bb633 100644 --- a/frontend/src/views/Faq.vue +++ b/frontend/src/views/Faq.vue @@ -32,14 +32,18 @@ :can-edit="canEdit" @refresh="loadFaqs" /> - +
+ +
@@ -68,7 +72,7 @@ const categories = ref([]); const faqs = ref([]); const loading = ref(false); const page = ref(1); -const pageSize = 10; +const pageSize = ref(10); const total = ref(0); const keyword = ref(""); const onlyActive = ref(true); @@ -96,8 +100,8 @@ const loadFaqs = async () => { loading.value = true; try { const params: Record = { - skip: (page.value - 1) * pageSize, - limit: pageSize, + skip: (page.value - 1) * pageSize.value, + limit: pageSize.value, study_id: study.currentStudy.id, }; if (activeCategory.value) params.category_id = activeCategory.value; @@ -123,6 +127,12 @@ const onPageChange = (p: number) => { loadFaqs(); }; +const onPageSizeChange = (size: number) => { + pageSize.value = size; + page.value = 1; + loadFaqs(); +}; + watch(activeCategory, () => { page.value = 1; loadFaqs(); @@ -161,8 +171,9 @@ onMounted(async () => { .spacer { flex: 1; } -.pagination { - margin-top: 12px; - text-align: right; +.pagination-wrap { + margin-top: 14px; + display: flex; + justify-content: flex-end; } diff --git a/frontend/src/views/admin/AuditLogs.vue b/frontend/src/views/admin/AuditLogs.vue index d167505d..fd304a74 100644 --- a/frontend/src/views/admin/AuditLogs.vue +++ b/frontend/src/views/admin/AuditLogs.vue @@ -4,17 +4,30 @@
- + - + - + @@ -29,7 +42,7 @@ format="YYYY-MM-DD" value-format="YYYY-MM-DD" class="date-range-picker-comp" - @change="loadLogs" + @change="onLocalFilterChange" />
@@ -89,14 +102,18 @@ - +
+ +
@@ -165,11 +182,11 @@ const router = useRouter(); const loading = ref(false); const exportLoading = ref(false); -const rawLogs = ref([]); const logs = ref([]); const users = ref([]); +const allLogs = ref([]); const page = ref(1); -const pageSize = 20; +const pageSize = ref(20); const total = ref(0); const detailVisible = ref(false); const selectedLog = ref(null); @@ -224,17 +241,25 @@ const loadLogs = async () => { if (!study.currentStudy) return; loading.value = true; try { + const batchLimit = 500; + let skip = 0; + const allItems: any[] = []; const params: Record = { - skip: (page.value - 1) * pageSize, - limit: pageSize, + skip, + limit: batchLimit, action: filters.value.eventType || undefined, operator_id: filters.value.operatorId || undefined, }; - const { data } = await fetchAuditLogs(study.currentStudy.id, params); - const items = Array.isArray(data) ? data : (data as any).items || []; - rawLogs.value = items; - total.value = (data as any).total || items.length; - enrichLogs(); + while (true) { + params.skip = skip; + const { data } = await fetchAuditLogs(study.currentStudy.id, params); + const items = Array.isArray(data) ? data : (data as any).items || []; + allItems.push(...items); + if (items.length < batchLimit) break; + skip += items.length; + } + enrichLogs(allItems); + refreshPagedLogs(); } catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.modules.adminAuditLogs.loadFailed); } finally { @@ -242,30 +267,17 @@ const loadLogs = async () => { } }; -const enrichLogs = () => { +const enrichLogs = (items: any[]) => { const userMap = users.value.reduce>((acc, cur) => { acc[cur.id] = resolveUserDisplayName(cur); return acc; }, {}); - const filtered = rawLogs.value.filter((log) => { - if (filters.value.eventType && String(log.action || "") !== filters.value.eventType) return false; - if (filters.value.operatorId && String(log.operator_id || "") !== filters.value.operatorId) return false; - if (filters.value.result && (log.result || "SUCCESS") !== filters.value.result) return false; - if (filters.value.range?.length === 2) { - const ts = new Date(log.created_at); - const start = new Date(filters.value.range[0]); - const end = new Date(filters.value.range[1]); - if (ts < start || ts > end) return false; - } - return true; - }); - logs.value = filtered + allLogs.value = items .map((log) => normalizeAuditEvent(log, userMap)) - .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) - .slice(0, pageSize); + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); if (users.value.length === 0) { const byActor = new Map(); - logs.value.forEach((log) => { + allLogs.value.forEach((log) => { if (!byActor.has(log.actorId)) { byActor.set(log.actorId, log.actorName || log.actorId); } @@ -274,9 +286,46 @@ const enrichLogs = () => { } }; +const filterLogs = (items: any[]) => + items.filter((log) => { + if (filters.value.result && (log.result || "SUCCESS") !== filters.value.result) return false; + if (filters.value.range?.length === 2) { + const ts = new Date(log.timestamp); + const start = new Date(filters.value.range[0]); + const end = new Date(filters.value.range[1]); + if (ts < start || ts > end) return false; + } + return true; + }); + +const refreshPagedLogs = () => { + const filtered = filterLogs(allLogs.value); + total.value = filtered.length; + const maxPage = Math.max(1, Math.ceil(total.value / pageSize.value)); + if (page.value > maxPage) page.value = maxPage; + const start = (page.value - 1) * pageSize.value; + logs.value = filtered.slice(start, start + pageSize.value); +}; + +const onServerFilterChange = () => { + page.value = 1; + loadLogs(); +}; + +const onLocalFilterChange = () => { + page.value = 1; + refreshPagedLogs(); +}; + const onPageChange = (p: number) => { page.value = p; - loadLogs(); + refreshPagedLogs(); +}; + +const onPageSizeChange = (size: number) => { + pageSize.value = size; + page.value = 1; + refreshPagedLogs(); }; const getDiffPreview = (log: any) => { @@ -459,8 +508,8 @@ onMounted(async () => { color: #94a3b8; } -.pagination { - margin-top: 12px; +.pagination-wrap { + margin-top: 14px; display: flex; justify-content: flex-end; } diff --git a/frontend/src/views/admin/Users.vue b/frontend/src/views/admin/Users.vue index 8dffaab0..817ea9c4 100644 --- a/frontend/src/views/admin/Users.vue +++ b/frontend/src/views/admin/Users.vue @@ -71,15 +71,18 @@ - +
+ +
@@ -170,6 +173,12 @@ const loadUsers = async () => { } }; +const onPageSizeChange = (size: number) => { + pageSize.value = size; + page.value = 1; + loadUsers(); +}; + const openCreate = () => { editingUser.value = null; formVisible.value = true; @@ -309,8 +318,8 @@ onMounted(() => { padding-bottom: 12px; } -.pagination { - margin-top: 12px; +.pagination-wrap { + margin-top: 14px; display: flex; justify-content: flex-end; } diff --git a/frontend/src/views/ia/RiskIssueMonitoringVisits.vue b/frontend/src/views/ia/RiskIssueMonitoringVisits.vue index 88bcdfcc..1254ce06 100644 --- a/frontend/src/views/ia/RiskIssueMonitoringVisits.vue +++ b/frontend/src/views/ia/RiskIssueMonitoringVisits.vue @@ -1,13 +1,935 @@ + + diff --git a/frontend/src/views/ia/SubjectManagement.vue b/frontend/src/views/ia/SubjectManagement.vue index d771c7f5..2e4ae57d 100644 --- a/frontend/src/views/ia/SubjectManagement.vue +++ b/frontend/src/views/ia/SubjectManagement.vue @@ -106,13 +106,14 @@ -
+
@@ -354,10 +355,10 @@ onMounted(async () => { background: #8b5cf6; } -.table-pagination { +.pagination-wrap { display: flex; justify-content: flex-end; - margin-top: 12px; + margin-top: 14px; }