feat(admin): /admin/users 新增按用户 API Key 所在分组过滤

- 支持专用/公开/订阅/已禁用四类分组:按 api_keys.group_id 精确匹配,
  排除软删除 key(EXISTS + DeletedAtIsNil);已禁用分组单独成区,
  覆盖 key 仍挂在禁用分组上的用户
- 后端:UserListFilters.APIKeyGroupID;handler 解析 api_key_group_id;
  repo HasAPIKeysWith 谓词;GetAllGroupsIncludingInactive 新接口
  (/admin/groups/all?include_inactive=true)
- 前端:下拉按类型分区单选;UsersView 独立 allGroupsForApiKeyFilter
  loader;分区标题用负数哨兵值修复 Vue :key 重复问题
- 测试:repo 集成 5/5(含软删除排除、多 key 去重、叠加 status 过滤)、
  handler 单测 5/5、前端 vitest 6/6

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DaydreamCodingandClaude Opus 4.8 authored and QTom committed 2026-06-08 22:54:01 +08:00
1 parent be01744565
commit 329414ea4f
15 files changed
+529 -12

No files matched your search

+12
View File
@@ -57,6 +57,17 @@ export async function getAll(platform?: GroupPlatform): Promise<AdminGroup[]> {
return data
}
/**
* Get ALL groups including disabled ones — used by the API Key group filter so
* that admins can filter users whose keys are still bound to a now-disabled group.
*/
export async function getAllIncludingInactive(): Promise<AdminGroup[]> {
const { data } = await apiClient.get<AdminGroup[]>('/admin/groups/all', {
params: { include_inactive: true }
})
return data
}
/**
* Get active groups by platform
* @param platform - Platform to filter by
@@ -322,6 +333,7 @@ export const groupsAPI = {
list,
getAll,
getByPlatform,
getAllIncludingInactive,
getById,
getModelsListCandidates,
create,
+2
View File
@@ -60,6 +60,7 @@ export async function list(
role?: 'admin' | 'user'
search?: string
group_name?: string // fuzzy filter by allowed group name
api_key_group_id?: number // filter users by the group their API keys are bound to
attributes?: Record<number, string> // attributeId -> value
include_subscriptions?: boolean
sort_by?: string
@@ -77,6 +78,7 @@ export async function list(
role: filters?.role,
search: filters?.search,
group_name: filters?.group_name,
api_key_group_id: filters?.api_key_group_id,
include_subscriptions: filters?.include_subscriptions,
sort_by: filters?.sort_by,
sort_order: filters?.sort_order
+10
View File
@@ -1796,6 +1796,16 @@ export default {
allGroups: 'All Groups',
searchGroups: 'Search groups...',
fuzzySearch: 'Fuzzy search',
apiKeyGroupFilter: 'API Key Group',
apiKeyGroupExclusive: 'Exclusive Groups',
apiKeyGroupPublic: 'Public Groups',
apiKeyGroupSubscription: 'Subscription Groups',
apiKeyGroupDisabled: 'Disabled Groups',
authorizedGroupFilter: 'Authorized Group',
allAuthorizedGroups: 'All Authorized Groups',
searchAuthorizedGroups: 'Search authorized groups...',
allApiKeyGroups: 'All API Key Groups',
searchApiKeyGroups: 'Search API Key groups...',
admin: 'Admin',
user: 'User',
disabled: 'Disabled',
+10
View File
@@ -1820,6 +1820,16 @@ export default {
allGroups: '全部分组',
searchGroups: '搜索分组...',
fuzzySearch: '模糊搜索',
apiKeyGroupFilter: 'API Key 分组',
apiKeyGroupExclusive: '专用分组',
apiKeyGroupPublic: '公开分组',
apiKeyGroupSubscription: '订阅分组',
apiKeyGroupDisabled: '已禁用分组',
authorizedGroupFilter: '授权分组',
allAuthorizedGroups: '全部授权分组',
searchAuthorizedGroups: '搜索授权分组...',
allApiKeyGroups: '全部 API Key 分组',
searchApiKeyGroups: '搜索 API Key 分组...',
statusFilter: '状态筛选',
allStatuses: '全部状态',
admin: '管理员',
+52 -5
View File
@@ -56,7 +56,18 @@
searchable
creatable
:creatable-prefix="t('admin.users.fuzzySearch')"
:search-placeholder="t('admin.users.searchGroups')"
:search-placeholder="t('admin.users.searchAuthorizedGroups')"
@change="applyFilter"
/>
</div>
<!-- API Key Group Filter (visible when enabled) -->
<div v-if="visibleFilters.has('apiKeyGroup')" class="w-full sm:w-44">
<Select
v-model="filters.apiKeyGroup"
:options="apiKeyGroupFilterOptions"
searchable
:search-placeholder="t('admin.users.searchApiKeyGroups')"
@change="applyFilter"
/>
</div>
@@ -751,6 +762,7 @@ import type { AdminUser, AdminGroup, UserAttributeDefinition } from '@/types'
import type { BatchUserUsageStats } from '@/api/admin/dashboard'
import type { PlatformQuotaItem } from '@/api/admin/users'
import type { Column } from '@/components/common/types'
import type { SelectOption } from '@/components/common/Select.vue'
import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.vue'
@@ -759,6 +771,7 @@ import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import GroupBadge from '@/components/common/GroupBadge.vue'
import Select from '@/components/common/Select.vue'
import { buildApiKeyGroupFilterOptions } from './apiKeyGroupFilterOptions'
import UserAttributesConfigModal from '@/components/user/UserAttributesConfigModal.vue'
import UserConcurrencyCell from '@/components/user/UserConcurrencyCell.vue'
import PlatformUsageBreakdown from '@/components/user/PlatformUsageBreakdown.vue'
@@ -1004,7 +1017,7 @@ const loadInitialSortState = (): { sort_by: string; sort_order: 'asc' | 'desc' }
}
const sortState = reactive(loadInitialSortState())
// Groups data for the groups column
// Groups data for the groups column and the existing "authorised group" filter (active only)
const allGroups = ref<AdminGroup[]>([])
const loadAllGroups = async () => {
if (allGroups.value.length > 0) return
@@ -1014,6 +1027,18 @@ const loadAllGroups = async () => {
console.error('Failed to load groups:', e)
}
}
// Groups for the API Key group filter — includes disabled groups so admins can
// filter users whose keys are still bound to a now-disabled group.
const allGroupsForApiKeyFilter = ref<AdminGroup[]>([])
const loadAllGroupsForApiKeyFilter = async () => {
if (allGroupsForApiKeyFilter.value.length > 0) return
try {
allGroupsForApiKeyFilter.value = await adminAPI.groups.getAllIncludingInactive()
} catch (e) {
console.error('Failed to load groups for API key filter:', e)
}
}
// Resolve user's accessible groups: exclusive groups first, then public groups
const getUserGroups = (user: AdminUser) => {
const exclusive: AdminGroup[] = []
@@ -1034,7 +1059,7 @@ const getUserGroups = (user: AdminUser) => {
// Group filter options: "All Groups" + active exclusive groups (value = group name for fuzzy match)
const groupFilterOptions = computed(() => {
const options: { value: string; label: string }[] = [
{ value: '', label: t('admin.users.allGroups') }
{ value: '', label: t('admin.users.allAuthorizedGroups') }
]
for (const g of allGroups.value) {
if (g.status !== 'active' || !g.is_exclusive || g.subscription_type !== 'standard') continue
@@ -1043,11 +1068,24 @@ const groupFilterOptions = computed(() => {
return options
})
// API Key group filter options: "All" + groups partitioned by type (value = group id).
// Uses allGroupsForApiKeyFilter which includes disabled groups.
const apiKeyGroupFilterOptions = computed(() =>
buildApiKeyGroupFilterOptions(allGroupsForApiKeyFilter.value, {
all: t('admin.users.allApiKeyGroups'),
exclusive: t('admin.users.apiKeyGroupExclusive'),
public: t('admin.users.apiKeyGroupPublic'),
subscription: t('admin.users.apiKeyGroupSubscription'),
disabled: t('admin.users.apiKeyGroupDisabled'),
}) as SelectOption[]
)
// Filter values (role, status, and custom attributes)
const filters = reactive({
role: '',
status: '',
group: '' // group name for fuzzy match, '' = all
group: '', // group name for fuzzy match, '' = all
apiKeyGroup: null as number | null // group id bound to the user's API keys, null = all
})
const activeAttributeFilters = reactive<Record<number, string>>({})
@@ -1076,7 +1114,8 @@ const filterableAttributes = computed(() =>
const builtInFilters = computed(() => [
{ key: 'role', name: t('admin.users.columns.role'), type: 'select' as const },
{ key: 'status', name: t('admin.users.columns.status'), type: 'select' as const },
{ key: 'group', name: t('admin.users.columns.groups'), type: 'select' as const }
{ key: 'group', name: t('admin.users.authorizedGroupFilter'), type: 'select' as const },
{ key: 'apiKeyGroup', name: t('admin.users.apiKeyGroupFilter'), type: 'select' as const }
])
// Load saved filters from localStorage
@@ -1095,6 +1134,7 @@ const loadSavedFilters = () => {
if (parsed.role) filters.role = parsed.role
if (parsed.status) filters.status = parsed.status
if (parsed.group) filters.group = parsed.group
if (typeof parsed.apiKeyGroup === 'number') filters.apiKeyGroup = parsed.apiKeyGroup
if (parsed.attributes) {
Object.assign(activeAttributeFilters, parsed.attributes)
}
@@ -1114,6 +1154,7 @@ const saveFiltersToStorage = () => {
role: filters.role,
status: filters.status,
group: filters.group,
apiKeyGroup: filters.apiKeyGroup,
attributes: activeAttributeFilters
}
localStorage.setItem(FILTER_VALUES_KEY, JSON.stringify(values))
@@ -1492,6 +1533,7 @@ const loadUsers = async () => {
status: filters.status as any,
search: searchQuery.value || undefined,
group_name: filters.group || undefined,
api_key_group_id: filters.apiKeyGroup ?? undefined,
attributes: Object.keys(attrFilters).length > 0 ? attrFilters : undefined,
// 始终请求 subscriptions:列隐藏时仍需用于 UserPlatformQuotaModal 的 active-subscription 警示 banner
include_subscriptions: true,
@@ -1576,9 +1618,11 @@ const toggleBuiltInFilter = (key: string) => {
if (key === 'role') filters.role = ''
if (key === 'status') filters.status = ''
if (key === 'group') filters.group = ''
if (key === 'apiKeyGroup') filters.apiKeyGroup = null
} else {
visibleFilters.add(key)
if (key === 'group') loadAllGroups()
if (key === 'apiKeyGroup') loadAllGroupsForApiKeyFilter()
}
saveFiltersToStorage()
pagination.page = 1
@@ -1740,6 +1784,9 @@ onMounted(async () => {
if (hasVisibleGroupsColumn.value || visibleFilters.has('group')) {
loadAllGroups()
}
if (visibleFilters.has('apiKeyGroup')) {
loadAllGroupsForApiKeyFilter()
}
document.addEventListener('click', handleClickOutside)
window.addEventListener('scroll', handleScroll, true)
})
@@ -0,0 +1,93 @@
import { describe, it, expect } from 'vitest'
import { buildApiKeyGroupFilterOptions } from '../apiKeyGroupFilterOptions'
import type { AdminGroup } from '@/types'
const labels = {
all: 'All',
exclusive: 'Exclusive',
public: 'Public',
subscription: 'Subscription',
disabled: 'Disabled',
}
function g(partial: Partial<AdminGroup>): AdminGroup {
return {
id: 0,
name: '',
status: 'active',
is_exclusive: false,
subscription_type: 'standard',
...partial,
} as AdminGroup
}
describe('buildApiKeyGroupFilterOptions', () => {
it('partitions active groups into exclusive/public/subscription with headers', () => {
const groups = [
g({ id: 1, name: 'Excl', is_exclusive: true, subscription_type: 'standard' }),
g({ id: 2, name: 'Pub', is_exclusive: false, subscription_type: 'standard' }),
g({ id: 3, name: 'Sub', is_exclusive: false, subscription_type: 'subscription' }),
]
expect(buildApiKeyGroupFilterOptions(groups, labels)).toEqual([
{ value: null, label: 'All' },
{ value: -1, label: 'Exclusive', kind: 'group', disabled: true },
{ value: 1, label: 'Excl' },
{ value: -2, label: 'Public', kind: 'group', disabled: true },
{ value: 2, label: 'Pub' },
{ value: -3, label: 'Subscription', kind: 'group', disabled: true },
{ value: 3, label: 'Sub' },
])
})
it('treats subscription_type=subscription as subscription even if is_exclusive', () => {
const groups = [g({ id: 9, name: 'X', is_exclusive: true, subscription_type: 'subscription' })]
const opts = buildApiKeyGroupFilterOptions(groups, labels)
expect(opts).toContainEqual({ value: 9, label: 'X' })
expect(opts.find((o) => o.label === 'Subscription')).toBeDefined()
expect(opts.find((o) => o.label === 'Exclusive')).toBeUndefined()
})
it('skips empty section headers', () => {
const groups = [g({ id: 2, name: 'Pub', is_exclusive: false, subscription_type: 'standard' })]
const opts = buildApiKeyGroupFilterOptions(groups, labels)
expect(opts.find((o) => o.label === 'Exclusive')).toBeUndefined()
expect(opts.find((o) => o.label === 'Subscription')).toBeUndefined()
expect(opts).toContainEqual({ value: -2, label: 'Public', kind: 'group', disabled: true })
})
it('places non-active groups in a separate disabled section (not omitted)', () => {
const groups = [
g({ id: 1, name: 'Active', is_exclusive: true }),
g({ id: 2, name: 'Inactive', is_exclusive: true, status: 'inactive' }),
]
const opts = buildApiKeyGroupFilterOptions(groups, labels)
// Active exclusive group appears in Exclusive section
expect(opts).toContainEqual({ value: 1, label: 'Active' })
// Disabled group appears in Disabled section
expect(opts).toContainEqual({ value: 2, label: 'Inactive' })
// Disabled section header present
expect(opts).toContainEqual({ value: -4, label: 'Disabled', kind: 'group', disabled: true })
// Not in Exclusive section
const exclIdx = opts.findIndex((o) => o.value === -1)
const disabledItemIdx = opts.findIndex((o) => o.value === 2)
expect(exclIdx).toBeLessThan(disabledItemIdx)
})
it('section headers use distinct negative values (no duplicate Vue :key)', () => {
const groups = [
g({ id: 1, name: 'E', is_exclusive: true }),
g({ id: 2, name: 'P', is_exclusive: false }),
g({ id: 3, name: 'S', subscription_type: 'subscription' }),
g({ id: 4, name: 'D', status: 'inactive' }),
]
const opts = buildApiKeyGroupFilterOptions(groups, labels)
const headerValues = opts.filter((o) => o.kind === 'group').map((o) => o.value)
const unique = new Set(headerValues)
expect(unique.size).toBe(headerValues.length) // all distinct
headerValues.forEach((v) => expect(v).toBeLessThan(0)) // all negative
})
it('returns only the all-option when there are no groups', () => {
expect(buildApiKeyGroupFilterOptions([], labels)).toEqual([{ value: null, label: 'All' }])
})
})
@@ -0,0 +1,75 @@
import type { AdminGroup } from '@/types'
export interface ApiKeyGroupFilterOption {
value: number | null
label: string
kind?: 'group'
disabled?: boolean
}
export interface ApiKeyGroupFilterLabels {
all: string
exclusive: string
public: string
subscription: string
disabled: string
}
// Sentinel values for section-header rows (negative so they never collide with real group ids).
// Select.vue generates :key from `${typeof value}:${String(value ?? '')}` — using distinct
// numbers avoids the duplicate "object:" keys that null-valued headers would produce.
const HEADER_EXCLUSIVE = -1
const HEADER_PUBLIC = -2
const HEADER_SUBSCRIPTION = -3
const HEADER_DISABLED = -4
/**
* Build options for the "API Key group" filter Select.
*
* Active groups are partitioned into exclusive / public / subscription sections,
* each preceded by a disabled section-header row. Disabled groups are collected
* into a final "disabled" section so admins can filter users whose keys are still
* bound to a now-disabled group. Empty sections render no header. The leading
* "all" option (value null) clears the filter.
*
* Section-header rows use negative sentinel values (-1 … -4) instead of null so
* that Vue's v-for :key expression produces distinct strings and avoids duplicate-
* key warnings (fixes F2).
*/
export function buildApiKeyGroupFilterOptions(
groups: AdminGroup[],
labels: ApiKeyGroupFilterLabels
): ApiKeyGroupFilterOption[] {
const exclusive: ApiKeyGroupFilterOption[] = []
const publicGroups: ApiKeyGroupFilterOption[] = []
const subscription: ApiKeyGroupFilterOption[] = []
const disabledGroups: ApiKeyGroupFilterOption[] = []
for (const grp of groups) {
const item: ApiKeyGroupFilterOption = { value: grp.id, label: grp.name }
if (grp.status !== 'active') {
disabledGroups.push(item)
} else if (grp.subscription_type === 'subscription') {
subscription.push(item)
} else if (grp.is_exclusive) {
exclusive.push(item)
} else {
publicGroups.push(item)
}
}
const options: ApiKeyGroupFilterOption[] = [{ value: null, label: labels.all }]
const sections: Array<[string, number, ApiKeyGroupFilterOption[]]> = [
[labels.exclusive, HEADER_EXCLUSIVE, exclusive],
[labels.public, HEADER_PUBLIC, publicGroups],
[labels.subscription, HEADER_SUBSCRIPTION, subscription],
[labels.disabled, HEADER_DISABLED, disabledGroups],
]
for (const [label, headerValue, items] of sections) {
if (items.length === 0) continue
options.push({ value: headerValue, label, kind: 'group', disabled: true })
options.push(...items)
}
return options
}