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:
seaislee1209 2026-03-20 20:32:12 +08:00
parent 277de4651f
commit 9259988094
31 changed files with 1354 additions and 317 deletions

View 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='每月生成次数上限'),
),
]

View File

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

View File

@ -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='创建时间')

View File

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

View 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 = [
('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='默认每月生成次数'),
),
]

View File

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

View File

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

View File

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

View 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;
}

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

View File

@ -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" />

View File

@ -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} />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: '角色',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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