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:
parent
f803a1ba71
commit
85f76d8543
16
CLAUDE.md
16
CLAUDE.md
@ -145,6 +145,13 @@ jimeng-clone/
|
||||
| PUT | `/api/v1/admin/users/<id>/status` | Toggle user active status |
|
||||
| GET | `/api/v1/admin/records` | List all generation records |
|
||||
| GET/PUT | `/api/v1/admin/settings` | Get/update global settings (QuotaConfig) |
|
||||
| GET | `/api/v1/admin/teams` | List all teams |
|
||||
| POST | `/api/v1/admin/teams/create` | Create new team |
|
||||
| GET/PUT | `/api/v1/admin/teams/<id>` | Get/update team details |
|
||||
| POST | `/api/v1/admin/teams/<id>/topup` | Add seconds to team pool |
|
||||
| PUT | `/api/v1/admin/teams/<id>/set-pool` | Directly set team total seconds pool |
|
||||
| POST | `/api/v1/admin/teams/<id>/admin` | Create team admin user |
|
||||
| GET | `/api/v1/admin/logs` | Audit logs (filter by action/operator/date) |
|
||||
|
||||
### Profile (`/api/v1/profile/`)
|
||||
| Method | Endpoint | Description |
|
||||
@ -164,6 +171,11 @@ jimeng-clone/
|
||||
- `status` (queued|processing|completed|failed), `result_url`, `error_message`, `reference_urls` (JSON)
|
||||
- Index: (user, created_at)
|
||||
|
||||
### AdminAuditLog
|
||||
- `operator` (FK User, SET_NULL), `operator_name` (denormalized), `action` (12 choices)
|
||||
- `target_type`, `target_id`, `target_name`, `before` (JSON), `after` (JSON)
|
||||
- `ip_address`, `created_at` (indexed)
|
||||
|
||||
### QuotaConfig (Singleton, pk=1)
|
||||
- `default_daily_seconds_limit`, `default_monthly_seconds_limit`
|
||||
- `announcement`, `announcement_enabled`, `updated_at`
|
||||
@ -179,6 +191,7 @@ jimeng-clone/
|
||||
| `/admin/users` | UsersPage | Admin | User management |
|
||||
| `/admin/records` | RecordsPage | Admin | Generation records |
|
||||
| `/admin/settings` | SettingsPage | Admin | Global quota & announcement |
|
||||
| `/admin/logs` | AuditLogsPage | Admin | Admin operation audit logs |
|
||||
|
||||
## Incremental Development Guide
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
36
backend/apps/accounts/migrations/0005_adminauditlog.py
Normal file
36
backend/apps/accounts/migrations/0005_adminauditlog.py
Normal file
@ -0,0 +1,36 @@
|
||||
# Generated by Django 4.2.29 on 2026-03-15 17:11
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0004_data_migrate_default_team'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AdminAuditLog',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('operator_name', models.CharField(max_length=150, verbose_name='操作人用户名')),
|
||||
('action', models.CharField(choices=[('team_create', '创建团队'), ('team_update', '更新团队'), ('team_topup', '团队充值'), ('team_set_pool', '设置团队额度池'), ('team_create_admin', '创建团队管理员'), ('user_create', '创建用户'), ('user_quota_update', '更新用户额度'), ('user_status_toggle', '切换用户状态'), ('settings_update', '更新系统设置'), ('member_create', '创建团队成员'), ('member_quota_update', '更新成员额度'), ('member_status_toggle', '切换成员状态')], max_length=30, verbose_name='操作类型')),
|
||||
('target_type', models.CharField(max_length=20, verbose_name='目标类型')),
|
||||
('target_id', models.IntegerField(blank=True, null=True, verbose_name='目标ID')),
|
||||
('target_name', models.CharField(blank=True, default='', max_length=200, verbose_name='目标名称')),
|
||||
('before', models.JSONField(blank=True, null=True, verbose_name='变更前')),
|
||||
('after', models.JSONField(blank=True, null=True, verbose_name='变更后')),
|
||||
('ip_address', models.GenericIPAddressField(blank=True, null=True, verbose_name='IP地址')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='操作时间')),
|
||||
('operator', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='audit_logs', to=settings.AUTH_USER_MODEL, verbose_name='操作人')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '审计日志',
|
||||
'verbose_name_plural': '审计日志',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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', '排队中'),
|
||||
|
||||
@ -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/<uuid:task_id>', 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/<int:team_id>', views.admin_team_detail_view, name='admin_team_detail'),
|
||||
path('admin/teams/<int:team_id>/topup', views.admin_team_topup_view, name='admin_team_topup'),
|
||||
path('admin/teams/<int:team_id>/set-pool', views.admin_team_set_pool_view, name='admin_team_set_pool'),
|
||||
path('admin/teams/<int:team_id>/admin', views.admin_team_create_admin_view, name='admin_team_create_admin'),
|
||||
|
||||
# ── Super Admin: User management ──
|
||||
@ -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>/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'),
|
||||
|
||||
@ -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/<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'])
|
||||
@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({
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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'):
|
||||
|
||||
@ -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 数据清理
|
||||
|
||||
**状态**: ✅ 已完成 | **验收**: ✅ 通过(本地端到端测试)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AmbientBackground } from './components/AmbientBackground';
|
||||
import { Toast } from './components/Toast';
|
||||
import { VideoGenerationPage } from './components/VideoGenerationPage';
|
||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
@ -11,6 +12,7 @@ import { TeamsPage } from './pages/TeamsPage';
|
||||
import { UsersPage } from './pages/UsersPage';
|
||||
import { RecordsPage } from './pages/RecordsPage';
|
||||
import { SettingsPage } from './pages/SettingsPage';
|
||||
import { AuditLogsPage } from './pages/AuditLogsPage';
|
||||
import { ProfilePage } from './pages/ProfilePage';
|
||||
import { AssetsPage } from './pages/AssetsPage';
|
||||
|
||||
@ -30,6 +32,7 @@ export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AmbientBackground />
|
||||
<Toast />
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
@ -71,6 +74,7 @@ export default function App() {
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="records" element={<RecordsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="logs" element={<AuditLogsPage />} />
|
||||
</Route>
|
||||
{/* Team Admin routes */}
|
||||
<Route
|
||||
|
||||
65
web/src/components/AnnouncementBanner.module.css
Normal file
65
web/src/components/AnnouncementBanner.module.css
Normal file
@ -0,0 +1,65 @@
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
background: linear-gradient(90deg, rgba(108, 99, 255, 0.10), rgba(0, 184, 230, 0.08));
|
||||
border-left: 3px solid var(--color-primary);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
font-size: 13px;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.marqueeWrapper {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
mask-image: linear-gradient(90deg, transparent, #000 5%, #000 95%, transparent);
|
||||
-webkit-mask-image: linear-gradient(90deg, transparent, #000 5%, #000 95%, transparent);
|
||||
}
|
||||
|
||||
.marqueeText {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
animation: marquee 20s linear infinite;
|
||||
padding-left: 100%;
|
||||
}
|
||||
|
||||
.marqueeWrapper:hover .marqueeText {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.closeBtn:hover {
|
||||
color: var(--color-text-primary);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
34
web/src/components/AnnouncementBanner.tsx
Normal file
34
web/src/components/AnnouncementBanner.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { videoApi } from '../lib/api';
|
||||
import styles from './AnnouncementBanner.module.css';
|
||||
|
||||
export function AnnouncementBanner() {
|
||||
const [text, setText] = useState('');
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
videoApi.getAnnouncement().then(({ data }) => {
|
||||
if (data.enabled && data.announcement) {
|
||||
setText(data.announcement);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
if (!text || dismissed) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.banner}>
|
||||
<svg className={styles.icon} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M22 17H2a3 3 0 0 0 3-3V9a7 7 0 0 1 14 0v5a3 3 0 0 0 3 3zm-8.27 4a2 2 0 0 1-3.46 0" />
|
||||
</svg>
|
||||
<div className={styles.marqueeWrapper}>
|
||||
<span className={styles.marqueeText}>{text}</span>
|
||||
</div>
|
||||
<button className={styles.closeBtn} onClick={() => setDismissed(true)} title="关闭">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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; }
|
||||
.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; }
|
||||
.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; }
|
||||
|
||||
146
web/src/components/DatePicker.module.css
Normal file
146
web/src/components/DatePicker.module.css
Normal file
@ -0,0 +1,146 @@
|
||||
.wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border-card);
|
||||
border-radius: 8px;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
min-width: 130px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.trigger:hover,
|
||||
.trigger:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
|
||||
.calendarIcon {
|
||||
color: var(--color-text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.clearBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.clearBtn:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
background: #16161e;
|
||||
border: 1px solid var(--color-border-card);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
padding: 12px;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.navBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.navBtn:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.monthLabel {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.weekRow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
text-align: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.weekDay {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-disabled);
|
||||
padding: 4px 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.daysGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.dayCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
|
||||
.dayCell:hover {
|
||||
background: rgba(0, 184, 230, 0.12);
|
||||
}
|
||||
|
||||
.otherMonth {
|
||||
color: var(--color-text-disabled);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.today {
|
||||
border: 1px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.selected {
|
||||
background: var(--color-primary) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
147
web/src/components/DatePicker.tsx
Normal file
147
web/src/components/DatePicker.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import styles from './DatePicker.module.css';
|
||||
|
||||
interface DatePickerProps {
|
||||
value: string; // 'YYYY-MM-DD' or ''
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function DatePicker({ value, onChange, placeholder = '选择日期' }: DatePickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const today = new Date();
|
||||
const selected = value ? new Date(value + 'T00:00:00') : null;
|
||||
const [viewYear, setViewYear] = useState(selected?.getFullYear() ?? today.getFullYear());
|
||||
const [viewMonth, setViewMonth] = useState(selected?.getMonth() ?? today.getMonth());
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handle = (e: MouseEvent) => {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handle);
|
||||
return () => document.removeEventListener('mousedown', handle);
|
||||
}, [open]);
|
||||
|
||||
const prevMonth = () => {
|
||||
if (viewMonth === 0) { setViewMonth(11); setViewYear(viewYear - 1); }
|
||||
else setViewMonth(viewMonth - 1);
|
||||
};
|
||||
const nextMonth = () => {
|
||||
if (viewMonth === 11) { setViewMonth(0); setViewYear(viewYear + 1); }
|
||||
else setViewMonth(viewMonth + 1);
|
||||
};
|
||||
|
||||
// Build calendar grid
|
||||
const firstDay = new Date(viewYear, viewMonth, 1).getDay(); // 0=Sun
|
||||
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||
const daysInPrevMonth = new Date(viewYear, viewMonth, 0).getDate();
|
||||
|
||||
const cells: { day: number; month: number; year: number; isOther: boolean }[] = [];
|
||||
|
||||
// Previous month padding
|
||||
for (let i = firstDay - 1; i >= 0; i--) {
|
||||
const m = viewMonth === 0 ? 11 : viewMonth - 1;
|
||||
const y = viewMonth === 0 ? viewYear - 1 : viewYear;
|
||||
cells.push({ day: daysInPrevMonth - i, month: m, year: y, isOther: true });
|
||||
}
|
||||
// Current month
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
cells.push({ day: d, month: viewMonth, year: viewYear, isOther: false });
|
||||
}
|
||||
// Next month padding
|
||||
const remaining = 42 - cells.length;
|
||||
for (let d = 1; d <= remaining; d++) {
|
||||
const m = viewMonth === 11 ? 0 : viewMonth + 1;
|
||||
const y = viewMonth === 11 ? viewYear + 1 : viewYear;
|
||||
cells.push({ day: d, month: m, year: y, isOther: true });
|
||||
}
|
||||
|
||||
const fmt = (y: number, m: number, d: number) =>
|
||||
`${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||
|
||||
const todayStr = fmt(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
const weekDays = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
const monthNames = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper} ref={wrapperRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.trigger}
|
||||
onClick={() => {
|
||||
if (!open && selected) {
|
||||
setViewYear(selected.getFullYear());
|
||||
setViewMonth(selected.getMonth());
|
||||
}
|
||||
setOpen(!open);
|
||||
}}
|
||||
>
|
||||
<svg className={styles.calendarIcon} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
<path d="M16 2v4M8 2v4M3 10h18" />
|
||||
</svg>
|
||||
{value ? (
|
||||
<span>{value}</span>
|
||||
) : (
|
||||
<span className={styles.placeholder}>{placeholder}</span>
|
||||
)}
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.clearBtn}
|
||||
onClick={(e) => { e.stopPropagation(); onChange(''); setOpen(false); }}
|
||||
title="清除"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className={styles.dropdown}>
|
||||
<div className={styles.header}>
|
||||
<button type="button" className={styles.navBtn} onClick={prevMonth}><</button>
|
||||
<span className={styles.monthLabel}>{viewYear}年 {monthNames[viewMonth]}</span>
|
||||
<button type="button" className={styles.navBtn} onClick={nextMonth}>></button>
|
||||
</div>
|
||||
<div className={styles.weekRow}>
|
||||
{weekDays.map((d) => <span key={d} className={styles.weekDay}>{d}</span>)}
|
||||
</div>
|
||||
<div className={styles.daysGrid}>
|
||||
{cells.map((c, i) => {
|
||||
const dateStr = fmt(c.year, c.month, c.day);
|
||||
const isToday = dateStr === todayStr;
|
||||
const isSelected = dateStr === value;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className={[
|
||||
styles.dayCell,
|
||||
c.isOther ? styles.otherMonth : '',
|
||||
isToday ? styles.today : '',
|
||||
isSelected ? styles.selected : '',
|
||||
].join(' ')}
|
||||
onClick={() => {
|
||||
onChange(dateStr);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{c.day}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -325,7 +325,7 @@
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--color-primary), #8b5cf6);
|
||||
border-radius: 2px;
|
||||
transition: width 0.6s ease;
|
||||
transition: width 1.5s ease-out;
|
||||
}
|
||||
|
||||
.progressText {
|
||||
|
||||
@ -293,7 +293,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
|
||||
style={{ width: `${task.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={styles.progressText}>{task.progress}%</span>
|
||||
<span className={styles.progressText}>{Math.round(task.progress)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
) : task.status === 'failed' ? (
|
||||
|
||||
@ -149,6 +149,25 @@ export function PromptInput() {
|
||||
setEditorHtml(el.innerHTML);
|
||||
}, [setPrompt, setEditorHtml]);
|
||||
|
||||
// Remove orphaned mention spans when a reference is deleted
|
||||
useEffect(() => {
|
||||
const el = editorRef.current;
|
||||
if (!el) return;
|
||||
const refIds = new Set(references.map((r) => r.id));
|
||||
const spans = el.querySelectorAll<HTMLElement>('[data-ref-id]');
|
||||
let changed = false;
|
||||
spans.forEach((span) => {
|
||||
if (!refIds.has(span.dataset.refId!)) {
|
||||
span.replaceWith('');
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (changed) {
|
||||
el.normalize();
|
||||
extractText();
|
||||
}
|
||||
}, [references, extractText]);
|
||||
|
||||
const handleInput = useCallback(() => {
|
||||
extractText();
|
||||
|
||||
@ -241,17 +260,32 @@ export function PromptInput() {
|
||||
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
||||
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
|
||||
const el = editorRef.current;
|
||||
if (el && references.length > 0) {
|
||||
rebuildMentionSpans(el);
|
||||
// Handle pasted image files (Ctrl+V screenshot / copied image)
|
||||
const items = e.clipboardData.items;
|
||||
const imageFiles: File[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.startsWith('image/')) {
|
||||
const file = items[i].getAsFile();
|
||||
if (file) imageFiles.push(file);
|
||||
}
|
||||
}
|
||||
if (imageFiles.length > 0) {
|
||||
useInputBarStore.getState().addReferences(imageFiles);
|
||||
return;
|
||||
}
|
||||
|
||||
// Plain text paste — strip @label patterns to prevent duplicate mention tags
|
||||
let text = e.clipboardData.getData('text/plain');
|
||||
for (const ref of references) {
|
||||
const pattern = `@${ref.label}`;
|
||||
while (text.includes(pattern)) {
|
||||
text = text.replace(pattern, ref.label);
|
||||
}
|
||||
}
|
||||
document.execCommand('insertText', false, text);
|
||||
extractText();
|
||||
}, [extractText, references, rebuildMentionSpans]);
|
||||
}, [extractText, references]);
|
||||
|
||||
// Mention hover — delegated event
|
||||
const handleMouseOver = useCallback((e: React.MouseEvent) => {
|
||||
|
||||
116
web/src/components/Select.module.css
Normal file
116
web/src/components/Select.module.css
Normal file
@ -0,0 +1,116 @@
|
||||
.wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border-card);
|
||||
border-radius: 8px;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
min-width: 120px;
|
||||
transition: border-color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.trigger:hover,
|
||||
.trigger:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
flex: 1;
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-secondary);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.arrowOpen {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
background: #16161e;
|
||||
border: 1px solid var(--color-border-card);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
pointer-events: none;
|
||||
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.open {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #b0b0c0;
|
||||
cursor: pointer;
|
||||
transition: all 0.12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.item.selected {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.check {
|
||||
margin-left: auto;
|
||||
opacity: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.item.selected .check {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
.menu::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.menu::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.menu::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2px;
|
||||
}
|
||||
81
web/src/components/Select.tsx
Normal file
81
web/src/components/Select.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import styles from './Select.module.css';
|
||||
|
||||
interface SelectOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface SelectProps {
|
||||
options: SelectOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
minWidth?: number;
|
||||
}
|
||||
|
||||
export function Select({ options, value, onChange, placeholder = '请选择', minWidth = 120 }: SelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handle = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handle);
|
||||
return () => document.removeEventListener('mousedown', handle);
|
||||
}, [open]);
|
||||
|
||||
const selected = options.find((o) => o.value === value);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper} ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.trigger}
|
||||
style={{ minWidth }}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
{selected ? (
|
||||
<span className={styles.label}>{selected.label}</span>
|
||||
) : (
|
||||
<span className={styles.placeholder}>{placeholder}</span>
|
||||
)}
|
||||
<svg
|
||||
className={`${styles.arrow} ${open ? styles.arrowOpen : ''}`}
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
className={`${styles.menu} ${open ? styles.open : ''}`}
|
||||
style={{ minWidth }}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<div
|
||||
key={opt.value}
|
||||
className={`${styles.item} ${value === opt.value ? styles.selected : ''}`}
|
||||
onClick={() => {
|
||||
onChange(opt.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span>{opt.label}</span>
|
||||
<svg className={styles.check} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -40,3 +40,15 @@
|
||||
gap: 20px;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.loadMoreWrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.loadMoreText {
|
||||
color: var(--color-text-disabled);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { useRef, useEffect, useState, useMemo } from 'react';
|
||||
import { useRef, useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { InputBar } from './InputBar';
|
||||
import { GenerationCard } from './GenerationCard';
|
||||
import { Toast } from './Toast';
|
||||
import { VideoDetailModal } from './VideoDetailModal';
|
||||
import { AnnouncementBanner } from './AnnouncementBanner';
|
||||
import { useGenerationStore } from '../store/generation';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import type { GenerationTask } from '../types';
|
||||
@ -12,12 +12,17 @@ import styles from './VideoGenerationPage.module.css';
|
||||
export function VideoGenerationPage() {
|
||||
const tasks = useGenerationStore((s) => s.tasks);
|
||||
const loadTasks = useGenerationStore((s) => s.loadTasks);
|
||||
const loadMore = useGenerationStore((s) => s.loadMore);
|
||||
const isLoadingMore = useGenerationStore((s) => s.isLoadingMore);
|
||||
const teamDisabled = useAuthStore((s) => s.teamDisabled);
|
||||
const reEdit = useGenerationStore((s) => s.reEdit);
|
||||
const regenerate = useGenerationStore((s) => s.regenerate);
|
||||
const removeTask = useGenerationStore((s) => s.removeTask);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const prevCountRef = useRef(tasks.length);
|
||||
const initialLoadRef = useRef(true);
|
||||
const savedScrollTop = useGenerationStore((s) => s.savedScrollTop);
|
||||
const saveScrollPosition = useGenerationStore((s) => s.saveScrollPosition);
|
||||
const [detailTask, setDetailTask] = useState<GenerationTask | null>(null);
|
||||
|
||||
// Load tasks from backend on mount (persist across page refresh)
|
||||
@ -25,13 +30,46 @@ export function VideoGenerationPage() {
|
||||
loadTasks();
|
||||
}, [loadTasks]);
|
||||
|
||||
// Auto-scroll to top when new task is added
|
||||
// Restore scroll position after initial load, or scroll to bottom for new tasks
|
||||
useEffect(() => {
|
||||
if (tasks.length === 0) return;
|
||||
if (initialLoadRef.current) {
|
||||
initialLoadRef.current = false;
|
||||
// Use requestAnimationFrame to ensure DOM has rendered
|
||||
requestAnimationFrame(() => {
|
||||
if (savedScrollTop !== null && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = savedScrollTop;
|
||||
} else if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
});
|
||||
prevCountRef.current = tasks.length;
|
||||
return;
|
||||
}
|
||||
if (tasks.length > prevCountRef.current && scrollRef.current) {
|
||||
scrollRef.current.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
|
||||
}
|
||||
prevCountRef.current = tasks.length;
|
||||
}, [tasks.length]);
|
||||
}, [tasks.length, savedScrollTop]);
|
||||
|
||||
// Save scroll position + auto-load older tasks when scrolled near top
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!scrollRef.current) return;
|
||||
saveScrollPosition(scrollRef.current.scrollTop);
|
||||
|
||||
// Trigger loadMore when scrolled within 100px of the top
|
||||
if (scrollRef.current.scrollTop < 100) {
|
||||
const el = scrollRef.current;
|
||||
const prevHeight = el.scrollHeight;
|
||||
loadMore().then(() => {
|
||||
// After older tasks are prepended, restore visual position so user doesn't jump
|
||||
requestAnimationFrame(() => {
|
||||
const diff = el.scrollHeight - prevHeight;
|
||||
if (diff > 0) el.scrollTop += diff;
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [saveScrollPosition, loadMore]);
|
||||
|
||||
const handleReEdit = (id: string) => {
|
||||
reEdit(id);
|
||||
@ -80,13 +118,19 @@ export function VideoGenerationPage() {
|
||||
<div className={styles.layout}>
|
||||
<Sidebar />
|
||||
<main className={styles.main}>
|
||||
<div className={styles.contentArea} ref={scrollRef}>
|
||||
<AnnouncementBanner />
|
||||
<div className={styles.contentArea} ref={scrollRef} onScroll={handleScroll}>
|
||||
{tasks.length === 0 ? (
|
||||
<div className={styles.emptyArea}>
|
||||
<p className={styles.emptyHint}>在下方输入提示词,开始创作 AI 视频</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.taskList}>
|
||||
{isLoadingMore && (
|
||||
<div className={styles.loadMoreWrap}>
|
||||
<span className={styles.loadMoreText}>加载中…</span>
|
||||
</div>
|
||||
)}
|
||||
{tasks.map((task) => (
|
||||
<GenerationCard
|
||||
key={task.id}
|
||||
@ -99,7 +143,6 @@ export function VideoGenerationPage() {
|
||||
</div>
|
||||
<InputBar />
|
||||
</main>
|
||||
<Toast />
|
||||
<VideoDetailModal
|
||||
task={detailTask}
|
||||
onClose={() => setDetailTask(null)}
|
||||
|
||||
@ -3,6 +3,7 @@ import type {
|
||||
User, Quota, AuthTokens, AdminStats, AdminUser, AdminUserDetail,
|
||||
AdminRecord, SystemSettings, ProfileOverview, PaginatedResponse,
|
||||
BackendTask, TeamInfo, Team, TeamDetail, TeamMember, TeamStats,
|
||||
AuditLog,
|
||||
} from '../types';
|
||||
import { reportError } from './logCenter';
|
||||
|
||||
@ -107,14 +108,17 @@ export const videoApi = {
|
||||
seconds_consumed: number;
|
||||
}>('/video/generate', data),
|
||||
|
||||
getTasks: () =>
|
||||
api.get<{ results: BackendTask[] }>('/video/tasks'),
|
||||
getTasks: (params?: { page_size?: number; offset?: number }) =>
|
||||
api.get<{ results: BackendTask[]; total: number; has_more: boolean }>('/video/tasks', { params }),
|
||||
|
||||
getTaskStatus: (taskId: string) =>
|
||||
api.get<BackendTask>(`/video/tasks/${taskId}`),
|
||||
|
||||
deleteTask: (taskId: string) =>
|
||||
api.delete(`/video/tasks/${taskId}`),
|
||||
|
||||
getAnnouncement: () =>
|
||||
api.get<{ announcement: string; enabled: boolean }>('/announcement'),
|
||||
};
|
||||
|
||||
// Admin APIs (Super Admin)
|
||||
@ -138,6 +142,9 @@ export const adminApi = {
|
||||
topUpTeam: (teamId: number, seconds: number) =>
|
||||
api.post(`/admin/teams/${teamId}/topup`, { seconds }),
|
||||
|
||||
setTeamPool: (teamId: number, totalSecondsPool: number) =>
|
||||
api.put(`/admin/teams/${teamId}/set-pool`, { total_seconds_pool: totalSecondsPool }),
|
||||
|
||||
createTeamAdmin: (teamId: number, data: { username: string; email: string; password: string }) =>
|
||||
api.post(`/admin/teams/${teamId}/admin`, data),
|
||||
|
||||
@ -188,6 +195,16 @@ export const adminApi = {
|
||||
|
||||
updateSettings: (settings: SystemSettings) =>
|
||||
api.put<SystemSettings & { updated_at: string }>('/admin/settings', settings),
|
||||
|
||||
getAuditLogs: (params: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
action?: string;
|
||||
operator?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
} = {}) =>
|
||||
api.get<PaginatedResponse<AuditLog> & { total_pages: number }>('/admin/logs', { params }),
|
||||
};
|
||||
|
||||
// Team Admin APIs
|
||||
|
||||
@ -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/records', label: '消费记录', icon: 'M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-2 10h-4v4h-2v-4H7v-2h4V7h2v4h4v2z' },
|
||||
{ path: '/admin/settings', label: '系统设置', icon: 'M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.488.488 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z' },
|
||||
{ path: '/admin/logs', label: '操作日志', icon: 'M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z' },
|
||||
];
|
||||
|
||||
export function AdminLayout() {
|
||||
|
||||
54
web/src/pages/AuditLogsPage.module.css
Normal file
54
web/src/pages/AuditLogsPage.module.css
Normal file
@ -0,0 +1,54 @@
|
||||
.page { max-width: 1200px; }
|
||||
.title { font-size: 22px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 20px; }
|
||||
|
||||
.filters { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.searchInput {
|
||||
padding: 8px 12px; background: var(--color-bg-card); border: 1px solid var(--color-border-card);
|
||||
border-radius: 8px; color: var(--color-text-primary); font-size: 13px; width: 160px; outline: none;
|
||||
}
|
||||
.searchInput:focus { border-color: var(--color-primary); }
|
||||
.dateSep { color: var(--color-text-secondary); font-size: 13px; }
|
||||
.searchBtn { padding: 8px 16px; background: var(--color-primary); border: none; border-radius: 8px; color: #fff; font-size: 13px; cursor: pointer; }
|
||||
.searchBtn:hover { opacity: 0.9; }
|
||||
.refreshBtn {
|
||||
padding: 8px 16px; border-radius: 8px; font-size: 13px; cursor: pointer; transition: all 0.15s;
|
||||
background: transparent; border: 1px solid var(--color-border-card); color: var(--color-text-secondary);
|
||||
}
|
||||
.refreshBtn:hover { background: var(--color-sidebar-hover); }
|
||||
|
||||
.tableWrapper {
|
||||
background: var(--color-bg-card); border: 1px solid var(--color-border-card);
|
||||
border-radius: var(--radius-card); overflow-x: auto;
|
||||
}
|
||||
.table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.table th { padding: 12px 16px; text-align: left; color: var(--color-text-secondary); font-weight: 500; border-bottom: 1px solid var(--color-border-card); white-space: nowrap; }
|
||||
.table td { padding: 12px 16px; color: var(--color-text-primary); border-bottom: 1px solid rgba(42, 42, 56, 0.5); vertical-align: top; }
|
||||
.table tr:last-child td { border-bottom: none; }
|
||||
.table tr:hover td { background: rgba(255, 255, 255, 0.02); }
|
||||
|
||||
.timeCell { white-space: nowrap; font-size: 12px; color: var(--color-text-secondary); }
|
||||
.actionBadge { padding: 2px 8px; border-radius: 4px; font-size: 12px; background: rgba(0, 184, 230, 0.12); color: var(--color-primary); white-space: nowrap; }
|
||||
.targetCell { max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ipCell { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--color-text-secondary); white-space: nowrap; }
|
||||
|
||||
.changeDetail { font-size: 12px; line-height: 1.6; }
|
||||
.changeItem { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.changeField { color: #8b8ea8; }
|
||||
.changeOld { color: var(--color-danger); text-decoration: line-through; }
|
||||
.changeArrow { color: #8b8ea8; }
|
||||
.changeNew { color: var(--color-success); }
|
||||
|
||||
.empty { text-align: center; color: var(--color-text-secondary); padding: 40px; }
|
||||
.skeletonCell { height: 16px; background: var(--color-border-card); border-radius: 4px; animation: pulse 1.5s ease-in-out infinite; }
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||
|
||||
.pagination { display: flex; justify-content: space-between; align-items: center; margin-top: 16px; }
|
||||
.pageInfo { color: var(--color-text-secondary); font-size: 13px; }
|
||||
.pageButtons { display: flex; gap: 4px; }
|
||||
.pageButtons button {
|
||||
padding: 6px 12px; background: var(--color-bg-card); border: 1px solid var(--color-border-card);
|
||||
border-radius: 6px; color: var(--color-text-secondary); font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.pageButtons button:hover:not(:disabled) { background: var(--color-sidebar-hover); color: var(--color-text-primary); }
|
||||
.pageButtons button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.activePage { background: var(--color-primary) !important; color: #fff !important; border-color: var(--color-primary) !important; }
|
||||
199
web/src/pages/AuditLogsPage.tsx
Normal file
199
web/src/pages/AuditLogsPage.tsx
Normal file
@ -0,0 +1,199 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { adminApi } from '../lib/api';
|
||||
import type { AuditLog } from '../types';
|
||||
import { showToast } from '../components/Toast';
|
||||
import { DatePicker } from '../components/DatePicker';
|
||||
import { Select } from '../components/Select';
|
||||
import styles from './AuditLogsPage.module.css';
|
||||
|
||||
const ACTION_OPTIONS = [
|
||||
{ label: '全部操作', value: '' },
|
||||
{ label: '创建团队', value: 'team_create' },
|
||||
{ label: '更新团队', value: 'team_update' },
|
||||
{ label: '团队充值', value: 'team_topup' },
|
||||
{ label: '设置额度池', value: 'team_set_pool' },
|
||||
{ label: '创建团队管理员', value: 'team_create_admin' },
|
||||
{ label: '创建用户', value: 'user_create' },
|
||||
{ label: '更新用户额度', value: 'user_quota_update' },
|
||||
{ label: '切换用户状态', value: 'user_status_toggle' },
|
||||
{ label: '更新系统设置', value: 'settings_update' },
|
||||
{ label: '创建团队成员', value: 'member_create' },
|
||||
{ label: '更新成员额度', value: 'member_quota_update' },
|
||||
{ label: '切换成员状态', value: 'member_status_toggle' },
|
||||
];
|
||||
|
||||
function renderChanges(before: Record<string, unknown> | null, after: Record<string, unknown> | null) {
|
||||
if (!before && !after) return '-';
|
||||
const fields = new Set([...Object.keys(before || {}), ...Object.keys(after || {})]);
|
||||
if (fields.size === 0) return '-';
|
||||
|
||||
return (
|
||||
<div className={styles.changeDetail}>
|
||||
{[...fields].map((field) => {
|
||||
const oldVal = before?.[field];
|
||||
const newVal = after?.[field];
|
||||
if (oldVal === undefined && newVal !== undefined) {
|
||||
return (
|
||||
<div key={field} className={styles.changeItem}>
|
||||
<span className={styles.changeField}>{field}:</span>
|
||||
<span className={styles.changeNew}>{String(newVal)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (oldVal !== undefined && newVal !== undefined && String(oldVal) !== String(newVal)) {
|
||||
return (
|
||||
<div key={field} className={styles.changeItem}>
|
||||
<span className={styles.changeField}>{field}:</span>
|
||||
<span className={styles.changeOld}>{String(oldVal)}</span>
|
||||
<span className={styles.changeArrow}>→</span>
|
||||
<span className={styles.changeNew}>{String(newVal)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (oldVal === undefined && newVal === undefined) return null;
|
||||
// Same value, show as-is for create actions
|
||||
if (oldVal === undefined) {
|
||||
return (
|
||||
<div key={field} className={styles.changeItem}>
|
||||
<span className={styles.changeField}>{field}:</span>
|
||||
<span className={styles.changeNew}>{String(newVal)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuditLogsPage() {
|
||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [actionFilter, setActionFilter] = useState('');
|
||||
const [operatorSearch, setOperatorSearch] = useState('');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const pageSize = 20;
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await adminApi.getAuditLogs({
|
||||
page, page_size: pageSize,
|
||||
action: actionFilter || undefined,
|
||||
operator: operatorSearch || undefined,
|
||||
start_date: startDate || undefined,
|
||||
end_date: endDate || undefined,
|
||||
});
|
||||
setLogs(data.results);
|
||||
setTotal(data.total);
|
||||
} catch {
|
||||
showToast('加载审计日志失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, actionFilter, operatorSearch, startDate, endDate]);
|
||||
|
||||
useEffect(() => { fetchLogs(); }, [fetchLogs]);
|
||||
|
||||
const handleSearch = () => {
|
||||
setPage(1);
|
||||
fetchLogs();
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<h1 className={styles.title}>操作日志</h1>
|
||||
|
||||
<div className={styles.filters}>
|
||||
<Select
|
||||
value={actionFilter}
|
||||
onChange={(v) => { setActionFilter(v); setPage(1); }}
|
||||
placeholder="全部操作"
|
||||
options={ACTION_OPTIONS}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.searchInput}
|
||||
placeholder="按操作人搜索..."
|
||||
value={operatorSearch}
|
||||
onChange={(e) => setOperatorSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
<DatePicker value={startDate} onChange={setStartDate} placeholder="开始日期" />
|
||||
<span className={styles.dateSep}>~</span>
|
||||
<DatePicker value={endDate} onChange={setEndDate} placeholder="结束日期" />
|
||||
<button className={styles.searchBtn} onClick={handleSearch}>查询</button>
|
||||
<button className={styles.refreshBtn} onClick={fetchLogs}>刷新</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>操作人</th>
|
||||
<th>操作类型</th>
|
||||
<th>目标</th>
|
||||
<th>变更详情</th>
|
||||
<th>IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
{Array.from({ length: 6 }).map((_, j) => (
|
||||
<td key={j}><div className={styles.skeletonCell} /></td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : logs.length === 0 ? (
|
||||
<tr><td colSpan={6} className={styles.empty}>暂无日志记录</td></tr>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<tr key={log.id}>
|
||||
<td className={styles.timeCell}>{new Date(log.created_at).toLocaleString('zh-CN')}</td>
|
||||
<td>{log.operator_name}</td>
|
||||
<td><span className={styles.actionBadge}>{log.action_display}</span></td>
|
||||
<td className={styles.targetCell}>
|
||||
{log.target_name || '-'}
|
||||
{log.target_type && <span style={{ color: '#8b8ea8', fontSize: 11, marginLeft: 4 }}>({log.target_type})</span>}
|
||||
</td>
|
||||
<td>{renderChanges(log.before, log.after)}</td>
|
||||
<td className={styles.ipCell}>{log.ip_address || '-'}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className={styles.pagination}>
|
||||
<span className={styles.pageInfo}>共 {total} 条</span>
|
||||
<div className={styles.pageButtons}>
|
||||
<button disabled={page <= 1} onClick={() => setPage(page - 1)}><</button>
|
||||
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
|
||||
let p: number;
|
||||
if (totalPages <= 5) p = i + 1;
|
||||
else if (page <= 3) p = i + 1;
|
||||
else if (page >= totalPages - 2) p = totalPages - 4 + i;
|
||||
else p = page - 2 + i;
|
||||
return (
|
||||
<button key={p} className={page === p ? styles.activePage : ''} onClick={() => setPage(p)}>
|
||||
{p}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button disabled={page >= totalPages} onClick={() => setPage(page + 1)}>></button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sortedTeams.length > 0 && (
|
||||
<div className={styles.chartSection}>
|
||||
<h2 className={styles.sectionTitle}>团队消费排行(本月)</h2>
|
||||
<div className={styles.chartWrapper}>
|
||||
<ReactEChartsCore echarts={echarts} option={teamBarOption} style={{ height: Math.max(200, sortedTeams.length * 36) }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.chartSection}>
|
||||
<h2 className={styles.sectionTitle}>用户消费排行(Top 10 · 本月)</h2>
|
||||
<div className={styles.chartWrapper}>
|
||||
|
||||
@ -19,6 +19,10 @@
|
||||
}
|
||||
.dateInput:focus { border-color: var(--color-primary); }
|
||||
.dateSep { color: var(--color-text-secondary); font-size: 13px; }
|
||||
.teamSelect {
|
||||
padding: 8px 12px; background: var(--color-bg-card); border: 1px solid var(--color-border-card);
|
||||
border-radius: 8px; color: var(--color-text-primary); font-size: 13px; outline: none;
|
||||
}
|
||||
.searchBtn { padding: 8px 16px; background: var(--color-primary); border: none; border-radius: 8px; color: #fff; font-size: 13px; cursor: pointer; }
|
||||
.searchBtn:hover { opacity: 0.9; }
|
||||
|
||||
@ -37,6 +41,16 @@
|
||||
.statusBadge { padding: 2px 8px; border-radius: 4px; font-size: 12px; }
|
||||
.completed { background: rgba(0, 184, 148, 0.15); color: var(--color-success); }
|
||||
.failed { background: rgba(231, 76, 60, 0.15); color: var(--color-danger); }
|
||||
.statusCell { position: relative; }
|
||||
.statusCell:hover .errorTooltip { opacity: 1; visibility: visible; transform: translateX(-50%) translateY(0); }
|
||||
.errorTooltip {
|
||||
position: absolute; bottom: calc(100% + 4px); left: 50%; transform: translateX(-50%) translateY(4px);
|
||||
background: #16161e; border: 1px solid var(--color-border-card); border-radius: 6px;
|
||||
padding: 6px 10px; font-size: 12px; color: var(--color-danger); white-space: nowrap;
|
||||
max-width: 300px; overflow: hidden; text-overflow: ellipsis;
|
||||
opacity: 0; visibility: hidden; transition: all 0.15s; z-index: 10;
|
||||
pointer-events: none; box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
}
|
||||
.queued, .processing { background: rgba(0, 184, 230, 0.15); color: var(--color-primary); }
|
||||
.empty { text-align: center; color: var(--color-text-secondary); padding: 40px; }
|
||||
.skeletonCell { height: 16px; background: var(--color-border-card); border-radius: 4px; animation: pulse 1.5s ease-in-out infinite; }
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { adminApi } from '../lib/api';
|
||||
import type { AdminRecord } from '../types';
|
||||
import type { AdminRecord, Team } from '../types';
|
||||
import { showToast } from '../components/Toast';
|
||||
import { DatePicker } from '../components/DatePicker';
|
||||
import { Select } from '../components/Select';
|
||||
import styles from './RecordsPage.module.css';
|
||||
|
||||
export function RecordsPage() {
|
||||
@ -11,9 +13,16 @@ export function RecordsPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [teamFilter, setTeamFilter] = useState('');
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const pageSize = 20;
|
||||
|
||||
// Load teams for filter dropdown
|
||||
useEffect(() => {
|
||||
adminApi.getTeams().then(({ data }) => setTeams(data.results)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const fetchRecords = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@ -21,6 +30,7 @@ export function RecordsPage() {
|
||||
page, page_size: pageSize, search,
|
||||
start_date: startDate || undefined,
|
||||
end_date: endDate || undefined,
|
||||
team_id: teamFilter ? Number(teamFilter) : undefined,
|
||||
});
|
||||
setRecords(data.results);
|
||||
setTotal(data.total);
|
||||
@ -29,7 +39,7 @@ export function RecordsPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, search, startDate, endDate]);
|
||||
}, [page, search, startDate, endDate, teamFilter]);
|
||||
|
||||
useEffect(() => { fetchRecords(); }, [fetchRecords]);
|
||||
|
||||
@ -45,15 +55,17 @@ export function RecordsPage() {
|
||||
page: 1, page_size: 10000, search,
|
||||
start_date: startDate || undefined,
|
||||
end_date: endDate || undefined,
|
||||
team_id: teamFilter ? Number(teamFilter) : undefined,
|
||||
});
|
||||
|
||||
const header = '时间,用户名,消费秒数,提示词,生成模式,状态\n';
|
||||
const header = '时间,团队,用户名,消费秒数,提示词,生成模式,状态,失败原因\n';
|
||||
const rows = data.results.map((r) => {
|
||||
// Escape CSV fields to prevent injection
|
||||
const prompt = r.prompt.replace(/"/g, '""').replace(/^[=+\-@]/, "'$&");
|
||||
const modeLabel = r.mode === 'universal' ? '全能参考' : '首尾帧';
|
||||
const statusLabel = { queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' }[r.status];
|
||||
return `${r.created_at},${r.username},"${r.seconds_consumed}","${prompt}","${modeLabel}","${statusLabel}"`;
|
||||
const errorMsg = (r.error_message || '').replace(/"/g, '""').replace(/^[=+\-@]/, "'$&");
|
||||
return `${r.created_at},"${r.team_name || '-'}",${r.username},"${r.seconds_consumed}","${prompt}","${modeLabel}","${statusLabel}","${errorMsg}"`;
|
||||
}).join('\n');
|
||||
|
||||
const blob = new Blob(['\uFEFF' + header + rows], { type: 'text/csv;charset=utf-8;' });
|
||||
@ -88,19 +100,15 @@ export function RecordsPage() {
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
className={styles.dateInput}
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
<Select
|
||||
value={teamFilter}
|
||||
onChange={(v) => { setTeamFilter(v); setPage(1); }}
|
||||
placeholder="全部团队"
|
||||
options={[{ label: '全部团队', value: '' }, ...teams.map((t) => ({ label: t.name, value: String(t.id) }))]}
|
||||
/>
|
||||
<DatePicker value={startDate} onChange={setStartDate} placeholder="开始日期" />
|
||||
<span className={styles.dateSep}>~</span>
|
||||
<input
|
||||
type="date"
|
||||
className={styles.dateInput}
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
/>
|
||||
<DatePicker value={endDate} onChange={setEndDate} placeholder="结束日期" />
|
||||
<button className={styles.searchBtn} onClick={handleSearch}>查询</button>
|
||||
</div>
|
||||
|
||||
@ -109,6 +117,7 @@ export function RecordsPage() {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>团队</th>
|
||||
<th>用户名</th>
|
||||
<th>消费秒数</th>
|
||||
<th>视频描述</th>
|
||||
@ -120,25 +129,29 @@ export function RecordsPage() {
|
||||
{loading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
{Array.from({ length: 6 }).map((_, j) => (
|
||||
{Array.from({ length: 7 }).map((_, j) => (
|
||||
<td key={j}><div className={styles.skeletonCell} /></td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : records.length === 0 ? (
|
||||
<tr><td colSpan={6} className={styles.empty}>暂无记录</td></tr>
|
||||
<tr><td colSpan={7} className={styles.empty}>暂无记录</td></tr>
|
||||
) : (
|
||||
records.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className={styles.timeCell}>{new Date(r.created_at).toLocaleString('zh-CN')}</td>
|
||||
<td>{r.team_name || '-'}</td>
|
||||
<td>{r.username}</td>
|
||||
<td><span className={styles.secondsBadge}>{r.seconds_consumed.toLocaleString()}s</span></td>
|
||||
<td className={styles.promptCell}>{r.prompt ? (r.prompt.slice(0, 40) + (r.prompt.length > 40 ? '...' : '')) : '-'}</td>
|
||||
<td>{r.mode === 'universal' ? '全能参考' : '首尾帧'}</td>
|
||||
<td>
|
||||
<td className={r.status === 'failed' && r.error_message ? styles.statusCell : undefined}>
|
||||
<span className={`${styles.statusBadge} ${styles[r.status]}`}>
|
||||
{statusMap[r.status]}
|
||||
</span>
|
||||
{r.status === 'failed' && r.error_message && (
|
||||
<span className={styles.errorTooltip}>{r.error_message}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -35,6 +35,11 @@ export function TeamsPage() {
|
||||
const [detailTeam, setDetailTeam] = useState<TeamDetail | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
// Edit pool modal
|
||||
const [editPoolOpen, setEditPoolOpen] = useState(false);
|
||||
const [editPoolValue, setEditPoolValue] = useState('');
|
||||
const [editPoolError, setEditPoolError] = useState('');
|
||||
|
||||
// Confirm toggle
|
||||
const [confirmTeam, setConfirmTeam] = useState<Team | null>(null);
|
||||
|
||||
@ -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 && (
|
||||
<div className={styles.drawerOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setDrawerOpen(false); }}>
|
||||
<div className={styles.drawer}>
|
||||
<div className={styles.drawerHeader}>
|
||||
<div className={styles.detailOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setDrawerOpen(false); }}>
|
||||
<div className={styles.detailModal}>
|
||||
<div className={styles.detailHeader}>
|
||||
<h3>
|
||||
团队详情 — {detailTeam.name}
|
||||
<span className={`${styles.statusBadge} ${detailTeam.is_active ? styles.active : styles.disabled}`} style={{ marginLeft: 8 }}>
|
||||
<span className={`${styles.statusBadge} ${detailTeam.is_active ? styles.active : styles.disabled}`}>
|
||||
{detailTeam.is_active ? '启用' : '禁用'}
|
||||
</span>
|
||||
</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 className={styles.drawerBody}>
|
||||
<div className={styles.detailBody}>
|
||||
<div className={styles.detailGrid}>
|
||||
<div className={styles.detailItem}>
|
||||
<span className={styles.detailLabel}>总秒数池</span>
|
||||
<span className={styles.detailValue}>{fmtSec(detailTeam.total_seconds_pool)}</span>
|
||||
<span className={styles.detailValue}>
|
||||
{fmtSec(detailTeam.total_seconds_pool)}
|
||||
<button
|
||||
className={styles.editPoolBtn}
|
||||
onClick={() => { setEditPoolValue(String(detailTeam.total_seconds_pool)); setEditPoolError(''); setEditPoolOpen(true); }}
|
||||
title="修改秒数"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.detailItem}>
|
||||
<span className={styles.detailLabel}>已消耗</span>
|
||||
@ -356,45 +395,68 @@ export function TeamsPage() {
|
||||
{detailTeam.members.length === 0 ? (
|
||||
<div className={styles.empty}>暂无成员</div>
|
||||
) : (
|
||||
<table className={styles.memberTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户名</th>
|
||||
<th>邮箱</th>
|
||||
<th>角色</th>
|
||||
<th>状态</th>
|
||||
<th>日限额</th>
|
||||
<th>今日消费</th>
|
||||
<th>本月消费</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{detailTeam.members.map((m) => (
|
||||
<tr key={m.id}>
|
||||
<td>{m.username}</td>
|
||||
<td>{m.email}</td>
|
||||
<td>
|
||||
{m.is_team_admin ? (
|
||||
<span className={styles.adminBadge}>管理员</span>
|
||||
) : '成员'}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`${styles.statusBadge} ${m.is_active ? styles.active : styles.disabled}`}>
|
||||
{m.is_active ? '启用' : '禁用'}
|
||||
</span>
|
||||
</td>
|
||||
<td>{fmtSec(m.daily_seconds_limit)}</td>
|
||||
<td>{fmtSec(m.seconds_today)}</td>
|
||||
<td>{fmtSec(m.seconds_this_month)}</td>
|
||||
<div className={styles.memberTableWrapper}>
|
||||
<table className={styles.memberTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户名</th>
|
||||
<th>邮箱</th>
|
||||
<th>角色</th>
|
||||
<th>状态</th>
|
||||
<th>日限额</th>
|
||||
<th>今日消费</th>
|
||||
<th>本月消费</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{detailTeam.members.map((m) => (
|
||||
<tr key={m.id}>
|
||||
<td>{m.username}</td>
|
||||
<td>{m.email}</td>
|
||||
<td>
|
||||
{m.is_team_admin ? (
|
||||
<span className={styles.adminBadge}>管理员</span>
|
||||
) : '成员'}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`${styles.statusBadge} ${m.is_active ? styles.active : styles.disabled}`}>
|
||||
{m.is_active ? '启用' : '禁用'}
|
||||
</span>
|
||||
</td>
|
||||
<td>{fmtSec(m.daily_seconds_limit)}</td>
|
||||
<td>{fmtSec(m.seconds_today)}</td>
|
||||
<td>{fmtSec(m.seconds_this_month)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Pool Modal */}
|
||||
{editPoolOpen && detailTeam && (
|
||||
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setEditPoolOpen(false); }}>
|
||||
<div className={styles.modal}>
|
||||
<h3 className={styles.modalTitle}>修改总秒数池 — {detailTeam.name}</h3>
|
||||
{editPoolError && <div className={styles.formError}>{editPoolError}</div>}
|
||||
<div className={styles.formGroup}>
|
||||
<label>总秒数池(秒)</label>
|
||||
<input type="number" value={editPoolValue} onChange={(e) => setEditPoolValue(e.target.value)} placeholder="输入总秒数" />
|
||||
<div className={styles.formHint}>
|
||||
当前: {fmtSec(detailTeam.total_seconds_pool)} | 已消耗: {fmtSec(detailTeam.total_seconds_used)} | 修改后剩余: {fmtSec(Math.max(0, (Number(editPoolValue) || 0) - detailTeam.total_seconds_used))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.modalActions}>
|
||||
<button className={styles.cancelBtn} onClick={() => setEditPoolOpen(false)}>取消</button>
|
||||
<button className={styles.saveBtn} onClick={handleSetPool}>确认修改</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -63,7 +63,7 @@
|
||||
|
||||
/* Modal */
|
||||
.modalOverlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 300; }
|
||||
.modal { background: var(--color-bg-card); border: 1px solid var(--color-border-card); border-radius: var(--radius-card); padding: 24px; width: 400px; max-width: 90vw; }
|
||||
.modal { background: #16161e; border: 1px solid var(--color-border-card); border-radius: var(--radius-card); padding: 24px; width: 400px; max-width: 90vw; }
|
||||
.modalTitle { font-size: 16px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 20px; }
|
||||
.formGroup { margin-bottom: 16px; }
|
||||
.formGroup label { display: block; color: var(--color-text-secondary); font-size: 13px; margin-bottom: 6px; }
|
||||
@ -82,7 +82,7 @@
|
||||
.drawerOverlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 300; }
|
||||
.drawer {
|
||||
position: fixed; right: 0; top: 0; bottom: 0; width: 440px; max-width: 90vw;
|
||||
background: var(--color-bg-card); border-left: 1px solid var(--color-border-card);
|
||||
background: #16161e; border-left: 1px solid var(--color-border-card);
|
||||
display: flex; flex-direction: column; z-index: 301;
|
||||
animation: slideIn 0.2s ease;
|
||||
}
|
||||
|
||||
@ -1,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<Team[]>([]);
|
||||
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()}
|
||||
/>
|
||||
<select
|
||||
className={styles.statusSelect}
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="active">启用</option>
|
||||
<option value="disabled">禁用</option>
|
||||
</select>
|
||||
onChange={(v) => { setStatusFilter(v); setPage(1); }}
|
||||
placeholder="全部状态"
|
||||
options={[
|
||||
{ label: '全部状态', value: '' },
|
||||
{ label: '启用', value: 'active' },
|
||||
{ label: '禁用', value: 'disabled' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={teamFilter}
|
||||
onChange={(v) => { setTeamFilter(v); setPage(1); }}
|
||||
placeholder="全部团队"
|
||||
options={[{ label: '全部团队', value: '' }, ...teams.map((t) => ({ label: t.name, value: String(t.id) }))]}
|
||||
/>
|
||||
<button className={styles.searchBtn} onClick={handleSearch}>查询</button>
|
||||
</div>
|
||||
<div className={styles.searchGroup}>
|
||||
@ -164,6 +180,7 @@ export function UsersPage() {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户名</th>
|
||||
<th>团队</th>
|
||||
<th>邮箱</th>
|
||||
<th>注册时间</th>
|
||||
<th>状态</th>
|
||||
@ -178,13 +195,13 @@ export function UsersPage() {
|
||||
{loading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
{Array.from({ length: 9 }).map((_, j) => (
|
||||
{Array.from({ length: 10 }).map((_, j) => (
|
||||
<td key={j}><div className={styles.skeletonCell} /></td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : users.length === 0 ? (
|
||||
<tr><td colSpan={9} className={styles.empty}>暂无数据</td></tr>
|
||||
<tr><td colSpan={10} className={styles.empty}>暂无数据</td></tr>
|
||||
) : (
|
||||
users.map((u) => (
|
||||
<tr key={u.id}>
|
||||
@ -193,6 +210,7 @@ export function UsersPage() {
|
||||
{u.username}
|
||||
</button>
|
||||
</td>
|
||||
<td>{u.team_name || '-'}</td>
|
||||
<td>{u.email}</td>
|
||||
<td>{new Date(u.date_joined).toLocaleDateString('zh-CN')}</td>
|
||||
<td>
|
||||
|
||||
@ -85,6 +85,30 @@ function backendToFrontend(bt: BackendTask): GenerationTask {
|
||||
// Active polling timers
|
||||
const pollTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
// Smooth progress animation — continuously ticks generating tasks forward
|
||||
let smoothProgressTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function ensureSmoothProgress() {
|
||||
if (smoothProgressTimer) return;
|
||||
smoothProgressTimer = setInterval(() => {
|
||||
const state = useGenerationStore.getState();
|
||||
const generating = state.tasks.filter((t) => t.status === 'generating');
|
||||
if (generating.length === 0) {
|
||||
clearInterval(smoothProgressTimer!);
|
||||
smoothProgressTimer = null;
|
||||
return;
|
||||
}
|
||||
useGenerationStore.setState((s) => ({
|
||||
tasks: s.tasks.map((t) => {
|
||||
if (t.status !== 'generating') return t;
|
||||
// Decelerate: fast at start, slow near end
|
||||
const increment = t.progress < 30 ? 2 : t.progress < 60 ? 1 : 0.5;
|
||||
return { ...t, progress: Math.min(t.progress + increment, 95) };
|
||||
}),
|
||||
}));
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// Progressive polling: 10s for first 2min, 30s for 2-5min, 60s after 5min
|
||||
function getPollingInterval(startTime: number): number {
|
||||
const elapsed = Date.now() - startTime;
|
||||
@ -110,7 +134,7 @@ function startPolling(taskId: string, frontendId: string) {
|
||||
? {
|
||||
...t,
|
||||
status: newStatus,
|
||||
progress: newStatus === 'completed' ? 100 : newStatus === 'failed' ? 0 : Math.min(t.progress + 5, 90),
|
||||
progress: newStatus === 'completed' ? 100 : newStatus === 'failed' ? 0 : t.progress,
|
||||
resultUrl: data.result_url || t.resultUrl,
|
||||
errorMessage: mapErrorMessage(data.error_message) || t.errorMessage,
|
||||
}
|
||||
@ -145,40 +169,78 @@ function stopPolling(frontendId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
interface GenerationState {
|
||||
tasks: GenerationTask[];
|
||||
isLoading: boolean;
|
||||
isLoadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
savedScrollTop: number | null;
|
||||
addTask: () => Promise<string | null>;
|
||||
removeTask: (id: string) => void;
|
||||
reEdit: (id: string) => void;
|
||||
regenerate: (id: string) => void;
|
||||
loadTasks: () => Promise<void>;
|
||||
loadMore: () => Promise<void>;
|
||||
saveScrollPosition: (top: number) => void;
|
||||
}
|
||||
|
||||
export const useGenerationStore = create<GenerationState>((set, get) => ({
|
||||
tasks: [],
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
hasMore: false,
|
||||
savedScrollTop: null,
|
||||
|
||||
loadTasks: async () => {
|
||||
set({ isLoading: true });
|
||||
|
||||
let tasks: GenerationTask[] = [];
|
||||
let hasMore = false;
|
||||
|
||||
try {
|
||||
const { data } = await videoApi.getTasks();
|
||||
const { data } = await videoApi.getTasks({ page_size: PAGE_SIZE, offset: 0 });
|
||||
tasks = data.results.map(backendToFrontend).reverse();
|
||||
hasMore = data.has_more;
|
||||
} catch {
|
||||
// API unavailable — tasks stays empty
|
||||
}
|
||||
|
||||
set({ tasks, 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) {
|
||||
if (task.status === 'generating' && task.taskId) {
|
||||
startPolling(task.taskId, task.id);
|
||||
hasGenerating = true;
|
||||
}
|
||||
}
|
||||
if (hasGenerating) ensureSmoothProgress();
|
||||
},
|
||||
|
||||
loadMore: async () => {
|
||||
const { tasks, isLoadingMore, hasMore } = get();
|
||||
if (isLoadingMore || !hasMore) return;
|
||||
|
||||
set({ isLoadingMore: true });
|
||||
|
||||
try {
|
||||
// Backend returns newest-first; we display oldest-first (reversed).
|
||||
// tasks[0] is the oldest currently loaded. We need older ones = higher offset.
|
||||
// offset = total loaded from backend perspective
|
||||
const currentBackendCount = tasks.filter((t) => !t.id.startsWith('temp_')).length;
|
||||
const { data } = await videoApi.getTasks({ page_size: PAGE_SIZE, offset: currentBackendCount });
|
||||
const olderTasks = data.results.map(backendToFrontend).reverse();
|
||||
set((s) => ({
|
||||
tasks: [...olderTasks, ...s.tasks],
|
||||
hasMore: data.has_more,
|
||||
isLoadingMore: false,
|
||||
}));
|
||||
} catch {
|
||||
set({ isLoadingMore: false });
|
||||
}
|
||||
},
|
||||
|
||||
addTask: async () => {
|
||||
@ -249,12 +311,15 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
|
||||
duration: input.duration,
|
||||
references: localRefs,
|
||||
status: 'generating',
|
||||
progress: 5,
|
||||
progress: 0,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
set((s) => ({ tasks: [...s.tasks, placeholderTask] }));
|
||||
|
||||
// Start smooth progress animation
|
||||
ensureSmoothProgress();
|
||||
|
||||
// Clear input
|
||||
useInputBarStore.setState({
|
||||
prompt: '',
|
||||
@ -269,12 +334,6 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
|
||||
const uploadedRefs: { url: string; type: string; role: string; label: string }[] = [];
|
||||
|
||||
for (const item of filesToUpload) {
|
||||
set((s) => ({
|
||||
tasks: s.tasks.map((t) =>
|
||||
t.id === tempId ? { ...t, progress: Math.min(t.progress + 10, 40) } : t
|
||||
),
|
||||
}));
|
||||
|
||||
if (item.tosUrl) {
|
||||
uploadedRefs.push({ url: item.tosUrl, type: item.type, role: item.role, label: item.label });
|
||||
} else if (item.file) {
|
||||
@ -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
|
||||
const { data: genResult } = await videoApi.generate({
|
||||
prompt: input.prompt,
|
||||
@ -311,7 +363,7 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
|
||||
id: frontendId,
|
||||
taskId: genResult.task_id,
|
||||
status: taskStatus as GenerationTask['status'],
|
||||
progress: taskStatus === 'completed' ? 100 : 60,
|
||||
progress: taskStatus === 'completed' ? 100 : t.progress,
|
||||
}
|
||||
: t
|
||||
),
|
||||
@ -438,4 +490,8 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
|
||||
// Trigger generation
|
||||
get().addTask();
|
||||
},
|
||||
|
||||
saveScrollPosition: (top: number) => {
|
||||
set({ savedScrollTop: top });
|
||||
},
|
||||
}));
|
||||
|
||||
@ -111,6 +111,7 @@ export interface AdminStats {
|
||||
month_change_percent: number;
|
||||
daily_trend: { date: string; seconds: number }[];
|
||||
top_users: { user_id: number; username: string; seconds_consumed: number }[];
|
||||
top_teams: { team_id: number; name: string; seconds_consumed: number }[];
|
||||
}
|
||||
|
||||
export interface AdminUser {
|
||||
@ -146,6 +147,7 @@ export interface AdminRecord {
|
||||
model: ModelOption;
|
||||
aspect_ratio?: string;
|
||||
status: 'queued' | 'processing' | 'completed' | 'failed';
|
||||
error_message?: string;
|
||||
}
|
||||
|
||||
export interface SystemSettings {
|
||||
@ -215,3 +217,17 @@ export interface TeamStats {
|
||||
daily_trend: { date: string; seconds: number }[];
|
||||
member_consumption: { user_id: number; username: string; seconds_consumed: number }[];
|
||||
}
|
||||
|
||||
export interface AuditLog {
|
||||
id: number;
|
||||
operator_name: string;
|
||||
action: string;
|
||||
action_display: string;
|
||||
target_type: string;
|
||||
target_id: number | null;
|
||||
target_name: string;
|
||||
before: Record<string, unknown> | null;
|
||||
after: Record<string, unknown> | null;
|
||||
ip_address: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user