fix: 自由创作分类入库区分视频与图片展示
This commit is contained in:
@@ -26,3 +26,6 @@ core/.tools/
|
||||
# 本地一键启动脚本,不入库
|
||||
start-code.bat
|
||||
stop-code.bat
|
||||
|
||||
# bug_todo,不入库,用于记录 bug 修复进度
|
||||
docs/bug_todo/
|
||||
|
||||
@@ -695,7 +695,8 @@ def serialize_free_video_task(task: AITask) -> dict:
|
||||
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:
|
||||
continue
|
||||
for f in asset.files.all():
|
||||
@@ -706,7 +707,7 @@ def serialize_free_video_task(task: AITask) -> dict:
|
||||
video_url = url
|
||||
elif not f.is_primary and "image" in (f.content_type or "") and not thumbnail_url:
|
||||
thumbnail_url = url
|
||||
if not video_url:
|
||||
if not video_url and not generated_assets:
|
||||
video_url = payload.get("fallback_video_url") or ""
|
||||
|
||||
return {
|
||||
|
||||
@@ -178,7 +178,7 @@ from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
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.services import enqueue_standalone_images
|
||||
from apps.assets.models import Asset, AssetFile
|
||||
@@ -186,6 +186,63 @@ from apps.billing.models import CreditAccount
|
||||
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):
|
||||
"""独立生图(平台套图 / 模特上身图)必须把商品真实主图(+ 模特图)作为参考图走 image_edit,
|
||||
而不是纯文生图——回归保护 #图片生成没参考商品主图# 这个 bug。
|
||||
|
||||
@@ -220,6 +220,84 @@ class AssetBatchesTests(TestCase):
|
||||
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):
|
||||
"""facets 返回本 tab 下真实关联的商品(?product= 三路之一:metadata.product_id 等),
|
||||
并按 tab 隔离、team 隔离、只回 id/title。"""
|
||||
|
||||
@@ -50,7 +50,11 @@ def _tab_q(tab: str) -> Q:
|
||||
return Q(category="model_tryon")
|
||||
if tab == "kits": # 平台套图(图片趴)
|
||||
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")
|
||||
if tab == "uploads":
|
||||
return Q(category="upload")
|
||||
@@ -64,7 +68,7 @@ def _tab_q(tab: str) -> Q:
|
||||
|
||||
|
||||
# 图片趴三类按「生成批次」成组展示:同一次提交的 N 张图共享 metadata.batch_id。
|
||||
BATCH_TABS = {"tryon", "kits", "creations"}
|
||||
BATCH_TABS = {"tryon", "kits", "creations", "image_creations"}
|
||||
|
||||
# 资产名形如「AI 生成 · 模特上身图 · 4」,去掉尾部的「· 序号」得到批次名「AI 生成 · 模特上身图」。
|
||||
_BATCH_NAME_RE = re.compile(r"\s*·\s*\d+\s*$")
|
||||
@@ -180,6 +184,7 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
for row in rows:
|
||||
seen.add(_batch_key((row["metadata"] or {}).get("batch_id"), row["origin_task_id"], row["id"]))
|
||||
out[t] = len(seen)
|
||||
out["video_creations"] = base.filter(_tab_q("video_creations")).count()
|
||||
out["others"] = base.filter(_tab_q("others")).count()
|
||||
return Response(out)
|
||||
|
||||
|
||||
@@ -51,7 +51,11 @@ export function Dashboard({ products, projects, productTotal, projectTotal, bill
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
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(() => {});
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Download, Image as ImageIcon, Images, Info, LayoutGrid, Music, Trash2,
|
||||
import { api } from "../api";
|
||||
import { SkeletonGrid } from "../components/loading";
|
||||
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 { Pager } from "../components/pager";
|
||||
|
||||
@@ -36,22 +36,25 @@ const UPLOAD_KINDS: Array<{ value: string; label: string; accept: string; hint:
|
||||
];
|
||||
|
||||
// 资产库 = 成品库(商品/模特/半成品各回各家):
|
||||
// 图片成品 tryon=模特上身图 / kits=平台套图 / creations=自由创作;视频成品 videopacks=按项目素材包;others=其他兜底
|
||||
type LibTab = "tryon" | "kits" | "creations" | "videopacks" | "others";
|
||||
// 图片成品 tryon=模特上身图 / kits=平台套图 / image_creations=图片自由创作;
|
||||
// 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 }> = [
|
||||
{ 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: "其他" }
|
||||
];
|
||||
|
||||
// 图片成品三类按「生成批次」成组展示(一次提交的多张图 = 一张批次卡,点开看整批);其余 tab 仍平铺
|
||||
const BATCH_TABS: LibTab[] = ["tryon", "kits", "creations"];
|
||||
const BATCH_TABS: LibTab[] = ["tryon", "kits", "image_creations"];
|
||||
|
||||
// 对齐 api-bridge:工具栏 chip 按 tab 显隐(视频成品走素材包,不用这些扁平筛选)
|
||||
const LIB_CHIPS: Array<{ key: string; label: string; tabs: LibTab[] }> = [
|
||||
{ key: "product", label: "关联商品", tabs: ["tryon", "kits"] },
|
||||
{ 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 里可能用的中文属性键(真实存在才渲染,缺则不显;属性区不造假)
|
||||
@@ -80,6 +83,20 @@ function personMissingTri(asset: Asset): boolean {
|
||||
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。
|
||||
@@ -452,13 +469,13 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
||||
// ── 服务端懒加载:列表按页拉、tab 计数走 summary、筛选项走 facets(不再前端取全量再切片)──
|
||||
const [items, setItems] = useState<Asset[]>([]);
|
||||
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 [openBatch, setOpenBatch] = useState<AssetBatch | null>(null);
|
||||
// 视频成品:按项目素材包(点包卡 → 弹窗看该项目所有片段)
|
||||
const [packs, setPacks] = useState<import("../types").VideoPack[]>([]);
|
||||
const [openPack, setOpenPack] = useState<import("../types").VideoPack | null>(null);
|
||||
const [packs, setPacks] = useState<VideoPack[]>([]);
|
||||
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 [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -477,6 +494,9 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
||||
|
||||
// 图片成品三类按批次成组展示;其余 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);
|
||||
@@ -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 packCurPage = Math.min(page, packTotalPages);
|
||||
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)
|
||||
const selCount = isBatchTab ? selectedBatches.size : selected.size;
|
||||
@@ -598,7 +619,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<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 className="actions">
|
||||
<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 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 ? (
|
||||
batches.length ? (
|
||||
@@ -809,6 +830,43 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
||||
) : (
|
||||
<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 ? (
|
||||
<div className="asset-grid" id="asset-grid">
|
||||
{shown.map((asset) => {
|
||||
@@ -880,12 +938,12 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
||||
|
||||
{/* 视频素材包弹窗:看该项目所有视频片段(可播) */}
|
||||
{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-h">
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
@@ -894,8 +952,8 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
||||
<div className="pack-clip-grid">
|
||||
{openPack.clips.map((c, i) => (
|
||||
<div className="pack-clip" key={c.id}>
|
||||
<video src={c.url} controls muted playsInline preload="metadata" />
|
||||
<div className="pack-clip-name mono">镜 {i + 1}</div>
|
||||
{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">{openPackIsFreeVideo ? (c.name || "视频") : `镜 ${i + 1}`}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -973,7 +1031,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
||||
title="删除资产"
|
||||
subtitle="// DELETE ASSET"
|
||||
icon={<Trash2 size={16} />}
|
||||
detail={`已选中 ${confirmIds?.length || 0} 个资产。删除的图片将回收到垃圾桶,是否需要删除?`}
|
||||
detail={`已选中 ${confirmIds?.length || 0} 个资产。删除的资产将回收到垃圾桶,是否需要删除?`}
|
||||
confirmText="删除"
|
||||
onCancel={() => setConfirmIds(null)}
|
||||
onConfirm={doDelete}
|
||||
|
||||
Reference in New Issue
Block a user