Compare commits

...

8 Commits

Author SHA1 Message Date
seaislee1209
6053c9b987 ci: deploy.yaml 添加 DJANGO_SECRET_KEY/DB 密钥引用
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m18s
K8s Secret 新增 DJANGO_SECRET_KEY、DB_HOST、DB_USER、DB_PASSWORD,
配合 backend-deployment.yaml 中的 secretKeyRef 使用。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 02:26:01 +08:00
seaislee1209
d9a12af078 fix: v0.8.5 安全加固 — CRITICAL/HIGH 漏洞修复
- C1/C2: 移除 settings.py 中硬编码的数据库密码和 SECRET_KEY 默认值
- K8s: DB_PASSWORD/DB_HOST/DB_USER/DJANGO_SECRET_KEY 改为 secretKeyRef
- H1: DEBUG 默认值从 True 改为 False
- H2: 登录接口添加 ScopedRateThrottle (5/min),全局限流 (anon 30/min, user 120/min)
- H4: Django Admin 仅在 DEBUG=True 时注册
- H6: PromptInput innerHTML 使用 DOMPurify 消毒防止 XSS
- H7: ALLOWED_HOSTS 从 "*" 收紧为实际域名
- H9: Nginx 添加安全响应头 + server_tokens off
- M1: 密码策略加强 (min 8 + CommonPassword + NumericPassword)
- M5: Django 生产环境安全头配置
- L1: 登录接口改为 POST-only

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 02:08:50 +08:00
seaislee1209
4c0605e589 fix: 首尾帧模式 aspect ratio 默认改为 adaptive(自适应)
切换到 keyframe 模式时自动设为 adaptive,API 根据首帧图片比例
自动匹配最接近的输出比例,避免图片与视频比例不匹配。
用户仍可手动选择固定比例覆盖。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 01:33:53 +08:00
seaislee1209
85f76d8543 feat: v0.8.2~v0.8.4 — 管理后台 UI 修复 + 团队详情重构 + 审计日志系统
v0.8.2: DatePicker/Select 暗色主题、公告跑马灯、Toast 全局化、失败原因 tooltip
v0.8.3: 团队详情抽屉→弹窗重构 + 修改秒数池功能 + member_count 修复
v0.8.4: AdminAuditLog 模型 + 12 处管理操作埋点 + 日志查询页面(/admin/logs)

审计日志覆盖所有管理员 mutation 操作(充值、修改额度、创建/禁用用户等),
记录操作人、变更前后值、IP 地址,支持按操作类型/操作人/日期筛选。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 01:18:44 +08:00
seaislee1209
f803a1ba71 feat: v0.8.1 — Seedance API 友好错误提示 + 前端 Mock 数据清理
- 新增 SeedanceAPIError 异常类 + ERROR_MESSAGES 错误码中文映射
- views.py 异常处理区分 SeedanceAPIError,存储友好错误信息
- 移除 DEV 环境 7 个 mock 任务,消除 404 轮询错误

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 22:31:14 +08:00
seaislee1209
32f0ee58f4 feat: v0.8.0 — Seedance API 全流程修复 + TOS 视频持久化
- 新增音频引用传递给 Seedance API
- 视频生成完成后自动持久化到 TOS(永久 CDN URL)
- 移除 ARK_API_KEY 硬编码默认值
- 前端渐进式轮询(10s/30s/60s)替代固定 3 分钟
- TOS 桶切换到 airdrama-media (cn-beijing)
- K8s Secret 注入 TOS 密钥,CI/CD 同步更新

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 21:26:27 +08:00
seaislee1209
add3af7904 feat: v0.7.0 — 确认弹窗 + 秒数显示统一 + 弹窗拖拽修复 + 团队模型完善
- 新增 ConfirmModal 组件,为6处危险操作添加二次确认弹窗
  (禁用团队/用户/成员、删除视频×3处)
- 所有秒数显示统一为千位分隔符+s后缀(如 36,000s)
- 修复 modal/drawer 在 input 中拖拽导致误关闭的 bug
  (onClick → onMouseDown + e.target === e.currentTarget)
- 团队模型完善:三种角色(超管/团管/成员)、四层额度检查、
  团管成员管理页、超管团队管理页
- 关闭公开注册,所有账号由管理员创建

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 20:16:21 +08:00
seaislee1209
f8358a28c6 feat: 前端UI重构 — Air Spark设计系统对标
- 全局样式对标Air Spark设计系统(背景、glass card、配色、圆角)
- 视频详情弹窗(VideoDetailModal)全屏预览+信息面板
- GenerationCard重构:fixed定位tooltip、9:16视频适配、blob下载
- 个人中心:总额度/今日/本月三卡片布局
- Dashboard图表配色统一为#6c63ff主色调
- Sidebar、InputBar、Toolbar等组件样式优化
- 新增AmbientBackground、AssetsPage组件

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 18:48:07 +08:00
87 changed files with 7388 additions and 1114 deletions

View File

@ -64,6 +64,12 @@ jobs:
run: |
kubectl create secret generic video-backend-secrets \
--from-literal=ARK_API_KEY=${{ secrets.ARK_API_KEY }} \
--from-literal=TOS_ACCESS_KEY=${{ secrets.TOS_ACCESS_KEY }} \
--from-literal=TOS_SECRET_KEY=${{ secrets.TOS_SECRET_KEY }} \
--from-literal=DJANGO_SECRET_KEY=${{ secrets.DJANGO_SECRET_KEY }} \
--from-literal=DB_HOST=${{ secrets.DB_HOST }} \
--from-literal=DB_USER=${{ secrets.DB_USER }} \
--from-literal=DB_PASSWORD=${{ secrets.DB_PASSWORD }} \
--dry-run=client -o yaml | kubectl apply -f -
- name: Apply K8s Manifests

View File

@ -145,6 +145,13 @@ jimeng-clone/
| PUT | `/api/v1/admin/users/<id>/status` | Toggle user active status |
| GET | `/api/v1/admin/records` | List all generation records |
| GET/PUT | `/api/v1/admin/settings` | Get/update global settings (QuotaConfig) |
| GET | `/api/v1/admin/teams` | List all teams |
| POST | `/api/v1/admin/teams/create` | Create new team |
| GET/PUT | `/api/v1/admin/teams/<id>` | Get/update team details |
| POST | `/api/v1/admin/teams/<id>/topup` | Add seconds to team pool |
| PUT | `/api/v1/admin/teams/<id>/set-pool` | Directly set team total seconds pool |
| POST | `/api/v1/admin/teams/<id>/admin` | Create team admin user |
| GET | `/api/v1/admin/logs` | Audit logs (filter by action/operator/date) |
### Profile (`/api/v1/profile/`)
| Method | Endpoint | Description |
@ -164,6 +171,11 @@ jimeng-clone/
- `status` (queued|processing|completed|failed), `result_url`, `error_message`, `reference_urls` (JSON)
- Index: (user, created_at)
### AdminAuditLog
- `operator` (FK User, SET_NULL), `operator_name` (denormalized), `action` (12 choices)
- `target_type`, `target_id`, `target_name`, `before` (JSON), `after` (JSON)
- `ip_address`, `created_at` (indexed)
### QuotaConfig (Singleton, pk=1)
- `default_daily_seconds_limit`, `default_monthly_seconds_limit`
- `announcement`, `announcement_enabled`, `updated_at`
@ -179,6 +191,7 @@ jimeng-clone/
| `/admin/users` | UsersPage | Admin | User management |
| `/admin/records` | RecordsPage | Admin | Generation records |
| `/admin/settings` | SettingsPage | Admin | Global quota & announcement |
| `/admin/logs` | AuditLogsPage | Admin | Admin operation audit logs |
## Incremental Development Guide
@ -314,9 +327,10 @@ npx tsx src/index.ts --resume /Users/maidong/Desktop/zyc/研究openclaw/视频
| `SECRET_KEY` | Django secret key | Yes |
| `TOS_ACCESS_KEY` | Volcano Engine TOS AccessKeyId | Yes (upload) |
| `TOS_SECRET_KEY` | Volcano Engine TOS SecretAccessKey | Yes (upload) |
| `TOS_BUCKET` | Volcano TOS bucket name (default: `video-huoshan`) | Yes (upload) |
| `TOS_ENDPOINT` | TOS endpoint URL (default: `https://tos-cn-guangzhou.volces.com`) | Yes (upload) |
| `TOS_REGION` | TOS region (default: `cn-guangzhou`) | Yes (upload) |
| `TOS_BUCKET` | Volcano TOS bucket name (default: `airdrama-media`) | Yes (upload) |
| `TOS_ENDPOINT` | TOS endpoint URL (default: `https://tos-cn-beijing.volces.com`) | Yes (upload) |
| `TOS_REGION` | TOS region (default: `cn-beijing`) | Yes (upload) |
| `TOS_CDN_DOMAIN` | TOS CDN domain for permanent URLs (default: `https://airdrama-media.tos-cn-beijing.volces.com`) | Yes (upload) |
| `ARK_API_KEY` | Volcano Engine ARK API key for Seedance | Yes (video gen) |
| `ARK_BASE_URL` | ARK API base URL (default: `https://ark.cn-beijing.volces.com/api/v3`) | No |
@ -361,6 +375,12 @@ npx tsx src/index.ts --resume /Users/maidong/Desktop/zyc/研究openclaw/视频
| 2026-03-13 | custom_exception_handler: 未处理异常返回 JSON 500 而非 HTML | Backend |
| 2026-03-13 | 前端轮询间隔从 5 秒改为 3 分钟 | Frontend |
| 2026-03-13 | CLAUDE.md 增量开发指南更新为 agent-auto替换 Autonomous Skill | Documentation |
| 2026-03-15 | v0.8.0: 音频引用支持 + 视频 TOS 持久化 + 移除硬编码密钥 + 渐进式轮询 | Full stack |
| 2026-03-15 | TOS 桶切换到 airdrama-media (cn-beijing)K8s Secret 注入 TOS 密钥 | Infra |
| 2026-03-15 | v0.8.1: Seedance API 友好错误提示 (SeedanceAPIError) + 前端 Mock 数据清理 | Full stack |
| 2026-03-16 | v0.8.2: 管理后台 UI 修复 — DatePicker/Select 暗色主题、公告跑马灯、Toast 全局化、失败原因 tooltip | Full stack |
| 2026-03-16 | v0.8.3: 团队详情抽屉→弹窗重构(VideoDetailModal 规范) + 修改秒数池功能 + member_count 修复 | Full stack |
| 2026-03-16 | v0.8.4: 管理员操作审计日志 — AdminAuditLog 模型 + 12 处埋点 + 日志查询页面 | Full stack |
### Phase 4 Details (2026-03-13)

View File

@ -1,15 +1,27 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth import get_user_model
from .models import Team
User = get_user_model()
@admin.register(Team)
class TeamAdmin(admin.ModelAdmin):
list_display = ('name', 'total_seconds_pool', 'total_seconds_used', 'monthly_seconds_limit', 'is_active', 'member_count', 'created_at')
list_filter = ('is_active',)
search_fields = ('name',)
def member_count(self, obj):
return obj.members.count()
member_count.short_description = '成员数'
@admin.register(User)
class UserAdmin(BaseUserAdmin):
list_display = ('username', 'email', 'daily_seconds_limit', 'monthly_seconds_limit', 'is_staff', 'date_joined')
list_filter = ('is_staff', 'is_active')
list_display = ('username', 'email', 'team', 'is_team_admin', 'daily_seconds_limit', 'monthly_seconds_limit', 'is_staff', 'date_joined')
list_filter = ('is_staff', 'is_active', 'is_team_admin', 'team')
search_fields = ('username', 'email')
fieldsets = BaseUserAdmin.fieldsets + (
('配额设置(秒数)', {'fields': ('daily_seconds_limit', 'monthly_seconds_limit')}),
('团队与配额', {'fields': ('team', 'is_team_admin', 'daily_seconds_limit', 'monthly_seconds_limit')}),
)

View File

@ -0,0 +1,42 @@
# Generated by Django 4.2.29 on 2026-03-15 11:04
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('accounts', '0002_remove_user_daily_limit_remove_user_monthly_limit_and_more'),
]
operations = [
migrations.CreateModel(
name='Team',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, unique=True, verbose_name='团队名称')),
('total_seconds_pool', models.BigIntegerField(default=0, verbose_name='总额度池(秒)')),
('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='新成员默认每日限额(秒)')),
('is_active', models.BooleanField(default=True, verbose_name='启用状态')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
],
options={
'verbose_name': '团队',
'verbose_name_plural': '团队',
},
),
migrations.AddField(
model_name='user',
name='is_team_admin',
field=models.BooleanField(default=False, verbose_name='团队管理员'),
),
migrations.AddField(
model_name='user',
name='team',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='members', to='accounts.team', verbose_name='所属团队'),
),
]

View File

@ -0,0 +1,59 @@
# Generated by Django 4.2.29 on 2026-03-15 11:04
from django.db import migrations
def create_default_team(apps, schema_editor):
"""Create a default team and assign all non-staff users to it."""
Team = apps.get_model('accounts', 'Team')
User = apps.get_model('accounts', 'User')
QuotaConfig = apps.get_model('generation', 'QuotaConfig')
# Read defaults from QuotaConfig if it exists
daily_default = 600
monthly_default = 6000
try:
config = QuotaConfig.objects.get(pk=1)
daily_default = config.default_daily_seconds_limit
monthly_default = config.default_monthly_seconds_limit
except QuotaConfig.DoesNotExist:
pass
# Calculate total seconds already consumed by non-staff users
GenerationRecord = apps.get_model('generation', 'GenerationRecord')
from django.db.models import Sum
total_used = GenerationRecord.objects.filter(
user__is_staff=False
).aggregate(total=Sum('seconds_consumed'))['total'] or 0
# Create default team with a generous initial pool
team = Team.objects.create(
name='默认团队',
total_seconds_pool=max(int(total_used) + 36000, 36000), # used + 10 hours buffer
total_seconds_used=total_used,
monthly_seconds_limit=monthly_default,
daily_member_limit_default=daily_default,
)
# Assign all non-staff users to the default team
User.objects.filter(is_staff=False).update(team=team)
def reverse_migration(apps, schema_editor):
"""Remove default team assignment."""
User = apps.get_model('accounts', 'User')
Team = apps.get_model('accounts', 'Team')
User.objects.filter(team__name='默认团队').update(team=None)
Team.objects.filter(name='默认团队').delete()
class Migration(migrations.Migration):
dependencies = [
('accounts', '0003_add_team_model_and_user_team_fields'),
('generation', '0003_generationrecord_ark_task_id_and_more'),
]
operations = [
migrations.RunPython(create_default_team, reverse_migration),
]

View File

@ -0,0 +1,36 @@
# Generated by Django 4.2.29 on 2026-03-15 17:11
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('accounts', '0004_data_migrate_default_team'),
]
operations = [
migrations.CreateModel(
name='AdminAuditLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('operator_name', models.CharField(max_length=150, verbose_name='操作人用户名')),
('action', models.CharField(choices=[('team_create', '创建团队'), ('team_update', '更新团队'), ('team_topup', '团队充值'), ('team_set_pool', '设置团队额度池'), ('team_create_admin', '创建团队管理员'), ('user_create', '创建用户'), ('user_quota_update', '更新用户额度'), ('user_status_toggle', '切换用户状态'), ('settings_update', '更新系统设置'), ('member_create', '创建团队成员'), ('member_quota_update', '更新成员额度'), ('member_status_toggle', '切换成员状态')], max_length=30, verbose_name='操作类型')),
('target_type', models.CharField(max_length=20, verbose_name='目标类型')),
('target_id', models.IntegerField(blank=True, null=True, verbose_name='目标ID')),
('target_name', models.CharField(blank=True, default='', max_length=200, verbose_name='目标名称')),
('before', models.JSONField(blank=True, null=True, verbose_name='变更前')),
('after', models.JSONField(blank=True, null=True, verbose_name='变更后')),
('ip_address', models.GenericIPAddressField(blank=True, null=True, verbose_name='IP地址')),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='操作时间')),
('operator', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='audit_logs', to=settings.AUTH_USER_MODEL, verbose_name='操作人')),
],
options={
'verbose_name': '审计日志',
'verbose_name_plural': '审计日志',
'ordering': ['-created_at'],
},
),
]

View File

@ -2,9 +2,39 @@ from django.contrib.auth.models import AbstractUser
from django.db import models
class Team(models.Model):
"""团队模型 — 额度管理的核心单位。"""
name = models.CharField(max_length=100, unique=True, verbose_name='团队名称')
total_seconds_pool = models.BigIntegerField(default=0, verbose_name='总额度池(秒)')
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='新成员默认每日限额(秒)')
is_active = models.BooleanField(default=True, verbose_name='启用状态')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间')
class Meta:
verbose_name = '团队'
verbose_name_plural = '团队'
def __str__(self):
return self.name
@property
def remaining_seconds(self):
return self.total_seconds_pool - self.total_seconds_used
class User(AbstractUser):
"""Extended user model — Phase 3: quota in seconds."""
"""Extended user model — Phase 5: team-based quota."""
email = models.EmailField(unique=True, verbose_name='邮箱')
team = models.ForeignKey(
Team, on_delete=models.SET_NULL,
null=True, blank=True,
related_name='members',
verbose_name='所属团队',
)
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='每月秒数上限')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
@ -16,3 +46,68 @@ class User(AbstractUser):
def __str__(self):
return self.username
@property
def role(self):
if self.is_staff and self.team is None:
return 'super_admin'
if self.is_team_admin and self.team is not None:
return 'team_admin'
return 'member'
class AdminAuditLog(models.Model):
"""管理员操作审计日志"""
ACTION_CHOICES = [
('team_create', '创建团队'),
('team_update', '更新团队'),
('team_topup', '团队充值'),
('team_set_pool', '设置团队额度池'),
('team_create_admin', '创建团队管理员'),
('user_create', '创建用户'),
('user_quota_update', '更新用户额度'),
('user_status_toggle', '切换用户状态'),
('settings_update', '更新系统设置'),
('member_create', '创建团队成员'),
('member_quota_update', '更新成员额度'),
('member_status_toggle', '切换成员状态'),
]
operator = models.ForeignKey(
User, on_delete=models.SET_NULL,
null=True, related_name='audit_logs',
verbose_name='操作人',
)
operator_name = models.CharField(max_length=150, verbose_name='操作人用户名')
action = models.CharField(max_length=30, choices=ACTION_CHOICES, verbose_name='操作类型')
target_type = models.CharField(max_length=20, verbose_name='目标类型')
target_id = models.IntegerField(null=True, blank=True, verbose_name='目标ID')
target_name = models.CharField(max_length=200, blank=True, default='', verbose_name='目标名称')
before = models.JSONField(null=True, blank=True, verbose_name='变更前')
after = models.JSONField(null=True, blank=True, verbose_name='变更后')
ip_address = models.GenericIPAddressField(null=True, blank=True, verbose_name='IP地址')
created_at = models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='操作时间')
class Meta:
verbose_name = '审计日志'
verbose_name_plural = '审计日志'
ordering = ['-created_at']
def __str__(self):
return f'{self.operator_name} - {self.get_action_display()} - {self.target_name}'
def log_admin_action(request, action, target_type, target_id=None, target_name='', before=None, after=None):
"""记录管理员操作日志"""
ip = request.META.get('HTTP_X_FORWARDED_FOR', '').split(',')[0].strip() or request.META.get('REMOTE_ADDR')
AdminAuditLog.objects.create(
operator=request.user,
operator_name=request.user.username,
action=action,
target_type=target_type,
target_id=target_id,
target_name=target_name,
before=before,
after=after,
ip_address=ip,
)

View File

@ -0,0 +1,45 @@
from rest_framework.permissions import BasePermission
class IsSuperAdmin(BasePermission):
"""超级管理员is_staff=True 且 team=NULL"""
def has_permission(self, request, view):
return (
request.user
and request.user.is_authenticated
and request.user.is_staff
and request.user.team is None
)
class IsTeamAdmin(BasePermission):
"""团队管理员is_team_admin=True 且 team≠NULL"""
def has_permission(self, request, view):
return (
request.user
and request.user.is_authenticated
and request.user.is_team_admin
and request.user.team is not None
)
class IsTeamAdminOrSuperAdmin(BasePermission):
"""团队管理员或超级管理员"""
def has_permission(self, request, view):
if not (request.user and request.user.is_authenticated):
return False
if request.user.is_staff and request.user.team is None:
return True
if request.user.is_team_admin and request.user.team is not None:
return True
return False
class IsTeamMember(BasePermission):
"""团队成员含团管team≠NULL"""
def has_permission(self, request, view):
return (
request.user
and request.user.is_authenticated
and request.user.team is not None
)

View File

@ -6,9 +6,12 @@ User = get_user_model()
class UserSerializer(serializers.ModelSerializer):
role = serializers.CharField(read_only=True)
team_name = serializers.CharField(source='team.name', read_only=True, default=None)
class Meta:
model = User
fields = ('id', 'username', 'email', 'is_staff')
fields = ('id', 'username', 'email', 'is_staff', 'is_team_admin', 'role', 'team_name')
class RegisterSerializer(serializers.Serializer):

View File

@ -1,43 +1,39 @@
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.decorators import api_view, permission_classes, throttle_classes
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework_simplejwt.tokens import RefreshToken
from django.contrib.auth import authenticate, get_user_model
from django.utils import timezone
from django.db.models import Sum
from .serializers import RegisterSerializer, UserSerializer
from .serializers import UserSerializer
User = get_user_model()
class LoginRateThrottle(ScopedRateThrottle):
scope = 'login'
@api_view(['POST'])
@permission_classes([AllowAny])
def register_view(request):
"""POST /api/v1/auth/register"""
serializer = RegisterSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.save()
refresh = RefreshToken.for_user(user)
return Response({
'user': UserSerializer(user).data,
'tokens': {
'access': str(refresh.access_token),
'refresh': str(refresh),
}
}, status=status.HTTP_201_CREATED)
"""POST /api/v1/auth/register — disabled, all accounts created by admins."""
return Response(
{'error': 'registration_disabled', 'message': '公开注册已关闭,请联系管理员'},
status=status.HTTP_403_FORBIDDEN,
)
@api_view(['GET', 'POST'])
@api_view(['POST'])
@permission_classes([AllowAny])
@throttle_classes([LoginRateThrottle])
def login_view(request):
"""GET/POST /api/v1/auth/login"""
if request.method == 'GET':
return Response({'message': 'Use POST to login', 'required_fields': ['username', 'password']})
"""POST /api/v1/auth/login"""
username = request.data.get('username', '')
username = request.data.get('username', '').strip()
password = request.data.get('password', '')
# Try authenticate with username first, then email
@ -69,7 +65,7 @@ def login_view(request):
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def me_view(request):
"""GET /api/v1/auth/me — Phase 3: returns seconds-based quota"""
"""GET /api/v1/auth/me — returns role, team info, and quota."""
user = request.user
today = timezone.now().date()
first_of_month = today.replace(day=1)
@ -89,4 +85,30 @@ def me_view(request):
'monthly_seconds_limit': user.monthly_seconds_limit,
'monthly_seconds_used': monthly_seconds_used,
}
# Team info
team = user.team
if team:
# Team monthly consumption
from apps.generation.models import GenerationRecord
team_monthly_used = GenerationRecord.objects.filter(
user__team=team,
created_at__date__gte=first_of_month,
).aggregate(total=Sum('seconds_consumed'))['total'] or 0
data['team'] = {
'id': team.id,
'name': team.name,
'total_seconds_pool': team.total_seconds_pool,
'total_seconds_used': team.total_seconds_used,
'remaining_seconds': team.remaining_seconds,
'monthly_seconds_limit': team.monthly_seconds_limit,
'monthly_seconds_used': team_monthly_used,
'is_active': team.is_active,
}
data['team_disabled'] = not team.is_active
else:
data['team'] = None
data['team_disabled'] = False
return Response(data)

View File

@ -10,8 +10,8 @@ class GenerationRecord(models.Model):
('keyframe', '首尾帧'),
]
MODEL_CHOICES = [
('seedance_2.0', 'Seedance 2.0'),
('seedance_2.0_fast', 'Seedance 2.0 Fast'),
('seedance_2.0', 'AirDrama'),
('seedance_2.0_fast', 'AirDrama Fast'),
]
STATUS_CHOICES = [
('queued', '排队中'),

View File

@ -11,8 +11,8 @@ class VideoGenerateSerializer(serializers.Serializer):
class QuotaUpdateSerializer(serializers.Serializer):
daily_seconds_limit = serializers.IntegerField(min_value=0)
monthly_seconds_limit = serializers.IntegerField(min_value=0)
daily_seconds_limit = serializers.IntegerField(min_value=-1)
monthly_seconds_limit = serializers.IntegerField(min_value=-1)
class UserStatusSerializer(serializers.Serializer):
@ -23,8 +23,8 @@ class AdminCreateUserSerializer(serializers.Serializer):
username = serializers.CharField(max_length=150)
email = serializers.EmailField()
password = serializers.CharField(min_length=6)
daily_seconds_limit = serializers.IntegerField(min_value=0, required=False, default=600)
monthly_seconds_limit = serializers.IntegerField(min_value=0, required=False, default=6000)
daily_seconds_limit = serializers.IntegerField(min_value=-1, required=False, default=600)
monthly_seconds_limit = serializers.IntegerField(min_value=-1, required=False, default=6000)
is_staff = serializers.BooleanField(required=False, default=False)
@ -33,3 +33,42 @@ class SystemSettingsSerializer(serializers.Serializer):
default_monthly_seconds_limit = serializers.IntegerField(min_value=0)
announcement = serializers.CharField(required=False, allow_blank=True, default='')
announcement_enabled = serializers.BooleanField(required=False, default=False)
# ── Team serializers ──
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)
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)
is_active = serializers.BooleanField(required=False)
class TeamTopUpSerializer(serializers.Serializer):
seconds = serializers.IntegerField(min_value=1)
class TeamAdminCreateSerializer(serializers.Serializer):
"""Create a team admin account for a specific team."""
username = serializers.CharField(max_length=150)
email = serializers.EmailField()
password = serializers.CharField(min_length=6)
class TeamMemberCreateSerializer(serializers.Serializer):
"""Team admin creates a member."""
username = serializers.CharField(max_length=150)
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)
class MemberQuotaSerializer(serializers.Serializer):
daily_seconds_limit = serializers.IntegerField(min_value=-1)
monthly_seconds_limit = serializers.IntegerField(min_value=-1)

View File

@ -8,19 +8,42 @@ urlpatterns = [
path('video/generate', views.video_generate_view, name='video_generate'),
path('video/tasks', views.video_tasks_list_view, name='video_tasks_list'),
path('video/tasks/<uuid:task_id>', views.video_task_detail_view, name='video_task_detail'),
# Admin: Dashboard
# Public announcement
path('announcement', views.announcement_view, name='announcement'),
# ── Super Admin: Dashboard ──
path('admin/stats', views.admin_stats_view, name='admin_stats'),
# Admin: User management
# ── Super Admin: Team management ──
path('admin/teams', views.admin_teams_list_view, name='admin_teams_list'),
path('admin/teams/create', views.admin_team_create_view, name='admin_team_create'),
path('admin/teams/<int:team_id>', views.admin_team_detail_view, name='admin_team_detail'),
path('admin/teams/<int:team_id>/topup', views.admin_team_topup_view, name='admin_team_topup'),
path('admin/teams/<int:team_id>/set-pool', views.admin_team_set_pool_view, name='admin_team_set_pool'),
path('admin/teams/<int:team_id>/admin', views.admin_team_create_admin_view, name='admin_team_create_admin'),
# ── Super Admin: User management ──
path('admin/users', views.admin_users_list_view, name='admin_users_list'),
path('admin/users/create', views.admin_create_user_view, name='admin_create_user'),
path('admin/users/<int:user_id>', views.admin_user_detail_view, name='admin_user_detail'),
path('admin/users/<int:user_id>/quota', views.admin_user_quota_view, name='admin_user_quota'),
path('admin/users/<int:user_id>/status', views.admin_user_status_view, name='admin_user_status'),
# Admin: Consumption records
# ── Super Admin: Records, Settings & Audit Logs ──
path('admin/records', views.admin_records_view, name='admin_records'),
# Admin: System settings
path('admin/settings', views.admin_settings_view, name='admin_settings'),
# Profile: User's own data
path('admin/logs', views.admin_audit_logs_view, name='admin_audit_logs'),
# ── Team Admin: Team management ──
path('team/info', views.team_info_view, name='team_info'),
path('team/stats', views.team_stats_view, name='team_stats'),
path('team/members', views.team_members_list_view, name='team_members_list'),
path('team/members/create', views.team_member_create_view, name='team_member_create'),
path('team/members/<int:member_id>', views.team_member_detail_view, name='team_member_detail'),
path('team/members/<int:member_id>/quota', views.team_member_quota_view, name='team_member_quota'),
path('team/members/<int:member_id>/status', views.team_member_status_view, name='team_member_status'),
# ── Profile: User's own data ──
path('profile/overview', views.profile_overview_view, name='profile_overview'),
path('profile/records', views.profile_records_view, name='profile_records'),
]

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
"""ASGI config for Jimeng Clone backend."""
"""ASGI config for AirDrama backend."""
import os
from django.core.asgi import get_asgi_application

View File

@ -1,4 +1,4 @@
"""Django settings for Jimeng Clone backend."""
"""Django settings for AirDrama backend."""
import os
from pathlib import Path
@ -6,12 +6,13 @@ from datetime import timedelta
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.environ.get(
'DJANGO_SECRET_KEY',
'django-insecure-dev-key-change-in-production-jimeng-clone-2026'
)
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', '')
if not SECRET_KEY:
import warnings
warnings.warn('DJANGO_SECRET_KEY not set — using insecure dev key. Do NOT deploy like this.')
SECRET_KEY = 'django-insecure-dev-only-do-not-deploy'
DEBUG = os.environ.get('DJANGO_DEBUG', 'True').lower() in ('true', '1', 'yes')
DEBUG = os.environ.get('DJANGO_DEBUG', 'False').lower() in ('true', '1', 'yes')
ALLOWED_HOSTS = os.environ.get('DJANGO_ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',')
@ -81,8 +82,8 @@ elif os.environ.get('USE_MYSQL', 'false').lower() in ('true', '1', 'yes'):
'ENGINE': 'django.db.backends.mysql',
'NAME': os.environ.get('DB_NAME', 'video_auto'),
'USER': os.environ.get('DB_USER', 'ai_video'),
'PASSWORD': os.environ.get('DB_PASSWORD', 'JogNQdtrd3WY8CBCAiYfYEGx'),
'HOST': os.environ.get('DB_HOST', 'rm-7xv1uaw910558p1788o.mysql.rds.aliyuncs.com'),
'PASSWORD': os.environ.get('DB_PASSWORD', ''),
'HOST': os.environ.get('DB_HOST', 'localhost'),
'PORT': os.environ.get('DB_PORT', '3306'),
'OPTIONS': {
'charset': 'utf8mb4',
@ -101,7 +102,9 @@ else:
AUTH_USER_MODEL = 'accounts.User'
AUTH_PASSWORD_VALIDATORS = [
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 'OPTIONS': {'min_length': 6}},
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 'OPTIONS': {'min_length': 8}},
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
]
# REST Framework
@ -116,6 +119,15 @@ REST_FRAMEWORK = {
'rest_framework.renderers.JSONRenderer',
),
'EXCEPTION_HANDLER': 'utils.log_center.custom_exception_handler',
'DEFAULT_THROTTLE_CLASSES': [
'rest_framework.throttling.AnonRateThrottle',
'rest_framework.throttling.UserRateThrottle',
],
'DEFAULT_THROTTLE_RATES': {
'anon': '30/minute',
'user': '120/minute',
'login': '5/minute',
},
}
# JWT settings
@ -149,21 +161,29 @@ STATIC_ROOT = BASE_DIR / 'staticfiles'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# ──────────────────────────────────────────────
# Security headers (production)
# ──────────────────────────────────────────────
if not DEBUG:
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = 'DENY'
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
# ──────────────────────────────────────────────
# TOS (Volcano Engine Object Storage)
# ──────────────────────────────────────────────
TOS_ACCESS_KEY = os.environ.get('TOS_ACCESS_KEY', 'AKLTYjVlOTQwOWFkZDY4NGNhMmFkZGRhODQzMTUwNmIxOTM')
TOS_SECRET_KEY = os.environ.get('TOS_SECRET_KEY', 'TlRFMU5EUmtNamxsWlRsaU5HSXdPV0l6TVRBeVpqWTNNR00xT1RZNE0yRQ==')
TOS_ENDPOINT = os.environ.get('TOS_ENDPOINT', 'https://tos-cn-guangzhou.volces.com')
TOS_S3_ENDPOINT = os.environ.get('TOS_S3_ENDPOINT', 'https://tos-s3-cn-guangzhou.volces.com')
TOS_BUCKET = os.environ.get('TOS_BUCKET', 'video-huoshan')
TOS_REGION = os.environ.get('TOS_REGION', 'cn-guangzhou')
TOS_CDN_DOMAIN = os.environ.get('TOS_CDN_DOMAIN', 'https://video-huoshan.tos-cn-guangzhou.volces.com')
TOS_ACCESS_KEY = os.environ.get('TOS_ACCESS_KEY', '')
TOS_SECRET_KEY = os.environ.get('TOS_SECRET_KEY', '')
TOS_ENDPOINT = os.environ.get('TOS_ENDPOINT', 'https://tos-cn-beijing.volces.com')
TOS_BUCKET = os.environ.get('TOS_BUCKET', 'airdrama-media')
TOS_REGION = os.environ.get('TOS_REGION', 'cn-beijing')
TOS_CDN_DOMAIN = os.environ.get('TOS_CDN_DOMAIN', 'https://airdrama-media.tos-cn-beijing.volces.com')
# ──────────────────────────────────────────────
# Seedance API (Volcano Engine ARK)
# ──────────────────────────────────────────────
ARK_API_KEY = os.environ.get('ARK_API_KEY', '846b6981-9954-4c58-bb39-63079393bdb8')
ARK_API_KEY = os.environ.get('ARK_API_KEY', '')
ARK_BASE_URL = os.environ.get('ARK_BASE_URL', 'https://ark.cn-beijing.volces.com/api/v3')
# Set to True when Seedance model is activated on ARK platform
SEEDANCE_ENABLED = os.environ.get('SEEDANCE_ENABLED', 'false').lower() == 'true'

