Compare commits

..

No commits in common. "62356c7e3fc4abd3fd78b16581b3ab040b021040" and "a389495ee7a1f632c76fc2f2530c4fe4cb635406" have entirely different histories.

41 changed files with 353 additions and 1556 deletions

View File

@ -1,53 +0,0 @@
# 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

@ -1,52 +0,0 @@
# 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,13 +11,6 @@ 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='禁用来源')
@ -35,10 +28,6 @@ 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."""
@ -52,9 +41,6 @@ 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, Count from django.db.models import Sum
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,45 +170,24 @@ 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, QuotaConfig from apps.generation.models import GenerationRecord
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,
@ -217,13 +196,6 @@ 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

@ -1,53 +0,0 @@
# 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,12 +34,6 @@ 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='错误信息')
@ -83,10 +77,6 @@ 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_generation_limit = serializers.IntegerField(min_value=-1) daily_seconds_limit = serializers.IntegerField(min_value=-1)
monthly_generation_limit = serializers.IntegerField(min_value=-1) monthly_seconds_limit = serializers.IntegerField(min_value=-1)
class UserStatusSerializer(serializers.Serializer): class UserStatusSerializer(serializers.Serializer):
@ -25,17 +25,12 @@ 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, required=False) default_daily_seconds_limit = serializers.IntegerField(min_value=0)
default_monthly_seconds_limit = serializers.IntegerField(min_value=0, required=False) default_monthly_seconds_limit = serializers.IntegerField(min_value=0)
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)
@ -65,9 +60,6 @@ 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)
@ -75,9 +67,6 @@ 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)
@ -98,7 +87,7 @@ class TeamAnomalyConfigSerializer(serializers.Serializer):
class TeamTopUpSerializer(serializers.Serializer): class TeamTopUpSerializer(serializers.Serializer):
amount = serializers.DecimalField(max_digits=12, decimal_places=2, min_value=0.01) seconds = serializers.IntegerField(min_value=1)
class TeamAdminCreateSerializer(serializers.Serializer): class TeamAdminCreateSerializer(serializers.Serializer):
@ -114,10 +103,8 @@ 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_generation_limit = serializers.IntegerField(min_value=-1) daily_seconds_limit = serializers.IntegerField(min_value=-1)
monthly_generation_limit = serializers.IntegerField(min_value=-1) monthly_seconds_limit = serializers.IntegerField(min_value=-1)

File diff suppressed because it is too large Load Diff

View File

@ -6,23 +6,6 @@ from datetime import timedelta
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
# 自动加载 .env.local本地开发用不进 git
_env_local = BASE_DIR / '.env.local'
if _env_local.exists():
with open(_env_local, encoding='utf-8') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
# 去掉 export 前缀
if line.startswith('export '):
line = line[7:]
key, _, value = line.partition('=')
if key and _ == '=':
# 去掉引号
value = value.strip().strip('"').strip("'")
os.environ.setdefault(key.strip(), value)
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', '') SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', '')
if not SECRET_KEY: if not SECRET_KEY:
import warnings import warnings

View File

@ -1,69 +0,0 @@
"""
计费工具模块 分辨率映射 + 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}</span> <span className={styles.label}>{task.aspectRatio === 'adaptive' ? '自适应' : 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}</span> <span></span><span>{task.aspectRatio === 'adaptive' ? '自适应' : 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

@ -1,17 +0,0 @@
.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

@ -1,24 +0,0 @@
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,14 +38,8 @@ export function LoginModal({ isOpen, onClose, onSuccess }: Props) {
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className={styles.overlay} <div className={styles.overlay} onClick={onClose}>
onMouseDown={(e) => { if (e.target === e.currentTarget) (e.currentTarget as HTMLElement).dataset.mouseDownOnOverlay = 'true'; }} <div className={styles.panel} onClick={(e) => e.stopPropagation()}>
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,7 +1,6 @@
import { useEffect, useCallback, useMemo } from 'react'; import { useEffect, useCallback } 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';
@ -90,6 +89,7 @@ const ratioItems = [
]; ];
const keyframeRatioItems = [ const keyframeRatioItems = [
{ label: '自适应', value: 'adaptive' as AspectRatio },
...ratioItems, ...ratioItems,
]; ];
@ -98,11 +98,6 @@ 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: '首尾帧',
@ -123,19 +118,9 @@ 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();
@ -203,7 +188,7 @@ export function Toolbar() {
trigger={ trigger={
<button className={styles.btn}> <button className={styles.btn}>
<MonitorIcon /> <MonitorIcon />
<span className={styles.label}>{aspectRatio}</span> <span className={styles.label}>{aspectRatio === 'adaptive' ? '自适应' : aspectRatio}</span>
</button> </button>
} }
/> />
@ -229,31 +214,9 @@ export function Toolbar() {
</button> </button>
)} )}
{/* Spacer — push right group to the end */} {/* Spacer */}
<div className={styles.spacer} /> <div className={styles.spacer} />
{/* 全部清空 + 预估消耗:仅有内容时显示 */}
{isSubmittable && (
<span
onClick={() => useInputBarStore.getState().reset()}
style={{ fontSize: 12, color: '#8b8ea8', whiteSpace: 'nowrap', userSelect: 'none', cursor: 'pointer', transition: 'filter 0.15s', marginRight: 20, lineHeight: 1 }}
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.filter = 'brightness(1.4)'; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.filter = ''; }}
>
&#x27F2;
</span>
)}
{/* Estimated cost */}
{isSubmittable && tokenPrice > 0 && (
<span
style={{ fontSize: 12, color: '#8b8ea8', whiteSpace: 'nowrap', userSelect: 'none', marginRight: 16, lineHeight: 1 }}
title={`预估公式: (宽 x 高 x 24fps x 时长) / 1024 = tokens, tokens x 单价 / 1000000 = 费用`}
>
{estimatedTokens.toLocaleString()} tokens / ¥{estimatedCost}
</span>
)}
{/* Send button */} {/* Send button */}
<button <button
className={`${styles.sendBtn} ${isSubmittable ? styles.sendEnabled : styles.sendDisabled}`} className={`${styles.sendBtn} ${isSubmittable ? styles.sendEnabled : styles.sendDisabled}`}

View File

@ -1,7 +1,6 @@
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
@ -27,7 +26,6 @@ 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();
@ -124,7 +122,7 @@ export function UniversalUpload() {
<AudioIcon /> <AudioIcon />
</div> </div>
) : ( ) : (
<img src={ref.previewUrl} alt={ref.label} className={styles.thumbMedia} style={{ cursor: 'zoom-in' }} onClick={(e) => { e.stopPropagation(); setLightboxSrc(ref.previewUrl); }} /> <img src={ref.previewUrl} alt={ref.label} className={styles.thumbMedia} />
)} )}
<div <div
className={styles.thumbClose} className={styles.thumbClose}
@ -174,7 +172,6 @@ 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: 0; left: 76px; /* sidebar width */
z-index: 200; z-index: 200;
background: #07070f; background: #07070f;
display: flex; display: flex;

View File

