Files
tiantian-system/app/pages/developer/apikeys.vue
2026-04-08 17:10:58 +08:00

676 lines
18 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="dev-page">
<!-- 页面头部 -->
<div class="page-header">
<div>
<h2 class="page-title">🔑 API Key 管理</h2>
<p class="page-desc">创建和管理你的 API Key用于调用平台 REST API SDK</p>
</div>
<a-button type="primary" @click="showCreateModal = true">
+ 创建 API Key
</a-button>
</div>
<div class="page-body">
<!-- 使用提示 -->
<a-alert
class="mb-5"
show-icon
type="info"
message="安全提示"
description="API Key 具有完整的账号访问权限,请勿在前端代码中明文使用,建议通过服务端调用 API。创建后请妥善保管丢失后需重新生成。"
/>
<!-- API Key 列表 -->
<div class="panel">
<div class="panel-header">
<span class="panel-title">我的 API Key</span>
<a-tag color="blue">{{ keyList.length }} </a-tag>
</div>
<a-spin :spinning="loading">
<div v-if="keyList.length === 0 && !loading" class="empty-state">
<div class="empty-icon">🔑</div>
<div class="empty-title">还没有 API Key</div>
<div class="empty-desc">创建第一个 API Key开始调用平台接口</div>
<a-button type="primary" class="mt-4" @click="showCreateModal = true">创建 API Key</a-button>
</div>
<div v-else-if="keyList.length > 0" class="key-list">
<div v-for="key in keyList" :key="key.id" class="key-item">
<div class="key-item-main">
<div class="key-name-row">
<span class="key-name">{{ key.name }}</span>
<a-tag :color="key.status === 'active' ? 'green' : 'default'">
{{ key.status === 'active' ? '正常' : '已禁用' }}
</a-tag>
</div>
<div class="key-value-row">
<code class="key-value">{{ key.visible ? key.value : maskKey(key.value) }}</code>
<a-tooltip title="显示/隐藏">
<a-button
type="text"
size="small"
class="key-action-btn"
@click="toggleVisible(key)"
>{{ key.visible ? '🙈' : '👁️' }}</a-button>
</a-tooltip>
<a-tooltip title="复制">
<a-button
type="text"
size="small"
class="key-action-btn"
@click="copyKey(key.value)"
>📋</a-button>
</a-tooltip>
</div>
<div class="key-meta">
<span>📅 创建于 {{ key.createdAt }}</span>
<span> 最近使用{{ key.lastUsed }}</span>
<span v-if="key.expireAt">🕐 到期{{ key.expireAt }}</span>
<span v-else> 永不过期</span>
</div>
</div>
<div class="key-item-actions">
<a-button
size="small"
:type="key.status === 'active' ? 'default' : 'primary'"
@click="toggleStatus(key)"
>
{{ key.status === 'active' ? '禁用' : '启用' }}
</a-button>
<a-popconfirm
title="确认删除该 API Key此操作不可撤销。"
ok-text="删除"
ok-type="danger"
cancel-text="取消"
@confirm="deleteKey(key.id)"
>
<a-button size="small" danger>删除</a-button>
</a-popconfirm>
</div>
</div>
</div>
</a-spin>
</div>
<!-- API 接入示例 -->
<div class="panel mt-4">
<div class="panel-header">
<span class="panel-title">📘 接入示例</span>
<a-radio-group v-model:value="activeTab" size="small" button-style="solid">
<a-radio-button v-for="tab in codeTabs" :key="tab.key" :value="tab.key">{{ tab.label }}</a-radio-button>
</a-radio-group>
</div>
<div class="code-example">
<div class="code-toolbar">
<span class="code-lang">{{ currentTab?.lang }}</span>
<a-button type="text" size="small" @click="copyCode">📋 复制代码</a-button>
</div>
<pre class="code-pre"><code>{{ currentTab?.code }}</code></pre>
</div>
</div>
<!-- 速率限制说明 -->
<div class="panel mt-4">
<div class="panel-header">
<span class="panel-title"> 速率限制</span>
</div>
<div class="rate-grid">
<div v-for="rate in rateLimits" :key="rate.plan" class="rate-card" :class="rate.highlight ? 'highlight' : ''">
<div class="rate-plan">{{ rate.plan }}</div>
<div class="rate-value">{{ rate.rps }} <span class="rate-unit">/</span></div>
<div class="rate-daily">日限 {{ rate.daily }}</div>
</div>
</div>
</div>
</div>
<!-- 创建 API Key 弹窗 -->
<a-modal
v-model:open="showCreateModal"
title="创建 API Key"
ok-text="创建"
cancel-text="取消"
:confirm-loading="creating"
@ok="handleCreate"
>
<a-form layout="vertical" class="mt-2">
<a-form-item label="名称" required>
<a-input
v-model:value="createForm.name"
placeholder="例如:生产环境、测试 Key..."
:maxlength="50"
show-count
/>
</a-form-item>
<a-form-item label="有效期">
<a-radio-group v-model:value="createForm.expire">
<a-radio value="">永不过期</a-radio>
<a-radio value="30d">30 </a-radio>
<a-radio value="90d">90 </a-radio>
<a-radio value="1y">1 </a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="权限范围">
<a-checkbox-group v-model:value="createForm.scopes">
<a-checkbox value="read">读取GET</a-checkbox>
<a-checkbox value="write">写入POST/PUT</a-checkbox>
<a-checkbox value="delete">删除DELETE</a-checkbox>
<a-checkbox value="ai">AI 接口</a-checkbox>
</a-checkbox-group>
</a-form-item>
<a-form-item label="备注">
<a-textarea
v-model:value="createForm.remark"
:rows="2"
placeholder="可选,用途说明"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import {
pageAppApiKey,
createAppApiKey,
updateAppApiKeyStatus,
removeAppApiKey,
getApiRateLimits
} from '@/api/app/apikey'
definePageMeta({ layout: 'developer' })
useHead({ title: 'API Key 管理 - 开发者中心' })
const showCreateModal = ref(false)
const activeTab = ref('ts')
const loading = ref(false)
const creating = ref(false)
const createForm = reactive({
name: '',
expire: '',
scopes: ['read', 'write'],
remark: '',
})
// API Key 列表
const keyList = ref<Array<{
id: string
name: string
value: string
status: 'active' | 'disabled' | 'expired'
createdAt: string
lastUsed: string
expireAt: string
visible: boolean
usageCount?: number
scopes?: string[]
remark?: string
}>>([])
// 速率限制信息
const rateLimits = ref<Array<{
plan: string
rps: string
daily: string
dailyLimit?: number
usedToday?: number
remainingToday?: number
highlight: boolean
}>>([])
// 加载API Key列表
async function loadApiKeys() {
loading.value = true
try {
const result = await pageAppApiKey({ page: 1, limit: 100 })
console.log('API Key 返回数据:', result)
keyList.value = (result.list || []).map((key) => ({
id: String(key.id),
name: key.name,
value: key.apiKey || key.keyPrefix,
status: key.status === 0 ? 'active' : 'disabled',
createdAt: formatDate(key.createTime),
lastUsed: key.lastUsedAt ? formatRelativeTime(key.lastUsedAt) : '从未使用',
expireAt: key.expireTime ? formatDate(key.expireTime) : '',
visible: false,
usageCount: key.usageCount,
scopes: key.scopes ? (typeof key.scopes === 'string' ? JSON.parse(key.scopes) : key.scopes) : [],
remark: key.remark
}))
} catch (error: any) {
console.error('加载API Key列表失败:', error)
message.error(error.message || '加载API Key列表失败请稍后重试')
} finally {
loading.value = false
}
}
// 加载速率限制信息
async function loadRateLimits() {
try {
const rateData = await getApiRateLimits()
// 更新默认的速率限制显示
rateLimits.value = [
{ plan: '免费版', rps: '5', daily: '1,000 次', highlight: rateData.plan === '免费版' },
{ plan: '基础版', rps: '20', daily: '10,000 次', highlight: rateData.plan === '基础版' },
{ plan: '专业版', rps: '100', daily: '100,000 次', highlight: rateData.plan === '专业版' },
{ plan: '企业版', rps: '自定义', daily: '不限', highlight: rateData.plan === '企业版' },
]
// 如果API返回了实际数据更新当前套餐
const currentPlanIndex = rateLimits.value.findIndex(r => r.plan.includes(rateData.plan))
if (currentPlanIndex !== -1) {
rateLimits.value[currentPlanIndex] = {
...rateLimits.value[currentPlanIndex],
dailyLimit: rateData.dailyLimit,
usedToday: rateData.usedToday,
remainingToday: rateData.remainingToday,
daily: `${(rateData.usedToday || 0).toLocaleString()} / ${(rateData.dailyLimit || 0).toLocaleString()}`,
highlight: true
}
}
} catch (error) {
console.error('加载速率限制失败:', error)
}
}
function maskKey(val: string) {
if (!val) return ''
return val.slice(0, 8) + '••••••••••••••••' + val.slice(-4)
}
function toggleVisible(key: any) {
key.visible = !key.visible
}
async function copyKey(val: string) {
try {
await navigator.clipboard.writeText(val)
message.success('已复制到剪贴板')
} catch {
message.error('复制失败,请手动复制')
}
}
// 切换API Key状态
async function toggleStatus(key: any) {
try {
const newStatus = key.status === 'active' ? 1 : 0 // 后端: 0=正常, 1=禁用
await updateAppApiKeyStatus(Number(key.id), newStatus)
key.status = newStatus === 0 ? 'active' : 'disabled'
message.success(key.status === 'active' ? '已启用' : '已禁用')
} catch (error: any) {
console.error('更新API Key状态失败:', error)
message.error(error.message || '操作失败,请稍后重试')
}
}
// 删除API Key
async function deleteKey(id: string) {
try {
await removeAppApiKey(Number(id))
await loadApiKeys()
message.success('API Key 已删除')
} catch (error: any) {
console.error('删除API Key失败:', error)
message.error(error.message || '删除失败,请稍后重试')
}
}
// 创建API Key
async function handleCreate() {
if (!createForm.name.trim()) {
message.error('请填写 API Key 名称')
return
}
// 将过期选项转换为过期时间
let expireTime: string | undefined
if (createForm.expire) {
const now = new Date()
switch (createForm.expire) {
case '30d': now.setDate(now.getDate() + 30); break
case '90d': now.setDate(now.getDate() + 90); break
case '1y': now.setFullYear(now.getFullYear() + 1); break
}
expireTime = now.toISOString()
}
creating.value = true
try {
const result = await createAppApiKey({
name: createForm.name.trim(),
expireTime,
scopes: JSON.stringify(createForm.scopes),
remark: createForm.remark.trim() || undefined
})
message.success('API Key 创建成功')
showCreateModal.value = false
// 重置表单
Object.assign(createForm, { name: '', expire: '', scopes: ['read', 'write'], remark: '' })
// 重新加载列表
await loadApiKeys()
// 显示新创建的Key提示
message.info(`新Key前缀: ${result.keyPrefix || 'sk-'}...,请在列表中查看完整密钥`)
} catch (error: any) {
console.error('创建API Key失败:', error)
message.error(error.message || '创建失败,请稍后重试')
} finally {
creating.value = false
}
}
// 工具函数:格式化日期
function formatDate(dateStr: string) {
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN')
}
// 工具函数:格式化相对时间
function formatRelativeTime(dateStr: string) {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
if (diffMins < 1) return '刚刚'
if (diffMins < 60) return `${diffMins}分钟前`
if (diffHours < 24) return `${diffHours}小时前`
if (diffDays < 30) return `${diffDays}天前`
return formatDate(dateStr)
}
const codeTabs = [
{
key: 'ts',
label: 'TypeScript',
lang: 'TypeScript',
code: `import { WebsopyClient } from '@websopy/sdk'
const client = new WebsopyClient({
apiKey: 'sk-xxxxxxxxxxxxxxxx' // 替换为你的 API Key
})
const reply = await client.agent.chat({
message: '你好,帮我查询今日数据'
})
console.log(reply.content)`,
},
{
key: 'python',
label: 'Python',
lang: 'Python',
code: `from websopy import WebsopyClient
client = WebsopyClient(api_key="sk-xxxxxxxxxxxxxxxx")
reply = client.agent.chat(
message="你好,帮我查询今日数据"
)
print(reply.content)`,
},
{
key: 'curl',
label: 'cURL',
lang: 'Shell',
code: `curl -X POST https://api.websopy.com/v1/agent/chat \\
-H "Authorization: Bearer sk-xxxxxxxxxxxxxxxx" \\
-H "Content-Type: application/json" \\
-d '{"message": "你好,帮我查询今日数据"}'`,
},
]
const currentTab = computed(() => codeTabs.find(t => t.key === activeTab.value))
async function copyCode() {
const code = currentTab.value?.code || ''
try {
await navigator.clipboard.writeText(code)
message.success('代码已复制')
} catch {
message.error('复制失败')
}
}
// 页面加载时初始化
onMounted(() => {
loadApiKeys()
loadRateLimits()
})
</script>
<style scoped>
.dev-page {
min-height: 100%;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 24px 28px 16px;
border-bottom: 1px solid #f0f0f0;
gap: 16px;
}
.page-title {
font-size: 20px;
font-weight: 700;
color: rgba(0, 0, 0, 0.88);
margin: 0 0 4px;
}
.page-desc {
font-size: 14px;
color: rgba(0, 0, 0, 0.45);
margin: 0;
}
.page-body {
padding: 20px 24px 28px;
}
.panel {
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 12px;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid #f5f5f5;
}
.panel-title {
font-size: 14px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 48px 24px;
text-align: center;
}
.empty-icon { font-size: 48px; margin-bottom: 12px; }
.empty-title { font-size: 16px; font-weight: 600; color: rgba(0, 0, 0, 0.7); }
.empty-desc { font-size: 13px; color: rgba(0, 0, 0, 0.4); margin-top: 6px; }
/* Key 列表 */
.key-list {
padding: 8px 0;
}
.key-item {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 20px;
border-bottom: 1px solid #f9f9f9;
transition: background 0.15s;
}
.key-item:hover { background: #fafafa; }
.key-item:last-child { border-bottom: none; }
.key-item-main { flex: 1; min-width: 0; }
.key-name-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.key-name {
font-size: 14px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
}
.key-value-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.key-value {
font-family: 'Fira Code', 'JetBrains Mono', monospace;
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
background: #f5f5f5;
padding: 3px 8px;
border-radius: 5px;
max-width: 420px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.key-action-btn {
padding: 0 5px;
height: 24px;
}
.key-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 12px;
color: rgba(0, 0, 0, 0.38);
}
.key-item-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
/* 代码示例 */
.code-example {
background: #0f0f23;
}
.code-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.code-lang {
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
font-family: monospace;
}
.code-toolbar :deep(.ant-btn) {
color: rgba(255, 255, 255, 0.5);
}
.code-toolbar :deep(.ant-btn:hover) {
color: rgba(255, 255, 255, 0.9);
}
.code-pre {
padding: 16px 20px;
margin: 0;
font-family: 'Fira Code', 'JetBrains Mono', monospace;
font-size: 13px;
line-height: 1.7;
color: #e2e8f0;
overflow-x: auto;
}
/* 速率限制 */
.rate-grid {
padding: 16px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
}
.rate-card {
padding: 16px;
border-radius: 10px;
border: 1px solid #f0f0f0;
background: #fafafa;
text-align: center;
transition: all 0.15s;
}
.rate-card.highlight {
background: linear-gradient(135deg, #f0f0ff 0%, #f5f3ff 100%);
border-color: #c4b5fd;
}
.rate-plan {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-bottom: 6px;
}
.rate-value {
font-size: 22px;
font-weight: 700;
color: rgba(0, 0, 0, 0.85);
line-height: 1.2;
}
.rate-unit {
font-size: 12px;
font-weight: normal;
color: rgba(0, 0, 0, 0.45);
}
.rate-daily {
font-size: 12px;
color: rgba(0, 0, 0, 0.4);
margin-top: 4px;
}
</style>