feat: v0.10.0 计费体系重构 — 秒数→金额+次数,token追踪,利润分析

## 计费体系
- 团队额度从秒数改为金额(余额/冻结/月消费上限)
- 用户限额从秒数改为次数(每日50次/每月1500次)
- 新增 billing.py 工具模块(分辨率→像素映射 + token/费用计算)
- 扣费流程:预扣制→冻结制(提交冻结预估金额,完成按实际tokens扣费,失败释放)
- 允许小额透支(实际费用超预估时余额可变负)
- 团队加价比例(markup_percentage),创建团队时必填

## Token 追踪
- GenerationRecord 新增 tokens_consumed/cost_amount/base_cost_amount
- 任务完成时从 Seedance API usage.total_tokens 获取精确值
- 生成页显示预估消耗(tokens + 金额),按团队售价计算

## 管理后台
- 仪表盘新增利润分析板块(总收入/成本/利润/利润率 + 团队利润排行)
- 消费记录新增 Tokens/售价/成本/利润列
- 团队管理:充值改为充金额,新增加价比例设置
- 系统设置:默认限额改为次数,新增基础token单价配置

## Bug 修复
- 登录弹窗:拖选输入框内容不再误关闭(onClick→mousedown+mouseup)
- 视频详情弹窗:遮罩层覆盖全视口(left:76px→0),admin/团管侧栏不再露出

## UI 增强
- 图片大图预览:上传区和视频详情弹窗的图片支持点击查看大图(ImageLightbox)
- 移除 adaptive 比例和智能时长选项,确保 token 预估可精确计算
- 视频详情弹窗显示实际消耗 tokens 和费用

## 前端全量更新
- 所有页面秒数显示替换为金额(元)和次数(次)
- TypeScript 类型全量更新
- API 调用参数同步更新

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-03-20 20:32:12 +08:00
parent 277de4651f
commit 9259988094
31 changed files with 1354 additions and 317 deletions

View File

@ -0,0 +1,53 @@
# Generated by Django 4.2.29 on 2026-03-20 11:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0008_anomaly_detection_phase2'),
]
operations = [
migrations.AddField(
model_name='team',
name='balance',
field=models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='团队余额(元)'),
),
migrations.AddField(
model_name='team',
name='daily_member_spending_default',
field=models.DecimalField(decimal_places=2, default=50, max_digits=12, verbose_name='新成员默认每日消费限额(元)'),
),
migrations.AddField(
model_name='team',
name='frozen_amount',
field=models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='冻结金额(元)'),
),
migrations.AddField(
model_name='team',
name='markup_percentage',
field=models.DecimalField(decimal_places=2, default=0, max_digits=5, verbose_name='加价百分比'),
),
migrations.AddField(
model_name='team',
name='monthly_spending_limit',
field=models.DecimalField(decimal_places=2, default=-1, max_digits=12, verbose_name='每月消费上限(元)'),
),
migrations.AddField(
model_name='team',
name='total_spent',
field=models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='已消费总额(元)'),
),
migrations.AddField(
model_name='user',
name='daily_generation_limit',
field=models.IntegerField(default=50, verbose_name='每日生成次数上限'),
),
migrations.AddField(
model_name='user',
name='monthly_generation_limit',
field=models.IntegerField(default=1500, verbose_name='每月生成次数上限'),
),
]

View File

@ -0,0 +1,52 @@
# Data migration: populate new billing fields from existing seconds-based data
from django.db import migrations
def forward(apps, schema_editor):
Team = apps.get_model('accounts', 'Team')
User = apps.get_model('accounts', 'User')
QuotaConfig = apps.get_model('generation', 'QuotaConfig')
# Teams: set balance=0 (admin will manually top up), spending limit=-1 (unlimited)
for team in Team.objects.all():
team.balance = 0
team.total_spent = 0
team.monthly_spending_limit = -1
team.daily_member_spending_default = 50
team.frozen_amount = 0
team.markup_percentage = 0
team.save(update_fields=[
'balance', 'total_spent', 'monthly_spending_limit',
'daily_member_spending_default', 'frozen_amount', 'markup_percentage',
])
# Users: set generation limits
User.objects.all().update(
daily_generation_limit=50,
monthly_generation_limit=1500,
)
# QuotaConfig: set defaults
config, _ = QuotaConfig.objects.get_or_create(pk=1)
config.default_daily_generation_limit = 50
config.default_monthly_generation_limit = 1500
config.base_token_price = 46
config.save(update_fields=[
'default_daily_generation_limit', 'default_monthly_generation_limit', 'base_token_price',
])
def backward(apps, schema_editor):
pass # No rollback needed, old seconds fields are untouched
class Migration(migrations.Migration):
dependencies = [
('accounts', '0009_billing_system_v010'),
('generation', '0007_billing_system_v010'),
]
operations = [
migrations.RunPython(forward, backward),
]

View File

@ -11,6 +11,13 @@ class Team(models.Model):
total_seconds_used = models.FloatField(default=0, verbose_name='已消耗总秒数')
monthly_seconds_limit = models.IntegerField(default=6000, verbose_name='每月消费上限(秒)')
daily_member_limit_default = models.IntegerField(default=600, verbose_name='新成员默认每日限额(秒)')
# ── 金额计费字段v0.10.0 新增) ──
balance = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name='团队余额(元)')
total_spent = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name='已消费总额(元)')
monthly_spending_limit = models.DecimalField(max_digits=12, decimal_places=2, default=-1, verbose_name='每月消费上限(元)')
daily_member_spending_default = models.DecimalField(max_digits=12, decimal_places=2, default=50, verbose_name='新成员默认每日消费限额(元)')
frozen_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name='冻结金额(元)')
markup_percentage = models.DecimalField(max_digits=5, decimal_places=2, default=0, verbose_name='加价百分比')
is_active = models.BooleanField(default=True, verbose_name='启用状态')
expected_regions = models.CharField(max_length=500, blank=True, default='', verbose_name='预期登录城市(逗号分隔)')
disabled_by = models.CharField(max_length=10, blank=True, default='', verbose_name='禁用来源')
@ -28,6 +35,10 @@ class Team(models.Model):
def remaining_seconds(self):
return self.total_seconds_pool - self.total_seconds_used
@property
def available_balance(self):
return self.balance - self.frozen_amount
class User(AbstractUser):
"""Extended user model — Phase 5: team-based quota."""
@ -41,6 +52,9 @@ class User(AbstractUser):
is_team_admin = models.BooleanField(default=False, verbose_name='团队管理员')
daily_seconds_limit = models.IntegerField(default=600, verbose_name='每日秒数上限')
monthly_seconds_limit = models.IntegerField(default=6000, verbose_name='每月秒数上限')
# ── 次数限额v0.10.0 新增) ──
daily_generation_limit = models.IntegerField(default=50, verbose_name='每日生成次数上限')
monthly_generation_limit = models.IntegerField(default=1500, verbose_name='每月生成次数上限')
must_change_password = models.BooleanField(default=True, verbose_name='必须修改密码')
disabled_by = models.CharField(max_length=10, blank=True, default='', verbose_name='禁用来源')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')

View File

@ -5,7 +5,7 @@ from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from django.contrib.auth import authenticate, get_user_model
from django.utils import timezone
from django.db.models import Sum
from django.db.models import Sum, Count
from .serializers import UserSerializer
from .models import ActiveSession, LoginRecord, get_client_ip, parse_device_type
@ -170,24 +170,45 @@ def me_view(request):
created_at__date__gte=first_of_month
).aggregate(total=Sum('seconds_consumed'))['total'] or 0
# Count-based usage
daily_generation_used = user.generation_records.filter(
created_at__date=today
).count()
monthly_generation_used = user.generation_records.filter(
created_at__date__gte=first_of_month
).count()
data = UserSerializer(user).data
data['quota'] = {
'daily_seconds_limit': user.daily_seconds_limit,
'daily_seconds_used': daily_seconds_used,
'monthly_seconds_limit': user.monthly_seconds_limit,
'monthly_seconds_used': monthly_seconds_used,
'daily_generation_limit': user.daily_generation_limit,
'daily_generation_used': daily_generation_used,
'monthly_generation_limit': user.monthly_generation_limit,
'monthly_generation_used': monthly_generation_used,
}
# Team info
team = user.team
if team:
# Team monthly consumption
from apps.generation.models import GenerationRecord
from apps.generation.models import GenerationRecord, QuotaConfig
team_monthly_used = GenerationRecord.objects.filter(
user__team=team,
created_at__date__gte=first_of_month,
).aggregate(total=Sum('seconds_consumed'))['total'] or 0
team_monthly_spent = GenerationRecord.objects.filter(
user__team=team,
created_at__date__gte=first_of_month,
).aggregate(total=Sum('cost_amount'))['total'] or 0
config = QuotaConfig.objects.get_or_create(pk=1)[0]
token_price = float(config.base_token_price) * (1 + float(team.markup_percentage) / 100)
data['team'] = {
'id': team.id,
'name': team.name,
@ -196,6 +217,13 @@ def me_view(request):
'remaining_seconds': team.remaining_seconds,
'monthly_seconds_limit': team.monthly_seconds_limit,
'monthly_seconds_used': team_monthly_used,
'balance': float(team.balance),
'total_spent': float(team.total_spent),
'available_balance': float(team.available_balance),
'monthly_spending_limit': float(team.monthly_spending_limit),
'monthly_spent': float(team_monthly_spent),
'frozen_amount': float(team.frozen_amount),
'token_price': token_price,
'is_active': team.is_active,
}
data['team_disabled'] = not team.is_active

View File

