feat: 接入用户与文档权限管理
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user