fix: align user group access controls

This commit is contained in:
fawney19
2026-05-10 17:28:23 +08:00
parent a8ac944959
commit d246f6b42c
33 changed files with 1958 additions and 1018 deletions

View File

@@ -73,10 +73,25 @@ export interface ProxyNodeExport {
export interface UsersExportData {
version: string
exported_at: string
user_groups?: UserGroupExport[]
users: UserExport[]
standalone_keys?: StandaloneKeyExport[]
}
export interface UserGroupExport {
id?: string
name: string
description?: string | null
allowed_providers?: string[] | null
allowed_providers_mode?: 'inherit' | 'unrestricted' | 'specific' | 'deny_all'
allowed_api_formats?: string[] | null
allowed_api_formats_mode?: 'inherit' | 'unrestricted' | 'specific' | 'deny_all'
allowed_models?: string[] | null
allowed_models_mode?: 'inherit' | 'unrestricted' | 'specific' | 'deny_all'
rate_limit?: number | null
rate_limit_mode?: 'inherit' | 'system' | 'custom'
}
export interface UserExport {
email: string
email_verified?: boolean
@@ -84,10 +99,16 @@ export interface UserExport {
password_hash: string
role: string
allowed_providers?: string[] | null
allowed_providers_mode?: 'inherit' | 'unrestricted' | 'specific' | 'deny_all'
allowed_api_formats?: string[] | null
allowed_api_formats_mode?: 'inherit' | 'unrestricted' | 'specific' | 'deny_all'
allowed_models?: string[] | null
allowed_models_mode?: 'inherit' | 'unrestricted' | 'specific' | 'deny_all'
rate_limit?: number | null // null = 跟随系统默认0 = 不限制
rate_limit_mode?: 'inherit' | 'system' | 'custom'
model_capability_settings?: Record<string, Record<string, boolean>>
group_ids?: string[]
group_names?: string[]
unlimited?: boolean
wallet?: BillingSummary | null
is_active: boolean
@@ -340,9 +361,10 @@ export interface UsersImportRequest extends UsersExportData {
export interface UsersImportResponse {
message: string
stats: {
user_groups?: { created: number; updated: number; skipped: number }
users: { created: number; updated: number; skipped: number }
api_keys: { created: number; skipped: number }
standalone_keys?: { created: number; skipped: number }
api_keys: { created: number; updated?: number; skipped: number }
standalone_keys?: { created: number; updated?: number; skipped: number }
errors: string[]
}
}

View File

@@ -9,7 +9,6 @@ export type RateLimitPolicyMode = 'inherit' | 'system' | 'custom'
export interface UserGroupSummary {
id: string
name: string
priority: number
}
export interface EffectivePolicyField<T> {
@@ -18,6 +17,8 @@ export interface EffectivePolicyField<T> {
source: 'user' | 'group' | 'fallback' | string
group_id?: string | null
group_name?: string | null
group_ids?: string[]
group_names?: string[]
}
export interface UserEffectivePolicy {
@@ -58,14 +59,6 @@ export interface CreateUserRequest {
role?: UserRole
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[]
}
@@ -75,14 +68,6 @@ export interface UpdateUserRequest {
role?: UserRole
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[]
}
@@ -121,10 +106,6 @@ export interface ResolveUserBatchSelectionResponse {
}
export interface UserBatchAccessControlPayload {
allowed_providers?: string[] | null
allowed_api_formats?: string[] | null
allowed_models?: string[] | null
rate_limit?: number | null
unlimited?: boolean
}
@@ -179,7 +160,6 @@ export interface UserGroup {
name: string
normalized_name?: string
description?: string | null
priority: number
allowed_providers?: string[] | null
allowed_providers_mode: ListPolicyMode
allowed_api_formats?: string[] | null
@@ -196,7 +176,6 @@ export interface UserGroup {
export interface UpsertUserGroupRequest {
name: string
description?: string | null
priority?: number
allowed_providers?: string[] | null
allowed_providers_mode?: ListPolicyMode
allowed_api_formats?: string[] | null

View File

@@ -2,7 +2,7 @@
<Dialog
:model-value="open"
title="用户批量操作"
description="按当前选择批量调整用户状态、角色、访问控制和额度"
description="按当前选择批量调整用户状态、角色和额度"
size="2xl"
persistent
@update:model-value="handleDialogUpdate"
@@ -19,7 +19,10 @@
{{ selectAllFiltered ? '目标为当前筛选条件匹配的全部用户,执行前后端会重新解析。' : '目标为当前已勾选的用户,重复 ID 会自动去重。' }}
</p>
</div>
<Badge variant="secondary" class="shrink-0">
<Badge
variant="secondary"
class="shrink-0"
>
{{ selectAllFiltered ? '全选筛选结果' : '手动选择' }}
</Badge>
</div>
@@ -55,7 +58,9 @@
<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>
<p class="mt-1 text-[11px] text-muted-foreground">
可与直接用户或筛选条件混合
</p>
</div>
<MultiSelect
v-model="selectedGroupIds"
@@ -80,7 +85,10 @@
>
<span class="flex items-center gap-2">
<span :class="actionIconClass(action.value)">
<component :is="action.icon" class="h-4 w-4" />
<component
:is="action.icon"
class="h-4 w-4"
/>
</span>
<span class="font-medium text-foreground">{{ action.label }}</span>
</span>
@@ -100,7 +108,9 @@
<UserCog class="h-4 w-4" />
</div>
<div class="min-w-0 space-y-1">
<h4 class="text-sm font-semibold text-foreground">批量修改用户角色</h4>
<h4 class="text-sm font-semibold text-foreground">
批量修改用户角色
</h4>
<p class="text-xs leading-relaxed text-muted-foreground">
将所选用户统一调整为同一个角色管理员角色拥有后台管理权限请确认选择范围
</p>
@@ -110,15 +120,21 @@
<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>
<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>
<SelectItem value="user">
普通用户
</SelectItem>
<SelectItem value="admin">
管理员
</SelectItem>
</SelectContent>
</Select>
</div>
@@ -137,151 +153,39 @@
<ShieldCheck class="h-4 w-4" />
</div>
<div class="min-w-0 space-y-1">
<h4 class="text-sm font-semibold text-foreground">批量设置访问控制与额度</h4>
<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 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>
@@ -328,7 +232,6 @@ import {
Dialog,
Button,
Badge,
Input,
Label,
Select,
SelectTrigger,
@@ -340,9 +243,7 @@ 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,
@@ -356,8 +257,6 @@ import type {
UserGroup,
} from '@/api/users'
type AccessFieldMode = 'skip' | 'unrestricted' | 'specific'
type RateLimitMode = 'skip' | 'inherit' | 'custom'
type QuotaMode = 'skip' | 'wallet' | 'unlimited'
interface ActionOption {
@@ -383,12 +282,6 @@ const emit = defineEmits<{
const usersStore = useUsersStore()
const { success, warning, error } = useToast()
const {
providerOptions,
apiFormatOptions,
modelOptions,
loadAccessControlOptions,
} = useUserAccessControlOptions()
const actionOptions: ActionOption[] = [
{
@@ -405,8 +298,8 @@ const actionOptions: ActionOption[] = [
},
{
value: 'update_access_control',
label: '访问控制',
description: '提供商、端点、模型、限速和额度',
label: '额度',
description: '批量调整用户额度模式',
icon: ShieldCheck,
},
{
@@ -419,16 +312,8 @@ const actionOptions: ActionOption[] = [
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 selectedGroupIds = ref<string[]>([])
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)
@@ -452,9 +337,6 @@ watch(
(open) => {
if (!open) return
resetLocalState()
void loadAccessControlOptions().catch((err) => {
error(parseApiError(err, '加载访问控制选项失败'))
})
void resolvePreview()
},
)
@@ -473,16 +355,8 @@ function handleDialogUpdate(value: boolean): void {
function resetLocalState(): void {
selectedAction.value = 'enable'
targetRole.value = 'user'
providerMode.value = 'skip'
apiFormatMode.value = 'skip'
modelMode.value = 'skip'
rateLimitMode.value = 'skip'
quotaMode.value = 'skip'
selectedGroupIds.value = []
allowedProviders.value = []
allowedApiFormats.value = []
allowedModels.value = []
rateLimit.value = undefined
lastResult.value = null
}
@@ -538,14 +412,6 @@ watch(selectedGroupIds, () => {
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
@@ -557,16 +423,12 @@ function buildRolePayload(): UserBatchRolePayload {
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('请至少选择一个要修改的访问控制或额度字段')
warning('请选择要修改的额度')
return
}
request = { selection, action: 'update_access_control', payload }

View File

@@ -1,7 +1,7 @@
<template>
<Dialog
:model-value="isOpen"
size="2xl"
size="xl"
@update:model-value="handleDialogUpdate"
>
<template #header>
@@ -35,13 +35,8 @@
autocomplete="off"
@submit.prevent="handleSubmit"
>
<div class="grid grid-cols-2 gap-0">
<!-- 左侧基础设置 -->
<div class="pr-6 space-y-4">
<div class="flex items-center gap-2 pb-2 border-b border-border/60">
<span class="text-sm font-medium">基础设置</span>
</div>
<div class="space-y-5">
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label
for="form-username"
@@ -63,97 +58,6 @@
>
{{ usernameError }}
</p>
<p
v-else
class="text-xs text-muted-foreground"
>
3-30个字符允许字母数字下划线连字符和点号
</p>
</div>
<div class="space-y-2">
<Label class="text-sm font-medium">
{{ isEditMode ? '新密码 (留空保持不变)' : '密码' }}
<span
v-if="!isEditMode"
class="text-muted-foreground"
>*</span>
</Label>
<Input
:id="`pwd-${formNonce}`"
v-model="form.password"
type="text"
masked
autocomplete="new-password"
disable-autofill
:name="`field-${formNonce}`"
:required="!isEditMode"
minlength="6"
:placeholder="isEditMode ? '留空保持原密码' : getPasswordPolicyPlaceholder(passwordPolicyLevel)"
class="h-10"
:class="[
passwordError ? 'border-destructive' : '',
]"
/>
<p
v-if="passwordError"
class="text-xs text-destructive"
>
{{ passwordError }}
</p>
<p
v-else-if="!isEditMode"
class="text-xs text-muted-foreground"
>
{{ passwordHint }}
</p>
</div>
<div
v-if="isEditMode && form.password.length > 0"
class="space-y-2"
>
<Label class="text-sm font-medium">
确认新密码 <span class="text-muted-foreground">*</span>
</Label>
<Input
:id="`pwd-confirm-${formNonce}`"
v-model="form.confirmPassword"
type="text"
masked
autocomplete="new-password"
data-form-type="other"
data-lpignore="true"
:name="`confirm-${formNonce}`"
required
minlength="6"
placeholder="再次输入新密码"
class="h-10"
/>
<p
v-if="
form.confirmPassword.length > 0 &&
form.password !== form.confirmPassword
"
class="text-xs text-destructive"
>
两次输入的密码不一致
</p>
</div>
<div class="space-y-2">
<Label
for="form-email"
class="text-sm font-medium"
>邮箱</Label>
<Input
id="form-email"
v-model="form.email"
type="email"
autocomplete="off"
data-form-type="other"
class="h-10"
/>
</div>
<div class="space-y-2">
@@ -180,168 +84,129 @@
</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>
<!-- 右侧:访问限制 -->
<div class="pl-6 space-y-4 border-l border-border">
<div class="flex items-center gap-2 pb-2 border-b border-border/60">
<span class="text-sm font-medium">访问限制</span>
</div>
<div class="space-y-2">
<Label
for="form-email"
class="text-sm font-medium"
>邮箱</Label>
<Input
id="form-email"
v-model="form.email"
type="email"
autocomplete="off"
data-form-type="other"
class="h-10"
/>
</div>
<!-- 提供商 -->
<div class="space-y-2">
<Label class="text-sm font-medium">允许的提供商</Label>
<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>
<!-- 端点 -->
<div class="space-y-2">
<Label class="text-sm font-medium">允许的端点</Label>
<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>
<!-- 模型 -->
<div class="space-y-2">
<Label class="text-sm font-medium">允许的模型</Label>
<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>
<div class="space-y-2">
<Label
for="form-rate-limit"
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
id="form-rate-limit"
:model-value="form.rate_limit ?? ''"
type="number"
min="0"
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 })"
/>
</div>
</div>
</div>
<!-- 额度 -->
<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">
<Input
v-if="!isEditMode && !form.unlimited"
id="form-initial-gift"
:model-value="form.initial_gift_usd ?? ''"
type="number"
step="0.01"
min="0.01"
placeholder="初始额度 (USD)"
class="h-10"
@update:model-value="(v) => form.initial_gift_usd = parseNumberInput(v, { allowFloat: true, min: 0.01 })"
/>
<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"
>{{ form.unlimited ? '无限制' : '按钱包余额限制' }}</span>
</div>
<Switch
v-model="form.unlimited"
class="shrink-0"
<div class="space-y-2">
<Label class="text-sm font-medium">
{{ isEditMode ? '新密码 (留空保持不变)' : '密码' }}
<span
v-if="!isEditMode"
class="text-muted-foreground"
>*</span>
</Label>
<Input
:id="`pwd-${formNonce}`"
v-model="form.password"
type="text"
masked
autocomplete="new-password"
disable-autofill
:name="`field-${formNonce}`"
:required="!isEditMode"
minlength="6"
:placeholder="isEditMode ? '留空保持原密码' : getPasswordPolicyPlaceholder(passwordPolicyLevel)"
class="h-10"
:class="[
passwordError ? 'border-destructive' : '',
]"
/>
<p
v-if="passwordError"
class="text-xs text-destructive"
>
{{ passwordError }}
</p>
<p
v-else-if="!isEditMode"
class="text-xs text-muted-foreground"
>
{{ passwordHint }}
</p>
</div>
<div
v-if="isEditMode && form.password.length > 0"
class="space-y-2"
>
<Label class="text-sm font-medium">
确认新密码 <span class="text-muted-foreground">*</span>
</Label>
<Input
:id="`pwd-confirm-${formNonce}`"
v-model="form.confirmPassword"
type="text"
masked
autocomplete="new-password"
data-form-type="other"
data-lpignore="true"
:name="`confirm-${formNonce}`"
required
minlength="6"
placeholder="再次输入新密码"
class="h-10"
/>
<p
v-if="
form.confirmPassword.length > 0 &&
form.password !== form.confirmPassword
"
class="text-xs text-destructive"
>
两次输入的密码不一致
</p>
</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 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">
<Input
v-if="!isEditMode && !form.unlimited"
id="form-initial-gift"
:model-value="form.initial_gift_usd ?? ''"
type="number"
step="0.01"
min="0.01"
placeholder="初始额度 (USD)"
class="h-10"
@update:model-value="(v) => form.initial_gift_usd = parseNumberInput(v, { allowFloat: true, min: 0.01 })"
/>
<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"
>{{ form.unlimited ? '无限制' : '按钱包余额限制' }}</span>
</div>
<Switch
v-model="form.unlimited"
class="shrink-0"
/>
</div>
</div>
</div>
@@ -387,7 +252,6 @@ import { MultiSelect } from '@/components/common'
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,
@@ -395,7 +259,7 @@ import {
validatePasswordByPolicy,
type PasswordPolicyLevel,
} from '@/utils/passwordPolicy'
import type { ListPolicyMode, RateLimitPolicyMode, UserGroup } from '@/api/users'
import type { UserGroup } from '@/api/users'
export interface UserFormData {
id?: string
@@ -405,14 +269,6 @@ export interface UserFormData {
unlimited?: boolean
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[]
}
@@ -432,13 +288,6 @@ const saving = ref(false)
const formNonce = ref(createFieldNonce())
const passwordPolicyLevel = ref<PasswordPolicyLevel>('weak')
const {
providerOptions,
apiFormatOptions,
modelOptions,
loadAccessControlOptions: loadAccessControlOptionLists,
} = useUserAccessControlOptions()
// 表单数据
const form = ref({
username: '',
@@ -449,14 +298,6 @@ const form = ref({
role: 'user' as 'admin' | 'user',
unlimited: false,
is_active: 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[],
})
@@ -480,14 +321,6 @@ function resetForm() {
role: 'user',
unlimited: false,
is_active: 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: [],
}
}
@@ -505,14 +338,6 @@ function loadUserData() {
role: props.user.role,
unlimited: props.user.unlimited ?? false,
is_active: props.user.is_active ?? true,
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] : [],
}
}
@@ -566,16 +391,14 @@ const isFormValid = computed(() => {
})
// 加载访问控制选项
async function loadAccessControlOptions(): Promise<void> {
async function loadPasswordPolicy(): Promise<void> {
try {
const [, passwordPolicyResponse] = await Promise.all([
loadAccessControlOptionLists(),
adminApi.getSystemConfig('password_policy_level').catch(() => ({ value: 'weak' })),
])
const passwordPolicyResponse = await adminApi
.getSystemConfig('password_policy_level')
.catch(() => ({ value: 'weak' }))
passwordPolicyLevel.value = normalizePasswordPolicyLevel(passwordPolicyResponse.value)
} catch (err) {
log.error('加载访问限制选项失败:', err)
log.error('加载密码策略失败:', err)
passwordPolicyLevel.value = 'weak'
}
}
@@ -589,20 +412,6 @@ async function handleSubmit() {
email: form.value.email.trim() || '',
unlimited: form.value.unlimited,
role: form.value.role,
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],
}
@@ -638,7 +447,7 @@ function setSaving(value: boolean) {
// 监听打开状态,加载选项数据
watch(isOpen, (val) => {
if (val) {
loadAccessControlOptions()
loadPasswordPolicy()
}
})

View File

@@ -3,21 +3,22 @@
:model-value="open"
title="用户分组"
description="管理用户组、默认注册组、成员和组级访问控制"
size="6xl"
size="4xl"
persistent
@update:model-value="handleDialogUpdate"
>
<div class="grid min-h-[560px] gap-4 lg:grid-cols-[17rem_minmax(0,1fr)]">
<div class="grid gap-4 lg:min-h-[560px] 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"
variant="ghost"
size="icon"
class="h-8 w-8"
title="新建分组"
@click="startCreate"
>
<Plus class="mr-1.5 h-3.5 w-3.5" />
新建
<Plus class="h-4 w-4" />
</Button>
</div>
@@ -35,7 +36,7 @@
</div>
<div
v-else
class="space-y-1.5"
class="max-h-60 space-y-1.5 overflow-y-auto lg:max-h-none lg:overflow-visible"
>
<button
v-for="group in groups"
@@ -55,81 +56,58 @@
默认
</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="min-w-0 rounded-xl border border-border/70 bg-background p-3 sm: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 自动创建用户' }}
{{ selectedGroup?.is_default ? '当前为所有用户的默认组' : '通过额外分组配置访问限制' }}
</p>
</div>
<div class="flex items-center gap-2">
<div
v-if="editingGroupId"
class="flex items-center gap-1"
>
<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"
variant="ghost"
size="icon"
class="h-8 w-8"
:class="selectedGroup?.is_default ? 'text-emerald-500 hover:text-emerald-500' : ''"
:disabled="saving || selectedGroup?.is_default"
:title="selectedGroup?.is_default ? '默认注册组' : '设为默认注册组'"
@click="toggleDefault"
>
<BadgeCheck class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:disabled="saving || selectedGroup?.is_default"
title="删除分组"
@click="deleteSelectedGroup"
>
<Trash2 class="mr-1.5 h-3.5 w-3.5" />
删除
<Trash2 class="h-4 w-4" />
</Button>
</div>
</div>
<div class="grid gap-5 lg:grid-cols-2">
<div class="space-y-5">
<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="可选"
<Label class="text-sm font-medium">名称</Label>
<Input
v-model="form.name"
class="h-10"
placeholder="例如:生产团队"
/>
</div>
@@ -139,6 +117,7 @@
v-model="memberUserIds"
:options="userOptions"
:search-threshold="0"
:disabled="selectedGroup?.is_default"
placeholder="选择用户"
empty-text="暂无用户"
no-results-text="未找到匹配用户"
@@ -146,47 +125,92 @@
</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">
<div class="space-y-4 border-t border-border/60 pt-5">
<div class="flex flex-wrap items-baseline justify-between gap-x-2 gap-y-1 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 flex-col gap-2 sm:flex-row sm:items-center">
<div class="flex w-full items-center sm:w-auto sm:shrink-0">
<Switch
:model-value="form.allowed_providers_mode === 'unrestricted'"
:disabled="selectedGroup?.is_default"
@update:model-value="(v) => (form.allowed_providers_mode = v ? 'unrestricted' : 'specific')"
/>
</div>
<div class="min-w-0 flex-1">
<MultiSelect
v-model="form.allowed_providers"
:options="providerOptions"
:search-threshold="0"
:disabled="selectedGroup?.is_default || form.allowed_providers_mode === 'unrestricted'"
:placeholder="form.allowed_providers_mode === 'unrestricted' ? '不限制所有选项' : '选择提供商'"
empty-text="暂无选项"
/>
</div>
</div>
</div>
<div class="space-y-2">
<Label class="text-sm font-medium">允许的端点</Label>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<div class="flex w-full items-center sm:w-auto sm:shrink-0">
<Switch
:model-value="form.allowed_api_formats_mode === 'unrestricted'"
:disabled="selectedGroup?.is_default"
@update:model-value="(v) => (form.allowed_api_formats_mode = v ? 'unrestricted' : 'specific')"
/>
</div>
<div class="min-w-0 flex-1">
<MultiSelect
v-model="form.allowed_api_formats"
:options="apiFormatOptions"
:search-threshold="0"
:disabled="selectedGroup?.is_default || form.allowed_api_formats_mode === 'unrestricted'"
:placeholder="form.allowed_api_formats_mode === 'unrestricted' ? '不限制所有选项' : '选择端点'"
empty-text="暂无选项"
/>
</div>
</div>
</div>
<div class="space-y-2">
<Label class="text-sm font-medium">允许的模型</Label>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<div class="flex w-full items-center sm:w-auto sm:shrink-0">
<Switch
:model-value="form.allowed_models_mode === 'unrestricted'"
:disabled="selectedGroup?.is_default"
@update:model-value="(v) => (form.allowed_models_mode = v ? 'unrestricted' : 'specific')"
/>
</div>
<div class="min-w-0 flex-1">
<MultiSelect
v-model="form.allowed_models"
:options="modelOptions"
:search-threshold="0"
:disabled="selectedGroup?.is_default || form.allowed_models_mode === 'unrestricted'"
:placeholder="form.allowed_models_mode === 'unrestricted' ? '不限制所有选项' : '选择模型'"
empty-text="暂无选项"
/>
</div>
</div>
</div>
<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 class="flex flex-col gap-2 sm:flex-row sm:items-center">
<div class="flex w-full items-center sm:w-auto sm:shrink-0">
<Switch
:model-value="form.rate_limit_mode === 'system'"
:disabled="selectedGroup?.is_default"
@update:model-value="(v) => (form.rate_limit_mode = v ? 'system' : 'custom')"
/>
</div>
<div class="min-w-0 flex-1">
<Input
@@ -195,8 +219,8 @@
min="0"
max="10000"
class="h-10"
:disabled="form.rate_limit_mode !== 'custom'"
:placeholder="rateLimitPlaceholder"
:disabled="selectedGroup?.is_default || form.rate_limit_mode === 'system'"
:placeholder="form.rate_limit_mode === 'system' ? '使用系统默认' : '0 = 不限速'"
@update:model-value="(value) => form.rate_limit = parseNumberInput(value, { min: 0, max: 10000 })"
/>
</div>
@@ -216,31 +240,25 @@
关闭
</Button>
<Button
:disabled="saving || !form.name.trim()"
:disabled="saving || !form.name.trim() || defaultGroupHasRestrictions"
@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 { computed, ref, watch } from 'vue'
import { BadgeCheck, 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'
@@ -258,64 +276,6 @@ import type {
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[]
@@ -328,7 +288,7 @@ const emit = defineEmits<{
const usersStore = useUsersStore()
const { success, error } = useToast()
const { confirmDanger } = useConfirm()
const { confirmDanger, confirmInfo } = useConfirm()
const {
providerOptions,
apiFormatOptions,
@@ -345,30 +305,23 @@ 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_mode: 'unrestricted' as ListPolicyMode,
allowed_api_formats_mode: 'unrestricted' as ListPolicyMode,
allowed_models_mode: 'unrestricted' as ListPolicyMode,
allowed_providers: [] as string[],
allowed_api_formats: [] as string[],
allowed_models: [] as string[],
rate_limit_mode: 'inherit' as RateLimitPolicyMode,
rate_limit_mode: 'system' 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 defaultGroupHasRestrictions = computed(() => {
if (!selectedGroup.value?.is_default) return false
return form.value.allowed_providers_mode !== 'unrestricted'
|| form.value.allowed_api_formats_mode !== 'unrestricted'
|| form.value.allowed_models_mode !== 'unrestricted'
|| form.value.rate_limit_mode !== 'system'
})
const userOptions = computed(() => props.users.map((user) => ({
label: `${user.username}${user.email ? ` (${user.email})` : ''}`,
@@ -420,16 +373,13 @@ async function selectGroup(groupId: string): Promise<void> {
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_mode: normalizeListMode(group.allowed_providers_mode),
allowed_api_formats_mode: normalizeListMode(group.allowed_api_formats_mode),
allowed_models_mode: normalizeListMode(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_mode: normalizeRateMode(group.rate_limit_mode),
rate_limit: group.rate_limit ?? undefined,
}
try {
@@ -441,20 +391,25 @@ async function selectGroup(groupId: string): Promise<void> {
}
}
function normalizeListMode(mode: ListPolicyMode): ListPolicyMode {
return mode === 'specific' ? 'specific' : 'unrestricted'
}
function normalizeRateMode(mode: RateLimitPolicyMode): RateLimitPolicyMode {
return mode === 'custom' ? 'custom' : 'system'
}
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_mode: 'unrestricted',
allowed_api_formats_mode: 'unrestricted',
allowed_models_mode: 'unrestricted',
allowed_providers: [],
allowed_api_formats: [],
allowed_models: [],
rate_limit_mode: 'inherit',
rate_limit_mode: 'system',
rate_limit: undefined,
}
memberUserIds.value = []
@@ -469,11 +424,30 @@ function groupButtonClass(groupId: string): string {
)
}
async function toggleDefault(): Promise<void> {
const group = selectedGroup.value
if (!group || group.is_default) return
const confirmed = await confirmInfo(
`确定将「${group.name}」设为默认注册组吗?后续本地注册和 OAuth 自动创建的用户将加入该分组。`,
'设为默认注册组',
)
if (!confirmed) return
saving.value = true
try {
await usersStore.setDefaultUserGroup(group.id)
success('已更新默认注册组')
emit('changed')
await loadDialogData()
} catch (err) {
error(parseApiError(err, '设置默认注册组失败'))
} finally {
saving.value = false
}
}
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,
@@ -497,16 +471,11 @@ 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)
if (!saved.is_default) {
await usersStore.replaceUserGroupMembers(saved.id, memberUserIds.value)
}
success('用户分组已保存')
emit('changed')

View File

@@ -1552,6 +1552,10 @@ function formatUserEffectiveRateLimitSource(user: User): string {
if (source.source === 'group' && source.group_name) {
return `继承自分组:${source.group_name}`
}
if (source.source === 'combined') {
const groupNames = Array.isArray(source.group_names) ? source.group_names.join('、') : ''
return groupNames ? `用户额外限制与分组叠加:${groupNames}` : '用户额外限制与分组叠加'
}
if (source.source === 'user') {
return '用户单独配置'
}
@@ -1596,14 +1600,6 @@ function editUser(user: User) {
unlimited: user.unlimited,
role: user.role,
is_active: user.is_active,
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,
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
@@ -1624,14 +1620,6 @@ async function handleUserFormSubmit(data: UserFormData & { password?: string; un
email: data.email || undefined,
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,
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) {
@@ -1649,14 +1637,6 @@ async function handleUserFormSubmit(data: UserFormData & { password?: string; un
initial_gift_usd: data.initial_gift_usd,
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,
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 ?? [],
})
// 如果创建时指定为禁用,则更新状态

View File

@@ -15,6 +15,9 @@
数据预览
</p>
<ul class="space-y-1 text-muted-foreground">
<li v-if="importUsersPreview.user_groups?.length">
用户组: {{ importUsersPreview.user_groups.length }}
</li>
<li>用户: {{ importUsersPreview.users?.length || 0 }} </li>
<li>
API Keys: {{ importUsersPreview.users?.reduce((sum: number, u: { api_keys?: unknown[] }) => sum + (u.api_keys?.length || 0), 0) }}
@@ -93,6 +96,16 @@
class="space-y-4"
>
<div class="grid grid-cols-2 gap-4 text-sm">
<div v-if="importUsersResult.stats.user_groups">
<p class="font-medium">
用户组
</p>
<p class="text-muted-foreground">
创建: {{ importUsersResult.stats.user_groups.created }},
更新: {{ importUsersResult.stats.user_groups.updated }},
跳过: {{ importUsersResult.stats.user_groups.skipped }}
</p>
</div>
<div>
<p class="font-medium">
用户

View File

@@ -36,7 +36,9 @@ function looksLikeConfigExport(value: JsonObject): boolean {
}
function looksLikeUsersExport(value: JsonObject): boolean {
return hasArrayField(value, 'users') || hasArrayField(value, 'standalone_keys')
return hasArrayField(value, 'users')
|| hasArrayField(value, 'standalone_keys')
|| hasArrayField(value, 'user_groups')
}
export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
@@ -231,6 +233,11 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
return
}
if (root.user_groups != null && !Array.isArray(root.user_groups)) {
error('无效的用户数据文件user_groups 必须是数组')
return
}
if (root.standalone_keys != null && !Array.isArray(root.standalone_keys)) {
error('无效的用户数据文件standalone_keys 必须是数组')
return