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

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
@@ -2,8 +2,7 @@
<div class="api-permissions">
<div class="api-permissions-toolbar">
<div class="toolbar-left">
<span class="toolbar-title">接口权限矩阵</span>
<span class="toolbar-desc">为各角色配置 API 接口的访问权限</span>
<span class="toolbar-title">项目级权限矩阵</span>
</div>
<div class="toolbar-filters">
<el-input
@@ -34,14 +33,40 @@
</div>
<div class="api-permissions-table-wrapper">
<el-table :data="filteredOperations" border stripe :span-method="spanMethod" class="perm-matrix-table">
<el-table-column prop="module" label="模块" width="140">
<el-table
:data="filteredOperations"
border
stripe
:span-method="spanMethod"
class="perm-matrix-table"
>
<el-table-column prop="permission_category" label="权限类型" width="120">
<template #default="{ row }">
<span class="module-label">{{ moduleLabel(row.module) }}</span>
<div class="hierarchy-cell hierarchy-cell--category" :data-category="getPermissionCategory(row)">
<span class="hierarchy-bar" />
<span class="category-label">{{ getPermissionCategoryLabel(row) }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="operation_key" label="权限操作" width="200">
<el-table-column prop="module" label="模块" width="160">
<template #default="{ row }">
<div class="hierarchy-cell hierarchy-cell--module">
<span class="module-dot" />
<span class="module-label">{{ moduleLabel(row.module) }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="section" label="模块细分" width="160">
<template #default="{ row }">
<div class="hierarchy-cell hierarchy-cell--section">
<span class="section-chip">{{ getOperationSection(row) }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="operation_key" label="权限操作" min-width="240">
<template #default="{ row }">
<div class="operation-cell">
<el-tag :type="getActionType(row)" size="small" class="action-tag">
@@ -60,11 +85,17 @@
align="center"
>
<template #default="{ row }">
<el-checkbox
:model-value="isOperationAllowed(row.operation_key, role)"
:disabled="!isRoleEditable(role)"
@change="(val: boolean) => onPermissionChange(row.operation_key, role, val)"
/>
<span
class="permission-state"
:class="{
'permission-state--allowed': isOperationAllowed(row.operation_key, role),
'permission-state--disabled': !isOperationAllowed(row.operation_key, role),
}"
:title="isOperationAllowed(row.operation_key, role) ? '已授权' : '未授权'"
>
<template v-if="isOperationAllowed(row.operation_key, role)"></template>
<CircleCloseFilled v-else />
</span>
</template>
</el-table-column>
</el-table>
@@ -74,14 +105,18 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { Search } from "@element-plus/icons-vue";
import { CircleCloseFilled, Search } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import type { ApiEndpointPermissionsResponse } from "@/types/api";
import { fetchApiOperations } from "@/api/projectPermissions";
import { isApiPermissionAllowed } from "@/utils/apiPermissionValue";
import { useAuthStore } from "@/store/auth";
import { isSystemAdmin } from "@/utils/roles";
import { useRoleTemplateMeta } from "@/composables/useRoleTemplateMeta";
import {
compareProjectPermissionModules,
projectPermissionCategory,
projectPermissionCategoryLabel,
projectPermissionModuleLabel,
} from "@/utils/projectPermissionModules";
interface Operation {
operation_key: string;
@@ -91,61 +126,65 @@ interface Operation {
default_roles: string[];
}
interface DisplayOperation extends Operation {
source_module: string;
source_index: number;
display_section?: string;
}
interface Props {
project: any;
matrix: ApiEndpointPermissionsResponse | null;
}
interface Emits {
(e: "update", matrix: ApiEndpointPermissionsResponse): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const auth = useAuthStore();
const { roleLabel, loadRoleTemplates } = useRoleTemplateMeta();
const isAdmin = computed(() => isSystemAdmin(auth.user));
const isRoleEditable = (role: string) => {
if (role === "ADMIN") return false;
if (isAdmin.value) return true;
return role !== "PM";
};
const { roleLabel, compareRolesByTemplateOrder, loadRoleTemplates } = useRoleTemplateMeta();
const searchText = ref("");
const filterModule = ref("");
const filterAction = ref("");
const operations = ref<Operation[]>([]);
const 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 moduleLabel = projectPermissionModuleLabel;
const getPermissionCategory = (row: Operation): "common" | "business" => projectPermissionCategory(row.module);
const getPermissionCategoryLabel = (row: Operation): string => projectPermissionCategoryLabel(row.module);
const RISK_ISSUE_REUSED_SECTIONS: Record<string, string> = {
subject_aes: "AE/SAE",
subject_pds: "PD",
};
const moduleLabel = (module: string): string => MODULE_LABELS[module] || module;
const getOperationPrefix = (row: Operation): string => row.operation_key.split(":")[0] || row.module;
const displayOperations = computed<DisplayOperation[]>(() => {
const rows: DisplayOperation[] = operations.value.map((operation, index) => ({
...operation,
source_module: operation.module,
source_index: index,
}));
operations.value.forEach((operation, index) => {
const prefix = getOperationPrefix(operation);
const displaySection = RISK_ISSUE_REUSED_SECTIONS[prefix];
if (!displaySection) return;
if (rows.some((row) => row.module === "risk_issues" && row.operation_key === operation.operation_key)) return;
rows.push({
...operation,
module: "risk_issues",
source_module: operation.module,
source_index: index,
display_section: displaySection,
});
});
return rows;
});
// 从矩阵中提取角色列表
const roles = computed(() => {
if (!props.matrix) return [];
return Object.keys(props.matrix).sort();
return Object.keys(props.matrix).sort(compareRolesByTemplateOrder);
});
// 加载权限操作列表
@@ -161,17 +200,19 @@ const loadOperations = async () => {
// 获取唯一的模块列表
const uniqueModules = computed(() => {
return [...new Set(operations.value.map((o) => o.module))].sort();
return [...new Set(displayOperations.value.map((o) => o.module))].sort(compareProjectPermissionModules);
});
// 过滤并按模块分组排序
const filteredOperations = computed(() => {
const filtered = operations.value.filter((operation) => {
const filtered = displayOperations.value.filter((operation) => {
const sectionLabel = getOperationSection(operation);
const matchSearch =
!searchText.value ||
operation.operation_key.toLowerCase().includes(searchText.value.toLowerCase()) ||
operation.description.toLowerCase().includes(searchText.value.toLowerCase()) ||
moduleLabel(operation.module).includes(searchText.value);
moduleLabel(operation.module).includes(searchText.value) ||
sectionLabel.includes(searchText.value);
const matchModule = !filterModule.value || operation.module === filterModule.value;
const matchAction = !filterAction.value || getOperationVerb(operation) === filterAction.value;
@@ -179,20 +220,36 @@ const filteredOperations = computed(() => {
return matchSearch && matchModule && matchAction;
});
// 按模块分组排序
// 按权限类型、模块分组排序
filtered.sort((a, b) => {
const categoryDiff = Number(getPermissionCategory(a) === "business") - Number(getPermissionCategory(b) === "business");
if (categoryDiff !== 0) return categoryDiff;
if (a.module !== b.module) {
return moduleLabel(a.module).localeCompare(moduleLabel(b.module), "zh-CN");
return compareProjectPermissionModules(a.module, b.module);
}
return a.operation_key.localeCompare(b.operation_key);
const sectionDiff = compareOperationSections(a, b);
if (sectionDiff !== 0) return sectionDiff;
return a.source_index - b.source_index;
});
return filtered;
});
// 模块列合并行
const spanMethod = ({ row, rowIndex, columnIndex }: { row: Operation; rowIndex: number; column: any; columnIndex: number }) => {
// 权限类型、模块与模块细分列合并行
const spanMethod = ({ row, rowIndex, columnIndex }: { row: DisplayOperation; rowIndex: number; column: any; columnIndex: number }) => {
if (columnIndex === 0) {
const list = filteredOperations.value;
const category = getPermissionCategory(row);
if (rowIndex === 0 || getPermissionCategory(list[rowIndex - 1]) !== category) {
let count = 1;
for (let i = rowIndex + 1; i < list.length && getPermissionCategory(list[i]) === category; i++) {
count++;
}
return { rowspan: count, colspan: 1 };
}
return { rowspan: 0, colspan: 0 };
}
if (columnIndex === 1) {
const list = filteredOperations.value;
if (rowIndex === 0 || list[rowIndex - 1].module !== row.module) {
let count = 1;
@@ -203,6 +260,28 @@ const spanMethod = ({ row, rowIndex, columnIndex }: { row: Operation; rowIndex:
}
return { rowspan: 0, colspan: 0 };
}
if (columnIndex === 2) {
const list = filteredOperations.value;
const section = getOperationSection(row);
if (
rowIndex === 0 ||
list[rowIndex - 1].module !== row.module ||
getOperationSection(list[rowIndex - 1]) !== section
) {
let count = 1;
for (
let i = rowIndex + 1;
i < list.length &&
list[i].module === row.module &&
getOperationSection(list[i]) === section;
i++
) {
count++;
}
return { rowspan: count, colspan: 1 };
}
return { rowspan: 0, colspan: 0 };
}
return { rowspan: 1, colspan: 1 };
};
@@ -252,19 +331,53 @@ const getActionType = (row: Operation): string => {
return ACTION_TAG_TYPES[verb] || "info";
};
// 权限变更处理
const onPermissionChange = (operation_key: string, role: string, allowed: boolean) => {
if (!props.matrix) return;
const SECTION_LABELS: Record<string, string> = {
subjects: "参与者基础信息",
visits: "访视",
subject_aes: "AE",
subject_pds: "PD",
subject_histories: "病史",
monitoring_issues: "监查访视问题",
project_milestones: "项目里程碑",
drug_shipments: "药品流向管理",
material_equipments: "设备管理",
startup_initiation: "立项记录",
startup_ethics: "伦理记录",
precautions: "注意事项",
precautions_attachments: "注意事项",
faq: "医学咨询/FAQ",
faq_category: "医学咨询/FAQ",
faq_reply: "医学咨询/FAQ",
faq_attachments: "医学咨询/FAQ",
};
const updatedMatrix: ApiEndpointPermissionsResponse = JSON.parse(JSON.stringify(props.matrix));
const SECTION_ORDER_BY_MODULE: Record<string, string[]> = {
subjects: ["参与者基础信息", "访视", "AE", "PD", "病史"],
risk_issues: ["AE/SAE", "PD", "监查访视问题"],
shared_library: ["医学咨询/FAQ", "注意事项"],
};
if (!updatedMatrix[role]) {
updatedMatrix[role] = {};
const getOperationSection = (row: Operation): string => {
const displaySection = (row as Partial<DisplayOperation>).display_section;
if (displaySection) return displaySection;
const prefix = getOperationPrefix(row);
return SECTION_LABELS[prefix] || moduleLabel(row.module);
};
const compareOperationSections = (a: Operation, b: Operation): number => {
const aSection = getOperationSection(a);
const bSection = getOperationSection(b);
const orderedSections = a.module === b.module ? SECTION_ORDER_BY_MODULE[a.module] : undefined;
if (orderedSections) {
const aIndex = orderedSections.indexOf(aSection);
const bIndex = orderedSections.indexOf(bSection);
if (aIndex !== -1 || bIndex !== -1) {
if (aIndex === -1) return 1;
if (bIndex === -1) return -1;
return aIndex - bIndex;
}
}
updatedMatrix[role][operation_key] = allowed;
emit("update", updatedMatrix);
return aSection.localeCompare(bSection, "zh-CN");
};
onMounted(() => {
@@ -272,7 +385,7 @@ onMounted(() => {
loadRoleTemplates();
});
defineExpose({ searchText });
defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermissionCategoryLabel });
</script>
<style scoped>
@@ -305,11 +418,6 @@ defineExpose({ searchText });
color: #1a2332;
}
.toolbar-desc {
font-size: 12px;
color: #64748b;
}
.toolbar-filters {
display: flex;
align-items: center;
@@ -327,10 +435,73 @@ defineExpose({ searchText });
width: 100%;
}
.hierarchy-cell {
display: flex;
align-items: center;
gap: 10px;
height: 100%;
}
/* 权限类型:左侧渐变色条 + 加粗大字 */
.hierarchy-cell--category {
position: relative;
padding-left: 6px;
}
.hierarchy-bar {
position: absolute;
left: -8px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 36px;
border-radius: 0 3px 3px 0;
background: linear-gradient(180deg, #94a3b8 0%, #cbd5e1 100%);
}
.hierarchy-cell--category[data-category="common"] .hierarchy-bar {
background: linear-gradient(180deg, #3b82f6 0%, #60a5fa 100%);
}
.hierarchy-cell--category[data-category="business"] .hierarchy-bar {
background: linear-gradient(180deg, #8b5cf6 0%, #a78bfa 100%);
}
.category-label {
font-size: 14px;
font-weight: 700;
color: #0f172a;
letter-spacing: 0.3px;
}
/* 模块:实心圆点 + 中等加粗字 */
.module-dot {
flex-shrink: 0;
width: 7px;
height: 7px;
border-radius: 50%;
background: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12);
}
.module-label {
font-weight: 600;
font-size: 13px;
color: #1a2332;
font-weight: 600;
color: #1e293b;
}
/* 模块细分:胶囊样式 */
.section-chip {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
background: #f1f5f9;
border: 1px solid #e2e8f0;
font-size: 12px;
font-weight: 500;
color: #475569;
white-space: nowrap;
}
.operation-cell {
@@ -349,4 +520,31 @@ defineExpose({ searchText });
font-size: 13px;
color: #475569;
}
.permission-state {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: #94a3b8;
font-size: 14px;
font-weight: 600;
}
.permission-state--allowed {
color: #0f766e;
background: #ecfdf5;
border: 1px solid #99f6e4;
border-radius: 999px;
}
.permission-state--disabled {
color: #94a3b8;
}
.permission-state--disabled :deep(svg) {
width: 16px;
height: 16px;
}
</style>