feat: multi-project per sub-account support

Data model:
- Add IAMUserProject model (sub-account → N projects, each with monitoring toggle)
- Remove old single project_name from IAMUser model
- Update SpendingRecord with per-project granularity

Backend:
- Project CRUD views: list/add/update-toggle/delete/toggle-all
- Create user view auto-adds first project if specified
- Scheduler aggregates spending across all enabled projects per user
- Per-project spending recorded in SpendingRecord + IAMUserProject.current_spending
- Alert details include per-project spending breakdown

Frontend:
- New "项目管理" dialog: add projects from Volcengine dropdown, toggle monitoring per project, remove projects, batch toggle all
- "项目" column in user table showing monitored/total count (clickable)
- BillingView: expandable rows showing per-project spending breakdown
- Create dialog: optional initial project selection
- Removed old single-project select from config dialog

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-03-19 20:37:38 +08:00
parent 9fed282f1e
commit 1e94241587
9 changed files with 476 additions and 97 deletions

View File

@ -1,5 +1,5 @@
from django.contrib import admin
from .models import VolcAccount, IAMUser, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation
from .models import VolcAccount, IAMUser, IAMUserProject, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation
@admin.register(VolcAccount)
@ -7,11 +7,23 @@ class VolcAccountAdmin(admin.ModelAdmin):
list_display = ('name', 'access_key_hint', 'is_active', 'updated_at')
class IAMUserProjectInline(admin.TabularInline):
model = IAMUserProject
extra = 0
@admin.register(IAMUser)
class IAMUserAdmin(admin.ModelAdmin):
list_display = ('username', 'display_name', 'status', 'monitor_enabled',
'allocated_quota', 'consumed_total')
list_filter = ('status', 'monitor_enabled')
inlines = [IAMUserProjectInline]
@admin.register(IAMUserProject)
class IAMUserProjectAdmin(admin.ModelAdmin):
list_display = ('iam_user', 'project_name', 'monitor_enabled', 'current_spending')
list_filter = ('monitor_enabled',)
@admin.register(QuotaAllocation)
@ -32,4 +44,4 @@ class AlertRecordAdmin(admin.ModelAdmin):
@admin.register(SpendingRecord)
class SpendingRecordAdmin(admin.ModelAdmin):
list_display = ('iam_user', 'bill_period', 'amount', 'updated_at')
list_display = ('iam_user', 'project_name', 'bill_period', 'amount', 'updated_at')

View File

@ -0,0 +1,59 @@
# Generated by Django 4.2.21 on 2026-03-19 12:34
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('monitor', '0003_remove_globalconfig_default_monthly_budget_and_more'),
]
operations = [
migrations.AlterUniqueTogether(
name='spendingrecord',
unique_together=set(),
),
migrations.RemoveField(
model_name='iamuser',
name='project_name',
),
migrations.AddField(
model_name='spendingrecord',
name='project_name',
field=models.CharField(blank=True, help_text='空=子账号总消费', max_length=200, verbose_name='项目名'),
),
migrations.AlterField(
model_name='quotaallocation',
name='amount',
field=models.DecimalField(decimal_places=2, max_digits=12, verbose_name='变更金额(元,正=追加,负=扣减)'),
),
migrations.AlterUniqueTogether(
name='spendingrecord',
unique_together={('iam_user', 'project_name', 'bill_period')},
),
migrations.RemoveField(
model_name='spendingrecord',
name='detail',
),
migrations.CreateModel(
name='IAMUserProject',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('project_name', models.CharField(max_length=200, verbose_name='火山项目名')),
('display_name', models.CharField(blank=True, max_length=200, verbose_name='显示名')),
('monitor_enabled', models.BooleanField(default=True, verbose_name='启用监测')),
('current_spending', models.DecimalField(decimal_places=2, default=0, help_text='此项目的累计消费,由定时任务更新', max_digits=12, verbose_name='当前消费(元)')),
('created_at', models.DateTimeField(auto_now_add=True)),
('iam_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='monitor.iamuser')),
],
options={
'verbose_name': '子账号关联项目',
'verbose_name_plural': '子账号关联项目',
'db_table': 'airgate_iam_user_project',
'ordering': ['project_name'],
'unique_together': {('iam_user', 'project_name')},
},
),
]

