| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960 |
- '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 = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>';
- const ICON_DOWNLOAD = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12"/><path d="M7 10l5 5 5-5"/><path d="M4 20h16"/></svg>';
- 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 = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6h16M4 12h10M4 18h7"/></svg>';
- const titleText = el('span', '', '');
- title.append(icon, titleText);
- const closeButton = el('button', 'modal-close');
- closeButton.type = 'button';
- closeButton.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>';
- 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 = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>';
- const titleText = el('span', '', '批量生成确认');
- title.append(icon, titleText);
- const closeButton = el('button', 'modal-close');
- closeButton.type = 'button';
- closeButton.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>';
- 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';
- 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 = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>';
- 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 = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>';
- 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}<small>张</small>`;
- document.getElementById('statPending').innerHTML = `${pending}<small>张</small>`;
- document.getElementById('statFail').innerHTML = `${failed}<small>项</small>`;
- 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 = '<div class="empty-hint"><div class="empty-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 20V9l8-5 8 5v11"/><path d="M9 20v-6h6v6"/></svg></div><p>未找到店铺文件夹</p><small>请在「系统设置 / 采集配置」检查输出根目录,文件夹内需包含菜品图片</small></div>';
- } else state.shops.forEach((shop) => list.appendChild(buildShopCard(shop)));
- updateStats();
- }
- function renderScanSelection() {
- const menu = document.getElementById('scanDropdownMenu');
- menu.querySelectorAll('[data-value]').forEach((node) => {
- const selected = state.scanSelection.has(node.dataset.value);
- node.classList.toggle('selected', selected); node.setAttribute('aria-selected', String(selected));
- });
- const folders = Array.from(state.scanSelection).filter((value) => value !== '__all__');
- document.getElementById('scanDropdownText').textContent = folders.length ? `已选 ${folders.length} 家门店` : '全量扫描';
- document.getElementById('scanDropdownTrigger').classList.toggle('active', folders.length > 0);
- }
- function updateScanOptions() {
- const menu = document.getElementById('scanDropdownMenu');
- const available = new Set(['__all__', ...state.allShops.map((shop) => shop.folderName)]);
- state.scanSelection.forEach((value) => { if (!available.has(value)) state.scanSelection.delete(value); });
- if (!state.scanSelection.size) state.scanSelection.add('__all__');
- menu.innerHTML = '';
- const all = el('button', 'scan-dropdown-item'); all.type = 'button'; all.dataset.value = '__all__'; all.setAttribute('role', 'option');
- all.innerHTML = '<span class="scan-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg></span><span>全量扫描</span>';
- menu.appendChild(all);
- state.allShops.forEach((shop) => {
- const item = el('button', 'scan-dropdown-item'); item.type = 'button'; item.dataset.value = shop.folderName; item.setAttribute('role', 'option');
- item.innerHTML = '<span class="scan-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg></span><span></span>';
- item.lastElementChild.textContent = shop.shopName;
- menu.appendChild(item);
- });
- renderScanSelection();
- }
- function toggleScanDropdown(open) {
- const trigger = document.getElementById('scanDropdownTrigger');
- const menu = document.getElementById('scanDropdownMenu');
- const shouldOpen = open === undefined ? menu.hidden : open;
- menu.hidden = !shouldOpen; trigger.setAttribute('aria-expanded', String(shouldOpen));
- }
- function handleScanDropdownClick(event) {
- const item = event.target.closest('[data-value]'); if (!item) return;
- const value = item.dataset.value;
- if (value === '__all__') state.scanSelection = new Set(['__all__']);
- else {
- state.scanSelection.delete('__all__');
- if (state.scanSelection.has(value)) state.scanSelection.delete(value); else state.scanSelection.add(value);
- if (!state.scanSelection.size) state.scanSelection.add('__all__');
- }
- renderScanSelection();
- saveScanSelection();
- }
- async function scan() {
- if (!state.root) return;
- const edits = captureCurrentEdits();
- const selected = Array.from(state.scanSelection).filter((value) => value !== '__all__');
- const scanAll = !selected.length;
- const button = document.getElementById('scanBtn');
- button.disabled = true;
- document.getElementById('scanIcon').innerHTML = '<span class="spinner spinner-inline"></span>';
- document.getElementById('scanText').textContent = '扫描中…';
- try {
- let url = `/api/scan?root=${encodeURIComponent(state.root)}`;
- if (!scanAll) url += `&folders=${encodeURIComponent(JSON.stringify(selected))}`;
- const response = await fetch(url);
- const data = await response.json();
- if (!response.ok) throw new Error(data.error || '扫描失败');
- data.shops.forEach((shop) => {
- const edit = edits[shop.folderName];
- if (edit) {
- if (edit.shopName) shop.shopName = edit.shopName;
- shop.promptOverrides = { ...(shop.promptOverrides || {}), ...(edit.prompts || {}) };
- }
- });
- if (scanAll) state.allShops = data.shops;
- state.shops = data.shops;
- updateScanOptions(); renderShops();
- showToast(scanAll ? `扫描完成,找到 ${data.shops.length} 家店铺` : `扫描完成,已更新 ${data.shops.length} 家门店`);
- } catch (error) { showToast(`扫描失败:${error.message}`, 'error'); }
- button.disabled = false;
- document.getElementById('scanIcon').innerHTML = ICON_PLAY;
- document.getElementById('scanText').textContent = '扫描店铺';
- }
- window.shanGenInit = async function () {
- if (!state.genInitialized) {
- state.genInitialized = true;
- document.getElementById('scanBtn').addEventListener('click', scan);
- document.getElementById('scanDropdownTrigger').addEventListener('click', () => toggleScanDropdown());
- document.getElementById('scanDropdownMenu').addEventListener('click', handleScanDropdownClick);
- document.getElementById('scanDropdownTrigger').addEventListener('keydown', (event) => {
- if (event.key === 'Escape') { toggleScanDropdown(false); event.currentTarget.focus(); }
- });
- document.addEventListener('click', (event) => { if (!document.getElementById('scanDropdown').contains(event.target)) toggleScanDropdown(false); });
- document.getElementById('btnRefreshGen').addEventListener('click', () => { if (state.root) scan(); });
- try {
- const response = await fetch('/api/settings');
- const settings = await response.json();
- state.model = settings.model || '';
- } catch {}
- }
- const response = await fetch('/api/default-root');
- const data = await response.json();
- const changed = !state.root || state.root !== data.root;
- state.root = data.root;
- state.expandedShops = readStoredSet('expanded');
- state.scanSelection = readStoredSet('scan-selection');
- if (!state.scanSelection.size) state.scanSelection.add('__all__');
- if (changed) scan();
- };
- })();
|