feat(library): 资产库重做为「成品库」— 图片成品三类 + 视频成品按项目素材包
定义:资产 = 平台产出的成品。原料(商品/模特)归各自的库、半成品(角色/三视图/场景/分镜)归项目, 资产库只装成品 —— tab 从 9 个收成 5 个。 - 后端 /api/assets/video-packs/:视频成品按项目素材包(与「导出全部」同源,取 VideoSegment.adopted_version 真实视频资产,给 TOS 直链 url 供 <video> 播放/取首帧);summary 改只数成品(tryon/kits/creations/others) - 资产库 _tab_q 加 others(我的上传+未归类兜底);角色/场景/商品图/素材 tab 移除(各回各家) - 前端 library:tab→模特上身图/平台套图/自由创作/视频成品/其他;视频成品=项目包卡(<video>首帧封面) → 点开弹窗看该项目所有片段(可播);图片成品/其他复用原网格 - 单测 VideoPacksTests:按项目分组 + 软删片段排除 验收:后端单测绿(基线 4 失败零新增);tsc+build 绿;无头 0 报错(5 tab + 视频包弹窗 3 段) 注:图片成品「按生成批次成组」待图片趴有真实数据时接线(当前 demo 已清空) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -93,8 +93,8 @@ class AssetSoftDeleteTests(TestCase):
|
||||
self.user, self.team = _mk_team("usd", "TeamSD")
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(self.user)
|
||||
self.live = Asset.objects.create(team=self.team, name="留存图", asset_type="image", source="ai_generated", category=Asset.Category.PERSON)
|
||||
self.dead = Asset.objects.create(team=self.team, name="废图", asset_type="image", source="ai_generated", category=Asset.Category.PERSON, is_deleted=True)
|
||||
self.live = Asset.objects.create(team=self.team, name="留存图", asset_type="image", source="ai_generated", category=Asset.Category.MODEL_TRYON)
|
||||
self.dead = Asset.objects.create(team=self.team, name="废图", asset_type="image", source="ai_generated", category=Asset.Category.MODEL_TRYON, is_deleted=True)
|
||||
|
||||
def test_list_excludes_soft_deleted(self):
|
||||
ids = {a["id"] for a in self.client.get("/api/assets/?page_size=50").json()["results"]}
|
||||
@@ -102,7 +102,43 @@ class AssetSoftDeleteTests(TestCase):
|
||||
self.assertNotIn(str(self.dead.id), ids)
|
||||
|
||||
def test_summary_excludes_soft_deleted(self):
|
||||
self.assertEqual(self.client.get("/api/assets/summary/").json()["people"], 1)
|
||||
self.assertEqual(self.client.get("/api/assets/summary/").json()["tryon"], 1)
|
||||
|
||||
|
||||
class VideoPacksTests(TestCase):
|
||||
"""视频成品按项目素材包分组(与导出同源:取 VideoSegment.adopted_version 的真实视频资产)。"""
|
||||
|
||||
def setUp(self):
|
||||
from apps.products.models import Product
|
||||
from apps.projects.models import Project, VideoSegment, VideoSegmentVersion
|
||||
from .models import AssetFile
|
||||
|
||||
self.user, self.team = _mk_team("uvp", "TeamVP")
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(self.user)
|
||||
self.product = Product.objects.create(team=self.team, created_by=self.user, title="P")
|
||||
self.proj = Project.objects.create(team=self.team, name="项目甲", product=self.product, created_by=self.user)
|
||||
self.assets = []
|
||||
for i in range(2):
|
||||
a = Asset.objects.create(team=self.team, name=f"clip{i}", asset_type="video", source="ai_generated", category=Asset.Category.VIDEO_CLIP)
|
||||
AssetFile.objects.create(asset=a, object_key=f"k{i}.mp4", bucket="b", content_type="video/mp4", is_primary=True)
|
||||
seg = VideoSegment.objects.create(project=self.proj, sort_order=i)
|
||||
ver = VideoSegmentVersion.objects.create(video_segment=seg, asset=a, is_adopted=True)
|
||||
seg.adopted_version = ver
|
||||
seg.save(update_fields=["adopted_version"])
|
||||
self.assets.append(a)
|
||||
|
||||
def test_packs_grouped_by_project(self):
|
||||
data = self.client.get("/api/assets/video-packs/").json()
|
||||
self.assertEqual(len(data), 1)
|
||||
self.assertEqual(data[0]["project_name"], "项目甲")
|
||||
self.assertEqual(len(data[0]["clips"]), 2)
|
||||
self.assertIn("url", data[0]["clips"][0])
|
||||
|
||||
def test_soft_deleted_clip_excluded(self):
|
||||
Asset.objects.filter(id=self.assets[0].id).update(is_deleted=True)
|
||||
data = self.client.get("/api/assets/video-packs/").json()
|
||||
self.assertEqual(len(data[0]["clips"]), 1)
|
||||
|
||||
|
||||
class ReviewScopeTests(TestCase):
|
||||
|
||||
@@ -54,6 +54,8 @@ def _tab_q(tab: str) -> Q:
|
||||
return Q(category="upload")
|
||||
if tab == "materials": # 素材(期3):视频素材 = 所有视频,排除「最终成片」(final_video 隐藏不列)
|
||||
return ~Q(category="final_video") & Q(asset_type="video")
|
||||
if tab == "others": # 其他(资产库成品化):我的上传 + 未归类非视频(兜底)
|
||||
return Q(category="upload") | (~Q(category__in=_KNOWN_CATS) & ~Q(asset_type="video"))
|
||||
if tab == "unclassified": # 未归类且非视频(也不含最终成片)
|
||||
return ~Q(category__in=_KNOWN_CATS) & ~Q(asset_type="video")
|
||||
return Q()
|
||||
@@ -110,9 +112,50 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
def summary(self, request):
|
||||
"""资产库 tab 计数(人物/场景/商品图/成片/我的上传/未分类),供 tab 徽标——不必取全量。"""
|
||||
base = Asset.objects.filter(team=self.get_team(), is_deleted=False)
|
||||
tabs = ["people", "scenes", "products", "tryon", "kits", "creations", "materials", "uploads", "unclassified"]
|
||||
# 资产库成品化:对外只数成品三类 + 其他(角色/场景/商品图各回各库,视频成品走 video-packs)
|
||||
tabs = ["tryon", "kits", "creations", "others"]
|
||||
return Response({t: base.filter(_tab_q(t)).count() for t in tabs})
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="video-packs")
|
||||
def video_packs(self, request):
|
||||
"""视频成品按「项目素材包」打包:每个项目的已采用视频片段 = 一个包(与「导出全部」同源:
|
||||
取 VideoSegment.adopted_version 的真实视频资产)。片段给 TOS 直链 url 供 <video> 播放/取首帧。"""
|
||||
from apps.assets.serializers import _asset_preview
|
||||
from apps.projects.models import Project
|
||||
|
||||
team = self.get_team()
|
||||
projects = (
|
||||
Project.objects.filter(team=team)
|
||||
.select_related("product__cover_asset")
|
||||
.prefetch_related("product__cover_asset__files")
|
||||
.order_by("-updated_at")
|
||||
)
|
||||
out = []
|
||||
for proj in projects:
|
||||
segs = (
|
||||
proj.video_segments.filter(adopted_version__isnull=False)
|
||||
.select_related("adopted_version__asset")
|
||||
.prefetch_related("adopted_version__asset__files")
|
||||
.order_by("sort_order")
|
||||
)
|
||||
clips = []
|
||||
for seg in segs:
|
||||
a = getattr(seg.adopted_version, "asset", None)
|
||||
if a is None or a.is_deleted:
|
||||
continue
|
||||
clips.append({"id": str(a.id), "name": a.name, "url": _asset_preview(a)})
|
||||
if not clips:
|
||||
continue
|
||||
product = proj.product
|
||||
out.append({
|
||||
"project_id": str(proj.id),
|
||||
"project_name": proj.name,
|
||||
"product_cover": _asset_preview(getattr(product, "cover_asset", None)) if product else "",
|
||||
"clips": clips,
|
||||
})
|
||||
out.sort(key=lambda p: len(p["clips"]), reverse=True)
|
||||
return Response(out)
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def facets(self, request):
|
||||
"""某 tab 下真实存在的筛选项(来源/类型/指定 metadata 键的取值),供下拉「只列真有的」。
|
||||
|
||||
@@ -436,6 +436,10 @@ export const api = {
|
||||
assetSummary() {
|
||||
return request<Record<string, number>>("/api/assets/summary/");
|
||||
},
|
||||
// 视频成品:按项目素材包分组(每项目一包,含片段列表)
|
||||
videoPacks() {
|
||||
return request<import("./types").VideoPack[]>("/api/assets/video-packs/");
|
||||
},
|
||||
// 某 tab 下真实存在的筛选项(来源/类型/指定 metadata 键取值),供下拉「只列真有的」
|
||||
assetFacets(tab?: string, metaKeys: string[] = []) {
|
||||
const qs = new URLSearchParams();
|
||||
|
||||
@@ -212,3 +212,22 @@ body.edit-mode .library-page .asset-card .card-del-btn { opacity: 0 !important;
|
||||
.upload-modal .modal-f { align-items: center; }
|
||||
.upload-foot-meta { flex: 1; font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .02em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.upload-foot-meta .accent { color: var(--heat); font-weight: 600; }
|
||||
|
||||
/* ── 资产库成品化:视频成品「项目素材包」 ── */
|
||||
.library-page .packs-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 14px; }
|
||||
.library-page .pack-card { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: background .15s, border-color .15s; position: relative; overflow: hidden; }
|
||||
.library-page .pack-card:hover { background: var(--background-lighter); border-color: var(--black-alpha-48); }
|
||||
.library-page .pack-thumb { position: relative; width: 100%; aspect-ratio: 9 / 16; max-height: 280px; }
|
||||
.library-page .pack-thumb video, .library-page .pack-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; border-radius: inherit; }
|
||||
.library-page .pack-count { position: absolute; left: 8px; top: 8px; padding: 2px 8px; border-radius: var(--r-pill); background: var(--black-alpha-56); color: var(--accent-white); font-size: 11px; letter-spacing: .02em; }
|
||||
|
||||
/* 素材包弹窗(portal 到 body) */
|
||||
.pack-modal-bg { position: fixed; inset: 0; z-index: 1200; background: rgba(0, 0, 0, .5); display: flex; align-items: center; justify-content: center; padding: 32px; }
|
||||
.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 .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; }
|
||||
.pack-clip video { width: 100%; aspect-ratio: 9 / 16; object-fit: cover; border-radius: var(--r-sm); background: #000; display: block; }
|
||||
.pack-clip-name { font-size: 11px; color: var(--black-alpha-48); margin-top: 4px; text-align: center; letter-spacing: .02em; }
|
||||
|
||||
@@ -34,27 +34,20 @@ const UPLOAD_KINDS: Array<{ value: string; label: string; accept: string; hint:
|
||||
{ value: "subtitle", label: "字幕", accept: ".srt,.vtt,.ass", hint: "SRT / VTT / ASS" }
|
||||
];
|
||||
|
||||
// 图片趴三类(期2):tryon=模特上身图 / kits=平台套图 / creations=自由创作
|
||||
// 期3:人物→角色;成片 tab→素材(视频素材,排除最终成片;最终成片隐藏不列)
|
||||
type LibTab = "people" | "scenes" | "products" | "tryon" | "kits" | "creations" | "materials" | "uploads" | "unclassified";
|
||||
// 资产库 = 成品库(商品/模特/半成品各回各家):
|
||||
// 图片成品 tryon=模特上身图 / kits=平台套图 / creations=自由创作;视频成品 videopacks=按项目素材包;others=其他兜底
|
||||
type LibTab = "tryon" | "kits" | "creations" | "videopacks" | "others";
|
||||
|
||||
const LIB_TABS: Array<{ key: LibTab; label: string }> = [
|
||||
{ key: "people", label: "角色" }, { key: "scenes", label: "场景" }, { key: "products", label: "商品图" },
|
||||
{ key: "tryon", label: "模特上身图" }, { key: "kits", label: "平台套图" }, { key: "creations", label: "自由创作" },
|
||||
{ key: "materials", label: "素材" }, { key: "uploads", label: "我的上传" }, { key: "unclassified", label: "未分类" }
|
||||
{ key: "videopacks", label: "视频成品" }, { key: "others", label: "其他" }
|
||||
];
|
||||
|
||||
// 对齐 api-bridge:工具栏 chip 按 tab 显隐
|
||||
// 对齐 api-bridge:工具栏 chip 按 tab 显隐(视频成品走素材包,不用这些扁平筛选)
|
||||
const LIB_CHIPS: Array<{ key: string; label: string; tabs: LibTab[] }> = [
|
||||
{ key: "gender", label: "性别", tabs: ["people"] },
|
||||
{ key: "age", label: "年龄段", tabs: ["people"] },
|
||||
{ key: "role", label: "角色标签", tabs: ["people"] },
|
||||
{ key: "sceneType", label: "场景类型", tabs: ["scenes"] },
|
||||
{ key: "product", label: "关联商品", tabs: ["products", "tryon", "kits"] },
|
||||
{ key: "project", label: "关联项目", tabs: ["materials"] },
|
||||
{ key: "duration", label: "时长", tabs: ["materials"] },
|
||||
{ key: "kind", label: "资产类型", tabs: ["uploads"] },
|
||||
{ key: "source", label: "来源", tabs: ["people", "scenes", "products", "tryon", "kits", "creations", "uploads"] }
|
||||
{ key: "product", label: "关联商品", tabs: ["tryon", "kits"] },
|
||||
{ key: "kind", label: "资产类型", tabs: ["others"] },
|
||||
{ key: "source", label: "来源", tabs: ["tryon", "kits", "creations", "others"] }
|
||||
];
|
||||
|
||||
// metadata 里可能用的中文属性键(真实存在才渲染,缺则不显;属性区不造假)
|
||||
@@ -396,7 +389,7 @@ function UploadModal({ open, close, onSubmit }: {
|
||||
}
|
||||
|
||||
export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormData) => Promise<unknown> | void; onDelete?: (id: string) => Promise<unknown> | void }) {
|
||||
const [tab, setTab] = useState<LibTab>("people");
|
||||
const [tab, setTab] = useState<LibTab>("tryon");
|
||||
const [query, setQuery] = useState("");
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [openChip, setOpenChip] = useState("");
|
||||
@@ -428,7 +421,10 @@ 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>>({ people: 0, scenes: 0, products: 0, tryon: 0, kits: 0, creations: 0, materials: 0, uploads: 0, unclassified: 0 });
|
||||
const [counts, setCounts] = useState<Record<LibTab, number>>({ tryon: 0, kits: 0, creations: 0, videopacks: 0, others: 0 });
|
||||
// 视频成品:按项目素材包(点包卡 → 弹窗看该项目所有片段)
|
||||
const [packs, setPacks] = useState<import("../types").VideoPack[]>([]);
|
||||
const [openPack, setOpenPack] = useState<import("../types").VideoPack | null>(null);
|
||||
const [facets, setFacets] = useState<{ sources: string[]; kinds: string[]; metadata: Record<string, string[]> }>({ sources: [], kinds: [], metadata: {} });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -448,6 +444,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
||||
// 列表数据(分页 + 过滤)
|
||||
const [reloadFlag, setReloadFlag] = useState(0);
|
||||
useEffect(() => {
|
||||
if (tab === "videopacks") { setLoading(false); return; } // 视频成品走素材包,不拉扁平资产
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
api.assetsPage({
|
||||
@@ -467,6 +464,12 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
||||
|
||||
// tab 计数(徽标)+ 当前 tab 的筛选项(下拉「只列真有的」):切 tab / 上传 / 删除后刷新
|
||||
useEffect(() => { api.assetSummary().then((c) => setCounts((prev) => ({ ...prev, ...c }))).catch(() => {}); }, [reloadFlag]);
|
||||
// 视频成品素材包:进页/刷新就拉一次,顺带把项目包数喂给 tab 徽标
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
api.videoPacks().then((ps) => { if (alive) { setPacks(ps); setCounts((prev) => ({ ...prev, videopacks: ps.length })); } }).catch(() => { if (alive) setPacks([]); });
|
||||
return () => { alive = false; };
|
||||
}, [reloadFlag]);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
api.assetFacets(tab, metaKeys).then((f) => { if (alive) setFacets(f); }).catch(() => {});
|
||||
@@ -523,7 +526,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>资产库</h1>
|
||||
<div className="sub"><span className="mono">// 跨项目复用 · <span id="sub-people">{counts.people}</span> 角色 · <span id="sub-scenes">{counts.scenes}</span> 景 · <span id="sub-products">{counts.products}</span> 商 · <span id="sub-materials">{counts.materials}</span> 素材</span></div>
|
||||
<div className="sub"><span className="mono">// 你的成品 · 图片 {counts.tryon + counts.kits + counts.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))}>
|
||||
@@ -543,6 +546,34 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "videopacks" ? (
|
||||
<div className="packs-grid">
|
||||
{packs.length === 0 ? (
|
||||
<div className="empty-filter">// 还没有视频成品 · 去视频项目生成片段</div>
|
||||
) : (
|
||||
packs.map((pack) => {
|
||||
const first = pack.clips[0];
|
||||
return (
|
||||
<article className="pack-card" key={pack.project_id || pack.project_name} onClick={() => setOpenPack(pack)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenPack(pack); } }}>
|
||||
<div className="placeholder asset-thumb pack-thumb">
|
||||
{first?.url ? (
|
||||
<video src={first.url} muted playsInline preload="metadata" style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} />
|
||||
) : pack.product_cover ? (
|
||||
<img src={pack.product_cover} alt={pack.project_name} loading="lazy" />
|
||||
) : (
|
||||
<span className="ph-frame">无片段</span>
|
||||
)}
|
||||
<span className="pack-count mono">{pack.clips.length} 段</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>
|
||||
) : (
|
||||
<>
|
||||
<div className="toolbar">
|
||||
<div className="search-inline">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
|
||||
@@ -696,6 +727,34 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
||||
)}
|
||||
|
||||
<Pager page={curPage} total={total} pageSize={LIB_PAGE_SIZE} onChange={setPage} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 视频素材包弹窗:看该项目所有视频片段(可播) */}
|
||||
{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">
|
||||
<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>
|
||||
</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>
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* 编辑模式浮动批量操作栏(scope 到资产库;删除/清空/完成) */}
|
||||
<div className={`lib-bulk-bar${selected.size > 0 ? " show" : ""}`} role="toolbar" aria-label="批量操作">
|
||||
|
||||
@@ -248,6 +248,14 @@ export type ModelEntity = {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
// 视频成品「项目素材包」:某项目产出的全部视频片段归一个包(资产库视频成品按此展示)
|
||||
export type VideoPack = {
|
||||
project_id: string | null;
|
||||
project_name: string;
|
||||
product_cover: string; // 商品主图(封面兜底)
|
||||
clips: Array<{ id: string; name: string; url: string }>;
|
||||
};
|
||||
|
||||
export type ProjectStage = {
|
||||
id: string;
|
||||
stage: string;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { chromium } from "playwright";
|
||||
import fs from "node:fs"; import path from "node:path";
|
||||
const BASE="http://localhost:5200"; const OUT=path.resolve("../../../_qa_shots/lib-v2"); fs.mkdirSync(OUT,{recursive:true});
|
||||
const b=await chromium.launch({headless:true}); const r={consoleErrors:[]};
|
||||
const p=await (await b.newContext({viewport:{width:1440,height:900}})).newPage();
|
||||
p.on("console",m=>{if(m.type()==="error")r.consoleErrors.push(m.text())});
|
||||
p.on("pageerror",e=>r.consoleErrors.push("PE:"+e.message));
|
||||
await p.goto(BASE+"/login"); await p.waitForTimeout(400);
|
||||
await p.fill("#auth-username","airshelf"); await p.fill("#auth-pwd","Restraint2026");
|
||||
await p.click("button.btn-cta"); await p.waitForFunction(()=>!location.pathname.startsWith("/login"),{timeout:12000});
|
||||
await p.goto(BASE+"/library"); await p.waitForTimeout(2800);
|
||||
r.tabs=(await p.locator(".tab").allInnerTexts().catch(()=>[])).join(" | ");
|
||||
await p.screenshot({path:path.join(OUT,"lib-default.png"),fullPage:true});
|
||||
// 视频成品
|
||||
await p.locator(".tab",{hasText:"视频成品"}).click(); await p.waitForTimeout(2500);
|
||||
r.packCards=await p.locator(".pack-card").count();
|
||||
await p.screenshot({path:path.join(OUT,"lib-videopacks.png"),fullPage:true});
|
||||
// 点开包
|
||||
if(r.packCards>0){
|
||||
await p.locator(".pack-card").first().click(); await p.waitForTimeout(1800);
|
||||
r.packModalOpen=await p.locator(".pack-modal").count();
|
||||
r.clipCount=await p.locator(".pack-clip").count();
|
||||
await p.screenshot({path:path.join(OUT,"lib-pack-modal.png"),fullPage:true});
|
||||
}
|
||||
await b.close(); console.log(JSON.stringify(r,null,2));
|
||||
Reference in New Issue
Block a user