feat: v0.12.0 用户总额度 + 并发控制 + 团管消费记录 + 安全加固
①用户总消费额度(User.spending_limit,默认-1不限,花完即停,含冻结中任务) ②团队并发任务控制(Team.max_concurrent_tasks,默认5,超限拒绝) ③额度检查竞态修复(Layer 1-4 全部移入 transaction.atomic + select_for_update) ④查询参数类型保护(_safe_int 替换所有裸 int() 调用,防 500) ⑤团管消费记录页(/team/records,按用户/日期筛选 + CSV 导出) ⑥超管用户页/团管成员页新增总额度列和编辑 ⑦超管团队页新增并发列和内联编辑 ⑧失败原因 tooltip 改右对齐防裁剪 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6a5ddbaf78
commit
203603f69a
@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-03-22 10:02
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0010_billing_data_migration'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='team',
|
||||||
|
name='max_concurrent_tasks',
|
||||||
|
field=models.IntegerField(default=5, verbose_name='最大并发任务数'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='spending_limit',
|
||||||
|
field=models.DecimalField(decimal_places=2, default=-1, max_digits=12, verbose_name='用户总消费额度(元)'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -18,6 +18,7 @@ class Team(models.Model):
|
|||||||
daily_member_spending_default = models.DecimalField(max_digits=12, decimal_places=2, default=50, verbose_name='新成员默认每日消费限额(元)')
|
daily_member_spending_default = models.DecimalField(max_digits=12, decimal_places=2, default=50, verbose_name='新成员默认每日消费限额(元)')
|
||||||
frozen_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name='冻结金额(元)')
|
frozen_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name='冻结金额(元)')
|
||||||
markup_percentage = models.DecimalField(max_digits=5, decimal_places=2, default=0, verbose_name='加价百分比')
|
markup_percentage = models.DecimalField(max_digits=5, decimal_places=2, default=0, verbose_name='加价百分比')
|
||||||
|
max_concurrent_tasks = models.IntegerField(default=5, verbose_name='最大并发任务数')
|
||||||
is_active = models.BooleanField(default=True, verbose_name='启用状态')
|
is_active = models.BooleanField(default=True, verbose_name='启用状态')
|
||||||
expected_regions = models.CharField(max_length=500, blank=True, default='', verbose_name='预期登录城市(逗号分隔)')
|
expected_regions = models.CharField(max_length=500, blank=True, default='', verbose_name='预期登录城市(逗号分隔)')
|
||||||
disabled_by = models.CharField(max_length=10, blank=True, default='', verbose_name='禁用来源')
|
disabled_by = models.CharField(max_length=10, blank=True, default='', verbose_name='禁用来源')
|
||||||
@ -55,6 +56,7 @@ class User(AbstractUser):
|
|||||||
# ── 次数限额(v0.10.0 新增) ──
|
# ── 次数限额(v0.10.0 新增) ──
|
||||||
daily_generation_limit = models.IntegerField(default=50, verbose_name='每日生成次数上限')
|
daily_generation_limit = models.IntegerField(default=50, verbose_name='每日生成次数上限')
|
||||||
monthly_generation_limit = models.IntegerField(default=1500, verbose_name='每月生成次数上限')
|
monthly_generation_limit = models.IntegerField(default=1500, verbose_name='每月生成次数上限')
|
||||||
|
spending_limit = models.DecimalField(max_digits=12, decimal_places=2, default=-1, verbose_name='用户总消费额度(元)')
|
||||||
must_change_password = models.BooleanField(default=True, verbose_name='必须修改密码')
|
must_change_password = models.BooleanField(default=True, verbose_name='必须修改密码')
|
||||||
disabled_by = models.CharField(max_length=10, blank=True, default='', verbose_name='禁用来源')
|
disabled_by = models.CharField(max_length=10, blank=True, default='', verbose_name='禁用来源')
|
||||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
||||||
|
|||||||
@ -13,6 +13,7 @@ class VideoGenerateSerializer(serializers.Serializer):
|
|||||||
class QuotaUpdateSerializer(serializers.Serializer):
|
class QuotaUpdateSerializer(serializers.Serializer):
|
||||||
daily_generation_limit = serializers.IntegerField(min_value=-1)
|
daily_generation_limit = serializers.IntegerField(min_value=-1)
|
||||||
monthly_generation_limit = serializers.IntegerField(min_value=-1)
|
monthly_generation_limit = serializers.IntegerField(min_value=-1)
|
||||||
|
spending_limit = serializers.DecimalField(max_digits=12, decimal_places=2, required=False)
|
||||||
|
|
||||||
|
|
||||||
class UserStatusSerializer(serializers.Serializer):
|
class UserStatusSerializer(serializers.Serializer):
|
||||||
@ -68,6 +69,7 @@ class TeamCreateSerializer(serializers.Serializer):
|
|||||||
markup_percentage = serializers.DecimalField(max_digits=5, decimal_places=2, min_value=0, required=True)
|
markup_percentage = serializers.DecimalField(max_digits=5, decimal_places=2, min_value=0, required=True)
|
||||||
monthly_spending_limit = serializers.DecimalField(max_digits=12, decimal_places=2, required=False, default=-1)
|
monthly_spending_limit = serializers.DecimalField(max_digits=12, decimal_places=2, required=False, default=-1)
|
||||||
daily_member_spending_default = serializers.DecimalField(max_digits=12, decimal_places=2, required=False, default=50)
|
daily_member_spending_default = serializers.DecimalField(max_digits=12, decimal_places=2, required=False, default=50)
|
||||||
|
max_concurrent_tasks = serializers.IntegerField(min_value=0, required=False, default=5)
|
||||||
expected_regions = serializers.CharField(max_length=500, required=True)
|
expected_regions = serializers.CharField(max_length=500, required=True)
|
||||||
|
|
||||||
|
|
||||||
@ -78,6 +80,7 @@ class TeamUpdateSerializer(serializers.Serializer):
|
|||||||
markup_percentage = serializers.DecimalField(max_digits=5, decimal_places=2, min_value=0, required=False)
|
markup_percentage = serializers.DecimalField(max_digits=5, decimal_places=2, min_value=0, required=False)
|
||||||
monthly_spending_limit = serializers.DecimalField(max_digits=12, decimal_places=2, required=False)
|
monthly_spending_limit = serializers.DecimalField(max_digits=12, decimal_places=2, required=False)
|
||||||
daily_member_spending_default = serializers.DecimalField(max_digits=12, decimal_places=2, required=False)
|
daily_member_spending_default = serializers.DecimalField(max_digits=12, decimal_places=2, required=False)
|
||||||
|
max_concurrent_tasks = serializers.IntegerField(min_value=0, required=False)
|
||||||
is_active = serializers.BooleanField(required=False)
|
is_active = serializers.BooleanField(required=False)
|
||||||
expected_regions = serializers.CharField(max_length=500, required=False, allow_blank=True)
|
expected_regions = serializers.CharField(max_length=500, required=False, allow_blank=True)
|
||||||
|
|
||||||
@ -121,3 +124,4 @@ class TeamMemberCreateSerializer(serializers.Serializer):
|
|||||||
class MemberQuotaSerializer(serializers.Serializer):
|
class MemberQuotaSerializer(serializers.Serializer):
|
||||||
daily_generation_limit = serializers.IntegerField(min_value=-1)
|
daily_generation_limit = serializers.IntegerField(min_value=-1)
|
||||||
monthly_generation_limit = serializers.IntegerField(min_value=-1)
|
monthly_generation_limit = serializers.IntegerField(min_value=-1)
|
||||||
|
spending_limit = serializers.DecimalField(max_digits=12, decimal_places=2, required=False)
|
||||||
|
|||||||
@ -58,6 +58,9 @@ urlpatterns = [
|
|||||||
path('team/members/<int:member_id>/quota', views.team_member_quota_view, name='team_member_quota'),
|
path('team/members/<int:member_id>/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'),
|
path('team/members/<int:member_id>/status', views.team_member_status_view, name='team_member_status'),
|
||||||
|
|
||||||
|
# ── Team Admin: Consumption Records ──
|
||||||
|
path('team/records', views.team_records_view, name='team_records'),
|
||||||
|
|
||||||
# ── Team Admin: Content Assets ──
|
# ── Team Admin: Content Assets ──
|
||||||
path('team/assets/overview', views.team_assets_overview, name='team_assets_overview'),
|
path('team/assets/overview', views.team_assets_overview, name='team_assets_overview'),
|
||||||
path('team/assets/member/<int:member_id>/videos', views.team_assets_member_videos, name='team_assets_member_videos'),
|
path('team/assets/member/<int:member_id>/videos', views.team_assets_member_videos, name='team_assets_member_videos'),
|
||||||
|
|||||||
@ -39,6 +39,15 @@ MAX_IMAGE_SIZE = 30 * 1024 * 1024 # 30MB
|
|||||||
MAX_VIDEO_SIZE = 50 * 1024 * 1024 # 50MB
|
MAX_VIDEO_SIZE = 50 * 1024 * 1024 # 50MB
|
||||||
MAX_AUDIO_SIZE = 15 * 1024 * 1024 # 15MB
|
MAX_AUDIO_SIZE = 15 * 1024 * 1024 # 15MB
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_int(value, default=0):
|
||||||
|
"""Safely convert query parameter to int, returning default on failure."""
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
# Columns added in migration 0003; may not exist in production DB yet.
|
# Columns added in migration 0003; may not exist in production DB yet.
|
||||||
_M0003_COLS = ('ark_task_id', 'result_url', 'error_message', 'reference_urls')
|
_M0003_COLS = ('ark_task_id', 'result_url', 'error_message', 'reference_urls')
|
||||||
_m0003_ok = None # None = unknown, True = columns exist, False = missing
|
_m0003_ok = None # None = unknown, True = columns exist, False = missing
|
||||||
@ -160,7 +169,11 @@ def video_generate_view(request):
|
|||||||
estimated_tokens = estimate_tokens(w, h, duration)
|
estimated_tokens = estimate_tokens(w, h, duration)
|
||||||
estimated_cost = calculate_cost(estimated_tokens, config.base_token_price, team.markup_percentage)
|
estimated_cost = calculate_cost(estimated_tokens, config.base_token_price, team.markup_percentage)
|
||||||
|
|
||||||
# ── Layer 1: 用户每日生成次数限额 (skip if -1) ──
|
# ── 所有额度检查在 transaction 内完成,select_for_update 串行化同团队请求 ──
|
||||||
|
with transaction.atomic():
|
||||||
|
locked_team = Team.objects.select_for_update().get(pk=team.pk)
|
||||||
|
|
||||||
|
# Layer 1: 用户每日生成次数限额 (skip if -1)
|
||||||
if user.daily_generation_limit != -1:
|
if user.daily_generation_limit != -1:
|
||||||
daily_count = user.generation_records.filter(created_at__date=today).count()
|
daily_count = user.generation_records.filter(created_at__date=today).count()
|
||||||
if daily_count >= user.daily_generation_limit:
|
if daily_count >= user.daily_generation_limit:
|
||||||
@ -174,7 +187,7 @@ def video_generate_view(request):
|
|||||||
).isoformat(),
|
).isoformat(),
|
||||||
}, status=status.HTTP_429_TOO_MANY_REQUESTS)
|
}, status=status.HTTP_429_TOO_MANY_REQUESTS)
|
||||||
|
|
||||||
# ── Layer 2: 用户每月生成次数限额 (skip if -1) ──
|
# Layer 2: 用户每月生成次数限额 (skip if -1)
|
||||||
if user.monthly_generation_limit != -1:
|
if user.monthly_generation_limit != -1:
|
||||||
monthly_count = user.generation_records.filter(
|
monthly_count = user.generation_records.filter(
|
||||||
created_at__date__gte=first_of_month
|
created_at__date__gte=first_of_month
|
||||||
@ -187,9 +200,32 @@ def video_generate_view(request):
|
|||||||
'monthly_generation_used': monthly_count,
|
'monthly_generation_used': monthly_count,
|
||||||
}, status=status.HTTP_429_TOO_MANY_REQUESTS)
|
}, status=status.HTTP_429_TOO_MANY_REQUESTS)
|
||||||
|
|
||||||
# ── Layer 3 & 4: 团队余额检查 + 冻结 (atomic with row lock) ──
|
# Layer 2.5: 用户总消费额度 (skip if -1)
|
||||||
with transaction.atomic():
|
from decimal import Decimal
|
||||||
locked_team = Team.objects.select_for_update().get(pk=team.pk)
|
if user.spending_limit != Decimal('-1'):
|
||||||
|
total_spent = GenerationRecord.objects.filter(
|
||||||
|
user=user,
|
||||||
|
status__in=['completed', 'processing', 'queued'],
|
||||||
|
).aggregate(total=Sum('cost_amount'))['total'] or Decimal('0')
|
||||||
|
if total_spent + estimated_cost > user.spending_limit:
|
||||||
|
return Response({
|
||||||
|
'error': 'spending_limit_exceeded',
|
||||||
|
'message': f'您的总消费已达上限(¥{user.spending_limit}),请联系管理员',
|
||||||
|
'spending_limit': float(user.spending_limit),
|
||||||
|
'total_spent': float(total_spent),
|
||||||
|
}, status=status.HTTP_429_TOO_MANY_REQUESTS)
|
||||||
|
|
||||||
|
# Layer 2.6: 团队并发限制
|
||||||
|
if locked_team.max_concurrent_tasks > 0:
|
||||||
|
processing_count = GenerationRecord.objects.filter(
|
||||||
|
user__team=locked_team,
|
||||||
|
status__in=['queued', 'processing'],
|
||||||
|
).count()
|
||||||
|
if processing_count >= locked_team.max_concurrent_tasks:
|
||||||
|
return Response({
|
||||||
|
'error': 'concurrent_limit',
|
||||||
|
'message': f'团队当前有 {processing_count} 个任务正在处理,已达并发上限 {processing_count}/{locked_team.max_concurrent_tasks}',
|
||||||
|
}, status=status.HTTP_429_TOO_MANY_REQUESTS)
|
||||||
|
|
||||||
# Layer 3: 团队月消费限额
|
# Layer 3: 团队月消费限额
|
||||||
if locked_team.monthly_spending_limit != -1:
|
if locked_team.monthly_spending_limit != -1:
|
||||||
@ -401,8 +437,8 @@ def video_tasks_list_view(request):
|
|||||||
offset: Number of tasks to skip (default 0).
|
offset: Number of tasks to skip (default 0).
|
||||||
"""
|
"""
|
||||||
user = request.user
|
user = request.user
|
||||||
page_size = min(int(request.query_params.get('page_size', 20)), 100)
|
page_size = min(_safe_int(request.query_params.get('page_size', 20), 20), 100)
|
||||||
offset = max(int(request.query_params.get('offset', 0)), 0)
|
offset = max(_safe_int(request.query_params.get('offset', 0), 0), 0)
|
||||||
|
|
||||||
qs = user.generation_records.order_by('-created_at')
|
qs = user.generation_records.order_by('-created_at')
|
||||||
total = qs.count()
|
total = qs.count()
|
||||||
@ -742,6 +778,10 @@ def admin_teams_list_view(request):
|
|||||||
'frozen_amount': float(t.frozen_amount),
|
'frozen_amount': float(t.frozen_amount),
|
||||||
'markup_percentage': float(t.markup_percentage),
|
'markup_percentage': float(t.markup_percentage),
|
||||||
'daily_member_limit_default': t.daily_member_limit_default,
|
'daily_member_limit_default': t.daily_member_limit_default,
|
||||||
|
'max_concurrent_tasks': t.max_concurrent_tasks,
|
||||||
|
'current_processing': GenerationRecord.objects.filter(
|
||||||
|
user__team=t, status__in=['queued', 'processing'],
|
||||||
|
).count(),
|
||||||
'member_count': t.members.count(),
|
'member_count': t.members.count(),
|
||||||
'is_active': t.is_active,
|
'is_active': t.is_active,
|
||||||
'expected_regions': t.expected_regions,
|
'expected_regions': t.expected_regions,
|
||||||
@ -837,6 +877,7 @@ def admin_team_detail_view(request, team_id):
|
|||||||
'markup_percentage': float(team.markup_percentage),
|
'markup_percentage': float(team.markup_percentage),
|
||||||
'monthly_spending_limit': float(team.monthly_spending_limit),
|
'monthly_spending_limit': float(team.monthly_spending_limit),
|
||||||
'daily_member_spending_default': float(team.daily_member_spending_default),
|
'daily_member_spending_default': float(team.daily_member_spending_default),
|
||||||
|
'max_concurrent_tasks': team.max_concurrent_tasks,
|
||||||
'is_active': team.is_active,
|
'is_active': team.is_active,
|
||||||
'expected_regions': team.expected_regions,
|
'expected_regions': team.expected_regions,
|
||||||
'disabled_by': team.disabled_by,
|
'disabled_by': team.disabled_by,
|
||||||
@ -921,6 +962,10 @@ def admin_team_detail_view(request, team_id):
|
|||||||
'frozen_amount': float(team.frozen_amount),
|
'frozen_amount': float(team.frozen_amount),
|
||||||
'markup_percentage': float(team.markup_percentage),
|
'markup_percentage': float(team.markup_percentage),
|
||||||
'daily_member_limit_default': team.daily_member_limit_default,
|
'daily_member_limit_default': team.daily_member_limit_default,
|
||||||
|
'max_concurrent_tasks': team.max_concurrent_tasks,
|
||||||
|
'current_processing': GenerationRecord.objects.filter(
|
||||||
|
user__team=team, status__in=['queued', 'processing'],
|
||||||
|
).count(),
|
||||||
'member_count': team.members.count(),
|
'member_count': team.members.count(),
|
||||||
'is_active': team.is_active,
|
'is_active': team.is_active,
|
||||||
'expected_regions': team.expected_regions,
|
'expected_regions': team.expected_regions,
|
||||||
@ -1124,8 +1169,8 @@ def admin_users_list_view(request):
|
|||||||
today = timezone.now().date()
|
today = timezone.now().date()
|
||||||
first_of_month = today.replace(day=1)
|
first_of_month = today.replace(day=1)
|
||||||
|
|
||||||
page = int(request.query_params.get('page', 1))
|
page = _safe_int(request.query_params.get('page', 1), 1)
|
||||||
page_size = min(int(request.query_params.get('page_size', 20)), 100)
|
page_size = min(_safe_int(request.query_params.get('page_size', 20), 20), 100)
|
||||||
search = request.query_params.get('search', '').strip()
|
search = request.query_params.get('search', '').strip()
|
||||||
status_filter = request.query_params.get('status', '').strip()
|
status_filter = request.query_params.get('status', '').strip()
|
||||||
team_id = request.query_params.get('team_id', '').strip()
|
team_id = request.query_params.get('team_id', '').strip()
|
||||||
@ -1156,6 +1201,10 @@ def admin_users_list_view(request):
|
|||||||
filter=Q(generation_records__created_at__date__gte=first_of_month),
|
filter=Q(generation_records__created_at__date__gte=first_of_month),
|
||||||
),
|
),
|
||||||
is_online=Exists(ActiveSession.objects.filter(user_id=OuterRef('pk'))),
|
is_online=Exists(ActiveSession.objects.filter(user_id=OuterRef('pk'))),
|
||||||
|
total_spent_all=Sum(
|
||||||
|
'generation_records__cost_amount',
|
||||||
|
filter=Q(generation_records__status='completed'),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
if search:
|
if search:
|
||||||
@ -1165,7 +1214,7 @@ def admin_users_list_view(request):
|
|||||||
elif status_filter == 'disabled':
|
elif status_filter == 'disabled':
|
||||||
qs = qs.filter(is_active=False)
|
qs = qs.filter(is_active=False)
|
||||||
if team_id:
|
if team_id:
|
||||||
qs = qs.filter(team_id=int(team_id))
|
qs = qs.filter(team_id=_safe_int(team_id))
|
||||||
|
|
||||||
total = qs.count()
|
total = qs.count()
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
@ -1194,6 +1243,8 @@ def admin_users_list_view(request):
|
|||||||
'generations_this_month': u.generations_this_month or 0,
|
'generations_this_month': u.generations_this_month or 0,
|
||||||
'spent_today': float(u.spent_today or 0),
|
'spent_today': float(u.spent_today or 0),
|
||||||
'spent_this_month': float(u.spent_this_month or 0),
|
'spent_this_month': float(u.spent_this_month or 0),
|
||||||
|
'spending_limit': float(u.spending_limit),
|
||||||
|
'total_spent': float(u.total_spent_all or 0),
|
||||||
'is_online': u.is_online,
|
'is_online': u.is_online,
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -1306,15 +1357,21 @@ def admin_user_quota_view(request, user_id):
|
|||||||
before = {
|
before = {
|
||||||
'daily_generation_limit': user.daily_generation_limit,
|
'daily_generation_limit': user.daily_generation_limit,
|
||||||
'monthly_generation_limit': user.monthly_generation_limit,
|
'monthly_generation_limit': user.monthly_generation_limit,
|
||||||
|
'spending_limit': float(user.spending_limit),
|
||||||
}
|
}
|
||||||
|
update_fields = ['daily_generation_limit', 'monthly_generation_limit']
|
||||||
user.daily_generation_limit = serializer.validated_data['daily_generation_limit']
|
user.daily_generation_limit = serializer.validated_data['daily_generation_limit']
|
||||||
user.monthly_generation_limit = serializer.validated_data['monthly_generation_limit']
|
user.monthly_generation_limit = serializer.validated_data['monthly_generation_limit']
|
||||||
user.save(update_fields=['daily_generation_limit', 'monthly_generation_limit'])
|
if 'spending_limit' in serializer.validated_data:
|
||||||
|
user.spending_limit = serializer.validated_data['spending_limit']
|
||||||
|
update_fields.append('spending_limit')
|
||||||
|
user.save(update_fields=update_fields)
|
||||||
log_admin_action(request, 'user_quota_update', 'user', target_id=user.id, target_name=user.username,
|
log_admin_action(request, 'user_quota_update', 'user', target_id=user.id, target_name=user.username,
|
||||||
before=before,
|
before=before,
|
||||||
after={
|
after={
|
||||||
'daily_generation_limit': user.daily_generation_limit,
|
'daily_generation_limit': user.daily_generation_limit,
|
||||||
'monthly_generation_limit': user.monthly_generation_limit,
|
'monthly_generation_limit': user.monthly_generation_limit,
|
||||||
|
'spending_limit': float(user.spending_limit),
|
||||||
})
|
})
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
@ -1322,6 +1379,7 @@ def admin_user_quota_view(request, user_id):
|
|||||||
'username': user.username,
|
'username': user.username,
|
||||||
'daily_generation_limit': user.daily_generation_limit,
|
'daily_generation_limit': user.daily_generation_limit,
|
||||||
'monthly_generation_limit': user.monthly_generation_limit,
|
'monthly_generation_limit': user.monthly_generation_limit,
|
||||||
|
'spending_limit': float(user.spending_limit),
|
||||||
'daily_seconds_limit': user.daily_seconds_limit,
|
'daily_seconds_limit': user.daily_seconds_limit,
|
||||||
'monthly_seconds_limit': user.monthly_seconds_limit,
|
'monthly_seconds_limit': user.monthly_seconds_limit,
|
||||||
'updated_at': timezone.now().isoformat(),
|
'updated_at': timezone.now().isoformat(),
|
||||||
@ -1431,8 +1489,8 @@ def admin_create_user_view(request):
|
|||||||
@permission_classes([IsSuperAdmin])
|
@permission_classes([IsSuperAdmin])
|
||||||
def admin_records_view(request):
|
def admin_records_view(request):
|
||||||
"""GET /api/v1/admin/records"""
|
"""GET /api/v1/admin/records"""
|
||||||
page = int(request.query_params.get('page', 1))
|
page = _safe_int(request.query_params.get('page', 1), 1)
|
||||||
page_size = min(int(request.query_params.get('page_size', 20)), 100)
|
page_size = min(_safe_int(request.query_params.get('page_size', 20), 20), 100)
|
||||||
search = request.query_params.get('search', '').strip()
|
search = request.query_params.get('search', '').strip()
|
||||||
start_date = request.query_params.get('start_date', '').strip()
|
start_date = request.query_params.get('start_date', '').strip()
|
||||||
end_date = request.query_params.get('end_date', '').strip()
|
end_date = request.query_params.get('end_date', '').strip()
|
||||||
@ -1447,7 +1505,7 @@ def admin_records_view(request):
|
|||||||
if end_date:
|
if end_date:
|
||||||
qs = qs.filter(created_at__date__lte=end_date)
|
qs = qs.filter(created_at__date__lte=end_date)
|
||||||
if team_id:
|
if team_id:
|
||||||
qs = qs.filter(user__team_id=int(team_id))
|
qs = qs.filter(user__team_id=_safe_int(team_id))
|
||||||
|
|
||||||
total = qs.count()
|
total = qs.count()
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
@ -1481,6 +1539,62 @@ def admin_records_view(request):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Team Admin: Consumption Records
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsTeamAdmin])
|
||||||
|
def team_records_view(request):
|
||||||
|
"""GET /api/v1/team/records — 团管查看本团队消费记录"""
|
||||||
|
team = request.user.team
|
||||||
|
page = _safe_int(request.query_params.get('page', 1), 1)
|
||||||
|
page_size = min(_safe_int(request.query_params.get('page_size', 20), 20), 100)
|
||||||
|
search = request.query_params.get('search', '').strip()
|
||||||
|
start_date = request.query_params.get('start_date', '').strip()
|
||||||
|
end_date = request.query_params.get('end_date', '').strip()
|
||||||
|
|
||||||
|
qs = GenerationRecord.objects.filter(
|
||||||
|
user__team=team
|
||||||
|
).select_related('user').order_by('-created_at')
|
||||||
|
|
||||||
|
if search:
|
||||||
|
qs = qs.filter(user__username__icontains=search)
|
||||||
|
if start_date:
|
||||||
|
qs = qs.filter(created_at__date__gte=start_date)
|
||||||
|
if end_date:
|
||||||
|
qs = qs.filter(created_at__date__lte=end_date)
|
||||||
|
|
||||||
|
total = qs.count()
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
records = _eval_qs(qs[offset:offset + page_size])
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for r in records:
|
||||||
|
results.append({
|
||||||
|
'id': r.id,
|
||||||
|
'created_at': r.created_at.isoformat(),
|
||||||
|
'user_id': r.user_id,
|
||||||
|
'username': r.user.username,
|
||||||
|
'seconds_consumed': r.seconds_consumed,
|
||||||
|
'tokens_consumed': r.tokens_consumed,
|
||||||
|
'cost_amount': float(r.cost_amount),
|
||||||
|
'prompt': r.prompt,
|
||||||
|
'mode': r.mode,
|
||||||
|
'model': r.model,
|
||||||
|
'aspect_ratio': r.aspect_ratio,
|
||||||
|
'status': r.status,
|
||||||
|
'error_message': r.error_message or '',
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'total': total,
|
||||||
|
'page': page,
|
||||||
|
'page_size': page_size,
|
||||||
|
'results': results,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
# Admin: System Settings
|
# Admin: System Settings
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
@ -1548,8 +1662,8 @@ def admin_settings_view(request):
|
|||||||
@permission_classes([IsSuperAdmin])
|
@permission_classes([IsSuperAdmin])
|
||||||
def admin_login_anomalies_view(request):
|
def admin_login_anomalies_view(request):
|
||||||
"""GET /api/v1/admin/anomalies — Login anomaly records list."""
|
"""GET /api/v1/admin/anomalies — Login anomaly records list."""
|
||||||
page = int(request.query_params.get('page', 1))
|
page = _safe_int(request.query_params.get('page', 1), 1)
|
||||||
page_size = min(int(request.query_params.get('page_size', 20)), 100)
|
page_size = min(_safe_int(request.query_params.get('page_size', 20), 20), 100)
|
||||||
team_id = request.query_params.get('team_id', '').strip()
|
team_id = request.query_params.get('team_id', '').strip()
|
||||||
rule = request.query_params.get('rule', '').strip()
|
rule = request.query_params.get('rule', '').strip()
|
||||||
level = request.query_params.get('level', '').strip()
|
level = request.query_params.get('level', '').strip()
|
||||||
@ -1559,7 +1673,7 @@ def admin_login_anomalies_view(request):
|
|||||||
qs = LoginAnomaly.objects.select_related('team', 'user', 'login_record').all()
|
qs = LoginAnomaly.objects.select_related('team', 'user', 'login_record').all()
|
||||||
|
|
||||||
if team_id:
|
if team_id:
|
||||||
qs = qs.filter(team_id=int(team_id))
|
qs = qs.filter(team_id=_safe_int(team_id))
|
||||||
if rule:
|
if rule:
|
||||||
qs = qs.filter(rule=rule)
|
qs = qs.filter(rule=rule)
|
||||||
if level:
|
if level:
|
||||||
@ -1694,8 +1808,8 @@ def admin_team_apply_learned_regions_view(request, team_id):
|
|||||||
@permission_classes([IsSuperAdmin])
|
@permission_classes([IsSuperAdmin])
|
||||||
def admin_audit_logs_view(request):
|
def admin_audit_logs_view(request):
|
||||||
"""GET /api/v1/admin/logs — Query admin audit logs."""
|
"""GET /api/v1/admin/logs — Query admin audit logs."""
|
||||||
page = int(request.query_params.get('page', 1))
|
page = _safe_int(request.query_params.get('page', 1), 1)
|
||||||
page_size = min(int(request.query_params.get('page_size', 20)), 100)
|
page_size = min(_safe_int(request.query_params.get('page_size', 20), 20), 100)
|
||||||
action = request.query_params.get('action', '').strip()
|
action = request.query_params.get('action', '').strip()
|
||||||
operator = request.query_params.get('operator', '').strip()
|
operator = request.query_params.get('operator', '').strip()
|
||||||
start_date = request.query_params.get('start_date', '').strip()
|
start_date = request.query_params.get('start_date', '').strip()
|
||||||
@ -1896,6 +2010,10 @@ def team_members_list_view(request):
|
|||||||
filter=Q(generation_records__created_at__date__gte=first_of_month),
|
filter=Q(generation_records__created_at__date__gte=first_of_month),
|
||||||
),
|
),
|
||||||
is_online=Exists(ActiveSession.objects.filter(user_id=OuterRef('pk'))),
|
is_online=Exists(ActiveSession.objects.filter(user_id=OuterRef('pk'))),
|
||||||
|
total_spent_all=Sum(
|
||||||
|
'generation_records__cost_amount',
|
||||||
|
filter=Q(generation_records__status='completed'),
|
||||||
|
),
|
||||||
).order_by('-date_joined')
|
).order_by('-date_joined')
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
@ -1915,6 +2033,8 @@ def team_members_list_view(request):
|
|||||||
'generations_this_month': m.generations_this_month or 0,
|
'generations_this_month': m.generations_this_month or 0,
|
||||||
'spent_today': float(m.spent_today or 0),
|
'spent_today': float(m.spent_today or 0),
|
||||||
'spent_this_month': float(m.spent_this_month or 0),
|
'spent_this_month': float(m.spent_this_month or 0),
|
||||||
|
'spending_limit': float(m.spending_limit),
|
||||||
|
'total_spent': float(m.total_spent_all or 0),
|
||||||
'is_online': m.is_online,
|
'is_online': m.is_online,
|
||||||
'date_joined': m.date_joined.isoformat(),
|
'date_joined': m.date_joined.isoformat(),
|
||||||
} for m in members],
|
} for m in members],
|
||||||
@ -2060,15 +2180,21 @@ def team_member_quota_view(request, member_id):
|
|||||||
before = {
|
before = {
|
||||||
'daily_generation_limit': member.daily_generation_limit,
|
'daily_generation_limit': member.daily_generation_limit,
|
||||||
'monthly_generation_limit': member.monthly_generation_limit,
|
'monthly_generation_limit': member.monthly_generation_limit,
|
||||||
|
'spending_limit': float(member.spending_limit),
|
||||||
}
|
}
|
||||||
|
update_fields = ['daily_generation_limit', 'monthly_generation_limit']
|
||||||
member.daily_generation_limit = serializer.validated_data['daily_generation_limit']
|
member.daily_generation_limit = serializer.validated_data['daily_generation_limit']
|
||||||
member.monthly_generation_limit = serializer.validated_data['monthly_generation_limit']
|
member.monthly_generation_limit = serializer.validated_data['monthly_generation_limit']
|
||||||
member.save(update_fields=['daily_generation_limit', 'monthly_generation_limit'])
|
if 'spending_limit' in serializer.validated_data:
|
||||||
|
member.spending_limit = serializer.validated_data['spending_limit']
|
||||||
|
update_fields.append('spending_limit')
|
||||||
|
member.save(update_fields=update_fields)
|
||||||
log_admin_action(request, 'member_quota_update', 'user', target_id=member.id, target_name=member.username,
|
log_admin_action(request, 'member_quota_update', 'user', target_id=member.id, target_name=member.username,
|
||||||
before=before,
|
before=before,
|
||||||
after={
|
after={
|
||||||
'daily_generation_limit': member.daily_generation_limit,
|
'daily_generation_limit': member.daily_generation_limit,
|
||||||
'monthly_generation_limit': member.monthly_generation_limit,
|
'monthly_generation_limit': member.monthly_generation_limit,
|
||||||
|
'spending_limit': float(member.spending_limit),
|
||||||
})
|
})
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
@ -2076,6 +2202,7 @@ def team_member_quota_view(request, member_id):
|
|||||||
'username': member.username,
|
'username': member.username,
|
||||||
'daily_generation_limit': member.daily_generation_limit,
|
'daily_generation_limit': member.daily_generation_limit,
|
||||||
'monthly_generation_limit': member.monthly_generation_limit,
|
'monthly_generation_limit': member.monthly_generation_limit,
|
||||||
|
'spending_limit': float(member.spending_limit),
|
||||||
'daily_seconds_limit': member.daily_seconds_limit,
|
'daily_seconds_limit': member.daily_seconds_limit,
|
||||||
'monthly_seconds_limit': member.monthly_seconds_limit,
|
'monthly_seconds_limit': member.monthly_seconds_limit,
|
||||||
})
|
})
|
||||||
@ -2237,8 +2364,8 @@ def profile_overview_view(request):
|
|||||||
def profile_records_view(request):
|
def profile_records_view(request):
|
||||||
"""GET /api/v1/profile/records"""
|
"""GET /api/v1/profile/records"""
|
||||||
user = request.user
|
user = request.user
|
||||||
page = int(request.query_params.get('page', 1))
|
page = _safe_int(request.query_params.get('page', 1), 1)
|
||||||
page_size = min(int(request.query_params.get('page_size', 20)), 100)
|
page_size = min(_safe_int(request.query_params.get('page_size', 20), 20), 100)
|
||||||
|
|
||||||
qs = user.generation_records.order_by('-created_at')
|
qs = user.generation_records.order_by('-created_at')
|
||||||
total = qs.count()
|
total = qs.count()
|
||||||
@ -2371,8 +2498,8 @@ def admin_assets_user_videos(request, user_id):
|
|||||||
except User.DoesNotExist:
|
except User.DoesNotExist:
|
||||||
return Response({'error': '用户不存在'}, status=status.HTTP_404_NOT_FOUND)
|
return Response({'error': '用户不存在'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
page = int(request.query_params.get('page', 1))
|
page = _safe_int(request.query_params.get('page', 1), 1)
|
||||||
page_size = min(int(request.query_params.get('page_size', 30)), 100)
|
page_size = min(_safe_int(request.query_params.get('page_size', 30), 30), 100)
|
||||||
|
|
||||||
qs = target_user.generation_records.filter(status='completed').order_by('-created_at')
|
qs = target_user.generation_records.filter(status='completed').order_by('-created_at')
|
||||||
total = qs.count()
|
total = qs.count()
|
||||||
@ -2452,8 +2579,8 @@ def team_assets_member_videos(request, member_id):
|
|||||||
except User.DoesNotExist:
|
except User.DoesNotExist:
|
||||||
return Response({'error': '成员不存在'}, status=status.HTTP_404_NOT_FOUND)
|
return Response({'error': '成员不存在'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
page = int(request.query_params.get('page', 1))
|
page = _safe_int(request.query_params.get('page', 1), 1)
|
||||||
page_size = min(int(request.query_params.get('page_size', 30)), 100)
|
page_size = min(_safe_int(request.query_params.get('page_size', 30), 30), 100)
|
||||||
|
|
||||||
qs = member.generation_records.filter(status='completed').order_by('-created_at')
|
qs = member.generation_records.filter(status='completed').order_by('-created_at')
|
||||||
total = qs.count()
|
total = qs.count()
|
||||||
@ -2492,8 +2619,8 @@ def team_assets_member_videos(request, member_id):
|
|||||||
@permission_classes([IsSuperAdmin])
|
@permission_classes([IsSuperAdmin])
|
||||||
def admin_login_records_view(request):
|
def admin_login_records_view(request):
|
||||||
"""GET /api/v1/admin/login-records"""
|
"""GET /api/v1/admin/login-records"""
|
||||||
page = int(request.query_params.get('page', 1))
|
page = _safe_int(request.query_params.get('page', 1), 1)
|
||||||
page_size = min(int(request.query_params.get('page_size', 20)), 100)
|
page_size = min(_safe_int(request.query_params.get('page_size', 20), 20), 100)
|
||||||
search = request.query_params.get('search', '').strip()
|
search = request.query_params.get('search', '').strip()
|
||||||
team_id = request.query_params.get('team_id', '').strip()
|
team_id = request.query_params.get('team_id', '').strip()
|
||||||
start_date = request.query_params.get('start_date', '').strip()
|
start_date = request.query_params.get('start_date', '').strip()
|
||||||
@ -2505,7 +2632,7 @@ def admin_login_records_view(request):
|
|||||||
if search:
|
if search:
|
||||||
qs = qs.filter(user__username__icontains=search)
|
qs = qs.filter(user__username__icontains=search)
|
||||||
if team_id:
|
if team_id:
|
||||||
qs = qs.filter(team_id=int(team_id))
|
qs = qs.filter(team_id=_safe_int(team_id))
|
||||||
if start_date:
|
if start_date:
|
||||||
qs = qs.filter(created_at__date__gte=start_date)
|
qs = qs.filter(created_at__date__gte=start_date)
|
||||||
if end_date:
|
if end_date:
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import { AssetsPage } from './pages/AssetsPage';
|
|||||||
import { TeamAdminLayout } from './pages/TeamAdminLayout';
|
import { TeamAdminLayout } from './pages/TeamAdminLayout';
|
||||||
import { TeamDashboardPage } from './pages/TeamDashboardPage';
|
import { TeamDashboardPage } from './pages/TeamDashboardPage';
|
||||||
import { TeamMembersPage } from './pages/TeamMembersPage';
|
import { TeamMembersPage } from './pages/TeamMembersPage';
|
||||||
|
import { TeamRecordsPage } from './pages/TeamRecordsPage';
|
||||||
import { AdminAssetsPage } from './pages/AdminAssetsPage';
|
import { AdminAssetsPage } from './pages/AdminAssetsPage';
|
||||||
import { TeamAssetsPage } from './pages/TeamAssetsPage';
|
import { TeamAssetsPage } from './pages/TeamAssetsPage';
|
||||||
|
|
||||||
@ -96,6 +97,7 @@ export default function App() {
|
|||||||
<Route index element={<Navigate to="/team/dashboard" replace />} />
|
<Route index element={<Navigate to="/team/dashboard" replace />} />
|
||||||
<Route path="dashboard" element={<TeamDashboardPage />} />
|
<Route path="dashboard" element={<TeamDashboardPage />} />
|
||||||
<Route path="members" element={<TeamMembersPage />} />
|
<Route path="members" element={<TeamMembersPage />} />
|
||||||
|
<Route path="records" element={<TeamRecordsPage />} />
|
||||||
<Route path="assets" element={<TeamAssetsPage />} />
|
<Route path="assets" element={<TeamAssetsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@ -171,7 +171,7 @@ export const adminApi = {
|
|||||||
getTeamDetail: (teamId: number) =>
|
getTeamDetail: (teamId: number) =>
|
||||||
api.get<TeamDetail>(`/admin/teams/${teamId}`),
|
api.get<TeamDetail>(`/admin/teams/${teamId}`),
|
||||||
|
|
||||||
updateTeam: (teamId: number, data: { name?: string; monthly_seconds_limit?: number; monthly_spending_limit?: number; daily_member_limit_default?: number; markup_percentage?: number; is_active?: boolean; expected_regions?: string; anomaly_config?: Partial<TeamAnomalyConfig> }) =>
|
updateTeam: (teamId: number, data: { name?: string; monthly_seconds_limit?: number; monthly_spending_limit?: number; daily_member_limit_default?: number; markup_percentage?: number; max_concurrent_tasks?: number; is_active?: boolean; expected_regions?: string; anomaly_config?: Partial<TeamAnomalyConfig> }) =>
|
||||||
api.put(`/admin/teams/${teamId}`, data),
|
api.put(`/admin/teams/${teamId}`, data),
|
||||||
|
|
||||||
topUpTeam: (teamId: number, amount: number) =>
|
topUpTeam: (teamId: number, amount: number) =>
|
||||||
@ -206,10 +206,11 @@ export const adminApi = {
|
|||||||
getUserDetail: (userId: number) =>
|
getUserDetail: (userId: number) =>
|
||||||
api.get<AdminUserDetail>(`/admin/users/${userId}`),
|
api.get<AdminUserDetail>(`/admin/users/${userId}`),
|
||||||
|
|
||||||
updateUserQuota: (userId: number, daily: number, monthly: number) =>
|
updateUserQuota: (userId: number, daily: number, monthly: number, spendingLimit?: number) =>
|
||||||
api.put(`/admin/users/${userId}/quota`, {
|
api.put(`/admin/users/${userId}/quota`, {
|
||||||
daily_generation_limit: daily,
|
daily_generation_limit: daily,
|
||||||
monthly_generation_limit: monthly,
|
monthly_generation_limit: monthly,
|
||||||
|
...(spendingLimit !== undefined && { spending_limit: spendingLimit }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
updateUserStatus: (userId: number, isActive: boolean) =>
|
updateUserStatus: (userId: number, isActive: boolean) =>
|
||||||
@ -326,10 +327,11 @@ export const teamApi = {
|
|||||||
getMemberDetail: (memberId: number) =>
|
getMemberDetail: (memberId: number) =>
|
||||||
api.get('/team/members/' + memberId),
|
api.get('/team/members/' + memberId),
|
||||||
|
|
||||||
updateMemberQuota: (memberId: number, daily: number, monthly: number) =>
|
updateMemberQuota: (memberId: number, daily: number, monthly: number, spendingLimit?: number) =>
|
||||||
api.put(`/team/members/${memberId}/quota`, {
|
api.put(`/team/members/${memberId}/quota`, {
|
||||||
daily_generation_limit: daily,
|
daily_generation_limit: daily,
|
||||||
monthly_generation_limit: monthly,
|
monthly_generation_limit: monthly,
|
||||||
|
...(spendingLimit !== undefined && { spending_limit: spendingLimit }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
updateMemberStatus: (memberId: number, isActive: boolean) =>
|
updateMemberStatus: (memberId: number, isActive: boolean) =>
|
||||||
@ -355,6 +357,16 @@ export const teamApi = {
|
|||||||
page_size: number;
|
page_size: number;
|
||||||
results: AssetVideo[];
|
results: AssetVideo[];
|
||||||
}>(`/team/assets/member/${memberId}/videos`, { params: { page, page_size: pageSize } }),
|
}>(`/team/assets/member/${memberId}/videos`, { params: { page, page_size: pageSize } }),
|
||||||
|
|
||||||
|
// Consumption Records
|
||||||
|
getRecords: (params: {
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
search?: string;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
} = {}) =>
|
||||||
|
api.get<{ total: number; page: number; page_size: number; results: AdminRecord[] }>('/team/records', { params }),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Profile APIs
|
// Profile APIs
|
||||||
|
|||||||
@ -42,12 +42,12 @@
|
|||||||
.completed { background: rgba(0, 184, 148, 0.15); color: var(--color-success); }
|
.completed { background: rgba(0, 184, 148, 0.15); color: var(--color-success); }
|
||||||
.failed { background: rgba(231, 76, 60, 0.15); color: var(--color-danger); }
|
.failed { background: rgba(231, 76, 60, 0.15); color: var(--color-danger); }
|
||||||
.statusCell { position: relative; }
|
.statusCell { position: relative; }
|
||||||
.statusCell:hover .errorTooltip { opacity: 1; visibility: visible; transform: translateX(-50%) translateY(0); }
|
.statusCell:hover .errorTooltip { opacity: 1; visibility: visible; transform: translateY(0); }
|
||||||
.errorTooltip {
|
.errorTooltip {
|
||||||
position: absolute; bottom: calc(100% + 4px); left: 50%; transform: translateX(-50%) translateY(4px);
|
position: absolute; bottom: calc(100% + 4px); right: 0; transform: translateY(4px);
|
||||||
background: #16161e; border: 1px solid var(--color-border-card); border-radius: 6px;
|
background: #16161e; border: 1px solid var(--color-border-card); border-radius: 6px;
|
||||||
padding: 6px 10px; font-size: 12px; color: var(--color-danger); white-space: nowrap;
|
padding: 6px 10px; font-size: 12px; color: var(--color-danger); white-space: normal;
|
||||||
max-width: 300px; overflow: hidden; text-overflow: ellipsis;
|
max-width: 360px; width: max-content;
|
||||||
opacity: 0; visibility: hidden; transition: all 0.15s; z-index: 10;
|
opacity: 0; visibility: hidden; transition: all 0.15s; z-index: 10;
|
||||||
pointer-events: none; box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
pointer-events: none; box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import styles from './AdminLayout.module.css';
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ path: '/team/dashboard', label: '概览', icon: 'M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z' },
|
{ path: '/team/dashboard', label: '概览', icon: 'M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z' },
|
||||||
{ path: '/team/members', label: '成员管理', icon: 'M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z' },
|
{ path: '/team/members', label: '成员管理', icon: 'M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z' },
|
||||||
|
{ path: '/team/records', label: '消费记录', icon: 'M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-2 10H7v-2h10v2zm0-4H7V7h10v2z' },
|
||||||
{ path: '/team/assets', label: '内容资产', icon: 'M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z' },
|
{ path: '/team/assets', label: '内容资产', icon: 'M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -24,6 +24,7 @@ export function TeamMembersPage() {
|
|||||||
const [editMember, setEditMember] = useState<TeamMember | null>(null);
|
const [editMember, setEditMember] = useState<TeamMember | null>(null);
|
||||||
const [editDaily, setEditDaily] = useState('');
|
const [editDaily, setEditDaily] = useState('');
|
||||||
const [editMonthly, setEditMonthly] = useState('');
|
const [editMonthly, setEditMonthly] = useState('');
|
||||||
|
const [editSpendingLimit, setEditSpendingLimit] = useState('');
|
||||||
|
|
||||||
const fetchMembers = useCallback(async () => {
|
const fetchMembers = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -56,12 +57,13 @@ export function TeamMembersPage() {
|
|||||||
setEditMember(member);
|
setEditMember(member);
|
||||||
setEditDaily(String(member.daily_generation_limit ?? 50));
|
setEditDaily(String(member.daily_generation_limit ?? 50));
|
||||||
setEditMonthly(String(member.monthly_generation_limit ?? 500));
|
setEditMonthly(String(member.monthly_generation_limit ?? 500));
|
||||||
|
setEditSpendingLimit(String(member.spending_limit ?? -1));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveQuota = async () => {
|
const handleSaveQuota = async () => {
|
||||||
if (!editMember) return;
|
if (!editMember) return;
|
||||||
try {
|
try {
|
||||||
await teamApi.updateMemberQuota(editMember.id, Number(editDaily), Number(editMonthly));
|
await teamApi.updateMemberQuota(editMember.id, Number(editDaily), Number(editMonthly), Number(editSpendingLimit));
|
||||||
showToast('配额已更新');
|
showToast('配额已更新');
|
||||||
setEditMember(null);
|
setEditMember(null);
|
||||||
fetchMembers();
|
fetchMembers();
|
||||||
@ -119,6 +121,7 @@ export function TeamMembersPage() {
|
|||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
<th>日生成上限</th>
|
<th>日生成上限</th>
|
||||||
<th>月生成上限</th>
|
<th>月生成上限</th>
|
||||||
|
<th>总额度</th>
|
||||||
<th>今日生成/消费</th>
|
<th>今日生成/消费</th>
|
||||||
<th>本月生成/消费</th>
|
<th>本月生成/消费</th>
|
||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
@ -128,13 +131,13 @@ export function TeamMembersPage() {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
Array.from({ length: 5 }).map((_, i) => (
|
Array.from({ length: 5 }).map((_, i) => (
|
||||||
<tr key={i}>
|
<tr key={i}>
|
||||||
{Array.from({ length: 8 }).map((_, j) => (
|
{Array.from({ length: 9 }).map((_, j) => (
|
||||||
<td key={j}><div className={styles.skeletonCell} /></td>
|
<td key={j}><div className={styles.skeletonCell} /></td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
) : members.length === 0 ? (
|
) : members.length === 0 ? (
|
||||||
<tr><td colSpan={8} className={styles.empty}>暂无成员</td></tr>
|
<tr><td colSpan={9} className={styles.empty}>暂无成员</td></tr>
|
||||||
) : (
|
) : (
|
||||||
members.map((m) => (
|
members.map((m) => (
|
||||||
<tr key={m.id}>
|
<tr key={m.id}>
|
||||||
@ -160,6 +163,7 @@ export function TeamMembersPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td>{formatLimit(m.daily_generation_limit)}</td>
|
<td>{formatLimit(m.daily_generation_limit)}</td>
|
||||||
<td>{formatLimit(m.monthly_generation_limit)}</td>
|
<td>{formatLimit(m.monthly_generation_limit)}</td>
|
||||||
|
<td>{m.spending_limit === -1 ? '不限' : `¥${m.total_spent.toFixed(2)} / ¥${m.spending_limit.toFixed(2)}`}</td>
|
||||||
<td>{(m.generations_today || 0) + '次 / ' + fmtMoney(m.spent_today)}</td>
|
<td>{(m.generations_today || 0) + '次 / ' + fmtMoney(m.spent_today)}</td>
|
||||||
<td>{(m.generations_this_month || 0) + '次 / ' + fmtMoney(m.spent_this_month)}</td>
|
<td>{(m.generations_this_month || 0) + '次 / ' + fmtMoney(m.spent_this_month)}</td>
|
||||||
<td>
|
<td>
|
||||||
@ -205,6 +209,10 @@ export function TeamMembersPage() {
|
|||||||
<label>每月生成次数上限(-1 为不限)</label>
|
<label>每月生成次数上限(-1 为不限)</label>
|
||||||
<input type="number" value={editMonthly} onChange={(e) => setEditMonthly(e.target.value)} />
|
<input type="number" value={editMonthly} onChange={(e) => setEditMonthly(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>总消费额度(-1 为不限)</label>
|
||||||
|
<input type="number" value={editSpendingLimit} onChange={(e) => setEditSpendingLimit(e.target.value)} />
|
||||||
|
</div>
|
||||||
<div className={styles.modalActions}>
|
<div className={styles.modalActions}>
|
||||||
<button className={styles.cancelBtn} onClick={() => setEditMember(null)}>取消</button>
|
<button className={styles.cancelBtn} onClick={() => setEditMember(null)}>取消</button>
|
||||||
<button className={styles.saveBtn} onClick={handleSaveQuota}>保存</button>
|
<button className={styles.saveBtn} onClick={handleSaveQuota}>保存</button>
|
||||||
|
|||||||
170
web/src/pages/TeamRecordsPage.tsx
Normal file
170
web/src/pages/TeamRecordsPage.tsx
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { teamApi } from '../lib/api';
|
||||||
|
import type { AdminRecord } from '../types';
|
||||||
|
import { showToast } from '../components/Toast';
|
||||||
|
import { DatePicker } from '../components/DatePicker';
|
||||||
|
import styles from './RecordsPage.module.css';
|
||||||
|
|
||||||
|
export function TeamRecordsPage() {
|
||||||
|
const [records, setRecords] = useState<AdminRecord[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [startDate, setStartDate] = useState('');
|
||||||
|
const [endDate, setEndDate] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const pageSize = 20;
|
||||||
|
|
||||||
|
const fetchRecords = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await teamApi.getRecords({
|
||||||
|
page, page_size: pageSize, search,
|
||||||
|
start_date: startDate || undefined,
|
||||||
|
end_date: endDate || undefined,
|
||||||
|
});
|
||||||
|
setRecords(data.results);
|
||||||
|
setTotal(data.total);
|
||||||
|
} catch {
|
||||||
|
showToast('加载消费记录失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [page, search, startDate, endDate]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchRecords(); }, [fetchRecords]);
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
setPage(1);
|
||||||
|
fetchRecords();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportCSV = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await teamApi.getRecords({
|
||||||
|
page: 1, page_size: 10000, search,
|
||||||
|
start_date: startDate || undefined,
|
||||||
|
end_date: endDate || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const header = '时间,用户名,消费秒数,Tokens,费用(元),提示词,生成模式,状态,失败原因\n';
|
||||||
|
const rows = data.results.map((r) => {
|
||||||
|
const prompt = r.prompt.replace(/"/g, '""').replace(/^[=+\-@]/, "'$&");
|
||||||
|
const modeLabel = r.mode === 'universal' ? '全能参考' : '首尾帧';
|
||||||
|
const statusLabel = { queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' }[r.status];
|
||||||
|
const errorMsg = (r.error_message || '').replace(/"/g, '""').replace(/^[=+\-@]/, "'$&");
|
||||||
|
return `${r.created_at},${r.username},"${r.seconds_consumed}","${r.tokens_consumed || 0}","${(r.cost_amount || 0).toFixed(2)}","${prompt}","${modeLabel}","${statusLabel}","${errorMsg}"`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
const blob = new Blob(['\uFEFF' + header + rows], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `团队消费记录_${new Date().toISOString().slice(0, 10)}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
showToast('导出成功');
|
||||||
|
} catch {
|
||||||
|
showToast('导出失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / pageSize);
|
||||||
|
const statusMap: Record<string, string> = { queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<h1 className={styles.title}>消费记录</h1>
|
||||||
|
<button className={styles.exportBtn} onClick={handleExportCSV}>导出 CSV</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.filters}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles.searchInput}
|
||||||
|
placeholder="按用户名搜索..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
/>
|
||||||
|
<DatePicker value={startDate} onChange={setStartDate} placeholder="开始日期" />
|
||||||
|
<span className={styles.dateSep}>~</span>
|
||||||
|
<DatePicker value={endDate} onChange={setEndDate} placeholder="结束日期" />
|
||||||
|
<button className={styles.searchBtn} onClick={handleSearch}>查询</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.tableWrapper}>
|
||||||
|
<table className={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>时间</th>
|
||||||
|
<th>用户名</th>
|
||||||
|
<th>消费秒数</th>
|
||||||
|
<th>Tokens</th>
|
||||||
|
<th>费用</th>
|
||||||
|
<th>视频描述</th>
|
||||||
|
<th>模式</th>
|
||||||
|
<th>状态</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
{Array.from({ length: 8 }).map((_, j) => (
|
||||||
|
<td key={j}><div className={styles.skeletonCell} /></td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : records.length === 0 ? (
|
||||||
|
<tr><td colSpan={8} className={styles.empty}>暂无记录</td></tr>
|
||||||
|
) : (
|
||||||
|
records.map((r) => (
|
||||||
|
<tr key={r.id}>
|
||||||
|
<td className={styles.timeCell}>{new Date(r.created_at).toLocaleString('zh-CN')}</td>
|
||||||
|
<td>{r.username}</td>
|
||||||
|
<td><span className={styles.secondsBadge}>{r.seconds_consumed.toLocaleString()}s</span></td>
|
||||||
|
<td>{(r.tokens_consumed || 0).toLocaleString()}</td>
|
||||||
|
<td>¥{(r.cost_amount || 0).toFixed(2)}</td>
|
||||||
|
<td className={styles.promptCell}>{r.prompt ? (r.prompt.slice(0, 40) + (r.prompt.length > 40 ? '...' : '')) : '-'}</td>
|
||||||
|
<td>{r.mode === 'universal' ? '全能参考' : '首尾帧'}</td>
|
||||||
|
<td className={r.status === 'failed' && r.error_message ? styles.statusCell : undefined}>
|
||||||
|
<span className={`${styles.statusBadge} ${styles[r.status]}`}>
|
||||||
|
{statusMap[r.status]}
|
||||||
|
</span>
|
||||||
|
{r.status === 'failed' && r.error_message && (
|
||||||
|
<span className={styles.errorTooltip}>{r.error_message}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className={styles.pagination}>
|
||||||
|
<span className={styles.pageInfo}>共 {total} 条</span>
|
||||||
|
<div className={styles.pageButtons}>
|
||||||
|
<button disabled={page <= 1} onClick={() => setPage(page - 1)}><</button>
|
||||||
|
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
|
||||||
|
let p: number;
|
||||||
|
if (totalPages <= 5) p = i + 1;
|
||||||
|
else if (page <= 3) p = i + 1;
|
||||||
|
else if (page >= totalPages - 2) p = totalPages - 4 + i;
|
||||||
|
else p = page - 2 + i;
|
||||||
|
return (
|
||||||
|
<button key={p} className={page === p ? styles.activePage : ''} onClick={() => setPage(p)}>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button disabled={page >= totalPages} onClick={() => setPage(page + 1)}>></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -87,6 +87,8 @@ export function TeamsPage() {
|
|||||||
const [editMarkupValue, setEditMarkupValue] = useState('');
|
const [editMarkupValue, setEditMarkupValue] = useState('');
|
||||||
const [editingAnomalyConfig, setEditingAnomalyConfig] = useState(false);
|
const [editingAnomalyConfig, setEditingAnomalyConfig] = useState(false);
|
||||||
const [anomalyConfigDraft, setAnomalyConfigDraft] = useState<Record<string, any>>({});
|
const [anomalyConfigDraft, setAnomalyConfigDraft] = useState<Record<string, any>>({});
|
||||||
|
const [editingMaxConcurrent, setEditingMaxConcurrent] = useState(false);
|
||||||
|
const [editMaxConcurrentValue, setEditMaxConcurrentValue] = useState('');
|
||||||
|
|
||||||
const resetCreateForm = () => {
|
const resetCreateForm = () => {
|
||||||
setNewName(''); setNewMonthlyLimit('10000'); setNewDailyMemberLimit('50');
|
setNewName(''); setNewMonthlyLimit('10000'); setNewDailyMemberLimit('50');
|
||||||
@ -212,7 +214,7 @@ export function TeamsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const colCount = 9;
|
const colCount = 10;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<div className={styles.page}>
|
||||||
@ -239,6 +241,7 @@ export function TeamsPage() {
|
|||||||
<th>月消费限额</th>
|
<th>月消费限额</th>
|
||||||
<th>本月消费</th>
|
<th>本月消费</th>
|
||||||
<th>成员数</th>
|
<th>成员数</th>
|
||||||
|
<th>并发</th>
|
||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -268,6 +271,7 @@ export function TeamsPage() {
|
|||||||
<td>{fmtMoney(t.monthly_spending_limit)}</td>
|
<td>{fmtMoney(t.monthly_spending_limit)}</td>
|
||||||
<td>{fmtMoney(t.monthly_spent)}</td>
|
<td>{fmtMoney(t.monthly_spent)}</td>
|
||||||
<td>{t.member_count}</td>
|
<td>{t.member_count}</td>
|
||||||
|
<td>{t.current_processing ?? 0}/{t.max_concurrent_tasks}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`${styles.statusBadge} ${t.is_active ? styles.active : styles.disabled}`}>
|
<span className={`${styles.statusBadge} ${t.is_active ? styles.active : styles.disabled}`}>
|
||||||
{t.is_active ? '启用' : '禁用'}
|
{t.is_active ? '启用' : '禁用'}
|
||||||
@ -556,6 +560,58 @@ export function TeamsPage() {
|
|||||||
<span className={styles.detailLabel}>成员数</span>
|
<span className={styles.detailLabel}>成员数</span>
|
||||||
<span className={styles.detailValue}>{detailTeam.member_count}</span>
|
<span className={styles.detailValue}>{detailTeam.member_count}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className={styles.detailItem}>
|
||||||
|
<span className={styles.detailLabel}>并发上限</span>
|
||||||
|
<span className={styles.detailValue}>
|
||||||
|
{editingMaxConcurrent ? (
|
||||||
|
<span style={{ display: 'inline-flex', gap: 6, alignItems: 'center', flexWrap: 'nowrap' }}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={editMaxConcurrentValue}
|
||||||
|
onChange={(e) => setEditMaxConcurrentValue(e.target.value)}
|
||||||
|
style={{ width: 80, padding: '3px 6px', borderRadius: 4, border: '1px solid var(--color-border-card)', background: 'var(--color-bg-page)', color: 'var(--color-text-primary)', fontSize: 13 }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className={styles.topupBtn}
|
||||||
|
onClick={async () => {
|
||||||
|
const val = Number(editMaxConcurrentValue);
|
||||||
|
if (isNaN(val) || val < 1) { showToast('请输入大于0的整数'); return; }
|
||||||
|
try {
|
||||||
|
await adminApi.updateTeam(detailTeam.id, { max_concurrent_tasks: val });
|
||||||
|
setDetailTeam({ ...detailTeam, max_concurrent_tasks: val });
|
||||||
|
setTeams(teams.map(t => t.id === detailTeam.id ? { ...t, max_concurrent_tasks: val } : t));
|
||||||
|
setEditingMaxConcurrent(false);
|
||||||
|
showToast('并发上限已更新');
|
||||||
|
} catch (e: any) {
|
||||||
|
showToast(e.response?.data?.error || '保存失败');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ fontSize: 12, padding: '4px 10px', whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.topupBtn}
|
||||||
|
onClick={() => setEditingMaxConcurrent(false)}
|
||||||
|
style={{ fontSize: 12, padding: '4px 10px', whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{detailTeam.max_concurrent_tasks}
|
||||||
|
<button
|
||||||
|
className={styles.topupBtn}
|
||||||
|
onClick={() => { setEditingMaxConcurrent(true); setEditMaxConcurrentValue(String(detailTeam.max_concurrent_tasks || 1)); }}
|
||||||
|
style={{ fontSize: 12, padding: '4px 10px', marginLeft: 8 }}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className={styles.detailItem}>
|
<div className={styles.detailItem}>
|
||||||
<span className={styles.detailLabel}>创建时间</span>
|
<span className={styles.detailLabel}>创建时间</span>
|
||||||
<span className={styles.detailValue}>{new Date(detailTeam.created_at).toLocaleDateString('zh-CN')}</span>
|
<span className={styles.detailValue}>{new Date(detailTeam.created_at).toLocaleDateString('zh-CN')}</span>
|
||||||
|
|||||||
@ -21,6 +21,7 @@ export function UsersPage() {
|
|||||||
const [editUser, setEditUser] = useState<AdminUser | null>(null);
|
const [editUser, setEditUser] = useState<AdminUser | null>(null);
|
||||||
const [editDaily, setEditDaily] = useState('');
|
const [editDaily, setEditDaily] = useState('');
|
||||||
const [editMonthly, setEditMonthly] = useState('');
|
const [editMonthly, setEditMonthly] = useState('');
|
||||||
|
const [editSpendingLimit, setEditSpendingLimit] = useState('');
|
||||||
|
|
||||||
// User detail drawer
|
// User detail drawer
|
||||||
const [detailUser, setDetailUser] = useState<AdminUserDetail | null>(null);
|
const [detailUser, setDetailUser] = useState<AdminUserDetail | null>(null);
|
||||||
@ -86,12 +87,13 @@ export function UsersPage() {
|
|||||||
setEditUser(user);
|
setEditUser(user);
|
||||||
setEditDaily(String(user.daily_generation_limit ?? 50));
|
setEditDaily(String(user.daily_generation_limit ?? 50));
|
||||||
setEditMonthly(String(user.monthly_generation_limit ?? 500));
|
setEditMonthly(String(user.monthly_generation_limit ?? 500));
|
||||||
|
setEditSpendingLimit(String(user.spending_limit ?? -1));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveQuota = async () => {
|
const handleSaveQuota = async () => {
|
||||||
if (!editUser) return;
|
if (!editUser) return;
|
||||||
try {
|
try {
|
||||||
await adminApi.updateUserQuota(editUser.id, Number(editDaily), Number(editMonthly));
|
await adminApi.updateUserQuota(editUser.id, Number(editDaily), Number(editMonthly), Number(editSpendingLimit));
|
||||||
showToast('配额已更新');
|
showToast('配额已更新');
|
||||||
setEditUser(null);
|
setEditUser(null);
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
@ -205,6 +207,7 @@ export function UsersPage() {
|
|||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
<th>日生成上限</th>
|
<th>日生成上限</th>
|
||||||
<th>月生成上限</th>
|
<th>月生成上限</th>
|
||||||
|
<th>总额度</th>
|
||||||
<th>今日生成/消费</th>
|
<th>今日生成/消费</th>
|
||||||
<th>本月生成/消费</th>
|
<th>本月生成/消费</th>
|
||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
@ -214,13 +217,13 @@ export function UsersPage() {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
Array.from({ length: 5 }).map((_, i) => (
|
Array.from({ length: 5 }).map((_, i) => (
|
||||||
<tr key={i}>
|
<tr key={i}>
|
||||||
{Array.from({ length: 10 }).map((_, j) => (
|
{Array.from({ length: 11 }).map((_, j) => (
|
||||||
<td key={j}><div className={styles.skeletonCell} /></td>
|
<td key={j}><div className={styles.skeletonCell} /></td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
) : users.length === 0 ? (
|
) : users.length === 0 ? (
|
||||||
<tr><td colSpan={10} className={styles.empty}>暂无数据</td></tr>
|
<tr><td colSpan={11} className={styles.empty}>暂无数据</td></tr>
|
||||||
) : (
|
) : (
|
||||||
users.map((u) => (
|
users.map((u) => (
|
||||||
<tr key={u.id}>
|
<tr key={u.id}>
|
||||||
@ -249,6 +252,7 @@ export function UsersPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td>{(u.daily_generation_limit ?? -1) === -1 ? '不限' : u.daily_generation_limit + '次'}</td>
|
<td>{(u.daily_generation_limit ?? -1) === -1 ? '不限' : u.daily_generation_limit + '次'}</td>
|
||||||
<td>{(u.monthly_generation_limit ?? -1) === -1 ? '不限' : u.monthly_generation_limit + '次'}</td>
|
<td>{(u.monthly_generation_limit ?? -1) === -1 ? '不限' : u.monthly_generation_limit + '次'}</td>
|
||||||
|
<td>{(u.spending_limit ?? -1) === -1 ? '不限' : '¥' + (u.total_spent || 0).toFixed(2) + ' / ¥' + (u.spending_limit).toFixed(2)}</td>
|
||||||
<td>{(u.generations_today || 0) + '次 / ¥' + (u.spent_today || 0).toFixed(2)}</td>
|
<td>{(u.generations_today || 0) + '次 / ¥' + (u.spent_today || 0).toFixed(2)}</td>
|
||||||
<td>{(u.generations_this_month || 0) + '次 / ¥' + (u.spent_this_month || 0).toFixed(2)}</td>
|
<td>{(u.generations_this_month || 0) + '次 / ¥' + (u.spent_this_month || 0).toFixed(2)}</td>
|
||||||
<td>
|
<td>
|
||||||
@ -317,6 +321,10 @@ export function UsersPage() {
|
|||||||
<label>每月生成次数上限(-1 为不限)</label>
|
<label>每月生成次数上限(-1 为不限)</label>
|
||||||
<input type="number" value={editMonthly} onChange={(e) => setEditMonthly(e.target.value)} />
|
<input type="number" value={editMonthly} onChange={(e) => setEditMonthly(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>总消费额度(-1 为不限)</label>
|
||||||
|
<input type="number" value={editSpendingLimit} onChange={(e) => setEditSpendingLimit(e.target.value)} />
|
||||||
|
</div>
|
||||||
<div className={styles.modalActions}>
|
<div className={styles.modalActions}>
|
||||||
<button className={styles.cancelBtn} onClick={() => setEditUser(null)}>取消</button>
|
<button className={styles.cancelBtn} onClick={() => setEditUser(null)}>取消</button>
|
||||||
<button className={styles.saveBtn} onClick={handleSaveQuota}>保存</button>
|
<button className={styles.saveBtn} onClick={handleSaveQuota}>保存</button>
|
||||||
|
|||||||
@ -162,6 +162,8 @@ export interface AdminUser {
|
|||||||
generations_this_month: number;
|
generations_this_month: number;
|
||||||
spent_today: number;
|
spent_today: number;
|
||||||
spent_this_month: number;
|
spent_this_month: number;
|
||||||
|
spending_limit: number;
|
||||||
|
total_spent: number;
|
||||||
is_online?: boolean;
|
is_online?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -272,6 +274,8 @@ export interface Team {
|
|||||||
frozen_amount: number;
|
frozen_amount: number;
|
||||||
markup_percentage: number;
|
markup_percentage: number;
|
||||||
daily_member_limit_default: number;
|
daily_member_limit_default: number;
|
||||||
|
max_concurrent_tasks: number;
|
||||||
|
current_processing?: number;
|
||||||
member_count: number;
|
member_count: number;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
expected_regions: string;
|
expected_regions: string;
|
||||||
@ -316,6 +320,8 @@ export interface TeamMember {
|
|||||||
generations_this_month: number;
|
generations_this_month: number;
|
||||||
spent_today: number;
|
spent_today: number;
|
||||||
spent_this_month: number;
|
spent_this_month: number;
|
||||||
|
spending_limit: number;
|
||||||
|
total_spent: number;
|
||||||
is_online?: boolean;
|
is_online?: boolean;
|
||||||
date_joined: string;
|
date_joined: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user