Step F5:受试者 & 访视管理
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
import { apiGet } from "./axios";
|
||||||
|
import type { ApiListResponse } from "../types/api";
|
||||||
|
|
||||||
|
export const fetchSites = (studyId: string) =>
|
||||||
|
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/sites/`);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { apiGet, apiPost, apiPatch } from "./axios";
|
||||||
|
import type { ApiListResponse } from "../types/api";
|
||||||
|
|
||||||
|
export const fetchSubjects = (studyId: string, params?: Record<string, any>) =>
|
||||||
|
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/subjects/`, { params });
|
||||||
|
|
||||||
|
export const createSubject = (studyId: string, payload: Record<string, any>) =>
|
||||||
|
apiPost(`/api/v1/studies/${studyId}/subjects/`, payload);
|
||||||
|
|
||||||
|
export const updateSubject = (studyId: string, subjectId: string, payload: Record<string, any>) =>
|
||||||
|
apiPatch(`/api/v1/studies/${studyId}/subjects/${subjectId}`, payload);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { apiGet, apiPatch } from "./axios";
|
||||||
|
|
||||||
|
export const fetchVisits = (studyId: string, subjectId: string) =>
|
||||||
|
apiGet(`/api/v1/studies/${studyId}/subjects/${subjectId}/visits/`);
|
||||||
|
|
||||||
|
export const updateVisit = (studyId: string, subjectId: string, visitId: string, payload: Record<string, any>) =>
|
||||||
|
apiPatch(`/api/v1/studies/${studyId}/subjects/${subjectId}/visits/${visitId}`, payload);
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
<el-menu-item index="/study/home">项目首页</el-menu-item>
|
<el-menu-item index="/study/home">项目首页</el-menu-item>
|
||||||
<el-menu-item index="/study/milestones">里程碑</el-menu-item>
|
<el-menu-item index="/study/milestones">里程碑</el-menu-item>
|
||||||
<el-menu-item index="/study/tasks">任务</el-menu-item>
|
<el-menu-item index="/study/tasks">任务</el-menu-item>
|
||||||
|
<el-menu-item index="/study/subjects">受试者</el-menu-item>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
</el-aside>
|
</el-aside>
|
||||||
<el-main>
|
<el-main>
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog title="新增受试者" v-model="visible" width="520px" @close="onClose">
|
||||||
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||||
|
<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-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="受试者编号" prop="subject_no">
|
||||||
|
<el-input v-model="form.subject_no" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="筛选日期" prop="screening_date">
|
||||||
|
<el-date-picker v-model="form.screening_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="onClose">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="onSubmit">提交</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref } from "vue";
|
||||||
|
import type { FormInstance, FormRules } from "element-plus";
|
||||||
|
import { ElMessage } from "element-plus";
|
||||||
|
import { createSubject } from "../api/subjects";
|
||||||
|
import { useStudyStore } from "../store/study";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue: boolean;
|
||||||
|
sites: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits(["update:modelValue", "success"]);
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (v: boolean) => emit("update:modelValue", v),
|
||||||
|
});
|
||||||
|
|
||||||
|
const formRef = ref<FormInstance>();
|
||||||
|
const submitting = ref(false);
|
||||||
|
const study = useStudyStore();
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
site_id: "",
|
||||||
|
subject_no: "",
|
||||||
|
screening_date: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const rules: FormRules = {
|
||||||
|
site_id: [{ required: true, message: "请选择中心", trigger: "change" }],
|
||||||
|
subject_no: [{ required: true, message: "请输入受试者编号", trigger: "blur" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClose = () => {
|
||||||
|
visible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
if (!formRef.value) return;
|
||||||
|
await formRef.value.validate(async (valid) => {
|
||||||
|
if (!valid) return;
|
||||||
|
if (!study.currentStudy) {
|
||||||
|
ElMessage.error("未选择项目");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
await createSubject(study.currentStudy.id, { ...form });
|
||||||
|
ElMessage.success("提交成功");
|
||||||
|
emit("success");
|
||||||
|
onClose();
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || "提交失败");
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<template>
|
||||||
|
<el-card>
|
||||||
|
<div class="header">
|
||||||
|
<h4>访视计划</h4>
|
||||||
|
</div>
|
||||||
|
<el-table :data="visits" style="width: 100%" v-loading="loading">
|
||||||
|
<el-table-column prop="visit_code" label="代码" width="120" />
|
||||||
|
<el-table-column prop="visit_name" label="名称" />
|
||||||
|
<el-table-column prop="planned_date" label="计划日期" width="140" />
|
||||||
|
<el-table-column prop="actual_date" label="实际日期" width="140" />
|
||||||
|
<el-table-column prop="status" label="状态" width="120" />
|
||||||
|
<el-table-column v-if="canEdit" label="操作" width="200">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-button type="primary" link size="small" @click="markDone(scope.row)">标记完成</el-button>
|
||||||
|
<el-button type="danger" link size="small" @click="markMissed(scope.row)">标记缺失</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
|
import { updateVisit } from "../api/visits";
|
||||||
|
import { useStudyStore } from "../store/study";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
visits: any[];
|
||||||
|
subjectId: string;
|
||||||
|
canEdit: boolean;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const study = useStudyStore();
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const doUpdate = async (visitId: string, payload: Record<string, any>) => {
|
||||||
|
if (!study.currentStudy) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
await updateVisit(study.currentStudy.id, props.subjectId, visitId, payload);
|
||||||
|
ElMessage.success("更新成功");
|
||||||
|
props.onRefresh();
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || "更新失败");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const markDone = async (row: any) => {
|
||||||
|
await ElMessageBox.confirm("确认标记为完成?", "提示");
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
doUpdate(row.id, { status: "DONE", actual_date: today });
|
||||||
|
};
|
||||||
|
|
||||||
|
const markMissed = async (row: any) => {
|
||||||
|
await ElMessageBox.confirm("确认标记为缺失?", "提示");
|
||||||
|
doUpdate(row.id, { status: "MISSED", actual_date: null });
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -9,6 +9,7 @@ import StudyHome from "../views/StudyHome.vue";
|
|||||||
import Tasks from "../views/Tasks.vue";
|
import Tasks from "../views/Tasks.vue";
|
||||||
import Milestones from "../views/Milestones.vue";
|
import Milestones from "../views/Milestones.vue";
|
||||||
import Subjects from "../views/Subjects.vue";
|
import Subjects from "../views/Subjects.vue";
|
||||||
|
import SubjectDetail from "../views/SubjectDetail.vue";
|
||||||
import Aes from "../views/Aes.vue";
|
import Aes from "../views/Aes.vue";
|
||||||
import DataQueries from "../views/DataQueries.vue";
|
import DataQueries from "../views/DataQueries.vue";
|
||||||
import Imp from "../views/Imp.vue";
|
import Imp from "../views/Imp.vue";
|
||||||
@@ -57,6 +58,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: Subjects,
|
component: Subjects,
|
||||||
meta: { title: "受试者", requiresStudy: true },
|
meta: { title: "受试者", requiresStudy: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "study/subjects/:subjectId",
|
||||||
|
name: "StudySubjectDetail",
|
||||||
|
component: SubjectDetail,
|
||||||
|
meta: { title: "受试者详情", requiresStudy: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "study/aes",
|
path: "study/aes",
|
||||||
name: "StudyAes",
|
name: "StudyAes",
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page" v-if="subject">
|
||||||
|
<el-card class="mb-12">
|
||||||
|
<h3>受试者 {{ subject.subject_no }}</h3>
|
||||||
|
<p>中心:{{ subject.site_id }}</p>
|
||||||
|
<p>状态:{{ subject.status }}</p>
|
||||||
|
<div v-if="canEdit" class="actions">
|
||||||
|
<el-button size="small" type="primary" @click="setStatus('ENROLLED')">标记入组</el-button>
|
||||||
|
<el-button size="small" type="success" @click="setStatus('COMPLETED')">标记完成</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="setStatus('DROPPED')">标记脱落</el-button>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<VisitTable :visits="visits" :subject-id="subject.id" :can-edit="canEdit" :on-refresh="loadVisits" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="page">
|
||||||
|
<el-skeleton rows="3" animated />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref, computed } from "vue";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
|
import { fetchSubjects, updateSubject } from "../api/subjects";
|
||||||
|
import { fetchVisits } from "../api/visits";
|
||||||
|
import { useStudyStore } from "../store/study";
|
||||||
|
import { useAuthStore } from "../store/auth";
|
||||||
|
import VisitTable from "../components/VisitTable.vue";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const study = useStudyStore();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
|
||||||
|
const subject = ref<any | null>(null);
|
||||||
|
const visits = ref<any[]>([]);
|
||||||
|
|
||||||
|
const canEdit = computed(() => {
|
||||||
|
const role = auth.user?.role;
|
||||||
|
return role === "ADMIN" || role === "PM" || role === "CRA";
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadSubject = async () => {
|
||||||
|
if (!study.currentStudy) return;
|
||||||
|
try {
|
||||||
|
const subjectId = route.params.subjectId as string;
|
||||||
|
const { data } = await fetchSubjects(study.currentStudy.id, { skip: 0, limit: 1000 });
|
||||||
|
const list = data.items || data || [];
|
||||||
|
subject.value = list.find((s: any) => s.id === subjectId) || null;
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || "受试者加载失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadVisits = async () => {
|
||||||
|
if (!study.currentStudy || !route.params.subjectId) return;
|
||||||
|
try {
|
||||||
|
const { data } = await fetchVisits(study.currentStudy.id, route.params.subjectId as string);
|
||||||
|
visits.value = data.items || data;
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || "访视加载失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setStatus = async (status: string) => {
|
||||||
|
await ElMessageBox.confirm(`确认将受试者状态设置为 ${status}?`, "提示");
|
||||||
|
if (!study.currentStudy || !subject.value) return;
|
||||||
|
const payload: Record<string, any> = { status };
|
||||||
|
if (status == "ENROLLED") payload.enrollment_date = new Date().toISOString().slice(0, 10);
|
||||||
|
if (status == "COMPLETED") payload.completion_date = new Date().toISOString().slice(0, 10);
|
||||||
|
if (status == "DROPPED") payload.drop_reason = "Dropped";
|
||||||
|
try {
|
||||||
|
await updateSubject(study.currentStudy.id, subject.value.id, payload);
|
||||||
|
ElMessage.success("状态已更新");
|
||||||
|
await loadSubject();
|
||||||
|
await loadVisits();
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || "更新失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!auth.user && auth.token) {
|
||||||
|
await auth.fetchMe().catch(() => {});
|
||||||
|
}
|
||||||
|
await loadSubject();
|
||||||
|
await loadVisits();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.mb-12 {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,13 +1,152 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<el-card><h2>受试者</h2></el-card>
|
<el-card class="mb-12">
|
||||||
|
<div class="filters">
|
||||||
|
<el-select
|
||||||
|
v-model="filters.site_id"
|
||||||
|
placeholder="中心"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
style="width: 200px"
|
||||||
|
@change="loadSubjects"
|
||||||
|
>
|
||||||
|
<el-option v-for="s in sites" :key="s.id" :label="s.name || s.id" :value="s.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="filters.status" placeholder="状态" clearable @change="loadSubjects">
|
||||||
|
<el-option v-for="s in statuses" :key="s" :label="s" :value="s" />
|
||||||
|
</el-select>
|
||||||
|
<div class="spacer" />
|
||||||
|
<el-button type="primary" v-if="canEdit" @click="showForm = true">新增受试者</el-button>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card>
|
||||||
|
<el-table :data="subjects" v-loading="loading" style="width: 100%" @row-click="goDetail">
|
||||||
|
<el-table-column prop="subject_no" label="受试者编号" />
|
||||||
|
<el-table-column prop="site_id" label="中心ID" />
|
||||||
|
<el-table-column prop="status" label="状态" width="120" />
|
||||||
|
<el-table-column prop="screening_date" label="筛选日期" width="140" />
|
||||||
|
<el-table-column prop="enrollment_date" label="入组日期" width="140" />
|
||||||
|
</el-table>
|
||||||
|
<el-pagination
|
||||||
|
class="pagination"
|
||||||
|
layout="prev, pager, next"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:current-page="page"
|
||||||
|
:total="total"
|
||||||
|
@current-change="onPageChange"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<SubjectForm v-model="showForm" :sites="sites" @success="loadSubjects" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts"></script>
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import { ElMessage } from "element-plus";
|
||||||
|
import { fetchSubjects } from "../api/subjects";
|
||||||
|
import { fetchSites } from "../api/sites";
|
||||||
|
import { useStudyStore } from "../store/study";
|
||||||
|
import { useAuthStore } from "../store/auth";
|
||||||
|
import SubjectForm from "../components/SubjectForm.vue";
|
||||||
|
|
||||||
|
const study = useStudyStore();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const subjects = ref<any[]>([]);
|
||||||
|
const sites = ref<any[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const total = ref(0);
|
||||||
|
const page = ref(1);
|
||||||
|
const pageSize = 10;
|
||||||
|
|
||||||
|
const statuses = ["SCREENING", "ENROLLED", "COMPLETED", "DROPPED"];
|
||||||
|
|
||||||
|
const filters = ref({
|
||||||
|
site_id: "",
|
||||||
|
status: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const showForm = ref(false);
|
||||||
|
|
||||||
|
const canEdit = computed(() => {
|
||||||
|
const role = auth.user?.role;
|
||||||
|
return role === "ADMIN" || role === "PM" || role === "CRA";
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadSubjects = async () => {
|
||||||
|
if (!study.currentStudy) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const params: Record<string, any> = {
|
||||||
|
skip: (page.value - 1) * pageSize,
|
||||||
|
limit: pageSize,
|
||||||
|
};
|
||||||
|
if (filters.value.site_id) params.site_id = filters.value.site_id;
|
||||||
|
if (filters.value.status) params.status = filters.value.status;
|
||||||
|
const { data } = await fetchSubjects(study.currentStudy.id, params);
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
subjects.value = data;
|
||||||
|
total.value = data.length;
|
||||||
|
} else {
|
||||||
|
subjects.value = data.items || [];
|
||||||
|
total.value = data.total || subjects.value.length;
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || "受试者加载失败");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPageChange = (p: number) => {
|
||||||
|
page.value = p;
|
||||||
|
loadSubjects();
|
||||||
|
};
|
||||||
|
|
||||||
|
const goDetail = (row: any) => {
|
||||||
|
router.push(`/study/subjects/${row.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSites = async () => {
|
||||||
|
if (!study.currentStudy) return;
|
||||||
|
try {
|
||||||
|
const { data } = await fetchSites(study.currentStudy.id);
|
||||||
|
sites.value = data.items || data;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!auth.user && auth.token) {
|
||||||
|
await auth.fetchMe().catch(() => {});
|
||||||
|
}
|
||||||
|
await loadSites();
|
||||||
|
loadSubjects();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page {
|
.page {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.pagination {
|
||||||
|
margin-top: 12px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.mb-12 {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user