d5279b124f
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
640 lines
18 KiB
Vue
640 lines
18 KiB
Vue
<template>
|
|
<div class="api-permissions">
|
|
<div class="api-permissions-toolbar">
|
|
<div class="toolbar-left">
|
|
<div class="toolbar-heading">
|
|
<span class="toolbar-title">有效权限矩阵</span>
|
|
<el-tag size="small" effect="plain" type="info">只读</el-tag>
|
|
</div>
|
|
<span class="toolbar-result">显示 {{ filteredPermissionCount }} / {{ totalPermissionCount }} 项权限</span>
|
|
</div>
|
|
<div class="toolbar-filters">
|
|
<el-input
|
|
v-model="searchText"
|
|
placeholder="搜索权限..."
|
|
clearable
|
|
style="width: 220px"
|
|
:prefix-icon="Search"
|
|
/>
|
|
<el-select v-model="filterModule" placeholder="筛选模块" clearable style="width: 150px">
|
|
<el-option label="全部模块" value="" />
|
|
<el-option
|
|
v-for="module in uniqueModules"
|
|
:key="module"
|
|
:label="moduleLabel(module)"
|
|
:value="module"
|
|
/>
|
|
</el-select>
|
|
<el-select v-model="filterAction" placeholder="筛选操作" clearable style="width: 120px">
|
|
<el-option label="全部操作" value="" />
|
|
<el-option label="创建" value="create" />
|
|
<el-option label="读取" value="read" />
|
|
<el-option label="更新" value="update" />
|
|
<el-option label="删除" value="delete" />
|
|
<el-option label="导出" value="export" />
|
|
</el-select>
|
|
<el-select v-model="filterAuthorization" placeholder="授权状态" style="width: 140px">
|
|
<el-option label="全部授权状态" value="" />
|
|
<el-option label="存在角色差异" value="different" />
|
|
<el-option label="至少一项授权" value="granted" />
|
|
<el-option label="全部未授权" value="ungranted" />
|
|
</el-select>
|
|
<el-button :disabled="!hasActiveFilters" @click="resetFilters">重置</el-button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="api-permissions-table-wrapper">
|
|
<el-table
|
|
:data="filteredOperations"
|
|
border
|
|
stripe
|
|
max-height="calc(100dvh - 330px)"
|
|
empty-text="未找到匹配的权限"
|
|
:span-method="spanMethod"
|
|
class="perm-matrix-table"
|
|
>
|
|
<el-table-column prop="permission_category" label="权限类型" width="120" fixed="left">
|
|
<template #default="{ row }">
|
|
<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="module" label="模块" width="160" fixed="left">
|
|
<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">
|
|
{{ getActionLabel(row) }}
|
|
</el-tag>
|
|
<span class="operation-name">{{ row.description || row.operation_key }}</span>
|
|
</div>
|
|
</template>
|
|
</el-table-column>
|
|
|
|
<el-table-column
|
|
v-for="role in roles"
|
|
:key="role"
|
|
:label="roleLabel(role)"
|
|
:width="90"
|
|
align="center"
|
|
>
|
|
<template #default="{ row }">
|
|
<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>
|
|
<template v-else>—</template>
|
|
</span>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from "vue";
|
|
import { 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 { useRoleTemplateMeta } from "@/composables/useRoleTemplateMeta";
|
|
import {
|
|
compareProjectPermissionModules,
|
|
projectPermissionCategory,
|
|
projectPermissionCategoryLabel,
|
|
projectPermissionModuleLabel,
|
|
} from "@/utils/projectPermissionModules";
|
|
|
|
interface Operation {
|
|
operation_key: string;
|
|
module: string;
|
|
action: string;
|
|
description: string;
|
|
default_roles: string[];
|
|
}
|
|
|
|
interface DisplayOperation extends Operation {
|
|
source_module: string;
|
|
source_index: number;
|
|
display_section?: string;
|
|
}
|
|
|
|
interface Props {
|
|
project: any;
|
|
matrix: ApiEndpointPermissionsResponse | null;
|
|
}
|
|
|
|
const props = defineProps<Props>();
|
|
|
|
const { roleLabel, compareRolesByTemplateOrder, loadRoleTemplates } = useRoleTemplateMeta();
|
|
|
|
const searchText = ref("");
|
|
const filterModule = ref("");
|
|
const filterAction = ref("");
|
|
const filterAuthorization = ref("");
|
|
const operations = ref<Operation[]>([]);
|
|
|
|
const hasActiveFilters = computed(() => Boolean(
|
|
searchText.value || filterModule.value || filterAction.value || filterAuthorization.value
|
|
));
|
|
|
|
const resetFilters = () => {
|
|
searchText.value = "";
|
|
filterModule.value = "";
|
|
filterAction.value = "";
|
|
filterAuthorization.value = "";
|
|
};
|
|
|
|
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 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(compareRolesByTemplateOrder);
|
|
});
|
|
|
|
// 加载权限操作列表
|
|
const loadOperations = async () => {
|
|
try {
|
|
const { data } = await fetchApiOperations();
|
|
operations.value = data.operations;
|
|
} catch (error) {
|
|
ElMessage.error("加载权限操作失败");
|
|
console.error(error);
|
|
}
|
|
};
|
|
|
|
// 获取唯一的模块列表
|
|
const uniqueModules = computed(() => {
|
|
return [...new Set(displayOperations.value.map((o) => o.module))].sort(compareProjectPermissionModules);
|
|
});
|
|
|
|
// 过滤并按模块分组排序
|
|
const filteredOperations = computed(() => {
|
|
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) ||
|
|
sectionLabel.includes(searchText.value);
|
|
|
|
const matchModule = !filterModule.value || operation.module === filterModule.value;
|
|
const matchAction = !filterAction.value || getOperationVerb(operation) === filterAction.value;
|
|
const roleStates = roles.value.map((role) => isOperationAllowed(operation.operation_key, role));
|
|
const matchAuthorization =
|
|
!filterAuthorization.value ||
|
|
(filterAuthorization.value === "different" && roleStates.some(Boolean) && roleStates.some((allowed) => !allowed)) ||
|
|
(filterAuthorization.value === "granted" && roleStates.some(Boolean)) ||
|
|
(filterAuthorization.value === "ungranted" && roleStates.every((allowed) => !allowed));
|
|
|
|
return matchSearch && matchModule && matchAction && matchAuthorization;
|
|
});
|
|
|
|
// 按权限类型、模块分组排序
|
|
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 compareProjectPermissionModules(a.module, b.module);
|
|
}
|
|
const sectionDiff = compareOperationSections(a, b);
|
|
if (sectionDiff !== 0) return sectionDiff;
|
|
return a.source_index - b.source_index;
|
|
});
|
|
|
|
return filtered;
|
|
});
|
|
|
|
const countUniquePermissions = (rows: DisplayOperation[]): number =>
|
|
new Set(rows.map((operation) => operation.operation_key)).size;
|
|
|
|
const totalPermissionCount = computed(() => countUniquePermissions(displayOperations.value));
|
|
const filteredPermissionCount = computed(() => countUniquePermissions(filteredOperations.value));
|
|
|
|
// 权限类型、模块与模块细分列合并行
|
|
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;
|
|
for (let i = rowIndex + 1; i < list.length && list[i].module === row.module; i++) {
|
|
count++;
|
|
}
|
|
return { rowspan: count, colspan: 1 };
|
|
}
|
|
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 };
|
|
};
|
|
|
|
// 检查操作是否允许
|
|
const isOperationAllowed = (operation_key: string, role: string): boolean => {
|
|
if (!props.matrix || !props.matrix[role]) return false;
|
|
return isApiPermissionAllowed(props.matrix[role][operation_key]);
|
|
};
|
|
|
|
// 从 operation_key 提取操作类型
|
|
const getOperationVerb = (row: Operation): string => {
|
|
const key = row.operation_key;
|
|
const suffix = key.split(":").pop() || "";
|
|
if (suffix === "create") return "create";
|
|
if (suffix === "read" || suffix === "list") return "read";
|
|
if (suffix === "update") return "update";
|
|
if (suffix === "delete") return "delete";
|
|
if (suffix === "export") return "export";
|
|
return row.action;
|
|
};
|
|
|
|
const ACTION_LABELS: Record<string, string> = {
|
|
create: "创建",
|
|
read: "读取",
|
|
update: "更新",
|
|
delete: "删除",
|
|
export: "导出",
|
|
write: "写入",
|
|
};
|
|
|
|
const ACTION_TAG_TYPES: Record<string, string> = {
|
|
create: "success",
|
|
read: "info",
|
|
update: "warning",
|
|
delete: "danger",
|
|
export: "",
|
|
write: "success",
|
|
};
|
|
|
|
const getActionLabel = (row: Operation): string => {
|
|
const verb = getOperationVerb(row);
|
|
return ACTION_LABELS[verb] || verb;
|
|
};
|
|
|
|
const getActionType = (row: Operation): string => {
|
|
const verb = getOperationVerb(row);
|
|
return ACTION_TAG_TYPES[verb] || "info";
|
|
};
|
|
|
|
const SECTION_LABELS: Record<string, string> = {
|
|
subjects: "参与者基础信息",
|
|
visits: "访视",
|
|
subject_aes: "AE",
|
|
subject_pds: "PD",
|
|
subject_histories: "病史",
|
|
monitoring_issues: "监查访视问题",
|
|
project_milestones: "项目里程碑",
|
|
drug_shipments: "药品流向管理",
|
|
drug_shipments_attachments: "药品流向管理",
|
|
material_equipments: "设备管理",
|
|
material_equipments_attachments: "设备管理",
|
|
startup_initiation: "立项",
|
|
startup_initiation_attachments: "立项",
|
|
startup_ethics: "伦理",
|
|
startup_ethics_attachments: "伦理",
|
|
precautions: "注意事项",
|
|
precautions_attachments: "注意事项",
|
|
faq: "医学咨询/FAQ",
|
|
faq_category: "医学咨询/FAQ",
|
|
faq_reply: "医学咨询/FAQ",
|
|
faq_attachments: "医学咨询/FAQ",
|
|
collaboration: "在线协作",
|
|
};
|
|
|
|
const SECTION_ORDER_BY_MODULE: Record<string, string[]> = {
|
|
subjects: ["参与者基础信息", "访视", "AE", "PD", "病史"],
|
|
risk_issues: ["AE/SAE", "PD", "监查访视问题"],
|
|
shared_library: ["医学咨询/FAQ", "注意事项", "在线协作"],
|
|
};
|
|
|
|
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;
|
|
}
|
|
}
|
|
return aSection.localeCompare(bSection, "zh-CN");
|
|
};
|
|
|
|
onMounted(() => {
|
|
loadOperations();
|
|
loadRoleTemplates();
|
|
});
|
|
|
|
defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermissionCategoryLabel });
|
|
</script>
|
|
|
|
<style scoped>
|
|
.api-permissions {
|
|
padding: 8px 0;
|
|
}
|
|
|
|
.api-permissions-toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
margin-bottom: 8px;
|
|
padding: 8px 10px;
|
|
background: #fff;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 8px;
|
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
|
|
}
|
|
|
|
.toolbar-left {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.toolbar-heading {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
.toolbar-title {
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: #1a2332;
|
|
}
|
|
|
|
.toolbar-result {
|
|
color: #7a8a9a;
|
|
font-size: 10.5px;
|
|
}
|
|
|
|
.toolbar-filters {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
.api-permissions-table-wrapper {
|
|
overflow-x: auto;
|
|
border-radius: 8px;
|
|
border: 1px solid #e2e8f0;
|
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
|
|
}
|
|
|
|
.perm-matrix-table {
|
|
width: 100%;
|
|
}
|
|
|
|
.hierarchy-cell {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
height: 100%;
|
|
}
|
|
|
|
/* 权限类型:左侧渐变色条 + 加粗大字 */
|
|
.hierarchy-cell--category {
|
|
position: relative;
|
|
padding-left: 6px;
|
|
}
|
|
|
|
.hierarchy-bar {
|
|
position: absolute;
|
|
left: -8px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
width: 3px;
|
|
height: 24px;
|
|
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: 12px;
|
|
font-weight: 700;
|
|
color: #0f172a;
|
|
letter-spacing: 0.3px;
|
|
}
|
|
|
|
/* 模块:实心圆点 + 中等加粗字 */
|
|
.module-dot {
|
|
flex-shrink: 0;
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
background: #3b82f6;
|
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12);
|
|
}
|
|
|
|
.module-label {
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
color: #1e293b;
|
|
}
|
|
|
|
/* 模块细分:胶囊样式 */
|
|
.section-chip {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 2px 7px;
|
|
border-radius: 999px;
|
|
background: #f1f5f9;
|
|
border: 1px solid #e2e8f0;
|
|
font-size: 10.5px;
|
|
font-weight: 500;
|
|
color: #475569;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.operation-cell {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
.action-tag {
|
|
flex-shrink: 0;
|
|
min-width: 36px;
|
|
text-align: center;
|
|
}
|
|
|
|
.operation-name {
|
|
font-size: 12px;
|
|
color: #475569;
|
|
}
|
|
|
|
.permission-state {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 18px;
|
|
height: 18px;
|
|
color: #94a3b8;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.permission-state--allowed {
|
|
color: #0f766e;
|
|
background: #ecfdf5;
|
|
border: 1px solid #99f6e4;
|
|
border-radius: 999px;
|
|
}
|
|
|
|
.permission-state--disabled {
|
|
color: #c3ccd5;
|
|
font-weight: 500;
|
|
}
|
|
|
|
:deep(.api-permissions .el-input__wrapper),
|
|
:deep(.api-permissions .el-select__wrapper) {
|
|
min-height: 28px;
|
|
}
|
|
|
|
:deep(.api-permissions .el-button) {
|
|
min-height: 28px;
|
|
padding: 4px 8px;
|
|
font-size: 11.5px;
|
|
}
|
|
|
|
:deep(.perm-matrix-table .el-table__header-wrapper th.el-table__cell) {
|
|
padding: 6px 0;
|
|
font-size: 11px;
|
|
}
|
|
|
|
:deep(.perm-matrix-table .el-table__body-wrapper td.el-table__cell) {
|
|
padding: 4px 0;
|
|
font-size: 12px;
|
|
}
|
|
|
|
@media (max-width: 1180px) {
|
|
.api-permissions-toolbar {
|
|
align-items: flex-start;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.toolbar-filters {
|
|
flex-wrap: wrap;
|
|
width: 100%;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 720px) {
|
|
.toolbar-filters > * {
|
|
width: 100% !important;
|
|
}
|
|
}
|
|
</style>
|