mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
Merge remote-tracking branch 'entropy-xu/codex/user-groups-default-permissions' into aether-rust-pioneer
This commit is contained in:
@@ -3,6 +3,29 @@ import { cachedRequest } from '@/utils/cache'
|
||||
import type { UserSession as SessionRecord } from '@/types/session'
|
||||
|
||||
export type UserRole = 'admin' | 'user'
|
||||
export type ListPolicyMode = 'inherit' | 'unrestricted' | 'specific' | 'deny_all'
|
||||
export type RateLimitPolicyMode = 'inherit' | 'system' | 'custom'
|
||||
|
||||
export interface UserGroupSummary {
|
||||
id: string
|
||||
name: string
|
||||
priority: number
|
||||
}
|
||||
|
||||
export interface EffectivePolicyField<T> {
|
||||
mode: string
|
||||
value: T | null
|
||||
source: 'user' | 'group' | 'fallback' | string
|
||||
group_id?: string | null
|
||||
group_name?: string | null
|
||||
}
|
||||
|
||||
export interface UserEffectivePolicy {
|
||||
allowed_providers?: EffectivePolicyField<string[]>
|
||||
allowed_api_formats?: EffectivePolicyField<string[]>
|
||||
allowed_models?: EffectivePolicyField<string[]>
|
||||
rate_limit?: EffectivePolicyField<number>
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string // UUID
|
||||
@@ -12,9 +35,15 @@ export interface User {
|
||||
is_active: boolean
|
||||
unlimited: boolean
|
||||
allowed_providers: string[] | null // 允许使用的提供商 ID 列表
|
||||
allowed_providers_mode?: ListPolicyMode
|
||||
allowed_api_formats: string[] | null // 允许使用的 API 格式列表
|
||||
allowed_api_formats_mode?: ListPolicyMode
|
||||
allowed_models: string[] | null // 允许使用的模型名称列表
|
||||
allowed_models_mode?: ListPolicyMode
|
||||
rate_limit?: number | null // null = 跟随系统默认,0 = 不限制
|
||||
rate_limit_mode?: RateLimitPolicyMode
|
||||
groups?: UserGroupSummary[]
|
||||
effective_policy?: UserEffectivePolicy
|
||||
created_at: string
|
||||
updated_at?: string
|
||||
last_login_at?: string | null
|
||||
@@ -30,9 +59,14 @@ export interface CreateUserRequest {
|
||||
initial_gift_usd?: number | null
|
||||
unlimited?: boolean
|
||||
allowed_providers?: string[] | null
|
||||
allowed_providers_mode?: ListPolicyMode
|
||||
allowed_api_formats?: string[] | null
|
||||
allowed_api_formats_mode?: ListPolicyMode
|
||||
allowed_models?: string[] | null
|
||||
allowed_models_mode?: ListPolicyMode
|
||||
rate_limit?: number | null
|
||||
rate_limit_mode?: RateLimitPolicyMode
|
||||
group_ids?: string[]
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
@@ -42,19 +76,26 @@ export interface UpdateUserRequest {
|
||||
unlimited?: boolean
|
||||
password?: string
|
||||
allowed_providers?: string[] | null
|
||||
allowed_providers_mode?: ListPolicyMode
|
||||
allowed_api_formats?: string[] | null
|
||||
allowed_api_formats_mode?: ListPolicyMode
|
||||
allowed_models?: string[] | null
|
||||
allowed_models_mode?: ListPolicyMode
|
||||
rate_limit?: number | null
|
||||
rate_limit_mode?: RateLimitPolicyMode
|
||||
group_ids?: string[]
|
||||
}
|
||||
|
||||
export interface UserBatchSelectionFilters {
|
||||
search?: string
|
||||
role?: UserRole
|
||||
is_active?: boolean
|
||||
group_id?: string
|
||||
}
|
||||
|
||||
export interface UserBatchSelection {
|
||||
user_ids?: string[]
|
||||
group_ids?: string[]
|
||||
filters?: UserBatchSelectionFilters | null
|
||||
}
|
||||
|
||||
@@ -64,11 +105,19 @@ export interface UserBatchSelectionItem {
|
||||
email?: string | null
|
||||
role: UserRole
|
||||
is_active: boolean
|
||||
matched_by?: string[]
|
||||
}
|
||||
|
||||
export interface UserBatchSelectionWarning {
|
||||
type: string
|
||||
group_id?: string | null
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface ResolveUserBatchSelectionResponse {
|
||||
total: number
|
||||
items: UserBatchSelectionItem[]
|
||||
warnings?: UserBatchSelectionWarning[]
|
||||
}
|
||||
|
||||
export interface UserBatchAccessControlPayload {
|
||||
@@ -120,10 +169,60 @@ export interface UserBatchActionResponse {
|
||||
success: number
|
||||
failed: number
|
||||
failures: UserBatchActionFailure[]
|
||||
warnings?: UserBatchSelectionWarning[]
|
||||
action?: string
|
||||
modified_fields?: string[]
|
||||
}
|
||||
|
||||
export interface UserGroup {
|
||||
id: string
|
||||
name: string
|
||||
normalized_name?: string
|
||||
description?: string | null
|
||||
priority: number
|
||||
allowed_providers?: string[] | null
|
||||
allowed_providers_mode: ListPolicyMode
|
||||
allowed_api_formats?: string[] | null
|
||||
allowed_api_formats_mode: ListPolicyMode
|
||||
allowed_models?: string[] | null
|
||||
allowed_models_mode: ListPolicyMode
|
||||
rate_limit?: number | null
|
||||
rate_limit_mode: RateLimitPolicyMode
|
||||
is_default?: boolean
|
||||
created_at?: string | null
|
||||
updated_at?: string | null
|
||||
}
|
||||
|
||||
export interface UpsertUserGroupRequest {
|
||||
name: string
|
||||
description?: string | null
|
||||
priority?: number
|
||||
allowed_providers?: string[] | null
|
||||
allowed_providers_mode?: ListPolicyMode
|
||||
allowed_api_formats?: string[] | null
|
||||
allowed_api_formats_mode?: ListPolicyMode
|
||||
allowed_models?: string[] | null
|
||||
allowed_models_mode?: ListPolicyMode
|
||||
rate_limit?: number | null
|
||||
rate_limit_mode?: RateLimitPolicyMode
|
||||
}
|
||||
|
||||
export interface UserGroupMember {
|
||||
group_id: string
|
||||
user_id: string
|
||||
username: string
|
||||
email?: string | null
|
||||
role: UserRole
|
||||
is_active: boolean
|
||||
is_deleted: boolean
|
||||
created_at?: string | null
|
||||
}
|
||||
|
||||
export interface ListUserGroupsResponse {
|
||||
items: UserGroup[]
|
||||
default_group_id?: string | null
|
||||
}
|
||||
|
||||
export interface ApiKey {
|
||||
id: string // UUID
|
||||
key?: string // 完整的 key,只在创建时返回
|
||||
@@ -151,6 +250,9 @@ export type UserSession = SessionRecord
|
||||
|
||||
export interface GetAllUsersOptions {
|
||||
search?: string
|
||||
role?: UserRole
|
||||
is_active?: boolean
|
||||
group_id?: string
|
||||
skip?: number
|
||||
limit?: number
|
||||
cacheTtlMs?: number
|
||||
@@ -163,6 +265,9 @@ export const usersApi = {
|
||||
const search = options.search?.trim()
|
||||
|
||||
if (search) params.search = search
|
||||
if (options.role) params.role = options.role
|
||||
if (options.is_active !== undefined) params.is_active = options.is_active ? 'true' : 'false'
|
||||
if (options.group_id) params.group_id = options.group_id
|
||||
if (options.skip !== undefined) params.skip = options.skip
|
||||
if (options.limit !== undefined) params.limit = options.limit
|
||||
|
||||
@@ -171,6 +276,9 @@ export const usersApi = {
|
||||
: [
|
||||
'admin:users:list',
|
||||
search ?? '',
|
||||
options.role ?? '',
|
||||
options.is_active ?? '',
|
||||
options.group_id ?? '',
|
||||
options.skip ?? '',
|
||||
options.limit ?? '',
|
||||
].join(':')
|
||||
@@ -220,6 +328,46 @@ export const usersApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
async listUserGroups(): Promise<ListUserGroupsResponse> {
|
||||
const response = await apiClient.get<ListUserGroupsResponse>('/api/admin/user-groups')
|
||||
return response.data
|
||||
},
|
||||
|
||||
async createUserGroup(payload: UpsertUserGroupRequest): Promise<UserGroup> {
|
||||
const response = await apiClient.post<UserGroup>('/api/admin/user-groups', payload)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async updateUserGroup(groupId: string, payload: UpsertUserGroupRequest): Promise<UserGroup> {
|
||||
const response = await apiClient.put<UserGroup>(`/api/admin/user-groups/${groupId}`, payload)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async deleteUserGroup(groupId: string): Promise<void> {
|
||||
await apiClient.delete(`/api/admin/user-groups/${groupId}`)
|
||||
},
|
||||
|
||||
async listUserGroupMembers(groupId: string): Promise<UserGroupMember[]> {
|
||||
const response = await apiClient.get<{ items: UserGroupMember[] }>(`/api/admin/user-groups/${groupId}/members`)
|
||||
return response.data.items
|
||||
},
|
||||
|
||||
async replaceUserGroupMembers(groupId: string, userIds: string[]): Promise<UserGroupMember[]> {
|
||||
const response = await apiClient.put<{ items: UserGroupMember[] }>(
|
||||
`/api/admin/user-groups/${groupId}/members`,
|
||||
{ user_ids: userIds },
|
||||
)
|
||||
return response.data.items
|
||||
},
|
||||
|
||||
async setDefaultUserGroup(groupId: string | null): Promise<{ default_group_id?: string | null }> {
|
||||
const response = await apiClient.put<{ default_group_id?: string | null }>(
|
||||
'/api/admin/user-groups/default',
|
||||
{ group_id: groupId },
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async deleteUser(userId: string): Promise<void> {
|
||||
await apiClient.delete(`/api/admin/users/${userId}`)
|
||||
},
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-h-48 overflow-y-auto">
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
<div
|
||||
v-if="hasOptions"
|
||||
class="sticky top-0 z-10 flex cursor-pointer items-center gap-2 border-b bg-popover/95 px-3 py-2 backdrop-blur hover:bg-muted/50 supports-[backdrop-filter]:bg-popover/85"
|
||||
|
||||
@@ -52,6 +52,20 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-2.5">
|
||||
<div class="grid gap-2 rounded-xl border border-border/70 bg-muted/20 p-3 sm:grid-cols-[9rem_minmax(0,1fr)] sm:items-start">
|
||||
<div>
|
||||
<Label class="text-sm font-medium">按分组选择</Label>
|
||||
<p class="mt-1 text-[11px] text-muted-foreground">可与直接用户或筛选条件混合</p>
|
||||
</div>
|
||||
<MultiSelect
|
||||
v-model="selectedGroupIds"
|
||||
:options="groupOptions"
|
||||
:search-threshold="0"
|
||||
placeholder="选择一个或多个分组"
|
||||
empty-text="暂无用户分组"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<Label class="text-sm font-medium">选择批量动作</Label>
|
||||
<span class="text-[11px] text-muted-foreground">只会提交当前动作对应的字段</span>
|
||||
@@ -339,6 +353,7 @@ import type {
|
||||
UserBatchSelectionFilters,
|
||||
UserBatchSelectionItem,
|
||||
UserRole,
|
||||
UserGroup,
|
||||
} from '@/api/users'
|
||||
|
||||
type AccessFieldMode = 'skip' | 'unrestricted' | 'specific'
|
||||
@@ -358,6 +373,7 @@ const props = defineProps<{
|
||||
selectAllFiltered: boolean
|
||||
selectedCount: number
|
||||
filters: UserBatchSelectionFilters
|
||||
groups: UserGroup[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -408,6 +424,7 @@ const apiFormatMode = ref<AccessFieldMode>('skip')
|
||||
const modelMode = ref<AccessFieldMode>('skip')
|
||||
const rateLimitMode = ref<RateLimitMode>('skip')
|
||||
const quotaMode = ref<QuotaMode>('skip')
|
||||
const selectedGroupIds = ref<string[]>([])
|
||||
const allowedProviders = ref<string[]>([])
|
||||
const allowedApiFormats = ref<string[]>([])
|
||||
const allowedModels = ref<string[]>([])
|
||||
@@ -418,8 +435,13 @@ const resolvedTotal = ref<number | null>(null)
|
||||
const executing = ref(false)
|
||||
const lastResult = ref<UserBatchActionResponse | null>(null)
|
||||
|
||||
const groupOptions = computed(() => props.groups.map((group) => ({
|
||||
label: `${group.name}${group.is_default ? '(默认)' : ''}`,
|
||||
value: group.id,
|
||||
})))
|
||||
const hasAnyTarget = computed(() => props.selectedCount > 0 || selectedGroupIds.value.length > 0)
|
||||
const impactCount = computed(() => resolvedTotal.value ?? props.selectedCount)
|
||||
const canExecute = computed(() => props.selectedCount > 0 && !previewLoading.value && !executing.value)
|
||||
const canExecute = computed(() => hasAnyTarget.value && !previewLoading.value && !executing.value)
|
||||
const selectedActionLabel = computed(() => (
|
||||
actionOptions.find((action) => action.value === selectedAction.value)?.label ?? '批量操作'
|
||||
))
|
||||
@@ -456,6 +478,7 @@ function resetLocalState(): void {
|
||||
modelMode.value = 'skip'
|
||||
rateLimitMode.value = 'skip'
|
||||
quotaMode.value = 'skip'
|
||||
selectedGroupIds.value = []
|
||||
allowedProviders.value = []
|
||||
allowedApiFormats.value = []
|
||||
allowedModels.value = []
|
||||
@@ -482,14 +505,15 @@ function actionIconClass(action: UserBatchAction): string {
|
||||
}
|
||||
|
||||
function buildSelection(): UserBatchSelection {
|
||||
const group_ids = selectedGroupIds.value.length > 0 ? [...selectedGroupIds.value] : undefined
|
||||
if (props.selectAllFiltered) {
|
||||
return { filters: props.filters }
|
||||
return { filters: props.filters, group_ids }
|
||||
}
|
||||
return { user_ids: [...props.selectedIds] }
|
||||
return { user_ids: [...props.selectedIds], group_ids }
|
||||
}
|
||||
|
||||
async function resolvePreview(): Promise<void> {
|
||||
if (props.selectedCount === 0) {
|
||||
if (!hasAnyTarget.value) {
|
||||
resolvedTotal.value = 0
|
||||
previewItems.value = []
|
||||
return
|
||||
@@ -508,6 +532,10 @@ async function resolvePreview(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
watch(selectedGroupIds, () => {
|
||||
if (props.open) void resolvePreview()
|
||||
})
|
||||
|
||||
function buildAccessControlPayload(): UserBatchAccessControlPayload | null {
|
||||
const payload: UserBatchAccessControlPayload = {}
|
||||
if (providerMode.value === 'unrestricted') payload.allowed_providers = null
|
||||
|
||||
@@ -180,6 +180,18 @@
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">所属分组</Label>
|
||||
<MultiSelect
|
||||
v-model="form.group_ids"
|
||||
:options="groupOptions"
|
||||
:search-threshold="0"
|
||||
placeholder="可选择多个分组"
|
||||
empty-text="暂无分组"
|
||||
no-results-text="未找到匹配的分组"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:访问限制 -->
|
||||
@@ -191,22 +203,27 @@
|
||||
<!-- 提供商 -->
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">允许的提供商</Label>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<MultiSelect
|
||||
v-model="form.allowed_providers"
|
||||
:options="providerOptions"
|
||||
:search-threshold="0"
|
||||
:disabled="form.provider_unrestricted"
|
||||
:placeholder="form.provider_unrestricted ? '不限制' : '未选择(全部禁用)'"
|
||||
empty-text="暂无可用提供商"
|
||||
no-results-text="未找到匹配的提供商"
|
||||
search-placeholder="搜索提供商名称..."
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
v-model="form.provider_unrestricted"
|
||||
class="shrink-0"
|
||||
<div class="grid gap-2 sm:grid-cols-[7rem_minmax(0,1fr)]">
|
||||
<Select v-model="form.allowed_providers_mode">
|
||||
<SelectTrigger class="h-10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="inherit">继承</SelectItem>
|
||||
<SelectItem value="unrestricted">不限制</SelectItem>
|
||||
<SelectItem value="specific">指定列表</SelectItem>
|
||||
<SelectItem value="deny_all">全部禁用</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<MultiSelect
|
||||
v-model="form.allowed_providers"
|
||||
:options="providerOptions"
|
||||
:search-threshold="0"
|
||||
:disabled="form.allowed_providers_mode !== 'specific'"
|
||||
placeholder="未选择时表示全部禁用"
|
||||
empty-text="暂无可用提供商"
|
||||
no-results-text="未找到匹配的提供商"
|
||||
search-placeholder="搜索提供商名称..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -214,22 +231,27 @@
|
||||
<!-- 端点 -->
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">允许的端点</Label>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<MultiSelect
|
||||
v-model="form.allowed_api_formats"
|
||||
:options="apiFormatOptions"
|
||||
:search-threshold="0"
|
||||
:disabled="form.api_format_unrestricted"
|
||||
:placeholder="form.api_format_unrestricted ? '不限制' : '未选择(全部禁用)'"
|
||||
empty-text="暂无可用端点"
|
||||
no-results-text="未找到匹配的端点"
|
||||
search-placeholder="搜索端点..."
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
v-model="form.api_format_unrestricted"
|
||||
class="shrink-0"
|
||||
<div class="grid gap-2 sm:grid-cols-[7rem_minmax(0,1fr)]">
|
||||
<Select v-model="form.allowed_api_formats_mode">
|
||||
<SelectTrigger class="h-10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="inherit">继承</SelectItem>
|
||||
<SelectItem value="unrestricted">不限制</SelectItem>
|
||||
<SelectItem value="specific">指定列表</SelectItem>
|
||||
<SelectItem value="deny_all">全部禁用</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<MultiSelect
|
||||
v-model="form.allowed_api_formats"
|
||||
:options="apiFormatOptions"
|
||||
:search-threshold="0"
|
||||
:disabled="form.allowed_api_formats_mode !== 'specific'"
|
||||
placeholder="未选择时表示全部禁用"
|
||||
empty-text="暂无可用端点"
|
||||
no-results-text="未找到匹配的端点"
|
||||
search-placeholder="搜索端点..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -237,22 +259,27 @@
|
||||
<!-- 模型 -->
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">允许的模型</Label>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<MultiSelect
|
||||
v-model="form.allowed_models"
|
||||
:options="modelOptions"
|
||||
:search-threshold="0"
|
||||
:disabled="form.model_unrestricted"
|
||||
:placeholder="form.model_unrestricted ? '不限制' : '未选择(全部禁用)'"
|
||||
empty-text="暂无可用模型"
|
||||
no-results-text="未找到匹配的模型"
|
||||
search-placeholder="输入模型名搜索..."
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
v-model="form.model_unrestricted"
|
||||
class="shrink-0"
|
||||
<div class="grid gap-2 sm:grid-cols-[7rem_minmax(0,1fr)]">
|
||||
<Select v-model="form.allowed_models_mode">
|
||||
<SelectTrigger class="h-10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="inherit">继承</SelectItem>
|
||||
<SelectItem value="unrestricted">不限制</SelectItem>
|
||||
<SelectItem value="specific">指定列表</SelectItem>
|
||||
<SelectItem value="deny_all">全部禁用</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<MultiSelect
|
||||
v-model="form.allowed_models"
|
||||
:options="modelOptions"
|
||||
:search-threshold="0"
|
||||
:disabled="form.allowed_models_mode !== 'specific'"
|
||||
placeholder="未选择时表示全部禁用"
|
||||
empty-text="暂无可用模型"
|
||||
no-results-text="未找到匹配的模型"
|
||||
search-placeholder="输入模型名搜索..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -263,9 +290,18 @@
|
||||
class="text-sm font-medium"
|
||||
>速率限制 (请求/分钟)</Label>
|
||||
<div class="flex items-center gap-3">
|
||||
<Select v-model="form.rate_limit_mode">
|
||||
<SelectTrigger class="h-10 w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="inherit">继承</SelectItem>
|
||||
<SelectItem value="system">系统默认</SelectItem>
|
||||
<SelectItem value="custom">指定数值</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div class="flex-1 min-w-0">
|
||||
<Input
|
||||
v-if="!form.rate_limit_inherited"
|
||||
id="form-rate-limit"
|
||||
:model-value="form.rate_limit ?? ''"
|
||||
type="number"
|
||||
@@ -273,17 +309,10 @@
|
||||
max="10000"
|
||||
placeholder="0 = 不限速"
|
||||
class="h-10"
|
||||
:disabled="form.rate_limit_mode !== 'custom'"
|
||||
@update:model-value="(v) => form.rate_limit = parseNumberInput(v, { min: 0, max: 10000 })"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="flex h-10 w-full items-center rounded-lg border bg-background px-3 text-sm text-muted-foreground opacity-60"
|
||||
>跟随系统默认</span>
|
||||
</div>
|
||||
<Switch
|
||||
v-model="form.rate_limit_inherited"
|
||||
class="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -366,6 +395,7 @@ import {
|
||||
validatePasswordByPolicy,
|
||||
type PasswordPolicyLevel,
|
||||
} from '@/utils/passwordPolicy'
|
||||
import type { ListPolicyMode, RateLimitPolicyMode, UserGroup } from '@/api/users'
|
||||
|
||||
export interface UserFormData {
|
||||
id?: string
|
||||
@@ -376,14 +406,20 @@ export interface UserFormData {
|
||||
role: 'admin' | 'user'
|
||||
is_active?: boolean
|
||||
allowed_providers?: string[] | null
|
||||
allowed_providers_mode?: ListPolicyMode
|
||||
allowed_api_formats?: string[] | null
|
||||
allowed_api_formats_mode?: ListPolicyMode
|
||||
allowed_models?: string[] | null
|
||||
allowed_models_mode?: ListPolicyMode
|
||||
rate_limit?: number | null
|
||||
rate_limit_mode?: RateLimitPolicyMode
|
||||
group_ids?: string[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
user: UserFormData | null
|
||||
groups?: UserGroup[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -413,16 +449,22 @@ const form = ref({
|
||||
role: 'user' as 'admin' | 'user',
|
||||
unlimited: false,
|
||||
is_active: true,
|
||||
provider_unrestricted: true,
|
||||
api_format_unrestricted: true,
|
||||
model_unrestricted: true,
|
||||
rate_limit_inherited: true,
|
||||
allowed_providers_mode: 'unrestricted' as ListPolicyMode,
|
||||
allowed_api_formats_mode: 'unrestricted' as ListPolicyMode,
|
||||
allowed_models_mode: 'unrestricted' as ListPolicyMode,
|
||||
rate_limit_mode: 'system' as RateLimitPolicyMode,
|
||||
allowed_providers: [] as string[],
|
||||
allowed_api_formats: [] as string[],
|
||||
allowed_models: [] as string[],
|
||||
rate_limit: undefined as number | undefined,
|
||||
group_ids: [] as string[],
|
||||
})
|
||||
|
||||
const groupOptions = computed(() => (props.groups || []).map((group) => ({
|
||||
label: group.name,
|
||||
value: group.id,
|
||||
})))
|
||||
|
||||
function createFieldNonce(): string {
|
||||
return Math.random().toString(36).slice(2, 10)
|
||||
}
|
||||
@@ -438,14 +480,15 @@ function resetForm() {
|
||||
role: 'user',
|
||||
unlimited: false,
|
||||
is_active: true,
|
||||
provider_unrestricted: true,
|
||||
api_format_unrestricted: true,
|
||||
model_unrestricted: true,
|
||||
rate_limit_inherited: true,
|
||||
allowed_providers_mode: 'unrestricted',
|
||||
allowed_api_formats_mode: 'unrestricted',
|
||||
allowed_models_mode: 'unrestricted',
|
||||
rate_limit_mode: 'system',
|
||||
allowed_providers: [],
|
||||
allowed_api_formats: [],
|
||||
allowed_models: [],
|
||||
rate_limit: undefined,
|
||||
group_ids: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,14 +505,15 @@ function loadUserData() {
|
||||
role: props.user.role,
|
||||
unlimited: props.user.unlimited ?? false,
|
||||
is_active: props.user.is_active ?? true,
|
||||
provider_unrestricted: props.user.allowed_providers == null,
|
||||
api_format_unrestricted: props.user.allowed_api_formats == null,
|
||||
model_unrestricted: props.user.allowed_models == null,
|
||||
rate_limit_inherited: props.user.rate_limit == null,
|
||||
allowed_providers_mode: props.user.allowed_providers_mode ?? (props.user.allowed_providers == null ? 'unrestricted' : 'specific'),
|
||||
allowed_api_formats_mode: props.user.allowed_api_formats_mode ?? (props.user.allowed_api_formats == null ? 'unrestricted' : 'specific'),
|
||||
allowed_models_mode: props.user.allowed_models_mode ?? (props.user.allowed_models == null ? 'unrestricted' : 'specific'),
|
||||
rate_limit_mode: props.user.rate_limit_mode ?? (props.user.rate_limit == null ? 'system' : 'custom'),
|
||||
allowed_providers: props.user.allowed_providers ? [...props.user.allowed_providers] : [],
|
||||
allowed_api_formats: props.user.allowed_api_formats ? [...props.user.allowed_api_formats] : [],
|
||||
allowed_models: props.user.allowed_models ? [...props.user.allowed_models] : [],
|
||||
rate_limit: props.user.rate_limit ?? undefined,
|
||||
group_ids: props.user.group_ids ? [...props.user.group_ids] : [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -545,16 +589,21 @@ async function handleSubmit() {
|
||||
email: form.value.email.trim() || '',
|
||||
unlimited: form.value.unlimited,
|
||||
role: form.value.role,
|
||||
allowed_providers: form.value.provider_unrestricted
|
||||
? null
|
||||
: [...form.value.allowed_providers],
|
||||
allowed_api_formats: form.value.api_format_unrestricted
|
||||
? null
|
||||
: [...form.value.allowed_api_formats],
|
||||
allowed_models: form.value.model_unrestricted
|
||||
? null
|
||||
: [...form.value.allowed_models],
|
||||
rate_limit: form.value.rate_limit_inherited ? null : (form.value.rate_limit ?? 0),
|
||||
allowed_providers: form.value.allowed_providers_mode === 'specific'
|
||||
? [...form.value.allowed_providers]
|
||||
: null,
|
||||
allowed_providers_mode: form.value.allowed_providers_mode,
|
||||
allowed_api_formats: form.value.allowed_api_formats_mode === 'specific'
|
||||
? [...form.value.allowed_api_formats]
|
||||
: null,
|
||||
allowed_api_formats_mode: form.value.allowed_api_formats_mode,
|
||||
allowed_models: form.value.allowed_models_mode === 'specific'
|
||||
? [...form.value.allowed_models]
|
||||
: null,
|
||||
allowed_models_mode: form.value.allowed_models_mode,
|
||||
rate_limit: form.value.rate_limit_mode === 'custom' ? (form.value.rate_limit ?? 0) : null,
|
||||
rate_limit_mode: form.value.rate_limit_mode,
|
||||
group_ids: [...form.value.group_ids],
|
||||
}
|
||||
|
||||
if (isEditMode.value && props.user?.id) {
|
||||
|
||||
543
frontend/src/features/users/components/UserGroupsDialog.vue
Normal file
543
frontend/src/features/users/components/UserGroupsDialog.vue
Normal file
@@ -0,0 +1,543 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="open"
|
||||
title="用户分组"
|
||||
description="管理用户组、默认注册组、成员和组级访问控制"
|
||||
size="6xl"
|
||||
persistent
|
||||
@update:model-value="handleDialogUpdate"
|
||||
>
|
||||
<div class="grid min-h-[560px] gap-4 lg:grid-cols-[17rem_minmax(0,1fr)]">
|
||||
<div class="rounded-xl border border-border/70 bg-muted/20 p-3">
|
||||
<div class="mb-3 flex items-center justify-between gap-2">
|
||||
<Label class="text-sm font-semibold">分组</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
class="h-8 px-2 text-xs"
|
||||
@click="startCreate"
|
||||
>
|
||||
<Plus class="mr-1.5 h-3.5 w-3.5" />
|
||||
新建
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loading"
|
||||
class="rounded-lg border border-dashed border-border/70 px-3 py-8 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
正在加载...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="groups.length === 0"
|
||||
class="rounded-lg border border-dashed border-border/70 px-3 py-8 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
暂无分组
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="space-y-1.5"
|
||||
>
|
||||
<button
|
||||
v-for="group in groups"
|
||||
:key="group.id"
|
||||
type="button"
|
||||
:class="groupButtonClass(group.id)"
|
||||
@click="selectGroup(group.id)"
|
||||
>
|
||||
<span class="min-w-0 flex-1 text-left">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="truncate text-sm font-medium">{{ group.name }}</span>
|
||||
<Badge
|
||||
v-if="group.is_default"
|
||||
variant="secondary"
|
||||
class="h-5 px-1.5 py-0 text-[10px]"
|
||||
>
|
||||
默认
|
||||
</Badge>
|
||||
</span>
|
||||
<span class="mt-0.5 block text-[11px] text-muted-foreground">
|
||||
优先级 {{ group.priority }}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 rounded-xl border border-border/70 bg-background p-4">
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h4 class="truncate text-base font-semibold text-foreground">
|
||||
{{ editingGroupId ? '编辑分组' : '新建分组' }}
|
||||
</h4>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ selectedGroup?.is_default ? '当前为自助注册默认组' : '默认组只影响本地注册和 OAuth 自动创建用户' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
v-if="editingGroupId"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 border-rose-200 px-2 text-xs text-rose-600 hover:bg-rose-50 dark:border-rose-900/60 dark:hover:bg-rose-950/40"
|
||||
:disabled="saving"
|
||||
@click="deleteSelectedGroup"
|
||||
>
|
||||
<Trash2 class="mr-1.5 h-3.5 w-3.5" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-5 lg:grid-cols-2">
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-3 sm:grid-cols-[minmax(0,1fr)_8rem]">
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">名称</Label>
|
||||
<Input
|
||||
v-model="form.name"
|
||||
class="h-10"
|
||||
placeholder="例如:生产团队"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">优先级</Label>
|
||||
<Input
|
||||
:model-value="form.priority"
|
||||
type="number"
|
||||
class="h-10"
|
||||
@update:model-value="(value) => form.priority = parseNumberInput(value, { min: -10000, max: 10000 }) ?? 0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 rounded-lg border border-border/70 bg-muted/20 px-3 py-2">
|
||||
<div class="min-w-0">
|
||||
<Label class="text-sm font-medium">默认注册组</Label>
|
||||
<div class="mt-0.5 text-[11px] text-muted-foreground">
|
||||
本地注册 / OAuth 自动创建
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
v-model="form.is_default"
|
||||
class="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">描述</Label>
|
||||
<Textarea
|
||||
v-model="form.description"
|
||||
class="min-h-20"
|
||||
placeholder="可选"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">成员</Label>
|
||||
<MultiSelect
|
||||
v-model="memberUserIds"
|
||||
:options="userOptions"
|
||||
:search-threshold="0"
|
||||
placeholder="选择用户"
|
||||
empty-text="暂无用户"
|
||||
no-results-text="未找到匹配用户"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 lg:border-l lg:border-border/60 lg:pl-5">
|
||||
<div class="flex items-baseline justify-between gap-2 pb-2 border-b border-border/60">
|
||||
<span class="text-sm font-medium">组权限</span>
|
||||
<span class="text-[11px] text-muted-foreground">
|
||||
用户选择继承时按优先级取首个已配置组
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<PolicyFieldEditor
|
||||
v-model:mode="form.allowed_providers_mode"
|
||||
v-model:values="form.allowed_providers"
|
||||
label="允许的提供商"
|
||||
:options="providerOptions"
|
||||
/>
|
||||
<PolicyFieldEditor
|
||||
v-model:mode="form.allowed_api_formats_mode"
|
||||
v-model:values="form.allowed_api_formats"
|
||||
label="允许的端点"
|
||||
:options="apiFormatOptions"
|
||||
/>
|
||||
<PolicyFieldEditor
|
||||
v-model:mode="form.allowed_models_mode"
|
||||
v-model:values="form.allowed_models"
|
||||
label="允许的模型"
|
||||
:options="modelOptions"
|
||||
/>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">速率限制 (请求/分钟)</Label>
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="w-28 shrink-0">
|
||||
<Select v-model="form.rate_limit_mode">
|
||||
<SelectTrigger class="h-10 w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="inherit">不配置</SelectItem>
|
||||
<SelectItem value="system">系统默认</SelectItem>
|
||||
<SelectItem value="custom">指定数值</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<Input
|
||||
:model-value="form.rate_limit ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
max="10000"
|
||||
class="h-10"
|
||||
:disabled="form.rate_limit_mode !== 'custom'"
|
||||
:placeholder="rateLimitPlaceholder"
|
||||
@update:model-value="(value) => form.rate_limit = parseNumberInput(value, { min: 0, max: 10000 })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="saving"
|
||||
@click="emit('close')"
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="saving || !form.name.trim()"
|
||||
@click="saveGroup"
|
||||
>
|
||||
{{ saving ? '保存中...' : '保存分组' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineComponent, h, ref, watch } from 'vue'
|
||||
import { ChevronRight, Plus, Trash2 } from 'lucide-vue-next'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Dialog,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Switch,
|
||||
Textarea,
|
||||
} from '@/components/ui'
|
||||
import { MultiSelect } from '@/components/common'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { parseNumberInput } from '@/utils/form'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserAccessControlOptions } from '@/features/users/composables/useUserAccessControlOptions'
|
||||
import type {
|
||||
ListPolicyMode,
|
||||
RateLimitPolicyMode,
|
||||
UpsertUserGroupRequest,
|
||||
User,
|
||||
UserGroup,
|
||||
} from '@/api/users'
|
||||
|
||||
const PolicyFieldEditor = defineComponent({
|
||||
name: 'PolicyFieldEditor',
|
||||
props: {
|
||||
label: { type: String, required: true },
|
||||
mode: { type: String as () => ListPolicyMode, required: true },
|
||||
values: { type: Array as () => string[], required: true },
|
||||
options: { type: Array as () => Array<{ label: string; value: string }>, required: true },
|
||||
},
|
||||
emits: ['update:mode', 'update:values'],
|
||||
setup(props, { emit }) {
|
||||
return () => h('div', { class: 'space-y-2' }, [
|
||||
h(Label, { class: 'text-sm font-medium' }, () => props.label),
|
||||
h('div', { class: 'flex items-start gap-2' }, [
|
||||
h('div', { class: 'w-28 shrink-0' }, [
|
||||
h(Select, {
|
||||
modelValue: props.mode,
|
||||
'onUpdate:modelValue': (value: string) => emit('update:mode', value),
|
||||
}, () => [
|
||||
h(SelectTrigger, { class: 'h-10 w-full' }, () => h(SelectValue)),
|
||||
h(SelectContent, null, () => [
|
||||
h(SelectItem, { value: 'inherit' }, () => '不配置'),
|
||||
h(SelectItem, { value: 'unrestricted' }, () => '不限制'),
|
||||
h(SelectItem, { value: 'specific' }, () => '指定列表'),
|
||||
h(SelectItem, { value: 'deny_all' }, () => '全部禁用'),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
h('div', { class: 'min-w-0 flex-1' }, [
|
||||
h(MultiSelect, {
|
||||
modelValue: props.values,
|
||||
'onUpdate:modelValue': (value: string[]) => emit('update:values', value),
|
||||
options: props.options,
|
||||
disabled: props.mode !== 'specific',
|
||||
searchThreshold: 0,
|
||||
placeholder: listPolicyValuePlaceholder(props.mode),
|
||||
emptyText: '暂无选项',
|
||||
dropdownMinWidth: '16rem',
|
||||
}),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
function listPolicyValuePlaceholder(mode: ListPolicyMode): string {
|
||||
switch (mode) {
|
||||
case 'inherit':
|
||||
return '该组不配置此项'
|
||||
case 'unrestricted':
|
||||
return '不限制所有选项'
|
||||
case 'deny_all':
|
||||
return '全部禁用'
|
||||
case 'specific':
|
||||
default:
|
||||
return '未选择时表示全部禁用'
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
users: User[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
changed: []
|
||||
}>()
|
||||
|
||||
const usersStore = useUsersStore()
|
||||
const { success, error } = useToast()
|
||||
const { confirmDanger } = useConfirm()
|
||||
const {
|
||||
providerOptions,
|
||||
apiFormatOptions,
|
||||
modelOptions,
|
||||
loadAccessControlOptions,
|
||||
} = useUserAccessControlOptions()
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const groups = ref<UserGroup[]>([])
|
||||
const defaultGroupId = ref<string | null>(null)
|
||||
const editingGroupId = ref<string | null>(null)
|
||||
const memberUserIds = ref<string[]>([])
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
priority: 0,
|
||||
is_default: false,
|
||||
allowed_providers_mode: 'inherit' as ListPolicyMode,
|
||||
allowed_api_formats_mode: 'inherit' as ListPolicyMode,
|
||||
allowed_models_mode: 'inherit' as ListPolicyMode,
|
||||
allowed_providers: [] as string[],
|
||||
allowed_api_formats: [] as string[],
|
||||
allowed_models: [] as string[],
|
||||
rate_limit_mode: 'inherit' as RateLimitPolicyMode,
|
||||
rate_limit: undefined as number | undefined,
|
||||
})
|
||||
|
||||
const selectedGroup = computed(() => groups.value.find((group) => group.id === editingGroupId.value) ?? null)
|
||||
const rateLimitPlaceholder = computed(() => {
|
||||
switch (form.value.rate_limit_mode) {
|
||||
case 'inherit':
|
||||
return '该组不配置速率'
|
||||
case 'system':
|
||||
return '使用系统默认'
|
||||
case 'custom':
|
||||
default:
|
||||
return '0 = 不限速'
|
||||
}
|
||||
})
|
||||
const userOptions = computed(() => props.users.map((user) => ({
|
||||
label: `${user.username}${user.email ? ` (${user.email})` : ''}`,
|
||||
value: user.id,
|
||||
})))
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (!open) return
|
||||
void loadDialogData()
|
||||
void loadAccessControlOptions().catch((err) => {
|
||||
error(parseApiError(err, '加载访问控制选项失败'))
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
function handleDialogUpdate(value: boolean): void {
|
||||
if (!value) emit('close')
|
||||
}
|
||||
|
||||
async function loadDialogData(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await usersStore.listUserGroups()
|
||||
groups.value = response.items
|
||||
defaultGroupId.value = response.default_group_id ?? null
|
||||
if (editingGroupId.value && !groups.value.some((group) => group.id === editingGroupId.value)) {
|
||||
editingGroupId.value = null
|
||||
}
|
||||
const nextGroup = editingGroupId.value
|
||||
? groups.value.find((group) => group.id === editingGroupId.value) ?? null
|
||||
: groups.value[0] ?? null
|
||||
if (nextGroup) {
|
||||
await selectGroup(nextGroup.id)
|
||||
} else {
|
||||
startCreate()
|
||||
}
|
||||
} catch (err) {
|
||||
error(parseApiError(err, '加载用户分组失败'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function selectGroup(groupId: string): Promise<void> {
|
||||
const group = groups.value.find((item) => item.id === groupId)
|
||||
if (!group) return
|
||||
editingGroupId.value = group.id
|
||||
form.value = {
|
||||
name: group.name,
|
||||
description: group.description ?? '',
|
||||
priority: group.priority,
|
||||
is_default: group.is_default === true,
|
||||
allowed_providers_mode: group.allowed_providers_mode,
|
||||
allowed_api_formats_mode: group.allowed_api_formats_mode,
|
||||
allowed_models_mode: group.allowed_models_mode,
|
||||
allowed_providers: group.allowed_providers ? [...group.allowed_providers] : [],
|
||||
allowed_api_formats: group.allowed_api_formats ? [...group.allowed_api_formats] : [],
|
||||
allowed_models: group.allowed_models ? [...group.allowed_models] : [],
|
||||
rate_limit_mode: group.rate_limit_mode,
|
||||
rate_limit: group.rate_limit ?? undefined,
|
||||
}
|
||||
try {
|
||||
const members = await usersStore.listUserGroupMembers(group.id)
|
||||
memberUserIds.value = members.map((member) => member.user_id)
|
||||
} catch (err) {
|
||||
memberUserIds.value = []
|
||||
error(parseApiError(err, '加载分组成员失败'))
|
||||
}
|
||||
}
|
||||
|
||||
function startCreate(): void {
|
||||
editingGroupId.value = null
|
||||
form.value = {
|
||||
name: '',
|
||||
description: '',
|
||||
priority: 0,
|
||||
is_default: false,
|
||||
allowed_providers_mode: 'inherit',
|
||||
allowed_api_formats_mode: 'inherit',
|
||||
allowed_models_mode: 'inherit',
|
||||
allowed_providers: [],
|
||||
allowed_api_formats: [],
|
||||
allowed_models: [],
|
||||
rate_limit_mode: 'inherit',
|
||||
rate_limit: undefined,
|
||||
}
|
||||
memberUserIds.value = []
|
||||
}
|
||||
|
||||
function groupButtonClass(groupId: string): string {
|
||||
return cn(
|
||||
'flex w-full items-center gap-2 rounded-lg border px-3 py-2 transition-colors',
|
||||
editingGroupId.value === groupId
|
||||
? 'border-primary/50 bg-primary/10'
|
||||
: 'border-transparent hover:border-border hover:bg-background',
|
||||
)
|
||||
}
|
||||
|
||||
function buildPayload(): UpsertUserGroupRequest {
|
||||
return {
|
||||
name: form.value.name.trim(),
|
||||
description: form.value.description.trim() || null,
|
||||
priority: form.value.priority,
|
||||
allowed_providers_mode: form.value.allowed_providers_mode,
|
||||
allowed_api_formats_mode: form.value.allowed_api_formats_mode,
|
||||
allowed_models_mode: form.value.allowed_models_mode,
|
||||
allowed_providers: form.value.allowed_providers_mode === 'specific'
|
||||
? [...form.value.allowed_providers]
|
||||
: null,
|
||||
allowed_api_formats: form.value.allowed_api_formats_mode === 'specific'
|
||||
? [...form.value.allowed_api_formats]
|
||||
: null,
|
||||
allowed_models: form.value.allowed_models_mode === 'specific'
|
||||
? [...form.value.allowed_models]
|
||||
: null,
|
||||
rate_limit_mode: form.value.rate_limit_mode,
|
||||
rate_limit: form.value.rate_limit_mode === 'custom'
|
||||
? (form.value.rate_limit ?? 0)
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
async function saveGroup(): Promise<void> {
|
||||
if (!form.value.name.trim()) return
|
||||
saving.value = true
|
||||
try {
|
||||
const wasDefault = selectedGroup.value?.is_default === true
|
||||
const wantsDefault = form.value.is_default
|
||||
const saved = editingGroupId.value
|
||||
? await usersStore.updateUserGroup(editingGroupId.value, buildPayload())
|
||||
: await usersStore.createUserGroup(buildPayload())
|
||||
await usersStore.replaceUserGroupMembers(saved.id, memberUserIds.value)
|
||||
if (wantsDefault) {
|
||||
await usersStore.setDefaultUserGroup(saved.id)
|
||||
} else if (wasDefault) {
|
||||
await usersStore.setDefaultUserGroup(null)
|
||||
}
|
||||
success('用户分组已保存')
|
||||
emit('changed')
|
||||
editingGroupId.value = saved.id
|
||||
await loadDialogData()
|
||||
} catch (err) {
|
||||
error(parseApiError(err, '保存用户分组失败'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSelectedGroup(): Promise<void> {
|
||||
if (!selectedGroup.value) return
|
||||
const group = selectedGroup.value
|
||||
const confirmed = await confirmDanger(
|
||||
`确定要删除用户分组 ${group.name} 吗?成员关系会一并清理。`,
|
||||
'删除用户分组',
|
||||
)
|
||||
if (!confirmed) return
|
||||
saving.value = true
|
||||
try {
|
||||
await usersStore.deleteUserGroup(group.id)
|
||||
success('用户分组已删除')
|
||||
emit('changed')
|
||||
editingGroupId.value = null
|
||||
await loadDialogData()
|
||||
} catch (err) {
|
||||
error(parseApiError(err, '删除用户分组失败'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
type ResolveUserBatchSelectionResponse,
|
||||
type UserBatchActionRequest,
|
||||
type UserBatchActionResponse,
|
||||
type UserGroup,
|
||||
type UserGroupMember,
|
||||
type UpsertUserGroupRequest,
|
||||
type ListUserGroupsResponse,
|
||||
} from '@/api/users'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
|
||||
@@ -20,7 +24,13 @@ export const useUsersStore = defineStore('users', () => {
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function fetchUsers(options: { cacheTtlMs?: number } = {}) {
|
||||
async function fetchUsers(options: {
|
||||
cacheTtlMs?: number
|
||||
search?: string
|
||||
role?: 'admin' | 'user'
|
||||
is_active?: boolean
|
||||
group_id?: string
|
||||
} = {}) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
@@ -111,6 +121,75 @@ export const useUsersStore = defineStore('users', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function listUserGroups(): Promise<ListUserGroupsResponse> {
|
||||
try {
|
||||
return await usersApi.listUserGroups()
|
||||
} catch (err: unknown) {
|
||||
error.value = parseApiError(err, '获取用户分组失败')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function createUserGroup(payload: UpsertUserGroupRequest): Promise<UserGroup> {
|
||||
try {
|
||||
return await usersApi.createUserGroup(payload)
|
||||
} catch (err: unknown) {
|
||||
error.value = parseApiError(err, '创建用户分组失败')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function updateUserGroup(
|
||||
groupId: string,
|
||||
payload: UpsertUserGroupRequest,
|
||||
): Promise<UserGroup> {
|
||||
try {
|
||||
return await usersApi.updateUserGroup(groupId, payload)
|
||||
} catch (err: unknown) {
|
||||
error.value = parseApiError(err, '更新用户分组失败')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUserGroup(groupId: string): Promise<void> {
|
||||
try {
|
||||
await usersApi.deleteUserGroup(groupId)
|
||||
} catch (err: unknown) {
|
||||
error.value = parseApiError(err, '删除用户分组失败')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function listUserGroupMembers(groupId: string): Promise<UserGroupMember[]> {
|
||||
try {
|
||||
return await usersApi.listUserGroupMembers(groupId)
|
||||
} catch (err: unknown) {
|
||||
error.value = parseApiError(err, '获取分组成员失败')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function replaceUserGroupMembers(
|
||||
groupId: string,
|
||||
userIds: string[],
|
||||
): Promise<UserGroupMember[]> {
|
||||
try {
|
||||
return await usersApi.replaceUserGroupMembers(groupId, userIds)
|
||||
} catch (err: unknown) {
|
||||
error.value = parseApiError(err, '更新分组成员失败')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function setDefaultUserGroup(groupId: string | null): Promise<{ default_group_id?: string | null }> {
|
||||
try {
|
||||
return await usersApi.setDefaultUserGroup(groupId)
|
||||
} catch (err: unknown) {
|
||||
error.value = parseApiError(err, '设置默认用户组失败')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function getUserApiKeys(userId: string): Promise<ApiKey[]> {
|
||||
try {
|
||||
return await usersApi.getUserApiKeys(userId)
|
||||
@@ -199,6 +278,13 @@ export const useUsersStore = defineStore('users', () => {
|
||||
deleteUser,
|
||||
resolveBatchSelection,
|
||||
batchAction,
|
||||
listUserGroups,
|
||||
createUserGroup,
|
||||
updateUserGroup,
|
||||
deleteUserGroup,
|
||||
listUserGroupMembers,
|
||||
replaceUserGroupMembers,
|
||||
setDefaultUserGroup,
|
||||
getUserApiKeys,
|
||||
createApiKey,
|
||||
updateApiKey,
|
||||
|
||||
@@ -15,6 +15,15 @@
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 新增用户按钮 -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="分组管理"
|
||||
@click="showUserGroupsDialog = true"
|
||||
>
|
||||
<FolderKanban class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -61,6 +70,25 @@
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
v-model="filterGroup"
|
||||
>
|
||||
<SelectTrigger class="w-24 h-8 text-xs border-border/60">
|
||||
<SelectValue placeholder="分组" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
全部
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
v-for="group in userGroups"
|
||||
:key="group.id"
|
||||
:value="group.id"
|
||||
>
|
||||
{{ group.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
v-model="filterStatus"
|
||||
>
|
||||
@@ -149,9 +177,38 @@
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Select v-model="filterGroup">
|
||||
<SelectTrigger class="w-32 h-8 text-xs border-border/60">
|
||||
<SelectValue placeholder="全部分组" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
全部分组
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
v-for="group in userGroups"
|
||||
:key="group.id"
|
||||
:value="group.id"
|
||||
>
|
||||
{{ group.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<div class="h-4 w-px bg-border" />
|
||||
|
||||
<!-- 新增用户按钮 -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="分组管理"
|
||||
@click="showUserGroupsDialog = true"
|
||||
>
|
||||
<FolderKanban class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
|
||||
<!-- 新增用户按钮 -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -207,7 +264,7 @@
|
||||
<Button
|
||||
size="sm"
|
||||
class="h-7 px-3 text-[11px]"
|
||||
:disabled="selectedCount === 0 || usersStore.loading"
|
||||
:disabled="(selectedCount === 0 && userGroups.length === 0) || usersStore.loading"
|
||||
@click="openUserBatchDialog"
|
||||
>
|
||||
批量操作
|
||||
@@ -317,6 +374,19 @@
|
||||
>
|
||||
{{ user.email || '-' }}
|
||||
</div>
|
||||
<div
|
||||
v-if="user.groups?.length"
|
||||
class="mt-1 flex flex-wrap gap-1"
|
||||
>
|
||||
<Badge
|
||||
v-for="group in user.groups"
|
||||
:key="group.id"
|
||||
variant="outline"
|
||||
class="h-5 px-1.5 py-0 text-[10px]"
|
||||
>
|
||||
{{ group.name }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -550,9 +620,18 @@
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="h-5 px-1.5 py-0 text-[10px] font-medium"
|
||||
:title="formatUserEffectiveRateLimitSource(user)"
|
||||
>
|
||||
{{ formatRateLimitInheritable(user.rate_limit) }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-for="group in user.groups || []"
|
||||
:key="group.id"
|
||||
variant="outline"
|
||||
class="h-5 px-1.5 py-0 text-[10px] font-medium"
|
||||
>
|
||||
{{ group.name }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-border/60 bg-muted/40 p-3.5">
|
||||
@@ -697,6 +776,7 @@
|
||||
ref="userFormDialogRef"
|
||||
:open="showUserFormDialog"
|
||||
:user="editingUser"
|
||||
:groups="userGroups"
|
||||
@close="closeUserFormDialog"
|
||||
@submit="handleUserFormSubmit"
|
||||
/>
|
||||
@@ -707,10 +787,18 @@
|
||||
:select-all-filtered="selectAllFiltered"
|
||||
:selected-count="selectedCount"
|
||||
:filters="batchSelectionFilters"
|
||||
:groups="userGroups"
|
||||
@close="showUserBatchDialog = false"
|
||||
@completed="handleUserBatchCompleted"
|
||||
/>
|
||||
|
||||
<UserGroupsDialog
|
||||
:open="showUserGroupsDialog"
|
||||
:users="usersStore.users"
|
||||
@close="showUserGroupsDialog = false"
|
||||
@changed="handleUserGroupsChanged"
|
||||
/>
|
||||
|
||||
<!-- API Keys 管理对话框 -->
|
||||
<Dialog
|
||||
v-model="showApiKeysDialog"
|
||||
@@ -1134,7 +1222,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
import type { User, ApiKey, UserSession, UserBatchActionResponse, UserBatchSelectionFilters } from '@/api/users'
|
||||
import type { User, ApiKey, UserSession, UserBatchActionResponse, UserBatchSelectionFilters, UserGroup } from '@/api/users'
|
||||
import { formatSessionMeta } from '@/types/session'
|
||||
import { adminWalletApi, type AdminWallet } from '@/api/admin-wallets'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
@@ -1184,12 +1272,14 @@ import {
|
||||
CheckCircle,
|
||||
Lock,
|
||||
LockOpen,
|
||||
MonitorSmartphone
|
||||
MonitorSmartphone,
|
||||
FolderKanban,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
// 功能组件
|
||||
import UserFormDialog, { type UserFormData } from '@/features/users/components/UserFormDialog.vue'
|
||||
import UserBatchActionDialog from '@/features/users/components/UserBatchActionDialog.vue'
|
||||
import UserGroupsDialog from '@/features/users/components/UserGroupsDialog.vue'
|
||||
import WalletOpsDrawer from '@/features/wallet/components/WalletOpsDrawer.vue'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { formatTokens, formatRateLimitInheritable, formatRateLimitSimple, isRateLimitInherited, isRateLimitUnlimited } from '@/utils/format'
|
||||
@@ -1233,10 +1323,13 @@ const userWalletMap = ref<Record<string, AdminWallet>>({})
|
||||
const showWalletActionDialogState = ref(false)
|
||||
const walletActionTarget = ref<{ user: User; wallet: AdminWallet } | null>(null)
|
||||
const showUserBatchDialog = ref(false)
|
||||
const showUserGroupsDialog = ref(false)
|
||||
|
||||
const searchQuery = ref('')
|
||||
const filterRole = ref('all')
|
||||
const filterStatus = ref('all')
|
||||
const filterGroup = ref('all')
|
||||
const userGroups = ref<UserGroup[]>([])
|
||||
const userRoleFilterOptions = [
|
||||
{ value: 'all', label: '全部角色' },
|
||||
{ value: 'admin', label: '管理员' },
|
||||
@@ -1285,6 +1378,10 @@ const filteredUsers = computed(() => {
|
||||
)
|
||||
}
|
||||
|
||||
if (filterGroup.value !== 'all') {
|
||||
filtered = filtered.filter(u => (u.groups || []).some(group => group.id === filterGroup.value))
|
||||
}
|
||||
|
||||
return filtered
|
||||
})
|
||||
|
||||
@@ -1322,11 +1419,12 @@ const batchSelectionFilters = computed<UserBatchSelectionFilters>(() => {
|
||||
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
|
||||
if (filterGroup.value !== 'all') filters.group_id = filterGroup.value
|
||||
return filters
|
||||
})
|
||||
|
||||
// Watch filter changes and reset to first page
|
||||
watch([searchQuery, filterRole, filterStatus], () => {
|
||||
watch([searchQuery, filterRole, filterStatus, filterGroup], () => {
|
||||
currentPage.value = 1
|
||||
resetBatchSelection()
|
||||
})
|
||||
@@ -1339,14 +1437,33 @@ onMounted(() => {
|
||||
|
||||
async function refreshUsers(options: { preferCache?: boolean } = {}) {
|
||||
const cacheTtlMs = options.preferCache ? USERS_PAGE_CACHE_TTL_MS : 0
|
||||
await usersStore.fetchUsers({ cacheTtlMs })
|
||||
await Promise.all([
|
||||
usersStore.fetchUsers({ cacheTtlMs }),
|
||||
loadUserGroups(),
|
||||
])
|
||||
void loadUserWallets({
|
||||
cacheTtlMs: options.preferCache ? USER_WALLETS_CACHE_TTL_MS : 0,
|
||||
})
|
||||
}
|
||||
|
||||
async function loadUserGroups(): Promise<void> {
|
||||
try {
|
||||
const response = await usersStore.listUserGroups()
|
||||
userGroups.value = response.items
|
||||
if (filterGroup.value !== 'all' && !userGroups.value.some((group) => group.id === filterGroup.value)) {
|
||||
filterGroup.value = 'all'
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('加载用户分组失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUserGroupsChanged(): Promise<void> {
|
||||
await refreshUsers()
|
||||
}
|
||||
|
||||
function openUserBatchDialog(): void {
|
||||
if (selectedCount.value === 0) return
|
||||
if (selectedCount.value === 0 && userGroups.value.length === 0) return
|
||||
showUserBatchDialog.value = true
|
||||
}
|
||||
|
||||
@@ -1429,6 +1546,18 @@ function formatConcurrentLimitSimple(concurrentLimit?: number | null): string {
|
||||
return `${concurrentLimit} 并发`
|
||||
}
|
||||
|
||||
function formatUserEffectiveRateLimitSource(user: User): string {
|
||||
const source = user.effective_policy?.rate_limit
|
||||
if (!source) return ''
|
||||
if (source.source === 'group' && source.group_name) {
|
||||
return `继承自分组:${source.group_name}`
|
||||
}
|
||||
if (source.source === 'user') {
|
||||
return '用户单独配置'
|
||||
}
|
||||
return '系统默认'
|
||||
}
|
||||
|
||||
function isNegativeWalletValue(value: number | null): boolean {
|
||||
return typeof value === 'number' && value < 0
|
||||
}
|
||||
@@ -1470,7 +1599,12 @@ function editUser(user: User) {
|
||||
allowed_providers: user.allowed_providers == null ? null : [...user.allowed_providers],
|
||||
allowed_api_formats: user.allowed_api_formats == null ? null : [...user.allowed_api_formats],
|
||||
allowed_models: user.allowed_models == null ? null : [...user.allowed_models],
|
||||
rate_limit: user.rate_limit ?? null
|
||||
rate_limit: user.rate_limit ?? null,
|
||||
allowed_providers_mode: user.allowed_providers_mode ?? (user.allowed_providers == null ? 'unrestricted' : 'specific'),
|
||||
allowed_api_formats_mode: user.allowed_api_formats_mode ?? (user.allowed_api_formats == null ? 'unrestricted' : 'specific'),
|
||||
allowed_models_mode: user.allowed_models_mode ?? (user.allowed_models == null ? 'unrestricted' : 'specific'),
|
||||
rate_limit_mode: user.rate_limit_mode ?? (user.rate_limit == null ? 'system' : 'custom'),
|
||||
group_ids: (user.groups || []).map(group => group.id),
|
||||
}
|
||||
showUserFormDialog.value = true
|
||||
}
|
||||
@@ -1491,9 +1625,14 @@ async function handleUserFormSubmit(data: UserFormData & { password?: string; un
|
||||
unlimited: data.unlimited,
|
||||
role: data.role,
|
||||
allowed_providers: data.allowed_providers,
|
||||
allowed_providers_mode: data.allowed_providers_mode,
|
||||
allowed_api_formats: data.allowed_api_formats,
|
||||
allowed_api_formats_mode: data.allowed_api_formats_mode,
|
||||
allowed_models: data.allowed_models,
|
||||
rate_limit: data.rate_limit ?? null
|
||||
allowed_models_mode: data.allowed_models_mode,
|
||||
rate_limit: data.rate_limit ?? null,
|
||||
rate_limit_mode: data.rate_limit_mode,
|
||||
group_ids: data.group_ids ?? [],
|
||||
}
|
||||
if (data.password) {
|
||||
updateData.password = data.password
|
||||
@@ -1511,9 +1650,14 @@ async function handleUserFormSubmit(data: UserFormData & { password?: string; un
|
||||
unlimited: data.unlimited,
|
||||
role: data.role,
|
||||
allowed_providers: data.allowed_providers,
|
||||
allowed_providers_mode: data.allowed_providers_mode,
|
||||
allowed_api_formats: data.allowed_api_formats,
|
||||
allowed_api_formats_mode: data.allowed_api_formats_mode,
|
||||
allowed_models: data.allowed_models,
|
||||
rate_limit: data.rate_limit ?? null
|
||||
allowed_models_mode: data.allowed_models_mode,
|
||||
rate_limit: data.rate_limit ?? null,
|
||||
rate_limit_mode: data.rate_limit_mode,
|
||||
group_ids: data.group_ids ?? [],
|
||||
})
|
||||
// 如果创建时指定为禁用,则更新状态
|
||||
if (data.is_active === false && newUser) {
|
||||
|
||||
Reference in New Issue
Block a user