统一业务页面权限与抽屉编辑

1、将 FAQ 分类和问题维护改为抽屉交互,并按分类、问题、回复的细粒度权限控制入口。

2、将注意事项、可行性和伦理记录接入详情页抽屉编辑,创建时支持先保存主记录再上传附件。

3、将参与者基础信息编辑改为抽屉,详情页按可读权限显示病史、访视、AE 和 PD 标签页。

4、补充业务页面权限一致性测试,覆盖停用中心、无权限入口和面包屑上下文。
This commit is contained in:
Cheng Zhou
2026-06-04 11:10:20 +08:00
parent 6a2e43f829
commit 6e8494abd5
39 changed files with 2334 additions and 337 deletions
@@ -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");
});
});
+59 -125
View File
@@ -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>