Compare commits

...

13 Commits

Author SHA1 Message Date
seaislee1209
5da67435b2 fix: v0.18.1 用户测试 8 项 Bug 修复
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
- MutationObserver 立刻同步 editorHtml(删 @ 标签后时长/数量立即重置)
- parseAssetMentionsFromDOM 从 DOM 实时读取(不用 stale state)
- renderPromptWithMentions 支持音频 ♫ + 视频首帧 + assetType
- rebuildMentionSpans 按 label 长度降序匹配(防子串冲突)
- 删除素材后 group 缩略图优先找图片/视频(不用音频 URL)
- 素材组整组删除功能(后端 DELETE + 前端按钮)
- Celery poll 架构重构(一次性任务 + recover_stuck_tasks 统一驱动)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:06:02 +08:00
zyc
d73175b101 fix: kubectl 装到 /usr/bin 避开 /usr/local/bin 的 bind mount
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m16s
2026-04-04 20:28:05 +08:00
zyc
f37c38d38b fix: kubectl 安装前先删旧目录避免 mv 覆盖目录报错
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
2026-04-04 20:25:52 +08:00
zyc
4cf9a0a4bb perf: 轮询调度间隔从30秒改为10秒
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
2026-04-04 20:21:25 +08:00
zyc
127ed9659d Merge remote-tracking branch 'origin/dev' into dev
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
2026-04-04 20:16:15 +08:00
zyc
ded5c4c44f fix bug 2026-04-04 20:13:23 +08:00
seaislee1209
ba33c35dd8 add test
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m13s
2026-04-04 19:38:36 +08:00
zyc
6353d2ec4f feat: rename /assets route to /user-assets
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
2026-04-04 19:29:31 +08:00
seaislee1209
f1a7ad8a2f fix: nginx /assets 路由 403 修复 — 静态缓存改为正则匹配文件扩展名
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m32s
/assets/ location 会拦截 SPA 路由 /assets(资产页),导致刷新 403。
改为正则匹配 /assets/*.{js,css,png,...},只缓存实际静态文件,
不影响 SPA fallback 到 index.html。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 19:16:38 +08:00
seaislee1209
9a6d95a69d fix: v0.18.0 商业级加固 — 并发安全、流式上传、错误反馈、类型修复
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m13s
- TOS 流式上传 upload_from_file_path(避免大文件 OOM)
- 视频生成完成后下载一次复用(TOS 上传 + 首帧提取)
- 并发安全:group thumbnail 用 select_for_update 原子更新
- 跨团队校验:_resolve_asset_group_all 加 group__team 过滤
- 异常信息脱敏:文件上传失败不再泄露内部异常
- SSRF 防护:download_to_temp 校验 URL scheme
- poll lock 终态释放:cache.delete 在 record.save 后调用
- duration=null 语义区分:ffprobe 失败存 None 非 0
- 前端 duration 未知 toast 警告:素材时长未确定时提示用户
- 搜索 API 失败 toast:素材搜索失败时反馈用户
- 视频保存降级标记:临时 URL 降级时设 error_message
- TypeScript 类型修复:AssetItem/AssetSearchResult.duration 改为 number|null
- rebuildMentionSpans 补完 assetId/assetType/assetName/duration 属性
- paste DOMPurify 白名单补完新 data attributes
- resolved_url NameError 修复:非素材库视频/音频引用用 url
- process_asset_media group 删除保护
- download_to_temp 改为 public API
- 清理前端死代码

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 18:49:08 +08:00
zyc
61bcb9576f add git guide
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m55s
2026-04-04 18:26:05 +08:00
seaislee1209
2e72c82116 Merge branch 'dev' of https://gitea.airlabs.art/zyc/video-shuoshan into dev
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m39s
2026-04-04 17:36:39 +08:00
seaislee1209
da9a1413c3 v0.18.0 素材库多类型支持 + @ 引用改为单素材
对齐火山 API 文档(Asset URI 小写、HEIC/HEIF、DeleteAsset)
素材库支持视频/音频上传(按类型分三区显示、前端校验、拖拽上传)
@ 引用从素材组改为单个素材(搜索返回具体素材、即时数量/时长检查)
ffmpeg 视频封面帧提取 + 音频时长读取(Celery 异步)
生产级安全修复(跨团队校验、异常信息脱敏、下载大小限制)
2026-04-04 17:36:35 +08:00
27 changed files with 1464 additions and 355 deletions

View File

@ -88,7 +88,7 @@ jobs:
curl -LO "https://files.m.daocloud.io/dl.k8s.io/release/v1.28.0/bin/linux/amd64/kubectl" && break
echo "Download attempt $attempt failed, retrying in 5s..." && sleep 5
done
chmod +x kubectl && mv kubectl /usr/local/bin/
chmod +x kubectl && mv kubectl /usr/bin/kubectl
fi
kubectl version --client

View File

@ -11,6 +11,7 @@ RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debia
gcc \
default-libmysqlclient-dev \
pkg-config \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
# Python dependencies

View File

@ -0,0 +1,28 @@
# Generated by Django 4.2.29 on 2026-04-04 09:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('generation', '0017_add_asset_type'),
]
operations = [
migrations.AddField(
model_name='asset',
name='duration',
field=models.FloatField(default=0, verbose_name='时长(秒)'),
),
migrations.AddField(
model_name='asset',
name='thumbnail_url',
field=models.CharField(blank=True, default='', max_length=1000, verbose_name='缩略图URL'),
),
migrations.AddField(
model_name='generationrecord',
name='thumbnail_url',
field=models.CharField(blank=True, default='', max_length=1000, verbose_name='视频缩略图URL'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.29 on 2026-04-04 17:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('generation', '0018_add_thumbnail_and_duration'),
]
operations = [
migrations.AlterField(
model_name='asset',
name='duration',
field=models.FloatField(default=None, null=True, verbose_name='时长(秒)'),
),
]

View File

@ -42,6 +42,7 @@ class GenerationRecord(models.Model):
resolution = models.CharField(max_length=10, blank=True, default='', verbose_name='分辨率')
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='queued', verbose_name='状态')
result_url = models.CharField(max_length=1000, blank=True, default='', verbose_name='生成结果URL')
thumbnail_url = models.CharField(max_length=1000, blank=True, default='', verbose_name='视频缩略图URL')
error_message = models.TextField(blank=True, default='', verbose_name='错误信息')
raw_error = models.TextField(blank=True, default='', verbose_name='原始错误信息')
reference_urls = models.JSONField(default=list, blank=True, verbose_name='参考素材信息')
@ -156,6 +157,8 @@ class Asset(models.Model):
name = models.CharField(max_length=100, default='', verbose_name='素材名称')
url = models.CharField(max_length=1000, blank=True, default='', verbose_name='素材URL')
asset_type = models.CharField(max_length=10, choices=ASSET_TYPE_CHOICES, default='Image', verbose_name='素材类型')
thumbnail_url = models.CharField(max_length=1000, blank=True, default='', verbose_name='缩略图URL')
duration = models.FloatField(null=True, default=None, verbose_name='时长(秒)')
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='创建时间')

View File

@ -6,44 +6,48 @@ from celery import shared_task
logger = logging.getLogger(__name__)
# 轮询间隔(秒):每次查完后重新入队,不占 worker 进程
POLL_INTERVAL = 5
@shared_task(ignore_result=True)
def poll_video_task(record_id):
"""Poll Volcano API once for a video generation task.
@shared_task(bind=True, max_retries=None, ignore_result=True)
def poll_video_task(self, record_id):
"""Poll Volcano API for a video generation task.
每次只执行一轮查询查完通过 self.retry 重新入队
这样 worker 不会被 sleep 占死重启也不丢任务
一次性任务查一次 API更新 DB结束
recover_stuck_tasksbeat 每10秒调度统一驱动不再自己 retry
Redis 锁防止 _handle_completed 期间被重复 dispatch
"""
from django.core.cache import cache
# Redis 锁:防止同一 record 被并发处理_handle_completed 耗时较长)
lock_key = f'poll_lock:{record_id}'
if not cache.add(lock_key, '1', timeout=120):
return
try:
_do_poll(record_id)
except Exception:
logger.exception('poll_video_task: unexpected error for record=%s', record_id)
finally:
cache.delete(lock_key)
def _do_poll(record_id):
"""实际轮询逻辑,由 poll_video_task 调用。"""
from django.utils import timezone
from apps.generation.models import GenerationRecord
from utils.airdrama_client import query_task, map_status
# 防重复:同一 record 同一时刻只允许一个 poll 在执行
from django.core.cache import cache
lock_key = f'poll_lock:{record_id}'
if not cache.add(lock_key, '1', timeout=POLL_INTERVAL * 3):
logger.info('poll_video_task: record %s already being polled, skipping', record_id)
return
try:
record = GenerationRecord.objects.get(pk=record_id)
except GenerationRecord.DoesNotExist:
logger.warning('poll_video_task: record %s not found', record_id)
cache.delete(lock_key)
return
if record.status not in ('queued', 'processing'):
return
ark_task_id = record.ark_task_id
if not ark_task_id:
logger.warning('poll_video_task: record %s has no ark_task_id', record_id)
cache.delete(lock_key)
return
if record.status not in ('queued', 'processing'):
logger.info('poll_video_task: record %s already in terminal state: %s', record_id, record.status)
cache.delete(lock_key)
return
# Poll Volcano API
@ -51,16 +55,13 @@ def poll_video_task(self, record_id):
ark_resp = query_task(ark_task_id)
new_status = map_status(ark_resp.get('status', ''))
except Exception:
logger.exception('poll_video_task: API query failed for %s, will retry', ark_task_id)
cache.delete(lock_key)
raise self.retry(countdown=POLL_INTERVAL)
logger.exception('poll_video_task: API query failed for record=%s ark=%s', record_id, ark_task_id)
return
if new_status in ('queued', 'processing'):
# Still running — update status, then re-enqueue
record.status = new_status
record.save(update_fields=['status', 'updated_at'])
cache.delete(lock_key)
raise self.retry(countdown=POLL_INTERVAL)
return
# Terminal state reached — process result
record.status = new_status
@ -76,7 +77,7 @@ def poll_video_task(self, record_id):
record.completed_at = timezone.now()
record.save(update_fields=[
'status', 'result_url', 'error_message', 'raw_error',
'status', 'result_url', 'thumbnail_url', 'error_message', 'raw_error',
'seed', 'completed_at',
])
@ -87,17 +88,35 @@ def poll_video_task(self, record_id):
def _handle_completed(record, ark_resp):
"""Process a completed task: persist video to TOS and settle payment."""
"""Process a completed task: persist video to TOS, extract thumbnail, settle payment."""
import os
from utils.airdrama_client import extract_video_url
video_url = extract_video_url(ark_resp)
if video_url:
# Download once to temp file, reuse for TOS upload + thumbnail extraction
tmp_path = None
try:
from utils.tos_client import upload_from_url
record.result_url = upload_from_url(video_url, folder='results')
from utils.media_utils import download_to_temp, extract_video_info_from_file
from utils.tos_client import upload_from_file_path, upload_file
tmp_path = download_to_temp(video_url, '.mp4')
# Upload video to TOS from file (streaming, no full memory load)
record.result_url = upload_from_file_path(tmp_path, folder='results', content_type='video/mp4')
# Extract thumbnail from the same local file (no second download)
thumb_file, _ = extract_video_info_from_file(tmp_path)
if thumb_file:
record.thumbnail_url = upload_file(thumb_file, folder='thumbnails')
except Exception:
logger.exception('poll_video_task: failed to persist video to TOS')
record.result_url = video_url
logger.exception('poll_video_task: failed to persist video / extract thumbnail')
if not record.result_url:
record.result_url = video_url
record.error_message = '视频保存失败临时链接将在24小时后过期请联系管理员'
finally:
if tmp_path and os.path.exists(tmp_path):
os.unlink(tmp_path)
# 结算:按实际 tokens 扣费
usage = ark_resp.get('usage', {})
@ -112,29 +131,27 @@ def _handle_completed(record, ark_resp):
@shared_task(ignore_result=True)
def recover_stuck_tasks():
"""定时扫描卡在 processing/queued 超过 3 分钟的任务,重新派发轮询。"""
from datetime import timedelta
from django.utils import timezone
"""每30秒扫一次所有进行中的任务统一派发轮询。
poll_video_task 是一次性任务不再自己 retry由这里统一驱动
"""
from apps.generation.models import GenerationRecord
cutoff = timezone.now() - timedelta(minutes=3)
stuck_records = GenerationRecord.objects.filter(
active_records = GenerationRecord.objects.filter(
status__in=('queued', 'processing'),
ark_task_id__isnull=False,
updated_at__lt=cutoff,
).exclude(ark_task_id='')
).exclude(ark_task_id='').values_list('id', flat=True)
count = 0
for record in stuck_records:
logger.warning('recover_stuck_tasks: re-dispatching record=%s ark=%s', record.id, record.ark_task_id)
for record_id in active_records:
try:
poll_video_task.delay(record.id)
poll_video_task.delay(record_id)
count += 1
except Exception:
logger.error('recover_stuck_tasks: failed to dispatch record=%s', record.id)
logger.error('recover_stuck_tasks: failed to dispatch record=%s', record_id)
if count:
logger.info('recover_stuck_tasks: re-dispatched %d stuck tasks', count)
logger.info('recover_stuck_tasks: dispatched %d active tasks', count)
def _handle_failed(record, ark_resp):
@ -155,3 +172,44 @@ def _handle_failed(record, ark_resp):
else:
from apps.generation.views import _release_freeze
_release_freeze(record)
@shared_task(ignore_result=True)
def process_asset_media(asset_id):
"""Extract thumbnail + duration for video/audio assets asynchronously."""
from apps.generation.models import Asset
try:
asset = Asset.objects.select_related('group').get(pk=asset_id)
except Asset.DoesNotExist:
logger.warning('process_asset_media: asset %s not found', asset_id)
return
from utils.media_utils import extract_video_info, get_audio_duration
from utils.tos_client import upload_file
if asset.asset_type == 'Video':
thumb_file, dur = extract_video_info(asset.url)
if thumb_file:
try:
asset.thumbnail_url = upload_file(thumb_file, folder='thumbnails')
except Exception:
logger.exception('process_asset_media: thumbnail upload failed for asset %s', asset_id)
asset.duration = dur if dur > 0 else None # None = ffprobe failed, frontend skips duration check
asset.save(update_fields=['thumbnail_url', 'duration'])
# Atomic update: only set group thumbnail if still empty (concurrent-safe)
from apps.generation.models import AssetGroup
from django.db import transaction
try:
with transaction.atomic():
group = AssetGroup.objects.select_for_update().get(pk=asset.group_id)
if not group.thumbnail_url and asset.thumbnail_url:
group.thumbnail_url = asset.thumbnail_url
group.save(update_fields=['thumbnail_url'])
except AssetGroup.DoesNotExist:
logger.warning('process_asset_media: group %s deleted, skipping thumbnail update', asset.group_id)
elif asset.asset_type == 'Audio':
dur = get_audio_duration(asset.url)
asset.duration = dur if dur > 0 else None
asset.save(update_fields=['duration'])
logger.info('process_asset_media: asset %s done (type=%s, dur=%s)', asset_id, asset.asset_type, asset.duration)

View File

@ -295,7 +295,7 @@ def video_generate_view(request):
"""查询本地 DB 获取组内所有 active 素材,返回 [(asset_url, asset_type), ...] 列表。
processing 的素材会尝试实时刷新状态"""
assets = list(AssetModel.objects.filter(
group_id=gid, status__in=['active', 'processing']
group_id=gid, group__team=team, status__in=['active', 'processing']
).exclude(remote_asset_id='').order_by('created_at'))
if not assets:
logger.warning('No assets found for group %s (label=%s)', gid, lbl)
@ -349,7 +349,45 @@ def video_generate_view(request):
snap['thumb_url'] = thumb_url
reference_snapshots.append(snap)
# 转换 asset://group-{id} → 展开为组内所有 active 素材(全发)
# 单素材引用asset://local-{id} → 查 Asset 表 → 单个 content_item
if url.startswith('asset://local-'):
try:
asset_local_id = int(url.replace('asset://local-', ''))
asset_obj = AssetModel.objects.get(pk=asset_local_id, group__team=team)
if asset_obj.status != 'active':
return Response({
'error': 'asset_not_ready',
'message': f'素材「{label}」尚在处理中,请稍后重试',
}, status=status.HTTP_400_BAD_REQUEST)
if not asset_obj.remote_asset_id:
return Response({
'error': 'asset_not_ready',
'message': f'素材「{label}」尚未就绪,请稍后重试',
}, status=status.HTTP_400_BAD_REQUEST)
aid = asset_obj.remote_asset_id
if aid.startswith('Asset-'):
aid = 'asset-' + aid[6:]
resolved_asset_url = f'asset://{aid}'
if asset_obj.asset_type == 'Video':
content_items.append({'type': 'video_url', 'video_url': {'url': resolved_asset_url}, 'role': 'reference_video'})
elif asset_obj.asset_type == 'Audio':
content_items.append({'type': 'audio_url', 'audio_url': {'url': resolved_asset_url}, 'role': 'reference_audio'})
else:
content_items.append({'type': 'image_url', 'image_url': {'url': resolved_asset_url}, 'role': 'reference_image'})
except AssetModel.DoesNotExist:
return Response({
'error': 'asset_not_found',
'message': f'素材「{label}」不存在或已被删除',
}, status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
logger.warning('Failed to resolve asset URL %s: %s', url, e)
return Response({
'error': 'asset_not_ready',
'message': f'素材「{label}」解析失败,请重试',
}, status=status.HTTP_400_BAD_REQUEST)
continue
# 向后兼容asset://group-{id} → 展开为组内所有 active 素材
if url.startswith('asset://group-'):
try:
group_id = int(url.replace('asset://group-', ''))
@ -387,12 +425,12 @@ def video_generate_view(request):
item['role'] = role
content_items.append(item)
elif ref_type == 'video':
item = {'type': 'video_url', 'video_url': {'url': resolved_url}}
item = {'type': 'video_url', 'video_url': {'url': url}}
if role:
item['role'] = role
content_items.append(item)
elif ref_type == 'audio':
item = {'type': 'audio_url', 'audio_url': {'url': resolved_url}}
item = {'type': 'audio_url', 'audio_url': {'url': url}}
if role:
item['role'] = role
content_items.append(item)
@ -603,6 +641,7 @@ def _serialize_task(record):
'base_cost_amount': float(record.base_cost_amount),
'status': record.status,
'result_url': d.get('result_url', ''),
'thumbnail_url': d.get('thumbnail_url', ''),
'error_message': d.get('error_message', ''),
'reference_urls': d.get('reference_urls') or [],
'is_favorited': record.is_favorited,
@ -3007,46 +3046,33 @@ def asset_groups_view(request):
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)
# Detect asset type and validate format/size
asset_type, err = _detect_asset_type(file)
if err:
return err
# Validate image dimensions (only for images)
if asset_type == 'Image':
try:
from PIL import Image
img = Image.open(file)
w, h = img.size
if w < 300 or h < 300:
return Response(
{'error': f'图片太小了(当前 {w}x{h}),宽高需在 300~6000 像素之间'},
status=status.HTTP_400_BAD_REQUEST,
)
if w > 6000 or h > 6000:
return Response(
{'error': f'图片太大了(当前 {w}x{h}),宽高需在 300~6000 像素之间'},
status=status.HTTP_400_BAD_REQUEST,
)
file.seek(0)
except ImportError:
pass
except Exception:
pass
# Upload to TOS
folder = 'assets' if asset_type == 'Image' else asset_type.lower()
try:
tos_url = tos_upload(file, folder=folder)
except Exception as e:
logger.exception('TOS upload failed for asset')
return Response(
{'error': f'文件上传失败: {e}'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
# Validate file BEFORE creating group (prevent orphan records)
asset_type = None
if file:
asset_type, err = _detect_asset_type(file)
if err:
return err
if asset_type == 'Image':
try:
from PIL import Image
img = Image.open(file)
w, h = img.size
if w < 300 or h < 300:
return Response(
{'error': f'图片太小了(当前 {w}x{h}),宽高需在 300~6000 像素之间'},
status=status.HTTP_400_BAD_REQUEST,
)
if w > 6000 or h > 6000:
return Response(
{'error': f'图片太大了(当前 {w}x{h}),宽高需在 300~6000 像素之间'},
status=status.HTTP_400_BAD_REQUEST,
)
file.seek(0)
except ImportError:
pass
except Exception:
pass
# Create remote group
from utils import assets_client
@ -3057,50 +3083,71 @@ def asset_groups_view(request):
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, asset_type=asset_type)
if err:
return err
if result is not None:
remote_asset_id = result
# Local DB records
# Local DB group
group = AssetGroup.objects.create(
team=team,
remote_group_id=remote_group_id,
name=name,
description='',
thumbnail_url=tos_url,
thumbnail_url='',
created_by=request.user,
)
Asset.objects.create(
group=group,
remote_asset_id=remote_asset_id,
name=name,
url=tos_url,
asset_type=asset_type,
status='processing' if remote_asset_id else 'active',
error_message='',
)
# If file provided, create first asset (validation already done above)
if file and asset_type:
folder = 'assets' if asset_type == 'Image' else asset_type.lower()
try:
tos_url = tos_upload(file, folder=folder)
except Exception as e:
logger.exception('TOS upload failed for asset')
return Response(
{'error': '文件上传失败,请稍后重试'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
remote_asset_id = ''
if remote_group_id:
result, err = _assets_api_call(assets_client.create_asset, remote_group_id, tos_url, name, asset_type=asset_type)
if err:
return err
if result is not None:
remote_asset_id = result
asset_obj = Asset.objects.create(
group=group,
remote_asset_id=remote_asset_id,
name=name,
url=tos_url,
asset_type=asset_type,
status='processing' if remote_asset_id else 'active',
error_message='',
)
# Set group thumbnail for images; video/audio thumbnails extracted async
if asset_type == 'Image':
group.thumbnail_url = tos_url
group.save(update_fields=['thumbnail_url'])
# Async: extract thumbnail + duration for video/audio
if asset_type in ('Video', 'Audio'):
from apps.generation.tasks import process_asset_media
process_asset_media.delay(asset_obj.id)
return Response({
'id': group.id,
'name': group.name,
'thumbnail_url': group.thumbnail_url,
'remote_group_id': group.remote_group_id,
'asset_count': 1,
'asset_count': Asset.objects.filter(group=group).count(),
'created_at': group.created_at.isoformat(),
}, status=status.HTTP_201_CREATED)
@api_view(['GET', 'PUT'])
@api_view(['GET', 'PUT', 'DELETE'])
@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.
DELETE /api/v1/assets/groups/<id> delete entire group + all assets.
"""
team = request.user.team
try:
@ -3108,6 +3155,20 @@ def asset_group_detail_view(request, group_id):
except AssetGroup.DoesNotExist:
return Response({'error': '素材组不存在'}, status=status.HTTP_404_NOT_FOUND)
if request.method == 'DELETE':
# Delete all remote assets in this group
from utils import assets_client
for asset in Asset.objects.filter(group=group):
if asset.remote_asset_id:
try:
assets_client.delete_asset(asset.remote_asset_id)
except Exception as e:
logger.warning('Failed to delete remote asset %s: %s', asset.remote_asset_id, e)
# Delete local records
Asset.objects.filter(group=group).delete()
group.delete()
return Response({'message': '素材组已删除'})
if request.method == 'GET':
# 同步火山端的素材组名字
if group.remote_group_id:
@ -3129,6 +3190,8 @@ def asset_group_detail_view(request, group_id):
'name': a.name,
'url': a.url,
'asset_type': a.asset_type,
'thumbnail_url': a.thumbnail_url,
'duration': a.duration,
'status': a.status,
'remote_asset_id': a.remote_asset_id,
'error_message': a.error_message,
@ -3230,7 +3293,7 @@ def asset_group_add_asset_view(request, group_id):
except Exception as e:
logger.exception('TOS upload failed for asset')
return Response(
{'error': f'文件上传失败: {e}'},
{'error': '文件上传失败,请稍后重试'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@ -3256,16 +3319,27 @@ def asset_group_add_asset_view(request, group_id):
error_message='',
)
# If first asset or no thumbnail, set thumbnail
if not group.thumbnail_url:
group.thumbnail_url = tos_url
group.save(update_fields=['thumbnail_url'])
# Atomic: set group thumbnail only if still empty (concurrent-safe)
if asset_type == 'Image':
from django.db import transaction
with transaction.atomic():
locked_group = AssetGroup.objects.select_for_update().get(pk=group.id)
if not locked_group.thumbnail_url:
locked_group.thumbnail_url = tos_url
locked_group.save(update_fields=['thumbnail_url'])
# Async: extract thumbnail + duration for video/audio
if asset_type in ('Video', 'Audio'):
from apps.generation.tasks import process_asset_media
process_asset_media.delay(asset.id)
return Response({
'id': asset.id,
'name': asset.name,
'url': asset.url,
'asset_type': asset.asset_type,
'thumbnail_url': asset.thumbnail_url,
'duration': asset.duration,
'status': asset.status,
'remote_asset_id': asset.remote_asset_id,
'created_at': asset.created_at.isoformat(),
@ -3295,14 +3369,17 @@ def asset_update_view(request, asset_id):
group = asset.group
asset.delete()
# Update group thumbnail if needed
remaining = Asset.objects.filter(group=group).exclude(status='failed').order_by('-created_at').first()
if remaining:
if group.thumbnail_url != remaining.url:
group.thumbnail_url = remaining.url
group.save(update_fields=['thumbnail_url'])
# Update group thumbnail: prefer Image > Video (with thumbnail) > empty
remaining_img = Asset.objects.filter(group=group, asset_type='Image').exclude(status='failed').first()
remaining_vid = Asset.objects.filter(group=group, asset_type='Video').exclude(status='failed').exclude(thumbnail_url='').first()
if remaining_img:
new_thumb = remaining_img.url
elif remaining_vid:
new_thumb = remaining_vid.thumbnail_url
else:
group.thumbnail_url = ''
new_thumb = ''
if group.thumbnail_url != new_thumb:
group.thumbnail_url = new_thumb
group.save(update_fields=['thumbnail_url'])
return Response({'message': '素材已删除'})
@ -3332,26 +3409,29 @@ def asset_update_view(request, asset_id):
@api_view(['GET'])
@permission_classes([IsTeamMember])
def asset_search_view(request):
"""GET /api/v1/assets/search?q=... — fast search for @ popup."""
"""GET /api/v1/assets/search?q=... — search individual assets for @ popup."""
team = request.user.team
q = request.query_params.get('q', '').strip()
q = request.query_params.get('q', '').strip()[:100] # 限制搜索长度
if not q:
return Response({'results': []})
groups = (
AssetGroup.objects
.filter(team=team, name__icontains=q)
.annotate(asset_count=Count('assets'))
assets = (
Asset.objects
.filter(group__team=team, name__icontains=q, status='active')
.select_related('group')
.order_by('-created_at')[:20]
)
results = []
for g in groups:
for a in assets:
results.append({
'id': g.id,
'name': g.name,
'thumbnail_url': g.thumbnail_url if g.asset_count > 0 else '',
'asset_count': g.asset_count,
'remote_group_id': g.remote_group_id,
'id': a.id,
'name': a.name,
'url': a.url,
'asset_type': a.asset_type,
'group_name': a.group.name,
'remote_asset_id': a.remote_asset_id,
'thumbnail_url': a.thumbnail_url,
'duration': a.duration,
})
return Response({'results': results})

View File

@ -182,7 +182,7 @@ CELERY_TIMEZONE = 'Asia/Shanghai'
CELERY_BEAT_SCHEDULE = {
'recover-stuck-tasks': {
'task': 'apps.generation.tasks.recover_stuck_tasks',
'schedule': 180, # 每 3 分钟
'schedule': 10, # 每 10 秒
},
}

View File

@ -0,0 +1,134 @@
"""Media utilities: extract video thumbnails and durations using ffmpeg/ffprobe.
WARNING: These functions download files and run subprocess commands.
They MUST only be called from Celery tasks, NEVER from HTTP request handlers.
Calling from gunicorn (especially with gevent workers) will block the worker pool.
"""
import logging
import subprocess
import tempfile
import os
import requests
from django.core.files.uploadedfile import SimpleUploadedFile
logger = logging.getLogger(__name__)
MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024 # 100MB safety limit
def download_to_temp(url: str, suffix: str) -> str:
"""Download a URL to a temporary file. Returns the temp file path.
Only accepts http/https URLs to prevent SSRF.
"""
if not url.startswith(('http://', 'https://')):
raise ValueError(f'Invalid URL scheme: {url[:30]}')
resp = requests.get(url, timeout=30, stream=True)
resp.raise_for_status()
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
downloaded = 0
try:
for chunk in resp.iter_content(8192):
downloaded += len(chunk)
if downloaded > MAX_DOWNLOAD_SIZE:
tmp.close()
os.unlink(tmp.name)
raise ValueError(f'File too large: {downloaded} bytes')
tmp.write(chunk)
tmp.close()
except Exception:
tmp.close()
if os.path.exists(tmp.name):
os.unlink(tmp.name)
raise
return tmp.name
def _get_duration_ffprobe(file_path: str) -> float:
"""Get media duration in seconds using ffprobe."""
try:
result = subprocess.run(
['ffprobe', '-v', 'quiet', '-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1', file_path],
capture_output=True, text=True, timeout=15,
)
return float(result.stdout.strip())
except Exception as e:
logger.warning('ffprobe duration failed: %s', e)
return 0
def _extract_first_frame(video_path: str, output_path: str) -> bool:
"""Extract the first frame of a video as JPEG using ffmpeg."""
try:
subprocess.run(
['ffmpeg', '-y', '-i', video_path, '-vframes', '1',
'-f', 'image2', '-q:v', '2', output_path],
capture_output=True, timeout=15,
)
return os.path.exists(output_path) and os.path.getsize(output_path) > 0
except Exception as e:
logger.warning('ffmpeg frame extraction failed: %s', e)
return False
def extract_video_info_from_file(video_path: str) -> tuple:
"""Extract first frame thumbnail + duration from a local video file.
Returns (thumbnail_file: SimpleUploadedFile | None, duration: float).
Does NOT delete the input file caller is responsible for cleanup.
"""
tmp_thumb = None
try:
duration = _get_duration_ffprobe(video_path)
tmp_thumb = video_path + '_thumb.jpg'
if _extract_first_frame(video_path, tmp_thumb):
with open(tmp_thumb, 'rb') as f:
thumb_file = SimpleUploadedFile(
'thumbnail.jpg', f.read(), content_type='image/jpeg'
)
return thumb_file, duration
return None, duration
except Exception as e:
logger.warning('extract_video_info_from_file failed: %s', e)
return None, 0
finally:
if tmp_thumb and os.path.exists(tmp_thumb):
os.unlink(tmp_thumb)
def extract_video_info(video_url: str) -> tuple:
"""Extract first frame thumbnail + duration from a video URL.
Returns (thumbnail_file: SimpleUploadedFile | None, duration: float).
NOTE: This function downloads the full video. For large files, call from
Celery tasks only never from HTTP request handlers.
"""
tmp_video = None
try:
suffix = '.mp4'
if '.mov' in video_url.lower():
suffix = '.mov'
tmp_video = download_to_temp(video_url, suffix)
return extract_video_info_from_file(tmp_video)
except Exception as e:
logger.warning('extract_video_info failed for %s: %s', video_url, e)
return None, 0
finally:
if tmp_video and os.path.exists(tmp_video):
os.unlink(tmp_video)
def get_audio_duration(audio_url: str) -> float:
"""Get audio duration in seconds from a URL."""
tmp_audio = None
try:
suffix = '.wav' if '.wav' in audio_url.lower() else '.mp3'
tmp_audio = download_to_temp(audio_url, suffix)
return _get_duration_ffprobe(tmp_audio)
except Exception as e:
logger.warning('get_audio_duration failed for %s: %s', audio_url, e)
return 0
finally:
if tmp_audio and os.path.exists(tmp_audio):
os.unlink(tmp_audio)

View File

@ -56,8 +56,10 @@ def upload_file(file_obj, folder='uploads'):
client.head_object(bucket=settings.TOS_BUCKET, key=key)
logger.info('TOS dedup hit: %s', key)
return url
except Exception:
pass # Object doesn't exist, proceed with upload
except Exception as e:
err_str = str(e).lower()
if '404' not in err_str and 'not found' not in err_str and 'nosuchkey' not in err_str:
logger.warning('TOS head_object unexpected error (proceeding with upload): %s', e)
client.put_object(
bucket=settings.TOS_BUCKET,
@ -69,6 +71,44 @@ def upload_file(file_obj, folder='uploads'):
return url
def upload_from_file_path(file_path, folder='uploads', content_type=None):
"""Upload a local file to TOS by path (streaming, no full memory load).
Returns the permanent CDN URL.
"""
ext = file_path.rsplit('.', 1)[-1].lower() if '.' in file_path else 'bin'
if not content_type:
content_type = CONTENT_TYPE_MAP.get(ext, 'application/octet-stream')
# Use content hash for dedup
h = hashlib.sha256()
with open(file_path, 'rb') as f:
for chunk in iter(lambda: f.read(8192), b''):
h.update(chunk)
content_hash = h.hexdigest()
key = f'{folder}/{content_hash}.{ext}'
url = f'{settings.TOS_CDN_DOMAIN}/{key}'
client = get_tos_client()
try:
client.head_object(bucket=settings.TOS_BUCKET, key=key)
logger.info('TOS dedup hit: %s', key)
return url
except Exception as e:
# Only proceed if object not found (404). Re-raise on auth/config errors.
err_str = str(e).lower()
if '404' not in err_str and 'not found' not in err_str and 'nosuchkey' not in err_str:
logger.warning('TOS head_object unexpected error (proceeding with upload): %s', e)
with open(file_path, 'rb') as f:
client.put_object(
bucket=settings.TOS_BUCKET,
key=key,
content=f,
content_type=content_type,
)
return url
def upload_from_url(source_url, folder='results'):
"""Download a file from a URL and upload to TOS, return permanent CDN URL."""
import requests as req

118
docs/deployment-guide.md Normal file
View File

@ -0,0 +1,118 @@
# 部署操作手册
> 本文档说明如何将代码推送到测试环境和生产环境。
> 日常开发在 `dev` 分支,生产发布通过合并到 `master` 分支触发。
---
## 环境说明
| 环境 | 触发分支 | 镜像仓库 | K3s 集群 | 域名 |
|------|---------|---------|---------|------|
| 测试development | `dev` | `cr.volces.com/zyc/...` | `192.168.0.129:6443` | `airflow-studio.test.airlabs.art` |
| 生产production | `master` | `gitea-prod-cn-shanghai.cr.volces.com/prod/...` | `192.168.0.130:6443` | `airflow-studio.airlabs.art` |
---
## 推送到测试环境
只需要把代码推到 `dev` 分支CI/CD 自动触发。
```bash
# 确认当前在 dev 分支
git checkout dev
# 提交代码
git add .
git commit -m "feat: 你的改动描述"
# 推送触发构建
git push origin dev
```
构建完成后在 Gitea Actions 查看进度:
- Build and Push Backend ✅
- Build and Push Web ✅
- Setup Kubectl ✅
- Deploy to K3s ✅
---
## 推送到生产环境
> ⚠️ **注意**:操作完成后必须切回 `dev` 分支,不要在 `master` 上继续开发。
### 完整流程
```bash
# 1. 确保 dev 分支代码是最新的
git checkout dev
git pull origin dev
# 2. 切换到 master 分支
git checkout master
# 3. 合并 dev 的代码
git merge dev
# 4. 推送到远程,触发生产构建
git push origin master
# 5. ⚠️ 立刻切回 dev不要停留在 master
git checkout dev
```
### 如果有合并冲突
```bash
# 解决冲突后
git add .
git commit -m "merge: dev into master"
git push origin master
git checkout dev
```
---
## 构建失败排查
### Build and Push 失败docker pull 超时)
Docker 镜像拉取超时CI 会自动重试 3 次。如仍失败,检查构建机网络。
### Setup Kubectl 失败command not found
kubectl 未安装或下载失败CI 会自动从 daocloud 镜像安装。
### Deploy to K3s 失败i/o timeout
K3s API Server 连接超时CI 会自动重试 3 次(每次间隔 10 秒)。
- 若持续失败,检查 K3s 节点状态:`kubectl get nodes`
- 确认 kubeconfig secret`VOLCANO_TEST_KUBE_CONFIG` / `VOLCANO_PROD_KUBE_CONFIG`)有值
---
## 快速检查部署状态
```bash
# 测试环境
ssh root@14.103.63.199
kubectl get pods -n default
# 生产环境
ssh root@118.196.0.100
kubectl get pods -n default
```
---
## Celery Worker 监控
Celery worker 负责轮询火山 API 的视频生成状态。
```bash
# 查看 worker 日志(测试环境)
kubectl logs -f deployment/celery-worker -n default
# 查看队列积压(测试环境 Redis
redis-cli -h redis-shzlsczo52dft8mia.redis.ivolces.com -p 6379 -a Zyc188208 llen celery
```
`recover_stuck_tasks` 定时任务每 3 分钟自动扫描卡住的任务并重新入队,无需手动干预。

View File

@ -24,8 +24,9 @@ server {
client_max_body_size 50m;
}
# Cache static assets (must be before SPA fallback)
location /assets/ {
# Cache static assets (JS/CSS/images built by Vite into dist/assets/)
# Use regex to only match actual files with extensions, not bare /assets path
location ~* ^/assets/.+\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|mp4|webm)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}

View File

@ -0,0 +1,12 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './test/e2e',
timeout: 30000,
retries: 0,
use: {
baseURL: 'https://airflow-studio.test.airlabs.art',
headless: true,
screenshot: 'only-on-failure',
},
});

View File

@ -50,7 +50,7 @@ export default function App() {
}
/>
<Route
path="/assets"
path="/user-assets"
element={
<ProtectedRoute requireTeamMember>
<AssetsPage />

View File

@ -233,6 +233,29 @@
opacity: 1;
}
.addAssetCard {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
border: 1.5px dashed #3a3a48;
border-radius: 12px;
cursor: pointer;
color: var(--color-text-disabled);
font-size: 12px;
transition: all 0.2s;
background: transparent;
/* match assetThumb height + assetInfo height */
min-height: 180px;
}
.addAssetCard:hover {
border-color: var(--color-primary);
color: var(--color-primary);
background: rgba(108, 99, 255, 0.04);
}
.assetThumb {
width: 100%;
height: 140px;

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useAssetLibraryStore } from '../store/assetLibrary';
import { assetsApi, tosThumb } from '../lib/api';
import { showToast } from './Toast';
@ -102,11 +102,7 @@ export function AssetLibraryModal({ open, onClose }: Props) {
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 [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const groups = useAssetLibraryStore((s) => s.groups);
const loading = useAssetLibraryStore((s) => s.loading);
@ -114,7 +110,6 @@ export function AssetLibraryModal({ open, onClose }: Props) {
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);
@ -178,29 +173,22 @@ export function AssetLibraryModal({ open, onClose }: Props) {
const handleUploadSubmit = useCallback(async () => {
const trimmed = newName.trim();
if (!trimmed || !uploadFile) return;
if (!trimmed) return;
if (trimmed.length > 64) { showToast('角色名称不能超过64个字符'); return; }
if (trimmed.includes('&&')) { showToast('角色名称不能包含 &&'); return; }
setUploading(true);
const result = await createGroup(newName.trim(), uploadFile);
const result = await createGroup(trimmed, null);
setUploading(false);
if (result) {
pollAssetStatus(result.id);
setNewName('');
setUploadFile(null);
if (uploadPreview) URL.revokeObjectURL(uploadPreview);
setUploadPreview(null);
handleBackToList();
// 创建成功后直接进入详情页
const group: AssetGroup = { id: result.id, name: trimmed, thumbnail_url: '', asset_count: 0, remote_group_id: result.remote_group_id || '', description: '', created_at: new Date().toISOString() };
setSelectedGroup(group);
setGroupAssets([]);
setView('detail');
loadGroups(page);
}
}, [newName, uploadFile, createGroup, pollAssetStatus, uploadPreview, handleBackToList]);
const handleFileSelect = useCallback(async (file: File) => {
const error = await validateAssetFile(file);
if (error) { showToast(error); return; }
if (uploadPreview) URL.revokeObjectURL(uploadPreview);
setUploadFile(file);
setUploadPreview(file.type.startsWith('image/') ? URL.createObjectURL(file) : null);
}, [uploadPreview]);
}, [newName, createGroup, loadGroups, page]);
const refreshGroupDetail = useCallback(async () => {
if (!selectedGroup) return;
@ -235,21 +223,13 @@ export function AssetLibraryModal({ open, onClose }: Props) {
clearInterval(pollInterval);
}
}, 3000);
showToast('图片已上传,处理中...');
const typeLabel = file.type.startsWith('video/') ? '视频' : file.type.startsWith('audio/') ? '音频' : '图片';
showToast(`${typeLabel}已上传,处理中...`);
} 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/') || file.type.startsWith('video/') || file.type.startsWith('audio/'))) {
handleFileSelect(file);
}
}, [handleFileSelect]);
if (!open) return null;
return (
@ -266,7 +246,7 @@ export function AssetLibraryModal({ open, onClose }: Props) {
</button>
)}
<span className={styles.title}>
{view === 'list' && '素材库'}
{view === 'list' && '人物素材库'}
{view === 'detail' && (selectedGroup?.name || '角色详情')}
{view === 'upload' && '上传新角色'}
</span>
@ -299,7 +279,7 @@ export function AssetLibraryModal({ open, onClose }: Props) {
{groups.map((group) => (
<div key={group.id} className={styles.card} onClick={() => handleGroupClick(group)}>
{group.asset_count === 0 ? (
<div className={styles.cardThumb} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--color-text-disabled)', fontSize: 12 }}></div>
<div className={styles.cardThumb} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--color-text-disabled)', fontSize: 12 }}></div>
) : (
<img src={tosThumb(group.thumbnail_url, 300)} alt={group.name} className={styles.cardThumb} />
)}
@ -383,6 +363,20 @@ export function AssetLibraryModal({ open, onClose }: Props) {
>
&#9998;
</button>
<button
className={styles.actionBtnOutline}
style={{ color: '#ef4444', borderColor: '#ef4444' }}
onClick={() => {
if (confirm('确认删除整个素材组?组内所有素材将被删除,此操作不可撤销。')) {
assetsApi.deleteGroup(selectedGroup.id).then(() => {
showToast('素材组已删除');
handleBackToList();
}).catch(() => showToast('删除失败,请重试'));
}
}}
>
</button>
</div>
{editingName && editingName.id === selectedGroup.id && (
@ -434,8 +428,87 @@ export function AssetLibraryModal({ open, onClose }: Props) {
<div key={assetType} style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>{typeLabel}</span>
<label className={styles.actionBtn} style={{ cursor: 'pointer', fontSize: 12, padding: '3px 10px' }}>
+
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-disabled)', marginBottom: 2 }}>{hintMap[assetType]}</div>
<div style={{ fontSize: 11, color: '#e8952e', marginBottom: 8 }}>{warningMap[assetType]}</div>
<div className={styles.assetGrid}>
{typeAssets.map((asset) => (
<div key={asset.id} className={styles.assetCard}>
{assetType === 'Video' ? (
<img src={tosThumb(asset.thumbnail_url || asset.url, 300)} alt={asset.name} className={styles.assetThumb} />
) : assetType === 'Audio' ? (
<div className={styles.assetThumb} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 32, background: '#1a1a2e' }}></div>
) : (
<img
src={tosThumb(asset.url, 300)}
alt={asset.name}
className={styles.assetThumb}
style={{ cursor: 'zoom-in' }}
onClick={() => setLightboxSrc(asset.url)}
/>
)}
<button
className={styles.assetDeleteBtn}
onClick={(e) => {
e.stopPropagation();
if (confirm('确认删除此素材?删除后无法恢复。')) {
assetsApi.deleteAsset(asset.id).then(() => {
showToast('素材已删除');
if (selectedGroup) {
assetsApi.getGroupDetail(selectedGroup.id).then(({ data }) => {
setGroupAssets(data.assets || []);
});
}
loadGroups(page);
}).catch(() => showToast('删除失败,请重试'));
}
}}
title="删除素材"
>×</button>
<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
}`}
title={asset.status === 'failed' ? (asset.error_message || '素材处理失败,请删除后重新上传') : undefined}
>
{asset.status === 'active' && '可用'}
{asset.status === 'processing' && '处理中'}
{asset.status === 'failed' && '失败'}
</span>
</div>
</div>
))}
{/* 拖拽上传卡片 — 和素材卡片同大小,始终在最后 */}
<label
className={styles.addAssetCard}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (!file) return;
// 检查文件类型是否匹配当前分区
const ft = file.type || '';
const matchesSection =
(assetType === 'Image' && ft.startsWith('image/')) ||
(assetType === 'Video' && ft.startsWith('video/')) ||
(assetType === 'Audio' && ft.startsWith('audio/'));
if (!matchesSection) {
const expected = assetType === 'Image' ? '图片' : assetType === 'Video' ? '视频' : '音频';
showToast(`请将${expected}文件拖到此区域`);
return;
}
handleAddAsset(file);
}}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
<span></span>
<input
type="file"
accept={acceptMap[assetType]}
@ -448,71 +521,13 @@ export function AssetLibraryModal({ open, onClose }: Props) {
/>
</label>
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-disabled)', marginBottom: 2 }}>{hintMap[assetType]}</div>
<div style={{ fontSize: 11, color: '#e8952e', marginBottom: 8 }}>{warningMap[assetType]}</div>
{typeAssets.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-disabled)', padding: '12px 0' }}></div>
) : (
<div className={styles.assetGrid}>
{typeAssets.map((asset) => (
<div key={asset.id} className={styles.assetCard}>
{assetType === 'Video' ? (
<video src={asset.url} className={styles.assetThumb} muted preload="metadata" />
) : assetType === 'Audio' ? (
<div className={styles.assetThumb} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 32, background: '#1a1a2e' }}></div>
) : (
<img
src={tosThumb(asset.url, 300)}
alt={asset.name}
className={styles.assetThumb}
style={{ cursor: 'zoom-in' }}
onClick={() => setLightboxSrc(asset.url)}
/>
)}
<button
className={styles.assetDeleteBtn}
onClick={(e) => {
e.stopPropagation();
if (confirm('确认删除此素材?删除后无法恢复。')) {
assetsApi.deleteAsset(asset.id).then(() => {
showToast('素材已删除');
if (selectedGroup) {
assetsApi.getGroupDetail(selectedGroup.id).then(({ data }) => {
setGroupAssets(data.assets || []);
});
}
loadGroups(page);
}).catch(() => showToast('删除失败,请重试'));
}
}}
title="删除素材"
>×</button>
<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
}`}
title={asset.status === 'failed' ? (asset.error_message || '素材处理失败,请删除后重新上传') : undefined}
>
{asset.status === 'active' && '可用'}
{asset.status === 'processing' && '处理中'}
{asset.status === 'failed' && '失败'}
</span>
</div>
</div>
))}
</div>
)}
</div>
);
})}
</>
)}
{/* Upload View */}
{/* Upload View — only name, no file */}
{view === 'upload' && (
<div className={styles.uploadForm}>
<div>
@ -523,59 +538,19 @@ export function AssetLibraryModal({ open, onClose }: Props) {
maxLength={64}
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleUploadSubmit(); }}
autoFocus
/>
</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}
>
{uploadFile ? (
<>
{uploadPreview ? (
<img src={uploadPreview} alt="预览" className={styles.dropZonePreview} />
) : (
<div style={{ fontSize: 32, padding: '16px 0' }}>
{uploadFile.type.startsWith('video/') ? '🎬' : '♫'}
</div>
)}
<div className={styles.dropZoneHint}>{uploadFile.name}</div>
<div className={styles.dropZoneHint} style={{ color: 'var(--color-text-disabled)' }}></div>
</>
) : (
<>
<div className={styles.dropZoneText}></div>
<div className={styles.dropZoneHint}></div>
<div className={styles.dropZoneHint}>(JPG/PNG/WEBP/HEIC)(MP4/MOV)(MP3/WAV)</div>
</>
)}
<div className={styles.dropZoneWarning}> 300~6000px 0.4~2.5</div>
<div className={styles.dropZoneWarning}> 2~1550MB | 2~1515MB</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*,video/mp4,video/quicktime,audio/mpeg,audio/wav"
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileSelect(file);
e.target.value = '';
}}
/>
<div style={{ fontSize: 12, color: 'var(--color-text-disabled)', marginTop: 4 }}>
</div>
<button
className={styles.submitBtn}
disabled={!newName.trim() || !uploadFile || uploading}
disabled={!newName.trim() || uploading}
onClick={handleUploadSubmit}
>
{uploading ? '上传中...' : '确认上传'}
{uploading ? '创建中...' : '创建角色'}
</button>
</div>
)}

