Implement compression quota refunds and admin manual subscription
This commit is contained in:
236
frontend/src/pages/dashboard/DashboardApiKeysPage.vue
Normal file
236
frontend/src/pages/dashboard/DashboardApiKeysPage.vue
Normal file
@@ -0,0 +1,236 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
createApiKey,
|
||||
disableApiKey,
|
||||
listApiKeys,
|
||||
rotateApiKey,
|
||||
type ApiKeyView,
|
||||
type CreateApiKeyResponse,
|
||||
} from '@/services/api'
|
||||
import { ApiError } from '@/services/http'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const apiKeys = ref<ApiKeyView[]>([])
|
||||
|
||||
const name = ref('')
|
||||
const creating = ref(false)
|
||||
const created = ref<CreateApiKeyResponse | null>(null)
|
||||
const createdContext = ref<'create' | 'rotate' | null>(null)
|
||||
const copyStatus = ref<string | null>(null)
|
||||
const rotating = ref<string | null>(null)
|
||||
|
||||
async function refresh() {
|
||||
if (!auth.token) return
|
||||
error.value = null
|
||||
try {
|
||||
const resp = await listApiKeys(auth.token)
|
||||
apiKeys.value = resp.api_keys
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
error.value = `[${err.code}] ${err.message}`
|
||||
} else {
|
||||
error.value = '加载失败,请稍后再试'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refresh()
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
async function create() {
|
||||
if (!auth.token) return
|
||||
creating.value = true
|
||||
error.value = null
|
||||
copyStatus.value = null
|
||||
try {
|
||||
const resp = await createApiKey(auth.token, name.value.trim())
|
||||
created.value = resp
|
||||
createdContext.value = 'create'
|
||||
name.value = ''
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
error.value = `[${err.code}] ${err.message}`
|
||||
} else {
|
||||
error.value = '创建失败,请稍后再试'
|
||||
}
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function disable(keyId: string) {
|
||||
if (!auth.token) return
|
||||
if (!confirm('确定要禁用这个 Key 吗?')) return
|
||||
try {
|
||||
await disableApiKey(auth.token, keyId)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
error.value = `[${err.code}] ${err.message}`
|
||||
} else {
|
||||
error.value = '操作失败,请稍后再试'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function rotate(keyId: string) {
|
||||
if (!auth.token) return
|
||||
if (!confirm('确定要轮换这个 Key 吗?旧 Key 将立即失效。')) return
|
||||
rotating.value = keyId
|
||||
error.value = null
|
||||
copyStatus.value = null
|
||||
try {
|
||||
const resp = await rotateApiKey(auth.token, keyId)
|
||||
created.value = resp
|
||||
createdContext.value = 'rotate'
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
error.value = `[${err.code}] ${err.message}`
|
||||
} else {
|
||||
error.value = '操作失败,请稍后再试'
|
||||
}
|
||||
} finally {
|
||||
rotating.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function copyKey() {
|
||||
if (!created.value?.key) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(created.value.key)
|
||||
copyStatus.value = '已复制'
|
||||
} catch {
|
||||
copyStatus.value = '复制失败'
|
||||
}
|
||||
}
|
||||
|
||||
function clearCreated() {
|
||||
created.value = null
|
||||
createdContext.value = null
|
||||
copyStatus.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-1">
|
||||
<h1 class="text-xl font-semibold text-slate-900">API Keys</h1>
|
||||
<p class="text-sm text-slate-600">仅 Pro/Business 可创建;创建时只展示一次完整 Key。</p>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="rounded-lg border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-if="created" class="rounded-xl border border-emerald-200 bg-emerald-50 p-5 text-sm text-emerald-950">
|
||||
<div class="font-medium">{{ createdContext === 'rotate' ? '已轮换' : '已创建' }}:{{ created.name }}</div>
|
||||
<div class="mt-2 text-xs text-emerald-900">请保存此 Key,它只会显示一次:</div>
|
||||
<pre class="mt-2 overflow-auto rounded-lg bg-slate-950 p-3 text-xs text-slate-100"><code>{{ created.key }}</code></pre>
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-700"
|
||||
@click="copyKey"
|
||||
>
|
||||
一键复制
|
||||
</button>
|
||||
<div v-if="copyStatus" class="text-xs text-emerald-900">{{ copyStatus }}</div>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-auto rounded-md border border-emerald-200 bg-white px-3 py-1.5 text-sm text-emerald-800 hover:bg-emerald-100"
|
||||
@click="clearCreated"
|
||||
>
|
||||
我已保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-5">
|
||||
<div class="text-sm font-medium text-slate-900">创建 API Key</div>
|
||||
<div class="mt-3 flex flex-col gap-2 sm:flex-row sm:items-end">
|
||||
<label class="flex-1 space-y-1">
|
||||
<div class="text-xs font-medium text-slate-600">名称</div>
|
||||
<input
|
||||
v-model="name"
|
||||
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
|
||||
placeholder="例如 CI / prod / local"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
|
||||
:disabled="creating || !name.trim()"
|
||||
@click="create"
|
||||
>
|
||||
{{ creating ? '创建中…' : '创建' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm font-medium text-slate-900">已有 Keys</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50"
|
||||
@click="refresh"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="mt-3 text-sm text-slate-600">加载中…</div>
|
||||
<div v-else-if="apiKeys.length === 0" class="mt-3 text-sm text-slate-600">暂无</div>
|
||||
<div v-else class="mt-4 space-y-2">
|
||||
<div
|
||||
v-for="k in apiKeys"
|
||||
:key="k.id"
|
||||
class="flex flex-col gap-2 rounded-lg border border-slate-200 p-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-medium text-slate-900">{{ k.name }}</div>
|
||||
<div class="text-xs text-slate-500">
|
||||
{{ k.key_prefix }} · rate: {{ k.rate_limit }}/min
|
||||
<span v-if="k.last_used_at"> · 上次使用 {{ new Date(k.last_used_at).toLocaleString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="rounded-full px-2 py-1 text-xs"
|
||||
:class="k.is_active ? 'bg-emerald-50 text-emerald-700' : 'bg-slate-100 text-slate-600'"
|
||||
>
|
||||
{{ k.is_active ? '启用' : '禁用' }}
|
||||
</span>
|
||||
<button
|
||||
v-if="k.is_active"
|
||||
type="button"
|
||||
class="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-700 hover:bg-slate-50"
|
||||
:disabled="rotating === k.id"
|
||||
@click="rotate(k.id)"
|
||||
>
|
||||
{{ rotating === k.id ? '轮换中…' : '轮换' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="k.is_active"
|
||||
type="button"
|
||||
class="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-700 hover:bg-slate-50"
|
||||
@click="disable(k.id)"
|
||||
>
|
||||
禁用
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user