diff --git a/core/backend/apps/ai/free_video.py b/core/backend/apps/ai/free_video.py index fb30957..aeafc33 100644 --- a/core/backend/apps/ai/free_video.py +++ b/core/backend/apps/ai/free_video.py @@ -504,7 +504,9 @@ def _store_free_video_media(*, task: AITask, media: str) -> Asset: id=asset_id, team=task.team, created_by=task.created_by, - name=(prompt[:50] or "自由创作视频"), + # Asset.name 是通用资产字段,受 255 字符上限约束;完整显示标题由资产接口从 + # 关联任务的 request_payload.prompt 派生,避免再出现 50 字符业务截断。 + name=(prompt[:255] or "自由创作视频"), asset_type=Asset.Type.VIDEO, source=Asset.Source.AI_GENERATED, category=Asset.Category.FREE_CREATE, diff --git a/core/backend/apps/ai/tests.py b/core/backend/apps/ai/tests.py index ddf3f4f..1ed3f09 100644 --- a/core/backend/apps/ai/tests.py +++ b/core/backend/apps/ai/tests.py @@ -254,6 +254,24 @@ class FreeVideoAssetDeletionTests(TestCase): self.assertEqual(trash_data["video_url"], "http://tos.example/video.mp4") self.assertEqual(trash_data["thumbnail_url"], "http://tos.example/free-video-poster.jpg") + def test_asset_library_uses_full_free_video_title_and_searches_it(self): + """资产库应从关联任务恢复历史截断标题,并支持搜索完整标题的后半段。""" + full_prompt = "@O1CN01anEq9u245Jn8Ccedn_!!4611686018427385691-0-s-完整后缀" + self.task.request_payload["prompt"] = full_prompt + self.task.save(update_fields=["request_payload"]) + self.asset.name = full_prompt[:50] # 模拟旧版本写库的截断数据 + self.asset.save(update_fields=["name"]) + + client = APIClient() + client.force_authenticate(self.user) + listing = client.get("/api/assets/?tab=video_creations&page_size=10").json() + + self.assertEqual(listing["count"], 1) + self.assertEqual(listing["results"][0]["display_name"], full_prompt) + + search = client.get(f"/api/assets/?tab=video_creations&q={full_prompt[-4:]}&page_size=10").json() + self.assertEqual([row["id"] for row in search["results"]], [str(self.asset.id)]) + def test_direct_asset_delete_stays_in_asset_trash(self): client = APIClient() client.force_authenticate(self.user) diff --git a/core/backend/apps/assets/serializers.py b/core/backend/apps/assets/serializers.py index 9596e53..01ca5ab 100644 --- a/core/backend/apps/assets/serializers.py +++ b/core/backend/apps/assets/serializers.py @@ -60,9 +60,25 @@ class AssetFileSerializer(serializers.ModelSerializer): class AssetSerializer(serializers.ModelSerializer): files = AssetFileSerializer(many=True, read_only=True) + # 自由创作视频的可见标题以任务原始提示词为准,Asset.name 仅保留通用资产名称/兼容搜索。 + # 这样历史的 50 字符截断资产也能直接恢复完整标题,无需数据迁移。 + display_name = serializers.SerializerMethodField() # 资产归属商品(只读):商品详情页据此只展示「该商品」的 AI 素材,而非全团队 product = serializers.SerializerMethodField() + def get_display_name(self, obj): + task = obj.origin_task + if ( + obj.category == Asset.Category.FREE_CREATE + and obj.asset_type == Asset.Type.VIDEO + and task is not None + and task.task_type == "free_video" + ): + prompt = (task.request_payload or {}).get("prompt") + if isinstance(prompt, str) and prompt.strip(): + return prompt.strip() + return obj.name + def get_product(self, obj): """解析资产所属商品: 1) 独立生图(图片创作/模特图/平台套图):生图时已写入 metadata.product_id; @@ -81,6 +97,7 @@ class AssetSerializer(serializers.ModelSerializer): fields = [ "id", "name", + "display_name", "asset_type", "source", "category", diff --git a/core/backend/apps/assets/views.py b/core/backend/apps/assets/views.py index 584feb2..b7e9697 100644 --- a/core/backend/apps/assets/views.py +++ b/core/backend/apps/assets/views.py @@ -94,7 +94,7 @@ def _batch_name(name: str) -> str: class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet): # select_related 回溯链:asset → origin_task → project,供序列化器解析资产归属商品(避免 N+1)。 - # ★ defer 掉 AITask 的两个巨型 JSON 列(request/response payload):列表序列化只需 project.product_id, + # ★ defer 掉 AITask 的两个巨型 JSON 列(request/response payload):绝大多数列表序列化只需 project.product_id, # 不 defer 的话 select_related 会把每个资产关联 AITask 的完整 AI 请求/响应 payload 全拖出来, # 200 个资产实测 100s+(数据越多越慢);defer 后 <1s、product 仍正常解析、无额外查询。 queryset = ( @@ -134,6 +134,12 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet): return trash_qs.exclude(owned_by_deleted_free_creation).order_by("-updated_at") qs = qs.filter(is_deleted=False, purged_at__isnull=True) # 软删资产不出现在资产库 p = self.request.query_params + tab = p.get("tab") + # 视频自由创作的显示标题来自关联任务的原始 prompt;只在该专用 tab 取回 JSON,避免普通资产列表 + # 重新加载巨型请求/响应 payload。 + if tab == "video_creations": + # Django 没有 QuerySet.undefer();先清除默认延迟字段,再仅保留不需要的 response_payload 延迟加载。 + qs = qs.defer(None).defer("origin_task__response_payload") # 资产库列表/批次默认只展示「已加入资产库」的资产(in_library=True);未加入的工作台生成图不出现在这里。 # 仅对列表型 action 过滤——retrieve / set-library / submit-review 等仍要能取到未加入的资产。 # 任务中心要看「全部生成图」(含未入库),传 ?in_library=all 旁路;?in_library=false 只看未入库。 @@ -145,8 +151,8 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet): qs = qs.filter(in_library=False) else: qs = qs.filter(in_library=True) - if p.get("tab"): - qs = qs.filter(_tab_q(p["tab"])) + if tab: + qs = qs.filter(_tab_q(tab)) if p.get("category"): qs = qs.filter(category=p["category"]) if p.get("asset_type"): @@ -175,7 +181,13 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet): | Q(product_images__product_id=pid) ).distinct() if p.get("q"): - qs = qs.filter(Q(name__icontains=p["q"]) | Q(description__icontains=p["q"])) + query = p["q"] + title_q = Q(name__icontains=query) | Q(description__icontains=query) + # display_name 不落在 Asset 表;视频自由创作搜索需同时匹配关联任务的完整提示词, + # 才能找到历史被截为前 50 字符的标题后半段。 + if tab == "video_creations": + title_q |= Q(origin_task__task_type="free_video", origin_task__request_payload__prompt__icontains=query) + qs = qs.filter(title_q) for key, val in p.items(): if key.startswith("m_") and val: qs = qs.filter(**{f"metadata__{key[2:]}": val}) diff --git a/core/frontend/src/library-page.css b/core/frontend/src/library-page.css index 81a916f..e4b6c80 100644 --- a/core/frontend/src/library-page.css +++ b/core/frontend/src/library-page.css @@ -14,7 +14,10 @@ /* 有 preview_url 显真图:铺满缩略容器、cover 裁切、继承 8px 圆角(由 .placeholder overflow:hidden 裁切) */ .asset-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; border-radius: inherit; } .asset-body { padding: 12px 14px; } - .asset-name { font-size: 13px; font-weight: 600; color: var(--accent-black); } + .asset-name { + font-size: 14px; font-weight: 500; line-height: 1.4; color: var(--accent-black); + overflow-wrap: anywhere; word-break: break-word; + } .asset-meta { font-size: 12px; color: var(--black-alpha-48); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .02em; } .asset-badge { position: absolute; top: 8px; left: 8px; font-family: var(--font-mono); font-size: 12px; letter-spacing: .04em; padding: 2px 6px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); color: var(--black-alpha-56); } .asset-card { position: relative; } @@ -243,7 +246,8 @@ body.edit-mode .library-page .batch-card.selected { border-color: var(--heat); b .pack-modal-bg.under-confirm { z-index: 990; } .pack-modal { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); width: min(900px, 92vw); max-height: 86vh; display: flex; flex-direction: column; overflow: hidden; } .pack-modal-h { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 20px; border-bottom: 1px solid var(--border-faint); } -.pack-modal-h h2 { font-size: 16px; font-weight: 600; color: var(--accent-black); } +.pack-modal-h > div { min-width: 0; } +.pack-modal-h h2 { font-size: 16px; font-weight: 600; line-height: 1.4; color: var(--accent-black); overflow-wrap: anywhere; word-break: break-word; } .pack-modal-h .x { width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; border: none; background: transparent; color: var(--black-alpha-48); border-radius: var(--r-sm); cursor: pointer; flex-shrink: 0; } .pack-modal-h .x:hover { background: var(--black-alpha-8); color: var(--accent-black); } .pack-clip-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; padding: 18px 20px; overflow-y: auto; } @@ -253,7 +257,7 @@ body.edit-mode .library-page .batch-card.selected { border-color: var(--heat); b .pack-clip img { width: 100%; aspect-ratio: 9 / 16; object-fit: cover; border-radius: var(--r-sm); background: var(--background-lighter); display: block; transition: opacity .15s; } .pack-clip.as-btn:hover img { opacity: .88; } .pack-clip .pack-clip-ph { width: 100%; aspect-ratio: 9 / 16; display: flex; align-items: center; justify-content: center; border-radius: var(--r-sm); background: var(--background-lighter); } -.pack-clip-name { font-size: 11px; color: var(--black-alpha-48); margin-top: 4px; text-align: center; letter-spacing: .02em; } +.pack-clip-name { font-size: 11px; line-height: 1.5; color: var(--black-alpha-48); margin-top: 4px; text-align: center; letter-spacing: .02em; overflow-wrap: anywhere; word-break: break-word; } /* R108:批次弹窗内单张悬浮删除 icon(复用共享 .card-del-btn,这里只补 hover 触发,不改共享类本体) */ .pack-modal .pack-clip { position: relative; } .pack-modal .pack-clip:hover .card-del-btn, diff --git a/core/frontend/src/routes/library.tsx b/core/frontend/src/routes/library.tsx index 2adba13..13ae5bb 100644 --- a/core/frontend/src/routes/library.tsx +++ b/core/frontend/src/routes/library.tsx @@ -89,6 +89,7 @@ function personMissingTri(asset: Asset): boolean { } function freeVideoToPack(asset: Asset): VideoPack { + const displayName = asset.display_name?.trim() || asset.name || "视频自由创作"; const files = asset.files || []; const video = files.find((f) => f.is_primary && (f.content_type || "").startsWith("video/")) || files.find((f) => (f.content_type || "").startsWith("video/")); @@ -96,9 +97,9 @@ function freeVideoToPack(asset: Asset): VideoPack { || files.find((f) => (f.content_type || "").startsWith("image/")); return { project_id: `free-video:${asset.id}`, - project_name: asset.name || "视频自由创作", + project_name: displayName, product_cover: poster?.preview_url || "", - clips: [{ id: asset.id, name: asset.name || "视频", url: video?.preview_url || "" }] + clips: [{ id: asset.id, name: displayName, url: video?.preview_url || "" }] }; } diff --git a/core/frontend/src/types.ts b/core/frontend/src/types.ts index ec54e7f..67e342c 100644 --- a/core/frontend/src/types.ts +++ b/core/frontend/src/types.ts @@ -234,6 +234,8 @@ export type Product = { export type Asset = { id: string; name: string; + // 自由创作视频由后端从关联任务的完整 prompt 派生;其他资产与 name 相同。 + display_name?: string; asset_type: string; source: string; category: string;