大量修改UI
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { FormEvent, ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Download, Image as ImageIcon, Images, Info, LayoutGrid, Music, Trash2, Upload, X } from "lucide-react";
|
||||
import { Check, ChevronDown, Download, Image as ImageIcon, Images, Info, LayoutGrid, Music, Search, Settings2, Trash2, Upload, X } from "lucide-react";
|
||||
import { api } from "../api";
|
||||
import { SkeletonGrid } from "../components/loading";
|
||||
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
|
||||
@@ -103,6 +103,19 @@ function freeVideoToPack(asset: Asset): VideoPack {
|
||||
};
|
||||
}
|
||||
|
||||
function createdLabel(iso?: string): string {
|
||||
if (!iso) return "";
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
const now = new Date();
|
||||
const startToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const startThat = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
const diffDays = Math.round((startToday.getTime() - startThat.getTime()) / 86400000);
|
||||
if (diffDays === 0) return "今天";
|
||||
if (diffDays === 1) return "昨天";
|
||||
return `${String(date.getMonth() + 1).padStart(2, "0")}.${String(date.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function videoPackKey(pack: VideoPack): string {
|
||||
return pack.project_id || pack.clips[0]?.id || pack.project_name;
|
||||
}
|
||||
@@ -266,7 +279,7 @@ function AssetDetailModal({ asset, close, onZoom }: {
|
||||
<span className="ic"><Info size={14} /></span>
|
||||
<span className="t">简介</span>
|
||||
</div>
|
||||
<p className="adx-intro">{intro || <span className="adx-muted">// 暂无简介</span>}</p>
|
||||
<p className="adx-intro">{intro || <span className="adx-muted">暂无简介</span>}</p>
|
||||
{tags.length > 0 && (
|
||||
<div className="adx-tags">
|
||||
{tags.map((t) => <span className="adx-tag" key={t}>{t}</span>)}
|
||||
@@ -316,7 +329,7 @@ function AssetDetailModal({ asset, close, onZoom }: {
|
||||
<Download size={13} /> 下载
|
||||
</a>
|
||||
)}
|
||||
<span className="adx-foot-meta mono">// {catLabel} · {srcLabel} · {createdAt}</span>
|
||||
<span className="adx-foot-meta mono">{catLabel} · {srcLabel} · {createdAt}</span>
|
||||
<button className="btn btn-primary" type="button" onClick={close}>完成</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -376,7 +389,7 @@ function UploadModal({ open, close, onSubmit }: {
|
||||
<div className={`modal-bg${show ? " show" : ""}`} onClick={close}>
|
||||
<div className="modal upload-modal" onClick={(event) => event.stopPropagation()}>
|
||||
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
||||
<div className="modal-h"><div className="ic-m"><Upload size={16} /></div><div className="ti">上传资产<span>// UPLOAD</span></div><button className="x modal-x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button></div>
|
||||
<div className="modal-h"><div className="ic-m"><Upload size={16} /></div><div className="ti">上传资产</div><button className="x modal-x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button></div>
|
||||
<form className="upload-modal-form" onSubmit={submit}>
|
||||
<div className="modal-b">
|
||||
<div className="field">
|
||||
@@ -424,7 +437,7 @@ function UploadModal({ open, close, onSubmit }: {
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-f">
|
||||
<span className="upload-foot-meta mono">{file ? <><span className="accent">{file.name}</span> · {(file.size / 1024 / 1024).toFixed(2)} MB</> : "// 选择一个文件"}</span>
|
||||
<span className="upload-foot-meta mono">{file ? <><span className="accent">{file.name}</span> · {(file.size / 1024 / 1024).toFixed(2)} MB</> : "选择一个文件"}</span>
|
||||
<button className="btn" type="button" onClick={close}>取消</button>
|
||||
<button className="btn btn-primary" type="submit" disabled={!file || uploading}>{uploading ? "上传中…" : "上传资产"}</button>
|
||||
</div>
|
||||
@@ -460,10 +473,6 @@ export function LibraryPage({ onUpload, onDeleteMany }: { onUpload: (formData: F
|
||||
// 资产详情弹窗 + 灯箱(详情里点大图再放大)
|
||||
const [detail, setDetail] = useState<Asset | null>(null);
|
||||
const [preview, setPreview] = useState<{ src: string; kind: "image" | "video" | "audio"; name: string } | null>(null);
|
||||
useEffect(() => {
|
||||
document.body.classList.toggle("edit-mode", editMode);
|
||||
return () => document.body.classList.remove("edit-mode");
|
||||
}, [editMode]);
|
||||
const toggleSelect = (id: string) => setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
@@ -593,7 +602,7 @@ export function LibraryPage({ onUpload, onDeleteMany }: { onUpload: (formData: F
|
||||
useEffect(() => {
|
||||
if (!openChip) return;
|
||||
const close = (event: MouseEvent) => {
|
||||
if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenChip("");
|
||||
if (!(event.target as HTMLElement).closest(".lib-select")) setOpenChip("");
|
||||
};
|
||||
document.addEventListener("click", close);
|
||||
return () => document.removeEventListener("click", close);
|
||||
@@ -643,325 +652,350 @@ export function LibraryPage({ onUpload, onDeleteMany }: { onUpload: (formData: F
|
||||
}
|
||||
}
|
||||
|
||||
const tabLabel = LIB_TABS.find((item) => item.key === tab)?.label || "资产";
|
||||
const shownCount = isVideoPackTab ? shownPacks.length : (isBatchTab ? batches.length : shown.length);
|
||||
const shownTotal = isVideoPackTab ? filteredPacks.length : total;
|
||||
const emptyHint = isVideoPackTab
|
||||
? (debouncedQuery ? "没有匹配的视频成品" : "还没有视频成品 · 去视频项目生成片段")
|
||||
: (hasFilter ? "没有找到符合条件的资产,请调整筛选后重试" : "当前分类暂无真实资产");
|
||||
|
||||
return (
|
||||
<section className="library-page">
|
||||
<div className="page-head">
|
||||
<section className={`library-page${editMode ? " manage-mode" : ""}`}>
|
||||
<header className="lib-head">
|
||||
<div>
|
||||
<h1>资产库</h1>
|
||||
<div className="sub"><span className="mono">// 你的成品 · 图片 {imageBatchCount} 批 · 视频 {counts.video_creations} 条 · 视频成品 {counts.videopacks} 包</span></div>
|
||||
<h1>成品库</h1>
|
||||
<p>集中管理图片、视频与场景成品 · 图片 {imageBatchCount} 批 · 视频 {counts.video_creations} 条 · 视频成品 {counts.videopacks} 包</p>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className={`btn btn-edit-toggle${editMode ? " active" : ""}`} type="button" id="lib-manage-btn" onClick={() => (editMode ? exitEdit() : setEditMode(true))}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m3 7 2 2 4-4" /><path d="m3 17 2 2 4-4" /><path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" /></svg>
|
||||
<span className="lib-manage-label">{editMode ? "完成" : "管理资产"}</span>
|
||||
<div className="lib-actions">
|
||||
<button className={`lib-ghost${editMode ? " is-on" : ""}`} type="button" id="lib-manage-btn" onClick={() => (editMode ? exitEdit() : setEditMode(true))}>
|
||||
<Settings2 />
|
||||
<span>{editMode ? "完成" : "管理资产"}</span>
|
||||
</button>
|
||||
{/* 上传资产入口已隐藏:资产由 AI 生成流水线自动入库,不开放手动上传 */}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="lib-toolbar">
|
||||
<div className="lib-seg" id="asset-tabs" role="tablist" aria-label="资产类型">
|
||||
{LIB_TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
data-tab={t.key}
|
||||
aria-selected={tab === t.key}
|
||||
className={`lib-seg-btn${tab === t.key ? " active" : ""}`}
|
||||
onClick={() => setTab(t.key)}
|
||||
>
|
||||
{t.label}
|
||||
<span className="lib-seg-count">{counts[t.key]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="lib-toolbar-right">
|
||||
<label className="lib-search">
|
||||
<Search />
|
||||
<input id="search-input" placeholder={isVideoPackTab ? "搜索项目名" : "搜索资产名称、标签"} value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
</label>
|
||||
{LIB_CHIPS.filter((chip) => chip.tabs.includes(tab)).map((chip) => {
|
||||
if (chip.key === "source") {
|
||||
return (
|
||||
<div className={`lib-select${openChip === "source" ? " open" : ""}`} data-key="source" key="source">
|
||||
<button className="lib-select-trigger" type="button" aria-haspopup="listbox" aria-expanded={openChip === "source"} onClick={() => setOpenChip((c) => (c === "source" ? "" : "source"))}>
|
||||
<span>{srcFilter ? SOURCE_LABELS[srcFilter] || srcFilter : "来源"}</span>
|
||||
<ChevronDown />
|
||||
</button>
|
||||
<div className="lib-select-menu" role="listbox">
|
||||
<button className={`lib-select-option${!srcFilter ? " selected" : ""}`} type="button" onClick={() => { setSrcFilter(""); setOpenChip(""); }}>全部来源</button>
|
||||
{srcOptions.map((src) => (
|
||||
<button className={`lib-select-option${srcFilter === src ? " selected" : ""}`} type="button" key={src} onClick={() => { setSrcFilter(src); setOpenChip(""); }}>{SOURCE_LABELS[src] || src}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (chip.key === "kind") {
|
||||
return (
|
||||
<div className={`lib-select${openChip === "kind" ? " open" : ""}`} data-key="kind" key="kind">
|
||||
<button className="lib-select-trigger" type="button" aria-haspopup="listbox" aria-expanded={openChip === "kind"} onClick={() => setOpenChip((c) => (c === "kind" ? "" : "kind"))}>
|
||||
<span>{kindFilter ? KIND_LABELS[kindFilter] || kindFilter : "资产类型"}</span>
|
||||
<ChevronDown />
|
||||
</button>
|
||||
<div className="lib-select-menu" role="listbox">
|
||||
<button className={`lib-select-option${!kindFilter ? " selected" : ""}`} type="button" onClick={() => { setKindFilter(""); setOpenChip(""); }}>全部类型</button>
|
||||
{kindOptions.map((k) => (
|
||||
<button className={`lib-select-option${kindFilter === k ? " selected" : ""}`} type="button" key={k} onClick={() => { setKindFilter(k); setOpenChip(""); }}>{KIND_LABELS[k] || k}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (chip.key === "product") {
|
||||
const curTitle = facets.products.find((p) => p.id === productFilter)?.title || "";
|
||||
return (
|
||||
<div className={`lib-select${openChip === "product" ? " open" : ""}`} data-key="product" key="product">
|
||||
<button className="lib-select-trigger" type="button" aria-haspopup="listbox" aria-expanded={openChip === "product"} onClick={() => setOpenChip((c) => (c === "product" ? "" : "product"))}>
|
||||
<span>{productFilter ? (curTitle || "关联商品") : "关联商品"}</span>
|
||||
<ChevronDown />
|
||||
</button>
|
||||
<div className="lib-select-menu" role="listbox">
|
||||
<button className={`lib-select-option${!productFilter ? " selected" : ""}`} type="button" onClick={() => { setProductFilter(""); setOpenChip(""); }}>全部商品</button>
|
||||
{facets.products.map((p) => (
|
||||
<button className={`lib-select-option${productFilter === p.id ? " selected" : ""}`} type="button" key={p.id} onClick={() => { setProductFilter(p.id); setOpenChip(""); }}>{p.title}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const opts = metaOptions(chip.key);
|
||||
const cur = metaFilter[chip.key] || "";
|
||||
return (
|
||||
<div className={`lib-select${openChip === chip.key ? " open" : ""}`} data-key={chip.key} key={chip.key}>
|
||||
<button className="lib-select-trigger" type="button" aria-haspopup="listbox" aria-expanded={openChip === chip.key} onClick={() => setOpenChip((c) => (c === chip.key ? "" : chip.key))}>
|
||||
<span>{cur || chip.label}</span>
|
||||
<ChevronDown />
|
||||
</button>
|
||||
<div className="lib-select-menu" role="listbox">
|
||||
<button className={`lib-select-option${!cur ? " selected" : ""}`} type="button" onClick={() => { setMetaFilter((m) => ({ ...m, [chip.key]: "" })); setOpenChip(""); }}>全部{chip.label}</button>
|
||||
{opts.map((v) => (
|
||||
<button className={`lib-select-option${cur === v ? " selected" : ""}`} type="button" key={v} onClick={() => { setMetaFilter((m) => ({ ...m, [chip.key]: v })); setOpenChip(""); }}>{v}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className={`lib-select${openChip === "sort" ? " open" : ""}`} data-key="sort">
|
||||
<button className="lib-select-trigger" type="button" aria-haspopup="listbox" aria-expanded={openChip === "sort"} onClick={() => setOpenChip((c) => (c === "sort" ? "" : "sort"))}>
|
||||
<span>{isVideoPackTab ? (sortDesc ? "片段最多" : "片段最少") : (sortDesc ? "最近添加" : "最早添加")}</span>
|
||||
<ChevronDown />
|
||||
</button>
|
||||
<div className="lib-select-menu" role="listbox">
|
||||
{isVideoPackTab ? (
|
||||
<>
|
||||
<button className={`lib-select-option${sortDesc ? " selected" : ""}`} type="button" onClick={() => { setSortDesc(true); setOpenChip(""); }}>片段最多</button>
|
||||
<button className={`lib-select-option${!sortDesc ? " selected" : ""}`} type="button" onClick={() => { setSortDesc(false); setOpenChip(""); }}>片段最少</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button className={`lib-select-option${sortDesc ? " selected" : ""}`} type="button" onClick={() => { setSortDesc(true); setOpenChip(""); }}>最近添加</button>
|
||||
<button className={`lib-select-option${!sortDesc ? " selected" : ""}`} type="button" onClick={() => { setSortDesc(false); setOpenChip(""); }}>最早添加</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tabs" id="asset-tabs">
|
||||
{LIB_TABS.map((t) => (
|
||||
<div className={`tab${tab === t.key ? " active" : ""}`} key={t.key} data-tab={t.key} onClick={() => setTab(t.key)}>{t.label} <span className="count">{counts[t.key]}</span></div>
|
||||
))}
|
||||
<div className="lib-section">
|
||||
<h2>{tabLabel}</h2>
|
||||
<p>按创作任务类型归档的最终资产 · 显示 {shownCount} / {shownTotal} {resultUnit}{hasFilter || (isVideoPackTab && debouncedQuery) ? "(已筛选)" : ""}{loading && tab !== "videopacks" ? " · 加载中…" : ""}</p>
|
||||
</div>
|
||||
|
||||
{tab === "videopacks" ? (
|
||||
<>
|
||||
<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>
|
||||
<input className="input" placeholder="搜索项目名" value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
</div>
|
||||
<span className="spacer"></span>
|
||||
<div className={`chip-wrap${openChip === "sort" ? " open" : ""}`} data-key="sort">
|
||||
<button className="chip" type="button" onClick={() => setOpenChip((c) => (c === "sort" ? "" : "sort"))}>
|
||||
<span className="chip-label">{sortDesc ? "片段最多" : "片段最少"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
|
||||
</button>
|
||||
<div className="chip-menu align-right">
|
||||
<div className={`mi${sortDesc ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setSortDesc(true); setOpenChip(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>片段最多
|
||||
</div>
|
||||
<div className={`mi${!sortDesc ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setSortDesc(false); setOpenChip(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>片段最少
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="result-meta">// 显示 <span className="count">{shownPacks.length}</span> / {filteredPacks.length} 个素材包{debouncedQuery ? "(已筛选)" : ""}</div>
|
||||
|
||||
<div className="packs-grid">
|
||||
{filteredPacks.length === 0 ? (
|
||||
<div className="empty-filter">// {debouncedQuery ? "没有匹配的视频成品" : "还没有视频成品 · 去视频项目生成片段"}</div>
|
||||
) : (
|
||||
shownPacks.map((pack) => {
|
||||
const first = pack.clips[0];
|
||||
// 视频成品的所有片段 id:批量删除整个素材包
|
||||
const packId = videoPackKey(pack);
|
||||
const isSelected = selectedVideoPacks.has(packId);
|
||||
const onPackClick = editMode ? () => toggleVideoPack(packId) : () => setOpenPack(pack);
|
||||
return (
|
||||
<article className={`pack-card batch-card${editMode && isSelected ? " selected" : ""}`} key={packId} onClick={onPackClick} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onPackClick(); } }}>
|
||||
<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>
|
||||
<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>
|
||||
<Pager page={packCurPage} total={filteredPacks.length} pageSize={LIB_PAGE_SIZE} onChange={setPage} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
<input className="input" id="search-input" placeholder="搜索资产名称、标签" value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
</div>
|
||||
{LIB_CHIPS.filter((chip) => chip.tabs.includes(tab)).map((chip) => {
|
||||
// 仅「来源 / 资产类型」有真实字段可筛;其余(性别/年龄/角色/场景类型/关联商品/关联项目/时长)Asset 无对应字段,保持静态
|
||||
if (chip.key === "source") {
|
||||
return (
|
||||
<div className={`chip-wrap${openChip === "source" ? " open" : ""}`} data-key="source" key="source">
|
||||
<button className={`chip${srcFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "source" ? "" : "source"))}>
|
||||
<span className="chip-label">{srcFilter ? SOURCE_LABELS[srcFilter] || srcFilter : "来源"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
|
||||
</button>
|
||||
<div className="chip-menu">
|
||||
<div className={`mi${!srcFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setSrcFilter(""); setOpenChip(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>全部来源
|
||||
</div>
|
||||
{srcOptions.length > 0 && <div className="mi-sep" />}
|
||||
{srcOptions.map((src) => (
|
||||
<div className={`mi${srcFilter === src ? " selected" : ""}`} key={src} role="button" tabIndex={0} onClick={() => { setSrcFilter(src); setOpenChip(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{SOURCE_LABELS[src] || src}
|
||||
filteredPacks.length === 0 ? (
|
||||
<div className="lib-empty" role="status"><Images /><strong>{emptyHint}</strong><span>视频项目生成片段后会自动进入视频成品</span></div>
|
||||
) : (
|
||||
<>
|
||||
<div className="lib-grid" id="pack-grid">
|
||||
{shownPacks.map((pack) => {
|
||||
const first = pack.clips[0];
|
||||
const packId = videoPackKey(pack);
|
||||
const isSelected = selectedVideoPacks.has(packId);
|
||||
const onPackClick = editMode ? () => toggleVideoPack(packId) : () => setOpenPack(pack);
|
||||
return (
|
||||
<article className={`lib-card pack-card batch-card${editMode && isSelected ? " selected" : ""}`} key={packId} onClick={onPackClick} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onPackClick(); } }}>
|
||||
<span className="lib-check" aria-hidden="true"><Check /></span>
|
||||
<div className="lib-thumb portrait">
|
||||
{first?.url ? (
|
||||
<video src={first.url} muted playsInline preload="metadata" />
|
||||
) : pack.product_cover ? (
|
||||
<img src={pack.product_cover} alt={pack.project_name} loading="lazy" />
|
||||
) : (
|
||||
<span className="ph-frame">无片段</span>
|
||||
)}
|
||||
<span className="lib-count">{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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (chip.key === "kind") {
|
||||
return (
|
||||
<div className={`chip-wrap${openChip === "kind" ? " open" : ""}`} data-key="kind" key="kind">
|
||||
<button className={`chip${kindFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "kind" ? "" : "kind"))}>
|
||||
<span className="chip-label">{kindFilter ? KIND_LABELS[kindFilter] || kindFilter : "资产类型"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
|
||||
</button>
|
||||
<div className="chip-menu">
|
||||
<div className={`mi${!kindFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setKindFilter(""); setOpenChip(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>全部类型
|
||||
</div>
|
||||
{kindOptions.length > 0 && <div className="mi-sep" />}
|
||||
{kindOptions.map((k) => (
|
||||
<div className={`mi${kindFilter === k ? " selected" : ""}`} key={k} role="button" tabIndex={0} onClick={() => { setKindFilter(k); setOpenChip(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{KIND_LABELS[k] || k}
|
||||
<div className="lib-meta">
|
||||
<h3>{pack.project_name}</h3>
|
||||
<p>视频成品 · {pack.clips.length} 段</p>
|
||||
<div className="lib-line">
|
||||
<span className="lib-tag blue">视频素材包</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (chip.key === "product") {
|
||||
// 关联商品:选项走 facets.products(本 tab 下真实关联到的商品),选中后 product=<id> 进列表/批次请求
|
||||
const curTitle = facets.products.find((p) => p.id === productFilter)?.title || "";
|
||||
return (
|
||||
<div className={`chip-wrap${openChip === "product" ? " open" : ""}`} data-key="product" key="product">
|
||||
<button className={`chip${productFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "product" ? "" : "product"))}>
|
||||
<span className="chip-label">{productFilter ? (curTitle || "关联商品") : "关联商品"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
|
||||
</button>
|
||||
<div className="chip-menu">
|
||||
<div className={`mi${!productFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setProductFilter(""); setOpenChip(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>全部商品
|
||||
</div>
|
||||
{facets.products.length > 0 && <div className="mi-sep" />}
|
||||
{facets.products.map((p) => (
|
||||
<div className={`mi${productFilter === p.id ? " selected" : ""}`} key={p.id} role="button" tabIndex={0} onClick={() => { setProductFilter(p.id); setOpenChip(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{p.title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 其余维度走 asset.metadata 真实派生:有标记数据才出可选项,否则只「全部」
|
||||
const opts = metaOptions(chip.key);
|
||||
const cur = metaFilter[chip.key] || "";
|
||||
return (
|
||||
<div className={`chip-wrap${openChip === chip.key ? " open" : ""}`} data-key={chip.key} key={chip.key}>
|
||||
<button className={`chip${cur ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === chip.key ? "" : chip.key))}>
|
||||
<span className="chip-label">{cur || chip.label}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
|
||||
</button>
|
||||
<div className="chip-menu">
|
||||
<div className={`mi${!cur ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setMetaFilter((m) => ({ ...m, [chip.key]: "" })); setOpenChip(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>全部{chip.label}
|
||||
</div>
|
||||
{opts.length > 0 && <div className="mi-sep" />}
|
||||
{opts.map((v) => (
|
||||
<div className={`mi${cur === v ? " selected" : ""}`} key={v} role="button" tabIndex={0} onClick={() => { setMetaFilter((m) => ({ ...m, [chip.key]: v })); setOpenChip(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{v}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<span className="spacer"></span>
|
||||
<div className={`chip-wrap${openChip === "sort" ? " open" : ""}`} data-key="sort">
|
||||
<button className="chip" type="button" onClick={() => setOpenChip((c) => (c === "sort" ? "" : "sort"))}>
|
||||
<span className="chip-label">{sortDesc ? "最近添加" : "最早添加"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
|
||||
</button>
|
||||
<div className="chip-menu align-right">
|
||||
<div className={`mi${sortDesc ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setSortDesc(true); setOpenChip(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>最近添加
|
||||
</div>
|
||||
<div className={`mi${!sortDesc ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setSortDesc(false); setOpenChip(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>最早添加
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="result-meta" id="result-meta">// 显示 <span className="count">{isBatchTab ? batches.length : shown.length}</span> / {total} {resultUnit}{hasFilter ? "(已筛选)" : ""}{loading ? " · 加载中…" : ""}</div>
|
||||
|
||||
{isBatchTab ? (
|
||||
<Pager page={packCurPage} total={filteredPacks.length} pageSize={LIB_PAGE_SIZE} onChange={setPage} />
|
||||
</>
|
||||
)
|
||||
) : isBatchTab ? (
|
||||
batches.length ? (
|
||||
<div className="packs-grid" id="batch-grid">
|
||||
{batches.map((batch) => {
|
||||
const isSel = selectedBatches.has(batch.batch_id);
|
||||
// 编辑态:整卡点击 = 选中/取消该批次(走 lib-bulk-bar 批量删);非编辑态:点开批次弹窗
|
||||
const onBatchClick = editMode ? () => toggleBatch(batch.batch_id) : () => setOpenBatch(batch);
|
||||
return (
|
||||
<article className={`pack-card batch-card${editMode && isSel ? " selected" : ""}`} key={batch.batch_id} onClick={onBatchClick} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onBatchClick(); } }}>
|
||||
<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>
|
||||
<div className="placeholder asset-thumb pack-thumb">
|
||||
{batch.cover ? <img src={batch.cover} alt={batch.name} loading="lazy" /> : <span className="ph-frame">无图</span>}
|
||||
<span className="pack-count mono">{batch.count} 张</span>
|
||||
</div>
|
||||
<div className="asset-body"><div className="asset-name">{batch.name}</div><div className="asset-meta mono">生成批次</div></div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<>
|
||||
<div className="lib-grid" id="batch-grid">
|
||||
{batches.map((batch) => {
|
||||
const isSel = selectedBatches.has(batch.batch_id);
|
||||
const onBatchClick = editMode ? () => toggleBatch(batch.batch_id) : () => setOpenBatch(batch);
|
||||
return (
|
||||
<article className={`lib-card pack-card batch-card${editMode && isSel ? " selected" : ""}`} key={batch.batch_id} onClick={onBatchClick} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onBatchClick(); } }}>
|
||||
<span className="lib-check" aria-hidden="true"><Check /></span>
|
||||
<div className="lib-thumb">
|
||||
{batch.cover ? <img src={batch.cover} alt={batch.name} loading="lazy" /> : <span className="ph-frame">无图</span>}
|
||||
<span className="lib-count">{batch.count} 张</span>
|
||||
</div>
|
||||
<div className="lib-meta">
|
||||
<h3>{batch.name}</h3>
|
||||
<p>{tabLabel} · {batch.count} 张</p>
|
||||
<div className="lib-line">
|
||||
<span className="lib-tag blue">生成批次</span>
|
||||
<span className="lib-tag">{createdLabel(batch.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Pager page={curPage} total={total} pageSize={LIB_PAGE_SIZE} onChange={setPage} />
|
||||
</>
|
||||
) : loading ? (
|
||||
<SkeletonGrid count={8} />
|
||||
) : (
|
||||
<div className="empty-filter">// 当前分类暂无真实资产</div>
|
||||
<div className="lib-empty" role="status"><Images /><strong>{emptyHint}</strong><span>对应创作流程生成后会自动入库</span></div>
|
||||
)
|
||||
) : isVideoCreationTab ? (
|
||||
shown.length ? (
|
||||
<div className="packs-grid" id="free-video-grid">
|
||||
<>
|
||||
<div className="lib-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={`lib-card asset-card 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="lib-check" aria-hidden="true"><Check /></span>
|
||||
{onDeleteMany && (
|
||||
<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="lib-thumb portrait">
|
||||
{pack.product_cover ? (
|
||||
<img src={pack.product_cover} alt={pack.project_name} loading="lazy" />
|
||||
) : first?.url ? (
|
||||
<video src={first.url} muted playsInline preload="metadata" />
|
||||
) : (
|
||||
<span className="ph-frame">无视频</span>
|
||||
)}
|
||||
<span className="lib-count">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="lib-meta">
|
||||
<h3>{pack.project_name}</h3>
|
||||
<p>视频自由创作 · 1 条</p>
|
||||
<div className="lib-line">
|
||||
<span className="lib-tag blue">视频</span>
|
||||
<span className="lib-tag">{createdLabel(asset.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Pager page={curPage} total={total} pageSize={LIB_PAGE_SIZE} onChange={setPage} />
|
||||
</>
|
||||
) : loading ? (
|
||||
<SkeletonGrid count={8} />
|
||||
) : (
|
||||
<div className="lib-empty" role="status"><Images /><strong>{emptyHint}</strong><span>对应创作流程生成后会自动入库</span></div>
|
||||
)
|
||||
) : shown.length ? (
|
||||
<>
|
||||
<div className="lib-grid" id="asset-grid">
|
||||
{shown.map((asset) => {
|
||||
const pack = freeVideoToPack(asset);
|
||||
const first = pack.clips[0];
|
||||
const cover = asset.files?.find((f) => f.is_primary)?.preview_url || asset.files?.[0]?.preview_url || "";
|
||||
const isVideo = asset.asset_type === "video";
|
||||
const isAudio = asset.asset_type === "audio";
|
||||
const isSelected = selected.has(asset.id);
|
||||
const onVideoClick = editMode ? () => toggleSelect(asset.id) : () => setOpenPack(pack);
|
||||
const catLabel = (asset.category === "upload" ? KIND_LABELS[asset.asset_type] : CATEGORY_LABELS[asset.category || ""]) || asset.category;
|
||||
const srcLabel = SOURCE_LABELS[asset.source || ""] || asset.source;
|
||||
const missingTri = personMissingTri(asset);
|
||||
const onCardClick = editMode ? () => toggleSelect(asset.id) : () => setDetail(asset);
|
||||
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 && onDeleteMany && (
|
||||
<button className="card-del-btn" type="button" title="删除视频" onClick={(event) => { event.stopPropagation(); setConfirmIds([asset.id]); }}>
|
||||
<article className={`lib-card asset-card ${asset.asset_type}${isSelected ? " selected" : ""}`} key={asset.id} onClick={onCardClick} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onCardClick(); } }}>
|
||||
<span className="lib-check" aria-hidden="true"><Check /></span>
|
||||
{onDeleteMany && (
|
||||
<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>
|
||||
<div className={`lib-thumb${isVideo ? " portrait" : ""}`} style={{ position: "relative" }}>
|
||||
{missingTri && (
|
||||
<span className="tri-missing-badge" tabIndex={0} role="button" aria-label="缺三视图,查看说明" onClick={(e) => e.stopPropagation()}>
|
||||
<span className="ico" aria-hidden="true"></span>
|
||||
<span className="lbl-mono">缺三视图</span>
|
||||
<span className="tri-missing-pop" role="tooltip">
|
||||
<span className="pop-h">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 9v4M12 17h.01" /><path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /></svg>
|
||||
MISSING TRI-VIEW
|
||||
</span>
|
||||
<span className="pop-body">手动上传的人物未生成 <b>正 / 侧 / 背</b> 三视图。直接进入图片或视频生成,人脸/服饰一致性可能下降。</span>
|
||||
<span className="pop-tip">建议:前往 <b>图片生成</b> 先补齐三视图,再发起后续生成。</span>
|
||||
</span>
|
||||
</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>
|
||||
{cover && isVideo && (
|
||||
<>
|
||||
<video src={cover} muted playsInline preload="metadata" />
|
||||
<span className="lib-play-badge" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg></span>
|
||||
</>
|
||||
)}
|
||||
{isAudio && (
|
||||
<span className="lib-audio-ph" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" /></svg>
|
||||
<span className="mono">AUDIO</span>
|
||||
</span>
|
||||
)}
|
||||
{cover && !isVideo && !isAudio && <img src={cover} alt={asset.name} loading="lazy" />}
|
||||
{!cover && !isAudio && <span className="ph-frame">{KIND_LABELS[asset.asset_type] || asset.asset_type}</span>}
|
||||
</div>
|
||||
<div className="lib-meta">
|
||||
<h3>{asset.name}</h3>
|
||||
<p>{catLabel} · {srcLabel}</p>
|
||||
<div className="lib-line">
|
||||
<span className="lib-tag blue">{KIND_LABELS[asset.asset_type] || asset.asset_type}</span>
|
||||
<span className="lib-tag">{createdLabel(asset.created_at) || (asset.category === "final_video" ? (asset.created_at || "").slice(0, 16).replace("T", " ") : srcLabel)}</span>
|
||||
</div>
|
||||
</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) => {
|
||||
const cover = asset.files?.find((f) => f.is_primary)?.preview_url || asset.files?.[0]?.preview_url || "";
|
||||
const isVideo = asset.asset_type === "video";
|
||||
const isAudio = asset.asset_type === "audio";
|
||||
const isSelected = selected.has(asset.id);
|
||||
// 卡片 meta:upload 分类下「上传 · 上传」无信息量,改显资产类型;其余显分类中文
|
||||
const catLabel = (asset.category === "upload" ? KIND_LABELS[asset.asset_type] : CATEGORY_LABELS[asset.category || ""]) || asset.category;
|
||||
const srcLabel = SOURCE_LABELS[asset.source || ""] || asset.source;
|
||||
// 成片卡补导出时间(created_at = 导出入库时刻),格式 2026-06-09 18:21
|
||||
const exportedAt = asset.category === "final_video" ? (asset.created_at || "").slice(0, 16).replace("T", " ") : "";
|
||||
const missingTri = personMissingTri(asset);
|
||||
// 编辑态:整卡点击 = 多选;非编辑态:整卡点击 = 开详情
|
||||
const onCardClick = editMode ? () => toggleSelect(asset.id) : () => setDetail(asset);
|
||||
return (
|
||||
<article className={`asset-card ${asset.asset_type}${isSelected ? " selected" : ""}`} key={asset.id} onClick={onCardClick} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onCardClick(); } }}>
|
||||
<span className="card-check" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="3 8 7 12 13 4" /></svg></span>
|
||||
{editMode && onDeleteMany && (
|
||||
<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" style={{ position: "relative" }}>
|
||||
{missingTri && (
|
||||
<span className="tri-missing-badge" tabIndex={0} role="button" aria-label="缺三视图,查看说明" onClick={(e) => e.stopPropagation()}>
|
||||
<span className="ico" aria-hidden="true"></span>
|
||||
<span className="lbl-mono">缺三视图</span>
|
||||
<span className="tri-missing-pop" role="tooltip">
|
||||
<span className="pop-h">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 9v4M12 17h.01" /><path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /></svg>
|
||||
MISSING TRI-VIEW
|
||||
</span>
|
||||
<span className="pop-body">手动上传的人物未生成 <b>正 / 侧 / 背</b> 三视图。直接进入图片或视频生成,人脸/服饰一致性可能下降。</span>
|
||||
<span className="pop-tip">建议:前往 <b>图片生成</b> 先补齐三视图,再发起后续生成。</span>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{cover && isVideo && (
|
||||
<>
|
||||
<video src={cover} muted playsInline preload="metadata" style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} />
|
||||
<span className="lib-play-badge" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg></span>
|
||||
</>
|
||||
)}
|
||||
{isAudio && (
|
||||
// 音频没有可视封面,preview_url 是音频文件本身,塞 <img> 必裂图 → 用默认音符占位
|
||||
<span className="lib-audio-ph" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" /></svg>
|
||||
<span className="mono">AUDIO</span>
|
||||
</span>
|
||||
)}
|
||||
{cover && !isVideo && !isAudio && <img src={cover} alt={asset.name} loading="lazy" />}
|
||||
{!cover && !isAudio && <span className="ph-frame">{KIND_LABELS[asset.asset_type] || asset.asset_type}</span>}
|
||||
</div>
|
||||
<div className="asset-body"><div className="asset-name">{asset.name}</div><div className="asset-meta">{catLabel} · {srcLabel}{exportedAt && <span className="asset-meta-time"> · {exportedAt}</span>}</div></div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Pager page={curPage} total={total} pageSize={LIB_PAGE_SIZE} onChange={setPage} />
|
||||
</>
|
||||
) : loading ? (
|
||||
<SkeletonGrid count={8} />
|
||||
) : (
|
||||
<div className="empty-filter">// 当前分类暂无真实资产</div>
|
||||
<div className="lib-empty" role="status"><Images /><strong>{emptyHint}</strong><span>对应创作流程生成后会自动入库</span></div>
|
||||
)}
|
||||
|
||||
<Pager page={curPage} total={total} pageSize={LIB_PAGE_SIZE} onChange={setPage} />
|
||||
</>
|
||||
)}
|
||||
<div className={`lib-sel-bar${editMode ? " active" : ""}`} role="toolbar" aria-label="批量操作">
|
||||
<div className="lib-sel-copy">
|
||||
<strong>已选择 {selCount} 个资产</strong>
|
||||
<span>确认后移入垃圾桶,可在垃圾桶中恢复</span>
|
||||
</div>
|
||||
<div className="lib-sel-actions">
|
||||
<button className="lib-sel-cancel" type="button" onClick={exitEdit}>取消</button>
|
||||
<button
|
||||
className="lib-sel-confirm"
|
||||
type="button"
|
||||
disabled={selCount === 0}
|
||||
onClick={() => { const ids = bulkDeleteIds(); if (ids.length) setConfirmIds(ids); }}
|
||||
>
|
||||
确认删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 视频素材包弹窗:看该项目所有视频片段(可播) */}
|
||||
{openPack && createPortal(
|
||||
@@ -970,7 +1004,7 @@ export function LibraryPage({ onUpload, onDeleteMany }: { onUpload: (formData: F
|
||||
<div className="pack-modal-h">
|
||||
<div>
|
||||
<h2>{openPack.project_name}</h2>
|
||||
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>// {openPackIsFreeVideo ? "视频自由创作" : "视频素材包"} · {openPack.clips.length} {openPackIsFreeVideo ? "条" : "段"}</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>
|
||||
@@ -997,7 +1031,7 @@ export function LibraryPage({ onUpload, onDeleteMany }: { onUpload: (formData: F
|
||||
<div className="pack-modal-h">
|
||||
<div>
|
||||
<h2>{openBatch.name}</h2>
|
||||
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>// 生成批次 · {openBatch.count} 张</span>
|
||||
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>生成批次 · {openBatch.count} 张</span>
|
||||
</div>
|
||||
<button className="x" type="button" aria-label="关闭" onClick={() => setOpenBatch(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>
|
||||
@@ -1033,18 +1067,6 @@ export function LibraryPage({ onUpload, onDeleteMany }: { onUpload: (formData: F
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* 编辑模式浮动批量操作栏(scope 到资产库;删除/清空/完成) */}
|
||||
<div className={`lib-bulk-bar${selCount > 0 ? " show" : ""}`} role="toolbar" aria-label="批量操作">
|
||||
<span className="ct">已选 <b>{selCount}</b> {isBatchTab || isVideoPackTab ? "批" : "项"}</span>
|
||||
<button className="clear-sel" type="button" onClick={clearSelection}>清空</button>
|
||||
<span className="sep" />
|
||||
<button className="danger" type="button" disabled={selCount === 0} onClick={() => { const ids = bulkDeleteIds(); if (ids.length) setConfirmIds(ids); }}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" 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>
|
||||
<button type="button" onClick={exitEdit}>完成</button>
|
||||
</div>
|
||||
|
||||
<AssetDetailModal
|
||||
asset={detail}
|
||||
close={() => setDetail(null)}
|
||||
@@ -1056,7 +1078,7 @@ export function LibraryPage({ onUpload, onDeleteMany }: { onUpload: (formData: F
|
||||
<ConfirmModal
|
||||
open={Boolean(confirmIds && confirmIds.length)}
|
||||
title="删除资产"
|
||||
subtitle="// DELETE ASSET"
|
||||
|
||||
icon={<Trash2 size={16} />}
|
||||
detail={`已选中 ${confirmIds?.length || 0} 个资产。删除的资产将回收到垃圾桶,是否需要删除?`}
|
||||
confirmText="删除"
|
||||
|
||||
Reference in New Issue
Block a user