- 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>
240 lines
8.7 KiB
Vue
240 lines
8.7 KiB
Vue
<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>
|