修复前后端显示异常问题(受试者、中心、负责人、日期等)

This commit is contained in:
Cheng Zhou
2025-12-18 22:09:28 +08:00
parent fb4950f8f7
commit 7137665410
48 changed files with 764 additions and 156 deletions
+25 -2
View File
@@ -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(
+27 -4
View File
@@ -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(
+75 -3
View File
@@ -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,
)
+36 -3
View File
@@ -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,
)
+1
View File
@@ -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)
+8
View File
@@ -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}
+8
View File
@@ -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)
+1
View File
@@ -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())
+7 -4
View File
@@ -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)
+5
View File
@@ -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
+13
View File
@@ -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
+3
View File
@@ -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
+8
View File
@@ -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
+28 -4
View File
@@ -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>
+4 -1
View File
@@ -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[];
+20 -16
View File
@@ -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) => {
+2 -4
View File
@@ -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) {
+18 -2
View File
@@ -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,
+17 -3
View File
@@ -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>
+45
View File
@@ -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()
)}`;
};
+28 -1
View File
@@ -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>
+13 -4
View File
@@ -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();
+54 -6
View File
@@ -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>
+58 -6
View File
@@ -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>
+78 -7
View File
@@ -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>
+26 -19
View File
@@ -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>
+19 -20
View File
@@ -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) => {
+20 -1
View File
@@ -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();
}); });
+43 -22
View File
@@ -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>
+49 -1
View File
@@ -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>
+8 -2
View File
@@ -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.