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:
parent
f8358a28c6
commit
add3af7904
@ -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')}),
|
||||
)
|
||||
|
||||
@ -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='所属团队'),
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
]
|
||||
@ -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'
|
||||
|
||||
45
backend/apps/accounts/permissions.py
Normal file
45
backend/apps/accounts/permissions.py
Normal 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
|
||||
)
|
||||
@ -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):
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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'),
|
||||
]
|
||||
|
||||
@ -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'])
|
||||
|
||||
@ -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>
|
||||
|
||||
8
web/src/components/ConfirmModal.module.css
Normal file
8
web/src/components/ConfirmModal.module.css
Normal 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); }
|
||||
28
web/src/components/ConfirmModal.tsx
Normal file
28
web/src/components/ConfirmModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}</>;
|
||||
}
|
||||
|
||||
@ -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'}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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') =>
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 />;
|
||||
}
|
||||
|
||||
85
web/src/pages/TeamAdminLayout.tsx
Normal file
85
web/src/pages/TeamAdminLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
web/src/pages/TeamDashboardPage.tsx
Normal file
171
web/src/pages/TeamDashboardPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
241
web/src/pages/TeamMembersPage.tsx
Normal file
241
web/src/pages/TeamMembersPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
web/src/pages/TeamsPage.module.css
Normal file
92
web/src/pages/TeamsPage.module.css
Normal 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
400
web/src/pages/TeamsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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]
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 }[];
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user