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

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.core.security import decode_token
from app.schemas.attachment import AttachmentRead
from app.schemas.user import UserDisplay
router = APIRouter()
global_router = APIRouter()
@@ -74,7 +75,14 @@ async def upload_attachment(
operator_id=current_user.id,
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(
@@ -90,7 +98,22 @@ async def list_attachments(
) -> list[AttachmentRead]:
await _ensure_study_exists(db, study_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(
+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.crud import member as member_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()
@@ -39,7 +41,7 @@ async def add_member(
@router.get(
"/",
response_model=list[StudyMemberRead],
response_model=list[StudyMemberReadWithUser],
dependencies=[Depends(require_study_member())],
)
async def list_members(
@@ -47,10 +49,31 @@ async def list_members(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db_session),
) -> list[StudyMemberRead]:
) -> list[StudyMemberReadWithUser]:
await _ensure_study_exists(db, study_id)
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(
+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 milestone as milestone_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.user import UserDisplay
from app.schemas.milestone import SiteDisplay
router = APIRouter()
@@ -33,6 +37,12 @@ async def create_milestone(
) -> MilestoneRead:
await _ensure_study_exists(db, study_id)
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(
db,
study_id=study_id,
@@ -43,7 +53,22 @@ async def create_milestone(
operator_id=current_user.id,
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(
@@ -57,7 +82,33 @@ async def list_milestones(
) -> list[MilestoneRead]:
await _ensure_study_exists(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(
@@ -78,6 +129,12 @@ async def update_milestone(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Milestone not found")
old_status = milestone.status
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
if milestone_in.status and milestone_in.status != old_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_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 study as study_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.user import UserDisplay
router = APIRouter()
@@ -47,6 +49,9 @@ async def create_task(
await _ensure_study_exists(db, study_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)
assignee = None
if task.assignee_id:
assignee = await user_crud.get_by_id(db, task.assignee_id)
await audit_crud.log_action(
db,
study_id=study_id,
@@ -57,7 +62,7 @@ async def create_task(
operator_id=current_user.id,
operator_role=current_user.role,
)
return task
return _to_task_read(task, assignee)
@router.get(
@@ -74,7 +79,13 @@ async def list_tasks(
) -> list[TaskRead]:
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)
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(
@@ -98,6 +109,9 @@ async def update_task(
old_status = task.status
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
if task_in.status and task_in.status != old_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_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,
status=milestone_in.status or "NOT_STARTED",
owner_id=milestone_in.owner_id,
site_id=milestone_in.site_id,
notes=milestone_in.notes,
)
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)
)
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()
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:
await db.execute(delete(StudyMember).where(StudyMember.user_id == user.id))
await db.delete(user)
+1
View File
@@ -18,6 +18,7 @@ class Milestone(Base):
planned_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")
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)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
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
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
from app.schemas.user import UserDisplay
class AttachmentRead(BaseModel):
id: uuid.UUID
filename: str
file_size: int
uploaded_by: uuid.UUID
file_size: int = Field(alias="size")
uploaded_by: UserDisplay | None = None
uploaded_by_id: uuid.UUID | None = None
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 pydantic import BaseModel, ConfigDict, Field
from app.schemas.user import UserDisplay
StudyRole = Literal["PM", "CRA", "PV", "IMP", "ADMIN"]
@@ -27,3 +28,7 @@ class StudyMemberRead(BaseModel):
added_at: datetime
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 app.schemas.user import UserDisplay
class SiteDisplay(BaseModel):
id: uuid.UUID
name: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class MilestoneCreate(BaseModel):
type: str
name: Optional[str] = None
planned_date: Optional[date] = None
owner_id: Optional[uuid.UUID] = None
site_id: Optional[uuid.UUID] = None
notes: Optional[str] = None
status: str = Field(default="NOT_STARTED")
@@ -20,6 +29,7 @@ class MilestoneUpdate(BaseModel):
actual_date: Optional[date] = None
status: Optional[str] = None
owner_id: Optional[uuid.UUID] = None
site_id: Optional[uuid.UUID] = None
notes: Optional[str] = None
@@ -32,6 +42,9 @@ class MilestoneRead(BaseModel):
actual_date: Optional[date]
status: str
owner_id: Optional[uuid.UUID]
site_id: Optional[uuid.UUID] = None
owner: Optional[UserDisplay] = None
site: Optional[SiteDisplay] = None
notes: Optional[str]
created_at: datetime
updated_at: datetime
+3
View File
@@ -4,6 +4,8 @@ from typing import Optional
from pydantic import BaseModel, ConfigDict
from app.schemas.user import UserDisplay
class TaskCreate(BaseModel):
milestone_id: Optional[uuid.UUID] = None
@@ -31,6 +33,7 @@ class TaskRead(BaseModel):
title: str
description: Optional[str]
assignee_id: Optional[uuid.UUID]
assignee: Optional[UserDisplay] = None
priority: str
due_date: Optional[date]
status: str
+8
View File
@@ -7,6 +7,14 @@ from pydantic import BaseModel, ConfigDict, Field
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):
username: str = Field(min_length=1)
role: UserRole
+28 -4
View File
@@ -14,9 +14,9 @@
</div>
</div>
<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">
<strong>{{ c.created_by }}</strong>
<strong>{{ displayUser(c.created_by, { users: userMap, members: memberMap }) }}</strong>
<p>{{ c.content }}</p>
</div>
</el-timeline-item>
@@ -25,10 +25,12 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { computed, onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import { fetchComments, createComment } from "../api/comments";
import { useStudyStore } from "../store/study";
import { listMembers } from "../api/members";
import { displayDateTime, displayUser } from "../utils/display";
interface Props {
studyId: string;
@@ -43,6 +45,7 @@ const loading = ref(false);
const newComment = ref("");
const showInput = ref(false);
const study = useStudyStore();
const members = ref<any[]>([]);
const load = async () => {
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 () => {
if (!newComment.value.trim()) {
ElMessage.warning("请输入评论");
@@ -77,7 +98,10 @@ const submit = async () => {
}
};
onMounted(() => load());
onMounted(() => {
loadMembers();
load();
});
</script>
<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>
</template>
</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">
<template #default="scope">
<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 type { FaqItem, FaqCategory } from "../api/faqs";
import PermissionAction from "./PermissionAction.vue";
import { displayDateTime } from "../utils/display";
const props = defineProps<{
items: FaqItem[];
+20 -16
View File
@@ -11,7 +11,7 @@
</el-form-item>
<el-form-item label="中心" prop="site_id">
<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-form-item>
<el-form-item label="负责人" prop="owner_id">
@@ -54,7 +54,6 @@ interface Props {
milestone?: Record<string, any>;
sites?: any[];
members?: any[];
users?: any[];
}
const props = defineProps<Props>();
@@ -94,23 +93,28 @@ const submitting = ref(false);
const isEdit = computed(() => !!props.milestone);
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);
if (memberList.length) {
return memberList.map((m: any) => ({
value: m.user_id,
label: userMap[m.user_id] || m.username || m.user_id,
}));
}
return (props.users || []).map((u: any) => ({
value: u.id,
label: u.username || u.id,
}));
const seen = new Set<string>();
return memberList
.map((m: any) => {
const user = m.user || {};
const label = user.display_name || user.username || m.username || "—";
return { value: m.user_id, label };
})
.filter((opt) => {
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(
() => props.milestone,
(val) => {
+2 -4
View File
@@ -64,10 +64,9 @@ const onClose = () => {
const resetForm = () => {
const studyId = study.currentStudy?.id;
const recentSite = studyId ? localStorage.getItem(`recent_site_${studyId}`) : null;
const recentSubject = studyId ? localStorage.getItem(`recent_subject_${studyId}`) : null;
form.site_id = recentSite || "";
form.subject_no = recentSubject || "";
form.screening_date = new Date().toISOString().slice(0, 10);
form.subject_no = "";
form.screening_date = "";
};
const onSubmit = async () => {
@@ -84,7 +83,6 @@ const onSubmit = async () => {
ElMessage.success("提交成功");
if (study.currentStudy?.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");
if (keepCreating.value) {
+18 -2
View File
@@ -13,7 +13,9 @@
</el-select>
</el-form-item>
<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 label="优先级" prop="priority">
<el-select v-model="form.priority" placeholder="请选择">
@@ -42,12 +44,12 @@ import type { FormInstance, FormRules } from "element-plus";
import { ElMessage } from "element-plus";
import { createTask, updateTask } from "../api/tasks";
import { useStudyStore } from "../store/study";
import UserSelect from "./selectors/UserSelect.vue";
interface Props {
modelValue: boolean;
task?: Record<string, any>;
milestones: any[];
members?: any[];
}
const props = defineProps<Props>();
@@ -89,6 +91,20 @@ const rules: FormRules = {
const submitting = ref(false);
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(
() => props.task,
+17 -3
View File
@@ -1,14 +1,24 @@
<template>
<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 label="完成度" width="180">
<template #default="scope">
<el-progress :percentage="scope.row.percent || 0" :stroke-width="12" />
</template>
</el-table-column>
<el-table-column prop="last_verified_at" label="最近核查" width="140" />
<el-table-column prop="verifier_id" label="核查人" width="160" />
<el-table-column prop="last_verified_at" label="最近核查" width="140">
<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">
<template #default="scope">
<el-button type="primary" link size="small" @click="$emit('edit', scope.row)">更新进度</el-button>
@@ -18,10 +28,14 @@
</template>
<script setup lang="ts">
import { displayDate } from "../utils/display";
defineProps<{
verifications: any[];
canEdit: boolean;
loading: boolean;
subjectMap: Record<string, string>;
verifierMap: Record<string, string>;
}>();
defineEmits(["edit"]);
</script>
@@ -12,14 +12,18 @@
<el-table :data="attachments" v-loading="loading" style="width: 100%">
<el-table-column prop="filename" label="文件名" min-width="200" />
<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 label="上传人" width="180">
<template #default="scope">
{{ uploaderLabel(scope.row) }}
</template>
</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">
<template #default="scope">
<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 { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import { fetchUsers } from "../../api/users";
import { listMembers } from "../../api/members";
import { displayDateTime, displayUser } from "../../utils/display";
const props = defineProps<{
studyId: string;
@@ -59,7 +63,6 @@ const attachments = ref<any[]>([]);
const loading = ref(false);
const auth = useAuthStore();
const study = useStudyStore();
const users = ref<any[]>([]);
const members = ref<any[]>([]);
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 () => {
if (!props.studyId) return;
try {
@@ -95,15 +89,15 @@ const loadMembers = async () => {
};
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) => {
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 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) => {
@@ -116,7 +110,9 @@ const canDelete = (row: any) => {
const userId = auth.user?.id;
const role = auth.user?.role;
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) => {
@@ -132,7 +128,7 @@ const remove = async (row: any) => {
};
onMounted(async () => {
await Promise.all([loadUsers(), loadMembers()]);
await Promise.all([loadMembers()]);
load();
});
</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>
<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.severity }}</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 { ElMessage, ElMessageBox } from "element-plus";
import { fetchAes, updateAe } from "../api/aes";
import { fetchSubjects } from "../api/subjects";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import CommentList from "../components/CommentList.vue";
@@ -74,6 +75,7 @@ const auth = useAuthStore();
const ae = ref<any | null>(null);
const studyId = computed(() => study.currentStudy?.id || "");
const subjects = ref<any[]>([]);
const { can } = usePermission();
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) => {
if (!study.currentStudy || !ae.value) return;
const prevStatus = ae.value.status;
@@ -167,6 +193,7 @@ onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
await loadSubjects();
load();
});
</script>
+13 -4
View File
@@ -44,15 +44,23 @@
{{ formatAmount(scope.row.amount) }}
</template>
</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">
<template #default="scope">
<el-tag :type="statusTag(scope.row.status)">{{ statusLabel(scope.row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="submitted_at" label="提交时间" width="160" />
<el-table-column prop="approved_at" label="审批时间" width="160" />
<el-table-column prop="paid_at" label="支付时间" width="160" />
<el-table-column prop="submitted_at" label="提交时间" width="180">
<template #default="scope">{{ displayDateTime(scope.row.submitted_at) }}</template>
</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">
<template #default="scope">
<el-button
@@ -102,6 +110,7 @@ import { usePermission } from "../utils/permission";
import { financeMachine, getAvailableActions } from "../state-machine";
import { evaluateAction } from "../guards/actionGuard";
import { logAudit } from "../audit";
import { displayDate, displayDateTime } from "../utils/display";
const study = useStudyStore();
const auth = useAuthStore();
+54 -6
View File
@@ -14,14 +14,14 @@
</div>
</div>
<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="发生日期">{{ item.occur_date }}</el-descriptions-item>
<el-descriptions-item label="中心">{{ item.site_id || "-" }}</el-descriptions-item>
<el-descriptions-item label="受试者">{{ item.subject_id || "-" }}</el-descriptions-item>
<el-descriptions-item label="发生日期">{{ displayDate(item.occur_date) }}</el-descriptions-item>
<el-descriptions-item label="中心">{{ siteMap[item.site_id] || "-" }}</el-descriptions-item>
<el-descriptions-item label="受试者">{{ subjectMap[item.subject_id] || "-" }}</el-descriptions-item>
<el-descriptions-item label="状态时间">
提交: {{ item.submitted_at || "-" }} / 审批: {{ item.approved_at || "-" }} /
支付: {{ item.paid_at || "-" }}
提交: {{ displayDateTime(item.submitted_at) }} / 审批: {{ displayDateTime(item.approved_at) }} /
支付: {{ displayDateTime(item.paid_at) }}
</el-descriptions-item>
<el-descriptions-item label="驳回原因">{{ item.reject_reason || "-" }}</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 AttachmentList from "../components/attachments/AttachmentList.vue";
import { fetchFinanceItem } from "../api/finance";
import { fetchSites } from "../api/sites";
import { fetchSubjects } from "../api/subjects";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import { usePermission } from "../utils/permission";
import { financeMachine, getAvailableActions } from "../state-machine";
import { financeStatusDict, getDictColor, getDictLabel } from "../dictionaries";
import { displayDate, displayDateTime } from "../utils/display";
const study = useStudyStore();
const auth = useAuthStore();
@@ -63,6 +66,8 @@ const route = useRoute();
const item = ref<any>(null);
const loading = ref(false);
const showEdit = ref(false);
const sites = ref<any[]>([]);
const subjects = ref<any[]>([]);
const { can } = usePermission();
const canEditDraft = computed(() => {
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 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 n = Number(num);
@@ -96,6 +143,7 @@ onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
await Promise.all([loadSites(), loadSubjects()]);
await loadItem();
});
</script>
+58 -6
View File
@@ -2,8 +2,17 @@
<div class="page">
<el-card class="mb-12">
<div class="filters">
<el-input v-model="filters.site_id" placeholder="中心ID" clearable @change="load" style="width: 200px" />
<el-select v-model="filters.product_id" placeholder="产品" clearable @change="load">
<el-select
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-select>
<div class="spacer" />
@@ -12,10 +21,16 @@
</el-card>
<el-card>
<el-table :data="rows" v-loading="loading" style="width: 100%">
<el-table-column prop="site_id" label="中心" />
<el-table-column prop="batch_id" label="批次" />
<el-table-column prop="site_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="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-card>
</div>
@@ -26,8 +41,11 @@ import { onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import { fetchImpInventory } from "../api/impInventory";
import { fetchImpProducts } from "../api/impProducts";
import { fetchImpBatches } from "../api/impBatches";
import { fetchSites } from "../api/sites";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import { displayDateTime } from "../utils/display";
const study = useStudyStore();
const auth = useAuthStore();
@@ -36,6 +54,8 @@ const filters = ref({ site_id: "", product_id: "" });
const rows = ref<any[]>([]);
const loading = ref(false);
const products = ref<any[]>([]);
const batches = ref<any[]>([]);
const sites = ref<any[]>([]);
const loadProducts = async () => {
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 () => {
if (!study.currentStudy) return;
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 () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
await loadProducts();
await Promise.all([loadProducts(), loadBatches(), loadSites()]);
load();
});
</script>
+78 -7
View File
@@ -2,8 +2,26 @@
<div class="page">
<el-card class="mb-12">
<div class="filters">
<el-input v-model="filters.site_id" placeholder="中心ID" clearable @change="load" style="width: 160px" />
<el-input v-model="filters.batch_id" placeholder="批次ID" clearable @change="load" style="width: 160px" />
<el-select
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-option v-for="t in txTypes" :key="t" :label="txTypeLabel(t)" :value="t" />
</el-select>
@@ -29,15 +47,23 @@
</el-card>
<el-card>
<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">
<template #default="scope">
{{ txTypeLabel(scope.row.tx_type) }}
</template>
</el-table-column>
<el-table-column prop="site_id" label="中心" width="180" />
<el-table-column prop="batch_id" label="批次" width="180" />
<el-table-column prop="subject_id" label="受试者" width="180" />
<el-table-column prop="site_id" label="中心" width="180">
<template #default="scope">{{ siteName(scope.row.site_id) }}</template>
</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="reference" label="单号/备注" />
<el-table-column label="操作" width="140">
@@ -71,6 +97,8 @@ import { computed, onMounted, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { fetchImpTransactions } from "../api/impTransactions";
import { fetchImpBatches } from "../api/impBatches";
import { fetchSubjects } from "../api/subjects";
import { fetchSites } from "../api/sites";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import PermissionAction from "../components/PermissionAction.vue";
@@ -78,6 +106,7 @@ import { usePermission } from "../utils/permission";
import ImpTransactionForm from "../components/ImpTransactionForm.vue";
import { evaluateAction } from "../guards/actionGuard";
import { logAudit } from "../audit";
import { displayDate } from "../utils/display";
const study = useStudyStore();
const auth = useAuthStore();
@@ -91,6 +120,8 @@ const pageSize = 10;
const loading = ref(false);
const showForm = ref(false);
const batches = ref<any[]>([]);
const sites = ref<any[]>([]);
const subjects = ref<any[]>([]);
const { can } = usePermission();
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 () => {
if (!study.currentStudy) return;
loading.value = true;
@@ -171,11 +222,31 @@ const confirmTx = async (row: any) => {
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 () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
await loadBatches();
await Promise.all([loadBatches(), loadSites(), loadSubjects()]);
load();
});
</script>
+26 -19
View File
@@ -12,8 +12,8 @@
<el-descriptions-item label="类型">{{ typeLabel(milestone.type) }}</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="计划日期">{{ milestone.planned_date || "—" }}</el-descriptions-item>
<el-descriptions-item label="实际日期">{{ milestone.actual_date || "—" }}</el-descriptions-item>
<el-descriptions-item label="计划日期">{{ displayDate(milestone.planned_date) }}</el-descriptions-item>
<el-descriptions-item label="实际日期">{{ displayDate(milestone.actual_date) }}</el-descriptions-item>
<el-descriptions-item label="当前状态">
<el-tag :type="statusColor(milestone.status)">{{ statusLabel(milestone.status) }}</el-tag>
</el-descriptions-item>
@@ -32,10 +32,14 @@
/>
</el-tab-pane>
<el-tab-pane label="附件">
<div class="tip">
<div>用于上传与本节点相关的证明文件如伦理批件会议纪要等仅用于证明该节点的完成情况</div>
<div class="sub-tip">项目通用文件请在项目详情查看中心级文件请在中心管理查看</div>
</div>
<AttachmentList
v-if="milestone.id"
:study-id="studyId"
entity-type="milestones"
entity-type="ethics_node"
:entity-id="milestone.id"
/>
</el-tab-pane>
@@ -53,7 +57,6 @@ import { ElMessage } from "element-plus";
import { fetchMilestones } from "../api/milestones";
import { fetchSites } from "../api/sites";
import { listMembers } from "../api/members";
import { fetchUsers } from "../api/users";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import CommentList from "../components/CommentList.vue";
@@ -63,6 +66,7 @@ import {
getMilestoneStatusLabel,
getMilestoneTypeLabel,
} from "../dictionaries/milestone.dict";
import { displayDate, displayUser } from "../utils/display";
const route = useRoute();
const study = useStudyStore();
@@ -72,7 +76,6 @@ const milestone = ref<any | null>(null);
const studyId = computed(() => study.currentStudy?.id || "");
const sites = ref<any[]>([]);
const members = ref<any[]>([]);
const users = ref<any[]>([]);
const loadMilestone = async () => {
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 statusLabel = (v: string) => getMilestoneStatusLabel(v);
const statusColor = (v: string) => getMilestoneStatusColor(v);
const siteName = (row: any) => {
if (row?.site?.name) return row.site.name;
const id = row?.site_id || row?.center_id;
const name = id ? sites.value.find((s: any) => s.id === id)?.name : row?.site_name;
return name || "—";
};
const ownerName = (ownerId: string) => {
if (!ownerId) return "未指定";
const member = members.value.find((m: any) => m.user_id === ownerId);
const user = users.value.find((u: any) => u.id === ownerId);
return user?.username || member?.username || ownerId || "未指定";
const memberMap = 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 owner = milestone.value?.owner;
return owner?.display_name || owner?.username || displayUser(ownerId, { members: memberMap });
};
onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
await Promise.all([loadSites(), loadMembers(), loadUsers(), loadMilestone()]);
await Promise.all([loadSites(), loadMembers(), loadMilestone()]);
});
</script>
@@ -155,4 +153,13 @@ onMounted(async () => {
.mb-12 {
margin-bottom: 12px;
}
.tip {
margin-bottom: 8px;
color: #666;
line-height: 1.4;
}
.sub-tip {
color: #888;
font-size: 12px;
}
</style>
+19 -20
View File
@@ -33,12 +33,12 @@
</el-table-column>
<el-table-column label="计划日期" width="140">
<template #default="scope">
{{ scope.row.planned_date || "—" }}
{{ displayDate(scope.row.planned_date) }}
</template>
</el-table-column>
<el-table-column label="实际日期" width="140">
<template #default="scope">
{{ scope.row.actual_date || "—" }}
{{ displayDate(scope.row.actual_date) }}
</template>
</el-table-column>
<el-table-column label="状态" width="140">
@@ -63,20 +63,18 @@
:milestone="editingMilestone"
:sites="sites"
:members="members"
:users="users"
@success="loadMilestones"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { fetchMilestones } from "../api/milestones";
import { fetchSites } from "../api/sites";
import { listMembers } from "../api/members";
import { fetchUsers } from "../api/users";
import { useStudyStore } from "../store/study";
import PermissionAction from "../components/PermissionAction.vue";
import MilestoneForm from "../components/MilestoneForm.vue";
@@ -85,6 +83,7 @@ import {
getMilestoneStatusLabel,
getMilestoneTypeLabel,
} from "../dictionaries/milestone.dict";
import { displayDate, displayUser } from "../utils/display";
const study = useStudyStore();
const router = useRouter();
@@ -92,7 +91,6 @@ const router = useRouter();
const milestones = ref<any[]>([]);
const sites = ref<any[]>([]);
const members = ref<any[]>([]);
const users = ref<any[]>([]);
const loading = ref(false);
const showForm = ref(false);
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 = () => {
editingMilestone.value = null;
showForm.value = true;
@@ -153,24 +142,34 @@ onMounted(() => {
loadMilestones();
loadSites();
loadMembers();
loadUsers();
});
const typeLabel = (v: string) => getMilestoneTypeLabel(v);
const statusLabel = (v: string) => getMilestoneStatusLabel(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) => {
if (row?.site?.name) return row.site.name;
const id = row?.site_id || row?.center_id;
const name = id ? sites.value.find((s: any) => s.id === id)?.name : row?.site_name;
return name || "—";
};
const ownerName = (ownerId: string) => {
if (!ownerId) return "未指定";
const member = members.value.find((m: any) => m.user_id === ownerId);
const user = users.value.find((u: any) => u.id === ownerId);
return user?.username || member?.username || ownerId || "未指定";
const fromPayload = milestones.value.find((m) => m.owner_id === ownerId)?.owner;
return (
fromPayload?.display_name ||
fromPayload?.username ||
displayUser(ownerId, { members: memberMap.value })
);
};
const goDetail = (row: any) => {
+20 -1
View File
@@ -2,7 +2,7 @@
<div class="page" v-if="subject">
<el-card class="mb-12">
<h3>受试者 {{ subject.subject_no }}</h3>
<p>中心{{ subject.site_id }}</p>
<p>中心{{ siteName(subject.site_id) }}</p>
<p>
状态
<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 { fetchSubjects, updateSubject } from "../api/subjects";
import { fetchVisits } from "../api/visits";
import { fetchSites } from "../api/sites";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import PermissionAction from "../components/PermissionAction.vue";
@@ -52,6 +53,7 @@ const auth = useAuthStore();
const subject = ref<any | null>(null);
const visits = ref<any[]>([]);
const sites = ref<any[]>([]);
const { can } = usePermission();
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 () => {
if (!study.currentStudy || !route.params.subjectId) return;
try {
@@ -143,6 +161,7 @@ onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
await loadSites();
await loadSubject();
await loadVisits();
});
+43 -22
View File
@@ -8,7 +8,16 @@
<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-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" />
<PermissionAction action="task.create">
<el-button type="primary" @click="openCreate">新建任务</el-button>
@@ -66,7 +75,13 @@
/>
</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>
</template>
@@ -76,7 +91,6 @@ import { ElMessage } from "element-plus";
import { fetchTasks, updateTask } from "../api/tasks";
import { fetchMilestones } from "../api/milestones";
import { listMembers } from "../api/members";
import { fetchUsers } from "../api/users";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import PermissionAction from "../components/PermissionAction.vue";
@@ -93,7 +107,6 @@ const auth = useAuthStore();
const tasks = ref<any[]>([]);
const milestones = ref<any[]>([]);
const members = ref<any[]>([]);
const users = ref<any[]>([]);
const loading = ref(false);
const total = ref(0);
const page = ref(1);
@@ -119,16 +132,25 @@ const milestoneMap = computed(() =>
);
const memberMap = computed(() =>
members.value.reduce<Record<string, string>>((acc, cur) => {
acc[cur.user_id] = cur.username || cur.user_id;
return acc;
}, {})
);
const userMap = computed(() =>
users.value.reduce<Record<string, string>>((acc, cur) => {
acc[cur.id] = cur.username || cur.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;
}, {})
);
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 () => {
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 () => {
if (!study.currentStudy) return;
loading.value = true;
@@ -238,7 +251,16 @@ const priorityLabel = (v: string) => getDictLabel(priorityDict, v);
const priorityColor = (v: string) => getDictColor(priorityDict, v) || "info";
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 actions = getAvailableActions(taskMachine, row.status);
@@ -303,7 +325,6 @@ onMounted(async () => {
loadSavedFilters();
loadMilestones();
loadMembers();
loadUsers();
loadTasks();
});
</script>
+49 -1
View File
@@ -18,7 +18,14 @@
</div>
</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-form :model="editForm" label-width="120px">
@@ -60,16 +67,21 @@
import { computed, onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import { fetchVerifications, upsertVerification } from "../api/verifications";
import { fetchSubjects } from "../api/subjects";
import { listMembers } from "../api/members";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import VerificationTable from "../components/VerificationTable.vue";
import SiteSelect from "../components/selectors/SiteSelect.vue";
import SubjectSelect from "../components/selectors/SubjectSelect.vue";
import { displayDate } from "../utils/display";
const study = useStudyStore();
const auth = useAuthStore();
const verifications = ref<any[]>([]);
const subjects = ref<any[]>([]);
const members = ref<any[]>([]);
const loading = ref(false);
const submitting = 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 () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
await Promise.all([loadSubjects(), loadMembers()]);
load();
});
</script>
+8 -2
View File
@@ -44,14 +44,19 @@
</el-button>
</div>
<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="actorRoleLabel" label="角色" width="120" />
<el-table-column prop="eventLabel" label="操作类型" min-width="160" />
<el-table-column prop="actionText" label="操作内容" min-width="180" />
<el-table-column label="操作对象" min-width="180">
<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>
</el-table-column>
<el-table-column label="变更详情" min-width="220">
@@ -94,6 +99,7 @@ import { useAuthStore } from "../../store/auth";
import { roleDict, getDictLabel } from "../../dictionaries";
import { exportAuditCsv } from "../../audit/export/auditExportService";
import { logAudit } from "../../audit";
import { displayDateTime } from "../../utils/display";
const study = useStudyStore();
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.