统一项目角色与接口权限配置

This commit is contained in:
Cheng Zhou
2026-05-28 10:48:33 +08:00
parent 6d1c98bcae
commit 2c85742040
45 changed files with 2863 additions and 1329 deletions
@@ -18,4 +18,18 @@ describe("audit logs access", () => {
expect(source).not.toContain('const role = auth.user?.role');
expect(source).not.toContain('role !== "ADMIN"');
});
it("does not expose legacy startup ethics business labels", () => {
const auditSource = readFileSync(resolve(__dirname, "../../audit/index.ts"), "utf8");
const overviewSource = readFileSync(resolve(__dirname, "../ia/project-overview/overview.adapter.ts"), "utf8");
const combined = `${auditSource}\n${overviewSource}`;
expect(combined).not.toContain("可行性评估");
expect(combined).not.toContain("伦理审批");
expect(combined).not.toContain("立项可行性");
expect(combined).not.toContain("立项伦理");
expect(auditSource).toContain('startup_feasibility: "立项记录"');
expect(auditSource).toContain('startup_ethics: "伦理记录"');
expect(overviewSource).toContain('label: "伦理记录"');
});
});
@@ -3,6 +3,8 @@ import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./PermissionManagement.vue"), "utf8");
const readProjectPermissionModulesSource = () =>
readFileSync(resolve(__dirname, "../../utils/projectPermissionModules.ts"), "utf8");
describe("permission management custom roles", () => {
it("uses role management wording in the drawer", () => {
@@ -38,7 +40,7 @@ describe("permission management custom roles", () => {
expect(source).not.toContain("输入自定义角色标识");
expect(source).toContain("roleOptions");
expect(source).toContain("useRoleTemplateMeta");
expect(source).toContain("const { roleLabel, roleDescription, roleIcon, loadRoleTemplates } = useRoleTemplateMeta();");
expect(source).toContain("const { roleLabel, roleDescription, roleIcon, compareRolesByTemplateOrder, loadRoleTemplates } = useRoleTemplateMeta();");
expect(source).toContain("await loadRoleTemplates();");
expect(source).toContain("const roleDisplayName = (row: PermissionTemplate) => row.name;");
expect(source).toContain("await loadPermissionData();");
@@ -59,7 +61,7 @@ describe("permission management custom roles", () => {
it("uses database role names when listing and editing preset roles", () => {
const source = readSource();
expect(source).toContain("const { roleLabel, roleDescription, roleIcon, loadRoleTemplates } = useRoleTemplateMeta();");
expect(source).toContain("const { roleLabel, roleDescription, roleIcon, compareRolesByTemplateOrder, loadRoleTemplates } = useRoleTemplateMeta();");
expect(source).toContain("const roleDisplayName = (row: PermissionTemplate) => row.name;");
expect(source).toContain("name: row.name, description: row.description ?? \"\",");
expect(source).not.toContain("templateRoleName(row)");
@@ -106,12 +108,10 @@ describe("permission management custom roles", () => {
expect(source).toContain("await addMember(selectedStudyId.value, addMemberForm);");
});
it("does not offer QA as a preset project permission role", () => {
it("uses QA and CTA as preset project permission role keys", () => {
const source = readSource();
expect(source).not.toContain('key: "QA"');
expect(source).not.toContain('QA: "QA"');
expect(source).not.toContain("QA: 60");
expect(source).toContain("compareRolesByTemplateOrder(a.key, b.key)");
});
it("submits only the selected role from role editor saves", () => {
@@ -143,7 +143,77 @@ describe("permission management custom roles", () => {
it("renders setup_config as a Chinese module name in the role editor", () => {
const source = readSource();
expect(source).toContain('setup_config: "立项配置"');
expect(source).toContain("projectPermissionModuleLabel");
});
it("sorts role editor modules with the shared project permission module order", () => {
const source = readSource();
expect(source).toContain("compareProjectPermissionModules");
expect(source).toContain("const sorted = [...filtered].sort");
expect(source).toContain("const moduleDiff = compareProjectPermissionModules(a.module, b.module);");
expect(source).toContain("return allOperations.value.indexOf(a) - allOperations.value.indexOf(b);");
expect(source).not.toContain("const ROLE_EDITOR_MODULE_LABELS");
});
it("groups role editor permissions by module sections", () => {
const source = readSource();
expect(source).toContain("roleEditorSectionGroups");
expect(source).toContain("roleEditorSectionLabel(op)");
expect(source).toContain("role-editor-section");
expect(source).toContain("getPermissionSectionLabel");
expect(source).toContain("comparePermissionSectionLabels");
expect(source).toContain('const SUBJECT_PERMISSION_SECTION_ORDER = ["基础信息", "病史", "访视", "AE", "PD"];');
expect(source).toContain('subjects: "基础信息"');
expect(source).toContain('visits: "访视"');
expect(source).toContain('subject_aes: "AE"');
expect(source).toContain('subject_pds: "PD"');
expect(source).toContain('subject_histories: "病史"');
expect(source).not.toContain('subjects: "参与者基础信息"');
});
it("shows startup ethics permissions in role editor with business section names", () => {
const source = readSource();
const moduleSource = readProjectPermissionModulesSource();
expect(moduleSource).toContain('startup_ethics: "立项与伦理"');
expect(source).toContain('startup_initiation: "立项记录"');
expect(source).toContain('startup_ethics: "伦理记录"');
});
it("does not expose the legacy dashboard module after merging into project overview", () => {
const moduleSource = readProjectPermissionModulesSource();
expect(moduleSource).not.toContain('dashboard: "仪表板"');
});
it("does not expose generic attachment management after module attachment migration", () => {
const moduleSource = readProjectPermissionModulesSource();
const source = readSource();
expect(moduleSource).not.toContain('attachments: "附件管理"');
expect(source).not.toContain('attachments: "附件管理"');
});
it("shows precautions under the shared library parent module", () => {
const moduleSource = readProjectPermissionModulesSource();
const source = readSource();
expect(moduleSource).toContain('shared_library: "共享库"');
expect(moduleSource).not.toContain('precautions: "注意事项"');
expect(source).toContain('precautions: "注意事项"');
expect(source).toContain('faq: "医学咨询/FAQ"');
});
it("labels contract fees without the removed finance contract prefix in role editor", () => {
const source = readSource();
expect(source).not.toContain("FEE_PERMISSION_SECTION_LABELS");
expect(source).not.toContain('fees_payments: "费用分析"');
expect(source).not.toContain('fees_attachments: "合同附件"');
expect(source).not.toContain("finance_contracts");
expect(source).not.toContain('contract_fees: "合同费用"');
});
it("does not render the duplicated monitoring header", () => {
+383 -211
View File
@@ -8,7 +8,6 @@
<div class="perm-header">
<div class="perm-header-left">
<h2 class="perm-title">项目权限配置</h2>
<span class="perm-subtitle">为每个项目的角色配置接口级权限</span>
</div>
<div class="perm-header-actions">
<div class="project-selector">
@@ -53,10 +52,6 @@
<el-icon><Files /></el-icon>
角色管理
</el-button>
<el-button type="primary" :loading="saving" :disabled="!dirty" @click="save">
<el-icon><Check /></el-icon>
保存变更
</el-button>
</div>
</div>
<PermissionTemplateSelector
@@ -69,7 +64,6 @@
<ApiEndpointPermissions
:project="selectedStudy"
:matrix="apiMatrix"
@update="onApiMatrixUpdate"
/>
</el-tab-pane>
<el-tab-pane label="成员管理" name="members">
@@ -238,137 +232,151 @@
角色管理抽屉
-->
<el-drawer v-model="templateDrawerVisible" title="角色管理" size="720px" direction="rtl" @open="onTemplateDrawerOpen">
<el-tabs v-model="templateDrawerTab" class="drawer-tabs">
<div class="drawer-tabs-shell">
<el-tabs v-model="templateDrawerTab" class="drawer-tabs">
<!-- 标签页角色列表 -->
<el-tab-pane label="角色列表" name="list">
<div class="drawer-toolbar">
<div class="filter-spacer" />
<el-button type="primary" @click="openCreateTemplate">新增角色</el-button>
</div>
<el-table :data="templates" v-loading="templatesLoading" stripe style="margin-top: 12px">
<el-table-column prop="name" label="角色名称" min-width="140">
<template #default="{ row }">
<span class="template-name">{{ roleDisplayName(row) }}</span>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
<el-table-column prop="template_type" label="类型" width="100">
<template #default="{ row }">
<el-tag :type="typeTagType(row.template_type)" size="small">{{ typeLabel(row.template_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="权限数" width="80">
<template #default="{ row }">{{ countPermissions(row) }}</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEditTemplate(row)">编辑</el-button>
<el-button link type="danger" :disabled="row.is_system" @click="confirmDeleteTemplate(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- 标签页角色列表 -->
<el-tab-pane label="角色列表" name="list">
<el-table :data="templates" v-loading="templatesLoading" stripe class="template-table">
<el-table-column prop="name" label="角色名称" min-width="140">
<template #default="{ row }">
<span class="template-name">{{ roleDisplayName(row) }}</span>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
<el-table-column prop="template_type" label="类型" width="100">
<template #default="{ row }">
<el-tag :type="typeTagType(row.template_type)" size="small">{{ typeLabel(row.template_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="权限数" width="80">
<template #default="{ row }">{{ countPermissions(row) }}</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEditTemplate(row)">编辑</el-button>
<el-button link type="danger" :disabled="row.is_system" @click="confirmDeleteTemplate(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- 标签页生效管理 -->
<el-tab-pane label="生效管理" name="active" :disabled="!selectedStudyId">
<div class="active-roles-desc">
<el-alert
title="生效的角色将映射到成员管理的项目角色中,未生效的角色不可在成员管理中分配。"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px"
/>
</div>
<div class="active-roles-list" v-loading="activeRolesLoading">
<div v-for="role in roleOptions" :key="role.key" class="active-role-row">
<div class="active-role-info">
<span class="active-role-icon">{{ role.icon }}</span>
<div>
<div class="active-role-name">{{ role.label }}</div>
<div class="active-role-desc">{{ role.desc }}</div>
</div>
</div>
<el-switch
:model-value="activeRolesDraft.includes(role.key)"
@change="(v: boolean) => toggleActiveRole(role.key, v)"
<!-- 标签页生效管理 -->
<el-tab-pane label="生效管理" name="active" :disabled="!selectedStudyId">
<div class="active-roles-desc">
<el-alert
title="生效的角色将映射到成员管理的项目角色中,未生效的角色不可在成员管理中分配。"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px"
/>
</div>
</div>
<div class="drawer-footer">
<el-button type="primary" :loading="activeRolesSaving" @click="saveActiveRoles">保存</el-button>
</div>
</el-tab-pane>
<!-- 标签页编辑权限 -->
<el-tab-pane label="编辑权限" name="edit-role" :disabled="!selectedStudyId">
<div class="role-editor-layout" v-loading="roleEditorLoading">
<!-- 左侧角色列表 -->
<div class="role-editor-sidebar">
<div
v-for="role in roleOptions"
:key="role.key"
class="role-sidebar-item"
:class="{ active: editingRole === role.key }"
@click="selectRoleForEdit(role.key)"
>
<span class="role-sidebar-icon">{{ role.icon }}</span>
<div class="role-sidebar-info">
<div class="role-sidebar-name">{{ role.label }}</div>
<div class="role-sidebar-count" v-if="editingRole === role.key && Object.keys(roleEditorDraft).length">
{{ Object.values(roleEditorDraft).filter(Boolean).length }} 项已启用
<div class="active-roles-list" v-loading="activeRolesLoading">
<div v-for="role in roleOptions" :key="role.key" class="active-role-row">
<div class="active-role-info">
<span class="active-role-icon">{{ role.icon }}</span>
<div>
<div class="active-role-name">{{ role.label }}</div>
<div class="active-role-desc">{{ role.desc }}</div>
</div>
</div>
<el-icon v-if="editingRole === role.key" class="role-sidebar-arrow"><ArrowRight /></el-icon>
<el-switch
:model-value="activeRolesDraft.includes(role.key)"
@change="(v: boolean) => toggleActiveRole(role.key, v)"
/>
</div>
</div>
</el-tab-pane>
<!-- 右侧权限编辑区 -->
<div class="role-editor-content">
<template v-if="editingRole">
<div v-if="!canEditSelectedRole" class="role-editor-notice">
<el-alert :title="editingRole === 'PM' ? '仅系统管理员可修改项目负责人权限' : '该角色权限不可修改'" type="info" :closable="false" show-icon />
</div>
<div class="role-editor-toolbar">
<el-input v-model="roleEditorSearch" placeholder="搜索权限..." clearable style="width: 220px">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<div class="filter-spacer" />
<el-button size="small" :disabled="!canEditSelectedRole" @click="roleEditorSelectAll(true)">全选</el-button>
<el-button size="small" :disabled="!canEditSelectedRole" @click="roleEditorSelectAll(false)">全不选</el-button>
</div>
<div class="role-editor-modules">
<div v-for="(ops, mod) in roleEditorGrouped" :key="mod" class="role-editor-module">
<div class="role-editor-module-header">
<span>{{ roleEditorModuleLabel(mod) }}</span>
<el-tag size="small" type="info">{{ ops.length }}</el-tag>
</div>
<div class="role-editor-ops">
<div v-for="op in ops" :key="op.operation_key" class="role-editor-op-row">
<el-checkbox
:model-value="roleEditorDraft[op.operation_key] ?? false"
:disabled="!canEditSelectedRole"
@change="(v: boolean) => roleEditorDraft[op.operation_key] = v"
/>
<el-tag :type="opTagType(op)" size="small" class="op-action-tag">{{ opActionLabel(op) }}</el-tag>
<span class="op-desc">{{ op.description || op.operation_key }}</span>
</div>
<!-- 标签页编辑权限 -->
<el-tab-pane label="编辑权限" name="edit-role" :disabled="!selectedStudyId">
<div class="role-editor-layout" v-loading="roleEditorLoading">
<!-- 左侧角色列表 -->
<div class="role-editor-sidebar">
<div
v-for="role in roleOptions"
:key="role.key"
class="role-sidebar-item"
:class="{ active: editingRole === role.key }"
@click="selectRoleForEdit(role.key)"
>
<span class="role-sidebar-icon">{{ role.icon }}</span>
<div class="role-sidebar-info">
<div class="role-sidebar-name">{{ role.label }}</div>
<div class="role-sidebar-count" v-if="editingRole === role.key && Object.keys(roleEditorDraft).length">
{{ Object.values(roleEditorDraft).filter(Boolean).length }} 项已启用
</div>
</div>
<el-icon v-if="editingRole === role.key" class="role-sidebar-arrow"><ArrowRight /></el-icon>
</div>
<div class="drawer-footer">
<el-button type="primary" :loading="roleEditorSaving" :disabled="!canEditSelectedRole" @click="saveRoleEditor">
保存 {{ roleLabel(editingRole) }} 权限
</el-button>
</div>
</template>
<el-empty v-else description="请从左侧选择角色" style="padding: 80px 0" />
</div>
</div>
</el-tab-pane>
</div>
</el-tabs>
<!-- 右侧权限编辑区 -->
<div class="role-editor-content">
<template v-if="editingRole">
<div v-if="!canEditSelectedRole" class="role-editor-notice">
<el-alert :title="editingRole === 'PM' ? '仅系统管理员可修改项目负责人权限' : '该角色权限不可修改'" type="info" :closable="false" show-icon />
</div>
<div class="role-editor-toolbar">
<el-input v-model="roleEditorSearch" placeholder="搜索权限..." clearable class="role-editor-search">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
</div>
<div class="role-editor-modules">
<div v-for="(sections, mod) in roleEditorSectionGroups" :key="mod" class="role-editor-module">
<div class="role-editor-module-header">
<span>{{ roleEditorModuleLabel(mod) }}</span>
</div>
<el-collapse class="role-editor-collapse">
<el-collapse-item
v-for="section in sections"
:key="`${mod}:${section.label}`"
:name="`${mod}:${section.label}`"
class="role-editor-section"
>
<template #title>
<span class="role-editor-section-title">{{ section.label }}</span>
<span class="role-editor-section-summary">{{ roleEditorEnabledCount(section.ops) }} / {{ section.ops.length }} 已启用</span>
</template>
<div class="role-editor-ops">
<div v-for="op in section.ops" :key="op.operation_key" class="role-editor-op-row">
<el-checkbox
:model-value="roleEditorDraft[op.operation_key] ?? false"
:disabled="!canEditSelectedRole"
@change="(v: boolean) => roleEditorDraft[op.operation_key] = v"
/>
<el-tag :type="opTagType(op)" size="small" class="op-action-tag" effect="light">{{ opActionLabel(op) }}</el-tag>
<span class="op-desc">{{ op.description || op.operation_key }}</span>
</div>
</div>
</el-collapse-item>
</el-collapse>
</div>
</div>
</template>
<el-empty v-else description="请从左侧选择角色" style="padding: 80px 0" />
</div>
</div>
</el-tab-pane>
</el-tabs>
<div class="drawer-tab-actions">
<el-button v-if="templateDrawerTab === 'list'" type="primary" @click="openCreateTemplate">新增角色</el-button>
<el-button v-else-if="templateDrawerTab === 'active'" type="primary" :loading="activeRolesSaving" @click="saveActiveRoles">
保存
</el-button>
<el-button
v-else-if="templateDrawerTab === 'edit-role' && editingRole"
type="primary"
:loading="roleEditorSaving"
:disabled="!canEditSelectedRole"
@click="saveRoleEditor"
>
保存 {{ roleLabel(editingRole) }} 权限
</el-button>
</div>
</div>
</el-drawer>
<!-- 新增/编辑角色对话框 -->
@@ -427,7 +435,7 @@
import { ref, computed, onMounted, watch, reactive } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { Check, Files, Search, ArrowRight } from "@element-plus/icons-vue";
import { Files, Search, ArrowRight } from "@element-plus/icons-vue";
import type { FormInstance, FormRules } from "element-plus";
import type {
ApiEndpointPermissionsResponse,
@@ -456,6 +464,10 @@ import { useAuthStore } from "@/store/auth";
import { displayDateTime } from "@/utils/display";
import { isApiPermissionAllowed } from "@/utils/apiPermissionValue";
import { isSystemAdmin } from "@/utils/roles";
import {
compareProjectPermissionModules,
projectPermissionModuleLabel,
} from "@/utils/projectPermissionModules";
import { useRoleTemplateMeta } from "@/composables/useRoleTemplateMeta";
import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue";
import PermissionMonitoring from "@/components/PermissionMonitoring.vue";
@@ -469,9 +481,9 @@ const selectedProjectRole = computed(() => selectedStudy.value?.role_in_study ||
const isSelectedProjectPm = computed(() => selectedProjectRole.value === "PM");
const canManageSelectedProject = computed(() => isAdmin.value || isSelectedProjectPm.value);
const { roleLabel, roleDescription, roleIcon, loadRoleTemplates } = useRoleTemplateMeta();
const { roleLabel, roleDescription, roleIcon, compareRolesByTemplateOrder, loadRoleTemplates } = useRoleTemplateMeta();
const ROLE_RANK: Record<string, number> = {
ADMIN: 100, PM: 80, PV: 50, MEDICAL_REVIEW: 50, CRA: 40, IMP: 40,
ADMIN: 100, PM: 80, QA: 60, PV: 50, CRA: 40, CTA: 40,
};
type MemberRoleRow = { user_id: string; role_in_study: string; user?: any };
const canAssignProjectRole = (role: string) => isAdmin.value || (ROLE_RANK[role] ?? 0) < ROLE_RANK.PM;
@@ -516,8 +528,6 @@ const projectSubTab = ref<"api" | "members">("api");
// 接口级权限
const apiMatrix = ref<ApiEndpointPermissionsResponse | null>(null);
const dirty = ref(false);
const saving = ref(false);
const currentPermissionsForTemplate = computed(() => {
if (!apiMatrix.value) return undefined;
@@ -545,7 +555,6 @@ const loadPermissionData = async () => {
try {
const res = await fetchApiEndpointPermissions(selectedStudyId.value);
apiMatrix.value = res.data;
dirty.value = false;
} catch {
ElMessage.error("加载权限数据失败");
} finally {
@@ -561,7 +570,6 @@ const onStudyChange = async (id: string) => {
return;
}
apiMatrix.value = null;
dirty.value = false;
members.value = [];
candidates.value = [];
activeRolesDraft.value = [];
@@ -570,8 +578,6 @@ const onStudyChange = async (id: string) => {
loadCandidates();
};
const onApiMatrixUpdate = (m: ApiEndpointPermissionsResponse) => { apiMatrix.value = m; dirty.value = true; };
const activeRolesInStudy = computed(() => activeRolesDraft.value);
// 把 apiMatrix(可能混有 { allowed: boolean } 或 boolean)展平为 boolean 格式
@@ -598,24 +604,6 @@ const flattenRolePermissions = (
[role]: { ...permissions },
});
const save = async () => {
if (!selectedStudyId.value || !dirty.value || !apiMatrix.value) return;
saving.value = true;
try {
const res = await updateApiEndpointPermissions(
selectedStudyId.value,
flattenMatrix(apiMatrix.value, { includePm: isAdmin.value })
);
apiMatrix.value = res.data;
dirty.value = false;
ElMessage.success("权限已保存");
} catch {
ElMessage.error("保存权限失败");
} finally {
saving.value = false;
}
};
// 成员管理
const members = ref<StudyMember[]>([]);
const candidates = ref<UserInfo[]>([]);
@@ -766,7 +754,7 @@ const roleOptions = computed(() => {
known.add(role);
}
}
options.sort((a, b) => (ROLE_RANK[b.key] ?? 0) - (ROLE_RANK[a.key] ?? 0) || a.key.localeCompare(b.key));
options.sort((a, b) => compareRolesByTemplateOrder(a.key, b.key));
return options;
});
@@ -961,6 +949,11 @@ interface RoleEditorOp {
description: string;
}
interface RoleEditorSection {
label: string;
ops: RoleEditorOp[];
}
const editingRole = ref("");
const roleEditorSearch = ref("");
const roleEditorDraft = ref<Record<string, boolean>>({});
@@ -968,17 +961,43 @@ const roleEditorSaving = ref(false);
const roleEditorLoading = ref(false);
const allOperations = ref<RoleEditorOp[]>([]);
const ROLE_EDITOR_MODULE_LABELS: Record<string, string> = {
subjects: "参与者管理", sites: "中心管理", risk_issues: "风险问题",
monitoring_audit: "监查稽查", project_members: "项目成员",
project_milestones: "项目里程碑", project_overview: "项目总览",
setup_config: "立项配置", fees: "合同费用", materials: "物资管理", material_equipments: "物资设备",
documents: "文件版本", startup_ethics: "立项与伦理", startup_auth: "启动与授权",
audit_export: "审计日志", faq: "FAQ", shared_library: "共享库",
attachments: "附件管理", dashboard: "仪表板", subject_histories: "参与者历史",
const roleEditorModuleLabel = projectPermissionModuleLabel;
const SUBJECT_PERMISSION_SECTION_ORDER = ["基础信息", "病史", "访视", "AE", "PD"];
const PERMISSION_SECTION_LABELS: Record<string, string> = {
subjects: "基础信息",
visits: "访视",
subject_aes: "AE",
subject_pds: "PD",
subject_histories: "病史",
monitoring_issues: "监查访视问题",
project_milestones: "项目里程碑",
startup_initiation: "立项记录",
startup_ethics: "伦理记录",
precautions: "注意事项",
precautions_attachments: "注意事项",
faq: "医学咨询/FAQ",
faq_category: "医学咨询/FAQ",
faq_reply: "医学咨询/FAQ",
faq_attachments: "医学咨询/FAQ",
};
const roleEditorModuleLabel = (mod: string) => ROLE_EDITOR_MODULE_LABELS[mod] || mod;
const getPermissionSectionLabel = (op: RoleEditorOp): string => {
const prefix = op.operation_key.split(":")[0] || op.module;
return PERMISSION_SECTION_LABELS[prefix] || roleEditorModuleLabel(op.module);
};
const roleEditorSectionLabel = (op: RoleEditorOp): string => getPermissionSectionLabel(op);
const comparePermissionSectionLabels = (a: string, b: string): number => {
const aIndex = SUBJECT_PERMISSION_SECTION_ORDER.indexOf(a);
const bIndex = SUBJECT_PERMISSION_SECTION_ORDER.indexOf(b);
if (aIndex !== -1 || bIndex !== -1) {
return (aIndex === -1 ? Number.MAX_SAFE_INTEGER : aIndex) - (bIndex === -1 ? Number.MAX_SAFE_INTEGER : bIndex);
}
return a.localeCompare(b, "zh-CN");
};
const getOpVerb = (op: { operation_key: string; action: string }): string => {
const suffix = op.operation_key.split(":").pop() || "";
@@ -1008,14 +1027,44 @@ const roleEditorGrouped = computed(() => {
op.operation_key.toLowerCase().includes(search) ||
roleEditorModuleLabel(op.module).includes(search)
);
const sorted = [...filtered].sort((a, b) => {
const moduleDiff = compareProjectPermissionModules(a.module, b.module);
if (moduleDiff !== 0) return moduleDiff;
return allOperations.value.indexOf(a) - allOperations.value.indexOf(b);
});
const map: Record<string, RoleEditorOp[]> = {};
for (const op of filtered) {
for (const op of sorted) {
if (!map[op.module]) map[op.module] = [];
map[op.module].push(op);
}
return map;
});
const roleEditorSectionGroups = computed(() => {
const result: Record<string, RoleEditorSection[]> = {};
for (const [mod, ops] of Object.entries(roleEditorGrouped.value)) {
const sections = new Map<string, RoleEditorOp[]>();
for (const op of ops) {
const label = roleEditorSectionLabel(op);
if (!sections.has(label)) sections.set(label, []);
sections.get(label)!.push(op);
}
result[mod] = [...sections.entries()]
.sort(([a], [b]) => comparePermissionSectionLabels(a, b))
.map(([label, sectionOps]) => ({
label,
ops: sectionOps,
}));
}
return result;
});
const roleEditorModuleCount = (sections: RoleEditorSection[]): number =>
sections.reduce((total, section) => total + section.ops.length, 0);
const roleEditorEnabledCount = (ops: RoleEditorOp[]): number =>
ops.filter((op) => roleEditorDraft.value[op.operation_key]).length;
const canEditSelectedRole = computed(() => {
if (editingRole.value === "ADMIN") return false;
if (isAdmin.value) return true;
@@ -1060,11 +1109,6 @@ const initRoleEditor = () => {
roleEditorDraft.value = draft;
};
const roleEditorSelectAll = (val: boolean) => {
const ops = Object.values(roleEditorGrouped.value).flat();
for (const op of ops) roleEditorDraft.value[op.operation_key] = val;
};
const saveRoleEditor = async () => {
if (!selectedStudyId.value || !apiMatrix.value) return;
if (!canEditSelectedRole.value) {
@@ -1076,7 +1120,6 @@ const saveRoleEditor = async () => {
const payload = flattenRolePermissions(editingRole.value, roleEditorDraft.value);
const res = await updateApiEndpointPermissions(selectedStudyId.value, payload);
apiMatrix.value = res.data;
dirty.value = false;
const savedRole = editingRole.value;
editingRole.value = "";
ElMessage.success(`${roleLabel(savedRole)} 权限已保存`);
@@ -1150,11 +1193,6 @@ onMounted(async () => {
line-height: 1.3;
}
.perm-subtitle {
font-size: 13px;
color: #64748b;
}
.perm-header-actions {
display: flex;
align-items: center;
@@ -1515,28 +1553,27 @@ onMounted(async () => {
}
/* ── 抽屉内部 ── */
.filter-spacer { flex: 1; }
.drawer-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 4px;
padding: 10px 14px;
background: #f8fafc;
border-radius: 10px;
border: 1px solid #f1f5f9;
.drawer-tabs-shell {
position: relative;
min-height: calc(100vh - 110px);
}
.drawer-tabs { padding-top: 4px; }
.drawer-footer {
.drawer-tab-actions {
position: absolute;
right: 0;
top: 0;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding-top: 16px;
margin-top: 16px;
border-top: 1px solid #f1f5f9;
min-width: 160px;
height: 48px;
background: #fff;
}
.template-table {
margin-top: 12px;
}
/* ── 生效角色列表 ── */
@@ -1670,60 +1707,195 @@ onMounted(async () => {
flex-direction: column;
}
.role-editor-modules { flex: 1; }
.role-editor-modules {
flex: 1;
display: flex;
flex-direction: column;
gap: 16px;
}
.role-editor-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
padding: 10px 14px;
background: #f8fafc;
border-radius: 10px;
border: 1px solid #f1f5f9;
margin-bottom: 18px;
padding: 4px;
background: #fff;
border-radius: 12px;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.role-editor-module { margin-bottom: 20px; }
.role-editor-search {
flex: 1;
}
:deep(.role-editor-search .el-input__wrapper) {
background: transparent;
border-radius: 8px;
box-shadow: none;
padding: 4px 12px;
transition: background 0.18s ease;
}
:deep(.role-editor-search .el-input__wrapper:hover) {
background: #f8fafc;
box-shadow: none;
}
:deep(.role-editor-search .el-input__wrapper.is-focus) {
background: #eff6ff;
box-shadow: none;
}
.role-editor-module {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 14px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04);
transition: box-shadow 0.18s ease, border-color 0.18s ease;
}
.role-editor-module:hover {
border-color: #cbd5e1;
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.06);
}
.role-editor-module-header {
position: relative;
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
color: #1a2332;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid #f1f5f9;
gap: 10px;
font-size: 14px;
font-weight: 700;
color: #0f172a;
padding: 14px 18px 14px 22px;
background: linear-gradient(135deg, #f1f5ff 0%, #f8fafc 100%);
border-bottom: 1px solid #e2e8f0;
letter-spacing: 0.2px;
}
.role-editor-module-header::before {
content: "";
position: absolute;
left: 0;
top: 12px;
bottom: 12px;
width: 4px;
border-radius: 0 4px 4px 0;
background: linear-gradient(180deg, #3b82f6 0%, #60a5fa 100%);
}
.role-editor-ops {
display: flex;
flex-direction: column;
gap: 4px;
gap: 2px;
padding: 8px 6px 6px;
}
.role-editor-collapse {
border: 0;
background: #fff;
}
.role-editor-section {
background: #fff;
}
.role-editor-section-title {
font-size: 13px;
font-weight: 600;
color: #1e293b;
margin-right: 8px;
display: inline-flex;
align-items: center;
gap: 8px;
}
.role-editor-section-title::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: #94a3b8;
flex-shrink: 0;
}
.role-editor-section-summary {
margin-left: auto;
margin-right: 14px;
font-size: 12px;
color: #64748b;
font-weight: 500;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
:deep(.role-editor-section .el-collapse-item__header) {
height: 46px;
padding: 0 18px;
background: #fff;
border-bottom: 1px solid #f1f5f9;
font-weight: 600;
transition: background 0.15s ease;
}
:deep(.role-editor-section:last-child .el-collapse-item__header) {
border-bottom: 0;
}
:deep(.role-editor-section .el-collapse-item__header:hover) {
background: #f8fafc;
}
:deep(.role-editor-section .el-collapse-item__header.is-active) {
background: #f8fafc;
border-bottom-color: #e2e8f0;
}
:deep(.role-editor-section .el-collapse-item__header.is-active .role-editor-section-title::before) {
background: #3b82f6;
}
:deep(.role-editor-section .el-collapse-item__arrow) {
margin-right: 0;
color: #94a3b8;
}
:deep(.role-editor-section .el-collapse-item__content) {
padding: 4px 8px 10px;
background: #fafbfc;
}
:deep(.role-editor-section .el-collapse-item__wrap) {
border-bottom: 0;
background: #fafbfc;
}
.role-editor-op-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
transition: background 0.12s ease;
gap: 10px;
padding: 8px 10px;
border-radius: 8px;
transition: background 0.15s ease;
}
.role-editor-op-row:hover {
background: #f8fafc;
background: #eef2ff;
}
.op-action-tag {
flex-shrink: 0;
width: 42px;
width: 48px;
text-align: center;
font-weight: 600;
border-radius: 6px;
}
.op-desc {
font-size: 13px;
color: #334155;
color: #1e293b;
}
</style>
@@ -47,11 +47,12 @@ describe("ProjectMembers user directory access", () => {
expect(source).not.toContain(':label="TEXT.enums.userRole.PM"');
});
it("does not offer QA as a project member role preset", () => {
it("offers QA and CTA as project member role presets", () => {
const source = readProjectMembers();
expect(source).not.toContain('value="QA"');
expect(source).not.toContain("canAssignRole('QA')");
expect(source).not.toContain("QA: 60");
expect(source).toContain('"QA"');
expect(source).toContain('"CTA"');
expect(source).toContain("QA: 60");
expect(source).toContain("CTA: 40");
});
});
+3 -3
View File
@@ -132,15 +132,15 @@ const newMember = reactive({
});
const canManageMembers = computed(() => permission.can("project.members.manage"));
const projectRole = computed(() => project.value?.role_in_study || "");
const ROLE_KEYS = ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "ADMIN"];
const ROLE_KEYS = ["PM", "CRA", "PV", "QA", "CTA", "ADMIN"];
const roleOptions = computed(() => roleOptionsFor(ROLE_KEYS));
const roleRank: Record<string, number> = {
ADMIN: 100,
PM: 80,
PV: 50,
MEDICAL_REVIEW: 50,
QA: 60,
CRA: 40,
IMP: 40,
CTA: 40,
};
const currentRoleRank = computed(() => auth.user?.is_admin ? Number.POSITIVE_INFINITY : roleRank[projectRole.value] || 0);
const canAssignRole = (role: string) => (auth.user?.is_admin ? true : (roleRank[role] || 0) <= currentRoleRank.value);