feat: v0.7.0 — 确认弹窗 + 秒数显示统一 + 弹窗拖拽修复 + 团队模型完善

- 新增 ConfirmModal 组件,为6处危险操作添加二次确认弹窗
  (禁用团队/用户/成员、删除视频×3处)
- 所有秒数显示统一为千位分隔符+s后缀(如 36,000s)
- 修复 modal/drawer 在 input 中拖拽导致误关闭的 bug
  (onClick → onMouseDown + e.target === e.currentTarget)
- 团队模型完善:三种角色(超管/团管/成员)、四层额度检查、
  团管成员管理页、超管团队管理页
- 关闭公开注册,所有账号由管理员创建

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-03-15 20:16:21 +08:00
parent f8358a28c6
commit add3af7904
32 changed files with 2301 additions and 338 deletions

View File

@ -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')}),
)

View File

@ -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='所属团队'),
),
]

View File

@ -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),
]

View File

@ -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'

View File

@ -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
)

View File

@ -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):

View File

@ -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)

View File

@ -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)

View File

@ -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/<uuid:task_id>', 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/<int:team_id>', views.admin_team_detail_view, name='admin_team_detail'),
path('admin/teams/<int:team_id>/topup', views.admin_team_topup_view, name='admin_team_topup'),
path('admin/teams/<int:team_id>/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/<int:user_id>', views.admin_user_detail_view, name='admin_user_detail'),
path('admin/users/<int:user_id>/quota', views.admin_user_quota_view, name='admin_user_quota'),
path('admin/users/<int:user_id>/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/<int:member_id>', views.team_member_detail_view, name='team_member_detail'),
path('team/members/<int:member_id>/quota', views.team_member_quota_view, name='team_member_quota'),
path('team/members/<int:member_id>/status', views.team_member_status_view, name='team_member_status'),
# ── 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'),
]

View File

