统一项目权限读取模型

1、合并列表与详情读取权限,统一使用 subjects:read、project_members:read、monitoring_issues:read 等业务读取 key。

2、增加历史读取权限 key 的兼容映射,确保存量项目权限覆盖值不会丢失。

3、同步前端权限工具、路由权限与权限管理页面,避免继续引用已合并的旧权限 key。

4、补充项目角色启停保护与抽屉未保存变更守卫,防止停用 PM 或仍有关联成员的角色。
This commit is contained in:
Cheng Zhou
2026-06-04 11:06:03 +08:00
parent 44d69c2d7b
commit ab4f0d93ed
22 changed files with 852 additions and 349 deletions
@@ -231,7 +231,15 @@
<!--
角色管理抽屉
-->
<el-drawer v-model="templateDrawerVisible" title="角色管理" size="720px" direction="rtl" @open="onTemplateDrawerOpen">
<el-drawer
v-model="templateDrawerVisible"
title="角色管理"
size="720px"
direction="rtl"
:close-on-click-modal="true"
:before-close="handleTemplateDrawerBeforeClose"
@open="onTemplateDrawerOpen"
>
<div class="drawer-tabs-shell">
<el-tabs v-model="templateDrawerTab" class="drawer-tabs">
@@ -252,10 +260,10 @@
<el-table-column label="权限数" width="80">
<template #default="{ row }">{{ countPermissions(row) }}</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<el-table-column v-if="isAdmin" 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>
<el-button v-if="isAdmin" link type="primary" @click="openEditTemplate(row)">编辑</el-button>
<el-button v-if="isAdmin" link type="danger" :disabled="row.is_system" @click="confirmDeleteTemplate(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -279,12 +287,24 @@
<div>
<div class="active-role-name">{{ role.label }}</div>
<div class="active-role-desc">{{ role.desc }}</div>
<div v-if="roleActiveStatusLabel(role.key)" class="active-role-meta">
{{ roleActiveStatusLabel(role.key) }}
</div>
</div>
</div>
<el-switch
:model-value="activeRolesDraft.includes(role.key)"
@change="(v: boolean) => toggleActiveRole(role.key, v)"
/>
<el-tooltip
:disabled="!roleActiveDisableReason(role.key)"
:content="roleActiveDisableReason(role.key)"
placement="top"
>
<span>
<el-switch
:model-value="activeRolesDraft.includes(role.key)"
:disabled="!canToggleActiveRole(role.key)"
@change="(v: boolean) => toggleActiveRole(role.key, v)"
/>
</span>
</el-tooltip>
</div>
</div>
</el-tab-pane>
@@ -362,7 +382,7 @@
</el-tabs>
<div class="drawer-tab-actions">
<el-button v-if="templateDrawerTab === 'list'" type="primary" @click="openCreateTemplate">新增角色</el-button>
<el-button v-if="templateDrawerTab === 'list' && isAdmin" type="primary" @click="openCreateTemplate">新增角色</el-button>
<el-button v-else-if="templateDrawerTab === 'active'" type="primary" :loading="activeRolesSaving" @click="saveActiveRoles">
保存
</el-button>
@@ -464,6 +484,7 @@ import { useAuthStore } from "@/store/auth";
import { displayDateTime } from "@/utils/display";
import { isApiPermissionAllowed } from "@/utils/apiPermissionValue";
import { isSystemAdmin } from "@/utils/roles";
import { useDrawerDirtyGuard } from "@/utils/drawerDirtyGuard";
import {
compareProjectPermissionModules,
projectPermissionModuleLabel,
@@ -486,8 +507,8 @@ const selectedProjectPermissionAllowed = (operationKey: string) => {
if (!role || !apiMatrix.value) return false;
return isApiPermissionAllowed(apiMatrix.value?.[role]?.[operationKey]);
};
const canListProjectMembers = computed(() => selectedProjectPermissionAllowed("project_members:list"));
const canListProjectMemberCandidates = computed(() => selectedProjectPermissionAllowed("project_members:candidates"));
const canListProjectMembers = computed(() => selectedProjectPermissionAllowed("project_members:read"));
const canListProjectMemberCandidates = computed(() => selectedProjectPermissionAllowed("project_members:read"));
const canCreateProjectMember = computed(() => selectedProjectPermissionAllowed("project_members:create"));
const canUpdateProjectMembers = computed(() => selectedProjectPermissionAllowed("project_members:update"));
const canDeleteProjectMembers = computed(() => selectedProjectPermissionAllowed("project_members:delete"));
@@ -775,6 +796,7 @@ const templateSaving = ref(false);
const activeRolesDraft = ref<string[]>([]);
const activeRolesLoading = ref(false);
const activeRolesSaving = ref(false);
const activeRolesDirtyGuard = useDrawerDirtyGuard(() => activeRolesDraft.value);
const roleOptions = computed(() => {
const options = templates.value.filter((template) => template.category).map((template) => ({
@@ -805,7 +827,8 @@ const loadActiveRoles = async () => {
activeRolesLoading.value = true;
try {
const res = await fetchActiveRoles(selectedStudyId.value);
activeRolesDraft.value = res.data.active_roles;
activeRolesDraft.value = protectPmActiveRole([...res.data.active_roles]);
activeRolesDirtyGuard.syncBaseline();
} catch {
ElMessage.error("加载生效角色失败");
} finally {
@@ -813,7 +836,44 @@ const loadActiveRoles = async () => {
}
};
const activeRoleMemberCounts = computed(() => {
const counts: Record<string, number> = {};
for (const member of members.value) {
if (!member.is_active) continue;
const role = member.role_in_study;
if (!role) continue;
counts[role] = (counts[role] ?? 0) + 1;
}
return counts;
});
const activeRoleMemberCount = (role: string) => activeRoleMemberCounts.value[role] ?? 0;
const isActiveRoleInUse = (role: string) => activeRolesDraft.value.includes(role) && activeRoleMemberCount(role) > 0;
const canToggleActiveRole = (role: string) => {
if (!isAdmin.value && role === "PM") return false;
if (isActiveRoleInUse(role)) return false;
return true;
};
const roleActiveStatusLabel = (role: string) => {
if (role === "PM" && !isAdmin.value) return "项目负责人必需";
const count = activeRoleMemberCount(role);
return count > 0 ? `${count} 名成员使用中` : "";
};
const roleActiveDisableReason = (role: string) => {
if (role === "PM" && !isAdmin.value) return "PM 为项目必需角色,项目 PM 不可停用";
if (isActiveRoleInUse(role)) return "该角色仍有关联成员,需先调整成员角色后才能停用";
return "";
};
const protectPmActiveRole = (roles: string[]) => {
if (!isAdmin.value && !roles.includes("PM")) roles.unshift("PM");
return roles;
};
const toggleActiveRole = (role: string, active: boolean) => {
if (!canToggleActiveRole(role)) return;
if (active) {
if (!activeRolesDraft.value.includes(role)) activeRolesDraft.value.push(role);
} else {
@@ -825,7 +885,10 @@ const saveActiveRoles = async () => {
if (!canManageSelectedProject.value) return;
activeRolesSaving.value = true;
try {
await updateActiveRoles(selectedStudyId.value, activeRolesDraft.value);
const roles = protectPmActiveRole([...activeRolesDraft.value]);
await updateActiveRoles(selectedStudyId.value, roles);
activeRolesDraft.value = roles;
activeRolesDirtyGuard.syncBaseline();
await loadPermissionData();
ElMessage.success("生效角色已保存");
} catch {
@@ -996,6 +1059,15 @@ const roleEditorDraft = ref<Record<string, boolean>>({});
const roleEditorSaving = ref(false);
const roleEditorLoading = ref(false);
const allOperations = ref<RoleEditorOp[]>([]);
const roleEditorDirtyGuard = useDrawerDirtyGuard(() => roleEditorDraft.value);
const handleTemplateDrawerBeforeClose = (done: () => void) => {
if (activeRolesDirtyGuard.isDirty.value || (editingRole.value && roleEditorDirtyGuard.isDirty.value)) {
ElMessage.warning("请先保存或取消编辑");
return;
}
done();
};
const roleEditorModuleLabel = projectPermissionModuleLabel;
@@ -1009,6 +1081,7 @@ const PERMISSION_SECTION_LABELS: Record<string, string> = {
subject_histories: "病史",
monitoring_issues: "监查访视问题",
project_milestones: "项目里程碑",
material_equipments_attachments: "设备管理",
startup_initiation: "立项",
startup_initiation_attachments: "立项",
startup_ethics: "伦理",
@@ -1145,6 +1218,7 @@ const initRoleEditor = () => {
}
}
roleEditorDraft.value = draft;
roleEditorDirtyGuard.syncBaseline();
};
const saveRoleEditor = async () => {
@@ -1160,6 +1234,7 @@ const saveRoleEditor = async () => {
apiMatrix.value = res.data;
const savedRole = editingRole.value;
editingRole.value = "";
roleEditorDirtyGuard.syncBaseline();
ElMessage.success(`${roleLabel(savedRole)} 权限已保存`);
} catch {
ElMessage.error("保存失败");
@@ -1667,6 +1742,13 @@ onMounted(async () => {
margin-top: 2px;
}
.active-role-meta {
font-size: 12px;
color: #b45309;
margin-top: 4px;
font-weight: 500;
}
.template-name { font-weight: 600; color: #1a2332; }
/* ── 角色编辑器 ── */