@ -0,0 +1,53 @@
# Generated by Django 4.2.29 on 2026-03-20 11:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('generation', '0006_anomaly_detection_phase2'),
]
operations = [
migrations.AddField(
model_name='generationrecord',
name='base_cost_amount',
field=models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='平台成本(元)'),
),
migrations.AddField(
model_name='generationrecord',
name='cost_amount',
field=models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='用户费用(元)'),
),
migrations.AddField(
model_name='generationrecord',
name='frozen_amount',
field=models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='冻结金额(元)'),
),
migrations.AddField(
model_name='generationrecord',
name='resolution',
field=models.CharField(blank=True, default='', max_length=10, verbose_name='分辨率'),
),
migrations.AddField(
model_name='generationrecord',
name='tokens_consumed',
field=models.IntegerField(default=0, verbose_name='消耗tokens'),
),
migrations.AddField(
model_name='quotaconfig',
name='base_token_price',
field=models.DecimalField(decimal_places=2, default=46, max_digits=10, verbose_name='基础token单价(元/百万tokens)'),
),
migrations.AddField(
model_name='quotaconfig',
name='default_daily_generation_limit',
field=models.IntegerField(default=50, verbose_name='默认每日生成次数'),
),
migrations.AddField(
model_name='quotaconfig',
name='default_monthly_generation_limit',
field=models.IntegerField(default=1500, verbose_name='默认每月生成次数'),
),
]

View File

@ -34,6 +34,12 @@ class GenerationRecord(models.Model):
aspect_ratio = models.CharField(max_length=10, verbose_name='宽高比')
duration = models.IntegerField(verbose_name='视频时长(秒)')
seconds_consumed = models.FloatField(default=0, verbose_name='消费秒数')
# ── 金额计费字段v0.10.0 新增) ──
tokens_consumed = models.IntegerField(default=0, verbose_name='消耗tokens')
cost_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name='用户费用(元)')
base_cost_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name='平台成本(元)')
frozen_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name='冻结金额(元)')
resolution = models.CharField(max_length=10, blank=True, default='', verbose_name='分辨率')
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='queued', verbose_name='状态')
result_url = models.CharField(max_length=1000, blank=True, default='', verbose_name='生成结果URL')
error_message = models.TextField(blank=True, default='', verbose_name='错误信息')
@ -77,6 +83,10 @@ class QuotaConfig(models.Model):
feishu_alert_mobiles = models.CharField(max_length=500, blank=True, default='', verbose_name='飞书告警接收人手机号')
sms_alert_mobiles = models.CharField(max_length=500, blank=True, default='', verbose_name='短信告警手机号(预留)')
alert_cooldown_seconds = models.IntegerField(default=1800, verbose_name='告警冷却时间(秒)')
# ── 计费全局配置v0.10.0 新增) ──
default_daily_generation_limit = models.IntegerField(default=50, verbose_name='默认每日生成次数')
default_monthly_generation_limit = models.IntegerField(default=1500, verbose_name='默认每月生成次数')
base_token_price = models.DecimalField(max_digits=10, decimal_places=2, default=46, verbose_name='基础token单价(元/百万tokens)')
updated_at = models.DateTimeField(auto_now=True)
class Meta:

View File

@ -11,8 +11,8 @@ class VideoGenerateSerializer(serializers.Serializer):
class QuotaUpdateSerializer(serializers.Serializer):
daily_seconds_limit = serializers.IntegerField(min_value=-1)
monthly_seconds_limit = serializers.IntegerField(min_value=-1)
daily_generation_limit = serializers.IntegerField(min_value=-1)
monthly_generation_limit = serializers.IntegerField(min_value=-1)
class UserStatusSerializer(serializers.Serializer):
@ -25,12 +25,17 @@ class AdminCreateUserSerializer(serializers.Serializer):
password = serializers.CharField(min_length=6)
daily_seconds_limit = serializers.IntegerField(min_value=-1, required=False, default=600)
monthly_seconds_limit = serializers.IntegerField(min_value=-1, required=False, default=6000)
daily_generation_limit = serializers.IntegerField(min_value=-1, required=False, default=50)
monthly_generation_limit = serializers.IntegerField(min_value=-1, required=False, default=1500)
is_staff = serializers.BooleanField(required=False, default=False)
class SystemSettingsSerializer(serializers.Serializer):
default_daily_seconds_limit = serializers.IntegerField(min_value=0)
default_monthly_seconds_limit = serializers.IntegerField(min_value=0)
default_daily_seconds_limit = serializers.IntegerField(min_value=0, required=False)
default_monthly_seconds_limit = serializers.IntegerField(min_value=0, required=False)
default_daily_generation_limit = serializers.IntegerField(min_value=0, required=False)
default_monthly_generation_limit = serializers.IntegerField(min_value=0, required=False)
base_token_price = serializers.DecimalField(max_digits=10, decimal_places=2, min_value=0, required=False)
announcement = serializers.CharField(required=False, allow_blank=True, default='')
announcement_enabled = serializers.BooleanField(required=False, default=False)
max_desktop_sessions = serializers.IntegerField(min_value=1, required=False, default=1)
@ -60,6 +65,9 @@ class TeamCreateSerializer(serializers.Serializer):
name = serializers.CharField(max_length=100)
monthly_seconds_limit = serializers.IntegerField(min_value=0, required=False, default=6000)
daily_member_limit_default = serializers.IntegerField(min_value=0, required=False, default=600)
markup_percentage = serializers.DecimalField(max_digits=5, decimal_places=2, min_value=0, required=True)
monthly_spending_limit = serializers.DecimalField(max_digits=12, decimal_places=2, required=False, default=-1)
daily_member_spending_default = serializers.DecimalField(max_digits=12, decimal_places=2, required=False, default=50)
expected_regions = serializers.CharField(max_length=500, required=True)
@ -67,6 +75,9 @@ class TeamUpdateSerializer(serializers.Serializer):
name = serializers.CharField(max_length=100, required=False)
monthly_seconds_limit = serializers.IntegerField(min_value=0, required=False)
daily_member_limit_default = serializers.IntegerField(min_value=0, required=False)
markup_percentage = serializers.DecimalField(max_digits=5, decimal_places=2, min_value=0, required=False)
monthly_spending_limit = serializers.DecimalField(max_digits=12, decimal_places=2, required=False)
daily_member_spending_default = serializers.DecimalField(max_digits=12, decimal_places=2, required=False)
is_active = serializers.BooleanField(required=False)
expected_regions = serializers.CharField(max_length=500, required=False, allow_blank=True)
@ -87,7 +98,7 @@ class TeamAnomalyConfigSerializer(serializers.Serializer):
class TeamTopUpSerializer(serializers.Serializer):
seconds = serializers.IntegerField(min_value=1)
amount = serializers.DecimalField(max_digits=12, decimal_places=2, min_value=0.01)
class TeamAdminCreateSerializer(serializers.Serializer):
@ -103,8 +114,10 @@ class TeamMemberCreateSerializer(serializers.Serializer):
password = serializers.CharField(min_length=6)
daily_seconds_limit = serializers.IntegerField(min_value=-1, required=False)
monthly_seconds_limit = serializers.IntegerField(min_value=-1, required=False)
daily_generation_limit = serializers.IntegerField(min_value=-1, required=False)
monthly_generation_limit = serializers.IntegerField(min_value=-1, required=False)
class MemberQuotaSerializer(serializers.Serializer):
daily_seconds_limit = serializers.IntegerField(min_value=-1)
monthly_seconds_limit = serializers.IntegerField(min_value=-1)
daily_generation_limit = serializers.IntegerField(min_value=-1)
monthly_generation_limit = serializers.IntegerField(min_value=-1)

File diff suppressed because it is too large Load Diff

69
backend/utils/billing.py Normal file
View File

@ -0,0 +1,69 @@
"""
计费工具模块 分辨率映射 + token/费用计算
Token 预估公式火山官方( × × 帧率 × 时长) / 1024
单价 / 百万 tokens
"""
from decimal import Decimal, ROUND_HALF_UP
# 分辨率 → 像素映射(来自 Seedance 2.0 API 文档)
RESOLUTION_MAP = {
# 720p
('720p', '16:9'): (1280, 720),
('720p', '9:16'): (720, 1280),
('720p', '4:3'): (1112, 834),
('720p', '1:1'): (960, 960),
('720p', '3:4'): (834, 1112),
('720p', '21:9'): (1470, 630),
# 480p
('480p', '16:9'): (864, 496),
('480p', '9:16'): (496, 864),
('480p', '4:3'): (752, 560),
('480p', '1:1'): (640, 640),
('480p', '3:4'): (560, 752),
('480p', '21:9'): (992, 432),
}
# 默认帧率
DEFAULT_FPS = 24
def get_resolution(aspect_ratio: str, tier: str = '720p') -> tuple:
"""根据宽高比和分辨率档位返回 (width, height) 像素值。"""
return RESOLUTION_MAP.get((tier, aspect_ratio), (1280, 720))
def estimate_tokens(width: int, height: int, duration: int, fps: int = DEFAULT_FPS) -> int:
"""预估视频生成消耗的 tokens。"""
return round(width * height * fps * duration / 1024)
def calculate_cost(tokens: int, base_price, markup_percentage) -> Decimal:
"""计算用户费用(加价后)。
Args:
tokens: 消耗的 tokens
base_price: 成本价/百万tokens
markup_percentage: 加价百分比 20 表示 20%
Returns:
Decimal: 加价后费用保留 2 位小数
"""
base_price = Decimal(str(base_price))
markup = Decimal(str(markup_percentage))
team_price = base_price * (1 + markup / 100)
cost = Decimal(str(tokens)) * team_price / Decimal('1000000')
return cost.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
def calculate_base_cost(tokens: int, base_price) -> Decimal:
"""计算平台成本(不加价)。
Args:
tokens: 消耗的 tokens
base_price: 成本价/百万tokens
Returns:
Decimal: 成本费用保留 2 位小数
"""
base_price = Decimal(str(base_price))
cost = Decimal(str(tokens)) * base_price / Decimal('1000000')
return cost.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)

