6e8494abd5
1、将 FAQ 分类和问题维护改为抽屉交互,并按分类、问题、回复的细粒度权限控制入口。 2、将注意事项、可行性和伦理记录接入详情页抽屉编辑,创建时支持先保存主记录再上传附件。 3、将参与者基础信息编辑改为抽屉,详情页按可读权限显示病史、访视、AE 和 PD 标签页。 4、补充业务页面权限一致性测试,覆盖停用中心、无权限入口和面包屑上下文。
266 lines
8.0 KiB
Vue
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>
|