feat: 接入用户与文档权限管理

This commit is contained in:
xy
2026-09-03 18:43:42 +08:00
parent 6d56bc0075
commit f45864e817
24 changed files with 2139 additions and 775 deletions
@@ -11,12 +11,15 @@ const props = defineProps<{
loading: boolean
saving: boolean
error: string
canEdit?: boolean
drawer?: boolean
}>()
const emit = defineEmits<{
selectRole: [roleId: string]
togglePermission: [permissionId: string]
save: []
close: []
}>()
const groupedPermissions = computed(() => {
@@ -37,14 +40,26 @@ function isSelected(permissionId: string) {
</script>
<template>
<section class="page-card api-permission-card">
<section
:class="['api-permission-card', { 'drawer-panel': props.drawer, 'page-card': !props.drawer }]"
>
<div class="card-heading">
<div>
<h2>角色 API 权限</h2>
<p>这里维护角色与后端 API 权限目录的关联,不等同于文档模板权限。</p>
<p>维护角色与后端 API 权限目录的关联,不等同于文档模板权限。</p>
</div>
<span class="heading-spacer" />
<button
v-if="props.drawer"
type="button"
class="close-button"
aria-label="关闭角色 API 权限"
@click="emit('close')"
>
×
</button>
<button
v-if="props.canEdit !== false"
type="button"
class="table-button"
:disabled="saving || loading || !selectedRoleId"
@@ -52,6 +67,7 @@ function isSelected(permissionId: string) {
>
{{ saving ? '保存中…' : '保存角色权限' }}
</button>
<span v-else class="readonly-note">当前账号仅可查看</span>
</div>
<div v-if="loading" class="loading-state">正在加载 API 权限目录…</div>
@@ -80,7 +96,7 @@ function isSelected(permissionId: string) {
<input
type="checkbox"
:checked="isSelected(permission.id)"
:disabled="saving"
:disabled="saving || props.canEdit === false"
@change="emit('togglePermission', permission.id)"
/>
<span>
@@ -102,6 +118,15 @@ function isSelected(permissionId: string) {
margin-top: 14px;
}
.drawer-panel {
display: flex;
min-height: 100%;
flex-direction: column;
margin-top: 0;
overflow: hidden;
background: var(--card);
}
.card-heading {
display: flex;
gap: 12px;
@@ -110,6 +135,11 @@ function isSelected(permissionId: string) {
border-bottom: 1px solid var(--line);
}
.drawer-panel .card-heading {
flex-shrink: 0;
padding: 18px 20px;
}
.card-heading h2 {
margin: 0;
color: var(--ink);
@@ -126,6 +156,23 @@ function isSelected(permissionId: string) {
flex: 1;
}
.close-button {
padding: 0 2px;
color: var(--mut);
font-size: 24px;
line-height: 1;
background: transparent;
}
.close-button:hover {
color: var(--ink);
}
.readonly-note {
color: var(--mut);
font-size: 11.5px;
}
.table-button {
padding: 5px 10px;
color: var(--brand);
@@ -160,6 +207,11 @@ function isSelected(permissionId: string) {
padding: 16px;
}
.drawer-panel .api-permission-layout {
flex: 1;
overflow: auto;
}
.role-selector {
display: grid;
align-content: start;
@@ -3,12 +3,27 @@ import type { RoleSummary } from '../types'
defineProps<{
rows: RoleSummary[]
interactive?: boolean
selectedRoleId?: string
}>()
const emit = defineEmits<{
select: [role: RoleSummary]
}>()
</script>
<template>
<div class="role-cards">
<article v-for="role in rows" :key="role.id" class="role-card">
<div v-if="rows.length" class="role-cards">
<button
v-for="role in rows"
:key="role.id"
type="button"
class="role-card"
:class="{ 'is-selected': selectedRoleId === role.id }"
:disabled="!interactive"
:aria-label="`${role.name},${interactive ? '配置角色 API 权限' : '无角色权限配置权限'}`"
@click="emit('select', role)"
>
<h3>
<span class="role-color" :style="{ backgroundColor: role.color }" aria-hidden="true" />
{{ role.name }}
@@ -17,8 +32,10 @@ defineProps<{
<span class="role-count">
{{ role.userCount === null ? '暂无人数统计' : `${role.userCount} 人` }}
</span>
</article>
<span v-if="interactive" class="role-action">配置角色权限 →</span>
</button>
</div>
<div v-else class="role-empty">暂无角色概览</div>
</template>
<style scoped>
@@ -30,10 +47,32 @@ defineProps<{
}
.role-card {
width: 100%;
color: inherit;
text-align: left;
padding: 13px 14px;
background: #fff;
border: 1px solid var(--line);
border-radius: 10px;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease,
transform 0.15s ease;
}
.role-card:not(:disabled) {
cursor: pointer;
}
.role-card:not(:disabled):hover,
.role-card.is-selected {
border-color: var(--brand);
box-shadow: var(--sh-hover);
transform: translateY(-1px);
}
.role-card:disabled {
cursor: default;
}
.role-card h3 {
@@ -69,6 +108,25 @@ defineProps<{
border-radius: 20px;
}
.role-action {
display: block;
margin-top: 9px;
color: var(--brand);
font-size: 11px;
font-weight: 700;
}
.role-empty {
padding: 26px 12px;
margin-bottom: 16px;
color: var(--mut);
font-size: 12px;
text-align: center;
background: #f8fbfc;
border: 1px solid var(--line);
border-radius: 10px;
}
@media (max-width: 1200px) {
.role-cards {
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1,10 +1,13 @@
<script setup lang="ts">
import type { UserStatus, UserTableRow } from '../types'
defineProps<{
const props = defineProps<{
rows: UserTableRow[]
roleColors: Record<string, string>
disablingId?: string | null
canEdit?: boolean
canResetPassword?: boolean
canDeactivate?: boolean
}>()
const emit = defineEmits<{
@@ -72,12 +75,24 @@ const statusLabels: Record<UserStatus, string> = {
</span>
</td>
<td class="actions-cell">
<button type="button" class="table-button" @click="emit('edit', row)">编辑</button>
<button type="button" class="table-button" @click="emit('resetPassword', row)">
<button
v-if="props.canEdit"
type="button"
class="table-button"
@click="emit('edit', row)"
>
编辑
</button>
<button
v-if="props.canResetPassword"
type="button"
class="table-button"
@click="emit('resetPassword', row)"
>
重置密码
</button>
<button
v-if="row.status === 'enabled'"
v-if="props.canDeactivate && row.status === 'enabled'"
type="button"
class="table-button table-button--danger"
:disabled="disablingId === row.id"
+143 -19
View File
@@ -34,6 +34,7 @@ import type {
UserRecord as ApiUserRecord,
} from '@/api/management/types'
import { useAppStore } from '@/stores/app'
import { useLoginStore } from '@/stores/login'
import RoleSummaryCards from './components/RoleSummaryCards.vue'
import UserTable from './components/UserTable.vue'
@@ -45,6 +46,7 @@ import { roleColors, roleSummaries, users } from './mock'
import type { RoleSummary, UserEditorForm, UserTableRow } from './types'
const appStore = useAppStore()
const loginStore = useLoginStore()
const loading = ref(true)
const keyword = ref('')
const roleRows = ref<RoleSummary[]>([])
@@ -74,8 +76,33 @@ const rolePermissionVersion = ref<number | undefined>()
const apiPermissionLoading = ref(false)
const apiPermissionSaving = ref(false)
const apiPermissionError = ref('')
const rolePermissionDrawerVisible = ref(false)
let userRequestId = 0
const canEditUsers = computed(
() => isUserMockEnabled || loginStore.hasPermission('system:user:update'),
)
const canCreateUsers = computed(
() => isUserMockEnabled || loginStore.hasPermission('system:user:create'),
)
const canDeactivateUsers = computed(
() => isUserMockEnabled || loginStore.hasPermission('system:user:disable'),
)
const canResetPasswords = computed(
() => isUserMockEnabled || loginStore.hasPermission('system:user:password:reset'),
)
const canImportUsers = computed(
() => isUserMockEnabled || loginStore.hasPermission('system:user:import'),
)
const canViewRoleApiPermissions = computed(
() =>
!isUserMockEnabled &&
loginStore.hasPermission(['system:permission:query', 'system:role:permission:query']),
)
const canEditRoleApiPermissions = computed(
() => !isUserMockEnabled && loginStore.hasPermission('system:role:permission:update'),
)
const mockCampuses: CampusRecord[] = [
{ id: 'mock-main', code: 'MAIN', name: '本部院区', status: 'ENABLED', sortNo: 1 },
{ id: 'mock-east', code: 'EAST', name: '东院区', status: 'ENABLED', sortNo: 2 },
@@ -229,7 +256,7 @@ async function loadSelectedRolePermissions() {
}
async function loadApiPermissionCatalog() {
if (isUserMockEnabled || apiPermissionLoading.value) {
if (isUserMockEnabled || !canViewRoleApiPermissions.value || apiPermissionLoading.value) {
return
}
@@ -255,6 +282,10 @@ async function loadApiPermissionCatalog() {
}
async function selectRoleForApiPermissions(roleId: string) {
if (!canViewRoleApiPermissions.value) {
return
}
selectedRoleId.value = roleId
apiPermissionLoading.value = true
apiPermissionError.value = ''
@@ -282,7 +313,7 @@ function toggleApiPermission(permissionId: string) {
}
async function saveRoleApiPermissions() {
if (!selectedRoleId.value || apiPermissionSaving.value) {
if (!canEditRoleApiPermissions.value || !selectedRoleId.value || apiPermissionSaving.value) {
return
}
@@ -363,11 +394,19 @@ function mapRoleSummary(role: RoleRecord): RoleSummary {
}
function showAddUserMessage() {
if (!canCreateUsers.value) {
return
}
editorInitial.value = null
editorVisible.value = true
}
function showImportMessage() {
if (!canImportUsers.value) {
return
}
if (isUserMockEnabled) {
ElMessage.info('批量导入需要切换到真实接口模式(VITE_USE_MOCK=false)')
return
@@ -379,6 +418,10 @@ function showImportMessage() {
}
async function editUser(row: UserTableRow) {
if (!canEditUsers.value) {
return
}
try {
const user = isUserMockEnabled
? userRows.value.find((item) => item.id === row.id)
@@ -419,6 +462,10 @@ async function editUser(row: UserTableRow) {
}
function resetPassword(user: UserTableRow) {
if (!canResetPasswords.value) {
return
}
if (isUserMockEnabled) {
ElMessage.info('密码重置需要切换到真实接口模式(VITE_USE_MOCK=false)')
return
@@ -515,7 +562,11 @@ function changeUserPage(nextPage: number) {
}
async function deactivateUser(row: UserTableRow) {
if (row.status !== 'enabled' || !window.confirm(`确定停用用户“${row.name}”吗?`)) {
if (
!canDeactivateUsers.value ||
row.status !== 'enabled' ||
!window.confirm(`确定停用用户“${row.name}”吗?`)
) {
return
}
@@ -663,6 +714,15 @@ watch(
)
onMounted(loadUsers)
function openRolePermissionDrawer(role: RoleSummary) {
if (!canViewRoleApiPermissions.value) {
return
}
rolePermissionDrawerVisible.value = true
void selectRoleForApiPermissions(role.id)
}
</script>
<template>
@@ -672,7 +732,12 @@ onMounted(loadUsers)
</div>
<template v-else>
<RoleSummaryCards :rows="roleRows" />
<RoleSummaryCards
:rows="roleRows"
:interactive="canViewRoleApiPermissions"
:selected-role-id="selectedRoleId"
@select="openRolePermissionDrawer"
/>
<section class="page-card">
<div class="card-heading">
@@ -687,13 +752,30 @@ onMounted(loadUsers)
@keydown.enter.prevent="searchUsers"
/>
<button type="button" class="table-button" @click="searchUsers">查询</button>
<button type="button" class="table-button" @click="showAddUserMessage">+新增用户</button>
<button type="button" class="table-button" @click="showImportMessage">批量导入</button>
<button
v-if="canCreateUsers"
type="button"
class="table-button"
@click="showAddUserMessage"
>
+新增用户
</button>
<button
v-if="canImportUsers"
type="button"
class="table-button"
@click="showImportMessage"
>
批量导入
</button>
</div>
<UserTable
:rows="visibleUsers"
:role-colors="roleColorMap"
:disabling-id="disablingUserId"
:can-edit="canEditUsers"
:can-reset-password="canResetPasswords"
:can-deactivate="canDeactivateUsers"
@edit="editUser"
@reset-password="resetPassword"
@deactivate="deactivateUser"
@@ -720,19 +802,31 @@ onMounted(loadUsers)
</div>
</section>
<RoleApiPermissionPanel
v-if="!isUserMockEnabled"
:roles="roleOptions"
:permissions="apiPermissions"
:selected-role-id="selectedRoleId"
:selected-permission-ids="selectedRolePermissionIds"
:loading="apiPermissionLoading"
:saving="apiPermissionSaving"
:error="apiPermissionError"
@select-role="selectRoleForApiPermissions"
@toggle-permission="toggleApiPermission"
@save="saveRoleApiPermissions"
/>
<Teleport to="body">
<div
v-if="rolePermissionDrawerVisible && canViewRoleApiPermissions"
class="role-permission-mask"
@click.self="rolePermissionDrawerVisible = false"
>
<aside class="role-permission-drawer" role="dialog" aria-modal="true">
<RoleApiPermissionPanel
:roles="roleOptions"
:permissions="apiPermissions"
:selected-role-id="selectedRoleId"
:selected-permission-ids="selectedRolePermissionIds"
:loading="apiPermissionLoading"
:saving="apiPermissionSaving"
:error="apiPermissionError"
:can-edit="canEditRoleApiPermissions"
drawer
@select-role="selectRoleForApiPermissions"
@toggle-permission="toggleApiPermission"
@save="saveRoleApiPermissions"
@close="rolePermissionDrawerVisible = false"
/>
</aside>
</div>
</Teleport>
</template>
<UserEditorDialog
@@ -841,6 +935,24 @@ onMounted(loadUsers)
opacity: 0.55;
}
.role-permission-mask {
position: fixed;
z-index: 50;
inset: 0;
display: flex;
justify-content: flex-end;
background: rgb(9 40 52 / 45%);
}
.role-permission-drawer {
width: min(760px, 96vw);
height: 100%;
overflow: hidden;
background: var(--card);
box-shadow: var(--sh-modal);
animation: slide-in 0.18s ease-out;
}
.loading-card {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -867,6 +979,18 @@ onMounted(loadUsers)
}
}
@keyframes slide-in {
from {
transform: translateX(12px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@media (max-width: 900px) {
.card-heading {
align-items: flex-start;