feat(admin): 用户管理新增分组列、分组筛选与专属分组一键替换

- 新增分组列:展示用户的专属/公开分组,支持 hover 查看详情
- 新增分组筛选:下拉选择或模糊搜索分组名过滤用户
- 专属分组替换:点击专属分组弹出操作菜单,选择目标分组后
  自动授予新分组权限、迁移绑定的 Key、移除旧分组权限
- 后端新增 POST /admin/users/:id/replace-group 端点,事务内
  完成分组替换并失效认证缓存
This commit is contained in:
QTom
2026-03-18 23:28:11 +08:00
parent 0236b97d49
commit ba7d2aecbb
29 changed files with 594 additions and 9 deletions

View File

@@ -79,7 +79,8 @@
'sticky-header-cell py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400',
getAdaptivePaddingClass(),
{ 'cursor-pointer hover:bg-gray-100 dark:hover:bg-dark-700': column.sortable },
getStickyColumnClass(column, index)
getStickyColumnClass(column, index),
column.class
]"
@click="column.sortable && handleSort(column.key)"
>
@@ -168,7 +169,8 @@
:class="[
'whitespace-nowrap py-4 text-sm text-gray-900 dark:text-gray-100',
getAdaptivePaddingClass(),
getStickyColumnClass(column, colIndex)
getStickyColumnClass(column, colIndex),
column.class
]"
>
<slot :name="`cell-${column.key}`"

View File

@@ -77,7 +77,13 @@
]"
>
<slot name="option" :option="option" :selected="isSelected(option)">
<span class="select-option-label">{{ getOptionLabel(option) }}</span>
<Icon
v-if="option._creatable"
name="search"
size="sm"
class="flex-shrink-0 text-gray-400"
/>
<span class="select-option-label" :class="option._creatable && 'italic text-gray-500 dark:text-dark-300'">{{ getOptionLabel(option) }}</span>
<Icon
v-if="isSelected(option)"
name="check"
@@ -127,6 +133,8 @@ interface Props {
emptyText?: string
valueKey?: string
labelKey?: string
creatable?: boolean
creatablePrefix?: string
}
interface Emits {
@@ -138,6 +146,8 @@ const props = withDefaults(defineProps<Props>(), {
disabled: false,
error: false,
searchable: false,
creatable: false,
creatablePrefix: '',
valueKey: 'value',
labelKey: 'label'
})
@@ -217,6 +227,10 @@ const selectedLabel = computed(() => {
if (selectedOption.value) {
return getOptionLabel(selectedOption.value)
}
// In creatable mode, show the raw value if no matching option
if (props.creatable && props.modelValue) {
return String(props.modelValue)
}
return placeholderText.value
})
@@ -231,6 +245,12 @@ const filteredOptions = computed(() => {
if (opt.description && String(opt.description).toLowerCase().includes(query)) return true
return false
})
// In creatable mode, always prepend a fuzzy search option
if (props.creatable && searchQuery.value.trim()) {
const trimmed = searchQuery.value.trim()
const prefix = props.creatablePrefix || t('common.search')
opts = [{ [props.valueKey]: trimmed, [props.labelKey]: `${prefix} "${trimmed}"`, _creatable: true }, ...opts]
}
}
return opts
})

View File

@@ -6,5 +6,6 @@ export interface Column {
key: string
label: string
sortable?: boolean
class?: string
formatter?: (value: any, row: any) => string
}