修复前后端显示异常问题(受试者、中心、负责人、日期等)
This commit is contained in:
@@ -15,6 +15,7 @@ from app.crud import user as user_crud
|
|||||||
from app.crud import member as member_crud
|
from app.crud import member as member_crud
|
||||||
from app.core.security import decode_token
|
from app.core.security import decode_token
|
||||||
from app.schemas.attachment import AttachmentRead
|
from app.schemas.attachment import AttachmentRead
|
||||||
|
from app.schemas.user import UserDisplay
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
global_router = APIRouter()
|
global_router = APIRouter()
|
||||||
@@ -74,7 +75,14 @@ async def upload_attachment(
|
|||||||
operator_id=current_user.id,
|
operator_id=current_user.id,
|
||||||
operator_role=current_user.role,
|
operator_role=current_user.role,
|
||||||
)
|
)
|
||||||
return attachment
|
return AttachmentRead(
|
||||||
|
id=attachment.id,
|
||||||
|
filename=attachment.filename,
|
||||||
|
file_size=attachment.file_size,
|
||||||
|
uploaded_by=UserDisplay.model_validate(current_user) if current_user else None,
|
||||||
|
uploaded_by_id=attachment.uploaded_by,
|
||||||
|
uploaded_at=attachment.uploaded_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -90,7 +98,22 @@ async def list_attachments(
|
|||||||
) -> list[AttachmentRead]:
|
) -> list[AttachmentRead]:
|
||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
attachments = await attachment_crud.list_attachments(db, study_id, entity_type, entity_id)
|
attachments = await attachment_crud.list_attachments(db, study_id, entity_type, entity_id)
|
||||||
return list(attachments)
|
user_ids = {a.uploaded_by for a in attachments if a.uploaded_by}
|
||||||
|
users_map = await user_crud.get_users_by_ids(db, user_ids)
|
||||||
|
result: list[AttachmentRead] = []
|
||||||
|
for a in attachments:
|
||||||
|
user = users_map.get(a.uploaded_by)
|
||||||
|
result.append(
|
||||||
|
AttachmentRead(
|
||||||
|
id=a.id,
|
||||||
|
filename=a.filename,
|
||||||
|
file_size=a.file_size,
|
||||||
|
uploaded_by=UserDisplay.model_validate(user) if user else None,
|
||||||
|
uploaded_by_id=a.uploaded_by,
|
||||||
|
uploaded_at=a.uploaded_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.core.deps import get_db_session, require_study_member, require_study_roles
|
from app.core.deps import get_db_session, require_study_member, require_study_roles
|
||||||
from app.crud import member as member_crud
|
from app.crud import member as member_crud
|
||||||
from app.crud import study as study_crud
|
from app.crud import study as study_crud
|
||||||
from app.schemas.member import StudyMemberCreate, StudyMemberRead, StudyMemberUpdate
|
from app.crud import user as user_crud
|
||||||
|
from app.schemas.member import StudyMemberCreate, StudyMemberRead, StudyMemberReadWithUser, StudyMemberUpdate
|
||||||
|
from app.schemas.user import UserDisplay
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -39,7 +41,7 @@ async def add_member(
|
|||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/",
|
"/",
|
||||||
response_model=list[StudyMemberRead],
|
response_model=list[StudyMemberReadWithUser],
|
||||||
dependencies=[Depends(require_study_member())],
|
dependencies=[Depends(require_study_member())],
|
||||||
)
|
)
|
||||||
async def list_members(
|
async def list_members(
|
||||||
@@ -47,10 +49,31 @@ async def list_members(
|
|||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
) -> list[StudyMemberRead]:
|
) -> list[StudyMemberReadWithUser]:
|
||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
members = await member_crud.list_members(db, study_id, skip=skip, limit=limit)
|
members = await member_crud.list_members(db, study_id, skip=skip, limit=limit)
|
||||||
return list(members)
|
user_ids = {m.user_id for m in members}
|
||||||
|
users_map = await user_crud.get_users_by_ids(db, user_ids)
|
||||||
|
result: list[StudyMemberReadWithUser] = []
|
||||||
|
for m in members:
|
||||||
|
# 仅返回项目内启用的成员 + 账号启用的用户
|
||||||
|
if not m.is_active:
|
||||||
|
continue
|
||||||
|
user = users_map.get(m.user_id)
|
||||||
|
if not user or not user.is_active:
|
||||||
|
continue
|
||||||
|
result.append(
|
||||||
|
StudyMemberReadWithUser(
|
||||||
|
id=m.id,
|
||||||
|
study_id=m.study_id,
|
||||||
|
user_id=m.user_id,
|
||||||
|
role_in_study=m.role_in_study,
|
||||||
|
is_active=m.is_active,
|
||||||
|
added_at=m.added_at,
|
||||||
|
user=UserDisplay.model_validate(user) if user else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
@router.patch(
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ from app.core.deps import get_current_user, get_db_session, require_study_member
|
|||||||
from app.crud import audit as audit_crud
|
from app.crud import audit as audit_crud
|
||||||
from app.crud import milestone as milestone_crud
|
from app.crud import milestone as milestone_crud
|
||||||
from app.crud import study as study_crud
|
from app.crud import study as study_crud
|
||||||
|
from app.crud import user as user_crud
|
||||||
|
from app.crud import site as site_crud
|
||||||
from app.schemas.milestone import MilestoneCreate, MilestoneRead, MilestoneUpdate
|
from app.schemas.milestone import MilestoneCreate, MilestoneRead, MilestoneUpdate
|
||||||
|
from app.schemas.user import UserDisplay
|
||||||
|
from app.schemas.milestone import SiteDisplay
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -33,6 +37,12 @@ async def create_milestone(
|
|||||||
) -> MilestoneRead:
|
) -> MilestoneRead:
|
||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
milestone = await milestone_crud.create(db, study_id, milestone_in)
|
milestone = await milestone_crud.create(db, study_id, milestone_in)
|
||||||
|
owner = None
|
||||||
|
site = None
|
||||||
|
if milestone.owner_id:
|
||||||
|
owner = await user_crud.get_by_id(db, milestone.owner_id)
|
||||||
|
if milestone.site_id:
|
||||||
|
site = await site_crud.get_site(db, milestone.site_id)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
study_id=study_id,
|
study_id=study_id,
|
||||||
@@ -43,7 +53,22 @@ async def create_milestone(
|
|||||||
operator_id=current_user.id,
|
operator_id=current_user.id,
|
||||||
operator_role=current_user.role,
|
operator_role=current_user.role,
|
||||||
)
|
)
|
||||||
return milestone
|
return MilestoneRead(
|
||||||
|
id=milestone.id,
|
||||||
|
study_id=milestone.study_id,
|
||||||
|
type=milestone.type,
|
||||||
|
name=milestone.name,
|
||||||
|
planned_date=milestone.planned_date,
|
||||||
|
actual_date=milestone.actual_date,
|
||||||
|
status=milestone.status,
|
||||||
|
owner_id=milestone.owner_id,
|
||||||
|
site_id=milestone.site_id,
|
||||||
|
owner=UserDisplay.model_validate(owner) if owner else None,
|
||||||
|
site=SiteDisplay.model_validate(site) if site else None,
|
||||||
|
notes=milestone.notes,
|
||||||
|
created_at=milestone.created_at,
|
||||||
|
updated_at=milestone.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -57,7 +82,33 @@ async def list_milestones(
|
|||||||
) -> list[MilestoneRead]:
|
) -> list[MilestoneRead]:
|
||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
milestones = await milestone_crud.list_milestones(db, study_id)
|
milestones = await milestone_crud.list_milestones(db, study_id)
|
||||||
return list(milestones)
|
owner_ids = {m.owner_id for m in milestones if m.owner_id}
|
||||||
|
site_ids = {m.site_id for m in milestones if m.site_id}
|
||||||
|
users_map = await user_crud.get_users_by_ids(db, owner_ids)
|
||||||
|
sites_map = await site_crud.get_sites_by_ids(db, site_ids)
|
||||||
|
result: list[MilestoneRead] = []
|
||||||
|
for m in milestones:
|
||||||
|
owner = users_map.get(m.owner_id)
|
||||||
|
site = sites_map.get(m.site_id)
|
||||||
|
result.append(
|
||||||
|
MilestoneRead(
|
||||||
|
id=m.id,
|
||||||
|
study_id=m.study_id,
|
||||||
|
type=m.type,
|
||||||
|
name=m.name,
|
||||||
|
planned_date=m.planned_date,
|
||||||
|
actual_date=m.actual_date,
|
||||||
|
status=m.status,
|
||||||
|
owner_id=m.owner_id,
|
||||||
|
site_id=m.site_id,
|
||||||
|
owner=UserDisplay.model_validate(owner) if owner else None,
|
||||||
|
site=SiteDisplay.model_validate(site) if site else None,
|
||||||
|
notes=m.notes,
|
||||||
|
created_at=m.created_at,
|
||||||
|
updated_at=m.updated_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
@router.patch(
|
||||||
@@ -78,6 +129,12 @@ async def update_milestone(
|
|||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Milestone not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Milestone not found")
|
||||||
old_status = milestone.status
|
old_status = milestone.status
|
||||||
updated = await milestone_crud.update(db, milestone, milestone_in)
|
updated = await milestone_crud.update(db, milestone, milestone_in)
|
||||||
|
owner = None
|
||||||
|
site = None
|
||||||
|
if updated.owner_id:
|
||||||
|
owner = await user_crud.get_by_id(db, updated.owner_id)
|
||||||
|
if updated.site_id:
|
||||||
|
site = await site_crud.get_site(db, updated.site_id)
|
||||||
detail = None
|
detail = None
|
||||||
if milestone_in.status and milestone_in.status != old_status:
|
if milestone_in.status and milestone_in.status != old_status:
|
||||||
detail = f"milestone {milestone_id} status {old_status} -> {milestone_in.status}"
|
detail = f"milestone {milestone_id} status {old_status} -> {milestone_in.status}"
|
||||||
@@ -91,4 +148,19 @@ async def update_milestone(
|
|||||||
operator_id=current_user.id,
|
operator_id=current_user.id,
|
||||||
operator_role=current_user.role,
|
operator_role=current_user.role,
|
||||||
)
|
)
|
||||||
return updated
|
return MilestoneRead(
|
||||||
|
id=updated.id,
|
||||||
|
study_id=updated.study_id,
|
||||||
|
type=updated.type,
|
||||||
|
name=updated.name,
|
||||||
|
planned_date=updated.planned_date,
|
||||||
|
actual_date=updated.actual_date,
|
||||||
|
status=updated.status,
|
||||||
|
owner_id=updated.owner_id,
|
||||||
|
site_id=updated.site_id,
|
||||||
|
owner=UserDisplay.model_validate(owner) if owner else None,
|
||||||
|
site=SiteDisplay.model_validate(site) if site else None,
|
||||||
|
notes=updated.notes,
|
||||||
|
created_at=updated.created_at,
|
||||||
|
updated_at=updated.updated_at,
|
||||||
|
)
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ from app.crud import audit as audit_crud
|
|||||||
from app.crud import milestone as milestone_crud
|
from app.crud import milestone as milestone_crud
|
||||||
from app.crud import study as study_crud
|
from app.crud import study as study_crud
|
||||||
from app.crud import task as task_crud
|
from app.crud import task as task_crud
|
||||||
|
from app.crud import user as user_crud
|
||||||
from app.schemas.task import TaskCreate, TaskRead, TaskUpdate
|
from app.schemas.task import TaskCreate, TaskRead, TaskUpdate
|
||||||
|
from app.schemas.user import UserDisplay
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -47,6 +49,9 @@ async def create_task(
|
|||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
await _validate_milestone(db, study_id, task_in.milestone_id)
|
await _validate_milestone(db, study_id, task_in.milestone_id)
|
||||||
task = await task_crud.create(db, study_id, task_in, created_by=current_user.id)
|
task = await task_crud.create(db, study_id, task_in, created_by=current_user.id)
|
||||||
|
assignee = None
|
||||||
|
if task.assignee_id:
|
||||||
|
assignee = await user_crud.get_by_id(db, task.assignee_id)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
study_id=study_id,
|
study_id=study_id,
|
||||||
@@ -57,7 +62,7 @@ async def create_task(
|
|||||||
operator_id=current_user.id,
|
operator_id=current_user.id,
|
||||||
operator_role=current_user.role,
|
operator_role=current_user.role,
|
||||||
)
|
)
|
||||||
return task
|
return _to_task_read(task, assignee)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -74,7 +79,13 @@ async def list_tasks(
|
|||||||
) -> list[TaskRead]:
|
) -> list[TaskRead]:
|
||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
tasks = await task_crud.list_tasks(db, study_id, milestone_id=milestone_id, assignee_id=assignee_id, status=status)
|
tasks = await task_crud.list_tasks(db, study_id, milestone_id=milestone_id, assignee_id=assignee_id, status=status)
|
||||||
return list(tasks)
|
assignee_ids = {t.assignee_id for t in tasks if t.assignee_id}
|
||||||
|
users_map = await user_crud.get_users_by_ids(db, assignee_ids)
|
||||||
|
result: list[TaskRead] = []
|
||||||
|
for t in tasks:
|
||||||
|
user = users_map.get(t.assignee_id)
|
||||||
|
result.append(_to_task_read(t, user))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
@router.patch(
|
||||||
@@ -98,6 +109,9 @@ async def update_task(
|
|||||||
|
|
||||||
old_status = task.status
|
old_status = task.status
|
||||||
updated = await task_crud.update(db, task, task_in)
|
updated = await task_crud.update(db, task, task_in)
|
||||||
|
assignee = None
|
||||||
|
if updated.assignee_id:
|
||||||
|
assignee = await user_crud.get_by_id(db, updated.assignee_id)
|
||||||
detail = None
|
detail = None
|
||||||
if task_in.status and task_in.status != old_status:
|
if task_in.status and task_in.status != old_status:
|
||||||
detail = f"task {task_id} status {old_status} -> {task_in.status}"
|
detail = f"task {task_id} status {old_status} -> {task_in.status}"
|
||||||
@@ -111,4 +125,23 @@ async def update_task(
|
|||||||
operator_id=current_user.id,
|
operator_id=current_user.id,
|
||||||
operator_role=current_user.role,
|
operator_role=current_user.role,
|
||||||
)
|
)
|
||||||
return updated
|
return _to_task_read(updated, assignee)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_task_read(task, assignee) -> TaskRead:
|
||||||
|
return TaskRead(
|
||||||
|
id=task.id,
|
||||||
|
study_id=task.study_id,
|
||||||
|
milestone_id=task.milestone_id,
|
||||||
|
title=task.title,
|
||||||
|
description=task.description,
|
||||||
|
assignee_id=task.assignee_id,
|
||||||
|
assignee=UserDisplay.model_validate(assignee) if assignee else None,
|
||||||
|
priority=task.priority,
|
||||||
|
due_date=task.due_date,
|
||||||
|
status=task.status,
|
||||||
|
completed_at=task.completed_at,
|
||||||
|
created_by=task.created_by,
|
||||||
|
created_at=task.created_at,
|
||||||
|
updated_at=task.updated_at,
|
||||||
|
)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ async def create(db: AsyncSession, study_id: uuid.UUID, milestone_in: MilestoneC
|
|||||||
actual_date=None,
|
actual_date=None,
|
||||||
status=milestone_in.status or "NOT_STARTED",
|
status=milestone_in.status or "NOT_STARTED",
|
||||||
owner_id=milestone_in.owner_id,
|
owner_id=milestone_in.owner_id,
|
||||||
|
site_id=milestone_in.site_id,
|
||||||
notes=milestone_in.notes,
|
notes=milestone_in.notes,
|
||||||
)
|
)
|
||||||
db.add(milestone)
|
db.add(milestone)
|
||||||
|
|||||||
@@ -46,3 +46,11 @@ async def list_by_study(db: AsyncSession, study_id: uuid.UUID, skip: int = 0, li
|
|||||||
select(Site).where(Site.study_id == study_id).offset(skip).limit(limit)
|
select(Site).where(Site.study_id == study_id).offset(skip).limit(limit)
|
||||||
)
|
)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_sites_by_ids(db: AsyncSession, ids: set[uuid.UUID]) -> dict[uuid.UUID, Site]:
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
result = await db.execute(select(Site).where(Site.id.in_(ids)))
|
||||||
|
sites = result.scalars().all()
|
||||||
|
return {s.id: s for s in sites}
|
||||||
|
|||||||
@@ -73,6 +73,14 @@ async def ensure_admin_exists(db: AsyncSession, *, default_password: str = "admi
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_users_by_ids(db: AsyncSession, ids: set[uuid.UUID]) -> dict[uuid.UUID, User]:
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
result = await db.execute(select(User).where(User.id.in_(ids)))
|
||||||
|
users = result.scalars().all()
|
||||||
|
return {u.id: u for u in users}
|
||||||
|
|
||||||
|
|
||||||
async def delete_user(db: AsyncSession, user: User) -> None:
|
async def delete_user(db: AsyncSession, user: User) -> None:
|
||||||
await db.execute(delete(StudyMember).where(StudyMember.user_id == user.id))
|
await db.execute(delete(StudyMember).where(StudyMember.user_id == user.id))
|
||||||
await db.delete(user)
|
await db.delete(user)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class Milestone(Base):
|
|||||||
planned_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
planned_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
actual_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
actual_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="NOT_STARTED")
|
status: Mapped[str] = mapped_column(String(20), nullable=False, default="NOT_STARTED")
|
||||||
|
site_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), nullable=True)
|
||||||
owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from app.schemas.user import UserDisplay
|
||||||
|
|
||||||
|
|
||||||
class AttachmentRead(BaseModel):
|
class AttachmentRead(BaseModel):
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
filename: str
|
filename: str
|
||||||
file_size: int
|
file_size: int = Field(alias="size")
|
||||||
uploaded_by: uuid.UUID
|
uploaded_by: UserDisplay | None = None
|
||||||
|
uploaded_by_id: uuid.UUID | None = None
|
||||||
uploaded_at: datetime
|
uploaded_at: datetime
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from datetime import datetime
|
|||||||
from typing import Literal, Optional
|
from typing import Literal, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
from app.schemas.user import UserDisplay
|
||||||
|
|
||||||
StudyRole = Literal["PM", "CRA", "PV", "IMP", "ADMIN"]
|
StudyRole = Literal["PM", "CRA", "PV", "IMP", "ADMIN"]
|
||||||
|
|
||||||
@@ -27,3 +28,7 @@ class StudyMemberRead(BaseModel):
|
|||||||
added_at: datetime
|
added_at: datetime
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class StudyMemberReadWithUser(StudyMemberRead):
|
||||||
|
user: Optional[UserDisplay] = None
|
||||||
|
|||||||
@@ -4,12 +4,21 @@ from typing import Optional
|
|||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from app.schemas.user import UserDisplay
|
||||||
|
|
||||||
|
|
||||||
|
class SiteDisplay(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
name: Optional[str] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
class MilestoneCreate(BaseModel):
|
class MilestoneCreate(BaseModel):
|
||||||
type: str
|
type: str
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
planned_date: Optional[date] = None
|
planned_date: Optional[date] = None
|
||||||
owner_id: Optional[uuid.UUID] = None
|
owner_id: Optional[uuid.UUID] = None
|
||||||
|
site_id: Optional[uuid.UUID] = None
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
status: str = Field(default="NOT_STARTED")
|
status: str = Field(default="NOT_STARTED")
|
||||||
|
|
||||||
@@ -20,6 +29,7 @@ class MilestoneUpdate(BaseModel):
|
|||||||
actual_date: Optional[date] = None
|
actual_date: Optional[date] = None
|
||||||
status: Optional[str] = None
|
status: Optional[str] = None
|
||||||
owner_id: Optional[uuid.UUID] = None
|
owner_id: Optional[uuid.UUID] = None
|
||||||
|
site_id: Optional[uuid.UUID] = None
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@@ -32,6 +42,9 @@ class MilestoneRead(BaseModel):
|
|||||||
actual_date: Optional[date]
|
actual_date: Optional[date]
|
||||||
status: str
|
status: str
|
||||||
owner_id: Optional[uuid.UUID]
|
owner_id: Optional[uuid.UUID]
|
||||||
|
site_id: Optional[uuid.UUID] = None
|
||||||
|
owner: Optional[UserDisplay] = None
|
||||||
|
site: Optional[SiteDisplay] = None
|
||||||
notes: Optional[str]
|
notes: Optional[str]
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ from typing import Optional
|
|||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
from app.schemas.user import UserDisplay
|
||||||
|
|
||||||
|
|
||||||
class TaskCreate(BaseModel):
|
class TaskCreate(BaseModel):
|
||||||
milestone_id: Optional[uuid.UUID] = None
|
milestone_id: Optional[uuid.UUID] = None
|
||||||
@@ -31,6 +33,7 @@ class TaskRead(BaseModel):
|
|||||||
title: str
|
title: str
|
||||||
description: Optional[str]
|
description: Optional[str]
|
||||||
assignee_id: Optional[uuid.UUID]
|
assignee_id: Optional[uuid.UUID]
|
||||||
|
assignee: Optional[UserDisplay] = None
|
||||||
priority: str
|
priority: str
|
||||||
due_date: Optional[date]
|
due_date: Optional[date]
|
||||||
status: str
|
status: str
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ from pydantic import BaseModel, ConfigDict, Field
|
|||||||
UserRole = Literal["PM", "CRA", "PV", "IMP", "ADMIN"]
|
UserRole = Literal["PM", "CRA", "PV", "IMP", "ADMIN"]
|
||||||
|
|
||||||
|
|
||||||
|
class UserDisplay(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
username: str
|
||||||
|
display_name: Optional[str] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
class UserBase(BaseModel):
|
class UserBase(BaseModel):
|
||||||
username: str = Field(min_length=1)
|
username: str = Field(min_length=1)
|
||||||
role: UserRole
|
role: UserRole
|
||||||
|
|||||||
BIN
Binary file not shown.
@@ -14,9 +14,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-timeline>
|
<el-timeline>
|
||||||
<el-timeline-item v-for="c in comments" :key="c.id" :timestamp="c.created_at">
|
<el-timeline-item v-for="c in comments" :key="c.id" :timestamp="displayDateTime(c.created_at)">
|
||||||
<div class="comment-item">
|
<div class="comment-item">
|
||||||
<strong>{{ c.created_by }}</strong>
|
<strong>{{ displayUser(c.created_by, { users: userMap, members: memberMap }) }}</strong>
|
||||||
<p>{{ c.content }}</p>
|
<p>{{ c.content }}</p>
|
||||||
</div>
|
</div>
|
||||||
</el-timeline-item>
|
</el-timeline-item>
|
||||||
@@ -25,10 +25,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import { fetchComments, createComment } from "../api/comments";
|
import { fetchComments, createComment } from "../api/comments";
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
|
import { listMembers } from "../api/members";
|
||||||
|
import { displayDateTime, displayUser } from "../utils/display";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
studyId: string;
|
studyId: string;
|
||||||
@@ -43,6 +45,7 @@ const loading = ref(false);
|
|||||||
const newComment = ref("");
|
const newComment = ref("");
|
||||||
const showInput = ref(false);
|
const showInput = ref(false);
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
|
const members = ref<any[]>([]);
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
if (!props.studyId) return;
|
if (!props.studyId) return;
|
||||||
@@ -57,6 +60,24 @@ const load = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadMembers = async () => {
|
||||||
|
if (!props.studyId) return;
|
||||||
|
try {
|
||||||
|
const { data } = await listMembers(props.studyId, { limit: 500 });
|
||||||
|
members.value = Array.isArray(data) ? data : data.items || [];
|
||||||
|
} catch {
|
||||||
|
members.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const memberMap = computed(() =>
|
||||||
|
members.value.reduce<Record<string, string>>((acc, cur) => {
|
||||||
|
const username = cur?.user?.display_name || cur?.user?.username || cur?.username;
|
||||||
|
if (cur?.user_id) acc[cur.user_id] = username || cur.user_id;
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
|
);
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!newComment.value.trim()) {
|
if (!newComment.value.trim()) {
|
||||||
ElMessage.warning("请输入评论");
|
ElMessage.warning("请输入评论");
|
||||||
@@ -77,7 +98,10 @@ const submit = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => load());
|
onMounted(() => {
|
||||||
|
loadMembers();
|
||||||
|
load();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -12,7 +12,9 @@
|
|||||||
<el-tag :type="scope.row.is_active ? 'success' : 'info'">{{ scope.row.is_active ? "是" : "否" }}</el-tag>
|
<el-tag :type="scope.row.is_active ? 'success' : 'info'">{{ scope.row.is_active ? "是" : "否" }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="updated_at" label="更新时间" width="180" />
|
<el-table-column prop="updated_at" label="更新时间" width="180">
|
||||||
|
<template #default="scope">{{ displayDateTime(scope.row.updated_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="180">
|
<el-table-column label="操作" width="180">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button type="text" size="small" @click="view(scope.row)">查看</el-button>
|
<el-button type="text" size="small" @click="view(scope.row)">查看</el-button>
|
||||||
@@ -41,6 +43,7 @@ import { useRouter } from "vue-router";
|
|||||||
import { updateFaqItem } from "../api/faqs";
|
import { updateFaqItem } from "../api/faqs";
|
||||||
import type { FaqItem, FaqCategory } from "../api/faqs";
|
import type { FaqItem, FaqCategory } from "../api/faqs";
|
||||||
import PermissionAction from "./PermissionAction.vue";
|
import PermissionAction from "./PermissionAction.vue";
|
||||||
|
import { displayDateTime } from "../utils/display";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
items: FaqItem[];
|
items: FaqItem[];
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="中心" prop="site_id">
|
<el-form-item label="中心" prop="site_id">
|
||||||
<el-select v-model="form.site_id" placeholder="请选择中心" filterable>
|
<el-select v-model="form.site_id" placeholder="请选择中心" filterable>
|
||||||
<el-option v-for="s in sites" :key="s.id" :label="s.name || s.id" :value="s.id" />
|
<el-option v-for="s in siteOptions" :key="s.value" :label="s.label" :value="s.value" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="负责人" prop="owner_id">
|
<el-form-item label="负责人" prop="owner_id">
|
||||||
@@ -54,7 +54,6 @@ interface Props {
|
|||||||
milestone?: Record<string, any>;
|
milestone?: Record<string, any>;
|
||||||
sites?: any[];
|
sites?: any[];
|
||||||
members?: any[];
|
members?: any[];
|
||||||
users?: any[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
@@ -94,23 +93,28 @@ const submitting = ref(false);
|
|||||||
const isEdit = computed(() => !!props.milestone);
|
const isEdit = computed(() => !!props.milestone);
|
||||||
|
|
||||||
const memberOptions = computed(() => {
|
const memberOptions = computed(() => {
|
||||||
const userMap = (props.users || []).reduce<Record<string, string>>((acc, cur: any) => {
|
|
||||||
if (cur?.id) acc[cur.id] = cur.username || cur.id;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
const memberList = (props.members || []).filter((m: any) => m.is_active !== false);
|
const memberList = (props.members || []).filter((m: any) => m.is_active !== false);
|
||||||
if (memberList.length) {
|
const seen = new Set<string>();
|
||||||
return memberList.map((m: any) => ({
|
return memberList
|
||||||
value: m.user_id,
|
.map((m: any) => {
|
||||||
label: userMap[m.user_id] || m.username || m.user_id,
|
const user = m.user || {};
|
||||||
}));
|
const label = user.display_name || user.username || m.username || "—";
|
||||||
}
|
return { value: m.user_id, label };
|
||||||
return (props.users || []).map((u: any) => ({
|
})
|
||||||
value: u.id,
|
.filter((opt) => {
|
||||||
label: u.username || u.id,
|
if (seen.has(opt.value)) return false;
|
||||||
}));
|
seen.add(opt.value);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const siteOptions = computed(() =>
|
||||||
|
(props.sites || []).map((s: any) => ({
|
||||||
|
value: s.id,
|
||||||
|
label: s.name || "—",
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.milestone,
|
() => props.milestone,
|
||||||
(val) => {
|
(val) => {
|
||||||
|
|||||||
@@ -64,10 +64,9 @@ const onClose = () => {
|
|||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
const studyId = study.currentStudy?.id;
|
const studyId = study.currentStudy?.id;
|
||||||
const recentSite = studyId ? localStorage.getItem(`recent_site_${studyId}`) : null;
|
const recentSite = studyId ? localStorage.getItem(`recent_site_${studyId}`) : null;
|
||||||
const recentSubject = studyId ? localStorage.getItem(`recent_subject_${studyId}`) : null;
|
|
||||||
form.site_id = recentSite || "";
|
form.site_id = recentSite || "";
|
||||||
form.subject_no = recentSubject || "";
|
form.subject_no = "";
|
||||||
form.screening_date = new Date().toISOString().slice(0, 10);
|
form.screening_date = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
@@ -84,7 +83,6 @@ const onSubmit = async () => {
|
|||||||
ElMessage.success("提交成功");
|
ElMessage.success("提交成功");
|
||||||
if (study.currentStudy?.id) {
|
if (study.currentStudy?.id) {
|
||||||
localStorage.setItem(`recent_site_${study.currentStudy.id}`, form.site_id || "");
|
localStorage.setItem(`recent_site_${study.currentStudy.id}`, form.site_id || "");
|
||||||
if (data?.id) localStorage.setItem(`recent_subject_${study.currentStudy.id}`, data.id);
|
|
||||||
}
|
}
|
||||||
emit("success");
|
emit("success");
|
||||||
if (keepCreating.value) {
|
if (keepCreating.value) {
|
||||||
|
|||||||
@@ -13,7 +13,9 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="负责人" prop="assignee_id">
|
<el-form-item label="负责人" prop="assignee_id">
|
||||||
<UserSelect v-model="form.assignee_id" placeholder="选择负责人" />
|
<el-select v-model="form.assignee_id" placeholder="选择负责人" filterable clearable>
|
||||||
|
<el-option v-for="m in assigneeOptions" :key="m.value" :label="m.label" :value="m.value" />
|
||||||
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="优先级" prop="priority">
|
<el-form-item label="优先级" prop="priority">
|
||||||
<el-select v-model="form.priority" placeholder="请选择">
|
<el-select v-model="form.priority" placeholder="请选择">
|
||||||
@@ -42,12 +44,12 @@ import type { FormInstance, FormRules } from "element-plus";
|
|||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import { createTask, updateTask } from "../api/tasks";
|
import { createTask, updateTask } from "../api/tasks";
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
import UserSelect from "./selectors/UserSelect.vue";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
modelValue: boolean;
|
modelValue: boolean;
|
||||||
task?: Record<string, any>;
|
task?: Record<string, any>;
|
||||||
milestones: any[];
|
milestones: any[];
|
||||||
|
members?: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
@@ -89,6 +91,20 @@ const rules: FormRules = {
|
|||||||
|
|
||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
const isEdit = computed(() => !!props.task);
|
const isEdit = computed(() => !!props.task);
|
||||||
|
const assigneeOptions = computed(() => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return (props.members || [])
|
||||||
|
.filter((m: any) => m.is_active !== false)
|
||||||
|
.map((m: any) => {
|
||||||
|
const user = m.user || {};
|
||||||
|
return { value: m.user_id, label: user.display_name || user.username || m.username || "—" };
|
||||||
|
})
|
||||||
|
.filter((opt) => {
|
||||||
|
if (seen.has(opt.value)) return false;
|
||||||
|
seen.add(opt.value);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.task,
|
() => props.task,
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-table :data="verifications" v-loading="loading" style="width: 100%">
|
<el-table :data="verifications" v-loading="loading" style="width: 100%">
|
||||||
<el-table-column prop="subject_id" label="受试者ID" />
|
<el-table-column prop="subject_id" label="受试者">
|
||||||
|
<template #default="scope">
|
||||||
|
{{ subjectMap[scope.row.subject_id] || scope.row.subject_no || scope.row.subject_id || "-" }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="level" label="类型" width="100" />
|
<el-table-column prop="level" label="类型" width="100" />
|
||||||
<el-table-column label="完成度" width="180">
|
<el-table-column label="完成度" width="180">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-progress :percentage="scope.row.percent || 0" :stroke-width="12" />
|
<el-progress :percentage="scope.row.percent || 0" :stroke-width="12" />
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="last_verified_at" label="最近核查" width="140" />
|
<el-table-column prop="last_verified_at" label="最近核查" width="140">
|
||||||
<el-table-column prop="verifier_id" label="核查人" width="160" />
|
<template #default="scope">{{ displayDate(scope.row.last_verified_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="verifier_id" label="核查人" width="160">
|
||||||
|
<template #default="scope">
|
||||||
|
{{ verifierMap[scope.row.verifier_id] || "-" }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column v-if="canEdit" label="操作" width="120">
|
<el-table-column v-if="canEdit" label="操作" width="120">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button type="primary" link size="small" @click="$emit('edit', scope.row)">更新进度</el-button>
|
<el-button type="primary" link size="small" @click="$emit('edit', scope.row)">更新进度</el-button>
|
||||||
@@ -18,10 +28,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { displayDate } from "../utils/display";
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
verifications: any[];
|
verifications: any[];
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
subjectMap: Record<string, string>;
|
||||||
|
verifierMap: Record<string, string>;
|
||||||
}>();
|
}>();
|
||||||
defineEmits(["edit"]);
|
defineEmits(["edit"]);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -12,14 +12,18 @@
|
|||||||
<el-table :data="attachments" v-loading="loading" style="width: 100%">
|
<el-table :data="attachments" v-loading="loading" style="width: 100%">
|
||||||
<el-table-column prop="filename" label="文件名" min-width="200" />
|
<el-table-column prop="filename" label="文件名" min-width="200" />
|
||||||
<el-table-column label="大小" width="120">
|
<el-table-column label="大小" width="120">
|
||||||
<template #default="scope">{{ formatFileSize(scope.row.file_size) }}</template>
|
<template #default="scope">{{ formatFileSize(scope.row.file_size ?? scope.row.size) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="上传人" width="180">
|
<el-table-column label="上传人" width="180">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ uploaderLabel(scope.row) }}
|
{{ uploaderLabel(scope.row) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="uploaded_at" label="上传时间" width="180" />
|
<el-table-column prop="uploaded_at" label="上传时间" width="180">
|
||||||
|
<template #default="scope">
|
||||||
|
{{ displayDateTime(scope.row.uploaded_at) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="180">
|
<el-table-column label="操作" width="180">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button link type="primary" size="small" @click="download(scope.row)">下载</el-button>
|
<el-button link type="primary" size="small" @click="download(scope.row)">下载</el-button>
|
||||||
@@ -46,8 +50,8 @@ import AttachmentUploader from "./AttachmentUploader.vue";
|
|||||||
import { formatFileSize } from "./attachmentUtils";
|
import { formatFileSize } from "./attachmentUtils";
|
||||||
import { useAuthStore } from "../../store/auth";
|
import { useAuthStore } from "../../store/auth";
|
||||||
import { useStudyStore } from "../../store/study";
|
import { useStudyStore } from "../../store/study";
|
||||||
import { fetchUsers } from "../../api/users";
|
|
||||||
import { listMembers } from "../../api/members";
|
import { listMembers } from "../../api/members";
|
||||||
|
import { displayDateTime, displayUser } from "../../utils/display";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
studyId: string;
|
studyId: string;
|
||||||
@@ -59,7 +63,6 @@ const attachments = ref<any[]>([]);
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
const users = ref<any[]>([]);
|
|
||||||
const members = ref<any[]>([]);
|
const members = ref<any[]>([]);
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
@@ -75,15 +78,6 @@ const load = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadUsers = async () => {
|
|
||||||
try {
|
|
||||||
const { data } = await fetchUsers({ limit: 500 });
|
|
||||||
users.value = (data as any).items || data || [];
|
|
||||||
} catch {
|
|
||||||
users.value = [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadMembers = async () => {
|
const loadMembers = async () => {
|
||||||
if (!props.studyId) return;
|
if (!props.studyId) return;
|
||||||
try {
|
try {
|
||||||
@@ -95,15 +89,15 @@ const loadMembers = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const uploaderLabel = (row: any) => {
|
const uploaderLabel = (row: any) => {
|
||||||
const userMap = users.value.reduce<Record<string, string>>((acc, cur) => {
|
|
||||||
if (cur?.id) acc[cur.id] = cur.username || cur.id;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
const memberMap = members.value.reduce<Record<string, string>>((acc, cur) => {
|
const memberMap = members.value.reduce<Record<string, string>>((acc, cur) => {
|
||||||
if (cur?.user_id) acc[cur.user_id] = cur.username || cur.user_id;
|
const username = cur?.user?.display_name || cur?.user?.username || cur?.username;
|
||||||
|
if (cur?.user_id) acc[cur.user_id] = username || cur.user_id;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
return userMap[row.uploaded_by] || memberMap[row.uploaded_by] || row.uploaded_by || "-";
|
if (row?.uploaded_by && typeof row.uploaded_by === "object") {
|
||||||
|
return row.uploaded_by.display_name || row.uploaded_by.username || row.uploaded_by.id || "—";
|
||||||
|
}
|
||||||
|
return displayUser(row.uploaded_by_id || row.uploaded_by, { members: memberMap });
|
||||||
};
|
};
|
||||||
|
|
||||||
const download = (row: any) => {
|
const download = (row: any) => {
|
||||||
@@ -116,7 +110,9 @@ const canDelete = (row: any) => {
|
|||||||
const userId = auth.user?.id;
|
const userId = auth.user?.id;
|
||||||
const role = auth.user?.role;
|
const role = auth.user?.role;
|
||||||
const projectRole = study.currentStudyRole || (study.currentStudy as any)?.role_in_study;
|
const projectRole = study.currentStudyRole || (study.currentStudy as any)?.role_in_study;
|
||||||
return userId === row.uploaded_by || role === "ADMIN" || projectRole === "PM";
|
const ownerId =
|
||||||
|
row.uploaded_by_id || (row.uploaded_by && typeof row.uploaded_by === "object" ? row.uploaded_by.id : row.uploaded_by);
|
||||||
|
return userId === ownerId || role === "ADMIN" || projectRole === "PM";
|
||||||
};
|
};
|
||||||
|
|
||||||
const remove = async (row: any) => {
|
const remove = async (row: any) => {
|
||||||
@@ -132,7 +128,7 @@ const remove = async (row: any) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadUsers(), loadMembers()]);
|
await Promise.all([loadMembers()]);
|
||||||
load();
|
load();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* 统一的显示适配层,避免直接暴露 ID / 枚举原值 / 原始时间。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const displayFallback = "—";
|
||||||
|
|
||||||
|
export const displayEnum = (enumMap: Record<string, string>, value?: string | null) => {
|
||||||
|
if (!value) return displayFallback;
|
||||||
|
return enumMap[value] || displayFallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const displayUser = (
|
||||||
|
userId?: string | null,
|
||||||
|
opts?: { users?: Record<string, string>; members?: Record<string, string> }
|
||||||
|
) => {
|
||||||
|
if (!userId) return displayFallback;
|
||||||
|
const name =
|
||||||
|
opts?.users?.[userId] ||
|
||||||
|
opts?.members?.[userId] ||
|
||||||
|
// 兼容成员数据的 user_id 为 key 的情况
|
||||||
|
(opts?.members && Object.values(opts.members).find((_, k) => k === userId));
|
||||||
|
return (name as string) || displayFallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const displayEntity = (entityMap: Record<string, string>, id?: string | null) => {
|
||||||
|
if (!id) return displayFallback;
|
||||||
|
return entityMap[id] || displayFallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const displayDate = (value?: string | number | Date | null) => {
|
||||||
|
if (!value) return displayFallback;
|
||||||
|
const date = new Date(value);
|
||||||
|
if (isNaN(date.getTime())) return displayFallback;
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const displayDateTime = (value?: string | number | Date | null) => {
|
||||||
|
if (!value) return displayFallback;
|
||||||
|
const date = new Date(value);
|
||||||
|
if (isNaN(date.getTime())) return displayFallback;
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(
|
||||||
|
date.getMinutes()
|
||||||
|
)}`;
|
||||||
|
};
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-descriptions :column="2" border>
|
<el-descriptions :column="2" border>
|
||||||
<el-descriptions-item label="受试者ID">{{ ae.subject_id }}</el-descriptions-item>
|
<el-descriptions-item label="受试者">{{ subjectName(ae.subject_id) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="严重性">{{ ae.seriousness }}</el-descriptions-item>
|
<el-descriptions-item label="严重性">{{ ae.seriousness }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="严重程度">{{ ae.severity }}</el-descriptions-item>
|
<el-descriptions-item label="严重程度">{{ ae.severity }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="发生日期">{{ ae.onset_date }}</el-descriptions-item>
|
<el-descriptions-item label="发生日期">{{ ae.onset_date }}</el-descriptions-item>
|
||||||
@@ -56,6 +56,7 @@ import { computed, onMounted, ref } from "vue";
|
|||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { fetchAes, updateAe } from "../api/aes";
|
import { fetchAes, updateAe } from "../api/aes";
|
||||||
|
import { fetchSubjects } from "../api/subjects";
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
import CommentList from "../components/CommentList.vue";
|
import CommentList from "../components/CommentList.vue";
|
||||||
@@ -74,6 +75,7 @@ const auth = useAuthStore();
|
|||||||
|
|
||||||
const ae = ref<any | null>(null);
|
const ae = ref<any | null>(null);
|
||||||
const studyId = computed(() => study.currentStudy?.id || "");
|
const studyId = computed(() => study.currentStudy?.id || "");
|
||||||
|
const subjects = ref<any[]>([]);
|
||||||
|
|
||||||
const { can } = usePermission();
|
const { can } = usePermission();
|
||||||
const aeState = computed(() => (ae.value?.status as string) || "NEW");
|
const aeState = computed(() => (ae.value?.status as string) || "NEW");
|
||||||
@@ -102,6 +104,30 @@ const load = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadSubjects = async () => {
|
||||||
|
if (!study.currentStudy) return;
|
||||||
|
try {
|
||||||
|
const { data } = await fetchSubjects(study.currentStudy.id, { skip: 0, limit: 500 });
|
||||||
|
subjects.value = data.items || data || [];
|
||||||
|
} catch {
|
||||||
|
subjects.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const subjectLabel = computed(() => {
|
||||||
|
const map = subjects.value.reduce<Record<string, string>>((acc, cur) => {
|
||||||
|
if (cur?.id) acc[cur.id] = cur.subject_no || cur.id;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
const subjectName = (id?: string | null) => {
|
||||||
|
if (!id) return "—";
|
||||||
|
return subjectLabel.value[id] || id;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
const onAction = async (action: ActionConfig) => {
|
const onAction = async (action: ActionConfig) => {
|
||||||
if (!study.currentStudy || !ae.value) return;
|
if (!study.currentStudy || !ae.value) return;
|
||||||
const prevStatus = ae.value.status;
|
const prevStatus = ae.value.status;
|
||||||
@@ -167,6 +193,7 @@ onMounted(async () => {
|
|||||||
if (!auth.user && auth.token) {
|
if (!auth.user && auth.token) {
|
||||||
await auth.fetchMe().catch(() => {});
|
await auth.fetchMe().catch(() => {});
|
||||||
}
|
}
|
||||||
|
await loadSubjects();
|
||||||
load();
|
load();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -44,15 +44,23 @@
|
|||||||
¥{{ formatAmount(scope.row.amount) }}
|
¥{{ formatAmount(scope.row.amount) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="occur_date" label="发生日期" width="140" />
|
<el-table-column prop="occur_date" label="发生日期" width="140">
|
||||||
|
<template #default="scope">{{ displayDate(scope.row.occur_date) }}</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="status" label="状态" width="120">
|
<el-table-column prop="status" label="状态" width="120">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-tag :type="statusTag(scope.row.status)">{{ statusLabel(scope.row.status) }}</el-tag>
|
<el-tag :type="statusTag(scope.row.status)">{{ statusLabel(scope.row.status) }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="submitted_at" label="提交时间" width="160" />
|
<el-table-column prop="submitted_at" label="提交时间" width="180">
|
||||||
<el-table-column prop="approved_at" label="审批时间" width="160" />
|
<template #default="scope">{{ displayDateTime(scope.row.submitted_at) }}</template>
|
||||||
<el-table-column prop="paid_at" label="支付时间" width="160" />
|
</el-table-column>
|
||||||
|
<el-table-column prop="approved_at" label="审批时间" width="180">
|
||||||
|
<template #default="scope">{{ displayDateTime(scope.row.approved_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="paid_at" label="支付时间" width="180">
|
||||||
|
<template #default="scope">{{ displayDateTime(scope.row.paid_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="220">
|
<el-table-column label="操作" width="220">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button
|
<el-button
|
||||||
@@ -102,6 +110,7 @@ import { usePermission } from "../utils/permission";
|
|||||||
import { financeMachine, getAvailableActions } from "../state-machine";
|
import { financeMachine, getAvailableActions } from "../state-machine";
|
||||||
import { evaluateAction } from "../guards/actionGuard";
|
import { evaluateAction } from "../guards/actionGuard";
|
||||||
import { logAudit } from "../audit";
|
import { logAudit } from "../audit";
|
||||||
|
import { displayDate, displayDateTime } from "../utils/display";
|
||||||
|
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
|||||||
@@ -14,14 +14,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-descriptions :column="2" border class="mt-12" v-if="item">
|
<el-descriptions :column="2" border class="mt-12" v-if="item">
|
||||||
<el-descriptions-item label="类别">{{ item.category }}</el-descriptions-item>
|
<el-descriptions-item label="类别">{{ categoryLabel(item.category) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="金额">¥{{ formatAmount(item.amount) }} {{ item.currency }}</el-descriptions-item>
|
<el-descriptions-item label="金额">¥{{ formatAmount(item.amount) }} {{ item.currency }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="发生日期">{{ item.occur_date }}</el-descriptions-item>
|
<el-descriptions-item label="发生日期">{{ displayDate(item.occur_date) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="中心">{{ item.site_id || "-" }}</el-descriptions-item>
|
<el-descriptions-item label="中心">{{ siteMap[item.site_id] || "-" }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="受试者">{{ item.subject_id || "-" }}</el-descriptions-item>
|
<el-descriptions-item label="受试者">{{ subjectMap[item.subject_id] || "-" }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="状态时间">
|
<el-descriptions-item label="状态时间">
|
||||||
提交: {{ item.submitted_at || "-" }} / 审批: {{ item.approved_at || "-" }} /
|
提交: {{ displayDateTime(item.submitted_at) }} / 审批: {{ displayDateTime(item.approved_at) }} /
|
||||||
支付: {{ item.paid_at || "-" }}
|
支付: {{ displayDateTime(item.paid_at) }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="驳回原因">{{ item.reject_reason || "-" }}</el-descriptions-item>
|
<el-descriptions-item label="驳回原因">{{ item.reject_reason || "-" }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="描述">{{ item.description || "-" }}</el-descriptions-item>
|
<el-descriptions-item label="描述">{{ item.description || "-" }}</el-descriptions-item>
|
||||||
@@ -50,11 +50,14 @@ import FinanceForm from "../components/FinanceForm.vue";
|
|||||||
import PermissionAction from "../components/PermissionAction.vue";
|
import PermissionAction from "../components/PermissionAction.vue";
|
||||||
import AttachmentList from "../components/attachments/AttachmentList.vue";
|
import AttachmentList from "../components/attachments/AttachmentList.vue";
|
||||||
import { fetchFinanceItem } from "../api/finance";
|
import { fetchFinanceItem } from "../api/finance";
|
||||||
|
import { fetchSites } from "../api/sites";
|
||||||
|
import { fetchSubjects } from "../api/subjects";
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
import { usePermission } from "../utils/permission";
|
import { usePermission } from "../utils/permission";
|
||||||
import { financeMachine, getAvailableActions } from "../state-machine";
|
import { financeMachine, getAvailableActions } from "../state-machine";
|
||||||
import { financeStatusDict, getDictColor, getDictLabel } from "../dictionaries";
|
import { financeStatusDict, getDictColor, getDictLabel } from "../dictionaries";
|
||||||
|
import { displayDate, displayDateTime } from "../utils/display";
|
||||||
|
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
@@ -63,6 +66,8 @@ const route = useRoute();
|
|||||||
const item = ref<any>(null);
|
const item = ref<any>(null);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const showEdit = ref(false);
|
const showEdit = ref(false);
|
||||||
|
const sites = ref<any[]>([]);
|
||||||
|
const subjects = ref<any[]>([]);
|
||||||
const { can } = usePermission();
|
const { can } = usePermission();
|
||||||
const canEditDraft = computed(() => {
|
const canEditDraft = computed(() => {
|
||||||
const actions = getAvailableActions(financeMachine, item.value?.status);
|
const actions = getAvailableActions(financeMachine, item.value?.status);
|
||||||
@@ -84,8 +89,50 @@ const loadItem = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadSites = async () => {
|
||||||
|
if (!study.currentStudy) return;
|
||||||
|
try {
|
||||||
|
const { data } = await fetchSites(study.currentStudy.id, { limit: 500 });
|
||||||
|
sites.value = (data as any).items || data || [];
|
||||||
|
} catch {
|
||||||
|
sites.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSubjects = async () => {
|
||||||
|
if (!study.currentStudy) return;
|
||||||
|
try {
|
||||||
|
const { data } = await fetchSubjects(study.currentStudy.id, { limit: 500 });
|
||||||
|
subjects.value = data.items || data || [];
|
||||||
|
} catch {
|
||||||
|
subjects.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const siteMap = computed(() =>
|
||||||
|
sites.value.reduce<Record<string, string>>((acc, cur) => {
|
||||||
|
if (cur?.id) acc[cur.id] = cur.name || cur.id;
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
|
);
|
||||||
|
|
||||||
|
const subjectMap = computed(() =>
|
||||||
|
subjects.value.reduce<Record<string, string>>((acc, cur) => {
|
||||||
|
if (cur?.id) acc[cur.id] = cur.subject_no || cur.id;
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
|
);
|
||||||
|
|
||||||
const stateLabel = (v?: string | null) => getDictLabel(financeStatusDict, v || "");
|
const stateLabel = (v?: string | null) => getDictLabel(financeStatusDict, v || "");
|
||||||
const stateColor = (v?: string | null) => getDictColor(financeStatusDict, v || "") || "info";
|
const stateColor = (v?: string | null) => getDictColor(financeStatusDict, v || "") || "info";
|
||||||
|
const categoryLabel = (c?: string | null) =>
|
||||||
|
({
|
||||||
|
SITE_FEE: "中心费用",
|
||||||
|
SUBJECT_STIPEND: "受试者补偿",
|
||||||
|
TRAVEL: "差旅",
|
||||||
|
OTHER: "其他",
|
||||||
|
VISIT_FEE: "访视补贴",
|
||||||
|
}[c || ""] || c || "-");
|
||||||
|
|
||||||
const formatAmount = (num?: number | string) => {
|
const formatAmount = (num?: number | string) => {
|
||||||
const n = Number(num);
|
const n = Number(num);
|
||||||
@@ -96,6 +143,7 @@ onMounted(async () => {
|
|||||||
if (!auth.user && auth.token) {
|
if (!auth.user && auth.token) {
|
||||||
await auth.fetchMe().catch(() => {});
|
await auth.fetchMe().catch(() => {});
|
||||||
}
|
}
|
||||||
|
await Promise.all([loadSites(), loadSubjects()]);
|
||||||
await loadItem();
|
await loadItem();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,8 +2,17 @@
|
|||||||
<div class="page">
|
<div class="page">
|
||||||
<el-card class="mb-12">
|
<el-card class="mb-12">
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<el-input v-model="filters.site_id" placeholder="中心ID" clearable @change="load" style="width: 200px" />
|
<el-select
|
||||||
<el-select v-model="filters.product_id" placeholder="产品" clearable @change="load">
|
v-model="filters.site_id"
|
||||||
|
placeholder="中心"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
@change="load"
|
||||||
|
style="width: 220px"
|
||||||
|
>
|
||||||
|
<el-option v-for="s in sites" :key="s.id" :label="s.name" :value="s.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="filters.product_id" placeholder="产品" clearable @change="load" filterable>
|
||||||
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id" />
|
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<div class="spacer" />
|
<div class="spacer" />
|
||||||
@@ -12,10 +21,16 @@
|
|||||||
</el-card>
|
</el-card>
|
||||||
<el-card>
|
<el-card>
|
||||||
<el-table :data="rows" v-loading="loading" style="width: 100%">
|
<el-table :data="rows" v-loading="loading" style="width: 100%">
|
||||||
<el-table-column prop="site_id" label="中心" />
|
<el-table-column prop="site_id" label="中心">
|
||||||
<el-table-column prop="batch_id" label="批次" />
|
<template #default="scope">{{ siteName(scope.row.site_id) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="batch_id" label="批次">
|
||||||
|
<template #default="scope">{{ batchName(scope.row.batch_id) }}</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="quantity_on_hand" label="当前结余" width="120" align="right" />
|
<el-table-column prop="quantity_on_hand" label="当前结余" width="120" align="right" />
|
||||||
<el-table-column prop="updated_at" label="更新时间" width="180" />
|
<el-table-column prop="updated_at" label="更新时间" width="180">
|
||||||
|
<template #default="scope">{{ displayDateTime(scope.row.updated_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
@@ -26,8 +41,11 @@ import { onMounted, ref } from "vue";
|
|||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import { fetchImpInventory } from "../api/impInventory";
|
import { fetchImpInventory } from "../api/impInventory";
|
||||||
import { fetchImpProducts } from "../api/impProducts";
|
import { fetchImpProducts } from "../api/impProducts";
|
||||||
|
import { fetchImpBatches } from "../api/impBatches";
|
||||||
|
import { fetchSites } from "../api/sites";
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
|
import { displayDateTime } from "../utils/display";
|
||||||
|
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
@@ -36,6 +54,8 @@ const filters = ref({ site_id: "", product_id: "" });
|
|||||||
const rows = ref<any[]>([]);
|
const rows = ref<any[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const products = ref<any[]>([]);
|
const products = ref<any[]>([]);
|
||||||
|
const batches = ref<any[]>([]);
|
||||||
|
const sites = ref<any[]>([]);
|
||||||
|
|
||||||
const loadProducts = async () => {
|
const loadProducts = async () => {
|
||||||
if (!study.currentStudy) return;
|
if (!study.currentStudy) return;
|
||||||
@@ -47,6 +67,26 @@ const loadProducts = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadBatches = async () => {
|
||||||
|
if (!study.currentStudy) return;
|
||||||
|
try {
|
||||||
|
const { data } = await fetchImpBatches(study.currentStudy.id, { limit: 500 });
|
||||||
|
batches.value = Array.isArray(data) ? data : data.items || [];
|
||||||
|
} catch {
|
||||||
|
batches.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSites = async () => {
|
||||||
|
if (!study.currentStudy) return;
|
||||||
|
try {
|
||||||
|
const { data } = await fetchSites(study.currentStudy.id, { limit: 500 });
|
||||||
|
sites.value = (data as any).items || data || [];
|
||||||
|
} catch {
|
||||||
|
sites.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
if (!study.currentStudy) return;
|
if (!study.currentStudy) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@@ -67,11 +107,23 @@ const load = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const siteName = (id?: string) => {
|
||||||
|
if (!id) return "-";
|
||||||
|
const found = sites.value.find((s: any) => s.id === id);
|
||||||
|
return found?.name || id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const batchName = (id?: string) => {
|
||||||
|
if (!id) return "-";
|
||||||
|
const found = batches.value.find((b: any) => b.id === id);
|
||||||
|
return found?.batch_no || found?.code || id;
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!auth.user && auth.token) {
|
if (!auth.user && auth.token) {
|
||||||
await auth.fetchMe().catch(() => {});
|
await auth.fetchMe().catch(() => {});
|
||||||
}
|
}
|
||||||
await loadProducts();
|
await Promise.all([loadProducts(), loadBatches(), loadSites()]);
|
||||||
load();
|
load();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,8 +2,26 @@
|
|||||||
<div class="page">
|
<div class="page">
|
||||||
<el-card class="mb-12">
|
<el-card class="mb-12">
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<el-input v-model="filters.site_id" placeholder="中心ID" clearable @change="load" style="width: 160px" />
|
<el-select
|
||||||
<el-input v-model="filters.batch_id" placeholder="批次ID" clearable @change="load" style="width: 160px" />
|
v-model="filters.site_id"
|
||||||
|
placeholder="中心"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
style="width: 200px"
|
||||||
|
@change="load"
|
||||||
|
>
|
||||||
|
<el-option v-for="s in sites" :key="s.id" :label="s.name" :value="s.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-select
|
||||||
|
v-model="filters.batch_id"
|
||||||
|
placeholder="批次"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
style="width: 220px"
|
||||||
|
@change="load"
|
||||||
|
>
|
||||||
|
<el-option v-for="b in batches" :key="b.id" :label="batchLabel(b)" :value="b.id" />
|
||||||
|
</el-select>
|
||||||
<el-select v-model="filters.tx_type" placeholder="交易类型" clearable @change="load" style="width: 160px">
|
<el-select v-model="filters.tx_type" placeholder="交易类型" clearable @change="load" style="width: 160px">
|
||||||
<el-option v-for="t in txTypes" :key="t" :label="txTypeLabel(t)" :value="t" />
|
<el-option v-for="t in txTypes" :key="t" :label="txTypeLabel(t)" :value="t" />
|
||||||
</el-select>
|
</el-select>
|
||||||
@@ -29,15 +47,23 @@
|
|||||||
</el-card>
|
</el-card>
|
||||||
<el-card>
|
<el-card>
|
||||||
<el-table :data="txs" v-loading="loading" style="width: 100%">
|
<el-table :data="txs" v-loading="loading" style="width: 100%">
|
||||||
<el-table-column prop="tx_date" label="日期" width="120" />
|
<el-table-column prop="tx_date" label="日期" width="120">
|
||||||
|
<template #default="scope">{{ displayDate(scope.row.tx_date) }}</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="tx_type" label="类型" width="140">
|
<el-table-column prop="tx_type" label="类型" width="140">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ txTypeLabel(scope.row.tx_type) }}
|
{{ txTypeLabel(scope.row.tx_type) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="site_id" label="中心" width="180" />
|
<el-table-column prop="site_id" label="中心" width="180">
|
||||||
<el-table-column prop="batch_id" label="批次" width="180" />
|
<template #default="scope">{{ siteName(scope.row.site_id) }}</template>
|
||||||
<el-table-column prop="subject_id" label="受试者" width="180" />
|
</el-table-column>
|
||||||
|
<el-table-column prop="batch_id" label="批次" width="220">
|
||||||
|
<template #default="scope">{{ batchName(scope.row.batch_id) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="subject_id" label="受试者" width="180">
|
||||||
|
<template #default="scope">{{ subjectName(scope.row.subject_id) }}</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="quantity" label="数量" width="100" align="right" />
|
<el-table-column prop="quantity" label="数量" width="100" align="right" />
|
||||||
<el-table-column prop="reference" label="单号/备注" />
|
<el-table-column prop="reference" label="单号/备注" />
|
||||||
<el-table-column label="操作" width="140">
|
<el-table-column label="操作" width="140">
|
||||||
@@ -71,6 +97,8 @@ import { computed, onMounted, ref } from "vue";
|
|||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { fetchImpTransactions } from "../api/impTransactions";
|
import { fetchImpTransactions } from "../api/impTransactions";
|
||||||
import { fetchImpBatches } from "../api/impBatches";
|
import { fetchImpBatches } from "../api/impBatches";
|
||||||
|
import { fetchSubjects } from "../api/subjects";
|
||||||
|
import { fetchSites } from "../api/sites";
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
import PermissionAction from "../components/PermissionAction.vue";
|
import PermissionAction from "../components/PermissionAction.vue";
|
||||||
@@ -78,6 +106,7 @@ import { usePermission } from "../utils/permission";
|
|||||||
import ImpTransactionForm from "../components/ImpTransactionForm.vue";
|
import ImpTransactionForm from "../components/ImpTransactionForm.vue";
|
||||||
import { evaluateAction } from "../guards/actionGuard";
|
import { evaluateAction } from "../guards/actionGuard";
|
||||||
import { logAudit } from "../audit";
|
import { logAudit } from "../audit";
|
||||||
|
import { displayDate } from "../utils/display";
|
||||||
|
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
@@ -91,6 +120,8 @@ const pageSize = 10;
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const showForm = ref(false);
|
const showForm = ref(false);
|
||||||
const batches = ref<any[]>([]);
|
const batches = ref<any[]>([]);
|
||||||
|
const sites = ref<any[]>([]);
|
||||||
|
const subjects = ref<any[]>([]);
|
||||||
|
|
||||||
const { can } = usePermission();
|
const { can } = usePermission();
|
||||||
const canEdit = computed(() => can("imp.transaction"));
|
const canEdit = computed(() => can("imp.transaction"));
|
||||||
@@ -105,6 +136,26 @@ const loadBatches = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadSites = async () => {
|
||||||
|
if (!study.currentStudy) return;
|
||||||
|
try {
|
||||||
|
const { data } = await fetchSites(study.currentStudy.id, { limit: 500 });
|
||||||
|
sites.value = (data as any).items || data || [];
|
||||||
|
} catch {
|
||||||
|
sites.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSubjects = async () => {
|
||||||
|
if (!study.currentStudy) return;
|
||||||
|
try {
|
||||||
|
const { data } = await fetchSubjects(study.currentStudy.id, { limit: 1000 });
|
||||||
|
subjects.value = data.items || data || [];
|
||||||
|
} catch {
|
||||||
|
subjects.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
if (!study.currentStudy) return;
|
if (!study.currentStudy) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@@ -171,11 +222,31 @@ const confirmTx = async (row: any) => {
|
|||||||
load();
|
load();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const siteName = (id?: string) => {
|
||||||
|
if (!id) return "-";
|
||||||
|
const found = sites.value.find((s: any) => s.id === id);
|
||||||
|
return found?.name || id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const batchLabel = (b: any) =>
|
||||||
|
`${b?.batch_no || b?.code || b?.id || "-"}` + (b?.product_name ? ` (${b.product_name})` : "");
|
||||||
|
const batchName = (id?: string) => {
|
||||||
|
if (!id) return "-";
|
||||||
|
const found = batches.value.find((b: any) => b.id === id);
|
||||||
|
return found ? batchLabel(found) : id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const subjectName = (id?: string) => {
|
||||||
|
if (!id) return "-";
|
||||||
|
const found = subjects.value.find((s: any) => s.id === id);
|
||||||
|
return found?.subject_no || id;
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!auth.user && auth.token) {
|
if (!auth.user && auth.token) {
|
||||||
await auth.fetchMe().catch(() => {});
|
await auth.fetchMe().catch(() => {});
|
||||||
}
|
}
|
||||||
await loadBatches();
|
await Promise.all([loadBatches(), loadSites(), loadSubjects()]);
|
||||||
load();
|
load();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
<el-descriptions-item label="类型">{{ typeLabel(milestone.type) }}</el-descriptions-item>
|
<el-descriptions-item label="类型">{{ typeLabel(milestone.type) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="中心">{{ siteName(milestone) }}</el-descriptions-item>
|
<el-descriptions-item label="中心">{{ siteName(milestone) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="负责人">{{ ownerName(milestone.owner_id) }}</el-descriptions-item>
|
<el-descriptions-item label="负责人">{{ ownerName(milestone.owner_id) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="计划日期">{{ milestone.planned_date || "—" }}</el-descriptions-item>
|
<el-descriptions-item label="计划日期">{{ displayDate(milestone.planned_date) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="实际日期">{{ milestone.actual_date || "—" }}</el-descriptions-item>
|
<el-descriptions-item label="实际日期">{{ displayDate(milestone.actual_date) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="当前状态">
|
<el-descriptions-item label="当前状态">
|
||||||
<el-tag :type="statusColor(milestone.status)">{{ statusLabel(milestone.status) }}</el-tag>
|
<el-tag :type="statusColor(milestone.status)">{{ statusLabel(milestone.status) }}</el-tag>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
@@ -32,10 +32,14 @@
|
|||||||
/>
|
/>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
<el-tab-pane label="附件">
|
<el-tab-pane label="附件">
|
||||||
|
<div class="tip">
|
||||||
|
<div>用于上传与本节点相关的证明文件,如伦理批件、会议纪要等,仅用于证明该节点的完成情况。</div>
|
||||||
|
<div class="sub-tip">项目通用文件请在【项目详情】查看,中心级文件请在【中心管理】查看。</div>
|
||||||
|
</div>
|
||||||
<AttachmentList
|
<AttachmentList
|
||||||
v-if="milestone.id"
|
v-if="milestone.id"
|
||||||
:study-id="studyId"
|
:study-id="studyId"
|
||||||
entity-type="milestones"
|
entity-type="ethics_node"
|
||||||
:entity-id="milestone.id"
|
:entity-id="milestone.id"
|
||||||
/>
|
/>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
@@ -53,7 +57,6 @@ import { ElMessage } from "element-plus";
|
|||||||
import { fetchMilestones } from "../api/milestones";
|
import { fetchMilestones } from "../api/milestones";
|
||||||
import { fetchSites } from "../api/sites";
|
import { fetchSites } from "../api/sites";
|
||||||
import { listMembers } from "../api/members";
|
import { listMembers } from "../api/members";
|
||||||
import { fetchUsers } from "../api/users";
|
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
import CommentList from "../components/CommentList.vue";
|
import CommentList from "../components/CommentList.vue";
|
||||||
@@ -63,6 +66,7 @@ import {
|
|||||||
getMilestoneStatusLabel,
|
getMilestoneStatusLabel,
|
||||||
getMilestoneTypeLabel,
|
getMilestoneTypeLabel,
|
||||||
} from "../dictionaries/milestone.dict";
|
} from "../dictionaries/milestone.dict";
|
||||||
|
import { displayDate, displayUser } from "../utils/display";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
@@ -72,7 +76,6 @@ const milestone = ref<any | null>(null);
|
|||||||
const studyId = computed(() => study.currentStudy?.id || "");
|
const studyId = computed(() => study.currentStudy?.id || "");
|
||||||
const sites = ref<any[]>([]);
|
const sites = ref<any[]>([]);
|
||||||
const members = ref<any[]>([]);
|
const members = ref<any[]>([]);
|
||||||
const users = ref<any[]>([]);
|
|
||||||
|
|
||||||
const loadMilestone = async () => {
|
const loadMilestone = async () => {
|
||||||
if (!study.currentStudy) return;
|
if (!study.currentStudy) return;
|
||||||
@@ -105,37 +108,32 @@ const loadMembers = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadUsers = async () => {
|
|
||||||
try {
|
|
||||||
const { data } = await fetchUsers({ limit: 500 });
|
|
||||||
users.value = (data as any).items || data || [];
|
|
||||||
} catch {
|
|
||||||
users.value = [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const typeLabel = (v: string) => getMilestoneTypeLabel(v);
|
const typeLabel = (v: string) => getMilestoneTypeLabel(v);
|
||||||
const statusLabel = (v: string) => getMilestoneStatusLabel(v);
|
const statusLabel = (v: string) => getMilestoneStatusLabel(v);
|
||||||
const statusColor = (v: string) => getMilestoneStatusColor(v);
|
const statusColor = (v: string) => getMilestoneStatusColor(v);
|
||||||
|
|
||||||
const siteName = (row: any) => {
|
const siteName = (row: any) => {
|
||||||
|
if (row?.site?.name) return row.site.name;
|
||||||
const id = row?.site_id || row?.center_id;
|
const id = row?.site_id || row?.center_id;
|
||||||
const name = id ? sites.value.find((s: any) => s.id === id)?.name : row?.site_name;
|
const name = id ? sites.value.find((s: any) => s.id === id)?.name : row?.site_name;
|
||||||
return name || "—";
|
return name || "—";
|
||||||
};
|
};
|
||||||
|
|
||||||
const ownerName = (ownerId: string) => {
|
const ownerName = (ownerId: string) => {
|
||||||
if (!ownerId) return "未指定";
|
const memberMap = members.value.reduce<Record<string, string>>((acc, cur) => {
|
||||||
const member = members.value.find((m: any) => m.user_id === ownerId);
|
const username = cur?.user?.display_name || cur?.user?.username || cur?.username;
|
||||||
const user = users.value.find((u: any) => u.id === ownerId);
|
if (cur?.user_id) acc[cur.user_id] = username || cur.user_id;
|
||||||
return user?.username || member?.username || ownerId || "未指定";
|
return acc;
|
||||||
|
}, {});
|
||||||
|
const owner = milestone.value?.owner;
|
||||||
|
return owner?.display_name || owner?.username || displayUser(ownerId, { members: memberMap });
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!auth.user && auth.token) {
|
if (!auth.user && auth.token) {
|
||||||
await auth.fetchMe().catch(() => {});
|
await auth.fetchMe().catch(() => {});
|
||||||
}
|
}
|
||||||
await Promise.all([loadSites(), loadMembers(), loadUsers(), loadMilestone()]);
|
await Promise.all([loadSites(), loadMembers(), loadMilestone()]);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -155,4 +153,13 @@ onMounted(async () => {
|
|||||||
.mb-12 {
|
.mb-12 {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
.tip {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.sub-tip {
|
||||||
|
color: #888;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -33,12 +33,12 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="计划日期" width="140">
|
<el-table-column label="计划日期" width="140">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ scope.row.planned_date || "—" }}
|
{{ displayDate(scope.row.planned_date) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="实际日期" width="140">
|
<el-table-column label="实际日期" width="140">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ scope.row.actual_date || "—" }}
|
{{ displayDate(scope.row.actual_date) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="140">
|
<el-table-column label="状态" width="140">
|
||||||
@@ -63,20 +63,18 @@
|
|||||||
:milestone="editingMilestone"
|
:milestone="editingMilestone"
|
||||||
:sites="sites"
|
:sites="sites"
|
||||||
:members="members"
|
:members="members"
|
||||||
:users="users"
|
|
||||||
@success="loadMilestones"
|
@success="loadMilestones"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import { fetchMilestones } from "../api/milestones";
|
import { fetchMilestones } from "../api/milestones";
|
||||||
import { fetchSites } from "../api/sites";
|
import { fetchSites } from "../api/sites";
|
||||||
import { listMembers } from "../api/members";
|
import { listMembers } from "../api/members";
|
||||||
import { fetchUsers } from "../api/users";
|
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
import PermissionAction from "../components/PermissionAction.vue";
|
import PermissionAction from "../components/PermissionAction.vue";
|
||||||
import MilestoneForm from "../components/MilestoneForm.vue";
|
import MilestoneForm from "../components/MilestoneForm.vue";
|
||||||
@@ -85,6 +83,7 @@ import {
|
|||||||
getMilestoneStatusLabel,
|
getMilestoneStatusLabel,
|
||||||
getMilestoneTypeLabel,
|
getMilestoneTypeLabel,
|
||||||
} from "../dictionaries/milestone.dict";
|
} from "../dictionaries/milestone.dict";
|
||||||
|
import { displayDate, displayUser } from "../utils/display";
|
||||||
|
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -92,7 +91,6 @@ const router = useRouter();
|
|||||||
const milestones = ref<any[]>([]);
|
const milestones = ref<any[]>([]);
|
||||||
const sites = ref<any[]>([]);
|
const sites = ref<any[]>([]);
|
||||||
const members = ref<any[]>([]);
|
const members = ref<any[]>([]);
|
||||||
const users = ref<any[]>([]);
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const showForm = ref(false);
|
const showForm = ref(false);
|
||||||
const editingMilestone = ref<any | null>(null);
|
const editingMilestone = ref<any | null>(null);
|
||||||
@@ -130,15 +128,6 @@ const loadMembers = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadUsers = async () => {
|
|
||||||
try {
|
|
||||||
const { data } = await fetchUsers({ limit: 500 });
|
|
||||||
users.value = (data as any).items || data || [];
|
|
||||||
} catch {
|
|
||||||
users.value = [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
editingMilestone.value = null;
|
editingMilestone.value = null;
|
||||||
showForm.value = true;
|
showForm.value = true;
|
||||||
@@ -153,24 +142,34 @@ onMounted(() => {
|
|||||||
loadMilestones();
|
loadMilestones();
|
||||||
loadSites();
|
loadSites();
|
||||||
loadMembers();
|
loadMembers();
|
||||||
loadUsers();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const typeLabel = (v: string) => getMilestoneTypeLabel(v);
|
const typeLabel = (v: string) => getMilestoneTypeLabel(v);
|
||||||
const statusLabel = (v: string) => getMilestoneStatusLabel(v);
|
const statusLabel = (v: string) => getMilestoneStatusLabel(v);
|
||||||
const statusColor = (v: string) => getMilestoneStatusColor(v);
|
const statusColor = (v: string) => getMilestoneStatusColor(v);
|
||||||
|
|
||||||
|
const memberMap = computed(() =>
|
||||||
|
members.value.reduce<Record<string, string>>((acc, cur) => {
|
||||||
|
const username = cur?.user?.display_name || cur?.user?.username || cur?.username;
|
||||||
|
if (cur?.user_id) acc[cur.user_id] = username || cur.user_id;
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
|
);
|
||||||
|
|
||||||
const siteName = (row: any) => {
|
const siteName = (row: any) => {
|
||||||
|
if (row?.site?.name) return row.site.name;
|
||||||
const id = row?.site_id || row?.center_id;
|
const id = row?.site_id || row?.center_id;
|
||||||
const name = id ? sites.value.find((s: any) => s.id === id)?.name : row?.site_name;
|
const name = id ? sites.value.find((s: any) => s.id === id)?.name : row?.site_name;
|
||||||
return name || "—";
|
return name || "—";
|
||||||
};
|
};
|
||||||
|
|
||||||
const ownerName = (ownerId: string) => {
|
const ownerName = (ownerId: string) => {
|
||||||
if (!ownerId) return "未指定";
|
const fromPayload = milestones.value.find((m) => m.owner_id === ownerId)?.owner;
|
||||||
const member = members.value.find((m: any) => m.user_id === ownerId);
|
return (
|
||||||
const user = users.value.find((u: any) => u.id === ownerId);
|
fromPayload?.display_name ||
|
||||||
return user?.username || member?.username || ownerId || "未指定";
|
fromPayload?.username ||
|
||||||
|
displayUser(ownerId, { members: memberMap.value })
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const goDetail = (row: any) => {
|
const goDetail = (row: any) => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="page" v-if="subject">
|
<div class="page" v-if="subject">
|
||||||
<el-card class="mb-12">
|
<el-card class="mb-12">
|
||||||
<h3>受试者 {{ subject.subject_no }}</h3>
|
<h3>受试者 {{ subject.subject_no }}</h3>
|
||||||
<p>中心:{{ subject.site_id }}</p>
|
<p>中心:{{ siteName(subject.site_id) }}</p>
|
||||||
<p>
|
<p>
|
||||||
状态:
|
状态:
|
||||||
<el-tag :type="stateColor(subject.status)">{{ stateLabel(subject.status) }}</el-tag>
|
<el-tag :type="stateColor(subject.status)">{{ stateLabel(subject.status) }}</el-tag>
|
||||||
@@ -35,6 +35,7 @@ import { useRoute } from "vue-router";
|
|||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { fetchSubjects, updateSubject } from "../api/subjects";
|
import { fetchSubjects, updateSubject } from "../api/subjects";
|
||||||
import { fetchVisits } from "../api/visits";
|
import { fetchVisits } from "../api/visits";
|
||||||
|
import { fetchSites } from "../api/sites";
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
import PermissionAction from "../components/PermissionAction.vue";
|
import PermissionAction from "../components/PermissionAction.vue";
|
||||||
@@ -52,6 +53,7 @@ const auth = useAuthStore();
|
|||||||
|
|
||||||
const subject = ref<any | null>(null);
|
const subject = ref<any | null>(null);
|
||||||
const visits = ref<any[]>([]);
|
const visits = ref<any[]>([]);
|
||||||
|
const sites = ref<any[]>([]);
|
||||||
|
|
||||||
const { can } = usePermission();
|
const { can } = usePermission();
|
||||||
const canVisitEdit = computed(() => can("subject.enroll"));
|
const canVisitEdit = computed(() => can("subject.enroll"));
|
||||||
@@ -76,6 +78,22 @@ const loadSubject = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadSites = async () => {
|
||||||
|
if (!study.currentStudy) return;
|
||||||
|
try {
|
||||||
|
const { data } = await fetchSites(study.currentStudy.id, { limit: 500 });
|
||||||
|
sites.value = (data as any).items || data || [];
|
||||||
|
} catch {
|
||||||
|
sites.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const siteName = (id?: string) => {
|
||||||
|
if (!id) return "-";
|
||||||
|
const found = sites.value.find((s: any) => s.id === id);
|
||||||
|
return found?.name || id;
|
||||||
|
};
|
||||||
|
|
||||||
const loadVisits = async () => {
|
const loadVisits = async () => {
|
||||||
if (!study.currentStudy || !route.params.subjectId) return;
|
if (!study.currentStudy || !route.params.subjectId) return;
|
||||||
try {
|
try {
|
||||||
@@ -143,6 +161,7 @@ onMounted(async () => {
|
|||||||
if (!auth.user && auth.token) {
|
if (!auth.user && auth.token) {
|
||||||
await auth.fetchMe().catch(() => {});
|
await auth.fetchMe().catch(() => {});
|
||||||
}
|
}
|
||||||
|
await loadSites();
|
||||||
await loadSubject();
|
await loadSubject();
|
||||||
await loadVisits();
|
await loadVisits();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,16 @@
|
|||||||
<el-select v-model="filters.milestone_id" placeholder="里程碑" clearable @change="loadTasks">
|
<el-select v-model="filters.milestone_id" placeholder="里程碑" clearable @change="loadTasks">
|
||||||
<el-option v-for="m in milestones" :key="m.id" :label="m.name" :value="m.id" />
|
<el-option v-for="m in milestones" :key="m.id" :label="m.name" :value="m.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-input v-model="filters.assignee_id" placeholder="负责人" style="width: 180px" @change="loadTasks" />
|
<el-select
|
||||||
|
v-model="filters.assignee_id"
|
||||||
|
placeholder="负责人"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
style="width: 200px"
|
||||||
|
@change="loadTasks"
|
||||||
|
>
|
||||||
|
<el-option v-for="opt in assigneeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||||
|
</el-select>
|
||||||
<div class="spacer" />
|
<div class="spacer" />
|
||||||
<PermissionAction action="task.create">
|
<PermissionAction action="task.create">
|
||||||
<el-button type="primary" @click="openCreate">新建任务</el-button>
|
<el-button type="primary" @click="openCreate">新建任务</el-button>
|
||||||
@@ -66,7 +75,13 @@
|
|||||||
/>
|
/>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<TaskForm v-model="showForm" :task="editingTask" :milestones="milestones" @success="loadTasks" />
|
<TaskForm
|
||||||
|
v-model="showForm"
|
||||||
|
:task="editingTask"
|
||||||
|
:milestones="milestones"
|
||||||
|
:members="members"
|
||||||
|
@success="loadTasks"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -76,7 +91,6 @@ import { ElMessage } from "element-plus";
|
|||||||
import { fetchTasks, updateTask } from "../api/tasks";
|
import { fetchTasks, updateTask } from "../api/tasks";
|
||||||
import { fetchMilestones } from "../api/milestones";
|
import { fetchMilestones } from "../api/milestones";
|
||||||
import { listMembers } from "../api/members";
|
import { listMembers } from "../api/members";
|
||||||
import { fetchUsers } from "../api/users";
|
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
import PermissionAction from "../components/PermissionAction.vue";
|
import PermissionAction from "../components/PermissionAction.vue";
|
||||||
@@ -93,7 +107,6 @@ const auth = useAuthStore();
|
|||||||
const tasks = ref<any[]>([]);
|
const tasks = ref<any[]>([]);
|
||||||
const milestones = ref<any[]>([]);
|
const milestones = ref<any[]>([]);
|
||||||
const members = ref<any[]>([]);
|
const members = ref<any[]>([]);
|
||||||
const users = ref<any[]>([]);
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
const page = ref(1);
|
const page = ref(1);
|
||||||
@@ -119,16 +132,25 @@ const milestoneMap = computed(() =>
|
|||||||
);
|
);
|
||||||
const memberMap = computed(() =>
|
const memberMap = computed(() =>
|
||||||
members.value.reduce<Record<string, string>>((acc, cur) => {
|
members.value.reduce<Record<string, string>>((acc, cur) => {
|
||||||
acc[cur.user_id] = cur.username || cur.user_id;
|
const username = cur?.user?.display_name || cur?.user?.username || cur?.username;
|
||||||
return acc;
|
if (cur?.user_id) acc[cur.user_id] = username || cur.user_id;
|
||||||
}, {})
|
|
||||||
);
|
|
||||||
const userMap = computed(() =>
|
|
||||||
users.value.reduce<Record<string, string>>((acc, cur) => {
|
|
||||||
acc[cur.id] = cur.username || cur.id;
|
|
||||||
return acc;
|
return acc;
|
||||||
}, {})
|
}, {})
|
||||||
);
|
);
|
||||||
|
const assigneeOptions = computed(() => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return members.value
|
||||||
|
.filter((m) => m.is_active !== false)
|
||||||
|
.map((m) => {
|
||||||
|
const user = m.user || {};
|
||||||
|
return { value: m.user_id, label: user.display_name || user.username || m.username || "—" };
|
||||||
|
})
|
||||||
|
.filter((opt) => {
|
||||||
|
if (seen.has(opt.value)) return false;
|
||||||
|
seen.add(opt.value);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const loadMilestones = async () => {
|
const loadMilestones = async () => {
|
||||||
if (!study.currentStudy) return;
|
if (!study.currentStudy) return;
|
||||||
@@ -150,15 +172,6 @@ const loadMembers = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadUsers = async () => {
|
|
||||||
try {
|
|
||||||
const { data } = await fetchUsers({ limit: 500 });
|
|
||||||
users.value = (data as any).items || data || [];
|
|
||||||
} catch {
|
|
||||||
users.value = [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadTasks = async () => {
|
const loadTasks = async () => {
|
||||||
if (!study.currentStudy) return;
|
if (!study.currentStudy) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@@ -238,7 +251,16 @@ const priorityLabel = (v: string) => getDictLabel(priorityDict, v);
|
|||||||
const priorityColor = (v: string) => getDictColor(priorityDict, v) || "info";
|
const priorityColor = (v: string) => getDictColor(priorityDict, v) || "info";
|
||||||
|
|
||||||
const milestoneName = (id: string) => milestoneMap.value[id] || id || "-";
|
const milestoneName = (id: string) => milestoneMap.value[id] || id || "-";
|
||||||
const assigneeName = (id: string) => userMap.value[id] || memberMap.value[id] || id || "-";
|
const assigneeName = (id: string) => {
|
||||||
|
const fromPayload = tasks.value.find((t) => t.assignee_id === id)?.assignee;
|
||||||
|
return (
|
||||||
|
fromPayload?.display_name ||
|
||||||
|
fromPayload?.username ||
|
||||||
|
memberMap.value[id] ||
|
||||||
|
id ||
|
||||||
|
"-"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const showComplete = (row: any) => {
|
const showComplete = (row: any) => {
|
||||||
const actions = getAvailableActions(taskMachine, row.status);
|
const actions = getAvailableActions(taskMachine, row.status);
|
||||||
@@ -303,7 +325,6 @@ onMounted(async () => {
|
|||||||
loadSavedFilters();
|
loadSavedFilters();
|
||||||
loadMilestones();
|
loadMilestones();
|
||||||
loadMembers();
|
loadMembers();
|
||||||
loadUsers();
|
|
||||||
loadTasks();
|
loadTasks();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -18,7 +18,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<VerificationTable :verifications="verifications" :can-edit="canEdit" :loading="loading" @edit="openEdit" />
|
<VerificationTable
|
||||||
|
:verifications="verifications"
|
||||||
|
:can-edit="canEdit"
|
||||||
|
:loading="loading"
|
||||||
|
:subject-map="subjectMap"
|
||||||
|
:verifier-map="verifierMap"
|
||||||
|
@edit="openEdit"
|
||||||
|
/>
|
||||||
|
|
||||||
<el-dialog :title="editForm.id ? '更新核查进度' : '新增核查进度'" v-model="showEdit" width="520px">
|
<el-dialog :title="editForm.id ? '更新核查进度' : '新增核查进度'" v-model="showEdit" width="520px">
|
||||||
<el-form :model="editForm" label-width="120px">
|
<el-form :model="editForm" label-width="120px">
|
||||||
@@ -60,16 +67,21 @@
|
|||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import { fetchVerifications, upsertVerification } from "../api/verifications";
|
import { fetchVerifications, upsertVerification } from "../api/verifications";
|
||||||
|
import { fetchSubjects } from "../api/subjects";
|
||||||
|
import { listMembers } from "../api/members";
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
import VerificationTable from "../components/VerificationTable.vue";
|
import VerificationTable from "../components/VerificationTable.vue";
|
||||||
import SiteSelect from "../components/selectors/SiteSelect.vue";
|
import SiteSelect from "../components/selectors/SiteSelect.vue";
|
||||||
import SubjectSelect from "../components/selectors/SubjectSelect.vue";
|
import SubjectSelect from "../components/selectors/SubjectSelect.vue";
|
||||||
|
import { displayDate } from "../utils/display";
|
||||||
|
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
|
||||||
const verifications = ref<any[]>([]);
|
const verifications = ref<any[]>([]);
|
||||||
|
const subjects = ref<any[]>([]);
|
||||||
|
const members = ref<any[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
const showEdit = ref(false);
|
const showEdit = ref(false);
|
||||||
@@ -144,10 +156,46 @@ const submitEdit = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadSubjects = async () => {
|
||||||
|
if (!study.currentStudy) return;
|
||||||
|
try {
|
||||||
|
const { data } = await fetchSubjects(study.currentStudy.id, { limit: 1000 });
|
||||||
|
subjects.value = data.items || data || [];
|
||||||
|
} catch {
|
||||||
|
subjects.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMembers = async () => {
|
||||||
|
if (!study.currentStudy) return;
|
||||||
|
try {
|
||||||
|
const { data } = await listMembers(study.currentStudy.id, { limit: 500 });
|
||||||
|
members.value = Array.isArray(data) ? data : data.items || [];
|
||||||
|
} catch {
|
||||||
|
members.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const subjectMap = computed(() =>
|
||||||
|
subjects.value.reduce<Record<string, string>>((acc, cur) => {
|
||||||
|
if (cur?.id) acc[cur.id] = cur.subject_no || cur.id;
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
|
);
|
||||||
|
|
||||||
|
const verifierMap = computed(() =>
|
||||||
|
members.value.reduce<Record<string, string>>((acc, cur) => {
|
||||||
|
const username = cur?.user?.display_name || cur?.user?.username || cur?.username;
|
||||||
|
if (cur?.user_id) acc[cur.user_id] = username || cur.user_id;
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
|
);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!auth.user && auth.token) {
|
if (!auth.user && auth.token) {
|
||||||
await auth.fetchMe().catch(() => {});
|
await auth.fetchMe().catch(() => {});
|
||||||
}
|
}
|
||||||
|
await Promise.all([loadSubjects(), loadMembers()]);
|
||||||
load();
|
load();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -44,14 +44,19 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="logs" v-loading="loading" style="width: 100%">
|
<el-table :data="logs" v-loading="loading" style="width: 100%">
|
||||||
<el-table-column prop="timestamp" label="时间" width="180" />
|
<el-table-column prop="timestamp" label="时间" width="180">
|
||||||
|
<template #default="scope">{{ displayDateTime(scope.row.timestamp) }}</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="actorName" label="操作人" width="140" />
|
<el-table-column prop="actorName" label="操作人" width="140" />
|
||||||
<el-table-column prop="actorRoleLabel" label="角色" width="120" />
|
<el-table-column prop="actorRoleLabel" label="角色" width="120" />
|
||||||
<el-table-column prop="eventLabel" label="操作类型" min-width="160" />
|
<el-table-column prop="eventLabel" label="操作类型" min-width="160" />
|
||||||
<el-table-column prop="actionText" label="操作内容" min-width="180" />
|
<el-table-column prop="actionText" label="操作内容" min-width="180" />
|
||||||
<el-table-column label="操作对象" min-width="180">
|
<el-table-column label="操作对象" min-width="180">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ scope.row.targetTypeLabel }}({{ scope.row.targetId }} {{ scope.row.targetName || "" }})
|
<span v-if="scope.row.targetTypeLabel">
|
||||||
|
{{ scope.row.targetTypeLabel }}({{ scope.row.targetName || "—" }})
|
||||||
|
</span>
|
||||||
|
<span v-else>—</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="变更详情" min-width="220">
|
<el-table-column label="变更详情" min-width="220">
|
||||||
@@ -94,6 +99,7 @@ import { useAuthStore } from "../../store/auth";
|
|||||||
import { roleDict, getDictLabel } from "../../dictionaries";
|
import { roleDict, getDictLabel } from "../../dictionaries";
|
||||||
import { exportAuditCsv } from "../../audit/export/auditExportService";
|
import { exportAuditCsv } from "../../audit/export/auditExportService";
|
||||||
import { logAudit } from "../../audit";
|
import { logAudit } from "../../audit";
|
||||||
|
import { displayDateTime } from "../../utils/display";
|
||||||
|
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user