Merge remote-tracking branch 'origin/pr/394' into aether-rust-pioneer

This commit is contained in:
fawney19
2026-05-08 02:46:35 +08:00
27 changed files with 2202 additions and 74 deletions

View File

@@ -2,11 +2,13 @@ import apiClient from './client'
import { cachedRequest } from '@/utils/cache'
import type { UserSession as SessionRecord } from '@/types/session'
export type UserRole = 'admin' | 'user'
export interface User {
id: string // UUID
username: string
email: string
role: 'admin' | 'user'
role: UserRole
is_active: boolean
unlimited: boolean
allowed_providers: string[] | null // 允许使用的提供商 ID 列表
@@ -24,7 +26,7 @@ export interface CreateUserRequest {
username: string
password: string
email: string
role?: 'admin' | 'user'
role?: UserRole
initial_gift_usd?: number | null
unlimited?: boolean
allowed_providers?: string[] | null
@@ -36,7 +38,7 @@ export interface CreateUserRequest {
export interface UpdateUserRequest {
email?: string
is_active?: boolean
role?: 'admin' | 'user'
role?: UserRole
unlimited?: boolean
password?: string
allowed_providers?: string[] | null
@@ -45,6 +47,83 @@ export interface UpdateUserRequest {
rate_limit?: number | null
}
export interface UserBatchSelectionFilters {
search?: string
role?: UserRole
is_active?: boolean
}
export interface UserBatchSelection {
user_ids?: string[]
filters?: UserBatchSelectionFilters | null
}
export interface UserBatchSelectionItem {
user_id: string
username: string
email?: string | null
role: UserRole
is_active: boolean
}
export interface ResolveUserBatchSelectionResponse {
total: number
items: UserBatchSelectionItem[]
}
export interface UserBatchAccessControlPayload {
allowed_providers?: string[] | null
allowed_api_formats?: string[] | null
allowed_models?: string[] | null
rate_limit?: number | null
unlimited?: boolean
}
export interface UserBatchRolePayload {
role: UserRole
}
export type UserBatchAction = 'enable' | 'disable' | 'update_access_control' | 'update_role'
export type UserBatchActionPayload = UserBatchAccessControlPayload | UserBatchRolePayload
export interface UserBatchToggleActionRequest {
selection: UserBatchSelection
action: 'enable' | 'disable'
payload?: null
}
export interface UserBatchAccessControlActionRequest {
selection: UserBatchSelection
action: 'update_access_control'
payload: UserBatchAccessControlPayload
}
export interface UserBatchRoleActionRequest {
selection: UserBatchSelection
action: 'update_role'
payload: UserBatchRolePayload
}
export type UserBatchActionRequest =
| UserBatchToggleActionRequest
| UserBatchAccessControlActionRequest
| UserBatchRoleActionRequest
export interface UserBatchActionFailure {
user_id: string
reason: string
}
export interface UserBatchActionResponse {
total: number
success: number
failed: number
failures: UserBatchActionFailure[]
action?: string
modified_fields?: string[]
}
export interface ApiKey {
id: string // UUID
key?: string // 完整的 key只在创建时返回
@@ -98,6 +177,24 @@ export const usersApi = {
return response.data
},
async resolveBatchSelection(
selection: UserBatchSelection
): Promise<ResolveUserBatchSelectionResponse> {
const response = await apiClient.post<ResolveUserBatchSelectionResponse>(
'/api/admin/users/resolve-selection',
selection
)
return response.data
},
async batchAction(request: UserBatchActionRequest): Promise<UserBatchActionResponse> {
const response = await apiClient.post<UserBatchActionResponse>(
'/api/admin/users/batch-action',
request
)
return response.data
},
async deleteUser(userId: string): Promise<void> {
await apiClient.delete(`/api/admin/users/${userId}`)
},
@@ -113,12 +210,12 @@ export const usersApi = {
},
async revokeUserSession(userId: string, sessionId: string): Promise<{ message: string }> {
const response = await apiClient.delete(`/api/admin/users/${userId}/sessions/${sessionId}`)
const response = await apiClient.delete<{ message: string }>(`/api/admin/users/${userId}/sessions/${sessionId}`)
return response.data
},
async revokeAllUserSessions(userId: string): Promise<{ message: string; revoked_count: number }> {
const response = await apiClient.delete(`/api/admin/users/${userId}/sessions`)
const response = await apiClient.delete<{ message: string; revoked_count: number }>(`/api/admin/users/${userId}/sessions`)
return response.data
},
@@ -157,7 +254,7 @@ export const usersApi = {
},
// 管理员统计
async getUsageStats(): Promise<Record<string, unknown>> {
const response = await apiClient.get('/api/admin/usage/stats')
const response = await apiClient.get<Record<string, unknown>>('/api/admin/usage/stats')
return response.data
}
}

View File

@@ -0,0 +1,89 @@
import { computed, ref, type Ref } from 'vue'
export function useBatchSelection<TItem>(options: {
pageItems: Ref<TItem[]>
filteredTotal: Ref<number>
getItemId: (item: TItem) => string
}) {
const selectedIds = ref<string[]>([])
const selectAllFiltered = ref(false)
const knownItemsById = ref<Record<string, TItem>>({})
const selectedIdSet = computed(() => new Set(selectedIds.value))
const selectedCount = computed(() => (
selectAllFiltered.value ? options.filteredTotal.value : selectedIds.value.length
))
const isAllFilteredSelected = computed(() => (
selectAllFiltered.value && options.filteredTotal.value > 0
))
const isPartiallyFilteredSelected = computed(() => (
!selectAllFiltered.value && selectedIds.value.length > 0
))
const isCurrentPageFullySelected = computed(() => {
const pageIds = options.pageItems.value.map(options.getItemId)
return pageIds.length > 0 && pageIds.every((id) => selectedIdSet.value.has(id))
})
const canClearSelection = computed(() => selectAllFiltered.value || selectedIds.value.length > 0)
function rememberItems(items: TItem[]): void {
if (items.length === 0) return
const next = { ...knownItemsById.value }
for (const item of items) {
next[options.getItemId(item)] = item
}
knownItemsById.value = next
}
function resetSelection(clearKnown = false): void {
selectAllFiltered.value = false
selectedIds.value = []
if (clearKnown) knownItemsById.value = {}
}
function toggleOne(id: string, checked: boolean): void {
if (selectAllFiltered.value) return
const set = new Set(selectedIds.value)
if (checked) set.add(id)
else set.delete(id)
selectedIds.value = [...set]
}
function toggleSelectFiltered(checked: boolean | 'indeterminate'): void {
selectAllFiltered.value = checked === true
if (selectAllFiltered.value) selectedIds.value = []
}
function toggleSelectCurrentPage(): void {
if (selectAllFiltered.value || options.pageItems.value.length === 0) return
const set = new Set(selectedIds.value)
const pageIds = options.pageItems.value.map(options.getItemId)
const shouldUnselect = pageIds.every((id) => set.has(id))
for (const id of pageIds) {
if (shouldUnselect) set.delete(id)
else set.add(id)
}
selectedIds.value = [...set]
}
function clearSelection(): void {
resetSelection()
}
return {
selectedIds,
selectAllFiltered,
knownItemsById,
selectedIdSet,
selectedCount,
isAllFilteredSelected,
isPartiallyFilteredSelected,
isCurrentPageFullySelected,
canClearSelection,
rememberItems,
resetSelection,
toggleOne,
toggleSelectFiltered,
toggleSelectCurrentPage,
clearSelection,
}
}

View File

@@ -301,6 +301,7 @@ import {
getOAuthStatusTitle,
} from '@/utils/providerKeyStatus'
import { getQuotaDisplayText } from '@/utils/providerKeyQuota'
import { runChunkedBatchAction } from '@/utils/batchAction'
type QuickSelectorValue =
| 'banned'
@@ -839,24 +840,20 @@ async function executeAction(actionOverride?: BatchActionValue): Promise<void> {
if (selectedAction.value === 'refresh_quota') {
const targetIds = selectedKeys.map((key) => key.key_id)
const BATCH_SIZE = 20
const totalBatches = Math.ceil(targetIds.length / BATCH_SIZE)
for (let i = 0; i < targetIds.length; i += BATCH_SIZE) {
const batchIndex = Math.floor(i / BATCH_SIZE) + 1
const batch = targetIds.slice(i, i + BATCH_SIZE)
progressLabel.value = `正在${actionLabel}...(第 ${batchIndex}/${totalBatches} 批)`
try {
const result = await refreshProviderQuota(props.providerId, batch)
successCount += Number(result.success || 0)
failedCount += Number(result.failed || 0)
skippedCount += Math.max(0, batch.length - Number(result.total || 0))
} catch {
failedCount += batch.length
}
progressDone.value = Math.min(i + BATCH_SIZE, targetIds.length)
}
const counts = await runChunkedBatchAction({
items: targetIds,
chunkSize: BATCH_SIZE,
runChunk: (batch) => refreshProviderQuota(props.providerId, batch),
onChunkStart: ({ batchIndex, totalBatches }) => {
progressLabel.value = `正在${actionLabel}...(第 ${batchIndex}/${totalBatches} 批)`
},
onChunkDone: ({ processed }) => {
progressDone.value = processed
},
})
successCount += counts.success
failedCount += counts.failed
skippedCount += counts.skipped
} else if (selectedAction.value === 'export') {
const exportableKeys = selectedKeys.filter((key) => canExportOAuthCredential(key))
const exportedEntries: Array<Record<string, unknown> | null> = Array.from({ length: exportableKeys.length }, () => null)

View File

@@ -0,0 +1,568 @@
<template>
<Dialog
:model-value="open"
title="用户批量操作"
description="按当前选择批量调整用户状态、角色、访问控制和额度"
size="2xl"
persistent
@update:model-value="handleDialogUpdate"
>
<div class="space-y-5">
<div class="rounded-2xl border border-primary/15 bg-gradient-to-br from-primary/10 via-background to-muted/40 p-4 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0 space-y-1">
<div class="flex items-center gap-2 text-sm font-semibold text-foreground">
<UsersRound class="h-4 w-4 text-primary" />
<span>影响用户{{ impactCount }} </span>
</div>
<p class="text-xs leading-relaxed text-muted-foreground">
{{ selectAllFiltered ? '目标为当前筛选条件匹配的全部用户,执行前后端会重新解析。' : '目标为当前已勾选的用户,重复 ID 会自动去重。' }}
</p>
</div>
<Badge variant="secondary" class="shrink-0">
{{ selectAllFiltered ? '全选筛选结果' : '手动选择' }}
</Badge>
</div>
<div
v-if="previewLoading"
class="mt-3 rounded-xl border border-border/60 bg-background/65 px-3 py-2 text-xs text-muted-foreground"
>
正在解析影响范围...
</div>
<div
v-else-if="previewItems.length > 0"
class="mt-3 flex flex-wrap items-center gap-1.5"
>
<Badge
v-for="item in previewItems"
:key="item.user_id"
variant="outline"
class="bg-background/70 text-[11px]"
>
{{ item.username }}
</Badge>
<span
v-if="impactCount > previewItems.length"
class="text-xs text-muted-foreground"
>
{{ impactCount }} 个用户
</span>
</div>
</div>
<div class="space-y-2.5">
<div class="flex items-center justify-between gap-3">
<Label class="text-sm font-medium">选择批量动作</Label>
<span class="text-[11px] text-muted-foreground">只会提交当前动作对应的字段</span>
</div>
<div class="grid gap-2 md:grid-cols-4">
<button
v-for="action in actionOptions"
:key="action.value"
type="button"
:class="actionCardClass(action.value)"
@click="selectedAction = action.value"
>
<span class="flex items-center gap-2">
<span :class="actionIconClass(action.value)">
<component :is="action.icon" class="h-4 w-4" />
</span>
<span class="font-medium text-foreground">{{ action.label }}</span>
</span>
<span class="mt-1 block text-[11px] leading-relaxed text-muted-foreground">
{{ action.description }}
</span>
</button>
</div>
</div>
<div
v-if="selectedAction === 'update_role'"
class="space-y-4 rounded-2xl border bg-background p-4 shadow-sm"
>
<div class="flex items-start gap-3">
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
<UserCog class="h-4 w-4" />
</div>
<div class="min-w-0 space-y-1">
<h4 class="text-sm font-semibold text-foreground">批量修改用户角色</h4>
<p class="text-xs leading-relaxed text-muted-foreground">
将所选用户统一调整为同一个角色管理员角色拥有后台管理权限请确认选择范围
</p>
</div>
</div>
<div class="grid gap-3 rounded-xl border border-border/70 bg-muted/25 p-3 sm:grid-cols-[10rem_minmax(0,1fr)] sm:items-center">
<div>
<Label class="text-sm font-medium">目标角色</Label>
<p class="mt-1 text-[11px] text-muted-foreground">对所有目标用户生效</p>
</div>
<Select v-model="targetRole">
<SelectTrigger class="h-10 w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">普通用户</SelectItem>
<SelectItem value="admin">管理员</SelectItem>
</SelectContent>
</Select>
</div>
<div class="rounded-xl border border-amber-200/70 bg-amber-50/70 px-3 py-2.5 text-xs leading-relaxed text-amber-800 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200">
{{ targetRole === 'admin' ? '提示:设置为管理员会授予用户后台管理能力。' : '提示:设置为普通用户会移除目标用户的管理员权限。' }}
</div>
</div>
<div
v-if="selectedAction === 'update_access_control'"
class="space-y-4 rounded-2xl border bg-background p-4 shadow-sm"
>
<div class="flex items-start gap-3">
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
<ShieldCheck class="h-4 w-4" />
</div>
<div class="min-w-0 space-y-1">
<h4 class="text-sm font-semibold text-foreground">批量设置访问控制与额度</h4>
<p class="text-xs leading-relaxed text-muted-foreground">
每个字段都可独立选择不修改 / 不限制 / 指定列表指定列表为空表示全部禁用
</p>
</div>
</div>
<div class="grid gap-3">
<div class="rounded-xl border border-border/70 bg-muted/20 p-3">
<div class="grid gap-3 lg:grid-cols-[9rem_minmax(0,1fr)] lg:items-start">
<div>
<Label class="text-sm font-medium">允许的提供商</Label>
<p class="mt-1 text-[11px] text-muted-foreground">控制可使用的供应商</p>
</div>
<div class="grid gap-2 sm:grid-cols-[9rem_minmax(0,1fr)]">
<Select v-model="providerMode">
<SelectTrigger class="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="skip">不修改</SelectItem>
<SelectItem value="unrestricted">不限制</SelectItem>
<SelectItem value="specific">指定列表</SelectItem>
</SelectContent>
</Select>
<MultiSelect
v-model="allowedProviders"
:options="providerOptions"
:disabled="providerMode !== 'specific'"
:search-threshold="0"
placeholder="未选择时表示全部禁用"
empty-text="暂无可用提供商"
/>
</div>
</div>
</div>
<div class="rounded-xl border border-border/70 bg-muted/20 p-3">
<div class="grid gap-3 lg:grid-cols-[9rem_minmax(0,1fr)] lg:items-start">
<div>
<Label class="text-sm font-medium">允许的端点</Label>
<p class="mt-1 text-[11px] text-muted-foreground">控制 API 格式入口</p>
</div>
<div class="grid gap-2 sm:grid-cols-[9rem_minmax(0,1fr)]">
<Select v-model="apiFormatMode">
<SelectTrigger class="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="skip">不修改</SelectItem>
<SelectItem value="unrestricted">不限制</SelectItem>
<SelectItem value="specific">指定列表</SelectItem>
</SelectContent>
</Select>
<MultiSelect
v-model="allowedApiFormats"
:options="apiFormatOptions"
:disabled="apiFormatMode !== 'specific'"
:search-threshold="0"
placeholder="未选择时表示全部禁用"
empty-text="暂无可用端点"
/>
</div>
</div>
</div>
<div class="rounded-xl border border-border/70 bg-muted/20 p-3">
<div class="grid gap-3 lg:grid-cols-[9rem_minmax(0,1fr)] lg:items-start">
<div>
<Label class="text-sm font-medium">允许的模型</Label>
<p class="mt-1 text-[11px] text-muted-foreground">控制模型白名单</p>
</div>
<div class="grid gap-2 sm:grid-cols-[9rem_minmax(0,1fr)]">
<Select v-model="modelMode">
<SelectTrigger class="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="skip">不修改</SelectItem>
<SelectItem value="unrestricted">不限制</SelectItem>
<SelectItem value="specific">指定列表</SelectItem>
</SelectContent>
</Select>
<MultiSelect
v-model="allowedModels"
:options="modelOptions"
:disabled="modelMode !== 'specific'"
:search-threshold="0"
placeholder="未选择时表示全部禁用"
empty-text="暂无可用模型"
/>
</div>
</div>
</div>
<div class="grid gap-3 md:grid-cols-2">
<div class="rounded-xl border border-border/70 bg-muted/20 p-3">
<div class="space-y-2">
<div>
<Label class="text-sm font-medium">速率限制</Label>
<p class="mt-1 text-[11px] text-muted-foreground">请求/分钟0 表示不限速</p>
</div>
<div class="grid gap-2 sm:grid-cols-[9rem_minmax(0,1fr)] md:grid-cols-1 xl:grid-cols-[9rem_minmax(0,1fr)]">
<Select v-model="rateLimitMode">
<SelectTrigger class="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="skip">不修改</SelectItem>
<SelectItem value="inherit">跟随默认</SelectItem>
<SelectItem value="custom">指定数值</SelectItem>
</SelectContent>
</Select>
<Input
:model-value="rateLimit ?? ''"
type="number"
min="0"
max="10000"
class="h-9"
:disabled="rateLimitMode !== 'custom'"
placeholder="0 = 不限速"
@update:model-value="(value) => rateLimit = parseNumberInput(value, { min: 0, max: 10000 })"
/>
</div>
</div>
</div>
<div class="rounded-xl border border-border/70 bg-muted/20 p-3">
<div class="space-y-2">
<div>
<Label class="text-sm font-medium">额度</Label>
<p class="mt-1 text-[11px] text-muted-foreground">与单用户编辑保持一致</p>
</div>
<Select v-model="quotaMode">
<SelectTrigger class="h-9 w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="skip">不修改</SelectItem>
<SelectItem value="wallet">按钱包余额限制</SelectItem>
<SelectItem value="unlimited">无限额度</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</div>
</div>
<div
v-if="lastResult"
class="rounded-xl border bg-muted/20 px-3 py-2 text-xs text-muted-foreground"
>
成功 {{ lastResult.success }} 个,失败 {{ lastResult.failed }} 个
<span v-if="lastResult.failures.length > 0">
{{ lastResult.failures.slice(0, 3).map((item) => `${item.user_id} ${item.reason}`).join('') }}
</span>
</div>
</div>
<template #footer>
<Button
variant="outline"
:disabled="executing"
@click="emit('close')"
>
关闭
</Button>
<Button
:disabled="!canExecute"
@click="executeBatchAction"
>
{{ executing ? '执行中...' : executeButtonLabel }}
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch, type Component } from 'vue'
import {
Ban,
CheckCircle2,
ShieldCheck,
UserCog,
UsersRound,
} from 'lucide-vue-next'
import {
Dialog,
Button,
Badge,
Input,
Label,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '@/components/ui'
import { MultiSelect } from '@/components/common'
import { useUsersStore } from '@/stores/users'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { parseNumberInput } from '@/utils/form'
import { cn } from '@/lib/utils'
import { useUserAccessControlOptions } from '@/features/users/composables/useUserAccessControlOptions'
import type {
UserBatchAccessControlPayload,
UserBatchAction,
UserBatchActionRequest,
UserBatchActionResponse,
UserBatchRolePayload,
UserBatchSelection,
UserBatchSelectionFilters,
UserBatchSelectionItem,
UserRole,
} from '@/api/users'
type AccessFieldMode = 'skip' | 'unrestricted' | 'specific'
type RateLimitMode = 'skip' | 'inherit' | 'custom'
type QuotaMode = 'skip' | 'wallet' | 'unlimited'
interface ActionOption {
value: UserBatchAction
label: string
description: string
icon: Component
}
const props = defineProps<{
open: boolean
selectedIds: string[]
selectAllFiltered: boolean
selectedCount: number
filters: UserBatchSelectionFilters
}>()
const emit = defineEmits<{
close: []
completed: [result: UserBatchActionResponse]
}>()
const usersStore = useUsersStore()
const { success, warning, error } = useToast()
const {
providerOptions,
apiFormatOptions,
modelOptions,
loadAccessControlOptions,
} = useUserAccessControlOptions()
const actionOptions: ActionOption[] = [
{
value: 'enable',
label: '启用',
description: '恢复用户登录与调用',
icon: CheckCircle2,
},
{
value: 'disable',
label: '禁用',
description: '暂停用户访问权限',
icon: Ban,
},
{
value: 'update_access_control',
label: '访问控制',
description: '提供商、端点、模型、限速和额度',
icon: ShieldCheck,
},
{
value: 'update_role',
label: '修改角色',
description: '批量设为普通用户或管理员',
icon: UserCog,
},
]
const selectedAction = ref<UserBatchAction>('enable')
const targetRole = ref<UserRole>('user')
const providerMode = ref<AccessFieldMode>('skip')
const apiFormatMode = ref<AccessFieldMode>('skip')
const modelMode = ref<AccessFieldMode>('skip')
const rateLimitMode = ref<RateLimitMode>('skip')
const quotaMode = ref<QuotaMode>('skip')
const allowedProviders = ref<string[]>([])
const allowedApiFormats = ref<string[]>([])
const allowedModels = ref<string[]>([])
const rateLimit = ref<number | undefined>(undefined)
const previewLoading = ref(false)
const previewItems = ref<UserBatchSelectionItem[]>([])
const resolvedTotal = ref<number | null>(null)
const executing = ref(false)
const lastResult = ref<UserBatchActionResponse | null>(null)
const impactCount = computed(() => resolvedTotal.value ?? props.selectedCount)
const canExecute = computed(() => props.selectedCount > 0 && !previewLoading.value && !executing.value)
const selectedActionLabel = computed(() => (
actionOptions.find((action) => action.value === selectedAction.value)?.label ?? '批量操作'
))
const executeButtonLabel = computed(() => `确认${selectedActionLabel.value}${impactCount.value}`)
watch(
() => props.open,
(open) => {
if (!open) return
resetLocalState()
void loadAccessControlOptions().catch((err) => {
error(parseApiError(err, '加载访问控制选项失败'))
})
void resolvePreview()
},
)
watch(
() => [props.selectedIds, props.selectAllFiltered, props.selectedCount, props.filters] as const,
() => {
if (props.open) void resolvePreview()
},
)
function handleDialogUpdate(value: boolean): void {
if (!value) emit('close')
}
function resetLocalState(): void {
selectedAction.value = 'enable'
targetRole.value = 'user'
providerMode.value = 'skip'
apiFormatMode.value = 'skip'
modelMode.value = 'skip'
rateLimitMode.value = 'skip'
quotaMode.value = 'skip'
allowedProviders.value = []
allowedApiFormats.value = []
allowedModels.value = []
rateLimit.value = undefined
lastResult.value = null
}
function actionCardClass(action: UserBatchAction): string {
return cn(
'rounded-xl border p-3 text-left transition-all hover:-translate-y-0.5 hover:border-primary/35 hover:bg-primary/5 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/30',
selectedAction.value === action
? 'border-primary/60 bg-primary/10 shadow-sm ring-1 ring-primary/20'
: 'border-border/70 bg-background',
)
}
function actionIconClass(action: UserBatchAction): string {
return cn(
'flex h-7 w-7 items-center justify-center rounded-lg transition-colors',
selectedAction.value === action
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground',
)
}
function buildSelection(): UserBatchSelection {
if (props.selectAllFiltered) {
return { filters: props.filters }
}
return { user_ids: [...props.selectedIds] }
}
async function resolvePreview(): Promise<void> {
if (props.selectedCount === 0) {
resolvedTotal.value = 0
previewItems.value = []
return
}
previewLoading.value = true
try {
const result = await usersStore.resolveBatchSelection(buildSelection())
resolvedTotal.value = result.total
previewItems.value = result.items.slice(0, 6)
} catch (err) {
resolvedTotal.value = props.selectedCount
previewItems.value = []
error(parseApiError(err, '解析用户选择失败'))
} finally {
previewLoading.value = false
}
}
function buildAccessControlPayload(): UserBatchAccessControlPayload | null {
const payload: UserBatchAccessControlPayload = {}
if (providerMode.value === 'unrestricted') payload.allowed_providers = null
if (providerMode.value === 'specific') payload.allowed_providers = [...allowedProviders.value]
if (apiFormatMode.value === 'unrestricted') payload.allowed_api_formats = null
if (apiFormatMode.value === 'specific') payload.allowed_api_formats = [...allowedApiFormats.value]
if (modelMode.value === 'unrestricted') payload.allowed_models = null
if (modelMode.value === 'specific') payload.allowed_models = [...allowedModels.value]
if (rateLimitMode.value === 'inherit') payload.rate_limit = null
if (rateLimitMode.value === 'custom' && rateLimit.value != null) payload.rate_limit = rateLimit.value
if (quotaMode.value === 'wallet') payload.unlimited = false
if (quotaMode.value === 'unlimited') payload.unlimited = true
return Object.keys(payload).length > 0 ? payload : null
}
function buildRolePayload(): UserBatchRolePayload {
return { role: targetRole.value }
}
async function executeBatchAction(): Promise<void> {
if (!canExecute.value) return
if (selectedAction.value === 'update_access_control' && rateLimitMode.value === 'custom' && rateLimit.value == null) {
warning('请输入速率限制数值0 表示不限速')
return
}
const selection = buildSelection()
let request: UserBatchActionRequest
if (selectedAction.value === 'update_access_control') {
const payload = buildAccessControlPayload()
if (payload === null) {
warning('请至少选择一个要修改的访问控制或额度字段')
return
}
request = { selection, action: 'update_access_control', payload }
} else if (selectedAction.value === 'update_role') {
request = { selection, action: 'update_role', payload: buildRolePayload() }
} else {
request = { selection, action: selectedAction.value }
}
executing.value = true
try {
const result = await usersStore.batchAction(request)
lastResult.value = result
const message = `批量操作完成:成功 ${result.success} 个,失败 ${result.failed}`
if (result.failed > 0) {
warning(message)
} else {
success(message)
}
emit('completed', result)
} catch (err) {
error(parseApiError(err, '批量操作失败'))
} finally {
executing.value = false
}
}
</script>

View File

@@ -355,11 +355,10 @@ import {
import { UserPlus, SquarePen } from 'lucide-vue-next'
import { useFormDialog } from '@/composables/useFormDialog'
import { MultiSelect } from '@/components/common'
import { getProvidersSummary } from '@/api/endpoints/providers'
import { getGlobalModels } from '@/api/global-models'
import { adminApi } from '@/api/admin'
import { log } from '@/utils/logger'
import { parseNumberInput } from '@/utils/form'
import { useUserAccessControlOptions } from '@/features/users/composables/useUserAccessControlOptions'
import {
getPasswordPolicyHint,
getPasswordPolicyPlaceholder,
@@ -367,10 +366,6 @@ import {
validatePasswordByPolicy,
type PasswordPolicyLevel,
} from '@/utils/passwordPolicy'
import type {
ProviderWithEndpointsSummary,
GlobalModelResponse,
} from '@/api/endpoints/types'
export interface UserFormData {
id?: string
@@ -401,29 +396,12 @@ const saving = ref(false)
const formNonce = ref(createFieldNonce())
const passwordPolicyLevel = ref<PasswordPolicyLevel>('weak')
// 选项数据
const providers = ref<ProviderWithEndpointsSummary[]>([])
const globalModels = ref<GlobalModelResponse[]>([])
const apiFormats = ref<Array<{ value: string; label: string }>>([])
const providerOptions = computed(() =>
providers.value.map((provider) => ({
value: provider.id,
label: provider.name,
})),
)
const apiFormatOptions = computed(() =>
apiFormats.value.map((format) => ({
value: format.value,
label: format.label,
})),
)
const modelOptions = computed(() =>
globalModels.value.map((model) => ({
value: model.name,
label: model.name,
})),
)
const {
providerOptions,
apiFormatOptions,
modelOptions,
loadAccessControlOptions: loadAccessControlOptionLists,
} = useUserAccessControlOptions()
// 表单数据
const form = ref({
@@ -547,15 +525,10 @@ const isFormValid = computed(() => {
// 加载访问控制选项
async function loadAccessControlOptions(): Promise<void> {
try {
const [providersResponse, modelsData, formatsData, passwordPolicyResponse] = await Promise.all([
getProvidersSummary({ page_size: 9999 }),
getGlobalModels({ limit: 1000, is_active: true }),
adminApi.getApiFormats(),
const [, passwordPolicyResponse] = await Promise.all([
loadAccessControlOptionLists(),
adminApi.getSystemConfig('password_policy_level').catch(() => ({ value: 'weak' })),
])
providers.value = providersResponse.items
globalModels.value = modelsData.models || []
apiFormats.value = formatsData.formats || []
passwordPolicyLevel.value = normalizePasswordPolicyLevel(passwordPolicyResponse.value)
} catch (err) {
log.error('加载访问限制选项失败:', err)

View File

@@ -0,0 +1,52 @@
import { computed, ref } from 'vue'
import { getProvidersSummary } from '@/api/endpoints/providers'
import { getGlobalModels } from '@/api/global-models'
import { adminApi } from '@/api/admin'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints/types'
import type { GlobalModelResponse } from '@/api/global-models'
export function useUserAccessControlOptions() {
const providers = ref<ProviderWithEndpointsSummary[]>([])
const globalModels = ref<GlobalModelResponse[]>([])
const apiFormats = ref<Array<{ value: string; label: string }>>([])
const providerOptions = computed(() =>
providers.value.map((provider) => ({
value: provider.id,
label: provider.name,
})),
)
const apiFormatOptions = computed(() =>
apiFormats.value.map((format) => ({
value: format.value,
label: format.label,
})),
)
const modelOptions = computed(() =>
globalModels.value.map((model) => ({
value: model.name,
label: model.name,
})),
)
async function loadAccessControlOptions(): Promise<void> {
const [providersResponse, modelsData, formatsData] = await Promise.all([
getProvidersSummary({ page_size: 9999 }),
getGlobalModels({ limit: 1000, is_active: true }),
adminApi.getApiFormats(),
])
providers.value = providersResponse.items
globalModels.value = modelsData.models || []
apiFormats.value = formatsData.formats || []
}
return {
providers,
globalModels,
apiFormats,
providerOptions,
apiFormatOptions,
modelOptions,
loadAccessControlOptions,
}
}

View File

@@ -8,6 +8,10 @@ import {
type ApiKey,
type UpsertUserApiKeyRequest,
type UserSession,
type UserBatchSelection,
type ResolveUserBatchSelectionResponse,
type UserBatchActionRequest,
type UserBatchActionResponse,
} from '@/api/users'
import { parseApiError } from '@/utils/errorParser'
@@ -83,6 +87,30 @@ export const useUsersStore = defineStore('users', () => {
}
}
async function resolveBatchSelection(
selection: UserBatchSelection
): Promise<ResolveUserBatchSelectionResponse> {
try {
return await usersApi.resolveBatchSelection(selection)
} catch (err: unknown) {
error.value = parseApiError(err, '解析用户选择失败')
throw err
}
}
async function batchAction(request: UserBatchActionRequest): Promise<UserBatchActionResponse> {
loading.value = true
error.value = null
try {
return await usersApi.batchAction(request)
} catch (err: unknown) {
error.value = parseApiError(err, '批量操作用户失败')
throw err
} finally {
loading.value = false
}
}
async function getUserApiKeys(userId: string): Promise<ApiKey[]> {
try {
return await usersApi.getUserApiKeys(userId)
@@ -169,6 +197,8 @@ export const useUsersStore = defineStore('users', () => {
createUser,
updateUser,
deleteUser,
resolveBatchSelection,
batchAction,
getUserApiKeys,
createApiKey,
updateApiKey,

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { runChunkedBatchAction } from '../batchAction'
describe('runChunkedBatchAction', () => {
it('counts unreported items as skipped when chunk total is omitted', async () => {
const counts = await runChunkedBatchAction({
items: ['a', 'b', 'c'],
chunkSize: 3,
runChunk: async () => ({ success: 1, failed: 1 }),
})
expect(counts).toEqual({ success: 1, failed: 1, skipped: 1 })
})
it('keeps legacy total-based skipped fallback when chunk total is reported', async () => {
const counts = await runChunkedBatchAction({
items: ['a', 'b', 'c'],
chunkSize: 3,
runChunk: async () => ({ total: 2, success: 1, failed: 0 }),
})
expect(counts).toEqual({ success: 1, failed: 0, skipped: 1 })
})
})

View File

@@ -0,0 +1,61 @@
export interface BatchChunkCounts {
total?: number
success?: number
failed?: number
skipped?: number
}
export interface BatchChunkProgress<TItem> {
batch: TItem[]
batchIndex: number
totalBatches: number
processed: number
total: number
}
export interface BatchActionCounts {
success: number
failed: number
skipped: number
}
export async function runChunkedBatchAction<TItem>(options: {
items: TItem[]
chunkSize: number
runChunk: (batch: TItem[], context: BatchChunkProgress<TItem>) => Promise<BatchChunkCounts>
onChunkStart?: (context: BatchChunkProgress<TItem>) => void
onChunkDone?: (context: BatchChunkProgress<TItem>, counts: BatchChunkCounts) => void
}): Promise<BatchActionCounts> {
const chunkSize = Math.max(1, options.chunkSize)
const totalBatches = Math.ceil(options.items.length / chunkSize)
const counts: BatchActionCounts = { success: 0, failed: 0, skipped: 0 }
for (let offset = 0; offset < options.items.length; offset += chunkSize) {
const batch = options.items.slice(offset, offset + chunkSize)
const context: BatchChunkProgress<TItem> = {
batch,
batchIndex: Math.floor(offset / chunkSize) + 1,
totalBatches,
processed: Math.min(offset + batch.length, options.items.length),
total: options.items.length,
}
options.onChunkStart?.(context)
try {
const result = await options.runChunk(batch, context)
const success = Number(result.success ?? 0)
const failed = Number(result.failed ?? 0)
const skipped = result.skipped == null
? Math.max(0, batch.length - Number(result.total ?? success + failed))
: Number(result.skipped)
counts.success += success
counts.failed += failed
counts.skipped += skipped
options.onChunkDone?.(context, result)
} catch {
counts.failed += batch.length
options.onChunkDone?.(context, { total: batch.length, failed: batch.length })
}
}
return counts
}

View File

@@ -172,11 +172,62 @@
</div>
</div>
<div class="flex flex-col gap-2 border-b border-border/60 bg-muted/20 px-4 py-2.5 text-xs sm:flex-row sm:items-center sm:justify-between sm:px-6 xl:px-4">
<div class="flex flex-wrap items-center gap-2 text-muted-foreground">
<label class="flex items-center gap-2">
<Checkbox
:checked="isAllFilteredSelected"
:indeterminate="isPartiallyFilteredSelected"
:disabled="filteredUsers.length === 0 || usersStore.loading"
@update:checked="toggleSelectFiltered"
/>
<span>全选筛选结果</span>
</label>
<span>匹配 {{ filteredUsers.length }} 当前页 {{ paginatedUsers.length }} 已选 {{ selectedCount }} </span>
</div>
<div class="flex flex-wrap items-center gap-1.5">
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-[11px]"
:disabled="paginatedUsers.length === 0 || selectAllFiltered || usersStore.loading"
@click="toggleSelectCurrentPage"
>
{{ isCurrentPageFullySelected ? '取消本页全选' : '本页全选' }}
</Button>
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-[11px]"
:disabled="!canClearSelection || usersStore.loading"
@click="clearSelection"
>
清空选择
</Button>
<Button
size="sm"
class="h-7 px-3 text-[11px]"
:disabled="selectedCount === 0 || usersStore.loading"
@click="openUserBatchDialog"
>
批量操作
</Button>
</div>
</div>
<!-- 桌面端表格 -->
<div class="hidden xl:block overflow-x-auto">
<Table>
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="w-[44px] h-12 px-4">
<Checkbox
:checked="isCurrentPageFullySelected || isAllFilteredSelected"
:indeterminate="isPartiallyFilteredSelected && !isCurrentPageFullySelected"
:disabled="paginatedUsers.length === 0 || selectAllFiltered || usersStore.loading"
@update:checked="toggleSelectCurrentPage"
/>
</TableHead>
<SortableTableHead
class="w-[260px] h-12 font-semibold"
column-key="role"
@@ -231,6 +282,13 @@
:key="user.id"
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
>
<TableCell class="w-[44px] px-4 py-4">
<Checkbox
:checked="selectAllFiltered || selectedIdSet.has(user.id)"
:disabled="selectAllFiltered || usersStore.loading"
@update:checked="(checked) => toggleOne(user.id, checked === true)"
/>
</TableCell>
<TableCell class="py-4">
<div class="flex items-center gap-3">
<Avatar class="h-10 w-10 ring-2 ring-background shadow-md">
@@ -440,6 +498,12 @@
>
<div class="space-y-4">
<div class="flex items-start gap-3">
<Checkbox
class="mt-2 shrink-0"
:checked="selectAllFiltered || selectedIdSet.has(user.id)"
:disabled="selectAllFiltered || usersStore.loading"
@update:checked="(checked) => toggleOne(user.id, checked === true)"
/>
<Avatar class="h-10 w-10 ring-2 ring-background shadow-md flex-shrink-0">
<AvatarFallback class="bg-primary text-sm font-bold text-white">
{{ user.username.charAt(0).toUpperCase() }}
@@ -637,6 +701,16 @@
@submit="handleUserFormSubmit"
/>
<UserBatchActionDialog
:open="showUserBatchDialog"
:selected-ids="selectedIds"
:select-all-filtered="selectAllFiltered"
:selected-count="selectedCount"
:filters="batchSelectionFilters"
@close="showUserBatchDialog = false"
@completed="handleUserBatchCompleted"
/>
<!-- API Keys 管理对话框 -->
<Dialog
v-model="showApiKeysDialog"
@@ -1060,7 +1134,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useUsersStore } from '@/stores/users'
import type { User, ApiKey, UserSession } from '@/api/users'
import type { User, ApiKey, UserSession, UserBatchActionResponse, UserBatchSelectionFilters } from '@/api/users'
import { formatSessionMeta } from '@/types/session'
import { adminWalletApi, type AdminWallet } from '@/api/admin-wallets'
import { useToast } from '@/composables/useToast'
@@ -1093,7 +1167,8 @@ import {
Avatar,
AvatarFallback,
Pagination,
RefreshButton
RefreshButton,
Checkbox
} from '@/components/ui'
import {
@@ -1114,11 +1189,13 @@ import {
// 功能组件
import UserFormDialog, { type UserFormData } from '@/features/users/components/UserFormDialog.vue'
import UserBatchActionDialog from '@/features/users/components/UserBatchActionDialog.vue'
import WalletOpsDrawer from '@/features/wallet/components/WalletOpsDrawer.vue'
import { parseApiError } from '@/utils/errorParser'
import { formatTokens, formatRateLimitInheritable, formatRateLimitSimple, isRateLimitInherited, isRateLimitUnlimited } from '@/utils/format'
import { parseNumberInput } from '@/utils/form'
import { log } from '@/utils/logger'
import { useBatchSelection } from '@/composables/useBatchSelection'
const { success, error } = useToast()
const { confirmDanger } = useConfirm()
@@ -1155,6 +1232,7 @@ const userWalletMap = ref<Record<string, AdminWallet>>({})
const showWalletActionDialogState = ref(false)
const walletActionTarget = ref<{ user: User; wallet: AdminWallet } | null>(null)
const showUserBatchDialog = ref(false)
const searchQuery = ref('')
const filterRole = ref('all')
@@ -1215,11 +1293,46 @@ const paginatedUsers = computed(() => {
return filteredUsers.value.slice(start, start + pageSize.value)
})
const filteredUserCount = computed(() => filteredUsers.value.length)
const {
selectedIds,
selectAllFiltered,
selectedIdSet,
selectedCount,
isAllFilteredSelected,
isPartiallyFilteredSelected,
isCurrentPageFullySelected,
canClearSelection,
rememberItems: rememberBatchPageUsers,
resetSelection: resetBatchSelection,
toggleOne,
toggleSelectFiltered,
toggleSelectCurrentPage,
clearSelection,
} = useBatchSelection<User>({
pageItems: paginatedUsers,
filteredTotal: filteredUserCount,
getItemId: (user) => user.id,
})
const batchSelectionFilters = computed<UserBatchSelectionFilters>(() => {
const filters: UserBatchSelectionFilters = {}
const search = searchQuery.value.trim()
if (search) filters.search = search
if (filterRole.value === 'admin' || filterRole.value === 'user') filters.role = filterRole.value
if (filterStatus.value === 'active') filters.is_active = true
if (filterStatus.value === 'inactive') filters.is_active = false
return filters
})
// Watch filter changes and reset to first page
watch([searchQuery, filterRole, filterStatus], () => {
currentPage.value = 1
resetBatchSelection()
})
watch(paginatedUsers, (users) => rememberBatchPageUsers(users), { immediate: true })
onMounted(() => {
void refreshUsers({ preferCache: true })
})
@@ -1232,6 +1345,16 @@ async function refreshUsers(options: { preferCache?: boolean } = {}) {
})
}
function openUserBatchDialog(): void {
if (selectedCount.value === 0) return
showUserBatchDialog.value = true
}
async function handleUserBatchCompleted(_result: UserBatchActionResponse): Promise<void> {
await refreshUsers()
resetBatchSelection(true)
}
function formatDate(dateString: string) {
return new Date(dateString).toLocaleDateString('zh-CN')
}