AirGate/frontend/src/views/iam/IAMUserList.vue
seaislee1209 3213d6d98a feat: complete AirGate core features + full audit fixes
Quota allocation system:
- Replace monthly budget with one-time quota allocation (prepaid model)
- Support both adding (+) and deducting (-) quota with underflow protection
- Stepped alerts at configurable percentages (e.g., 50%/80%/90%)
- Auto-disable when quota exhausted (100%), alert state resets on new allocation
- Quota allocation history with operator audit trail

IAM management:
- Create new IAM sub-accounts directly from AirGate (auto-generates API keys)
- SecretKey shown once in dialog with copy-to-clipboard
- Attach/detach IAM policies via UI (ArkFullAccess, TOSFullAccess, etc.)
- Sync existing users from Volcengine
- Project list pulled from Volcengine API for dropdown selection

Security & auth:
- API Key authentication for external systems (AirDrama integration)
- SECRET_KEY enforced in production (raises error if missing with DEBUG=False)
- APIKeyUser with proper pk/is_staff attributes for DRF compatibility

Infrastructure:
- Docker + docker-compose for backend and frontend
- Nginx reverse proxy for frontend with /api/ forwarding
- Entrypoint with auto-migrate and default admin creation
- SQLite data persisted via Docker volume at /app/data/

Bug fixes from audit:
- Fix frontend referencing non-existent fields (current_month_spending, effective_budget, budget_usage_percent)
- Fix scheduler using naive datetime.now() → timezone.now()
- Fix scheduler reading interval from settings instead of GlobalConfig DB
- Fix docker-compose SQLite volume mounting as directory
- Fix CORS origin with explicit port 80
- Remove dead config (VOLC_ACCESS_KEY/SK, MONITOR_INTERVAL from settings)
- Remove unused imports

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 15:08:33 +08:00

