Răsfoiți Sursa

fix(skill): 报告默认展示原图;交互全量事件委托修复手机触屏点击无效

- renderReport 增加 preferSVG 开关,默认 imageDataUrl 优先(用户反馈重绘偏差大)
- 触屏修复:document 级事件委托、click/touchend 双通道、12px 滑动阈值防误触、
  touch-action:manipulation、去 alert 改页内 toast(微信内置浏览器兼容)
- publish.js 新增 fmode 短信登录(fmodeLogin) + Parse File 上传(parseFileUpload),
  CLI 增加 login/upload 子命令
- iPhone 13 触屏仿真验证通过:tap 调价/关闭/toast 全部生效,零 JS 报错
Hermes Agent 2 săptămâni în urmă
părinte
comite
bb38f11447

+ 30 - 0
skill/CLAUDE.md

@@ -0,0 +1,30 @@
+# floorplan-quotation skill 开发上下文(给 Claude Code)
+
+## 项目
+拉迷全屋定制「户型图→报价单」智能体技能,纯 JS ESM(浏览器/CLI 共用)。
+- `skill/src/` 各模块:defaults.js(规则库) quote.js(计价) vision.js(GLM视觉识别) geometry.js redraw.js(SVG重绘) parse.js(PDF/图片) report.js(HTML报告) publish.js(S3) index.js(管线+CLI)
+- CLI:`node bin/fpc.js quote <图> --client X --tier standard`
+- 测试:`node test/run.js`(12 项断言,必须全过)
+- 计价口径与公司 Excel 一致:投影面积×三档材质区间单价;厨房按计费延米(L/U型扣转角0.6m×处数+冰箱位1m);客户三型 price/standard/quality;谈价区间=到手价±浮动率
+
+## 当前任务(用户反馈的两个 bug)
+
+### Bug 1(必须修):报告默认展示重绘 SVG,但与原图结构相差太大
+用户要求:**默认展示原始户型图**。修改 `skill/src/report.js` 的 renderReport:
+- planBlock 逻辑改为:`imageDataUrl` 存在时**优先原图**(`.plan-img`),SVG 仅在 `opts.preferSVG === true` 时使用
+- 同步改 `skill/src/index.js`:quoteFromFile 不再把 planSVG 传给 renderReport(或传 preferSVG:false)
+- 保留 redraw.js 不删(未来实验用)
+
+### Bug 2(必须修):手机上「调整报价」操作台点击无效
+桌面浏览器正常、手机无效 → 触屏兼容问题。修改 report.js 内嵌脚本与样式:
+- 所有交互元素加 `cursor:pointer; touch-action:manipulation; -webkit-tap-highlight-color:transparent`
+- 事件绑定从裸 `addEventListener('click')` 改为**统一事件委托**且同时监听 `click` 与 `touchend`(touchend 里 preventDefault 防止幽灵 click 双触发),委托到 document 级
+- `.chip`/`.btn`/`.del`/`.room-h` 全部走委托;input 的 change 事件用委托 `change` 监听
+- `#cabList` 行内 input 的 `data-i` 索引绑定逻辑重写:委托模式下从 `e.target` 读 dataset
+- 不要用 alert()(微信内置浏览器可能屏蔽),改用页内 toast div
+- 检查 CSS:`:active` 状态替代 `:hover`(触屏无 hover)
+
+## 约束
+- 只改 skill/ 目录下文件,不碰仓库其他程序
+- 改完运行 `node test/run.js` 必须全过;再用 `node bin/fpc.js` 无 API key 的模式跑不了真实识别没关系,重点是语法与单测
+- 中文注释,保持现有代码风格

Fișier diff suprimat deoarece este prea mare
+ 101 - 0
skill/out/报价单-缪女士(时代之城)-09010142.html


+ 20 - 1
skill/src/index.js

@@ -15,7 +15,7 @@ import { recognizeFloorplan } from './vision.js';
 import { buildQuote, applyOps } from './quote.js';
 import { buildQuote, applyOps } from './quote.js';
 import { renderSVG, buildPlan, fallbackImage } from './redraw.js';
 import { renderSVG, buildPlan, fallbackImage } from './redraw.js';
 import { renderReport, writeReport } from './report.js';
 import { renderReport, writeReport } from './report.js';
