diff --git a/backend/apps/accounts/migrations/0011_team_max_concurrent_tasks_user_spending_limit.py b/backend/apps/accounts/migrations/0011_team_max_concurrent_tasks_user_spending_limit.py new file mode 100644 index 0000000..35698ec --- /dev/null +++ b/backend/apps/accounts/migrations/0011_team_max_concurrent_tasks_user_spending_limit.py @@ -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='用户总消费额度(元)'), + ), + ] diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py index 3373f69..3380135 100644 --- a/backend/apps/accounts/models.py +++ b/backend/apps/accounts/models.py @@ -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='创建时间') diff --git a/backend/apps/generation/serializers.py b/backend/apps/generation/serializers.py index bf7a855..8d27a8d 100644 --- a/backend/apps/generation/serializers.py +++ b/backend/apps/generation/serializers.py @@ -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) diff --git a/backend/apps/generation/urls.py b/backend/apps/generation/urls.py index 99c283b..5420efe 100644 --- a/backend/apps/generation/urls.py +++ b/backend/apps/generation/urls.py @@ -58,6 +58,9 @@ urlpatterns = [ path('team/members//quota', views.team_member_quota_view, name='team_member_quota'), path('team/members//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//videos', views.team_assets_member_videos, name='team_assets_member_videos'), diff --git a/backend/apps/generation/views.py b/backend/apps/generation/views.py index 73a4d9c..024dce0 100644 --- a/backend/apps/generation/views.py +++ b/backend/apps/generation/views.py @@ -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: diff --git a/web/src/App.tsx b/web/src/App.tsx index dabe1f6..e7aa802 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index abc7f04..982f115 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -171,7 +171,7 @@ export const adminApi = { getTeamDetail: (teamId: number) => api.get(`/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 }) => + 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 }) => api.put(`/admin/teams/${teamId}`, data), topUpTeam: (teamId: number, amount: number) => @@ -206,10 +206,11 @@ export const adminApi = { getUserDetail: (userId: number) => api.get(`/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 diff --git a/web/src/pages/RecordsPage.module.css b/web/src/pages/RecordsPage.module.css index 53abb3e..c6806a1 100644 --- a/web/src/pages/RecordsPage.module.css +++ b/web/src/pages/RecordsPage.module.css @@ -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); } diff --git a/web/src/pages/TeamAdminLayout.tsx b/web/src/pages/TeamAdminLayout.tsx index 643b1dc..5d384fb 100644 --- a/web/src/pages/TeamAdminLayout.tsx +++ b/web/src/pages/TeamAdminLayout.tsx @@ -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' }, ]; diff --git a/web/src/pages/TeamMembersPage.tsx b/web/src/pages/TeamMembersPage.tsx index fabe3ea..c930033 100644 --- a/web/src/pages/TeamMembersPage.tsx +++ b/web/src/pages/TeamMembersPage.tsx @@ -24,6 +24,7 @@ export function TeamMembersPage() { const [editMember, setEditMember] = useState(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() { 状态 日生成上限 月生成上限 + 总额度 今日生成/消费 本月生成/消费 操作 @@ -128,13 +131,13 @@ export function TeamMembersPage() { {loading ? ( Array.from({ length: 5 }).map((_, i) => ( - {Array.from({ length: 8 }).map((_, j) => ( + {Array.from({ length: 9 }).map((_, j) => (
))} )) ) : members.length === 0 ? ( - 暂无成员 + 暂无成员 ) : ( members.map((m) => ( @@ -160,6 +163,7 @@ export function TeamMembersPage() { {formatLimit(m.daily_generation_limit)} {formatLimit(m.monthly_generation_limit)} + {m.spending_limit === -1 ? '不限' : `¥${m.total_spent.toFixed(2)} / ¥${m.spending_limit.toFixed(2)}`} {(m.generations_today || 0) + '次 / ' + fmtMoney(m.spent_today)} {(m.generations_this_month || 0) + '次 / ' + fmtMoney(m.spent_this_month)} @@ -205,6 +209,10 @@ export function TeamMembersPage() { setEditMonthly(e.target.value)} />
+
+ + setEditSpendingLimit(e.target.value)} /> +
diff --git a/web/src/pages/TeamRecordsPage.tsx b/web/src/pages/TeamRecordsPage.tsx new file mode 100644 index 0000000..52695d0 --- /dev/null +++ b/web/src/pages/TeamRecordsPage.tsx @@ -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([]); + 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 = { queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' }; + + return ( +
+
+

消费记录

+ +
+ +
+ setSearch(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + /> + + ~ + + +
+ +
+ + + + + + + + + + + + + + + {loading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + {Array.from({ length: 8 }).map((_, j) => ( + + ))} + + )) + ) : records.length === 0 ? ( + + ) : ( + records.map((r) => ( + + + + + + + + + + + )) + )} + +
时间用户名消费秒数Tokens费用视频描述模式状态
暂无记录
{new Date(r.created_at).toLocaleString('zh-CN')}{r.username}{r.seconds_consumed.toLocaleString()}s{(r.tokens_consumed || 0).toLocaleString()}¥{(r.cost_amount || 0).toFixed(2)}{r.prompt ? (r.prompt.slice(0, 40) + (r.prompt.length > 40 ? '...' : '')) : '-'}{r.mode === 'universal' ? '全能参考' : '首尾帧'} + + {statusMap[r.status]} + + {r.status === 'failed' && r.error_message && ( + {r.error_message} + )} +
+
+ + {totalPages > 1 && ( +
+ 共 {total} 条 +
+ + {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 ( + + ); + })} + +
+
+ )} +
+ ); +} diff --git a/web/src/pages/TeamsPage.tsx b/web/src/pages/TeamsPage.tsx index 9d0e9f4..2b0eb24 100644 --- a/web/src/pages/TeamsPage.tsx +++ b/web/src/pages/TeamsPage.tsx @@ -87,6 +87,8 @@ export function TeamsPage() { const [editMarkupValue, setEditMarkupValue] = useState(''); const [editingAnomalyConfig, setEditingAnomalyConfig] = useState(false); const [anomalyConfigDraft, setAnomalyConfigDraft] = useState>({}); + 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 (
@@ -239,6 +241,7 @@ export function TeamsPage() { 月消费限额 本月消费 成员数 + 并发 状态 操作 @@ -268,6 +271,7 @@ export function TeamsPage() { {fmtMoney(t.monthly_spending_limit)} {fmtMoney(t.monthly_spent)} {t.member_count} + {t.current_processing ?? 0}/{t.max_concurrent_tasks} {t.is_active ? '启用' : '禁用'} @@ -556,6 +560,58 @@ export function TeamsPage() { 成员数 {detailTeam.member_count}
+
+ 并发上限 + + {editingMaxConcurrent ? ( + + 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 }} + /> + + + + ) : ( + <> + {detailTeam.max_concurrent_tasks} + + + )} + +
创建时间 {new Date(detailTeam.created_at).toLocaleDateString('zh-CN')} diff --git a/web/src/pages/UsersPage.tsx b/web/src/pages/UsersPage.tsx index d490dca..5fae861 100644 --- a/web/src/pages/UsersPage.tsx +++ b/web/src/pages/UsersPage.tsx @@ -21,6 +21,7 @@ export function UsersPage() { const [editUser, setEditUser] = useState(null); const [editDaily, setEditDaily] = useState(''); const [editMonthly, setEditMonthly] = useState(''); + const [editSpendingLimit, setEditSpendingLimit] = useState(''); // User detail drawer const [detailUser, setDetailUser] = useState(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() { 状态 日生成上限 月生成上限 + 总额度 今日生成/消费 本月生成/消费 操作 @@ -214,13 +217,13 @@ export function UsersPage() { {loading ? ( Array.from({ length: 5 }).map((_, i) => ( - {Array.from({ length: 10 }).map((_, j) => ( + {Array.from({ length: 11 }).map((_, j) => (
))} )) ) : users.length === 0 ? ( - 暂无数据 + 暂无数据 ) : ( users.map((u) => ( @@ -249,6 +252,7 @@ export function UsersPage() { {(u.daily_generation_limit ?? -1) === -1 ? '不限' : u.daily_generation_limit + '次'} {(u.monthly_generation_limit ?? -1) === -1 ? '不限' : u.monthly_generation_limit + '次'} + {(u.spending_limit ?? -1) === -1 ? '不限' : '¥' + (u.total_spent || 0).toFixed(2) + ' / ¥' + (u.spending_limit).toFixed(2)} {(u.generations_today || 0) + '次 / ¥' + (u.spent_today || 0).toFixed(2)} {(u.generations_this_month || 0) + '次 / ¥' + (u.spent_this_month || 0).toFixed(2)} @@ -317,6 +321,10 @@ export function UsersPage() { setEditMonthly(e.target.value)} />
+
+ + setEditSpendingLimit(e.target.value)} /> +
diff --git a/web/src/types/index.ts b/web/src/types/index.ts index ead7e06..dea4d59 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -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; }