参与者管理- 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
+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>