feat: v0.10.0 计费体系重构 — 秒数→金额+次数,token追踪,利润分析
## 计费体系 - 团队额度从秒数改为金额(余额/冻结/月消费上限) - 用户限额从秒数改为次数(每日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) <noreply@anthropic.com>
This commit is contained in:
parent
277de4651f
commit
9259988094
53
backend/apps/accounts/migrations/0009_billing_system_v010.py
Normal file
53
backend/apps/accounts/migrations/0009_billing_system_v010.py
Normal file
@ -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='每月生成次数上限'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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),
|
||||||
|
]
|
||||||
@ -11,6 +11,13 @@ class Team(models.Model):
|
|||||||
total_seconds_used = models.FloatField(default=0, verbose_name='已消耗总秒数')
|
total_seconds_used = models.FloatField(default=0, verbose_name='已消耗总秒数')
|
||||||
monthly_seconds_limit = models.IntegerField(default=6000, verbose_name='每月消费上限(秒)')
|
monthly_seconds_limit = models.IntegerField(default=6000, verbose_name='每月消费上限(秒)')
|
||||||
daily_member_limit_default = models.IntegerField(default=600, 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='启用状态')
|
is_active = models.BooleanField(default=True, verbose_name='启用状态')
|
||||||
expected_regions = models.CharField(max_length=500, blank=True, default='', verbose_name='预期登录城市(逗号分隔)')
|
expected_regions = models.CharField(max_length=500, blank=True, default='', verbose_name='预期登录城市(逗号分隔)')
|
||||||
disabled_by = models.CharField(max_length=10, blank=True, default='', verbose_name='禁用来源')
|
disabled_by = models.CharField(max_length=10, blank=True, default='', verbose_name='禁用来源')
|
||||||
@ -28,6 +35,10 @@ class Team(models.Model):
|
|||||||
def remaining_seconds(self):
|
def remaining_seconds(self):
|
||||||
return self.total_seconds_pool - self.total_seconds_used
|
return self.total_seconds_pool - self.total_seconds_used
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available_balance(self):
|
||||||
|
return self.balance - self.frozen_amount
|
||||||
|
|
||||||
|
|
||||||
class User(AbstractUser):
|
class User(AbstractUser):
|
||||||
"""Extended user model — Phase 5: team-based quota."""
|
"""Extended user model — Phase 5: team-based quota."""
|
||||||
@ -41,6 +52,9 @@ class User(AbstractUser):
|
|||||||
is_team_admin = models.BooleanField(default=False, verbose_name='团队管理员')
|
is_team_admin = models.BooleanField(default=False, verbose_name='团队管理员')
|
||||||
daily_seconds_limit = models.IntegerField(default=600, verbose_name='每日秒数上限')
|
daily_seconds_limit = models.IntegerField(default=600, verbose_name='每日秒数上限')
|
||||||
monthly_seconds_limit = models.IntegerField(default=6000, 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='必须修改密码')
|
must_change_password = models.BooleanField(default=True, verbose_name='必须修改密码')
|
||||||
disabled_by = models.CharField(max_length=10, blank=True, default='', verbose_name='禁用来源')
|
disabled_by = models.CharField(max_length=10, blank=True, default='', verbose_name='禁用来源')
|
||||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
||||||
|
|||||||
@ -5,7 +5,7 @@ from rest_framework.response import Response
|
|||||||
from rest_framework.throttling import ScopedRateThrottle
|
from rest_framework.throttling import ScopedRateThrottle
|
||||||
from django.contrib.auth import authenticate, get_user_model
|
from django.contrib.auth import authenticate, get_user_model
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.db.models import Sum
|
from django.db.models import Sum, Count
|
||||||
|
|
||||||
from .serializers import UserSerializer
|
from .serializers import UserSerializer
|
||||||
from .models import ActiveSession, LoginRecord, get_client_ip, parse_device_type
|
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
|
created_at__date__gte=first_of_month
|
||||||
).aggregate(total=Sum('seconds_consumed'))['total'] or 0
|
).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 = UserSerializer(user).data
|
||||||
data['quota'] = {
|
data['quota'] = {
|
||||||
'daily_seconds_limit': user.daily_seconds_limit,
|
'daily_seconds_limit': user.daily_seconds_limit,
|
||||||
'daily_seconds_used': daily_seconds_used,
|
'daily_seconds_used': daily_seconds_used,
|
||||||
'monthly_seconds_limit': user.monthly_seconds_limit,
|
'monthly_seconds_limit': user.monthly_seconds_limit,
|
||||||
'monthly_seconds_used': monthly_seconds_used,
|
'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 info
|
||||||
team = user.team
|
team = user.team
|
||||||
if team:
|
if team:
|
||||||
# Team monthly consumption
|
# Team monthly consumption
|
||||||
from apps.generation.models import GenerationRecord
|
from apps.generation.models import GenerationRecord, QuotaConfig
|
||||||
team_monthly_used = GenerationRecord.objects.filter(
|
team_monthly_used = GenerationRecord.objects.filter(
|
||||||
user__team=team,
|
user__team=team,
|
||||||
created_at__date__gte=first_of_month,
|
created_at__date__gte=first_of_month,
|
||||||
).aggregate(total=Sum('seconds_consumed'))['total'] or 0
|
).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'] = {
|
data['team'] = {
|
||||||
'id': team.id,
|
'id': team.id,
|
||||||
'name': team.name,
|
'name': team.name,
|
||||||
@ -196,6 +217,13 @@ def me_view(request):
|
|||||||
'remaining_seconds': team.remaining_seconds,
|
'remaining_seconds': team.remaining_seconds,
|
||||||
'monthly_seconds_limit': team.monthly_seconds_limit,
|
'monthly_seconds_limit': team.monthly_seconds_limit,
|
||||||
'monthly_seconds_used': team_monthly_used,
|
'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,
|
'is_active': team.is_active,
|
||||||
}
|
}
|
||||||
data['team_disabled'] = not team.is_active
|
data['team_disabled'] = not team.is_active
|
||||||
|
|||||||
@ -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='默认每月生成次数'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -34,6 +34,12 @@ class GenerationRecord(models.Model):
|
|||||||
aspect_ratio = models.CharField(max_length=10, verbose_name='宽高比')
|
aspect_ratio = models.CharField(max_length=10, verbose_name='宽高比')
|
||||||
duration = models.IntegerField(verbose_name='视频时长(秒)')
|
duration = models.IntegerField(verbose_name='视频时长(秒)')
|
||||||
seconds_consumed = models.FloatField(default=0, 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='状态')
|
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')
|
result_url = models.CharField(max_length=1000, blank=True, default='', verbose_name='生成结果URL')
|
||||||
error_message = models.TextField(blank=True, default='', verbose_name='错误信息')
|
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='飞书告警接收人手机号')
|
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='短信告警手机号(预留)')
|
sms_alert_mobiles = models.CharField(max_length=500, blank=True, default='', verbose_name='短信告警手机号(预留)')
|
||||||
alert_cooldown_seconds = models.IntegerField(default=1800, 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)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@ -11,8 +11,8 @@ class VideoGenerateSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
|
|
||||||
class QuotaUpdateSerializer(serializers.Serializer):
|
class QuotaUpdateSerializer(serializers.Serializer):
|
||||||
daily_seconds_limit = serializers.IntegerField(min_value=-1)
|
daily_generation_limit = serializers.IntegerField(min_value=-1)
|
||||||
monthly_seconds_limit = serializers.IntegerField(min_value=-1)
|
monthly_generation_limit = serializers.IntegerField(min_value=-1)
|
||||||
|
|
||||||
|
|
||||||
class UserStatusSerializer(serializers.Serializer):
|
class UserStatusSerializer(serializers.Serializer):
|
||||||
@ -25,12 +25,17 @@ class AdminCreateUserSerializer(serializers.Serializer):
|
|||||||
password = serializers.CharField(min_length=6)
|
password = serializers.CharField(min_length=6)
|
||||||
daily_seconds_limit = serializers.IntegerField(min_value=-1, required=False, default=600)
|
daily_seconds_limit = serializers.IntegerField(min_value=-1, required=False, default=600)
|
||||||
monthly_seconds_limit = serializers.IntegerField(min_value=-1, required=False, default=6000)
|
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)
|
is_staff = serializers.BooleanField(required=False, default=False)
|
||||||
|
|
||||||
|
|
||||||
class SystemSettingsSerializer(serializers.Serializer):
|
class SystemSettingsSerializer(serializers.Serializer):
|
||||||
default_daily_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)
|
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 = serializers.CharField(required=False, allow_blank=True, default='')
|
||||||
announcement_enabled = serializers.BooleanField(required=False, default=False)
|
announcement_enabled = serializers.BooleanField(required=False, default=False)
|
||||||
max_desktop_sessions = serializers.IntegerField(min_value=1, required=False, default=1)
|
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)
|
name = serializers.CharField(max_length=100)
|
||||||
monthly_seconds_limit = serializers.IntegerField(min_value=0, required=False, default=6000)
|
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)
|
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)
|
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)
|
name = serializers.CharField(max_length=100, required=False)
|
||||||
monthly_seconds_limit = serializers.IntegerField(min_value=0, required=False)
|
monthly_seconds_limit = serializers.IntegerField(min_value=0, required=False)
|
||||||
daily_member_limit_default = 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)
|
is_active = serializers.BooleanField(required=False)
|
||||||
expected_regions = serializers.CharField(max_length=500, required=False, allow_blank=True)
|
expected_regions = serializers.CharField(max_length=500, required=False, allow_blank=True)
|
||||||
|
|
||||||
@ -87,7 +98,7 @@ class TeamAnomalyConfigSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
|
|
||||||
class TeamTopUpSerializer(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):
|
class TeamAdminCreateSerializer(serializers.Serializer):
|
||||||
@ -103,8 +114,10 @@ class TeamMemberCreateSerializer(serializers.Serializer):
|
|||||||
password = serializers.CharField(min_length=6)
|
password = serializers.CharField(min_length=6)
|
||||||
daily_seconds_limit = serializers.IntegerField(min_value=-1, required=False)
|
daily_seconds_limit = serializers.IntegerField(min_value=-1, required=False)
|
||||||
monthly_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):
|
class MemberQuotaSerializer(serializers.Serializer):
|
||||||
daily_seconds_limit = serializers.IntegerField(min_value=-1)
|
daily_generation_limit = serializers.IntegerField(min_value=-1)
|
||||||
monthly_seconds_limit = serializers.IntegerField(min_value=-1)
|
monthly_generation_limit = serializers.IntegerField(min_value=-1)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
69
backend/utils/billing.py
Normal file
69
backend/utils/billing.py
Normal file
@ -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)
|
||||||
@ -237,7 +237,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
|
|||||||
{task.model === 'seedance_2.0' ? 'AirDrama' : 'AirDrama Fast'}
|
{task.model === 'seedance_2.0' ? 'AirDrama' : 'AirDrama Fast'}
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.label}>{task.duration}s</span>
|
<span className={styles.label}>{task.duration}s</span>
|
||||||
<span className={styles.label}>{task.aspectRatio === 'adaptive' ? '自适应' : task.aspectRatio}</span>
|
<span className={styles.label}>{task.aspectRatio}</span>
|
||||||
<span
|
<span
|
||||||
ref={detailLinkRef}
|
ref={detailLinkRef}
|
||||||
className={styles.detailLink}
|
className={styles.detailLink}
|
||||||
@ -258,7 +258,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
|
|||||||
{detailHover && (
|
{detailHover && (
|
||||||
<div className={styles.detailTooltip} style={{ top: detailPos.top, right: detailPos.right }}>
|
<div className={styles.detailTooltip} style={{ top: detailPos.top, right: detailPos.right }}>
|
||||||
<div className={styles.detailRow}>
|
<div className={styles.detailRow}>
|
||||||
<span>视频比例</span><span>{task.aspectRatio === 'adaptive' ? '自适应' : task.aspectRatio}</span>
|
<span>视频比例</span><span>{task.aspectRatio}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.detailRow}>
|
<div className={styles.detailRow}>
|
||||||
<span>时长</span><span>{task.duration}s</span>
|
<span>时长</span><span>{task.duration}s</span>
|
||||||
|
|||||||
17
web/src/components/ImageLightbox.module.css
Normal file
17
web/src/components/ImageLightbox.module.css
Normal file
@ -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;
|
||||||
|
}
|
||||||
24
web/src/components/ImageLightbox.tsx
Normal file
24
web/src/components/ImageLightbox.tsx
Normal file
@ -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 (
|
||||||
|
<div className={styles.overlay} onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||||
|
<img src={src} alt="" className={styles.image} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -38,8 +38,14 @@ export function LoginModal({ isOpen, onClose, onSuccess }: Props) {
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.overlay} onClick={onClose}>
|
<div className={styles.overlay}
|
||||||
<div className={styles.panel} onClick={(e) => e.stopPropagation()}>
|
onMouseDown={(e) => { 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 = '';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={styles.panel}>
|
||||||
<button className={styles.closeBtn} onClick={onClose} aria-label="关闭">
|
<button className={styles.closeBtn} onClick={onClose} aria-label="关闭">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<path d="M18 6L6 18M6 6l12 12" />
|
<path d="M18 6L6 18M6 6l12 12" />
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useCallback } from 'react';
|
import { useEffect, useCallback, useMemo } from 'react';
|
||||||
import { useInputBarStore } from '../store/inputBar';
|
import { useInputBarStore } from '../store/inputBar';
|
||||||
import { useGenerationStore } from '../store/generation';
|
import { useGenerationStore } from '../store/generation';
|
||||||
|
import { useAuthStore } from '../store/auth';
|
||||||
import { Dropdown } from './Dropdown';
|
import { Dropdown } from './Dropdown';
|
||||||
import type { CreationMode, AspectRatio, Duration, GenerationType, ModelOption } from '../types';
|
import type { CreationMode, AspectRatio, Duration, GenerationType, ModelOption } from '../types';
|
||||||
import styles from './Toolbar.module.css';
|
import styles from './Toolbar.module.css';
|
||||||
@ -89,7 +90,6 @@ const ratioItems = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const keyframeRatioItems = [
|
const keyframeRatioItems = [
|
||||||
{ label: '自适应', value: 'adaptive' as AspectRatio },
|
|
||||||
...ratioItems,
|
...ratioItems,
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -98,6 +98,11 @@ const durationItems = Array.from({ length: 12 }, (_, i) => {
|
|||||||
return { label: `${v}s`, value: String(v) };
|
return { label: `${v}s`, value: String(v) };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const RESOLUTION_MAP: Record<string, [number, number]> = {
|
||||||
|
'16:9': [1280, 720], '9:16': [720, 1280], '4:3': [1112, 834],
|
||||||
|
'1:1': [960, 960], '3:4': [834, 1112], '21:9': [1470, 630],
|
||||||
|
};
|
||||||
|
|
||||||
const modeLabels: Record<CreationMode, string> = {
|
const modeLabels: Record<CreationMode, string> = {
|
||||||
universal: '全能参考',
|
universal: '全能参考',
|
||||||
keyframe: '首尾帧',
|
keyframe: '首尾帧',
|
||||||
@ -118,9 +123,19 @@ export function Toolbar() {
|
|||||||
const triggerInsertAt = useInputBarStore((s) => s.triggerInsertAt);
|
const triggerInsertAt = useInputBarStore((s) => s.triggerInsertAt);
|
||||||
|
|
||||||
const isKeyframe = mode === 'keyframe';
|
const isKeyframe = mode === 'keyframe';
|
||||||
|
const tokenPrice = useAuthStore((s) => s.team?.token_price) || 0;
|
||||||
|
|
||||||
const addTask = useGenerationStore((s) => s.addTask);
|
const addTask = useGenerationStore((s) => s.addTask);
|
||||||
|
|
||||||
|
const estimatedTokens = useMemo(() => {
|
||||||
|
const res = RESOLUTION_MAP[aspectRatio] || [1280, 720];
|
||||||
|
return Math.round((res[0] * res[1] * 24 * duration) / 1024);
|
||||||
|
}, [aspectRatio, duration]);
|
||||||
|
|
||||||
|
const estimatedCost = useMemo(() => {
|
||||||
|
return (estimatedTokens * tokenPrice / 1000000).toFixed(2);
|
||||||
|
}, [estimatedTokens, tokenPrice]);
|
||||||
|
|
||||||
const handleSend = useCallback(() => {
|
const handleSend = useCallback(() => {
|
||||||
if (!isSubmittable) return;
|
if (!isSubmittable) return;
|
||||||
addTask();
|
addTask();
|
||||||
@ -188,7 +203,7 @@ export function Toolbar() {
|
|||||||
trigger={
|
trigger={
|
||||||
<button className={styles.btn}>
|
<button className={styles.btn}>
|
||||||
<MonitorIcon />
|
<MonitorIcon />
|
||||||
<span className={styles.label}>{aspectRatio === 'adaptive' ? '自适应' : aspectRatio}</span>
|
<span className={styles.label}>{aspectRatio}</span>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -214,6 +229,16 @@ export function Toolbar() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Estimated cost */}
|
||||||
|
{tokenPrice > 0 && (
|
||||||
|
<span
|
||||||
|
style={{ fontSize: 12, color: '#8b8ea8', whiteSpace: 'nowrap', userSelect: 'none' }}
|
||||||
|
title={`预估公式: (宽 x 高 x 24fps x 时长) / 1024 = tokens, tokens x 单价 / 1000000 = 费用`}
|
||||||
|
>
|
||||||
|
预估消耗:{estimatedTokens.toLocaleString()} tokens / ¥{estimatedCost}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Spacer */}
|
{/* Spacer */}
|
||||||
<div className={styles.spacer} />
|
<div className={styles.spacer} />
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { useInputBarStore } from '../store/inputBar';
|
import { useInputBarStore } from '../store/inputBar';
|
||||||
import { showToast } from './Toast';
|
import { showToast } from './Toast';
|
||||||
|
import { ImageLightbox } from './ImageLightbox';
|
||||||
import styles from './UniversalUpload.module.css';
|
import styles from './UniversalUpload.module.css';
|
||||||
|
|
||||||
const MAX_IMAGE_SIZE = 30 * 1024 * 1024; // 30MB per API doc
|
const MAX_IMAGE_SIZE = 30 * 1024 * 1024; // 30MB per API doc
|
||||||
@ -26,6 +27,7 @@ export function UniversalUpload() {
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [badgeHover, setBadgeHover] = useState(false);
|
const [badgeHover, setBadgeHover] = useState(false);
|
||||||
|
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleTrigger = () => {
|
const handleTrigger = () => {
|
||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
@ -122,7 +124,7 @@ export function UniversalUpload() {
|
|||||||
<AudioIcon />
|
<AudioIcon />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<img src={ref.previewUrl} alt={ref.label} className={styles.thumbMedia} />
|
<img src={ref.previewUrl} alt={ref.label} className={styles.thumbMedia} style={{ cursor: 'zoom-in' }} onClick={(e) => { e.stopPropagation(); setLightboxSrc(ref.previewUrl); }} />
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={styles.thumbClose}
|
className={styles.thumbClose}
|
||||||
@ -172,6 +174,7 @@ export function UniversalUpload() {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<ImageLightbox src={lightboxSrc} onClose={() => setLightboxSrc(null)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 76px; /* sidebar width */
|
left: 0;
|
||||||
z-index: 200;
|
z-index: 200;
|
||||||
background: #07070f;
|
background: #07070f;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useRef, useState, useEffect, useCallback, useMemo } from 'react';
|
|||||||
import type { GenerationTask } from '../types';
|
import type { GenerationTask } from '../types';
|
||||||
import { AmbientBackground } from './AmbientBackground';
|
import { AmbientBackground } from './AmbientBackground';
|
||||||
import { ConfirmModal } from './ConfirmModal';
|
import { ConfirmModal } from './ConfirmModal';
|
||||||
|
import { ImageLightbox } from './ImageLightbox';
|
||||||
import styles from './VideoDetailModal.module.css';
|
import styles from './VideoDetailModal.module.css';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -30,19 +31,17 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
|
|||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const [showMoreMenu, setShowMoreMenu] = useState(false);
|
const [showMoreMenu, setShowMoreMenu] = useState(false);
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
|
||||||
const [fitSize, setFitSize] = useState<{ w: number; h: number } | null>(null);
|
const [fitSize, setFitSize] = useState<{ w: number; h: number } | null>(null);
|
||||||
const [intrinsicRatio, setIntrinsicRatio] = useState<number | null>(null);
|
const [intrinsicRatio, setIntrinsicRatio] = useState<number | null>(null);
|
||||||
const moreMenuRef = useRef<HTMLDivElement>(null);
|
const moreMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
const hideTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
// Parse aspect ratio from task; for 'adaptive', use video's intrinsic ratio
|
// Parse aspect ratio from task
|
||||||
const arNum = useMemo(() => {
|
const arNum = useMemo(() => {
|
||||||
const ar = task?.aspectRatio || '16:9';
|
const ar = task?.aspectRatio || '16:9';
|
||||||
if (ar === 'adaptive') {
|
|
||||||
return intrinsicRatio || 16 / 9;
|
|
||||||
}
|
|
||||||
const parts = ar.split(':').map(Number);
|
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]);
|
}, [task?.aspectRatio, intrinsicRatio]);
|
||||||
|
|
||||||
// Compute container size to fit aspect ratio within videoArea
|
// Compute container size to fit aspect ratio within videoArea
|
||||||
@ -458,7 +457,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<img src={ref.previewUrl} alt={ref.label} className={styles.refImg} />
|
<img src={ref.previewUrl} alt={ref.label} className={styles.refImg} style={{ cursor: 'zoom-in' }} onClick={() => setLightboxSrc(ref.previewUrl)} />
|
||||||
)}
|
)}
|
||||||
<span className={styles.refLabel}>{ref.label}</span>
|
<span className={styles.refLabel}>{ref.label}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -477,7 +476,15 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
|
|||||||
<span className={styles.infoBarDot} />
|
<span className={styles.infoBarDot} />
|
||||||
<span>{task.duration}s</span>
|
<span>{task.duration}s</span>
|
||||||
<span className={styles.infoBarDot} />
|
<span className={styles.infoBarDot} />
|
||||||
<span>{task.aspectRatio === 'adaptive' ? '自适应' : task.aspectRatio}</span>
|
<span>{task.aspectRatio}</span>
|
||||||
|
{(task.tokensConsumed ?? 0) > 0 && (
|
||||||
|
<>
|
||||||
|
<span className={styles.infoBarDot} />
|
||||||
|
<span>{(task.tokensConsumed ?? 0).toLocaleString()} tokens</span>
|
||||||
|
<span className={styles.infoBarDot} />
|
||||||
|
<span>¥{(task.costAmount ?? 0).toFixed(2)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(onReEdit || onRegenerate) && (
|
{(onReEdit || onRegenerate) && (
|
||||||
@ -514,6 +521,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
|
|||||||
onConfirm={doDelete}
|
onConfirm={doDelete}
|
||||||
onCancel={() => setConfirmDelete(false)}
|
onCancel={() => setConfirmDelete(false)}
|
||||||
/>
|
/>
|
||||||
|
<ImageLightbox src={lightboxSrc} onClose={() => setLightboxSrc(null)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -162,7 +162,7 @@ export const adminApi = {
|
|||||||
getTeams: () =>
|
getTeams: () =>
|
||||||
api.get<{ results: Team[] }>('/admin/teams'),
|
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),
|
api.post('/admin/teams/create', data),
|
||||||
|
|
||||||
getTeamDetail: (teamId: number) =>
|
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<TeamAnomalyConfig> }) =>
|
updateTeam: (teamId: number, data: { name?: string; monthly_seconds_limit?: number; daily_member_limit_default?: number; is_active?: boolean; expected_regions?: string; anomaly_config?: Partial<TeamAnomalyConfig> }) =>
|
||||||
api.put(`/admin/teams/${teamId}`, data),
|
api.put(`/admin/teams/${teamId}`, data),
|
||||||
|
|
||||||
topUpTeam: (teamId: number, seconds: number) =>
|
topUpTeam: (teamId: number, amount: number) =>
|
||||||
api.post(`/admin/teams/${teamId}/topup`, { seconds }),
|
api.post(`/admin/teams/${teamId}/topup`, { amount }),
|
||||||
|
|
||||||
setTeamPool: (teamId: number, totalSecondsPool: number) =>
|
setTeamPool: (teamId: number, balance: number) =>
|
||||||
api.put(`/admin/teams/${teamId}/set-pool`, { total_seconds_pool: totalSecondsPool }),
|
api.put(`/admin/teams/${teamId}/set-pool`, { balance }),
|
||||||
|
|
||||||
createTeamAdmin: (teamId: number, data: { username: string; email: string; password: string }) =>
|
createTeamAdmin: (teamId: number, data: { username: string; email: string; password: string }) =>
|
||||||
api.post(`/admin/teams/${teamId}/admin`, data),
|
api.post(`/admin/teams/${teamId}/admin`, data),
|
||||||
@ -185,8 +185,8 @@ export const adminApi = {
|
|||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
daily_seconds_limit?: number;
|
daily_generation_limit?: number;
|
||||||
monthly_seconds_limit?: number;
|
monthly_generation_limit?: number;
|
||||||
is_staff?: boolean;
|
is_staff?: boolean;
|
||||||
}) =>
|
}) =>
|
||||||
api.post('/admin/users/create', data),
|
api.post('/admin/users/create', data),
|
||||||
@ -205,8 +205,8 @@ export const adminApi = {
|
|||||||
|
|
||||||
updateUserQuota: (userId: number, daily: number, monthly: number) =>
|
updateUserQuota: (userId: number, daily: number, monthly: number) =>
|
||||||
api.put(`/admin/users/${userId}/quota`, {
|
api.put(`/admin/users/${userId}/quota`, {
|
||||||
daily_seconds_limit: daily,
|
daily_generation_limit: daily,
|
||||||
monthly_seconds_limit: monthly,
|
monthly_generation_limit: monthly,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
updateUserStatus: (userId: number, isActive: boolean) =>
|
updateUserStatus: (userId: number, isActive: boolean) =>
|
||||||
@ -306,7 +306,7 @@ export const teamApi = {
|
|||||||
getMembers: () =>
|
getMembers: () =>
|
||||||
api.get<{ results: TeamMember[] }>('/team/members'),
|
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),
|
api.post('/team/members/create', data),
|
||||||
|
|
||||||
getMemberDetail: (memberId: number) =>
|
getMemberDetail: (memberId: number) =>
|
||||||
@ -314,8 +314,8 @@ export const teamApi = {
|
|||||||
|
|
||||||
updateMemberQuota: (memberId: number, daily: number, monthly: number) =>
|
updateMemberQuota: (memberId: number, daily: number, monthly: number) =>
|
||||||
api.put(`/team/members/${memberId}/quota`, {
|
api.put(`/team/members/${memberId}/quota`, {
|
||||||
daily_seconds_limit: daily,
|
daily_generation_limit: daily,
|
||||||
monthly_seconds_limit: monthly,
|
monthly_generation_limit: monthly,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
updateMemberStatus: (memberId: number, isActive: boolean) =>
|
updateMemberStatus: (memberId: number, isActive: boolean) =>
|
||||||
|
|||||||
@ -4,8 +4,8 @@ import { VideoDetailModal } from '../components/VideoDetailModal';
|
|||||||
import type { AssetTeamSummary, AssetMemberSummary, AssetVideo, GenerationTask } from '../types';
|
import type { AssetTeamSummary, AssetMemberSummary, AssetVideo, GenerationTask } from '../types';
|
||||||
import styles from './AdminAssetsPage.module.css';
|
import styles from './AdminAssetsPage.module.css';
|
||||||
|
|
||||||
function formatSeconds(s: number) {
|
function formatCost(val: number) {
|
||||||
return `${s.toLocaleString()}s`;
|
return `¥${(val || 0).toFixed(2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function VideoThumbnail({ video, onClick }: { video: AssetVideo; onClick: () => void }) {
|
function VideoThumbnail({ video, onClick }: { video: AssetVideo; onClick: () => void }) {
|
||||||
@ -136,8 +136,8 @@ export function AdminAssetsPage() {
|
|||||||
<div className={styles.statValue}>{overview.total_videos}</div>
|
<div className={styles.statValue}>{overview.total_videos}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.statCard}>
|
<div className={styles.statCard}>
|
||||||
<div className={styles.statLabel}>总消耗</div>
|
<div className={styles.statLabel}>总费用</div>
|
||||||
<div className={styles.statValue}>{formatSeconds(overview.total_seconds)}</div>
|
<div className={styles.statValue}>{formatCost(overview.total_seconds)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.statCard}>
|
<div className={styles.statCard}>
|
||||||
<div className={styles.statLabel}>团队数</div>
|
<div className={styles.statLabel}>团队数</div>
|
||||||
@ -153,7 +153,7 @@ export function AdminAssetsPage() {
|
|||||||
<span className={styles.accordionName}>{team.name}</span>
|
<span className={styles.accordionName}>{team.name}</span>
|
||||||
<div className={styles.accordionMeta}>
|
<div className={styles.accordionMeta}>
|
||||||
<span className={styles.accordionBadge}>{team.video_count} 个视频</span>
|
<span className={styles.accordionBadge}>{team.video_count} 个视频</span>
|
||||||
<span className={styles.accordionBadge}>{formatSeconds(team.seconds_consumed)}</span>
|
<span className={styles.accordionBadge}>{formatCost(team.cost_consumed ?? team.seconds_consumed)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{expandedTeam === team.id && (
|
{expandedTeam === team.id && (
|
||||||
@ -169,7 +169,7 @@ export function AdminAssetsPage() {
|
|||||||
</span>
|
</span>
|
||||||
<div className={styles.accordionMeta}>
|
<div className={styles.accordionMeta}>
|
||||||
<span className={styles.accordionBadge}>{member.video_count} 个视频</span>
|
<span className={styles.accordionBadge}>{member.video_count} 个视频</span>
|
||||||
<span className={styles.accordionBadge}>{formatSeconds(member.seconds_consumed)}</span>
|
<span className={styles.accordionBadge}>{formatCost(member.cost_consumed ?? member.seconds_consumed)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{expandedMember === member.id && memberVideos[member.id] && (
|
{expandedMember === member.id && memberVideos[member.id] && (
|
||||||
@ -212,7 +212,7 @@ export function AdminAssetsPage() {
|
|||||||
<span className={styles.accordionName}>无团队用户</span>
|
<span className={styles.accordionName}>无团队用户</span>
|
||||||
<div className={styles.accordionMeta}>
|
<div className={styles.accordionMeta}>
|
||||||
<span className={styles.accordionBadge}>{overview.no_team.video_count} 个视频</span>
|
<span className={styles.accordionBadge}>{overview.no_team.video_count} 个视频</span>
|
||||||
<span className={styles.accordionBadge}>{formatSeconds(overview.no_team.seconds_consumed)}</span>
|
<span className={styles.accordionBadge}>{formatCost(overview.no_team.seconds_consumed)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -24,15 +24,23 @@ const ACTION_OPTIONS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const FIELD_LABELS: Record<string, string> = {
|
const FIELD_LABELS: Record<string, string> = {
|
||||||
default_daily_seconds_limit: '每日限额',
|
default_daily_seconds_limit: '每日限额(秒)',
|
||||||
default_monthly_seconds_limit: '每月限额',
|
default_monthly_seconds_limit: '每月限额(秒)',
|
||||||
|
default_daily_generation_limit: '每日生成次数',
|
||||||
|
default_monthly_generation_limit: '每月生成次数',
|
||||||
|
base_token_price: '基础token单价',
|
||||||
announcement: '公告内容',
|
announcement: '公告内容',
|
||||||
announcement_enabled: '公告开关',
|
announcement_enabled: '公告开关',
|
||||||
name: '名称',
|
name: '名称',
|
||||||
monthly_seconds_limit: '月额度',
|
monthly_seconds_limit: '月额度(秒)',
|
||||||
|
monthly_spending_limit: '月消费限额',
|
||||||
total_seconds_pool: '秒数池',
|
total_seconds_pool: '秒数池',
|
||||||
|
balance: '余额',
|
||||||
|
markup_percentage: '加价率',
|
||||||
is_active: '状态',
|
is_active: '状态',
|
||||||
daily_seconds_limit: '每日限额',
|
daily_seconds_limit: '每日限额(秒)',
|
||||||
|
daily_generation_limit: '每日生成次数',
|
||||||
|
monthly_generation_limit: '每月生成次数',
|
||||||
username: '用户名',
|
username: '用户名',
|
||||||
email: '邮箱',
|
email: '邮箱',
|
||||||
role: '角色',
|
role: '角色',
|
||||||
|
|||||||
@ -45,10 +45,10 @@ export function DashboardPage() {
|
|||||||
if (!stats) return null;
|
if (!stats) return null;
|
||||||
|
|
||||||
const statCards = [
|
const statCards = [
|
||||||
{ label: '总团队数', value: stats.total_teams, change: null },
|
{ label: '总团队数', value: String(stats.total_teams), change: null },
|
||||||
{ label: '总用户数', value: stats.total_users, change: null },
|
{ label: '总用户数', value: String(stats.total_users), change: null },
|
||||||
{ label: '今日消费秒数', value: stats.seconds_consumed_today, change: stats.today_change_percent },
|
{ label: '今日消费', value: `¥${(stats.cost_today || 0).toFixed(2)}`, change: stats.today_change_percent },
|
||||||
{ label: '本月消费秒数', value: stats.seconds_consumed_this_month, change: stats.month_change_percent },
|
{ label: '本月消费', value: `¥${(stats.cost_this_month || 0).toFixed(2)}`, change: stats.month_change_percent },
|
||||||
];
|
];
|
||||||
|
|
||||||
const trendOption: echarts.EChartsCoreOption = {
|
const trendOption: echarts.EChartsCoreOption = {
|
||||||
@ -59,7 +59,7 @@ export function DashboardPage() {
|
|||||||
textStyle: { color: '#f1f0ff', fontSize: 12 },
|
textStyle: { color: '#f1f0ff', fontSize: 12 },
|
||||||
formatter: (params: unknown) => {
|
formatter: (params: unknown) => {
|
||||||
const p = (params as { name: string; value: number }[])[0];
|
const p = (params as { name: string; value: number }[])[0];
|
||||||
return `${p.name}<br/>消费: ${p.value}s`;
|
return `${p.name}<br/>消费: ¥${p.value.toFixed(2)}`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
grid: { left: 50, right: 20, top: 20, bottom: 60 },
|
grid: { left: 50, right: 20, top: 20, bottom: 60 },
|
||||||
@ -77,7 +77,7 @@ export function DashboardPage() {
|
|||||||
dataZoom: [{ type: 'inside', start: 0, end: 100 }],
|
dataZoom: [{ type: 'inside', start: 0, end: 100 }],
|
||||||
series: [{
|
series: [{
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: stats.daily_trend.map((d) => d.seconds),
|
data: stats.daily_trend.map((d) => d.cost),
|
||||||
smooth: true,
|
smooth: true,
|
||||||
lineStyle: { color: '#6c63ff', width: 2 },
|
lineStyle: { color: '#6c63ff', width: 2 },
|
||||||
areaStyle: {
|
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 = {
|
const teamBarOption: echarts.EChartsCoreOption = {
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
@ -113,7 +113,7 @@ export function DashboardPage() {
|
|||||||
},
|
},
|
||||||
series: [{
|
series: [{
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: sortedTeams.map((t) => t.seconds_consumed),
|
data: sortedTeams.map((t) => t.cost_consumed || 0),
|
||||||
barWidth: 16,
|
barWidth: 16,
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
|
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
|
||||||
@ -127,12 +127,12 @@ export function DashboardPage() {
|
|||||||
position: 'right',
|
position: 'right',
|
||||||
color: '#8b8ea8',
|
color: '#8b8ea8',
|
||||||
fontSize: 11,
|
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 = {
|
const barOption: echarts.EChartsCoreOption = {
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
@ -155,7 +155,7 @@ export function DashboardPage() {
|
|||||||
},
|
},
|
||||||
series: [{
|
series: [{
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: sortedUsers.map((u) => u.seconds_consumed),
|
data: sortedUsers.map((u) => u.cost_consumed || 0),
|
||||||
barWidth: 16,
|
barWidth: 16,
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
|
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
|
||||||
@ -169,7 +169,7 @@ export function DashboardPage() {
|
|||||||
position: 'right',
|
position: 'right',
|
||||||
color: '#8b8ea8',
|
color: '#8b8ea8',
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
formatter: '{c}s',
|
formatter: (p: { value: number }) => `¥${p.value.toFixed(2)}`,
|
||||||
},
|
},
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
@ -182,7 +182,7 @@ export function DashboardPage() {
|
|||||||
{statCards.map((card) => (
|
{statCards.map((card) => (
|
||||||
<div key={card.label} className={styles.statCard}>
|
<div key={card.label} className={styles.statCard}>
|
||||||
<div className={styles.statLabel}>{card.label}</div>
|
<div className={styles.statLabel}>{card.label}</div>
|
||||||
<div className={styles.statValue}>{card.value.toLocaleString()}{card.label.includes('秒') ? 's' : ''}</div>
|
<div className={styles.statValue}>{card.value}</div>
|
||||||
{card.change !== null && (
|
{card.change !== null && (
|
||||||
<div className={`${styles.statChange} ${card.change >= 0 ? styles.positive : styles.negative}`}>
|
<div className={`${styles.statChange} ${card.change >= 0 ? styles.positive : styles.negative}`}>
|
||||||
<span>{card.change >= 0 ? '↑' : '↓'}</span>
|
<span>{card.change >= 0 ? '↑' : '↓'}</span>
|
||||||
@ -194,7 +194,7 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.chartSection}>
|
<div className={styles.chartSection}>
|
||||||
<h2 className={styles.sectionTitle}>消费趋势(近30天)</h2>
|
<h2 className={styles.sectionTitle}>消费趋势(近30天 · 元)</h2>
|
||||||
<div className={styles.chartWrapper}>
|
<div className={styles.chartWrapper}>
|
||||||
<ReactEChartsCore echarts={echarts} option={trendOption} style={{ height: 320 }} />
|
<ReactEChartsCore echarts={echarts} option={trendOption} style={{ height: 320 }} />
|
||||||
</div>
|
</div>
|
||||||
@ -217,6 +217,83 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Profit Section */}
|
||||||
|
<div className={styles.statsGrid}>
|
||||||
|
<div className={styles.statCard}>
|
||||||
|
<div className={styles.statLabel}>总收入</div>
|
||||||
|
<div className={styles.statValue}>{`¥${(stats.total_revenue || 0).toFixed(2)}`}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statCard}>
|
||||||
|
<div className={styles.statLabel}>总成本</div>
|
||||||
|
<div className={styles.statValue}>{`¥${(stats.total_base_cost || 0).toFixed(2)}`}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statCard}>
|
||||||
|
<div className={styles.statLabel}>总利润</div>
|
||||||
|
<div className={styles.statValue}>{`¥${(stats.total_profit || 0).toFixed(2)}`}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statCard}>
|
||||||
|
<div className={styles.statLabel}>利润率</div>
|
||||||
|
<div className={styles.statValue}>{`${(stats.profit_margin || 0).toFixed(1)}%`}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(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}<br/>收入: ¥${team.revenue.toFixed(2)}<br/>成本: ¥${team.base_cost.toFixed(2)}<br/>利润: ¥${team.profit.toFixed(2)}<br/>加价率: ${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 (
|
||||||
|
<div className={styles.chartSection}>
|
||||||
|
<h2 className={styles.sectionTitle}>团队利润排行</h2>
|
||||||
|
<div className={styles.chartWrapper}>
|
||||||
|
<ReactEChartsCore echarts={echarts} option={profitBarOption} style={{ height: Math.max(300, profitRanking.length * 36) }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 dailyGenLimit = overview.daily_generation_limit || 0;
|
||||||
const monthlyPercent = overview.monthly_seconds_limit > 0 ? (overview.monthly_seconds_used / overview.monthly_seconds_limit) * 100 : 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 dailyPercent = dailyGenLimit > 0 ? (dailyGenUsed / dailyGenLimit) * 100 : 0;
|
||||||
const totalPercent = overview.monthly_seconds_limit > 0 ? (overview.total_seconds_used / overview.monthly_seconds_limit) * 100 : 0;
|
const monthlyPercent = monthlyGenLimit > 0 ? (monthlyGenUsed / monthlyGenLimit) * 100 : 0;
|
||||||
|
|
||||||
const sparklineOption: echarts.EChartsCoreOption = {
|
const sparklineOption: echarts.EChartsCoreOption = {
|
||||||
tooltip: {
|
tooltip: {
|
||||||
@ -162,38 +164,37 @@ export function ProfilePage() {
|
|||||||
<h2 className={styles.sectionTitle}>消费概览</h2>
|
<h2 className={styles.sectionTitle}>消费概览</h2>
|
||||||
<div className={styles.overviewGrid}>
|
<div className={styles.overviewGrid}>
|
||||||
<div className={styles.quotaCard}>
|
<div className={styles.quotaCard}>
|
||||||
<div className={styles.quotaLabel}>总额度</div>
|
<div className={styles.quotaLabel}>今日生成</div>
|
||||||
<div className={styles.quotaValue}>已消耗: {overview.total_seconds_used.toLocaleString()}s / {overview.monthly_seconds_limit.toLocaleString()}s</div>
|
<div className={styles.quotaValue}>{dailyGenUsed} / {dailyGenLimit === -1 ? '不限' : dailyGenLimit + '次'}</div>
|
||||||
<div className={styles.progressBar}>
|
|
||||||
<div className={styles.progressFill} style={{
|
|
||||||
width: `${Math.min(totalPercent, 100)}%`,
|
|
||||||
background: totalPercent > 80 ? (totalPercent >= 100 ? 'var(--color-danger)' : 'var(--color-warning)') : 'var(--color-primary)',
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
<div className={styles.quotaPercent}>剩余 {totalRemaining.toLocaleString()}s</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.quotaCard}>
|
|
||||||
<div className={styles.quotaLabel}>今日额度</div>
|
|
||||||
<div className={styles.quotaValue}>已用: {overview.daily_seconds_used.toLocaleString()}s / {overview.daily_seconds_limit.toLocaleString()}s</div>
|
|
||||||
<div className={styles.progressBar}>
|
<div className={styles.progressBar}>
|
||||||
<div className={styles.progressFill} style={{
|
<div className={styles.progressFill} style={{
|
||||||
width: `${Math.min(dailyPercent, 100)}%`,
|
width: `${Math.min(dailyPercent, 100)}%`,
|
||||||
background: dailyPercent > 80 ? (dailyPercent >= 100 ? 'var(--color-danger)' : 'var(--color-warning)') : 'var(--color-primary)',
|
background: dailyPercent > 80 ? (dailyPercent >= 100 ? 'var(--color-danger)' : 'var(--color-warning)') : 'var(--color-primary)',
|
||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.quotaPercent}>{dailyPercent.toFixed(1)}%</div>
|
<div className={styles.quotaPercent}>今日消费 ¥{(overview.daily_spent || 0).toFixed(2)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.quotaCard}>
|
<div className={styles.quotaCard}>
|
||||||
<div className={styles.quotaLabel}>本月额度</div>
|
<div className={styles.quotaLabel}>本月生成</div>
|
||||||
<div className={styles.quotaValue}>已用: {overview.monthly_seconds_used.toLocaleString()}s / {overview.monthly_seconds_limit.toLocaleString()}s</div>
|
<div className={styles.quotaValue}>{monthlyGenUsed} / {monthlyGenLimit === -1 ? '不限' : monthlyGenLimit + '次'}</div>
|
||||||
<div className={styles.progressBar}>
|
<div className={styles.progressBar}>
|
||||||
<div className={styles.progressFill} style={{
|
<div className={styles.progressFill} style={{
|
||||||
width: `${Math.min(monthlyPercent, 100)}%`,
|
width: `${Math.min(monthlyPercent, 100)}%`,
|
||||||
background: monthlyPercent > 80 ? 'var(--color-warning)' : 'var(--color-primary)',
|
background: monthlyPercent > 80 ? 'var(--color-warning)' : 'var(--color-primary)',
|
||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.quotaPercent}>{monthlyPercent.toFixed(1)}%</div>
|
<div className={styles.quotaPercent}>本月消费 ¥{(overview.monthly_spent || 0).toFixed(2)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
{overview.team && (
|
||||||
|
<div className={styles.quotaCard}>
|
||||||
|
<div className={styles.quotaLabel}>团队 — {overview.team.name}</div>
|
||||||
|
<div className={styles.quotaValue}>余额: ¥{(overview.team.balance || 0).toFixed(2)}</div>
|
||||||
|
<div className={styles.progressBar}>
|
||||||
|
<div className={styles.progressFill} style={{ width: '0%' }} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.quotaPercent}>可用余额 ¥{(overview.team.available_balance || 0).toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -225,7 +226,7 @@ export function ProfilePage() {
|
|||||||
<div className={styles.recordPrompt}>{r.prompt || '-'}</div>
|
<div className={styles.recordPrompt}>{r.prompt || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.recordRight}>
|
<div className={styles.recordRight}>
|
||||||
<span className={styles.recordSeconds}>{r.seconds_consumed.toLocaleString()}s</span>
|
<span className={styles.recordSeconds}>¥{(r.cost_amount || 0).toFixed(2)}</span>
|
||||||
<span className={styles.recordMode}>{r.mode === 'universal' ? '全能参考' : '首尾帧'}</span>
|
<span className={styles.recordMode}>{r.mode === 'universal' ? '全能参考' : '首尾帧'}</span>
|
||||||
<span className={`${styles.recordStatus} ${styles[r.status]}`}>{statusMap[r.status]}</span>
|
<span className={`${styles.recordStatus} ${styles[r.status]}`}>{statusMap[r.status]}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -58,14 +58,15 @@ export function RecordsPage() {
|
|||||||
team_id: teamFilter ? Number(teamFilter) : undefined,
|
team_id: teamFilter ? Number(teamFilter) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const header = '时间,团队,用户名,消费秒数,提示词,生成模式,状态,失败原因\n';
|
const header = '时间,团队,用户名,消费秒数,Tokens,费用(元),成本(元),利润(元),提示词,生成模式,状态,失败原因\n';
|
||||||
const rows = data.results.map((r) => {
|
const rows = data.results.map((r) => {
|
||||||
// Escape CSV fields to prevent injection
|
// Escape CSV fields to prevent injection
|
||||||
const prompt = r.prompt.replace(/"/g, '""').replace(/^[=+\-@]/, "'$&");
|
const prompt = r.prompt.replace(/"/g, '""').replace(/^[=+\-@]/, "'$&");
|
||||||
const modeLabel = r.mode === 'universal' ? '全能参考' : '首尾帧';
|
const modeLabel = r.mode === 'universal' ? '全能参考' : '首尾帧';
|
||||||
const statusLabel = { queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' }[r.status];
|
const statusLabel = { queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' }[r.status];
|
||||||
const errorMsg = (r.error_message || '').replace(/"/g, '""').replace(/^[=+\-@]/, "'$&");
|
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');
|
}).join('\n');
|
||||||
|
|
||||||
const blob = new Blob(['\uFEFF' + header + rows], { type: 'text/csv;charset=utf-8;' });
|
const blob = new Blob(['\uFEFF' + header + rows], { type: 'text/csv;charset=utf-8;' });
|
||||||
@ -120,6 +121,10 @@ export function RecordsPage() {
|
|||||||
<th>团队</th>
|
<th>团队</th>
|
||||||
<th>用户名</th>
|
<th>用户名</th>
|
||||||
<th>消费秒数</th>
|
<th>消费秒数</th>
|
||||||
|
<th>Tokens</th>
|
||||||
|
<th>费用</th>
|
||||||
|
<th>成本</th>
|
||||||
|
<th>利润</th>
|
||||||
<th>视频描述</th>
|
<th>视频描述</th>
|
||||||
<th>模式</th>
|
<th>模式</th>
|
||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
@ -129,13 +134,13 @@ export function RecordsPage() {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
Array.from({ length: 5 }).map((_, i) => (
|
Array.from({ length: 5 }).map((_, i) => (
|
||||||
<tr key={i}>
|
<tr key={i}>
|
||||||
{Array.from({ length: 7 }).map((_, j) => (
|
{Array.from({ length: 11 }).map((_, j) => (
|
||||||
<td key={j}><div className={styles.skeletonCell} /></td>
|
<td key={j}><div className={styles.skeletonCell} /></td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
) : records.length === 0 ? (
|
) : records.length === 0 ? (
|
||||||
<tr><td colSpan={7} className={styles.empty}>暂无记录</td></tr>
|
<tr><td colSpan={11} className={styles.empty}>暂无记录</td></tr>
|
||||||
) : (
|
) : (
|
||||||
records.map((r) => (
|
records.map((r) => (
|
||||||
<tr key={r.id}>
|
<tr key={r.id}>
|
||||||
@ -143,6 +148,10 @@ export function RecordsPage() {
|
|||||||
<td>{r.team_name || '-'}</td>
|
<td>{r.team_name || '-'}</td>
|
||||||
<td>{r.username}</td>
|
<td>{r.username}</td>
|
||||||
<td><span className={styles.secondsBadge}>{r.seconds_consumed.toLocaleString()}s</span></td>
|
<td><span className={styles.secondsBadge}>{r.seconds_consumed.toLocaleString()}s</span></td>
|
||||||
|
<td>{(r.tokens_consumed || 0).toLocaleString()}</td>
|
||||||
|
<td>¥{(r.cost_amount || 0).toFixed(2)}</td>
|
||||||
|
<td>¥{(r.base_cost_amount || 0).toFixed(2)}</td>
|
||||||
|
<td>¥{((r.cost_amount || 0) - (r.base_cost_amount || 0)).toFixed(2)}</td>
|
||||||
<td className={styles.promptCell}>{r.prompt ? (r.prompt.slice(0, 40) + (r.prompt.length > 40 ? '...' : '')) : '-'}</td>
|
<td className={styles.promptCell}>{r.prompt ? (r.prompt.slice(0, 40) + (r.prompt.length > 40 ? '...' : '')) : '-'}</td>
|
||||||
<td>{r.mode === 'universal' ? '全能参考' : '首尾帧'}</td>
|
<td>{r.mode === 'universal' ? '全能参考' : '首尾帧'}</td>
|
||||||
<td className={r.status === 'failed' && r.error_message ? styles.statusCell : undefined}>
|
<td className={r.status === 'failed' && r.error_message ? styles.statusCell : undefined}>
|
||||||
|
|||||||
@ -8,6 +8,9 @@ export function SettingsPage() {
|
|||||||
const [settings, setSettings] = useState<SystemSettings>({
|
const [settings, setSettings] = useState<SystemSettings>({
|
||||||
default_daily_seconds_limit: 600,
|
default_daily_seconds_limit: 600,
|
||||||
default_monthly_seconds_limit: 6000,
|
default_monthly_seconds_limit: 6000,
|
||||||
|
default_daily_generation_limit: 50,
|
||||||
|
default_monthly_generation_limit: 500,
|
||||||
|
base_token_price: 0,
|
||||||
announcement: '',
|
announcement: '',
|
||||||
announcement_enabled: false,
|
announcement_enabled: false,
|
||||||
max_desktop_sessions: 1,
|
max_desktop_sessions: 1,
|
||||||
@ -104,22 +107,31 @@ export function SettingsPage() {
|
|||||||
<p className={styles.cardDesc}>新注册用户将自动获得以下配额</p>
|
<p className={styles.cardDesc}>新注册用户将自动获得以下配额</p>
|
||||||
<div className={styles.formRow}>
|
<div className={styles.formRow}>
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
<label>默认每日限额 (秒)</label>
|
<label>默认每日生成次数</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={settings.default_daily_seconds_limit}
|
value={settings.default_daily_generation_limit}
|
||||||
onChange={(e) => setSettings({ ...settings, default_daily_seconds_limit: Number(e.target.value) })}
|
onChange={(e) => setSettings({ ...settings, default_daily_generation_limit: Number(e.target.value) })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
<label>默认每月限额 (秒)</label>
|
<label>默认每月生成次数</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={settings.default_monthly_seconds_limit}
|
value={settings.default_monthly_generation_limit}
|
||||||
onChange={(e) => setSettings({ ...settings, default_monthly_seconds_limit: Number(e.target.value) })}
|
onChange={(e) => setSettings({ ...settings, default_monthly_generation_limit: Number(e.target.value) })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>基础token单价 (元/百万tokens)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={settings.base_token_price}
|
||||||
|
onChange={(e) => setSettings({ ...settings, base_token_price: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<button className={styles.saveBtn} onClick={handleSaveQuota} disabled={saving}>
|
<button className={styles.saveBtn} onClick={handleSaveQuota} disabled={saving}>
|
||||||
{saving ? '保存中...' : '保存配额设置'}
|
{saving ? '保存中...' : '保存配额设置'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -49,14 +49,14 @@ export function TeamDashboardPage() {
|
|||||||
|
|
||||||
if (!info || !stats) return null;
|
if (!info || !stats) return null;
|
||||||
|
|
||||||
const formatLimit = (v: number) => v === -1 ? '不限' : v.toLocaleString() + 's';
|
const fmtMoney = (val: number) => '¥' + (val || 0).toFixed(2);
|
||||||
|
|
||||||
const statCards = [
|
const statCards = [
|
||||||
{ label: '总秒数池', value: formatLimit(info.total_seconds_pool) },
|
{ label: '余额', value: fmtMoney(info.balance) },
|
||||||
{ label: '已使用', value: info.total_seconds_used.toLocaleString() + 's' },
|
{ label: '累计消费', value: fmtMoney(info.total_spent) },
|
||||||
{ label: '剩余', value: info.remaining_seconds.toLocaleString() + 's' },
|
{ label: '可用余额', value: fmtMoney(info.available_balance) },
|
||||||
{ label: '月限额', value: formatLimit(info.monthly_seconds_limit) },
|
{ label: '月消费限额', value: fmtMoney(info.monthly_spending_limit) },
|
||||||
{ label: '本月已用', value: info.monthly_seconds_used.toLocaleString() + 's' },
|
{ label: '本月消费', value: fmtMoney(info.monthly_spent) },
|
||||||
];
|
];
|
||||||
|
|
||||||
const trendOption: echarts.EChartsCoreOption = {
|
const trendOption: echarts.EChartsCoreOption = {
|
||||||
@ -67,7 +67,7 @@ export function TeamDashboardPage() {
|
|||||||
textStyle: { color: '#f1f0ff', fontSize: 12 },
|
textStyle: { color: '#f1f0ff', fontSize: 12 },
|
||||||
formatter: (params: unknown) => {
|
formatter: (params: unknown) => {
|
||||||
const p = (params as { name: string; value: number }[])[0];
|
const p = (params as { name: string; value: number }[])[0];
|
||||||
return `${p.name}<br/>消费: ${p.value}s`;
|
return `${p.name}<br/>消费: ¥${p.value.toFixed(2)}`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
grid: { left: 50, right: 20, top: 20, bottom: 60 },
|
grid: { left: 50, right: 20, top: 20, bottom: 60 },
|
||||||
@ -85,7 +85,7 @@ export function TeamDashboardPage() {
|
|||||||
dataZoom: [{ type: 'inside', start: 0, end: 100 }],
|
dataZoom: [{ type: 'inside', start: 0, end: 100 }],
|
||||||
series: [{
|
series: [{
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: stats.daily_trend.map((d) => d.seconds),
|
data: stats.daily_trend.map((d) => d.cost ?? d.seconds),
|
||||||
smooth: true,
|
smooth: true,
|
||||||
lineStyle: { color: '#6c63ff', width: 2 },
|
lineStyle: { color: '#6c63ff', width: 2 },
|
||||||
areaStyle: {
|
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 = {
|
const barOption: echarts.EChartsCoreOption = {
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
@ -121,7 +121,7 @@ export function TeamDashboardPage() {
|
|||||||
},
|
},
|
||||||
series: [{
|
series: [{
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: sortedMembers.map((m) => m.seconds_consumed),
|
data: sortedMembers.map((m) => m.cost_consumed ?? m.seconds_consumed),
|
||||||
barWidth: 16,
|
barWidth: 16,
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
|
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
|
||||||
@ -135,7 +135,7 @@ export function TeamDashboardPage() {
|
|||||||
position: 'right',
|
position: 'right',
|
||||||
color: '#8b8ea8',
|
color: '#8b8ea8',
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
formatter: '{c}s',
|
formatter: (p: { value: number }) => `¥${p.value.toFixed(2)}`,
|
||||||
},
|
},
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
@ -154,7 +154,7 @@ export function TeamDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.chartSection}>
|
<div className={styles.chartSection}>
|
||||||
<h2 className={styles.sectionTitle}>团队消费趋势(近30天)</h2>
|
<h2 className={styles.sectionTitle}>团队消费趋势(近30天 · 元)</h2>
|
||||||
<div className={styles.chartWrapper}>
|
<div className={styles.chartWrapper}>
|
||||||
<ReactEChartsCore echarts={echarts} option={trendOption} style={{ height: 320 }} />
|
<ReactEChartsCore echarts={echarts} option={trendOption} style={{ height: 320 }} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -13,8 +13,8 @@ export function TeamMembersPage() {
|
|||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [newUsername, setNewUsername] = useState('');
|
const [newUsername, setNewUsername] = useState('');
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
const [newDaily, setNewDaily] = useState('600');
|
const [newDaily, setNewDaily] = useState('50');
|
||||||
const [newMonthly, setNewMonthly] = useState('6000');
|
const [newMonthly, setNewMonthly] = useState('500');
|
||||||
const [createError, setCreateError] = useState('');
|
const [createError, setCreateError] = useState('');
|
||||||
|
|
||||||
// Confirm toggle
|
// Confirm toggle
|
||||||
@ -39,7 +39,8 @@ export function TeamMembersPage() {
|
|||||||
|
|
||||||
useEffect(() => { fetchMembers(); }, [fetchMembers]);
|
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) => {
|
const handleToggleStatus = async (member: TeamMember) => {
|
||||||
try {
|
try {
|
||||||
@ -53,8 +54,8 @@ export function TeamMembersPage() {
|
|||||||
|
|
||||||
const openEditModal = (member: TeamMember) => {
|
const openEditModal = (member: TeamMember) => {
|
||||||
setEditMember(member);
|
setEditMember(member);
|
||||||
setEditDaily(String(member.daily_seconds_limit));
|
setEditDaily(String(member.daily_generation_limit ?? 50));
|
||||||
setEditMonthly(String(member.monthly_seconds_limit));
|
setEditMonthly(String(member.monthly_generation_limit ?? 500));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveQuota = async () => {
|
const handleSaveQuota = async () => {
|
||||||
@ -71,7 +72,7 @@ export function TeamMembersPage() {
|
|||||||
|
|
||||||
const resetCreateForm = () => {
|
const resetCreateForm = () => {
|
||||||
setNewUsername(''); setNewPassword('');
|
setNewUsername(''); setNewPassword('');
|
||||||
setNewDaily('600'); setNewMonthly('6000');
|
setNewDaily('50'); setNewMonthly('500');
|
||||||
setCreateError('');
|
setCreateError('');
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -83,8 +84,8 @@ export function TeamMembersPage() {
|
|||||||
await teamApi.createMember({
|
await teamApi.createMember({
|
||||||
username: newUsername.trim(),
|
username: newUsername.trim(),
|
||||||
password: newPassword,
|
password: newPassword,
|
||||||
daily_seconds_limit: Number(newDaily),
|
daily_generation_limit: Number(newDaily),
|
||||||
monthly_seconds_limit: Number(newMonthly),
|
monthly_generation_limit: Number(newMonthly),
|
||||||
});
|
});
|
||||||
showToast('成员创建成功');
|
showToast('成员创建成功');
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
@ -116,10 +117,10 @@ export function TeamMembersPage() {
|
|||||||
<th>用户名</th>
|
<th>用户名</th>
|
||||||
<th>角色</th>
|
<th>角色</th>
|
||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
<th>日限额(秒)</th>
|
<th>日生成上限</th>
|
||||||
<th>月限额(秒)</th>
|
<th>月生成上限</th>
|
||||||
<th>今日消费(秒)</th>
|
<th>今日生成/消费</th>
|
||||||
<th>本月消费(秒)</th>
|
<th>本月生成/消费</th>
|
||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -150,10 +151,10 @@ export function TeamMembersPage() {
|
|||||||
{m.is_active ? '启用' : '禁用'}
|
{m.is_active ? '启用' : '禁用'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{formatLimit(m.daily_seconds_limit)}</td>
|
<td>{formatLimit(m.daily_generation_limit)}</td>
|
||||||
<td>{formatLimit(m.monthly_seconds_limit)}</td>
|
<td>{formatLimit(m.monthly_generation_limit)}</td>
|
||||||
<td>{m.seconds_today.toLocaleString()}s</td>
|
<td>{(m.generations_today || 0) + '次 / ' + fmtMoney(m.spent_today)}</td>
|
||||||
<td>{m.seconds_this_month.toLocaleString()}s</td>
|
<td>{(m.generations_this_month || 0) + '次 / ' + fmtMoney(m.spent_this_month)}</td>
|
||||||
<td>
|
<td>
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
<button className={styles.editBtn} onClick={() => openEditModal(m)}>编辑配额</button>
|
<button className={styles.editBtn} onClick={() => openEditModal(m)}>编辑配额</button>
|
||||||
@ -190,11 +191,11 @@ export function TeamMembersPage() {
|
|||||||
<div className={styles.modal}>
|
<div className={styles.modal}>
|
||||||
<h3 className={styles.modalTitle}>编辑配额 — {editMember.username}</h3>
|
<h3 className={styles.modalTitle}>编辑配额 — {editMember.username}</h3>
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
<label>每日秒数限额(-1 为不限)</label>
|
<label>每日生成次数上限(-1 为不限)</label>
|
||||||
<input type="number" value={editDaily} onChange={(e) => setEditDaily(e.target.value)} />
|
<input type="number" value={editDaily} onChange={(e) => setEditDaily(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
<label>每月秒数限额(-1 为不限)</label>
|
<label>每月生成次数上限(-1 为不限)</label>
|
||||||
<input type="number" value={editMonthly} onChange={(e) => setEditMonthly(e.target.value)} />
|
<input type="number" value={editMonthly} onChange={(e) => setEditMonthly(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.modalActions}>
|
<div className={styles.modalActions}>
|
||||||
@ -220,11 +221,11 @@ export function TeamMembersPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.formRow}>
|
<div className={styles.formRow}>
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
<label>每日秒数限额</label>
|
<label>每日生成次数上限</label>
|
||||||
<input type="number" value={newDaily} onChange={(e) => setNewDaily(e.target.value)} />
|
<input type="number" value={newDaily} onChange={(e) => setNewDaily(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
<label>每月秒数限额</label>
|
<label>每月生成次数上限</label>
|
||||||
<input type="number" value={newMonthly} onChange={(e) => setNewMonthly(e.target.value)} />
|
<input type="number" value={newMonthly} onChange={(e) => setNewMonthly(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -6,6 +6,10 @@ import { ConfirmModal } from '../components/ConfirmModal';
|
|||||||
import { Select } from '../components/Select';
|
import { Select } from '../components/Select';
|
||||||
import styles from './TeamsPage.module.css';
|
import styles from './TeamsPage.module.css';
|
||||||
|
|
||||||
|
function fmtMoney(val: number): string {
|
||||||
|
return '¥' + (val || 0).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
function fmtSec(s: number): string {
|
function fmtSec(s: number): string {
|
||||||
return Math.round(s).toLocaleString() + 's';
|
return Math.round(s).toLocaleString() + 's';
|
||||||
}
|
}
|
||||||
@ -17,14 +21,15 @@ export function TeamsPage() {
|
|||||||
// Create team modal
|
// Create team modal
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [newName, setNewName] = useState('');
|
const [newName, setNewName] = useState('');
|
||||||
const [newMonthlyLimit, setNewMonthlyLimit] = useState('36000');
|
const [newMonthlyLimit, setNewMonthlyLimit] = useState('10000');
|
||||||
const [newDailyMemberLimit, setNewDailyMemberLimit] = useState('600');
|
const [newDailyMemberLimit, setNewDailyMemberLimit] = useState('50');
|
||||||
const [newExpectedRegions, setNewExpectedRegions] = useState('');
|
const [newExpectedRegions, setNewExpectedRegions] = useState('');
|
||||||
|
const [newMarkup, setNewMarkup] = useState('30');
|
||||||
const [createError, setCreateError] = useState('');
|
const [createError, setCreateError] = useState('');
|
||||||
|
|
||||||
// Top-up modal
|
// Top-up modal
|
||||||
const [topupTeam, setTopupTeam] = useState<Team | null>(null);
|
const [topupTeam, setTopupTeam] = useState<Team | null>(null);
|
||||||
const [topupSeconds, setTopupSeconds] = useState('3600');
|
const [topupAmount, setTopupAmount] = useState('1000');
|
||||||
|
|
||||||
// Create admin modal
|
// Create admin modal
|
||||||
const [adminTeam, setAdminTeam] = useState<Team | null>(null);
|
const [adminTeam, setAdminTeam] = useState<Team | null>(null);
|
||||||
@ -79,8 +84,8 @@ export function TeamsPage() {
|
|||||||
const [anomalyConfigDraft, setAnomalyConfigDraft] = useState<Record<string, any>>({});
|
const [anomalyConfigDraft, setAnomalyConfigDraft] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
const resetCreateForm = () => {
|
const resetCreateForm = () => {
|
||||||
setNewName(''); setNewMonthlyLimit('36000'); setNewDailyMemberLimit('600');
|
setNewName(''); setNewMonthlyLimit('10000'); setNewDailyMemberLimit('50');
|
||||||
setNewExpectedRegions(''); setCreateError('');
|
setNewExpectedRegions(''); setNewMarkup('30'); setCreateError('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateTeam = async () => {
|
const handleCreateTeam = async () => {
|
||||||
@ -90,9 +95,10 @@ export function TeamsPage() {
|
|||||||
try {
|
try {
|
||||||
await adminApi.createTeam({
|
await adminApi.createTeam({
|
||||||
name: newName.trim(),
|
name: newName.trim(),
|
||||||
monthly_seconds_limit: Number(newMonthlyLimit),
|
monthly_spending_limit: Number(newMonthlyLimit),
|
||||||
daily_member_limit_default: Number(newDailyMemberLimit),
|
daily_member_limit_default: Number(newDailyMemberLimit),
|
||||||
expected_regions: newExpectedRegions.trim(),
|
expected_regions: newExpectedRegions.trim(),
|
||||||
|
markup_percentage: Number(newMarkup),
|
||||||
});
|
});
|
||||||
showToast('团队创建成功');
|
showToast('团队创建成功');
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
@ -106,11 +112,11 @@ export function TeamsPage() {
|
|||||||
|
|
||||||
const handleTopUp = async () => {
|
const handleTopUp = async () => {
|
||||||
if (!topupTeam) return;
|
if (!topupTeam) return;
|
||||||
const seconds = Number(topupSeconds);
|
const amount = Number(topupAmount);
|
||||||
if (!seconds || seconds <= 0) { showToast('请输入有效的秒数'); return; }
|
if (!amount || amount <= 0) { showToast('请输入有效的金额'); return; }
|
||||||
try {
|
try {
|
||||||
await adminApi.topUpTeam(topupTeam.id, seconds);
|
await adminApi.topUpTeam(topupTeam.id, amount);
|
||||||
showToast(`已为 ${topupTeam.name} 充值 ${fmtSec(seconds)} 秒`);
|
showToast(`已为 ${topupTeam.name} 充值 ${fmtMoney(amount)}`);
|
||||||
setTopupTeam(null);
|
setTopupTeam(null);
|
||||||
fetchTeams();
|
fetchTeams();
|
||||||
} catch {
|
} catch {
|
||||||
@ -120,11 +126,11 @@ export function TeamsPage() {
|
|||||||
|
|
||||||
const handleSetPool = async () => {
|
const handleSetPool = async () => {
|
||||||
if (!detailTeam) return;
|
if (!detailTeam) return;
|
||||||
const newPool = Number(editPoolValue);
|
const newBalance = Number(editPoolValue);
|
||||||
if (isNaN(newPool) || newPool < 0) { setEditPoolError('请输入有效的非负数'); return; }
|
if (isNaN(newBalance) || newBalance < 0) { setEditPoolError('请输入有效的非负数'); return; }
|
||||||
try {
|
try {
|
||||||
await adminApi.setTeamPool(detailTeam.id, newPool);
|
await adminApi.setTeamPool(detailTeam.id, newBalance);
|
||||||
showToast(`已将 ${detailTeam.name} 总秒数池修改为 ${fmtSec(newPool)}`);
|
showToast(`已将 ${detailTeam.name} 余额修改为 ${fmtMoney(newBalance)}`);
|
||||||
setEditPoolOpen(false);
|
setEditPoolOpen(false);
|
||||||
// Refresh detail
|
// Refresh detail
|
||||||
const { data } = await adminApi.getTeamDetail(detailTeam.id);
|
const { data } = await adminApi.getTeamDetail(detailTeam.id);
|
||||||
@ -222,10 +228,10 @@ export function TeamsPage() {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>团队名称</th>
|
<th>团队名称</th>
|
||||||
<th>总秒数池</th>
|
<th>余额</th>
|
||||||
<th>已消耗</th>
|
<th>累计消费</th>
|
||||||
<th>剩余</th>
|
<th>可用余额</th>
|
||||||
<th>月限额</th>
|
<th>月消费限额</th>
|
||||||
<th>本月消费</th>
|
<th>本月消费</th>
|
||||||
<th>成员数</th>
|
<th>成员数</th>
|
||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
@ -251,11 +257,11 @@ export function TeamsPage() {
|
|||||||
{t.name}
|
{t.name}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td>{fmtSec(t.total_seconds_pool)}</td>
|
<td>{fmtMoney(t.balance)}</td>
|
||||||
<td>{fmtSec(t.total_seconds_used)}</td>
|
<td>{fmtMoney(t.total_spent)}</td>
|
||||||
<td>{fmtSec(t.remaining_seconds)}</td>
|
<td>{fmtMoney(t.available_balance)}</td>
|
||||||
<td>{fmtSec(t.monthly_seconds_limit)}</td>
|
<td>{fmtMoney(t.monthly_spending_limit)}</td>
|
||||||
<td>{fmtSec(t.monthly_seconds_used)}</td>
|
<td>{fmtMoney(t.monthly_spent)}</td>
|
||||||
<td>{t.member_count}</td>
|
<td>{t.member_count}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`${styles.statusBadge} ${t.is_active ? styles.active : styles.disabled}`}>
|
<span className={`${styles.statusBadge} ${t.is_active ? styles.active : styles.disabled}`}>
|
||||||
@ -269,7 +275,7 @@ export function TeamsPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
<button className={styles.topupBtn} onClick={() => { setTopupTeam(t); setTopupSeconds('3600'); }}>充值</button>
|
<button className={styles.topupBtn} onClick={() => { setTopupTeam(t); setTopupAmount('1000'); }}>充值</button>
|
||||||
<button className={styles.adminBtn} onClick={() => { setAdminTeam(t); resetAdminForm(); }}>添加管理员</button>
|
<button className={styles.adminBtn} onClick={() => { setAdminTeam(t); resetAdminForm(); }}>添加管理员</button>
|
||||||
<button
|
<button
|
||||||
className={`${styles.toggleBtn} ${t.is_active ? styles.disableBtn : styles.enableBtn}`}
|
className={`${styles.toggleBtn} ${t.is_active ? styles.disableBtn : styles.enableBtn}`}
|
||||||
@ -297,14 +303,18 @@ export function TeamsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.formRow}>
|
<div className={styles.formRow}>
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
<label>每月秒数限额</label>
|
<label>每月消费限额(元)</label>
|
||||||
<input type="number" value={newMonthlyLimit} onChange={(e) => setNewMonthlyLimit(e.target.value)} />
|
<input type="number" value={newMonthlyLimit} onChange={(e) => setNewMonthlyLimit(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
<label>成员日限额(默认)</label>
|
<label>成员日生成次数(默认)</label>
|
||||||
<input type="number" value={newDailyMemberLimit} onChange={(e) => setNewDailyMemberLimit(e.target.value)} />
|
<input type="number" value={newDailyMemberLimit} onChange={(e) => setNewDailyMemberLimit(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>加价率(%)</label>
|
||||||
|
<input type="number" value={newMarkup} onChange={(e) => setNewMarkup(e.target.value)} placeholder="如 30 表示加价 30%" />
|
||||||
|
</div>
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
<label>预期登录城市(必填,逗号分隔)</label>
|
<label>预期登录城市(必填,逗号分隔)</label>
|
||||||
<input type="text" value={newExpectedRegions} onChange={(e) => setNewExpectedRegions(e.target.value)} placeholder="广州市,深圳市,北京市" />
|
<input type="text" value={newExpectedRegions} onChange={(e) => setNewExpectedRegions(e.target.value)} placeholder="广州市,深圳市,北京市" />
|
||||||
@ -322,12 +332,12 @@ export function TeamsPage() {
|
|||||||
{topupTeam && (
|
{topupTeam && (
|
||||||
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setTopupTeam(null); }}>
|
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setTopupTeam(null); }}>
|
||||||
<div className={styles.modal}>
|
<div className={styles.modal}>
|
||||||
<h3 className={styles.modalTitle}>充值秒数 — {topupTeam.name}</h3>
|
<h3 className={styles.modalTitle}>充值金额 — {topupTeam.name}</h3>
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
<label>充值秒数</label>
|
<label>充值金额(元)</label>
|
||||||
<input type="number" value={topupSeconds} onChange={(e) => setTopupSeconds(e.target.value)} placeholder="输入秒数" />
|
<input type="number" value={topupAmount} onChange={(e) => setTopupAmount(e.target.value)} placeholder="输入金额" />
|
||||||
<div className={styles.formHint}>
|
<div className={styles.formHint}>
|
||||||
当前剩余: {fmtSec(topupTeam.remaining_seconds)} | 充值后: {fmtSec(topupTeam.remaining_seconds + (Number(topupSeconds) || 0))}
|
当前余额: {fmtMoney(topupTeam.balance)} | 充值后: {fmtMoney((topupTeam.balance || 0) + (Number(topupAmount) || 0))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.modalActions}>
|
<div className={styles.modalActions}>
|
||||||
@ -401,13 +411,13 @@ export function TeamsPage() {
|
|||||||
<div className={styles.detailBody}>
|
<div className={styles.detailBody}>
|
||||||
<div className={styles.detailGrid}>
|
<div className={styles.detailGrid}>
|
||||||
<div className={styles.detailItem}>
|
<div className={styles.detailItem}>
|
||||||
<span className={styles.detailLabel}>总秒数池</span>
|
<span className={styles.detailLabel}>余额</span>
|
||||||
<span className={styles.detailValue}>
|
<span className={styles.detailValue}>
|
||||||
{fmtSec(detailTeam.total_seconds_pool)}
|
{fmtMoney(detailTeam.balance)}
|
||||||
<button
|
<button
|
||||||
className={styles.editPoolBtn}
|
className={styles.editPoolBtn}
|
||||||
onClick={() => { setEditPoolValue(String(detailTeam.total_seconds_pool)); setEditPoolError(''); setEditPoolOpen(true); }}
|
onClick={() => { setEditPoolValue(String(detailTeam.balance || 0)); setEditPoolError(''); setEditPoolOpen(true); }}
|
||||||
title="修改秒数"
|
title="修改余额"
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||||
@ -417,29 +427,29 @@ export function TeamsPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.detailItem}>
|
<div className={styles.detailItem}>
|
||||||
<span className={styles.detailLabel}>已消耗</span>
|
<span className={styles.detailLabel}>累计消费</span>
|
||||||
<span className={styles.detailValue}>{fmtSec(detailTeam.total_seconds_used)}</span>
|
<span className={styles.detailValue}>{fmtMoney(detailTeam.total_spent)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.detailItem}>
|
<div className={styles.detailItem}>
|
||||||
<span className={styles.detailLabel}>剩余</span>
|
<span className={styles.detailLabel}>可用余额</span>
|
||||||
<span className={styles.detailValue}>{fmtSec(detailTeam.remaining_seconds)}</span>
|
<span className={styles.detailValue}>{fmtMoney(detailTeam.available_balance)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.detailItem}>
|
<div className={styles.detailItem}>
|
||||||
<span className={styles.detailLabel}>月限额</span>
|
<span className={styles.detailLabel}>月消费限额</span>
|
||||||
<span className={styles.detailValue}>{fmtSec(detailTeam.monthly_seconds_limit)}</span>
|
<span className={styles.detailValue}>{fmtMoney(detailTeam.monthly_spending_limit)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.detailItem}>
|
<div className={styles.detailItem}>
|
||||||
<span className={styles.detailLabel}>本月消费</span>
|
<span className={styles.detailLabel}>本月消费</span>
|
||||||
<span className={styles.detailValue}>{fmtSec(detailTeam.monthly_seconds_used)}</span>
|
<span className={styles.detailValue}>{fmtMoney(detailTeam.monthly_spent)}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.detailItem}>
|
||||||
|
<span className={styles.detailLabel}>加价率</span>
|
||||||
|
<span className={styles.detailValue}>{(detailTeam.markup_percentage || 0)}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.detailItem}>
|
<div className={styles.detailItem}>
|
||||||
<span className={styles.detailLabel}>成员数</span>
|
<span className={styles.detailLabel}>成员数</span>
|
||||||
<span className={styles.detailValue}>{detailTeam.member_count}</span>
|
<span className={styles.detailValue}>{detailTeam.member_count}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.detailItem}>
|
|
||||||
<span className={styles.detailLabel}>成员日限额(默认)</span>
|
|
||||||
<span className={styles.detailValue}>{fmtSec(detailTeam.daily_member_limit_default)}</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.detailItem}>
|
<div className={styles.detailItem}>
|
||||||
<span className={styles.detailLabel}>创建时间</span>
|
<span className={styles.detailLabel}>创建时间</span>
|
||||||
<span className={styles.detailValue}>{new Date(detailTeam.created_at).toLocaleDateString('zh-CN')}</span>
|
<span className={styles.detailValue}>{new Date(detailTeam.created_at).toLocaleDateString('zh-CN')}</span>
|
||||||
@ -631,9 +641,9 @@ export function TeamsPage() {
|
|||||||
<th>邮箱</th>
|
<th>邮箱</th>
|
||||||
<th>角色</th>
|
<th>角色</th>
|
||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
<th>日限额</th>
|
<th>日生成上限</th>
|
||||||
<th>今日消费</th>
|
<th>今日生成/消费</th>
|
||||||
<th>本月消费</th>
|
<th>本月生成/消费</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -656,9 +666,9 @@ export function TeamsPage() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>{fmtSec(m.daily_seconds_limit)}</td>
|
<td>{m.daily_generation_limit === -1 ? '不限' : (m.daily_generation_limit || 0) + '次'}</td>
|
||||||
<td>{fmtSec(m.seconds_today)}</td>
|
<td>{(m.generations_today || 0) + '次 / ' + fmtMoney(m.spent_today)}</td>
|
||||||
<td>{fmtSec(m.seconds_this_month)}</td>
|
<td>{(m.generations_this_month || 0) + '次 / ' + fmtMoney(m.spent_this_month)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -707,13 +717,13 @@ export function TeamsPage() {
|
|||||||
{editPoolOpen && detailTeam && (
|
{editPoolOpen && detailTeam && (
|
||||||
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setEditPoolOpen(false); }}>
|
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setEditPoolOpen(false); }}>
|
||||||
<div className={styles.modal}>
|
<div className={styles.modal}>
|
||||||
<h3 className={styles.modalTitle}>修改总秒数池 — {detailTeam.name}</h3>
|
<h3 className={styles.modalTitle}>修改余额 — {detailTeam.name}</h3>
|
||||||
{editPoolError && <div className={styles.formError}>{editPoolError}</div>}
|
{editPoolError && <div className={styles.formError}>{editPoolError}</div>}
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
<label>总秒数池(秒)</label>
|
<label>余额(元)</label>
|
||||||
<input type="number" value={editPoolValue} onChange={(e) => setEditPoolValue(e.target.value)} placeholder="输入总秒数" />
|
<input type="number" value={editPoolValue} onChange={(e) => setEditPoolValue(e.target.value)} placeholder="输入余额" />
|
||||||
<div className={styles.formHint}>
|
<div className={styles.formHint}>
|
||||||
当前: {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)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.modalActions}>
|
<div className={styles.modalActions}>
|
||||||
|
|||||||
@ -39,8 +39,8 @@ export function UsersPage() {
|
|||||||
const [newUsername, setNewUsername] = useState('');
|
const [newUsername, setNewUsername] = useState('');
|
||||||
const [newEmail, setNewEmail] = useState('');
|
const [newEmail, setNewEmail] = useState('');
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
const [newDaily, setNewDaily] = useState('600');
|
const [newDaily, setNewDaily] = useState('50');
|
||||||
const [newMonthly, setNewMonthly] = useState('6000');
|
const [newMonthly, setNewMonthly] = useState('500');
|
||||||
const [newIsStaff, setNewIsStaff] = useState(false);
|
const [newIsStaff, setNewIsStaff] = useState(false);
|
||||||
const [createError, setCreateError] = useState('');
|
const [createError, setCreateError] = useState('');
|
||||||
|
|
||||||
@ -84,8 +84,8 @@ export function UsersPage() {
|
|||||||
|
|
||||||
const openEditModal = (user: AdminUser) => {
|
const openEditModal = (user: AdminUser) => {
|
||||||
setEditUser(user);
|
setEditUser(user);
|
||||||
setEditDaily(String(user.daily_seconds_limit));
|
setEditDaily(String(user.daily_generation_limit ?? 50));
|
||||||
setEditMonthly(String(user.monthly_seconds_limit));
|
setEditMonthly(String(user.monthly_generation_limit ?? 500));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveQuota = async () => {
|
const handleSaveQuota = async () => {
|
||||||
@ -112,7 +112,7 @@ export function UsersPage() {
|
|||||||
|
|
||||||
const resetCreateForm = () => {
|
const resetCreateForm = () => {
|
||||||
setNewUsername(''); setNewEmail(''); setNewPassword('');
|
setNewUsername(''); setNewEmail(''); setNewPassword('');
|
||||||
setNewDaily('600'); setNewMonthly('6000'); setNewIsStaff(false);
|
setNewDaily('50'); setNewMonthly('500'); setNewIsStaff(false);
|
||||||
setCreateError('');
|
setCreateError('');
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -126,8 +126,8 @@ export function UsersPage() {
|
|||||||
username: newUsername.trim(),
|
username: newUsername.trim(),
|
||||||
email: newEmail.trim(),
|
email: newEmail.trim(),
|
||||||
password: newPassword,
|
password: newPassword,
|
||||||
daily_seconds_limit: Number(newDaily),
|
daily_generation_limit: Number(newDaily),
|
||||||
monthly_seconds_limit: Number(newMonthly),
|
monthly_generation_limit: Number(newMonthly),
|
||||||
is_staff: newIsStaff,
|
is_staff: newIsStaff,
|
||||||
});
|
});
|
||||||
showToast('用户创建成功');
|
showToast('用户创建成功');
|
||||||
@ -203,10 +203,10 @@ export function UsersPage() {
|
|||||||
<th>邮箱</th>
|
<th>邮箱</th>
|
||||||
<th>注册时间</th>
|
<th>注册时间</th>
|
||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
<th>日限额(秒)</th>
|
<th>日生成上限</th>
|
||||||
<th>月限额(秒)</th>
|
<th>月生成上限</th>
|
||||||
<th>今日消费(秒)</th>
|
<th>今日生成/消费</th>
|
||||||
<th>本月消费(秒)</th>
|
<th>本月生成/消费</th>
|
||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -242,10 +242,10 @@ export function UsersPage() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>{u.daily_seconds_limit === -1 ? '不限' : u.daily_seconds_limit.toLocaleString() + 's'}</td>
|
<td>{(u.daily_generation_limit ?? -1) === -1 ? '不限' : u.daily_generation_limit + '次'}</td>
|
||||||
<td>{u.monthly_seconds_limit === -1 ? '不限' : u.monthly_seconds_limit.toLocaleString() + 's'}</td>
|
<td>{(u.monthly_generation_limit ?? -1) === -1 ? '不限' : u.monthly_generation_limit + '次'}</td>
|
||||||
<td>{u.seconds_today.toLocaleString()}s</td>
|
<td>{(u.generations_today || 0) + '次 / ¥' + (u.spent_today || 0).toFixed(2)}</td>
|
||||||
<td>{u.seconds_this_month.toLocaleString()}s</td>
|
<td>{(u.generations_this_month || 0) + '次 / ¥' + (u.spent_this_month || 0).toFixed(2)}</td>
|
||||||
<td>
|
<td>
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
<button className={styles.editBtn} onClick={() => openEditModal(u)}>编辑</button>
|
<button className={styles.editBtn} onClick={() => openEditModal(u)}>编辑</button>
|
||||||
@ -305,11 +305,11 @@ export function UsersPage() {
|
|||||||
<div className={styles.modal}>
|
<div className={styles.modal}>
|
||||||
<h3 className={styles.modalTitle}>编辑配额 — {editUser.username}</h3>
|
<h3 className={styles.modalTitle}>编辑配额 — {editUser.username}</h3>
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
<label>每日秒数限额</label>
|
<label>每日生成次数上限(-1 为不限)</label>
|
||||||
<input type="number" value={editDaily} onChange={(e) => setEditDaily(e.target.value)} />
|
<input type="number" value={editDaily} onChange={(e) => setEditDaily(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
<label>每月秒数限额</label>
|
<label>每月生成次数上限(-1 为不限)</label>
|
||||||
<input type="number" value={editMonthly} onChange={(e) => setEditMonthly(e.target.value)} />
|
<input type="number" value={editMonthly} onChange={(e) => setEditMonthly(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.modalActions}>
|
<div className={styles.modalActions}>
|
||||||
@ -339,11 +339,11 @@ export function UsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.formRow}>
|
<div className={styles.formRow}>
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
<label>每日秒数限额</label>
|
<label>每日生成次数上限</label>
|
||||||
<input type="number" value={newDaily} onChange={(e) => setNewDaily(e.target.value)} />
|
<input type="number" value={newDaily} onChange={(e) => setNewDaily(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
<label>每月秒数限额</label>
|
<label>每月生成次数上限</label>
|
||||||
<input type="number" value={newMonthly} onChange={(e) => setNewMonthly(e.target.value)} />
|
<input type="number" value={newMonthly} onChange={(e) => setNewMonthly(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -415,16 +415,24 @@ export function UsersPage() {
|
|||||||
<span className={styles.detailValue}>{new Date(detailUser.date_joined).toLocaleString('zh-CN')}</span>
|
<span className={styles.detailValue}>{new Date(detailUser.date_joined).toLocaleString('zh-CN')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.detailItem}>
|
<div className={styles.detailItem}>
|
||||||
<span className={styles.detailLabel}>日限额/今日消费</span>
|
<span className={styles.detailLabel}>今日生成/上限</span>
|
||||||
<span className={styles.detailValue}>{detailUser.seconds_today.toLocaleString()}s / {detailUser.daily_seconds_limit === -1 ? '不限' : detailUser.daily_seconds_limit.toLocaleString() + 's'}</span>
|
<span className={styles.detailValue}>{(detailUser.generations_today || 0)}次 / {(detailUser.daily_generation_limit ?? -1) === -1 ? '不限' : detailUser.daily_generation_limit + '次'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.detailItem}>
|
<div className={styles.detailItem}>
|
||||||
<span className={styles.detailLabel}>月限额/本月消费</span>
|
<span className={styles.detailLabel}>本月生成/上限</span>
|
||||||
<span className={styles.detailValue}>{detailUser.seconds_this_month.toLocaleString()}s / {detailUser.monthly_seconds_limit === -1 ? '不限' : detailUser.monthly_seconds_limit.toLocaleString() + 's'}</span>
|
<span className={styles.detailValue}>{(detailUser.generations_this_month || 0)}次 / {(detailUser.monthly_generation_limit ?? -1) === -1 ? '不限' : detailUser.monthly_generation_limit + '次'}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.detailItem}>
|
||||||
|
<span className={styles.detailLabel}>今日消费</span>
|
||||||
|
<span className={styles.detailValue}>¥{(detailUser.spent_today || 0).toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.detailItem}>
|
||||||
|
<span className={styles.detailLabel}>本月消费</span>
|
||||||
|
<span className={styles.detailValue}>¥{(detailUser.spent_this_month || 0).toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.detailItem}>
|
<div className={styles.detailItem}>
|
||||||
<span className={styles.detailLabel}>累计消费</span>
|
<span className={styles.detailLabel}>累计消费</span>
|
||||||
<span className={styles.detailValue}>{detailUser.seconds_total.toLocaleString()}s</span>
|
<span className={styles.detailValue}>¥{(detailUser.total_spent || 0).toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -437,7 +445,7 @@ export function UsersPage() {
|
|||||||
<div key={r.id} className={styles.recordItem}>
|
<div key={r.id} className={styles.recordItem}>
|
||||||
<div className={styles.recordTime}>{new Date(r.created_at).toLocaleString('zh-CN')}</div>
|
<div className={styles.recordTime}>{new Date(r.created_at).toLocaleString('zh-CN')}</div>
|
||||||
<div className={styles.recordMeta}>
|
<div className={styles.recordMeta}>
|
||||||
<span className={styles.recordSeconds}>{r.seconds_consumed.toLocaleString()}s</span>
|
<span className={styles.recordSeconds}>¥{(r.cost_amount || 0).toFixed(2)}</span>
|
||||||
<span className={styles.recordMode}>{r.mode === 'universal' ? '全能参考' : '首尾帧'}</span>
|
<span className={styles.recordMode}>{r.mode === 'universal' ? '全能参考' : '首尾帧'}</span>
|
||||||
<span className={`${styles.recordStatus} ${styles[r.status]}`}>{
|
<span className={`${styles.recordStatus} ${styles[r.status]}`}>{
|
||||||
{ queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' }[r.status]
|
{ queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' }[r.status]
|
||||||
|
|||||||
@ -79,6 +79,8 @@ function backendToFrontend(bt: BackendTask): GenerationTask {
|
|||||||
resultUrl: bt.result_url || undefined,
|
resultUrl: bt.result_url || undefined,
|
||||||
errorMessage: mapErrorMessage(bt.error_message),
|
errorMessage: mapErrorMessage(bt.error_message),
|
||||||
createdAt: new Date(bt.created_at).getTime(),
|
createdAt: new Date(bt.created_at).getTime(),
|
||||||
|
tokensConsumed: bt.tokens_consumed || 0,
|
||||||
|
costAmount: bt.cost_amount || 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -214,7 +214,7 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
|
|||||||
mode,
|
mode,
|
||||||
prevReferences: state.references,
|
prevReferences: state.references,
|
||||||
references: [],
|
references: [],
|
||||||
aspectRatio: 'adaptive',
|
aspectRatio: '16:9',
|
||||||
duration: 5,
|
duration: 5,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
export type CreationMode = 'universal' | 'keyframe';
|
export type CreationMode = 'universal' | 'keyframe';
|
||||||
export type ModelOption = 'seedance_2.0' | 'seedance_2.0_fast';
|
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 Duration = 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15;
|
||||||
export type GenerationType = 'video' | 'image';
|
export type GenerationType = 'video' | 'image';
|
||||||
export type UserRole = 'super_admin' | 'team_admin' | 'member';
|
export type UserRole = 'super_admin' | 'team_admin' | 'member';
|
||||||
@ -46,6 +46,8 @@ export interface GenerationTask {
|
|||||||
resultUrl?: string;
|
resultUrl?: string;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
tokensConsumed?: number;
|
||||||
|
costAmount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackendTask {
|
export interface BackendTask {
|
||||||
@ -58,6 +60,9 @@ export interface BackendTask {
|
|||||||
aspect_ratio: string;
|
aspect_ratio: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
seconds_consumed: number;
|
seconds_consumed: number;
|
||||||
|
tokens_consumed: number;
|
||||||
|
cost_amount: number;
|
||||||
|
base_cost_amount: number;
|
||||||
status: 'queued' | 'processing' | 'completed' | 'failed';
|
status: 'queued' | 'processing' | 'completed' | 'failed';
|
||||||
result_url: string;
|
result_url: string;
|
||||||
error_message: string;
|
error_message: string;
|
||||||
@ -85,6 +90,13 @@ export interface TeamInfo {
|
|||||||
remaining_seconds: number;
|
remaining_seconds: number;
|
||||||
monthly_seconds_limit: number;
|
monthly_seconds_limit: number;
|
||||||
monthly_seconds_used: 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;
|
is_active: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -94,6 +106,10 @@ export interface Quota {
|
|||||||
daily_seconds_used: number;
|
daily_seconds_used: number;
|
||||||
monthly_seconds_limit: number;
|
monthly_seconds_limit: number;
|
||||||
monthly_seconds_used: number;
|
monthly_seconds_used: number;
|
||||||
|
daily_generation_limit: number;
|
||||||
|
daily_generation_used: number;
|
||||||
|
monthly_generation_limit: number;
|
||||||
|
monthly_generation_used: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthTokens {
|
export interface AuthTokens {
|
||||||
@ -108,11 +124,20 @@ export interface AdminStats {
|
|||||||
new_users_today: number;
|
new_users_today: number;
|
||||||
seconds_consumed_today: number;
|
seconds_consumed_today: number;
|
||||||
seconds_consumed_this_month: 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;
|
today_change_percent: number;
|
||||||
month_change_percent: number;
|
month_change_percent: number;
|
||||||
daily_trend: { date: string; seconds: number }[];
|
daily_trend: { date: string; seconds: number; cost: number; base_cost: number }[];
|
||||||
top_users: { user_id: number; username: string; seconds_consumed: number }[];
|
top_users: { user_id: number; username: string; seconds_consumed: number; cost_consumed: number }[];
|
||||||
top_teams: { team_id: number; name: string; seconds_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 {
|
export interface AdminUser {
|
||||||
@ -130,10 +155,17 @@ export interface AdminUser {
|
|||||||
monthly_seconds_limit: number;
|
monthly_seconds_limit: number;
|
||||||
seconds_today: number;
|
seconds_today: number;
|
||||||
seconds_this_month: 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 {
|
export interface AdminUserDetail extends AdminUser {
|
||||||
seconds_total: number;
|
seconds_total: number;
|
||||||
|
total_spent: number;
|
||||||
recent_records: AdminRecord[];
|
recent_records: AdminRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -144,6 +176,9 @@ export interface AdminRecord {
|
|||||||
username?: string;
|
username?: string;
|
||||||
team_name?: string;
|
team_name?: string;
|
||||||
seconds_consumed: number;
|
seconds_consumed: number;
|
||||||
|
tokens_consumed: number;
|
||||||
|
cost_amount: number;
|
||||||
|
base_cost_amount: number;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
mode: CreationMode;
|
mode: CreationMode;
|
||||||
model: ModelOption;
|
model: ModelOption;
|
||||||
@ -155,6 +190,9 @@ export interface AdminRecord {
|
|||||||
export interface SystemSettings {
|
export interface SystemSettings {
|
||||||
default_daily_seconds_limit: number;
|
default_daily_seconds_limit: number;
|
||||||
default_monthly_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: string;
|
||||||
announcement_enabled: boolean;
|
announcement_enabled: boolean;
|
||||||
max_desktop_sessions: number;
|
max_desktop_sessions: number;
|
||||||
@ -184,6 +222,13 @@ export interface ProfileOverview {
|
|||||||
monthly_seconds_limit: number;
|
monthly_seconds_limit: number;
|
||||||
monthly_seconds_used: number;
|
monthly_seconds_used: number;
|
||||||
total_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 }[];
|
daily_trend: { date: string; seconds: number }[];
|
||||||
team?: {
|
team?: {
|
||||||
name: string;
|
name: string;
|
||||||
@ -192,6 +237,12 @@ export interface ProfileOverview {
|
|||||||
remaining_seconds: number;
|
remaining_seconds: number;
|
||||||
monthly_seconds_limit: number;
|
monthly_seconds_limit: number;
|
||||||
monthly_seconds_used: 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;
|
remaining_seconds: number;
|
||||||
monthly_seconds_limit: number;
|
monthly_seconds_limit: number;
|
||||||
monthly_seconds_used: 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;
|
daily_member_limit_default: number;
|
||||||
member_count: number;
|
member_count: number;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
@ -250,6 +308,12 @@ export interface TeamMember {
|
|||||||
monthly_seconds_limit: number;
|
monthly_seconds_limit: number;
|
||||||
seconds_today: number;
|
seconds_today: number;
|
||||||
seconds_this_month: 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;
|
date_joined: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -273,8 +337,8 @@ export interface LoginAnomaly {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamStats {
|
export interface TeamStats {
|
||||||
daily_trend: { date: string; seconds: number }[];
|
daily_trend: { date: string; seconds: number; cost?: number }[];
|
||||||
member_consumption: { user_id: number; username: string; seconds_consumed: number }[];
|
member_consumption: { user_id: number; username: string; seconds_consumed: number; cost_consumed?: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Asset management types
|
// Asset management types
|
||||||
@ -283,6 +347,7 @@ export interface AssetTeamSummary {
|
|||||||
name: string;
|
name: string;
|
||||||
video_count: number;
|
video_count: number;
|
||||||
seconds_consumed: number;
|
seconds_consumed: number;
|
||||||
|
cost_consumed?: number;
|
||||||
member_count: number;
|
member_count: number;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
}
|
}
|
||||||
@ -293,6 +358,7 @@ export interface AssetMemberSummary {
|
|||||||
is_team_admin: boolean;
|
is_team_admin: boolean;
|
||||||
video_count: number;
|
video_count: number;
|
||||||
seconds_consumed: number;
|
seconds_consumed: number;
|
||||||
|
cost_consumed?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AssetVideo {
|
export interface AssetVideo {
|
||||||
@ -302,6 +368,7 @@ export interface AssetVideo {
|
|||||||
result_url: string;
|
result_url: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
seconds_consumed: number;
|
seconds_consumed: number;
|
||||||
|
cost_amount?: number;
|
||||||
aspect_ratio: string;
|
aspect_ratio: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user