统一业务页面权限与抽屉编辑
1、将 FAQ 分类和问题维护改为抽屉交互,并按分类、问题、回复的细粒度权限控制入口。 2、将注意事项、可行性和伦理记录接入详情页抽屉编辑,创建时支持先保存主记录再上传附件。 3、将参与者基础信息编辑改为抽屉,详情页按可读权限显示病史、访视、AE 和 PD 标签页。 4、补充业务页面权限一致性测试,覆盖停用中心、无权限入口和面包屑上下文。
This commit is contained in:
@@ -57,14 +57,16 @@ describe("SubjectDetail medication adherence", () => {
|
||||
expect(source).toContain("isSystemAdmin");
|
||||
[
|
||||
"subjects:update",
|
||||
"subject_histories:read",
|
||||
"subject_histories:create",
|
||||
"subject_histories:update",
|
||||
"subject_histories:delete",
|
||||
"visits:list",
|
||||
"visits:create",
|
||||
"visits:update",
|
||||
"visits:delete",
|
||||
"subject_aes:create",
|
||||
"subject_aes:list",
|
||||
"subject_aes:read",
|
||||
"subject_aes:update",
|
||||
"subject_aes:delete",
|
||||
"subject_pds:create",
|
||||
@@ -75,6 +77,10 @@ describe("SubjectDetail medication adherence", () => {
|
||||
expect(source).toContain(operationKey);
|
||||
});
|
||||
expect(source).toContain("v-if=\"currentTabAction.allowed\"");
|
||||
expect(source).toContain("v-if=\"canReadHistory\"");
|
||||
expect(source).toContain("v-if=\"canReadVisit\"");
|
||||
expect(source).toContain("v-if=\"canListAe\"");
|
||||
expect(source).toContain("v-if=\"canListPd\"");
|
||||
expect(source).toContain("v-if=\"canUpdateHistory || canDeleteHistory\"");
|
||||
expect(source).toContain("v-if=\"canUpdateVisit || canDeleteVisit\"");
|
||||
expect(source).toContain("v-if=\"canUpdateAe || canDeleteAe\"");
|
||||
@@ -83,10 +89,26 @@ describe("SubjectDetail medication adherence", () => {
|
||||
expect(source).toContain("const canSaveVisit = computed(() => (visitEditingId.value ? canUpdateVisit.value : canCreateVisit.value));");
|
||||
expect(source).toContain("const canSaveAe = computed(() => (aeEditingId.value ? canUpdateAe.value : canCreateAe.value));");
|
||||
expect(source).toContain("const canSavePd = computed(() => (pdEditingId.value ? canUpdatePd.value : canCreatePd.value));");
|
||||
expect(source).toContain("const canReadHistory = computed(() => canUseApiPermission(\"subject_histories:read\"));");
|
||||
expect(source).toContain("const canReadVisit = computed(() => canUseApiPermission(\"visits:list\"));");
|
||||
expect(source).toContain("if (!canReadHistory.value)");
|
||||
expect(source).toContain("if (!canReadVisit.value)");
|
||||
expect(source).toContain("const canCreateAe = computed(() => canUseApiPermission(\"subject_aes:create\"));");
|
||||
expect(source).toContain("const canUpdateAe = computed(() => canUseApiPermission(\"subject_aes:update\"));");
|
||||
expect(source).toContain("const canDeleteAe = computed(() => canUseApiPermission(\"subject_aes:delete\"));");
|
||||
expect(source).toContain("const canListAe = computed(() => canUseApiPermission(\"subject_aes:list\"));");
|
||||
expect(source).toContain("const canListAe = computed(() => canUseApiPermission(\"subject_aes:read\"));");
|
||||
expect(source).toContain("const canListPd = computed(() => canUseApiPermission(\"subject_pds:list\"));");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SubjectDetail drawer editor", () => {
|
||||
it("opens participant basic information edit in the shared drawer editor", () => {
|
||||
const source = readSubjectDetail();
|
||||
|
||||
expect(source).toContain("SubjectEditorDrawer");
|
||||
expect(source).toContain("subjectEditorVisible");
|
||||
expect(source).toContain("@success=\"handleSubjectEditorSuccess\"");
|
||||
expect(source).not.toContain("subjectEditing");
|
||||
expect(source).not.toContain("saveSubjectEdit");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,22 +6,9 @@
|
||||
<p class="page-subtitle">{{ TEXT.modules.subjectManagement.screeningNo }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button
|
||||
v-if="subjectEditing"
|
||||
type="primary"
|
||||
:loading="subjectSaving"
|
||||
:disabled="isReadOnly || !canUpdateSubject"
|
||||
@click="saveSubjectEdit"
|
||||
>
|
||||
{{ TEXT.common.actions.save }}
|
||||
</el-button>
|
||||
<el-button v-if="subjectEditing" :disabled="isReadOnly" @click="cancelSubjectEdit">
|
||||
{{ TEXT.common.actions.cancel }}
|
||||
</el-button>
|
||||
<el-button v-else-if="canUpdateSubject" type="primary" :disabled="isReadOnly" @click="startSubjectEdit">
|
||||
<el-button v-if="canUpdateSubject" type="primary" :disabled="isReadOnly" @click="openSubjectEditor">
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,63 +20,27 @@
|
||||
<span>{{ displaySubjectStatus(detail) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.screeningDate">
|
||||
<el-date-picker
|
||||
v-if="subjectEditing"
|
||||
v-model="subjectForm.screening_date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
size="small"
|
||||
/>
|
||||
<span v-else>{{ displayDate(detail.screening_date) }}</span>
|
||||
<span>{{ displayDate(detail.screening_date) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.consentDate">
|
||||
<el-date-picker
|
||||
v-if="subjectEditing"
|
||||
v-model="subjectForm.consent_date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
size="small"
|
||||
/>
|
||||
<span v-else>{{ displayDate(detail.consent_date) }}</span>
|
||||
<span>{{ displayDate(detail.consent_date) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.enrollmentDate">
|
||||
<el-date-picker
|
||||
v-if="subjectEditing"
|
||||
v-model="subjectForm.enrollment_date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
size="small"
|
||||
/>
|
||||
<span v-else>{{ displayDate(detail.enrollment_date) }}</span>
|
||||
<span>{{ displayDate(detail.enrollment_date) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.baselineDate">
|
||||
<el-date-picker
|
||||
v-if="subjectEditing"
|
||||
v-model="subjectForm.baseline_date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
size="small"
|
||||
/>
|
||||
<span v-else>{{ displayDate(detail.baseline_date) }}</span>
|
||||
<span>{{ displayDate(detail.baseline_date) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.completionDate">
|
||||
<el-date-picker
|
||||
v-if="subjectEditing"
|
||||
v-model="subjectForm.completion_date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
size="small"
|
||||
/>
|
||||
<span v-else>{{ displayDate(detail.completion_date) }}</span>
|
||||
<span>{{ displayDate(detail.completion_date) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.dropReason" :span="2">
|
||||
<el-input v-if="subjectEditing" v-model="subjectForm.drop_reason" type="textarea" :rows="2" />
|
||||
<span v-else>{{ detail.drop_reason || TEXT.common.fallback }}</span>
|
||||
<span>{{ detail.drop_reason || TEXT.common.fallback }}</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-card class="unified-shell subject-shell subject-tabs-shell">
|
||||
<el-card v-if="readableTabs.length > 0" class="unified-shell subject-shell subject-tabs-shell">
|
||||
<div class="subject-tabs-action">
|
||||
<el-button
|
||||
v-if="currentTabAction.allowed"
|
||||
@@ -114,7 +65,7 @@
|
||||
</el-button>
|
||||
</div>
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.history" name="history">
|
||||
<el-tab-pane v-if="canReadHistory" :label="TEXT.modules.subjectDetail.tabs.history" name="history">
|
||||
<el-table :data="historyItems" v-loading="loadingHistory" style="width: 100%" class="subject-detail-table" table-layout="fixed">
|
||||
<el-table-column prop="record_date" :label="TEXT.common.fields.recordDate" show-overflow-tooltip>
|
||||
<template #default="scope">{{ displayDate(scope.row.record_date) }}</template>
|
||||
@@ -150,7 +101,7 @@
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.visits" name="visits">
|
||||
<el-tab-pane v-if="canReadVisit" :label="TEXT.modules.subjectDetail.tabs.visits" name="visits">
|
||||
<el-table :data="visitItems" v-loading="loadingVisits" style="width: 100%" class="subject-detail-table" table-layout="fixed">
|
||||
<el-table-column prop="visit_code" :label="TEXT.common.fields.visitCode" show-overflow-tooltip />
|
||||
<el-table-column prop="planned_date" :label="TEXT.common.fields.plannedDate" show-overflow-tooltip>
|
||||
@@ -237,7 +188,7 @@
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="用药依从性" name="medicationAdherence">
|
||||
<el-tab-pane v-if="canReadVisit" label="用药依从性" name="medicationAdherence">
|
||||
<div class="adherence-panel">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="开始用药日期">
|
||||
@@ -274,7 +225,7 @@
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.ae" name="ae">
|
||||
<el-tab-pane v-if="canListAe" :label="TEXT.modules.subjectDetail.tabs.ae" name="ae">
|
||||
<el-table :data="aeItems" v-loading="loadingAes" style="width: 100%" class="subject-detail-table" table-layout="fixed">
|
||||
<el-table-column prop="onset_date" :label="TEXT.common.fields.occurDate" show-overflow-tooltip>
|
||||
<template #default="scope">{{ displayDate(scope.row.onset_date) }}</template>
|
||||
@@ -329,7 +280,7 @@
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.pd" name="pd">
|
||||
<el-tab-pane v-if="canListPd" :label="TEXT.modules.subjectDetail.tabs.pd" name="pd">
|
||||
<el-table :data="pdItems" v-loading="loadingPds" style="width: 100%" class="subject-detail-table" table-layout="fixed">
|
||||
<el-table-column prop="pd_no" :label="TEXT.common.fields.pdNo" show-overflow-tooltip />
|
||||
<el-table-column prop="pd_type" :label="TEXT.common.fields.pdType" show-overflow-tooltip />
|
||||
@@ -374,6 +325,12 @@
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
|
||||
<SubjectEditorDrawer
|
||||
v-model="subjectEditorVisible"
|
||||
:subject-id="subjectId"
|
||||
@success="handleSubjectEditorSuccess"
|
||||
/>
|
||||
|
||||
<el-dialog v-if="historyDialogVisible" append-to=".layout-main .content-wrapper" v-model="historyDialogVisible" :title="TEXT.modules.subjectDetail.dialogHistory" width="520px">
|
||||
<el-form :model="historyForm" label-width="90px">
|
||||
<el-form-item :label="TEXT.common.fields.recordDate">
|
||||
@@ -522,7 +479,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useRoute } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
@@ -536,9 +493,9 @@ import { displayDate, displayEnum, displayText } from "../../utils/display";
|
||||
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
|
||||
import { getProjectRole, isSystemAdmin } from "../../utils/roles";
|
||||
import { TEXT } from "../../locales";
|
||||
import SubjectEditorDrawer from "./SubjectEditorDrawer.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const study = useStudyStore();
|
||||
const subjectId = route.params.subjectId as string;
|
||||
@@ -556,6 +513,7 @@ const resolveTabFromQuery = () => {
|
||||
};
|
||||
|
||||
const loading = ref(false);
|
||||
const subjectEditorVisible = ref(false);
|
||||
const detail = reactive<any>({
|
||||
subject_no: "",
|
||||
site_id: "",
|
||||
@@ -569,17 +527,6 @@ const detail = reactive<any>({
|
||||
drop_reason: "",
|
||||
});
|
||||
|
||||
const subjectEditing = ref(false);
|
||||
const subjectSaving = ref(false);
|
||||
const subjectForm = reactive({
|
||||
screening_date: "",
|
||||
enrollment_date: "",
|
||||
baseline_date: "",
|
||||
consent_date: "",
|
||||
completion_date: "",
|
||||
drop_reason: "",
|
||||
});
|
||||
|
||||
const siteMap = ref<Record<string, string>>({});
|
||||
const siteActiveMap = ref<Record<string, boolean>>({});
|
||||
const isReadOnly = computed(() => !!detail.site_id && siteActiveMap.value[detail.site_id] === false);
|
||||
@@ -590,6 +537,7 @@ const historyItems = ref<any[]>([]);
|
||||
const loadingHistory = ref(false);
|
||||
const historyDialogVisible = ref(false);
|
||||
const historyEditingId = ref<string | null>(null);
|
||||
const canReadHistory = computed(() => canUseApiPermission("subject_histories:read"));
|
||||
const canCreateHistory = computed(() => canUseApiPermission("subject_histories:create"));
|
||||
const canUpdateHistory = computed(() => canUseApiPermission("subject_histories:update"));
|
||||
const canDeleteHistory = computed(() => canUseApiPermission("subject_histories:delete"));
|
||||
@@ -606,6 +554,7 @@ const earlyTerminationDialogVisible = ref(false);
|
||||
const earlyTerminationSaving = ref(false);
|
||||
const visitEditingId = ref<string | null>(null);
|
||||
const visitEditingRowId = ref<string | null>(null);
|
||||
const canReadVisit = computed(() => canUseApiPermission("visits:list"));
|
||||
const canCreateVisit = computed(() => canUseApiPermission("visits:create"));
|
||||
const canUpdateVisit = computed(() => canUseApiPermission("visits:update"));
|
||||
const canDeleteVisit = computed(() => canUseApiPermission("visits:delete"));
|
||||
@@ -639,7 +588,7 @@ const aeItems = ref<any[]>([]);
|
||||
const loadingAes = ref(false);
|
||||
const aeDialogVisible = ref(false);
|
||||
const aeEditingId = ref<string | null>(null);
|
||||
const canListAe = computed(() => canUseApiPermission("subject_aes:list"));
|
||||
const canListAe = computed(() => canUseApiPermission("subject_aes:read"));
|
||||
const canCreateAe = computed(() => canUseApiPermission("subject_aes:create"));
|
||||
const canUpdateAe = computed(() => canUseApiPermission("subject_aes:update"));
|
||||
const canDeleteAe = computed(() => canUseApiPermission("subject_aes:delete"));
|
||||
@@ -687,6 +636,12 @@ const canCreatePd = computed(() => canUseApiPermission("subject_pds:create"));
|
||||
const canUpdatePd = computed(() => canUseApiPermission("subject_pds:update"));
|
||||
const canDeletePd = computed(() => canUseApiPermission("subject_pds:delete"));
|
||||
const canSavePd = computed(() => (pdEditingId.value ? canUpdatePd.value : canCreatePd.value));
|
||||
const readableTabs = computed(() => [
|
||||
...(canReadHistory.value ? ["history"] : []),
|
||||
...(canReadVisit.value ? ["visits", "medicationAdherence"] : []),
|
||||
...(canListAe.value ? ["ae"] : []),
|
||||
...(canListPd.value ? ["pd"] : []),
|
||||
]);
|
||||
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 }));
|
||||
@@ -756,6 +711,10 @@ const loadSubject = async () => {
|
||||
try {
|
||||
const { data } = await getSubject(studyId, subjectId);
|
||||
Object.assign(detail, data);
|
||||
study.setViewContext({
|
||||
siteName: siteMap.value[detail.site_id] || TEXT.common.fallback,
|
||||
pageTitle: detail.subject_no || TEXT.modules.subjectManagement.screeningNo,
|
||||
});
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
} finally {
|
||||
@@ -763,7 +722,7 @@ const loadSubject = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const startSubjectEdit = () => {
|
||||
const openSubjectEditor = () => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
@@ -772,53 +731,20 @@ const startSubjectEdit = () => {
|
||||
ElMessage.warning(TEXT.common.messages.noPermission);
|
||||
return;
|
||||
}
|
||||
subjectForm.screening_date = detail.screening_date || "";
|
||||
subjectForm.consent_date = detail.consent_date || "";
|
||||
subjectForm.enrollment_date = detail.enrollment_date || "";
|
||||
subjectForm.baseline_date = detail.baseline_date || "";
|
||||
subjectForm.completion_date = detail.completion_date || "";
|
||||
subjectForm.drop_reason = detail.drop_reason || "";
|
||||
subjectEditing.value = true;
|
||||
subjectEditorVisible.value = true;
|
||||
};
|
||||
|
||||
const cancelSubjectEdit = () => {
|
||||
subjectEditing.value = false;
|
||||
};
|
||||
|
||||
const saveSubjectEdit = async () => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
if (!canUpdateSubject.value) {
|
||||
ElMessage.warning(TEXT.common.messages.noPermission);
|
||||
return;
|
||||
}
|
||||
if (!studyId || !subjectId) return;
|
||||
subjectSaving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
screening_date: subjectForm.screening_date || null,
|
||||
consent_date: subjectForm.consent_date || null,
|
||||
enrollment_date: subjectForm.enrollment_date || null,
|
||||
baseline_date: subjectForm.baseline_date || null,
|
||||
completion_date: subjectForm.completion_date || null,
|
||||
drop_reason: subjectForm.drop_reason || null,
|
||||
};
|
||||
await updateSubject(studyId, subjectId, payload);
|
||||
ElMessage.success(TEXT.common.messages.saveSuccess);
|
||||
subjectEditing.value = false;
|
||||
await loadSubject();
|
||||
await loadVisits();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
||||
} finally {
|
||||
subjectSaving.value = false;
|
||||
}
|
||||
const handleSubjectEditorSuccess = async () => {
|
||||
await loadSubject();
|
||||
await loadVisits();
|
||||
};
|
||||
|
||||
const loadHistories = async () => {
|
||||
if (!studyId || !subjectId) return;
|
||||
if (!canReadHistory.value) {
|
||||
historyItems.value = [];
|
||||
return;
|
||||
}
|
||||
loadingHistory.value = true;
|
||||
try {
|
||||
const { data } = (await listSubjectHistories(studyId, subjectId)) as any;
|
||||
@@ -896,6 +822,10 @@ const removeHistory = async (row: any) => {
|
||||
|
||||
const loadVisits = async () => {
|
||||
if (!studyId || !subjectId) return;
|
||||
if (!canReadVisit.value) {
|
||||
visitItems.value = [];
|
||||
return;
|
||||
}
|
||||
loadingVisits.value = true;
|
||||
try {
|
||||
const { data } = (await fetchVisits(studyId, subjectId)) as any;
|
||||
@@ -1510,14 +1440,18 @@ const removePd = async (row: any) => {
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => router.push("/subjects");
|
||||
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
() => {
|
||||
activeTab.value = resolveTabFromQuery();
|
||||
const syncActiveTab = () => {
|
||||
const requested = resolveTabFromQuery();
|
||||
if (readableTabs.value.includes(requested)) {
|
||||
activeTab.value = requested;
|
||||
return;
|
||||
}
|
||||
);
|
||||
if (readableTabs.value.length > 0) {
|
||||
activeTab.value = readableTabs.value[0];
|
||||
}
|
||||
};
|
||||
|
||||
watch([() => route.query.tab, readableTabs], syncActiveTab, { immediate: true });
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSites();
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
v-if="modelValue"
|
||||
:model-value="modelValue"
|
||||
direction="rtl"
|
||||
size="620px"
|
||||
:close-on-click-modal="true"
|
||||
:before-close="drawerDirtyGuard.beforeClose"
|
||||
:show-close="false"
|
||||
class="subject-editor-drawer"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<template #header>
|
||||
<div class="editor-header">
|
||||
<div class="editor-title">{{ subjectId ? TEXT.common.actions.edit : TEXT.common.actions.add }}{{ TEXT.modules.subjectManagement.subjectLabel }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form :model="form" label-position="top" class="subject-editor-form">
|
||||
<div class="form-group">
|
||||
<div class="form-group-title">
|
||||
<span class="group-dot group-dot-basic"></span>
|
||||
参与者标识
|
||||
</div>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.modules.subjectManagement.screeningNo" required>
|
||||
<el-input v-model="form.subject_no" :disabled="isEdit || isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.modules.subjectManagement.screeningNo" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.site" required>
|
||||
<el-select v-model="form.site_id" :disabled="isEdit || isReadOnly" :placeholder="TEXT.common.placeholders.select" class="full-width">
|
||||
<el-option v-for="site in sites" :key="site.id" :label="site.name" :value="site.id" :disabled="!site.is_active" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-group-title">
|
||||
<span class="group-dot group-dot-date"></span>
|
||||
关键日期
|
||||
</div>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.screeningDate">
|
||||
<el-date-picker v-model="form.screening_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" :disabled="isEdit || isReadOnly" class="full-width" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.consentDate">
|
||||
<el-date-picker v-model="form.consent_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" :disabled="isReadOnly" class="full-width" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.enrollmentDate">
|
||||
<el-date-picker v-model="form.enrollment_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" :disabled="isReadOnly" class="full-width" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.baselineDate">
|
||||
<el-date-picker v-model="form.baseline_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" :disabled="isReadOnly" class="full-width" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<template v-if="isEdit">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.completionDate">
|
||||
<el-date-picker v-model="form.completion_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" :disabled="isReadOnly" class="full-width" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item :label="TEXT.common.fields.dropReason">
|
||||
<el-input v-model="form.drop_reason" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" :disabled="isReadOnly" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<div class="drawer-footer">
|
||||
<el-button @click="emit('update:modelValue', false)">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">{{ TEXT.common.actions.save }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { createSubject, getSubject, updateSubject } from "../../api/subjects";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import { TEXT } from "../../locales";
|
||||
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
|
||||
import { getProjectRole, isSystemAdmin } from "../../utils/roles";
|
||||
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; subjectId?: string }>();
|
||||
const emit = defineEmits<{
|
||||
(event: "update:modelValue", value: boolean): void;
|
||||
(event: "success", id: string): void;
|
||||
}>();
|
||||
|
||||
const auth = useAuthStore();
|
||||
const study = useStudyStore();
|
||||
const saving = ref(false);
|
||||
const sites = ref<any[]>([]);
|
||||
const studyId = computed(() => study.currentStudy?.id || "");
|
||||
const isEdit = computed(() => !!props.subjectId);
|
||||
const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole));
|
||||
const canMutate = computed(() => {
|
||||
if (isSystemAdmin(auth.user)) return true;
|
||||
const role = projectRole.value;
|
||||
if (!role) return false;
|
||||
const operationKey = isEdit.value ? "subjects:update" : "subjects:create";
|
||||
return isApiPermissionAllowed(study.currentPermissions?.[role]?.[operationKey]);
|
||||
});
|
||||
|
||||
const form = reactive({
|
||||
subject_no: "",
|
||||
site_id: "",
|
||||
screening_date: "",
|
||||
consent_date: "",
|
||||
enrollment_date: "",
|
||||
baseline_date: "",
|
||||
completion_date: "",
|
||||
drop_reason: "",
|
||||
});
|
||||
|
||||
const siteActiveMap = computed(() => new Map(sites.value.map((site) => [site.id, !!site.is_active])));
|
||||
const isReadOnly = computed(() => !canMutate.value || (isEdit.value && !!form.site_id && siteActiveMap.value.get(form.site_id) === false));
|
||||
const drawerDirtyGuard = useDrawerDirtyGuard(() => form);
|
||||
|
||||
const reset = () => {
|
||||
form.subject_no = "";
|
||||
form.site_id = "";
|
||||
form.screening_date = "";
|
||||
form.consent_date = "";
|
||||
form.enrollment_date = "";
|
||||
form.baseline_date = "";
|
||||
form.completion_date = "";
|
||||
form.drop_reason = "";
|
||||
};
|
||||
|
||||
const loadSites = async () => {
|
||||
if (!studyId.value) return;
|
||||
try {
|
||||
const { data } = await fetchSites(studyId.value, { limit: 500 });
|
||||
sites.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch {
|
||||
sites.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
reset();
|
||||
await loadSites();
|
||||
if (!isEdit.value || !studyId.value || !props.subjectId) {
|
||||
drawerDirtyGuard.syncBaseline();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data } = await getSubject(studyId.value, props.subjectId);
|
||||
Object.assign(form, {
|
||||
subject_no: data.subject_no || "",
|
||||
site_id: data.site_id || "",
|
||||
screening_date: data.screening_date || "",
|
||||
consent_date: data.consent_date || "",
|
||||
enrollment_date: data.enrollment_date || "",
|
||||
baseline_date: data.baseline_date || "",
|
||||
completion_date: data.completion_date || "",
|
||||
drop_reason: data.drop_reason || "",
|
||||
});
|
||||
drawerDirtyGuard.syncBaseline();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!canMutate.value) {
|
||||
ElMessage.warning("权限不足");
|
||||
return;
|
||||
}
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
if (!studyId.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
let id = props.subjectId || "";
|
||||
if (props.subjectId) {
|
||||
await updateSubject(studyId.value, props.subjectId, {
|
||||
consent_date: form.consent_date || null,
|
||||
enrollment_date: form.enrollment_date || null,
|
||||
baseline_date: form.baseline_date || null,
|
||||
completion_date: form.completion_date || null,
|
||||
drop_reason: form.drop_reason || null,
|
||||
});
|
||||
} else {
|
||||
const { data } = await createSubject(studyId.value, {
|
||||
subject_no: form.subject_no,
|
||||
site_id: form.site_id,
|
||||
screening_date: form.screening_date || null,
|
||||
consent_date: form.consent_date || null,
|
||||
enrollment_date: form.enrollment_date || null,
|
||||
baseline_date: form.baseline_date || null,
|
||||
});
|
||||
id = data.id;
|
||||
}
|
||||
ElMessage.success(props.subjectId ? TEXT.common.messages.saveSuccess : TEXT.common.messages.createSuccess);
|
||||
drawerDirtyGuard.syncBaseline();
|
||||
emit("success", id);
|
||||
emit("update:modelValue", false);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [props.modelValue, props.subjectId, studyId.value] as const,
|
||||
([visible]) => {
|
||||
if (visible) load();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.editor-title {
|
||||
color: var(--ctms-text-main);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.subject-editor-form {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.form-group + .form-group {
|
||||
border-top: 1px solid #edf2f7;
|
||||
}
|
||||
|
||||
.form-group-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
color: var(--ctms-text-main);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.group-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.group-dot-basic {
|
||||
background: var(--ctms-primary);
|
||||
}
|
||||
|
||||
.group-dot-date {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.drawer-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user