参与者管理- PD模块内容补充

This commit is contained in:
Cheng Zhou
2026-02-28 14:33:57 +08:00
parent af0c1b4b7d
commit 9b36a42e6f
14 changed files with 855 additions and 35 deletions
+20
View File
@@ -0,0 +1,20 @@
import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
export const listStudySubjectPds = (studyId: string, params?: Record<string, any>) =>
apiGet(`/api/v1/studies/${studyId}/subject-pds`, { params });
export const listSubjectPds = (studyId: string, subjectId: string, params?: Record<string, any>) =>
apiGet(`/api/v1/studies/${studyId}/subjects/${subjectId}/pds`, { params });
export const createSubjectPd = (studyId: string, subjectId: string, payload: Record<string, any>) =>
apiPost(`/api/v1/studies/${studyId}/subjects/${subjectId}/pds`, payload);
export const updateSubjectPd = (
studyId: string,
subjectId: string,
pdId: string,
payload: Record<string, any>
) => apiPatch(`/api/v1/studies/${studyId}/subjects/${subjectId}/pds/${pdId}`, payload);
export const deleteSubjectPd = (studyId: string, subjectId: string, pdId: string) =>
apiDelete(`/api/v1/studies/${studyId}/subjects/${subjectId}/pds/${pdId}`);
+24 -1
View File
@@ -149,6 +149,7 @@ export const TEXT = {
approvedDate: "批准日期",
meetingDate: "会议日期",
approvalNo: "批件号",
approvalStatus: "审批状态",
projectNo: "项目编号",
kickoffDate: "启动会日期",
attendees: "参训人员",
@@ -175,6 +176,10 @@ export const TEXT = {
signedDate: "签署日期",
currency: "币种",
currencyDefault: "CNY",
pdNo: "PD 编号",
pdType: "PD 类型",
pdLevel: "PD 分级",
pdDescription: "PD 描述",
remark: "备注",
},
empty: {
@@ -670,9 +675,12 @@ export const TEXT = {
emptyVisits: "暂无访视记录",
emptyAe: "暂无 AE 记录",
emptyPd: "暂无 PD 记录",
newPd: "新增 PD",
dialogHistory: "病史记录",
dialogVisit: "访视记录",
dialogAe: "AE 记录",
dialogPd: "PD 记录",
pdNoAuto: "保存后自动生成",
},
riskIssues: {
title: "风险问题",
@@ -692,7 +700,7 @@ export const TEXT = {
title: "PD",
subtitle: "同步参与者 PD 相关信息列表",
listTitle: "PD 列表",
emptyDescription: "暂无参与者记录",
emptyDescription: "暂无 PD 记录",
syncStatus: "同步状态",
synced: "已同步",
toSubject: "查看参与者",
@@ -1136,6 +1144,21 @@ export const TEXT = {
PROCESSING: "处理中",
RESOLVED: "已解决",
},
pdLevel: {
GENERAL: "一般",
MAJOR: "重大",
CRITICAL: "严重",
},
pdApprovalStatus: {
DRAFT: "未提交",
SUBMITTED: "审批中",
APPROVED: "通过",
REJECTED: "驳回",
},
pdStatus: {
OPEN: "开放",
CLOSED: "关闭",
},
generalStatus: {
TODO: "待处理",
DOING: "进行中",
+4 -4
View File
@@ -36,8 +36,8 @@
</div>
<div class="setup-view-mode">
<el-radio-group v-model="setupViewMode" size="small">
<el-radio-button label="draft">草稿视图</el-radio-button>
<el-radio-button label="published">发布预览</el-radio-button>
<el-radio-button label="draft">草稿</el-radio-button>
<el-radio-button label="published">预览</el-radio-button>
</el-radio-group>
</div>
</div>
@@ -3623,7 +3623,7 @@ const startEdit = () => {
if (!project.value) return;
if (activeStep.value !== 0 && activeStep.value !== 2 && activeStep.value !== 4) return;
if (isPublishedView.value) {
ElMessage.info("当前为发布预览,请先切换到草稿视图后再编辑");
ElMessage.info("当前为预览,请先切换到草稿后再编辑");
return;
}
if (!canManageSetup.value) {
@@ -4128,7 +4128,7 @@ const addProjectMilestone = () => {
const ensureStep2Editable = (): boolean => {
if (!project.value) return false;
if (isPublishedView.value) {
ElMessage.info("当前为发布预览,请先切换到草稿视图后再操作");
ElMessage.info("当前为预览,请先切换到草稿后再操作");
return false;
}
if (!canManageSetup.value) {
+27 -28
View File
@@ -4,7 +4,7 @@
<div class="filter-container unified-action-bar">
<el-form :inline="true" class="filter-form">
<el-form-item class="filter-item-form">
<el-input v-model="filters.keyword" :placeholder="TEXT.modules.subjectManagement.screeningNo" clearable class="filter-input-comp">
<el-input v-model="filters.keyword" :placeholder="TEXT.common.fields.keyword" clearable class="filter-input-comp">
<template #prefix>
<el-icon><Search /></el-icon>
</template>
@@ -34,29 +34,20 @@
</el-form>
</div>
<div class="unified-section table-section">
<el-table :data="filteredItems" v-loading="loading" style="width: 100%">
<el-table-column prop="subject_no" :label="TEXT.modules.subjectManagement.screeningNo" min-width="160" />
<el-table-column :label="TEXT.common.fields.site" min-width="160">
<el-table :data="filteredItems" v-loading="loading" style="width: 100%" table-layout="fixed">
<el-table-column prop="pd_no" :label="TEXT.common.fields.pdNo" min-width="160" show-overflow-tooltip />
<el-table-column prop="subject_no" :label="TEXT.modules.subjectManagement.screeningNo" min-width="160" show-overflow-tooltip />
<el-table-column :label="TEXT.common.fields.site" min-width="160" show-overflow-tooltip>
<template #default="scope">{{ siteMap[scope.row.site_id] || TEXT.common.fallback }}</template>
</el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status" width="120">
<template #default="scope">{{ displayEnum(TEXT.enums.subjectStatus, scope.row.status) }}</template>
<el-table-column prop="status" :label="TEXT.common.fields.status" min-width="160">
<template #default="scope">{{ displayEnum(TEXT.enums.pdStatus, scope.row.status) }}</template>
</el-table-column>
<el-table-column prop="enrollment_date" :label="TEXT.common.fields.enrollmentDate" width="140">
<template #default="scope">{{ displayDate(scope.row.enrollment_date) }}</template>
</el-table-column>
<el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" min-width="180">
<template #default="scope">{{ displayDate(scope.row.updated_at) }}</template>
</el-table-column>
<el-table-column :label="TEXT.modules.riskIssuePd.syncStatus" width="120">
<template #default>
<el-tag size="small" type="success" effect="plain">{{ TEXT.modules.riskIssuePd.synced }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120">
<el-table-column prop="description" :label="TEXT.common.fields.pdDescription" min-width="160" show-overflow-tooltip />
<el-table-column :label="TEXT.common.labels.actions" min-width="160" align="center">
<template #default="scope">
<el-button link type="primary" size="small" @click="goSubject(scope.row.id)">
{{ TEXT.modules.riskIssuePd.toSubject }}
<el-button link type="primary" size="small" @click="goSubject(scope.row.subject_id)">
{{ TEXT.common.actions.view }}
</el-button>
</template>
</el-table-column>
@@ -73,9 +64,9 @@ import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { CircleCheck, Location, Search } from "@element-plus/icons-vue";
import { useStudyStore } from "../../store/study";
import { fetchSubjects } from "../../api/subjects";
import { listStudySubjectPds } from "../../api/subjectPds";
import { fetchSites } from "../../api/sites";
import { displayDate, displayEnum } from "../../utils/display";
import { displayEnum } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
@@ -91,7 +82,7 @@ const filters = ref({
status: "",
});
const statusOptions = Object.entries(TEXT.enums.subjectStatus).map(([value, label]) => ({ value, label }));
const statusOptions = Object.entries(TEXT.enums.pdStatus).map(([value, label]) => ({ value, label }));
const resetFilters = () => {
filters.value.keyword = "";
@@ -120,10 +111,16 @@ const load = async () => {
loading.value = true;
try {
await loadSites(studyId);
const { data } = await fetchSubjects(studyId);
items.value = (Array.isArray(data) ? data : data.items || []).sort((a: any, b: any) =>
String(b.updated_at || "").localeCompare(String(a.updated_at || ""))
);
const { data } = await listStudySubjectPds(studyId);
const list = Array.isArray(data) ? data : data.items || [];
const getPdSeq = (pdNo: string) => {
const text = String(pdNo || "");
const matched = text.match(/^PD(\d+)$/i);
if (!matched) return Number.POSITIVE_INFINITY;
const num = Number(matched[1]);
return Number.isFinite(num) ? num : Number.POSITIVE_INFINITY;
};
items.value = list.sort((a: any, b: any) => getPdSeq(a?.pd_no) - getPdSeq(b?.pd_no));
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
@@ -134,8 +131,10 @@ const load = async () => {
const filteredItems = computed(() => {
const keyword = filters.value.keyword.trim().toLowerCase();
return items.value.filter((item) => {
const pdNo = String(item?.pd_no || "").toLowerCase();
const subjectNo = String(item?.subject_no || "").toLowerCase();
if (keyword && !subjectNo.includes(keyword)) return false;
const description = String(item?.description || "").toLowerCase();
if (keyword && !pdNo.includes(keyword) && !subjectNo.includes(keyword) && !description.includes(keyword)) return false;
if (filters.value.siteId && item?.site_id !== filters.value.siteId) return false;
if (filters.value.status && item?.status !== filters.value.status) return false;
return true;
+171 -1
View File
@@ -253,7 +253,47 @@
</el-tab-pane>
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.pd" name="pd">
<StateEmpty :description="TEXT.modules.subjectDetail.emptyPd" />
<div class="tab-actions">
<el-button type="primary" :disabled="isReadOnly" @click="openPdDialog()">
{{ TEXT.modules.subjectDetail.newPd }}
</el-button>
</div>
<el-table :data="pdItems" v-loading="loadingPds" style="width: 100%">
<el-table-column prop="pd_no" :label="TEXT.common.fields.pdNo" width="130" />
<el-table-column prop="pd_type" :label="TEXT.common.fields.pdType" min-width="140" />
<el-table-column prop="pd_level" :label="TEXT.common.fields.pdLevel" min-width="120">
<template #default="scope">{{ displayEnum(TEXT.enums.pdLevel, scope.row.pd_level) }}</template>
</el-table-column>
<el-table-column prop="description" :label="TEXT.common.fields.pdDescription" min-width="240">
<template #default="scope">{{ scope.row.description || TEXT.common.fallback }}</template>
</el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status" min-width="100">
<template #default="scope">{{ displayEnum(TEXT.enums.pdStatus, scope.row.status) }}</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="150">
<template #default="scope">
<el-button
link
type="primary"
size="small"
:disabled="isReadOnly"
@click="openPdDialog(scope.row)"
>
{{ TEXT.common.actions.edit }}
</el-button>
<el-button
link
type="danger"
size="small"
:disabled="isReadOnly"
@click="removePd(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template>
</el-table-column>
</el-table>
<StateEmpty v-if="!loadingPds && pdItems.length === 0" :description="TEXT.modules.subjectDetail.emptyPd" />
</el-tab-pane>
</el-tabs>
</el-card>
@@ -342,6 +382,36 @@
<el-button type="primary" :disabled="isReadOnly" @click="saveAe">{{ TEXT.common.actions.save }}</el-button>
</template>
</el-dialog>
<el-dialog append-to=".layout-main .content-wrapper" v-model="pdDialogVisible" :title="TEXT.modules.subjectDetail.dialogPd" width="560px">
<el-form :model="pdForm" label-width="100px">
<el-form-item :label="TEXT.common.fields.pdNo">
<el-input v-model="pdForm.pd_no" disabled :placeholder="TEXT.modules.subjectDetail.pdNoAuto" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.pdType" required>
<el-select v-model="pdForm.pd_type" filterable allow-create default-first-option clearable>
<el-option v-for="item in pdTypeOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
<el-form-item :label="TEXT.common.fields.pdLevel" required>
<el-select v-model="pdForm.pd_level">
<el-option v-for="option in pdLevelOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</el-form-item>
<el-form-item :label="TEXT.common.fields.status" required>
<el-select v-model="pdForm.status">
<el-option v-for="option in pdStatusOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</el-form-item>
<el-form-item :label="TEXT.common.fields.pdDescription">
<el-input v-model="pdForm.description" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="pdDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :disabled="isReadOnly" @click="savePd">{{ TEXT.common.actions.save }}</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -355,6 +425,7 @@ import { fetchSites } from "../../api/sites";
import { listSubjectHistories, createSubjectHistory, updateSubjectHistory, deleteSubjectHistory } from "../../api/subjectHistories";
import { fetchVisits, createVisit, updateVisit, deleteVisit } from "../../api/visits";
import { fetchAes, createAe, updateAe, deleteAe } from "../../api/aes";
import { listSubjectPds, createSubjectPd, updateSubjectPd, deleteSubjectPd } from "../../api/subjectPds";
import { displayDate, displayEnum, displayText } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
@@ -461,6 +532,22 @@ const aeSeriousnessOptions = [
{ value: "IV", label: TEXT.enums.aeSeriousness.IV },
{ value: "V", label: TEXT.enums.aeSeriousness.V },
];
const pdItems = ref<any[]>([]);
const loadingPds = ref(false);
const pdDialogVisible = ref(false);
const pdEditingId = ref<string | null>(null);
const pdTypeOptions = ["知情同意", "排除标准", "药物使用", "入排标准", "访视流程", "检验检查", "其他"];
const pdLevelOptions = Object.entries(TEXT.enums.pdLevel).map(([value, label]) => ({ value, label }));
const pdStatusOptions = Object.entries(TEXT.enums.pdStatus).map(([value, label]) => ({ value, label }));
const pdForm = reactive({
pd_no: "",
pd_type: "",
pd_level: "GENERAL",
description: "",
status: "OPEN",
});
const normalizeSeriousness = (value?: string | null) => {
if (!value) return "I";
const normalized = String(value).trim().toUpperCase();
@@ -905,6 +992,88 @@ const removeAe = async (row: any) => {
}
};
const loadPds = async () => {
if (!studyId || !subjectId) return;
loadingPds.value = true;
try {
const { data } = (await listSubjectPds(studyId, subjectId)) as any;
const list = Array.isArray(data) ? data : data.items || [];
const getPdSeq = (pdNo: string) => {
const text = String(pdNo || "");
const matched = text.match(/^PD(\d+)$/i);
if (!matched) return Number.POSITIVE_INFINITY;
const num = Number(matched[1]);
return Number.isFinite(num) ? num : Number.POSITIVE_INFINITY;
};
pdItems.value = list.sort((a: any, b: any) => getPdSeq(a?.pd_no) - getPdSeq(b?.pd_no));
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
loadingPds.value = false;
}
};
const openPdDialog = (row?: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
pdEditingId.value = row?.id || null;
Object.assign(pdForm, {
pd_no: row?.pd_no || "",
pd_type: row?.pd_type || "",
pd_level: row?.pd_level || "GENERAL",
description: row?.description || "",
status: row?.status || "OPEN",
});
pdDialogVisible.value = true;
};
const savePd = async () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId || !subjectId) return;
if (!pdForm.pd_type?.trim()) {
ElMessage.error(TEXT.common.messages.required);
return;
}
try {
const basePayload = {
pd_type: pdForm.pd_type.trim(),
pd_level: pdForm.pd_level,
description: pdForm.description?.trim() || null,
status: pdForm.status,
};
if (pdEditingId.value) {
await updateSubjectPd(studyId, subjectId, pdEditingId.value, basePayload);
} else {
await createSubjectPd(studyId, subjectId, { subject_id: subjectId, ...basePayload });
}
pdDialogVisible.value = false;
loadPds();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
}
};
const removePd = async (row: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId || !subjectId) return;
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return;
try {
await deleteSubjectPd(studyId, subjectId, row.id);
loadPds();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
}
};
const goBack = () => router.push("/subjects");
watch(
@@ -920,6 +1089,7 @@ onMounted(async () => {
loadHistories();
loadVisits();
loadAes();
loadPds();
});
</script>