AirGate/frontend/src/views/ark/ArkKeysView.vue
seaislee1209 7feb007f57 feat: rewrite API Key management as manual entry mode
- New ArkApiKey model (encrypted storage, bound to user+project)
- Admin enters API Key from Volcengine console into AirGate
- Sub-accounts can only view their own keys
- Reveal endpoint decrypts key on demand with audit log
- Updated research report: documented Ark API limitation (CreateApiKey
  doesn't return plaintext) and manual entry solution

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 01:25:12 +08:00

240 lines
8.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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 style="max-width: 1400px; margin: 0 auto;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<h2 style="margin: 0;">API Key 管理</h2>
<el-button type="primary" @click="openCreate">录入 API Key</el-button>
</div>
<!-- Filters -->
<div style="margin-bottom: 16px; display: flex; gap: 12px; align-items: center;">
<el-select v-model="filterUser" placeholder="按子账号筛选" clearable filterable
style="width: 200px;" @change="loadKeys">
<el-option v-for="u in users" :key="u.id" :label="u.display_name || u.username"
:value="u.id" />
</el-select>
<el-select v-model="filterProject" placeholder="按项目筛选" clearable filterable
style="width: 200px;" @change="loadKeys">
<el-option v-for="p in allProjects" :key="p" :label="p" :value="p" />
</el-select>
<el-button @click="loadKeys" text><el-icon><Refresh /></el-icon></el-button>
</div>
<!-- Keys table -->
<el-table :data="keys" stripe v-loading="loading" style="width: 100%;"
empty-text="暂无 API Key">
<el-table-column label="子账号" min-width="120">
<template #default="{ row }">
{{ row.iam_display_name || row.iam_username }}
</template>
</el-table-column>
<el-table-column prop="project_name" label="所属项目" min-width="150" />
<el-table-column prop="key_name" label="名称/用途" min-width="160" />
<el-table-column label="API Key" min-width="200">
<template #default="{ row }">
<code style="color: #999;">{{ row.api_key_hint }}</code>
<el-button size="small" text type="primary" @click="handleReveal(row)"
style="margin-left: 4px;">查看</el-button>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'danger'" size="small">
{{ row.status === 'active' ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
<el-table-column label="录入时间" min-width="160">
<template #default="{ row }">
{{ new Date(row.created_at).toLocaleString('zh-CN') }}
</template>
</el-table-column>
<el-table-column label="操作" min-width="180">
<template #default="{ row }">
<el-button v-if="row.status === 'active'" size="small" text type="warning"
@click="handleToggle(row, 'disabled')">停用</el-button>
<el-button v-else size="small" text type="success"
@click="handleToggle(row, 'active')">启用</el-button>
<el-button size="small" text type="danger"
@click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- Create (manual entry) dialog -->
<el-dialog v-model="showCreate" title="录入 API Key" width="90%" style="max-width: 600px;">
<el-alert type="info" :closable="false" show-icon style="margin-bottom: 16px;">
请先在火山控制台创建 API Key然后将完整 Key 粘贴到下方录入。
</el-alert>
<el-form label-width="100px">
<el-form-item label="子账号" required>
<el-select v-model="createForm.iam_user_id" placeholder="选择子账号" filterable style="width: 100%;">
<el-option v-for="u in users" :key="u.id"
:label="`${u.username} (${u.display_name || '-'})`" :value="u.id" />
</el-select>
</el-form-item>
<el-form-item label="所属项目" required>
<el-select v-model="createForm.project_name" placeholder="选择项目" filterable style="width: 100%;">
<el-option v-for="p in volcProjects" :key="p.name"
:label="p.display_name || p.name" :value="p.name" />
</el-select>
</el-form-item>
<el-form-item label="名称/用途" required>
<el-input v-model="createForm.key_name" placeholder="如zyc-seedance-production" />
</el-form-item>
<el-form-item label="API Key" required>
<el-input v-model="createForm.api_key" type="textarea" :rows="2"
placeholder="粘贴完整的 API Key" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="createForm.remark" placeholder="选填" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreate = false">取消</el-button>
<el-button type="primary" @click="handleCreate" :loading="creating">录入</el-button>
</template>
</el-dialog>
<!-- Reveal key dialog -->
<el-dialog v-model="showReveal" title="查看 API Key" width="90%" style="max-width: 600px;">
<div style="margin-bottom: 8px; color: #606266;">
<strong>{{ revealData.key_name }}</strong> · {{ revealData.project_name }}
</div>
<div style="background: #f5f7fa; padding: 16px; border-radius: 8px; word-break: break-all;">
<code style="font-size: 14px; color: #409eff;">{{ revealData.api_key }}</code>
</div>
<template #footer>
<el-button type="primary" @click="copyRevealKey">复制</el-button>
<el-button @click="showReveal = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '../../api'
const keys = ref([])
const loading = ref(false)
const users = ref([])
const volcProjects = ref([])
const filterUser = ref('')
const filterProject = ref('')
const allProjects = computed(() => {
const set = new Set(keys.value.map(k => k.project_name))
return [...set].sort()
})
const showCreate = ref(false)
const createForm = ref({ iam_user_id: '', project_name: '', key_name: '', api_key: '', remark: '' })
const creating = ref(false)
const showReveal = ref(false)
const revealData = ref({ api_key: '', key_name: '', project_name: '' })
async function loadKeys() {
loading.value = true
try {
const params = {}
if (filterUser.value) params.iam_user_id = filterUser.value
if (filterProject.value) params.project_name = filterProject.value
const { data } = await api.get('/api/v1/ark-keys/', { params })
keys.value = data
} catch (e) {
keys.value = []
} finally {
loading.value = false
}
}
async function loadUsers() {
try {
const { data } = await api.get('/api/v1/iam-users/')
users.value = data
} catch { users.value = [] }
}
async function loadVolcProjects() {
try {
const { data } = await api.get('/api/v1/projects/')
volcProjects.value = data
} catch { volcProjects.value = [] }
}
function openCreate() {
createForm.value = { iam_user_id: '', project_name: '', key_name: '', api_key: '', remark: '' }
showCreate.value = true
loadVolcProjects()
}
async function handleCreate() {
const f = createForm.value
if (!f.iam_user_id || !f.project_name || !f.key_name || !f.api_key) {
ElMessage.warning('请填写完整')
return
}
creating.value = true
try {
const { data } = await api.post('/api/v1/ark-keys/create/', f)
ElMessage.success(data.message)
showCreate.value = false
await loadKeys()
} catch (e) {
ElMessage.error(e.response?.data?.message || '录入失败')
} finally {
creating.value = false
}
}
async function handleReveal(row) {
try {
const { data } = await api.get(`/api/v1/ark-keys/${row.id}/reveal/`)
revealData.value = data
showReveal.value = true
} catch (e) {
ElMessage.error('获取 Key 失败')
}
}
async function copyRevealKey() {
try {
await navigator.clipboard.writeText(revealData.value.api_key)
ElMessage.success('已复制')
} catch {
ElMessage.error('复制失败,请手动复制')
}
}
async function handleToggle(row, newStatus) {
const action = newStatus === 'active' ? '启用' : '停用'
try {
await api.put(`/api/v1/ark-keys/${row.id}/`, { status: newStatus })
ElMessage.success(`${action}`)
await loadKeys()
} catch (e) {
ElMessage.error(`${action}失败`)
}
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除 "${row.key_name}" 吗?`, '确认删除', { type: 'error' })
try {
await api.delete(`/api/v1/ark-keys/${row.id}/delete/`)
ElMessage.success('已删除')
await loadKeys()
} catch (e) {
ElMessage.error('删除失败')
}
}
onMounted(() => {
loadKeys()
loadUsers()
})
</script>