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 <LewisPen@nyadoo.com>
This commit is contained in:
fawney19
2026-03-15 14:22:59 +08:00
parent 920a383136
commit f92b0943b5
35 changed files with 2051 additions and 238 deletions

View File

@@ -86,6 +86,7 @@ export interface UserExport {
allowed_providers?: string[] | null
allowed_api_formats?: string[] | null
allowed_models?: string[] | null
rate_limit?: number | null // null = 跟随系统默认0 = 不限制
model_capability_settings?: Record<string, Record<string, boolean>>
unlimited?: boolean
wallet?: BillingSummary | null
@@ -102,7 +103,7 @@ export interface UserApiKeyExport {
allowed_providers?: string[] | null
allowed_api_formats?: string[] | null
allowed_models?: string[] | null
rate_limit?: number | null // null = 无限制
rate_limit?: number | null // legacy/null 兼容1.3+ standalone null = 跟随系统默认
concurrent_limit?: number | null
force_capabilities?: Record<string, boolean>
is_active: boolean
@@ -349,7 +350,7 @@ export interface AdminApiKey {
total_requests?: number
total_tokens?: number
total_cost_usd?: number
rate_limit?: number | null // null = 限制
rate_limit?: number | null // null = 跟随系统默认0 = 不限制
allowed_providers?: string[] | null // 允许的提供商列表
allowed_api_formats?: string[] | null // 允许的 API 格式列表
allowed_models?: string[] | null // 允许的模型列表
@@ -365,7 +366,7 @@ export interface CreateStandaloneApiKeyRequest {
allowed_providers?: string[] | null
allowed_api_formats?: string[] | null
allowed_models?: string[] | null
rate_limit?: number | null // null = 限制
rate_limit?: number | null // null = 跟随系统默认0 = 不限制
expires_at?: string | null // ISO 日期字符串,如 "2025-12-31"null = 永不过期
initial_balance_usd: number | null // 初始余额null = 无限制
unlimited_balance?: boolean | null // 编辑时仅切换额度模式,不调整余额数值

View File

@@ -142,6 +142,7 @@ export interface ApiKey {
created_at: string
total_requests?: number
total_cost_usd?: number
rate_limit?: number | null
allowed_providers?: ProviderConfig[]
force_capabilities?: Record<string, boolean> | null // 强制能力配置
}
@@ -181,8 +182,8 @@ export const meApi = {
return response.data
},
async createApiKey(name: string): Promise<ApiKey> {
const response = await apiClient.post<ApiKey>('/api/users/me/api-keys', { name })
async createApiKey(data: { name: string; rate_limit?: number }): Promise<ApiKey> {
const response = await apiClient.post<ApiKey>('/api/users/me/api-keys', data)
return response.data
},
@@ -212,6 +213,17 @@ export const meApi = {
return response.data
},
async updateApiKey(
keyId: string,
data: { name?: string; rate_limit?: number | null }
): Promise<ApiKey & { message: string }> {
const response = await apiClient.put<ApiKey & { message: string }>(
`/api/users/me/api-keys/${keyId}`,
data
)
return response.data
},
// 使用统计
async getUsage(params?: {
start_date?: string

View File

@@ -10,6 +10,7 @@ export interface User {
allowed_providers: string[] | null // 允许使用的提供商 ID 列表
allowed_api_formats: string[] | null // 允许使用的 API 格式列表
allowed_models: string[] | null // 允许使用的模型名称列表
rate_limit?: number | null // null = 跟随系统默认0 = 不限制
created_at: string
updated_at?: string
last_login_at?: string | null
@@ -25,6 +26,7 @@ export interface CreateUserRequest {
allowed_providers?: string[] | null
allowed_api_formats?: string[] | null
allowed_models?: string[] | null
rate_limit?: number | null
}
export interface UpdateUserRequest {
@@ -36,6 +38,7 @@ export interface UpdateUserRequest {
allowed_providers?: string[] | null
allowed_api_formats?: string[] | null
allowed_models?: string[] | null
rate_limit?: number | null
}
export interface ApiKey {
@@ -49,11 +52,16 @@ export interface ApiKey {
is_active: boolean
is_locked: boolean // 管理员锁定标志
is_standalone: boolean // 是否为独立余额Key
rate_limit?: number // 速率限制(请求/分钟)
rate_limit?: number | null // 普通Key: 0 = 不限制,历史 null 视为跟随系统默认
total_requests?: number // 总请求数
total_cost_usd?: number // 总费用
}
export interface UpsertUserApiKeyRequest {
name?: string
rate_limit?: number | null
}
export const usersApi = {
async getAllUsers(): Promise<User[]> {
const response = await apiClient.get<User[]>('/api/admin/users')
@@ -84,8 +92,26 @@ export const usersApi = {
return response.data.api_keys
},
async createApiKey(userId: string, name?: string): Promise<ApiKey & { key: string }> {
const response = await apiClient.post<ApiKey & { key: string }>(`/api/admin/users/${userId}/api-keys`, { name })
async createApiKey(
userId: string,
data: UpsertUserApiKeyRequest
): Promise<ApiKey & { key: string }> {
const response = await apiClient.post<ApiKey & { key: string }>(
`/api/admin/users/${userId}/api-keys`,
data
)
return response.data
},
async updateApiKey(
userId: string,
keyId: string,
data: UpsertUserApiKeyRequest
): Promise<ApiKey & { message: string }> {
const response = await apiClient.put<ApiKey & { message: string }>(
`/api/admin/users/${userId}/api-keys/${keyId}`,
data
)
return response.data
},

View File

@@ -96,23 +96,6 @@
{{ form.expires_at ? '到期后' + (form.auto_delete_on_expiry ? '自动删除' : '仅禁用') + '(当天 23:59 失效)' : '留空表示永不过期' }}
</p>
</div>
<div class="space-y-2">
<Label
for="form-rate-limit"
class="text-sm font-medium"
>速率限制 (请求/分钟)</Label>
<Input
id="form-rate-limit"
:model-value="form.rate_limit ?? ''"
type="number"
min="1"
max="10000"
placeholder="留空不限制"
class="h-10"
@update:model-value="(v) => form.rate_limit = parseNumberInput(v, { min: 1, max: 10000 })"
/>
</div>
</div>
<!-- 右侧:访问限制 -->
@@ -190,6 +173,36 @@
</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>
@@ -267,7 +280,7 @@ export interface StandaloneKeyFormData {
initial_balance_usd?: number
unlimited_balance?: boolean
expires_at?: string // ISO 日期字符串,如 "2025-12-31"undefined = 永不过期
rate_limit?: number
rate_limit?: number | null
auto_delete_on_expiry: boolean
allowed_providers?: string[] | null
allowed_api_formats?: string[] | null
@@ -280,6 +293,7 @@ interface StandaloneKeyFormState {
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
@@ -333,6 +347,7 @@ const form = ref<StandaloneKeyFormState>({
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,
@@ -356,6 +371,7 @@ function resetForm() {
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,
@@ -375,7 +391,8 @@ function loadKeyData() {
initial_balance_usd: props.apiKey.initial_balance_usd,
unlimited_balance: props.apiKey.initial_balance_usd == null,
expires_at: props.apiKey.expires_at,
rate_limit: props.apiKey.rate_limit,
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,
@@ -425,7 +442,7 @@ function handleSubmit() {
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,
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],

View File

@@ -256,6 +256,36 @@
</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>
@@ -352,6 +382,7 @@ export interface UserFormData {
allowed_providers?: string[] | null
allowed_api_formats?: string[] | null
allowed_models?: string[] | null
rate_limit?: number | null
}
const props = defineProps<{
@@ -406,9 +437,11 @@ const form = ref({
provider_unrestricted: true,
api_format_unrestricted: true,
model_unrestricted: true,
rate_limit_inherited: true,
allowed_providers: [] as string[],
allowed_api_formats: [] as string[],
allowed_models: [] as string[],
rate_limit: undefined as number | undefined,
})
function createFieldNonce(): string {
@@ -429,9 +462,11 @@ function resetForm() {
provider_unrestricted: true,
api_format_unrestricted: true,
model_unrestricted: true,
rate_limit_inherited: true,
allowed_providers: [],
allowed_api_formats: [],
allowed_models: [],
rate_limit: undefined,
}
}
@@ -451,9 +486,11 @@ function loadUserData() {
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: 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,
}
}
@@ -543,6 +580,7 @@ async function handleSubmit() {
allowed_models: form.value.model_unrestricted
? null
: [...form.value.allowed_models],
rate_limit: form.value.rate_limit_inherited ? null : (form.value.rate_limit ?? 0),
}
if (isEditMode.value && props.user?.id) {

View File

@@ -1,6 +1,13 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { usersApi, type User, type CreateUserRequest, type UpdateUserRequest, type ApiKey } from '@/api/users'
import {
usersApi,
type User,
type CreateUserRequest,
type UpdateUserRequest,
type ApiKey,
type UpsertUserApiKeyRequest
} from '@/api/users'
import { parseApiError } from '@/utils/errorParser'
export const useUsersStore = defineStore('users', () => {
@@ -84,15 +91,28 @@ export const useUsersStore = defineStore('users', () => {
}
}
async function createApiKey(userId: string, name?: string): Promise<ApiKey> {
async function createApiKey(userId: string, data: UpsertUserApiKeyRequest): Promise<ApiKey> {
try {
return await usersApi.createApiKey(userId, name)
return await usersApi.createApiKey(userId, data)
} catch (err: unknown) {
error.value = parseApiError(err, '创建 API Key 失败')
throw err
}
}
async function updateApiKey(
userId: string,
keyId: string,
data: UpsertUserApiKeyRequest
): Promise<ApiKey> {
try {
return await usersApi.updateApiKey(userId, keyId, data)
} catch (err: unknown) {
error.value = parseApiError(err, '更新 API Key 失败')
throw err
}
}
async function deleteApiKey(userId: string, keyId: string) {
try {
await usersApi.deleteApiKey(userId, keyId)
@@ -121,6 +141,7 @@ export const useUsersStore = defineStore('users', () => {
deleteUser,
getUserApiKeys,
createApiKey,
updateApiKey,
deleteApiKey,
getFullApiKey
}

View File

@@ -159,3 +159,25 @@ export function formatHitRate(rate: number | undefined): string {
if (typeof rate !== 'number' || Number.isNaN(rate)) return '-'
return `${rate.toFixed(2)}%`
}
// Rate limit formatting (supports "inherit" semantics: null = inherit system default)
export function formatRateLimitInheritable(rateLimit?: number | null): string {
if (rateLimit == null) return '跟随系统'
if (rateLimit === 0) return '不限速'
return `${rateLimit}/min`
}
// Rate limit formatting (simple: null/0 both mean unlimited)
export function formatRateLimitSimple(rateLimit?: number | null): string {
if (rateLimit == null || rateLimit === 0) return '不限速'
return `${rateLimit}/min`
}
// Rate limit state helpers
export function isRateLimitInherited(rateLimit?: number | null): boolean {
return rateLimit == null
}
export function isRateLimitUnlimited(rateLimit?: number | null): boolean {
return rateLimit === 0
}

View File

@@ -105,8 +105,8 @@
<TableHead class="w-[240px] h-12 font-semibold">
钱包
</TableHead>
<TableHead class="w-[130px] h-12 font-semibold">
使用统计
<TableHead class="w-[190px] h-12 font-semibold">
统计/限速
</TableHead>
<TableHead class="w-[110px] h-12 font-semibold">
有效期
@@ -221,7 +221,23 @@
请求: <span class="font-medium text-foreground">{{ (apiKey.total_requests || 0).toLocaleString() }}</span>
</div>
<div class="text-muted-foreground">
速率: <span class="font-medium text-foreground">{{ apiKey.rate_limit ? `${apiKey.rate_limit}/min` : '未设置' }}</span>
Tokens: <span class="font-medium text-foreground">{{ formatTokens(apiKey.total_tokens || 0) }}</span>
</div>
<div class="flex items-center gap-1 text-muted-foreground">
<span>限速:</span>
<Badge
v-if="isRateLimitInherited(apiKey.rate_limit) || isRateLimitUnlimited(apiKey.rate_limit)"
variant="secondary"
class="h-5 px-1.5 py-0 text-[10px] font-medium"
>
{{ formatRateLimitInheritable(apiKey.rate_limit) }}
</Badge>
<span
v-else
class="font-medium text-foreground"
>
{{ formatRateLimitInheritable(apiKey.rate_limit) }}
</span>
</div>
</div>
</TableCell>
@@ -387,6 +403,12 @@
>
{{ walletStatusLabel(getApiKeyWalletStatus(apiKey.id)) }}
</Badge>
<Badge
variant="secondary"
class="h-5 px-1.5 py-0 text-[10px] font-medium"
>
{{ formatRateLimitInheritable(apiKey.rate_limit) }}
</Badge>
<Badge
v-if="apiKey.auto_delete_on_expiry"
variant="secondary"
@@ -429,14 +451,6 @@
</div>
<div class="grid grid-cols-2 gap-2.5 text-xs">
<div class="rounded-lg border border-border/50 bg-background/70 p-2.5">
<div class="mb-1 text-muted-foreground">
速率限制
</div>
<div class="font-semibold text-foreground">
{{ apiKey.rate_limit ? `${apiKey.rate_limit}/min` : '未设置' }}
</div>
</div>
<div class="rounded-lg border border-border/50 bg-background/70 p-2.5">
<div class="mb-1 text-muted-foreground">
请求次数
@@ -445,6 +459,14 @@
{{ (apiKey.total_requests || 0).toLocaleString() }}
</div>
</div>
<div class="rounded-lg border border-border/50 bg-background/70 p-2.5">
<div class="mb-1 text-muted-foreground">
Tokens
</div>
<div class="font-semibold text-foreground">
{{ formatTokens(apiKey.total_tokens || 0) }}
</div>
</div>
<div class="col-span-2 rounded-lg border border-border/50 bg-background/70 p-2.5">
<div class="mb-1 text-muted-foreground">
有效期
@@ -663,6 +685,7 @@ import {
import { StandaloneKeyFormDialog, type StandaloneKeyFormData } from '@/features/api-keys'
import { parseApiError } from '@/utils/errorParser'
import { formatTokens, formatRateLimitInheritable, isRateLimitInherited, isRateLimitUnlimited } from '@/utils/format'
import { log } from '@/utils/logger'
const { success, error } = useToast()
@@ -1028,7 +1051,7 @@ async function handleKeyFormSubmit(data: StandaloneKeyFormData) {
const updateData: Partial<CreateStandaloneApiKeyRequest> = {
name: data.name || undefined,
unlimited_balance: Boolean(data.unlimited_balance),
rate_limit: data.rate_limit ?? null, // undefined = 无限制,显式传 null
rate_limit: data.rate_limit ?? null, // undefined = 跟随系统默认,显式传 null
expires_at: data.expires_at || null, // undefined/空 = 永不过期
auto_delete_on_expiry: data.auto_delete_on_expiry,
// 空数组表示清除限制(允许全部),后端会将空数组存为 NULL
@@ -1057,7 +1080,7 @@ async function handleKeyFormSubmit(data: StandaloneKeyFormData) {
const createData: CreateStandaloneApiKeyRequest = {
name: data.name || undefined,
initial_balance_usd: isUnlimited ? null : (data.initial_balance_usd as number),
rate_limit: data.rate_limit ?? null, // undefined = 无限制,显式传 null
rate_limit: data.rate_limit ?? null, // undefined = 跟随系统默认,显式传 null
expires_at: data.expires_at || null, // undefined/空 = 永不过期
auto_delete_on_expiry: data.auto_delete_on_expiry,
// 空数组表示不设置限制(允许全部),后端会将空数组存为 NULL

View File

@@ -180,7 +180,7 @@
钱包
</TableHead>
<TableHead class="w-[170px] h-12 font-semibold">
使用统计
统计/限速
</TableHead>
<TableHead class="w-[110px] h-12 font-semibold">
创建时间
@@ -258,26 +258,42 @@
</div>
</TableCell>
<TableCell class="py-4">
<div
v-if="userStats[user.id]"
class="space-y-1 text-xs"
>
<div class="flex items-center text-muted-foreground">
<span class="w-14">请求:</span>
<span class="font-medium text-foreground">{{ formatNumber(userStats[user.id]?.request_count) }}</span>
<div class="space-y-1 text-xs">
<template v-if="userStats[user.id]">
<div class="flex items-center text-muted-foreground">
<span class="w-14">请求:</span>
<span class="font-medium text-foreground">{{ formatNumber(userStats[user.id]?.request_count) }}</span>
</div>
<div class="flex items-center text-muted-foreground">
<span class="w-14">Tokens:</span>
<span class="font-medium text-foreground">{{ formatTokens(userStats[user.id]?.total_tokens ?? 0) }}</span>
</div>
</template>
<div
v-else
class="flex items-center text-muted-foreground"
>
<span class="w-14">统计:</span>
<span v-if="loadingStats">加载中...</span>
<span v-else>无数据</span>
</div>
<div class="flex items-center text-muted-foreground">
<span class="w-14">Tokens:</span>
<span class="font-medium text-foreground">{{ formatTokens(userStats[user.id]?.total_tokens ?? 0) }}</span>
<span class="w-14">限速:</span>
<Badge
v-if="isRateLimitInherited(user.rate_limit) || isRateLimitUnlimited(user.rate_limit)"
variant="secondary"
class="h-5 px-1.5 py-0 text-[10px] font-medium"
>
{{ formatRateLimitInheritable(user.rate_limit) }}
</Badge>
<span
v-else
class="font-medium text-foreground"
>
{{ formatRateLimitInheritable(user.rate_limit) }}
</span>
</div>
</div>
<div
v-else
class="text-xs text-muted-foreground"
>
<span v-if="loadingStats">加载中...</span>
<span v-else>无数据</span>
</div>
</TableCell>
<TableCell class="py-4 text-xs text-muted-foreground">
{{ formatDate(user.created_at) }}
@@ -436,6 +452,12 @@
>
{{ walletStatusLabel(getUserWalletStatus(user.id)) }}
</Badge>
<Badge
variant="secondary"
class="h-5 px-1.5 py-0 text-[10px] font-medium"
>
{{ formatRateLimitInheritable(user.rate_limit) }}
</Badge>
</div>
<div class="rounded-xl border border-border/60 bg-muted/40 p-3.5">
@@ -633,6 +655,12 @@
>
独立余额
</Badge>
<Badge
variant="secondary"
class="text-xs"
>
{{ formatRateLimitSimple(apiKey.rate_limit) }}
</Badge>
</div>
<div class="flex items-center gap-1 mt-0.5">
<code class="text-xs font-mono text-muted-foreground">
@@ -658,6 +686,15 @@
${{ (apiKey.total_cost_usd || 0).toFixed(4) }}
</div>
</div>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="编辑"
@click="openEditUserApiKeyDialog(apiKey)"
>
<SquarePen class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
@@ -718,13 +755,87 @@
<Button
class="h-10 px-5"
:disabled="creatingApiKey"
@click="createApiKey"
@click="openCreateUserApiKeyDialog"
>
{{ creatingApiKey ? '创建中...' : '创建' }}
</Button>
</template>
</Dialog>
<Dialog
v-model="showUserApiKeyFormDialog"
size="lg"
>
<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-kraft/10 flex-shrink-0">
<Key class="h-5 w-5 text-kraft" />
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-foreground leading-tight">
{{ editingUserApiKey ? '编辑 API Key' : '创建 API Key' }}
</h3>
<p class="text-xs text-muted-foreground">
{{ editingUserApiKey ? '更新用户 API Key 的名称和速率限制' : '为用户创建新的 API Key' }}
</p>
</div>
</div>
</div>
</template>
<div class="space-y-4">
<div class="space-y-2">
<Label
for="admin-user-key-name"
class="text-sm font-medium"
>密钥名称</Label>
<Input
id="admin-user-key-name"
v-model="userApiKeyForm.name"
class="h-10"
placeholder="例如:生产环境 Key"
/>
</div>
<div class="space-y-2">
<Label
for="admin-user-key-rate-limit"
class="text-sm font-medium"
>速率限制 (请求/分钟)</Label>
<Input
id="admin-user-key-rate-limit"
:model-value="userApiKeyForm.rate_limit ?? ''"
type="number"
min="0"
max="10000"
class="h-10"
placeholder="留空不限"
@update:model-value="(v) => userApiKeyForm.rate_limit = parseNumberInput(v, { min: 0, max: 10000 })"
/>
<p class="text-xs text-muted-foreground">
留空表示不限制
</p>
</div>
</div>
<template #footer>
<Button
variant="outline"
class="h-10 px-5"
@click="closeUserApiKeyFormDialog"
>
取消
</Button>
<Button
class="h-10 px-5"
:disabled="creatingApiKey"
@click="submitUserApiKeyForm"
>
{{ creatingApiKey ? (editingUserApiKey ? '保存中...' : '创建中...') : (editingUserApiKey ? '保存' : '创建') }}
</Button>
</template>
</Dialog>
<WalletOpsDrawer
:open="showWalletActionDialogState"
:wallet="walletActionTarget?.wallet || null"
@@ -849,6 +960,8 @@ import {
import UserFormDialog, { type UserFormData } from '@/features/users/components/UserFormDialog.vue'
import WalletOpsDrawer from '@/features/wallet/components/WalletOpsDrawer.vue'
import { parseApiError } from '@/utils/errorParser'
import { formatTokens, formatRateLimitInheritable, formatRateLimitSimple, isRateLimitInherited, isRateLimitUnlimited } from '@/utils/format'
import { parseNumberInput } from '@/utils/form'
import { log } from '@/utils/logger'
const { success, error } = useToast()
@@ -864,11 +977,17 @@ const userFormDialogRef = ref<InstanceType<typeof UserFormDialog>>()
// API Keys 对话框状态
const showApiKeysDialog = ref(false)
const showNewApiKeyDialog = ref(false)
const showUserApiKeyFormDialog = ref(false)
const selectedUser = ref<User | null>(null)
const userApiKeys = ref<ApiKey[]>([])
const newApiKey = ref('')
const creatingApiKey = ref(false)
const apiKeyInput = ref<HTMLInputElement>()
const editingUserApiKey = ref<ApiKey | null>(null)
const userApiKeyForm = ref({
name: '',
rate_limit: undefined as number | undefined,
})
// 用户统计
const userStats = ref<Record<string, UsageByUser>>({})
@@ -979,15 +1098,6 @@ async function loadUserWallets() {
}
}
function formatTokens(tokens: number): string {
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(1)}M`
} else if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(1)}K`
}
return tokens.toString()
}
function formatNumber(value?: number | null): string {
const numericValue = typeof value === 'number' && Number.isFinite(value) ? value : 0
return numericValue.toLocaleString()
@@ -1071,7 +1181,8 @@ function editUser(user: User) {
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]
allowed_models: user.allowed_models == null ? null : [...user.allowed_models],
rate_limit: user.rate_limit ?? null
}
showUserFormDialog.value = true
}
@@ -1093,7 +1204,8 @@ async function handleUserFormSubmit(data: UserFormData & { password?: string; un
role: data.role,
allowed_providers: data.allowed_providers,
allowed_api_formats: data.allowed_api_formats,
allowed_models: data.allowed_models
allowed_models: data.allowed_models,
rate_limit: data.rate_limit ?? null
}
if (data.password) {
updateData.password = data.password
@@ -1112,7 +1224,8 @@ async function handleUserFormSubmit(data: UserFormData & { password?: string; un
role: data.role,
allowed_providers: data.allowed_providers,
allowed_api_formats: data.allowed_api_formats,
allowed_models: data.allowed_models
allowed_models: data.allowed_models,
rate_limit: data.rate_limit ?? null
})
// 如果创建时指定为禁用,则更新状态
if (data.is_active === false && newUser) {
@@ -1145,20 +1258,61 @@ async function loadUserApiKeys(userId: string) {
}
}
async function createApiKey() {
function openCreateUserApiKeyDialog() {
userApiKeyForm.value = {
name: `Key-${new Date().toISOString().split('T')[0]}`,
rate_limit: undefined,
}
editingUserApiKey.value = null
showUserApiKeyFormDialog.value = true
}
function openEditUserApiKeyDialog(apiKey: ApiKey) {
editingUserApiKey.value = apiKey
userApiKeyForm.value = {
name: apiKey.name || '',
rate_limit: apiKey.rate_limit ?? undefined,
}
showUserApiKeyFormDialog.value = true
}
function closeUserApiKeyFormDialog() {
showUserApiKeyFormDialog.value = false
editingUserApiKey.value = null
userApiKeyForm.value = {
name: '',
rate_limit: undefined,
}
}
async function submitUserApiKeyForm() {
if (!selectedUser.value) return
if (!userApiKeyForm.value.name.trim()) {
error('请输入密钥名称', editingUserApiKey.value ? '更新 API Key 失败' : '创建 API Key 失败')
return
}
creatingApiKey.value = true
try {
const response = await usersStore.createApiKey(
selectedUser.value.id,
`Key-${new Date().toISOString().split('T')[0]}`
)
newApiKey.value = response.key || ''
showNewApiKeyDialog.value = true
if (editingUserApiKey.value) {
await usersStore.updateApiKey(selectedUser.value.id, editingUserApiKey.value.id, {
name: userApiKeyForm.value.name,
rate_limit: userApiKeyForm.value.rate_limit ?? 0,
})
success('API Key已更新')
} else {
const response = await usersStore.createApiKey(selectedUser.value.id, {
name: userApiKeyForm.value.name,
rate_limit: userApiKeyForm.value.rate_limit ?? 0,
})
newApiKey.value = response.key || ''
showNewApiKeyDialog.value = true
success('API Key创建成功')
}
await loadUserApiKeys(selectedUser.value.id)
closeUserApiKeyFormDialog()
} catch (err: unknown) {
error(parseApiError(err, '未知错误'), '创建 API Key 失败')
error(parseApiError(err, '未知错误'), editingUserApiKey.value ? '更新 API Key 失败' : '创建 API Key 失败')
} finally {
creatingApiKey.value = false
}

View File

@@ -39,7 +39,7 @@
for="rate-limit"
class="block text-sm font-medium"
>
每分钟请求限制
默认速率限制 (请求/分钟)
</Label>
<Input
id="rate-limit"
@@ -50,7 +50,7 @@
@update:model-value="$emit('update:rateLimitPerMinute', Number($event))"
/>
<p class="mt-1 text-xs text-muted-foreground">
0 表示不限制
0 表示默认不限制未单独配置的用户和独立 Key 会跟随这里
</p>
</div>

View File

@@ -20,7 +20,7 @@
size="icon"
class="h-8 w-8"
title="创建新 API Key"
@click="showCreateDialog = true"
@click="openCreateApiKeyDialog"
>
<Plus class="w-3.5 h-3.5" />
</Button>
@@ -56,7 +56,7 @@
<Button
size="lg"
class="shadow-lg shadow-primary/20"
@click="showCreateDialog = true"
@click="openCreateApiKeyDialog"
>
<Plus class="mr-2 h-4 w-4" />
创建新 API Key
@@ -157,17 +157,23 @@
<div class="flex flex-col items-center gap-1">
<Badge
:variant="apiKey.is_active ? 'success' : 'secondary'"
class="font-medium px-3 py-1"
class="h-5 px-2 py-0 text-[10px] font-medium"
>
{{ apiKey.is_active ? '活跃' : '禁用' }}
</Badge>
<Badge
v-if="apiKey.is_locked"
variant="warning"
class="font-medium text-[10px]"
class="h-5 px-2 py-0 text-[10px] font-medium"
>
已锁定
</Badge>
<Badge
variant="secondary"
class="h-5 px-2 py-0 text-[10px] font-medium"
>
{{ formatRateLimitSimple(apiKey.rate_limit) }}
</Badge>
</div>
</TableCell>
@@ -179,6 +185,16 @@
<!-- 操作按钮 -->
<TableCell class="py-4">
<div class="flex justify-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:title="apiKey.is_locked ? '已锁定' : '编辑'"
:disabled="apiKey.is_locked"
@click="openEditApiKeyDialog(apiKey)"
>
<SquarePen class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
@@ -237,8 +253,24 @@
>
已锁定
</Badge>
<Badge
variant="secondary"
class="text-[10px] px-1.5 py-0"
>
{{ formatRateLimitSimple(apiKey.rate_limit) }}
</Badge>
</div>
<div class="flex items-center gap-0.5 flex-shrink-0">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:title="apiKey.is_locked ? '已锁定' : '编辑'"
:disabled="apiKey.is_locked"
@click="openEditApiKeyDialog(apiKey)"
>
<SquarePen class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
@@ -288,6 +320,10 @@
<span class="text-foreground font-medium">
{{ formatNumber(apiKey.total_requests || 0) }}
</span>
<span class="text-muted-foreground"></span>
<span class="text-muted-foreground">
{{ formatRateLimitSimple(apiKey.rate_limit) }}
</span>
</div>
</div>
</div>
@@ -316,10 +352,10 @@
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-foreground leading-tight">
创建 API 密钥
{{ editingApiKey ? '编辑 API 密钥' : '创建 API 密钥' }}
</h3>
<p class="text-xs text-muted-foreground">
创建一个新的密钥用于访问 API 服务
{{ editingApiKey ? '更新密钥名称和速率限制' : '创建一个新的密钥用于访问 API 服务' }}
</p>
</div>
</div>
@@ -344,26 +380,46 @@
给密钥起一个有意义的名称方便识别
</p>
</div>
<div class="space-y-2">
<Label
for="key-rate-limit"
class="text-sm font-semibold"
>速率限制 (请求/分钟)</Label>
<Input
id="key-rate-limit"
:model-value="newKeyRateLimit ?? ''"
type="number"
min="0"
max="10000"
placeholder="留空不限"
class="h-11 border-border/60"
@update:model-value="(v) => newKeyRateLimit = parseNumberInput(v, { min: 0, max: 10000 })"
/>
<p class="text-xs text-muted-foreground">
留空不限
</p>
</div>
</div>
<template #footer>
<Button
variant="outline"
class="h-11 px-6"
@click="showCreateDialog = false"
@click="closeApiKeyDialog"
>
取消
</Button>
<Button
class="h-11 px-6 shadow-lg shadow-primary/20"
:disabled="creating"
@click="createApiKey"
@click="saveApiKey"
>
<Loader2
v-if="creating"
class="animate-spin h-4 w-4 mr-2"
/>
{{ creating ? '创建中...' : '创建' }}
{{ creating ? (editingApiKey ? '保存中...' : '创建中...') : (editingApiKey ? '保存' : '创建') }}
</Button>
</template>
</Dialog>
@@ -455,10 +511,12 @@ import {
TableRow
} from '@/components/ui'
import RefreshButton from '@/components/ui/refresh-button.vue'
import { Plus, Key, Copy, Trash2, Loader2, Activity, CheckCircle, Power } from 'lucide-vue-next'
import { Plus, Key, Copy, Trash2, Loader2, Activity, CheckCircle, Power, SquarePen } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { log } from '@/utils/logger'
import { parseApiError } from '@/utils/errorParser'
import { formatRateLimitSimple } from '@/utils/format'
import { parseNumberInput } from '@/utils/form'
import { getErrorStatus } from '@/types/api-error'
import { computed } from 'vue'
@@ -483,8 +541,10 @@ const showKeyDialog = ref(false)
const showDeleteDialog = ref(false)
const newKeyName = ref('')
const newKeyRateLimit = ref<number | undefined>(undefined)
const newKeyValue = ref('')
const keyToDelete = ref<ApiKey | null>(null)
const editingApiKey = ref<ApiKey | null>(null)
onMounted(() => {
loadApiKeys()
@@ -509,7 +569,28 @@ async function loadApiKeys() {
}
}
async function createApiKey() {
function openEditApiKeyDialog(apiKey: ApiKey) {
editingApiKey.value = apiKey
newKeyName.value = apiKey.name || ''
newKeyRateLimit.value = apiKey.rate_limit ?? undefined
showCreateDialog.value = true
}
function openCreateApiKeyDialog() {
editingApiKey.value = null
newKeyName.value = ''
newKeyRateLimit.value = undefined
showCreateDialog.value = true
}
function closeApiKeyDialog() {
showCreateDialog.value = false
editingApiKey.value = null
newKeyName.value = ''
newKeyRateLimit.value = undefined
}
async function saveApiKey() {
if (!newKeyName.value.trim()) {
showError('请输入密钥名称')
return
@@ -517,16 +598,26 @@ async function createApiKey() {
creating.value = true
try {
const newKey = await meApi.createApiKey(newKeyName.value)
newKeyValue.value = newKey.key || ''
showCreateDialog.value = false
showKeyDialog.value = true
newKeyName.value = ''
if (editingApiKey.value) {
await meApi.updateApiKey(editingApiKey.value.id, {
name: newKeyName.value,
rate_limit: newKeyRateLimit.value ?? 0,
})
success('API 密钥更新成功')
} else {
const newKey = await meApi.createApiKey({
name: newKeyName.value,
rate_limit: newKeyRateLimit.value ?? 0,
})
newKeyValue.value = newKey.key || ''
showKeyDialog.value = true
success('API 密钥创建成功')
}
closeApiKeyDialog()
await loadApiKeys()
success('API 密钥创建成功')
} catch (error) {
log.error('创建 API 密钥失败:', error)
showError('创建 API 密钥失败')
log.error(editingApiKey.value ? '更新 API 密钥失败:' : '创建 API 密钥失败:', error)
showError(editingApiKey.value ? '更新 API 密钥失败' : '创建 API 密钥失败')
} finally {
creating.value = false
}