补充监查访视问题模块内容、翻页功能UI统一

This commit is contained in:
Cheng Zhou
2026-02-28 09:51:13 +08:00
parent db2d38edbc
commit af0c1b4b7d
16 changed files with 2372 additions and 84 deletions
@@ -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")
@@ -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")
@@ -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))
@@ -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],
)
+2 -1
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter 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() 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(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(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(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(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(faq_categories.router, prefix="/faqs/categories", tags=["faq-categories"])
api_router.include_router(faqs.router, prefix="/faqs/items", tags=["faqs"]) api_router.include_router(faqs.router, prefix="/faqs/items", tags=["faqs"])
+174
View File
@@ -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()
+1
View File
@@ -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.fee_attachment import FeeAttachment # noqa: F401
from app.models.drug_shipment import DrugShipment # noqa: F401 from app.models.drug_shipment import DrugShipment # noqa: F401
from app.models.material_equipment import MaterialEquipment # 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_feasibility import StartupFeasibility # noqa: F401
from app.models.startup_ethics import StartupEthics # noqa: F401 from app.models.startup_ethics import StartupEthics # noqa: F401
from app.models.kickoff_meeting import KickoffMeeting # noqa: F401 from app.models.kickoff_meeting import KickoffMeeting # noqa: F401
@@ -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()
)
@@ -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)
+32
View File
@@ -0,0 +1,32 @@
import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
export const listMonitoringVisitIssues = (studyId: string, params?: Record<string, any>) =>
apiGet(`/api/v1/studies/${studyId}/monitoring/issues`, { params });
export const createMonitoringVisitIssue = (studyId: string, payload: Record<string, any>) =>
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<string, any>) =>
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<string, any>) =>
apiGet<Blob>(`/api/v1/studies/${studyId}/monitoring/issues/export`, {
params,
responseType: "blob",
timeout: 120000,
});
+53 -5
View File
@@ -129,6 +129,23 @@ const splitAuditDetailSegments = (line: string): string[] => {
}; };
const auditFieldLabelMap: Record<string, string> = { const auditFieldLabelMap: Record<string, string> = {
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: "状态", status: "状态",
is_active: "启用状态", is_active: "启用状态",
role: "角色", role: "角色",
@@ -148,6 +165,35 @@ const auditFieldLabelMap: Record<string, string> = {
plan_end_date: "计划结束日期", 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<string, any>)
.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, any>): 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 toReadableFieldLabel = (key: string): string => {
const normalized = String(key || "").trim(); const normalized = String(key || "").trim();
if (!normalized) return "字段"; if (!normalized) return "字段";
@@ -170,11 +216,13 @@ const toReadableValue = (key: string, value: any): string => {
} }
return text; 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 === "object") {
if (typeof value.name === "string" && value.name.trim()) return value.name.trim(); return compactObjectValue(value as Record<string, any>);
if (typeof value.label === "string" && value.label.trim()) return value.label.trim();
return "已配置";
} }
return String(value); return String(value);
}; };
@@ -187,7 +235,7 @@ const buildDiffText = (before?: Record<string, any>, after?: Record<string, any>
Object.keys({ ...prevObj, ...nextObj }).forEach((key) => { Object.keys({ ...prevObj, ...nextObj }).forEach((key) => {
const prev = prevObj[key]; const prev = prevObj[key];
const next = nextObj[key]; const next = nextObj[key];
if (prev === next) return; if (isSameValue(prev, next)) return;
const label = toReadableFieldLabel(key); const label = toReadableFieldLabel(key);
const prevText = toReadableValue(key, prev); const prevText = toReadableValue(key, prev);
const nextText = toReadableValue(key, next); const nextText = toReadableValue(key, next);
+21 -10
View File
@@ -32,15 +32,19 @@
:can-edit="canEdit" :can-edit="canEdit"
@refresh="loadFaqs" @refresh="loadFaqs"
/> />
<div class="pagination-wrap" v-if="total > 0">
<el-pagination <el-pagination
class="pagination" v-model:current-page="page"
layout="prev, pager, next" v-model:page-size="pageSize"
:page-size="pageSize" :page-sizes="[5, 10, 20]"
:current-page="page"
:total="total" :total="total"
layout="prev, pager, next, sizes, total"
small
@current-change="onPageChange" @current-change="onPageChange"
@size-change="onPageSizeChange"
/> />
</div> </div>
</div>
</el-col> </el-col>
</el-row> </el-row>
@@ -68,7 +72,7 @@ const categories = ref<any[]>([]);
const faqs = ref<any[]>([]); const faqs = ref<any[]>([]);
const loading = ref(false); const loading = ref(false);
const page = ref(1); const page = ref(1);
const pageSize = 10; const pageSize = ref(10);
const total = ref(0); const total = ref(0);
const keyword = ref(""); const keyword = ref("");
const onlyActive = ref(true); const onlyActive = ref(true);
@@ -96,8 +100,8 @@ const loadFaqs = async () => {
loading.value = true; loading.value = true;
try { try {
const params: Record<string, any> = { const params: Record<string, any> = {
skip: (page.value - 1) * pageSize, skip: (page.value - 1) * pageSize.value,
limit: pageSize, limit: pageSize.value,
study_id: study.currentStudy.id, study_id: study.currentStudy.id,
}; };
if (activeCategory.value) params.category_id = activeCategory.value; if (activeCategory.value) params.category_id = activeCategory.value;
@@ -123,6 +127,12 @@ const onPageChange = (p: number) => {
loadFaqs(); loadFaqs();
}; };
const onPageSizeChange = (size: number) => {
pageSize.value = size;
page.value = 1;
loadFaqs();
};
watch(activeCategory, () => { watch(activeCategory, () => {
page.value = 1; page.value = 1;
loadFaqs(); loadFaqs();
@@ -161,8 +171,9 @@ onMounted(async () => {
.spacer { .spacer {
flex: 1; flex: 1;
} }
.pagination { .pagination-wrap {
margin-top: 12px; margin-top: 14px;
text-align: right; display: flex;
justify-content: flex-end;
} }
</style> </style>
+84 -35
View File
@@ -4,17 +4,30 @@
<div class="audit-toolbar"> <div class="audit-toolbar">
<el-form :inline="true" :model="filters" class="filter-form"> <el-form :inline="true" :model="filters" class="filter-form">
<el-form-item label="" class="filter-item-form"> <el-form-item label="" class="filter-item-form">
<el-select v-model="filters.eventType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterEvent" @change="loadLogs" class="filter-select-comp"> <el-select
v-model="filters.eventType"
clearable
:placeholder="TEXT.modules.adminAuditLogs.filterEvent"
@change="onServerFilterChange"
class="filter-select-comp"
>
<el-option v-for="opt in eventTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" /> <el-option v-for="opt in eventTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="" class="filter-item-form"> <el-form-item label="" class="filter-item-form">
<el-select v-model="filters.operatorId" clearable :placeholder="TEXT.modules.adminAuditLogs.filterOperator" filterable @change="loadLogs" class="filter-select-comp"> <el-select
v-model="filters.operatorId"
clearable
:placeholder="TEXT.modules.adminAuditLogs.filterOperator"
filterable
@change="onServerFilterChange"
class="filter-select-comp"
>
<el-option v-for="u in userOptions" :key="u.value" :label="u.label" :value="u.value" /> <el-option v-for="u in userOptions" :key="u.value" :label="u.label" :value="u.value" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="" class="filter-item-form"> <el-form-item label="" class="filter-item-form">
<el-select v-model="filters.result" clearable :placeholder="TEXT.modules.adminAuditLogs.filterResult" @change="loadLogs" class="filter-select-result"> <el-select v-model="filters.result" clearable :placeholder="TEXT.modules.adminAuditLogs.filterResult" @change="onLocalFilterChange" class="filter-select-result">
<el-option :label="TEXT.audit.resultSuccess" value="SUCCESS" /> <el-option :label="TEXT.audit.resultSuccess" value="SUCCESS" />
<el-option :label="TEXT.audit.resultFail" value="FAIL" /> <el-option :label="TEXT.audit.resultFail" value="FAIL" />
</el-select> </el-select>
@@ -29,7 +42,7 @@
format="YYYY-MM-DD" format="YYYY-MM-DD"
value-format="YYYY-MM-DD" value-format="YYYY-MM-DD"
class="date-range-picker-comp" class="date-range-picker-comp"
@change="loadLogs" @change="onLocalFilterChange"
/> />
</el-form-item> </el-form-item>
<div class="filter-spacer"></div> <div class="filter-spacer"></div>
@@ -89,15 +102,19 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<div class="pagination-wrap" v-if="total > 0">
<el-pagination <el-pagination
class="pagination" v-model:current-page="page"
layout="prev, pager, next" v-model:page-size="pageSize"
:page-size="pageSize" :page-sizes="[5, 10, 20]"
:current-page="page"
:total="total" :total="total"
layout="prev, pager, next, sizes, total"
small
@current-change="onPageChange" @current-change="onPageChange"
@size-change="onPageSizeChange"
/> />
</div> </div>
</div>
</el-card> </el-card>
<el-drawer <el-drawer
@@ -165,11 +182,11 @@ const router = useRouter();
const loading = ref(false); const loading = ref(false);
const exportLoading = ref(false); const exportLoading = ref(false);
const rawLogs = ref<any[]>([]);
const logs = ref<any[]>([]); const logs = ref<any[]>([]);
const users = ref<any[]>([]); const users = ref<any[]>([]);
const allLogs = ref<any[]>([]);
const page = ref(1); const page = ref(1);
const pageSize = 20; const pageSize = ref(20);
const total = ref(0); const total = ref(0);
const detailVisible = ref(false); const detailVisible = ref(false);
const selectedLog = ref<any | null>(null); const selectedLog = ref<any | null>(null);
@@ -224,17 +241,25 @@ const loadLogs = async () => {
if (!study.currentStudy) return; if (!study.currentStudy) return;
loading.value = true; loading.value = true;
try { try {
const batchLimit = 500;
let skip = 0;
const allItems: any[] = [];
const params: Record<string, any> = { const params: Record<string, any> = {
skip: (page.value - 1) * pageSize, skip,
limit: pageSize, limit: batchLimit,
action: filters.value.eventType || undefined, action: filters.value.eventType || undefined,
operator_id: filters.value.operatorId || undefined, operator_id: filters.value.operatorId || undefined,
}; };
while (true) {
params.skip = skip;
const { data } = await fetchAuditLogs(study.currentStudy.id, params); const { data } = await fetchAuditLogs(study.currentStudy.id, params);
const items = Array.isArray(data) ? data : (data as any).items || []; const items = Array.isArray(data) ? data : (data as any).items || [];
rawLogs.value = items; allItems.push(...items);
total.value = (data as any).total || items.length; if (items.length < batchLimit) break;
enrichLogs(); skip += items.length;
}
enrichLogs(allItems);
refreshPagedLogs();
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminAuditLogs.loadFailed); ElMessage.error(e?.response?.data?.message || TEXT.modules.adminAuditLogs.loadFailed);
} finally { } finally {
@@ -242,30 +267,17 @@ const loadLogs = async () => {
} }
}; };
const enrichLogs = () => { const enrichLogs = (items: any[]) => {
const userMap = users.value.reduce<Record<string, string>>((acc, cur) => { const userMap = users.value.reduce<Record<string, string>>((acc, cur) => {
acc[cur.id] = resolveUserDisplayName(cur); acc[cur.id] = resolveUserDisplayName(cur);
return acc; return acc;
}, {}); }, {});
const filtered = rawLogs.value.filter((log) => { allLogs.value = items
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
.map((log) => normalizeAuditEvent(log, userMap)) .map((log) => normalizeAuditEvent(log, userMap))
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
.slice(0, pageSize);
if (users.value.length === 0) { if (users.value.length === 0) {
const byActor = new Map<string, string>(); const byActor = new Map<string, string>();
logs.value.forEach((log) => { allLogs.value.forEach((log) => {
if (!byActor.has(log.actorId)) { if (!byActor.has(log.actorId)) {
byActor.set(log.actorId, log.actorName || 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) => { const onPageChange = (p: number) => {
page.value = p; page.value = p;
loadLogs(); refreshPagedLogs();
};
const onPageSizeChange = (size: number) => {
pageSize.value = size;
page.value = 1;
refreshPagedLogs();
}; };
const getDiffPreview = (log: any) => { const getDiffPreview = (log: any) => {
@@ -459,8 +508,8 @@ onMounted(async () => {
color: #94a3b8; color: #94a3b8;
} }
.pagination { .pagination-wrap {
margin-top: 12px; margin-top: 14px;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
} }
+16 -7
View File
@@ -71,16 +71,19 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<div class="pagination-wrap" v-if="total > 0">
<el-pagination <el-pagination
background
layout="prev, pager, next, total"
:page-size="pageSize"
:total="total"
v-model:current-page="page" v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[5, 10, 20]"
:total="total"
layout="prev, pager, next, sizes, total"
small
@current-change="loadUsers" @current-change="loadUsers"
class="pagination" @size-change="onPageSizeChange"
/> />
</div> </div>
</div>
</el-card> </el-card>
<UserForm v-model:visible="formVisible" :user="editingUser" :admin-count="activeAdminCount" @saved="loadUsers" /> <UserForm v-model:visible="formVisible" :user="editingUser" :admin-count="activeAdminCount" @saved="loadUsers" />
<UserResetPassword v-model:visible="resetVisible" :user="resetUser" @reset="loadUsers" /> <UserResetPassword v-model:visible="resetVisible" :user="resetUser" @reset="loadUsers" />
@@ -170,6 +173,12 @@ const loadUsers = async () => {
} }
}; };
const onPageSizeChange = (size: number) => {
pageSize.value = size;
page.value = 1;
loadUsers();
};
const openCreate = () => { const openCreate = () => {
editingUser.value = null; editingUser.value = null;
formVisible.value = true; formVisible.value = true;
@@ -309,8 +318,8 @@ onMounted(() => {
padding-bottom: 12px; padding-bottom: 12px;
} }
.pagination { .pagination-wrap {
margin-top: 12px; margin-top: 14px;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
} }
@@ -1,13 +1,935 @@
<template> <template>
<ModulePlaceholder <div class="page">
:title="TEXT.modules.riskIssueMonitoringVisits.title" <template v-if="study.currentStudy">
:subtitle="TEXT.modules.riskIssueMonitoringVisits.subtitle" <el-card shadow="never" class="main-content-card unified-shell">
:list-title="TEXT.modules.riskIssueMonitoringVisits.listTitle" <div class="filter-container unified-action-bar">
:empty-description="TEXT.modules.riskIssueMonitoringVisits.emptyDescription" <div class="filter-left">
<el-form :inline="true" :model="filters" class="filter-form">
<el-form-item label="问题分类" class="filter-item">
<el-select v-model="filters.category" clearable placeholder="请选择" class="filter-select">
<el-option label="全部" value="" />
<el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
<el-form-item label="状态" class="filter-item">
<el-select v-model="filters.status" clearable placeholder="请选择" class="filter-select">
<el-option label="全部" value="" />
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="是否超期" class="filter-item">
<el-select v-model="filters.overdue" clearable placeholder="请选择" class="filter-select">
<el-option label="全部" value="" />
<el-option label="是" value="YES" />
<el-option label="否" value="NO" />
</el-select>
</el-form-item>
<el-form-item class="filter-actions">
<el-button type="primary" @click="handleSearch">{{ TEXT.common.actions.search }}</el-button>
<el-button link type="info" @click="handleReset">{{ TEXT.common.actions.reset }}</el-button>
</el-form-item>
</el-form>
</div>
<div class="toolbar-right">
<el-button type="primary" @click="openCreateDialog">新建</el-button>
<el-upload
ref="importUploadRef"
:auto-upload="false"
:show-file-list="false"
accept=".xlsx,.csv"
:on-change="onImportChange"
:disabled="importing"
>
<el-button :loading="importing">导入</el-button>
</el-upload>
<el-button :loading="exporting" @click="handleExportExcel">导出Excel</el-button>
</div>
</div>
<div class="unified-section table-section">
<el-table
class="issue-table"
:data="tableRows"
style="width: 100%"
v-loading="loading"
:fit="true"
:span-method="tableSpanMethod"
:row-class-name="tableRowClassName"
>
<el-table-column label="问题编号" min-width="130">
<template #default="{ row }">
<div v-if="isDescRow(row)" class="issue-desc">问题描述{{ row.description || TEXT.common.fallback }}</div>
<div v-else class="issue-no">{{ row.issue_no }}</div>
</template>
</el-table-column>
<el-table-column prop="open_duration" label="开放时长" min-width="130">
<template #default="{ row }">{{ isDescRow(row) ? "" : row.open_duration }}</template>
</el-table-column>
<el-table-column prop="category" label="问题分类" min-width="130">
<template #default="{ row }">{{ isDescRow(row) ? "" : row.category }}</template>
</el-table-column>
<el-table-column prop="subject_code" label="受试者" min-width="120">
<template #default="{ row }">{{ isDescRow(row) ? "" : row.subject_code || TEXT.common.fallback }}</template>
</el-table-column>
<el-table-column prop="monitor_item" label="监查项" min-width="130" show-overflow-tooltip>
<template #default="{ row }">{{ isDescRow(row) ? "" : row.monitor_item || TEXT.common.fallback }}</template>
</el-table-column>
<el-table-column prop="monitor_type" label="监查类型" min-width="130">
<template #default="{ row }">{{ isDescRow(row) ? "" : row.monitor_type || TEXT.common.fallback }}</template>
</el-table-column>
<el-table-column label="进度" min-width="120">
<template #default="{ row }">
<div v-if="!isDescRow(row)" class="progress-cell">
<span class="progress-dot" :class="`dot-${String(row.status).toLowerCase()}`"></span>
<span>{{ row.progress }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="操作" min-width="150" align="center">
<template #default="{ row }">
<template v-if="!isDescRow(row)">
<div class="action-cell">
<el-button class="action-btn" link type="primary" size="small" @click="openViewDialog(row)">查看</el-button>
<el-button class="action-btn" link type="primary" size="small" @click="openEditDialog(row)">编辑</el-button>
<el-button class="action-btn" link type="danger" size="small" @click="removeIssue(row)">删除</el-button>
</div>
</template>
</template>
</el-table-column>
<template #empty>
<StateEmpty v-if="!loading" description="暂无监查访视问题" />
</template>
</el-table>
<div class="pagination-wrap" v-if="filteredItems.length > 0">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:page-sizes="[5, 10, 20]"
:total="filteredItems.length"
layout="prev, pager, next, sizes, total"
small
/> />
</div>
</div>
</el-card>
<el-drawer
v-model="formDialogVisible"
direction="rtl"
size="580px"
:close-on-click-modal="false"
:show-close="false"
class="issue-editor-drawer"
>
<template #header>
<div class="editor-header">
<div class="editor-title">{{ formMode === "create" ? "新建监查访视问题" : "编辑监查访视问题" }}</div>
<div class="editor-subtitle">
{{ formMode === "create" ? "记录监查发现问题与后续处理计划" : "更新问题状态、处理措施与时间节点" }}
</div>
</div>
</template>
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-position="top" class="issue-editor-form">
<div class="editor-group">
<div class="editor-group-title">
<span class="group-dot group-dot-basic"></span>
基础信息
</div>
<el-row :gutter="14">
<el-col :span="12">
<el-form-item label="问题编号">
<el-input v-model="createForm.issue_no" :placeholder="formMode === 'create' ? '可选,不填自动生成' : '请输入问题编号'" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="问题来源" prop="source">
<el-select v-model="createForm.source" class="dialog-select" placeholder="请选择">
<el-option v-for="item in sourceOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="监查类型">
<el-select v-model="createForm.monitor_type" class="dialog-select" placeholder="请选择">
<el-option v-for="item in monitorTypeOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="监查项">
<el-input v-model="createForm.monitor_item" placeholder="请输入" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="问题分类" prop="category">
<el-input v-model="createForm.category" placeholder="请输入" />
</el-form-item>
</el-col>
</el-row>
</div>
<div class="editor-group">
<div class="editor-group-title">
<span class="group-dot group-dot-issue"></span>
问题信息
</div>
<el-row :gutter="14">
<el-col :span="24">
<el-form-item label="建议措施">
<el-input v-model="createForm.recommendation" type="textarea" :rows="2" placeholder="请输入" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="受试者">
<el-input v-model="createForm.subject_name" placeholder="请输入" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="受试者缩写号">
<el-input v-model="createForm.subject_code" placeholder="请输入" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="问题描述" prop="description">
<el-input v-model="createForm.description" type="textarea" :rows="3" placeholder="请输入" />
</el-form-item>
</el-col>
</el-row>
</div>
<div class="editor-group">
<div class="editor-group-title">
<span class="group-dot group-dot-action"></span>
处理进展
</div>
<el-row :gutter="14">
<el-col :span="24">
<el-form-item label="采取措施">
<el-input v-model="createForm.action_taken" type="textarea" :rows="2" placeholder="请输入" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="跟进计划及进展">
<el-input v-model="createForm.follow_up_progress" type="textarea" :rows="2" placeholder="请输入" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="责任人">
<el-input v-model="createForm.responsible_name" placeholder="请输入" />
</el-form-item>
</el-col>
</el-row>
</div>
<div class="editor-group">
<div class="editor-group-title">
<span class="group-dot group-dot-time"></span>
时间节点
</div>
<el-row :gutter="14">
<el-col :span="12">
<el-form-item label="发现时间" prop="found_date">
<el-date-picker
v-model="createForm.found_date"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="请选择日期"
class="dialog-select"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="预计解决日期">
<el-date-picker
v-model="createForm.expected_resolve_date"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="请选择日期"
class="dialog-select"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="实际解决日期">
<el-date-picker
v-model="createForm.actual_resolve_date"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="请选择日期"
class="dialog-select"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="关闭日期">
<el-date-picker
v-model="createForm.closed_date"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="请选择日期"
class="dialog-select"
/>
</el-form-item>
</el-col>
</el-row>
</div>
</el-form>
<template #footer>
<div class="editor-footer">
<el-button @click="formDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="saving" @click="submitForm">{{ TEXT.common.actions.save }}</el-button>
</div>
</template>
</el-drawer>
<el-dialog v-model="viewDialogVisible" title="监查访视问题详情" width="680px">
<el-descriptions :column="2" border>
<el-descriptions-item label="问题编号">{{ viewIssue?.issue_no || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="问题来源">{{ viewIssue?.source || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="监查类型">{{ viewIssue?.monitor_type || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="监查项">{{ viewIssue?.monitor_item || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="问题分类">{{ viewIssue?.category || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="建议措施">{{ viewIssue?.recommendation || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="受试者">{{ viewIssue?.subject_name || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="受试者缩写号">{{ viewIssue?.subject_code || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="进度">{{ viewIssue?.progress || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="创建者">{{ viewIssue?.creator_name || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="责任人">{{ viewIssue?.responsible_name || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ displayDateTime(viewIssue?.created_at) }}</el-descriptions-item>
<el-descriptions-item label="发现时间">{{ viewIssue?.found_date || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="开放时长">{{ viewIssue?.open_duration || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="预计解决日期">{{ displayDateTime(viewIssue?.due_at) }}</el-descriptions-item>
<el-descriptions-item label="实际解决日期">{{ viewIssue?.actual_resolve_date || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="关闭日期">{{ displayDateTime(viewIssue?.closed_at) }}</el-descriptions-item>
<el-descriptions-item label="问题描述" :span="2">{{ viewIssue?.description || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="采取措施" :span="2">{{ viewIssue?.action_taken || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="跟进计划及进展" :span="2">{{ viewIssue?.follow_up_progress || TEXT.common.fallback }}</el-descriptions-item>
</el-descriptions>
</el-dialog>
</template>
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import ModulePlaceholder from "../../components/ModulePlaceholder.vue"; import { computed, reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules, type UploadFile, type UploadInstance } from "element-plus";
import {
createMonitoringVisitIssue,
deleteMonitoringVisitIssue,
exportMonitoringVisitIssues,
getMonitoringVisitIssue,
importMonitoringVisitIssues,
listMonitoringVisitIssues,
updateMonitoringVisitIssue,
} from "../../api/monitoringVisitIssues";
import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
import { useStudyStore } from "../../store/study";
import { displayDateTime } from "../../utils/display";
interface MonitoringIssueRow {
id: string;
issue_no: string;
open_duration: string;
category: string;
subject_name?: string | null;
subject_code: string | null;
monitor_item: string | null;
monitor_type: string | null;
progress: string;
status: "OPEN" | "CLOSED";
source: string | null;
creator_name: string | null;
recommendation?: string | null;
created_at: string;
description: string | null;
action_taken?: string | null;
follow_up_progress?: string | null;
found_date?: string | null;
actual_resolve_date?: string | null;
closed_at?: string | null;
responsible_name?: string | null;
overdue: boolean;
due_at?: string | null;
}
type MonitoringIssueTableRow = MonitoringIssueRow & { __rowType: "data" | "desc" };
const study = useStudyStore();
const loading = ref(false);
const saving = ref(false);
const importing = ref(false);
const exporting = ref(false);
const formDialogVisible = ref(false);
const viewDialogVisible = ref(false);
const formMode = ref<"create" | "edit">("create");
const editingIssueId = ref("");
const createFormRef = ref<FormInstance>();
const importUploadRef = ref<UploadInstance>();
const allItems = ref<MonitoringIssueRow[]>([]);
const viewIssue = ref<MonitoringIssueRow | null>(null);
const filters = reactive({
category: "",
status: "",
overdue: "",
});
const appliedFilters = reactive({
category: "",
status: "",
overdue: "",
});
const pagination = reactive({
page: 1,
pageSize: 10,
});
const createForm = reactive({
issue_no: "",
source: "监查",
monitor_type: "监查访视",
monitor_item: "",
category: "",
recommendation: "",
subject_name: "",
subject_code: "",
description: "",
action_taken: "",
follow_up_progress: "",
found_date: "",
expected_resolve_date: "",
actual_resolve_date: "",
closed_date: "",
responsible_name: "",
});
const createRules: FormRules = {
source: [{ required: true, message: "请选择问题来源", trigger: "change" }],
category: [{ required: true, message: "请输入问题分类", trigger: "blur" }],
description: [{ required: true, message: "请输入问题描述", trigger: "blur" }],
found_date: [{ required: true, message: "请选择发现时间", trigger: "change" }],
};
const sourceOptions = ["监查", "稽查", "其他"];
const monitorTypeOptions = ["监查访视", "远程监查", "现场核查", "稽查", "其他"];
const statusOptions = [
{ label: "开放中", value: "OPEN" },
{ label: "已关闭", value: "CLOSED" },
];
const categoryOptions = computed(() =>
Array.from(new Set(allItems.value.map((item) => item.category))).sort((a, b) => a.localeCompare(b, "zh-CN"))
);
const filteredItems = computed(() =>
allItems.value.filter((item) => {
if (appliedFilters.category && item.category !== appliedFilters.category) return false;
if (appliedFilters.status && item.status !== appliedFilters.status) return false;
if (appliedFilters.overdue === "YES" && !item.overdue) return false;
if (appliedFilters.overdue === "NO" && item.overdue) return false;
return true;
})
);
const pagedItems = computed(() => {
const start = (pagination.page - 1) * pagination.pageSize;
return filteredItems.value.slice(start, start + pagination.pageSize);
});
const tableRows = computed<MonitoringIssueTableRow[]>(() =>
pagedItems.value.flatMap((item) => [
{ ...item, __rowType: "data" as const },
{ ...item, __rowType: "desc" as const },
])
);
const isDescRow = (row: MonitoringIssueTableRow | MonitoringIssueRow) => (row as MonitoringIssueTableRow).__rowType === "desc";
const tableRowClassName = ({ row }: { row: MonitoringIssueTableRow }) => (isDescRow(row) ? "issue-desc-table-row" : "");
const tableSpanMethod = ({ row, columnIndex }: { row: MonitoringIssueTableRow; columnIndex: number }) => {
if (!isDescRow(row)) return { rowspan: 1, colspan: 1 };
if (columnIndex === 0) return { rowspan: 1, colspan: 8 };
return { rowspan: 0, colspan: 0 };
};
const resetCreateForm = () => {
createForm.issue_no = "";
createForm.source = "监查";
createForm.monitor_type = "监查访视";
createForm.monitor_item = "";
createForm.category = "";
createForm.recommendation = "";
createForm.subject_name = "";
createForm.subject_code = "";
createForm.description = "";
createForm.action_taken = "";
createForm.follow_up_progress = "";
createForm.found_date = "";
createForm.expected_resolve_date = "";
createForm.actual_resolve_date = "";
createForm.closed_date = "";
createForm.responsible_name = "";
createFormRef.value?.clearValidate();
};
const fillFormFromIssue = (issue: MonitoringIssueRow) => {
createForm.issue_no = issue.issue_no || "";
createForm.source = issue.source || "监查";
createForm.monitor_type = issue.monitor_type || "监查访视";
createForm.monitor_item = issue.monitor_item || "";
createForm.category = issue.category || "";
createForm.recommendation = issue.recommendation || "";
createForm.subject_name = issue.subject_name || "";
createForm.subject_code = issue.subject_code || "";
createForm.description = issue.description || "";
createForm.action_taken = issue.action_taken || "";
createForm.follow_up_progress = issue.follow_up_progress || "";
createForm.found_date = issue.found_date ? String(issue.found_date).slice(0, 10) : "";
createForm.expected_resolve_date = issue.due_at ? String(issue.due_at).slice(0, 10) : "";
createForm.actual_resolve_date = issue.actual_resolve_date ? String(issue.actual_resolve_date).slice(0, 10) : "";
createForm.closed_date = issue.closed_at ? String(issue.closed_at).slice(0, 10) : "";
createForm.responsible_name = issue.responsible_name || "";
createFormRef.value?.clearValidate();
};
const loadIssues = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) {
allItems.value = [];
return;
}
loading.value = true;
try {
const { data } = await listMonitoringVisitIssues(studyId, { limit: 2000 });
allItems.value = Array.isArray(data) ? data : [];
} catch (e: any) {
allItems.value = [];
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.loadFailed);
} finally {
loading.value = false;
}
};
const handleSearch = () => {
appliedFilters.category = filters.category;
appliedFilters.status = filters.status;
appliedFilters.overdue = filters.overdue;
pagination.page = 1;
};
const handleReset = () => {
filters.category = "";
filters.status = "";
filters.overdue = "";
handleSearch();
};
const openCreateDialog = () => {
resetCreateForm();
formMode.value = "create";
editingIssueId.value = "";
formDialogVisible.value = true;
};
const openEditDialog = async (row: MonitoringIssueRow) => {
const studyId = study.currentStudy?.id;
if (!studyId || !row?.id) return;
try {
const { data } = await getMonitoringVisitIssue(studyId, row.id);
const issue = (data || row) as MonitoringIssueRow;
formMode.value = "edit";
editingIssueId.value = row.id;
fillFormFromIssue(issue);
formDialogVisible.value = true;
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.loadFailed);
}
};
const openViewDialog = async (row: MonitoringIssueRow) => {
const studyId = study.currentStudy?.id;
if (!studyId || !row?.id) return;
try {
const { data } = await getMonitoringVisitIssue(studyId, row.id);
viewIssue.value = (data || row) as MonitoringIssueRow;
viewDialogVisible.value = true;
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.loadFailed);
}
};
const submitForm = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
const ok = await createFormRef.value?.validate().catch(() => false);
if (!ok) return;
saving.value = true;
try {
const resolvedStatus = createForm.closed_date ? "CLOSED" : "OPEN";
const payload = {
issue_no: createForm.issue_no || undefined,
source: createForm.source || undefined,
monitor_type: createForm.monitor_type || undefined,
monitor_item: createForm.monitor_item || undefined,
category: createForm.category,
recommendation: createForm.recommendation || undefined,
subject_name: createForm.subject_name || undefined,
subject_code: createForm.subject_code || undefined,
description: createForm.description || undefined,
action_taken: createForm.action_taken || undefined,
follow_up_progress: createForm.follow_up_progress || undefined,
found_date: createForm.found_date || undefined,
due_at: createForm.expected_resolve_date ? `${createForm.expected_resolve_date}T00:00:00` : undefined,
actual_resolve_date: createForm.actual_resolve_date || undefined,
closed_at: createForm.closed_date ? `${createForm.closed_date}T00:00:00` : undefined,
responsible_name: createForm.responsible_name || undefined,
status: resolvedStatus,
};
if (formMode.value === "create") {
await createMonitoringVisitIssue(studyId, payload);
ElMessage.success(TEXT.common.messages.createSuccess);
} else if (editingIssueId.value) {
await updateMonitoringVisitIssue(studyId, editingIssueId.value, payload);
ElMessage.success(TEXT.common.messages.updateSuccess);
}
formDialogVisible.value = false;
await loadIssues();
handleSearch();
} catch (e: any) {
ElMessage.error(
e?.response?.data?.detail || (formMode.value === "create" ? TEXT.common.messages.createFailed : TEXT.common.messages.updateFailed)
);
} finally {
saving.value = false;
}
};
const removeIssue = async (row: MonitoringIssueRow) => {
const studyId = study.currentStudy?.id;
if (!studyId || !row?.id) return;
const confirmed = await ElMessageBox.confirm(`确认删除问题「${row.issue_no}」?`, TEXT.common.labels.tips).catch(() => null);
if (!confirmed) return;
try {
await deleteMonitoringVisitIssue(studyId, row.id);
ElMessage.success(TEXT.common.messages.deleteSuccess);
await loadIssues();
handleSearch();
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.deleteFailed);
}
};
const getFilename = (header?: string | null) => {
if (!header) return null;
const match = /filename\*=UTF-8''([^;]+)|filename="?([^\";]+)"?/i.exec(header);
if (!match) return null;
return decodeURIComponent(match[1] || match[2] || "");
};
const handleExportExcel = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
if (filteredItems.value.length === 0) {
ElMessage.warning("暂无可导出数据");
return;
}
exporting.value = true;
try {
const params: Record<string, any> = {};
if (appliedFilters.category) params.category = appliedFilters.category;
if (appliedFilters.status) params.status = appliedFilters.status;
if (appliedFilters.overdue === "YES") params.overdue = true;
if (appliedFilters.overdue === "NO") params.overdue = false;
const response = await exportMonitoringVisitIssues(studyId, params);
const contentType = response.headers?.["content-type"] || "application/octet-stream";
const filename = getFilename(response.headers?.["content-disposition"]) || "监查访视问题.xlsx";
const blob = new Blob([response.data], { type: contentType });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
ElMessage.success("导出成功");
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.downloadFailed);
} finally {
exporting.value = false;
}
};
const onImportChange = async (uploadFile: UploadFile) => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
const file = uploadFile.raw;
if (!file) return;
importing.value = true;
try {
const { data } = await importMonitoringVisitIssues(studyId, file);
const summary = data || {};
const skippedTip = summary.skipped_count ? `,跳过 ${summary.skipped_count}` : "";
ElMessage.success(`导入完成:新增 ${summary.created_count || 0} 条,更新 ${summary.updated_count || 0}${skippedTip}`);
if (Array.isArray(summary.skipped_rows) && summary.skipped_rows.length > 0) {
ElMessage.warning(`部分行未导入:${summary.skipped_rows.slice(0, 3).join("")}`);
}
await loadIssues();
handleSearch();
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.uploadFailed);
} finally {
importing.value = false;
importUploadRef.value?.clearFiles();
}
};
watch(
() => study.currentStudy?.id,
() => {
handleReset();
loadIssues();
},
{ immediate: true }
);
</script> </script>
<style scoped>
.page {
display: flex;
flex-direction: column;
}
.main-content-card :deep(.el-card__body) {
padding: 0;
}
.filter-container {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
padding: 16px 16px 0;
}
.filter-left {
flex: 1;
min-width: 580px;
}
.filter-form {
display: flex;
width: 100%;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.filter-item {
display: flex;
align-items: center;
margin-bottom: 0 !important;
margin-right: 0 !important;
}
.filter-select {
width: 150px;
}
.filter-actions {
display: flex;
align-items: center;
margin-bottom: 0 !important;
margin-right: 0 !important;
}
.toolbar-right {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.table-section {
padding: 12px 0;
}
.issue-no {
font-weight: 600;
color: #24324a;
}
.issue-desc {
margin: 0;
color: #71839f;
font-size: 12px;
}
:deep(.issue-desc-table-row .el-table__cell) {
background: #fbfdff;
}
.progress-cell {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.progress-dot {
width: 6px;
height: 6px;
border-radius: 50%;
display: inline-block;
}
.action-cell {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
white-space: nowrap;
}
.action-btn {
padding: 0;
min-width: auto;
}
:deep(.action-cell .el-button + .el-button) {
margin-left: 0;
}
.dot-open {
background: #409eff;
}
.dot-closed {
background: #67c23a;
}
.pagination-wrap {
margin: 14px 16px 0;
display: flex;
justify-content: flex-end;
}
.dialog-select {
width: 100%;
}
:deep(.issue-editor-drawer > .el-drawer__header) {
margin-bottom: 0;
padding: 16px 18px 10px;
border-bottom: 1px solid #edf2f8;
}
:deep(.issue-editor-drawer > .el-drawer__body) {
padding: 14px 18px 4px;
background: #f8fbff;
}
:deep(.issue-editor-drawer > .el-drawer__footer) {
padding: 12px 18px;
border-top: 1px solid #edf2f8;
background: #ffffff;
}
.editor-header {
display: flex;
flex-direction: column;
gap: 2px;
}
.editor-title {
font-size: 18px;
font-weight: 700;
color: #15315b;
}
.editor-subtitle {
font-size: 12px;
color: #7a8aa5;
}
.issue-editor-form {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 560px;
margin: 0 auto;
width: 100%;
}
.editor-group {
border: 1px solid #e6edf7;
border-radius: 12px;
background: #ffffff;
padding: 12px 12px 2px;
}
.editor-group-title {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-size: 14px;
font-weight: 700;
color: #203a63;
}
.group-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.group-dot-basic {
background: #409eff;
}
.group-dot-issue {
background: #e6a23c;
}
.group-dot-action {
background: #67c23a;
}
.group-dot-time {
background: #909399;
}
.editor-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
@media (max-width: 768px) {
:deep(.issue-editor-drawer) {
width: 100% !important;
}
}
@media (max-width: 1200px) {
.filter-left {
min-width: 100%;
}
}
</style>
+6 -5
View File
@@ -106,13 +106,14 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<div class="table-pagination"> <div class="pagination-wrap" v-if="filteredItems.length > 0">
<el-pagination <el-pagination
v-model:current-page="pagination.currentPage" v-model:current-page="pagination.currentPage"
v-model:page-size="pagination.pageSize" v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50]" :page-sizes="[5, 10, 20]"
:total="filteredItems.length" :total="filteredItems.length"
layout="total, prev, pager, next, sizes" layout="prev, pager, next, sizes, total"
small
/> />
</div> </div>
<StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.subjectManagement.empty" /> <StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.subjectManagement.empty" />
@@ -354,10 +355,10 @@ onMounted(async () => {
background: #8b5cf6; background: #8b5cf6;
} }
.table-pagination { .pagination-wrap {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
margin-top: 12px; margin-top: 14px;
} }
</style> </style>