@ -1,10 +1,7 @@
import { useRef, useState, useEffect, useCallback, useMemo } from 'react'; import { useRef, useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
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 { useInputBarStore } from '../store/inputBar';
import styles from './VideoDetailModal.module.css'; import styles from './VideoDetailModal.module.css';
interface Props { interface Props {
@ -13,15 +10,13 @@ interface Props {
onReEdit?: (id: string) => void; onReEdit?: (id: string) => void;
onRegenerate?: (id: string) => void; onRegenerate?: (id: string) => void;
onDelete?: (id: string) => void; onDelete?: (id: string) => void;
hideReEdit?: boolean;
onPrev?: () => void; onPrev?: () => void;
onNext?: () => void; onNext?: () => void;
hasPrev?: boolean; hasPrev?: boolean;
hasNext?: boolean; hasNext?: boolean;
} }
export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDelete, onPrev, onNext, hasPrev, hasNext, hideReEdit }: Props) { export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDelete, onPrev, onNext, hasPrev, hasNext }: Props) {
const navigate = useNavigate();
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const videoContainerRef = useRef<HTMLDivElement>(null); const videoContainerRef = useRef<HTMLDivElement>(null);
const videoAreaRef = useRef<HTMLDivElement>(null); const videoAreaRef = useRef<HTMLDivElement>(null);
@ -35,17 +30,19 @@ 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 // Parse aspect ratio from task; for 'adaptive', use video's intrinsic ratio
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] : (intrinsicRatio || 16 / 9); return (parts[0] && parts[1]) ? parts[0] / parts[1] : 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
@ -203,35 +200,9 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
}; };
const handleReEdit = () => { const handleReEdit = () => {
if (!task) return; if (task && onReEdit) {
if (onReEdit) {
onReEdit(task.id); onReEdit(task.id);
onClose(); onClose();
} else {
// Fallback: load task into input bar and navigate to generation page
const store = useInputBarStore.getState();
store.reset();
store.setPrompt(task.prompt || '');
if (task.mode) store.setMode(task.mode as 'universal' | 'keyframe');
if (task.model) store.setModel(task.model as 'seedance_2.0' | 'seedance_2.0_fast');
if (task.aspectRatio) store.setAspectRatio(task.aspectRatio as any);
if (task.duration) store.setDuration(task.duration);
// Load references from task
if (task.references && task.references.length > 0) {
const refs = task.references.filter(r => r.previewUrl).map(r => ({
id: r.id,
file: null as unknown as File,
previewUrl: r.previewUrl,
type: r.type as 'image' | 'video' | 'audio',
label: r.label,
tosUrl: r.previewUrl,
}));
if (refs.length > 0) {
useInputBarStore.setState({ references: refs });
}
}
onClose();
navigate('/app');
} }
}; };
@ -487,7 +458,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
</svg> </svg>
</div> </div>
) : ( ) : (
<img src={ref.previewUrl} alt={ref.label} className={styles.refImg} style={{ cursor: 'zoom-in' }} onClick={() => setLightboxSrc(ref.previewUrl)} /> <img src={ref.previewUrl} alt={ref.label} className={styles.refImg} />
)} )}
<span className={styles.refLabel}>{ref.label}</span> <span className={styles.refLabel}>{ref.label}</span>
</div> </div>
@ -497,17 +468,6 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
)} )}
</div> </div>
{/* Re-edit button above info bar */}
{!hideReEdit && <div style={{ padding: '0 20px 12px', borderBottom: '1px solid rgba(255,255,255,0.08)' }}>
<button className={styles.cardBtn} onClick={handleReEdit} style={{ width: '100%', justifyContent: 'center' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<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="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
</div>}
{/* Fixed bottom: info bar + actions card */} {/* Fixed bottom: info bar + actions card */}
<div className={styles.infoPanelBottom}> <div className={styles.infoPanelBottom}>
<div className={styles.infoBar}> <div className={styles.infoBar}>
@ -517,15 +477,7 @@ 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}</span> <span>{task.aspectRatio === 'adaptive' ? '自适应' : 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) && (
@ -562,7 +514,6 @@ 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,20 +162,20 @@ export const adminApi = {
getTeams: () => getTeams: () =>
api.get<{ results: Team[] }>('/admin/teams'), api.get<{ results: Team[] }>('/admin/teams'),
createTeam: (data: { name: string; monthly_spending_limit?: number; daily_member_limit_default?: number; expected_regions: string; markup_percentage?: number }) => createTeam: (data: { name: string; monthly_seconds_limit?: number; daily_member_limit_default?: number; expected_regions: string }) =>
api.post('/admin/teams/create', data), api.post('/admin/teams/create', data),
getTeamDetail: (teamId: number) => getTeamDetail: (teamId: number) =>
api.get<TeamDetail>(`/admin/teams/${teamId}`), api.get<TeamDetail>(`/admin/teams/${teamId}`),
updateTeam: (teamId: number, data: { name?: string; monthly_seconds_limit?: number; monthly_spending_limit?: number; daily_member_limit_default?: number; 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, amount: number) => topUpTeam: (teamId: number, seconds: number) =>
api.post(`/admin/teams/${teamId}/topup`, { amount }), api.post(`/admin/teams/${teamId}/topup`, { seconds }),
setTeamPool: (teamId: number, balance: number) => setTeamPool: (teamId: number, totalSecondsPool: number) =>
api.put(`/admin/teams/${teamId}/set-pool`, { balance }), api.put(`/admin/teams/${teamId}/set-pool`, { total_seconds_pool: totalSecondsPool }),
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_generation_limit?: number; daily_seconds_limit?: number;
monthly_generation_limit?: number; monthly_seconds_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_generation_limit: daily, daily_seconds_limit: daily,
monthly_generation_limit: monthly, monthly_seconds_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_generation_limit?: number; monthly_generation_limit?: number }) => createMember: (data: { username: string; password: string; daily_seconds_limit?: number; monthly_seconds_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_generation_limit: daily, daily_seconds_limit: daily,
monthly_generation_limit: monthly, monthly_seconds_limit: monthly,
}), }),
updateMemberStatus: (memberId: number, isActive: boolean) => updateMemberStatus: (memberId: number, isActive: boolean) =>

View File

