Compare commits
8 Commits
c1f29cbf85
...
6053c9b987
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6053c9b987 | ||
|
|
d9a12af078 | ||
|
|
4c0605e589 | ||
|
|
85f76d8543 | ||
|
|
f803a1ba71 | ||
|
|
32f0ee58f4 | ||
|
|
add3af7904 | ||
|
|
f8358a28c6 |
@ -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
|
||||
|
||||
26
CLAUDE.md
26
CLAUDE.md
@ -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)
|
||||
|
||||
|
||||
@ -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')}),
|
||||
)
|
||||
|
||||
@ -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='所属团队'),
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
]
|
||||
36
backend/apps/accounts/migrations/0005_adminauditlog.py
Normal file
36
backend/apps/accounts/migrations/0005_adminauditlog.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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,
|
||||
)
|
||||
|
||||
45
backend/apps/accounts/permissions.py
Normal file
45
backend/apps/accounts/permissions.py
Normal 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
|
||||
)
|
||||
@ -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):
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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', '排队中'),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
@ -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
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 返回的临时 URL(24h 有效)自动下载并上传到 TOS,生成永久 CDN 链接;失败时降级使用临时 URL
|
||||
3. **移除硬编码密钥** — ARK_API_KEY 默认值从测试 key 改为空字符串,避免生产环境误用
|
||||
4. **渐进式轮询** — 前端轮询从固定 3 分钟改为渐进式:前 2 分钟每 10s → 2-5 分钟每 30s → 5 分钟后每 60s
|
||||
5. **TOS 新桶配置** — 切换到独立桶 `airdrama-media`(cn-beijing),TOS 配置默认值更新
|
||||
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 — 异常上报体系优化 + 轮询策略调整
|
||||
|
||||
**状态**: ✅ 已完成 | **验收**: ✅ 通过
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
52
web/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
BIN
web/public/demo/demo-16-9.mp4
Normal file
BIN
web/public/demo/demo-16-9.mp4
Normal file
Binary file not shown.
BIN
web/public/demo/demo-21-9.mp4
Normal file
BIN
web/public/demo/demo-21-9.mp4
Normal file
Binary file not shown.
BIN
web/public/demo/demo-9-16.mp4
Normal file
BIN
web/public/demo/demo-9-16.mp4
Normal file
Binary file not shown.
@ -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>
|
||||
|
||||
48
web/src/components/AmbientBackground.tsx
Normal file
48
web/src/components/AmbientBackground.tsx
Normal 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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
65
web/src/components/AnnouncementBanner.module.css
Normal file
65
web/src/components/AnnouncementBanner.module.css
Normal 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);
|
||||
}
|
||||
34
web/src/components/AnnouncementBanner.tsx
Normal file
34
web/src/components/AnnouncementBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
web/src/components/ConfirmModal.module.css
Normal file
8
web/src/components/ConfirmModal.module.css
Normal 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); }
|
||||
28
web/src/components/ConfirmModal.tsx
Normal file
28
web/src/components/ConfirmModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
146
web/src/components/DatePicker.module.css
Normal file
146
web/src/components/DatePicker.module.css
Normal 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;
|
||||
}
|
||||
147
web/src/components/DatePicker.tsx
Normal file
147
web/src/components/DatePicker.tsx
Normal 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}><</button>
|
||||
<span className={styles.monthLabel}>{viewYear}年 {monthNames[viewMonth]}</span>
|
||||
<button type="button" className={styles.navBtn} onClick={nextMonth}>></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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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}>
|
||||
{/* 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>
|
||||
</div>
|
||||
) : task.status === 'failed' ? (
|
||||
<div className={styles.resultPlaceholder}>
|
||||
<span style={{ color: '#e74c3c' }}>{task.errorMessage || '生成失败'}</span>
|
||||
</div>
|
||||
<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>
|
||||
<RefreshIcon /> <span>再次生成</span>
|
||||
</button>
|
||||
<button className={`${styles.actionBtn} ${styles.deleteBtn}`} onClick={() => removeTask(task.id)}>
|
||||
<TrashIcon />
|
||||
<span>删除</span>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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') {
|
||||
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);
|
||||
|
||||
@ -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}</>;
|
||||
}
|
||||
|
||||
116
web/src/components/Select.module.css
Normal file
116
web/src/components/Select.module.css
Normal 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;
|
||||
}
|
||||
81
web/src/components/Select.tsx
Normal file
81
web/src/components/Select.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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) => (
|
||||
{/* Nav items */}
|
||||
<nav className={styles.navItems}>
|
||||
{/* Video generation - team members and team admins only */}
|
||||
{role !== 'super_admin' && (
|
||||
<>
|
||||
<div
|
||||
key={item.name}
|
||||
className={`${styles.navItem} ${item.active ? styles.active : ''}`}
|
||||
title={item.name}
|
||||
className={`${styles.navItem} ${isActive('/') ? styles.active : ''}`}
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</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 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 style={{ fontSize: 10 }}>API</span>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(-20px);
|
||||
background: var(--color-bg-dropdown);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 */}
|
||||
{/* Model selector */}
|
||||
<Dropdown
|
||||
items={modelItems}
|
||||
value={model}
|
||||
onSelect={(v) => setModel(v as ModelOption)}
|
||||
minWidth={160}
|
||||
trigger={
|
||||
<button className={styles.btn}>
|
||||
<DiamondIcon />
|
||||
<span className={styles.label}>Seedance 2.0</span>
|
||||
{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}
|
||||
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}</span>
|
||||
<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}`}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,10 +97,11 @@ export function UniversalUpload() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thumbnails row - single container, animate via CSS transitions */}
|
||||
{/* Thumbnails — thumbRow always absolute, hover to expand */}
|
||||
{hasFiles && (
|
||||
<>
|
||||
<div
|
||||
className={styles.thumbRow}
|
||||
className={`${styles.thumbRow} ${expanded ? styles.thumbRowExpanded : ''}`}
|
||||
onMouseEnter={() => setExpanded(true)}
|
||||
onMouseLeave={() => setExpanded(false)}
|
||||
>
|
||||
@ -81,15 +110,17 @@ export function UniversalUpload() {
|
||||
key={ref.id}
|
||||
className={`${styles.thumbItem} ${expanded ? styles.itemExpanded : ''}`}
|
||||
style={{
|
||||
marginLeft: i === 0 ? 0 : (expanded ? 8 : -64),
|
||||
marginLeft: i === 0 ? 0 : (expanded ? 8 : -48),
|
||||
zIndex: expanded ? 1 : count - i,
|
||||
transform: expanded
|
||||
? 'rotate(0deg) translateY(0px)'
|
||||
: `rotate(${i * -2.5}deg) translateY(${i * -2}px)`,
|
||||
}}
|
||||
>
|
||||
<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} />
|
||||
)}
|
||||
@ -104,30 +135,43 @@ export function UniversalUpload() {
|
||||
</div>
|
||||
<div className={styles.thumbLabel}>{ref.label}</div>
|
||||
</div>
|
||||
<div className={styles.thumbTooltip}>{ref.label}</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add more button */}
|
||||
{references.length < 5 && (
|
||||
{/* Add more button (expanded state only) */}
|
||||
{expanded && !allFull && (
|
||||
<div
|
||||
className={`${styles.addMore} ${expanded ? styles.addMoreVisible : styles.addMoreHidden}`}
|
||||
style={{
|
||||
marginLeft: expanded ? 8 : -64,
|
||||
}}
|
||||
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>
|
||||
|
||||
{/* Count badge when collapsed */}
|
||||
{!expanded && count > 1 && (
|
||||
<div className={styles.countBadge}>{count}</div>
|
||||
{/* "+" badge — outside thumbRow, position based on stack width */}
|
||||
{!expanded && !allFull && (
|
||||
<div
|
||||
className={styles.countBadge}
|
||||
style={{ left: stackWidth - 14 }}
|
||||
onClick={(e) => { e.stopPropagation(); handleTrigger(); }}
|
||||
onMouseEnter={() => setBadgeHover(true)}
|
||||
onMouseLeave={() => setBadgeHover(false)}
|
||||
>
|
||||
+
|
||||
{badgeHover && (
|
||||
<div className={styles.badgeTooltip}>上传参考内容</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
499
web/src/components/VideoDetailModal.module.css
Normal file
499
web/src/components/VideoDetailModal.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
504
web/src/components/VideoDetailModal.tsx
Normal file
504
web/src/components/VideoDetailModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 && (
|
||||
|
||||
167
web/src/pages/AssetsPage.module.css
Normal file
167
web/src/pages/AssetsPage.module.css
Normal 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;
|
||||
}
|
||||
195
web/src/pages/AssetsPage.tsx
Normal file
195
web/src/pages/AssetsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
web/src/pages/AuditLogsPage.module.css
Normal file
54
web/src/pages/AuditLogsPage.module.css
Normal 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; }
|
||||
199
web/src/pages/AuditLogsPage.tsx
Normal file
199
web/src/pages/AuditLogsPage.tsx
Normal 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}>→</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)}><</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)}>></button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -5,6 +5,8 @@
|
||||
justify-content: center;
|
||||
background: var(--color-bg-page);
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.card {
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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>
|
||||
))
|
||||
|
||||
@ -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 />;
|
||||
}
|
||||
|
||||
85
web/src/pages/TeamAdminLayout.tsx
Normal file
85
web/src/pages/TeamAdminLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
web/src/pages/TeamDashboardPage.tsx
Normal file
171
web/src/pages/TeamDashboardPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
241
web/src/pages/TeamMembersPage.tsx
Normal file
241
web/src/pages/TeamMembersPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
266
web/src/pages/TeamsPage.module.css
Normal file
266
web/src/pages/TeamsPage.module.css
Normal 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
462
web/src/pages/TeamsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,18 +77,53 @@ 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 () => {
|
||||
const startTime = Date.now();
|
||||
|
||||
function schedulePoll() {
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const { data } = await videoApi.getTaskStatus(taskId);
|
||||
const newStatus = mapStatus(data.status);
|
||||
@ -63,66 +134,112 @@ function startPolling(taskId: string, frontendId: string) {
|
||||
? {
|
||||
...t,
|
||||
status: newStatus,
|
||||
progress: newStatus === 'completed' ? 100 : newStatus === 'failed' ? 0 : Math.min(t.progress + 5, 90),
|
||||
progress: newStatus === 'completed' ? 100 : newStatus === 'failed' ? 0 : t.progress,
|
||||
resultUrl: data.result_url || t.resultUrl,
|
||||
errorMessage: data.error_message || t.errorMessage,
|
||||
errorMessage: mapErrorMessage(data.error_message) || t.errorMessage,
|
||||
}
|
||||
: t
|
||||
),
|
||||
}));
|
||||
|
||||
if (newStatus === 'completed' || newStatus === 'failed') {
|
||||
clearInterval(timer);
|
||||
pollTimers.delete(frontendId);
|
||||
if (newStatus === 'completed') {
|
||||
useAuthStore.getState().fetchUserInfo();
|
||||
}
|
||||
} else {
|
||||
schedulePoll(); // schedule next poll with progressive interval
|
||||
}
|
||||
} catch {
|
||||
// Silently continue polling on error
|
||||
schedulePoll(); // retry on error
|
||||
}
|
||||
}, 3 * 60 * 1000); // 3 minutes
|
||||
}, getPollingInterval(startTime));
|
||||
|
||||
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
|
||||
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 {
|
||||
// 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({ isLoading: false });
|
||||
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 });
|
||||
},
|
||||
}));
|
||||
|
||||
@ -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}`,
|
||||
};
|
||||
});
|
||||
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',
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user