View File

@ -1,6 +1,7 @@
from django.contrib import admin
from django.urls import path, include
from django.http import JsonResponse
from django.conf import settings
def healthz(request):
@ -9,7 +10,10 @@ def healthz(request):
urlpatterns = [
path('healthz/', healthz),
path('admin/', admin.site.urls),
path('api/v1/auth/', include('apps.accounts.urls')),
path('api/v1/', include('apps.generation.urls')),
]
# Only expose Django admin in DEBUG mode
if settings.DEBUG:
urlpatterns.insert(1, path('admin/', admin.site.urls))

View File

@ -1,4 +1,4 @@
"""WSGI config for Jimeng Clone backend."""
"""WSGI config for AirDrama backend."""
import os
from django.core.wsgi import get_wsgi_application

View File

@ -1,9 +1,31 @@
"""Volcano Engine Seedance (ARK) video generation API client."""
"""Volcano Engine ARK video generation API client."""
import requests
from django.conf import settings
# API error code → user-friendly Chinese message
ERROR_MESSAGES = {
'InputImageSensitiveContentDetected.PrivacyInformation': '参考图片中检测到真实人脸,系统不允许处理包含真人面部的图片',
'InputImageSensitiveContentDetected': '参考图片包含敏感内容,请更换图片后重试',
'InputVideoSensitiveContentDetected': '参考视频包含敏感内容,请更换视频后重试',
'InvalidParameter': '请求参数无效,请检查输入',
'RateLimitExceeded': 'API 调用频率超限,请稍后重试',
'InsufficientBalance': '账户余额不足,请联系管理员充值',
}
class AirDramaAPIError(Exception):
"""Raised when video generation API returns an error response."""
def __init__(self, code, message, status_code=400):
self.code = code
self.api_message = message
self.status_code = status_code
# Use friendly message if available, otherwise use API message
self.user_message = ERROR_MESSAGES.get(code, message)
super().__init__(self.user_message)
MODEL_MAP = {
'seedance_2.0': 'doubao-seedance-2-0-260128',
'seedance_2.0_fast': 'doubao-seedance-2-0-fast-260128',
@ -22,7 +44,7 @@ def create_task(prompt, model, content_items, aspect_ratio, duration, generate_a
Args:
prompt: Text prompt for video generation.
model: Model key ('seedance_2.0' or 'seedance_2.0_fast').
model: Model key ('airdrama' or 'airdrama_fast').
content_items: List of media content dicts (image_url, video_url, audio_url).
aspect_ratio: Video aspect ratio ('16:9', '9:16', etc.).
duration: Video duration in seconds.
@ -48,7 +70,15 @@ def create_task(prompt, model, content_items, aspect_ratio, duration, generate_a
}
resp = requests.post(url, json=payload, headers=_headers(), timeout=60)
resp.raise_for_status()
if resp.status_code != 200:
# Extract human-readable error from API response
try:
err = resp.json().get('error', {})
code = err.get('code', '')
message = err.get('message', resp.text)
except Exception:
code, message = '', resp.text
raise AirDramaAPIError(code, message, resp.status_code)
return resp.json()

View File

@ -1,8 +1,12 @@
"""Volcano Engine TOS file upload utility using official TOS SDK."""
import hashlib
import uuid
import logging
from django.conf import settings
logger = logging.getLogger(__name__)
CONTENT_TYPE_MAP = {
'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png',
@ -30,14 +34,54 @@ def get_tos_client():
def upload_file(file_obj, folder='uploads'):
"""Upload a file to TOS bucket, return its public URL."""
"""Upload a file to TOS bucket with content-hash dedup, return its public URL.
Uses MD5 hash of file content as the object key. If the same file
has already been uploaded, the existing URL is returned without
re-uploading, saving storage and bandwidth.
"""
ext = file_obj.name.rsplit('.', 1)[-1].lower()
key = f'{folder}/{uuid.uuid4().hex}.{ext}'
content_type = CONTENT_TYPE_MAP.get(ext, 'application/octet-stream')
client = get_tos_client()
content = file_obj.read()
# Use content hash as key for dedup
content_hash = hashlib.md5(content).hexdigest()
key = f'{folder}/{content_hash}.{ext}'
url = f'{settings.TOS_CDN_DOMAIN}/{key}'
# Check if object already exists — skip upload if so
try:
client.head_object(bucket=settings.TOS_BUCKET, key=key)
logger.info('TOS dedup hit: %s', key)
return url
except Exception:
pass # Object doesn't exist, proceed with upload
client.put_object(
bucket=settings.TOS_BUCKET,
key=key,
content=content,
content_type=content_type,
)
return url
def upload_from_url(source_url, folder='results'):
"""Download a file from a URL and upload to TOS, return permanent CDN URL."""
import requests as req
resp = req.get(source_url, timeout=120, stream=True)
resp.raise_for_status()
content = resp.content
content_type = resp.headers.get('Content-Type', 'video/mp4')
ext = 'mp4' # Seedance always returns mp4
key = f'{folder}/{uuid.uuid4().hex}.{ext}'
client = get_tos_client()
client.put_object(
bucket=settings.TOS_BUCKET,
key=key,

View File

@ -4,6 +4,202 @@
---
## 2026-03-16 — v0.8.5: 安全加固CRITICAL + HIGH 修复)
**状态**: ✅ 已完成 | **验收**: 待线上验证
### 变更内容
1. **C1/C2: 密钥硬编码清除**`settings.py` 移除数据库密码和 SECRET_KEY 默认值,`backend-deployment.yaml` 中 DB_PASSWORD/DB_HOST/DB_USER/DJANGO_SECRET_KEY 改为 K8s Secret 引用
2. **H1: DEBUG 默认改 False** — 防止生产环境遗漏配置时暴露调试信息
3. **H2: 登录限流** — DRF `ScopedRateThrottle` 实现 `login: 5/min`,全局匿名 30/min、认证用户 120/min
4. **H4: Django Admin 限制** — 仅在 `DEBUG=True` 时注册 `/admin/` URL
5. **H6: XSS 防护** — 安装 DOMPurify`PromptInput.tsx``innerHTML` 赋值前进行 HTML 消毒
6. **H7: ALLOWED_HOSTS 收紧** — 从 `"*"` 改为 `video-huoshan-api.airlabs.art,localhost`
7. **H9: Nginx 安全头**`server_tokens off` + X-Frame-Options/X-Content-Type-Options/X-XSS-Protection/Referrer-Policy/Permissions-Policy
8. **M1: 密码策略加强** — 最小 8 位 + 常见密码检测 + 纯数字密码检测
9. **M5: Django 安全头** — 生产环境启用 XSS Filter/Content-Type-Nosniff/X-Frame-Options/SSL Proxy Header
10. **L1: 登录 POST-only** — 移除 GET 方法支持
### 变更文件
| 文件 | 改动 |
|------|------|
| `backend/config/settings.py` | SECRET_KEY/DB 默认值清除、DEBUG 默认 False、密码策略加强、DRF 限流配置、生产安全头 |
| `backend/config/urls.py` | Django Admin 仅 DEBUG 模式注册 |
| `backend/apps/accounts/views.py` | 登录 POST-only + LoginRateThrottle |
| `k8s/backend-deployment.yaml` | DB/SECRET_KEY 改为 secretKeyRef、ALLOWED_HOSTS 收紧 |
| `web/nginx.conf` | server_tokens off + 5 个安全响应头 |
| `web/src/components/PromptInput.tsx` | DOMPurify 消毒 innerHTML |
| `web/package.json` | 新增 dompurify 依赖 |
---
## 2026-03-16 — v0.8.4: 管理员操作审计日志
**状态**: ✅ 已完成 | **验收**: ✅ 通过(本地测试)
### 变更内容
1. **AdminAuditLog 模型** — 新增审计日志 Model记录操作人、操作类型12 种、目标、变更前后值JSONField、IP 地址、时间
2. **`log_admin_action()` 辅助函数** — 统一的审计日志写入接口,自动获取操作人和客户端 IP
3. **12 处 view 埋点** — 所有管理员 mutation 操作均记录审计日志:
- 创建类:团队创建、团队管理员创建、用户创建、成员创建
- 修改类:团队更新、团队充值、设置秒数池、用户额度更新、系统设置更新、成员额度更新
- 状态类:用户状态切换、成员状态切换
4. **日志查询 API**`GET /api/v1/admin/logs`,支持按操作类型、操作人、日期范围筛选 + 分页
5. **前端日志页面**`/admin/logs` 操作日志页,含筛选栏(操作类型下拉、操作人搜索、日期范围)、变更详情展示(旧值 → 新值)、分页
6. **侧栏导航** — AdminLayout 新增"操作日志"菜单项
### 变更文件
| 文件 | 改动 |
|------|------|
| `backend/apps/accounts/models.py` | 新增 AdminAuditLog 模型 + log_admin_action 函数 |
| `backend/apps/accounts/migrations/0005_adminauditlog.py` | 新增迁移 |
| `backend/apps/generation/views.py` | 12 处埋点 + admin_audit_logs_view 新端点 |
| `backend/apps/generation/urls.py` | 新增 admin/logs 路由 |
| `web/src/types/index.ts` | 新增 AuditLog 接口 |
| `web/src/lib/api.ts` | 新增 getAuditLogs 方法 |
| `web/src/pages/AuditLogsPage.tsx` | 新建日志页面 |
| `web/src/pages/AuditLogsPage.module.css` | 新建日志页面样式 |
| `web/src/pages/AdminLayout.tsx` | 侧栏新增"操作日志" |
| `web/src/App.tsx` | 新增 /admin/logs 路由 |
### 新增 API
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/v1/admin/logs` | 审计日志查询(支持 action/operator/start_date/end_date 筛选) |
### 触发原因
- 充值、修改秒数等操作直接对应金钱,填错无法追溯
- 需要记录谁在什么时候做了什么操作、改了什么值
---
## 2026-03-16 — v0.8.3: 团队详情弹窗重构 + 修改秒数功能
**状态**: ✅ 已完成 | **验收**: 待测试
### 变更内容
1. **团队详情:抽屉→弹窗** — 右侧抽屉改为居中弹窗Modal遵循 VideoDetailModal 设计规范:毛玻璃背景 `backdrop-filter: blur(24px)``border-radius: 16px`、入场动画、精致的关闭按钮
2. **弹窗尺寸优化** — 宽度 1080px、最小高度 70vh桌面端大气不小气
3. **字号提升** — 统计卡片标签 `#8b8ea8` 12px、数值 `#f1f0ff` 18px、成员表 14px对齐 VideoDetailModal 规范)
4. **修改秒数池功能** — 团队详情"总秒数池"卡片旁新增编辑按钮,支持直接设置 `total_seconds_pool` 值(后端校验不能低于已消耗秒数、不能为负)
5. **member_count 修复** — 后端团队详情 API 漏返回 `member_count` 字段,已补上
### 变更文件
| 文件 | 改动 |
|------|------|
| `web/src/pages/TeamsPage.tsx` | 抽屉→弹窗结构、修改秒数池 UI + handler |
| `web/src/pages/TeamsPage.module.css` | 全部 Team Detail Modal 样式重写VideoDetailModal 规范) |
| `web/src/lib/api.ts` | 新增 `setTeamPool` API 方法 |
| `backend/apps/generation/views.py` | 新增 `admin_team_set_pool_view`、团队详情补返 `member_count` |
| `backend/apps/generation/urls.py` | 新增 `admin/teams/<id>/set-pool` 路由 |
### 新增 API
| Method | Endpoint | Description |
|--------|----------|-------------|
| PUT | `/api/v1/admin/teams/<id>/set-pool` | 直接设置团队总秒数池 |
### 触发原因
- 团队详情使用右侧抽屉形式,信息拥挤、不符合暗色主题规范
- 充值秒数填错后无法修改,而这些秒数直接对应金钱
- 成员数卡片值为空(后端遗漏字段)
---
## 2026-03-16 — v0.8.2: 管理后台 UI 修复4 项)+ 失败原因展示
**状态**: ✅ 已完成 | **验收**: 待测试
### 变更内容
1. **DatePicker 日历透明修复**`.dropdown` 背景从半透明 `var(--color-bg-card)` 改为不透明 `#16161e` + `backdrop-filter`
2. **自定义 Select 组件** — 替换原生 `<select>` 白色下拉面板,暗色主题 + 动画 + click-outside 关闭RecordsPage 1 处、UsersPage 2 处)
3. **公告横幅美化** — 渐变背景 + 左侧强调色竖条 + CSS 跑马灯滚动hover 暂停)+ 淡出遮罩
4. **Toast 全局化**`<Toast />` 从 VideoGenerationPage 移至 App.tsx 根级,管理后台页面(如设置页保存)可正常显示提示
5. **失败原因 tooltip** — 消费记录表中失败状态悬浮显示 `error_message`CSV 导出增加"失败原因"列;后端 admin_records API 返回 `error_message` 字段
### 变更文件
| 文件 | 改动 |
|------|------|
| `web/src/components/DatePicker.module.css` | `.dropdown` 背景改为不透明 |
| `web/src/components/Select.tsx` | 新建自定义暗色 Select 组件 |
| `web/src/components/Select.module.css` | 新建 Select 样式 |
| `web/src/pages/RecordsPage.tsx` | 替换原生 select + 失败原因 tooltip + CSV 导出 |
| `web/src/pages/RecordsPage.module.css` | 新增 errorTooltip 样式 |
| `web/src/pages/UsersPage.tsx` | 替换 2 处原生 select |
| `web/src/components/AnnouncementBanner.tsx` | 跑马灯结构 |
| `web/src/components/AnnouncementBanner.module.css` | 渐变背景 + 滚动动画 |
| `web/src/App.tsx` | 全局 `<Toast />` |
| `web/src/components/VideoGenerationPage.tsx` | 移除局部 `<Toast />` |
| `web/src/types/index.ts` | `AdminRecord` 增加 `error_message` |
| `backend/apps/generation/views.py` | admin_records 返回 `error_message` |
### 触发原因
- DatePicker / Select 下拉面板与暗色主题不协调
- 公告横幅样式简陋
- 管理后台保存设置无反馈
- 失败记录无法查看具体原因
---
## 2026-03-15 — v0.8.1: Seedance API 友好错误提示 + Mock 数据清理
**状态**: ✅ 已完成 | **验收**: ✅ 通过(本地端到端测试)
### 变更内容
1. **Seedance API 友好错误提示**`seedance_client.py` 新增 `SeedanceAPIError` 异常类 + `ERROR_MESSAGES` 错误码映射表API 报错时返回中文友好提示(如"参考图片中检测到真实人脸")而非原始英文错误
2. **views.py 错误传递优化**`video_generate_view` 异常处理识别 `SeedanceAPIError`,将 `user_message` 存入 `error_message` 字段,前端直接展示具体原因
3. **移除前端 Mock 数据**`generation.ts` 删除 DEV 环境下的 7 个硬编码 mock 任务,消除页面加载时的 404 轮询错误
### 变更文件
| 文件 | 改动 |
|------|------|
| `backend/utils/seedance_client.py` | 新增 `SeedanceAPIError` 异常类 + `ERROR_MESSAGES` 映射 + `create_task` 错误解析 |
| `backend/apps/generation/views.py` | 异常处理区分 `SeedanceAPIError`,存储友好错误信息 |
| `web/src/store/generation.ts` | 删除 DEV mock 数据7 个假任务),消除 404 轮询 |
### 触发原因
- 本地测试上传含真人面部的图片Seedance 返回 400 但前端只显示"生成失败,请重试",用户无法理解失败原因
- DEV 环境 mock 数据的假 taskId 触发持续 404 轮询错误
### 备注
- 已覆盖错误码:隐私人脸、敏感图片/视频、参数无效、频率超限、余额不足
- 未匹配的错误码会直接展示 API 原始 message
---
## 2026-03-15 — v0.8.0: Seedance API 全流程修复 + TOS 视频持久化
**状态**: ✅ 已完成 | **验收**: ✅ 通过(本地端到端测试)
### 变更内容
1. **音频引用支持** — 后端 content_items 构建新增 `audio` 类型分支Seedance API 可接收音频参考素材
2. **视频 TOS 持久化** — Seedance 返回的临时 URL24h 有效)自动下载并上传到 TOS生成永久 CDN 链接;失败时降级使用临时 URL
3. **移除硬编码密钥** — ARK_API_KEY 默认值从测试 key 改为空字符串,避免生产环境误用
4. **渐进式轮询** — 前端轮询从固定 3 分钟改为渐进式:前 2 分钟每 10s → 2-5 分钟每 30s → 5 分钟后每 60s
5. **TOS 新桶配置** — 切换到独立桶 `airdrama-media`cn-beijingTOS 配置默认值更新
6. **K8s Secret 注入** — TOS_ACCESS_KEY / TOS_SECRET_KEY 通过 K8s Secret 注入CI/CD 自动创建
7. **CI/CD 密钥同步** — deploy.yaml 新增 TOS 密钥到 kubectl secret 创建命令
### 变更文件
| 文件 | 改动 |
|------|------|
| `backend/apps/generation/views.py` | 新增 audio content_item 分支 + 完成时 TOS 持久化逻辑 |
| `backend/utils/tos_client.py` | 新增 `upload_from_url()` — 从 URL 下载并上传到 TOS |
| `backend/config/settings.py` | TOS 桶/区域/端点改为 airdrama-media (cn-beijing)ARK_API_KEY 默认值清空 |
| `web/src/store/generation.ts` | setInterval → setTimeout 渐进式轮询 |
| `k8s/backend-deployment.yaml` | 新增 6 个 TOS 环境变量AK/SK from Secret |
| `.gitea/workflows/deploy.yaml` | kubectl secret 新增 TOS_ACCESS_KEY / TOS_SECRET_KEY |
### 触发原因
- Seedance API 文档审查发现:音频引用未传递、视频 URL 24h 过期、密钥硬编码
- 需要独立 TOS 桶存放生成视频(原桶为同事的阿里云 OSS
### 备注
- 本地测试通过:文生视频 "一只猫在阳光下伸懒腰" → 87,300 tokens → 永久 TOS URL
- Seedance 2.0 定价:不含视频输入 46 元/百万 tokens含视频输入 28 元/百万 tokens
- 资源包 5,000,000 tokens约可生成 57 个 4 秒 720p 视频
---
## 2026-03-13 — 异常上报体系优化 + 轮询策略调整
**状态**: ✅ 已完成 | **验收**: ✅ 通过

View File

@ -26,18 +26,30 @@ spec:
- name: DJANGO_DEBUG
value: "False"
- name: DJANGO_ALLOWED_HOSTS
value: "*"
value: "video-huoshan-api.airlabs.art,localhost"
- name: DJANGO_SECRET_KEY
value: "video-huoshan-prod-secret-key-2026"
valueFrom:
secretKeyRef:
name: video-backend-secrets
key: DJANGO_SECRET_KEY
# Database (Aliyun RDS)
- name: DB_HOST
value: "rm-7xv1uaw910558p1788o.mysql.rds.aliyuncs.com"
valueFrom:
secretKeyRef:
name: video-backend-secrets
key: DB_HOST
- name: DB_NAME
value: "video_auto"
- name: DB_USER
value: "ai_video"
valueFrom:
secretKeyRef:
name: video-backend-secrets
key: DB_USER
- name: DB_PASSWORD
value: "JogNQdtrd3WY8CBCAiYfYEGx"
valueFrom:
secretKeyRef:
name: video-backend-secrets
key: DB_PASSWORD
- name: DB_PORT
value: "3306"
# CORS
@ -50,6 +62,25 @@ spec:
value: "true"
- name: ENVIRONMENT
value: "production"
# TOS (from Secret)
- name: TOS_ACCESS_KEY
valueFrom:
secretKeyRef:
name: video-backend-secrets
key: TOS_ACCESS_KEY
- name: TOS_SECRET_KEY
valueFrom:
secretKeyRef:
name: video-backend-secrets
key: TOS_SECRET_KEY
- name: TOS_BUCKET
value: "airdrama-media"
- name: TOS_ENDPOINT
value: "https://tos-cn-beijing.volces.com"
- name: TOS_REGION
value: "cn-beijing"
- name: TOS_CDN_DOMAIN
value: "https://airdrama-media.tos-cn-beijing.volces.com"
# Seedance API (from Secret)
- name: ARK_API_KEY
valueFrom:

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>即梦 — AI 视频生成</title>
<title>AirDrama — AI 视频生成</title>
</head>
<body>
<div id="root"></div>

View File

@ -1,9 +1,18 @@
server_tokens off;
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Security headers
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# API requests proxy to backend service
location /api/ {
proxy_pass http://video-backend:8000;

52
web/package-lock.json generated
View File

@ -10,6 +10,7 @@
"dependencies": {
"@arco-design/web-react": "^2.64.0",
"axios": "^1.13.6",
"dompurify": "^3.3.3",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"react": "^18.3.1",
@ -22,6 +23,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/dompurify": "^3.0.5",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/react-router-dom": "^5.3.3",
@ -170,6 +172,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@ -531,6 +534,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
},
@ -571,6 +575,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
}
@ -1560,8 +1565,7 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@ -1626,6 +1630,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/dompurify": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/trusted-types": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -1653,6 +1667,7 @@
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@ -1664,6 +1679,7 @@
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^18.0.0"
}
@ -1691,6 +1707,13 @@
"@types/react-router": "*"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@ -1839,7 +1862,6 @@
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@ -1850,7 +1872,6 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@ -1950,6 +1971,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -2209,8 +2231,7 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/dom-helpers": {
"version": "5.2.1",
@ -2222,6 +2243,15 @@
"csstype": "^3.0.2"
}
},
"node_modules/dompurify": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -2680,6 +2710,7 @@
"integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.31",
"@asamuzakjp/dom-selector": "^6.8.1",
@ -2775,7 +2806,6 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@ -2929,6 +2959,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -3018,7 +3049,6 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@ -3033,8 +3063,7 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/prop-types": {
"version": "15.8.1",
@ -3074,6 +3103,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@ -3098,6 +3128,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@ -3593,6 +3624,7 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",

View File

