1360 lines
47 KiB
Vue
1360 lines
47 KiB
Vue
<template>
|
||
<div class="perm-page" v-loading="pageLoading">
|
||
|
||
<!-- ══════════════════════════════════════
|
||
项目权限配置
|
||
══════════════════════════════════════ -->
|
||
<template v-if="activeTab === 'project'">
|
||
<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">
|
||
<el-select
|
||
v-model="selectedStudyId"
|
||
placeholder="选择项目"
|
||
filterable
|
||
style="width: 280px"
|
||
@change="onStudyChange"
|
||
>
|
||
<el-option
|
||
v-for="s in studies"
|
||
:key="s.id"
|
||
:label="`${s.code} · ${s.name}`"
|
||
:value="s.id"
|
||
/>
|
||
</el-select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="perm-body">
|
||
<div v-if="!selectedStudyId" class="perm-empty">
|
||
<el-empty description="请先选择一个项目以配置权限" />
|
||
</div>
|
||
<template v-else>
|
||
<el-tabs v-model="projectSubTab" class="project-sub-tabs">
|
||
<el-tab-pane label="角色权限" name="api">
|
||
<div class="sub-tab-toolbar">
|
||
<div class="sub-tab-spacer" />
|
||
<el-button @click="templateDrawerVisible = true">
|
||
<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>
|
||
<PermissionTemplateSelector
|
||
:study-id="selectedStudyId"
|
||
:current-permissions="currentPermissionsForTemplate"
|
||
:active-roles="activeRolesInStudy"
|
||
@edit-role="openRoleEditor"
|
||
/>
|
||
<el-divider />
|
||
<ApiEndpointPermissions
|
||
:project="selectedStudy"
|
||
:matrix="apiMatrix"
|
||
@update="onApiMatrixUpdate"
|
||
/>
|
||
</el-tab-pane>
|
||
<el-tab-pane label="成员管理" name="members">
|
||
<div class="sub-tab-toolbar">
|
||
<div class="sub-tab-spacer" />
|
||
<el-button type="primary" @click="openAddMember">添加成员</el-button>
|
||
</div>
|
||
<el-table :data="memberRows" v-loading="membersLoading" stripe class="full-table">
|
||
<el-table-column prop="full_name" label="姓名" min-width="140" />
|
||
<el-table-column label="项目角色" min-width="140">
|
||
<template #default="{ row }">
|
||
<el-select
|
||
v-model="row.role_in_study"
|
||
size="small"
|
||
style="width: 130px"
|
||
@change="(val: string) => updateMemberRole(row.id, val)"
|
||
:disabled="!canEditMember(row) || !row.is_active || row.effectiveStatus === 'DISABLED_GLOBAL'"
|
||
>
|
||
<el-option v-for="(label, val) in activeRoleLabels" :key="val" :label="label" :value="val" />
|
||
</el-select>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="110">
|
||
<template #default="{ row }">
|
||
<el-tooltip v-if="row.effectiveStatus === 'DISABLED_GLOBAL'" content="请前往账号管理启用该账号" placement="top">
|
||
<el-tag type="danger">全局已禁用</el-tag>
|
||
</el-tooltip>
|
||
<el-tag v-else :type="row.is_active ? 'success' : 'danger'">
|
||
{{ row.is_active ? "启用" : "已禁用" }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="added_at" label="加入时间" min-width="160">
|
||
<template #default="{ row }">{{ displayDateTime(row.added_at) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="160" align="center">
|
||
<template #default="{ row }">
|
||
<el-button
|
||
link
|
||
:type="row.is_active ? 'warning' : 'primary'"
|
||
size="small"
|
||
:disabled="!canEditMember(row) || row.effectiveStatus === 'DISABLED_GLOBAL'"
|
||
@click="toggleMemberActive(row)"
|
||
>
|
||
{{ row.is_active ? "停用" : "启用" }}
|
||
</el-button>
|
||
<el-button link type="danger" size="small" :disabled="!canEditMember(row)" @click="deleteMember(row)">
|
||
移除
|
||
</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- ══════════════════════════════════════
|
||
系统级权限
|
||
══════════════════════════════════════ -->
|
||
<template v-else-if="activeTab === 'system'">
|
||
<div class="perm-header">
|
||
<div class="perm-header-left">
|
||
<h2 class="perm-title">系统级权限</h2>
|
||
<span class="perm-subtitle">只读展示,所有系统管理操作仅限 ADMIN 角色执行</span>
|
||
</div>
|
||
</div>
|
||
<div class="perm-body">
|
||
<div v-if="systemLoading" class="perm-empty">
|
||
<el-skeleton :rows="8" animated />
|
||
</div>
|
||
<div v-else-if="!systemPermissions.length" class="perm-empty">
|
||
<el-empty description="暂无系统级权限数据" />
|
||
</div>
|
||
<template v-else>
|
||
<div v-for="(items, moduleKey) in systemPermissionsByModule" :key="moduleKey" class="system-module-block">
|
||
<div class="system-module-header">
|
||
<span class="system-module-title">{{ systemModuleLabels[moduleKey] || moduleKey }}</span>
|
||
<el-tag size="small" type="info" round>{{ items.length }} 项操作</el-tag>
|
||
</div>
|
||
<el-table :data="items" border size="small" class="system-perm-table">
|
||
<el-table-column prop="description" label="操作描述" min-width="180" />
|
||
<el-table-column prop="permission_key" label="权限标识" min-width="220">
|
||
<template #default="{ row }">
|
||
<code class="perm-key">{{ row.permission_key }}</code>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="action" label="操作类型" width="100">
|
||
<template #default="{ row }">
|
||
<el-tag :type="opTagType({ operation_key: row.permission_key, action: row.action })" size="small">
|
||
{{ opActionLabel({ operation_key: row.permission_key, action: row.action }) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="授权角色" width="120">
|
||
<template #default="{ row }">
|
||
<el-tag v-for="r in row.roles" :key="r" size="small" type="danger" style="margin-right: 4px">{{ r }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- ══════════════════════════════════════
|
||
权限监控
|
||
══════════════════════════════════════ -->
|
||
<template v-else-if="activeTab === 'monitoring'">
|
||
<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">
|
||
<el-button @click="loadMonitoringData" :loading="monitoringLoading">
|
||
<el-icon><RefreshRight /></el-icon>
|
||
刷新
|
||
</el-button>
|
||
<el-button @click="doResetMetrics" :loading="resetting">重置指标</el-button>
|
||
</div>
|
||
</div>
|
||
<div class="perm-body">
|
||
<div class="perm-body-padded">
|
||
<PermissionMonitoring
|
||
:metrics="metrics"
|
||
:health="health"
|
||
:alerts="alerts"
|
||
@refresh="loadMonitoringData"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
|
||
<!-- ══════════════════════════════════════
|
||
模板管理抽屉
|
||
══════════════════════════════════════ -->
|
||
<el-drawer v-model="templateDrawerVisible" title="权限模板管理" size="720px" direction="rtl" @open="onTemplateDrawerOpen">
|
||
<el-tabs v-model="templateDrawerTab" class="drawer-tabs">
|
||
|
||
<!-- 标签页:模板列表 -->
|
||
<el-tab-pane label="模板列表" name="list">
|
||
<div class="drawer-toolbar">
|
||
<el-select v-model="typeFilter" placeholder="模板类型" clearable style="width: 140px" @change="loadTemplates">
|
||
<el-option label="角色模板" value="ROLE" />
|
||
<el-option label="场景模板" value="SCENARIO" />
|
||
<el-option label="自定义模板" value="CUSTOM" />
|
||
</el-select>
|
||
<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">{{ row.name }}</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 ALL_ROLES" :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)"
|
||
/>
|
||
</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 ALL_ROLES"
|
||
: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>
|
||
|
||
<!-- 右侧权限编辑区 -->
|
||
<div class="role-editor-content">
|
||
<template v-if="editingRole">
|
||
<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" @click="roleEditorSelectAll(true)">全选</el-button>
|
||
<el-button size="small" @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"
|
||
@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>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="drawer-footer">
|
||
<el-button type="primary" :loading="roleEditorSaving" @click="saveRoleEditor">
|
||
保存 {{ ROLE_LABELS[editingRole] }} 权限
|
||
</el-button>
|
||
</div>
|
||
</template>
|
||
<el-empty v-else description="请从左侧选择角色" style="padding: 80px 0" />
|
||
</div>
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
</el-tabs>
|
||
</el-drawer>
|
||
|
||
<!-- 新增/编辑模板对话框 -->
|
||
<el-dialog v-model="templateDialogVisible" :title="editingTemplateId ? '编辑权限模板' : '新增权限模板'" width="520px" @close="resetTemplateForm">
|
||
<el-form :model="templateForm" :rules="templateRules" ref="templateFormRef" label-width="90px">
|
||
<el-form-item label="模板名称" prop="name">
|
||
<el-input v-model="templateForm.name" placeholder="请输入模板名称" />
|
||
</el-form-item>
|
||
<el-form-item label="描述">
|
||
<el-input v-model="templateForm.description" type="textarea" :rows="2" placeholder="可选描述" />
|
||
</el-form-item>
|
||
<el-form-item label="模板类型" prop="template_type">
|
||
<el-select v-model="templateForm.template_type" style="width: 100%">
|
||
<el-option label="角色模板" value="ROLE" />
|
||
<el-option label="场景模板" value="SCENARIO" />
|
||
<el-option label="自定义模板" value="CUSTOM" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="分类">
|
||
<el-input v-model="templateForm.category" placeholder="如 PM / CRA / PV" />
|
||
</el-form-item>
|
||
<el-form-item label="推荐角色">
|
||
<el-input v-model="templateForm.recommended_roles" placeholder="如 PM,CRA" />
|
||
</el-form-item>
|
||
<el-form-item label="标签">
|
||
<el-input v-model="templateForm.tags" placeholder="可选标签" />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="templateDialogVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="templateSaving" @click="submitTemplateForm">保存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 新增成员对话框 -->
|
||
<el-dialog v-model="addMemberVisible" title="新增成员" width="480px" @close="resetAddMemberForm">
|
||
<el-form :model="addMemberForm" :rules="addMemberRules" ref="addMemberFormRef" label-width="90px">
|
||
<el-form-item label="用户" prop="user_id">
|
||
<el-select v-model="addMemberForm.user_id" filterable placeholder="请选择用户" style="width: 100%">
|
||
<el-option
|
||
v-for="u in availableCandidates"
|
||
:key="u.id"
|
||
:label="u.full_name"
|
||
:value="u.id"
|
||
:disabled="!u.is_active"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="项目角色" prop="role_in_study">
|
||
<el-select v-model="addMemberForm.role_in_study" style="width: 100%">
|
||
<el-option v-for="(label, val) in activeRoleLabels" :key="val" :label="label" :value="val" />
|
||
</el-select>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="addMemberVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="addMemberSaving" @click="submitAddMember">添加</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 角色权限编辑抽屉已合并至模板管理抽屉 -->
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted, watch, reactive } from "vue";
|
||
import { useRoute, useRouter } from "vue-router";
|
||
import { ElMessage, ElMessageBox } from "element-plus";
|
||
import { RefreshRight, Check, Files, Search, ArrowRight } from "@element-plus/icons-vue";
|
||
import type { FormInstance, FormRules } from "element-plus";
|
||
import type {
|
||
ApiEndpointPermissionsResponse,
|
||
PermissionMetricsResponse,
|
||
AlertsResponse,
|
||
HealthResponse,
|
||
Study,
|
||
StudyMember,
|
||
UserInfo,
|
||
} from "@/types/api";
|
||
import {
|
||
fetchApiEndpointPermissions,
|
||
updateApiEndpointPermissions,
|
||
fetchApiOperations,
|
||
fetchPermissionMetrics,
|
||
fetchPermissionAlerts,
|
||
fetchPermissionHealth,
|
||
resetPermissionMetrics,
|
||
fetchPermissionTemplates,
|
||
createPermissionTemplate,
|
||
updatePermissionTemplate,
|
||
deletePermissionTemplate,
|
||
fetchSystemPermissions,
|
||
fetchActiveRoles,
|
||
updateActiveRoles,
|
||
type PermissionTemplate,
|
||
type SystemPermissionItem,
|
||
} from "@/api/projectPermissions";
|
||
import { addMember, listMemberCandidates, listMembers, removeMember, updateMember } from "@/api/members";
|
||
import { fetchStudies } from "@/api/studies";
|
||
import { useAuthStore } from "@/store/auth";
|
||
import { displayDateTime } from "@/utils/display";
|
||
import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue";
|
||
import PermissionMonitoring from "@/components/PermissionMonitoring.vue";
|
||
import PermissionTemplateSelector from "@/components/PermissionTemplateSelector.vue";
|
||
|
||
const route = useRoute();
|
||
const router = useRouter();
|
||
const auth = useAuthStore();
|
||
|
||
const ROLE_LABELS: Record<string, string> = {
|
||
PM: "项目负责人", CRA: "CRA", PV: "PV",
|
||
MEDICAL_REVIEW: "医学审核", IMP: "药品管理员", QA: "QA",
|
||
};
|
||
|
||
const activeRoleLabels = computed(() => {
|
||
const result: Record<string, string> = {};
|
||
for (const role of activeRolesInStudy.value) {
|
||
if (ROLE_LABELS[role]) result[role] = ROLE_LABELS[role];
|
||
}
|
||
return result;
|
||
});
|
||
const ROLE_RANK: Record<string, number> = {
|
||
ADMIN: 100, PM: 80, QA: 60, PV: 50, MEDICAL_REVIEW: 50, CRA: 40, IMP: 40,
|
||
};
|
||
|
||
// ── 顶层标签 ──
|
||
const tabFromPath = (): "project" | "system" | "monitoring" => {
|
||
const p = route.path;
|
||
if (p.endsWith("/system")) return "system";
|
||
if (p.endsWith("/monitoring")) return "monitoring";
|
||
return "project";
|
||
};
|
||
const activeTab = computed({
|
||
get: () => tabFromPath(),
|
||
set: (tab: "project" | "system" | "monitoring") => {
|
||
const pathMap = { system: "/admin/permissions/system", project: "/admin/permissions/project", monitoring: "/admin/permissions/monitoring" };
|
||
router.push({ path: pathMap[tab], query: route.query });
|
||
},
|
||
});
|
||
const pageLoading = ref(false);
|
||
|
||
// ═══════════════════════════════════════════
|
||
// 项目权限配置
|
||
// ═══════════════════════════════════════════
|
||
const studies = ref<Study[]>([]);
|
||
const selectedStudyId = ref<string>("");
|
||
const selectedStudy = computed(() => studies.value.find((s) => s.id === selectedStudyId.value) ?? null);
|
||
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;
|
||
const result: Record<string, Record<string, boolean>> = {};
|
||
for (const [role, endpoints] of Object.entries(apiMatrix.value as Record<string, Record<string, { allowed: boolean }>>)) {
|
||
result[role] = {};
|
||
for (const [key, val] of Object.entries(endpoints)) result[role][key] = val.allowed;
|
||
}
|
||
return result;
|
||
});
|
||
|
||
const loadStudies = async () => {
|
||
try {
|
||
const res = await fetchStudies();
|
||
studies.value = res.data.items ?? [];
|
||
} catch {
|
||
ElMessage.error("加载项目列表失败");
|
||
}
|
||
};
|
||
|
||
const loadPermissionData = async () => {
|
||
if (!selectedStudyId.value) return;
|
||
pageLoading.value = true;
|
||
try {
|
||
const res = await fetchApiEndpointPermissions(selectedStudyId.value);
|
||
apiMatrix.value = res.data;
|
||
dirty.value = false;
|
||
} catch {
|
||
ElMessage.error("加载权限数据失败");
|
||
} finally {
|
||
pageLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const onStudyChange = async (id: string) => {
|
||
apiMatrix.value = null;
|
||
dirty.value = false;
|
||
members.value = [];
|
||
candidates.value = [];
|
||
activeRolesDraft.value = [];
|
||
router.replace({ query: { ...route.query, projectId: id } });
|
||
await Promise.all([loadPermissionData(), loadMembers(), loadActiveRoles()]);
|
||
loadCandidates();
|
||
};
|
||
|
||
const onApiMatrixUpdate = (m: ApiEndpointPermissionsResponse) => { apiMatrix.value = m; dirty.value = true; };
|
||
|
||
const activeRolesInStudy = computed(() => activeRolesDraft.value);
|
||
|
||
// 把 apiMatrix(可能混有 { allowed: boolean } 或 boolean)展平为 boolean 格式
|
||
const flattenMatrix = (matrix: ApiEndpointPermissionsResponse): Record<string, Record<string, boolean>> => {
|
||
const result: Record<string, Record<string, boolean>> = {};
|
||
for (const [role, endpoints] of Object.entries(matrix as Record<string, Record<string, any>>)) {
|
||
result[role] = {};
|
||
for (const [key, val] of Object.entries(endpoints)) {
|
||
result[role][key] = typeof val === "boolean" ? val : val.allowed;
|
||
}
|
||
}
|
||
return result;
|
||
};
|
||
|
||
const save = async () => {
|
||
if (!selectedStudyId.value || !dirty.value || !apiMatrix.value) return;
|
||
saving.value = true;
|
||
try {
|
||
const res = await updateApiEndpointPermissions(selectedStudyId.value, flattenMatrix(apiMatrix.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[]>([]);
|
||
const membersLoading = ref(false);
|
||
const addMemberVisible = ref(false);
|
||
const addMemberSaving = ref(false);
|
||
const addMemberFormRef = ref<FormInstance>();
|
||
const addMemberForm = reactive({ user_id: "", role_in_study: "PM" });
|
||
const addMemberRules: FormRules = {
|
||
user_id: [{ required: true, message: "请选择用户", trigger: "change" }],
|
||
role_in_study: [{ required: true, message: "请选择角色", trigger: "change" }],
|
||
};
|
||
|
||
const canEditMember = (row: { user_id: string; role_in_study: string; user?: any }) => {
|
||
if (auth.user?.role !== "ADMIN") return false;
|
||
if (row.user_id === auth.user?.id) return false;
|
||
if (row.user?.role === "ADMIN") return false;
|
||
return true;
|
||
};
|
||
|
||
const memberRows = computed(() =>
|
||
members.value.map((m) => {
|
||
const user = candidates.value.find((u) => u.id === m.user_id) || m.user;
|
||
const effectiveStatus = user && (user as any).is_active === false ? "DISABLED_GLOBAL" : m.is_active ? "ACTIVE" : "DISABLED";
|
||
return { ...m, full_name: (user as any)?.full_name || m.user_id, effectiveStatus };
|
||
})
|
||
);
|
||
|
||
const availableCandidates = computed(() => {
|
||
const ids = new Set(members.value.map((m) => m.user_id));
|
||
return candidates.value.filter((u) => !ids.has(u.id));
|
||
});
|
||
|
||
const loadMembers = async () => {
|
||
if (!selectedStudyId.value) return;
|
||
membersLoading.value = true;
|
||
try {
|
||
const { data } = await listMembers(selectedStudyId.value, { limit: 500, include_inactive: true });
|
||
members.value = Array.isArray(data) ? data : (data as any).items ?? [];
|
||
} catch {
|
||
ElMessage.error("加载成员失败");
|
||
} finally {
|
||
membersLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const loadCandidates = async () => {
|
||
if (!selectedStudyId.value) return;
|
||
try {
|
||
const { data } = await listMemberCandidates(selectedStudyId.value, { limit: 500 });
|
||
candidates.value = data ?? [];
|
||
} catch {
|
||
candidates.value = [];
|
||
}
|
||
};
|
||
|
||
const openAddMember = () => {
|
||
addMemberForm.user_id = "";
|
||
addMemberForm.role_in_study = "PM";
|
||
addMemberVisible.value = true;
|
||
};
|
||
|
||
const resetAddMemberForm = () => { addMemberFormRef.value?.clearValidate(); };
|
||
|
||
const submitAddMember = async () => {
|
||
const valid = await addMemberFormRef.value?.validate().catch(() => false);
|
||
if (!valid) return;
|
||
addMemberSaving.value = true;
|
||
try {
|
||
await addMember(selectedStudyId.value, addMemberForm);
|
||
ElMessage.success("成员已添加");
|
||
addMemberVisible.value = false;
|
||
await Promise.all([loadMembers(), loadCandidates()]);
|
||
} catch (e: any) {
|
||
ElMessage.error(e?.response?.data?.message || "添加失败");
|
||
} finally {
|
||
addMemberSaving.value = false;
|
||
}
|
||
};
|
||
|
||
const updateMemberRole = async (memberId: string, role: string) => {
|
||
try {
|
||
await updateMember(selectedStudyId.value, memberId, { role_in_study: role });
|
||
ElMessage.success("角色已更新");
|
||
} catch (e: any) {
|
||
ElMessage.error(e?.response?.data?.message || "更新失败");
|
||
loadMembers();
|
||
}
|
||
};
|
||
|
||
const toggleMemberActive = async (row: any) => {
|
||
if (row.is_active) {
|
||
const ok = await ElMessageBox.confirm("确认停用该成员?", "停用成员", { type: "warning" }).catch(() => null);
|
||
if (!ok) return;
|
||
}
|
||
try {
|
||
await updateMember(selectedStudyId.value, row.id, { is_active: !row.is_active });
|
||
ElMessage.success(row.is_active ? "已停用" : "已启用");
|
||
loadMembers();
|
||
} catch (e: any) {
|
||
ElMessage.error(e?.response?.data?.message || "操作失败");
|
||
}
|
||
};
|
||
|
||
const deleteMember = async (row: any) => {
|
||
const ok = await ElMessageBox.confirm("确认将该账号从本项目移除?账号本身不会被删除。", "移除成员", {
|
||
type: "warning",
|
||
}).catch(() => null);
|
||
if (!ok) return;
|
||
try {
|
||
await removeMember(selectedStudyId.value, row.id);
|
||
ElMessage.success("已移除");
|
||
await Promise.all([loadMembers(), loadCandidates()]);
|
||
} catch (e: any) {
|
||
ElMessage.error(e?.response?.data?.message || "移除失败");
|
||
}
|
||
};
|
||
|
||
// ═══════════════════════════════════════════
|
||
// 模板管理(抽屉)
|
||
// ═══════════════════════════════════════════
|
||
const templateDrawerVisible = ref(false);
|
||
const templateDrawerTab = ref<"list" | "active" | "edit-role">("list");
|
||
const templates = ref<PermissionTemplate[]>([]);
|
||
const templatesLoading = ref(false);
|
||
const typeFilter = ref<string | undefined>(undefined);
|
||
const templateDialogVisible = ref(false);
|
||
const editingTemplateId = ref<string | null>(null);
|
||
const templateFormRef = ref<FormInstance>();
|
||
const templateSaving = ref(false);
|
||
|
||
const ALL_ROLES = [
|
||
{ key: "PM", label: "项目负责人", icon: "👑", desc: "项目管理员,拥有所有权限" },
|
||
{ key: "CRA", label: "CRA", icon: "📋", desc: "数据输入和日常管理人员" },
|
||
{ key: "PV", label: "PV", icon: "🔍", desc: "访视和参与者管理人员" },
|
||
{ key: "MEDICAL_REVIEW", label: "医学审核", icon: "🏥", desc: "医学审核人员" },
|
||
{ key: "IMP", label: "药品管理员", icon: "📦", desc: "物资和设备管理人员" },
|
||
{ key: "QA", label: "QA", icon: "✅", desc: "质量检查和审计人员" },
|
||
];
|
||
|
||
const activeRolesDraft = ref<string[]>([]);
|
||
const activeRolesLoading = ref(false);
|
||
const activeRolesSaving = ref(false);
|
||
|
||
const onTemplateDrawerOpen = async () => {
|
||
await loadTemplates();
|
||
if (selectedStudyId.value) {
|
||
await Promise.all([loadActiveRoles(), ensureOperationsLoaded()]);
|
||
}
|
||
};
|
||
|
||
const loadActiveRoles = async () => {
|
||
activeRolesLoading.value = true;
|
||
try {
|
||
const res = await fetchActiveRoles(selectedStudyId.value);
|
||
activeRolesDraft.value = res.data.active_roles;
|
||
} catch {
|
||
ElMessage.error("加载生效角色失败");
|
||
} finally {
|
||
activeRolesLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const toggleActiveRole = (role: string, active: boolean) => {
|
||
if (active) {
|
||
if (!activeRolesDraft.value.includes(role)) activeRolesDraft.value.push(role);
|
||
} else {
|
||
activeRolesDraft.value = activeRolesDraft.value.filter((r) => r !== role);
|
||
}
|
||
};
|
||
|
||
const saveActiveRoles = async () => {
|
||
activeRolesSaving.value = true;
|
||
try {
|
||
await updateActiveRoles(selectedStudyId.value, activeRolesDraft.value);
|
||
ElMessage.success("生效角色已保存");
|
||
} catch {
|
||
ElMessage.error("保存失败");
|
||
} finally {
|
||
activeRolesSaving.value = false;
|
||
}
|
||
};
|
||
|
||
const templateForm = ref({
|
||
name: "", description: "", template_type: "CUSTOM" as "ROLE" | "SCENARIO" | "CUSTOM",
|
||
category: "", recommended_roles: "", tags: "",
|
||
});
|
||
const templateRules: FormRules = {
|
||
name: [{ required: true, message: "请输入模板名称", trigger: "blur" }],
|
||
template_type: [{ required: true, message: "请选择模板类型", trigger: "change" }],
|
||
};
|
||
|
||
const typeLabel = (t: string) => ({ ROLE: "角色模板", SCENARIO: "场景模板", CUSTOM: "自定义" }[t] ?? t);
|
||
const typeTagType = (t: string) =>
|
||
({ ROLE: "primary", SCENARIO: "warning", CUSTOM: "success" } as Record<string, any>)[t] ?? "info";
|
||
const countPermissions = (row: PermissionTemplate) => {
|
||
let n = 0;
|
||
for (const perms of Object.values(row.permissions) as Record<string, boolean>[])
|
||
n += Object.values(perms).filter(Boolean).length;
|
||
return n;
|
||
};
|
||
|
||
const loadTemplates = async () => {
|
||
templatesLoading.value = true;
|
||
try {
|
||
const res = await fetchPermissionTemplates(typeFilter.value ? { template_type: typeFilter.value } : undefined);
|
||
templates.value = res.data;
|
||
} catch {
|
||
ElMessage.error("加载模板失败");
|
||
} finally {
|
||
templatesLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const openCreateTemplate = () => {
|
||
editingTemplateId.value = null;
|
||
resetTemplateForm();
|
||
templateDialogVisible.value = true;
|
||
};
|
||
|
||
const openEditTemplate = (row: PermissionTemplate) => {
|
||
editingTemplateId.value = row.id;
|
||
templateForm.value = {
|
||
name: row.name, description: row.description ?? "",
|
||
template_type: row.template_type, category: row.category ?? "",
|
||
recommended_roles: row.recommended_roles ?? "", tags: row.tags ?? "",
|
||
};
|
||
templateDialogVisible.value = true;
|
||
};
|
||
|
||
const resetTemplateForm = () => {
|
||
templateForm.value = { name: "", description: "", template_type: "CUSTOM", category: "", recommended_roles: "", tags: "" };
|
||
templateFormRef.value?.clearValidate();
|
||
};
|
||
|
||
const submitTemplateForm = async () => {
|
||
const valid = await templateFormRef.value?.validate().catch(() => false);
|
||
if (!valid) return;
|
||
templateSaving.value = true;
|
||
try {
|
||
const payload = {
|
||
...templateForm.value,
|
||
description: templateForm.value.description || undefined,
|
||
category: templateForm.value.category || undefined,
|
||
recommended_roles: templateForm.value.recommended_roles || undefined,
|
||
tags: templateForm.value.tags || undefined,
|
||
permissions: editingTemplateId.value
|
||
? (templates.value.find((t) => t.id === editingTemplateId.value)?.permissions ?? {})
|
||
: {},
|
||
};
|
||
if (editingTemplateId.value) {
|
||
await updatePermissionTemplate(editingTemplateId.value, payload);
|
||
ElMessage.success("模板已更新");
|
||
} else {
|
||
await createPermissionTemplate(payload);
|
||
ElMessage.success("模板已创建");
|
||
}
|
||
templateDialogVisible.value = false;
|
||
await loadTemplates();
|
||
} catch {
|
||
ElMessage.error("保存失败");
|
||
} finally {
|
||
templateSaving.value = false;
|
||
}
|
||
};
|
||
|
||
const confirmDeleteTemplate = async (row: PermissionTemplate) => {
|
||
await ElMessageBox.confirm(`确认删除模板「${row.name}」?此操作不可撤销。`, "删除确认", {
|
||
type: "warning", confirmButtonText: "删除", confirmButtonClass: "el-button--danger",
|
||
});
|
||
try {
|
||
await deletePermissionTemplate(row.id);
|
||
ElMessage.success("已删除");
|
||
await loadTemplates();
|
||
} catch {
|
||
ElMessage.error("删除失败");
|
||
}
|
||
};
|
||
|
||
// ═══════════════════════════════════════════
|
||
// 系统级权限
|
||
// ═══════════════════════════════════════════
|
||
const systemPermissions = ref<SystemPermissionItem[]>([]);
|
||
const systemModuleLabels = ref<Record<string, string>>({});
|
||
const systemLoading = ref(false);
|
||
|
||
const systemPermissionsByModule = computed(() => {
|
||
const map: Record<string, SystemPermissionItem[]> = {};
|
||
for (const item of systemPermissions.value) {
|
||
if (!map[item.module]) map[item.module] = [];
|
||
map[item.module].push(item);
|
||
}
|
||
return map;
|
||
});
|
||
|
||
const loadSystemPermissions = async () => {
|
||
systemLoading.value = true;
|
||
try {
|
||
const res = await fetchSystemPermissions();
|
||
systemPermissions.value = res.data.permissions;
|
||
systemModuleLabels.value = res.data.module_labels;
|
||
} catch {
|
||
ElMessage.error("加载系统级权限失败");
|
||
} finally {
|
||
systemLoading.value = false;
|
||
}
|
||
};
|
||
|
||
// ═══════════════════════════════════════════
|
||
// 权限监控
|
||
// ═══════════════════════════════════════════
|
||
const metrics = ref<PermissionMetricsResponse | null>(null);
|
||
const health = ref<HealthResponse | null>(null);
|
||
const alerts = ref<AlertsResponse | null>(null);
|
||
const monitoringLoading = ref(false);
|
||
const resetting = ref(false);
|
||
|
||
const loadMonitoringData = async () => {
|
||
monitoringLoading.value = true;
|
||
try {
|
||
const [metricsRes, healthRes, alertsRes] = await Promise.all([
|
||
fetchPermissionMetrics(),
|
||
fetchPermissionHealth(),
|
||
fetchPermissionAlerts(undefined, 20),
|
||
]);
|
||
metrics.value = metricsRes.data;
|
||
health.value = healthRes.data;
|
||
alerts.value = alertsRes.data;
|
||
} catch {
|
||
ElMessage.error("加载监控数据失败");
|
||
} finally {
|
||
monitoringLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const doResetMetrics = async () => {
|
||
resetting.value = true;
|
||
try {
|
||
await resetPermissionMetrics();
|
||
await loadMonitoringData();
|
||
ElMessage.success("指标已重置");
|
||
} catch {
|
||
ElMessage.error("重置失败");
|
||
} finally {
|
||
resetting.value = false;
|
||
}
|
||
};
|
||
|
||
// ═══════════════════════════════════════════
|
||
// 角色权限编辑器(抽屉)
|
||
// ═══════════════════════════════════════════
|
||
interface RoleEditorOp {
|
||
operation_key: string;
|
||
module: string;
|
||
action: string;
|
||
description: string;
|
||
}
|
||
|
||
const editingRole = ref("");
|
||
const roleEditorSearch = ref("");
|
||
const roleEditorDraft = ref<Record<string, boolean>>({});
|
||
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: "项目总览",
|
||
fees: "合同费用", materials: "物资管理", material_equipments: "物资设备",
|
||
documents: "文件版本", startup_ethics: "立项与伦理", startup_auth: "启动与授权",
|
||
audit_export: "审计日志", faq: "FAQ", shared_library: "共享库",
|
||
attachments: "附件管理", dashboard: "仪表板", subject_histories: "参与者历史",
|
||
};
|
||
|
||
const roleEditorModuleLabel = (mod: string) => ROLE_EDITOR_MODULE_LABELS[mod] || mod;
|
||
|
||
const getOpVerb = (op: { operation_key: string; action: string }): string => {
|
||
const suffix = op.operation_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 op.action;
|
||
};
|
||
|
||
const opActionLabel = (op: { operation_key: string; action: string }) => {
|
||
const verb = getOpVerb(op);
|
||
return ({ create: "创建", read: "读取", update: "更新", delete: "删除", export: "导出", write: "写入" }[verb] ?? verb);
|
||
};
|
||
|
||
const opTagType = (op: { operation_key: string; action: string }) => {
|
||
const verb = getOpVerb(op);
|
||
return (({ create: "success", read: "info", update: "warning", delete: "danger", export: "", write: "success" } as Record<string, string>)[verb] ?? "info");
|
||
};
|
||
|
||
const roleEditorGrouped = computed(() => {
|
||
const search = roleEditorSearch.value.toLowerCase();
|
||
const filtered = allOperations.value.filter((op) =>
|
||
!search ||
|
||
op.description.toLowerCase().includes(search) ||
|
||
op.operation_key.toLowerCase().includes(search) ||
|
||
roleEditorModuleLabel(op.module).includes(search)
|
||
);
|
||
const map: Record<string, RoleEditorOp[]> = {};
|
||
for (const op of filtered) {
|
||
if (!map[op.module]) map[op.module] = [];
|
||
map[op.module].push(op);
|
||
}
|
||
return map;
|
||
});
|
||
|
||
const selectRoleForEdit = (role: string) => {
|
||
editingRole.value = role;
|
||
roleEditorSearch.value = "";
|
||
initRoleEditor();
|
||
};
|
||
|
||
const openRoleEditor = async (role: string) => {
|
||
templateDrawerTab.value = "edit-role";
|
||
templateDrawerVisible.value = true;
|
||
await ensureOperationsLoaded();
|
||
selectRoleForEdit(role);
|
||
};
|
||
|
||
const ensureOperationsLoaded = async () => {
|
||
if (allOperations.value.length) return;
|
||
roleEditorLoading.value = true;
|
||
try {
|
||
const res = await fetchApiOperations();
|
||
allOperations.value = res.data.operations;
|
||
} catch {
|
||
ElMessage.error("加载权限操作失败");
|
||
} finally {
|
||
roleEditorLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const initRoleEditor = () => {
|
||
const role = editingRole.value;
|
||
const draft: Record<string, boolean> = {};
|
||
if (apiMatrix.value && apiMatrix.value[role]) {
|
||
for (const [key, val] of Object.entries(apiMatrix.value[role] as Record<string, any>)) {
|
||
draft[key] = typeof val === "boolean" ? val : val.allowed;
|
||
}
|
||
}
|
||
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;
|
||
roleEditorSaving.value = true;
|
||
try {
|
||
const payload = flattenMatrix(apiMatrix.value);
|
||
if (!payload[editingRole.value]) payload[editingRole.value] = {};
|
||
for (const [key, val] of Object.entries(roleEditorDraft.value)) {
|
||
payload[editingRole.value][key] = val;
|
||
}
|
||
const res = await updateApiEndpointPermissions(selectedStudyId.value, payload);
|
||
apiMatrix.value = res.data;
|
||
dirty.value = false;
|
||
const savedRole = editingRole.value;
|
||
editingRole.value = "";
|
||
ElMessage.success(`${ROLE_LABELS[savedRole] || savedRole} 权限已保存`);
|
||
} catch {
|
||
ElMessage.error("保存失败");
|
||
} finally {
|
||
roleEditorSaving.value = false;
|
||
}
|
||
};
|
||
|
||
// ═══════════════════════════════════════════
|
||
// 懒加载 & 初始化
|
||
// ═══════════════════════════════════════════
|
||
watch(() => route.path, (path) => {
|
||
if (path.endsWith("/system") && !systemPermissions.value.length) loadSystemPermissions();
|
||
if (path.endsWith("/monitoring") && !metrics.value) loadMonitoringData();
|
||
});
|
||
|
||
onMounted(async () => {
|
||
await loadStudies();
|
||
await loadTemplates();
|
||
|
||
const qProjectId = route.query.projectId as string | undefined;
|
||
const qSub = route.query.sub as string | undefined;
|
||
if (qProjectId) {
|
||
selectedStudyId.value = qProjectId;
|
||
if (qSub === "members") projectSubTab.value = "members";
|
||
await Promise.all([loadPermissionData(), loadMembers(), loadActiveRoles()]);
|
||
loadCandidates();
|
||
}
|
||
|
||
const tab = tabFromPath();
|
||
if (tab === "system" && !systemPermissions.value.length) loadSystemPermissions();
|
||
if (tab === "monitoring" && !metrics.value) loadMonitoringData();
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* ── 页面容器:铺满,无 padding ── */
|
||
.perm-page {
|
||
display: flex;
|
||
flex-direction: column;
|
||
margin: -6px -8px;
|
||
min-height: calc(100vh - 52px);
|
||
min-height: calc(100dvh - 52px);
|
||
background: #f5f7fa;
|
||
}
|
||
|
||
/* ── 顶部标题栏 ── */
|
||
.perm-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 20px 28px 16px;
|
||
background: #fff;
|
||
border-bottom: 1px solid #e8edf3;
|
||
flex-shrink: 0;
|
||
gap: 16px;
|
||
}
|
||
|
||
.perm-header-left {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 3px;
|
||
}
|
||
|
||
.perm-title {
|
||
margin: 0;
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: #1a1f2e;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.perm-subtitle {
|
||
font-size: 13px;
|
||
color: #8c96a8;
|
||
}
|
||
|
||
.perm-header-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* ── 内容区:可滚动 ── */
|
||
.perm-body {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 20px 0 20px;
|
||
}
|
||
|
||
.perm-empty {
|
||
padding: 80px 28px;
|
||
display: flex;
|
||
justify-content: center;
|
||
}
|
||
|
||
.perm-body-padded {
|
||
padding: 0 28px;
|
||
}
|
||
|
||
/* ── 项目权限:子 tabs ── */
|
||
.project-sub-tabs {
|
||
background: #fff;
|
||
border-radius: 0;
|
||
padding: 0 28px 24px;
|
||
}
|
||
|
||
:deep(.project-sub-tabs .el-tabs__header) {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.sub-tab-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.sub-tab-spacer { flex: 1; }
|
||
|
||
.full-table { width: 100%; }
|
||
|
||
/* ── 系统级权限:模块块 ── */
|
||
.system-module-block {
|
||
background: #fff;
|
||
border-radius: 0;
|
||
padding: 20px 28px;
|
||
margin-bottom: 12px;
|
||
border-bottom: 1px solid #eef0f4;
|
||
}
|
||
|
||
.system-module-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.system-module-title {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: #1a1f2e;
|
||
}
|
||
|
||
.system-perm-table { width: 100%; }
|
||
|
||
.perm-key {
|
||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||
font-size: 12px;
|
||
background: #f0f4f8;
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
color: #4a5568;
|
||
}
|
||
|
||
/* ── 抽屉内部 ── */
|
||
.filter-spacer { flex: 1; }
|
||
|
||
.drawer-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.drawer-tabs { padding-top: 4px; }
|
||
|
||
.drawer-footer {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
padding-top: 16px;
|
||
margin-top: 16px;
|
||
border-top: 1px solid #edf2f8;
|
||
}
|
||
|
||
/* ── 生效角色列表 ── */
|
||
.active-roles-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.active-role-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 12px 16px;
|
||
border-radius: 6px;
|
||
border: 1px solid #edf2f8;
|
||
background: #fff;
|
||
}
|
||
|
||
.active-role-row:hover { background: #f5f7fa; }
|
||
|
||
.active-role-info {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.active-role-icon { font-size: 20px; }
|
||
|
||
.active-role-name {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #303133;
|
||
}
|
||
|
||
.active-role-desc {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.template-name { font-weight: 500; }
|
||
|
||
/* ── 角色编辑器 ── */
|
||
.role-editor-layout {
|
||
display: flex;
|
||
height: calc(100vh - 200px);
|
||
gap: 0;
|
||
}
|
||
|
||
.role-editor-sidebar {
|
||
width: 160px;
|
||
flex-shrink: 0;
|
||
border-right: 1px solid #edf2f8;
|
||
overflow-y: auto;
|
||
padding: 4px 0;
|
||
}
|
||
|
||
.role-sidebar-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 10px 12px;
|
||
cursor: pointer;
|
||
border-radius: 6px;
|
||
margin: 2px 4px;
|
||
transition: background 0.15s;
|
||
}
|
||
|
||
.role-sidebar-item:hover { background: #f5f7fa; }
|
||
|
||
.role-sidebar-item.active {
|
||
background: #ecf5ff;
|
||
color: #409eff;
|
||
}
|
||
|
||
.role-sidebar-icon { font-size: 18px; flex-shrink: 0; }
|
||
|
||
.role-sidebar-info { flex: 1; min-width: 0; }
|
||
|
||
.role-sidebar-name {
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: #303133;
|
||
}
|
||
|
||
.role-sidebar-item.active .role-sidebar-name { color: #409eff; }
|
||
|
||
.role-sidebar-count {
|
||
font-size: 11px;
|
||
color: #67c23a;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.role-sidebar-arrow {
|
||
font-size: 12px;
|
||
color: #409eff;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.role-editor-content {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 0 16px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.role-editor-modules { flex: 1; }
|
||
|
||
.role-editor-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.role-editor-module { margin-bottom: 20px; }
|
||
|
||
.role-editor-module-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #303133;
|
||
margin-bottom: 8px;
|
||
padding-bottom: 6px;
|
||
border-bottom: 1px solid #edf2f8;
|
||
}
|
||
|
||
.role-editor-ops {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.role-editor-op-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 4px 0;
|
||
}
|
||
|
||
.op-action-tag {
|
||
flex-shrink: 0;
|
||
width: 42px;
|
||
text-align: center;
|
||
}
|
||
|
||
.op-desc {
|
||
font-size: 13px;
|
||
color: #303133;
|
||
}
|
||
</style>
|