feat: v0.11.0 素材库功能 + 生成页面 UI 优化

素材库(虚拟人像):
- 后端: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) <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-03-22 03:11:05 +08:00
parent 5bb49b5940
commit 6c364f4c3f
30 changed files with 2725 additions and 230 deletions

View File

@ -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'],
},
),
]

View File

@ -99,3 +99,56 @@ class QuotaConfig(models.Model):
def __str__(self): def __str__(self):
return f'全局配额: {self.default_daily_seconds_limit}s/日, {self.default_monthly_seconds_limit}s/月' 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}'

View File

@ -35,6 +35,9 @@ urlpatterns = [
path('admin/settings', views.admin_settings_view, name='admin_settings'), path('admin/settings', views.admin_settings_view, name='admin_settings'),
path('admin/logs', views.admin_audit_logs_view, name='admin_audit_logs'), 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 ── # ── Super Admin: Anomaly Detection ──
path('admin/anomalies', views.admin_login_anomalies_view, name='admin_login_anomalies'), 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'), path('admin/test-feishu', views.admin_test_feishu_view, name='admin_test_feishu'),
@ -62,4 +65,12 @@ urlpatterns = [
# ── Profile: User's own data ── # ── Profile: User's own data ──
path('profile/overview', views.profile_overview_view, name='profile_overview'), path('profile/overview', views.profile_overview_view, name='profile_overview'),
path('profile/records', views.profile_records_view, name='profile_records'), 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/<int:group_id>', views.asset_group_detail_view, name='asset_group_detail'),
path('assets/groups/<int:group_id>/assets', views.asset_group_add_asset_view, name='asset_group_add_asset'),
path('assets/<int:asset_id>', views.asset_update_view, name='asset_update'),
path('assets/<int:asset_id>/status', views.asset_poll_status_view, name='asset_poll_status'),
path('assets/search', views.asset_search_view, name='asset_search'),
] ]

View File

