前端: API Keys 移动端卡片布局 + 复制兼容

- 移动端(md 以下)以卡片列表替代横向滚动表格, 展示配额/过期/最近使用
- 桌面表格补充配额/过期列, 移除创建时间列
- copyText 支持 Clipboard API + execCommand 回退(HTTP 环境可用)
- 删除文案由"吊销"改为"删除(不可恢复)"

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sakurasan
2026-08-16 09:04:26 +08:00
co-authored by Claude
parent fb7db00817
commit 6f895acf63
2 changed files with 123 additions and 34 deletions
+30
View File
@@ -0,0 +1,30 @@
// copyText 复制文本到剪贴板。
// 优先用 Clipboard API(安全上下文 HTTPS/localhost);HTTP 环境回退到 execCommand('copy')。
export async function copyText(text: string): Promise<boolean> {
// 1. Clipboard API
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
/* 权限/焦点问题,回退 */
}
}
// 2. execCommand 兜底(非安全上下文可用)
try {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.opacity = '0'
ta.style.pointerEvents = 'none'
document.body.appendChild(ta)
ta.focus()
ta.select()
ta.setSelectionRange(0, text.length)
const ok = document.execCommand('copy')
document.body.removeChild(ta)
return ok
} catch {
return false
}
}
+93 -34
View File
@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { PhCopy, PhCheck } from '@phosphor-icons/vue' import { PhCopy, PhCheck, PhNotePencil } 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'
import { fmtTime } from '@/lib/format' import { fmtNum, fmtTime } from '@/lib/format'
import { copyText } from '@/lib/clipboard'
import Button from '@/components/ui/Button.vue' import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue' import Input from '@/components/ui/Input.vue'
import Modal from '@/components/ui/Modal.vue' import Modal from '@/components/ui/Modal.vue'
@@ -15,7 +16,6 @@ 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 createAdv = reactive({ quota_tokens_per_day: '', quota_requests_per_day: '', allowed_models: '' })
const creating = ref(false) const creating = ref(false)
@@ -45,6 +45,13 @@ function parseModels(s: string): string[] | undefined {
return arr.length ? arr : undefined return arr.length ? arr : undefined
} }
function quotaText(k: ApiKey): string {
const parts: string[] = []
if (k.quota_tokens_per_day) parts.push(`${fmtNum(k.quota_tokens_per_day)} tok/日`)
if (k.quota_requests_per_day) parts.push(`${fmtNum(k.quota_requests_per_day)} req/日`)
return parts.length ? parts.join(' · ') : '不限'
}
async function load() { async function load() {
try { try {
const { data } = await http.get('/keys') const { data } = await http.get('/keys')
@@ -70,7 +77,6 @@ async function createKey() {
createOpen.value = false createOpen.value = false
keyName.value = '' keyName.value = ''
Object.assign(createAdv, { quota_tokens_per_day: '', quota_requests_per_day: '', allowed_models: '' }) 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))
@@ -113,11 +119,11 @@ async function saveEdit() {
} }
} }
async function revoke(k: ApiKey) { async function remove(k: ApiKey) {
if (!confirm(`吊销密钥 ${k.name}?吊销后立即失效。`)) return if (!confirm(`删除密钥 ${k.name}?删除后立即失效且不可恢复。`)) return
try { try {
await http.delete(`/keys/${k.id}`) await http.delete(`/keys/${k.id}`)
toast.ok('密钥已吊销') toast.ok('密钥已删除')
await load() await load()
} catch (e) { } catch (e) {
toast.err(errMsg(e)) toast.err(errMsg(e))
@@ -126,11 +132,11 @@ async function revoke(k: ApiKey) {
async function copyKey() { async function copyKey() {
if (!created.value) return if (!created.value) return
try { const ok = await copyText(created.value.key)
await navigator.clipboard.writeText(created.value.key) if (ok) {
copied.value = true copied.value = true
setTimeout(() => (copied.value = false), 1500) setTimeout(() => (copied.value = false), 1500)
} catch { } else {
toast.err('复制失败,请手动复制') toast.err('复制失败,请手动复制')
} }
} }
@@ -149,15 +155,56 @@ onMounted(load)
</div> </div>
<div class="card"> <div class="card">
<div class="overflow-x-auto"> <!-- 移动端:卡片列表 -->
<table class="w-full text-sm min-w-[520px]"> <div class="space-y-3 p-3 md:hidden">
<div v-for="k in keys" :key="k.id" class="card p-3">
<div class="flex items-center justify-between gap-2">
<div class="min-w-0">
<p class="truncate text-sm font-medium text-ink">{{ k.name }}</p>
<p class="truncate font-mono text-xs text-muted">{{ k.key_prefix }}…</p>
</div>
<Badge :variant="k.status === 'active' ? 'ok' : 'neutral'">{{ k.status }}</Badge>
</div>
<div class="mt-2.5 space-y-1.5 text-xs">
<p class="flex items-baseline justify-between gap-3">
<span class="shrink-0 text-muted">配额</span>
<span class="mono-num truncate text-ink">{{ quotaText(k) }}</span>
</p>
<p class="flex items-baseline justify-between gap-3">
<span class="shrink-0 text-muted">过期</span>
<span class="mono-num truncate text-ink">{{ fmtTime(k.expires_at) }}</span>
</p>
<p class="flex items-baseline justify-between gap-3">
<span class="shrink-0 text-muted">最近使用</span>
<span class="mono-num truncate text-ink">{{ fmtTime(k.last_used_at) }}</span>
</p>
</div>
<div class="mt-2.5 flex gap-2 border-t border-edge pt-2.5">
<Button size="sm" variant="ghost" class="flex-1" @click="openEdit(k)">
<PhNotePencil :size="13" weight="bold" />
编辑
</Button>
<Button v-if="k.status === 'active'" size="sm" variant="danger" class="flex-1" @click="remove(k)">
删除
</Button>
</div>
</div>
<p v-if="keys.length === 0" class="card px-4 py-8 text-center text-sm text-muted">
还没有密钥,点击右上角「新建密钥」
</p>
</div>
<!-- 桌面端:表格 -->
<div class="hidden overflow-x-auto md:block">
<table class="w-full text-sm min-w-[680px]">
<thead> <thead>
<tr class="border-b border-edge text-left text-xs text-muted"> <tr class="border-b border-edge text-left text-xs text-muted">
<th scope="col" class="px-4 py-2.5 font-medium">名称</th> <th scope="col" class="px-4 py-2.5 font-medium">名称</th>
<th scope="col" class="px-4 py-2.5 font-medium">前缀</th> <th scope="col" class="px-4 py-2.5 font-medium">前缀</th>
<th scope="col" class="px-4 py-2.5 font-medium">配额</th>
<th scope="col" class="px-4 py-2.5 font-medium">过期</th>
<th scope="col" class="px-4 py-2.5 font-medium">状态</th> <th scope="col" class="px-4 py-2.5 font-medium">状态</th>
<th scope="col" class="px-4 py-2.5 font-medium">最近使用</th> <th scope="col" class="px-4 py-2.5 font-medium">最近使用</th>
<th scope="col" class="px-4 py-2.5 font-medium">创建时间</th>
<th scope="col" class="px-4 py-2.5" /> <th scope="col" class="px-4 py-2.5" />
</tr> </tr>
</thead> </thead>
@@ -165,26 +212,40 @@ onMounted(load)
<tr v-for="k in keys" :key="k.id" class="table-row"> <tr v-for="k in keys" :key="k.id" class="table-row">
<td class="px-4 py-2.5 text-ink">{{ k.name }}</td> <td class="px-4 py-2.5 text-ink">{{ k.name }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ k.key_prefix }}…</td> <td class="px-4 py-2.5 font-mono text-xs text-muted">{{ k.key_prefix }}…</td>
<td class="px-4 py-2.5 font-mono text-xs text-muted">
<template v-if="k.quota_tokens_per_day || k.quota_requests_per_day">
<span v-if="k.quota_tokens_per_day">{{ fmtNum(k.quota_tokens_per_day) }} tok/日</span>
<span v-if="k.quota_tokens_per_day && k.quota_requests_per_day"> · </span>
<span v-if="k.quota_requests_per_day">{{ fmtNum(k.quota_requests_per_day) }} req/日</span>
</template>
<span v-else class="text-muted">不限</span>
</td>
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ fmtTime(k.expires_at) }}</td>
<td class="px-4 py-2.5"> <td class="px-4 py-2.5">
<Badge :variant="k.status === 'active' ? 'ok' : 'neutral'">{{ k.status }}</Badge> <Badge :variant="k.status === 'active' ? 'ok' : 'neutral'">{{ k.status }}</Badge>
</td> </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.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"> <td class="px-4 py-2.5 text-right">
<div class="flex justify-end gap-2.5"> <div class="flex justify-end gap-2">
<button class="text-xs text-muted hover:text-ink" @click="openEdit(k)">编辑</button> <button
class="inline-flex items-center gap-1 rounded-md border border-accent/40 bg-accent-soft px-2 py-1 text-xs font-medium text-accent transition hover:border-accent"
@click="openEdit(k)"
>
<PhNotePencil :size="13" weight="bold" />
编辑
</button>
<button <button
v-if="k.status === 'active'" v-if="k.status === 'active'"
class="text-xs text-muted hover:text-err" class="text-xs text-muted transition hover:text-err"
@click="revoke(k)" @click="remove(k)"
> >
吊销 删除
</button> </button>
</div> </div>
</td> </td>
</tr> </tr>
<tr v-if="keys.length === 0"> <tr v-if="keys.length === 0">
<td colspan="6" class="px-4 py-10 text-center text-sm text-muted"> <td colspan="7" class="px-4 py-10 text-center text-sm text-muted">
还没有密钥,点击右上角「新建密钥」 还没有密钥,点击右上角「新建密钥」
</td> </td>
</tr> </tr>
@@ -197,18 +258,16 @@ onMounted(load)
<Modal :open="createOpen" title="新建密钥" @close="createOpen = false"> <Modal :open="createOpen" title="新建密钥" @close="createOpen = false">
<div class="space-y-4"> <div class="space-y-4">
<Input v-model="keyName" label="密钥名称" placeholder="例如 dev / prod" @keyup.enter="createKey" /> <Input v-model="keyName" label="密钥名称" placeholder="例如 dev / prod" @keyup.enter="createKey" />
<div> <div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button class="text-xs text-muted transition hover:text-ink" @click="advOpen = !advOpen"> <Input v-model="createAdv.quota_tokens_per_day" label="每日 Token 上限(可选)" placeholder="如 100000" />
{{ advOpen ? '收起' : '展开' }}高级选项(配额 / 白名单) <Input v-model="createAdv.quota_requests_per_day" label="每日请求上限(可选)" placeholder="如 1000" />
</button>
<div v-if="advOpen" class="mt-3 space-y-4">
<div class="grid grid-cols-1 sm: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>
<Input
v-model="createAdv.allowed_models"
label="模型白名单(可选)"
placeholder="逗号分隔,如 gpt-4o, claude-sonnet-5"
hint="留空则不限模型"
/>
</div> </div>
<template #footer> <template #footer>
<Button variant="ghost" @click="createOpen = false">取消</Button> <Button variant="ghost" @click="createOpen = false">取消</Button>
@@ -232,7 +291,7 @@ onMounted(load)
class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 text-sm text-ink outline-none focus:border-accent" 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="active">active(启用)</option>
<option value="revoked">revoked(吊销)</option> <option value="revoked">revoked(停用)</option>
</select> </select>
</label> </label>
</div> </div>
@@ -246,11 +305,11 @@ onMounted(load)
<Modal :open="!!created" title="密钥已创建" @close="created = null"> <Modal :open="!!created" title="密钥已创建" @close="created = null">
<div class="space-y-4"> <div class="space-y-4">
<p class="text-xs text-muted">请复制并妥善保存,关闭后不再显示。</p> <p class="text-xs text-muted">请复制并妥善保存,关闭后不再显示。</p>
<div class="flex items-center gap-2"> <div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<code class="mono-num flex-1 truncate rounded-md border border-accent bg-surface px-3 py-2 text-xs text-accent"> <code class="mono-num flex-1 truncate rounded-md border border-accent bg-surface px-3 py-2 text-xs text-accent">
{{ created?.key }} {{ created?.key }}
</code> </code>
<Button size="sm" @click="copyKey"> <Button size="sm" class="shrink-0" @click="copyKey">
<PhCheck v-if="copied" :size="14" /> <PhCheck v-if="copied" :size="14" />
<PhCopy v-else :size="14" /> <PhCopy v-else :size="14" />
{{ copied ? '已复制' : '复制' }} {{ copied ? '已复制' : '复制' }}