Merge remote-tracking branch 'entropy-xu/codex/user-groups-default-permissions' into aether-rust-pioneer

This commit is contained in:
fawney19
2026-05-10 11:26:29 +08:00
49 changed files with 6373 additions and 250 deletions

View File

@@ -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

View File

@@ -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) {

View 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>