gen.js 48 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960
  1. 'use strict';
  2. (function () {
  3. const BRAND_KINDS = ['logo', 'banner', 'signage', 'sticker'];
  4. const BRAND_META = {
  5. logo: { label: '店铺 LOGO', ratio: '1:1', minW: 260, minH: 260 },
  6. banner: { label: '店内海报', ratio: '21:9', minW: 1138, minH: 292 },
  7. signage: { label: '招牌头图', ratio: '21:9', minW: 750, minH: 288 },
  8. sticker: { label: '商品自定义生成', ratio: '1:1', minW: 0, minH: 0 },
  9. };
  10. const CROP_SPECS = {
  11. banner: { label: '店内海报', width: 1138, height: 292, fileName: '店内海报_1138x292.png' },
  12. signage: { label: '招牌头图', width: 750, height: 288, fileName: '招牌头图_750x288.png' },
  13. };
  14. const POSITIONS = [
  15. { value: 'bottom-right', label: '右下角' },
  16. { value: 'bottom-left', label: '左下角' },
  17. { value: 'top-right', label: '右上角' },
  18. { value: 'top-left', label: '左上角' },
  19. ];
  20. const OVERLAY_MODES = [{ value: 'ai', label: 'AI 融合' }, { value: 'composite', label: '本地合成' }];
  21. const ASPECT_OPTIONS = ['auto', '1:1', '4:3', '3:4', '16:9', '9:16', '3:2', '2:3', '21:9'];
  22. const DEFAULT_PROMPTS = {
  23. logo: `为『{shopName}』设计一个可直接用于外卖门店、小程序和商品列表的品牌LOGO图标。
  24. 目标:形成高识别度、可缩放、可长期使用的餐饮品牌符号。
  25. 品牌信息:主营『{dishNames}』,视觉气质应温暖、有食欲、专业可信。
  26. 构图:主体居中,轮廓清晰,正负空间干净;避免复杂细节和高光渐变堆叠。
  27. 配色:使用不超过3组主色,优先结合主营品类和参考图的色彩关系。
  28. 背景:纯色或极简背景,保证在浅色、深色和小尺寸场景中都易读。
  29. 禁止:照片质感、二维码、网址、联系电话、竞对品牌、复杂装饰、无意义图形。`,
  30. banner: `为『{shopName}』设计一张店内促销海报,最终画面是1138:292超宽横幅。
  31. 目标:快速传达品牌氛围和核心品类,适配外卖门店头图或店内宣传位。
  32. 主体:选择最具食欲感的核心菜品作为视觉焦点,搭配『{shopName}』的品牌色和简约装饰元素。
  33. 构图:重要内容集中在中央横向安全区,左右和上下保留呼吸空间;关键元素远离裁切边缘。
  34. 视觉:商业美食摄影质感,光线干净,色彩有层次但不杂乱;保持品牌识别统一。
  35. 文字:少量店名或品类文字可出现,必须清晰简洁;不堆叠标题、卖点和小字。
  36. 禁止:二维码、网址、联系方式、竞对品牌、低质素材拼贴、涉黄或政策风险内容。`,
  37. signage: `为『{shopName}』设计一张门店招牌头图,突出餐饮门店的专业感和主营『{dishNames}』。
  38. 目标:让顾客第一眼理解品类、品牌调性和门店氛围。
  39. 构图:主视觉居中或遵循稳定的三分法,保留品牌识别区域;招牌元素不要顶边或被裁切。
  40. 视觉:结合门头光感、简洁材质、品牌色和有食欲的菜品焦点,画面干净、有层次、可信。
  41. 品牌:统一使用传入的品牌色和店铺LOGO;若未提供,则预留清晰的招牌识别空间。
  42. 禁止:杂乱街景、多余行人、二维码、网址、联系电话、竞对品牌、低质感文字和涉险内容。`,
  43. sticker: `参考所选菜品实拍图,为『{shopName}』生成一张商品自定义展示图。
  44. 目标:保留原图菜品的真实识别度,同时提升电商陈列的精致感和食欲感。
  45. 主体:菜品形态、分量、主要配料和烹饪状态必须与原图一致,不做夸大替换。
  46. 构图:主体居中,边缘留白适中,高光和阴影自然;适合商品列表小图浏览。
  47. 视觉:使用与店铺LOGO色彩一致的浅色背景,呈现干净、统一、专业的商品氛围。
  48. 禁止:改变菜品真实卖点、添加旧LOGO、水印、二维码、网址、无关食材或过度滤镜。`,
  49. dish: `参考这张菜品实拍图,为『{shopName}』生成商品图:『{dishName}』。
  50. 主体:保持菜品的品类、形态、分量、配料和烹饪状态真实一致,突出自然食欲感。
  51. 旧标识处理:识别并彻底移除原图中的旧LOGO、水印、店铺名、徽章;自然修复背景,不复制、不模仿。
  52. 品牌叠加:若提供店铺指定LOGO,只使用传入LOGO,保持其图形、文字、配色和比例完整清晰。
  53. 构图:主体居中,视角稳定,背景干净,光影真实;适合商品列表和详情页展示。
  54. 背景:使用与店铺LOGO色彩一致的浅色背景,保持整店商品视觉统一。
  55. 禁止:替换关键食材、夸张失真、杂乱道具、旧标识残留、二维码、网址和无关文字。`,
  56. };
  57. const ICON_PLAY = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>';
  58. 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>';
  59. const state = { root: '', allShops: [], shops: [], model: '', scanSelection: new Set(['__all__']), expandedShops: new Set(), genInitialized: false };
  60. const activeJobs = new Map();
  61. let hoverPreview = null;
  62. function el(tag, className, text) {
  63. const node = document.createElement(tag);
  64. if (className) node.className = className;
  65. if (text !== undefined) node.textContent = text;
  66. return node;
  67. }
  68. function select(options, value, className = 'control') {
  69. const node = el('select', className);
  70. options.forEach((item) => {
  71. const option = el('option', '', item.label);
  72. option.value = item.value;
  73. option.selected = item.value === value;
  74. node.appendChild(option);
  75. });
  76. return node;
  77. }
  78. function showToast(message, type) {
  79. const wrap = document.getElementById('toast-wrap');
  80. const node = el('div', `toast toast-${type || 'success'}`, message);
  81. wrap.appendChild(node);
  82. setTimeout(() => { node.style.opacity = '0'; node.style.transition = 'opacity .3s'; }, 2600);
  83. setTimeout(() => { if (node.parentNode) node.remove(); }, 3000);
  84. }
  85. let promptModal = null;
  86. function ensurePromptModal() {
  87. if (promptModal) return promptModal;
  88. const overlay = el('div', 'modal-overlay prompt-modal-overlay');
  89. overlay.id = 'promptModal';
  90. overlay.hidden = true;
  91. const modal = el('div', 'modal prompt-modal');
  92. const header = el('div', 'modal-header');
  93. const title = el('div', 'modal-title');
  94. const icon = el('span', 'modal-status-icon');
  95. 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>';
  96. const titleText = el('span', '', '');
  97. title.append(icon, titleText);
  98. const closeButton = el('button', 'modal-close');
  99. closeButton.type = 'button';
  100. 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>';
  101. header.append(title, closeButton);
  102. const body = el('div', 'modal-body');
  103. const field = el('div', 'field');
  104. const textarea = el('textarea', 'prompt-modal-input');
  105. textarea.rows = 14;
  106. field.appendChild(textarea);
  107. const previewLabel = el('label', 'prompt-preview-label', '参数预览');
  108. const preview = el('pre', 'prompt-preview');
  109. field.append(previewLabel, preview);
  110. textarea.addEventListener('input', updatePreview);
  111. body.appendChild(field);
  112. const footer = el('div', 'modal-footer');
  113. const cancel = el('button', 'btn btn-ghost btn-small', '取消');
  114. cancel.type = 'button';
  115. const save = el('button', 'btn btn-primary btn-small', '保存提示词');
  116. save.type = 'button';
  117. footer.append(cancel, save);
  118. modal.append(header, body, footer);
  119. overlay.appendChild(modal);
  120. document.body.appendChild(overlay);
  121. let activePrompt = null;
  122. let previewVars = null;
  123. function updatePreview() {
  124. preview.hidden = !previewVars;
  125. previewLabel.hidden = !previewVars;
  126. if (previewVars) preview.textContent = fillTemplate(textarea.value, previewVars);
  127. }
  128. function closeModal() {
  129. overlay.hidden = true;
  130. activePrompt = null;
  131. previewVars = null;
  132. }
  133. closeButton.addEventListener('click', closeModal);
  134. overlay.addEventListener('click', (event) => { if (event.target === overlay) closeModal(); });
  135. cancel.addEventListener('click', closeModal);
  136. save.addEventListener('click', () => {
  137. if (activePrompt) {
  138. activePrompt.value = textarea.value;
  139. activePrompt.dispatchEvent(new Event('input'));
  140. }
  141. closeModal();
  142. });
  143. overlay.addEventListener('keydown', (event) => {
  144. if (event.key === 'Escape') closeModal();
  145. });
  146. promptModal = {
  147. open(label, target, vars) {
  148. activePrompt = target;
  149. previewVars = vars;
  150. titleText.textContent = `${label} · 提示词`;
  151. textarea.value = target.value;
  152. updatePreview();
  153. overlay.hidden = false;
  154. setTimeout(() => textarea.focus(), 0);
  155. },
  156. };
  157. return promptModal;
  158. }
  159. let batchDishModal = null;
  160. function ensureBatchDishModal() {
  161. if (batchDishModal) return batchDishModal;
  162. const overlay = el('div', 'modal-overlay batch-dish-modal-overlay');
  163. overlay.hidden = true;
  164. const modal = el('div', 'modal batch-dish-modal');
  165. const header = el('div', 'modal-header');
  166. const title = el('div', 'modal-title');
  167. const icon = el('span', 'modal-status-icon');
  168. 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>';
  169. const titleText = el('span', '', '批量生成确认');
  170. title.append(icon, titleText);
  171. const closeButton = el('button', 'modal-close');
  172. closeButton.type = 'button';
  173. 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>';
  174. header.append(title, closeButton);
  175. const body = el('div', 'modal-body');
  176. const summary = el('div', 'batch-summary');
  177. const controls = el('div', 'batch-controls');
  178. controls.appendChild(el('div', 'batch-config-label', '叠加配置'));
  179. const field = el('div', 'field');
  180. field.appendChild(el('label', 'batch-prompt-label', '公共提示词模板(生成时自动替换参数)'));
  181. const textarea = el('textarea', 'prompt-input batch-prompt');
  182. field.appendChild(textarea);
  183. const previewLabel = el('label', 'batch-prompt-label', '参数预览(按首个勾选菜品渲染)');
  184. const preview = el('pre', 'batch-prompt-preview');
  185. field.append(previewLabel, preview);
  186. textarea.addEventListener('input', updatePreview);
  187. const guard = el('div', 'guard-hint');
  188. guard.hidden = true;
  189. body.append(summary, controls, field, guard);
  190. const footer = el('div', 'modal-footer');
  191. const cancel = el('button', 'btn btn-ghost btn-small', '取消');
  192. const confirm = el('button', 'btn btn-primary btn-small', '确认批量生成');
  193. footer.append(cancel, confirm);
  194. modal.append(header, body, footer);
  195. overlay.appendChild(modal);
  196. document.body.appendChild(overlay);
  197. let activeConfig = null;
  198. let overlayRow = null;
  199. let previewVars = null;
  200. function updatePreview() {
  201. preview.hidden = !previewVars;
  202. previewLabel.hidden = !previewVars;
  203. if (previewVars) preview.textContent = fillTemplate(textarea.value, previewVars);
  204. }
  205. function closeModal() { overlay.hidden = true; activeConfig = null; previewVars = null; }
  206. function refresh() {
  207. if (!activeConfig || !overlayRow) return;
  208. const options = overlayRow.get();
  209. const missing = [];
  210. if (options.addLogo && !activeConfig.hasLogo()) missing.push('LOGO');
  211. guard.hidden = !missing.length;
  212. guard.textContent = missing.length ? missing.map((item) => `未找到可用${item},请先生成`).join(';') : '';
  213. confirm.disabled = !!missing.length;
  214. }
  215. closeButton.addEventListener('click', closeModal);
  216. cancel.addEventListener('click', closeModal);
  217. overlay.addEventListener('click', (event) => { if (event.target === overlay) closeModal(); });
  218. overlay.addEventListener('keydown', (event) => { if (event.key === 'Escape') closeModal(); });
  219. confirm.addEventListener('click', () => {
  220. if (!activeConfig || confirm.disabled) return;
  221. const callback = activeConfig.onConfirm;
  222. const options = overlayRow.get();
  223. const prompt = textarea.value;
  224. closeModal();
  225. callback(options, prompt);
  226. });
  227. batchDishModal = {
  228. open({ count, config, prompt, shopName, dishName, hasLogo, onConfirm }) {
  229. activeConfig = { hasLogo, onConfirm };
  230. summary.textContent = `以下公共配置将用于 ${count} 个勾选菜品`;
  231. controls.innerHTML = '';
  232. overlayRow = buildOverlayRow(false, refresh, config, true);
  233. controls.appendChild(overlayRow.row);
  234. textarea.value = prompt;
  235. previewVars = { shopName, dishName };
  236. updatePreview();
  237. overlay.hidden = false;
  238. refresh();
  239. },
  240. };
  241. return batchDishModal;
  242. }
  243. function fillTemplate(template, vars) { return template.replace(/\{(\w+)\}/g, (part, key) => vars[key] ?? part); }
  244. function dishNamesSummary(dishes) {
  245. const names = dishes.slice(0, 8).map((dish) => dish.name).join('、');
  246. return dishes.length > 8 ? `${names} 等` : names;
  247. }
  248. function formatBytes(bytes) { return bytes ? `${(bytes / 1024 / 1024).toFixed(2)}MB` : ''; }
  249. function storageKey(key) { return `shanhui:gen:${key}:${state.root || 'default'}`; }
  250. function readStoredSet(key) {
  251. try { return new Set(JSON.parse(localStorage.getItem(storageKey(key)) || '[]')); }
  252. catch { return new Set(); }
  253. }
  254. function writeStoredSet(key, values) {
  255. try { localStorage.setItem(storageKey(key), JSON.stringify(Array.from(values))); }
  256. catch {}
  257. }
  258. function saveExpandedShops() { writeStoredSet('expanded', state.expandedShops); }
  259. function saveScanSelection() { writeStoredSet('scan-selection', state.scanSelection); }
  260. function getPromptTemplate(shop, kind) {
  261. return (shop.promptOverrides && shop.promptOverrides[kind]) || DEFAULT_PROMPTS[kind];
  262. }
  263. function ensureHoverPreview() {
  264. if (hoverPreview) return hoverPreview;
  265. hoverPreview = el('div', 'hover-zoom-preview');
  266. hoverPreview.appendChild(el('img'));
  267. document.body.appendChild(hoverPreview);
  268. return hoverPreview;
  269. }
  270. function positionHoverPreview(x, y) {
  271. const rect = hoverPreview.getBoundingClientRect();
  272. const left = x + rect.width + 18 > innerWidth ? x - rect.width - 18 : x + 18;
  273. const top = y + rect.height + 18 > innerHeight ? y - rect.height - 18 : y + 18;
  274. hoverPreview.style.left = `${Math.max(4, left)}px`;
  275. hoverPreview.style.top = `${Math.max(4, top)}px`;
  276. }
  277. function attachHoverZoom(node, source, transparent) {
  278. node.addEventListener('mouseenter', (event) => {
  279. const preview = ensureHoverPreview();
  280. preview.querySelector('img').src = source;
  281. preview.classList.toggle('transparent-bg', !!transparent);
  282. preview.style.display = 'block';
  283. positionHoverPreview(event.clientX, event.clientY);
  284. });
  285. node.addEventListener('mousemove', (event) => positionHoverPreview(event.clientX, event.clientY));
  286. node.addEventListener('mouseleave', () => { if (hoverPreview) hoverPreview.style.display = 'none'; });
  287. }
  288. window.shanHoverZoom = attachHoverZoom;
  289. function renderResult(container, input, context = {}) {
  290. container.innerHTML = '';
  291. const resultUrl = input.resultUrl || input.url;
  292. if (input.state === 'queued' || input.state === 'running') {
  293. const line = el('div', 'status-line');
  294. line.appendChild(el('span', 'spinner'));
  295. line.appendChild(el('span', '', input.state === 'queued' ? '排队中…' : `生成中… ${input.progress || ''}`));
  296. container.appendChild(line);
  297. return;
  298. }
  299. if (input.state === 'failed') {
  300. container.appendChild(el('div', 'status-line is-error', input.error || '生成失败'));
  301. return;
  302. }
  303. if (input.state !== 'success' || !resultUrl) return;
  304. if (input.warning) container.appendChild(el('div', 'status-line is-warning', input.warning));
  305. const preview = el('div', 'result-preview');
  306. const holder = el('div', `result-thumb-holder${input.kind === 'sticker' ? ' transparent-bg' : ''}`);
  307. const image = el('img');
  308. image.src = `${resultUrl}${resultUrl.includes('?') ? '&' : '?'}t=${Date.now()}`;
  309. image.alt = BRAND_META[input.kind] ? BRAND_META[input.kind].label : '生成结果';
  310. holder.appendChild(image);
  311. attachHoverZoom(holder, image.src, input.kind === 'sticker');
  312. const download = el('a', 'result-download-icon');
  313. download.href = resultUrl;
  314. download.download = '';
  315. download.title = '下载原图';
  316. download.setAttribute('aria-label', '下载原图');
  317. download.innerHTML = ICON_DOWNLOAD;
  318. holder.appendChild(download);
  319. preview.appendChild(holder);
  320. container.appendChild(preview);
  321. const cropSpec = CROP_SPECS[input.kind];
  322. if (cropSpec && context.root) container.appendChild(buildPosterCropper(image.src, cropSpec, context, input.kind));
  323. }
  324. function buildPosterCropper(source, spec, context, kind) {
  325. const panel = el('div', 'poster-crop-panel');
  326. const status = el('div', 'status-line', `正在生成 ${spec.width}×${spec.height}px 平台图…`);
  327. const actions = el('div', 'crop-actions');
  328. const download = el('a', 'btn btn-ghost btn-small', '下载平台尺寸');
  329. const open = el('button', 'btn btn-ghost btn-small', '打开资源目录');
  330. download.hidden = true; open.disabled = true;
  331. actions.append(download, open);
  332. panel.append(status, actions);
  333. const image = new Image();
  334. image.crossOrigin = 'anonymous';
  335. image.onload = () => {
  336. const canvas = el('canvas');
  337. canvas.width = spec.width; canvas.height = spec.height;
  338. const ctx = canvas.getContext('2d');
  339. const scale = Math.max(spec.width / image.naturalWidth, spec.height / image.naturalHeight);
  340. const width = image.naturalWidth * scale;
  341. const height = image.naturalHeight * scale;
  342. ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, spec.width, spec.height);
  343. ctx.drawImage(image, (spec.width - width) / 2, (spec.height - height) / 2, width, height);
  344. const dataUrl = canvas.toDataURL('image/png');
  345. fetch('/api/save-poster-canvas', {
  346. method: 'POST', headers: { 'Content-Type': 'application/json' },
  347. body: JSON.stringify({ root: context.root, folderName: context.folderName, kind, imageDataUrl: dataUrl }),
  348. }).then(async (response) => {
  349. const data = await response.json();
  350. if (!response.ok) throw new Error(data.error || '保存失败');
  351. status.textContent = `${spec.label}已保存为平台尺寸`;
  352. download.href = dataUrl; download.download = spec.fileName; download.hidden = false; open.disabled = false;
  353. }).catch((error) => { status.textContent = `画板图保存失败:${error.message}`; });
  354. };
  355. image.onerror = () => { status.textContent = '无法载入原图,不能生成画板图。'; };
  356. image.src = source;
  357. open.addEventListener('click', () => openShopFolder(context.folderName));
  358. return panel;
  359. }
  360. function buildReferenceStrip(shop, kind) {
  361. const wrap = el('div', 'field reference-field');
  362. wrap.appendChild(el('label', '', kind === 'sticker' ? '菜品参考图(最多 3 张,可空)' : '参考菜品图(默认前 3 张)'));
  363. const strip = el('div', 'ref-strip');
  364. shop.dishes.forEach((dish, index) => {
  365. const label = el('label', `ref-thumb${kind !== 'sticker' && index < 3 ? ' checked' : ''}`);
  366. const image = el('img'); image.src = dish.url; image.alt = dish.name; image.loading = 'lazy';
  367. attachHoverZoom(label, dish.url, false);
  368. const checkbox = el('input'); checkbox.type = 'checkbox'; checkbox.checked = kind !== 'sticker' && index < 3;
  369. checkbox.addEventListener('change', () => {
  370. if (checkbox.checked && strip.querySelectorAll('input:checked').length > 3) {
  371. checkbox.checked = false; showToast('参考图最多选择 3 张', 'error'); return;
  372. }
  373. label.classList.toggle('checked', checkbox.checked);
  374. });
  375. label.append(image, checkbox);
  376. strip.appendChild(label);
  377. });
  378. wrap.appendChild(strip);
  379. return wrap;
  380. }
  381. function buildOverlayRow(withSticker, onChange, initialState = {}, compactLabels = false) {
  382. const row = el('div', 'overlay-row');
  383. const controls = [];
  384. const create = (labelText, checked, position, mode = 'ai') => {
  385. const label = el('label', 'checkbox-label');
  386. const checkbox = el('input'); checkbox.type = 'checkbox'; checkbox.checked = checked;
  387. label.append(checkbox, el('span', '', labelText));
  388. const positionSelect = select(POSITIONS, position);
  389. const modeSelect = select(OVERLAY_MODES, mode);
  390. [checkbox, positionSelect, modeSelect].forEach((control) => control.addEventListener('change', () => onChange(get())));
  391. row.append(label, positionSelect, modeSelect);
  392. controls.push({ checkbox, positionSelect, modeSelect });
  393. };
  394. create(compactLabels ? '添加LOGO' : '添加店铺 LOGO', initialState.addLogo ?? true, initialState.logoPosition || 'bottom-right', initialState.logoMode || 'ai');
  395. if (withSticker) create(compactLabels ? '添加贴纸' : '添加店铺贴纸', initialState.addSticker ?? false, initialState.stickerPosition || 'bottom-left', initialState.stickerMode || 'ai');
  396. const get = () => ({
  397. addLogo: controls[0].checkbox.checked, logoPosition: controls[0].positionSelect.value, logoMode: controls[0].modeSelect.value,
  398. addSticker: !!controls[1] && controls[1].checkbox.checked, stickerPosition: controls[1] ? controls[1].positionSelect.value : undefined,
  399. stickerMode: controls[1] ? controls[1].modeSelect.value : undefined,
  400. });
  401. const set = (next = {}) => {
  402. controls[0].checkbox.checked = next.addLogo ?? true;
  403. controls[0].positionSelect.value = next.logoPosition || 'bottom-right';
  404. controls[0].modeSelect.value = next.logoMode || 'ai';
  405. if (controls[1]) {
  406. controls[1].checkbox.checked = !!next.addSticker;
  407. controls[1].positionSelect.value = next.stickerPosition || 'bottom-left';
  408. controls[1].modeSelect.value = next.stickerMode || 'ai';
  409. }
  410. };
  411. return { row, get, set };
  412. }
  413. function buildAssetPanel(shop, kind, shopNameInput, context) {
  414. const meta = BRAND_META[kind];
  415. const panel = el('article', 'asset-panel');
  416. const editor = el('div', 'asset-editor');
  417. const resultHost = el('div', 'asset-result');
  418. const head = el('div', 'asset-panel-head');
  419. head.append(el('h4', '', meta.label), el('span', 'asset-spec', `${meta.minW ? `≥${meta.minW}×${meta.minH}px · ` : ''}支持 1K / 2K`));
  420. editor.appendChild(head);
  421. const prompt = el('textarea', 'prompt-input', getPromptTemplate(shop, kind));
  422. prompt.dataset.role = `prompt:${kind}`;
  423. prompt.hidden = true;
  424. editor.appendChild(prompt);
  425. const sizeSelect = select([{ value: '1K', label: '1K' }, { value: '2K', label: '2K' }], '1K');
  426. const aspectSelect = select(ASPECT_OPTIONS.map((value) => ({ value, label: `比例 ${value}` })), meta.ratio);
  427. editor.appendChild(buildReferenceStrip(shop, kind));
  428. let overlay = null;
  429. if (kind !== 'logo') {
  430. overlay = buildOverlayRow(false, () => refresh());
  431. editor.appendChild(overlay.row);
  432. }
  433. const result = el('div', 'result-area');
  434. const generated = shop.generated && shop.generated[kind];
  435. if (generated) renderResult(result, { state: 'success', kind, ...generated }, context);
  436. else result.appendChild(el('div', 'result-empty', '尚未生成'));
  437. resultHost.appendChild(result);
  438. const button = el('button', 'btn btn-primary btn-small', generated ? '重新生成' : '生成');
  439. const promptButton = el('button', 'btn btn-ghost btn-small', '编辑提示词');
  440. promptButton.type = 'button';
  441. const brandVars = () => ({ shopName: shopNameInput.value, dishNames: dishNamesSummary(shop.dishes) });
  442. promptButton.addEventListener('click', () => ensurePromptModal().open(meta.label, prompt, brandVars()));
  443. const actions = el('div', 'asset-actions');
  444. actions.append(sizeSelect, aspectSelect, promptButton, button);
  445. editor.appendChild(actions);
  446. const warning = el('div', 'guard-hint', '未找到可用LOGO,请先生成');
  447. warning.hidden = true;
  448. editor.appendChild(warning);
  449. panel.append(editor, resultHost);
  450. const blocked = () => !!(overlay && overlay.get().addLogo && !context.hasLogo());
  451. function refresh() { const isBlocked = blocked(); button.disabled = isBlocked; warning.hidden = !isBlocked; }
  452. refresh();
  453. async function generate() {
  454. if (blocked()) return;
  455. const refs = Array.from(panel.querySelectorAll('.ref-thumb input:checked')).map((input) => input.value);
  456. const selects = panel.querySelectorAll('select.control');
  457. button.disabled = true;
  458. renderResult(result, { state: 'queued', kind });
  459. try {
  460. const response = await fetch('/api/generate/brand', {
  461. method: 'POST', headers: { 'Content-Type': 'application/json' },
  462. body: JSON.stringify({
  463. root: state.root, folderName: shop.folderName, shopName: shopNameInput.value,
  464. kind, model: state.model, prompt: fillTemplate(prompt.value, brandVars()), size: selects[0].value,
  465. aspectRatio: selects[1].value, refDishFiles: refs,
  466. addLogo: overlay ? overlay.get().addLogo : false,
  467. logoPosition: overlay ? overlay.get().logoPosition : undefined,
  468. logoMode: overlay ? overlay.get().logoMode : undefined,
  469. }),
  470. });
  471. const data = await response.json();
  472. if (!response.ok) throw new Error(data.error || '提交失败');
  473. trackJob(data.jobId, result, kind, (job) => { if (job.state === 'success') context.onGenerated(kind, job); }, context);
  474. } catch (error) {
  475. renderResult(result, { state: 'failed', kind, error: error.message });
  476. } finally { refresh(); }
  477. }
  478. button.addEventListener('click', generate);
  479. return { el: panel, prompt, generate, blocked, refresh };
  480. }
  481. function buildDishPanel(shop, dish, shopNameInput, context) {
  482. const card = el('article', 'dish-work-card');
  483. const head = el('div', 'dish-work-head');
  484. const checkbox = el('input'); checkbox.type = 'checkbox'; checkbox.checked = false;
  485. const nameInput = el('input', 'dish-name-input'); nameInput.value = dish.name;
  486. const button = el('button', 'btn btn-primary btn-small', shop.generated?.dishes?.[dish.name] ? '重新生成' : '生成');
  487. head.append(checkbox, nameInput);
  488. card.appendChild(head);
  489. const frames = el('div', 'dish-work-frames');
  490. const original = el('figure', 'dish-frame');
  491. const originalImage = el('img'); originalImage.src = dish.url; originalImage.alt = `${dish.name} 原图`; originalImage.loading = 'lazy';
  492. original.append(originalImage, el('figcaption', '', '原图'));
  493. const resultFrame = el('figure', 'dish-frame result');
  494. const resultArea = el('div', 'dish-result-area');
  495. const existing = shop.generated?.dishes?.[dish.name];
  496. if (existing) renderResult(resultArea, { state: 'success', kind: 'dish', ...existing });
  497. else resultArea.appendChild(el('div', 'result-empty', '待生成'));
  498. resultFrame.append(resultArea, el('figcaption', '', '生成结果'));
  499. frames.append(original, resultFrame);
  500. card.appendChild(frames);
  501. const saved = (shop.promptOverrides?.dishes?.[dish.file]) || '';
  502. const prompt = el('textarea', 'prompt-input dish-override', saved || context.sharedPrompt());
  503. prompt.hidden = true;
  504. prompt.dataset.inherit = saved ? '0' : '1';
  505. prompt.placeholder = '默认与批量公共提示词一致;修改后仅对本菜品生效';
  506. prompt.dataset.role = `dish:${dish.file}`;
  507. prompt.addEventListener('input', () => { prompt.dataset.inherit = '0'; });
  508. const initialOptions = context.defaultOverlayOptions();
  509. const overlay = buildOverlayRow(false, () => refresh(), initialOptions, true);
  510. const promptStatus = el('span', 'dish-prompt-status inherit', saved ? '已单独编辑' : '默认公共提示词');
  511. const promptButton = el('button', 'btn btn-ghost btn-small', '编辑提示词');
  512. promptButton.type = 'button';
  513. promptButton.addEventListener('click', () => ensurePromptModal().open(dish.name, prompt, {
  514. shopName: shopNameInput.value,
  515. dishName: nameInput.value,
  516. }));
  517. const config = el('div', 'dish-config');
  518. const configHead = el('div', 'dish-config-head');
  519. configHead.append(el('div', 'dish-config-title', '单品配置'), promptStatus);
  520. const configActions = el('div', 'dish-config-actions');
  521. const configButtons = el('div', 'dish-config-buttons');
  522. configButtons.append(promptButton, button);
  523. configActions.append(overlay.row, configButtons);
  524. config.append(configHead, configActions);
  525. const guard = el('div', 'guard-hint');
  526. guard.hidden = true;
  527. config.appendChild(guard);
  528. card.appendChild(config);
  529. const blocked = () => overlay.get().addLogo && !context.hasLogo();
  530. function refresh() {
  531. const isBlocked = blocked();
  532. button.disabled = isBlocked;
  533. guard.hidden = !isBlocked;
  534. const missing = [];
  535. if (overlay.get().addLogo && !context.hasLogo()) missing.push('LOGO');
  536. guard.textContent = missing.length ? missing.map((item) => `未找到可用${item},请先生成`).join(';') : '';
  537. }
  538. refresh();
  539. async function generate(batch) {
  540. const options = batch?.options || overlay.get();
  541. const selectedPrompt = batch?.prompt || prompt.value.trim() || context.sharedPrompt();
  542. const promptText = fillTemplate(selectedPrompt, { shopName: shopNameInput.value, dishName: nameInput.value });
  543. if (!batch && blocked()) return;
  544. button.disabled = true;
  545. renderResult(resultArea, { state: 'queued', kind: 'dish' });
  546. try {
  547. const response = await fetch('/api/generate/dish', {
  548. method: 'POST', headers: { 'Content-Type': 'application/json' },
  549. body: JSON.stringify({
  550. root: state.root, folderName: shop.folderName, shopName: shopNameInput.value,
  551. dishFile: dish.file, dishName: nameInput.value, model: state.model,
  552. prompt: promptText,
  553. size: '1K', ...options,
  554. }),
  555. });
  556. const data = await response.json();
  557. if (!response.ok) throw new Error(data.error || '提交失败');
  558. trackJob(data.jobId, resultArea, 'dish', (job) => {
  559. if (job.state === 'success') recordGenerated('dish', job, nameInput.value);
  560. });
  561. } catch (error) {
  562. renderResult(resultArea, { state: 'failed', kind: 'dish', error: error.message });
  563. } finally { refresh(); }
  564. }
  565. button.addEventListener('click', generate);
  566. return {
  567. el: card, checkbox, prompt, generate, button,
  568. syncDefaultPrompt(value) {
  569. if (prompt.dataset.inherit === '1') prompt.value = value;
  570. },
  571. };
  572. }
  573. function buildStepHead(title, hint, action, appendix) {
  574. const head = el('div', 'step-head');
  575. const text = el('div', 'step-head-text');
  576. text.append(el('h3', '', title), el('span', '', hint));
  577. head.append(text);
  578. if (action || appendix) {
  579. const actions = el('div', 'step-head-actions');
  580. if (action) actions.appendChild(action);
  581. if (appendix) actions.appendChild(appendix);
  582. head.appendChild(actions);
  583. }
  584. return head;
  585. }
  586. function makeStepCollapsible(head, container, collapsed = false) {
  587. const toggle = el('button', 'step-toggle');
  588. toggle.type = 'button';
  589. toggle.title = '展开 / 收起';
  590. toggle.setAttribute('aria-expanded', String(!collapsed));
  591. 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>';
  592. head.querySelector('.step-head-text').appendChild(toggle);
  593. container.classList.toggle('step-collapsed', collapsed);
  594. toggle.addEventListener('click', () => {
  595. const isCollapsed = container.classList.toggle('step-collapsed');
  596. toggle.setAttribute('aria-expanded', String(!isCollapsed));
  597. });
  598. }
  599. function buildShopCard(shop) {
  600. const folder = shop.folderName;
  601. const shouldExpand = state.expandedShops.has(folder);
  602. const card = el('section', shouldExpand ? 'shop-card workflow-card' : 'shop-card workflow-card collapsed');
  603. card.dataset.folder = folder;
  604. const generatedCount = BRAND_KINDS.filter((kind) => shop.generated?.[kind]).length + Object.keys(shop.generated?.dishes || {}).length;
  605. const totalCount = BRAND_KINDS.length + shop.dishes.length;
  606. const head = el('div', 'workflow-head');
  607. const toggle = el('button', 'workflow-toggle');
  608. toggle.type = 'button';
  609. toggle.setAttribute('aria-expanded', String(shouldExpand));
  610. toggle.title = '展开 / 收起';
  611. 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>';
  612. const info = el('div', 'workflow-info');
  613. const shopNameInput = el('input', 'shop-name-input'); shopNameInput.value = shop.shopName; shopNameInput.dataset.role = 'shopName';
  614. info.append(shopNameInput, el('span', 'workflow-folder', folder));
  615. const actions = el('div', 'workflow-actions');
  616. const save = el('button', 'btn btn-ghost btn-small', '保存提示词');
  617. const open = el('button', 'btn btn-ghost btn-small', '打开目录');
  618. actions.append(save, open);
  619. const countBadge = el('span', 'badge badge-info workflow-count', `${generatedCount}/${totalCount} 已生成`);
  620. head.append(toggle, info, countBadge, actions);
  621. card.appendChild(head);
  622. const body = el('div', 'workflow-body');
  623. card.appendChild(body);
  624. let initialized = false;
  625. function initializeContent() {
  626. if (initialized) return;
  627. initialized = true;
  628. const context = {
  629. root: state.root,
  630. folderName: folder,
  631. hasLogo: () => !!(shop.generated?.logo),
  632. onGenerated: (kind, job) => {
  633. recordGenerated(kind, job);
  634. if (kind === 'logo') context.hasLogo = () => true;
  635. refreshWorkflow();
  636. },
  637. };
  638. function recordGenerated(kind, job, dishName) {
  639. shop.generated = shop.generated || {};
  640. const asset = { url: job?.resultUrl, width: job?.width, height: job?.height, fileSizeBytes: job?.fileSizeBytes };
  641. if (dishName) {
  642. shop.generated.dishes = shop.generated.dishes || {};
  643. shop.generated.dishes[dishName] = asset;
  644. } else shop.generated[kind] = asset;
  645. const nextCount = BRAND_KINDS.filter((item) => shop.generated?.[item]).length + Object.keys(shop.generated?.dishes || {}).length;
  646. countBadge.textContent = `${nextCount}/${totalCount} 已生成`;
  647. }
  648. const step1 = el('section', 'workflow-step');
  649. const step1Head = buildStepHead('步骤 1 · LOGO生成', 'LOGO 是其他视觉资产与菜品图的叠加素材');
  650. step1.appendChild(step1Head);
  651. const step1Body = el('div', 'step-body single');
  652. const logoPanel = buildAssetPanel(shop, 'logo', shopNameInput, context);
  653. step1Body.appendChild(logoPanel.el);
  654. step1.appendChild(step1Body);
  655. makeStepCollapsible(step1Head, step1, true);
  656. body.appendChild(step1);
  657. const step2 = el('section', 'workflow-step');
  658. const brandButton = el('button', 'btn btn-primary btn-small', '批量生成海报 + 招牌');
  659. step2.appendChild(buildStepHead('步骤 2 · 其他视觉资产', '海报、招牌、商品自定义生成', brandButton));
  660. const step2Body = el('div', 'step-body brand-columns');
  661. const banner = buildAssetPanel(shop, 'banner', shopNameInput, context);
  662. const signage = buildAssetPanel(shop, 'signage', shopNameInput, context);
  663. const sticker = buildAssetPanel(shop, 'sticker', shopNameInput, context);
  664. step2Body.append(banner.el, signage.el, sticker.el);
  665. step2.appendChild(step2Body);
  666. body.appendChild(step2);
  667. const step3 = el('section', 'workflow-step');
  668. const selectAll = el('label', 'checkbox-label compact');
  669. const selectAllBox = el('input'); selectAllBox.type = 'checkbox'; selectAllBox.checked = false;
  670. selectAll.append(selectAllBox, el('span', '', '全选菜品'));
  671. const dishButton = el('button', 'btn btn-primary btn-small', '批量生成勾选菜品');
  672. step3.appendChild(buildStepHead('步骤 3 · 菜品商品图', '原图与生成结果分开预览', selectAll, dishButton));
  673. const overlay = buildOverlayRow(false, () => refreshWorkflow());
  674. const sharedPrompt = el('textarea', 'prompt-input', shop.promptOverrides?.dishTemplate || DEFAULT_PROMPTS.dish);
  675. sharedPrompt.hidden = true;
  676. sharedPrompt.dataset.role = 'prompt:dishTemplate';
  677. overlay.row.hidden = true;
  678. step3.append(overlay.row, sharedPrompt);
  679. context.defaultOverlayOptions = overlay.get;
  680. context.sharedPrompt = () => sharedPrompt.value;
  681. const dishBody = el('div', 'dish-work-grid');
  682. const dishPanels = shop.dishes.map((dish) => buildDishPanel(shop, dish, shopNameInput, context));
  683. dishPanels.forEach((panel) => dishBody.appendChild(panel.el));
  684. step3.appendChild(dishBody);
  685. body.appendChild(step3);
  686. function refreshWorkflow() {
  687. [banner, signage, sticker].forEach((panel) => panel.refresh());
  688. }
  689. selectAllBox.addEventListener('change', () => dishPanels.forEach((panel) => { panel.checkbox.checked = selectAllBox.checked; }));
  690. dishButton.addEventListener('click', () => {
  691. const selectedPanels = dishPanels.filter((panel) => panel.checkbox.checked);
  692. if (!selectedPanels.length) return showToast('请先勾选要生成的菜品', 'error');
  693. ensureBatchDishModal().open({
  694. count: selectedPanels.length,
  695. config: overlay.get(),
  696. prompt: sharedPrompt.value,
  697. shopName: shop.shopName,
  698. dishName: selectedPanels[0].el.querySelector('.dish-name-input').value,
  699. hasLogo: context.hasLogo,
  700. onConfirm: (options, prompt) => {
  701. overlay.set(options);
  702. sharedPrompt.value = prompt;
  703. selectedPanels.forEach((panel) => panel.generate({ options, prompt }));
  704. },
  705. });
  706. });
  707. brandButton.addEventListener('click', () => {
  708. if (banner.blocked() || signage.blocked()) return;
  709. banner.generate(); signage.generate();
  710. });
  711. save.addEventListener('click', async () => {
  712. const prompts = { logo: logoPanel.prompt.value, banner: banner.prompt.value, signage: signage.prompt.value, sticker: sticker.prompt.value, dishTemplate: sharedPrompt.value, dishes: {} };
  713. dishPanels.forEach((panel, index) => {
  714. const value = panel.prompt.value.trim();
  715. if (value && panel.prompt.dataset.inherit !== '1') prompts.dishes[shop.dishes[index].file] = value;
  716. });
  717. const response = await fetch('/api/save-config', {
  718. method: 'POST', headers: { 'Content-Type': 'application/json' },
  719. body: JSON.stringify({ root: state.root, folderName, shopName: shopNameInput.value, prompts }),
  720. });
  721. const data = await response.json().catch(() => ({}));
  722. if (!response.ok) return showToast(data.error || '保存失败', 'error');
  723. showToast(`已保存「${shopNameInput.value}」配置`);
  724. });
  725. refreshWorkflow();
  726. sharedPrompt.addEventListener('input', () => {
  727. dishPanels.forEach((panel) => panel.syncDefaultPrompt(sharedPrompt.value));
  728. });
  729. }
  730. toggle.addEventListener('click', () => {
  731. const collapsed = card.classList.toggle('collapsed');
  732. toggle.setAttribute('aria-expanded', String(!collapsed));
  733. if (collapsed) state.expandedShops.delete(folder);
  734. else state.expandedShops.add(folder);
  735. saveExpandedShops();
  736. if (!collapsed) initializeContent();
  737. });
  738. open.addEventListener('click', () => openShopFolder(folder));
  739. save.addEventListener('click', () => { if (!initialized) initializeContent(); });
  740. if (shouldExpand) initializeContent();
  741. return card;
  742. }
  743. async function openShopFolder(folderName) {
  744. const response = await fetch('/api/open-folder', {
  745. method: 'POST', headers: { 'Content-Type': 'application/json' },
  746. body: JSON.stringify({ root: state.root, folderName }),
  747. });
  748. const data = await response.json().catch(() => ({}));
  749. if (!response.ok) showToast(data.error || '打开目录失败', 'error');
  750. }
  751. function trackJob(jobId, container, kind, done, context) {
  752. activeJobs.set(jobId, { state: 'queued', cost: 0 });
  753. updateStats();
  754. const tick = async () => {
  755. try {
  756. const response = await fetch(`/api/job/${jobId}`);
  757. const job = await response.json();
  758. activeJobs.set(jobId, { state: job.state, cost: job.cost || 0 });
  759. renderResult(container, { kind, ...job }, context);
  760. if (job.state === 'queued' || job.state === 'running') {
  761. setTimeout(tick, 3000);
  762. return;
  763. }
  764. activeJobs.delete(jobId);
  765. updateStats();
  766. if (job.state === 'success') showToast(`${job.dishName || BRAND_META[kind]?.label || '作品'}生成完成`);
  767. else if (job.state === 'failed') showToast(`${job.dishName || BRAND_META[kind]?.label || '作品'}生成失败:${job.error}`, 'error');
  768. if (done) done(job);
  769. } catch { setTimeout(tick, 4000); }
  770. };
  771. tick();
  772. }
  773. function updateStats() {
  774. let done = 0, pending = 0, failed = 0, total = 0;
  775. for (const shop of state.shops) {
  776. BRAND_KINDS.forEach((kind) => { total++; if (shop.generated?.[kind]) done++; else pending++; });
  777. shop.dishes.forEach((dish) => { total++; if (shop.generated?.dishes?.[dish.name]) done++; else pending++; });
  778. }
  779. let running = 0, cost = 0;
  780. activeJobs.forEach((job) => {
  781. if (job.state === 'queued' || job.state === 'running') running++;
  782. if (job.state === 'failed') failed++;
  783. cost += job.cost || 0;
  784. });
  785. document.getElementById('statShops').textContent = state.shops.length;
  786. document.getElementById('statDone').innerHTML = `${done}<small>张</small>`;
  787. document.getElementById('statPending').innerHTML = `${pending}<small>张</small>`;
  788. document.getElementById('statFail').innerHTML = `${failed}<small>项</small>`;
  789. document.getElementById('statShopsDelta').textContent = `运行中 ${running} · 消耗 ${cost.toFixed(2)}`;
  790. document.getElementById('statDoneDelta').textContent = total ? `完成率 ${Math.round(done / total * 100)}%` : '';
  791. document.getElementById('statPendingDelta').textContent = '';
  792. document.getElementById('statFailDelta').textContent = '';
  793. }
  794. function captureCurrentEdits() {
  795. const edits = {};
  796. document.querySelectorAll('.workflow-card').forEach((card) => {
  797. const folder = card.dataset.folder;
  798. edits[folder] = { shopName: card.querySelector('[data-role="shopName"]')?.value, prompts: { dishes: {} } };
  799. card.querySelectorAll('[data-role]').forEach((node) => {
  800. const [type, key] = node.dataset.role.split(':');
  801. if (type === 'prompt' && key) edits[folder].prompts[key] = node.value;
  802. if (type === 'dish' && key && node.dataset.inherit !== '1') edits[folder].prompts.dishes[key] = node.value;
  803. });
  804. });
  805. return edits;
  806. }
  807. function renderShops() {
  808. const list = document.getElementById('shopList');
  809. list.innerHTML = '';
  810. if (!state.shops.length) {
  811. 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>';
  812. } else state.shops.forEach((shop) => list.appendChild(buildShopCard(shop)));
  813. updateStats();
  814. }
  815. function renderScanSelection() {
  816. const menu = document.getElementById('scanDropdownMenu');
  817. menu.querySelectorAll('[data-value]').forEach((node) => {
  818. const selected = state.scanSelection.has(node.dataset.value);
  819. node.classList.toggle('selected', selected); node.setAttribute('aria-selected', String(selected));
  820. });
  821. const folders = Array.from(state.scanSelection).filter((value) => value !== '__all__');
  822. document.getElementById('scanDropdownText').textContent = folders.length ? `已选 ${folders.length} 家门店` : '全量扫描';
  823. document.getElementById('scanDropdownTrigger').classList.toggle('active', folders.length > 0);
  824. }
  825. function updateScanOptions() {
  826. const menu = document.getElementById('scanDropdownMenu');
  827. const available = new Set(['__all__', ...state.allShops.map((shop) => shop.folderName)]);
  828. state.scanSelection.forEach((value) => { if (!available.has(value)) state.scanSelection.delete(value); });
  829. if (!state.scanSelection.size) state.scanSelection.add('__all__');
  830. menu.innerHTML = '';
  831. const all = el('button', 'scan-dropdown-item'); all.type = 'button'; all.dataset.value = '__all__'; all.setAttribute('role', 'option');
  832. 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>';
  833. menu.appendChild(all);
  834. state.allShops.forEach((shop) => {
  835. const item = el('button', 'scan-dropdown-item'); item.type = 'button'; item.dataset.value = shop.folderName; item.setAttribute('role', 'option');
  836. 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>';
  837. item.lastElementChild.textContent = shop.shopName;
  838. menu.appendChild(item);
  839. });
  840. renderScanSelection();
  841. }
  842. function toggleScanDropdown(open) {
  843. const trigger = document.getElementById('scanDropdownTrigger');
  844. const menu = document.getElementById('scanDropdownMenu');
  845. const shouldOpen = open === undefined ? menu.hidden : open;
  846. menu.hidden = !shouldOpen; trigger.setAttribute('aria-expanded', String(shouldOpen));
  847. }
  848. function handleScanDropdownClick(event) {
  849. const item = event.target.closest('[data-value]'); if (!item) return;
  850. const value = item.dataset.value;
  851. if (value === '__all__') state.scanSelection = new Set(['__all__']);
  852. else {
  853. state.scanSelection.delete('__all__');
  854. if (state.scanSelection.has(value)) state.scanSelection.delete(value); else state.scanSelection.add(value);
  855. if (!state.scanSelection.size) state.scanSelection.add('__all__');
  856. }
  857. renderScanSelection();
  858. saveScanSelection();
  859. }
  860. async function scan() {
  861. if (!state.root) return;
  862. const edits = captureCurrentEdits();
  863. const selected = Array.from(state.scanSelection).filter((value) => value !== '__all__');
  864. const scanAll = !selected.length;
  865. const button = document.getElementById('scanBtn');
  866. button.disabled = true;
  867. document.getElementById('scanIcon').innerHTML = '<span class="spinner spinner-inline"></span>';
  868. document.getElementById('scanText').textContent = '扫描中…';
  869. try {
  870. let url = `/api/scan?root=${encodeURIComponent(state.root)}`;
  871. if (!scanAll) url += `&folders=${encodeURIComponent(JSON.stringify(selected))}`;
  872. const response = await fetch(url);
  873. const data = await response.json();
  874. if (!response.ok) throw new Error(data.error || '扫描失败');
  875. data.shops.forEach((shop) => {
  876. const edit = edits[shop.folderName];
  877. if (edit) {
  878. if (edit.shopName) shop.shopName = edit.shopName;
  879. shop.promptOverrides = { ...(shop.promptOverrides || {}), ...(edit.prompts || {}) };
  880. }
  881. });
  882. if (scanAll) state.allShops = data.shops;
  883. state.shops = data.shops;
  884. updateScanOptions(); renderShops();
  885. showToast(scanAll ? `扫描完成,找到 ${data.shops.length} 家店铺` : `扫描完成,已更新 ${data.shops.length} 家门店`);
  886. } catch (error) { showToast(`扫描失败:${error.message}`, 'error'); }
  887. button.disabled = false;
  888. document.getElementById('scanIcon').innerHTML = ICON_PLAY;
  889. document.getElementById('scanText').textContent = '扫描店铺';
  890. }
  891. window.shanGenInit = async function () {
  892. if (!state.genInitialized) {
  893. state.genInitialized = true;
  894. document.getElementById('scanBtn').addEventListener('click', scan);
  895. document.getElementById('scanDropdownTrigger').addEventListener('click', () => toggleScanDropdown());
  896. document.getElementById('scanDropdownMenu').addEventListener('click', handleScanDropdownClick);
  897. document.getElementById('scanDropdownTrigger').addEventListener('keydown', (event) => {
  898. if (event.key === 'Escape') { toggleScanDropdown(false); event.currentTarget.focus(); }
  899. });
  900. document.addEventListener('click', (event) => { if (!document.getElementById('scanDropdown').contains(event.target)) toggleScanDropdown(false); });
  901. document.getElementById('btnRefreshGen').addEventListener('click', () => { if (state.root) scan(); });
  902. try {
  903. const response = await fetch('/api/settings');
  904. const settings = await response.json();
  905. state.model = settings.model || '';
  906. } catch {}
  907. }
  908. const response = await fetch('/api/default-root');
  909. const data = await response.json();
  910. const changed = !state.root || state.root !== data.root;
  911. state.root = data.root;
  912. state.expandedShops = readStoredSet('expanded');
  913. state.scanSelection = readStoredSet('scan-selection');
  914. if (!state.scanSelection.size) state.scanSelection.add('__all__');
  915. if (changed) scan();
  916. };
  917. })();