API 密钥: 支持编辑与高级选项, 修复 jsonb 白名单更新

- 前端密钥页新增"编辑": 改名/每日配额/模型白名单/状态切换
- 新建密钥展开"高级选项": 每日 Token/请求上限、模型白名单
- 修复 PATCH 更新 allowed_models(jsonb)不走序列化导致失败,
  改为 JSON 字符串写入, 跨 SQLite/Postgres 可靠

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sakurasan
2026-08-15 20:55:26 +08:00
co-authored by Claude
parent d0bc28a4fe
commit eb57a09c5d
2 changed files with 130 additions and 13 deletions
+9 -3
View File
@@ -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})
}
+121 -10
View File
@@ -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">