improve(ui):优化api key编辑表单
This commit is contained in:
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>
|
||||
Reference in new issue
Block a user