-import { publish } from './publish.js';
+import { publish, fmodeLogin, parseFileUpload } from './publish.js';
 
 
 export { buildQuote, applyOps, kitchenBillable, tierUnitPrice } from './quote.js';
 export { buildQuote, applyOps, kitchenBillable, tierUnitPrice } from './quote.js';
 export { recognizeFloorplan, analyzeFloorplan, measureCabinets, normalizeCabinetName } from './vision.js';
 export { recognizeFloorplan, analyzeFloorplan, measureCabinets, normalizeCabinetName } from './vision.js';
@@ -116,6 +116,25 @@ export async function main(argv = process.argv.slice(2)) {
     console.log(JSON.stringify({ analysis: rec.analysis, cabinets: rec.cabinets }, null, 2));
     console.log(JSON.stringify({ analysis: rec.analysis, cabinets: rec.cabinets }, null, 2));
     return;
     return;
   }
   }
+  if (cmd === 'login') {
+    // 用法: fpc login <手机号> <验证码>
+    const mobile = rest[0]; const code = rest[1];
+    if (!mobile || !code) { console.error('用法: fpc login <手机号> <验证码>  (验证码先通过 apig-pay 登录页或 send-code 流程获取)'); process.exit(1); }
+    const r = await fmodeLogin(mobile, code);
+    console.log('登录成功:', r.user.username || r.user.objectId, '| session 已存 ~/.fpc/fmode-session.json');
+    return;
+  }
+  if (cmd === 'upload') {
+    // 用法: fpc upload <文件路径> [文件名]
+    const file = rest[0]; const name = rest[1];
+    if (!file || !fs.existsSync(file)) { console.error('文件不存在:', file); process.exit(1); }
+    const { loadFmodeSessionExport } = await import('./publish.js').catch(() => ({}));
+    const sessPath = path.join((process.env.HOME || '/opt/data/home'), '.fpc', 'fmode-session.json');
+    const sessionToken = process.env.FMODE_SESSION_TOKEN || JSON.parse(fs.readFileSync(sessPath, 'utf8')).sessionToken;
+    const r = await parseFileUpload({ filename: name || path.basename(file), body: fs.readFileSync(file), contentType: name?.endsWith('.html') || file.endsWith('.html') ? 'text/html; charset=utf-8' : 'application/octet-stream', sessionToken });
+    console.log(r.url);
+    return;
+  }
   if (cmd !== 'quote') { console.error('未知命令:', cmd); process.exit(1); }
   if (cmd !== 'quote') { console.error('未知命令:', cmd); process.exit(1); }
 
 
   const file = rest[0];
   const file = rest[0];

+ 71 - 1
skill/src/publish.js

@@ -81,6 +81,16 @@ export async function publish({ html, filename, cfg: override } = {}) {
     const url = await s3Put({ key, body: html, contentType: 'text/html; charset=utf-8', cfg });
     const url = await s3Put({ key, body: html, contentType: 'text/html; charset=utf-8', cfg });
     return { url, local: null, mode: 's3' };
     return { url, local: null, mode: 's3' };
   }
   }
+  // fmode session 上传(个人空间):Parse File + FileObject 记录
+  const sess = cfg.sessionToken || (typeof loadFmodeSession === 'function' ? loadFmodeSession() : null);
+  if (sess) {
+    try {
+      const r = await parseFileUpload({ filename: path.basename(key), body: html, contentType: 'text/html; charset=utf-8', sessionToken: sess });
+      return { url: r.url, local: null, mode: 'fmode', objectId: r.objectId };
+    } catch (e) {
+      if (process.env.FPC_DEBUG) console.error('[fpc] fmode 上传失败,降级本地:', e.message);
+    }
+  }
   // 本地兜底
   // 本地兜底
   const outDir = path.resolve(process.cwd(), 'out');
   const outDir = path.resolve(process.cwd(), 'out');
   const local = path.join(outDir, path.basename(key));
   const local = path.join(outDir, path.basename(key));
