From add3af7904529c8745256737b83b76baad8a2150 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Sun, 15 Mar 2026 20:16:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v0.7.0=20=E2=80=94=20=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=20+=20=E7=A7=92=E6=95=B0=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=20+=20=E5=BC=B9=E7=AA=97=E6=8B=96=E6=8B=BD?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20+=20=E5=9B=A2=E9=98=9F=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E5=AE=8C=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ConfirmModal 组件,为6处危险操作添加二次确认弹窗 (禁用团队/用户/成员、删除视频×3处) - 所有秒数显示统一为千位分隔符+s后缀(如 36,000s) - 修复 modal/drawer 在 input 中拖拽导致误关闭的 bug (onClick → onMouseDown + e.target === e.currentTarget) - 团队模型完善:三种角色(超管/团管/成员)、四层额度检查、 团管成员管理页、超管团队管理页 - 关闭公开注册,所有账号由管理员创建 Co-Authored-By: Claude Opus 4.6 --- backend/apps/accounts/admin.py | 18 +- ...003_add_team_model_and_user_team_fields.py | 42 + .../0004_data_migrate_default_team.py | 59 ++ backend/apps/accounts/models.py | 40 +- backend/apps/accounts/permissions.py | 45 ++ backend/apps/accounts/serializers.py | 5 +- backend/apps/accounts/views.py | 48 +- backend/apps/generation/serializers.py | 47 +- backend/apps/generation/urls.py | 29 +- backend/apps/generation/views.py | 738 +++++++++++++++--- web/src/App.tsx | 27 +- web/src/components/ConfirmModal.module.css | 8 + web/src/components/ConfirmModal.tsx | 28 + web/src/components/GenerationCard.tsx | 14 +- web/src/components/ProtectedRoute.tsx | 15 +- web/src/components/Sidebar.tsx | 91 ++- web/src/components/VideoDetailModal.tsx | 19 +- web/src/components/VideoGenerationPage.tsx | 24 + web/src/lib/api.ts | 59 +- web/src/pages/AdminLayout.tsx | 1 + web/src/pages/AssetsPage.tsx | 23 +- web/src/pages/ProfilePage.tsx | 10 +- web/src/pages/RecordsPage.tsx | 2 +- web/src/pages/RegisterPage.tsx | 115 +-- web/src/pages/TeamAdminLayout.tsx | 85 ++ web/src/pages/TeamDashboardPage.tsx | 171 ++++ web/src/pages/TeamMembersPage.tsx | 241 ++++++ web/src/pages/TeamsPage.module.css | 92 +++ web/src/pages/TeamsPage.tsx | 400 ++++++++++ web/src/pages/UsersPage.tsx | 42 +- web/src/store/auth.ts | 32 +- web/src/types/index.ts | 69 +- 32 files changed, 2301 insertions(+), 338 deletions(-) create mode 100644 backend/apps/accounts/migrations/0003_add_team_model_and_user_team_fields.py create mode 100644 backend/apps/accounts/migrations/0004_data_migrate_default_team.py create mode 100644 backend/apps/accounts/permissions.py create mode 100644 web/src/components/ConfirmModal.module.css create mode 100644 web/src/components/ConfirmModal.tsx create mode 100644 web/src/pages/TeamAdminLayout.tsx create mode 100644 web/src/pages/TeamDashboardPage.tsx create mode 100644 web/src/pages/TeamMembersPage.tsx create mode 100644 web/src/pages/TeamsPage.module.css create mode 100644 web/src/pages/TeamsPage.tsx diff --git a/backend/apps/accounts/admin.py b/backend/apps/accounts/admin.py index 0e89757..713bc04 100644 --- a/backend/apps/accounts/admin.py +++ b/backend/apps/accounts/admin.py @@ -1,15 +1,27 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from django.contrib.auth import get_user_model +from .models import Team User = get_user_model() +@admin.register(Team) +class TeamAdmin(admin.ModelAdmin): + list_display = ('name', 'total_seconds_pool', 'total_seconds_used', 'monthly_seconds_limit', 'is_active', 'member_count', 'created_at') + list_filter = ('is_active',) + search_fields = ('name',) + + def member_count(self, obj): + return obj.members.count() + member_count.short_description = '成员数' + + @admin.register(User) class UserAdmin(BaseUserAdmin): - list_display = ('username', 'email', 'daily_seconds_limit', 'monthly_seconds_limit', 'is_staff', 'date_joined') - list_filter = ('is_staff', 'is_active') + list_display = ('username', 'email', 'team', 'is_team_admin', 'daily_seconds_limit', 'monthly_seconds_limit', 'is_staff', 'date_joined') + list_filter = ('is_staff', 'is_active', 'is_team_admin', 'team') search_fields = ('username', 'email') fieldsets = BaseUserAdmin.fieldsets + ( - ('配额设置(秒数)', {'fields': ('daily_seconds_limit', 'monthly_seconds_limit')}), + ('团队与配额', {'fields': ('team', 'is_team_admin', 'daily_seconds_limit', 'monthly_seconds_limit')}), ) diff --git a/backend/apps/accounts/migrations/0003_add_team_model_and_user_team_fields.py b/backend/apps/accounts/migrations/0003_add_team_model_and_user_team_fields.py new file mode 100644 index 0000000..ee9f1ab --- /dev/null +++ b/backend/apps/accounts/migrations/0003_add_team_model_and_user_team_fields.py @@ -0,0 +1,42 @@ +# Generated by Django 4.2.29 on 2026-03-15 11:04 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_remove_user_daily_limit_remove_user_monthly_limit_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Team', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True, verbose_name='团队名称')), + ('total_seconds_pool', models.BigIntegerField(default=0, verbose_name='总额度池(秒)')), + ('total_seconds_used', models.FloatField(default=0, verbose_name='已消耗总秒数')), + ('monthly_seconds_limit', models.IntegerField(default=6000, verbose_name='每月消费上限(秒)')), + ('daily_member_limit_default', models.IntegerField(default=600, verbose_name='新成员默认每日限额(秒)')), + ('is_active', models.BooleanField(default=True, verbose_name='启用状态')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ], + options={ + 'verbose_name': '团队', + 'verbose_name_plural': '团队', + }, + ), + migrations.AddField( + model_name='user', + name='is_team_admin', + field=models.BooleanField(default=False, verbose_name='团队管理员'), + ), + migrations.AddField( + model_name='user', + name='team', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='members', to='accounts.team', verbose_name='所属团队'), + ), + ] diff --git a/backend/apps/accounts/migrations/0004_data_migrate_default_team.py b/backend/apps/accounts/migrations/0004_data_migrate_default_team.py new file mode 100644 index 0000000..d8c5b1a --- /dev/null +++ b/backend/apps/accounts/migrations/0004_data_migrate_default_team.py @@ -0,0 +1,59 @@ +# Generated by Django 4.2.29 on 2026-03-15 11:04 + +from django.db import migrations + + +def create_default_team(apps, schema_editor): + """Create a default team and assign all non-staff users to it.""" + Team = apps.get_model('accounts', 'Team') + User = apps.get_model('accounts', 'User') + QuotaConfig = apps.get_model('generation', 'QuotaConfig') + + # Read defaults from QuotaConfig if it exists + daily_default = 600 + monthly_default = 6000 + try: + config = QuotaConfig.objects.get(pk=1) + daily_default = config.default_daily_seconds_limit + monthly_default = config.default_monthly_seconds_limit + except QuotaConfig.DoesNotExist: + pass + + # Calculate total seconds already consumed by non-staff users + GenerationRecord = apps.get_model('generation', 'GenerationRecord') + from django.db.models import Sum + total_used = GenerationRecord.objects.filter( + user__is_staff=False + ).aggregate(total=Sum('seconds_consumed'))['total'] or 0 + + # Create default team with a generous initial pool + team = Team.objects.create( + name='默认团队', + total_seconds_pool=max(int(total_used) + 36000, 36000), # used + 10 hours buffer + total_seconds_used=total_used, + monthly_seconds_limit=monthly_default, + daily_member_limit_default=daily_default, + ) + + # Assign all non-staff users to the default team + User.objects.filter(is_staff=False).update(team=team) + + +def reverse_migration(apps, schema_editor): + """Remove default team assignment.""" + User = apps.get_model('accounts', 'User') + Team = apps.get_model('accounts', 'Team') + User.objects.filter(team__name='默认团队').update(team=None) + Team.objects.filter(name='默认团队').delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_add_team_model_and_user_team_fields'), + ('generation', '0003_generationrecord_ark_task_id_and_more'), + ] + + operations = [ + migrations.RunPython(create_default_team, reverse_migration), + ] diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py index 539fa99..de4cdf5 100644 --- a/backend/apps/accounts/models.py +++ b/backend/apps/accounts/models.py @@ -2,9 +2,39 @@ from django.contrib.auth.models import AbstractUser from django.db import models +class Team(models.Model): + """团队模型 — 额度管理的核心单位。""" + name = models.CharField(max_length=100, unique=True, verbose_name='团队名称') + total_seconds_pool = models.BigIntegerField(default=0, verbose_name='总额度池(秒)') + total_seconds_used = models.FloatField(default=0, verbose_name='已消耗总秒数') + monthly_seconds_limit = models.IntegerField(default=6000, verbose_name='每月消费上限(秒)') + daily_member_limit_default = models.IntegerField(default=600, verbose_name='新成员默认每日限额(秒)') + is_active = models.BooleanField(default=True, verbose_name='启用状态') + created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') + updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间') + + class Meta: + verbose_name = '团队' + verbose_name_plural = '团队' + + def __str__(self): + return self.name + + @property + def remaining_seconds(self): + return self.total_seconds_pool - self.total_seconds_used + + class User(AbstractUser): - """Extended user model — Phase 3: quota in seconds.""" + """Extended user model — Phase 5: team-based quota.""" email = models.EmailField(unique=True, verbose_name='邮箱') + team = models.ForeignKey( + Team, on_delete=models.SET_NULL, + null=True, blank=True, + related_name='members', + verbose_name='所属团队', + ) + is_team_admin = models.BooleanField(default=False, verbose_name='团队管理员') daily_seconds_limit = models.IntegerField(default=600, verbose_name='每日秒数上限') monthly_seconds_limit = models.IntegerField(default=6000, verbose_name='每月秒数上限') created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') @@ -16,3 +46,11 @@ class User(AbstractUser): def __str__(self): return self.username + + @property + def role(self): + if self.is_staff and self.team is None: + return 'super_admin' + if self.is_team_admin and self.team is not None: + return 'team_admin' + return 'member' diff --git a/backend/apps/accounts/permissions.py b/backend/apps/accounts/permissions.py new file mode 100644 index 0000000..7a8da9f --- /dev/null +++ b/backend/apps/accounts/permissions.py @@ -0,0 +1,45 @@ +from rest_framework.permissions import BasePermission + + +class IsSuperAdmin(BasePermission): + """超级管理员:is_staff=True 且 team=NULL""" + def has_permission(self, request, view): + return ( + request.user + and request.user.is_authenticated + and request.user.is_staff + and request.user.team is None + ) + + +class IsTeamAdmin(BasePermission): + """团队管理员:is_team_admin=True 且 team≠NULL""" + def has_permission(self, request, view): + return ( + request.user + and request.user.is_authenticated + and request.user.is_team_admin + and request.user.team is not None + ) + + +class IsTeamAdminOrSuperAdmin(BasePermission): + """团队管理员或超级管理员""" + def has_permission(self, request, view): + if not (request.user and request.user.is_authenticated): + return False + if request.user.is_staff and request.user.team is None: + return True + if request.user.is_team_admin and request.user.team is not None: + return True + return False + + +class IsTeamMember(BasePermission): + """团队成员(含团管):team≠NULL""" + def has_permission(self, request, view): + return ( + request.user + and request.user.is_authenticated + and request.user.team is not None + ) diff --git a/backend/apps/accounts/serializers.py b/backend/apps/accounts/serializers.py index c815fb8..c028009 100644 --- a/backend/apps/accounts/serializers.py +++ b/backend/apps/accounts/serializers.py @@ -6,9 +6,12 @@ User = get_user_model() class UserSerializer(serializers.ModelSerializer): + role = serializers.CharField(read_only=True) + team_name = serializers.CharField(source='team.name', read_only=True, default=None) + class Meta: model = User - fields = ('id', 'username', 'email', 'is_staff') + fields = ('id', 'username', 'email', 'is_staff', 'is_team_admin', 'role', 'team_name') class RegisterSerializer(serializers.Serializer): diff --git a/backend/apps/accounts/views.py b/backend/apps/accounts/views.py index e9fc022..a50b6f6 100644 --- a/backend/apps/accounts/views.py +++ b/backend/apps/accounts/views.py @@ -7,7 +7,7 @@ from django.contrib.auth import authenticate, get_user_model from django.utils import timezone from django.db.models import Sum -from .serializers import RegisterSerializer, UserSerializer +from .serializers import UserSerializer User = get_user_model() @@ -15,19 +15,11 @@ User = get_user_model() @api_view(['POST']) @permission_classes([AllowAny]) def register_view(request): - """POST /api/v1/auth/register""" - serializer = RegisterSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - user = serializer.save() - - refresh = RefreshToken.for_user(user) - return Response({ - 'user': UserSerializer(user).data, - 'tokens': { - 'access': str(refresh.access_token), - 'refresh': str(refresh), - } - }, status=status.HTTP_201_CREATED) + """POST /api/v1/auth/register — disabled, all accounts created by admins.""" + return Response( + {'error': 'registration_disabled', 'message': '公开注册已关闭,请联系管理员'}, + status=status.HTTP_403_FORBIDDEN, + ) @api_view(['GET', 'POST']) @@ -69,7 +61,7 @@ def login_view(request): @api_view(['GET']) @permission_classes([IsAuthenticated]) def me_view(request): - """GET /api/v1/auth/me — Phase 3: returns seconds-based quota""" + """GET /api/v1/auth/me — returns role, team info, and quota.""" user = request.user today = timezone.now().date() first_of_month = today.replace(day=1) @@ -89,4 +81,30 @@ def me_view(request): 'monthly_seconds_limit': user.monthly_seconds_limit, 'monthly_seconds_used': monthly_seconds_used, } + + # Team info + team = user.team + if team: + # Team monthly consumption + from apps.generation.models import GenerationRecord + team_monthly_used = GenerationRecord.objects.filter( + user__team=team, + created_at__date__gte=first_of_month, + ).aggregate(total=Sum('seconds_consumed'))['total'] or 0 + + data['team'] = { + 'id': team.id, + 'name': team.name, + 'total_seconds_pool': team.total_seconds_pool, + 'total_seconds_used': team.total_seconds_used, + 'remaining_seconds': team.remaining_seconds, + 'monthly_seconds_limit': team.monthly_seconds_limit, + 'monthly_seconds_used': team_monthly_used, + 'is_active': team.is_active, + } + data['team_disabled'] = not team.is_active + else: + data['team'] = None + data['team_disabled'] = False + return Response(data) diff --git a/backend/apps/generation/serializers.py b/backend/apps/generation/serializers.py index 657a853..8131ac2 100644 --- a/backend/apps/generation/serializers.py +++ b/backend/apps/generation/serializers.py @@ -11,8 +11,8 @@ class VideoGenerateSerializer(serializers.Serializer): class QuotaUpdateSerializer(serializers.Serializer): - daily_seconds_limit = serializers.IntegerField(min_value=0) - monthly_seconds_limit = serializers.IntegerField(min_value=0) + daily_seconds_limit = serializers.IntegerField(min_value=-1) + monthly_seconds_limit = serializers.IntegerField(min_value=-1) class UserStatusSerializer(serializers.Serializer): @@ -23,8 +23,8 @@ class AdminCreateUserSerializer(serializers.Serializer): username = serializers.CharField(max_length=150) email = serializers.EmailField() password = serializers.CharField(min_length=6) - daily_seconds_limit = serializers.IntegerField(min_value=0, required=False, default=600) - monthly_seconds_limit = serializers.IntegerField(min_value=0, required=False, default=6000) + daily_seconds_limit = serializers.IntegerField(min_value=-1, required=False, default=600) + monthly_seconds_limit = serializers.IntegerField(min_value=-1, required=False, default=6000) is_staff = serializers.BooleanField(required=False, default=False) @@ -33,3 +33,42 @@ class SystemSettingsSerializer(serializers.Serializer): default_monthly_seconds_limit = serializers.IntegerField(min_value=0) announcement = serializers.CharField(required=False, allow_blank=True, default='') announcement_enabled = serializers.BooleanField(required=False, default=False) + + +# ── Team serializers ── + +class TeamCreateSerializer(serializers.Serializer): + name = serializers.CharField(max_length=100) + monthly_seconds_limit = serializers.IntegerField(min_value=0, required=False, default=6000) + daily_member_limit_default = serializers.IntegerField(min_value=0, required=False, default=600) + + +class TeamUpdateSerializer(serializers.Serializer): + name = serializers.CharField(max_length=100, required=False) + monthly_seconds_limit = serializers.IntegerField(min_value=0, required=False) + daily_member_limit_default = serializers.IntegerField(min_value=0, required=False) + is_active = serializers.BooleanField(required=False) + + +class TeamTopUpSerializer(serializers.Serializer): + seconds = serializers.IntegerField(min_value=1) + + +class TeamAdminCreateSerializer(serializers.Serializer): + """Create a team admin account for a specific team.""" + username = serializers.CharField(max_length=150) + email = serializers.EmailField() + password = serializers.CharField(min_length=6) + + +class TeamMemberCreateSerializer(serializers.Serializer): + """Team admin creates a member.""" + username = serializers.CharField(max_length=150) + password = serializers.CharField(min_length=6) + daily_seconds_limit = serializers.IntegerField(min_value=-1, required=False) + monthly_seconds_limit = serializers.IntegerField(min_value=-1, required=False) + + +class MemberQuotaSerializer(serializers.Serializer): + daily_seconds_limit = serializers.IntegerField(min_value=-1) + monthly_seconds_limit = serializers.IntegerField(min_value=-1) diff --git a/backend/apps/generation/urls.py b/backend/apps/generation/urls.py index 4734d25..59cd385 100644 --- a/backend/apps/generation/urls.py +++ b/backend/apps/generation/urls.py @@ -8,19 +8,38 @@ urlpatterns = [ path('video/generate', views.video_generate_view, name='video_generate'), path('video/tasks', views.video_tasks_list_view, name='video_tasks_list'), path('video/tasks/', views.video_task_detail_view, name='video_task_detail'), - # Admin: Dashboard + + # ── Super Admin: Dashboard ── path('admin/stats', views.admin_stats_view, name='admin_stats'), - # Admin: User management + + # ── Super Admin: Team management ── + path('admin/teams', views.admin_teams_list_view, name='admin_teams_list'), + path('admin/teams/create', views.admin_team_create_view, name='admin_team_create'), + path('admin/teams/', views.admin_team_detail_view, name='admin_team_detail'), + path('admin/teams//topup', views.admin_team_topup_view, name='admin_team_topup'), + path('admin/teams//admin', views.admin_team_create_admin_view, name='admin_team_create_admin'), + + # ── Super Admin: User management ── path('admin/users', views.admin_users_list_view, name='admin_users_list'), path('admin/users/create', views.admin_create_user_view, name='admin_create_user'), path('admin/users/', views.admin_user_detail_view, name='admin_user_detail'), path('admin/users//quota', views.admin_user_quota_view, name='admin_user_quota'), path('admin/users//status', views.admin_user_status_view, name='admin_user_status'), - # Admin: Consumption records + + # ── Super Admin: Records & Settings ── path('admin/records', views.admin_records_view, name='admin_records'), - # Admin: System settings path('admin/settings', views.admin_settings_view, name='admin_settings'), - # Profile: User's own data + + # ── Team Admin: Team management ── + path('team/info', views.team_info_view, name='team_info'), + path('team/stats', views.team_stats_view, name='team_stats'), + path('team/members', views.team_members_list_view, name='team_members_list'), + path('team/members/create', views.team_member_create_view, name='team_member_create'), + path('team/members/', views.team_member_detail_view, name='team_member_detail'), + 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'), + + # ── Profile: User's own data ── path('profile/overview', views.profile_overview_view, name='profile_overview'), path('profile/records', views.profile_records_view, name='profile_records'), ] diff --git a/backend/apps/generation/views.py b/backend/apps/generation/views.py index 9f38b33..5ef88d0 100644 --- a/backend/apps/generation/views.py +++ b/backend/apps/generation/views.py @@ -3,11 +3,12 @@ import logging from rest_framework import status from rest_framework.decorators import api_view, permission_classes, parser_classes from rest_framework.parsers import MultiPartParser, JSONParser -from rest_framework.permissions import IsAuthenticated, IsAdminUser +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from django.contrib.auth import get_user_model from django.utils import timezone -from django.db.models import Sum, Q +from django.db import transaction +from django.db.models import Sum, Q, F from django.db.models.functions import TruncDate from django.db.utils import OperationalError as DbOperationalError from datetime import timedelta @@ -17,7 +18,11 @@ from .serializers import ( VideoGenerateSerializer, QuotaUpdateSerializer, UserStatusSerializer, SystemSettingsSerializer, AdminCreateUserSerializer, + TeamCreateSerializer, TeamUpdateSerializer, TeamTopUpSerializer, + TeamAdminCreateSerializer, TeamMemberCreateSerializer, MemberQuotaSerializer, ) +from apps.accounts.models import Team +from apps.accounts.permissions import IsSuperAdmin, IsTeamAdmin, IsTeamMember from utils.tos_client import upload_file as tos_upload from utils.seedance_client import create_task, query_task, extract_video_url, map_status @@ -118,116 +123,134 @@ def upload_media_view(request): # ────────────────────────────────────────────── -# Video Generation (with Seedance API) +# Video Generation (with 4-layer quota check) # ────────────────────────────────────────────── @api_view(['POST']) -@permission_classes([IsAuthenticated]) +@permission_classes([IsTeamMember]) def video_generate_view(request): - """POST /api/v1/video/generate — Create video generation task. - - Accepts JSON: - { - "prompt": "...", - "mode": "universal" | "keyframe", - "model": "seedance_2.0" | "seedance_2.0_fast", - "aspect_ratio": "16:9", - "duration": 10, - "references": [ - {"url": "https://...", "type": "image", "role": "reference_image", "label": "图片1"}, - {"url": "https://...", "type": "video", "role": "reference_video", "label": "视频1"} - ] - } - """ + """POST /api/v1/video/generate — Four-layer quota check + Seedance API.""" serializer = VideoGenerateSerializer(data=request.data) serializer.is_valid(raise_exception=True) user = request.user + team = user.team + + # Pre-check: team disabled + if not team.is_active: + return Response( + {'error': 'team_disabled', 'message': '您的团队已被停用,请联系管理员'}, + status=status.HTTP_403_FORBIDDEN, + ) + today = timezone.now().date() first_of_month = today.replace(day=1) duration = serializer.validated_data['duration'] - # ── Quota check ── - daily_used = user.generation_records.filter( - created_at__date=today - ).aggregate(total=Sum('seconds_consumed'))['total'] or 0 + # ── Layer 1: User daily limit (skip if -1) ── + if user.daily_seconds_limit != -1: + daily_used = user.generation_records.filter( + created_at__date=today + ).aggregate(total=Sum('seconds_consumed'))['total'] or 0 - monthly_used = user.generation_records.filter( - created_at__date__gte=first_of_month - ).aggregate(total=Sum('seconds_consumed'))['total'] or 0 + if daily_used + duration > user.daily_seconds_limit: + return Response({ + 'error': 'quota_exceeded', + 'message': '您今日的生成额度已用完', + 'daily_seconds_limit': user.daily_seconds_limit, + 'daily_seconds_used': daily_used, + 'reset_at': (timezone.now() + timedelta(days=1)).replace( + hour=0, minute=0, second=0, microsecond=0 + ).isoformat(), + }, status=status.HTTP_429_TOO_MANY_REQUESTS) - if daily_used + duration > user.daily_seconds_limit: - return Response({ - 'error': 'quota_exceeded', - 'message': '您今日的生成额度已用完', - 'daily_seconds_limit': user.daily_seconds_limit, - 'daily_seconds_used': daily_used, - '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: User monthly limit (skip if -1) ── + if user.monthly_seconds_limit != -1: + monthly_used = user.generation_records.filter( + created_at__date__gte=first_of_month + ).aggregate(total=Sum('seconds_consumed'))['total'] or 0 - if monthly_used + duration > user.monthly_seconds_limit: - return Response({ - 'error': 'quota_exceeded', - 'message': '您本月的生成额度已用完', - 'monthly_seconds_limit': user.monthly_seconds_limit, - 'monthly_seconds_used': monthly_used, - }, status=status.HTTP_429_TOO_MANY_REQUESTS) + if monthly_used + duration > user.monthly_seconds_limit: + return Response({ + 'error': 'quota_exceeded', + 'message': '您本月的生成额度已用完', + 'monthly_seconds_limit': user.monthly_seconds_limit, + 'monthly_seconds_used': monthly_used, + }, status=status.HTTP_429_TOO_MANY_REQUESTS) - # ── Build Seedance content items from references ── - references = request.data.get('references', []) - content_items = [] - reference_snapshots = [] + # ── Layer 3 & 4: Team checks + pre-deduction (atomic with row lock) ── + with transaction.atomic(): + locked_team = Team.objects.select_for_update().get(pk=team.pk) - for ref in references: - url = ref.get('url', '') - ref_type = ref.get('type', 'image') - role = ref.get('role', '') - label = ref.get('label', '') + # Layer 3: Team monthly limit + team_monthly_used = GenerationRecord.objects.filter( + user__team=locked_team, + created_at__date__gte=first_of_month, + ).aggregate(total=Sum('seconds_consumed'))['total'] or 0 - reference_snapshots.append({ - 'url': url, - 'type': ref_type, - 'role': role, - 'label': label, - }) + if team_monthly_used + duration > locked_team.monthly_seconds_limit: + return Response({ + 'error': 'quota_exceeded', + 'message': '团队本月消费额度已用完', + 'team_monthly_limit': locked_team.monthly_seconds_limit, + 'team_monthly_used': team_monthly_used, + }, status=status.HTTP_429_TOO_MANY_REQUESTS) - if ref_type == 'image': - item = { - 'type': 'image_url', - 'image_url': {'url': url}, - } - if role: - item['role'] = role - content_items.append(item) - elif ref_type == 'video': - item = { - 'type': 'video_url', - 'video_url': {'url': url}, - } - if role: - item['role'] = role - content_items.append(item) + # Layer 4: Team total pool + if locked_team.total_seconds_used + duration > locked_team.total_seconds_pool: + return Response({ + 'error': 'quota_exceeded', + 'message': '团队总额度已用完,请联系管理员充值', + 'team_pool': locked_team.total_seconds_pool, + 'team_used': locked_team.total_seconds_used, + }, status=status.HTTP_429_TOO_MANY_REQUESTS) - # ── Create DB record first ── - prompt = serializer.validated_data['prompt'] - mode = serializer.validated_data['mode'] - model = serializer.validated_data['model'] - aspect_ratio = serializer.validated_data['aspect_ratio'] + # Pre-deduction: create record + update team used + references = request.data.get('references', []) + reference_snapshots = [] + content_items = [] - record = GenerationRecord.objects.create( - user=user, - prompt=prompt, - mode=mode, - model=model, - aspect_ratio=aspect_ratio, - duration=duration, - seconds_consumed=duration, - reference_urls=reference_snapshots, - ) + for ref in references: + url = ref.get('url', '') + ref_type = ref.get('type', 'image') + role = ref.get('role', '') + label = ref.get('label', '') - # ── Call Seedance API (or skip if not enabled) ── + reference_snapshots.append({ + 'url': url, 'type': ref_type, 'role': role, 'label': label, + }) + + if ref_type == 'image': + item = {'type': 'image_url', 'image_url': {'url': url}} + if role: + item['role'] = role + content_items.append(item) + elif ref_type == 'video': + item = {'type': 'video_url', 'video_url': {'url': url}} + if role: + item['role'] = role + content_items.append(item) + + prompt = serializer.validated_data['prompt'] + mode = serializer.validated_data['mode'] + model = serializer.validated_data['model'] + aspect_ratio = serializer.validated_data['aspect_ratio'] + + record = GenerationRecord.objects.create( + user=user, + prompt=prompt, + mode=mode, + model=model, + aspect_ratio=aspect_ratio, + duration=duration, + seconds_consumed=duration, + reference_urls=reference_snapshots, + ) + + locked_team.total_seconds_used = F('total_seconds_used') + duration + locked_team.save(update_fields=['total_seconds_used']) + + # ── Call Seedance API (outside transaction to avoid holding lock) ── from django.conf import settings as django_settings if django_settings.SEEDANCE_ENABLED and django_settings.ARK_API_KEY: try: @@ -247,24 +270,38 @@ def video_generate_view(request): record.status = 'failed' record.error_message = str(e) record.save(update_fields=['status', 'error_message']) + # Refund: API call failed, Seedance didn't charge + _refund_quota(record, duration) else: - # Seedance not enabled — treat as completed immediately record.status = 'completed' record.save(update_fields=['status']) - remaining = user.daily_seconds_limit - daily_used - duration return Response({ 'task_id': str(record.task_id), - 'ark_task_id': record.ark_task_id, + 'ark_task_id': getattr(record, 'ark_task_id', ''), 'status': record.status, 'estimated_time': 120, 'seconds_consumed': duration, - 'remaining_seconds_today': max(remaining, 0), }, status=status.HTTP_202_ACCEPTED) +def _refund_quota(record, seconds): + """Refund pre-deducted seconds to team pool.""" + if record.seconds_consumed == 0: + return # already refunded + team = record.user.team + if not team: + return + with transaction.atomic(): + locked_team = Team.objects.select_for_update().get(pk=team.pk) + locked_team.total_seconds_used = F('total_seconds_used') - seconds + locked_team.save(update_fields=['total_seconds_used']) + record.seconds_consumed = 0 + record.save(update_fields=['seconds_consumed']) + + # ────────────────────────────────────────────── -# Video Tasks: List + Detail (for frontend polling) +# Video Tasks: List + Detail (with failure refund) # ────────────────────────────────────────────── @api_view(['GET']) @@ -284,7 +321,7 @@ def video_tasks_list_view(request): @api_view(['GET', 'DELETE']) @permission_classes([IsAuthenticated]) def video_task_detail_view(request, task_id): - """GET /api/v1/video/tasks/ — Get task status, poll Seedance if active. + """GET /api/v1/video/tasks/ — Poll Seedance + refund on failure. DELETE /api/v1/video/tasks/ — Delete task record.""" try: record = _eval_qs( @@ -315,6 +352,11 @@ def video_task_detail_view(request, task_id): record.error_message = ( error.get('message', '') if isinstance(error, dict) else str(error) ) + # Phase 5: Refund if Seedance didn't charge + usage = ark_resp.get('usage', {}) + total_tokens = usage.get('total_tokens', 0) if isinstance(usage, dict) else 0 + if total_tokens == 0 and record.seconds_consumed > 0: + _refund_quota(record, record.seconds_consumed) record.save(update_fields=['status', 'result_url', 'error_message']) except Exception as e: @@ -349,7 +391,7 @@ def _serialize_task(record): # ────────────────────────────────────────────── @api_view(['GET']) -@permission_classes([IsAdminUser]) +@permission_classes([IsSuperAdmin]) def admin_stats_view(request): """GET /api/v1/admin/stats""" today = timezone.now().date() @@ -358,6 +400,7 @@ def admin_stats_view(request): thirty_days_ago = today - timedelta(days=29) total_users = User.objects.count() + total_teams = Team.objects.count() new_users_today = User.objects.filter(date_joined__date=today).count() seconds_today = GenerationRecord.objects.filter( @@ -416,6 +459,7 @@ def admin_stats_view(request): return Response({ 'total_users': total_users, + 'total_teams': total_teams, 'new_users_today': new_users_today, 'seconds_consumed_today': seconds_today, 'seconds_consumed_this_month': seconds_this_month, @@ -429,12 +473,207 @@ def admin_stats_view(request): }) +# ────────────────────────────────────────────── +# Admin: Team Management (Super Admin only) +# ────────────────────────────────────────────── + +@api_view(['GET']) +@permission_classes([IsSuperAdmin]) +def admin_teams_list_view(request): + """GET /api/v1/admin/teams — List all teams.""" + today = timezone.now().date() + first_of_month = today.replace(day=1) + + teams = Team.objects.all().order_by('-created_at') + results = [] + for t in teams: + monthly_used = GenerationRecord.objects.filter( + user__team=t, + created_at__date__gte=first_of_month, + ).aggregate(total=Sum('seconds_consumed'))['total'] or 0 + + results.append({ + 'id': t.id, + 'name': t.name, + 'total_seconds_pool': t.total_seconds_pool, + 'total_seconds_used': t.total_seconds_used, + 'remaining_seconds': t.remaining_seconds, + 'monthly_seconds_limit': t.monthly_seconds_limit, + 'monthly_seconds_used': monthly_used, + 'daily_member_limit_default': t.daily_member_limit_default, + 'member_count': t.members.count(), + 'is_active': t.is_active, + 'created_at': t.created_at.isoformat(), + }) + + return Response({'results': results}) + + +@api_view(['POST']) +@permission_classes([IsSuperAdmin]) +def admin_team_create_view(request): + """POST /api/v1/admin/teams/create — Create a new team.""" + serializer = TeamCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + name = serializer.validated_data['name'] + if Team.objects.filter(name=name).exists(): + return Response({'error': '团队名称已存在'}, status=status.HTTP_400_BAD_REQUEST) + + team = Team.objects.create(**serializer.validated_data) + return Response({ + 'id': team.id, + 'name': team.name, + 'monthly_seconds_limit': team.monthly_seconds_limit, + 'daily_member_limit_default': team.daily_member_limit_default, + 'created_at': team.created_at.isoformat(), + }, status=status.HTTP_201_CREATED) + + +@api_view(['GET', 'PUT']) +@permission_classes([IsSuperAdmin]) +def admin_team_detail_view(request, team_id): + """GET/PUT /api/v1/admin/teams/ — Team detail + members / update team.""" + try: + team = Team.objects.get(id=team_id) + except Team.DoesNotExist: + return Response({'error': '团队不存在'}, status=status.HTTP_404_NOT_FOUND) + + if request.method == 'PUT': + serializer = TeamUpdateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + for field, value in serializer.validated_data.items(): + setattr(team, field, value) + team.save() + return Response({ + 'id': team.id, + 'name': team.name, + 'monthly_seconds_limit': team.monthly_seconds_limit, + 'daily_member_limit_default': team.daily_member_limit_default, + 'is_active': team.is_active, + 'updated_at': team.updated_at.isoformat(), + }) + + # GET: team detail + members + today = timezone.now().date() + first_of_month = today.replace(day=1) + + monthly_used = GenerationRecord.objects.filter( + user__team=team, + created_at__date__gte=first_of_month, + ).aggregate(total=Sum('seconds_consumed'))['total'] or 0 + + members = team.members.annotate( + seconds_today=Sum( + 'generation_records__seconds_consumed', + filter=Q(generation_records__created_at__date=today), + ), + seconds_this_month=Sum( + 'generation_records__seconds_consumed', + filter=Q(generation_records__created_at__date__gte=first_of_month), + ), + ).order_by('-date_joined') + + return Response({ + 'id': team.id, + 'name': team.name, + 'total_seconds_pool': team.total_seconds_pool, + 'total_seconds_used': team.total_seconds_used, + 'remaining_seconds': team.remaining_seconds, + 'monthly_seconds_limit': team.monthly_seconds_limit, + 'monthly_seconds_used': monthly_used, + 'daily_member_limit_default': team.daily_member_limit_default, + 'is_active': team.is_active, + 'created_at': team.created_at.isoformat(), + 'members': [{ + 'id': m.id, + 'username': m.username, + 'email': m.email, + 'is_team_admin': m.is_team_admin, + 'is_active': m.is_active, + 'daily_seconds_limit': m.daily_seconds_limit, + 'monthly_seconds_limit': m.monthly_seconds_limit, + 'seconds_today': m.seconds_today or 0, + 'seconds_this_month': m.seconds_this_month or 0, + 'date_joined': m.date_joined.isoformat(), + } for m in members], + }) + + +@api_view(['POST']) +@permission_classes([IsSuperAdmin]) +def admin_team_topup_view(request, team_id): + """POST /api/v1/admin/teams//topup — Add seconds to team pool.""" + try: + team = Team.objects.get(id=team_id) + except Team.DoesNotExist: + return Response({'error': '团队不存在'}, status=status.HTTP_404_NOT_FOUND) + + serializer = TeamTopUpSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + seconds = serializer.validated_data['seconds'] + with transaction.atomic(): + locked = Team.objects.select_for_update().get(pk=team.pk) + locked.total_seconds_pool = F('total_seconds_pool') + seconds + locked.save(update_fields=['total_seconds_pool']) + + team.refresh_from_db() + return Response({ + 'id': team.id, + 'name': team.name, + 'total_seconds_pool': team.total_seconds_pool, + 'total_seconds_used': team.total_seconds_used, + 'remaining_seconds': team.remaining_seconds, + 'topped_up': seconds, + }) + + +@api_view(['POST']) +@permission_classes([IsSuperAdmin]) +def admin_team_create_admin_view(request, team_id): + """POST /api/v1/admin/teams//admin — Create team admin account.""" + try: + team = Team.objects.get(id=team_id) + except Team.DoesNotExist: + return Response({'error': '团队不存在'}, status=status.HTTP_404_NOT_FOUND) + + serializer = TeamAdminCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + username = serializer.validated_data['username'] + email = serializer.validated_data['email'] + + if User.objects.filter(username=username).exists(): + return Response({'error': '用户名已存在'}, status=status.HTTP_400_BAD_REQUEST) + if User.objects.filter(email=email).exists(): + return Response({'error': '邮箱已存在'}, status=status.HTTP_400_BAD_REQUEST) + + user = User.objects.create_user( + username=username, + email=email, + password=serializer.validated_data['password'], + team=team, + is_team_admin=True, + daily_seconds_limit=team.daily_member_limit_default, + monthly_seconds_limit=-1, # Team admin unlimited by default + ) + + return Response({ + 'id': user.id, + 'username': user.username, + 'email': user.email, + 'team': team.name, + 'is_team_admin': True, + }, status=status.HTTP_201_CREATED) + + # ────────────────────────────────────────────── # Admin: User Management # ────────────────────────────────────────────── @api_view(['GET']) -@permission_classes([IsAdminUser]) +@permission_classes([IsSuperAdmin]) def admin_users_list_view(request): """GET /api/v1/admin/users""" today = timezone.now().date() @@ -444,8 +683,9 @@ def admin_users_list_view(request): page_size = min(int(request.query_params.get('page_size', 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() - qs = User.objects.annotate( + qs = User.objects.select_related('team').annotate( seconds_today=Sum( 'generation_records__seconds_consumed', filter=Q(generation_records__created_at__date=today), @@ -462,6 +702,8 @@ def admin_users_list_view(request): qs = qs.filter(is_active=True) elif status_filter == 'disabled': qs = qs.filter(is_active=False) + if team_id: + qs = qs.filter(team_id=int(team_id)) total = qs.count() offset = (page - 1) * page_size @@ -474,6 +716,10 @@ def admin_users_list_view(request): 'username': u.username, 'email': u.email, 'is_active': u.is_active, + 'is_staff': u.is_staff, + 'is_team_admin': u.is_team_admin, + 'team_id': u.team_id, + 'team_name': u.team.name if u.team else None, 'date_joined': u.date_joined.isoformat(), 'daily_seconds_limit': u.daily_seconds_limit, 'monthly_seconds_limit': u.monthly_seconds_limit, @@ -490,11 +736,11 @@ def admin_users_list_view(request): @api_view(['GET']) -@permission_classes([IsAdminUser]) +@permission_classes([IsSuperAdmin]) def admin_user_detail_view(request, user_id): """GET /api/v1/admin/users/:id""" try: - user = User.objects.get(id=user_id) + user = User.objects.select_related('team').get(id=user_id) except User.DoesNotExist: return Response({'error': '用户不存在'}, status=status.HTTP_404_NOT_FOUND) @@ -521,6 +767,9 @@ def admin_user_detail_view(request, user_id): 'email': user.email, 'is_active': user.is_active, 'is_staff': user.is_staff, + 'is_team_admin': user.is_team_admin, + 'team_id': user.team_id, + 'team_name': user.team.name if user.team else None, 'date_joined': user.date_joined.isoformat(), 'daily_seconds_limit': user.daily_seconds_limit, 'monthly_seconds_limit': user.monthly_seconds_limit, @@ -543,7 +792,7 @@ def admin_user_detail_view(request, user_id): @api_view(['PUT']) -@permission_classes([IsAdminUser]) +@permission_classes([IsSuperAdmin]) def admin_user_quota_view(request, user_id): """PUT /api/v1/admin/users/:id/quota""" try: @@ -568,7 +817,7 @@ def admin_user_quota_view(request, user_id): @api_view(['PATCH']) -@permission_classes([IsAdminUser]) +@permission_classes([IsSuperAdmin]) def admin_user_status_view(request, user_id): """PATCH /api/v1/admin/users/:id/status""" try: @@ -591,9 +840,9 @@ def admin_user_status_view(request, user_id): @api_view(['POST']) -@permission_classes([IsAdminUser]) +@permission_classes([IsSuperAdmin]) def admin_create_user_view(request): - """POST /api/v1/admin/users — Admin creates a new user""" + """POST /api/v1/admin/users/create — Super admin creates a user.""" serializer = AdminCreateUserSerializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -631,7 +880,7 @@ def admin_create_user_view(request): # ────────────────────────────────────────────── @api_view(['GET']) -@permission_classes([IsAdminUser]) +@permission_classes([IsSuperAdmin]) def admin_records_view(request): """GET /api/v1/admin/records""" page = int(request.query_params.get('page', 1)) @@ -639,8 +888,9 @@ def admin_records_view(request): 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() + team_id = request.query_params.get('team_id', '').strip() - qs = GenerationRecord.objects.select_related('user').order_by('-created_at') + qs = GenerationRecord.objects.select_related('user', 'user__team').order_by('-created_at') if search: qs = qs.filter(user__username__icontains=search) @@ -648,6 +898,8 @@ def admin_records_view(request): qs = qs.filter(created_at__date__gte=start_date) if end_date: qs = qs.filter(created_at__date__lte=end_date) + if team_id: + qs = qs.filter(user__team_id=int(team_id)) total = qs.count() offset = (page - 1) * page_size @@ -660,6 +912,7 @@ def admin_records_view(request): 'created_at': r.created_at.isoformat(), 'user_id': r.user_id, 'username': r.user.username, + 'team_name': r.user.team.name if r.user.team else None, 'seconds_consumed': r.seconds_consumed, 'prompt': r.prompt, 'mode': r.mode, @@ -681,7 +934,7 @@ def admin_records_view(request): # ────────────────────────────────────────────── @api_view(['GET', 'PUT']) -@permission_classes([IsAdminUser]) +@permission_classes([IsSuperAdmin]) def admin_settings_view(request): """GET/PUT /api/v1/admin/settings""" config, _ = QuotaConfig.objects.get_or_create(pk=1) @@ -712,6 +965,252 @@ def admin_settings_view(request): }) +# ────────────────────────────────────────────── +# Team Admin: Team Management +# ────────────────────────────────────────────── + +@api_view(['GET']) +@permission_classes([IsTeamAdmin]) +def team_info_view(request): + """GET /api/v1/team/info — Team basic info for team admin.""" + team = request.user.team + today = timezone.now().date() + first_of_month = today.replace(day=1) + + monthly_used = GenerationRecord.objects.filter( + user__team=team, + created_at__date__gte=first_of_month, + ).aggregate(total=Sum('seconds_consumed'))['total'] or 0 + + return Response({ + 'id': team.id, + 'name': team.name, + 'total_seconds_pool': team.total_seconds_pool, + 'total_seconds_used': team.total_seconds_used, + 'remaining_seconds': team.remaining_seconds, + 'monthly_seconds_limit': team.monthly_seconds_limit, + 'monthly_seconds_used': monthly_used, + 'daily_member_limit_default': team.daily_member_limit_default, + 'member_count': team.members.count(), + 'is_active': team.is_active, + }) + + +@api_view(['GET']) +@permission_classes([IsTeamAdmin]) +def team_stats_view(request): + """GET /api/v1/team/stats — Team consumption overview + member breakdown.""" + team = request.user.team + today = timezone.now().date() + first_of_month = today.replace(day=1) + thirty_days_ago = today - timedelta(days=29) + + # Daily trend + daily_trend_qs = ( + GenerationRecord.objects + .filter(user__team=team, created_at__date__gte=thirty_days_ago) + .annotate(date=TruncDate('created_at')) + .values('date') + .annotate(seconds=Sum('seconds_consumed')) + .order_by('date') + ) + trend_map = {str(item['date']): item['seconds'] or 0 for item in daily_trend_qs} + daily_trend = [] + for i in range(30): + d = thirty_days_ago + timedelta(days=i) + daily_trend.append({'date': str(d), 'seconds': trend_map.get(str(d), 0)}) + + # Member consumption this month + members = team.members.annotate( + seconds_this_month=Sum( + 'generation_records__seconds_consumed', + filter=Q(generation_records__created_at__date__gte=first_of_month), + ), + ).filter(seconds_this_month__gt=0).order_by('-seconds_this_month') + + return Response({ + 'daily_trend': daily_trend, + 'member_consumption': [ + {'user_id': m.id, 'username': m.username, 'seconds_consumed': m.seconds_this_month or 0} + for m in members + ], + }) + + +@api_view(['GET']) +@permission_classes([IsTeamAdmin]) +def team_members_list_view(request): + """GET /api/v1/team/members — List team members.""" + team = request.user.team + today = timezone.now().date() + first_of_month = today.replace(day=1) + + members = team.members.annotate( + seconds_today=Sum( + 'generation_records__seconds_consumed', + filter=Q(generation_records__created_at__date=today), + ), + seconds_this_month=Sum( + 'generation_records__seconds_consumed', + filter=Q(generation_records__created_at__date__gte=first_of_month), + ), + ).order_by('-date_joined') + + return Response({ + 'results': [{ + 'id': m.id, + 'username': m.username, + 'email': m.email, + 'is_team_admin': m.is_team_admin, + 'is_active': m.is_active, + 'daily_seconds_limit': m.daily_seconds_limit, + 'monthly_seconds_limit': m.monthly_seconds_limit, + 'seconds_today': m.seconds_today or 0, + 'seconds_this_month': m.seconds_this_month or 0, + 'date_joined': m.date_joined.isoformat(), + } for m in members], + }) + + +@api_view(['POST']) +@permission_classes([IsTeamAdmin]) +def team_member_create_view(request): + """POST /api/v1/team/members/create — Team admin creates a member.""" + serializer = TeamMemberCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + team = request.user.team + username = serializer.validated_data['username'] + + if User.objects.filter(username=username).exists(): + return Response({'error': '用户名已存在'}, status=status.HTTP_400_BAD_REQUEST) + + daily = serializer.validated_data.get('daily_seconds_limit', team.daily_member_limit_default) + monthly = serializer.validated_data.get('monthly_seconds_limit', -1) + + # Generate email from username (team members may not need real email) + email = f'{username}@team.local' + if User.objects.filter(email=email).exists(): + email = f'{username}_{team.id}@team.local' + + user = User.objects.create_user( + username=username, + email=email, + password=serializer.validated_data['password'], + team=team, + is_team_admin=False, # Cannot escalate privileges + daily_seconds_limit=daily, + monthly_seconds_limit=monthly, + ) + + return Response({ + 'id': user.id, + 'username': user.username, + 'team': team.name, + 'daily_seconds_limit': user.daily_seconds_limit, + 'monthly_seconds_limit': user.monthly_seconds_limit, + }, status=status.HTTP_201_CREATED) + + +@api_view(['GET']) +@permission_classes([IsTeamAdmin]) +def team_member_detail_view(request, member_id): + """GET /api/v1/team/members/ — Member detail + recent records.""" + team = request.user.team + try: + member = team.members.get(id=member_id) + except User.DoesNotExist: + return Response({'error': '成员不存在'}, status=status.HTTP_404_NOT_FOUND) + + today = timezone.now().date() + first_of_month = today.replace(day=1) + + seconds_today = member.generation_records.filter( + created_at__date=today + ).aggregate(total=Sum('seconds_consumed'))['total'] or 0 + + seconds_this_month = member.generation_records.filter( + created_at__date__gte=first_of_month + ).aggregate(total=Sum('seconds_consumed'))['total'] or 0 + + recent_records = _eval_qs(member.generation_records.order_by('-created_at'), limit=20) + + return Response({ + 'id': member.id, + 'username': member.username, + 'is_active': member.is_active, + 'is_team_admin': member.is_team_admin, + 'daily_seconds_limit': member.daily_seconds_limit, + 'monthly_seconds_limit': member.monthly_seconds_limit, + 'seconds_today': seconds_today, + 'seconds_this_month': seconds_this_month, + 'recent_records': [ + { + 'id': r.id, + 'created_at': r.created_at.isoformat(), + 'seconds_consumed': r.seconds_consumed, + 'prompt': r.prompt, + 'mode': r.mode, + 'model': r.model, + 'status': r.status, + } + for r in recent_records + ], + }) + + +@api_view(['PUT']) +@permission_classes([IsTeamAdmin]) +def team_member_quota_view(request, member_id): + """PUT /api/v1/team/members//quota — Set member daily/monthly limit.""" + team = request.user.team + try: + member = team.members.get(id=member_id) + except User.DoesNotExist: + return Response({'error': '成员不存在'}, status=status.HTTP_404_NOT_FOUND) + + serializer = MemberQuotaSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + member.daily_seconds_limit = serializer.validated_data['daily_seconds_limit'] + member.monthly_seconds_limit = serializer.validated_data['monthly_seconds_limit'] + member.save(update_fields=['daily_seconds_limit', 'monthly_seconds_limit']) + + return Response({ + 'user_id': member.id, + 'username': member.username, + 'daily_seconds_limit': member.daily_seconds_limit, + 'monthly_seconds_limit': member.monthly_seconds_limit, + }) + + +@api_view(['PATCH']) +@permission_classes([IsTeamAdmin]) +def team_member_status_view(request, member_id): + """PATCH /api/v1/team/members//status — Enable/disable member.""" + team = request.user.team + try: + member = team.members.get(id=member_id) + except User.DoesNotExist: + return Response({'error': '成员不存在'}, status=status.HTTP_404_NOT_FOUND) + + # Cannot disable yourself or other team admins + if member.id == request.user.id: + return Response({'error': '不能停用自己的账号'}, status=status.HTTP_400_BAD_REQUEST) + + serializer = UserStatusSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + member.is_active = serializer.validated_data['is_active'] + member.save(update_fields=['is_active']) + + return Response({ + 'user_id': member.id, + 'username': member.username, + 'is_active': member.is_active, + }) + + # ────────────────────────────────────────────── # Profile: User's own consumption data # ────────────────────────────────────────────── @@ -754,14 +1253,33 @@ def profile_overview_view(request): d = start_date + timedelta(days=i) daily_trend.append({'date': str(d), 'seconds': trend_map.get(str(d), 0)}) - return Response({ + data = { 'daily_seconds_limit': user.daily_seconds_limit, 'daily_seconds_used': daily_seconds_used, 'monthly_seconds_limit': user.monthly_seconds_limit, 'monthly_seconds_used': monthly_seconds_used, 'total_seconds_used': total_seconds_used, 'daily_trend': daily_trend, - }) + } + + # Include team info + team = user.team + if team: + team_monthly_used = GenerationRecord.objects.filter( + user__team=team, + created_at__date__gte=first_of_month, + ).aggregate(total=Sum('seconds_consumed'))['total'] or 0 + + data['team'] = { + 'name': team.name, + 'total_seconds_pool': team.total_seconds_pool, + 'total_seconds_used': team.total_seconds_used, + 'remaining_seconds': team.remaining_seconds, + 'monthly_seconds_limit': team.monthly_seconds_limit, + 'monthly_seconds_used': team_monthly_used, + } + + return Response(data) @api_view(['GET']) diff --git a/web/src/App.tsx b/web/src/App.tsx index af5f725..d2b01ec 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -7,11 +7,17 @@ import { LoginPage } from './pages/LoginPage'; import { AdminLayout } from './pages/AdminLayout'; import { DashboardPage } from './pages/DashboardPage'; +import { TeamsPage } from './pages/TeamsPage'; import { UsersPage } from './pages/UsersPage'; import { RecordsPage } from './pages/RecordsPage'; import { SettingsPage } from './pages/SettingsPage'; import { ProfilePage } from './pages/ProfilePage'; import { AssetsPage } from './pages/AssetsPage'; + +import { TeamAdminLayout } from './pages/TeamAdminLayout'; +import { TeamDashboardPage } from './pages/TeamDashboardPage'; +import { TeamMembersPage } from './pages/TeamMembersPage'; + import { useAuthStore } from './store/auth'; export default function App() { @@ -29,7 +35,7 @@ export default function App() { + } @@ -37,7 +43,7 @@ export default function App() { + } @@ -45,11 +51,12 @@ export default function App() { + } /> + {/* Super Admin routes */} } /> } /> + } /> } /> } /> } /> + {/* Team Admin routes */} + + + + } + > + } /> + } /> + } /> + } /> diff --git a/web/src/components/ConfirmModal.module.css b/web/src/components/ConfirmModal.module.css new file mode 100644 index 0000000..2670744 --- /dev/null +++ b/web/src/components/ConfirmModal.module.css @@ -0,0 +1,8 @@ +.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 300; } +.modal { background: var(--color-bg-card); border: 1px solid var(--color-border-card); border-radius: var(--radius-card); padding: 24px; width: 400px; max-width: 90vw; } +.title { font-size: 16px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 12px; } +.message { font-size: 14px; color: var(--color-text-secondary); line-height: 1.6; margin-bottom: 20px; } +.actions { display: flex; justify-content: flex-end; gap: 8px; } +.cancelBtn { padding: 8px 16px; background: transparent; border: 1px solid var(--color-border-card); border-radius: 8px; color: var(--color-text-secondary); font-size: 13px; cursor: pointer; } +.confirmBtn { padding: 8px 16px; background: var(--color-primary); border: none; border-radius: 8px; color: #fff; font-size: 13px; cursor: pointer; } +.danger { background: var(--color-danger); } diff --git a/web/src/components/ConfirmModal.tsx b/web/src/components/ConfirmModal.tsx new file mode 100644 index 0000000..a93ad14 --- /dev/null +++ b/web/src/components/ConfirmModal.tsx @@ -0,0 +1,28 @@ +import styles from './ConfirmModal.module.css'; + +interface ConfirmModalProps { + open: boolean; + title: string; + message: string; + confirmText?: string; + cancelText?: string; + danger?: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +export function ConfirmModal({ open, title, message, confirmText = '确认', cancelText = '取消', danger, onConfirm, onCancel }: ConfirmModalProps) { + if (!open) return null; + return ( +
{ if (e.target === e.currentTarget) onCancel(); }}> +
+

