diff --git a/CLAUDE.md b/CLAUDE.md index 63d3a56..3e7e584 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -145,6 +145,13 @@ jimeng-clone/ | PUT | `/api/v1/admin/users//status` | Toggle user active status | | GET | `/api/v1/admin/records` | List all generation records | | GET/PUT | `/api/v1/admin/settings` | Get/update global settings (QuotaConfig) | +| GET | `/api/v1/admin/teams` | List all teams | +| POST | `/api/v1/admin/teams/create` | Create new team | +| GET/PUT | `/api/v1/admin/teams/` | Get/update team details | +| POST | `/api/v1/admin/teams//topup` | Add seconds to team pool | +| PUT | `/api/v1/admin/teams//set-pool` | Directly set team total seconds pool | +| POST | `/api/v1/admin/teams//admin` | Create team admin user | +| GET | `/api/v1/admin/logs` | Audit logs (filter by action/operator/date) | ### Profile (`/api/v1/profile/`) | Method | Endpoint | Description | @@ -164,6 +171,11 @@ jimeng-clone/ - `status` (queued|processing|completed|failed), `result_url`, `error_message`, `reference_urls` (JSON) - Index: (user, created_at) +### AdminAuditLog +- `operator` (FK User, SET_NULL), `operator_name` (denormalized), `action` (12 choices) +- `target_type`, `target_id`, `target_name`, `before` (JSON), `after` (JSON) +- `ip_address`, `created_at` (indexed) + ### QuotaConfig (Singleton, pk=1) - `default_daily_seconds_limit`, `default_monthly_seconds_limit` - `announcement`, `announcement_enabled`, `updated_at` @@ -179,6 +191,7 @@ jimeng-clone/ | `/admin/users` | UsersPage | Admin | User management | | `/admin/records` | RecordsPage | Admin | Generation records | | `/admin/settings` | SettingsPage | Admin | Global quota & announcement | +| `/admin/logs` | AuditLogsPage | Admin | Admin operation audit logs | ## Incremental Development Guide @@ -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 | TOS 桶切换到 airdrama-media (cn-beijing),K8s Secret 注入 TOS 密钥 | Infra | | 2026-03-15 | v0.8.1: Seedance API 友好错误提示 (SeedanceAPIError) + 前端 Mock 数据清理 | Full stack | +| 2026-03-16 | v0.8.2: 管理后台 UI 修复 — DatePicker/Select 暗色主题、公告跑马灯、Toast 全局化、失败原因 tooltip | Full stack | +| 2026-03-16 | v0.8.3: 团队详情抽屉→弹窗重构(VideoDetailModal 规范) + 修改秒数池功能 + member_count 修复 | Full stack | +| 2026-03-16 | v0.8.4: 管理员操作审计日志 — AdminAuditLog 模型 + 12 处埋点 + 日志查询页面 | Full stack | ### Phase 4 Details (2026-03-13) diff --git a/backend/apps/accounts/migrations/0005_adminauditlog.py b/backend/apps/accounts/migrations/0005_adminauditlog.py new file mode 100644 index 0000000..8a5d5b2 --- /dev/null +++ b/backend/apps/accounts/migrations/0005_adminauditlog.py @@ -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'], + }, + ), + ] diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py index de4cdf5..03a3b35 100644 --- a/backend/apps/accounts/models.py +++ b/backend/apps/accounts/models.py @@ -54,3 +54,60 @@ class User(AbstractUser): if self.is_team_admin and self.team is not None: return 'team_admin' return 'member' + + +class AdminAuditLog(models.Model): + """管理员操作审计日志""" + ACTION_CHOICES = [ + ('team_create', '创建团队'), + ('team_update', '更新团队'), + ('team_topup', '团队充值'), + ('team_set_pool', '设置团队额度池'), + ('team_create_admin', '创建团队管理员'), + ('user_create', '创建用户'), + ('user_quota_update', '更新用户额度'), + ('user_status_toggle', '切换用户状态'), + ('settings_update', '更新系统设置'), + ('member_create', '创建团队成员'), + ('member_quota_update', '更新成员额度'), + ('member_status_toggle', '切换成员状态'), + ] + + operator = models.ForeignKey( + User, on_delete=models.SET_NULL, + null=True, related_name='audit_logs', + verbose_name='操作人', + ) + operator_name = models.CharField(max_length=150, verbose_name='操作人用户名') + action = models.CharField(max_length=30, choices=ACTION_CHOICES, verbose_name='操作类型') + target_type = models.CharField(max_length=20, verbose_name='目标类型') + target_id = models.IntegerField(null=True, blank=True, verbose_name='目标ID') + target_name = models.CharField(max_length=200, blank=True, default='', verbose_name='目标名称') + before = models.JSONField(null=True, blank=True, verbose_name='变更前') + after = models.JSONField(null=True, blank=True, verbose_name='变更后') + ip_address = models.GenericIPAddressField(null=True, blank=True, verbose_name='IP地址') + created_at = models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='操作时间') + + class Meta: + verbose_name = '审计日志' + verbose_name_plural = '审计日志' + ordering = ['-created_at'] + + def __str__(self): + return f'{self.operator_name} - {self.get_action_display()} - {self.target_name}' + + +def log_admin_action(request, action, target_type, target_id=None, target_name='', before=None, after=None): + """记录管理员操作日志""" + ip = request.META.get('HTTP_X_FORWARDED_FOR', '').split(',')[0].strip() or request.META.get('REMOTE_ADDR') + AdminAuditLog.objects.create( + operator=request.user, + operator_name=request.user.username, + action=action, + target_type=target_type, + target_id=target_id, + target_name=target_name, + before=before, + after=after, + ip_address=ip, + ) diff --git a/backend/apps/generation/models.py b/backend/apps/generation/models.py index 888ed88..b976b5d 100644 --- a/backend/apps/generation/models.py +++ b/backend/apps/generation/models.py @@ -10,8 +10,8 @@ class GenerationRecord(models.Model): ('keyframe', '首尾帧'), ] MODEL_CHOICES = [ - ('seedance_2.0', 'Seedance 2.0'), - ('seedance_2.0_fast', 'Seedance 2.0 Fast'), + ('seedance_2.0', 'AirDrama'), + ('seedance_2.0_fast', 'AirDrama Fast'), ] STATUS_CHOICES = [ ('queued', '排队中'), diff --git a/backend/apps/generation/urls.py b/backend/apps/generation/urls.py index 59cd385..050a4dd 100644 --- a/backend/apps/generation/urls.py +++ b/backend/apps/generation/urls.py @@ -8,6 +8,8 @@ urlpatterns = [ path('video/generate', views.video_generate_view, name='video_generate'), path('video/tasks', views.video_tasks_list_view, name='video_tasks_list'), path('video/tasks/', views.video_task_detail_view, name='video_task_detail'), + # Public announcement + path('announcement', views.announcement_view, name='announcement'), # ── Super Admin: Dashboard ── 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/', views.admin_team_detail_view, name='admin_team_detail'), path('admin/teams//topup', views.admin_team_topup_view, name='admin_team_topup'), + path('admin/teams//set-pool', views.admin_team_set_pool_view, name='admin_team_set_pool'), path('admin/teams//admin', views.admin_team_create_admin_view, name='admin_team_create_admin'), # ── Super Admin: User management ── @@ -26,9 +29,10 @@ urlpatterns = [ path('admin/users//quota', views.admin_user_quota_view, name='admin_user_quota'), path('admin/users//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/settings', views.admin_settings_view, name='admin_settings'), + path('admin/logs', views.admin_audit_logs_view, name='admin_audit_logs'), # ── Team Admin: Team management ── path('team/info', views.team_info_view, name='team_info'), diff --git a/backend/apps/generation/views.py b/backend/apps/generation/views.py index 4cafca7..15ab86f 100644 --- a/backend/apps/generation/views.py +++ b/backend/apps/generation/views.py @@ -21,10 +21,10 @@ from .serializers import ( TeamCreateSerializer, TeamUpdateSerializer, TeamTopUpSerializer, 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 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() logger = logging.getLogger(__name__) @@ -129,7 +129,7 @@ def upload_media_view(request): @api_view(['POST']) @permission_classes([IsTeamMember]) 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.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.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 if django_settings.SEEDANCE_ENABLED and django_settings.ARK_API_KEY: try: @@ -271,10 +271,10 @@ def video_generate_view(request): record.status = 'processing' record.save(update_fields=['ark_task_id', 'status']) except Exception as e: - logger.exception('Seedance API create task failed') + logger.exception('AirDrama API create task failed') record.status = 'failed' - from utils.seedance_client import SeedanceAPIError - if isinstance(e, SeedanceAPIError): + from utils.airdrama_client import AirDramaAPIError + if isinstance(e, AirDramaAPIError): record.error_message = e.user_message else: record.error_message = str(e) @@ -316,15 +316,28 @@ def _refund_quota(record, seconds): @api_view(['GET']) @permission_classes([IsAuthenticated]) 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 - 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') - 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] - return Response({'results': results}) + return Response({ + 'results': results, + 'total': total, + 'has_more': offset + page_size < total, + }) @api_view(['GET', 'DELETE']) @@ -344,7 +357,7 @@ def video_task_detail_view(request, task_id): record.delete() 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', '') if record.status in ('queued', 'processing') and ark_task_id: try: @@ -375,7 +388,7 @@ def video_task_detail_view(request, task_id): record.save(update_fields=['status', 'result_url', 'error_message']) 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)) @@ -472,6 +485,18 @@ def admin_stats_view(request): .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({ 'total_users': total_users, '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} 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) 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({ 'id': team.id, 'name': team.name, @@ -557,9 +589,13 @@ def admin_team_detail_view(request, team_id): if request.method == 'PUT': serializer = TeamUpdateSerializer(data=request.data) 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(): setattr(team, field, value) 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({ 'id': team.id, 'name': team.name, @@ -598,6 +634,7 @@ def admin_team_detail_view(request, team_id): 'monthly_seconds_limit': team.monthly_seconds_limit, 'monthly_seconds_used': monthly_used, 'daily_member_limit_default': team.daily_member_limit_default, + 'member_count': team.members.count(), 'is_active': team.is_active, 'created_at': team.created_at.isoformat(), 'members': [{ @@ -628,12 +665,16 @@ def admin_team_topup_view(request, team_id): serializer.is_valid(raise_exception=True) seconds = serializer.validated_data['seconds'] + old_pool = team.total_seconds_pool with transaction.atomic(): locked = Team.objects.select_for_update().get(pk=team.pk) locked.total_seconds_pool = F('total_seconds_pool') + seconds locked.save(update_fields=['total_seconds_pool']) 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({ 'id': team.id, '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//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']) @permission_classes([IsSuperAdmin]) 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, 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({ 'id': user.id, @@ -800,6 +886,7 @@ def admin_user_detail_view(request, user_id): 'mode': r.mode, 'model': r.model, 'status': r.status, + 'error_message': r.error_message or '', } for r in recent_records ], @@ -818,9 +905,13 @@ def admin_user_quota_view(request, user_id): serializer = QuotaUpdateSerializer(data=request.data) 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.monthly_seconds_limit = serializer.validated_data['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({ 'user_id': user.id, @@ -843,8 +934,11 @@ def admin_user_status_view(request, user_id): serializer = UserStatusSerializer(data=request.data) serializer.is_valid(raise_exception=True) + old_active = user.is_active user.is_active = serializer.validated_data['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({ 'user_id': user.id, @@ -877,6 +971,8 @@ def admin_create_user_view(request): monthly_seconds_limit=serializer.validated_data['monthly_seconds_limit'], 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({ 'id': user.id, @@ -934,6 +1030,7 @@ def admin_records_view(request): 'model': r.model, 'aspect_ratio': r.aspect_ratio, 'status': r.status, + 'error_message': r.error_message or '', }) return Response({ @@ -965,11 +1062,25 @@ def admin_settings_view(request): serializer = SystemSettingsSerializer(data=request.data) 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_monthly_seconds_limit = serializer.validated_data['default_monthly_seconds_limit'] config.announcement = serializer.validated_data.get('announcement', '') config.announcement_enabled = serializer.validated_data.get('announcement_enabled', False) 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({ '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 # ────────────────────────────────────────────── @@ -1117,6 +1297,8 @@ def team_member_create_view(request): daily_seconds_limit=daily, 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({ 'id': user.id, @@ -1168,6 +1350,7 @@ def team_member_detail_view(request, member_id): 'mode': r.mode, 'model': r.model, 'status': r.status, + 'error_message': r.error_message or '', } for r in recent_records ], @@ -1187,9 +1370,13 @@ def team_member_quota_view(request, member_id): serializer = MemberQuotaSerializer(data=request.data) 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.monthly_seconds_limit = serializer.validated_data['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({ 'user_id': member.id, @@ -1216,8 +1403,11 @@ def team_member_status_view(request, member_id): serializer = UserStatusSerializer(data=request.data) serializer.is_valid(raise_exception=True) + old_active = member.is_active member.is_active = serializer.validated_data['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({ 'user_id': member.id, @@ -1321,6 +1511,7 @@ def profile_records_view(request): 'model': r.model, 'aspect_ratio': r.aspect_ratio, 'status': r.status, + 'error_message': r.error_message or '', }) return Response({ diff --git a/backend/utils/seedance_client.py b/backend/utils/airdrama_client.py similarity index 87% rename from backend/utils/seedance_client.py rename to backend/utils/airdrama_client.py index f440ff1..241cd7d 100644 --- a/backend/utils/seedance_client.py +++ b/backend/utils/airdrama_client.py @@ -1,12 +1,12 @@ -"""Volcano Engine Seedance (ARK) video generation API client.""" +"""Volcano Engine ARK video generation API client.""" import requests from django.conf import settings -# Seedance API error code → user-friendly Chinese message +# API error code → user-friendly Chinese message ERROR_MESSAGES = { - 'InputImageSensitiveContentDetected.PrivacyInformation': '参考图片中检测到真实人脸,Seedance 不允许处理包含真人面部的图片', + 'InputImageSensitiveContentDetected.PrivacyInformation': '参考图片中检测到真实人脸,系统不允许处理包含真人面部的图片', 'InputImageSensitiveContentDetected': '参考图片包含敏感内容,请更换图片后重试', 'InputVideoSensitiveContentDetected': '参考视频包含敏感内容,请更换视频后重试', 'InvalidParameter': '请求参数无效,请检查输入', @@ -15,8 +15,8 @@ ERROR_MESSAGES = { } -class SeedanceAPIError(Exception): - """Raised when Seedance API returns an error response.""" +class AirDramaAPIError(Exception): + """Raised when video generation API returns an error response.""" def __init__(self, code, message, status_code=400): self.code = code self.api_message = message @@ -44,7 +44,7 @@ def create_task(prompt, model, content_items, aspect_ratio, duration, generate_a Args: prompt: Text prompt for video generation. - model: Model key ('seedance_2.0' or 'seedance_2.0_fast'). + model: Model key ('airdrama' or 'airdrama_fast'). content_items: List of media content dicts (image_url, video_url, audio_url). aspect_ratio: Video aspect ratio ('16:9', '9:16', etc.). duration: Video duration in seconds. @@ -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) if resp.status_code != 200: - # Extract human-readable error from Seedance API response + # Extract human-readable error from API response try: err = resp.json().get('error', {}) code = err.get('code', '') message = err.get('message', resp.text) except Exception: code, message = '', resp.text - raise SeedanceAPIError(code, message, resp.status_code) + raise AirDramaAPIError(code, message, resp.status_code) return resp.json() diff --git a/backend/utils/tos_client.py b/backend/utils/tos_client.py index a0727ec..377d7f6 100644 --- a/backend/utils/tos_client.py +++ b/backend/utils/tos_client.py @@ -1,8 +1,12 @@ """Volcano Engine TOS file upload utility using official TOS SDK.""" +import hashlib import uuid +import logging from django.conf import settings +logger = logging.getLogger(__name__) + CONTENT_TYPE_MAP = { 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png', @@ -30,14 +34,31 @@ def get_tos_client(): def upload_file(file_obj, folder='uploads'): - """Upload a file to TOS bucket, return its public URL.""" + """Upload a file to TOS bucket with content-hash dedup, return its public URL. + + Uses MD5 hash of file content as the object key. If the same file + has already been uploaded, the existing URL is returned without + re-uploading, saving storage and bandwidth. + """ ext = file_obj.name.rsplit('.', 1)[-1].lower() - key = f'{folder}/{uuid.uuid4().hex}.{ext}' content_type = CONTENT_TYPE_MAP.get(ext, 'application/octet-stream') client = get_tos_client() content = file_obj.read() + # Use content hash as key for dedup + content_hash = hashlib.md5(content).hexdigest() + key = f'{folder}/{content_hash}.{ext}' + url = f'{settings.TOS_CDN_DOMAIN}/{key}' + + # Check if object already exists — skip upload if so + try: + client.head_object(bucket=settings.TOS_BUCKET, key=key) + logger.info('TOS dedup hit: %s', key) + return url + except Exception: + pass # Object doesn't exist, proceed with upload + client.put_object( bucket=settings.TOS_BUCKET, key=key, @@ -45,7 +66,7 @@ def upload_file(file_obj, folder='uploads'): content_type=content_type, ) - return f'{settings.TOS_CDN_DOMAIN}/{key}' + return url def upload_from_url(source_url, folder='results'): diff --git a/docs/changelog.md b/docs/changelog.md index 6f7827e..46b2776 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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//set-pool` 路由 | + +### 新增 API +| Method | Endpoint | Description | +|--------|----------|-------------| +| PUT | `/api/v1/admin/teams//set-pool` | 直接设置团队总秒数池 | + +### 触发原因 +- 团队详情使用右侧抽屉形式,信息拥挤、不符合暗色主题规范 +- 充值秒数填错后无法修改,而这些秒数直接对应金钱 +- 成员数卡片值为空(后端遗漏字段) + +--- + +## 2026-03-16 — v0.8.2: 管理后台 UI 修复(4 项)+ 失败原因展示 + +**状态**: ✅ 已完成 | **验收**: 待测试 + +### 变更内容 +1. **DatePicker 日历透明修复** — `.dropdown` 背景从半透明 `var(--color-bg-card)` 改为不透明 `#16161e` + `backdrop-filter` +2. **自定义 Select 组件** — 替换原生 ` { setActionFilter(v); setPage(1); }} + placeholder="全部操作" + options={ACTION_OPTIONS} + /> + setOperatorSearch(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + /> + + ~ + + + + + +
+ + + + + + + + + + + + + {loading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + {Array.from({ length: 6 }).map((_, j) => ( + + ))} + + )) + ) : logs.length === 0 ? ( + + ) : ( + logs.map((log) => ( + + + + + + + + + )) + )} + +
时间操作人操作类型目标变更详情IP
暂无日志记录
{new Date(log.created_at).toLocaleString('zh-CN')}{log.operator_name}{log.action_display} + {log.target_name || '-'} + {log.target_type && ({log.target_type})} + {renderChanges(log.before, log.after)}{log.ip_address || '-'}
+
+ + {totalPages > 1 && ( +
+ 共 {total} 条 +
+ + {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 ( + + ); + })} + +
+
+ )} + + ); +} diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx index 3094229..cf4820c 100644 --- a/web/src/pages/DashboardPage.tsx +++ b/web/src/pages/DashboardPage.tsx @@ -45,8 +45,8 @@ export function DashboardPage() { if (!stats) return null; const statCards = [ + { label: '总团队数', value: stats.total_teams, change: null }, { label: '总用户数', value: stats.total_users, change: null }, - { label: '今日新增用户', value: stats.new_users_today, change: null }, { label: '今日消费秒数', value: stats.seconds_consumed_today, change: stats.today_change_percent }, { label: '本月消费秒数', value: stats.seconds_consumed_this_month, change: stats.month_change_percent }, ]; @@ -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 barOption: echarts.EChartsCoreOption = { tooltip: { @@ -158,6 +200,15 @@ export function DashboardPage() { + {sortedTeams.length > 0 && ( +
+

团队消费排行(本月)

+
+ +
+
+ )} +

用户消费排行(Top 10 · 本月)

diff --git a/web/src/pages/RecordsPage.module.css b/web/src/pages/RecordsPage.module.css index 18d1a04..aa217a0 100644 --- a/web/src/pages/RecordsPage.module.css +++ b/web/src/pages/RecordsPage.module.css @@ -19,6 +19,10 @@ } .dateInput:focus { border-color: var(--color-primary); } .dateSep { color: var(--color-text-secondary); font-size: 13px; } +.teamSelect { + padding: 8px 12px; background: var(--color-bg-card); border: 1px solid var(--color-border-card); + border-radius: 8px; color: var(--color-text-primary); font-size: 13px; outline: none; +} .searchBtn { padding: 8px 16px; background: var(--color-primary); border: none; border-radius: 8px; color: #fff; font-size: 13px; cursor: pointer; } .searchBtn:hover { opacity: 0.9; } @@ -37,6 +41,16 @@ .statusBadge { padding: 2px 8px; border-radius: 4px; font-size: 12px; } .completed { background: rgba(0, 184, 148, 0.15); color: var(--color-success); } .failed { background: rgba(231, 76, 60, 0.15); color: var(--color-danger); } +.statusCell { position: relative; } +.statusCell:hover .errorTooltip { opacity: 1; visibility: visible; transform: translateX(-50%) translateY(0); } +.errorTooltip { + position: absolute; bottom: calc(100% + 4px); left: 50%; transform: translateX(-50%) translateY(4px); + background: #16161e; border: 1px solid var(--color-border-card); border-radius: 6px; + padding: 6px 10px; font-size: 12px; color: var(--color-danger); white-space: nowrap; + max-width: 300px; overflow: hidden; text-overflow: ellipsis; + opacity: 0; visibility: hidden; transition: all 0.15s; z-index: 10; + pointer-events: none; box-shadow: 0 4px 12px rgba(0,0,0,0.3); +} .queued, .processing { background: rgba(0, 184, 230, 0.15); color: var(--color-primary); } .empty { text-align: center; color: var(--color-text-secondary); padding: 40px; } .skeletonCell { height: 16px; background: var(--color-border-card); border-radius: 4px; animation: pulse 1.5s ease-in-out infinite; } diff --git a/web/src/pages/RecordsPage.tsx b/web/src/pages/RecordsPage.tsx index 39c513f..6d6e537 100644 --- a/web/src/pages/RecordsPage.tsx +++ b/web/src/pages/RecordsPage.tsx @@ -1,7 +1,9 @@ import { useEffect, useState, useCallback } from 'react'; import { adminApi } from '../lib/api'; -import type { AdminRecord } from '../types'; +import type { AdminRecord, Team } from '../types'; import { showToast } from '../components/Toast'; +import { DatePicker } from '../components/DatePicker'; +import { Select } from '../components/Select'; import styles from './RecordsPage.module.css'; export function RecordsPage() { @@ -11,9 +13,16 @@ export function RecordsPage() { const [search, setSearch] = useState(''); const [startDate, setStartDate] = useState(''); const [endDate, setEndDate] = useState(''); + const [teamFilter, setTeamFilter] = useState(''); + const [teams, setTeams] = useState([]); const [loading, setLoading] = useState(true); const pageSize = 20; + // Load teams for filter dropdown + useEffect(() => { + adminApi.getTeams().then(({ data }) => setTeams(data.results)).catch(() => {}); + }, []); + const fetchRecords = useCallback(async () => { setLoading(true); try { @@ -21,6 +30,7 @@ export function RecordsPage() { page, page_size: pageSize, search, start_date: startDate || undefined, end_date: endDate || undefined, + team_id: teamFilter ? Number(teamFilter) : undefined, }); setRecords(data.results); setTotal(data.total); @@ -29,7 +39,7 @@ export function RecordsPage() { } finally { setLoading(false); } - }, [page, search, startDate, endDate]); + }, [page, search, startDate, endDate, teamFilter]); useEffect(() => { fetchRecords(); }, [fetchRecords]); @@ -45,15 +55,17 @@ export function RecordsPage() { page: 1, page_size: 10000, search, start_date: startDate || undefined, end_date: endDate || undefined, + team_id: teamFilter ? Number(teamFilter) : undefined, }); - const header = '时间,用户名,消费秒数,提示词,生成模式,状态\n'; + const header = '时间,团队,用户名,消费秒数,提示词,生成模式,状态,失败原因\n'; const rows = data.results.map((r) => { // Escape CSV fields to prevent injection const prompt = r.prompt.replace(/"/g, '""').replace(/^[=+\-@]/, "'$&"); const modeLabel = r.mode === 'universal' ? '全能参考' : '首尾帧'; const statusLabel = { queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' }[r.status]; - return `${r.created_at},${r.username},"${r.seconds_consumed}","${prompt}","${modeLabel}","${statusLabel}"`; + const errorMsg = (r.error_message || '').replace(/"/g, '""').replace(/^[=+\-@]/, "'$&"); + return `${r.created_at},"${r.team_name || '-'}",${r.username},"${r.seconds_consumed}","${prompt}","${modeLabel}","${statusLabel}","${errorMsg}"`; }).join('\n'); const blob = new Blob(['\uFEFF' + header + rows], { type: 'text/csv;charset=utf-8;' }); @@ -88,19 +100,15 @@ export function RecordsPage() { onChange={(e) => setSearch(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleSearch()} /> - setStartDate(e.target.value)} + setEndDate(e.target.value)} - /> +
@@ -109,6 +117,7 @@ export function RecordsPage() { 时间 + 团队 用户名 消费秒数 视频描述 @@ -120,25 +129,29 @@ export function RecordsPage() { {loading ? ( Array.from({ length: 5 }).map((_, i) => ( - {Array.from({ length: 6 }).map((_, j) => ( + {Array.from({ length: 7 }).map((_, j) => (
))} )) ) : records.length === 0 ? ( - 暂无记录 + 暂无记录 ) : ( records.map((r) => ( {new Date(r.created_at).toLocaleString('zh-CN')} + {r.team_name || '-'} {r.username} {r.seconds_consumed.toLocaleString()}s {r.prompt ? (r.prompt.slice(0, 40) + (r.prompt.length > 40 ? '...' : '')) : '-'} {r.mode === 'universal' ? '全能参考' : '首尾帧'} - + {statusMap[r.status]} + {r.status === 'failed' && r.error_message && ( + {r.error_message} + )} )) diff --git a/web/src/pages/TeamsPage.module.css b/web/src/pages/TeamsPage.module.css index 59dd119..5a8cb76 100644 --- a/web/src/pages/TeamsPage.module.css +++ b/web/src/pages/TeamsPage.module.css @@ -51,7 +51,7 @@ /* Modal */ .modalOverlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 300; } -.modal { background: var(--color-bg-card); border: 1px solid var(--color-border-card); border-radius: var(--radius-card); padding: 24px; width: 400px; max-width: 90vw; } +.modal { background: #16161e; border: 1px solid var(--color-border-card); border-radius: var(--radius-card); padding: 24px; width: 400px; max-width: 90vw; } .modalTitle { font-size: 16px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 20px; } .formGroup { margin-bottom: 16px; } .formGroup label { display: block; color: var(--color-text-secondary); font-size: 13px; margin-bottom: 6px; } @@ -65,28 +65,202 @@ .formError { color: var(--color-danger); font-size: 13px; margin-bottom: 12px; } .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; } -.drawer { - position: fixed; right: 0; top: 0; bottom: 0; width: 560px; max-width: 90vw; - background: var(--color-bg-card); border-left: 1px solid var(--color-border-card); - display: flex; flex-direction: column; z-index: 301; - animation: slideIn 0.2s ease; +/* ══════════════════════════════════════ + Team Detail Modal (follows VideoDetailModal spec) + ══════════════════════════════════════ */ +.detailOverlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.65); + display: flex; + align-items: center; + justify-content: center; + z-index: 300; + animation: overlayIn 0.2s ease-out; +} + +@keyframes overlayIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.detailModal { + background: rgba(22, 22, 30, 0.92); + backdrop-filter: blur(24px) saturate(180%); + -webkit-backdrop-filter: blur(24px) saturate(180%); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + width: 1080px; + max-width: 96vw; + min-height: 70vh; + max-height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255, 255, 255, 0.04) inset; + animation: modalIn 0.25s ease; +} + +@keyframes modalIn { + from { opacity: 0; transform: scale(0.96) translateY(12px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +/* ── Header ── */ +.detailHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 28px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + flex-shrink: 0; +} + +.detailHeader h3 { + font-size: 17px; + font-weight: 600; + color: var(--color-text-primary); + display: flex; + align-items: center; + gap: 10px; + margin: 0; +} + +.detailClose { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.06); + border: none; + color: rgba(255, 255, 255, 0.5); + cursor: pointer; + transition: color 0.15s, background 0.15s; +} + +.detailClose:hover { + color: #fff; + background: rgba(255, 255, 255, 0.12); +} + +/* ── Body ── */ +.detailBody { + flex: 1; + overflow-y: auto; + padding: 28px; +} + +/* ── Stats grid ── */ +.detailGrid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 14px; + margin-bottom: 28px; +} + +.detailItem { + display: flex; + flex-direction: column; + gap: 8px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 10px; + padding: 16px 18px; + transition: background 0.15s; +} + +.detailItem:hover { + background: rgba(255, 255, 255, 0.06); +} + +.detailLabel { + color: #8b8ea8; + font-size: 12px; + font-weight: 500; + line-height: 1; +} + +.detailValue { + color: #f1f0ff; + font-size: 18px; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; +} + +.editPoolBtn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.06); + border: none; + color: rgba(255, 255, 255, 0.4); + cursor: pointer; + transition: color 0.15s, background 0.15s; + flex-shrink: 0; +} + +.editPoolBtn:hover { + color: var(--color-primary); + background: rgba(0, 184, 230, 0.12); +} + +/* ── Members section ── */ +.membersTitle { + font-size: 14px; + font-weight: 600; + color: var(--color-text-primary); + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.memberTableWrapper { + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 10px; + overflow: hidden; +} + +.memberTable { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} + +.memberTable th { + padding: 12px 18px; + text-align: left; + color: #8b8ea8; + font-weight: 500; + font-size: 13px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + background: rgba(255, 255, 255, 0.02); + white-space: nowrap; +} + +.memberTable td { + padding: 14px 18px; + color: #f1f0ff; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); } -@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; } -.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; +} diff --git a/web/src/pages/TeamsPage.tsx b/web/src/pages/TeamsPage.tsx index 69f0f35..c30947f 100644 --- a/web/src/pages/TeamsPage.tsx +++ b/web/src/pages/TeamsPage.tsx @@ -35,6 +35,11 @@ export function TeamsPage() { const [detailTeam, setDetailTeam] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); + // Edit pool modal + const [editPoolOpen, setEditPoolOpen] = useState(false); + const [editPoolValue, setEditPoolValue] = useState(''); + const [editPoolError, setEditPoolError] = useState(''); + // Confirm toggle const [confirmTeam, setConfirmTeam] = useState(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 = () => { setAdminUsername(''); setAdminEmail(''); setAdminPassword(''); setAdminError(''); @@ -303,24 +326,40 @@ export function TeamsPage() { onCancel={() => setConfirmTeam(null)} /> - {/* Team Detail Drawer */} + {/* Team Detail Modal */} {drawerOpen && detailTeam && ( -
{ if (e.target === e.currentTarget) setDrawerOpen(false); }}> -
-
+
{ if (e.target === e.currentTarget) setDrawerOpen(false); }}> +
+

团队详情 — {detailTeam.name} - + {detailTeam.is_active ? '启用' : '禁用'}

- +
-
+
总秒数池 - {fmtSec(detailTeam.total_seconds_pool)} + + {fmtSec(detailTeam.total_seconds_pool)} + +
已消耗 @@ -356,45 +395,68 @@ export function TeamsPage() { {detailTeam.members.length === 0 ? (
暂无成员
) : ( - - - - - - - - - - - - - - {detailTeam.members.map((m) => ( - - - - - - - - +
+
用户名邮箱角色状态日限额今日消费本月消费
{m.username}{m.email} - {m.is_team_admin ? ( - 管理员 - ) : '成员'} - - - {m.is_active ? '启用' : '禁用'} - - {fmtSec(m.daily_seconds_limit)}{fmtSec(m.seconds_today)}{fmtSec(m.seconds_this_month)}
+ + + + + + + + + - ))} - -
用户名邮箱角色状态日限额今日消费本月消费
+ + + {detailTeam.members.map((m) => ( + + {m.username} + {m.email} + + {m.is_team_admin ? ( + 管理员 + ) : '成员'} + + + + {m.is_active ? '启用' : '禁用'} + + + {fmtSec(m.daily_seconds_limit)} + {fmtSec(m.seconds_today)} + {fmtSec(m.seconds_this_month)} + + ))} + + +
)}
)} + + {/* Edit Pool Modal */} + {editPoolOpen && detailTeam && ( +
{ if (e.target === e.currentTarget) setEditPoolOpen(false); }}> +
+

修改总秒数池 — {detailTeam.name}

+ {editPoolError &&
{editPoolError}
} +
+ + setEditPoolValue(e.target.value)} placeholder="输入总秒数" /> +
+ 当前: {fmtSec(detailTeam.total_seconds_pool)} | 已消耗: {fmtSec(detailTeam.total_seconds_used)} | 修改后剩余: {fmtSec(Math.max(0, (Number(editPoolValue) || 0) - detailTeam.total_seconds_used))} +
+
+
+ + +
+
+
+ )}
); } diff --git a/web/src/pages/UsersPage.module.css b/web/src/pages/UsersPage.module.css index 1217bfb..b662a84 100644 --- a/web/src/pages/UsersPage.module.css +++ b/web/src/pages/UsersPage.module.css @@ -63,7 +63,7 @@ /* Modal */ .modalOverlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 300; } -.modal { background: var(--color-bg-card); border: 1px solid var(--color-border-card); border-radius: var(--radius-card); padding: 24px; width: 400px; max-width: 90vw; } +.modal { background: #16161e; border: 1px solid var(--color-border-card); border-radius: var(--radius-card); padding: 24px; width: 400px; max-width: 90vw; } .modalTitle { font-size: 16px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 20px; } .formGroup { margin-bottom: 16px; } .formGroup label { display: block; color: var(--color-text-secondary); font-size: 13px; margin-bottom: 6px; } @@ -82,7 +82,7 @@ .drawerOverlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 300; } .drawer { position: fixed; right: 0; top: 0; bottom: 0; width: 440px; max-width: 90vw; - background: var(--color-bg-card); border-left: 1px solid var(--color-border-card); + background: #16161e; border-left: 1px solid var(--color-border-card); display: flex; flex-direction: column; z-index: 301; animation: slideIn 0.2s ease; } diff --git a/web/src/pages/UsersPage.tsx b/web/src/pages/UsersPage.tsx index d12cd39..e97810d 100644 --- a/web/src/pages/UsersPage.tsx +++ b/web/src/pages/UsersPage.tsx @@ -1,8 +1,9 @@ import { useEffect, useState, useCallback } from 'react'; import { adminApi } from '../lib/api'; -import type { AdminUser, AdminUserDetail } from '../types'; +import type { AdminUser, AdminUserDetail, Team } from '../types'; import { showToast } from '../components/Toast'; import { ConfirmModal } from '../components/ConfirmModal'; +import { Select } from '../components/Select'; import styles from './UsersPage.module.css'; export function UsersPage() { @@ -11,6 +12,8 @@ export function UsersPage() { const [page, setPage] = useState(1); const [search, setSearch] = useState(''); const [statusFilter, setStatusFilter] = useState(''); + const [teamFilter, setTeamFilter] = useState(''); + const [teams, setTeams] = useState([]); const [loading, setLoading] = useState(true); const pageSize = 20; @@ -36,11 +39,17 @@ export function UsersPage() { const [newIsStaff, setNewIsStaff] = useState(false); const [createError, setCreateError] = useState(''); + // Load teams for filter dropdown + useEffect(() => { + adminApi.getTeams().then(({ data }) => setTeams(data.results)).catch(() => {}); + }, []); + const fetchUsers = useCallback(async () => { setLoading(true); try { const { data } = await adminApi.getUsers({ page, page_size: pageSize, search, status: statusFilter, + team_id: teamFilter ? Number(teamFilter) : undefined, }); setUsers(data.results); setTotal(data.total); @@ -49,7 +58,7 @@ export function UsersPage() { } finally { setLoading(false); } - }, [page, search, statusFilter]); + }, [page, search, statusFilter, teamFilter]); useEffect(() => { fetchUsers(); }, [fetchUsers]); @@ -142,15 +151,22 @@ export function UsersPage() { onChange={(e) => setSearch(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleSearch()} /> - + onChange={(v) => { setStatusFilter(v); setPage(1); }} + placeholder="全部状态" + options={[ + { label: '全部状态', value: '' }, + { label: '启用', value: 'active' }, + { label: '禁用', value: 'disabled' }, + ]} + /> +