@ -13,7 +13,7 @@ from django.db.models.functions import TruncDate
from django.db.utils import OperationalError as DbOperationalError from django.db.utils import OperationalError as DbOperationalError
from datetime import timedelta from datetime import timedelta
from .models import GenerationRecord, QuotaConfig from .models import GenerationRecord, QuotaConfig, AssetGroup, Asset
from .serializers import ( from .serializers import (
VideoGenerateSerializer, QuotaUpdateSerializer, VideoGenerateSerializer, QuotaUpdateSerializer,
UserStatusSerializer, SystemSettingsSerializer, UserStatusSerializer, SystemSettingsSerializer,
@ -22,7 +22,7 @@ from .serializers import (
TeamAdminCreateSerializer, TeamMemberCreateSerializer, MemberQuotaSerializer, TeamAdminCreateSerializer, TeamMemberCreateSerializer, MemberQuotaSerializer,
TeamAnomalyConfigSerializer, 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 apps.accounts.permissions import IsSuperAdmin, IsTeamAdmin, IsTeamMember
from utils.tos_client import upload_file as tos_upload from utils.tos_client import upload_file as tos_upload
from utils.airdrama_client import create_task, query_task, extract_video_url, map_status from utils.airdrama_client import create_task, query_task, extract_video_url, map_status
@ -152,6 +152,7 @@ def video_generate_view(request):
mode = serializer.validated_data['mode'] mode = serializer.validated_data['mode']
model = serializer.validated_data['model'] model = serializer.validated_data['model']
aspect_ratio = serializer.validated_data['aspect_ratio'] aspect_ratio = serializer.validated_data['aspect_ratio']
search_mode = request.data.get('search_mode', 'off')
# ── 预估 token 和费用 ── # ── 预估 token 和费用 ──
config = QuotaConfig.objects.get_or_create(pk=1)[0] config = QuotaConfig.objects.get_or_create(pk=1)[0]
@ -220,29 +221,59 @@ def video_generate_view(request):
references = request.data.get('references', []) references = request.data.get('references', [])
reference_snapshots = [] reference_snapshots = []
content_items = [] content_items = []
seen_urls = set() # 去重:同一个素材只引用一次
from .models import Asset as AssetModel
for ref in references: for ref in references:
url = ref.get('url', '') url = ref.get('url', '')
original_url = url # 保留原始 URL 用于 reference_snapshots
ref_type = ref.get('type', 'image') ref_type = ref.get('type', 'image')
role = ref.get('role', '') role = ref.get('role', '')
label = ref.get('label', '') label = ref.get('label', '')
reference_snapshots.append({ # 跳过重复 URL — 在所有操作之前判断
'url': url, 'type': ref_type, 'role': role, 'label': label, 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': if ref_type == 'image':
item = {'type': 'image_url', 'image_url': {'url': url}} item = {'type': 'image_url', 'image_url': {'url': resolved_url}}
if role: if role:
item['role'] = role item['role'] = role
content_items.append(item) content_items.append(item)
elif ref_type == 'video': elif ref_type == 'video':
item = {'type': 'video_url', 'video_url': {'url': url}} item = {'type': 'video_url', 'video_url': {'url': resolved_url}}
if role: if role:
item['role'] = role item['role'] = role
content_items.append(item) content_items.append(item)
elif ref_type == 'audio': elif ref_type == 'audio':
item = {'type': 'audio_url', 'audio_url': {'url': url}} item = {'type': 'audio_url', 'audio_url': {'url': resolved_url}}
if role: if role:
item['role'] = role item['role'] = role
content_items.append(item) content_items.append(item)
@ -278,6 +309,7 @@ def video_generate_view(request):
content_items=content_items, content_items=content_items,
aspect_ratio=aspect_ratio, aspect_ratio=aspect_ratio,
duration=duration, duration=duration,
search_mode=search_mode,
) )
ark_task_id = ark_response.get('id', '') ark_task_id = ark_response.get('id', '')
record.ark_task_id = ark_task_id record.ark_task_id = ark_task_id
@ -2450,3 +2482,432 @@ def team_assets_member_videos(request, member_id):
'page_size': page_size, 'page_size': page_size,
'results': results, '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/<id> — group info + assets.
PUT /api/v1/assets/groups/<id> 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/<id>/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/<id> — 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/<id>/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,
})

View File

@ -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') 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 # Set to True when Seedance model is activated on ARK platform
SEEDANCE_ENABLED = os.environ.get('SEEDANCE_ENABLED', 'false').lower() == 'true' 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 (短信告警) # Aliyun SMS (短信告警)

View File

@ -7,3 +7,4 @@ gunicorn>=21.2,<23.0
tos>=2.7,<3.0 tos>=2.7,<3.0
requests>=2.31,<3.0 requests>=2.31,<3.0
ip-region>=1.0 ip-region>=1.0
volcengine>=1.0.218

View File

@ -6,26 +6,42 @@ from django.conf import settings
# API error code → user-friendly Chinese message # API error code → user-friendly Chinese message
ERROR_MESSAGES = { ERROR_MESSAGES = {
# Input content moderation # Input content moderation — 人脸/敏感内容
'InputImageSensitiveContentDetected.PrivacyInformation': '参考图片中检测到真实人脸,系统不允许处理包含真人面部的图', 'InputImageSensitiveContentDetected.PrivacyInformation': '参考图片中检测到真实人脸,请使用虚拟人像素材替代真人照',
'InputImageSensitiveContentDetected': '参考图片包含敏感内容,请更换图片后重试', 'InputImageSensitiveContentDetected': '参考图片包含敏感内容,请更换图片后重试',
'InputVideoSensitiveContentDetected': '参考视频包含敏感内容,请更换视频后重试', 'InputVideoSensitiveContentDetected': '参考视频包含敏感内容,请更换视频后重试',
'InputTextSensitiveContentDetected': '提示词包含敏感内容,请修改后重试', 'InputTextSensitiveContentDetected': '提示词包含敏感内容,请修改后重试',
'InputAudioSensitiveContentDetected': '参考音频包含敏感内容,请更换音频后重试',
# Output content moderation # Output content moderation
'OutputVideoSensitiveContentDetected': '生成的视频包含敏感内容,已被系统拦截', 'OutputVideoSensitiveContentDetected': '生成的视频包含敏感内容,已被系统拦截,请修改提示词后重试',
'OutputImageSensitiveContentDetected': '生成的图片包含敏感内容,已被系统拦截', 'OutputImageSensitiveContentDetected': '生成的图片包含敏感内容,已被系统拦截',
# Parameter & rate limit errors # Parameter errors
'InvalidParameter': '请求参数无效,请检查输入', 'InvalidParameter': '请求参数无效,请检查输入内容',
'RateLimitExceeded': 'API 调用频率超限,请稍后重试', 'InvalidImage': '图片格式或尺寸不符合要求,请检查后重试',
'ConcurrencyLimitExceeded': '并发数超限,请稍后重试', 'InvalidVideo': '视频格式或尺寸不符合要求,请检查后重试',
'InvalidAudio': '音频格式不符合要求,请检查后重试',
# Rate limit
'RateLimitExceeded': '请求过于频繁,请稍后重试',
'ConcurrencyLimitExceeded': '当前生成任务过多,请稍后重试',
# Account & billing # Account & billing
'InsufficientBalance': '账户余额不足,请联系管理员充值', 'InsufficientBalance': '平台账户余额不足,请联系管理员',
# Asset errors
'AssetNotFound': '引用的素材不存在或已被删除,请检查素材库',
# Server errors # Server errors
'ServerOverloaded': '服务器繁忙,请稍后重试', 'ServerOverloaded': '服务器繁忙,请稍后重试',
'InternalError': '服务内部错误,请稍后重试', 'InternalError': '视频生成服务异常,请稍后重试',
'Timeout': '生成超时,请重试', 'Timeout': '生成超时,请重试',
} }
# 关键词匹配API 返回的 message 中包含这些关键词时,映射为对应中文提示
_MESSAGE_KEYWORDS = {
'face': '检测到真实人脸,请使用虚拟人像素材替代真人照片',
'privacy': '检测到真实人脸,请使用虚拟人像素材替代真人照片',
'sensitive': '内容包含敏感信息,请修改后重试',
'not found': '引用的素材不存在或已被删除,请检查素材库',
'not valid': '请求参数无效,请检查输入内容',
}
class AirDramaAPIError(Exception): class AirDramaAPIError(Exception):
"""Raised when video generation API returns an error response.""" """Raised when video generation API returns an error response."""
@ -33,8 +49,16 @@ class AirDramaAPIError(Exception):
self.code = code self.code = code
self.api_message = message self.api_message = message
self.status_code = status_code self.status_code = status_code
# Use friendly message if available, otherwise use API message # 1. 精确匹配 error code
self.user_message = ERROR_MESSAGES.get(code, message) 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) 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. """Create a video generation task.
Args: 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.). aspect_ratio: Video aspect ratio ('16:9', '9:16', etc.).
duration: Video duration in seconds. duration: Video duration in seconds.
generate_audio: Whether to generate audio with the video. generate_audio: Whether to generate audio with the video.
search_mode: 'smart' to enable internet search, 'off' to disable.
Returns: Returns:
dict: API response with task id and status. 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, '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) resp = requests.post(url, json=payload, headers=_headers(), timeout=60)
if resp.status_code != 200: if resp.status_code != 200:
# Extract human-readable error from API response # 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', {}) err = resp.json().get('error', {})
code = err.get('code', '') code = err.get('code', '')
message = err.get('message', resp.text) message = err.get('message', resp.text)
logger.error('AirDrama API error: status=%s code=%s message=%s', resp.status_code, code, message)
except Exception: except Exception:
code, message = '', resp.text 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) raise AirDramaAPIError(code, message, resp.status_code)
return resp.json() return resp.json()

View File

@ -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)

View File

@ -14,6 +14,7 @@ import { RecordsPage } from './pages/RecordsPage';
import { SettingsPage } from './pages/SettingsPage'; import { SettingsPage } from './pages/SettingsPage';
import { AuditLogsPage } from './pages/AuditLogsPage'; import { AuditLogsPage } from './pages/AuditLogsPage';
import { AnomalyLogPage } from './pages/AnomalyLogPage'; import { AnomalyLogPage } from './pages/AnomalyLogPage';
import { LoginRecordsPage } from './pages/LoginRecordsPage';
import { ProfilePage } from './pages/ProfilePage'; import { ProfilePage } from './pages/ProfilePage';
import { AssetsPage } from './pages/AssetsPage'; import { AssetsPage } from './pages/AssetsPage';
@ -79,6 +80,7 @@ export default function App() {
<Route path="records" element={<RecordsPage />} /> <Route path="records" element={<RecordsPage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
<Route path="security" element={<AnomalyLogPage />} /> <Route path="security" element={<AnomalyLogPage />} />
<Route path="login-records" element={<LoginRecordsPage />} />
<Route path="logs" element={<AuditLogsPage />} /> <Route path="logs" element={<AuditLogsPage />} />
<Route path="assets" element={<AdminAssetsPage />} /> <Route path="assets" element={<AdminAssetsPage />} />
</Route> </Route>

View File

@ -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;
}

View File

@ -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<AssetGroup | null>(null);
const [groupAssets, setGroupAssets] = useState<AssetItem[]>([]);
const [newName, setNewName] = useState('');
const [uploading, setUploading] = useState(false);
const [editingName, setEditingName] = useState<{ id: number; value: string } | null>(null);
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [uploadPreview, setUploadPreview] = useState<string | null>(null);
const [dragOver, setDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const addFileInputRef = useRef<HTMLInputElement>(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 (
<div className={styles.overlay} onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className={styles.modal}>
{/* Header */}
<div className={styles.header}>
<div className={styles.headerLeft}>
{view !== 'list' && (
<button className={styles.backBtn} onClick={handleBackToList}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
)}
<span className={styles.title}>
{view === 'list' && '素材库'}
{view === 'detail' && (selectedGroup?.name || '角色详情')}
{view === 'upload' && '上传新角色'}
</span>
</div>
<button className={styles.closeBtn} onClick={onClose}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{/* Body */}
<div className={styles.body}>
{/* List View */}
{view === 'list' && (
<>
<div className={styles.actions}>
<button className={styles.actionBtn} onClick={() => setView('upload')}>
+
</button>
</div>
{loading ? (
<div className={styles.empty}>...</div>
) : groups.length === 0 ? (
<div className={styles.empty}></div>
) : (
<div className={styles.grid}>
{groups.map((group) => (
<div key={group.id} className={styles.card} onClick={() => handleGroupClick(group)}>
<img src={group.thumbnail_url} alt={group.name} className={styles.cardThumb} />
<div className={styles.cardInfo}>
{editingName && editingName.id === group.id ? (
<div className={styles.inlineEditWrap} onClick={(e) => e.stopPropagation()}>
<input
className={styles.inlineInput}
value={editingName.value}
onChange={(e) => setEditingName({ ...editingName, value: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameGroup(group.id, editingName.value);
if (e.key === 'Escape') setEditingName(null);
}}
autoFocus
/>
<button
className={styles.editBtn}
onClick={() => handleRenameGroup(group.id, editingName.value)}
style={{ fontSize: 12, padding: '4px 10px', whiteSpace: 'nowrap' }}
>
</button>
<button
className={styles.editBtn}
onClick={() => setEditingName(null)}
style={{ fontSize: 12, padding: '4px 10px', whiteSpace: 'nowrap' }}
>
</button>
</div>
) : (
<>
<span className={styles.cardName}>{group.name}</span>
<button
className={styles.editBtn}
onClick={(e) => {
e.stopPropagation();
setEditingName({ id: group.id, value: group.name });
}}
>
&#9998;
</button>
</>
)}
</div>
</div>
))}
</div>
)}
{totalPages > 1 && (
<div className={styles.pagination}>
<button
className={styles.pageBtn}
disabled={page <= 1}
onClick={() => loadGroups(page - 1)}
>
</button>
<span className={styles.pageInfo}>{page} / {totalPages}</span>
<button
className={styles.pageBtn}
disabled={page >= totalPages}
onClick={() => loadGroups(page + 1)}
>
</button>
</div>
)}
</>
)}
{/* Detail View */}
{view === 'detail' && selectedGroup && (
<>
<div className={styles.actions}>
<button className={styles.actionBtn} onClick={() => addFileInputRef.current?.click()}>
+
</button>
<button
className={styles.actionBtnOutline}
onClick={() => setEditingName({ id: selectedGroup.id, value: selectedGroup.name })}
>
&#9998;
</button>
<input
ref={addFileInputRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleAddAsset(file);
e.target.value = '';
}}
/>
</div>
{editingName && editingName.id === selectedGroup.id && (
<div style={{ display: 'flex', gap: 8, marginBottom: 16, alignItems: 'center' }}>
<input
className={styles.textInput}
style={{ flex: 1 }}
value={editingName.value}
onChange={(e) => setEditingName({ ...editingName, value: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameGroup(selectedGroup.id, editingName.value);
if (e.key === 'Escape') setEditingName(null);
}}
autoFocus
/>
<button
className={styles.actionBtn}
onClick={() => handleRenameGroup(selectedGroup.id, editingName.value)}
style={{ fontSize: 12, padding: '4px 10px', whiteSpace: 'nowrap' }}
>
</button>
<button
className={styles.actionBtnOutline}
onClick={() => setEditingName(null)}
style={{ fontSize: 12, padding: '4px 10px', whiteSpace: 'nowrap' }}
>
</button>
</div>
)}
{groupAssets.length === 0 ? (
<div className={styles.empty}></div>
) : (
<div className={styles.assetGrid}>
{groupAssets.map((asset) => (
<div key={asset.id} className={styles.assetCard}>
<img src={asset.url} alt={asset.name} className={styles.assetThumb} />
<div className={styles.assetInfo}>
<div className={styles.assetName}>{asset.name}</div>
<span className={`${styles.statusBadge} ${
asset.status === 'active' ? styles.statusActive
: asset.status === 'processing' ? styles.statusProcessing
: styles.statusFailed
}`}>
{asset.status === 'active' && '可用'}
{asset.status === 'processing' && '处理中'}
{asset.status === 'failed' && '失败'}
</span>
</div>
</div>
))}
</div>
)}
</>
)}
{/* Upload View */}
{view === 'upload' && (
<div className={styles.uploadForm}>
<div>
<div className={styles.inputLabel}></div>
<input
className={styles.textInput}
placeholder="请输入角色名称,如:林峰"
maxLength={64}
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
</div>
<div>
<div className={styles.inputLabel}></div>
<div
className={`${styles.dropZone} ${dragOver ? styles.dropZoneActive : ''}`}
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
>
{uploadPreview ? (
<>
<img src={uploadPreview} alt="预览" className={styles.dropZonePreview} />
<div className={styles.dropZoneHint}></div>
</>
) : (
<>
<div className={styles.dropZoneText}></div>
<div className={styles.dropZoneHint}></div>
<div className={styles.dropZoneHint}> JPGPNG 30MB</div>
</>
)}
<div className={styles.dropZoneWarning}> </div>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileSelect(file);
e.target.value = '';
}}
/>
</div>
<button
className={styles.submitBtn}
disabled={!newName.trim() || !uploadFile || uploading}
onClick={handleUploadSubmit}
>
{uploading ? '上传中...' : '确认上传'}
</button>
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -3,7 +3,7 @@
border: none; border: none;
border-radius: 0; border-radius: 0;
padding: 20px 0; padding: 20px 0;
max-width: 800px; max-width: 1024px;
width: 100%; width: 100%;
animation: cardFadeIn 0.3s ease-out; animation: cardFadeIn 0.3s ease-out;
border-bottom: 1px solid rgba(255, 255, 255, 0.06); border-bottom: 1px solid rgba(255, 255, 255, 0.06);
@ -17,8 +17,10 @@
/* Header */ /* Header */
.header { .header {
display: flex; display: flex;
align-items: center;
gap: 12px; gap: 12px;
margin-bottom: 12px; margin-bottom: 12px;
position: relative;
} }
.refColumn { .refColumn {
@ -40,7 +42,7 @@
} }
.refThumb { .refThumb {
height: 48px; height: 56px;
aspect-ratio: 3 / 4; aspect-ratio: 3 / 4;
border-radius: 6px; border-radius: 6px;
overflow: hidden; overflow: hidden;
@ -79,63 +81,63 @@
overflow: hidden; overflow: hidden;
} }
.promptTooltip { /* hover 展开黑底:基于 .header 定位,左边距图片 4px */
.promptExpanded {
position: absolute; position: absolute;
top: 100%; top: 0;
left: 0;
right: 0; right: 0;
z-index: 10; z-index: 10;
background: #1e1e2a; font-size: 14px;
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;
color: var(--color-text-primary); color: var(--color-text-primary);
line-height: 1.6; line-height: 1.6;
margin-bottom: 8px;
word-break: break-word; 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 { .mentionTag {
display: inline-flex; display: inline;
align-items: center; padding: 1px 5px;
padding: 4px 12px; 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; 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 { .mentionPreviewLabel {
background: rgba(108, 99, 255, 0.18); text-align: center;
color: #8a8a9a;
font-size: 11px;
margin-top: 4px;
} }
/* Inline labels after prompt text */ /* Inline labels after prompt text */
.labelsInline { .labelsInline {
display: inline; display: inline;
@ -143,6 +145,7 @@
white-space: nowrap; white-space: nowrap;
} }
.label { .label {
display: inline-flex; display: inline-flex;
font-size: 12px; font-size: 12px;

View File

@ -1,4 +1,5 @@
import { useRef, useState, useEffect, useCallback } from 'react'; import { useRef, useState, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import type { GenerationTask } from '../types'; import type { GenerationTask } from '../types';
import { useGenerationStore } from '../store/generation'; import { useGenerationStore } from '../store/generation';
import { showToast } from './Toast'; import { showToast } from './Toast';
@ -34,6 +35,83 @@ const DownloadIcon = () => (
</svg> </svg>
); );
// Mention tag with thumbnail + hover preview
function MentionTag({ label, thumbUrl }: { label: string; thumbUrl?: string }) {
const [hover, setHover] = useState(false);
const ref = useRef<HTMLSpanElement>(null);
const [pos, setPos] = useState({ top: 0, left: 0 });
return (
<>
<span
ref={ref}
className={styles.mentionTag}
onMouseEnter={() => {
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 && (
<img
src={thumbUrl}
alt=""
style={{ width: 14, height: 14, borderRadius: 3, objectFit: 'cover', verticalAlign: 'middle', marginRight: 3 }}
/>
)}
{label}
</span>
{hover && thumbUrl && createPortal(
<div className={styles.mentionPreview} style={{ top: pos.top, left: pos.left }}>
<img src={thumbUrl} alt={label} className={styles.mentionPreviewImg} />
<div className={styles.mentionPreviewLabel}>{label}</div>
</div>,
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<string, string>();
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 <MentionTag key={i} label={label} thumbUrl={thumbMap.get(label)} />;
}
regex.lastIndex = 0;
return part;
});
}
interface Props { interface Props {
task: GenerationTask; task: GenerationTask;
onOpenDetail?: (task: GenerationTask) => void; onOpenDetail?: (task: GenerationTask) => void;
@ -49,6 +127,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
const promptLineRef = useRef<HTMLDivElement>(null); const promptLineRef = useRef<HTMLDivElement>(null);
const promptWrapperRef = useRef<HTMLDivElement>(null); const promptWrapperRef = useRef<HTMLDivElement>(null);
const labelsRef = useRef<HTMLSpanElement>(null); const labelsRef = useRef<HTMLSpanElement>(null);
const refColumnRef = useRef<HTMLDivElement>(null);
const [videoHover, setVideoHover] = useState(false); const [videoHover, setVideoHover] = useState(false);
const [promptHover, setPromptHover] = useState(false); const [promptHover, setPromptHover] = useState(false);
const [showMore, setShowMore] = useState(false); const [showMore, setShowMore] = useState(false);
@ -56,8 +135,16 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const [detailHover, setDetailHover] = useState(false); const [detailHover, setDetailHover] = useState(false);
const [detailPos, setDetailPos] = useState({ top: 0, right: 0 }); const [detailPos, setDetailPos] = useState({ top: 0, right: 0 });
const [promptAbove, setPromptAbove] = useState(false);
const detailLinkRef = useRef<HTMLSpanElement>(null); const detailLinkRef = useRef<HTMLSpanElement>(null);
const detailLeaveTimer = useRef<ReturnType<typeof setTimeout> | 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 // Close more menu on click outside
useEffect(() => { useEffect(() => {
@ -82,11 +169,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
const style = getComputedStyle(container); const style = getComputedStyle(container);
const font = `${style.fontSize} ${style.fontFamily}`; const font = `${style.fontSize} ${style.fontFamily}`;
const labelsWidth = labelsEl.offsetWidth + 8;
// 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 totalAvailable = containerWidth * 2 - labelsWidth - 24; const totalAvailable = containerWidth * 2 - labelsWidth - 24;
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
@ -94,35 +177,28 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
ctx.font = font; ctx.font = font;
const prompt = task.prompt || ''; const prompt = task.prompt || '';
let totalWidth = 0;
let needsTruncation = false;
// Check if prompt fits
const fullWidth = ctx.measureText(prompt).width; const fullWidth = ctx.measureText(prompt).width;
if (fullWidth <= totalAvailable) { if (fullWidth <= totalAvailable) {
setTruncatedPrompt(prompt); setTruncatedPrompt(prompt);
return; return;
} }
// Truncate character by character
let truncated = ''; let truncated = '';
let totalWidth = 0;
const ellipsisWidth = ctx.measureText('…').width; const ellipsisWidth = ctx.measureText('…').width;
for (const char of prompt) { for (const char of prompt) {
const charWidth = ctx.measureText(char).width; const charWidth = ctx.measureText(char).width;
if (totalWidth + charWidth + ellipsisWidth > totalAvailable) { if (totalWidth + charWidth + ellipsisWidth > totalAvailable) {
needsTruncation = true;
break; break;
} }
truncated += char; truncated += char;
totalWidth += charWidth; totalWidth += charWidth;
} }
setTruncatedPrompt(truncated + '…');
setTruncatedPrompt(needsTruncation ? truncated + '…' : prompt);
}, [task.prompt]); }, [task.prompt]);
useEffect(() => { useEffect(() => {
computeTruncation(); computeTruncation();
const container = promptLineRef.current; const container = promptLineRef.current;
if (!container) return; if (!container) return;
const ro = new ResizeObserver(() => computeTruncation()); const ro = new ResizeObserver(() => computeTruncation());
@ -194,7 +270,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
<div className={styles.header}> <div className={styles.header}>
{/* Left: reference thumbnails */} {/* Left: reference thumbnails */}
{task.references.length > 0 && ( {task.references.length > 0 && (
<div className={styles.refColumn}> <div ref={refColumnRef} className={styles.refColumn}>
{task.references.map((ref) => ( {task.references.map((ref) => (
<div key={ref.id} className={styles.refThumb}> <div key={ref.id} className={styles.refThumb}>
{ref.type === 'video' ? ( {ref.type === 'video' ? (
@ -219,20 +295,18 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
<div <div
ref={promptWrapperRef} ref={promptWrapperRef}
className={styles.promptWrapper} className={styles.promptWrapper}
onMouseLeave={() => setPromptHover(false)} onMouseLeave={() => { setPromptHover(false); startDetailLeave(); }}
> >
{/* 默认状态:截断提示词 + inline 标签 */}
<div ref={promptLineRef} className={styles.promptLine}> <div ref={promptLineRef} className={styles.promptLine}>
<span onMouseEnter={() => setPromptHover(true)}>
{renderPromptWithMentions(truncatedPrompt || '(无文字描述)', task.assetMentions || [], task.references)}
</span>
<span <span
onMouseEnter={() => { ref={labelsRef}
const el = promptWrapperRef.current; className={styles.labelsInline}
if (el) { onMouseEnter={() => setPromptHover(false)}
const rect = el.getBoundingClientRect(); >
setPromptAbove(rect.bottom + 350 > window.innerHeight);
}
setPromptHover(true);
}}
>{truncatedPrompt || '(无文字描述)'}</span>
<span ref={labelsRef} className={styles.labelsInline} onMouseEnter={() => setPromptHover(false)}>
<span className={styles.label}> <span className={styles.label}>
{task.model === 'seedance_2.0' ? 'AirDrama' : 'AirDrama Fast'} {task.model === 'seedance_2.0' ? 'AirDrama' : 'AirDrama Fast'}
</span> </span>
@ -242,6 +316,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
ref={detailLinkRef} ref={detailLinkRef}
className={styles.detailLink} className={styles.detailLink}
onMouseEnter={() => { onMouseEnter={() => {
cancelDetailLeave();
const el = detailLinkRef.current; const el = detailLinkRef.current;
if (el) { if (el) {
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
@ -252,41 +327,64 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
} }
setDetailHover(true); setDetailHover(true);
}} }}
onMouseLeave={() => setDetailHover(false)} onMouseLeave={startDetailLeave}
> >
{detailHover && (
<div className={styles.detailTooltip} style={{ top: detailPos.top, right: detailPos.right }}>
<div className={styles.detailRow}>
<span></span><span>{task.aspectRatio}</span>
</div>
<div className={styles.detailRow}>
<span></span><span>{task.duration}s</span>
</div>
<div className={styles.detailRow}>
<span></span><span>720p</span>
</div>
<div className={styles.detailRow}>
<span></span>
<span>{task.model === 'seedance_2.0' ? 'AirDrama' : 'AirDrama Fast'}</span>
</div>
<div className={styles.detailRow}>
<span></span>
<span>{new Date(task.createdAt).toLocaleString('zh-CN')}</span>
</div>
</div>
)}
</span> </span>
</span> </span>
</div> </div>
{promptHover && task.prompt && (
<div className={`${styles.promptTooltip} ${promptAbove ? styles.promptTooltipAbove : ''}`}>
<p className={styles.promptTooltipText}>{task.prompt}</p>
<button className={styles.copyBtn} onClick={handleCopyPrompt}></button>
</div>
)}
</div> </div>
{/* 详细信息弹窗 — 放在 promptWrapper 外,鼠标可以移到弹窗上 */}
{detailHover && (
<div
className={styles.detailTooltip}
style={{ top: detailPos.top, right: detailPos.right }}
onMouseEnter={() => { cancelDetailLeave(); setDetailHover(true); }}
onMouseLeave={startDetailLeave}
>
<div className={styles.detailRow}>
<span></span><span>{task.aspectRatio}</span>
</div>
<div className={styles.detailRow}>
<span></span><span>{task.duration}s</span>
</div>
<div className={styles.detailRow}>
<span></span><span>720p</span>
</div>
<div className={styles.detailRow}>
<span></span>
<span>{task.model === 'seedance_2.0' ? 'AirDrama' : 'AirDrama Fast'}</span>
</div>
<div className={styles.detailRow}>
<span></span>
<span>{new Date(task.createdAt).toLocaleString('zh-CN')}</span>
</div>
{(task.tokensConsumed ?? 0) > 0 && (
<>
<div className={styles.detailRow}>
<span> Tokens</span>
<span>{(task.tokensConsumed ?? 0).toLocaleString()}</span>
</div>
<div className={styles.detailRow}>
<span></span>
<span>¥{(task.costAmount ?? 0).toFixed(2)}</span>
</div>
</>
)}
</div>
)}
</div> </div>
{/* hover 展开黑底:基于 header 定位,左边距图片 4px */}
{promptHover && task.prompt && (
<div
className={styles.promptExpanded}
style={{ left: refColumnRef.current ? refColumnRef.current.offsetWidth + 4 : 0 }}
onMouseEnter={() => setPromptHover(true)}
onMouseLeave={() => setPromptHover(false)}
>
{renderPromptWithMentions(task.prompt, task.assetMentions || [], task.references)}
</div>
)}
</div> </div>
{/* Video / result area */} {/* Video / result area */}

View File

@ -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 { useInputBarStore } from '../store/inputBar';
import { UniversalUpload } from './UniversalUpload'; import { UniversalUpload } from './UniversalUpload';
import { KeyframeUpload } from './KeyframeUpload'; import { KeyframeUpload } from './KeyframeUpload';
import { PromptInput } from './PromptInput'; import { PromptInput } from './PromptInput';
import { Toolbar } from './Toolbar'; import { Toolbar } from './Toolbar';
import { AssetLibraryModal } from './AssetLibraryModal';
import { showToast } from './Toast'; import { showToast } from './Toast';
import styles from './InputBar.module.css'; import styles from './InputBar.module.css';
@ -15,7 +16,8 @@ export function InputBar() {
const handleDragOver = useCallback((e: DragEvent) => { const handleDragOver = useCallback((e: DragEvent) => {
e.preventDefault(); e.preventDefault();
if (barRef.current) { // 只有外部文件拖入时才显示蓝色边框(内部 mention 标签拖拽不触发)
if (e.dataTransfer.types.includes('Files') && barRef.current) {
barRef.current.style.borderColor = '#00b8e6'; barRef.current.style.borderColor = '#00b8e6';
} }
}, []); }, []);
@ -71,9 +73,54 @@ export function InputBar() {
} }
}, [mode, addReferences, setFirstFrame]); }, [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 ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
<div className={styles.container}> <div className={styles.container}>
{/* 素材库 + 联网搜索按钮 — 输入框上方 */}
<div style={{ display: 'flex', gap: 8, marginBottom: 6, paddingLeft: 4 }}>
<button
onClick={() => setAssetModalOpen(true)}
style={{
background: 'transparent', border: '1px solid var(--color-border-card)',
borderRadius: 6, padding: '4px 12px', fontSize: 12,
color: 'var(--color-text-secondary)', cursor: 'pointer',
transition: 'all 0.15s',
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.borderColor = 'var(--color-primary)'; (e.currentTarget as HTMLElement).style.color = 'var(--color-primary)'; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.borderColor = 'var(--color-border-card)'; (e.currentTarget as HTMLElement).style.color = 'var(--color-text-secondary)'; }}
>
</button>
<button
onClick={() => { if (!searchDisabled) setSearchMode(searchMode === 'smart' ? 'off' : 'smart'); }}
title={searchDisabled ? '联网搜索仅支持纯文生视频' : ''}
style={{
background: searchMode === 'smart' && !searchDisabled ? 'rgba(108, 99, 255, 0.12)' : 'transparent',
border: `1px solid ${searchMode === 'smart' && !searchDisabled ? 'var(--color-primary)' : 'var(--color-border-card)'}`,
borderRadius: 6, padding: '4px 12px', fontSize: 12,
color: searchDisabled ? '#3a3a4a' : searchMode === 'smart' ? 'var(--color-primary)' : 'var(--color-text-secondary)',
cursor: searchDisabled ? 'not-allowed' : 'pointer', transition: 'all 0.15s',
opacity: searchDisabled ? 0.5 : 1,
}}
onMouseEnter={(e) => { if (!searchDisabled && searchMode !== 'smart') { (e.currentTarget as HTMLElement).style.borderColor = 'var(--color-primary)'; (e.currentTarget as HTMLElement).style.color = 'var(--color-primary)'; } }}
onMouseLeave={(e) => { if (!searchDisabled && searchMode !== 'smart') { (e.currentTarget as HTMLElement).style.borderColor = 'var(--color-border-card)'; (e.currentTarget as HTMLElement).style.color = 'var(--color-text-secondary)'; } }}
>
</button>
</div>
<div <div
ref={barRef} ref={barRef}
className={styles.bar} className={styles.bar}
@ -94,6 +141,7 @@ export function InputBar() {
<Toolbar /> <Toolbar />
</div> </div>
</div> </div>
<AssetLibraryModal open={assetModalOpen} onClose={() => setAssetModalOpen(false)} />
</div> </div>
); );
} }

View File

@ -41,9 +41,24 @@
background: rgba(108, 99, 255, 0.12); background: rgba(108, 99, 255, 0.12);
color: rgba(108, 99, 255, 0.7); color: rgba(108, 99, 255, 0.7);
font-size: 13px; font-size: 13px;
cursor: default; cursor: grab;
user-select: none; 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 { .mention:hover {

View File

@ -1,7 +1,8 @@
import { useRef, useEffect, useCallback, useState } from 'react'; import { useRef, useEffect, useCallback, useState } from 'react';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import { useInputBarStore } from '../store/inputBar'; 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'; import styles from './PromptInput.module.css';
const placeholders: Record<string, string> = { const placeholders: Record<string, string> = {
@ -25,6 +26,9 @@ export function PromptInput() {
const [highlightedIdx, setHighlightedIdx] = useState(0); const [highlightedIdx, setHighlightedIdx] = useState(0);
const [hoverRef, setHoverRef] = useState<UploadedFile | null>(null); const [hoverRef, setHoverRef] = useState<UploadedFile | null>(null);
const [hoverPos, setHoverPos] = useState({ top: 0, left: 0 }); const [hoverPos, setHoverPos] = useState({ top: 0, left: 0 });
const [mentionMode, setMentionMode] = useState<'references' | 'assets'>('references');
const [assetSearchResults, setAssetSearchResults] = useState<AssetGroup[]>([]);
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Auto-focus // Auto-focus
useEffect(() => { useEffect(() => {
@ -36,10 +40,11 @@ export function PromptInput() {
const el = editorRef.current; const el = editorRef.current;
if (!el) return; if (!el) return;
if (el.innerHTML !== editorHtml) { if (el.innerHTML !== editorHtml) {
el.innerHTML = DOMPurify.sanitize(editorHtml, { ALLOWED_TAGS: ['span', 'br'], ALLOWED_ATTR: ['class', 'contenteditable', 'data-ref-id', 'data-ref-type'] }); 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, rebuild mention spans // 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) // 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); rebuildMentionSpans(el);
} }
} }
@ -55,26 +60,84 @@ export function PromptInput() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [insertAtTrigger]); }, [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 // Rebuild mention spans from plain text @label patterns
const rebuildMentionSpans = useCallback((el: HTMLElement) => { 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 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; let textNode: Text | null;
while ((textNode = walker.nextNode() as Text | null)) { while ((textNode = walker.nextNode() as Text | null)) {
const text = textNode.textContent || ''; const text = textNode.textContent || '';
const matches: { start: number; end: number; ref: UploadedFile }[] = []; const matches: { start: number; end: number; target: MatchTarget }[] = [];
for (const ref of references) { for (const target of targets) {
const pattern = `@${ref.label}`; const pattern = `@${target.label}`;
let idx = text.indexOf(pattern); let idx = text.indexOf(pattern);
while (idx !== -1) { 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); idx = text.indexOf(pattern, idx + pattern.length);
} }
} }
if (matches.length > 0) { if (matches.length > 0) {
// Sort by position, remove overlapping matches
matches.sort((a, b) => a.start - b.start); 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) { if (m.start > lastIdx) {
frag.appendChild(document.createTextNode(text.slice(lastIdx, m.start))); frag.appendChild(document.createTextNode(text.slice(lastIdx, m.start)));
} }
const span = document.createElement('span'); const span = createMentionSpan({
span.className = styles.mention; refId: m.target.refId,
span.contentEditable = 'false'; refType: m.target.refType,
span.dataset.refId = m.ref.id; label: m.target.label,
span.dataset.refType = m.ref.type; thumbUrl: m.target.thumbUrl,
span.textContent = `@${m.ref.label}`; assetGroupId: m.target.assetGroupId,
groupName: m.target.groupName,
});
frag.appendChild(span); frag.appendChild(span);
lastIdx = m.end; lastIdx = m.end;
} }
@ -104,7 +169,7 @@ export function PromptInput() {
if (replacements.length > 0) { if (replacements.length > 0) {
setEditorHtml(el.innerHTML); setEditorHtml(el.innerHTML);
} }
}, [references, setEditorHtml]); }, [references, setEditorHtml, createMentionSpan]);
const openMentionPopup = useCallback(() => { const openMentionPopup = useCallback(() => {
const el = editorRef.current; const el = editorRef.current;
@ -151,6 +216,7 @@ export function PromptInput() {
}, [setPrompt, setEditorHtml]); }, [setPrompt, setEditorHtml]);
// Remove orphaned mention spans when a reference is deleted // Remove orphaned mention spans when a reference is deleted
// Skip asset-type spans — they are not tied to uploaded references
useEffect(() => { useEffect(() => {
const el = editorRef.current; const el = editorRef.current;
if (!el) return; if (!el) return;
@ -158,6 +224,7 @@ export function PromptInput() {
const spans = el.querySelectorAll<HTMLElement>('[data-ref-id]'); const spans = el.querySelectorAll<HTMLElement>('[data-ref-id]');
let changed = false; let changed = false;
spans.forEach((span) => { spans.forEach((span) => {
if (span.dataset.refType === 'asset') return; // skip asset mentions
if (!refIds.has(span.dataset.refId!)) { if (!refIds.has(span.dataset.refId!)) {
span.replaceWith(''); span.replaceWith('');
changed = true; changed = true;
@ -181,10 +248,39 @@ export function PromptInput() {
const text = node.textContent || ''; const text = node.textContent || '';
const offset = range.startOffset; const offset = range.startOffset;
if (offset > 0 && text[offset - 1] === '@' && references.length > 0) {
// Keep the @ visible, open popup above it // Find the last @ before cursor
typedAtRef.current = true; const textBeforeCursor = text.substring(0, offset);
openMentionPopup(); 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]); }, [extractText, references.length, openMentionPopup]);
@ -217,13 +313,13 @@ export function PromptInput() {
range.deleteContents(); range.deleteContents();
// Create mention span // Create mention span with thumbnail
const mention = document.createElement('span'); const mention = createMentionSpan({
mention.className = styles.mention; refId: ref.id,
mention.contentEditable = 'false'; refType: ref.type,
mention.dataset.refId = ref.id; label: ref.label,
mention.dataset.refType = ref.type; thumbUrl: ref.previewUrl,
mention.textContent = `@${ref.label}`; });
// Insert mention + trailing space // Insert mention + trailing space
range.insertNode(mention); range.insertNode(mention);
@ -241,23 +337,85 @@ export function PromptInput() {
extractText(); extractText();
}, [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) => { const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (showMentionPopup) { if (showMentionPopup) {
const items = mentionMode === 'assets' ? assetSearchResults : references;
if (items.length === 0) return;
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault(); e.preventDefault();
setShowMentionPopup(false); setShowMentionPopup(false);
setMentionMode('references');
} else if (e.key === 'ArrowDown') { } else if (e.key === 'ArrowDown') {
e.preventDefault(); e.preventDefault();
setHighlightedIdx((prev) => (prev + 1) % references.length); setHighlightedIdx((prev) => (prev + 1) % items.length);
} else if (e.key === 'ArrowUp') { } else if (e.key === 'ArrowUp') {
e.preventDefault(); e.preventDefault();
setHighlightedIdx((prev) => (prev - 1 + references.length) % references.length); setHighlightedIdx((prev) => (prev - 1 + items.length) % items.length);
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
e.preventDefault(); 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) => { const handlePaste = useCallback((e: React.ClipboardEvent) => {
e.preventDefault(); e.preventDefault();
@ -276,6 +434,22 @@ export function PromptInput() {
return; 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 // Plain text paste — strip @label patterns to prevent duplicate mention tags
let text = e.clipboardData.getData('text/plain'); let text = e.clipboardData.getData('text/plain');
for (const ref of references) { for (const ref of references) {
@ -288,17 +462,35 @@ export function PromptInput() {
extractText(); extractText();
}, [extractText, references]); }, [extractText, references]);
// Mention hover — delegated event // Mention hover — delegated event (supports both reference and asset mentions)
const handleMouseOver = useCallback((e: React.MouseEvent) => { const handleMouseOver = useCallback((e: React.MouseEvent) => {
const target = (e.target as HTMLElement).closest('[data-ref-id]') as HTMLElement | null; const target = (e.target as HTMLElement).closest('[data-ref-id]') as HTMLElement | null;
if (!target) return; if (!target) return;
const refId = target.dataset.refId; const refId = target.dataset.refId;
const ref = references.find((r) => r.id === refId); const refType = target.dataset.refType;
if (!ref) return;
// 参考图:从 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 rect = target.getBoundingClientRect();
const wrapperRect = editorRef.current!.parentElement!.getBoundingClientRect(); const wrapperRect = editorRef.current!.parentElement!.getBoundingClientRect();
setHoverRef(ref); setHoverRef(found);
setHoverPos({ setHoverPos({
top: rect.top - wrapperRect.top - 8, top: rect.top - wrapperRect.top - 8,
left: rect.left - wrapperRect.left + rect.width / 2, left: rect.left - wrapperRect.left + rect.width / 2,
@ -340,37 +532,110 @@ export function PromptInput() {
onPaste={handlePaste} onPaste={handlePaste}
onMouseOver={handleMouseOver} onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut} 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 */} {/* Mention popup */}
{showMentionPopup && references.length > 0 && ( {showMentionPopup && (
<div <div
className={styles.mentionPopup} className={styles.mentionPopup}
style={{ top: mentionPos.top, left: mentionPos.left }} style={{ top: mentionPos.top, left: mentionPos.left }}
> >
<div className={styles.mentionHeader}>@的内容</div> {mentionMode === 'references' && references.length > 0 && (
{references.map((ref, idx) => ( <>
<button <div className={styles.mentionHeader}>@的内容</div>
key={`${ref.id}-${idx}`} {references.map((ref, idx) => (
className={`${styles.mentionItem} ${idx === highlightedIdx ? styles.mentionItemActive : ''}`} <button
onMouseDown={(e) => { key={`${ref.id}-${idx}`}
e.preventDefault(); className={`${styles.mentionItem} ${idx === highlightedIdx ? styles.mentionItemActive : ''}`}
insertMention(ref); onMouseDown={(e) => {
}} e.preventDefault();
> insertMention(ref);
<div className={styles.mentionThumb}> }}
{ref.type === 'video' ? ( >
<video src={ref.previewUrl} muted className={styles.thumbMedia} /> <div className={styles.mentionThumb}>
) : ( {ref.type === 'video' ? (
<img src={ref.previewUrl} alt="" className={styles.thumbMedia} /> <video src={ref.previewUrl} muted className={styles.thumbMedia} />
)} ) : (
</div> <img src={ref.previewUrl} alt="" className={styles.thumbMedia} />
<span className={styles.mentionLabel}>{ref.label}</span> )}
<span className={styles.mentionType}> </div>
{ref.type === 'video' ? '视频' : '图片'} <span className={styles.mentionLabel}>{ref.label}</span>
</span> <span className={styles.mentionType}>
</button> {ref.type === 'video' ? '视频' : '图片'}
))} </span>
</button>
))}
</>
)}
{mentionMode === 'assets' && assetSearchResults.length > 0 && (
<>
<div className={styles.mentionHeader}></div>
{assetSearchResults.map((group, idx) => (
<button
key={group.id}
className={`${styles.mentionItem} ${idx === highlightedIdx ? styles.mentionItemActive : ''}`}
onMouseDown={(e) => {
e.preventDefault();
insertAssetMention(group);
}}
>
<div className={styles.mentionThumb}>
<img src={group.thumbnail_url} alt="" className={styles.thumbMedia} />
</div>
<span className={styles.mentionLabel}>{group.name}</span>
<span className={styles.mentionType}></span>
</button>
))}
</>
)}
</div> </div>
)} )}

View File

@ -264,6 +264,7 @@ export function Toolbar() {
<polyline points="5 12 12 5 19 12" /> <polyline points="5 12 12 5 19 12" />
</svg> </svg>
</button> </button>
</div> </div>
); );
} }

View File

@ -236,7 +236,7 @@
.navArrowDisabled { .navArrowDisabled {
opacity: 0.3; opacity: 0.3;
pointer-events: none; cursor: default;
} }
/* /*
@ -428,7 +428,7 @@
.infoBar { .infoBar {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: center;
gap: 8px; gap: 8px;
padding: 12px 16px; padding: 12px 16px;
border-radius: 10px; border-radius: 10px;

View File

@ -5,6 +5,7 @@ import { AmbientBackground } from './AmbientBackground';
import { ConfirmModal } from './ConfirmModal'; import { ConfirmModal } from './ConfirmModal';
import { ImageLightbox } from './ImageLightbox'; import { ImageLightbox } from './ImageLightbox';
import { useInputBarStore } from '../store/inputBar'; import { useInputBarStore } from '../store/inputBar';
import { renderPromptWithMentions } from './GenerationCard';
import styles from './VideoDetailModal.module.css'; import styles from './VideoDetailModal.module.css';
interface Props { interface Props {
@ -468,7 +469,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
<div className={styles.infoPanelContent}> <div className={styles.infoPanelContent}>
<div className={styles.promptSection}> <div className={styles.promptSection}>
<div className={styles.sectionLabel}></div> <div className={styles.sectionLabel}></div>
<p className={styles.promptText}>{task.prompt || '(无文字描述)'}</p> <p className={styles.promptText}>{renderPromptWithMentions(task.prompt || '(无文字描述)', task.assetMentions || [], task.references)}</p>
</div> </div>
{task.references.length > 0 && ( {task.references.length > 0 && (
@ -498,7 +499,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
</div> </div>
{/* Re-edit button above info bar */} {/* Re-edit button above info bar */}
{!hideReEdit && <div style={{ padding: '0 20px 12px', borderBottom: '1px solid rgba(255,255,255,0.08)' }}> {!hideReEdit && <div style={{ padding: '16px 24px 12px' }}>
<button className={styles.cardBtn} onClick={handleReEdit} style={{ width: '100%', justifyContent: 'center' }}> <button className={styles.cardBtn} onClick={handleReEdit} style={{ width: '100%', justifyContent: 'center' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /> <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
@ -510,7 +511,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
{/* Fixed bottom: info bar + actions card */} {/* Fixed bottom: info bar + actions card */}
<div className={styles.infoPanelBottom}> <div className={styles.infoPanelBottom}>
<div className={styles.infoBar}> <div className={styles.infoBar} style={{ flexWrap: 'wrap', rowGap: 6 }}>
<span>{modeLabel}</span> <span>{modeLabel}</span>
<span className={styles.infoBarDot} /> <span className={styles.infoBarDot} />
<span>{modelLabel}</span> <span>{modelLabel}</span>
@ -520,7 +521,6 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
<span>{task.aspectRatio}</span> <span>{task.aspectRatio}</span>
{(task.tokensConsumed ?? 0) > 0 && ( {(task.tokensConsumed ?? 0) > 0 && (
<> <>
<span className={styles.infoBarDot} />
<span>{(task.tokensConsumed ?? 0).toLocaleString()} tokens</span> <span>{(task.tokensConsumed ?? 0).toLocaleString()} tokens</span>
<span className={styles.infoBarDot} /> <span className={styles.infoBarDot} />
<span>¥{(task.costAmount ?? 0).toFixed(2)}</span> <span>¥{(task.costAmount ?? 0).toFixed(2)}</span>
@ -528,28 +528,6 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
)} )}
</div> </div>
{(onReEdit || onRegenerate) && (
<div className={styles.cardActions}>
{onReEdit && (
<button className={styles.cardBtn} onClick={handleReEdit}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
)}
{onRegenerate && (
<button className={styles.cardBtn} onClick={handleRegenerate}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polyline points="23 4 23 10 17 10" />
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
</button>
)}
</div>
)}
</div> </div>
</div> </div>

View File

@ -4,7 +4,7 @@ import type {
AdminRecord, SystemSettings, ProfileOverview, PaginatedResponse, AdminRecord, SystemSettings, ProfileOverview, PaginatedResponse,
BackendTask, TeamInfo, Team, TeamDetail, TeamMember, TeamStats, BackendTask, TeamInfo, Team, TeamDetail, TeamMember, TeamStats,
AuditLog, AssetTeamSummary, AssetMemberSummary, AssetVideo, AuditLog, AssetTeamSummary, AssetMemberSummary, AssetVideo,
LoginAnomaly, TeamAnomalyConfig, LoginAnomaly, TeamAnomalyConfig, AssetGroup, AssetItem,
} from '../types'; } from '../types';
import { reportError } from './logCenter'; import { reportError } from './logCenter';
@ -131,7 +131,8 @@ export const videoApi = {
model: string; model: string;
aspect_ratio: string; aspect_ratio: string;
duration: number; 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<{ api.post<{
task_id: string; task_id: string;
@ -286,6 +287,17 @@ export const adminApi = {
teamApplyLearnedRegions: (teamId: number, cities: string[]) => teamApplyLearnedRegions: (teamId: number, cities: string[]) =>
api.post(`/admin/teams/${teamId}/apply-learned-regions`, { cities }), 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: { getAuditLogs: (params: {
page?: number; page?: number;
page_size?: 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<AssetGroup>('/assets/groups', data, { headers: { 'Content-Type': 'multipart/form-data' } }),
getGroupDetail: (id: number) =>
api.get<AssetGroup & { assets: AssetItem[] }>(`/assets/groups/${id}`),
updateGroup: (id: number, data: { name?: string; description?: string }) =>
api.put(`/assets/groups/${id}`, data),
addAsset: (groupId: number, data: FormData) =>
api.post<AssetItem>(`/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; export default api;

View File

@ -49,6 +49,7 @@ function assetVideoToTask(v: AssetVideo): GenerationTask {
aspectRatio: (v.aspect_ratio as any) || '16:9', aspectRatio: (v.aspect_ratio as any) || '16:9',
duration: v.duration as any, duration: v.duration as any,
references, references,
assetMentions: [],
status: 'completed', status: 'completed',
progress: 100, progress: 100,
resultUrl: v.result_url, resultUrl: v.result_url,

View File

@ -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/records', label: '消费记录', icon: 'M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-2 10h-4v4h-2v-4H7v-2h4V7h2v4h4v2z' },
{ path: '/admin/settings', label: '系统设置', icon: 'M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.488.488 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z' }, { path: '/admin/settings', label: '系统设置', icon: 'M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.488.488 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z' },
{ path: '/admin/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/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' }, { 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' },
]; ];

View File

@ -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; }

View File

@ -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<string, string> = {
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<LoginRecord[]>([]);
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<Team[]>([]);
const [loading, setLoading] = useState(true);
const pageSize = 20;
// Load teams for filter dropdown
useEffect(() => {
adminApi.getTeams().then(({ data }) => setTeams(data.results)).catch(() => {});
}, []);
const fetchRecords = useCallback(async () => {
setLoading(true);
try {
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 (
<div className={styles.page}>
<h1 className={styles.title}></h1>
<div className={styles.filters}>
<input
type="text"
className={styles.searchInput}
placeholder="搜索用户名..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
<Select
value={teamFilter}
onChange={(v) => { setTeamFilter(v); setPage(1); }}
placeholder="全部团队"
options={[{ label: '全部团队', value: '' }, ...teams.map((t) => ({ label: t.name, value: String(t.id) }))]}
/>
<input
type="text"
className={styles.searchInput}
placeholder="搜索城市..."
value={citySearch}
onChange={(e) => setCitySearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
<DatePicker value={startDate} onChange={setStartDate} placeholder="开始日期" />
<span className={styles.dateSep}>~</span>
<DatePicker value={endDate} onChange={setEndDate} placeholder="结束日期" />
<button className={styles.searchBtn} onClick={handleSearch}></button>
<button className={styles.refreshBtn} onClick={fetchRecords}></button>
</div>
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th></th>
<th></th>
<th>IP地址</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i}>
{Array.from({ length: 8 }).map((_, j) => (
<td key={j}><div className={styles.skeletonCell} /></td>
))}
</tr>
))
) : records.length === 0 ? (
<tr><td colSpan={8} className={styles.empty}></td></tr>
) : (
records.map((r) => (
<tr key={r.id}>
<td>{r.username}</td>
<td>{r.team_name || '-'}</td>
<td className={styles.ipCell}>{r.ip_address || '-'}</td>
<td>{r.geo_country || '-'}</td>
<td>{r.geo_province || '-'}</td>
<td>{r.geo_city || '-'}</td>
<td><span className={styles.sourceBadge}>{formatGeoSource(r.geo_source)}</span></td>
<td className={styles.timeCell}>{new Date(r.created_at).toLocaleString('zh-CN')}</td>
</tr>
))
)}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className={styles.pagination}>
<span className={styles.pageInfo}> {total} </span>
<div className={styles.pageButtons}>
<button disabled={page <= 1} onClick={() => setPage(page - 1)}>&lt;</button>
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
let p: number;
if (totalPages <= 5) p = i + 1;
else if (page <= 3) p = i + 1;
else if (page >= totalPages - 2) p = totalPages - 4 + i;
else p = page - 2 + i;
return (
<button key={p} className={page === p ? styles.activePage : ''} onClick={() => setPage(p)}>
{p}
</button>
);
})}
<button disabled={page >= totalPages} onClick={() => setPage(page + 1)}>&gt;</button>
</div>
</div>
)}
</div>
);
}

View File

@ -49,6 +49,7 @@ function assetVideoToTask(v: AssetVideo): GenerationTask {
aspectRatio: (v.aspect_ratio as any) || '16:9', aspectRatio: (v.aspect_ratio as any) || '16:9',
duration: v.duration as any, duration: v.duration as any,
references, references,
assetMentions: [],
status: 'completed', status: 'completed',
progress: 100, progress: 100,
resultUrl: v.result_url, resultUrl: v.result_url,

View File

@ -322,7 +322,7 @@ export function TeamsPage() {
</div> </div>
<div className={styles.formGroup}> <div className={styles.formGroup}>
<label></label> <label></label>
<input type="text" value={newExpectedRegions} onChange={(e) => setNewExpectedRegions(e.target.value)} placeholder="广州,深圳,北京" /> <input type="text" value={newExpectedRegions} onChange={(e) => setNewExpectedRegions(e.target.value)} placeholder="广州,深圳,北京" />
</div> </div>
{createError && <div className={styles.formError}>{createError}</div>} {createError && <div className={styles.formError}>{createError}</div>}
<div className={styles.modalActions}> <div className={styles.modalActions}>

View File

@ -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<void>;
searchAssets: (query: string) => Promise<void>;
createGroup: (name: string, file: File) => Promise<AssetGroup | null>;
pollAssetStatus: (assetId: number) => void;
}
export const useAssetLibraryStore = create<AssetLibraryState>((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);
},
}));

View File

@ -56,13 +56,36 @@ function mapProgress(backendStatus: string): number {
// Convert a BackendTask to a frontend GenerationTask // Convert a BackendTask to a frontend GenerationTask
function backendToFrontend(bt: BackendTask): GenerationTask { function backendToFrontend(bt: BackendTask): GenerationTask {
const references: ReferenceSnapshot[] = (bt.reference_urls || []).map((ref, i) => ({ const allRefs = bt.reference_urls || [];
id: `ref_${bt.task_id}_${i}`,
type: (ref.type || 'image') as 'image' | 'video', // 普通引用(参考图/视频/音频)— 可显示的缩略图
previewUrl: ref.url, const references: ReferenceSnapshot[] = allRefs
label: ref.label || `素材${i + 1}`, .filter((ref) => {
role: ref.role, 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<string, string>).thumb_url || '' };
});
return { return {
id: `backend_${bt.task_id}`, id: `backend_${bt.task_id}`,
@ -74,6 +97,7 @@ function backendToFrontend(bt: BackendTask): GenerationTask {
aspectRatio: bt.aspect_ratio as GenerationTask['aspectRatio'], aspectRatio: bt.aspect_ratio as GenerationTask['aspectRatio'],
duration: bt.duration as GenerationTask['duration'], duration: bt.duration as GenerationTask['duration'],
references, references,
assetMentions,
status: mapStatus(bt.status), status: mapStatus(bt.status),
progress: bt.status === 'processing' ? Number(sessionStorage.getItem(`progress_${bt.task_id}`) || mapProgress(bt.status)) : mapProgress(bt.status), progress: bt.status === 'processing' ? Number(sessionStorage.getItem(`progress_${bt.task_id}`) || mapProgress(bt.status)) : mapProgress(bt.status),
resultUrl: bt.result_url || undefined, resultUrl: bt.result_url || undefined,
@ -303,6 +327,31 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
}, },
].filter(Boolean) as ReferenceSnapshot[]; ].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<string>();
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 // Create a placeholder task immediately for UI feedback
const tempId = `temp_${Date.now()}`; const tempId = `temp_${Date.now()}`;
const placeholderTask: GenerationTask = { const placeholderTask: GenerationTask = {
@ -315,6 +364,7 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
aspectRatio: input.aspectRatio, aspectRatio: input.aspectRatio,
duration: input.duration, duration: input.duration,
references: localRefs, references: localRefs,
assetMentions: placeholderAssetMentions,
status: 'generating', status: 'generating',
progress: 0, progress: 0,
createdAt: Date.now(), createdAt: Date.now(),
@ -330,13 +380,14 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
prompt: '', prompt: '',
editorHtml: '', editorHtml: '',
references: [], references: [],
assetMentions: [],
firstFrame: null, firstFrame: null,
lastFrame: null, lastFrame: null,
}); });
try { try {
// Upload files to TOS (or reuse existing TOS URLs) // 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) { for (const item of filesToUpload) {
if (item.tosUrl) { if (item.tosUrl) {
@ -347,6 +398,44 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
} }
} }
// Extract asset mentions from editor HTML — deduplicate by groupId
const seenGroupIds = new Set<string>();
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 // Call generate API
const { data: genResult } = await videoApi.generate({ const { data: genResult } = await videoApi.generate({
prompt: input.prompt, prompt: input.prompt,
@ -355,6 +444,7 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
aspect_ratio: input.aspectRatio, aspect_ratio: input.aspectRatio,
duration: input.duration, duration: input.duration,
references: uploadedRefs, references: uploadedRefs,
search_mode: input.searchMode || 'off',
}); });
// Update task with real backend IDs // Update task with real backend IDs
@ -396,14 +486,15 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
return frontendId; return frontendId;
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { status?: number; data?: { message?: string } } }; const error = err as { response?: { status?: number; data?: { message?: string; error_message?: string } } };
const msg = error.response?.data?.message; const msg = error.response?.data?.error_message || error.response?.data?.message || '生成失败,请重试';
showToast(msg || '生成失败,请重试'); const displayMsg = mapErrorMessage(msg) || msg;
showToast(displayMsg);
// Mark task as failed // Mark task as failed with error message
set((s) => ({ set((s) => ({
tasks: s.tasks.map((t) => 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<GenerationState>((set, get) => ({
} }
if (task.mode === 'universal') { if (task.mode === 'universal') {
// task.references only contains file refs (assets filtered in backendToFrontend)
const references: UploadedFile[] = task.references.map((r) => ({ const references: UploadedFile[] = task.references.map((r) => ({
id: r.id, id: r.id,
type: r.type, type: r.type,
@ -444,6 +536,7 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
aspectRatio: task.aspectRatio, aspectRatio: task.aspectRatio,
duration: task.duration, duration: task.duration,
references, references,
assetMentions: task.assetMentions || [],
}); });
} else { } else {
// Keyframe mode: restore firstFrame and lastFrame // Keyframe mode: restore firstFrame and lastFrame
@ -454,6 +547,7 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
editorHtml: task.editorHtml || task.prompt, editorHtml: task.editorHtml || task.prompt,
aspectRatio: task.aspectRatio, aspectRatio: task.aspectRatio,
duration: task.duration, duration: task.duration,
assetMentions: [],
firstFrame: firstRef ? { id: firstRef.id, type: firstRef.type, previewUrl: firstRef.previewUrl, label: '首帧', tosUrl: firstRef.previewUrl } : null, 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, lastFrame: lastRef ? { id: lastRef.id, type: lastRef.type, previewUrl: lastRef.previewUrl, label: '尾帧', tosUrl: lastRef.previewUrl } : null,
}); });
@ -478,7 +572,7 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
type: r.type, type: r.type,
previewUrl: r.previewUrl, previewUrl: r.previewUrl,
label: r.label, label: r.label,
tosUrl: r.previewUrl, // TOS URL from previous upload tosUrl: r.previewUrl,
})); }));
useInputBarStore.setState({ useInputBarStore.setState({
@ -488,6 +582,7 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
aspectRatio: task.aspectRatio, aspectRatio: task.aspectRatio,
duration: task.duration, duration: task.duration,
references: task.mode === 'universal' ? references : [], references: task.mode === 'universal' ? references : [],
assetMentions: task.assetMentions || [],
}); });
// Trigger generation // Trigger generation

View File

@ -56,6 +56,13 @@ interface InputBarState {
// Computed // Computed
canSubmit: () => boolean; 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) // @ trigger (for toolbar button to insert @ in contentEditable)
insertAtTrigger: number; insertAtTrigger: number;
triggerInsertAt: () => void; triggerInsertAt: () => void;
@ -202,6 +209,11 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
return true; return true;
}, },
searchMode: 'off',
setSearchMode: (searchMode) => set({ searchMode }),
assetMentions: [],
insertAtTrigger: 0, insertAtTrigger: 0,
triggerInsertAt: () => set((s) => ({ insertAtTrigger: s.insertAtTrigger + 1 })), triggerInsertAt: () => set((s) => ({ insertAtTrigger: s.insertAtTrigger + 1 })),
@ -254,6 +266,7 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
editorHtml: '', editorHtml: '',
references: [], references: [],
prevReferences: [], prevReferences: [],
assetMentions: [],
firstFrame: null, firstFrame: null,
lastFrame: null, lastFrame: null,
generationType: 'video', generationType: 'video',

View File

@ -41,6 +41,7 @@ export interface GenerationTask {
aspectRatio: AspectRatio; aspectRatio: AspectRatio;
duration: Duration; duration: Duration;
references: ReferenceSnapshot[]; references: ReferenceSnapshot[];
assetMentions: { groupId: string; label: string; thumbUrl: string }[];
status: TaskStatus; status: TaskStatus;
progress: number; progress: number;
resultUrl?: string; resultUrl?: string;
@ -390,3 +391,23 @@ export interface AuditLog {
ip_address: string | null; ip_address: string | null;
created_at: string; 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;
}