feat: v0.8.2~v0.8.4 — 管理后台 UI 修复 + 团队详情重构 + 审计日志系统

v0.8.2: DatePicker/Select 暗色主题、公告跑马灯、Toast 全局化、失败原因 tooltip
v0.8.3: 团队详情抽屉→弹窗重构 + 修改秒数池功能 + member_count 修复
v0.8.4: AdminAuditLog 模型 + 12 处管理操作埋点 + 日志查询页面(/admin/logs)

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-03-16 01:18:44 +08:00
parent f803a1ba71
commit 85f76d8543
35 changed files with 1950 additions and 161 deletions

View File

@ -145,6 +145,13 @@ jimeng-clone/
| PUT | `/api/v1/admin/users/<id>/status` | Toggle user active status | | PUT | `/api/v1/admin/users/<id>/status` | Toggle user active status |
| GET | `/api/v1/admin/records` | List all generation records | | GET | `/api/v1/admin/records` | List all generation records |
| GET/PUT | `/api/v1/admin/settings` | Get/update global settings (QuotaConfig) | | 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/`) ### Profile (`/api/v1/profile/`)
| Method | Endpoint | Description | | Method | Endpoint | Description |
@ -164,6 +171,11 @@ jimeng-clone/
- `status` (queued|processing|completed|failed), `result_url`, `error_message`, `reference_urls` (JSON) - `status` (queued|processing|completed|failed), `result_url`, `error_message`, `reference_urls` (JSON)
- Index: (user, created_at) - 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) ### QuotaConfig (Singleton, pk=1)
- `default_daily_seconds_limit`, `default_monthly_seconds_limit` - `default_daily_seconds_limit`, `default_monthly_seconds_limit`
- `announcement`, `announcement_enabled`, `updated_at` - `announcement`, `announcement_enabled`, `updated_at`
@ -179,6 +191,7 @@ jimeng-clone/
| `/admin/users` | UsersPage | Admin | User management | | `/admin/users` | UsersPage | Admin | User management |
| `/admin/records` | RecordsPage | Admin | Generation records | | `/admin/records` | RecordsPage | Admin | Generation records |
| `/admin/settings` | SettingsPage | Admin | Global quota & announcement | | `/admin/settings` | SettingsPage | Admin | Global quota & announcement |
| `/admin/logs` | AuditLogsPage | Admin | Admin operation audit logs |
## Incremental Development Guide ## Incremental Development Guide
@ -365,6 +378,9 @@ npx tsx src/index.ts --resume /Users/maidong/Desktop/zyc/研究openclaw/视频
| 2026-03-15 | v0.8.0: 音频引用支持 + 视频 TOS 持久化 + 移除硬编码密钥 + 渐进式轮询 | Full stack | | 2026-03-15 | v0.8.0: 音频引用支持 + 视频 TOS 持久化 + 移除硬编码密钥 + 渐进式轮询 | Full stack |
| 2026-03-15 | TOS 桶切换到 airdrama-media (cn-beijing)K8s Secret 注入 TOS 密钥 | Infra | | 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-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) ### Phase 4 Details (2026-03-13)

View File

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

View File

@ -54,3 +54,60 @@ class User(AbstractUser):
if self.is_team_admin and self.team is not None: if self.is_team_admin and self.team is not None:
return 'team_admin' return 'team_admin'
return 'member' return 'member'
class AdminAuditLog(models.Model):
"""管理员操作审计日志"""
ACTION_CHOICES = [
('team_create', '创建团队'),
('team_update', '更新团队'),
('team_topup', '团队充值'),
('team_set_pool', '设置团队额度池'),
('team_create_admin', '创建团队管理员'),
('user_create', '创建用户'),
('user_quota_update', '更新用户额度'),
('user_status_toggle', '切换用户状态'),
('settings_update', '更新系统设置'),
('member_create', '创建团队成员'),
('member_quota_update', '更新成员额度'),
('member_status_toggle', '切换成员状态'),
]
operator = models.ForeignKey(
User, on_delete=models.SET_NULL,
null=True, related_name='audit_logs',
verbose_name='操作人',
)
operator_name = models.CharField(max_length=150, verbose_name='操作人用户名')
action = models.CharField(max_length=30, choices=ACTION_CHOICES, verbose_name='操作类型')
target_type = models.CharField(max_length=20, verbose_name='目标类型')
target_id = models.IntegerField(null=True, blank=True, verbose_name='目标ID')
target_name = models.CharField(max_length=200, blank=True, default='', verbose_name='目标名称')
before = models.JSONField(null=True, blank=True, verbose_name='变更前')
after = models.JSONField(null=True, blank=True, verbose_name='变更后')
ip_address = models.GenericIPAddressField(null=True, blank=True, verbose_name='IP地址')
created_at = models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='操作时间')
class Meta:
verbose_name = '审计日志'
verbose_name_plural = '审计日志'
ordering = ['-created_at']
def __str__(self):
return f'{self.operator_name} - {self.get_action_display()} - {self.target_name}'
def log_admin_action(request, action, target_type, target_id=None, target_name='', before=None, after=None):
"""记录管理员操作日志"""
ip = request.META.get('HTTP_X_FORWARDED_FOR', '').split(',')[0].strip() or request.META.get('REMOTE_ADDR')
AdminAuditLog.objects.create(
operator=request.user,
operator_name=request.user.username,
action=action,
target_type=target_type,
target_id=target_id,
target_name=target_name,
before=before,
after=after,
ip_address=ip,
)

View File

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

View File

@ -8,6 +8,8 @@ urlpatterns = [
path('video/generate', views.video_generate_view, name='video_generate'), 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', views.video_tasks_list_view, name='video_tasks_list'),
path('video/tasks/<uuid:task_id>', views.video_task_detail_view, name='video_task_detail'), path('video/tasks/<uuid:task_id>', views.video_task_detail_view, name='video_task_detail'),
# Public announcement
path('announcement', views.announcement_view, name='announcement'),
# ── Super Admin: Dashboard ── # ── Super Admin: Dashboard ──
path('admin/stats', views.admin_stats_view, name='admin_stats'), path('admin/stats', views.admin_stats_view, name='admin_stats'),
@ -17,6 +19,7 @@ urlpatterns = [
path('admin/teams/create', views.admin_team_create_view, name='admin_team_create'), 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>', 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>/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'), path('admin/teams/<int:team_id>/admin', views.admin_team_create_admin_view, name='admin_team_create_admin'),
# ── Super Admin: User management ── # ── Super Admin: User management ──
@ -26,9 +29,10 @@ urlpatterns = [
path('admin/users/<int:user_id>/quota', views.admin_user_quota_view, name='admin_user_quota'), 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'), path('admin/users/<int:user_id>/status', views.admin_user_status_view, name='admin_user_status'),
# ── Super Admin: Records & Settings ── # ── Super Admin: Records, Settings & Audit Logs ──
path('admin/records', views.admin_records_view, name='admin_records'), path('admin/records', views.admin_records_view, name='admin_records'),
path('admin/settings', views.admin_settings_view, name='admin_settings'), path('admin/settings', views.admin_settings_view, name='admin_settings'),
path('admin/logs', views.admin_audit_logs_view, name='admin_audit_logs'),
# ── Team Admin: Team management ── # ── Team Admin: Team management ──
path('team/info', views.team_info_view, name='team_info'), path('team/info', views.team_info_view, name='team_info'),

View File

@ -21,10 +21,10 @@ from .serializers import (
TeamCreateSerializer, TeamUpdateSerializer, TeamTopUpSerializer, TeamCreateSerializer, TeamUpdateSerializer, TeamTopUpSerializer,
TeamAdminCreateSerializer, TeamMemberCreateSerializer, MemberQuotaSerializer, TeamAdminCreateSerializer, TeamMemberCreateSerializer, MemberQuotaSerializer,
) )
from apps.accounts.models import Team from apps.accounts.models import Team, AdminAuditLog, log_admin_action
from apps.accounts.permissions import IsSuperAdmin, IsTeamAdmin, IsTeamMember from apps.accounts.permissions import IsSuperAdmin, IsTeamAdmin, IsTeamMember
from utils.tos_client import upload_file as tos_upload from utils.tos_client import upload_file as tos_upload
from utils.seedance_client import create_task, query_task, extract_video_url, map_status from utils.airdrama_client import create_task, query_task, extract_video_url, map_status
User = get_user_model() User = get_user_model()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -129,7 +129,7 @@ def upload_media_view(request):
@api_view(['POST']) @api_view(['POST'])
@permission_classes([IsTeamMember]) @permission_classes([IsTeamMember])
def video_generate_view(request): def video_generate_view(request):
"""POST /api/v1/video/generate — Four-layer quota check + Seedance API.""" """POST /api/v1/video/generate — Four-layer quota check + AirDrama API."""
serializer = VideoGenerateSerializer(data=request.data) serializer = VideoGenerateSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
@ -255,7 +255,7 @@ def video_generate_view(request):
locked_team.total_seconds_used = F('total_seconds_used') + duration locked_team.total_seconds_used = F('total_seconds_used') + duration
locked_team.save(update_fields=['total_seconds_used']) locked_team.save(update_fields=['total_seconds_used'])
# ── Call Seedance API (outside transaction to avoid holding lock) ── # ── Call AirDrama API (outside transaction to avoid holding lock) ──
from django.conf import settings as django_settings from django.conf import settings as django_settings
if django_settings.SEEDANCE_ENABLED and django_settings.ARK_API_KEY: if django_settings.SEEDANCE_ENABLED and django_settings.ARK_API_KEY:
try: try:
@ -271,10 +271,10 @@ def video_generate_view(request):
record.status = 'processing' record.status = 'processing'
record.save(update_fields=['ark_task_id', 'status']) record.save(update_fields=['ark_task_id', 'status'])
except Exception as e: except Exception as e:
logger.exception('Seedance API create task failed') logger.exception('AirDrama API create task failed')
record.status = 'failed' record.status = 'failed'
from utils.seedance_client import SeedanceAPIError from utils.airdrama_client import AirDramaAPIError
if isinstance(e, SeedanceAPIError): if isinstance(e, AirDramaAPIError):
record.error_message = e.user_message record.error_message = e.user_message
else: else:
record.error_message = str(e) record.error_message = str(e)
@ -316,15 +316,28 @@ def _refund_quota(record, seconds):
@api_view(['GET']) @api_view(['GET'])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
def video_tasks_list_view(request): def video_tasks_list_view(request):
"""GET /api/v1/video/tasks — User's recent generation tasks.""" """GET /api/v1/video/tasks — User's recent generation tasks (paginated).
Query params:
page_size: Number of tasks per page (default 20, max 100).
offset: Number of tasks to skip (default 0).
"""
user = request.user user = request.user
page_size = min(int(request.query_params.get('page_size', 50)), 100) page_size = min(int(request.query_params.get('page_size', 20)), 100)
offset = max(int(request.query_params.get('offset', 0)), 0)
qs = user.generation_records.order_by('-created_at') qs = user.generation_records.order_by('-created_at')
records = _eval_qs(qs, limit=page_size) total = qs.count()
records = _eval_qs(qs, limit=offset + page_size)
# Apply offset after evaluation (defer compat)
records = records[offset:]
results = [_serialize_task(r) for r in records] results = [_serialize_task(r) for r in records]
return Response({'results': results}) return Response({
'results': results,
'total': total,
'has_more': offset + page_size < total,
})
@api_view(['GET', 'DELETE']) @api_view(['GET', 'DELETE'])
@ -344,7 +357,7 @@ def video_task_detail_view(request, task_id):
record.delete() record.delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
# If task is still active, poll Seedance API for latest status # If task is still active, poll AirDrama API for latest status
ark_task_id = record.__dict__.get('ark_task_id', '') ark_task_id = record.__dict__.get('ark_task_id', '')
if record.status in ('queued', 'processing') and ark_task_id: if record.status in ('queued', 'processing') and ark_task_id:
try: try:
@ -375,7 +388,7 @@ def video_task_detail_view(request, task_id):
record.save(update_fields=['status', 'result_url', 'error_message']) record.save(update_fields=['status', 'result_url', 'error_message'])
except Exception as e: except Exception as e:
logger.exception('Seedance API query failed for %s', ark_task_id) logger.exception('AirDrama API query failed for %s', ark_task_id)
return Response(_serialize_task(record)) return Response(_serialize_task(record))
@ -472,6 +485,18 @@ def admin_stats_view(request):
.order_by('-seconds_consumed')[:10] .order_by('-seconds_consumed')[:10]
) )
# Team consumption ranking this month
top_teams = (
Team.objects.annotate(
seconds_consumed=Sum(
'members__generation_records__seconds_consumed',
filter=Q(members__generation_records__created_at__date__gte=first_of_month),
)
)
.filter(seconds_consumed__gt=0)
.order_by('-seconds_consumed')
)
return Response({ return Response({
'total_users': total_users, 'total_users': total_users,
'total_teams': total_teams, 'total_teams': total_teams,
@ -485,6 +510,10 @@ def admin_stats_view(request):
{'user_id': u.id, 'username': u.username, 'seconds_consumed': u.seconds_consumed or 0} {'user_id': u.id, 'username': u.username, 'seconds_consumed': u.seconds_consumed or 0}
for u in top_users for u in top_users
], ],
'top_teams': [
{'team_id': t.id, 'name': t.name, 'seconds_consumed': t.seconds_consumed or 0}
for t in top_teams
],
}) })
@ -536,6 +565,9 @@ def admin_team_create_view(request):
return Response({'error': '团队名称已存在'}, status=status.HTTP_400_BAD_REQUEST) return Response({'error': '团队名称已存在'}, status=status.HTTP_400_BAD_REQUEST)
team = Team.objects.create(**serializer.validated_data) team = Team.objects.create(**serializer.validated_data)
log_admin_action(request, 'team_create', 'team', target_id=team.id, target_name=team.name,
after={'name': team.name, 'monthly_seconds_limit': team.monthly_seconds_limit,
'daily_member_limit_default': team.daily_member_limit_default})
return Response({ return Response({
'id': team.id, 'id': team.id,
'name': team.name, 'name': team.name,
@ -557,9 +589,13 @@ def admin_team_detail_view(request, team_id):
if request.method == 'PUT': if request.method == 'PUT':
serializer = TeamUpdateSerializer(data=request.data) serializer = TeamUpdateSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
before = {f: getattr(team, f) for f in serializer.validated_data}
for field, value in serializer.validated_data.items(): for field, value in serializer.validated_data.items():
setattr(team, field, value) setattr(team, field, value)
team.save() team.save()
after = {f: getattr(team, f) for f in serializer.validated_data}
log_admin_action(request, 'team_update', 'team', target_id=team.id, target_name=team.name,
before=before, after=after)
return Response({ return Response({
'id': team.id, 'id': team.id,
'name': team.name, 'name': team.name,
@ -598,6 +634,7 @@ def admin_team_detail_view(request, team_id):
'monthly_seconds_limit': team.monthly_seconds_limit, 'monthly_seconds_limit': team.monthly_seconds_limit,
'monthly_seconds_used': monthly_used, 'monthly_seconds_used': monthly_used,
'daily_member_limit_default': team.daily_member_limit_default, 'daily_member_limit_default': team.daily_member_limit_default,
'member_count': team.members.count(),
'is_active': team.is_active, 'is_active': team.is_active,
'created_at': team.created_at.isoformat(), 'created_at': team.created_at.isoformat(),
'members': [{ 'members': [{
@ -628,12 +665,16 @@ def admin_team_topup_view(request, team_id):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
seconds = serializer.validated_data['seconds'] seconds = serializer.validated_data['seconds']
old_pool = team.total_seconds_pool
with transaction.atomic(): with transaction.atomic():
locked = Team.objects.select_for_update().get(pk=team.pk) locked = Team.objects.select_for_update().get(pk=team.pk)
locked.total_seconds_pool = F('total_seconds_pool') + seconds locked.total_seconds_pool = F('total_seconds_pool') + seconds
locked.save(update_fields=['total_seconds_pool']) locked.save(update_fields=['total_seconds_pool'])
team.refresh_from_db() team.refresh_from_db()
log_admin_action(request, 'team_topup', 'team', target_id=team.id, target_name=team.name,
before={'total_seconds_pool': old_pool},
after={'total_seconds_pool': team.total_seconds_pool, 'topped_up': seconds})
return Response({ return Response({
'id': team.id, 'id': team.id,
'name': team.name, 'name': team.name,
@ -644,6 +685,49 @@ def admin_team_topup_view(request, team_id):
}) })
@api_view(['PUT'])
@permission_classes([IsSuperAdmin])
def admin_team_set_pool_view(request, team_id):
"""PUT /api/v1/admin/teams/<id>/set-pool — Directly set total_seconds_pool."""
try:
team = Team.objects.get(id=team_id)
except Team.DoesNotExist:
return Response({'error': '团队不存在'}, status=status.HTTP_404_NOT_FOUND)
new_pool = request.data.get('total_seconds_pool')
if new_pool is None:
return Response({'error': 'total_seconds_pool is required'}, status=status.HTTP_400_BAD_REQUEST)
try:
new_pool = int(new_pool)
except (ValueError, TypeError):
return Response({'error': '请输入有效的数字'}, status=status.HTTP_400_BAD_REQUEST)
if new_pool < 0:
return Response({'error': '总秒数池不能为负数'}, status=status.HTTP_400_BAD_REQUEST)
if new_pool < team.total_seconds_used:
return Response({'error': f'不能低于已消耗秒数 ({int(team.total_seconds_used)}s)'}, status=status.HTTP_400_BAD_REQUEST)
old_pool = team.total_seconds_pool
with transaction.atomic():
locked = Team.objects.select_for_update().get(pk=team.pk)
locked.total_seconds_pool = new_pool
locked.save(update_fields=['total_seconds_pool'])
team.refresh_from_db()
log_admin_action(request, 'team_set_pool', 'team', target_id=team.id, target_name=team.name,
before={'total_seconds_pool': old_pool},
after={'total_seconds_pool': team.total_seconds_pool})
return Response({
'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,
})
@api_view(['POST']) @api_view(['POST'])
@permission_classes([IsSuperAdmin]) @permission_classes([IsSuperAdmin])
def admin_team_create_admin_view(request, team_id): def admin_team_create_admin_view(request, team_id):
@ -673,6 +757,8 @@ def admin_team_create_admin_view(request, team_id):
daily_seconds_limit=team.daily_member_limit_default, daily_seconds_limit=team.daily_member_limit_default,
monthly_seconds_limit=-1, # Team admin unlimited by default monthly_seconds_limit=-1, # Team admin unlimited by default
) )
log_admin_action(request, 'team_create_admin', 'user', target_id=user.id, target_name=user.username,
after={'username': user.username, 'email': user.email, 'team': team.name})
return Response({ return Response({
'id': user.id, 'id': user.id,
@ -800,6 +886,7 @@ def admin_user_detail_view(request, user_id):
'mode': r.mode, 'mode': r.mode,
'model': r.model, 'model': r.model,
'status': r.status, 'status': r.status,
'error_message': r.error_message or '',
} }
for r in recent_records for r in recent_records
], ],
@ -818,9 +905,13 @@ def admin_user_quota_view(request, user_id):
serializer = QuotaUpdateSerializer(data=request.data) serializer = QuotaUpdateSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
before = {'daily_seconds_limit': user.daily_seconds_limit, 'monthly_seconds_limit': user.monthly_seconds_limit}
user.daily_seconds_limit = serializer.validated_data['daily_seconds_limit'] user.daily_seconds_limit = serializer.validated_data['daily_seconds_limit']
user.monthly_seconds_limit = serializer.validated_data['monthly_seconds_limit'] user.monthly_seconds_limit = serializer.validated_data['monthly_seconds_limit']
user.save(update_fields=['daily_seconds_limit', 'monthly_seconds_limit']) user.save(update_fields=['daily_seconds_limit', 'monthly_seconds_limit'])
log_admin_action(request, 'user_quota_update', 'user', target_id=user.id, target_name=user.username,
before=before,
after={'daily_seconds_limit': user.daily_seconds_limit, 'monthly_seconds_limit': user.monthly_seconds_limit})
return Response({ return Response({
'user_id': user.id, 'user_id': user.id,
@ -843,8 +934,11 @@ def admin_user_status_view(request, user_id):
serializer = UserStatusSerializer(data=request.data) serializer = UserStatusSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
old_active = user.is_active
user.is_active = serializer.validated_data['is_active'] user.is_active = serializer.validated_data['is_active']
user.save(update_fields=['is_active']) user.save(update_fields=['is_active'])
log_admin_action(request, 'user_status_toggle', 'user', target_id=user.id, target_name=user.username,
before={'is_active': old_active}, after={'is_active': user.is_active})
return Response({ return Response({
'user_id': user.id, 'user_id': user.id,
@ -877,6 +971,8 @@ def admin_create_user_view(request):
monthly_seconds_limit=serializer.validated_data['monthly_seconds_limit'], monthly_seconds_limit=serializer.validated_data['monthly_seconds_limit'],
is_staff=serializer.validated_data['is_staff'], is_staff=serializer.validated_data['is_staff'],
) )
log_admin_action(request, 'user_create', 'user', target_id=user.id, target_name=user.username,
after={'username': user.username, 'email': user.email, 'is_staff': user.is_staff})
return Response({ return Response({
'id': user.id, 'id': user.id,
@ -934,6 +1030,7 @@ def admin_records_view(request):
'model': r.model, 'model': r.model,
'aspect_ratio': r.aspect_ratio, 'aspect_ratio': r.aspect_ratio,
'status': r.status, 'status': r.status,
'error_message': r.error_message or '',
}) })
return Response({ return Response({
@ -965,11 +1062,25 @@ def admin_settings_view(request):
serializer = SystemSettingsSerializer(data=request.data) serializer = SystemSettingsSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
before = {
'default_daily_seconds_limit': config.default_daily_seconds_limit,
'default_monthly_seconds_limit': config.default_monthly_seconds_limit,
'announcement': config.announcement,
'announcement_enabled': config.announcement_enabled,
}
config.default_daily_seconds_limit = serializer.validated_data['default_daily_seconds_limit'] config.default_daily_seconds_limit = serializer.validated_data['default_daily_seconds_limit']
config.default_monthly_seconds_limit = serializer.validated_data['default_monthly_seconds_limit'] config.default_monthly_seconds_limit = serializer.validated_data['default_monthly_seconds_limit']
config.announcement = serializer.validated_data.get('announcement', '') config.announcement = serializer.validated_data.get('announcement', '')
config.announcement_enabled = serializer.validated_data.get('announcement_enabled', False) config.announcement_enabled = serializer.validated_data.get('announcement_enabled', False)
config.save() config.save()
log_admin_action(request, 'settings_update', 'settings', target_name='系统设置',
before=before,
after={
'default_daily_seconds_limit': config.default_daily_seconds_limit,
'default_monthly_seconds_limit': config.default_monthly_seconds_limit,
'announcement': config.announcement,
'announcement_enabled': config.announcement_enabled,
})
return Response({ return Response({
'default_daily_seconds_limit': config.default_daily_seconds_limit, 'default_daily_seconds_limit': config.default_daily_seconds_limit,
@ -980,6 +1091,75 @@ def admin_settings_view(request):
}) })
# ──────────────────────────────────────────────
# Admin: Audit Logs
# ──────────────────────────────────────────────
@api_view(['GET'])
@permission_classes([IsSuperAdmin])
def admin_audit_logs_view(request):
"""GET /api/v1/admin/logs — Query admin audit logs."""
page = int(request.query_params.get('page', 1))
page_size = min(int(request.query_params.get('page_size', 20)), 100)
action = request.query_params.get('action', '').strip()
operator = request.query_params.get('operator', '').strip()
start_date = request.query_params.get('start_date', '').strip()
end_date = request.query_params.get('end_date', '').strip()
qs = AdminAuditLog.objects.select_related('operator').all()
if action:
qs = qs.filter(action=action)
if operator:
qs = qs.filter(operator_name__icontains=operator)
if start_date:
qs = qs.filter(created_at__date__gte=start_date)
if end_date:
qs = qs.filter(created_at__date__lte=end_date)
total = qs.count()
offset = (page - 1) * page_size
logs = list(qs[offset:offset + page_size])
results = []
for log in logs:
results.append({
'id': log.id,
'operator_name': log.operator_name,
'action': log.action,
'action_display': log.get_action_display(),
'target_type': log.target_type,
'target_id': log.target_id,
'target_name': log.target_name,
'before': log.before,
'after': log.after,
'ip_address': log.ip_address,
'created_at': log.created_at.isoformat(),
})
return Response({
'total': total,
'page': page,
'page_size': page_size,
'total_pages': (total + page_size - 1) // page_size,
'results': results,
})
# ──────────────────────────────────────────────
# Public: Announcement
# ──────────────────────────────────────────────
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def announcement_view(request):
"""GET /api/v1/announcement — return active announcement for logged-in users."""
config, _ = QuotaConfig.objects.get_or_create(pk=1)
if config.announcement_enabled and config.announcement:
return Response({'announcement': config.announcement, 'enabled': True})
return Response({'announcement': '', 'enabled': False})
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
# Team Admin: Team Management # Team Admin: Team Management
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
@ -1117,6 +1297,8 @@ def team_member_create_view(request):
daily_seconds_limit=daily, daily_seconds_limit=daily,
monthly_seconds_limit=monthly, monthly_seconds_limit=monthly,
) )
log_admin_action(request, 'member_create', 'user', target_id=user.id, target_name=user.username,
after={'username': user.username, 'team': team.name})
return Response({ return Response({
'id': user.id, 'id': user.id,
@ -1168,6 +1350,7 @@ def team_member_detail_view(request, member_id):
'mode': r.mode, 'mode': r.mode,
'model': r.model, 'model': r.model,
'status': r.status, 'status': r.status,
'error_message': r.error_message or '',
} }
for r in recent_records for r in recent_records
], ],
@ -1187,9 +1370,13 @@ def team_member_quota_view(request, member_id):
serializer = MemberQuotaSerializer(data=request.data) serializer = MemberQuotaSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
before = {'daily_seconds_limit': member.daily_seconds_limit, 'monthly_seconds_limit': member.monthly_seconds_limit}
member.daily_seconds_limit = serializer.validated_data['daily_seconds_limit'] member.daily_seconds_limit = serializer.validated_data['daily_seconds_limit']
member.monthly_seconds_limit = serializer.validated_data['monthly_seconds_limit'] member.monthly_seconds_limit = serializer.validated_data['monthly_seconds_limit']
member.save(update_fields=['daily_seconds_limit', 'monthly_seconds_limit']) member.save(update_fields=['daily_seconds_limit', 'monthly_seconds_limit'])
log_admin_action(request, 'member_quota_update', 'user', target_id=member.id, target_name=member.username,
before=before,
after={'daily_seconds_limit': member.daily_seconds_limit, 'monthly_seconds_limit': member.monthly_seconds_limit})
return Response({ return Response({
'user_id': member.id, 'user_id': member.id,
@ -1216,8 +1403,11 @@ def team_member_status_view(request, member_id):
serializer = UserStatusSerializer(data=request.data) serializer = UserStatusSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
old_active = member.is_active
member.is_active = serializer.validated_data['is_active'] member.is_active = serializer.validated_data['is_active']
member.save(update_fields=['is_active']) member.save(update_fields=['is_active'])
log_admin_action(request, 'member_status_toggle', 'user', target_id=member.id, target_name=member.username,
before={'is_active': old_active}, after={'is_active': member.is_active})
return Response({ return Response({
'user_id': member.id, 'user_id': member.id,
@ -1321,6 +1511,7 @@ def profile_records_view(request):
'model': r.model, 'model': r.model,
'aspect_ratio': r.aspect_ratio, 'aspect_ratio': r.aspect_ratio,
'status': r.status, 'status': r.status,
'error_message': r.error_message or '',
}) })
return Response({ return Response({

View File

@ -1,12 +1,12 @@
"""Volcano Engine Seedance (ARK) video generation API client.""" """Volcano Engine ARK video generation API client."""
import requests import requests
from django.conf import settings from django.conf import settings
# Seedance API error code → user-friendly Chinese message # API error code → user-friendly Chinese message
ERROR_MESSAGES = { ERROR_MESSAGES = {
'InputImageSensitiveContentDetected.PrivacyInformation': '参考图片中检测到真实人脸,Seedance 不允许处理包含真人面部的图片', 'InputImageSensitiveContentDetected.PrivacyInformation': '参考图片中检测到真实人脸,系统不允许处理包含真人面部的图片',
'InputImageSensitiveContentDetected': '参考图片包含敏感内容,请更换图片后重试', 'InputImageSensitiveContentDetected': '参考图片包含敏感内容,请更换图片后重试',
'InputVideoSensitiveContentDetected': '参考视频包含敏感内容,请更换视频后重试', 'InputVideoSensitiveContentDetected': '参考视频包含敏感内容,请更换视频后重试',
'InvalidParameter': '请求参数无效,请检查输入', 'InvalidParameter': '请求参数无效,请检查输入',
@ -15,8 +15,8 @@ ERROR_MESSAGES = {
} }
class SeedanceAPIError(Exception): class AirDramaAPIError(Exception):
"""Raised when Seedance API returns an error response.""" """Raised when video generation API returns an error response."""
def __init__(self, code, message, status_code=400): def __init__(self, code, message, status_code=400):
self.code = code self.code = code
self.api_message = message self.api_message = message
@ -44,7 +44,7 @@ def create_task(prompt, model, content_items, aspect_ratio, duration, generate_a
Args: Args:
prompt: Text prompt for video generation. 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). content_items: List of media content dicts (image_url, video_url, audio_url).
aspect_ratio: Video aspect ratio ('16:9', '9:16', etc.). aspect_ratio: Video aspect ratio ('16:9', '9:16', etc.).
duration: Video duration in seconds. duration: Video duration in seconds.
@ -71,14 +71,14 @@ def create_task(prompt, model, content_items, aspect_ratio, duration, generate_a
resp = requests.post(url, json=payload, headers=_headers(), timeout=60) resp = requests.post(url, json=payload, headers=_headers(), timeout=60)
if resp.status_code != 200: if resp.status_code != 200:
# Extract human-readable error from Seedance API response # Extract human-readable error from API response
try: try:
err = resp.json().get('error', {}) err = resp.json().get('error', {})
code = err.get('code', '') code = err.get('code', '')
message = err.get('message', resp.text) message = err.get('message', resp.text)
except Exception: except Exception:
code, message = '', resp.text code, message = '', resp.text
raise SeedanceAPIError(code, message, resp.status_code) raise AirDramaAPIError(code, message, resp.status_code)
return resp.json() return resp.json()

View File

@ -1,8 +1,12 @@
"""Volcano Engine TOS file upload utility using official TOS SDK.""" """Volcano Engine TOS file upload utility using official TOS SDK."""
import hashlib
import uuid import uuid
import logging
from django.conf import settings from django.conf import settings
logger = logging.getLogger(__name__)
CONTENT_TYPE_MAP = { CONTENT_TYPE_MAP = {
'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png',
@ -30,14 +34,31 @@ def get_tos_client():
def upload_file(file_obj, folder='uploads'): 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() 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') content_type = CONTENT_TYPE_MAP.get(ext, 'application/octet-stream')
client = get_tos_client() client = get_tos_client()
content = file_obj.read() 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( client.put_object(
bucket=settings.TOS_BUCKET, bucket=settings.TOS_BUCKET,
key=key, key=key,
@ -45,7 +66,7 @@ def upload_file(file_obj, folder='uploads'):
content_type=content_type, content_type=content_type,
) )
return f'{settings.TOS_CDN_DOMAIN}/{key}' return url
def upload_from_url(source_url, folder='results'): def upload_from_url(source_url, folder='results'):

View File

@ -4,6 +4,113 @@
--- ---
## 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 数据清理 ## 2026-03-15 — v0.8.1: Seedance API 友好错误提示 + Mock 数据清理
**状态**: ✅ 已完成 | **验收**: ✅ 通过(本地端到端测试) **状态**: ✅ 已完成 | **验收**: ✅ 通过(本地端到端测试)

View File

@ -1,6 +1,7 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AmbientBackground } from './components/AmbientBackground'; import { AmbientBackground } from './components/AmbientBackground';
import { Toast } from './components/Toast';
import { VideoGenerationPage } from './components/VideoGenerationPage'; import { VideoGenerationPage } from './components/VideoGenerationPage';
import { ProtectedRoute } from './components/ProtectedRoute'; import { ProtectedRoute } from './components/ProtectedRoute';
import { LoginPage } from './pages/LoginPage'; import { LoginPage } from './pages/LoginPage';
@ -11,6 +12,7 @@ import { TeamsPage } from './pages/TeamsPage';
import { UsersPage } from './pages/UsersPage'; import { UsersPage } from './pages/UsersPage';
import { RecordsPage } from './pages/RecordsPage'; import { RecordsPage } from './pages/RecordsPage';
import { SettingsPage } from './pages/SettingsPage'; import { SettingsPage } from './pages/SettingsPage';
import { AuditLogsPage } from './pages/AuditLogsPage';
import { ProfilePage } from './pages/ProfilePage'; import { ProfilePage } from './pages/ProfilePage';
import { AssetsPage } from './pages/AssetsPage'; import { AssetsPage } from './pages/AssetsPage';
@ -30,6 +32,7 @@ export default function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<AmbientBackground /> <AmbientBackground />
<Toast />
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route <Route
@ -71,6 +74,7 @@ export default function App() {
<Route path="users" element={<UsersPage />} /> <Route path="users" element={<UsersPage />} />
<Route path="records" element={<RecordsPage />} /> <Route path="records" element={<RecordsPage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
<Route path="logs" element={<AuditLogsPage />} />
</Route> </Route>
{/* Team Admin routes */} {/* Team Admin routes */}
<Route <Route

View File

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

View File

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

View File

@ -1,5 +1,5 @@
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 300; } .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: 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; }
.title { font-size: 16px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 12px; } .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; } .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; } .actions { display: flex; justify-content: flex-end; gap: 8px; }

View File

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

View File

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

View File

@ -325,7 +325,7 @@
height: 100%; height: 100%;
background: linear-gradient(90deg, var(--color-primary), #8b5cf6); background: linear-gradient(90deg, var(--color-primary), #8b5cf6);
border-radius: 2px; border-radius: 2px;
transition: width 0.6s ease; transition: width 1.5s ease-out;
} }
.progressText { .progressText {

View File

@ -293,7 +293,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
style={{ width: `${task.progress}%` }} style={{ width: `${task.progress}%` }}
/> />
</div> </div>
<span className={styles.progressText}>{task.progress}%</span> <span className={styles.progressText}>{Math.round(task.progress)}%</span>
</div> </div>
</div> </div>
) : task.status === 'failed' ? ( ) : task.status === 'failed' ? (

View File

@ -149,6 +149,25 @@ export function PromptInput() {
setEditorHtml(el.innerHTML); setEditorHtml(el.innerHTML);
}, [setPrompt, setEditorHtml]); }, [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(() => { const handleInput = useCallback(() => {
extractText(); extractText();
@ -241,17 +260,32 @@ export function PromptInput() {
const handlePaste = useCallback((e: React.ClipboardEvent) => { const handlePaste = useCallback((e: React.ClipboardEvent) => {
e.preventDefault(); e.preventDefault();
// Always paste as plain text for reliability, then rebuild mentions
const text = e.clipboardData.getData('text/plain');
document.execCommand('insertText', false, text);
// Rebuild @label patterns (e.g. @图片1) into styled mention spans // Handle pasted image files (Ctrl+V screenshot / copied image)
const el = editorRef.current; const items = e.clipboardData.items;
if (el && references.length > 0) { const imageFiles: File[] = [];
rebuildMentionSpans(el); 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();
}, [extractText, references, rebuildMentionSpans]); }, [extractText, references]);
// Mention hover — delegated event // Mention hover — delegated event
const handleMouseOver = useCallback((e: React.MouseEvent) => { const handleMouseOver = useCallback((e: React.MouseEvent) => {

View File

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

View File

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

View File

@ -40,3 +40,15 @@
gap: 20px; gap: 20px;
padding: 24px 16px; padding: 24px 16px;
} }
.loadMoreWrap {
display: flex;
justify-content: center;
width: 100%;
padding: 8px 0;
}
.loadMoreText {
color: var(--color-text-disabled);
font-size: 12px;
}

View File

@ -1,9 +1,9 @@
import { useRef, useEffect, useState, useMemo } from 'react'; import { useRef, useEffect, useState, useMemo, useCallback } from 'react';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
import { InputBar } from './InputBar'; import { InputBar } from './InputBar';
import { GenerationCard } from './GenerationCard'; import { GenerationCard } from './GenerationCard';
import { Toast } from './Toast';
import { VideoDetailModal } from './VideoDetailModal'; import { VideoDetailModal } from './VideoDetailModal';
import { AnnouncementBanner } from './AnnouncementBanner';
import { useGenerationStore } from '../store/generation'; import { useGenerationStore } from '../store/generation';
import { useAuthStore } from '../store/auth'; import { useAuthStore } from '../store/auth';
import type { GenerationTask } from '../types'; import type { GenerationTask } from '../types';
@ -12,12 +12,17 @@ import styles from './VideoGenerationPage.module.css';
export function VideoGenerationPage() { export function VideoGenerationPage() {
const tasks = useGenerationStore((s) => s.tasks); const tasks = useGenerationStore((s) => s.tasks);
const loadTasks = useGenerationStore((s) => s.loadTasks); 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 teamDisabled = useAuthStore((s) => s.teamDisabled);
const reEdit = useGenerationStore((s) => s.reEdit); const reEdit = useGenerationStore((s) => s.reEdit);
const regenerate = useGenerationStore((s) => s.regenerate); const regenerate = useGenerationStore((s) => s.regenerate);
const removeTask = useGenerationStore((s) => s.removeTask); const removeTask = useGenerationStore((s) => s.removeTask);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const prevCountRef = useRef(tasks.length); 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); const [detailTask, setDetailTask] = useState<GenerationTask | null>(null);
// Load tasks from backend on mount (persist across page refresh) // Load tasks from backend on mount (persist across page refresh)
@ -25,13 +30,46 @@ export function VideoGenerationPage() {
loadTasks(); loadTasks();
}, [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(() => { 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) { if (tasks.length > prevCountRef.current && scrollRef.current) {
scrollRef.current.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' }); scrollRef.current.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
} }
prevCountRef.current = tasks.length; 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) => { const handleReEdit = (id: string) => {
reEdit(id); reEdit(id);
@ -80,13 +118,19 @@ export function VideoGenerationPage() {
<div className={styles.layout}> <div className={styles.layout}>
<Sidebar /> <Sidebar />
<main className={styles.main}> <main className={styles.main}>
<div className={styles.contentArea} ref={scrollRef}> <AnnouncementBanner />
<div className={styles.contentArea} ref={scrollRef} onScroll={handleScroll}>
{tasks.length === 0 ? ( {tasks.length === 0 ? (
<div className={styles.emptyArea}> <div className={styles.emptyArea}>
<p className={styles.emptyHint}> AI </p> <p className={styles.emptyHint}> AI </p>
</div> </div>
) : ( ) : (
<div className={styles.taskList}> <div className={styles.taskList}>
{isLoadingMore && (
<div className={styles.loadMoreWrap}>
<span className={styles.loadMoreText}></span>
</div>
)}
{tasks.map((task) => ( {tasks.map((task) => (
<GenerationCard <GenerationCard
key={task.id} key={task.id}
@ -99,7 +143,6 @@ export function VideoGenerationPage() {
</div> </div>
<InputBar /> <InputBar />
</main> </main>
<Toast />
<VideoDetailModal <VideoDetailModal
task={detailTask} task={detailTask}
onClose={() => setDetailTask(null)} onClose={() => setDetailTask(null)}

View File

@ -3,6 +3,7 @@ import type {
User, Quota, AuthTokens, AdminStats, AdminUser, AdminUserDetail, User, Quota, AuthTokens, AdminStats, AdminUser, AdminUserDetail,
AdminRecord, SystemSettings, ProfileOverview, PaginatedResponse, AdminRecord, SystemSettings, ProfileOverview, PaginatedResponse,
BackendTask, TeamInfo, Team, TeamDetail, TeamMember, TeamStats, BackendTask, TeamInfo, Team, TeamDetail, TeamMember, TeamStats,
AuditLog,
} from '../types'; } from '../types';
import { reportError } from './logCenter'; import { reportError } from './logCenter';
@ -107,14 +108,17 @@ export const videoApi = {
seconds_consumed: number; seconds_consumed: number;
}>('/video/generate', data), }>('/video/generate', data),
getTasks: () => getTasks: (params?: { page_size?: number; offset?: number }) =>
api.get<{ results: BackendTask[] }>('/video/tasks'), api.get<{ results: BackendTask[]; total: number; has_more: boolean }>('/video/tasks', { params }),
getTaskStatus: (taskId: string) => getTaskStatus: (taskId: string) =>
api.get<BackendTask>(`/video/tasks/${taskId}`), api.get<BackendTask>(`/video/tasks/${taskId}`),
deleteTask: (taskId: string) => deleteTask: (taskId: string) =>
api.delete(`/video/tasks/${taskId}`), api.delete(`/video/tasks/${taskId}`),
getAnnouncement: () =>
api.get<{ announcement: string; enabled: boolean }>('/announcement'),
}; };
// Admin APIs (Super Admin) // Admin APIs (Super Admin)
@ -138,6 +142,9 @@ export const adminApi = {
topUpTeam: (teamId: number, seconds: number) => topUpTeam: (teamId: number, seconds: number) =>
api.post(`/admin/teams/${teamId}/topup`, { seconds }), 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 }) => createTeamAdmin: (teamId: number, data: { username: string; email: string; password: string }) =>
api.post(`/admin/teams/${teamId}/admin`, data), api.post(`/admin/teams/${teamId}/admin`, data),
@ -188,6 +195,16 @@ export const adminApi = {
updateSettings: (settings: SystemSettings) => updateSettings: (settings: SystemSettings) =>
api.put<SystemSettings & { updated_at: string }>('/admin/settings', settings), 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 // Team Admin APIs

View File

@ -9,6 +9,7 @@ const navItems = [
{ 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/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/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/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() { export function AdminLayout() {

View File

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

View File

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

View File

@ -45,8 +45,8 @@ export function DashboardPage() {
if (!stats) return null; if (!stats) return null;
const statCards = [ const statCards = [
{ label: '总团队数', value: stats.total_teams, change: null },
{ label: '总用户数', value: stats.total_users, 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_today, change: stats.today_change_percent },
{ label: '本月消费秒数', value: stats.seconds_consumed_this_month, change: stats.month_change_percent }, { label: '本月消费秒数', value: stats.seconds_consumed_this_month, change: stats.month_change_percent },
]; ];
@ -90,6 +90,48 @@ export function DashboardPage() {
}], }],
}; };
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',
},
}],
};
const sortedUsers = [...stats.top_users].sort((a, b) => a.seconds_consumed - b.seconds_consumed); const sortedUsers = [...stats.top_users].sort((a, b) => a.seconds_consumed - b.seconds_consumed);
const barOption: echarts.EChartsCoreOption = { const barOption: echarts.EChartsCoreOption = {
tooltip: { tooltip: {
@ -158,6 +200,15 @@ export function DashboardPage() {
</div> </div>
</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}> <div className={styles.chartSection}>
<h2 className={styles.sectionTitle}>Top 10 · </h2> <h2 className={styles.sectionTitle}>Top 10 · </h2>
<div className={styles.chartWrapper}> <div className={styles.chartWrapper}>

View File

@ -19,6 +19,10 @@
} }
.dateInput:focus { border-color: var(--color-primary); } .dateInput:focus { border-color: var(--color-primary); }
.dateSep { color: var(--color-text-secondary); font-size: 13px; } .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 { padding: 8px 16px; background: var(--color-primary); border: none; border-radius: 8px; color: #fff; font-size: 13px; cursor: pointer; }
.searchBtn:hover { opacity: 0.9; } .searchBtn:hover { opacity: 0.9; }
@ -37,6 +41,16 @@
.statusBadge { padding: 2px 8px; border-radius: 4px; font-size: 12px; } .statusBadge { padding: 2px 8px; border-radius: 4px; font-size: 12px; }
.completed { background: rgba(0, 184, 148, 0.15); color: var(--color-success); } .completed { background: rgba(0, 184, 148, 0.15); color: var(--color-success); }
.failed { background: rgba(231, 76, 60, 0.15); color: var(--color-danger); } .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); } .queued, .processing { background: rgba(0, 184, 230, 0.15); color: var(--color-primary); }
.empty { text-align: center; color: var(--color-text-secondary); padding: 40px; } .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; } .skeletonCell { height: 16px; background: var(--color-border-card); border-radius: 4px; animation: pulse 1.5s ease-in-out infinite; }

View File

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

View File

@ -51,7 +51,7 @@
/* Modal */ /* Modal */
.modalOverlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 300; } .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; } .modalTitle { font-size: 16px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 20px; }
.formGroup { margin-bottom: 16px; } .formGroup { margin-bottom: 16px; }
.formGroup label { display: block; color: var(--color-text-secondary); font-size: 13px; margin-bottom: 6px; } .formGroup label { display: block; color: var(--color-text-secondary); font-size: 13px; margin-bottom: 6px; }
@ -65,28 +65,202 @@
.formError { color: var(--color-danger); font-size: 13px; margin-bottom: 12px; } .formError { color: var(--color-danger); font-size: 13px; margin-bottom: 12px; }
.formHint { color: var(--color-text-secondary); font-size: 12px; margin-top: 4px; } .formHint { color: var(--color-text-secondary); font-size: 12px; margin-top: 4px; }
/* Drawer */ /*
.drawerOverlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 300; } Team Detail Modal (follows VideoDetailModal spec)
.drawer { */
position: fixed; right: 0; top: 0; bottom: 0; width: 560px; max-width: 90vw; .detailOverlay {
background: var(--color-bg-card); border-left: 1px solid var(--color-border-card); position: fixed;
display: flex; flex-direction: column; z-index: 301; inset: 0;
animation: slideIn 0.2s ease; 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);
} }
@keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
.drawerHeader { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; border-bottom: 1px solid var(--color-border-card); }
.drawerHeader h3 { font-size: 16px; color: var(--color-text-primary); }
.drawerClose { background: none; border: none; color: var(--color-text-secondary); font-size: 24px; cursor: pointer; line-height: 1; }
.drawerBody { flex: 1; overflow-y: auto; padding: 20px; }
.detailGrid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; margin-bottom: 24px; }
.detailItem { display: flex; flex-direction: column; gap: 4px; }
.detailLabel { color: var(--color-text-secondary); font-size: 12px; }
.detailValue { color: var(--color-text-primary); font-size: 14px; }
.membersTitle { font-size: 15px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 12px; }
.memberTable { width: 100%; border-collapse: collapse; font-size: 12px; }
.memberTable th { padding: 8px 12px; text-align: left; color: var(--color-text-secondary); font-weight: 500; border-bottom: 1px solid var(--color-border-card); white-space: nowrap; }
.memberTable td { padding: 8px 12px; color: var(--color-text-primary); border-bottom: 1px solid rgba(42, 42, 56, 0.5); }
.memberTable tr:last-child td { border-bottom: none; } .memberTable tr:last-child td { border-bottom: none; }
.adminBadge { background: rgba(167, 139, 250, 0.15); color: #a78bfa; padding: 1px 6px; border-radius: 4px; font-size: 11px; margin-left: 6px; } .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;
}

View File

@ -35,6 +35,11 @@ export function TeamsPage() {
const [detailTeam, setDetailTeam] = useState<TeamDetail | null>(null); const [detailTeam, setDetailTeam] = useState<TeamDetail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
// Edit pool modal
const [editPoolOpen, setEditPoolOpen] = useState(false);
const [editPoolValue, setEditPoolValue] = useState('');
const [editPoolError, setEditPoolError] = useState('');
// Confirm toggle // Confirm toggle
const [confirmTeam, setConfirmTeam] = useState<Team | null>(null); const [confirmTeam, setConfirmTeam] = useState<Team | null>(null);
@ -100,6 +105,24 @@ export function TeamsPage() {
} }
}; };
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 = () => { const resetAdminForm = () => {
setAdminUsername(''); setAdminEmail(''); setAdminPassword(''); setAdminUsername(''); setAdminEmail(''); setAdminPassword('');
setAdminError(''); setAdminError('');
@ -303,24 +326,40 @@ export function TeamsPage() {
onCancel={() => setConfirmTeam(null)} onCancel={() => setConfirmTeam(null)}
/> />
{/* Team Detail Drawer */} {/* Team Detail Modal */}
{drawerOpen && detailTeam && ( {drawerOpen && detailTeam && (
<div className={styles.drawerOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setDrawerOpen(false); }}> <div className={styles.detailOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setDrawerOpen(false); }}>
<div className={styles.drawer}> <div className={styles.detailModal}>
<div className={styles.drawerHeader}> <div className={styles.detailHeader}>
<h3> <h3>
{detailTeam.name} {detailTeam.name}
<span className={`${styles.statusBadge} ${detailTeam.is_active ? styles.active : styles.disabled}`} style={{ marginLeft: 8 }}> <span className={`${styles.statusBadge} ${detailTeam.is_active ? styles.active : styles.disabled}`}>
{detailTeam.is_active ? '启用' : '禁用'} {detailTeam.is_active ? '启用' : '禁用'}
</span> </span>
</h3> </h3>
<button className={styles.drawerClose} onClick={() => setDrawerOpen(false)}>×</button> <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>
<div className={styles.drawerBody}> <div className={styles.detailBody}>
<div className={styles.detailGrid}> <div className={styles.detailGrid}>
<div className={styles.detailItem}> <div className={styles.detailItem}>
<span className={styles.detailLabel}></span> <span className={styles.detailLabel}></span>
<span className={styles.detailValue}>{fmtSec(detailTeam.total_seconds_pool)}</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>
<div className={styles.detailItem}> <div className={styles.detailItem}>
<span className={styles.detailLabel}></span> <span className={styles.detailLabel}></span>
@ -356,45 +395,68 @@ export function TeamsPage() {
{detailTeam.members.length === 0 ? ( {detailTeam.members.length === 0 ? (
<div className={styles.empty}></div> <div className={styles.empty}></div>
) : ( ) : (
<table className={styles.memberTable}> <div className={styles.memberTableWrapper}>
<thead> <table className={styles.memberTable}>
<tr> <thead>
<th></th> <tr>
<th></th> <th></th>
<th></th> <th></th>
<th></th> <th></th>
<th></th> <th></th>
<th></th> <th></th>
<th></th> <th></th>
</tr> <th></th>
</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> </tr>
))} </thead>
</tbody> <tbody>
</table> {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> </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> </div>
); );
} }

View File

@ -63,7 +63,7 @@
/* Modal */ /* Modal */
.modalOverlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 300; } .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; } .modalTitle { font-size: 16px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 20px; }
.formGroup { margin-bottom: 16px; } .formGroup { margin-bottom: 16px; }
.formGroup label { display: block; color: var(--color-text-secondary); font-size: 13px; margin-bottom: 6px; } .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; } .drawerOverlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 300; }
.drawer { .drawer {
position: fixed; right: 0; top: 0; bottom: 0; width: 440px; max-width: 90vw; 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; display: flex; flex-direction: column; z-index: 301;
animation: slideIn 0.2s ease; animation: slideIn 0.2s ease;
} }

View File

@ -1,8 +1,9 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { adminApi } from '../lib/api'; import { adminApi } from '../lib/api';
import type { AdminUser, AdminUserDetail } from '../types'; import type { AdminUser, AdminUserDetail, Team } from '../types';
import { showToast } from '../components/Toast'; import { showToast } from '../components/Toast';
import { ConfirmModal } from '../components/ConfirmModal'; import { ConfirmModal } from '../components/ConfirmModal';
import { Select } from '../components/Select';
import styles from './UsersPage.module.css'; import styles from './UsersPage.module.css';
export function UsersPage() { export function UsersPage() {
@ -11,6 +12,8 @@ export function UsersPage() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState(''); const [statusFilter, setStatusFilter] = useState('');
const [teamFilter, setTeamFilter] = useState('');
const [teams, setTeams] = useState<Team[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const pageSize = 20; const pageSize = 20;
@ -36,11 +39,17 @@ export function UsersPage() {
const [newIsStaff, setNewIsStaff] = useState(false); const [newIsStaff, setNewIsStaff] = useState(false);
const [createError, setCreateError] = useState(''); const [createError, setCreateError] = useState('');
// Load teams for filter dropdown
useEffect(() => {
adminApi.getTeams().then(({ data }) => setTeams(data.results)).catch(() => {});
}, []);
const fetchUsers = useCallback(async () => { const fetchUsers = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const { data } = await adminApi.getUsers({ const { data } = await adminApi.getUsers({
page, page_size: pageSize, search, status: statusFilter, page, page_size: pageSize, search, status: statusFilter,
team_id: teamFilter ? Number(teamFilter) : undefined,
}); });
setUsers(data.results); setUsers(data.results);
setTotal(data.total); setTotal(data.total);
@ -49,7 +58,7 @@ export function UsersPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [page, search, statusFilter]); }, [page, search, statusFilter, teamFilter]);
useEffect(() => { fetchUsers(); }, [fetchUsers]); useEffect(() => { fetchUsers(); }, [fetchUsers]);
@ -142,15 +151,22 @@ export function UsersPage() {
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()} onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/> />
<select <Select
className={styles.statusSelect}
value={statusFilter} value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }} onChange={(v) => { setStatusFilter(v); setPage(1); }}
> placeholder="全部状态"
<option value=""></option> options={[
<option value="active"></option> { label: '全部状态', value: '' },
<option value="disabled"></option> { label: '启用', value: 'active' },
</select> { 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> <button className={styles.searchBtn} onClick={handleSearch}></button>
</div> </div>
<div className={styles.searchGroup}> <div className={styles.searchGroup}>
@ -164,6 +180,7 @@ export function UsersPage() {
<thead> <thead>
<tr> <tr>
<th></th> <th></th>
<th></th>
<th></th> <th></th>
<th></th> <th></th>
<th></th> <th></th>
@ -178,13 +195,13 @@ export function UsersPage() {
{loading ? ( {loading ? (
Array.from({ length: 5 }).map((_, i) => ( Array.from({ length: 5 }).map((_, i) => (
<tr key={i}> <tr key={i}>
{Array.from({ length: 9 }).map((_, j) => ( {Array.from({ length: 10 }).map((_, j) => (
<td key={j}><div className={styles.skeletonCell} /></td> <td key={j}><div className={styles.skeletonCell} /></td>
))} ))}
</tr> </tr>
)) ))
) : users.length === 0 ? ( ) : users.length === 0 ? (
<tr><td colSpan={9} className={styles.empty}></td></tr> <tr><td colSpan={10} className={styles.empty}></td></tr>
) : ( ) : (
users.map((u) => ( users.map((u) => (
<tr key={u.id}> <tr key={u.id}>
@ -193,6 +210,7 @@ export function UsersPage() {
{u.username} {u.username}
</button> </button>
</td> </td>
<td>{u.team_name || '-'}</td>
<td>{u.email}</td> <td>{u.email}</td>
<td>{new Date(u.date_joined).toLocaleDateString('zh-CN')}</td> <td>{new Date(u.date_joined).toLocaleDateString('zh-CN')}</td>
<td> <td>

View File

@ -85,6 +85,30 @@ function backendToFrontend(bt: BackendTask): GenerationTask {
// Active polling timers // Active polling timers
const pollTimers = new Map<string, ReturnType<typeof setTimeout>>(); 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 // Progressive polling: 10s for first 2min, 30s for 2-5min, 60s after 5min
function getPollingInterval(startTime: number): number { function getPollingInterval(startTime: number): number {
const elapsed = Date.now() - startTime; const elapsed = Date.now() - startTime;
@ -110,7 +134,7 @@ function startPolling(taskId: string, frontendId: string) {
? { ? {
...t, ...t,
status: newStatus, 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, resultUrl: data.result_url || t.resultUrl,
errorMessage: mapErrorMessage(data.error_message) || t.errorMessage, errorMessage: mapErrorMessage(data.error_message) || t.errorMessage,
} }
@ -145,40 +169,78 @@ function stopPolling(frontendId: string) {
} }
} }
const PAGE_SIZE = 20;
interface GenerationState { interface GenerationState {
tasks: GenerationTask[]; tasks: GenerationTask[];
isLoading: boolean; isLoading: boolean;
isLoadingMore: boolean;
hasMore: boolean;
savedScrollTop: number | null;
addTask: () => Promise<string | null>; addTask: () => Promise<string | null>;
removeTask: (id: string) => void; removeTask: (id: string) => void;
reEdit: (id: string) => void; reEdit: (id: string) => void;
regenerate: (id: string) => void; regenerate: (id: string) => void;
loadTasks: () => Promise<void>; loadTasks: () => Promise<void>;
loadMore: () => Promise<void>;
saveScrollPosition: (top: number) => void;
} }
export const useGenerationStore = create<GenerationState>((set, get) => ({ export const useGenerationStore = create<GenerationState>((set, get) => ({
tasks: [], tasks: [],
isLoading: false, isLoading: false,
isLoadingMore: false,
hasMore: false,
savedScrollTop: null,
loadTasks: async () => { loadTasks: async () => {
set({ isLoading: true }); set({ isLoading: true });
let tasks: GenerationTask[] = []; let tasks: GenerationTask[] = [];
let hasMore = false;
try { try {
const { data } = await videoApi.getTasks(); const { data } = await videoApi.getTasks({ page_size: PAGE_SIZE, offset: 0 });
tasks = data.results.map(backendToFrontend).reverse(); tasks = data.results.map(backendToFrontend).reverse();
hasMore = data.has_more;
} catch { } catch {
// API unavailable — tasks stays empty // API unavailable — tasks stays empty
} }
set({ tasks, isLoading: false }); set({ tasks, hasMore, isLoading: false });
// Start polling for any active tasks // Start polling and smooth progress for any active tasks
let hasGenerating = false;
for (const task of tasks) { for (const task of tasks) {
if (task.status === 'generating' && task.taskId) { if (task.status === 'generating' && task.taskId) {
startPolling(task.taskId, task.id); startPolling(task.taskId, task.id);
hasGenerating = true;
} }
} }
if (hasGenerating) ensureSmoothProgress();
},
loadMore: async () => {
const { tasks, isLoadingMore, hasMore } = get();
if (isLoadingMore || !hasMore) return;
set({ isLoadingMore: true });
try {
// Backend returns newest-first; we display oldest-first (reversed).
// tasks[0] is the oldest currently loaded. We need older ones = higher offset.
// offset = total loaded from backend perspective
const currentBackendCount = tasks.filter((t) => !t.id.startsWith('temp_')).length;
const { data } = await videoApi.getTasks({ page_size: PAGE_SIZE, offset: currentBackendCount });
const olderTasks = data.results.map(backendToFrontend).reverse();
set((s) => ({
tasks: [...olderTasks, ...s.tasks],
hasMore: data.has_more,
isLoadingMore: false,
}));
} catch {
set({ isLoadingMore: false });
}
}, },
addTask: async () => { addTask: async () => {
@ -249,12 +311,15 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
duration: input.duration, duration: input.duration,
references: localRefs, references: localRefs,
status: 'generating', status: 'generating',
progress: 5, progress: 0,
createdAt: Date.now(), createdAt: Date.now(),
}; };
set((s) => ({ tasks: [...s.tasks, placeholderTask] })); set((s) => ({ tasks: [...s.tasks, placeholderTask] }));
// Start smooth progress animation
ensureSmoothProgress();
// Clear input // Clear input
useInputBarStore.setState({ useInputBarStore.setState({
prompt: '', prompt: '',
@ -269,12 +334,6 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
const uploadedRefs: { url: string; type: string; role: string; label: string }[] = []; const uploadedRefs: { url: string; type: string; role: string; label: string }[] = [];
for (const item of filesToUpload) { 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) { if (item.tosUrl) {
uploadedRefs.push({ url: item.tosUrl, type: item.type, role: item.role, label: item.label }); uploadedRefs.push({ url: item.tosUrl, type: item.type, role: item.role, label: item.label });
} else if (item.file) { } else if (item.file) {
@ -283,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 // Call generate API
const { data: genResult } = await videoApi.generate({ const { data: genResult } = await videoApi.generate({
prompt: input.prompt, prompt: input.prompt,
@ -311,7 +363,7 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
id: frontendId, id: frontendId,
taskId: genResult.task_id, taskId: genResult.task_id,
status: taskStatus as GenerationTask['status'], status: taskStatus as GenerationTask['status'],
progress: taskStatus === 'completed' ? 100 : 60, progress: taskStatus === 'completed' ? 100 : t.progress,
} }
: t : t
), ),
@ -438,4 +490,8 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
// Trigger generation // Trigger generation
get().addTask(); get().addTask();
}, },
saveScrollPosition: (top: number) => {
set({ savedScrollTop: top });
},
})); }));

View File

@ -111,6 +111,7 @@ export interface AdminStats {
month_change_percent: number; month_change_percent: number;
daily_trend: { date: string; seconds: number }[]; daily_trend: { date: string; seconds: number }[];
top_users: { user_id: number; username: string; seconds_consumed: number }[]; top_users: { user_id: number; username: string; seconds_consumed: number }[];
top_teams: { team_id: number; name: string; seconds_consumed: number }[];
} }
export interface AdminUser { export interface AdminUser {
@ -146,6 +147,7 @@ export interface AdminRecord {
model: ModelOption; model: ModelOption;
aspect_ratio?: string; aspect_ratio?: string;
status: 'queued' | 'processing' | 'completed' | 'failed'; status: 'queued' | 'processing' | 'completed' | 'failed';
error_message?: string;
} }
export interface SystemSettings { export interface SystemSettings {
@ -215,3 +217,17 @@ export interface TeamStats {
daily_trend: { date: string; seconds: number }[]; daily_trend: { date: string; seconds: number }[];
member_consumption: { user_id: number; username: string; seconds_consumed: 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;
}