improve(ui):优化api key编辑表单

This commit is contained in:
chengqian committed 2026-08-13 14:37:35 +08:00
1 parent 6c9b54b74d
commit c040e17916
1 file changed
+162 -117
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { NForm, NFormItem, NInput, NInputNumber, NDatePicker, NButton } from 'naive-ui'
import type { FormInst, FormRules } from 'naive-ui'
import BaseModal from '@/components/common/BaseModal.vue'
import Icon from '@/components/common/Icon.vue'
import request from '@/utils/request'
@@ -33,12 +35,16 @@ const emit = defineEmits<{
const form = reactive({
name: '',
expires_at: '',
expires_at: null as number | null,
rate_limit_qps: null as number | null,
rate_limit_concurrency: null as number | null,
ip_whitelist_text: '',
total_quota: null as number | null,
})
const formRef = ref<FormInst | null>(null)
const rules: FormRules = {
name: [{ required: true, message: '请输入密钥名称', trigger: ['input', 'blur'] }],
}
const loading = ref(false)
const createdKey = ref('')
@@ -115,15 +121,25 @@ async function fetchAllModels() {
}
}
function quickFillExpires(days: number) {
const d = new Date()
d.setDate(d.getDate() + days)
form.expires_at = d.toISOString().slice(0, 16)
// 过期时间:内部以时间戳存储,提交/回填时与后端字符串互转
function pad2(n: number): string {
return String(n).padStart(2, '0')
}
function tsToLocalInput(ts: number): string {
const d = new Date(ts)
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`
}
function parseToTs(s: string | null): number | null {
if (!s) return null
const v = new Date(s.replace(' ', 'T')).getTime()
return Number.isNaN(v) ? null : v
}
function formatDate(d: string | null): string {
if (!d) return '永不过期'
return d.replace('T', ' ').substring(0, 16)
// NDatePicker 面板快捷选项
const expiresShortcuts = {
'1天': () => Date.now() + 24 * 60 * 60 * 1000,
'1周': () => Date.now() + 7 * 24 * 60 * 60 * 1000,
'1月': () => Date.now() + 30 * 24 * 60 * 60 * 1000,
}
function copyKey() {
@@ -135,7 +151,7 @@ function copyKey() {
function resetForm() {
form.name = ''
form.expires_at = ''
form.expires_at = null
form.rate_limit_qps = null
form.rate_limit_concurrency = null
form.ip_whitelist_text = ''
@@ -193,6 +209,7 @@ watch(() => props.show, (val) => {
fetchAllModels()
if (props.mode === 'edit' && props.apiKey) {
form.name = props.apiKey.name
form.expires_at = parseToTs(props.apiKey.expires_at)
form.rate_limit_qps = props.apiKey.rate_limit_qps
form.rate_limit_concurrency = props.apiKey.rate_limit_concurrency
form.ip_whitelist_text = (props.apiKey.ip_whitelist || []).join('\n')
@@ -207,7 +224,12 @@ function handleClose() {
}
async function handleSubmit() {
if (!form.name.trim()) return
try {
await formRef.value?.validate()
} catch {
// 校验失败:错误信息已由 NFormItem 内联展示
return
}
loading.value = true
try {
if (props.mode === 'create') {
@@ -217,7 +239,7 @@ async function handleSubmit() {
model_names: selectedModelNames.value,
}
applyLimitFields(body)
if (form.expires_at) body.expires_at = form.expires_at
if (form.expires_at) body.expires_at = tsToLocalInput(form.expires_at)
const url = props.projectId
? `/tenant/projects/${props.projectId}/api-keys`
: '/tenant/api-keys'
@@ -229,7 +251,7 @@ async function handleSubmit() {
model_names: selectedModelNames.value,
}
applyLimitFields(body)
if (form.expires_at) body.expires_at = form.expires_at
if (form.expires_at) body.expires_at = tsToLocalInput(form.expires_at)
await request.put(`/tenant/api-keys/${props.apiKey!.id}`, body)
toast.success('更新成功')
emit('update:show', false)
@@ -254,110 +276,129 @@ const isFormMode = computed(() => {
width="wide"
@close="handleClose"
>
<div v-if="isFormMode" class="space-y-4">
<div>
<label class="input-label">名称</label>
<input
v-model="form.name"
type="text"
class="input"
:placeholder="mode === 'create' ? '例如:生产环境密钥' : '密钥名称'"
/>
</div>
<div v-if="isFormMode">
<n-form ref="formRef" :model="form" :rules="rules" label-placement="top">
<div class="grid grid-cols-1 items-start gap-x-4 sm:grid-cols-2">
<n-form-item path="name" label="名称">
<n-input
v-model:value="form.name"
:maxlength="64"
clearable
:placeholder="mode === 'create' ? '例如:生产环境密钥' : '密钥名称'"
/>
</n-form-item>
<div>
<label class="input-label">可用模型</label>
<p class="text-xs text-gray-500 mb-2">留空表示不限制。</p>
<div class="flex items-center gap-3 mb-2">
<div class="relative flex-1">
<Icon name="search" size="sm" class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input v-model="modelSearch" type="text" class="input pl-9" placeholder="搜索模型..." />
</div>
<button class="btn btn-ghost btn-sm" @click="selectAllModels">全选</button>
<button class="btn btn-ghost btn-sm" @click="clearAllModels">清空</button>
<n-form-item label="过期时间">
<n-date-picker
v-model:value="form.expires_at"
type="datetime"
clearable
:format="'yyyy-MM-dd HH:mm'"
:shortcuts="expiresShortcuts"
:actions="['clear', 'confirm']"
placeholder="选择过期时间(留空表示用不过期)"
style="width: 100%"
/>
</n-form-item>
</div>
<div class="max-h-60 overflow-y-auto border border-gray-200 rounded-xl divide-y divide-gray-100">
<template v-for="(items, cat) in groupedFilteredModels" :key="cat">
<div class="px-4 py-2 bg-gray-50 text-xs font-semibold text-gray-500 uppercase tracking-wider sticky top-0">
{{ categoryLabel[cat as string] || cat }}
<span class="text-gray-300">({{ items.length }})</span>
</div>
<label
v-for="m in items"
:key="m.id"
class="flex items-center gap-3 px-4 py-2 hover:bg-gray-50 cursor-pointer"
>
<input
type="checkbox"
:checked="selectedModelNames.includes(m.model_id)"
@change="toggleModelName(m.model_id)"
class="h-4 w-4 rounded border-gray-300 text-primary-500 focus:ring-primary-500/30"
/>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-900 truncate">{{ m.model_name || m.model_id }}</p>
<p class="text-xs text-gray-400 font-mono truncate">{{ m.model_id }}</p>
<n-form-item label="可用模型">
<div class="w-full">
<p class="mb-2 text-xs text-gray-500">留空表示不限制。</p>
<div class="mb-2 flex items-center gap-2">
<div class="flex-1">
<n-input v-model:value="modelSearch" clearable placeholder="搜索模型...">
<template #prefix>
<Icon name="search" size="sm" class="text-gray-400" />
</template>
</n-input>
</div>
<span class="badge shrink-0" :class="categoryBadgeClass[m.category] || 'badge-gray'">
{{ categoryLabel[m.category] || m.category }}
</span>
</label>
</template>
<div v-if="filteredModels.length === 0" class="px-4 py-6 text-center text-sm text-gray-400">
没有匹配的模型
<n-button size="small" secondary @click="selectAllModels">全选</n-button>
<n-button size="small" secondary @click="clearAllModels">清空</n-button>
</div>
<div class="max-h-60 overflow-y-auto divide-y divide-gray-100 rounded-xl border border-gray-200">
<template v-for="(items, cat) in groupedFilteredModels" :key="cat">
<div class="sticky top-0 px-4 py-2 bg-gray-50 text-xs font-semibold text-gray-500 uppercase tracking-wider">
{{ categoryLabel[cat as string] || cat }}
<span class="text-gray-300">({{ items.length }})</span>
</div>
<label
v-for="m in items"
:key="m.id"
class="flex cursor-pointer items-center gap-3 px-4 py-2 hover:bg-gray-50"
>
<input
type="checkbox"
:checked="selectedModelNames.includes(m.model_id)"
@change="toggleModelName(m.model_id)"
class="h-4 w-4 rounded border-gray-300 text-primary-500 focus:ring-primary-500/30"
/>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-gray-900">{{ m.model_name || m.model_id }}</p>
<p class="truncate font-mono text-xs text-gray-400">{{ m.model_id }}</p>
</div>
<span class="badge shrink-0" :class="categoryBadgeClass[m.category] || 'badge-gray'">
{{ categoryLabel[m.category] || m.category }}
</span>
</label>
</template>
<div v-if="filteredModels.length === 0" class="px-4 py-6 text-center text-sm text-gray-400">
没有匹配的模型
</div>
</div>
<p class="mt-1 text-xs text-gray-500">
已选择 <span class="font-medium text-gray-700">{{ selectedModelNames.length }}</span> 个模型
<template v-if="selectedModelNames.length === 0">(不限制)</template>
</p>
</div>
</div>
<p class="text-xs text-gray-500 mt-1">
已选择 <span class="font-medium text-gray-700">{{ selectedModelNames.length }}</span> 个模型
<template v-if="selectedModelNames.length === 0">(不限制)</template>
</p>
</div>
</n-form-item>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label class="input-label">QPS 限速</label>
<input v-model.number="form.rate_limit_qps" type="number" min="0" class="input" placeholder="留空使用默认" />
<div class="grid grid-cols-1 gap-x-4 sm:grid-cols-3">
<n-form-item label="QPS 限速" class="!mb-0">
<n-input-number
v-model:value="form.rate_limit_qps"
:min="0"
:show-button="false"
clearable
placeholder="留空使用默认"
style="width: 100%"
/>
</n-form-item>
<n-form-item label="并发限制" class="!mb-0">
<n-input-number
v-model:value="form.rate_limit_concurrency"
:min="0"
:show-button="false"
clearable
placeholder="0 = 不限制"
style="width: 100%"
/>
</n-form-item>
<n-form-item label="总额度 (USD)" class="!mb-0">
<n-input-number
v-model:value="form.total_quota"
:min="0"
:show-button="false"
clearable
placeholder="0 = 不限制"
style="width: 100%"
/>
</n-form-item>
</div>
<div>
<label class="input-label">并发限制</label>
<input v-model.number="form.rate_limit_concurrency" type="number" min="0" class="input" placeholder="0 = 不限制" />
</div>
<div>
<label class="input-label">总额度 (USD)</label>
<input v-model.number="form.total_quota" type="number" step="0.01" min="0" class="input" placeholder="0 = 不限制" />
<p v-if="apiKey && apiKey.used_quota" class="input-hint">已使用 ${{ apiKey.used_quota?.toFixed(4) }}</p>
</div>
</div>
<p v-if="apiKey && apiKey.used_quota" class="input-hint mb-2">已使用 ${{ apiKey.used_quota?.toFixed(4) }}</p>
<div>
<label class="input-label">IP 白名单</label>
<textarea
v-model="form.ip_whitelist_text"
class="input"
rows="3"
placeholder="每行一个 IP 或 CIDR,留空表示不限制"
></textarea>
<p class="input-hint">支持 192.168.1.10 或 10.0.0.0/24,也可用逗号分隔。</p>
</div>
<div>
<label class="input-label">过期时间</label>
<div class="flex items-center gap-2">
<input
v-model="form.expires_at"
type="datetime-local"
class="input flex-1"
:placeholder="mode === 'create' ? '留空表示永不过期' : '留空表示不修改'"
/>
<div class="inline-flex rounded-lg overflow-hidden border border-gray-200 shrink-0">
<button type="button" @click="quickFillExpires(1)" class="px-3.5 py-2 text-sm font-medium text-emerald-700 bg-emerald-50 hover:bg-emerald-100 transition-colors border-r border-gray-200">1天</button>
<button type="button" @click="quickFillExpires(7)" class="px-3.5 py-2 text-sm font-medium text-amber-700 bg-amber-50 hover:bg-amber-100 transition-colors border-r border-gray-200">1周</button>
<button type="button" @click="quickFillExpires(30)" class="px-3.5 py-2 text-sm font-medium text-violet-700 bg-violet-50 hover:bg-violet-100 transition-colors">1月</button>
<n-form-item label="IP 白名单">
<div class="w-full">
<n-input
v-model:value="form.ip_whitelist_text"
type="textarea"
:rows="3"
placeholder="每行一个 IP 或 CIDR,留空表示不限制"
/>
<p class="input-hint">支持 192.168.1.10 或 10.0.0.0/24,也可用逗号分隔。</p>
</div>
</div>
<p v-if="mode === 'edit' && apiKey && apiKey.expires_at" class="input-hint">当前过期时间:{{ formatDate(apiKey.expires_at) }}</p>
<p v-else-if="mode === 'edit'" class="input-hint">当前:永不过期</p>
<p v-else class="input-hint">留空表示永不过期</p>
</div>
</n-form-item>
</n-form>
</div>
<div v-else class="space-y-4">
@@ -380,20 +421,24 @@ const isFormMode = computed(() => {
<template #footer>
<template v-if="mode === 'create' && !createdKey">
<button @click="handleClose" class="btn btn-secondary">取消</button>
<button @click="handleSubmit" :disabled="loading || !form.name.trim()" class="btn btn-primary">
{{ loading ? '创建中...' : '创建' }}
</button>
<div class="flex items-center justify-end gap-2">
<button @click="handleClose" class="btn btn-secondary">取消</button>
<button @click="handleSubmit" :disabled="loading" class="btn btn-primary">
{{ loading ? '创建中...' : '创建' }}
</button>
</div>
</template>
<template v-else-if="mode === 'create' && createdKey">
<button @click="handleClose" class="btn btn-secondary w-full">完成</button>
</template>
<template v-else>
<button @click="handleClose" class="btn btn-secondary">取消</button>
<button @click="handleSubmit" :disabled="loading || !form.name.trim()" class="btn btn-primary">
{{ loading ? '保存中...' : '保存' }}
</button>
<div class="flex items-center justify-end gap-2">
<button @click="handleClose" class="btn btn-secondary">取消</button>
<button @click="handleSubmit" :disabled="loading" class="btn btn-primary">
{{ loading ? '保存中...' : '保存' }}
</button>
</div>
</template>
</template>
</BaseModal>
</template>
</template>