补充监查访视问题模块内容、翻页功能UI统一
This commit is contained in:
@@ -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")
|
||||||
+36
@@ -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],
|
||||||
|
)
|
||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
+25
-14
@@ -32,14 +32,18 @@
|
|||||||
:can-edit="canEdit"
|
:can-edit="canEdit"
|
||||||
@refresh="loadFaqs"
|
@refresh="loadFaqs"
|
||||||
/>
|
/>
|
||||||
<el-pagination
|
<div class="pagination-wrap" v-if="total > 0">
|
||||||
class="pagination"
|
<el-pagination
|
||||||
layout="prev, pager, next"
|
v-model:current-page="page"
|
||||||
:page-size="pageSize"
|
v-model:page-size="pageSize"
|
||||||
:current-page="page"
|
:page-sizes="[5, 10, 20]"
|
||||||
:total="total"
|
:total="total"
|
||||||
@current-change="onPageChange"
|
layout="prev, pager, next, sizes, total"
|
||||||
/>
|
small
|
||||||
|
@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>
|
||||||
|
|||||||
@@ -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,14 +102,18 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
<el-pagination
|
<div class="pagination-wrap" v-if="total > 0">
|
||||||
class="pagination"
|
<el-pagination
|
||||||
layout="prev, pager, next"
|
v-model:current-page="page"
|
||||||
:page-size="pageSize"
|
v-model:page-size="pageSize"
|
||||||
:current-page="page"
|
:page-sizes="[5, 10, 20]"
|
||||||
:total="total"
|
:total="total"
|
||||||
@current-change="onPageChange"
|
layout="prev, pager, next, sizes, total"
|
||||||
/>
|
small
|
||||||
|
@current-change="onPageChange"
|
||||||
|
@size-change="onPageSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
const { data } = await fetchAuditLogs(study.currentStudy.id, params);
|
while (true) {
|
||||||
const items = Array.isArray(data) ? data : (data as any).items || [];
|
params.skip = skip;
|
||||||
rawLogs.value = items;
|
const { data } = await fetchAuditLogs(study.currentStudy.id, params);
|
||||||
total.value = (data as any).total || items.length;
|
const items = Array.isArray(data) ? data : (data as any).items || [];
|
||||||
enrichLogs();
|
allItems.push(...items);
|
||||||
|
if (items.length < batchLimit) break;
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,15 +71,18 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
<el-pagination
|
<div class="pagination-wrap" v-if="total > 0">
|
||||||
background
|
<el-pagination
|
||||||
layout="prev, pager, next, total"
|
v-model:current-page="page"
|
||||||
:page-size="pageSize"
|
v-model:page-size="pageSize"
|
||||||
:total="total"
|
:page-sizes="[5, 10, 20]"
|
||||||
v-model:current-page="page"
|
:total="total"
|
||||||
@current-change="loadUsers"
|
layout="prev, pager, next, sizes, total"
|
||||||
class="pagination"
|
small
|
||||||
/>
|
@current-change="loadUsers"
|
||||||
|
@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" />
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user