View File

@ -35,8 +35,6 @@ class IAMUser(models.Model):
user_id = models.CharField('火山 UserID', max_length=100, blank=True)
email = models.EmailField('邮箱', blank=True)
phone = models.CharField('手机号', max_length=20, blank=True)
project_name = models.CharField('关联项目名', max_length=200, blank=True,
help_text='用于按项目维度追踪消费')
status = models.CharField('状态', max_length=20, choices=Status.choices, default=Status.UNKNOWN)
# Access keys (stored as JSON list of AK IDs, not secrets)
@ -90,6 +88,28 @@ class IAMUser(models.Model):
return sorted(config.default_alert_thresholds or [50, 80, 90])
class IAMUserProject(models.Model):
"""子账号关联的火山项目(多对多)"""
iam_user = models.ForeignKey(IAMUser, on_delete=models.CASCADE, related_name='projects')
project_name = models.CharField('火山项目名', max_length=200)
display_name = models.CharField('显示名', max_length=200, blank=True)
monitor_enabled = models.BooleanField('启用监测', default=True)
current_spending = models.DecimalField('当前消费(元)', max_digits=12, decimal_places=2, default=0,
help_text='此项目的累计消费,由定时任务更新')
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = '子账号关联项目'
verbose_name_plural = '子账号关联项目'
db_table = 'airgate_iam_user_project'
unique_together = [('iam_user', 'project_name')]
ordering = ['project_name']
def __str__(self):
status = '监测中' if self.monitor_enabled else '未监测'
return f"{self.project_name} ({status}) ¥{self.current_spending}"
class QuotaAllocation(models.Model):
"""额度划拨记录"""
iam_user = models.ForeignKey(IAMUser, on_delete=models.CASCADE, related_name='quota_allocations')
@ -161,18 +181,20 @@ class AlertRecord(models.Model):
class SpendingRecord(models.Model):
"""月度消费快照"""
"""月度消费快照(按项目粒度)"""
iam_user = models.ForeignKey(IAMUser, on_delete=models.CASCADE, related_name='spending_records')
project_name = models.CharField('项目名', max_length=200, blank=True,
help_text='空=子账号总消费')
bill_period = models.CharField('账期 (YYYY-MM)', max_length=7, db_index=True)
amount = models.DecimalField('消费金额(元)', max_digits=12, decimal_places=2, default=0)
detail = models.JSONField('消费明细', default=dict, blank=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = '消费记录'
verbose_name_plural = '消费记录'
db_table = 'airgate_spending_record'
unique_together = [('iam_user', 'bill_period')]
unique_together = [('iam_user', 'project_name', 'bill_period')]
def __str__(self):
return f"{self.iam_user.username} {self.bill_period}: ¥{self.amount}"
proj = self.project_name or '总计'
return f"{self.iam_user.username} [{proj}] {self.bill_period}: ¥{self.amount}"

View File

@ -1,5 +1,5 @@
from rest_framework import serializers
from .models import IAMUser, VolcAccount, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation
from .models import IAMUser, IAMUserProject, VolcAccount, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation
class VolcAccountSerializer(serializers.ModelSerializer):
@ -15,21 +15,32 @@ class VolcAccountCreateSerializer(serializers.Serializer):
secret_key = serializers.CharField(write_only=True)
class IAMUserProjectSerializer(serializers.ModelSerializer):
class Meta:
model = IAMUserProject
fields = ['id', 'project_name', 'display_name', 'monitor_enabled',
'current_spending', 'created_at']
read_only_fields = ['current_spending', 'created_at']
class IAMUserSerializer(serializers.ModelSerializer):
remaining_quota = serializers.DecimalField(max_digits=12, decimal_places=2, read_only=True)
usage_percent = serializers.FloatField(read_only=True)
effective_alert_thresholds = serializers.SerializerMethodField()
projects = IAMUserProjectSerializer(many=True, read_only=True)
monitored_project_count = serializers.SerializerMethodField()
class Meta:
model = IAMUser
fields = [
'id', 'username', 'display_name', 'user_id', 'email', 'phone',
'project_name', 'status', 'access_key_ids',
'status', 'access_key_ids',
'allocated_quota', 'consumed_total', 'remaining_quota', 'usage_percent',
'spending_updated_at',
'monitor_enabled', 'auto_disable_enabled',
'alert_thresholds', 'triggered_alerts',
'effective_alert_thresholds',
'projects', 'monitored_project_count',
'remark', 'created_at', 'updated_at',
]
read_only_fields = ['user_id', 'access_key_ids', 'status',
@ -40,6 +51,9 @@ class IAMUserSerializer(serializers.ModelSerializer):
def get_effective_alert_thresholds(self, obj):
return obj.get_alert_thresholds()
def get_monitored_project_count(self, obj):
return obj.projects.filter(monitor_enabled=True).count()
class IAMUserCreateSerializer(serializers.Serializer):
username = serializers.CharField(max_length=200)
@ -47,7 +61,8 @@ class IAMUserCreateSerializer(serializers.Serializer):
email = serializers.EmailField(required=False, default='')
phone = serializers.CharField(max_length=20, required=False, default='')
password = serializers.CharField(write_only=True, required=False, default='')
project_name = serializers.CharField(max_length=200, required=False, default='')
project_name = serializers.CharField(max_length=200, required=False, default='',
help_text='可选,创建后自动关联此项目')
class IAMUserImportSerializer(serializers.Serializer):
@ -55,8 +70,6 @@ class IAMUserImportSerializer(serializers.Serializer):
class IAMUserConfigSerializer(serializers.Serializer):
"""子账号配置更新"""
project_name = serializers.CharField(max_length=200, required=False, allow_blank=True)
alert_thresholds = serializers.ListField(
child=serializers.IntegerField(min_value=1, max_value=99),
required=False,
@ -65,6 +78,16 @@ class IAMUserConfigSerializer(serializers.Serializer):
auto_disable_enabled = serializers.BooleanField(required=False)
class IAMUserProjectAddSerializer(serializers.Serializer):
project_name = serializers.CharField(max_length=200)
display_name = serializers.CharField(max_length=200, required=False, default='')
monitor_enabled = serializers.BooleanField(required=False, default=True)
class IAMUserProjectUpdateSerializer(serializers.Serializer):
monitor_enabled = serializers.BooleanField()
class QuotaAllocateSerializer(serializers.Serializer):
"""额度变更:正数=追加,负数=扣减"""
amount = serializers.DecimalField(max_digits=12, decimal_places=2)
@ -106,14 +129,6 @@ class AlertRecordSerializer(serializers.ModelSerializer):
]
class SpendingRecordSerializer(serializers.ModelSerializer):
iam_username = serializers.CharField(source='iam_user.username')
class Meta:
model = SpendingRecord
fields = ['id', 'iam_user', 'iam_username', 'bill_period', 'amount', 'updated_at']
class DashboardSerializer(serializers.Serializer):
total_users = serializers.IntegerField()
active_users = serializers.IntegerField()

View File

@ -22,6 +22,14 @@ urlpatterns = [
path('iam-users/<int:pk>/policies/', views.iam_user_policies_view),
path('iam-users/<int:pk>/policies/attach/', views.iam_user_attach_policy_view),
path('iam-users/<int:pk>/policies/detach/', views.iam_user_detach_policy_view),
# IAM user projects (multi-project)
path('iam-users/<int:pk>/projects/', views.iam_user_project_list_view),
path('iam-users/<int:pk>/projects/add/', views.iam_user_project_add_view),
path('iam-users/<int:pk>/projects/<int:pid>/', views.iam_user_project_update_view),
path('iam-users/<int:pk>/projects/<int:pid>/delete/', views.iam_user_project_delete_view),
path('iam-users/<int:pk>/projects/toggle-all/', views.iam_user_project_toggle_all_view),
# Quota
path('iam-users/<int:pk>/allocate/', views.quota_allocate_view),
path('iam-users/<int:pk>/quota-history/', views.quota_history_view),

View File

@ -14,11 +14,13 @@ from utils.iam_service import IAMService, ProjectService
from utils.billing_service import BillingService
from utils.volcengine_client import VolcengineAPIError
from .models import VolcAccount, IAMUser, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation
from .models import VolcAccount, IAMUser, IAMUserProject, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation
from .serializers import (
VolcAccountSerializer, VolcAccountCreateSerializer,
IAMUserSerializer, IAMUserCreateSerializer, IAMUserImportSerializer,
IAMUserConfigSerializer, QuotaAllocateSerializer, QuotaAllocationSerializer,
IAMUserConfigSerializer,
IAMUserProjectSerializer, IAMUserProjectAddSerializer, IAMUserProjectUpdateSerializer,
QuotaAllocateSerializer, QuotaAllocationSerializer,
GlobalConfigSerializer,
AlertRecordSerializer,
DashboardSerializer,
@ -272,11 +274,19 @@ def iam_user_create_view(request):
user_id=volc_user.get("UserId", ""),
email=d.get('email', ''),
phone=d.get('phone', ''),
project_name=d.get('project_name', ''),
status=IAMUser.Status.ACTIVE,
access_key_ids=[result_info.get('access_key_id', '')] if result_info.get('access_key_id') else [],
)
# 6. Auto-add project if specified
project_name = d.get('project_name', '')
if project_name:
IAMUserProject.objects.create(
iam_user=obj,
project_name=project_name,
monitor_enabled=True,
)
AlertRecord.objects.create(
iam_user=obj,
alert_type=AlertRecord.AlertType.MANUAL,
@ -499,6 +509,91 @@ def iam_user_detach_policy_view(request, pk):
status=status.HTTP_502_BAD_GATEWAY)
# ==================== IAM User Projects ====================
@api_view(['GET'])
def iam_user_project_list_view(request, pk):
"""查看子账号关联的项目列表"""
try:
user = IAMUser.objects.get(pk=pk)
except IAMUser.DoesNotExist:
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
projects = user.projects.all()
return Response(IAMUserProjectSerializer(projects, many=True).data)
@api_view(['POST'])
def iam_user_project_add_view(request, pk):
"""给子账号添加关联项目"""
try:
user = IAMUser.objects.get(pk=pk)
except IAMUser.DoesNotExist:
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
serializer = IAMUserProjectAddSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
d = serializer.validated_data
obj, created = IAMUserProject.objects.get_or_create(
iam_user=user,
project_name=d['project_name'],
defaults={
'display_name': d.get('display_name', ''),
'monitor_enabled': d.get('monitor_enabled', True),
},
)
if not created:
return Response({'error': 'duplicate', 'message': f'项目 {d["project_name"]} 已关联'},
status=status.HTTP_409_CONFLICT)
return Response({
'message': f'已关联项目 {d["project_name"]}',
'project': IAMUserProjectSerializer(obj).data,
}, status=status.HTTP_201_CREATED)
@api_view(['PUT'])
def iam_user_project_update_view(request, pk, pid):
"""更新项目监测开关"""
try:
project = IAMUserProject.objects.get(pk=pid, iam_user_id=pk)
except IAMUserProject.DoesNotExist:
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
serializer = IAMUserProjectUpdateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
project.monitor_enabled = serializer.validated_data['monitor_enabled']
project.save(update_fields=['monitor_enabled'])
return Response(IAMUserProjectSerializer(project).data)
@api_view(['DELETE'])
def iam_user_project_delete_view(request, pk, pid):
"""移除关联项目"""
try:
project = IAMUserProject.objects.get(pk=pid, iam_user_id=pk)
except IAMUserProject.DoesNotExist:
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
name = project.project_name
project.delete()
return Response({'message': f'已移除项目 {name}'})
@api_view(['POST'])
def iam_user_project_toggle_all_view(request, pk):
"""批量切换所有项目的监测开关"""
try:
user = IAMUser.objects.get(pk=pk)
except IAMUser.DoesNotExist:
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
enable = request.data.get('monitor_enabled', True)
count = user.projects.update(monitor_enabled=enable)
label = '开启' if enable else '关闭'
return Response({'message': f'{label}全部 {count} 个项目的监测'})
# ==================== Quota Allocation ====================
@api_view(['POST'])

View File

@ -1,4 +1,4 @@
"""定时消费监控任务 -- 额度划拨制 + 阶梯式告警"""
"""定时消费监控任务 -- 多项目聚合 + 额度划拨制 + 阶梯式告警"""
import logging
from decimal import Decimal
@ -10,8 +10,8 @@ _scheduler_started = False
def check_spending():
"""定时检查所有子账号消费,对比已划拨额度触发阶梯告警"""
from apps.monitor.models import VolcAccount, IAMUser, GlobalConfig, AlertRecord, SpendingRecord
"""定时检查所有子账号消费:遍历开启监测的项目,聚合消费,触发阶梯告警"""
from apps.monitor.models import VolcAccount, IAMUser, IAMUserProject, GlobalConfig, AlertRecord, SpendingRecord
from utils.crypto import decrypt
from utils.billing_service import BillingService
from utils.iam_service import IAMService
@ -19,6 +19,7 @@ def check_spending():
config = GlobalConfig.get_solo()
webhook = config.feishu_webhook_url
bill_period = timezone.now().strftime("%Y-%m")
for volc_account in VolcAccount.objects.filter(is_active=True):
ak = decrypt(volc_account.access_key_enc)
@ -37,25 +38,50 @@ def check_spending():
for user in users:
try:
# 查询当月消费(按项目筛选)
bill_period = timezone.now().strftime("%Y-%m")
spending = billing.get_spending_by_project(
bill_period, user.project_name or None
# --- 遍历所有开启监测的项目,分别查询消费并累加 ---
enabled_projects = IAMUserProject.objects.filter(
iam_user=user, monitor_enabled=True
)
# 记录月度快照
if not enabled_projects.exists():
logger.info(f"用户 {user.username} 无开启监测的项目,跳过")
continue
total_spending = Decimal('0')
for project in enabled_projects:
try:
proj_spending = billing.get_spending_by_project(
bill_period, project.project_name
)
except Exception as e:
logger.error(f"查询项目 {project.project_name} 消费失败: {e}")
proj_spending = project.current_spending # 保留上次值
# 更新项目级消费
project.current_spending = proj_spending
project.save(update_fields=['current_spending'])
# 记录项目级月度快照
SpendingRecord.objects.update_or_create(
iam_user=user, bill_period=bill_period,
defaults={'amount': spending},
iam_user=user,
project_name=project.project_name,
bill_period=bill_period,
defaults={'amount': proj_spending},
)
# 累计消费 = 所有月份的消费之和
total_spending += proj_spending
# 更新子账号总消费
# 累计消费 = 所有月份的所有开启监测项目的消费之和
all_enabled_names = list(enabled_projects.values_list('project_name', flat=True))
from django.db.models import Sum
total = SpendingRecord.objects.filter(
iam_user=user
cumulative = SpendingRecord.objects.filter(
iam_user=user,
project_name__in=all_enabled_names,
).aggregate(total=Sum('amount'))['total'] or Decimal('0')
user.consumed_total = total
user.consumed_total = cumulative
user.spending_updated_at = timezone.now()
quota = user.allocated_quota
@ -63,7 +89,7 @@ def check_spending():
user.save(update_fields=['consumed_total', 'spending_updated_at'])
continue
usage_percent = float(total) / float(quota) * 100
usage_percent = float(cumulative) / float(quota) * 100
triggered = user.triggered_alerts or []
# --- 阶梯式告警 ---
@ -72,16 +98,23 @@ def check_spending():
triggered.append(step)
threshold_amount = Decimal(str(quota)) * step / 100
# 构建项目明细
detail_lines = "\n".join(
f" {p.project_name}: ¥{p.current_spending}"
for p in enabled_projects
)
AlertRecord.objects.create(
iam_user=user,
alert_type=AlertRecord.AlertType.WARNING,
title=f"{user.username} 消费达到额度 {step}%",
content=(
f"累计消费 ¥{total:.2f}"
f"累计消费 ¥{cumulative:.2f}"
f"已划拨额度 ¥{quota:.2f}{step}%\n"
f"剩余额度: ¥{user.remaining_quota:.2f}"
f"剩余额度: ¥{user.remaining_quota:.2f}\n"
f"项目明细:\n{detail_lines}"
),
spending_amount=total,
spending_amount=cumulative,
threshold_amount=threshold_amount,
notified=True,
)
@ -89,10 +122,12 @@ def check_spending():
webhook,
f"⚠️ {user.username} 消费达到额度 {step}%",
f"**用户**: {user.username}\n"
f"**累计消费**: ¥{total:.2f}\n"
f"**累计消费**: ¥{cumulative:.2f}\n"
f"**已划拨额度**: ¥{quota:.2f}\n"
f"**剩余额度**: ¥{user.remaining_quota:.2f}\n"
f"**使用率**: {usage_percent:.1f}%",
f"**使用率**: {usage_percent:.1f}%\n"
f"**监测项目数**: {enabled_projects.count()}\n"
f"**项目明细**:\n{detail_lines}",
template="orange" if step < 90 else "red",
)
@ -108,15 +143,21 @@ def check_spending():
except Exception as e:
logger.error(f"停用用户 {user.username} 失败: {e}")
detail_lines = "\n".join(
f" {p.project_name}: ¥{p.current_spending}"
for p in enabled_projects
)
AlertRecord.objects.create(
iam_user=user,
alert_type=AlertRecord.AlertType.DISABLE,
title=f"{user.username} 额度用尽,已自动停用",
content=(
f"累计消费 ¥{total:.2f},已划拨额度 ¥{quota:.2f} 已用尽。\n"
f"累计消费 ¥{cumulative:.2f},已划拨额度 ¥{quota:.2f} 已用尽。\n"
f"项目明细:\n{detail_lines}\n"
f"如需继续使用,请划拨新额度后恢复账号。"
),
spending_amount=total,
spending_amount=cumulative,
threshold_amount=quota,
notified=True,
)
@ -124,8 +165,9 @@ def check_spending():
webhook,
f"🚨 {user.username} 额度用尽,已自动停用",
f"**用户**: {user.username}\n"
f"**累计消费**: ¥{total:.2f}\n"
f"**累计消费**: ¥{cumulative:.2f}\n"
f"**已划拨额度**: ¥{quota:.2f}\n"
f"**项目明细**:\n{detail_lines}\n"
f"额度已用尽,账号已自动停用。\n"
f"请在 AirGate 划拨新额度后恢复。",
template="red",

View File

@ -24,10 +24,37 @@
<span>各子账号消费与额度</span>
</template>
<el-table :data="overview.users || []" stripe v-loading="loading" table-layout="auto"
:default-sort="{ prop: 'consumed_total', order: 'descending' }">
:default-sort="{ prop: 'consumed_total', order: 'descending' }"
row-key="id">
<el-table-column type="expand">
<template #default="{ row }">
<div style="padding: 8px 48px;" v-if="(row.projects || []).length">
<el-table :data="row.projects" size="small" :show-header="true">
<el-table-column prop="project_name" label="项目名" min-width="160" />
<el-table-column label="消费" min-width="100" align="right">
<template #default="{ row: p }">
<span style="color:#e6a23c;">¥{{ Number(p.current_spending).toLocaleString() }}</span>
</template>
</el-table-column>
<el-table-column label="监测" min-width="80" align="center">
<template #default="{ row: p }">
<el-tag :type="p.monitor_enabled ? 'success' : 'info'" size="small">
{{ p.monitor_enabled ? '开' : '关' }}
</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<div v-else style="padding:8px 48px; color:#999;">暂无关联项目</div>
</template>
</el-table-column>
<el-table-column prop="username" label="用户名" min-width="120" />
<el-table-column prop="display_name" label="显示名" min-width="100" />
<el-table-column prop="project_name" label="项目" min-width="120" />
<el-table-column label="监测项目" min-width="90" align="center">
<template #default="{ row }">
{{ row.monitored_project_count || 0 }} / {{ (row.projects || []).length }}
</template>
</el-table-column>
<el-table-column prop="consumed_total" label="累计消费" min-width="110" sortable align="right">
<template #default="{ row }">
<span style="font-weight: 600; color: #e6a23c;">
@ -56,15 +83,6 @@
<span v-else style="color:#999;font-size:12px;">未划拨</span>
</template>
</el-table-column>
<el-table-column label="告警" min-width="110" align="center">
<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;">
{{ step }}%
</el-tag>
</template>
</el-table-column>
<el-table-column prop="spending_updated_at" label="更新时间" min-width="150">
<template #default="{ row }">
{{ row.spending_updated_at ? new Date(row.spending_updated_at).toLocaleString('zh-CN') : '暂无' }}

View File

@ -51,6 +51,13 @@
<span v-else style="color:#999;font-size:12px;">未划拨</span>
</template>
</el-table-column>
<el-table-column label="项目" min-width="80" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openProjectsDialog(row)">
{{ row.monitored_project_count || 0 }} / {{ (row.projects || []).length }}
</el-button>
</template>
</el-table-column>
<el-table-column label="告警" min-width="110" align="center">
<template #default="{ row }">
<el-tag v-for="step in (row.effective_alert_thresholds || [])" :key="step"
@ -69,6 +76,7 @@
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="openProjectsDialog(row)">项目管理</el-dropdown-item>
<el-dropdown-item @click="openConfig(row)">监控配置</el-dropdown-item>
<el-dropdown-item @click="openPolicies(row)">权限策略</el-dropdown-item>
<el-dropdown-item @click="openQuotaHistory(row)">划拨记录</el-dropdown-item>
@ -125,20 +133,8 @@
</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-dialog v-model="configVisible" title="监控配置" width="520px">
<el-form :model="configForm" label-width="140px">
<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"
@ -148,9 +144,6 @@
</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>
@ -165,6 +158,43 @@
</template>
</el-dialog>
<!-- Projects Dialog -->
<el-dialog v-model="projectsDialogVisible" :title="`${projectsUser?.username} 关联项目`" width="680px">
<div style="margin-bottom:12px; display:flex; gap:8px; align-items:center;">
<el-select v-model="projectToAdd" placeholder="选择火山项目" filterable style="flex:1;"
:loading="volcProjectsLoading">
<el-option v-for="p in volcProjects" :key="p.name" :label="p.display_name || p.name" :value="p.name" />
</el-select>
<el-button type="primary" @click="handleAddProject" :disabled="!projectToAdd">添加</el-button>
<el-button @click="loadVolcProjects" :loading="volcProjectsLoading" text>
<el-icon><Refresh /></el-icon>
</el-button>
</div>
<div style="margin-bottom:12px;">
<el-button size="small" @click="handleToggleAll(true)">全部开启监测</el-button>
<el-button size="small" @click="handleToggleAll(false)">全部关闭监测</el-button>
</div>
<el-table :data="userProjects" stripe v-loading="projectsDialogLoading" empty-text="暂无关联项目">
<el-table-column prop="project_name" label="项目名" min-width="160" />
<el-table-column prop="display_name" label="显示名" min-width="120" />
<el-table-column label="消费" min-width="100" align="right">
<template #default="{ row }">
<span style="color:#e6a23c;">¥{{ Number(row.current_spending).toLocaleString() }}</span>
</template>
</el-table-column>
<el-table-column label="监测" min-width="80" align="center">
<template #default="{ row }">
<el-switch :model-value="row.monitor_enabled" @change="val => handleToggleProject(row, val)" />
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template #default="{ row }">
<el-button size="small" type="danger" text @click="handleRemoveProject(row)">移除</el-button>
</template>
</el-table-column>
</el-table>
</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="暂无划拨记录">
@ -235,8 +265,9 @@
</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" />
style="width:100%;" :loading="volcProjectsLoading"
@focus="() => { if (!volcProjects.length) loadVolcProjects() }">
<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>
@ -295,10 +326,6 @@ 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)
@ -361,6 +388,15 @@ const policies = ref([])
const policiesLoading = ref(false)
const policyToAttach = ref('')
// Projects dialog
const projectsDialogVisible = ref(false)
const projectsUser = ref(null)
const userProjects = ref([])
const projectsDialogLoading = ref(false)
const projectToAdd = ref('')
const volcProjects = ref([])
const volcProjectsLoading = ref(false)
// --- Allocate ---
const maxDeduct = computed(() => {
if (!allocateUser.value) return 0
@ -404,29 +440,101 @@ async function submitAllocate() {
}
// --- 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()
}
// --- Projects Dialog ---
async function loadVolcProjects() {
volcProjectsLoading.value = true
try {
const { data } = await api.get('/api/v1/projects/')
volcProjects.value = data
} catch (e) {
ElMessage.error(e.response?.data?.message || '获取火山项目列表失败')
} finally {
volcProjectsLoading.value = false
}
}
async function openProjectsDialog(row) {
projectsUser.value = row
projectsDialogVisible.value = true
projectToAdd.value = ''
await loadUserProjects(row.id)
if (volcProjects.value.length === 0) loadVolcProjects()
}
async function loadUserProjects(userId) {
projectsDialogLoading.value = true
try {
const { data } = await api.get(`/api/v1/iam-users/${userId}/projects/`)
userProjects.value = data
} catch (e) {
ElMessage.error('获取项目列表失败')
userProjects.value = []
} finally {
projectsDialogLoading.value = false
}
}
async function handleAddProject() {
if (!projectToAdd.value) return
try {
await api.post(`/api/v1/iam-users/${projectsUser.value.id}/projects/add/`, {
project_name: projectToAdd.value,
})
ElMessage.success('已添加')
projectToAdd.value = ''
await loadUserProjects(projectsUser.value.id)
await loadUsers()
} catch (e) {
ElMessage.error(e.response?.data?.message || '添加失败')
}
}
async function handleToggleProject(row, val) {
try {
await api.put(`/api/v1/iam-users/${projectsUser.value.id}/projects/${row.id}/`, {
monitor_enabled: val,
})
await loadUserProjects(projectsUser.value.id)
await loadUsers()
} catch (e) {
ElMessage.error('切换失败')
}
}
async function handleRemoveProject(row) {
await ElMessageBox.confirm(`确定移除项目 "${row.project_name}" 吗?`, '确认', { type: 'warning' })
try {
await api.delete(`/api/v1/iam-users/${projectsUser.value.id}/projects/${row.id}/delete/`)
ElMessage.success('已移除')
await loadUserProjects(projectsUser.value.id)
await loadUsers()
} catch (e) {
ElMessage.error('移除失败')
}
}
async function handleToggleAll(enable) {
try {
const { data } = await api.post(`/api/v1/iam-users/${projectsUser.value.id}/projects/toggle-all/`, {
monitor_enabled: enable,
})
ElMessage.success(data.message)
await loadUserProjects(projectsUser.value.id)
await loadUsers()
} catch (e) {
ElMessage.error('操作失败')
}
}
function addStep() {