@@ -88,4 +98,64 @@ export async function publish({ html, filename, cfg: override } = {}) {
   fs.writeFileSync(local, html);
   fs.writeFileSync(local, html);
   return { url: null, local, mode: 'local' };
   return { url: null, local, mode: 'local' };
 }
 }
-export default { publish, loadConfig, isConfigured, s3Put };
+
+// ---------- fmode(Parse)上传 ----------
+const PARSE_BASE = process.env.FMODE_PARSE_BASE || 'https://server.fmode.cn/parse';
+const PARSE_APP_ID = process.env.FMODE_PARSE_APP_ID || 'ncloudmaster';
+
+function loadFmodeSession() {
+  const file = path.join(os.homedir(), '.fpc', 'fmode-session.json');
+  try { return JSON.parse(fs.readFileSync(file, 'utf8')).sessionToken || null; } catch { return null; }
+}
+export function saveFmodeSession(sessionToken, meta = {}) {
+  const file = path.join(os.homedir(), '.fpc', 'fmode-session.json');
+  fs.mkdirSync(path.dirname(file), { recursive: true });
+  fs.writeFileSync(file, JSON.stringify({ sessionToken, ...meta, savedAt: new Date().toISOString() }, null, 2));
+  return file;
+}
+// 短信验证码登录(协议来自 FmodeStudio apig-pay 前端)
+export async function fmodeLogin(mobile, smsCode, { company = 'E4KpGvTEto' } = {}) {
+  const base = PARSE_BASE.replace(/\/parse$/, '');
+  const r1 = await fetch(`${base}/api/apig/message`, {
+    method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Parse-Application-Id': PARSE_APP_ID },
+    body: JSON.stringify({ mobile, company }),
+  });
+  if (!r1.ok) throw new Error(`验证码发送失败 ${r1.status}`);
+  const r2 = await fetch(`${base}/api/fmode/mobile?mobile=${encodeURIComponent(mobile)}&code=${encodeURIComponent(smsCode)}`, {
+    headers: { 'X-Parse-Application-Id': PARSE_APP_ID },
+  });
+  const j = await r2.json();
+  if (j.code !== 200 || !j.data?.token) throw new Error(j.mess || j.message || j.error || '验证码错误或已过期');
+  const sessionToken = j.data.token;
+  const me = await fetch(`${PARSE_BASE}/users/me`, { headers: { 'X-Parse-Application-Id': PARSE_APP_ID, 'X-Parse-Session-Token': sessionToken } });
+  const user = await me.json();
+  saveFmodeSession(sessionToken, { objectId: user.objectId, username: user.username, mobile });
+  return { sessionToken, user };
+}
+
+// Parse File 上传(JSON base64)+ FileObject 业务记录;返回公网 URL
+export async function parseFileUpload({ filename, body, contentType = 'text/html', sessionToken }) {
+  if (!sessionToken) throw new Error('缺少 fmode session token(先 fmodeLogin 或配置 FMODE_SESSION_TOKEN)');
+  const payload = typeof body === 'string' && !body.startsWith('data:')
+    ? Buffer.from(body, 'utf8').toString('base64')
+    : String(body).split(',')[1] || '';
+  const r1 = await fetch(`${PARSE_BASE}/files/${encodeURIComponent(filename)}`, {
+    method: 'POST',
+    headers: { 'X-Parse-Application-Id': PARSE_APP_ID, 'X-Parse-Session-Token': sessionToken, 'Content-Type': 'application/json' },
+    body: JSON.stringify({ base64: payload, _ContentType: contentType }),
+  });
+  if (!r1.ok) throw new Error(`Parse 文件上传失败 ${r1.status}: ${(await r1.text()).slice(0, 200)}`);
+  const f = await r1.json(); // { url, name }
+  // 业务记录:FileObject(老平台通用类,失败不阻塞——文件本身已可访问)
+  let objectId = null;
+  try {
+    const r2 = await fetch(`${PARSE_BASE}/classes/FileObject`, {
+      method: 'POST',
+      headers: { 'X-Parse-Application-Id': PARSE_APP_ID, 'X-Parse-Session-Token': sessionToken, 'Content-Type': 'application/json' },
+      body: JSON.stringify({ file: { name: f.name, url: f.url, __type: 'File' }, filename, mimeType: contentType, source: 'floorplan-quote-skill' }),
+    });
+    if (r2.ok) objectId = (await r2.json()).objectId;
+  } catch { /* 记录失败不影响 */ }
+  return { url: f.url, name: f.name, objectId };
+}
+export default { publish, loadConfig, isConfigured, s3Put, fmodeLogin, parseFileUpload };

