feat: v0.12.0 用户总额度 + 并发控制 + 团管消费记录 + 安全加固

①用户总消费额度(User.spending_limit,默认-1不限,花完即停,含冻结中任务)
②团队并发任务控制(Team.max_concurrent_tasks,默认5,超限拒绝)
③额度检查竞态修复(Layer 1-4 全部移入 transaction.atomic + select_for_update)
④查询参数类型保护(_safe_int 替换所有裸 int() 调用,防 500)
⑤团管消费记录页(/team/records,按用户/日期筛选 + CSV 导出)
⑥超管用户页/团管成员页新增总额度列和编辑
⑦超管团队页新增并发列和内联编辑
⑧失败原因 tooltip 改右对齐防裁剪

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-03-22 18:53:56 +08:00
parent 6a5ddbaf78
commit 203603f69a
14 changed files with 488 additions and 66 deletions

View File

@ -0,0 +1,23 @@
# Generated by Django 4.2.29 on 2026-03-22 10:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0010_billing_data_migration'),
]
operations = [
migrations.AddField(
model_name='team',
name='max_concurrent_tasks',
field=models.IntegerField(default=5, verbose_name='最大并发任务数'),
),
migrations.AddField(
model_name='user',
name='spending_limit',
field=models.DecimalField(decimal_places=2, default=-1, max_digits=12, verbose_name='用户总消费额度(元)'),
),
]

View File

@ -18,6 +18,7 @@ class Team(models.Model):
daily_member_spending_default = models.DecimalField(max_digits=12, decimal_places=2, default=50, verbose_name='新成员默认每日消费限额(元)')
frozen_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name='冻结金额(元)')
markup_percentage = models.DecimalField(max_digits=5, decimal_places=2, default=0, verbose_name='加价百分比')
max_concurrent_tasks = models.IntegerField(default=5, verbose_name='最大并发任务数')
is_active = models.BooleanField(default=True, verbose_name='启用状态')
expected_regions = models.CharField(max_length=500, blank=True, default='', verbose_name='预期登录城市(逗号分隔)')
disabled_by = models.CharField(max_length=10, blank=True, default='', verbose_name='禁用来源')
@ -55,6 +56,7 @@ class User(AbstractUser):
# ── 次数限额v0.10.0 新增) ──
daily_generation_limit = models.IntegerField(default=50, verbose_name='每日生成次数上限')
monthly_generation_limit = models.IntegerField(default=1500, verbose_name='每月生成次数上限')
spending_limit = models.DecimalField(max_digits=12, decimal_places=2, default=-1, verbose_name='用户总消费额度(元)')
must_change_password = models.BooleanField(default=True, verbose_name='必须修改密码')
disabled_by = models.CharField(max_length=10, blank=True, default='', verbose_name='禁用来源')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')

View File

@ -13,6 +13,7 @@ class VideoGenerateSerializer(serializers.Serializer):
class QuotaUpdateSerializer(serializers.Serializer):
daily_generation_limit = serializers.IntegerField(min_value=-1)
monthly_generation_limit = serializers.IntegerField(min_value=-1)
spending_limit = serializers.DecimalField(max_digits=12, decimal_places=2, required=False)
class UserStatusSerializer(serializers.Serializer):
@ -68,6 +69,7 @@ class TeamCreateSerializer(serializers.Serializer):
markup_percentage = serializers.DecimalField(max_digits=5, decimal_places=2, min_value=0, required=True)
monthly_spending_limit = serializers.DecimalField(max_digits=12, decimal_places=2, required=False, default=-1)
daily_member_spending_default = serializers.DecimalField(max_digits=12, decimal_places=2, required=False, default=50)
max_concurrent_tasks = serializers.IntegerField(min_value=0, required=False, default=5)
expected_regions = serializers.CharField(max_length=500, required=True)
@ -78,6 +80,7 @@ class TeamUpdateSerializer(serializers.Serializer):
markup_percentage = serializers.DecimalField(max_digits=5, decimal_places=2, min_value=0, required=False)
monthly_spending_limit = serializers.DecimalField(max_digits=12, decimal_places=2, required=False)
daily_member_spending_default = serializers.DecimalField(max_digits=12, decimal_places=2, required=False)
max_concurrent_tasks = serializers.IntegerField(min_value=0, required=False)
is_active = serializers.BooleanField(required=False)
expected_regions = serializers.CharField(max_length=500, required=False, allow_blank=True)
@ -121,3 +124,4 @@ class TeamMemberCreateSerializer(serializers.Serializer):
class MemberQuotaSerializer(serializers.Serializer):
daily_generation_limit = serializers.IntegerField(min_value=-1)
monthly_generation_limit = serializers.IntegerField(min_value=-1)
spending_limit = serializers.DecimalField(max_digits=12, decimal_places=2, required=False)

View File

