From 6c364f4c3f5551adc408702d8feb696e1c6c8e7d Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Sun, 22 Mar 2026 03:11:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v0.11.0=20=E7=B4=A0=E6=9D=90=E5=BA=93?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=20+=20=E7=94=9F=E6=88=90=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=20UI=20=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 素材库(虚拟人像): - 后端:AssetGroup/Asset 模型 + 火山 Assets API 客户端 + 7 个 API 端点 - 前端:素材库管理弹窗(上传/浏览/追加/改名/状态轮询) - PromptInput:@ 搜索素材库 + mention 标签(缩略图+名字) - 提交生成时提取 asset:// 引用并去重 - 打开素材详情时自动检查云端状态,已删除的自动清理 - 后端 reference_snapshots 存储 thumb_url,刷新后标签缩略图和 hover 预览正常 生成页面 UI: - 提示词 hover 即梦风格:原位展开玻璃底覆盖视频,不弹浮层 - 标签(AirDrama/时长/比例)inline 排列,溢出时 canvas 截断 - 详细信息弹窗支持鼠标移上去不消失(延迟关闭),增加 token/费用信息 - 任务卡片/视频详情页提示词标签化(renderPromptWithMentions) - 视频详情页底部去掉重复按钮,信息栏 flex-wrap 自动换行 mention 标签: - 输入框内剪切/复制粘贴保留标签(handlePaste 检测 text/html) - 拖拽标签跟手(caretRangeFromPoint + drop 位置精确插入) - 拖拽时 hover 预览自动关闭,InputBar 蓝边仅外部文件拖入时触发 其他: - 联网搜索按钮(暂禁用,等火山确认 API) - card max-width 800→1024,参考图缩略图 48→56px 居中对齐 - 导航箭头禁用时不触发关闭(去掉 pointer-events:none) - API 错误信息附带原始报错便于排查 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../migrations/0008_asset_library.py | 53 ++ backend/apps/generation/models.py | 53 ++ backend/apps/generation/urls.py | 11 + backend/apps/generation/views.py | 477 +++++++++++++++++- backend/config/settings.py | 2 + backend/requirements.txt | 1 + backend/utils/airdrama_client.py | 59 ++- backend/utils/assets_client.py | 177 +++++++ web/src/App.tsx | 2 + .../components/AssetLibraryModal.module.css | 394 +++++++++++++++ web/src/components/AssetLibraryModal.tsx | 428 ++++++++++++++++ web/src/components/GenerationCard.module.css | 95 ++-- web/src/components/GenerationCard.tsx | 208 ++++++-- web/src/components/InputBar.tsx | 52 +- web/src/components/PromptInput.module.css | 19 +- web/src/components/PromptInput.tsx | 385 +++++++++++--- web/src/components/Toolbar.tsx | 1 + .../components/VideoDetailModal.module.css | 4 +- web/src/components/VideoDetailModal.tsx | 30 +- web/src/lib/api.ts | 35 +- web/src/pages/AdminAssetsPage.tsx | 1 + web/src/pages/AdminLayout.tsx | 1 + web/src/pages/LoginRecordsPage.module.css | 46 ++ web/src/pages/LoginRecordsPage.tsx | 182 +++++++ web/src/pages/TeamAssetsPage.tsx | 1 + web/src/pages/TeamsPage.tsx | 2 +- web/src/store/assetLibrary.ts | 79 +++ web/src/store/generation.ts | 123 ++++- web/src/store/inputBar.ts | 13 + web/src/types/index.ts | 21 + 30 files changed, 2725 insertions(+), 230 deletions(-) create mode 100644 backend/apps/generation/migrations/0008_asset_library.py create mode 100644 backend/utils/assets_client.py create mode 100644 web/src/components/AssetLibraryModal.module.css create mode 100644 web/src/components/AssetLibraryModal.tsx create mode 100644 web/src/pages/LoginRecordsPage.module.css create mode 100644 web/src/pages/LoginRecordsPage.tsx create mode 100644 web/src/store/assetLibrary.ts diff --git a/backend/apps/generation/migrations/0008_asset_library.py b/backend/apps/generation/migrations/0008_asset_library.py new file mode 100644 index 0000000..5fb872f --- /dev/null +++ b/backend/apps/generation/migrations/0008_asset_library.py @@ -0,0 +1,53 @@ +# Generated by Django 4.2.29 on 2026-03-21 09:44 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('accounts', '0010_billing_data_migration'), + ('generation', '0007_billing_system_v010'), + ] + + operations = [ + migrations.CreateModel( + name='AssetGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('remote_group_id', models.CharField(default='', max_length=100, verbose_name='火山Group ID')), + ('name', models.CharField(default='', max_length=100, verbose_name='角色名')), + ('description', models.CharField(blank=True, default='', max_length=300, verbose_name='描述')), + ('thumbnail_url', models.CharField(blank=True, default='', max_length=1000, verbose_name='缩略图URL')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_asset_groups', to=settings.AUTH_USER_MODEL, verbose_name='创建人')), + ('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='asset_groups', to='accounts.team', verbose_name='所属团队')), + ], + options={ + 'verbose_name': '素材组', + 'verbose_name_plural': '素材组', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='Asset', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('remote_asset_id', models.CharField(default='', max_length=100, verbose_name='火山Asset ID')), + ('name', models.CharField(default='', max_length=100, verbose_name='素材名称')), + ('url', models.CharField(blank=True, default='', max_length=1000, verbose_name='图片URL')), + ('status', models.CharField(choices=[('processing', '处理中'), ('active', '可用'), ('failed', '失败')], default='processing', max_length=20, verbose_name='状态')), + ('error_message', models.CharField(blank=True, default='', max_length=500, verbose_name='错误信息')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assets', to='generation.assetgroup', verbose_name='所属素材组')), + ], + options={ + 'verbose_name': '素材', + 'verbose_name_plural': '素材', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/backend/apps/generation/models.py b/backend/apps/generation/models.py index 79b0803..18c9cd4 100644 --- a/backend/apps/generation/models.py +++ b/backend/apps/generation/models.py @@ -99,3 +99,56 @@ class QuotaConfig(models.Model): def __str__(self): return f'全局配额: {self.default_daily_seconds_limit}s/日, {self.default_monthly_seconds_limit}s/月' + + +class AssetGroup(models.Model): + """虚拟人像素材组 — 一个角色对应一个组。""" + team = models.ForeignKey( + 'accounts.Team', on_delete=models.CASCADE, + related_name='asset_groups', verbose_name='所属团队', + ) + remote_group_id = models.CharField(max_length=100, default='', verbose_name='火山Group ID') + name = models.CharField(max_length=100, default='', verbose_name='角色名') + description = models.CharField(max_length=300, blank=True, default='', verbose_name='描述') + thumbnail_url = models.CharField(max_length=1000, blank=True, default='', verbose_name='缩略图URL') + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, + null=True, blank=True, related_name='created_asset_groups', verbose_name='创建人', + ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') + + class Meta: + verbose_name = '素材组' + verbose_name_plural = '素材组' + ordering = ['-created_at'] + + def __str__(self): + return f'{self.team.name} - {self.name}' + + +class Asset(models.Model): + """虚拟人像素材 — 单张图片。""" + STATUS_CHOICES = [ + ('processing', '处理中'), + ('active', '可用'), + ('failed', '失败'), + ] + + group = models.ForeignKey( + AssetGroup, on_delete=models.CASCADE, + related_name='assets', verbose_name='所属素材组', + ) + remote_asset_id = models.CharField(max_length=100, default='', verbose_name='火山Asset ID') + name = models.CharField(max_length=100, default='', verbose_name='素材名称') + url = models.CharField(max_length=1000, blank=True, default='', verbose_name='图片URL') + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='processing', verbose_name='状态') + error_message = models.CharField(max_length=500, blank=True, default='', verbose_name='错误信息') + created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') + + class Meta: + verbose_name = '素材' + verbose_name_plural = '素材' + ordering = ['-created_at'] + + def __str__(self): + return f'{self.group.name} - {self.name}' diff --git a/backend/apps/generation/urls.py b/backend/apps/generation/urls.py index ff99666..99c283b 100644 --- a/backend/apps/generation/urls.py +++ b/backend/apps/generation/urls.py @@ -35,6 +35,9 @@ urlpatterns = [ path('admin/settings', views.admin_settings_view, name='admin_settings'), path('admin/logs', views.admin_audit_logs_view, name='admin_audit_logs'), + # ── Super Admin: Login Records ── + path('admin/login-records', views.admin_login_records_view, name='admin_login_records'), + # ── Super Admin: Anomaly Detection ── path('admin/anomalies', views.admin_login_anomalies_view, name='admin_login_anomalies'), path('admin/test-feishu', views.admin_test_feishu_view, name='admin_test_feishu'), @@ -62,4 +65,12 @@ urlpatterns = [ # ── Profile: User's own data ── path('profile/overview', views.profile_overview_view, name='profile_overview'), path('profile/records', views.profile_records_view, name='profile_records'), + + # ── Assets API (Virtual Avatar Library) ── + path('assets/groups', views.asset_groups_view, name='asset_groups'), + path('assets/groups/', views.asset_group_detail_view, name='asset_group_detail'), + path('assets/groups//assets', views.asset_group_add_asset_view, name='asset_group_add_asset'), + path('assets/', views.asset_update_view, name='asset_update'), + path('assets//status', views.asset_poll_status_view, name='asset_poll_status'), + path('assets/search', views.asset_search_view, name='asset_search'), ] diff --git a/backend/apps/generation/views.py b/backend/apps/generation/views.py index b47b3dd..73a4d9c 100644 --- a/backend/apps/generation/views.py +++ b/backend/apps/generation/views.py @@ -13,7 +13,7 @@ from django.db.models.functions import TruncDate from django.db.utils import OperationalError as DbOperationalError from datetime import timedelta -from .models import GenerationRecord, QuotaConfig +from .models import GenerationRecord, QuotaConfig, AssetGroup, Asset from .serializers import ( VideoGenerateSerializer, QuotaUpdateSerializer, UserStatusSerializer, SystemSettingsSerializer, @@ -22,7 +22,7 @@ from .serializers import ( TeamAdminCreateSerializer, TeamMemberCreateSerializer, MemberQuotaSerializer, TeamAnomalyConfigSerializer, ) -from apps.accounts.models import Team, AdminAuditLog, log_admin_action, TeamAnomalyConfig, LoginAnomaly, ActiveSession +from apps.accounts.models import Team, AdminAuditLog, log_admin_action, TeamAnomalyConfig, LoginAnomaly, ActiveSession, LoginRecord from apps.accounts.permissions import IsSuperAdmin, IsTeamAdmin, IsTeamMember from utils.tos_client import upload_file as tos_upload from utils.airdrama_client import create_task, query_task, extract_video_url, map_status @@ -152,6 +152,7 @@ def video_generate_view(request): mode = serializer.validated_data['mode'] model = serializer.validated_data['model'] aspect_ratio = serializer.validated_data['aspect_ratio'] + search_mode = request.data.get('search_mode', 'off') # ── 预估 token 和费用 ── config = QuotaConfig.objects.get_or_create(pk=1)[0] @@ -220,29 +221,59 @@ def video_generate_view(request): references = request.data.get('references', []) reference_snapshots = [] content_items = [] + seen_urls = set() # 去重:同一个素材只引用一次 + + from .models import Asset as AssetModel for ref in references: url = ref.get('url', '') + original_url = url # 保留原始 URL 用于 reference_snapshots ref_type = ref.get('type', 'image') role = ref.get('role', '') label = ref.get('label', '') - reference_snapshots.append({ - 'url': url, 'type': ref_type, 'role': role, 'label': label, - }) + # 跳过重复 URL — 在所有操作之前判断 + if original_url in seen_urls: + continue + seen_urls.add(original_url) + + # 快照存原始 URL(前端重建 reEdit 需要 asset://group-{id} 格式) + snap = {'url': original_url, 'type': ref_type, 'role': role, 'label': label} + thumb_url = ref.get('thumb_url', '') + if thumb_url: + snap['thumb_url'] = thumb_url + reference_snapshots.append(snap) + + # 转换 asset://group-{id} 为火山 Asset://Asset-xxx 格式(仅用于 content_items) + resolved_url = url + if url.startswith('asset://group-'): + try: + group_id = int(url.replace('asset://group-', '')) + first_asset = AssetModel.objects.filter( + group_id=group_id, status='active' + ).first() + if first_asset and first_asset.remote_asset_id: + aid = first_asset.remote_asset_id + if aid.startswith('asset-'): + aid = 'Asset-' + aid[6:] + resolved_url = f'Asset://{aid}' + else: + logger.warning('No active asset found for group %s', group_id) + except (ValueError, Exception) as e: + logger.warning('Failed to resolve asset group URL %s: %s', url, e) if ref_type == 'image': - item = {'type': 'image_url', 'image_url': {'url': url}} + item = {'type': 'image_url', 'image_url': {'url': resolved_url}} if role: item['role'] = role content_items.append(item) elif ref_type == 'video': - item = {'type': 'video_url', 'video_url': {'url': url}} + item = {'type': 'video_url', 'video_url': {'url': resolved_url}} if role: item['role'] = role content_items.append(item) elif ref_type == 'audio': - item = {'type': 'audio_url', 'audio_url': {'url': url}} + item = {'type': 'audio_url', 'audio_url': {'url': resolved_url}} if role: item['role'] = role content_items.append(item) @@ -278,6 +309,7 @@ def video_generate_view(request): content_items=content_items, aspect_ratio=aspect_ratio, duration=duration, + search_mode=search_mode, ) ark_task_id = ark_response.get('id', '') record.ark_task_id = ark_task_id @@ -2450,3 +2482,432 @@ def team_assets_member_videos(request, member_id): 'page_size': page_size, 'results': results, }) + + +# ────────────────────────────────────────────── +# Admin: Login Records +# ────────────────────────────────────────────── + +@api_view(['GET']) +@permission_classes([IsSuperAdmin]) +def admin_login_records_view(request): + """GET /api/v1/admin/login-records""" + page = int(request.query_params.get('page', 1)) + page_size = min(int(request.query_params.get('page_size', 20)), 100) + search = request.query_params.get('search', '').strip() + team_id = request.query_params.get('team_id', '').strip() + start_date = request.query_params.get('start_date', '').strip() + end_date = request.query_params.get('end_date', '').strip() + city = request.query_params.get('city', '').strip() + + qs = LoginRecord.objects.select_related('user', 'team').order_by('-created_at') + + if search: + qs = qs.filter(user__username__icontains=search) + if team_id: + qs = qs.filter(team_id=int(team_id)) + if start_date: + qs = qs.filter(created_at__date__gte=start_date) + if end_date: + qs = qs.filter(created_at__date__lte=end_date) + if city: + qs = qs.filter(geo_city__icontains=city) + + total = qs.count() + offset = (page - 1) * page_size + records = list(qs[offset:offset + page_size]) + + results = [] + for r in records: + results.append({ + 'id': r.id, + 'username': r.user.username, + 'user_id': r.user_id, + 'team_name': r.team.name if r.team else None, + 'ip_address': r.ip_address or '', + 'geo_country': r.geo_country, + 'geo_province': r.geo_province, + 'geo_city': r.geo_city, + 'geo_source': r.geo_source, + 'user_agent': r.user_agent, + 'created_at': r.created_at.isoformat(), + }) + + return Response({ + 'total': total, + 'page': page, + 'page_size': page_size, + 'results': results, + }) + + +# ────────────────────────────────────────────── +# Virtual Avatar Asset Library +# ────────────────────────────────────────────── + +def _assets_api_call(func, *args, **kwargs): + """Safely call an assets_client function; returns (result, error_response). + + If ASSETS_API_ENABLED is False the remote call is skipped and an empty + placeholder is returned so that local DB records are still created. + """ + from django.conf import settings as django_settings + if not django_settings.ASSETS_API_ENABLED: + return None, None + try: + from utils.assets_client import AssetsAPIError + result = func(*args, **kwargs) + return result, None + except AssetsAPIError as e: + logger.warning('Assets API error: %s', e) + return None, Response( + {'error': 'assets_api_error', 'message': str(e)}, + status=status.HTTP_502_BAD_GATEWAY, + ) + except Exception as e: + logger.exception('Assets API unexpected error') + return None, Response( + {'error': 'assets_api_error', 'message': f'素材 API 调用失败: {e}'}, + status=status.HTTP_502_BAD_GATEWAY, + ) + + +@api_view(['GET', 'POST']) +@permission_classes([IsTeamMember]) +@parser_classes([MultiPartParser, JSONParser]) +def asset_groups_view(request): + """GET /api/v1/assets/groups — list groups for current team. + POST /api/v1/assets/groups — create a group with an initial image. + """ + team = request.user.team + + if request.method == 'GET': + groups = ( + AssetGroup.objects + .filter(team=team) + .annotate(asset_count=Count('assets')) + .order_by('-created_at') + ) + results = [] + for g in groups: + results.append({ + 'id': g.id, + 'name': g.name, + 'thumbnail_url': g.thumbnail_url, + 'asset_count': g.asset_count, + 'remote_group_id': g.remote_group_id, + 'created_at': g.created_at.isoformat(), + }) + return Response({'results': results}) + + # ── POST: create group + first asset ── + name = request.data.get('name', '').strip() + if not name: + return Response({'error': '请输入角色名称'}, status=status.HTTP_400_BAD_REQUEST) + + file = request.FILES.get('file') + if not file: + return Response({'error': '请上传一张素材图片'}, status=status.HTTP_400_BAD_REQUEST) + + # Upload to TOS + try: + tos_url = tos_upload(file, folder='assets') + except Exception as e: + logger.exception('TOS upload failed for asset') + return Response( + {'error': f'文件上传失败: {e}'}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + # Create remote group + from utils import assets_client + remote_group_id = '' + result, err = _assets_api_call(assets_client.create_asset_group, name) + if err: + return err + if result is not None: + remote_group_id = result + + # Create remote asset + remote_asset_id = '' + if remote_group_id: + result, err = _assets_api_call(assets_client.create_asset, remote_group_id, tos_url, name) + if err: + return err + if result is not None: + remote_asset_id = result + + # Local DB records + group = AssetGroup.objects.create( + team=team, + remote_group_id=remote_group_id, + name=name, + description='', + thumbnail_url=tos_url, + created_by=request.user, + ) + Asset.objects.create( + group=group, + remote_asset_id=remote_asset_id, + name=name, + url=tos_url, + status='processing' if remote_asset_id else 'active', + error_message='', + ) + + return Response({ + 'id': group.id, + 'name': group.name, + 'thumbnail_url': group.thumbnail_url, + 'remote_group_id': group.remote_group_id, + 'asset_count': 1, + 'created_at': group.created_at.isoformat(), + }, status=status.HTTP_201_CREATED) + + +@api_view(['GET', 'PUT']) +@permission_classes([IsTeamMember]) +@parser_classes([JSONParser]) +def asset_group_detail_view(request, group_id): + """GET /api/v1/assets/groups/ — group info + assets. + PUT /api/v1/assets/groups/ — update name/description. + """ + team = request.user.team + try: + group = AssetGroup.objects.get(pk=group_id, team=team) + except AssetGroup.DoesNotExist: + return Response({'error': '素材组不存在'}, status=status.HTTP_404_NOT_FOUND) + + if request.method == 'GET': + assets_qs = Asset.objects.filter(group=group).order_by('-created_at') + asset_list = [] + for a in assets_qs: + asset_list.append({ + 'id': a.id, + 'name': a.name, + 'url': a.url, + 'status': a.status, + 'remote_asset_id': a.remote_asset_id, + 'error_message': a.error_message, + 'created_at': a.created_at.isoformat(), + }) + return Response({ + 'id': group.id, + 'name': group.name, + 'description': group.description, + 'thumbnail_url': group.thumbnail_url, + 'remote_group_id': group.remote_group_id, + 'created_at': group.created_at.isoformat(), + 'assets': asset_list, + }) + + # ── PUT ── + new_name = request.data.get('name') + new_desc = request.data.get('description') + + if new_name is None and new_desc is None: + return Response({'error': '请提供要更新的字段'}, status=status.HTTP_400_BAD_REQUEST) + + # Update remote + if group.remote_group_id: + from utils import assets_client + _, err = _assets_api_call( + assets_client.update_asset_group, + group.remote_group_id, name=new_name, description=new_desc, + ) + if err: + return err + + update_fields = [] + if new_name is not None: + group.name = new_name + update_fields.append('name') + if new_desc is not None: + group.description = new_desc + update_fields.append('description') + group.save(update_fields=update_fields) + + return Response({ + 'id': group.id, + 'name': group.name, + 'description': group.description, + 'thumbnail_url': group.thumbnail_url, + 'remote_group_id': group.remote_group_id, + }) + + +@api_view(['POST']) +@permission_classes([IsTeamMember]) +@parser_classes([MultiPartParser]) +def asset_group_add_asset_view(request, group_id): + """POST /api/v1/assets/groups//assets — add an image to a group.""" + team = request.user.team + try: + group = AssetGroup.objects.get(pk=group_id, team=team) + except AssetGroup.DoesNotExist: + return Response({'error': '素材组不存在'}, status=status.HTTP_404_NOT_FOUND) + + file = request.FILES.get('file') + if not file: + return Response({'error': '请上传文件'}, status=status.HTTP_400_BAD_REQUEST) + + name = request.data.get('name', '').strip() or file.name + + # Upload to TOS + try: + tos_url = tos_upload(file, folder='assets') + except Exception as e: + logger.exception('TOS upload failed for asset') + return Response( + {'error': f'文件上传失败: {e}'}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + # Create remote asset + from utils import assets_client + remote_asset_id = '' + if group.remote_group_id: + result, err = _assets_api_call( + assets_client.create_asset, group.remote_group_id, tos_url, name, + ) + if err: + return err + if result is not None: + remote_asset_id = result + + asset = Asset.objects.create( + group=group, + remote_asset_id=remote_asset_id, + name=name, + url=tos_url, + status='processing' if remote_asset_id else 'active', + error_message='', + ) + + # If first asset, set thumbnail + if not group.thumbnail_url: + group.thumbnail_url = tos_url + group.save(update_fields=['thumbnail_url']) + + return Response({ + 'id': asset.id, + 'name': asset.name, + 'url': asset.url, + 'status': asset.status, + 'remote_asset_id': asset.remote_asset_id, + 'created_at': asset.created_at.isoformat(), + }, status=status.HTTP_201_CREATED) + + +@api_view(['PUT']) +@permission_classes([IsTeamMember]) +@parser_classes([JSONParser]) +def asset_update_view(request, asset_id): + """PUT /api/v1/assets/ — rename an asset.""" + team = request.user.team + try: + asset = Asset.objects.select_related('group').get(pk=asset_id, group__team=team) + except Asset.DoesNotExist: + return Response({'error': '素材不存在'}, status=status.HTTP_404_NOT_FOUND) + + new_name = request.data.get('name') + if not new_name: + return Response({'error': '请提供素材名称'}, status=status.HTTP_400_BAD_REQUEST) + + if asset.remote_asset_id: + from utils import assets_client + _, err = _assets_api_call(assets_client.update_asset, asset.remote_asset_id, name=new_name) + if err: + return err + + asset.name = new_name + asset.save(update_fields=['name']) + + return Response({ + 'id': asset.id, + 'name': asset.name, + 'url': asset.url, + 'status': asset.status, + }) + + +@api_view(['GET']) +@permission_classes([IsTeamMember]) +def asset_search_view(request): + """GET /api/v1/assets/search?q=... — fast search for @ popup.""" + team = request.user.team + q = request.query_params.get('q', '').strip() + if not q: + return Response({'results': []}) + + groups = ( + AssetGroup.objects + .filter(team=team, name__icontains=q) + .order_by('-created_at')[:20] + ) + results = [] + for g in groups: + results.append({ + 'id': g.id, + 'name': g.name, + 'thumbnail_url': g.thumbnail_url, + 'remote_group_id': g.remote_group_id, + }) + return Response({'results': results}) + + +@api_view(['GET']) +@permission_classes([IsTeamMember]) +def asset_poll_status_view(request, asset_id): + """GET /api/v1/assets//status — poll remote processing status.""" + team = request.user.team + try: + asset = Asset.objects.select_related('group').get(pk=asset_id, group__team=team) + except Asset.DoesNotExist: + return Response({'error': '素材不存在'}, status=status.HTTP_404_NOT_FOUND) + + if asset.remote_asset_id: + from utils import assets_client + from utils.assets_client import AssetsAPIError + try: + result = assets_client.get_asset(asset.remote_asset_id) + remote_status = result.get('Status', '') + if remote_status == 'Active': + asset.status = 'active' + asset.url = result.get('Url', asset.url) + elif remote_status == 'Failed': + asset.status = 'failed' + asset.error_message = result.get('ErrorMessage', '') + else: + asset.status = 'processing' + asset.save(update_fields=['status', 'url', 'error_message']) + except AssetsAPIError as e: + error_str = str(e) + # 火山返回素材不存在 → 删除本地记录 + if 'not found' in error_str.lower() or 'NotFound' in e.code or 'NotExist' in e.code: + asset.delete() + return Response({'status': 'deleted', 'message': '素材在云端已被删除'}) + return Response( + {'error': 'assets_api_error', 'message': error_str}, + status=status.HTTP_502_BAD_GATEWAY, + ) + except Exception as e: + error_str = str(e) + # 空响应 / JSON 解析失败 = 火山已删除该素材 + if 'Expecting value' in error_str or 'JSONDecodeError' in type(e).__name__: + logger.info('Asset %s appears deleted on remote (empty response), removing local record', asset.remote_asset_id) + asset.delete() + return Response({'status': 'deleted', 'message': '素材在云端已被删除'}) + logger.exception('Assets API unexpected error in poll') + return Response( + {'error': 'assets_api_error', 'message': f'素材 API 调用失败: {e}'}, + status=status.HTTP_502_BAD_GATEWAY, + ) + + return Response({ + 'id': asset.id, + 'name': asset.name, + 'url': asset.url, + 'status': asset.status, + 'error_message': asset.error_message, + }) diff --git a/backend/config/settings.py b/backend/config/settings.py index 798d6c7..71531d6 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -210,6 +210,8 @@ ARK_API_KEY = os.environ.get('ARK_API_KEY', '') ARK_BASE_URL = os.environ.get('ARK_BASE_URL', 'https://ark.cn-beijing.volces.com/api/v3') # Set to True when Seedance model is activated on ARK platform SEEDANCE_ENABLED = os.environ.get('SEEDANCE_ENABLED', 'false').lower() == 'true' +# Set to True to enable the Assets API (virtual avatar library) +ASSETS_API_ENABLED = os.environ.get('ASSETS_API_ENABLED', 'false').lower() == 'true' # ────────────────────────────────────────────── # Aliyun SMS (短信告警) diff --git a/backend/requirements.txt b/backend/requirements.txt index 3580889..6612ae7 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,3 +7,4 @@ gunicorn>=21.2,<23.0 tos>=2.7,<3.0 requests>=2.31,<3.0 ip-region>=1.0 +volcengine>=1.0.218 diff --git a/backend/utils/airdrama_client.py b/backend/utils/airdrama_client.py index 9f58b32..63e05a9 100644 --- a/backend/utils/airdrama_client.py +++ b/backend/utils/airdrama_client.py @@ -6,26 +6,42 @@ from django.conf import settings # API error code → user-friendly Chinese message ERROR_MESSAGES = { - # Input content moderation - 'InputImageSensitiveContentDetected.PrivacyInformation': '参考图片中检测到真实人脸,系统不允许处理包含真人面部的图片', + # Input content moderation — 人脸/敏感内容 + 'InputImageSensitiveContentDetected.PrivacyInformation': '参考图片中检测到真实人脸,请使用虚拟人像素材替代真人照片', 'InputImageSensitiveContentDetected': '参考图片包含敏感内容,请更换图片后重试', 'InputVideoSensitiveContentDetected': '参考视频包含敏感内容,请更换视频后重试', 'InputTextSensitiveContentDetected': '提示词包含敏感内容,请修改后重试', + 'InputAudioSensitiveContentDetected': '参考音频包含敏感内容,请更换音频后重试', # Output content moderation - 'OutputVideoSensitiveContentDetected': '生成的视频包含敏感内容,已被系统拦截', + 'OutputVideoSensitiveContentDetected': '生成的视频包含敏感内容,已被系统拦截,请修改提示词后重试', 'OutputImageSensitiveContentDetected': '生成的图片包含敏感内容,已被系统拦截', - # Parameter & rate limit errors - 'InvalidParameter': '请求参数无效,请检查输入', - 'RateLimitExceeded': 'API 调用频率超限,请稍后重试', - 'ConcurrencyLimitExceeded': '并发数超限,请稍后重试', + # Parameter errors + 'InvalidParameter': '请求参数无效,请检查输入内容', + 'InvalidImage': '图片格式或尺寸不符合要求,请检查后重试', + 'InvalidVideo': '视频格式或尺寸不符合要求,请检查后重试', + 'InvalidAudio': '音频格式不符合要求,请检查后重试', + # Rate limit + 'RateLimitExceeded': '请求过于频繁,请稍后重试', + 'ConcurrencyLimitExceeded': '当前生成任务过多,请稍后重试', # Account & billing - 'InsufficientBalance': '账户余额不足,请联系管理员充值', + 'InsufficientBalance': '平台账户余额不足,请联系管理员', + # Asset errors + 'AssetNotFound': '引用的素材不存在或已被删除,请检查素材库', # Server errors 'ServerOverloaded': '服务器繁忙,请稍后重试', - 'InternalError': '服务内部错误,请稍后重试', + 'InternalError': '视频生成服务异常,请稍后重试', 'Timeout': '生成超时,请重试', } +# 关键词匹配:API 返回的 message 中包含这些关键词时,映射为对应中文提示 +_MESSAGE_KEYWORDS = { + 'face': '检测到真实人脸,请使用虚拟人像素材替代真人照片', + 'privacy': '检测到真实人脸,请使用虚拟人像素材替代真人照片', + 'sensitive': '内容包含敏感信息,请修改后重试', + 'not found': '引用的素材不存在或已被删除,请检查素材库', + 'not valid': '请求参数无效,请检查输入内容', +} + class AirDramaAPIError(Exception): """Raised when video generation API returns an error response.""" @@ -33,8 +49,16 @@ class AirDramaAPIError(Exception): self.code = code self.api_message = message self.status_code = status_code - # Use friendly message if available, otherwise use API message - self.user_message = ERROR_MESSAGES.get(code, message) + # 1. 精确匹配 error code + friendly = ERROR_MESSAGES.get(code) + if not friendly: + # 2. 关键词匹配 message 内容 + msg_lower = (message or '').lower() + for keyword, hint in _MESSAGE_KEYWORDS.items(): + if keyword in msg_lower: + friendly = hint + break + self.user_message = friendly or '生成失败,请重试' super().__init__(self.user_message) @@ -51,7 +75,8 @@ def _headers(): } -def create_task(prompt, model, content_items, aspect_ratio, duration, generate_audio=True): +def create_task(prompt, model, content_items, aspect_ratio, duration, + generate_audio=True, search_mode='off'): """Create a video generation task. Args: @@ -61,6 +86,7 @@ def create_task(prompt, model, content_items, aspect_ratio, duration, generate_a aspect_ratio: Video aspect ratio ('16:9', '9:16', etc.). duration: Video duration in seconds. generate_audio: Whether to generate audio with the video. + search_mode: 'smart' to enable internet search, 'off' to disable. Returns: dict: API response with task id and status. @@ -81,6 +107,13 @@ def create_task(prompt, model, content_items, aspect_ratio, duration, generate_a 'watermark': False, } + if search_mode and search_mode != 'off': + payload['tools'] = [{'type': 'web_search'}] + + import logging + logger = logging.getLogger(__name__) + logger.info('AirDrama API payload: %s', {k: v for k, v in payload.items() if k != 'content'}) + resp = requests.post(url, json=payload, headers=_headers(), timeout=60) if resp.status_code != 200: # Extract human-readable error from API response @@ -88,8 +121,10 @@ def create_task(prompt, model, content_items, aspect_ratio, duration, generate_a err = resp.json().get('error', {}) code = err.get('code', '') message = err.get('message', resp.text) + logger.error('AirDrama API error: status=%s code=%s message=%s', resp.status_code, code, message) except Exception: code, message = '', resp.text + logger.error('AirDrama API error: status=%s body=%s', resp.status_code, resp.text) raise AirDramaAPIError(code, message, resp.status_code) return resp.json() diff --git a/backend/utils/assets_client.py b/backend/utils/assets_client.py new file mode 100644 index 0000000..713772a --- /dev/null +++ b/backend/utils/assets_client.py @@ -0,0 +1,177 @@ +"""Volcano Engine Assets API client — uses volcengine SDK for AK/SK auth. + +All functions are synchronous and raise ``AssetsAPIError`` on API errors. +""" + +import json +import logging + +from django.conf import settings +from volcengine.ApiInfo import ApiInfo +from volcengine.base.Service import Service +from volcengine.Credentials import Credentials +from volcengine.ServiceInfo import ServiceInfo + +logger = logging.getLogger(__name__) + +SERVICE = 'ark' +REGION = 'cn-beijing' +API_VERSION = '2024-01-01' +HOST = 'open.volcengineapi.com' +PROJECT_NAME = 'int_dev_Airlabs' + + +class AssetsAPIError(Exception): + """Raised when the Assets API returns an error.""" + def __init__(self, code, message, status_code=400): + self.code = code + self.api_message = message + self.status_code = status_code + super().__init__(f'[{code}] {message}') + + +def _get_service(): + """Build a volcengine Service instance with AK/SK credentials.""" + ak = settings.TOS_ACCESS_KEY + sk = settings.TOS_SECRET_KEY + if not ak or not sk: + raise AssetsAPIError('ConfigError', 'TOS_ACCESS_KEY / TOS_SECRET_KEY not configured') + + service_info = ServiceInfo( + HOST, + {'Accept': 'application/json', 'Content-Type': 'application/json'}, + Credentials(ak, sk, SERVICE, REGION), + 10, 30, + ) + + api_info = { + 'CreateAssetGroup': ApiInfo('POST', '/', {'Action': 'CreateAssetGroup', 'Version': API_VERSION}, {}, {}), + 'CreateAsset': ApiInfo('POST', '/', {'Action': 'CreateAsset', 'Version': API_VERSION}, {}, {}), + 'ListAssetGroups': ApiInfo('POST', '/', {'Action': 'ListAssetGroups', 'Version': API_VERSION}, {}, {}), + 'ListAssets': ApiInfo('POST', '/', {'Action': 'ListAssets', 'Version': API_VERSION}, {}, {}), + 'GetAsset': ApiInfo('POST', '/', {'Action': 'GetAsset', 'Version': API_VERSION}, {}, {}), + 'GetAssetGroup': ApiInfo('POST', '/', {'Action': 'GetAssetGroup', 'Version': API_VERSION}, {}, {}), + 'UpdateAssetGroup': ApiInfo('POST', '/', {'Action': 'UpdateAssetGroup', 'Version': API_VERSION}, {}, {}), + 'UpdateAsset': ApiInfo('POST', '/', {'Action': 'UpdateAsset', 'Version': API_VERSION}, {}, {}), + } + + return Service(service_info, api_info) + + +def _do_request(action: str, body_dict: dict) -> dict: + """Send a signed POST to the Assets API and return the Result dict.""" + service = _get_service() + body = json.dumps(body_dict, ensure_ascii=False) + + try: + resp = service.json(action, {}, body) + except Exception as e: + error_str = str(e) + try: + error_data = json.loads(error_str) + err = error_data.get('error', {}) + raise AssetsAPIError(err.get('code', 'Unknown'), err.get('message', error_str)) + except (json.JSONDecodeError, AssetsAPIError): + raise + except Exception: + raise AssetsAPIError('RequestError', error_str) + + data = json.loads(resp) if isinstance(resp, str) else resp + + meta = data.get('ResponseMetadata', {}) + error = meta.get('Error', {}) + if error: + raise AssetsAPIError( + error.get('Code', 'Unknown'), + error.get('Message', str(data)), + ) + + return data.get('Result', {}) + + +# ────────────────────────────────────────────── +# Public helpers +# ────────────────────────────────────────────── + +def create_asset_group(name: str, description: str = '', group_type: str = 'AIGC') -> str: + """Create an asset group. Returns the remote group id.""" + body = { + 'Name': name, + 'Description': description, + 'GroupType': group_type, + 'ProjectName': PROJECT_NAME, + } + result = _do_request('CreateAssetGroup', body) + return result.get('Id', '') + + +def create_asset(group_id: str, image_url: str, name: str = '', asset_type: str = 'Image') -> str: + """Create an asset inside an existing group. Returns the remote asset id.""" + body = { + 'GroupId': group_id, + 'URL': image_url, + 'Name': name, + 'AssetType': asset_type, + 'ProjectName': PROJECT_NAME, + } + result = _do_request('CreateAsset', body) + return result.get('Id', '') + + +def list_asset_groups(page: int = 1, page_size: int = 20, name: str = None) -> tuple: + """List asset groups. Returns (items_list, total_count).""" + filter_dict = {'GroupType': 'AIGC'} + if name: + filter_dict['Name'] = name + body = { + 'Filter': filter_dict, + 'PageNumber': page, + 'PageSize': page_size, + 'ProjectName': PROJECT_NAME, + } + result = _do_request('ListAssetGroups', body) + return result.get('Items', []), result.get('TotalCount', 0) + + +def list_assets(group_ids: list = None, status: str = None, + name: str = None, page: int = 1, page_size: int = 20) -> tuple: + """List assets with optional filters. Returns (items_list, total_count).""" + filter_dict = {'GroupType': 'AIGC'} + if group_ids: + filter_dict['GroupIds'] = group_ids + if status: + filter_dict['Statuses'] = [status] + if name: + filter_dict['Name'] = name + body = { + 'Filter': filter_dict, + 'PageNumber': page, + 'PageSize': page_size, + 'ProjectName': PROJECT_NAME, + } + result = _do_request('ListAssets', body) + return result.get('Items', []), result.get('TotalCount', 0) + + +def get_asset(asset_id: str) -> dict: + """Get single asset details including processing status.""" + body = {'Id': asset_id, 'ProjectName': PROJECT_NAME} + return _do_request('GetAsset', body) + + +def update_asset_group(group_id: str, name: str = None, description: str = None): + """Update an asset group's name and/or description.""" + body = {'Id': group_id, 'ProjectName': PROJECT_NAME} + if name is not None: + body['Name'] = name + if description is not None: + body['Description'] = description + _do_request('UpdateAssetGroup', body) + + +def update_asset(asset_id: str, name: str = None): + """Update an asset's name.""" + body = {'Id': asset_id, 'ProjectName': PROJECT_NAME} + if name is not None: + body['Name'] = name + _do_request('UpdateAsset', body) diff --git a/web/src/App.tsx b/web/src/App.tsx index 0df3aee..dabe1f6 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -14,6 +14,7 @@ import { RecordsPage } from './pages/RecordsPage'; import { SettingsPage } from './pages/SettingsPage'; import { AuditLogsPage } from './pages/AuditLogsPage'; import { AnomalyLogPage } from './pages/AnomalyLogPage'; +import { LoginRecordsPage } from './pages/LoginRecordsPage'; import { ProfilePage } from './pages/ProfilePage'; import { AssetsPage } from './pages/AssetsPage'; @@ -79,6 +80,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/web/src/components/AssetLibraryModal.module.css b/web/src/components/AssetLibraryModal.module.css new file mode 100644 index 0000000..41177d9 --- /dev/null +++ b/web/src/components/AssetLibraryModal.module.css @@ -0,0 +1,394 @@ +.overlay { + position: fixed; + inset: 0; + z-index: 300; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; +} + +.modal { + width: 90vw; + max-width: 1400px; + min-height: 85vh; + max-height: 92vh; + background: #16161e; + border: 1px solid var(--color-border-card); + border-radius: 12px; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px 16px; + border-bottom: 1px solid var(--color-border-card); + flex-shrink: 0; +} + +.headerLeft { + display: flex; + align-items: center; + gap: 12px; +} + +.backBtn { + background: none; + border: none; + color: var(--color-text-secondary); + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + transition: color 0.15s; +} + +.backBtn:hover { + color: var(--color-text-primary); +} + +.title { + font-size: 16px; + font-weight: 600; + color: var(--color-text-primary); +} + +.closeBtn { + background: none; + border: none; + color: var(--color-text-secondary); + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + transition: color 0.15s; +} + +.closeBtn:hover { + color: var(--color-text-primary); +} + +.body { + padding: 20px 24px; + flex: 1; + overflow-y: auto; +} + +.actions { + display: flex; + gap: 8px; + margin-bottom: 16px; +} + +.actionBtn { + padding: 6px 14px; + background: var(--color-primary); + border: none; + border-radius: 8px; + color: #fff; + font-size: 13px; + cursor: pointer; + transition: filter 0.15s; +} + +.actionBtn:hover { + filter: brightness(1.15); +} + +.actionBtnOutline { + padding: 6px 14px; + background: transparent; + border: 1px solid var(--color-border-card); + border-radius: 8px; + color: var(--color-text-secondary); + font-size: 13px; + cursor: pointer; + transition: all 0.15s; +} + +.actionBtnOutline:hover { + background: var(--color-bg-hover); + color: var(--color-text-primary); +} + +.grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; +} + +.card { + background: var(--color-bg-card); + border: 1px solid var(--color-border-card); + border-radius: 12px; + overflow: hidden; + cursor: pointer; + transition: border-color 0.15s, transform 0.15s; +} + +.card:hover { + border-color: var(--color-primary); + transform: translateY(-2px); +} + +.cardThumb { + width: 100%; + height: 120px; + object-fit: cover; + display: block; + background: #1a1a2e; +} + +.cardInfo { + padding: 10px 12px; + display: flex; + align-items: center; + gap: 6px; +} + +.cardName { + flex: 1; + font-size: 13px; + color: var(--color-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.editBtn { + background: none; + border: none; + color: var(--color-text-secondary); + cursor: pointer; + padding: 2px; + font-size: 12px; + flex-shrink: 0; + transition: color 0.15s; +} + +.editBtn:hover { + color: var(--color-text-primary); +} + +.inlineEditWrap { + display: flex; + align-items: center; + gap: 4px; + flex: 1; + min-width: 0; +} + +.inlineInput { + flex: 1; + min-width: 0; + padding: 2px 6px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid var(--color-primary); + border-radius: 4px; + color: var(--color-text-primary); + font-size: 13px; + outline: none; +} + +/* Detail view - asset cards */ +.assetGrid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; +} + +.assetCard { + background: var(--color-bg-card); + border: 1px solid var(--color-border-card); + border-radius: 12px; + overflow: hidden; +} + +.assetThumb { + width: 100%; + height: 140px; + object-fit: cover; + display: block; + background: #1a1a2e; +} + +.assetInfo { + padding: 10px 12px; +} + +.assetName { + font-size: 13px; + color: var(--color-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 4px; +} + +.statusBadge { + display: inline-block; + font-size: 11px; + padding: 1px 6px; + border-radius: 4px; +} + +.statusActive { + color: var(--color-success); + background: rgba(0, 184, 148, 0.12); +} + +.statusProcessing { + color: var(--color-warning); + background: rgba(243, 156, 18, 0.12); +} + +.statusFailed { + color: var(--color-danger); + background: rgba(231, 76, 60, 0.12); +} + +/* Upload view */ +.uploadForm { + display: flex; + flex-direction: column; + gap: 16px; + max-width: 560px; + margin: 0 auto; +} + +.inputLabel { + font-size: 13px; + color: var(--color-text-secondary); + margin-bottom: 4px; +} + +.textInput { + width: 100%; + padding: 10px 14px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid var(--color-border-card); + border-radius: 8px; + color: var(--color-text-primary); + font-size: 14px; + outline: none; + transition: border-color 0.15s; +} + +.textInput:focus { + border-color: var(--color-primary); +} + +.dropZone { + border: 2px dashed var(--color-border-card); + border-radius: 12px; + padding: 40px 24px; + text-align: center; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; +} + +.dropZone:hover { + border-color: var(--color-primary); + background: rgba(108, 99, 255, 0.04); +} + +.dropZoneActive { + border-color: var(--color-primary); + background: rgba(108, 99, 255, 0.08); +} + +.dropZoneText { + font-size: 14px; + color: var(--color-text-secondary); + margin-bottom: 8px; +} + +.dropZoneHint { + font-size: 12px; + color: var(--color-text-disabled); +} + +.dropZoneWarning { + font-size: 14px; + font-weight: 600; + color: #ff4d4f; + margin-top: 12px; + padding: 8px 12px; + background: rgba(255, 77, 79, 0.08); + border: 1px solid rgba(255, 77, 79, 0.25); + border-radius: 6px; +} + +.dropZonePreview { + max-width: 200px; + max-height: 160px; + object-fit: contain; + border-radius: 8px; + margin-bottom: 8px; +} + +.submitBtn { + padding: 10px 0; + background: var(--color-primary); + border: none; + border-radius: 8px; + color: #fff; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: filter 0.15s; +} + +.submitBtn:hover { + filter: brightness(1.15); +} + +.submitBtn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-top: 20px; +} + +.pageBtn { + padding: 6px 12px; + background: transparent; + border: 1px solid var(--color-border-card); + border-radius: 6px; + color: var(--color-text-secondary); + font-size: 13px; + cursor: pointer; + transition: all 0.15s; +} + +.pageBtn:hover { + background: var(--color-bg-hover); + color: var(--color-text-primary); +} + +.pageBtn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.pageInfo { + font-size: 13px; + color: var(--color-text-secondary); +} + +.empty { + text-align: center; + padding: 40px 0; + color: var(--color-text-secondary); + font-size: 14px; +} diff --git a/web/src/components/AssetLibraryModal.tsx b/web/src/components/AssetLibraryModal.tsx new file mode 100644 index 0000000..e1f10ef --- /dev/null +++ b/web/src/components/AssetLibraryModal.tsx @@ -0,0 +1,428 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { useAssetLibraryStore } from '../store/assetLibrary'; +import { assetsApi } from '../lib/api'; +import { showToast } from './Toast'; +import type { AssetGroup, AssetItem } from '../types'; +import styles from './AssetLibraryModal.module.css'; + +interface Props { + open: boolean; + onClose: () => void; +} + +export function AssetLibraryModal({ open, onClose }: Props) { + const [view, setView] = useState<'list' | 'detail' | 'upload'>('list'); + const [selectedGroup, setSelectedGroup] = useState(null); + const [groupAssets, setGroupAssets] = useState([]); + const [newName, setNewName] = useState(''); + const [uploading, setUploading] = useState(false); + const [editingName, setEditingName] = useState<{ id: number; value: string } | null>(null); + const [uploadFile, setUploadFile] = useState(null); + const [uploadPreview, setUploadPreview] = useState(null); + const [dragOver, setDragOver] = useState(false); + const fileInputRef = useRef(null); + const addFileInputRef = useRef(null); + + const groups = useAssetLibraryStore((s) => s.groups); + const loading = useAssetLibraryStore((s) => s.loading); + const total = useAssetLibraryStore((s) => s.total); + const page = useAssetLibraryStore((s) => s.page); + const loadGroups = useAssetLibraryStore((s) => s.loadGroups); + const createGroup = useAssetLibraryStore((s) => s.createGroup); + const pollAssetStatus = useAssetLibraryStore((s) => s.pollAssetStatus); + + const totalPages = Math.ceil(total / 20); + + useEffect(() => { + if (open) { + loadGroups(1); + setView('list'); + setSelectedGroup(null); + } + }, [open, loadGroups]); + + const handleGroupClick = useCallback(async (group: AssetGroup) => { + setSelectedGroup(group); + try { + const { data } = await assetsApi.getGroupDetail(group.id); + const assets: AssetItem[] = data.assets || []; + setGroupAssets(assets); + // 对所有素材检查一次云端状态(处理中的更新状态,被删的清理掉) + let needRefresh = false; + const checks = assets.map((asset) => + assetsApi.pollStatus(asset.id).then(({ data: statusData }) => { + if (statusData.status !== asset.status || statusData.status as string === 'deleted') { + needRefresh = true; + } + }).catch(() => {}) + ); + Promise.all(checks).then(() => { + if (needRefresh) { + assetsApi.getGroupDetail(group.id).then(({ data: refreshed }) => { + setGroupAssets(refreshed.assets || []); + }).catch(() => {}); + } + }); + } catch { + setGroupAssets([]); + } + setView('detail'); + }, []); + + const handleBackToList = useCallback(() => { + setView('list'); + setSelectedGroup(null); + setGroupAssets([]); + setEditingName(null); + loadGroups(page); + }, [loadGroups, page]); + + const handleRenameGroup = useCallback(async (id: number, name: string) => { + try { + await assetsApi.updateGroup(id, { name }); + showToast('重命名成功'); + setEditingName(null); + loadGroups(page); + if (selectedGroup && selectedGroup.id === id) { + setSelectedGroup({ ...selectedGroup, name }); + } + } catch { + showToast('重命名失败'); + } + }, [loadGroups, page, selectedGroup]); + + const handleUploadSubmit = useCallback(async () => { + const trimmed = newName.trim(); + if (!trimmed || !uploadFile) return; + if (trimmed.length > 64) { showToast('角色名称不能超过64个字符'); return; } + if (trimmed.includes('&&')) { showToast('角色名称不能包含 &&'); return; } + setUploading(true); + const result = await createGroup(newName.trim(), uploadFile); + setUploading(false); + if (result) { + pollAssetStatus(result.id); + setNewName(''); + setUploadFile(null); + if (uploadPreview) URL.revokeObjectURL(uploadPreview); + setUploadPreview(null); + handleBackToList(); + } + }, [newName, uploadFile, createGroup, pollAssetStatus, uploadPreview, handleBackToList]); + + const handleFileSelect = useCallback((file: File) => { + if (uploadPreview) URL.revokeObjectURL(uploadPreview); + setUploadFile(file); + setUploadPreview(URL.createObjectURL(file)); + }, [uploadPreview]); + + const refreshGroupDetail = useCallback(async () => { + if (!selectedGroup) return; + try { + const { data } = await assetsApi.getGroupDetail(selectedGroup.id); + setGroupAssets(data.assets || []); + } catch { /* ignore */ } + }, [selectedGroup]); + + const handleAddAsset = useCallback(async (file: File) => { + if (!selectedGroup) return; + const formData = new FormData(); + formData.append('file', file); + try { + const { data } = await assetsApi.addAsset(selectedGroup.id, formData); + setGroupAssets((prev) => [...prev, data]); + // 轮询状态,完成后刷新详情 + const pollId = data.id; + const pollInterval = setInterval(async () => { + try { + const { data: statusData } = await assetsApi.pollStatus(pollId); + if (statusData.status !== 'processing') { + clearInterval(pollInterval); + if (statusData.status === 'active') showToast('素材已就绪'); + else if (statusData.status === 'deleted') showToast('素材在云端已被删除'); + else showToast('素材处理失败'); + refreshGroupDetail(); + } + } catch { + clearInterval(pollInterval); + } + }, 3000); + showToast('图片已上传,处理中...'); + } catch { + showToast('上传失败,请重试'); + } + }, [selectedGroup, refreshGroupDetail]); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + const file = e.dataTransfer.files[0]; + if (file && file.type.startsWith('image/')) { + handleFileSelect(file); + } + }, [handleFileSelect]); + + if (!open) return null; + + return ( +
{ if (e.target === e.currentTarget) onClose(); }}> +
+ {/* Header */} +
+
+ {view !== 'list' && ( + + )} + + {view === 'list' && '素材库'} + {view === 'detail' && (selectedGroup?.name || '角色详情')} + {view === 'upload' && '上传新角色'} + +
+ +
+ + {/* Body */} +
+ {/* List View */} + {view === 'list' && ( + <> +
+ +
+ + {loading ? ( +
加载中...
+ ) : groups.length === 0 ? ( +
暂无素材,点击上方按钮上传
+ ) : ( +
+ {groups.map((group) => ( +
handleGroupClick(group)}> + {group.name} +
+ {editingName && editingName.id === group.id ? ( +
e.stopPropagation()}> + setEditingName({ ...editingName, value: e.target.value })} + onKeyDown={(e) => { + if (e.key === 'Enter') handleRenameGroup(group.id, editingName.value); + if (e.key === 'Escape') setEditingName(null); + }} + autoFocus + /> + + +
+ ) : ( + <> + {group.name} + + + )} +
+
+ ))} +
+ )} + + {totalPages > 1 && ( +
+ + {page} / {totalPages} + +
+ )} + + )} + + {/* Detail View */} + {view === 'detail' && selectedGroup && ( + <> +
+ + + { + const file = e.target.files?.[0]; + if (file) handleAddAsset(file); + e.target.value = ''; + }} + /> +
+ + {editingName && editingName.id === selectedGroup.id && ( +
+ setEditingName({ ...editingName, value: e.target.value })} + onKeyDown={(e) => { + if (e.key === 'Enter') handleRenameGroup(selectedGroup.id, editingName.value); + if (e.key === 'Escape') setEditingName(null); + }} + autoFocus + /> + + +
+ )} + + {groupAssets.length === 0 ? ( +
暂无素材图片
+ ) : ( +
+ {groupAssets.map((asset) => ( +
+ {asset.name} +
+
{asset.name}
+ + {asset.status === 'active' && '可用'} + {asset.status === 'processing' && '处理中'} + {asset.status === 'failed' && '失败'} + +
+
+ ))} +
+ )} + + )} + + {/* Upload View */} + {view === 'upload' && ( +
+
+
角色名称
+ setNewName(e.target.value)} + /> +
+ +
+
角色图片
+
fileInputRef.current?.click()} + onDragOver={(e) => { e.preventDefault(); setDragOver(true); }} + onDragLeave={() => setDragOver(false)} + onDrop={handleDrop} + > + {uploadPreview ? ( + <> + 预览 +
点击重新选择
+ + ) : ( + <> +
上传角色图片
+
将角色的正面图或三视图拖拽到这里,或点击选择文件
+
支持 JPG、PNG 格式,单张不超过 30MB
+ + )} +
⚠️ 素材上传后无法删除,请确认后再上传
+
+ { + const file = e.target.files?.[0]; + if (file) handleFileSelect(file); + e.target.value = ''; + }} + /> +
+ + +
+ )} +
+
+
+ ); +} diff --git a/web/src/components/GenerationCard.module.css b/web/src/components/GenerationCard.module.css index e8be938..23b5581 100644 --- a/web/src/components/GenerationCard.module.css +++ b/web/src/components/GenerationCard.module.css @@ -3,7 +3,7 @@ border: none; border-radius: 0; padding: 20px 0; - max-width: 800px; + max-width: 1024px; width: 100%; animation: cardFadeIn 0.3s ease-out; border-bottom: 1px solid rgba(255, 255, 255, 0.06); @@ -17,8 +17,10 @@ /* Header */ .header { display: flex; + align-items: center; gap: 12px; margin-bottom: 12px; + position: relative; } .refColumn { @@ -40,7 +42,7 @@ } .refThumb { - height: 48px; + height: 56px; aspect-ratio: 3 / 4; border-radius: 6px; overflow: hidden; @@ -79,63 +81,63 @@ overflow: hidden; } -.promptTooltip { +/* hover 展开黑底:基于 .header 定位,左边距图片 4px */ +.promptExpanded { position: absolute; - top: 100%; - left: 0; + top: 0; right: 0; z-index: 10; - background: #1e1e2a; - border: 1px solid #2a2a38; - border-radius: 10px; - padding: 12px; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); - animation: tooltipFadeIn 0.15s ease-out; -} - -@keyframes tooltipFadeIn { - from { opacity: 0; transform: translateY(-4px); } - to { opacity: 1; transform: translateY(0); } -} - -.promptTooltipAbove { - top: auto; - bottom: 100%; - margin-bottom: 4px; - animation: tooltipFadeInAbove 0.15s ease-out; -} - -@keyframes tooltipFadeInAbove { - from { opacity: 0; transform: translateY(4px); } - to { opacity: 1; transform: translateY(0); } -} - -.promptTooltipText { - font-size: 13px; + font-size: 14px; color: var(--color-text-primary); line-height: 1.6; - margin-bottom: 8px; word-break: break-word; + background: rgba(13, 13, 26, 0.95); + backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.10); + padding: 6px 8px; + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); } -.copyBtn { - display: inline-flex; - align-items: center; - padding: 4px 12px; +.mentionTag { + display: inline; + padding: 1px 5px; + border-radius: 4px; + background: rgba(108, 99, 255, 0.12); + color: rgba(108, 99, 255, 0.7); + font-size: 13px; + white-space: nowrap; + cursor: default; +} + +.mentionPreview { + position: fixed; + z-index: 9999; + transform: translate(-50%, -100%); + background: #1e1e2e; + border: 1px solid #2a2a3a; + border-radius: 10px; + padding: 6px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + pointer-events: none; +} + +.mentionPreviewImg { + display: block; + width: 160px; + height: 100px; + object-fit: cover; border-radius: 6px; - font-size: 12px; - color: var(--color-primary); - background: rgba(108, 99, 255, 0.1); - border: 1px solid rgba(108, 99, 255, 0.2); - cursor: pointer; - transition: background 0.15s; - font-family: inherit; } -.copyBtn:hover { - background: rgba(108, 99, 255, 0.18); +.mentionPreviewLabel { + text-align: center; + color: #8a8a9a; + font-size: 11px; + margin-top: 4px; } + /* Inline labels after prompt text */ .labelsInline { display: inline; @@ -143,6 +145,7 @@ white-space: nowrap; } + .label { display: inline-flex; font-size: 12px; diff --git a/web/src/components/GenerationCard.tsx b/web/src/components/GenerationCard.tsx index 2174843..4dc743e 100644 --- a/web/src/components/GenerationCard.tsx +++ b/web/src/components/GenerationCard.tsx @@ -1,4 +1,5 @@ import { useRef, useState, useEffect, useCallback } from 'react'; +import { createPortal } from 'react-dom'; import type { GenerationTask } from '../types'; import { useGenerationStore } from '../store/generation'; import { showToast } from './Toast'; @@ -34,6 +35,83 @@ const DownloadIcon = () => ( ); +// Mention tag with thumbnail + hover preview +function MentionTag({ label, thumbUrl }: { label: string; thumbUrl?: string }) { + const [hover, setHover] = useState(false); + const ref = useRef(null); + const [pos, setPos] = useState({ top: 0, left: 0 }); + + return ( + <> + { + if (thumbUrl && ref.current) { + const rect = ref.current.getBoundingClientRect(); + setPos({ top: rect.top - 8, left: rect.left + rect.width / 2 }); + setHover(true); + } + }} + onMouseLeave={() => setHover(false)} + > + {thumbUrl && ( + + )} + {label} + + {hover && thumbUrl && createPortal( +
+ {label} +
{label}
+
, + document.body + )} + + ); +} + +// Render prompt text with @mentions as styled tags (thumbnail + hover preview) +export function renderPromptWithMentions( + text: string, + assetMentions: { label: string; thumbUrl?: string }[], + references: { label: string; previewUrl?: string }[] +) { + // Build lookup: label → thumbUrl + const thumbMap = new Map(); + for (const am of assetMentions) { + if (am.label) thumbMap.set(am.label, am.thumbUrl || ''); + } + for (const r of references) { + if (r.label && !thumbMap.has(r.label)) thumbMap.set(r.label, r.previewUrl || ''); + } + + const labels = [...thumbMap.keys()]; + if (labels.length === 0) return text; + + // Build regex: match @label patterns, longest first + labels.sort((a, b) => b.length - a.length); + const escaped = labels.map((l) => l.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + const regex = new RegExp(`(@(?:${escaped.join('|')}))`, 'g'); + + const parts = text.split(regex); + if (parts.length === 1) return text; + + return parts.map((part, i) => { + if (regex.test(part)) { + regex.lastIndex = 0; + const label = part.slice(1); // remove @ + return ; + } + regex.lastIndex = 0; + return part; + }); +} + interface Props { task: GenerationTask; onOpenDetail?: (task: GenerationTask) => void; @@ -49,6 +127,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) { const promptLineRef = useRef(null); const promptWrapperRef = useRef(null); const labelsRef = useRef(null); + const refColumnRef = useRef(null); const [videoHover, setVideoHover] = useState(false); const [promptHover, setPromptHover] = useState(false); const [showMore, setShowMore] = useState(false); @@ -56,8 +135,16 @@ export function GenerationCard({ task, onOpenDetail }: Props) { const [confirmDelete, setConfirmDelete] = useState(false); const [detailHover, setDetailHover] = useState(false); const [detailPos, setDetailPos] = useState({ top: 0, right: 0 }); - const [promptAbove, setPromptAbove] = useState(false); const detailLinkRef = useRef(null); + const detailLeaveTimer = useRef | null>(null); + + const startDetailLeave = useCallback(() => { + if (detailLeaveTimer.current) clearTimeout(detailLeaveTimer.current); + detailLeaveTimer.current = setTimeout(() => setDetailHover(false), 200); + }, []); + const cancelDetailLeave = useCallback(() => { + if (detailLeaveTimer.current) clearTimeout(detailLeaveTimer.current); + }, []); // Close more menu on click outside useEffect(() => { @@ -82,11 +169,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) { const style = getComputedStyle(container); const font = `${style.fontSize} ${style.fontFamily}`; - - // Measure labels width - const labelsWidth = labelsEl.offsetWidth + 8; // +8 for gap - - // Two lines of available width, minus labels on line 2, with safety margin + const labelsWidth = labelsEl.offsetWidth + 8; const totalAvailable = containerWidth * 2 - labelsWidth - 24; const canvas = document.createElement('canvas'); @@ -94,35 +177,28 @@ export function GenerationCard({ task, onOpenDetail }: Props) { ctx.font = font; const prompt = task.prompt || ''; - let totalWidth = 0; - let needsTruncation = false; - - // Check if prompt fits const fullWidth = ctx.measureText(prompt).width; if (fullWidth <= totalAvailable) { setTruncatedPrompt(prompt); return; } - // Truncate character by character let truncated = ''; + let totalWidth = 0; const ellipsisWidth = ctx.measureText('…').width; for (const char of prompt) { const charWidth = ctx.measureText(char).width; if (totalWidth + charWidth + ellipsisWidth > totalAvailable) { - needsTruncation = true; break; } truncated += char; totalWidth += charWidth; } - - setTruncatedPrompt(needsTruncation ? truncated + '…' : prompt); + setTruncatedPrompt(truncated + '…'); }, [task.prompt]); useEffect(() => { computeTruncation(); - const container = promptLineRef.current; if (!container) return; const ro = new ResizeObserver(() => computeTruncation()); @@ -194,7 +270,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
{/* Left: reference thumbnails */} {task.references.length > 0 && ( -
+
{task.references.map((ref) => (
{ref.type === 'video' ? ( @@ -219,20 +295,18 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
setPromptHover(false)} + onMouseLeave={() => { setPromptHover(false); startDetailLeave(); }} > + {/* 默认状态:截断提示词 + inline 标签 */}
+ setPromptHover(true)}> + {renderPromptWithMentions(truncatedPrompt || '(无文字描述)', task.assetMentions || [], task.references)} + { - const el = promptWrapperRef.current; - if (el) { - const rect = el.getBoundingClientRect(); - setPromptAbove(rect.bottom + 350 > window.innerHeight); - } - setPromptHover(true); - }} - >{truncatedPrompt || '(无文字描述)'} - setPromptHover(false)}> + ref={labelsRef} + className={styles.labelsInline} + onMouseEnter={() => setPromptHover(false)} + > {task.model === 'seedance_2.0' ? 'AirDrama' : 'AirDrama Fast'} @@ -242,6 +316,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) { ref={detailLinkRef} className={styles.detailLink} onMouseEnter={() => { + cancelDetailLeave(); const el = detailLinkRef.current; if (el) { const rect = el.getBoundingClientRect(); @@ -252,41 +327,64 @@ export function GenerationCard({ task, onOpenDetail }: Props) { } setDetailHover(true); }} - onMouseLeave={() => setDetailHover(false)} + onMouseLeave={startDetailLeave} > 详细信息 ⓘ - {detailHover && ( -
-
- 视频比例{task.aspectRatio} -
-
- 时长{task.duration}s -
-
- 分辨率720p -
-
- 模型 - {task.model === 'seedance_2.0' ? 'AirDrama' : 'AirDrama Fast'} -
-
- 生成时间 - {new Date(task.createdAt).toLocaleString('zh-CN')} -
-
- )}
- {promptHover && task.prompt && ( -
-

{task.prompt}

- -
- )}
+ {/* 详细信息弹窗 — 放在 promptWrapper 外,鼠标可以移到弹窗上 */} + {detailHover && ( +
{ cancelDetailLeave(); setDetailHover(true); }} + onMouseLeave={startDetailLeave} + > +
+ 视频比例{task.aspectRatio} +
+
+ 时长{task.duration}s +
+
+ 分辨率720p +
+
+ 模型 + {task.model === 'seedance_2.0' ? 'AirDrama' : 'AirDrama Fast'} +
+
+ 生成时间 + {new Date(task.createdAt).toLocaleString('zh-CN')} +
+ {(task.tokensConsumed ?? 0) > 0 && ( + <> +
+ 消耗 Tokens + {(task.tokensConsumed ?? 0).toLocaleString()} +
+
+ 费用 + ¥{(task.costAmount ?? 0).toFixed(2)} +
+ + )} +
+ )}
+ {/* hover 展开黑底:基于 header 定位,左边距图片 4px */} + {promptHover && task.prompt && ( +
setPromptHover(true)} + onMouseLeave={() => setPromptHover(false)} + > + {renderPromptWithMentions(task.prompt, task.assetMentions || [], task.references)} +
+ )}
{/* Video / result area */} diff --git a/web/src/components/InputBar.tsx b/web/src/components/InputBar.tsx index 3c11a01..d1f3d8b 100644 --- a/web/src/components/InputBar.tsx +++ b/web/src/components/InputBar.tsx @@ -1,9 +1,10 @@ -import { useRef, useCallback, type DragEvent } from 'react'; +import { useRef, useState, useCallback, type DragEvent } from 'react'; import { useInputBarStore } from '../store/inputBar'; import { UniversalUpload } from './UniversalUpload'; import { KeyframeUpload } from './KeyframeUpload'; import { PromptInput } from './PromptInput'; import { Toolbar } from './Toolbar'; +import { AssetLibraryModal } from './AssetLibraryModal'; import { showToast } from './Toast'; import styles from './InputBar.module.css'; @@ -15,7 +16,8 @@ export function InputBar() { const handleDragOver = useCallback((e: DragEvent) => { e.preventDefault(); - if (barRef.current) { + // 只有外部文件拖入时才显示蓝色边框(内部 mention 标签拖拽不触发) + if (e.dataTransfer.types.includes('Files') && barRef.current) { barRef.current.style.borderColor = '#00b8e6'; } }, []); @@ -71,9 +73,54 @@ export function InputBar() { } }, [mode, addReferences, setFirstFrame]); + const [assetModalOpen, setAssetModalOpen] = useState(false); + const searchMode = useInputBarStore((s) => s.searchMode); + const setSearchMode = useInputBarStore((s) => s.setSearchMode); + const references = useInputBarStore((s) => s.references); + const editorHtml = useInputBarStore((s) => s.editorHtml); + const firstFrame = useInputBarStore((s) => s.firstFrame); + const lastFrame = useInputBarStore((s) => s.lastFrame); + + // 联网搜索仅支持纯文生视频(无参考图/视频/音频/素材) + const hasMedia = references.length > 0 || !!firstFrame || !!lastFrame || editorHtml.includes('data-ref-type="asset"'); + const searchDisabled = hasMedia; + return (
+ {/* 素材库 + 联网搜索按钮 — 输入框上方 */} +
+ + +
+
+ setAssetModalOpen(false)} />
); } diff --git a/web/src/components/PromptInput.module.css b/web/src/components/PromptInput.module.css index bb45567..2c62e30 100644 --- a/web/src/components/PromptInput.module.css +++ b/web/src/components/PromptInput.module.css @@ -41,9 +41,24 @@ background: rgba(108, 99, 255, 0.12); color: rgba(108, 99, 255, 0.7); font-size: 13px; - cursor: default; + cursor: grab; user-select: none; - transition: background 0.15s; + transition: background 0.15s, opacity 0.15s; +} + +.mentionImg { + width: 16px; + height: 16px; + border-radius: 3px; + object-fit: cover; + vertical-align: middle; + margin-right: 3px; + display: inline-block; + pointer-events: none; +} + +.dragging { + opacity: 0.4; } .mention:hover { diff --git a/web/src/components/PromptInput.tsx b/web/src/components/PromptInput.tsx index bc08182..b387b8b 100644 --- a/web/src/components/PromptInput.tsx +++ b/web/src/components/PromptInput.tsx @@ -1,7 +1,8 @@ import { useRef, useEffect, useCallback, useState } from 'react'; import DOMPurify from 'dompurify'; import { useInputBarStore } from '../store/inputBar'; -import type { UploadedFile } from '../types'; +import { assetsApi } from '../lib/api'; +import type { UploadedFile, AssetGroup } from '../types'; import styles from './PromptInput.module.css'; const placeholders: Record = { @@ -25,6 +26,9 @@ export function PromptInput() { const [highlightedIdx, setHighlightedIdx] = useState(0); const [hoverRef, setHoverRef] = useState(null); const [hoverPos, setHoverPos] = useState({ top: 0, left: 0 }); + const [mentionMode, setMentionMode] = useState<'references' | 'assets'>('references'); + const [assetSearchResults, setAssetSearchResults] = useState([]); + const searchTimerRef = useRef | null>(null); // Auto-focus useEffect(() => { @@ -36,10 +40,11 @@ export function PromptInput() { const el = editorRef.current; if (!el) return; if (el.innerHTML !== editorHtml) { - el.innerHTML = DOMPurify.sanitize(editorHtml, { ALLOWED_TAGS: ['span', 'br'], ALLOWED_ATTR: ['class', 'contenteditable', 'data-ref-id', 'data-ref-type'] }); - // If the HTML is plain text but we have references, rebuild mention spans + el.innerHTML = DOMPurify.sanitize(editorHtml, { ALLOWED_TAGS: ['span', 'br', 'img'], ALLOWED_ATTR: ['class', 'contenteditable', 'data-ref-id', 'data-ref-type', 'data-asset-group-id', 'data-group-name', 'data-thumb-url', 'draggable', 'src', 'alt', 'width', 'height', 'style'] }); + // If the HTML is plain text but we have references or asset mentions, rebuild mention spans // This handles the case where editorHtml comes from backend (plain text only) - if (editorHtml && !editorHtml.includes('data-ref-id') && references.length > 0) { + const currentAssetMentions = useInputBarStore.getState().assetMentions || []; + if (editorHtml && !editorHtml.includes('data-ref-id') && (references.length > 0 || currentAssetMentions.length > 0)) { rebuildMentionSpans(el); } } @@ -55,26 +60,84 @@ export function PromptInput() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [insertAtTrigger]); + // Helper: create a mention span with optional thumbnail + const createMentionSpan = useCallback((opts: { + refId: string; refType: string; label: string; thumbUrl?: string; + assetGroupId?: string; groupName?: string; + }) => { + const span = document.createElement('span'); + span.className = styles.mention; + span.contentEditable = 'false'; + span.dataset.refId = opts.refId; + span.dataset.refType = opts.refType; + span.draggable = true; + if (opts.thumbUrl) span.dataset.thumbUrl = opts.thumbUrl; + if (opts.assetGroupId) span.dataset.assetGroupId = opts.assetGroupId; + if (opts.groupName) span.dataset.groupName = opts.groupName; + + if (opts.thumbUrl) { + const img = document.createElement('img'); + img.src = opts.thumbUrl; + img.className = styles.mentionImg; + // 显式设置尺寸,防止 CSS class 未生效时图片为 0x0 + img.setAttribute('width', '16'); + img.setAttribute('height', '16'); + img.style.cssText = 'width:16px;height:16px;border-radius:3px;object-fit:cover;vertical-align:middle;margin-right:3px;display:inline-block;pointer-events:none'; + span.appendChild(img); + } + // @ 前缀隐藏(textContent 保留用于模式匹配,视觉上不显示) + const atHidden = document.createElement('span'); + atHidden.style.cssText = 'font-size:0;width:0;overflow:hidden;display:inline'; + atHidden.textContent = '@'; + span.appendChild(atHidden); + span.appendChild(document.createTextNode(opts.label)); + return span; + }, []); + // Rebuild mention spans from plain text @label patterns const rebuildMentionSpans = useCallback((el: HTMLElement) => { + // Collect all targets to match: references + asset mentions + const currentAssetMentions = useInputBarStore.getState().assetMentions || []; + type MatchTarget = { label: string; refId: string; refType: string; thumbUrl: string; assetGroupId?: string; groupName?: string }; + const targets: MatchTarget[] = [ + ...references.map((ref) => ({ + label: ref.label, refId: ref.id, refType: ref.type, thumbUrl: ref.previewUrl, + })), + ...currentAssetMentions.map((am) => ({ + label: am.label, refId: am.groupId, refType: 'asset', thumbUrl: am.thumbUrl || '', + assetGroupId: am.groupId, groupName: am.label, + })), + ]; + + if (targets.length === 0) return; + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); - const replacements: { node: Text; matches: { start: number; end: number; ref: UploadedFile }[] }[] = []; + const replacements: { node: Text; matches: { start: number; end: number; target: MatchTarget }[] }[] = []; let textNode: Text | null; while ((textNode = walker.nextNode() as Text | null)) { const text = textNode.textContent || ''; - const matches: { start: number; end: number; ref: UploadedFile }[] = []; - for (const ref of references) { - const pattern = `@${ref.label}`; + const matches: { start: number; end: number; target: MatchTarget }[] = []; + for (const target of targets) { + const pattern = `@${target.label}`; let idx = text.indexOf(pattern); while (idx !== -1) { - matches.push({ start: idx, end: idx + pattern.length, ref }); + matches.push({ start: idx, end: idx + pattern.length, target }); idx = text.indexOf(pattern, idx + pattern.length); } } if (matches.length > 0) { + // Sort by position, remove overlapping matches matches.sort((a, b) => a.start - b.start); - replacements.push({ node: textNode, matches }); + const filtered: typeof matches = []; + let lastEnd = 0; + for (const m of matches) { + if (m.start >= lastEnd) { + filtered.push(m); + lastEnd = m.end; + } + } + replacements.push({ node: textNode, matches: filtered }); } } @@ -86,12 +149,14 @@ export function PromptInput() { if (m.start > lastIdx) { frag.appendChild(document.createTextNode(text.slice(lastIdx, m.start))); } - const span = document.createElement('span'); - span.className = styles.mention; - span.contentEditable = 'false'; - span.dataset.refId = m.ref.id; - span.dataset.refType = m.ref.type; - span.textContent = `@${m.ref.label}`; + const span = createMentionSpan({ + refId: m.target.refId, + refType: m.target.refType, + label: m.target.label, + thumbUrl: m.target.thumbUrl, + assetGroupId: m.target.assetGroupId, + groupName: m.target.groupName, + }); frag.appendChild(span); lastIdx = m.end; } @@ -104,7 +169,7 @@ export function PromptInput() { if (replacements.length > 0) { setEditorHtml(el.innerHTML); } - }, [references, setEditorHtml]); + }, [references, setEditorHtml, createMentionSpan]); const openMentionPopup = useCallback(() => { const el = editorRef.current; @@ -151,6 +216,7 @@ export function PromptInput() { }, [setPrompt, setEditorHtml]); // Remove orphaned mention spans when a reference is deleted + // Skip asset-type spans — they are not tied to uploaded references useEffect(() => { const el = editorRef.current; if (!el) return; @@ -158,6 +224,7 @@ export function PromptInput() { const spans = el.querySelectorAll('[data-ref-id]'); let changed = false; spans.forEach((span) => { + if (span.dataset.refType === 'asset') return; // skip asset mentions if (!refIds.has(span.dataset.refId!)) { span.replaceWith(''); changed = true; @@ -181,10 +248,39 @@ export function PromptInput() { const text = node.textContent || ''; const offset = range.startOffset; - if (offset > 0 && text[offset - 1] === '@' && references.length > 0) { - // Keep the @ visible, open popup above it - typedAtRef.current = true; - openMentionPopup(); + + // Find the last @ before cursor + const textBeforeCursor = text.substring(0, offset); + const lastAtIdx = textBeforeCursor.lastIndexOf('@'); + + if (lastAtIdx >= 0) { + const textAfterAt = textBeforeCursor.substring(lastAtIdx + 1); + + if (textAfterAt.length === 0 && references.length > 0) { + // Just typed @, show reference popup + typedAtRef.current = true; + setMentionMode('references'); + openMentionPopup(); + } else if (/[\u4e00-\u9fff]+/.test(textAfterAt) && !textAfterAt.includes(' ')) { + // Chinese text after @, search assets + if (searchTimerRef.current) clearTimeout(searchTimerRef.current); + searchTimerRef.current = setTimeout(() => { + assetsApi.search(textAfterAt).then((res) => { + if (res.data.results.length > 0) { + setAssetSearchResults(res.data.results); + setMentionMode('assets'); + typedAtRef.current = true; + setHighlightedIdx(0); + openMentionPopup(); + } else { + setShowMentionPopup(false); + } + }).catch(() => {}); + }, 300); + } else if (textAfterAt.includes(' ')) { + // Space after @ text, close popup + setShowMentionPopup(false); + } } }, [extractText, references.length, openMentionPopup]); @@ -217,13 +313,13 @@ export function PromptInput() { range.deleteContents(); - // Create mention span - const mention = document.createElement('span'); - mention.className = styles.mention; - mention.contentEditable = 'false'; - mention.dataset.refId = ref.id; - mention.dataset.refType = ref.type; - mention.textContent = `@${ref.label}`; + // Create mention span with thumbnail + const mention = createMentionSpan({ + refId: ref.id, + refType: ref.type, + label: ref.label, + thumbUrl: ref.previewUrl, + }); // Insert mention + trailing space range.insertNode(mention); @@ -241,23 +337,85 @@ export function PromptInput() { extractText(); }, [extractText]); + const insertAssetMention = useCallback((group: AssetGroup) => { + setShowMentionPopup(false); + setMentionMode('references'); + setAssetSearchResults([]); + const el = editorRef.current; + if (!el) return; + + el.focus(); + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0) return; + + const range = sel.getRangeAt(0); + + // Remove the @query text that was typed + if (typedAtRef.current) { + typedAtRef.current = false; + const node = range.startContainer; + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent || ''; + const offset = range.startOffset; + const atIdx = text.lastIndexOf('@', offset - 1); + if (atIdx >= 0) { + node.textContent = text.substring(0, atIdx) + text.substring(offset); + range.setStart(node, atIdx); + range.collapse(true); + } + } + } + + range.deleteContents(); + + // Create mention span for asset with thumbnail + const mention = createMentionSpan({ + refId: String(group.id), + refType: 'asset', + label: group.name, + thumbUrl: group.thumbnail_url, + assetGroupId: String(group.id), + groupName: group.name, + }); + + range.insertNode(mention); + + const space = document.createTextNode('\u00A0'); + mention.after(space); + + const newRange = document.createRange(); + newRange.setStartAfter(space); + newRange.collapse(true); + sel.removeAllRanges(); + sel.addRange(newRange); + + extractText(); + }, [extractText]); + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (showMentionPopup) { + const items = mentionMode === 'assets' ? assetSearchResults : references; + if (items.length === 0) return; if (e.key === 'Escape') { e.preventDefault(); setShowMentionPopup(false); + setMentionMode('references'); } else if (e.key === 'ArrowDown') { e.preventDefault(); - setHighlightedIdx((prev) => (prev + 1) % references.length); + setHighlightedIdx((prev) => (prev + 1) % items.length); } else if (e.key === 'ArrowUp') { e.preventDefault(); - setHighlightedIdx((prev) => (prev - 1 + references.length) % references.length); + setHighlightedIdx((prev) => (prev - 1 + items.length) % items.length); } else if (e.key === 'Enter') { e.preventDefault(); - insertMention(references[highlightedIdx]); + if (mentionMode === 'assets') { + insertAssetMention(assetSearchResults[highlightedIdx]); + } else { + insertMention(references[highlightedIdx]); + } } } - }, [showMentionPopup, references, highlightedIdx, insertMention]); + }, [showMentionPopup, mentionMode, references, assetSearchResults, highlightedIdx, insertMention, insertAssetMention]); const handlePaste = useCallback((e: React.ClipboardEvent) => { e.preventDefault(); @@ -276,6 +434,22 @@ export function PromptInput() { return; } + // Check if clipboard HTML contains mention spans (from our editor) + const html = e.clipboardData.getData('text/html'); + if (html && html.includes('data-ref-id')) { + const sanitized = DOMPurify.sanitize(html, { + ALLOWED_TAGS: ['span', 'br', 'img'], + ALLOWED_ATTR: [ + 'class', 'contenteditable', 'data-ref-id', 'data-ref-type', + 'data-asset-group-id', 'data-group-name', 'data-thumb-url', + 'draggable', 'src', 'alt', 'width', 'height', 'style', + ], + }); + document.execCommand('insertHTML', false, sanitized); + extractText(); + return; + } + // Plain text paste — strip @label patterns to prevent duplicate mention tags let text = e.clipboardData.getData('text/plain'); for (const ref of references) { @@ -288,17 +462,35 @@ export function PromptInput() { extractText(); }, [extractText, references]); - // Mention hover — delegated event + // Mention hover — delegated event (supports both reference and asset mentions) const handleMouseOver = useCallback((e: React.MouseEvent) => { const target = (e.target as HTMLElement).closest('[data-ref-id]') as HTMLElement | null; if (!target) return; + const refId = target.dataset.refId; - const ref = references.find((r) => r.id === refId); - if (!ref) return; + const refType = target.dataset.refType; + + // 参考图:从 references 中查找 + let found = references.find((r) => r.id === refId); + + // 素材库标签:用 data-thumb-url 构造预览数据 + if (!found && refType === 'asset') { + const thumbUrl = target.dataset.thumbUrl; + if (thumbUrl) { + found = { + id: refId || '', + type: 'image', + previewUrl: thumbUrl, + label: target.dataset.groupName || target.textContent || '', + }; + } + } + + if (!found) return; const rect = target.getBoundingClientRect(); const wrapperRect = editorRef.current!.parentElement!.getBoundingClientRect(); - setHoverRef(ref); + setHoverRef(found); setHoverPos({ top: rect.top - wrapperRect.top - 8, left: rect.left - wrapperRect.left + rect.width / 2, @@ -340,37 +532,110 @@ export function PromptInput() { onPaste={handlePaste} onMouseOver={handleMouseOver} onMouseOut={handleMouseOut} + onDragStart={(e) => { + const target = (e.target as HTMLElement).closest('[data-ref-id]') as HTMLElement | null; + if (target) { + e.dataTransfer.setData('text/html', target.outerHTML); + e.dataTransfer.effectAllowed = 'move'; + target.classList.add(styles.dragging); + setHoverRef(null); + } + }} + onDragOver={(e) => { + e.preventDefault(); + // 拖拽 mention 标签时让光标跟随鼠标位置 + if (!e.dataTransfer.types.includes('Files')) { + const range = document.caretRangeFromPoint(e.clientX, e.clientY); + if (range) { + const sel = window.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(range); + } + } + }} + onDrop={(e) => { + e.preventDefault(); + const html = e.dataTransfer.getData('text/html'); + if (html && html.includes('data-ref-id')) { + // 1. 先用鼠标坐标算出目标位置,插入临时 marker(此时 DOM 还没变) + const dropRange = document.caretRangeFromPoint(e.clientX, e.clientY); + if (!dropRange) return; + const marker = document.createTextNode('\u200B'); + dropRange.insertNode(marker); + + // 2. 再删除原始标签(DOM 重排不影响 marker 位置) + const dragging = editorRef.current?.querySelector(`.${styles.dragging}`); + if (dragging) dragging.remove(); + + // 3. 在 marker 位置插入标签 + const temp = document.createElement('div'); + temp.innerHTML = html; + const node = temp.firstChild; + if (node) { + marker.parentNode?.insertBefore(node, marker); + } + marker.remove(); + + editorRef.current?.normalize(); + extractText(); + } + }} /> {/* Mention popup */} - {showMentionPopup && references.length > 0 && ( + {showMentionPopup && (
-
可能@的内容
- {references.map((ref, idx) => ( - - ))} + {mentionMode === 'references' && references.length > 0 && ( + <> +
可能@的内容
+ {references.map((ref, idx) => ( + + ))} + + )} + {mentionMode === 'assets' && assetSearchResults.length > 0 && ( + <> +
素材库匹配
+ {assetSearchResults.map((group, idx) => ( + + ))} + + )}
)} diff --git a/web/src/components/Toolbar.tsx b/web/src/components/Toolbar.tsx index 580f304..f99a570 100644 --- a/web/src/components/Toolbar.tsx +++ b/web/src/components/Toolbar.tsx @@ -264,6 +264,7 @@ export function Toolbar() { +
); } diff --git a/web/src/components/VideoDetailModal.module.css b/web/src/components/VideoDetailModal.module.css index 0f93185..16f6358 100644 --- a/web/src/components/VideoDetailModal.module.css +++ b/web/src/components/VideoDetailModal.module.css @@ -236,7 +236,7 @@ .navArrowDisabled { opacity: 0.3; - pointer-events: none; + cursor: default; } /* ══════════════════════════════════════ @@ -428,7 +428,7 @@ .infoBar { display: flex; align-items: center; - justify-content: space-between; + justify-content: center; gap: 8px; padding: 12px 16px; border-radius: 10px; diff --git a/web/src/components/VideoDetailModal.tsx b/web/src/components/VideoDetailModal.tsx index 067d8f2..8b57bcf 100644 --- a/web/src/components/VideoDetailModal.tsx +++ b/web/src/components/VideoDetailModal.tsx @@ -5,6 +5,7 @@ import { AmbientBackground } from './AmbientBackground'; import { ConfirmModal } from './ConfirmModal'; import { ImageLightbox } from './ImageLightbox'; import { useInputBarStore } from '../store/inputBar'; +import { renderPromptWithMentions } from './GenerationCard'; import styles from './VideoDetailModal.module.css'; interface Props { @@ -468,7 +469,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
视频提示词
-

{task.prompt || '(无文字描述)'}

+

{renderPromptWithMentions(task.prompt || '(无文字描述)', task.assetMentions || [], task.references)}

{task.references.length > 0 && ( @@ -498,7 +499,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
{/* Re-edit button above info bar */} - {!hideReEdit &&
+ {!hideReEdit &&
- )} - {onRegenerate && ( - - )} -
- )}
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 31b1840..c9d4e6b 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -4,7 +4,7 @@ import type { AdminRecord, SystemSettings, ProfileOverview, PaginatedResponse, BackendTask, TeamInfo, Team, TeamDetail, TeamMember, TeamStats, AuditLog, AssetTeamSummary, AssetMemberSummary, AssetVideo, - LoginAnomaly, TeamAnomalyConfig, + LoginAnomaly, TeamAnomalyConfig, AssetGroup, AssetItem, } from '../types'; import { reportError } from './logCenter'; @@ -131,7 +131,8 @@ export const videoApi = { model: string; aspect_ratio: string; duration: number; - references: { url: string; type: string; role: string; label: string }[]; + references: { url: string; type: string; role: string; label: string; thumb_url?: string }[]; + search_mode?: string; }) => api.post<{ task_id: string; @@ -286,6 +287,17 @@ export const adminApi = { teamApplyLearnedRegions: (teamId: number, cities: string[]) => api.post(`/admin/teams/${teamId}/apply-learned-regions`, { cities }), + getLoginRecords: (params: { + page?: number; + page_size?: number; + search?: string; + team_id?: string; + start_date?: string; + end_date?: string; + city?: string; + } = {}) => + api.get('/admin/login-records', { params }), + getAuditLogs: (params: { page?: number; page_size?: number; @@ -356,4 +368,23 @@ export const profileApi = { }), }; +export const assetsApi = { + getGroups: (params: { page?: number; page_size?: number } = {}) => + api.get<{ results: AssetGroup[]; total: number }>('/assets/groups', { params }), + createGroup: (data: FormData) => + api.post('/assets/groups', data, { headers: { 'Content-Type': 'multipart/form-data' } }), + getGroupDetail: (id: number) => + api.get(`/assets/groups/${id}`), + updateGroup: (id: number, data: { name?: string; description?: string }) => + api.put(`/assets/groups/${id}`, data), + addAsset: (groupId: number, data: FormData) => + api.post(`/assets/groups/${groupId}/assets`, data, { headers: { 'Content-Type': 'multipart/form-data' } }), + updateAsset: (id: number, data: { name: string }) => + api.put(`/assets/${id}`, data), + search: (q: string) => + api.get<{ results: AssetGroup[] }>('/assets/search', { params: { q } }), + pollStatus: (id: number) => + api.get<{ id: number; status: string; url: string; error_message: string }>(`/assets/${id}/status`), +}; + export default api; diff --git a/web/src/pages/AdminAssetsPage.tsx b/web/src/pages/AdminAssetsPage.tsx index 7e39284..d3d01d9 100644 --- a/web/src/pages/AdminAssetsPage.tsx +++ b/web/src/pages/AdminAssetsPage.tsx @@ -49,6 +49,7 @@ function assetVideoToTask(v: AssetVideo): GenerationTask { aspectRatio: (v.aspect_ratio as any) || '16:9', duration: v.duration as any, references, + assetMentions: [], status: 'completed', progress: 100, resultUrl: v.result_url, diff --git a/web/src/pages/AdminLayout.tsx b/web/src/pages/AdminLayout.tsx index 1009157..89314f0 100644 --- a/web/src/pages/AdminLayout.tsx +++ b/web/src/pages/AdminLayout.tsx @@ -13,6 +13,7 @@ const navItems = [ { 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/security', label: '安全日志', icon: 'M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z' }, + { path: '/admin/login-records', label: '登录记录', icon: 'M11 7L9.6 8.4l2.6 2.6H2v2h10.2l-2.6 2.6L11 17l5-5-5-5zm9 12h-8v2h8c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2h-8v2h8v14z' }, { 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' }, ]; diff --git a/web/src/pages/LoginRecordsPage.module.css b/web/src/pages/LoginRecordsPage.module.css new file mode 100644 index 0000000..c1f8472 --- /dev/null +++ b/web/src/pages/LoginRecordsPage.module.css @@ -0,0 +1,46 @@ +.page { max-width: none; } +.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; max-width: none; } +.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); white-space: nowrap; } +.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); } +.ipCell { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--color-text-secondary); white-space: nowrap; } +.sourceBadge { padding: 2px 8px; border-radius: 4px; font-size: 12px; background: rgba(0, 184, 230, 0.12); color: var(--color-primary); white-space: nowrap; } + +.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; } diff --git a/web/src/pages/LoginRecordsPage.tsx b/web/src/pages/LoginRecordsPage.tsx new file mode 100644 index 0000000..684d65c --- /dev/null +++ b/web/src/pages/LoginRecordsPage.tsx @@ -0,0 +1,182 @@ +import { useEffect, useState, useCallback } from 'react'; +import { adminApi } from '../lib/api'; +import type { Team } from '../types'; +import { showToast } from '../components/Toast'; +import { DatePicker } from '../components/DatePicker'; +import { Select } from '../components/Select'; +import styles from './LoginRecordsPage.module.css'; + +interface LoginRecord { + id: number; + username: string; + user_id: number; + team_name: string | null; + ip_address: string; + geo_country: string; + geo_province: string; + geo_city: string; + geo_source: string; + user_agent: string; + created_at: string; +} + +const GEO_SOURCE_MAP: Record = { + online: '在线API', + offline: '离线库', + skip: '跳过', + failed: '失败', +}; + +function formatGeoSource(source: string): string { + if (!source) return '-'; + return GEO_SOURCE_MAP[source] || source; +} + +export function LoginRecordsPage() { + const [records, setRecords] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(''); + const [teamFilter, setTeamFilter] = useState(''); + const [citySearch, setCitySearch] = useState(''); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + const [teams, setTeams] = useState([]); + const [loading, setLoading] = useState(true); + const pageSize = 20; + + // Load teams for filter dropdown + useEffect(() => { + adminApi.getTeams().then(({ data }) => setTeams(data.results)).catch(() => {}); + }, []); + + const fetchRecords = useCallback(async () => { + setLoading(true); + try { + const { data } = await adminApi.getLoginRecords({ + page, page_size: pageSize, + search: search || undefined, + team_id: teamFilter || undefined, + start_date: startDate || undefined, + end_date: endDate || undefined, + city: citySearch || undefined, + }); + setRecords(data.results); + setTotal(data.total); + } catch { + showToast('加载登录记录失败'); + } finally { + setLoading(false); + } + }, [page, search, teamFilter, startDate, endDate, citySearch]); + + useEffect(() => { fetchRecords(); }, [fetchRecords]); + + const handleSearch = () => { + setPage(1); + fetchRecords(); + }; + + const totalPages = Math.ceil(total / pageSize); + + return ( +
+

登录记录

+ +
+ setSearch(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + /> + setCitySearch(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + /> + + ~ + + + +
+ +
+ + + + + + + + + + + + + + + {loading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + {Array.from({ length: 8 }).map((_, j) => ( + + ))} + + )) + ) : records.length === 0 ? ( + + ) : ( + records.map((r) => ( + + + + + + + + + + + )) + )} + +
用户名团队IP地址国家省份城市来源登录时间
暂无登录记录
{r.username}{r.team_name || '-'}{r.ip_address || '-'}{r.geo_country || '-'}{r.geo_province || '-'}{r.geo_city || '-'}{formatGeoSource(r.geo_source)}{new Date(r.created_at).toLocaleString('zh-CN')}
+
+ + {totalPages > 1 && ( +
+ 共 {total} 条 +
+ + {Array.from({ length: Math.min(totalPages, 5) }, (_, i) => { + let p: number; + if (totalPages <= 5) p = i + 1; + else if (page <= 3) p = i + 1; + else if (page >= totalPages - 2) p = totalPages - 4 + i; + else p = page - 2 + i; + return ( + + ); + })} + +
+
+ )} +
+ ); +} diff --git a/web/src/pages/TeamAssetsPage.tsx b/web/src/pages/TeamAssetsPage.tsx index e92877d..a5da389 100644 --- a/web/src/pages/TeamAssetsPage.tsx +++ b/web/src/pages/TeamAssetsPage.tsx @@ -49,6 +49,7 @@ function assetVideoToTask(v: AssetVideo): GenerationTask { aspectRatio: (v.aspect_ratio as any) || '16:9', duration: v.duration as any, references, + assetMentions: [], status: 'completed', progress: 100, resultUrl: v.result_url, diff --git a/web/src/pages/TeamsPage.tsx b/web/src/pages/TeamsPage.tsx index c1105f4..9d0e9f4 100644 --- a/web/src/pages/TeamsPage.tsx +++ b/web/src/pages/TeamsPage.tsx @@ -322,7 +322,7 @@ export function TeamsPage() {
- setNewExpectedRegions(e.target.value)} placeholder="广州市,深圳市,北京市" /> + setNewExpectedRegions(e.target.value)} placeholder="广州,深圳,北京" />
{createError &&
{createError}
}
diff --git a/web/src/store/assetLibrary.ts b/web/src/store/assetLibrary.ts new file mode 100644 index 0000000..0988a33 --- /dev/null +++ b/web/src/store/assetLibrary.ts @@ -0,0 +1,79 @@ +import { create } from 'zustand'; +import { assetsApi } from '../lib/api'; +import type { AssetGroup } from '../types'; +import { showToast } from '../components/Toast'; + +interface AssetLibraryState { + groups: AssetGroup[]; + loading: boolean; + total: number; + page: number; + searchResults: AssetGroup[]; + searching: boolean; + + loadGroups: (page?: number) => Promise; + searchAssets: (query: string) => Promise; + createGroup: (name: string, file: File) => Promise; + pollAssetStatus: (assetId: number) => void; +} + +export const useAssetLibraryStore = create((set) => ({ + groups: [], + loading: false, + total: 0, + page: 1, + searchResults: [], + searching: false, + + loadGroups: async (page = 1) => { + set({ loading: true }); + try { + const { data } = await assetsApi.getGroups({ page, page_size: 20 }); + set({ groups: data.results, total: data.total, page, loading: false }); + } catch { + set({ loading: false }); + } + }, + + searchAssets: async (query: string) => { + set({ searching: true }); + try { + const { data } = await assetsApi.search(query); + set({ searchResults: data.results, searching: false }); + } catch { + set({ searching: false }); + } + }, + + createGroup: async (name: string, file: File) => { + const formData = new FormData(); + formData.append('name', name); + formData.append('file', file); + try { + const { data } = await assetsApi.createGroup(formData); + showToast('角色创建成功'); + return data; + } catch { + showToast('创建失败,请重试'); + return null; + } + }, + + pollAssetStatus: (assetId: number) => { + const poll = async () => { + try { + const { data } = await assetsApi.pollStatus(assetId); + if (data.status === 'processing') { + setTimeout(poll, 3000); + } else if (data.status === 'active') { + showToast('素材处理完成'); + } else if (data.status === 'failed') { + showToast(data.error_message || '素材处理失败'); + } + } catch { + setTimeout(poll, 3000); + } + }; + setTimeout(poll, 3000); + }, +})); diff --git a/web/src/store/generation.ts b/web/src/store/generation.ts index 6cf27e6..c54aaf9 100644 --- a/web/src/store/generation.ts +++ b/web/src/store/generation.ts @@ -56,13 +56,36 @@ function mapProgress(backendStatus: string): number { // Convert a BackendTask to a frontend GenerationTask function backendToFrontend(bt: BackendTask): GenerationTask { - const references: ReferenceSnapshot[] = (bt.reference_urls || []).map((ref, i) => ({ - id: `ref_${bt.task_id}_${i}`, - type: (ref.type || 'image') as 'image' | 'video', - previewUrl: ref.url, - label: ref.label || `素材${i + 1}`, - role: ref.role, - })); + const allRefs = bt.reference_urls || []; + + // 普通引用(参考图/视频/音频)— 可显示的缩略图 + const references: ReferenceSnapshot[] = allRefs + .filter((ref) => { + const url = ref.url || ''; + return !url.startsWith('asset://') && !url.startsWith('Asset://'); + }) + .map((ref, i) => ({ + id: `ref_${bt.task_id}_${i}`, + type: (ref.type || 'image') as 'image' | 'video', + previewUrl: ref.url, + label: ref.label || `素材${i + 1}`, + role: ref.role, + })); + + // Asset 引用 — 仅用于 reEdit/regenerate 重建 mention span + const assetMentions = allRefs + .filter((ref) => { + const url = ref.url || ''; + return url.startsWith('asset://') || url.startsWith('Asset://'); + }) + .map((ref) => { + const url = ref.url || ''; + let groupId = ''; + if (url.startsWith('asset://group-')) { + groupId = url.replace('asset://group-', ''); + } + return { groupId, label: ref.label || '', thumbUrl: (ref as Record).thumb_url || '' }; + }); return { id: `backend_${bt.task_id}`, @@ -74,6 +97,7 @@ function backendToFrontend(bt: BackendTask): GenerationTask { aspectRatio: bt.aspect_ratio as GenerationTask['aspectRatio'], duration: bt.duration as GenerationTask['duration'], references, + assetMentions, status: mapStatus(bt.status), progress: bt.status === 'processing' ? Number(sessionStorage.getItem(`progress_${bt.task_id}`) || mapProgress(bt.status)) : mapProgress(bt.status), resultUrl: bt.result_url || undefined, @@ -303,6 +327,31 @@ export const useGenerationStore = create((set, get) => ({ }, ].filter(Boolean) as ReferenceSnapshot[]; + // Extract asset mentions for placeholder display + const placeholderAssetMentions: { groupId: string; label: string; thumbUrl: string }[] = []; + if (input.editorHtml) { + const parser = new DOMParser(); + const doc = parser.parseFromString(input.editorHtml, 'text/html'); + const spans = doc.querySelectorAll('[data-ref-type="asset"]'); + const seen = new Set(); + spans.forEach((span) => { + const el = span as HTMLElement; + const gid = el.dataset.assetGroupId; + if (gid && !seen.has(gid)) { + seen.add(gid); + placeholderAssetMentions.push({ + groupId: gid, + label: el.dataset.groupName || el.textContent?.replace('@', '') || '', + thumbUrl: el.dataset.thumbUrl || '', + }); + } + }); + } + // Fallback: from inputBar store (regenerate 场景 editorHtml 是纯文本) + if (placeholderAssetMentions.length === 0 && input.assetMentions?.length) { + placeholderAssetMentions.push(...input.assetMentions); + } + // Create a placeholder task immediately for UI feedback const tempId = `temp_${Date.now()}`; const placeholderTask: GenerationTask = { @@ -315,6 +364,7 @@ export const useGenerationStore = create((set, get) => ({ aspectRatio: input.aspectRatio, duration: input.duration, references: localRefs, + assetMentions: placeholderAssetMentions, status: 'generating', progress: 0, createdAt: Date.now(), @@ -330,13 +380,14 @@ export const useGenerationStore = create((set, get) => ({ prompt: '', editorHtml: '', references: [], + assetMentions: [], firstFrame: null, lastFrame: null, }); try { // Upload files to TOS (or reuse existing TOS URLs) - const uploadedRefs: { url: string; type: string; role: string; label: string }[] = []; + const uploadedRefs: { url: string; type: string; role: string; label: string; thumb_url?: string }[] = []; for (const item of filesToUpload) { if (item.tosUrl) { @@ -347,6 +398,44 @@ export const useGenerationStore = create((set, get) => ({ } } + // Extract asset mentions from editor HTML — deduplicate by groupId + const seenGroupIds = new Set(); + if (input.editorHtml) { + const parser = new DOMParser(); + const doc = parser.parseFromString(input.editorHtml, 'text/html'); + const assetSpans = doc.querySelectorAll('[data-ref-type="asset"]'); + assetSpans.forEach((span) => { + const el = span as HTMLElement; + const groupId = el.dataset.assetGroupId; + const groupName = el.dataset.groupName || el.textContent?.replace('@', '') || ''; + if (groupId && !seenGroupIds.has(groupId)) { + seenGroupIds.add(groupId); + uploadedRefs.push({ + url: `asset://group-${groupId}`, + type: 'image', + role: 'reference_image', + label: groupName, + thumb_url: el.dataset.thumbUrl || '', + }); + } + }); + } + + // Fallback: also add from inputBar assetMentions (for regenerate scenario) + const inputAssetMentions = input.assetMentions || []; + for (const am of inputAssetMentions) { + if (am.groupId && !seenGroupIds.has(am.groupId)) { + seenGroupIds.add(am.groupId); + uploadedRefs.push({ + url: `asset://group-${am.groupId}`, + type: 'image', + role: 'reference_image', + label: am.label, + thumb_url: am.thumbUrl || '', + }); + } + } + // Call generate API const { data: genResult } = await videoApi.generate({ prompt: input.prompt, @@ -355,6 +444,7 @@ export const useGenerationStore = create((set, get) => ({ aspect_ratio: input.aspectRatio, duration: input.duration, references: uploadedRefs, + search_mode: input.searchMode || 'off', }); // Update task with real backend IDs @@ -396,14 +486,15 @@ export const useGenerationStore = create((set, get) => ({ return frontendId; } catch (err: unknown) { - const error = err as { response?: { status?: number; data?: { message?: string } } }; - const msg = error.response?.data?.message; - showToast(msg || '生成失败,请重试'); + const error = err as { response?: { status?: number; data?: { message?: string; error_message?: string } } }; + const msg = error.response?.data?.error_message || error.response?.data?.message || '生成失败,请重试'; + const displayMsg = mapErrorMessage(msg) || msg; + showToast(displayMsg); - // Mark task as failed + // Mark task as failed with error message set((s) => ({ tasks: s.tasks.map((t) => - t.id === tempId ? { ...t, status: 'failed' as const, progress: 0 } : t + t.id === tempId ? { ...t, status: 'failed' as const, progress: 0, errorMessage: displayMsg } : t ), })); @@ -431,6 +522,7 @@ export const useGenerationStore = create((set, get) => ({ } if (task.mode === 'universal') { + // task.references only contains file refs (assets filtered in backendToFrontend) const references: UploadedFile[] = task.references.map((r) => ({ id: r.id, type: r.type, @@ -444,6 +536,7 @@ export const useGenerationStore = create((set, get) => ({ aspectRatio: task.aspectRatio, duration: task.duration, references, + assetMentions: task.assetMentions || [], }); } else { // Keyframe mode: restore firstFrame and lastFrame @@ -454,6 +547,7 @@ export const useGenerationStore = create((set, get) => ({ editorHtml: task.editorHtml || task.prompt, aspectRatio: task.aspectRatio, duration: task.duration, + assetMentions: [], firstFrame: firstRef ? { id: firstRef.id, type: firstRef.type, previewUrl: firstRef.previewUrl, label: '首帧', tosUrl: firstRef.previewUrl } : null, lastFrame: lastRef ? { id: lastRef.id, type: lastRef.type, previewUrl: lastRef.previewUrl, label: '尾帧', tosUrl: lastRef.previewUrl } : null, }); @@ -478,7 +572,7 @@ export const useGenerationStore = create((set, get) => ({ type: r.type, previewUrl: r.previewUrl, label: r.label, - tosUrl: r.previewUrl, // TOS URL from previous upload + tosUrl: r.previewUrl, })); useInputBarStore.setState({ @@ -488,6 +582,7 @@ export const useGenerationStore = create((set, get) => ({ aspectRatio: task.aspectRatio, duration: task.duration, references: task.mode === 'universal' ? references : [], + assetMentions: task.assetMentions || [], }); // Trigger generation diff --git a/web/src/store/inputBar.ts b/web/src/store/inputBar.ts index cd9c0eb..354752f 100644 --- a/web/src/store/inputBar.ts +++ b/web/src/store/inputBar.ts @@ -56,6 +56,13 @@ interface InputBarState { // Computed canSubmit: () => boolean; + // Search mode (联网搜索) + searchMode: 'smart' | 'off'; + setSearchMode: (mode: 'smart' | 'off') => void; + + // Asset mentions (for reEdit/regenerate to pass asset data to PromptInput rebuild) + assetMentions: { groupId: string; label: string; thumbUrl: string }[]; + // @ trigger (for toolbar button to insert @ in contentEditable) insertAtTrigger: number; triggerInsertAt: () => void; @@ -202,6 +209,11 @@ export const useInputBarStore = create((set, get) => ({ return true; }, + searchMode: 'off', + setSearchMode: (searchMode) => set({ searchMode }), + + assetMentions: [], + insertAtTrigger: 0, triggerInsertAt: () => set((s) => ({ insertAtTrigger: s.insertAtTrigger + 1 })), @@ -254,6 +266,7 @@ export const useInputBarStore = create((set, get) => ({ editorHtml: '', references: [], prevReferences: [], + assetMentions: [], firstFrame: null, lastFrame: null, generationType: 'video', diff --git a/web/src/types/index.ts b/web/src/types/index.ts index ce0d377..ead7e06 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -41,6 +41,7 @@ export interface GenerationTask { aspectRatio: AspectRatio; duration: Duration; references: ReferenceSnapshot[]; + assetMentions: { groupId: string; label: string; thumbUrl: string }[]; status: TaskStatus; progress: number; resultUrl?: string; @@ -390,3 +391,23 @@ export interface AuditLog { ip_address: string | null; created_at: string; } + +export interface AssetGroup { + id: number; + name: string; + thumbnail_url: string; + asset_count: number; + remote_group_id: string; + description: string; + created_at: string; +} + +export interface AssetItem { + id: number; + name: string; + url: string; + status: 'processing' | 'active' | 'failed'; + remote_asset_id: string; + error_message: string; + created_at: string; +}