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
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
@@ -135,9 +136,6 @@ func (h *Handler) PatchKey(c *gin.Context) {
|
|||||||
if req.QuotaRequestsPerDay != nil {
|
if req.QuotaRequestsPerDay != nil {
|
||||||
updates["quota_requests_per_day"] = *req.QuotaRequestsPerDay
|
updates["quota_requests_per_day"] = *req.QuotaRequestsPerDay
|
||||||
}
|
}
|
||||||
if req.AllowedModels != nil {
|
|
||||||
updates["allowed_models"] = *req.AllowedModels
|
|
||||||
}
|
|
||||||
if req.Status != nil {
|
if req.Status != nil {
|
||||||
if *req.Status != store.KeyStatusActive && *req.Status != store.KeyStatusRevoked {
|
if *req.Status != store.KeyStatusActive && *req.Status != store.KeyStatusRevoked {
|
||||||
resp.Fail(c, http.StatusBadRequest, "status must be active or revoked")
|
resp.Fail(c, http.StatusBadRequest, "status must be active or revoked")
|
||||||
@@ -151,6 +149,14 @@ func (h *Handler) PatchKey(c *gin.Context) {
|
|||||||
return
|
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})
|
resp.OK(c, gin.H{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
import { PhCopy, PhCheck } from '@phosphor-icons/vue'
|
import { PhCopy, PhCheck } from '@phosphor-icons/vue'
|
||||||
import { http, errMsg } from '@/api/client'
|
import { http, errMsg } from '@/api/client'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
@@ -15,11 +15,36 @@ const keys = ref<ApiKey[]>([])
|
|||||||
|
|
||||||
const createOpen = ref(false)
|
const createOpen = ref(false)
|
||||||
const keyName = ref('')
|
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 creating = ref(false)
|
||||||
|
|
||||||
const created = ref<{ name: string; key: string; key_prefix: string } | null>(null)
|
const created = ref<{ name: string; key: string; key_prefix: string } | null>(null)
|
||||||
const copied = ref(false)
|
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() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const { data } = await http.get('/keys')
|
const { data } = await http.get('/keys')
|
||||||
@@ -33,10 +58,19 @@ async function createKey() {
|
|||||||
if (!keyName.value) return
|
if (!keyName.value) return
|
||||||
creating.value = true
|
creating.value = true
|
||||||
try {
|
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
|
created.value = data.data
|
||||||
createOpen.value = false
|
createOpen.value = false
|
||||||
keyName.value = ''
|
keyName.value = ''
|
||||||
|
Object.assign(createAdv, { quota_tokens_per_day: '', quota_requests_per_day: '', allowed_models: '' })
|
||||||
|
advOpen.value = false
|
||||||
await load()
|
await load()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.err(errMsg(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) {
|
async function revoke(k: ApiKey) {
|
||||||
if (!confirm(`吊销密钥 ${k.name}?吊销后立即失效。`)) return
|
if (!confirm(`吊销密钥 ${k.name}?吊销后立即失效。`)) return
|
||||||
try {
|
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.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 font-mono text-xs text-muted">{{ fmtTime(k.created_at) }}</td>
|
||||||
<td class="px-4 py-2.5 text-right">
|
<td class="px-4 py-2.5 text-right">
|
||||||
<button
|
<div class="flex justify-end gap-2.5">
|
||||||
v-if="k.status === 'active'"
|
<button class="text-xs text-muted hover:text-ink" @click="openEdit(k)">编辑</button>
|
||||||
class="text-xs text-muted hover:text-err"
|
<button
|
||||||
@click="revoke(k)"
|
v-if="k.status === 'active'"
|
||||||
>
|
class="text-xs text-muted hover:text-err"
|
||||||
吊销
|
@click="revoke(k)"
|
||||||
</button>
|
>
|
||||||
|
吊销
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="keys.length === 0">
|
<tr v-if="keys.length === 0">
|
||||||
@@ -124,13 +195,53 @@ onMounted(load)
|
|||||||
|
|
||||||
<!-- 新建密钥 -->
|
<!-- 新建密钥 -->
|
||||||
<Modal :open="createOpen" title="新建密钥" @close="createOpen = false">
|
<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>
|
<template #footer>
|
||||||
<Button variant="ghost" @click="createOpen = false">取消</Button>
|
<Button variant="ghost" @click="createOpen = false">取消</Button>
|
||||||
<Button :loading="creating" @click="createKey">创建</Button>
|
<Button :loading="creating" @click="createKey">创建</Button>
|
||||||
</template>
|
</template>
|
||||||
</Modal>
|
</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">
|
<Modal :open="!!created" title="密钥已创建" @close="created = null">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user