{title}

+

{message}

+
+ + +
+
+
+ ); +} diff --git a/web/src/components/GenerationCard.tsx b/web/src/components/GenerationCard.tsx index 9a0aad5..28f2f28 100644 --- a/web/src/components/GenerationCard.tsx +++ b/web/src/components/GenerationCard.tsx @@ -2,6 +2,7 @@ import { useRef, useState, useEffect, useCallback } from 'react'; import type { GenerationTask } from '../types'; import { useGenerationStore } from '../store/generation'; import { showToast } from './Toast'; +import { ConfirmModal } from './ConfirmModal'; import styles from './GenerationCard.module.css'; const EditIcon = () => ( @@ -51,6 +52,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) { const [promptHover, setPromptHover] = useState(false); const [showMore, setShowMore] = useState(false); const [truncatedPrompt, setTruncatedPrompt] = useState(task.prompt); + const [confirmDelete, setConfirmDelete] = useState(false); const [detailHover, setDetailHover] = useState(false); const [detailPos, setDetailPos] = useState({ top: 0, right: 0 }); const detailLinkRef = useRef(null); @@ -348,7 +350,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) { {showMore && (
-
)} + + { removeTask(task.id); setConfirmDelete(false); }} + onCancel={() => setConfirmDelete(false)} + /> ); } diff --git a/web/src/components/ProtectedRoute.tsx b/web/src/components/ProtectedRoute.tsx index 3de2f73..a42562e 100644 --- a/web/src/components/ProtectedRoute.tsx +++ b/web/src/components/ProtectedRoute.tsx @@ -4,9 +4,11 @@ import { useAuthStore } from '../store/auth'; interface Props { children: React.ReactNode; requireAdmin?: boolean; + requireTeamAdmin?: boolean; + requireTeamMember?: boolean; } -export function ProtectedRoute({ children, requireAdmin }: Props) { +export function ProtectedRoute({ children, requireAdmin, requireTeamAdmin, requireTeamMember }: Props) { const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const isLoading = useAuthStore((s) => s.isLoading); const user = useAuthStore((s) => s.user); @@ -31,9 +33,18 @@ export function ProtectedRoute({ children, requireAdmin }: Props) { return ; } - if (requireAdmin && user && !user.is_staff) { + if (requireAdmin && user?.role !== 'super_admin') { return ; } + if (requireTeamAdmin && user?.role !== 'team_admin') { + return ; + } + + // requireTeamMember: must have a team (team_admin or member) + if (requireTeamMember && user?.role === 'super_admin') { + return ; + } + return <>{children}; } diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 49b41f3..324b148 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -9,15 +9,16 @@ export function Sidebar() { const quota = useAuthStore((s) => s.quota); const isActive = (path: string) => location.pathname === path; + const role = user?.role; const dailyRemaining = quota - ? Math.max(0, quota.daily_seconds_limit - quota.daily_seconds_used) + ? (quota.daily_seconds_limit === -1 ? Infinity : Math.max(0, quota.daily_seconds_limit - quota.daily_seconds_used)) : 0; return (