'use strict'; (function () { const BRAND_KINDS = ['logo', 'banner', 'signage', 'sticker']; const BRAND_META = { logo: { label: '店铺 LOGO', ratio: '1:1', minW: 260, minH: 260 }, banner: { label: '店内海报', ratio: '21:9', minW: 1138, minH: 292 }, signage: { label: '招牌头图', ratio: '21:9', minW: 750, minH: 288 }, sticker: { label: '商品自定义生成', ratio: '1:1', minW: 0, minH: 0 }, }; const CROP_SPECS = { banner: { label: '店内海报', width: 1138, height: 292, fileName: '店内海报_1138x292.png' }, signage: { label: '招牌头图', width: 750, height: 288, fileName: '招牌头图_750x288.png' }, }; const POSITIONS = [ { value: 'bottom-right', label: '右下角' }, { value: 'bottom-left', label: '左下角' }, { value: 'top-right', label: '右上角' }, { value: 'top-left', label: '左上角' }, ]; const OVERLAY_MODES = [{ value: 'ai', label: 'AI 融合' }, { value: 'composite', label: '本地合成' }]; const ASPECT_OPTIONS = ['auto', '1:1', '4:3', '3:4', '16:9', '9:16', '3:2', '2:3', '21:9']; const DEFAULT_PROMPTS = { logo: `为『{shopName}』设计一个可直接用于外卖门店、小程序和商品列表的品牌LOGO图标。 目标:形成高识别度、可缩放、可长期使用的餐饮品牌符号。 品牌信息:主营『{dishNames}』,视觉气质应温暖、有食欲、专业可信。 构图:主体居中,轮廓清晰,正负空间干净;避免复杂细节和高光渐变堆叠。 配色:使用不超过3组主色,优先结合主营品类和参考图的色彩关系。 背景:纯色或极简背景,保证在浅色、深色和小尺寸场景中都易读。 禁止:照片质感、二维码、网址、联系电话、竞对品牌、复杂装饰、无意义图形。`, banner: `为『{shopName}』设计一张店内促销海报,最终画面是1138:292超宽横幅。 目标:快速传达品牌氛围和核心品类,适配外卖门店头图或店内宣传位。 主体:选择最具食欲感的核心菜品作为视觉焦点,搭配『{shopName}』的品牌色和简约装饰元素。 构图:重要内容集中在中央横向安全区,左右和上下保留呼吸空间;关键元素远离裁切边缘。 视觉:商业美食摄影质感,光线干净,色彩有层次但不杂乱;保持品牌识别统一。 文字:少量店名或品类文字可出现,必须清晰简洁;不堆叠标题、卖点和小字。 禁止:二维码、网址、联系方式、竞对品牌、低质素材拼贴、涉黄或政策风险内容。`, signage: `为『{shopName}』设计一张门店招牌头图,突出餐饮门店的专业感和主营『{dishNames}』。 目标:让顾客第一眼理解品类、品牌调性和门店氛围。 构图:主视觉居中或遵循稳定的三分法,保留品牌识别区域;招牌元素不要顶边或被裁切。 视觉:结合门头光感、简洁材质、品牌色和有食欲的菜品焦点,画面干净、有层次、可信。 品牌:统一使用传入的品牌色和店铺LOGO;若未提供,则预留清晰的招牌识别空间。 禁止:杂乱街景、多余行人、二维码、网址、联系电话、竞对品牌、低质感文字和涉险内容。`, sticker: `参考所选菜品实拍图,为『{shopName}』生成一张商品自定义展示图。 目标:保留原图菜品的真实识别度,同时提升电商陈列的精致感和食欲感。 主体:菜品形态、分量、主要配料和烹饪状态必须与原图一致,不做夸大替换。 构图:主体居中,边缘留白适中,高光和阴影自然;适合商品列表小图浏览。 视觉:使用与店铺LOGO色彩一致的浅色背景,呈现干净、统一、专业的商品氛围。 禁止:改变菜品真实卖点、添加旧LOGO、水印、二维码、网址、无关食材或过度滤镜。`, dish: `参考这张菜品实拍图,为『{shopName}』生成商品图:『{dishName}』。 主体:保持菜品的品类、形态、分量、配料和烹饪状态真实一致,突出自然食欲感。 旧标识处理:识别并彻底移除原图中的旧LOGO、水印、店铺名、徽章;自然修复背景,不复制、不模仿。 品牌叠加:若提供店铺指定LOGO,只使用传入LOGO,保持其图形、文字、配色和比例完整清晰。 构图:主体居中,视角稳定,背景干净,光影真实;适合商品列表和详情页展示。 背景:使用与店铺LOGO色彩一致的浅色背景,保持整店商品视觉统一。 禁止:替换关键食材、夸张失真、杂乱道具、旧标识残留、二维码、网址和无关文字。`, }; const ICON_PLAY = ''; const ICON_DOWNLOAD = ''; const state = { root: '', allShops: [], shops: [], model: '', scanSelection: new Set(['__all__']), expandedShops: new Set(), genInitialized: false }; const activeJobs = new Map(); let hoverPreview = null; function el(tag, className, text) { const node = document.createElement(tag); if (className) node.className = className; if (text !== undefined) node.textContent = text; return node; } function select(options, value, className = 'control') { const node = el('select', className); options.forEach((item) => { const option = el('option', '', item.label); option.value = item.value; option.selected = item.value === value; node.appendChild(option); }); return node; } function showToast(message, type) { const wrap = document.getElementById('toast-wrap'); const node = el('div', `toast toast-${type || 'success'}`, message); wrap.appendChild(node); setTimeout(() => { node.style.opacity = '0'; node.style.transition = 'opacity .3s'; }, 2600); setTimeout(() => { if (node.parentNode) node.remove(); }, 3000); } let promptModal = null; function ensurePromptModal() { if (promptModal) return promptModal; const overlay = el('div', 'modal-overlay prompt-modal-overlay'); overlay.id = 'promptModal'; overlay.hidden = true; const modal = el('div', 'modal prompt-modal'); const header = el('div', 'modal-header'); const title = el('div', 'modal-title'); const icon = el('span', 'modal-status-icon'); icon.innerHTML = ''; const titleText = el('span', '', ''); title.append(icon, titleText); const closeButton = el('button', 'modal-close'); closeButton.type = 'button'; closeButton.innerHTML = ''; header.append(title, closeButton); const body = el('div', 'modal-body'); const field = el('div', 'field'); const textarea = el('textarea', 'prompt-modal-input'); textarea.rows = 14; field.appendChild(textarea); const previewLabel = el('label', 'prompt-preview-label', '参数预览'); const preview = el('pre', 'prompt-preview'); field.append(previewLabel, preview); textarea.addEventListener('input', updatePreview); body.appendChild(field); const footer = el('div', 'modal-footer'); const cancel = el('button', 'btn btn-ghost btn-small', '取消'); cancel.type = 'button'; const save = el('button', 'btn btn-primary btn-small', '保存提示词'); save.type = 'button'; footer.append(cancel, save); modal.append(header, body, footer); overlay.appendChild(modal); document.body.appendChild(overlay); let activePrompt = null; let previewVars = null; function updatePreview() { preview.hidden = !previewVars; previewLabel.hidden = !previewVars; if (previewVars) preview.textContent = fillTemplate(textarea.value, previewVars); } function closeModal() { overlay.hidden = true; activePrompt = null; previewVars = null; } closeButton.addEventListener('click', closeModal); overlay.addEventListener('click', (event) => { if (event.target === overlay) closeModal(); }); cancel.addEventListener('click', closeModal); save.addEventListener('click', () => { if (activePrompt) { activePrompt.value = textarea.value; activePrompt.dispatchEvent(new Event('input')); } closeModal(); }); overlay.addEventListener('keydown', (event) => { if (event.key === 'Escape') closeModal(); }); promptModal = { open(label, target, vars) { activePrompt = target; previewVars = vars; titleText.textContent = `${label} · 提示词`; textarea.value = target.value; updatePreview(); overlay.hidden = false; setTimeout(() => textarea.focus(), 0); }, }; return promptModal; } let batchDishModal = null; function ensureBatchDishModal() { if (batchDishModal) return batchDishModal; const overlay = el('div', 'modal-overlay batch-dish-modal-overlay'); overlay.hidden = true; const modal = el('div', 'modal batch-dish-modal'); const header = el('div', 'modal-header'); const title = el('div', 'modal-title'); const icon = el('span', 'modal-status-icon'); icon.innerHTML = ''; const titleText = el('span', '', '批量生成确认'); title.append(icon, titleText); const closeButton = el('button', 'modal-close'); closeButton.type = 'button'; closeButton.innerHTML = ''; header.append(title, closeButton); const body = el('div', 'modal-body'); const summary = el('div', 'batch-summary'); const controls = el('div', 'batch-controls'); controls.appendChild(el('div', 'batch-config-label', '叠加配置')); const field = el('div', 'field'); field.appendChild(el('label', 'batch-prompt-label', '公共提示词模板(生成时自动替换参数)')); const textarea = el('textarea', 'prompt-input batch-prompt'); field.appendChild(textarea); const previewLabel = el('label', 'batch-prompt-label', '参数预览(按首个勾选菜品渲染)'); const preview = el('pre', 'batch-prompt-preview'); field.append(previewLabel, preview); textarea.addEventListener('input', updatePreview); const guard = el('div', 'guard-hint'); guard.hidden = true; body.append(summary, controls, field, guard); const footer = el('div', 'modal-footer'); const cancel = el('button', 'btn btn-ghost btn-small', '取消'); const confirm = el('button', 'btn btn-primary btn-small', '确认批量生成'); footer.append(cancel, confirm); modal.append(header, body, footer); overlay.appendChild(modal); document.body.appendChild(overlay); let activeConfig = null; let overlayRow = null; let previewVars = null; function updatePreview() { preview.hidden = !previewVars; previewLabel.hidden = !previewVars; if (previewVars) preview.textContent = fillTemplate(textarea.value, previewVars); } function closeModal() { overlay.hidden = true; activeConfig = null; previewVars = null; } function refresh() { if (!activeConfig || !overlayRow) return; const options = overlayRow.get(); const missing = []; if (options.addLogo && !activeConfig.hasLogo()) missing.push('LOGO'); guard.hidden = !missing.length; guard.textContent = missing.length ? missing.map((item) => `未找到可用${item},请先生成`).join(';') : ''; confirm.disabled = !!missing.length; } closeButton.addEventListener('click', closeModal); cancel.addEventListener('click', closeModal); overlay.addEventListener('click', (event) => { if (event.target === overlay) closeModal(); }); overlay.addEventListener('keydown', (event) => { if (event.key === 'Escape') closeModal(); }); confirm.addEventListener('click', () => { if (!activeConfig || confirm.disabled) return; const callback = activeConfig.onConfirm; const options = overlayRow.get(); const prompt = textarea.value; closeModal(); callback(options, prompt); }); batchDishModal = { open({ count, config, prompt, shopName, dishName, hasLogo, onConfirm }) { activeConfig = { hasLogo, onConfirm }; summary.textContent = `以下公共配置将用于 ${count} 个勾选菜品`; controls.innerHTML = ''; overlayRow = buildOverlayRow(false, refresh, config, true); controls.appendChild(overlayRow.row); textarea.value = prompt; previewVars = { shopName, dishName }; updatePreview(); overlay.hidden = false; refresh(); }, }; return batchDishModal; } function fillTemplate(template, vars) { return template.replace(/\{(\w+)\}/g, (part, key) => vars[key] ?? part); } function dishNamesSummary(dishes) { const names = dishes.slice(0, 8).map((dish) => dish.name).join('、'); return dishes.length > 8 ? `${names} 等` : names; } function formatBytes(bytes) { return bytes ? `${(bytes / 1024 / 1024).toFixed(2)}MB` : ''; } function storageKey(key) { return `shanhui:gen:${key}:${state.root || 'default'}`; } function readStoredSet(key) { try { return new Set(JSON.parse(localStorage.getItem(storageKey(key)) || '[]')); } catch { return new Set(); } } function writeStoredSet(key, values) { try { localStorage.setItem(storageKey(key), JSON.stringify(Array.from(values))); } catch {} } function saveExpandedShops() { writeStoredSet('expanded', state.expandedShops); } function saveScanSelection() { writeStoredSet('scan-selection', state.scanSelection); } function getPromptTemplate(shop, kind) { return (shop.promptOverrides && shop.promptOverrides[kind]) || DEFAULT_PROMPTS[kind]; } function ensureHoverPreview() { if (hoverPreview) return hoverPreview; hoverPreview = el('div', 'hover-zoom-preview'); hoverPreview.appendChild(el('img')); document.body.appendChild(hoverPreview); return hoverPreview; } function positionHoverPreview(x, y) { const rect = hoverPreview.getBoundingClientRect(); const left = x + rect.width + 18 > innerWidth ? x - rect.width - 18 : x + 18; const top = y + rect.height + 18 > innerHeight ? y - rect.height - 18 : y + 18; hoverPreview.style.left = `${Math.max(4, left)}px`; hoverPreview.style.top = `${Math.max(4, top)}px`; } function attachHoverZoom(node, source, transparent) { node.addEventListener('mouseenter', (event) => { const preview = ensureHoverPreview(); preview.querySelector('img').src = source; preview.classList.toggle('transparent-bg', !!transparent); preview.style.display = 'block'; positionHoverPreview(event.clientX, event.clientY); }); node.addEventListener('mousemove', (event) => positionHoverPreview(event.clientX, event.clientY)); node.addEventListener('mouseleave', () => { if (hoverPreview) hoverPreview.style.display = 'none'; }); } window.shanHoverZoom = attachHoverZoom; function renderResult(container, input, context = {}) { container.innerHTML = ''; const resultUrl = input.resultUrl || input.url; if (input.state === 'queued' || input.state === 'running') { const line = el('div', 'status-line'); line.appendChild(el('span', 'spinner')); line.appendChild(el('span', '', input.state === 'queued' ? '排队中…' : `生成中… ${input.progress || ''}`)); container.appendChild(line); return; } if (input.state === 'failed') { container.appendChild(el('div', 'status-line is-error', input.error || '生成失败')); return; } if (input.state !== 'success' || !resultUrl) return; if (input.warning) container.appendChild(el('div', 'status-line is-warning', input.warning)); const preview = el('div', 'result-preview'); const holder = el('div', `result-thumb-holder${input.kind === 'sticker' ? ' transparent-bg' : ''}`); const image = el('img'); image.src = `${resultUrl}${resultUrl.includes('?') ? '&' : '?'}t=${Date.now()}`; image.alt = BRAND_META[input.kind] ? BRAND_META[input.kind].label : '生成结果'; holder.appendChild(image); attachHoverZoom(holder, image.src, input.kind === 'sticker'); const download = el('a', 'result-download-icon'); download.href = resultUrl; download.download = ''; download.title = '下载原图'; download.setAttribute('aria-label', '下载原图'); download.innerHTML = ICON_DOWNLOAD; holder.appendChild(download); preview.appendChild(holder); container.appendChild(preview); const cropSpec = CROP_SPECS[input.kind]; if (cropSpec && context.root) container.appendChild(buildPosterCropper(image.src, cropSpec, context, input.kind)); } function buildPosterCropper(source, spec, context, kind) { const panel = el('div', 'poster-crop-panel'); const status = el('div', 'status-line', `正在生成 ${spec.width}×${spec.height}px 平台图…`); const actions = el('div', 'crop-actions'); const download = el('a', 'btn btn-ghost btn-small', '下载平台尺寸'); const open = el('button', 'btn btn-ghost btn-small', '打开资源目录'); download.hidden = true; open.disabled = true; actions.append(download, open); panel.append(status, actions); const image = new Image(); image.crossOrigin = 'anonymous'; image.onload = () => { const canvas = el('canvas'); canvas.width = spec.width; canvas.height = spec.height; const ctx = canvas.getContext('2d'); const scale = Math.max(spec.width / image.naturalWidth, spec.height / image.naturalHeight); const width = image.naturalWidth * scale; const height = image.naturalHeight * scale; ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, spec.width, spec.height); ctx.drawImage(image, (spec.width - width) / 2, (spec.height - height) / 2, width, height); const dataUrl = canvas.toDataURL('image/png'); fetch('/api/save-poster-canvas', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ root: context.root, folderName: context.folderName, kind, imageDataUrl: dataUrl }), }).then(async (response) => { const data = await response.json(); if (!response.ok) throw new Error(data.error || '保存失败'); status.textContent = `${spec.label}已保存为平台尺寸`; download.href = dataUrl; download.download = spec.fileName; download.hidden = false; open.disabled = false; }).catch((error) => { status.textContent = `画板图保存失败:${error.message}`; }); }; image.onerror = () => { status.textContent = '无法载入原图,不能生成画板图。'; }; image.src = source; open.addEventListener('click', () => openShopFolder(context.folderName)); return panel; } function buildReferenceStrip(shop, kind) { const wrap = el('div', 'field reference-field'); wrap.appendChild(el('label', '', kind === 'sticker' ? '菜品参考图(最多 3 张,可空)' : '参考菜品图(默认前 3 张)')); const strip = el('div', 'ref-strip'); shop.dishes.forEach((dish, index) => { const label = el('label', `ref-thumb${kind !== 'sticker' && index < 3 ? ' checked' : ''}`); const image = el('img'); image.src = dish.url; image.alt = dish.name; image.loading = 'lazy'; attachHoverZoom(label, dish.url, false); const checkbox = el('input'); checkbox.type = 'checkbox'; checkbox.checked = kind !== 'sticker' && index < 3; checkbox.addEventListener('change', () => { if (checkbox.checked && strip.querySelectorAll('input:checked').length > 3) { checkbox.checked = false; showToast('参考图最多选择 3 张', 'error'); return; } label.classList.toggle('checked', checkbox.checked); }); label.append(image, checkbox); strip.appendChild(label); }); wrap.appendChild(strip); return wrap; } function buildOverlayRow(withSticker, onChange, initialState = {}, compactLabels = false) { const row = el('div', 'overlay-row'); const controls = []; const create = (labelText, checked, position, mode = 'ai') => { const label = el('label', 'checkbox-label'); const checkbox = el('input'); checkbox.type = 'checkbox'; checkbox.checked = checked; label.append(checkbox, el('span', '', labelText)); const positionSelect = select(POSITIONS, position); const modeSelect = select(OVERLAY_MODES, mode); [checkbox, positionSelect, modeSelect].forEach((control) => control.addEventListener('change', () => onChange(get()))); row.append(label, positionSelect, modeSelect); controls.push({ checkbox, positionSelect, modeSelect }); }; create(compactLabels ? '添加LOGO' : '添加店铺 LOGO', initialState.addLogo ?? true, initialState.logoPosition || 'bottom-right', initialState.logoMode || 'ai'); if (withSticker) create(compactLabels ? '添加贴纸' : '添加店铺贴纸', initialState.addSticker ?? false, initialState.stickerPosition || 'bottom-left', initialState.stickerMode || 'ai'); const get = () => ({ addLogo: controls[0].checkbox.checked, logoPosition: controls[0].positionSelect.value, logoMode: controls[0].modeSelect.value, addSticker: !!controls[1] && controls[1].checkbox.checked, stickerPosition: controls[1] ? controls[1].positionSelect.value : undefined, stickerMode: controls[1] ? controls[1].modeSelect.value : undefined, }); const set = (next = {}) => { controls[0].checkbox.checked = next.addLogo ?? true; controls[0].positionSelect.value = next.logoPosition || 'bottom-right'; controls[0].modeSelect.value = next.logoMode || 'ai'; if (controls[1]) { controls[1].checkbox.checked = !!next.addSticker; controls[1].positionSelect.value = next.stickerPosition || 'bottom-left'; controls[1].modeSelect.value = next.stickerMode || 'ai'; } }; return { row, get, set }; } function buildAssetPanel(shop, kind, shopNameInput, context) { const meta = BRAND_META[kind]; const panel = el('article', 'asset-panel'); const editor = el('div', 'asset-editor'); const resultHost = el('div', 'asset-result'); const head = el('div', 'asset-panel-head'); head.append(el('h4', '', meta.label), el('span', 'asset-spec', `${meta.minW ? `≥${meta.minW}×${meta.minH}px · ` : ''}支持 1K / 2K`)); editor.appendChild(head); const prompt = el('textarea', 'prompt-input', getPromptTemplate(shop, kind)); prompt.dataset.role = `prompt:${kind}`; prompt.hidden = true; editor.appendChild(prompt); const sizeSelect = select([{ value: '1K', label: '1K' }, { value: '2K', label: '2K' }], '1K'); const aspectSelect = select(ASPECT_OPTIONS.map((value) => ({ value, label: `比例 ${value}` })), meta.ratio); editor.appendChild(buildReferenceStrip(shop, kind)); let overlay = null; if (kind !== 'logo') { overlay = buildOverlayRow(false, () => refresh()); editor.appendChild(overlay.row); } const result = el('div', 'result-area'); const generated = shop.generated && shop.generated[kind]; if (generated) renderResult(result, { state: 'success', kind, ...generated }, context); else result.appendChild(el('div', 'result-empty', '尚未生成')); resultHost.appendChild(result); const button = el('button', 'btn btn-primary btn-small', generated ? '重新生成' : '生成'); const promptButton = el('button', 'btn btn-ghost btn-small', '编辑提示词'); promptButton.type = 'button'; const brandVars = () => ({ shopName: shopNameInput.value, dishNames: dishNamesSummary(shop.dishes) }); promptButton.addEventListener('click', () => ensurePromptModal().open(meta.label, prompt, brandVars())); const actions = el('div', 'asset-actions'); actions.append(sizeSelect, aspectSelect, promptButton, button); editor.appendChild(actions); const warning = el('div', 'guard-hint', '未找到可用LOGO,请先生成'); warning.hidden = true; editor.appendChild(warning); panel.append(editor, resultHost); const blocked = () => !!(overlay && overlay.get().addLogo && !context.hasLogo()); function refresh() { const isBlocked = blocked(); button.disabled = isBlocked; warning.hidden = !isBlocked; } refresh(); async function generate() { if (blocked()) return; const refs = Array.from(panel.querySelectorAll('.ref-thumb input:checked')).map((input) => input.value); const selects = panel.querySelectorAll('select.control'); button.disabled = true; renderResult(result, { state: 'queued', kind }); try { const response = await fetch('/api/generate/brand', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ root: state.root, folderName: shop.folderName, shopName: shopNameInput.value, kind, model: state.model, prompt: fillTemplate(prompt.value, brandVars()), size: selects[0].value, aspectRatio: selects[1].value, refDishFiles: refs, addLogo: overlay ? overlay.get().addLogo : false, logoPosition: overlay ? overlay.get().logoPosition : undefined, logoMode: overlay ? overlay.get().logoMode : undefined, }), }); const data = await response.json(); if (!response.ok) throw new Error(data.error || '提交失败'); trackJob(data.jobId, result, kind, (job) => { if (job.state === 'success') context.onGenerated(kind, job); }, context); } catch (error) { renderResult(result, { state: 'failed', kind, error: error.message }); } finally { refresh(); } } button.addEventListener('click', generate); return { el: panel, prompt, generate, blocked, refresh }; } function buildDishPanel(shop, dish, shopNameInput, context) { const card = el('article', 'dish-work-card'); const head = el('div', 'dish-work-head'); const checkbox = el('input'); checkbox.type = 'checkbox'; checkbox.checked = false; const nameInput = el('input', 'dish-name-input'); nameInput.value = dish.name; const button = el('button', 'btn btn-primary btn-small', shop.generated?.dishes?.[dish.name] ? '重新生成' : '生成'); head.append(checkbox, nameInput); card.appendChild(head); const frames = el('div', 'dish-work-frames'); const original = el('figure', 'dish-frame'); const originalImage = el('img'); originalImage.src = dish.url; originalImage.alt = `${dish.name} 原图`; originalImage.loading = 'lazy'; attachHoverZoom(originalImage, dish.url, false); original.append(originalImage, el('figcaption', '', '原图')); const resultFrame = el('figure', 'dish-frame result'); const resultArea = el('div', 'dish-result-area'); const existing = shop.generated?.dishes?.[dish.name]; if (existing) renderResult(resultArea, { state: 'success', kind: 'dish', ...existing }); else resultArea.appendChild(el('div', 'result-empty', '待生成')); resultFrame.append(resultArea, el('figcaption', '', '生成结果')); frames.append(original, resultFrame); card.appendChild(frames); const saved = (shop.promptOverrides?.dishes?.[dish.file]) || ''; const prompt = el('textarea', 'prompt-input dish-override', saved || context.sharedPrompt()); prompt.hidden = true; prompt.dataset.inherit = saved ? '0' : '1'; prompt.placeholder = '默认与批量公共提示词一致;修改后仅对本菜品生效'; prompt.dataset.role = `dish:${dish.file}`; prompt.addEventListener('input', () => { prompt.dataset.inherit = '0'; }); const initialOptions = context.defaultOverlayOptions(); const overlay = buildOverlayRow(false, () => refresh(), initialOptions, true); const promptStatus = el('span', 'dish-prompt-status inherit', saved ? '已单独编辑' : '默认公共提示词'); const promptButton = el('button', 'btn btn-ghost btn-small', '编辑提示词'); promptButton.type = 'button'; promptButton.addEventListener('click', () => ensurePromptModal().open(dish.name, prompt, { shopName: shopNameInput.value, dishName: nameInput.value, })); const config = el('div', 'dish-config'); const configHead = el('div', 'dish-config-head'); configHead.append(el('div', 'dish-config-title', '单品配置'), promptStatus); const configActions = el('div', 'dish-config-actions'); const configButtons = el('div', 'dish-config-buttons'); configButtons.append(promptButton, button); configActions.append(overlay.row, configButtons); config.append(configHead, configActions); const guard = el('div', 'guard-hint'); guard.hidden = true; config.appendChild(guard); card.appendChild(config); const blocked = () => overlay.get().addLogo && !context.hasLogo(); function refresh() { const isBlocked = blocked(); button.disabled = isBlocked; guard.hidden = !isBlocked; const missing = []; if (overlay.get().addLogo && !context.hasLogo()) missing.push('LOGO'); guard.textContent = missing.length ? missing.map((item) => `未找到可用${item},请先生成`).join(';') : ''; } refresh(); async function generate(batch) { const options = batch?.options || overlay.get(); const selectedPrompt = batch?.prompt || prompt.value.trim() || context.sharedPrompt(); const promptText = fillTemplate(selectedPrompt, { shopName: shopNameInput.value, dishName: nameInput.value }); if (!batch && blocked()) return; button.disabled = true; renderResult(resultArea, { state: 'queued', kind: 'dish' }); try { const response = await fetch('/api/generate/dish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ root: state.root, folderName: shop.folderName, shopName: shopNameInput.value, dishFile: dish.file, dishName: nameInput.value, model: state.model, prompt: promptText, size: '1K', ...options, }), }); const data = await response.json(); if (!response.ok) throw new Error(data.error || '提交失败'); trackJob(data.jobId, resultArea, 'dish', (job) => { if (job.state === 'success') recordGenerated('dish', job, nameInput.value); }); } catch (error) { renderResult(resultArea, { state: 'failed', kind: 'dish', error: error.message }); } finally { refresh(); } } button.addEventListener('click', generate); return { el: card, checkbox, prompt, generate, button, syncDefaultPrompt(value) { if (prompt.dataset.inherit === '1') prompt.value = value; }, }; } function buildStepHead(title, hint, action, appendix) { const head = el('div', 'step-head'); const text = el('div', 'step-head-text'); text.append(el('h3', '', title), el('span', '', hint)); head.append(text); if (action || appendix) { const actions = el('div', 'step-head-actions'); if (action) actions.appendChild(action); if (appendix) actions.appendChild(appendix); head.appendChild(actions); } return head; } function makeStepCollapsible(head, container, collapsed = false) { const toggle = el('button', 'step-toggle'); toggle.type = 'button'; toggle.title = '展开 / 收起'; toggle.setAttribute('aria-expanded', String(!collapsed)); toggle.innerHTML = ''; head.querySelector('.step-head-text').appendChild(toggle); container.classList.toggle('step-collapsed', collapsed); toggle.addEventListener('click', () => { const isCollapsed = container.classList.toggle('step-collapsed'); toggle.setAttribute('aria-expanded', String(!isCollapsed)); }); } function buildShopCard(shop) { const folder = shop.folderName; const shouldExpand = state.expandedShops.has(folder); const card = el('section', shouldExpand ? 'shop-card workflow-card' : 'shop-card workflow-card collapsed'); card.dataset.folder = folder; const generatedCount = BRAND_KINDS.filter((kind) => shop.generated?.[kind]).length + Object.keys(shop.generated?.dishes || {}).length; const totalCount = BRAND_KINDS.length + shop.dishes.length; const head = el('div', 'workflow-head'); const toggle = el('button', 'workflow-toggle'); toggle.type = 'button'; toggle.setAttribute('aria-expanded', String(shouldExpand)); toggle.title = '展开 / 收起'; toggle.innerHTML = ''; const info = el('div', 'workflow-info'); const shopNameInput = el('input', 'shop-name-input'); shopNameInput.value = shop.shopName; shopNameInput.dataset.role = 'shopName'; info.append(shopNameInput, el('span', 'workflow-folder', folder)); const actions = el('div', 'workflow-actions'); const save = el('button', 'btn btn-ghost btn-small', '保存提示词'); const open = el('button', 'btn btn-ghost btn-small', '打开目录'); actions.append(save, open); const countBadge = el('span', 'badge badge-info workflow-count', `${generatedCount}/${totalCount} 已生成`); head.append(toggle, info, countBadge, actions); card.appendChild(head); const body = el('div', 'workflow-body'); card.appendChild(body); let initialized = false; function initializeContent() { if (initialized) return; initialized = true; const context = { root: state.root, folderName: folder, hasLogo: () => !!(shop.generated?.logo), onGenerated: (kind, job) => { recordGenerated(kind, job); if (kind === 'logo') context.hasLogo = () => true; refreshWorkflow(); }, }; function recordGenerated(kind, job, dishName) { shop.generated = shop.generated || {}; const asset = { url: job?.resultUrl, width: job?.width, height: job?.height, fileSizeBytes: job?.fileSizeBytes }; if (dishName) { shop.generated.dishes = shop.generated.dishes || {}; shop.generated.dishes[dishName] = asset; } else shop.generated[kind] = asset; const nextCount = BRAND_KINDS.filter((item) => shop.generated?.[item]).length + Object.keys(shop.generated?.dishes || {}).length; countBadge.textContent = `${nextCount}/${totalCount} 已生成`; } const step1 = el('section', 'workflow-step'); const step1Head = buildStepHead('步骤 1 · LOGO生成', 'LOGO 是其他视觉资产与菜品图的叠加素材'); step1.appendChild(step1Head); const step1Body = el('div', 'step-body single'); const logoPanel = buildAssetPanel(shop, 'logo', shopNameInput, context); step1Body.appendChild(logoPanel.el); step1.appendChild(step1Body); makeStepCollapsible(step1Head, step1, true); body.appendChild(step1); const step2 = el('section', 'workflow-step'); const brandButton = el('button', 'btn btn-primary btn-small', '批量生成海报 + 招牌'); step2.appendChild(buildStepHead('步骤 2 · 其他视觉资产', '海报、招牌、商品自定义生成', brandButton)); const step2Body = el('div', 'step-body brand-columns'); const banner = buildAssetPanel(shop, 'banner', shopNameInput, context); const signage = buildAssetPanel(shop, 'signage', shopNameInput, context); const sticker = buildAssetPanel(shop, 'sticker', shopNameInput, context); step2Body.append(banner.el, signage.el, sticker.el); step2.appendChild(step2Body); body.appendChild(step2); const step3 = el('section', 'workflow-step'); const selectAll = el('label', 'checkbox-label compact'); const selectAllBox = el('input'); selectAllBox.type = 'checkbox'; selectAllBox.checked = false; selectAll.append(selectAllBox, el('span', '', '全选菜品')); const dishButton = el('button', 'btn btn-primary btn-small', '批量生成勾选菜品'); step3.appendChild(buildStepHead('步骤 3 · 菜品商品图', '原图与生成结果分开预览', selectAll, dishButton)); const overlay = buildOverlayRow(false, () => refreshWorkflow()); const sharedPrompt = el('textarea', 'prompt-input', shop.promptOverrides?.dishTemplate || DEFAULT_PROMPTS.dish); sharedPrompt.hidden = true; sharedPrompt.dataset.role = 'prompt:dishTemplate'; overlay.row.hidden = true; step3.append(overlay.row, sharedPrompt); context.defaultOverlayOptions = overlay.get; context.sharedPrompt = () => sharedPrompt.value; const dishBody = el('div', 'dish-work-grid'); const dishPanels = shop.dishes.map((dish) => buildDishPanel(shop, dish, shopNameInput, context)); dishPanels.forEach((panel) => dishBody.appendChild(panel.el)); step3.appendChild(dishBody); body.appendChild(step3); function refreshWorkflow() { [banner, signage, sticker].forEach((panel) => panel.refresh()); } selectAllBox.addEventListener('change', () => dishPanels.forEach((panel) => { panel.checkbox.checked = selectAllBox.checked; })); dishButton.addEventListener('click', () => { const selectedPanels = dishPanels.filter((panel) => panel.checkbox.checked); if (!selectedPanels.length) return showToast('请先勾选要生成的菜品', 'error'); ensureBatchDishModal().open({ count: selectedPanels.length, config: overlay.get(), prompt: sharedPrompt.value, shopName: shop.shopName, dishName: selectedPanels[0].el.querySelector('.dish-name-input').value, hasLogo: context.hasLogo, onConfirm: (options, prompt) => { overlay.set(options); sharedPrompt.value = prompt; selectedPanels.forEach((panel) => panel.generate({ options, prompt })); }, }); }); brandButton.addEventListener('click', () => { if (banner.blocked() || signage.blocked()) return; banner.generate(); signage.generate(); }); save.addEventListener('click', async () => { const prompts = { logo: logoPanel.prompt.value, banner: banner.prompt.value, signage: signage.prompt.value, sticker: sticker.prompt.value, dishTemplate: sharedPrompt.value, dishes: {} }; dishPanels.forEach((panel, index) => { const value = panel.prompt.value.trim(); if (value && panel.prompt.dataset.inherit !== '1') prompts.dishes[shop.dishes[index].file] = value; }); const response = await fetch('/api/save-config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ root: state.root, folderName, shopName: shopNameInput.value, prompts }), }); const data = await response.json().catch(() => ({})); if (!response.ok) return showToast(data.error || '保存失败', 'error'); showToast(`已保存「${shopNameInput.value}」配置`); }); refreshWorkflow(); sharedPrompt.addEventListener('input', () => { dishPanels.forEach((panel) => panel.syncDefaultPrompt(sharedPrompt.value)); }); } toggle.addEventListener('click', () => { const collapsed = card.classList.toggle('collapsed'); toggle.setAttribute('aria-expanded', String(!collapsed)); if (collapsed) state.expandedShops.delete(folder); else state.expandedShops.add(folder); saveExpandedShops(); if (!collapsed) initializeContent(); }); open.addEventListener('click', () => openShopFolder(folder)); save.addEventListener('click', () => { if (!initialized) initializeContent(); }); if (shouldExpand) initializeContent(); return card; } async function openShopFolder(folderName) { const response = await fetch('/api/open-folder', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ root: state.root, folderName }), }); const data = await response.json().catch(() => ({})); if (!response.ok) showToast(data.error || '打开目录失败', 'error'); } function trackJob(jobId, container, kind, done, context) { activeJobs.set(jobId, { state: 'queued', cost: 0 }); updateStats(); const tick = async () => { try { const response = await fetch(`/api/job/${jobId}`); const job = await response.json(); activeJobs.set(jobId, { state: job.state, cost: job.cost || 0 }); renderResult(container, { kind, ...job }, context); if (job.state === 'queued' || job.state === 'running') { setTimeout(tick, 3000); return; } activeJobs.delete(jobId); updateStats(); if (job.state === 'success') showToast(`${job.dishName || BRAND_META[kind]?.label || '作品'}生成完成`); else if (job.state === 'failed') showToast(`${job.dishName || BRAND_META[kind]?.label || '作品'}生成失败:${job.error}`, 'error'); if (done) done(job); } catch { setTimeout(tick, 4000); } }; tick(); } function updateStats() { let done = 0, pending = 0, failed = 0, total = 0; for (const shop of state.shops) { BRAND_KINDS.forEach((kind) => { total++; if (shop.generated?.[kind]) done++; else pending++; }); shop.dishes.forEach((dish) => { total++; if (shop.generated?.dishes?.[dish.name]) done++; else pending++; }); } let running = 0, cost = 0; activeJobs.forEach((job) => { if (job.state === 'queued' || job.state === 'running') running++; if (job.state === 'failed') failed++; cost += job.cost || 0; }); document.getElementById('statShops').textContent = state.shops.length; document.getElementById('statDone').innerHTML = `${done}张`; document.getElementById('statPending').innerHTML = `${pending}张`; document.getElementById('statFail').innerHTML = `${failed}项`; document.getElementById('statShopsDelta').textContent = `运行中 ${running} · 消耗 ${cost.toFixed(2)}`; document.getElementById('statDoneDelta').textContent = total ? `完成率 ${Math.round(done / total * 100)}%` : ''; document.getElementById('statPendingDelta').textContent = ''; document.getElementById('statFailDelta').textContent = ''; } function captureCurrentEdits() { const edits = {}; document.querySelectorAll('.workflow-card').forEach((card) => { const folder = card.dataset.folder; edits[folder] = { shopName: card.querySelector('[data-role="shopName"]')?.value, prompts: { dishes: {} } }; card.querySelectorAll('[data-role]').forEach((node) => { const [type, key] = node.dataset.role.split(':'); if (type === 'prompt' && key) edits[folder].prompts[key] = node.value; if (type === 'dish' && key && node.dataset.inherit !== '1') edits[folder].prompts.dishes[key] = node.value; }); }); return edits; } function renderShops() { const list = document.getElementById('shopList'); list.innerHTML = ''; if (!state.shops.length) { list.innerHTML = '
未找到店铺文件夹
请在「系统设置 / 采集配置」检查输出根目录,文件夹内需包含菜品图片