统一项目角色与接口权限配置
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user