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