View File

@ -37,10 +37,11 @@ const DownloadIcon = () => (
);
// Mention tag with thumbnail + hover preview
function MentionTag({ label, thumbUrl }: { label: string; thumbUrl?: string }) {
function MentionTag({ label, thumbUrl, assetType }: { label: string; thumbUrl?: string; assetType?: string }) {
const [hover, setHover] = useState(false);
const ref = useRef<HTMLSpanElement>(null);
const [pos, setPos] = useState({ top: 0, left: 0 });
const isAudio = assetType === 'Audio' || assetType === 'audio';
return (
<>
@ -48,7 +49,7 @@ function MentionTag({ label, thumbUrl }: { label: string; thumbUrl?: string }) {
ref={ref}
className={styles.mentionTag}
onMouseEnter={() => {
if (thumbUrl && ref.current) {
if (!isAudio && thumbUrl && ref.current) {
const rect = ref.current.getBoundingClientRect();
setPos({ top: rect.top - 8, left: rect.left + rect.width / 2 });
setHover(true);
@ -56,13 +57,15 @@ function MentionTag({ label, thumbUrl }: { label: string; thumbUrl?: string }) {
}}
onMouseLeave={() => setHover(false)}
>
{thumbUrl && (
{isAudio ? (
<span style={{ marginRight: 3, fontSize: 13, verticalAlign: 'middle' }}></span>
) : thumbUrl ? (
<img
src={tosThumb(thumbUrl, 28)}
alt=""
style={{ width: 14, height: 14, borderRadius: 3, objectFit: 'cover', verticalAlign: 'middle', marginRight: 3 }}
/>
)}
) : null}
{label}
</span>
{hover && thumbUrl && createPortal(
@ -79,16 +82,22 @@ function MentionTag({ label, thumbUrl }: { label: string; thumbUrl?: string }) {
// Render prompt text with @mentions as styled tags (thumbnail + hover preview)
export function renderPromptWithMentions(
text: string,
assetMentions: { label: string; thumbUrl?: string }[],
assetMentions: Record<string, unknown>[],
references: { label: string; previewUrl?: string }[]
) {
// Build lookup: label → thumbUrl
const thumbMap = new Map<string, string>();
// Build lookup: label → { thumbUrl, assetType }
const thumbMap = new Map<string, { thumbUrl: string; assetType: string }>();
for (const am of assetMentions) {
if (am.label) thumbMap.set(am.label, am.thumbUrl || '');
if (am.label) thumbMap.set(am.label as string, {
thumbUrl: (am.thumbUrl as string) || '',
assetType: (am.assetType as string) || 'image',
});
}
for (const r of references) {
if (r.label && !thumbMap.has(r.label)) thumbMap.set(r.label, r.previewUrl || '');
if (r.label && !thumbMap.has(r.label)) thumbMap.set(r.label, {
thumbUrl: r.previewUrl || '',
assetType: (r as Record<string, unknown>).type as string || 'image',
});
}
const labels = [...thumbMap.keys()];
@ -106,7 +115,8 @@ export function renderPromptWithMentions(
if (regex.test(part)) {
regex.lastIndex = 0;
const label = part.slice(1); // remove @
return <MentionTag key={i} label={label} thumbUrl={thumbMap.get(label)} />;
const info = thumbMap.get(label);
return <MentionTag key={i} label={label} thumbUrl={info?.thumbUrl} assetType={info?.assetType} />;
}
regex.lastIndex = 0;
return part;

View File

@ -114,7 +114,7 @@ export function InputBar({ scrollBottomBtn }: { scrollBottomBtn?: React.ReactNod
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'); }}

View File

@ -2,7 +2,9 @@ import { useRef, useEffect, useCallback, useState } from 'react';
import DOMPurify from 'dompurify';
import { useInputBarStore } from '../store/inputBar';
import { assetsApi, tosThumb } from '../lib/api';
import type { UploadedFile, AssetGroup } from '../types';
import type { UploadedFile, AssetSearchResult } from '../types';
import { parseAssetMentionsFromDOM } from '../lib/assetMentions';
import { showToast } from './Toast';
import styles from './PromptInput.module.css';
const placeholders: Record<string, string> = {
@ -27,7 +29,7 @@ export function PromptInput() {
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 [assetSearchResults, setAssetSearchResults] = useState<AssetSearchResult[]>([]);
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Auto-focus
@ -40,7 +42,7 @@ export function PromptInput() {
const el = editorRef.current;
if (!el) return;
if (el.innerHTML !== editorHtml) {
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'] });
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-asset-id', 'data-asset-type', 'data-asset-name', 'data-duration', '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)
const currentAssetMentions = useInputBarStore.getState().assetMentions || [];
@ -64,6 +66,7 @@ export function PromptInput() {
const createMentionSpan = useCallback((opts: {
refId: string; refType: string; label: string; thumbUrl?: string;
assetGroupId?: string; groupName?: string;
assetId?: string; assetType?: string; assetName?: string; duration?: string;
}) => {
const span = document.createElement('span');
span.className = styles.mention;
@ -72,10 +75,18 @@ export function PromptInput() {
span.dataset.refType = opts.refType;
span.draggable = true;
if (opts.thumbUrl) span.dataset.thumbUrl = opts.thumbUrl;
// New asset attributes (individual asset reference)
if (opts.assetId) span.dataset.assetId = opts.assetId;
if (opts.assetType) span.dataset.assetType = opts.assetType;
if (opts.assetName) span.dataset.assetName = opts.assetName;
if (opts.duration) span.dataset.duration = opts.duration;
// Legacy group attributes (backward compat for old records)
if (opts.assetGroupId) span.dataset.assetGroupId = opts.assetGroupId;
if (opts.groupName) span.dataset.groupName = opts.groupName;
if (opts.refType === 'audio') {
// Render icon/thumbnail based on type
const isAudio = opts.refType === 'audio' || opts.assetType === 'Audio';
if (isAudio) {
const icon = document.createElement('span');
icon.textContent = '\u266B';
icon.style.cssText = 'margin-right:3px;font-size:13px;vertical-align:middle;pointer-events:none';
@ -102,19 +113,40 @@ export function PromptInput() {
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 };
type MatchTarget = {
label: string; refId: string; refType: string; thumbUrl: string;
assetGroupId?: string; groupName?: string;
assetId?: string; assetType?: string; assetName?: string; duration?: 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,
})),
...currentAssetMentions.map((am: Record<string, unknown>) => {
// New format (individual asset)
if (am.assetId) {
return {
label: am.label as string, refId: am.assetId as string, refType: 'asset',
thumbUrl: (am.thumbUrl as string) || '',
assetId: am.assetId as string, assetType: am.assetType as string,
assetName: am.label as string, duration: String(am.duration || 0),
};
}
// Legacy format (group reference)
return {
label: am.label as string, refId: (am.groupId as string) || '', refType: 'asset',
thumbUrl: (am.thumbUrl as string) || '',
assetGroupId: am.groupId as string, groupName: am.label as string,
};
}),
];
if (targets.length === 0) return;
// Sort targets by label length descending — longer labels match first
// Prevents "苏晓雨" from stealing the match before "苏晓雨音频"
targets.sort((a, b) => b.label.length - a.label.length);
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
const replacements: { node: Text; matches: { start: number; end: number; target: MatchTarget }[] }[] = [];
@ -160,6 +192,10 @@ export function PromptInput() {
thumbUrl: m.target.thumbUrl,
assetGroupId: m.target.assetGroupId,
groupName: m.target.groupName,
assetId: m.target.assetId,
assetType: m.target.assetType,
assetName: m.target.assetName,
duration: m.target.duration,
});
frag.appendChild(span);
lastIdx = m.end;
@ -240,6 +276,16 @@ export function PromptInput() {
}
}, [references, extractText]);
// Sync editorHtml immediately on ANY DOM change (backspace delete, etc.)
// Without this, deleting a mention span doesn't update editorHtml until next input event
useEffect(() => {
const el = editorRef.current;
if (!el) return;
const observer = new MutationObserver(() => extractText());
observer.observe(el, { childList: true, subtree: true, characterData: true });
return () => observer.disconnect();
}, [extractText]);
const handleInput = useCallback(() => {
extractText();
@ -285,7 +331,7 @@ export function PromptInput() {
} else {
setShowMentionPopup(false);
}
}).catch(() => {});
}).catch(() => { showToast('素材搜索失败,请重试'); });
}, 300);
} else if (textAfterAt.includes(' ')) {
// Space after @ text, close popup
@ -347,7 +393,35 @@ export function PromptInput() {
extractText();
}, [extractText]);
const insertAssetMention = useCallback((group: AssetGroup) => {
const insertAssetMention = useCallback((asset: AssetSearchResult) => {
// Instant check: count limit
const stats = editorRef.current ? parseAssetMentionsFromDOM(editorRef.current) : { counts: { image: 0, video: 0, audio: 0 }, durations: { video: 0, audio: 0 } };
const refs = useInputBarStore.getState().references;
const refCounts = { image: 0, video: 0, audio: 0 };
refs.forEach((r) => refCounts[r.type]++);
const typeKey = asset.asset_type === 'Video' ? 'video' : asset.asset_type === 'Audio' ? 'audio' : 'image';
const maxMap = { image: 9, video: 3, audio: 3 };
if (refCounts[typeKey] + stats.counts[typeKey] >= maxMap[typeKey]) {
const typeLabel = asset.asset_type === 'Video' ? '视频' : asset.asset_type === 'Audio' ? '音频' : '图片';
showToast(`${typeLabel}已达上限`);
return;
}
// Instant check: duration limit (video/audio)
if (asset.asset_type === 'Video' || asset.asset_type === 'Audio') {
if (!asset.duration) {
// Duration unknown (still processing or ffprobe failed) — warn but allow
showToast('该素材时长未确定,提交时将由服务端校验');
} else {
const existingDur = refs.filter((r) => r.type === typeKey && r.duration).reduce((s, r) => s + (r.duration || 0), 0);
const assetDur = typeKey === 'video' ? stats.durations.video : stats.durations.audio;
if (existingDur + assetDur + asset.duration > 15.4) {
const typeLabel = asset.asset_type === 'Video' ? '视频' : '音频';
showToast(`${typeLabel}总时长超过15秒限制`);
return;
}
}
}
setShowMentionPopup(false);
setMentionMode('references');
setAssetSearchResults([]);
@ -378,14 +452,16 @@ export function PromptInput() {
range.deleteContents();
// Create mention span for asset with thumbnail
// Create mention span for individual asset
const mention = createMentionSpan({
refId: String(group.id),
refId: String(asset.id),
refType: 'asset',
label: group.name,
thumbUrl: group.thumbnail_url,
assetGroupId: String(group.id),
groupName: group.name,
label: asset.name,
thumbUrl: asset.thumbnail_url || asset.url,
assetId: String(asset.id),
assetType: asset.asset_type,
assetName: asset.name,
duration: asset.duration != null ? String(asset.duration) : '',
});
range.insertNode(mention);
@ -400,7 +476,7 @@ export function PromptInput() {
sel.addRange(newRange);
extractText();
}, [extractText]);
}, [extractText, editorHtml, references]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (showMentionPopup) {
@ -451,8 +527,9 @@ export function PromptInput() {
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',
'data-asset-group-id', 'data-group-name',
'data-asset-id', 'data-asset-type', 'data-asset-name', 'data-duration',
'data-thumb-url', 'draggable', 'src', 'alt', 'width', 'height', 'style',
],
});
document.execCommand('insertHTML', false, sanitized);
@ -488,13 +565,15 @@ export function PromptInput() {
// 素材库标签:用 data-thumb-url 构造预览数据
if (!found && refType === 'asset') {
const assetType = target.dataset.assetType || 'Image';
if (assetType === 'Audio') return; // 音频素材不弹预览
const thumbUrl = target.dataset.thumbUrl;
if (thumbUrl) {
found = {
id: refId || '',
type: 'image',
type: assetType === 'Video' ? 'video' : 'image',
previewUrl: thumbUrl,
label: target.dataset.groupName || target.textContent || '',
label: target.dataset.assetName || target.textContent || '',
};
}
}
@ -632,25 +711,32 @@ export function PromptInput() {
)}
{mentionMode === 'assets' && assetSearchResults.length > 0 && (
<>
<div className={styles.mentionHeader}></div>
{assetSearchResults.map((group, idx) => (
<div className={styles.mentionHeader}></div>
{assetSearchResults.map((asset, idx) => (
<button
key={group.id}
key={asset.id}
className={`${styles.mentionItem} ${idx === highlightedIdx ? styles.mentionItemActive : ''}`}
onMouseDown={(e) => {
e.preventDefault();
insertAssetMention(group);
insertAssetMention(asset);
}}
>
<div className={styles.mentionThumb}>
{group.thumbnail_url ? (
<img src={tosThumb(group.thumbnail_url, 72)} alt="" className={styles.thumbMedia} />
{asset.asset_type === 'Audio' ? (
<span style={{ fontSize: 16 }}></span>
) : (asset.thumbnail_url || asset.url) ? (
<img src={tosThumb(asset.thumbnail_url || asset.url, 72)} alt="" className={styles.thumbMedia} />
) : (
<span style={{ fontSize: 9, color: 'var(--color-text-disabled)' }}></span>
)}
</div>
<span className={styles.mentionLabel}>{group.name}</span>
<span className={styles.mentionType}></span>
<div style={{ flex: 1, minWidth: 0 }}>
<span className={styles.mentionLabel}>{asset.name}</span>
<span style={{ fontSize: 10, color: '#5a5a6a', marginLeft: 4 }}>{asset.group_name}</span>
</div>
<span className={styles.mentionType}>
{asset.asset_type === 'Video' ? '视频' : asset.asset_type === 'Audio' ? '音频' : '图片'}
</span>
</button>
))}
</>

View File

@ -38,8 +38,8 @@ export function Sidebar() {
<span></span>
</div>
<div
className={`${styles.navItem} ${isActive('/assets') ? styles.active : ''}`}
onClick={() => navigate('/assets')}
className={`${styles.navItem} ${isActive('/user-assets') ? styles.active : ''}`}
onClick={() => navigate('/user-assets')}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" />

View File

@ -4,7 +4,7 @@ import type {
AdminRecord, SystemSettings, ProfileOverview, PaginatedResponse,
BackendTask, TeamInfo, Team, TeamDetail, TeamMember, TeamStats,
AuditLog, AssetTeamSummary, AssetMemberSummary, AssetVideo,
LoginAnomaly, TeamAnomalyConfig, AssetGroup, AssetItem,
LoginAnomaly, TeamAnomalyConfig, AssetGroup, AssetItem, AssetSearchResult,
} from '../types';
import { reportError } from './logCenter';
@ -146,7 +146,7 @@ export const videoApi = {
model: string;
aspect_ratio: string;
duration: number;
references: { url: string; type: string; role: string; label: string; thumb_url?: string }[];
references: { url: string; type: string; role: string; label: string; thumb_url?: string; duration?: string }[];
search_mode?: string;
seed?: number;
}) =>
@ -420,6 +420,8 @@ export const assetsApi = {
api.get<AssetGroup & { assets: AssetItem[] }>(`/assets/groups/${id}`),
updateGroup: (id: number, data: { name?: string; description?: string }) =>
api.put(`/assets/groups/${id}`, data),
deleteGroup: (id: number) =>
api.delete(`/assets/groups/${id}`),
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 }) =>
@ -427,7 +429,7 @@ export const assetsApi = {
deleteAsset: (id: number) =>
api.delete(`/assets/${id}`),
search: (q: string) =>
api.get<{ results: AssetGroup[] }>('/assets/search', { params: { q } }),
api.get<{ results: AssetSearchResult[] }>('/assets/search', { params: { q } }),
pollStatus: (id: number) =>
api.get<{ id: number; status: string; url: string; error_message: string }>(`/assets/${id}/status`),
};

View File

@ -0,0 +1,44 @@
/**
* Parse asset mention spans directly from a DOM element (real-time, no stale state).
* Use this when you have access to the editor DOM element.
*/
export function parseAssetMentionsFromDOM(el: HTMLElement): {
counts: { image: number; video: number; audio: number };
durations: { video: number; audio: number };
} {
const counts = { image: 0, video: 0, audio: 0 };
const durations = { video: 0, audio: 0 };
el.querySelectorAll('[data-ref-type="asset"]').forEach((span) => {
const t = (span as HTMLElement).dataset.assetType || 'Image';
const rawDur = parseFloat((span as HTMLElement).dataset.duration || '0');
const dur = isNaN(rawDur) ? 0 : rawDur;
if (t === 'Video') { counts.video++; durations.video += dur; }
else if (t === 'Audio') { counts.audio++; durations.audio += dur; }
else { counts.image++; }
});
return { counts, durations };
}
/**
* Parse asset mention spans from editor HTML string.
* Use this when you only have the HTML string (e.g., from store state).
*/
export function parseAssetMentions(html: string): {
counts: { image: number; video: number; audio: number };
durations: { video: number; audio: number };
} {
const counts = { image: 0, video: 0, audio: 0 };
const durations = { video: 0, audio: 0 };
if (!html) return { counts, durations };
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
doc.querySelectorAll('[data-ref-type="asset"]').forEach((el) => {
const t = (el as HTMLElement).dataset.assetType || 'Image';
const rawDur = parseFloat((el as HTMLElement).dataset.duration || '0');
const dur = isNaN(rawDur) ? 0 : rawDur; // null/undefined → NaN → 0, ffprobe 失败不计入时长
if (t === 'Video') { counts.video++; durations.video += dur; }
else if (t === 'Audio') { counts.audio++; durations.audio += dur; }
else { counts.image++; }
});
return { counts, durations };
}

View File

@ -1,6 +1,6 @@
import { create } from 'zustand';
import { assetsApi } from '../lib/api';
import type { AssetGroup } from '../types';
import type { AssetGroup, AssetSearchResult } from '../types';
import { showToast } from '../components/Toast';
interface AssetLibraryState {
@ -8,12 +8,12 @@ interface AssetLibraryState {
loading: boolean;
total: number;
page: number;
searchResults: AssetGroup[];
searchResults: AssetSearchResult[];
searching: boolean;
loadGroups: (page?: number) => Promise<void>;
searchAssets: (query: string) => Promise<void>;
createGroup: (name: string, file: File) => Promise<AssetGroup | null>;
createGroup: (name: string, file: File | null) => Promise<AssetGroup | null>;
pollAssetStatus: (assetId: number) => void;
}
@ -45,10 +45,10 @@ export const useAssetLibraryStore = create<AssetLibraryState>((set) => ({
}
},
createGroup: async (name: string, file: File) => {
createGroup: async (name: string, file: File | null) => {
const formData = new FormData();
formData.append('name', name);
formData.append('file', file);
if (file) formData.append('file', file);
try {
const { data } = await assetsApi.createGroup(formData);
showToast('角色创建成功');

View File

@ -84,8 +84,17 @@ function buildAssetMentions(refs: Array<Record<string, string>>) {
.filter((ref) => isAssetUrl(ref.url || ''))
.map((ref) => {
const url = ref.url || '';
// New format: asset://local-{id}
if (url.startsWith('asset://local-')) {
const assetId = url.replace('asset://local-', '');
return {
assetId, label: ref.label || '', thumbUrl: ref.thumb_url || '',
assetType: ref.type || 'image', duration: parseFloat(ref.duration || '0'),
};
}
// Legacy format: asset://group-{id}
const groupId = url.startsWith('asset://group-') ? url.replace('asset://group-', '') : '';
return { groupId, label: ref.label || '', thumbUrl: ref.thumb_url || '' };
return { groupId, label: ref.label || '', thumbUrl: ref.thumb_url || '', assetType: 'image', duration: 0 };
});
}
@ -109,6 +118,7 @@ function backendToFrontend(bt: BackendTask): GenerationTask {
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,
thumbnailUrl: bt.thumbnail_url || undefined,
errorMessage: mapErrorMessage(bt.error_message),
createdAt: new Date(bt.created_at).getTime(),
tokensConsumed: bt.tokens_consumed || 0,
@ -349,7 +359,7 @@ 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 }[] = [];
const placeholderAssetMentions: Record<string, unknown>[] = [];
if (input.editorHtml) {
const parser = new DOMParser();
const doc = parser.parseFromString(input.editorHtml, 'text/html');
@ -410,7 +420,7 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
try {
// Use pre-uploaded TOS URLs (immediate upload), fallback to upload here if needed
const uploadedRefs: { url: string; type: string; role: string; label: string; thumb_url?: string }[] = [];
const uploadedRefs: { url: string; type: string; role: string; label: string; thumb_url?: string; duration?: string }[] = [];
for (const item of filesToUpload) {
if (item.tosUrl && !item.tosUrl.startsWith('blob:')) {
@ -422,38 +432,69 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
}
}
// Extract asset mentions from editor HTML — deduplicate by groupId
const seenGroupIds = new Set<string>();
// Extract asset mentions from editor HTML — deduplicate by assetId
const seenAssetIds = 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);
const assetId = el.dataset.assetId;
const assetType = (el.dataset.assetType || 'Image').toLowerCase();
const assetName = el.dataset.assetName || el.textContent?.replace('@', '') || '';
const duration = el.dataset.duration || '0';
if (assetId && !seenAssetIds.has(assetId)) {
seenAssetIds.add(assetId);
uploadedRefs.push({
url: `asset://group-${groupId}`,
type: 'image',
role: 'reference_image',
label: groupName,
url: `asset://local-${assetId}`,
type: assetType,
role: `reference_${assetType}`,
label: assetName,
thumb_url: el.dataset.thumbUrl || '',
duration,
});
}
// Legacy: data-asset-group-id (old format)
if (!assetId && el.dataset.assetGroupId) {
const groupId = el.dataset.assetGroupId;
const groupName = el.dataset.groupName || el.textContent?.replace('@', '') || '';
if (!seenAssetIds.has(`group-${groupId}`)) {
seenAssetIds.add(`group-${groupId}`);
uploadedRefs.push({
url: `asset://group-${groupId}`,
type: 'image',
role: 'reference_image',
label: groupName,
thumb_url: el.dataset.thumbUrl || '',
});
}
}
});
}
// Fallback: only use inputBar assetMentions when editorHtml has NO asset spans
// (regenerate scenario where editorHtml is plain text)
// If user edited the HTML and removed some asset tags, respect that — don't re-add from store
const htmlHadAssetSpans = input.editorHtml?.includes('data-ref-type="asset"');
if (!htmlHadAssetSpans) {
const inputAssetMentions = input.assetMentions || [];
for (const am of inputAssetMentions) {
if (am.groupId && !seenGroupIds.has(am.groupId)) {
seenGroupIds.add(am.groupId);
// New format
if (am.assetId && !seenAssetIds.has(am.assetId)) {
seenAssetIds.add(am.assetId);
const t = (am.assetType || 'Image').toLowerCase();
uploadedRefs.push({
url: `asset://local-${am.assetId}`,
type: t,
role: `reference_${t}`,
label: am.label,
thumb_url: am.thumbUrl || '',
duration: String(am.duration || 0),
});
}
// Legacy format
if (!am.assetId && am.groupId && !seenAssetIds.has(`group-${am.groupId}`)) {
seenAssetIds.add(`group-${am.groupId}`);
uploadedRefs.push({
url: `asset://group-${am.groupId}`,
type: 'image',

View File

@ -2,6 +2,7 @@ import { create } from 'zustand';
import type { CreationMode, ModelOption, AspectRatio, Duration, GenerationType, UploadedFile } from '../types';
import { showToast } from '../components/Toast';
import { mediaApi } from '../lib/api';
import { parseAssetMentions } from '../lib/assetMentions';
let fileCounter = 0;
@ -123,7 +124,8 @@ interface InputBarState {
setSeedEnabled: (enabled: boolean) => void;
// Asset mentions (for reEdit/regenerate to pass asset data to PromptInput rebuild)
assetMentions: { groupId: string; label: string; thumbUrl: string }[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assetMentions: Record<string, any>[];
// @ trigger (for toolbar button to insert @ in contentEditable)
insertAtTrigger: number;
@ -170,9 +172,13 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
prevReferences: [],
addReferences: (files) => {
const state = get();
// Count existing references by type
// Count existing references by type + merge @ asset mentions
const counts = { image: 0, video: 0, audio: 0 };
for (const ref of state.references) counts[ref.type]++;
const { counts: assetCounts } = parseAssetMentions(state.editorHtml);
counts.image += assetCounts.image;
counts.video += assetCounts.video;
counts.audio += assetCounts.audio;
// Separate images (sync) from audio/video (need async duration check)
const imageFiles: File[] = [];
@ -496,11 +502,13 @@ async function _validateAndAddMedia(files: File[]) {
}
}
// Total duration check (same type)
// Total duration check (same type) — merge @ asset mention durations
const state = useInputBarStore.getState();
const existingDuration = state.references
const { durations: assetDurations } = parseAssetMentions(state.editorHtml);
const refDuration = state.references
.filter((r) => r.type === type && r.duration)
.reduce((sum, r) => sum + (r.duration || 0), 0);
const existingDuration = refDuration + (type === 'video' ? assetDurations.video : assetDurations.audio);
if (existingDuration + dur > MAX_MEDIA_DURATION + 0.4) {
showToast(`${typeLabel}总时长不能超过${MAX_MEDIA_DURATION}`);
continue;

View File

@ -44,10 +44,12 @@ export interface GenerationTask {
aspectRatio: AspectRatio;
duration: Duration;
references: ReferenceSnapshot[];
assetMentions: { groupId: string; label: string; thumbUrl: string }[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assetMentions: Record<string, any>[];
status: TaskStatus;
progress: number;
resultUrl?: string;
thumbnailUrl?: string;
errorMessage?: string;
createdAt: number;
tokensConsumed?: number;
@ -71,6 +73,7 @@ export interface BackendTask {
base_cost_amount: number;
status: 'queued' | 'processing' | 'completed' | 'failed';
result_url: string;
thumbnail_url: string;
error_message: string;
reference_urls: { url: string; type: string; role: string; label: string }[];
is_favorited: boolean;
@ -436,8 +439,21 @@ export interface AssetItem {
name: string;
url: string;
asset_type: 'Image' | 'Video' | 'Audio';
thumbnail_url: string;
duration: number | null;
status: 'processing' | 'active' | 'failed';
remote_asset_id: string;
error_message: string;
created_at: string;
}
export interface AssetSearchResult {
id: number;
name: string;
url: string;
asset_type: 'Image' | 'Video' | 'Audio';
group_name: string;
remote_asset_id: string;
thumbnail_url: string;
duration: number | null;
}

View File

@ -0,0 +1,411 @@
/**
* v0.18.0 E2E Tests run against test environment
* Tests: asset search, asset library, generation page interactions
*/
import { test, expect, Page } from '@playwright/test';
const BASE_URL = 'https://airflow-studio.test.airlabs.art';
const API_URL = 'https://airflow-studio-api.test.airlabs.art';
const USERNAME = 'tudou';
const PASSWORD = 'seaislee';
let accessToken = '';
// Login and get token
async function login(page: Page) {
const resp = await page.request.post(`${API_URL}/api/v1/auth/login`, {
data: { username: USERNAME, password: PASSWORD },
});
expect(resp.ok()).toBeTruthy();
const body = await resp.json();
accessToken = body.tokens.access;
// Set tokens in localStorage and navigate
await page.goto(BASE_URL);
await page.evaluate(({ access, refresh }) => {
localStorage.setItem('access_token', access);
localStorage.setItem('refresh_token', refresh);
}, { access: body.tokens.access, refresh: body.tokens.refresh });
await page.goto(`${BASE_URL}/app`);
await page.waitForTimeout(2000);
}
// ─── API Tests ───
test.describe('Backend API Tests', () => {
test('asset search returns individual assets (not groups)', async ({ request }) => {
// Login
const loginResp = await request.post(`${API_URL}/api/v1/auth/login`, {
data: { username: USERNAME, password: PASSWORD },
});
const { tokens } = await loginResp.json();
// Search for assets
const searchResp = await request.get(`${API_URL}/api/v1/assets/search?q=test`, {
headers: { Authorization: `Bearer ${tokens.access}` },
});
expect(searchResp.ok()).toBeTruthy();
const data = await searchResp.json();
// Should return results array
expect(data).toHaveProperty('results');
expect(Array.isArray(data.results)).toBeTruthy();
// Each result should have individual asset fields (not group fields)
if (data.results.length > 0) {
const asset = data.results[0];
expect(asset).toHaveProperty('id');
expect(asset).toHaveProperty('name');
expect(asset).toHaveProperty('url');
expect(asset).toHaveProperty('asset_type');
expect(asset).toHaveProperty('group_name');
expect(asset).toHaveProperty('thumbnail_url');
expect(asset).toHaveProperty('duration');
// Should NOT have group-level fields
expect(asset).not.toHaveProperty('asset_count');
expect(asset).not.toHaveProperty('remote_group_id');
}
});
test('asset search only returns active assets', async ({ request }) => {
const loginResp = await request.post(`${API_URL}/api/v1/auth/login`, {
data: { username: USERNAME, password: PASSWORD },
});
const { tokens } = await loginResp.json();
const searchResp = await request.get(`${API_URL}/api/v1/assets/search?q=a`, {
headers: { Authorization: `Bearer ${tokens.access}` },
});
const data = await searchResp.json();
// All returned assets should be active
for (const asset of data.results) {
// Search API doesn't return status, but only queries active ones
expect(asset).toHaveProperty('id');
}
});
test('search query is truncated at 100 chars', async ({ request }) => {
const loginResp = await request.post(`${API_URL}/api/v1/auth/login`, {
data: { username: USERNAME, password: PASSWORD },
});
const { tokens } = await loginResp.json();
const longQuery = 'a'.repeat(200);
const searchResp = await request.get(`${API_URL}/api/v1/assets/search?q=${longQuery}`, {
headers: { Authorization: `Bearer ${tokens.access}` },
});
// Should not crash
expect(searchResp.ok()).toBeTruthy();
});
test('create asset group without file', async ({ request }) => {
const loginResp = await request.post(`${API_URL}/api/v1/auth/login`, {
data: { username: USERNAME, password: PASSWORD },
});
const { tokens } = await loginResp.json();
const formData = new FormData();
formData.append('name', `test-e2e-${Date.now()}`);
// Note: multipart/form-data without file
const createResp = await request.post(`${API_URL}/api/v1/assets/groups`, {
headers: { Authorization: `Bearer ${tokens.access}` },
multipart: { name: `test-e2e-${Date.now()}` },
});
expect(createResp.ok()).toBeTruthy();
const group = await createResp.json();
expect(group).toHaveProperty('id');
expect(group.asset_count).toBe(0);
expect(group.thumbnail_url).toBe('');
});
test('delete asset endpoint works', async ({ request }) => {
const loginResp = await request.post(`${API_URL}/api/v1/auth/login`, {
data: { username: USERNAME, password: PASSWORD },
});
const { tokens } = await loginResp.json();
// Get groups to find an asset to test with
const groupsResp = await request.get(`${API_URL}/api/v1/assets/groups`, {
headers: { Authorization: `Bearer ${tokens.access}` },
});
const groups = await groupsResp.json();
// Find a group with assets
for (const group of groups.results) {
if (group.asset_count > 0) {
const detailResp = await request.get(`${API_URL}/api/v1/assets/groups/${group.id}`, {
headers: { Authorization: `Bearer ${tokens.access}` },
});
const detail = await detailResp.json();
// Verify assets have asset_type and thumbnail_url fields
if (detail.assets && detail.assets.length > 0) {
const asset = detail.assets[0];
expect(asset).toHaveProperty('asset_type');
expect(asset).toHaveProperty('thumbnail_url');
expect(asset).toHaveProperty('duration');
}
break;
}
}
});
test('HEIC format accepted in upload', async ({ request }) => {
const loginResp = await request.post(`${API_URL}/api/v1/auth/login`, {
data: { username: USERNAME, password: PASSWORD },
});
const { tokens } = await loginResp.json();
// Upload a fake HEIC file (just test the format check, not actual processing)
const uploadResp = await request.post(`${API_URL}/api/v1/media/upload`, {
headers: { Authorization: `Bearer ${tokens.access}` },
multipart: {
file: {
name: 'test.heic',
mimeType: 'image/heic',
buffer: Buffer.from('fake heic content'),
},
},
});
// Should not reject with "不支持的文件格式"
// It may fail for other reasons (invalid image), but format should be accepted
const status = uploadResp.status();
if (status === 400) {
const body = await uploadResp.json();
expect(body.error).not.toContain('不支持的文件格式');
}
});
test('asset://local- format accepted in generation', async ({ request }) => {
const loginResp = await request.post(`${API_URL}/api/v1/auth/login`, {
data: { username: USERNAME, password: PASSWORD },
});
const { tokens } = await loginResp.json();
// Try to generate with a non-existent asset://local- reference
const genResp = await request.post(`${API_URL}/api/v1/video/generate`, {
headers: { Authorization: `Bearer ${tokens.access}` },
data: {
prompt: 'test',
mode: 'universal',
model: 'seedance_2.0',
aspect_ratio: '16:9',
duration: 5,
references: [
{ url: 'asset://local-99999', type: 'image', role: 'reference_image', label: 'test' },
],
},
});
// Should return 400 with friendly error (asset not found), not 500
expect(genResp.status()).toBe(400);
const body = await genResp.json();
expect(body.error).toBe('asset_not_found');
});
test('asset://group- format still works (backward compat)', async ({ request }) => {
const loginResp = await request.post(`${API_URL}/api/v1/auth/login`, {
data: { username: USERNAME, password: PASSWORD },
});
const { tokens } = await loginResp.json();
// Try old format with non-existent group
const genResp = await request.post(`${API_URL}/api/v1/video/generate`, {
headers: { Authorization: `Bearer ${tokens.access}` },
data: {
prompt: 'test',
mode: 'universal',
model: 'seedance_2.0',
aspect_ratio: '16:9',
duration: 5,
references: [
{ url: 'asset://group-99999', type: 'image', role: 'reference_image', label: 'test' },
],
},
});
// Should return 400 (not ready), not 500
expect(genResp.status()).toBe(400);
const body = await genResp.json();
expect(body.error).toBe('asset_not_ready');
});
test('blob: URL rejected by backend', async ({ request }) => {
const loginResp = await request.post(`${API_URL}/api/v1/auth/login`, {
data: { username: USERNAME, password: PASSWORD },
});
const { tokens } = await loginResp.json();
const genResp = await request.post(`${API_URL}/api/v1/video/generate`, {
headers: { Authorization: `Bearer ${tokens.access}` },
data: {
prompt: 'test blob',
mode: 'universal',
model: 'seedance_2.0',
aspect_ratio: '16:9',
duration: 5,
references: [
{ url: 'blob:http://localhost/fake', type: 'image', role: 'reference_image', label: 'test' },
],
},
});
expect(genResp.status()).toBe(400);
const body = await genResp.json();
expect(body.error).toBe('upload_failed');
});
test('task detail returns thumbnail_url field', async ({ request }) => {
const loginResp = await request.post(`${API_URL}/api/v1/auth/login`, {
data: { username: USERNAME, password: PASSWORD },
});
const { tokens } = await loginResp.json();
const tasksResp = await request.get(`${API_URL}/api/v1/video/tasks?page_size=1`, {
headers: { Authorization: `Bearer ${tokens.access}` },
});
expect(tasksResp.ok()).toBeTruthy();
const data = await tasksResp.json();
if (data.results.length > 0) {
const task = data.results[0];
expect(task).toHaveProperty('thumbnail_url');
expect(task).toHaveProperty('result_url');
}
});
});
// ─── Frontend Page Tests ───
test.describe('Frontend Page Tests', () => {
test('login page loads', async ({ page }) => {
await page.goto(`${BASE_URL}/login`);
await expect(page).toHaveTitle(/AirDrama|Airflow/i);
});
test('generation page loads after login', async ({ page }) => {
await login(page);
// Should see the input bar
await expect(page.locator('text=人物素材库')).toBeVisible({ timeout: 10000 });
});
test('asset library modal opens', async ({ page }) => {
await login(page);
// Click the asset library button
await page.click('text=人物素材库');
await page.waitForTimeout(1000);
// Should see the modal
await expect(page.locator('text=上传新角色').first()).toBeVisible({ timeout: 5000 });
});
test('create group flow — name only', async ({ page }) => {
await login(page);
await page.click('text=人物素材库');
await page.waitForTimeout(1000);
// Click create
await page.click('text=上传新角色');
await page.waitForTimeout(500);
// Should see name input and "创建角色" button
await expect(page.locator('text=角色名称')).toBeVisible();
await expect(page.locator('text=创建角色')).toBeVisible();
// Should NOT see file upload area
await expect(page.locator('text=创建后可在详情页上传图片、视频、音频素材')).toBeVisible();
});
test('asset detail page shows three sections', async ({ page }) => {
await login(page);
await page.click('text=人物素材库');
await page.waitForTimeout(2000);
// Click on first group with assets (skip the empty test-e2e group)
const groupNames = page.locator('[class*="cardInfo"]');
if (await groupNames.count() > 1) {
await groupNames.nth(1).click();
await page.waitForTimeout(1000);
// Should see three sections
await expect(page.locator('text=肖像(图片)')).toBeVisible({ timeout: 5000 });
await expect(page.locator('text=视频').first()).toBeVisible();
await expect(page.locator('text=音频').first()).toBeVisible();
// Should see warning text
await expect(page.locator('text=宽高 300~6000 像素').first()).toBeVisible();
await expect(page.locator('text=时长 2~15 秒').first()).toBeVisible();
}
});
test('@ mention popup shows individual assets', async ({ page }) => {
await login(page);
// Type @ in the prompt input
const editor = page.locator('[contenteditable="true"]');
await editor.click();
await editor.type('@');
await page.waitForTimeout(500);
// If there are references, popup may show "可能@的内容"
// Type a search query
await editor.type('苏');
await page.waitForTimeout(1000);
// Check if popup appears with asset results
const popup = page.locator('text=人物素材库匹配');
if (await popup.isVisible()) {
// Should show individual asset names, not group names
// Should show type badges (图片/视频/音频)
const typeBadges = page.locator('text=图片, text=视频, text=音频');
// At least one badge should be visible
}
});
test('toast component has glass-card style', async ({ page }) => {
await login(page);
// Trigger a toast by uploading an invalid file format
// Check toast styling includes backdrop-filter
const toastEl = page.locator('[class*="toast"]');
// Toast may not be visible immediately, this is a structural check
});
test('scroll to bottom button appears', async ({ page }) => {
await login(page);
await page.waitForTimeout(2000);
// If there are enough tasks, scroll up
const contentArea = page.locator('[class*="contentArea"]');
if (await contentArea.isVisible()) {
await contentArea.evaluate((el) => el.scrollTop = 0);
await page.waitForTimeout(500);
// Check if "回到底部" button appears
const scrollBtn = page.locator('text=回到底部');
// May or may not appear depending on content height
}
});
test('assets page shows correct order (newest first)', async ({ page }) => {
await login(page);
await page.goto(`${BASE_URL}/assets`);
await page.waitForTimeout(2000);
// First date group should be "今天" or most recent date
const dateLabels = page.locator('h3');
if (await dateLabels.count() > 0) {
const firstLabel = await dateLabels.first().textContent();
// Should be "今天" or a recent date, not an old date
expect(firstLabel).toBeTruthy();
}
});
test('assets page has load more button', async ({ page }) => {
await login(page);
await page.goto(`${BASE_URL}/assets`);
await page.waitForTimeout(2000);
// If there are more than 20 videos, load more should appear
const loadMore = page.locator('text=加载更多');
// Just check it doesn't crash
});
});