fix: YYX测试清单16条收口——资产库弹窗层级/批次多选删/商品筛选/模特卡单卡化/垃圾桶清空等

- 资产库:批次弹窗降层(under-confirm 990<999)修单图删除确认弹窗被压;编辑态批次卡多选批删;facets 新增 products 关联商品筛选(前后端,+2 测试);视频成品 tab 补搜索/排序/分页
- 平台套图工作台:「更多」气泡与 icon 左对齐+鼠标移出自动收起;模特卡改单卡(空态「+」占位,点卡进模特库单选回填)
- 工作台:删余额条,冻结/共计移至小标题行右侧(.dash-funds-mono)
- 模特库:点模特卡弹详情(形象图/标签/三视图+灯箱放大)
- 垃圾桶:新增「全部恢复」/「清空垃圾桶」(清空二次确认)
- 视频项目页:字重/阴影等轻量对齐设计规范
- api.assetFacets 返回体逐键兜底:旧后端缺 products 键会致资产库整页 undefined.find 白屏(前后端部署时序防护,浏览器已复现验证)

验证:tsc --noEmit / vite build / apps.assets 测试全过;暂存树已独立编译验证,不依赖工作区其余在途改动

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-07-02 18:03:26 +08:00
co-authored by Claude Fable 5
parent a048c32527
commit 5ec69ad91f
14 changed files with 496 additions and 176 deletions
+37
View File
@@ -220,6 +220,43 @@ class AssetBatchesTests(TestCase):
self.assertEqual(self.client.get("/api/assets/summary/").json()["tryon"], 2)
class FacetsProductsTests(TestCase):
"""facets 返回本 tab 下真实关联的商品(?product= 三路之一:metadata.product_id 等),
并按 tab 隔离、team 隔离、只回 id/title。"""
def setUp(self):
from apps.products.models import Product
self.user, self.team = _mk_team("ufp", "TeamFP")
self.client = APIClient()
self.client.force_authenticate(self.user)
self.p1 = Product.objects.create(team=self.team, created_by=self.user, title="商品甲")
self.p2 = Product.objects.create(team=self.team, created_by=self.user, title="商品乙")
# tryon 图经 metadata.product_id 关联 p1
Asset.objects.create(
team=self.team, name="上身图", asset_type="image", source="ai_generated",
category=Asset.Category.MODEL_TRYON, metadata={"product_id": str(self.p1.id)},
)
# kits 图关联 p2(验证 tab 隔离:tryon 下不应出现 p2)
Asset.objects.create(
team=self.team, name="套图", asset_type="image", source="ai_generated",
category=Asset.Category.PLATFORM_KIT, metadata={"product_id": str(self.p2.id)},
)
def test_facets_products_scoped_by_tab(self):
data = self.client.get("/api/assets/facets/?tab=tryon").json()
self.assertIn("products", data)
self.assertEqual({p["id"] for p in data["products"]}, {str(self.p1.id)})
self.assertEqual({p["title"] for p in data["products"]}, {"商品甲"})
# kits tab 只见 p2
kits = self.client.get("/api/assets/facets/?tab=kits").json()
self.assertEqual({p["id"] for p in kits["products"]}, {str(self.p2.id)})
def test_facets_products_empty_when_none(self):
data = self.client.get("/api/assets/facets/?tab=others").json()
self.assertEqual(data["products"], [])
class SubmitReviewTests(TestCase):
"""手动兜底:灰盾点击 → 提交审核;只对送审类放行,非送审类 400。"""
+40 -2
View File
@@ -335,7 +335,7 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
@action(detail=False, methods=["get"])
def facets(self, request):
"""某 tab 下真实存在的筛选项(来源/类型/指定 metadata 键的取值),供下拉「只列真有的」。
"""某 tab 下真实存在的筛选项(来源/类型/关联商品/指定 metadata 键的取值),供下拉「只列真有的」。
参数:tab、meta_keys=gender,age,role,...(逗号分隔)。"""
base = Asset.objects.filter(team=self.get_team(), is_deleted=False, in_library=True)
if request.query_params.get("tab"):
@@ -346,7 +346,45 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
for key in (k for k in request.query_params.get("meta_keys", "").split(",") if k):
vals = base.values_list(f"metadata__{key}", flat=True)
meta[key] = sorted({str(v) for v in vals if v not in (None, "")})
return Response({"sources": sources, "kinds": kinds, "metadata": meta})
# 关联商品:本 tab 下真实关联到的商品(供「关联商品」下拉只列真有的),只回 id/title
products = self._facet_products(base)
return Response({"sources": sources, "kinds": kinds, "metadata": meta, "products": products})
def _facet_products(self, base):
"""收集 base(已 team+tab 过滤)下真实关联的商品 id → 解析成 [{id, title}]。
三路与 get_queryset(?product=)完全对齐:① metadata.product_id ② origin_task→project→product
(仅「确属商品」的类目,不误纳角色/场景/分镜)③ ProductImage 关联。
只 values 取需要的列(product_id / title),不 select_related AITask,不拖巨型 payload。"""
from apps.products.models import Product
product_ids: set[str] = set()
# 路① 独立生图写 metadata.product_id:JSON 键在 Python 侧取,避开 MySQL JSON 抽取比较坑
for md in base.values_list("metadata", flat=True):
if isinstance(md, dict):
pid = md.get("product_id")
if pid:
product_ids.add(str(pid))
# 路② 项目内生成回溯,仅「确属商品」的类目(与 ZWQ#5 一致)
product_categories = (
Asset.Category.PRODUCT_IMAGE,
Asset.Category.MODEL_TRYON,
Asset.Category.PLATFORM_KIT,
Asset.Category.FREE_CREATE,
)
pid2 = base.filter(
category__in=product_categories, origin_task__project__product__isnull=False
).values_list("origin_task__project__product_id", flat=True)
product_ids.update(str(x) for x in pid2 if x)
# 路③ 上传商品图经 ProductImage 关联
pid3 = base.filter(product_images__isnull=False).values_list("product_images__product_id", flat=True)
product_ids.update(str(x) for x in pid3 if x)
if not product_ids:
return []
# 团队隔离(即便某 product_id 越权也过滤掉);只取 id/title
rows = Product.objects.filter(team=self.get_team(), id__in=product_ids).values("id", "title")
products = [{"id": str(r["id"]), "title": r["title"]} for r in rows]
products.sort(key=lambda x: x["title"] or "")
return products
@action(detail=True, methods=["get"], url_path="raw")
def raw(self, request, pk=None):
+31 -17
View File
@@ -461,22 +461,6 @@
.image-workbench .iw-step-h .title { font-size: 14px; font-weight: 600; color: var(--accent-black); }
.image-workbench .iw-step-h .right { font-size: 12px; color: var(--heat); cursor: pointer; }
.image-workbench .iw-step-h .right:hover { text-decoration: underline; }
/* P0①「模特库」入口按钮(打开全屏 ActorLibrary · 二级胶囊 · 不抢主橙) */
.image-workbench .iw-step-h .iw-actor-lib-btn {
margin-left: auto;
display: inline-flex; align-items: center; gap: 5px;
height: 26px; padding: 0 10px 0 8px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
color: var(--black-alpha-72);
font-size: 12px; font-family: inherit; cursor: pointer;
transition: border-color var(--t-base), color var(--t-base), background var(--t-base);
}
.image-workbench .iw-step-h .iw-actor-lib-btn:hover { border-color: var(--heat-20); color: var(--heat); background: var(--heat-12); }
.image-workbench .iw-step-h .iw-actor-lib-btn svg { width: 12px; height: 12px; }
/* 模特库按钮在场时,「全部 (n)」紧随其后,不再 margin-left:auto */
.image-workbench .iw-step-h .iw-actor-lib-btn + .right { margin-left: 8px; }
.image-workbench .iw-sub-h {
font-size: 12px; color: var(--black-alpha-48);
margin-bottom: 6px;
@@ -573,6 +557,28 @@
.image-workbench .model-card .m-check svg { width: 11px; height: 11px; visibility: hidden; }
.image-workbench .model-card.selected .m-check { background: var(--heat); border-color: var(--heat); }
.image-workbench .model-card.selected .m-check svg { visibility: visible; }
/* row40:模特改单选 —— 只一张卡,一列且宽度约等原双列模特卡,不留半宽空位;占位/已选同用 .model-card 故同尺寸 */
.image-workbench .model-grid.model-grid-single { grid-template-columns: minmax(0, 150px); }
/* 未选时占位「+」卡(虚线,点击打开模特库);已选卡点击可换模特 */
.image-workbench .model-card.model-card-empty {
border-style: dashed;
border-color: var(--black-alpha-24);
background: var(--background-lighter);
}
.image-workbench .model-card.model-card-empty:hover {
border-color: var(--heat-40);
background: var(--heat-12);
}
.image-workbench .model-card .m-thumb-empty {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 8px;
color: var(--black-alpha-48);
}
.image-workbench .model-card.model-card-empty:hover .m-thumb-empty { color: var(--heat); }
.image-workbench .model-card .m-thumb-empty .m-empty-label {
font-size: 12px; color: inherit;
font-family: var(--font-mono); letter-spacing: .02em;
}
/* ── 平台选择 · 3 列卡多选(基线 .platform-card)── */
.image-workbench .platform-grid {
@@ -1081,7 +1087,9 @@
.image-workbench .gen-img-more,
.image-workbench .gen-batch-more { position: relative; display: inline-flex; }
.image-workbench .gen-bubble {
position: absolute; top: calc(100% + 6px); right: 0;
/* row37:气泡左缘与触发它的「更多」icon 左缘对齐(原 right:0 是右对齐)。
.gen-image 不裁切(见上方 901 行注释),气泡可溢出卡片显示,不会被卡片切掉。 */
position: absolute; top: calc(100% + 6px); left: 0;
min-width: 168px;
background: var(--surface);
border: 1px solid var(--border-faint);
@@ -1097,6 +1105,12 @@
.image-workbench .gen-batch-more:focus-within .gen-bubble { display: block; }
/* 单图气泡改 click 驱动(§4.18 悬浮组在 hover 浮层内,hover 嵌套会闪;用 .open 显式控制) */
.image-workbench .gen-img-more .gen-bubble.open { display: block; }
/* row39:「更多按钮 → 气泡」间 6px 空隙的透明桥接 —— 鼠标下移到气泡途中不掠出容器,
避免 .gen-img-more 的 onMouseLeave 在空隙处误触发把气泡收起(桥接是气泡的后代,仍算「在区域内」)。 */
.image-workbench .gen-img-more .gen-bubble::before {
content: ""; position: absolute;
left: 0; right: 0; top: -6px; height: 6px;
}
.image-workbench .gen-bubble-item {
width: 100%;
display: flex; align-items: center; gap: 8px;
+5 -3
View File
@@ -493,7 +493,7 @@ export const api = {
},
// 图片趴(模特上身图/平台套图/自由创作)按生成批次成组分页:每批一张卡(封面+张数),点开看整批图
assetBatches(params: {
tab?: string; source?: string; q?: string; ordering?: string; page?: number; pageSize?: number;
tab?: string; source?: string; product?: string; q?: string; ordering?: string; page?: number; pageSize?: number;
meta?: Record<string, string>;
} = {}) {
const qs = new URLSearchParams();
@@ -520,11 +520,13 @@ export const api = {
return request<{ id: string; in_library: boolean }>(`/api/assets/${id}/set-library/`, { method: "POST", body: JSON.stringify({ in_library: inLibrary }) });
},
// 某 tab 下真实存在的筛选项(来源/类型/指定 metadata 键取值),供下拉「只列真有的」
assetFacets(tab?: string, metaKeys: string[] = []) {
async assetFacets(tab?: string, metaKeys: string[] = []) {
const qs = new URLSearchParams();
if (tab) qs.set("tab", tab);
if (metaKeys.length) qs.set("meta_keys", metaKeys.join(","));
return request<{ sources: string[]; kinds: string[]; metadata: Record<string, string[]> }>(`/api/assets/facets/?${qs.toString()}`);
const f = await request<{ sources?: string[]; kinds?: string[]; metadata?: Record<string, string[]>; products?: { id: string; title: string }[] }>(`/api/assets/facets/?${qs.toString()}`);
// 旧后端(未部署 products 字段)返回体缺键会让页面 undefined.find 白屏,逐键兜底
return { sources: f.sources ?? [], kinds: f.kinds ?? [], metadata: f.metadata ?? {}, products: f.products ?? [] };
},
// 导出全部:把项目所有已采用视频片段打成一个 zip 下载(后端逐段从 TOS 拉取打包)
async exportProjectClips(projectId: string): Promise<Blob> {
+9
View File
@@ -2407,3 +2407,12 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
.mono-10 { font-size: 10.5px; letter-spacing: .04em; }
.mono-11 { font-size: 11px; letter-spacing: .04em; }
.mono-12 { font-size: 11.5px; letter-spacing: .04em; }
/* ─── 工作台(dashboard.tsx)专用:R41 余额条移除后,冻结/共计信息移到 page-head .sub 行右侧 ─── */
.dash-funds-mono {
font-family: var(--font-mono); font-size: 12px;
color: var(--black-alpha-48); letter-spacing: .04em;
cursor: pointer;
transition: color var(--t-base);
}
.dash-funds-mono:hover { color: var(--heat); }
+19
View File
@@ -47,6 +47,21 @@ body.edit-mode .library-page .asset-card.selected { border-color: var(--heat); b
/* 多选态隐藏单卡删除(改走 bulk-bar 批量删) */
body.edit-mode .library-page .asset-card .card-del-btn { opacity: 0 !important; pointer-events: none !important; }
/* 管理资产:图片成品批次卡多选(与平铺资产卡一致,复用 .card-check/.selected 视觉)
.batch-card,不波及视频素材包卡(它仍走 .pack-card 的单包删除按钮) */
.library-page .batch-card .card-check {
position: absolute; top: 10px; left: 10px;
width: 22px; height: 22px; border-radius: 50%;
background: var(--surface); border: 2px solid var(--border-loud);
display: none; place-items: center; color: var(--accent-white);
z-index: 5; pointer-events: none;
}
.library-page .batch-card .card-check svg { width: 11px; height: 11px; visibility: hidden; }
body.edit-mode .library-page .batch-card .card-check { display: grid; }
body.edit-mode .library-page .batch-card.selected .card-check { background: var(--heat); border-color: var(--heat); }
body.edit-mode .library-page .batch-card.selected .card-check svg { visibility: visible; }
body.edit-mode .library-page .batch-card.selected { border-color: var(--heat); box-shadow: 0 0 0 1px var(--heat) inset; }
/* 吸底批量操作栏 · 仅 edit-mode 出现 */
.lib-bulk-bar {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
@@ -223,6 +238,10 @@ body.edit-mode .library-page .asset-card .card-del-btn { opacity: 0 !important;
/* 素材包弹窗(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-bg.under-confirm 990 < .modal-bg(ConfirmModal/详情/上传)999 < toast 1000 < .np-lightbox(灯箱)9999
批次弹窗内单张删除会在其上弹二次确认(.modal-bg 999),故此变体降到 990,让确认弹窗可见;
仅用于生成批次弹窗,视频素材包弹窗仍走默认 1200(其内无二次确认) */
.pack-modal-bg.under-confirm { z-index: 990; }
.pack-modal { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); width: min(900px, 92vw); max-height: 86vh; display: flex; flex-direction: column; overflow: hidden; }
.pack-modal-h { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 20px; border-bottom: 1px solid var(--border-faint); }
.pack-modal-h h2 { font-size: 16px; font-weight: 600; color: var(--accent-black); }
+42
View File
@@ -13,9 +13,11 @@
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
overflow: hidden;
cursor: pointer;
transition: border-color .15s;
}
.model-card:hover { border-color: var(--border-loud); }
.model-card:focus-visible { outline: 2px solid var(--heat-40); outline-offset: 2px; }
/* 形象图(9:16 氛围正面图)· 主视觉 */
.model-portrait {
@@ -96,3 +98,43 @@
color: var(--black-alpha-48);
letter-spacing: .02em;
}
/* 模特详情弹窗:继承 .modal(不重写),只加宽度 modifier(同 .invite-modal 惯例) */
.model-detail-modal { max-width: 560px; }
.model-detail-b { display: flex; flex-direction: column; gap: 14px; }
/* 形象图大图:3:4 竖版,与卡片缩图比例一致 */
.model-detail-portrait {
width: 100%;
aspect-ratio: 3 / 4;
border-radius: var(--r-md);
}
.model-detail-portrait[role="button"] { cursor: zoom-in; }
.model-detail-tags {
display: flex;
align-items: center;
gap: 8px;
}
.model-detail-desc {
margin: 0;
font-size: 13px;
line-height: 1.65;
color: var(--black-alpha-64);
}
.model-detail-label {
font-size: 11px;
color: var(--black-alpha-48);
letter-spacing: .04em;
}
/* 三视图:16:9 单容器(不拆 3 张) */
.model-detail-triview {
width: 100%;
aspect-ratio: 16 / 9;
border-radius: var(--r-sm);
}
.model-detail-triview[role="button"] { cursor: zoom-in; }
+3 -5
View File
@@ -33,13 +33,13 @@
.proj-card .hstack { gap: 8px; }
.row-more { position: relative; display: inline-flex; cursor: pointer; align-items: center; color: var(--black-alpha-56); padding: 4px; }
.row-more:hover { color: var(--accent-black); }
.row-more-tip { position: absolute; top: calc(100% + 6px); right: 0; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); box-shadow: 0 4px 16px rgba(0,0,0,.08); padding: 4px; min-width: 110px; opacity: 0; pointer-events: none; transform: translateY(-2px); transition: opacity .15s, transform .15s; z-index: 12; }
.row-more-tip { position: absolute; top: calc(100% + 6px); right: 0; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); box-shadow: var(--shadow-floating); padding: 4px; min-width: 110px; opacity: 0; pointer-events: none; transform: translateY(-2px); transition: opacity .15s, transform .15s; z-index: 12; }
.row-more-tip::before { content: ''; position: absolute; top: -8px; left: 0; right: 0; height: 8px; }
.row-more:hover .row-more-tip, .row-more-tip:hover { opacity: 1; pointer-events: auto; transform: translateY(0); }
.row-more-tip .mi { display: flex; align-items: center; gap: 6px; width: 100%; padding: 6px 10px; background: transparent; border: 0; border-radius: var(--r-sm); cursor: pointer; font-size: 13px; color: var(--accent-black); font-family: inherit; text-align: left; transition: background var(--t-base), color var(--t-base); }
.row-more-tip .mi:hover { background: var(--crimson-bg, #fdebea); color: var(--accent-crimson, #c43d3d); }
.row-more-tip .mi svg { width: 13px; height: 13px; }
.btn.active { background: var(--accent-black); color: var(--accent-white); border-color: var(--accent-black); }
.btn-edit-toggle.active { background: var(--accent-black); color: var(--accent-white); border-color: var(--accent-black); }
.proj-card .card-body { padding: 14px; display: flex; flex-direction: column; gap: 10px; flex: 1; }
.proj-card .card-name { font-size: 14px; font-weight: 600; color: var(--accent-black); line-height: 1.4; }
.proj-card .card-sub { font-size: 12px; color: var(--black-alpha-48); font-family: var(--font-mono); letter-spacing: .02em; margin-top: 4px; }
@@ -54,8 +54,6 @@
.row-check.on svg { opacity: 1; }
body.edit-mode table.t tbody tr { cursor: pointer; }
/* chip 下拉容器(默认收起)· chip 本体/caret 走全局 restraint .chip */
.chip-wrap { position: relative; }
}
/* ─── 批量管理(忠实移植自 V1 projects.html · body.edit-mode 全局态,控件仅项目页出现) ─── */
@@ -75,7 +73,7 @@ body.edit-mode .proj-card.selected { border-color: var(--heat); box-shadow: 0 0
/* 显隐绑定「是否有选中」而非编辑模式 → 退选即消失 */
.bulk-bar.show { display: inline-flex; }
.bulk-bar .ct { font-family: var(--font-mono); letter-spacing: .02em; }
.bulk-bar .ct b { color: var(--heat); font-weight: 700; padding: 0 3px; }
.bulk-bar .ct b { color: var(--heat); font-weight: 600; padding: 0 3px; }
.bulk-bar .sep { width: 1px; height: 18px; background: rgba(255,255,255,.16); }
.bulk-bar button { height: 30px; padding: 0 12px; background: transparent; border: 1px solid rgba(255,255,255,.24); border-radius: var(--r-sm); color: var(--accent-white); font-size: 12.5px; font-family: inherit; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; transition: background .15s, border-color .15s; }
.bulk-bar button:hover { background: rgba(255,255,255,.08); }
+58 -107
View File
@@ -547,14 +547,6 @@ const IMAGE_SUGGESTIONS = [
"电影感都市夜景,街道湿漉漉反射霓虹,4K 高清"
];
/* 模特上身图 · 真人模特默认占位卡(基线 model-photo Ava/Luna/Mia/Zoe) */
const FALLBACK_MODELS = [
{ id: "m1", name: "Ava", tag: "亚洲·25岁·清新" },
{ id: "m2", name: "Luna", tag: "亚洲·22岁·学生" },
{ id: "m3", name: "Mia", tag: "混血·28岁·OL" },
{ id: "m4", name: "Zoe", tag: "亚洲·30岁·健身" }
];
/* · (YYX#row10: logo, public/assets/svg;
logo ,img logo) */
const PLATFORM_OPTIONS = [
@@ -705,18 +697,15 @@ export function ImageWorkbenchPage({
const resultsEndRef = useRef<HTMLDivElement | null>(null);
// 生成结果图片放大预览
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
// 模特/平台 工作台头部:搜索 + 时间排序 + 模特筛选(对左侧网格真实生效)
// 平台套图头部:按提示词搜索已生成的结果图(gridQuery / searchOpen 仅 cover 用;
// row40 后模特模式改单卡选择,已无网格可搜/排序,时间排序/模特筛选下拉一并移除)
const [gridQuery, setGridQuery] = useState("");
const [gridSort, setGridSort] = useState<"recent" | "name">("recent");
const [tbOpen, setTbOpen] = useState<"" | "time" | "model">("");
const [searchOpen, setSearchOpen] = useState(false);
// YYX#17:左侧商品栏折叠(收起后只剩商品图)
const [sideCollapsed, setSideCollapsed] = useState(false);
// YYX#12:平台套图结果筛选弹窗 —— 按平台筛选已生成的图(空集 = 不筛)
const [filterOpen, setFilterOpen] = useState(false);
const [platformFilter, setPlatformFilter] = useState<string[]>([]);
// 模特网格默认只露 6 个;「全部模特 →」展开后露全部,再点收起(超过 6 个时第 7 个之后本来选不到)
const [showAllModels, setShowAllModels] = useState(false);
// 左侧商品空间搜索(原 input 无 state/无过滤=死框,输入无效);按名称/分类过滤
const [prodQuery, setProdQuery] = useState("");
const visibleProducts = products.filter((item) => {
@@ -1336,7 +1325,10 @@ export function ImageWorkbenchPage({
<button className="gen-img-btn" type="button" title="下载" onClick={() => downloadImage(url, `${meta.title}-${index + 1}.png`)}>
<Download size={14} />
</button>
<div className="gen-img-more">
{/* row39: + (,;
6px .gen-bubble::before ,)
, + */}
<div className="gen-img-more" onMouseLeave={() => setOpenMore((k) => (k === cellKey ? "" : k))}>
<button className="gen-img-btn" type="button" title="更多" onClick={() => setOpenMore((k) => (k === cellKey ? "" : cellKey))}>
<MoreHorizontal size={14} />
</button>
@@ -1783,22 +1775,25 @@ export function ImageWorkbenchPage({
</button> */}
<span className="spacer" />
<div className="tb-search-wrap">
{searchOpen && (
<div className="iw-search-box">
<input className="input" autoFocus placeholder={mode === "model" ? "搜索模特" : "搜索图片提示词"} value={gridQuery} onChange={(event) => setGridQuery(event.target.value)} />
{/* YYX#12:输入框内 × 清空已输入内容 */}
{gridQuery && (
<button className="iw-search-clear" type="button" aria-label="清空搜索" onClick={() => setGridQuery("")}>
<X size={13} />
</button>
)}
</div>
)}
<button className={`search-btn${searchOpen ? " active" : ""}`} type="button" title={mode === "model" ? "搜索批次/模特" : "搜索"} onClick={() => { setSearchOpen((v) => !v); if (searchOpen) setGridQuery(""); }}>
<Search size={14} />
</button>
</div>
{/* 搜索仅平台套图用(按提示词筛结果图);模特模式改单卡「+」选择,无网格可搜,不再显示 */}
{mode === "cover" && (
<div className="tb-search-wrap">
{searchOpen && (
<div className="iw-search-box">
<input className="input" autoFocus placeholder="搜索图片提示词" value={gridQuery} onChange={(event) => setGridQuery(event.target.value)} />
{/* YYX#12:输入框内 × 清空已输入内容 */}
{gridQuery && (
<button className="iw-search-clear" type="button" aria-label="清空搜索" onClick={() => setGridQuery("")}>
<X size={13} />
</button>
)}
</div>
)}
<button className={`search-btn${searchOpen ? " active" : ""}`} type="button" title="搜索" onClick={() => { setSearchOpen((v) => !v); if (searchOpen) setGridQuery(""); }}>
<Search size={14} />
</button>
</div>
)}
{/* YYX#12:平台套图 —— 搜索 icon 右侧加筛选按钮,点开悬浮弹窗按平台筛选结果图 */}
{mode === "cover" && (
<div className={`tb-menu-wrap chip-wrap iw-filter-wrap${filterOpen ? " open" : ""}`} data-filter="platform">
@@ -1829,26 +1824,7 @@ export function ImageWorkbenchPage({
</div>
</div>
)}
{mode === "model" && (
<>
<div className={`tb-menu-wrap chip-wrap${tbOpen === "time" ? " open" : ""}`} data-filter="time">
<button className={`tb-chip${gridSort === "name" ? " active" : ""}`} type="button" onClick={() => setTbOpen((c) => (c === "time" ? "" : "time"))}><span className="lbl">{gridSort === "name" ? "按名称" : "时间"}</span> <svg 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${gridSort === "recent" ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setGridSort("recent"); setTbOpen(""); }}><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${gridSort === "name" ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setGridSort("name"); setTbOpen(""); }}><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 className={`tb-menu-wrap chip-wrap${tbOpen === "model" ? " open" : ""}`} data-filter="model">
<button className={`tb-chip${gridQuery ? " active" : ""}`} type="button" onClick={() => setTbOpen((c) => (c === "model" ? "" : "model"))}><span className="lbl">{gridQuery || "模特"}</span> <svg 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${!gridQuery ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setGridQuery(""); setTbOpen(""); }}><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>
{(personAssets.length > 0 ? personAssets.map((p) => p.name) : FALLBACK_MODELS.map((m) => m.name)).map((nm) => (
<div className={`mi${gridQuery === nm ? " selected" : ""}`} key={nm} role="button" tabIndex={0} onClick={() => { setGridQuery(nm); setTbOpen(""); }}><svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{nm}</div>
))}
</div>
</div>
</>
)}
{/* row40:模特模式已改单卡「+」选择,原「时间排序 / 全部模特」下拉随网格一并移除 */}
</div>
<div className="iw-main-body">
@@ -1859,65 +1835,40 @@ export function ImageWorkbenchPage({
<div className="iw-step-h">
<span className="num">1</span>
<span className="title"></span>
{/* P0①:接入现成模特库全屏弹窗(原「全部模特→」仅内联展开;现可打开完整演员库选用) */}
<button className="iw-actor-lib-btn" type="button" onClick={() => setActorLibOpen(true)}>
<Users size={12} />
</button>
{personAssets.length > 6 && (
<span className="right" role="button" tabIndex={0} style={{ cursor: "pointer" }} onClick={() => setShowAllModels((v) => !v)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setShowAllModels((v) => !v); } }}>{showAllModels ? "收起 ↑" : `全部 (${personAssets.length})`}</span>
)}
</div>
<div className="model-grid">
{personAssets.length > 0
? personAssets
.filter((item) => !gridQuery || item.name.toLowerCase().includes(gridQuery.toLowerCase()))
.sort((a, b) => (gridSort === "name" ? a.name.localeCompare(b.name) : (b.created_at || "").localeCompare(a.created_at || "")))
.slice(0, showAllModels ? undefined : 6)
.map((item) => {
const url = item.files?.[0]?.preview_url;
return (
<button
type="button"
key={item.id}
className={`model-card ${pickedIds.includes(item.id) ? "selected" : ""}`}
onClick={() => togglePick(item.id, item.name)}
>
<span className="m-check">
<Check size={11} />
</span>
<div className="m-thumb">
{url ? (
<img className="m-thumb-img" src={url} alt={item.name} loading="lazy" />
) : (
<div className="placeholder">
<span className="ph-frame">{item.name.slice(0, 4)}</span>
</div>
)}
<div className="m-name">{item.name}</div>
</div>
</button>
);
})
: FALLBACK_MODELS
.filter((item) => !gridQuery || item.name.toLowerCase().includes(gridQuery.toLowerCase()))
.sort((a, b) => (gridSort === "name" ? a.name.localeCompare(b.name) : 0))
.map((item) => (
<button
type="button"
key={item.id}
className={`model-card ${pickedIds.includes(item.id) ? "selected" : ""}`}
onClick={() => togglePick(item.id, item.name)}
>
<span className="m-check">
<Check size={11} />
</span>
<div className="placeholder m-thumb">
<span className="ph-frame">{item.name}</span>
<div className="m-name">{item.name}</div>
{/* row40: ();
+(ActorLibrary,mode=replace),
onPick pickedIds=[id] , */}
<div className="model-grid model-grid-single">
{(() => {
const selected = pickedIds[0] ? personAssets.find((a) => a.id === pickedIds[0]) : undefined;
const url = selected?.files?.[0]?.preview_url;
const name = selected?.name || pickedModelName;
if (pickedIds[0]) {
return (
<button type="button" className="model-card selected" title="点击打开模特库更换模特" onClick={() => setActorLibOpen(true)}>
<div className="m-thumb">
{url ? (
<img className="m-thumb-img" src={url} alt={name} loading="lazy" />
) : (
<div className="placeholder">
<span className="ph-frame">{(name || "模特").slice(0, 4)}</span>
</div>
)}
<div className="m-name">{name || "已选模特"}</div>
</div>
</button>
))}
);
}
return (
<button type="button" className="model-card model-card-empty" title="从模特库选择模特" onClick={() => setActorLibOpen(true)}>
<div className="m-thumb m-thumb-empty">
<Plus size={26} />
<span className="m-empty-label"></span>
</div>
</button>
);
})()}
</div>
</div>
) : (
@@ -1950,7 +1901,7 @@ export function ImageWorkbenchPage({
<span className="title"></span>
</div>
<div className="iw-sub">
<div className="iw-sub-h">// 生成数量{mode === "model" ? " (每模特)" : ""}</div>
<div className="iw-sub-h">// 生成数量</div>
<div className="pill-row">
{countOptions.map((value) => (
<button
+10 -5
View File
@@ -72,17 +72,22 @@ export function Dashboard({ products, projects, productTotal, projectTotal, bill
const allProjectsLoaded = projects.length >= projCount;
const totalDelta = allProjectsLoaded && newThisMonth > 0 ? `↑ 本月 +${newThisMonth}` : undefined;
// 余额 mini progress:真实冻结占比 = 冻结 /(可用 + 冻结);无 budget/已用字段,故不复刻 V1「已用 ¥X/¥Y」,改显真实「冻结 ¥X / 共 ¥Y」。
// 冻结信息:真实冻结 ¥X / 共 ¥Y」,无 budget/已用字段,故不复刻 V1「已用 ¥X/¥Y」。
// R41:余额 stat 块已从 KPI 行移除,冻结信息改放 page-head 小标题行右侧(见下方 .sub 内)。
const balanceNum = Number(billing?.account.balance ?? 0);
const reservedNum = Number(billing?.account.reserved_balance ?? 0);
const fundsTotal = balanceNum + reservedNum;
const reservedPct = fundsTotal > 0 ? Math.min(100, Math.round((reservedNum / fundsTotal) * 100)) : 0;
return (
<>
<div className="page-head">
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<h1>{userName ? `${userName.split("@")[0]}` : ""}</h1>
<div className="sub"><span className="mono">// {dateMono}</span><span>·</span><span>你有 <b>{running} 个项目</b> 正在进行中</span></div>
<div className="sub">
<span className="mono">// {dateMono}</span><span>·</span><span>你有 <b>{running} 个项目</b> 正在进行中</span>
<span className="dash-funds-mono" style={{ marginLeft: "auto" }} onClick={() => navigate("account")}>
{money(balanceNum)} · {money(reservedNum)} / {money(fundsTotal)}
</span>
</div>
</div>
<div className="actions">
<button className="btn" type="button" onClick={() => (onCreateProduct ? setCreateDrawer(true) : navigate("products"))}>
@@ -95,7 +100,7 @@ export function Dashboard({ products, projects, productTotal, projectTotal, bill
</button>
</div>
</div>
<div className="stats with-corners"><span className="corner-tr">+</span><span className="corner-bl">+</span><KpiStat label="总项目" badge="ALL" value={projCount} delta={totalDelta} onClick={() => navigate("projects", { tab: "all" })} /><KpiStat label="进行中" badge="WIP" value={running} onClick={() => navigate("projects", { tab: "wip" })} /><KpiStat label="成片" badge="DONE" value={completed} onClick={() => navigate("projects", { tab: "done" })} /><button className="stat" type="button" onClick={() => navigate("account")}><div className="lbl"> <span className="badge">¥</span></div><div className="v">{money(billing?.account.balance)}</div><div className="bar"><span style={{ width: `${reservedPct}%` }} /></div><div className="sub"> {money(reservedNum)} / {money(fundsTotal)}</div></button></div>
<div className="stats with-corners" style={{ gridTemplateColumns: "repeat(3, 1fr)" }}><span className="corner-tr">+</span><span className="corner-bl">+</span><KpiStat label="总项目" badge="ALL" value={projCount} delta={totalDelta} onClick={() => navigate("projects", { tab: "all" })} /><KpiStat label="进行中" badge="WIP" value={running} onClick={() => navigate("projects", { tab: "wip" })} /><KpiStat label="成片" badge="DONE" value={completed} onClick={() => navigate("projects", { tab: "done" })} /></div>
<div className="dash-grid"><div><div className="section-h"><h2></h2><button className="more" type="button" onClick={() => navigate("projects")}>[ ALL · {projCount} ] </button></div><div className="card-hard">{loading && projects.length === 0 ? <SkeletonRows count={4} /> : projects.slice(0, 6).map((project) => <button className="recent-row" key={project.id} type="button" onClick={() => navigate("pipeline", { projectId: project.id })}><div className="placeholder thumb"><span className="ph-frame">9:16</span></div><div className="recent-meta"><div className="name">{project.name}</div><div className="sub">{[productTitle(project.product), "AI 全生", project.video_segment_count ? `${project.video_segment_count}` : null].filter(Boolean).join(" / ")}</div></div><DashProgress project={project} /><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{stageMeta[project.current_stage]?.label || project.current_stage}</span><span className="btn btn-sm"></span></button>)}</div></div><div style={{ display: "flex", flexDirection: "column", gap: 24 }}><div><div className="section-h"><h2></h2><span className="more">[ /shortcuts ]</span></div><div className="shortcuts"><Shortcut name="package" title="" desc={`${prodCount} SKU`} onClick={() => navigate("products")} /><Shortcut name="images" title="" desc={`${assetCount ?? ""} `} onClick={() => navigate("library")} /><Shortcut name="creditCard" title="" desc={money(billing?.account.balance)} onClick={() => navigate("account")} /><Shortcut name="clapperboard" title="" desc={`${projCount} `} onClick={() => navigate("projects")} /></div></div><div><div className="section-h"><h2></h2><span className="more">[ FAQ ]</span></div><div className="tip"><strong></strong> <span className="mono">[ ]</span> token </div></div></div></div>
{/* YYX#11:新建商品抽屉 · 复用商品库同一组件,创建成功后跳商品库看新商品 */}
{onCreateProduct && (
+111 -32
View File
@@ -415,10 +415,14 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
const [openChip, setOpenChip] = useState("");
const [srcFilter, setSrcFilter] = useState("");
const [kindFilter, setKindFilter] = useState("");
// 关联商品筛选(仅图片成品 tab):存商品 id,选中后把 product=<id> 传进列表/批次请求;选项走 facets.products
const [productFilter, setProductFilter] = useState("");
const [sortDesc, setSortDesc] = useState(true);
// 编辑模式 + 多选 + 元数据筛选(性别/年龄/角色/场景类型/关联/时长 走 asset.metadata,真实存在才有可选项)
const [editMode, setEditMode] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
// 批次多选(图片成品 tab):按 batch_id 存;删除时展开成整批 asset id(见 bulkDeleteIds)
const [selectedBatches, setSelectedBatches] = useState<Set<string>>(new Set());
const [metaFilter, setMetaFilter] = useState<Record<string, string>>({});
// 删除确认:单删传 [id],批删传选中数组
const [confirmIds, setConfirmIds] = useState<string[] | null>(null);
@@ -436,7 +440,14 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
const exitEdit = () => { setEditMode(false); setSelected(new Set()); };
// 批次多选:整卡点选 = 选中/取消该批次(与「管理项目」一致)
const toggleBatch = (batchId: string) => setSelectedBatches((prev) => {
const next = new Set(prev);
if (next.has(batchId)) next.delete(batchId); else next.add(batchId);
return next;
});
const clearSelection = () => { setSelected(new Set()); setSelectedBatches(new Set()); };
const exitEdit = () => { setEditMode(false); clearSelection(); };
// ── 服务端懒加载:列表按页拉、tab 计数走 summary、筛选项走 facets(不再前端取全量再切片)──
const [items, setItems] = useState<Asset[]>([]);
@@ -448,7 +459,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
// 视频成品:按项目素材包(点包卡 → 弹窗看该项目所有片段)
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 [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);
@@ -457,12 +468,12 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
useEffect(() => { const id = setTimeout(() => setDebouncedQuery(query), 400); return () => clearTimeout(id); }, [query]);
// 切 tab / 改筛选 / 改排序回到第 1 页
useEffect(() => { setPage(1); }, [tab, debouncedQuery, srcFilter, kindFilter, metaFilter, sortDesc]);
// 切 tab 时清空与该 tab 无关的筛选 + 退出多选(避免跨 tab 误删)
useEffect(() => { setOpenChip(""); setSrcFilter(""); setKindFilter(""); setMetaFilter({}); setSelected(new Set()); }, [tab]);
useEffect(() => { setPage(1); }, [tab, debouncedQuery, srcFilter, kindFilter, productFilter, metaFilter, sortDesc]);
// 切 tab 时清空与该 tab 无关的筛选 + 退出多选(避免跨 tab 误删/误筛)
useEffect(() => { setOpenChip(""); setSrcFilter(""); setKindFilter(""); setProductFilter(""); setMetaFilter({}); setSelected(new Set()); setSelectedBatches(new Set()); }, [tab]);
// 当前 tab 的 metadata 筛选键(来源/类型走独立字段,不算 metadata)
const metaKeys = LIB_CHIPS.filter((c) => c.tabs.includes(tab) && c.key !== "source" && c.key !== "kind").map((c) => c.key);
// 当前 tab 的 metadata 筛选键(来源/类型/关联商品走独立字段与 facets,不算 metadata)
const metaKeys = LIB_CHIPS.filter((c) => c.tabs.includes(tab) && c.key !== "source" && c.key !== "kind" && c.key !== "product").map((c) => c.key);
// 图片成品三类按批次成组展示;其余 tab 平铺
const isBatchTab = BATCH_TABS.includes(tab);
@@ -477,6 +488,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
tab,
q: debouncedQuery || undefined,
source: srcFilter || undefined,
product: productFilter || undefined, // 后端 assetsPage/batches 两条通道都按 ?product= 三路过滤
meta: metaFilter,
ordering: sortDesc ? "-created_at" : "created_at",
page,
@@ -495,7 +507,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
.finally(() => { if (alive) setLoading(false); });
}
return () => { alive = false; };
}, [tab, isBatchTab, debouncedQuery, srcFilter, kindFilter, metaFilter, sortDesc, page, reloadFlag]);
}, [tab, isBatchTab, debouncedQuery, srcFilter, kindFilter, productFilter, metaFilter, sortDesc, page, reloadFlag]);
// tab 计数(徽标)+ 当前 tab 的筛选项(下拉「只列真有的」):切 tab / 上传 / 删除后刷新
useEffect(() => { api.assetSummary().then((c) => setCounts((prev) => ({ ...prev, ...c }))).catch(() => {}); }, [reloadFlag]);
@@ -515,12 +527,28 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
const srcOptions = facets.sources;
const kindOptions = facets.kinds;
const metaOptions = (key: string) => facets.metadata[key] || [];
const hasFilter = Boolean(debouncedQuery || srcFilter || kindFilter || Object.values(metaFilter).some(Boolean));
const hasFilter = Boolean(debouncedQuery || srcFilter || kindFilter || productFilter || Object.values(metaFilter).some(Boolean));
const totalPages = Math.max(1, Math.ceil(total / LIB_PAGE_SIZE));
const curPage = Math.min(page, totalPages);
// 乐观隐藏后真正渲染的列表
const shown = items.filter((a) => !deletingIds.has(a.id));
// 视频成品:前端全量数组按搜索(项目名)过滤 + 排序(按片段数)+ 分页切片(10/页,共享 Pager)
const filteredPacks = useMemo(() => {
const q = debouncedQuery.trim().toLowerCase();
const arr = q ? packs.filter((p) => (p.project_name || "").toLowerCase().includes(q)) : packs.slice();
return arr.sort((a, b) => (sortDesc ? b.clips.length - a.clips.length : a.clips.length - b.clips.length));
}, [packs, debouncedQuery, sortDesc]);
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);
// 批量删除:批次 tab 走 selectedBatches(展开成整批 asset id),其余 tab 走 selected(asset id)
const selCount = isBatchTab ? selectedBatches.size : selected.size;
const bulkDeleteIds = () => (isBatchTab
? batches.filter((b) => selectedBatches.has(b.batch_id)).flatMap((b) => b.items.map((a) => a.id))
: Array.from(selected));
useEffect(() => {
if (!openChip) return;
const close = (event: MouseEvent) => {
@@ -548,6 +576,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
if (!ids.length || !onDelete) return;
setDeletingIds((prev) => { const next = new Set(prev); ids.forEach((id) => next.add(id)); return next; });
setSelected(new Set());
setSelectedBatches(new Set());
// R108:批次弹窗内单张删除 —— 同步从打开中的批次里摘掉;删空则关掉弹窗
setOpenBatch((prev) => {
if (!prev) return prev;
@@ -587,11 +616,35 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
</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">
{packs.length === 0 ? (
<div className="empty-filter">// 还没有视频成品 · 去视频项目生成片段</div>
{filteredPacks.length === 0 ? (
<div className="empty-filter">// {debouncedQuery ? "没有匹配的视频成品" : "还没有视频成品 · 去视频项目生成片段"}</div>
) : (
packs.map((pack) => {
shownPacks.map((pack) => {
const first = pack.clips[0];
// 视频成品的所有片段 id:批量删除整个素材包
const packClipIds = pack.clips.map((c) => c.id).filter(Boolean);
@@ -619,6 +672,8 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
})
)}
</div>
<Pager page={packCurPage} total={filteredPacks.length} pageSize={LIB_PAGE_SIZE} onChange={setPage} />
</>
) : (
<>
<div className="toolbar">
@@ -668,6 +723,28 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
</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] || "";
@@ -711,20 +788,21 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
{isBatchTab ? (
batches.length ? (
<div className="packs-grid" id="batch-grid">
{batches.map((batch) => (
<article className="pack-card" key={batch.batch_id} onClick={() => !editMode && setOpenBatch(batch)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (!editMode) setOpenBatch(batch); } }}>
{editMode && onDelete && (
<button className="card-del-btn" type="button" title="删除整批" onClick={(event) => { event.stopPropagation(); setConfirmIds(batch.items.map((a) => a.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">
{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>
))}
{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>
) : loading ? (
<SkeletonGrid count={8} />
@@ -826,9 +904,10 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
document.body
)}
{/* 生成批次弹窗:看该批次所有图片(点单图开详情/大图) */}
{/* :(/)
under-confirm:批次弹窗内点单图删除会弹 ConfirmModal(.modal-bg), .modal-bg ,(R109 ) */}
{openBatch && createPortal(
<div className="pack-modal-bg" role="dialog" aria-modal="true" aria-label="生成批次" onClick={(e) => { if (e.target === e.currentTarget) setOpenBatch(null); }}>
<div className="pack-modal-bg under-confirm" role="dialog" aria-modal="true" aria-label="生成批次" onClick={(e) => { if (e.target === e.currentTarget) setOpenBatch(null); }}>
<div className="pack-modal">
<div className="pack-modal-h">
<div>
@@ -870,11 +949,11 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
)}
{/* 编辑模式浮动批量操作栏(scope 到资产库;删除/清空/完成) */}
<div className={`lib-bulk-bar${selected.size > 0 ? " show" : ""}`} role="toolbar" aria-label="批量操作">
<span className="ct"> <b>{selected.size}</b> </span>
<button className="clear-sel" type="button" onClick={() => setSelected(new Set())}></button>
<div className={`lib-bulk-bar${selCount > 0 ? " show" : ""}`} role="toolbar" aria-label="批量操作">
<span className="ct"> <b>{selCount}</b> {isBatchTab ? "批" : "项"}</span>
<button className="clear-sel" type="button" onClick={clearSelection}></button>
<span className="sep" />
<button className="danger" type="button" disabled={selected.size === 0} onClick={() => selected.size && setConfirmIds(Array.from(selected))}>
<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>
+75 -4
View File
@@ -1,7 +1,10 @@
import { useEffect, useRef, useState } from "react";
import type { ChangeEvent, CSSProperties } from "react";
import type { ChangeEvent, CSSProperties, KeyboardEvent } from "react";
import { createPortal } from "react-dom";
import { User, X } from "lucide-react";
import { api } from "../api";
import type { ModelEntity } from "../types";
import { MediaLightbox, useBodyScrollLock, useOverlayTransition } from "../components/overlays";
import "../models-page.css";
const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
@@ -13,6 +16,62 @@ const TABS: { k: Tab; label: string }[] = [
{ k: "mine", label: "我的模特" }
];
type Preview = { src: string; kind: "image"; name: string };
// 模特详情弹窗:大图形象图 + 名字 + 官方模板/来源标签 + 三视图(16:9 单容器,无则占位)。
// 复用 overlays.tsx 的 .modal 家族机制(useBodyScrollLock + useOverlayTransition + createPortal)。
function ModelDetailModal({ model, close, onZoom }: { model: ModelEntity | null; close: () => void; onZoom: (p: Preview) => void }) {
const open = !!model;
useBodyScrollLock(open);
const { mounted, show } = useOverlayTransition(open, close);
if (!mounted || !model) return null;
const zoomKey = (p: Preview) => (e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onZoom(p); } };
return createPortal(
<div className={`modal-bg${show ? " show" : ""}`} onClick={close}>
<div className="modal model-detail-modal" onClick={(event) => event.stopPropagation()}>
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h">
<div className="ic-m"><User size={16} /></div>
<div className="ti">
{model.name}
<span>{model.is_official ? "官方模板" : model.source === "upload" ? "真人上传" : "AI 生成"}</span>
</div>
<button className="x modal-x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button>
</div>
<div className="modal-b model-detail-b">
<div
className={`placeholder model-detail-portrait${model.portrait ? " has-mock-media" : ""}`}
style={model.portrait ? mediaStyle(model.portrait) : undefined}
role={model.portrait ? "button" : undefined}
tabIndex={model.portrait ? 0 : undefined}
onClick={model.portrait ? () => onZoom({ src: model.portrait, kind: "image", name: model.name }) : undefined}
onKeyDown={model.portrait ? zoomKey({ src: model.portrait, kind: "image", name: model.name }) : undefined}
>
{!model.portrait && <span className="ph-frame"></span>}
</div>
<div className="model-detail-tags mono">
{model.is_official && <span className="pill info"></span>}
<span className="pill neutral">{model.source === "upload" ? "真人上传" : "AI 生成"}</span>
</div>
{model.description && <p className="model-detail-desc">{model.description}</p>}
<div className="model-detail-label mono">// 三视图</div>
<div
className={`placeholder model-detail-triview${model.triview ? " has-mock-media" : ""}`}
style={model.triview ? mediaStyle(model.triview) : undefined}
role={model.triview ? "button" : undefined}
tabIndex={model.triview ? 0 : undefined}
onClick={model.triview ? () => onZoom({ src: model.triview, kind: "image", name: `${model.name} · 三视图` }) : undefined}
onKeyDown={model.triview ? zoomKey({ src: model.triview, kind: "image", name: `${model.name} · 三视图` }) : undefined}
>
{!model.triview && <span className="ph-frame mono">// 无三视图</span>}
</div>
</div>
</div>
</div>,
document.body,
);
}
// 模特库:顶级实体(团队级可复用)= 形象图 + 三视图 + 声线(尾期)。官方预制打「官方模板」标签。
// 图片创作(模特上身图)与视频项目(角色)都引用它。期1:看归好的成套模特 + 真人上传 + 删自建。
export function ModelsPage() {
@@ -21,6 +80,8 @@ export function ModelsPage() {
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [confirmId, setConfirmId] = useState<string | null>(null);
const [detail, setDetail] = useState<ModelEntity | null>(null);
const [preview, setPreview] = useState<Preview | null>(null);
const fileRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
@@ -96,7 +157,14 @@ export function ModelsPage() {
) : (
<div className="models-grid">
{items.map((m) => (
<div className="model-card" key={m.id}>
<div
className="model-card"
key={m.id}
role="button"
tabIndex={0}
onClick={() => setDetail(m)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setDetail(m); } }}
>
<div
className={`placeholder model-portrait${m.portrait ? " has-mock-media" : ""}`}
style={m.portrait ? mediaStyle(m.portrait) : undefined}
@@ -105,12 +173,12 @@ export function ModelsPage() {
{m.is_official && <span className="pill info model-official"></span>}
{!m.is_official &&
(confirmId === m.id ? (
<div className="model-confirm">
<div className="model-confirm" onClick={(e) => e.stopPropagation()}>
<button className="btn btn-sm model-del" type="button" onClick={() => void remove(m.id)}></button>
<button className="btn btn-sm" type="button" onClick={() => setConfirmId(null)}></button>
</div>
) : (
<button className="model-del-btn" type="button" title="删除模特" onClick={() => setConfirmId(m.id)}>×</button>
<button className="model-del-btn" type="button" title="删除模特" onClick={(e) => { e.stopPropagation(); setConfirmId(m.id); }}>×</button>
))}
</div>
<div className="model-body">
@@ -131,6 +199,9 @@ export function ModelsPage() {
))}
</div>
)}
<ModelDetailModal model={detail} close={() => setDetail(null)} onZoom={setPreview} />
<MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />
</div>
);
}
+1 -1
View File
@@ -665,7 +665,7 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
<td>
<div className="hstack">
<div className="prog">{Array.from({ length: PROJ_STAGE_TOTAL }, (_, k) => k + 1).map((i) => <span key={i} className={projProgClass(project, i, no)} />)}</div>
<span className="muted-2 mono" style={{ fontSize: "12px" }}>{no}/{PROJ_STAGE_TOTAL}</span>
<span className="muted-2 mono" style={{ fontSize: "11px" }}>{no}/{PROJ_STAGE_TOTAL}</span>
</div>
</td>
<td><span className={`pill ${projPillClass(project)}`}><span className="dot" />{projStatusLabel(project)}</span></td>
+55
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import { api } from "../api";
import { ConfirmModal } from "../components/overlays";
import type { NavigateFn } from "./route-config";
import type { Asset, Product } from "../types";
import "../trash-page.css";
@@ -32,6 +33,9 @@ export function TrashPage({ onRestore, onPurge }: {
const [confirmId, setConfirmId] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [errText, setErrText] = useState("");
const [bulkBusy, setBulkBusy] = useState<"restore" | "purge" | null>(null);
const [purgeAllOpen, setPurgeAllOpen] = useState(false);
const isEmpty = items.length === 0 && assets.length === 0;
useEffect(() => {
let alive = true;
@@ -70,6 +74,41 @@ export function TrashPage({ onRestore, onPurge }: {
finally { setBusyId(null); }
}
// 全部恢复:商品走 onRestore、资产走 api.restoreAsset,各自独立通道并发跑;失败的留在列表、错误如实亮出
// 按 id 而非下标比对结果:防止执行期间单条操作改动了 items/assets 导致下标错位
async function restoreAll() {
setBulkBusy("restore");
setErrText("");
const itemIds = items.map((p) => p.id);
const assetIds = assets.map((a) => a.id);
const itemResults = await Promise.allSettled(itemIds.map((id) => Promise.resolve(onRestore(id))));
const assetResults = await Promise.allSettled(assetIds.map((id) => api.restoreAsset(id)));
const failedItemIds = new Set(itemIds.filter((_, i) => itemResults[i].status === "rejected"));
const failedAssetIds = new Set(assetIds.filter((_, i) => assetResults[i].status === "rejected"));
setItems((list) => list.filter((p) => failedItemIds.has(p.id)));
setAssets((list) => list.filter((a) => failedAssetIds.has(a.id)));
const firstFail = [...itemResults, ...assetResults].find((r): r is PromiseRejectedResult => r.status === "rejected");
if (firstFail) setErrText(firstFail.reason instanceof Error ? firstFail.reason.message : "部分恢复失败");
setBulkBusy(null);
}
// 清空垃圾桶:同上,走各自的彻底删除通道
async function purgeAll() {
setPurgeAllOpen(false);
setBulkBusy("purge");
setErrText("");
const itemIds = items.map((p) => p.id);
const assetIds = assets.map((a) => a.id);
const itemResults = await Promise.allSettled(itemIds.map((id) => Promise.resolve(onPurge(id))));
const assetResults = await Promise.allSettled(assetIds.map((id) => api.purgeAsset(id)));
const failedItemIds = new Set(itemIds.filter((_, i) => itemResults[i].status === "rejected"));
const failedAssetIds = new Set(assetIds.filter((_, i) => assetResults[i].status === "rejected"));
setItems((list) => list.filter((p) => failedItemIds.has(p.id)));
setAssets((list) => list.filter((a) => failedAssetIds.has(a.id)));
const firstFail = [...itemResults, ...assetResults].find((r): r is PromiseRejectedResult => r.status === "rejected");
if (firstFail) setErrText(firstFail.reason instanceof Error ? firstFail.reason.message : "部分删除失败");
setBulkBusy(null);
}
return (
<div className="trash-page">
<div className="page-head">
@@ -81,6 +120,12 @@ export function TrashPage({ onRestore, onPurge }: {
<span>,()</span>
</div>
</div>
<div className="actions">
<button className="btn" type="button" disabled={isEmpty || bulkBusy !== null} onClick={() => setPurgeAllOpen(true)}></button>
<button className="btn btn-primary" type="button" disabled={isEmpty || bulkBusy !== null} onClick={() => void restoreAll()}>
{bulkBusy === "restore" ? "恢复中…" : "全部恢复"}
</button>
</div>
</div>
{loading ? (
@@ -165,6 +210,16 @@ export function TrashPage({ onRestore, onPurge }: {
)}
</>
)}
<ConfirmModal
open={purgeAllOpen}
title="清空垃圾桶"
subtitle="// PURGE ALL"
detail="清空垃圾桶数据无法恢复,是否需要清空?"
confirmText="清空"
onCancel={() => setPurgeAllOpen(false)}
onConfirm={purgeAll}
/>
</div>
);
}