Sfoglia il codice sorgente

feat: initial storefront visual toolkit

KenZ1117 6 giorni fa
commit
8737253702
18 ha cambiato i file con 4368 aggiunte e 0 eliminazioni
  1. 8 0
      .gitignore
  2. 10 0
      config.example.json
  3. 44 0
      imageMeta.js
  4. 31 0
      package-lock.json
  5. 12 0
      package.json
  6. 161 0
      public/app.js
  7. BIN
      public/assets/hyreal.png
  8. BIN
      public/assets/logo.png
  9. BIN
      public/assets/powered-by.png
  10. 222 0
      public/crawl.js
  11. 960 0
      public/gen.js
  12. 179 0
      public/history.js
  13. 344 0
      public/index.html
  14. 668 0
      public/style.css
  15. 1705 0
      server.js
  16. 8 0
      启动.bat
  17. 8 0
      启动.command
  18. 8 0
      启动.sh

+ 8 - 0
.gitignore

@@ -0,0 +1,8 @@
+node_modules/
+config.json
+session.json
+manual-shops.json
+workspace/
+output/
+.playwright-cli/
+.DS_Store

+ 10 - 0
config.example.json

@@ -0,0 +1,10 @@
+{
+  "port": 5177,
+  "apiKey": "your-api-key-here",
+  "apiBase": "https://api.lk888.ai",
+  "model": "gpt-image-2",
+  "maxConcurrentJobs": 4,
+  "pageSize": 50,
+  "delayMs": 300,
+  "requestTimeoutMs": 20000
+}

+ 44 - 0
imageMeta.js

@@ -0,0 +1,44 @@
+'use strict';
+
+function readPngSize(buf) {
+  const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+  if (buf.length < 24 || !buf.subarray(0, 8).equals(sig)) return null;
+  if (buf.toString('ascii', 12, 16) !== 'IHDR') return null;
+  return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
+}
+
+function readJpegSize(buf) {
+  if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return null;
+  let offset = 2;
+  const SOF_MARKERS = new Set([
+    0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7,
+    0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf,
+  ]);
+  while (offset + 1 < buf.length) {
+    if (buf[offset] !== 0xff) { offset++; continue; }
+    const marker = buf[offset + 1];
+    if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
+      offset += 2;
+      continue;
+    }
+    if (marker === 0xd9 || offset + 3 >= buf.length) break;
+    const segmentLength = buf.readUInt16BE(offset + 2);
+    if (SOF_MARKERS.has(marker)) {
+      const height = buf.readUInt16BE(offset + 5);
+      const width = buf.readUInt16BE(offset + 7);
+      return { width, height };
+    }
+    offset += 2 + segmentLength;
+  }
+  return null;
+}
+
+function getImageSize(buffer) {
+  try {
+    return readPngSize(buffer) || readJpegSize(buffer);
+  } catch {
+    return null;
+  }
+}
+
+module.exports = { getImageSize };

+ 31 - 0
package-lock.json

@@ -0,0 +1,31 @@
+{
+  "name": "shanhuivisual",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "shanhuivisual",
+      "version": "1.0.0",
+      "dependencies": {
+        "jpeg-js": "^0.4.4",
+        "pngjs": "^7.0.0"
+      }
+    },
+    "node_modules/jpeg-js": {
+      "version": "0.4.4",
+      "resolved": "https://registry.npmmirror.com/jpeg-js/-/jpeg-js-0.4.4.tgz",
+      "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/pngjs": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmmirror.com/pngjs/-/pngjs-7.0.0.tgz",
+      "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=14.19.0"
+      }
+    }
+  }
+}

+ 12 - 0
package.json

@@ -0,0 +1,12 @@
+{
+  "name": "shanhuivisual",
+  "version": "1.0.0",
+  "description": "门店装修工具",
+  "scripts": {
+    "start": "node server.js"
+  },
+  "dependencies": {
+    "jpeg-js": "^0.4.4",
+    "pngjs": "^7.0.0"
+  }
+}

+ 161 - 0
public/app.js

@@ -0,0 +1,161 @@
+'use strict';
+
+(function () {
+  const views = {
+    crawl: document.getElementById('viewCrawl'),
+    gen: document.getElementById('viewGen'),
+    history: document.getElementById('viewHistory'),
+    settings: document.getElementById('viewSettings'),
+  };
+  const navItems = document.querySelectorAll('.nav-item[data-view]');
+  let settingsLoaded = false;
+  let logTimer = null;
+  let lastLogSignature = '';
+
+  function showSettingsStatus(message, type = 'info') {
+    const status = document.getElementById('settingsStatus');
+    status.textContent = message;
+    status.className = `badge ${type === 'success' ? 'badge-ok' : type === 'error' ? 'badge-fail' : 'badge-info'}`;
+  }
+
+  async function loadModels(selectedModel) {
+    const select = document.getElementById('settingsModel');
+    const response = await fetch('/api/models');
+    if (!response.ok) throw new Error('模型列表加载失败');
+    const data = await response.json();
+    select.innerHTML = '';
+    for (const model of data.models || []) {
+      const option = document.createElement('option');
+      option.value = typeof model === 'string' ? model : model.id;
+      option.textContent = typeof model === 'string' ? model : (model.label || model.name || model.id);
+      option.selected = option.value === selectedModel;
+      select.appendChild(option);
+    }
+  }
+
+  async function loadSettings() {
+    showSettingsStatus('加载中…');
+    const response = await fetch('/api/settings');
+    if (!response.ok) throw new Error('设置加载失败');
+    const settings = await response.json();
+    document.getElementById('settingsApiKey').value = settings.apiKey || '';
+    document.getElementById('settingsApiBase').value = settings.apiBase || '';
+    document.getElementById('settingsRootDir').value = settings.rootDir || '';
+    document.getElementById('pageSizeInput').value = settings.pageSize || 50;
+    document.getElementById('delayInput').value = settings.delayMs ?? 300;
+    document.getElementById('settingsCookie').value = settings.cookie || '';
+    await loadModels(settings.model);
+    showSettingsStatus('已同步');
+  }
+
+  async function saveSettings() {
+    const button = document.getElementById('btnSaveSettings');
+    button.disabled = true;
+    showSettingsStatus('保存中…');
+    try {
+      const response = await fetch('/api/settings', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          apiKey: document.getElementById('settingsApiKey').value.trim(),
+          model: document.getElementById('settingsModel').value,
+          rootDir: document.getElementById('settingsRootDir').value.trim(),
+          pageSize: Number(document.getElementById('pageSizeInput').value),
+          delayMs: Number(document.getElementById('delayInput').value),
+          cookie: document.getElementById('settingsCookie').value.trim(),
+        }),
+      });
+      const data = await response.json();
+      if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
+      await loadSettings();
+      showSettingsStatus('已保存', 'success');
+    } catch (error) {
+      showSettingsStatus(error.message, 'error');
+    } finally {
+      button.disabled = false;
+    }
+  }
+
+  async function initSettings() {
+    if (settingsLoaded) return;
+    document.getElementById('btnSaveSettings').addEventListener('click', saveSettings);
+    document.getElementById('btnClearLog').addEventListener('click', () => {
+      clearLogs().catch(() => {});
+    });
+    try {
+      await loadSettings();
+      settingsLoaded = true;
+    } catch (error) {
+      showSettingsStatus(error.message, 'error');
+    }
+  }
+
+  function escapeLogText(value) {
+    const div = document.createElement('div');
+    div.textContent = String(value ?? '');
+    return div.innerHTML;
+  }
+
+  function renderLogs(logs) {
+    const panel = document.getElementById('logPanel');
+    if (!panel) return;
+    const sourceMap = { task: '任务', system: '系统' };
+    const levelMap = { info: 'INFO', success: 'OK', warn: 'WARN', error: 'FAIL' };
+    panel.innerHTML = logs.map((log) => {
+      const level = levelMap[log.level] || 'INFO';
+      const levelClass = log.level === 'success' ? 'ok' : log.level || 'info';
+      return `
+        <div class="log-line">
+          <span class="log-ts">${escapeLogText(log.time)}</span>
+          <span class="log-source ${log.source === 'system' ? 'system' : 'task'}">${sourceMap[log.source] || log.source}</span>
+          <span class="log-lvl ${levelClass}">${level}</span>
+          <span class="log-msg">${escapeLogText(log.message)}</span>
+        </div>`;
+    }).join('');
+  }
+
+  async function refreshLogs() {
+    const response = await fetch('/api/logs');
+    if (!response.ok) return;
+    const data = await response.json();
+    const logs = (data.logs || []).sort((a, b) => b.seq - a.seq);
+    const signature = logs.map((log) => `${log.seq}:${log.level}:${log.message}`).join('|');
+    if (signature === lastLogSignature) return;
+    lastLogSignature = signature;
+    renderLogs(logs);
+  }
+
+  function startLogPolling() {
+    if (logTimer) return;
+    refreshLogs();
+    logTimer = setInterval(refreshLogs, 1500);
+  }
+
+  async function clearLogs() {
+    await fetch('/api/logs/clear', { method: 'POST' });
+    lastLogSignature = '';
+    await refreshLogs();
+  }
+
+  function switchView(name) {
+    for (const [key, el] of Object.entries(views)) {
+      el.classList.toggle('active', key === name);
+    }
+    navItems.forEach((n) => n.classList.toggle('active', n.dataset.view === name));
+    if (name === 'crawl' && window.shanCrawlInit) window.shanCrawlInit();
+    if (name === 'gen' && window.shanGenInit) window.shanGenInit();
+    if (name === 'history' && window.shanHistoryInit) window.shanHistoryInit();
+    if (name === 'settings') initSettings();
+  }
+
+  navItems.forEach((n) => n.addEventListener('click', () => switchView(n.dataset.view)));
+
+  switchView('crawl');
+  startLogPolling();
+
+  const splash = document.getElementById('splash');
+  if (splash) {
+    setTimeout(() => splash.classList.add('hide'), 1800);
+    setTimeout(() => splash.remove(), 2400);
+  }
+})();

BIN
public/assets/hyreal.png


BIN
public/assets/logo.png


BIN
public/assets/powered-by.png


+ 222 - 0
public/crawl.js

@@ -0,0 +1,222 @@
+'use strict';
+
+(function () {
+const state = {
+  shops: [],
+  folderNameByShopId: new Map(),
+  statusByShopId: new Map(),
+};
+
+let pollTimer = null;
+let crawlRoot = '';
+let initialized = false;
+
+function showToast(msg, type) {
+  const wrap = document.getElementById('toast-wrap');
+  const el = document.createElement('div');
+  el.className = 'toast toast-' + (type || 'success');
+  el.textContent = msg;
+  wrap.appendChild(el);
+  setTimeout(() => { el.style.opacity = '0'; el.style.transition = 'opacity 0.3s'; }, 2400);
+  setTimeout(() => { if (el.parentNode) wrap.removeChild(el); }, 2800);
+}
+
+function jsonFetch(url, opts) {
+  return fetch(url, opts).then((r) =>
+    r.json().then((data) => {
+      if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
+      return data;
+    })
+  );
+}
+
+function renderShopTable() {
+  const body = document.getElementById('shopTableBody');
+  body.innerHTML = '';
+  document.getElementById('shopTableEmpty').style.display = state.shops.length ? 'none' : 'block';
+  const countEl = document.getElementById('shopCount');
+  if (countEl) countEl.textContent = state.shops.length + ' 家';
+  for (const shop of state.shops) {
+    const tr = document.createElement('tr');
+    const tdCb = document.createElement('td');
+    const cb = document.createElement('input');
+    cb.type = 'checkbox'; cb.checked = true; cb.dataset.shopId = String(shop.shopId);
+    tdCb.appendChild(cb); tr.appendChild(tdCb);
+    const tdName = document.createElement('td');
+    tdName.innerHTML = `<div class="shop-name">${shop.shopName}</div>`;
+    tr.appendChild(tdName);
+    const tdId = document.createElement('td');
+    tdId.textContent = shop.shopId;
+    tdId.className = 'num';
+    tr.appendChild(tdId);
+    const tdStatus = document.createElement('td');
+    const st = state.statusByShopId.get(shop.shopId) || (shop.completed ? { state: 'skipped_completed' } : null);
+    tdStatus.innerHTML = st ? statusBadgeHtml(st) : '<span class="badge">待抓取</span>';
+    tr.appendChild(tdStatus);
+    const saved = shop.completed;
+    const folderName = state.folderNameByShopId.get(shop.shopId) || (saved ? saved.folderName : null);
+    const tdFolder = document.createElement('td');
+    tdFolder.className = 'col-folder';
+    tdFolder.innerHTML = folderName
+      ? `<span class="badge badge-ok">${folderName}</span>`
+      : '<span class="badge">—</span>';
+    tr.appendChild(tdFolder);
+    tr.dataset.shopId = String(shop.shopId);
+    body.appendChild(tr);
+  }
+}
+
+function statusBadgeHtml(st) {
+  const label = {
+    pending: '排队中', running: '抓取中',
+    done: `完成 ${st.downloaded}/${st.total || 0}`,
+    skipped_completed: '已完成',
+    failed: '失败', stopped: '已停止',
+  }[st.state] || st.state;
+  const cls = { done: 'badge-ok', failed: 'badge-fail', running: 'badge-warn' }[st.state] || 'badge-info';
+  return `<span class="badge ${cls}">${label}</span>`;
+}
+
+const ICON_PLAY = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>';
+
+function renderCrawlState(cs) {
+  const badge = document.getElementById('crawlStatusBadge');
+  const labelMap = { idle: '空闲', running: '抓取中', done: '已完成', stopped: '已停止', error: '出错' };
+  const clsMap = { running: 'badge-warn', done: 'badge-ok', error: 'badge-fail', stopped: 'badge-warn' };
+  badge.textContent = labelMap[cs.status] || cs.status;
+  badge.className = 'badge ' + (clsMap[cs.status] || '');
+
+  const startBtn = document.getElementById('startBtn');
+  const stopBtn = document.getElementById('stopBtn');
+  const startIcon = document.getElementById('startBtnIcon');
+  const startText = document.getElementById('startBtnText');
+  startBtn.disabled = cs.status === 'running';
+  stopBtn.style.display = cs.status === 'running' ? '' : 'none';
+  if (cs.status === 'running') {
+    startIcon.innerHTML = '<span class="spinner spinner-inline"></span>';
+    startText.textContent = '抓取中… 点击停止';
+    startBtn.classList.remove('btn-primary');
+    startBtn.classList.add('btn-ghost');
+    startBtn.disabled = false;
+  } else {
+    startIcon.innerHTML = ICON_PLAY;
+    startText.textContent = '开始抓取';
+    startBtn.classList.remove('btn-ghost');
+    startBtn.classList.add('btn-primary');
+  }
+
+  state.statusByShopId.clear();
+  state.folderNameByShopId.clear();
+  for (const s of cs.shops) {
+    state.statusByShopId.set(s.shopId, s);
+    if (s.folderName) state.folderNameByShopId.set(s.shopId, s.folderName);
+  }
+  renderShopTable();
+
+  const list = document.getElementById('progressList');
+  list.innerHTML = '';
+  if (!cs.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 6h16M4 12h10M4 18h16"/></svg></div><p>暂无任务</p></div>';
+  }
+  for (const s of cs.shops) {
+    const row = document.createElement('div');
+    row.className = 'progress-row';
+    const pct = s.total > 0 ? Math.round((s.downloaded + s.skipped) / s.total * 100) : 0;
+    row.innerHTML = `
+      <div class="left"><span class="badge badge-info">采集</span><span class="name">${s.shopName}</span><span class="meta">· 菜品图</span></div>
+      <div class="right">
+        <div class="progress-bar"><span style="width:${pct}%;"></span></div>
+        <span>${s.downloaded + s.skipped} / ${s.total || 0}</span>
+        ${statusBadgeHtml(s)}
+      </div>`;
+    list.appendChild(row);
+  }
+
+  if (cs.status !== 'running' && pollTimer) { clearInterval(pollTimer); pollTimer = null; }
+}
+
+function startPolling() {
+  if (pollTimer) clearInterval(pollTimer);
+  const tick = () => jsonFetch('/api/crawl/status').then(renderCrawlState).catch(() => {});
+  tick();
+  pollTimer = setInterval(tick, 1500);
+}
+
+function refreshShops() {
+  const root = crawlRoot;
+  const query = root ? `?root=${btoa(unescape(encodeURIComponent(root))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}` : '';
+  return jsonFetch(`/api/shops${query}`).then((data) => { state.shops = data.shops || []; renderShopTable(); });
+}
+
+window.shanCrawlInit = function () {
+  if (initialized) return;
+  initialized = true;
+
+  const modal = document.getElementById('modalAddShop');
+  document.getElementById('btnToggleAdd').addEventListener('click', () => {
+    modal.hidden = false;
+    document.getElementById('manualInput').focus();
+  });
+  document.getElementById('btnCloseModal').addEventListener('click', () => { modal.hidden = true; });
+  document.getElementById('btnCancelAdd').addEventListener('click', () => { modal.hidden = true; });
+  modal.addEventListener('click', (e) => { if (e.target === modal) modal.hidden = true; });
+  document.addEventListener('keydown', (e) => {
+    if (e.key === 'Escape' && !modal.hidden) modal.hidden = true;
+  });
+
+  document.getElementById('advToggle').addEventListener('click', function () {
+    const open = this.getAttribute('aria-expanded') === 'true';
+    this.setAttribute('aria-expanded', String(!open));
+    document.getElementById('advBody').classList.toggle('is-open', !open);
+  });
+
+  document.getElementById('btnClearLog').addEventListener('click', () => {
+    document.getElementById('logPanel').innerHTML = '';
+  });
+
+  jsonFetch('/api/settings').then(s => {
+    crawlRoot = s.rootDir;
+    return refreshShops();
+  });
+  jsonFetch('/api/crawl/status').then((cs) => {
+    renderCrawlState(cs);
+    if (cs.status === 'running') startPolling();
+  });
+
+  document.getElementById('useManualBtn').addEventListener('click', () => {
+    const text = document.getElementById('manualInput').value;
+    if (!text.trim()) return showToast('请先填写手动清单', 'error');
+    jsonFetch('/api/shops/manual', {
+      method: 'POST', headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ text }),
+    }).then((data) => {
+      state.shops = data.shops; renderShopTable();
+      showToast(`已添加 ${data.addedCount} 家店铺`);
+    }).catch((e) => showToast('添加失败: ' + e.message, 'error'));
+  });
+
+  document.getElementById('selectAllCb').addEventListener('change', (e) => {
+    document.querySelectorAll('#shopTableBody input[type=checkbox]').forEach((cb) => { cb.checked = e.target.checked; });
+  });
+
+  document.getElementById('startBtn').addEventListener('click', () => {
+    if (document.getElementById('stopBtn').style.display !== 'none') {
+      jsonFetch('/api/crawl/stop', { method: 'POST' }).then(() => showToast('已发送停止请求'));
+      return;
+    }
+    const shopIds = Array.from(document.querySelectorAll('#shopTableBody input[type=checkbox]'))
+      .filter((cb) => cb.checked).map((cb) => Number(cb.dataset.shopId));
+    if (!shopIds.length) return showToast('请先在店铺列表里勾选要抓取的店铺', 'error');
+    const root = crawlRoot;
+    jsonFetch('/api/crawl/start', {
+      method: 'POST', headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ root, shopIds, forceFullCrawl: document.getElementById('forceFullCrawl').checked }),
+    }).then(() => { showToast('已开始抓取'); startPolling(); })
+      .catch((e) => showToast('启动失败: ' + e.message, 'error'));
+  });
+
+  document.getElementById('stopBtn').addEventListener('click', () => {
+    jsonFetch('/api/crawl/stop', { method: 'POST' }).then(() => showToast('已发送停止请求'));
+  });
+};
+})();

+ 960 - 0
public/gen.js

@@ -0,0 +1,960 @@
+'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();
+};
+})();

+ 179 - 0
public/history.js

