Files
ctms/frontend/src/views/admin/PermissionManagement.vue
T
Cheng Zhou 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
release(main): 同步 dev 最新候选改动
2026-07-16 17:15:50 +08:00

3279 lines
102 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>
</div>
<div class="perm-header-actions">
<div class="project-selector">
<svg class="project-selector-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
<el-select
v-model="selectedStudyId"
placeholder="选择项目"
filterable
popper-class="project-selector-dropdown"
style="width: 240px"
@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>
<div class="perm-body">
<div v-if="!selectedStudyId" class="perm-empty">
<div class="empty-state">
<div class="empty-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
</div>
<h3>选择项目</h3>
<p>选择项目后即可管理角色权限策略与项目成员</p>
</div>
</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-info">
<span class="sub-tab-project-name" v-if="selectedStudy">{{ selectedStudy.code }} · {{ selectedStudy.name }}</span>
</div>
<div class="sub-tab-actions">
<el-radio-group v-model="permissionView" size="small" class="permission-view-switch">
<el-radio-button value="roles">角色配置</el-radio-button>
<el-radio-button value="matrix">权限对比</el-radio-button>
</el-radio-group>
<el-button @click="templateDrawerVisible = true">
<el-icon><Files /></el-icon>
角色与权限
</el-button>
</div>
</div>
<PermissionTemplateSelector
v-if="permissionView === 'roles'"
:study-id="selectedStudyId"
:current-permissions="currentPermissionsForTemplate"
:active-roles="activeRolesInStudy"
:refresh-key="templateRefreshKey"
@edit-role="openRoleEditor"
/>
<ApiEndpointPermissions
v-else
:project="selectedStudy"
:matrix="apiMatrix"
/>
</el-tab-pane>
<el-tab-pane label="项目成员" name="members">
<div class="sub-tab-toolbar">
<div class="sub-tab-info">
<span class="sub-tab-project-name" v-if="selectedStudy">{{ selectedStudy.code }} · {{ selectedStudy.name }}</span>
<el-tag v-if="memberRows.length" effect="plain" size="small" round>{{ memberRows.length }} 名成员</el-tag>
<el-tag v-for="item in memberRoleSummary" :key="item.role" effect="plain" size="small" type="info" round>
{{ roleLabel(item.role) }} {{ item.count }}
</el-tag>
</div>
<div class="sub-tab-actions">
<el-button v-if="canAddProjectMember" type="primary" @click="openAddMember">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 14px; height: 14px; margin-right: 4px"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>
添加成员
</el-button>
</div>
</div>
<div class="members-filter-bar">
<div class="members-filter-controls">
<el-input
v-model="memberSearch"
clearable
placeholder="搜索姓名或邮箱"
:prefix-icon="Search"
style="width: 240px"
/>
<el-select v-model="memberRoleFilter" style="width: 150px">
<el-option label="全部项目角色" value="" />
<el-option v-for="role in memberFilterRoles" :key="role" :label="roleLabel(role)" :value="role" />
</el-select>
<el-select v-model="memberStatusFilter" style="width: 140px">
<el-option label="全部状态" value="" />
<el-option label="有效" value="ACTIVE" />
<el-option label="项目内停用" value="DISABLED" />
<el-option label="账号已停用" value="DISABLED_GLOBAL" />
</el-select>
<el-button :disabled="!hasMemberFilters" @click="resetMemberFilters">重置</el-button>
</div>
<div class="members-filter-meta">
<span class="members-filter-result">显示 {{ filteredMemberRows.length }} / {{ memberRows.length }} 名成员</span>
<span v-if="lastMemberRoleChange" class="member-undo">
已调整 {{ lastMemberRoleChange.fullName }} 的角色
<el-button link type="primary" size="small" @click="undoMemberRoleChange">撤销</el-button>
</span>
</div>
</div>
<div class="members-table-wrapper">
<el-table :data="filteredMemberRows" v-loading="membersLoading" class="members-table" empty-text="未找到匹配的项目成员">
<el-table-column prop="full_name" label="姓名" min-width="140">
<template #default="{ row }">
<div class="member-name-cell">
<span class="member-name">{{ row.full_name }}</span>
<span v-if="row.email" class="member-email">{{ row.email }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="项目角色" min-width="140">
<template #default="{ row }">
<el-select
:model-value="row.role_in_study"
size="small"
style="width: 130px"
@change="(val: string) => confirmMemberRoleChange(row, val)"
:disabled="!canUpdateProjectMember(row) || !row.is_active || row.effectiveStatus === 'DISABLED_GLOBAL'"
>
<el-option
v-for="(label, val) in memberRoleLabels(row)"
:key="val"
:label="label"
:value="val"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-tooltip v-if="row.effectiveStatus === 'DISABLED_GLOBAL'" content="请前往账号管理启用该账号" placement="top">
<el-tag type="danger" effect="plain" round size="small">账号已停用</el-tag>
</el-tooltip>
<el-tag v-else :type="row.is_active ? 'success' : 'warning'" effect="plain" round size="small">
{{ row.is_active ? "有效" : "项目内停用" }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="added_at" label="加入时间" min-width="160">
<template #default="{ row }">
<span class="time-text">{{ displayDateTime(row.added_at) }}</span>
</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="!canUpdateProjectMember(row) || row.effectiveStatus === 'DISABLED_GLOBAL'"
@click="toggleMemberActive(row)"
>
{{ row.is_active ? "停用" : "启用" }}
</el-button>
<el-button link type="danger" size="small" :disabled="!canDeleteProjectMember(row)" @click="deleteMember(row)">
移除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
</el-tabs>
</template>
</div>
</template>
<!-- ══════════════════════════════════════
系统级权限
══════════════════════════════════════ -->
<template v-else-if="activeTab === 'system'">
<div class="perm-body perm-body--system">
<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>
<div v-else class="system-perm-container">
<div class="system-stats" aria-label="系统级权限概览">
<div class="system-stat-item system-stat-item--modules">
<span class="system-stat-label">权限模块</span>
<div class="system-stat-value">
<strong>{{ Object.keys(systemPermissionsByModule).length }}</strong>
<span></span>
</div>
</div>
<div class="system-stat-item system-stat-item--permissions">
<span class="system-stat-label">权限总数</span>
<div class="system-stat-value">
<strong>{{ systemPermissions.length }}</strong>
<span></span>
</div>
</div>
<div class="system-stat-item system-stat-item--pm">
<span class="system-stat-label">PM 可访问</span>
<div class="system-stat-value">
<strong>{{ systemPermissions.filter(p => p.roles.includes('PM')).length }}</strong>
<span></span>
</div>
</div>
</div>
<div class="system-permissions-card">
<section v-for="(items, moduleKey) in systemPermissionsByModule" :key="moduleKey" class="system-module-block">
<div class="system-module-header">
<div class="system-module-icon" :class="`module-icon--${moduleKey}`">
<svg v-if="moduleKey === 'system_users'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>
<svg v-else-if="moduleKey === 'system_projects'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
<svg v-else-if="moduleKey === 'system_permissions'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
<svg v-else-if="moduleKey === 'system_monitoring'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
<svg v-else-if="moduleKey === 'system_audit'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
</div>
<div class="system-module-info">
<span class="system-module-title">{{ systemModuleLabels[moduleKey] || moduleKey }}</span>
<span class="system-module-count">{{ items.length }} 项操作</span>
</div>
<div class="system-module-roles-summary">
<el-tag v-if="items.some(i => i.roles.includes('PM'))" size="small" type="warning" effect="plain" round>PM 可访问</el-tag>
<el-tag v-else size="small" type="info" effect="plain" round>仅管理员</el-tag>
</div>
</div>
<div class="system-perm-list">
<div class="system-perm-columns" aria-hidden="true">
<span>权限操作</span>
<span>权限标识</span>
<span>可访问角色</span>
</div>
<div v-for="row in items" :key="row.permission_key" class="system-perm-row">
<div class="system-perm-main">
<el-tag :type="opTagType({ operation_key: row.permission_key, action: row.action })" effect="plain" size="small" class="system-perm-action">
{{ opActionLabel({ operation_key: row.permission_key, action: row.action }) }}
</el-tag>
<span class="system-perm-desc" :title="row.description">{{ row.description }}</span>
</div>
<code class="perm-key" :title="row.permission_key">{{ row.permission_key }}</code>
<div class="system-perm-roles">
<el-tag
v-for="r in row.roles"
:key="r"
size="small"
:effect="r === 'ADMIN' ? 'dark' : 'plain'"
:type="r === 'ADMIN' ? '' : 'warning'"
round
class="role-chip"
>{{ roleLabel(r) }}</el-tag>
</div>
</div>
</div>
</section>
</div>
</div>
</div>
</template>
<!-- ══════════════════════════════════════
角色管理抽屉
══════════════════════════════════════ -->
<el-drawer
v-model="templateDrawerVisible"
title="角色与权限"
size="720px"
direction="rtl"
:close-on-click-modal="true"
:before-close="handleTemplateDrawerBeforeClose"
@open="onTemplateDrawerOpen"
>
<div class="drawer-tabs-shell">
<el-tabs v-model="templateDrawerTab" class="drawer-tabs">
<!-- 标签页角色列表 -->
<el-tab-pane label="角色定义" name="list">
<el-table :data="sortedTemplates" 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 v-if="isAdmin" label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button v-if="isAdmin" link type="primary" @click="openEditTemplate(row)">编辑</el-button>
<el-button v-if="isAdmin" link type="danger" :disabled="row.is_system" @click="confirmDeleteTemplate(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</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 v-if="roleActiveStatusLabel(role.key)" class="active-role-meta">
{{ roleActiveStatusLabel(role.key) }}
</div>
</div>
</div>
<el-tooltip
:disabled="!roleActiveDisableReason(role.key)"
:content="roleActiveDisableReason(role.key)"
placement="top"
>
<span>
<el-switch
:model-value="activeRolesDraft.includes(role.key)"
:disabled="!canToggleActiveRole(role.key)"
@change="(v: boolean) => toggleActiveRole(role.key, v)"
/>
</span>
</el-tooltip>
</div>
</div>
</el-tab-pane>
<!-- 标签页编辑权限 -->
<el-tab-pane label="权限配置" name="edit-role" :disabled="!selectedStudyId">
<div class="role-editor-layout" v-loading="roleEditorLoading">
<!-- 左侧角色列表 -->
<div class="role-editor-sidebar">
<button
v-for="role in roleOptions"
:key="role.key"
type="button"
class="role-sidebar-item"
:class="{ active: editingRole === role.key }"
:aria-pressed="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>
</button>
</div>
<!-- 右侧权限编辑区 -->
<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>
<el-select v-model="roleEditorScope" class="role-editor-scope" style="width: 140px">
<el-option label="全部权限" value="all" />
<el-option label="仅已授权" value="granted" />
<el-option label="仅本次变更" value="changed" />
</el-select>
<div class="role-editor-change-summary" :class="{ 'role-editor-change-summary--dirty': roleEditorChangeCount > 0 }">
<template v-if="roleEditorChangeCount">
<span>新增授权 {{ roleEditorGrantedCount }} </span>
<span>取消授权 {{ roleEditorRevokedCount }} </span>
</template>
<span v-else>暂无变更</span>
</div>
</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">
<div class="role-editor-module-heading">
<span>{{ roleEditorModuleLabel(mod) }}</span>
<span>{{ roleEditorEnabledCount(roleEditorModuleOps(sections)) }} / {{ roleEditorModuleCount(sections) }} 已授权</span>
</div>
<div v-if="canEditSelectedRole" class="role-editor-module-actions">
<el-button link type="primary" size="small" @click="setRoleEditorPermissions(roleEditorModuleOps(sections), true)">全部授权</el-button>
<el-button link size="small" @click="setRoleEditorPermissions(roleEditorModuleOps(sections), false)">全部取消</el-button>
</div>
</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>
<span v-if="canEditSelectedRole" class="role-editor-section-actions" @click.stop>
<el-button link type="primary" size="small" @click="setRoleEditorPermissions(section.ops, true)">全选</el-button>
<el-button link size="small" @click="setRoleEditorPermissions(section.ops, false)">清空</el-button>
</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) => handleRolePermissionChange(op, 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>
<el-empty v-if="Object.keys(roleEditorSectionGroups).length === 0" description="没有符合当前筛选条件的权限" />
</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' && isAdmin" type="primary" @click="openCreateTemplate">创建角色</el-button>
<el-button v-else-if="templateDrawerTab === 'active'" type="primary" :loading="activeRolesSaving" @click="saveActiveRoles">
保存
</el-button>
<template v-else-if="templateDrawerTab === 'edit-role' && editingRole">
<el-button @click="cancelRoleEditor">取消</el-button>
<el-button
type="primary"
:loading="roleEditorSaving"
:disabled="!canEditSelectedRole || !roleEditorDirtyGuard.isDirty.value"
@click="saveRoleEditor"
>
保存 {{ roleLabel(editingRole) }} 权限
</el-button>
</template>
</div>
</div>
</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="template_type">
<el-select v-model="templateForm.template_type" style="width: 100%" :disabled="Boolean(editingTemplateId)">
<el-option label="预设角色" value="ROLE" :disabled="!editingTemplateId" />
<el-option label="自定义角色" value="CUSTOM" :disabled="Boolean(editingTemplateId)" />
</el-select>
</el-form-item>
<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>
<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" width="480px" class="member-form-dialog" :show-close="false" @close="resetAddMemberForm">
<div class="member-form-panel">
<div class="member-form-panel__header">
<h2>新增成员</h2>
<button type="button" class="member-form-panel__close" aria-label="关闭" @click="addMemberVisible = false">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 6l12 12M18 6L6 18" /></svg>
</button>
</div>
<div class="member-form-panel__body">
<el-form :model="addMemberForm" :rules="addMemberRules" ref="addMemberFormRef" label-width="80px">
<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 assignableRoleLabels" :key="val" :label="label" :value="val" />
</el-select>
</el-form-item>
</el-form>
</div>
<div class="member-form-panel__footer">
<el-button @click="addMemberVisible = false">取消</el-button>
<el-button type="primary" :loading="addMemberSaving" @click="submitAddMember">添加</el-button>
</div>
</div>
</el-dialog>
<!-- 权限校验 Dialog -->
<el-dialog
v-model="prereqCheckVisible"
width="520px"
:close-on-click-modal="false"
:show-close="prereqCheckStep === 'failed'"
class="prereq-check-dialog"
title="权限依赖校验"
align-center
>
<!-- 校验步骤动画 -->
<div class="prereq-check-body">
<div class="prereq-check-steps">
<div
v-for="(item, i) in prereqCheckItems"
:key="i"
class="prereq-check-step"
:class="`prereq-check-step--${item.status}`"
>
<span class="prereq-step-icon">
<svg v-if="item.status === 'ok'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>
<svg v-else-if="item.status === 'fail'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
<svg v-else-if="item.status === 'warn'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span v-else class="prereq-step-spinner" />
</span>
<span class="prereq-step-label">{{ item.label }}</span>
</div>
</div>
<!-- 通过状态 -->
<transition name="prereq-result">
<div v-if="prereqCheckStep === 'passed'" class="prereq-result prereq-result--pass">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
<span>所有权限依赖校验通过</span>
</div>
</transition>
<!-- 失败缺少前置权限 -->
<transition name="prereq-result">
<div v-if="prereqCheckStep === 'failed'" class="prereq-issues">
<div class="prereq-issues-header">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<span>发现 {{ prereqMissingItems.length + prereqRemoveItems.length }} 项依赖冲突需要处理后才能保存</span>
</div>
<!-- 缺少前置权限 -->
<div v-if="prereqMissingItems.length" class="prereq-section">
<div class="prereq-section-title">
<span class="prereq-section-badge prereq-section-badge--add">需补充前置权限</span>
<el-button size="small" type="primary" @click="grantAllPrereqs" class="prereq-grant-all-btn">
一键授权全部前置
</el-button>
</div>
<div class="prereq-item-list">
<div v-for="(item, i) in prereqMissingItems" :key="i" class="prereq-item prereq-item--missing">
<div class="prereq-item-info">
<div class="prereq-item-row">
<span class="prereq-item-chip prereq-item-chip--enabled">已启用</span>
<span class="prereq-item-name">{{ rolePermissionDisplayLabel(item.dependentKey) }}</span>
</div>
<div class="prereq-item-arrow">依赖</div>
<div class="prereq-item-row">
<span class="prereq-item-chip prereq-item-chip--missing">缺少</span>
<span class="prereq-item-name">{{ rolePermissionDisplayLabel(item.prereqKey) }}</span>
</div>
</div>
<el-button
size="small"
type="success"
:disabled="roleEditorDraft[item.prereqKey] === true"
@click="grantSinglePrereq(item.prereqKey)"
>授权</el-button>
</div>
</div>
</div>
<!-- 需取消的下游权限 -->
<div v-if="prereqRemoveItems.length" class="prereq-section">
<div class="prereq-section-title">
<span class="prereq-section-badge prereq-section-badge--remove">受影响的下游权限</span>
</div>
<div class="prereq-item-list">
<div v-for="(item, i) in prereqRemoveItems" :key="i" class="prereq-item prereq-item--remove">
<div class="prereq-item-info">
<div class="prereq-item-row">
<span class="prereq-item-chip prereq-item-chip--disabled">已禁用</span>
<span class="prereq-item-name">{{ rolePermissionDisplayLabel(item.prereqKey) }}</span>
</div>
<div class="prereq-item-arrow">影响</div>
<div class="prereq-item-row">
<span class="prereq-item-chip prereq-item-chip--will-remove">将同步取消</span>
<span class="prereq-item-name">{{ rolePermissionDisplayLabel(item.dependentKey) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</transition>
</div>
<template #footer>
<div v-if="prereqCheckStep === 'failed'" class="prereq-dialog-footer">
<el-button @click="prereqCheckVisible = false">取消保存</el-button>
<el-button
v-if="prereqRemoveItems.length && !prereqMissingItems.length"
type="primary"
:loading="prereqSaving"
@click="confirmPrereqAndSave"
>确认调整并保存</el-button>
<el-button
v-if="prereqMissingItems.length === 0 && prereqRemoveItems.length === 0"
type="primary"
:loading="prereqSaving"
@click="confirmPrereqAndSave"
>已处理保存</el-button>
</div>
</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 { Files, Search, ArrowRight } from "@element-plus/icons-vue";
import type { FormInstance, FormRules } from "element-plus";
import type {
ApiEndpointPermissionsResponse,
Study,
StudyMember,
UserInfo,
} from "@/types/api";
import {
fetchApiEndpointPermissions,
updateApiEndpointPermissions,
fetchApiOperations,
fetchPermissionTemplates,
createPermissionTemplate,
updatePermissionTemplate,
deletePermissionTemplate,
fetchSystemPermissions,
fetchActiveRoles,
updateActiveRoles,
type PermissionTemplate,
type PermissionTemplateCreate,
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 { isApiPermissionAllowed } from "@/utils/apiPermissionValue";
import { isSystemAdmin } from "@/utils/roles";
import { useDrawerDirtyGuard } from "@/utils/drawerDirtyGuard";
import {
compareProjectPermissionModules,
projectPermissionModuleLabel,
} from "@/utils/projectPermissionModules";
import { useRoleTemplateMeta } from "@/composables/useRoleTemplateMeta";
import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue";
import PermissionTemplateSelector from "@/components/PermissionTemplateSelector.vue";
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const isAdmin = computed(() => isSystemAdmin(auth.user));
const selectedProjectRole = computed(() => selectedStudy.value?.role_in_study || "");
const isSelectedProjectPm = computed(() => selectedProjectRole.value === "PM");
const canManageSelectedProject = computed(() => isAdmin.value || isSelectedProjectPm.value);
const selectedProjectPermissionAllowed = (operationKey: string) => {
if (isAdmin.value) return true;
const role = selectedProjectRole.value;
if (!role || !apiMatrix.value) return false;
return isApiPermissionAllowed(apiMatrix.value?.[role]?.[operationKey]);
};
const canListProjectMembers = computed(() => selectedProjectPermissionAllowed("project_members:read"));
const canListProjectMemberCandidates = computed(() => selectedProjectPermissionAllowed("project_members:read"));
const canCreateProjectMember = computed(() => selectedProjectPermissionAllowed("project_members:create"));
const canUpdateProjectMembers = computed(() => selectedProjectPermissionAllowed("project_members:update"));
const canDeleteProjectMembers = computed(() => selectedProjectPermissionAllowed("project_members:delete"));
const canAddProjectMember = computed(() => canCreateProjectMember.value && canListProjectMemberCandidates.value);
const { roleLabel, roleDescription, roleIcon, compareRolesByTemplateOrder, loadRoleTemplates } = useRoleTemplateMeta();
const ROLE_RANK: Record<string, number> = {
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;
const assignableRoleLabels = computed(() => {
const result: Record<string, string> = {};
for (const role of activeRolesInStudy.value) {
if (canAssignProjectRole(role)) result[role] = roleLabel(role);
}
return result;
});
const memberRoleLabels = (row: MemberRoleRow) => {
if (canUpdateProjectMember(row)) return assignableRoleLabels.value;
return {
...assignableRoleLabels.value,
[row.role_in_study]: roleLabel(row.role_in_study),
};
};
// ── 顶层标签 ──
const tabFromPath = (): "project" | "system" => {
const p = route.path;
if (p.endsWith("/system")) return "system";
return "project";
};
const activeTab = computed({
get: () => tabFromPath(),
set: (tab: "project" | "system") => {
const pathMap = { system: "/admin/permissions/system", project: "/admin/permissions/project" };
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 permissionView = ref<"roles" | "matrix">("roles");
// 接口级权限
const apiMatrix = ref<ApiEndpointPermissionsResponse | null>(null);
const currentPermissionsForTemplate = computed(() => {
if (!apiMatrix.value) return undefined;
const result: Record<string, Record<string, boolean>> = {};
for (const [role, endpoints] of Object.entries(apiMatrix.value)) {
result[role] = {};
for (const [key, val] of Object.entries(endpoints)) result[role][key] = isApiPermissionAllowed(val);
}
return result;
});
const loadStudies = async () => {
try {
const res = await fetchStudies();
const items = res.data.items ?? [];
studies.value = isAdmin.value ? items : items.filter((item: Study) => item.role_in_study === "PM");
} catch {
ElMessage.error("加载项目列表失败");
}
};
const loadPermissionData = async () => {
if (!selectedStudyId.value) return;
pageLoading.value = true;
try {
const res = await fetchApiEndpointPermissions(selectedStudyId.value);
apiMatrix.value = res.data;
} catch {
ElMessage.error("加载权限数据失败");
} finally {
pageLoading.value = false;
}
};
const onStudyChange = async (id: string) => {
const study = studies.value.find((s) => s.id === id);
if (!isAdmin.value && study?.role_in_study !== "PM") {
ElMessage.error("仅项目 PM 可配置该项目");
selectedStudyId.value = "";
return;
}
apiMatrix.value = null;
members.value = [];
lastMemberRoleChange.value = null;
candidates.value = [];
activeRolesDraft.value = [];
router.replace({ query: { ...route.query, projectId: id } });
await loadPermissionData();
await Promise.all([loadMembers(), loadActiveRoles()]);
loadCandidates();
};
const activeRolesInStudy = computed(() => activeRolesDraft.value);
const flattenRolePermissions = (
role: string,
permissions: Record<string, boolean>
): Record<string, Record<string, boolean>> => ({
[role]: { ...permissions },
});
// 成员管理
const members = ref<StudyMember[]>([]);
const candidates = ref<UserInfo[]>([]);
const membersLoading = ref(false);
const lastMemberRoleChange = ref<{ memberId: string; fullName: string; previousRole: string; currentRole: string } | null>(null);
const memberSearch = ref("");
const memberRoleFilter = ref("");
const memberStatusFilter = ref("");
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 canMutateProjectMember = (row: MemberRoleRow) => {
if (row.user_id === auth.user?.id) return false;
if (row.user?.is_admin) return false;
if (!isAdmin.value && (ROLE_RANK[row.role_in_study] ?? 0) >= ROLE_RANK.PM) return false;
return true;
};
const canUpdateProjectMember = (row: MemberRoleRow) => canUpdateProjectMembers.value && canMutateProjectMember(row);
const canDeleteProjectMember = (row: MemberRoleRow) => canDeleteProjectMembers.value && canMutateProjectMember(row);
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,
email: (user as any)?.email || "",
effectiveStatus,
};
})
);
const memberFilterRoles = computed(() =>
[...new Set(memberRows.value.map((row) => row.role_in_study).filter(Boolean))].sort(compareRolesByTemplateOrder)
);
const memberRoleSummary = computed(() => memberFilterRoles.value.map((role) => ({
role,
count: memberRows.value.filter((row) => row.role_in_study === role).length,
})));
const filteredMemberRows = computed(() => {
const search = memberSearch.value.trim().toLowerCase();
return memberRows.value.filter((row) => {
const matchesSearch = !search || row.full_name.toLowerCase().includes(search) || row.email.toLowerCase().includes(search);
const matchesRole = !memberRoleFilter.value || row.role_in_study === memberRoleFilter.value;
const matchesStatus = !memberStatusFilter.value || row.effectiveStatus === memberStatusFilter.value;
return matchesSearch && matchesRole && matchesStatus;
});
});
const hasMemberFilters = computed(() => Boolean(memberSearch.value || memberRoleFilter.value || memberStatusFilter.value));
const resetMemberFilters = () => {
memberSearch.value = "";
memberRoleFilter.value = "";
memberStatusFilter.value = "";
};
const availableCandidates = computed(() => {
const ids = new Set(members.value.map((m) => m.user_id));
return candidates.value.filter((u) => !u.is_admin && !ids.has(u.id));
});
const loadMembers = async () => {
if (!canListProjectMembers.value || !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 (!canListProjectMemberCandidates.value || !selectedStudyId.value) {
candidates.value = [];
return;
}
try {
const { data } = await listMemberCandidates(selectedStudyId.value, { limit: 500 });
candidates.value = data ?? [];
} catch {
candidates.value = [];
}
};
const openAddMember = () => {
if (!canAddProjectMember.value) return;
addMemberForm.user_id = "";
addMemberForm.role_in_study = Object.keys(assignableRoleLabels.value)[0] || "";
addMemberVisible.value = true;
};
const resetAddMemberForm = () => { addMemberFormRef.value?.clearValidate(); };
const submitAddMember = async () => {
if (!canAddProjectMember.value) {
ElMessage.warning("无权限添加成员");
return;
}
const valid = await addMemberFormRef.value?.validate().catch(() => false);
if (!valid) return;
if (!availableCandidates.value.some((candidate) => candidate.id === addMemberForm.user_id)) {
ElMessage.warning("请选择可添加的项目成员");
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 (row: any, role: string, { trackChange = true }: { trackChange?: boolean } = {}) => {
const member = members.value.find((item) => item.id === row.id);
if (!member || !canUpdateProjectMember(member)) {
ElMessage.warning("无权限更新成员");
loadMembers();
return false;
}
const previousRole = member.role_in_study;
try {
await updateMember(selectedStudyId.value, member.id, { role_in_study: role });
member.role_in_study = role;
members.value = [...members.value];
if (trackChange && previousRole !== role) {
lastMemberRoleChange.value = {
memberId: member.id,
fullName: row.full_name || member.user_id,
previousRole,
currentRole: role,
};
}
ElMessage.success("项目角色已更新");
return true;
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "更新失败");
loadMembers();
return false;
}
};
const confirmMemberRoleChange = async (row: any, role: string) => {
if (!role || role === row.role_in_study) return;
const confirmed = await ElMessageBox.confirm(
`确认将 ${row.full_name} 的项目角色由“${roleLabel(row.role_in_study)}”调整为“${roleLabel(role)}”?`,
"调整项目角色",
{ type: "warning", confirmButtonText: "确认调整", cancelButtonText: "取消" }
).catch(() => false);
if (!confirmed) {
members.value = [...members.value];
return;
}
await updateMemberRole(row, role);
};
const undoMemberRoleChange = async () => {
const change = lastMemberRoleChange.value;
if (!change) return;
const row = memberRows.value.find((item) => item.id === change.memberId);
if (!row) {
lastMemberRoleChange.value = null;
return;
}
const restored = await updateMemberRole(row, change.previousRole, { trackChange: false });
if (restored) lastMemberRoleChange.value = null;
};
const toggleMemberActive = async (row: any) => {
if (!canUpdateProjectMember(row)) {
ElMessage.warning("无权限更新成员");
return;
}
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) => {
if (!canDeleteProjectMember(row)) {
ElMessage.warning("无权限移除成员");
return;
}
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 templateRefreshKey = ref(0);
const templateDialogVisible = ref(false);
const editingTemplateId = ref<string | null>(null);
const templateFormRef = ref<FormInstance>();
const templateSaving = ref(false);
const activeRolesDraft = ref<string[]>([]);
const activeRolesLoading = ref(false);
const activeRolesSaving = ref(false);
const activeRolesDirtyGuard = useDrawerDirtyGuard(() => activeRolesDraft.value);
const sortedTemplates = computed(() =>
[...templates.value].sort((a, b) => {
if (a.category && b.category) return compareRolesByTemplateOrder(a.category, b.category);
if (a.category) return -1;
if (b.category) return 1;
return 0;
})
);
const roleOptions = computed(() => {
const options = templates.value.filter((template) => template.category).map((template) => ({
key: template.category!,
label: template.name,
icon: roleIcon(template.category),
desc: template.description || roleDescription(template.category),
}));
const known = new Set(options.map((role) => role.key));
for (const role of activeRolesDraft.value) {
if (!known.has(role)) {
options.push({ key: role, label: roleLabel(role), icon: roleIcon(role), desc: roleDescription(role) });
known.add(role);
}
}
options.sort((a, b) => compareRolesByTemplateOrder(a.key, b.key));
return options;
});
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 = protectPmActiveRole([...res.data.active_roles]);
activeRolesDirtyGuard.syncBaseline();
} catch {
ElMessage.error("加载生效角色失败");
} finally {
activeRolesLoading.value = false;
}
};
const activeRoleMemberCounts = computed(() => {
const counts: Record<string, number> = {};
for (const member of members.value) {
if (!member.is_active) continue;
const role = member.role_in_study;
if (!role) continue;
counts[role] = (counts[role] ?? 0) + 1;
}
return counts;
});
const activeRoleMemberCount = (role: string) => activeRoleMemberCounts.value[role] ?? 0;
const isActiveRoleInUse = (role: string) => activeRolesDraft.value.includes(role) && activeRoleMemberCount(role) > 0;
const canToggleActiveRole = (role: string) => {
if (!isAdmin.value && role === "PM") return false;
if (isActiveRoleInUse(role)) return false;
return true;
};
const roleActiveStatusLabel = (role: string) => {
if (role === "PM" && !isAdmin.value) return "项目负责人必需";
const count = activeRoleMemberCount(role);
return count > 0 ? `${count} 名成员使用中` : "";
};
const roleActiveDisableReason = (role: string) => {
if (role === "PM" && !isAdmin.value) return "PM 为项目必需角色,项目 PM 不可停用";
if (isActiveRoleInUse(role)) return "该角色仍有关联成员,需先调整成员角色后才能停用";
return "";
};
const protectPmActiveRole = (roles: string[]) => {
if (!isAdmin.value && !roles.includes("PM")) roles.unshift("PM");
return roles;
};
const toggleActiveRole = (role: string, active: boolean) => {
if (!canToggleActiveRole(role)) return;
if (active) {
if (!activeRolesDraft.value.includes(role)) activeRolesDraft.value.push(role);
} else {
activeRolesDraft.value = activeRolesDraft.value.filter((r) => r !== role);
}
};
const saveActiveRoles = async () => {
if (!canManageSelectedProject.value) return;
activeRolesSaving.value = true;
try {
const roles = protectPmActiveRole([...activeRolesDraft.value]);
await updateActiveRoles(selectedStudyId.value, roles);
activeRolesDraft.value = roles;
activeRolesDirtyGuard.syncBaseline();
await loadPermissionData();
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 roleDisplayName = (row: PermissionTemplate) => row.name;
const countEnabledPermissions = (permissions: Record<string, boolean>) =>
Object.values(permissions).filter(Boolean).length;
const countPermissions = (row: PermissionTemplate) => {
const role = row.category;
const currentRolePermissions = role ? currentPermissionsForTemplate.value?.[role] : undefined;
if (currentRolePermissions) return countEnabledPermissions(currentRolePermissions);
let n = 0;
for (const perms of Object.values(row.permissions) as Record<string, boolean>[])
n += countEnabledPermissions(perms);
return n;
};
const loadTemplates = async () => {
templatesLoading.value = true;
try {
const res = await fetchPermissionTemplates();
templates.value = res.data;
await loadRoleTemplates();
templateRefreshKey.value += 1;
} 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 buildTemplateUpdatePayload = () => ({
name: templateForm.value.name,
description: templateForm.value.description || undefined,
});
const buildTemplateCreatePayload = (): PermissionTemplateCreate => ({
...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: {},
});
const submitTemplateForm = async () => {
const valid = await templateFormRef.value?.validate().catch(() => false);
if (!valid) return;
templateSaving.value = true;
try {
if (editingTemplateId.value) {
const payload = buildTemplateUpdatePayload();
await updatePermissionTemplate(editingTemplateId.value, payload);
ElMessage.success("角色已更新");
} else {
const payload = buildTemplateCreatePayload();
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;
}
};
// ═══════════════════════════════════════════
// 角色权限编辑器(抽屉)
// ═══════════════════════════════════════════
interface RoleEditorOp {
operation_key: string;
module: string;
action: string;
description: string;
prerequisite_permissions: string[];
}
interface RoleEditorSection {
label: string;
ops: RoleEditorOp[];
}
interface RolePrerequisiteAdjustments {
add: Record<string, string[]>;
remove: Record<string, string[]>;
}
const editingRole = ref("");
const roleEditorSearch = ref("");
const roleEditorScope = ref<"all" | "granted" | "changed">("all");
const roleEditorDraft = ref<Record<string, boolean>>({});
const roleEditorSaving = ref(false);
const roleEditorLoading = ref(false);
const allOperations = ref<RoleEditorOp[]>([]);
const roleEditorDirtyGuard = useDrawerDirtyGuard(() => roleEditorDraft.value);
const handleTemplateDrawerBeforeClose = (done: () => void) => {
if (activeRolesDirtyGuard.isDirty.value || (editingRole.value && roleEditorDirtyGuard.isDirty.value)) {
ElMessage.warning("请先保存或取消编辑");
return;
}
done();
};
const roleEditorModuleLabel = projectPermissionModuleLabel;
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: "项目里程碑",
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",
};
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() || "";
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) => {
const matchesSearch =
!search ||
op.description.toLowerCase().includes(search) ||
op.operation_key.toLowerCase().includes(search) ||
roleEditorModuleLabel(op.module).includes(search);
const currentAllowed = Boolean(roleEditorDraft.value[op.operation_key]);
const originalAllowed = originalRolePermissionAllowed(op.operation_key);
const matchesScope =
roleEditorScope.value === "all" ||
(roleEditorScope.value === "granted" && currentAllowed) ||
(roleEditorScope.value === "changed" && currentAllowed !== originalAllowed);
return matchesSearch && matchesScope;
});
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 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 roleEditorModuleOps = (sections: RoleEditorSection[]): RoleEditorOp[] =>
sections.flatMap((section) => section.ops);
const roleEditorEnabledCount = (ops: RoleEditorOp[]): number =>
ops.filter((op) => roleEditorDraft.value[op.operation_key]).length;
const originalRolePermissionAllowed = (operationKey: string): boolean => {
const permission = apiMatrix.value?.[editingRole.value]?.[operationKey];
if (typeof permission === "boolean") return permission;
return Boolean(permission?.allowed);
};
const roleEditorGrantedCount = computed(() =>
allOperations.value.filter((op) =>
Boolean(roleEditorDraft.value[op.operation_key]) && !originalRolePermissionAllowed(op.operation_key)
).length
);
const roleEditorRevokedCount = computed(() =>
allOperations.value.filter((op) =>
!roleEditorDraft.value[op.operation_key] && originalRolePermissionAllowed(op.operation_key)
).length
);
const roleEditorChangeCount = computed(() => roleEditorGrantedCount.value + roleEditorRevokedCount.value);
const operationByKey = computed(() => {
const map: Record<string, RoleEditorOp> = {};
for (const op of allOperations.value) map[op.operation_key] = op;
return map;
});
const roleOperationLabel = (operationKey: string): string =>
operationByKey.value[operationKey]?.description || operationKey;
const roleOperationActionLabel = (operationKey: string): string => {
const op = operationByKey.value[operationKey];
if (!op) return "授权";
const verb = getOpVerb(op);
if (verb === "read") return "读取";
if (verb === "delete") return "删除";
return "写入";
};
const rolePermissionDisplayLabel = (operationKey: string): string =>
`${roleOperationActionLabel(operationKey)}${roleOperationLabel(operationKey)}`;
const previewRolePrerequisiteAdjustments = (draft: Record<string, boolean>): RolePrerequisiteAdjustments => {
const add: Record<string, string[]> = {};
const remove: Record<string, string[]> = {};
const baseline: Record<string, boolean> = {};
const candidate = { ...draft };
const explicitlyDenied = new Set<string>();
const role = editingRole.value;
if (apiMatrix.value && apiMatrix.value[role]) {
for (const [key, val] of Object.entries(apiMatrix.value[role] as Record<string, any>)) {
baseline[key] = typeof val === "boolean" ? val : val.allowed;
}
}
for (const [key, allowed] of Object.entries(draft)) {
if (baseline[key] !== allowed && !allowed) explicitlyDenied.add(key);
}
// 先补充缺失的前置权限(add 优先)
let changed = true;
while (changed) {
changed = false;
for (const op of allOperations.value) {
if (!candidate[op.operation_key]) continue;
for (const prereq of op.prerequisite_permissions) {
if (candidate[prereq]) continue;
const prerequisites = add[op.operation_key] || [];
if (!prerequisites.includes(prereq)) prerequisites.push(prereq);
add[op.operation_key] = prerequisites;
candidate[prereq] = true;
explicitlyDenied.delete(prereq); // 补回来的前置不再是"明确禁用"
changed = true;
}
}
}
// 再级联禁用受影响的下游权限(remove)
changed = true;
while (changed) {
changed = false;
for (const op of allOperations.value) {
if (!candidate[op.operation_key] || explicitlyDenied.has(op.operation_key)) continue;
const deniedPrereq = op.prerequisite_permissions.find((key) => explicitlyDenied.has(key));
if (!deniedPrereq) continue;
const dependents = remove[deniedPrereq] || [];
if (!dependents.includes(op.operation_key)) dependents.push(op.operation_key);
remove[deniedPrereq] = dependents;
candidate[op.operation_key] = false;
explicitlyDenied.add(op.operation_key);
changed = true;
}
}
return { add, remove };
};
const hasPrerequisiteAdjustments = (adjustments: RolePrerequisiteAdjustments): boolean =>
Object.keys(adjustments.add).length > 0 || Object.keys(adjustments.remove).length > 0;
const handleRolePermissionChange = (op: RoleEditorOp, allowed: boolean) => {
roleEditorDraft.value = { ...roleEditorDraft.value, [op.operation_key]: allowed };
};
const setRoleEditorPermissions = (ops: RoleEditorOp[], allowed: boolean) => {
if (!canEditSelectedRole.value) return;
const next = { ...roleEditorDraft.value };
for (const op of ops) next[op.operation_key] = allowed;
roleEditorDraft.value = next;
};
const canEditSelectedRole = computed(() => {
if (editingRole.value === "ADMIN") return false;
if (isAdmin.value) return true;
if (editingRole.value === "PM") return false;
return true;
});
const selectRoleForEdit = (role: string) => {
editingRole.value = role;
roleEditorSearch.value = "";
roleEditorScope.value = "all";
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;
roleEditorDirtyGuard.syncBaseline();
};
const cancelRoleEditor = () => {
editingRole.value = "";
roleEditorSearch.value = "";
roleEditorScope.value = "all";
roleEditorDraft.value = {};
roleEditorDirtyGuard.syncBaseline();
};
// ── 权限校验 Dialog ──
const prereqCheckVisible = ref(false);
const prereqCheckStep = ref<"checking" | "passed" | "failed">("checking");
const prereqCheckItems = ref<Array<{ label: string; status: "checking" | "ok" | "fail" | "warn" }>>([]);
const prereqAdjustments = ref<RolePrerequisiteAdjustments>({ add: {}, remove: {} });
const prereqSaving = ref(false);
const prereqCheckLabels = [
"分析已启用权限依赖关系",
"检查前置权限完整性",
"检查下游权限一致性",
];
const openPrereqCheck = async (): Promise<boolean> => {
prereqCheckVisible.value = true;
prereqCheckStep.value = "checking";
prereqCheckItems.value = prereqCheckLabels.map((label) => ({ label, status: "checking" }));
prereqAdjustments.value = { add: {}, remove: {} };
// 逐步动画显示校验过程
for (let i = 0; i < prereqCheckLabels.length; i++) {
await new Promise((r) => setTimeout(r, 320 + i * 180));
prereqCheckItems.value[i].status = "ok";
}
const adjustments = previewRolePrerequisiteAdjustments(roleEditorDraft.value);
prereqAdjustments.value = adjustments;
if (!hasPrerequisiteAdjustments(adjustments)) {
prereqCheckStep.value = "passed";
await new Promise((r) => setTimeout(r, 600));
prereqCheckVisible.value = false;
return true;
}
// 标记状态:有缺失前置 → 红叉;纯下游受影响 → 黄色警告
const hasAdd = Object.keys(adjustments.add).length > 0;
for (const item of prereqCheckItems.value) {
item.status = hasAdd ? "fail" : "warn";
}
prereqCheckStep.value = "failed";
return false;
};
const prereqMissingItems = computed(() => {
const items: Array<{ dependentKey: string; prereqKey: string }> = [];
for (const [dependentKey, prereqs] of Object.entries(prereqAdjustments.value.add)) {
for (const prereqKey of prereqs) {
items.push({ dependentKey, prereqKey });
}
}
return items;
});
const prereqRemoveItems = computed(() => {
const items: Array<{ prereqKey: string; dependentKey: string }> = [];
for (const [prereqKey, dependents] of Object.entries(prereqAdjustments.value.remove)) {
for (const dependentKey of dependents) {
items.push({ prereqKey, dependentKey });
}
}
return items;
});
const markAllStepsOk = () => {
prereqCheckItems.value = prereqCheckItems.value.map((item) => ({ ...item, status: "ok" as const }));
};
const grantSinglePrereq = (prereqKey: string) => {
roleEditorDraft.value = { ...roleEditorDraft.value, [prereqKey]: true };
prereqAdjustments.value = previewRolePrerequisiteAdjustments(roleEditorDraft.value);
if (!hasPrerequisiteAdjustments(prereqAdjustments.value)) {
markAllStepsOk();
prereqCheckStep.value = "passed";
setTimeout(() => confirmPrereqAndSave(), 800);
}
};
const grantAllPrereqs = () => {
const next = { ...roleEditorDraft.value };
for (const prereqs of Object.values(prereqAdjustments.value.add)) {
for (const p of prereqs) next[p] = true;
}
roleEditorDraft.value = next;
prereqAdjustments.value = previewRolePrerequisiteAdjustments(next);
markAllStepsOk();
prereqCheckStep.value = "passed";
setTimeout(() => confirmPrereqAndSave(), 800);
};
const confirmPrereqAndSave = async () => {
prereqSaving.value = true;
try {
const payload = flattenRolePermissions(editingRole.value, roleEditorDraft.value);
const res = await updateApiEndpointPermissions(selectedStudyId.value, payload, { confirm_prerequisite_adjustments: true });
apiMatrix.value = res.data;
const savedRole = editingRole.value;
prereqCheckVisible.value = false;
editingRole.value = "";
roleEditorDirtyGuard.syncBaseline();
ElMessage.success(`${roleLabel(savedRole)} 权限已保存`);
} catch (error: any) {
ElMessage.error(error?.response?.data?.detail?.message || "保存失败");
} finally {
prereqSaving.value = false;
}
};
const saveRoleEditor = async () => {
if (!selectedStudyId.value || !apiMatrix.value) return;
if (!canEditSelectedRole.value) {
ElMessage.warning("当前角色权限不可修改");
return;
}
const passed = await openPrereqCheck();
if (!passed) return; // Dialog 保持打开,等用户操作
// 校验通过,直接保存
roleEditorSaving.value = true;
try {
const payload = flattenRolePermissions(editingRole.value, roleEditorDraft.value);
const res = await updateApiEndpointPermissions(selectedStudyId.value, payload);
apiMatrix.value = res.data;
const savedRole = editingRole.value;
editingRole.value = "";
roleEditorDirtyGuard.syncBaseline();
ElMessage.success(`${roleLabel(savedRole)} 权限已保存`);
} catch (error: any) {
if (error === "cancel" || error === "close") return;
ElMessage.error(error?.response?.data?.detail?.message || "保存失败");
} finally {
roleEditorSaving.value = false;
}
};
// ═══════════════════════════════════════════
// 懒加载 & 初始化
// ═══════════════════════════════════════════
watch(() => route.path, (path) => {
if (path.endsWith("/system") && !systemPermissions.value.length) loadSystemPermissions();
});
onMounted(async () => {
await loadStudies();
await loadTemplates();
const qProjectId = route.query.projectId as string | undefined;
const qSub = route.query.sub as string | undefined;
const initialStudyId = qProjectId || studies.value[0]?.id;
if (initialStudyId) {
selectedStudyId.value = initialStudyId;
if (qSub === "members") projectSubTab.value = "members";
await loadPermissionData();
await Promise.all([loadMembers(), loadActiveRoles()]);
loadCandidates();
}
const tab = tabFromPath();
if (tab === "system" && !systemPermissions.value.length) loadSystemPermissions();
});
</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: 10px 14px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
flex-shrink: 0;
gap: 10px;
}
.perm-header-left {
display: flex;
flex-direction: column;
gap: 3px;
}
.perm-title {
margin: 0;
font-size: 15px;
font-weight: 700;
color: #1a2332;
line-height: 1.3;
}
.perm-header-actions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.project-selector {
display: flex;
align-items: center;
gap: 8px;
}
.project-selector :deep(.el-select) {
--el-select-input-color: #193b5a;
--el-select-multiple-input-color: #193b5a;
--el-input-text-color: #193b5a;
}
.project-selector :deep(.el-select__wrapper),
.project-selector :deep(.el-select__selection) {
color: #193b5a;
}
.project-selector :deep(.el-select__selected-item) {
color: #193b5a !important;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
font-size: 13px;
font-weight: 600;
line-height: 1.2;
letter-spacing: 0.01em;
font-variant-numeric: tabular-nums;
}
.project-selector :deep(.el-select__input) {
color: #193b5a !important;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.01em;
font-variant-numeric: tabular-nums;
}
.project-selector :deep(.el-select__placeholder) {
color: #94a3b8;
font-size: 13px;
}
.project-selector :deep(.el-select__caret) {
color: #315f7a;
}
.project-selector-icon {
width: 16px;
height: 16px;
color: #315f7a;
flex-shrink: 0;
}
/* ── 内容区:可滚动 ── */
.perm-body {
flex: 1;
overflow-y: auto;
padding: 0 0 8px;
}
.perm-empty {
padding: 80px 28px;
display: flex;
justify-content: center;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
text-align: center;
max-width: 360px;
}
.empty-icon {
display: flex;
align-items: center;
justify-content: center;
width: 72px;
height: 72px;
border-radius: 20px;
background: linear-gradient(135deg, #eff6ff, #e0f2fe);
color: #3b82f6;
}
.empty-icon svg {
width: 36px;
height: 36px;
}
.empty-state h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #1a2332;
}
.empty-state p {
margin: 0;
font-size: 13px;
color: #64748b;
line-height: 1.6;
}
.perm-body-padded {
padding: 0 28px;
}
/* ── 项目权限:子 tabs ── */
.project-sub-tabs {
background: #fff;
border-radius: 0;
padding: 0 12px 12px;
}
:deep(.project-sub-tabs .el-tabs__header) {
margin-bottom: 8px;
}
.sub-tab-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
padding: 7px 10px;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #f1f5f9;
}
.sub-tab-info {
display: flex;
align-items: center;
gap: 10px;
}
.sub-tab-project-name {
font-size: 12px;
font-weight: 600;
color: #334155;
}
.sub-tab-actions {
display: flex;
align-items: center;
gap: 8px;
}
.permission-view-switch :deep(.el-radio-button__inner) {
min-width: 78px;
}
.sub-tab-spacer { flex: 1; }
/* ── 成员管理表格 ── */
.members-table-wrapper {
border-radius: 8px;
border: 1px solid #e2e8f0;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
}
.members-table {
width: 100%;
}
.member-name-cell {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
}
.member-name {
font-size: 12px;
font-weight: 500;
color: #1a2332;
}
.member-email {
max-width: 260px;
overflow: hidden;
color: #81909e;
font-size: 10.5px;
text-overflow: ellipsis;
white-space: nowrap;
}
.members-filter-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
padding: 6px 8px;
border: 1px solid #e5ebf0;
border-radius: 7px;
background: #fbfcfd;
}
.members-filter-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.members-filter-result {
flex-shrink: 0;
color: #718294;
font-size: 10.5px;
font-weight: 500;
}
.members-filter-meta {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
min-width: 0;
}
.member-undo {
display: inline-flex;
align-items: center;
gap: 2px;
color: #64788a;
font-size: 10.5px;
white-space: nowrap;
}
:global(.member-form-dialog.el-dialog) {
--el-dialog-bg-color: transparent !important;
--el-dialog-box-shadow: none !important;
--el-dialog-border-radius: 0 !important;
overflow: visible !important;
margin: 0 auto !important;
padding: 0 !important;
border: 0 !important;
background: transparent !important;
box-shadow: none !important;
backdrop-filter: none !important;
}
:global(.member-form-dialog .el-dialog__header) {
display: none;
}
:global(.member-form-dialog .el-dialog__body) {
padding: 0 !important;
background: transparent !important;
}
.member-form-panel {
overflow: hidden;
border: 1px solid #dce5eb;
border-radius: 10px;
background: #fff;
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.16);
}
.member-form-panel__header,
.member-form-panel__footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
}
.member-form-panel__header {
border-bottom: 1px solid #e8edf1;
}
.member-form-panel__header h2 {
margin: 0;
color: #1e293b;
font-size: 14px;
font-weight: 700;
}
.member-form-panel__close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: #718096;
cursor: pointer;
}
.member-form-panel__close:hover {
background: #f1f5f9;
color: #334155;
}
.member-form-panel__close svg {
width: 16px;
height: 16px;
}
.member-form-panel__body {
padding: 14px 14px 6px;
}
.member-form-panel__footer {
justify-content: flex-end;
gap: 8px;
border-top: 1px solid #e8edf1;
background: #fafbfc;
}
.time-text {
font-size: 11px;
color: #64748b;
}
.full-table { width: 100%; }
:deep(.perm-page .el-input__wrapper),
:deep(.perm-page .el-select__wrapper) {
min-height: 30px;
}
:deep(.perm-page .el-button) {
min-height: 28px;
padding: 5px 9px;
font-size: 12px;
}
:deep(.members-table .el-table__header-wrapper th.el-table__cell) {
padding: 6px 0;
font-size: 11px;
}
:deep(.members-table .el-table__body-wrapper td.el-table__cell) {
padding: 5px 0;
font-size: 12px;
}
/* ── 系统级权限:模块块 ── */
.system-perm-container {
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.perm-body--system {
padding-bottom: 0;
}
.system-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
overflow: hidden;
border: 1px solid #dfe7f0;
border-radius: 12px;
background: #fff;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04);
}
.system-stat-item {
position: relative;
display: flex;
min-width: 0;
min-height: 46px;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 6px 12px;
border-left: 1px solid #edf1f6;
}
.system-stat-item:first-child {
border-left: 0;
}
.system-stat-item::before {
position: absolute;
top: 0;
right: 0;
left: 0;
height: 2px;
background: #3b82f6;
content: "";
}
.system-stat-item--permissions::before {
background: #10b981;
}
.system-stat-item--pm::before {
background: #f59e0b;
}
.system-stat-label {
overflow: hidden;
color: #64748b;
font-size: 12px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.system-stat-value {
display: flex;
align-items: baseline;
gap: 3px;
flex-shrink: 0;
}
.system-stat-value strong {
color: #172033;
font-size: 18px;
font-weight: 720;
line-height: 1;
}
.system-stat-value span {
color: #94a3b8;
font-size: 11px;
}
.system-permissions-card {
overflow: hidden;
background: #fff;
border-radius: 12px;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.system-module-block {
background: #fff;
}
.system-module-block + .system-module-block {
border-top: 1px solid #dbe4ee;
}
.system-module-header {
display: flex;
align-items: center;
gap: 9px;
min-height: 42px;
padding: 6px 12px;
border-bottom: 1px solid #e8edf3;
background: #fbfcfe;
}
.system-module-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 7px;
background: linear-gradient(135deg, #eff6ff, #e0f2fe);
color: #3b82f6;
flex-shrink: 0;
}
.system-module-icon.module-icon--system_users {
background: linear-gradient(135deg, #fef3c7, #fde68a);
color: #d97706;
}
.system-module-icon.module-icon--system_projects {
background: linear-gradient(135deg, #ede9fe, #ddd6fe);
color: #7c3aed;
}
.system-module-icon.module-icon--system_permissions {
background: linear-gradient(135deg, #ecfdf5, #d1fae5);
color: #059669;
}
.system-module-icon.module-icon--system_monitoring {
background: linear-gradient(135deg, #eff6ff, #dbeafe);
color: #2563eb;
}
.system-module-icon.module-icon--system_audit {
background: linear-gradient(135deg, #fef2f2, #fecaca);
color: #dc2626;
}
.system-module-icon svg {
width: 14px;
height: 14px;
}
.system-module-info {
display: flex;
flex-direction: row;
align-items: baseline;
gap: 7px;
flex: 1;
min-width: 0;
}
.system-module-title {
font-size: 13px;
font-weight: 650;
color: #1a2332;
}
.system-module-count {
font-size: 11px;
color: #94a3b8;
}
.system-module-roles-summary {
flex-shrink: 0;
}
.system-module-roles-summary :deep(.el-tag) {
height: 20px;
padding: 0 7px;
font-size: 10.5px;
}
.system-perm-list {
display: flex;
flex-direction: column;
gap: 0;
}
.system-perm-columns,
.system-perm-row {
display: grid;
grid-template-columns: minmax(240px, 1.2fr) minmax(240px, 1fr) minmax(180px, 0.8fr);
gap: 10px;
align-items: center;
}
.system-perm-columns {
min-height: 26px;
padding: 4px 12px;
border-bottom: 1px solid #e8edf3;
background: #f7f9fc;
color: #64748b;
font-size: 10.5px;
font-weight: 650;
letter-spacing: 0.02em;
}
.system-perm-row {
min-height: 36px;
padding: 4px 12px;
border-bottom: 1px solid #edf1f5;
transition: background 0.14s ease;
}
.system-perm-row:last-child {
border-bottom: 0;
}
.system-perm-row:hover {
background: #f3f7fb;
}
.system-perm-main {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
justify-self: start;
width: 100%;
}
.system-perm-action {
flex-shrink: 0;
min-width: 38px;
height: 20px;
padding: 0 6px;
text-align: center;
font-size: 10.5px;
}
.system-perm-desc {
min-width: 0;
flex: 1;
font-size: 12px;
color: #334155;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.system-perm-roles {
display: flex;
gap: 4px;
flex-wrap: wrap;
justify-content: flex-start;
justify-self: start;
}
.role-chip {
height: 20px;
padding: 0 7px;
font-size: 10.5px;
}
.system-perm-table { width: 100%; }
.perm-key {
display: block;
overflow: hidden;
max-width: 100%;
justify-self: start;
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
font-size: 10.5px;
background: #f4f7fa;
padding: 2px 6px;
border-radius: 5px;
color: #475569;
border: 1px solid #dfe7ef;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 920px) {
.system-perm-columns,
.system-perm-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.system-perm-columns {
display: none;
}
.system-perm-main {
grid-column: 1;
grid-row: 1;
}
.system-perm-roles {
grid-column: 2;
grid-row: 1;
}
.perm-key {
grid-column: 1 / -1;
grid-row: 2;
}
}
@media (max-width: 620px) {
.system-perm-container {
padding: 0;
}
.system-stats {
grid-template-columns: 1fr;
}
.system-stat-item {
min-height: 42px;
border-top: 1px solid #edf1f6;
border-left: 0;
}
.system-stat-item:first-child {
border-top: 0;
}
.system-module-header {
flex-wrap: wrap;
}
.system-module-roles-summary {
width: 100%;
padding-left: 46px;
}
.system-perm-row {
grid-template-columns: minmax(0, 1fr);
gap: 7px;
padding: 10px 12px;
}
.system-perm-main,
.system-perm-roles,
.perm-key {
grid-column: 1;
grid-row: auto;
}
}
/* ── 抽屉内部 ── */
.drawer-tabs-shell {
position: relative;
display: flex;
flex-direction: column;
min-height: calc(100vh - 110px);
}
.drawer-tabs {
flex: 1;
padding-top: 4px;
}
.drawer-tab-actions {
position: sticky;
z-index: 4;
right: auto;
bottom: 0;
display: flex;
align-items: center;
justify-content: flex-end;
width: 100%;
min-height: 58px;
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid #e8edf2;
background: #fff;
box-shadow: 0 -8px 18px rgba(255, 255, 255, 0.92);
}
.template-table {
margin-top: 12px;
}
/* ── 生效角色列表 ── */
.active-roles-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.active-role-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-radius: 12px;
border: 1px solid #e2e8f0;
background: #fff;
transition: all 0.15s ease;
}
.active-role-row:hover {
background: #f8fafc;
border-color: #cbd5e1;
}
.active-role-info {
display: flex;
align-items: center;
gap: 12px;
}
.active-role-icon {
font-size: 22px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: #f8fafc;
border-radius: 10px;
}
.active-role-name {
font-size: 14px;
font-weight: 600;
color: #1a2332;
}
.active-role-desc {
font-size: 12px;
color: #64748b;
margin-top: 2px;
}
.active-role-meta {
font-size: 12px;
color: #b45309;
margin-top: 4px;
font-weight: 500;
}
.template-name { font-weight: 600; color: #1a2332; }
/* ── 角色编辑器 ── */
.role-editor-layout {
display: flex;
height: calc(100vh - 200px);
gap: 0;
}
.role-editor-sidebar {
width: 170px;
flex-shrink: 0;
border-right: 1px solid #f1f5f9;
overflow-y: auto;
padding: 4px 0;
}
.role-sidebar-item {
display: flex;
align-items: center;
width: calc(100% - 12px);
gap: 8px;
padding: 10px 12px;
cursor: pointer;
border: 1px solid transparent;
border-radius: 10px;
margin: 2px 6px;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
transition: all 0.15s ease;
}
.role-sidebar-item:hover { background: #f8fafc; }
.role-sidebar-item:focus-visible {
outline: 3px solid rgba(59, 130, 246, 0.22);
outline-offset: -1px;
}
.role-sidebar-item.active {
background: linear-gradient(135deg, #eff6ff, #e0f2fe);
border: 1px solid #bfdbfe;
}
.role-sidebar-icon {
font-size: 18px;
flex-shrink: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.role-sidebar-info { flex: 1; min-width: 0; }
.role-sidebar-name {
font-size: 13px;
font-weight: 500;
color: #334155;
}
.role-sidebar-item.active .role-sidebar-name { color: #1d4ed8; font-weight: 600; }
.role-sidebar-count {
font-size: 11px;
color: #10b981;
margin-top: 2px;
font-weight: 500;
}
.role-sidebar-arrow {
font-size: 12px;
color: #3b82f6;
flex-shrink: 0;
}
.role-editor-content {
flex: 1;
overflow-y: auto;
padding: 0 16px;
display: flex;
flex-direction: column;
}
.role-editor-modules {
flex: 1;
display: flex;
flex-direction: column;
gap: 16px;
}
.role-editor-toolbar {
display: flex;
align-items: center;
gap: 8px;
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-change-summary {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
padding: 0 10px;
color: #8795a3;
font-size: 11.5px;
font-weight: 600;
}
.role-editor-change-summary--dirty {
color: #315f7a;
}
.role-editor-change-summary--dirty span + span {
padding-left: 10px;
border-left: 1px solid #dbe4ea;
}
.role-editor-search {
flex: 1;
}
.role-editor-scope {
flex-shrink: 0;
}
: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: 8px;
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: 10px;
justify-content: space-between;
font-size: 12px;
font-weight: 700;
color: #0f172a;
padding: 8px 12px 8px 16px;
background: linear-gradient(135deg, #f1f5ff 0%, #f8fafc 100%);
border-bottom: 1px solid #e2e8f0;
letter-spacing: 0.2px;
}
.role-editor-module-heading {
display: flex;
align-items: baseline;
gap: 9px;
}
.role-editor-module-heading > span:last-child {
color: #718294;
font-size: 10.5px;
font-weight: 500;
letter-spacing: 0;
}
.role-editor-module-actions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.role-editor-module-actions :deep(.el-button + .el-button) {
margin-left: 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: 2px;
padding: 4px;
}
.role-editor-collapse {
border: 0;
background: #fff;
}
.role-editor-section {
background: #fff;
}
.role-editor-section-title {
font-size: 12px;
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: 8px;
font-size: 10.5px;
color: #64748b;
font-weight: 500;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.role-editor-section-actions {
display: inline-flex;
align-items: center;
flex-shrink: 0;
margin-right: 10px;
}
.role-editor-section-actions :deep(.el-button + .el-button) {
margin-left: 2px;
}
:deep(.role-editor-section .el-collapse-item__header) {
height: 36px;
padding: 0 12px;
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: 2px 4px 5px;
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: 7px;
padding: 5px 7px;
border-radius: 5px;
transition: background 0.15s ease;
}
.role-editor-op-row:hover {
background: #eef2ff;
}
.op-action-tag {
flex-shrink: 0;
width: 42px;
text-align: center;
font-weight: 600;
border-radius: 6px;
}
.op-desc {
font-size: 12px;
color: #1e293b;
}
@media (max-width: 720px) {
.role-editor-toolbar,
.role-editor-module-header {
align-items: stretch;
flex-direction: column;
}
.role-editor-change-summary {
justify-content: space-between;
padding: 7px 10px;
border-top: 1px solid #edf1f5;
}
.role-editor-module-actions {
justify-content: flex-end;
}
.role-editor-scope {
width: 100% !important;
}
.members-filter-bar {
align-items: stretch;
flex-direction: column;
}
.members-filter-controls > * {
width: 100% !important;
}
.members-filter-result {
text-align: right;
}
.members-filter-meta {
align-items: flex-end;
flex-direction: column;
}
}
</style>
<style>
.project-selector-dropdown .el-select-dropdown__item {
color: #1f3954;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
font-size: 13px;
font-weight: 500;
letter-spacing: 0.01em;
font-variant-numeric: tabular-nums;
}
.project-selector-dropdown .el-select-dropdown__item.is-selected {
color: #123f63;
background: #f4f8fc;
font-weight: 600;
}
.project-selector-dropdown .el-select-dropdown__item.is-hovering {
background: #f1f6fa;
}
/* ══════════════════════════════════════
权限依赖校验 Dialog
══════════════════════════════════════ */
.prereq-check-body {
padding: 4px 0 8px;
}
.prereq-check-steps {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 20px;
}
.prereq-check-step {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 8px;
background: #f8fafc;
border: 1px solid #e8edf3;
transition: all 0.3s ease;
font-size: 14px;
color: #64748b;
}
.prereq-check-step--ok {
background: #f0fdf4;
border-color: #bbf7d0;
color: #15803d;
}
.prereq-check-step--fail {
background: #fff7ed;
border-color: #fed7aa;
color: #c2410c;
}
.prereq-check-step--warn {
background: #fefce8;
border-color: #fde68a;
color: #92400e;
}
.prereq-step-icon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.prereq-step-icon svg {
width: 16px;
height: 16px;
}
.prereq-check-step--ok .prereq-step-icon svg { stroke: #16a34a; }
.prereq-check-step--fail .prereq-step-icon svg { stroke: #dc2626; }
.prereq-check-step--warn .prereq-step-icon svg { stroke: #d97706; }
.prereq-step-spinner {
display: block;
width: 14px;
height: 14px;
border: 2px solid #cbd5e1;
border-top-color: #6366f1;
border-radius: 50%;
animation: prereq-spin 0.7s linear infinite;
}
@keyframes prereq-spin {
to { transform: rotate(360deg); }
}
.prereq-step-label {
font-weight: 500;
}
/* 通过结果 */
.prereq-result--pass {
display: flex;
align-items: center;
gap: 10px;
padding: 16px 18px;
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
border: 1.5px solid #86efac;
border-radius: 10px;
color: #15803d;
font-weight: 600;
font-size: 15px;
}
.prereq-result--pass svg {
width: 22px;
height: 22px;
stroke: #16a34a;
flex-shrink: 0;
}
/* 失败:问题列表 */
.prereq-issues {
display: flex;
flex-direction: column;
gap: 14px;
}
.prereq-issues-header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px;
background: #fff7ed;
border: 1px solid #fed7aa;
border-radius: 8px;
color: #9a3412;
font-size: 13px;
font-weight: 500;
}
.prereq-issues-header svg {
width: 16px;
height: 16px;
stroke: #ea580c;
flex-shrink: 0;
}
.prereq-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.prereq-section-title {
display: flex;
align-items: center;
gap: 8px;
}
.prereq-section-badge {
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 20px;
letter-spacing: 0.02em;
}
.prereq-section-badge--add {
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #bfdbfe;
}
.prereq-section-badge--remove {
background: #fef2f2;
color: #b91c1c;
border: 1px solid #fecaca;
}
.prereq-grant-all-btn {
margin-left: auto;
font-size: 12px;
}
.prereq-item-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.prereq-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid;
}
.prereq-item--missing {
background: #f8faff;
border-color: #c7d7fe;
}
.prereq-item--remove {
background: #fff8f8;
border-color: #fecaca;
}
.prereq-item-info {
flex: 1;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
min-width: 0;
}
.prereq-item-row {
display: flex;
align-items: center;
gap: 5px;
}
.prereq-item-arrow {
font-size: 11px;
color: #94a3b8;
padding: 0 2px;
}
.prereq-item-chip {
font-size: 10px;
font-weight: 600;
padding: 1px 6px;
border-radius: 20px;
white-space: nowrap;
}
.prereq-item-chip--enabled { background: #dcfce7; color: #15803d; }
.prereq-item-chip--missing { background: #fee2e2; color: #b91c1c; }
.prereq-item-chip--disabled { background: #f1f5f9; color: #64748b; }
.prereq-item-chip--will-remove { background: #fef9c3; color: #854d0e; }
.prereq-item-name {
font-size: 12px;
color: #334155;
font-weight: 500;
}
.prereq-dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* 进入动画 */
.prereq-result-enter-active {
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.prereq-result-enter-from {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
</style>