feat: refine asset media and project setup flows
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 10s

This commit is contained in:
iye 2026-06-01 13:42:52 +08:00
parent e06a16e200
commit 2ba1058329
2 changed files with 552 additions and 68 deletions

View File

@ -1566,7 +1566,7 @@ submitBtn.addEventListener('click', () => {
card.dataset.source = '手动上传';
card.dataset.used = '0';
card.dataset.added = stamp;
card.setAttribute('onclick', `Shell.toast('查看资产', ${JSON.stringify(name)})`);
card.setAttribute('onclick', `if(!document.body.classList.contains('edit-mode')&&window.LibraryAssetDetailOpen){window.LibraryAssetDetailOpen(this)}else if(!document.body.classList.contains('edit-mode')){Shell.toast('查看资产', ${JSON.stringify(name)})}`);
let metaText = '';
if (kind === 'people') {
@ -1979,6 +1979,7 @@ document.querySelectorAll('.asset-card').forEach(card => {
.asset-modal-bg { position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 1000; display: none; align-items: center; justify-content: center; padding: 40px; }
.asset-modal-bg.show { display: flex; }
.asset-modal { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); width: min(1040px, 100%); max-height: calc(100vh - 80px); overflow: hidden; display: flex; flex-direction: column; box-shadow: 0 16px 48px rgba(0,0,0,.18); }
.asset-modal.product-mode { width: min(1180px, 100%); }
.asset-modal-h { display: flex; align-items: center; gap: 10px; padding: 14px 20px; border-bottom: 1px solid var(--border-faint); }
.asset-modal-h h2 { font-size: 15px; font-weight: 600; }
.asset-modal-h .ad-tag { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; }
@ -1997,9 +1998,11 @@ document.querySelectorAll('.asset-card').forEach(card => {
.asset-detail-tri-row .placeholder:hover .ad-zoom-btn { opacity: 1; }
.asset-detail-tri-row .placeholder { position: relative; }
.asset-detail-lead .ad-thumbs { display: flex; gap: 8px; }
.asset-detail-lead .ad-thumbs .thumb { flex: 0 0 64px; aspect-ratio: 3/4; border-radius: var(--r-sm); border: 1px solid var(--border-faint); cursor: pointer; overflow: hidden; }
.asset-detail-lead .ad-thumbs .thumb { flex: 0 0 64px; aspect-ratio: 3/4; border-radius: var(--r-sm); border: 1px solid var(--border-faint); cursor: pointer; overflow: hidden; background-size: cover; background-position: center; display: grid; place-items: center; }
.asset-detail-lead .ad-thumbs .thumb:hover { border-color: var(--heat-40); }
.asset-detail-lead .ad-thumbs .thumb.active { border-color: var(--heat); border-width: 2px; }
.asset-detail-lead .ad-thumbs .thumb.thumb-wide { aspect-ratio: 16/9; }
.asset-detail-lead .ad-thumbs .thumb .ph-frame { font-size: 10px; }
.asset-detail-right .ad-section + .ad-section { margin-top: 18px; }
.asset-detail-section-h { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; color: var(--accent-black); margin-bottom: 10px; }
.asset-detail-section-h .ic { width: 14px; height: 14px; color: var(--heat); display: grid; place-items: center; }
@ -2022,6 +2025,91 @@ document.querySelectorAll('.asset-card').forEach(card => {
.ad-props .ad-prop:nth-last-child(-n+3) { border-bottom: 0; }
.ad-props .ad-prop .k { flex: 0 0 64px; color: var(--black-alpha-56); font-family: var(--font-mono); font-size: 11px; }
.ad-props .ad-prop .v { color: var(--accent-black); font-weight: 500; word-break: break-all; }
.asset-detail-product[hidden], .asset-detail-right[hidden] { display: none !important; }
.asset-detail-product { min-width: 0; }
.asset-modal.product-mode .asset-modal-body { padding: 0; }
.asset-modal.product-mode .asset-detail-grid { grid-template-columns: 280px minmax(0, 1fr); gap: 0; min-height: 640px; }
.asset-modal.product-mode .asset-detail-lead { padding: 20px; border-right: 1px solid var(--border-faint); background: var(--background-lighter); gap: 16px; }
.asset-modal.product-mode .asset-detail-lead .placeholder.ad-lead-img { aspect-ratio: 1; background-color: var(--surface); }
.asset-modal.product-mode .asset-detail-lead .placeholder.ad-lead-img.tri-previewing { aspect-ratio: 16/9; background-color: var(--background-lighter); border-style: dashed; color: var(--black-alpha-64); }
.asset-modal.product-mode .asset-detail-lead .placeholder.ad-lead-img.tri-previewing .ph-frame { max-width: 92%; white-space: normal; line-height: 1.5; }
.asset-modal.product-mode .ad-thumbs { display: block; }
.product-side-info { display: grid; gap: 6px; }
.product-side-info .psi-name { font-size: 14px; font-weight: 600; color: var(--accent-black); line-height: 1.45; }
.product-side-info .psi-meta { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; line-height: 1.6; }
.product-side-nav { display: grid; gap: 6px; padding-top: 10px; border-top: 1px solid var(--border-faint); }
.product-side-nav .psn-item { width: 100%; height: 34px; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 0 10px; border: 0; border-radius: var(--r-sm); background: transparent; color: var(--black-alpha-64); font: inherit; font-size: 12.5px; cursor: pointer; text-align: left; }
.product-side-nav .psn-item:hover { background: var(--black-alpha-4); color: var(--accent-black); }
.product-side-nav .psn-item.active { background: var(--heat-12); color: var(--heat); }
.product-side-nav .psn-item .ct { font-family: var(--font-mono); font-size: 10.5px; color: currentColor; opacity: .72; }
.product-gallery { padding: 22px 24px 28px; }
.product-gallery-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 20px; }
.product-gallery-head .pgh-title { font-size: 14px; font-weight: 600; color: var(--accent-black); line-height: 1.45; }
.product-gallery-head .pgh-sub { margin-top: 4px; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }
.product-gallery-head .pgh-actions { display: flex; gap: 8px; flex-shrink: 0; }
.product-gallery-head .pgh-actions .btn { height: 32px; }
.product-source-filter { display: flex; flex-wrap: wrap; gap: 8px; margin: -6px 0 20px; }
.product-source-filter .chip { height: 30px; padding: 0 12px; font-size: 12px; }
.product-block + .product-block { margin-top: 24px; padding-top: 22px; border-top: 1px solid var(--border-faint); }
.product-block-h { display: flex; align-items: baseline; gap: 8px; margin-bottom: 12px; }
.product-block-h .t { font-size: 13px; font-weight: 600; color: var(--accent-black); }
.product-block-h .m { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; }
.product-block-h .spacer { flex: 1; }
.product-block-h .mini-action { height: 26px; padding: 0 10px; border: 1px solid var(--border-faint); border-radius: var(--r-sm); background: var(--surface); color: var(--black-alpha-72); font-size: 11.5px; font-family: inherit; cursor: pointer; }
.product-block-h .mini-action:hover { background: var(--black-alpha-4); color: var(--accent-black); border-color: var(--black-alpha-24); }
.tri-header-actions { display: inline-flex; align-items: center; gap: 8px; }
.tri-header-actions[hidden] { display: none; }
.tri-header-actions .btn { height: 26px; padding: 0 10px; font-size: 11.5px; }
.product-media-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.product-media-card { border: 1px solid var(--border-faint); border-radius: var(--r-md); overflow: hidden; background: var(--surface); cursor: pointer; transition: background var(--t-base), border-color var(--t-base); }
.product-media-card[hidden], .product-triview-card[hidden], .product-block[hidden] { display: none; }
.product-media-card:hover { background: var(--black-alpha-4); border-color: var(--black-alpha-24); }
.product-media-card.active { border-color: var(--heat); box-shadow: 0 0 0 1px var(--heat) inset; }
.product-media-card .pm-img { aspect-ratio: 1; background: var(--background-lighter); background-size: cover; background-position: center; position: relative; display: grid; place-items: center; }
.product-media-card .pm-img.wide { aspect-ratio: 16/9; }
.product-media-card .pm-img .asset-badge { top: 6px; left: 6px; }
.product-media-card .pm-b { padding: 9px 10px 10px; min-width: 0; }
.product-media-card .pm-t { font-size: 12.5px; font-weight: 600; color: var(--accent-black); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.product-media-card .pm-m { margin-top: 3px; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.product-triview-card { display: grid; grid-template-columns: minmax(0, 1fr) 220px; gap: 12px; align-items: stretch; border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 12px; background: var(--surface); cursor: pointer; }
.product-triview-card:hover { border-color: var(--black-alpha-24); background: var(--black-alpha-4); }
.product-triview-card.active { border-color: var(--heat); box-shadow: 0 0 0 1px var(--heat) inset; }
.product-triview-card.tri-pending { border-color: var(--heat-40); background: var(--heat-12); }
.product-triview-card .tri-preview { min-height: 132px; aspect-ratio: 16/9; border-radius: var(--r-md); background: var(--background-lighter); border: 1px solid var(--border-faint); display: grid; place-items: center; position: relative; overflow: hidden; }
.product-triview-card .tri-preview .ph-frame { font-size: 11px; }
.tri-meta-list { display: flex; flex-direction: column; gap: 8px; }
.tri-meta-list .tm-row { display: flex; justify-content: space-between; gap: 12px; font-size: 12.5px; padding-bottom: 8px; border-bottom: 1px solid var(--border-faint); }
.tri-meta-list .tm-row:last-child { border-bottom: 0; padding-bottom: 0; }
.tri-meta-list .k { color: var(--black-alpha-56); font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .04em; }
.tri-meta-list .v { color: var(--accent-black); font-weight: 500; text-align: right; }
.tri-version-panel { margin-top: 10px; padding: 9px 10px 10px; border: 1px solid var(--border-faint); border-radius: var(--r-md); background: var(--background-lighter); overflow: hidden; }
.tri-version-panel + .product-triview-card { margin-top: 10px; }
.tri-version-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
.tri-version-head .t { font-size: 12.5px; font-weight: 600; color: var(--accent-black); }
.tri-version-head .m { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .04em; color: var(--black-alpha-48); }
.tri-version-list { display: flex; gap: 6px; overflow-x: auto; overflow-y: hidden; padding: 0 0 4px; scrollbar-width: thin; scrollbar-color: var(--black-alpha-24) transparent; }
.tri-version-list::-webkit-scrollbar { height: 6px; }
.tri-version-list::-webkit-scrollbar-track { background: transparent; }
.tri-version-list::-webkit-scrollbar-thumb { background: var(--black-alpha-12); border-radius: var(--r-pill); }
.tri-version-list:hover::-webkit-scrollbar-thumb { background: var(--black-alpha-24); }
.tri-version-item { flex: 0 0 132px; min-height: 44px; padding: 7px 8px; border: 1px solid var(--border-faint); border-radius: var(--r-md); background: var(--surface); color: var(--black-alpha-64); font: inherit; cursor: pointer; text-align: left; display: grid; gap: 3px; transition: background var(--t-base), border-color var(--t-base); }
.tri-version-item:hover { background: var(--black-alpha-4); border-color: var(--black-alpha-24); color: var(--accent-black); }
.tri-version-item.active { border-color: var(--heat); box-shadow: 0 0 0 1px var(--heat) inset; color: var(--accent-black); }
.tri-version-item.current:not(.active) { border-color: var(--forest-bd); background: var(--forest-bg); }
.tri-version-item.pending { background: var(--heat-12); border-color: var(--heat-20); }
.tri-version-item .tv-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.tri-version-item .tv-v { font-size: 12px; font-weight: 600; color: var(--accent-black); }
.tri-version-item .tv-tag { height: 17px; display: inline-flex; align-items: center; padding: 0 6px; border: 1px solid var(--border-faint); border-radius: var(--r-pill); font-size: 10px; white-space: nowrap; }
.tri-version-item .tv-tag.info { color: var(--heat); background: var(--heat-12); border-color: var(--heat-20); }
.tri-version-item .tv-tag.ok { color: var(--accent-forest); background: var(--forest-bg); border-color: var(--forest-bd); }
.tri-version-item .tv-tag.neutral { color: var(--black-alpha-56); background: var(--black-alpha-4); border-color: var(--border-faint); }
.tri-version-item .tv-meta { font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-48); letter-spacing: .02em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@media (max-width: 1100px) {
.asset-detail-grid { grid-template-columns: 280px 1fr; }
.product-media-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.product-triview-card { grid-template-columns: 1fr; }
.tri-version-item { flex-basis: 124px; }
}
.asset-detail-tip { margin-top: 10px; padding: 10px 12px; background: var(--heat-12); border: 1px solid var(--heat-20); border-radius: var(--r-sm); font-size: 12px; color: var(--accent-black); display: flex; align-items: center; gap: 8px; line-height: 1.5; }
.asset-detail-tip svg { width: 14px; height: 14px; color: var(--heat); flex-shrink: 0; }
.asset-detail-tip .ai-gen-btn { margin-left: auto; height: 26px; padding: 0 10px; background: var(--heat); color: #fff; border: 1px solid var(--heat); border-radius: var(--r-sm); font-size: 11.5px; cursor: pointer; font-family: inherit; flex-shrink: 0; display: inline-flex; align-items: center; }
@ -2080,7 +2168,7 @@ document.querySelectorAll('.asset-card').forEach(card => {
</div>
<div class="ad-thumbs" id="lib-detail-thumbs"></div>
</div>
<div class="asset-detail-right">
<div class="asset-detail-right" id="lib-detail-generic-pane">
<div class="ad-section" id="lib-detail-tri-section">
<div class="asset-detail-section-h">
<span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg></span>
@ -2114,6 +2202,7 @@ document.querySelectorAll('.asset-card').forEach(card => {
</div>
<div class="ad-props" id="lib-detail-props"></div>
</div>
<div class="asset-detail-product" id="lib-detail-product-pane" hidden></div>
</div>
</div>
<div class="asset-modal-f">
@ -2149,10 +2238,13 @@ document.querySelectorAll('.asset-card').forEach(card => {
document.body.insertAdjacentHTML('beforeend', modalHTML);
const bg = document.getElementById('lib-detail-bg');
const modalEl = bg.querySelector('.asset-modal');
const titleEl = document.getElementById('lib-detail-title');
const kindEl = document.getElementById('lib-detail-kind');
const leadImg = document.getElementById('lib-detail-lead-img');
const thumbsEl = document.getElementById('lib-detail-thumbs');
const genericPane = document.getElementById('lib-detail-generic-pane');
const productPane = document.getElementById('lib-detail-product-pane');
const triSection = document.getElementById('lib-detail-tri-section');
const triEl = document.getElementById('lib-detail-tri');
const ratioChip = document.getElementById('lib-detail-ratio');
@ -2178,6 +2270,406 @@ document.querySelectorAll('.asset-card').forEach(card => {
let _generating = false;
let _allowGen = false; // 是否启用生成入口(missing tri-view 才启用)
function _esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function _imgStyle(src) {
return src ? ' style="background-image:url(' + _esc(src) + ')"' : '';
}
function _productInfo(productName, assetName) {
const key = productName + ' ' + assetName;
const map = [
{ test: /面膜|补水/i, product: '透真玻尿酸补水面膜', cat: '美妆个护', sku: 'SKU-MASK-0515', hero: 'assets/mock/product-mask.png', tone: '敏感肌 · 熬夜修护' },
{ test: /防晒/i, product: '透真清透物理防晒霜', cat: '美妆个护', sku: 'SKU-SUN-0508', hero: 'assets/mock/product-sunscreen.png', tone: '通勤防晒 · 物理防护' },
{ test: /耳机|南卡/i, product: '南卡 Lite Pro 蓝牙耳机', cat: '数码 3C', sku: 'SKU-EAR-0512', hero: 'assets/mock/product-earbuds.png', tone: '通勤降噪 · 长续航' },
{ test: /速食|牛肉面|面/i, product: '滋啦速食牛肉面 · 6 桶装', cat: '食品饮料', sku: 'SKU-NOODLE-0510', hero: 'assets/mock/product-noodle.png', tone: '加班夜宵 · 热汤治愈' },
{ test: /咖啡|三顿半/i, product: '三顿半同款冻干咖啡粉', cat: '食品饮料', sku: 'SKU-COFFEE-0505', hero: 'assets/mock/product-coffee.png', tone: '早八提神 · 冷萃口感' },
{ test: /炸锅|小熊/i, product: '小熊 4L 可视空气炸锅', cat: '家居家电', sku: 'SKU-FRYER-0503', hero: 'assets/mock/product-air-fryer.png', tone: '小户型 · 低脂快手' },
{ test: /瑜伽|露露/i, product: '露露同款裸感瑜伽裤', cat: '运动户外', sku: 'SKU-YOGA-0430', hero: 'assets/mock/product-yoga-pants.png', tone: '通勤运动 · 裸感高弹' },
];
return map.find(x => x.test.test(key)) || { product: productName || assetName || '未绑定商品', cat: '商品图', sku: 'SKU-ASSET-' + (_hash(assetName || productName) % 1000), hero: '', tone: '商品素材 · 待补充标签' };
}
function _assetKind(name, source) {
if (/三视图|正.?侧.?背/.test(name)) return '商品三视图';
if (/AI|生成|优化/.test(source) || /AI|优化|套图|场景/.test(name)) return 'AI 生成图';
if (/上传|官方/.test(source) || /官方|实拍|原图/.test(name)) return '上传图';
return '商品详情图';
}
function _productMediaCard(item, idx) {
return `<div class="product-media-card" data-product-media="${idx}" data-product-source="${_esc(item.sourceKey || 'detail')}">
<div class="pm-img${item.wide ? ' wide' : ''}"${_imgStyle(item.img)}>
<span class="asset-badge">${_esc(item.badge)}</span>
${item.img ? '' : '<span class="ph-frame">' + _esc(item.frame || item.title) + '</span>'}
</div>
<div class="pm-b">
<div class="pm-t">${_esc(item.title)}</div>
<div class="pm-m">${_esc(item.meta)}</div>
</div>
</div>`;
}
function _renderProductDetail(card, name, source, used) {
const productName = card.dataset.product || name.split('·')[0].trim() || name;
const info = _productInfo(productName, name);
const currentKind = _assetKind(name, source);
const hero = info.hero;
const detailImages = [
{ sourceKey: 'detail', badge: '主图', title: '官方主图', meta: '商品详情页 · 1:1', img: hero, frame: info.product },
{ sourceKey: 'detail', badge: '细节', title: '包装细节', meta: '商品详情页 · 4:3', img: hero, frame: '包装细节' },
{ sourceKey: 'detail', badge: '场景', title: '使用场景', meta: '商品详情页 · 3:4', img: hero, frame: '使用场景' },
{ sourceKey: 'detail', badge: '当前', title: name, meta: source + ' · 当前查看', img: hero, frame: name },
];
const triImage = { sourceKey: 'tri', badge: '三视图', title: '正 / 侧 / 背合图', meta: '16:9 · 当前采用 v2', img: '', wide: true, frame: info.product + ' · 商品三视图' };
const aiImages = [
{ sourceKey: 'tryon', badge: '模特上身', title: 'Ava · 试用图', meta: '图片生成 · 已入库', img: hero, frame: '模特上身图' },
{ sourceKey: 'platform', badge: '平台头图', title: '小红书首图', meta: '图片生成 · 3:4', img: hero, frame: '平台头图' },
{ sourceKey: 'creation', badge: '图片创作', title: '卖点海报', meta: '图片生成 · 1:1', img: hero, frame: '卖点海报' },
];
const allMedia = [...detailImages, triImage, ...aiImages];
const triIndex = detailImages.length;
let triVersion = 2;
let triVersionSeq = triVersion;
let triGeneratingVersion = null;
let triViewingVersion = triVersion;
let triHistory = [
{ version: 2, state: 'current', date: '2026-05-27', source: 'image-2 · 商品库生成' },
{ version: 1, state: 'history', date: '2026-05-19', source: '商品详情页补录' },
];
leadImg.style.backgroundImage = hero ? `url("${hero}")` : '';
leadImg.style.backgroundSize = 'cover';
leadImg.style.backgroundPosition = 'center';
leadImg.innerHTML = hero ? '' : '<span class="ph-frame">' + _esc(name) + '</span>';
thumbsEl.innerHTML = `
<div class="product-side-info">
<div class="psi-name">${_esc(info.product)}</div>
<div class="psi-meta">// ${_esc(info.cat)} · ${_esc(info.tone)}</div>
<div class="psi-meta">// 共 ${allMedia.length} 张 · 商品详情图 ${detailImages.length} · 三视图 1 · AI 生成图 ${aiImages.length}</div>
</div>
<div class="product-side-nav">
<button class="psn-item active" type="button" data-product-jump="product-section-detail"><span>商品详情图</span><span class="ct">${detailImages.length}</span></button>
<button class="psn-item" type="button" data-product-jump="product-section-triview"><span>商品三视图</span><span class="ct">1</span></button>
<button class="psn-item" type="button" data-product-jump="product-section-ai"><span>AI 生成图</span><span class="ct">${aiImages.length}</span></button>
</div>`;
titleEl.textContent = info.product + ' · 商品图片';
kindEl.textContent = '/ 商品图';
productPane.innerHTML = `
<div class="product-gallery">
<div class="product-gallery-head">
<div>
<div class="pgh-title">按来源整理商品详情页中的全部图片</div>
<div class="pgh-sub">// 当前查看: ${_esc(currentKind)} · 用过 ${_esc(used)} 次</div>
</div>
<div class="pgh-actions">
<button class="btn" type="button" data-product-action="open-product">打开商品详情</button>
<button class="btn" type="button" data-product-action="upload-more">上传图片</button>
</div>
</div>
<div class="product-source-filter" aria-label="图片来源筛选">
<button class="chip active" type="button" data-product-filter="all">全部 ${allMedia.length}</button>
<button class="chip" type="button" data-product-filter="detail">商品详情图 ${detailImages.length}</button>
<button class="chip" type="button" data-product-filter="tri">商品三视图 1</button>
<button class="chip" type="button" data-product-filter="tryon">模特上身 1</button>
<button class="chip" type="button" data-product-filter="platform">平台头图 1</button>
<button class="chip" type="button" data-product-filter="creation">图片创作 1</button>
</div>
<div class="product-block" id="product-section-detail">
<div class="product-block-h">
<span class="t">商品详情图</span>
<span class="m">// 商品详情页已绑定图片,不是单独的平台头图</span>
</div>
<div class="product-media-grid">${detailImages.map((item, i) => _productMediaCard(item, i)).join('')}</div>
</div>
<div class="product-block" id="product-section-triview">
<div class="product-block-h">
<span class="t">商品三视图</span>
<span class="m">// 单张 16:9 · 正 / 侧 / 背 合一</span>
<span class="spacer"></span>
<span class="tri-header-actions" hidden>
<button class="btn btn-sm btn-primary" type="button" data-product-action="adopt-tri">采用此版</button>
</span>
<button class="mini-action" type="button" data-product-action="regen-tri">重新生成</button>
</div>
<div class="tri-version-panel">
<div class="tri-version-head">
<span class="t">版本记录</span>
<span class="m">// 横向滚动查看,当前采用已标记</span>
</div>
<div class="tri-version-list" data-tri-history></div>
</div>
<div class="product-triview-card" data-product-media="${triIndex}" data-product-source="tri">
<div class="tri-preview">
<span class="ph-frame">${_esc(info.product)} · 三视图(正 / 侧 / 背) · v2</span>
</div>
<div class="tri-meta-list">
<div class="tm-row"><span class="k">状态</span><span class="v">当前采用</span></div>
<div class="tm-row"><span class="k">版本</span><span class="v">v2 · 2026-05-27</span></div>
<div class="tm-row"><span class="k">用途</span><span class="v">视频镜头一致性 / 商品角度参考</span></div>
<div class="tm-row"><span class="k">来源</span><span class="v">image-2 · 商品库生成</span></div>
</div>
</div>
</div>
<div class="product-block" id="product-section-ai">
<div class="product-block-h">
<span class="t">AI 生成图</span>
<span class="m">// 模特上身 / 平台头图 / 图片创作</span>
<span class="spacer"></span>
<button class="mini-action" type="button" data-product-action="go-generate">继续生成</button>
</div>
<div class="product-media-grid">${aiImages.map((item, i) => _productMediaCard(item, detailImages.length + 1 + i)).join('')}</div>
</div>
</div>
`;
function triVersionInfo(version) {
const row = triHistory.find(v => v.version === version) || triHistory.find(v => v.state === 'current') || triHistory[0];
const isGenerating = row.state === 'generating';
const isCurrent = row.state === 'current' || row.version === triVersion;
const stateText = isGenerating ? '生成中' : (isCurrent ? '当前采用' : '可切换采用');
const tag = isGenerating ? 'info' : (isCurrent ? 'ok' : '');
const suffix = isGenerating ? ' 生成中' : (isCurrent ? ' 当前采用' : '');
return {
...row,
isGenerating,
isCurrent,
isAdoptable: !isGenerating && !isCurrent,
stateText,
tag,
versionText: `v${row.version} · ${row.date}`,
frame: `${info.product} · 三视图(正 / 侧 / 背) · v${row.version}${suffix}`,
};
}
function renderTriVersionHistory() {
const historyEl = productPane.querySelector('[data-tri-history]');
if (!historyEl) return;
const rows = [...triHistory].sort((a, b) => b.version - a.version).map(row => triVersionInfo(row.version));
historyEl.innerHTML = rows.map(row => `
<button class="tri-version-item ${row.isCurrent ? 'current' : ''} ${row.isGenerating ? 'pending' : ''} ${triViewingVersion === row.version ? 'active' : ''}" type="button" data-tri-version="${row.version}">
<span class="tv-top">
<span class="tv-v">v${row.version}</span>
${row.tag ? `<span class="tv-tag ${row.tag}">${_esc(row.stateText)}</span>` : ''}
</span>
<span class="tv-meta">${_esc(row.date)} · ${_esc(row.source)}</span>
</button>
`).join('');
historyEl.querySelectorAll('[data-tri-version]').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
viewTriVersion(Number(btn.dataset.triVersion));
});
});
historyEl.querySelector('.tri-version-item.active')?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
function renderTriPreview() {
const triCard = productPane.querySelector('.product-triview-card');
const headerActions = productPane.querySelector('.tri-header-actions');
if (!triCard) return;
const preview = triCard.querySelector('.tri-preview');
const meta = triCard.querySelector('.tri-meta-list');
const view = triVersionInfo(triViewingVersion);
triCard.classList.toggle('tri-pending', view.isGenerating);
if (headerActions) headerActions.hidden = !view.isAdoptable;
preview.innerHTML = view.isGenerating
? '<div style="display:grid;place-items:center;gap:8px;"><div class="spinner"></div><span class="ph-frame">三视图生成中 · 约 12s</span></div>'
: `<span class="ph-frame">${_esc(view.frame)}</span>`;
meta.innerHTML = `
<div class="tm-row"><span class="k">状态</span><span class="v">${_esc(view.stateText)}</span></div>
<div class="tm-row"><span class="k">版本</span><span class="v">${_esc(view.versionText)}</span></div>
<div class="tm-row"><span class="k">用途</span><span class="v">视频镜头一致性 / 商品角度参考</span></div>
<div class="tm-row"><span class="k">来源</span><span class="v">${_esc(view.source)}</span></div>`;
}
function viewTriVersion(version) {
triViewingVersion = version;
renderTriPreview();
renderTriVersionHistory();
selectProductMedia(triIndex);
}
function renderTriState(mode) {
const triCard = productPane.querySelector('.product-triview-card');
const regenBtn = productPane.querySelector('[data-product-action="regen-tri"]');
if (!triCard) return;
if (regenBtn) {
const hasExtraVersion = triHistory.some(row => row.state === 'candidate' || row.state === 'generating');
regenBtn.disabled = Boolean(triGeneratingVersion);
regenBtn.textContent = triGeneratingVersion ? '生成中...' : (hasExtraVersion ? '再次生成' : '重新生成');
}
renderTriPreview();
renderTriVersionHistory();
}
function selectProductMedia(idx) {
const item = allMedia[idx] || allMedia[0];
const isTri = item.sourceKey === 'tri';
const triView = triVersionInfo(triViewingVersion);
const triLabel = triView.isGenerating
? info.product + ' · 三视图生成中'
: info.product + ' · 三视图 · v' + triView.version + (triView.isCurrent ? ' 当前采用' : '');
leadImg.classList.toggle('tri-previewing', isTri && !item.img);
if (!item.img) {
leadImg.classList.remove('has-mock-media', 'mock-label');
leadImg.style.removeProperty('--mock-media-url');
leadImg.dataset.mockMediaApplied = '1';
}
leadImg.style.backgroundImage = item.img ? `url("${item.img}")` : '';
leadImg.innerHTML = item.img ? '' : '<span class="ph-frame">' + _esc(isTri ? triLabel : (item.frame || item.title)) + '</span>';
productPane.querySelectorAll('[data-product-media]').forEach(x => x.classList.toggle('active', Number(x.dataset.productMedia) === idx));
}
function openProductUpload() {
resetUploadModal();
uploadState.kind = 'products';
kindSel.value = 'products';
syncKindFields();
const productSelect = $('upload-product');
const matchedOption = [...productSelect.options].find(opt => {
const text = opt.textContent.trim();
return text && (info.product.includes(text) || text.includes(info.product.replace(/^透真玻尿酸/, '透真').replace(/ ·.*/, '')));
});
if (matchedOption) productSelect.value = matchedOption.value;
nameInput.value = info.product + ' · 补充图';
syncSubmit();
modalBg.style.zIndex = '1300';
Shell.openModal('upload-modal-bg');
setTimeout(() => fileInput.click(), 120);
}
function startTriRegenerate() {
if (triGeneratingVersion) return;
const nextVersion = ++triVersionSeq;
triGeneratingVersion = nextVersion;
triViewingVersion = nextVersion;
triHistory = [
{ version: nextVersion, state: 'generating', date: '生成中', source: 'image-2 · 资产库重跑' },
...triHistory.filter(row => row.version !== nextVersion),
];
renderTriState('loading');
selectProductMedia(triIndex);
setTimeout(() => {
const generated = triHistory.find(row => row.version === nextVersion);
if (!generated) return;
generated.state = 'candidate';
generated.date = '刚刚生成';
triGeneratingVersion = null;
triViewingVersion = nextVersion;
triImage.title = '正 / 侧 / 背合图 v' + nextVersion;
triImage.meta = '16:9 · v' + nextVersion;
triImage.frame = info.product + ' · 商品三视图 · v' + nextVersion;
renderTriState('pending');
selectProductMedia(triIndex);
Shell.toast('三视图已生成', 'v' + nextVersion + ' 已加入版本记录,可切换采用');
}, 1200);
}
function adoptTriVersion() {
const selected = triHistory.find(row => row.version === triViewingVersion);
if (!selected || selected.state === 'generating' || selected.version === triVersion) return;
triHistory = triHistory.map(row => {
if (row.version === selected.version) return { ...row, state: 'current' };
if (row.state === 'generating') return row;
return { ...row, state: 'candidate' };
});
triVersion = selected.version;
triVersionSeq = Math.max(triVersionSeq, triVersion);
triViewingVersion = triVersion;
triImage.title = '正 / 侧 / 背合图 v' + triVersion;
triImage.meta = '16:9 · 当前采用 v' + triVersion;
triImage.frame = info.product + ' · 商品三视图 · v' + triVersion;
renderTriState('current');
selectProductMedia(triIndex);
Shell.toast('已采用此版本三视图', info.product + ' · v' + triVersion);
}
function applyProductSourceFilter(sourceKey) {
const source = sourceKey || 'all';
productPane.querySelectorAll('[data-product-media]').forEach(el => {
el.hidden = source !== 'all' && el.dataset.productSource !== source;
});
productPane.querySelectorAll('.product-block').forEach(block => {
const media = [...block.querySelectorAll('[data-product-media]')];
block.hidden = media.length > 0 && media.every(el => el.hidden);
});
const firstVisible = productPane.querySelector('[data-product-media]:not([hidden])');
if (firstVisible) selectProductMedia(Number(firstVisible.dataset.productMedia));
}
thumbsEl.querySelectorAll('[data-product-jump]').forEach(el => {
el.addEventListener('click', () => {
const target = productPane.querySelector('#' + el.dataset.productJump);
thumbsEl.querySelectorAll('.psn-item').forEach(x => x.classList.remove('active'));
el.classList.add('active');
target?.scrollIntoView({ block: 'start', behavior: 'smooth' });
});
});
productPane.querySelectorAll('[data-product-media]').forEach(el => {
el.addEventListener('click', () => {
const idx = Number(el.dataset.productMedia);
selectProductMedia(idx);
const item = allMedia[idx];
Shell.toast('已选中图片', item ? item.title : name);
});
});
renderTriState('current');
selectProductMedia(0);
productPane.querySelectorAll('[data-product-filter]').forEach(btn => {
btn.addEventListener('click', () => {
productPane.querySelectorAll('[data-product-filter]').forEach(x => x.classList.remove('active'));
btn.classList.add('active');
applyProductSourceFilter(btn.dataset.productFilter);
});
});
function bindProductActions() {
productPane.querySelectorAll('[data-product-action]').forEach(btn => {
if (btn.dataset.bound === '1') return;
btn.dataset.bound = '1';
btn.addEventListener('click', e => {
e.stopPropagation();
const act = btn.dataset.productAction;
if (act === 'open-product') {
location.href = 'product-detail.html?product=' + encodeURIComponent(info.product);
return;
}
if (act === 'upload-more') {
openProductUpload();
return;
}
if (act === 'new-video') {
location.href = 'projects-new.html?product=' + encodeURIComponent(info.product);
return;
}
if (act === 'go-generate') {
location.href = 'image-optimize.html?product=' + encodeURIComponent(info.product);
return;
}
if (act === 'regen-tri') {
startTriRegenerate();
return;
}
if (act === 'adopt-tri') {
adoptTriVersion();
return;
}
Shell.toast('已记录', info.product);
});
});
}
bindProductActions();
}
function _renderHistory() {
if (!_versions.length) { historyEl.style.display = 'none'; return; }
historyEl.style.display = 'block';
@ -2258,13 +2750,32 @@ document.querySelectorAll('.asset-card').forEach(card => {
historyEl.style.display = 'none';
tipEl.classList.remove('is-loading');
_setAigenLabel('AI 生成三视图', false);
modalEl.classList.remove('product-mode');
genericPane.hidden = false;
productPane.hidden = true;
leadImg.style.backgroundImage = '';
leadImg.style.backgroundSize = '';
leadImg.style.backgroundPosition = '';
applyBtn.textContent = '保存';
const name = card.dataset.name || '资产';
_curName = name;
const used = card.dataset.used || '0';
const source = card.dataset.source || '平台预设';
const isProductAsset = !!card.dataset.product || card.dataset.kind === '商品';
let tagText = 'AI 素材', intro = '', tags = [], props = [], hasTri = false, isActor = false;
if (isProductAsset) {
titleEl.textContent = name;
modalEl.classList.add('product-mode');
genericPane.hidden = true;
productPane.hidden = false;
applyBtn.textContent = '完成';
_renderProductDetail(card, name, source, used);
bg.classList.add('show');
return;
}
if (card.dataset.gender) {
tagText = '人物 · 模特';
isActor = true; hasTri = true;
@ -2309,6 +2820,7 @@ document.querySelectorAll('.asset-card').forEach(card => {
titleEl.textContent = name;
kindEl.textContent = '/ ' + tagText;
leadImg.style.backgroundImage = '';
leadImg.innerHTML = '<span class="ph-frame">' + name + '</span>';
// 立绘 zoom 按钮(单次绑定 · 通过 name 闭包始终读最新 _curName)
const _leadZoomBtn = document.getElementById('lib-detail-lead-zoom');
@ -2385,6 +2897,7 @@ document.querySelectorAll('.asset-card').forEach(card => {
bg.classList.add('show');
}
window.LibraryAssetDetailOpen = open;
// ── 二次确认弹窗 ──
const confirmBg = document.getElementById('lib-confirm-bg');
const confirmCountEl = document.getElementById('lib-confirm-count');

View File

@ -442,8 +442,16 @@
/* ── shared field styles ── */
.field { display: block; margin-bottom: 16px; }
.config-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; align-items: end; margin-bottom: 16px; }
.config-row.single { grid-template-columns: minmax(0, 1fr); }
.config-row .field { margin-bottom: 0; }
.duration-select { cursor: pointer; }
.duration-card-row { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.duration-card { width: 100%; min-height: 108px; text-align: left; font-family: inherit; }
.duration-card .duration-title { font-size: 14px; font-weight: 600; color: var(--accent-black); }
.duration-card .duration-meta { margin-top: 5px; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }
.duration-card .duration-note { margin-top: auto; padding-top: 10px; font-size: 11.5px; color: var(--black-alpha-56); line-height: 1.45; }
.duration-card.selected .duration-title,
.duration-card.selected .duration-meta { color: var(--heat); }
@media (max-width: 1100px) { .duration-card-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 900px) { .config-row { grid-template-columns: 1fr; } }
.field-label { display: block; font-size: 12.5px; color: var(--black-alpha-56); font-weight: 500; margin-bottom: 6px; }
.field-label .req { color: var(--heat); margin-left: 2px; }
@ -654,10 +662,10 @@
];
const DURATIONS = [
{ id: '0-10', label: '0-10 秒', shots: [3, 4], tag: '黄金完播', completion: 52, conversion: 1.6 },
{ id: '0-15', label: '0-15 秒', shots: [4, 5], tag: '完播率最佳', completion: 42, conversion: 1.8 },
{ id: '0-30', label: '0-30 秒', shots: [6, 8], tag: '卖点详解', completion: 32, conversion: 2.1 },
{ id: '0-60', label: '0-60 秒', shots: [10, 12], tag: '故事化', completion: 26, conversion: 2.4 },
{ id: '0-10', label: '0-10 秒', shots: [3, 4], tag: '快速种草' },
{ id: '0-15', label: '0-15 秒', shots: [4, 5], tag: '标准短片' },
{ id: '0-30', label: '0-30 秒', shots: [6, 8], tag: '卖点展开' },
{ id: '0-60', label: '0-60 秒', shots: [10, 12], tag: '故事化' },
];
const STYLES = [
@ -775,7 +783,7 @@
if (s.id === 'manual') return state.manualScript.trim().length >= 20;
return true;
}
function canPass3() { return state.projectName.trim().length >= 2; }
function canPass3() { return state.projectName.trim().length >= 2 && !!state.duration; }
function canFinish() { return canPass1() && canPass2() && canPass3() && state.agreed && balanceAfter() >= 5; }
/* ---------- icons ---------- */
@ -839,8 +847,8 @@
}
function startGenerate() {
const p = getProduct(), d = getDuration(), st = getStyle();
if (!p || !d || !st || state.projectName.trim().length < 2) return;
const p = getProduct(), d = getDuration();
if (!p || !d || state.projectName.trim().length < 2) return;
// 持久化项目, 让 projects.html 下次加载时自动 prepend 到列表
try {
const seconds = (d.id.split('-')[1] || '15');
@ -1001,11 +1009,11 @@
============================================================ */
function railConfig() {
const p = getProduct(), du = getDuration(), st = getStyle();
const cfgReady = !!(du && st);
const p = getProduct(), du = getDuration();
const cfgReady = !!du && canPass3();
return [
{ n: 1, label: '选择商品', desc: p ? p.name : '未选择', done: canPass1() },
{ n: 2, label: '项目配置', desc: cfgReady ? (du.label + ' · ' + st.name) : '时长 · 风格 · 人物', done: cfgReady && canPass3() },
{ n: 2, label: '项目配置', desc: cfgReady ? du.label : '项目名 · 视频时长', done: cfgReady },
];
}
@ -1221,67 +1229,31 @@
}
function renderStep3() {
const personaObj = getPersona(), durObj = getDuration(), styleObj = getStyle();
let showReco = false, recoDur = null, recoStyle = null;
if (personaObj && state.duration && state.scriptStyle) {
const recoMismatch = personaObj.defaults.duration !== state.duration || personaObj.defaults.style !== state.scriptStyle;
showReco = recoMismatch && !state.recoDismissed;
recoDur = DURATIONS.find(d => d.id === personaObj.defaults.duration);
recoStyle = STYLES.find(s => s.id === personaObj.defaults.style);
}
return `<div class="wiz-pane active" data-step="2">
<div class="wiz-step-h">
<h2>第 2 步 · 项目配置</h2>
<p>这些设置会影响 LLM 生成脚本的方向,确认后会进入流水线第 1 步(脚本生成)</p>
<p>基础配置只保留项目名和成片时长。脚本来源、风格和人物设定已移到流水线第 1 步由脚本助手引导。</p>
</div>
<div class="config-row">
<div class="config-row single">
<div class="field">
<label class="field-label">项目名称<span class="req">*</span></label>
<input class="input" value="${esc(state.projectName)}" oninput="_wiz.setName(this.value)">
</div>
<div class="field">
<label class="field-label">视频时长<span class="req">*</span></label>
<select class="input duration-select" onchange="_wiz.setDur(this.value)">
<option value="" disabled ${state.duration ? '' : 'selected'}>选择时长</option>
${DURATIONS.map(d => `<option value="${esc(d.id)}" ${state.duration === d.id ? 'selected' : ''}>${esc(d.label)} · ${d.shots[0]}-${d.shots[1]} 镜</option>`).join('')}
</select>
</div>
</div>
<div class="field">
<label class="field-label">脚本风格</label>
<div class="opt-row cols-4">
${STYLES.map(s => `<div class="opt-card${state.scriptStyle === s.id ? ' selected' : ''}" onclick="_wiz.setStyle('${s.id}')">
<h4>${esc(s.name)}</h4>
<div class="note">${esc(s.note)}</div>
${s.tag ? `<span class="badge">[ ${esc(s.tag)} ]</span>` : ''}
</div>`).join('')}
<label class="field-label">视频时长<span class="req">*</span></label>
<div class="opt-row duration-card-row">
${DURATIONS.map(d => `<button class="opt-card duration-card${state.duration === d.id ? ' selected' : ''}" type="button" data-duration="${esc(d.id)}" aria-pressed="${state.duration === d.id ? 'true' : 'false'}" onclick="_wiz.setDur('${esc(d.id)}')">
<span class="badge">[ ${esc(d.tag)} ]</span>
<span class="duration-title">${esc(d.label)}</span>
<span class="duration-meta">${d.shots[0]}-${d.shots[1]} 镜 · 9:16</span>
<span class="duration-note">适合${d.id === '0-10' ? '快速种草' : d.id === '0-15' ? '短平快投放' : d.id === '0-30' ? '卖点展开' : '故事化表达'}</span>
</button>`).join('')}
</div>
</div>
<div class="field">
<label class="field-label">人物设定</label>
<div class="opt-row cols-6">
${PERSONAS.map(p => `<div class="opt-card${state.persona === p.id ? ' selected' : ''}" onclick="_wiz.setPersona('${p.id}')">
<h4>${esc(p.name)}</h4>
<div class="sub">${esc(p.sub)}</div>
<div class="metric"><span class="val">${esc(p.metric)}</span></div>
</div>`).join('')}
</div>
${showReco ? `<div class="reco-bubble">
<span class="ic">${ICONS.bulb}</span>
<div class="txt">
<span>抖音同人设 TOP 视频更常用 <strong>${esc(recoDur.label)}</strong> + <strong>${esc(recoStyle.name)}</strong></span>
<span class="meta">当前 ${esc(durObj.label)} · ${esc(styleObj.name)} → 推荐换为同人设最优组合</span>
</div>
<button class="btn-apply" onclick="_wiz.applyPreset()">一键套用</button>
<button class="dismiss" onclick="_wiz.dismissReco()" aria-label="忽略">${ICONS.x}</button>
</div>` : ''}
</div>
${Object.keys(state.points).length > 0 ? `<div class="field" style="margin-bottom: 0;">
<label class="field-label">关键卖点(可勾选要重点突出的)</label>
<div class="theme-pill-row">
@ -1292,7 +1264,7 @@
}
function renderStep4() {
const p = getProduct(), s = getSource(), personaObj = getPersona(), durObj = getDuration(), styleObj = getStyle();
const p = getProduct(), s = getSource(), durObj = getDuration();
const c = getCost();
const ba = balanceAfter();
const low = ba < 5;
@ -1331,7 +1303,7 @@
<div class="cc-h"><span>// 项目配置</span><button class="cc-edit" onclick="_wiz.jumpTo(3)">修改</button></div>
<div class="cc-body">
<div style="font-weight:600; font-size:13px;">${esc(state.projectName)}</div>
<div class="ln"><b>${esc(styleObj.name)}</b> · ${esc(personaObj.name)} · ${esc(personaObj.sub)}</div>
<div class="ln"><b>${esc(durObj.label)}</b> · ${durObj.shots[0]}-${durObj.shots[1]} 镜 · 9:16</div>
<div class="ln" style="font-family: var(--font-mono); font-size: 11.5px; color: var(--black-alpha-48);">卖点:${esc(pointsList)}</div>
</div>
</div>
@ -1340,8 +1312,7 @@
<div class="cc-h"><span>// 输出参数</span></div>
<div class="cc-body">
<div class="ln"><b>${esc(durObj.label)}</b> · <b>${durObj.shots[0]}-${durObj.shots[1]} 镜</b> · 9:16</div>
<div class="ln">预估完播 <b>${durObj.completion}%</b> · 预估转化 <b>${durObj.conversion}%</b></div>
<div class="ln" style="font-family: var(--font-mono); font-size: 11.5px; color: var(--black-alpha-48);">// 数据来源:抖音同品类 TOP 均值</div>
<div class="ln" style="font-family: var(--font-mono); font-size: 11.5px; color: var(--black-alpha-48);">// 进入 Stage 1 后由脚本助手确认风格与人物设定</div>
</div>
</div>
</div>
@ -1516,8 +1487,8 @@
<div class="pv-title">${esc(title)}</div>
<div class="pv-metrics">
<div class="pv-metric"><div class="l">镜头</div><div class="v">${shots}<small></small></div></div>
<div class="pv-metric accent"><div class="l">预估完播</div><div class="v">${durObj.completion}<small>%</small></div></div>
<div class="pv-metric"><div class="l">预估转化</div><div class="v">${durObj.conversion}<small>%</small></div></div>
<div class="pv-metric accent"><div class="l">时长</div><div class="v">${esc(durObj.label)}</div></div>
<div class="pv-metric"><div class="l">比例</div><div class="v">9:16</div></div>
<div class="pv-metric"><div class="l">预估成本</div><div class="v">¥${c.total.toFixed(2)}</div></div>
</div>
${summaryBlock}
@ -1573,8 +1544,8 @@
renderRail();
const body = $('#wiz-body');
// 单页式: 商品 (step1) + 项目配置 (原 step3, 现 step2),底部「开始」CTA
const p = getProduct(), du = getDuration(), st = getStyle();
const canStart = !!(p && du && st && state.projectName.trim().length >= 2);
const p = getProduct(), du = getDuration();
const canStart = !!(p && du && state.projectName.trim().length >= 2);
let html = '';
html += '<section id="step-pane-1" class="step-pane-wrap">' + renderStep1() + '</section>';
html += '<section id="step-pane-2" class="step-pane-wrap">' + renderStep3() + '</section>';