Improve frontend dialog and layout behavior

This commit is contained in:
jxxghp
2026-07-07 08:54:09 +08:00
parent 39a9fe0541
commit b27eeea6c0
15 changed files with 940 additions and 113 deletions

View File

@@ -1,6 +1,6 @@
import type { Component, Ref, VNode } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import type { UserPermissionKey } from '@/utils/permission'
import type { UserPermissionFeatureKey, UserPermissionKey } from '@/utils/permission'
import { ContentWidth, FooterType, NavbarType } from './enums'
export interface UserConfig {
@@ -124,6 +124,7 @@ export interface NavLink extends NavLinkProps, Partial<AclProperties> {
badgeClass?: string
disable?: boolean
permission?: UserPermissionKey
feature?: UserPermissionFeatureKey
}
export interface NavMenuTabItem {
@@ -131,6 +132,8 @@ export interface NavMenuTabItem {
icon?: string
tab: string
description?: string
permission?: UserPermissionKey
feature?: UserPermissionFeatureKey
}
export interface NavMenu extends NavLink {

View File

@@ -153,6 +153,7 @@ function getMenus(): NavMenu[] {
header: item.header,
admin: item.admin,
permission: item.permission,
feature: item.feature,
}),
)
// 设置标签页

View File

@@ -7,6 +7,14 @@ import { useDisplay } from 'vuetify'
import avatar1 from '@images/avatars/avatar-1.png'
import { useUserStore } from '@/stores'
import { useI18n } from 'vue-i18n'
import {
USER_PERMISSION_FEATURES,
buildDefaultFeaturePermissions,
normalizeUserPermissions,
type UserPermissionCategoryKey,
type UserPermissionFeatureKey,
type UserPermissions,
} from '@/utils/permission'
// 多语言支持
const { t } = useI18n()
@@ -65,14 +73,6 @@ interface ExtendedUser extends User {
nickname?: string
}
// 权限类型定义
interface UserPermissions {
discovery: boolean // 发现权限
search: boolean // 搜索权限
subscribe: boolean // 订阅权限
manage: boolean // 管理权限
}
// 用户编辑表单数据
const userForm = ref<ExtendedUser>({
id: 0,
@@ -88,6 +88,7 @@ const userForm = ref<ExtendedUser>({
search: true,
subscribe: true,
manage: false,
features: buildDefaultFeaturePermissions(),
},
settings: {
wechat_userid: null,
@@ -127,34 +128,115 @@ const permissionOptions = [
description: t('dialog.userAddEdit.permissions.manageDesc'),
icon: 'mdi-cog-outline',
},
]
] as const
const activePermissionCategory = ref<UserPermissionCategoryKey>('discovery')
// 权限状态计算属性
const userPermissions = computed({
get: () => {
const permissions = userForm.value.permissions as UserPermissions
return {
discovery: permissions?.discovery ?? true,
search: permissions?.search ?? true,
subscribe: permissions?.subscribe ?? true,
manage: permissions?.manage ?? false,
}
return normalizeUserPermissions(userForm.value.permissions as Partial<UserPermissions>)
},
set: (value: UserPermissions) => {
userForm.value.permissions = value
},
})
// 切换权限状态
function togglePermission(key: keyof UserPermissions) {
const permissionFeatureOptions = computed(() =>
USER_PERMISSION_FEATURES.map(feature => ({
...feature,
title: t(feature.titleKey),
description: t(feature.descriptionKey),
})),
)
const activePermissionOption = computed(() =>
permissionOptions.find(option => option.key === activePermissionCategory.value) ?? permissionOptions[0],
)
const activePermissionFeatures = computed(() =>
permissionFeatureOptions.value.filter(feature => feature.permission === activePermissionCategory.value),
)
const enabledCategoryCount = computed(
() => permissionOptions.filter(option => userPermissions.value[option.key]).length,
)
const enabledFeatureCount = computed(
() =>
permissionFeatureOptions.value.filter(
feature => userPermissions.value[feature.permission] && isFeatureEnabled(feature.key),
).length,
)
/** 切换当前查看的权限分类。 */
function selectPermissionCategory(key: UserPermissionCategoryKey) {
activePermissionCategory.value = key
}
/** 切换权限分类启用状态,并聚焦到该分类的功能列表。 */
function togglePermission(key: UserPermissionCategoryKey) {
const currentPermissions = userPermissions.value
userPermissions.value = {
...currentPermissions,
[key]: !currentPermissions[key],
}
activePermissionCategory.value = key
}
// 更新头像
/** 判断功能项是否已启用,缺省功能按启用处理以兼容旧权限数据。 */
function isFeatureEnabled(key: UserPermissionFeatureKey) {
return userPermissions.value.features?.[key] !== false
}
/** 切换单个功能项的启用状态。 */
function togglePermissionFeature(key: UserPermissionFeatureKey) {
userPermissions.value = {
...userPermissions.value,
features: {
...(userPermissions.value.features ?? {}),
[key]: !isFeatureEnabled(key),
},
}
}
/** 统计指定分类下已经勾选的功能数量。 */
function getEnabledFeatureCount(permission: UserPermissionCategoryKey) {
return permissionFeatureOptions.value.filter(
feature => feature.permission === permission && isFeatureEnabled(feature.key),
).length
}
/** 返回指定分类下的功能总数。 */
function getFeatureTotalCount(permission: UserPermissionCategoryKey) {
return permissionFeatureOptions.value.filter(feature => feature.permission === permission).length
}
/** 判断指定分类是否处于功能部分勾选状态。 */
function isPermissionPartiallySelected(permission: UserPermissionCategoryKey) {
const enabledCount = getEnabledFeatureCount(permission)
const totalCount = getFeatureTotalCount(permission)
return enabledCount > 0 && enabledCount < totalCount
}
/** 批量设置当前分类下的所有功能项。 */
function setCategoryFeatures(permission: UserPermissionCategoryKey, enabled: boolean) {
const nextFeatures = { ...(userPermissions.value.features ?? {}) }
for (const feature of permissionFeatureOptions.value.filter(item => item.permission === permission)) {
nextFeatures[feature.key] = enabled
}
userPermissions.value = {
...userPermissions.value,
features: nextFeatures,
}
}
/** 将当前分类下的功能项恢复为默认全选状态。 */
function resetCategoryFeatures(permission: UserPermissionCategoryKey) {
setCategoryFeatures(permission, true)
}
/** 校验并读取用户上传的新头像。 */
function changeAvatar(file: Event) {
const fileReader = new FileReader()
const { files } = file.target as HTMLInputElement
@@ -182,19 +264,19 @@ function changeAvatar(file: Event) {
}
}
// 重置默认头像
/** 将头像恢复为系统默认头像。 */
function resetDefaultAvatar() {
currentAvatar.value = avatar1
$toast.success(t('dialog.userAddEdit.resetAvatarSuccess'))
}
// 还原当前头像
/** 还原为用户当前已保存的头像。 */
function restoreCurrentAvatar() {
currentAvatar.value = userForm.value.avatar
$toast.success(t('dialog.userAddEdit.restoreAvatarSuccess'))
}
// 查询用户信息
/** 从接口查询当前编辑用户信息并回填表单。 */
async function fetchUserInfo() {
try {
userForm.value = await api.get(`user/${props.username}`)
@@ -210,7 +292,7 @@ async function fetchUserInfo() {
}
}
// 调用API 新增用户
/** 调用接口创建新用户。 */
async function addUser() {
if (isAdding.value) {
$toast.error(t('dialog.userAddEdit.creatingUser', { name: userForm.value.name }))
@@ -256,7 +338,7 @@ async function addUser() {
isAdding.value = false
}
// 调用API更新用户信息
/** 调用接口更新当前用户信息。 */
async function updateUser() {
if (isUpdating.value) {
$toast.error(t('dialog.userAddEdit.updatingUser', { name: userForm.value.name }))
@@ -367,7 +449,7 @@ onMounted(() => {
</script>
<template>
<VDialog scrollable max-width="40rem" :fullscreen="!display.mdAndUp.value">
<VDialog scrollable max-width="64rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem :class="props.oper === 'add' ? 'py-3' : 'py-2'">
<template #prepend>
@@ -572,43 +654,144 @@ onMounted(() => {
<span>{{ t('dialog.userAddEdit.permissions.title') }}</span>
</VDivider>
<!-- 权限设置 -->
<div v-if="canControl">
<VRow>
<VCol v-for="option in permissionOptions" :key="option.key" cols="6">
<VCard
:color="userPermissions[option.key as keyof UserPermissions] ? 'primary' : 'surface'"
:variant="userPermissions[option.key as keyof UserPermissions] ? 'tonal' : 'outlined'"
class="cursor-pointer transition-all h-full"
@click="togglePermission(option.key as keyof UserPermissions)"
hover
<div v-if="canControl" class="user-permission-editor">
<div class="permission-section-header">
<div>
<div class="text-subtitle-1 font-weight-medium">
{{ t('dialog.userAddEdit.permissions.categoryTitle') }}
</div>
<div class="text-caption text-medium-emphasis">
{{ t('dialog.userAddEdit.permissions.categoryHint') }}
</div>
</div>
<VChip size="small" color="primary" variant="tonal">
{{
t('dialog.userAddEdit.permissions.summary', {
categories: enabledCategoryCount,
features: enabledFeatureCount,
})
}}
</VChip>
</div>
<div class="permission-category-grid">
<div
v-for="option in permissionOptions"
:key="option.key"
role="button"
tabindex="0"
class="permission-category-option"
:class="{
'is-active': activePermissionCategory === option.key,
'is-enabled': userPermissions[option.key],
'is-partial': isPermissionPartiallySelected(option.key),
}"
@click="selectPermissionCategory(option.key)"
@keydown.enter="selectPermissionCategory(option.key)"
@keydown.space.prevent="selectPermissionCategory(option.key)"
>
<span class="permission-category-option__icon">
<VIcon :icon="option.icon" size="20" />
</span>
<span class="permission-category-option__body">
<span class="permission-category-option__title-row">
<span class="permission-category-option__title">{{ option.title }}</span>
<span class="permission-category-option__count">
{{ getEnabledFeatureCount(option.key) }}/{{ getFeatureTotalCount(option.key) }}
</span>
</span>
<span class="permission-category-option__desc">{{ option.description }}</span>
</span>
<VBtn
:icon="userPermissions[option.key] ? 'mdi-check-circle' : 'mdi-circle-outline'"
:color="userPermissions[option.key] ? 'primary' : 'secondary'"
variant="text"
size="small"
class="permission-category-option__toggle"
@click.stop="togglePermission(option.key)"
/>
</div>
</div>
<div class="permission-feature-panel">
<div class="permission-feature-panel__header">
<div>
<div class="text-subtitle-1 font-weight-medium">
{{ activePermissionOption.title }}{{ t('dialog.userAddEdit.permissions.featureTitleSuffix') }}
</div>
<div class="text-caption text-medium-emphasis">
{{
userPermissions[activePermissionCategory]
? t('dialog.userAddEdit.permissions.featureHint')
: t('dialog.userAddEdit.permissions.disabledCategoryHint')
}}
</div>
</div>
<div class="permission-feature-panel__actions">
<VBtn
size="small"
color="primary"
variant="tonal"
:disabled="!userPermissions[activePermissionCategory]"
@click="setCategoryFeatures(activePermissionCategory, true)"
>
{{ t('dialog.userAddEdit.permissions.selectAll') }}
</VBtn>
<VBtn
size="small"
variant="outlined"
:disabled="!userPermissions[activePermissionCategory]"
@click="setCategoryFeatures(activePermissionCategory, false)"
>
{{ t('dialog.userAddEdit.permissions.clear') }}
</VBtn>
<VBtn
size="small"
variant="outlined"
:disabled="!userPermissions[activePermissionCategory]"
@click="resetCategoryFeatures(activePermissionCategory)"
>
{{ t('dialog.userAddEdit.permissions.default') }}
</VBtn>
</div>
</div>
<div class="permission-feature-list">
<div
v-for="feature in activePermissionFeatures"
:key="feature.key"
role="checkbox"
:aria-checked="isFeatureEnabled(feature.key)"
:aria-disabled="!userPermissions[activePermissionCategory]"
:tabindex="userPermissions[activePermissionCategory] ? 0 : -1"
class="permission-feature-item"
:class="{
'is-enabled': isFeatureEnabled(feature.key),
'is-disabled': !userPermissions[activePermissionCategory],
}"
@click="userPermissions[activePermissionCategory] && togglePermissionFeature(feature.key)"
@keydown.enter="userPermissions[activePermissionCategory] && togglePermissionFeature(feature.key)"
@keydown.space.prevent="userPermissions[activePermissionCategory] && togglePermissionFeature(feature.key)"
>
<VCardText class="d-flex align-center pa-4">
<VAvatar
:color="userPermissions[option.key as keyof UserPermissions] ? 'primary' : 'surface-variant'"
size="40"
class="me-3"
>
<VIcon :icon="option.icon" />
</VAvatar>
<div class="flex-grow-1">
<div class="text-subtitle-1 font-weight-medium d-flex align-center">
{{ option.title }}
<VIcon
v-if="userPermissions[option.key as keyof UserPermissions]"
icon="mdi-check-circle"
color="primary"
size="small"
class="ms-2"
/>
</div>
<div class="text-caption text-medium-emphasis">
{{ option.description }}
</div>
</div>
</VCardText>
</VCard>
</VCol>
</VRow>
<VCheckboxBtn
:model-value="isFeatureEnabled(feature.key)"
:disabled="!userPermissions[activePermissionCategory]"
color="primary"
density="compact"
class="permission-feature-item__check"
@click.stop
@update:model-value="togglePermissionFeature(feature.key)"
/>
<span class="permission-feature-item__icon">
<VIcon :icon="feature.icon" size="20" />
</span>
<span class="permission-feature-item__body">
<span class="permission-feature-item__title">{{ feature.title }}</span>
<span class="permission-feature-item__desc">{{ feature.description }}</span>
</span>
</div>
</div>
</div>
</div>
</VForm>
</VCardText>
@@ -642,3 +825,273 @@ onMounted(() => {
</VCard>
</VDialog>
</template>
<style scoped lang="scss">
.user-permission-editor {
--permission-editor-radius: min(var(--app-surface-radius, 8px), 8px);
--permission-editor-border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
--permission-editor-muted-border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
--permission-editor-panel-bg: rgb(var(--v-theme-surface));
--permission-editor-hover-bg: rgba(var(--v-theme-primary), 0.04);
--permission-editor-active-bg: rgba(var(--v-theme-primary), 0.1);
--permission-editor-selected-bg: rgba(var(--v-theme-primary), 0.08);
display: flex;
flex-direction: column;
gap: 1rem;
}
.permission-section-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.permission-category-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15.5rem, 1fr));
gap: 0.75rem;
}
.permission-category-option,
.permission-feature-item {
border: var(--permission-editor-muted-border);
border-radius: var(--permission-editor-radius);
background: var(--permission-editor-panel-bg);
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
cursor: pointer;
transition: border-color 0.18s ease, background-color 0.18s ease, opacity 0.18s ease;
}
.permission-category-option {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: flex-start;
min-block-size: 5.75rem;
padding: 0.875rem;
text-align: start;
}
.permission-category-option:hover,
.permission-feature-item:hover:not(.is-disabled) {
border-color: rgba(var(--v-theme-primary), 0.36);
background: var(--permission-editor-hover-bg);
}
.permission-category-option:focus-visible,
.permission-feature-item:focus-visible {
outline: 2px solid rgba(var(--v-theme-primary), 0.72);
outline-offset: 2px;
}
.permission-category-option.is-active {
border-color: rgba(var(--v-theme-primary), 0.72);
background: var(--permission-editor-active-bg);
}
.permission-category-option.is-enabled {
border-color: rgba(var(--v-theme-primary), 0.34);
}
.permission-category-option.is-partial .permission-category-option__count {
color: rgb(var(--v-theme-warning));
background: rgba(var(--v-theme-warning), 0.12);
}
.permission-category-option__icon,
.permission-feature-item__icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
border-radius: var(--app-control-radius, 6px);
background: rgba(var(--v-theme-primary), 0.1);
color: rgb(var(--v-theme-primary));
}
.permission-category-option__icon {
inline-size: 2.25rem;
block-size: 2.25rem;
margin-inline-end: 0.75rem;
}
.permission-category-option__body,
.permission-feature-item__body {
min-inline-size: 0;
}
.permission-category-option__title-row {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
min-inline-size: 0;
}
.permission-category-option__title,
.permission-feature-item__title {
display: block;
overflow: hidden;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.permission-category-option__count {
flex: 0 0 auto;
border-radius: var(--app-control-radius, 6px);
padding: 0.125rem 0.5rem;
background: rgba(var(--v-theme-primary), 0.1);
color: rgb(var(--v-theme-primary));
font-size: 0.75rem;
font-weight: 700;
line-height: 1.25rem;
}
.permission-category-option__desc,
.permission-feature-item__desc,
.permission-feature-item__scope {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.75rem;
}
.permission-category-option__desc,
.permission-feature-item__desc {
display: block;
overflow: visible;
line-height: 1.35;
overflow-wrap: anywhere;
white-space: normal;
}
.permission-category-option__toggle {
margin-inline-start: 0.25rem;
}
.permission-feature-panel {
overflow: hidden;
border: var(--permission-editor-border);
border-radius: var(--permission-editor-radius);
background: rgb(var(--v-theme-surface));
}
.permission-feature-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem;
border-block-end: var(--permission-editor-muted-border);
}
.permission-feature-panel__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
}
.permission-feature-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.625rem;
padding: 1rem;
}
.permission-feature-item {
display: grid;
grid-template-columns: auto auto minmax(0, 1fr);
align-items: flex-start;
min-block-size: 4rem;
padding: 0.625rem 0.875rem;
text-align: start;
}
.permission-feature-item.is-disabled {
cursor: not-allowed;
}
.permission-feature-item.is-disabled .permission-feature-item__icon {
opacity: 0.72;
}
.permission-feature-item.is-disabled .permission-feature-item__title,
.permission-feature-item.is-disabled .permission-feature-item__desc {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
}
.permission-feature-item.is-enabled {
background: var(--permission-editor-selected-bg);
}
.permission-feature-item__check {
margin-inline-end: 0.5rem;
}
.permission-feature-item__icon {
inline-size: 2rem;
block-size: 2rem;
margin-inline-end: 0.75rem;
}
.permission-feature-item__body {
display: flex;
flex-direction: column;
}
.permission-feature-item__scope {
max-inline-size: 8rem;
margin-inline-start: 1rem;
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
}
@media (width <= 960px) {
.permission-category-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.permission-feature-list {
grid-template-columns: minmax(0, 1fr);
}
.permission-section-header,
.permission-feature-panel__header {
flex-direction: column;
align-items: stretch;
}
.permission-feature-panel__actions {
justify-content: flex-start;
}
}
@media (width <= 600px) {
.user-permission-editor {
gap: 0.875rem;
}
.permission-category-option {
grid-template-columns: minmax(0, 1fr) auto;
min-block-size: 3.75rem;
padding: 0.75rem;
}
.permission-category-option__icon,
.permission-category-option__desc {
display: none;
}
.permission-category-option__toggle {
margin-inline-start: 0.5rem;
}
.permission-feature-item {
grid-template-columns: auto minmax(0, 1fr);
}
.permission-feature-item__icon {
display: none;
}
}
</style>

View File

@@ -722,7 +722,7 @@ onMounted(() => {
.filter-buttons-grid {
display: grid;
gap: 4px;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.filter-btn-mobile {
@@ -732,10 +732,23 @@ onMounted(() => {
justify-content: center;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: 8px;
block-size: auto;
min-block-size: 48px;
block-size: 56px;
min-block-size: 56px;
min-inline-size: 0;
padding-block: 4px;
padding-inline: 0;
padding-inline: 4px;
}
.filter-btn-mobile :deep(.v-btn__content) {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
inline-size: 100%;
max-inline-size: 100%;
min-inline-size: 0;
line-height: 1.15;
white-space: normal;
}
.filter-icon {
@@ -744,20 +757,20 @@ onMounted(() => {
}
.filter-label {
display: -webkit-box;
overflow: hidden;
font-size: 0.8rem;
line-height: 1.15;
max-inline-size: 100%;
overflow-wrap: anywhere;
text-align: center;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
@media (width <= 600px) {
.filter-buttons-grid {
gap: 6px;
}
.filter-label {
overflow: hidden;
max-inline-size: 100%;
text-overflow: ellipsis;
white-space: nowrap;
}
}
</style>

View File

@@ -12,7 +12,7 @@ import {
type ComputedRef,
type Ref,
} from 'vue'
import type { UserPermissionKey } from '@/utils/permission'
import type { UserPermissionFeatureKey, UserPermissionKey } from '@/utils/permission'
// 声明全局变量类型
declare global {
@@ -31,6 +31,7 @@ export interface DynamicButtonMenuItem {
icon?: string
color?: string
permission?: UserPermissionKey
feature?: UserPermissionFeatureKey
disabled?: boolean
action: () => void
}
@@ -61,11 +62,12 @@ export function useDynamicButton(options: {
onClick?: () => void
menuItems?: MaybeRefValue<DynamicButtonMenuItem[] | undefined>
permission?: UserPermissionKey
feature?: UserPermissionFeatureKey
show?: MaybeRefValue<boolean>
autoRegister?: boolean // 是否自动注册默认为true
}) {
// 提取配置
const { icon, onClick, menuItems, permission, show, autoRegister = true } = options
const { icon, onClick, menuItems, permission, feature, show, autoRegister = true } = options
// 动态按钮相关
const registerDynamicButton = inject<((button: any) => void) | null>('registerDynamicButton', null)
@@ -79,6 +81,7 @@ export function useDynamicButton(options: {
const resolvedShow = computed(() => resolveMaybeRef(show, true))
const resolvedMenuItems = computed(() => resolveMaybeRef(menuItems))
/** 根据当前响应式配置生成可注册的动态按钮对象。 */
function buildDynamicButton() {
const buttonMenuItems = resolvedMenuItems.value
@@ -86,12 +89,13 @@ export function useDynamicButton(options: {
icon: resolvedIcon.value,
action: onClick || (() => {}),
permission,
feature,
show: resolvedShow.value,
menuItems: buttonMenuItems && buttonMenuItems.length > 0 ? buttonMenuItems : undefined,
}
}
// 注册动态按钮
/** 在当前页面激活时注册动态按钮。 */
function setupDynamicButton() {
if (!componentActive.value) return
@@ -133,7 +137,7 @@ export function useDynamicButton(options: {
})
}
// 取消注册动态按钮
/** 清理当前页面注册过的动态按钮。 */
function cleanupDynamicButton() {
if (unregisterDynamicButton && dynamicButtonRegistered.value) {
unregisterDynamicButton()
@@ -147,7 +151,7 @@ export function useDynamicButton(options: {
}
}
// 暴露方法:手动打开对话框
/** 手动触发动态按钮主操作。 */
function openDialog() {
onClick?.()
}

View File

@@ -1,6 +1,6 @@
import type { ComputedRef, Ref } from 'vue'
import { useTabStateRestore } from '@/composables/useStateRestore'
import type { UserPermissionKey } from '@/utils/permission'
import type { UserPermissionFeatureKey, UserPermissionKey } from '@/utils/permission'
// 动态标签页相关类型
interface DynamicHeaderTabButton {
@@ -11,6 +11,7 @@ interface DynamicHeaderTabButton {
class?: string
action?: () => void
permission?: UserPermissionKey
feature?: UserPermissionFeatureKey
show?: boolean | ComputedRef<boolean>
loading?: boolean | ComputedRef<boolean>
dataAttr?: string // 用于VMenu定位的data属性
@@ -20,6 +21,8 @@ interface DynamicHeaderTabItem {
title: string
icon?: string
tab: string
permission?: UserPermissionKey
feature?: UserPermissionFeatureKey
}
interface DynamicHeaderTabConfig {
@@ -30,6 +33,7 @@ interface DynamicHeaderTabConfig {
onUpdateModelValue?: (value: string) => void
}
/** 提供页面动态头部标签的注册、状态恢复和注销能力。 */
export function useDynamicHeaderTab() {
const route = useRoute()
@@ -37,7 +41,7 @@ export function useDynamicHeaderTab() {
const registerDynamicHeaderTab = inject<(tab: DynamicHeaderTabConfig) => void>('registerDynamicHeaderTab')
const unregisterDynamicHeaderTab = inject<() => void>('unregisterDynamicHeaderTab')
// 注册动态标签页
/** 注册当前页面的动态头部标签配置。 */
const registerHeaderTab = (config: {
items: DynamicHeaderTabItem[] | ComputedRef<DynamicHeaderTabItem[]> | Ref<DynamicHeaderTabItem[]>
modelValue: Ref<string>
@@ -184,7 +188,7 @@ export function useDynamicHeaderTab() {
})
}
// 取消注册
/** 取消当前页面注册的动态头部标签。 */
const unregisterHeaderTab = () => {
if (unregisterDynamicHeaderTab) {
unregisterDynamicHeaderTab()

View File

@@ -24,6 +24,7 @@ import {
filterMenusByPermission,
hasItemPermission,
hasPermission,
type UserPermissionFeatureKey,
type UserPermissionKey,
} from '@/utils/permission'
import { usePullDownGesture } from '@/composables/usePullDownGesture'
@@ -119,6 +120,7 @@ interface DynamicHeaderTabButton {
class?: string
action?: () => void
permission?: UserPermissionKey
feature?: UserPermissionFeatureKey
show?: boolean | ComputedRef<boolean>
loading?: boolean | ComputedRef<boolean>
dataAttr?: string
@@ -128,6 +130,8 @@ interface DynamicHeaderTabItem {
title: string
icon?: string
tab: string
permission?: UserPermissionKey
feature?: UserPermissionFeatureKey
}
interface DynamicHeaderTab {
@@ -160,6 +164,8 @@ const unregisterDynamicHeaderTab = () => {
/** 更新当前动态标签页选中值,并通知注册页面同步状态。 */
const handleTabChange = (newValue: string) => {
if (dynamicHeaderTab.value) {
if (!visibleDynamicHeaderTabItems.value.some(item => item.tab === newValue)) return
dynamicHeaderTab.value.modelValue = newValue
// 通知注册的页面更新值
if (dynamicHeaderTab.value.onUpdateModelValue) {
@@ -193,13 +199,15 @@ watch(
{ immediate: false },
)
// 当前路由是否注册了动态标签页。
const hasDynamicHeaderTab = computed(() => {
return (
dynamicHeaderTab.value && dynamicHeaderTab.value.items.length > 0 && dynamicHeaderTab.value.routePath === route.path
)
const visibleDynamicHeaderTabItems = computed(() => {
if (!dynamicHeaderTab.value || dynamicHeaderTab.value.routePath !== route.path) return []
return filterItemsByPermission(dynamicHeaderTab.value.items, userPermissions.value)
})
// 当前路由是否注册了动态标签页。
const hasDynamicHeaderTab = computed(() => visibleDynamicHeaderTabItems.value.length > 0)
// 水平布局下动态标签页会并入顶部导航三级菜单,不再额外显示标签页栏。
const showDynamicHeaderTab = computed(() => hasDynamicHeaderTab.value && !showHorizontalThemeNav.value)
@@ -384,10 +392,10 @@ function getHorizontalNavTabs(item: NavMenu): DynamicHeaderTabItem[] {
const targetPath = normalizeMenuPath(item.to)
if (targetPath && isHorizontalNavActive(item) && hasDynamicHeaderTab.value) {
return dynamicHeaderTab.value?.items ?? []
return visibleDynamicHeaderTabItems.value
}
return item.tabs ?? []
return filterItemsByPermission(item.tabs ?? [], userPermissions.value)
}
/** 在目标页面注册动态标签后应用此前暂存的标签切换。 */
@@ -397,7 +405,7 @@ function applyPendingHorizontalTab() {
const pending = pendingHorizontalTab.value
if (normalizeMenuPath(route.path) !== pending.path) return
const tabExists = dynamicHeaderTab.value?.items.some(item => item.tab === pending.tab)
const tabExists = visibleDynamicHeaderTabItems.value.some(item => item.tab === pending.tab)
if (!tabExists) return
handleTabChange(pending.tab)
@@ -673,7 +681,7 @@ onMounted(async () => {
<template #dynamic-header-tab>
<div v-if="showDynamicHeaderTab">
<HeaderTab
:items="dynamicHeaderTab!.items"
:items="visibleDynamicHeaderTabItems"
:model-value="dynamicHeaderTab!.modelValue"
@update:model-value="handleTabChange"
>

View File

@@ -119,6 +119,7 @@ interface DynamicButton {
icon: string
action: () => void
permission?: DynamicButtonMenuItem['permission']
feature?: DynamicButtonMenuItem['feature']
show: boolean
routePath?: string // 添加路径属性,用于标识哪个路由注册的
menuItems?: DynamicButtonMenuItem[]

View File

@@ -2487,6 +2487,44 @@ export default {
subscribeDesc: 'Manage movie and TV show subscriptions',
manage: 'Manage',
manageDesc: 'Access download management and site management etc.',
categoryTitle: 'Permission Categories',
categoryHint: 'Enable a category first, then choose the concrete features available in it',
summary: '{categories} categories / {features} features enabled',
featureTitleSuffix: ' Features',
featureHint: 'Disabled features are hidden from navigation, app center, and direct route access',
disabledCategoryHint: 'This category is disabled, so its features are not accessible',
selectAll: 'Select All',
clear: 'Clear',
default: 'Default',
featureScope: 'Feature scope',
features: {
recommend: 'Recommendations',
explore: 'Explore',
resourceSearch: 'Resource Search',
movieSubscribe: 'Movie Subscriptions',
tvSubscribe: 'TV Subscriptions',
calendar: 'Subscription Calendar',
subscribeShare: 'Subscription Share',
workflow: 'Workflow',
downloading: 'Download Manager',
history: 'Media Organize',
fileManager: 'File Manager',
site: 'Site Manager',
},
featureDescriptions: {
recommend: 'Recommendation page and ranking content',
explore: 'Explore page, discovery sources, and media detail browsing',
resourceSearch: 'Site resource search and result page',
movieSubscribe: 'Movie subscription list and popular movies',
tvSubscribe: 'TV subscription list, popular shows, and share tab',
calendar: 'Subscription schedule and calendar view',
subscribeShare: 'Subscription share list access',
workflow: 'Workflow list and shared workflows',
downloading: 'Current download task management',
history: 'Organize history and import records',
fileManager: 'File browsing and file management',
site: 'Site list, status, and maintenance',
},
},
},
searchBar: {

View File

@@ -2442,6 +2442,44 @@ export default {
subscribeDesc: '管理电影和电视剧订阅',
manage: '管理',
manageDesc: '访问下载管理和站点管理等功能',
categoryTitle: '权限分类',
categoryHint: '先启用权限分类,再选择该分类下允许访问的具体功能',
summary: '已启用 {categories} 类 / {features} 项功能',
featureTitleSuffix: '功能',
featureHint: '取消勾选后,该功能会从导航、应用中心和路由访问中隐藏',
disabledCategoryHint: '当前分类未启用,功能项将随分类一起不可访问',
selectAll: '全选',
clear: '清空',
default: '默认',
featureScope: '功能范围',
features: {
recommend: '推荐',
explore: '探索',
resourceSearch: '资源搜索',
movieSubscribe: '电影订阅',
tvSubscribe: '电视剧订阅',
calendar: '订阅日历',
subscribeShare: '订阅分享',
workflow: '工作流',
downloading: '下载管理',
history: '媒体整理',
fileManager: '文件管理',
site: '站点管理',
},
featureDescriptions: {
recommend: '推荐页与榜单内容入口',
explore: '探索页、媒体发现与详情浏览',
resourceSearch: '站点资源搜索与资源结果页',
movieSubscribe: '电影订阅列表和热门电影',
tvSubscribe: '电视剧订阅列表、热门剧集和分享标签',
calendar: '订阅排期与日历视图',
subscribeShare: '订阅分享列表访问',
workflow: '工作流列表与分享入口',
downloading: '当前下载任务管理',
history: '整理历史与入库记录',
fileManager: '文件浏览和文件管理',
site: '站点列表、站点状态和站点维护',
},
},
},
searchBar: {

View File

@@ -2441,6 +2441,44 @@ export default {
subscribeDesc: '管理電影和電視劇訂閱',
manage: '管理',
manageDesc: '存取下載管理和站點管理等功能',
categoryTitle: '權限分類',
categoryHint: '先啟用權限分類,再選擇該分類下允許存取的具體功能',
summary: '已啟用 {categories} 類 / {features} 項功能',
featureTitleSuffix: '功能',
featureHint: '取消勾選後,該功能會從導覽、應用中心和路由存取中隱藏',
disabledCategoryHint: '目前分類未啟用,功能項將隨分類一起不可存取',
selectAll: '全選',
clear: '清空',
default: '預設',
featureScope: '功能範圍',
features: {
recommend: '推薦',
explore: '探索',
resourceSearch: '資源搜索',
movieSubscribe: '電影訂閱',
tvSubscribe: '電視劇訂閱',
calendar: '訂閱日曆',
subscribeShare: '訂閱分享',
workflow: '工作流',
downloading: '下載管理',
history: '媒體整理',
fileManager: '文件管理',
site: '站點管理',
},
featureDescriptions: {
recommend: '推薦頁與榜單內容入口',
explore: '探索頁、媒體發現與詳情瀏覽',
resourceSearch: '站點資源搜索與資源結果頁',
movieSubscribe: '電影訂閱列表和熱門電影',
tvSubscribe: '電視劇訂閱列表、熱門劇集和分享標籤',
calendar: '訂閱排期與日曆視圖',
subscribeShare: '訂閱分享列表存取',
workflow: '工作流列表與分享入口',
downloading: '目前下載任務管理',
history: '整理歷史與入庫記錄',
fileManager: '文件瀏覽和文件管理',
site: '站點列表、站點狀態和站點維護',
},
},
},
searchBar: {

View File

@@ -1,6 +1,7 @@
import { useGlobalSettingsStore } from '@/stores'
import type { NavMenu, NavMenuTabItem } from '@/@layouts/types'
import type { Composer } from 'vue-i18n'
import { PERMISSION_FEATURE } from '@/utils/permission'
/** 构建当前语言与全局模式对应的主导航菜单。 */
export function getNavMenus(t: Composer['t']): NavMenu[] {
@@ -28,6 +29,7 @@ export function getNavMenus(t: Composer['t']): NavMenu[] {
header: t('menu.start'),
admin: false,
permission: 'search',
feature: PERMISSION_FEATURE.SEARCH_RESOURCE,
},
{
title: t('navItems.recommend'),
@@ -38,6 +40,7 @@ export function getNavMenus(t: Composer['t']): NavMenu[] {
admin: false,
footer: true,
permission: 'discovery',
feature: PERMISSION_FEATURE.DISCOVERY_RECOMMEND,
tabs: getRecommendTabs(t),
},
{
@@ -49,6 +52,7 @@ export function getNavMenus(t: Composer['t']): NavMenu[] {
admin: false,
footer: true,
permission: 'discovery',
feature: PERMISSION_FEATURE.DISCOVERY_EXPLORE,
tabs: getDiscoverTabs(t),
},
{
@@ -61,6 +65,7 @@ export function getNavMenus(t: Composer['t']): NavMenu[] {
admin: false,
footer: false,
permission: 'subscribe',
feature: PERMISSION_FEATURE.SUBSCRIBE_MOVIE,
tabs: getSubscribeMovieTabs(t),
},
{
@@ -73,6 +78,7 @@ export function getNavMenus(t: Composer['t']): NavMenu[] {
admin: false,
footer: false,
permission: 'subscribe',
feature: PERMISSION_FEATURE.SUBSCRIBE_TV,
tabs: getSubscribeTvTabs(t),
},
{
@@ -85,6 +91,7 @@ export function getNavMenus(t: Composer['t']): NavMenu[] {
admin: true,
footer: false,
permission: 'manage',
feature: PERMISSION_FEATURE.MANAGE_WORKFLOW,
tabs: getWorkflowTabs(t),
},
{
@@ -96,6 +103,7 @@ export function getNavMenus(t: Composer['t']): NavMenu[] {
header: t('menu.subscribe'),
admin: false,
permission: 'subscribe',
feature: PERMISSION_FEATURE.SUBSCRIBE_CALENDAR,
},
{
title: t('navItems.downloadManager'),
@@ -105,6 +113,7 @@ export function getNavMenus(t: Composer['t']): NavMenu[] {
header: t('menu.organize'),
admin: false,
permission: 'manage',
feature: PERMISSION_FEATURE.MANAGE_DOWNLOADING,
},
{
title: t('navItems.mediaOrganize'),
@@ -114,6 +123,7 @@ export function getNavMenus(t: Composer['t']): NavMenu[] {
header: t('menu.organize'),
admin: true,
permission: 'manage',
feature: PERMISSION_FEATURE.MANAGE_HISTORY,
},
{
title: t('navItems.fileManager'),
@@ -123,6 +133,7 @@ export function getNavMenus(t: Composer['t']): NavMenu[] {
header: t('menu.organize'),
admin: true,
permission: 'manage',
feature: PERMISSION_FEATURE.MANAGE_FILEMANAGER,
},
{
title: t('navItems.pluginManager'),
@@ -142,6 +153,7 @@ export function getNavMenus(t: Composer['t']): NavMenu[] {
header: t('menu.system'),
admin: true,
permission: 'manage',
feature: PERMISSION_FEATURE.MANAGE_SITE,
},
{
title: t('navItems.userManager'),

View File

@@ -2,7 +2,14 @@ import { createRouter, createWebHashHistory } from 'vue-router'
import { configureNProgress } from '@/api/nprogress'
import { useAuthStore, usePluginSidebarNavStore, useUserStore } from '@/stores'
import { setNavigatingState as setRequestNavigatingState } from '@/utils/requestOptimizer'
import { buildUserPermissionContext, hasPermission, type UserPermissionKey } from '@/utils/permission'
import {
buildPluginPermissionFeatureKey,
buildUserPermissionContext,
hasItemPermission,
PERMISSION_FEATURE,
type PermissionProtectedItem,
type UserPermissionKey,
} from '@/utils/permission'
// Nprogress
configureNProgress()
@@ -45,6 +52,7 @@ const router = createRouter({
keepAlive: true,
requiresAuth: true,
permission: 'discovery',
feature: PERMISSION_FEATURE.DISCOVERY_RECOMMEND,
},
},
{
@@ -54,6 +62,7 @@ const router = createRouter({
keepAlive: true,
requiresAuth: true,
permission: 'discovery',
feature: PERMISSION_FEATURE.DISCOVERY_EXPLORE,
},
},
{
@@ -63,6 +72,7 @@ const router = createRouter({
keepAlive: true,
requiresAuth: true,
permission: 'search',
feature: PERMISSION_FEATURE.SEARCH_RESOURCE,
},
},
{
@@ -73,6 +83,7 @@ const router = createRouter({
keepAliveKey: 'subscribe-movie',
requiresAuth: true,
permission: 'subscribe',
feature: PERMISSION_FEATURE.SUBSCRIBE_MOVIE,
subType: '电影',
},
},
@@ -84,6 +95,7 @@ const router = createRouter({
keepAliveKey: 'subscribe-tv',
requiresAuth: true,
permission: 'subscribe',
feature: PERMISSION_FEATURE.SUBSCRIBE_TV,
subType: '电视剧',
},
},
@@ -93,6 +105,7 @@ const router = createRouter({
meta: {
requiresAuth: true,
permission: 'subscribe',
feature: PERMISSION_FEATURE.SUBSCRIBE_SHARE,
},
},
{
@@ -102,6 +115,7 @@ const router = createRouter({
keepAlive: true,
requiresAuth: true,
permission: 'manage',
feature: PERMISSION_FEATURE.MANAGE_WORKFLOW,
},
},
{
@@ -111,6 +125,7 @@ const router = createRouter({
keepAlive: true,
requiresAuth: true,
permission: 'subscribe',
feature: PERMISSION_FEATURE.SUBSCRIBE_CALENDAR,
},
},
{
@@ -120,6 +135,7 @@ const router = createRouter({
keepAlive: true,
requiresAuth: true,
permission: 'manage',
feature: PERMISSION_FEATURE.MANAGE_DOWNLOADING,
},
},
{
@@ -129,6 +145,7 @@ const router = createRouter({
keepAlive: true,
requiresAuth: true,
permission: 'manage',
feature: PERMISSION_FEATURE.MANAGE_HISTORY,
hideFooter: true,
},
},
@@ -139,6 +156,7 @@ const router = createRouter({
keepAlive: true,
requiresAuth: true,
permission: 'manage',
feature: PERMISSION_FEATURE.MANAGE_SITE,
},
},
{
@@ -193,6 +211,7 @@ const router = createRouter({
keepAliveByFullPath: true,
requiresAuth: true,
permission: 'discovery',
feature: PERMISSION_FEATURE.DISCOVERY_EXPLORE,
},
},
{
@@ -217,6 +236,7 @@ const router = createRouter({
meta: {
requiresAuth: true,
permission: 'discovery',
feature: PERMISSION_FEATURE.DISCOVERY_EXPLORE,
},
},
{
@@ -226,6 +246,7 @@ const router = createRouter({
keepAlive: true,
requiresAuth: true,
permission: 'manage',
feature: PERMISSION_FEATURE.MANAGE_FILEMANAGER,
hideFooter: true,
},
},
@@ -264,13 +285,16 @@ const router = createRouter({
],
})
async function getRoutePermission(to: any): Promise<UserPermissionKey | undefined> {
async function getRoutePermission(to: any): Promise<PermissionProtectedItem> {
if (to.meta.permission) {
return to.meta.permission as UserPermissionKey
return {
permission: to.meta.permission as UserPermissionKey,
feature: to.meta.feature,
}
}
if (to.name !== 'plugin-app') {
return undefined
return {}
}
const pluginId = String(to.params.pluginId || '')
@@ -279,7 +303,12 @@ async function getRoutePermission(to: any): Promise<UserPermissionKey | undefine
await pluginSidebarNavStore.ensureSidebarNav()
const navItem = pluginSidebarNavStore.items.find(item => item.plugin_id === pluginId && item.nav_key === navKey)
return (navItem?.permission || undefined) as UserPermissionKey | undefined
if (!navItem) return {}
return {
permission: (navItem.permission || undefined) as UserPermissionKey | undefined,
feature: buildPluginPermissionFeatureKey(pluginId, navKey),
}
}
// 路由导航守卫
@@ -299,15 +328,15 @@ router.beforeEach(async (to: any, from: any, next: any) => {
next('/login')
} else if (to.meta.requiresAuth) {
const routePermission = await getRoutePermission(to)
if (!routePermission) {
if (!routePermission.permission && !routePermission.feature) {
next()
return
}
const userStore = useUserStore()
const allowed = hasPermission(
buildUserPermissionContext(userStore.superUser, userStore.permissions),
const allowed = hasItemPermission(
routePermission,
buildUserPermissionContext(userStore.superUser, userStore.permissions),
)
if (!allowed) {
setRequestNavigatingState(false)

View File

@@ -1,22 +1,182 @@
// 权限类型定义
export type UserPermissionCategoryKey = 'discovery' | 'search' | 'subscribe' | 'manage'
export type UserPermissionKey = UserPermissionCategoryKey | 'admin'
export type UserPermissionFeatureKey = string
export type UserPermissionFeatureMap = Record<UserPermissionFeatureKey, boolean>
export interface UserPermissionFeatureOption {
key: UserPermissionFeatureKey
permission: UserPermissionCategoryKey
titleKey: string
descriptionKey: string
icon: string
path?: string
}
export interface UserPermissions {
discovery: boolean // 发现权限
search: boolean // 搜索权限
subscribe: boolean // 订阅权限
manage: boolean // 管理权限
admin?: boolean // 管理员权限,仅用于前端入口标识,实际由 is_superuser 决定
features?: UserPermissionFeatureMap // 功能级权限,缺省功能默认继承分类权限
}
export type UserPermissionKey = keyof UserPermissions
export type UserPermissionContext = UserPermissions & { is_superuser?: boolean; [key: string]: unknown }
export type PermissionProtectedItem = { permission?: UserPermissionKey }
export type PermissionProtectedItem = { permission?: UserPermissionKey; feature?: UserPermissionFeatureKey }
// 构造权限检查上下文,统一超级管理员标记与功能权限字段。
export const PERMISSION_FEATURE = {
DISCOVERY_RECOMMEND: 'discovery.recommend',
DISCOVERY_EXPLORE: 'discovery.explore',
SEARCH_RESOURCE: 'search.resource',
SUBSCRIBE_MOVIE: 'subscribe.movie',
SUBSCRIBE_TV: 'subscribe.tv',
SUBSCRIBE_CALENDAR: 'subscribe.calendar',
SUBSCRIBE_SHARE: 'subscribe.share',
MANAGE_WORKFLOW: 'manage.workflow',
MANAGE_DOWNLOADING: 'manage.downloading',
MANAGE_HISTORY: 'manage.history',
MANAGE_FILEMANAGER: 'manage.filemanager',
MANAGE_SITE: 'manage.site',
} as const
export const USER_PERMISSION_FEATURES: UserPermissionFeatureOption[] = [
{
key: PERMISSION_FEATURE.DISCOVERY_RECOMMEND,
permission: 'discovery',
titleKey: 'dialog.userAddEdit.permissions.features.recommend',
descriptionKey: 'dialog.userAddEdit.permissions.featureDescriptions.recommend',
icon: 'mdi-star-outline',
path: '/recommend',
},
{
key: PERMISSION_FEATURE.DISCOVERY_EXPLORE,
permission: 'discovery',
titleKey: 'dialog.userAddEdit.permissions.features.explore',
descriptionKey: 'dialog.userAddEdit.permissions.featureDescriptions.explore',
icon: 'mdi-apple-safari',
path: '/discover',
},
{
key: PERMISSION_FEATURE.SEARCH_RESOURCE,
permission: 'search',
titleKey: 'dialog.userAddEdit.permissions.features.resourceSearch',
descriptionKey: 'dialog.userAddEdit.permissions.featureDescriptions.resourceSearch',
icon: 'mdi-magnify',
path: '/resource',
},
{
key: PERMISSION_FEATURE.SUBSCRIBE_MOVIE,
permission: 'subscribe',
titleKey: 'dialog.userAddEdit.permissions.features.movieSubscribe',
descriptionKey: 'dialog.userAddEdit.permissions.featureDescriptions.movieSubscribe',
icon: 'mdi-movie-open-outline',
path: '/subscribe/movie',
},
{
key: PERMISSION_FEATURE.SUBSCRIBE_TV,
permission: 'subscribe',
titleKey: 'dialog.userAddEdit.permissions.features.tvSubscribe',
descriptionKey: 'dialog.userAddEdit.permissions.featureDescriptions.tvSubscribe',
icon: 'mdi-television',
path: '/subscribe/tv',
},
{
key: PERMISSION_FEATURE.SUBSCRIBE_CALENDAR,
permission: 'subscribe',
titleKey: 'dialog.userAddEdit.permissions.features.calendar',
descriptionKey: 'dialog.userAddEdit.permissions.featureDescriptions.calendar',
icon: 'mdi-calendar',
path: '/calendar',
},
{
key: PERMISSION_FEATURE.SUBSCRIBE_SHARE,
permission: 'subscribe',
titleKey: 'dialog.userAddEdit.permissions.features.subscribeShare',
descriptionKey: 'dialog.userAddEdit.permissions.featureDescriptions.subscribeShare',
icon: 'mdi-share-variant',
path: '/subscribe-share',
},
{
key: PERMISSION_FEATURE.MANAGE_WORKFLOW,
permission: 'manage',
titleKey: 'dialog.userAddEdit.permissions.features.workflow',
descriptionKey: 'dialog.userAddEdit.permissions.featureDescriptions.workflow',
icon: 'mdi-state-machine',
path: '/workflow',
},
{
key: PERMISSION_FEATURE.MANAGE_DOWNLOADING,
permission: 'manage',
titleKey: 'dialog.userAddEdit.permissions.features.downloading',
descriptionKey: 'dialog.userAddEdit.permissions.featureDescriptions.downloading',
icon: 'mdi-download-outline',
path: '/downloading',
},
{
key: PERMISSION_FEATURE.MANAGE_HISTORY,
permission: 'manage',
titleKey: 'dialog.userAddEdit.permissions.features.history',
descriptionKey: 'dialog.userAddEdit.permissions.featureDescriptions.history',
icon: 'mdi-folder-play-outline',
path: '/history',
},
{
key: PERMISSION_FEATURE.MANAGE_FILEMANAGER,
permission: 'manage',
titleKey: 'dialog.userAddEdit.permissions.features.fileManager',
descriptionKey: 'dialog.userAddEdit.permissions.featureDescriptions.fileManager',
icon: 'mdi-folder-multiple-outline',
path: '/filemanager',
},
{
key: PERMISSION_FEATURE.MANAGE_SITE,
permission: 'manage',
titleKey: 'dialog.userAddEdit.permissions.features.site',
descriptionKey: 'dialog.userAddEdit.permissions.featureDescriptions.site',
icon: 'mdi-web',
path: '/site',
},
]
/** 构造功能级权限默认值,新建用户默认不收窄已拥有的分类权限。 */
export function buildDefaultFeaturePermissions(enabled = true): UserPermissionFeatureMap {
return Object.fromEntries(USER_PERMISSION_FEATURES.map(feature => [feature.key, enabled]))
}
/** 构造插件导航的功能权限键,供动态插件入口复用。 */
export function buildPluginPermissionFeatureKey(pluginId: string, navKey = 'main'): UserPermissionFeatureKey {
return `plugin.${pluginId}.${navKey}`
}
/** 判断传入值是否为可安全读取的普通对象。 */
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/** 规整用户权限数据,兼容没有 features 字段的历史用户。 */
export function normalizeUserPermissions(permissions: Partial<UserPermissions> | null | undefined = {}): UserPermissions {
const permissionData = permissions ?? {}
const rawFeatures = isRecord(permissionData.features) ? permissionData.features : {}
const features = Object.fromEntries(
Object.entries(rawFeatures).filter(([, value]) => typeof value === 'boolean'),
) as UserPermissionFeatureMap
return {
discovery: permissionData.discovery ?? DEFAULT_PERMISSIONS.discovery,
search: permissionData.search ?? DEFAULT_PERMISSIONS.search,
subscribe: permissionData.subscribe ?? DEFAULT_PERMISSIONS.subscribe,
manage: permissionData.manage ?? DEFAULT_PERMISSIONS.manage,
admin: permissionData.admin ?? DEFAULT_PERMISSIONS.admin,
features,
}
}
/** 构造权限检查上下文,统一超级管理员标记、分类权限与功能权限字段。 */
export function buildUserPermissionContext(isSuperuser: boolean, permissions: Partial<UserPermissions> = {}): UserPermissionContext {
return {
is_superuser: isSuperuser,
...DEFAULT_PERMISSIONS,
...permissions,
...normalizeUserPermissions(permissions),
}
}
@@ -27,6 +187,7 @@ export const DEFAULT_PERMISSIONS: UserPermissions = {
subscribe: true,
manage: false,
admin: false,
features: {},
}
// 管理员权限配置
@@ -36,9 +197,10 @@ export const ADMIN_PERMISSIONS: UserPermissions = {
subscribe: true,
manage: true,
admin: true,
features: buildDefaultFeaturePermissions(),
}
// 权限检查函数
/** 检查用户是否拥有指定权限分类。 */
export function hasPermission(userPermissions: any, permission: UserPermissionKey): boolean {
// 如果用户是超级用户,拥有所有权限
if (userPermissions?.is_superuser === true) {
@@ -55,31 +217,53 @@ export function hasPermission(userPermissions: any, permission: UserPermissionKe
return permissions[permission] === true
}
// 批量权限检查
/** 检查用户是否拥有指定功能权限,缺省功能项默认放行以兼容历史数据。 */
export function hasFeaturePermission(
userPermissions: any,
feature?: UserPermissionFeatureKey,
permission?: UserPermissionKey,
): boolean {
if (!feature) {
return true
}
if (userPermissions?.is_superuser === true) {
return true
}
if (permission && !hasPermission(userPermissions, permission)) {
return false
}
const features = isRecord(userPermissions?.features) ? userPermissions.features : {}
return features[feature] !== false
}
/** 批量检查是否拥有任一权限分类。 */
export function hasAnyPermission(userPermissions: any, permissionList: UserPermissionKey[]): boolean {
return permissionList.some(permission => hasPermission(userPermissions, permission))
}
// 检查是否有所有权限
/** 批量检查是否拥有全部权限分类。 */
export function hasAllPermissions(userPermissions: any, permissionList: UserPermissionKey[]): boolean {
return permissionList.every(permission => hasPermission(userPermissions, permission))
}
// 统一检查带 permission 字段的入口,避免菜单、按钮、快捷入口各自实现判断。
/** 统一检查带 permission / feature 字段的入口,避免菜单、按钮、快捷入口各自实现判断。 */
export function hasItemPermission(item: PermissionProtectedItem, userPermissions: any): boolean {
if (!item.permission) {
return true
if (item.permission && !hasPermission(userPermissions, item.permission)) {
return false
}
return hasPermission(userPermissions, item.permission)
return hasFeaturePermission(userPermissions, item.feature, item.permission)
}
// 根据权限过滤带 permission 字段的入口
/** 根据权限过滤带 permission / feature 字段的入口。 */
export function filterItemsByPermission<T extends PermissionProtectedItem>(items: T[], userPermissions: any): T[] {
return items.filter(item => hasItemPermission(item, userPermissions))
}
// 根据权限过滤菜单项
/** 根据权限过滤菜单项。 */
export function filterMenusByPermission(menus: any[], userPermissions: any): any[] {
return filterItemsByPermission(menus, userPermissions)
}

View File

@@ -2,7 +2,7 @@ import type { Composer } from 'vue-i18n'
import type { NavMenu } from '@/@layouts/types'
import type { PluginSidebarNavItem } from '@/api/types'
import { pluginSidebarSectionToHeaderKey } from '@/router/i18n-menu'
import { filterMenusByPermission } from '@/utils/permission'
import { buildPluginPermissionFeatureKey, filterMenusByPermission } from '@/utils/permission'
export type PluginNavMenuEntry = {
navMenu: NavMenu & { permission?: string }
@@ -31,6 +31,7 @@ export function navMenuFromPluginSidebarItem(
},
header,
permission: item.permission ?? undefined,
feature: buildPluginPermissionFeatureKey(item.plugin_id, item.nav_key),
} as NavMenu & { permission?: string }
}