+ 68 - 32
skill/src/report.js

@@ -20,18 +20,21 @@ function loadEngineSource() {
 }
 }
 
 
 // ---------- 主入口 ----------
 // ---------- 主入口 ----------
-export function renderReport({ quote, planSVG = null, imageDataUrl = null, analysis = null, clientName = '', salesNote = '' }) {
+export function renderReport({ quote, planSVG = null, imageDataUrl = null, analysis = null, clientName = '', salesNote = '', preferSVG = false }) {
   const engine = loadEngineSource();
   const engine = loadEngineSource();
   const meta = quote.meta || {};
   const meta = quote.meta || {};
   const t = quote.totals || {};
   const t = quote.totals || {};
   const money = (n) => '¥' + Number(n || 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 });
   const money = (n) => '¥' + Number(n || 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 });
   const title = `${clientName || meta.clientName || '客户'} · 全屋定制报价方案`;
   const title = `${clientName || meta.clientName || '客户'} · 全屋定制报价方案`;
 
 
-  const planBlock = planSVG
-    ? `<div class="plan-wrap" id="planWrap">${planSVG}</div><p class="plan-note">📐 AI 结构化重绘示意图(非施工图,以实际量尺为准)</p>`
-    : imageDataUrl
-      ? `<div class="plan-wrap"><img src="${imageDataUrl}" alt="户型图" class="plan-img"></div><p class="plan-note">📐 原始户型图(以实际量尺为准)</p>`
-      : '';
+  // 默认展示原始户型图(用户要求:重绘与原图结构差距大);preferSVG=true 时才用 SVG 重绘
+  const planBlock = !preferSVG && imageDataUrl
+    ? `<div class="plan-wrap"><img src="${imageDataUrl}" alt="户型图" class="plan-img" loading="lazy"></div><p class="plan-note">📐 原始户型图(以实际量尺为准)</p>`
+    : planSVG
+      ? `<div class="plan-wrap" id="planWrap">${planSVG}</div><p class="plan-note">📐 AI 结构化重绘示意图(非施工图,以实际量尺为准)</p>`
+      : imageDataUrl
+        ? `<div class="plan-wrap"><img src="${imageDataUrl}" alt="户型图" class="plan-img" loading="lazy"></div><p class="plan-note">📐 原始户型图(以实际量尺为准)</p>`
+        : '';
 
 
   const dimsBlock = analysis?.dimensions?.length
   const dimsBlock = analysis?.dimensions?.length
     ? `<details class="dims"><summary>图面尺寸标注(${analysis.dimensions.length} 条,用作交叉验证)</summary><ul>${
     ? `<details class="dims"><summary>图面尺寸标注(${analysis.dimensions.length} 条,用作交叉验证)</summary><ul>${
@@ -108,6 +111,14 @@ input[type=range]{width:100%}
 .cabrow input{width:64px;padding:6px;border:1px solid var(--bd);border-radius:6px;font-size:13px}
 .cabrow input{width:64px;padding:6px;border:1px solid var(--bd);border-radius:6px;font-size:13px}
 .cabrow .del{color:var(--dg);font-size:18px;padding:0 6px;cursor:pointer}
 .cabrow .del{color:var(--dg);font-size:18px;padding:0 6px;cursor:pointer}
 .cabrow .add{color:var(--ok)}
 .cabrow .add{color:var(--ok)}
+/* 触屏交互保障 */
+.chip,.btn,.del,.room-h{cursor:pointer;touch-action:manipulation;-webkit-tap-highlight-color:transparent;user-select:none;-webkit-user-select:none}
+.chip:active{background:#DBEAFE}
+.btn:active{filter:brightness(.92)}
+.del:active{transform:scale(1.25)}
+.room-h:active{background:#F7FAFC}
+.fpc-toast{position:fixed;left:50%;bottom:calc(96px + env(safe-area-inset-bottom));transform:translateX(-50%) translateY(16px);background:rgba(45,55,72,.94);color:#fff;padding:10px 18px;border-radius:22px;font-size:13px;max-width:82vw;opacity:0;pointer-events:none;transition:all .25s;z-index:200}
+.fpc-toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
 @media(min-width:640px){.hero .price{font-size:44px}}
 @media(min-width:640px){.hero .price{font-size:44px}}
 </style>
 </style>
 </head>
 </head>
@@ -143,7 +154,7 @@ input[type=range]{width:100%}
     <div class="card" style="margin-top:8px">
     <div class="card" style="margin-top:8px">
       <div class="trow"><span>项目小计</span><span class="v" id="tSub">${money(t.itemsSubtotal)}</span></div>
       <div class="trow"><span>项目小计</span><span class="v" id="tSub">${money(t.itemsSubtotal)}</span></div>
       <div class="trow dim"><span id="tDiscLabel">${esc(t.discountLabel || '')}</span><span class="v" id="tAfter">${money(t.afterDiscount)}</span></div>
       <div class="trow dim"><span id="tDiscLabel">${esc(t.discountLabel || '')}</span><span class="v" id="tAfter">${money(t.afterDiscount)}</span></div>
-      <div class="trow" id="actRow" style="${t.activityTotal ? '' : 'display:none'}"><span>活动优惠</span><span class="v" style="color:var(--ok)" id="tAct">-${money(t.activityTotal)}</span></div>
+      <div class="trow" id="tActRow" style="${t.activityTotal ? '' : 'display:none'}"><span>活动优惠</span><span class="v" style="color:var(--ok)" id="tAct">-${money(t.activityTotal)}</span></div>
       <div class="trow total"><span>到手价</span><span class="v" id="tFinal">${money(t.finalPrice)}</span></div>
       <div class="trow total"><span>到手价</span><span class="v" id="tFinal">${money(t.finalPrice)}</span></div>
     </div>
     </div>
   </div>
   </div>
@@ -195,6 +206,7 @@ console.log('fpc engine ready');
 (function(){
 (function(){
   var Q = window.__Q__; var S = document; var money = function(n){ return '¥' + Math.round(n||0).toLocaleString('zh-CN'); };
   var Q = window.__Q__; var S = document; var money = function(n){ return '¥' + Math.round(n||0).toLocaleString('zh-CN'); };
   var cfg = Object.assign({ tierKey:'standard', tierName:'PET', floatPct:15, discount:1.0, mode:'quick' }, Q.config||{});
   var cfg = Object.assign({ tierKey:'standard', tierName:'PET', floatPct:15, discount:1.0, mode:'quick' }, Q.config||{});
+  var d = S.getElementById('drawer');
   function calc(){ return buildQuote({ cabinets: Q.cabinets, tierKey: cfg.tierKey, tierName: cfg.tierName, floatPct: cfg.floatPct, discount: cfg.discount, mode: cfg.mode, clientName: ${JSON.stringify(clientName || '')} }); }
   function calc(){ return buildQuote({ cabinets: Q.cabinets, tierKey: cfg.tierKey, tierName: cfg.tierName, floatPct: cfg.floatPct, discount: cfg.discount, mode: cfg.mode, clientName: ${JSON.stringify(clientName || '')} }); }
   function refresh(){
   function refresh(){
     var q = calc(); var t = q.totals;
     var q = calc(); var t = q.totals;
@@ -204,43 +216,67 @@ console.log('fpc engine ready');
     S.getElementById('tDiscLabel').textContent = t.discountLabel;
     S.getElementById('tDiscLabel').textContent = t.discountLabel;
     S.getElementById('tAfter').textContent = money(t.afterDiscount);
     S.getElementById('tAfter').textContent = money(t.afterDiscount);
     S.getElementById('tAct').textContent = '-' + money(t.activityTotal);
     S.getElementById('tAct').textContent = '-' + money(t.activityTotal);
+    S.getElementById('tActRow').style.display = t.activityTotal ? 'flex' : 'none';
     S.getElementById('tFinal').textContent = money(t.finalPrice);
     S.getElementById('tFinal').textContent = money(t.finalPrice);
     S.getElementById('bTier').textContent = (q.meta.tierName||'') + '材质';
     S.getElementById('bTier').textContent = (q.meta.tierName||'') + '材质';
     S.getElementById('bDisc').textContent = t.discountLabel;
     S.getElementById('bDisc').textContent = t.discountLabel;
   }
   }
-  // room expand
-  S.querySelectorAll('.room-h').forEach(function(el){ el.addEventListener('click', function(){ el.parentElement.classList.toggle('open'); }); });
-  // chips
-  function bindChips(id, fn){ var box = S.getElementById(id); if(!box) return; box.addEventListener('click', function(e){ var b = e.target.closest('.chip'); if(!b) return; box.querySelectorAll('.chip').forEach(function(x){x.classList.remove('on');}); b.classList.add('on'); fn(b.dataset.v); }); }
-  bindChips('optTier', function(v){ cfg.tierKey = v; refresh(); });
-  bindChips('optMat', function(v){ cfg.tierName = v; refresh(); });
-  bindChips('optDisc', function(v){ cfg.discount = parseFloat(v); refresh(); });
-  var fr = S.getElementById('floatR'); fr.addEventListener('input', function(){ S.getElementById('floatV').textContent = fr.value; cfg.floatPct = parseInt(fr.value); refresh(); });
-  // drawer
-  var d = S.getElementById('drawer');
-  S.getElementById('btnAdjust').addEventListener('click', function(){ d.classList.add('open'); renderCabs(); });
-  d.addEventListener('click', function(e){ if(e.target === d) d.classList.remove('open'); });
+  // ---- 页内 toast(替代 alert,微信内置浏览器兼容) ----
+  var toastEl = null;
+  function toast(msg){
+    if (!toastEl) { toastEl = S.createElement('div'); toastEl.className = 'fpc-toast'; S.body.appendChild(toastEl); }
+    toastEl.textContent = msg; toastEl.classList.add('show');
+    clearTimeout(toastEl._t); toastEl._t = setTimeout(function(){ toastEl.classList.remove('show'); }, 2400);
+  }
+  // ---- 统一触屏/鼠标激活:事件委托 + click/touchend 双通道(滑动>12px 判定为滚动不触发) ----
+  var touchStart = null;
+  S.addEventListener('touchstart', function(e){ if (e.touches.length === 1) { var t0 = e.touches[0]; touchStart = { x: t0.clientX, y: t0.clientY }; } }, { passive: true });
+  function onActivate(selector, handler){
+    S.addEventListener('click', function(e){ var t = e.target.closest(selector); if (t) handler(t, e); }, false);
+    S.addEventListener('touchend', function(e){
+      var t = e.target.closest(selector); if (!t) return;
+      if (touchStart && e.changedTouches && e.changedTouches.length) {
+        var t1 = e.changedTouches[0];
+        if (Math.hypot(t1.clientX - touchStart.x, t1.clientY - touchStart.y) > 12) return; // 滚动,忽略
+      }
+      e.preventDefault(); // 阻止合成 click 双触发
+      handler(t, e);
+    }, { passive: false });
+  }
+  onActivate('.room-h', function(t){ t.parentElement.classList.toggle('open'); });
+  onActivate('.chip[data-v]', function(t){
+    var box = t.closest('.opts');
+    if (box) box.querySelectorAll('.chip').forEach(function(x){ x.classList.remove('on'); });
+    t.classList.add('on');
+    var v = t.dataset.v; var id = box ? box.id : '';
+    if (id === 'optTier') { cfg.tierKey = v; refresh(); }
+    else if (id === 'optMat') { cfg.tierName = v; refresh(); }
+    else if (id === 'optDisc') { cfg.discount = parseFloat(v); refresh(); }
+  });
+  var fr = S.getElementById('floatR');
+  function onFloat(){ S.getElementById('floatV').textContent = fr.value; cfg.floatPct = parseInt(fr.value, 10); refresh(); }
+  fr.addEventListener('input', onFloat); fr.addEventListener('change', onFloat);
+  onActivate('#btnAdjust', function(){ d.classList.add('open'); renderCabs(); });
+  onActivate('#btnApply', function(){ d.classList.remove('open'); toast('✅ 已按新参数重算'); });
+  onActivate('#drawer', function(t, e){ if (e.target === d) d.classList.remove('open'); }); // 仅点遮罩本身关闭(sheet 内点击不触发)
+  onActivate('#btnAddCab', function(){ Q.cabinets.push({ type:'储物柜', rawName:'储物柜', width:1.5, height:2.6 }); renderCabs(); refresh(); toast('已添加柜体,可改宽度'); });
+  onActivate('#btnSave', function(){ toast('已记录!销售将带这份报价与您确认到店时间,到店凭此单领取到店礼'); });
+  onActivate('.cabrow .del', function(t){ var i = parseInt(t.dataset.i, 10); if (!isNaN(i)) { Q.cabinets.splice(i, 1); renderCabs(); refresh(); } });
+  // 柜体宽度输入:委托 change(移动端键盘确认/失焦都会触发)
+  S.addEventListener('change', function(e){
+    var inp = e.target.closest ? e.target.closest('#cabList input') : null;
+    if (inp) { var i = parseInt(inp.dataset.i, 10); if (!isNaN(i)) { Q.cabinets[i].width = parseFloat(inp.value) || 1.5; refresh(); } }
+  });
   function renderCabs(){
   function renderCabs(){
     var box = S.getElementById('cabList'); box.innerHTML = '';
     var box = S.getElementById('cabList'); box.innerHTML = '';
     Q.cabinets.forEach(function(c, i){
     Q.cabinets.forEach(function(c, i){
       var row = S.createElement('div'); row.className = 'cabrow';
       var row = S.createElement('div'); row.className = 'cabrow';
-      var dims = String(c.width||'') ? String(c.width) : '';
-      row.innerHTML = '<span style="flex:1">' + c.rawName + '</span>' +
-        '<input type="number" step="0.05" value="' + (c.width||1.5) + '" data-i="' + i + '" data-f="width" title="宽(米)">' +
+      row.innerHTML = '<span style="flex:1">' + (c.rawName || c.type) + '</span>' +
+        '<input type="number" inputmode="decimal" step="0.05" min="0.3" value="' + (c.width || 1.5) + '" data-i="' + i + '" title="宽(米)">' +
         '<span class="del" data-i="' + i + '">✕</span>';
         '<span class="del" data-i="' + i + '">✕</span>';
       box.appendChild(row);
       box.appendChild(row);
     });
     });
-    box.onclick = function(e){
-      var del = e.target.closest('.del');
-      if (del) { Q.cabinets.splice(parseInt(del.dataset.i),1); renderCabs(); refresh(); return; }
-      var inp = e.target.closest('input');
-      if (inp) { inp.addEventListener('change', function(){ Q.cabinets[parseInt(inp.dataset.i)].width = parseFloat(inp.value)||1.5; refresh(); }); }
-    };
   }
   }
-  S.getElementById('btnAddCab').addEventListener('click', function(){ Q.cabinets.push({ type:'储物柜', rawName:'储物柜', width:1.5, height:2.6 }); renderCabs(); refresh(); });
-  S.getElementById('btnSave').addEventListener('click', function(){
-    alert('已记录!销售将带着这份报价与您确认到店时间,到店凭此单领取到店礼。');
-  });
   refresh();
   refresh();
 })();
 })();
 </script>
 </script>

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff