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
3279 lines
102 KiB
Vue
3279 lines
102 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>
|
||
</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>
|