From 9259988094aa02c4e672880ebb635bec85633654 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Fri, 20 Mar 2026 20:32:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v0.10.0=20=E8=AE=A1=E8=B4=B9=E4=BD=93?= =?UTF-8?q?=E7=B3=BB=E9=87=8D=E6=9E=84=20=E2=80=94=20=E7=A7=92=E6=95=B0?= =?UTF-8?q?=E2=86=92=E9=87=91=E9=A2=9D+=E6=AC=A1=E6=95=B0=EF=BC=8Ctoken?= =?UTF-8?q?=E8=BF=BD=E8=B8=AA=EF=BC=8C=E5=88=A9=E6=B6=A6=E5=88=86=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 计费体系 - 团队额度从秒数改为金额(余额/冻结/月消费上限) - 用户限额从秒数改为次数(每日50次/每月1500次) - 新增 billing.py 工具模块(分辨率→像素映射 + token/费用计算) - 扣费流程:预扣制→冻结制(提交冻结预估金额,完成按实际tokens扣费,失败释放) - 允许小额透支(实际费用超预估时余额可变负) - 团队加价比例(markup_percentage),创建团队时必填 ## Token 追踪 - GenerationRecord 新增 tokens_consumed/cost_amount/base_cost_amount - 任务完成时从 Seedance API usage.total_tokens 获取精确值 - 生成页显示预估消耗(tokens + 金额),按团队售价计算 ## 管理后台 - 仪表盘新增利润分析板块(总收入/成本/利润/利润率 + 团队利润排行) - 消费记录新增 Tokens/售价/成本/利润列 - 团队管理:充值改为充金额,新增加价比例设置 - 系统设置:默认限额改为次数,新增基础token单价配置 ## Bug 修复 - 登录弹窗:拖选输入框内容不再误关闭(onClick→mousedown+mouseup) - 视频详情弹窗:遮罩层覆盖全视口(left:76px→0),admin/团管侧栏不再露出 ## UI 增强 - 图片大图预览:上传区和视频详情弹窗的图片支持点击查看大图(ImageLightbox) - 移除 adaptive 比例和智能时长选项,确保 token 预估可精确计算 - 视频详情弹窗显示实际消耗 tokens 和费用 ## 前端全量更新 - 所有页面秒数显示替换为金额(元)和次数(次) - TypeScript 类型全量更新 - API 调用参数同步更新 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../migrations/0009_billing_system_v010.py | 53 ++ .../migrations/0010_billing_data_migration.py | 52 ++ backend/apps/accounts/models.py | 14 + backend/apps/accounts/views.py | 32 +- .../migrations/0007_billing_system_v010.py | 53 ++ backend/apps/generation/models.py | 10 + backend/apps/generation/serializers.py | 27 +- backend/apps/generation/views.py | 675 +++++++++++++++--- backend/utils/billing.py | 69 ++ web/src/components/GenerationCard.tsx | 4 +- web/src/components/ImageLightbox.module.css | 17 + web/src/components/ImageLightbox.tsx | 24 + web/src/components/LoginModal.tsx | 10 +- web/src/components/Toolbar.tsx | 31 +- web/src/components/UniversalUpload.tsx | 5 +- .../components/VideoDetailModal.module.css | 2 +- web/src/components/VideoDetailModal.tsx | 22 +- web/src/lib/api.ts | 24 +- web/src/pages/AdminAssetsPage.tsx | 14 +- web/src/pages/AuditLogsPage.tsx | 16 +- web/src/pages/DashboardPage.tsx | 105 ++- web/src/pages/ProfilePage.tsx | 45 +- web/src/pages/RecordsPage.tsx | 17 +- web/src/pages/SettingsPage.tsx | 24 +- web/src/pages/TeamDashboardPage.tsx | 24 +- web/src/pages/TeamMembersPage.tsx | 41 +- web/src/pages/TeamsPage.tsx | 120 ++-- web/src/pages/UsersPage.tsx | 58 +- web/src/store/generation.ts | 2 + web/src/store/inputBar.ts | 2 +- web/src/types/index.ts | 79 +- 31 files changed, 1354 insertions(+), 317 deletions(-) create mode 100644 backend/apps/accounts/migrations/0009_billing_system_v010.py create mode 100644 backend/apps/accounts/migrations/0010_billing_data_migration.py create mode 100644 backend/apps/generation/migrations/0007_billing_system_v010.py create mode 100644 backend/utils/billing.py create mode 100644 web/src/components/ImageLightbox.module.css create mode 100644 web/src/components/ImageLightbox.tsx diff --git a/backend/apps/accounts/migrations/0009_billing_system_v010.py b/backend/apps/accounts/migrations/0009_billing_system_v010.py new file mode 100644 index 0000000..288afd6 --- /dev/null +++ b/backend/apps/accounts/migrations/0009_billing_system_v010.py @@ -0,0 +1,53 @@ +# Generated by Django 4.2.29 on 2026-03-20 11:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0008_anomaly_detection_phase2'), + ] + + operations = [ + migrations.AddField( + model_name='team', + name='balance', + field=models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='团队余额(元)'), + ), + migrations.AddField( + model_name='team', + name='daily_member_spending_default', + field=models.DecimalField(decimal_places=2, default=50, max_digits=12, verbose_name='新成员默认每日消费限额(元)'), + ), + migrations.AddField( + model_name='team', + name='frozen_amount', + field=models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='冻结金额(元)'), + ), + migrations.AddField( + model_name='team', + name='markup_percentage', + field=models.DecimalField(decimal_places=2, default=0, max_digits=5, verbose_name='加价百分比'), + ), + migrations.AddField( + model_name='team', + name='monthly_spending_limit', + field=models.DecimalField(decimal_places=2, default=-1, max_digits=12, verbose_name='每月消费上限(元)'), + ), + migrations.AddField( + model_name='team', + name='total_spent', + field=models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='已消费总额(元)'), + ), + migrations.AddField( + model_name='user', + name='daily_generation_limit', + field=models.IntegerField(default=50, verbose_name='每日生成次数上限'), + ), + migrations.AddField( + model_name='user', + name='monthly_generation_limit', + field=models.IntegerField(default=1500, verbose_name='每月生成次数上限'), + ), + ] diff --git a/backend/apps/accounts/migrations/0010_billing_data_migration.py b/backend/apps/accounts/migrations/0010_billing_data_migration.py new file mode 100644 index 0000000..5007173 --- /dev/null +++ b/backend/apps/accounts/migrations/0010_billing_data_migration.py @@ -0,0 +1,52 @@ +# Data migration: populate new billing fields from existing seconds-based data +from django.db import migrations + + +def forward(apps, schema_editor): + Team = apps.get_model('accounts', 'Team') + User = apps.get_model('accounts', 'User') + QuotaConfig = apps.get_model('generation', 'QuotaConfig') + + # Teams: set balance=0 (admin will manually top up), spending limit=-1 (unlimited) + for team in Team.objects.all(): + team.balance = 0 + team.total_spent = 0 + team.monthly_spending_limit = -1 + team.daily_member_spending_default = 50 + team.frozen_amount = 0 + team.markup_percentage = 0 + team.save(update_fields=[ + 'balance', 'total_spent', 'monthly_spending_limit', + 'daily_member_spending_default', 'frozen_amount', 'markup_percentage', + ]) + + # Users: set generation limits + User.objects.all().update( + daily_generation_limit=50, + monthly_generation_limit=1500, + ) + + # QuotaConfig: set defaults + config, _ = QuotaConfig.objects.get_or_create(pk=1) + config.default_daily_generation_limit = 50 + config.default_monthly_generation_limit = 1500 + config.base_token_price = 46 + config.save(update_fields=[ + 'default_daily_generation_limit', 'default_monthly_generation_limit', 'base_token_price', + ]) + + +def backward(apps, schema_editor): + pass # No rollback needed, old seconds fields are untouched + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0009_billing_system_v010'), + ('generation', '0007_billing_system_v010'), + ] + + operations = [ + migrations.RunPython(forward, backward), + ] diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py index dfc2259..3373f69 100644 --- a/backend/apps/accounts/models.py +++ b/backend/apps/accounts/models.py @@ -11,6 +11,13 @@ class Team(models.Model): 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='新成员默认每日限额(秒)') + # ── 金额计费字段(v0.10.0 新增) ── + balance = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name='团队余额(元)') + total_spent = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name='已消费总额(元)') + monthly_spending_limit = models.DecimalField(max_digits=12, decimal_places=2, default=-1, 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='冻结金额(元)') + markup_percentage = models.DecimalField(max_digits=5, decimal_places=2, default=0, verbose_name='加价百分比') is_active = models.BooleanField(default=True, verbose_name='启用状态') expected_regions = models.CharField(max_length=500, blank=True, default='', verbose_name='预期登录城市(逗号分隔)') disabled_by = models.CharField(max_length=10, blank=True, default='', verbose_name='禁用来源') @@ -28,6 +35,10 @@ class Team(models.Model): def remaining_seconds(self): return self.total_seconds_pool - self.total_seconds_used + @property + def available_balance(self): + return self.balance - self.frozen_amount + class User(AbstractUser): """Extended user model — Phase 5: team-based quota.""" @@ -41,6 +52,9 @@ class User(AbstractUser): 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='每月秒数上限') + # ── 次数限额(v0.10.0 新增) ── + daily_generation_limit = models.IntegerField(default=50, verbose_name='每日生成次数上限') + monthly_generation_limit = models.IntegerField(default=1500, verbose_name='每月生成次数上限') must_change_password = models.BooleanField(default=True, verbose_name='必须修改密码') disabled_by = models.CharField(max_length=10, blank=True, default='', verbose_name='禁用来源') created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') diff --git a/backend/apps/accounts/views.py b/backend/apps/accounts/views.py index 3df05a4..085a5c8 100644 --- a/backend/apps/accounts/views.py +++ b/backend/apps/accounts/views.py @@ -5,7 +5,7 @@ from rest_framework.response import Response from rest_framework.throttling import ScopedRateThrottle from django.contrib.auth import authenticate, get_user_model from django.utils import timezone -from django.db.models import Sum +from django.db.models import Sum, Count from .serializers import UserSerializer from .models import ActiveSession, LoginRecord, get_client_ip, parse_device_type @@ -170,24 +170,45 @@ def me_view(request): created_at__date__gte=first_of_month ).aggregate(total=Sum('seconds_consumed'))['total'] or 0 + # Count-based usage + daily_generation_used = user.generation_records.filter( + created_at__date=today + ).count() + + monthly_generation_used = user.generation_records.filter( + created_at__date__gte=first_of_month + ).count() + data = UserSerializer(user).data data['quota'] = { '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, + 'daily_generation_limit': user.daily_generation_limit, + 'daily_generation_used': daily_generation_used, + 'monthly_generation_limit': user.monthly_generation_limit, + 'monthly_generation_used': monthly_generation_used, } # Team info team = user.team if team: # Team monthly consumption - from apps.generation.models import GenerationRecord + from apps.generation.models import GenerationRecord, QuotaConfig team_monthly_used = GenerationRecord.objects.filter( user__team=team, created_at__date__gte=first_of_month, ).aggregate(total=Sum('seconds_consumed'))['total'] or 0 + team_monthly_spent = GenerationRecord.objects.filter( + user__team=team, + created_at__date__gte=first_of_month, + ).aggregate(total=Sum('cost_amount'))['total'] or 0 + + config = QuotaConfig.objects.get_or_create(pk=1)[0] + token_price = float(config.base_token_price) * (1 + float(team.markup_percentage) / 100) + data['team'] = { 'id': team.id, 'name': team.name, @@ -196,6 +217,13 @@ def me_view(request): 'remaining_seconds': team.remaining_seconds, 'monthly_seconds_limit': team.monthly_seconds_limit, 'monthly_seconds_used': team_monthly_used, + 'balance': float(team.balance), + 'total_spent': float(team.total_spent), + 'available_balance': float(team.available_balance), + 'monthly_spending_limit': float(team.monthly_spending_limit), + 'monthly_spent': float(team_monthly_spent), + 'frozen_amount': float(team.frozen_amount), + 'token_price': token_price, 'is_active': team.is_active, } data['team_disabled'] = not team.is_active diff --git a/backend/apps/generation/migrations/0007_billing_system_v010.py b/backend/apps/generation/migrations/0007_billing_system_v010.py new file mode 100644 index 0000000..ad4abf3 --- /dev/null +++ b/backend/apps/generation/migrations/0007_billing_system_v010.py @@ -0,0 +1,53 @@ +# Generated by Django 4.2.29 on 2026-03-20 11:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('generation', '0006_anomaly_detection_phase2'), + ] + + operations = [ + migrations.AddField( + model_name='generationrecord', + name='base_cost_amount', + field=models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='平台成本(元)'), + ), + migrations.AddField( + model_name='generationrecord', + name='cost_amount', + field=models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='用户费用(元)'), + ), + migrations.AddField( + model_name='generationrecord', + name='frozen_amount', + field=models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='冻结金额(元)'), + ), + migrations.AddField( + model_name='generationrecord', + name='resolution', + field=models.CharField(blank=True, default='', max_length=10, verbose_name='分辨率'), + ), + migrations.AddField( + model_name='generationrecord', + name='tokens_consumed', + field=models.IntegerField(default=0, verbose_name='消耗tokens'), + ), + migrations.AddField( + model_name='quotaconfig', + name='base_token_price', + field=models.DecimalField(decimal_places=2, default=46, max_digits=10, verbose_name='基础token单价(元/百万tokens)'), + ), + migrations.AddField( + model_name='quotaconfig', + name='default_daily_generation_limit', + field=models.IntegerField(default=50, verbose_name='默认每日生成次数'), + ), + migrations.AddField( + model_name='quotaconfig', + name='default_monthly_generation_limit', + field=models.IntegerField(default=1500, verbose_name='默认每月生成次数'), + ), + ] diff --git a/backend/apps/generation/models.py b/backend/apps/generation/models.py index 40f21c8..79b0803 100644 --- a/backend/apps/generation/models.py +++ b/backend/apps/generation/models.py @@ -34,6 +34,12 @@ class GenerationRecord(models.Model): aspect_ratio = models.CharField(max_length=10, verbose_name='宽高比') duration = models.IntegerField(verbose_name='视频时长(秒)') seconds_consumed = models.FloatField(default=0, verbose_name='消费秒数') + # ── 金额计费字段(v0.10.0 新增) ── + tokens_consumed = models.IntegerField(default=0, verbose_name='消耗tokens') + cost_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name='用户费用(元)') + base_cost_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='冻结金额(元)') + resolution = models.CharField(max_length=10, blank=True, default='', verbose_name='分辨率') status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='queued', verbose_name='状态') result_url = models.CharField(max_length=1000, blank=True, default='', verbose_name='生成结果URL') error_message = models.TextField(blank=True, default='', verbose_name='错误信息') @@ -77,6 +83,10 @@ class QuotaConfig(models.Model): feishu_alert_mobiles = models.CharField(max_length=500, blank=True, default='', verbose_name='飞书告警接收人手机号') sms_alert_mobiles = models.CharField(max_length=500, blank=True, default='', verbose_name='短信告警手机号(预留)') alert_cooldown_seconds = models.IntegerField(default=1800, verbose_name='告警冷却时间(秒)') + # ── 计费全局配置(v0.10.0 新增) ── + default_daily_generation_limit = models.IntegerField(default=50, verbose_name='默认每日生成次数') + default_monthly_generation_limit = models.IntegerField(default=1500, verbose_name='默认每月生成次数') + base_token_price = models.DecimalField(max_digits=10, decimal_places=2, default=46, verbose_name='基础token单价(元/百万tokens)') updated_at = models.DateTimeField(auto_now=True) class Meta: diff --git a/backend/apps/generation/serializers.py b/backend/apps/generation/serializers.py index b6cc30f..bf7a855 100644 --- a/backend/apps/generation/serializers.py +++ b/backend/apps/generation/serializers.py @@ -11,8 +11,8 @@ class VideoGenerateSerializer(serializers.Serializer): class QuotaUpdateSerializer(serializers.Serializer): - daily_seconds_limit = serializers.IntegerField(min_value=-1) - monthly_seconds_limit = serializers.IntegerField(min_value=-1) + daily_generation_limit = serializers.IntegerField(min_value=-1) + monthly_generation_limit = serializers.IntegerField(min_value=-1) class UserStatusSerializer(serializers.Serializer): @@ -25,12 +25,17 @@ class AdminCreateUserSerializer(serializers.Serializer): password = serializers.CharField(min_length=6) daily_seconds_limit = serializers.IntegerField(min_value=-1, required=False, default=600) monthly_seconds_limit = serializers.IntegerField(min_value=-1, required=False, default=6000) + daily_generation_limit = serializers.IntegerField(min_value=-1, required=False, default=50) + monthly_generation_limit = serializers.IntegerField(min_value=-1, required=False, default=1500) is_staff = serializers.BooleanField(required=False, default=False) class SystemSettingsSerializer(serializers.Serializer): - default_daily_seconds_limit = serializers.IntegerField(min_value=0) - default_monthly_seconds_limit = serializers.IntegerField(min_value=0) + default_daily_seconds_limit = serializers.IntegerField(min_value=0, required=False) + default_monthly_seconds_limit = serializers.IntegerField(min_value=0, required=False) + default_daily_generation_limit = serializers.IntegerField(min_value=0, required=False) + default_monthly_generation_limit = serializers.IntegerField(min_value=0, required=False) + base_token_price = serializers.DecimalField(max_digits=10, decimal_places=2, min_value=0, required=False) announcement = serializers.CharField(required=False, allow_blank=True, default='') announcement_enabled = serializers.BooleanField(required=False, default=False) max_desktop_sessions = serializers.IntegerField(min_value=1, required=False, default=1) @@ -60,6 +65,9 @@ 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) + markup_percentage = serializers.DecimalField(max_digits=5, decimal_places=2, min_value=0, required=True) + monthly_spending_limit = serializers.DecimalField(max_digits=12, decimal_places=2, required=False, default=-1) + daily_member_spending_default = serializers.DecimalField(max_digits=12, decimal_places=2, required=False, default=50) expected_regions = serializers.CharField(max_length=500, required=True) @@ -67,6 +75,9 @@ 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) + markup_percentage = serializers.DecimalField(max_digits=5, decimal_places=2, min_value=0, required=False) + monthly_spending_limit = serializers.DecimalField(max_digits=12, decimal_places=2, required=False) + daily_member_spending_default = serializers.DecimalField(max_digits=12, decimal_places=2, required=False) is_active = serializers.BooleanField(required=False) expected_regions = serializers.CharField(max_length=500, required=False, allow_blank=True) @@ -87,7 +98,7 @@ class TeamAnomalyConfigSerializer(serializers.Serializer): class TeamTopUpSerializer(serializers.Serializer): - seconds = serializers.IntegerField(min_value=1) + amount = serializers.DecimalField(max_digits=12, decimal_places=2, min_value=0.01) class TeamAdminCreateSerializer(serializers.Serializer): @@ -103,8 +114,10 @@ class TeamMemberCreateSerializer(serializers.Serializer): 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) + daily_generation_limit = serializers.IntegerField(min_value=-1, required=False) + monthly_generation_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) + daily_generation_limit = serializers.IntegerField(min_value=-1) + monthly_generation_limit = serializers.IntegerField(min_value=-1) diff --git a/backend/apps/generation/views.py b/backend/apps/generation/views.py index 5c07c99..a4596d8 100644 --- a/backend/apps/generation/views.py +++ b/backend/apps/generation/views.py @@ -8,7 +8,7 @@ from rest_framework.response import Response from django.contrib.auth import get_user_model from django.utils import timezone from django.db import transaction -from django.db.models import Sum, Q, F +from django.db.models import Sum, Q, F, Count from django.db.models.functions import TruncDate from django.db.utils import OperationalError as DbOperationalError from datetime import timedelta @@ -26,6 +26,7 @@ from apps.accounts.models import Team, AdminAuditLog, log_admin_action, TeamAnom from apps.accounts.permissions import IsSuperAdmin, IsTeamAdmin, IsTeamMember from utils.tos_client import upload_file as tos_upload from utils.airdrama_client import create_task, query_task, extract_video_url, map_status +from utils.billing import get_resolution, estimate_tokens, calculate_cost, calculate_base_cost User = get_user_model() logger = logging.getLogger(__name__) @@ -147,66 +148,75 @@ def video_generate_view(request): today = timezone.now().date() first_of_month = today.replace(day=1) duration = serializer.validated_data['duration'] + prompt = serializer.validated_data['prompt'] + mode = serializer.validated_data['mode'] + model = serializer.validated_data['model'] + aspect_ratio = serializer.validated_data['aspect_ratio'] - # ── 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 + # ── 预估 token 和费用 ── + config = QuotaConfig.objects.get_or_create(pk=1)[0] + w, h = get_resolution(aspect_ratio) + estimated_tokens = estimate_tokens(w, h, duration) + estimated_cost = calculate_cost(estimated_tokens, config.base_token_price, team.markup_percentage) - if daily_used + duration > user.daily_seconds_limit: + # ── Layer 1: 用户每日生成次数限额 (skip if -1) ── + if user.daily_generation_limit != -1: + daily_count = user.generation_records.filter(created_at__date=today).count() + if daily_count >= user.daily_generation_limit: return Response({ 'error': 'quota_exceeded', - 'message': '您今日的生成额度已用完', - 'daily_seconds_limit': user.daily_seconds_limit, - 'daily_seconds_used': daily_used, + 'message': f'您今日的生成次数已达上限({user.daily_generation_limit}次)', + 'daily_generation_limit': user.daily_generation_limit, + 'daily_generation_used': daily_count, 'reset_at': (timezone.now() + timedelta(days=1)).replace( hour=0, minute=0, second=0, microsecond=0 ).isoformat(), }, status=status.HTTP_429_TOO_MANY_REQUESTS) - # ── Layer 2: User monthly limit (skip if -1) ── - if user.monthly_seconds_limit != -1: - monthly_used = user.generation_records.filter( + # ── Layer 2: 用户每月生成次数限额 (skip if -1) ── + if user.monthly_generation_limit != -1: + monthly_count = 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: + ).count() + if monthly_count >= user.monthly_generation_limit: return Response({ 'error': 'quota_exceeded', - 'message': '您本月的生成额度已用完', - 'monthly_seconds_limit': user.monthly_seconds_limit, - 'monthly_seconds_used': monthly_used, + 'message': f'您本月的生成次数已达上限({user.monthly_generation_limit}次)', + 'monthly_generation_limit': user.monthly_generation_limit, + 'monthly_generation_used': monthly_count, }, status=status.HTTP_429_TOO_MANY_REQUESTS) - # ── Layer 3 & 4: Team checks + pre-deduction (atomic with row lock) ── + # ── Layer 3 & 4: 团队余额检查 + 冻结 (atomic with row lock) ── with transaction.atomic(): locked_team = Team.objects.select_for_update().get(pk=team.pk) - # 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 + # Layer 3: 团队月消费限额 + if locked_team.monthly_spending_limit != -1: + team_monthly_spent = GenerationRecord.objects.filter( + user__team=locked_team, + created_at__date__gte=first_of_month, + ).aggregate(total=Sum('cost_amount'))['total'] or 0 + if team_monthly_spent + estimated_cost > locked_team.monthly_spending_limit: + return Response({ + 'error': 'quota_exceeded', + 'message': '团队本月消费额度已用完', + 'team_monthly_limit': float(locked_team.monthly_spending_limit), + 'team_monthly_spent': float(team_monthly_spent), + }, status=status.HTTP_429_TOO_MANY_REQUESTS) - if team_monthly_used + duration > locked_team.monthly_seconds_limit: + # Layer 4: 团队可用余额 + available = locked_team.balance - locked_team.frozen_amount + if estimated_cost > available: return Response({ 'error': 'quota_exceeded', - 'message': '团队本月消费额度已用完', - 'team_monthly_limit': locked_team.monthly_seconds_limit, - 'team_monthly_used': team_monthly_used, + 'message': '团队余额不足,请联系管理员充值', + 'team_balance': float(locked_team.balance), + 'team_frozen': float(locked_team.frozen_amount), + 'team_available': float(available), + 'estimated_cost': float(estimated_cost), }, status=status.HTTP_429_TOO_MANY_REQUESTS) - # 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) - - # Pre-deduction: create record + update team used + # 构建参考素材 references = request.data.get('references', []) reference_snapshots = [] content_items = [] @@ -237,11 +247,7 @@ def video_generate_view(request): 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, @@ -250,13 +256,19 @@ def video_generate_view(request): aspect_ratio=aspect_ratio, duration=duration, seconds_consumed=duration, + frozen_amount=estimated_cost, + resolution='720p', + tokens_consumed=0, + cost_amount=0, + base_cost_amount=0, reference_urls=reference_snapshots, ) + locked_team.frozen_amount = F('frozen_amount') + estimated_cost locked_team.total_seconds_used = F('total_seconds_used') + duration - locked_team.save(update_fields=['total_seconds_used']) + locked_team.save(update_fields=['frozen_amount', 'total_seconds_used']) - # ── Call AirDrama API (outside transaction to avoid holding lock) ── + # ── 调用 AirDrama API(事务外,避免持锁) ── from django.conf import settings as django_settings if django_settings.SEEDANCE_ENABLED and django_settings.ARK_API_KEY: try: @@ -280,8 +292,8 @@ def video_generate_view(request): else: record.error_message = str(e) record.save(update_fields=['status', 'error_message']) - # Refund: API call failed, Seedance didn't charge - _refund_quota(record, duration) + # API 调用失败,释放冻结 + _release_freeze(record) else: record.status = 'completed' record.save(update_fields=['status']) @@ -292,23 +304,52 @@ def video_generate_view(request): 'status': record.status, 'estimated_time': 120, 'seconds_consumed': duration, + 'estimated_tokens': estimated_tokens, + 'estimated_cost': float(estimated_cost), 'error_message': getattr(record, 'error_message', '') or '', }, 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 +def _release_freeze(record): + """释放冻结金额(不扣费)。""" + if record.frozen_amount == 0: + return # already released team = record.user.team if not team: return + frozen = record.frozen_amount 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']) + locked_team.frozen_amount = F('frozen_amount') - frozen + locked_team.total_seconds_used = F('total_seconds_used') - record.seconds_consumed + locked_team.save(update_fields=['frozen_amount', 'total_seconds_used']) + record.frozen_amount = 0 record.seconds_consumed = 0 - record.save(update_fields=['seconds_consumed']) + record.save(update_fields=['frozen_amount', 'seconds_consumed']) + + +def _settle_payment(record, total_tokens): + """任务完成时结算:按实际 tokens 扣费并释放冻结。""" + team = record.user.team + if not team: + return + config = QuotaConfig.objects.get_or_create(pk=1)[0] + actual_cost = calculate_cost(total_tokens, config.base_token_price, team.markup_percentage) + base_cost = calculate_base_cost(total_tokens, config.base_token_price) + frozen = record.frozen_amount + + with transaction.atomic(): + locked_team = Team.objects.select_for_update().get(pk=team.pk) + locked_team.balance = F('balance') - actual_cost + locked_team.total_spent = F('total_spent') + actual_cost + locked_team.frozen_amount = F('frozen_amount') - frozen + locked_team.save(update_fields=['balance', 'total_spent', 'frozen_amount']) + + record.tokens_consumed = total_tokens + record.cost_amount = actual_cost + record.base_cost_amount = base_cost + record.frozen_amount = 0 + record.save(update_fields=['tokens_consumed', 'cost_amount', 'base_cost_amount', 'frozen_amount']) # ────────────────────────────────────────────── @@ -377,17 +418,29 @@ def video_task_detail_view(request, task_id): except Exception: logger.exception('Failed to persist video to TOS, using temporary URL') record.result_url = video_url + # 结算:按实际 tokens 扣费 + usage = ark_resp.get('usage', {}) + total_tokens = usage.get('total_tokens', 0) if isinstance(usage, dict) else 0 + if total_tokens > 0: + _settle_payment(record, total_tokens) + else: + # API 没返回 tokens(异常),释放冻结不扣费 + _release_freeze(record) elif new_status == 'failed': error = ark_resp.get('error', {}) code = error.get('code', '') if isinstance(error, dict) else '' raw_msg = error.get('message', '') if isinstance(error, dict) else str(error) from utils.airdrama_client import ERROR_MESSAGES record.error_message = ERROR_MESSAGES.get(code, raw_msg) - # Phase 5: Refund if Seedance didn't charge + # 失败时检查是否产生了 token 消耗 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) + if total_tokens > 0: + # Seedance 已计费,按实际扣费(允许透支) + _settle_payment(record, total_tokens) + else: + # Seedance 未计费,释放冻结 + _release_freeze(record) record.save(update_fields=['status', 'result_url', 'error_message']) except Exception as e: @@ -409,6 +462,9 @@ def _serialize_task(record): 'aspect_ratio': record.aspect_ratio, 'duration': record.duration, 'seconds_consumed': record.seconds_consumed, + 'tokens_consumed': record.tokens_consumed, + 'cost_amount': float(record.cost_amount), + 'base_cost_amount': float(record.base_cost_amount), 'status': record.status, 'result_url': d.get('result_url', ''), 'error_message': d.get('error_message', ''), @@ -446,6 +502,33 @@ def admin_stats_view(request): created_at__date__gte=first_of_month ).aggregate(total=Sum('seconds_consumed'))['total'] or 0 + # Cost-based stats + cost_today = GenerationRecord.objects.filter( + created_at__date=today + ).aggregate(total=Sum('cost_amount'))['total'] or 0 + + cost_yesterday = GenerationRecord.objects.filter( + created_at__date=yesterday + ).aggregate(total=Sum('cost_amount'))['total'] or 0 + + cost_this_month = GenerationRecord.objects.filter( + created_at__date__gte=first_of_month + ).aggregate(total=Sum('cost_amount'))['total'] or 0 + + base_cost_today = GenerationRecord.objects.filter( + created_at__date=today + ).aggregate(total=Sum('base_cost_amount'))['total'] or 0 + + base_cost_this_month = GenerationRecord.objects.filter( + created_at__date__gte=first_of_month + ).aggregate(total=Sum('base_cost_amount'))['total'] or 0 + + # Total revenue / cost / profit + total_revenue = GenerationRecord.objects.aggregate(total=Sum('cost_amount'))['total'] or 0 + total_base_cost = GenerationRecord.objects.aggregate(total=Sum('base_cost_amount'))['total'] or 0 + total_profit = total_revenue - total_base_cost + profit_margin = round(float(total_profit) / max(float(total_revenue), 0.01) * 100, 1) if total_revenue else 0 + # Last month same period for comparison if first_of_month.month == 1: last_month_start = first_of_month.replace(year=first_of_month.year - 1, month=12) @@ -458,46 +541,85 @@ def admin_stats_view(request): created_at__date__lte=last_month_same_day ).aggregate(total=Sum('seconds_consumed'))['total'] or 0 - today_change = round(((seconds_today - seconds_yesterday) / max(seconds_yesterday, 1)) * 100, 1) if seconds_yesterday else 0 - month_change = round(((seconds_this_month - seconds_last_month_period) / max(seconds_last_month_period, 1)) * 100, 1) if seconds_last_month_period else 0 + cost_last_month_period = GenerationRecord.objects.filter( + created_at__date__gte=last_month_start, + created_at__date__lte=last_month_same_day + ).aggregate(total=Sum('cost_amount'))['total'] or 0 - # Daily trend for past 30 days + today_change = round(((float(cost_today) - float(cost_yesterday)) / max(float(cost_yesterday), 0.01)) * 100, 1) if cost_yesterday else 0 + month_change = round(((float(cost_this_month) - float(cost_last_month_period)) / max(float(cost_last_month_period), 0.01)) * 100, 1) if cost_last_month_period else 0 + + # Daily trend for past 30 days (cost + base_cost) daily_trend_qs = ( GenerationRecord.objects .filter(created_at__date__gte=thirty_days_ago) .annotate(date=TruncDate('created_at')) .values('date') - .annotate(seconds=Sum('seconds_consumed')) + .annotate( + seconds=Sum('seconds_consumed'), + cost=Sum('cost_amount'), + base_cost=Sum('base_cost_amount'), + ) .order_by('date') ) - trend_map = {str(item['date']): item['seconds'] or 0 for item in daily_trend_qs} + trend_map = {str(item['date']): item 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)}) + item = trend_map.get(str(d), {}) + daily_trend.append({ + 'date': str(d), + 'seconds': item.get('seconds') or 0, + 'cost': float(item.get('cost') or 0), + 'base_cost': float(item.get('base_cost') or 0), + }) - # Top 10 users by seconds consumed this month + # Top 10 users by cost consumed this month top_users = ( User.objects.annotate( + cost_consumed=Sum( + 'generation_records__cost_amount', + filter=Q(generation_records__created_at__date__gte=first_of_month), + ), seconds_consumed=Sum( 'generation_records__seconds_consumed', filter=Q(generation_records__created_at__date__gte=first_of_month), - ) + ), ) - .filter(seconds_consumed__gt=0) - .order_by('-seconds_consumed')[:10] + .filter(cost_consumed__gt=0) + .order_by('-cost_consumed')[:10] ) # Team consumption ranking this month top_teams = ( Team.objects.annotate( + cost_consumed=Sum( + 'members__generation_records__cost_amount', + filter=Q(members__generation_records__created_at__date__gte=first_of_month), + ), seconds_consumed=Sum( 'members__generation_records__seconds_consumed', filter=Q(members__generation_records__created_at__date__gte=first_of_month), - ) + ), ) - .filter(seconds_consumed__gt=0) - .order_by('-seconds_consumed') + .filter(cost_consumed__gt=0) + .order_by('-cost_consumed') + ) + + # Team profit ranking + team_profit_ranking = ( + Team.objects.annotate( + team_revenue=Sum( + 'members__generation_records__cost_amount', + filter=Q(members__generation_records__created_at__date__gte=first_of_month), + ), + team_base_cost=Sum( + 'members__generation_records__base_cost_amount', + filter=Q(members__generation_records__created_at__date__gte=first_of_month), + ), + ) + .filter(team_revenue__gt=0) + .order_by('-team_revenue') ) return Response({ @@ -506,17 +628,42 @@ def admin_stats_view(request): 'new_users_today': new_users_today, 'seconds_consumed_today': seconds_today, 'seconds_consumed_this_month': seconds_this_month, + 'cost_today': float(cost_today), + 'cost_this_month': float(cost_this_month), + 'base_cost_today': float(base_cost_today), + 'base_cost_this_month': float(base_cost_this_month), + 'total_revenue': float(total_revenue), + 'total_base_cost': float(total_base_cost), + 'total_profit': float(total_profit), + 'profit_margin': profit_margin, 'today_change_percent': today_change, 'month_change_percent': month_change, 'daily_trend': daily_trend, 'top_users': [ - {'user_id': u.id, 'username': u.username, 'seconds_consumed': u.seconds_consumed or 0} + { + 'user_id': u.id, 'username': u.username, + 'cost_consumed': float(u.cost_consumed or 0), + 'seconds_consumed': u.seconds_consumed or 0, + } for u in top_users ], 'top_teams': [ - {'team_id': t.id, 'name': t.name, 'seconds_consumed': t.seconds_consumed or 0} + { + 'team_id': t.id, 'name': t.name, + 'cost_consumed': float(t.cost_consumed or 0), + 'seconds_consumed': t.seconds_consumed or 0, + } for t in top_teams ], + 'team_profit_ranking': [ + { + 'team_id': t.id, 'name': t.name, + 'revenue': float(t.team_revenue or 0), + 'base_cost': float(t.team_base_cost or 0), + 'profit': float((t.team_revenue or 0) - (t.team_base_cost or 0)), + } + for t in team_profit_ranking + ], }) @@ -539,6 +686,11 @@ def admin_teams_list_view(request): created_at__date__gte=first_of_month, ).aggregate(total=Sum('seconds_consumed'))['total'] or 0 + monthly_spent = GenerationRecord.objects.filter( + user__team=t, + created_at__date__gte=first_of_month, + ).aggregate(total=Sum('cost_amount'))['total'] or 0 + results.append({ 'id': t.id, 'name': t.name, @@ -547,6 +699,13 @@ def admin_teams_list_view(request): 'remaining_seconds': t.remaining_seconds, 'monthly_seconds_limit': t.monthly_seconds_limit, 'monthly_seconds_used': monthly_used, + 'balance': float(t.balance), + 'total_spent': float(t.total_spent), + 'available_balance': float(t.available_balance), + 'monthly_spending_limit': float(t.monthly_spending_limit), + 'monthly_spent': float(monthly_spent), + 'frozen_amount': float(t.frozen_amount), + 'markup_percentage': float(t.markup_percentage), 'daily_member_limit_default': t.daily_member_limit_default, 'member_count': t.members.count(), 'is_active': t.is_active, @@ -573,12 +732,18 @@ def admin_team_create_view(request): log_admin_action(request, 'team_create', 'team', target_id=team.id, target_name=team.name, after={'name': team.name, 'monthly_seconds_limit': team.monthly_seconds_limit, 'daily_member_limit_default': team.daily_member_limit_default, + 'markup_percentage': float(team.markup_percentage), + 'monthly_spending_limit': float(team.monthly_spending_limit), + 'daily_member_spending_default': float(team.daily_member_spending_default), 'expected_regions': team.expected_regions}) return Response({ 'id': team.id, 'name': team.name, 'monthly_seconds_limit': team.monthly_seconds_limit, 'daily_member_limit_default': team.daily_member_limit_default, + 'markup_percentage': float(team.markup_percentage), + 'monthly_spending_limit': float(team.monthly_spending_limit), + 'daily_member_spending_default': float(team.daily_member_spending_default), 'expected_regions': team.expected_regions, 'created_at': team.created_at.isoformat(), }, status=status.HTTP_201_CREATED) @@ -631,6 +796,9 @@ def admin_team_detail_view(request, team_id): 'name': team.name, 'monthly_seconds_limit': team.monthly_seconds_limit, 'daily_member_limit_default': team.daily_member_limit_default, + 'markup_percentage': float(team.markup_percentage), + 'monthly_spending_limit': float(team.monthly_spending_limit), + 'daily_member_spending_default': float(team.daily_member_spending_default), 'is_active': team.is_active, 'expected_regions': team.expected_regions, 'disabled_by': team.disabled_by, @@ -646,6 +814,11 @@ def admin_team_detail_view(request, team_id): created_at__date__gte=first_of_month, ).aggregate(total=Sum('seconds_consumed'))['total'] or 0 + monthly_spent = GenerationRecord.objects.filter( + user__team=team, + created_at__date__gte=first_of_month, + ).aggregate(total=Sum('cost_amount'))['total'] or 0 + members = team.members.annotate( seconds_today=Sum( 'generation_records__seconds_consumed', @@ -655,6 +828,22 @@ def admin_team_detail_view(request, team_id): 'generation_records__seconds_consumed', filter=Q(generation_records__created_at__date__gte=first_of_month), ), + generations_today=Count( + 'generation_records', + filter=Q(generation_records__created_at__date=today), + ), + generations_this_month=Count( + 'generation_records', + filter=Q(generation_records__created_at__date__gte=first_of_month), + ), + spent_today=Sum( + 'generation_records__cost_amount', + filter=Q(generation_records__created_at__date=today), + ), + spent_this_month=Sum( + 'generation_records__cost_amount', + filter=Q(generation_records__created_at__date__gte=first_of_month), + ), ).order_by('-date_joined') # TeamAnomalyConfig @@ -685,6 +874,13 @@ def admin_team_detail_view(request, team_id): 'remaining_seconds': team.remaining_seconds, 'monthly_seconds_limit': team.monthly_seconds_limit, 'monthly_seconds_used': monthly_used, + 'balance': float(team.balance), + 'total_spent': float(team.total_spent), + 'available_balance': float(team.available_balance), + 'monthly_spending_limit': float(team.monthly_spending_limit), + 'monthly_spent': float(monthly_spent), + 'frozen_amount': float(team.frozen_amount), + 'markup_percentage': float(team.markup_percentage), 'daily_member_limit_default': team.daily_member_limit_default, 'member_count': team.members.count(), 'is_active': team.is_active, @@ -701,8 +897,14 @@ def admin_team_detail_view(request, team_id): 'disabled_by': m.disabled_by, 'daily_seconds_limit': m.daily_seconds_limit, 'monthly_seconds_limit': m.monthly_seconds_limit, + 'daily_generation_limit': m.daily_generation_limit, + 'monthly_generation_limit': m.monthly_generation_limit, 'seconds_today': m.seconds_today or 0, 'seconds_this_month': m.seconds_this_month or 0, + 'generations_today': m.generations_today or 0, + 'generations_this_month': m.generations_this_month or 0, + 'spent_today': float(m.spent_today or 0), + 'spent_this_month': float(m.spent_this_month or 0), 'date_joined': m.date_joined.isoformat(), } for m in members], }) @@ -711,7 +913,7 @@ def admin_team_detail_view(request, team_id): @api_view(['POST']) @permission_classes([IsSuperAdmin]) def admin_team_topup_view(request, team_id): - """POST /api/v1/admin/teams//topup — Add seconds to team pool.""" + """POST /api/v1/admin/teams//topup — Add balance to team.""" try: team = Team.objects.get(id=team_id) except Team.DoesNotExist: @@ -720,39 +922,79 @@ def admin_team_topup_view(request, team_id): serializer = TeamTopUpSerializer(data=request.data) serializer.is_valid(raise_exception=True) - seconds = serializer.validated_data['seconds'] - old_pool = team.total_seconds_pool + amount = serializer.validated_data['amount'] + old_balance = float(team.balance) 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']) + locked.balance = F('balance') + amount + locked.save(update_fields=['balance']) team.refresh_from_db() log_admin_action(request, 'team_topup', 'team', target_id=team.id, target_name=team.name, - before={'total_seconds_pool': old_pool}, - after={'total_seconds_pool': team.total_seconds_pool, 'topped_up': seconds}) + before={'balance': old_balance}, + after={'balance': float(team.balance), 'topped_up': float(amount)}) return Response({ 'id': team.id, 'name': team.name, + 'balance': float(team.balance), + 'total_spent': float(team.total_spent), + 'available_balance': float(team.available_balance), + 'frozen_amount': float(team.frozen_amount), + 'topped_up': float(amount), 'total_seconds_pool': team.total_seconds_pool, 'total_seconds_used': team.total_seconds_used, 'remaining_seconds': team.remaining_seconds, - 'topped_up': seconds, }) @api_view(['PUT']) @permission_classes([IsSuperAdmin]) def admin_team_set_pool_view(request, team_id): - """PUT /api/v1/admin/teams//set-pool — Directly set total_seconds_pool.""" + """PUT /api/v1/admin/teams//set-pool — Directly set team balance.""" try: team = Team.objects.get(id=team_id) except Team.DoesNotExist: return Response({'error': '团队不存在'}, status=status.HTTP_404_NOT_FOUND) + # Accept both 'balance' (new) and 'total_seconds_pool' (backward compat) + new_balance = request.data.get('balance') new_pool = request.data.get('total_seconds_pool') + + if new_balance is not None: + from decimal import Decimal, InvalidOperation + try: + new_balance = Decimal(str(new_balance)) + except (InvalidOperation, ValueError, TypeError): + return Response({'error': '请输入有效的金额'}, status=status.HTTP_400_BAD_REQUEST) + + if new_balance < 0: + return Response({'error': '余额不能为负数'}, status=status.HTTP_400_BAD_REQUEST) + + old_balance = float(team.balance) + with transaction.atomic(): + locked = Team.objects.select_for_update().get(pk=team.pk) + locked.balance = new_balance + locked.save(update_fields=['balance']) + + team.refresh_from_db() + log_admin_action(request, 'team_set_pool', 'team', target_id=team.id, target_name=team.name, + before={'balance': old_balance}, + after={'balance': float(team.balance)}) + return Response({ + 'id': team.id, + 'name': team.name, + 'balance': float(team.balance), + 'total_spent': float(team.total_spent), + 'available_balance': float(team.available_balance), + 'frozen_amount': float(team.frozen_amount), + 'total_seconds_pool': team.total_seconds_pool, + 'total_seconds_used': team.total_seconds_used, + 'remaining_seconds': team.remaining_seconds, + }) + + # Backward compat: total_seconds_pool if new_pool is None: - return Response({'error': 'total_seconds_pool is required'}, status=status.HTTP_400_BAD_REQUEST) + return Response({'error': 'balance or total_seconds_pool is required'}, status=status.HTTP_400_BAD_REQUEST) try: new_pool = int(new_pool) @@ -778,6 +1020,9 @@ def admin_team_set_pool_view(request, team_id): return Response({ 'id': team.id, 'name': team.name, + 'balance': float(team.balance), + 'total_spent': float(team.total_spent), + 'available_balance': float(team.available_balance), 'total_seconds_pool': team.total_seconds_pool, 'total_seconds_used': team.total_seconds_used, 'remaining_seconds': team.remaining_seconds, @@ -804,6 +1049,7 @@ def admin_team_create_admin_view(request, team_id): if User.objects.filter(email=email).exists(): return Response({'error': '邮箱已存在'}, status=status.HTTP_400_BAD_REQUEST) + config = QuotaConfig.objects.get_or_create(pk=1)[0] user = User.objects.create_user( username=username, email=email, @@ -812,6 +1058,8 @@ def admin_team_create_admin_view(request, team_id): is_team_admin=True, daily_seconds_limit=team.daily_member_limit_default, monthly_seconds_limit=-1, # Team admin unlimited by default + daily_generation_limit=-1, # Team admin unlimited by default + monthly_generation_limit=-1, ) log_admin_action(request, 'team_create_admin', 'user', target_id=user.id, target_name=user.username, after={'username': user.username, 'email': user.email, 'team': team.name}) @@ -851,6 +1099,22 @@ def admin_users_list_view(request): 'generation_records__seconds_consumed', filter=Q(generation_records__created_at__date__gte=first_of_month), ), + generations_today=Count( + 'generation_records', + filter=Q(generation_records__created_at__date=today), + ), + generations_this_month=Count( + 'generation_records', + filter=Q(generation_records__created_at__date__gte=first_of_month), + ), + spent_today=Sum( + 'generation_records__cost_amount', + filter=Q(generation_records__created_at__date=today), + ), + spent_this_month=Sum( + 'generation_records__cost_amount', + filter=Q(generation_records__created_at__date__gte=first_of_month), + ), ) if search: @@ -881,8 +1145,14 @@ def admin_users_list_view(request): 'date_joined': u.date_joined.isoformat(), 'daily_seconds_limit': u.daily_seconds_limit, 'monthly_seconds_limit': u.monthly_seconds_limit, + 'daily_generation_limit': u.daily_generation_limit, + 'monthly_generation_limit': u.monthly_generation_limit, 'seconds_today': u.seconds_today or 0, 'seconds_this_month': u.seconds_this_month or 0, + 'generations_today': u.generations_today or 0, + 'generations_this_month': u.generations_this_month or 0, + 'spent_today': float(u.spent_today or 0), + 'spent_this_month': float(u.spent_this_month or 0), }) return Response({ @@ -917,6 +1187,26 @@ def admin_user_detail_view(request, user_id): total=Sum('seconds_consumed') )['total'] or 0 + generations_today = user.generation_records.filter( + created_at__date=today + ).count() + + generations_this_month = user.generation_records.filter( + created_at__date__gte=first_of_month + ).count() + + spent_today = user.generation_records.filter( + created_at__date=today + ).aggregate(total=Sum('cost_amount'))['total'] or 0 + + spent_this_month = user.generation_records.filter( + created_at__date__gte=first_of_month + ).aggregate(total=Sum('cost_amount'))['total'] or 0 + + total_spent = user.generation_records.aggregate( + total=Sum('cost_amount') + )['total'] or 0 + recent_records = _eval_qs(user.generation_records.order_by('-created_at'), limit=20) return Response({ @@ -931,19 +1221,28 @@ def admin_user_detail_view(request, user_id): 'date_joined': user.date_joined.isoformat(), 'daily_seconds_limit': user.daily_seconds_limit, 'monthly_seconds_limit': user.monthly_seconds_limit, + 'daily_generation_limit': user.daily_generation_limit, + 'monthly_generation_limit': user.monthly_generation_limit, 'seconds_today': seconds_today, 'seconds_this_month': seconds_this_month, 'seconds_total': seconds_total, + 'generations_today': generations_today, + 'generations_this_month': generations_this_month, + 'spent_today': float(spent_today), + 'spent_this_month': float(spent_this_month), + 'total_spent': float(total_spent), 'recent_records': [ { 'id': r.id, 'created_at': r.created_at.isoformat(), '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, 'status': r.status, - 'error_message': r.error_message or '', + 'error_message': r.error_message or '', } for r in recent_records ], @@ -962,17 +1261,25 @@ def admin_user_quota_view(request, user_id): serializer = QuotaUpdateSerializer(data=request.data) serializer.is_valid(raise_exception=True) - before = {'daily_seconds_limit': user.daily_seconds_limit, 'monthly_seconds_limit': user.monthly_seconds_limit} - user.daily_seconds_limit = serializer.validated_data['daily_seconds_limit'] - user.monthly_seconds_limit = serializer.validated_data['monthly_seconds_limit'] - user.save(update_fields=['daily_seconds_limit', 'monthly_seconds_limit']) + before = { + 'daily_generation_limit': user.daily_generation_limit, + 'monthly_generation_limit': user.monthly_generation_limit, + } + user.daily_generation_limit = serializer.validated_data['daily_generation_limit'] + user.monthly_generation_limit = serializer.validated_data['monthly_generation_limit'] + user.save(update_fields=['daily_generation_limit', 'monthly_generation_limit']) log_admin_action(request, 'user_quota_update', 'user', target_id=user.id, target_name=user.username, before=before, - after={'daily_seconds_limit': user.daily_seconds_limit, 'monthly_seconds_limit': user.monthly_seconds_limit}) + after={ + 'daily_generation_limit': user.daily_generation_limit, + 'monthly_generation_limit': user.monthly_generation_limit, + }) return Response({ 'user_id': user.id, 'username': user.username, + 'daily_generation_limit': user.daily_generation_limit, + 'monthly_generation_limit': user.monthly_generation_limit, 'daily_seconds_limit': user.daily_seconds_limit, 'monthly_seconds_limit': user.monthly_seconds_limit, 'updated_at': timezone.now().isoformat(), @@ -1053,6 +1360,8 @@ def admin_create_user_view(request): password=serializer.validated_data['password'], daily_seconds_limit=serializer.validated_data['daily_seconds_limit'], monthly_seconds_limit=serializer.validated_data['monthly_seconds_limit'], + daily_generation_limit=serializer.validated_data['daily_generation_limit'], + monthly_generation_limit=serializer.validated_data['monthly_generation_limit'], is_staff=serializer.validated_data['is_staff'], ) log_admin_action(request, 'user_create', 'user', target_id=user.id, target_name=user.username, @@ -1066,6 +1375,8 @@ def admin_create_user_view(request): 'is_staff': user.is_staff, 'daily_seconds_limit': user.daily_seconds_limit, 'monthly_seconds_limit': user.monthly_seconds_limit, + 'daily_generation_limit': user.daily_generation_limit, + 'monthly_generation_limit': user.monthly_generation_limit, 'created_at': timezone.now().isoformat(), }, status=status.HTTP_201_CREATED) @@ -1109,6 +1420,9 @@ def admin_records_view(request): 'username': r.user.username, 'team_name': r.user.team.name if r.user.team else None, 'seconds_consumed': r.seconds_consumed, + 'tokens_consumed': r.tokens_consumed, + 'cost_amount': float(r.cost_amount), + 'base_cost_amount': float(r.base_cost_amount), 'prompt': r.prompt, 'mode': r.mode, 'model': r.model, @@ -1134,6 +1448,9 @@ def _settings_dict(config): return { 'default_daily_seconds_limit': config.default_daily_seconds_limit, 'default_monthly_seconds_limit': config.default_monthly_seconds_limit, + 'default_daily_generation_limit': config.default_daily_generation_limit, + 'default_monthly_generation_limit': config.default_monthly_generation_limit, + 'base_token_price': float(config.base_token_price), 'announcement': config.announcement, 'announcement_enabled': config.announcement_enabled, 'max_desktop_sessions': config.max_desktop_sessions, @@ -1413,6 +1730,11 @@ def team_info_view(request): created_at__date__gte=first_of_month, ).aggregate(total=Sum('seconds_consumed'))['total'] or 0 + monthly_spent = GenerationRecord.objects.filter( + user__team=team, + created_at__date__gte=first_of_month, + ).aggregate(total=Sum('cost_amount'))['total'] or 0 + return Response({ 'id': team.id, 'name': team.name, @@ -1421,6 +1743,13 @@ def team_info_view(request): 'remaining_seconds': team.remaining_seconds, 'monthly_seconds_limit': team.monthly_seconds_limit, 'monthly_seconds_used': monthly_used, + 'balance': float(team.balance), + 'total_spent': float(team.total_spent), + 'available_balance': float(team.available_balance), + 'monthly_spending_limit': float(team.monthly_spending_limit), + 'monthly_spent': float(monthly_spent), + 'frozen_amount': float(team.frozen_amount), + 'markup_percentage': float(team.markup_percentage), 'daily_member_limit_default': team.daily_member_limit_default, 'member_count': team.members.count(), 'is_active': team.is_active, @@ -1436,20 +1765,30 @@ def team_stats_view(request): first_of_month = today.replace(day=1) thirty_days_ago = today - timedelta(days=29) - # Daily trend + # Daily trend (cost + base_cost) 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')) + .annotate( + seconds=Sum('seconds_consumed'), + cost=Sum('cost_amount'), + base_cost=Sum('base_cost_amount'), + ) .order_by('date') ) - trend_map = {str(item['date']): item['seconds'] or 0 for item in daily_trend_qs} + trend_map = {str(item['date']): item 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)}) + item = trend_map.get(str(d), {}) + daily_trend.append({ + 'date': str(d), + 'seconds': item.get('seconds') or 0, + 'cost': float(item.get('cost') or 0), + 'base_cost': float(item.get('base_cost') or 0), + }) # Member consumption this month members = team.members.annotate( @@ -1457,12 +1796,25 @@ def team_stats_view(request): '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') + cost_this_month=Sum( + 'generation_records__cost_amount', + filter=Q(generation_records__created_at__date__gte=first_of_month), + ), + generations_this_month=Count( + 'generation_records', + filter=Q(generation_records__created_at__date__gte=first_of_month), + ), + ).filter(generations_this_month__gt=0).order_by('-cost_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} + { + 'user_id': m.id, 'username': m.username, + 'seconds_consumed': m.seconds_this_month or 0, + 'cost_consumed': float(m.cost_this_month or 0), + 'generation_count': m.generations_this_month or 0, + } for m in members ], }) @@ -1485,6 +1837,22 @@ def team_members_list_view(request): 'generation_records__seconds_consumed', filter=Q(generation_records__created_at__date__gte=first_of_month), ), + generations_today=Count( + 'generation_records', + filter=Q(generation_records__created_at__date=today), + ), + generations_this_month=Count( + 'generation_records', + filter=Q(generation_records__created_at__date__gte=first_of_month), + ), + spent_today=Sum( + 'generation_records__cost_amount', + filter=Q(generation_records__created_at__date=today), + ), + spent_this_month=Sum( + 'generation_records__cost_amount', + filter=Q(generation_records__created_at__date__gte=first_of_month), + ), ).order_by('-date_joined') return Response({ @@ -1496,8 +1864,14 @@ def team_members_list_view(request): 'is_active': m.is_active, 'daily_seconds_limit': m.daily_seconds_limit, 'monthly_seconds_limit': m.monthly_seconds_limit, + 'daily_generation_limit': m.daily_generation_limit, + 'monthly_generation_limit': m.monthly_generation_limit, 'seconds_today': m.seconds_today or 0, 'seconds_this_month': m.seconds_this_month or 0, + 'generations_today': m.generations_today or 0, + 'generations_this_month': m.generations_this_month or 0, + 'spent_today': float(m.spent_today or 0), + 'spent_this_month': float(m.spent_this_month or 0), 'date_joined': m.date_joined.isoformat(), } for m in members], }) @@ -1519,6 +1893,11 @@ def team_member_create_view(request): daily = serializer.validated_data.get('daily_seconds_limit', team.daily_member_limit_default) monthly = serializer.validated_data.get('monthly_seconds_limit', -1) + # Generation count limits + config = QuotaConfig.objects.get_or_create(pk=1)[0] + daily_gen = serializer.validated_data.get('daily_generation_limit', config.default_daily_generation_limit) + monthly_gen = serializer.validated_data.get('monthly_generation_limit', config.default_monthly_generation_limit) + # Generate email from username (team members may not need real email) email = f'{username}@team.local' if User.objects.filter(email=email).exists(): @@ -1532,6 +1911,8 @@ def team_member_create_view(request): is_team_admin=False, # Cannot escalate privileges daily_seconds_limit=daily, monthly_seconds_limit=monthly, + daily_generation_limit=daily_gen, + monthly_generation_limit=monthly_gen, ) log_admin_action(request, 'member_create', 'user', target_id=user.id, target_name=user.username, after={'username': user.username, 'team': team.name}) @@ -1542,6 +1923,8 @@ def team_member_create_view(request): 'team': team.name, 'daily_seconds_limit': user.daily_seconds_limit, 'monthly_seconds_limit': user.monthly_seconds_limit, + 'daily_generation_limit': user.daily_generation_limit, + 'monthly_generation_limit': user.monthly_generation_limit, }, status=status.HTTP_201_CREATED) @@ -1566,6 +1949,22 @@ def team_member_detail_view(request, member_id): created_at__date__gte=first_of_month ).aggregate(total=Sum('seconds_consumed'))['total'] or 0 + generations_today = member.generation_records.filter( + created_at__date=today + ).count() + + generations_this_month = member.generation_records.filter( + created_at__date__gte=first_of_month + ).count() + + spent_today = member.generation_records.filter( + created_at__date=today + ).aggregate(total=Sum('cost_amount'))['total'] or 0 + + spent_this_month = member.generation_records.filter( + created_at__date__gte=first_of_month + ).aggregate(total=Sum('cost_amount'))['total'] or 0 + recent_records = _eval_qs(member.generation_records.order_by('-created_at'), limit=20) return Response({ @@ -1575,18 +1974,26 @@ def team_member_detail_view(request, member_id): 'is_team_admin': member.is_team_admin, 'daily_seconds_limit': member.daily_seconds_limit, 'monthly_seconds_limit': member.monthly_seconds_limit, + 'daily_generation_limit': member.daily_generation_limit, + 'monthly_generation_limit': member.monthly_generation_limit, 'seconds_today': seconds_today, 'seconds_this_month': seconds_this_month, + 'generations_today': generations_today, + 'generations_this_month': generations_this_month, + 'spent_today': float(spent_today), + 'spent_this_month': float(spent_this_month), 'recent_records': [ { 'id': r.id, 'created_at': r.created_at.isoformat(), '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, 'status': r.status, - 'error_message': r.error_message or '', + 'error_message': r.error_message or '', } for r in recent_records ], @@ -1606,17 +2013,25 @@ def team_member_quota_view(request, member_id): serializer = MemberQuotaSerializer(data=request.data) serializer.is_valid(raise_exception=True) - before = {'daily_seconds_limit': member.daily_seconds_limit, 'monthly_seconds_limit': member.monthly_seconds_limit} - 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']) + before = { + 'daily_generation_limit': member.daily_generation_limit, + 'monthly_generation_limit': member.monthly_generation_limit, + } + member.daily_generation_limit = serializer.validated_data['daily_generation_limit'] + member.monthly_generation_limit = serializer.validated_data['monthly_generation_limit'] + member.save(update_fields=['daily_generation_limit', 'monthly_generation_limit']) log_admin_action(request, 'member_quota_update', 'user', target_id=member.id, target_name=member.username, before=before, - after={'daily_seconds_limit': member.daily_seconds_limit, 'monthly_seconds_limit': member.monthly_seconds_limit}) + after={ + 'daily_generation_limit': member.daily_generation_limit, + 'monthly_generation_limit': member.monthly_generation_limit, + }) return Response({ 'user_id': member.id, 'username': member.username, + 'daily_generation_limit': member.daily_generation_limit, + 'monthly_generation_limit': member.monthly_generation_limit, 'daily_seconds_limit': member.daily_seconds_limit, 'monthly_seconds_limit': member.monthly_seconds_limit, }) @@ -1679,20 +2094,52 @@ def profile_overview_view(request): total=Sum('seconds_consumed') )['total'] or 0 + # Count-based usage + daily_generation_used = user.generation_records.filter( + created_at__date=today + ).count() + + monthly_generation_used = user.generation_records.filter( + created_at__date__gte=first_of_month + ).count() + + # Spending + daily_spent = user.generation_records.filter( + created_at__date=today + ).aggregate(total=Sum('cost_amount'))['total'] or 0 + + monthly_spent = user.generation_records.filter( + created_at__date__gte=first_of_month + ).aggregate(total=Sum('cost_amount'))['total'] or 0 + + total_spent = user.generation_records.aggregate( + total=Sum('cost_amount') + )['total'] or 0 + # Daily trend trend_qs = ( user.generation_records .filter(created_at__date__gte=start_date) .annotate(date=TruncDate('created_at')) .values('date') - .annotate(seconds=Sum('seconds_consumed')) + .annotate( + seconds=Sum('seconds_consumed'), + cost=Sum('cost_amount'), + count=Count('id'), + ) .order_by('date') ) - trend_map = {str(item['date']): item['seconds'] or 0 for item in trend_qs} + trend_map = {str(item['date']): item for item in trend_qs} daily_trend = [] for i in range(days): d = start_date + timedelta(days=i) - daily_trend.append({'date': str(d), 'seconds': trend_map.get(str(d), 0)}) + item = trend_map.get(str(d), {}) + daily_trend.append({ + 'date': str(d), + 'seconds': item.get('seconds') or 0, + 'cost': float(item.get('cost') or 0), + 'count': item.get('count') or 0, + }) data = { 'daily_seconds_limit': user.daily_seconds_limit, @@ -1700,6 +2147,13 @@ def profile_overview_view(request): 'monthly_seconds_limit': user.monthly_seconds_limit, 'monthly_seconds_used': monthly_seconds_used, 'total_seconds_used': total_seconds_used, + 'daily_generation_limit': user.daily_generation_limit, + 'daily_generation_used': daily_generation_used, + 'monthly_generation_limit': user.monthly_generation_limit, + 'monthly_generation_used': monthly_generation_used, + 'daily_spent': float(daily_spent), + 'monthly_spent': float(monthly_spent), + 'total_spent': float(total_spent), 'daily_trend': daily_trend, } @@ -1711,6 +2165,11 @@ def profile_overview_view(request): created_at__date__gte=first_of_month, ).aggregate(total=Sum('seconds_consumed'))['total'] or 0 + team_monthly_spent = GenerationRecord.objects.filter( + user__team=team, + created_at__date__gte=first_of_month, + ).aggregate(total=Sum('cost_amount'))['total'] or 0 + data['team'] = { 'name': team.name, 'total_seconds_pool': team.total_seconds_pool, @@ -1718,6 +2177,12 @@ def profile_overview_view(request): 'remaining_seconds': team.remaining_seconds, 'monthly_seconds_limit': team.monthly_seconds_limit, 'monthly_seconds_used': team_monthly_used, + 'balance': float(team.balance), + 'total_spent': float(team.total_spent), + 'available_balance': float(team.available_balance), + 'monthly_spending_limit': float(team.monthly_spending_limit), + 'monthly_spent': float(team_monthly_spent), + 'frozen_amount': float(team.frozen_amount), } return Response(data) @@ -1742,6 +2207,8 @@ def profile_records_view(request): 'id': r.id, 'created_at': r.created_at.isoformat(), '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, diff --git a/backend/utils/billing.py b/backend/utils/billing.py new file mode 100644 index 0000000..ee34db2 --- /dev/null +++ b/backend/utils/billing.py @@ -0,0 +1,69 @@ +""" +计费工具模块 — 分辨率映射 + token/费用计算 + +Token 预估公式(火山官方):(宽 × 高 × 帧率 × 时长) / 1024 +单价:元 / 百万 tokens +""" +from decimal import Decimal, ROUND_HALF_UP + +# 分辨率 → 像素映射(来自 Seedance 2.0 API 文档) +RESOLUTION_MAP = { + # 720p + ('720p', '16:9'): (1280, 720), + ('720p', '9:16'): (720, 1280), + ('720p', '4:3'): (1112, 834), + ('720p', '1:1'): (960, 960), + ('720p', '3:4'): (834, 1112), + ('720p', '21:9'): (1470, 630), + # 480p + ('480p', '16:9'): (864, 496), + ('480p', '9:16'): (496, 864), + ('480p', '4:3'): (752, 560), + ('480p', '1:1'): (640, 640), + ('480p', '3:4'): (560, 752), + ('480p', '21:9'): (992, 432), +} + +# 默认帧率 +DEFAULT_FPS = 24 + + +def get_resolution(aspect_ratio: str, tier: str = '720p') -> tuple: + """根据宽高比和分辨率档位返回 (width, height) 像素值。""" + return RESOLUTION_MAP.get((tier, aspect_ratio), (1280, 720)) + + +def estimate_tokens(width: int, height: int, duration: int, fps: int = DEFAULT_FPS) -> int: + """预估视频生成消耗的 tokens。""" + return round(width * height * fps * duration / 1024) + + +def calculate_cost(tokens: int, base_price, markup_percentage) -> Decimal: + """计算用户费用(加价后)。 + + Args: + tokens: 消耗的 tokens 数 + base_price: 成本价(元/百万tokens) + markup_percentage: 加价百分比,如 20 表示 20% + Returns: + Decimal: 加价后费用,保留 2 位小数 + """ + base_price = Decimal(str(base_price)) + markup = Decimal(str(markup_percentage)) + team_price = base_price * (1 + markup / 100) + cost = Decimal(str(tokens)) * team_price / Decimal('1000000') + return cost.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) + + +def calculate_base_cost(tokens: int, base_price) -> Decimal: + """计算平台成本(不加价)。 + + Args: + tokens: 消耗的 tokens 数 + base_price: 成本价(元/百万tokens) + Returns: + Decimal: 成本费用,保留 2 位小数 + """ + base_price = Decimal(str(base_price)) + cost = Decimal(str(tokens)) * base_price / Decimal('1000000') + return cost.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) diff --git a/web/src/components/GenerationCard.tsx b/web/src/components/GenerationCard.tsx index 90ea0db..2174843 100644 --- a/web/src/components/GenerationCard.tsx +++ b/web/src/components/GenerationCard.tsx @@ -237,7 +237,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) { {task.model === 'seedance_2.0' ? 'AirDrama' : 'AirDrama Fast'} {task.duration}s - {task.aspectRatio === 'adaptive' ? '自适应' : task.aspectRatio} + {task.aspectRatio}
- 视频比例{task.aspectRatio === 'adaptive' ? '自适应' : task.aspectRatio} + 视频比例{task.aspectRatio}
时长{task.duration}s diff --git a/web/src/components/ImageLightbox.module.css b/web/src/components/ImageLightbox.module.css new file mode 100644 index 0000000..16bcfdc --- /dev/null +++ b/web/src/components/ImageLightbox.module.css @@ -0,0 +1,17 @@ +.overlay { + position: fixed; + inset: 0; + z-index: 400; + background: rgba(0, 0, 0, 0.85); + display: flex; + align-items: center; + justify-content: center; + cursor: zoom-out; +} +.image { + max-width: 90vw; + max-height: 90vh; + object-fit: contain; + border-radius: 8px; + cursor: default; +} diff --git a/web/src/components/ImageLightbox.tsx b/web/src/components/ImageLightbox.tsx new file mode 100644 index 0000000..14f8273 --- /dev/null +++ b/web/src/components/ImageLightbox.tsx @@ -0,0 +1,24 @@ +import { useEffect } from 'react'; +import styles from './ImageLightbox.module.css'; + +interface Props { + src: string | null; + onClose: () => void; +} + +export function ImageLightbox({ src, onClose }: Props) { + useEffect(() => { + if (!src) return; + const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [src, onClose]); + + if (!src) return null; + + return ( +
{ if (e.target === e.currentTarget) onClose(); }}> + +
+ ); +} diff --git a/web/src/components/LoginModal.tsx b/web/src/components/LoginModal.tsx index e3708e2..e595e1e 100644 --- a/web/src/components/LoginModal.tsx +++ b/web/src/components/LoginModal.tsx @@ -38,8 +38,14 @@ export function LoginModal({ isOpen, onClose, onSuccess }: Props) { if (!isOpen) return null; return ( -
-
e.stopPropagation()}> +
{ if (e.target === e.currentTarget) (e.currentTarget as HTMLElement).dataset.mouseDownOnOverlay = 'true'; }} + onMouseUp={(e) => { + if ((e.currentTarget as HTMLElement).dataset.mouseDownOnOverlay === 'true' && e.target === e.currentTarget) onClose(); + (e.currentTarget as HTMLElement).dataset.mouseDownOnOverlay = ''; + }} + > +
} /> @@ -214,6 +229,16 @@ export function Toolbar() { )} + {/* Estimated cost */} + {tokenPrice > 0 && ( + + 预估消耗:{estimatedTokens.toLocaleString()} tokens / ¥{estimatedCost} + + )} + {/* Spacer */}
diff --git a/web/src/components/UniversalUpload.tsx b/web/src/components/UniversalUpload.tsx index f05db0e..f598f34 100644 --- a/web/src/components/UniversalUpload.tsx +++ b/web/src/components/UniversalUpload.tsx @@ -1,6 +1,7 @@ import { useRef, useState } from 'react'; import { useInputBarStore } from '../store/inputBar'; import { showToast } from './Toast'; +import { ImageLightbox } from './ImageLightbox'; import styles from './UniversalUpload.module.css'; const MAX_IMAGE_SIZE = 30 * 1024 * 1024; // 30MB per API doc @@ -26,6 +27,7 @@ export function UniversalUpload() { const fileInputRef = useRef(null); const [expanded, setExpanded] = useState(false); const [badgeHover, setBadgeHover] = useState(false); + const [lightboxSrc, setLightboxSrc] = useState(null); const handleTrigger = () => { fileInputRef.current?.click(); @@ -122,7 +124,7 @@ export function UniversalUpload() {
) : ( - {ref.label} + {ref.label} { e.stopPropagation(); setLightboxSrc(ref.previewUrl); }} /> )}
)} + setLightboxSrc(null)} />
); } diff --git a/web/src/components/VideoDetailModal.module.css b/web/src/components/VideoDetailModal.module.css index 311ad00..0f93185 100644 --- a/web/src/components/VideoDetailModal.module.css +++ b/web/src/components/VideoDetailModal.module.css @@ -3,7 +3,7 @@ top: 0; right: 0; bottom: 0; - left: 76px; /* sidebar width */ + left: 0; z-index: 200; background: #07070f; display: flex; diff --git a/web/src/components/VideoDetailModal.tsx b/web/src/components/VideoDetailModal.tsx index d8add26..20a325c 100644 --- a/web/src/components/VideoDetailModal.tsx +++ b/web/src/components/VideoDetailModal.tsx @@ -2,6 +2,7 @@ import { useRef, useState, useEffect, useCallback, useMemo } from 'react'; import type { GenerationTask } from '../types'; import { AmbientBackground } from './AmbientBackground'; import { ConfirmModal } from './ConfirmModal'; +import { ImageLightbox } from './ImageLightbox'; import styles from './VideoDetailModal.module.css'; interface Props { @@ -30,19 +31,17 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele const [isFullscreen, setIsFullscreen] = useState(false); const [showMoreMenu, setShowMoreMenu] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); + const [lightboxSrc, setLightboxSrc] = useState(null); const [fitSize, setFitSize] = useState<{ w: number; h: number } | null>(null); const [intrinsicRatio, setIntrinsicRatio] = useState(null); const moreMenuRef = useRef(null); const hideTimerRef = useRef>(); - // Parse aspect ratio from task; for 'adaptive', use video's intrinsic ratio + // Parse aspect ratio from task const arNum = useMemo(() => { const ar = task?.aspectRatio || '16:9'; - if (ar === 'adaptive') { - return intrinsicRatio || 16 / 9; - } const parts = ar.split(':').map(Number); - return (parts[0] && parts[1]) ? parts[0] / parts[1] : 16 / 9; + return (parts[0] && parts[1]) ? parts[0] / parts[1] : (intrinsicRatio || 16 / 9); }, [task?.aspectRatio, intrinsicRatio]); // Compute container size to fit aspect ratio within videoArea @@ -458,7 +457,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
) : ( - {ref.label} + {ref.label} setLightboxSrc(ref.previewUrl)} /> )} {ref.label}
@@ -477,7 +476,15 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele {task.duration}s - {task.aspectRatio === 'adaptive' ? '自适应' : task.aspectRatio} + {task.aspectRatio} + {(task.tokensConsumed ?? 0) > 0 && ( + <> + + {(task.tokensConsumed ?? 0).toLocaleString()} tokens + + ¥{(task.costAmount ?? 0).toFixed(2)} + + )}
{(onReEdit || onRegenerate) && ( @@ -514,6 +521,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele onConfirm={doDelete} onCancel={() => setConfirmDelete(false)} /> + setLightboxSrc(null)} />
); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 201a816..ebd13d6 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -162,7 +162,7 @@ export const adminApi = { getTeams: () => api.get<{ results: Team[] }>('/admin/teams'), - createTeam: (data: { name: string; monthly_seconds_limit?: number; daily_member_limit_default?: number; expected_regions: string }) => + createTeam: (data: { name: string; monthly_spending_limit?: number; daily_member_limit_default?: number; expected_regions: string; markup_percentage?: number }) => api.post('/admin/teams/create', data), getTeamDetail: (teamId: number) => @@ -171,11 +171,11 @@ export const adminApi = { updateTeam: (teamId: number, data: { name?: string; monthly_seconds_limit?: number; daily_member_limit_default?: number; is_active?: boolean; expected_regions?: string; anomaly_config?: Partial }) => api.put(`/admin/teams/${teamId}`, data), - topUpTeam: (teamId: number, seconds: number) => - api.post(`/admin/teams/${teamId}/topup`, { seconds }), + topUpTeam: (teamId: number, amount: number) => + api.post(`/admin/teams/${teamId}/topup`, { amount }), - setTeamPool: (teamId: number, totalSecondsPool: number) => - api.put(`/admin/teams/${teamId}/set-pool`, { total_seconds_pool: totalSecondsPool }), + setTeamPool: (teamId: number, balance: number) => + api.put(`/admin/teams/${teamId}/set-pool`, { balance }), createTeamAdmin: (teamId: number, data: { username: string; email: string; password: string }) => api.post(`/admin/teams/${teamId}/admin`, data), @@ -185,8 +185,8 @@ export const adminApi = { username: string; email: string; password: string; - daily_seconds_limit?: number; - monthly_seconds_limit?: number; + daily_generation_limit?: number; + monthly_generation_limit?: number; is_staff?: boolean; }) => api.post('/admin/users/create', data), @@ -205,8 +205,8 @@ export const adminApi = { updateUserQuota: (userId: number, daily: number, monthly: number) => api.put(`/admin/users/${userId}/quota`, { - daily_seconds_limit: daily, - monthly_seconds_limit: monthly, + daily_generation_limit: daily, + monthly_generation_limit: monthly, }), updateUserStatus: (userId: number, isActive: boolean) => @@ -306,7 +306,7 @@ export const teamApi = { getMembers: () => api.get<{ results: TeamMember[] }>('/team/members'), - createMember: (data: { username: string; password: string; daily_seconds_limit?: number; monthly_seconds_limit?: number }) => + createMember: (data: { username: string; password: string; daily_generation_limit?: number; monthly_generation_limit?: number }) => api.post('/team/members/create', data), getMemberDetail: (memberId: number) => @@ -314,8 +314,8 @@ export const teamApi = { updateMemberQuota: (memberId: number, daily: number, monthly: number) => api.put(`/team/members/${memberId}/quota`, { - daily_seconds_limit: daily, - monthly_seconds_limit: monthly, + daily_generation_limit: daily, + monthly_generation_limit: monthly, }), updateMemberStatus: (memberId: number, isActive: boolean) => diff --git a/web/src/pages/AdminAssetsPage.tsx b/web/src/pages/AdminAssetsPage.tsx index 7ad045f..93a70b6 100644 --- a/web/src/pages/AdminAssetsPage.tsx +++ b/web/src/pages/AdminAssetsPage.tsx @@ -4,8 +4,8 @@ import { VideoDetailModal } from '../components/VideoDetailModal'; import type { AssetTeamSummary, AssetMemberSummary, AssetVideo, GenerationTask } from '../types'; import styles from './AdminAssetsPage.module.css'; -function formatSeconds(s: number) { - return `${s.toLocaleString()}s`; +function formatCost(val: number) { + return `¥${(val || 0).toFixed(2)}`; } function VideoThumbnail({ video, onClick }: { video: AssetVideo; onClick: () => void }) { @@ -136,8 +136,8 @@ export function AdminAssetsPage() {
{overview.total_videos}
-
总消耗
-
{formatSeconds(overview.total_seconds)}
+
总费用
+
{formatCost(overview.total_seconds)}
团队数
@@ -153,7 +153,7 @@ export function AdminAssetsPage() { {team.name}
{team.video_count} 个视频 - {formatSeconds(team.seconds_consumed)} + {formatCost(team.cost_consumed ?? team.seconds_consumed)}
{expandedTeam === team.id && ( @@ -169,7 +169,7 @@ export function AdminAssetsPage() {
{member.video_count} 个视频 - {formatSeconds(member.seconds_consumed)} + {formatCost(member.cost_consumed ?? member.seconds_consumed)}
{expandedMember === member.id && memberVideos[member.id] && ( @@ -212,7 +212,7 @@ export function AdminAssetsPage() { 无团队用户
{overview.no_team.video_count} 个视频 - {formatSeconds(overview.no_team.seconds_consumed)} + {formatCost(overview.no_team.seconds_consumed)}
diff --git a/web/src/pages/AuditLogsPage.tsx b/web/src/pages/AuditLogsPage.tsx index c71c1e6..431cf95 100644 --- a/web/src/pages/AuditLogsPage.tsx +++ b/web/src/pages/AuditLogsPage.tsx @@ -24,15 +24,23 @@ const ACTION_OPTIONS = [ ]; const FIELD_LABELS: Record = { - default_daily_seconds_limit: '每日限额', - default_monthly_seconds_limit: '每月限额', + default_daily_seconds_limit: '每日限额(秒)', + default_monthly_seconds_limit: '每月限额(秒)', + default_daily_generation_limit: '每日生成次数', + default_monthly_generation_limit: '每月生成次数', + base_token_price: '基础token单价', announcement: '公告内容', announcement_enabled: '公告开关', name: '名称', - monthly_seconds_limit: '月额度', + monthly_seconds_limit: '月额度(秒)', + monthly_spending_limit: '月消费限额', total_seconds_pool: '秒数池', + balance: '余额', + markup_percentage: '加价率', is_active: '状态', - daily_seconds_limit: '每日限额', + daily_seconds_limit: '每日限额(秒)', + daily_generation_limit: '每日生成次数', + monthly_generation_limit: '每月生成次数', username: '用户名', email: '邮箱', role: '角色', diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx index 815b0fa..b1480a3 100644 --- a/web/src/pages/DashboardPage.tsx +++ b/web/src/pages/DashboardPage.tsx @@ -45,10 +45,10 @@ export function DashboardPage() { if (!stats) return null; const statCards = [ - { label: '总团队数', value: stats.total_teams, change: null }, - { label: '总用户数', value: stats.total_users, change: null }, - { label: '今日消费秒数', value: stats.seconds_consumed_today, change: stats.today_change_percent }, - { label: '本月消费秒数', value: stats.seconds_consumed_this_month, change: stats.month_change_percent }, + { label: '总团队数', value: String(stats.total_teams), change: null }, + { label: '总用户数', value: String(stats.total_users), change: null }, + { label: '今日消费', value: `¥${(stats.cost_today || 0).toFixed(2)}`, change: stats.today_change_percent }, + { label: '本月消费', value: `¥${(stats.cost_this_month || 0).toFixed(2)}`, change: stats.month_change_percent }, ]; const trendOption: echarts.EChartsCoreOption = { @@ -59,7 +59,7 @@ export function DashboardPage() { textStyle: { color: '#f1f0ff', fontSize: 12 }, formatter: (params: unknown) => { const p = (params as { name: string; value: number }[])[0]; - return `${p.name}
消费: ${p.value}s`; + return `${p.name}
消费: ¥${p.value.toFixed(2)}`; }, }, grid: { left: 50, right: 20, top: 20, bottom: 60 }, @@ -77,7 +77,7 @@ export function DashboardPage() { dataZoom: [{ type: 'inside', start: 0, end: 100 }], series: [{ type: 'line', - data: stats.daily_trend.map((d) => d.seconds), + data: stats.daily_trend.map((d) => d.cost), smooth: true, lineStyle: { color: '#6c63ff', width: 2 }, areaStyle: { @@ -90,7 +90,7 @@ export function DashboardPage() { }], }; - const sortedTeams = [...(stats.top_teams || [])].sort((a, b) => a.seconds_consumed - b.seconds_consumed); + const sortedTeams = [...(stats.top_teams || [])].sort((a, b) => (a.cost_consumed || 0) - (b.cost_consumed || 0)); const teamBarOption: echarts.EChartsCoreOption = { tooltip: { trigger: 'axis', @@ -113,7 +113,7 @@ export function DashboardPage() { }, series: [{ type: 'bar', - data: sortedTeams.map((t) => t.seconds_consumed), + data: sortedTeams.map((t) => t.cost_consumed || 0), barWidth: 16, itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [ @@ -127,12 +127,12 @@ export function DashboardPage() { position: 'right', color: '#8b8ea8', fontSize: 11, - formatter: '{c}s', + formatter: (p: { value: number }) => `¥${p.value.toFixed(2)}`, }, }], }; - const sortedUsers = [...stats.top_users].sort((a, b) => a.seconds_consumed - b.seconds_consumed); + const sortedUsers = [...stats.top_users].sort((a, b) => (a.cost_consumed || 0) - (b.cost_consumed || 0)); const barOption: echarts.EChartsCoreOption = { tooltip: { trigger: 'axis', @@ -155,7 +155,7 @@ export function DashboardPage() { }, series: [{ type: 'bar', - data: sortedUsers.map((u) => u.seconds_consumed), + data: sortedUsers.map((u) => u.cost_consumed || 0), barWidth: 16, itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [ @@ -169,7 +169,7 @@ export function DashboardPage() { position: 'right', color: '#8b8ea8', fontSize: 11, - formatter: '{c}s', + formatter: (p: { value: number }) => `¥${p.value.toFixed(2)}`, }, }], }; @@ -182,7 +182,7 @@ export function DashboardPage() { {statCards.map((card) => (
{card.label}
-
{card.value.toLocaleString()}{card.label.includes('秒') ? 's' : ''}
+
{card.value}
{card.change !== null && (
= 0 ? styles.positive : styles.negative}`}> {card.change >= 0 ? '↑' : '↓'} @@ -194,7 +194,7 @@ export function DashboardPage() {
-

消费趋势(近30天)

+

消费趋势(近30天 · 元)

@@ -217,6 +217,83 @@ export function DashboardPage() {
+ + {/* Profit Section */} +
+
+
总收入
+
{`¥${(stats.total_revenue || 0).toFixed(2)}`}
+
+
+
总成本
+
{`¥${(stats.total_base_cost || 0).toFixed(2)}`}
+
+
+
总利润
+
{`¥${(stats.total_profit || 0).toFixed(2)}`}
+
+
+
利润率
+
{`${(stats.profit_margin || 0).toFixed(1)}%`}
+
+
+ + {(stats.team_profit_ranking || []).length > 0 && (() => { + const profitRanking = [...(stats.team_profit_ranking || [])].sort((a, b) => a.profit - b.profit); + const profitBarOption: 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 }, + formatter: (params: unknown) => { + const p = (params as { name: string; value: number; dataIndex: number }[])[0]; + const team = profitRanking[p.dataIndex]; + return `${p.name}
收入: ¥${team.revenue.toFixed(2)}
成本: ¥${team.base_cost.toFixed(2)}
利润: ¥${team.profit.toFixed(2)}
加价率: ${team.markup_percentage}%`; + }, + }, + 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: profitRanking.map((t) => t.name), + axisLabel: { color: '#8b8ea8', fontSize: 12 }, + axisLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.08)' } }, + }, + series: [{ + type: 'bar', + data: profitRanking.map((t) => t.profit), + barWidth: 16, + itemStyle: { + color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [ + { offset: 0, color: '#06d6a0' }, + { offset: 1, color: '#00b8e6' }, + ]), + borderRadius: [0, 4, 4, 0], + }, + label: { + show: true, + position: 'right', + color: '#8b8ea8', + fontSize: 11, + formatter: (p: { value: number }) => `¥${p.value.toFixed(2)}`, + }, + }], + }; + return ( +
+

团队利润排行

+
+ +
+
+ ); + })()} ); } diff --git a/web/src/pages/ProfilePage.tsx b/web/src/pages/ProfilePage.tsx index 1cb45c7..260488c 100644 --- a/web/src/pages/ProfilePage.tsx +++ b/web/src/pages/ProfilePage.tsx @@ -99,11 +99,13 @@ export function ProfilePage() { ); } - const dailyPercent = overview.daily_seconds_limit > 0 ? (overview.daily_seconds_used / overview.daily_seconds_limit) * 100 : 0; - const monthlyPercent = overview.monthly_seconds_limit > 0 ? (overview.monthly_seconds_used / overview.monthly_seconds_limit) * 100 : 0; + const dailyGenLimit = overview.daily_generation_limit || 0; + const dailyGenUsed = overview.daily_generation_used || 0; + const monthlyGenLimit = overview.monthly_generation_limit || 0; + const monthlyGenUsed = overview.monthly_generation_used || 0; - const totalRemaining = Math.max(0, overview.monthly_seconds_limit - overview.total_seconds_used); - const totalPercent = overview.monthly_seconds_limit > 0 ? (overview.total_seconds_used / overview.monthly_seconds_limit) * 100 : 0; + const dailyPercent = dailyGenLimit > 0 ? (dailyGenUsed / dailyGenLimit) * 100 : 0; + const monthlyPercent = monthlyGenLimit > 0 ? (monthlyGenUsed / monthlyGenLimit) * 100 : 0; const sparklineOption: echarts.EChartsCoreOption = { tooltip: { @@ -162,38 +164,37 @@ export function ProfilePage() {

消费概览

-
总额度
-
已消耗: {overview.total_seconds_used.toLocaleString()}s / {overview.monthly_seconds_limit.toLocaleString()}s
-
-
80 ? (totalPercent >= 100 ? 'var(--color-danger)' : 'var(--color-warning)') : 'var(--color-primary)', - }} /> -
-
剩余 {totalRemaining.toLocaleString()}s
-
-
-
今日额度
-
已用: {overview.daily_seconds_used.toLocaleString()}s / {overview.daily_seconds_limit.toLocaleString()}s
+
今日生成
+
{dailyGenUsed} / {dailyGenLimit === -1 ? '不限' : dailyGenLimit + '次'}
80 ? (dailyPercent >= 100 ? 'var(--color-danger)' : 'var(--color-warning)') : 'var(--color-primary)', }} />
-
{dailyPercent.toFixed(1)}%
+
今日消费 ¥{(overview.daily_spent || 0).toFixed(2)}
-
本月额度
-
已用: {overview.monthly_seconds_used.toLocaleString()}s / {overview.monthly_seconds_limit.toLocaleString()}s
+
本月生成
+
{monthlyGenUsed} / {monthlyGenLimit === -1 ? '不限' : monthlyGenLimit + '次'}
80 ? 'var(--color-warning)' : 'var(--color-primary)', }} />
-
{monthlyPercent.toFixed(1)}%
+
本月消费 ¥{(overview.monthly_spent || 0).toFixed(2)}
+ {overview.team && ( +
+
团队 — {overview.team.name}
+
余额: ¥{(overview.team.balance || 0).toFixed(2)}
+
+
+
+
可用余额 ¥{(overview.team.available_balance || 0).toFixed(2)}
+
+ )}
@@ -225,7 +226,7 @@ export function ProfilePage() {
{r.prompt || '-'}
- {r.seconds_consumed.toLocaleString()}s + ¥{(r.cost_amount || 0).toFixed(2)} {r.mode === 'universal' ? '全能参考' : '首尾帧'} {statusMap[r.status]}
diff --git a/web/src/pages/RecordsPage.tsx b/web/src/pages/RecordsPage.tsx index 6d6e537..e263670 100644 --- a/web/src/pages/RecordsPage.tsx +++ b/web/src/pages/RecordsPage.tsx @@ -58,14 +58,15 @@ export function RecordsPage() { team_id: teamFilter ? Number(teamFilter) : undefined, }); - const header = '时间,团队,用户名,消费秒数,提示词,生成模式,状态,失败原因\n'; + const header = '时间,团队,用户名,消费秒数,Tokens,费用(元),成本(元),利润(元),提示词,生成模式,状态,失败原因\n'; const rows = data.results.map((r) => { // Escape CSV fields to prevent injection 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.team_name || '-'}",${r.username},"${r.seconds_consumed}","${prompt}","${modeLabel}","${statusLabel}","${errorMsg}"`; + const profit = ((r.cost_amount || 0) - (r.base_cost_amount || 0)).toFixed(2); + return `${r.created_at},"${r.team_name || '-'}",${r.username},"${r.seconds_consumed}","${r.tokens_consumed || 0}","${(r.cost_amount || 0).toFixed(2)}","${(r.base_cost_amount || 0).toFixed(2)}","${profit}","${prompt}","${modeLabel}","${statusLabel}","${errorMsg}"`; }).join('\n'); const blob = new Blob(['\uFEFF' + header + rows], { type: 'text/csv;charset=utf-8;' }); @@ -120,6 +121,10 @@ export function RecordsPage() { 团队 用户名 消费秒数 + Tokens + 费用 + 成本 + 利润 视频描述 模式 状态 @@ -129,13 +134,13 @@ export function RecordsPage() { {loading ? ( Array.from({ length: 5 }).map((_, i) => ( - {Array.from({ length: 7 }).map((_, j) => ( + {Array.from({ length: 11 }).map((_, j) => (
))} )) ) : records.length === 0 ? ( - 暂无记录 + 暂无记录 ) : ( records.map((r) => ( @@ -143,6 +148,10 @@ export function RecordsPage() { {r.team_name || '-'} {r.username} {r.seconds_consumed.toLocaleString()}s + {(r.tokens_consumed || 0).toLocaleString()} + ¥{(r.cost_amount || 0).toFixed(2)} + ¥{(r.base_cost_amount || 0).toFixed(2)} + ¥{((r.cost_amount || 0) - (r.base_cost_amount || 0)).toFixed(2)} {r.prompt ? (r.prompt.slice(0, 40) + (r.prompt.length > 40 ? '...' : '')) : '-'} {r.mode === 'universal' ? '全能参考' : '首尾帧'} diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 1dd485e..5cc27b2 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -8,6 +8,9 @@ export function SettingsPage() { const [settings, setSettings] = useState({ default_daily_seconds_limit: 600, default_monthly_seconds_limit: 6000, + default_daily_generation_limit: 50, + default_monthly_generation_limit: 500, + base_token_price: 0, announcement: '', announcement_enabled: false, max_desktop_sessions: 1, @@ -104,22 +107,31 @@ export function SettingsPage() {

新注册用户将自动获得以下配额

- + setSettings({ ...settings, default_daily_seconds_limit: Number(e.target.value) })} + value={settings.default_daily_generation_limit} + onChange={(e) => setSettings({ ...settings, default_daily_generation_limit: Number(e.target.value) })} />
- + setSettings({ ...settings, default_monthly_seconds_limit: Number(e.target.value) })} + value={settings.default_monthly_generation_limit} + onChange={(e) => setSettings({ ...settings, default_monthly_generation_limit: Number(e.target.value) })} />
+
+ + setSettings({ ...settings, base_token_price: Number(e.target.value) })} + /> +
diff --git a/web/src/pages/TeamDashboardPage.tsx b/web/src/pages/TeamDashboardPage.tsx index d9cce9f..846d352 100644 --- a/web/src/pages/TeamDashboardPage.tsx +++ b/web/src/pages/TeamDashboardPage.tsx @@ -49,14 +49,14 @@ export function TeamDashboardPage() { if (!info || !stats) return null; - const formatLimit = (v: number) => v === -1 ? '不限' : v.toLocaleString() + 's'; + const fmtMoney = (val: number) => '¥' + (val || 0).toFixed(2); 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' }, + { label: '余额', value: fmtMoney(info.balance) }, + { label: '累计消费', value: fmtMoney(info.total_spent) }, + { label: '可用余额', value: fmtMoney(info.available_balance) }, + { label: '月消费限额', value: fmtMoney(info.monthly_spending_limit) }, + { label: '本月消费', value: fmtMoney(info.monthly_spent) }, ]; const trendOption: echarts.EChartsCoreOption = { @@ -67,7 +67,7 @@ export function TeamDashboardPage() { textStyle: { color: '#f1f0ff', fontSize: 12 }, formatter: (params: unknown) => { const p = (params as { name: string; value: number }[])[0]; - return `${p.name}
消费: ${p.value}s`; + return `${p.name}
消费: ¥${p.value.toFixed(2)}`; }, }, grid: { left: 50, right: 20, top: 20, bottom: 60 }, @@ -85,7 +85,7 @@ export function TeamDashboardPage() { dataZoom: [{ type: 'inside', start: 0, end: 100 }], series: [{ type: 'line', - data: stats.daily_trend.map((d) => d.seconds), + data: stats.daily_trend.map((d) => d.cost ?? d.seconds), smooth: true, lineStyle: { color: '#6c63ff', width: 2 }, areaStyle: { @@ -98,7 +98,7 @@ export function TeamDashboardPage() { }], }; - const sortedMembers = [...stats.member_consumption].sort((a, b) => a.seconds_consumed - b.seconds_consumed); + const sortedMembers = [...stats.member_consumption].sort((a, b) => (a.cost_consumed ?? a.seconds_consumed) - (b.cost_consumed ?? b.seconds_consumed)); const barOption: echarts.EChartsCoreOption = { tooltip: { trigger: 'axis', @@ -121,7 +121,7 @@ export function TeamDashboardPage() { }, series: [{ type: 'bar', - data: sortedMembers.map((m) => m.seconds_consumed), + data: sortedMembers.map((m) => m.cost_consumed ?? m.seconds_consumed), barWidth: 16, itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [ @@ -135,7 +135,7 @@ export function TeamDashboardPage() { position: 'right', color: '#8b8ea8', fontSize: 11, - formatter: '{c}s', + formatter: (p: { value: number }) => `¥${p.value.toFixed(2)}`, }, }], }; @@ -154,7 +154,7 @@ export function TeamDashboardPage() {
-

团队消费趋势(近30天)

+

团队消费趋势(近30天 · 元)

diff --git a/web/src/pages/TeamMembersPage.tsx b/web/src/pages/TeamMembersPage.tsx index ccad6f3..3e0a035 100644 --- a/web/src/pages/TeamMembersPage.tsx +++ b/web/src/pages/TeamMembersPage.tsx @@ -13,8 +13,8 @@ export function TeamMembersPage() { const [createOpen, setCreateOpen] = useState(false); const [newUsername, setNewUsername] = useState(''); const [newPassword, setNewPassword] = useState(''); - const [newDaily, setNewDaily] = useState('600'); - const [newMonthly, setNewMonthly] = useState('6000'); + const [newDaily, setNewDaily] = useState('50'); + const [newMonthly, setNewMonthly] = useState('500'); const [createError, setCreateError] = useState(''); // Confirm toggle @@ -39,7 +39,8 @@ export function TeamMembersPage() { useEffect(() => { fetchMembers(); }, [fetchMembers]); - const formatLimit = (v: number) => v === -1 ? '不限' : v.toLocaleString() + 's'; + const formatLimit = (v: number) => v === -1 ? '不限' : v + '次'; + const fmtMoney = (val: number) => '¥' + (val || 0).toFixed(2); const handleToggleStatus = async (member: TeamMember) => { try { @@ -53,8 +54,8 @@ export function TeamMembersPage() { const openEditModal = (member: TeamMember) => { setEditMember(member); - setEditDaily(String(member.daily_seconds_limit)); - setEditMonthly(String(member.monthly_seconds_limit)); + setEditDaily(String(member.daily_generation_limit ?? 50)); + setEditMonthly(String(member.monthly_generation_limit ?? 500)); }; const handleSaveQuota = async () => { @@ -71,7 +72,7 @@ export function TeamMembersPage() { const resetCreateForm = () => { setNewUsername(''); setNewPassword(''); - setNewDaily('600'); setNewMonthly('6000'); + setNewDaily('50'); setNewMonthly('500'); setCreateError(''); }; @@ -83,8 +84,8 @@ export function TeamMembersPage() { await teamApi.createMember({ username: newUsername.trim(), password: newPassword, - daily_seconds_limit: Number(newDaily), - monthly_seconds_limit: Number(newMonthly), + daily_generation_limit: Number(newDaily), + monthly_generation_limit: Number(newMonthly), }); showToast('成员创建成功'); setCreateOpen(false); @@ -116,10 +117,10 @@ export function TeamMembersPage() { 用户名 角色 状态 - 日限额(秒) - 月限额(秒) - 今日消费(秒) - 本月消费(秒) + 日生成上限 + 月生成上限 + 今日生成/消费 + 本月生成/消费 操作 @@ -150,10 +151,10 @@ export function TeamMembersPage() { {m.is_active ? '启用' : '禁用'} - {formatLimit(m.daily_seconds_limit)} - {formatLimit(m.monthly_seconds_limit)} - {m.seconds_today.toLocaleString()}s - {m.seconds_this_month.toLocaleString()}s + {formatLimit(m.daily_generation_limit)} + {formatLimit(m.monthly_generation_limit)} + {(m.generations_today || 0) + '次 / ' + fmtMoney(m.spent_today)} + {(m.generations_this_month || 0) + '次 / ' + fmtMoney(m.spent_this_month)}
@@ -190,11 +191,11 @@ export function TeamMembersPage() {

编辑配额 — {editMember.username}

- + setEditDaily(e.target.value)} />
- + setEditMonthly(e.target.value)} />
@@ -220,11 +221,11 @@ export function TeamMembersPage() {
- + setNewDaily(e.target.value)} />
- + setNewMonthly(e.target.value)} />
diff --git a/web/src/pages/TeamsPage.tsx b/web/src/pages/TeamsPage.tsx index 49696fb..f1d3a41 100644 --- a/web/src/pages/TeamsPage.tsx +++ b/web/src/pages/TeamsPage.tsx @@ -6,6 +6,10 @@ import { ConfirmModal } from '../components/ConfirmModal'; import { Select } from '../components/Select'; import styles from './TeamsPage.module.css'; +function fmtMoney(val: number): string { + return '¥' + (val || 0).toFixed(2); +} + function fmtSec(s: number): string { return Math.round(s).toLocaleString() + 's'; } @@ -17,14 +21,15 @@ export function TeamsPage() { // Create team modal const [createOpen, setCreateOpen] = useState(false); const [newName, setNewName] = useState(''); - const [newMonthlyLimit, setNewMonthlyLimit] = useState('36000'); - const [newDailyMemberLimit, setNewDailyMemberLimit] = useState('600'); + const [newMonthlyLimit, setNewMonthlyLimit] = useState('10000'); + const [newDailyMemberLimit, setNewDailyMemberLimit] = useState('50'); const [newExpectedRegions, setNewExpectedRegions] = useState(''); + const [newMarkup, setNewMarkup] = useState('30'); const [createError, setCreateError] = useState(''); // Top-up modal const [topupTeam, setTopupTeam] = useState(null); - const [topupSeconds, setTopupSeconds] = useState('3600'); + const [topupAmount, setTopupAmount] = useState('1000'); // Create admin modal const [adminTeam, setAdminTeam] = useState(null); @@ -79,8 +84,8 @@ export function TeamsPage() { const [anomalyConfigDraft, setAnomalyConfigDraft] = useState>({}); const resetCreateForm = () => { - setNewName(''); setNewMonthlyLimit('36000'); setNewDailyMemberLimit('600'); - setNewExpectedRegions(''); setCreateError(''); + setNewName(''); setNewMonthlyLimit('10000'); setNewDailyMemberLimit('50'); + setNewExpectedRegions(''); setNewMarkup('30'); setCreateError(''); }; const handleCreateTeam = async () => { @@ -90,9 +95,10 @@ export function TeamsPage() { try { await adminApi.createTeam({ name: newName.trim(), - monthly_seconds_limit: Number(newMonthlyLimit), + monthly_spending_limit: Number(newMonthlyLimit), daily_member_limit_default: Number(newDailyMemberLimit), expected_regions: newExpectedRegions.trim(), + markup_percentage: Number(newMarkup), }); showToast('团队创建成功'); setCreateOpen(false); @@ -106,11 +112,11 @@ export function TeamsPage() { const handleTopUp = async () => { if (!topupTeam) return; - const seconds = Number(topupSeconds); - if (!seconds || seconds <= 0) { showToast('请输入有效的秒数'); return; } + const amount = Number(topupAmount); + if (!amount || amount <= 0) { showToast('请输入有效的金额'); return; } try { - await adminApi.topUpTeam(topupTeam.id, seconds); - showToast(`已为 ${topupTeam.name} 充值 ${fmtSec(seconds)} 秒`); + await adminApi.topUpTeam(topupTeam.id, amount); + showToast(`已为 ${topupTeam.name} 充值 ${fmtMoney(amount)}`); setTopupTeam(null); fetchTeams(); } catch { @@ -120,11 +126,11 @@ export function TeamsPage() { const handleSetPool = async () => { if (!detailTeam) return; - const newPool = Number(editPoolValue); - if (isNaN(newPool) || newPool < 0) { setEditPoolError('请输入有效的非负数'); return; } + const newBalance = Number(editPoolValue); + if (isNaN(newBalance) || newBalance < 0) { setEditPoolError('请输入有效的非负数'); return; } try { - await adminApi.setTeamPool(detailTeam.id, newPool); - showToast(`已将 ${detailTeam.name} 总秒数池修改为 ${fmtSec(newPool)}`); + await adminApi.setTeamPool(detailTeam.id, newBalance); + showToast(`已将 ${detailTeam.name} 余额修改为 ${fmtMoney(newBalance)}`); setEditPoolOpen(false); // Refresh detail const { data } = await adminApi.getTeamDetail(detailTeam.id); @@ -222,10 +228,10 @@ export function TeamsPage() { 团队名称 - 总秒数池 - 已消耗 - 剩余 - 月限额 + 余额 + 累计消费 + 可用余额 + 月消费限额 本月消费 成员数 状态 @@ -251,11 +257,11 @@ export function TeamsPage() { {t.name} - {fmtSec(t.total_seconds_pool)} - {fmtSec(t.total_seconds_used)} - {fmtSec(t.remaining_seconds)} - {fmtSec(t.monthly_seconds_limit)} - {fmtSec(t.monthly_seconds_used)} + {fmtMoney(t.balance)} + {fmtMoney(t.total_spent)} + {fmtMoney(t.available_balance)} + {fmtMoney(t.monthly_spending_limit)} + {fmtMoney(t.monthly_spent)} {t.member_count} @@ -269,7 +275,7 @@ export function TeamsPage() {
- +
- 已消耗 - {fmtSec(detailTeam.total_seconds_used)} + 累计消费 + {fmtMoney(detailTeam.total_spent)}
- 剩余 - {fmtSec(detailTeam.remaining_seconds)} + 可用余额 + {fmtMoney(detailTeam.available_balance)}
- 月限额 - {fmtSec(detailTeam.monthly_seconds_limit)} + 月消费限额 + {fmtMoney(detailTeam.monthly_spending_limit)}
本月消费 - {fmtSec(detailTeam.monthly_seconds_used)} + {fmtMoney(detailTeam.monthly_spent)} +
+
+ 加价率 + {(detailTeam.markup_percentage || 0)}%
成员数 {detailTeam.member_count}
-
- 成员日限额(默认) - {fmtSec(detailTeam.daily_member_limit_default)} -
创建时间 {new Date(detailTeam.created_at).toLocaleDateString('zh-CN')} @@ -631,9 +641,9 @@ export function TeamsPage() { 邮箱 角色 状态 - 日限额 - 今日消费 - 本月消费 + 日生成上限 + 今日生成/消费 + 本月生成/消费 @@ -656,9 +666,9 @@ export function TeamsPage() { )} - {fmtSec(m.daily_seconds_limit)} - {fmtSec(m.seconds_today)} - {fmtSec(m.seconds_this_month)} + {m.daily_generation_limit === -1 ? '不限' : (m.daily_generation_limit || 0) + '次'} + {(m.generations_today || 0) + '次 / ' + fmtMoney(m.spent_today)} + {(m.generations_this_month || 0) + '次 / ' + fmtMoney(m.spent_this_month)} ))} @@ -707,13 +717,13 @@ export function TeamsPage() { {editPoolOpen && detailTeam && (
{ if (e.target === e.currentTarget) setEditPoolOpen(false); }}>
-

修改总秒数池 — {detailTeam.name}

+

修改余额 — {detailTeam.name}

{editPoolError &&
{editPoolError}
}
- - setEditPoolValue(e.target.value)} placeholder="输入总秒数" /> + + setEditPoolValue(e.target.value)} placeholder="输入余额" />
- 当前: {fmtSec(detailTeam.total_seconds_pool)} | 已消耗: {fmtSec(detailTeam.total_seconds_used)} | 修改后剩余: {fmtSec(Math.max(0, (Number(editPoolValue) || 0) - detailTeam.total_seconds_used))} + 当前余额: {fmtMoney(detailTeam.balance)} | 累计消费: {fmtMoney(detailTeam.total_spent)}
diff --git a/web/src/pages/UsersPage.tsx b/web/src/pages/UsersPage.tsx index a1556f9..b5c62cf 100644 --- a/web/src/pages/UsersPage.tsx +++ b/web/src/pages/UsersPage.tsx @@ -39,8 +39,8 @@ export function UsersPage() { const [newUsername, setNewUsername] = useState(''); const [newEmail, setNewEmail] = useState(''); const [newPassword, setNewPassword] = useState(''); - const [newDaily, setNewDaily] = useState('600'); - const [newMonthly, setNewMonthly] = useState('6000'); + const [newDaily, setNewDaily] = useState('50'); + const [newMonthly, setNewMonthly] = useState('500'); const [newIsStaff, setNewIsStaff] = useState(false); const [createError, setCreateError] = useState(''); @@ -84,8 +84,8 @@ export function UsersPage() { const openEditModal = (user: AdminUser) => { setEditUser(user); - setEditDaily(String(user.daily_seconds_limit)); - setEditMonthly(String(user.monthly_seconds_limit)); + setEditDaily(String(user.daily_generation_limit ?? 50)); + setEditMonthly(String(user.monthly_generation_limit ?? 500)); }; const handleSaveQuota = async () => { @@ -112,7 +112,7 @@ export function UsersPage() { const resetCreateForm = () => { setNewUsername(''); setNewEmail(''); setNewPassword(''); - setNewDaily('600'); setNewMonthly('6000'); setNewIsStaff(false); + setNewDaily('50'); setNewMonthly('500'); setNewIsStaff(false); setCreateError(''); }; @@ -126,8 +126,8 @@ export function UsersPage() { username: newUsername.trim(), email: newEmail.trim(), password: newPassword, - daily_seconds_limit: Number(newDaily), - monthly_seconds_limit: Number(newMonthly), + daily_generation_limit: Number(newDaily), + monthly_generation_limit: Number(newMonthly), is_staff: newIsStaff, }); showToast('用户创建成功'); @@ -203,10 +203,10 @@ export function UsersPage() { 邮箱 注册时间 状态 - 日限额(秒) - 月限额(秒) - 今日消费(秒) - 本月消费(秒) + 日生成上限 + 月生成上限 + 今日生成/消费 + 本月生成/消费 操作 @@ -242,10 +242,10 @@ export function UsersPage() { )} - {u.daily_seconds_limit === -1 ? '不限' : u.daily_seconds_limit.toLocaleString() + 's'} - {u.monthly_seconds_limit === -1 ? '不限' : u.monthly_seconds_limit.toLocaleString() + 's'} - {u.seconds_today.toLocaleString()}s - {u.seconds_this_month.toLocaleString()}s + {(u.daily_generation_limit ?? -1) === -1 ? '不限' : u.daily_generation_limit + '次'} + {(u.monthly_generation_limit ?? -1) === -1 ? '不限' : u.monthly_generation_limit + '次'} + {(u.generations_today || 0) + '次 / ¥' + (u.spent_today || 0).toFixed(2)} + {(u.generations_this_month || 0) + '次 / ¥' + (u.spent_this_month || 0).toFixed(2)}
@@ -305,11 +305,11 @@ export function UsersPage() {

编辑配额 — {editUser.username}

- + setEditDaily(e.target.value)} />
- + setEditMonthly(e.target.value)} />
@@ -339,11 +339,11 @@ export function UsersPage() {
- + setNewDaily(e.target.value)} />
- + setNewMonthly(e.target.value)} />
@@ -415,16 +415,24 @@ export function UsersPage() { {new Date(detailUser.date_joined).toLocaleString('zh-CN')}
- 日限额/今日消费 - {detailUser.seconds_today.toLocaleString()}s / {detailUser.daily_seconds_limit === -1 ? '不限' : detailUser.daily_seconds_limit.toLocaleString() + 's'} + 今日生成/上限 + {(detailUser.generations_today || 0)}次 / {(detailUser.daily_generation_limit ?? -1) === -1 ? '不限' : detailUser.daily_generation_limit + '次'}
- 月限额/本月消费 - {detailUser.seconds_this_month.toLocaleString()}s / {detailUser.monthly_seconds_limit === -1 ? '不限' : detailUser.monthly_seconds_limit.toLocaleString() + 's'} + 本月生成/上限 + {(detailUser.generations_this_month || 0)}次 / {(detailUser.monthly_generation_limit ?? -1) === -1 ? '不限' : detailUser.monthly_generation_limit + '次'} +
+
+ 今日消费 + ¥{(detailUser.spent_today || 0).toFixed(2)} +
+
+ 本月消费 + ¥{(detailUser.spent_this_month || 0).toFixed(2)}
累计消费 - {detailUser.seconds_total.toLocaleString()}s + ¥{(detailUser.total_spent || 0).toFixed(2)}
@@ -437,7 +445,7 @@ export function UsersPage() {
{new Date(r.created_at).toLocaleString('zh-CN')}
- {r.seconds_consumed.toLocaleString()}s + ¥{(r.cost_amount || 0).toFixed(2)} {r.mode === 'universal' ? '全能参考' : '首尾帧'} { { queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' }[r.status] diff --git a/web/src/store/generation.ts b/web/src/store/generation.ts index 77db9ad..5918af4 100644 --- a/web/src/store/generation.ts +++ b/web/src/store/generation.ts @@ -79,6 +79,8 @@ function backendToFrontend(bt: BackendTask): GenerationTask { resultUrl: bt.result_url || undefined, errorMessage: mapErrorMessage(bt.error_message), createdAt: new Date(bt.created_at).getTime(), + tokensConsumed: bt.tokens_consumed || 0, + costAmount: bt.cost_amount || 0, }; } diff --git a/web/src/store/inputBar.ts b/web/src/store/inputBar.ts index ada92ec..cd9c0eb 100644 --- a/web/src/store/inputBar.ts +++ b/web/src/store/inputBar.ts @@ -214,7 +214,7 @@ export const useInputBarStore = create((set, get) => ({ mode, prevReferences: state.references, references: [], - aspectRatio: 'adaptive', + aspectRatio: '16:9', duration: 5, }); } else { diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 1e5c08f..9985733 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -1,6 +1,6 @@ export type CreationMode = 'universal' | 'keyframe'; 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' | 'adaptive'; +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'; @@ -46,6 +46,8 @@ export interface GenerationTask { resultUrl?: string; errorMessage?: string; createdAt: number; + tokensConsumed?: number; + costAmount?: number; } export interface BackendTask { @@ -58,6 +60,9 @@ export interface BackendTask { aspect_ratio: string; duration: number; seconds_consumed: number; + tokens_consumed: number; + cost_amount: number; + base_cost_amount: number; status: 'queued' | 'processing' | 'completed' | 'failed'; result_url: string; error_message: string; @@ -85,6 +90,13 @@ export interface TeamInfo { remaining_seconds: number; monthly_seconds_limit: number; monthly_seconds_used: number; + balance: number; + total_spent: number; + available_balance: number; + monthly_spending_limit: number; + monthly_spent: number; + frozen_amount: number; + token_price: number; is_active: boolean; } @@ -94,6 +106,10 @@ export interface Quota { daily_seconds_used: number; monthly_seconds_limit: number; monthly_seconds_used: number; + daily_generation_limit: number; + daily_generation_used: number; + monthly_generation_limit: number; + monthly_generation_used: number; } export interface AuthTokens { @@ -108,11 +124,20 @@ export interface AdminStats { new_users_today: number; seconds_consumed_today: number; seconds_consumed_this_month: number; + cost_today: number; + cost_this_month: number; + base_cost_today: number; + base_cost_this_month: number; + total_revenue: number; + total_base_cost: number; + total_profit: number; + profit_margin: number; today_change_percent: number; month_change_percent: number; - daily_trend: { date: string; seconds: number }[]; - top_users: { user_id: number; username: string; seconds_consumed: number }[]; - top_teams: { team_id: number; name: string; seconds_consumed: number }[]; + daily_trend: { date: string; seconds: number; cost: number; base_cost: number }[]; + top_users: { user_id: number; username: string; seconds_consumed: number; cost_consumed: number }[]; + top_teams: { team_id: number; name: string; seconds_consumed: number; cost_consumed: number }[]; + team_profit_ranking: { team_id: number; name: string; revenue: number; base_cost: number; profit: number; markup_percentage: number }[]; } export interface AdminUser { @@ -130,10 +155,17 @@ export interface AdminUser { monthly_seconds_limit: number; seconds_today: number; seconds_this_month: number; + daily_generation_limit: number; + monthly_generation_limit: number; + generations_today: number; + generations_this_month: number; + spent_today: number; + spent_this_month: number; } export interface AdminUserDetail extends AdminUser { seconds_total: number; + total_spent: number; recent_records: AdminRecord[]; } @@ -144,6 +176,9 @@ export interface AdminRecord { username?: string; team_name?: string; seconds_consumed: number; + tokens_consumed: number; + cost_amount: number; + base_cost_amount: number; prompt: string; mode: CreationMode; model: ModelOption; @@ -155,6 +190,9 @@ export interface AdminRecord { export interface SystemSettings { default_daily_seconds_limit: number; default_monthly_seconds_limit: number; + default_daily_generation_limit: number; + default_monthly_generation_limit: number; + base_token_price: number; announcement: string; announcement_enabled: boolean; max_desktop_sessions: number; @@ -184,6 +222,13 @@ export interface ProfileOverview { monthly_seconds_limit: number; monthly_seconds_used: number; total_seconds_used: number; + daily_generation_limit: number; + daily_generation_used: number; + monthly_generation_limit: number; + monthly_generation_used: number; + daily_spent: number; + monthly_spent: number; + total_spent_amount: number; daily_trend: { date: string; seconds: number }[]; team?: { name: string; @@ -192,6 +237,12 @@ export interface ProfileOverview { remaining_seconds: number; monthly_seconds_limit: number; monthly_seconds_used: number; + balance: number; + total_spent: number; + available_balance: number; + monthly_spending_limit: number; + monthly_spent: number; + frozen_amount: number; }; } @@ -211,6 +262,13 @@ export interface Team { remaining_seconds: number; monthly_seconds_limit: number; monthly_seconds_used: number; + balance: number; + total_spent: number; + available_balance: number; + monthly_spending_limit: number; + monthly_spent: number; + frozen_amount: number; + markup_percentage: number; daily_member_limit_default: number; member_count: number; is_active: boolean; @@ -250,6 +308,12 @@ export interface TeamMember { monthly_seconds_limit: number; seconds_today: number; seconds_this_month: number; + daily_generation_limit: number; + monthly_generation_limit: number; + generations_today: number; + generations_this_month: number; + spent_today: number; + spent_this_month: number; date_joined: string; } @@ -273,8 +337,8 @@ export interface LoginAnomaly { } export interface TeamStats { - daily_trend: { date: string; seconds: number }[]; - member_consumption: { user_id: number; username: string; seconds_consumed: number }[]; + daily_trend: { date: string; seconds: number; cost?: number }[]; + member_consumption: { user_id: number; username: string; seconds_consumed: number; cost_consumed?: number }[]; } // Asset management types @@ -283,6 +347,7 @@ export interface AssetTeamSummary { name: string; video_count: number; seconds_consumed: number; + cost_consumed?: number; member_count: number; is_active: boolean; } @@ -293,6 +358,7 @@ export interface AssetMemberSummary { is_team_admin: boolean; video_count: number; seconds_consumed: number; + cost_consumed?: number; } export interface AssetVideo { @@ -302,6 +368,7 @@ export interface AssetVideo { result_url: string; duration: number; seconds_consumed: number; + cost_amount?: number; aspect_ratio: string; created_at: string; }