mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-08 16:56:23 +00:00
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:
1 parent
be01744565
commit
329414ea4f
15 files changed
+529
-12
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '管理员',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user