feat(core): Wave 3 batch2 — 资产库详情/上传/批量 + AI工具模特库/gen-card/pl-modal

资产库(P0,sub-agent):多选+吸底 bulk-bar、资产详情弹窗(立绘+三视图+标签/属性+商品媒体画廊)、
  上传 Drawer→居中 Modal(类型分段+按类型字段+拖拽预览)、缺三视图徽标(复用现成类)
AI图片工具(P0,sub-agent):接入现成 ActorLibrary 模特库弹窗、gen-card 三件套(再生成/就地反馈/
  已采用绿角标,采用反馈去全局弹窗改就地)、平台套图恢复多选+分组、商品库 pl-modal 选择器(restraint重写)

验证: tsc 0 · build 0 · 走查 资产详情/上传弹窗开关ESC + 模特库/pl-modal 打开 全过 · 0 console error

⚠️ 真实数据缺(未造假):资产「移动到」改分类需 api.updateAsset 支持 category 字段;
  详情三视图版本历史 V1 是 mock 无真实源;平台无 platform 字段→前端组织分批。均移交监督。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-19 05:08:21 +08:00
co-authored by Claude Opus 4.8
parent 0083918207
commit 38f56cb037
5 changed files with 1395 additions and 164 deletions
+277 -3
View File
@@ -391,6 +391,19 @@
.image-workbench .iw-main-h .tb-chip svg { width: 10px; height: 10px; color: var(--ink-3); } .image-workbench .iw-main-h .tb-chip svg { width: 10px; height: 10px; color: var(--ink-3); }
.image-workbench .iw-main-h .tb-menu-wrap { position: relative; } .image-workbench .iw-main-h .tb-menu-wrap { position: relative; }
.image-workbench .iw-main-h .tb-search-wrap { display: inline-flex; align-items: center; } .image-workbench .iw-main-h .tb-search-wrap { display: inline-flex; align-items: center; }
/* P0④「商品库」入口(打开 pl-modal 全屏选择器 · 二级胶囊) */
.image-workbench .iw-main-h .iw-pl-btn {
display: inline-flex; align-items: center; gap: 6px;
height: 32px; padding: 0 12px 0 10px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
color: var(--black-alpha-72);
font-size: 13px; font-family: inherit; cursor: pointer;
transition: border-color var(--t-base), color var(--t-base), background var(--t-base);
}
.image-workbench .iw-main-h .iw-pl-btn:hover { border-color: var(--heat-20); color: var(--heat); background: var(--heat-12); }
.image-workbench .iw-main-h .iw-pl-btn svg { width: 13px; height: 13px; }
.image-workbench .iw-main-body { .image-workbench .iw-main-body {
flex: 1; min-height: 0; flex: 1; min-height: 0;
display: grid; display: grid;
@@ -426,8 +439,24 @@
flex-shrink: 0; flex-shrink: 0;
} }
.image-workbench .iw-step-h .title { font-size: 14px; font-weight: 600; color: var(--accent-black); } .image-workbench .iw-step-h .title { font-size: 14px; font-weight: 600; color: var(--accent-black); }
.image-workbench .iw-step-h .right { margin-left: auto; font-size: 12px; color: var(--heat); cursor: pointer; } .image-workbench .iw-step-h .right { font-size: 12px; color: var(--heat); cursor: pointer; }
.image-workbench .iw-step-h .right:hover { text-decoration: underline; } .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 { .image-workbench .iw-sub-h {
font-size: 12px; color: var(--black-alpha-48); font-size: 12px; color: var(--black-alpha-48);
margin-bottom: 6px; margin-bottom: 6px;
@@ -687,7 +716,40 @@
transition: background var(--t-base), color var(--t-base); transition: background var(--t-base), color var(--t-base);
} }
.image-workbench .gen-img-btn:hover { background: var(--black-alpha-4); color: var(--accent-black); } .image-workbench .gen-img-btn:hover { background: var(--black-alpha-4); color: var(--accent-black); }
.image-workbench .gen-img-btn:disabled { color: var(--black-alpha-24); cursor: not-allowed; }
.image-workbench .gen-img-btn:disabled:hover { background: transparent; color: var(--black-alpha-24); }
.image-workbench .gen-img-btn svg { width: 14px; height: 14px; } .image-workbench .gen-img-btn svg { width: 14px; height: 14px; }
/* §4.18 ② 中央就地反馈(采用瞬间亮 1.5s · 暗底白字 · 禁全局 toast) */
.image-workbench .gen-image-feedback {
position: absolute; inset: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px;
background: rgba(38, 38, 38, .88);
color: var(--accent-white);
border-radius: var(--r-md);
opacity: 0;
pointer-events: none;
transition: opacity .2s ease;
z-index: 3;
}
.image-workbench .gen-image.show-feedback .gen-image-feedback { opacity: 1; }
.image-workbench .gen-image-feedback svg { width: 20px; height: 20px; }
.image-workbench .gen-image-feedback span { font-size: 12px; font-weight: 500; letter-spacing: .02em; }
/* §4.18 ③ 左上「已采用」绿角标(批次 adopted 常驻 · forest token) */
.image-workbench .gen-adopt-badge {
position: absolute; top: 8px; left: 8px;
display: inline-flex; align-items: center; gap: 4px;
height: 22px; padding: 0 8px 0 6px;
background: var(--accent-forest);
color: var(--accent-white);
font-family: var(--font-mono); font-size: 11px; font-weight: 600;
letter-spacing: .02em;
border-radius: var(--r-pill);
z-index: 3;
}
.image-workbench .gen-adopt-badge svg { width: 11px; height: 11px; }
/* 底部操作按钮行(§4.18 .gen-card-actions · 二级 + ghost · 不放主橙) */ /* 底部操作按钮行(§4.18 .gen-card-actions · 二级 + ghost · 不放主橙) */
.image-workbench .gen-card-actions { .image-workbench .gen-card-actions {
display: flex; gap: 8px; display: flex; gap: 8px;
@@ -735,6 +797,32 @@
/* 批次卡:复用 .gen-card。每批各带一个导航头(.iw-pv-h),批次间靠「卡片→下一个头」拉开间距; /* 批次卡:复用 .gen-card。每批各带一个导航头(.iw-pv-h),批次间靠「卡片→下一个头」拉开间距;
首个头紧跟 iw-preview 顶 padding,无需额外上间距(故只给跟在卡片后的头加 margin)。 */ 首个头紧跟 iw-preview 顶 padding,无需额外上间距(故只给跟在卡片后的头加 margin)。 */
.image-workbench .gen-batch-card + .iw-pv-h { margin-top: 22px; } .image-workbench .gen-batch-card + .iw-pv-h { margin-top: 22px; }
/* P0③ · 平台套图多平台分组 section 头(平台 logo + 名 + 批次数) */
.image-workbench .iw-cover-group-h {
display: flex; align-items: center; gap: 8px;
margin: 6px 0 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-faint);
}
.image-workbench .gen-batch-card + .iw-cover-group-h { margin-top: 28px; }
.image-workbench .iw-cover-group-h .cg-logo {
width: 24px; height: 24px;
border-radius: var(--r-sm);
display: grid; place-items: center;
color: var(--accent-white);
font-family: var(--font-mono); font-size: 12px; font-weight: 600;
flex-shrink: 0;
}
.image-workbench .iw-cover-group-h .cg-name { font-size: 14px; font-weight: 600; color: var(--accent-black); }
.image-workbench .iw-cover-group-h .cg-ct {
margin-left: auto;
font-family: var(--font-mono); font-size: 12px;
color: var(--black-alpha-48); letter-spacing: .02em;
}
/* 分组头里第一个导航头紧跟,无需额外上间距 */
.image-workbench .iw-cover-group-h + .iw-pv-h { margin-top: 0; }
/* 批次头(数量 + 状态) */ /* 批次头(数量 + 状态) */
.image-workbench .gen-batch-h { .image-workbench .gen-batch-h {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: 10px;
@@ -780,10 +868,10 @@
} }
/* 批次底部气泡向上弹出(底部空间不足) */ /* 批次底部气泡向上弹出(底部空间不足) */
.image-workbench .gen-bubble.gen-bubble-up { top: auto; bottom: calc(100% + 6px); } .image-workbench .gen-bubble.gen-bubble-up { top: auto; bottom: calc(100% + 6px); }
.image-workbench .gen-img-more:hover .gen-bubble,
.image-workbench .gen-img-more:focus-within .gen-bubble,
.image-workbench .gen-batch-more:hover .gen-bubble, .image-workbench .gen-batch-more:hover .gen-bubble,
.image-workbench .gen-batch-more:focus-within .gen-bubble { display: block; } .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; }
.image-workbench .gen-bubble-item { .image-workbench .gen-bubble-item {
width: 100%; width: 100%;
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
@@ -1622,3 +1710,189 @@
.asset-factory .history-type { font-size: 12px; color: var(--black-alpha-48); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .02em; } .asset-factory .history-type { font-size: 12px; color: var(--black-alpha-48); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .02em; }
.asset-factory .history-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 10px; } .asset-factory .history-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 10px; }
.asset-factory .history-foot .mono { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); } .asset-factory .history-foot .mono { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); }
/* ============================================================
D · 商品库全屏选择器(.pl-modal · P0④)
基线:public/exact/model-photo.html .pl-modal —— 重写为 restraint token
(旧 styles.css .pl-card/.pl-modal 是暖米/出错色,禁复用)。
全屏无遮罩,portal 到 body,顶 z-index。多选 + 右上勾 + 底部已选汇总。
============================================================ */
.pl-modal-bg {
position: fixed; inset: 0;
background: var(--surface);
z-index: 998;
display: flex;
}
.pl-modal {
flex: 1;
background: var(--surface);
overflow: hidden;
display: flex; flex-direction: column;
min-width: 0;
}
.pl-modal-h {
display: flex; align-items: center; gap: 14px;
padding: 14px 28px;
border-bottom: 1px solid var(--border-faint);
flex-shrink: 0;
}
.pl-modal-h h2 { font-size: 16px; font-weight: 600; color: var(--accent-black); }
.pl-modal-h .ct {
font-family: var(--font-mono); font-size: 12px;
color: var(--black-alpha-48); letter-spacing: .02em;
}
.pl-modal-h .actions { margin-left: auto; display: flex; gap: 10px; }
.pl-modal-h .x {
width: 32px; height: 32px;
display: grid; place-items: center;
background: transparent; border: 0; border-radius: var(--r-sm);
cursor: pointer; color: var(--black-alpha-56);
transition: background var(--t-base), color var(--t-base);
}
.pl-modal-h .x:hover { background: var(--black-alpha-8); color: var(--accent-black); }
.pl-modal-body {
flex: 1; min-height: 0;
display: grid;
grid-template-columns: 200px minmax(0, 1fr);
}
@media (max-width: 900px) { .pl-modal-body { grid-template-columns: 160px minmax(0, 1fr); } }
/* 左 · 分类侧栏 */
.pl-side {
border-right: 1px solid var(--border-faint);
padding: 18px 0;
overflow-y: auto;
}
.pl-side .pl-side-h {
padding: 0 20px 8px;
font-family: var(--font-mono); font-size: 12px;
color: var(--black-alpha-48); letter-spacing: .06em;
text-transform: uppercase;
}
.pl-side .pl-side-item {
display: flex; align-items: center; gap: 8px;
padding: 9px 20px;
cursor: pointer; color: var(--black-alpha-72);
font-size: 13px;
border-left: 3px solid transparent;
transition: background var(--t-base), color var(--t-base);
}
.pl-side .pl-side-item:hover { background: var(--black-alpha-4); }
.pl-side .pl-side-item.active {
background: var(--heat-12); color: var(--accent-black);
border-left-color: var(--heat); font-weight: 600;
}
.pl-side .pl-side-item .ct {
margin-left: auto;
font-family: var(--font-mono); font-size: 12px;
color: var(--black-alpha-48);
}
/* 右 · 搜索 toolbar + 网格 + 底部汇总 */
.pl-main { overflow: hidden; padding: 0; display: flex; flex-direction: column; min-width: 0; }
.pl-toolbar {
padding: 14px 28px;
border-bottom: 1px solid var(--border-faint);
display: flex; align-items: center; gap: 12px;
flex-shrink: 0;
}
.pl-toolbar .search { position: relative; flex: 1; max-width: 360px; }
.pl-toolbar .search input {
width: 100%; height: 32px;
padding: 0 10px 0 32px;
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
font-size: 13px; font-family: inherit;
color: var(--accent-black); outline: none;
transition: border-color var(--t-base), background var(--t-base);
}
.pl-toolbar .search input:focus { border-color: var(--heat-40); background: var(--surface); }
.pl-toolbar .search input::placeholder { color: var(--black-alpha-48); }
.pl-toolbar .search svg {
position: absolute; left: 10px; top: 50%; transform: translateY(-50%);
width: 14px; height: 14px; color: var(--black-alpha-48);
pointer-events: none;
}
.pl-toolbar .btn-new {
height: 32px; padding: 0 14px;
display: inline-flex; align-items: center; gap: 6px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
color: var(--accent-black);
font-family: inherit; font-size: 13px;
cursor: pointer; margin-left: auto;
transition: background var(--t-base), border-color var(--t-base);
}
.pl-toolbar .btn-new:hover { background: var(--background-lighter); border-color: var(--black-alpha-24); }
.pl-scroll {
flex: 1; min-height: 0;
overflow-y: auto;
padding: 20px 28px 28px;
}
.pl-empty {
padding: 60px 20px;
text-align: center; color: var(--black-alpha-48);
font-size: 13px; line-height: 1.7;
}
.pl-empty .mono { font-family: var(--font-mono); font-size: 12px; letter-spacing: .04em; margin-bottom: 6px; }
.pl-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
.pl-card {
position: relative;
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
padding: 10px;
cursor: pointer;
display: flex; flex-direction: column; gap: 6px;
text-align: left; font-family: inherit;
transition: background var(--t-base), border-color var(--t-base);
}
.pl-card:hover { background: var(--surface); }
.pl-card.selected { border-color: var(--heat); background: var(--heat-12); }
.pl-card .pl-thumb {
aspect-ratio: 1;
border-radius: var(--r-sm);
}
.pl-card .pl-thumb.has-real-media { overflow: hidden; background: var(--black-alpha-4); }
.pl-card .pl-thumb.has-real-media img { width: 100%; height: 100%; object-fit: cover; display: block; }
.pl-card .pl-name {
font-size: 13px; font-weight: 500; color: var(--accent-black);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.pl-card .pl-meta {
font-family: var(--font-mono); font-size: 12px;
color: var(--black-alpha-48); letter-spacing: .02em;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.pl-card .pl-check {
position: absolute; top: 16px; right: 16px;
width: 22px; height: 22px;
background: var(--surface);
border: 1.5px solid var(--black-alpha-24);
border-radius: 50%;
display: grid; place-items: center;
z-index: 2; color: var(--accent-white);
}
.pl-card .pl-check svg { width: 11px; height: 11px; visibility: hidden; }
.pl-card.selected .pl-check { background: var(--heat); border-color: var(--heat); }
.pl-card.selected .pl-check svg { visibility: visible; }
/* 底部 · 已选汇总 + 操作 */
.pl-modal-f {
padding: 14px 28px;
border-top: 1px solid var(--border-faint);
display: flex; justify-content: flex-end; align-items: center; gap: 10px;
flex-shrink: 0;
}
.pl-modal-f .summary {
margin-right: auto;
font-family: var(--font-mono); font-size: 12px;
color: var(--black-alpha-56); letter-spacing: .02em;
}
.pl-modal-f .summary b { color: var(--heat); font-weight: 600; }
+185
View File
@@ -26,3 +26,188 @@
/* 编辑模式:开启「管理资产」后,资产卡删除按钮常显(否则全局只 hover 显) */ /* 编辑模式:开启「管理资产」后,资产卡删除按钮常显(否则全局只 hover 显) */
body.edit-mode .asset-card .card-del-btn { opacity: 1 !important; pointer-events: auto !important; } body.edit-mode .asset-card .card-del-btn { opacity: 1 !important; pointer-events: auto !important; }
/* ─────────────────────────────────────────────────────────────
批量编辑 · 多选 + 吸底 bulk-bar(scope 到资产库;蓝本 products-page.css)
───────────────────────────────────────────────────────────── */
/* 卡片左上多选框(默认隐藏,edit-mode 才出) */
.library-page .asset-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 .asset-card .card-check svg { width: 11px; height: 11px; visibility: hidden; }
body.edit-mode .library-page .asset-card { cursor: pointer; }
body.edit-mode .library-page .asset-card .card-check { display: grid; }
body.edit-mode .library-page .asset-card.selected .card-check { background: var(--heat); border-color: var(--heat); }
body.edit-mode .library-page .asset-card.selected .card-check svg { visibility: visible; }
body.edit-mode .library-page .asset-card.selected { border-color: var(--heat); box-shadow: 0 0 0 1px var(--heat) inset; }
/* 多选态隐藏单卡删除(改走 bulk-bar 批量删) */
body.edit-mode .library-page .asset-card .card-del-btn { opacity: 0 !important; pointer-events: none !important; }
/* 吸底批量操作栏 · 仅 edit-mode 出现 */
.lib-bulk-bar {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
background: var(--accent-black); color: var(--accent-white); border-radius: var(--r-md);
padding: 10px 14px 10px 18px; display: none; align-items: center; gap: 16px;
box-shadow: 0 8px 24px rgba(0, 0, 0, .18); z-index: 100; font-size: 13px;
}
body.edit-mode .lib-bulk-bar { display: inline-flex; }
.lib-bulk-bar .ct { font-family: var(--font-mono); letter-spacing: .02em; }
.lib-bulk-bar .ct b { color: var(--heat); font-weight: 600; padding: 0 3px; }
.lib-bulk-bar .sep { width: 1px; height: 18px; background: rgba(255, 255, 255, .16); }
.lib-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: 13px; font-family: inherit; cursor: pointer;
display: inline-flex; align-items: center; gap: 5px;
transition: background .15s, border-color .15s;
}
.lib-bulk-bar button:hover { background: rgba(255, 255, 255, .08); }
.lib-bulk-bar button.danger { background: var(--accent-crimson); border-color: var(--accent-crimson); }
.lib-bulk-bar button.danger:hover { filter: brightness(1.06); }
.lib-bulk-bar button:disabled { opacity: .4; cursor: not-allowed; }
.lib-bulk-bar button svg { width: 12px; height: 12px; }
.lib-bulk-bar .clear-sel { color: rgba(255, 255, 255, .6); font-size: 12px; cursor: pointer; background: none; border: 0; padding: 4px 6px; }
.lib-bulk-bar .clear-sel:hover { color: var(--accent-white); }
/* edit-toggle 激活态(管理资产 → 完成 高亮) */
.library-page .btn-edit-toggle.active { background: var(--accent-black); color: var(--accent-white); border-color: var(--accent-black); }
/* ─────────────────────────────────────────────────────────────
资产详情弹窗 · .adx-*(立绘大图 + 三视图/版本历史 + 简介/标签/属性 + 商品媒体画廊)
复用全局 .modal-bg 遮罩 + corner-tr/bl 装订角;主体类自建 scope 进 .adx-。
───────────────────────────────────────────────────────────── */
.asset-detail-modal {
position: relative;
background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md);
width: min(1040px, 92vw); max-height: calc(100vh - 80px);
overflow: hidden; display: flex; flex-direction: column;
box-shadow: 0 16px 48px rgba(0, 0, 0, .18);
opacity: 0; transform: scale(.97); transition: opacity .26s, transform .26s;
}
.modal-bg.show .asset-detail-modal { opacity: 1; transform: scale(1); }
.asset-detail-modal.product-mode { width: min(1100px, 94vw); }
.adx-h { display: flex; align-items: center; gap: 10px; padding: 14px 20px; border-bottom: 1px solid var(--border-faint); flex-shrink: 0; }
.adx-h h2 { font-size: 15px; font-weight: 600; color: var(--accent-black); margin: 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.adx-kind { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .02em; flex-shrink: 0; }
.adx-shield { flex-shrink: 0; }
.adx-shield svg { vertical-align: -1px; margin-right: 2px; }
.adx-x { width: 30px; height: 30px; display: grid; place-items: center; background: transparent; border: 0; cursor: pointer; color: var(--black-alpha-56); border-radius: var(--r-sm); margin-left: auto; flex-shrink: 0; transition: background var(--t-base), color var(--t-base); }
.adx-x:hover { background: var(--black-alpha-8); color: var(--accent-crimson); }
.adx-body { padding: 20px 24px 24px; overflow-y: auto; flex: 1; min-height: 0; }
.adx-grid { display: grid; grid-template-columns: 320px 1fr; gap: 24px; }
/* 左:立绘 / 主图 */
.adx-lead { display: flex; flex-direction: column; gap: 10px; }
.adx-lead-wrap { position: relative; }
.adx-lead-img { aspect-ratio: 3 / 4; border-radius: var(--r-md); overflow: hidden; display: grid; place-items: center; background: var(--background-lighter); }
.asset-detail-modal.product-mode .adx-lead-img { aspect-ratio: 1; }
.adx-lead-img[role="button"] { cursor: zoom-in; }
.adx-lead-img img,
.adx-lead-img video { width: 100%; height: 100%; object-fit: cover; display: block; }
.adx-audio-ph { display: flex; flex-direction: column; align-items: center; gap: 8px; color: var(--black-alpha-48); }
.adx-audio-ph svg { width: 30px; height: 30px; }
.adx-audio-ph .mono { font-size: 12px; letter-spacing: .08em; }
.adx-zoom-btn { position: absolute; top: 8px; right: 8px; width: 30px; height: 30px; border-radius: var(--r-sm); background: rgba(255, 255, 255, .92); border: 1px solid var(--black-alpha-12); display: grid; place-items: center; color: var(--black-alpha-56); cursor: pointer; opacity: 0; transition: opacity var(--t-base), background var(--t-base), color var(--t-base); }
.adx-zoom-btn svg { width: 14px; height: 14px; }
.adx-lead-wrap:hover .adx-zoom-btn { opacity: 1; }
.adx-zoom-btn:hover { background: var(--heat); color: var(--accent-white); border-color: var(--heat); }
.adx-thumbs { display: flex; gap: 8px; flex-wrap: wrap; }
.adx-thumb { flex: 0 0 56px; aspect-ratio: 3 / 4; border-radius: var(--r-sm); border: 1px solid var(--border-faint); cursor: pointer; overflow: hidden; background: var(--background-lighter); padding: 0; display: grid; place-items: center; }
.adx-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.adx-thumb:hover { border-color: var(--heat-40); }
.adx-thumb.active { border-color: var(--heat); border-width: 2px; }
.adx-thumb .ph-frame { font-size: 12px; }
/* 右:分区 */
.adx-section + .adx-section { margin-top: 18px; }
.adx-section-h { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; color: var(--accent-black); margin-bottom: 10px; }
.adx-section-h .ic { width: 14px; height: 14px; color: var(--heat); display: grid; place-items: center; }
.adx-ratio-chip { margin-left: auto; font-family: var(--font-mono); font-size: 12px; padding: 2px 8px; border-radius: var(--r-sm); background: var(--background-lighter); border: 1px solid var(--border-faint); color: var(--black-alpha-56); }
.adx-tri-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.adx-tri-cell { aspect-ratio: 3 / 4; border-radius: var(--r-md); overflow: hidden; border: 1px solid var(--border-faint); background: var(--background-lighter); display: grid; place-items: center; cursor: pointer; padding: 0; }
.adx-tri-cell.single { grid-column: 1 / -1; aspect-ratio: 16 / 9; }
.adx-tri-cell img { width: 100%; height: 100%; object-fit: cover; display: block; }
.adx-tri-cell .ph-frame { font-size: 12px; }
.adx-tri-missing { display: flex; align-items: flex-start; gap: 8px; padding: 11px 13px; background: var(--heat-12); border: 1px solid var(--heat-20); border-radius: var(--r-sm); font-size: 12px; line-height: 1.6; color: var(--accent-black); }
.adx-tri-missing svg { color: var(--heat); flex-shrink: 0; margin-top: 1px; }
.adx-tri-missing b { font-weight: 600; }
.adx-intro { font-size: 13px; line-height: 1.7; color: var(--black-alpha-72); margin: 0; }
.adx-muted { color: var(--black-alpha-48); font-family: var(--font-mono); font-size: 12px; }
.adx-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.adx-tag { height: 24px; display: inline-flex; align-items: center; padding: 0 10px; border-radius: var(--r-pill); background: var(--background-lighter); border: 1px solid var(--border-faint); font-size: 12px; color: var(--black-alpha-64); }
.adx-props { display: grid; gap: 0; border: 1px solid var(--border-faint); border-radius: var(--r-md); overflow: hidden; }
.adx-prop-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 12px; font-size: 12.5px; }
.adx-prop-row + .adx-prop-row { border-top: 1px solid var(--border-faint); }
.adx-prop-row .k { color: var(--black-alpha-56); font-family: var(--font-mono); font-size: 12px; letter-spacing: .02em; }
.adx-prop-row .v { color: var(--accent-black); font-weight: 500; text-align: right; }
/* 商品媒体画廊 */
.adx-gallery { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.adx-media-card { border: 1px solid var(--border-faint); border-radius: var(--r-md); overflow: hidden; background: var(--surface); cursor: pointer; padding: 0; transition: background var(--t-base), border-color var(--t-base); }
.adx-media-card:hover { background: var(--black-alpha-4); border-color: var(--black-alpha-24); }
.adx-media-img { aspect-ratio: 1; background: var(--background-lighter); position: relative; display: grid; place-items: center; }
.adx-media-img img { width: 100%; height: 100%; object-fit: cover; display: block; }
.adx-media-img .asset-badge { position: absolute; top: 6px; left: 6px; font-family: var(--font-mono); font-size: 12px; letter-spacing: .04em; padding: 2px 6px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); color: var(--black-alpha-56); }
.adx-f { padding: 14px 20px; border-top: 1px solid var(--border-faint); display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.adx-foot-dl { height: 32px; }
.adx-foot-dl svg { vertical-align: -2px; margin-right: 4px; }
.adx-foot-meta { font-size: 12px; color: var(--black-alpha-48); letter-spacing: .02em; margin-left: auto; }
.adx-foot-meta + .btn { margin-left: 0; }
@media (max-width: 1100px) {
.adx-grid { grid-template-columns: 260px 1fr; }
.adx-gallery { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
/* ─────────────────────────────────────────────────────────────
上传资产 · 居中 Modal · 类型分段 + 拖拽区 + 预览
复用全局 .modal / .modal-bg / .modal-h / .modal-b / .modal-f / .field。
───────────────────────────────────────────────────────────── */
.upload-modal { width: min(520px, 92vw); max-width: 520px; }
.upload-modal-form { display: contents; }
/* 标题占满 → 关闭键贴右上(modal-h 是 flex,默认不会把 .x 推到最右) */
.upload-modal .modal-h .ti { flex: 1; min-width: 0; }
.upload-modal .modal-h .modal-x { flex-shrink: 0; width: 32px; height: 32px; border-radius: var(--r-sm); display: grid; place-items: center; background: transparent; border: 0; padding: 0; color: var(--black-alpha-56); cursor: pointer; transition: background var(--t-base), color var(--t-base); }
.upload-modal .modal-h .modal-x:hover { background: var(--black-alpha-8); color: var(--accent-crimson); }
.upload-modal .modal-b { padding: 20px 24px; }
.upload-modal .field { margin-bottom: 16px; }
.upload-modal .field:last-child { margin-bottom: 0; }
/* 资产类型分段开关 */
.upload-kind-seg { display: inline-flex; gap: 4px; padding: 3px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-md); }
.uk-seg { height: 30px; padding: 0 14px; border: 0; background: transparent; color: var(--black-alpha-56); font-size: 12.5px; font-family: inherit; cursor: pointer; border-radius: var(--r-sm); transition: background var(--t-base), color var(--t-base); }
.uk-seg:hover { color: var(--accent-black); }
.uk-seg.active { background: var(--surface); color: var(--accent-black); font-weight: 500; box-shadow: 0 1px 2px rgba(0, 0, 0, .06); }
/* 拖拽区 */
.upload-zone { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; padding: 24px; cursor: pointer; border: 1px dashed var(--border-faint); border-radius: var(--r-md); background: var(--background-lighter); color: var(--black-alpha-56); font-size: 13px; transition: border-color .15s, background .15s, color .15s; }
.upload-zone:hover,
.upload-zone.dragover { border-color: var(--heat); background: var(--heat-8, var(--heat-12)); color: var(--heat); }
.upload-zone:hover .uz-ic,
.upload-zone.dragover .uz-ic { background: var(--heat); color: var(--accent-white); border-color: var(--heat); }
.upload-zone .uz-ic { width: 40px; height: 40px; border-radius: var(--r-md); border: 1px solid var(--border-faint); background: var(--surface); display: grid; place-items: center; color: var(--black-alpha-56); transition: background .15s, color .15s, border-color .15s; }
.upload-zone .uz-hint { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .02em; }
/* 预览 */
.upload-preview { position: relative; width: 110px; aspect-ratio: 1; border-radius: var(--r-md); overflow: hidden; border: 1px solid var(--border-faint); background: var(--background-lighter); }
.upload-preview.video { aspect-ratio: 9 / 16; width: 96px; }
.upload-preview img,
.upload-preview video { width: 100%; height: 100%; object-fit: cover; display: block; }
.upload-file-chip { width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; color: var(--black-alpha-48); }
.upload-file-chip .mono { font-family: var(--font-mono); font-size: 12px; letter-spacing: .04em; }
.upload-preview .preview-x { position: absolute; top: 8px; right: 8px; width: 24px; height: 24px; border-radius: var(--r-pill); background: rgba(38, 38, 38, .7); color: var(--accent-white); display: grid; place-items: center; cursor: pointer; border: 0; transition: background .15s, transform .15s; }
.upload-preview .preview-x:hover { background: var(--accent-crimson); transform: scale(1.08); }
.upload-modal .modal-f { align-items: center; }
.upload-foot-meta { flex: 1; font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .02em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.upload-foot-meta .accent { color: var(--heat); font-weight: 600; }
+393 -86
View File
@@ -1,4 +1,5 @@
import { Fragment, useEffect, useMemo, useRef, useState } from "react"; import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { ChangeEvent } from "react"; import type { ChangeEvent } from "react";
import { import {
ArrowLeft, ArrowLeft,
@@ -18,13 +19,15 @@ import {
Search, Search,
Sparkles, Sparkles,
Trash2, Trash2,
Users,
WandSparkles, WandSparkles,
X X
} from "lucide-react"; } from "lucide-react";
import type { AITask, Asset, ModelConfig, Product } from "../types"; import type { AITask, Asset, ModelConfig, Product } from "../types";
import { api } from "../api"; import { api } from "../api";
import { ActorLibrary } from "../components/actor-library";
import { SkeletonRows } from "../components/loading"; import { SkeletonRows } from "../components/loading";
import { MediaLightbox, SuccessModal } from "../components/overlays"; import { MediaLightbox } from "../components/overlays";
import { Pager } from "../components/pager"; import { Pager } from "../components/pager";
import type { Page } from "./route-config"; import type { Page } from "./route-config";
import { statusPill } from "./stage-config"; import { statusPill } from "./stage-config";
@@ -487,6 +490,10 @@ type GenBatch = {
productTitle?: string; productTitle?: string;
/** 该批次选中的模特资产 id(模特上身图:重跑时沿用同一模特) */ /** 该批次选中的模特资产 id(模特上身图:重跑时沿用同一模特) */
modelId?: string; modelId?: string;
/** 该批次选中的模特展示名(导航头显示) */
modelName?: string;
/** 该批次选中的平台 id 列表(平台套图:多选 → 各平台分组,P0③) */
platformIds?: string[];
}; };
export function ImageWorkbenchPage({ export function ImageWorkbenchPage({
@@ -521,12 +528,23 @@ export function ImageWorkbenchPage({
const [ratio, setRatio] = useState(meta.ratio); const [ratio, setRatio] = useState(meta.ratio);
const [style, setStyle] = useState("auto"); const [style, setStyle] = useState("auto");
const [count, setCount] = useState(mode === "image" ? "4" : "4"); const [count, setCount] = useState(mode === "image" ? "4" : "4");
// 行29:模特/平台改单选(原 string[] 多选 → 单个 id;沿用数组结构最小改动,长度恒 0/1) // 模特单选(长度恒 0/1);平台改回多选(P0③:可选多个平台,各出一组结果)
const [pickedIds, setPickedIds] = useState<string[]>([]); const [pickedIds, setPickedIds] = useState<string[]>([]);
// 模特库全屏弹窗(P0①:复用现成 ActorLibrary,mode=replace 选一个回填)
const [actorLibOpen, setActorLibOpen] = useState(false);
// 选中模特的展示名(从模特库/内联卡记下,导航头/CTA 显示用)
const [pickedModelName, setPickedModelName] = useState("");
// 商品库全屏选择器(P0④:pl-modal 多选 → 每个选中商品各起一批)
const [plOpen, setPlOpen] = useState(false);
const [plDraft, setPlDraft] = useState<string[]>([]);
const [plQuery, setPlQuery] = useState("");
const [plCat, setPlCat] = useState("");
// gen-card 就地反馈(§4.18:采用反馈不走全局弹窗,改卡内 show-feedback 1.5s)
const [feedbackKey, setFeedbackKey] = useState("");
// 单图「更多」气泡当前展开的 key(点击切换,点空收起)
const [openMore, setOpenMore] = useState("");
// 批次列表:每次生成/重跑追加一条,各自展示;可多批并行 generating // 批次列表:每次生成/重跑追加一条,各自展示;可多批并行 generating
const [batches, setBatches] = useState<GenBatch[]>([]); const [batches, setBatches] = useState<GenBatch[]>([]);
// 加入资产库成功提示(行32④)
const [adoptNotice, setAdoptNotice] = useState<{ count: number } | null>(null);
const [refImage, setRefImage] = useState<{ name: string; url: string } | null>(null); const [refImage, setRefImage] = useState<{ name: string; url: string } | null>(null);
const refInputRef = useRef<HTMLInputElement | null>(null); const refInputRef = useRef<HTMLInputElement | null>(null);
// 生成结果图片放大预览 // 生成结果图片放大预览
@@ -578,10 +596,27 @@ export function ImageWorkbenchPage({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [productId, mode]); }, [productId, mode]);
// 行29:单选——再点同一张取消,点别张替换(长度恒为 0 或 1) // 模特:单选(再点同一张取消,点别张替换);平台:多选(toggle 进/出数组)
function togglePick(id: string) { function togglePick(id: string, name?: string) {
setPickedIds((prev) => (prev[0] === id ? [] : [id])); if (mode === "cover") {
setPickedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
return;
} }
setPickedIds((prev) => (prev[0] === id ? [] : [id]));
setPickedModelName((prev) => (pickedIds[0] === id ? "" : name || ""));
}
// 单图就地反馈(§4.18):显示 1.5s 后自动撤掉
function flashFeedback(key: string) {
setFeedbackKey(key);
window.setTimeout(() => setFeedbackKey((k) => (k === key ? "" : k)), 1500);
}
// 点击空白收起单图「更多」气泡
useEffect(() => {
if (!openMore) return;
const close = (event: MouseEvent) => { if (!(event.target as HTMLElement).closest(".gen-img-more")) setOpenMore(""); };
document.addEventListener("click", close);
return () => document.removeEventListener("click", close);
}, [openMore]);
const ratioVar = ratio.replace(":", " / "); const ratioVar = ratio.replace(":", " / ");
const candidateCount = Math.max(1, Number(count) || 4); const candidateCount = Math.max(1, Number(count) || 4);
@@ -589,7 +624,9 @@ export function ImageWorkbenchPage({
// 模特上身图需结合「商品图 + 模特图」合成,故必须先选商品且选一个模特,否则只是凭文字脑补 // 模特上身图需结合「商品图 + 模特图」合成,故必须先选商品且选一个模特,否则只是凭文字脑补
const canGenerate = const canGenerate =
prompt.trim().length > 0 && prompt.trim().length > 0 &&
(mode !== "model" || (!!product?.id && pickedIds.length > 0)); (mode !== "model" || (!!product?.id && pickedIds.length > 0)) &&
// 平台套图:必须选商品 + 至少一个平台(P0③ 多选)
(mode !== "cover" || (!!product?.id && pickedIds.length > 0));
const anyGenerating = batches.some((b) => b.status === "generating"); const anyGenerating = batches.some((b) => b.status === "generating");
/* 多批次本地持久化(行33):用 ai-tools 私有 key,不与 App 的单批 `airshelf:imgwb:{mode}` 抢同一槽。 /* 多批次本地持久化(行33):用 ai-tools 私有 key,不与 App 的单批 `airshelf:imgwb:{mode}` 抢同一槽。
@@ -605,32 +642,38 @@ export function ImageWorkbenchPage({
} }
} }
async function runGenerate() { /* 单批次执行:追加占位 → onGenerate → 回填结果/失败。所有提交路径(立即生成 / 重跑 / 单图再生成 / 平台分组)共用。 */
if (!canGenerate) return; async function startBatch(opts: {
prompt: string;
ratio: string;
count: number;
productId?: string;
productTitle?: string;
modelId?: string;
modelName?: string;
platformIds?: string[];
}) {
const batchId = `b-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; const batchId = `b-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const newBatch: GenBatch = { const newBatch: GenBatch = {
id: batchId, id: batchId,
prompt: prompt.trim(), prompt: opts.prompt,
ratio, ratio: opts.ratio,
count: candidateCount, count: opts.count,
status: "generating", status: "generating",
results: [], results: [],
adopted: false, adopted: false,
ts: Date.now(), ts: Date.now(),
productId: product?.id, productId: opts.productId,
productTitle: product?.title, productTitle: opts.productTitle,
modelId: mode === "model" ? pickedIds[0] : undefined modelId: opts.modelId,
modelName: opts.modelName,
platformIds: opts.platformIds
}; };
// 追加新批次(行32②:不覆盖,在下面新增一行)
setBatches((prev) => [...prev, newBatch]); setBatches((prev) => [...prev, newBatch]);
try { try {
const result = await onGenerate({ prompt: prompt.trim(), mode, count: candidateCount, product_id: product?.id, model_id: mode === "model" ? pickedIds[0] : undefined, ratio }); const result = await onGenerate({ prompt: opts.prompt, mode, count: opts.count, product_id: opts.productId, model_id: opts.modelId, ratio: opts.ratio });
setBatches((prev) => { setBatches((prev) => {
const next = prev.map((b) => const next = prev.map((b) => (b.id === batchId ? { ...b, status: result?.assets ? ("done" as const) : ("failed" as const), results: result?.assets || [] } : b));
b.id === batchId
? { ...b, status: result?.assets ? ("done" as const) : ("failed" as const), results: result?.assets || [] }
: b
);
persistBatches(next); persistBatches(next);
return next; return next;
}); });
@@ -643,42 +686,44 @@ export function ImageWorkbenchPage({
} }
} }
async function runGenerate() {
if (!canGenerate) return;
const base = {
prompt: prompt.trim(),
ratio,
count: candidateCount,
productId: product?.id,
productTitle: product?.title
};
if (mode === "cover") {
// P0③:每个选中平台各起一批 → 右侧自然形成多平台分组 section;
// 平台名拼进提示词,让各平台套图按其版式倾向出图(后端无 platform 字段,靠 prompt 表达)
for (const pid of pickedIds) {
const platName = PLATFORM_OPTIONS.find((p) => p.id === pid)?.name;
void startBatch({ ...base, prompt: platName ? `${base.prompt}${platName}平台套图版式` : base.prompt, platformIds: [pid] });
}
return;
}
void startBatch({
...base,
modelId: mode === "model" ? pickedIds[0] : undefined,
modelName: mode === "model" ? pickedModelName : undefined
});
}
/* 重跑指定批次(行32①②):用该批次的参数新起一个批次追加到列表末尾,原批次保留。 */ /* 重跑指定批次(行32①②):用该批次的参数新起一个批次追加到列表末尾,原批次保留。 */
async function rerunBatch(src: GenBatch) { async function rerunBatch(src: GenBatch) {
const batchId = `b-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; await startBatch({
const newBatch: GenBatch = {
id: batchId,
prompt: src.prompt, prompt: src.prompt,
ratio: src.ratio, ratio: src.ratio,
count: src.count, count: src.count,
status: "generating",
results: [],
adopted: false,
ts: Date.now(),
// 重跑归属原批次的商品,而非当前选中商品 // 重跑归属原批次的商品,而非当前选中商品
productId: src.productId ?? product?.id, productId: src.productId ?? product?.id,
productTitle: src.productTitle ?? product?.title, productTitle: src.productTitle ?? product?.title,
modelId: src.modelId modelId: src.modelId,
}; modelName: src.modelName,
setBatches((prev) => [...prev, newBatch]); platformIds: src.platformIds
try {
const result = await onGenerate({ prompt: src.prompt, mode, count: src.count, product_id: src.productId ?? product?.id, model_id: src.modelId, ratio: src.ratio });
setBatches((prev) => {
const next = prev.map((b) =>
b.id === batchId
? { ...b, status: result?.assets ? ("done" as const) : ("failed" as const), results: result?.assets || [] }
: b
);
persistBatches(next);
return next;
}); });
} catch {
setBatches((prev) => {
const next = prev.map((b) => (b.id === batchId ? { ...b, status: "failed" as const } : b));
persistBatches(next);
return next;
});
}
} }
/* 删除整个批次(行32③:批次气泡"删除当前批次") */ /* 删除整个批次(行32③:批次气泡"删除当前批次") */
@@ -701,31 +746,46 @@ export function ImageWorkbenchPage({
}); });
} }
/* 加入资产库 / 取消加入(行32④:批次级来回切 + 成功提示) */ /* 加入资产库 / 取消加入(批次级来回切;§4.18:就地反馈,亮在该批首图上,不弹全局窗) */
function toggleAdoptBatch(batchId: string) { function toggleAdoptBatch(batchId: string) {
setBatches((prev) => { setBatches((prev) => {
const target = prev.find((b) => b.id === batchId); const target = prev.find((b) => b.id === batchId);
const willAdopt = !target?.adopted; const willAdopt = !target?.adopted;
const next = prev.map((b) => (b.id === batchId ? { ...b, adopted: willAdopt } : b)); const next = prev.map((b) => (b.id === batchId ? { ...b, adopted: willAdopt } : b));
persistBatches(next); persistBatches(next);
if (willAdopt && target) setAdoptNotice({ count: target.results.length }); const firstId = target?.results[0]?.id;
if (willAdopt && firstId) flashFeedback(`${batchId}:${firstId}`);
return next; return next;
}); });
} }
/* 加入资产库 / 取消(单图级,行32④:单图气泡"加入资产库",来回切) */ /* 加入资产库 / 取消(单图级,§4.18:单图气泡"加入资产库",就地反馈不弹全局窗) */
function toggleAdoptImage(batchId: string, _assetId: string) { function toggleAdoptImage(batchId: string, assetId: string) {
// 单图采用以批次级 adopted 表达(后端无单图采用接口),提示单张 // 单图采用以批次级 adopted 表达(后端无单图采用接口),反馈落在被点的那张图上
setBatches((prev) => { setBatches((prev) => {
const target = prev.find((b) => b.id === batchId); const target = prev.find((b) => b.id === batchId);
const willAdopt = !target?.adopted; const willAdopt = !target?.adopted;
const next = prev.map((b) => (b.id === batchId ? { ...b, adopted: willAdopt } : b)); const next = prev.map((b) => (b.id === batchId ? { ...b, adopted: willAdopt } : b));
persistBatches(next); persistBatches(next);
if (willAdopt) setAdoptNotice({ count: 1 }); if (willAdopt) flashFeedback(`${batchId}:${assetId}`);
return next; return next;
}); });
} }
/* 单图「再生成」(§4.18 悬浮①):用该批参数另起一个 count=1 的批次,产出一张新图 */
async function regenSingleImage(src: GenBatch) {
await startBatch({
prompt: src.prompt,
ratio: src.ratio,
count: 1,
productId: src.productId ?? product?.id,
productTitle: src.productTitle ?? product?.title,
modelId: src.modelId,
modelName: src.modelName,
platformIds: src.platformIds
});
}
/* 跨刷新恢复(行33):先读 ai-tools 多批次残留回显已出的批次; /* 跨刷新恢复(行33):先读 ai-tools 多批次残留回显已出的批次;
再读 App 写的单批 `airshelf:imgwb:{mode}`(仍在跑的任务),对其继续轮询补一个恢复批次。 */ 再读 App 写的单批 `airshelf:imgwb:{mode}`(仍在跑的任务),对其继续轮询补一个恢复批次。 */
useEffect(() => { useEffect(() => {
@@ -786,7 +846,9 @@ export function ImageWorkbenchPage({
const hasResults = batches.length > 0; const hasResults = batches.length > 0;
/* ── 单个批次的结果网格(行30:生成中转圈 icon;行32:单图 hover「更多」气泡菜单)── */ /* ── 单个批次的结果网格 · §4.18 gen-card 三件套 ──
每张 .gen-image 内:① 右上 .gen-image-actions(再生成 / 下载 / 更多→加入·删除)
② 中央 .gen-image-feedback 就地反馈(采用后亮 1.5s)③ 左上「已采用」绿角标。 */
function renderBatchGrid(batch: GenBatch) { function renderBatchGrid(batch: GenBatch) {
const generating = batch.status === "generating"; const generating = batch.status === "generating";
const n = batch.results.length || batch.count; const n = batch.results.length || batch.count;
@@ -800,14 +862,16 @@ export function ImageWorkbenchPage({
{(batch.results.length {(batch.results.length
? batch.results.map((asset, index) => ({ key: asset.id, index, url: asset.files?.[0]?.preview_url, assetId: asset.id })) ? batch.results.map((asset, index) => ({ key: asset.id, index, url: asset.files?.[0]?.preview_url, assetId: asset.id }))
: Array.from({ length: batch.count }).map((_, index) => ({ key: `ph-${index}`, index, url: undefined as string | undefined, assetId: "" })) : Array.from({ length: batch.count }).map((_, index) => ({ key: `ph-${index}`, index, url: undefined as string | undefined, assetId: "" }))
).map(({ key, index, url, assetId }) => ( ).map(({ key, index, url, assetId }) => {
<div className={`gen-image ${generating && !url ? "gen" : ""}`} key={key}> const cellKey = `${batch.id}:${assetId || key}`;
const showFeedback = feedbackKey === `${batch.id}:${assetId}` && !!assetId;
return (
<div className={`gen-image ${generating && !url ? "gen" : ""}${batch.adopted && url ? " adopted" : ""}${showFeedback ? " show-feedback" : ""}`} key={key}>
{url ? ( {url ? (
<img className="gen-image-img" src={url} alt={`${meta.title} #${index + 1}`} loading="lazy" title="点击放大" style={{ cursor: "zoom-in" }} onClick={() => setPreview({ src: url, name: `${meta.title} #${index + 1}` })} /> <img className="gen-image-img" src={url} alt={`${meta.title} #${index + 1}`} loading="lazy" title="点击放大" style={{ cursor: "zoom-in" }} onClick={() => setPreview({ src: url, name: `${meta.title} #${index + 1}` })} />
) : ( ) : (
<div className="placeholder"> <div className="placeholder">
{generating ? ( {generating ? (
// 行30:生成中占位用旋转 spinner icon,不再是「生成中…」文字
<span className="gen-loading" aria-label="生成中"> <span className="gen-loading" aria-label="生成中">
<span className="spinner" aria-hidden /> <span className="spinner" aria-hidden />
</span> </span>
@@ -816,22 +880,39 @@ export function ImageWorkbenchPage({
)} )}
</div> </div>
)} )}
{/* ③ 左上「已采用」绿角标(批次 adopted 时常驻) */}
{url && batch.adopted && (
<span className="gen-adopt-badge">
<Check size={11} />
</span>
)}
{/* ② 中央就地反馈(采用瞬间亮 1.5s,§4.18 禁全局 toast) */}
{url && (
<div className="gen-image-feedback" aria-hidden={!showFeedback}>
<Check size={20} />
<span></span>
</div>
)}
{/* ① 右上悬浮操作组:再生成 / 下载 / 更多 */}
{url && ( {url && (
<div className="gen-image-actions"> <div className="gen-image-actions">
<button className="gen-img-btn" type="button" title="再生成" disabled={generating} onClick={() => regenSingleImage(batch)}>
<RefreshCw size={14} />
</button>
<button className="gen-img-btn" type="button" title="下载" onClick={() => downloadImage(url, `${meta.title}-${index + 1}.png`)}> <button className="gen-img-btn" type="button" title="下载" onClick={() => downloadImage(url, `${meta.title}-${index + 1}.png`)}>
<Download size={14} /> <Download size={14} />
</button> </button>
{/* 行32③:hover「更多」出气泡菜单(加入资产库 / 删除),不再直接下载 */}
<div className="gen-img-more"> <div className="gen-img-more">
<button className="gen-img-btn" type="button" title="更多"> <button className="gen-img-btn" type="button" title="更多" onClick={() => setOpenMore((k) => (k === cellKey ? "" : cellKey))}>
<MoreHorizontal size={14} /> <MoreHorizontal size={14} />
</button> </button>
<div className="gen-bubble"> <div className={`gen-bubble${openMore === cellKey ? " open" : ""}`}>
<button type="button" className="gen-bubble-item" onClick={() => toggleAdoptImage(batch.id, assetId)}> <button type="button" className="gen-bubble-item" onClick={() => { setOpenMore(""); toggleAdoptImage(batch.id, assetId); }}>
<Bookmark size={13} /> <Bookmark size={13} />
{batch.adopted ? "取消加入资产库" : "加入资产库"} {batch.adopted ? "取消加入资产库" : "加入资产库"}
</button> </button>
<button type="button" className="gen-bubble-item danger" onClick={() => removeImageFromBatch(batch.id, assetId)}> <button type="button" className="gen-bubble-item danger" onClick={() => { setOpenMore(""); removeImageFromBatch(batch.id, assetId); }}>
<Trash2 size={13} /> <Trash2 size={13} />
</button> </button>
@@ -840,7 +921,8 @@ export function ImageWorkbenchPage({
</div> </div>
)} )}
</div> </div>
))} );
})}
</div> </div>
); );
} }
@@ -873,12 +955,18 @@ export function ImageWorkbenchPage({
<span className="k"></span> <span className="k"></span>
<span className="v">{batchProductTitle(batch)}</span> <span className="v">{batchProductTitle(batch)}</span>
</div> </div>
{mode === "model" && batch.modelName && (
<div className="pv-line">
<span className="k"></span>
<span className="v">{batch.modelName}</span>
</div>
)}
{mode === "cover" && ( {mode === "cover" && (
<div className="pv-line"> <div className="pv-line">
<span className="k"></span> <span className="k"></span>
<span className="v"> <span className="v">
{pickedIds.length {batch.platformIds && batch.platformIds.length
? PLATFORM_OPTIONS.filter((p) => pickedIds.includes(p.id)) ? PLATFORM_OPTIONS.filter((p) => batch.platformIds!.includes(p.id))
.map((p) => p.name) .map((p) => p.name)
.join("、") .join("、")
: "未选择"} : "未选择"}
@@ -933,6 +1021,42 @@ export function ImageWorkbenchPage({
); );
} }
/* ── 平台套图 · 多平台分组结果(P0③)──
每个平台一个 section 头(平台 logo + 名 + 批次数),其下挂该平台的批次卡。 */
function renderCoverGrouped(list: GenBatch[]) {
// 按平台 id 分组;无平台标签的批次归到「未分类」组(兜底,正常不出现)
const groups: Array<{ pid: string; batches: GenBatch[] }> = [];
for (const batch of list) {
const pid = batch.platformIds?.[0] || "_";
let g = groups.find((x) => x.pid === pid);
if (!g) { g = { pid, batches: [] }; groups.push(g); }
g.batches.push(batch);
}
return (
<>
{groups.map((g) => {
const plat = PLATFORM_OPTIONS.find((p) => p.id === g.pid);
return (
<Fragment key={g.pid}>
<div className="iw-cover-group-h">
{plat ? (
<>
<span className={`cg-logo p-logo-${plat.id}`}>{plat.logo}</span>
<span className="cg-name">{plat.name}</span>
</>
) : (
<span className="cg-name"></span>
)}
<span className="cg-ct">// {g.batches.length} 批</span>
</div>
{renderBatchCards(g.batches)}
</Fragment>
);
})}
</>
);
}
/* ════════════════════════════════════════════════ /* ════════════════════════════════════════════════
mode === "image" → 对话流形态(基线 image-optimize · design.md §4.13) mode === "image" → 对话流形态(基线 image-optimize · design.md §4.13)
左会话列表 + 中央 hero/对话流 + 底部 chat 输入栏 左会话列表 + 中央 hero/对话流 + 底部 chat 输入栏
@@ -1094,13 +1218,6 @@ export function ImageWorkbenchPage({
</div> </div>
</div> </div>
</section> </section>
<SuccessModal
open={!!adoptNotice}
title="加入资产库"
detail={`${adoptNotice?.count ?? 0} 张图片已成功加入资产库`}
actions={<><button className="btn" type="button" onClick={() => setAdoptNotice(null)}></button><button className="btn btn-primary" type="button" onClick={() => { setAdoptNotice(null); navigate?.("library"); }}></button></>}
close={() => setAdoptNotice(null)}
/>
</div> </div>
); );
} }
@@ -1186,6 +1303,11 @@ export function ImageWorkbenchPage({
{product?.title || "未选择 · 请在左侧商品空间选一个"} {product?.title || "未选择 · 请在左侧商品空间选一个"}
</span> </span>
</div> </div>
{/* P0④:商品库全屏选择器入口(多选商品,各起一批) */}
<button className="iw-pl-btn" type="button" onClick={() => { setPlDraft(productId ? [productId] : []); setPlQuery(""); setPlCat(""); setPlOpen(true); }} title="从商品库选择">
<LayoutGrid size={13} />
</button>
<span className="spacer" /> <span className="spacer" />
<div className="tb-search-wrap"> <div className="tb-search-wrap">
{searchOpen && ( {searchOpen && (
@@ -1225,8 +1347,13 @@ export function ImageWorkbenchPage({
<div className="iw-step-h"> <div className="iw-step-h">
<span className="num">1</span> <span className="num">1</span>
<span className="title"></span> <span className="title"></span>
{/* P0①:接入现成模特库全屏弹窗(原「全部模特→」仅内联展开;现可打开完整演员库选用) */}
<button className="iw-actor-lib-btn" type="button" onClick={() => setActorLibOpen(true)}>
<Users size={12} />
</button>
{personAssets.length > 6 && ( {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> <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>
<div className="model-grid"> <div className="model-grid">
@@ -1242,7 +1369,7 @@ export function ImageWorkbenchPage({
type="button" type="button"
key={item.id} key={item.id}
className={`model-card ${pickedIds.includes(item.id) ? "selected" : ""}`} className={`model-card ${pickedIds.includes(item.id) ? "selected" : ""}`}
onClick={() => togglePick(item.id)} onClick={() => togglePick(item.id, item.name)}
> >
<span className="m-check"> <span className="m-check">
<Check size={11} /> <Check size={11} />
@@ -1269,7 +1396,7 @@ export function ImageWorkbenchPage({
type="button" type="button"
key={item.id} key={item.id}
className={`model-card ${pickedIds.includes(item.id) ? "selected" : ""}`} className={`model-card ${pickedIds.includes(item.id) ? "selected" : ""}`}
onClick={() => togglePick(item.id)} onClick={() => togglePick(item.id, item.name)}
> >
<span className="m-check"> <span className="m-check">
<Check size={11} /> <Check size={11} />
@@ -1377,21 +1504,72 @@ export function ImageWorkbenchPage({
) : ( ) : (
<> <>
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} /> <MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
{/* 行34:每批各自一个导航头(含所属商品)——见 renderBatchCards */} {/* P0③:平台套图按平台分组出 section;模特图沿用每批一个导航头 */}
{renderBatchCards(batches)} {mode === "cover" ? renderCoverGrouped(batches) : renderBatchCards(batches)}
</> </>
)} )}
</div> </div>
</div> </div>
</section> </section>
</div> </div>
<SuccessModal
open={!!adoptNotice} {/* P0①:模特库全屏弹窗(复用现成 ActorLibrary,mode=replace 选一个回填到「选择模特」) */}
title="加入资产库" <ActorLibrary
detail={`${adoptNotice?.count ?? 0} 张图片已成功加入资产库`} open={actorLibOpen}
actions={<><button className="btn" type="button" onClick={() => setAdoptNotice(null)}></button><button className="btn btn-primary" type="button" onClick={() => { setAdoptNotice(null); navigate?.("library"); }}></button></>} mode="replace"
close={() => setAdoptNotice(null)} assets={personAssets}
onClose={() => setActorLibOpen(false)}
onPick={(assetId, assetName) => {
setPickedIds([assetId]);
setPickedModelName(assetName || "");
void api.assetsPage({ category: "person", pageSize: 200 }).then((res) => setPersonAssets(res.results)).catch(() => {});
setActorLibOpen(false);
}}
onGenerate={(p) => onGenerate({ prompt: p, mode: "model", count: 1 })}
onUpload={(file) => {
const fd = new FormData();
fd.append("file", file);
fd.append("name", file.name);
fd.append("asset_type", "image");
fd.append("category", "person");
return api.uploadAsset(fd).catch(() => null);
}}
onRename={(assetId, name) => api.updateAsset(assetId, { name }).catch(() => null)}
onRefresh={() => { void api.assetsPage({ category: "person", pageSize: 200 }).then((res) => setPersonAssets(res.results)).catch(() => {}); }}
/> />
{/* P0④:商品库全屏选择器(pl-modal · 多选 + 右上勾 + 已选汇总 · restraint token) */}
{plOpen && (
<ProductLibraryModal
products={products}
draft={plDraft}
setDraft={setPlDraft}
query={plQuery}
setQuery={setPlQuery}
cat={plCat}
setCat={setPlCat}
coverUrl={productCoverUrl}
onClose={() => setPlOpen(false)}
onConfirm={() => {
// 第一个选中作为当前商品;若选了多个,各商品另起一批(沿用当前提示词/比例/张数/模特/平台)
if (plDraft.length) {
setProductId(plDraft[0]);
if (plDraft.length > 1 && (mode === "model" ? pickedIds.length > 0 : mode === "cover" ? pickedIds.length > 0 : true) && prompt.trim()) {
for (const pid of plDraft.slice(1)) {
const p = products.find((x) => x.id === pid);
if (mode === "cover") {
for (const platId of pickedIds) void startBatch({ prompt: prompt.trim(), ratio, count: candidateCount, productId: pid, productTitle: p?.title, platformIds: [platId] });
} else {
void startBatch({ prompt: prompt.trim(), ratio, count: candidateCount, productId: pid, productTitle: p?.title, modelId: mode === "model" ? pickedIds[0] : undefined, modelName: mode === "model" ? pickedModelName : undefined });
}
}
}
}
setPlOpen(false);
}}
navigate={navigate}
/>
)}
</div> </div>
); );
} }
@@ -1441,6 +1619,135 @@ function Pill({
); );
} }
/* ════════════════════════════════════════════════
P0④ · 商品库全屏选择器(pl-modal · 基线 model-photo.html .pl-modal)
左 = 分类侧栏;右 = 搜索 toolbar + 商品多选网格(右上勾)+ 底部已选汇总 + 确定。
全 restraint token,不复用 styles.css 旧版 .pl-card(暖米)。
════════════════════════════════════════════════ */
function ProductLibraryModal({
products,
draft,
setDraft,
query,
setQuery,
cat,
setCat,
coverUrl,
onClose,
onConfirm,
navigate
}: {
products: Product[];
draft: string[];
setDraft: (next: string[]) => void;
query: string;
setQuery: (v: string) => void;
cat: string;
setCat: (v: string) => void;
coverUrl: (p: Product) => string;
onClose: () => void;
onConfirm: () => void;
navigate?: (page: Page) => void;
}) {
// Esc 关闭 + body 滚动锁(与 ActorLibrary 一致体感)
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => { window.removeEventListener("keydown", onKey); document.body.style.overflow = prev; };
}, [onClose]);
const categories = Array.from(new Set(products.map((p) => p.category).filter(Boolean))) as string[];
const visible = products.filter((p) => {
if (cat && p.category !== cat) return false;
const q = query.trim().toLowerCase();
if (!q) return true;
return `${p.title} ${p.category || ""} ${p.brand || ""}`.toLowerCase().includes(q);
});
function toggle(id: string) {
setDraft(draft.includes(id) ? draft.filter((x) => x !== id) : [...draft, id]);
}
return createPortal(
<div className="pl-modal-bg" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className="pl-modal" role="dialog" aria-modal="true" aria-label="商品库">
<div className="pl-modal-h">
<h2></h2>
<span className="ct">// 选择商品 · 可多选</span>
<div className="actions">
<button className="x" type="button" aria-label="关闭" onClick={onClose}>
<X size={16} />
</button>
</div>
</div>
<div className="pl-modal-body">
<aside className="pl-side">
<div className="pl-side-h">// 分类</div>
<div className={`pl-side-item${!cat ? " active" : ""}`} role="button" tabIndex={0} onClick={() => setCat("")}>
<span className="ct">{products.length}</span>
</div>
{categories.map((c) => (
<div className={`pl-side-item${cat === c ? " active" : ""}`} key={c} role="button" tabIndex={0} onClick={() => setCat(c)}>
{c}
<span className="ct">{products.filter((p) => p.category === c).length}</span>
</div>
))}
</aside>
<div className="pl-main">
<div className="pl-toolbar">
<div className="search">
<Search size={14} />
<input placeholder="搜索商品 / 分类 / 品牌" value={query} onChange={(e) => setQuery(e.target.value)} />
</div>
{navigate && (
<button className="btn-new" type="button" onClick={() => navigate("productCreateUpload")}>
<Plus size={13} />
</button>
)}
</div>
<div className="pl-scroll">
{visible.length === 0 ? (
<div className="pl-empty">
<div className="mono">// NO MATCH</div>
<div></div>
</div>
) : (
<div className="pl-grid">
{visible.map((p) => {
const url = coverUrl(p);
const on = draft.includes(p.id);
return (
<div className={`pl-card${on ? " selected" : ""}`} key={p.id} role="button" tabIndex={0} onClick={() => toggle(p.id)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(p.id); } }}>
<span className="pl-check"><Check size={11} /></span>
{url ? (
<div className="pl-thumb has-real-media"><img src={url} alt={p.title} loading="lazy" /></div>
) : (
<div className="placeholder pl-thumb"><span className="ph-frame">{p.title.slice(0, 2)}</span></div>
)}
<div className="pl-name">{p.title}</div>
<div className="pl-meta">// {p.category || "未分类"}</div>
</div>
);
})}
</div>
)}
</div>
<div className="pl-modal-f">
<span className="summary"> <b>{draft.length}</b> </span>
<button className="btn" type="button" onClick={onClose}></button>
<button className="btn btn-primary" type="button" disabled={draft.length === 0} onClick={onConfirm}> ({draft.length})</button>
</div>
</div>
</div>
</div>
</div>,
document.body
);
}
/* ════════════════════════════════════════════════ /* ════════════════════════════════════════════════
模特图方案对比 Demo(纯静态展示 · 像素还原) 模特图方案对比 Demo(纯静态展示 · 像素还原)
variant="A" → public/exact/model-photo-demo-a.html variant="A" → public/exact/model-photo-demo-a.html
+436 -47
View File
@@ -1,9 +1,11 @@
import { useEffect, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import type { FormEvent } from "react"; import type { FormEvent, ReactNode } from "react";
import { createPortal } from "react-dom";
import { Download, Image as ImageIcon, Images, Info, LayoutGrid, Music, Shield, Trash2, Upload, X } from "lucide-react";
import { api } from "../api"; import { api } from "../api";
import { SkeletonGrid } from "../components/loading"; import { SkeletonGrid } from "../components/loading";
import type { Asset } from "../types"; import type { Asset } from "../types";
import { ConfirmModal, Drawer, MediaLightbox } from "../components/overlays"; import { ConfirmModal, MediaLightbox, useBodyScrollLock, useOverlayTransition } from "../components/overlays";
import { Pager } from "../components/pager"; import { Pager } from "../components/pager";
const LIB_PAGE_SIZE = 10; const LIB_PAGE_SIZE = 10;
@@ -24,6 +26,14 @@ function inferAssetType(file: File): string {
return "document"; return "document";
} }
// 资产类型 → 上传 Modal 接受的文件 MIME(类型决定能选什么文件 + 动态字段)
const UPLOAD_KINDS: Array<{ value: string; label: string; accept: string; hint: string }> = [
{ value: "image", label: "图片", accept: "image/*", hint: "PNG / JPG / WEBP" },
{ value: "video", label: "视频", accept: "video/*", hint: "MP4 / MOV · 9:16 竖屏优先" },
{ value: "audio", label: "音频", accept: "audio/*", hint: "MP3 / WAV · 配音 / BGM" },
{ value: "subtitle", label: "字幕", accept: ".srt,.vtt,.ass", hint: "SRT / VTT / ASS" }
];
type LibTab = "people" | "scenes" | "products" | "finals" | "uploads" | "unclassified"; type LibTab = "people" | "scenes" | "products" | "finals" | "uploads" | "unclassified";
const LIB_TABS: Array<{ key: LibTab; label: string }> = [ const LIB_TABS: Array<{ key: LibTab; label: string }> = [
@@ -44,38 +54,373 @@ const LIB_CHIPS: Array<{ key: string; label: string; tabs: LibTab[] }> = [
{ key: "source", label: "来源", tabs: ["people", "scenes", "products", "uploads"] } { key: "source", label: "来源", tabs: ["people", "scenes", "products", "uploads"] }
]; ];
function assetTab(asset: Asset): LibTab { // metadata 里可能用的中文属性键(真实存在才渲染,缺则不显;属性区不造假)
switch (asset.category) { const META_PROP_LABELS: Record<string, string> = {
case "person": return "people"; gender: "性别", age: "年龄段", role: "角色", sceneType: "场景类型", scene_type: "场景类型",
case "scene": return "scenes"; duration: "时长", product: "关联商品", project: "关联项目", ratio: "画幅", resolution: "分辨率",
case "product_image": return "products"; tone: "风格", style: "风格"
case "final_video": return "finals"; };
case "upload": return "uploads";
default: return asset.asset_type === "video" ? "finals" : "unclassified"; function metaStr(v: unknown): string {
if (v == null) return "";
if (typeof v === "string") return v;
if (typeof v === "number" || typeof v === "boolean") return String(v);
return "";
} }
// 人物资产是否缺三视图:metadata.tri_view / triview / has_triview 任一为真即算有;无标记一律按「缺」处理
function personMissingTri(asset: Asset): boolean {
if (asset.category !== "person") return false;
const m = asset.metadata || {};
const has = m.tri_view ?? m.triview ?? m.has_triview ?? m.three_view ?? m.tri_views;
if (has === true) return false;
if (Array.isArray(has) && has.length > 0) return false;
if (typeof has === "string" && has.trim()) return false;
// 上传来源 + 无三视图标记 → 判缺(AI 生成默认带三视图,不打扰)
return asset.source === "upload";
}
// ─────────────────────────────────────────────────────────────────────────
// 资产详情弹窗 · 立绘大图 + 三视图/版本历史(人物)+ 简介/标签/属性 + 媒体画廊(商品)
// 用真实 Asset 字段渲染;缺字段留「-」或整段降级,不造 mock。
// 复用 useOverlayTransition / useBodyScrollLock(不改 overlays.tsx),portal 到 body 盖住侧栏。
// ─────────────────────────────────────────────────────────────────────────
function AssetDetailModal({ asset, close, onZoom }: {
asset: Asset | null;
close: () => void;
onZoom: (src: string, kind: "image" | "video" | "audio", name: string) => void;
}) {
useBodyScrollLock(!!asset);
const { mounted, show } = useOverlayTransition(!!asset, close);
// 多图资产:当前选中的主图索引(缩略图切换)
const [activeIdx, setActiveIdx] = useState(0);
useEffect(() => { setActiveIdx(0); }, [asset?.id]);
if (!mounted || !asset) return null;
const files = asset.files || [];
const isVideo = asset.asset_type === "video";
const isAudio = asset.asset_type === "audio";
const isPerson = asset.category === "person";
const isProduct = asset.category === "product_image";
const kindLabel = KIND_LABELS[asset.asset_type] || asset.asset_type || "资产";
const catLabel = CATEGORY_LABELS[asset.category || ""] || asset.category || "-";
const srcLabel = SOURCE_LABELS[asset.source || ""] || asset.source || "-";
const createdAt = (asset.created_at || "").slice(0, 16).replace("T", " ") || "-";
const previewKind: "image" | "video" | "audio" = isVideo ? "video" : isAudio ? "audio" : "image";
// 主图候选:优先 is_primary,其次首张;activeIdx 控制当前看哪张
const ordered = [...files].sort((a, b) => Number(b.is_primary) - Number(a.is_primary));
const cur = ordered[activeIdx] || ordered[0];
const leadSrc = cur?.preview_url || "";
// 简介:asset.description 真有才显,否则降级提示
const intro = (asset.description || "").trim();
// 标签:metadata.tags(数组)真有才渲染
const rawTags = (asset.metadata?.tags as unknown);
const tags = Array.isArray(rawTags) ? rawTags.map(metaStr).filter(Boolean) : [];
// 属性表:从 metadata 真实键派生 + 资产固有字段;空值留「-」
const metaProps: Array<{ k: string; v: string }> = [];
Object.entries(META_PROP_LABELS).forEach(([key, label]) => {
const v = metaStr(asset.metadata?.[key]);
if (v && !metaProps.some((p) => p.k === label)) metaProps.push({ k: label, v });
});
const props: Array<{ k: string; v: string }> = [
{ k: "分类", v: catLabel },
{ k: "类型", v: kindLabel },
{ k: "来源", v: srcLabel },
...metaProps,
{ k: "文件数", v: files.length ? String(files.length) : "-" },
{ k: "入库时间", v: createdAt }
];
// 人物审核盾:仅真人有 review_status(processing/active 绿/failed 红)
const review = asset.review_status;
function renderLead(): ReactNode {
if (leadSrc && isVideo) {
return <video src={leadSrc} muted playsInline preload="metadata" />;
}
if (isAudio) {
return <span className="adx-audio-ph" aria-hidden="true"><Music /><span className="mono">AUDIO</span></span>;
}
if (leadSrc) return <img src={leadSrc} alt={asset!.name} />;
return <span className="ph-frame">{isPerson ? "立绘 / 主图" : kindLabel}</span>;
}
return createPortal(
<div className={`modal-bg${show ? " show" : ""}`} onClick={close}>
<div className={`asset-detail-modal with-corners${isProduct ? " product-mode" : ""}`} onClick={(event) => event.stopPropagation()}>
<span className="corner-tr" /><span className="corner-bl" />
<div className="adx-h">
<h2>{asset.name || "资产详情"}</h2>
<span className="adx-kind">/ {kindLabel}</span>
{review === "active" && <span className="pill ok adx-shield"><Shield size={11} /> </span>}
{review === "processing" && <span className="pill info adx-shield"></span>}
{review === "failed" && <span className="pill err adx-shield"></span>}
<button className="x adx-x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button>
</div>
<div className="adx-body">
<div className="adx-grid">
{/* ── 左:立绘 / 主图 + 缩略图切换 ── */}
<div className="adx-lead">
<div className="adx-lead-wrap">
<div className="placeholder adx-lead-img" role={leadSrc ? "button" : undefined} tabIndex={leadSrc ? 0 : undefined} onClick={leadSrc ? () => onZoom(leadSrc, previewKind, asset!.name) : undefined} title={leadSrc ? "查看大图" : undefined}>
{renderLead()}
</div>
{leadSrc && (
<button className="adx-zoom-btn" type="button" aria-label="查看大图" title="查看大图" onClick={() => onZoom(leadSrc, previewKind, asset!.name)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 8V3h5M16 3h5v5M21 16v5h-5M8 21H3v-5" /></svg>
</button>
)}
</div>
{ordered.length > 1 && (
<div className="adx-thumbs">
{ordered.map((f, i) => (
<button key={f.id} type="button" className={`adx-thumb${i === activeIdx ? " active" : ""}`} onClick={() => setActiveIdx(i)} title={f.is_primary ? "主图" : `${i + 1}`}>
{f.preview_url ? <img src={f.preview_url} alt="" /> : <span className="ph-frame">{i + 1}</span>}
</button>
))}
</div>
)}
</div>
{/* ── 右:三视图(人物)+ 简介 + 标签 + 属性 ── */}
<div className="adx-right">
{isPerson && (
<div className="adx-section">
<div className="adx-section-h">
<span className="ic"><LayoutGrid size={14} /></span>
<span className="t"></span>
<span className="adx-ratio-chip">16:9</span>
</div>
{personMissingTri(asset) ? (
<div className="adx-tri-missing">
<Info size={14} />
<span> <b> / / </b> ,</span>
</div>
) : ordered.length > 1 ? (
<div className="adx-tri-row">
{ordered.slice(0, 3).map((f, i) => (
<button key={f.id} type="button" className="placeholder adx-tri-cell" onClick={f.preview_url ? () => onZoom(f.preview_url, "image", asset!.name) : undefined}>
{f.preview_url ? <img src={f.preview_url} alt="" /> : <span className="ph-frame">{["正", "侧", "背"][i] || i + 1}</span>}
</button>
))}
</div>
) : (
<div className="adx-tri-row">
<div className="placeholder adx-tri-cell single">
{leadSrc ? <img src={leadSrc} alt="" /> : <span className="ph-frame"> / / · </span>}
</div>
</div>
)}
</div>
)}
<div className="adx-section">
<div className="adx-section-h">
<span className="ic"><Info size={14} /></span>
<span className="t"></span>
</div>
<p className="adx-intro">{intro || <span className="adx-muted">// 暂无简介</span>}</p>
{tags.length > 0 && (
<div className="adx-tags">
{tags.map((t) => <span className="adx-tag" key={t}>{t}</span>)}
</div>
)}
</div>
<div className="adx-section">
<div className="adx-section-h">
<span className="ic"><LayoutGrid size={14} /></span>
<span className="t"></span>
</div>
<div className="adx-props">
{props.map((p) => (
<div className="adx-prop-row" key={p.k}><span className="k">{p.k}</span><span className="v">{p.v}</span></div>
))}
</div>
</div>
{/* 商品图:媒体画廊(用资产自身的多文件;无更多关联数据时仅列已有文件,不编造 SKU) */}
{isProduct && files.length > 0 && (
<div className="adx-section">
<div className="adx-section-h">
<span className="ic"><Images size={14} /></span>
<span className="t"></span>
<span className="adx-ratio-chip">{files.length} </span>
</div>
<div className="adx-gallery">
{files.map((f, i) => (
<button key={f.id} type="button" className="adx-media-card" onClick={f.preview_url ? () => onZoom(f.preview_url, "image", asset!.name) : undefined}>
<div className="adx-media-img">
{f.preview_url ? <img src={f.preview_url} alt="" /> : <span className="ph-frame"> {i + 1} </span>}
<span className="asset-badge">{f.is_primary ? "主图" : `#${i + 1}`}</span>
</div>
</button>
))}
</div>
</div>
)}
</div>
</div>
</div>
<div className="adx-f">
{leadSrc && (
<a className="btn adx-foot-dl" href={leadSrc} download={asset.name || undefined} target="_blank" rel="noreferrer">
<Download size={13} />
</a>
)}
<span className="adx-foot-meta mono">// {catLabel} · {srcLabel} · {createdAt}</span>
<button className="btn btn-primary" type="button" onClick={close}></button>
</div>
</div>
</div>,
document.body
);
}
// ─────────────────────────────────────────────────────────────────────────
// 上传资产 · 居中 Modal · 资产类型下拉 + 按类型动态 accept + 拖拽预览
// 复用 useOverlayTransition / useBodyScrollLock,portal 到 body。
// ─────────────────────────────────────────────────────────────────────────
function UploadModal({ open, close, onSubmit }: {
open: boolean;
close: () => void;
onSubmit: (file: File, name: string, assetType: string) => Promise<void>;
}) {
useBodyScrollLock(open);
const { mounted, show } = useOverlayTransition(open, close);
const [kind, setKind] = useState("image");
const [file, setFile] = useState<File | null>(null);
const [name, setName] = useState("");
const [uploading, setUploading] = useState(false);
const [dragging, setDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const previewUrl = useMemo(() => (file ? URL.createObjectURL(file) : ""), [file]);
useEffect(() => () => { if (previewUrl) URL.revokeObjectURL(previewUrl); }, [previewUrl]);
// 关闭后清空,下次打开是干净状态
useEffect(() => { if (!open) { setFile(null); setName(""); setKind("image"); setDragging(false); } }, [open]);
const curKind = UPLOAD_KINDS.find((k) => k.value === kind) || UPLOAD_KINDS[0];
const isImageKind = kind === "image";
const isVideoKind = kind === "video";
const isAudioKind = kind === "audio";
function pick(f: File | null) {
if (!f) return;
setFile(f);
if (!name) setName(f.name.replace(/\.[^.]+$/, ""));
}
async function submit(event: FormEvent) {
event.preventDefault();
if (!file || uploading) return;
setUploading(true);
try {
// 资产类型以 MIME 推断为准,下拉只决定可选文件 + 字段;不一致时仍按真实文件类型落库
await onSubmit(file, name || file.name, inferAssetType(file));
close();
} finally {
setUploading(false);
}
}
if (!mounted) return null;
return createPortal(
<div className={`modal-bg${show ? " show" : ""}`} onClick={close}>
<div className="modal upload-modal" onClick={(event) => event.stopPropagation()}>
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h"><div className="ic-m"><Upload size={16} /></div><div className="ti"><span>// UPLOAD</span></div><button className="x modal-x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button></div>
<form className="upload-modal-form" onSubmit={submit}>
<div className="modal-b">
<div className="field">
<label className="field-label"></label>
<div className="upload-kind-seg" role="tablist">
{UPLOAD_KINDS.map((k) => (
<button key={k.value} type="button" className={`uk-seg${kind === k.value ? " active" : ""}`} onClick={() => { setKind(k.value); setFile(null); }}>{k.label}</button>
))}
</div>
</div>
<div className="field">
<label className="field-label"></label>
{file ? (
<div className={`upload-preview${isVideoKind ? " video" : ""}`}>
{isImageKind && <img src={previewUrl} alt="预览" />}
{isVideoKind && <video src={previewUrl} muted playsInline />}
{(isAudioKind || (!isImageKind && !isVideoKind)) && (
<span className="upload-file-chip"><ImageIcon size={18} /><span className="mono">{(file.name.split(".").pop() || "FILE").toUpperCase()}</span></span>
)}
<button className="preview-x" type="button" aria-label="移除" onClick={() => setFile(null)}><X size={12} /></button>
</div>
) : (
<div
className={`upload-zone${dragging ? " dragover" : ""}`}
role="button"
tabIndex={0}
onClick={() => inputRef.current?.click()}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); inputRef.current?.click(); } }}
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => { e.preventDefault(); setDragging(false); pick(e.dataTransfer.files?.[0] || null); }}
>
<span className="uz-ic"><Upload size={18} /></span>
<span>,</span>
<span className="uz-hint mono">{curKind.hint}</span>
</div>
)}
<input ref={inputRef} type="file" accept={curKind.accept} hidden onChange={(e) => pick(e.target.files?.[0] || null)} />
</div>
<div className="field">
<label className="field-label"></label>
<input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder="留空则用文件名" />
</div>
</div>
<div className="modal-f">
<span className="upload-foot-meta mono">{file ? <><span className="accent">{file.name}</span> · {(file.size / 1024 / 1024).toFixed(2)} MB</> : "// 选择一个文件"}</span>
<button className="btn" type="button" onClick={close}></button>
<button className="btn btn-primary" type="submit" disabled={!file || uploading}>{uploading ? "上传中…" : "上传资产"}</button>
</div>
</form>
</div>
</div>,
document.body
);
} }
export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormData) => Promise<unknown> | void; onDelete?: (id: string) => Promise<unknown> | void }) { export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormData) => Promise<unknown> | void; onDelete?: (id: string) => Promise<unknown> | void }) {
const [tab, setTab] = useState<LibTab>("people"); const [tab, setTab] = useState<LibTab>("people");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [drawer, setDrawer] = useState(false); const [uploadOpen, setUploadOpen] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [name, setName] = useState("");
const [uploading, setUploading] = useState(false);
const [openChip, setOpenChip] = useState(""); const [openChip, setOpenChip] = useState("");
const [srcFilter, setSrcFilter] = useState(""); const [srcFilter, setSrcFilter] = useState("");
const [kindFilter, setKindFilter] = useState(""); const [kindFilter, setKindFilter] = useState("");
const [sortDesc, setSortDesc] = useState(true); const [sortDesc, setSortDesc] = useState(true);
// 编辑模式 + 元数据筛选(性别/年龄/角色/场景类型/关联/时长 走 asset.metadata,真实存在才有可选项) // 编辑模式 + 多选 + 元数据筛选(性别/年龄/角色/场景类型/关联/时长 走 asset.metadata,真实存在才有可选项)
const [editMode, setEditMode] = useState(false); const [editMode, setEditMode] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [metaFilter, setMetaFilter] = useState<Record<string, string>>({}); const [metaFilter, setMetaFilter] = useState<Record<string, string>>({});
const [confirmId, setConfirmId] = useState<string | null>(null); // 删除确认:单删传 [id],批删传选中数组
// 资产预览灯箱(图片放大 / 视频播放 / 音频试听) const [confirmIds, setConfirmIds] = useState<string[] | null>(null);
// 乐观隐藏:删除中的卡片立即从网格移除
const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set());
// 资产详情弹窗 + 灯箱(详情里点大图再放大)
const [detail, setDetail] = useState<Asset | null>(null);
const [preview, setPreview] = useState<{ src: string; kind: "image" | "video" | "audio"; name: string } | null>(null); const [preview, setPreview] = useState<{ src: string; kind: "image" | "video" | "audio"; name: string } | null>(null);
useEffect(() => { useEffect(() => {
document.body.classList.toggle("edit-mode", editMode); document.body.classList.toggle("edit-mode", editMode);
return () => document.body.classList.remove("edit-mode"); return () => document.body.classList.remove("edit-mode");
}, [editMode]); }, [editMode]);
const toggleSelect = (id: string) => setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
const exitEdit = () => { setEditMode(false); setSelected(new Set()); };
// ── 服务端懒加载:列表按页拉、tab 计数走 summary、筛选项走 facets(不再前端取全量再切片)── // ── 服务端懒加载:列表按页拉、tab 计数走 summary、筛选项走 facets(不再前端取全量再切片)──
const [items, setItems] = useState<Asset[]>([]); const [items, setItems] = useState<Asset[]>([]);
@@ -91,8 +436,8 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
// 切 tab / 改筛选 / 改排序回到第 1 页 // 切 tab / 改筛选 / 改排序回到第 1 页
useEffect(() => { setPage(1); }, [tab, debouncedQuery, srcFilter, kindFilter, metaFilter, sortDesc]); useEffect(() => { setPage(1); }, [tab, debouncedQuery, srcFilter, kindFilter, metaFilter, sortDesc]);
// 切 tab 时清空与该 tab 无关的筛选 // 切 tab 时清空与该 tab 无关的筛选 + 退出多选(避免跨 tab 误删)
useEffect(() => { setOpenChip(""); setSrcFilter(""); setKindFilter(""); setMetaFilter({}); }, [tab]); useEffect(() => { setOpenChip(""); setSrcFilter(""); setKindFilter(""); setMetaFilter({}); setSelected(new Set()); }, [tab]);
// 当前 tab 的 metadata 筛选键(来源/类型走独立字段,不算 metadata) // 当前 tab 的 metadata 筛选键(来源/类型走独立字段,不算 metadata)
const metaKeys = LIB_CHIPS.filter((c) => c.tabs.includes(tab) && c.key !== "source" && c.key !== "kind").map((c) => c.key); const metaKeys = LIB_CHIPS.filter((c) => c.tabs.includes(tab) && c.key !== "source" && c.key !== "kind").map((c) => c.key);
@@ -132,6 +477,8 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
const hasFilter = Boolean(debouncedQuery || srcFilter || kindFilter || Object.values(metaFilter).some(Boolean)); const hasFilter = Boolean(debouncedQuery || srcFilter || kindFilter || Object.values(metaFilter).some(Boolean));
const totalPages = Math.max(1, Math.ceil(total / LIB_PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(total / LIB_PAGE_SIZE));
const curPage = Math.min(page, totalPages); const curPage = Math.min(page, totalPages);
// 乐观隐藏后真正渲染的列表
const shown = items.filter((a) => !deletingIds.has(a.id));
useEffect(() => { useEffect(() => {
if (!openChip) return; if (!openChip) return;
@@ -142,24 +489,29 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
return () => document.removeEventListener("click", close); return () => document.removeEventListener("click", close);
}, [openChip]); }, [openChip]);
async function submit(event: FormEvent) { // 上传:拼 FormData 走父级 onUpload,完成后刷新列表/计数/筛选项
event.preventDefault(); async function doUpload(file: File, name: string, assetType: string) {
if (!file || uploading) return;
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
formData.append("name", name || file.name); formData.append("name", name || file.name);
formData.append("asset_type", inferAssetType(file)); formData.append("asset_type", assetType);
formData.append("category", "upload"); formData.append("category", "upload");
// 上传 + 列表刷新要数秒,按钮给「上传中…」反馈,防止用户重复点
setUploading(true);
try {
await onUpload(formData); await onUpload(formData);
setDrawer(false); setReloadFlag((f) => f + 1);
setFile(null); }
setName("");
setReloadFlag((f) => f + 1); // 上传后刷新列表/计数/筛选项 // 删除(单 / 批):乐观隐藏 → 并发删 → 刷新计数/列表
async function doDelete() {
const ids = confirmIds || [];
setConfirmIds(null);
if (!ids.length || !onDelete) return;
setDeletingIds((prev) => { const next = new Set(prev); ids.forEach((id) => next.add(id)); return next; });
setSelected(new Set());
try {
await Promise.all(ids.map((id) => onDelete(id)));
setReloadFlag((f) => f + 1);
} finally { } finally {
setUploading(false); setDeletingIds((prev) => { const next = new Set(prev); ids.forEach((id) => next.delete(id)); return next; });
} }
} }
@@ -171,11 +523,11 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
<div className="sub"><span className="mono">// 跨项目复用 · <span id="sub-people">{counts.people}</span> 人 · <span id="sub-scenes">{counts.scenes}</span> 景 · <span id="sub-products">{counts.products}</span> 商 · <span id="sub-finals">{counts.finals}</span> 片</span></div> <div className="sub"><span className="mono">// 跨项目复用 · <span id="sub-people">{counts.people}</span> 人 · <span id="sub-scenes">{counts.scenes}</span> 景 · <span id="sub-products">{counts.products}</span> 商 · <span id="sub-finals">{counts.finals}</span> 片</span></div>
</div> </div>
<div className="actions"> <div className="actions">
<button className={`btn btn-edit-toggle${editMode ? " active" : ""}`} type="button" id="lib-manage-btn" onClick={() => setEditMode((v) => !v)}> <button className={`btn btn-edit-toggle${editMode ? " active" : ""}`} type="button" id="lib-manage-btn" onClick={() => (editMode ? exitEdit() : setEditMode(true))}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m3 7 2 2 4-4" /><path d="m3 17 2 2 4-4" /><path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" /></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m3 7 2 2 4-4" /><path d="m3 17 2 2 4-4" /><path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" /></svg>
<span className="lib-manage-label">{editMode ? "完成" : "管理资产"}</span> <span className="lib-manage-label">{editMode ? "完成" : "管理资产"}</span>
</button> </button>
<button className="btn btn-primary" type="button" id="open-upload-btn" onClick={() => setDrawer(true)}> <button className="btn btn-primary" type="button" id="open-upload-btn" onClick={() => setUploadOpen(true)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" /></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" /></svg>
</button> </button>
@@ -273,29 +625,46 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
</div> </div>
</div> </div>
<div className="result-meta" id="result-meta">// 显示 <span className="count">{items.length}</span> / {total} 个资产{hasFilter ? "(已筛选)" : ""}{loading ? " · 加载中…" : ""}</div> <div className="result-meta" id="result-meta">// 显示 <span className="count">{shown.length}</span> / {total} 个资产{hasFilter ? "(已筛选)" : ""}{loading ? " · 加载中…" : ""}</div>
{items.length ? ( {shown.length ? (
<div className="asset-grid" id="asset-grid"> <div className="asset-grid" id="asset-grid">
{items.map((asset) => { {shown.map((asset) => {
const cover = asset.files?.find((f) => f.is_primary)?.preview_url || asset.files?.[0]?.preview_url || ""; const cover = asset.files?.find((f) => f.is_primary)?.preview_url || asset.files?.[0]?.preview_url || "";
const isVideo = asset.asset_type === "video"; const isVideo = asset.asset_type === "video";
const isAudio = asset.asset_type === "audio"; const isAudio = asset.asset_type === "audio";
const previewKind = isVideo ? "video" : isAudio ? "audio" : "image"; const isSelected = selected.has(asset.id);
const openPreview = cover ? () => setPreview({ src: cover, kind: previewKind, name: asset.name }) : undefined;
// 卡片 meta:upload 分类下「上传 · 上传」无信息量,改显资产类型;其余显分类中文 // 卡片 meta:upload 分类下「上传 · 上传」无信息量,改显资产类型;其余显分类中文
const catLabel = (asset.category === "upload" ? KIND_LABELS[asset.asset_type] : CATEGORY_LABELS[asset.category || ""]) || asset.category; const catLabel = (asset.category === "upload" ? KIND_LABELS[asset.asset_type] : CATEGORY_LABELS[asset.category || ""]) || asset.category;
const srcLabel = SOURCE_LABELS[asset.source || ""] || asset.source; const srcLabel = SOURCE_LABELS[asset.source || ""] || asset.source;
// 成片卡补导出时间(created_at = 导出入库时刻),格式 2026-06-09 18:21 // 成片卡补导出时间(created_at = 导出入库时刻),格式 2026-06-09 18:21
const exportedAt = asset.category === "final_video" ? (asset.created_at || "").slice(0, 16).replace("T", " ") : ""; const exportedAt = asset.category === "final_video" ? (asset.created_at || "").slice(0, 16).replace("T", " ") : "";
const missingTri = personMissingTri(asset);
// 编辑态:整卡点击 = 多选;非编辑态:整卡点击 = 开详情
const onCardClick = editMode ? () => toggleSelect(asset.id) : () => setDetail(asset);
return ( return (
<article className={`asset-card ${asset.asset_type}`} key={asset.id}> <article className={`asset-card ${asset.asset_type}${isSelected ? " selected" : ""}`} key={asset.id} onClick={onCardClick} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onCardClick(); } }}>
<span className="card-check" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="3 8 7 12 13 4" /></svg></span>
{editMode && onDelete && ( {editMode && onDelete && (
<button className="card-del-btn" type="button" title="删除资产" onClick={(event) => { event.stopPropagation(); setConfirmId(asset.id); }}> <button className="card-del-btn" type="button" title="删除资产" onClick={(event) => { event.stopPropagation(); setConfirmIds([asset.id]); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg> <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> </button>
)} )}
<div className="placeholder asset-thumb" role={openPreview ? "button" : undefined} tabIndex={openPreview ? 0 : undefined} title={openPreview ? (isVideo ? "点击播放" : isAudio ? "点击试听" : "点击放大") : undefined} style={openPreview ? { cursor: isVideo || isAudio ? "pointer" : "zoom-in", position: "relative" } : undefined} onClick={openPreview} onKeyDown={openPreview ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openPreview(); } } : undefined}> <div className="placeholder asset-thumb" style={{ position: "relative" }}>
{missingTri && (
<span className="tri-missing-badge" tabIndex={0} role="button" aria-label="缺三视图,查看说明" onClick={(e) => e.stopPropagation()}>
<span className="ico" aria-hidden="true"></span>
<span className="lbl-mono"></span>
<span className="tri-missing-pop" role="tooltip">
<span className="pop-h">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 9v4M12 17h.01" /><path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /></svg>
MISSING TRI-VIEW
</span>
<span className="pop-body"> <b> / / </b> ,/</span>
<span className="pop-tip">建议:前往 <b></b> ,</span>
</span>
</span>
)}
{cover && isVideo && ( {cover && isVideo && (
<> <>
<video src={cover} muted playsInline preload="metadata" style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} /> <video src={cover} muted playsInline preload="metadata" style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} />
@@ -325,18 +694,38 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
<Pager page={curPage} total={total} pageSize={LIB_PAGE_SIZE} onChange={setPage} /> <Pager page={curPage} total={total} pageSize={LIB_PAGE_SIZE} onChange={setPage} />
{/* 编辑模式浮动批量操作栏(scope 到资产库;删除/清空/完成) */}
<div className="lib-bulk-bar" role="toolbar" aria-label="批量操作">
<span className="ct"> <b>{selected.size}</b> </span>
<button className="clear-sel" type="button" onClick={() => setSelected(new Set())}></button>
<span className="sep" />
<button className="danger" type="button" disabled={selected.size === 0} onClick={() => selected.size && setConfirmIds(Array.from(selected))}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
</button>
<button type="button" onClick={exitEdit}></button>
</div>
<AssetDetailModal
asset={detail}
close={() => setDetail(null)}
onZoom={(src, kind, name) => setPreview({ src, kind, name })}
/>
<MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} /> <MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />
<ConfirmModal <ConfirmModal
open={Boolean(confirmId)} open={Boolean(confirmIds && confirmIds.length)}
title="删除资产" title="删除资产"
detail="确定删除该资产?该操作不可撤销。" subtitle="// DELETE ASSET"
icon={<Trash2 size={16} />}
detail={`确定删除选中的 ${confirmIds?.length || 0} 个资产?该操作不可撤销。`}
confirmText="删除" confirmText="删除"
onCancel={() => setConfirmId(null)} onCancel={() => setConfirmIds(null)}
onConfirm={async () => { const id = confirmId; setConfirmId(null); if (id) { await onDelete?.(id); setReloadFlag((f) => f + 1); } }} onConfirm={doDelete}
/> />
<Drawer title="上传资产" open={drawer} close={() => setDrawer(false)}><form onSubmit={submit}><div className="field"><label className="field-label"></label><input className="input file-input" type="file" accept="image/*,video/*,audio/*,.srt,.vtt,.ass" onChange={(event) => setFile(event.target.files?.[0] || null)} /></div><div className="field"><label className="field-label"></label><input className="input" value={name} onChange={(event) => setName(event.target.value)} /></div><div className="drawer-actions"><button className="btn btn-ghost" type="button" onClick={() => setDrawer(false)}></button><button className="btn btn-primary" type="submit" disabled={!file || uploading}>{uploading ? "上传中…" : "上传资产"}</button></div></form></Drawer> <UploadModal open={uploadOpen} close={() => setUploadOpen(false)} onSubmit={doUpload} />
</section> </section>
); );
} }
+76
View File
@@ -0,0 +1,76 @@
// Wave 3 batch2 walkthrough (scratch): library 详情/上传/批量 + ai-tools 模特库/pl-modal.
import { chromium } from "playwright";
import fs from "node:fs";
import path from "node:path";
const BASE = process.env.BASE || "http://localhost:5173";
const TOKEN = process.env.TOKEN || "";
const OUT = path.resolve("../../../_qa_shots/wave3-batch2");
fs.mkdirSync(OUT, { recursive: true });
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, colorScheme: "light" });
const seed = await ctx.newPage();
await seed.goto(BASE + "/", { waitUntil: "domcontentloaded" });
await seed.evaluate((t) => localStorage.setItem("airshelf_token", t), TOKEN);
await seed.close();
const r = { library: {}, aitools: {}, consoleErrors: [] };
// ---- LIBRARY ----
{
const p = await ctx.newPage();
p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push("lib:" + m.text()); });
p.on("pageerror", (e) => r.consoleErrors.push("lib:PAGEERR:" + e.message));
await p.goto(BASE + "/library", { waitUntil: "load" });
await p.waitForTimeout(1800);
r.library.assetCards = await p.locator(".asset-card").count();
await p.screenshot({ path: path.join(OUT, "library.png") });
// 详情弹窗:点首张卡
if (r.library.assetCards > 0) {
await p.locator(".asset-card").first().click();
await p.waitForTimeout(500);
r.library.detailModal = await p.locator(".asset-detail-modal, .modal-bg.show").count();
await p.screenshot({ path: path.join(OUT, "library-detail.png") });
await p.keyboard.press("Escape");
await p.waitForTimeout(400);
r.library.detailClosedByEsc = (await p.locator(".asset-detail-modal").count()) === 0;
}
// 上传弹窗:找含「上传」的按钮
const upBtn = p.locator("button:has-text('上传')").first();
if (await upBtn.count()) {
await upBtn.click();
await p.waitForTimeout(450);
r.library.uploadModal = await p.locator(".upload-modal, .modal-bg.show").count();
await p.screenshot({ path: path.join(OUT, "library-upload.png") });
await p.keyboard.press("Escape");
await p.waitForTimeout(300);
}
await p.close();
}
// ---- AI-TOOLS (model-photo) ----
{
const p = await ctx.newPage();
p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push("ai:" + m.text()); });
p.on("pageerror", (e) => r.consoleErrors.push("ai:PAGEERR:" + e.message));
await p.goto(BASE + "/model-photo", { waitUntil: "load" });
await p.waitForTimeout(1800);
r.aitools.actorLibBtn = await p.locator(".iw-actor-lib-btn").count();
r.aitools.plBtn = await p.locator(".iw-pl-btn").count();
await p.screenshot({ path: path.join(OUT, "model-photo.png") });
// 商品库 pl-modal
if (await p.locator(".iw-pl-btn").count()) {
await p.locator(".iw-pl-btn").first().click();
await p.waitForTimeout(500);
r.aitools.plModal = await p.locator(".pl-modal, .pl-modal-bg").count();
await p.screenshot({ path: path.join(OUT, "model-photo-plmodal.png") });
await p.keyboard.press("Escape");
await p.waitForTimeout(400);
}
await p.close();
}
await browser.close();
console.log(JSON.stringify(r, null, 2));
fs.writeFileSync(path.join(OUT, "summary.json"), JSON.stringify(r, null, 2));