Files
Aether/frontend/src/features/api-keys/components/StandaloneKeyFormDialog.vue
T
fawney19andLewisPen f92b0943b5 feat(rate-limit): 实现分层 RPM 限速,支持系统默认/用户/独立Key三级配置
- 新增用户级 rate_limit 字段,支持系统默认/用户自定义/不限制三种模式
- 独立 Key 的 rate_limit 语义调整:null=跟随系统默认,0=不限制,>0=自定义
- 实现 UserRpmLimiter 基于 Redis sliding window 的 RPM 限速引擎
- Pipeline 请求流程集成用户级 RPM 检查
- 管理后台和用户面板新增 RPM 限速配置与实时状态查看
- 系统设置新增全局默认 RPM 配置项
- 迁移脚本回填现有 API Key 的 rate_limit 默认值
- 新增用户/Key RPM 状态监控 API 和前端展示

Closes #231

Co-authored-by: LewisPen <[email protected]>
2026-03-15 14:22:59 +08:00

480 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<Dialog
:model-value="isOpen"
size="2xl"
@update:model-value="handleDialogUpdate"
>
<template #header>
<div class="border-b border-border px-6 py-4">
<div class="flex items-center gap-3">
<div
class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0"
>
<Plus
v-if="!isEditMode"
class="h-5 w-5 text-primary"
/>
<SquarePen
v-else
class="h-5 w-5 text-primary"
/>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-foreground leading-tight">
{{ isEditMode ? '编辑独立余额 API Key' : '创建独立余额 API Key' }}
</h3>
<p class="text-xs text-muted-foreground">
{{ isEditMode ? '修改密钥名称、有效期和访问限制' : '用于非注册用户调用接口,可设置初始余额或无限制额度' }}
</p>
</div>
</div>
</div>
</template>
<form @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-2">
<Label
for="form-name"
class="text-sm font-medium"
>密钥名称</Label>
<Input
id="form-name"
v-model="form.name"
type="text"
placeholder="例如: 用户A专用"
class="h-10"
/>
</div>
<div class="space-y-2">
<Label
for="form-expires-at"
class="text-sm font-medium"
>有效期设置</Label>
<div class="flex items-center gap-2">
<div class="relative flex-1">
<Input
id="form-expires-at"
:model-value="form.expires_at || ''"
type="date"
:min="minExpiryDate"
class="h-9 pr-8"
:placeholder="form.expires_at ? '' : '永不过期'"
@update:model-value="(v) => form.expires_at = v || undefined"
/>
<button
v-if="form.expires_at"
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
title="清空(永不过期)"
@click="clearExpiryDate"
>
<X class="h-4 w-4" />
</button>
</div>
<label
class="flex items-center gap-1.5 border rounded-md px-2 py-1.5 bg-muted/50 cursor-pointer text-xs whitespace-nowrap"
:class="!form.expires_at ? 'opacity-50 cursor-not-allowed' : ''"
>
<input
v-model="form.auto_delete_on_expiry"
type="checkbox"
class="h-3.5 w-3.5 rounded border-gray-300 cursor-pointer"
:disabled="!form.expires_at"
>
到期删除
</label>
</div>
<p class="text-xs text-muted-foreground">
{{ form.expires_at ? '到期后' + (form.auto_delete_on_expiry ? '自动删除' : '仅禁用') + '(当天 23:59 失效)' : '留空表示永不过期' }}
</p>
</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>
<!-- Provider -->
<div class="space-y-2">
<Label class="text-sm font-medium">允许的 Provider</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="暂无可用 Provider"
no-results-text="未找到匹配的 Provider"
search-placeholder="搜索 Provider 名称..."
/>
</div>
<Switch
v-model="form.provider_unrestricted"
class="shrink-0"
/>
</div>
</div>
<!-- API 格式 -->
<div class="space-y-2">
<Label class="text-sm font-medium">允许的 API 格式</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="暂无可用 API 格式"
no-results-text="未找到匹配的 API 格式"
search-placeholder="搜索 API 格式..."
/>
</div>
<Switch
v-model="form.api_format_unrestricted"
class="shrink-0"
/>
</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">
<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>
</div>
<div class="space-y-2">
<Label
for="form-rate-limit"
class="text-sm font-medium"
>速率限制 (请求/分钟)</Label>
<div class="flex items-center gap-3">
<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"
min="0"
max="10000"
placeholder="0 = 不限速"
class="h-10"
@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>
<!-- 额度 -->
<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_balance"
id="form-balance"
:model-value="form.initial_balance_usd ?? ''"
type="number"
step="0.01"
min="0.01"
placeholder="初始额度 (USD)"
class="h-10"
@update:model-value="(v) => form.initial_balance_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_balance ? '无限制' : '按钱包余额限制' }}</span>
</div>
<Switch
:model-value="form.unlimited_balance ?? false"
class="shrink-0"
@update:model-value="(v) => form.unlimited_balance = v"
/>
</div>
</div>
</div>
</div>
</form>
<template #footer>
<Button
variant="outline"
type="button"
class="h-10 px-5"
@click="handleCancel"
>
取消
</Button>
<Button
:disabled="saving"
class="h-10 px-5"
@click="handleSubmit"
>
{{ saving ? (isEditMode ? '更新中...' : '创建中...') : (isEditMode ? '更新' : '创建') }}
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import {
Dialog,
Button,
Input,
Label,
Switch,
} from '@/components/ui'
import { Plus, SquarePen, X } from 'lucide-vue-next'
import { useFormDialog } from '@/composables/useFormDialog'
import { MultiSelect } from '@/components/common'
import { getProvidersSummary } from '@/api/endpoints/providers'
import { getGlobalModels } from '@/api/global-models'
import { adminApi } from '@/api/admin'
import { log } from '@/utils/logger'
import { parseNumberInput } from '@/utils/form'
import type { ProviderWithEndpointsSummary, GlobalModelResponse } from '@/api/endpoints/types'
export interface StandaloneKeyFormData {
id?: string
name: string
initial_balance_usd?: number
unlimited_balance?: boolean
expires_at?: string // ISO 日期字符串,如 "2025-12-31",undefined = 永不过期
rate_limit?: number | null
auto_delete_on_expiry: boolean
allowed_providers?: string[] | null
allowed_api_formats?: string[] | null
allowed_models?: string[] | null
}
interface StandaloneKeyFormState {
id?: string
name: string
initial_balance_usd?: number
unlimited_balance?: boolean
expires_at?: string
rate_limit_inherited: boolean
rate_limit?: number
auto_delete_on_expiry: boolean
provider_unrestricted: boolean
api_format_unrestricted: boolean
model_unrestricted: boolean
allowed_providers: string[]
allowed_api_formats: string[]
allowed_models: string[]
}
const props = defineProps<{
open: boolean
apiKey: StandaloneKeyFormData | null
}>()
const emit = defineEmits<{
close: []
submit: [data: StandaloneKeyFormData]
}>()
const isOpen = computed(() => props.open)
const saving = ref(false)
// 选项数据
const providers = ref<ProviderWithEndpointsSummary[]>([])
const globalModels = ref<GlobalModelResponse[]>([])
const allApiFormats = ref<string[]>([])
const providerOptions = computed(() =>
providers.value.map((provider) => ({
value: provider.id,
label: provider.name,
}))
)
const apiFormatOptions = computed(() =>
allApiFormats.value.map((format) => ({
value: format,
label: format,
}))
)
const modelOptions = computed(() =>
globalModels.value.map((model) => ({
value: model.name,
label: model.name,
}))
)
// 表单数据
const form = ref<StandaloneKeyFormState>({
name: '',
initial_balance_usd: 10,
unlimited_balance: false,
expires_at: undefined,
rate_limit_inherited: true,
rate_limit: undefined,
auto_delete_on_expiry: false,
provider_unrestricted: true,
api_format_unrestricted: true,
model_unrestricted: true,
allowed_providers: [],
allowed_api_formats: [],
allowed_models: [],
})
// 计算最小可选日期(明天)
const minExpiryDate = computed(() => {
const tomorrow = new Date()
tomorrow.setDate(tomorrow.getDate() + 1)
return tomorrow.toISOString().split('T')[0]
})
function resetForm() {
form.value = {
name: '',
initial_balance_usd: 10,
unlimited_balance: false,
expires_at: undefined,
rate_limit_inherited: true,
rate_limit: undefined,
auto_delete_on_expiry: false,
provider_unrestricted: true,
api_format_unrestricted: true,
model_unrestricted: true,
allowed_providers: [],
allowed_api_formats: [],
allowed_models: [],
} as typeof form.value
}
function loadKeyData() {
if (!props.apiKey) return
form.value = {
id: props.apiKey.id,
name: props.apiKey.name || '',
initial_balance_usd: props.apiKey.initial_balance_usd,
unlimited_balance: props.apiKey.initial_balance_usd == null,
expires_at: props.apiKey.expires_at,
rate_limit_inherited: props.apiKey.rate_limit == null,
rate_limit: props.apiKey.rate_limit ?? undefined,
auto_delete_on_expiry: props.apiKey.auto_delete_on_expiry,
provider_unrestricted: props.apiKey.allowed_providers == null,
api_format_unrestricted: props.apiKey.allowed_api_formats == null,
model_unrestricted: props.apiKey.allowed_models == null,
allowed_providers: props.apiKey.allowed_providers ? [...props.apiKey.allowed_providers] : [],
allowed_api_formats: props.apiKey.allowed_api_formats ? [...props.apiKey.allowed_api_formats] : [],
allowed_models: props.apiKey.allowed_models ? [...props.apiKey.allowed_models] : [],
} as typeof form.value
}
const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
isOpen: () => props.open,
entity: () => props.apiKey,
isLoading: saving,
onClose: () => emit('close'),
loadData: loadKeyData,
resetForm,
})
// 加载选项数据
async function loadAccessRestrictionOptions() {
try {
const [providersResponse, modelsData, formatsData] = await Promise.all([
getProvidersSummary({ page_size: 9999 }),
getGlobalModels({ limit: 1000, is_active: true }),
adminApi.getApiFormats()
])
providers.value = providersResponse.items
globalModels.value = modelsData.models || []
allApiFormats.value = formatsData.formats?.map((f: { value: string }) => f.value) || []
} catch (err) {
log.error('加载访问限制选项失败:', err)
}
}
// 清空过期日期(同时清空到期删除选项)
function clearExpiryDate() {
form.value.expires_at = undefined
form.value.auto_delete_on_expiry = false
}
// 提交表单
function handleSubmit() {
emit('submit', {
id: form.value.id,
name: form.value.name,
initial_balance_usd: form.value.initial_balance_usd,
unlimited_balance: form.value.unlimited_balance,
expires_at: form.value.expires_at,
rate_limit: form.value.rate_limit_inherited ? null : (form.value.rate_limit ?? 0),
auto_delete_on_expiry: form.value.auto_delete_on_expiry,
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],
})
}
// 设置保存状态
function setSaving(value: boolean) {
saving.value = value
}
// 监听打开状态,加载选项数据
watch(isOpen, (val) => {
if (val) {
loadAccessRestrictionOptions()
}
})
watch(
() => form.value.unlimited_balance,
(unlimited) => {
if (unlimited) {
form.value.initial_balance_usd = undefined
} else if (form.value.initial_balance_usd == null) {
form.value.initial_balance_usd = 10
}
}
)
defineExpose({
setSaving
})
</script>