feat: 同步 V9.3 生图工作流、用户意图解析和参考图一致性
This commit is contained in:
+706
-20
@@ -3,6 +3,27 @@ const esc = value => String(value ?? '').replace(/[&<>"']/g, char => ({'&':'&
|
||||
const arr = value => Array.isArray(value) ? value : [];
|
||||
const compact = value => arr(value).map(item => typeof item === 'object' ? (item.value || item.topic || item.name || item.label || item.action || item.insight || '') : item).filter(Boolean).join(' · ');
|
||||
const percent = value => `${(Number(value || 0) * 100).toFixed(1).replace('.0','')}%`;
|
||||
const modelLabel = item => item?.modelLabel || item?.model || '默认生图模型';
|
||||
const modelQuality = item => item?.quality || '服务商默认';
|
||||
const IMAGE_QUALITY_OPTIONS = ['auto','low','medium','high','xhigh','max'];
|
||||
const resultSortKey = item => {
|
||||
const type = item?.assetType === 'main' ? 0 : 1;
|
||||
const index = Number(item?.slotIndex || 0);
|
||||
return [type, index, String(item?.modelLabel || item?.model || ''), String(item?.url || '')];
|
||||
};
|
||||
const orderedResults = items => arr(items).slice().sort((a,b) => {
|
||||
const aa = resultSortKey(a), bb = resultSortKey(b);
|
||||
return aa[0] - bb[0] || aa[1] - bb[1] || aa[2].localeCompare(bb[2]);
|
||||
});
|
||||
async function readJsonResponse(response, endpoint='接口') {
|
||||
const body=await response.text();
|
||||
try { return JSON.parse(body); }
|
||||
catch (_) {
|
||||
const returnedHtml=/^\s*</.test(body);
|
||||
const detail=returnedHtml?'返回了网页内容':'返回内容不是有效 JSON';
|
||||
throw new Error(`${endpoint} ${detail}(HTTP ${response.status})。请关闭旧版服务,并从 http://127.0.0.1:17962 重新打开当前报告。`);
|
||||
}
|
||||
}
|
||||
let evidenceIndex = new Map();
|
||||
const EMPTY_COPY = /^(暂无|无|未采集|未评级|尚未结构化|图片证据未采集|评论原文未采集|日期未采集)$/;
|
||||
function hasContent(value) {
|
||||
@@ -37,6 +58,19 @@ const FIELD_LABELS = {
|
||||
fashionRisks:'视觉风险', designAssets:'设计资产', styleSignals:'风格信号', validation:'审核结果', scope:'审核范围',
|
||||
structuredStatus:'结构化状态', status:'状态', gate:'验证门', examples:'评论原话', mentions:'提及次数', sampleRate:'样本占比'
|
||||
};
|
||||
Object.assign(FIELD_LABELS,{
|
||||
all:'全部图片',batchFindings:'批次发现',buyerShow:'买家实拍',compareDimensions:'对比维度',composition:'构图信息',concern:'购前顾虑',concerns:'购前顾虑',
|
||||
consumerThesis:'消费者结论',conversionImpact:'转化影响',coreClaims:'核心卖点',coreStory:'核心表达链',decision:'决策',decisionGates:'决策门槛',decisionQuestion:'决策问题',deemphasize:'降低强调',
|
||||
detailStatus:'详情证明状态',driver:'驱动因素',evidenceId:'证据编号',executiveDecision:'经营决策',expectationGaps:'预期差异',expected:'用户预期',finding:'分析发现',fixAction:'修正动作',
|
||||
grade:'数据等级',hypothesis:'验证假设',ifOnlyOneAction:'唯一优先动作',imageEvidenceId:'图片证据编号',imageRole:'图片任务',imageRoles:'图片任务分工',images:'图片统计',
|
||||
isProductRiskOrInfoGap:'问题性质',item:'项目',keepAction:'保留动作',knownLimitations:'已知限制',mainStatus:'主图表达状态',mechanism:'作用机制',metrics:'验收指标',module:'分析模块',
|
||||
mustFix:'必须修正',oneSentence:'一句话结论',owner:'负责人',pageFix:'页面修正',painPoints:'用户痛点',patternColor:'花型与颜色',prePurchaseConcerns:'购前关注',prePurchaseThesis:'购前判断',
|
||||
primaryQuestions:'核心问题',priority:'优先级',problem:'问题',productOpportunity:'产品机会',productSubject:'商品主体',promotionAnalysis:'促销分析',purchaseDrivers:'购买驱动',question:'用户问题',
|
||||
questionConcern:'问答关注点',questionSample:'问答样本',reason:'原因',representativeQuotes:'代表评论',requiredCompetitorSet:'所需竞品样本',reviewReality:'评论实际反馈',reviewSample:'评论样本',
|
||||
reviewStatus:'评论验证状态',role:'作用',rows:'验证步骤',satisfactionDrivers:'满意驱动',sectionPlan:'章节计划',severity:'严重程度',skuSignals:'规格反馈',stage:'验证阶段',successMeaning:'通过标准',
|
||||
text:'原文',textInfo:'图中文字',titleAnalysis:'标题分析',unansweredQuestions:'未解决问题',validatedByReviews:'评论验证结果',valuePillars:'价值支柱',verbatimQuotes:'评论原话',verdict:'最终判断',
|
||||
visualSignals:'视觉信号',visualThesis:'视觉结论',whatUserIsBuying:'用户实际购买价值',whyItBlocksPurchase:'阻碍购买原因',whyItSells:'购买理由',main:'主图',detail:'详情图',sku:'规格结构'
|
||||
});
|
||||
|
||||
const ROLE_META = [
|
||||
['productStrategy','01','商品策略','卖什么、为什么值得参考、下一款继承什么'],
|
||||
@@ -67,8 +101,14 @@ function decisionCards(items) {
|
||||
}
|
||||
function planCards(items) {
|
||||
const tone = item => String(item?.sourceType || '').includes('探索') ? 'explore' : String(item?.sourceType || '').includes('反馈') ? 'upgrade' : 'evidence';
|
||||
const themeCopy = item => {
|
||||
const value=String(item?.sourceType||item?.type||'');
|
||||
if (value.includes('反馈')) return [['升级动作',item.productAction],['体验证明',item.pageAction]];
|
||||
if (value.includes('探索')) return [['探索假设',item.productAction],['验证表达',item.pageAction]];
|
||||
return [['证据动作',item.productAction],['证据表达',item.pageAction]];
|
||||
};
|
||||
const valid=arr(items).map((item,index)=>({item,index})).filter(({item})=>hasContent(item));
|
||||
return valid.length ? `<div class="plan-grid">${valid.map(({item,index}) => {const rows=[['产品',item.productAction],['页面',item.pageAction],['验证',item.validation]].filter(([,value])=>hasContent(value));return `<article class="plan-card plan-${tone(item)}">${hasContent(item.sourceType||item.type)?`<span>${esc(item.sourceType||item.type)}</span>`:''}${hasContent(item.name)?`<h3>${esc(item.name)}</h3>`:''}${hasContent(item.whyThisPlan)?`<p>${esc(item.whyThisPlan)}</p>`:''}${rows.length?`<dl>${rows.map(([label,value])=>`<div><dt>${label}</dt><dd>${esc(value)}</dd></div>`).join('')}</dl>`:''}<button class="generate-plan-btn" type="button" data-plan-index="${index}">立即生成 <span>→</span></button></article>`}).join('')}</div>` : '';
|
||||
return valid.length ? `<div class="plan-grid">${valid.map(({item,index}) => {const rows=themeCopy(item).filter(([,value])=>hasContent(value));return `<article class="plan-card plan-${tone(item)}">${hasContent(item.sourceType||item.type)?`<span>${esc(item.sourceType||item.type)}</span>`:''}${rows.length?`<dl>${rows.map(([label,value])=>`<div><dt>${label}</dt><dd>${esc(value)}</dd></div>`).join('')}</dl>`:''}<button class="generate-plan-btn" type="button" data-plan-index="${index}">按此方向开品 <span>→</span></button></article>`}).join('')}</div>` : '';
|
||||
}
|
||||
function titleAnalysis(block) {
|
||||
const expressed = compact(block.currentExpression);
|
||||
@@ -82,6 +122,18 @@ function mainImages(data, block) {
|
||||
return `${hasContent(block.professionalOpinion)?`<p class="lead-opinion">${esc(block.professionalOpinion)}</p>`:''}${items.length?`<div class="main-analysis-grid">${items.map(item=>`<article>${figure(entries.get(item.imageEvidenceId), item.imageRole || '商品主图')}<div class="image-copy"><span>主图分析</span>${hasContent(item.imageRole)?`<h3>${esc(item.imageRole)}</h3>`:''}${hasContent(item.visualSignals)?`<b>${esc(compact(item.visualSignals))}</b>`:''}${hasContent(item.businessMeaning)?`<p>${esc(item.businessMeaning)}</p>`:''}${hasContent(item.nextAction)?`<em>${esc(item.nextAction)}</em>`:''}</div></article>`).join('')}</div>`:''}`;
|
||||
}
|
||||
function classifyDetailGroup(group) {
|
||||
const keyed = {
|
||||
scene_problem: [10, '场景证明', '先回答为什么需要'],
|
||||
material_proof: [20, '材质证明', '证明产品基础'],
|
||||
structure_proof: [30, '结构工艺', '证明结构如何实现'],
|
||||
function_proof: [40, '功能表现', '证明使用表现'],
|
||||
spec_adaptation: [50, '规格适配', '降低选择门槛'],
|
||||
care_durability: [60, '使用维护', '说明洗护与长期使用']
|
||||
};
|
||||
if (keyed[group?.contentKey]) {
|
||||
const [sequencePriority, sequenceLabel, sequenceHint] = keyed[group.contentKey];
|
||||
return {...group, sequencePriority, sequenceLabel, sequenceHint};
|
||||
}
|
||||
const source = `${group?.type || ''} ${group?.stage || ''}`;
|
||||
const rules = [
|
||||
[/场景|人群|体验|需求/,10,'使用场景','先回答为什么需要'],
|
||||
@@ -90,7 +142,7 @@ function classifyDetailGroup(group) {
|
||||
[/工艺|细节|结构|做工/,30,'工艺细节','证明品质如何实现'],
|
||||
[/耐用|性能|功能|洗涤|防护|透气/,40,'性能验证','证明长期使用表现'],
|
||||
[/规格|尺寸|SKU|选择|适配/,50,'规格选择','降低选择门槛'],
|
||||
[/品牌|服务|质检|证书|信任/,60,'信任背书','最后消除决策顾虑']
|
||||
[/洗护|护理|清洁|耐用/,60,'使用维护','说明洗护与长期使用']
|
||||
];
|
||||
const found = rules.find(([pattern])=>pattern.test(source));
|
||||
return found ? {...group,sequencePriority:found[1],sequenceLabel:found[2],sequenceHint:found[3]} : {...group,sequencePriority:70,sequenceLabel:'补充证据',sequenceHint:'补充未覆盖的信息'};
|
||||
@@ -125,14 +177,14 @@ function buyerGallery(data, block) {
|
||||
const grouped = new Map();
|
||||
items.forEach(item=>{
|
||||
const key = item.reviewEvidenceId || item.imageEvidenceId;
|
||||
if (!grouped.has(key)) grouped.set(key,{reviewId:item.reviewEvidenceId,quote:item.quote,date:item.date,sku:item.sku,images:[]});
|
||||
if (!grouped.has(key)) grouped.set(key,{reviewId:item.reviewEvidenceId,quote:item.quote,date:item.date,sku:item.sku,scenario:item.scenario || item.context,insight:item.insight || item.conclusion || item.businessMeaning,images:[]});
|
||||
grouped.get(key).images.push(byId.get(item.imageEvidenceId));
|
||||
});
|
||||
if (!grouped.size) {
|
||||
const loose = imageEntries(data,'buyerShow');
|
||||
return loose.length ? `<div class="buyer-story"><div class="buyer-photo-grid">${loose.map(item=>figure(item,item.id)).join('')}</div><p>买家秀已采集,尚未关联到具体评论。</p></div>` : '';
|
||||
}
|
||||
return [...grouped.values()].filter(group=>group.images.some(Boolean)||hasContent(group.quote)||hasContent(group.sku)).map(group=>`<article class="buyer-story"><div class="buyer-story-head">${hasContent(group.reviewId)?`<span>${esc(group.reviewId)}</span>`:''}<small>${group.images.filter(Boolean).length} 张实拍图${hasContent(group.date)?` · ${esc(group.date)}`:''}</small></div>${group.images.some(Boolean)?`<div class="buyer-photo-grid">${group.images.filter(Boolean).map(item=>figure(item,'')).join('')}</div>`:''}${hasContent(group.quote)?`<blockquote>“${esc(group.quote)}”</blockquote>`:''}${hasContent(group.sku)?`<p>${esc(group.sku)}</p>`:''}</article>`).join('');
|
||||
return [...grouped.values()].filter(group=>group.images.some(Boolean)||hasContent(group.quote)||hasContent(group.sku)).map((group,index)=>`<article class="buyer-story"><div class="buyer-story-head"><span>案例 ${String(index+1).padStart(2,'0')}${hasContent(group.reviewId)?` · ${esc(group.reviewId)}`:''}</span><small>${group.images.filter(Boolean).length} 张实拍图${hasContent(group.date)?` · ${esc(group.date)}`:''}</small></div>${group.images.some(Boolean)?`<div class="buyer-photo-grid">${group.images.filter(Boolean).map(item=>figure(item,'')).join('')}</div>`:''}${hasContent(group.scenario)?`<p class="case-scenario"><b>使用场景</b>${esc(group.scenario)}</p>`:''}${hasContent(group.quote)?`<blockquote>“${esc(group.quote)}”</blockquote>`:''}${hasContent(group.insight)?`<p class="case-insight"><b>案例结论</b>${esc(group.insight)}</p>`:''}${hasContent(group.sku)?`<p><b>SKU</b> ${esc(group.sku)}</p>`:''}</article>`).join('');
|
||||
}
|
||||
function reviews(data, block) {
|
||||
const base = data.baseline || {};
|
||||
@@ -160,7 +212,13 @@ function reviews(data, block) {
|
||||
const ROLE_DENSE_FIELDS = new Set(['baseline','buyerEvidence','signals','stages','evidenceDetail']);
|
||||
function displayValue(value) {
|
||||
if (typeof value === 'boolean') return value ? '是' : '否';
|
||||
const status = {passed:'已通过',confirmed:'已确认',high:'高风险',medium:'中风险',low:'低风险',consumer_supported:'评论支持',conflicted:'存在冲突',page_claim_only:'仅页面宣称',user_stopped:'用户主动停止'};
|
||||
const status = {
|
||||
passed:'已通过',confirmed:'已确认',missing:'缺失',partial:'部分满足',unknown:'待确认',high:'高风险',medium:'中风险',low:'低风险',
|
||||
consumer_supported:'评论支持',conflicted:'存在冲突',page_claim_only:'仅页面宣称',not_enough_evidence:'证据不足',user_stopped:'用户主动停止',
|
||||
expressed:'主图已表达',proven:'详情已证明',validated:'评论已验证',main_missing:'主图未表达',detail_missing:'详情未证明',review_pending:'等待评论验证',closed_loop:'证据闭环',conflict:'存在冲突',
|
||||
product_risk:'产品风险',information_gap:'信息缺口',purchase_driver:'购买驱动',satisfaction:'满意因素',pain:'用户痛点',expectation_gap:'预期差异',prepurchase_concern:'购前顾虑',sku_signal:'规格反馈',usage_scene:'使用场景',
|
||||
complete:'采集完成',limited:'样本有限',collected:'已采集',ready:'已就绪',normal:'一般',good:'良好',risk:'风险',true:'是',false:'否'
|
||||
};
|
||||
return status[value] || value;
|
||||
}
|
||||
function itemTitle(item) {
|
||||
@@ -201,7 +259,7 @@ function renderStructured(value, depth=0) {
|
||||
const valid=value.filter(hasContent);
|
||||
if (!valid.length) return '';
|
||||
if (valid.every(item=>typeof item !== 'object')) return `<div class="role-chip-list">${valid.map(item=>`<span>${esc(displayValue(item))}</span>`).join('')}</div>`;
|
||||
return `<div class="structured-list">${valid.map(item=>{const title=itemTitle(item);const inner=renderStructured(Object.fromEntries(Object.entries(item || {}).filter(([key])=>!title || !['headline','name','type','asset','signal','claim','risk','gate','topic','field'].includes(key))),depth+1);return title||inner?`<article class="structured-card">${title?`<header><span>${esc(title)}</span></header>`:''}${inner}</article>`:''}).join('')}</div>`;
|
||||
return `<div class="structured-list">${valid.map(item=>{const title=itemTitle(item);const inner=renderStructured(Object.fromEntries(Object.entries(item || {}).filter(([key])=>!title || !['headline','name','type','asset','signal','claim','risk','gate','topic','field'].includes(key))),depth+1);return title||inner?`<article class="structured-card">${title?`<header><span>${esc(displayValue(title))}</span></header>`:''}${inner}</article>`:''}).join('')}</div>`;
|
||||
}
|
||||
return `<div class="structured-object">${Object.entries(value).map(([key,item])=>{
|
||||
if (!hasContent(item)) return '';
|
||||
@@ -276,26 +334,616 @@ function initHeroCarousel() {
|
||||
window.addEventListener('resize',()=>show(current),{passive:true});
|
||||
show(0); start();
|
||||
}
|
||||
function initPlanActions(plans) {
|
||||
function initPlanActions(plans, context={}) {
|
||||
const dialog = document.querySelector('#quick-plan-dialog');
|
||||
if (!dialog) return;
|
||||
const title = dialog.querySelector('[data-plan-title]'), reason = dialog.querySelector('[data-plan-reason]');
|
||||
const product = dialog.querySelector('[data-plan-product]'), page = dialog.querySelector('[data-plan-page]'), validation = dialog.querySelector('[data-plan-validation]');
|
||||
let currentText = '';
|
||||
const generateButton=dialog.querySelector('[data-generate-images]');
|
||||
const generateStatus=dialog.querySelector('[data-generate-status]');
|
||||
const generateResults=dialog.querySelector('[data-generate-results]');
|
||||
const generationControls=dialog.querySelector('[data-generation-controls]');
|
||||
const userDirectionInput=dialog.querySelector('[data-user-direction]');
|
||||
const fissionWrap=dialog.querySelector('[data-fission-wrap]');
|
||||
const fissionInput=dialog.querySelector('[data-fission-pattern]');
|
||||
const matchReferenceWrap=dialog.querySelector('[data-match-reference-wrap]');
|
||||
const matchReferenceInput=dialog.querySelector('[data-match-reference-shooting]');
|
||||
const matchReferenceNote=dialog.querySelector('[data-match-reference-note]');
|
||||
const referenceInput=dialog.querySelector('[data-reference-input]');
|
||||
const referenceList=dialog.querySelector('[data-reference-list]');
|
||||
const referenceHint=dialog.querySelector('[data-reference-hint]');
|
||||
let uploadedReferences=[];
|
||||
if (generateButton) { generateButton.textContent='正在计算生成数量…'; generateButton.disabled=true; }
|
||||
let activePlanIndex=0, previewSlots=[], selectedSlotKeys=new Set(), promptOverrides={};
|
||||
let retainedResults=[], generatedTaskSlots=[];
|
||||
let imageModelOptions=[], selectedImageModels=new Set(), selectedImageQualities=new Map(), imageModelPromise=null;
|
||||
let submittedModelSpecs=[];
|
||||
const slotKey=slot=>`${slot.assetType}:${slot.index}`;
|
||||
const mergeResults=(older,newer)=>{
|
||||
const resultKey=item=>`${item.modelKey||item.model||'default'}:${item.assetType}:${item.slotIndex}`;
|
||||
const merged=new Map(arr(older).map(item=>[resultKey(item),item]));
|
||||
arr(newer).forEach(item=>merged.set(resultKey(item),item));
|
||||
return orderedResults([...merged.values()]);
|
||||
};
|
||||
const selectedTypes=()=>[...new Set(previewSlots.filter(slot=>selectedSlotKeys.has(slotKey(slot))).map(slot=>slot.assetType))];
|
||||
const generationCounts=()=>({
|
||||
main:previewSlots.filter(x=>x.assetType==='main'&&selectedSlotKeys.has(slotKey(x))).length,
|
||||
detail:previewSlots.filter(x=>x.assetType==='detail'&&selectedSlotKeys.has(slotKey(x))).length
|
||||
});
|
||||
const countText=counts=>`${counts.main} 张主图 + ${counts.detail} 张详情图`;
|
||||
const selectedModelOptions=()=>imageModelOptions
|
||||
.filter(item=>selectedImageModels.has(item.id))
|
||||
.map(item=>({...item,quality:selectedImageQualities.get(item.id) ?? item.quality ?? ''}));
|
||||
const comparisonMode=()=>selectedImageModels.size>1;
|
||||
const modelKey=item=>item?.modelKey||item?.model||'default';
|
||||
const imageModelControls=()=>dialog.querySelector('[data-image-model-controls]');
|
||||
const generationRunSummary=()=>dialog.querySelector('[data-generation-run-summary]');
|
||||
const renderGenerationRunSummary=(status='queued',specs=submittedModelSpecs)=>{
|
||||
const summary=generationRunSummary();
|
||||
if (!summary) return;
|
||||
const items=arr(specs);
|
||||
if (!items.length) {
|
||||
summary.hidden=true;
|
||||
summary.innerHTML='';
|
||||
return;
|
||||
}
|
||||
const stateLabel=status==='complete'?'已完成':status==='error'?'生成失败':status==='running'?'生成中':'已提交';
|
||||
summary.hidden=false;
|
||||
summary.innerHTML=`<b>本次生成参数 · ${stateLabel}</b>${items.map(item=>`<span><strong>${esc(item.label||item.id||'默认生图模型')}</strong><small>模型:${esc(item.id||'默认')} · 请求质量:${esc(modelQuality(item))}${item.quality?'':'(实际档位由服务商决定)'}</small></span>`).join('')}`;
|
||||
};
|
||||
const renderImageModelControls=()=>{
|
||||
const controls=imageModelControls();
|
||||
if (!controls) return;
|
||||
if (!imageModelOptions.length) {
|
||||
controls.innerHTML='<span class="image-model-loading">未读取到模型列表,请先在本地配置页填写生图测试模型。</span>';
|
||||
return;
|
||||
}
|
||||
controls.innerHTML=`<b>模型对比</b><span class="image-model-hint">同一图片任务会按模型并排生成,最多建议选择 3 个;模型对比建议统一使用 medium。</span>${imageModelOptions.map(item=>`<label class="image-model-option"><input type="checkbox" data-image-model="${esc(item.id)}" ${selectedImageModels.has(item.id)?'checked':''}><span class="image-model-option-name"><b>${esc(item.label||item.id)}</b><small>${esc(item.id)}</small></span><select data-image-quality-for="${esc(item.id)}" aria-label="${esc(`${item.label||item.id} 图片质量`)}">${['',...IMAGE_QUALITY_OPTIONS].map(value=>`<option value="${esc(value)}" ${((selectedImageQualities.get(item.id) ?? item.quality ?? '')===value)?'selected':''}>${esc(value||'服务商默认')}</option>`).join('')}</select></label>`).join('')}`;
|
||||
};
|
||||
const loadImageModels=()=>{
|
||||
if (imageModelPromise) return imageModelPromise;
|
||||
imageModelPromise=fetch('/health',{cache:'no-store'}).then(response=>readJsonResponse(response,'/health')).then(payload=>{
|
||||
imageModelOptions=arr(payload.imageModels).map(item=>typeof item==='string'?{id:item,label:item}:item).filter(item=>item?.id);
|
||||
imageModelOptions.forEach(item=>{
|
||||
if (!selectedImageQualities.has(item.id)) selectedImageQualities.set(item.id,item.quality||'');
|
||||
});
|
||||
if (!imageModelOptions.length&&payload.imageModel) imageModelOptions=[{id:payload.imageModel,label:payload.imageModel}];
|
||||
if (!selectedImageModels.size&&imageModelOptions[0]) selectedImageModels.add(imageModelOptions[0].id);
|
||||
selectedImageModels=new Set([...selectedImageModels].filter(id=>imageModelOptions.some(item=>item.id===id)));
|
||||
if (!selectedImageModels.size&&imageModelOptions[0]) selectedImageModels.add(imageModelOptions[0].id);
|
||||
renderImageModelControls();
|
||||
syncFissionControl();
|
||||
refreshGenerationConfirmation();
|
||||
}).catch(()=>{
|
||||
imageModelOptions=[{id:'',label:'默认生图模型'}];
|
||||
selectedImageQualities.set('','');
|
||||
selectedImageModels=new Set(['']);
|
||||
renderImageModelControls();
|
||||
refreshGenerationConfirmation();
|
||||
});
|
||||
return imageModelPromise;
|
||||
};
|
||||
const userDirection=()=>String(userDirectionInput?.value||'').trim();
|
||||
const fissionEnabled=()=>!!fissionInput?.checked&&!uploadedReferences.length&&!matchReferenceShooting()&&!comparisonMode();
|
||||
const matchReferenceShooting=()=>!!matchReferenceInput?.checked;
|
||||
const matchReferenceDescription=()=>matchReferenceShooting()
|
||||
? '已开启:第1张上传产品图只锁定商品本身;第2张采集参考图只提供展示状态;视角、动作、朝向、展开/折叠、摆放、支撑和部件关系跟随采集商品对应图片;不复制采集图中的其他商品、颜色、花型、品牌或文字。'
|
||||
: '未开启:产品图只用于商品身份,采集图的拍摄方式、具体动作和摆放状态不继承;按每张图任务重新设计。';
|
||||
const syncMatchReferenceControl=plan=>{
|
||||
if (!matchReferenceInput) return;
|
||||
const matchReferenceLabel=matchReferenceWrap?.querySelector('b');
|
||||
if (matchReferenceLabel) {
|
||||
if (matchReferenceShooting()) matchReferenceLabel.textContent='产品展示状态跟随采集商品对应图片';
|
||||
else matchReferenceLabel.textContent='产品图身份 + 采集参考图展示状态';
|
||||
}
|
||||
const referenceHeaderLabel=dialog.querySelector('.reference-upload header b');
|
||||
if (referenceHeaderLabel) referenceHeaderLabel.textContent=uploadedReferences.length?'产品图(商品身份)':'采集参考图(默认身份参考)';
|
||||
const fallback=fallbackReferences(plan||{});
|
||||
const hasReference=uploadedReferences.length>0&&fallback.length>0;
|
||||
matchReferenceInput.disabled=!hasReference;
|
||||
if (!hasReference) matchReferenceInput.checked=false;
|
||||
if (matchReferenceNote) matchReferenceNote.textContent=hasReference?matchReferenceDescription():'请同时准备产品图和采集商品对应参考图,才能启用展示状态一致。';
|
||||
if (matchReferenceWrap) matchReferenceWrap.title=hasReference?matchReferenceDescription():'需要产品图和采集参考图';
|
||||
};
|
||||
const ensureFissionBaseSelected=()=>{
|
||||
if (!fissionEnabled()) return;
|
||||
const hasAnySelection=previewSlots.some(slot=>selectedSlotKeys.has(slotKey(slot)));
|
||||
const base=previewSlots.find(slot=>slot.assetType==='main'&&slot.index===1);
|
||||
if (hasAnySelection&&base) selectedSlotKeys.add(slotKey(base));
|
||||
};
|
||||
const syncFissionControl=()=>{
|
||||
if (!fissionWrap||!fissionInput) return;
|
||||
const lockedByReferenceMatch=matchReferenceShooting();
|
||||
const lockedByComparison=comparisonMode();
|
||||
fissionWrap.hidden=!!uploadedReferences.length||lockedByReferenceMatch;
|
||||
fissionInput.disabled=!!uploadedReferences.length||lockedByReferenceMatch||lockedByComparison;
|
||||
if (uploadedReferences.length||lockedByReferenceMatch||lockedByComparison) fissionInput.checked=false;
|
||||
if (lockedByComparison&&fissionWrap) fissionWrap.title='多模型对比使用同一参考图,暂不启用裂变基准。';
|
||||
};
|
||||
const directionNote=direction=>{
|
||||
if (!direction?.raw) return '';
|
||||
const accepted=arr(direction.accepted), rejected=arr(direction.rejected);
|
||||
const ok=accepted.length?`已采纳:${accepted.join(' / ')}`:'未采纳可执行补充';
|
||||
const no=rejected.length?`;已忽略:${rejected.join(' / ')}`:'';
|
||||
return `${ok}${no}`;
|
||||
};
|
||||
const syncTypeSwitches=()=>{
|
||||
if (!generationControls) return;
|
||||
generationControls.querySelectorAll('[data-select-type]').forEach(input=>{
|
||||
const slots=previewSlots.filter(slot=>slot.assetType===input.dataset.selectType);
|
||||
const selected=slots.filter(slot=>selectedSlotKeys.has(slotKey(slot))).length;
|
||||
input.checked=!!slots.length&&selected===slots.length;
|
||||
input.indeterminate=selected>0&&selected<slots.length;
|
||||
input.disabled=!slots.length;
|
||||
const count=input.closest('label')?.querySelector('span');
|
||||
if (count) count.textContent=`${selected}/${slots.length}`;
|
||||
});
|
||||
};
|
||||
const fallbackReferences=plan=>{
|
||||
const firstMain=[...evidenceIndex.values()].find(item=>item?.type==='image'&&item?.meta?.group==='main');
|
||||
return [firstMain].filter(item=>Boolean(item&&imageUrl(item)));
|
||||
};
|
||||
const renderReferences=plan=>{
|
||||
const fallback=fallbackReferences(plan), urls=uploadedReferences.length?uploadedReferences:fallback.map(imageUrl).filter(Boolean);
|
||||
if (referenceList) {
|
||||
const uploadRoot=referenceList.closest('.reference-upload');
|
||||
uploadRoot?.classList.toggle('has-reference', urls.length>0);
|
||||
referenceList.innerHTML=urls.map((url,index)=>`<figure><img src="${esc(url)}" alt="${uploadedReferences.length?'产品图':'采集参考图'} ${index+1}">${uploadedReferences.length?`<button type="button" data-remove-reference="${index}" aria-label="删除产品图">×</button>`:''}</figure>`).join('');
|
||||
}
|
||||
syncFissionControl();
|
||||
syncMatchReferenceControl(plan);
|
||||
if (referenceHint) referenceHint.textContent=uploadedReferences.length?`已上传 ${uploadedReferences.length} 张产品图;第一张是整套图片唯一商品身份基准,其他上传图不会混入生图请求。开启展示状态一致后,采集商品对应图片只负责视角和摆放。`:(fallback.length?(fissionEnabled()?'默认先裂变生成相近花型基准图,后续主图/详情围绕该基准图生成。':'当前将直接参考采集商品主图生成。'):'当前没有商品主图,请上传产品图后生成。');
|
||||
};
|
||||
const taskName=slot=>slot.role||`${slot.assetType==='detail'?'详情图':'主图'}任务 ${slot.index||''}`;
|
||||
const taskCard=(slot,result,status='queued')=>{
|
||||
const label=status==='complete'?'已完成':status==='generating'?'生成中':status==='error'?'生成失败':'等待生成';
|
||||
const key=slotKey(slot), hasUserEdit=Object.prototype.hasOwnProperty.call(promptOverrides,key);
|
||||
const promptText=hasUserEdit?promptOverrides[key]:'';
|
||||
const mergeMode=slot.promptMerge?.mode;
|
||||
const globalDirection=slot.userDirection||{};
|
||||
const hasGlobalAccepted=arr(globalDirection.accepted).length>0;
|
||||
const hasGlobalIgnored=arr(globalDirection.ignored).length>0;
|
||||
const hasGlobalRejected=arr(globalDirection.rejected).length>0;
|
||||
const mergeNote=mergeMode==='applied'?'本图已关联并优先融合本图编辑':
|
||||
hasGlobalAccepted?'本图已关联并优先融合全局用户要求':
|
||||
mergeMode==='ignored_unrelated'||hasGlobalIgnored||hasGlobalRejected?'本图未关联或存在不可执行要求,继续执行内置策划':
|
||||
'本图执行内置策划';
|
||||
const planContext=compact([slot.planProductAction,slot.planPageAction]);
|
||||
const planContextHtml=planContext?`<p class="generation-plan-context"><b>${esc(slot.planName||'当前开品方案')}</b> · ${esc(planContext)}</p>`:'';
|
||||
const media=result?.url?`<button class="generation-preview-btn" type="button" data-preview-url="${esc(result.url)}" data-preview-title="${esc(taskName(slot))}"><img loading="lazy" src="${esc(result.url)}" alt="${esc(taskName(slot))}"></button>`:`<div class="generation-task-placeholder"><span>${slot.assetType==='detail'?'DETAIL':'MAIN'}</span><b>${String(slot.index||1).padStart(2,'0')}</b></div>`;
|
||||
const referencePreview=slot.displayReferenceUrl||slot.identityReferenceUrl;
|
||||
const referenceLabel=slot.displayReferenceUrl?'展示状态参考图':'产品身份参考图';
|
||||
const referenceThumb=referencePreview?`<div class="generation-reference-preview"><img loading="lazy" src="${esc(referencePreview)}" alt="${esc(referenceLabel)}"><span>${esc(referenceLabel)}</span></div>`:'';
|
||||
const resultSpec=result?`<small class="generation-task-spec">${esc(modelLabel(result))} · 质量 ${esc(modelQuality(result))}</small>`:'';
|
||||
const visibleResultSpec=result?`<div class="generation-task-result-meta"><b>本次实际请求</b>${resultSpec}</div>`:'';
|
||||
const choose=status==='queued'?`<label class="generation-slot-select"><input type="checkbox" data-select-slot="${esc(key)}" ${selectedSlotKeys.has(key)?'checked':''}><span>生成此图</span></label>`:'';
|
||||
return `<article class="generation-task-card generation-task-${status} ${selectedSlotKeys.has(key)?'is-selected':''}" data-task-card="${esc(key)}"><div class="generation-task-media">${media}<i>${esc(label)}</i><div class="generation-task-overlay"><small>${slot.assetType==='detail'?'详情图':'主图'} ${String(slot.index||1).padStart(2,'0')}</small><h4>${esc(taskName(slot))}</h4>${planContextHtml}${resultSpec}${arr(slot.task).length?`<p>${arr(slot.task).map(esc).join(' · ')}</p>`:''}<p class="generation-prompt-status">${esc(mergeNote)}</p>${choose}<details class="task-prompt"><summary>编辑本张提示词</summary><em>商品材质、颜色、结构等修改会同步整套图片;视觉要求仅在关联本图时融合。</em><textarea data-prompt-slot="${esc(key)}" placeholder="例如:改成天丝棉材质;或背景留白更大、改为面料微距特写">${esc(promptText)}</textarea><details class="task-final-prompt"><summary>查看最终提示词</summary><pre>${esc(slot.prompt||'')}</pre></details></details></div></div>${referenceThumb}${visibleResultSpec}</article>`;
|
||||
};
|
||||
const openPreview=button=>{
|
||||
const buttons=[...generateResults.querySelectorAll('[data-preview-url]')];
|
||||
openImagePreview(buttons.map(node=>({url:node.dataset.previewUrl,title:node.dataset.previewTitle||'生成图片'})),buttons.indexOf(button));
|
||||
};
|
||||
const comparisonCell=(slot,result,status,model)=>{
|
||||
const media=result?.url
|
||||
? `<button class="generation-preview-btn" type="button" data-preview-url="${esc(result.url)}" data-preview-title="${esc(`${modelLabel(model)} · ${taskName(slot)}`)}"><img loading="lazy" src="${esc(result.url)}" alt="${esc(`${modelLabel(model)} · ${taskName(slot)}`)}"></button>`
|
||||
: `<div class="generation-task-placeholder"><span>${status==='error'?'FAILED':status==='generating'?'RUNNING':slot.assetType==='detail'?'DETAIL':'MAIN'}</span><b>${String(slot.index||1).padStart(2,'0')}</b></div>`;
|
||||
const error=result?.error||'';
|
||||
return `<article class="generation-comparison-cell generation-task-${status}"><header><b>${esc(modelLabel(model))}<small>${esc(modelQuality(model))}</small></b><i>${status==='complete'?'已完成':status==='generating'?'生成中':status==='error'?'失败':'等待生成'}</i></header><div class="generation-comparison-media">${media}</div>${error?`<p class="generation-error">${esc(error)}</p>`:''}</article>`;
|
||||
};
|
||||
const renderComparisonBoard=(slots,results=[],jobStatus='queued',failedSlots=[],models=selectedModelOptions())=>{
|
||||
const modelList=models.length?models:imageModelOptions;
|
||||
const resultKey=(model,slot)=>`${modelKey(model)}:${slot.assetType}:${slot.index}`;
|
||||
const resultMap=new Map(arr(results).map(item=>[`${modelKey(item)}:${item.assetType}:${item.slotIndex}`,item]));
|
||||
const failedMap=new Map(arr(failedSlots).map(item=>[`${modelKey(item)}:${item.assetType}:${item.slotIndex}`,item]));
|
||||
const row=(type,title)=>{
|
||||
const items=arr(slots).filter(slot=>slot.assetType===type);
|
||||
if (!items.length) return '';
|
||||
const completed=items.reduce((sum,slot)=>sum+modelList.filter(model=>resultMap.has(resultKey(model,slot))).length,0);
|
||||
const modelHeaders=modelList.map(model=>`<div class="generation-comparison-model"><b>${esc(modelLabel(model))}</b><small>${esc(modelQuality(model))}</small></div>`).join('');
|
||||
const taskRows=items.map(slot=>{
|
||||
const slotKeyValue=slotKey(slot);
|
||||
const choose=jobStatus==='queued'
|
||||
? `<label class="generation-slot-select"><input type="checkbox" data-select-slot="${esc(slotKeyValue)}" ${selectedSlotKeys.has(slotKeyValue)?'checked':''}><span>生成此图</span></label>`
|
||||
: '';
|
||||
const ref=slot.displayReferenceUrl||slot.identityReferenceUrl;
|
||||
const refLabel=slot.displayReferenceUrl?'展示状态参考图':'产品身份参考图';
|
||||
const refThumb=ref?`<div class="generation-reference-preview"><img loading="lazy" src="${esc(ref)}" alt="${esc(refLabel)}"><span>${esc(refLabel)}</span></div>`:'';
|
||||
const planContext=compact([slot.planProductAction,slot.planPageAction]);
|
||||
const planContextHtml=planContext?`<span class="generation-plan-context"><b>${esc(slot.planName||'当前开品方案')}</b> · ${esc(planContext)}</span>`:'';
|
||||
const taskCell=`<div class="generation-comparison-task"><small>${slot.assetType==='detail'?'详情图':'主图'} ${String(slot.index||1).padStart(2,'0')}</small><strong>${esc(taskName(slot))}</strong>${planContextHtml}${refThumb}${choose}</div>`;
|
||||
const modelCells=modelList.map(model=>{
|
||||
const key=resultKey(model,slot);
|
||||
const result=resultMap.get(key);
|
||||
const failure=failedMap.get(key);
|
||||
let status=result?'complete':'queued';
|
||||
if (!result&&jobStatus==='running') status='generating';
|
||||
if (!result&&(jobStatus==='error'||failure)) status='error';
|
||||
return comparisonCell(slot,result||failure,status,model);
|
||||
}).join('');
|
||||
return taskCell+modelCells;
|
||||
}).join('');
|
||||
return `<section class="generation-comparison-row"><header><div><span>${type==='main'?'MAIN IMAGE TASKS':'DETAIL IMAGE TASKS'}</span><h3>${title}</h3></div><b>${completed} / ${items.length*modelList.length}</b></header><div class="generation-comparison-table" style="--comparison-columns:${Math.max(1,modelList.length)}"><div class="generation-comparison-corner">图片任务</div>${modelHeaders}${taskRows}</div></section>`;
|
||||
};
|
||||
generateResults.innerHTML=row('main','主图模型对比')+row('detail','详情图模型对比');
|
||||
};
|
||||
const renderTaskBoard=(slots,results=[],jobStatus='queued',failedSlots=[])=>{
|
||||
if (!generateResults) return;
|
||||
if (selectedImageModels.size>1) {
|
||||
renderComparisonBoard(slots,results,jobStatus,failedSlots,selectedModelOptions());
|
||||
return;
|
||||
}
|
||||
const selected=arr(slots);
|
||||
const resultMap=new Map(arr(results).map(item=>[`${item.assetType}:${item.slotIndex}`,item]));
|
||||
const failedKeys=new Set(arr(failedSlots).map(item=>`${item.assetType}:${item.slotIndex}`));
|
||||
const row=(type,title)=>{
|
||||
const items=selected.filter(slot=>slot.assetType===type);
|
||||
if (!items.length) return '';
|
||||
const heading=type==='detail'?`${title}(${items.length})`:`${title}(${items.length})`;
|
||||
const right=type==='detail'?`<span class="generation-task-hint">左右滑动查看全部 ${items.length} 张</span>`:`<b>${items.filter(slot=>resultMap.has(`${slot.assetType}:${slot.index}`)).length} / ${items.length}</b>`;
|
||||
return `<section class="generation-task-row generation-task-row-${type}"><header><div><span>${type==='main'?'MAIN IMAGE TASKS':'DETAIL IMAGE TASKS'}</span><h3>${heading}</h3></div>${right}</header><div class="generation-task-track" style="--task-count:${items.length}">${items.map(slot=>{const key=`${slot.assetType}:${slot.index}`;const result=resultMap.get(key);let status=result?'complete':'queued';if(!result&&jobStatus==='running')status='generating';if(!result&&(jobStatus==='error'||failedKeys.has(key)))status='error';return taskCard(slot,result,status)}).join('')}</div></section>`;
|
||||
};
|
||||
generateResults.innerHTML=row('main','主图生成任务')+row('detail','详情图生成任务');
|
||||
};
|
||||
const refreshGenerationConfirmation=()=>{
|
||||
ensureFissionBaseSelected();
|
||||
const counts=generationCounts(), total=counts.main+counts.detail;
|
||||
const modelCount=Math.max(1,selectedImageModels.size);
|
||||
const requestTotal=total*modelCount;
|
||||
// Keep the count calculation in one place; the single-model label follows the modal spec.
|
||||
// 兼容旧版校验:确认生成:${countText(counts)}
|
||||
if (generateButton) { generateButton.disabled=!total||!selectedImageModels.size; generateButton.textContent=requestTotal?(modelCount>1?`${modelCount} 个模型 × ${countText(counts)} = ${requestTotal} 张`:`确认生成:${countText(counts)}`):'请选择要生成的图片类型'; }
|
||||
if (generateStatus && previewSlots.length) generateStatus.textContent=requestTotal?`本次计划生成 ${countText(counts)};${modelCount>1?`将按 ${modelCount} 个模型并排对比,共 `:''} ${requestTotal} 张。${fissionEnabled()&&counts.main?'裂变模式已计入 main:1 基准图。':''}${modelCount>1?'多模型对比使用同一参考图和同一份提示词。':''}点击按钮后还需确认。`:'请至少选择一种图片类型和一个生图模型。';
|
||||
syncTypeSwitches();
|
||||
if (previewSlots.length) renderTaskBoard(previewSlots);
|
||||
};
|
||||
dialog.addEventListener('change',event=>{
|
||||
const input=event.target.closest('[data-image-model]');
|
||||
const qualityInput=event.target.closest('[data-image-quality-for]');
|
||||
if (qualityInput) {
|
||||
selectedImageQualities.set(qualityInput.dataset.imageQualityFor,qualityInput.value);
|
||||
refreshGenerationConfirmation();
|
||||
return;
|
||||
}
|
||||
if (!input) return;
|
||||
if (input.checked) selectedImageModels.add(input.dataset.imageModel);
|
||||
else selectedImageModels.delete(input.dataset.imageModel);
|
||||
if (!selectedImageModels.size) input.checked=true,selectedImageModels.add(input.dataset.imageModel);
|
||||
syncFissionControl();
|
||||
refreshGenerationConfirmation();
|
||||
});
|
||||
generateResults?.addEventListener('click',event=>{
|
||||
const button=event.target.closest('[data-preview-url]');if(button){openPreview(button);return;}
|
||||
if (event.target.closest('input,textarea,summary,details,label,button')) return;
|
||||
const card=event.target.closest('[data-task-card]');
|
||||
if (!card||!card.classList.contains('generation-task-queued')) return;
|
||||
if (selectedSlotKeys.has(card.dataset.taskCard)) selectedSlotKeys.delete(card.dataset.taskCard);
|
||||
else selectedSlotKeys.add(card.dataset.taskCard);
|
||||
refreshGenerationConfirmation();
|
||||
});
|
||||
const refreshPromptPreview=()=>{
|
||||
if (!previewSlots.length) return Promise.resolve();
|
||||
const previewBody={planIndex:activePlanIndex,assetTypes:['main','detail'],referenceImages:uploadedReferences,userDirection:userDirection(),fissionPattern:fissionEnabled(),matchReferenceShooting:matchReferenceShooting(),promptOverrides};
|
||||
if (context.source) previewBody.source=context.source; else if (context.data) previewBody.reportData=context.data;
|
||||
return fetch('/api/image-prompt-preview',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(previewBody)}).then(response=>readJsonResponse(response,'/api/image-prompt-preview')).then(payload=>{
|
||||
if (!payload.ok) throw new Error(payload.error||'提示词融合失败');
|
||||
const promptIndexes={main:0,detail:0};
|
||||
previewSlots=arr(payload.slots).map(slot=>{
|
||||
const index=promptIndexes[slot.assetType]++;
|
||||
const key=`${slot.assetType}:${slot.index}`;
|
||||
return {...slot,prompt:arr(payload.prompts?.[slot.assetType])[index]||'',promptMerge:payload.promptMerges?.[key],userDirection:payload.userDirectionBySlot?.[key]};
|
||||
});
|
||||
if (generateStatus && payload.userDirection?.raw) generateStatus.textContent=directionNote(payload.userDirection);
|
||||
});
|
||||
};
|
||||
generateResults?.addEventListener('change',event=>{
|
||||
const textarea=event.target.closest('[data-prompt-slot]');
|
||||
if (textarea) {
|
||||
promptOverrides[textarea.dataset.promptSlot]=textarea.value;
|
||||
refreshPromptPreview().then(refreshGenerationConfirmation).catch(error=>{if(generateStatus) generateStatus.textContent=error.message||'提示词融合失败';});
|
||||
return;
|
||||
}
|
||||
const input=event.target.closest('[data-select-slot]');
|
||||
if(!input)return;if(input.checked)selectedSlotKeys.add(input.dataset.selectSlot);else selectedSlotKeys.delete(input.dataset.selectSlot);refreshGenerationConfirmation();
|
||||
});
|
||||
generateResults?.addEventListener('input',event=>{const textarea=event.target.closest('[data-prompt-slot]');if(!textarea)return;promptOverrides[textarea.dataset.promptSlot]=textarea.value;});
|
||||
generationControls?.addEventListener('change',event=>{
|
||||
const input=event.target.closest('[data-select-type]');
|
||||
if (!input) return;
|
||||
previewSlots.filter(slot=>slot.assetType===input.dataset.selectType).forEach(slot=>{
|
||||
if (input.checked) selectedSlotKeys.add(slotKey(slot));
|
||||
else selectedSlotKeys.delete(slotKey(slot));
|
||||
});
|
||||
refreshGenerationConfirmation();
|
||||
});
|
||||
const pollJob = async jobId => {
|
||||
let networkErrors=0;
|
||||
for (let i=0;i<240;i++) {
|
||||
const endpoint='/api/image-job/'+encodeURIComponent(jobId);
|
||||
let state;
|
||||
try {
|
||||
const r=await fetch(endpoint,{cache:'no-store'});
|
||||
state=await readJsonResponse(r,endpoint);
|
||||
networkErrors=0;
|
||||
} catch (error) {
|
||||
networkErrors++;
|
||||
if (generateStatus) generateStatus.textContent=`本地服务短暂断开,正在自动重连(${networkErrors}/5)…`;
|
||||
if (networkErrors>=5) throw new Error('无法连接本地生图服务,请确认服务仍在运行后重试。');
|
||||
await new Promise(resolve=>setTimeout(resolve,Math.min(2000*networkErrors,6000)));
|
||||
continue;
|
||||
}
|
||||
// A fission run may inject main:1 as the shared product baseline even
|
||||
// when the user selected detail-only slots. Keep that implicit asset in
|
||||
// the board so the UI mirrors the manifest and does not hide the image
|
||||
// that all follow-up slots used as their reference.
|
||||
const expected=arr(state.expectedSlots);
|
||||
if (arr(state.comparisonModels).length) {
|
||||
const runModels=arr(state.comparisonModels).map(item=>({id:item.id||'',label:item.label||item.id||'默认生图模型',quality:item.quality||'',modelKey:item.modelKey}));
|
||||
imageModelOptions=runModels;
|
||||
selectedImageModels=new Set(imageModelOptions.map(item=>item.id));
|
||||
const preservedQualities=new Map(selectedImageQualities);
|
||||
runModels.forEach(item=>{
|
||||
if (item.quality || !preservedQualities.has(item.id)) preservedQualities.set(item.id,item.quality||'');
|
||||
});
|
||||
selectedImageQualities=preservedQualities;
|
||||
renderImageModelControls();
|
||||
submittedModelSpecs=runModels.map(item=>({...item,quality:selectedImageQualities.get(item.id) ?? item.quality ?? ''}));
|
||||
renderGenerationRunSummary(state.status,submittedModelSpecs);
|
||||
}
|
||||
if (expected.length) {
|
||||
const known=new Set(previewSlots.map(slotKey));
|
||||
const generatedKnown=new Set(generatedTaskSlots.map(slotKey));
|
||||
expected.forEach(slot=>{
|
||||
const key=slotKey(slot);
|
||||
if (!known.has(key)) { previewSlots.push({...slot}); known.add(key); }
|
||||
if (!generatedKnown.has(key)) { generatedTaskSlots.push({...slot}); generatedKnown.add(key); }
|
||||
if (state.fissionPattern && slot.assetType==='main' && slot.index===1) selectedSlotKeys.add(key);
|
||||
});
|
||||
}
|
||||
const displayResults=mergeResults(retainedResults,state.results);
|
||||
const boardSlots=generatedTaskSlots.length?generatedTaskSlots:expected.length?expected:previewSlots;
|
||||
if (state.status==='complete') { const gate=state.consistencyGate||{}; const shootPolicy=state.matchReferenceShooting?'已按参考图拍摄与产品展示状态生成。':'按每张图片任务重新设计拍摄和展示状态。'; const failedSlots=arr(state.failedSlots), failed=failedSlots.length; retainedResults=displayResults; if(failed){selectedSlotKeys=new Set(failedSlots.map(item=>`${item.assetType}:${item.slotIndex}`));const counts=generationCounts();if(generateButton){generateButton.disabled=false;generateButton.textContent=`仅重试失败:${countText(counts)}`;}syncTypeSwitches();} if(generateStatus) generateStatus.textContent=failed?`部分完成:累计成功 ${displayResults.length} 张,失败 ${failed} 张。点击按钮仅重试失败槽位。`:`已生成 ${displayResults.length} 张图片。${shootPolicy}${gate.status==='passed'?'一致性校验通过。':'已共享产品身份锁,发布前仍需完成一致性复核。'}`; renderTaskBoard(boardSlots,displayResults,'complete',failedSlots); return; }
|
||||
if (state.status==='error') { renderTaskBoard(boardSlots,displayResults,'error',state.failedSlots); throw new Error(state.error||'生图失败'); }
|
||||
renderTaskBoard(boardSlots,displayResults,'running',state.failedSlots);
|
||||
const counts=generationCounts(); const expectedTotal=(counts.main+counts.detail)*Math.max(1,selectedImageModels.size); if (generateStatus) generateStatus.textContent=`正在并发生成:已完成 ${arr(state.results).length} / ${expectedTotal} 张 · ${state.progress||0}%`;
|
||||
await new Promise(resolve=>setTimeout(resolve,1200));
|
||||
}
|
||||
throw new Error('生图任务等待超时,请稍后刷新报告查看结果。');
|
||||
};
|
||||
generateButton?.addEventListener('click',async event=>{
|
||||
ensureFissionBaseSelected();
|
||||
const types=selectedTypes(), counts=generationCounts(), total=counts.main+counts.detail;
|
||||
if (!types.length) { if(generateStatus) generateStatus.textContent='至少选择主图或详情图一种类型。'; return; }
|
||||
if (!total) { if(generateStatus) generateStatus.textContent='尚未完成生成数量计算,请稍后再试。'; return; }
|
||||
const modelSpecs=selectedModelOptions(), requestTotal=total*Math.max(1,modelSpecs.length);
|
||||
submittedModelSpecs=modelSpecs.map(item=>({...item}));
|
||||
renderGenerationRunSummary('running',submittedModelSpecs);
|
||||
const modelSummary=modelSpecs.map(item=>`${item.label||item.id}(${modelQuality(item)})`).join('、');
|
||||
if (!window.confirm(`确认开始生成?\n\n模型:${modelSummary}\n主图:${counts.main} 张\n详情图:${counts.detail} 张\n合计:${requestTotal} 张`)) {
|
||||
renderGenerationRunSummary('queued',[]);
|
||||
if (generateStatus) generateStatus.textContent=`已取消。本次原计划生成 ${modelSpecs.length} 个模型 × ${countText(counts)}。`;
|
||||
return;
|
||||
}
|
||||
const submittedSlots=previewSlots.filter(slot=>selectedSlotKeys.has(slotKey(slot)));
|
||||
event.currentTarget.disabled=true; if(generateStatus) generateStatus.textContent='正在提交生图任务…'; renderTaskBoard(submittedSlots,retainedResults,'running');
|
||||
try {
|
||||
const body={planIndex:activePlanIndex,assetTypes:types,selectedSlots:[...selectedSlotKeys],completeSet:true,imageModels:modelSpecs.map(item=>({id:item.id,quality:item.quality||''})),referenceImages:uploadedReferences,userDirection:userDirection(),fissionPattern:fissionEnabled(),matchReferenceShooting:matchReferenceShooting(),promptOverrides};
|
||||
if (context.source) body.source=context.source; else if (context.data) body.reportData=context.data;
|
||||
const response=await fetch('/api/generate-images',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}), payload=await readJsonResponse(response,'/api/generate-images');
|
||||
if (!response.ok || !payload.ok) throw new Error(payload.error||'无法提交生图任务');
|
||||
window.dispatchEvent(new CustomEvent('image-job-submitted',{detail:{jobId:payload.jobId}}));
|
||||
await pollJob(payload.jobId);
|
||||
window.dispatchEvent(new CustomEvent('image-job-finished',{detail:{jobId:payload.jobId}}));
|
||||
} catch (error) { renderGenerationRunSummary('error',submittedModelSpecs); if(generateStatus) generateStatus.textContent=error.message||'生图失败'; }
|
||||
finally { event.currentTarget.disabled=false; }
|
||||
});
|
||||
const referenceUpload=dialog.querySelector('.reference-upload');
|
||||
const referenceUploadButton=dialog.querySelector('.reference-upload-btn');
|
||||
const referenceEmpty=dialog.querySelector('.reference-empty');
|
||||
const addReferenceFiles=async fileList=>{
|
||||
const files=[...fileList].slice(0,Math.max(0,4-uploadedReferences.length));
|
||||
for (const file of files) {
|
||||
if (!file.type.startsWith('image/')) continue;
|
||||
if (file.size>9*1024*1024) { if(generateStatus) generateStatus.textContent='单张参考图不能超过 9MB。'; continue; }
|
||||
try {
|
||||
const dataUrl=await new Promise((resolve,reject)=>{const reader=new FileReader();reader.onload=()=>resolve(reader.result);reader.onerror=reject;reader.readAsDataURL(file)});
|
||||
uploadedReferences.push(dataUrl);
|
||||
} catch (_) {
|
||||
if (generateStatus) generateStatus.textContent='参考图读取失败,请重新选择图片。';
|
||||
}
|
||||
}
|
||||
renderReferences(arr(plans)[activePlanIndex]||{});
|
||||
if (previewSlots.length) await refreshPromptPreview().then(refreshGenerationConfirmation).catch(error=>{if(generateStatus) generateStatus.textContent=error.message||'参考图提示词刷新失败';});
|
||||
if(generateStatus) generateStatus.textContent=uploadedReferences.length?`已使用上传产品图;生成时会保持产品身份。${matchReferenceShooting()?' '+matchReferenceDescription():''}`:`将使用默认采集参考图。${matchReferenceShooting()?' '+matchReferenceDescription():''}`;
|
||||
};
|
||||
referenceInput?.addEventListener('change',async event=>{
|
||||
await addReferenceFiles(event.target.files);
|
||||
event.target.value='';
|
||||
});
|
||||
// Explicitly proxy the visible button to the file input. This remains
|
||||
// reliable when modal CSS or browser label activation suppresses the native
|
||||
// label-to-input click behavior.
|
||||
referenceUploadButton?.addEventListener('click',event=>{
|
||||
if (event.target===referenceInput) return;
|
||||
event.preventDefault();
|
||||
referenceInput?.click();
|
||||
});
|
||||
referenceEmpty?.addEventListener('click',()=>referenceInput?.click());
|
||||
referenceUpload?.addEventListener('dragenter',event=>{
|
||||
event.preventDefault();
|
||||
referenceUpload.classList.add('is-dragging');
|
||||
});
|
||||
referenceUpload?.addEventListener('dragover',event=>event.preventDefault());
|
||||
referenceUpload?.addEventListener('dragleave',event=>{
|
||||
if (!referenceUpload.contains(event.relatedTarget)) referenceUpload.classList.remove('is-dragging');
|
||||
});
|
||||
referenceUpload?.addEventListener('drop',async event=>{
|
||||
event.preventDefault();
|
||||
referenceUpload.classList.remove('is-dragging');
|
||||
await addReferenceFiles(event.dataTransfer?.files||[]);
|
||||
});
|
||||
referenceList?.addEventListener('click',event=>{
|
||||
const button=event.target.closest('[data-remove-reference]'); if(!button)return;
|
||||
uploadedReferences.splice(Number(button.dataset.removeReference),1);
|
||||
if (!uploadedReferences.length&&fissionInput) fissionInput.checked=true;
|
||||
renderReferences(arr(plans)[activePlanIndex]||{});
|
||||
if (previewSlots.length) refreshPromptPreview().then(refreshGenerationConfirmation).catch(error=>{if(generateStatus) generateStatus.textContent=error.message||'参考图提示词刷新失败';});
|
||||
});
|
||||
fissionInput?.addEventListener('change',()=>{
|
||||
renderReferences(arr(plans)[activePlanIndex]||{});
|
||||
if (previewSlots.length) refreshPromptPreview().then(refreshGenerationConfirmation).catch(error=>{if(generateStatus) generateStatus.textContent=error.message||'裂变提示词刷新失败';});
|
||||
});
|
||||
matchReferenceInput?.addEventListener('change',async()=>{
|
||||
syncMatchReferenceControl(arr(plans)[activePlanIndex]||{});
|
||||
syncFissionControl();
|
||||
if (!previewSlots.length) {
|
||||
if (generateStatus) generateStatus.textContent=matchReferenceDescription();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await refreshPromptPreview();
|
||||
refreshGenerationConfirmation();
|
||||
if (generateStatus) generateStatus.textContent=matchReferenceDescription();
|
||||
} catch(error) {
|
||||
if (generateStatus) generateStatus.textContent=error.message||'参考图拍摄方式提示词刷新失败';
|
||||
}
|
||||
});
|
||||
document.querySelectorAll('.generate-plan-btn').forEach(button=>button.addEventListener('click',()=>{
|
||||
loadImageModels();
|
||||
const plan = arr(plans)[Number(button.dataset.planIndex)] || {};
|
||||
activePlanIndex=Number(button.dataset.planIndex)||0;
|
||||
if (userDirectionInput) userDirectionInput.value='';
|
||||
if (fissionInput) fissionInput.checked=true;
|
||||
if (matchReferenceInput) matchReferenceInput.checked=false;
|
||||
uploadedReferences=[]; renderReferences(plan);
|
||||
previewSlots=[]; selectedSlotKeys=new Set(); promptOverrides={}; retainedResults=[]; generatedTaskSlots=[];
|
||||
if (generateButton) { generateButton.disabled=true; generateButton.textContent='正在计算生成数量…'; }
|
||||
submittedModelSpecs=[];
|
||||
renderGenerationRunSummary('queued',submittedModelSpecs);
|
||||
title.textContent = plan.name || '快速开品方案';
|
||||
reason.textContent = plan.whyThisPlan || ''; reason.hidden = !hasContent(plan.whyThisPlan);
|
||||
[[product,plan.productAction],[page,plan.pageAction],[validation,plan.validation]].forEach(([node,value])=>{node.textContent=value||'';node.closest('div').hidden=!hasContent(value)});
|
||||
currentText = [[`快速开品任务|${plan.name||''}`,plan.name],['依据:'+(plan.whyThisPlan||''),plan.whyThisPlan],['产品动作:'+(plan.productAction||''),plan.productAction],['页面动作:'+(plan.pageAction||''),plan.pageAction],['验证指标:'+(plan.validation||''),plan.validation]].filter(([,value])=>hasContent(value)).map(([line])=>line).join('\n');
|
||||
if (reason) { reason.textContent = plan.whyThisPlan || ''; reason.hidden = !hasContent(plan.whyThisPlan); }
|
||||
[[product,plan.productAction],[page,plan.pageAction],[validation,plan.validation]].forEach(([node,value])=>{if(!node)return;node.textContent=value||'';node.closest('div').hidden=!hasContent(value)});
|
||||
if (generateStatus) generateStatus.textContent='已按此方案准备生图提示词。请先选择要生成的图片类型。';
|
||||
if (generateResults) generateResults.replaceChildren();
|
||||
const previewBody={planIndex:activePlanIndex,assetTypes:['main','detail'],referenceImages:uploadedReferences,userDirection:userDirection(),fissionPattern:fissionEnabled(),matchReferenceShooting:matchReferenceShooting(),promptOverrides};
|
||||
if (context.source) previewBody.source=context.source; else if (context.data) previewBody.reportData=context.data;
|
||||
fetch('/api/image-prompt-preview',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(previewBody)}).then(response=>readJsonResponse(response,'/api/image-prompt-preview')).then(payload=>{
|
||||
if (!payload.ok) throw new Error(payload.error||'提示词预览失败');
|
||||
const promptIndexes={main:0,detail:0};
|
||||
previewSlots=arr(payload.slots).map(slot=>{
|
||||
const index=promptIndexes[slot.assetType]++;
|
||||
const key=`${slot.assetType}:${slot.index}`;
|
||||
return {...slot,prompt:arr(payload.prompts?.[slot.assetType])[index]||'',promptMerge:payload.promptMerges?.[key],userDirection:payload.userDirectionBySlot?.[key]};
|
||||
});
|
||||
// 默认保留完整主图链和前六个详情证明位;详情 7-15 可按需勾选。
|
||||
selectedSlotKeys=new Set(previewSlots.filter(slot=>slot.assetType==='main'||slot.index<=6).map(slotKey));
|
||||
refreshGenerationConfirmation();
|
||||
}).catch(error=>{if(generateStatus) generateStatus.textContent=error.message||'生成数量计算失败,请重新打开方案。'});
|
||||
if (typeof dialog.showModal === 'function') dialog.showModal(); else dialog.setAttribute('open','');
|
||||
}));
|
||||
dialog.querySelector('[data-close-plan]')?.addEventListener('click',()=>dialog.close ? dialog.close() : dialog.removeAttribute('open'));
|
||||
dialog.querySelector('[data-copy-plan]')?.addEventListener('click',async event=>{
|
||||
try { await navigator.clipboard.writeText(currentText); event.currentTarget.textContent='已复制任务'; }
|
||||
catch { event.currentTarget.textContent='复制失败,请手动复制'; }
|
||||
setTimeout(()=>event.currentTarget.textContent='复制开品任务',1600);
|
||||
userDirectionInput?.addEventListener('change',()=>{
|
||||
if (!previewSlots.length) return;
|
||||
const previewBody={planIndex:activePlanIndex,assetTypes:['main','detail'],referenceImages:uploadedReferences,userDirection:userDirection(),fissionPattern:fissionEnabled(),matchReferenceShooting:matchReferenceShooting(),promptOverrides};
|
||||
if (context.source) previewBody.source=context.source; else if (context.data) previewBody.reportData=context.data;
|
||||
fetch('/api/image-prompt-preview',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(previewBody)}).then(response=>readJsonResponse(response,'/api/image-prompt-preview')).then(payload=>{
|
||||
if (!payload.ok) throw new Error(payload.error||'用户补充判断失败');
|
||||
const promptIndexes={main:0,detail:0};
|
||||
previewSlots=arr(payload.slots).map(slot=>{
|
||||
const index=promptIndexes[slot.assetType]++;
|
||||
const key=`${slot.assetType}:${slot.index}`;
|
||||
return {...slot,prompt:arr(payload.prompts?.[slot.assetType])[index]||'',promptMerge:payload.promptMerges?.[key],userDirection:payload.userDirectionBySlot?.[key]};
|
||||
});
|
||||
refreshGenerationConfirmation();
|
||||
if (generateStatus && payload.userDirection?.raw) generateStatus.textContent=directionNote(payload.userDirection);
|
||||
}).catch(error=>{if(generateStatus) generateStatus.textContent=error.message||'用户补充判断失败';});
|
||||
});
|
||||
dialog.querySelector('[data-close-plan]')?.addEventListener('click',()=>dialog.close ? dialog.close() : dialog.removeAttribute('open'));
|
||||
loadImageModels();
|
||||
}
|
||||
function openImagePreview(items,startIndex=0) {
|
||||
const list=arr(items).filter(item=>item?.url);
|
||||
if (!list.length) return;
|
||||
let index=Math.max(0,Math.min(startIndex,list.length-1));
|
||||
let dialog=document.querySelector('[data-image-preview-dialog]');
|
||||
if (!dialog) {
|
||||
dialog=document.createElement('dialog');
|
||||
dialog.className='image-preview-dialog';
|
||||
dialog.setAttribute('data-image-preview-dialog','');
|
||||
dialog.innerHTML='<button class="image-preview-close" type="button" data-preview-close aria-label="关闭">×</button><button class="image-preview-nav image-preview-prev" type="button" data-preview-prev aria-label="上一张">‹</button><figure><img data-preview-image alt=""><figcaption><b data-preview-title></b><span data-preview-count></span></figcaption></figure><button class="image-preview-nav image-preview-next" type="button" data-preview-next aria-label="下一张">›</button>';
|
||||
document.body.append(dialog);
|
||||
dialog.addEventListener('click',event=>{if(event.target===dialog||event.target.closest('[data-preview-close]')) dialog.close();});
|
||||
dialog.addEventListener('keydown',event=>{if(event.key==='ArrowLeft'){event.preventDefault();dialog.querySelector('[data-preview-prev]')?.click();}if(event.key==='ArrowRight'){event.preventDefault();dialog.querySelector('[data-preview-next]')?.click();}});
|
||||
}
|
||||
const render=()=>{
|
||||
const item=list[index]||list[0], img=dialog.querySelector('[data-preview-image]');
|
||||
img.src=item.url; img.alt=item.title||'生成图片';
|
||||
dialog.querySelector('[data-preview-title]').textContent=item.title||'生成图片';
|
||||
dialog.querySelector('[data-preview-count]').textContent=`${index+1} / ${list.length}`;
|
||||
};
|
||||
dialog.querySelector('[data-preview-prev]').onclick=()=>{index=(index-1+list.length)%list.length;render();};
|
||||
dialog.querySelector('[data-preview-next]').onclick=()=>{index=(index+1)%list.length;render();};
|
||||
render();
|
||||
if (typeof dialog.showModal === 'function') dialog.showModal(); else dialog.setAttribute('open','');
|
||||
}
|
||||
function initGenerationHistory(context={}) {
|
||||
const root=document.querySelector('[data-generation-history]');
|
||||
if (!root) return;
|
||||
const endpoint='/api/image-jobs'+(context.source?'?source='+encodeURIComponent(context.source):'');
|
||||
const dialog=document.querySelector('[data-generation-history-dialog]');
|
||||
const trigger=document.querySelector('[data-generation-history-open]');
|
||||
const countNode=document.querySelector('[data-generation-history-count]');
|
||||
let timer=null;
|
||||
const openHistory=()=>{
|
||||
if (dialog && typeof dialog.showModal === 'function') dialog.showModal();
|
||||
else if (dialog) dialog.setAttribute('open','');
|
||||
};
|
||||
trigger?.addEventListener('click',openHistory);
|
||||
dialog?.addEventListener('click',event=>{
|
||||
if (event.target===dialog || event.target.closest('[data-generation-history-close]')) {
|
||||
if (typeof dialog.close === 'function') dialog.close();
|
||||
else dialog.removeAttribute('open');
|
||||
}
|
||||
});
|
||||
const render=records=>{
|
||||
const items=arr(records);
|
||||
if (countNode) {
|
||||
countNode.textContent=items.length>99?'99+':String(items.length);
|
||||
countNode.hidden=!items.length;
|
||||
}
|
||||
if (!items.length) { root.innerHTML='<p class="generation-history-empty">还没有生成记录。选择具体开品方案后,可异步生成主图与详情图。</p>'; return; }
|
||||
root.innerHTML=items.map(job=>{
|
||||
const failedCount=arr(job.failedSlots).length;
|
||||
const status=job.status==='complete'?(failedCount?`部分完成 · 失败 ${failedCount} 张`:'已完成'):job.status==='error'?'失败':`生成中 ${Number(job.progress||0)}%`;
|
||||
const thumbs=orderedResults(job.results).slice(0,15).map(item=>`<button type="button" data-history-preview-url="${esc(item.url)}" data-history-preview-title="${esc(`${modelLabel(item)} · ${item.assetType==='detail'?'详情图':'主图'} ${String(item.slotIndex||'').padStart(2,'0')}`)}"><img loading="lazy" src="${esc(item.url)}" alt="${esc(`${modelLabel(item)} · ${job.planName||'开品方案'}生成图`)}"></button>`).join('');
|
||||
const date=job.createdAt?new Date(Number(job.createdAt)*1000).toLocaleString('zh-CN',{hour12:false}):'';
|
||||
const shootPolicy=job.matchReferenceShooting?'已按参考图拍摄与展示状态一致':'按任务重新设计拍摄与展示';
|
||||
const download=job.jobId && arr(job.results).length ? `<a class="generation-download" href="/api/image-job/${encodeURIComponent(job.jobId)}/download" download>打包下载</a>` : '';
|
||||
const resultCount=arr(job.results).length;
|
||||
const modelSummary=arr(job.comparisonModels).map(item=>`${item.label||item.id}(质量:${modelQuality(item)})`).filter(Boolean).join('、');
|
||||
return `<article class="generation-record"><header><div><span>${job.comparisonMode?'模型对比任务':'开品方案'}</span><h3>${esc(job.planName||'未命名开品方案')}</h3><p>${esc(job.productName||'')} ${date?'· '+esc(date):''}</p></div><div class="generation-record-actions"><b class="generation-status generation-status-${esc(job.status||'queued')}">${esc(status)}</b>${download}</div></header><p class="generation-record-policy">${modelSummary?`对比模型:${esc(modelSummary)} · `:''}${shootPolicy} · ${job.referenceShootingPolicy==='match_reference'?'锁定机位/构图/光线/动作/摆放':'仅锁定产品身份'}</p>${job.error?`<p class="generation-error">${esc(job.error)}</p>`:''}${thumbs?`<div class="generation-thumbs">${thumbs}</div>`:''}<div class="generation-record-meta"><small>记录编号:${esc(job.jobId||'')}</small>${resultCount?`<small>已保存 ${resultCount} 张图片</small>`:''}</div></article>`;
|
||||
}).join('');
|
||||
};
|
||||
root.addEventListener('click',event=>{
|
||||
const button=event.target.closest('[data-history-preview-url]');
|
||||
if (!button) return;
|
||||
const record=button.closest('.generation-record')||root;
|
||||
const buttons=[...record.querySelectorAll('[data-history-preview-url]')];
|
||||
openImagePreview(buttons.map(node=>({url:node.dataset.historyPreviewUrl,title:node.dataset.historyPreviewTitle||'生成图片'})),buttons.indexOf(button));
|
||||
});
|
||||
const load=async()=>{
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
const response=await fetch(endpoint,{cache:'no-store'}), payload=await readJsonResponse(response,endpoint);
|
||||
if (!response.ok||!payload.ok) throw new Error(payload.error||'生成记录读取失败');
|
||||
render(payload.records);
|
||||
if (arr(payload.records).some(x=>!['complete','error'].includes(x.status))) timer=setTimeout(load,1800);
|
||||
} catch(error) {
|
||||
if (countNode) countNode.hidden=true;
|
||||
root.innerHTML=`<p class="generation-history-empty">${esc(error.message||'生成记录读取失败')}</p>`;
|
||||
}
|
||||
};
|
||||
load();
|
||||
window.addEventListener('image-job-submitted',load);
|
||||
window.addEventListener('image-job-finished',load);
|
||||
}
|
||||
async function main() {
|
||||
const query = new URLSearchParams(location.search);
|
||||
@@ -306,7 +954,7 @@ async function main() {
|
||||
else {
|
||||
const response = await fetch(source,{cache:'no-store'});
|
||||
if (!response.ok) throw new Error(`报告数据读取失败(${response.status})`);
|
||||
data = await response.json();
|
||||
data = await readJsonResponse(response,String(source));
|
||||
}
|
||||
evidenceIndex = new Map(arr(data.evidenceLedger).map(item=>[item.id,item]));
|
||||
const exp = data.experienceSolution || {}, roles = data.roleOutputs || {}, facts = data.facts || {};
|
||||
@@ -342,10 +990,48 @@ async function main() {
|
||||
const navNames = {decision:'老板速览',detail:'详情',review:'评论',verification:'验证闭环',roles:'完整分析'};
|
||||
const navLinks = bodies.filter(([id])=>navNames[id]).map(([id])=>`<a href="#${id}">${navNames[id]}</a>`).join('');
|
||||
const hasPlans = bodies.some(([id])=>id==='plan');
|
||||
const heroStats = [['当前价格',sales.currentPrice,'price'],['评论样本',data.baseline?.reviewCount,'sample'],['数据质量',data.dataQuality?.grade,'status']].filter(([,value])=>hasContent(value));
|
||||
const heroStats = [['当前价格',sales.currentPrice,'price'],['评论样本',data.baseline?.reviewCount,'sample'],['状态',data.dataQuality?.grade,'status']].filter(([,value])=>hasContent(value));
|
||||
document.title = summary.title || product.title || '商品开品分析报告';
|
||||
document.getElementById('app').innerHTML = `<div class="report-top"><nav class="report-nav"><div class="wrap"><a class="brand" href="#top">三笙 · 商品开品分析</a><div class="report-nav-actions">${navLinks}${hasPlans?'<a class="quick-product-btn" href="#plan">快速开品 <span>→</span></a>':''}</div></div></nav><header id="top" class="report-hero hero-carousel" tabindex="0">${heroSlides?`<div class="hero-slides">${heroSlides}</div><div class="hero-shade"></div>`:''}<div class="wrap hero-overlay"><div class="hero-copy"><div class="report-kicker">PRODUCT DECISION REPORT · EVIDENCE BASED</div><h1>${esc(summary.title || product.title || '商品分析报告')}</h1>${hasContent(summary.verdict)?`<p>${esc(summary.verdict)}</p>`:''}${auditNotice?`<p class="report-audit-notice" role="status">${esc(auditNotice)}</p>`:''}${heroStats.length?`<div class="hero-meta">${heroStats.map(([label,value,tone])=>`<span class="hero-stat hero-stat-${tone}"><small>${label}</small><b>${esc(value)}</b></span>`).join('')}</div>`:''}</div>${heroDeck?`<div class="hero-deck" aria-label="商品主图牌组">${heroDeck}</div>`:''}${orderedMain.length>1?`<div class="hero-carousel-ui"><button type="button" data-carousel-prev aria-label="上一张主图">←</button><div class="hero-dots">${heroDots}</div><button type="button" data-carousel-next aria-label="下一张主图">→</button></div>`:''}</div></header></div>${sectionHtml}${hasPlans?'<dialog id="quick-plan-dialog" class="quick-plan-dialog"><button class="dialog-close" type="button" data-close-plan aria-label="关闭">×</button><span>QUICK PRODUCT BRIEF</span><h2 data-plan-title>快速开品方案</h2><p data-plan-reason></p><dl><div><dt>产品动作</dt><dd data-plan-product></dd></div><div><dt>页面动作</dt><dd data-plan-page></dd></div><div><dt>验证指标</dt><dd data-plan-validation></dd></div></dl><button class="copy-plan-btn" type="button" data-copy-plan>复制开品任务</button></dialog>':''}<footer class="report-footer"><div class="wrap"><b>数据边界</b> 本报告仅使用本次采集证据;评论主题可以重叠,未完成采集时不宣称平台全量。</div></footer>`;
|
||||
document.getElementById('app').innerHTML = `<div class="report-top"><nav class="report-nav"><div class="wrap"><a class="brand" href="#top">三笙 · 商品开品分析</a><div class="report-nav-actions">${navLinks}${hasPlans?'<a class="quick-product-btn" href="#plan">快速开品 <span>→</span></a>':''}</div></div></nav><header id="top" class="report-hero hero-carousel" tabindex="0">${heroSlides?`<div class="hero-slides">${heroSlides}</div><div class="hero-shade"></div>`:''}<div class="wrap hero-overlay"><div class="hero-copy"><div class="report-kicker">PRODUCT DECISION REPORT · EVIDENCE BASED</div><h1>${esc(summary.title || product.title || '商品分析报告')}</h1>${hasContent(summary.verdict)?`<p>${esc(summary.verdict)}</p>`:''}${auditNotice?`<p class="report-audit-notice" role="status">${esc(auditNotice)}</p>`:''}${heroStats.length?`<div class="hero-meta">${heroStats.map(([label,value,tone])=>`<span class="hero-stat hero-stat-${tone}"><small>${label}</small><b>${esc(value)}</b></span>`).join('')}</div>`:''}</div>${heroDeck?`<div class="hero-deck" aria-label="商品主图牌组">${heroDeck}</div>`:''}${orderedMain.length>1?`<div class="hero-carousel-ui"><button type="button" data-carousel-prev aria-label="上一张主图">←</button><div class="hero-dots">${heroDots}</div><button type="button" data-carousel-next aria-label="下一张主图">→</button></div>`:''}</div></header></div>${sectionHtml}${hasPlans?'<dialog id="quick-plan-dialog" class="quick-plan-dialog"><button class="dialog-close" type="button" data-close-plan aria-label="关闭弹窗">×</button><div class="quick-plan-top"><section class="product-brief"><span>PRODUCT LAUNCH BRIEF · 产品开品说明</span><h2 data-plan-title>快速开品方案</h2><dl><div><dt>产品开品</dt><dd data-plan-product></dd></div><div><dt>主图 / 详情打造</dt><dd data-plan-page></dd></div></dl><div class="brief-controls"><label class="fission-pattern-toggle match-reference-shooting-toggle" data-match-reference-wrap><input type="checkbox" data-match-reference-shooting><span><b>产品图身份 + 采集参考图展示状态(产品展示与参考图拍摄一致)</b><small data-match-reference-note>请同时准备产品图和采集商品对应参考图,才能启用展示状态一致。</small></span></label><label class="fission-pattern-toggle" data-fission-wrap><input type="checkbox" data-fission-pattern checked> 默认裂变花型,先生成相近商品基准图</label><label class="image-user-direction"><span>用户补充要求</span><textarea data-user-direction rows="3" placeholder="例如:更偏高端酒店感、浅色背景、突出面料垂坠;不要填写价格、品牌Logo、认证或改商品结构。"></textarea></label></div></section><section class="reference-upload" aria-label="商品主图参考"><header><b>产品图(商品身份)</b><span>本地上传 · 最多 4 张</span></header><div class="reference-list" data-reference-list></div><div class="reference-empty"><span>+</span><b>点击或拖拽上传参考图</b><small>本地上传 · 最多 4 张</small></div><label class="reference-upload-btn">+ 上传产品图<input type="file" accept="image/*" multiple data-reference-input></label><p data-reference-hint></p><em>提示:上传主体清晰、结构完整的产品图;采集商品对应图片自动作为展示状态参考,两者不是同一张图。</em></section></div><section class="image-generation-panel"><header><div><span>AI VISUAL PRODUCTION</span><h3>AI 生图任务</h3><p>勾选任意图片任务,可横向滑动查看;主图最多 5 张,详情图最多 15 张。</p></div><button class="generate-images-btn" type="button" data-generate-images disabled>正在计算生成数量…</button></header><div class="image-generation-controls" data-generation-controls><b>默认勾选:5 张主图 + 6 张详情图。可按需增减。</b><label><input type="checkbox" data-select-type="main"> 生主图 <span>0/0</span></label><label><input type="checkbox" data-select-type="detail"> 生详情 <span>0/0</span></label></div><p class="image-generation-status" data-generate-status aria-live="polite"></p><div class="generated-image-results" data-generate-results></div></section></dialog>':''}<footer class="report-footer"><div class="wrap"><b>数据边界</b> 本报告仅使用本次采集证据;评论主题可以重叠,未准备采集证据时不宣称页面全量。</div></footer>`;
|
||||
const modelControls=document.createElement('div');
|
||||
modelControls.className='image-model-controls';
|
||||
modelControls.setAttribute('data-image-model-controls','');
|
||||
modelControls.innerHTML='<span class="image-model-loading">正在读取生图模型…</span>';
|
||||
const generationControlsNode=document.querySelector('[data-generation-controls]');
|
||||
generationControlsNode?.after(modelControls);
|
||||
const generationHeading=document.querySelector('#quick-plan-dialog .image-generation-panel h3');
|
||||
if (generationHeading) generationHeading.textContent='选择要生成的图片';
|
||||
const generationButton=document.querySelector('#quick-plan-dialog [data-generate-images]');
|
||||
const generationStatus=document.querySelector('#quick-plan-dialog [data-generate-status]');
|
||||
const generationRunSummary=document.createElement('div');
|
||||
generationRunSummary.className='generation-run-summary';
|
||||
generationRunSummary.setAttribute('data-generation-run-summary','');
|
||||
generationRunSummary.hidden=true;
|
||||
generationStatus?.after(generationRunSummary);
|
||||
const historyTrigger=document.createElement('button');
|
||||
historyTrigger.className='generation-history-fab';
|
||||
historyTrigger.type='button';
|
||||
historyTrigger.setAttribute('data-generation-history-open','');
|
||||
historyTrigger.setAttribute('aria-label','打开生成记录');
|
||||
historyTrigger.innerHTML='<span class="generation-history-fab-icon" aria-hidden="true">↺</span><span>生成记录</span><b data-generation-history-count hidden>0</b>';
|
||||
const historyDialog=document.createElement('dialog');
|
||||
historyDialog.className='generation-history-dialog';
|
||||
historyDialog.setAttribute('data-generation-history-dialog','');
|
||||
historyDialog.innerHTML='<header class="generation-history-dialog-head"><div><span>AI VISUAL PRODUCTION</span><h2>生成记录</h2><p>按任务查看生成结果;点击图片查看原图,已有成果可直接打包下载。</p></div><button class="dialog-close" type="button" data-generation-history-close aria-label="关闭生成记录">×</button></header><div class="generation-history" data-generation-history><p class="generation-history-empty">正在读取生成记录…</p></div>';
|
||||
document.body.append(historyTrigger,historyDialog);
|
||||
initHeroCarousel();
|
||||
initPlanActions(plans);
|
||||
const userDirectionField=document.querySelector('[data-user-direction]');
|
||||
if (userDirectionField) {
|
||||
userDirectionField.previousElementSibling.textContent='用户最终要求';
|
||||
userDirectionField.placeholder='例如:改成天丝棉材质、颜色改为浅灰;或更偏高端酒店感、背景留白更大。不要填写价格、品牌Logo、认证、二维码等平台禁区。';
|
||||
}
|
||||
const footer=document.querySelector('.report-footer .wrap');
|
||||
if (footer) {
|
||||
const brand=document.createElement('div'); brand.className='report-footer-brand'; brand.innerHTML='<b>三笙AI</b><span>商品开品分析</span>';
|
||||
footer.prepend(brand);
|
||||
footer.append(document.createElement('br'),'参数事实、验证门与验证闭环统一收录在底部完整分析区。');
|
||||
}
|
||||
initPlanActions(plans,{source:typeof source==='string'?source:'',data:typeof source==='object'?data:null});
|
||||
initGenerationHistory({source:typeof source==='string'?source:'',data:typeof source==='object'?data:null});
|
||||
}
|
||||
main().catch(error => { document.getElementById('app').innerHTML = `<div class="report-error"><h1>报告加载失败</h1><p>${esc(error.message)}</p></div>`; });
|
||||
|
||||
Reference in New Issue
Block a user