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

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
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = (relativePath: string) => readFileSync(resolve(__dirname, relativePath), "utf8");
describe("precautions project permissions", () => {
it("splits list create and delete controls by backend operation permissions", () => {
const source = readSource("./Precautions.vue");
expect(source).toContain('const canCreatePrecaution = computed(() => can("precautions.create"))');
expect(source).toContain('const canDeletePrecaution = computed(() => can("precautions.delete"))');
expect(source).toContain('v-if="canCreatePrecaution"');
expect(source).toContain('v-if="canDeletePrecaution"');
expect(source).toContain("if (!canDeletePrecaution.value)");
expect(source).not.toContain("canWritePrecautions");
});
});
describe("precautions drawer editor", () => {
it("opens create form in a drawer instead of routing to the form page", () => {
const source = readSource("./Precautions.vue");
expect(source).toContain("PrecautionEditorDrawer");
expect(source).toContain("precautionDrawerVisible");
expect(source).not.toContain('router.push("/knowledge/precautions/new")');
});
it("uses selects for site and level fields in the create drawer", () => {
const source = readSource("../knowledge/PrecautionEditorDrawer.vue");
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("moves precaution attachment upload into the editor drawer", () => {
const drawer = readSource("../knowledge/PrecautionEditorDrawer.vue");
const detail = readSource("../knowledge/PrecautionDetail.vue");
expect(drawer).toContain("AttachmentList");
expect(drawer).toContain('ref="attachmentPanelRef"');
expect(drawer).toContain(':entity-type="\'precaution\'"');
expect(drawer).toContain(':mode="\'upload\'"');
expect(drawer).toContain("attachments: attachmentPanelRef.value?.pendingSnapshot() || []");
expect(drawer).toContain("await attachmentPanelRef.value?.uploadPending(id)");
expect(detail).toContain(":hide-uploader=\"true\"");
expect(detail).toContain(":refresh-key=\"attachmentRefreshKey\"");
expect(detail).toContain("attachmentRefreshKey.value += 1");
});
});
+21 -5
View File
@@ -24,7 +24,7 @@
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
</div>
<div class="filter-spacer"></div>
<el-button v-if="canWritePrecautions" type="primary" @click="goNew">
<el-button v-if="canCreatePrecaution" type="primary" @click="goNew">
{{ TEXT.modules.knowledgeNotes.newTitle }}
</el-button>
</el-form>
@@ -45,10 +45,10 @@
<el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" show-overflow-tooltip>
<template #default="scope">{{ displayDateTime(scope.row.updated_at) }}</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120">
<el-table-column v-if="canDeletePrecaution" :label="TEXT.common.labels.actions" width="120">
<template #default="scope">
<el-button
v-if="canWritePrecautions"
v-if="canDeletePrecaution"
link
type="danger"
size="small"
@@ -65,6 +65,10 @@
</el-table>
</div>
</div>
<PrecautionEditorDrawer
v-model="precautionDrawerVisible"
@success="handlePrecautionEditorSuccess"
/>
</div>
</template>
@@ -78,11 +82,13 @@ import { fetchSites } from "../../api/sites";
import { displayDateTime } from "../../utils/display";
import { usePermission } from "../../utils/permission";
import { TEXT } from "../../locales";
import PrecautionEditorDrawer from "../knowledge/PrecautionEditorDrawer.vue";
const router = useRouter();
const study = useStudyStore();
const { can } = usePermission();
const loading = ref(false);
const precautionDrawerVisible = ref(false);
const items = ref<any[]>([]);
const sites = ref<any[]>([]);
const filters = reactive({
@@ -112,7 +118,8 @@ const filteredItems = computed(() => {
const sortedItems = computed(() =>
[...filteredItems.value].sort((a, b) => Number(isInactiveSite(a?.site_name)) - Number(isInactiveSite(b?.site_name)))
);
const canWritePrecautions = computed(() => can("precautions.write"));
const canCreatePrecaution = computed(() => can("precautions.create"));
const canDeletePrecaution = computed(() => can("precautions.delete"));
const loadSites = async () => {
const studyId = study.currentStudy?.id;
@@ -144,8 +151,13 @@ const resetFilters = () => {
filters.keyword = "";
};
const goNew = () => router.push("/knowledge/precautions/new");
const goNew = () => {
precautionDrawerVisible.value = true;
};
const goDetail = (id: string) => router.push(`/knowledge/precautions/${id}`);
const handlePrecautionEditorSuccess = () => {
load();
};
const onRowClick = (row: any) => {
if (!row?.id) return;
goDetail(row.id);
@@ -154,6 +166,10 @@ const onRowClick = (row: any) => {
const remove = async (row: any) => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
if (!canDeletePrecaution.value) {
ElMessage.warning("权限不足");
return;
}
if (isInactiveSite(row?.site_name)) {
ElMessage.warning("中心已停用");
return;
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./ProjectMilestones.vue"), "utf8");
describe("ProjectMilestones project permissions", () => {
it("hides edit controls and blocks saves without project milestone update permission", () => {
const source = readSource();
expect(source).toContain('const canUpdateMilestones = computed(() => can("project.milestones.update"))');
expect(source).toContain('v-if="canUpdateMilestones"');
expect(source).toContain("if (!canUpdateMilestones.value)");
});
});
+19 -4
View File
@@ -102,9 +102,9 @@
<el-table-column prop="remark" :label="TEXT.modules.projectMilestones.columns.remark" show-overflow-tooltip>
<template #default="scope">{{ scope.row.remark || TEXT.common.fallback }}</template>
</el-table-column>
<el-table-column :label="TEXT.modules.projectMilestones.columns.operations" width="100">
<el-table-column v-if="canUpdateMilestones" :label="TEXT.modules.projectMilestones.columns.operations" width="100">
<template #default="scope">
<el-button link type="primary" @click="openEditor(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
<el-button v-if="canUpdateMilestones" link type="primary" @click="openEditor(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
</template>
</el-table-column>
<template #empty>
@@ -120,7 +120,8 @@
v-model="editorVisible"
direction="rtl"
size="460px"
:close-on-click-modal="false"
:close-on-click-modal="true"
:before-close="editorDirtyGuard.beforeClose"
:show-close="false"
class="milestone-editor-drawer"
>
@@ -185,7 +186,7 @@
<template #footer>
<div class="time-editor-footer">
<el-button @click="editorVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" @click="saveEditor">{{ TEXT.common.actions.save }}</el-button>
<el-button v-if="canUpdateMilestones" type="primary" @click="saveEditor">{{ TEXT.common.actions.save }}</el-button>
</div>
</template>
</el-drawer>
@@ -200,6 +201,8 @@ import { listProjectMilestones, updateProjectMilestone } from "../../api/project
import { TEXT } from "../../locales";
import StateEmpty from "../../components/StateEmpty.vue";
import { displayDateTime } from "../../utils/display";
import { usePermission } from "../../utils/permission";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
interface MilestoneRow {
id: string;
@@ -231,6 +234,7 @@ type MilestoneLocalEdit = {
};
const study = useStudyStore();
const { can } = usePermission();
const rows = ref<MilestoneRow[]>([]);
const loading = ref(false);
const dataSourceLabel = ref<string>(TEXT.modules.projectMilestones.sourcePublished);
@@ -244,8 +248,10 @@ const editorForm = reactive<MilestoneLocalEdit>({
actualEndDate: "",
remark: "",
});
const editorDirtyGuard = useDrawerDirtyGuard(() => editorForm);
const doneCount = computed(() => rows.value.filter((item) => getStatusView(item).completed).length);
const canUpdateMilestones = computed(() => can("project.milestones.update"));
const toDateOnly = (value?: string): Date | null => {
if (!value) return null;
@@ -398,18 +404,27 @@ const validateDateRange = (start?: string, end?: string, label?: string) => {
};
const openEditor = (row: MilestoneRow) => {
if (!canUpdateMilestones.value) {
ElMessage.warning("权限不足");
return;
}
editingRowId.value = row.id;
editorForm.adjustedStartDate = row.adjustedStartDate || "";
editorForm.adjustedEndDate = row.adjustedEndDate || "";
editorForm.actualStartDate = row.actualStartDate || "";
editorForm.actualEndDate = row.actualEndDate || "";
editorForm.remark = row.remark || "";
editorDirtyGuard.syncBaseline();
editorVisible.value = true;
};
const saveEditor = async () => {
const studyId = study.currentStudy?.id;
if (!studyId || !editingRowId.value) return;
if (!canUpdateMilestones.value) {
ElMessage.warning("权限不足");
return;
}
if (!validateDateRange(editorForm.adjustedStartDate, editorForm.adjustedEndDate, "调整计划时间")) return;
if (!validateDateRange(editorForm.actualStartDate, editorForm.actualEndDate, "实际时间")) return;
const patch = {
@@ -13,7 +13,7 @@ describe("RiskIssueMonitoringVisits permissions", () => {
expect(source).toContain("getProjectRole");
expect(source).toContain("isApiPermissionAllowed");
[
"monitoring_issues:list",
"monitoring_issues:read",
"monitoring_issues:read",
"monitoring_issues:create",
"monitoring_issues:update",
@@ -26,6 +26,9 @@ describe("RiskIssueMonitoringVisits permissions", () => {
expect(source).toContain('v-if="canReadIssue"');
expect(source).toContain('v-if="canUpdateIssue"');
expect(source).toContain('v-if="canDeleteIssue"');
expect(source).toContain('v-if="canSaveIssue"');
expect(source).not.toContain(':disabled="!canSaveIssue"');
expect(source).toContain("const canSaveIssue = computed(() => (formMode.value === \"edit\" ? canUpdateIssue.value : canCreateIssue.value));");
expect(source).toContain('ElMessage.warning("权限不足")');
});
});
@@ -207,7 +207,8 @@
v-model="formDialogVisible"
direction="rtl"
size="580px"
:close-on-click-modal="false"
:close-on-click-modal="true"
:before-close="formDirtyGuard.beforeClose"
:show-close="false"
class="issue-editor-drawer"
>
@@ -411,7 +412,7 @@
<template #footer>
<div class="editor-footer">
<el-button @click="formDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="saving" :disabled="!canSaveIssue" @click="submitForm">{{ TEXT.common.actions.save }}</el-button>
<el-button v-if="canSaveIssue" type="primary" :loading="saving" @click="submitForm">{{ TEXT.common.actions.save }}</el-button>
</div>
</template>
</el-drawer>
@@ -474,6 +475,7 @@ import type { Site } from "../../types/api";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { displayDateTime } from "../../utils/display";
import { getProjectRole, isSystemAdmin } from "../../utils/roles";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
interface MonitoringIssueRow {
id: string;
@@ -534,7 +536,7 @@ const canUseApiPermission = (operationKey: string) => {
if (!role) return false;
return isApiPermissionAllowed(study.currentPermissions?.[role]?.[operationKey]);
};
const canListIssues = computed(() => canUseApiPermission("monitoring_issues:list"));
const canListIssues = computed(() => canUseApiPermission("monitoring_issues:read"));
const canReadIssue = computed(() => canUseApiPermission("monitoring_issues:read"));
const canCreateIssue = computed(() => canUseApiPermission("monitoring_issues:create"));
const canUpdateIssue = computed(() => canUseApiPermission("monitoring_issues:update"));
@@ -605,6 +607,7 @@ const createForm = reactive({
closed_date: "",
responsible_name: "",
});
const formDirtyGuard = useDrawerDirtyGuard(() => createForm);
const createRules: FormRules = {
source: [{ required: true, message: "请选择问题来源", trigger: "change" }],
@@ -831,6 +834,7 @@ const openCreateDialog = () => {
resetCreateForm();
formMode.value = "create";
editingIssueId.value = "";
formDirtyGuard.syncBaseline();
formDialogVisible.value = true;
};
@@ -843,6 +847,7 @@ const openEditDialog = async (row: MonitoringIssueRow) => {
formMode.value = "edit";
editingIssueId.value = row.id;
fillFormFromIssue(issue);
formDirtyGuard.syncBaseline();
formDialogVisible.value = true;
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.loadFailed);
@@ -863,7 +868,11 @@ const openViewDialog = async (row: MonitoringIssueRow) => {
const submitForm = async () => {
const studyId = study.currentStudy?.id;
if (!studyId || !canSaveIssue.value) return;
if (!studyId) return;
if (!canSaveIssue.value) {
ElMessage.warning("权限不足");
return;
}
const ok = await createFormRef.value?.validate().catch(() => false);
if (!ok) return;
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./StartupFeasibilityEthics.vue"), "utf8");
describe("StartupFeasibilityEthics project permissions", () => {
it("hides initiation and ethics create/delete actions by their backend operation permissions", () => {
const source = readSource();
expect(source).toContain('canUseApiPermission("startup_initiation:create")');
expect(source).toContain('canUseApiPermission("startup_initiation:read")');
expect(source).toContain('canUseApiPermission("startup_initiation:update")');
expect(source).toContain('canUseApiPermission("startup_initiation:delete")');
expect(source).toContain('canUseApiPermission("startup_ethics:read")');
expect(source).toContain('canUseApiPermission("startup_ethics:create")');
expect(source).toContain('canUseApiPermission("startup_ethics:update")');
expect(source).toContain('canUseApiPermission("startup_ethics:delete")');
expect(source).toContain('v-if="canReadFeasibility"');
expect(source).toContain('v-if="canReadEthics"');
expect(source).toContain("if (!canReadFeasibility.value)");
expect(source).toContain("if (!canReadEthics.value)");
expect(source).toContain('v-if="canCreateFeasibility"');
expect(source).toContain('v-if="canUpdateFeasibility"');
expect(source).toContain('v-if="canDeleteFeasibility"');
expect(source).toContain('v-if="canCreateEthics"');
expect(source).toContain('v-if="canUpdateEthics"');
expect(source).toContain('v-if="canDeleteEthics"');
});
});
describe("StartupFeasibilityEthics drawer editors", () => {
it("opens initiation and ethics create forms in drawers instead of routing to form pages", () => {
const source = readSource();
expect(source).toContain("FeasibilityEditorDrawer");
expect(source).toContain("EthicsEditorDrawer");
expect(source).toContain("feasibilityDrawerVisible");
expect(source).toContain("ethicsDrawerVisible");
expect(source).not.toContain('router.push("/startup/feasibility/new")');
expect(source).not.toContain('router.push("/startup/ethics/new")');
});
it("opens initiation and ethics edit drawers from table action columns", () => {
const source = readSource();
expect(source).toContain(':record-id="editingFeasibilityId"');
expect(source).toContain(':record-id="editingEthicsId"');
expect(source).toContain("const editingFeasibilityId = ref<string | undefined>();");
expect(source).toContain("const editingEthicsId = ref<string | undefined>();");
expect(source).toContain("@click.stop=\"openFeasibilityEditor(scope.row)\"");
expect(source).toContain("@click.stop=\"openEthicsEditor(scope.row)\"");
expect(source).toContain("editingFeasibilityId.value = row?.id;");
expect(source).toContain("editingEthicsId.value = row?.id;");
});
});
@@ -1,13 +1,13 @@
<template>
<div class="page ctms-page-shell page--flush">
<div class="main-content-flat unified-shell">
<el-tabs v-model="activeTab" class="ctms-tabs">
<el-tab-pane :label="TEXT.modules.startupFeasibilityEthics.feasibilityTab" name="feasibility">
<el-tabs v-if="canReadFeasibility || canReadEthics" v-model="activeTab" class="ctms-tabs">
<el-tab-pane v-if="canReadFeasibility" :label="TEXT.modules.startupFeasibilityEthics.feasibilityTab" name="feasibility">
<div class="ctms-tab-content unified-section section--flush">
<div class="ctms-section-header">
<div class="ctms-section-title">{{ TEXT.modules.startupFeasibilityEthics.feasibilityTab }}</div>
<div class="ctms-section-actions">
<el-button type="primary" @click="goNewFeasibility">
<el-button v-if="canCreateFeasibility" type="primary" @click="goNewFeasibility">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.modules.startupFeasibilityEthics.feasibilityNewTitle }}
</el-button>
@@ -47,9 +47,19 @@
<span class="font-mono text-main">{{ row.project_no }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120" align="center">
<el-table-column v-if="canUpdateFeasibility || canDeleteFeasibility" :label="TEXT.common.labels.actions" width="160" align="center">
<template #default="scope">
<el-button
v-if="canUpdateFeasibility"
link
type="primary"
:disabled="isInactiveSite(scope.row.site_id)"
@click.stop="openFeasibilityEditor(scope.row)"
>
{{ TEXT.common.actions.edit }}
</el-button>
<el-button
v-if="canDeleteFeasibility"
link
type="danger"
:disabled="isInactiveSite(scope.row.site_id)"
@@ -65,12 +75,12 @@
</el-table>
</div>
</el-tab-pane>
<el-tab-pane :label="TEXT.modules.startupFeasibilityEthics.ethicsTab" name="ethics">
<el-tab-pane v-if="canReadEthics" :label="TEXT.modules.startupFeasibilityEthics.ethicsTab" name="ethics">
<div class="ctms-tab-content unified-section section--flush">
<div class="ctms-section-header">
<div class="ctms-section-title">{{ TEXT.modules.startupFeasibilityEthics.ethicsTab }}</div>
<div class="ctms-section-actions">
<el-button type="primary" @click="goNewEthics">
<el-button v-if="canCreateEthics" type="primary" @click="goNewEthics">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.modules.startupFeasibilityEthics.ethicsNewTitle }}
</el-button>
@@ -113,9 +123,19 @@
<span class="font-mono text-main">{{ row.approval_no }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120" align="center">
<el-table-column v-if="canUpdateEthics || canDeleteEthics" :label="TEXT.common.labels.actions" width="160" align="center">
<template #default="scope">
<el-button
v-if="canUpdateEthics"
link
type="primary"
:disabled="isInactiveSite(scope.row.site_id)"
@click.stop="openEthicsEditor(scope.row)"
>
{{ TEXT.common.actions.edit }}
</el-button>
<el-button
v-if="canDeleteEthics"
link
type="danger"
:disabled="isInactiveSite(scope.row.site_id)"
@@ -133,14 +153,26 @@
</el-tab-pane>
</el-tabs>
</div>
<FeasibilityEditorDrawer
v-model="feasibilityDrawerVisible"
:record-id="editingFeasibilityId"
@success="handleFeasibilityEditorSuccess"
/>
<EthicsEditorDrawer
v-model="ethicsDrawerVisible"
:record-id="editingEthicsId"
@success="handleEthicsEditorSuccess"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { computed, onMounted, ref, watchEffect } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import { listFeasibility, listEthics, deleteFeasibility, deleteEthics } from "../../api/startup";
import { fetchSites } from "../../api/sites";
@@ -148,16 +180,41 @@ import type { Site } from "../../types/api";
import { displayDate } from "../../utils/display";
import dayjs from "dayjs";
import type { StageStatus } from "./project-overview/overview.adapter";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { isSystemAdmin } from "../../utils/roles";
import { TEXT } from "../../locales";
import FeasibilityEditorDrawer from "../startup/FeasibilityEditorDrawer.vue";
import EthicsEditorDrawer from "../startup/EthicsEditorDrawer.vue";
const router = useRouter();
const auth = useAuthStore();
const study = useStudyStore();
const activeTab = ref("feasibility");
const feasibilityItems = ref<any[]>([]);
const ethicsItems = ref<any[]>([]);
const loadingFeasibility = ref(false);
const loadingEthics = ref(false);
const feasibilityDrawerVisible = ref(false);
const ethicsDrawerVisible = ref(false);
const editingFeasibilityId = ref<string | undefined>();
const editingEthicsId = ref<string | undefined>();
const sites = ref<Site[]>([]);
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
const isAdmin = computed(() => isSystemAdmin(auth.user));
const canUseApiPermission = (operationKey: string) =>
isAdmin.value || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.[operationKey]);
const canReadFeasibility = computed(() => canUseApiPermission("startup_initiation:read"));
const canCreateFeasibility = computed(() => canUseApiPermission("startup_initiation:create"));
const canUpdateFeasibility = computed(() => canUseApiPermission("startup_initiation:update"));
const canDeleteFeasibility = computed(() => canUseApiPermission("startup_initiation:delete"));
const canReadEthics = computed(() => canUseApiPermission("startup_ethics:read"));
const canCreateEthics = computed(() => canUseApiPermission("startup_ethics:create"));
const canUpdateEthics = computed(() => canUseApiPermission("startup_ethics:update"));
const canDeleteEthics = computed(() => canUseApiPermission("startup_ethics:delete"));
const readableTabs = computed(() => [
...(canReadFeasibility.value ? ["feasibility"] : []),
...(canReadEthics.value ? ["ethics"] : []),
]);
const siteMap = computed(() => new Map(sites.value.map((site) => [site.id, site.name])));
const siteActiveMap = computed(() => new Map(sites.value.map((site) => [site.id, !!site.is_active])));
@@ -237,6 +294,10 @@ const loadSites = async () => {
const loadFeasibility = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
if (!canReadFeasibility.value) {
feasibilityItems.value = [];
return;
}
loadingFeasibility.value = true;
try {
const { data } = await listFeasibility(studyId) as any;
@@ -251,6 +312,10 @@ const loadFeasibility = async () => {
const loadEthics = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
if (!canReadEthics.value) {
ethicsItems.value = [];
return;
}
loadingEthics.value = true;
try {
const { data } = await listEthics(studyId) as any;
@@ -262,11 +327,47 @@ const loadEthics = async () => {
}
};
const goNewFeasibility = () => router.push("/startup/feasibility/new");
const goFeasibilityDetail = (id: string) => router.push(`/startup/feasibility/${id}`);
const goNewFeasibility = () => {
editingFeasibilityId.value = undefined;
feasibilityDrawerVisible.value = true;
};
const openFeasibilityEditor = (row: any) => {
if (!canUpdateFeasibility.value) return;
if (isInactiveSite(row?.site_id)) {
ElMessage.warning("中心已停用");
return;
}
editingFeasibilityId.value = row?.id;
feasibilityDrawerVisible.value = true;
};
const goFeasibilityDetail = (id: string) => {
if (!canReadFeasibility.value) return;
router.push(`/startup/feasibility/${id}`);
};
const goNewEthics = () => router.push("/startup/ethics/new");
const goEthicsDetail = (id: string) => router.push(`/startup/ethics/${id}`);
const goNewEthics = () => {
editingEthicsId.value = undefined;
ethicsDrawerVisible.value = true;
};
const openEthicsEditor = (row: any) => {
if (!canUpdateEthics.value) return;
if (isInactiveSite(row?.site_id)) {
ElMessage.warning("中心已停用");
return;
}
editingEthicsId.value = row?.id;
ethicsDrawerVisible.value = true;
};
const goEthicsDetail = (id: string) => {
if (!canReadEthics.value) return;
router.push(`/startup/ethics/${id}`);
};
const handleFeasibilityEditorSuccess = () => {
loadFeasibility();
};
const handleEthicsEditorSuccess = () => {
loadEthics();
};
const onFeasibilityRowClick = (row: any) => goFeasibilityDetail(row.id);
const onEthicsRowClick = (row: any) => goEthicsDetail(row.id);
const feasibilityRowClass = ({ row }: { row: any }) =>
@@ -277,6 +378,7 @@ const ethicsRowClass = ({ row }: { row: any }) =>
const removeFeasibility = async (row: any) => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
if (!canDeleteFeasibility.value) return;
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return;
try {
@@ -291,6 +393,7 @@ const removeFeasibility = async (row: any) => {
const removeEthics = async (row: any) => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
if (!canDeleteEthics.value) return;
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return;
try {
@@ -307,6 +410,12 @@ onMounted(() => {
loadFeasibility();
loadEthics();
});
watchEffect(() => {
if (readableTabs.value.length > 0 && !readableTabs.value.includes(activeTab.value)) {
activeTab.value = readableTabs.value[0];
}
});
</script>
<style scoped>
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSubjectManagementSource = () => readFileSync(resolve(__dirname, "./SubjectManagement.vue"), "utf8");
describe("SubjectManagement project permissions", () => {
it("hides create and delete actions when the current role lacks backend operation permissions", () => {
const source = readSubjectManagementSource();
expect(source).toContain("isSystemAdmin");
expect(source).toContain("isApiPermissionAllowed");
expect(source).toContain("canUseApiPermission");
expect(source).toContain('canUseApiPermission("subjects:create")');
expect(source).toContain('canUseApiPermission("subjects:delete")');
expect(source).toContain('v-if="canCreateSubject"');
expect(source).toContain('v-if="canDeleteSubject"');
});
});
describe("SubjectManagement drawer editor", () => {
it("opens the participant create form in a drawer instead of routing to a form page", () => {
const source = readSubjectManagementSource();
expect(source).toContain("SubjectEditorDrawer");
expect(source).toContain("subjectDrawerVisible");
expect(source).not.toContain('router.push("/subjects/new")');
});
});
+28 -3
View File
@@ -48,7 +48,7 @@
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
</div>
<div class="filter-spacer"></div>
<el-button type="primary" @click="goNew" class="header-action-btn">
<el-button v-if="canCreateSubject" type="primary" @click="goNew" class="header-action-btn">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.common.actions.add }}{{ TEXT.modules.subjectManagement.subjectLabel }}
</el-button>
@@ -93,9 +93,10 @@
<el-table-column prop="completion_date" :label="TEXT.common.fields.completionDate">
<template #default="scope">{{ displayDate(scope.row.completion_date) }}</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120">
<el-table-column v-if="canDeleteSubject" :label="TEXT.common.labels.actions" width="120">
<template #default="scope">
<el-button
v-if="canDeleteSubject"
link
type="danger"
size="small"
@@ -122,6 +123,10 @@
</div>
</div>
</div>
<SubjectEditorDrawer
v-model="subjectDrawerVisible"
@success="handleSubjectEditorSuccess"
/>
</div>
</template>
@@ -130,15 +135,21 @@ import { computed, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { Search, Location, CircleCheck, Plus } from "@element-plus/icons-vue";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import { deleteSubject, fetchSubjects } from "../../api/subjects";
import { fetchSites } from "../../api/sites";
import { displayDate, displayEnum } from "../../utils/display";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { isSystemAdmin } from "../../utils/roles";
import { TEXT } from "../../locales";
import SubjectEditorDrawer from "../subjects/SubjectEditorDrawer.vue";
const router = useRouter();
const auth = useAuthStore();
const study = useStudyStore();
const loading = ref(false);
const subjectDrawerVisible = ref(false);
const items = ref<any[]>([]);
const siteOptions = ref<Array<{ id: string; name: string }>>([]);
const siteMap = ref<Record<string, string>>({});
@@ -158,6 +169,14 @@ const resetFilters = () => {
filters.value.siteId = "";
filters.value.status = "";
};
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
const canUseApiPermission = (operationKey: string) => {
if (isSystemAdmin(auth.user)) return true;
if (!projectRole.value) return false;
return isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.[operationKey]);
};
const canCreateSubject = computed(() => canUseApiPermission("subjects:create"));
const canDeleteSubject = computed(() => canUseApiPermission("subjects:delete"));
const loadSites = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
@@ -193,8 +212,13 @@ const load = async () => {
}
};
const goNew = () => router.push("/subjects/new");
const goNew = () => {
subjectDrawerVisible.value = true;
};
const goDetail = (id: string) => router.push(`/subjects/${id}`);
const handleSubjectEditorSuccess = () => {
load();
};
const onRowClick = (row: any) => {
if (!row?.id) return;
goDetail(row.id);
@@ -202,6 +226,7 @@ const onRowClick = (row: any) => {
const remove = async (row: any) => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
if (!canDeleteSubject.value) return;
if (isInactiveSite(row?.site_id)) {
ElMessage.warning("中心已停用");
return;