fix: 自由创作分类入库区分视频与图片展示

This commit is contained in:
hh
2026-07-07 18:39:01 +08:00
parent 45ff7838d8
commit ff723b4281
7 changed files with 228 additions and 22 deletions
+3
View File
@@ -26,3 +26,6 @@ core/.tools/
# 本地一键启动脚本,不入库 # 本地一键启动脚本,不入库
start-code.bat start-code.bat
stop-code.bat stop-code.bat
# bug_todo,不入库,用于记录 bug 修复进度
docs/bug_todo/
+3 -2
View File
@@ -695,7 +695,8 @@ def serialize_free_video_task(task: AITask) -> dict:
return "" return ""
return "" return ""
for asset in task.generated_assets.all(): generated_assets = list(task.generated_assets.all())
for asset in generated_assets:
if asset.is_deleted: if asset.is_deleted:
continue continue
for f in asset.files.all(): for f in asset.files.all():
@@ -706,7 +707,7 @@ def serialize_free_video_task(task: AITask) -> dict:
video_url = url video_url = url
elif not f.is_primary and "image" in (f.content_type or "") and not thumbnail_url: elif not f.is_primary and "image" in (f.content_type or "") and not thumbnail_url:
thumbnail_url = url thumbnail_url = url
if not video_url: if not video_url and not generated_assets:
video_url = payload.get("fallback_video_url") or "" video_url = payload.get("fallback_video_url") or ""
return { return {
+58 -1
View File
@@ -178,7 +178,7 @@ from django.test import TestCase
from rest_framework.test import APIClient from rest_framework.test import APIClient
from apps.accounts.models import Team, TeamMember, User from apps.accounts.models import Team, TeamMember, User
from apps.ai.models import AITask, ImageConversation, ModelConfig from apps.ai.models import AITask, ImageConversation, ModelConfig, ModelProvider
from apps.ai.providers.volcano import VolcanoArkProvider from apps.ai.providers.volcano import VolcanoArkProvider
from apps.ai.services import enqueue_standalone_images from apps.ai.services import enqueue_standalone_images
from apps.assets.models import Asset, AssetFile from apps.assets.models import Asset, AssetFile
@@ -186,6 +186,63 @@ from apps.billing.models import CreditAccount
from apps.products.models import Product from apps.products.models import Product
class FreeVideoAssetDeletionTests(TestCase):
"""自由创作视频资产删除后,任务流应降级为无可播成品,不能回退到旧 fallback URL。"""
def setUp(self):
self.user = User.objects.create_user(username="fvdel", password="pass")
self.team = Team.objects.create(name="FreeVideoDel", owner=self.user)
TeamMember.objects.create(team=self.team, user=self.user, role="owner", status="active")
provider = ModelProvider.objects.create(name="fv-provider", display_name="FV Provider")
self.model = ModelConfig.objects.create(
provider=provider,
name="fv-model",
display_name="FV Model",
capability=ModelConfig.Capability.VIDEO,
)
self.task = AITask.objects.create(
team=self.team,
created_by=self.user,
task_type=AITask.Type.FREE_VIDEO,
status=AITask.Status.SUCCEEDED,
model_config=self.model,
idempotency_key="free-video-delete-test",
request_payload={
"prompt": "生成一个广告视频",
"fallback_video_url": "http://fallback.example/video.mp4",
"fallback_note": "fallback",
},
)
self.asset = Asset.objects.create(
team=self.team,
created_by=self.user,
name="自由创作视频",
asset_type=Asset.Type.VIDEO,
source=Asset.Source.AI_GENERATED,
category=Asset.Category.FREE_CREATE,
origin_task=self.task,
)
AssetFile.objects.create(
asset=self.asset,
object_key="free-video.mp4",
bucket="b",
content_type="video/mp4",
preview_url="http://tos.example/video.mp4",
is_primary=True,
)
def test_deleted_persisted_asset_does_not_fall_back_to_raw_video_url(self):
from apps.ai.free_video import serialize_free_video_task
self.assertEqual(serialize_free_video_task(self.task)["video_url"], "http://tos.example/video.mp4")
self.asset.is_deleted = True
self.asset.save(update_fields=["is_deleted"])
task = AITask.objects.prefetch_related("generated_assets", "generated_assets__files").get(id=self.task.id)
data = serialize_free_video_task(task)
self.assertEqual(data["video_url"], "")
self.assertEqual(data["fallback_note"], "fallback")
class StandaloneImageReferenceTests(TestCase): class StandaloneImageReferenceTests(TestCase):
"""独立生图(平台套图 / 模特上身图)必须把商品真实主图(+ 模特图)作为参考图走 image_edit, """独立生图(平台套图 / 模特上身图)必须把商品真实主图(+ 模特图)作为参考图走 image_edit,
而不是纯文生图——回归保护 #图片生成没参考商品主图# 这个 bug。 而不是纯文生图——回归保护 #图片生成没参考商品主图# 这个 bug。
+78
View File
@@ -220,6 +220,84 @@ class AssetBatchesTests(TestCase):
self.assertEqual(self.client.get("/api/assets/summary/").json()["tryon"], 2) self.assertEqual(self.client.get("/api/assets/summary/").json()["tryon"], 2)
class FreeCreateSplitTabTests(TestCase):
"""Free create assets share category=free_create; tabs split by asset_type."""
def setUp(self):
self.user, self.team = _mk_team("ufc", "TeamFC")
self.client = APIClient()
self.client.force_authenticate(self.user)
self.image1 = Asset.objects.create(
team=self.team,
name="free image 1",
asset_type=Asset.Type.IMAGE,
source=Asset.Source.AI_GENERATED,
category=Asset.Category.FREE_CREATE,
metadata={"batch_id": "IMG-B1"},
)
self.image2 = Asset.objects.create(
team=self.team,
name="free image 2",
asset_type=Asset.Type.IMAGE,
source=Asset.Source.AI_GENERATED,
category=Asset.Category.FREE_CREATE,
metadata={"batch_id": "IMG-B1"},
)
self.video = Asset.objects.create(
team=self.team,
name="free video",
asset_type=Asset.Type.VIDEO,
source=Asset.Source.AI_GENERATED,
category=Asset.Category.FREE_CREATE,
metadata={"feature": "free_video"},
)
def test_image_creations_batches_exclude_videos(self):
data = self.client.get("/api/assets/batches/?tab=image_creations&page_size=10").json()
self.assertEqual(data["count"], 1)
self.assertEqual(data["results"][0]["count"], 2)
self.assertEqual({a["asset_type"] for a in data["results"][0]["items"]}, {"image"})
def test_video_creations_list_excludes_images(self):
data = self.client.get("/api/assets/?tab=video_creations&page_size=10").json()
self.assertEqual(data["count"], 1)
self.assertEqual(data["results"][0]["id"], str(self.video.id))
self.assertEqual(data["results"][0]["asset_type"], "video")
def test_summary_exposes_split_counts_and_keeps_legacy_creations(self):
data = self.client.get("/api/assets/summary/").json()
self.assertEqual(data["image_creations"], 1)
self.assertEqual(data["video_creations"], 1)
self.assertEqual(data["creations"], 2)
def test_delete_and_restore_video_returns_to_video_creations_tab(self):
res = self.client.delete(f"/api/assets/{self.video.id}/")
self.assertEqual(res.status_code, 204)
self.video.refresh_from_db()
self.assertTrue(self.video.is_deleted)
video_tab = self.client.get("/api/assets/?tab=video_creations&page_size=10").json()
self.assertEqual(video_tab["count"], 0)
summary = self.client.get("/api/assets/summary/").json()
self.assertEqual(summary["video_creations"], 0)
self.assertEqual(summary["image_creations"], 1)
trash_ids = {a["id"] for a in self.client.get("/api/assets/trash/").json()["results"]}
self.assertIn(str(self.video.id), trash_ids)
restore = self.client.post(f"/api/assets/{self.video.id}/restore/")
self.assertEqual(restore.status_code, 200)
self.video.refresh_from_db()
self.assertFalse(self.video.is_deleted)
self.assertEqual(self.video.category, Asset.Category.FREE_CREATE)
self.assertEqual(self.video.asset_type, Asset.Type.VIDEO)
video_tab = self.client.get("/api/assets/?tab=video_creations&page_size=10").json()
self.assertEqual(video_tab["count"], 1)
self.assertEqual(video_tab["results"][0]["id"], str(self.video.id))
image_batches = self.client.get("/api/assets/batches/?tab=image_creations&page_size=10").json()
self.assertEqual({a["asset_type"] for a in image_batches["results"][0]["items"]}, {"image"})
class FacetsProductsTests(TestCase): class FacetsProductsTests(TestCase):
"""facets 返回本 tab 下真实关联的商品(?product= 三路之一:metadata.product_id 等), """facets 返回本 tab 下真实关联的商品(?product= 三路之一:metadata.product_id 等),
并按 tab 隔离、team 隔离、只回 id/title。""" 并按 tab 隔离、team 隔离、只回 id/title。"""
+7 -2
View File
@@ -50,7 +50,11 @@ def _tab_q(tab: str) -> Q:
return Q(category="model_tryon") return Q(category="model_tryon")
if tab == "kits": # 平台套图(图片趴) if tab == "kits": # 平台套图(图片趴)
return Q(category="platform_kit") return Q(category="platform_kit")
if tab == "creations": # 自由创作(图片趴) if tab == "image_creations": # 图片自由创作
return Q(category="free_create", asset_type="image")
if tab == "video_creations": # 视频自由创作
return Q(category="free_create", asset_type="video")
if tab == "creations": # 自由创作(兼容旧入口:图片+视频)
return Q(category="free_create") return Q(category="free_create")
if tab == "uploads": if tab == "uploads":
return Q(category="upload") return Q(category="upload")
@@ -64,7 +68,7 @@ def _tab_q(tab: str) -> Q:
# 图片趴三类按「生成批次」成组展示:同一次提交的 N 张图共享 metadata.batch_id。 # 图片趴三类按「生成批次」成组展示:同一次提交的 N 张图共享 metadata.batch_id。
BATCH_TABS = {"tryon", "kits", "creations"} BATCH_TABS = {"tryon", "kits", "creations", "image_creations"}
# 资产名形如「AI 生成 · 模特上身图 · 4」,去掉尾部的「· 序号」得到批次名「AI 生成 · 模特上身图」。 # 资产名形如「AI 生成 · 模特上身图 · 4」,去掉尾部的「· 序号」得到批次名「AI 生成 · 模特上身图」。
_BATCH_NAME_RE = re.compile(r"\s*·\s*\d+\s*$") _BATCH_NAME_RE = re.compile(r"\s*·\s*\d+\s*$")
@@ -180,6 +184,7 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
for row in rows: for row in rows:
seen.add(_batch_key((row["metadata"] or {}).get("batch_id"), row["origin_task_id"], row["id"])) seen.add(_batch_key((row["metadata"] or {}).get("batch_id"), row["origin_task_id"], row["id"]))
out[t] = len(seen) out[t] = len(seen)
out["video_creations"] = base.filter(_tab_q("video_creations")).count()
out["others"] = base.filter(_tab_q("others")).count() out["others"] = base.filter(_tab_q("others")).count()
return Response(out) return Response(out)
+5 -1
View File
@@ -51,7 +51,11 @@ export function Dashboard({ products, projects, productTotal, projectTotal, bill
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
api.assetSummary() api.assetSummary()
.then((c) => { if (alive) setAssetCount(Object.values(c).reduce((a, b) => a + b, 0)); }) .then((c) => {
if (!alive) return;
const imageCreations = c.image_creations ?? c.creations ?? 0;
setAssetCount((c.tryon || 0) + (c.kits || 0) + imageCreations + (c.video_creations || 0) + (c.others || 0));
})
.catch(() => {}); .catch(() => {});
return () => { alive = false; }; return () => { alive = false; };
}, []); }, []);
+74 -16
View File
@@ -5,7 +5,7 @@ import { Download, Image as ImageIcon, Images, Info, LayoutGrid, Music, Trash2,
import { api } from "../api"; import { api } from "../api";
import { SkeletonGrid } from "../components/loading"; import { SkeletonGrid } from "../components/loading";
import { ReviewBadge, type ReviewStatus } from "../components/review-badge"; import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
import type { Asset, AssetBatch } from "../types"; import type { Asset, AssetBatch, VideoPack } from "../types";
import { ConfirmModal, MediaLightbox, useBodyScrollLock, useOverlayTransition } from "../components/overlays"; import { ConfirmModal, MediaLightbox, useBodyScrollLock, useOverlayTransition } from "../components/overlays";
import { Pager } from "../components/pager"; import { Pager } from "../components/pager";
@@ -36,22 +36,25 @@ const UPLOAD_KINDS: Array<{ value: string; label: string; accept: string; hint:
]; ];
// 资产库 = 成品库(商品/模特/半成品各回各家): // 资产库 = 成品库(商品/模特/半成品各回各家):
// 图片成品 tryon=模特上身图 / kits=平台套图 / creations=自由创作;视频成品 videopacks=按项目素材包;others=其他兜底 // 图片成品 tryon=模特上身图 / kits=平台套图 / image_creations=图片自由创作;
type LibTab = "tryon" | "kits" | "creations" | "videopacks" | "others"; // video_creations=视频自由创作;视频成品 videopacks=按项目素材包;others=其他兜底
type LibTab = "tryon" | "kits" | "image_creations" | "video_creations" | "videopacks" | "others";
type LibCountKey = LibTab | "creations"; // creations 是旧后端兼容字段,不再作为可见 tab
const LIB_TABS: Array<{ key: LibTab; label: string }> = [ const LIB_TABS: Array<{ key: LibTab; label: string }> = [
{ key: "tryon", label: "模特上身图" }, { key: "kits", label: "平台套图" }, { key: "creations", label: "自由创作" }, { key: "tryon", label: "模特上身图" }, { key: "kits", label: "平台套图" }, { key: "image_creations", label: "图片自由创作" },
{ key: "video_creations", label: "视频自由创作" },
{ key: "videopacks", label: "视频成品" }, { key: "others", label: "其他" } { key: "videopacks", label: "视频成品" }, { key: "others", label: "其他" }
]; ];
// 图片成品三类按「生成批次」成组展示(一次提交的多张图 = 一张批次卡,点开看整批);其余 tab 仍平铺 // 图片成品三类按「生成批次」成组展示(一次提交的多张图 = 一张批次卡,点开看整批);其余 tab 仍平铺
const BATCH_TABS: LibTab[] = ["tryon", "kits", "creations"]; const BATCH_TABS: LibTab[] = ["tryon", "kits", "image_creations"];
// 对齐 api-bridge:工具栏 chip 按 tab 显隐(视频成品走素材包,不用这些扁平筛选) // 对齐 api-bridge:工具栏 chip 按 tab 显隐(视频成品走素材包,不用这些扁平筛选)
const LIB_CHIPS: Array<{ key: string; label: string; tabs: LibTab[] }> = [ const LIB_CHIPS: Array<{ key: string; label: string; tabs: LibTab[] }> = [
{ key: "product", label: "关联商品", tabs: ["tryon", "kits"] }, { key: "product", label: "关联商品", tabs: ["tryon", "kits"] },
{ key: "kind", label: "资产类型", tabs: ["others"] }, { key: "kind", label: "资产类型", tabs: ["others"] },
{ key: "source", label: "来源", tabs: ["tryon", "kits", "creations", "others"] } { key: "source", label: "来源", tabs: ["tryon", "kits", "image_creations", "video_creations", "others"] }
]; ];
// metadata 里可能用的中文属性键(真实存在才渲染,缺则不显;属性区不造假) // metadata 里可能用的中文属性键(真实存在才渲染,缺则不显;属性区不造假)
@@ -80,6 +83,20 @@ function personMissingTri(asset: Asset): boolean {
return asset.source === "upload"; return asset.source === "upload";
} }
function freeVideoToPack(asset: Asset): VideoPack {
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/"));
const poster = files.find((f) => !f.is_primary && (f.content_type || "").startsWith("image/"))
|| files.find((f) => (f.content_type || "").startsWith("image/"));
return {
project_id: `free-video:${asset.id}`,
project_name: asset.name || "视频自由创作",
product_cover: poster?.preview_url || "",
clips: [{ id: asset.id, name: asset.name || "视频", url: video?.preview_url || "" }]
};
}
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
// 资产详情弹窗 · 立绘大图 + 三视图/版本历史(人物)+ 简介/标签/属性 + 媒体画廊(商品) // 资产详情弹窗 · 立绘大图 + 三视图/版本历史(人物)+ 简介/标签/属性 + 媒体画廊(商品)
// 用真实 Asset 字段渲染;缺字段留「-」或整段降级,不造 mock。 // 用真实 Asset 字段渲染;缺字段留「-」或整段降级,不造 mock。
@@ -452,13 +469,13 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
// ── 服务端懒加载:列表按页拉、tab 计数走 summary、筛选项走 facets(不再前端取全量再切片)── // ── 服务端懒加载:列表按页拉、tab 计数走 summary、筛选项走 facets(不再前端取全量再切片)──
const [items, setItems] = useState<Asset[]>([]); const [items, setItems] = useState<Asset[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [counts, setCounts] = useState<Record<LibTab, number>>({ tryon: 0, kits: 0, creations: 0, videopacks: 0, others: 0 }); const [counts, setCounts] = useState<Record<LibCountKey, number>>({ tryon: 0, kits: 0, image_creations: 0, video_creations: 0, videopacks: 0, others: 0, creations: 0 });
// 图片成品:按生成批次成组(点批次卡 → 弹窗看整批图片) // 图片成品:按生成批次成组(点批次卡 → 弹窗看整批图片)
const [batches, setBatches] = useState<AssetBatch[]>([]); const [batches, setBatches] = useState<AssetBatch[]>([]);
const [openBatch, setOpenBatch] = useState<AssetBatch | null>(null); const [openBatch, setOpenBatch] = useState<AssetBatch | null>(null);
// 视频成品:按项目素材包(点包卡 → 弹窗看该项目所有片段) // 视频成品:按项目素材包(点包卡 → 弹窗看该项目所有片段)
const [packs, setPacks] = useState<import("../types").VideoPack[]>([]); const [packs, setPacks] = useState<VideoPack[]>([]);
const [openPack, setOpenPack] = useState<import("../types").VideoPack | null>(null); const [openPack, setOpenPack] = useState<VideoPack | null>(null);
const [facets, setFacets] = useState<{ sources: string[]; kinds: string[]; metadata: Record<string, string[]>; products: { id: string; title: string }[] }>({ sources: [], kinds: [], metadata: {}, products: [] }); const [facets, setFacets] = useState<{ sources: string[]; kinds: string[]; metadata: Record<string, string[]>; products: { id: string; title: string }[] }>({ sources: [], kinds: [], metadata: {}, products: [] });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -477,6 +494,9 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
// 图片成品三类按批次成组展示;其余 tab 平铺 // 图片成品三类按批次成组展示;其余 tab 平铺
const isBatchTab = BATCH_TABS.includes(tab); const isBatchTab = BATCH_TABS.includes(tab);
const isVideoCreationTab = tab === "video_creations";
const imageBatchCount = counts.tryon + counts.kits + counts.image_creations;
const resultUnit = isBatchTab ? "个批次" : (tab === "video_creations" ? "条视频" : "个资产");
// 列表数据(分页 + 过滤) // 列表数据(分页 + 过滤)
const [reloadFlag, setReloadFlag] = useState(0); const [reloadFlag, setReloadFlag] = useState(0);
@@ -542,6 +562,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
const packTotalPages = Math.max(1, Math.ceil(filteredPacks.length / LIB_PAGE_SIZE)); const packTotalPages = Math.max(1, Math.ceil(filteredPacks.length / LIB_PAGE_SIZE));
const packCurPage = Math.min(page, packTotalPages); const packCurPage = Math.min(page, packTotalPages);
const shownPacks = filteredPacks.slice((packCurPage - 1) * LIB_PAGE_SIZE, packCurPage * LIB_PAGE_SIZE); const shownPacks = filteredPacks.slice((packCurPage - 1) * LIB_PAGE_SIZE, packCurPage * LIB_PAGE_SIZE);
const openPackIsFreeVideo = Boolean(openPack?.project_id?.startsWith("free-video:"));
// 批量删除:批次 tab 走 selectedBatches(展开成整批 asset id),其余 tab 走 selected(asset id) // 批量删除:批次 tab 走 selectedBatches(展开成整批 asset id),其余 tab 走 selected(asset id)
const selCount = isBatchTab ? selectedBatches.size : selected.size; const selCount = isBatchTab ? selectedBatches.size : selected.size;
@@ -598,7 +619,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
<div className="page-head"> <div className="page-head">
<div> <div>
<h1>资产库</h1> <h1>资产库</h1>
<div className="sub"><span className="mono">// 你的成品 · 图片 {counts.tryon + counts.kits + counts.creations} 批 · 视频 {counts.videopacks} 包</span></div> <div className="sub"><span className="mono">// 你的成品 · 图片 {imageBatchCount} 批 · 视频 {counts.video_creations} 条 · 视频成品 {counts.videopacks} 包</span></div>
</div> </div>
<div className="actions"> <div className="actions">
<button className={`btn btn-edit-toggle${editMode ? " active" : ""}`} type="button" id="lib-manage-btn" onClick={() => (editMode ? exitEdit() : setEditMode(true))}> <button className={`btn btn-edit-toggle${editMode ? " active" : ""}`} type="button" id="lib-manage-btn" onClick={() => (editMode ? exitEdit() : setEditMode(true))}>
@@ -783,7 +804,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
</div> </div>
</div> </div>
<div className="result-meta" id="result-meta">// 显示 <span className="count">{isBatchTab ? batches.length : shown.length}</span> / {total} {isBatchTab ? "个批次" : "个资产"}{hasFilter ? "(已筛选)" : ""}{loading ? " · 加载中…" : ""}</div> <div className="result-meta" id="result-meta">// 显示 <span className="count">{isBatchTab ? batches.length : shown.length}</span> / {total} {resultUnit}{hasFilter ? "(已筛选)" : ""}{loading ? " · 加载中…" : ""}</div>
{isBatchTab ? ( {isBatchTab ? (
batches.length ? ( batches.length ? (
@@ -809,6 +830,43 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
) : ( ) : (
<div className="empty-filter">// 当前分类暂无真实资产</div> <div className="empty-filter">// 当前分类暂无真实资产</div>
) )
) : isVideoCreationTab ? (
shown.length ? (
<div className="packs-grid" id="free-video-grid">
{shown.map((asset) => {
const pack = freeVideoToPack(asset);
const first = pack.clips[0];
const isSelected = selected.has(asset.id);
const onVideoClick = editMode ? () => toggleSelect(asset.id) : () => setOpenPack(pack);
return (
<article className={`pack-card batch-card${isSelected ? " selected" : ""}`} key={asset.id} onClick={onVideoClick} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onVideoClick(); } }}>
<span className="card-check" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg></span>
{editMode && onDelete && (
<button className="card-del-btn" type="button" title="删除视频" onClick={(event) => { event.stopPropagation(); setConfirmIds([asset.id]); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
</button>
)}
<div className="placeholder asset-thumb pack-thumb">
{pack.product_cover ? (
<img src={pack.product_cover} alt={pack.project_name} loading="lazy" />
) : first?.url ? (
<video src={first.url} muted playsInline preload="metadata" style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} />
) : (
<span className="ph-frame">无视频</span>
)}
<span className="pack-count mono">1 条</span>
<span className="lib-play-badge" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg></span>
</div>
<div className="asset-body"><div className="asset-name">{pack.project_name}</div><div className="asset-meta mono">视频自由创作</div></div>
</article>
);
})}
</div>
) : loading ? (
<SkeletonGrid count={8} />
) : (
<div className="empty-filter">// 当前分类暂无真实资产</div>
)
) : shown.length ? ( ) : shown.length ? (
<div className="asset-grid" id="asset-grid"> <div className="asset-grid" id="asset-grid">
{shown.map((asset) => { {shown.map((asset) => {
@@ -880,12 +938,12 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
{/* 视频素材包弹窗:看该项目所有视频片段(可播) */} {/* 视频素材包弹窗:看该项目所有视频片段(可播) */}
{openPack && createPortal( {openPack && createPortal(
<div className="pack-modal-bg" role="dialog" aria-modal="true" aria-label="视频素材包" onClick={(e) => { if (e.target === e.currentTarget) setOpenPack(null); }}> <div className="pack-modal-bg" role="dialog" aria-modal="true" aria-label={openPackIsFreeVideo ? "视频自由创作" : "视频素材包"} onClick={(e) => { if (e.target === e.currentTarget) setOpenPack(null); }}>
<div className="pack-modal"> <div className="pack-modal">
<div className="pack-modal-h"> <div className="pack-modal-h">
<div> <div>
<h2>{openPack.project_name}</h2> <h2>{openPack.project_name}</h2>
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>// 视频素材包 · {openPack.clips.length} 段</span> <span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>// {openPackIsFreeVideo ? "视频自由创作" : "视频素材包"} · {openPack.clips.length} {openPackIsFreeVideo ? "条" : "段"}</span>
</div> </div>
<button className="x" type="button" aria-label="关闭" onClick={() => setOpenPack(null)}> <button className="x" type="button" aria-label="关闭" onClick={() => setOpenPack(null)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
@@ -894,8 +952,8 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
<div className="pack-clip-grid"> <div className="pack-clip-grid">
{openPack.clips.map((c, i) => ( {openPack.clips.map((c, i) => (
<div className="pack-clip" key={c.id}> <div className="pack-clip" key={c.id}>
<video src={c.url} controls muted playsInline preload="metadata" /> {c.url ? <video src={c.url} controls muted playsInline preload="metadata" /> : <span className="pack-clip-ph ph-frame">无视频</span>}
<div className="pack-clip-name mono">镜 {i + 1}</div> <div className="pack-clip-name mono">{openPackIsFreeVideo ? (c.name || "视频") : `镜 ${i + 1}`}</div>
</div> </div>
))} ))}
</div> </div>
@@ -973,7 +1031,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
title="删除资产" title="删除资产"
subtitle="// DELETE ASSET" subtitle="// DELETE ASSET"
icon={<Trash2 size={16} />} icon={<Trash2 size={16} />}
detail={`已选中 ${confirmIds?.length || 0} 个资产。删除的图片将回收到垃圾桶,是否需要删除?`} detail={`已选中 ${confirmIds?.length || 0} 个资产。删除的资产将回收到垃圾桶,是否需要删除?`}
confirmText="删除" confirmText="删除"
onCancel={() => setConfirmIds(null)} onCancel={() => setConfirmIds(null)}
onConfirm={doDelete} onConfirm={doDelete}