@ -58,6 +58,9 @@ urlpatterns = [
path('team/members/<int:member_id>/quota', views.team_member_quota_view, name='team_member_quota'),
path('team/members/<int:member_id>/status', views.team_member_status_view, name='team_member_status'),
# ── Team Admin: Consumption Records ──
path('team/records', views.team_records_view, name='team_records'),
# ── Team Admin: Content Assets ──
path('team/assets/overview', views.team_assets_overview, name='team_assets_overview'),
path('team/assets/member/<int:member_id>/videos', views.team_assets_member_videos, name='team_assets_member_videos'),

View File

@ -39,6 +39,15 @@ MAX_IMAGE_SIZE = 30 * 1024 * 1024 # 30MB
MAX_VIDEO_SIZE = 50 * 1024 * 1024 # 50MB
MAX_AUDIO_SIZE = 15 * 1024 * 1024 # 15MB
def _safe_int(value, default=0):
"""Safely convert query parameter to int, returning default on failure."""
try:
return int(value)
except (TypeError, ValueError):
return default
# Columns added in migration 0003; may not exist in production DB yet.
_M0003_COLS = ('ark_task_id', 'result_url', 'error_message', 'reference_urls')
_m0003_ok = None # None = unknown, True = columns exist, False = missing
@ -160,37 +169,64 @@ def video_generate_view(request):
estimated_tokens = estimate_tokens(w, h, duration)
estimated_cost = calculate_cost(estimated_tokens, config.base_token_price, team.markup_percentage)
# ── Layer 1: 用户每日生成次数限额 (skip if -1) ──
if user.daily_generation_limit != -1:
daily_count = user.generation_records.filter(created_at__date=today).count()
if daily_count >= user.daily_generation_limit:
return Response({
'error': 'quota_exceeded',
'message': f'您今日的生成次数已达上限({user.daily_generation_limit}次)',
'daily_generation_limit': user.daily_generation_limit,
'daily_generation_used': daily_count,
'reset_at': (timezone.now() + timedelta(days=1)).replace(
hour=0, minute=0, second=0, microsecond=0
).isoformat(),
}, status=status.HTTP_429_TOO_MANY_REQUESTS)
# ── Layer 2: 用户每月生成次数限额 (skip if -1) ──
if user.monthly_generation_limit != -1:
monthly_count = user.generation_records.filter(
created_at__date__gte=first_of_month
).count()
if monthly_count >= user.monthly_generation_limit:
return Response({
'error': 'quota_exceeded',
'message': f'您本月的生成次数已达上限({user.monthly_generation_limit}次)',
'monthly_generation_limit': user.monthly_generation_limit,
'monthly_generation_used': monthly_count,
}, status=status.HTTP_429_TOO_MANY_REQUESTS)
# ── Layer 3 & 4: 团队余额检查 + 冻结 (atomic with row lock) ──
# ── 所有额度检查在 transaction 内完成select_for_update 串行化同团队请求 ──
with transaction.atomic():
locked_team = Team.objects.select_for_update().get(pk=team.pk)
# Layer 1: 用户每日生成次数限额 (skip if -1)
if user.daily_generation_limit != -1:
daily_count = user.generation_records.filter(created_at__date=today).count()
if daily_count >= user.daily_generation_limit:
return Response({
'error': 'quota_exceeded',
'message': f'您今日的生成次数已达上限({user.daily_generation_limit}次)',
'daily_generation_limit': user.daily_generation_limit,
'daily_generation_used': daily_count,
'reset_at': (timezone.now() + timedelta(days=1)).replace(
hour=0, minute=0, second=0, microsecond=0
).isoformat(),
}, status=status.HTTP_429_TOO_MANY_REQUESTS)
# Layer 2: 用户每月生成次数限额 (skip if -1)
if user.monthly_generation_limit != -1:
monthly_count = user.generation_records.filter(
created_at__date__gte=first_of_month
).count()
if monthly_count >= user.monthly_generation_limit:
return Response({
'error': 'quota_exceeded',
'message': f'您本月的生成次数已达上限({user.monthly_generation_limit}次)',
'monthly_generation_limit': user.monthly_generation_limit,
'monthly_generation_used': monthly_count,
}, status=status.HTTP_429_TOO_MANY_REQUESTS)
# Layer 2.5: 用户总消费额度 (skip if -1)
from decimal import Decimal
if user.spending_limit != Decimal('-1'):
total_spent = GenerationRecord.objects.filter(
user=user,
status__in=['completed', 'processing', 'queued'],
).aggregate(total=Sum('cost_amount'))['total'] or Decimal('0')
if total_spent + estimated_cost > user.spending_limit:
return Response({
'error': 'spending_limit_exceeded',
'message': f'您的总消费已达上限(¥{user.spending_limit}),请联系管理员',
'spending_limit': float(user.spending_limit),
'total_spent': float(total_spent),
}, status=status.HTTP_429_TOO_MANY_REQUESTS)
# Layer 2.6: 团队并发限制
if locked_team.max_concurrent_tasks > 0:
processing_count = GenerationRecord.objects.filter(
user__team=locked_team,
status__in=['queued', 'processing'],
).count()
if processing_count >= locked_team.max_concurrent_tasks:
return Response({
'error': 'concurrent_limit',
'message': f'团队当前有 {processing_count} 个任务正在处理,已达并发上限 {processing_count}/{locked_team.max_concurrent_tasks}',
}, status=status.HTTP_429_TOO_MANY_REQUESTS)
# Layer 3: 团队月消费限额
if locked_team.monthly_spending_limit != -1:
team_monthly_spent = GenerationRecord.objects.filter(
@ -401,8 +437,8 @@ def video_tasks_list_view(request):
offset: Number of tasks to skip (default 0).
"""
user = request.user
page_size = min(int(request.query_params.get('page_size', 20)), 100)
offset = max(int(request.query_params.get('offset', 0)), 0)
page_size = min(_safe_int(request.query_params.get('page_size', 20), 20), 100)
offset = max(_safe_int(request.query_params.get('offset', 0), 0), 0)
qs = user.generation_records.order_by('-created_at')
total = qs.count()
@ -742,6 +778,10 @@ def admin_teams_list_view(request):
'frozen_amount': float(t.frozen_amount),
'markup_percentage': float(t.markup_percentage),
'daily_member_limit_default': t.daily_member_limit_default,
'max_concurrent_tasks': t.max_concurrent_tasks,
'current_processing': GenerationRecord.objects.filter(
user__team=t, status__in=['queued', 'processing'],
).count(),
'member_count': t.members.count(),
'is_active': t.is_active,
'expected_regions': t.expected_regions,
@ -837,6 +877,7 @@ def admin_team_detail_view(request, team_id):
'markup_percentage': float(team.markup_percentage),
'monthly_spending_limit': float(team.monthly_spending_limit),
'daily_member_spending_default': float(team.daily_member_spending_default),
'max_concurrent_tasks': team.max_concurrent_tasks,
'is_active': team.is_active,
'expected_regions': team.expected_regions,
'disabled_by': team.disabled_by,
@ -921,6 +962,10 @@ def admin_team_detail_view(request, team_id):
'frozen_amount': float(team.frozen_amount),
'markup_percentage': float(team.markup_percentage),
'daily_member_limit_default': team.daily_member_limit_default,
'max_concurrent_tasks': team.max_concurrent_tasks,
'current_processing': GenerationRecord.objects.filter(
user__team=team, status__in=['queued', 'processing'],
).count(),
'member_count': team.members.count(),
'is_active': team.is_active,
'expected_regions': team.expected_regions,
@ -1124,8 +1169,8 @@ def admin_users_list_view(request):
today = timezone.now().date()
first_of_month = today.replace(day=1)
page = int(request.query_params.get('page', 1))
page_size = min(int(request.query_params.get('page_size', 20)), 100)
page = _safe_int(request.query_params.get('page', 1), 1)
page_size = min(_safe_int(request.query_params.get('page_size', 20), 20), 100)
search = request.query_params.get('search', '').strip()
status_filter = request.query_params.get('status', '').strip()
team_id = request.query_params.get('team_id', '').strip()
@ -1156,6 +1201,10 @@ def admin_users_list_view(request):
filter=Q(generation_records__created_at__date__gte=first_of_month),
),
is_online=Exists(ActiveSession.objects.filter(user_id=OuterRef('pk'))),
total_spent_all=Sum(
'generation_records__cost_amount',
filter=Q(generation_records__status='completed'),
),
)
if search:
@ -1165,7 +1214,7 @@ def admin_users_list_view(request):
elif status_filter == 'disabled':
qs = qs.filter(is_active=False)
if team_id:
qs = qs.filter(team_id=int(team_id))
qs = qs.filter(team_id=_safe_int(team_id))
total = qs.count()
offset = (page - 1) * page_size
@ -1194,6 +1243,8 @@ def admin_users_list_view(request):
'generations_this_month': u.generations_this_month or 0,
'spent_today': float(u.spent_today or 0),
'spent_this_month': float(u.spent_this_month or 0),
'spending_limit': float(u.spending_limit),
'total_spent': float(u.total_spent_all or 0),
'is_online': u.is_online,
})
@ -1306,15 +1357,21 @@ def admin_user_quota_view(request, user_id):
before = {
'daily_generation_limit': user.daily_generation_limit,
'monthly_generation_limit': user.monthly_generation_limit,
'spending_limit': float(user.spending_limit),
}
update_fields = ['daily_generation_limit', 'monthly_generation_limit']
user.daily_generation_limit = serializer.validated_data['daily_generation_limit']
user.monthly_generation_limit = serializer.validated_data['monthly_generation_limit']
user.save(update_fields=['daily_generation_limit', 'monthly_generation_limit'])
if 'spending_limit' in serializer.validated_data:
user.spending_limit = serializer.validated_data['spending_limit']
update_fields.append('spending_limit')
user.save(update_fields=update_fields)
log_admin_action(request, 'user_quota_update', 'user', target_id=user.id, target_name=user.username,
before=before,
after={
'daily_generation_limit': user.daily_generation_limit,
'monthly_generation_limit': user.monthly_generation_limit,
'spending_limit': float(user.spending_limit),
})
return Response({
@ -1322,6 +1379,7 @@ def admin_user_quota_view(request, user_id):
'username': user.username,
'daily_generation_limit': user.daily_generation_limit,
'monthly_generation_limit': user.monthly_generation_limit,
'spending_limit': float(user.spending_limit),
'daily_seconds_limit': user.daily_seconds_limit,
'monthly_seconds_limit': user.monthly_seconds_limit,
'updated_at': timezone.now().isoformat(),
@ -1431,8 +1489,8 @@ def admin_create_user_view(request):
@permission_classes([IsSuperAdmin])
def admin_records_view(request):
"""GET /api/v1/admin/records"""
page = int(request.query_params.get('page', 1))
page_size = min(int(request.query_params.get('page_size', 20)), 100)
page = _safe_int(request.query_params.get('page', 1), 1)
page_size = min(_safe_int(request.query_params.get('page_size', 20), 20), 100)
search = request.query_params.get('search', '').strip()
start_date = request.query_params.get('start_date', '').strip()
end_date = request.query_params.get('end_date', '').strip()
@ -1447,7 +1505,7 @@ def admin_records_view(request):
if end_date:
qs = qs.filter(created_at__date__lte=end_date)
if team_id:
qs = qs.filter(user__team_id=int(team_id))
qs = qs.filter(user__team_id=_safe_int(team_id))
total = qs.count()
offset = (page - 1) * page_size
@ -1481,6 +1539,62 @@ def admin_records_view(request):
})
# ──────────────────────────────────────────────
# Team Admin: Consumption Records
# ──────────────────────────────────────────────
@api_view(['GET'])
@permission_classes([IsTeamAdmin])
def team_records_view(request):
"""GET /api/v1/team/records — 团管查看本团队消费记录"""
team = request.user.team
page = _safe_int(request.query_params.get('page', 1), 1)
page_size = min(_safe_int(request.query_params.get('page_size', 20), 20), 100)
search = request.query_params.get('search', '').strip()
start_date = request.query_params.get('start_date', '').strip()
end_date = request.query_params.get('end_date', '').strip()
qs = GenerationRecord.objects.filter(
user__team=team
).select_related('user').order_by('-created_at')
if search:
qs = qs.filter(user__username__icontains=search)
if start_date:
qs = qs.filter(created_at__date__gte=start_date)
if end_date:
qs = qs.filter(created_at__date__lte=end_date)
total = qs.count()
offset = (page - 1) * page_size
records = _eval_qs(qs[offset:offset + page_size])
results = []
for r in records:
results.append({
'id': r.id,
'created_at': r.created_at.isoformat(),
'user_id': r.user_id,
'username': r.user.username,
'seconds_consumed': r.seconds_consumed,
'tokens_consumed': r.tokens_consumed,
'cost_amount': float(r.cost_amount),
'prompt': r.prompt,
'mode': r.mode,
'model': r.model,
'aspect_ratio': r.aspect_ratio,
'status': r.status,
'error_message': r.error_message or '',
})
return Response({
'total': total,
'page': page,
'page_size': page_size,
'results': results,
})
# ──────────────────────────────────────────────
# Admin: System Settings
# ──────────────────────────────────────────────
@ -1548,8 +1662,8 @@ def admin_settings_view(request):
@permission_classes([IsSuperAdmin])
def admin_login_anomalies_view(request):
"""GET /api/v1/admin/anomalies — Login anomaly records list."""
page = int(request.query_params.get('page', 1))
page_size = min(int(request.query_params.get('page_size', 20)), 100)
page = _safe_int(request.query_params.get('page', 1), 1)
page_size = min(_safe_int(request.query_params.get('page_size', 20), 20), 100)
team_id = request.query_params.get('team_id', '').strip()
rule = request.query_params.get('rule', '').strip()
level = request.query_params.get('level', '').strip()
@ -1559,7 +1673,7 @@ def admin_login_anomalies_view(request):
qs = LoginAnomaly.objects.select_related('team', 'user', 'login_record').all()
if team_id:
qs = qs.filter(team_id=int(team_id))
qs = qs.filter(team_id=_safe_int(team_id))
if rule:
qs = qs.filter(rule=rule)
if level:
@ -1694,8 +1808,8 @@ def admin_team_apply_learned_regions_view(request, team_id):
@permission_classes([IsSuperAdmin])
def admin_audit_logs_view(request):
"""GET /api/v1/admin/logs — Query admin audit logs."""
page = int(request.query_params.get('page', 1))
page_size = min(int(request.query_params.get('page_size', 20)), 100)
page = _safe_int(request.query_params.get('page', 1), 1)
page_size = min(_safe_int(request.query_params.get('page_size', 20), 20), 100)
action = request.query_params.get('action', '').strip()
operator = request.query_params.get('operator', '').strip()
start_date = request.query_params.get('start_date', '').strip()
@ -1896,6 +2010,10 @@ def team_members_list_view(request):
filter=Q(generation_records__created_at__date__gte=first_of_month),
),
is_online=Exists(ActiveSession.objects.filter(user_id=OuterRef('pk'))),
total_spent_all=Sum(
'generation_records__cost_amount',
filter=Q(generation_records__status='completed'),
),
).order_by('-date_joined')
return Response({
@ -1915,6 +2033,8 @@ def team_members_list_view(request):
'generations_this_month': m.generations_this_month or 0,
'spent_today': float(m.spent_today or 0),
'spent_this_month': float(m.spent_this_month or 0),
'spending_limit': float(m.spending_limit),
'total_spent': float(m.total_spent_all or 0),
'is_online': m.is_online,
'date_joined': m.date_joined.isoformat(),
} for m in members],
@ -2060,15 +2180,21 @@ def team_member_quota_view(request, member_id):
before = {
'daily_generation_limit': member.daily_generation_limit,
'monthly_generation_limit': member.monthly_generation_limit,
'spending_limit': float(member.spending_limit),
}
update_fields = ['daily_generation_limit', 'monthly_generation_limit']
member.daily_generation_limit = serializer.validated_data['daily_generation_limit']
member.monthly_generation_limit = serializer.validated_data['monthly_generation_limit']
member.save(update_fields=['daily_generation_limit', 'monthly_generation_limit'])
if 'spending_limit' in serializer.validated_data:
member.spending_limit = serializer.validated_data['spending_limit']
update_fields.append('spending_limit')
member.save(update_fields=update_fields)
log_admin_action(request, 'member_quota_update', 'user', target_id=member.id, target_name=member.username,
before=before,
after={
'daily_generation_limit': member.daily_generation_limit,
'monthly_generation_limit': member.monthly_generation_limit,
'spending_limit': float(member.spending_limit),
})
return Response({
@ -2076,6 +2202,7 @@ def team_member_quota_view(request, member_id):
'username': member.username,
'daily_generation_limit': member.daily_generation_limit,
'monthly_generation_limit': member.monthly_generation_limit,
'spending_limit': float(member.spending_limit),
'daily_seconds_limit': member.daily_seconds_limit,
'monthly_seconds_limit': member.monthly_seconds_limit,
})
@ -2237,8 +2364,8 @@ def profile_overview_view(request):
def profile_records_view(request):
"""GET /api/v1/profile/records"""
user = request.user
page = int(request.query_params.get('page', 1))
page_size = min(int(request.query_params.get('page_size', 20)), 100)
page = _safe_int(request.query_params.get('page', 1), 1)
page_size = min(_safe_int(request.query_params.get('page_size', 20), 20), 100)
qs = user.generation_records.order_by('-created_at')
total = qs.count()
@ -2371,8 +2498,8 @@ def admin_assets_user_videos(request, user_id):
except User.DoesNotExist:
return Response({'error': '用户不存在'}, status=status.HTTP_404_NOT_FOUND)
page = int(request.query_params.get('page', 1))
page_size = min(int(request.query_params.get('page_size', 30)), 100)
page = _safe_int(request.query_params.get('page', 1), 1)
page_size = min(_safe_int(request.query_params.get('page_size', 30), 30), 100)
qs = target_user.generation_records.filter(status='completed').order_by('-created_at')
total = qs.count()
@ -2452,8 +2579,8 @@ def team_assets_member_videos(request, member_id):
except User.DoesNotExist:
return Response({'error': '成员不存在'}, status=status.HTTP_404_NOT_FOUND)
page = int(request.query_params.get('page', 1))
page_size = min(int(request.query_params.get('page_size', 30)), 100)
page = _safe_int(request.query_params.get('page', 1), 1)
page_size = min(_safe_int(request.query_params.get('page_size', 30), 30), 100)
qs = member.generation_records.filter(status='completed').order_by('-created_at')
total = qs.count()
@ -2492,8 +2619,8 @@ def team_assets_member_videos(request, member_id):
@permission_classes([IsSuperAdmin])
def admin_login_records_view(request):
"""GET /api/v1/admin/login-records"""
page = int(request.query_params.get('page', 1))
page_size = min(int(request.query_params.get('page_size', 20)), 100)
page = _safe_int(request.query_params.get('page', 1), 1)
page_size = min(_safe_int(request.query_params.get('page_size', 20), 20), 100)
search = request.query_params.get('search', '').strip()
team_id = request.query_params.get('team_id', '').strip()
start_date = request.query_params.get('start_date', '').strip()
@ -2505,7 +2632,7 @@ def admin_login_records_view(request):
if search:
qs = qs.filter(user__username__icontains=search)
if team_id:
qs = qs.filter(team_id=int(team_id))
qs = qs.filter(team_id=_safe_int(team_id))
if start_date:
qs = qs.filter(created_at__date__gte=start_date)
if end_date:

View File

@ -21,6 +21,7 @@ import { AssetsPage } from './pages/AssetsPage';
import { TeamAdminLayout } from './pages/TeamAdminLayout';
import { TeamDashboardPage } from './pages/TeamDashboardPage';
import { TeamMembersPage } from './pages/TeamMembersPage';
import { TeamRecordsPage } from './pages/TeamRecordsPage';
import { AdminAssetsPage } from './pages/AdminAssetsPage';
import { TeamAssetsPage } from './pages/TeamAssetsPage';
@ -96,6 +97,7 @@ export default function App() {
<Route index element={<Navigate to="/team/dashboard" replace />} />
<Route path="dashboard" element={<TeamDashboardPage />} />
<Route path="members" element={<TeamMembersPage />} />
<Route path="records" element={<TeamRecordsPage />} />
<Route path="assets" element={<TeamAssetsPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />

View File

@ -171,7 +171,7 @@ export const adminApi = {
getTeamDetail: (teamId: number) =>
api.get<TeamDetail>(`/admin/teams/${teamId}`),
updateTeam: (teamId: number, data: { name?: string; monthly_seconds_limit?: number; monthly_spending_limit?: number; daily_member_limit_default?: number; markup_percentage?: number; is_active?: boolean; expected_regions?: string; anomaly_config?: Partial<TeamAnomalyConfig> }) =>
updateTeam: (teamId: number, data: { name?: string; monthly_seconds_limit?: number; monthly_spending_limit?: number; daily_member_limit_default?: number; markup_percentage?: number; max_concurrent_tasks?: number; is_active?: boolean; expected_regions?: string; anomaly_config?: Partial<TeamAnomalyConfig> }) =>
api.put(`/admin/teams/${teamId}`, data),
topUpTeam: (teamId: number, amount: number) =>
@ -206,10 +206,11 @@ export const adminApi = {
getUserDetail: (userId: number) =>
api.get<AdminUserDetail>(`/admin/users/${userId}`),
updateUserQuota: (userId: number, daily: number, monthly: number) =>
updateUserQuota: (userId: number, daily: number, monthly: number, spendingLimit?: number) =>
api.put(`/admin/users/${userId}/quota`, {
daily_generation_limit: daily,
monthly_generation_limit: monthly,
...(spendingLimit !== undefined && { spending_limit: spendingLimit }),
}),
updateUserStatus: (userId: number, isActive: boolean) =>
@ -326,10 +327,11 @@ export const teamApi = {
getMemberDetail: (memberId: number) =>
api.get('/team/members/' + memberId),
updateMemberQuota: (memberId: number, daily: number, monthly: number) =>
updateMemberQuota: (memberId: number, daily: number, monthly: number, spendingLimit?: number) =>
api.put(`/team/members/${memberId}/quota`, {
daily_generation_limit: daily,
monthly_generation_limit: monthly,
...(spendingLimit !== undefined && { spending_limit: spendingLimit }),
}),
updateMemberStatus: (memberId: number, isActive: boolean) =>
@ -355,6 +357,16 @@ export const teamApi = {
page_size: number;
results: AssetVideo[];
}>(`/team/assets/member/${memberId}/videos`, { params: { page, page_size: pageSize } }),
// Consumption Records
getRecords: (params: {
page?: number;
page_size?: number;
search?: string;
start_date?: string;
end_date?: string;
} = {}) =>
api.get<{ total: number; page: number; page_size: number; results: AdminRecord[] }>('/team/records', { params }),
};
// Profile APIs

View File

@ -42,12 +42,12 @@
.completed { background: rgba(0, 184, 148, 0.15); color: var(--color-success); }
.failed { background: rgba(231, 76, 60, 0.15); color: var(--color-danger); }
.statusCell { position: relative; }
.statusCell:hover .errorTooltip { opacity: 1; visibility: visible; transform: translateX(-50%) translateY(0); }
.statusCell:hover .errorTooltip { opacity: 1; visibility: visible; transform: translateY(0); }
.errorTooltip {
position: absolute; bottom: calc(100% + 4px); left: 50%; transform: translateX(-50%) translateY(4px);
position: absolute; bottom: calc(100% + 4px); right: 0; transform: translateY(4px);
background: #16161e; border: 1px solid var(--color-border-card); border-radius: 6px;
padding: 6px 10px; font-size: 12px; color: var(--color-danger); white-space: nowrap;
max-width: 300px; overflow: hidden; text-overflow: ellipsis;
padding: 6px 10px; font-size: 12px; color: var(--color-danger); white-space: normal;
max-width: 360px; width: max-content;
opacity: 0; visibility: hidden; transition: all 0.15s; z-index: 10;
pointer-events: none; box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}

View File

@ -7,6 +7,7 @@ import styles from './AdminLayout.module.css';
const navItems = [
{ path: '/team/dashboard', label: '概览', icon: 'M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z' },
{ path: '/team/members', label: '成员管理', icon: 'M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z' },
{ path: '/team/records', label: '消费记录', icon: 'M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-2 10H7v-2h10v2zm0-4H7V7h10v2z' },
{ path: '/team/assets', label: '内容资产', icon: 'M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z' },
];

View File

@ -24,6 +24,7 @@ export function TeamMembersPage() {
const [editMember, setEditMember] = useState<TeamMember | null>(null);
const [editDaily, setEditDaily] = useState('');
const [editMonthly, setEditMonthly] = useState('');
const [editSpendingLimit, setEditSpendingLimit] = useState('');
const fetchMembers = useCallback(async () => {
setLoading(true);
@ -56,12 +57,13 @@ export function TeamMembersPage() {
setEditMember(member);
setEditDaily(String(member.daily_generation_limit ?? 50));
setEditMonthly(String(member.monthly_generation_limit ?? 500));
setEditSpendingLimit(String(member.spending_limit ?? -1));
};
const handleSaveQuota = async () => {
if (!editMember) return;
try {
await teamApi.updateMemberQuota(editMember.id, Number(editDaily), Number(editMonthly));
await teamApi.updateMemberQuota(editMember.id, Number(editDaily), Number(editMonthly), Number(editSpendingLimit));
showToast('配额已更新');
setEditMember(null);
fetchMembers();
@ -119,6 +121,7 @@ export function TeamMembersPage() {
<th></th>
<th></th>
<th></th>
<th></th>
<th>/</th>
<th>/</th>
<th></th>
@ -128,13 +131,13 @@ export function TeamMembersPage() {
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i}>
{Array.from({ length: 8 }).map((_, j) => (
{Array.from({ length: 9 }).map((_, j) => (
<td key={j}><div className={styles.skeletonCell} /></td>
))}
</tr>
))
) : members.length === 0 ? (
<tr><td colSpan={8} className={styles.empty}></td></tr>
<tr><td colSpan={9} className={styles.empty}></td></tr>
) : (
members.map((m) => (
<tr key={m.id}>
@ -160,6 +163,7 @@ export function TeamMembersPage() {
</td>
<td>{formatLimit(m.daily_generation_limit)}</td>
<td>{formatLimit(m.monthly_generation_limit)}</td>
<td>{m.spending_limit === -1 ? '不限' : `¥${m.total_spent.toFixed(2)} / ¥${m.spending_limit.toFixed(2)}`}</td>
<td>{(m.generations_today || 0) + '次 / ' + fmtMoney(m.spent_today)}</td>
<td>{(m.generations_this_month || 0) + '次 / ' + fmtMoney(m.spent_this_month)}</td>
<td>
@ -205,6 +209,10 @@ export function TeamMembersPage() {
<label>-1 </label>
<input type="number" value={editMonthly} onChange={(e) => setEditMonthly(e.target.value)} />
</div>
<div className={styles.formGroup}>
<label>-1 </label>
<input type="number" value={editSpendingLimit} onChange={(e) => setEditSpendingLimit(e.target.value)} />
</div>
<div className={styles.modalActions}>
<button className={styles.cancelBtn} onClick={() => setEditMember(null)}></button>
<button className={styles.saveBtn} onClick={handleSaveQuota}></button>

View File

@ -0,0 +1,170 @@
import { useEffect, useState, useCallback } from 'react';
import { teamApi } from '../lib/api';
import type { AdminRecord } from '../types';
import { showToast } from '../components/Toast';
import { DatePicker } from '../components/DatePicker';
import styles from './RecordsPage.module.css';
export function TeamRecordsPage() {
const [records, setRecords] = useState<AdminRecord[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [loading, setLoading] = useState(true);
const pageSize = 20;
const fetchRecords = useCallback(async () => {
setLoading(true);
try {
const { data } = await teamApi.getRecords({
page, page_size: pageSize, search,
start_date: startDate || undefined,
end_date: endDate || undefined,
});
setRecords(data.results);
setTotal(data.total);
} catch {
showToast('加载消费记录失败');
} finally {
setLoading(false);
}
}, [page, search, startDate, endDate]);
useEffect(() => { fetchRecords(); }, [fetchRecords]);
const handleSearch = () => {
setPage(1);
fetchRecords();
};
const handleExportCSV = async () => {
try {
const { data } = await teamApi.getRecords({
page: 1, page_size: 10000, search,
start_date: startDate || undefined,
end_date: endDate || undefined,
});
const header = '时间,用户名,消费秒数,Tokens,费用(元),提示词,生成模式,状态,失败原因\n';
const rows = data.results.map((r) => {
const prompt = r.prompt.replace(/"/g, '""').replace(/^[=+\-@]/, "'$&");
const modeLabel = r.mode === 'universal' ? '全能参考' : '首尾帧';
const statusLabel = { queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' }[r.status];
const errorMsg = (r.error_message || '').replace(/"/g, '""').replace(/^[=+\-@]/, "'$&");
return `${r.created_at},${r.username},"${r.seconds_consumed}","${r.tokens_consumed || 0}","${(r.cost_amount || 0).toFixed(2)}","${prompt}","${modeLabel}","${statusLabel}","${errorMsg}"`;
}).join('\n');
const blob = new Blob(['\uFEFF' + header + rows], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `团队消费记录_${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
showToast('导出成功');
} catch {
showToast('导出失败');
}
};
const totalPages = Math.ceil(total / pageSize);
const statusMap: Record<string, string> = { queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' };
return (
<div className={styles.page}>
<div className={styles.header}>
<h1 className={styles.title}></h1>
<button className={styles.exportBtn} onClick={handleExportCSV}> CSV</button>
</div>
<div className={styles.filters}>
<input
type="text"
className={styles.searchInput}
placeholder="按用户名搜索..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
<DatePicker value={startDate} onChange={setStartDate} placeholder="开始日期" />
<span className={styles.dateSep}>~</span>
<DatePicker value={endDate} onChange={setEndDate} placeholder="结束日期" />
<button className={styles.searchBtn} onClick={handleSearch}></button>
</div>
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th>Tokens</th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i}>
{Array.from({ length: 8 }).map((_, j) => (
<td key={j}><div className={styles.skeletonCell} /></td>
))}
</tr>
))
) : records.length === 0 ? (
<tr><td colSpan={8} className={styles.empty}></td></tr>
) : (
records.map((r) => (
<tr key={r.id}>
<td className={styles.timeCell}>{new Date(r.created_at).toLocaleString('zh-CN')}</td>
<td>{r.username}</td>
<td><span className={styles.secondsBadge}>{r.seconds_consumed.toLocaleString()}s</span></td>
<td>{(r.tokens_consumed || 0).toLocaleString()}</td>
<td>¥{(r.cost_amount || 0).toFixed(2)}</td>
<td className={styles.promptCell}>{r.prompt ? (r.prompt.slice(0, 40) + (r.prompt.length > 40 ? '...' : '')) : '-'}</td>
<td>{r.mode === 'universal' ? '全能参考' : '首尾帧'}</td>
<td className={r.status === 'failed' && r.error_message ? styles.statusCell : undefined}>
<span className={`${styles.statusBadge} ${styles[r.status]}`}>
{statusMap[r.status]}
</span>
{r.status === 'failed' && r.error_message && (
<span className={styles.errorTooltip}>{r.error_message}</span>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className={styles.pagination}>
<span className={styles.pageInfo}> {total} </span>
<div className={styles.pageButtons}>
<button disabled={page <= 1} onClick={() => setPage(page - 1)}>&lt;</button>
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
let p: number;
if (totalPages <= 5) p = i + 1;
else if (page <= 3) p = i + 1;
else if (page >= totalPages - 2) p = totalPages - 4 + i;
else p = page - 2 + i;
return (
<button key={p} className={page === p ? styles.activePage : ''} onClick={() => setPage(p)}>
{p}
</button>
);
})}
<button disabled={page >= totalPages} onClick={() => setPage(page + 1)}>&gt;</button>
</div>
</div>
)}
</div>
);
}

View File

@ -87,6 +87,8 @@ export function TeamsPage() {
const [editMarkupValue, setEditMarkupValue] = useState('');
const [editingAnomalyConfig, setEditingAnomalyConfig] = useState(false);
const [anomalyConfigDraft, setAnomalyConfigDraft] = useState<Record<string, any>>({});
const [editingMaxConcurrent, setEditingMaxConcurrent] = useState(false);
const [editMaxConcurrentValue, setEditMaxConcurrentValue] = useState('');
const resetCreateForm = () => {
setNewName(''); setNewMonthlyLimit('10000'); setNewDailyMemberLimit('50');
@ -212,7 +214,7 @@ export function TeamsPage() {
}
};
const colCount = 9;
const colCount = 10;
return (
<div className={styles.page}>
@ -239,6 +241,7 @@ export function TeamsPage() {
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
@ -268,6 +271,7 @@ export function TeamsPage() {
<td>{fmtMoney(t.monthly_spending_limit)}</td>
<td>{fmtMoney(t.monthly_spent)}</td>
<td>{t.member_count}</td>
<td>{t.current_processing ?? 0}/{t.max_concurrent_tasks}</td>
<td>
<span className={`${styles.statusBadge} ${t.is_active ? styles.active : styles.disabled}`}>
{t.is_active ? '启用' : '禁用'}
@ -556,6 +560,58 @@ export function TeamsPage() {
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>{detailTeam.member_count}</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>
{editingMaxConcurrent ? (
<span style={{ display: 'inline-flex', gap: 6, alignItems: 'center', flexWrap: 'nowrap' }}>
<input
type="number"
value={editMaxConcurrentValue}
onChange={(e) => setEditMaxConcurrentValue(e.target.value)}
style={{ width: 80, padding: '3px 6px', borderRadius: 4, border: '1px solid var(--color-border-card)', background: 'var(--color-bg-page)', color: 'var(--color-text-primary)', fontSize: 13 }}
/>
<button
className={styles.topupBtn}
onClick={async () => {
const val = Number(editMaxConcurrentValue);
if (isNaN(val) || val < 1) { showToast('请输入大于0的整数'); return; }
try {
await adminApi.updateTeam(detailTeam.id, { max_concurrent_tasks: val });
setDetailTeam({ ...detailTeam, max_concurrent_tasks: val });
setTeams(teams.map(t => t.id === detailTeam.id ? { ...t, max_concurrent_tasks: val } : t));
setEditingMaxConcurrent(false);
showToast('并发上限已更新');
} catch (e: any) {
showToast(e.response?.data?.error || '保存失败');
}
}}
style={{ fontSize: 12, padding: '4px 10px', whiteSpace: 'nowrap' }}
>
</button>
<button
className={styles.topupBtn}
onClick={() => setEditingMaxConcurrent(false)}
style={{ fontSize: 12, padding: '4px 10px', whiteSpace: 'nowrap' }}
>
</button>
</span>
) : (
<>
{detailTeam.max_concurrent_tasks}
<button
className={styles.topupBtn}
onClick={() => { setEditingMaxConcurrent(true); setEditMaxConcurrentValue(String(detailTeam.max_concurrent_tasks || 1)); }}
style={{ fontSize: 12, padding: '4px 10px', marginLeft: 8 }}
>
</button>
</>
)}
</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>{new Date(detailTeam.created_at).toLocaleDateString('zh-CN')}</span>

View File

@ -21,6 +21,7 @@ export function UsersPage() {
const [editUser, setEditUser] = useState<AdminUser | null>(null);
const [editDaily, setEditDaily] = useState('');
const [editMonthly, setEditMonthly] = useState('');
const [editSpendingLimit, setEditSpendingLimit] = useState('');
// User detail drawer
const [detailUser, setDetailUser] = useState<AdminUserDetail | null>(null);
@ -86,12 +87,13 @@ export function UsersPage() {
setEditUser(user);
setEditDaily(String(user.daily_generation_limit ?? 50));
setEditMonthly(String(user.monthly_generation_limit ?? 500));
setEditSpendingLimit(String(user.spending_limit ?? -1));
};
const handleSaveQuota = async () => {
if (!editUser) return;
try {
await adminApi.updateUserQuota(editUser.id, Number(editDaily), Number(editMonthly));
await adminApi.updateUserQuota(editUser.id, Number(editDaily), Number(editMonthly), Number(editSpendingLimit));
showToast('配额已更新');
setEditUser(null);
fetchUsers();
@ -205,6 +207,7 @@ export function UsersPage() {
<th></th>
<th></th>
<th></th>
<th></th>
<th>/</th>
<th>/</th>
<th></th>
@ -214,13 +217,13 @@ export function UsersPage() {
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i}>
{Array.from({ length: 10 }).map((_, j) => (
{Array.from({ length: 11 }).map((_, j) => (
<td key={j}><div className={styles.skeletonCell} /></td>
))}
</tr>
))
) : users.length === 0 ? (
<tr><td colSpan={10} className={styles.empty}></td></tr>
<tr><td colSpan={11} className={styles.empty}></td></tr>
) : (
users.map((u) => (
<tr key={u.id}>
@ -249,6 +252,7 @@ export function UsersPage() {
</td>
<td>{(u.daily_generation_limit ?? -1) === -1 ? '不限' : u.daily_generation_limit + '次'}</td>
<td>{(u.monthly_generation_limit ?? -1) === -1 ? '不限' : u.monthly_generation_limit + '次'}</td>
<td>{(u.spending_limit ?? -1) === -1 ? '不限' : '¥' + (u.total_spent || 0).toFixed(2) + ' / ¥' + (u.spending_limit).toFixed(2)}</td>
<td>{(u.generations_today || 0) + '次 / ¥' + (u.spent_today || 0).toFixed(2)}</td>
<td>{(u.generations_this_month || 0) + '次 / ¥' + (u.spent_this_month || 0).toFixed(2)}</td>
<td>
@ -317,6 +321,10 @@ export function UsersPage() {
<label>-1 </label>
<input type="number" value={editMonthly} onChange={(e) => setEditMonthly(e.target.value)} />
</div>
<div className={styles.formGroup}>
<label>-1 </label>
<input type="number" value={editSpendingLimit} onChange={(e) => setEditSpendingLimit(e.target.value)} />
</div>
<div className={styles.modalActions}>
<button className={styles.cancelBtn} onClick={() => setEditUser(null)}></button>
<button className={styles.saveBtn} onClick={handleSaveQuota}></button>

View File

@ -162,6 +162,8 @@ export interface AdminUser {
generations_this_month: number;
spent_today: number;
spent_this_month: number;
spending_limit: number;
total_spent: number;
is_online?: boolean;
}
@ -272,6 +274,8 @@ export interface Team {
frozen_amount: number;
markup_percentage: number;
daily_member_limit_default: number;
max_concurrent_tasks: number;
current_processing?: number;
member_count: number;
is_active: boolean;
expected_regions: string;
@ -316,6 +320,8 @@ export interface TeamMember {
generations_this_month: number;
spent_today: number;
spent_this_month: number;
spending_limit: number;
total_spent: number;
is_online?: boolean;
date_joined: string;
}