前端: API Keys 移动端卡片布局 + 复制兼容
- 移动端(md 以下)以卡片列表替代横向滚动表格, 展示配额/过期/最近使用 - 桌面表格补充配额/过期列, 移除创建时间列 - copyText 支持 Clipboard API + execCommand 回退(HTTP 环境可用) - 删除文案由"吊销"改为"删除(不可恢复)" Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
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 { 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 Input from '@/components/ui/Input.vue'
|
||||
import Modal from '@/components/ui/Modal.vue'
|
||||
@@ -15,7 +16,6 @@ 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)
|
||||
|
||||
@@ -45,6 +45,13 @@ function parseModels(s: string): string[] | 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() {
|
||||
try {
|
||||
const { data } = await http.get('/keys')
|
||||
@@ -70,7 +77,6 @@ async function createKey() {
|
||||
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))
|
||||
@@ -113,11 +119,11 @@ async function saveEdit() {
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(k: ApiKey) {
|
||||
if (!confirm(`吊销密钥 ${k.name}?吊销后立即失效。`)) return
|
||||
async function remove(k: ApiKey) {
|
||||
if (!confirm(`删除密钥 ${k.name}?删除后立即失效且不可恢复。`)) return
|
||||
try {
|
||||
await http.delete(`/keys/${k.id}`)
|
||||
toast.ok('密钥已吊销')
|
||||
toast.ok('密钥已删除')
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
@@ -126,11 +132,11 @@ async function revoke(k: ApiKey) {
|
||||
|
||||
async function copyKey() {
|
||||
if (!created.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(created.value.key)
|
||||
const ok = await copyText(created.value.key)
|
||||
if (ok) {
|
||||
copied.value = true
|
||||
setTimeout(() => (copied.value = false), 1500)
|
||||
} catch {
|
||||
} else {
|
||||
toast.err('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
@@ -149,15 +155,56 @@ onMounted(load)
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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" />
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -165,26 +212,40 @@ onMounted(load)
|
||||
<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 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">
|
||||
<Badge :variant="k.status === 'active' ? 'ok' : 'neutral'">{{ k.status }}</Badge>
|
||||
</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">
|
||||
<div class="flex justify-end gap-2.5">
|
||||
<button class="text-xs text-muted hover:text-ink" @click="openEdit(k)">编辑</button>
|
||||
<div class="flex justify-end gap-2">
|
||||
<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
|
||||
v-if="k.status === 'active'"
|
||||
class="text-xs text-muted hover:text-err"
|
||||
@click="revoke(k)"
|
||||
class="text-xs text-muted transition hover:text-err"
|
||||
@click="remove(k)"
|
||||
>
|
||||
吊销
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<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>
|
||||
</tr>
|
||||
@@ -197,18 +258,16 @@ onMounted(load)
|
||||
<Modal :open="createOpen" title="新建密钥" @close="createOpen = false">
|
||||
<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-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 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"
|
||||
hint="留空则不限模型"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<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"
|
||||
>
|
||||
<option value="active">active(启用)</option>
|
||||
<option value="revoked">revoked(吊销)</option>
|
||||
<option value="revoked">revoked(停用)</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
@@ -246,11 +305,11 @@ onMounted(load)
|
||||
<Modal :open="!!created" title="密钥已创建" @close="created = null">
|
||||
<div class="space-y-4">
|
||||
<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">
|
||||
{{ created?.key }}
|
||||
</code>
|
||||
<Button size="sm" @click="copyKey">
|
||||
<Button size="sm" class="shrink-0" @click="copyKey">
|
||||
<PhCheck v-if="copied" :size="14" />
|
||||
<PhCopy v-else :size="14" />
|
||||
{{ copied ? '已复制' : '复制' }}
|
||||
|
||||
Reference in New Issue
Block a user