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):
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/logs', views.admin_audit_logs_view, name='admin_audit_logs'),
# ── Super Admin: Login Records ──
path('admin/login-records', views.admin_login_records_view, name='admin_login_records'),
# ── Super Admin: Anomaly Detection ──
path('admin/anomalies', views.admin_login_anomalies_view, name='admin_login_anomalies'),
path('admin/test-feishu', views.admin_test_feishu_view, name='admin_test_feishu'),
@ -62,4 +65,12 @@ urlpatterns = [
# ── Profile: User's own data ──
path('profile/overview', views.profile_overview_view, name='profile_overview'),
path('profile/records', views.profile_records_view, name='profile_records'),
# ── Assets API (Virtual Avatar Library) ──
path('assets/groups', views.asset_groups_view, name='asset_groups'),
path('assets/groups/<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 datetime import timedelta
from .models import GenerationRecord, QuotaConfig
from .models import GenerationRecord, QuotaConfig, AssetGroup, Asset
from .serializers import (
VideoGenerateSerializer, QuotaUpdateSerializer,
UserStatusSerializer, SystemSettingsSerializer,
@ -22,7 +22,7 @@ from .serializers import (
TeamAdminCreateSerializer, TeamMemberCreateSerializer, MemberQuotaSerializer,
TeamAnomalyConfigSerializer,
)
from apps.accounts.models import Team, AdminAuditLog, log_admin_action, TeamAnomalyConfig, LoginAnomaly, ActiveSession
from apps.accounts.models import Team, AdminAuditLog, log_admin_action, TeamAnomalyConfig, LoginAnomaly, ActiveSession, LoginRecord
from apps.accounts.permissions import IsSuperAdmin, IsTeamAdmin, IsTeamMember
from utils.tos_client import upload_file as tos_upload
from utils.airdrama_client import create_task, query_task, extract_video_url, map_status
@ -152,6 +152,7 @@ def video_generate_view(request):
mode = serializer.validated_data['mode']
model = serializer.validated_data['model']
aspect_ratio = serializer.validated_data['aspect_ratio']
search_mode = request.data.get('search_mode', 'off')
# ── 预估 token 和费用 ──
config = QuotaConfig.objects.get_or_create(pk=1)[0]
@ -220,29 +221,59 @@ def video_generate_view(request):
references = request.data.get('references', [])
reference_snapshots = []
content_items = []
seen_urls = set() # 去重:同一个素材只引用一次
from .models import Asset as AssetModel
for ref in references:
url = ref.get('url', '')
original_url = url # 保留原始 URL 用于 reference_snapshots
ref_type = ref.get('type', 'image')
role = ref.get('role', '')
label = ref.get('label', '')
reference_snapshots.append({
'url': url, 'type': ref_type, 'role': role, 'label': label,
})
# 跳过重复 URL — 在所有操作之前判断
if original_url in seen_urls:
continue
seen_urls.add(original_url)
# 快照存原始 URL前端重建 reEdit 需要 asset://group-{id} 格式)
snap = {'url': original_url, 'type': ref_type, 'role': role, 'label': label}
thumb_url = ref.get('thumb_url', '')
if thumb_url:
snap['thumb_url'] = thumb_url
reference_snapshots.append(snap)
# 转换 asset://group-{id} 为火山 Asset://Asset-xxx 格式(仅用于 content_items
resolved_url = url
if url.startswith('asset://group-'):
try:
group_id = int(url.replace('asset://group-', ''))
first_asset = AssetModel.objects.filter(
group_id=group_id, status='active'
).first()
if first_asset and first_asset.remote_asset_id:
aid = first_asset.remote_asset_id
if aid.startswith('asset-'):
aid = 'Asset-' + aid[6:]
resolved_url = f'Asset://{aid}'
else:
logger.warning('No active asset found for group %s', group_id)
except (ValueError, Exception) as e:
logger.warning('Failed to resolve asset group URL %s: %s', url, e)
if ref_type == 'image':
item = {'type': 'image_url', 'image_url': {'url': url}}
item = {'type': 'image_url', 'image_url': {'url': resolved_url}}
if role:
item['role'] = role
content_items.append(item)
elif ref_type == 'video':
item = {'type': 'video_url', 'video_url': {'url': url}}
item = {'type': 'video_url', 'video_url': {'url': resolved_url}}
if role:
item['role'] = role
content_items.append(item)
elif ref_type == 'audio':
item = {'type': 'audio_url', 'audio_url': {'url': url}}
item = {'type': 'audio_url', 'audio_url': {'url': resolved_url}}
if role:
item['role'] = role
content_items.append(item)
@ -278,6 +309,7 @@ def video_generate_view(request):
content_items=content_items,
aspect_ratio=aspect_ratio,
duration=duration,
search_mode=search_mode,
)
ark_task_id = ark_response.get('id', '')
record.ark_task_id = ark_task_id
@ -2450,3 +2482,432 @@ def team_assets_member_videos(request, member_id):
'page_size': page_size,
'results': results,
})
# ──────────────────────────────────────────────
# Admin: Login Records
# ──────────────────────────────────────────────
@api_view(['GET'])
@permission_classes([IsSuperAdmin])
def admin_login_records_view(request):
"""GET /api/v1/admin/login-records"""
page = int(request.query_params.get('page', 1))
page_size = min(int(request.query_params.get('page_size', 20)), 100)
search = request.query_params.get('search', '').strip()
team_id = request.query_params.get('team_id', '').strip()
start_date = request.query_params.get('start_date', '').strip()
end_date = request.query_params.get('end_date', '').strip()
city = request.query_params.get('city', '').strip()
qs = LoginRecord.objects.select_related('user', 'team').order_by('-created_at')
if search:
qs = qs.filter(user__username__icontains=search)
if team_id:
qs = qs.filter(team_id=int(team_id))
if start_date:
qs = qs.filter(created_at__date__gte=start_date)
if end_date:
qs = qs.filter(created_at__date__lte=end_date)
if city:
qs = qs.filter(geo_city__icontains=city)
total = qs.count()
offset = (page - 1) * page_size
records = list(qs[offset:offset + page_size])
results = []
for r in records:
results.append({
'id': r.id,
'username': r.user.username,
'user_id': r.user_id,
'team_name': r.team.name if r.team else None,
'ip_address': r.ip_address or '',
'geo_country': r.geo_country,
'geo_province': r.geo_province,
'geo_city': r.geo_city,
'geo_source': r.geo_source,
'user_agent': r.user_agent,
'created_at': r.created_at.isoformat(),
})
return Response({
'total': total,
'page': page,
'page_size': page_size,
'results': results,
})
# ──────────────────────────────────────────────
# Virtual Avatar Asset Library
# ──────────────────────────────────────────────
def _assets_api_call(func, *args, **kwargs):
"""Safely call an assets_client function; returns (result, error_response).
If ASSETS_API_ENABLED is False the remote call is skipped and an empty
placeholder is returned so that local DB records are still created.
"""
from django.conf import settings as django_settings
if not django_settings.ASSETS_API_ENABLED:
return None, None
try:
from utils.assets_client import AssetsAPIError
result = func(*args, **kwargs)
return result, None
except AssetsAPIError as e:
logger.warning('Assets API error: %s', e)
return None, Response(
{'error': 'assets_api_error', 'message': str(e)},
status=status.HTTP_502_BAD_GATEWAY,
)
except Exception as e:
logger.exception('Assets API unexpected error')
return None, Response(
{'error': 'assets_api_error', 'message': f'素材 API 调用失败: {e}'},
status=status.HTTP_502_BAD_GATEWAY,
)
@api_view(['GET', 'POST'])
@permission_classes([IsTeamMember])
@parser_classes([MultiPartParser, JSONParser])
def asset_groups_view(request):
"""GET /api/v1/assets/groups — list groups for current team.
POST /api/v1/assets/groups create a group with an initial image.
"""
team = request.user.team
if request.method == 'GET':
groups = (
AssetGroup.objects
.filter(team=team)
.annotate(asset_count=Count('assets'))
.order_by('-created_at')
)
results = []
for g in groups:
results.append({
'id': g.id,
'name': g.name,
'thumbnail_url': g.thumbnail_url,
'asset_count': g.asset_count,
'remote_group_id': g.remote_group_id,
'created_at': g.created_at.isoformat(),
})
return Response({'results': results})
# ── POST: create group + first asset ──
name = request.data.get('name', '').strip()
if not name:
return Response({'error': '请输入角色名称'}, status=status.HTTP_400_BAD_REQUEST)
file = request.FILES.get('file')
if not file:
return Response({'error': '请上传一张素材图片'}, status=status.HTTP_400_BAD_REQUEST)
# Upload to TOS
try:
tos_url = tos_upload(file, folder='assets')
except Exception as e:
logger.exception('TOS upload failed for asset')
return Response(
{'error': f'文件上传失败: {e}'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
# Create remote group
from utils import assets_client
remote_group_id = ''
result, err = _assets_api_call(assets_client.create_asset_group, name)
if err:
return err
if result is not None:
remote_group_id = result
# Create remote asset
remote_asset_id = ''
if remote_group_id:
result, err = _assets_api_call(assets_client.create_asset, remote_group_id, tos_url, name)
if err:
return err
if result is not None:
remote_asset_id = result
# Local DB records
group = AssetGroup.objects.create(
team=team,
remote_group_id=remote_group_id,
name=name,
description='',
thumbnail_url=tos_url,
created_by=request.user,
)
Asset.objects.create(
group=group,
remote_asset_id=remote_asset_id,
name=name,
url=tos_url,
status='processing' if remote_asset_id else 'active',
error_message='',
)
return Response({
'id': group.id,
'name': group.name,
'thumbnail_url': group.thumbnail_url,
'remote_group_id': group.remote_group_id,
'asset_count': 1,
'created_at': group.created_at.isoformat(),
}, status=status.HTTP_201_CREATED)
@api_view(['GET', 'PUT'])
@permission_classes([IsTeamMember])
@parser_classes([JSONParser])
def asset_group_detail_view(request, group_id):
"""GET /api/v1/assets/groups/<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')
# Set to True when Seedance model is activated on ARK platform
SEEDANCE_ENABLED = os.environ.get('SEEDANCE_ENABLED', 'false').lower() == 'true'
# Set to True to enable the Assets API (virtual avatar library)
ASSETS_API_ENABLED = os.environ.get('ASSETS_API_ENABLED', 'false').lower() == 'true'
# ──────────────────────────────────────────────
# Aliyun SMS (短信告警)

View File

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

View File

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

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 { AuditLogsPage } from './pages/AuditLogsPage';
import { AnomalyLogPage } from './pages/AnomalyLogPage';
import { LoginRecordsPage } from './pages/LoginRecordsPage';
import { ProfilePage } from './pages/ProfilePage';
import { AssetsPage } from './pages/AssetsPage';
@ -79,6 +80,7 @@ export default function App() {
<Route path="records" element={<RecordsPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="security" element={<AnomalyLogPage />} />
<Route path="login-records" element={<LoginRecordsPage />} />
<Route path="logs" element={<AuditLogsPage />} />
<Route path="assets" element={<AdminAssetsPage />} />
</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-radius: 0;
padding: 20px 0;
max-width: 800px;
max-width: 1024px;
width: 100%;
animation: cardFadeIn 0.3s ease-out;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
@ -17,8 +17,10 @@
/* Header */
.header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
position: relative;
}
.refColumn {
@ -40,7 +42,7 @@
}
.refThumb {
height: 48px;
height: 56px;
aspect-ratio: 3 / 4;
border-radius: 6px;
overflow: hidden;
@ -79,63 +81,63 @@
overflow: hidden;
}
.promptTooltip {
/* hover 展开黑底:基于 .header 定位,左边距图片 4px */
.promptExpanded {
position: absolute;
top: 100%;
left: 0;
top: 0;
right: 0;
z-index: 10;
background: #1e1e2a;
border: 1px solid #2a2a38;
border-radius: 10px;
padding: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
animation: tooltipFadeIn 0.15s ease-out;
}
@keyframes tooltipFadeIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.promptTooltipAbove {
top: auto;
bottom: 100%;
margin-bottom: 4px;
animation: tooltipFadeInAbove 0.15s ease-out;
}
@keyframes tooltipFadeInAbove {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.promptTooltipText {
font-size: 13px;
font-size: 14px;
color: var(--color-text-primary);
line-height: 1.6;
margin-bottom: 8px;
word-break: break-word;
background: rgba(13, 13, 26, 0.95);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.10);
padding: 6px 8px;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.copyBtn {
display: inline-flex;
align-items: center;
padding: 4px 12px;
.mentionTag {
display: inline;
padding: 1px 5px;
border-radius: 4px;
background: rgba(108, 99, 255, 0.12);
color: rgba(108, 99, 255, 0.7);
font-size: 13px;
white-space: nowrap;
cursor: default;
}
.mentionPreview {
position: fixed;
z-index: 9999;
transform: translate(-50%, -100%);
background: #1e1e2e;
border: 1px solid #2a2a3a;
border-radius: 10px;
padding: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
pointer-events: none;
}
.mentionPreviewImg {
display: block;
width: 160px;
height: 100px;
object-fit: cover;
border-radius: 6px;
font-size: 12px;
color: var(--color-primary);
background: rgba(108, 99, 255, 0.1);
border: 1px solid rgba(108, 99, 255, 0.2);
cursor: pointer;
transition: background 0.15s;
font-family: inherit;
}
.copyBtn:hover {
background: rgba(108, 99, 255, 0.18);
.mentionPreviewLabel {
text-align: center;
color: #8a8a9a;
font-size: 11px;
margin-top: 4px;
}
/* Inline labels after prompt text */
.labelsInline {
display: inline;
@ -143,6 +145,7 @@
white-space: nowrap;
}
.label {
display: inline-flex;
font-size: 12px;

View File

@ -1,4 +1,5 @@
import { useRef, useState, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import type { GenerationTask } from '../types';
import { useGenerationStore } from '../store/generation';
import { showToast } from './Toast';
@ -34,6 +35,83 @@ const DownloadIcon = () => (
</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 {
task: GenerationTask;
onOpenDetail?: (task: GenerationTask) => void;
@ -49,6 +127,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
const promptLineRef = useRef<HTMLDivElement>(null);
const promptWrapperRef = useRef<HTMLDivElement>(null);
const labelsRef = useRef<HTMLSpanElement>(null);
const refColumnRef = useRef<HTMLDivElement>(null);
const [videoHover, setVideoHover] = useState(false);
const [promptHover, setPromptHover] = useState(false);
const [showMore, setShowMore] = useState(false);
@ -56,8 +135,16 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
const [confirmDelete, setConfirmDelete] = useState(false);
const [detailHover, setDetailHover] = useState(false);
const [detailPos, setDetailPos] = useState({ top: 0, right: 0 });
const [promptAbove, setPromptAbove] = useState(false);
const detailLinkRef = useRef<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
useEffect(() => {
@ -82,11 +169,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
const style = getComputedStyle(container);
const font = `${style.fontSize} ${style.fontFamily}`;
// Measure labels width
const labelsWidth = labelsEl.offsetWidth + 8; // +8 for gap
// Two lines of available width, minus labels on line 2, with safety margin
const labelsWidth = labelsEl.offsetWidth + 8;
const totalAvailable = containerWidth * 2 - labelsWidth - 24;
const canvas = document.createElement('canvas');
@ -94,35 +177,28 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
ctx.font = font;
const prompt = task.prompt || '';
let totalWidth = 0;
let needsTruncation = false;
// Check if prompt fits
const fullWidth = ctx.measureText(prompt).width;
if (fullWidth <= totalAvailable) {
setTruncatedPrompt(prompt);
return;
}
// Truncate character by character
let truncated = '';
let totalWidth = 0;
const ellipsisWidth = ctx.measureText('…').width;
for (const char of prompt) {
const charWidth = ctx.measureText(char).width;
if (totalWidth + charWidth + ellipsisWidth > totalAvailable) {
needsTruncation = true;
break;
}
truncated += char;
totalWidth += charWidth;
}
setTruncatedPrompt(needsTruncation ? truncated + '…' : prompt);
setTruncatedPrompt(truncated + '…');
}, [task.prompt]);
useEffect(() => {
computeTruncation();
const container = promptLineRef.current;
if (!container) return;
const ro = new ResizeObserver(() => computeTruncation());
@ -194,7 +270,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
<div className={styles.header}>
{/* Left: reference thumbnails */}
{task.references.length > 0 && (
<div className={styles.refColumn}>
<div ref={refColumnRef} className={styles.refColumn}>
{task.references.map((ref) => (
<div key={ref.id} className={styles.refThumb}>
{ref.type === 'video' ? (
@ -219,20 +295,18 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
<div
ref={promptWrapperRef}
className={styles.promptWrapper}
onMouseLeave={() => setPromptHover(false)}
onMouseLeave={() => { setPromptHover(false); startDetailLeave(); }}
>
{/* 默认状态:截断提示词 + inline 标签 */}
<div ref={promptLineRef} className={styles.promptLine}>
<span onMouseEnter={() => setPromptHover(true)}>
{renderPromptWithMentions(truncatedPrompt || '(无文字描述)', task.assetMentions || [], task.references)}
</span>
<span
onMouseEnter={() => {
const el = promptWrapperRef.current;
if (el) {
const rect = el.getBoundingClientRect();
setPromptAbove(rect.bottom + 350 > window.innerHeight);
}
setPromptHover(true);
}}
>{truncatedPrompt || '(无文字描述)'}</span>
<span ref={labelsRef} className={styles.labelsInline} onMouseEnter={() => setPromptHover(false)}>
ref={labelsRef}
className={styles.labelsInline}
onMouseEnter={() => setPromptHover(false)}
>
<span className={styles.label}>
{task.model === 'seedance_2.0' ? 'AirDrama' : 'AirDrama Fast'}
</span>
@ -242,6 +316,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
ref={detailLinkRef}
className={styles.detailLink}
onMouseEnter={() => {
cancelDetailLeave();
const el = detailLinkRef.current;
if (el) {
const rect = el.getBoundingClientRect();
@ -252,41 +327,64 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
}
setDetailHover(true);
}}
onMouseLeave={() => setDetailHover(false)}
onMouseLeave={startDetailLeave}
>
{detailHover && (
<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>
</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>
{/* 详细信息弹窗 — 放在 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>
{/* 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>
{/* 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 { UniversalUpload } from './UniversalUpload';
import { KeyframeUpload } from './KeyframeUpload';
import { PromptInput } from './PromptInput';
import { Toolbar } from './Toolbar';
import { AssetLibraryModal } from './AssetLibraryModal';
import { showToast } from './Toast';
import styles from './InputBar.module.css';
@ -15,7 +16,8 @@ export function InputBar() {
const handleDragOver = useCallback((e: DragEvent) => {
e.preventDefault();
if (barRef.current) {
// 只有外部文件拖入时才显示蓝色边框(内部 mention 标签拖拽不触发)
if (e.dataTransfer.types.includes('Files') && barRef.current) {
barRef.current.style.borderColor = '#00b8e6';
}
}, []);
@ -71,9 +73,54 @@ export function InputBar() {
}
}, [mode, addReferences, setFirstFrame]);
const [assetModalOpen, setAssetModalOpen] = useState(false);
const searchMode = useInputBarStore((s) => s.searchMode);
const setSearchMode = useInputBarStore((s) => s.setSearchMode);
const references = useInputBarStore((s) => s.references);
const editorHtml = useInputBarStore((s) => s.editorHtml);
const firstFrame = useInputBarStore((s) => s.firstFrame);
const lastFrame = useInputBarStore((s) => s.lastFrame);
// 联网搜索仅支持纯文生视频(无参考图/视频/音频/素材)
const hasMedia = references.length > 0 || !!firstFrame || !!lastFrame || editorHtml.includes('data-ref-type="asset"');
const searchDisabled = hasMedia;
return (
<div className={styles.wrapper}>
<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
ref={barRef}
className={styles.bar}
@ -94,6 +141,7 @@ export function InputBar() {
<Toolbar />
</div>
</div>
<AssetLibraryModal open={assetModalOpen} onClose={() => setAssetModalOpen(false)} />
</div>
);
}

View File

@ -41,9 +41,24 @@
background: rgba(108, 99, 255, 0.12);
color: rgba(108, 99, 255, 0.7);
font-size: 13px;
cursor: default;
cursor: grab;
user-select: none;
transition: background 0.15s;
transition: background 0.15s, opacity 0.15s;
}
.mentionImg {
width: 16px;
height: 16px;
border-radius: 3px;
object-fit: cover;
vertical-align: middle;
margin-right: 3px;
display: inline-block;
pointer-events: none;
}
.dragging {
opacity: 0.4;
}
.mention:hover {

View File

@ -1,7 +1,8 @@
import { useRef, useEffect, useCallback, useState } from 'react';
import DOMPurify from 'dompurify';
import { useInputBarStore } from '../store/inputBar';
import type { UploadedFile } from '../types';
import { assetsApi } from '../lib/api';
import type { UploadedFile, AssetGroup } from '../types';
import styles from './PromptInput.module.css';
const placeholders: Record<string, string> = {
@ -25,6 +26,9 @@ export function PromptInput() {
const [highlightedIdx, setHighlightedIdx] = useState(0);
const [hoverRef, setHoverRef] = useState<UploadedFile | null>(null);
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
useEffect(() => {
@ -36,10 +40,11 @@ export function PromptInput() {
const el = editorRef.current;
if (!el) return;
if (el.innerHTML !== editorHtml) {
el.innerHTML = DOMPurify.sanitize(editorHtml, { ALLOWED_TAGS: ['span', 'br'], ALLOWED_ATTR: ['class', 'contenteditable', 'data-ref-id', 'data-ref-type'] });
// If the HTML is plain text but we have references, rebuild mention spans
el.innerHTML = DOMPurify.sanitize(editorHtml, { ALLOWED_TAGS: ['span', 'br', 'img'], ALLOWED_ATTR: ['class', 'contenteditable', 'data-ref-id', 'data-ref-type', 'data-asset-group-id', 'data-group-name', 'data-thumb-url', 'draggable', 'src', 'alt', 'width', 'height', 'style'] });
// If the HTML is plain text but we have references or asset mentions, rebuild mention spans
// This handles the case where editorHtml comes from backend (plain text only)
if (editorHtml && !editorHtml.includes('data-ref-id') && references.length > 0) {
const currentAssetMentions = useInputBarStore.getState().assetMentions || [];
if (editorHtml && !editorHtml.includes('data-ref-id') && (references.length > 0 || currentAssetMentions.length > 0)) {
rebuildMentionSpans(el);
}
}
@ -55,26 +60,84 @@ export function PromptInput() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [insertAtTrigger]);
// Helper: create a mention span with optional thumbnail
const createMentionSpan = useCallback((opts: {
refId: string; refType: string; label: string; thumbUrl?: string;
assetGroupId?: string; groupName?: string;
}) => {
const span = document.createElement('span');
span.className = styles.mention;
span.contentEditable = 'false';
span.dataset.refId = opts.refId;
span.dataset.refType = opts.refType;
span.draggable = true;
if (opts.thumbUrl) span.dataset.thumbUrl = opts.thumbUrl;
if (opts.assetGroupId) span.dataset.assetGroupId = opts.assetGroupId;
if (opts.groupName) span.dataset.groupName = opts.groupName;
if (opts.thumbUrl) {
const img = document.createElement('img');
img.src = opts.thumbUrl;
img.className = styles.mentionImg;
// 显式设置尺寸,防止 CSS class 未生效时图片为 0x0
img.setAttribute('width', '16');
img.setAttribute('height', '16');
img.style.cssText = 'width:16px;height:16px;border-radius:3px;object-fit:cover;vertical-align:middle;margin-right:3px;display:inline-block;pointer-events:none';
span.appendChild(img);
}
// @ 前缀隐藏textContent 保留用于模式匹配,视觉上不显示)
const atHidden = document.createElement('span');
atHidden.style.cssText = 'font-size:0;width:0;overflow:hidden;display:inline';
atHidden.textContent = '@';
span.appendChild(atHidden);
span.appendChild(document.createTextNode(opts.label));
return span;
}, []);
// Rebuild mention spans from plain text @label patterns
const rebuildMentionSpans = useCallback((el: HTMLElement) => {
// Collect all targets to match: references + asset mentions
const currentAssetMentions = useInputBarStore.getState().assetMentions || [];
type MatchTarget = { label: string; refId: string; refType: string; thumbUrl: string; assetGroupId?: string; groupName?: string };
const targets: MatchTarget[] = [
...references.map((ref) => ({
label: ref.label, refId: ref.id, refType: ref.type, thumbUrl: ref.previewUrl,
})),
...currentAssetMentions.map((am) => ({
label: am.label, refId: am.groupId, refType: 'asset', thumbUrl: am.thumbUrl || '',
assetGroupId: am.groupId, groupName: am.label,
})),
];
if (targets.length === 0) return;
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
const replacements: { node: Text; matches: { start: number; end: number; ref: UploadedFile }[] }[] = [];
const replacements: { node: Text; matches: { start: number; end: number; target: MatchTarget }[] }[] = [];
let textNode: Text | null;
while ((textNode = walker.nextNode() as Text | null)) {
const text = textNode.textContent || '';
const matches: { start: number; end: number; ref: UploadedFile }[] = [];
for (const ref of references) {
const pattern = `@${ref.label}`;
const matches: { start: number; end: number; target: MatchTarget }[] = [];
for (const target of targets) {
const pattern = `@${target.label}`;
let idx = text.indexOf(pattern);
while (idx !== -1) {
matches.push({ start: idx, end: idx + pattern.length, ref });
matches.push({ start: idx, end: idx + pattern.length, target });
idx = text.indexOf(pattern, idx + pattern.length);
}
}
if (matches.length > 0) {
// Sort by position, remove overlapping matches
matches.sort((a, b) => a.start - b.start);
replacements.push({ node: textNode, matches });
const filtered: typeof matches = [];
let lastEnd = 0;
for (const m of matches) {
if (m.start >= lastEnd) {
filtered.push(m);
lastEnd = m.end;
}
}
replacements.push({ node: textNode, matches: filtered });
}
}
@ -86,12 +149,14 @@ export function PromptInput() {
if (m.start > lastIdx) {
frag.appendChild(document.createTextNode(text.slice(lastIdx, m.start)));
}
const span = document.createElement('span');
span.className = styles.mention;
span.contentEditable = 'false';
span.dataset.refId = m.ref.id;
span.dataset.refType = m.ref.type;
span.textContent = `@${m.ref.label}`;
const span = createMentionSpan({
refId: m.target.refId,
refType: m.target.refType,
label: m.target.label,
thumbUrl: m.target.thumbUrl,
assetGroupId: m.target.assetGroupId,
groupName: m.target.groupName,
});
frag.appendChild(span);
lastIdx = m.end;
}
@ -104,7 +169,7 @@ export function PromptInput() {
if (replacements.length > 0) {
setEditorHtml(el.innerHTML);
}
}, [references, setEditorHtml]);
}, [references, setEditorHtml, createMentionSpan]);
const openMentionPopup = useCallback(() => {
const el = editorRef.current;
@ -151,6 +216,7 @@ export function PromptInput() {
}, [setPrompt, setEditorHtml]);
// Remove orphaned mention spans when a reference is deleted
// Skip asset-type spans — they are not tied to uploaded references
useEffect(() => {
const el = editorRef.current;
if (!el) return;
@ -158,6 +224,7 @@ export function PromptInput() {
const spans = el.querySelectorAll<HTMLElement>('[data-ref-id]');
let changed = false;
spans.forEach((span) => {
if (span.dataset.refType === 'asset') return; // skip asset mentions
if (!refIds.has(span.dataset.refId!)) {
span.replaceWith('');
changed = true;
@ -181,10 +248,39 @@ export function PromptInput() {
const text = node.textContent || '';
const offset = range.startOffset;
if (offset > 0 && text[offset - 1] === '@' && references.length > 0) {
// Keep the @ visible, open popup above it
typedAtRef.current = true;
openMentionPopup();
// Find the last @ before cursor
const textBeforeCursor = text.substring(0, offset);
const lastAtIdx = textBeforeCursor.lastIndexOf('@');
if (lastAtIdx >= 0) {
const textAfterAt = textBeforeCursor.substring(lastAtIdx + 1);
if (textAfterAt.length === 0 && references.length > 0) {
// Just typed @, show reference popup
typedAtRef.current = true;
setMentionMode('references');
openMentionPopup();
} else if (/[\u4e00-\u9fff]+/.test(textAfterAt) && !textAfterAt.includes(' ')) {
// Chinese text after @, search assets
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
searchTimerRef.current = setTimeout(() => {
assetsApi.search(textAfterAt).then((res) => {
if (res.data.results.length > 0) {
setAssetSearchResults(res.data.results);
setMentionMode('assets');
typedAtRef.current = true;
setHighlightedIdx(0);
openMentionPopup();
} else {
setShowMentionPopup(false);
}
}).catch(() => {});
}, 300);
} else if (textAfterAt.includes(' ')) {
// Space after @ text, close popup
setShowMentionPopup(false);
}
}
}, [extractText, references.length, openMentionPopup]);
@ -217,13 +313,13 @@ export function PromptInput() {
range.deleteContents();
// Create mention span
const mention = document.createElement('span');
mention.className = styles.mention;
mention.contentEditable = 'false';
mention.dataset.refId = ref.id;
mention.dataset.refType = ref.type;
mention.textContent = `@${ref.label}`;
// Create mention span with thumbnail
const mention = createMentionSpan({
refId: ref.id,
refType: ref.type,
label: ref.label,
thumbUrl: ref.previewUrl,
});
// Insert mention + trailing space
range.insertNode(mention);
@ -241,23 +337,85 @@ export function PromptInput() {
extractText();
}, [extractText]);
const insertAssetMention = useCallback((group: AssetGroup) => {
setShowMentionPopup(false);
setMentionMode('references');
setAssetSearchResults([]);
const el = editorRef.current;
if (!el) return;
el.focus();
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0);
// Remove the @query text that was typed
if (typedAtRef.current) {
typedAtRef.current = false;
const node = range.startContainer;
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent || '';
const offset = range.startOffset;
const atIdx = text.lastIndexOf('@', offset - 1);
if (atIdx >= 0) {
node.textContent = text.substring(0, atIdx) + text.substring(offset);
range.setStart(node, atIdx);
range.collapse(true);
}
}
}
range.deleteContents();
// Create mention span for asset with thumbnail
const mention = createMentionSpan({
refId: String(group.id),
refType: 'asset',
label: group.name,
thumbUrl: group.thumbnail_url,
assetGroupId: String(group.id),
groupName: group.name,
});
range.insertNode(mention);
const space = document.createTextNode('\u00A0');
mention.after(space);
const newRange = document.createRange();
newRange.setStartAfter(space);
newRange.collapse(true);
sel.removeAllRanges();
sel.addRange(newRange);
extractText();
}, [extractText]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (showMentionPopup) {
const items = mentionMode === 'assets' ? assetSearchResults : references;
if (items.length === 0) return;
if (e.key === 'Escape') {
e.preventDefault();
setShowMentionPopup(false);
setMentionMode('references');
} else if (e.key === 'ArrowDown') {
e.preventDefault();
setHighlightedIdx((prev) => (prev + 1) % references.length);
setHighlightedIdx((prev) => (prev + 1) % items.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlightedIdx((prev) => (prev - 1 + references.length) % references.length);
setHighlightedIdx((prev) => (prev - 1 + items.length) % items.length);
} else if (e.key === 'Enter') {
e.preventDefault();
insertMention(references[highlightedIdx]);
if (mentionMode === 'assets') {
insertAssetMention(assetSearchResults[highlightedIdx]);
} else {
insertMention(references[highlightedIdx]);
}
}
}
}, [showMentionPopup, references, highlightedIdx, insertMention]);
}, [showMentionPopup, mentionMode, references, assetSearchResults, highlightedIdx, insertMention, insertAssetMention]);
const handlePaste = useCallback((e: React.ClipboardEvent) => {
e.preventDefault();
@ -276,6 +434,22 @@ export function PromptInput() {
return;
}
// Check if clipboard HTML contains mention spans (from our editor)
const html = e.clipboardData.getData('text/html');
if (html && html.includes('data-ref-id')) {
const sanitized = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['span', 'br', 'img'],
ALLOWED_ATTR: [
'class', 'contenteditable', 'data-ref-id', 'data-ref-type',
'data-asset-group-id', 'data-group-name', 'data-thumb-url',
'draggable', 'src', 'alt', 'width', 'height', 'style',
],
});
document.execCommand('insertHTML', false, sanitized);
extractText();
return;
}
// Plain text paste — strip @label patterns to prevent duplicate mention tags
let text = e.clipboardData.getData('text/plain');
for (const ref of references) {
@ -288,17 +462,35 @@ export function PromptInput() {
extractText();
}, [extractText, references]);
// Mention hover — delegated event
// Mention hover — delegated event (supports both reference and asset mentions)
const handleMouseOver = useCallback((e: React.MouseEvent) => {
const target = (e.target as HTMLElement).closest('[data-ref-id]') as HTMLElement | null;
if (!target) return;
const refId = target.dataset.refId;
const ref = references.find((r) => r.id === refId);
if (!ref) return;
const refType = target.dataset.refType;
// 参考图:从 references 中查找
let found = references.find((r) => r.id === refId);
// 素材库标签:用 data-thumb-url 构造预览数据
if (!found && refType === 'asset') {
const thumbUrl = target.dataset.thumbUrl;
if (thumbUrl) {
found = {
id: refId || '',
type: 'image',
previewUrl: thumbUrl,
label: target.dataset.groupName || target.textContent || '',
};
}
}
if (!found) return;
const rect = target.getBoundingClientRect();
const wrapperRect = editorRef.current!.parentElement!.getBoundingClientRect();
setHoverRef(ref);
setHoverRef(found);
setHoverPos({
top: rect.top - wrapperRect.top - 8,
left: rect.left - wrapperRect.left + rect.width / 2,
@ -340,37 +532,110 @@ export function PromptInput() {
onPaste={handlePaste}
onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut}
onDragStart={(e) => {
const target = (e.target as HTMLElement).closest('[data-ref-id]') as HTMLElement | null;
if (target) {
e.dataTransfer.setData('text/html', target.outerHTML);
e.dataTransfer.effectAllowed = 'move';
target.classList.add(styles.dragging);
setHoverRef(null);
}
}}
onDragOver={(e) => {
e.preventDefault();
// 拖拽 mention 标签时让光标跟随鼠标位置
if (!e.dataTransfer.types.includes('Files')) {
const range = document.caretRangeFromPoint(e.clientX, e.clientY);
if (range) {
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}
}
}}
onDrop={(e) => {
e.preventDefault();
const html = e.dataTransfer.getData('text/html');
if (html && html.includes('data-ref-id')) {
// 1. 先用鼠标坐标算出目标位置,插入临时 marker此时 DOM 还没变)
const dropRange = document.caretRangeFromPoint(e.clientX, e.clientY);
if (!dropRange) return;
const marker = document.createTextNode('\u200B');
dropRange.insertNode(marker);
// 2. 再删除原始标签DOM 重排不影响 marker 位置)
const dragging = editorRef.current?.querySelector(`.${styles.dragging}`);
if (dragging) dragging.remove();
// 3. 在 marker 位置插入标签
const temp = document.createElement('div');
temp.innerHTML = html;
const node = temp.firstChild;
if (node) {
marker.parentNode?.insertBefore(node, marker);
}
marker.remove();
editorRef.current?.normalize();
extractText();
}
}}
/>
{/* Mention popup */}
{showMentionPopup && references.length > 0 && (
{showMentionPopup && (
<div
className={styles.mentionPopup}
style={{ top: mentionPos.top, left: mentionPos.left }}
>
<div className={styles.mentionHeader}>@的内容</div>
{references.map((ref, idx) => (
<button
key={`${ref.id}-${idx}`}
className={`${styles.mentionItem} ${idx === highlightedIdx ? styles.mentionItemActive : ''}`}
onMouseDown={(e) => {
e.preventDefault();
insertMention(ref);
}}
>
<div className={styles.mentionThumb}>
{ref.type === 'video' ? (
<video src={ref.previewUrl} muted className={styles.thumbMedia} />
) : (
<img src={ref.previewUrl} alt="" className={styles.thumbMedia} />
)}
</div>
<span className={styles.mentionLabel}>{ref.label}</span>
<span className={styles.mentionType}>
{ref.type === 'video' ? '视频' : '图片'}
</span>
</button>
))}
{mentionMode === 'references' && references.length > 0 && (
<>
<div className={styles.mentionHeader}>@的内容</div>
{references.map((ref, idx) => (
<button
key={`${ref.id}-${idx}`}
className={`${styles.mentionItem} ${idx === highlightedIdx ? styles.mentionItemActive : ''}`}
onMouseDown={(e) => {
e.preventDefault();
insertMention(ref);
}}
>
<div className={styles.mentionThumb}>
{ref.type === 'video' ? (
<video src={ref.previewUrl} muted className={styles.thumbMedia} />
) : (
<img src={ref.previewUrl} alt="" className={styles.thumbMedia} />
)}
</div>
<span className={styles.mentionLabel}>{ref.label}</span>
<span className={styles.mentionType}>
{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>
)}

View File

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

View File

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

View File

@ -5,6 +5,7 @@ import { AmbientBackground } from './AmbientBackground';
import { ConfirmModal } from './ConfirmModal';
import { ImageLightbox } from './ImageLightbox';
import { useInputBarStore } from '../store/inputBar';
import { renderPromptWithMentions } from './GenerationCard';
import styles from './VideoDetailModal.module.css';
interface Props {
@ -468,7 +469,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
<div className={styles.infoPanelContent}>
<div className={styles.promptSection}>
<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>
{task.references.length > 0 && (
@ -498,7 +499,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
</div>
{/* 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' }}>
<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" />
@ -510,7 +511,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
{/* Fixed bottom: info bar + actions card */}
<div className={styles.infoPanelBottom}>
<div className={styles.infoBar}>
<div className={styles.infoBar} style={{ flexWrap: 'wrap', rowGap: 6 }}>
<span>{modeLabel}</span>
<span className={styles.infoBarDot} />
<span>{modelLabel}</span>
@ -520,7 +521,6 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
<span>{task.aspectRatio}</span>
{(task.tokensConsumed ?? 0) > 0 && (
<>
<span className={styles.infoBarDot} />
<span>{(task.tokensConsumed ?? 0).toLocaleString()} tokens</span>
<span className={styles.infoBarDot} />
<span>¥{(task.costAmount ?? 0).toFixed(2)}</span>
@ -528,28 +528,6 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
)}
</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>

View File

@ -4,7 +4,7 @@ import type {
AdminRecord, SystemSettings, ProfileOverview, PaginatedResponse,
BackendTask, TeamInfo, Team, TeamDetail, TeamMember, TeamStats,
AuditLog, AssetTeamSummary, AssetMemberSummary, AssetVideo,
LoginAnomaly, TeamAnomalyConfig,
LoginAnomaly, TeamAnomalyConfig, AssetGroup, AssetItem,
} from '../types';
import { reportError } from './logCenter';
@ -131,7 +131,8 @@ export const videoApi = {
model: string;
aspect_ratio: string;
duration: number;
references: { url: string; type: string; role: string; label: string }[];
references: { url: string; type: string; role: string; label: string; thumb_url?: string }[];
search_mode?: string;
}) =>
api.post<{
task_id: string;
@ -286,6 +287,17 @@ export const adminApi = {
teamApplyLearnedRegions: (teamId: number, cities: string[]) =>
api.post(`/admin/teams/${teamId}/apply-learned-regions`, { cities }),
getLoginRecords: (params: {
page?: number;
page_size?: number;
search?: string;
team_id?: string;
start_date?: string;
end_date?: string;
city?: string;
} = {}) =>
api.get('/admin/login-records', { params }),
getAuditLogs: (params: {
page?: number;
page_size?: number;
@ -356,4 +368,23 @@ export const profileApi = {
}),
};
export const assetsApi = {
getGroups: (params: { page?: number; page_size?: number } = {}) =>
api.get<{ results: AssetGroup[]; total: number }>('/assets/groups', { params }),
createGroup: (data: FormData) =>
api.post<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;

View File

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

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/settings', label: '系统设置', icon: 'M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.488.488 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z' },
{ path: '/admin/security', label: '安全日志', icon: 'M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z' },
{ path: '/admin/login-records', label: '登录记录', icon: 'M11 7L9.6 8.4l2.6 2.6H2v2h10.2l-2.6 2.6L11 17l5-5-5-5zm9 12h-8v2h8c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2h-8v2h8v14z' },
{ path: '/admin/logs', label: '操作日志', icon: 'M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z' },
];

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',
duration: v.duration as any,
references,
assetMentions: [],
status: 'completed',
progress: 100,
resultUrl: v.result_url,

View File

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

View File

@ -56,6 +56,13 @@ interface InputBarState {
// Computed
canSubmit: () => boolean;
// Search mode (联网搜索)
searchMode: 'smart' | 'off';
setSearchMode: (mode: 'smart' | 'off') => void;
// Asset mentions (for reEdit/regenerate to pass asset data to PromptInput rebuild)
assetMentions: { groupId: string; label: string; thumbUrl: string }[];
// @ trigger (for toolbar button to insert @ in contentEditable)
insertAtTrigger: number;
triggerInsertAt: () => void;
@ -202,6 +209,11 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
return true;
},
searchMode: 'off',
setSearchMode: (searchMode) => set({ searchMode }),
assetMentions: [],
insertAtTrigger: 0,
triggerInsertAt: () => set((s) => ({ insertAtTrigger: s.insertAtTrigger + 1 })),
@ -254,6 +266,7 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
editorHtml: '',
references: [],
prevReferences: [],
assetMentions: [],
firstFrame: null,
lastFrame: null,
generationType: 'video',

View File

@ -41,6 +41,7 @@ export interface GenerationTask {
aspectRatio: AspectRatio;
duration: Duration;
references: ReferenceSnapshot[];
assetMentions: { groupId: string; label: string; thumbUrl: string }[];
status: TaskStatus;
progress: number;
resultUrl?: string;
@ -390,3 +391,23 @@ export interface AuditLog {
ip_address: string | null;
created_at: string;
}
export interface AssetGroup {
id: number;
name: string;
thumbnail_url: string;
asset_count: number;
remote_group_id: string;
description: string;
created_at: string;
}
export interface AssetItem {
id: number;
name: string;
url: string;
status: 'processing' | 'active' | 'failed';
remote_asset_id: string;
error_message: string;
created_at: string;
}