@ -12,6 +12,7 @@
"dependencies": {
"@arco-design/web-react": "^2.64.0",
"axios": "^1.13.6",
"dompurify": "^3.3.3",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"react": "^18.3.1",
@ -24,6 +25,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/dompurify": "^3.0.5",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/react-router-dom": "^5.3.3",

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,15 +1,25 @@
import { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AmbientBackground } from './components/AmbientBackground';
import { Toast } from './components/Toast';
import { VideoGenerationPage } from './components/VideoGenerationPage';
import { ProtectedRoute } from './components/ProtectedRoute';
import { LoginPage } from './pages/LoginPage';
import { AdminLayout } from './pages/AdminLayout';
import { DashboardPage } from './pages/DashboardPage';
import { TeamsPage } from './pages/TeamsPage';
import { UsersPage } from './pages/UsersPage';
import { RecordsPage } from './pages/RecordsPage';
import { SettingsPage } from './pages/SettingsPage';
import { AuditLogsPage } from './pages/AuditLogsPage';
import { ProfilePage } from './pages/ProfilePage';
import { AssetsPage } from './pages/AssetsPage';
import { TeamAdminLayout } from './pages/TeamAdminLayout';
import { TeamDashboardPage } from './pages/TeamDashboardPage';
import { TeamMembersPage } from './pages/TeamMembersPage';
import { useAuthStore } from './store/auth';
export default function App() {
@ -21,24 +31,35 @@ export default function App() {
return (
<BrowserRouter>
<AmbientBackground />
<Toast />
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/"
element={
<ProtectedRoute>
<ProtectedRoute requireTeamMember>
<VideoGenerationPage />
</ProtectedRoute>
}
/>
<Route
path="/assets"
element={
<ProtectedRoute requireTeamMember>
<AssetsPage />
</ProtectedRoute>
}
/>
<Route
path="/profile"
element={
<ProtectedRoute>
<ProtectedRoute requireTeamMember>
<ProfilePage />
</ProtectedRoute>
}
/>
{/* Super Admin routes */}
<Route
path="/admin"
element={
@ -49,9 +70,24 @@ export default function App() {
>
<Route index element={<Navigate to="/admin/dashboard" replace />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route path="teams" element={<TeamsPage />} />
<Route path="users" element={<UsersPage />} />
<Route path="records" element={<RecordsPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="logs" element={<AuditLogsPage />} />
</Route>
{/* Team Admin routes */}
<Route
path="/team"
element={
<ProtectedRoute requireTeamAdmin>
<TeamAdminLayout />
</ProtectedRoute>
}
>
<Route index element={<Navigate to="/team/dashboard" replace />} />
<Route path="dashboard" element={<TeamDashboardPage />} />
<Route path="members" element={<TeamMembersPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@ -0,0 +1,48 @@
import { useEffect, useRef } from 'react';
export function AmbientBackground() {
const glowRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = glowRef.current;
if (!el) return;
let rafId: number;
let targetX = 50;
let targetY = 50;
let currentX = 50;
let currentY = 50;
const handleMouseMove = (e: MouseEvent) => {
targetX = (e.clientX / window.innerWidth) * 100;
targetY = (e.clientY / window.innerHeight) * 100;
};
const animate = () => {
currentX += (targetX - currentX) * 0.08;
currentY += (targetY - currentY) * 0.08;
el.style.setProperty('--mouse-x', `${currentX}%`);
el.style.setProperty('--mouse-y', `${currentY}%`);
rafId = requestAnimationFrame(animate);
};
window.addEventListener('mousemove', handleMouseMove, { passive: true });
rafId = requestAnimationFrame(animate);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
cancelAnimationFrame(rafId);
};
}, []);
return (
<>
<div className="aurora-bg" aria-hidden="true">
<div className="aurora-blob-3" />
</div>
<div className="grid-pattern" aria-hidden="true" />
<div className="noise-overlay" aria-hidden="true" />
<div ref={glowRef} className="cursor-glow" aria-hidden="true" />
</>
);
}

View File

@ -0,0 +1,65 @@
.banner {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
background: linear-gradient(90deg, rgba(108, 99, 255, 0.10), rgba(0, 184, 230, 0.08));
border-left: 3px solid var(--color-primary);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
font-size: 13px;
color: var(--color-text-primary);
line-height: 1.5;
flex-shrink: 0;
}
.icon {
flex-shrink: 0;
color: var(--color-primary);
}
.marqueeWrapper {
flex: 1;
overflow: hidden;
position: relative;
mask-image: linear-gradient(90deg, transparent, #000 5%, #000 95%, transparent);
-webkit-mask-image: linear-gradient(90deg, transparent, #000 5%, #000 95%, transparent);
}
.marqueeText {
display: inline-block;
white-space: nowrap;
animation: marquee 20s linear infinite;
padding-left: 100%;
}
.marqueeWrapper:hover .marqueeText {
animation-play-state: paused;
}
@keyframes marquee {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-100%);
}
}
.closeBtn {
flex-shrink: 0;
background: none;
border: none;
color: var(--color-text-secondary);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.15s;
}
.closeBtn:hover {
color: var(--color-text-primary);
background: rgba(255, 255, 255, 0.06);
}

View File

@ -0,0 +1,34 @@
import { useEffect, useState } from 'react';
import { videoApi } from '../lib/api';
import styles from './AnnouncementBanner.module.css';
export function AnnouncementBanner() {
const [text, setText] = useState('');
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
videoApi.getAnnouncement().then(({ data }) => {
if (data.enabled && data.announcement) {
setText(data.announcement);
}
}).catch(() => {});
}, []);
if (!text || dismissed) return null;
return (
<div className={styles.banner}>
<svg className={styles.icon} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 17H2a3 3 0 0 0 3-3V9a7 7 0 0 1 14 0v5a3 3 0 0 0 3 3zm-8.27 4a2 2 0 0 1-3.46 0" />
</svg>
<div className={styles.marqueeWrapper}>
<span className={styles.marqueeText}>{text}</span>
</div>
<button className={styles.closeBtn} onClick={() => setDismissed(true)} title="关闭">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
);
}

View File

@ -0,0 +1,8 @@
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 300; }
.modal { background: #16161e; border: 1px solid var(--color-border-card); border-radius: var(--radius-card); padding: 24px; width: 400px; max-width: 90vw; }
.title { font-size: 16px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 12px; }
.message { font-size: 14px; color: var(--color-text-secondary); line-height: 1.6; margin-bottom: 20px; }
.actions { display: flex; justify-content: flex-end; gap: 8px; }
.cancelBtn { padding: 8px 16px; background: transparent; border: 1px solid var(--color-border-card); border-radius: 8px; color: var(--color-text-secondary); font-size: 13px; cursor: pointer; }
.confirmBtn { padding: 8px 16px; background: var(--color-primary); border: none; border-radius: 8px; color: #fff; font-size: 13px; cursor: pointer; }
.danger { background: var(--color-danger); }

View File

@ -0,0 +1,28 @@
import styles from './ConfirmModal.module.css';
interface ConfirmModalProps {
open: boolean;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
danger?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmModal({ open, title, message, confirmText = '确认', cancelText = '取消', danger, onConfirm, onCancel }: ConfirmModalProps) {
if (!open) return null;
return (
<div className={styles.overlay} onMouseDown={(e) => { if (e.target === e.currentTarget) onCancel(); }}>
<div className={styles.modal}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.message}>{message}</p>
<div className={styles.actions}>
<button className={styles.cancelBtn} onClick={onCancel}>{cancelText}</button>
<button className={`${styles.confirmBtn} ${danger ? styles.danger : ''}`} onClick={onConfirm}>{confirmText}</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,146 @@
.wrapper {
position: relative;
display: inline-block;
}
.trigger {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: var(--color-bg-card);
border: 1px solid var(--color-border-card);
border-radius: 8px;
color: var(--color-text-primary);
font-size: 13px;
cursor: pointer;
outline: none;
min-width: 130px;
transition: border-color 0.15s;
}
.trigger:hover,
.trigger:focus {
border-color: var(--color-primary);
}
.placeholder {
color: var(--color-text-disabled);
}
.calendarIcon {
color: var(--color-text-secondary);
flex-shrink: 0;
}
.clearBtn {
background: none;
border: none;
color: var(--color-text-secondary);
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
margin-left: auto;
}
.clearBtn:hover {
color: var(--color-text-primary);
}
.dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 1000;
background: #16161e;
border: 1px solid var(--color-border-card);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
backdrop-filter: blur(20px) saturate(180%);
padding: 12px;
min-width: 280px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.navBtn {
background: none;
border: none;
color: var(--color-text-secondary);
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
display: flex;
align-items: center;
font-size: 14px;
}
.navBtn:hover {
background: rgba(255, 255, 255, 0.05);
color: var(--color-text-primary);
}
.monthLabel {
font-size: 14px;
font-weight: 500;
color: var(--color-text-primary);
}
.weekRow {
display: grid;
grid-template-columns: repeat(7, 1fr);
text-align: center;
margin-bottom: 4px;
}
.weekDay {
font-size: 11px;
color: var(--color-text-disabled);
padding: 4px 0;
user-select: none;
}
.daysGrid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.dayCell {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 32px;
border: none;
background: none;
color: var(--color-text-primary);
font-size: 13px;
cursor: pointer;
border-radius: 6px;
transition: all 0.1s;
}
.dayCell:hover {
background: rgba(0, 184, 230, 0.12);
}
.otherMonth {
color: var(--color-text-disabled);
opacity: 0.4;
}
.today {
border: 1px solid var(--color-primary);
}
.selected {
background: var(--color-primary) !important;
color: #fff !important;
}

View File

@ -0,0 +1,147 @@
import { useState, useRef, useEffect } from 'react';
import styles from './DatePicker.module.css';
interface DatePickerProps {
value: string; // 'YYYY-MM-DD' or ''
onChange: (value: string) => void;
placeholder?: string;
}
export function DatePicker({ value, onChange, placeholder = '选择日期' }: DatePickerProps) {
const [open, setOpen] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
const today = new Date();
const selected = value ? new Date(value + 'T00:00:00') : null;
const [viewYear, setViewYear] = useState(selected?.getFullYear() ?? today.getFullYear());
const [viewMonth, setViewMonth] = useState(selected?.getMonth() ?? today.getMonth());
// Close on outside click
useEffect(() => {
if (!open) return;
const handle = (e: MouseEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handle);
return () => document.removeEventListener('mousedown', handle);
}, [open]);
const prevMonth = () => {
if (viewMonth === 0) { setViewMonth(11); setViewYear(viewYear - 1); }
else setViewMonth(viewMonth - 1);
};
const nextMonth = () => {
if (viewMonth === 11) { setViewMonth(0); setViewYear(viewYear + 1); }
else setViewMonth(viewMonth + 1);
};
// Build calendar grid
const firstDay = new Date(viewYear, viewMonth, 1).getDay(); // 0=Sun
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const daysInPrevMonth = new Date(viewYear, viewMonth, 0).getDate();
const cells: { day: number; month: number; year: number; isOther: boolean }[] = [];
// Previous month padding
for (let i = firstDay - 1; i >= 0; i--) {
const m = viewMonth === 0 ? 11 : viewMonth - 1;
const y = viewMonth === 0 ? viewYear - 1 : viewYear;
cells.push({ day: daysInPrevMonth - i, month: m, year: y, isOther: true });
}
// Current month
for (let d = 1; d <= daysInMonth; d++) {
cells.push({ day: d, month: viewMonth, year: viewYear, isOther: false });
}
// Next month padding
const remaining = 42 - cells.length;
for (let d = 1; d <= remaining; d++) {
const m = viewMonth === 11 ? 0 : viewMonth + 1;
const y = viewMonth === 11 ? viewYear + 1 : viewYear;
cells.push({ day: d, month: m, year: y, isOther: true });
}
const fmt = (y: number, m: number, d: number) =>
`${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
const todayStr = fmt(today.getFullYear(), today.getMonth(), today.getDate());
const weekDays = ['日', '一', '二', '三', '四', '五', '六'];
const monthNames = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
return (
<div className={styles.wrapper} ref={wrapperRef}>
<button
type="button"
className={styles.trigger}
onClick={() => {
if (!open && selected) {
setViewYear(selected.getFullYear());
setViewMonth(selected.getMonth());
}
setOpen(!open);
}}
>
<svg className={styles.calendarIcon} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" />
</svg>
{value ? (
<span>{value}</span>
) : (
<span className={styles.placeholder}>{placeholder}</span>
)}
{value && (
<button
type="button"
className={styles.clearBtn}
onClick={(e) => { e.stopPropagation(); onChange(''); setOpen(false); }}
title="清除"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
)}
</button>
{open && (
<div className={styles.dropdown}>
<div className={styles.header}>
<button type="button" className={styles.navBtn} onClick={prevMonth}>&lt;</button>
<span className={styles.monthLabel}>{viewYear} {monthNames[viewMonth]}</span>
<button type="button" className={styles.navBtn} onClick={nextMonth}>&gt;</button>
</div>
<div className={styles.weekRow}>
{weekDays.map((d) => <span key={d} className={styles.weekDay}>{d}</span>)}
</div>
<div className={styles.daysGrid}>
{cells.map((c, i) => {
const dateStr = fmt(c.year, c.month, c.day);
const isToday = dateStr === todayStr;
const isSelected = dateStr === value;
return (
<button
key={i}
type="button"
className={[
styles.dayCell,
c.isOther ? styles.otherMonth : '',
isToday ? styles.today : '',
isSelected ? styles.selected : '',
].join(' ')}
onClick={() => {
onChange(dateStr);
setOpen(false);
}}
>
{c.day}
</button>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@ -11,6 +11,7 @@
border-radius: var(--radius-dropdown);
padding: 6px;
z-index: 100;
backdrop-filter: blur(20px) saturate(180%);
opacity: 0;
transform: translateY(8px);
pointer-events: none;

View File

@ -1,11 +1,12 @@
.card {
background: var(--color-bg-input-bar);
border: 1px solid var(--color-border-input-bar);
border-radius: 16px;
padding: 20px;
max-width: 680px;
background: transparent;
border: none;
border-radius: 0;
padding: 20px 0;
max-width: 800px;
width: 100%;
animation: cardFadeIn 0.3s ease-out;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
@keyframes cardFadeIn {
@ -17,61 +18,30 @@
.header {
display: flex;
gap: 12px;
margin-bottom: 16px;
margin-bottom: 12px;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(0, 184, 230, 0.12);
color: var(--color-primary);
.refColumn {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
flex-shrink: 0;
}
.headerContent {
.headerRight {
flex: 1;
min-width: 0;
}
.prompt {
font-size: 14px;
color: var(--color-text-primary);
line-height: 1.6;
margin-bottom: 4px;
word-break: break-word;
}
.meta {
font-size: 12px;
color: var(--color-text-secondary);
display: flex;
align-items: center;
gap: 6px;
}
.metaDot {
opacity: 0.4;
}
/* Content */
.content {
margin-bottom: 16px;
}
/* Reference thumbnails row */
/* Reference thumbnails row (legacy) */
.refRow {
display: flex;
gap: 6px;
margin-bottom: 12px;
margin-bottom: 10px;
}
.refThumb {
width: 48px;
height: 48px;
aspect-ratio: 3 / 4;
border-radius: 6px;
overflow: hidden;
background: #1a1a24;
@ -79,6 +49,15 @@
border: 1px solid #2a2a38;
}
.audioThumb {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-secondary);
}
.refMedia {
width: 100%;
height: 100%;
@ -86,24 +65,191 @@
display: block;
}
/* Prompt with tooltip */
.promptWrapper {
position: relative;
}
.promptLine {
font-size: 14px;
color: var(--color-text-primary);
line-height: 1.6;
word-break: break-word;
max-height: calc(1.6em * 2);
}
.promptTooltip {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 10;
background: #1e1e2a;
border: 1px solid #2a2a38;
border-radius: 10px;
padding: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
animation: tooltipFadeIn 0.15s ease-out;
}
@keyframes tooltipFadeIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.promptTooltipText {
font-size: 13px;
color: var(--color-text-primary);
line-height: 1.6;
margin-bottom: 8px;
word-break: break-word;
}
.copyBtn {
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: 6px;
font-size: 12px;
color: var(--color-primary);
background: rgba(108, 99, 255, 0.1);
border: 1px solid rgba(108, 99, 255, 0.2);
cursor: pointer;
transition: background 0.15s;
font-family: inherit;
}
.copyBtn:hover {
background: rgba(108, 99, 255, 0.18);
}
/* Inline labels after prompt text */
.labelsInline {
display: inline;
margin-left: 6px;
white-space: nowrap;
}
.label {
display: inline-flex;
font-size: 12px;
color: var(--color-text-secondary);
padding: 1px 6px;
background: rgba(255, 255, 255, 0.06);
border-radius: 4px;
white-space: nowrap;
margin-left: 4px;
vertical-align: middle;
position: relative;
top: -1px;
}
/* Detail info link + tooltip */
.detailLink {
font-size: 12px;
color: var(--color-text-secondary);
cursor: default;
margin-left: 6px;
position: relative;
vertical-align: middle;
}
.detailTooltip {
position: fixed;
z-index: 1000;
background: rgba(13, 13, 26, 0.95);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.10);
border-radius: 8px;
padding: 12px 20px;
min-width: 260px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
animation: detailTooltipFadeIn 0.15s ease-out;
}
@keyframes detailTooltipFadeIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.detailRow {
display: flex;
justify-content: space-between;
gap: 24px;
padding: 4px 0;
font-size: 13px;
white-space: nowrap;
}
.detailRow span:first-child {
color: var(--color-text-secondary);
}
.detailRow span:last-child {
color: var(--color-text-primary);
}
/* Content */
.content {
margin-bottom: 8px;
}
/* Result area */
.resultArea {
border-radius: 12px;
overflow: hidden;
background: #0e0e16;
min-height: 200px;
background: rgba(0, 0, 0, 0.3);
aspect-ratio: 16 / 9;
max-height: 320px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.resultMedia {
width: 100%;
max-height: 400px;
height: 100%;
object-fit: contain;
display: block;
}
/* Video hover overlay with download */
.videoOverlay {
position: absolute;
inset: 0;
background: transparent;
display: flex;
align-items: flex-start;
justify-content: flex-end;
padding: 12px;
animation: overlayFadeIn 0.15s ease-out;
}
@keyframes overlayFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.downloadBtn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(4px);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
cursor: pointer;
transition: background 0.15s;
}
.downloadBtn:hover {
background: rgba(255, 255, 255, 0.25);
}
.resultPlaceholder {
display: flex;
flex-direction: column;
@ -114,23 +260,46 @@
padding: 40px;
}
/* Generating state */
/* Generating state — shimmer background */
.shimmerBg {
position: absolute;
inset: 0;
background: linear-gradient(
110deg,
rgba(108, 99, 255, 0.03) 0%,
rgba(108, 99, 255, 0.08) 40%,
rgba(139, 92, 246, 0.12) 50%,
rgba(108, 99, 255, 0.08) 60%,
rgba(108, 99, 255, 0.03) 100%
);
background-size: 200% 100%;
animation: shimmer 2.5s ease-in-out infinite;
z-index: 0;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.generating {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 40px;
gap: 10px;
padding: 32px 40px;
width: 100%;
position: relative;
z-index: 1;
}
.loadingSpinner {
width: 36px;
height: 36px;
border: 3px solid #2a2a38;
width: 32px;
height: 32px;
border: 2.5px solid rgba(108, 99, 255, 0.15);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
animation: spin 1s linear infinite;
}
@keyframes spin {
@ -140,30 +309,42 @@
.loadingText {
font-size: 13px;
color: var(--color-text-secondary);
letter-spacing: 0.5px;
}
.progressBar {
width: 100%;
max-width: 300px;
height: 4px;
background: #2a2a38;
max-width: 200px;
height: 3px;
background: rgba(255, 255, 255, 0.06);
border-radius: 2px;
overflow: hidden;
}
.progressFill {
height: 100%;
background: var(--color-primary);
background: linear-gradient(90deg, var(--color-primary), #8b5cf6);
border-radius: 2px;
transition: width 0.4s ease;
transition: width 1.5s ease-out;
}
.progressText {
font-size: 11px;
color: var(--color-text-disabled);
}
/* Failed state — no video box, just text */
.errorText {
color: #e74c3c;
font-size: 13px;
line-height: 1.5;
padding: 8px 0;
}
/* Action buttons */
.actions {
display: flex;
gap: 8px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.actionBtn {
@ -191,3 +372,67 @@
border-color: rgba(255, 107, 107, 0.3);
background: rgba(255, 107, 107, 0.08);
}
/* More menu */
.moreMenu {
position: relative;
margin-left: auto;
}
.moreBtn {
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
border-radius: 8px;
color: var(--color-text-disabled);
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
cursor: pointer;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.moreBtn:hover {
color: var(--color-text-secondary);
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.15);
}
.moreDropdown {
position: absolute;
bottom: calc(100% + 6px);
right: 0;
background: rgba(13, 13, 26, 0.95);
backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.10);
border-radius: 10px;
padding: 4px;
min-width: 100px;
z-index: 10;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
animation: dropdownFadeIn 0.12s ease-out;
}
@keyframes dropdownFadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.moreDropdown button {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 14px;
background: none;
border: none;
color: #ff6b6b;
font-size: 13px;
cursor: pointer;
border-radius: 6px;
font-family: inherit;
text-align: left;
white-space: nowrap;
transition: background 0.12s;
}
.moreDropdown button:hover {
background: rgba(255, 107, 107, 0.10);
}

View File

@ -1,5 +1,8 @@
import { useRef, useState, useEffect, useCallback } from 'react';
import type { GenerationTask } from '../types';
import { useGenerationStore } from '../store/generation';
import { showToast } from './Toast';
import { ConfirmModal } from './ConfirmModal';
import styles from './GenerationCard.module.css';
const EditIcon = () => (
@ -16,13 +19,6 @@ const RefreshIcon = () => (
</svg>
);
const TrashIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
);
const VideoIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="23 7 16 12 23 17 23 7" />
@ -30,45 +26,185 @@ const VideoIcon = () => (
</svg>
);
const DownloadIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
);
interface Props {
task: GenerationTask;
onOpenDetail?: (task: GenerationTask) => void;
}
export function GenerationCard({ task }: Props) {
export function GenerationCard({ task, onOpenDetail }: Props) {
const removeTask = useGenerationStore((s) => s.removeTask);
const reEdit = useGenerationStore((s) => s.reEdit);
const regenerate = useGenerationStore((s) => s.regenerate);
const videoRef = useRef<HTMLVideoElement>(null);
const moreRef = useRef<HTMLDivElement>(null);
const promptLineRef = useRef<HTMLDivElement>(null);
const labelsRef = useRef<HTMLSpanElement>(null);
const [videoHover, setVideoHover] = useState(false);
const [promptHover, setPromptHover] = useState(false);
const [showMore, setShowMore] = useState(false);
const [truncatedPrompt, setTruncatedPrompt] = useState(task.prompt);
const [confirmDelete, setConfirmDelete] = useState(false);
const [detailHover, setDetailHover] = useState(false);
const [detailPos, setDetailPos] = useState({ top: 0, right: 0 });
const detailLinkRef = useRef<HTMLSpanElement>(null);
// Close more menu on click outside
useEffect(() => {
if (!showMore) return;
const handler = (e: MouseEvent) => {
if (moreRef.current && !moreRef.current.contains(e.target as Node)) {
setShowMore(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [showMore]);
// JS-level prompt truncation: ensure labels always visible at end of line 2
const computeTruncation = useCallback(() => {
const container = promptLineRef.current;
const labelsEl = labelsRef.current;
if (!container || !labelsEl) return;
const containerWidth = container.offsetWidth;
if (containerWidth === 0) return;
const style = getComputedStyle(container);
const font = `${style.fontSize} ${style.fontFamily}`;
// Measure labels width
const labelsWidth = labelsEl.offsetWidth + 8; // +8 for gap
// Two lines of available width, minus labels on line 2
const totalAvailable = containerWidth * 2 - labelsWidth;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
ctx.font = font;
const prompt = task.prompt || '';
let totalWidth = 0;
let needsTruncation = false;
// Check if prompt fits
const fullWidth = ctx.measureText(prompt).width;
if (fullWidth <= totalAvailable) {
setTruncatedPrompt(prompt);
return;
}
// Truncate character by character
let truncated = '';
const ellipsisWidth = ctx.measureText('…').width;
for (const char of prompt) {
const charWidth = ctx.measureText(char).width;
if (totalWidth + charWidth + ellipsisWidth > totalAvailable) {
needsTruncation = true;
break;
}
truncated += char;
totalWidth += charWidth;
}
setTruncatedPrompt(needsTruncation ? truncated + '…' : prompt);
}, [task.prompt]);
useEffect(() => {
computeTruncation();
const container = promptLineRef.current;
if (!container) return;
const ro = new ResizeObserver(() => computeTruncation());
ro.observe(container);
return () => ro.disconnect();
}, [computeTruncation]);
const isGenerating = task.status === 'generating';
const hasResult = task.status === 'completed' && !!task.resultUrl;
const handleVideoMouseEnter = () => {
setVideoHover(true);
const v = videoRef.current;
if (!v) return;
v.muted = false;
v.play().catch(() => {
// Browser blocks unmuted autoplay — fallback to muted
v.muted = true;
v.play().catch(() => {});
});
};
const handleVideoMouseLeave = () => {
setVideoHover(false);
const v = videoRef.current;
if (!v) return;
v.pause();
v.currentTime = 0;
};
const handleDownload = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!task.resultUrl) return;
try {
const res = await fetch(task.resultUrl);
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = `airdrama-${task.id}.mp4`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobUrl), 10000);
} catch {
const a = document.createElement('a');
a.href = task.resultUrl;
a.download = `airdrama-${task.id}.mp4`;
a.click();
}
};
const handleCopyPrompt = (e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard.writeText(task.prompt).then(() => {
showToast('已复制');
});
};
const handleVideoClick = () => {
if (hasResult && onOpenDetail) {
onOpenDetail(task);
}
};
return (
<div className={styles.card}>
{/* Header: avatar + prompt */}
{/* Header: reference thumbnails + prompt + meta labels */}
<div className={styles.header}>
<div className={styles.avatar}>
<VideoIcon />
</div>
<div className={styles.headerContent}>
<p className={styles.prompt}>{task.prompt || '(无文字描述)'}</p>
<div className={styles.meta}>
<span>{task.model === 'seedance_2.0' ? 'Seedance 2.0' : 'Seedance 2.0 Fast'}</span>
<span className={styles.metaDot}>|</span>
<span>{task.duration}s</span>
<span className={styles.metaDot}>|</span>
<span>{task.aspectRatio}</span>
</div>
</div>
</div>
{/* Content area */}
<div className={styles.content}>
{/* Reference thumbnails (small) */}
{/* Left: reference thumbnails */}
{task.references.length > 0 && (
<div className={styles.refRow}>
<div className={styles.refColumn}>
{task.references.map((ref) => (
<div key={ref.id} className={styles.refThumb}>
{ref.type === 'video' ? (
<video src={ref.previewUrl} className={styles.refMedia} muted />
) : ref.type === 'audio' ? (
<div className={styles.audioThumb}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
</div>
) : (
<img src={ref.previewUrl} alt={ref.label} className={styles.refMedia} />
)}
@ -76,57 +212,166 @@ export function GenerationCard({ task }: Props) {
))}
</div>
)}
{/* Right: prompt + inline labels */}
<div className={styles.headerRight}>
<div
className={styles.promptWrapper}
onMouseLeave={() => setPromptHover(false)}
>
<div ref={promptLineRef} className={styles.promptLine}>
<span
onMouseEnter={() => setPromptHover(true)}
>{truncatedPrompt || '(无文字描述)'}</span>
<span ref={labelsRef} className={styles.labelsInline} onMouseEnter={() => setPromptHover(false)}>
<span className={styles.label}>
{task.model === 'seedance_2.0' ? 'AirDrama' : 'AirDrama Fast'}
</span>
<span className={styles.label}>{task.duration}s</span>
<span className={styles.label}>{task.aspectRatio}</span>
<span
ref={detailLinkRef}
className={styles.detailLink}
onMouseEnter={() => {
const el = detailLinkRef.current;
if (el) {
const rect = el.getBoundingClientRect();
setDetailPos({
top: rect.bottom + 8,
right: window.innerWidth - rect.right,
});
}
setDetailHover(true);
}}
onMouseLeave={() => setDetailHover(false)}
>
{detailHover && (
<div className={styles.detailTooltip} style={{ top: detailPos.top, right: detailPos.right }}>
<div className={styles.detailRow}>
<span></span><span>{task.aspectRatio}</span>
</div>
<div className={styles.detailRow}>
<span></span><span>{task.duration}s</span>
</div>
<div className={styles.detailRow}>
<span></span><span>720p</span>
</div>
<div className={styles.detailRow}>
<span></span>
<span>{task.model === 'seedance_2.0' ? 'AirDrama' : 'AirDrama Fast'}</span>
</div>
<div className={styles.detailRow}>
<span></span>
<span>{new Date(task.createdAt).toLocaleString('zh-CN')}</span>
</div>
</div>
)}
</span>
</span>
</div>
{promptHover && task.prompt && (
<div className={styles.promptTooltip}>
<p className={styles.promptTooltipText}>{task.prompt}</p>
<button className={styles.copyBtn} onClick={handleCopyPrompt}></button>
</div>
)}
</div>
</div>
</div>
{/* Generation result or loading */}
<div className={styles.resultArea}>
{isGenerating ? (
{/* Video / result area */}
<div className={styles.content}>
{isGenerating ? (
<div className={styles.resultArea}>
<div className={styles.shimmerBg} />
<div className={styles.generating}>
<div className={styles.loadingSpinner} />
<span className={styles.loadingText}>...</span>
<span className={styles.loadingText}></span>
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{ width: `${task.progress}%` }}
/>
</div>
<span className={styles.progressText}>{Math.round(task.progress)}%</span>
</div>
) : task.status === 'failed' ? (
<div className={styles.resultPlaceholder}>
<span style={{ color: '#e74c3c' }}>{task.errorMessage || '生成失败'}</span>
</div>
) : task.resultUrl ? (
</div>
) : task.status === 'failed' ? (
<p className={styles.errorText}>{task.errorMessage || '生成失败,请重试'}</p>
) : task.resultUrl ? (
<div
className={styles.resultArea}
onMouseEnter={handleVideoMouseEnter}
onMouseLeave={handleVideoMouseLeave}
onClick={handleVideoClick}
style={{ cursor: 'pointer' }}
>
<video
ref={videoRef}
src={task.resultUrl}
controls
className={styles.resultMedia}
style={{ maxWidth: '100%', borderRadius: 8 }}
loop
preload="metadata"
/>
) : (
{videoHover && (
<div className={styles.videoOverlay}>
<button className={styles.downloadBtn} onClick={handleDownload}>
<DownloadIcon />
</button>
</div>
)}
</div>
) : (
<div className={styles.resultArea}>
<div className={styles.resultPlaceholder}>
<VideoIcon />
<span></span>
</div>
)}
</div>
</div>
)}
</div>
{/* Action buttons */}
{/* Bottom action buttons */}
{!isGenerating && (
<div className={styles.actions}>
<button className={styles.actionBtn} onClick={() => reEdit(task.id)}>
<EditIcon />
<span></span>
<EditIcon /> <span></span>
</button>
<button className={styles.actionBtn} onClick={() => regenerate(task.id)}>
<RefreshIcon />
<span></span>
</button>
<button className={`${styles.actionBtn} ${styles.deleteBtn}`} onClick={() => removeTask(task.id)}>
<TrashIcon />
<span></span>
<RefreshIcon /> <span></span>
</button>
<div className={styles.moreMenu} ref={moreRef}>
<button className={styles.moreBtn} onClick={() => setShowMore(!showMore)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<circle cx="5" cy="12" r="2" />
<circle cx="12" cy="12" r="2" />
<circle cx="19" cy="12" r="2" />
</svg>
</button>
{showMore && (
<div className={styles.moreDropdown}>
<button onClick={() => { setConfirmDelete(true); setShowMore(false); }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
)}
</div>
</div>
)}
<ConfirmModal
open={confirmDelete}
title="删除视频"
message="确定要删除这条生成记录吗?此操作不可撤销。"
confirmText="删除"
danger
onConfirm={() => { removeTask(task.id); setConfirmDelete(false); }}
onCancel={() => setConfirmDelete(false)}
/>
</div>
);
}

View File

@ -11,7 +11,6 @@ export function InputBar() {
const mode = useInputBarStore((s) => s.mode);
const addReferences = useInputBarStore((s) => s.addReferences);
const setFirstFrame = useInputBarStore((s) => s.setFirstFrame);
const references = useInputBarStore((s) => s.references);
const barRef = useRef<HTMLDivElement>(null);
const handleDragOver = useCallback((e: DragEvent) => {
@ -32,18 +31,30 @@ export function InputBar() {
if (barRef.current) {
barRef.current.style.borderColor = '#2a2a38';
}
const IMAGE_MAX = 20 * 1024 * 1024;
const VIDEO_MAX = 100 * 1024 * 1024;
const IMAGE_MAX = 30 * 1024 * 1024;
const VIDEO_MAX = 50 * 1024 * 1024;
const AUDIO_MAX = 15 * 1024 * 1024;
const files = Array.from(e.dataTransfer.files).filter(
(f) => f.type.startsWith('image/') || f.type.startsWith('video/')
(f) => f.type.startsWith('image/') || f.type.startsWith('video/') || f.type.startsWith('audio/')
);
if (!files.length) return;
const valid: File[] = [];
for (const f of files) {
const limit = f.type.startsWith('video/') ? VIDEO_MAX : IMAGE_MAX;
let limit: number;
let limitLabel: string;
if (f.type.startsWith('video/')) {
limit = VIDEO_MAX;
limitLabel = '视频文件不能超过50MB';
} else if (f.type.startsWith('audio/')) {
limit = AUDIO_MAX;
limitLabel = '音频文件不能超过15MB';
} else {
limit = IMAGE_MAX;
limitLabel = '图片文件不能超过30MB';
}
if (f.size > limit) {
showToast(f.type.startsWith('video/') ? '视频文件不能超过100MB' : '图片文件不能超过20MB');
showToast(limitLabel);
} else {
valid.push(f);
}
@ -51,19 +62,14 @@ export function InputBar() {
if (!valid.length) return;
if (mode === 'universal') {
const remaining = 5 - references.length;
if (remaining <= 0) {
showToast('最多上传5张参考内容');
return;
}
addReferences(valid);
if (valid.length > remaining) {
showToast('最多上传5张参考内容');
}
} else {
setFirstFrame(valid[0]);
const imageFiles = valid.filter((f) => f.type.startsWith('image/'));
if (imageFiles.length > 0) {
setFirstFrame(imageFiles[0]);
}
}
}, [mode, references.length, addReferences, setFirstFrame]);
}, [mode, addReferences, setFirstFrame]);
return (
<div className={styles.wrapper}>

View File

@ -10,8 +10,8 @@
}
.trigger {
width: var(--thumbnail-size);
height: var(--thumbnail-size);
aspect-ratio: 3 / 4;
border: 1.5px dashed #3a3a48;
background: rgba(255, 255, 255, 0.03);
border-radius: var(--radius-btn);
@ -47,8 +47,8 @@
.thumbItem {
position: relative;
width: var(--thumbnail-size);
height: var(--thumbnail-size);
aspect-ratio: 3 / 4;
border-radius: var(--radius-thumbnail);
overflow: hidden;
background: #1a1a24;

View File

@ -13,7 +13,7 @@
line-height: 1.6;
width: 100%;
min-height: 24px;
max-height: 144px;
max-height: 202px;
font-family: 'Noto Sans SC', system-ui, sans-serif;
overflow-y: auto;
white-space: pre-wrap;
@ -33,12 +33,13 @@
/* @ mention tag (inserted as contentEditable=false span) */
.mention {
display: inline;
display: inline-block;
white-space: nowrap;
padding: 1px 6px;
margin: 0 2px;
border-radius: 4px;
background: rgba(0, 184, 230, 0.12);
color: rgba(0, 184, 230, 0.7);
background: rgba(108, 99, 255, 0.12);
color: rgba(108, 99, 255, 0.7);
font-size: 13px;
cursor: default;
user-select: none;
@ -46,21 +47,22 @@
}
.mention:hover {
background: rgba(0, 184, 230, 0.22);
color: rgba(0, 184, 230, 0.9);
background: rgba(108, 99, 255, 0.22);
color: rgba(108, 99, 255, 0.9);
}
/* Mention popup — appears above cursor */
.mentionPopup {
position: absolute;
z-index: 100;
background: #1e1e2e;
border: 1px solid #2a2a3a;
background: rgba(13, 13, 26, 0.92);
border: 1px solid rgba(255, 255, 255, 0.10);
border-radius: 10px;
padding: 6px;
min-width: 200px;
max-width: 280px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
backdrop-filter: blur(20px) saturate(180%);
transform: translateY(-100%);
animation: fadeInUp 0.12s ease;
}
@ -96,6 +98,11 @@
background: rgba(255, 255, 255, 0.06);
}
.mentionItemActive {
background: rgba(108, 99, 255, 0.15);
color: #f1f0ff;
}
.mentionThumb {
width: 36px;
height: 36px;

View File

@ -1,4 +1,5 @@
import { useRef, useEffect, useCallback, useState } from 'react';
import DOMPurify from 'dompurify';
import { useInputBarStore } from '../store/inputBar';
import type { UploadedFile } from '../types';
import styles from './PromptInput.module.css';
@ -21,6 +22,7 @@ export function PromptInput() {
const [showMentionPopup, setShowMentionPopup] = useState(false);
const [mentionPos, setMentionPos] = useState({ top: 0, left: 0 });
const typedAtRef = useRef(false); // tracks if popup was triggered by typing @
const [highlightedIdx, setHighlightedIdx] = useState(0);
const [hoverRef, setHoverRef] = useState<UploadedFile | null>(null);
const [hoverPos, setHoverPos] = useState({ top: 0, left: 0 });
@ -34,7 +36,7 @@ export function PromptInput() {
const el = editorRef.current;
if (!el) return;
if (el.innerHTML !== editorHtml) {
el.innerHTML = editorHtml;
el.innerHTML = DOMPurify.sanitize(editorHtml, { ALLOWED_TAGS: ['span', 'br'], ALLOWED_ATTR: ['class', 'contenteditable', 'data-ref-id', 'data-ref-type'] });
// If the HTML is plain text but we have references, rebuild mention spans
// This handles the case where editorHtml comes from backend (plain text only)
if (editorHtml && !editorHtml.includes('data-ref-id') && references.length > 0) {
@ -137,6 +139,7 @@ export function PromptInput() {
}
setMentionPos({ top, left });
setHighlightedIdx(0);
setShowMentionPopup(true);
}, []);
@ -147,6 +150,25 @@ export function PromptInput() {
setEditorHtml(el.innerHTML);
}, [setPrompt, setEditorHtml]);
// Remove orphaned mention spans when a reference is deleted
useEffect(() => {
const el = editorRef.current;
if (!el) return;
const refIds = new Set(references.map((r) => r.id));
const spans = el.querySelectorAll<HTMLElement>('[data-ref-id]');
let changed = false;
spans.forEach((span) => {
if (!refIds.has(span.dataset.refId!)) {
span.replaceWith('');
changed = true;
}
});
if (changed) {
el.normalize();
extractText();
}
}, [references, extractText]);
const handleInput = useCallback(() => {
extractText();
@ -220,17 +242,51 @@ export function PromptInput() {
}, [extractText]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (showMentionPopup && e.key === 'Escape') {
e.preventDefault();
setShowMentionPopup(false);
if (showMentionPopup) {
if (e.key === 'Escape') {
e.preventDefault();
setShowMentionPopup(false);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
setHighlightedIdx((prev) => (prev + 1) % references.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlightedIdx((prev) => (prev - 1 + references.length) % references.length);
} else if (e.key === 'Enter') {
e.preventDefault();
insertMention(references[highlightedIdx]);
}
}
}, [showMentionPopup]);
}, [showMentionPopup, references, highlightedIdx, insertMention]);
const handlePaste = useCallback((e: React.ClipboardEvent) => {
e.preventDefault();
const text = e.clipboardData.getData('text/plain');
// Handle pasted image files (Ctrl+V screenshot / copied image)
const items = e.clipboardData.items;
const imageFiles: File[] = [];
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
const file = items[i].getAsFile();
if (file) imageFiles.push(file);
}
}
if (imageFiles.length > 0) {
useInputBarStore.getState().addReferences(imageFiles);
return;
}
// Plain text paste — strip @label patterns to prevent duplicate mention tags
let text = e.clipboardData.getData('text/plain');
for (const ref of references) {
const pattern = `@${ref.label}`;
while (text.includes(pattern)) {
text = text.replace(pattern, ref.label);
}
}
document.execCommand('insertText', false, text);
}, []);
extractText();
}, [extractText, references]);
// Mention hover — delegated event
const handleMouseOver = useCallback((e: React.MouseEvent) => {
@ -296,7 +352,7 @@ export function PromptInput() {
{references.map((ref, idx) => (
<button
key={`${ref.id}-${idx}`}
className={styles.mentionItem}
className={`${styles.mentionItem} ${idx === highlightedIdx ? styles.mentionItemActive : ''}`}
onMouseDown={(e) => {
e.preventDefault();
insertMention(ref);

View File

@ -4,9 +4,11 @@ import { useAuthStore } from '../store/auth';
interface Props {
children: React.ReactNode;
requireAdmin?: boolean;
requireTeamAdmin?: boolean;
requireTeamMember?: boolean;
}
export function ProtectedRoute({ children, requireAdmin }: Props) {
export function ProtectedRoute({ children, requireAdmin, requireTeamAdmin, requireTeamMember }: Props) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const isLoading = useAuthStore((s) => s.isLoading);
const user = useAuthStore((s) => s.user);
@ -31,9 +33,18 @@ export function ProtectedRoute({ children, requireAdmin }: Props) {
return <Navigate to="/login" replace />;
}
if (requireAdmin && user && !user.is_staff) {
if (requireAdmin && user?.role !== 'super_admin') {
return <Navigate to="/" replace />;
}
if (requireTeamAdmin && user?.role !== 'team_admin') {
return <Navigate to="/" replace />;
}
// requireTeamMember: must have a team (team_admin or member)
if (requireTeamMember && user?.role === 'super_admin') {
return <Navigate to="/admin/dashboard" replace />;
}
return <>{children}</>;
}

View File

@ -0,0 +1,116 @@
.wrapper {
position: relative;
display: inline-block;
}
.trigger {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: var(--color-bg-card);
border: 1px solid var(--color-border-card);
border-radius: 8px;
color: var(--color-text-primary);
font-size: 13px;
cursor: pointer;
outline: none;
min-width: 120px;
transition: border-color 0.15s;
white-space: nowrap;
}
.trigger:hover,
.trigger:focus {
border-color: var(--color-primary);
}
.label {
flex: 1;
}
.placeholder {
flex: 1;
color: var(--color-text-disabled);
}
.arrow {
flex-shrink: 0;
color: var(--color-text-secondary);
transition: transform 0.2s;
}
.arrowOpen {
transform: rotate(180deg);
}
.menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
background: #16161e;
border: 1px solid var(--color-border-card);
border-radius: 8px;
padding: 4px;
z-index: 1000;
backdrop-filter: blur(20px) saturate(180%);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
opacity: 0;
transform: translateY(-4px);
pointer-events: none;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
max-height: 240px;
overflow-y: auto;
}
.open {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border-radius: 6px;
font-size: 13px;
color: #b0b0c0;
cursor: pointer;
transition: all 0.12s;
white-space: nowrap;
}
.item:hover {
background: rgba(255, 255, 255, 0.06);
color: #fff;
}
.item.selected {
color: var(--color-primary);
}
.check {
margin-left: auto;
opacity: 0;
flex-shrink: 0;
}
.item.selected .check {
opacity: 1;
}
/* Scrollbar */
.menu::-webkit-scrollbar {
width: 4px;
}
.menu::-webkit-scrollbar-track {
background: transparent;
}
.menu::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
}

View File

@ -0,0 +1,81 @@
import { useState, useRef, useEffect } from 'react';
import styles from './Select.module.css';
interface SelectOption {
label: string;
value: string;
}
interface SelectProps {
options: SelectOption[];
value: string;
onChange: (value: string) => void;
placeholder?: string;
minWidth?: number;
}
export function Select({ options, value, onChange, placeholder = '请选择', minWidth = 120 }: SelectProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handle = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handle);
return () => document.removeEventListener('mousedown', handle);
}, [open]);
const selected = options.find((o) => o.value === value);
return (
<div className={styles.wrapper} ref={ref}>
<button
type="button"
className={styles.trigger}
style={{ minWidth }}
onClick={() => setOpen(!open)}
>
{selected ? (
<span className={styles.label}>{selected.label}</span>
) : (
<span className={styles.placeholder}>{placeholder}</span>
)}
<svg
className={`${styles.arrow} ${open ? styles.arrowOpen : ''}`}
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
<div
className={`${styles.menu} ${open ? styles.open : ''}`}
style={{ minWidth }}
>
{options.map((opt) => (
<div
key={opt.value}
className={`${styles.item} ${value === opt.value ? styles.selected : ''}`}
onClick={() => {
onChange(opt.value);
setOpen(false);
}}
>
<span>{opt.label}</span>
<svg className={styles.check} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
))}
</div>
</div>
);
}

View File

@ -1,8 +1,9 @@
.sidebar {
width: 60px;
width: 76px;
height: 100%;
background: var(--color-sidebar-bg);
border-right: 1px solid #1a1a24;
backdrop-filter: blur(16px) saturate(160%);
border-right: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
flex-direction: column;
align-items: center;
@ -12,14 +13,18 @@
}
.logo {
margin-bottom: 24px;
margin-bottom: 28px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.navItems {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.navItem {
@ -27,27 +32,105 @@
flex-direction: column;
align-items: center;
gap: 4px;
padding: 12px 0;
color: #5a5a6a;
padding: 10px 0;
width: 56px;
border-radius: 8px;
color: var(--color-text-disabled);
cursor: pointer;
transition: color 0.15s;
transition: color 0.15s, background 0.15s;
font-size: 11px;
user-select: none;
}
.navItem:hover {
color: #b0b0c0;
color: var(--color-text-secondary);
background: rgba(255, 255, 255, 0.04);
}
.navItem.active {
color: var(--color-primary);
background: var(--color-sidebar-active);
}
.bottomItems {
/* Bottom section */
.bottom {
margin-top: auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
gap: 12px;
padding-bottom: 8px;
}
/* Quota display */
.quota {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
cursor: pointer;
padding: 8px 4px;
border-radius: 8px;
transition: background 0.15s;
}
.quota:hover {
background: rgba(255, 255, 255, 0.04);
}
.diamondIcon {
flex-shrink: 0;
}
.quotaNumber {
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary);
line-height: 1;
}
.quotaLabel {
font-size: 9px;
color: var(--color-text-secondary);
white-space: nowrap;
}
/* Admin button */
.adminBtn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
color: var(--color-text-disabled);
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.adminBtn:hover {
color: var(--color-primary);
background: rgba(255, 255, 255, 0.06);
}
/* User avatar */
.avatar {
width: 34px;
height: 34px;
border-radius: 50%;
background: var(--color-primary);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.avatar:hover {
opacity: 0.85;
}
@media (max-width: 767px) {

View File

@ -1,73 +1,111 @@
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../store/auth';
import styles from './Sidebar.module.css';
const sidebarItems = [
{
name: '灵感',
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1" />
</svg>
),
},
{
name: '生成',
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
),
active: true,
},
{
name: '资产',
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M3 9h18M9 3v18" />
</svg>
),
},
{
name: '画布',
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" />
</svg>
),
},
];
export function Sidebar() {
const navigate = useNavigate();
const location = useLocation();
const user = useAuthStore((s) => s.user);
const quota = useAuthStore((s) => s.quota);
const isActive = (path: string) => location.pathname === path;
const role = user?.role;
const dailyRemaining = quota
? (quota.daily_seconds_limit === -1 ? Infinity : Math.max(0, quota.daily_seconds_limit - quota.daily_seconds_used))
: 0;
return (
<aside className={styles.sidebar}>
<div className={styles.logo}>
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<path d="M4 8L14 2L24 8V20L14 26L4 20V8Z" fill="#00b8e6" opacity="0.9" />
<path d="M14 2L24 8L14 14L4 8L14 2Z" fill="#33ccf0" />
{/* Logo */}
<div className={styles.logo} onClick={() => navigate(role === 'super_admin' ? '/admin/dashboard' : '/')}>
<svg width="32" height="32" viewBox="0 0 28 28" fill="none">
<path d="M4 8L14 2L24 8V20L14 26L4 20V8Z" fill="#6c63ff" opacity="0.9" />
<path d="M14 2L24 8L14 14L4 8L14 2Z" fill="#8b83ff" />
<path d="M10 10L18 6" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" opacity="0.6" />
</svg>
</div>
<div className={styles.navItems}>
{sidebarItems.map((item) => (
<div
key={item.name}
className={`${styles.navItem} ${item.active ? styles.active : ''}`}
title={item.name}
>
{item.icon}
<span>{item.name}</span>
</div>
))}
</div>
{/* Nav items */}
<nav className={styles.navItems}>
{/* Video generation - team members and team admins only */}
{role !== 'super_admin' && (
<>
<div
className={`${styles.navItem} ${isActive('/') ? styles.active : ''}`}
onClick={() => navigate('/')}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span></span>
</div>
<div
className={`${styles.navItem} ${isActive('/assets') ? styles.active : ''}`}
onClick={() => navigate('/assets')}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M3 9h18M9 3v18" />
</svg>
<span></span>
</div>
</>
)}
<div className={styles.bottomItems}>
<div className={styles.navItem} title="API">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
<span style={{ fontSize: 10 }}>API</span>
{/* Team management - team admin only */}
{role === 'team_admin' && (
<div
className={`${styles.navItem} ${location.pathname.startsWith('/team') ? styles.active : ''}`}
onClick={() => navigate('/team/dashboard')}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" />
</svg>
<span></span>
</div>
)}
</nav>
{/* Bottom section: quota + avatar + admin */}
<div className={styles.bottom}>
{/* Quota display - not for super admin */}
{role !== 'super_admin' && (
<div className={styles.quota} onClick={() => navigate('/profile')}>
<svg className={styles.diamondIcon} width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M6 3h12l4 8-10 12L2 11l4-8z" fill="#6c63ff" opacity="0.85" />
<path d="M2 11h20M6 3l4 8M18 3l-4 8M12 23l-4-12M12 23l4-12" stroke="#fff" strokeWidth="0.8" opacity="0.4" />
</svg>
<span className={styles.quotaNumber}>
{dailyRemaining === Infinity ? '∞' : dailyRemaining.toLocaleString()}
</span>
<span className={styles.quotaLabel}></span>
</div>
)}
{/* Admin entry - super admin only */}
{role === 'super_admin' && (
<div
className={styles.adminBtn}
onClick={() => navigate('/admin/dashboard')}
title="管理后台"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" />
</svg>
</div>
)}
{/* User avatar */}
<div
className={styles.avatar}
onClick={() => navigate(role === 'super_admin' ? '/admin/dashboard' : '/profile')}
title={user?.username || '个人中心'}
>
{user?.username?.charAt(0).toUpperCase() || 'U'}
</div>
</div>
</aside>

View File

@ -1,6 +1,6 @@
.toast {
position: fixed;
top: 60px;
top: 20px;
left: 50%;
transform: translateX(-50%) translateY(-20px);
background: var(--color-bg-dropdown);

View File

@ -39,16 +39,6 @@
flex: 1;
}
.credits {
display: flex;
align-items: center;
gap: 4px;
color: var(--color-text-secondary);
font-size: 12px;
margin-right: 8px;
opacity: 0.7;
}
.sendBtn {
width: var(--send-btn-size);
height: var(--send-btn-size);

View File

@ -70,7 +70,8 @@ const generationTypeItems = [
];
const modelItems = [
{ label: 'Seedance 2.0', value: 'seedance_2.0' as ModelOption, icon: <DiamondIcon /> },
{ label: 'AirDrama', value: 'seedance_2.0' as ModelOption, icon: <DiamondIcon /> },
{ label: 'AirDrama Fast', value: 'seedance_2.0_fast' as ModelOption, icon: <LightningIcon /> },
];
const modeItems = [
@ -79,12 +80,17 @@ const modeItems = [
];
const ratioItems = [
{ label: '16:9', value: '16:9' as AspectRatio },
{ label: '9:16', value: '9:16' as AspectRatio },
{ label: '1:1', value: '1:1' as AspectRatio },
{ label: '21:9', value: '21:9' as AspectRatio },
{ label: '16:9', value: '16:9' as AspectRatio },
{ label: '4:3', value: '4:3' as AspectRatio },
{ label: '1:1', value: '1:1' as AspectRatio },
{ label: '3:4', value: '3:4' as AspectRatio },
{ label: '9:16', value: '9:16' as AspectRatio },
];
const keyframeRatioItems = [
{ label: '自适应', value: 'adaptive' as AspectRatio },
...ratioItems,
];
const durationItems = Array.from({ length: 12 }, (_, i) => {
@ -143,11 +149,20 @@ export function Toolbar() {
<span className={styles.label}></span>
</button>
{/* Model — fixed to Seedance 2.0 */}
<button className={styles.btn}>
<DiamondIcon />
<span className={styles.label}>Seedance 2.0</span>
</button>
{/* Model selector */}
<Dropdown
items={modelItems}
value={model}
onSelect={(v) => setModel(v as ModelOption)}
minWidth={160}
trigger={
<button className={styles.btn}>
{model === 'seedance_2.0_fast' ? <LightningIcon /> : <DiamondIcon />}
<span className={styles.label}>{model === 'seedance_2.0_fast' ? 'AirDrama Fast' : 'AirDrama'}</span>
<ChevronDown />
</button>
}
/>
{/* Mode selector */}
<Dropdown
@ -165,25 +180,18 @@ export function Toolbar() {
/>
{/* Aspect ratio */}
{isKeyframe ? (
<button className={styles.btn} style={{ opacity: 0.5, pointerEvents: 'none' }}>
<MonitorIcon />
<span className={styles.label}></span>
</button>
) : (
<Dropdown
items={ratioItems}
value={aspectRatio}
onSelect={(v) => setAspectRatio(v as AspectRatio)}
minWidth={100}
trigger={
<button className={styles.btn}>
<MonitorIcon />
<span className={styles.label}>{aspectRatio}</span>
</button>
}
/>
)}
<Dropdown
items={isKeyframe ? keyframeRatioItems : ratioItems}
value={aspectRatio}
onSelect={(v) => setAspectRatio(v as AspectRatio)}
minWidth={100}
trigger={
<button className={styles.btn}>
<MonitorIcon />
<span className={styles.label}>{aspectRatio === 'adaptive' ? '自适应' : aspectRatio}</span>
</button>
}
/>
{/* Duration */}
<Dropdown
@ -209,15 +217,6 @@ export function Toolbar() {
{/* Spacer */}
<div className={styles.spacer} />
{/* Credits indicator */}
<div className={styles.credits}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
<span>30</span>
</div>
{/* Send button */}
<button
className={`${styles.sendBtn} ${isSubmittable ? styles.sendEnabled : styles.sendDisabled}`}

View File

@ -1,8 +1,15 @@
.wrapper {
flex-shrink: 0;
display: flex;
gap: 8px;
align-items: flex-start;
position: relative;
z-index: 10;
}
/* hasFiles state: fixed dimensions via inline style, overflow visible for expanded content */
.wrapperActive {
overflow: visible;
align-self: flex-start;
}
.hiddenInput {
@ -10,8 +17,8 @@
}
.trigger {
width: var(--thumbnail-size);
height: var(--thumbnail-size);
aspect-ratio: 3 / 4;
border: 1.5px dashed #3a3a48;
background: rgba(255, 255, 255, 0.03);
border-radius: var(--radius-btn);
@ -35,32 +42,56 @@
color: var(--color-text-disabled);
}
/* Single row container for all thumbnails */
/* Always absolute — no position toggling to avoid jitter */
.thumbRow {
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: flex-end;
align-items: flex-start;
flex-shrink: 0;
position: relative;
}
/* Each thumbnail card */
/* Expanded: overlay on top of prompt text */
.thumbRowExpanded {
z-index: 11;
padding-right: 12px;
}
/* Add-more button gets opaque background when expanded (overlays prompt text) */
.thumbRowExpanded .addMore {
background: #16161e;
border-color: #3a3a48;
}
.thumbRowExpanded .addMore:hover {
background: #1e1e2a;
border-color: #5a5a6a;
}
/* Each thumbnail card — 3:4 portrait ratio, overflow visible for tooltip */
.thumbItem {
position: relative;
width: var(--thumbnail-size);
height: var(--thumbnail-size);
aspect-ratio: 3 / 4;
overflow: visible;
flex-shrink: 0;
cursor: default;
transition: margin-left 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Inner container: clips media, label, close button within rounded corners */
.thumbInner {
width: 100%;
height: 100%;
border-radius: var(--radius-thumbnail);
overflow: hidden;
background: #1a1a24;
flex-shrink: 0;
border: 1.5px solid #2a2a38;
cursor: default;
transition:
margin-left 0.35s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.35s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.2s;
position: relative;
}
.thumbItem:hover {
.thumbItem:hover .thumbInner {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
}
@ -71,7 +102,7 @@
display: block;
}
/* Close / remove button */
/* Close / remove button — inside thumbInner */
.thumbClose {
position: absolute;
top: 4px;
@ -97,7 +128,30 @@
opacity: 1;
}
/* Label at bottom of thumbnail */
/* Tooltip above thumbnail on hover — outside thumbInner */
.thumbTooltip {
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
padding: 4px 10px;
border-radius: 6px;
background: rgba(13, 13, 26, 0.92);
border: 1px solid rgba(255, 255, 255, 0.10);
color: var(--color-text-primary);
font-size: 12px;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s;
z-index: 20;
}
.itemExpanded:hover .thumbTooltip {
opacity: 1;
}
/* Label at bottom of thumbnail — inside thumbInner */
.thumbLabel {
position: absolute;
bottom: 0;
@ -113,16 +167,18 @@
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-radius: 0 0 var(--radius-thumbnail) var(--radius-thumbnail);
}
.itemExpanded .thumbLabel {
opacity: 1;
}
/* Add more button */
/* Add more button — 3:4 to match thumbnails */
.addMore {
width: var(--thumbnail-size);
position: relative;
height: var(--thumbnail-size);
aspect-ratio: 3 / 4;
border: 1.5px dashed #3a3a48;
background: rgba(255, 255, 255, 0.03);
border-radius: var(--radius-btn);
@ -133,6 +189,7 @@
gap: 4px;
cursor: pointer;
flex-shrink: 0;
overflow: visible;
transition:
margin-left 0.35s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.25s,
@ -145,32 +202,82 @@
background: rgba(255, 255, 255, 0.06);
}
.addMoreHidden {
opacity: 0;
pointer-events: none;
}
.addMoreVisible {
opacity: 1;
pointer-events: auto;
}
/* Count badge shown in collapsed state */
/* Tooltip for add-more button */
.addMoreTooltip {
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
padding: 4px 10px;
border-radius: 6px;
background: rgba(13, 13, 26, 0.92);
border: 1px solid rgba(255, 255, 255, 0.10);
color: var(--color-text-primary);
font-size: 12px;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s;
z-index: 20;
}
.addMore:hover .addMoreTooltip {
opacity: 1;
}
/* "+" badge — positioned relative to .wrapper, left set via inline style */
.countBadge {
position: absolute;
bottom: -2px;
right: -6px;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: var(--color-primary);
bottom: -6px;
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(4px);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
font-size: 11px;
font-weight: 600;
font-size: 16px;
font-weight: 400;
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
pointer-events: none;
cursor: pointer;
transition: background 0.15s;
}
.countBadge:hover {
background: rgba(255, 255, 255, 0.25);
}
/* Tooltip for "+" badge */
.badgeTooltip {
position: absolute;
bottom: calc(100% + 6px);
right: -8px;
white-space: nowrap;
padding: 4px 10px;
border-radius: 6px;
background: rgba(13, 13, 26, 0.92);
border: 1px solid rgba(255, 255, 255, 0.10);
color: var(--color-text-primary);
font-size: 12px;
pointer-events: none;
z-index: 101;
}
/* Audio placeholder icon */
.audioPlaceholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #1a1a24;
color: var(--color-text-secondary);
}

View File

@ -3,12 +3,29 @@ import { useInputBarStore } from '../store/inputBar';
import { showToast } from './Toast';
import styles from './UniversalUpload.module.css';
const MAX_IMAGE_SIZE = 30 * 1024 * 1024; // 30MB per API doc
const MAX_VIDEO_SIZE = 50 * 1024 * 1024; // 50MB per API doc
const MAX_AUDIO_SIZE = 15 * 1024 * 1024; // 15MB per API doc
const THUMB_H = 80; // matches --thumbnail-size
const THUMB_W = THUMB_H * 3 / 4; // 60px (aspect-ratio 3:4)
const PEEK = 12; // visible width per stacked card beyond the first
const AudioIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
);
export function UniversalUpload() {
const references = useInputBarStore((s) => s.references);
const addReferences = useInputBarStore((s) => s.addReferences);
const removeReference = useInputBarStore((s) => s.removeReference);
const fileInputRef = useRef<HTMLInputElement>(null);
const [expanded, setExpanded] = useState(false);
const [badgeHover, setBadgeHover] = useState(false);
const handleTrigger = () => {
fileInputRef.current?.click();
@ -18,19 +35,22 @@ export function UniversalUpload() {
const files = Array.from(e.target.files || []);
if (!files.length) return;
const remaining = 5 - references.length;
if (remaining <= 0) {
showToast('最多上传5张参考内容');
return;
}
const IMAGE_MAX = 20 * 1024 * 1024;
const VIDEO_MAX = 100 * 1024 * 1024;
const valid: File[] = [];
for (const f of files) {
const limit = f.type.startsWith('video/') ? VIDEO_MAX : IMAGE_MAX;
let limit: number;
let limitLabel: string;
if (f.type.startsWith('video/')) {
limit = MAX_VIDEO_SIZE;
limitLabel = '视频文件不能超过50MB';
} else if (f.type.startsWith('audio/')) {
limit = MAX_AUDIO_SIZE;
limitLabel = '音频文件不能超过15MB';
} else {
limit = MAX_IMAGE_SIZE;
limitLabel = '图片文件不能超过30MB';
}
if (f.size > limit) {
showToast(f.type.startsWith('video/') ? '视频文件不能超过100MB' : '图片文件不能超过20MB');
showToast(limitLabel);
} else {
valid.push(f);
}
@ -38,21 +58,29 @@ export function UniversalUpload() {
if (!valid.length) { e.target.value = ''; return; }
addReferences(valid);
if (valid.length > remaining) {
showToast('最多上传5张参考内容');
}
e.target.value = '';
};
const hasFiles = references.length > 0;
const count = references.length;
// Check if all type slots are full
const counts = { image: 0, video: 0, audio: 0 };
for (const ref of references) counts[ref.type]++;
const allFull = counts.image >= 9 && counts.video >= 3 && counts.audio >= 3;
// Collapsed stack visual width
const stackWidth = THUMB_W + Math.max(0, count - 1) * PEEK;
return (
<div className={styles.wrapper}>
<div
className={`${styles.wrapper} ${hasFiles ? styles.wrapperActive : ''}`}
style={hasFiles ? { width: stackWidth, height: THUMB_H } : undefined}
>
<input
ref={fileInputRef}
type="file"
accept="image/*,video/*"
accept="image/*,video/*,audio/*"
multiple
className={styles.hiddenInput}
onChange={handleFileChange}
@ -69,64 +97,80 @@ export function UniversalUpload() {
</div>
)}
{/* Thumbnails row - single container, animate via CSS transitions */}
{/* Thumbnails — thumbRow always absolute, hover to expand */}
{hasFiles && (
<div
className={styles.thumbRow}
onMouseEnter={() => setExpanded(true)}
onMouseLeave={() => setExpanded(false)}
>
{references.map((ref, i) => (
<div
key={ref.id}
className={`${styles.thumbItem} ${expanded ? styles.itemExpanded : ''}`}
style={{
marginLeft: i === 0 ? 0 : (expanded ? 8 : -64),
zIndex: expanded ? 1 : count - i,
transform: expanded
? 'rotate(0deg) translateY(0px)'
: `rotate(${i * -2.5}deg) translateY(${i * -2}px)`,
}}
>
{ref.type === 'video' ? (
<video src={ref.previewUrl} className={styles.thumbMedia} muted />
) : (
<img src={ref.previewUrl} alt={ref.label} className={styles.thumbMedia} />
)}
<>
<div
className={`${styles.thumbRow} ${expanded ? styles.thumbRowExpanded : ''}`}
onMouseEnter={() => setExpanded(true)}
onMouseLeave={() => setExpanded(false)}
>
{references.map((ref, i) => (
<div
className={styles.thumbClose}
onClick={(e) => { e.stopPropagation(); removeReference(ref.id); }}
key={ref.id}
className={`${styles.thumbItem} ${expanded ? styles.itemExpanded : ''}`}
style={{
marginLeft: i === 0 ? 0 : (expanded ? 8 : -48),
zIndex: expanded ? 1 : count - i,
}}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="3" strokeLinecap="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
<div className={styles.thumbInner}>
{ref.type === 'video' ? (
<video src={ref.previewUrl} className={styles.thumbMedia} muted />
) : ref.type === 'audio' ? (
<div className={styles.audioPlaceholder}>
<AudioIcon />
</div>
) : (
<img src={ref.previewUrl} alt={ref.label} className={styles.thumbMedia} />
)}
<div
className={styles.thumbClose}
onClick={(e) => { e.stopPropagation(); removeReference(ref.id); }}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="3" strokeLinecap="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</div>
<div className={styles.thumbLabel}>{ref.label}</div>
</div>
<div className={styles.thumbTooltip}>{ref.label}</div>
</div>
<div className={styles.thumbLabel}>{ref.label}</div>
</div>
))}
))}
{/* Add more button */}
{references.length < 5 && (
{/* Add more button (expanded state only) */}
{expanded && !allFull && (
<div
className={`${styles.addMore} ${styles.addMoreVisible}`}
style={{ marginLeft: 8 }}
onClick={(e) => { e.stopPropagation(); handleTrigger(); }}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#5a5a6a" strokeWidth="1.5" strokeLinecap="round">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
<div className={styles.addMoreTooltip}></div>
</div>
)}
</div>
{/* "+" badge — outside thumbRow, position based on stack width */}
{!expanded && !allFull && (
<div
className={`${styles.addMore} ${expanded ? styles.addMoreVisible : styles.addMoreHidden}`}
style={{
marginLeft: expanded ? 8 : -64,
}}
className={styles.countBadge}
style={{ left: stackWidth - 14 }}
onClick={(e) => { e.stopPropagation(); handleTrigger(); }}
onMouseEnter={() => setBadgeHover(true)}
onMouseLeave={() => setBadgeHover(false)}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#5a5a6a" strokeWidth="1.5" strokeLinecap="round">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
+
{badgeHover && (
<div className={styles.badgeTooltip}></div>
)}
</div>
)}
{/* Count badge when collapsed */}
{!expanded && count > 1 && (
<div className={styles.countBadge}>{count}</div>
)}
</div>
</>
)}
</div>
);

View File

@ -1,100 +0,0 @@
.bar {
position: fixed;
top: 0;
right: 0;
left: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: flex-end;
padding: 8px 24px;
background: rgba(10, 10, 15, 0.85);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--color-border-input-bar);
}
.userSection {
display: flex;
align-items: center;
gap: 10px;
}
.avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--color-primary);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
}
.username {
color: var(--color-text-primary);
font-size: 13px;
font-weight: 500;
}
.quota {
color: var(--color-text-secondary);
font-size: 12px;
padding: 2px 8px;
background: rgba(255, 255, 255, 0.04);
border-radius: 4px;
}
.actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: 16px;
}
.adminBtn {
padding: 4px 12px;
background: transparent;
border: 1px solid var(--color-border-input-bar);
border-radius: 6px;
color: var(--color-primary);
font-size: 12px;
cursor: pointer;
transition: background 0.2s;
}
.adminBtn:hover {
background: var(--color-bg-hover);
}
.logoutBtn {
padding: 4px 12px;
background: transparent;
border: 1px solid var(--color-border-input-bar);
border-radius: 6px;
color: var(--color-text-secondary);
font-size: 12px;
cursor: pointer;
transition: background 0.2s;
}
.logoutBtn:hover {
background: var(--color-bg-hover);
}
.profileBtn {
padding: 4px 12px;
background: transparent;
border: 1px solid var(--color-border-input-bar);
border-radius: 6px;
color: var(--color-text-secondary);
font-size: 12px;
cursor: pointer;
transition: background 0.2s;
}
.profileBtn:hover {
background: var(--color-bg-hover);
color: var(--color-primary);
}

View File

@ -1,46 +0,0 @@
import { useAuthStore } from '../store/auth';
import { useNavigate } from 'react-router-dom';
import styles from './UserInfoBar.module.css';
export function UserInfoBar() {
const user = useAuthStore((s) => s.user);
const quota = useAuthStore((s) => s.quota);
const logout = useAuthStore((s) => s.logout);
const navigate = useNavigate();
if (!user) return null;
const handleLogout = () => {
logout();
navigate('/login', { replace: true });
};
return (
<div className={styles.bar}>
<div className={styles.userSection}>
<div className={styles.avatar}>
{user.username.charAt(0).toUpperCase()}
</div>
<span className={styles.username}>{user.username}</span>
{quota && (
<span className={styles.quota}>
: {Math.max(quota.daily_seconds_limit - quota.daily_seconds_used, 0)}s/{quota.daily_seconds_limit}s()
</span>
)}
</div>
<div className={styles.actions}>
<button className={styles.profileBtn} onClick={() => navigate('/profile')}>
</button>
{user.is_staff && (
<button className={styles.adminBtn} onClick={() => navigate('/admin/dashboard')}>
</button>
)}
<button className={styles.logoutBtn} onClick={handleLogout}>
退
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,499 @@
.overlay {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 76px; /* sidebar width */
z-index: 200;
background: #07070f;
display: flex;
overflow: hidden;
animation: overlayIn 0.2s ease-out;
}
@keyframes overlayIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal {
position: relative;
z-index: 2;
display: flex;
width: 100%;
height: 100%;
overflow: hidden;
}
/*
Left: Video player section
*/
.playerSection {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
padding: 0 72px 20px 20px; /* right: space for close + arrows */
}
/* Close button — top-right, aligned with right panel header */
.closeBtn {
position: absolute;
top: 20px;
right: 20px;
z-index: 10;
width: 36px;
height: 36px;
background: rgba(255, 255, 255, 0.06);
border: none;
color: rgba(255, 255, 255, 0.5);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 8px;
transition: color 0.15s, background 0.15s;
}
.closeBtn:hover {
color: #fff;
background: rgba(255, 255, 255, 0.12);
}
/* Video area — centres the player */
.videoArea {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
padding: 20px 0; /* vertical breathing room, matches bottom padding */
}
/* Rounded video container — sized by JS (ResizeObserver + aspect ratio) */
.videoContainer {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
overflow: hidden;
border-radius: 16px;
background: #000;
position: relative;
}
.video {
width: 100%;
height: 100%;
object-fit: contain;
}
/* ── Controls bar — inside rounded container ── */
.controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
flex-direction: column;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
border-radius: 0 0 16px 16px;
padding: 24px 0 0;
opacity: 0;
transition: opacity 0.25s;
}
.controlsVisible {
opacity: 1;
}
/* Progress bar — full width at top of controls */
.progressTrack {
width: 100%;
height: 4px;
background: rgba(255, 255, 255, 0.2);
cursor: pointer;
position: relative;
flex-shrink: 0;
}
.progressTrack:hover {
height: 6px;
}
.progressFill {
height: 100%;
background: var(--color-primary);
transition: width 0.1s linear;
}
/* Controls row — below progress bar */
.controlsRow {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px 12px;
}
.controlBtn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: none;
color: #fff;
cursor: pointer;
flex-shrink: 0;
border-radius: 4px;
transition: background 0.15s;
}
.controlBtn:hover {
background: rgba(255, 255, 255, 0.1);
}
.timeDisplay {
font-size: 13px;
color: rgba(255, 255, 255, 0.8);
white-space: nowrap;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.controlsSpacer {
flex: 1;
}
/* Volume control */
.volumeControl {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.volumeSlider {
width: 72px;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: rgba(255, 255, 255, 0.2);
border-radius: 2px;
outline: none;
cursor: pointer;
}
.volumeSlider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: #fff;
cursor: pointer;
}
.volumeSlider::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: #fff;
border: none;
cursor: pointer;
}
/* ── Nav arrows — in the right padding of playerSection ── */
.navArrows {
position: absolute;
right: 20px;
top: 50%;
transform: translateY(-50%);
z-index: 10;
display: flex;
flex-direction: column;
gap: 40px;
}
.navArrowBtn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.1);
border: none;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.navArrowBtn:hover:not(.navArrowDisabled) {
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
.navArrowDisabled {
opacity: 0.3;
pointer-events: none;
}
/*
Right: Info panel (glass style)
*/
.infoPanel {
width: 360px;
flex-shrink: 0;
display: flex;
flex-direction: column;
border-left: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
backdrop-filter: blur(24px) saturate(180%);
-webkit-backdrop-filter: blur(24px) saturate(180%);
}
/* Header with download + icons */
.infoPanelHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.headerIcons {
display: flex;
align-items: center;
gap: 4px;
}
.iconBtn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
background: none;
border: none;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.iconBtn:hover {
color: rgba(255, 255, 255, 0.85);
background: rgba(255, 255, 255, 0.06);
}
/* More menu dropdown */
.moreMenuWrap {
position: relative;
}
.moreDropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
min-width: 120px;
background: #1a1a24;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
padding: 4px;
z-index: 20;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.moreDropdownItem {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
border: none;
background: none;
color: #ef4444;
font-size: 13px;
cursor: pointer;
border-radius: 6px;
font-family: inherit;
transition: background 0.15s;
}
.moreDropdownItem:hover {
background: rgba(255, 255, 255, 0.06);
}
.downloadBtn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 24px;
border-radius: 10px;
background: var(--color-primary);
color: #fff;
border: none;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s;
font-family: inherit;
}
.downloadBtn:hover {
opacity: 0.85;
}
/* Scrollable content area */
.infoPanelContent {
flex: 1;
overflow-y: auto;
padding: 0 24px 24px;
}
/* Prompt */
.promptSection {
padding-top: 20px;
padding-bottom: 16px;
}
.sectionLabel {
font-size: 12px;
font-weight: 500;
color: #8b8ea8;
margin-bottom: 10px;
}
.promptText {
font-size: 14px;
color: #f1f0ff;
line-height: 1.7;
word-break: break-word;
}
/* References */
.refSection {
padding-bottom: 16px;
}
.refGrid {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.refItem {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.refImg {
width: 56px;
height: 56px;
border-radius: 6px;
object-fit: cover;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.refAudioPlaceholder {
width: 56px;
height: 56px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
display: flex;
align-items: center;
justify-content: center;
color: #8b8ea8;
}
.refLabel {
font-size: 10px;
color: #8b8ea8;
}
/* ── Fixed bottom section ── */
.infoPanelBottom {
flex-shrink: 0;
padding: 16px 24px 24px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
/* Compact info bar — single-line meta */
.infoBar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 16px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
font-size: 13px;
color: #8b8ea8;
margin-bottom: 12px;
}
.infoBarDot {
width: 3px;
height: 3px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
flex-shrink: 0;
}
/* Action buttons — ghost style */
.cardActions {
display: flex;
gap: 10px;
}
.cardBtn {
display: inline-flex;
align-items: center;
gap: 6px;
flex: 1;
padding: 10px 0;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.10);
border-radius: 10px;
font-size: 13px;
color: #8b8ea8;
cursor: pointer;
transition: color 0.15s, background 0.15s;
font-family: inherit;
justify-content: center;
}
.cardBtn:hover {
color: #f1f0ff;
background: rgba(255, 255, 255, 0.10);
}
/*
Mobile
*/
@media (max-width: 767px) {
.overlay {
left: 0;
}
.modal {
flex-direction: column;
}
.playerSection {
flex: none;
height: 50vh;
padding: 0 56px 12px 12px;
}
.infoPanel {
flex: 1;
width: 100%;
border-left: none;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
}

View File

@ -0,0 +1,504 @@
import { useRef, useState, useEffect, useCallback, useMemo } from 'react';
import type { GenerationTask } from '../types';
import { AmbientBackground } from './AmbientBackground';
import { ConfirmModal } from './ConfirmModal';
import styles from './VideoDetailModal.module.css';
interface Props {
task: GenerationTask | null;
onClose: () => void;
onReEdit: (id: string) => void;
onRegenerate: (id: string) => void;
onDelete?: (id: string) => void;
onPrev?: () => void;
onNext?: () => void;
hasPrev?: boolean;
hasNext?: boolean;
}
export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDelete, onPrev, onNext, hasPrev, hasNext }: Props) {
const videoRef = useRef<HTMLVideoElement>(null);
const videoContainerRef = useRef<HTMLDivElement>(null);
const videoAreaRef = useRef<HTMLDivElement>(null);
const progressRef = useRef<HTMLDivElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [showControls, setShowControls] = useState(true);
const [volume, setVolume] = useState(1);
const [isMuted, setIsMuted] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [showMoreMenu, setShowMoreMenu] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [fitSize, setFitSize] = useState<{ w: number; h: number } | null>(null);
const moreMenuRef = useRef<HTMLDivElement>(null);
const hideTimerRef = useRef<ReturnType<typeof setTimeout>>();
// Parse aspect ratio from task
const arNum = useMemo(() => {
const ar = task?.aspectRatio || '16:9';
const parts = ar.split(':').map(Number);
return (parts[0] && parts[1]) ? parts[0] / parts[1] : 16 / 9;
}, [task?.aspectRatio]);
// Compute container size to fit aspect ratio within videoArea
useEffect(() => {
const el = videoAreaRef.current;
if (!el || !task) return;
const ro = new ResizeObserver(([entry]) => {
const { width: aw, height: ah } = entry.contentRect;
let w = aw;
let h = w / arNum;
if (h > ah) { h = ah; w = h * arNum; }
setFitSize({ w, h });
});
ro.observe(el);
return () => ro.disconnect();
}, [arNum, task]);
// ESC to close
useEffect(() => {
if (!task) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKey);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', handleKey);
document.body.style.overflow = '';
};
}, [task, onClose]);
// Close more menu on outside click
useEffect(() => {
if (!showMoreMenu) return;
const handleClick = (e: MouseEvent) => {
if (moreMenuRef.current && !moreMenuRef.current.contains(e.target as Node)) {
setShowMoreMenu(false);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [showMoreMenu]);
// Reset playback state when task changes (for prev/next navigation)
useEffect(() => {
if (!task) return;
const v = videoRef.current;
if (v) {
v.pause();
v.currentTime = 0;
}
setIsPlaying(false);
setCurrentTime(0);
setDuration(0);
setShowMoreMenu(false);
}, [task?.id]);
// Track fullscreen changes
useEffect(() => {
const handler = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', handler);
return () => document.removeEventListener('fullscreenchange', handler);
}, []);
const scheduleHideControls = useCallback(() => {
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
setShowControls(true);
hideTimerRef.current = setTimeout(() => {
if (isPlaying) setShowControls(false);
}, 3000);
}, [isPlaying]);
const togglePlay = () => {
const v = videoRef.current;
if (!v) return;
if (v.paused) {
v.play().catch(() => {});
} else {
v.pause();
}
};
const handleTimeUpdate = () => {
const v = videoRef.current;
if (v) setCurrentTime(v.currentTime);
};
const handleLoadedMetadata = () => {
const v = videoRef.current;
if (v) setDuration(v.duration);
};
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
const v = videoRef.current;
const bar = progressRef.current;
if (!v || !bar) return;
const rect = bar.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
v.currentTime = ratio * v.duration;
};
const toggleMute = () => {
const v = videoRef.current;
if (!v) return;
const newMuted = !isMuted;
v.muted = newMuted;
setIsMuted(newMuted);
};
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = parseFloat(e.target.value);
setVolume(val);
if (videoRef.current) {
videoRef.current.volume = val;
videoRef.current.muted = val === 0;
}
setIsMuted(val === 0);
};
const toggleFullscreen = () => {
const el = videoContainerRef.current;
if (!el) return;
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {});
} else {
el.requestFullscreen().catch(() => {});
}
};
const handleDownload = async () => {
if (!task?.resultUrl) return;
try {
const res = await fetch(task.resultUrl);
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = `airdrama-${task.id}.mp4`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobUrl), 10000);
} catch {
const a = document.createElement('a');
a.href = task.resultUrl;
a.download = `airdrama-${task.id}.mp4`;
a.click();
}
};
const handleReEdit = () => {
if (task) {
onReEdit(task.id);
onClose();
}
};
const handleRegenerate = () => {
if (task) {
onRegenerate(task.id);
onClose();
}
};
const handleDelete = () => {
setConfirmDelete(true);
setShowMoreMenu(false);
};
const doDelete = () => {
if (task && onDelete) {
onDelete(task.id);
onClose();
}
setConfirmDelete(false);
};
const formatTime = (s: number) => {
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
};
if (!task) return null;
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
const modelLabel = task.model === 'seedance_2.0' ? 'AirDrama' : 'AirDrama Fast';
const modeLabel = task.mode === 'universal' ? '全能参考' : '首尾帧';
const effectiveVolume = isMuted ? 0 : volume;
return (
<div className={styles.overlay} onClick={onClose}>
<AmbientBackground />
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
{/* Left: Video player area — clicking blank space closes modal */}
<div
className={styles.playerSection}
onClick={onClose}
onMouseMove={scheduleHideControls}
onMouseEnter={scheduleHideControls}
>
{/* Close button — top-right corner of player section */}
<button className={styles.closeBtn} onClick={onClose}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
{/* Video area — centers the aspect-ratio-aware player */}
<div ref={videoAreaRef} className={styles.videoArea}>
<div
ref={videoContainerRef}
className={styles.videoContainer}
style={fitSize ? { width: fitSize.w, height: fitSize.h } : undefined}
onClick={(e) => { e.stopPropagation(); togglePlay(); }}
>
<video
ref={videoRef}
src={task.resultUrl}
className={styles.video}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
preload="auto"
/>
{/* Full controls bar — inside rounded container */}
<div
className={`${styles.controls} ${showControls ? styles.controlsVisible : ''}`}
onClick={(e) => e.stopPropagation()}
>
{/* Progress bar (full width, top of controls) */}
<div
ref={progressRef}
className={styles.progressTrack}
onClick={handleProgressClick}
>
<div className={styles.progressFill} style={{ width: `${progress}%` }} />
</div>
{/* Controls row */}
<div className={styles.controlsRow}>
{/* Play/Pause */}
<button className={styles.controlBtn} onClick={togglePlay}>
{isPlaying ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="4" width="4" height="16" rx="1" />
<rect x="14" y="4" width="4" height="16" rx="1" />
</svg>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
)}
</button>
{/* Time */}
<span className={styles.timeDisplay}>
{formatTime(currentTime)} / {formatTime(duration)}
</span>
{/* Spacer */}
<div className={styles.controlsSpacer} />
{/* Volume */}
<div className={styles.volumeControl}>
<button className={styles.controlBtn} onClick={toggleMute}>
{effectiveVolume === 0 ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" fill="currentColor" stroke="none" />
<line x1="23" y1="9" x2="17" y2="15" />
<line x1="17" y1="9" x2="23" y2="15" />
</svg>
) : effectiveVolume < 0.5 ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" fill="currentColor" stroke="none" />
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" fill="currentColor" stroke="none" />
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
</svg>
)}
</button>
<input
type="range"
min="0"
max="1"
step="0.01"
value={effectiveVolume}
onChange={handleVolumeChange}
className={styles.volumeSlider}
/>
</div>
{/* Fullscreen */}
<button className={styles.controlBtn} onClick={toggleFullscreen}>
{isFullscreen ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M8 3v3a2 2 0 0 1-2 2H3" />
<path d="M21 8h-3a2 2 0 0 1-2-2V3" />
<path d="M3 16h3a2 2 0 0 1 2 2v3" />
<path d="M16 21v-3a2 2 0 0 1 2-2h3" />
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M8 3H5a2 2 0 0 0-2 2v3" />
<path d="M21 8V5a2 2 0 0 0-2-2h-3" />
<path d="M3 16v3a2 2 0 0 0 2 2h3" />
<path d="M16 21h3a2 2 0 0 0 2-2v-3" />
</svg>
)}
</button>
</div>
</div>
</div>
</div>
{/* Prev/Next navigation arrows — outside video container */}
{(onPrev || onNext) && (
<div className={styles.navArrows}>
<button
className={`${styles.navArrowBtn} ${!hasPrev ? styles.navArrowDisabled : ''}`}
onClick={(e) => { e.stopPropagation(); hasPrev && onPrev?.(); }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polyline points="18 15 12 9 6 15" />
</svg>
</button>
<button
className={`${styles.navArrowBtn} ${!hasNext ? styles.navArrowDisabled : ''}`}
onClick={(e) => { e.stopPropagation(); hasNext && onNext?.(); }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
</div>
)}
</div>
{/* Right: Info panel */}
<div className={styles.infoPanel}>
{/* Header: download left, favorite + more right */}
<div className={styles.infoPanelHeader}>
<button className={styles.downloadBtn} onClick={handleDownload}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
<div className={styles.headerIcons}>
<button className={styles.iconBtn} title="收藏">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
</button>
<div className={styles.moreMenuWrap} ref={moreMenuRef}>
<button className={styles.iconBtn} onClick={() => setShowMoreMenu(!showMoreMenu)}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="1.5" />
<circle cx="12" cy="12" r="1.5" />
<circle cx="12" cy="19" r="1.5" />
</svg>
</button>
{showMoreMenu && (
<div className={styles.moreDropdown}>
<button className={styles.moreDropdownItem} onClick={handleDelete}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
)}
</div>
</div>
</div>
{/* Scrollable content: prompt + references */}
<div className={styles.infoPanelContent}>
<div className={styles.promptSection}>
<div className={styles.sectionLabel}></div>
<p className={styles.promptText}>{task.prompt || '(无文字描述)'}</p>
</div>
{task.references.length > 0 && (
<div className={styles.refSection}>
<div className={styles.refGrid}>
{task.references.map((ref) => (
<div key={ref.id} className={styles.refItem}>
{ref.type === 'video' ? (
<video src={ref.previewUrl} className={styles.refImg} muted />
) : ref.type === 'audio' ? (
<div className={styles.refAudioPlaceholder}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
</div>
) : (
<img src={ref.previewUrl} alt={ref.label} className={styles.refImg} />
)}
<span className={styles.refLabel}>{ref.label}</span>
</div>
))}
</div>
</div>
)}
</div>
{/* Fixed bottom: info bar + actions card */}
<div className={styles.infoPanelBottom}>
<div className={styles.infoBar}>
<span>{modeLabel}</span>
<span className={styles.infoBarDot} />
<span>{modelLabel}</span>
<span className={styles.infoBarDot} />
<span>{task.duration}s</span>
<span className={styles.infoBarDot} />
<span>{task.aspectRatio}</span>
</div>
<div className={styles.cardActions}>
<button className={styles.cardBtn} onClick={handleReEdit}>
<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>
<button className={styles.cardBtn} onClick={handleRegenerate}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polyline points="23 4 23 10 17 10" />
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
</button>
</div>
</div>
</div>
<ConfirmModal
open={confirmDelete}
title="删除视频"
message="确定要删除这条生成记录吗?此操作不可撤销。"
confirmText="删除"
danger
onConfirm={doDelete}
onCancel={() => setConfirmDelete(false)}
/>
</div>
</div>
);
}

View File

@ -1,6 +1,8 @@
.layout {
display: flex;
height: 100%;
position: relative;
z-index: 2;
}
.main {
@ -38,3 +40,15 @@
gap: 20px;
padding: 24px 16px;
}
.loadMoreWrap {
display: flex;
justify-content: center;
width: 100%;
padding: 8px 0;
}
.loadMoreText {
color: var(--color-text-disabled);
font-size: 12px;
}

View File

@ -1,52 +1,159 @@
import { useRef, useEffect } from 'react';
import { useRef, useEffect, useState, useMemo, useCallback } from 'react';
import { Sidebar } from './Sidebar';
import { InputBar } from './InputBar';
import { GenerationCard } from './GenerationCard';
import { Toast } from './Toast';
import { UserInfoBar } from './UserInfoBar';
import { VideoDetailModal } from './VideoDetailModal';
import { AnnouncementBanner } from './AnnouncementBanner';
import { useGenerationStore } from '../store/generation';
import { useAuthStore } from '../store/auth';
import type { GenerationTask } from '../types';
import styles from './VideoGenerationPage.module.css';
export function VideoGenerationPage() {
const tasks = useGenerationStore((s) => s.tasks);
const loadTasks = useGenerationStore((s) => s.loadTasks);
const loadMore = useGenerationStore((s) => s.loadMore);
const isLoadingMore = useGenerationStore((s) => s.isLoadingMore);
const teamDisabled = useAuthStore((s) => s.teamDisabled);
const reEdit = useGenerationStore((s) => s.reEdit);
const regenerate = useGenerationStore((s) => s.regenerate);
const removeTask = useGenerationStore((s) => s.removeTask);
const scrollRef = useRef<HTMLDivElement>(null);
const prevCountRef = useRef(tasks.length);
const initialLoadRef = useRef(true);
const savedScrollTop = useGenerationStore((s) => s.savedScrollTop);
const saveScrollPosition = useGenerationStore((s) => s.saveScrollPosition);
const [detailTask, setDetailTask] = useState<GenerationTask | null>(null);
// Load tasks from backend on mount (persist across page refresh)
useEffect(() => {
loadTasks();
}, [loadTasks]);
// Auto-scroll to top when new task is added
// Restore scroll position after initial load, or scroll to bottom for new tasks
useEffect(() => {
if (tasks.length === 0) return;
if (initialLoadRef.current) {
initialLoadRef.current = false;
// Use requestAnimationFrame to ensure DOM has rendered
requestAnimationFrame(() => {
if (savedScrollTop !== null && scrollRef.current) {
scrollRef.current.scrollTop = savedScrollTop;
} else if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
});
prevCountRef.current = tasks.length;
return;
}
if (tasks.length > prevCountRef.current && scrollRef.current) {
scrollRef.current.scrollTo({ top: 0, behavior: 'smooth' });
scrollRef.current.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
}
prevCountRef.current = tasks.length;
}, [tasks.length]);
}, [tasks.length, savedScrollTop]);
// Save scroll position + auto-load older tasks when scrolled near top
const handleScroll = useCallback(() => {
if (!scrollRef.current) return;
saveScrollPosition(scrollRef.current.scrollTop);
// Trigger loadMore when scrolled within 100px of the top
if (scrollRef.current.scrollTop < 100) {
const el = scrollRef.current;
const prevHeight = el.scrollHeight;
loadMore().then(() => {
// After older tasks are prepended, restore visual position so user doesn't jump
requestAnimationFrame(() => {
const diff = el.scrollHeight - prevHeight;
if (diff > 0) el.scrollTop += diff;
});
});
}
}, [saveScrollPosition, loadMore]);
const handleReEdit = (id: string) => {
reEdit(id);
setDetailTask(null);
};
const handleRegenerate = (id: string) => {
regenerate(id);
setDetailTask(null);
};
const handleDelete = (id: string) => {
removeTask(id);
setDetailTask(null);
};
const completedTasks = useMemo(
() => tasks.filter((t) => t.status === 'completed' && t.resultUrl),
[tasks],
);
const detailIdx = detailTask ? completedTasks.findIndex((t) => t.id === detailTask.id) : -1;
if (teamDisabled) {
return (
<div className={styles.layout}>
<Sidebar />
<main className={styles.main}>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
height: '100%', flexDirection: 'column', gap: 16,
color: 'var(--color-text-secondary)',
}}>
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#8b8ea8" strokeWidth="1.5">
<circle cx="12" cy="12" r="10" />
<path d="M4.93 4.93l14.14 14.14" />
</svg>
<p style={{ fontSize: 18, color: 'var(--color-text-primary)' }}></p>
<p></p>
</div>
</main>
</div>
);
}
return (
<div className={styles.layout}>
<Sidebar />
<main className={styles.main}>
<UserInfoBar />
<div className={styles.contentArea} ref={scrollRef}>
<AnnouncementBanner />
<div className={styles.contentArea} ref={scrollRef} onScroll={handleScroll}>
{tasks.length === 0 ? (
<div className={styles.emptyArea}>
<p className={styles.emptyHint}> AI </p>
</div>
) : (
<div className={styles.taskList}>
{isLoadingMore && (
<div className={styles.loadMoreWrap}>
<span className={styles.loadMoreText}></span>
</div>
)}
{tasks.map((task) => (
<GenerationCard key={task.id} task={task} />
<GenerationCard
key={task.id}
task={task}
onOpenDetail={setDetailTask}
/>
))}
</div>
)}
</div>
<InputBar />
</main>
<Toast />
<VideoDetailModal
task={detailTask}
onClose={() => setDetailTask(null)}
onReEdit={handleReEdit}
onRegenerate={handleRegenerate}
onDelete={handleDelete}
hasPrev={detailIdx > 0}
hasNext={detailIdx >= 0 && detailIdx < completedTasks.length - 1}
onPrev={() => detailIdx > 0 && setDetailTask(completedTasks[detailIdx - 1])}
onNext={() => detailIdx < completedTasks.length - 1 && setDetailTask(completedTasks[detailIdx + 1])}
/>
</div>
);
}

View File

@ -1,27 +1,27 @@
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;600;700&display=swap');
:root {
--color-bg-page: #0a0a0f;
--color-bg-input-bar: #16161e;
--color-border-input-bar: #2a2a38;
--color-primary: #00b8e6;
--color-text-primary: #ffffff;
--color-text-secondary: #8a8a9a;
--color-text-disabled: #4a4a5a;
--color-bg-hover: rgba(255, 255, 255, 0.06);
--color-bg-dropdown: #1e1e2a;
--color-bg-page: #07070f;
--color-bg-input-bar: rgba(255, 255, 255, 0.06);
--color-border-input-bar: rgba(255, 255, 255, 0.10);
--color-primary: #6c63ff;
--color-text-primary: #f1f0ff;
--color-text-secondary: #8b8ea8;
--color-text-disabled: #4c4f6b;
--color-bg-hover: rgba(255, 255, 255, 0.08);
--color-bg-dropdown: rgba(13, 13, 26, 0.92);
--color-bg-upload: rgba(255, 255, 255, 0.04);
--color-border-upload: #2a2a38;
--color-border-upload: rgba(255, 255, 255, 0.08);
--color-btn-send-disabled: #3a3a4a;
--color-btn-send-active: #00b8e6;
--color-sidebar-bg: #0e0e14;
--color-btn-send-active: #6c63ff;
--color-sidebar-bg: rgba(7, 7, 15, 0.80);
/* Phase 3: Admin theme tokens */
--color-bg-sidebar: #111118;
--color-bg-sidebar: rgba(7, 7, 15, 0.80);
--color-sidebar-active: rgba(255, 255, 255, 0.08);
--color-sidebar-hover: rgba(255, 255, 255, 0.04);
--color-bg-card: #16161e;
--color-border-card: #2a2a38;
--color-bg-card: rgba(255, 255, 255, 0.06);
--color-border-card: rgba(255, 255, 255, 0.10);
--color-success: #00b894;
--color-danger: #e74c3c;
--color-warning: #f39c12;
@ -61,13 +61,162 @@ body {
-moz-osx-font-smoothing: grayscale;
}
/* Scrollbar: Firefox */
* {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
*:hover {
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
}
/* Scrollbar: Webkit — hidden by default, visible on hover */
::-webkit-scrollbar {
width: 4px;
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-border-input-bar);
border-radius: 4px;
background: transparent;
border-radius: 3px;
transition: background 0.2s;
}
*:hover::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
}
*:hover::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
/*
LAYER 1: Aurora Gradient Background
*/
.aurora-bg {
position: fixed;
inset: 0;
z-index: 0;
overflow: hidden;
pointer-events: none;
}
.aurora-bg::before,
.aurora-bg::after {
content: "";
position: absolute;
border-radius: 50%;
filter: blur(110px);
opacity: 0.4;
will-change: transform;
}
.aurora-bg::before {
width: 600px;
height: 600px;
top: -10%;
right: -5%;
background: radial-gradient(circle, rgba(108, 99, 255, 0.6) 0%, transparent 70%);
animation: aurora-drift-1 20s ease-in-out infinite alternate;
}
.aurora-bg::after {
width: 500px;
height: 500px;
bottom: -5%;
left: -5%;
background: radial-gradient(circle, rgba(59, 130, 246, 0.5) 0%, transparent 70%);
animation: aurora-drift-2 25s ease-in-out infinite alternate;
}
@keyframes aurora-drift-1 {
0% { transform: translate(0, 0) scale(1); }
33% { transform: translate(-15vw, 10vh) scale(1.1); }
66% { transform: translate(-5vw, 25vh) scale(0.95); }
100% { transform: translate(-20vw, 15vh) scale(1.05); }
}
@keyframes aurora-drift-2 {
0% { transform: translate(0, 0) scale(1); }
33% { transform: translate(10vw, -15vh) scale(1.15); }
66% { transform: translate(20vw, -5vh) scale(0.9); }
100% { transform: translate(15vw, -20vh) scale(1.1); }
}
.aurora-blob-3 {
position: absolute;
width: 400px;
height: 400px;
top: 40%;
left: 50%;
border-radius: 50%;
background: radial-gradient(circle, rgba(139, 92, 246, 0.35) 0%, transparent 70%);
filter: blur(100px);
opacity: 0.3;
will-change: transform;
animation: aurora-drift-3 30s ease-in-out infinite alternate;
pointer-events: none;
}
@keyframes aurora-drift-3 {
0% { transform: translate(-50%, -50%) scale(1); }
50% { transform: translate(-30%, -60%) scale(1.2); }
100% { transform: translate(-70%, -40%) scale(0.85); }
}
/*
LAYER 2: Noise Texture Overlay
*/
.noise-overlay {
position: fixed;
inset: 0;
z-index: 1;
pointer-events: none;
opacity: 0.03;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
background-repeat: repeat;
background-size: 256px 256px;
}
/*
LAYER 3: Mouse-tracking Glow
*/
.cursor-glow {
position: fixed;
inset: 0;
z-index: 1;
pointer-events: none;
background: radial-gradient(
600px circle at var(--mouse-x, 50%) var(--mouse-y, 50%),
rgba(108, 99, 255, 0.06) 0%,
transparent 60%
);
transition: opacity 0.3s ease;
}
/*
LAYER 4: Subtle grid pattern
*/
.grid-pattern {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px);
background-size: 64px 64px;
mask-image: radial-gradient(ellipse 80% 60% at 50% 50%, black 20%, transparent 100%);
-webkit-mask-image: radial-gradient(ellipse 80% 60% at 50% 50%, black 20%, transparent 100%);
}
/*
Reduced motion
*/
@media (prefers-reduced-motion: reduce) {
.aurora-bg::before,
.aurora-bg::after,
.aurora-blob-3 {
animation: none !important;
opacity: 0.2 !important;
}
}

View File

@ -2,7 +2,8 @@ import axios, { AxiosError } from 'axios';
import type {
User, Quota, AuthTokens, AdminStats, AdminUser, AdminUserDetail,
AdminRecord, SystemSettings, ProfileOverview, PaginatedResponse,
BackendTask,
BackendTask, TeamInfo, Team, TeamDetail, TeamMember, TeamStats,
AuditLog,
} from '../types';
import { reportError } from './logCenter';
@ -63,9 +64,6 @@ api.interceptors.response.use(
// Auth APIs
export const authApi = {
register: (username: string, email: string, password: string) =>
api.post<{ user: User; tokens: AuthTokens }>('/auth/register', { username, email, password }),
login: (username: string, password: string) =>
api.post<{ user: User; tokens: AuthTokens }>('/auth/login', { username, password }),
@ -73,7 +71,7 @@ export const authApi = {
api.post<{ access: string }>('/auth/token/refresh', { refresh }),
getMe: () =>
api.get<User & { quota: Quota }>('/auth/me'),
api.get<User & { quota: Quota; team: TeamInfo | null; team_disabled: boolean }>('/auth/me'),
};
// Media upload API
@ -108,21 +106,49 @@ export const videoApi = {
status: string;
estimated_time: number;
seconds_consumed: number;
remaining_seconds_today: number;
}>('/video/generate', data),
getTasks: () =>
api.get<{ results: BackendTask[] }>('/video/tasks'),
getTasks: (params?: { page_size?: number; offset?: number }) =>
api.get<{ results: BackendTask[]; total: number; has_more: boolean }>('/video/tasks', { params }),
getTaskStatus: (taskId: string) =>
api.get<BackendTask>(`/video/tasks/${taskId}`),
deleteTask: (taskId: string) =>
api.delete(`/video/tasks/${taskId}`),
getAnnouncement: () =>
api.get<{ announcement: string; enabled: boolean }>('/announcement'),
};
// Admin APIs
// Admin APIs (Super Admin)
export const adminApi = {
getStats: () =>
api.get<AdminStats>('/admin/stats'),
// Team management
getTeams: () =>
api.get<{ results: Team[] }>('/admin/teams'),
createTeam: (data: { name: string; monthly_seconds_limit?: number; daily_member_limit_default?: number }) =>
api.post('/admin/teams/create', data),
getTeamDetail: (teamId: number) =>
api.get<TeamDetail>(`/admin/teams/${teamId}`),
updateTeam: (teamId: number, data: { name?: string; monthly_seconds_limit?: number; daily_member_limit_default?: number; is_active?: boolean }) =>
api.put(`/admin/teams/${teamId}`, data),
topUpTeam: (teamId: number, seconds: number) =>
api.post(`/admin/teams/${teamId}/topup`, { seconds }),
setTeamPool: (teamId: number, totalSecondsPool: number) =>
api.put(`/admin/teams/${teamId}/set-pool`, { total_seconds_pool: totalSecondsPool }),
createTeamAdmin: (teamId: number, data: { username: string; email: string; password: string }) =>
api.post(`/admin/teams/${teamId}/admin`, data),
// User management
createUser: (data: {
username: string;
email: string;
@ -138,6 +164,7 @@ export const adminApi = {
page_size?: number;
search?: string;
status?: string;
team_id?: number;
} = {}) =>
api.get<PaginatedResponse<AdminUser>>('/admin/users', { params }),
@ -159,6 +186,7 @@ export const adminApi = {
search?: string;
start_date?: string;
end_date?: string;
team_id?: number;
} = {}) =>
api.get<PaginatedResponse<AdminRecord>>('/admin/records', { params }),
@ -167,6 +195,43 @@ export const adminApi = {
updateSettings: (settings: SystemSettings) =>
api.put<SystemSettings & { updated_at: string }>('/admin/settings', settings),
getAuditLogs: (params: {
page?: number;
page_size?: number;
action?: string;
operator?: string;
start_date?: string;
end_date?: string;
} = {}) =>
api.get<PaginatedResponse<AuditLog> & { total_pages: number }>('/admin/logs', { params }),
};
// Team Admin APIs
export const teamApi = {
getInfo: () =>
api.get<TeamInfo & { daily_member_limit_default: number; member_count: number }>('/team/info'),
getStats: () =>
api.get<TeamStats>('/team/stats'),
getMembers: () =>
api.get<{ results: TeamMember[] }>('/team/members'),
createMember: (data: { username: string; password: string; daily_seconds_limit?: number; monthly_seconds_limit?: number }) =>
api.post('/team/members/create', data),
getMemberDetail: (memberId: number) =>
api.get('/team/members/' + memberId),
updateMemberQuota: (memberId: number, daily: number, monthly: number) =>
api.put(`/team/members/${memberId}/quota`, {
daily_seconds_limit: daily,
monthly_seconds_limit: monthly,
}),
updateMemberStatus: (memberId: number, isActive: boolean) =>
api.patch(`/team/members/${memberId}/status`, { is_active: isActive }),
};
// Profile APIs

View File

@ -87,6 +87,12 @@
color: var(--color-text-primary);
}
.navDivider {
height: 1px;
background: var(--color-border-card);
margin: 4px 12px;
}
.sidebarFooter {
padding: 12px;
border-top: 1px solid var(--color-border-card);
@ -95,26 +101,6 @@
gap: 8px;
}
.backBtn {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: transparent;
border: 1px solid var(--color-border-card);
border-radius: 8px;
color: var(--color-text-secondary);
font-size: 13px;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.backBtn:hover {
background: var(--color-sidebar-hover);
color: var(--color-text-primary);
}
.userInfo {
display: flex;
align-items: center;

View File

@ -5,9 +5,11 @@ import styles from './AdminLayout.module.css';
const navItems = [
{ path: '/admin/dashboard', label: '仪表盘', icon: 'M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z' },
{ path: '/admin/teams', label: '团队管理', icon: 'M12 7V3H2v18h20V7H12zM6 19H4v-2h2v2zm0-4H4v-2h2v2zm0-4H4V9h2v2zm0-4H4V5h2v2zm4 12H8v-2h2v2zm0-4H8v-2h2v2zm0-4H8V9h2v2zm0-4H8V5h2v2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2z' },
{ path: '/admin/users', label: '用户管理', icon: 'M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z' },
{ path: '/admin/records', label: '消费记录', icon: 'M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-2 10h-4v4h-2v-4H7v-2h4V7h2v4h4v2z' },
{ path: '/admin/settings', label: '系统设置', icon: 'M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.488.488 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z' },
{ path: '/admin/logs', label: '操作日志', icon: 'M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z' },
];
export function AdminLayout() {
@ -29,7 +31,7 @@ export function AdminLayout() {
<svg viewBox="0 0 24 24" width="24" height="24" fill="var(--color-primary)">
<path d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z"/>
</svg>
{!collapsed && <span className={styles.logoText}>Jimeng Admin</span>}
{!collapsed && <span className={styles.logoText}>AirDrama Admin</span>}
</div>
<button className={styles.collapseBtn} onClick={() => setCollapsed(!collapsed)}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="var(--color-text-secondary)">
@ -43,6 +45,13 @@ export function AdminLayout() {
</div>
<nav className={styles.nav}>
<button className={styles.navItem} onClick={() => navigate('/')} style={{ border: 'none', background: 'transparent', cursor: 'pointer', textAlign: 'left', width: '100%' }}>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>
</svg>
{!collapsed && <span></span>}
</button>
<div className={styles.navDivider} />
{navItems.map((item) => (
<NavLink
key={item.path}
@ -60,12 +69,6 @@ export function AdminLayout() {
</nav>
<div className={styles.sidebarFooter}>
<button className={styles.backBtn} onClick={() => navigate('/')}>
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/>
</svg>
{!collapsed && <span></span>}
</button>
<div className={styles.userInfo}>
<div className={styles.userAvatar}>{user?.username.charAt(0).toUpperCase()}</div>
{!collapsed && (

View File

@ -0,0 +1,167 @@
.layout {
display: flex;
height: 100%;
position: relative;
z-index: 2;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Tab header */
.tabHeader {
padding: 20px 32px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.tabs {
display: flex;
gap: 24px;
margin-bottom: 12px;
}
.tab {
font-size: 16px;
font-weight: 500;
color: var(--color-text-secondary);
cursor: pointer;
padding-bottom: 8px;
border-bottom: 2px solid transparent;
transition: color 0.15s;
}
.tab:hover {
color: var(--color-text-primary);
}
.tabActive {
color: var(--color-text-primary);
border-bottom-color: var(--color-primary);
}
.subTabs {
display: flex;
gap: 16px;
}
.subTab {
font-size: 13px;
color: var(--color-text-secondary);
cursor: pointer;
padding-bottom: 10px;
border-bottom: 2px solid transparent;
transition: color 0.15s;
}
.subTab:hover {
color: var(--color-text-primary);
}
.subTabActive {
color: var(--color-text-primary);
border-bottom-color: var(--color-primary);
}
/* Content */
.content {
flex: 1;
overflow-y: auto;
padding: 24px 32px;
}
.empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--color-text-disabled);
font-size: 14px;
}
/* Date group */
.dateGroup {
margin-bottom: 32px;
}
.dateLabel {
font-size: 14px;
font-weight: 500;
color: var(--color-text-secondary);
margin-bottom: 16px;
}
/* Thumbnail grid: 5 columns */
.grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
}
@media (max-width: 1200px) {
.grid {
grid-template-columns: repeat(4, 1fr);
}
}
@media (max-width: 900px) {
.grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 600px) {
.grid {
grid-template-columns: repeat(2, 1fr);
}
}
/* Thumbnail card */
.thumbnail {
position: relative;
aspect-ratio: 16 / 9;
border-radius: 10px;
overflow: hidden;
background: rgba(0, 0, 0, 0.3);
cursor: pointer;
transition: transform 0.15s;
}
.thumbnail:hover {
transform: scale(1.02);
}
.thumbVideo {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.thumbPlaceholder {
width: 100%;
height: 100%;
background: #1a1a24;
}
.durationBadge {
position: absolute;
bottom: 6px;
left: 6px;
padding: 2px 6px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.thumbOverlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.15);
pointer-events: none;
}

View File

@ -0,0 +1,195 @@
import { useEffect, useState, useRef, useMemo } from 'react';
import { Sidebar } from '../components/Sidebar';
import { VideoDetailModal } from '../components/VideoDetailModal';
import { useGenerationStore } from '../store/generation';
import { ConfirmModal } from '../components/ConfirmModal';
import type { GenerationTask } from '../types';
import styles from './AssetsPage.module.css';
function groupByDate(tasks: GenerationTask[]): { label: string; tasks: GenerationTask[] }[] {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const yesterday = today - 86400000;
const groups = new Map<string, GenerationTask[]>();
const order: string[] = [];
for (const task of tasks) {
const d = new Date(task.createdAt);
const dayStart = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
let label: string;
if (dayStart >= today) {
label = '今天';
} else if (dayStart >= yesterday) {
label = '昨天';
} else {
label = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
if (!groups.has(label)) {
groups.set(label, []);
order.push(label);
}
groups.get(label)!.push(task);
}
return order.map((label) => ({ label, tasks: groups.get(label)! }));
}
function VideoThumbnail({
task,
onClick,
}: {
task: GenerationTask;
onClick: () => void;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const [hover, setHover] = useState(false);
const handleEnter = () => {
setHover(true);
videoRef.current?.play().catch(() => {});
};
const handleLeave = () => {
setHover(false);
if (videoRef.current) {
videoRef.current.pause();
videoRef.current.currentTime = 0;
}
};
const durationLabel = `00:${String(task.duration).padStart(2, '0')}`;
return (
<div
className={styles.thumbnail}
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
onClick={onClick}
>
{task.resultUrl ? (
<video
ref={videoRef}
src={task.resultUrl}
className={styles.thumbVideo}
muted
loop
preload="metadata"
/>
) : (
<div className={styles.thumbPlaceholder} />
)}
<span className={styles.durationBadge}>{durationLabel}</span>
{hover && <div className={styles.thumbOverlay} />}
</div>
);
}
export function AssetsPage() {
const tasks = useGenerationStore((s) => s.tasks);
const loadTasks = useGenerationStore((s) => s.loadTasks);
const reEdit = useGenerationStore((s) => s.reEdit);
const regenerate = useGenerationStore((s) => s.regenerate);
const removeTask = useGenerationStore((s) => s.removeTask);
const [detailTask, setDetailTask] = useState<GenerationTask | null>(null);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
useEffect(() => {
loadTasks();
}, [loadTasks]);
const completedTasks = useMemo(
() => tasks.filter((t) => t.status === 'completed'),
[tasks],
);
const dateGroups = useMemo(() => groupByDate(completedTasks), [completedTasks]);
const handleReEdit = (id: string) => {
reEdit(id);
setDetailTask(null);
};
const handleRegenerate = (id: string) => {
regenerate(id);
setDetailTask(null);
};
const handleDelete = (id: string) => {
setConfirmDeleteId(id);
};
const doDelete = () => {
if (confirmDeleteId) {
removeTask(confirmDeleteId);
setDetailTask(null);
}
setConfirmDeleteId(null);
};
const detailIdx = detailTask ? completedTasks.findIndex((t) => t.id === detailTask.id) : -1;
return (
<div className={styles.layout}>
<Sidebar />
<main className={styles.main}>
{/* Tab header */}
<div className={styles.tabHeader}>
<div className={styles.tabs}>
<span className={`${styles.tab} ${styles.tabActive}`}></span>
</div>
<div className={styles.subTabs}>
<span className={`${styles.subTab} ${styles.subTabActive}`}></span>
<span className={styles.subTab}></span>
</div>
</div>
{/* Video grid by date */}
<div className={styles.content}>
{completedTasks.length === 0 ? (
<div className={styles.empty}>
<p></p>
</div>
) : (
dateGroups.map((group) => (
<section key={group.label} className={styles.dateGroup}>
<h3 className={styles.dateLabel}>{group.label}</h3>
<div className={styles.grid}>
{group.tasks.map((task) => (
<VideoThumbnail
key={task.id}
task={task}
onClick={() => setDetailTask(task)}
/>
))}
</div>
</section>
))
)}
</div>
</main>
<VideoDetailModal
task={detailTask}
onClose={() => setDetailTask(null)}
onReEdit={handleReEdit}
onRegenerate={handleRegenerate}
onDelete={handleDelete}
hasPrev={detailIdx > 0}
hasNext={detailIdx >= 0 && detailIdx < completedTasks.length - 1}
onPrev={() => detailIdx > 0 && setDetailTask(completedTasks[detailIdx - 1])}
onNext={() => detailIdx < completedTasks.length - 1 && setDetailTask(completedTasks[detailIdx + 1])}
/>
<ConfirmModal
open={!!confirmDeleteId}
title="删除视频"
message="确定要删除这条生成记录吗?此操作不可撤销。"
confirmText="删除"
danger
onConfirm={doDelete}
onCancel={() => setConfirmDeleteId(null)}
/>
</div>
);
}

View File

@ -0,0 +1,54 @@
.page { max-width: 1200px; }
.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; }
.searchInput {
padding: 8px 12px; background: var(--color-bg-card); border: 1px solid var(--color-border-card);
border-radius: 8px; color: var(--color-text-primary); font-size: 13px; width: 160px; outline: none;
}
.searchInput:focus { border-color: var(--color-primary); }
.dateSep { color: var(--color-text-secondary); font-size: 13px; }
.searchBtn { padding: 8px 16px; background: var(--color-primary); border: none; border-radius: 8px; color: #fff; font-size: 13px; cursor: pointer; }
.searchBtn:hover { opacity: 0.9; }
.refreshBtn {
padding: 8px 16px; border-radius: 8px; font-size: 13px; cursor: pointer; transition: all 0.15s;
background: transparent; border: 1px solid var(--color-border-card); color: var(--color-text-secondary);
}
.refreshBtn:hover { background: var(--color-sidebar-hover); }
.tableWrapper {
background: var(--color-bg-card); border: 1px solid var(--color-border-card);
border-radius: var(--radius-card); overflow-x: auto;
}
.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 td { padding: 12px 16px; color: var(--color-text-primary); border-bottom: 1px solid rgba(42, 42, 56, 0.5); vertical-align: top; }
.table tr:last-child td { border-bottom: none; }
.table tr:hover td { background: rgba(255, 255, 255, 0.02); }
.timeCell { white-space: nowrap; font-size: 12px; color: var(--color-text-secondary); }
.actionBadge { padding: 2px 8px; border-radius: 4px; font-size: 12px; background: rgba(0, 184, 230, 0.12); color: var(--color-primary); white-space: nowrap; }
.targetCell { max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ipCell { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--color-text-secondary); white-space: nowrap; }
.changeDetail { font-size: 12px; line-height: 1.6; }
.changeItem { display: flex; gap: 4px; flex-wrap: wrap; }
.changeField { color: #8b8ea8; }
.changeOld { color: var(--color-danger); text-decoration: line-through; }
.changeArrow { color: #8b8ea8; }
.changeNew { color: var(--color-success); }
.empty { text-align: center; color: var(--color-text-secondary); padding: 40px; }
.skeletonCell { height: 16px; background: var(--color-border-card); border-radius: 4px; animation: pulse 1.5s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
.pagination { display: flex; justify-content: space-between; align-items: center; margin-top: 16px; }
.pageInfo { color: var(--color-text-secondary); font-size: 13px; }
.pageButtons { display: flex; gap: 4px; }
.pageButtons button {
padding: 6px 12px; background: var(--color-bg-card); border: 1px solid var(--color-border-card);
border-radius: 6px; color: var(--color-text-secondary); font-size: 13px; cursor: pointer;
}
.pageButtons button:hover:not(:disabled) { background: var(--color-sidebar-hover); color: var(--color-text-primary); }
.pageButtons button:disabled { opacity: 0.4; cursor: not-allowed; }
.activePage { background: var(--color-primary) !important; color: #fff !important; border-color: var(--color-primary) !important; }

View File

@ -0,0 +1,199 @@
import { useEffect, useState, useCallback } from 'react';
import { adminApi } from '../lib/api';
import type { AuditLog } from '../types';
import { showToast } from '../components/Toast';
import { DatePicker } from '../components/DatePicker';
import { Select } from '../components/Select';
import styles from './AuditLogsPage.module.css';
const ACTION_OPTIONS = [
{ label: '全部操作', value: '' },
{ label: '创建团队', value: 'team_create' },
{ label: '更新团队', value: 'team_update' },
{ label: '团队充值', value: 'team_topup' },
{ label: '设置额度池', value: 'team_set_pool' },
{ label: '创建团队管理员', value: 'team_create_admin' },
{ label: '创建用户', value: 'user_create' },
{ label: '更新用户额度', value: 'user_quota_update' },
{ label: '切换用户状态', value: 'user_status_toggle' },
{ label: '更新系统设置', value: 'settings_update' },
{ label: '创建团队成员', value: 'member_create' },
{ label: '更新成员额度', value: 'member_quota_update' },
{ label: '切换成员状态', value: 'member_status_toggle' },
];
function renderChanges(before: Record<string, unknown> | null, after: Record<string, unknown> | null) {
if (!before && !after) return '-';
const fields = new Set([...Object.keys(before || {}), ...Object.keys(after || {})]);
if (fields.size === 0) return '-';
return (
<div className={styles.changeDetail}>
{[...fields].map((field) => {
const oldVal = before?.[field];
const newVal = after?.[field];
if (oldVal === undefined && newVal !== undefined) {
return (
<div key={field} className={styles.changeItem}>
<span className={styles.changeField}>{field}:</span>
<span className={styles.changeNew}>{String(newVal)}</span>
</div>
);
}
if (oldVal !== undefined && newVal !== undefined && String(oldVal) !== String(newVal)) {
return (
<div key={field} className={styles.changeItem}>
<span className={styles.changeField}>{field}:</span>
<span className={styles.changeOld}>{String(oldVal)}</span>
<span className={styles.changeArrow}>&rarr;</span>
<span className={styles.changeNew}>{String(newVal)}</span>
</div>
);
}
if (oldVal === undefined && newVal === undefined) return null;
// Same value, show as-is for create actions
if (oldVal === undefined) {
return (
<div key={field} className={styles.changeItem}>
<span className={styles.changeField}>{field}:</span>
<span className={styles.changeNew}>{String(newVal)}</span>
</div>
);
}
return null;
})}
</div>
);
}
export function AuditLogsPage() {
const [logs, setLogs] = useState<AuditLog[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [actionFilter, setActionFilter] = useState('');
const [operatorSearch, setOperatorSearch] = useState('');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [loading, setLoading] = useState(true);
const pageSize = 20;
const fetchLogs = useCallback(async () => {
setLoading(true);
try {
const { data } = await adminApi.getAuditLogs({
page, page_size: pageSize,
action: actionFilter || undefined,
operator: operatorSearch || undefined,
start_date: startDate || undefined,
end_date: endDate || undefined,
});
setLogs(data.results);
setTotal(data.total);
} catch {
showToast('加载审计日志失败');
} finally {
setLoading(false);
}
}, [page, actionFilter, operatorSearch, startDate, endDate]);
useEffect(() => { fetchLogs(); }, [fetchLogs]);
const handleSearch = () => {
setPage(1);
fetchLogs();
};
const totalPages = Math.ceil(total / pageSize);
return (
<div className={styles.page}>
<h1 className={styles.title}></h1>
<div className={styles.filters}>
<Select
value={actionFilter}
onChange={(v) => { setActionFilter(v); setPage(1); }}
placeholder="全部操作"
options={ACTION_OPTIONS}
/>
<input
type="text"
className={styles.searchInput}
placeholder="按操作人搜索..."
value={operatorSearch}
onChange={(e) => setOperatorSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
<DatePicker value={startDate} onChange={setStartDate} placeholder="开始日期" />
<span className={styles.dateSep}>~</span>
<DatePicker value={endDate} onChange={setEndDate} placeholder="结束日期" />
<button className={styles.searchBtn} onClick={handleSearch}></button>
<button className={styles.refreshBtn} onClick={fetchLogs}></button>
</div>
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th>IP</th>
</tr>
</thead>
<tbody>
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i}>
{Array.from({ length: 6 }).map((_, j) => (
<td key={j}><div className={styles.skeletonCell} /></td>
))}
</tr>
))
) : logs.length === 0 ? (
<tr><td colSpan={6} className={styles.empty}></td></tr>
) : (
logs.map((log) => (
<tr key={log.id}>
<td className={styles.timeCell}>{new Date(log.created_at).toLocaleString('zh-CN')}</td>
<td>{log.operator_name}</td>
<td><span className={styles.actionBadge}>{log.action_display}</span></td>
<td className={styles.targetCell}>
{log.target_name || '-'}
{log.target_type && <span style={{ color: '#8b8ea8', fontSize: 11, marginLeft: 4 }}>({log.target_type})</span>}
</td>
<td>{renderChanges(log.before, log.after)}</td>
<td className={styles.ipCell}>{log.ip_address || '-'}</td>
</tr>
))
)}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className={styles.pagination}>
<span className={styles.pageInfo}> {total} </span>
<div className={styles.pageButtons}>
<button disabled={page <= 1} onClick={() => setPage(page - 1)}>&lt;</button>
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
let p: number;
if (totalPages <= 5) p = i + 1;
else if (page <= 3) p = i + 1;
else if (page >= totalPages - 2) p = totalPages - 4 + i;
else p = page - 2 + i;
return (
<button key={p} className={page === p ? styles.activePage : ''} onClick={() => setPage(p)}>
{p}
</button>
);
})}
<button disabled={page >= totalPages} onClick={() => setPage(page + 1)}>&gt;</button>
</div>
</div>
)}
</div>
);
}

View File

@ -5,6 +5,8 @@
justify-content: center;
background: var(--color-bg-page);
padding: 20px;
position: relative;
z-index: 2;
}
.card {

View File

@ -45,8 +45,8 @@ 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.new_users_today, 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 },
];
@ -54,9 +54,9 @@ export function DashboardPage() {
const trendOption: echarts.EChartsCoreOption = {
tooltip: {
trigger: 'axis',
backgroundColor: '#1e1e2a',
borderColor: '#2a2a38',
textStyle: { color: '#e2e8f0', fontSize: 12 },
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 }[])[0];
return `${p.name}<br/>消费: ${p.value}s`;
@ -66,27 +66,69 @@ export function DashboardPage() {
xAxis: {
type: 'category',
data: stats.daily_trend.map((d) => d.date.slice(5)),
axisLabel: { color: '#8a8a9a', fontSize: 11 },
axisLine: { lineStyle: { color: '#2a2a38' } },
axisLabel: { color: '#8b8ea8', fontSize: 11 },
axisLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.08)' } },
},
yAxis: {
type: 'value',
axisLabel: { color: '#8a8a9a', fontSize: 11 },
splitLine: { lineStyle: { color: '#1e1e2a' } },
axisLabel: { color: '#8b8ea8', fontSize: 11 },
splitLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.06)' } },
},
dataZoom: [{ type: 'inside', start: 0, end: 100 }],
series: [{
type: 'line',
data: stats.daily_trend.map((d) => d.seconds),
smooth: true,
lineStyle: { color: '#00b8e6', width: 2 },
lineStyle: { color: '#6c63ff', width: 2 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(0, 184, 230, 0.25)' },
{ offset: 1, color: 'rgba(0, 184, 230, 0.02)' },
{ offset: 0, color: 'rgba(108, 99, 255, 0.25)' },
{ offset: 1, color: 'rgba(108, 99, 255, 0.02)' },
]),
},
itemStyle: { color: '#00b8e6' },
itemStyle: { color: '#6c63ff' },
}],
};
const sortedTeams = [...(stats.top_teams || [])].sort((a, b) => a.seconds_consumed - b.seconds_consumed);
const teamBarOption: 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 },
},
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: sortedTeams.map((t) => t.name),
axisLabel: { color: '#8b8ea8', fontSize: 12 },
axisLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.08)' } },
},
series: [{
type: 'bar',
data: sortedTeams.map((t) => t.seconds_consumed),
barWidth: 16,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#00b8e6' },
{ offset: 1, color: '#06d6a0' },
]),
borderRadius: [0, 4, 4, 0],
},
label: {
show: true,
position: 'right',
color: '#8b8ea8',
fontSize: 11,
formatter: '{c}s',
},
}],
};
@ -95,21 +137,21 @@ export function DashboardPage() {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
backgroundColor: '#1e1e2a',
borderColor: '#2a2a38',
textStyle: { color: '#e2e8f0', fontSize: 12 },
backgroundColor: 'rgba(13, 13, 26, 0.95)',
borderColor: 'rgba(255, 255, 255, 0.10)',
textStyle: { color: '#f1f0ff', fontSize: 12 },
},
grid: { left: 80, right: 40, top: 10, bottom: 20 },
xAxis: {
type: 'value',
axisLabel: { color: '#8a8a9a', fontSize: 11 },
splitLine: { lineStyle: { color: '#1e1e2a' } },
axisLabel: { color: '#8b8ea8', fontSize: 11 },
splitLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.06)' } },
},
yAxis: {
type: 'category',
data: sortedUsers.map((u) => u.username),
axisLabel: { color: '#8a8a9a', fontSize: 12 },
axisLine: { lineStyle: { color: '#2a2a38' } },
axisLabel: { color: '#8b8ea8', fontSize: 12 },
axisLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.08)' } },
},
series: [{
type: 'bar',
@ -117,15 +159,15 @@ export function DashboardPage() {
barWidth: 16,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#00b8e6' },
{ offset: 1, color: '#7c3aed' },
{ offset: 0, color: '#6c63ff' },
{ offset: 1, color: '#8b5cf6' },
]),
borderRadius: [0, 4, 4, 0],
},
label: {
show: true,
position: 'right',
color: '#8a8a9a',
color: '#8b8ea8',
fontSize: 11,
formatter: '{c}s',
},
@ -158,6 +200,15 @@ export function DashboardPage() {
</div>
</div>
{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(200, sortedTeams.length * 36) }} />
</div>
</div>
)}
<div className={styles.chartSection}>
<h2 className={styles.sectionTitle}>Top 10 · </h2>
<div className={styles.chartWrapper}>

View File

@ -33,7 +33,7 @@ export function LoginPage() {
return (
<div className={styles.page}>
<div className={styles.card}>
<h1 className={styles.title}>Jimeng Clone</h1>
<h1 className={styles.title}>AirDrama</h1>
<p className={styles.subtitle}>AI </p>
<form onSubmit={handleSubmit} className={styles.form}>

View File

@ -1,9 +1,8 @@
.page {
max-width: 720px;
max-width: 1200px;
margin: 0 auto;
padding: 24px 20px 60px;
height: 100vh;
overflow-y: auto;
min-height: 100vh;
}
/* Header */
@ -105,28 +104,11 @@
/* Overview grid */
.overviewGrid {
display: grid;
grid-template-columns: 180px 1fr 1fr;
grid-template-columns: 1fr 1fr 1fr;
gap: 14px;
align-items: stretch;
}
.gaugeCard {
background: var(--color-bg-card);
border: 1px solid var(--color-border-card);
border-radius: var(--radius-card);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 12px;
}
.gaugeLabel {
color: var(--color-text-secondary);
font-size: 12px;
margin-top: 4px;
}
.quotaCard {
background: var(--color-bg-card);
border: 1px solid var(--color-border-card);
@ -356,11 +338,17 @@
.skeletonCards {
display: grid;
grid-template-columns: 180px 1fr 1fr;
grid-template-columns: 1fr 1fr 1fr;
gap: 14px;
height: 180px;
}
@media (max-width: 900px) {
.skeletonCards {
grid-template-columns: 1fr 1fr;
}
}
.skeletonCards::before,
.skeletonCards::after {
content: '';
@ -381,6 +369,12 @@
50% { opacity: 0.5; }
}
@media (max-width: 900px) {
.overviewGrid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 640px) {
.overviewGrid {
grid-template-columns: 1fr;

View File

@ -2,7 +2,7 @@ import { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import ReactEChartsCore from 'echarts-for-react/lib/core';
import * as echarts from 'echarts/core';
import { GaugeChart, LineChart } from 'echarts/charts';
import { LineChart } from 'echarts/charts';
import { GridComponent, TooltipComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import { useAuthStore } from '../store/auth';
@ -11,7 +11,7 @@ import type { ProfileOverview, AdminRecord } from '../types';
import { showToast } from '../components/Toast';
import styles from './ProfilePage.module.css';
echarts.use([GaugeChart, LineChart, GridComponent, TooltipComponent, CanvasRenderer]);
echarts.use([LineChart, GridComponent, TooltipComponent, CanvasRenderer]);
export function ProfilePage() {
const user = useAuthStore((s) => s.user);
@ -75,37 +75,8 @@ 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 gaugeOption: echarts.EChartsCoreOption = {
series: [{
type: 'gauge',
startAngle: 220,
endAngle: -40,
min: 0,
max: overview.daily_seconds_limit,
pointer: { show: false },
progress: {
show: true,
width: 14,
roundCap: true,
itemStyle: {
color: dailyPercent > 80 ? (dailyPercent >= 100 ? '#e74c3c' : '#f39c12') : '#00b8e6',
},
},
axisLine: { lineStyle: { width: 14, color: [[1, '#1e1e2a']] } },
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
detail: {
valueAnimation: true,
fontSize: 20,
fontWeight: 700,
color: '#fff',
formatter: `${overview.daily_seconds_used}s\n/${overview.daily_seconds_limit}s`,
offsetCenter: [0, '10%'],
},
data: [{ value: overview.daily_seconds_used }],
}],
};
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 sparklineOption: echarts.EChartsCoreOption = {
tooltip: {
@ -162,13 +133,20 @@ export function ProfilePage() {
<div className={styles.overviewSection}>
<h2 className={styles.sectionTitle}></h2>
<div className={styles.overviewGrid}>
<div className={styles.gaugeCard}>
<ReactEChartsCore echarts={echarts} option={gaugeOption} style={{ height: 180, width: 180 }} />
<div className={styles.gaugeLabel}></div>
<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}s / {overview.daily_seconds_limit}s</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.progressFill} style={{
width: `${Math.min(dailyPercent, 100)}%`,
@ -179,7 +157,7 @@ export function ProfilePage() {
</div>
<div className={styles.quotaCard}>
<div className={styles.quotaLabel}></div>
<div className={styles.quotaValue}>: {overview.monthly_seconds_used}s / {overview.monthly_seconds_limit}s</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.progressFill} style={{
width: `${Math.min(monthlyPercent, 100)}%`,
@ -219,7 +197,7 @@ export function ProfilePage() {
<div className={styles.recordPrompt}>{r.prompt || '-'}</div>
</div>
<div className={styles.recordRight}>
<span className={styles.recordSeconds}>{r.seconds_consumed}s</span>
<span className={styles.recordSeconds}>{r.seconds_consumed.toLocaleString()}s</span>
<span className={styles.recordMode}>{r.mode === 'universal' ? '全能参考' : '首尾帧'}</span>
<span className={`${styles.recordStatus} ${styles[r.status]}`}>{statusMap[r.status]}</span>
</div>

View File

@ -19,6 +19,10 @@
}
.dateInput:focus { border-color: var(--color-primary); }
.dateSep { color: var(--color-text-secondary); font-size: 13px; }
.teamSelect {
padding: 8px 12px; background: var(--color-bg-card); border: 1px solid var(--color-border-card);
border-radius: 8px; color: var(--color-text-primary); font-size: 13px; outline: none;
}
.searchBtn { padding: 8px 16px; background: var(--color-primary); border: none; border-radius: 8px; color: #fff; font-size: 13px; cursor: pointer; }
.searchBtn:hover { opacity: 0.9; }
@ -37,6 +41,16 @@
.statusBadge { padding: 2px 8px; border-radius: 4px; font-size: 12px; }
.completed { background: rgba(0, 184, 148, 0.15); color: var(--color-success); }
.failed { background: rgba(231, 76, 60, 0.15); color: var(--color-danger); }
.statusCell { position: relative; }
.statusCell:hover .errorTooltip { opacity: 1; visibility: visible; transform: translateX(-50%) translateY(0); }
.errorTooltip {
position: absolute; bottom: calc(100% + 4px); left: 50%; transform: translateX(-50%) translateY(4px);
background: #16161e; border: 1px solid var(--color-border-card); border-radius: 6px;
padding: 6px 10px; font-size: 12px; color: var(--color-danger); white-space: nowrap;
max-width: 300px; overflow: hidden; text-overflow: ellipsis;
opacity: 0; visibility: hidden; transition: all 0.15s; z-index: 10;
pointer-events: none; box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
.queued, .processing { background: rgba(0, 184, 230, 0.15); color: var(--color-primary); }
.empty { text-align: center; color: var(--color-text-secondary); padding: 40px; }
.skeletonCell { height: 16px; background: var(--color-border-card); border-radius: 4px; animation: pulse 1.5s ease-in-out infinite; }

View File

@ -1,7 +1,9 @@
import { useEffect, useState, useCallback } from 'react';
import { adminApi } from '../lib/api';
import type { AdminRecord } from '../types';
import type { AdminRecord, Team } from '../types';
import { showToast } from '../components/Toast';
import { DatePicker } from '../components/DatePicker';
import { Select } from '../components/Select';
import styles from './RecordsPage.module.css';
export function RecordsPage() {
@ -11,9 +13,16 @@ export function RecordsPage() {
const [search, setSearch] = useState('');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [teamFilter, setTeamFilter] = useState('');
const [teams, setTeams] = useState<Team[]>([]);
const [loading, setLoading] = useState(true);
const pageSize = 20;
// Load teams for filter dropdown
useEffect(() => {
adminApi.getTeams().then(({ data }) => setTeams(data.results)).catch(() => {});
}, []);
const fetchRecords = useCallback(async () => {
setLoading(true);
try {
@ -21,6 +30,7 @@ export function RecordsPage() {
page, page_size: pageSize, search,
start_date: startDate || undefined,
end_date: endDate || undefined,
team_id: teamFilter ? Number(teamFilter) : undefined,
});
setRecords(data.results);
setTotal(data.total);
@ -29,7 +39,7 @@ export function RecordsPage() {
} finally {
setLoading(false);
}
}, [page, search, startDate, endDate]);
}, [page, search, startDate, endDate, teamFilter]);
useEffect(() => { fetchRecords(); }, [fetchRecords]);
@ -45,15 +55,17 @@ export function RecordsPage() {
page: 1, page_size: 10000, search,
start_date: startDate || undefined,
end_date: endDate || undefined,
team_id: teamFilter ? Number(teamFilter) : undefined,
});
const header = '时间,用户名,消费秒数,提示词,生成模式,状态\n';
const header = '时间,团队,用户名,消费秒数,提示词,生成模式,状态,失败原因\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];
return `${r.created_at},${r.username},"${r.seconds_consumed}","${prompt}","${modeLabel}","${statusLabel}"`;
const errorMsg = (r.error_message || '').replace(/"/g, '""').replace(/^[=+\-@]/, "'$&");
return `${r.created_at},"${r.team_name || '-'}",${r.username},"${r.seconds_consumed}","${prompt}","${modeLabel}","${statusLabel}","${errorMsg}"`;
}).join('\n');
const blob = new Blob(['\uFEFF' + header + rows], { type: 'text/csv;charset=utf-8;' });
@ -88,19 +100,15 @@ export function RecordsPage() {
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
<input
type="date"
className={styles.dateInput}
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
<Select
value={teamFilter}
onChange={(v) => { setTeamFilter(v); setPage(1); }}
placeholder="全部团队"
options={[{ label: '全部团队', value: '' }, ...teams.map((t) => ({ label: t.name, value: String(t.id) }))]}
/>
<DatePicker value={startDate} onChange={setStartDate} placeholder="开始日期" />
<span className={styles.dateSep}>~</span>
<input
type="date"
className={styles.dateInput}
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
<DatePicker value={endDate} onChange={setEndDate} placeholder="结束日期" />
<button className={styles.searchBtn} onClick={handleSearch}></button>
</div>
@ -109,6 +117,7 @@ export function RecordsPage() {
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
@ -120,25 +129,29 @@ export function RecordsPage() {
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i}>
{Array.from({ length: 6 }).map((_, j) => (
{Array.from({ length: 7 }).map((_, j) => (
<td key={j}><div className={styles.skeletonCell} /></td>
))}
</tr>
))
) : records.length === 0 ? (
<tr><td colSpan={6} className={styles.empty}></td></tr>
<tr><td colSpan={7} className={styles.empty}></td></tr>
) : (
records.map((r) => (
<tr key={r.id}>
<td className={styles.timeCell}>{new Date(r.created_at).toLocaleString('zh-CN')}</td>
<td>{r.team_name || '-'}</td>
<td>{r.username}</td>
<td><span className={styles.secondsBadge}>{r.seconds_consumed}s</span></td>
<td><span className={styles.secondsBadge}>{r.seconds_consumed.toLocaleString()}s</span></td>
<td className={styles.promptCell}>{r.prompt ? (r.prompt.slice(0, 40) + (r.prompt.length > 40 ? '...' : '')) : '-'}</td>
<td>{r.mode === 'universal' ? '全能参考' : '首尾帧'}</td>
<td>
<td className={r.status === 'failed' && r.error_message ? styles.statusCell : undefined}>
<span className={`${styles.statusBadge} ${styles[r.status]}`}>
{statusMap[r.status]}
</span>
{r.status === 'failed' && r.error_message && (
<span className={styles.errorTooltip}>{r.error_message}</span>
)}
</td>
</tr>
))

View File

@ -1,115 +1,6 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/auth';
import styles from './AuthPage.module.css';
import { Navigate } from 'react-router-dom';
export function RegisterPage() {
const navigate = useNavigate();
const register = useAuthStore((s) => s.register);
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (username.length < 3 || username.length > 20) {
setError('用户名需要3-20个字符'); return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setError('请输入合法的邮箱地址'); return;
}
if (password.length < 6) {
setError('密码至少6位'); return;
}
if (password !== confirmPassword) {
setError('两次输入的密码不一致'); return;
}
setLoading(true);
try {
await register(username, email, password);
navigate('/', { replace: true });
} catch (err: any) {
const data = err.response?.data;
if (data) {
const messages = Object.values(data).flat();
setError(messages.join('') || '注册失败,请重试');
} else {
setError('注册失败,请重试');
}
} finally {
setLoading(false);
}
};
return (
<div className={styles.page}>
<div className={styles.card}>
<h1 className={styles.title}></h1>
<p className={styles.subtitle}> AI </p>
<form onSubmit={handleSubmit} className={styles.form}>
<div className={styles.field}>
<label className={styles.label}></label>
<input
type="text"
className={styles.input}
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="3-20个字符"
autoFocus
/>
</div>
<div className={styles.field}>
<label className={styles.label}></label>
<input
type="email"
className={styles.input}
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
/>
</div>
<div className={styles.field}>
<label className={styles.label}></label>
<input
type="password"
className={styles.input}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="至少6位"
/>
</div>
<div className={styles.field}>
<label className={styles.label}></label>
<input
type="password"
className={styles.input}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="再次输入密码"
/>
</div>
{error && <div className={styles.error}>{error}</div>}
<button type="submit" className={styles.submitBtn} disabled={loading}>
{loading ? '注册中...' : '注册'}
</button>
</form>
<p className={styles.switchLink}>
<Link to="/login"> </Link>
</p>
</div>
</div>
);
// Registration is disabled — all accounts created by admins
return <Navigate to="/login" replace />;
}

View File

@ -0,0 +1,85 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/auth';
import { useState } from 'react';
import styles from './AdminLayout.module.css';
const navItems = [
{ path: '/team/dashboard', label: '概览', icon: 'M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z' },
{ path: '/team/members', label: '成员管理', icon: 'M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z' },
];
export function TeamAdminLayout() {
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const navigate = useNavigate();
const [collapsed, setCollapsed] = useState(false);
const handleLogout = () => {
logout();
navigate('/login', { replace: true });
};
return (
<div className={styles.layout}>
<aside className={`${styles.sidebar} ${collapsed ? styles.collapsed : ''}`}>
<div className={styles.sidebarHeader}>
<div className={styles.logo}>
<svg viewBox="0 0 24 24" width="24" height="24" fill="var(--color-primary)">
<path d="M12 7V3H2v18h20V7H12zM6 19H4v-2h2v2zm0-4H4v-2h2v2zm0-4H4V9h2v2zm0-4H4V5h2v2zm4 12H8v-2h2v2zm0-4H8v-2h2v2zm0-4H8V9h2v2zm0-4H8V5h2v2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2z"/>
</svg>
{!collapsed && <span className={styles.logoText}></span>}
</div>
<button className={styles.collapseBtn} onClick={() => setCollapsed(!collapsed)}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="var(--color-text-secondary)">
{collapsed ? (
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
) : (
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
)}
</svg>
</button>
</div>
<nav className={styles.nav}>
<button className={styles.navItem} onClick={() => navigate('/')} style={{ border: 'none', background: 'transparent', cursor: 'pointer', textAlign: 'left', width: '100%' }}>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>
</svg>
{!collapsed && <span></span>}
</button>
<div className={styles.navDivider} />
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
`${styles.navItem} ${isActive ? styles.navItemActive : ''}`
}
>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d={item.icon} />
</svg>
{!collapsed && <span>{item.label}</span>}
</NavLink>
))}
</nav>
<div className={styles.sidebarFooter}>
<div className={styles.userInfo}>
<div className={styles.userAvatar}>{user?.username.charAt(0).toUpperCase()}</div>
{!collapsed && (
<div className={styles.userMeta}>
<span className={styles.userName}>{user?.username}</span>
<button className={styles.logoutLink} onClick={handleLogout}>退</button>
</div>
)}
</div>
</div>
</aside>
<main className={`${styles.content} ${collapsed ? styles.contentExpanded : ''}`}>
<Outlet />
</main>
</div>
);
}

View File

@ -0,0 +1,171 @@
import { useEffect, useState, useCallback } from 'react';
import ReactEChartsCore from 'echarts-for-react/lib/core';
import * as echarts from 'echarts/core';
import { LineChart, BarChart } from 'echarts/charts';
import { GridComponent, TooltipComponent, LegendComponent, DataZoomComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import { teamApi } from '../lib/api';
import type { TeamInfo, TeamStats } from '../types';
import { showToast } from '../components/Toast';
import styles from './DashboardPage.module.css';
echarts.use([LineChart, BarChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, CanvasRenderer]);
export function TeamDashboardPage() {
const [info, setInfo] = useState<(TeamInfo & { daily_member_limit_default: number; member_count: number }) | null>(null);
const [stats, setStats] = useState<TeamStats | null>(null);
const [loading, setLoading] = useState(true);
const fetchData = useCallback(async () => {
try {
const [infoRes, statsRes] = await Promise.all([
teamApi.getInfo(),
teamApi.getStats(),
]);
setInfo(infoRes.data);
setStats(statsRes.data);
} catch {
showToast('加载团队数据失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchData(); }, [fetchData]);
if (loading) {
return (
<div className={styles.page}>
<div className={styles.skeleton}>
<div className={styles.skeletonCards}>
{[1, 2, 3, 4, 5].map((i) => <div key={i} className={styles.skeletonCard} />)}
</div>
<div className={styles.skeletonChart} />
<div className={styles.skeletonChart} />
</div>
</div>
);
}
if (!info || !stats) return null;
const formatLimit = (v: number) => v === -1 ? '不限' : v.toLocaleString() + 's';
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' },
];
const trendOption: echarts.EChartsCoreOption = {
tooltip: {
trigger: 'axis',
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 }[])[0];
return `${p.name}<br/>消费: ${p.value}s`;
},
},
grid: { left: 50, right: 20, top: 20, bottom: 60 },
xAxis: {
type: 'category',
data: stats.daily_trend.map((d) => d.date.slice(5)),
axisLabel: { color: '#8b8ea8', fontSize: 11 },
axisLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.08)' } },
},
yAxis: {
type: 'value',
axisLabel: { color: '#8b8ea8', fontSize: 11 },
splitLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.06)' } },
},
dataZoom: [{ type: 'inside', start: 0, end: 100 }],
series: [{
type: 'line',
data: stats.daily_trend.map((d) => d.seconds),
smooth: true,
lineStyle: { color: '#6c63ff', width: 2 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(108, 99, 255, 0.25)' },
{ offset: 1, color: 'rgba(108, 99, 255, 0.02)' },
]),
},
itemStyle: { color: '#6c63ff' },
}],
};
const sortedMembers = [...stats.member_consumption].sort((a, b) => a.seconds_consumed - b.seconds_consumed);
const barOption: 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 },
},
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: sortedMembers.map((m) => m.username),
axisLabel: { color: '#8b8ea8', fontSize: 12 },
axisLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.08)' } },
},
series: [{
type: 'bar',
data: sortedMembers.map((m) => m.seconds_consumed),
barWidth: 16,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#6c63ff' },
{ offset: 1, color: '#8b5cf6' },
]),
borderRadius: [0, 4, 4, 0],
},
label: {
show: true,
position: 'right',
color: '#8b8ea8',
fontSize: 11,
formatter: '{c}s',
},
}],
};
return (
<div className={styles.page}>
<h1 className={styles.title}></h1>
<div className={styles.statsGrid} style={{ gridTemplateColumns: 'repeat(5, 1fr)' }}>
{statCards.map((card) => (
<div key={card.label} className={styles.statCard}>
<div className={styles.statLabel}>{card.label}</div>
<div className={styles.statValue}>{card.value}</div>
</div>
))}
</div>
<div className={styles.chartSection}>
<h2 className={styles.sectionTitle}>30</h2>
<div className={styles.chartWrapper}>
<ReactEChartsCore echarts={echarts} option={trendOption} style={{ height: 320 }} />
</div>
</div>
<div className={styles.chartSection}>
<h2 className={styles.sectionTitle}></h2>
<div className={styles.chartWrapper}>
<ReactEChartsCore echarts={echarts} option={barOption} style={{ height: Math.max(300, sortedMembers.length * 36) }} />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,241 @@
import { useEffect, useState, useCallback } from 'react';
import { teamApi } from '../lib/api';
import type { TeamMember } from '../types';
import { showToast } from '../components/Toast';
import { ConfirmModal } from '../components/ConfirmModal';
import styles from './UsersPage.module.css';
export function TeamMembersPage() {
const [members, setMembers] = useState<TeamMember[]>([]);
const [loading, setLoading] = useState(true);
// Create member modal
const [createOpen, setCreateOpen] = useState(false);
const [newUsername, setNewUsername] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newDaily, setNewDaily] = useState('600');
const [newMonthly, setNewMonthly] = useState('6000');
const [createError, setCreateError] = useState('');
// Confirm toggle
const [confirmMember, setConfirmMember] = useState<TeamMember | null>(null);
// Edit quota modal
const [editMember, setEditMember] = useState<TeamMember | null>(null);
const [editDaily, setEditDaily] = useState('');
const [editMonthly, setEditMonthly] = useState('');
const fetchMembers = useCallback(async () => {
setLoading(true);
try {
const { data } = await teamApi.getMembers();
setMembers(data.results);
} catch {
showToast('加载成员列表失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchMembers(); }, [fetchMembers]);
const formatLimit = (v: number) => v === -1 ? '不限' : v.toLocaleString() + 's';
const handleToggleStatus = async (member: TeamMember) => {
try {
await teamApi.updateMemberStatus(member.id, !member.is_active);
showToast(member.is_active ? '已禁用成员' : '已启用成员');
fetchMembers();
} catch {
showToast('操作失败');
}
};
const openEditModal = (member: TeamMember) => {
setEditMember(member);
setEditDaily(String(member.daily_seconds_limit));
setEditMonthly(String(member.monthly_seconds_limit));
};
const handleSaveQuota = async () => {
if (!editMember) return;
try {
await teamApi.updateMemberQuota(editMember.id, Number(editDaily), Number(editMonthly));
showToast('配额已更新');
setEditMember(null);
fetchMembers();
} catch {
showToast('更新失败');
}
};
const resetCreateForm = () => {
setNewUsername(''); setNewPassword('');
setNewDaily('600'); setNewMonthly('6000');
setCreateError('');
};
const handleCreateMember = async () => {
setCreateError('');
if (!newUsername.trim()) { setCreateError('请输入用户名'); return; }
if (newPassword.length < 6) { setCreateError('密码至少6位'); return; }
try {
await teamApi.createMember({
username: newUsername.trim(),
password: newPassword,
daily_seconds_limit: Number(newDaily),
monthly_seconds_limit: Number(newMonthly),
});
showToast('成员创建成功');
setCreateOpen(false);
resetCreateForm();
fetchMembers();
} catch (err: any) {
const msg = err.response?.data?.error || err.response?.data?.username?.[0] || '创建失败';
setCreateError(msg);
}
};
return (
<div className={styles.page}>
<h1 className={styles.title}></h1>
<div className={styles.filters}>
<div className={styles.searchGroup}>
<button className={styles.refreshBtn} onClick={fetchMembers}></button>
</div>
<div className={styles.searchGroup}>
<button className={styles.createBtn} onClick={() => { resetCreateForm(); setCreateOpen(true); }}>+ </button>
</div>
</div>
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th>()</th>
<th>()</th>
<th>()</th>
<th>()</th>
<th></th>
</tr>
</thead>
<tbody>
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i}>
{Array.from({ length: 8 }).map((_, j) => (
<td key={j}><div className={styles.skeletonCell} /></td>
))}
</tr>
))
) : members.length === 0 ? (
<tr><td colSpan={8} className={styles.empty}></td></tr>
) : (
members.map((m) => (
<tr key={m.id}>
<td>{m.username}</td>
<td>
{m.is_team_admin ? (
<span className={styles.statusBadge} style={{ background: 'rgba(108, 99, 255, 0.15)', color: '#6c63ff' }}></span>
) : (
<span style={{ color: 'var(--color-text-secondary)', fontSize: 12 }}></span>
)}
</td>
<td>
<span className={`${styles.statusBadge} ${m.is_active ? styles.active : styles.disabled}`}>
{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>
<div className={styles.actions}>
<button className={styles.editBtn} onClick={() => openEditModal(m)}></button>
<button
className={`${styles.toggleBtn} ${m.is_active ? styles.disableBtn : styles.enableBtn}`}
onClick={() => setConfirmMember(m)}
>
{m.is_active ? '禁用' : '启用'}
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<ConfirmModal
open={!!confirmMember}
title={confirmMember?.is_active ? '禁用成员' : '启用成员'}
message={confirmMember?.is_active
? `确定要禁用成员「${confirmMember?.username}」吗?`
: `确定要启用成员「${confirmMember?.username}」吗?`}
confirmText={confirmMember?.is_active ? '禁用' : '启用'}
danger={confirmMember?.is_active}
onConfirm={() => { if (confirmMember) { handleToggleStatus(confirmMember); setConfirmMember(null); } }}
onCancel={() => setConfirmMember(null)}
/>
{/* Edit Quota Modal */}
{editMember && (
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setEditMember(null); }}>
<div className={styles.modal}>
<h3 className={styles.modalTitle}> {editMember.username}</h3>
<div className={styles.formGroup}>
<label>-1 </label>
<input type="number" value={editDaily} onChange={(e) => setEditDaily(e.target.value)} />
</div>
<div className={styles.formGroup}>
<label>-1 </label>
<input type="number" value={editMonthly} onChange={(e) => setEditMonthly(e.target.value)} />
</div>
<div className={styles.modalActions}>
<button className={styles.cancelBtn} onClick={() => setEditMember(null)}></button>
<button className={styles.saveBtn} onClick={handleSaveQuota}></button>
</div>
</div>
</div>
)}
{/* Create Member Modal */}
{createOpen && (
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setCreateOpen(false); }}>
<div className={styles.modal}>
<h3 className={styles.modalTitle}></h3>
<div className={styles.formGroup}>
<label></label>
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} placeholder="请输入用户名" />
</div>
<div className={styles.formGroup}>
<label></label>
<input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} placeholder="至少6位" />
</div>
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label></label>
<input type="number" value={newDaily} onChange={(e) => setNewDaily(e.target.value)} />
</div>
<div className={styles.formGroup}>
<label></label>
<input type="number" value={newMonthly} onChange={(e) => setNewMonthly(e.target.value)} />
</div>
</div>
{createError && <div className={styles.formError}>{createError}</div>}
<div className={styles.modalActions}>
<button className={styles.cancelBtn} onClick={() => setCreateOpen(false)}></button>
<button className={styles.saveBtn} onClick={handleCreateMember}></button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,266 @@
.page { max-width: 1200px; }
.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; }
.searchGroup { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.searchInput {
padding: 8px 12px; background: var(--color-bg-card); border: 1px solid var(--color-border-card);
border-radius: 8px; color: var(--color-text-primary); font-size: 13px; width: 240px; outline: none;
}
.searchInput:focus { border-color: var(--color-primary); }
.refreshBtn {
padding: 8px 16px; border-radius: 8px; font-size: 13px; cursor: pointer; transition: all 0.15s;
background: transparent; border: 1px solid var(--color-border-card); color: var(--color-text-secondary);
}
.refreshBtn:hover { background: var(--color-sidebar-hover); }
.createBtn { padding: 8px 16px; border-radius: 8px; font-size: 13px; cursor: pointer; transition: all 0.15s; background: var(--color-success); border: none; color: #fff; font-weight: 500; }
.createBtn:hover { opacity: 0.9; }
.tableWrapper {
background: var(--color-bg-card); border: 1px solid var(--color-border-card);
border-radius: var(--radius-card); overflow-x: auto;
}
.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 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:hover td { background: rgba(255, 255, 255, 0.02); }
.teamNameLink { background: none; border: none; color: var(--color-primary); cursor: pointer; font-size: 13px; text-decoration: underline; }
.statusBadge { padding: 2px 8px; border-radius: 4px; font-size: 12px; }
.active { background: rgba(0, 184, 148, 0.15); color: var(--color-success); }
.disabled { background: rgba(231, 76, 60, 0.15); color: var(--color-danger); }
.actions { display: flex; gap: 6px; }
.editBtn, .toggleBtn, .topupBtn, .adminBtn { padding: 4px 10px; border-radius: 6px; font-size: 12px; cursor: pointer; transition: all 0.15s; }
.topupBtn { background: transparent; border: 1px solid var(--color-primary); color: var(--color-primary); }
.topupBtn:hover { background: rgba(0, 184, 230, 0.1); }
.adminBtn { background: transparent; border: 1px solid #a78bfa; color: #a78bfa; }
.adminBtn:hover { background: rgba(167, 139, 250, 0.1); }
.disableBtn { background: transparent; border: 1px solid var(--color-danger); color: var(--color-danger); }
.disableBtn:hover { background: rgba(231, 76, 60, 0.1); }
.enableBtn { background: transparent; border: 1px solid var(--color-success); color: var(--color-success); }
.enableBtn:hover { background: rgba(0, 184, 148, 0.1); }
.empty { text-align: center; color: var(--color-text-secondary); padding: 40px; }
.skeletonCell { height: 16px; background: var(--color-border-card); border-radius: 4px; animation: pulse 1.5s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
.secondsMain { color: var(--color-text-primary); font-weight: 500; }
.secondsSub { color: var(--color-text-secondary); font-size: 11px; margin-left: 4px; }
/* Modal */
.modalOverlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 300; }
.modal { background: #16161e; border: 1px solid var(--color-border-card); border-radius: var(--radius-card); padding: 24px; width: 400px; max-width: 90vw; }
.modalTitle { font-size: 16px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 20px; }
.formGroup { margin-bottom: 16px; }
.formGroup label { display: block; color: var(--color-text-secondary); font-size: 13px; margin-bottom: 6px; }
.formGroup input { width: 100%; padding: 8px 12px; background: var(--color-bg-page); border: 1px solid var(--color-border-card); border-radius: 8px; color: var(--color-text-primary); font-size: 14px; outline: none; box-sizing: border-box; }
.formGroup input:focus { border-color: var(--color-primary); }
.modalActions { display: flex; justify-content: flex-end; gap: 8px; }
.cancelBtn { padding: 8px 16px; background: transparent; border: 1px solid var(--color-border-card); border-radius: 8px; color: var(--color-text-secondary); font-size: 13px; cursor: pointer; }
.saveBtn { padding: 8px 16px; background: var(--color-primary); border: none; border-radius: 8px; color: #fff; font-size: 13px; cursor: pointer; }
.formRow { display: flex; gap: 12px; }
.formRow .formGroup { flex: 1; }
.formError { color: var(--color-danger); font-size: 13px; margin-bottom: 12px; }
.formHint { color: var(--color-text-secondary); font-size: 12px; margin-top: 4px; }
/*
Team Detail Modal (follows VideoDetailModal spec)
*/
.detailOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.65);
display: flex;
align-items: center;
justify-content: center;
z-index: 300;
animation: overlayIn 0.2s ease-out;
}
@keyframes overlayIn {
from { opacity: 0; }
to { opacity: 1; }
}
.detailModal {
background: rgba(22, 22, 30, 0.92);
backdrop-filter: blur(24px) saturate(180%);
-webkit-backdrop-filter: blur(24px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
width: 1080px;
max-width: 96vw;
min-height: 70vh;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255, 255, 255, 0.04) inset;
animation: modalIn 0.25s ease;
}
@keyframes modalIn {
from { opacity: 0; transform: scale(0.96) translateY(12px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
/* ── Header ── */
.detailHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 28px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0;
}
.detailHeader h3 {
font-size: 17px;
font-weight: 600;
color: var(--color-text-primary);
display: flex;
align-items: center;
gap: 10px;
margin: 0;
}
.detailClose {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.06);
border: none;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.detailClose:hover {
color: #fff;
background: rgba(255, 255, 255, 0.12);
}
/* ── Body ── */
.detailBody {
flex: 1;
overflow-y: auto;
padding: 28px;
}
/* ── Stats grid ── */
.detailGrid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
margin-bottom: 28px;
}
.detailItem {
display: flex;
flex-direction: column;
gap: 8px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
padding: 16px 18px;
transition: background 0.15s;
}
.detailItem:hover {
background: rgba(255, 255, 255, 0.06);
}
.detailLabel {
color: #8b8ea8;
font-size: 12px;
font-weight: 500;
line-height: 1;
}
.detailValue {
color: #f1f0ff;
font-size: 18px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.editPoolBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.06);
border: none;
color: rgba(255, 255, 255, 0.4);
cursor: pointer;
transition: color 0.15s, background 0.15s;
flex-shrink: 0;
}
.editPoolBtn:hover {
color: var(--color-primary);
background: rgba(0, 184, 230, 0.12);
}
/* ── Members section ── */
.membersTitle {
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: 14px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.memberTableWrapper {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
overflow: hidden;
}
.memberTable {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.memberTable th {
padding: 12px 18px;
text-align: left;
color: #8b8ea8;
font-weight: 500;
font-size: 13px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.02);
white-space: nowrap;
}
.memberTable td {
padding: 14px 18px;
color: #f1f0ff;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.memberTable tr:last-child td { border-bottom: none; }
.memberTable tr:hover td {
background: rgba(255, 255, 255, 0.04);
}
.adminBadge {
background: rgba(167, 139, 250, 0.15);
color: #a78bfa;
padding: 3px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
}

462
web/src/pages/TeamsPage.tsx Normal file
View File

@ -0,0 +1,462 @@
import { useEffect, useState, useCallback } from 'react';
import { adminApi } from '../lib/api';
import type { Team, TeamDetail } from '../types';
import { showToast } from '../components/Toast';
import { ConfirmModal } from '../components/ConfirmModal';
import styles from './TeamsPage.module.css';
function fmtSec(s: number): string {
return Math.round(s).toLocaleString() + 's';
}
export function TeamsPage() {
const [teams, setTeams] = useState<Team[]>([]);
const [loading, setLoading] = useState(true);
// Create team modal
const [createOpen, setCreateOpen] = useState(false);
const [newName, setNewName] = useState('');
const [newMonthlyLimit, setNewMonthlyLimit] = useState('36000');
const [newDailyMemberLimit, setNewDailyMemberLimit] = useState('600');
const [createError, setCreateError] = useState('');
// Top-up modal
const [topupTeam, setTopupTeam] = useState<Team | null>(null);
const [topupSeconds, setTopupSeconds] = useState('3600');
// Create admin modal
const [adminTeam, setAdminTeam] = useState<Team | null>(null);
const [adminUsername, setAdminUsername] = useState('');
const [adminEmail, setAdminEmail] = useState('');
const [adminPassword, setAdminPassword] = useState('');
const [adminError, setAdminError] = useState('');
// Team detail drawer
const [detailTeam, setDetailTeam] = useState<TeamDetail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
// Edit pool modal
const [editPoolOpen, setEditPoolOpen] = useState(false);
const [editPoolValue, setEditPoolValue] = useState('');
const [editPoolError, setEditPoolError] = useState('');
// Confirm toggle
const [confirmTeam, setConfirmTeam] = useState<Team | null>(null);
const fetchTeams = useCallback(async () => {
setLoading(true);
try {
const { data } = await adminApi.getTeams();
setTeams(data.results);
} catch {
showToast('加载团队列表失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchTeams(); }, [fetchTeams]);
const handleToggleStatus = async (team: Team) => {
try {
await adminApi.updateTeam(team.id, { is_active: !team.is_active });
showToast(team.is_active ? '已禁用团队' : '已启用团队');
fetchTeams();
} catch {
showToast('操作失败');
}
};
const resetCreateForm = () => {
setNewName(''); setNewMonthlyLimit('36000'); setNewDailyMemberLimit('600');
setCreateError('');
};
const handleCreateTeam = async () => {
setCreateError('');
if (!newName.trim()) { setCreateError('请输入团队名称'); return; }
try {
await adminApi.createTeam({
name: newName.trim(),
monthly_seconds_limit: Number(newMonthlyLimit),
daily_member_limit_default: Number(newDailyMemberLimit),
});
showToast('团队创建成功');
setCreateOpen(false);
resetCreateForm();
fetchTeams();
} catch (err: any) {
const msg = err.response?.data?.error || err.response?.data?.name?.[0] || '创建失败';
setCreateError(msg);
}
};
const handleTopUp = async () => {
if (!topupTeam) return;
const seconds = Number(topupSeconds);
if (!seconds || seconds <= 0) { showToast('请输入有效的秒数'); return; }
try {
await adminApi.topUpTeam(topupTeam.id, seconds);
showToast(`已为 ${topupTeam.name} 充值 ${fmtSec(seconds)}`);
setTopupTeam(null);
fetchTeams();
} catch {
showToast('充值失败');
}
};
const handleSetPool = async () => {
if (!detailTeam) return;
const newPool = Number(editPoolValue);
if (isNaN(newPool) || newPool < 0) { setEditPoolError('请输入有效的非负数'); return; }
try {
await adminApi.setTeamPool(detailTeam.id, newPool);
showToast(`已将 ${detailTeam.name} 总秒数池修改为 ${fmtSec(newPool)}`);
setEditPoolOpen(false);
// Refresh detail
const { data } = await adminApi.getTeamDetail(detailTeam.id);
setDetailTeam(data);
fetchTeams();
} catch (err: any) {
const msg = err.response?.data?.error || '修改失败';
setEditPoolError(msg);
}
};
const resetAdminForm = () => {
setAdminUsername(''); setAdminEmail(''); setAdminPassword('');
setAdminError('');
};
const handleCreateAdmin = async () => {
if (!adminTeam) return;
setAdminError('');
if (!adminUsername.trim()) { setAdminError('请输入用户名'); return; }
if (!adminEmail.trim()) { setAdminError('请输入邮箱'); return; }
if (adminPassword.length < 6) { setAdminError('密码至少6位'); return; }
try {
await adminApi.createTeamAdmin(adminTeam.id, {
username: adminUsername.trim(),
email: adminEmail.trim(),
password: adminPassword,
});
showToast('团队管理员创建成功');
setAdminTeam(null);
resetAdminForm();
fetchTeams();
} catch (err: any) {
const msg = err.response?.data?.error || err.response?.data?.username?.[0] || '创建失败';
setAdminError(msg);
}
};
const openDrawer = async (teamId: number) => {
try {
const { data } = await adminApi.getTeamDetail(teamId);
setDetailTeam(data);
setDrawerOpen(true);
} catch {
showToast('加载团队详情失败');
}
};
const colCount = 9;
return (
<div className={styles.page}>
<h1 className={styles.title}></h1>
<div className={styles.filters}>
<div className={styles.searchGroup}>
<span style={{ color: 'var(--color-text-secondary)', fontSize: 13 }}> {teams.length} </span>
</div>
<div className={styles.searchGroup}>
<button className={styles.refreshBtn} onClick={fetchTeams}></button>
<button className={styles.createBtn} onClick={() => { resetCreateForm(); setCreateOpen(true); }}>+ </button>
</div>
</div>
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i}>
{Array.from({ length: colCount }).map((_, j) => (
<td key={j}><div className={styles.skeletonCell} /></td>
))}
</tr>
))
) : teams.length === 0 ? (
<tr><td colSpan={colCount} className={styles.empty}></td></tr>
) : (
teams.map((t) => (
<tr key={t.id}>
<td>
<button className={styles.teamNameLink} onClick={() => openDrawer(t.id)}>
{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>{t.member_count}</td>
<td>
<span className={`${styles.statusBadge} ${t.is_active ? styles.active : styles.disabled}`}>
{t.is_active ? '启用' : '禁用'}
</span>
</td>
<td>
<div className={styles.actions}>
<button className={styles.topupBtn} onClick={() => { setTopupTeam(t); setTopupSeconds('3600'); }}></button>
<button className={styles.adminBtn} onClick={() => { setAdminTeam(t); resetAdminForm(); }}></button>
<button
className={`${styles.toggleBtn} ${t.is_active ? styles.disableBtn : styles.enableBtn}`}
onClick={() => setConfirmTeam(t)}
>
{t.is_active ? '禁用' : '启用'}
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Create Team Modal */}
{createOpen && (
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setCreateOpen(false); }}>
<div className={styles.modal}>
<h3 className={styles.modalTitle}></h3>
<div className={styles.formGroup}>
<label></label>
<input type="text" value={newName} onChange={(e) => setNewName(e.target.value)} placeholder="请输入团队名称" />
</div>
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label></label>
<input type="number" value={newMonthlyLimit} onChange={(e) => setNewMonthlyLimit(e.target.value)} />
</div>
<div className={styles.formGroup}>
<label>()</label>
<input type="number" value={newDailyMemberLimit} onChange={(e) => setNewDailyMemberLimit(e.target.value)} />
</div>
</div>
{createError && <div className={styles.formError}>{createError}</div>}
<div className={styles.modalActions}>
<button className={styles.cancelBtn} onClick={() => setCreateOpen(false)}></button>
<button className={styles.saveBtn} onClick={handleCreateTeam}></button>
</div>
</div>
</div>
)}
{/* Top-up Modal */}
{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>
<div className={styles.formGroup}>
<label></label>
<input type="number" value={topupSeconds} onChange={(e) => setTopupSeconds(e.target.value)} placeholder="输入秒数" />
<div className={styles.formHint}>
: {fmtSec(topupTeam.remaining_seconds)} | : {fmtSec(topupTeam.remaining_seconds + (Number(topupSeconds) || 0))}
</div>
</div>
<div className={styles.modalActions}>
<button className={styles.cancelBtn} onClick={() => setTopupTeam(null)}></button>
<button className={styles.saveBtn} onClick={handleTopUp}></button>
</div>
</div>
</div>
)}
{/* Create Team Admin Modal */}
{adminTeam && (
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setAdminTeam(null); }}>
<div className={styles.modal}>
<h3 className={styles.modalTitle}> {adminTeam.name}</h3>
<div className={styles.formGroup}>
<label></label>
<input type="text" value={adminUsername} onChange={(e) => setAdminUsername(e.target.value)} placeholder="请输入用户名" />
</div>
<div className={styles.formGroup}>
<label></label>
<input type="email" value={adminEmail} onChange={(e) => setAdminEmail(e.target.value)} placeholder="请输入邮箱" />
</div>
<div className={styles.formGroup}>
<label></label>
<input type="password" value={adminPassword} onChange={(e) => setAdminPassword(e.target.value)} placeholder="至少6位" />
</div>
{adminError && <div className={styles.formError}>{adminError}</div>}
<div className={styles.modalActions}>
<button className={styles.cancelBtn} onClick={() => setAdminTeam(null)}></button>
<button className={styles.saveBtn} onClick={handleCreateAdmin}></button>
</div>
</div>
</div>
)}
<ConfirmModal
open={!!confirmTeam}
title={confirmTeam?.is_active ? '禁用团队' : '启用团队'}
message={confirmTeam?.is_active
? `确定要禁用团队「${confirmTeam?.name}」吗?禁用后该团队所有成员将无法生成视频。`
: `确定要启用团队「${confirmTeam?.name}」吗?`}
confirmText={confirmTeam?.is_active ? '禁用' : '启用'}
danger={confirmTeam?.is_active}
onConfirm={() => { if (confirmTeam) { handleToggleStatus(confirmTeam); setConfirmTeam(null); } }}
onCancel={() => setConfirmTeam(null)}
/>
{/* Team Detail Modal */}
{drawerOpen && detailTeam && (
<div className={styles.detailOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setDrawerOpen(false); }}>
<div className={styles.detailModal}>
<div className={styles.detailHeader}>
<h3>
{detailTeam.name}
<span className={`${styles.statusBadge} ${detailTeam.is_active ? styles.active : styles.disabled}`}>
{detailTeam.is_active ? '启用' : '禁用'}
</span>
</h3>
<button className={styles.detailClose} onClick={() => setDrawerOpen(false)}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<div className={styles.detailBody}>
<div className={styles.detailGrid}>
<div className={styles.detailItem}>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>
{fmtSec(detailTeam.total_seconds_pool)}
<button
className={styles.editPoolBtn}
onClick={() => { setEditPoolValue(String(detailTeam.total_seconds_pool)); 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" />
<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>
</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>{fmtSec(detailTeam.total_seconds_used)}</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>{fmtSec(detailTeam.remaining_seconds)}</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}></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}>{fmtSec(detailTeam.monthly_seconds_used)}</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>
</div>
</div>
<h4 className={styles.membersTitle}> ({detailTeam.members.length})</h4>
{detailTeam.members.length === 0 ? (
<div className={styles.empty}></div>
) : (
<div className={styles.memberTableWrapper}>
<table className={styles.memberTable}>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{detailTeam.members.map((m) => (
<tr key={m.id}>
<td>{m.username}</td>
<td>{m.email}</td>
<td>
{m.is_team_admin ? (
<span className={styles.adminBadge}></span>
) : '成员'}
</td>
<td>
<span className={`${styles.statusBadge} ${m.is_active ? styles.active : styles.disabled}`}>
{m.is_active ? '启用' : '禁用'}
</span>
</td>
<td>{fmtSec(m.daily_seconds_limit)}</td>
<td>{fmtSec(m.seconds_today)}</td>
<td>{fmtSec(m.seconds_this_month)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
)}
{/* Edit Pool Modal */}
{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>
{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="输入总秒数" />
<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))}
</div>
</div>
<div className={styles.modalActions}>
<button className={styles.cancelBtn} onClick={() => setEditPoolOpen(false)}></button>
<button className={styles.saveBtn} onClick={handleSetPool}></button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -63,7 +63,7 @@
/* Modal */
.modalOverlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 300; }
.modal { background: var(--color-bg-card); border: 1px solid var(--color-border-card); border-radius: var(--radius-card); padding: 24px; width: 400px; max-width: 90vw; }
.modal { background: #16161e; border: 1px solid var(--color-border-card); border-radius: var(--radius-card); padding: 24px; width: 400px; max-width: 90vw; }
.modalTitle { font-size: 16px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 20px; }
.formGroup { margin-bottom: 16px; }
.formGroup label { display: block; color: var(--color-text-secondary); font-size: 13px; margin-bottom: 6px; }
@ -82,7 +82,7 @@
.drawerOverlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 300; }
.drawer {
position: fixed; right: 0; top: 0; bottom: 0; width: 440px; max-width: 90vw;
background: var(--color-bg-card); border-left: 1px solid var(--color-border-card);
background: #16161e; border-left: 1px solid var(--color-border-card);
display: flex; flex-direction: column; z-index: 301;
animation: slideIn 0.2s ease;
}

View File

@ -1,7 +1,9 @@
import { useEffect, useState, useCallback } from 'react';
import { adminApi } from '../lib/api';
import type { AdminUser, AdminUserDetail } from '../types';
import type { AdminUser, AdminUserDetail, Team } from '../types';
import { showToast } from '../components/Toast';
import { ConfirmModal } from '../components/ConfirmModal';
import { Select } from '../components/Select';
import styles from './UsersPage.module.css';
export function UsersPage() {
@ -10,6 +12,8 @@ export function UsersPage() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [teamFilter, setTeamFilter] = useState('');
const [teams, setTeams] = useState<Team[]>([]);
const [loading, setLoading] = useState(true);
const pageSize = 20;
@ -22,6 +26,9 @@ export function UsersPage() {
const [detailUser, setDetailUser] = useState<AdminUserDetail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
// Confirm toggle
const [confirmUser, setConfirmUser] = useState<AdminUser | null>(null);
// Create user modal
const [createOpen, setCreateOpen] = useState(false);
const [newUsername, setNewUsername] = useState('');
@ -32,11 +39,17 @@ export function UsersPage() {
const [newIsStaff, setNewIsStaff] = useState(false);
const [createError, setCreateError] = useState('');
// Load teams for filter dropdown
useEffect(() => {
adminApi.getTeams().then(({ data }) => setTeams(data.results)).catch(() => {});
}, []);
const fetchUsers = useCallback(async () => {
setLoading(true);
try {
const { data } = await adminApi.getUsers({
page, page_size: pageSize, search, status: statusFilter,
team_id: teamFilter ? Number(teamFilter) : undefined,
});
setUsers(data.results);
setTotal(data.total);
@ -45,7 +58,7 @@ export function UsersPage() {
} finally {
setLoading(false);
}
}, [page, search, statusFilter]);
}, [page, search, statusFilter, teamFilter]);
useEffect(() => { fetchUsers(); }, [fetchUsers]);
@ -138,15 +151,22 @@ export function UsersPage() {
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
<select
className={styles.statusSelect}
<Select
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
>
<option value=""></option>
<option value="active"></option>
<option value="disabled"></option>
</select>
onChange={(v) => { setStatusFilter(v); setPage(1); }}
placeholder="全部状态"
options={[
{ label: '全部状态', value: '' },
{ label: '启用', value: 'active' },
{ label: '禁用', value: 'disabled' },
]}
/>
<Select
value={teamFilter}
onChange={(v) => { setTeamFilter(v); setPage(1); }}
placeholder="全部团队"
options={[{ label: '全部团队', value: '' }, ...teams.map((t) => ({ label: t.name, value: String(t.id) }))]}
/>
<button className={styles.searchBtn} onClick={handleSearch}></button>
</div>
<div className={styles.searchGroup}>
@ -160,6 +180,7 @@ export function UsersPage() {
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
@ -174,13 +195,13 @@ export function UsersPage() {
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i}>
{Array.from({ length: 9 }).map((_, j) => (
{Array.from({ length: 10 }).map((_, j) => (
<td key={j}><div className={styles.skeletonCell} /></td>
))}
</tr>
))
) : users.length === 0 ? (
<tr><td colSpan={9} className={styles.empty}></td></tr>
<tr><td colSpan={10} className={styles.empty}></td></tr>
) : (
users.map((u) => (
<tr key={u.id}>
@ -189,6 +210,7 @@ export function UsersPage() {
{u.username}
</button>
</td>
<td>{u.team_name || '-'}</td>
<td>{u.email}</td>
<td>{new Date(u.date_joined).toLocaleDateString('zh-CN')}</td>
<td>
@ -196,16 +218,16 @@ export function UsersPage() {
{u.is_active ? '启用' : '禁用'}
</span>
</td>
<td>{u.daily_seconds_limit}</td>
<td>{u.monthly_seconds_limit}</td>
<td>{u.seconds_today}</td>
<td>{u.seconds_this_month}</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>
<div className={styles.actions}>
<button className={styles.editBtn} onClick={() => openEditModal(u)}></button>
<button
className={`${styles.toggleBtn} ${u.is_active ? styles.disableBtn : styles.enableBtn}`}
onClick={() => handleToggleStatus(u)}
onClick={() => setConfirmUser(u)}
>
{u.is_active ? '禁用' : '启用'}
</button>
@ -240,10 +262,22 @@ export function UsersPage() {
</div>
)}
<ConfirmModal
open={!!confirmUser}
title={confirmUser?.is_active ? '禁用用户' : '启用用户'}
message={confirmUser?.is_active
? `确定要禁用用户「${confirmUser?.username}」吗?`
: `确定要启用用户「${confirmUser?.username}」吗?`}
confirmText={confirmUser?.is_active ? '禁用' : '启用'}
danger={confirmUser?.is_active}
onConfirm={() => { if (confirmUser) { handleToggleStatus(confirmUser); setConfirmUser(null); } }}
onCancel={() => setConfirmUser(null)}
/>
{/* Quota Edit Modal */}
{editUser && (
<div className={styles.modalOverlay} onClick={() => setEditUser(null)}>
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setEditUser(null); }}>
<div className={styles.modal}>
<h3 className={styles.modalTitle}> {editUser.username}</h3>
<div className={styles.formGroup}>
<label></label>
@ -263,8 +297,8 @@ export function UsersPage() {
{/* Create User Modal */}
{createOpen && (
<div className={styles.modalOverlay} onClick={() => setCreateOpen(false)}>
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setCreateOpen(false); }}>
<div className={styles.modal}>
<h3 className={styles.modalTitle}></h3>
<div className={styles.formGroup}>
<label></label>
@ -333,15 +367,15 @@ export function UsersPage() {
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}>/</span>
<span className={styles.detailValue}>{detailUser.seconds_today}s / {detailUser.daily_seconds_limit}s</span>
<span className={styles.detailValue}>{detailUser.seconds_today.toLocaleString()}s / {detailUser.daily_seconds_limit === -1 ? '不限' : detailUser.daily_seconds_limit.toLocaleString() + 's'}</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}>/</span>
<span className={styles.detailValue}>{detailUser.seconds_this_month}s / {detailUser.monthly_seconds_limit}s</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.seconds_total}s</span>
<span className={styles.detailValue}>{detailUser.seconds_total.toLocaleString()}s</span>
</div>
</div>
@ -354,7 +388,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}s</span>
<span className={styles.recordSeconds}>{r.seconds_consumed.toLocaleString()}s</span>
<span className={styles.recordMode}>{r.mode === 'universal' ? '全能参考' : '首尾帧'}</span>
<span className={`${styles.recordStatus} ${styles[r.status]}`}>{
{ queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' }[r.status]

View File

@ -1,5 +1,5 @@
import { create } from 'zustand';
import type { User, Quota } from '../types';
import type { User, Quota, TeamInfo } from '../types';
import { authApi } from '../lib/api';
interface AuthState {
@ -9,9 +9,10 @@ interface AuthState {
isAuthenticated: boolean;
isLoading: boolean;
quota: Quota | null;
team: TeamInfo | null;
teamDisabled: boolean;
login: (username: string, password: string) => Promise<void>;
register: (username: string, email: string, password: string) => Promise<void>;
logout: () => void;
refreshAccessToken: () => Promise<void>;
fetchUserInfo: () => Promise<void>;
@ -25,6 +26,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
isAuthenticated: !!localStorage.getItem('access_token'),
isLoading: true,
quota: null,
team: null,
teamDisabled: false,
login: async (username, password) => {
const { data } = await authApi.login(username, password);
@ -40,19 +43,6 @@ export const useAuthStore = create<AuthState>((set, get) => ({
await get().fetchUserInfo();
},
register: async (username, email, password) => {
const { data } = await authApi.register(username, email, password);
localStorage.setItem('access_token', data.tokens.access);
localStorage.setItem('refresh_token', data.tokens.refresh);
set({
user: data.user,
accessToken: data.tokens.access,
refreshToken: data.tokens.refresh,
isAuthenticated: true,
});
await get().fetchUserInfo();
},
logout: () => {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
@ -62,6 +52,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
refreshToken: null,
isAuthenticated: false,
quota: null,
team: null,
teamDisabled: false,
});
},
@ -76,8 +68,14 @@ export const useAuthStore = create<AuthState>((set, get) => ({
fetchUserInfo: async () => {
try {
const { data } = await authApi.getMe();
const { quota, ...user } = data;
set({ user, quota, isAuthenticated: true });
const { quota, team, team_disabled, ...user } = data;
set({
user,
quota,
team: team || null,
teamDisabled: team_disabled || false,
isAuthenticated: true,
});
} catch {
// Token invalid
get().logout();

View File

@ -5,6 +5,42 @@ import { videoApi, mediaApi } from '../lib/api';
import { useAuthStore } from './auth';
import { showToast } from '../components/Toast';
// Map raw API error messages to user-friendly Chinese
function mapErrorMessage(raw?: string): string | undefined {
if (!raw) return undefined;
const s = raw.toLowerCase();
// HTTP 4xx
if (s.includes('400') || s.includes('bad request') || s.includes('invalid'))
return '请求参数有误,请检查输入内容';
if (s.includes('401') || s.includes('403') || s.includes('unauthorized') || s.includes('forbidden'))
return '服务认证失败,请联系管理员';
if (s.includes('429') || s.includes('too many') || s.includes('rate limit'))
return '请求过于频繁,请稍后再试';
// HTTP 5xx / server errors
if (s.includes('500') || s.includes('502') || s.includes('503') || s.includes('internal server') || s.includes('bad gateway') || s.includes('service unavailable'))
return '服务器繁忙,请稍后重试';
// Timeout
if (s.includes('timeout') || s.includes('timed out'))
return '请求超时,请重试';
// Connection errors
if (s.includes('connection') || s.includes('network') || s.includes('econnrefused'))
return '网络连接失败,请检查网络后重试';
// Model / generation errors
if (s.includes('quota') || s.includes('insufficient'))
return '额度不足,请联系管理员';
// If already Chinese, return as-is
if (/[\u4e00-\u9fa5]/.test(raw)) return raw;
// Fallback
return '生成失败,请重试';
}
// Map backend status to frontend TaskStatus
function mapStatus(backendStatus: string): 'generating' | 'completed' | 'failed' {
if (backendStatus === 'completed' || backendStatus === 'succeeded') return 'completed';
@ -41,88 +77,169 @@ function backendToFrontend(bt: BackendTask): GenerationTask {
status: mapStatus(bt.status),
progress: mapProgress(bt.status),
resultUrl: bt.result_url || undefined,
errorMessage: bt.error_message || undefined,
errorMessage: mapErrorMessage(bt.error_message),
createdAt: new Date(bt.created_at).getTime(),
};
}
// Active polling timers
const pollTimers = new Map<string, ReturnType<typeof setInterval>>();
const pollTimers = new Map<string, ReturnType<typeof setTimeout>>();
// Smooth progress animation — continuously ticks generating tasks forward
let smoothProgressTimer: ReturnType<typeof setInterval> | null = null;
function ensureSmoothProgress() {
if (smoothProgressTimer) return;
smoothProgressTimer = setInterval(() => {
const state = useGenerationStore.getState();
const generating = state.tasks.filter((t) => t.status === 'generating');
if (generating.length === 0) {
clearInterval(smoothProgressTimer!);
smoothProgressTimer = null;
return;
}
useGenerationStore.setState((s) => ({
tasks: s.tasks.map((t) => {
if (t.status !== 'generating') return t;
// Decelerate: fast at start, slow near end
const increment = t.progress < 30 ? 2 : t.progress < 60 ? 1 : 0.5;
return { ...t, progress: Math.min(t.progress + increment, 95) };
}),
}));
}, 2000);
}
// Progressive polling: 10s for first 2min, 30s for 2-5min, 60s after 5min
function getPollingInterval(startTime: number): number {
const elapsed = Date.now() - startTime;
if (elapsed < 2 * 60 * 1000) return 10 * 1000; // first 2 min: every 10s
if (elapsed < 5 * 60 * 1000) return 30 * 1000; // 2-5 min: every 30s
return 60 * 1000; // 5+ min: every 60s
}
function startPolling(taskId: string, frontendId: string) {
if (pollTimers.has(frontendId)) return;
const timer = setInterval(async () => {
try {
const { data } = await videoApi.getTaskStatus(taskId);
const newStatus = mapStatus(data.status);
const startTime = Date.now();
useGenerationStore.setState((s) => ({
tasks: s.tasks.map((t) =>
t.id === frontendId
? {
...t,
status: newStatus,
progress: newStatus === 'completed' ? 100 : newStatus === 'failed' ? 0 : Math.min(t.progress + 5, 90),
resultUrl: data.result_url || t.resultUrl,
errorMessage: data.error_message || t.errorMessage,
}
: t
),
}));
function schedulePoll() {
const timer = setTimeout(async () => {
try {
const { data } = await videoApi.getTaskStatus(taskId);
const newStatus = mapStatus(data.status);
if (newStatus === 'completed' || newStatus === 'failed') {
clearInterval(timer);
pollTimers.delete(frontendId);
if (newStatus === 'completed') {
useAuthStore.getState().fetchUserInfo();
useGenerationStore.setState((s) => ({
tasks: s.tasks.map((t) =>
t.id === frontendId
? {
...t,
status: newStatus,
progress: newStatus === 'completed' ? 100 : newStatus === 'failed' ? 0 : t.progress,
resultUrl: data.result_url || t.resultUrl,
errorMessage: mapErrorMessage(data.error_message) || t.errorMessage,
}
: t
),
}));
if (newStatus === 'completed' || newStatus === 'failed') {
pollTimers.delete(frontendId);
if (newStatus === 'completed') {
useAuthStore.getState().fetchUserInfo();
}
} else {
schedulePoll(); // schedule next poll with progressive interval
}
} catch {
schedulePoll(); // retry on error
}
} catch {
// Silently continue polling on error
}
}, 3 * 60 * 1000); // 3 minutes
}, getPollingInterval(startTime));
pollTimers.set(frontendId, timer);
pollTimers.set(frontendId, timer);
}
schedulePoll();
}
function stopPolling(frontendId: string) {
const timer = pollTimers.get(frontendId);
if (timer) {
clearInterval(timer);
clearTimeout(timer);
pollTimers.delete(frontendId);
}
}
const PAGE_SIZE = 20;
interface GenerationState {
tasks: GenerationTask[];
isLoading: boolean;
isLoadingMore: boolean;
hasMore: boolean;
savedScrollTop: number | null;
addTask: () => Promise<string | null>;
removeTask: (id: string) => void;
reEdit: (id: string) => void;
regenerate: (id: string) => void;
loadTasks: () => Promise<void>;
loadMore: () => Promise<void>;
saveScrollPosition: (top: number) => void;
}
export const useGenerationStore = create<GenerationState>((set, get) => ({
tasks: [],
isLoading: false,
isLoadingMore: false,
hasMore: false,
savedScrollTop: null,
loadTasks: async () => {
set({ isLoading: true });
try {
const { data } = await videoApi.getTasks();
const tasks = data.results.map(backendToFrontend);
set({ tasks, isLoading: false });
// Start polling for any active tasks
for (const task of tasks) {
if (task.status === 'generating' && task.taskId) {
startPolling(task.taskId, task.id);
}
}
let tasks: GenerationTask[] = [];
let hasMore = false;
try {
const { data } = await videoApi.getTasks({ page_size: PAGE_SIZE, offset: 0 });
tasks = data.results.map(backendToFrontend).reverse();
hasMore = data.has_more;
} catch {
set({ isLoading: false });
// API unavailable — tasks stays empty
}
set({ tasks, hasMore, isLoading: false });
// Start polling and smooth progress for any active tasks
let hasGenerating = false;
for (const task of tasks) {
if (task.status === 'generating' && task.taskId) {
startPolling(task.taskId, task.id);
hasGenerating = true;
}
}
if (hasGenerating) ensureSmoothProgress();
},
loadMore: async () => {
const { tasks, isLoadingMore, hasMore } = get();
if (isLoadingMore || !hasMore) return;
set({ isLoadingMore: true });
try {
// Backend returns newest-first; we display oldest-first (reversed).
// tasks[0] is the oldest currently loaded. We need older ones = higher offset.
// offset = total loaded from backend perspective
const currentBackendCount = tasks.filter((t) => !t.id.startsWith('temp_')).length;
const { data } = await videoApi.getTasks({ page_size: PAGE_SIZE, offset: currentBackendCount });
const olderTasks = data.results.map(backendToFrontend).reverse();
set((s) => ({
tasks: [...olderTasks, ...s.tasks],
hasMore: data.has_more,
isLoadingMore: false,
}));
} catch {
set({ isLoadingMore: false });
}
},
@ -131,15 +248,17 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
if (!input.canSubmit()) return null;
// Collect files to upload (or existing TOS URLs for regeneration)
const filesToUpload: { file?: File; tosUrl?: string; type: 'image' | 'video'; role: string; label: string }[] = [];
const filesToUpload: { file?: File; tosUrl?: string; type: 'image' | 'video' | 'audio'; role: string; label: string }[] = [];
const getRoleForType = (type: 'image' | 'video' | 'audio') =>
type === 'video' ? 'reference_video' : type === 'audio' ? 'reference_audio' : 'reference_image';
if (input.mode === 'universal') {
for (const ref of input.references) {
if (ref.tosUrl) {
// Already uploaded to TOS (regeneration)
filesToUpload.push({ tosUrl: ref.tosUrl, type: ref.type, role: ref.type === 'video' ? 'reference_video' : 'reference_image', label: ref.label });
filesToUpload.push({ tosUrl: ref.tosUrl, type: ref.type, role: getRoleForType(ref.type), label: ref.label });
} else if (ref.file) {
filesToUpload.push({ file: ref.file, type: ref.type, role: ref.type === 'video' ? 'reference_video' : 'reference_image', label: ref.label });
filesToUpload.push({ file: ref.file, type: ref.type, role: getRoleForType(ref.type), label: ref.label });
}
}
} else {
@ -192,11 +311,14 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
duration: input.duration,
references: localRefs,
status: 'generating',
progress: 5,
progress: 0,
createdAt: Date.now(),
};
set((s) => ({ tasks: [placeholderTask, ...s.tasks] }));
set((s) => ({ tasks: [...s.tasks, placeholderTask] }));
// Start smooth progress animation
ensureSmoothProgress();
// Clear input
useInputBarStore.setState({
@ -212,12 +334,6 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
const uploadedRefs: { url: string; type: string; role: string; label: string }[] = [];
for (const item of filesToUpload) {
set((s) => ({
tasks: s.tasks.map((t) =>
t.id === tempId ? { ...t, progress: Math.min(t.progress + 10, 40) } : t
),
}));
if (item.tosUrl) {
uploadedRefs.push({ url: item.tosUrl, type: item.type, role: item.role, label: item.label });
} else if (item.file) {
@ -226,13 +342,6 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
}
}
// Update progress: files uploaded
set((s) => ({
tasks: s.tasks.map((t) =>
t.id === tempId ? { ...t, progress: 50 } : t
),
}));
// Call generate API
const { data: genResult } = await videoApi.generate({
prompt: input.prompt,
@ -254,7 +363,7 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
id: frontendId,
taskId: genResult.task_id,
status: taskStatus as GenerationTask['status'],
progress: taskStatus === 'completed' ? 100 : 60,
progress: taskStatus === 'completed' ? 100 : t.progress,
}
: t
),
@ -301,7 +410,11 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
removeTask: (id) => {
stopPolling(id);
const task = get().tasks.find((t) => t.id === id);
set((s) => ({ tasks: s.tasks.filter((t) => t.id !== id) }));
if (task?.taskId) {
videoApi.deleteTask(task.taskId).catch(() => {});
}
},
reEdit: (id) => {
@ -377,4 +490,8 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
// Trigger generation
get().addTask();
},
saveScrollPosition: (top: number) => {
set({ savedScrollTop: top });
},
}));

View File

@ -1,8 +1,14 @@
import { create } from 'zustand';
import type { CreationMode, ModelOption, AspectRatio, Duration, GenerationType, UploadedFile } from '../types';
import { showToast } from '../components/Toast';
let fileCounter = 0;
// API limits per Seedance 2.0 official doc
const MAX_IMAGES = 9;
const MAX_VIDEOS = 3;
const MAX_AUDIO = 3;
interface InputBarState {
// Generation type
generationType: GenerationType;
@ -36,6 +42,7 @@ interface InputBarState {
// Universal references
references: UploadedFile[];
prevReferences: UploadedFile[];
addReferences: (files: File[]) => void;
removeReference: (id: string) => void;
clearReferences: () => void;
@ -91,24 +98,42 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
setEditorHtml: (editorHtml) => set({ editorHtml }),
references: [],
prevReferences: [],
addReferences: (files) => {
const state = get();
const remaining = 5 - state.references.length;
if (remaining <= 0) return;
const toAdd = files.slice(0, remaining);
const newRefs: UploadedFile[] = toAdd.map((file) => {
// Count existing references by type
const counts = { image: 0, video: 0, audio: 0 };
for (const ref of state.references) counts[ref.type]++;
const newRefs: UploadedFile[] = [];
for (const file of files) {
const type: 'image' | 'video' | 'audio' = file.type.startsWith('video/')
? 'video'
: file.type.startsWith('audio/')
? 'audio'
: 'image';
const max = type === 'image' ? MAX_IMAGES : type === 'video' ? MAX_VIDEOS : MAX_AUDIO;
if (counts[type] >= max) {
const label = type === 'image' ? `最多上传${MAX_IMAGES}张图片` : type === 'video' ? `最多上传${MAX_VIDEOS}个视频` : `最多上传${MAX_AUDIO}个音频`;
showToast(label);
continue;
}
fileCounter++;
const type = file.type.startsWith('video') ? 'video' as const : 'image' as const;
const labelPrefix = type === 'video' ? '视频' : '图片';
return {
const labelPrefix = type === 'video' ? '视频' : type === 'audio' ? '音频' : '图片';
newRefs.push({
id: `ref_${fileCounter}`,
file,
type,
previewUrl: URL.createObjectURL(file),
previewUrl: type === 'audio' ? '' : URL.createObjectURL(file),
label: `${labelPrefix}${fileCounter}`,
};
});
set({ references: [...state.references, ...newRefs] });
});
counts[type]++;
}
if (newRefs.length > 0) {
set({ references: [...state.references, ...newRefs] });
}
},
removeReference: (id) => {
const state = get();
@ -168,7 +193,13 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
state.mode === 'universal'
? state.references.length > 0
: state.firstFrame !== null || state.lastFrame !== null;
return hasText || hasFiles;
if (!hasText && !hasFiles) return false;
// Audio cannot be sent alone — must have image or video
if (state.mode === 'universal' && state.references.length > 0) {
const hasImageOrVideo = state.references.some((r) => r.type === 'image' || r.type === 'video');
if (!hasImageOrVideo && !hasText) return false;
}
return true;
},
insertAtTrigger: 0,
@ -179,11 +210,11 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
if (state.mode === mode) return;
if (mode === 'keyframe') {
// Clear universal references
state.references.forEach((r) => URL.revokeObjectURL(r.previewUrl));
set({
mode,
prevReferences: state.references,
references: [],
aspectRatio: 'adaptive',
duration: 5,
});
} else {
@ -194,6 +225,8 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
mode,
firstFrame: null,
lastFrame: null,
references: state.prevReferences,
prevReferences: [],
aspectRatio: state.prevAspectRatio,
duration: state.prevDuration,
});
@ -207,6 +240,7 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
reset: () => {
const state = get();
state.references.forEach((r) => URL.revokeObjectURL(r.previewUrl));
state.prevReferences.forEach((r) => URL.revokeObjectURL(r.previewUrl));
if (state.firstFrame) URL.revokeObjectURL(state.firstFrame.previewUrl);
if (state.lastFrame) URL.revokeObjectURL(state.lastFrame.previewUrl);
set({
@ -219,6 +253,7 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
prompt: '',
editorHtml: '',
references: [],
prevReferences: [],
firstFrame: null,
lastFrame: null,
generationType: 'video',

View File

@ -1,13 +1,14 @@
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';
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 GenerationType = 'video' | 'image';
export type UserRole = 'super_admin' | 'team_admin' | 'member';
export interface UploadedFile {
id: string;
file?: File;
type: 'image' | 'video';
type: 'image' | 'video' | 'audio';
previewUrl: string;
label: string;
tosUrl?: string; // TOS URL after upload
@ -24,7 +25,7 @@ export type TaskStatus = 'generating' | 'completed' | 'failed';
export interface ReferenceSnapshot {
id: string;
type: 'image' | 'video';
type: 'image' | 'video' | 'audio';
previewUrl: string;
label: string;
role?: string;
@ -70,6 +71,20 @@ export interface User {
username: string;
email: string;
is_staff: boolean;
is_team_admin: boolean;
role: UserRole;
team_name: string | null;
}
export interface TeamInfo {
id: number;
name: string;
total_seconds_pool: number;
total_seconds_used: number;
remaining_seconds: number;
monthly_seconds_limit: number;
monthly_seconds_used: number;
is_active: boolean;
}
// Phase 3: seconds-based quota
@ -85,9 +100,10 @@ export interface AuthTokens {
refresh: string;
}
// Phase 3: Admin types
// Admin types
export interface AdminStats {
total_users: number;
total_teams: number;
new_users_today: number;
seconds_consumed_today: number;
seconds_consumed_this_month: number;
@ -95,6 +111,7 @@ export interface AdminStats {
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 }[];
}
export interface AdminUser {
@ -102,6 +119,10 @@ export interface AdminUser {
username: string;
email: string;
is_active: boolean;
is_staff: boolean;
is_team_admin: boolean;
team_id: number | null;
team_name: string | null;
date_joined: string;
daily_seconds_limit: number;
monthly_seconds_limit: number;
@ -110,7 +131,6 @@ export interface AdminUser {
}
export interface AdminUserDetail extends AdminUser {
is_staff: boolean;
seconds_total: number;
recent_records: AdminRecord[];
}
@ -120,12 +140,14 @@ export interface AdminRecord {
created_at: string;
user_id?: number;
username?: string;
team_name?: string;
seconds_consumed: number;
prompt: string;
mode: CreationMode;
model: ModelOption;
aspect_ratio?: string;
status: 'queued' | 'processing' | 'completed' | 'failed';
error_message?: string;
}
export interface SystemSettings {
@ -142,6 +164,14 @@ export interface ProfileOverview {
monthly_seconds_used: number;
total_seconds_used: number;
daily_trend: { date: string; seconds: number }[];
team?: {
name: string;
total_seconds_pool: number;
total_seconds_used: number;
remaining_seconds: number;
monthly_seconds_limit: number;
monthly_seconds_used: number;
};
}
export interface PaginatedResponse<T> {
@ -150,3 +180,54 @@ export interface PaginatedResponse<T> {
page_size: number;
results: T[];
}
// Team management types
export interface Team {
id: number;
name: string;
total_seconds_pool: number;
total_seconds_used: number;
remaining_seconds: number;
monthly_seconds_limit: number;
monthly_seconds_used: number;
daily_member_limit_default: number;
member_count: number;
is_active: boolean;
created_at: string;
}
export interface TeamDetail extends Team {
members: TeamMember[];
}
export interface TeamMember {
id: number;
username: string;
email: string;
is_team_admin: boolean;
is_active: boolean;
daily_seconds_limit: number;
monthly_seconds_limit: number;
seconds_today: number;
seconds_this_month: number;
date_joined: string;
}
export interface TeamStats {
daily_trend: { date: string; seconds: number }[];
member_consumption: { user_id: number; username: string; seconds_consumed: number }[];
}
export interface AuditLog {
id: number;
operator_name: string;
action: string;
action_display: string;
target_type: string;
target_id: number | null;
target_name: string;
before: Record<string, unknown> | null;
after: Record<string, unknown> | null;
ip_address: string | null;
created_at: string;
}