前端: 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">
|
<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 ? '已复制' : '复制' }}
|
||||||
|
|||||||
Reference in New Issue
Block a user