API 密钥: 支持编辑与高级选项, 修复 jsonb 白名单更新
- 前端密钥页新增"编辑": 改名/每日配额/模型白名单/状态切换 - 新建密钥展开"高级选项": 每日 Token/请求上限、模型白名单 - 修复 PATCH 更新 allowed_models(jsonb)不走序列化导致失败, 改为 JSON 字符串写入, 跨 SQLite/Postgres 可靠 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -135,9 +136,6 @@ func (h *Handler) PatchKey(c *gin.Context) {
|
||||
if req.QuotaRequestsPerDay != nil {
|
||||
updates["quota_requests_per_day"] = *req.QuotaRequestsPerDay
|
||||
}
|
||||
if req.AllowedModels != nil {
|
||||
updates["allowed_models"] = *req.AllowedModels
|
||||
}
|
||||
if req.Status != nil {
|
||||
if *req.Status != store.KeyStatusActive && *req.Status != store.KeyStatusRevoked {
|
||||
resp.Fail(c, http.StatusBadRequest, "status must be active or revoked")
|
||||
@@ -151,6 +149,14 @@ func (h *Handler) PatchKey(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// allowed_models 是 jsonb:手动序列化为 JSON 字符串写入(跨 SQLite/Postgres)
|
||||
if req.AllowedModels != nil {
|
||||
raw, _ := json.Marshal(*req.AllowedModels)
|
||||
if err := h.a.DB.Model(&k).Update("allowed_models", string(raw)).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to update key")
|
||||
return
|
||||
}
|
||||
}
|
||||
resp.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { PhCopy, PhCheck } from '@phosphor-icons/vue'
|
||||
import { http, errMsg } from '@/api/client'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
@@ -15,11 +15,36 @@ const keys = ref<ApiKey[]>([])
|
||||
|
||||
const createOpen = ref(false)
|
||||
const keyName = ref('')
|
||||
const advOpen = ref(false)
|
||||
const createAdv = reactive({ quota_tokens_per_day: '', quota_requests_per_day: '', allowed_models: '' })
|
||||
const creating = ref(false)
|
||||
|
||||
const created = ref<{ name: string; key: string; key_prefix: string } | null>(null)
|
||||
const copied = ref(false)
|
||||
|
||||
const editOpen = ref(false)
|
||||
const editing = ref<ApiKey | null>(null)
|
||||
const savingEdit = ref(false)
|
||||
const editForm = reactive({
|
||||
name: '',
|
||||
status: 'active',
|
||||
quota_tokens_per_day: '',
|
||||
quota_requests_per_day: '',
|
||||
allowed_models: '',
|
||||
})
|
||||
|
||||
function parseOptionalInt(s: string): number | undefined {
|
||||
const t = s.trim()
|
||||
if (t === '') return undefined
|
||||
const n = Number(t)
|
||||
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : undefined
|
||||
}
|
||||
|
||||
function parseModels(s: string): string[] | undefined {
|
||||
const arr = s.split(/[,,\s]+/).map((x) => x.trim()).filter(Boolean)
|
||||
return arr.length ? arr : undefined
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const { data } = await http.get('/keys')
|
||||
@@ -33,10 +58,19 @@ async function createKey() {
|
||||
if (!keyName.value) return
|
||||
creating.value = true
|
||||
try {
|
||||
const { data } = await http.post('/keys', { name: keyName.value })
|
||||
const payload: Record<string, unknown> = { name: keyName.value }
|
||||
const t = parseOptionalInt(createAdv.quota_tokens_per_day)
|
||||
const r = parseOptionalInt(createAdv.quota_requests_per_day)
|
||||
const m = parseModels(createAdv.allowed_models)
|
||||
if (t !== undefined) payload.quota_tokens_per_day = t
|
||||
if (r !== undefined) payload.quota_requests_per_day = r
|
||||
if (m !== undefined) payload.allowed_models = m
|
||||
const { data } = await http.post('/keys', payload)
|
||||
created.value = data.data
|
||||
createOpen.value = false
|
||||
keyName.value = ''
|
||||
Object.assign(createAdv, { quota_tokens_per_day: '', quota_requests_per_day: '', allowed_models: '' })
|
||||
advOpen.value = false
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
@@ -45,6 +79,40 @@ async function createKey() {
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(k: ApiKey) {
|
||||
editing.value = k
|
||||
Object.assign(editForm, {
|
||||
name: k.name,
|
||||
status: k.status,
|
||||
quota_tokens_per_day: k.quota_tokens_per_day != null ? String(k.quota_tokens_per_day) : '',
|
||||
quota_requests_per_day: k.quota_requests_per_day != null ? String(k.quota_requests_per_day) : '',
|
||||
allowed_models: (k.allowed_models || []).join(', '),
|
||||
})
|
||||
editOpen.value = true
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editing.value || !editForm.name) return
|
||||
savingEdit.value = true
|
||||
try {
|
||||
const payload: Record<string, unknown> = { name: editForm.name, status: editForm.status }
|
||||
const t = parseOptionalInt(editForm.quota_tokens_per_day)
|
||||
const r = parseOptionalInt(editForm.quota_requests_per_day)
|
||||
const m = parseModels(editForm.allowed_models)
|
||||
if (t !== undefined) payload.quota_tokens_per_day = t
|
||||
if (r !== undefined) payload.quota_requests_per_day = r
|
||||
if (m !== undefined) payload.allowed_models = m
|
||||
await http.patch(`/keys/${editing.value.id}`, payload)
|
||||
toast.ok('密钥已更新')
|
||||
editOpen.value = false
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
} finally {
|
||||
savingEdit.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(k: ApiKey) {
|
||||
if (!confirm(`吊销密钥 ${k.name}?吊销后立即失效。`)) return
|
||||
try {
|
||||
@@ -103,13 +171,16 @@ onMounted(load)
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ fmtTime(k.last_used_at) }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ fmtTime(k.created_at) }}</td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<button
|
||||
v-if="k.status === 'active'"
|
||||
class="text-xs text-muted hover:text-err"
|
||||
@click="revoke(k)"
|
||||
>
|
||||
吊销
|
||||
</button>
|
||||
<div class="flex justify-end gap-2.5">
|
||||
<button class="text-xs text-muted hover:text-ink" @click="openEdit(k)">编辑</button>
|
||||
<button
|
||||
v-if="k.status === 'active'"
|
||||
class="text-xs text-muted hover:text-err"
|
||||
@click="revoke(k)"
|
||||
>
|
||||
吊销
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="keys.length === 0">
|
||||
@@ -124,13 +195,53 @@ onMounted(load)
|
||||
|
||||
<!-- 新建密钥 -->
|
||||
<Modal :open="createOpen" title="新建密钥" @close="createOpen = false">
|
||||
<Input v-model="keyName" label="密钥名称" placeholder="例如 dev / prod" @keyup.enter="createKey" />
|
||||
<div class="space-y-4">
|
||||
<Input v-model="keyName" label="密钥名称" placeholder="例如 dev / prod" @keyup.enter="createKey" />
|
||||
<div>
|
||||
<button class="text-xs text-muted transition hover:text-ink" @click="advOpen = !advOpen">
|
||||
{{ advOpen ? '收起' : '展开' }}高级选项(配额 / 白名单)
|
||||
</button>
|
||||
<div v-if="advOpen" class="mt-3 space-y-4">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<Input v-model="createAdv.quota_tokens_per_day" label="每日 Token 上限" placeholder="如 100000" />
|
||||
<Input v-model="createAdv.quota_requests_per_day" label="每日请求上限" placeholder="如 1000" />
|
||||
</div>
|
||||
<Input v-model="createAdv.allowed_models" label="模型白名单" placeholder="逗号分隔,如 gpt-4o, claude-sonnet-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button variant="ghost" @click="createOpen = false">取消</Button>
|
||||
<Button :loading="creating" @click="createKey">创建</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 编辑密钥 -->
|
||||
<Modal :open="editOpen" :title="`编辑密钥 · ${editing?.name}`" @close="editOpen = false">
|
||||
<div class="space-y-4">
|
||||
<Input v-model="editForm.name" label="名称" />
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<Input v-model="editForm.quota_tokens_per_day" label="每日 Token 上限" placeholder="留空则不修改" />
|
||||
<Input v-model="editForm.quota_requests_per_day" label="每日请求上限" placeholder="留空则不修改" />
|
||||
</div>
|
||||
<Input v-model="editForm.allowed_models" label="模型白名单" placeholder="逗号分隔" hint="留空则不修改" />
|
||||
<label class="block">
|
||||
<span class="mb-1.5 block text-xs font-medium text-muted">状态</span>
|
||||
<select
|
||||
v-model="editForm.status"
|
||||
class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 text-sm text-ink outline-none focus:border-accent"
|
||||
>
|
||||
<option value="active">active(启用)</option>
|
||||
<option value="revoked">revoked(吊销)</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button variant="ghost" @click="editOpen = false">取消</Button>
|
||||
<Button :loading="savingEdit" @click="saveEdit">保存</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 一次性展示密钥 -->
|
||||
<Modal :open="!!created" title="密钥已创建" @close="created = null">
|
||||
<div class="space-y-4">
|
||||
|
||||
Reference in New Issue
Block a user