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

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
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./PrecautionDetail.vue"), "utf8");
describe("PrecautionDetail project permissions", () => {
it("hides edit controls unless the backend update operation is allowed", () => {
const source = readSource();
expect(source).toContain('const canUpdatePrecaution = computed(() => can("precautions.update"))');
expect(source).toContain('v-if="canUpdatePrecaution"');
expect(source).toContain("if (!canUpdatePrecaution.value)");
expect(source).not.toContain("canWritePrecautions");
});
});
describe("PrecautionDetail drawer editor", () => {
it("opens edit in the shared drawer editor instead of routing to the form page", () => {
const source = readSource();
expect(source).toContain("PrecautionEditorDrawer");
expect(source).toContain("precautionEditorVisible");
expect(source).toContain("@success=\"handlePrecautionEditorSuccess\"");
expect(source).not.toContain("router.push(`/knowledge/precautions/${precautionId}/edit`)");
});
});
@@ -6,8 +6,7 @@
<p class="page-subtitle">{{ TEXT.modules.knowledgeNotes.detailSubtitle }}</p>
</div>
<div class="actions">
<el-button type="primary" :disabled="isReadOnly" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
<el-button v-if="canUpdatePrecaution" type="primary" :disabled="isInactiveSite" @click="openEditor">{{ TEXT.common.actions.edit }}</el-button>
</div>
</div>
@@ -26,13 +25,20 @@
entity-type="precaution"
:entity-id="precautionId"
:readonly="isReadOnly"
:hide-uploader="true"
:refresh-key="attachmentRefreshKey"
/>
<PrecautionEditorDrawer
v-model="precautionEditorVisible"
:precaution-id="precautionId"
@success="handlePrecautionEditorSuccess"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useRoute } from "vue-router";
import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study";
import { getPrecaution } from "../../api/precautions";
@@ -40,12 +46,14 @@ import { fetchSites } from "../../api/sites";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import { usePermission } from "../../utils/permission";
import { TEXT } from "../../locales";
import PrecautionEditorDrawer from "./PrecautionEditorDrawer.vue";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const { can } = usePermission();
const loading = ref(false);
const precautionEditorVisible = ref(false);
const attachmentRefreshKey = ref(0);
const precautionId = route.params.id as string;
const studyId = study.currentStudy?.id || "";
const sites = ref<any[]>([]);
@@ -64,8 +72,9 @@ const siteActiveMap = computed(() => {
});
return map;
});
const canWritePrecautions = computed(() => can("precautions.write"));
const isReadOnly = computed(() => !canWritePrecautions.value || (!!detail.site_name && siteActiveMap.value[detail.site_name] === false));
const canUpdatePrecaution = computed(() => can("precautions.update"));
const isInactiveSite = computed(() => !!detail.site_name && siteActiveMap.value[detail.site_name] === false);
const isReadOnly = computed(() => !canUpdatePrecaution.value || isInactiveSite.value);
const loadSites = async () => {
if (!studyId) return;
@@ -83,6 +92,10 @@ const load = async () => {
try {
const { data } = await getPrecaution(studyId, precautionId);
Object.assign(detail, data);
study.setViewContext({
siteName: detail.site_name || TEXT.common.fallback,
pageTitle: detail.title || TEXT.modules.knowledgeNotes.detailTitle,
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
@@ -90,14 +103,22 @@ const load = async () => {
}
};
const goEdit = () => {
if (isReadOnly.value) {
ElMessage.warning(canWritePrecautions.value ? "中心已停用" : "权限不足");
const openEditor = () => {
if (!canUpdatePrecaution.value) {
ElMessage.warning("权限不足");
return;
}
router.push(`/knowledge/precautions/${precautionId}/edit`);
if (isInactiveSite.value) {
ElMessage.warning("中心已停用");
return;
}
precautionEditorVisible.value = true;
};
const handlePrecautionEditorSuccess = async () => {
await load();
attachmentRefreshKey.value += 1;
};
const goBack = () => router.push("/knowledge/precautions");
onMounted(async () => {
await loadSites();
@@ -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>
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./PrecautionForm.vue"), "utf8");
describe("PrecautionForm project permissions", () => {
it("uses create permission for new records and update permission for edits", () => {
const source = readSource();
expect(source).toContain('const canCreatePrecaution = computed(() => can("precautions.create"))');
expect(source).toContain('const canUpdatePrecaution = computed(() => can("precautions.update"))');
expect(source).toContain("const canSavePrecaution = computed(() => (isEdit.value ? canUpdatePrecaution.value : canCreatePrecaution.value))");
expect(source).toContain("if (!canSavePrecaution.value)");
expect(source).not.toContain("canWritePrecautions");
});
it("uses selects for site and level fields", () => {
const source = readSource();
expect(source).toContain('<el-select v-model="form.site_name"');
expect(source).toContain('v-for="site in sites"');
expect(source).toContain(':value="site.name"');
expect(source).toContain('<el-select v-model="form.level"');
expect(source).toContain("const levelOptions = precautionLevelOptions");
expect(source).toContain('v-for="level in levelOptions"');
expect(source).not.toContain('<el-input v-model="form.site_name"');
expect(source).not.toContain('<el-input v-model="form.level"');
});
it("allows attachments to be selected before creating a precaution", () => {
const source = readSource();
expect(source).toContain("<AttachmentList");
expect(source).toContain('ref="attachmentPanelRef"');
expect(source).toContain('entity-type="precaution"');
expect(source).toContain(':entity-id="precautionId || \'\'"');
expect(source).toContain(':mode="\'upload\'"');
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(savedId)");
expect(source).not.toContain('v-if="isEdit && precautionId"');
expect(source).not.toContain("StateEmpty");
});
});
+34 -17
View File
@@ -11,13 +11,23 @@
<el-card class="unified-shell">
<el-form :model="form" label-width="120px">
<el-form-item :label="TEXT.common.fields.site" required>
<el-input v-model="form.site_name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
<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-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.level">
<el-input v-model="form.level" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input" />
<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-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" />
@@ -30,15 +40,13 @@
</el-card>
<AttachmentList
v-if="isEdit && precautionId"
ref="attachmentPanelRef"
:study-id="studyId"
entity-type="precaution"
:entity-id="precautionId"
:entity-id="precautionId || ''"
:readonly="isReadOnly"
:mode="'upload'"
/>
<el-card v-else class="hint-card unified-shell">
<StateEmpty :description="TEXT.modules.knowledgeNotes.uploadHint" />
</el-card>
</div>
</template>
@@ -50,16 +58,18 @@ import { useStudyStore } from "../../store/study";
import { createPrecaution, getPrecaution, updatePrecaution } from "../../api/precautions";
import { fetchSites } from "../../api/sites";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue";
import { usePermission } from "../../utils/permission";
import { TEXT } from "../../locales";
import { precautionLevelOptions } from "./precautionOptions";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const { can } = usePermission();
const saving = ref(false);
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
const sites = ref<any[]>([]);
const levelOptions = precautionLevelOptions;
const precautionId = computed(() => route.params.id as string | undefined);
const isEdit = computed(() => !!precautionId.value);
@@ -79,8 +89,10 @@ const siteActiveMap = computed(() => {
});
return map;
});
const canWritePrecautions = computed(() => can("precautions.write"));
const isReadOnly = computed(() => !canWritePrecautions.value || (isEdit.value && !!form.site_name && siteActiveMap.value[form.site_name] === false));
const canCreatePrecaution = computed(() => can("precautions.create"));
const canUpdatePrecaution = computed(() => can("precautions.update"));
const canSavePrecaution = computed(() => (isEdit.value ? canUpdatePrecaution.value : canCreatePrecaution.value));
const isReadOnly = computed(() => !canSavePrecaution.value || (isEdit.value && !!form.site_name && siteActiveMap.value[form.site_name] === false));
const loadSites = async () => {
if (!studyId.value) return;
@@ -109,8 +121,12 @@ const load = async () => {
const submit = async () => {
if (!studyId.value) return;
if (!canSavePrecaution.value) {
ElMessage.warning("权限不足");
return;
}
if (isReadOnly.value) {
ElMessage.warning(canWritePrecautions.value ? "中心已停用" : "权限不足");
ElMessage.warning("中心已停用");
return;
}
if (!form.site_name) {
@@ -129,15 +145,16 @@ const submit = async () => {
level: form.level || null,
content: form.content,
};
let savedId = precautionId.value || "";
if (isEdit.value && precautionId.value) {
await updatePrecaution(studyId.value, precautionId.value, payload);
ElMessage.success(TEXT.common.messages.saveSuccess);
router.push(`/knowledge/precautions/${precautionId.value}`);
} else {
const { data } = await createPrecaution(studyId.value, payload);
ElMessage.success(TEXT.common.messages.createSuccess);
router.push(`/knowledge/precautions/${data.id}`);
savedId = data.id;
}
await attachmentPanelRef.value?.uploadPending(savedId);
ElMessage.success(isEdit.value ? TEXT.common.messages.saveSuccess : TEXT.common.messages.createSuccess);
router.push(`/knowledge/precautions/${savedId}`);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
@@ -178,7 +195,7 @@ onMounted(async () => {
color: var(--ctms-text-secondary);
}
.hint-card {
padding: 12px;
.full-width {
width: 100%;
}
</style>
@@ -0,0 +1,5 @@
export const precautionLevelOptions = [
{ label: "低", value: "低" },
{ label: "中", value: "中" },
{ label: "高", value: "高" },
];