View File

@ -237,7 +237,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
{task.model === 'seedance_2.0' ? 'AirDrama' : 'AirDrama Fast'}
</span>
<span className={styles.label}>{task.duration}s</span>
<span className={styles.label}>{task.aspectRatio === 'adaptive' ? '自适应' : task.aspectRatio}</span>
<span className={styles.label}>{task.aspectRatio}</span>
<span
ref={detailLinkRef}
className={styles.detailLink}
@ -258,7 +258,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
{detailHover && (
<div className={styles.detailTooltip} style={{ top: detailPos.top, right: detailPos.right }}>
<div className={styles.detailRow}>
<span></span><span>{task.aspectRatio === 'adaptive' ? '自适应' : task.aspectRatio}</span>
<span></span><span>{task.aspectRatio}</span>
</div>
<div className={styles.detailRow}>
<span></span><span>{task.duration}s</span>

View File

@ -0,0 +1,17 @@
.overlay {
position: fixed;
inset: 0;
z-index: 400;
background: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
cursor: zoom-out;
}
.image {
max-width: 90vw;
max-height: 90vh;
object-fit: contain;
border-radius: 8px;
cursor: default;
}

View File

@ -0,0 +1,24 @@
import { useEffect } from 'react';
import styles from './ImageLightbox.module.css';
interface Props {
src: string | null;
onClose: () => void;
}
export function ImageLightbox({ src, onClose }: Props) {
useEffect(() => {
if (!src) return;
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [src, onClose]);
if (!src) return null;
return (
<div className={styles.overlay} onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<img src={src} alt="" className={styles.image} />
</div>
);
}

View File

@ -38,8 +38,14 @@ export function LoginModal({ isOpen, onClose, onSuccess }: Props) {
if (!isOpen) return null;
return (
<div className={styles.overlay} onClick={onClose}>
<div className={styles.panel} onClick={(e) => e.stopPropagation()}>
<div className={styles.overlay}
onMouseDown={(e) => { if (e.target === e.currentTarget) (e.currentTarget as HTMLElement).dataset.mouseDownOnOverlay = 'true'; }}
onMouseUp={(e) => {
if ((e.currentTarget as HTMLElement).dataset.mouseDownOnOverlay === 'true' && e.target === e.currentTarget) onClose();
(e.currentTarget as HTMLElement).dataset.mouseDownOnOverlay = '';
}}
>
<div className={styles.panel}>
<button className={styles.closeBtn} onClick={onClose} aria-label="关闭">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" />

View File

@ -1,6 +1,7 @@
import { useEffect, useCallback } from 'react';
import { useEffect, useCallback, useMemo } from 'react';
import { useInputBarStore } from '../store/inputBar';
import { useGenerationStore } from '../store/generation';
import { useAuthStore } from '../store/auth';
import { Dropdown } from './Dropdown';
import type { CreationMode, AspectRatio, Duration, GenerationType, ModelOption } from '../types';
import styles from './Toolbar.module.css';
@ -89,7 +90,6 @@ const ratioItems = [
];
const keyframeRatioItems = [
{ label: '自适应', value: 'adaptive' as AspectRatio },
...ratioItems,
];
@ -98,6 +98,11 @@ const durationItems = Array.from({ length: 12 }, (_, i) => {
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> = {
universal: '全能参考',
keyframe: '首尾帧',
@ -118,9 +123,19 @@ export function Toolbar() {
const triggerInsertAt = useInputBarStore((s) => s.triggerInsertAt);
const isKeyframe = mode === 'keyframe';
const tokenPrice = useAuthStore((s) => s.team?.token_price) || 0;
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(() => {
if (!isSubmittable) return;
addTask();
@ -188,7 +203,7 @@ export function Toolbar() {
trigger={
<button className={styles.btn}>
<MonitorIcon />
<span className={styles.label}>{aspectRatio === 'adaptive' ? '自适应' : aspectRatio}</span>
<span className={styles.label}>{aspectRatio}</span>
</button>
}
/>
@ -214,6 +229,16 @@ export function Toolbar() {
</button>
)}
{/* Estimated cost */}
{tokenPrice > 0 && (
<span
style={{ fontSize: 12, color: '#8b8ea8', whiteSpace: 'nowrap', userSelect: 'none' }}
title={`预估公式: (宽 x 高 x 24fps x 时长) / 1024 = tokens, tokens x 单价 / 1000000 = 费用`}
>
{estimatedTokens.toLocaleString()} tokens / ¥{estimatedCost}
</span>
)}
{/* Spacer */}
<div className={styles.spacer} />

View File

@ -1,6 +1,7 @@
import { useRef, useState } from 'react';
import { useInputBarStore } from '../store/inputBar';
import { showToast } from './Toast';
import { ImageLightbox } from './ImageLightbox';
import styles from './UniversalUpload.module.css';
const MAX_IMAGE_SIZE = 30 * 1024 * 1024; // 30MB per API doc
@ -26,6 +27,7 @@ export function UniversalUpload() {
const fileInputRef = useRef<HTMLInputElement>(null);
const [expanded, setExpanded] = useState(false);
const [badgeHover, setBadgeHover] = useState(false);
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
const handleTrigger = () => {
fileInputRef.current?.click();
@ -122,7 +124,7 @@ export function UniversalUpload() {
<AudioIcon />
</div>
) : (
<img src={ref.previewUrl} alt={ref.label} className={styles.thumbMedia} />
<img src={ref.previewUrl} alt={ref.label} className={styles.thumbMedia} style={{ cursor: 'zoom-in' }} onClick={(e) => { e.stopPropagation(); setLightboxSrc(ref.previewUrl); }} />
)}
<div
className={styles.thumbClose}
@ -172,6 +174,7 @@ export function UniversalUpload() {
)}
</>
)}
<ImageLightbox src={lightboxSrc} onClose={() => setLightboxSrc(null)} />
</div>
);
}

View File

@ -3,7 +3,7 @@
top: 0;
right: 0;
bottom: 0;
left: 76px; /* sidebar width */
left: 0;
z-index: 200;
background: #07070f;
display: flex;

View File

@ -2,6 +2,7 @@ import { useRef, useState, useEffect, useCallback, useMemo } from 'react';
import type { GenerationTask } from '../types';
import { AmbientBackground } from './AmbientBackground';
import { ConfirmModal } from './ConfirmModal';
import { ImageLightbox } from './ImageLightbox';
import styles from './VideoDetailModal.module.css';
interface Props {
@ -30,19 +31,17 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
const [isFullscreen, setIsFullscreen] = useState(false);
const [showMoreMenu, setShowMoreMenu] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
const [fitSize, setFitSize] = useState<{ w: number; h: number } | null>(null);
const [intrinsicRatio, setIntrinsicRatio] = useState<number | null>(null);
const moreMenuRef = useRef<HTMLDivElement>(null);
const hideTimerRef = useRef<ReturnType<typeof setTimeout>>();
// Parse aspect ratio from task; for 'adaptive', use video's intrinsic ratio
// Parse aspect ratio from task
const arNum = useMemo(() => {
const ar = task?.aspectRatio || '16:9';
if (ar === 'adaptive') {
return intrinsicRatio || 16 / 9;
}
const parts = ar.split(':').map(Number);
return (parts[0] && parts[1]) ? parts[0] / parts[1] : 16 / 9;
return (parts[0] && parts[1]) ? parts[0] / parts[1] : (intrinsicRatio || 16 / 9);
}, [task?.aspectRatio, intrinsicRatio]);
// Compute container size to fit aspect ratio within videoArea
@ -458,7 +457,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
</svg>
</div>
) : (
<img src={ref.previewUrl} alt={ref.label} className={styles.refImg} />
<img src={ref.previewUrl} alt={ref.label} className={styles.refImg} style={{ cursor: 'zoom-in' }} onClick={() => setLightboxSrc(ref.previewUrl)} />
)}
<span className={styles.refLabel}>{ref.label}</span>
</div>
@ -477,7 +476,15 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
<span className={styles.infoBarDot} />
<span>{task.duration}s</span>
<span className={styles.infoBarDot} />
<span>{task.aspectRatio === 'adaptive' ? '自适应' : task.aspectRatio}</span>
<span>{task.aspectRatio}</span>
{(task.tokensConsumed ?? 0) > 0 && (
<>
<span className={styles.infoBarDot} />
<span>{(task.tokensConsumed ?? 0).toLocaleString()} tokens</span>
<span className={styles.infoBarDot} />
<span>¥{(task.costAmount ?? 0).toFixed(2)}</span>
</>
)}
</div>
{(onReEdit || onRegenerate) && (
@ -514,6 +521,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
onConfirm={doDelete}
onCancel={() => setConfirmDelete(false)}
/>
<ImageLightbox src={lightboxSrc} onClose={() => setLightboxSrc(null)} />
</div>
</div>
);

View File

@ -162,7 +162,7 @@ export const adminApi = {
getTeams: () =>
api.get<{ results: Team[] }>('/admin/teams'),
createTeam: (data: { name: string; monthly_seconds_limit?: number; daily_member_limit_default?: number; expected_regions: string }) =>
createTeam: (data: { name: string; monthly_spending_limit?: number; daily_member_limit_default?: number; expected_regions: string; markup_percentage?: number }) =>
api.post('/admin/teams/create', data),
getTeamDetail: (teamId: number) =>
@ -171,11 +171,11 @@ export const adminApi = {
updateTeam: (teamId: number, data: { name?: string; monthly_seconds_limit?: number; daily_member_limit_default?: number; is_active?: boolean; expected_regions?: string; anomaly_config?: Partial<TeamAnomalyConfig> }) =>
api.put(`/admin/teams/${teamId}`, data),
topUpTeam: (teamId: number, seconds: number) =>
api.post(`/admin/teams/${teamId}/topup`, { seconds }),
topUpTeam: (teamId: number, amount: number) =>
api.post(`/admin/teams/${teamId}/topup`, { amount }),
setTeamPool: (teamId: number, totalSecondsPool: number) =>
api.put(`/admin/teams/${teamId}/set-pool`, { total_seconds_pool: totalSecondsPool }),
setTeamPool: (teamId: number, balance: number) =>
api.put(`/admin/teams/${teamId}/set-pool`, { balance }),
createTeamAdmin: (teamId: number, data: { username: string; email: string; password: string }) =>
api.post(`/admin/teams/${teamId}/admin`, data),
@ -185,8 +185,8 @@ export const adminApi = {
username: string;
email: string;
password: string;
daily_seconds_limit?: number;
monthly_seconds_limit?: number;
daily_generation_limit?: number;
monthly_generation_limit?: number;
is_staff?: boolean;
}) =>
api.post('/admin/users/create', data),
@ -205,8 +205,8 @@ export const adminApi = {
updateUserQuota: (userId: number, daily: number, monthly: number) =>
api.put(`/admin/users/${userId}/quota`, {
daily_seconds_limit: daily,
monthly_seconds_limit: monthly,
daily_generation_limit: daily,
monthly_generation_limit: monthly,
}),
updateUserStatus: (userId: number, isActive: boolean) =>
@ -306,7 +306,7 @@ export const teamApi = {
getMembers: () =>
api.get<{ results: TeamMember[] }>('/team/members'),
createMember: (data: { username: string; password: string; daily_seconds_limit?: number; monthly_seconds_limit?: number }) =>
createMember: (data: { username: string; password: string; daily_generation_limit?: number; monthly_generation_limit?: number }) =>
api.post('/team/members/create', data),
getMemberDetail: (memberId: number) =>
@ -314,8 +314,8 @@ export const teamApi = {
updateMemberQuota: (memberId: number, daily: number, monthly: number) =>
api.put(`/team/members/${memberId}/quota`, {
daily_seconds_limit: daily,
monthly_seconds_limit: monthly,
daily_generation_limit: daily,
monthly_generation_limit: monthly,
}),
updateMemberStatus: (memberId: number, isActive: boolean) =>

View File

@ -4,8 +4,8 @@ import { VideoDetailModal } from '../components/VideoDetailModal';
import type { AssetTeamSummary, AssetMemberSummary, AssetVideo, GenerationTask } from '../types';
import styles from './AdminAssetsPage.module.css';
function formatSeconds(s: number) {
return `${s.toLocaleString()}s`;
function formatCost(val: number) {
return `¥${(val || 0).toFixed(2)}`;
}
function VideoThumbnail({ video, onClick }: { video: AssetVideo; onClick: () => void }) {
@ -136,8 +136,8 @@ export function AdminAssetsPage() {
<div className={styles.statValue}>{overview.total_videos}</div>
</div>
<div className={styles.statCard}>
<div className={styles.statLabel}></div>
<div className={styles.statValue}>{formatSeconds(overview.total_seconds)}</div>
<div className={styles.statLabel}></div>
<div className={styles.statValue}>{formatCost(overview.total_seconds)}</div>
</div>
<div className={styles.statCard}>
<div className={styles.statLabel}></div>
@ -153,7 +153,7 @@ export function AdminAssetsPage() {
<span className={styles.accordionName}>{team.name}</span>
<div className={styles.accordionMeta}>
<span className={styles.accordionBadge}>{team.video_count} </span>
<span className={styles.accordionBadge}>{formatSeconds(team.seconds_consumed)}</span>
<span className={styles.accordionBadge}>{formatCost(team.cost_consumed ?? team.seconds_consumed)}</span>
</div>
</div>
{expandedTeam === team.id && (
@ -169,7 +169,7 @@ export function AdminAssetsPage() {
</span>
<div className={styles.accordionMeta}>
<span className={styles.accordionBadge}>{member.video_count} </span>
<span className={styles.accordionBadge}>{formatSeconds(member.seconds_consumed)}</span>
<span className={styles.accordionBadge}>{formatCost(member.cost_consumed ?? member.seconds_consumed)}</span>
</div>
</div>
{expandedMember === member.id && memberVideos[member.id] && (
@ -212,7 +212,7 @@ export function AdminAssetsPage() {
<span className={styles.accordionName}></span>
<div className={styles.accordionMeta}>
<span className={styles.accordionBadge}>{overview.no_team.video_count} </span>
<span className={styles.accordionBadge}>{formatSeconds(overview.no_team.seconds_consumed)}</span>
<span className={styles.accordionBadge}>{formatCost(overview.no_team.seconds_consumed)}</span>
</div>
</div>
</div>

View File

@ -24,15 +24,23 @@ const ACTION_OPTIONS = [
];
const FIELD_LABELS: Record<string, string> = {
default_daily_seconds_limit: '每日限额',
default_monthly_seconds_limit: '每月限额',
default_daily_seconds_limit: '每日限额(秒)',
default_monthly_seconds_limit: '每月限额(秒)',
default_daily_generation_limit: '每日生成次数',
default_monthly_generation_limit: '每月生成次数',
base_token_price: '基础token单价',
announcement: '公告内容',
announcement_enabled: '公告开关',
name: '名称',
monthly_seconds_limit: '月额度',
monthly_seconds_limit: '月额度(秒)',
monthly_spending_limit: '月消费限额',
total_seconds_pool: '秒数池',
balance: '余额',
markup_percentage: '加价率',
is_active: '状态',
daily_seconds_limit: '每日限额',
daily_seconds_limit: '每日限额(秒)',
daily_generation_limit: '每日生成次数',
monthly_generation_limit: '每月生成次数',
username: '用户名',
email: '邮箱',
role: '角色',

View File

@ -45,10 +45,10 @@ export function DashboardPage() {
if (!stats) return null;
const statCards = [
{ label: '总团队数', value: stats.total_teams, change: null },
{ label: '总用户数', value: stats.total_users, change: null },
{ label: '今日消费秒数', value: stats.seconds_consumed_today, change: stats.today_change_percent },
{ label: '本月消费秒数', value: stats.seconds_consumed_this_month, change: stats.month_change_percent },
{ label: '总团队数', value: String(stats.total_teams), change: null },
{ label: '总用户数', value: String(stats.total_users), change: null },
{ label: '今日消费', value: `¥${(stats.cost_today || 0).toFixed(2)}`, change: stats.today_change_percent },
{ label: '本月消费', value: `¥${(stats.cost_this_month || 0).toFixed(2)}`, change: stats.month_change_percent },
];
const trendOption: echarts.EChartsCoreOption = {
@ -59,7 +59,7 @@ export function DashboardPage() {
textStyle: { color: '#f1f0ff', fontSize: 12 },
formatter: (params: unknown) => {
const p = (params as { name: string; value: number }[])[0];
return `${p.name}<br/>消费: ${p.value}s`;
return `${p.name}<br/>消费: ¥${p.value.toFixed(2)}`;
},
},
grid: { left: 50, right: 20, top: 20, bottom: 60 },
@ -77,7 +77,7 @@ export function DashboardPage() {
dataZoom: [{ type: 'inside', start: 0, end: 100 }],
series: [{
type: 'line',
data: stats.daily_trend.map((d) => d.seconds),
data: stats.daily_trend.map((d) => d.cost),
smooth: true,
lineStyle: { color: '#6c63ff', width: 2 },
areaStyle: {
@ -90,7 +90,7 @@ export function DashboardPage() {
}],
};
const sortedTeams = [...(stats.top_teams || [])].sort((a, b) => a.seconds_consumed - b.seconds_consumed);
const sortedTeams = [...(stats.top_teams || [])].sort((a, b) => (a.cost_consumed || 0) - (b.cost_consumed || 0));
const teamBarOption: echarts.EChartsCoreOption = {
tooltip: {
trigger: 'axis',
@ -113,7 +113,7 @@ export function DashboardPage() {
},
series: [{
type: 'bar',
data: sortedTeams.map((t) => t.seconds_consumed),
data: sortedTeams.map((t) => t.cost_consumed || 0),
barWidth: 16,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
@ -127,12 +127,12 @@ export function DashboardPage() {
position: 'right',
color: '#8b8ea8',
fontSize: 11,
formatter: '{c}s',
formatter: (p: { value: number }) => `¥${p.value.toFixed(2)}`,
},
}],
};
const sortedUsers = [...stats.top_users].sort((a, b) => a.seconds_consumed - b.seconds_consumed);
const sortedUsers = [...stats.top_users].sort((a, b) => (a.cost_consumed || 0) - (b.cost_consumed || 0));
const barOption: echarts.EChartsCoreOption = {
tooltip: {
trigger: 'axis',
@ -155,7 +155,7 @@ export function DashboardPage() {
},
series: [{
type: 'bar',
data: sortedUsers.map((u) => u.seconds_consumed),
data: sortedUsers.map((u) => u.cost_consumed || 0),
barWidth: 16,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
@ -169,7 +169,7 @@ export function DashboardPage() {
position: 'right',
color: '#8b8ea8',
fontSize: 11,
formatter: '{c}s',
formatter: (p: { value: number }) => `¥${p.value.toFixed(2)}`,
},
}],
};
@ -182,7 +182,7 @@ export function DashboardPage() {
{statCards.map((card) => (
<div key={card.label} className={styles.statCard}>
<div className={styles.statLabel}>{card.label}</div>
<div className={styles.statValue}>{card.value.toLocaleString()}{card.label.includes('秒') ? 's' : ''}</div>
<div className={styles.statValue}>{card.value}</div>
{card.change !== null && (
<div className={`${styles.statChange} ${card.change >= 0 ? styles.positive : styles.negative}`}>
<span>{card.change >= 0 ? '↑' : '↓'}</span>
@ -194,7 +194,7 @@ export function DashboardPage() {
</div>
<div className={styles.chartSection}>
<h2 className={styles.sectionTitle}>30</h2>
<h2 className={styles.sectionTitle}>30 · </h2>
<div className={styles.chartWrapper}>
<ReactEChartsCore echarts={echarts} option={trendOption} style={{ height: 320 }} />
</div>
@ -217,6 +217,83 @@ export function DashboardPage() {
</div>
</div>
</div>
{/* Profit Section */}
<div className={styles.statsGrid}>
<div className={styles.statCard}>
<div className={styles.statLabel}></div>
<div className={styles.statValue}>{`¥${(stats.total_revenue || 0).toFixed(2)}`}</div>
</div>
<div className={styles.statCard}>
<div className={styles.statLabel}></div>
<div className={styles.statValue}>{`¥${(stats.total_base_cost || 0).toFixed(2)}`}</div>
</div>
<div className={styles.statCard}>
<div className={styles.statLabel}></div>
<div className={styles.statValue}>{`¥${(stats.total_profit || 0).toFixed(2)}`}</div>
</div>
<div className={styles.statCard}>
<div className={styles.statLabel}></div>
<div className={styles.statValue}>{`${(stats.profit_margin || 0).toFixed(1)}%`}</div>
</div>
</div>
{(stats.team_profit_ranking || []).length > 0 && (() => {
const profitRanking = [...(stats.team_profit_ranking || [])].sort((a, b) => a.profit - b.profit);
const profitBarOption: echarts.EChartsCoreOption = {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
backgroundColor: 'rgba(13, 13, 26, 0.95)',
borderColor: 'rgba(255, 255, 255, 0.10)',
textStyle: { color: '#f1f0ff', fontSize: 12 },
formatter: (params: unknown) => {
const p = (params as { name: string; value: number; dataIndex: number }[])[0];
const team = profitRanking[p.dataIndex];
return `${p.name}<br/>收入: ¥${team.revenue.toFixed(2)}<br/>成本: ¥${team.base_cost.toFixed(2)}<br/>利润: ¥${team.profit.toFixed(2)}<br/>加价率: ${team.markup_percentage}%`;
},
},
grid: { left: 80, right: 40, top: 10, bottom: 20 },
xAxis: {
type: 'value',
axisLabel: { color: '#8b8ea8', fontSize: 11 },
splitLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.06)' } },
},
yAxis: {
type: 'category',
data: profitRanking.map((t) => t.name),
axisLabel: { color: '#8b8ea8', fontSize: 12 },
axisLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.08)' } },
},
series: [{
type: 'bar',
data: profitRanking.map((t) => t.profit),
barWidth: 16,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#06d6a0' },
{ offset: 1, color: '#00b8e6' },
]),
borderRadius: [0, 4, 4, 0],
},
label: {
show: true,
position: 'right',
color: '#8b8ea8',
fontSize: 11,
formatter: (p: { value: number }) => `¥${p.value.toFixed(2)}`,
},
}],
};
return (
<div className={styles.chartSection}>
<h2 className={styles.sectionTitle}></h2>
<div className={styles.chartWrapper}>
<ReactEChartsCore echarts={echarts} option={profitBarOption} style={{ height: Math.max(300, profitRanking.length * 36) }} />
</div>
</div>
);
})()}
</div>
);
}

View File

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

View File

@ -58,14 +58,15 @@ export function RecordsPage() {
team_id: teamFilter ? Number(teamFilter) : undefined,
});
const header = '时间,团队,用户名,消费秒数,提示词,生成模式,状态,失败原因\n';
const header = '时间,团队,用户名,消费秒数,Tokens,费用(元),成本(元),利润(元),提示词,生成模式,状态,失败原因\n';
const rows = data.results.map((r) => {
// Escape CSV fields to prevent injection
const prompt = r.prompt.replace(/"/g, '""').replace(/^[=+\-@]/, "'$&");
const modeLabel = r.mode === 'universal' ? '全能参考' : '首尾帧';
const statusLabel = { queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' }[r.status];
const errorMsg = (r.error_message || '').replace(/"/g, '""').replace(/^[=+\-@]/, "'$&");
return `${r.created_at},"${r.team_name || '-'}",${r.username},"${r.seconds_consumed}","${prompt}","${modeLabel}","${statusLabel}","${errorMsg}"`;
const profit = ((r.cost_amount || 0) - (r.base_cost_amount || 0)).toFixed(2);
return `${r.created_at},"${r.team_name || '-'}",${r.username},"${r.seconds_consumed}","${r.tokens_consumed || 0}","${(r.cost_amount || 0).toFixed(2)}","${(r.base_cost_amount || 0).toFixed(2)}","${profit}","${prompt}","${modeLabel}","${statusLabel}","${errorMsg}"`;
}).join('\n');
const blob = new Blob(['\uFEFF' + header + rows], { type: 'text/csv;charset=utf-8;' });
@ -120,6 +121,10 @@ export function RecordsPage() {
<th></th>
<th></th>
<th></th>
<th>Tokens</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
@ -129,13 +134,13 @@ export function RecordsPage() {
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i}>
{Array.from({ length: 7 }).map((_, j) => (
{Array.from({ length: 11 }).map((_, j) => (
<td key={j}><div className={styles.skeletonCell} /></td>
))}
</tr>
))
) : records.length === 0 ? (
<tr><td colSpan={7} className={styles.empty}></td></tr>
<tr><td colSpan={11} className={styles.empty}></td></tr>
) : (
records.map((r) => (
<tr key={r.id}>
@ -143,6 +148,10 @@ export function RecordsPage() {
<td>{r.team_name || '-'}</td>
<td>{r.username}</td>
<td><span className={styles.secondsBadge}>{r.seconds_consumed.toLocaleString()}s</span></td>
<td>{(r.tokens_consumed || 0).toLocaleString()}</td>
<td>¥{(r.cost_amount || 0).toFixed(2)}</td>
<td>¥{(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>{r.mode === 'universal' ? '全能参考' : '首尾帧'}</td>
<td className={r.status === 'failed' && r.error_message ? styles.statusCell : undefined}>

View File

@ -8,6 +8,9 @@ export function SettingsPage() {
const [settings, setSettings] = useState<SystemSettings>({
default_daily_seconds_limit: 600,
default_monthly_seconds_limit: 6000,
default_daily_generation_limit: 50,
default_monthly_generation_limit: 500,
base_token_price: 0,
announcement: '',
announcement_enabled: false,
max_desktop_sessions: 1,
@ -104,22 +107,31 @@ export function SettingsPage() {
<p className={styles.cardDesc}></p>
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label> ()</label>
<label></label>
<input
type="number"
value={settings.default_daily_seconds_limit}
onChange={(e) => setSettings({ ...settings, default_daily_seconds_limit: Number(e.target.value) })}
value={settings.default_daily_generation_limit}
onChange={(e) => setSettings({ ...settings, default_daily_generation_limit: Number(e.target.value) })}
/>
</div>
<div className={styles.formGroup}>
<label> ()</label>
<label></label>
<input
type="number"
value={settings.default_monthly_seconds_limit}
onChange={(e) => setSettings({ ...settings, default_monthly_seconds_limit: Number(e.target.value) })}
value={settings.default_monthly_generation_limit}
onChange={(e) => setSettings({ ...settings, default_monthly_generation_limit: Number(e.target.value) })}
/>
</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}>
{saving ? '保存中...' : '保存配额设置'}
</button>

View File

@ -49,14 +49,14 @@ export function TeamDashboardPage() {
if (!info || !stats) return null;
const formatLimit = (v: number) => v === -1 ? '不限' : v.toLocaleString() + 's';
const fmtMoney = (val: number) => '¥' + (val || 0).toFixed(2);
const statCards = [
{ label: '总秒数池', value: formatLimit(info.total_seconds_pool) },
{ label: '已使用', value: info.total_seconds_used.toLocaleString() + 's' },
{ label: '剩余', value: info.remaining_seconds.toLocaleString() + 's' },
{ label: '月限额', value: formatLimit(info.monthly_seconds_limit) },
{ label: '本月已用', value: info.monthly_seconds_used.toLocaleString() + 's' },
{ label: '余额', value: fmtMoney(info.balance) },
{ label: '累计消费', value: fmtMoney(info.total_spent) },
{ label: '可用余额', value: fmtMoney(info.available_balance) },
{ label: '月消费限额', value: fmtMoney(info.monthly_spending_limit) },
{ label: '本月消费', value: fmtMoney(info.monthly_spent) },
];
const trendOption: echarts.EChartsCoreOption = {
@ -67,7 +67,7 @@ export function TeamDashboardPage() {
textStyle: { color: '#f1f0ff', fontSize: 12 },
formatter: (params: unknown) => {
const p = (params as { name: string; value: number }[])[0];
return `${p.name}<br/>消费: ${p.value}s`;
return `${p.name}<br/>消费: ¥${p.value.toFixed(2)}`;
},
},
grid: { left: 50, right: 20, top: 20, bottom: 60 },
@ -85,7 +85,7 @@ export function TeamDashboardPage() {
dataZoom: [{ type: 'inside', start: 0, end: 100 }],
series: [{
type: 'line',
data: stats.daily_trend.map((d) => d.seconds),
data: stats.daily_trend.map((d) => d.cost ?? d.seconds),
smooth: true,
lineStyle: { color: '#6c63ff', width: 2 },
areaStyle: {
@ -98,7 +98,7 @@ export function TeamDashboardPage() {
}],
};
const sortedMembers = [...stats.member_consumption].sort((a, b) => a.seconds_consumed - b.seconds_consumed);
const sortedMembers = [...stats.member_consumption].sort((a, b) => (a.cost_consumed ?? a.seconds_consumed) - (b.cost_consumed ?? b.seconds_consumed));
const barOption: echarts.EChartsCoreOption = {
tooltip: {
trigger: 'axis',
@ -121,7 +121,7 @@ export function TeamDashboardPage() {
},
series: [{
type: 'bar',
data: sortedMembers.map((m) => m.seconds_consumed),
data: sortedMembers.map((m) => m.cost_consumed ?? m.seconds_consumed),
barWidth: 16,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
@ -135,7 +135,7 @@ export function TeamDashboardPage() {
position: 'right',
color: '#8b8ea8',
fontSize: 11,
formatter: '{c}s',
formatter: (p: { value: number }) => `¥${p.value.toFixed(2)}`,
},
}],
};
@ -154,7 +154,7 @@ export function TeamDashboardPage() {
</div>
<div className={styles.chartSection}>
<h2 className={styles.sectionTitle}>30</h2>
<h2 className={styles.sectionTitle}>30 · </h2>
<div className={styles.chartWrapper}>
<ReactEChartsCore echarts={echarts} option={trendOption} style={{ height: 320 }} />
</div>

View File

@ -13,8 +13,8 @@ export function TeamMembersPage() {
const [createOpen, setCreateOpen] = useState(false);
const [newUsername, setNewUsername] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newDaily, setNewDaily] = useState('600');
const [newMonthly, setNewMonthly] = useState('6000');
const [newDaily, setNewDaily] = useState('50');
const [newMonthly, setNewMonthly] = useState('500');
const [createError, setCreateError] = useState('');
// Confirm toggle
@ -39,7 +39,8 @@ export function TeamMembersPage() {
useEffect(() => { fetchMembers(); }, [fetchMembers]);
const formatLimit = (v: number) => v === -1 ? '不限' : v.toLocaleString() + 's';
const formatLimit = (v: number) => v === -1 ? '不限' : v + '次';
const fmtMoney = (val: number) => '¥' + (val || 0).toFixed(2);
const handleToggleStatus = async (member: TeamMember) => {
try {
@ -53,8 +54,8 @@ export function TeamMembersPage() {
const openEditModal = (member: TeamMember) => {
setEditMember(member);
setEditDaily(String(member.daily_seconds_limit));
setEditMonthly(String(member.monthly_seconds_limit));
setEditDaily(String(member.daily_generation_limit ?? 50));
setEditMonthly(String(member.monthly_generation_limit ?? 500));
};
const handleSaveQuota = async () => {
@ -71,7 +72,7 @@ export function TeamMembersPage() {
const resetCreateForm = () => {
setNewUsername(''); setNewPassword('');
setNewDaily('600'); setNewMonthly('6000');
setNewDaily('50'); setNewMonthly('500');
setCreateError('');
};
@ -83,8 +84,8 @@ export function TeamMembersPage() {
await teamApi.createMember({
username: newUsername.trim(),
password: newPassword,
daily_seconds_limit: Number(newDaily),
monthly_seconds_limit: Number(newMonthly),
daily_generation_limit: Number(newDaily),
monthly_generation_limit: Number(newMonthly),
});
showToast('成员创建成功');
setCreateOpen(false);
@ -116,10 +117,10 @@ export function TeamMembersPage() {
<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>
</thead>
@ -150,10 +151,10 @@ export function TeamMembersPage() {
{m.is_active ? '启用' : '禁用'}
</span>
</td>
<td>{formatLimit(m.daily_seconds_limit)}</td>
<td>{formatLimit(m.monthly_seconds_limit)}</td>
<td>{m.seconds_today.toLocaleString()}s</td>
<td>{m.seconds_this_month.toLocaleString()}s</td>
<td>{formatLimit(m.daily_generation_limit)}</td>
<td>{formatLimit(m.monthly_generation_limit)}</td>
<td>{(m.generations_today || 0) + '次 / ' + fmtMoney(m.spent_today)}</td>
<td>{(m.generations_this_month || 0) + '次 / ' + fmtMoney(m.spent_this_month)}</td>
<td>
<div className={styles.actions}>
<button className={styles.editBtn} onClick={() => openEditModal(m)}></button>
@ -190,11 +191,11 @@ export function TeamMembersPage() {
<div className={styles.modal}>
<h3 className={styles.modalTitle}> {editMember.username}</h3>
<div className={styles.formGroup}>
<label>-1 </label>
<label>-1 </label>
<input type="number" value={editDaily} onChange={(e) => setEditDaily(e.target.value)} />
</div>
<div className={styles.formGroup}>
<label>-1 </label>
<label>-1 </label>
<input type="number" value={editMonthly} onChange={(e) => setEditMonthly(e.target.value)} />
</div>
<div className={styles.modalActions}>
@ -220,11 +221,11 @@ export function TeamMembersPage() {
</div>
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label></label>
<label></label>
<input type="number" value={newDaily} onChange={(e) => setNewDaily(e.target.value)} />
</div>
<div className={styles.formGroup}>
<label></label>
<label></label>
<input type="number" value={newMonthly} onChange={(e) => setNewMonthly(e.target.value)} />
</div>
</div>

View File

@ -6,6 +6,10 @@ import { ConfirmModal } from '../components/ConfirmModal';
import { Select } from '../components/Select';
import styles from './TeamsPage.module.css';
function fmtMoney(val: number): string {
return '¥' + (val || 0).toFixed(2);
}
function fmtSec(s: number): string {
return Math.round(s).toLocaleString() + 's';
}
@ -17,14 +21,15 @@ export function TeamsPage() {
// Create team modal
const [createOpen, setCreateOpen] = useState(false);
const [newName, setNewName] = useState('');
const [newMonthlyLimit, setNewMonthlyLimit] = useState('36000');
const [newDailyMemberLimit, setNewDailyMemberLimit] = useState('600');
const [newMonthlyLimit, setNewMonthlyLimit] = useState('10000');
const [newDailyMemberLimit, setNewDailyMemberLimit] = useState('50');
const [newExpectedRegions, setNewExpectedRegions] = useState('');
const [newMarkup, setNewMarkup] = useState('30');
const [createError, setCreateError] = useState('');
// Top-up modal
const [topupTeam, setTopupTeam] = useState<Team | null>(null);
const [topupSeconds, setTopupSeconds] = useState('3600');
const [topupAmount, setTopupAmount] = useState('1000');
// Create admin modal
const [adminTeam, setAdminTeam] = useState<Team | null>(null);
@ -79,8 +84,8 @@ export function TeamsPage() {
const [anomalyConfigDraft, setAnomalyConfigDraft] = useState<Record<string, any>>({});
const resetCreateForm = () => {
setNewName(''); setNewMonthlyLimit('36000'); setNewDailyMemberLimit('600');
setNewExpectedRegions(''); setCreateError('');
setNewName(''); setNewMonthlyLimit('10000'); setNewDailyMemberLimit('50');
setNewExpectedRegions(''); setNewMarkup('30'); setCreateError('');
};
const handleCreateTeam = async () => {
@ -90,9 +95,10 @@ export function TeamsPage() {
try {
await adminApi.createTeam({
name: newName.trim(),
monthly_seconds_limit: Number(newMonthlyLimit),
monthly_spending_limit: Number(newMonthlyLimit),
daily_member_limit_default: Number(newDailyMemberLimit),
expected_regions: newExpectedRegions.trim(),
markup_percentage: Number(newMarkup),
});
showToast('团队创建成功');
setCreateOpen(false);
@ -106,11 +112,11 @@ export function TeamsPage() {
const handleTopUp = async () => {
if (!topupTeam) return;
const seconds = Number(topupSeconds);
if (!seconds || seconds <= 0) { showToast('请输入有效的秒数'); return; }
const amount = Number(topupAmount);
if (!amount || amount <= 0) { showToast('请输入有效的金额'); return; }
try {
await adminApi.topUpTeam(topupTeam.id, seconds);
showToast(`已为 ${topupTeam.name} 充值 ${fmtSec(seconds)}`);
await adminApi.topUpTeam(topupTeam.id, amount);
showToast(`已为 ${topupTeam.name} 充值 ${fmtMoney(amount)}`);
setTopupTeam(null);
fetchTeams();
} catch {
@ -120,11 +126,11 @@ export function TeamsPage() {
const handleSetPool = async () => {
if (!detailTeam) return;
const newPool = Number(editPoolValue);
if (isNaN(newPool) || newPool < 0) { setEditPoolError('请输入有效的非负数'); return; }
const newBalance = Number(editPoolValue);
if (isNaN(newBalance) || newBalance < 0) { setEditPoolError('请输入有效的非负数'); return; }
try {
await adminApi.setTeamPool(detailTeam.id, newPool);
showToast(`已将 ${detailTeam.name} 总秒数池修改为 ${fmtSec(newPool)}`);
await adminApi.setTeamPool(detailTeam.id, newBalance);
showToast(`已将 ${detailTeam.name} 余额修改为 ${fmtMoney(newBalance)}`);
setEditPoolOpen(false);
// Refresh detail
const { data } = await adminApi.getTeamDetail(detailTeam.id);
@ -222,10 +228,10 @@ export function TeamsPage() {
<thead>
<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>
@ -251,11 +257,11 @@ export function TeamsPage() {
{t.name}
</button>
</td>
<td>{fmtSec(t.total_seconds_pool)}</td>
<td>{fmtSec(t.total_seconds_used)}</td>
<td>{fmtSec(t.remaining_seconds)}</td>
<td>{fmtSec(t.monthly_seconds_limit)}</td>
<td>{fmtSec(t.monthly_seconds_used)}</td>
<td>{fmtMoney(t.balance)}</td>
<td>{fmtMoney(t.total_spent)}</td>
<td>{fmtMoney(t.available_balance)}</td>
<td>{fmtMoney(t.monthly_spending_limit)}</td>
<td>{fmtMoney(t.monthly_spent)}</td>
<td>{t.member_count}</td>
<td>
<span className={`${styles.statusBadge} ${t.is_active ? styles.active : styles.disabled}`}>
@ -269,7 +275,7 @@ export function TeamsPage() {
</td>
<td>
<div className={styles.actions}>
<button className={styles.topupBtn} onClick={() => { setTopupTeam(t); setTopupSeconds('3600'); }}></button>
<button className={styles.topupBtn} onClick={() => { setTopupTeam(t); setTopupAmount('1000'); }}></button>
<button className={styles.adminBtn} onClick={() => { setAdminTeam(t); resetAdminForm(); }}></button>
<button
className={`${styles.toggleBtn} ${t.is_active ? styles.disableBtn : styles.enableBtn}`}
@ -297,14 +303,18 @@ export function TeamsPage() {
</div>
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label></label>
<label>()</label>
<input type="number" value={newMonthlyLimit} onChange={(e) => setNewMonthlyLimit(e.target.value)} />
</div>
<div className={styles.formGroup}>
<label>()</label>
<label>()</label>
<input type="number" value={newDailyMemberLimit} onChange={(e) => setNewDailyMemberLimit(e.target.value)} />
</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}>
<label></label>
<input type="text" value={newExpectedRegions} onChange={(e) => setNewExpectedRegions(e.target.value)} placeholder="广州市,深圳市,北京市" />
@ -322,12 +332,12 @@ export function TeamsPage() {
{topupTeam && (
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setTopupTeam(null); }}>
<div className={styles.modal}>
<h3 className={styles.modalTitle}> {topupTeam.name}</h3>
<h3 className={styles.modalTitle}> {topupTeam.name}</h3>
<div className={styles.formGroup}>
<label></label>
<input type="number" value={topupSeconds} onChange={(e) => setTopupSeconds(e.target.value)} placeholder="输入秒数" />
<label>()</label>
<input type="number" value={topupAmount} onChange={(e) => setTopupAmount(e.target.value)} placeholder="输入金额" />
<div className={styles.formHint}>
: {fmtSec(topupTeam.remaining_seconds)} | : {fmtSec(topupTeam.remaining_seconds + (Number(topupSeconds) || 0))}
: {fmtMoney(topupTeam.balance)} | : {fmtMoney((topupTeam.balance || 0) + (Number(topupAmount) || 0))}
</div>
</div>
<div className={styles.modalActions}>
@ -401,13 +411,13 @@ export function TeamsPage() {
<div className={styles.detailBody}>
<div className={styles.detailGrid}>
<div className={styles.detailItem}>
<span className={styles.detailLabel}></span>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>
{fmtSec(detailTeam.total_seconds_pool)}
{fmtMoney(detailTeam.balance)}
<button
className={styles.editPoolBtn}
onClick={() => { setEditPoolValue(String(detailTeam.total_seconds_pool)); setEditPoolError(''); setEditPoolOpen(true); }}
title="修改秒数"
onClick={() => { setEditPoolValue(String(detailTeam.balance || 0)); setEditPoolError(''); setEditPoolOpen(true); }}
title="修改余额"
>
<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" />
@ -417,29 +427,29 @@ export function TeamsPage() {
</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>{fmtSec(detailTeam.total_seconds_used)}</span>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>{fmtMoney(detailTeam.total_spent)}</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>{fmtSec(detailTeam.remaining_seconds)}</span>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>{fmtMoney(detailTeam.available_balance)}</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>{fmtSec(detailTeam.monthly_seconds_limit)}</span>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>{fmtMoney(detailTeam.monthly_spending_limit)}</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>{fmtSec(detailTeam.monthly_seconds_used)}</span>
<span className={styles.detailValue}>{fmtMoney(detailTeam.monthly_spent)}</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>{(detailTeam.markup_percentage || 0)}%</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>{detailTeam.member_count}</span>
</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}>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>{new Date(detailTeam.created_at).toLocaleDateString('zh-CN')}</span>
@ -631,9 +641,9 @@ export function TeamsPage() {
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th>/</th>
<th>/</th>
</tr>
</thead>
<tbody>
@ -656,9 +666,9 @@ export function TeamsPage() {
</span>
)}
</td>
<td>{fmtSec(m.daily_seconds_limit)}</td>
<td>{fmtSec(m.seconds_today)}</td>
<td>{fmtSec(m.seconds_this_month)}</td>
<td>{m.daily_generation_limit === -1 ? '不限' : (m.daily_generation_limit || 0) + '次'}</td>
<td>{(m.generations_today || 0) + '次 / ' + fmtMoney(m.spent_today)}</td>
<td>{(m.generations_this_month || 0) + '次 / ' + fmtMoney(m.spent_this_month)}</td>
</tr>
))}
</tbody>
@ -707,13 +717,13 @@ export function TeamsPage() {
{editPoolOpen && detailTeam && (
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setEditPoolOpen(false); }}>
<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>}
<div className={styles.formGroup}>
<label></label>
<input type="number" value={editPoolValue} onChange={(e) => setEditPoolValue(e.target.value)} placeholder="输入总秒数" />
<label></label>
<input type="number" value={editPoolValue} onChange={(e) => setEditPoolValue(e.target.value)} placeholder="输入余额" />
<div className={styles.formHint}>
: {fmtSec(detailTeam.total_seconds_pool)} | : {fmtSec(detailTeam.total_seconds_used)} | : {fmtSec(Math.max(0, (Number(editPoolValue) || 0) - detailTeam.total_seconds_used))}
: {fmtMoney(detailTeam.balance)} | : {fmtMoney(detailTeam.total_spent)}
</div>
</div>
<div className={styles.modalActions}>

View File

@ -39,8 +39,8 @@ export function UsersPage() {
const [newUsername, setNewUsername] = useState('');
const [newEmail, setNewEmail] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newDaily, setNewDaily] = useState('600');
const [newMonthly, setNewMonthly] = useState('6000');
const [newDaily, setNewDaily] = useState('50');
const [newMonthly, setNewMonthly] = useState('500');
const [newIsStaff, setNewIsStaff] = useState(false);
const [createError, setCreateError] = useState('');
@ -84,8 +84,8 @@ export function UsersPage() {
const openEditModal = (user: AdminUser) => {
setEditUser(user);
setEditDaily(String(user.daily_seconds_limit));
setEditMonthly(String(user.monthly_seconds_limit));
setEditDaily(String(user.daily_generation_limit ?? 50));
setEditMonthly(String(user.monthly_generation_limit ?? 500));
};
const handleSaveQuota = async () => {
@ -112,7 +112,7 @@ export function UsersPage() {
const resetCreateForm = () => {
setNewUsername(''); setNewEmail(''); setNewPassword('');
setNewDaily('600'); setNewMonthly('6000'); setNewIsStaff(false);
setNewDaily('50'); setNewMonthly('500'); setNewIsStaff(false);
setCreateError('');
};
@ -126,8 +126,8 @@ export function UsersPage() {
username: newUsername.trim(),
email: newEmail.trim(),
password: newPassword,
daily_seconds_limit: Number(newDaily),
monthly_seconds_limit: Number(newMonthly),
daily_generation_limit: Number(newDaily),
monthly_generation_limit: Number(newMonthly),
is_staff: newIsStaff,
});
showToast('用户创建成功');
@ -203,10 +203,10 @@ export function UsersPage() {
<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>
</thead>
@ -242,10 +242,10 @@ export function UsersPage() {
</span>
)}
</td>
<td>{u.daily_seconds_limit === -1 ? '不限' : u.daily_seconds_limit.toLocaleString() + 's'}</td>
<td>{u.monthly_seconds_limit === -1 ? '不限' : u.monthly_seconds_limit.toLocaleString() + 's'}</td>
<td>{u.seconds_today.toLocaleString()}s</td>
<td>{u.seconds_this_month.toLocaleString()}s</td>
<td>{(u.daily_generation_limit ?? -1) === -1 ? '不限' : u.daily_generation_limit + '次'}</td>
<td>{(u.monthly_generation_limit ?? -1) === -1 ? '不限' : u.monthly_generation_limit + '次'}</td>
<td>{(u.generations_today || 0) + '次 / ¥' + (u.spent_today || 0).toFixed(2)}</td>
<td>{(u.generations_this_month || 0) + '次 / ¥' + (u.spent_this_month || 0).toFixed(2)}</td>
<td>
<div className={styles.actions}>
<button className={styles.editBtn} onClick={() => openEditModal(u)}></button>
@ -305,11 +305,11 @@ export function UsersPage() {
<div className={styles.modal}>
<h3 className={styles.modalTitle}> {editUser.username}</h3>
<div className={styles.formGroup}>
<label></label>
<label>-1 </label>
<input type="number" value={editDaily} onChange={(e) => setEditDaily(e.target.value)} />
</div>
<div className={styles.formGroup}>
<label></label>
<label>-1 </label>
<input type="number" value={editMonthly} onChange={(e) => setEditMonthly(e.target.value)} />
</div>
<div className={styles.modalActions}>
@ -339,11 +339,11 @@ export function UsersPage() {
</div>
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label></label>
<label></label>
<input type="number" value={newDaily} onChange={(e) => setNewDaily(e.target.value)} />
</div>
<div className={styles.formGroup}>
<label></label>
<label></label>
<input type="number" value={newMonthly} onChange={(e) => setNewMonthly(e.target.value)} />
</div>
</div>
@ -415,16 +415,24 @@ export function UsersPage() {
<span className={styles.detailValue}>{new Date(detailUser.date_joined).toLocaleString('zh-CN')}</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}>/</span>
<span className={styles.detailValue}>{detailUser.seconds_today.toLocaleString()}s / {detailUser.daily_seconds_limit === -1 ? '不限' : detailUser.daily_seconds_limit.toLocaleString() + 's'}</span>
<span className={styles.detailLabel}>/</span>
<span className={styles.detailValue}>{(detailUser.generations_today || 0)} / {(detailUser.daily_generation_limit ?? -1) === -1 ? '不限' : detailUser.daily_generation_limit + '次'}</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}>/</span>
<span className={styles.detailValue}>{detailUser.seconds_this_month.toLocaleString()}s / {detailUser.monthly_seconds_limit === -1 ? '不限' : detailUser.monthly_seconds_limit.toLocaleString() + 's'}</span>
<span className={styles.detailLabel}>/</span>
<span className={styles.detailValue}>{(detailUser.generations_this_month || 0)} / {(detailUser.monthly_generation_limit ?? -1) === -1 ? '不限' : detailUser.monthly_generation_limit + '次'}</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>¥{(detailUser.spent_today || 0).toFixed(2)}</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>¥{(detailUser.spent_this_month || 0).toFixed(2)}</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>{detailUser.seconds_total.toLocaleString()}s</span>
<span className={styles.detailValue}>¥{(detailUser.total_spent || 0).toFixed(2)}</span>
</div>
</div>
@ -437,7 +445,7 @@ export function UsersPage() {
<div key={r.id} className={styles.recordItem}>
<div className={styles.recordTime}>{new Date(r.created_at).toLocaleString('zh-CN')}</div>
<div className={styles.recordMeta}>
<span className={styles.recordSeconds}>{r.seconds_consumed.toLocaleString()}s</span>
<span className={styles.recordSeconds}>¥{(r.cost_amount || 0).toFixed(2)}</span>
<span className={styles.recordMode}>{r.mode === 'universal' ? '全能参考' : '首尾帧'}</span>
<span className={`${styles.recordStatus} ${styles[r.status]}`}>{
{ queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' }[r.status]

View File

@ -79,6 +79,8 @@ function backendToFrontend(bt: BackendTask): GenerationTask {
resultUrl: bt.result_url || undefined,
errorMessage: mapErrorMessage(bt.error_message),
createdAt: new Date(bt.created_at).getTime(),
tokensConsumed: bt.tokens_consumed || 0,
costAmount: bt.cost_amount || 0,
};
}

View File

@ -214,7 +214,7 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
mode,
prevReferences: state.references,
references: [],
aspectRatio: 'adaptive',
aspectRatio: '16:9',
duration: 5,
});
} else {

View File

@ -1,6 +1,6 @@
export type CreationMode = 'universal' | 'keyframe';
export type ModelOption = 'seedance_2.0' | 'seedance_2.0_fast';
export type AspectRatio = '16:9' | '9:16' | '1:1' | '21:9' | '4:3' | '3:4' | 'adaptive';
export type AspectRatio = '16:9' | '9:16' | '1:1' | '21:9' | '4:3' | '3:4';
export type Duration = 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15;
export type GenerationType = 'video' | 'image';
export type UserRole = 'super_admin' | 'team_admin' | 'member';
@ -46,6 +46,8 @@ export interface GenerationTask {
resultUrl?: string;
errorMessage?: string;
createdAt: number;
tokensConsumed?: number;
costAmount?: number;
}
export interface BackendTask {
@ -58,6 +60,9 @@ export interface BackendTask {
aspect_ratio: string;
duration: number;
seconds_consumed: number;
tokens_consumed: number;
cost_amount: number;
base_cost_amount: number;
status: 'queued' | 'processing' | 'completed' | 'failed';
result_url: string;
error_message: string;
@ -85,6 +90,13 @@ export interface TeamInfo {
remaining_seconds: number;
monthly_seconds_limit: number;
monthly_seconds_used: number;
balance: number;
total_spent: number;
available_balance: number;
monthly_spending_limit: number;
monthly_spent: number;
frozen_amount: number;
token_price: number;
is_active: boolean;
}
@ -94,6 +106,10 @@ export interface Quota {
daily_seconds_used: number;
monthly_seconds_limit: number;
monthly_seconds_used: number;
daily_generation_limit: number;
daily_generation_used: number;
monthly_generation_limit: number;
monthly_generation_used: number;
}
export interface AuthTokens {
@ -108,11 +124,20 @@ export interface AdminStats {
new_users_today: number;
seconds_consumed_today: number;
seconds_consumed_this_month: number;
cost_today: number;
cost_this_month: number;
base_cost_today: number;
base_cost_this_month: number;
total_revenue: number;
total_base_cost: number;
total_profit: number;
profit_margin: number;
today_change_percent: number;
month_change_percent: number;
daily_trend: { date: string; seconds: number }[];
top_users: { user_id: number; username: string; seconds_consumed: number }[];
top_teams: { team_id: number; name: string; seconds_consumed: number }[];
daily_trend: { date: string; seconds: number; cost: number; base_cost: number }[];
top_users: { user_id: number; username: string; seconds_consumed: number; cost_consumed: number }[];
top_teams: { team_id: number; name: string; seconds_consumed: number; cost_consumed: number }[];
team_profit_ranking: { team_id: number; name: string; revenue: number; base_cost: number; profit: number; markup_percentage: number }[];
}
export interface AdminUser {
@ -130,10 +155,17 @@ export interface AdminUser {
monthly_seconds_limit: number;
seconds_today: number;
seconds_this_month: number;
daily_generation_limit: number;
monthly_generation_limit: number;
generations_today: number;
generations_this_month: number;
spent_today: number;
spent_this_month: number;
}
export interface AdminUserDetail extends AdminUser {
seconds_total: number;
total_spent: number;
recent_records: AdminRecord[];
}
@ -144,6 +176,9 @@ export interface AdminRecord {
username?: string;
team_name?: string;
seconds_consumed: number;
tokens_consumed: number;
cost_amount: number;
base_cost_amount: number;
prompt: string;
mode: CreationMode;
model: ModelOption;
@ -155,6 +190,9 @@ export interface AdminRecord {
export interface SystemSettings {
default_daily_seconds_limit: number;
default_monthly_seconds_limit: number;
default_daily_generation_limit: number;
default_monthly_generation_limit: number;
base_token_price: number;
announcement: string;
announcement_enabled: boolean;
max_desktop_sessions: number;
@ -184,6 +222,13 @@ export interface ProfileOverview {
monthly_seconds_limit: number;
monthly_seconds_used: number;
total_seconds_used: number;
daily_generation_limit: number;
daily_generation_used: number;
monthly_generation_limit: number;
monthly_generation_used: number;
daily_spent: number;
monthly_spent: number;
total_spent_amount: number;
daily_trend: { date: string; seconds: number }[];
team?: {
name: string;
@ -192,6 +237,12 @@ export interface ProfileOverview {
remaining_seconds: number;
monthly_seconds_limit: number;
monthly_seconds_used: number;
balance: number;
total_spent: number;
available_balance: number;
monthly_spending_limit: number;
monthly_spent: number;
frozen_amount: number;
};
}
@ -211,6 +262,13 @@ export interface Team {
remaining_seconds: number;
monthly_seconds_limit: number;
monthly_seconds_used: number;
balance: number;
total_spent: number;
available_balance: number;
monthly_spending_limit: number;
monthly_spent: number;
frozen_amount: number;
markup_percentage: number;
daily_member_limit_default: number;
member_count: number;
is_active: boolean;
@ -250,6 +308,12 @@ export interface TeamMember {
monthly_seconds_limit: number;
seconds_today: number;
seconds_this_month: number;
daily_generation_limit: number;
monthly_generation_limit: number;
generations_today: number;
generations_this_month: number;
spent_today: number;
spent_this_month: number;
date_joined: string;
}
@ -273,8 +337,8 @@ export interface LoginAnomaly {
}
export interface TeamStats {
daily_trend: { date: string; seconds: number }[];
member_consumption: { user_id: number; username: string; seconds_consumed: number }[];
daily_trend: { date: string; seconds: number; cost?: number }[];
member_consumption: { user_id: number; username: string; seconds_consumed: number; cost_consumed?: number }[];
}
// Asset management types
@ -283,6 +347,7 @@ export interface AssetTeamSummary {
name: string;
video_count: number;
seconds_consumed: number;
cost_consumed?: number;
member_count: number;
is_active: boolean;
}
@ -293,6 +358,7 @@ export interface AssetMemberSummary {
is_team_admin: boolean;
video_count: number;
seconds_consumed: number;
cost_consumed?: number;
}
export interface AssetVideo {
@ -302,6 +368,7 @@ export interface AssetVideo {
result_url: string;
duration: number;
seconds_consumed: number;
cost_amount?: number;
aspect_ratio: string;
created_at: string;
}