@ -1,4 +1,4 @@
.page { max-width: none; } .page { max-width: 1200px; }
.title { font-size: 22px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 24px; } .title { font-size: 22px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 24px; }
/* Stats bar */ /* Stats bar */

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 formatCost(val: number) { function formatSeconds(s: number) {
return `¥${(val || 0).toFixed(2)}`; return `${s.toLocaleString()}s`;
} }
function VideoThumbnail({ video, onClick }: { video: AssetVideo; onClick: () => void }) { function VideoThumbnail({ video, onClick }: { video: AssetVideo; onClick: () => void }) {
@ -32,13 +32,6 @@ function VideoThumbnail({ video, onClick }: { video: AssetVideo; onClick: () =>
} }
function assetVideoToTask(v: AssetVideo): GenerationTask { function assetVideoToTask(v: AssetVideo): GenerationTask {
const references = (v.reference_urls || []).map((ref, i) => ({
id: `ref_${v.task_id}_${i}`,
type: (ref.type || 'image') as 'image' | 'video',
previewUrl: ref.url,
label: ref.label || `素材${i + 1}`,
role: ref.role,
}));
return { return {
id: String(v.id), id: String(v.id),
taskId: v.task_id, taskId: v.task_id,
@ -48,7 +41,7 @@ function assetVideoToTask(v: AssetVideo): GenerationTask {
model: 'seedance_2.0', model: 'seedance_2.0',
aspectRatio: (v.aspect_ratio as any) || '16:9', aspectRatio: (v.aspect_ratio as any) || '16:9',
duration: v.duration as any, duration: v.duration as any,
references, references: [],
status: 'completed', status: 'completed',
progress: 100, progress: 100,
resultUrl: v.result_url, resultUrl: v.result_url,
@ -143,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}>{formatCost(overview.total_seconds)}</div> <div className={styles.statValue}>{formatSeconds(overview.total_seconds)}</div>
</div> </div>
<div className={styles.statCard}> <div className={styles.statCard}>
<div className={styles.statLabel}></div> <div className={styles.statLabel}></div>
@ -160,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}>{formatCost(team.cost_consumed ?? team.seconds_consumed)}</span> <span className={styles.accordionBadge}>{formatSeconds(team.seconds_consumed)}</span>
</div> </div>
</div> </div>
{expandedTeam === team.id && ( {expandedTeam === team.id && (
@ -176,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}>{formatCost(member.cost_consumed ?? member.seconds_consumed)}</span> <span className={styles.accordionBadge}>{formatSeconds(member.seconds_consumed)}</span>
</div> </div>
</div> </div>
{expandedMember === member.id && memberVideos[member.id] && ( {expandedMember === member.id && memberVideos[member.id] && (
@ -219,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}>{formatCost(overview.no_team.seconds_consumed)}</span> <span className={styles.accordionBadge}>{formatSeconds(overview.no_team.seconds_consumed)}</span>
</div> </div>
</div> </div>
</div> </div>
@ -229,7 +222,6 @@ export function AdminAssetsPage() {
<VideoDetailModal <VideoDetailModal
task={detailTask} task={detailTask}
onClose={() => setDetailTask(null)} onClose={() => setDetailTask(null)}
hideReEdit
/> />
</div> </div>
); );

View File

@ -115,7 +115,7 @@ export function AdminLayout() {
{pwModalOpen && ( {pwModalOpen && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }} <div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}
onClick={() => setPwModalOpen(false)}> onClick={() => setPwModalOpen(false)}>
<div style={{ background: '#16161e', borderRadius: '12px', padding: '24px', width: '360px', border: '1px solid var(--color-border-card)' }} <div style={{ background: 'var(--color-bg-card)', borderRadius: '12px', padding: '24px', width: '360px', border: '1px solid var(--color-border-card)' }}
onClick={(e) => e.stopPropagation()}> onClick={(e) => e.stopPropagation()}>
<h3 style={{ margin: '0 0 16px', color: 'var(--color-text-primary)', fontSize: '16px' }}></h3> <h3 style={{ margin: '0 0 16px', color: 'var(--color-text-primary)', fontSize: '16px' }}></h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>

View File

@ -1,4 +1,4 @@
.page { max-width: none; } .page { max-width: 1200px; }
.title { font-size: 22px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 20px; } .title { font-size: 22px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 20px; }
.filters { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; flex-wrap: wrap; } .filters { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; flex-wrap: wrap; }

View File

@ -24,23 +24,15 @@ 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

@ -1,5 +1,5 @@
.page { .page {
max-width: none; max-width: 1200px;
} }
.title { .title {
@ -108,15 +108,8 @@
50% { opacity: 0.5; } 50% { opacity: 0.5; }
} }
.chartsRow {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 1024px) { @media (max-width: 1024px) {
.statsGrid { grid-template-columns: repeat(2, 1fr); } .statsGrid { grid-template-columns: repeat(2, 1fr); }
.chartsRow { grid-template-columns: 1fr; }
} }
@media (max-width: 640px) { @media (max-width: 640px) {

View File

@ -45,10 +45,10 @@ export function DashboardPage() {
if (!stats) return null; if (!stats) return null;
const statCards = [ const statCards = [
{ label: '总团队数', value: String(stats.total_teams), change: null }, { label: '总团队数', value: stats.total_teams, change: null },
{ label: '总用户数', value: String(stats.total_users), change: null }, { label: '总用户数', value: stats.total_users, change: null },
{ label: '今日消费', value: `¥${(stats.cost_today || 0).toFixed(2)}`, change: stats.today_change_percent }, { label: '今日消费秒数', value: stats.seconds_consumed_today, change: stats.today_change_percent },
{ label: '本月消费', value: `¥${(stats.cost_this_month || 0).toFixed(2)}`, change: stats.month_change_percent }, { label: '本月消费秒数', value: stats.seconds_consumed_this_month, 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.toFixed(2)}`; return `${p.name}<br/>消费: ${p.value}s`;
}, },
}, },
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.cost), data: stats.daily_trend.map((d) => d.seconds),
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.cost_consumed || 0) - (b.cost_consumed || 0)); const sortedTeams = [...(stats.top_teams || [])].sort((a, b) => a.seconds_consumed - b.seconds_consumed);
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.cost_consumed || 0), data: sortedTeams.map((t) => t.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, [
@ -127,12 +127,12 @@ export function DashboardPage() {
position: 'right', position: 'right',
color: '#8b8ea8', color: '#8b8ea8',
fontSize: 11, fontSize: 11,
formatter: (p: { value: number }) => `¥${p.value.toFixed(2)}`, formatter: '{c}s',
}, },
}], }],
}; };
const sortedUsers = [...stats.top_users].sort((a, b) => (a.cost_consumed || 0) - (b.cost_consumed || 0)); const sortedUsers = [...stats.top_users].sort((a, b) => a.seconds_consumed - b.seconds_consumed);
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.cost_consumed || 0), data: sortedUsers.map((u) => u.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, [
@ -169,7 +169,7 @@ export function DashboardPage() {
position: 'right', position: 'right',
color: '#8b8ea8', color: '#8b8ea8',
fontSize: 11, fontSize: 11,
formatter: (p: { value: number }) => `¥${p.value.toFixed(2)}`, formatter: '{c}s',
}, },
}], }],
}; };
@ -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}</div> <div className={styles.statValue}>{card.value.toLocaleString()}{card.label.includes('秒') ? 's' : ''}</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>
@ -193,51 +193,27 @@ export function DashboardPage() {
))} ))}
</div> </div>
{/* Row 2: Profit cards */}
<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>
{/* Row 3: Trend chart (full width) */}
<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>
</div> </div>
{/* Row 4: Team + User ranking (two columns) */} {sortedTeams.length > 0 && (
<div className={styles.chartsRow}>
{sortedTeams.length > 0 && (
<div className={styles.chartSection}>
<h2 className={styles.sectionTitle}></h2>
<div className={styles.chartWrapper}>
<ReactEChartsCore echarts={echarts} option={teamBarOption} style={{ height: Math.max(300, sortedTeams.length * 36) }} />
</div>
</div>
)}
<div className={styles.chartSection}> <div className={styles.chartSection}>
<h2 className={styles.sectionTitle}>Top 10 · </h2> <h2 className={styles.sectionTitle}></h2>
<div className={styles.chartWrapper}> <div className={styles.chartWrapper}>
<ReactEChartsCore echarts={echarts} option={barOption} style={{ height: Math.max(300, sortedUsers.length * 36) }} /> <ReactEChartsCore echarts={echarts} option={teamBarOption} style={{ height: Math.max(200, sortedTeams.length * 36) }} />
</div> </div>
</div> </div>
)}
<div className={styles.chartSection}>
<h2 className={styles.sectionTitle}>Top 10 · </h2>
<div className={styles.chartWrapper}>
<ReactEChartsCore echarts={echarts} option={barOption} style={{ height: Math.max(300, sortedUsers.length * 36) }} />
</div>
</div> </div>
</div> </div>
); );

View File

@ -99,13 +99,11 @@ export function ProfilePage() {
); );
} }
const dailyGenLimit = overview.daily_generation_limit || 0; const dailyPercent = overview.daily_seconds_limit > 0 ? (overview.daily_seconds_used / overview.daily_seconds_limit) * 100 : 0;
const dailyGenUsed = overview.daily_generation_used || 0; const monthlyPercent = overview.monthly_seconds_limit > 0 ? (overview.monthly_seconds_used / overview.monthly_seconds_limit) * 100 : 0;
const monthlyGenLimit = overview.monthly_generation_limit || 0;
const monthlyGenUsed = overview.monthly_generation_used || 0;
const dailyPercent = dailyGenLimit > 0 ? (dailyGenUsed / dailyGenLimit) * 100 : 0; const totalRemaining = Math.max(0, overview.monthly_seconds_limit - overview.total_seconds_used);
const monthlyPercent = monthlyGenLimit > 0 ? (monthlyGenUsed / monthlyGenLimit) * 100 : 0; const totalPercent = overview.monthly_seconds_limit > 0 ? (overview.total_seconds_used / overview.monthly_seconds_limit) * 100 : 0;
const sparklineOption: echarts.EChartsCoreOption = { const sparklineOption: echarts.EChartsCoreOption = {
tooltip: { tooltip: {
@ -164,37 +162,38 @@ 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}>{dailyGenUsed} / {dailyGenLimit === -1 ? '不限' : dailyGenLimit + '次'}</div> <div className={styles.quotaValue}>: {overview.total_seconds_used.toLocaleString()}s / {overview.monthly_seconds_limit.toLocaleString()}s</div>
<div className={styles.progressBar}>
<div className={styles.progressFill} style={{
width: `${Math.min(totalPercent, 100)}%`,
background: totalPercent > 80 ? (totalPercent >= 100 ? 'var(--color-danger)' : 'var(--color-warning)') : 'var(--color-primary)',
}} />
</div>
<div className={styles.quotaPercent}> {totalRemaining.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}> ¥{(overview.daily_spent || 0).toFixed(2)}</div> <div className={styles.quotaPercent}>{dailyPercent.toFixed(1)}%</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}>{monthlyGenUsed} / {monthlyGenLimit === -1 ? '不限' : monthlyGenLimit + '次'}</div> <div className={styles.quotaValue}>: {overview.monthly_seconds_used.toLocaleString()}s / {overview.monthly_seconds_limit.toLocaleString()}s</div>
<div className={styles.progressBar}> <div className={styles.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}> ¥{(overview.monthly_spent || 0).toFixed(2)}</div> <div className={styles.quotaPercent}>{monthlyPercent.toFixed(1)}%</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>
@ -226,7 +225,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.cost_amount || 0).toFixed(2)}</span> <span className={styles.recordSeconds}>{r.seconds_consumed.toLocaleString()}s</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

@ -1,4 +1,4 @@
.page { max-width: none; } .page { max-width: 1200px; }
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; } .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.title { font-size: 22px; font-weight: 600; color: var(--color-text-primary); } .title { font-size: 22px; font-weight: 600; color: var(--color-text-primary); }
.exportBtn { .exportBtn {
@ -32,7 +32,7 @@
} }
.table { width: 100%; border-collapse: collapse; font-size: 13px; } .table { width: 100%; border-collapse: collapse; font-size: 13px; }
.table th { padding: 12px 16px; text-align: left; color: var(--color-text-secondary); font-weight: 500; border-bottom: 1px solid var(--color-border-card); white-space: nowrap; } .table th { padding: 12px 16px; text-align: left; color: var(--color-text-secondary); font-weight: 500; border-bottom: 1px solid var(--color-border-card); white-space: nowrap; }
.table td { padding: 12px 16px; color: var(--color-text-primary); border-bottom: 1px solid rgba(42, 42, 56, 0.5); white-space: nowrap; } .table td { padding: 12px 16px; color: var(--color-text-primary); border-bottom: 1px solid rgba(42, 42, 56, 0.5); }
.table tr:last-child td { border-bottom: none; } .table tr:last-child td { border-bottom: none; }
.table tr:hover td { background: rgba(255, 255, 255, 0.02); } .table tr:hover td { background: rgba(255, 255, 255, 0.02); }
.timeCell { white-space: nowrap; font-size: 12px; color: var(--color-text-secondary); } .timeCell { white-space: nowrap; font-size: 12px; color: var(--color-text-secondary); }

View File

@ -58,15 +58,14 @@ export function RecordsPage() {
team_id: teamFilter ? Number(teamFilter) : undefined, team_id: teamFilter ? Number(teamFilter) : undefined,
}); });
const header = '时间,团队,用户名,消费秒数,Tokens,费用(元),成本(元),利润(元),提示词,生成模式,状态,失败原因\n'; const header = '时间,团队,用户名,消费秒数,提示词,生成模式,状态,失败原因\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(/^[=+\-@]/, "'$&");
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}","${prompt}","${modeLabel}","${statusLabel}","${errorMsg}"`;
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;' });
@ -121,10 +120,6 @@ 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>
@ -134,13 +129,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: 11 }).map((_, j) => ( {Array.from({ length: 7 }).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={11} className={styles.empty}></td></tr> <tr><td colSpan={7} className={styles.empty}></td></tr>
) : ( ) : (
records.map((r) => ( records.map((r) => (
<tr key={r.id}> <tr key={r.id}>
@ -148,10 +143,6 @@ 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

@ -1,11 +1,9 @@
.page { max-width: none; } .page { max-width: 720px; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.gridFull { grid-column: 1 / -1; }
.title { font-size: 22px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 24px; } .title { font-size: 22px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 24px; }
.card { .card {
background: var(--color-bg-card); border: 1px solid var(--color-border-card); background: var(--color-bg-card); border: 1px solid var(--color-border-card);
border-radius: var(--radius-card); padding: 24px; border-radius: var(--radius-card); padding: 24px; margin-bottom: 20px;
} }
.cardHeader { display: flex; justify-content: space-between; align-items: flex-start; } .cardHeader { display: flex; justify-content: space-between; align-items: flex-start; }
.cardTitle { font-size: 16px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 4px; } .cardTitle { font-size: 16px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 4px; }
@ -49,9 +47,6 @@
} }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
@media (max-width: 900px) {
.grid { grid-template-columns: 1fr; }
}
@media (max-width: 640px) { @media (max-width: 640px) {
.formRow { grid-template-columns: 1fr; } .formRow { grid-template-columns: 1fr; }
} }

View File

@ -8,9 +8,6 @@ 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,
@ -101,37 +98,27 @@ export function SettingsPage() {
<div className={styles.page}> <div className={styles.page}>
<h1 className={styles.title}></h1> <h1 className={styles.title}></h1>
<div className={styles.grid}>
<div className={styles.card}> <div className={styles.card}>
<h2 className={styles.cardTitle}></h2> <h2 className={styles.cardTitle}></h2>
<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_generation_limit} value={settings.default_daily_seconds_limit}
onChange={(e) => setSettings({ ...settings, default_daily_generation_limit: Number(e.target.value) })} onChange={(e) => setSettings({ ...settings, default_daily_seconds_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_generation_limit} value={settings.default_monthly_seconds_limit}
onChange={(e) => setSettings({ ...settings, default_monthly_generation_limit: Number(e.target.value) })} onChange={(e) => setSettings({ ...settings, default_monthly_seconds_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>
@ -166,7 +153,7 @@ export function SettingsPage() {
</button> </button>
</div> </div>
<div className={`${styles.card} ${styles.gridFull}`}> <div className={styles.card}>
<div className={styles.cardHeader}> <div className={styles.cardHeader}>
<div> <div>
<h2 className={styles.cardTitle}></h2> <h2 className={styles.cardTitle}></h2>
@ -196,7 +183,7 @@ export function SettingsPage() {
</button> </button>
</div> </div>
<div className={`${styles.card} ${styles.gridFull}`}> <div className={styles.card}>
<div className={styles.cardHeader}> <div className={styles.cardHeader}>
<div> <div>
<h2 className={styles.cardTitle}></h2> <h2 className={styles.cardTitle}></h2>
@ -354,7 +341,6 @@ export function SettingsPage() {
{saving ? '保存中...' : '保存异常检测设置'} {saving ? '保存中...' : '保存异常检测设置'}
</button> </button>
</div> </div>
</div>
</div> </div>
); );
} }

View File

@ -32,13 +32,6 @@ function VideoThumbnail({ video, onClick }: { video: AssetVideo; onClick: () =>
} }
function assetVideoToTask(v: AssetVideo): GenerationTask { function assetVideoToTask(v: AssetVideo): GenerationTask {
const references = (v.reference_urls || []).map((ref, i) => ({
id: `ref_${v.task_id}_${i}`,
type: (ref.type || 'image') as 'image' | 'video',
previewUrl: ref.url,
label: ref.label || `素材${i + 1}`,
role: ref.role,
}));
return { return {
id: String(v.id), id: String(v.id),
taskId: v.task_id, taskId: v.task_id,
@ -48,7 +41,7 @@ function assetVideoToTask(v: AssetVideo): GenerationTask {
model: 'seedance_2.0', model: 'seedance_2.0',
aspectRatio: (v.aspect_ratio as any) || '16:9', aspectRatio: (v.aspect_ratio as any) || '16:9',
duration: v.duration as any, duration: v.duration as any,
references, references: [],
status: 'completed', status: 'completed',
progress: 100, progress: 100,
resultUrl: v.result_url, resultUrl: v.result_url,

View File

@ -49,14 +49,14 @@ export function TeamDashboardPage() {
if (!info || !stats) return null; if (!info || !stats) return null;
const fmtMoney = (val: number) => '¥' + (val || 0).toFixed(2); const formatLimit = (v: number) => v === -1 ? '不限' : v.toLocaleString() + 's';
const statCards = [ const statCards = [
{ label: '余额', value: fmtMoney(info.balance) }, { label: '总秒数池', value: formatLimit(info.total_seconds_pool) },
{ label: '累计消费', value: fmtMoney(info.total_spent) }, { label: '已使用', value: info.total_seconds_used.toLocaleString() + 's' },
{ label: '可用余额', value: fmtMoney(info.available_balance) }, { label: '剩余', value: info.remaining_seconds.toLocaleString() + 's' },
{ label: '月消费限额', value: fmtMoney(info.monthly_spending_limit) }, { label: '月限额', value: formatLimit(info.monthly_seconds_limit) },
{ label: '本月消费', value: fmtMoney(info.monthly_spent) }, { label: '本月已用', value: info.monthly_seconds_used.toLocaleString() + 's' },
]; ];
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.toFixed(2)}`; return `${p.name}<br/>消费: ${p.value}s`;
}, },
}, },
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.cost ?? d.seconds), data: stats.daily_trend.map((d) => 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.cost_consumed ?? a.seconds_consumed) - (b.cost_consumed ?? b.seconds_consumed)); const sortedMembers = [...stats.member_consumption].sort((a, b) => a.seconds_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.cost_consumed ?? m.seconds_consumed), data: sortedMembers.map((m) => 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: (p: { value: number }) => `¥${p.value.toFixed(2)}`, formatter: '{c}s',
}, },
}], }],
}; };
@ -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('50'); const [newDaily, setNewDaily] = useState('600');
const [newMonthly, setNewMonthly] = useState('500'); const [newMonthly, setNewMonthly] = useState('6000');
const [createError, setCreateError] = useState(''); const [createError, setCreateError] = useState('');
// Confirm toggle // Confirm toggle
@ -39,8 +39,7 @@ export function TeamMembersPage() {
useEffect(() => { fetchMembers(); }, [fetchMembers]); useEffect(() => { fetchMembers(); }, [fetchMembers]);
const formatLimit = (v: number) => v === -1 ? '不限' : v + '次'; const formatLimit = (v: number) => v === -1 ? '不限' : v.toLocaleString() + 's';
const fmtMoney = (val: number) => '¥' + (val || 0).toFixed(2);
const handleToggleStatus = async (member: TeamMember) => { const handleToggleStatus = async (member: TeamMember) => {
try { try {
@ -54,8 +53,8 @@ export function TeamMembersPage() {
const openEditModal = (member: TeamMember) => { const openEditModal = (member: TeamMember) => {
setEditMember(member); setEditMember(member);
setEditDaily(String(member.daily_generation_limit ?? 50)); setEditDaily(String(member.daily_seconds_limit));
setEditMonthly(String(member.monthly_generation_limit ?? 500)); setEditMonthly(String(member.monthly_seconds_limit));
}; };
const handleSaveQuota = async () => { const handleSaveQuota = async () => {
@ -72,7 +71,7 @@ export function TeamMembersPage() {
const resetCreateForm = () => { const resetCreateForm = () => {
setNewUsername(''); setNewPassword(''); setNewUsername(''); setNewPassword('');
setNewDaily('50'); setNewMonthly('500'); setNewDaily('600'); setNewMonthly('6000');
setCreateError(''); setCreateError('');
}; };
@ -84,8 +83,8 @@ export function TeamMembersPage() {
await teamApi.createMember({ await teamApi.createMember({
username: newUsername.trim(), username: newUsername.trim(),
password: newPassword, password: newPassword,
daily_generation_limit: Number(newDaily), daily_seconds_limit: Number(newDaily),
monthly_generation_limit: Number(newMonthly), monthly_seconds_limit: Number(newMonthly),
}); });
showToast('成员创建成功'); showToast('成员创建成功');
setCreateOpen(false); setCreateOpen(false);
@ -117,10 +116,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>
@ -151,10 +150,10 @@ export function TeamMembersPage() {
{m.is_active ? '启用' : '禁用'} {m.is_active ? '启用' : '禁用'}
</span> </span>
</td> </td>
<td>{formatLimit(m.daily_generation_limit)}</td> <td>{formatLimit(m.daily_seconds_limit)}</td>
<td>{formatLimit(m.monthly_generation_limit)}</td> <td>{formatLimit(m.monthly_seconds_limit)}</td>
<td>{(m.generations_today || 0) + '次 / ' + fmtMoney(m.spent_today)}</td> <td>{m.seconds_today.toLocaleString()}s</td>
<td>{(m.generations_this_month || 0) + '次 / ' + fmtMoney(m.spent_this_month)}</td> <td>{m.seconds_this_month.toLocaleString()}s</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>
@ -191,11 +190,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}>
@ -221,11 +220,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

@ -1,4 +1,4 @@
.page { max-width: none; } .page { max-width: 1200px; }
.title { font-size: 22px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 20px; } .title { font-size: 22px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 20px; }
.filters { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; gap: 12px; flex-wrap: wrap; } .filters { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; gap: 12px; flex-wrap: wrap; }
@ -22,7 +22,7 @@
} }
.table { width: 100%; border-collapse: collapse; font-size: 13px; } .table { width: 100%; border-collapse: collapse; font-size: 13px; }
.table th { padding: 12px 16px; text-align: left; color: var(--color-text-secondary); font-weight: 500; border-bottom: 1px solid var(--color-border-card); white-space: nowrap; } .table th { padding: 12px 16px; text-align: left; color: var(--color-text-secondary); font-weight: 500; border-bottom: 1px solid var(--color-border-card); white-space: nowrap; }
.table td { padding: 12px 16px; color: var(--color-text-primary); border-bottom: 1px solid rgba(42, 42, 56, 0.5); white-space: nowrap; } .table td { padding: 12px 16px; color: var(--color-text-primary); border-bottom: 1px solid rgba(42, 42, 56, 0.5); }
.table tr:last-child td { border-bottom: none; } .table tr:last-child td { border-bottom: none; }
.table tr:hover td { background: rgba(255, 255, 255, 0.02); } .table tr:hover td { background: rgba(255, 255, 255, 0.02); }

View File

@ -6,11 +6,6 @@ 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 {
if (val === -1) return '不限';
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';
} }
@ -22,15 +17,14 @@ 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('10000'); const [newMonthlyLimit, setNewMonthlyLimit] = useState('36000');
const [newDailyMemberLimit, setNewDailyMemberLimit] = useState('50'); const [newDailyMemberLimit, setNewDailyMemberLimit] = useState('600');
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 [topupAmount, setTopupAmount] = useState('1000'); const [topupSeconds, setTopupSeconds] = useState('3600');
// Create admin modal // Create admin modal
const [adminTeam, setAdminTeam] = useState<Team | null>(null); const [adminTeam, setAdminTeam] = useState<Team | null>(null);
@ -81,16 +75,12 @@ export function TeamsPage() {
const [learnOpen, setLearnOpen] = useState(false); const [learnOpen, setLearnOpen] = useState(false);
const [editingRegions, setEditingRegions] = useState(false); const [editingRegions, setEditingRegions] = useState(false);
const [editRegionsValue, setEditRegionsValue] = useState(''); const [editRegionsValue, setEditRegionsValue] = useState('');
const [editingMonthlyLimit, setEditingMonthlyLimit] = useState(false);
const [editMonthlyLimitValue, setEditMonthlyLimitValue] = useState('');
const [editingMarkup, setEditingMarkup] = useState(false);
const [editMarkupValue, setEditMarkupValue] = useState('');
const [editingAnomalyConfig, setEditingAnomalyConfig] = useState(false); const [editingAnomalyConfig, setEditingAnomalyConfig] = useState(false);
const [anomalyConfigDraft, setAnomalyConfigDraft] = useState<Record<string, any>>({}); const [anomalyConfigDraft, setAnomalyConfigDraft] = useState<Record<string, any>>({});
const resetCreateForm = () => { const resetCreateForm = () => {
setNewName(''); setNewMonthlyLimit('10000'); setNewDailyMemberLimit('50'); setNewName(''); setNewMonthlyLimit('36000'); setNewDailyMemberLimit('600');
setNewExpectedRegions(''); setNewMarkup('30'); setCreateError(''); setNewExpectedRegions(''); setCreateError('');
}; };
const handleCreateTeam = async () => { const handleCreateTeam = async () => {
@ -100,10 +90,9 @@ export function TeamsPage() {
try { try {
await adminApi.createTeam({ await adminApi.createTeam({
name: newName.trim(), name: newName.trim(),
monthly_spending_limit: Number(newMonthlyLimit), monthly_seconds_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);
@ -117,11 +106,11 @@ export function TeamsPage() {
const handleTopUp = async () => { const handleTopUp = async () => {
if (!topupTeam) return; if (!topupTeam) return;
const amount = Number(topupAmount); const seconds = Number(topupSeconds);
if (!amount || amount <= 0) { showToast('请输入有效的金额'); return; } if (!seconds || seconds <= 0) { showToast('请输入有效的秒数'); return; }
try { try {
await adminApi.topUpTeam(topupTeam.id, amount); await adminApi.topUpTeam(topupTeam.id, seconds);
showToast(`已为 ${topupTeam.name} 充值 ${fmtMoney(amount)}`); showToast(`已为 ${topupTeam.name} 充值 ${fmtSec(seconds)}`);
setTopupTeam(null); setTopupTeam(null);
fetchTeams(); fetchTeams();
} catch { } catch {
@ -131,11 +120,11 @@ export function TeamsPage() {
const handleSetPool = async () => { const handleSetPool = async () => {
if (!detailTeam) return; if (!detailTeam) return;
const newBalance = Number(editPoolValue); const newPool = Number(editPoolValue);
if (isNaN(newBalance) || newBalance < 0) { setEditPoolError('请输入有效的非负数'); return; } if (isNaN(newPool) || newPool < 0) { setEditPoolError('请输入有效的非负数'); return; }
try { try {
await adminApi.setTeamPool(detailTeam.id, newBalance); await adminApi.setTeamPool(detailTeam.id, newPool);
showToast(`已将 ${detailTeam.name} 余额修改为 ${fmtMoney(newBalance)}`); showToast(`已将 ${detailTeam.name} 总秒数池修改为 ${fmtSec(newPool)}`);
setEditPoolOpen(false); setEditPoolOpen(false);
// Refresh detail // Refresh detail
const { data } = await adminApi.getTeamDetail(detailTeam.id); const { data } = await adminApi.getTeamDetail(detailTeam.id);
@ -233,10 +222,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>
@ -262,11 +251,11 @@ export function TeamsPage() {
{t.name} {t.name}
</button> </button>
</td> </td>
<td>{fmtMoney(t.balance)}</td> <td>{fmtSec(t.total_seconds_pool)}</td>
<td>{fmtMoney(t.total_spent)}</td> <td>{fmtSec(t.total_seconds_used)}</td>
<td>{fmtMoney(t.available_balance)}</td> <td>{fmtSec(t.remaining_seconds)}</td>
<td>{fmtMoney(t.monthly_spending_limit)}</td> <td>{fmtSec(t.monthly_seconds_limit)}</td>
<td>{fmtMoney(t.monthly_spent)}</td> <td>{fmtSec(t.monthly_seconds_used)}</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}`}>
@ -280,7 +269,7 @@ export function TeamsPage() {
</td> </td>
<td> <td>
<div className={styles.actions}> <div className={styles.actions}>
<button className={styles.topupBtn} onClick={() => { setTopupTeam(t); setTopupAmount('1000'); }}></button> <button className={styles.topupBtn} onClick={() => { setTopupTeam(t); setTopupSeconds('3600'); }}></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}`}
@ -308,18 +297,14 @@ 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="广州市,深圳市,北京市" />
@ -337,12 +322,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={topupAmount} onChange={(e) => setTopupAmount(e.target.value)} placeholder="输入金额" /> <input type="number" value={topupSeconds} onChange={(e) => setTopupSeconds(e.target.value)} placeholder="输入秒数" />
<div className={styles.formHint}> <div className={styles.formHint}>
: {fmtMoney(topupTeam.balance)} | : {fmtMoney((topupTeam.balance || 0) + (Number(topupAmount) || 0))} : {fmtSec(topupTeam.remaining_seconds)} | : {fmtSec(topupTeam.remaining_seconds + (Number(topupSeconds) || 0))}
</div> </div>
</div> </div>
<div className={styles.modalActions}> <div className={styles.modalActions}>
@ -416,13 +401,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}>
{fmtMoney(detailTeam.balance)} {fmtSec(detailTeam.total_seconds_pool)}
<button <button
className={styles.editPoolBtn} className={styles.editPoolBtn}
onClick={() => { setEditPoolValue(String(detailTeam.balance || 0)); setEditPoolError(''); setEditPoolOpen(true); }} onClick={() => { setEditPoolValue(String(detailTeam.total_seconds_pool)); 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" />
@ -432,130 +417,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}>{fmtMoney(detailTeam.total_spent)}</span> <span className={styles.detailValue}>{fmtSec(detailTeam.total_seconds_used)}</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}> <span className={styles.detailValue}>{fmtSec(detailTeam.remaining_seconds)}</span>
{editingMonthlyLimit ? (
<span style={{ display: 'inline-flex', gap: 6, alignItems: 'center', flexWrap: 'nowrap' }}>
<input
type="number"
value={editMonthlyLimitValue}
onChange={(e) => setEditMonthlyLimitValue(e.target.value)}
style={{ width: 80, padding: '3px 6px', borderRadius: 4, border: '1px solid var(--color-border-card)', background: 'var(--color-bg-page)', color: 'var(--color-text-primary)', fontSize: 13 }}
/>
<button
className={styles.topupBtn}
onClick={async () => {
const val = Number(editMonthlyLimitValue);
if (isNaN(val) || (val < 0 && val !== -1)) { showToast('请输入有效金额,-1为不限制'); return; }
try {
await adminApi.updateTeam(detailTeam.id, { monthly_spending_limit: val });
setDetailTeam({ ...detailTeam, monthly_spending_limit: val });
setTeams(teams.map(t => t.id === detailTeam.id ? { ...t, monthly_spending_limit: val } : t));
setEditingMonthlyLimit(false);
showToast('月消费限额已更新');
} catch (e: any) {
showToast(e.response?.data?.error || '保存失败');
}
}}
style={{ fontSize: 12, padding: '4px 10px', whiteSpace: 'nowrap' }}
>
</button>
<button
className={styles.topupBtn}
onClick={() => setEditingMonthlyLimit(false)}
style={{ fontSize: 12, padding: '4px 10px', whiteSpace: 'nowrap' }}
>
</button>
</span>
) : (
<>
{fmtMoney(detailTeam.monthly_spending_limit)}
<button
className={styles.topupBtn}
onClick={() => { setEditingMonthlyLimit(true); setEditMonthlyLimitValue(String(detailTeam.monthly_spending_limit || 0)); }}
style={{ fontSize: 12, padding: '4px 10px', marginLeft: 8 }}
>
</button>
</>
)}
</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}>{fmtMoney(detailTeam.available_balance)}</span> <span className={styles.detailValue}>{fmtSec(detailTeam.monthly_seconds_limit)}</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>{fmtMoney(detailTeam.frozen_amount)}</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}>{fmtMoney(detailTeam.monthly_spent)}</span> <span className={styles.detailValue}>{fmtSec(detailTeam.monthly_seconds_used)}</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>
{editingMarkup ? (
<span style={{ display: 'inline-flex', gap: 6, alignItems: 'center', flexWrap: 'nowrap' }}>
<input
type="number"
value={editMarkupValue}
onChange={(e) => setEditMarkupValue(e.target.value)}
style={{ width: 80, padding: '3px 6px', borderRadius: 4, border: '1px solid var(--color-border-card)', background: 'var(--color-bg-page)', color: 'var(--color-text-primary)', fontSize: 13 }}
/>
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>%</span>
<button
className={styles.topupBtn}
onClick={async () => {
const val = Number(editMarkupValue);
if (isNaN(val) || val < 0) { showToast('请输入有效的加价百分比'); return; }
try {
await adminApi.updateTeam(detailTeam.id, { markup_percentage: val });
setDetailTeam({ ...detailTeam, markup_percentage: val });
setTeams(teams.map(t => t.id === detailTeam.id ? { ...t, markup_percentage: val } : t));
setEditingMarkup(false);
showToast('加价率已更新');
} catch (e: any) {
showToast(e.response?.data?.error || '保存失败');
}
}}
style={{ fontSize: 12, padding: '4px 10px', whiteSpace: 'nowrap' }}
>
</button>
<button
className={styles.topupBtn}
onClick={() => setEditingMarkup(false)}
style={{ fontSize: 12, padding: '4px 10px', whiteSpace: 'nowrap' }}
>
</button>
</span>
) : (
<>
{(detailTeam.markup_percentage || 0)}%
<button
className={styles.topupBtn}
onClick={() => { setEditingMarkup(true); setEditMarkupValue(String(detailTeam.markup_percentage || 0)); }}
style={{ fontSize: 12, padding: '4px 10px', marginLeft: 8 }}
>
</button>
</>
)}
</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>
@ -603,14 +487,14 @@ export function TeamsPage() {
alert(e.response?.data?.error || '保存失败'); alert(e.response?.data?.error || '保存失败');
} }
}} }}
style={{ fontSize: 12, padding: '4px 10px', whiteSpace: 'nowrap' }} style={{ fontSize: 12, padding: '4px 12px' }}
> >
</button> </button>
<button <button
className={styles.topupBtn} className={styles.topupBtn}
onClick={() => setEditingRegions(false)} onClick={() => setEditingRegions(false)}
style={{ fontSize: 12, padding: '4px 10px', whiteSpace: 'nowrap' }} style={{ fontSize: 12, padding: '4px 12px' }}
> >
</button> </button>
@ -747,10 +631,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>
<th>/</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -773,10 +656,9 @@ export function TeamsPage() {
</span> </span>
)} )}
</td> </td>
<td>{m.daily_generation_limit === -1 ? '不限' : (m.daily_generation_limit || 0) + '次'}</td> <td>{fmtSec(m.daily_seconds_limit)}</td>
<td>{m.monthly_generation_limit === -1 ? '不限' : (m.monthly_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>
@ -825,13 +707,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}>
: {fmtMoney(detailTeam.balance)} | : {fmtMoney(detailTeam.total_spent)} : {fmtSec(detailTeam.total_seconds_pool)} | : {fmtSec(detailTeam.total_seconds_used)} | : {fmtSec(Math.max(0, (Number(editPoolValue) || 0) - detailTeam.total_seconds_used))}
</div> </div>
</div> </div>
<div className={styles.modalActions}> <div className={styles.modalActions}>

View File

@ -1,4 +1,4 @@
.page { max-width: none; } .page { max-width: 1200px; }
.title { font-size: 22px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 20px; } .title { font-size: 22px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 20px; }
.filters { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; gap: 12px; flex-wrap: wrap; } .filters { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; gap: 12px; flex-wrap: wrap; }
@ -28,7 +28,7 @@
} }
.table { width: 100%; border-collapse: collapse; font-size: 13px; } .table { width: 100%; border-collapse: collapse; font-size: 13px; }
.table th { padding: 12px 16px; text-align: left; color: var(--color-text-secondary); font-weight: 500; border-bottom: 1px solid var(--color-border-card); white-space: nowrap; } .table th { padding: 12px 16px; text-align: left; color: var(--color-text-secondary); font-weight: 500; border-bottom: 1px solid var(--color-border-card); white-space: nowrap; }
.table td { padding: 12px 16px; color: var(--color-text-primary); border-bottom: 1px solid rgba(42, 42, 56, 0.5); white-space: nowrap; } .table td { padding: 12px 16px; color: var(--color-text-primary); border-bottom: 1px solid rgba(42, 42, 56, 0.5); }
.table tr:last-child td { border-bottom: none; } .table tr:last-child td { border-bottom: none; }
.table tr:hover td { background: rgba(255, 255, 255, 0.02); } .table tr:hover td { background: rgba(255, 255, 255, 0.02); }

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('50'); const [newDaily, setNewDaily] = useState('600');
const [newMonthly, setNewMonthly] = useState('500'); const [newMonthly, setNewMonthly] = useState('6000');
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_generation_limit ?? 50)); setEditDaily(String(user.daily_seconds_limit));
setEditMonthly(String(user.monthly_generation_limit ?? 500)); setEditMonthly(String(user.monthly_seconds_limit));
}; };
const handleSaveQuota = async () => { const handleSaveQuota = async () => {
@ -112,7 +112,7 @@ export function UsersPage() {
const resetCreateForm = () => { const resetCreateForm = () => {
setNewUsername(''); setNewEmail(''); setNewPassword(''); setNewUsername(''); setNewEmail(''); setNewPassword('');
setNewDaily('50'); setNewMonthly('500'); setNewIsStaff(false); setNewDaily('600'); setNewMonthly('6000'); 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_generation_limit: Number(newDaily), daily_seconds_limit: Number(newDaily),
monthly_generation_limit: Number(newMonthly), monthly_seconds_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_generation_limit ?? -1) === -1 ? '不限' : u.daily_generation_limit + '次'}</td> <td>{u.daily_seconds_limit === -1 ? '不限' : u.daily_seconds_limit.toLocaleString() + 's'}</td>
<td>{(u.monthly_generation_limit ?? -1) === -1 ? '不限' : u.monthly_generation_limit + '次'}</td> <td>{u.monthly_seconds_limit === -1 ? '不限' : u.monthly_seconds_limit.toLocaleString() + 's'}</td>
<td>{(u.generations_today || 0) + '次 / ¥' + (u.spent_today || 0).toFixed(2)}</td> <td>{u.seconds_today.toLocaleString()}s</td>
<td>{(u.generations_this_month || 0) + '次 / ¥' + (u.spent_this_month || 0).toFixed(2)}</td> <td>{u.seconds_this_month.toLocaleString()}s</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>-1 </label> <label></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></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,24 +415,16 @@ 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.generations_today || 0)} / {(detailUser.daily_generation_limit ?? -1) === -1 ? '不限' : detailUser.daily_generation_limit + '次'}</span> <span className={styles.detailValue}>{detailUser.seconds_today.toLocaleString()}s / {detailUser.daily_seconds_limit === -1 ? '不限' : detailUser.daily_seconds_limit.toLocaleString() + 's'}</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.generations_this_month || 0)} / {(detailUser.monthly_generation_limit ?? -1) === -1 ? '不限' : detailUser.monthly_generation_limit + '次'}</span> <span className={styles.detailValue}>{detailUser.seconds_this_month.toLocaleString()}s / {detailUser.monthly_seconds_limit === -1 ? '不限' : detailUser.monthly_seconds_limit.toLocaleString() + 's'}</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>¥{(detailUser.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.total_spent || 0).toFixed(2)}</span> <span className={styles.detailValue}>{detailUser.seconds_total.toLocaleString()}s</span>
</div> </div>
</div> </div>
@ -445,7 +437,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.cost_amount || 0).toFixed(2)}</span> <span className={styles.recordSeconds}>{r.seconds_consumed.toLocaleString()}s</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

@ -75,12 +75,10 @@ function backendToFrontend(bt: BackendTask): GenerationTask {
duration: bt.duration as GenerationTask['duration'], duration: bt.duration as GenerationTask['duration'],
references, references,
status: mapStatus(bt.status), status: mapStatus(bt.status),
progress: bt.status === 'processing' ? Number(sessionStorage.getItem(`progress_${bt.task_id}`) || mapProgress(bt.status)) : mapProgress(bt.status), progress: mapProgress(bt.status),
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,
}; };
} }
@ -105,9 +103,7 @@ function ensureSmoothProgress() {
if (t.status !== 'generating') return t; if (t.status !== 'generating') return t;
// Decelerate: fast at start, slow near end // Decelerate: fast at start, slow near end
const increment = t.progress < 30 ? 2 : t.progress < 60 ? 1 : 0.5; const increment = t.progress < 30 ? 2 : t.progress < 60 ? 1 : 0.5;
const newProgress = Math.min(t.progress + increment, 95); return { ...t, progress: Math.min(t.progress + increment, 95) };
if (t.taskId) sessionStorage.setItem(`progress_${t.taskId}`, String(newProgress));
return { ...t, progress: newProgress };
}), }),
})); }));
}, 2000); }, 2000);
@ -148,7 +144,6 @@ function startPolling(taskId: string, frontendId: string) {
if (newStatus === 'completed' || newStatus === 'failed') { if (newStatus === 'completed' || newStatus === 'failed') {
pollTimers.delete(frontendId); pollTimers.delete(frontendId);
sessionStorage.removeItem(`progress_${taskId}`);
if (newStatus === 'completed') { if (newStatus === 'completed') {
useAuthStore.getState().fetchUserInfo(); useAuthStore.getState().fetchUserInfo();
} }

View File

@ -214,7 +214,7 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
mode, mode,
prevReferences: state.references, prevReferences: state.references,
references: [], references: [],
aspectRatio: '16:9', aspectRatio: 'adaptive',
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'; export type AspectRatio = '16:9' | '9:16' | '1:1' | '21:9' | '4:3' | '3:4' | 'adaptive';
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,8 +46,6 @@ 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 {
@ -60,9 +58,6 @@ 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;
@ -90,13 +85,6 @@ 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;
} }
@ -106,10 +94,6 @@ 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 {
@ -124,20 +108,11 @@ 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; cost: number; base_cost: number }[]; daily_trend: { date: string; seconds: number }[];
top_users: { user_id: number; username: string; seconds_consumed: number; cost_consumed: number }[]; top_users: { user_id: number; username: string; seconds_consumed: number }[];
top_teams: { team_id: number; name: string; seconds_consumed: number; cost_consumed: number }[]; top_teams: { team_id: number; name: string; seconds_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 {
@ -155,17 +130,10 @@ 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[];
} }
@ -176,9 +144,6 @@ 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;
@ -190,9 +155,6 @@ 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;
@ -222,13 +184,6 @@ 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;
@ -237,12 +192,6 @@ 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;
}; };
} }
@ -262,13 +211,6 @@ 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;
@ -308,12 +250,6 @@ 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;
} }
@ -337,8 +273,8 @@ export interface LoginAnomaly {
} }
export interface TeamStats { export interface TeamStats {
daily_trend: { date: string; seconds: number; cost?: number }[]; daily_trend: { date: string; seconds: number }[];
member_consumption: { user_id: number; username: string; seconds_consumed: number; cost_consumed?: number }[]; member_consumption: { user_id: number; username: string; seconds_consumed: number }[];
} }
// Asset management types // Asset management types
@ -347,7 +283,6 @@ 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;
} }
@ -358,7 +293,6 @@ 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 {
@ -368,9 +302,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;
reference_urls?: { url: string; type: string; role: string; label: string }[];
created_at: string; created_at: string;
} }