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