diff --git a/core/backend/apps/assets/tests.py b/core/backend/apps/assets/tests.py index 4e7bee1..a7480c2 100644 --- a/core/backend/apps/assets/tests.py +++ b/core/backend/apps/assets/tests.py @@ -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。""" diff --git a/core/backend/apps/assets/views.py b/core/backend/apps/assets/views.py index 3f59a67..d825882 100644 --- a/core/backend/apps/assets/views.py +++ b/core/backend/apps/assets/views.py @@ -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): diff --git a/core/frontend/src/ai-tools-page.css b/core/frontend/src/ai-tools-page.css index c84c03e..c3edc10 100644 --- a/core/frontend/src/ai-tools-page.css +++ b/core/frontend/src/ai-tools-page.css @@ -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; diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index c914228..cad9516 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -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; } = {}) { 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 }>(`/api/assets/facets/?${qs.toString()}`); + const f = await request<{ sources?: string[]; kinds?: string[]; metadata?: Record; 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 { diff --git a/core/frontend/src/design-restraint.css b/core/frontend/src/design-restraint.css index fceb338..f8b6a89 100644 --- a/core/frontend/src/design-restraint.css +++ b/core/frontend/src/design-restraint.css @@ -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); } diff --git a/core/frontend/src/library-page.css b/core/frontend/src/library-page.css index 2ee7e60..e750b5b 100644 --- a/core/frontend/src/library-page.css +++ b/core/frontend/src/library-page.css @@ -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); } diff --git a/core/frontend/src/models-page.css b/core/frontend/src/models-page.css index 664ac8a..4c99b40 100644 --- a/core/frontend/src/models-page.css +++ b/core/frontend/src/models-page.css @@ -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; } diff --git a/core/frontend/src/projects-page.css b/core/frontend/src/projects-page.css index 2d41f09..f60c0a0 100644 --- a/core/frontend/src/projects-page.css +++ b/core/frontend/src/projects-page.css @@ -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); } diff --git a/core/frontend/src/routes/ai-tools.tsx b/core/frontend/src/routes/ai-tools.tsx index 1ee651b..f3f7b6c 100644 --- a/core/frontend/src/routes/ai-tools.tsx +++ b/core/frontend/src/routes/ai-tools.tsx @@ -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(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([]); - // 模特网格默认只露 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({ -
+ {/* row39:鼠标移出「更多按钮 + 气泡」整体区域自动收起(气泡是本容器后代,悬停气泡不算移出; + 按钮与气泡间 6px 空隙由 .gen-bubble::before 透明桥接补上,移过去不会误收起)。 + 仅当收起的是本格气泡才清空,避免误关他格。保留点击切换 + 点空白收起。 */} +
setOpenMore((k) => (k === cellKey ? "" : k))}> @@ -1783,22 +1775,25 @@ export function ImageWorkbenchPage({ 商品库 */} -
- {searchOpen && ( -
- setGridQuery(event.target.value)} /> - {/* YYX#12:输入框内 × 清空已输入内容 */} - {gridQuery && ( - - )} -
- )} - -
+ {/* 搜索仅平台套图用(按提示词筛结果图);模特模式改单卡「+」选择,无网格可搜,不再显示 */} + {mode === "cover" && ( +
+ {searchOpen && ( +
+ setGridQuery(event.target.value)} /> + {/* YYX#12:输入框内 × 清空已输入内容 */} + {gridQuery && ( + + )} +
+ )} + +
+ )} {/* YYX#12:平台套图 —— 搜索 icon 右侧加筛选按钮,点开悬浮弹窗按平台筛选结果图 */} {mode === "cover" && (
@@ -1829,26 +1824,7 @@ export function ImageWorkbenchPage({
)} - {mode === "model" && ( - <> -
- -
-
{ setGridSort("recent"); setTbOpen(""); }}>最近添加
-
{ setGridSort("name"); setTbOpen(""); }}>按名称
-
-
-
- -
-
{ setGridQuery(""); setTbOpen(""); }}>全部模特
- {(personAssets.length > 0 ? personAssets.map((p) => p.name) : FALLBACK_MODELS.map((m) => m.name)).map((nm) => ( -
{ setGridQuery(nm); setTbOpen(""); }}>{nm}
- ))} -
-
- - )} + {/* row40:模特模式已改单卡「+」选择,原「时间排序 / 全部模特」下拉随网格一并移除 */}
@@ -1859,65 +1835,40 @@ export function ImageWorkbenchPage({
1 选择模特 - {/* P0①:接入现成模特库全屏弹窗(原「全部模特→」仅内联展开;现可打开完整演员库选用) */} - - {personAssets.length > 6 && ( - setShowAllModels((v) => !v)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setShowAllModels((v) => !v); } }}>{showAllModels ? "收起 ↑" : `全部 (${personAssets.length})`} - )}
-
- {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 ( - - ); - }) - : FALLBACK_MODELS - .filter((item) => !gridQuery || item.name.toLowerCase().includes(gridQuery.toLowerCase())) - .sort((a, b) => (gridSort === "name" ? a.name.localeCompare(b.name) : 0)) - .map((item) => ( - - ))} + ); + } + return ( + + ); + })()}
) : ( @@ -1950,7 +1901,7 @@ export function ImageWorkbenchPage({ 生成设置
-
// 生成数量{mode === "model" ? " (每模特)" : ""}
+
// 生成数量
{countOptions.map((value) => (
-
++ navigate("projects", { tab: "all" })} /> navigate("projects", { tab: "wip" })} /> navigate("projects", { tab: "done" })} />
+
++ navigate("projects", { tab: "all" })} /> navigate("projects", { tab: "wip" })} /> navigate("projects", { tab: "done" })} />

最近项目

{loading && projects.length === 0 ? : projects.slice(0, 6).map((project) => )}

快捷入口

[ /shortcuts ]
navigate("products")} /> navigate("library")} /> navigate("account")} /> navigate("projects")} />

提示

[ FAQ ]
扣费规则生成失败、超时、用户重跑 — 均不扣费。仅在你点 [ 确认通过 ] 时按 token 实际结算。
{/* YYX#11:新建商品抽屉 · 复用商品库同一组件,创建成功后跳商品库看新商品 */} {onCreateProduct && ( diff --git a/core/frontend/src/routes/library.tsx b/core/frontend/src/routes/library.tsx index 1a516e4..5b8c8bc 100644 --- a/core/frontend/src/routes/library.tsx +++ b/core/frontend/src/routes/library.tsx @@ -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= 传进列表/批次请求;选项走 facets.products + const [productFilter, setProductFilter] = useState(""); const [sortDesc, setSortDesc] = useState(true); // 编辑模式 + 多选 + 元数据筛选(性别/年龄/角色/场景类型/关联/时长 走 asset.metadata,真实存在才有可选项) const [editMode, setEditMode] = useState(false); const [selected, setSelected] = useState>(new Set()); + // 批次多选(图片成品 tab):按 batch_id 存;删除时展开成整批 asset id(见 bulkDeleteIds) + const [selectedBatches, setSelectedBatches] = useState>(new Set()); const [metaFilter, setMetaFilter] = useState>({}); // 删除确认:单删传 [id],批删传选中数组 const [confirmIds, setConfirmIds] = useState(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([]); @@ -448,7 +459,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD // 视频成品:按项目素材包(点包卡 → 弹窗看该项目所有片段) const [packs, setPacks] = useState([]); const [openPack, setOpenPack] = useState(null); - const [facets, setFacets] = useState<{ sources: string[]; kinds: string[]; metadata: Record }>({ sources: [], kinds: [], metadata: {} }); + const [facets, setFacets] = useState<{ sources: string[]; kinds: string[]; metadata: Record; 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 {tab === "videopacks" ? ( + <> +
+
+ + setQuery(event.target.value)} /> +
+ +
+ +
+
{ setSortDesc(true); setOpenChip(""); }}> + 片段最多 +
+
{ setSortDesc(false); setOpenChip(""); }}> + 片段最少 +
+
+
+
+ +
// 显示 {shownPacks.length} / {filteredPacks.length} 个素材包{debouncedQuery ? "(已筛选)" : ""}
+
- {packs.length === 0 ? ( -
// 还没有视频成品 · 去视频项目生成片段
+ {filteredPacks.length === 0 ? ( +
// {debouncedQuery ? "没有匹配的视频成品" : "还没有视频成品 · 去视频项目生成片段"}
) : ( - 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 }) )}
+ + ) : ( <>
@@ -668,6 +723,28 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
); } + if (chip.key === "product") { + // 关联商品:选项走 facets.products(本 tab 下真实关联到的商品),选中后 product= 进列表/批次请求 + const curTitle = facets.products.find((p) => p.id === productFilter)?.title || ""; + return ( +
+ +
+
{ setProductFilter(""); setOpenChip(""); }}> + 全部商品 +
+ {facets.products.length > 0 &&
} + {facets.products.map((p) => ( +
{ setProductFilter(p.id); setOpenChip(""); }}> + {p.title} +
+ ))} +
+
+ ); + } // 其余维度走 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 ? (
- {batches.map((batch) => ( -
!editMode && setOpenBatch(batch)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (!editMode) setOpenBatch(batch); } }}> - {editMode && onDelete && ( - - )} -
- {batch.cover ? {batch.name} : 无图} - {batch.count} 张 -
-
{batch.name}
生成批次
-
- ))} + {batches.map((batch) => { + const isSel = selectedBatches.has(batch.batch_id); + // 编辑态:整卡点击 = 选中/取消该批次(走 lib-bulk-bar 批量删);非编辑态:点开批次弹窗 + const onBatchClick = editMode ? () => toggleBatch(batch.batch_id) : () => setOpenBatch(batch); + return ( +
{ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onBatchClick(); } }}> + +
+ {batch.cover ? {batch.name} : 无图} + {batch.count} 张 +
+
{batch.name}
生成批次
+
+ ); + })}
) : loading ? ( @@ -826,9 +904,10 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD document.body )} - {/* 生成批次弹窗:看该批次所有图片(点单图开详情/大图) */} + {/* 生成批次弹窗:看该批次所有图片(点单图开详情/大图)。 + under-confirm:批次弹窗内点单图删除会弹 ConfirmModal(.modal-bg),故批次层必须降到 .modal-bg 之下,否则二次确认被压住看不见(R109 修复) */} {openBatch && createPortal( -
{ if (e.target === e.currentTarget) setOpenBatch(null); }}> +
{ if (e.target === e.currentTarget) setOpenBatch(null); }}>
@@ -870,11 +949,11 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD )} {/* 编辑模式浮动批量操作栏(scope 到资产库;删除/清空/完成) */} -
0 ? " show" : ""}`} role="toolbar" aria-label="批量操作"> - 已选 {selected.size} - +
0 ? " show" : ""}`} role="toolbar" aria-label="批量操作"> + 已选 {selCount} {isBatchTab ? "批" : "项"} + - diff --git a/core/frontend/src/routes/models.tsx b/core/frontend/src/routes/models.tsx index 64bf27c..652dfae 100644 --- a/core/frontend/src/routes/models.tsx +++ b/core/frontend/src/routes/models.tsx @@ -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( +
+
event.stopPropagation()}> + ++ +
+
+
+ {model.name} + {model.is_official ? "官方模板" : model.source === "upload" ? "真人上传" : "AI 生成"} +
+ +
+
+
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 && 无图} +
+
+ {model.is_official && 官方模板} + {model.source === "upload" ? "真人上传" : "AI 生成"} +
+ {model.description &&

{model.description}

} +
// 三视图
+
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 && // 无三视图} +
+
+
+
, + 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(null); + const [detail, setDetail] = useState(null); + const [preview, setPreview] = useState(null); const fileRef = useRef(null); useEffect(() => { @@ -96,7 +157,14 @@ export function ModelsPage() { ) : (
{items.map((m) => ( -
+
setDetail(m)} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setDetail(m); } }} + >
官方模板} {!m.is_official && (confirmId === m.id ? ( -
+
e.stopPropagation()}>
) : ( - + ))}
@@ -131,6 +199,9 @@ export function ModelsPage() { ))}
)} + + setDetail(null)} onZoom={setPreview} /> + setPreview(null)} />
); } diff --git a/core/frontend/src/routes/projects.tsx b/core/frontend/src/routes/projects.tsx index 04448b3..84a9c8b 100644 --- a/core/frontend/src/routes/projects.tsx +++ b/core/frontend/src/routes/projects.tsx @@ -665,7 +665,7 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
{Array.from({ length: PROJ_STAGE_TOTAL }, (_, k) => k + 1).map((i) => )}
- {no}/{PROJ_STAGE_TOTAL} + {no}/{PROJ_STAGE_TOTAL}
{projStatusLabel(project)} diff --git a/core/frontend/src/routes/trash.tsx b/core/frontend/src/routes/trash.tsx index c25e372..cf58534 100644 --- a/core/frontend/src/routes/trash.tsx +++ b/core/frontend/src/routes/trash.tsx @@ -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(null); const [busyId, setBusyId] = useState(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 (
@@ -81,6 +120,12 @@ export function TrashPage({ onRestore, onPurge }: { 可恢复,或彻底删除(不可恢复)
+
+ + +
{loading ? ( @@ -165,6 +210,16 @@ export function TrashPage({ onRestore, onPurge }: { )} )} + + setPurgeAllOpen(false)} + onConfirm={purgeAll} + />
); }