@ -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/<task_id> — Get task status, poll Seedance if active.
"""GET /api/v1/video/tasks/<task_id> — Poll Seedance + refund on failure.
DELETE /api/v1/video/tasks/<task_id> 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/<id> — 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/<id>/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/<id>/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/<id> — 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/<id>/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/<id>/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'])

View File

@ -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() {
<Route
path="/"
element={
<ProtectedRoute>
<ProtectedRoute requireTeamMember>
<VideoGenerationPage />
</ProtectedRoute>
}
@ -37,7 +43,7 @@ export default function App() {
<Route
path="/assets"
element={
<ProtectedRoute>
<ProtectedRoute requireTeamMember>
<AssetsPage />
</ProtectedRoute>
}
@ -45,11 +51,12 @@ export default function App() {
<Route
path="/profile"
element={
<ProtectedRoute>
<ProtectedRoute requireTeamMember>
<ProfilePage />
</ProtectedRoute>
}
/>
{/* Super Admin routes */}
<Route
path="/admin"
element={
@ -60,10 +67,24 @@ export default function App() {
>
<Route index element={<Navigate to="/admin/dashboard" replace />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route path="teams" element={<TeamsPage />} />
<Route path="users" element={<UsersPage />} />
<Route path="records" element={<RecordsPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
{/* Team Admin routes */}
<Route
path="/team"
element={
<ProtectedRoute requireTeamAdmin>
<TeamAdminLayout />
</ProtectedRoute>
}
>
<Route index element={<Navigate to="/team/dashboard" replace />} />
<Route path="dashboard" element={<TeamDashboardPage />} />
<Route path="members" element={<TeamMembersPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>

View File

@ -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); }

View File

@ -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 (
<div className={styles.overlay} onMouseDown={(e) => { if (e.target === e.currentTarget) onCancel(); }}>
<div className={styles.modal}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.message}>{message}</p>
<div className={styles.actions}>
<button className={styles.cancelBtn} onClick={onCancel}>{cancelText}</button>
<button className={`${styles.confirmBtn} ${danger ? styles.danger : ''}`} onClick={onConfirm}>{confirmText}</button>
</div>
</div>
</div>
);
}

View File

@ -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<HTMLSpanElement>(null);
@ -348,7 +350,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
</button>
{showMore && (
<div className={styles.moreDropdown}>
<button onClick={() => { removeTask(task.id); setShowMore(false); }}>
<button onClick={() => { setConfirmDelete(true); setShowMore(false); }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
@ -360,6 +362,16 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
</div>
</div>
)}
<ConfirmModal
open={confirmDelete}
title="删除视频"
message="确定要删除这条生成记录吗?此操作不可撤销。"
confirmText="删除"
danger
onConfirm={() => { removeTask(task.id); setConfirmDelete(false); }}
onCancel={() => setConfirmDelete(false)}
/>
</div>
);
}

View File

@ -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 <Navigate to="/login" replace />;
}
if (requireAdmin && user && !user.is_staff) {
if (requireAdmin && user?.role !== 'super_admin') {
return <Navigate to="/" replace />;
}
if (requireTeamAdmin && user?.role !== 'team_admin') {
return <Navigate to="/" replace />;
}
// requireTeamMember: must have a team (team_admin or member)
if (requireTeamMember && user?.role === 'super_admin') {
return <Navigate to="/admin/dashboard" replace />;
}
return <>{children}</>;
}

View File

@ -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 (
<aside className={styles.sidebar}>
{/* Logo */}
<div className={styles.logo} onClick={() => navigate('/')}>
<div className={styles.logo} onClick={() => navigate(role === 'super_admin' ? '/admin/dashboard' : '/')}>
<svg width="32" height="32" viewBox="0 0 28 28" fill="none">
<path d="M4 8L14 2L24 8V20L14 26L4 20V8Z" fill="#6c63ff" opacity="0.9" />
<path d="M14 2L24 8L14 14L4 8L14 2Z" fill="#8b83ff" />
@ -27,41 +28,65 @@ export function Sidebar() {
{/* Nav items */}
<nav className={styles.navItems}>
<div
className={`${styles.navItem} ${isActive('/') ? styles.active : ''}`}
onClick={() => navigate('/')}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span></span>
</div>
<div
className={`${styles.navItem} ${isActive('/assets') ? styles.active : ''}`}
onClick={() => navigate('/assets')}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M3 9h18M9 3v18" />
</svg>
<span></span>
</div>
{/* Video generation - team members and team admins only */}
{role !== 'super_admin' && (
<>
<div
className={`${styles.navItem} ${isActive('/') ? styles.active : ''}`}
onClick={() => navigate('/')}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span></span>
</div>
<div
className={`${styles.navItem} ${isActive('/assets') ? styles.active : ''}`}
onClick={() => navigate('/assets')}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M3 9h18M9 3v18" />
</svg>
<span></span>
</div>
</>
)}
{/* Team management - team admin only */}
{role === 'team_admin' && (
<div
className={`${styles.navItem} ${location.pathname.startsWith('/team') ? styles.active : ''}`}
onClick={() => navigate('/team/dashboard')}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" />
</svg>
<span></span>
</div>
)}
</nav>
{/* Bottom section: quota + avatar + admin */}
<div className={styles.bottom}>
{/* Quota display */}
<div className={styles.quota} onClick={() => navigate('/profile')}>
<svg className={styles.diamondIcon} width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M6 3h12l4 8-10 12L2 11l4-8z" fill="#6c63ff" opacity="0.85" />
<path d="M2 11h20M6 3l4 8M18 3l-4 8M12 23l-4-12M12 23l4-12" stroke="#fff" strokeWidth="0.8" opacity="0.4" />
</svg>
<span className={styles.quotaNumber}>{dailyRemaining}</span>
<span className={styles.quotaLabel}></span>
</div>
{/* Quota display - not for super admin */}
{role !== 'super_admin' && (
<div className={styles.quota} onClick={() => navigate('/profile')}>
<svg className={styles.diamondIcon} width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M6 3h12l4 8-10 12L2 11l4-8z" fill="#6c63ff" opacity="0.85" />
<path d="M2 11h20M6 3l4 8M18 3l-4 8M12 23l-4-12M12 23l4-12" stroke="#fff" strokeWidth="0.8" opacity="0.4" />
</svg>
<span className={styles.quotaNumber}>
{dailyRemaining === Infinity ? '∞' : dailyRemaining.toLocaleString()}
</span>
<span className={styles.quotaLabel}></span>
</div>
)}
{/* Admin entry */}
{user?.is_staff && (
{/* Admin entry - super admin only */}
{role === 'super_admin' && (
<div
className={styles.adminBtn}
onClick={() => navigate('/admin/dashboard')}
@ -77,7 +102,7 @@ export function Sidebar() {
{/* User avatar */}
<div
className={styles.avatar}
onClick={() => navigate('/profile')}
onClick={() => navigate(role === 'super_admin' ? '/admin/dashboard' : '/profile')}
title={user?.username || '个人中心'}
>
{user?.username?.charAt(0).toUpperCase() || 'U'}

View File

@ -1,6 +1,7 @@
import { useRef, useState, useEffect, useCallback, useMemo } from 'react';
import type { GenerationTask } from '../types';
import { AmbientBackground } from './AmbientBackground';
import { ConfirmModal } from './ConfirmModal';
import styles from './VideoDetailModal.module.css';
interface Props {
@ -28,6 +29,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
const [isMuted, setIsMuted] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [showMoreMenu, setShowMoreMenu] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [fitSize, setFitSize] = useState<{ w: number; h: number } | null>(null);
const moreMenuRef = useRef<HTMLDivElement>(null);
const hideTimerRef = useRef<ReturnType<typeof setTimeout>>();
@ -202,11 +204,16 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
};
const handleDelete = () => {
setConfirmDelete(true);
setShowMoreMenu(false);
};
const doDelete = () => {
if (task && onDelete) {
onDelete(task.id);
onClose();
}
setShowMoreMenu(false);
setConfirmDelete(false);
};
const formatTime = (s: number) => {
@ -481,6 +488,16 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
</div>
</div>
</div>
<ConfirmModal
open={confirmDelete}
title="删除视频"
message="确定要删除这条生成记录吗?此操作不可撤销。"
confirmText="删除"
danger
onConfirm={doDelete}
onCancel={() => setConfirmDelete(false)}
/>
</div>
</div>
);

View File

@ -5,12 +5,14 @@ import { GenerationCard } from './GenerationCard';
import { Toast } from './Toast';
import { VideoDetailModal } from './VideoDetailModal';
import { useGenerationStore } from '../store/generation';
import { useAuthStore } from '../store/auth';
import type { GenerationTask } from '../types';
import styles from './VideoGenerationPage.module.css';
export function VideoGenerationPage() {
const tasks = useGenerationStore((s) => s.tasks);
const loadTasks = useGenerationStore((s) => s.loadTasks);
const teamDisabled = useAuthStore((s) => s.teamDisabled);
const reEdit = useGenerationStore((s) => s.reEdit);
const regenerate = useGenerationStore((s) => s.regenerate);
const removeTask = useGenerationStore((s) => s.removeTask);
@ -52,6 +54,28 @@ export function VideoGenerationPage() {
);
const detailIdx = detailTask ? completedTasks.findIndex((t) => t.id === detailTask.id) : -1;
if (teamDisabled) {
return (
<div className={styles.layout}>
<Sidebar />
<main className={styles.main}>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
height: '100%', flexDirection: 'column', gap: 16,
color: 'var(--color-text-secondary)',
}}>
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#8b8ea8" strokeWidth="1.5">
<circle cx="12" cy="12" r="10" />
<path d="M4.93 4.93l14.14 14.14" />
</svg>
<p style={{ fontSize: 18, color: 'var(--color-text-primary)' }}></p>
<p></p>
</div>
</main>
</div>
);
}
return (
<div className={styles.layout}>
<Sidebar />

View File

@ -2,7 +2,7 @@ import axios, { AxiosError } from 'axios';
import type {
User, Quota, AuthTokens, AdminStats, AdminUser, AdminUserDetail,
AdminRecord, SystemSettings, ProfileOverview, PaginatedResponse,
BackendTask,
BackendTask, TeamInfo, Team, TeamDetail, TeamMember, TeamStats,
} from '../types';
import { reportError } from './logCenter';
@ -63,9 +63,6 @@ api.interceptors.response.use(
// Auth APIs
export const authApi = {
register: (username: string, email: string, password: string) =>
api.post<{ user: User; tokens: AuthTokens }>('/auth/register', { username, email, password }),
login: (username: string, password: string) =>
api.post<{ user: User; tokens: AuthTokens }>('/auth/login', { username, password }),
@ -73,7 +70,7 @@ export const authApi = {
api.post<{ access: string }>('/auth/token/refresh', { refresh }),
getMe: () =>
api.get<User & { quota: Quota }>('/auth/me'),
api.get<User & { quota: Quota; team: TeamInfo | null; team_disabled: boolean }>('/auth/me'),
};
// Media upload API
@ -108,7 +105,6 @@ export const videoApi = {
status: string;
estimated_time: number;
seconds_consumed: number;
remaining_seconds_today: number;
}>('/video/generate', data),
getTasks: () =>
@ -121,11 +117,31 @@ export const videoApi = {
api.delete(`/video/tasks/${taskId}`),
};
// Admin APIs
// Admin APIs (Super Admin)
export const adminApi = {
getStats: () =>
api.get<AdminStats>('/admin/stats'),
// Team management
getTeams: () =>
api.get<{ results: Team[] }>('/admin/teams'),
createTeam: (data: { name: string; monthly_seconds_limit?: number; daily_member_limit_default?: number }) =>
api.post('/admin/teams/create', data),
getTeamDetail: (teamId: number) =>
api.get<TeamDetail>(`/admin/teams/${teamId}`),
updateTeam: (teamId: number, data: { name?: string; monthly_seconds_limit?: number; daily_member_limit_default?: number; is_active?: boolean }) =>
api.put(`/admin/teams/${teamId}`, data),
topUpTeam: (teamId: number, seconds: number) =>
api.post(`/admin/teams/${teamId}/topup`, { seconds }),
createTeamAdmin: (teamId: number, data: { username: string; email: string; password: string }) =>
api.post(`/admin/teams/${teamId}/admin`, data),
// User management
createUser: (data: {
username: string;
email: string;
@ -141,6 +157,7 @@ export const adminApi = {
page_size?: number;
search?: string;
status?: string;
team_id?: number;
} = {}) =>
api.get<PaginatedResponse<AdminUser>>('/admin/users', { params }),
@ -162,6 +179,7 @@ export const adminApi = {
search?: string;
start_date?: string;
end_date?: string;
team_id?: number;
} = {}) =>
api.get<PaginatedResponse<AdminRecord>>('/admin/records', { params }),
@ -172,6 +190,33 @@ export const adminApi = {
api.put<SystemSettings & { updated_at: string }>('/admin/settings', settings),
};
// Team Admin APIs
export const teamApi = {
getInfo: () =>
api.get<TeamInfo & { daily_member_limit_default: number; member_count: number }>('/team/info'),
getStats: () =>
api.get<TeamStats>('/team/stats'),
getMembers: () =>
api.get<{ results: TeamMember[] }>('/team/members'),
createMember: (data: { username: string; password: string; daily_seconds_limit?: number; monthly_seconds_limit?: number }) =>
api.post('/team/members/create', data),
getMemberDetail: (memberId: number) =>
api.get('/team/members/' + memberId),
updateMemberQuota: (memberId: number, daily: number, monthly: number) =>
api.put(`/team/members/${memberId}/quota`, {
daily_seconds_limit: daily,
monthly_seconds_limit: monthly,
}),
updateMemberStatus: (memberId: number, isActive: boolean) =>
api.patch(`/team/members/${memberId}/status`, { is_active: isActive }),
};
// Profile APIs
export const profileApi = {
getOverview: (period: '7d' | '30d' = '7d') =>

View File

@ -5,6 +5,7 @@ import styles from './AdminLayout.module.css';
const navItems = [
{ path: '/admin/dashboard', label: '仪表盘', icon: 'M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z' },
{ path: '/admin/teams', label: '团队管理', icon: 'M12 7V3H2v18h20V7H12zM6 19H4v-2h2v2zm0-4H4v-2h2v2zm0-4H4V9h2v2zm0-4H4V5h2v2zm4 12H8v-2h2v2zm0-4H8v-2h2v2zm0-4H8V9h2v2zm0-4H8V5h2v2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2z' },
{ path: '/admin/users', 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: '/admin/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 10h-4v4h-2v-4H7v-2h4V7h2v4h4v2z' },
{ path: '/admin/settings', label: '系统设置', icon: 'M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.488.488 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z' },

View File

@ -2,6 +2,7 @@ import { useEffect, useState, useRef, useMemo } from 'react';
import { Sidebar } from '../components/Sidebar';
import { VideoDetailModal } from '../components/VideoDetailModal';
import { useGenerationStore } from '../store/generation';
import { ConfirmModal } from '../components/ConfirmModal';
import type { GenerationTask } from '../types';
import styles from './AssetsPage.module.css';
@ -91,6 +92,7 @@ export function AssetsPage() {
const regenerate = useGenerationStore((s) => s.regenerate);
const removeTask = useGenerationStore((s) => s.removeTask);
const [detailTask, setDetailTask] = useState<GenerationTask | null>(null);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
useEffect(() => {
loadTasks();
@ -114,8 +116,15 @@ export function AssetsPage() {
};
const handleDelete = (id: string) => {
removeTask(id);
setDetailTask(null);
setConfirmDeleteId(id);
};
const doDelete = () => {
if (confirmDeleteId) {
removeTask(confirmDeleteId);
setDetailTask(null);
}
setConfirmDeleteId(null);
};
const detailIdx = detailTask ? completedTasks.findIndex((t) => t.id === detailTask.id) : -1;
@ -171,6 +180,16 @@ export function AssetsPage() {
onPrev={() => detailIdx > 0 && setDetailTask(completedTasks[detailIdx - 1])}
onNext={() => detailIdx < completedTasks.length - 1 && setDetailTask(completedTasks[detailIdx + 1])}
/>
<ConfirmModal
open={!!confirmDeleteId}
title="删除视频"
message="确定要删除这条生成记录吗?此操作不可撤销。"
confirmText="删除"
danger
onConfirm={doDelete}
onCancel={() => setConfirmDeleteId(null)}
/>
</div>
);
}

View File

@ -135,18 +135,18 @@ export function ProfilePage() {
<div className={styles.overviewGrid}>
<div className={styles.quotaCard}>
<div className={styles.quotaLabel}></div>
<div className={styles.quotaValue}>: {overview.total_seconds_used}s / {overview.monthly_seconds_limit}s</div>
<div className={styles.quotaValue}>: {overview.total_seconds_used.toLocaleString()}s / {overview.monthly_seconds_limit.toLocaleString()}s</div>
<div className={styles.progressBar}>
<div className={styles.progressFill} style={{
width: `${Math.min(totalPercent, 100)}%`,
background: totalPercent > 80 ? (totalPercent >= 100 ? 'var(--color-danger)' : 'var(--color-warning)') : 'var(--color-primary)',
}} />
</div>
<div className={styles.quotaPercent}> {totalRemaining}s</div>
<div className={styles.quotaPercent}> {totalRemaining.toLocaleString()}s</div>
</div>
<div className={styles.quotaCard}>
<div className={styles.quotaLabel}></div>
<div className={styles.quotaValue}>: {overview.daily_seconds_used}s / {overview.daily_seconds_limit}s</div>
<div className={styles.quotaValue}>: {overview.daily_seconds_used.toLocaleString()}s / {overview.daily_seconds_limit.toLocaleString()}s</div>
<div className={styles.progressBar}>
<div className={styles.progressFill} style={{
width: `${Math.min(dailyPercent, 100)}%`,
@ -157,7 +157,7 @@ export function ProfilePage() {
</div>
<div className={styles.quotaCard}>
<div className={styles.quotaLabel}></div>
<div className={styles.quotaValue}>: {overview.monthly_seconds_used}s / {overview.monthly_seconds_limit}s</div>
<div className={styles.quotaValue}>: {overview.monthly_seconds_used.toLocaleString()}s / {overview.monthly_seconds_limit.toLocaleString()}s</div>
<div className={styles.progressBar}>
<div className={styles.progressFill} style={{
width: `${Math.min(monthlyPercent, 100)}%`,
@ -197,7 +197,7 @@ export function ProfilePage() {
<div className={styles.recordPrompt}>{r.prompt || '-'}</div>
</div>
<div className={styles.recordRight}>
<span className={styles.recordSeconds}>{r.seconds_consumed}s</span>
<span className={styles.recordSeconds}>{r.seconds_consumed.toLocaleString()}s</span>
<span className={styles.recordMode}>{r.mode === 'universal' ? '全能参考' : '首尾帧'}</span>
<span className={`${styles.recordStatus} ${styles[r.status]}`}>{statusMap[r.status]}</span>
</div>

View File

@ -132,7 +132,7 @@ export function RecordsPage() {
<tr key={r.id}>
<td className={styles.timeCell}>{new Date(r.created_at).toLocaleString('zh-CN')}</td>
<td>{r.username}</td>
<td><span className={styles.secondsBadge}>{r.seconds_consumed}s</span></td>
<td><span className={styles.secondsBadge}>{r.seconds_consumed.toLocaleString()}s</span></td>
<td className={styles.promptCell}>{r.prompt ? (r.prompt.slice(0, 40) + (r.prompt.length > 40 ? '...' : '')) : '-'}</td>
<td>{r.mode === 'universal' ? '全能参考' : '首尾帧'}</td>
<td>

View File

@ -1,115 +1,6 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/auth';
import styles from './AuthPage.module.css';
import { Navigate } from 'react-router-dom';
export function RegisterPage() {
const navigate = useNavigate();
const register = useAuthStore((s) => s.register);
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (username.length < 3 || username.length > 20) {
setError('用户名需要3-20个字符'); return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setError('请输入合法的邮箱地址'); return;
}
if (password.length < 6) {
setError('密码至少6位'); return;
}
if (password !== confirmPassword) {
setError('两次输入的密码不一致'); return;
}
setLoading(true);
try {
await register(username, email, password);
navigate('/', { replace: true });
} catch (err: any) {
const data = err.response?.data;
if (data) {
const messages = Object.values(data).flat();
setError(messages.join('') || '注册失败,请重试');
} else {
setError('注册失败,请重试');
}
} finally {
setLoading(false);
}
};
return (
<div className={styles.page}>
<div className={styles.card}>
<h1 className={styles.title}></h1>
<p className={styles.subtitle}> AI </p>
<form onSubmit={handleSubmit} className={styles.form}>
<div className={styles.field}>
<label className={styles.label}></label>
<input
type="text"
className={styles.input}
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="3-20个字符"
autoFocus
/>
</div>
<div className={styles.field}>
<label className={styles.label}></label>
<input
type="email"
className={styles.input}
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
/>
</div>
<div className={styles.field}>
<label className={styles.label}></label>
<input
type="password"
className={styles.input}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="至少6位"
/>
</div>
<div className={styles.field}>
<label className={styles.label}></label>
<input
type="password"
className={styles.input}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="再次输入密码"
/>
</div>
{error && <div className={styles.error}>{error}</div>}
<button type="submit" className={styles.submitBtn} disabled={loading}>
{loading ? '注册中...' : '注册'}
</button>
</form>
<p className={styles.switchLink}>
<Link to="/login"> </Link>
</p>
</div>
</div>
);
// Registration is disabled — all accounts created by admins
return <Navigate to="/login" replace />;
}

View File

@ -0,0 +1,85 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/auth';
import { useState } from 'react';
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' },
];
export function TeamAdminLayout() {
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const navigate = useNavigate();
const [collapsed, setCollapsed] = useState(false);
const handleLogout = () => {
logout();
navigate('/login', { replace: true });
};
return (
<div className={styles.layout}>
<aside className={`${styles.sidebar} ${collapsed ? styles.collapsed : ''}`}>
<div className={styles.sidebarHeader}>
<div className={styles.logo}>
<svg viewBox="0 0 24 24" width="24" height="24" fill="var(--color-primary)">
<path d="M12 7V3H2v18h20V7H12zM6 19H4v-2h2v2zm0-4H4v-2h2v2zm0-4H4V9h2v2zm0-4H4V5h2v2zm4 12H8v-2h2v2zm0-4H8v-2h2v2zm0-4H8V9h2v2zm0-4H8V5h2v2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2z"/>
</svg>
{!collapsed && <span className={styles.logoText}></span>}
</div>
<button className={styles.collapseBtn} onClick={() => setCollapsed(!collapsed)}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="var(--color-text-secondary)">
{collapsed ? (
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
) : (
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
)}
</svg>
</button>
</div>
<nav className={styles.nav}>
<button className={styles.navItem} onClick={() => navigate('/')} style={{ border: 'none', background: 'transparent', cursor: 'pointer', textAlign: 'left', width: '100%' }}>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>
</svg>
{!collapsed && <span></span>}
</button>
<div className={styles.navDivider} />
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
`${styles.navItem} ${isActive ? styles.navItemActive : ''}`
}
>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d={item.icon} />
</svg>
{!collapsed && <span>{item.label}</span>}
</NavLink>
))}
</nav>
<div className={styles.sidebarFooter}>
<div className={styles.userInfo}>
<div className={styles.userAvatar}>{user?.username.charAt(0).toUpperCase()}</div>
{!collapsed && (
<div className={styles.userMeta}>
<span className={styles.userName}>{user?.username}</span>
<button className={styles.logoutLink} onClick={handleLogout}>退</button>
</div>
)}
</div>
</div>
</aside>
<main className={`${styles.content} ${collapsed ? styles.contentExpanded : ''}`}>
<Outlet />
</main>
</div>
);
}

View File

@ -0,0 +1,171 @@
import { useEffect, useState, useCallback } from 'react';
import ReactEChartsCore from 'echarts-for-react/lib/core';
import * as echarts from 'echarts/core';
import { LineChart, BarChart } from 'echarts/charts';
import { GridComponent, TooltipComponent, LegendComponent, DataZoomComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import { teamApi } from '../lib/api';
import type { TeamInfo, TeamStats } from '../types';
import { showToast } from '../components/Toast';
import styles from './DashboardPage.module.css';
echarts.use([LineChart, BarChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, CanvasRenderer]);
export function TeamDashboardPage() {
const [info, setInfo] = useState<(TeamInfo & { daily_member_limit_default: number; member_count: number }) | null>(null);
const [stats, setStats] = useState<TeamStats | null>(null);
const [loading, setLoading] = useState(true);
const fetchData = useCallback(async () => {
try {
const [infoRes, statsRes] = await Promise.all([
teamApi.getInfo(),
teamApi.getStats(),
]);
setInfo(infoRes.data);
setStats(statsRes.data);
} catch {
showToast('加载团队数据失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchData(); }, [fetchData]);
if (loading) {
return (
<div className={styles.page}>
<div className={styles.skeleton}>
<div className={styles.skeletonCards}>
{[1, 2, 3, 4, 5].map((i) => <div key={i} className={styles.skeletonCard} />)}
</div>
<div className={styles.skeletonChart} />
<div className={styles.skeletonChart} />
</div>
</div>
);
}
if (!info || !stats) return null;
const formatLimit = (v: number) => v === -1 ? '不限' : v.toLocaleString() + 's';
const statCards = [
{ label: '总秒数池', value: formatLimit(info.total_seconds_pool) },
{ label: '已使用', value: info.total_seconds_used.toLocaleString() + 's' },
{ label: '剩余', value: info.remaining_seconds.toLocaleString() + 's' },
{ label: '月限额', value: formatLimit(info.monthly_seconds_limit) },
{ label: '本月已用', value: info.monthly_seconds_used.toLocaleString() + 's' },
];
const trendOption: echarts.EChartsCoreOption = {
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(13, 13, 26, 0.95)',
borderColor: 'rgba(255, 255, 255, 0.10)',
textStyle: { color: '#f1f0ff', fontSize: 12 },
formatter: (params: unknown) => {
const p = (params as { name: string; value: number }[])[0];
return `${p.name}<br/>消费: ${p.value}s`;
},
},
grid: { left: 50, right: 20, top: 20, bottom: 60 },
xAxis: {
type: 'category',
data: stats.daily_trend.map((d) => d.date.slice(5)),
axisLabel: { color: '#8b8ea8', fontSize: 11 },
axisLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.08)' } },
},
yAxis: {
type: 'value',
axisLabel: { color: '#8b8ea8', fontSize: 11 },
splitLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.06)' } },
},
dataZoom: [{ type: 'inside', start: 0, end: 100 }],
series: [{
type: 'line',
data: stats.daily_trend.map((d) => d.seconds),
smooth: true,
lineStyle: { color: '#6c63ff', width: 2 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(108, 99, 255, 0.25)' },
{ offset: 1, color: 'rgba(108, 99, 255, 0.02)' },
]),
},
itemStyle: { color: '#6c63ff' },
}],
};
const sortedMembers = [...stats.member_consumption].sort((a, b) => a.seconds_consumed - b.seconds_consumed);
const barOption: echarts.EChartsCoreOption = {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
backgroundColor: 'rgba(13, 13, 26, 0.95)',
borderColor: 'rgba(255, 255, 255, 0.10)',
textStyle: { color: '#f1f0ff', fontSize: 12 },
},
grid: { left: 80, right: 40, top: 10, bottom: 20 },
xAxis: {
type: 'value',
axisLabel: { color: '#8b8ea8', fontSize: 11 },
splitLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.06)' } },
},
yAxis: {
type: 'category',
data: sortedMembers.map((m) => m.username),
axisLabel: { color: '#8b8ea8', fontSize: 12 },
axisLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.08)' } },
},
series: [{
type: 'bar',
data: sortedMembers.map((m) => m.seconds_consumed),
barWidth: 16,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#6c63ff' },
{ offset: 1, color: '#8b5cf6' },
]),
borderRadius: [0, 4, 4, 0],
},
label: {
show: true,
position: 'right',
color: '#8b8ea8',
fontSize: 11,
formatter: '{c}s',
},
}],
};
return (
<div className={styles.page}>
<h1 className={styles.title}></h1>
<div className={styles.statsGrid} style={{ gridTemplateColumns: 'repeat(5, 1fr)' }}>
{statCards.map((card) => (
<div key={card.label} className={styles.statCard}>
<div className={styles.statLabel}>{card.label}</div>
<div className={styles.statValue}>{card.value}</div>
</div>
))}
</div>
<div className={styles.chartSection}>
<h2 className={styles.sectionTitle}>30</h2>
<div className={styles.chartWrapper}>
<ReactEChartsCore echarts={echarts} option={trendOption} style={{ height: 320 }} />
</div>
</div>
<div className={styles.chartSection}>
<h2 className={styles.sectionTitle}></h2>
<div className={styles.chartWrapper}>
<ReactEChartsCore echarts={echarts} option={barOption} style={{ height: Math.max(300, sortedMembers.length * 36) }} />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,241 @@
import { useEffect, useState, useCallback } from 'react';
import { teamApi } from '../lib/api';
import type { TeamMember } from '../types';
import { showToast } from '../components/Toast';
import { ConfirmModal } from '../components/ConfirmModal';
import styles from './UsersPage.module.css';
export function TeamMembersPage() {
const [members, setMembers] = useState<TeamMember[]>([]);
const [loading, setLoading] = useState(true);
// Create member modal
const [createOpen, setCreateOpen] = useState(false);
const [newUsername, setNewUsername] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newDaily, setNewDaily] = useState('600');
const [newMonthly, setNewMonthly] = useState('6000');
const [createError, setCreateError] = useState('');
// Confirm toggle
const [confirmMember, setConfirmMember] = useState<TeamMember | null>(null);
// Edit quota modal
const [editMember, setEditMember] = useState<TeamMember | null>(null);
const [editDaily, setEditDaily] = useState('');
const [editMonthly, setEditMonthly] = useState('');
const fetchMembers = useCallback(async () => {
setLoading(true);
try {
const { data } = await teamApi.getMembers();
setMembers(data.results);
} catch {
showToast('加载成员列表失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchMembers(); }, [fetchMembers]);
const formatLimit = (v: number) => v === -1 ? '不限' : v.toLocaleString() + 's';
const handleToggleStatus = async (member: TeamMember) => {
try {
await teamApi.updateMemberStatus(member.id, !member.is_active);
showToast(member.is_active ? '已禁用成员' : '已启用成员');
fetchMembers();
} catch {
showToast('操作失败');
}
};
const openEditModal = (member: TeamMember) => {
setEditMember(member);
setEditDaily(String(member.daily_seconds_limit));
setEditMonthly(String(member.monthly_seconds_limit));
};
const handleSaveQuota = async () => {
if (!editMember) return;
try {
await teamApi.updateMemberQuota(editMember.id, Number(editDaily), Number(editMonthly));
showToast('配额已更新');
setEditMember(null);
fetchMembers();
} catch {
showToast('更新失败');
}
};
const resetCreateForm = () => {
setNewUsername(''); setNewPassword('');
setNewDaily('600'); setNewMonthly('6000');
setCreateError('');
};
const handleCreateMember = async () => {
setCreateError('');
if (!newUsername.trim()) { setCreateError('请输入用户名'); return; }
if (newPassword.length < 6) { setCreateError('密码至少6位'); return; }
try {
await teamApi.createMember({
username: newUsername.trim(),
password: newPassword,
daily_seconds_limit: Number(newDaily),
monthly_seconds_limit: Number(newMonthly),
});
showToast('成员创建成功');
setCreateOpen(false);
resetCreateForm();
fetchMembers();
} catch (err: any) {
const msg = err.response?.data?.error || err.response?.data?.username?.[0] || '创建失败';
setCreateError(msg);
}
};
return (
<div className={styles.page}>
<h1 className={styles.title}></h1>
<div className={styles.filters}>
<div className={styles.searchGroup}>
<button className={styles.refreshBtn} onClick={fetchMembers}></button>
</div>
<div className={styles.searchGroup}>
<button className={styles.createBtn} onClick={() => { resetCreateForm(); setCreateOpen(true); }}>+ </button>
</div>
</div>
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th>()</th>
<th>()</th>
<th>()</th>
<th>()</th>
<th></th>
</tr>
</thead>
<tbody>
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i}>
{Array.from({ length: 8 }).map((_, j) => (
<td key={j}><div className={styles.skeletonCell} /></td>
))}
</tr>
))
) : members.length === 0 ? (
<tr><td colSpan={8} className={styles.empty}></td></tr>
) : (
members.map((m) => (
<tr key={m.id}>
<td>{m.username}</td>
<td>
{m.is_team_admin ? (
<span className={styles.statusBadge} style={{ background: 'rgba(108, 99, 255, 0.15)', color: '#6c63ff' }}></span>
) : (
<span style={{ color: 'var(--color-text-secondary)', fontSize: 12 }}></span>
)}
</td>
<td>
<span className={`${styles.statusBadge} ${m.is_active ? styles.active : styles.disabled}`}>
{m.is_active ? '启用' : '禁用'}
</span>
</td>
<td>{formatLimit(m.daily_seconds_limit)}</td>
<td>{formatLimit(m.monthly_seconds_limit)}</td>
<td>{m.seconds_today.toLocaleString()}s</td>
<td>{m.seconds_this_month.toLocaleString()}s</td>
<td>
<div className={styles.actions}>
<button className={styles.editBtn} onClick={() => openEditModal(m)}></button>
<button
className={`${styles.toggleBtn} ${m.is_active ? styles.disableBtn : styles.enableBtn}`}
onClick={() => setConfirmMember(m)}
>
{m.is_active ? '禁用' : '启用'}
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<ConfirmModal
open={!!confirmMember}
title={confirmMember?.is_active ? '禁用成员' : '启用成员'}
message={confirmMember?.is_active
? `确定要禁用成员「${confirmMember?.username}」吗?`
: `确定要启用成员「${confirmMember?.username}」吗?`}
confirmText={confirmMember?.is_active ? '禁用' : '启用'}
danger={confirmMember?.is_active}
onConfirm={() => { if (confirmMember) { handleToggleStatus(confirmMember); setConfirmMember(null); } }}
onCancel={() => setConfirmMember(null)}
/>
{/* Edit Quota Modal */}
{editMember && (
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setEditMember(null); }}>
<div className={styles.modal}>
<h3 className={styles.modalTitle}> {editMember.username}</h3>
<div className={styles.formGroup}>
<label>-1 </label>
<input type="number" value={editDaily} onChange={(e) => setEditDaily(e.target.value)} />
</div>
<div className={styles.formGroup}>
<label>-1 </label>
<input type="number" value={editMonthly} onChange={(e) => setEditMonthly(e.target.value)} />
</div>
<div className={styles.modalActions}>
<button className={styles.cancelBtn} onClick={() => setEditMember(null)}></button>
<button className={styles.saveBtn} onClick={handleSaveQuota}></button>
</div>
</div>
</div>
)}
{/* Create Member Modal */}
{createOpen && (
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setCreateOpen(false); }}>
<div className={styles.modal}>
<h3 className={styles.modalTitle}></h3>
<div className={styles.formGroup}>
<label></label>
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} placeholder="请输入用户名" />
</div>
<div className={styles.formGroup}>
<label></label>
<input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} placeholder="至少6位" />
</div>
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label></label>
<input type="number" value={newDaily} onChange={(e) => setNewDaily(e.target.value)} />
</div>
<div className={styles.formGroup}>
<label></label>
<input type="number" value={newMonthly} onChange={(e) => setNewMonthly(e.target.value)} />
</div>
</div>
{createError && <div className={styles.formError}>{createError}</div>}
<div className={styles.modalActions}>
<button className={styles.cancelBtn} onClick={() => setCreateOpen(false)}></button>
<button className={styles.saveBtn} onClick={handleCreateMember}></button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,92 @@
.page { max-width: 1200px; }
.title { font-size: 22px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 20px; }
.filters { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; gap: 12px; flex-wrap: wrap; }
.searchGroup { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.searchInput {
padding: 8px 12px; background: var(--color-bg-card); border: 1px solid var(--color-border-card);
border-radius: 8px; color: var(--color-text-primary); font-size: 13px; width: 240px; outline: none;
}
.searchInput:focus { border-color: var(--color-primary); }
.refreshBtn {
padding: 8px 16px; border-radius: 8px; font-size: 13px; cursor: pointer; transition: all 0.15s;
background: transparent; border: 1px solid var(--color-border-card); color: var(--color-text-secondary);
}
.refreshBtn:hover { background: var(--color-sidebar-hover); }
.createBtn { padding: 8px 16px; border-radius: 8px; font-size: 13px; cursor: pointer; transition: all 0.15s; background: var(--color-success); border: none; color: #fff; font-weight: 500; }
.createBtn:hover { opacity: 0.9; }
.tableWrapper {
background: var(--color-bg-card); border: 1px solid var(--color-border-card);
border-radius: var(--radius-card); overflow-x: auto;
}
.table { width: 100%; border-collapse: collapse; font-size: 13px; }
.table th { padding: 12px 16px; text-align: left; color: var(--color-text-secondary); font-weight: 500; border-bottom: 1px solid var(--color-border-card); white-space: nowrap; }
.table td { padding: 12px 16px; color: var(--color-text-primary); border-bottom: 1px solid rgba(42, 42, 56, 0.5); }
.table tr:last-child td { border-bottom: none; }
.table tr:hover td { background: rgba(255, 255, 255, 0.02); }
.teamNameLink { background: none; border: none; color: var(--color-primary); cursor: pointer; font-size: 13px; text-decoration: underline; }
.statusBadge { padding: 2px 8px; border-radius: 4px; font-size: 12px; }
.active { background: rgba(0, 184, 148, 0.15); color: var(--color-success); }
.disabled { background: rgba(231, 76, 60, 0.15); color: var(--color-danger); }
.actions { display: flex; gap: 6px; }
.editBtn, .toggleBtn, .topupBtn, .adminBtn { padding: 4px 10px; border-radius: 6px; font-size: 12px; cursor: pointer; transition: all 0.15s; }
.topupBtn { background: transparent; border: 1px solid var(--color-primary); color: var(--color-primary); }
.topupBtn:hover { background: rgba(0, 184, 230, 0.1); }
.adminBtn { background: transparent; border: 1px solid #a78bfa; color: #a78bfa; }
.adminBtn:hover { background: rgba(167, 139, 250, 0.1); }
.disableBtn { background: transparent; border: 1px solid var(--color-danger); color: var(--color-danger); }
.disableBtn:hover { background: rgba(231, 76, 60, 0.1); }
.enableBtn { background: transparent; border: 1px solid var(--color-success); color: var(--color-success); }
.enableBtn:hover { background: rgba(0, 184, 148, 0.1); }
.empty { text-align: center; color: var(--color-text-secondary); padding: 40px; }
.skeletonCell { height: 16px; background: var(--color-border-card); border-radius: 4px; animation: pulse 1.5s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
.secondsMain { color: var(--color-text-primary); font-weight: 500; }
.secondsSub { color: var(--color-text-secondary); font-size: 11px; margin-left: 4px; }
/* Modal */
.modalOverlay { 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; }
.modalTitle { font-size: 16px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 20px; }
.formGroup { margin-bottom: 16px; }
.formGroup label { display: block; color: var(--color-text-secondary); font-size: 13px; margin-bottom: 6px; }
.formGroup input { width: 100%; padding: 8px 12px; background: var(--color-bg-page); border: 1px solid var(--color-border-card); border-radius: 8px; color: var(--color-text-primary); font-size: 14px; outline: none; box-sizing: border-box; }
.formGroup input:focus { border-color: var(--color-primary); }
.modalActions { 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; }
.saveBtn { padding: 8px 16px; background: var(--color-primary); border: none; border-radius: 8px; color: #fff; font-size: 13px; cursor: pointer; }
.formRow { display: flex; gap: 12px; }
.formRow .formGroup { flex: 1; }
.formError { color: var(--color-danger); font-size: 13px; margin-bottom: 12px; }
.formHint { color: var(--color-text-secondary); font-size: 12px; margin-top: 4px; }
/* Drawer */
.drawerOverlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 300; }
.drawer {
position: fixed; right: 0; top: 0; bottom: 0; width: 560px; max-width: 90vw;
background: var(--color-bg-card); border-left: 1px solid var(--color-border-card);
display: flex; flex-direction: column; z-index: 301;
animation: slideIn 0.2s ease;
}
@keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
.drawerHeader { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; border-bottom: 1px solid var(--color-border-card); }
.drawerHeader h3 { font-size: 16px; color: var(--color-text-primary); }
.drawerClose { background: none; border: none; color: var(--color-text-secondary); font-size: 24px; cursor: pointer; line-height: 1; }
.drawerBody { flex: 1; overflow-y: auto; padding: 20px; }
.detailGrid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; margin-bottom: 24px; }
.detailItem { display: flex; flex-direction: column; gap: 4px; }
.detailLabel { color: var(--color-text-secondary); font-size: 12px; }
.detailValue { color: var(--color-text-primary); font-size: 14px; }
.membersTitle { font-size: 15px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 12px; }
.memberTable { width: 100%; border-collapse: collapse; font-size: 12px; }
.memberTable th { padding: 8px 12px; text-align: left; color: var(--color-text-secondary); font-weight: 500; border-bottom: 1px solid var(--color-border-card); white-space: nowrap; }
.memberTable td { padding: 8px 12px; color: var(--color-text-primary); border-bottom: 1px solid rgba(42, 42, 56, 0.5); }
.memberTable tr:last-child td { border-bottom: none; }
.adminBadge { background: rgba(167, 139, 250, 0.15); color: #a78bfa; padding: 1px 6px; border-radius: 4px; font-size: 11px; margin-left: 6px; }

400
web/src/pages/TeamsPage.tsx Normal file
View File

@ -0,0 +1,400 @@
import { useEffect, useState, useCallback } from 'react';
import { adminApi } from '../lib/api';
import type { Team, TeamDetail } from '../types';
import { showToast } from '../components/Toast';
import { ConfirmModal } from '../components/ConfirmModal';
import styles from './TeamsPage.module.css';
function fmtSec(s: number): string {
return Math.round(s).toLocaleString() + 's';
}
export function TeamsPage() {
const [teams, setTeams] = useState<Team[]>([]);
const [loading, setLoading] = useState(true);
// Create team modal
const [createOpen, setCreateOpen] = useState(false);
const [newName, setNewName] = useState('');
const [newMonthlyLimit, setNewMonthlyLimit] = useState('36000');
const [newDailyMemberLimit, setNewDailyMemberLimit] = useState('600');
const [createError, setCreateError] = useState('');
// Top-up modal
const [topupTeam, setTopupTeam] = useState<Team | null>(null);
const [topupSeconds, setTopupSeconds] = useState('3600');
// Create admin modal
const [adminTeam, setAdminTeam] = useState<Team | null>(null);
const [adminUsername, setAdminUsername] = useState('');
const [adminEmail, setAdminEmail] = useState('');
const [adminPassword, setAdminPassword] = useState('');
const [adminError, setAdminError] = useState('');
// Team detail drawer
const [detailTeam, setDetailTeam] = useState<TeamDetail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
// Confirm toggle
const [confirmTeam, setConfirmTeam] = useState<Team | null>(null);
const fetchTeams = useCallback(async () => {
setLoading(true);
try {
const { data } = await adminApi.getTeams();
setTeams(data.results);
} catch {
showToast('加载团队列表失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchTeams(); }, [fetchTeams]);
const handleToggleStatus = async (team: Team) => {
try {
await adminApi.updateTeam(team.id, { is_active: !team.is_active });
showToast(team.is_active ? '已禁用团队' : '已启用团队');
fetchTeams();
} catch {
showToast('操作失败');
}
};
const resetCreateForm = () => {
setNewName(''); setNewMonthlyLimit('36000'); setNewDailyMemberLimit('600');
setCreateError('');
};
const handleCreateTeam = async () => {
setCreateError('');
if (!newName.trim()) { setCreateError('请输入团队名称'); return; }
try {
await adminApi.createTeam({
name: newName.trim(),
monthly_seconds_limit: Number(newMonthlyLimit),
daily_member_limit_default: Number(newDailyMemberLimit),
});
showToast('团队创建成功');
setCreateOpen(false);
resetCreateForm();
fetchTeams();
} catch (err: any) {
const msg = err.response?.data?.error || err.response?.data?.name?.[0] || '创建失败';
setCreateError(msg);
}
};
const handleTopUp = async () => {
if (!topupTeam) return;
const seconds = Number(topupSeconds);
if (!seconds || seconds <= 0) { showToast('请输入有效的秒数'); return; }
try {
await adminApi.topUpTeam(topupTeam.id, seconds);
showToast(`已为 ${topupTeam.name} 充值 ${fmtSec(seconds)}`);
setTopupTeam(null);
fetchTeams();
} catch {
showToast('充值失败');
}
};
const resetAdminForm = () => {
setAdminUsername(''); setAdminEmail(''); setAdminPassword('');
setAdminError('');
};
const handleCreateAdmin = async () => {
if (!adminTeam) return;
setAdminError('');
if (!adminUsername.trim()) { setAdminError('请输入用户名'); return; }
if (!adminEmail.trim()) { setAdminError('请输入邮箱'); return; }
if (adminPassword.length < 6) { setAdminError('密码至少6位'); return; }
try {
await adminApi.createTeamAdmin(adminTeam.id, {
username: adminUsername.trim(),
email: adminEmail.trim(),
password: adminPassword,
});
showToast('团队管理员创建成功');
setAdminTeam(null);
resetAdminForm();
fetchTeams();
} catch (err: any) {
const msg = err.response?.data?.error || err.response?.data?.username?.[0] || '创建失败';
setAdminError(msg);
}
};
const openDrawer = async (teamId: number) => {
try {
const { data } = await adminApi.getTeamDetail(teamId);
setDetailTeam(data);
setDrawerOpen(true);
} catch {
showToast('加载团队详情失败');
}
};
const colCount = 9;
return (
<div className={styles.page}>
<h1 className={styles.title}></h1>
<div className={styles.filters}>
<div className={styles.searchGroup}>
<span style={{ color: 'var(--color-text-secondary)', fontSize: 13 }}> {teams.length} </span>
</div>
<div className={styles.searchGroup}>
<button className={styles.refreshBtn} onClick={fetchTeams}></button>
<button className={styles.createBtn} onClick={() => { resetCreateForm(); setCreateOpen(true); }}>+ </button>
</div>
</div>
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i}>
{Array.from({ length: colCount }).map((_, j) => (
<td key={j}><div className={styles.skeletonCell} /></td>
))}
</tr>
))
) : teams.length === 0 ? (
<tr><td colSpan={colCount} className={styles.empty}></td></tr>
) : (
teams.map((t) => (
<tr key={t.id}>
<td>
<button className={styles.teamNameLink} onClick={() => openDrawer(t.id)}>
{t.name}
</button>
</td>
<td>{fmtSec(t.total_seconds_pool)}</td>
<td>{fmtSec(t.total_seconds_used)}</td>
<td>{fmtSec(t.remaining_seconds)}</td>
<td>{fmtSec(t.monthly_seconds_limit)}</td>
<td>{fmtSec(t.monthly_seconds_used)}</td>
<td>{t.member_count}</td>
<td>
<span className={`${styles.statusBadge} ${t.is_active ? styles.active : styles.disabled}`}>
{t.is_active ? '启用' : '禁用'}
</span>
</td>
<td>
<div className={styles.actions}>
<button className={styles.topupBtn} onClick={() => { setTopupTeam(t); setTopupSeconds('3600'); }}></button>
<button className={styles.adminBtn} onClick={() => { setAdminTeam(t); resetAdminForm(); }}></button>
<button
className={`${styles.toggleBtn} ${t.is_active ? styles.disableBtn : styles.enableBtn}`}
onClick={() => setConfirmTeam(t)}
>
{t.is_active ? '禁用' : '启用'}
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Create Team Modal */}
{createOpen && (
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setCreateOpen(false); }}>
<div className={styles.modal}>
<h3 className={styles.modalTitle}></h3>
<div className={styles.formGroup}>
<label></label>
<input type="text" value={newName} onChange={(e) => setNewName(e.target.value)} placeholder="请输入团队名称" />
</div>
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label></label>
<input type="number" value={newMonthlyLimit} onChange={(e) => setNewMonthlyLimit(e.target.value)} />
</div>
<div className={styles.formGroup}>
<label>()</label>
<input type="number" value={newDailyMemberLimit} onChange={(e) => setNewDailyMemberLimit(e.target.value)} />
</div>
</div>
{createError && <div className={styles.formError}>{createError}</div>}
<div className={styles.modalActions}>
<button className={styles.cancelBtn} onClick={() => setCreateOpen(false)}></button>
<button className={styles.saveBtn} onClick={handleCreateTeam}></button>
</div>
</div>
</div>
)}
{/* Top-up Modal */}
{topupTeam && (
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setTopupTeam(null); }}>
<div className={styles.modal}>
<h3 className={styles.modalTitle}> {topupTeam.name}</h3>
<div className={styles.formGroup}>
<label></label>
<input type="number" value={topupSeconds} onChange={(e) => setTopupSeconds(e.target.value)} placeholder="输入秒数" />
<div className={styles.formHint}>
: {fmtSec(topupTeam.remaining_seconds)} | : {fmtSec(topupTeam.remaining_seconds + (Number(topupSeconds) || 0))}
</div>
</div>
<div className={styles.modalActions}>
<button className={styles.cancelBtn} onClick={() => setTopupTeam(null)}></button>
<button className={styles.saveBtn} onClick={handleTopUp}></button>
</div>
</div>
</div>
)}
{/* Create Team Admin Modal */}
{adminTeam && (
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setAdminTeam(null); }}>
<div className={styles.modal}>
<h3 className={styles.modalTitle}> {adminTeam.name}</h3>
<div className={styles.formGroup}>
<label></label>
<input type="text" value={adminUsername} onChange={(e) => setAdminUsername(e.target.value)} placeholder="请输入用户名" />
</div>
<div className={styles.formGroup}>
<label></label>
<input type="email" value={adminEmail} onChange={(e) => setAdminEmail(e.target.value)} placeholder="请输入邮箱" />
</div>
<div className={styles.formGroup}>
<label></label>
<input type="password" value={adminPassword} onChange={(e) => setAdminPassword(e.target.value)} placeholder="至少6位" />
</div>
{adminError && <div className={styles.formError}>{adminError}</div>}
<div className={styles.modalActions}>
<button className={styles.cancelBtn} onClick={() => setAdminTeam(null)}></button>
<button className={styles.saveBtn} onClick={handleCreateAdmin}></button>
</div>
</div>
</div>
)}
<ConfirmModal
open={!!confirmTeam}
title={confirmTeam?.is_active ? '禁用团队' : '启用团队'}
message={confirmTeam?.is_active
? `确定要禁用团队「${confirmTeam?.name}」吗?禁用后该团队所有成员将无法生成视频。`
: `确定要启用团队「${confirmTeam?.name}」吗?`}
confirmText={confirmTeam?.is_active ? '禁用' : '启用'}
danger={confirmTeam?.is_active}
onConfirm={() => { if (confirmTeam) { handleToggleStatus(confirmTeam); setConfirmTeam(null); } }}
onCancel={() => setConfirmTeam(null)}
/>
{/* Team Detail Drawer */}
{drawerOpen && detailTeam && (
<div className={styles.drawerOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setDrawerOpen(false); }}>
<div className={styles.drawer}>
<div className={styles.drawerHeader}>
<h3>
{detailTeam.name}
<span className={`${styles.statusBadge} ${detailTeam.is_active ? styles.active : styles.disabled}`} style={{ marginLeft: 8 }}>
{detailTeam.is_active ? '启用' : '禁用'}
</span>
</h3>
<button className={styles.drawerClose} onClick={() => setDrawerOpen(false)}>×</button>
</div>
<div className={styles.drawerBody}>
<div className={styles.detailGrid}>
<div className={styles.detailItem}>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>{fmtSec(detailTeam.total_seconds_pool)}</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>{fmtSec(detailTeam.total_seconds_used)}</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>{fmtSec(detailTeam.remaining_seconds)}</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>{fmtSec(detailTeam.monthly_seconds_limit)}</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>{fmtSec(detailTeam.monthly_seconds_used)}</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>{detailTeam.member_count}</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}>()</span>
<span className={styles.detailValue}>{fmtSec(detailTeam.daily_member_limit_default)}</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>{new Date(detailTeam.created_at).toLocaleDateString('zh-CN')}</span>
</div>
</div>
<h4 className={styles.membersTitle}> ({detailTeam.members.length})</h4>
{detailTeam.members.length === 0 ? (
<div className={styles.empty}></div>
) : (
<table className={styles.memberTable}>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{detailTeam.members.map((m) => (
<tr key={m.id}>
<td>{m.username}</td>
<td>{m.email}</td>
<td>
{m.is_team_admin ? (
<span className={styles.adminBadge}></span>
) : '成员'}
</td>
<td>
<span className={`${styles.statusBadge} ${m.is_active ? styles.active : styles.disabled}`}>
{m.is_active ? '启用' : '禁用'}
</span>
</td>
<td>{fmtSec(m.daily_seconds_limit)}</td>
<td>{fmtSec(m.seconds_today)}</td>
<td>{fmtSec(m.seconds_this_month)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -2,6 +2,7 @@ import { useEffect, useState, useCallback } from 'react';
import { adminApi } from '../lib/api';
import type { AdminUser, AdminUserDetail } from '../types';
import { showToast } from '../components/Toast';
import { ConfirmModal } from '../components/ConfirmModal';
import styles from './UsersPage.module.css';
export function UsersPage() {
@ -22,6 +23,9 @@ export function UsersPage() {
const [detailUser, setDetailUser] = useState<AdminUserDetail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
// Confirm toggle
const [confirmUser, setConfirmUser] = useState<AdminUser | null>(null);
// Create user modal
const [createOpen, setCreateOpen] = useState(false);
const [newUsername, setNewUsername] = useState('');
@ -196,16 +200,16 @@ export function UsersPage() {
{u.is_active ? '启用' : '禁用'}
</span>
</td>
<td>{u.daily_seconds_limit}</td>
<td>{u.monthly_seconds_limit}</td>
<td>{u.seconds_today}</td>
<td>{u.seconds_this_month}</td>
<td>{u.daily_seconds_limit === -1 ? '不限' : u.daily_seconds_limit.toLocaleString() + 's'}</td>
<td>{u.monthly_seconds_limit === -1 ? '不限' : u.monthly_seconds_limit.toLocaleString() + 's'}</td>
<td>{u.seconds_today.toLocaleString()}s</td>
<td>{u.seconds_this_month.toLocaleString()}s</td>
<td>
<div className={styles.actions}>
<button className={styles.editBtn} onClick={() => openEditModal(u)}></button>
<button
className={`${styles.toggleBtn} ${u.is_active ? styles.disableBtn : styles.enableBtn}`}
onClick={() => handleToggleStatus(u)}
onClick={() => setConfirmUser(u)}
>
{u.is_active ? '禁用' : '启用'}
</button>
@ -240,10 +244,22 @@ export function UsersPage() {
</div>
)}
<ConfirmModal
open={!!confirmUser}
title={confirmUser?.is_active ? '禁用用户' : '启用用户'}
message={confirmUser?.is_active
? `确定要禁用用户「${confirmUser?.username}」吗?`
: `确定要启用用户「${confirmUser?.username}」吗?`}
confirmText={confirmUser?.is_active ? '禁用' : '启用'}
danger={confirmUser?.is_active}
onConfirm={() => { if (confirmUser) { handleToggleStatus(confirmUser); setConfirmUser(null); } }}
onCancel={() => setConfirmUser(null)}
/>
{/* Quota Edit Modal */}
{editUser && (
<div className={styles.modalOverlay} onClick={() => setEditUser(null)}>
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setEditUser(null); }}>
<div className={styles.modal}>
<h3 className={styles.modalTitle}> {editUser.username}</h3>
<div className={styles.formGroup}>
<label></label>
@ -263,8 +279,8 @@ export function UsersPage() {
{/* Create User Modal */}
{createOpen && (
<div className={styles.modalOverlay} onClick={() => setCreateOpen(false)}>
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setCreateOpen(false); }}>
<div className={styles.modal}>
<h3 className={styles.modalTitle}></h3>
<div className={styles.formGroup}>
<label></label>
@ -333,15 +349,15 @@ export function UsersPage() {
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}>/</span>
<span className={styles.detailValue}>{detailUser.seconds_today}s / {detailUser.daily_seconds_limit}s</span>
<span className={styles.detailValue}>{detailUser.seconds_today.toLocaleString()}s / {detailUser.daily_seconds_limit === -1 ? '不限' : detailUser.daily_seconds_limit.toLocaleString() + 's'}</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}>/</span>
<span className={styles.detailValue}>{detailUser.seconds_this_month}s / {detailUser.monthly_seconds_limit}s</span>
<span className={styles.detailValue}>{detailUser.seconds_this_month.toLocaleString()}s / {detailUser.monthly_seconds_limit === -1 ? '不限' : detailUser.monthly_seconds_limit.toLocaleString() + 's'}</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>{detailUser.seconds_total}s</span>
<span className={styles.detailValue}>{detailUser.seconds_total.toLocaleString()}s</span>
</div>
</div>
@ -354,7 +370,7 @@ export function UsersPage() {
<div key={r.id} className={styles.recordItem}>
<div className={styles.recordTime}>{new Date(r.created_at).toLocaleString('zh-CN')}</div>
<div className={styles.recordMeta}>
<span className={styles.recordSeconds}>{r.seconds_consumed}s</span>
<span className={styles.recordSeconds}>{r.seconds_consumed.toLocaleString()}s</span>
<span className={styles.recordMode}>{r.mode === 'universal' ? '全能参考' : '首尾帧'}</span>
<span className={`${styles.recordStatus} ${styles[r.status]}`}>{
{ queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' }[r.status]

View File

@ -1,5 +1,5 @@
import { create } from 'zustand';
import type { User, Quota } from '../types';
import type { User, Quota, TeamInfo } from '../types';
import { authApi } from '../lib/api';
interface AuthState {
@ -9,9 +9,10 @@ interface AuthState {
isAuthenticated: boolean;
isLoading: boolean;
quota: Quota | null;
team: TeamInfo | null;
teamDisabled: boolean;
login: (username: string, password: string) => Promise<void>;
register: (username: string, email: string, password: string) => Promise<void>;
logout: () => void;
refreshAccessToken: () => Promise<void>;
fetchUserInfo: () => Promise<void>;
@ -25,6 +26,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
isAuthenticated: !!localStorage.getItem('access_token'),
isLoading: true,
quota: null,
team: null,
teamDisabled: false,
login: async (username, password) => {
const { data } = await authApi.login(username, password);
@ -40,19 +43,6 @@ export const useAuthStore = create<AuthState>((set, get) => ({
await get().fetchUserInfo();
},
register: async (username, email, password) => {
const { data } = await authApi.register(username, email, password);
localStorage.setItem('access_token', data.tokens.access);
localStorage.setItem('refresh_token', data.tokens.refresh);
set({
user: data.user,
accessToken: data.tokens.access,
refreshToken: data.tokens.refresh,
isAuthenticated: true,
});
await get().fetchUserInfo();
},
logout: () => {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
@ -62,6 +52,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
refreshToken: null,
isAuthenticated: false,
quota: null,
team: null,
teamDisabled: false,
});
},
@ -76,8 +68,14 @@ export const useAuthStore = create<AuthState>((set, get) => ({
fetchUserInfo: async () => {
try {
const { data } = await authApi.getMe();
const { quota, ...user } = data;
set({ user, quota, isAuthenticated: true });
const { quota, team, team_disabled, ...user } = data;
set({
user,
quota,
team: team || null,
teamDisabled: team_disabled || false,
isAuthenticated: true,
});
} catch {
// Token invalid
get().logout();

View File

@ -3,6 +3,7 @@ export type ModelOption = 'seedance_2.0' | 'seedance_2.0_fast';
export type AspectRatio = '16:9' | '9:16' | '1:1' | '21:9' | '4:3' | '3:4';
export type Duration = 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15;
export type GenerationType = 'video' | 'image';
export type UserRole = 'super_admin' | 'team_admin' | 'member';
export interface UploadedFile {
id: string;
@ -70,6 +71,20 @@ export interface User {
username: string;
email: string;
is_staff: boolean;
is_team_admin: boolean;
role: UserRole;
team_name: string | null;
}
export interface TeamInfo {
id: number;
name: string;
total_seconds_pool: number;
total_seconds_used: number;
remaining_seconds: number;
monthly_seconds_limit: number;
monthly_seconds_used: number;
is_active: boolean;
}
// Phase 3: seconds-based quota
@ -85,9 +100,10 @@ export interface AuthTokens {
refresh: string;
}
// Phase 3: Admin types
// Admin types
export interface AdminStats {
total_users: number;
total_teams: number;
new_users_today: number;
seconds_consumed_today: number;
seconds_consumed_this_month: number;
@ -102,6 +118,10 @@ export interface AdminUser {
username: string;
email: string;
is_active: boolean;
is_staff: boolean;
is_team_admin: boolean;
team_id: number | null;
team_name: string | null;
date_joined: string;
daily_seconds_limit: number;
monthly_seconds_limit: number;
@ -110,7 +130,6 @@ export interface AdminUser {
}
export interface AdminUserDetail extends AdminUser {
is_staff: boolean;
seconds_total: number;
recent_records: AdminRecord[];
}
@ -120,6 +139,7 @@ export interface AdminRecord {
created_at: string;
user_id?: number;
username?: string;
team_name?: string;
seconds_consumed: number;
prompt: string;
mode: CreationMode;
@ -142,6 +162,14 @@ export interface ProfileOverview {
monthly_seconds_used: number;
total_seconds_used: number;
daily_trend: { date: string; seconds: number }[];
team?: {
name: string;
total_seconds_pool: number;
total_seconds_used: number;
remaining_seconds: number;
monthly_seconds_limit: number;
monthly_seconds_used: number;
};
}
export interface PaginatedResponse<T> {
@ -150,3 +178,40 @@ export interface PaginatedResponse<T> {
page_size: number;
results: T[];
}
// Team management types
export interface Team {
id: number;
name: string;
total_seconds_pool: number;
total_seconds_used: number;
remaining_seconds: number;
monthly_seconds_limit: number;
monthly_seconds_used: number;
daily_member_limit_default: number;
member_count: number;
is_active: boolean;
created_at: string;
}
export interface TeamDetail extends Team {
members: TeamMember[];
}
export interface TeamMember {
id: number;
username: string;
email: string;
is_team_admin: boolean;
is_active: boolean;
daily_seconds_limit: number;
monthly_seconds_limit: number;
seconds_today: number;
seconds_this_month: number;
date_joined: string;
}
export interface TeamStats {
daily_trend: { date: string; seconds: number }[];
member_consumption: { user_id: number; username: string; seconds_consumed: number }[];
}