@@ -0,0 +1,179 @@
+'use strict';
+
+(function () {
+  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 21h16"/></svg>';
+
+  function esc(value) {
+    const div = document.createElement('div');
+    div.textContent = String(value ?? '');
+    return div.innerHTML;
+  }
+
+  const state = {
+    initialized: false,
+    loading: false,
+    root: '',
+    items: [],
+    filter: 'all',
+    keyword: '',
+  };
+
+  const FILTER_LABELS = {
+    logo: 'LOGO',
+    banner: '海报',
+    signage: '招牌',
+    sticker: '贴纸',
+    dish: '菜品',
+    other: '其他',
+  };
+
+  function formatBytes(size) {
+    if (!Number.isFinite(size) || size <= 0) return '—';
+    if (size < 1024 * 1024) return `${Math.round(size / 1024)} KB`;
+    return `${(size / 1024 / 1024).toFixed(1)} MB`;
+  }
+
+  function formatTime(value) {
+    if (!value) return '';
+    const date = new Date(value);
+    const pad = (n) => String(n).padStart(2, '0');
+    return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
+  }
+
+  function getFilteredItems() {
+    const keyword = state.keyword.trim().toLowerCase();
+    return state.items.filter((item) => {
+      if (state.filter !== 'all' && item.kind !== state.filter) return false;
+      if (!keyword) return true;
+      return `${item.shopName} ${item.name} ${item.fileName}`.toLowerCase().includes(keyword);
+    });
+  }
+
+  function renderSummary() {
+    const summary = document.getElementById('historySummary');
+    const filtered = getFilteredItems();
+    if (state.loading) {
+      summary.textContent = '正在加载历史作品…';
+      return;
+    }
+    summary.textContent = state.items.length
+      ? `共 ${state.items.length} 件作品 · 当前显示 ${filtered.length} 件`
+      : '暂无历史作品';
+  }
+
+  function renderGallery() {
+    const gallery = document.getElementById('historyGallery');
+    const items = getFilteredItems();
+    gallery.classList.toggle('empty', items.length === 0);
+    if (!items.length) {
+      gallery.innerHTML = `
+        <div class="history-empty">
+          <div class="history-empty-icon">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="3"/><circle cx="9" cy="10" r="1.8"/><path d="M21 16l-5-5-6 6"/></svg>
+          </div>
+          <p>${state.loading ? '正在加载作品' : '暂无匹配的历史作品'}</p>
+          <small>${state.loading ? '请稍候' : '生成完成的作品会自动出现在这里'}</small>
+        </div>`;
+      return;
+    }
+    gallery.innerHTML = '';
+    items.forEach((item) => {
+      const card = document.createElement('figure');
+      card.className = 'creation-card';
+      card.dataset.kind = item.kind;
+
+      const frame = document.createElement('div');
+      frame.className = 'creation-frame';
+      if (item.kind === 'sticker') frame.classList.add('transparent-bg');
+      const image = document.createElement('img');
+      image.src = item.url;
+      image.alt = `${item.shopName} ${item.name}`;
+      image.loading = 'lazy';
+      frame.appendChild(image);
+      window.shanHoverZoom(frame, image.src, item.kind === 'sticker');
+
+      const kind = document.createElement('span');
+      kind.className = 'creation-kind';
+      kind.textContent = FILTER_LABELS[item.kind] || '其他';
+      frame.appendChild(kind);
+
+      const download = document.createElement('a');
+      download.className = 'result-download-icon';
+      download.href = item.url;
+      download.download = '';
+      download.title = '下载原图';
+      download.setAttribute('aria-label', '下载原图');
+      download.innerHTML = ICON_DOWNLOAD;
+      frame.appendChild(download);
+
+      const caption = document.createElement('figcaption');
+      const title = document.createElement('div');
+      title.className = 'creation-title';
+      title.title = item.name;
+      title.textContent = item.name;
+      const shop = document.createElement('div');
+      shop.className = 'creation-shop';
+      shop.title = item.shopName;
+      shop.textContent = item.shopName;
+      const meta = document.createElement('div');
+      meta.className = 'creation-meta';
+      meta.textContent = `${formatTime(item.modifiedAt)} · ${formatBytes(item.fileSizeBytes)}`;
+      caption.append(title, shop, meta);
+
+      card.append(frame, caption);
+      gallery.appendChild(card);
+    });
+  }
+
+  function render() {
+    renderSummary();
+    renderGallery();
+  }
+
+  async function loadHistory() {
+    if (state.loading) return;
+    state.loading = true;
+    render();
+    try {
+      const rootResponse = await fetch('/api/default-root');
+      const rootData = await rootResponse.json();
+      state.root = rootData.root;
+      const response = await fetch(`/api/creations?root=${encodeURIComponent(state.root)}&limit=600`);
+      const data = await response.json();
+      if (!response.ok) throw new Error(data.error || '历史作品加载失败');
+      state.items = data.items || [];
+    } catch (error) {
+      state.items = [];
+      renderGallery();
+      document.getElementById('historySummary').textContent = error.message;
+    } finally {
+      state.loading = false;
+      render();
+    }
+  }
+
+  function bindEvents() {
+    document.getElementById('btnRefreshHistory').addEventListener('click', loadHistory);
+    document.getElementById('historySearch').addEventListener('input', (event) => {
+      state.keyword = event.target.value;
+      render();
+    });
+    document.getElementById('historyFilters').addEventListener('click', (event) => {
+      const button = event.target.closest('[data-filter]');
+      if (!button) return;
+      state.filter = button.dataset.filter;
+      document.querySelectorAll('#historyFilters .history-filter').forEach((item) => {
+        item.classList.toggle('active', item === button);
+      });
+      render();
+    });
+  }
+
+  window.shanHistoryInit = function () {
+    if (!state.initialized) {
+      state.initialized = true;
+      bindEvents();
+    }
+    loadHistory();
+  };
+})();

+ 344 - 0
public/index.html

@@ -0,0 +1,344 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>门店采集 · 门店装修工具</title>
+<link rel="icon" href="/assets/logo.png">
+<link rel="stylesheet" href="/style.css">
+</head>
+<body>
+
+<!-- Splash Screen -->
+<div class="splash" id="splash">
+  <img src="/assets/logo.png" alt="门店装修工具" class="splash-logo">
+  <div class="splash-name">门店装修工具</div>
+  <div class="splash-sub">淘宝闪购专业版</div>
+  <div class="splash-bar"><span></span></div>
+  <div class="splash-powered">
+    <span>powered by</span>
+    <img src="/assets/powered-by.png" alt="Hyreal IDE">
+  </div>
+</div>
+
+<div class="app-frame">
+
+  <aside class="sidebar">
+    <div class="sidebar-brand">
+      <div class="sidebar-logo"><img src="/assets/logo.png" alt="门"></div>
+      <div class="sidebar-titles">
+        <div class="t1">门店装修工具</div>
+        <div class="t2">淘宝闪购专业版</div>
+      </div>
+    </div>
+
+    <div class="nav-section-label">采集</div>
+    <button class="nav-item active" data-view="crawl"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6h16M4 12h10M4 18h16"/></svg></span><span>门店采集</span></button>
+
+    <div class="nav-section-label">生成</div>
+    <button class="nav-item" data-view="gen"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l1.8 4.9L19 9.7l-4.3 3 1.2 5.3-3.9-3-3.9 3 1.2-5.3-4.3-3 5.2-1.8z"/></svg></span><span>视觉生成</span></button>
+    <button class="nav-item" data-view="history"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="3"/><circle cx="9" cy="10" r="1.8"/><path d="M21 16l-5-5-6 6"/></svg></span><span>历史创作</span></button>
+
+    <div class="nav-section-label">设置</div>
+    <button class="nav-item" data-view="settings"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 1.55V21a2 2 0 1 1-4 0v-.05A1.7 1.7 0 0 0 9 19.4a1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.55-1H3a2 2 0 1 1 0-4h.05A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6h.09A1.7 1.7 0 0 0 10 3.05V3a2 2 0 1 1 4 0v.05A1.7 1.7 0 0 0 15 4.6a1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9v.09a1.7 1.7 0 0 0 1.55.91H21a2 2 0 1 1 0 4h-.05a1.7 1.7 0 0 0-1.55 1z"/></svg></span><span>系统设置</span></button>
+
+    <div class="sidebar-bottom">
+      <span>v1.0.0</span>
+      <img src="/assets/powered-by.png" alt="Hyreal IDE" class="sidebar-powered">
+    </div>
+  </aside>
+
+  <div class="main-wrap">
+
+    <!-- ==================== 门店采集 ==================== -->
+    <div id="viewCrawl" class="view active">
+      <main class="content">
+
+        <div class="page-header">
+          <div>
+            <h1>门店采集</h1>
+            <div class="sub">从淘宝闪购后台按店铺批量采集菜品图,用于后续视觉美化</div>
+          </div>
+        </div>
+
+        <div class="crawl-grid">
+
+          <div class="left-col">
+
+            
+
+            <div class="panel panel-narrow panel-designed">
+              <div class="stage-marker"><span class="stage-icon"><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 7h16M4 17h16"/><circle cx="9" cy="7" r="2.2"/><circle cx="15" cy="17" r="2.2"/></svg></span><span>抓取设置</span></div>
+              <span class="panel-watermark"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Z"/><path d="M12 8v4l3 2"/></svg></span>
+              <div class="panel-body">
+                
+                <div class="field">
+                  <label>抓取内容</label>
+                  <select disabled><option>菜品图片 + 名称</option></select>
+                </div>
+                <button class="collapse-trigger" id="advToggle" aria-expanded="false">
+                  <span>高级设置</span><span class="chev"><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></span>
+                </button>
+                <div id="advBody" class="collapse">
+                  <div style="padding-top:12px;display:flex;flex-direction:column;gap:12px;">
+                    <label style="display:flex;align-items:center;gap:6px;font-size:13px;cursor:pointer;">
+                      <input type="checkbox" id="forceFullCrawl">
+                      <span>强制全量抓取(覆盖已有图片)</span>
+                    </label>
+                  </div>
+                </div>
+              </div>
+            </div>
+
+            <button class="btn btn-primary btn-block" id="startBtn">
+              <span id="startBtnIcon"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg></span>
+              <span id="startBtnText">开始抓取</span>
+            </button>
+            <button class="btn btn-danger btn-block" id="stopBtn" disabled style="display:none;"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2"/></svg><span>停止</span></button>
+
+            <div class="panel panel-fixed panel-progress">
+              <div class="panel-title">
+                <span>进度列表</span>
+                <span class="actions"><span class="badge" id="crawlStatusBadge"></span></span>
+              </div>
+              <div id="progressList"></div>
+            </div>
+
+          </div>
+
+          <div class="right-col">
+
+            <div class="panel panel-fixed panel-shops">
+              <div class="panel-title">
+                <span>店铺清单</span>
+                <span class="actions">
+                  <span class="badge badge-info" id="shopCount">0 家</span>
+                  <button class="btn btn-ghost btn-small" id="btnToggleAdd">+ 添加店铺</button>
+                </span>
+              </div>
+              <div style="overflow-x:auto;">
+                <table class="data-table">
+                  <thead>
+                    <tr>
+                      <th style="width:36px;"><input type="checkbox" id="selectAllCb"></th>
+                      <th style="min-width:220px;">店铺</th>
+                      <th style="width:100px;">shopId</th>
+                      <th style="width:100px;">状态</th>
+                      <th style="width:220px;">文件夹</th>
+                    </tr>
+                  </thead>
+                  <tbody id="shopTableBody"></tbody>
+                </table>
+                <div id="shopTableEmpty" class="empty-hint">
+                  <div class="empty-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="4" width="14" height="17" rx="2"/><path d="M9 4a3 3 0 0 1 6 0"/><path d="M9 10h6M9 14h6M9 18h4"/></svg></div>
+                  <p>尚无店铺<br><small>点击右上角「+ 添加店铺」手动录入</small></p>
+                </div>
+              </div>
+            </div>
+
+          </div>
+        </div>
+
+      </main>
+    </div>
+
+    <!-- ==================== 视觉生成 ==================== -->
+    <div id="viewGen" class="view">
+      <main class="content">
+        <div class="page-header">
+          <div>
+            <h1>视觉生成</h1>
+            <div class="sub">基于已采集的菜品图片,批量生成 LOGO / 海报 / 招牌 / 贴纸 + 菜品图</div>
+          </div>
+          <div class="toolbar">
+            <button class="btn btn-ghost btn-small" id="btnRefreshGen">
+              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 12a8 8 0 1 1-2.3-5.6"/><path d="M20 4v5h-5"/></svg>
+              刷新
+            </button>
+          </div>
+        </div>
+
+        <div class="gen-overview">
+          <div class="stats-grid" id="statsBar">
+            <div class="metric accent"><span class="metric-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 20V9l8-5 8 5v11"/><path d="M9 20v-6h6v6"/></svg></span><span class="metric-body"><span class="metric-label">店铺</span><span class="metric-value" id="statShops">—</span><span class="stat-delta" id="statShopsDelta"></span></span></div>
+            <div class="metric ok"><span class="metric-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg></span><span class="metric-body"><span class="metric-label">已生成</span><span class="metric-value" id="statDone">—</span><span class="stat-delta" id="statDoneDelta"></span></span></div>
+            <div class="metric warn"><span class="metric-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 3h10M7 21h10"/><path d="M8 3c0 8 8 8 8 18M16 3c0 8-8 8-8 18"/></svg></span><span class="metric-body"><span class="metric-label">待处理</span><span class="metric-value" id="statPending">—</span><span class="stat-delta" id="statPendingDelta"></span></span></div>
+            <div class="metric fail"><span class="metric-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v6M12 16.5v.5"/></svg></span><span class="metric-body"><span class="metric-label">失败</span><span class="metric-value" id="statFail">—</span><span class="stat-delta" id="statFailDelta"></span></span></div>
+          </div>
+          <div class="gen-controls">
+            <div class="field">
+              <label>扫描范围</label>
+              <div class="scan-dropdown" id="scanDropdown">
+                <button type="button" class="scan-dropdown-trigger" id="scanDropdownTrigger" aria-expanded="false" aria-haspopup="listbox">
+                  <span id="scanDropdownText">全量扫描</span>
+                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg>
+                </button>
+                <div class="scan-dropdown-menu" id="scanDropdownMenu" role="listbox" aria-multiselectable="true" hidden></div>
+              </div>
+            </div>
+            <button class="btn btn-primary" id="scanBtn">
+              <span id="scanIcon"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg></span>
+              <span id="scanText">扫描店铺</span>
+            </button>
+          </div>
+        </div>
+
+        <div class="shop-cards" id="shopList"></div>
+      </main>
+    </div>
+
+    <!-- ==================== 历史创作 ==================== -->
+    <div id="viewHistory" class="view">
+      <main class="content">
+        <div class="page-header">
+          <div>
+            <h1>历史创作</h1>
+            <div class="sub">以图片墙回顾已生成的品牌物料与菜品作品</div>
+          </div>
+          <div class="toolbar">
+            <button class="btn btn-ghost btn-small" id="btnRefreshHistory">
+              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 12a8 8 0 1 1-2.3-5.6"/><path d="M20 4v5h-5"/></svg>
+              刷新
+            </button>
+          </div>
+        </div>
+        <div class="history-toolbar panel">
+          <div class="history-search">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="6.5"/><path d="M20 20l-4.2-4.2"/></svg>
+            <input type="text" id="historySearch" placeholder="搜索门店或作品名称">
+          </div>
+          <div class="history-filters" id="historyFilters">
+            <button class="history-filter active" data-filter="all">全部</button>
+            <button class="history-filter" data-filter="logo">LOGO</button>
+            <button class="history-filter" data-filter="banner">海报</button>
+            <button class="history-filter" data-filter="signage">招牌</button>
+            <button class="history-filter" data-filter="sticker">贴纸</button>
+            <button class="history-filter" data-filter="dish">菜品</button>
+          </div>
+        </div>
+        <div class="history-summary" id="historySummary">加载中…</div>
+        <div class="history-gallery" id="historyGallery"></div>
+      </main>
+    </div>
+
+    <!-- ==================== 系统设置 ==================== -->
+    <div id="viewSettings" class="view">
+      <main class="content">
+        <div class="page-header">
+          <div>
+            <h1>系统设置</h1>
+            <div class="sub">API 密钥与采集配置,保存后立即生效</div>
+          </div>
+        </div>
+        <div class="settings-workspace">
+          <div class="settings-forms">
+          <div class="panel panel-narrow">
+            <div class="stage-marker"><span class="num">1</span><span>API 设置</span></div>
+            <span class="panel-watermark"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="7.5" cy="15.5" r="4.5"/><path d="M11 12L21 2"/><path d="M16 7l3 3"/></svg></span>
+            <div class="panel-body">
+              <div class="field">
+                <label>API Key<span class="req">*</span></label>
+                <input type="text" id="settingsApiKey" placeholder="sk-...">
+              </div>
+              <div class="field">
+                <label>API 地址</label>
+                <input type="text" id="settingsApiBase" value="https://api.lk888.ai" disabled>
+              </div>
+	              <div class="field">
+	                <label>默认模型</label>
+	                <select id="settingsModel"></select>
+	              </div>
+	              <div class="settings-links">
+	                <a class="settings-link" href="https://o-hubs.com/api/console/keys" target="_blank" rel="noopener noreferrer">
+	                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="7.5" cy="15.5" r="4.5"/><path d="M11 12L21 2"/><path d="M16 7l3 3"/></svg>
+	                  <span>获取 API Key</span>
+	                </a>
+	                <a class="settings-link" href="https://o-hubs.com/home" target="_blank" rel="noopener noreferrer">
+	                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="6" width="18" height="13" rx="3"/><path d="M3 10h18"/><path d="M7 15h3"/></svg>
+	                  <span>充值</span>
+	                </a>
+	              </div>
+            </div>
+          </div>
+          <div class="panel panel-narrow">
+            <div class="stage-marker"><span class="num">2</span><span>采集配置</span></div>
+            <span class="panel-watermark"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/><path d="M3 11h18"/></svg></span>
+            <div class="panel-body">
+              <div class="field">
+                <label>输出根目录</label>
+                <input type="text" id="settingsRootDir" placeholder="/path/to/workspace">
+              </div>
+              <div class="field-row">
+                <div class="field">
+                  <label>每页条数</label>
+                  <input type="number" id="pageSizeInput" min="1" value="50">
+                </div>
+                <div class="field">
+                  <label>间隔 (ms)</label>
+                  <input type="number" id="delayInput" min="0" value="300">
+                  <div class="help">建议 ≥ 200</div>
+                </div>
+              </div>
+              <div class="field">
+                <label>Cookie(淘宝闪购)</label>
+                <textarea id="settingsCookie" rows="3" placeholder="登录 melody.shop.ele.me 后从 DevTools 复制"></textarea>
+                <div class="help">会话过期时需重新获取并更新</div>
+              </div>
+	              <div style="display:flex;align-items:center;gap:8px;">
+	                <button class="btn btn-primary btn-small" id="btnSaveSettings">保存设置</button>
+	                <span id="settingsStatus" class="badge"></span>
+	              </div>
+	            </div>
+	          </div>
+	          </div>
+	          <div class="panel panel-fixed panel-log settings-log">
+            <div class="panel-title">
+              <span>实时日志</span>
+              <span class="actions">
+                <button class="btn btn-ghost btn-small" id="btnClearLog">清空日志</button>
+              </span>
+            </div>
+            <span class="panel-watermark log-watermark"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 5h16v11H8l-4 4Z"/><path d="M8 9h8M8 12h5"/></svg></span>
+            <div class="log-box" id="logPanel"></div>
+          </div>
+        </div>
+      </main>
+    </div>
+
+  </div>
+</div>
+
+<div class="toast-wrap" id="toast-wrap"></div>
+
+<!-- 添加店铺弹窗 -->
+<div class="modal-overlay" id="modalAddShop" hidden>
+  <div class="modal">
+    <div class="modal-header">
+      <div class="modal-title">
+        <span class="modal-status-icon">
+          <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 20V9l8-5 8 5v11"/><path d="M9 20v-6h6v6"/></svg>
+        </span>
+        <span>添加店铺</span>
+      </div>
+      <button class="modal-close" id="btnCloseModal">&times;</button>
+    </div>
+    <div class="modal-body">
+      <div class="field">
+        <label>店铺清单</label>
+        <textarea id="manualInput" rows="5" placeholder="每行一个:shopId 店铺名&#10;507068342 鑫龙福麻辣烫(康诺美食城店)"></textarea>
+        <div class="help">每行一条,shopId 和店铺名用空格或逗号分隔</div>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <button class="btn btn-ghost btn-small" id="btnCancelAdd">取消</button>
+      <button class="btn btn-primary btn-small" id="useManualBtn">确认添加</button>
+    </div>
+  </div>
+</div>
+
+<script src="/crawl.js"></script>
+<script src="/gen.js"></script>
+<script src="/history.js"></script>
+<script src="/app.js"></script>
+</body>
+</html>

+ 668 - 0
public/style.css

