Files
ctms/frontend/src/views/knowledge/PrecautionEditorDrawer.vue
T
Cheng Zhou 6e8494abd5 统一业务页面权限与抽屉编辑
1、将 FAQ 分类和问题维护改为抽屉交互,并按分类、问题、回复的细粒度权限控制入口。

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

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

4、补充业务页面权限一致性测试,覆盖停用中心、无权限入口和面包屑上下文。
2026-06-04 11:10:20 +08:00

266 lines
8.0 KiB
Vue

<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="precaution-editor-drawer"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header>
<div class="editor-header">
<div class="editor-title">{{ precautionId ? TEXT.modules.knowledgeNotes.editTitle : TEXT.modules.knowledgeNotes.newTitle }}</div>
</div>
</template>
<el-form :model="form" label-position="top" class="precaution-editor-form">
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-basic"></span>
{{ TEXT.modules.knowledgeNotes.title }}
</div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.site" required>
<el-select v-model="form.site_name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select" filterable class="full-width">
<el-option
v-for="site in sites"
:key="site.id || site.name"
:label="site.name"
:value="site.name"
:disabled="site.is_active === false"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.level">
<el-select v-model="form.level" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select" clearable class="full-width">
<el-option v-for="level in levelOptions" :key="level.value" :label="level.label" :value="level.value" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item :label="TEXT.common.fields.title" required>
<el-input v-model="form.title" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.title" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.content" required>
<el-input v-model="form.content" :disabled="isReadOnly" type="textarea" :rows="4" :placeholder="TEXT.common.placeholders.input" />
</el-form-item>
</div>
<div class="form-group attachment-form-group">
<div class="form-group-title">
<span class="group-dot group-dot-attachment"></span>
{{ TEXT.common.labels.attachments }}
</div>
<AttachmentList
ref="attachmentPanelRef"
:study-id="studyId"
:entity-type="'precaution'"
:entity-id="precautionId || ''"
:mode="'upload'"
:readonly="isReadOnly"
/>
</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 { createPrecaution, getPrecaution, updatePrecaution } from "../../api/precautions";
import { fetchSites } from "../../api/sites";
import { useStudyStore } from "../../store/study";
import { usePermission } from "../../utils/permission";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import { TEXT } from "../../locales";
import { precautionLevelOptions } from "./precautionOptions";
const props = defineProps<{ modelValue: boolean; precautionId?: string }>();
const emit = defineEmits<{
(event: "update:modelValue", value: boolean): void;
(event: "success", id: string): void;
}>();
const study = useStudyStore();
const { can } = usePermission();
const saving = ref(false);
const sites = ref<any[]>([]);
const levelOptions = precautionLevelOptions;
const studyId = computed(() => study.currentStudy?.id || "");
const isEdit = computed(() => !!props.precautionId);
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
const form = reactive({
site_name: "",
title: "",
level: "",
content: "",
});
const siteActiveMap = computed(() => new Map(sites.value.map((site) => [site.name, !!site.is_active])));
const canCreatePrecaution = computed(() => can("precautions.create"));
const canUpdatePrecaution = computed(() => can("precautions.update"));
const canSavePrecaution = computed(() => (isEdit.value ? canUpdatePrecaution.value : canCreatePrecaution.value));
const isInactiveSite = computed(() => isEdit.value && !!form.site_name && siteActiveMap.value.get(form.site_name) === false);
const isReadOnly = computed(() => !canSavePrecaution.value || isInactiveSite.value);
const drawerDirtyGuard = useDrawerDirtyGuard(() => ({
form,
attachments: attachmentPanelRef.value?.pendingSnapshot() || [],
}));
const reset = () => {
form.site_name = "";
form.title = "";
form.level = "";
form.content = "";
};
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.precautionId) {
drawerDirtyGuard.syncBaseline();
return;
}
try {
const { data } = await getPrecaution(studyId.value, props.precautionId);
Object.assign(form, {
site_name: data.site_name || "",
title: data.title || "",
level: data.level || "",
content: data.content || "",
});
drawerDirtyGuard.syncBaseline();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
}
};
const submit = async () => {
if (!studyId.value) return;
if (!canSavePrecaution.value) {
ElMessage.warning("权限不足");
return;
}
if (isReadOnly.value || siteActiveMap.value.get(form.site_name) === false) {
ElMessage.warning("中心已停用");
return;
}
if (!form.site_name) {
ElMessage.warning(TEXT.common.messages.required);
return;
}
saving.value = true;
try {
const payload = {
site_name: form.site_name,
title: form.title,
level: form.level || null,
content: form.content,
};
let id = props.precautionId || "";
if (props.precautionId) {
await updatePrecaution(studyId.value, props.precautionId, payload);
} else {
const { data } = await createPrecaution(studyId.value, payload);
id = data.id;
}
await attachmentPanelRef.value?.uploadPending(id);
ElMessage.success(props.precautionId ? 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.precautionId, 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;
}
.precaution-editor-form {
padding: 0 4px;
}
.form-group {
padding: 16px 0;
}
.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-attachment {
background: #22c55e;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.full-width {
width: 100%;
}
</style>