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:
co-authored by
Claude Opus 4.8
parent
0083918207
commit
38f56cb037
@@ -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-menu-wrap { position: relative; }
|
||||
.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 {
|
||||
flex: 1; min-height: 0;
|
||||
display: grid;
|
||||
@@ -426,8 +439,24 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.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; }
|
||||
/* P0①「模特库」入口按钮(打开全屏 ActorLibrary · 二级胶囊 · 不抢主橙) */
|
||||
.image-workbench .iw-step-h .iw-actor-lib-btn {
|
||||
margin-left: auto;
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
height: 26px; padding: 0 10px 0 8px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--black-alpha-72);
|
||||
font-size: 12px; font-family: inherit; cursor: pointer;
|
||||
transition: border-color var(--t-base), color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.image-workbench .iw-step-h .iw-actor-lib-btn:hover { border-color: var(--heat-20); color: var(--heat); background: var(--heat-12); }
|
||||
.image-workbench .iw-step-h .iw-actor-lib-btn svg { width: 12px; height: 12px; }
|
||||
/* 模特库按钮在场时,「全部 (n)」紧随其后,不再 margin-left:auto */
|
||||
.image-workbench .iw-step-h .iw-actor-lib-btn + .right { margin-left: 8px; }
|
||||
.image-workbench .iw-sub-h {
|
||||
font-size: 12px; color: var(--black-alpha-48);
|
||||
margin-bottom: 6px;
|
||||
@@ -687,7 +716,40 @@
|
||||
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: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; }
|
||||
|
||||
/* §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 · 不放主橙) */
|
||||
.image-workbench .gen-card-actions {
|
||||
display: flex; gap: 8px;
|
||||
@@ -735,6 +797,32 @@
|
||||
/* 批次卡:复用 .gen-card。每批各带一个导航头(.iw-pv-h),批次间靠「卡片→下一个头」拉开间距;
|
||||
首个头紧跟 iw-preview 顶 padding,无需额外上间距(故只给跟在卡片后的头加 margin)。 */
|
||||
.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 {
|
||||
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-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: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 {
|
||||
width: 100%;
|
||||
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-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); }
|
||||
|
||||
/* ============================================================
|
||||
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; }
|
||||
|
||||
@@ -26,3 +26,188 @@
|
||||
|
||||
/* 编辑模式:开启「管理资产」后,资产卡删除按钮常显(否则全局只 hover 显) */
|
||||
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; }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { ChangeEvent } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -18,13 +19,15 @@ import {
|
||||
Search,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Users,
|
||||
WandSparkles,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import type { AITask, Asset, ModelConfig, Product } from "../types";
|
||||
import { api } from "../api";
|
||||
import { ActorLibrary } from "../components/actor-library";
|
||||
import { SkeletonRows } from "../components/loading";
|
||||
import { MediaLightbox, SuccessModal } from "../components/overlays";
|
||||
import { MediaLightbox } from "../components/overlays";
|
||||
import { Pager } from "../components/pager";
|
||||
import type { Page } from "./route-config";
|
||||
import { statusPill } from "./stage-config";
|
||||
@@ -487,6 +490,10 @@ type GenBatch = {
|
||||
productTitle?: string;
|
||||
/** 该批次选中的模特资产 id(模特上身图:重跑时沿用同一模特) */
|
||||
modelId?: string;
|
||||
/** 该批次选中的模特展示名(导航头显示) */
|
||||
modelName?: string;
|
||||
/** 该批次选中的平台 id 列表(平台套图:多选 → 各平台分组,P0③) */
|
||||
platformIds?: string[];
|
||||
};
|
||||
|
||||
export function ImageWorkbenchPage({
|
||||
@@ -521,12 +528,23 @@ export function ImageWorkbenchPage({
|
||||
const [ratio, setRatio] = useState(meta.ratio);
|
||||
const [style, setStyle] = useState("auto");
|
||||
const [count, setCount] = useState(mode === "image" ? "4" : "4");
|
||||
// 行29:模特/平台改单选(原 string[] 多选 → 单个 id;沿用数组结构最小改动,长度恒为 0/1)
|
||||
// 模特单选(长度恒 0/1);平台改回多选(P0③:可选多个平台,各出一组结果)
|
||||
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
|
||||
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 refInputRef = useRef<HTMLInputElement | null>(null);
|
||||
// 生成结果图片放大预览
|
||||
@@ -578,10 +596,27 @@ export function ImageWorkbenchPage({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [productId, mode]);
|
||||
|
||||
// 行29:单选——再点同一张取消,点别张替换(长度恒为 0 或 1)
|
||||
function togglePick(id: string) {
|
||||
// 模特:单选(再点同一张取消,点别张替换);平台:多选(toggle 进/出数组)
|
||||
function togglePick(id: string, name?: string) {
|
||||
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 candidateCount = Math.max(1, Number(count) || 4);
|
||||
@@ -589,7 +624,9 @@ export function ImageWorkbenchPage({
|
||||
// 模特上身图需结合「商品图 + 模特图」合成,故必须先选商品且选一个模特,否则只是凭文字脑补
|
||||
const canGenerate =
|
||||
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");
|
||||
|
||||
/* 多批次本地持久化(行33):用 ai-tools 私有 key,不与 App 的单批 `airshelf:imgwb:{mode}` 抢同一槽。
|
||||
@@ -605,32 +642,38 @@ export function ImageWorkbenchPage({
|
||||
}
|
||||
}
|
||||
|
||||
async function runGenerate() {
|
||||
if (!canGenerate) return;
|
||||
/* 单批次执行:追加占位 → onGenerate → 回填结果/失败。所有提交路径(立即生成 / 重跑 / 单图再生成 / 平台分组)共用。 */
|
||||
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 newBatch: GenBatch = {
|
||||
id: batchId,
|
||||
prompt: prompt.trim(),
|
||||
ratio,
|
||||
count: candidateCount,
|
||||
prompt: opts.prompt,
|
||||
ratio: opts.ratio,
|
||||
count: opts.count,
|
||||
status: "generating",
|
||||
results: [],
|
||||
adopted: false,
|
||||
ts: Date.now(),
|
||||
productId: product?.id,
|
||||
productTitle: product?.title,
|
||||
modelId: mode === "model" ? pickedIds[0] : undefined
|
||||
productId: opts.productId,
|
||||
productTitle: opts.productTitle,
|
||||
modelId: opts.modelId,
|
||||
modelName: opts.modelName,
|
||||
platformIds: opts.platformIds
|
||||
};
|
||||
// 追加新批次(行32②:不覆盖,在下面新增一行)
|
||||
setBatches((prev) => [...prev, newBatch]);
|
||||
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) => {
|
||||
const next = prev.map((b) =>
|
||||
b.id === batchId
|
||||
? { ...b, status: result?.assets ? ("done" as const) : ("failed" as const), results: result?.assets || [] }
|
||||
: b
|
||||
);
|
||||
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;
|
||||
});
|
||||
@@ -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①②):用该批次的参数新起一个批次追加到列表末尾,原批次保留。 */
|
||||
async function rerunBatch(src: GenBatch) {
|
||||
const batchId = `b-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
const newBatch: GenBatch = {
|
||||
id: batchId,
|
||||
await startBatch({
|
||||
prompt: src.prompt,
|
||||
ratio: src.ratio,
|
||||
count: src.count,
|
||||
status: "generating",
|
||||
results: [],
|
||||
adopted: false,
|
||||
ts: Date.now(),
|
||||
// 重跑归属原批次的商品,而非当前选中商品
|
||||
productId: src.productId ?? product?.id,
|
||||
productTitle: src.productTitle ?? product?.title,
|
||||
modelId: src.modelId
|
||||
};
|
||||
setBatches((prev) => [...prev, newBatch]);
|
||||
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;
|
||||
});
|
||||
}
|
||||
modelId: src.modelId,
|
||||
modelName: src.modelName,
|
||||
platformIds: src.platformIds
|
||||
});
|
||||
}
|
||||
|
||||
/* 删除整个批次(行32③:批次气泡"删除当前批次") */
|
||||
@@ -701,31 +746,46 @@ export function ImageWorkbenchPage({
|
||||
});
|
||||
}
|
||||
|
||||
/* 加入资产库 / 取消加入(行32④:批次级来回切 + 成功提示) */
|
||||
/* 加入资产库 / 取消加入(批次级来回切;§4.18:就地反馈,亮在该批首图上,不弹全局窗) */
|
||||
function toggleAdoptBatch(batchId: string) {
|
||||
setBatches((prev) => {
|
||||
const target = prev.find((b) => b.id === batchId);
|
||||
const willAdopt = !target?.adopted;
|
||||
const next = prev.map((b) => (b.id === batchId ? { ...b, adopted: willAdopt } : b));
|
||||
persistBatches(next);
|
||||
if (willAdopt && target) setAdoptNotice({ count: target.results.length });
|
||||
const firstId = target?.results[0]?.id;
|
||||
if (willAdopt && firstId) flashFeedback(`${batchId}:${firstId}`);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
/* 加入资产库 / 取消(单图级,行32④:单图气泡"加入资产库",来回切) */
|
||||
function toggleAdoptImage(batchId: string, _assetId: string) {
|
||||
// 单图采用以批次级 adopted 表达(后端无单图采用接口),提示单张
|
||||
/* 加入资产库 / 取消(单图级,§4.18:单图气泡"加入资产库",就地反馈不弹全局窗) */
|
||||
function toggleAdoptImage(batchId: string, assetId: string) {
|
||||
// 单图采用以批次级 adopted 表达(后端无单图采用接口),反馈落在被点的那张图上
|
||||
setBatches((prev) => {
|
||||
const target = prev.find((b) => b.id === batchId);
|
||||
const willAdopt = !target?.adopted;
|
||||
const next = prev.map((b) => (b.id === batchId ? { ...b, adopted: willAdopt } : b));
|
||||
persistBatches(next);
|
||||
if (willAdopt) setAdoptNotice({ count: 1 });
|
||||
if (willAdopt) flashFeedback(`${batchId}:${assetId}`);
|
||||
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 多批次残留回显已出的批次;
|
||||
再读 App 写的单批 `airshelf:imgwb:{mode}`(仍在跑的任务),对其继续轮询补一个恢复批次。 */
|
||||
useEffect(() => {
|
||||
@@ -786,7 +846,9 @@ export function ImageWorkbenchPage({
|
||||
|
||||
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) {
|
||||
const generating = batch.status === "generating";
|
||||
const n = batch.results.length || batch.count;
|
||||
@@ -800,47 +862,67 @@ export function ImageWorkbenchPage({
|
||||
{(batch.results.length
|
||||
? 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: "" }))
|
||||
).map(({ key, index, url, assetId }) => (
|
||||
<div className={`gen-image ${generating && !url ? "gen" : ""}`} key={key}>
|
||||
{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}` })} />
|
||||
) : (
|
||||
<div className="placeholder">
|
||||
{generating ? (
|
||||
// 行30:生成中占位用旋转 spinner icon,不再是「生成中…」文字
|
||||
<span className="gen-loading" aria-label="生成中">
|
||||
<span className="spinner" aria-hidden />
|
||||
</span>
|
||||
) : (
|
||||
<span className="ph-frame">{batch.ratio} · #{index + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{url && (
|
||||
<div className="gen-image-actions">
|
||||
<button className="gen-img-btn" type="button" title="下载" onClick={() => downloadImage(url, `${meta.title}-${index + 1}.png`)}>
|
||||
<Download size={14} />
|
||||
</button>
|
||||
{/* 行32③:hover「更多」出气泡菜单(加入资产库 / 删除),不再直接下载 */}
|
||||
<div className="gen-img-more">
|
||||
<button className="gen-img-btn" type="button" title="更多">
|
||||
<MoreHorizontal size={14} />
|
||||
).map(({ key, index, url, assetId }) => {
|
||||
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 ? (
|
||||
<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">
|
||||
{generating ? (
|
||||
<span className="gen-loading" aria-label="生成中">
|
||||
<span className="spinner" aria-hidden />
|
||||
</span>
|
||||
) : (
|
||||
<span className="ph-frame">{batch.ratio} · #{index + 1}</span>
|
||||
)}
|
||||
</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 && (
|
||||
<div className="gen-image-actions">
|
||||
<button className="gen-img-btn" type="button" title="再生成" disabled={generating} onClick={() => regenSingleImage(batch)}>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
<div className="gen-bubble">
|
||||
<button type="button" className="gen-bubble-item" onClick={() => toggleAdoptImage(batch.id, assetId)}>
|
||||
<Bookmark size={13} />
|
||||
{batch.adopted ? "取消加入资产库" : "加入资产库"}
|
||||
</button>
|
||||
<button type="button" className="gen-bubble-item danger" onClick={() => removeImageFromBatch(batch.id, assetId)}>
|
||||
<Trash2 size={13} />
|
||||
删除
|
||||
<button className="gen-img-btn" type="button" title="下载" onClick={() => downloadImage(url, `${meta.title}-${index + 1}.png`)}>
|
||||
<Download size={14} />
|
||||
</button>
|
||||
<div className="gen-img-more">
|
||||
<button className="gen-img-btn" type="button" title="更多" onClick={() => setOpenMore((k) => (k === cellKey ? "" : cellKey))}>
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
<div className={`gen-bubble${openMore === cellKey ? " open" : ""}`}>
|
||||
<button type="button" className="gen-bubble-item" onClick={() => { setOpenMore(""); toggleAdoptImage(batch.id, assetId); }}>
|
||||
<Bookmark size={13} />
|
||||
{batch.adopted ? "取消加入资产库" : "加入资产库"}
|
||||
</button>
|
||||
<button type="button" className="gen-bubble-item danger" onClick={() => { setOpenMore(""); removeImageFromBatch(batch.id, assetId); }}>
|
||||
<Trash2 size={13} />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -873,12 +955,18 @@ export function ImageWorkbenchPage({
|
||||
<span className="k">商品</span>
|
||||
<span className="v">{batchProductTitle(batch)}</span>
|
||||
</div>
|
||||
{mode === "model" && batch.modelName && (
|
||||
<div className="pv-line">
|
||||
<span className="k">模特</span>
|
||||
<span className="v">{batch.modelName}</span>
|
||||
</div>
|
||||
)}
|
||||
{mode === "cover" && (
|
||||
<div className="pv-line">
|
||||
<span className="k">平台</span>
|
||||
<span className="v">
|
||||
{pickedIds.length
|
||||
? PLATFORM_OPTIONS.filter((p) => pickedIds.includes(p.id))
|
||||
{batch.platformIds && batch.platformIds.length
|
||||
? PLATFORM_OPTIONS.filter((p) => batch.platformIds!.includes(p.id))
|
||||
.map((p) => p.name)
|
||||
.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)
|
||||
左会话列表 + 中央 hero/对话流 + 底部 chat 输入栏
|
||||
@@ -1094,13 +1218,6 @@ export function ImageWorkbenchPage({
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1186,6 +1303,11 @@ export function ImageWorkbenchPage({
|
||||
{product?.title || "未选择 · 请在左侧商品空间选一个"}
|
||||
</span>
|
||||
</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" />
|
||||
<div className="tb-search-wrap">
|
||||
{searchOpen && (
|
||||
@@ -1225,8 +1347,13 @@ export function ImageWorkbenchPage({
|
||||
<div className="iw-step-h">
|
||||
<span className="num">1</span>
|
||||
<span className="title">选择模特</span>
|
||||
{/* P0①:接入现成模特库全屏弹窗(原「全部模特→」仅内联展开;现可打开完整演员库选用) */}
|
||||
<button className="iw-actor-lib-btn" type="button" onClick={() => setActorLibOpen(true)}>
|
||||
<Users size={12} />
|
||||
模特库
|
||||
</button>
|
||||
{personAssets.length > 6 && (
|
||||
<span className="right" role="button" tabIndex={0} style={{ cursor: "pointer" }} onClick={() => setShowAllModels((v) => !v)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setShowAllModels((v) => !v); } }}>{showAllModels ? "收起 ↑" : `全部模特 (${personAssets.length}) →`}</span>
|
||||
<span className="right" role="button" tabIndex={0} style={{ cursor: "pointer" }} onClick={() => setShowAllModels((v) => !v)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setShowAllModels((v) => !v); } }}>{showAllModels ? "收起 ↑" : `全部 (${personAssets.length})`}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="model-grid">
|
||||
@@ -1242,7 +1369,7 @@ export function ImageWorkbenchPage({
|
||||
type="button"
|
||||
key={item.id}
|
||||
className={`model-card ${pickedIds.includes(item.id) ? "selected" : ""}`}
|
||||
onClick={() => togglePick(item.id)}
|
||||
onClick={() => togglePick(item.id, item.name)}
|
||||
>
|
||||
<span className="m-check">
|
||||
<Check size={11} />
|
||||
@@ -1269,7 +1396,7 @@ export function ImageWorkbenchPage({
|
||||
type="button"
|
||||
key={item.id}
|
||||
className={`model-card ${pickedIds.includes(item.id) ? "selected" : ""}`}
|
||||
onClick={() => togglePick(item.id)}
|
||||
onClick={() => togglePick(item.id, item.name)}
|
||||
>
|
||||
<span className="m-check">
|
||||
<Check size={11} />
|
||||
@@ -1377,21 +1504,72 @@ export function ImageWorkbenchPage({
|
||||
) : (
|
||||
<>
|
||||
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
|
||||
{/* 行34:每批各自一个导航头(含所属商品)——见 renderBatchCards */}
|
||||
{renderBatchCards(batches)}
|
||||
{/* P0③:平台套图按平台分组出 section;模特图沿用每批一个导航头 */}
|
||||
{mode === "cover" ? renderCoverGrouped(batches) : renderBatchCards(batches)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<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)}
|
||||
|
||||
{/* P0①:模特库全屏弹窗(复用现成 ActorLibrary,mode=replace 选一个回填到「选择模特」) */}
|
||||
<ActorLibrary
|
||||
open={actorLibOpen}
|
||||
mode="replace"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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(纯静态展示 · 像素还原)
|
||||
variant="A" → public/exact/model-photo-demo-a.html
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { FormEvent, ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Download, Image as ImageIcon, Images, Info, LayoutGrid, Music, Shield, Trash2, Upload, X } from "lucide-react";
|
||||
import { api } from "../api";
|
||||
import { SkeletonGrid } from "../components/loading";
|
||||
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";
|
||||
|
||||
const LIB_PAGE_SIZE = 10;
|
||||
@@ -24,6 +26,14 @@ function inferAssetType(file: File): string {
|
||||
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";
|
||||
|
||||
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"] }
|
||||
];
|
||||
|
||||
function assetTab(asset: Asset): LibTab {
|
||||
switch (asset.category) {
|
||||
case "person": return "people";
|
||||
case "scene": return "scenes";
|
||||
case "product_image": return "products";
|
||||
case "final_video": return "finals";
|
||||
case "upload": return "uploads";
|
||||
default: return asset.asset_type === "video" ? "finals" : "unclassified";
|
||||
// metadata 里可能用的中文属性键(真实存在才渲染,缺则不显;属性区不造假)
|
||||
const META_PROP_LABELS: Record<string, string> = {
|
||||
gender: "性别", age: "年龄段", role: "角色", sceneType: "场景类型", scene_type: "场景类型",
|
||||
duration: "时长", product: "关联商品", project: "关联项目", ratio: "画幅", resolution: "分辨率",
|
||||
tone: "风格", style: "风格"
|
||||
};
|
||||
|
||||
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 }) {
|
||||
const [tab, setTab] = useState<LibTab>("people");
|
||||
const [query, setQuery] = useState("");
|
||||
const [drawer, setDrawer] = useState(false);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [openChip, setOpenChip] = useState("");
|
||||
const [srcFilter, setSrcFilter] = useState("");
|
||||
const [kindFilter, setKindFilter] = useState("");
|
||||
const [sortDesc, setSortDesc] = useState(true);
|
||||
// 编辑模式 + 元数据筛选(性别/年龄/角色/场景类型/关联/时长 走 asset.metadata,真实存在才有可选项)
|
||||
// 编辑模式 + 多选 + 元数据筛选(性别/年龄/角色/场景类型/关联/时长 走 asset.metadata,真实存在才有可选项)
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
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);
|
||||
useEffect(() => {
|
||||
document.body.classList.toggle("edit-mode", editMode);
|
||||
return () => document.body.classList.remove("edit-mode");
|
||||
}, [editMode]);
|
||||
const toggleSelect = (id: string) => setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
const exitEdit = () => { setEditMode(false); setSelected(new Set()); };
|
||||
|
||||
// ── 服务端懒加载:列表按页拉、tab 计数走 summary、筛选项走 facets(不再前端取全量再切片)──
|
||||
const [items, setItems] = useState<Asset[]>([]);
|
||||
@@ -91,8 +436,8 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
||||
|
||||
// 切 tab / 改筛选 / 改排序回到第 1 页
|
||||
useEffect(() => { setPage(1); }, [tab, debouncedQuery, srcFilter, kindFilter, metaFilter, sortDesc]);
|
||||
// 切 tab 时清空与该 tab 无关的筛选
|
||||
useEffect(() => { setOpenChip(""); setSrcFilter(""); setKindFilter(""); setMetaFilter({}); }, [tab]);
|
||||
// 切 tab 时清空与该 tab 无关的筛选 + 退出多选(避免跨 tab 误删)
|
||||
useEffect(() => { setOpenChip(""); setSrcFilter(""); setKindFilter(""); setMetaFilter({}); setSelected(new Set()); }, [tab]);
|
||||
|
||||
// 当前 tab 的 metadata 筛选键(来源/类型走独立字段,不算 metadata)
|
||||
const metaKeys = LIB_CHIPS.filter((c) => c.tabs.includes(tab) && c.key !== "source" && c.key !== "kind").map((c) => c.key);
|
||||
@@ -132,6 +477,8 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
||||
const hasFilter = Boolean(debouncedQuery || srcFilter || kindFilter || Object.values(metaFilter).some(Boolean));
|
||||
const totalPages = Math.max(1, Math.ceil(total / LIB_PAGE_SIZE));
|
||||
const curPage = Math.min(page, totalPages);
|
||||
// 乐观隐藏后真正渲染的列表
|
||||
const shown = items.filter((a) => !deletingIds.has(a.id));
|
||||
|
||||
useEffect(() => {
|
||||
if (!openChip) return;
|
||||
@@ -142,24 +489,29 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
||||
return () => document.removeEventListener("click", close);
|
||||
}, [openChip]);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!file || uploading) return;
|
||||
// 上传:拼 FormData 走父级 onUpload,完成后刷新列表/计数/筛选项
|
||||
async function doUpload(file: File, name: string, assetType: string) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("name", name || file.name);
|
||||
formData.append("asset_type", inferAssetType(file));
|
||||
formData.append("asset_type", assetType);
|
||||
formData.append("category", "upload");
|
||||
// 上传 + 列表刷新要数秒,按钮给「上传中…」反馈,防止用户重复点
|
||||
setUploading(true);
|
||||
await onUpload(formData);
|
||||
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 onUpload(formData);
|
||||
setDrawer(false);
|
||||
setFile(null);
|
||||
setName("");
|
||||
setReloadFlag((f) => f + 1); // 上传后刷新列表/计数/筛选项
|
||||
await Promise.all(ids.map((id) => onDelete(id)));
|
||||
setReloadFlag((f) => f + 1);
|
||||
} 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>
|
||||
<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>
|
||||
<span className="lib-manage-label">{editMode ? "完成" : "管理资产"}</span>
|
||||
</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>
|
||||
上传资产
|
||||
</button>
|
||||
@@ -273,29 +625,46 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
||||
</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">
|
||||
{items.map((asset) => {
|
||||
{shown.map((asset) => {
|
||||
const cover = asset.files?.find((f) => f.is_primary)?.preview_url || asset.files?.[0]?.preview_url || "";
|
||||
const isVideo = asset.asset_type === "video";
|
||||
const isAudio = asset.asset_type === "audio";
|
||||
const previewKind = isVideo ? "video" : isAudio ? "audio" : "image";
|
||||
const openPreview = cover ? () => setPreview({ src: cover, kind: previewKind, name: asset.name }) : undefined;
|
||||
const isSelected = selected.has(asset.id);
|
||||
// 卡片 meta:upload 分类下「上传 · 上传」无信息量,改显资产类型;其余显分类中文
|
||||
const catLabel = (asset.category === "upload" ? KIND_LABELS[asset.asset_type] : CATEGORY_LABELS[asset.category || ""]) || asset.category;
|
||||
const srcLabel = SOURCE_LABELS[asset.source || ""] || asset.source;
|
||||
// 成片卡补导出时间(created_at = 导出入库时刻),格式 2026-06-09 18:21
|
||||
const exportedAt = asset.category === "final_video" ? (asset.created_at || "").slice(0, 16).replace("T", " ") : "";
|
||||
const missingTri = personMissingTri(asset);
|
||||
// 编辑态:整卡点击 = 多选;非编辑态:整卡点击 = 开详情
|
||||
const onCardClick = editMode ? () => toggleSelect(asset.id) : () => setDetail(asset);
|
||||
return (
|
||||
<article className={`asset-card ${asset.asset_type}`} 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 && (
|
||||
<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>
|
||||
</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 && (
|
||||
<>
|
||||
<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} />
|
||||
|
||||
{/* 编辑模式浮动批量操作栏(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)} />
|
||||
|
||||
<ConfirmModal
|
||||
open={Boolean(confirmId)}
|
||||
open={Boolean(confirmIds && confirmIds.length)}
|
||||
title="删除资产"
|
||||
detail="确定删除该资产?该操作不可撤销。"
|
||||
subtitle="// DELETE ASSET"
|
||||
icon={<Trash2 size={16} />}
|
||||
detail={`确定删除选中的 ${confirmIds?.length || 0} 个资产?该操作不可撤销。`}
|
||||
confirmText="删除"
|
||||
onCancel={() => setConfirmId(null)}
|
||||
onConfirm={async () => { const id = confirmId; setConfirmId(null); if (id) { await onDelete?.(id); setReloadFlag((f) => f + 1); } }}
|
||||
onCancel={() => setConfirmIds(null)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user