@@ -0,0 +1,668 @@
+/* ═══════════════════════════════════════════════════════════════
+   Design System — 门店装修工具 · 淘宝闪购专业版
+   Extracted from dish-crawl.html design spec
+   ═══════════════════════════════════════════════════════════════ */
+
+:root {
+  --accent: #ff6b35;
+  --accent-dark: #e0501c;
+  --bg: #f4f2ef;
+  --card-bg: #ffffff;
+  --sidebar-bg: #1d1c1a;
+  --text: #2b2a28;
+  --text-2: #6b6560;
+  --text-3: #a5a09a;
+  --border: #e8e4df;
+  --border-light: #f0ede8;
+  --ok: #1a8a4c; --ok-bg: #e8f7ee;
+  --warn: #b8860b; --warn-bg: #fdf3d9;
+  --fail: #c8402e; --fail-bg: #fdeceb;
+  --info: #2563eb; --info-bg: #dbeafe;
+  --sidebar-hover-bg: #2d2c2a;
+  --sidebar-text: #a0a0a0;
+  --sidebar-active-bg: rgba(255,107,53,0.14);
+  --focus-ring: rgba(255,107,53,0.12);
+  --font-family-base: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
+  --font-size-h1: 18px; --font-size-h2: 14px; --font-size-h3: 12px;
+  --font-size-body: 13px; --font-size-small: 11.5px; --font-size-badge: 11px;
+  --font-weight-h1: 700; --font-weight-h2: 600; --font-weight-h3: 600; --font-weight-body: 400; --font-weight-badge: 500;
+  --line-height-h1: 1.3; --line-height-body: 1.5; --line-height-small: 1.45;
+  --space-xs: 4px; --space-sm: 8px; --space-md: 12px; --space-lg: 16px; --space-xl: 20px; --space-xxl: 28px;
+  --radius-sm: 6px; --radius-md: 10px; --radius-lg: 14px; --radius-pill: 999px;
+  --sidebar-width: 220px;
+  --content-max-width: 1300px;
+  --page-padding-x: 28px;
+  --page-padding-y: 20px;
+  --page-padding-bottom: 60px;
+  --duration-fast: 0.05s; --duration-base: 0.15s; --duration-slow: 0.25s;
+  --ease-out: cubic-bezier(0.22, 0.61, 0.36, 1);
+  --tap-target-min: 32px;
+  --tap-target-row-min: 28px;
+}
+
+* { box-sizing: border-box; }
+[hidden] { display: none !important; }
+
+html, body { margin: 0; padding: 0; background: var(--bg); color: var(--text); font-family: var(--font-family-base); -webkit-font-smoothing: antialiased; font-size: var(--font-size-body); line-height: var(--line-height-body); }
+
+/* ── Global interaction ─────────────────────────────────── */
+button, [role="button"], .btn { min-height: var(--tap-target-row-min); min-width: var(--tap-target-min); }
+:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+[disabled], [aria-disabled="true"], .btn:disabled { opacity: 0.5; cursor: not-allowed; }
+
+@keyframes spinner-rotate { to { transform: rotate(360deg); } }
+.spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid var(--accent); border-top-color: transparent; border-radius: 50%; vertical-align: -2px; animation: spinner-rotate 0.8s linear infinite; }
+.spinner-lg { width: 16px; height: 16px; border-width: 2px; }
+.spinner-inline { vertical-align: middle; margin-right: 4px; }
+
+.collapse { overflow: hidden; max-height: 0; opacity: 0; transition: max-height var(--duration-slow) var(--ease-out), opacity var(--duration-base) var(--ease-out); }
+.collapse.is-open { max-height: 1000px; opacity: 1; }
+
+/* ── App Frame Layout ──────────────────────────────────── */
+.app-frame { display: flex; align-items: stretch; height: 100vh; overflow: hidden; }
+.app-frame > main { flex: 1; min-width: 0; }
+
+/* ── Sidebar ───────────────────────────────────────────── */
+.sidebar {
+  width: var(--sidebar-width); flex-shrink: 0;
+  background: var(--sidebar-bg); color: #fff;
+  display: flex; flex-direction: column;
+  padding: 20px 14px; box-sizing: border-box;
+  height: 100vh;
+  overflow-y: auto;
+}
+.sidebar-brand { display: flex; align-items: center; gap: 10px; padding: 4px 6px 18px; border-bottom: 1px solid rgba(255,255,255,0.08); margin-bottom: 14px; }
+.sidebar-logo { width: 44px; height: 44px; background: #fff; border-radius: 12px; display: flex; align-items: center; justify-content: center; overflow: hidden; flex-shrink: 0; }
+.sidebar-logo img { width: 100%; height: 100%; object-fit: contain; }
+.sidebar-titles .t1 { font-size: 14px; font-weight: 700; line-height: 1.25; }
+.sidebar-titles .t2 { font-size: 11px; color: rgba(255,255,255,0.55); margin-top: 2px; }
+.nav-section-label { font-size: 11px; color: rgba(255,255,255,0.4); text-transform: uppercase; letter-spacing: 0.06em; padding: 12px 10px 6px; }
+.nav-item { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 8px; color: var(--sidebar-text); cursor: pointer; font-size: 13px; margin-bottom: 4px; transition: background 0.15s, color 0.15s; min-height: 36px; border: none; background: transparent; width: 100%; text-align: left; }
+.nav-item:hover { background: var(--sidebar-hover-bg); color: #f0ede8; }
+.nav-item.active { background: var(--sidebar-active-bg); color: var(--accent); font-weight: 600; }
+.nav-item .ico { width: 16px; display: inline-flex; align-items: center; justify-content: center; opacity: 0.9; }
+.nav-item .ico svg { width: 16px; height: 16px; display: block; }
+.sidebar-bottom { margin-top: auto; padding: 12px 10px; font-size: 11px; color: rgba(255,255,255,0.4); border-top: 1px solid rgba(255,255,255,0.08); display: flex; align-items: center; justify-content: space-between; }
+.sidebar-powered { height: 14px; opacity: 0.4; }
+
+.main-wrap { flex: 1; min-width: 0; height: 100vh; overflow-y: auto; }
+
+/* ── Page Header ───────────────────────────────────────── */
+.page-header { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 18px; }
+.page-header h1 { font-size: var(--font-size-h1); font-weight: var(--font-weight-h1); margin: 0; color: var(--text); line-height: var(--line-height-h1); }
+.page-header .sub { font-size: var(--font-size-small); color: var(--text-2); margin-top: 4px; }
+.page-header .toolbar { display: flex; gap: 8px; }
+
+/* ── Content Area ──────────────────────────────────────── */
+main.content { padding: var(--page-padding-y) var(--page-padding-x) var(--page-padding-bottom); }
+
+.view { display: none; }
+.view.active { display: block; }
+
+/* ── Crawl Grid (双栏) ─────────────────────────────────── */
+.crawl-grid { display: grid; grid-template-columns: 320px minmax(0, 1fr); gap: 20px; align-items: start; }
+.left-col { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
+.right-col { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
+
+#viewCrawl.active { display: flex; height: 100%; min-height: 0; }
+#viewCrawl .content { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; padding-bottom: 20px; }
+#viewCrawl .crawl-grid { flex: 1; min-height: 0; align-items: stretch; }
+#viewCrawl .left-col, #viewCrawl .right-col { min-height: 0; }
+#viewCrawl .panel-progress { flex: 1; min-height: 180px; height: auto; }
+#viewCrawl .panel-shops { flex: 1; min-height: 0; height: auto; }
+
+@media (max-width: 900px) {
+  .crawl-grid { grid-template-columns: 1fr !important; }
+  .page-header { flex-direction: column; align-items: flex-start; gap: 10px; }
+  #viewCrawl.active { display: block; height: auto; }
+  #viewCrawl .crawl-grid { flex: none; }
+  #viewCrawl .panel-progress, #viewCrawl .panel-shops { flex: none; height: 320px; }
+}
+
+/* ── Panel ─────────────────────────────────────────────── */
+.panel { background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 16px; }
+.panel-narrow { padding: 16px; }
+.panel-title { font-size: var(--font-size-h2); font-weight: 600; color: var(--text); margin: 0 0 12px; display: flex; align-items: center; justify-content: space-between; }
+.panel-title .actions { display: flex; gap: 8px; align-items: center; }
+.panel-body { display: flex; flex-direction: column; gap: 12px; }
+.panel-hint { font-size: var(--font-size-small); color: var(--text-3); margin-top: -4px; }
+
+/* Fixed-height panels (right column) */
+.panel-fixed { overflow: hidden; display: flex; flex-direction: column; }
+.panel-fixed .panel-title { flex-shrink: 0; }
+.panel-shops { height: 380px; }
+.panel-shops > div:last-child { flex: 1; display: flex; flex-direction: column; overflow: auto; min-height: 0; }
+.panel-progress { height: 260px; }
+.panel-progress #progressList { display: flex; flex-direction: column; overflow-y: auto; flex: 1; min-height: 0; }
+.panel-log { height: 240px; }
+.panel-log .log-box { flex: 1; min-height: 0; max-height: none; }
+
+.stage-marker { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; font-size: var(--font-size-h2); font-weight: 600; color: var(--text); }
+.stage-marker .num { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: 50%; background: var(--accent); color: #fff; font-size: 11px; font-weight: 700; flex-shrink: 0; }
+
+/* ── Form Fields ───────────────────────────────────────── */
+.field { display: flex; flex-direction: column; gap: 5px; }
+.field label { font-size: var(--font-size-small); color: var(--text-2); font-weight: 500; }
+.field .req { color: var(--fail); margin-left: 2px; }
+.field input, .field select, .field textarea {
+  padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm);
+  background: #faf9f7; color: var(--text); font-size: var(--font-size-body); font-family: inherit;
+  outline: none; transition: border-color 0.15s, box-shadow 0.15s; min-height: 32px; box-sizing: border-box;
+  resize: vertical;
+}
+.field input:focus, .field select:focus, .field textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--focus-ring); background: #fff; }
+.field input:disabled {
+  background: #efece8;
+  border-color: var(--border-light);
+  color: var(--text-3);
+  cursor: not-allowed;
+  opacity: 1;
+}
+.field .help { font-size: 11px; color: var(--text-3); }
+.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
+.field-row .field input { width: 100%; min-width: 0; }
+.settings-workspace { display: grid; grid-template-columns: minmax(340px, 420px) minmax(0, 1fr); gap: 16px; align-items: stretch; }
+.settings-forms { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
+.settings-forms .field textarea { min-height: 78px; resize: vertical; line-height: 1.5; }
+.settings-forms .panel,
+.panel-designed,
+.settings-log { position: relative; overflow: hidden; background: linear-gradient(135deg, #fff 0%, #faf8f5 100%); border-radius: 14px; transition: border-color 0.18s var(--ease-out), box-shadow 0.18s var(--ease-out); }
+.settings-forms .panel { padding: 18px; }
+.panel-designed { padding: 18px; }
+.panel-designed .panel-body { position: relative; z-index: 1; }
+.panel-designed .panel-watermark { right: -42px; bottom: -46px; width: 118px; height: 118px; color: rgba(255, 107, 53, .08); }
+.stage-icon { width: 28px; height: 28px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; border-radius: 9px; background: rgba(255, 107, 53, .10); color: var(--accent); box-shadow: inset 0 0 0 1px rgba(255, 107, 53, .12); }
+.stage-icon svg { width: 15px; height: 15px; display: block; }
+.panel-watermark { position: absolute; right: -26px; bottom: -30px; width: 104px; height: 104px; color: rgba(255, 107, 53, .12); pointer-events: none; }
+.settings-forms .panel-body { position: relative; z-index: 1; }
+.panel-watermark svg { width: 100%; height: 100%; display: block; }
+.log-watermark { right: -10px; bottom: auto; top: -26px; width: 110px; height: 110px; color: rgba(255, 107, 53, .08); }
+.settings-forms .stage-marker .num { width: 28px; height: 28px; border-radius: 9px; background: rgba(255, 107, 53, .10); color: var(--accent); font-size: 12px; box-shadow: inset 0 0 0 1px rgba(255, 107, 53, .12); }
+.settings-log .panel-title,
+.settings-log .log-box { position: relative; z-index: 1; }
+.settings-actions { display: flex; align-items: center; gap: 8px; }
+.settings-links { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
+.settings-link {
+  display: inline-flex; align-items: center; justify-content: center; gap: 7px;
+  min-height: 32px; padding: 0 10px; border: 1px solid var(--border); border-radius: var(--radius-sm);
+  background: #fff; color: var(--text-2); font-size: 12px; font-weight: 600; line-height: 1;
+  text-decoration: none; transition: border-color 0.15s, color 0.15s, background 0.15s, box-shadow 0.15s;
+}
+.settings-link svg { width: 13px; height: 13px; flex-shrink: 0; }
+.settings-link:hover {
+  border-color: var(--accent); color: var(--accent); background: #fff;
+  box-shadow: 0 0 0 2px var(--focus-ring);
+}
+.settings-log { height: auto; min-height: 360px; }
+.settings-log .log-box { min-height: 0; }
+
+#viewSettings.active { display: flex; height: 100%; min-height: 0; }
+#viewSettings .content { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; padding-bottom: 20px; }
+#viewSettings .settings-workspace { flex: 1; min-height: 0; }
+#viewSettings .settings-forms { min-height: 0; }
+#viewSettings .settings-log { flex: 1; min-height: 0; }
+
+@media (max-width: 960px) {
+  .settings-workspace { grid-template-columns: 1fr; }
+  #viewSettings.active { display: block; height: auto; }
+  #viewSettings .content { min-height: 0; }
+  #viewSettings .settings-workspace { flex: none; }
+  #viewSettings .settings-log { flex: none; min-height: 320px; }
+}
+
+/* ── Buttons ───────────────────────────────────────────── */
+.btn {
+  padding: 8px 16px; border-radius: var(--radius-sm); border: 1px solid transparent;
+  background: transparent; color: var(--text); font-size: 13px; font-weight: 600;
+  cursor: pointer; transition: background 0.15s, transform 0.05s, border-color 0.15s;
+  display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 32px;
+}
+.btn:hover { filter: brightness(0.96); }
+.btn:active { transform: scale(0.97); }
+.btn:disabled { opacity: 0.5; cursor: not-allowed; }
+.btn-primary { background: var(--accent); color: #fff; }
+.btn-primary:hover { background: var(--accent-dark); }
+.btn-ghost { background: #f1efec; color: var(--text); border-color: transparent; }
+.btn-ghost:hover { background: #ebe7e1; }
+.btn-danger { background: var(--fail); color: #fff; }
+.btn-danger:hover { background: #b03826; }
+.btn-small { padding: 5px 10px; font-size: 12px; min-height: 28px; }
+.btn-block { width: 100%; }
+.btn svg { width: 12px; height: 12px; display: block; flex-shrink: 0; }
+.btn #startBtnIcon, .btn #scanIcon { display: inline-flex; align-items: center; }
+.badge svg { width: 10px; height: 10px; display: block; }
+
+/* ── Data Table ────────────────────────────────────────── */
+.data-table { width: 100%; border-collapse: collapse; font-size: var(--font-size-body); }
+.data-table th { text-align: left; font-size: var(--font-size-small); font-weight: 600; color: var(--text-2); text-transform: uppercase; letter-spacing: 0.03em; padding: 10px 12px; border-bottom: 1px solid var(--border); background: var(--bg); }
+.data-table td { padding: 10px 12px; border-bottom: 1px solid var(--border-light); color: var(--text); vertical-align: middle; }
+.data-table tr:last-child td { border-bottom: none; }
+.data-table tbody tr:hover td { background: #faf9f7; }
+.data-table .col-actions { text-align: right; white-space: nowrap; }
+.data-table .shop-name { font-weight: 600; color: var(--text); }
+.data-table .col-folder .badge { display: inline-block; max-width: 210px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle; }
+.data-table .shop-meta { font-size: var(--font-size-small); color: var(--text-3); margin-top: 2px; }
+.data-table .num { font-variant-numeric: tabular-nums; }
+
+/* ── Badge ─────────────────────────────────────────────── */
+.badge { display: inline-block; padding: 2px 8px; border-radius: var(--radius-pill); font-size: var(--font-size-badge); font-weight: var(--font-weight-badge); line-height: 1.5; background: #f1efec; color: var(--text-2); }
+.badge-ok { background: var(--ok-bg); color: var(--ok); }
+.badge-warn { background: var(--warn-bg); color: var(--warn); }
+.badge-fail { background: var(--fail-bg); color: var(--fail); }
+.badge-info { background: var(--info-bg); color: var(--info); }
+
+/* ── Progress ──────────────────────────────────────────── */
+.progress-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 4px 10px; padding: 10px 0; border-bottom: 1px solid var(--border-light); font-size: var(--font-size-body); }
+.progress-row:last-child { border-bottom: none; }
+.progress-row .left { grid-column: 1 / -1; display: flex; align-items: center; gap: 8px; min-width: 0; }
+.progress-row .left .badge { flex-shrink: 0; }
+.progress-row .left .name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.progress-row .left .meta { color: var(--text-3); font-size: var(--font-size-small); flex-shrink: 0; }
+.progress-row .right { grid-column: 1 / -1; display: flex; align-items: center; gap: 10px; min-width: 0; white-space: nowrap; font-size: var(--font-size-small); color: var(--text-2); font-variant-numeric: tabular-nums; }
+.progress-bar { display: inline-block; flex: 1; min-width: 56px; height: 6px; background: var(--border-light); border-radius: var(--radius-pill); overflow: hidden; }
+.progress-bar > span { display: block; height: 100%; background: var(--accent); border-radius: inherit; transition: width 0.3s; }
+
+/* ── Collapse / Accordion ──────────────────────────────── */
+.collapse-trigger { display: flex; align-items: center; justify-content: space-between; width: 100%; padding: 8px 0; border: none; background: transparent; cursor: pointer; font-size: var(--font-size-body); font-weight: 500; color: var(--text-2); }
+.collapse-trigger:hover { color: var(--text); }
+.collapse-trigger .chev { display: inline-flex; transition: transform var(--duration-base) var(--ease-out); }
+.collapse-trigger .chev svg { width: 10px; height: 10px; display: block; }
+.collapse-trigger[aria-expanded="true"] .chev { transform: rotate(90deg); }
+
+/* ── Log ───────────────────────────────────────────────── */
+.log-box { max-height: 280px; overflow: auto; background: #1d1c1a; border-radius: var(--radius-md); padding: 12px 14px; font-family: "SF Mono", "Consolas", monospace; font-size: 12px; line-height: 1.6; }
+.log-line { display: flex; gap: 8px; align-items: baseline; color: #d8d4cd; }
+.log-line .log-ts { color: #8a8580; font-variant-numeric: tabular-nums; flex-shrink: 0; }
+.log-line .log-source { flex-shrink: 0; width: 32px; text-align: center; font-size: 10px; line-height: 16px; border-radius: 999px; background: rgba(255, 255, 255, 0.07); color: #b8b3ad; }
+.log-line .log-source.system { background: rgba(124, 179, 232, 0.12); color: #7cb3e8; }
+.log-line .log-source.task { background: rgba(255, 107, 53, 0.12); color: #ffa475; }
+.log-line .log-lvl { font-weight: 700; flex-shrink: 0; width: 36px; text-align: right; }
+.log-line .log-lvl.info { color: #7cb3e8; }
+.log-line .log-lvl.ok { color: #5cba7d; }
+.log-line .log-lvl.warn { color: #e0a832; }
+.log-line .log-lvl.error { color: #e86a5e; }
+.log-line .log-msg { word-break: break-all; min-width: 0; }
+
+/* ── Toast ─────────────────────────────────────────────── */
+.toast-wrap { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); display: flex; flex-direction: column; gap: 8px; align-items: center; z-index: 9999; }
+.toast { background: #2b2a28; color: #fff; padding: 10px 20px; border-radius: 10px; font-size: var(--font-size-body); line-height: var(--line-height-body); box-shadow: 0 8px 24px -8px rgba(0, 0, 0, 0.18); animation: toast-in 0.25s ease both; }
+.toast.toast-success { background: #1a8a4c; }
+.toast.toast-error { background: var(--fail); }
+@keyframes toast-in { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
+
+/* ── Modal ─────────────────────────────────────────────── */
+.modal-overlay {
+  position: fixed; top: 0; left: 0; right: 0; bottom: 0;
+  background: rgba(0, 0, 0, 0.4);
+  display: flex; align-items: center; justify-content: center;
+  z-index: 9998;
+}
+.modal-overlay[hidden] { display: none; }
+.modal {
+  background: var(--card-bg);
+  border-radius: var(--radius-lg);
+  box-shadow: 0 16px 48px -8px rgba(0, 0, 0, 0.24);
+  width: 460px;
+  max-width: 90vw;
+  animation: modal-in var(--duration-base) var(--ease-out) both;
+}
+@keyframes modal-in { from { opacity: 0; transform: translateY(12px) scale(0.98); } to { opacity: 1; transform: translateY(0) scale(1); } }
+.modal-header {
+  display: flex; align-items: center; justify-content: space-between;
+  padding: 16px 20px;
+  border-bottom: 1px solid var(--border-light);
+  font-size: var(--font-size-h2); font-weight: 600;
+}
+.modal-title { display: flex; align-items: center; gap: 10px; min-width: 0; }
+.modal-status-icon { position: relative; z-index: 0; width: 34px; height: 34px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; border-radius: 10px; background: #fff; color: var(--accent); box-shadow: inset 0 0 0 1px rgba(255, 107, 53, 0.14); }
+.modal-status-icon::before { content: ""; position: absolute; inset: -9px; z-index: -1; border-radius: 16px; background: radial-gradient(circle, rgba(255, 107, 53, 0.20) 0%, rgba(255, 107, 53, 0.05) 48%, transparent 72%); pointer-events: none; }
+.modal-status-icon svg { width: 16px; height: 16px; display: block; }
+.modal-title > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.modal-close {
+  border: none; background: transparent; cursor: pointer;
+  font-size: 20px; color: var(--text-3);
+  width: 28px; height: 28px; display: flex; align-items: center; justify-content: center;
+  border-radius: var(--radius-sm);
+}
+.modal-close:hover { background: #f1efec; color: var(--text); }
+.modal-body { padding: 20px; }
+.modal-body .field textarea { width: 100%; }
+.modal-footer {
+  display: flex; justify-content: flex-end; gap: 8px;
+  padding: 12px 20px 16px;
+}
+
+/* ── Splash Screen ─────────────────────────────────────── */
+.splash {
+  position: fixed; top: 0; left: 0; right: 0; bottom: 0;
+  background: var(--sidebar-bg);
+  display: flex; flex-direction: column; align-items: center; justify-content: center;
+  z-index: 99999;
+  transition: opacity 0.4s ease, visibility 0.4s ease;
+}
+.splash.hide { opacity: 0; visibility: hidden; pointer-events: none; }
+.splash-logo {
+  width: 96px; height: 96px; border-radius: 20px; object-fit: contain;
+  background: #fff; padding: 8px;
+  animation: splash-pop 0.6s var(--ease-out) both;
+}
+.splash-name {
+  font-size: 20px; font-weight: 700; color: #f0ede8; margin-top: 16px;
+  animation: splash-fade 0.5s 0.2s var(--ease-out) both;
+}
+.splash-sub {
+  font-size: 12px; color: rgba(255,255,255,0.45); margin-top: 4px;
+  animation: splash-fade 0.5s 0.35s var(--ease-out) both;
+}
+.splash-bar {
+  width: 160px; height: 3px; background: rgba(255,255,255,0.1);
+  border-radius: 999px; margin-top: 24px; overflow: hidden;
+  animation: splash-fade 0.5s 0.45s var(--ease-out) both;
+}
+.splash-bar span {
+  display: block; height: 100%; background: var(--accent); border-radius: inherit;
+  animation: splash-load 1.2s 0.5s var(--ease-out) both;
+}
+@keyframes splash-pop { from { opacity: 0; transform: scale(0.8); } to { opacity: 1; transform: scale(1); } }
+@keyframes splash-fade { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
+@keyframes splash-load { from { width: 0; } to { width: 100%; } }
+.splash-powered {
+  display: flex; align-items: center; gap: 6px; margin-top: 20px;
+  animation: splash-fade 0.5s 0.6s var(--ease-out) both;
+}
+.splash-powered span { font-size: 11px; color: rgba(255,255,255,0.35); }
+.splash-powered img { height: 18px; opacity: 0.6; }
+
+/* ── Empty State ───────────────────────────────────────── */
+.empty-hint { position: relative; display: flex; flex: 1 1 auto; flex-direction: column; align-items: center; justify-content: center; overflow: hidden; color: var(--text-3); min-height: 180px; padding: clamp(28px, 6vh, 48px) clamp(24px, 5vw, 40px); text-align: center; font-size: var(--font-size-body); }
+.panel-progress .empty-hint,
+.panel-shops .empty-hint { min-height: 0; }
+.empty-icon { width: clamp(52px, 10vh, 76px); height: clamp(52px, 10vh, 76px); margin: 0 auto 14px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; color: var(--text-3); opacity: .16; }
+.empty-icon svg { width: 100%; height: 100%; display: block; }
+.empty-hint p { margin: 0; color: var(--text-2); font-size: 14px; font-weight: 650; line-height: 1.45; }
+.empty-hint small { display: block; margin-top: 6px; color: var(--text-3); font-size: var(--font-size-small); font-weight: 450; }
+
+/* ── Visual Gen Page ───────────────────────────────────── */
+.select-input { padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: var(--font-size-body); background: #faf9f7; color: var(--text); outline: none; min-height: 32px; }
+.select-input:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--focus-ring); }
+
+
+/* compact generation overview */
+.gen-overview { margin-bottom: 16px; padding: 14px; background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--radius-lg); display: flex; align-items: stretch; gap: 14px; }
+.stats-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; align-items: stretch; flex: 1; min-width: 0; }
+.metric { position: relative; display: flex; align-items: center; overflow: hidden; min-width: 0; min-height: 116px; padding: 20px 22px; background: linear-gradient(135deg, #fff 0%, #faf8f5 100%); border: 1px solid var(--border-light); border-radius: 14px; transition: border-color 0.18s var(--ease-out), box-shadow 0.18s var(--ease-out), transform 0.18s var(--ease-out); }
+.metric::before { content: ""; position: absolute; inset: 0; background: radial-gradient(circle at calc(100% - 8px) calc(100% + 8px), rgba(107, 101, 96, 0.10) 0%, transparent 38%); opacity: 0; transition: opacity 0.18s var(--ease-out); pointer-events: none; }
+.metric:hover { transform: translateY(-1px); border-color: var(--border); box-shadow: 0 10px 24px rgba(31, 27, 24, 0.06); }
+.metric:hover::before { opacity: 1; }
+.metric.accent { border-color: rgba(255, 107, 53, 0.16); }
+.metric.accent::before { background: radial-gradient(circle at calc(100% - 6px) calc(100% + 6px), rgba(255, 107, 53, 0.20) 0%, transparent 42%); opacity: 1; }
+.metric.ok { border-color: rgba(26, 138, 76, 0.14); }
+.metric.ok::before { background: radial-gradient(circle at calc(100% - 6px) calc(100% + 6px), rgba(26, 138, 76, 0.16) 0%, transparent 42%); opacity: 1; }
+.metric.warn { border-color: rgba(184, 134, 11, 0.14); }
+.metric.warn::before { background: radial-gradient(circle at calc(100% - 6px) calc(100% + 6px), rgba(184, 134, 11, 0.16) 0%, transparent 42%); opacity: 1; }
+.metric.fail { border-color: rgba(200, 64, 46, 0.14); }
+.metric.fail::before { background: radial-gradient(circle at calc(100% - 6px) calc(100% + 6px), rgba(200, 64, 46, 0.16) 0%, transparent 42%); opacity: 1; }
+.metric-icon { position: absolute; right: -8px; bottom: -12px; width: 88px; height: 88px; border-radius: 0; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; background: transparent; color: var(--text-2); box-shadow: none; opacity: 0.1; transition: opacity 0.18s var(--ease-out); pointer-events: none; }
+.metric-icon svg { width: 100%; height: 100%; display: block; stroke-width: 1.4; }
+.metric.accent .metric-icon { color: var(--accent); }
+.metric.ok .metric-icon { color: var(--ok); }
+.metric.warn .metric-icon { color: var(--warn); }
+.metric.fail .metric-icon { color: var(--fail); }
+.metric:hover .metric-icon { opacity: 0.16; }
+.metric-body { position: relative; z-index: 1; display: flex; flex-direction: column; min-width: 0; gap: 10px; }
+.metric-label { display: inline-flex; align-items: center; gap: 7px; font-size: 12.5px; line-height: 1.2; color: var(--text-2); font-weight: 600; letter-spacing: 0.04em; }
+.metric-label::before { content: ""; width: 7px; height: 7px; flex: 0 0 auto; border-radius: 50%; background: var(--text-3); }
+.metric.accent .metric-label::before { background: var(--accent); }
+.metric.ok .metric-label::before { background: var(--ok); }
+.metric.warn .metric-label::before { background: var(--warn); }
+.metric.fail .metric-label::before { background: var(--fail); }
+.metric-value { font-size: 38px; font-weight: 770; color: var(--text); line-height: 1; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; }
+.metric-value small { font-size: 13px; font-weight: 600; color: var(--text-3); margin-left: 6px; letter-spacing: 0; }
+.metric .stat-delta { min-height: 14px; font-size: 12px; color: var(--text-3); font-weight: 600; line-height: 1.2; }
+.metric.ok .stat-delta { color: var(--ok); }
+.gen-controls { flex: 0 0 168px; padding: 12px; background: #faf9f7; border: 1px solid var(--border-light); border-radius: 10px; display: flex; flex-direction: column; justify-content: center; gap: 8px; }
+.gen-controls select { padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: #faf9f7; font-size: var(--font-size-body); outline: none; min-height: 36px; width: 100%; }
+.gen-controls select:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--focus-ring); background: #fff; }
+.scan-dropdown { position: relative; }
+.scan-dropdown-trigger { position: relative; z-index: 31; width: 100%; min-height: 36px; padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: #fff; color: var(--text); font-size: var(--font-size-body); line-height: 1.2; display: flex; align-items: center; justify-content: space-between; gap: 8px; cursor: pointer; transition: border-color .15s, box-shadow .15s, background .15s; }
+.scan-dropdown-trigger:hover { border-color: var(--accent); }
+.scan-dropdown-trigger.active { border-color: var(--accent); background: rgba(255, 107, 53, 0.05); }
+.scan-dropdown-trigger:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px var(--focus-ring); }
+.scan-dropdown-trigger > span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; }
+.scan-dropdown-trigger svg { width: 14px; height: 14px; flex: 0 0 auto; color: var(--text-3); }
+.scan-dropdown-menu { position: absolute; top: calc(100% + 6px); right: 0; z-index: 40; width: max(220px, calc(100% + 220px)); max-height: 230px; overflow-y: auto; padding: 6px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 14px 34px rgba(31, 27, 24, .14); scrollbar-width: thin; }
+.scan-dropdown-item { width: 100%; min-height: 34px; padding: 7px 9px; border: 0; border-radius: 7px; background: transparent; color: var(--text); font-size: 12.5px; line-height: 1.2; display: flex; align-items: center; gap: 9px; text-align: left; cursor: pointer; transition: background .12s, color .12s; }
+.scan-dropdown-item:hover { background: #f6f4f2; }
+.scan-dropdown-item > span:last-child { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.scan-check { width: 16px; height: 16px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; border: 1px solid var(--border); border-radius: 5px; background: #fff; color: transparent; transition: all .12s; }
+.scan-check svg { width: 10px; height: 10px; }
+.scan-dropdown-item.selected { background: rgba(255, 107, 53, .07); color: var(--accent); font-weight: 600; }
+.scan-dropdown-item.selected .scan-check { border-color: var(--accent); background: var(--accent); color: #fff; }
+.gen-controls .field { gap: 6px; }
+.gen-controls .btn { width: 100%; min-height: 32px; }
+
+@media (max-width: 1080px) {
+  .gen-overview { flex-direction: column; gap: 12px; }
+  .stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
+  .metric { min-height: 96px; padding: 16px; }
+  .metric-value { font-size: 30px; }
+  .metric-icon { width: 72px; height: 72px; right: -6px; bottom: -10px; }
+  .gen-controls { flex-basis: auto; }
+}
+
+/* shop-cards */
+.shop-cards { display: flex; flex-direction: column; gap: 16px; }
+.shop-cards:has(.empty-hint) { min-height: clamp(340px, calc(100vh - 300px), 620px); }
+.shop-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
+
+/* Complete visual generation workflow */
+.workflow-card { overflow: visible; box-shadow: 0 1px 2px rgba(31,27,24,.04); }
+.workflow-head { display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; align-items: center; gap: 12px; padding: 15px 18px; background: linear-gradient(135deg, #fff, #faf8f5); border-bottom: 1px solid var(--border-light); }
+.workflow-card.collapsed .workflow-head { border-bottom-color: transparent; }
+.workflow-card.collapsed .workflow-body { display: none; }
+.workflow-toggle { width: 28px; height: 28px; padding: 0; display: inline-flex; align-items: center; justify-content: center; border: 1px solid var(--border); border-radius: 8px; background: #fff; color: var(--text-2); cursor: pointer; transition: all .15s; }
+.workflow-toggle:hover { border-color: var(--accent); color: var(--accent); }
+.workflow-toggle svg { width: 14px; height: 14px; transition: transform .18s var(--ease-out); }
+.workflow-card.collapsed .workflow-toggle svg { transform: rotate(0); }
+.workflow-card:not(.collapsed) .workflow-toggle svg { transform: rotate(90deg); }
+.workflow-info { min-width: 0; display: flex; align-items: center; gap: 10px; }
+.shop-name-input { width: min(260px, 34vw); min-width: 150px; height: 34px; padding: 0 10px; border: 1px solid transparent; border-radius: 8px; background: transparent; color: var(--text); font-size: 15px; font-weight: 700; outline: none; transition: all .15s; }
+.shop-name-input:hover { border-color: var(--border); background: #fff; }
+.shop-name-input:focus { border-color: var(--accent); background: #fff; box-shadow: 0 0 0 2px var(--focus-ring); }
+.workflow-folder { max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-3); font-size: 11.5px; font-variant-numeric: tabular-nums; }
+.workflow-actions { display: flex; gap: 8px; }
+.workflow-body { padding: 16px; display: flex; flex-direction: column; gap: 14px; }
+.workflow-step { padding: 14px; background: #faf9f7; border: 1px solid var(--border-light); border-radius: 12px; }
+.step-head { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin-bottom: 12px; }
+.step-head-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; justify-content: flex-end; }
+.step-head-text { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
+.step-head h3 { margin: 0; color: var(--text); font-size: 15px; font-weight: 720; line-height: 1.2; }
+.step-head span { color: var(--text-3); font-size: 11.5px; line-height: 1.3; }
+.step-head-text { position: relative; padding-right: 30px; }
+.step-toggle { position: absolute; top: 0; right: 0; width: 24px; height: 24px; display: inline-flex; align-items: center; justify-content: center; padding: 0; background: #fff; border: 1px solid var(--border); border-radius: 7px; color: var(--text-2); cursor: pointer; transition: all .15s; }
+.step-toggle:hover { border-color: var(--accent); color: var(--accent); }
+.step-toggle svg { width: 12px; height: 12px; transition: transform .18s var(--ease-out); }
+.step-collapsed .step-toggle svg { transform: rotate(0); }
+.workflow-step:not(.step-collapsed) .step-toggle svg { transform: rotate(90deg); }
+.step-collapsed .step-body { display: none; }
+.step-collapsed { padding-bottom: 12px; }
+.step-body { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
+.step-body.single { grid-template-columns: minmax(0, 1fr); }
+.asset-panel { min-width: 0; padding: 12px; background: #fff; border: 1px solid var(--border-light); border-radius: 10px; box-shadow: 0 1px 2px rgba(31,27,24,.03); }
+.asset-editor { min-width: 0; display: flex; flex-direction: column; }
+.asset-result { min-width: 0; display: flex; flex-direction: column; }
+.asset-actions { display: grid; grid-template-columns: minmax(56px, .55fr) minmax(82px, .8fr) minmax(0, 1fr) auto; gap: 6px; align-items: center; margin-top: 9px; }
+.step-body.single .asset-actions { margin-top: 0; }
+.prompt-modal-overlay { z-index: 10000; }
+.prompt-modal { width: min(920px, 88vw); }
+.prompt-modal-input { width: 100%; min-height: 340px; padding: 12px; border: 1px solid var(--border); border-radius: 10px; background: #faf9f7; color: var(--text); font-size: 12.5px; line-height: 1.6; font-family: inherit; resize: vertical; outline: none; box-sizing: border-box; }
+.prompt-modal-input:focus { border-color: var(--accent); background: #fff; box-shadow: 0 0 0 2px var(--focus-ring); }
+.prompt-preview-label { display: block; margin-top: 12px; color: var(--text-2); font-size: 11.5px; font-weight: 680; }
+.prompt-preview { margin: 6px 0 0; padding: 11px; max-height: 150px; overflow: auto; background: #f7f5f2; border: 1px dashed var(--border); border-radius: 8px; color: var(--text-2); font-family: inherit; font-size: 11.5px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
+.asset-panel-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 9px; }
+.asset-panel-head h4 { margin: 0; color: var(--text); font-size: 13px; font-weight: 680; }
+.asset-spec { color: var(--text-3); font-size: 10.5px; white-space: nowrap; }
+.prompt-input { width: 100%; min-height: 82px; padding: 9px 10px; border: 1px solid var(--border); border-radius: 8px; background: #faf9f7; color: var(--text); font-size: 12px; line-height: 1.5; font-family: inherit; resize: vertical; outline: none; box-sizing: border-box; }
+.prompt-input:focus { border-color: var(--accent); background: #fff; box-shadow: 0 0 0 2px var(--focus-ring); }
+.dish-override { min-height: 64px; margin-top: 8px; }
+.overlay-row select, .asset-actions select, .dish-work-head .dish-name-input { min-height: 31px; padding: 5px 8px; border: 1px solid var(--border); border-radius: 7px; background: #faf9f7; color: var(--text); font-size: 11.5px; outline: none; width: 100%; box-sizing: border-box; }
+.asset-actions select:focus, .overlay-row select:focus { border-color: var(--accent); background: #fff; box-shadow: 0 0 0 2px var(--focus-ring); }
+.reference-field label { color: var(--text-2); font-size: 11.5px; font-weight: 600; }
+.ref-strip { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 4px; }
+.ref-strip:empty::after { content: "无参考菜品"; color: var(--text-3); font-size: 11px; padding: 6px 0; }
+.ref-thumb { position: relative; flex: 0 0 auto; width: 50px; height: 50px; overflow: hidden; border: 1px solid var(--border); border-radius: 7px; cursor: pointer; }
+.ref-thumb img { width: 100%; height: 100%; display: block; object-fit: cover; opacity: .72; }
+.ref-thumb input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; }
+.ref-thumb.checked { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
+.ref-thumb.checked::after { content: ""; position: absolute; right: 3px; bottom: 3px; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); }
+.ref-thumb.checked img { opacity: 1; }
+.overlay-row { display: grid; grid-template-columns: 120px 1fr 1fr; gap: 7px; align-items: center; margin-top: 8px; }
+.overlay-row + .overlay-row { margin-top: 7px; }
+.checkbox-label { display: inline-flex; align-items: center; gap: 7px; min-height: 31px; padding: 0 8px; background: #faf9f7; border: 1px solid var(--border); border-radius: 7px; color: var(--text-2); font-size: 11.5px; font-weight: 600; cursor: pointer; white-space: nowrap; }
+.checkbox-label.compact { background: transparent; border: 0; padding: 0; }
+.checkbox-label input { width: 14px; height: 14px; accent-color: var(--accent); margin: 0; }
+.guard-hint { margin-top: 8px; padding: 8px 10px; border: 1px solid rgba(184,134,11,.16); border-radius: 8px; background: var(--warn-bg); color: #8b6508; font-size: 11.5px; line-height: 1.4; }
+.result-area, .dish-result-area { position: relative; min-height: 118px; margin-top: 10px; display: flex; align-items: center; justify-content: center; overflow: hidden; background: var(--bg); border: 1px dashed var(--border); border-radius: 8px; }
+.step-body.single .asset-panel { display: grid; grid-template-columns: minmax(0, 1fr) 180px; gap: 14px; align-items: stretch; }
+.step-body.single .asset-editor { gap: 9px; }
+.step-body.single .asset-editor .asset-panel-head { margin-bottom: 0; }
+.step-body.single .asset-editor .prompt-input { margin-top: 0; }
+.step-body.single .asset-editor .compact-controls { margin: 0; }
+.step-body.single .asset-editor .reference-field { flex: 1; }
+.step-body.single .asset-editor .ref-strip { min-height: 56px; align-items: stretch; }
+.step-body.single .asset-editor .btn { align-self: flex-start; }
+.step-body.single .asset-editor .guard-hint { margin-top: 0; }
+.step-body.single .asset-result { display: flex; align-items: flex-start; justify-content: stretch; }
+.step-body.single .asset-result .result-area { flex: 1 1 auto; width: 100%; height: auto; min-height: 0; margin-top: 0; align-items: center; }
+.step-body.single .asset-result .result-area:not(:has(.result-preview)) { aspect-ratio: 1 / 1; }
+.step-body.single .asset-result .result-preview { width: 100%; display: flex; flex-direction: column; justify-content: center; }
+.step-body.single .asset-result .result-thumb-holder { width: 100%; aspect-ratio: 1 / 1; height: auto; max-height: none; margin: 0 auto; }
+.step-body.single .asset-result .result-thumb-holder img { width: 100%; height: 100%; max-height: none; object-fit: contain; }
+.result-empty { color: var(--text-3); font-size: 11.5px; }
+.result-preview { width: 100%; }
+.result-thumb-holder { position: relative; width: 100%; max-height: 230px; overflow: hidden; cursor: zoom-in; background-image: linear-gradient(45deg, #eee 25%, transparent 25%), linear-gradient(-45deg, #eee 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #eee 75%), linear-gradient(-45deg, transparent 75%, #eee 75%); background-size: 12px 12px; background-position: 0 0, 0 6px, 6px -6px, -6px 0; }
+.result-thumb-holder.transparent-bg { display: block; }
+.result-thumb-holder img { width: 100%; max-height: 230px; object-fit: contain; display: block; }
+.result-download-icon { position: absolute; right: 6px; top: 6px; z-index: 2; width: 26px; height: 26px; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; background: rgba(29, 28, 26, .68); color: #fff; opacity: 0; transition: opacity .14s var(--ease-out), background .14s var(--ease-out), transform .14s var(--ease-out); }
+.result-download-icon svg { width: 14px; height: 14px; display: block; }
+.result-thumb-holder:hover .result-download-icon, .creation-frame:hover .result-download-icon, .result-download-icon:focus-visible, .result-download-icon:hover { opacity: 1; }
+.result-download-icon:hover { background: var(--accent); transform: translateY(-1px); }
+.step-body.single .asset-result .link-btn { justify-self: end; white-space: nowrap; }
+.status-line { display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; color: var(--text-2); font-size: 12px; padding: 4px 8px; text-align: center; }
+.status-line.is-error { color: var(--fail); }
+.status-line.is-warning { color: #8b6508; }
+.hover-zoom-preview { position: fixed; z-index: 9999; display: none; max-width: min(52vw, 620px); max-height: 70vh; overflow: hidden; border-radius: 10px; border: 1px solid rgba(31,27,24,.1); background: #fff; box-shadow: 0 20px 46px rgba(31,27,24,.2); pointer-events: none; }
+.hover-zoom-preview img { max-width: 100%; max-height: 70vh; object-fit: contain; display: block; }
+.poster-crop-panel { margin-top: 8px; padding: 9px; background: #faf9f7; border: 1px solid var(--border-light); border-radius: 8px; }
+.crop-actions { display: flex; gap: 8px; margin-top: 7px; }
+.dish-config-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
+.dish-work-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 10px; }
+.dish-work-card { min-width: 0; padding: 10px; background: #fff; border: 1px solid var(--border-light); border-radius: 10px; }
+.dish-config { margin-top: 9px; padding-top: 9px; border-top: 1px solid var(--border-light); }
+.dish-config-title { color: var(--text); font-size: 11.5px; font-weight: 680; }
+.dish-prompt-status { flex: 0 1 auto; overflow: hidden; color: var(--text-3); font-size: 10px; text-align: right; text-overflow: ellipsis; white-space: nowrap; }
+.dish-prompt-status.inherit { color: var(--accent-dark); }
+.dish-config-actions { display: grid; gap: 6px; margin-top: 7px; }
+.dish-config-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
+.dish-config-actions .btn { width: 100%; }
+.dish-config .overlay-row { grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr); gap: 6px 5px; margin-top: 0; }
+.dish-config .prompt-input { min-height: 84px; margin-top: 8px; font-size: 11px; line-height: 1.45; }
+.dish-config .guard-hint { margin-top: 7px; }
+.batch-dish-modal { width: min(760px, 92vw); }
+.batch-dish-modal .modal-body { padding: 18px 20px 6px; max-height: min(74vh, 680px); overflow: auto; }
+.batch-summary { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 9px 11px; background: rgba(255, 107, 53, .07); border: 1px solid rgba(255, 107, 53, .12); border-radius: 9px; color: var(--accent-dark); font-size: 12px; font-weight: 650; }
+.batch-summary::before { content: ""; flex: 0 0 auto; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
+.batch-controls { padding: 10px; margin-bottom: 14px; background: #faf9f7; border: 1px solid var(--border-light); border-radius: 10px; }
+.batch-controls .overlay-row { grid-template-columns: minmax(96px, auto) minmax(0, 1fr) minmax(0, 1fr); gap: 7px; margin-top: 0; }
+.batch-config-label { display: block; margin-bottom: 8px; }
+.batch-controls .overlay-row + .overlay-row { margin-top: 7px; }
+.batch-prompt { width: 100%; min-height: 240px; margin-top: 6px; font-size: 12px; line-height: 1.6; }
+.batch-config-label, .batch-prompt-label { margin-bottom: 7px; color: var(--text-2); font-size: 11.5px; font-weight: 680; }
+.batch-prompt-label:not(:first-child) { margin-top: 12px; }
+.batch-prompt-preview { margin: 6px 0 0; padding: 11px; max-height: 145px; overflow: auto; background: #f7f5f2; border: 1px dashed var(--border); border-radius: 8px; color: var(--text-2); font-family: inherit; font-size: 11.5px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
+.dish-work-head { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 7px; }
+.dish-name-input { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
+.dish-work-frames { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 9px; }
+.dish-frame { margin: 0; min-width: 0; }
+.dish-frame img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 7px; border: 1px solid var(--border-light); display: block; }
+.dish-frame figcaption { margin-top: 4px; color: var(--text-3); font-size: 10.5px; text-align: center; }
+.dish-frame .dish-result-area { min-height: 104px; margin-top: 0; overflow: hidden; background: transparent; border: 0; border-radius: 0; }
+.dish-frame .result-preview { width: 100%; }
+.dish-frame .result-thumb-holder { width: 100%; aspect-ratio: 1; height: auto; max-height: none; border: 1px solid var(--border-light); border-radius: 7px; }
+.dish-frame .result-thumb-holder img { width: 100%; height: 100%; max-height: none; object-fit: cover; }
+.link-btn { color: var(--accent); background: transparent; border: 0; padding: 0; font-size: 11.5px; font-weight: 650; cursor: pointer; text-decoration: none; }
+.link-btn:hover { color: var(--accent-dark); }
+@media (max-width: 1440px) { .step-body { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
+@media (max-width: 1080px) {
+  .workflow-head { grid-template-columns: 1fr; align-items: stretch; }
+  .step-body, .step-body.single { grid-template-columns: 1fr; }
+  .step-body.single .asset-panel { grid-template-columns: 1fr; }
+  .step-body.single .asset-result .result-area { min-height: 240px; }
+  .overlay-row { grid-template-columns: 1fr; }
+}
+.shop-head { display: flex; align-items: center; gap: 14px; padding: 14px 18px; border-bottom: 1px solid var(--border-light); }
+.shop-head .shop-info { flex: 1; min-width: 0; }
+.shop-head .shop-name { font-size: 14px; font-weight: 700; color: var(--text); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
+.shop-head .shop-path { font-size: 12px; color: var(--text-3); margin-top: 4px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
+.shop-head .shop-actions { display: flex; gap: 8px; flex-shrink: 0; }
+
+/* brand-section */
+.brand-section { padding: 14px 18px; border-bottom: 1px solid var(--border-light); }
+.brand-section .section-title { font-size: 11.5px; font-weight: 600; color: var(--text-2); text-transform: uppercase; letter-spacing: 0.04em; margin: 0 0 10px; }
+.brand-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
+.brand-cell { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
+.brand-cell .thumb { aspect-ratio: 4 / 3; border-radius: var(--radius-md); border: 1px solid var(--border-light); display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 700; font-size: 12px; letter-spacing: 0.05em; position: relative; overflow: hidden; background: var(--bg); color: var(--text-3); }
+.brand-cell .thumb img { width: 100%; height: 100%; object-fit: cover; }
+.brand-cell .thumb .corner-badge { position: absolute; top: 6px; right: 6px; font-size: 10px; padding: 1px 6px; }
+.brand-cell .thumb.is-pending { border-style: dashed; font-weight: 500; }
+.brand-cell .meta { font-size: 11px; color: var(--text-2); display: flex; justify-content: space-between; gap: 6px; }
+.brand-cell .meta .name { color: var(--text); font-weight: 500; }
+.brand-cell .meta .status { font-variant-numeric: tabular-nums; }
+
+/* dish-section */
+.dish-section { padding: 14px 18px 18px; }
+.dish-section .section-title { font-size: 11.5px; font-weight: 600; color: var(--text-2); text-transform: uppercase; letter-spacing: 0.04em; margin: 0 0 10px; display: flex; align-items: center; justify-content: space-between; }
+.dish-section .section-title .count { color: var(--text-3); font-weight: 500; text-transform: none; letter-spacing: 0; }
+.dish-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; }
+.dish-card { background: var(--card-bg); border: 1px solid var(--border-light); border-radius: var(--radius-md); overflow: hidden; display: flex; flex-direction: column; min-width: 0; cursor: pointer; transition: border-color 0.15s; }
+.dish-card:hover { border-color: var(--accent); }
+.dish-card .dish-thumb { aspect-ratio: 1 / 1; width: 100%; display: block; object-fit: cover; }
+.dish-card .thumb-is-pending { aspect-ratio: 1 / 1; display: flex; align-items: center; justify-content: center; background: var(--bg); color: var(--text-3); font-size: 20px; border-style: dashed; }
+.dish-card .meta-row { padding: 8px 10px; display: flex; justify-content: space-between; align-items: center; font-size: 12px; gap: 8px; }
+.dish-card .meta-row .name { color: var(--text); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.dish-card .meta-row .price { color: var(--accent); font-weight: 700; font-variant-numeric: tabular-nums; flex-shrink: 0; }
+
+/* history gallery */
+.history-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 14px; margin-bottom: 14px; }
+.history-search { position: relative; flex: 0 1 320px; min-width: 220px; }
+.history-search svg { position: absolute; left: 11px; top: 50%; width: 15px; height: 15px; transform: translateY(-50%); color: var(--text-3); pointer-events: none; }
+.history-search input { width: 100%; min-height: 34px; padding: 0 10px 0 34px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: #faf9f7; color: var(--text); font-size: 12.5px; outline: none; transition: border-color .15s, box-shadow .15s, background .15s; }
+.history-search input:focus { border-color: var(--accent); background: #fff; box-shadow: 0 0 0 2px var(--focus-ring); }
+.history-filters { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; justify-content: flex-end; }
+.history-filter { min-height: 30px; padding: 0 11px; border: 1px solid var(--border); border-radius: 999px; background: #faf9f7; color: var(--text-2); font-size: 12px; font-weight: 600; cursor: pointer; transition: all .14s var(--ease-out); }
+.history-filter:hover { color: var(--text); border-color: var(--border); background: #fff; }
+.history-filter.active { border-color: rgba(255,107,53,.18); background: rgba(255,107,53,.08); color: var(--accent); }
+.history-summary { padding: 0 28px 12px; color: var(--text-3); font-size: 12px; }
+.history-gallery { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); align-items: start; gap: 14px; padding: 0 28px 60px; }
+.history-gallery.empty { display: flex; min-height: clamp(360px, calc(100vh - 330px), 620px); }
+.creation-card { break-inside: avoid; margin: 0 0 14px; overflow: hidden; background: var(--card-bg); border: 1px solid var(--border-light); border-radius: 12px; transition: transform .18s var(--ease-out), border-color .18s var(--ease-out), box-shadow .18s var(--ease-out); }
+.creation-card:hover { transform: translateY(-2px); border-color: var(--border); box-shadow: 0 12px 28px rgba(31,27,24,.08); }
+.creation-frame { position: relative; overflow: hidden; cursor: zoom-in; background: var(--bg); }
+.creation-frame img { display: block; width: 100%; max-height: 250px; object-fit: cover; transition: transform .25s var(--ease-out); }
+.creation-frame.transparent-bg { background-image: linear-gradient(45deg, #eee 25%, transparent 25%), linear-gradient(-45deg, #eee 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #eee 75%), linear-gradient(-45deg, transparent 75%, #eee 75%); background-size: 12px 12px; background-position: 0 0, 0 6px, 6px -6px, -6px 0; }
+.creation-kind { position: absolute; top: 8px; left: 8px; padding: 2px 7px; border-radius: 999px; background: rgba(29,28,26,.68); color: #fff; font-size: 10px; font-weight: 700; letter-spacing: .02em; backdrop-filter: blur(4px); }
+.creation-card figcaption { padding: 10px 12px 12px; }
+.creation-title { color: var(--text); font-size: 12.5px; font-weight: 650; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.creation-shop { margin-top: 3px; color: var(--text-2); font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.creation-meta { margin-top: 5px; color: var(--text-3); font-size: 10.5px; font-variant-numeric: tabular-nums; }
+.history-empty { flex: 1 1 auto; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 28px; text-align: center; }
+.history-empty-icon { width: 76px; height: 76px; margin: 0 auto 16px; display: flex; align-items: center; justify-content: center; color: var(--text-3); opacity: .16; }
+.history-empty-icon svg { width: 100%; height: 100%; }
+.history-empty p { margin: 0; color: var(--text-2); font-size: 14px; font-weight: 650; }
+.history-empty small { display: block; margin-top: 6px; color: var(--text-3); font-size: 11.5px; }
+
+@media (max-width: 1080px) {
+  .history-toolbar { align-items: stretch; flex-direction: column; }
+  .history-search { flex-basis: auto; }
+  .history-filters { justify-content: flex-start; }
+}

+ 1705 - 0
server.js

@@ -0,0 +1,1705 @@
+'use strict';
+
+const http = require('http');
+const https = require('https');
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const { execFile } = require('child_process');
+const { URL } = require('url');
+const jpeg = require('jpeg-js');
+const { PNG } = require('pngjs');
+const { getImageSize } = require('./imageMeta.js');
+
+let config = JSON.parse(fs.readFileSync(path.join(__dirname, 'config.json'), 'utf8'));
+
+function saveConfig() {
+  const temp = path.join(__dirname, 'config.json.tmp');
+  fs.writeFileSync(temp, JSON.stringify(config, null, 2), 'utf8');
+  fs.renameSync(temp, path.join(__dirname, 'config.json'));
+}
+const WORKSPACE_ROOT = path.join(path.dirname(__dirname), 'workspace');
+function getWorkspaceRoot() {
+  const configuredRoot = String(config.rootDir || WORKSPACE_ROOT).trim() || WORKSPACE_ROOT;
+  return path.resolve(configuredRoot);
+}
+fs.mkdirSync(getWorkspaceRoot(), { recursive: true });
+const SESSION_PATH = path.join(__dirname, 'session.json');
+const MANUAL_SHOPS_PATH = path.join(__dirname, 'manual-shops.json');
+
+const MODELS = [
+  { id: 'gpt-image-2', label: 'GPT Image 2(默认)' },
+  { id: 'gpt-image-2-guan', label: 'GPT Image 2 官转' },
+  { id: 'doubao-seedream-5-0-pro-260628', label: '豆包 Seedream 5.0 Pro' },
+  { id: 'doubao-seedream-5-0-260128', label: '即梦 5.0(Seedream 5.0)' },
+  { id: 'doubao-seedream-4-5-251128', label: '即梦 4.5(Seedream 4.5)' },
+  { id: 'wan2.6-image', label: '通义万相 wan2.6-image' },
+];
+const MODEL_IDS = new Set(MODELS.map((m) => m.id));
+
+// These seedream variants don't support the '1K' tier the UI's size dropdown offers by
+// default (doubao-seedream-4-5-251128: 2K/4K only; doubao-seedream-5-0-260128: 2K/3K only) -
+// map it up to '2K' rather than sending an invalid enum value.
+const NO_1K_SUPPORT = new Set(['doubao-seedream-4-5-251128', 'doubao-seedream-5-0-260128']);
+
+const IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
+const MIME_BY_EXT = {
+  '.jpg': 'image/jpeg',
+  '.jpeg': 'image/jpeg',
+  '.png': 'image/png',
+  '.webp': 'image/webp',
+  '.gif': 'image/gif',
+};
+const POSTER_CANVAS_SPECS = {
+  banner: { fileName: '店内海报_1138x292.png', width: 1138, height: 292 },
+  signage: { fileName: '招牌头图_750x288.png', width: 750, height: 288 },
+};
+
+const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
+const SKIP_DIR_NAMES = new Set(['膳绘']);
+
+// ---------- small utils ----------
+
+function sendJson(res, statusCode, obj) {
+  const data = JSON.stringify(obj);
+  res.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' });
+  res.end(data);
+}
+
+function sleep(ms) {
+  return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function sanitizeFileName(name) {
+  return String(name).replace(/[\\/:*?"<>|]/g, '_').trim() || 'file';
+}
+
+function readBody(req) {
+  return new Promise((resolve, reject) => {
+    const chunks = [];
+    req.on('data', (c) => chunks.push(c));
+    req.on('end', () => {
+      if (!chunks.length) return resolve({});
+      try {
+        resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
+      } catch (e) {
+        reject(e);
+      }
+    });
+    req.on('error', reject);
+  });
+}
+
+// ============================================================
+// CRAWLER MODULE — 闪购菜品图爬取
+// ============================================================
+
+let session = { cookie: '' };
+
+function extractCookieValue(cookie, name) {
+  const match = String(cookie || '').match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
+  if (!match) return '';
+  try { return decodeURIComponent(match[1]); } catch { return match[1]; }
+}
+
+function getKsid() { return extractCookieValue(session.cookie, 'ksid'); }
+
+function loadSession() {
+  try {
+    const saved = JSON.parse(fs.readFileSync(SESSION_PATH, 'utf8'));
+    session.cookie = String(saved.cookie || '');
+  } catch { /* no saved session */ }
+}
+
+function saveSession() {
+  fs.writeFileSync(SESSION_PATH, JSON.stringify(session, null, 2), 'utf8');
+}
+
+loadSession();
+
+function invokeApi(service, method, requestParams, extraMetas) {
+  return new Promise((resolve, reject) => {
+    const body = {
+      id: crypto.randomBytes(16).toString('hex').toUpperCase() + '|' + Date.now(),
+      method, service,
+      params: { request: requestParams || {} },
+      metas: Object.assign({ appName: 'melody', appVersion: '4.4.0', ksid: getKsid() }, extraMetas || {}),
+      ncp: '2.0.0', adapter: 'ncp',
+      extendConfig: { docMode: 'master,,' },
+    };
+    const data = JSON.stringify(body);
+    const options = {
+      hostname: 'app-api.shop.ele.me', port: 443,
+      path: `/nevermore.goods/invoke?method=${encodeURIComponent(service + '.' + method)}`,
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json; charset=UTF-8', Accept: 'application/json',
+        Origin: 'https://napos-goods-pc.faas.ele.me', Referer: 'https://napos-goods-pc.faas.ele.me/',
+        Cookie: session.cookie || '', 'User-Agent': UA,
+        'Content-Length': Buffer.byteLength(data),
+      },
+      timeout: config.requestTimeoutMs,
+    };
+    const req = https.request(options, (res) => {
+      const chunks = [];
+      res.on('data', (c) => chunks.push(c));
+      res.on('end', () => {
+        const raw = Buffer.concat(chunks).toString('utf8');
+        let json = null;
+        try { json = JSON.parse(raw); } catch { /* keep raw */ }
+        resolve({ statusCode: res.statusCode, json, raw });
+      });
+    });
+    req.on('error', reject);
+    req.on('timeout', () => req.destroy(new Error('请求超时')));
+    req.write(data);
+    req.end();
+  });
+}
+
+function isAuthError(resp) {
+  if (resp.statusCode === 401 || resp.statusCode === 403) return true;
+  const j = resp.json;
+  if (!j) return false;
+  if (j.result !== undefined) return false;
+  return /未登录|请登录|login|unauthorized|token.*expired|session.*expired/i.test(JSON.stringify(j));
+}
+
+function downloadImage(urlStr, redirectsLeft) {
+  if (redirectsLeft === undefined) redirectsLeft = 5;
+  return new Promise((resolve, reject) => {
+    let u;
+    try { u = new URL(urlStr); } catch (e) { return reject(new Error('无效的图片地址: ' + urlStr)); }
+    const mod = u.protocol === 'http:' ? http : https;
+    mod.get(u, { headers: { Referer: 'https://melody.shop.ele.me/', 'User-Agent': UA } }, (res) => {
+      if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirectsLeft > 0) {
+        res.resume();
+        return resolve(downloadImage(res.headers.location, redirectsLeft - 1));
+      }
+      if (res.statusCode !== 200) { res.resume(); return reject(new Error('下载图片失败 HTTP ' + res.statusCode)); }
+      const chunks = [];
+      res.on('data', (c) => chunks.push(c));
+      res.on('end', () => resolve(Buffer.concat(chunks)));
+    }).on('error', reject);
+  });
+}
+
+let shopList = [];
+
+function loadManualShops() {
+  try {
+    const data = JSON.parse(fs.readFileSync(MANUAL_SHOPS_PATH, 'utf8'));
+    if (data.version !== 1 || !Array.isArray(data.shops)) throw new Error('清单格式无效');
+    const shopsById = new Map();
+    for (const shop of data.shops) {
+      if (!shop || !Number.isInteger(shop.shopId) || shop.shopId <= 0) continue;
+      shopsById.set(shop.shopId, { shopId: shop.shopId, shopName: String(shop.shopName || shop.shopId), source: 'manual' });
+    }
+    shopList = Array.from(shopsById.values());
+  } catch (e) {
+    if (e.code !== 'ENOENT') console.warn(`读取手动店铺清单失败: ${e.message || e}`);
+  }
+}
+
+function saveManualShops(shops) {
+  const temp = `${MANUAL_SHOPS_PATH}.${process.pid}.tmp`;
+  fs.writeFileSync(temp, JSON.stringify({ version: 1, shops }, null, 2), 'utf8');
+  fs.renameSync(temp, MANUAL_SHOPS_PATH);
+}
+
+function mergeShops(newOnes) {
+  const shopsById = new Map(shopList.map((shop) => [shop.shopId, shop]));
+  for (const shop of newOnes) shopsById.set(shop.shopId, shop);
+  return Array.from(shopsById.values());
+}
+
+loadManualShops();
+
+function completionStatePath(root) { return path.join(root, '_膳绘_店铺状态.json'); }
+
+function readCompletionState(root) {
+  try {
+    const data = JSON.parse(fs.readFileSync(completionStatePath(root), 'utf8'));
+    return data.version === 1 && data.shops && typeof data.shops === 'object' ? data.shops : {};
+  } catch { return {}; }
+}
+
+function writeCompletionState(root, shops) {
+  const target = completionStatePath(root);
+  const temp = `${target}.${process.pid}.tmp`;
+  fs.writeFileSync(temp, JSON.stringify({ version: 1, shops }, null, 2), 'utf8');
+  fs.renameSync(temp, target);
+}
+
+function markShopCompleted(root, shopEntry) {
+  const shops = readCompletionState(root);
+  shops[String(shopEntry.shopId)] = {
+    shopId: shopEntry.shopId, shopName: shopEntry.shopName, folderName: shopEntry.folderName,
+    completedAt: new Date().toISOString(), downloaded: shopEntry.downloaded,
+    skipped: shopEntry.skipped, total: shopEntry.total,
+  };
+  writeCompletionState(root, shops);
+}
+
+function handleShopsManual(body, res) {
+  const lines = String((body && body.text) || '').split(/\r?\n/);
+  const parsed = [];
+  for (const line of lines) {
+    const t = line.trim();
+    if (!t) continue;
+    const parts = t.split(/[\s,,]+/).filter(Boolean);
+    const idPart = parts.find((p) => /^\d+$/.test(p));
+    if (!idPart) continue;
+    const shopId = Number(idPart);
+    const shopName = parts.filter((p) => p !== idPart).join(' ') || String(shopId);
+    parsed.push({ shopId, shopName, source: 'manual' });
+  }
+  const previousShops = shopList;
+  const mergedShops = mergeShops(parsed);
+  try { saveManualShops(mergedShops); shopList = mergedShops; }
+  catch (e) { shopList = previousShops; throw e; }
+  sendJson(res, 200, { shops: shopList, addedCount: parsed.length });
+}
+
+function handleShopsList(root, res) {
+  const completed = root && fs.existsSync(root) ? readCompletionState(root) : {};
+  sendJson(res, 200, {
+    shops: shopList.map((shop) => ({ ...shop, completed: completed[String(shop.shopId)] || null })),
+  });
+}
+
+function listRootDirs(root) {
+  return fs.readdirSync(root, { withFileTypes: true })
+    .filter((e) => e.isDirectory() && !e.name.startsWith('.') && !SKIP_DIR_NAMES.has(e.name))
+    .map((e) => e.name);
+}
+
+function resolveShopDir(root, shopId, shopName) {
+  const dirs = listRootDirs(root);
+  const suffix = '_' + String(shopId);
+  const match = dirs.find((d) => d.endsWith(suffix));
+  if (match) return { dir: path.join(root, match), folderName: match, reused: true };
+  let maxSeq = 0;
+  for (const d of dirs) {
+    const mm = /^(\d+)_/.exec(d);
+    if (mm) maxSeq = Math.max(maxSeq, parseInt(mm[1], 10));
+  }
+  const seq = String(maxSeq + 1).padStart(3, '0');
+  const folderName = `${seq}_${sanitizeFileName(shopName)}_${shopId}`;
+  const dir = path.join(root, folderName);
+  fs.mkdirSync(dir, { recursive: true });
+  return { dir, folderName, reused: false };
+}
+
+let crawlState = { status: 'idle', startedAt: null, finishedAt: null, shops: [], log: [] };
+let cancelFlag = false;
+
+const systemLogs = [];
+let logSeq = 0;
+
+function detectLogLevel(message) {
+  if (/失败|错误|异常|过期|error|鉴权/i.test(message)) return 'error';
+  if (/停止|跳过|警告|warn/i.test(message)) return 'warn';
+  if (/完成|成功|已保存|已启动|done/i.test(message)) return 'success';
+  return 'info';
+}
+
+function createLogEntry(message, source = 'task', level = 'info') {
+  const now = new Date();
+  return {
+    seq: ++logSeq,
+    id: `${source}-${logSeq}`,
+    source,
+    level,
+    time: now.toTimeString().slice(0, 8),
+    timestamp: now.getTime(),
+    message: String(message),
+  };
+}
+
+function pushLog(msg) {
+  const entry = createLogEntry(msg, 'task', detectLogLevel(msg));
+  crawlState.log.push(entry);
+  if (crawlState.log.length > 500) crawlState.log.shift();
+}
+
+function pushSystemLog(msg, level = 'info') {
+  systemLogs.push(createLogEntry(msg, 'system', level));
+  if (systemLogs.length > 500) systemLogs.shift();
+}
+
+function getMergedLogs() {
+  return [...crawlState.log, ...systemLogs]
+    .sort((a, b) => b.seq - a.seq)
+    .slice(0, 500);
+}
+
+async function startCrawl(body, res) {
+  if (crawlState.status === 'running') return sendJson(res, 409, { error: '已有抓取任务在进行中' });
+  if (!session.cookie || !getKsid()) return sendJson(res, 400, { error: '请先填写包含 ksid 的 Cookie' });
+  const root = String((body && body.root) || getWorkspaceRoot()).trim();
+  if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
+    return sendJson(res, 400, { error: '输出根目录不存在: ' + root });
+  }
+  const shopIds = Array.isArray(body && body.shopIds) ? body.shopIds.map(Number) : [];
+  const selectedShops = shopIds.map((id) => shopList.find((s) => s.shopId === id)).filter(Boolean);
+  if (!selectedShops.length) return sendJson(res, 400, { error: '没有可抓取的店铺,请先填写手动店铺清单并勾选' });
+  const forceFullCrawl = Boolean(body && body.forceFullCrawl);
+  const completed = readCompletionState(root);
+  const skippedCompleted = forceFullCrawl ? [] : selectedShops.filter((s) => completed[String(s.shopId)]);
+  const shops = forceFullCrawl ? selectedShops : selectedShops.filter((s) => !completed[String(s.shopId)]);
+  const pageSize = Number(body && body.pageSize) || config.pageSize;
+  const delayMs = Number(body && body.delayMs) || config.delayMs;
+
+  cancelFlag = false;
+  crawlState = {
+    status: shops.length ? 'running' : 'done', startedAt: Date.now(),
+    finishedAt: shops.length ? null : Date.now(), forceFullCrawl,
+    shops: [
+      ...skippedCompleted.map((s) => ({
+        shopId: s.shopId, shopName: s.shopName, folderName: completed[String(s.shopId)].folderName || null,
+        state: 'skipped_completed', total: completed[String(s.shopId)].total || 0,
+        downloaded: 0, skipped: 0, failed: 0, error: null,
+      })),
+      ...shops.map((s) => ({
+        shopId: s.shopId, shopName: s.shopName, folderName: null, state: 'pending',
+        total: 0, downloaded: 0, skipped: 0, failed: 0, error: null,
+      })),
+    ],
+    log: [],
+  };
+  pushLog(`开始抓取,实际抓取 ${shops.length} 家${skippedCompleted.length ? `,历史完成跳过 ${skippedCompleted.length} 家` : ''},root=${root}`);
+  sendJson(res, 200, { ok: true, crawlCount: shops.length, skippedCompletedCount: skippedCompleted.length });
+  if (!shops.length) return pushLog('所选店铺均已完成;勾选"强制全量抓取"可重新下载');
+
+  runCrawlLoop(root, pageSize, delayMs, forceFullCrawl).catch((e) => {
+    crawlState.status = 'error';
+    crawlState.finishedAt = Date.now();
+    pushLog('抓取流程异常终止: ' + (e.message || e));
+  });
+}
+
+async function runCrawlLoop(root, pageSize, delayMs, forceFullCrawl) {
+  for (const shopEntry of crawlState.shops) {
+    if (shopEntry.state === 'skipped_completed') continue;
+    if (cancelFlag) {
+      crawlState.status = 'stopped';
+      crawlState.finishedAt = Date.now();
+      pushLog('已停止');
+      return;
+    }
+    shopEntry.state = 'running';
+    pushLog(`[${shopEntry.shopName}] 开始`);
+
+    let dirInfo;
+    try { dirInfo = resolveShopDir(root, shopEntry.shopId, shopEntry.shopName); }
+    catch (e) {
+      shopEntry.state = 'failed';
+      shopEntry.error = '创建目录失败: ' + (e.message || e);
+      pushLog(`[${shopEntry.shopName}] 创建目录失败: ${e.message || e}`);
+      continue;
+    }
+    shopEntry.folderName = dirInfo.folderName;
+    pushLog(`[${shopEntry.shopName}] ${dirInfo.reused ? '复用已有目录' : '新建目录'} ${dirInfo.folderName}`);
+
+    const allItems = [];
+    const seen = new Set();
+    let nextId;
+    let page = 0;
+    const MAX_PAGES = 200;
+    let authFailed = false;
+
+    while (page < MAX_PAGES) {
+      if (cancelFlag) break;
+      page++;
+      const requestParams = {
+        shopId: shopEntry.shopId, pageSize, queryType: 'SHOP',
+        searchKey: '', searchType: ['ITEM_NAME'],
+      };
+      if (nextId) requestParams.nextId = nextId;
+
+      let resp;
+      try { resp = await invokeApi('ItemPhotoProcessService', 'getItemPhotoGallery', requestParams, { shopId: shopEntry.shopId }); }
+      catch (e) {
+        shopEntry.error = '请求失败: ' + (e.message || e);
+        pushLog(`[${shopEntry.shopName}] 请求失败: ${e.message || e}`);
+        break;
+      }
+      if (isAuthError(resp)) {
+        authFailed = true;
+        shopEntry.error = '会话可能已过期,请重新登录后更新 Cookie';
+        pushLog(`[${shopEntry.shopName}] 鉴权失败(ksid/Cookie 可能已过期)`);
+        break;
+      }
+      const items = (resp.json && resp.json.result && resp.json.result.result) || [];
+      nextId = resp.json && resp.json.result && resp.json.result.nextId;
+      if (!items.length) {
+        pushLog(`[${shopEntry.shopName}] 第${page}页无数据,结束翻页`);
+        break;
+      }
+      let newCount = 0;
+      for (const it of items) {
+        const gid = it.itemGlobalId;
+        if (gid !== undefined && gid !== null && seen.has(gid)) continue;
+        if (gid !== undefined && gid !== null) seen.add(gid);
+        allItems.push(it);
+        newCount++;
+      }
+      pushLog(`[${shopEntry.shopName}] 第${page}页 新增${newCount}条`);
+      if (!nextId) break;
+      await sleep(200);
+    }
+
+    shopEntry.total = allItems.length;
+    if (authFailed && !allItems.length) {
+      shopEntry.state = 'failed';
+      continue;
+    }
+
+    pushLog(`[${shopEntry.shopName}] 共${allItems.length}个菜品,开始下载`);
+    const manifest = [];
+    const usedNames = new Set();
+    for (const it of allItems) {
+      if (cancelFlag) break;
+      const itemName = String(it.itemName || it.itemGlobalId || 'unnamed').trim();
+      const photoUrl = it.photoUrl || it.thumbnailUrl;
+      if (!photoUrl) {
+        shopEntry.skipped++;
+        manifest.push({ itemName, itemGlobalId: it.itemGlobalId, photoUrl: null, savedFile: null, status: 'no_photo' });
+        continue;
+      }
+      let finalBase = sanitizeFileName(itemName);
+      let n = 1;
+      while (usedNames.has(finalBase)) { finalBase = `${sanitizeFileName(itemName)}_${n++}`; }
+      usedNames.add(finalBase);
+      try {
+        const buf = await downloadImage(photoUrl);
+        const ext = guessExtFromUrl(photoUrl);
+        const fileName = `${finalBase}.${ext}`;
+        fs.writeFileSync(path.join(dirInfo.dir, fileName), buf);
+        shopEntry.downloaded++;
+        manifest.push({ itemName, itemGlobalId: it.itemGlobalId, photoUrl, savedFile: fileName, status: 'downloaded' });
+      } catch (e) {
+        shopEntry.failed++;
+        manifest.push({ itemName, itemGlobalId: it.itemGlobalId, photoUrl, savedFile: null, status: 'download_failed', error: e.message || String(e) });
+        pushLog(`[${shopEntry.shopName}] 下载失败 ${itemName}: ${e.message || e}`);
+      }
+      await sleep(delayMs);
+    }
+
+    try {
+      fs.writeFileSync(path.join(dirInfo.dir, '_菜品图爬取记录.json'), JSON.stringify(manifest, null, 2), 'utf8');
+      if (!shopEntry.failed && !cancelFlag) markShopCompleted(root, shopEntry);
+    } catch (e) {
+      shopEntry.failed++;
+      shopEntry.error = `保存抓取状态失败: ${e.message || e}`;
+      pushLog(`[${shopEntry.shopName}] 保存抓取状态失败: ${e.message || e}`);
+    }
+    shopEntry.state = shopEntry.failed ? 'failed' : cancelFlag ? 'stopped' : 'done';
+    pushLog(`[${shopEntry.shopName}] 完成: 成功${shopEntry.downloaded} 跳过${shopEntry.skipped} 失败${shopEntry.failed}`);
+  }
+
+  crawlState.status = cancelFlag ? 'stopped' : 'done';
+  crawlState.finishedAt = Date.now();
+  pushLog('抓取任务结束');
+}
+
+function stopCrawl(res) {
+  if (crawlState.status !== 'running') return sendJson(res, 200, { ok: true, note: '当前没有正在进行的任务' });
+  cancelFlag = true;
+  pushLog('收到停止请求,将在当前步骤完成后停止');
+  sendJson(res, 200, { ok: true });
+}
+
+// ============================================================
+// GENERATOR MODULE — 店铺视觉图生成
+// ============================================================
+
+const OVERLAY_POSITION_LABELS = {
+  'top-left': '左上角',
+  'top-right': '右上角',
+  'bottom-left': '左下角',
+  'bottom-right': '右下角',
+};
+
+function findExistingAsset(shopDir, baseName) {
+  const dir = path.join(shopDir, '_生成图片');
+  for (const ext of IMAGE_EXT) {
+    const p = path.join(dir, baseName + ext);
+    if (fs.existsSync(p)) return p;
+  }
+  return null;
+}
+
+const findExistingLogo = (shopDir) => findExistingAsset(shopDir, 'logo');
+const findExistingSticker = (shopDir) => findExistingAsset(shopDir, '贴纸');
+
+function readAsDataUri(filePath) {
+  const buf = fs.readFileSync(filePath);
+  let ext = path.extname(filePath).toLowerCase().replace('.', '');
+  if (ext === 'jpg') ext = 'jpeg';
+  return `data:image/${ext};base64,${buf.toString('base64')}`;
+}
+
+// Builds the reference-image list and final prompt for a generation request, optionally
+// fusing the shop's already-generated LOGO and/or 贴纸(sticker) into the frame. Each overlay
+// can use either of two modes:
+//  - 'ai' (default): the asset is passed as an extra reference image and the model is asked
+//    to paint it into the frame itself.
+//  - 'composite': the model is only asked to leave the target corner visually clean; the
+//    actual pixel fusion is done afterwards by compositeOverlaysOntoImage() with no LLM call.
+function buildImagesAndPrompt(shopDir, basePrompt, refDishFiles, opts) {
+  const { addLogo, logoPosition, logoMode, addSticker, stickerPosition, stickerMode } = opts || {};
+  const images = [];
+  for (const f of (refDishFiles || []).slice(0, 3)) {
+    try {
+      images.push(readAsDataUri(path.join(shopDir, f)));
+    } catch {
+      // skip unreadable reference image
+    }
+  }
+  const warnings = [];
+  const overlayClauses = [];
+  const compositeOverlays = [];
+
+  if (addLogo) {
+    const logoPath = findExistingLogo(shopDir);
+    if (logoPath) {
+      const posLabel = OVERLAY_POSITION_LABELS[logoPosition] || OVERLAY_POSITION_LABELS['bottom-right'];
+      if (logoMode === 'composite') {
+        compositeOverlays.push({ type: 'logo', path: logoPath, position: logoPosition || 'bottom-right' });
+        overlayClauses.push(
+          opts.sourceImageFirst
+            ? `第一张图是菜品原图,第二张图是店铺指定LOGO。必须彻底移除菜品原图中已有的LOGO、水印、店铺名、徽章和旧品牌标识,并自然修复该区域背景;不要模仿或保留这些旧标识。画面${posLabel}保持干净,留出用于程序叠加店铺指定LOGO的区域。`
+            : `画面的${posLabel}请保持背景简洁、不绘制任何LOGO或品牌图案(该区域将在生成后由程序直接叠加店铺LOGO图片)`
+        );
+      } else {
+        try {
+          images.push(readAsDataUri(logoPath));
+          overlayClauses.push(
+            opts.sourceImageFirst
+              ? `第一张图是菜品原图,第二张图是店铺指定LOGO。店铺指定LOGO是唯一权威品牌标识;必须彻底移除菜品原图中已有的LOGO、水印、店铺名、徽章和旧品牌标识,并自然修复该区域背景。只在画面${posLabel}使用店铺指定LOGO,严格保持其图形、文字、配色和比例完整清晰,不要复制、模仿或保留原图旧标识。`
+              : `参考图中的店铺LOGO图案(简洁品牌标识)请原样添加到画面的${posLabel}`
+          );
+        } catch {
+          warnings.push('读取已生成的LOGO文件失败,本次生成未附加LOGO');
+        }
+      }
+    } else {
+      warnings.push('未找到可用LOGO,请先生成');
+    }
+  }
+  if (addSticker) {
+    const stickerPath = findExistingSticker(shopDir);
+    if (stickerPath) {
+      const posLabel = OVERLAY_POSITION_LABELS[stickerPosition] || OVERLAY_POSITION_LABELS['bottom-left'];
+      if (stickerMode === 'composite') {
+        compositeOverlays.push({ type: 'sticker', path: stickerPath, position: stickerPosition || 'bottom-left' });
+        overlayClauses.push(`画面的${posLabel}请保持背景简洁、不绘制任何贴纸/徽章图案(该区域将在生成后由程序直接叠加店铺贴纸图片)`);
+      } else {
+        try {
+          images.push(readAsDataUri(stickerPath));
+          overlayClauses.push(`参考图中的店铺贴纸插画(徽章/贴纸风格图案)请原样叠加到画面的${posLabel}`);
+        } catch {
+          warnings.push('读取已生成的贴纸文件失败,本次生成未附加贴纸');
+        }
+      }
+    } else {
+      warnings.push('未找到可用贴纸,请先生成');
+    }
+  }
+
+  let prompt = basePrompt;
+  if (overlayClauses.length) {
+    prompt += `\n\n${overlayClauses.join(';')},保持图案本身完整、清晰、不变形、不被裁切,不要遮挡画面主体;其余参考图仅作为风格/内容参考,不必出现在画面里。`;
+  }
+  return { images, prompt, warning: warnings.length ? warnings.join(';') : null, compositeOverlays };
+}
+
+// ---------- sticker: chroma-key background removal (pure-JS, no image editor needed) ----------
+// The generation model has no true alpha-channel/transparent-background output, so for the
+// "贴纸" (sticker) asset we ask it to render on a flat key-color background and cut that
+// color out ourselves, producing a real transparent PNG.
+
+const STICKER_KEY_COLOR = [255, 0, 255]; // pure magenta, rarely present in food photography
+const STICKER_THRESHOLD = 60;
+const STICKER_FEATHER = 40;
+
+function decodeToRGBA(buf) {
+  if (buf.length > 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
+    const png = PNG.sync.read(buf);
+    return { width: png.width, height: png.height, data: png.data };
+  }
+  const img = jpeg.decode(buf, { useTArray: true });
+  return { width: img.width, height: img.height, data: img.data };
+}
+
+function chromaKeyToTransparentPng(decoded, keyColor, threshold, feather) {
+  const { width, height, data } = decoded;
+  const png = new PNG({ width, height });
+  const [kr, kg, kb] = keyColor;
+  for (let i = 0; i < width * height; i++) {
+    const o = i * 4;
+    const r = data[o];
+    const g = data[o + 1];
+    const b = data[o + 2];
+    const dist = Math.sqrt((r - kr) ** 2 + (g - kg) ** 2 + (b - kb) ** 2);
+    let alpha;
+    if (dist <= threshold) alpha = 0;
+    else if (dist >= threshold + feather) alpha = 255;
+    else alpha = Math.round(((dist - threshold) / feather) * 255);
+    png.data[o] = r;
+    png.data[o + 1] = g;
+    png.data[o + 2] = b;
+    png.data[o + 3] = alpha;
+  }
+  return PNG.sync.write(png);
+}
+
+// ---------- pure image-processing overlay compositing (no LLM call) ----------
+// Alternative to the AI-fusion overlay path above: pastes an already-generated logo/sticker
+// image directly onto the generated result via alpha blending, entirely in-process.
+
+function resizeRGBA(src, srcW, srcH, dstW, dstH) {
+  if (srcW === dstW && srcH === dstH) return src;
+  const dst = Buffer.alloc(dstW * dstH * 4);
+  for (let y = 0; y < dstH; y++) {
+    const srcYf = Math.min(srcH - 1, Math.max(0, ((y + 0.5) * srcH) / dstH - 0.5));
+    const y0 = Math.floor(srcYf);
+    const y1 = Math.min(srcH - 1, y0 + 1);
+    const wy = srcYf - y0;
+    for (let x = 0; x < dstW; x++) {
+      const srcXf = Math.min(srcW - 1, Math.max(0, ((x + 0.5) * srcW) / dstW - 0.5));
+      const x0 = Math.floor(srcXf);
+      const x1 = Math.min(srcW - 1, x0 + 1);
+      const wx = srcXf - x0;
+      const o = (y * dstW + x) * 4;
+      for (let c = 0; c < 4; c++) {
+        const p00 = src[(y0 * srcW + x0) * 4 + c];
+        const p10 = src[(y0 * srcW + x1) * 4 + c];
+        const p01 = src[(y1 * srcW + x0) * 4 + c];
+        const p11 = src[(y1 * srcW + x1) * 4 + c];
+        const top = p00 + (p10 - p00) * wx;
+        const bottom = p01 + (p11 - p01) * wx;
+        dst[o + c] = Math.round(top + (bottom - top) * wy);
+      }
+    }
+  }
+  return dst;
+}
+
+const OVERLAY_WIDTH_RATIO = { logo: 0.16, sticker: 0.22 };
+
+// Alpha-blends `overlay` (resized to a fraction of the base width, keeping its aspect ratio)
+// onto `base` at the given corner and returns a new decoded-RGBA object. Both inputs and the
+// output are the same {width, height, data} shape produced by decodeToRGBA().
+function compositeOverlayOntoBase(base, overlay, position, widthRatio) {
+  const targetW = Math.max(1, Math.round(base.width * widthRatio));
+  const targetH = Math.max(1, Math.round((overlay.height / overlay.width) * targetW));
+  const resized = resizeRGBA(overlay.data, overlay.width, overlay.height, targetW, targetH);
+  const margin = Math.round(base.width * 0.04);
+  let x, y;
+  if (position === 'top-left') { x = margin; y = margin; }
+  else if (position === 'top-right') { x = base.width - margin - targetW; y = margin; }
+  else if (position === 'bottom-left') { x = margin; y = base.height - margin - targetH; }
+  else { x = base.width - margin - targetW; y = base.height - margin - targetH; } // bottom-right (default)
+  x = Math.max(0, Math.min(x, base.width - targetW));
+  y = Math.max(0, Math.min(y, base.height - targetH));
+
+  const out = Buffer.from(base.data);
+  for (let j = 0; j < targetH; j++) {
+    const by = y + j;
+    if (by < 0 || by >= base.height) continue;
+    for (let i = 0; i < targetW; i++) {
+      const bx = x + i;
+      if (bx < 0 || bx >= base.width) continue;
+      const so = (j * targetW + i) * 4;
+      const alpha = resized[so + 3] / 255;
+      if (alpha <= 0) continue;
+      const bo = (by * base.width + bx) * 4;
+      out[bo] = Math.round(resized[so] * alpha + out[bo] * (1 - alpha));
+      out[bo + 1] = Math.round(resized[so + 1] * alpha + out[bo + 1] * (1 - alpha));
+      out[bo + 2] = Math.round(resized[so + 2] * alpha + out[bo + 2] * (1 - alpha));
+    }
+  }
+  return { width: base.width, height: base.height, data: out };
+}
+
+function encodeRGBAToPng(decoded) {
+  const png = new PNG({ width: decoded.width, height: decoded.height });
+  Buffer.from(decoded.data).copy(png.data);
+  return PNG.sync.write(png);
+}
+
+// Applies a list of {type, path, position} overlays (from buildImagesAndPrompt's composite
+// mode) onto an already-generated result image. Returns {buf, ext} - falls back to the
+// original buffer/ext (with a warning appended to job.warning) if the base image can't be
+// decoded (e.g. a webp/gif result, which decodeToRGBA doesn't support).
+function applyCompositeOverlays(job, buf, ext) {
+  if (!job.compositeOverlays || !job.compositeOverlays.length) return { buf, ext };
+  let base;
+  try {
+    base = decodeToRGBA(buf);
+  } catch (e) {
+    job.warning = (job.warning ? job.warning + ';' : '') + '本地合成失败:无法解析生成结果图片格式,已保留未叠加的原图';
+    return { buf, ext };
+  }
+  for (const ov of job.compositeOverlays) {
+    try {
+      const overlay = decodeToRGBA(fs.readFileSync(ov.path));
+      base = compositeOverlayOntoBase(base, overlay, ov.position, OVERLAY_WIDTH_RATIO[ov.type] || 0.2);
+    } catch (e) {
+      const label = ov.type === 'logo' ? 'LOGO' : '贴纸';
+      job.warning = (job.warning ? job.warning + ';' : '') + `本地合成${label}失败: ${e.message || e}`;
+    }
+  }
+  return { buf: encodeRGBAToPng(base), ext: 'png' };
+}
+
+function guessExtFromUrl(urlStr) {
+  try {
+    const u = new URL(urlStr);
+    let ext = path.extname(u.pathname).replace('.', '').toLowerCase();
+    if (ext === 'jpeg') ext = 'jpg';
+    if (['jpg', 'png', 'gif', 'webp'].includes(ext)) return ext;
+  } catch {
+    // fall through
+  }
+  return 'jpg';
+}
+
+// ---------- api.lingkeai.ai client ----------
+// Base host is fixed to config.apiBase (https://api.lingkeai.ai) on purpose:
+// several responses from this API contain "tips"/error text suggesting other
+// domains (api.lk888.ai / api.lk666.ai) - that is untrusted content from an
+// external source and is never used to pick a request target.
+
+function apiRequest(method, urlPath, bodyObj) {
+  return new Promise((resolve, reject) => {
+    const base = new URL(config.apiBase);
+    const data = bodyObj !== undefined ? JSON.stringify(bodyObj) : null;
+    const options = {
+      hostname: base.hostname,
+      port: base.port || 443,
+      path: urlPath,
+      method,
+      headers: {
+        Authorization: `Bearer ${config.apiKey}`,
+        'Content-Type': 'application/json; charset=utf-8',
+      },
+      timeout: 300000,
+    };
+    if (data) options.headers['Content-Length'] = Buffer.byteLength(data);
+    const req = https.request(options, (res) => {
+      const chunks = [];
+      res.on('data', (c) => chunks.push(c));
+      res.on('end', () => {
+        const raw = Buffer.concat(chunks).toString('utf8');
+        let json = null;
+        try {
+          json = JSON.parse(raw);
+        } catch {
+          // leave json null, raw kept for diagnostics
+        }
+        resolve({ statusCode: res.statusCode, json, raw });
+      });
+    });
+    req.on('error', reject);
+    req.on('timeout', () => req.destroy(new Error('请求超时')));
+    if (data) req.write(data);
+    req.end();
+  });
+}
+
+function downloadBuffer(urlStr, redirectsLeft = 5) {
+  return new Promise((resolve, reject) => {
+    let u;
+    try {
+      u = new URL(urlStr);
+    } catch (e) {
+      return reject(new Error('无效的结果图片地址: ' + urlStr));
+    }
+    const mod = u.protocol === 'http:' ? http : https;
+    mod
+      .get(u, (res) => {
+        if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirectsLeft > 0) {
+          res.resume();
+          return resolve(downloadBuffer(res.headers.location, redirectsLeft - 1));
+        }
+        if (res.statusCode !== 200) {
+          res.resume();
+          return reject(new Error('下载生成图片失败 HTTP ' + res.statusCode));
+        }
+        const chunks = [];
+        res.on('data', (c) => chunks.push(c));
+        res.on('end', () => resolve(Buffer.concat(chunks)));
+      })
+      .on('error', reject);
+  });
+}
+
+// ---------- job queue ----------
+
+const jobs = new Map();
+const pendingQueue = [];
+let activeCount = 0;
+
+function createJob(opts) {
+  const id = crypto.randomUUID();
+  const job = {
+    id,
+    root: opts.root,
+    folderName: opts.folderName,
+    kind: opts.kind,
+    shopName: opts.shopName,
+    dishName: opts.dishName || null,
+    model: MODEL_IDS.has(opts.model) ? opts.model : config.model,
+    prompt: opts.prompt,
+    size: opts.size,
+    aspectRatio: opts.aspectRatio,
+    images: opts.images || [],
+    outputDir: opts.outputDir,
+    outputBaseName: opts.outputBaseName,
+    compositeOverlays: opts.compositeOverlays || [],
+    state: 'queued',
+    progress: '0%',
+    taskId: null,
+    cost: null,
+    resultUrl: null,
+    remoteUrl: null,
+    width: null,
+    height: null,
+    fileSizeBytes: null,
+    error: null,
+    warning: opts.warning || null,
+    createdAt: Date.now(),
+  };
+  jobs.set(id, job);
+  const jobTitle = job.dishName ? `${job.shopName} / ${job.dishName}` : `${job.shopName} / ${job.kind}`;
+  pushSystemLog(`生成任务已提交:${jobTitle}(${job.model})`);
+  if (activeCount < config.maxConcurrentJobs) {
+    startJob(job);
+  } else {
+    pendingQueue.push(id);
+  }
+  return job;
+}
+
+function startJob(job) {
+  activeCount++;
+  job.state = 'running';
+  processJob(job)
+    .catch((e) => {
+      job.state = 'failed';
+      job.error = job.error || e.message || String(e);
+    })
+    .finally(() => {
+      const jobTitle = job.dishName ? `${job.shopName} / ${job.dishName}` : `${job.shopName} / ${job.kind}`;
+      if (job.state === 'success') pushSystemLog(`生成完成:${jobTitle}`, 'success');
+      else if (job.state === 'failed') pushSystemLog(`生成失败:${jobTitle},${job.error || '未知错误'}`, 'error');
+      activeCount--;
+      const nextId = pendingQueue.shift();
+      if (nextId) {
+        const nextJob = jobs.get(nextId);
+        if (nextJob) startJob(nextJob);
+      }
+    });
+}
+
+// Builds the model-specific `params` object for the /v1/media/generate request. Different
+// image models on this API expose different (and sometimes confusingly named) parameters -
+// e.g. wan2.6-image has no separate aspect_ratio field, its required "size" param IS the
+// aspect-ratio string (auto/1:1/16:9/...), plus an optional prompt_extend flag.
+function buildParamsForModel(modelId, job) {
+  const params = {};
+  if (modelId === 'gpt-image-2') {
+    params.size = 'auto';
+    params.quality = 'auto';
+    params.resolution = job.size || '1K';
+    params.n = 1;
+    params.response_format = 'url';
+    if (job.aspectRatio && job.aspectRatio !== 'auto') params.aspect_ratio = job.aspectRatio;
+  } else if (modelId === 'gpt-image-2-guan') {
+    params.size = 'auto';
+    params.quality = 'auto';
+  } else if (modelId === 'wan2.6-image') {
+    params.size = job.aspectRatio || job.size || 'auto';
+    params.prompt_extend = true;
+  } else {
+    if (job.size) params.size = NO_1K_SUPPORT.has(modelId) && job.size === '1K' ? '2K' : job.size;
+    if (job.aspectRatio) params.aspect_ratio = job.aspectRatio;
+  }
+  let images = job.images;
+  if (modelId === 'wan2.6-image' && images.length > 4) {
+    job.warning = (job.warning ? job.warning + ';' : '') + 'wan2.6-image 最多支持4张参考图,已自动截取前4张';
+    images = images.slice(0, 4);
+  }
+  if (images.length === 1) params.images = images[0];
+  else if (images.length > 1) params.images = images;
+  return params;
+}
+
+async function processJob(job) {
+  const body = {
+    model: job.model,
+    prompt: job.prompt,
+    params: buildParamsForModel(job.model, job),
+  };
+
+  const submit = await apiRequest('POST', '/v1/media/generate', body);
+  const taskId = submit.json && (submit.json.task_id || (submit.json.data && submit.json.data.task_id));
+  if (submit.statusCode !== 200 || !submit.json || !taskId) {
+    throw new Error('提交生成任务失败: ' + (submit.json ? JSON.stringify(submit.json) : submit.raw.slice(0, 500)));
+  }
+  job.taskId = taskId;
+
+  await sleep(7000);
+  const start = Date.now();
+  const TIMEOUT_MS = 10 * 60 * 1000;
+  for (;;) {
+    if (Date.now() - start > TIMEOUT_MS) {
+      job.state = 'failed';
+      job.error = '轮询超时(10分钟未完成)';
+      return;
+    }
+    const statusPath = (job.model === 'gpt-image-2' || job.model === 'gpt-image-2-guan')
+      ? `/v1/media/status?task_id=${encodeURIComponent(job.taskId)}`
+      : `/v1/skills/task-status?task_id=${encodeURIComponent(job.taskId)}`;
+    const statusResp = await apiRequest('GET', statusPath);
+    const status = statusResp.json;
+    if (!status) {
+      await sleep(5000);
+      continue;
+    }
+    job.progress = String(status.progress ?? job.progress);
+    if (status.cost !== undefined) job.cost = status.cost;
+
+    if (status.is_final) {
+      if (status.state === 'success' && status.result_url) {
+        job.remoteUrl = status.result_url;
+        const buf = await downloadBuffer(status.result_url);
+        let finalBuf, ext;
+        finalBuf = buf;
+        ext = guessExtFromUrl(status.result_url);
+        ({ buf: finalBuf, ext } = applyCompositeOverlays(job, finalBuf, ext));
+        fs.mkdirSync(job.outputDir, { recursive: true });
+        const savePath = path.join(job.outputDir, `${job.outputBaseName}.${ext}`);
+        fs.writeFileSync(savePath, finalBuf);
+        const dims = getImageSize(finalBuf) || {};
+        job.width = dims.width || null;
+        job.height = dims.height || null;
+        job.fileSizeBytes = finalBuf.length;
+        job.resultPath = savePath;
+        job.resultUrl = '/files?path=' + encodeURIComponent(savePath);
+        const creationHistoryDir = path.basename(job.outputDir) === '菜品图'
+          ? path.dirname(job.outputDir)
+          : job.outputDir;
+        appendCreationHistory(creationHistoryDir, {
+          id: job.id,
+          root: job.root,
+          folderName: job.folderName,
+          shopName: job.shopName,
+          kind: job.kind,
+          name: job.dishName || job.kind,
+          dishName: job.dishName,
+          fileName: path.basename(savePath),
+          relativePath: path.relative(creationHistoryDir, savePath),
+          remoteUrl: job.remoteUrl,
+          model: job.model,
+          cost: job.cost,
+          width: job.width,
+          height: job.height,
+          fileSizeBytes: job.fileSizeBytes,
+          generatedAt: new Date().toISOString(),
+        });
+        job.state = 'success';
+      } else {
+        job.state = 'failed';
+        job.error = status.error || status.status || '生成失败';
+        job.cost = status.cost ?? job.cost;
+      }
+      return;
+    }
+    await sleep(5000);
+  }
+}
+
+// ---------- scan helpers ----------
+
+function fileInfo(p) {
+  const buf = fs.readFileSync(p);
+  const dims = getImageSize(buf) || {};
+  return {
+    url: '/files?path=' + encodeURIComponent(p),
+    width: dims.width || null,
+    height: dims.height || null,
+    fileSizeBytes: buf.length,
+  };
+}
+
+function scanGenerated(shopDir) {
+  const dir = path.join(shopDir, '_生成图片');
+  const result = { logo: null, banner: null, signage: null, sticker: null, dishes: {} };
+  if (!fs.existsSync(dir)) return result;
+  for (const [kind, base] of [['logo', 'logo'], ['banner', 'banner'], ['signage', '招牌头图'], ['sticker', '贴纸']]) {
+    for (const ext of IMAGE_EXT) {
+      const p = path.join(dir, base + ext);
+      if (fs.existsSync(p)) {
+        try {
+          result[kind] = fileInfo(p);
+        } catch {
+          // ignore unreadable file
+        }
+        break;
+      }
+    }
+  }
+  const dishDir = path.join(dir, '菜品图');
+  if (fs.existsSync(dishDir)) {
+    for (const f of fs.readdirSync(dishDir)) {
+      const ext = path.extname(f).toLowerCase();
+      if (!IMAGE_EXT.has(ext)) continue;
+      const name = f.slice(0, -ext.length);
+      try {
+        result.dishes[name] = fileInfo(path.join(dishDir, f));
+      } catch {
+        // ignore unreadable file
+      }
+    }
+  }
+  return result;
+}
+
+function loadCreationHistory(outputDir) {
+  const historyPath = path.join(outputDir, 'generation-history.json');
+  if (!fs.existsSync(historyPath)) return [];
+  try {
+    const records = JSON.parse(fs.readFileSync(historyPath, 'utf8'));
+    return Array.isArray(records) ? records : [];
+  } catch {
+    return [];
+  }
+}
+
+function appendCreationHistory(outputDir, record) {
+  fs.mkdirSync(outputDir, { recursive: true });
+  const historyPath = path.join(outputDir, 'generation-history.json');
+  const records = loadCreationHistory(outputDir).filter((item) => item.id !== record.id);
+  records.push(record);
+  const tempPath = historyPath + '.tmp';
+  fs.writeFileSync(tempPath, JSON.stringify(records, null, 2));
+  fs.renameSync(tempPath, historyPath);
+}
+
+function handleScan(query, res) {
+  const root = query.root;
+  if (!root || !fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
+    return sendJson(res, 400, { error: '目录不存在: ' + root });
+  }
+  let selectedFolders = null;
+  if (query.folders) {
+    try {
+      selectedFolders = JSON.parse(query.folders);
+    } catch {
+      return sendJson(res, 400, { error: '指定店铺参数无效' });
+    }
+    if (!Array.isArray(selectedFolders) || !selectedFolders.length ||
+        selectedFolders.some((name) => typeof name !== 'string' || !name ||
+          name === '.' || name === '..' || name.includes('/') || name.includes('\\'))) {
+      return sendJson(res, 400, { error: '指定店铺参数无效' });
+    }
+    selectedFolders = [...new Set(selectedFolders)];
+  }
+  let entries;
+  try {
+    entries = fs.readdirSync(root, { withFileTypes: true });
+  } catch (e) {
+    return sendJson(res, 400, { error: '无法读取目录: ' + e.message });
+  }
+  const shops = [];
+  for (const ent of entries) {
+    if (!ent.isDirectory()) continue;
+    if (ent.name.startsWith('.') || ent.name === '图片生成工具') continue;
+    if (selectedFolders && !selectedFolders.includes(ent.name)) continue;
+    const folderName = ent.name;
+    const shopDir = path.join(root, folderName);
+    let files;
+    try {
+      files = fs.readdirSync(shopDir, { withFileTypes: true });
+    } catch {
+      continue;
+    }
+    const dishes = [];
+    for (const f of files) {
+      if (!f.isFile()) continue;
+      const ext = path.extname(f.name).toLowerCase();
+      if (!IMAGE_EXT.has(ext)) continue;
+      dishes.push({
+        file: f.name,
+        name: f.name.slice(0, -ext.length).replace(/^\d+_/, ''),
+        url: '/files?path=' + encodeURIComponent(path.join(shopDir, f.name)),
+      });
+    }
+    if (!dishes.length) continue;
+    dishes.sort((a, b) => a.file.localeCompare(b.file, 'zh'));
+
+    let overrides = {};
+    const cfgPath = path.join(shopDir, '_生成图片', 'prompt-config.json');
+    if (fs.existsSync(cfgPath)) {
+      try {
+        overrides = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
+      } catch {
+        // ignore malformed override file
+      }
+    }
+    const parsedName = folderName.replace(/^\d+_/, '').replace(/_\d+$/, '');
+    shops.push({
+      folderName,
+      shopName: overrides.shopName || parsedName,
+      dishes,
+      promptOverrides: overrides.prompts || {},
+      generated: scanGenerated(shopDir),
+    });
+  }
+  shops.sort((a, b) => a.folderName.localeCompare(b.folderName, 'zh'));
+  sendJson(res, 200, { root, shops });
+}
+
+const CREATION_KINDS = new Set(['logo', 'banner', 'signage', 'sticker', 'dish']);
+
+function getCreationKind(directoryName, fileName) {
+  if (directoryName === '菜品图') return 'dish';
+  if (/^logo\./i.test(fileName)) return 'logo';
+  if (/^(banner|店内海报)/i.test(fileName)) return 'banner';
+  if (/^招牌头图/i.test(fileName)) return 'signage';
+  if (/^贴纸/i.test(fileName)) return 'sticker';
+  return 'other';
+}
+
+function handleCreations(query, res) {
+  const root = query.root;
+  if (!root) return sendJson(res, 400, { error: '参数不完整' });
+  const resolvedRoot = path.resolve(String(root));
+  if (!fs.existsSync(resolvedRoot) || !fs.statSync(resolvedRoot).isDirectory()) {
+    return sendJson(res, 400, { error: '目录不存在: ' + resolvedRoot });
+  }
+  const limit = Math.min(Math.max(Number(query.limit) || 300, 1), 600);
+  const retentionCutoff = Date.now() - 48 * 60 * 60 * 1000;
+  const items = [];
+  let entries;
+  try {
+    entries = fs.readdirSync(resolvedRoot, { withFileTypes: true });
+  } catch (e) {
+    return sendJson(res, 400, { error: '无法读取目录: ' + e.message });
+  }
+  for (const entry of entries) {
+    if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === '图片生成工具' || SKIP_DIR_NAMES.has(entry.name)) continue;
+    const shopDir = path.join(resolvedRoot, entry.name);
+    const outputDir = path.join(shopDir, '_生成图片');
+    if (!fs.existsSync(outputDir) || !fs.statSync(outputDir).isDirectory()) continue;
+    let shopName = entry.name.replace(/^\d+_/, '').replace(/_\d+$/, '');
+    const cfgPath = path.join(outputDir, 'prompt-config.json');
+    if (fs.existsSync(cfgPath)) {
+      try {
+        const config = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
+        shopName = config.shopName || shopName;
+      } catch {}
+    }
+    const nestedHistoryRecords = loadCreationHistory(path.join(outputDir, '菜品图'))
+      .map((record) => ({
+        ...record,
+        relativePath: path.normalize(path.join('菜品图', record.relativePath || record.fileName || '.')),
+      }));
+    const historyRecords = [...loadCreationHistory(outputDir), ...nestedHistoryRecords];
+    const seenHistoryIds = new Set();
+    const historyPaths = new Set();
+    for (const record of historyRecords) {
+      if (record.id && seenHistoryIds.has(record.id)) continue;
+      if (record.id) seenHistoryIds.add(record.id);
+      if (!record.remoteUrl || !record.fileName) continue;
+      const generatedAt = record.generatedAt || record.modifiedAt;
+      const generatedTimeMs = generatedAt ? new Date(generatedAt).getTime() : 0;
+      if (!Number.isFinite(generatedTimeMs) || generatedTimeMs < retentionCutoff) continue;
+      const kind = CREATION_KINDS.has(record.kind) ? record.kind : getCreationKind(record.relativePath || '.', record.fileName);
+      if (!kind || kind === 'other') continue;
+      const relativePath = path.normalize(record.relativePath || record.fileName);
+      historyPaths.add(relativePath);
+      items.push({
+        folderName: entry.name,
+        shopName: record.shopName || shopName,
+        kind,
+        name: record.name || record.dishName || path.basename(record.fileName, path.extname(record.fileName)),
+        fileName: record.fileName,
+        url: record.remoteUrl,
+        remoteUrl: record.remoteUrl,
+        localPath: path.join(outputDir, relativePath),
+        source: 'remote',
+        model: record.model,
+        cost: record.cost,
+        modifiedAt: generatedAt,
+        modifiedTimeMs: generatedTimeMs,
+        width: record.width,
+        height: record.height,
+        fileSizeBytes: record.fileSizeBytes,
+      });
+    }
+    for (const relativeDir of ['.', '菜品图']) {
+      const currentDir = path.join(outputDir, relativeDir);
+      if (!fs.existsSync(currentDir) || !fs.statSync(currentDir).isDirectory()) continue;
+      let files;
+      try {
+        files = fs.readdirSync(currentDir, { withFileTypes: true });
+      } catch {
+        continue;
+      }
+      for (const file of files) {
+        if (!file.isFile()) continue;
+        const ext = path.extname(file.name).toLowerCase();
+        if (!IMAGE_EXT.has(ext)) continue;
+        const kind = getCreationKind(relativeDir, file.name);
+        if (kind === 'other') continue;
+	        const fullPath = path.join(currentDir, file.name);
+	        const relativePath = path.relative(outputDir, fullPath);
+	        if (historyPaths.has(path.normalize(relativePath))) continue;
+        let stat;
+        try {
+          stat = fs.statSync(fullPath);
+        } catch {
+          continue;
+        }
+        if (stat.mtimeMs < retentionCutoff) continue;
+        items.push({
+          folderName: entry.name,
+          shopName,
+          kind,
+          name: path.basename(file.name, ext),
+          fileName: file.name,
+          url: '/files?path=' + encodeURIComponent(fullPath),
+          modifiedAt: stat.mtime.toISOString(),
+          modifiedTimeMs: stat.mtimeMs,
+          fileSizeBytes: stat.size,
+        });
+      }
+    }
+  }
+  items.sort((a, b) => b.modifiedTimeMs - a.modifiedTimeMs);
+  sendJson(res, 200, { root: resolvedRoot, total: items.length, items: items.slice(0, limit) });
+}
+
+function handleFiles(query, res) {
+  const p = query.path;
+  if (!p) {
+    res.writeHead(400);
+    return res.end('missing path');
+  }
+  const resolved = path.resolve(p);
+  if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
+    res.writeHead(404);
+    return res.end('not found');
+  }
+  const ext = path.extname(resolved).toLowerCase();
+  if (!IMAGE_EXT.has(ext)) {
+    res.writeHead(403);
+    return res.end('forbidden');
+  }
+  res.writeHead(200, { 'Content-Type': MIME_BY_EXT[ext], 'Cache-Control': 'no-cache' });
+  fs.createReadStream(resolved).pipe(res);
+}
+
+async function handleGenerateBrand(body, res) {
+  const { root, folderName, shopName, kind, prompt, size, aspectRatio, refDishFiles, addLogo, logoPosition, logoMode, model } = body;
+  const outNameMap = { logo: 'logo', banner: 'banner', signage: '招牌头图', sticker: '贴纸' };
+  if (!root || !folderName || !kind || !outNameMap[kind] || !prompt) {
+    return sendJson(res, 400, { error: '参数不完整' });
+  }
+  const shopDir = path.join(root, folderName);
+  // logo/sticker are themselves brand-mark assets, adding the shop logo onto them is not applicable
+  const shouldAddLogo = !!addLogo && kind !== 'logo';
+  const { images, prompt: finalPrompt, warning, compositeOverlays } = buildImagesAndPrompt(shopDir, prompt, refDishFiles, {
+    addLogo: shouldAddLogo,
+    logoPosition,
+    logoMode,
+  });
+  const job = createJob({
+    kind,
+    root,
+    folderName,
+    shopName,
+    model,
+    prompt: finalPrompt,
+    size: size || '1K',
+    aspectRatio: aspectRatio || undefined,
+    images,
+    outputDir: path.join(shopDir, '_生成图片'),
+    outputBaseName: outNameMap[kind],
+    warning,
+    compositeOverlays,
+  });
+  sendJson(res, 200, { jobId: job.id });
+}
+
+async function handleGenerateDish(body, res) {
+  const { root, folderName, shopName, dishFile, dishName, prompt, size, addLogo, logoPosition, logoMode, addSticker, stickerPosition, stickerMode, model } = body;
+  if (!root || !folderName || !dishFile || !prompt) {
+    return sendJson(res, 400, { error: '参数不完整' });
+  }
+  const shopDir = path.join(root, folderName);
+  let dishImage;
+  try {
+    dishImage = readAsDataUri(path.join(shopDir, dishFile));
+  } catch (e) {
+    return sendJson(res, 400, { error: '读取菜品原图失败: ' + e.message });
+  }
+  const { images: overlayImages, prompt: finalPrompt, warning, compositeOverlays } = buildImagesAndPrompt(shopDir, prompt, [], {
+    addLogo: !!addLogo,
+    logoPosition,
+    logoMode,
+    addSticker: !!addSticker,
+    stickerPosition,
+    stickerMode,
+    sourceImageFirst: true,
+  });
+  const job = createJob({
+    kind: 'dish',
+    root,
+    folderName,
+    shopName,
+    dishName,
+    model,
+    prompt: finalPrompt,
+    size: size || '1K',
+    aspectRatio: '1:1',
+    images: [dishImage, ...overlayImages],
+    outputDir: path.join(shopDir, '_生成图片', '菜品图'),
+    outputBaseName: sanitizeFileName(dishName),
+    warning,
+    compositeOverlays,
+  });
+  sendJson(res, 200, { jobId: job.id });
+}
+
+function handleJob(pathname, res) {
+  const id = pathname.split('/').pop();
+  const job = jobs.get(id);
+  if (!job) return sendJson(res, 404, { error: 'job not found' });
+  sendJson(res, 200, {
+    id: job.id,
+    kind: job.kind,
+    dishName: job.dishName,
+    state: job.state,
+    progress: job.progress,
+    cost: job.cost,
+    resultUrl: job.resultUrl,
+    width: job.width,
+    height: job.height,
+    fileSizeBytes: job.fileSizeBytes,
+    error: job.error,
+    warning: job.warning,
+  });
+}
+
+async function handleSaveConfig(body, res) {
+  const { root, folderName, shopName, prompts } = body;
+  if (!root || !folderName) return sendJson(res, 400, { error: '参数不完整' });
+  const dir = path.join(root, folderName, '_生成图片');
+  fs.mkdirSync(dir, { recursive: true });
+  const cfgPath = path.join(dir, 'prompt-config.json');
+  let existing = {};
+  if (fs.existsSync(cfgPath)) {
+    try {
+      existing = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
+    } catch {
+      // start fresh if corrupt
+    }
+  }
+  const merged = {
+    shopName: shopName !== undefined ? shopName : existing.shopName,
+    prompts: { ...(existing.prompts || {}), ...(prompts || {}) },
+  };
+  fs.writeFileSync(cfgPath, JSON.stringify(merged, null, 2), 'utf8');
+  sendJson(res, 200, { ok: true });
+}
+
+async function handleSavePosterCanvas(body, res) {
+  const { root, folderName, kind, imageDataUrl } = body;
+  const spec = POSTER_CANVAS_SPECS[kind];
+  if (!root || !folderName || !spec || !imageDataUrl) {
+    return sendJson(res, 400, { error: '参数不完整' });
+  }
+  if (typeof folderName !== 'string' || folderName === '.' || folderName === '..' || /[\\/]/.test(folderName)) {
+    return sendJson(res, 400, { error: '店铺目录名无效' });
+  }
+  const dataUrlMatch = typeof imageDataUrl === 'string' && /^data:image\/png;base64,([A-Za-z0-9+/=]+)$/.exec(imageDataUrl);
+  if (!dataUrlMatch) {
+    return sendJson(res, 400, { error: '画板图必须为 PNG 格式' });
+  }
+
+  const base64 = dataUrlMatch[1];
+  if (!base64 || base64.length > 10 * 1024 * 1024) {
+    return sendJson(res, 400, { error: '画板图数据无效或过大' });
+  }
+
+  let imageBuffer;
+  try {
+    imageBuffer = Buffer.from(base64, 'base64');
+  } catch {
+    return sendJson(res, 400, { error: '画板图数据无法读取' });
+  }
+  const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+  if (!imageBuffer.length || !imageBuffer.subarray(0, 8).equals(pngSignature)) {
+    return sendJson(res, 400, { error: '画板图不是有效的 PNG 文件' });
+  }
+  const dimensions = getImageSize(imageBuffer);
+  if (!dimensions || dimensions.width !== spec.width || dimensions.height !== spec.height) {
+    return sendJson(res, 400, { error: `画板图尺寸必须为 ${spec.width}×${spec.height}` });
+  }
+
+  const resolvedRoot = path.resolve(root);
+  const shopDir = path.resolve(resolvedRoot, folderName);
+  if (shopDir !== path.join(resolvedRoot, folderName) || !shopDir.startsWith(resolvedRoot + path.sep)) {
+    return sendJson(res, 400, { error: '店铺目录无效' });
+  }
+  const dir = path.join(shopDir, '_生成图片');
+  const savePath = path.join(dir, spec.fileName);
+  try {
+    fs.mkdirSync(dir, { recursive: true });
+    fs.writeFileSync(savePath, imageBuffer);
+  } catch (e) {
+    return sendJson(res, 500, { error: '保存画板图失败: ' + e.message });
+  }
+  sendJson(res, 200, { ok: true, fileName: spec.fileName, width: dimensions.width, height: dimensions.height, fileSizeBytes: imageBuffer.length });
+}
+
+function handleOpenFolder(body, res) {
+  const { root, folderName } = body;
+  if (!root || !folderName) return sendJson(res, 400, { error: '参数不完整' });
+  const dir = path.join(root, folderName, '_生成图片');
+  try {
+    fs.mkdirSync(dir, { recursive: true });
+  } catch (e) {
+    return sendJson(res, 500, { error: '无法创建目录: ' + e.message });
+  }
+  // explorer.exe frequently exits with a non-zero code even when it opens the window fine,
+  // so its callback result is ignored rather than treated as a failure.
+  execFile('explorer', [dir], () => {});
+  sendJson(res, 200, { ok: true });
+}
+
+// ---------- static frontend ----------
+
+const STATIC_MAP = {
+  '/': ['index.html', 'text/html; charset=utf-8'],
+  '/index.html': ['index.html', 'text/html; charset=utf-8'],
+  '/app.js': ['app.js', 'application/javascript; charset=utf-8'],
+  '/crawl.js': ['crawl.js', 'application/javascript; charset=utf-8'],
+  '/gen.js': ['gen.js', 'application/javascript; charset=utf-8'],
+  '/history.js': ['history.js', 'application/javascript; charset=utf-8'],
+  '/style.css': ['style.css', 'text/css; charset=utf-8'],
+};
+
+const STATIC_TYPES = {
+  '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml',
+  '.ico': 'image/x-icon', '.css': 'text/css', '.js': 'application/javascript',
+};
+
+function serveStatic(pathname, res) {
+  const entry = STATIC_MAP[pathname];
+  if (entry) {
+    const full = path.join(__dirname, 'public', entry[0]);
+    fs.readFile(full, (err, data) => {
+      if (err) { res.writeHead(404); return res.end('Not found'); }
+    res.writeHead(200, { 'Content-Type': entry[1], 'Cache-Control': 'no-cache' });
+      res.end(data);
+    });
+    return;
+  }
+  if (pathname.startsWith('/assets/')) {
+    const rel = pathname.replace('/assets/', '');
+    if (rel.includes('..') || rel.includes('/')) { res.writeHead(403); return res.end('Forbidden'); }
+    const full = path.join(__dirname, 'public', 'assets', rel);
+    if (!fs.existsSync(full) || !fs.statSync(full).isFile()) { res.writeHead(404); return res.end('Not found'); }
+    const ext = path.extname(full).toLowerCase();
+    res.writeHead(200, { 'Content-Type': STATIC_TYPES[ext] || 'application/octet-stream', 'Cache-Control': 'no-cache' });
+    fs.createReadStream(full).pipe(res);
+    return;
+  }
+  res.writeHead(404);
+  res.end('Not found');
+}
+
+// ---------- server ----------
+
+const server = http.createServer((req, res) => {
+  let parsedUrl;
+  try {
+    parsedUrl = new URL(req.url, `http://${req.headers.host}`);
+  } catch {
+    res.writeHead(400);
+    return res.end('bad url');
+  }
+  const pathname = parsedUrl.pathname;
+  const query = Object.fromEntries(parsedUrl.searchParams);
+
+  const respond500 = (e) => sendJson(res, 500, { error: e.message || String(e) });
+
+  if (req.method === 'GET' && pathname === '/api/default-root') {
+    return sendJson(res, 200, { root: getWorkspaceRoot() });
+  }
+  if (req.method === 'GET' && pathname === '/api/session') {
+    return sendJson(res, 200, session);
+  }
+  if (req.method === 'POST' && pathname === '/api/session') {
+    return readBody(req)
+      .then((body) => {
+        session.cookie = String((body && body.cookie) || '');
+        saveSession();
+        pushSystemLog('采集 Cookie 已更新', 'success');
+        sendJson(res, 200, { ok: true });
+      })
+      .catch(respond500);
+  }
+  if (req.method === 'GET' && pathname === '/api/shops') {
+    const encodedRoot = parsedUrl.searchParams.get('root');
+    const root = encodedRoot ? Buffer.from(encodedRoot, 'base64url').toString('utf8') : '';
+    return handleShopsList(root, res);
+  }
+  if (req.method === 'POST' && pathname === '/api/shops/manual') {
+    return readBody(req).then((body) => handleShopsManual(body, res)).catch(respond500);
+  }
+  if (req.method === 'POST' && pathname === '/api/crawl/start') {
+    return readBody(req).then((body) => startCrawl(body, res)).catch(respond500);
+  }
+  if (req.method === 'POST' && pathname === '/api/crawl/stop') {
+    return stopCrawl(res);
+  }
+  if (req.method === 'GET' && pathname === '/api/crawl/status') {
+    return sendJson(res, 200, crawlState);
+  }
+  if (req.method === 'GET' && pathname === '/api/logs') {
+    return sendJson(res, 200, { logs: getMergedLogs() });
+  }
+  if (req.method === 'POST' && pathname === '/api/logs/clear') {
+    crawlState.log = [];
+    systemLogs.length = 0;
+    pushSystemLog('日志已清空', 'warn');
+    return sendJson(res, 200, { ok: true });
+  }
+  if (req.method === 'GET' && pathname === '/api/models') {
+    return sendJson(res, 200, { models: MODELS, default: config.model });
+  }
+  if (req.method === 'GET' && pathname === '/api/settings') {
+    return sendJson(res, 200, {
+      apiKey: config.apiKey ? config.apiKey.slice(0, 8) + '••••••••' : '',
+      apiBase: config.apiBase,
+      model: config.model,
+      rootDir: getWorkspaceRoot(),
+      pageSize: config.pageSize,
+      delayMs: config.delayMs,
+      cookie: session.cookie,
+      port: config.port,
+    });
+  }
+  if (req.method === 'POST' && pathname === '/api/settings') {
+    return readBody(req).then((body) => {
+      const previousApiKey = config.apiKey;
+      const previousCookie = session.cookie;
+      if (body.apiKey && !body.apiKey.includes('••••')) config.apiKey = body.apiKey;
+      if (body.model && MODEL_IDS.has(body.model)) config.model = body.model;
+      if (body.rootDir) {
+        const rootDir = path.resolve(String(body.rootDir).trim());
+        if (!fs.existsSync(rootDir) || !fs.statSync(rootDir).isDirectory()) {
+          return sendJson(res, 400, { error: '输出根目录不存在: ' + rootDir });
+        }
+        config.rootDir = rootDir;
+      }
+      if (body.pageSize !== undefined) {
+        const pageSize = Number(body.pageSize);
+        if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 200) {
+          return sendJson(res, 400, { error: '每页条数必须为 1-200 的整数' });
+        }
+        config.pageSize = pageSize;
+      }
+      if (body.delayMs !== undefined) {
+        const delayMs = Number(body.delayMs);
+        if (!Number.isFinite(delayMs) || delayMs < 0 || delayMs > 60000) {
+          return sendJson(res, 400, { error: '间隔必须为 0-60000 毫秒' });
+        }
+        config.delayMs = delayMs;
+      }
+      saveConfig();
+      if (body.cookie !== undefined) {
+        session.cookie = body.cookie;
+        saveSession();
+      }
+      pushSystemLog(
+        `系统设置已保存:模型=${config.model},根目录=${config.rootDir},每页=${config.pageSize},间隔=${config.delayMs}ms` +
+        (previousApiKey !== config.apiKey ? ',API Key 已更新' : '') +
+        (body.cookie !== undefined && previousCookie !== session.cookie ? ',Cookie 已更新' : ''),
+        'success'
+      );
+      sendJson(res, 200, { ok: true });
+    }).catch(respond500);
+  }
+  if (req.method === 'GET' && pathname === '/api/scan') {
+    try {
+      return handleScan(query, res);
+    } catch (e) {
+      return respond500(e);
+    }
+  }
+  if (req.method === 'GET' && pathname === '/api/creations') {
+    try {
+      return handleCreations(query, res);
+    } catch (e) {
+      return respond500(e);
+    }
+  }
+  if (req.method === 'GET' && pathname === '/files') {
+    try {
+      return handleFiles(query, res);
+    } catch (e) {
+      return respond500(e);
+    }
+  }
+  if (req.method === 'POST' && pathname === '/api/generate/brand') {
+    return readBody(req).then((body) => handleGenerateBrand(body, res)).catch(respond500);
+  }
+  if (req.method === 'POST' && pathname === '/api/generate/dish') {
+    return readBody(req).then((body) => handleGenerateDish(body, res)).catch(respond500);
+  }
+  if (req.method === 'GET' && pathname.startsWith('/api/job/')) {
+    try {
+      return handleJob(pathname, res);
+    } catch (e) {
+      return respond500(e);
+    }
+  }
+  if (req.method === 'POST' && pathname === '/api/save-config') {
+    return readBody(req).then((body) => handleSaveConfig(body, res)).catch(respond500);
+  }
+  if (req.method === 'POST' && pathname === '/api/save-poster-canvas') {
+    return readBody(req).then((body) => handleSavePosterCanvas(body, res)).catch(respond500);
+  }
+  if (req.method === 'POST' && pathname === '/api/open-folder') {
+    return readBody(req).then((body) => {
+      const dir = body.dir || path.join(body.root || getWorkspaceRoot(), body.folderName || '', '_生成图片');
+      const fallbackDir = body.dir ? null : path.join(body.root || getWorkspaceRoot(), body.folderName || '');
+      const targetDir = fs.existsSync(dir) ? dir : (fallbackDir && fs.existsSync(fallbackDir) ? fallbackDir : null);
+      if (!targetDir) return sendJson(res, 400, { error: '目录不存在: ' + dir });
+      const command = process.platform === 'win32' ? 'explorer' : process.platform === 'darwin' ? 'open' : 'xdg-open';
+      execFile(command, [targetDir], () => {});
+      sendJson(res, 200, { ok: true, opened: targetDir });
+    }).catch(respond500);
+  }
+
+  return serveStatic(pathname, res);
+});
+
+server.listen(config.port, () => {
+  const url = `http://localhost:${config.port}`;
+  pushSystemLog(`服务已启动:${url}`, 'success');
+  console.log(`门店装修工具已启动: ${url}`);
+  // "start" is a cmd.exe builtin (not a standalone executable), so it must be run via cmd /c;
+  // the empty "" argument is the window-title placeholder start expects before the URL.
+  execFile('cmd', ['/c', 'start', '', url], () => {});
+});

+ 8 - 0
启动.bat

@@ -0,0 +1,8 @@
+@echo off
+cd /d "%~dp0"
+if not exist node_modules (
+  echo 正在安装依赖...
+  call npm install
+)
+echo 正在启动门店装修工具...
+call npm start

+ 8 - 0
启动.command

@@ -0,0 +1,8 @@
+#!/bin/bash
+cd "$(dirname "$0")"
+if [ ! -d node_modules ]; then
+  echo "正在安装依赖..."
+  npm install
+fi
+echo "正在启动门店装修工具..."
+npm start

+ 8 - 0
启动.sh

@@ -0,0 +1,8 @@
+#!/bin/bash
+cd "$(dirname "$0")"
+if [ ! -d node_modules ]; then
+  echo "正在安装依赖..."
+  npm install
+fi
+echo "正在启动门店装修工具..."
+npm start