561 lines
22 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>
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
<h2>子账号管理</h2>
<div style="display:flex; gap:8px;">
<el-button type="success" @click="showCreate = true">
<el-icon><Plus /></el-icon> 创建子账号
</el-button>
<el-button type="primary" @click="handleSync" :loading="syncing">
<el-icon><Refresh /></el-icon> 同步已有用户
</el-button>
</div>
</div>
<el-card>
<el-table :data="users" stripe v-loading="loading" style="width: 100%;">
<el-table-column prop="username" label="用户名" width="140" />
<el-table-column prop="display_name" label="显示名" width="110" />
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : row.status === 'disabled' ? 'danger' : 'info'" size="small">
{{ row.status === 'active' ? '正常' : row.status === 'disabled' ? '已停用' : '未知' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="已划拨额度" width="120">
<template #default="{ row }">¥{{ Number(row.allocated_quota).toLocaleString() }}</template>
</el-table-column>
<el-table-column label="累计消费" width="120">
<template #default="{ row }">
<span :style="{ color: Number(row.consumed_total) > 0 ? '#e6a23c' : '' }">
¥{{ Number(row.consumed_total).toLocaleString() }}
</span>
</template>
</el-table-column>
<el-table-column label="剩余额度" width="120">
<template #default="{ row }">
<span :style="{ color: Number(row.remaining_quota) <= 0 ? '#f56c6c' : '#67c23a', fontWeight: 600 }">
¥{{ Number(row.remaining_quota).toLocaleString() }}
</span>
</template>
</el-table-column>
<el-table-column label="使用率" width="140">
<template #default="{ row }">
<el-progress v-if="Number(row.allocated_quota) > 0"
:percentage="Math.min(100, row.usage_percent || 0)"
:color="row.usage_percent >= 90 ? '#f56c6c' : row.usage_percent >= 50 ? '#e6a23c' : '#67c23a'"
:stroke-width="10"
:format="() => `${row.usage_percent || 0}%`"
/>
<span v-else style="color:#999;font-size:12px;">未划拨</span>
</template>
</el-table-column>
<el-table-column label="告警阶梯" width="130">
<template #default="{ row }">
<el-tag v-for="step in (row.effective_alert_thresholds || [])" :key="step"
:type="(row.triggered_alerts || []).includes(step) ? 'danger' : 'info'"
size="small" style="margin:1px 2px;">
{{ step }}%
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="320" fixed="right">
<template #default="{ row }">
<el-button size="small" type="warning" @click="openAllocate(row)">划拨</el-button>
<el-button size="small" @click="openConfig(row)">配置</el-button>
<el-button v-if="row.status === 'active'" size="small" type="danger" @click="handleDisable(row)">停用</el-button>
<el-button v-if="row.status === 'disabled'" size="small" type="success" @click="handleEnable(row)">恢复</el-button>
<el-button size="small" @click="openPolicies(row)">权限</el-button>
<el-button size="small" @click="openQuotaHistory(row)">记录</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- Allocate Dialog -->
<el-dialog v-model="allocateVisible" title="额度变更" width="480px">
<div style="margin-bottom:16px; padding:12px; background:#f5f7fa; border-radius:8px;">
<div>用户: <b>{{ allocateUser?.username }}</b></div>
<div>当前额度: ¥{{ Number(allocateUser?.allocated_quota || 0).toLocaleString() }}</div>
<div>已消费: ¥{{ Number(allocateUser?.consumed_total || 0).toLocaleString() }}</div>
<div>剩余: <span style="color:#67c23a;font-weight:600;">¥{{ Number(allocateUser?.remaining_quota || 0).toLocaleString() }}</span></div>
</div>
<el-form :model="allocateForm" label-width="100px">
<el-form-item label="操作类型">
<el-radio-group v-model="allocateForm.mode">
<el-radio-button value="add">追加额度</el-radio-button>
<el-radio-button value="deduct">扣减额度</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item :label="allocateForm.mode === 'add' ? '追加金额(元)' : '扣减金额(元)'">
<el-input-number v-model="allocateForm.amount" :min="1" :step="10000"
:max="allocateForm.mode === 'deduct' ? maxDeduct : undefined"
:precision="2" style="width:100%;" controls-position="right" />
<div v-if="allocateForm.mode === 'deduct'" class="form-hint">
最多可扣减: ¥{{ maxDeduct.toLocaleString() }}(不能低于已消费金额)
</div>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="allocateForm.note" :placeholder="allocateForm.mode === 'add' ? '如3月额度追加' : '如:额度调整'" />
</el-form-item>
</el-form>
<div v-if="allocateForm.amount" style="padding:8px 12px; border-radius:4px; font-size:13px;"
:style="{ background: allocateForm.mode === 'add' ? '#f0f9eb' : '#fef0f0' }">
变更后总额度: ¥{{ newTotalAfter.toLocaleString() }}
</div>
<template #footer>
<el-button @click="allocateVisible = false">取消</el-button>
<el-button :type="allocateForm.mode === 'add' ? 'primary' : 'warning'"
@click="submitAllocate" :loading="allocating">
{{ allocateForm.mode === 'add' ? '确认追加' : '确认扣减' }}
</el-button>
</template>
</el-dialog>
<!-- Config Dialog -->
<el-dialog v-model="configVisible" title="监控配置" width="560px">
<el-form :model="configForm" label-width="130px">
<el-form-item label="关联项目">
<el-select v-model="configForm.project_name" placeholder="选择火山引擎项目"
filterable clearable style="width:100%;" :loading="projectsLoading">
<el-option v-for="p in projects" :key="p.name" :label="p.display_name || p.name" :value="p.name" />
</el-select>
<div class="form-hint">
<el-button link type="primary" size="small" @click="loadProjects" :loading="projectsLoading">刷新项目列表</el-button>
</div>
</el-form-item>
<el-divider content-position="left">告警阶梯</el-divider>
<el-form-item label="告警阶梯(%)">
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
<el-tag v-for="(step, i) in configForm.alert_thresholds" :key="i"
closable @close="removeStep(i)" size="large">{{ step }}%</el-tag>
<el-input-number v-model="newStep" :min="1" :max="99" size="small" style="width:100px;" />
<el-button size="small" @click="addStep">添加</el-button>
</div>
<div class="form-hint">达到已划拨额度对应百分比时发送告警</div>
</el-form-item>
<el-divider content-position="left">开关</el-divider>
<el-form-item label="消费监控">
<el-switch v-model="configForm.monitor_enabled" />
</el-form-item>
<el-form-item label="额度用尽自动停用">
<el-switch v-model="configForm.auto_disable_enabled" />
<span class="switch-hint">{{ configForm.auto_disable_enabled ? '消费达100%额度时自动停用' : '仅通知不停用' }}</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="configVisible = false">取消</el-button>
<el-button type="primary" @click="saveConfig" :loading="saving">保存</el-button>
</template>
</el-dialog>
<!-- Quota History Dialog -->
<el-dialog v-model="historyVisible" :title="`${historyUser?.username} 额度划拨记录`" width="600px">
<el-table :data="quotaHistory" stripe v-loading="historyLoading" empty-text="暂无划拨记录">
<el-table-column prop="created_at" label="时间" width="180">
<template #default="{ row }">{{ new Date(row.created_at).toLocaleString('zh-CN') }}</template>
</el-table-column>
<el-table-column prop="amount" label="变更金额" width="120">
<template #default="{ row }">
<span :style="{ color: Number(row.amount) >= 0 ? '#67c23a' : '#f56c6c' }">
{{ Number(row.amount) >= 0 ? '+' : '' }}¥{{ Number(row.amount).toLocaleString() }}
</span>
</template>
</el-table-column>
<el-table-column prop="total_after" label="划拨后总额度" width="130">
<template #default="{ row }">¥{{ Number(row.total_after).toLocaleString() }}</template>
</el-table-column>
<el-table-column prop="note" label="备注" />
<el-table-column prop="created_by" label="操作人" width="100" />
</el-table>
</el-dialog>
<!-- Policies Dialog -->
<el-dialog v-model="policiesVisible" :title="`${policiesUser?.username} 权限策略`" width="650px">
<div style="margin-bottom:12px; display:flex; gap:8px;">
<el-select v-model="policyToAttach" placeholder="选择要附加的策略" filterable style="flex:1;">
<el-option-group label="常用策略">
<el-option value="ArkFullAccess" label="ArkFullAccess方舟/Seedance 完整权限)" />
<el-option value="ArkReadOnlyAccess" label="ArkReadOnlyAccess方舟只读" />
<el-option value="TOSFullAccess" label="TOSFullAccess对象存储完整权限" />
<el-option value="TOSReadOnlyAccess" label="TOSReadOnlyAccess对象存储只读" />
<el-option value="AccessKeySelfManageAccess" label="AccessKeySelfManageAccess自管理密钥" />
</el-option-group>
</el-select>
<el-button type="primary" @click="handleAttachPolicy" :disabled="!policyToAttach">附加</el-button>
</div>
<el-table :data="policies" stripe v-loading="policiesLoading" empty-text="暂无策略">
<el-table-column prop="PolicyName" label="策略名" />
<el-table-column prop="PolicyType" label="类型" width="80" />
<el-table-column prop="Description" label="说明" />
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button size="small" type="danger" text @click="handleDetachPolicy(row)">移除</el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
<!-- Create User Dialog -->
<el-dialog v-model="showCreate" title="创建子账号" width="520px">
<el-alert type="warning" :closable="false" style="margin-bottom:16px;"
description="创建后会在火山引擎生成 IAM 用户和 API 密钥。SecretKey 仅显示一次,请务必保存!" />
<el-form :model="createForm" label-width="110px">
<el-form-item label="用户名" required>
<el-input v-model="createForm.username" placeholder="英文字母开头,如 dept_video" />
</el-form-item>
<el-form-item label="显示名">
<el-input v-model="createForm.display_name" placeholder="如:视频部门" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="createForm.email" placeholder="选填" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="createForm.phone" placeholder="选填,如 +8618000000000" />
</el-form-item>
<el-form-item label="控制台密码">
<el-input v-model="createForm.password" type="password" show-password
placeholder="选填,填了才开通控制台登录" />
</el-form-item>
<el-form-item label="关联项目">
<el-select v-model="createForm.project_name" placeholder="选填" filterable clearable
style="width:100%;" :loading="projectsLoading">
<el-option v-for="p in projects" :key="p.name" :label="p.display_name || p.name" :value="p.name" />
</el-select>
</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>
<!-- Secret Key Display Dialog -->
<el-dialog v-model="showSecretKey" title="API 密钥已生成" width="520px" :close-on-click-modal="false">
<el-alert type="error" :closable="false" style="margin-bottom:16px;"
description="SecretAccessKey 仅此一次显示!关闭后无法再次获取,请立即复制保存。" />
<el-descriptions :column="1" border>
<el-descriptions-item label="AccessKey ID">
<code>{{ createdKeys.access_key_id }}</code>
</el-descriptions-item>
<el-descriptions-item label="SecretAccessKey">
<code style="word-break:break-all;">{{ createdKeys.secret_access_key }}</code>
</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button type="primary" @click="copyKeys">复制到剪贴板</el-button>
<el-button @click="showSecretKey = 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 users = ref([])
const loading = ref(false)
const syncing = ref(false)
// Create
const showCreate = ref(false)
const creating = ref(false)
const createForm = ref({ username: '', display_name: '', email: '', phone: '', password: '', project_name: '' })
const showSecretKey = ref(false)
const createdKeys = ref({ access_key_id: '', secret_access_key: '' })
// Allocate
const allocateVisible = ref(false)
const allocateUser = ref(null)
const allocateForm = ref({ amount: null, note: '' })
const allocating = ref(false)
// Config
const configVisible = ref(false)
const configForm = ref({})
const configUserId = ref(null)
const saving = ref(false)
const newStep = ref(null)
// Projects
const projects = ref([])
const projectsLoading = ref(false)
// Quota History
const historyVisible = ref(false)
const historyUser = ref(null)
const quotaHistory = ref([])
const historyLoading = ref(false)
async function loadUsers() {
loading.value = true
try {
const { data } = await api.get('/api/v1/iam-users/')
users.value = data
} catch (e) {
ElMessage.error('加载用户列表失败')
} finally {
loading.value = false
}
}
async function handleSync() {
syncing.value = true
try {
const { data } = await api.post('/api/v1/iam-users/sync/')
ElMessage.success(data.message)
await loadUsers()
} catch (e) {
ElMessage.error(e.response?.data?.message || '同步失败')
} finally {
syncing.value = false
}
}
async function handleDisable(row) {
await ElMessageBox.confirm(
`确定要停用子账号 "${row.username}" 吗?`, '确认停用', { type: 'warning' }
)
try {
await api.post(`/api/v1/iam-users/${row.id}/disable/`)
ElMessage.success('已停用')
await loadUsers()
} catch (e) {
ElMessage.error(e.response?.data?.message || '停用失败')
}
}
async function handleEnable(row) {
await ElMessageBox.confirm(`确定要恢复子账号 "${row.username}" 吗?`, '确认恢复')
try {
await api.post(`/api/v1/iam-users/${row.id}/enable/`)
ElMessage.success('已恢复')
await loadUsers()
} catch (e) {
ElMessage.error(e.response?.data?.message || '恢复失败')
}
}
// Policies
const policiesVisible = ref(false)
const policiesUser = ref(null)
const policies = ref([])
const policiesLoading = ref(false)
const policyToAttach = ref('')
// --- Allocate ---
const maxDeduct = computed(() => {
if (!allocateUser.value) return 0
return Math.max(0, Number(allocateUser.value.allocated_quota || 0) - Number(allocateUser.value.consumed_total || 0))
})
const newTotalAfter = computed(() => {
const current = Number(allocateUser.value?.allocated_quota || 0)
const amt = Number(allocateForm.value.amount || 0)
return allocateForm.value.mode === 'add' ? current + amt : current - amt
})
function openAllocate(row) {
allocateUser.value = row
allocateForm.value = { mode: 'add', amount: null, note: '' }
allocateVisible.value = true
}
async function submitAllocate() {
if (!allocateForm.value.amount || allocateForm.value.amount <= 0) {
ElMessage.warning('请输入金额')
return
}
allocating.value = true
const actualAmount = allocateForm.value.mode === 'deduct'
? -allocateForm.value.amount
: allocateForm.value.amount
try {
const { data } = await api.post(
`/api/v1/iam-users/${allocateUser.value.id}/allocate/`,
{ amount: actualAmount, note: allocateForm.value.note }
)
ElMessage.success(data.message)
allocateVisible.value = false
await loadUsers()
} catch (e) {
ElMessage.error(e.response?.data?.message || '操作失败')
} finally {
allocating.value = false
}
}
// --- Config ---
async function loadProjects() {
projectsLoading.value = true
try {
const { data } = await api.get('/api/v1/projects/')
projects.value = data
} catch (e) {
ElMessage.error(e.response?.data?.message || '获取项目列表失败')
} finally {
projectsLoading.value = false
}
}
function openConfig(row) {
configUserId.value = row.id
configForm.value = {
project_name: row.project_name || '',
alert_thresholds: [...(row.alert_thresholds?.length ? row.alert_thresholds : row.effective_alert_thresholds || [50, 80, 90])],
monitor_enabled: row.monitor_enabled,
auto_disable_enabled: row.auto_disable_enabled,
}
newStep.value = null
configVisible.value = true
if (projects.value.length === 0) loadProjects()
}
function addStep() {
if (!newStep.value || newStep.value < 1 || newStep.value > 99) {
ElMessage.warning('请输入 1-99 之间的百分比')
return
}
if (configForm.value.alert_thresholds.includes(newStep.value)) {
ElMessage.warning('该阈值已存在')
return
}
configForm.value.alert_thresholds.push(newStep.value)
configForm.value.alert_thresholds.sort((a, b) => a - b)
newStep.value = null
}
function removeStep(index) {
configForm.value.alert_thresholds.splice(index, 1)
}
async function saveConfig() {
saving.value = true
try {
await api.put(`/api/v1/iam-users/${configUserId.value}/update/`, configForm.value)
ElMessage.success('配置已保存')
configVisible.value = false
await loadUsers()
} catch (e) {
ElMessage.error('保存失败')
} finally {
saving.value = false
}
}
// --- Quota History ---
async function openQuotaHistory(row) {
historyUser.value = row
historyVisible.value = true
historyLoading.value = true
try {
const { data } = await api.get(`/api/v1/iam-users/${row.id}/quota-history/`)
quotaHistory.value = data
} catch (e) {
ElMessage.error('获取划拨记录失败')
quotaHistory.value = []
} finally {
historyLoading.value = false
}
}
// --- Policies ---
async function openPolicies(row) {
policiesUser.value = row
policiesVisible.value = true
policiesLoading.value = true
policyToAttach.value = ''
try {
const { data } = await api.get(`/api/v1/iam-users/${row.id}/policies/`)
policies.value = data.policies || []
} catch (e) {
ElMessage.error(e.response?.data?.message || '获取权限失败')
policies.value = []
} finally {
policiesLoading.value = false
}
}
async function handleAttachPolicy() {
if (!policyToAttach.value) return
try {
await api.post(`/api/v1/iam-users/${policiesUser.value.id}/policies/attach/`, {
policy_name: policyToAttach.value,
policy_type: 'System',
})
ElMessage.success(`已附加 ${policyToAttach.value}`)
policyToAttach.value = ''
await openPolicies(policiesUser.value)
} catch (e) {
ElMessage.error(e.response?.data?.message || '附加失败')
}
}
async function handleDetachPolicy(row) {
await ElMessageBox.confirm(`确定移除策略 "${row.PolicyName}" 吗?`, '确认移除', { type: 'warning' })
try {
await api.post(`/api/v1/iam-users/${policiesUser.value.id}/policies/detach/`, {
policy_name: row.PolicyName,
policy_type: row.PolicyType,
})
ElMessage.success(`已移除 ${row.PolicyName}`)
await openPolicies(policiesUser.value)
} catch (e) {
ElMessage.error(e.response?.data?.message || '移除失败')
}
}
// --- Create User ---
async function handleCreate() {
if (!createForm.value.username) {
ElMessage.warning('请输入用户名')
return
}
creating.value = true
try {
const { data } = await api.post('/api/v1/iam-users/create/', createForm.value)
ElMessage.success(data.message)
showCreate.value = false
createForm.value = { username: '', display_name: '', email: '', phone: '', password: '', project_name: '' }
// Show secret key if generated
if (data.volcengine?.secret_access_key) {
createdKeys.value = {
access_key_id: data.volcengine.access_key_id,
secret_access_key: data.volcengine.secret_access_key,
}
showSecretKey.value = true
}
await loadUsers()
} catch (e) {
ElMessage.error(e.response?.data?.message || '创建失败')
} finally {
creating.value = false
}
}
async function copyKeys() {
const text = `AccessKey ID: ${createdKeys.value.access_key_id}\nSecretAccessKey: ${createdKeys.value.secret_access_key}`
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制到剪贴板')
} catch {
ElMessage.error('复制失败,请手动复制')
}
}
onMounted(loadUsers)
</script>
<style scoped>
.form-hint { font-size: 12px; color: #999; margin-top: 4px; }
.switch-hint { font-size: 12px; color: #999; margin-left: 8px; }
</style>