_check.js 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. "use strict";
  2. /* ========== 规则数据(完整规则手册) ========== */
  3. const MATS=[{n:"双饰面",min:3000,max:3500},{n:"PET",min:4500,max:5000},{n:"混油",min:6000,max:7000}];
  4. const WARD=[["板门补差价",538,"元/㎡"],["玻璃门补差价",1852,"元/㎡"],["灯带",386,"元/㎡或元/m"],["变压器+开关",536,"元/套"],["自弹器",115,"元/个"],["见光板",1138,"元/㎡"],["铰链",40,"元/个"],["护墙板",1852,"元/㎡"],["拉手",226,"元/个"],["抽屉",1300,"元/个"],["金属拉手(普通)",225,"元/m"],["金属拉手(铝格栅)",680,"元/个"],["衣杆",288,"元/m"],["圆弧",1560,"元/m"]];
  5. const DOORS=[["开放","开放柜","无门",1748,2380],["开放","装饰柜","无门",868,null],["双饰面板","双饰面-平板门","18MM",1748,2380],["双饰面板","双饰面-平板门","25MM",1918,2550],["双饰面板","双饰面+轻奢卡条","18MM",2216,2848],["双饰面板","双饰面+轻奢卡条","25MM",2386,3018],["水晶板门","水晶板门","18MM",2120,2960],["水晶板门","水晶板门","25MM",2290,3130],["水晶板门","水晶板门+轻奢卡条","18MM",2590,3430],["水晶板门","水晶板门+轻奢卡条","25MM",2758,3598],["铝框门","KM2611门板","生态板/PET",2488,3360],["铝框门","铝框玻璃门(有缝)","灰玻/茶玻/黑玻",2488,3248]];
  6. const KIT_FIX=[["fridge","冰箱吊柜",2622,"元/m"],["tall","高柜",5950,"元/m"],["counter","台面(石英石)",1480,"元/m"],["over","台面超出部分",2480,"元/m"],["side","见光板",1138,"元/㎡"]];
  7. const KIT_ACC=[["碗篮",1268],["锅篮",1628],["自弹器",115],["切角",500],["台下盆",500],["包管",500]];
  8. const RULES=[["目测估算像素/毫米比例","用图纸标注(如有)或厨房台面深度600mm(自动模式)"],["只看到红框部分","完整测量整个红框边界"],["厨房尺寸目测估算","用量尺/标准图/标注/CAD数据"],["混淆W/H方向","取 max(W,H) 为沿墙长度"],["厨房按面积整体计价","分项按延米/按个计价"],["有红框时跳过AI识别","AI始终先执行"],["平面图上假装能识别护墙板","平面图只能识别柜体和厨房,护墙板需手动指定或CAD图"],["护墙板用不同公式","与柜体相同:最长边×2.5=面积"],["报价单不区分基础项和增值项","必须分为两部分"],["橱柜地柜/吊柜固定死单价","支持12种门板配置下拉选择"],["使用门洞/床等不可靠隐式参照物","只保留「厨房台面深度600mm」"],["无标注时要求用户手动标注尺寸","AI全自动:600mm全图锚点,无需用户标注"],["不告知用户精度等级","UI必须标注「精确值」或「估算值」"],["平面图斜线填充误判为护墙板","一律不自动识别为护墙板"]];
  9. /* ========== 状态 ========== */
  10. const S={ft:"plan",redbox:false,scale:null,scaleSrc:"",objs:[],mat:0,rate:10,
  11. ward:WARD.map(()=>({on:false,v:0})),cfgBase:2,cfgHang:2,
  12. len:{base:0,hang:0,fridge:0,tall:0,counter:0,over:0,side:0},acc:KIT_ACC.map(()=>0)};
  13. const $=id=>document.getElementById(id);
  14. const fmt=n=>(Math.round(n)||0).toLocaleString("zh-CN");
  15. const fmt2=n=>(Math.round(n*100)/100).toLocaleString("zh-CN",{maximumFractionDigits:2});
  16. const unit=()=>{const m=MATS[S.mat];return m.min+(m.max-m.min)*(S.rate-10)/5;};
  17. const dimScale=()=>S.ft==="cad"?0.001:(S.scale||0);
  18. const sleep=ms=>new Promise(r=>setTimeout(r,ms));
  19. function precision(){if(S.ft==="cad")return{cls:"exact",txt:"精确值 ±0.1-1%"};if(S.redbox)return{cls:"est",txt:"估算值 ±2-5%(红框+标注)"};return S.scale?{cls:"est",txt:"估算值 ±5-8%(600mm锚点)"}:{cls:"na",txt:"精度:待识别"};}
  20. function toast(msg,cls){const t=document.createElement("div");t.className="toast "+(cls||"toast-info");t.textContent=msg;document.body.appendChild(t);setTimeout(()=>t.remove(),2600);}
  21. /* ========== 视图/步骤导航 ========== */
  22. function switchView(v){
  23. ["quotes","editor","rules"].forEach(k=>{
  24. $("view-"+k).classList.toggle("hidden",k!==v);
  25. $("nav-"+k).classList.toggle("active",k===v);
  26. });
  27. }
  28. const STEP_NAMES=["上传资料","AI解析","自动计价","报价单"];
  29. function qOpenEditorStep(n){
  30. for(let i=1;i<=4;i++)$("step-"+i).classList.toggle("hidden",i!==n);
  31. document.querySelectorAll("#steps-bar .step-dot").forEach(d=>{
  32. const s=+d.dataset.step;
  33. d.classList.toggle("active",s===n);
  34. d.classList.toggle("done",s<n);
  35. });
  36. $("editor-step-breadcrumb").textContent=`步骤 ${n}/4 · ${STEP_NAMES[n-1]}`;
  37. if(n===2)$("q-step2-fh-display").textContent=($("q-floor-height").value||"—")+" m";
  38. if(n===3)recalc();
  39. if(n===4){recalc();renderTiers();}
  40. window.scrollTo({top:0,behavior:"smooth"});
  41. }
  42. function qNewQuote(){switchView("editor");qOpenEditorStep(1);}
  43. function backToList(){qSaveQuote(true);switchView("quotes");}
  44. function qToggleRecogConfig(){const p=$("q-recog-config-panel");p.style.display=p.style.display==="none"?"block":"none";}
  45. /* ========== 画布与叠加 ========== */
  46. let img=null,overlays=[];
  47. const cv=$("q-ai-canvas"),ctx=cv.getContext("2d");
  48. function draw(){if(!img)return;const k=cv.width/img.naturalWidth;ctx.clearRect(0,0,cv.width,cv.height);ctx.drawImage(img,0,0,cv.width,cv.height);
  49. overlays.forEach(o=>{ctx.lineWidth=3;
  50. if(o.t==="anchor"){ctx.strokeStyle="#E53E3E";ctx.beginPath();ctx.moveTo(o.x*k,o.y*k);ctx.lineTo((o.x+o.w)*k,(o.y+o.h)*k);ctx.stroke();}
  51. else if(o.t==="red"){ctx.strokeStyle="#E53E3E";ctx.strokeRect(o.x*k,o.y*k,o.w*k,o.h*k);}
  52. else{ctx.strokeStyle=o.t==="cab"?"#3182CE":o.t==="panel"?"#38A169":"#805AD5";ctx.strokeRect(o.x*k,o.y*k,o.w*k,o.h*k);
  53. ctx.fillStyle=ctx.strokeStyle;ctx.font="bold 14px sans-serif";ctx.fillText(o.label||"",o.x*k+4,o.y*k+16);}});}
  54. function showImg(im){img=im;cv.width=1000;cv.height=Math.round(1000*im.naturalHeight/im.naturalWidth);draw();
  55. $("q-btn-analyze").disabled=false;
  56. $("q-status-drawing").textContent="✅ 图纸:已上传("+im.naturalWidth+"×"+im.naturalHeight+")";
  57. $("q-status-drawing").style.background="#C6F6D5";$("q-status-drawing").style.color="#276749";}
  58. /* ========== 示例图纸生成 ========== */
  59. function genSample(u){
  60. const c=document.createElement("canvas");c.width=1600;c.height=1100;const g=c.getContext("2d");
  61. g.fillStyle="#fff";g.fillRect(0,0,1600,1100);
  62. g.strokeStyle="#000";g.lineWidth=10;g.strokeRect(60,60,1480,980);
  63. g.lineWidth=4;g.strokeStyle="#000";
  64. g.beginPath();g.moveTo(800,60);g.lineTo(800,400);g.stroke(); // 内墙
  65. g.fillStyle="#333"; // 厨房(深色填充带,AI可解算台面深度)
  66. if(u){g.beginPath();g.moveTo(100,100);g.lineTo(140,100);g.lineTo(140,220);g.lineTo(380,220);g.lineTo(380,100);g.lineTo(420,100);g.lineTo(420,260);g.lineTo(100,260);g.closePath();g.fill();}
  67. else{g.beginPath();g.moveTo(100,100);g.lineTo(540,100);g.lineTo(540,140);g.lineTo(140,140);g.lineTo(140,360);g.lineTo(100,360);g.closePath();g.fill();}
  68. g.strokeStyle="#000";g.lineWidth=3; // 柜体(矩形+叉线)
  69. [[1150,120,300,80],[1150,700,240,70],[620,940,200,70],[300,700,70,220]].forEach(r=>{g.strokeRect(...r);g.beginPath();g.moveTo(r[0],r[1]);g.lineTo(r[0]+r[2],r[1]+r[3]);g.moveTo(r[0]+r[2],r[1]);g.lineTo(r[0],r[1]+r[3]);g.stroke();});
  70. g.lineWidth=2; // 护墙板斜线填充(AI 必须忽略,规则#14)
  71. for(let i=0;i<12;i++){g.beginPath();g.moveTo(620+i*26,120);g.lineTo(640+i*26,160);g.stroke();}
  72. g.fillStyle="#999";g.font="26px sans-serif";g.fillText("厨房",220,320);g.fillText("主卧",1250,450);g.fillText("客厅",600,600);
  73. if(u){g.strokeStyle="#E53E3E";g.lineWidth=8;g.strokeRect(80,80,360,200);} // 红框
  74. const im=new Image();im.src=c.toDataURL("image/png");return im;
  75. }
  76. function qUseMockDrawing(isU){const im=genSample(isU);im.onload=()=>{showImg(im);toast(isU?"已载入示例户型图(U型厨房+红框)":"已载入示例户型图(L型厨房)");qStartAnalysis();};}
  77. function qHandleUnified(inp){const f=inp.files[0];if(!f)return;
  78. if(/\.(pdf|dwg|dxf)$/i.test(f.name)){alert("PDF/DWG 请先转 JPG/PNG(Demo 限制)");return;}
  79. const rd=new FileReader();rd.onload=()=>{const im=new Image();im.onload=()=>{showImg(im);qStartAnalysis();};im.src=rd.result;};rd.readAsDataURL(f);}
  80. function qStartAnalysis(){if(!img){toast("请先上传图纸或选择示例","toast-error");return;}qOpenEditorStep(2);runAI();}
  81. /* ========== AI 识别引擎 v3(墙体分离 + 柜体特征分类) ========== */
  82. function logAI(html,cls){const el=$("q-ai-log");const s=document.createElement("span");if(cls)s.className=cls;s.innerHTML=html;el.appendChild(s);el.scrollTop=el.scrollHeight;}
  83. function setProg(i,st){$("q-ai-prog-"+i).className="ai-progress-step "+(st||"");}
  84. function erodeMask(m,w,h,it){let cur=m;for(let t=0;t<it;t++){const o=new Uint8Array(w*h);
  85. for(let y=1;y<h-1;y++)for(let x=1;x<w-1;x++){const i=y*w+x;
  86. if(cur[i]&&cur[i-1]&&cur[i+1]&&cur[i-w]&&cur[i+w]&&cur[i-w-1]&&cur[i-w+1]&&cur[i+w-1]&&cur[i+w+1])o[i]=1;}
  87. cur=o;}return cur;}
  88. function dilateMask(m,w,h,it){let cur=m;for(let t=0;t<it;t++){const o=new Uint8Array(cur);
  89. for(let y=1;y<h-1;y++)for(let x=1;x<w-1;x++){const i=y*w+x;
  90. if(cur[i]){o[i-1]=1;o[i+1]=1;o[i-w]=1;o[i+w]=1;}}
  91. cur=o;}return cur;}
  92. function wallThickness(wm,w,h){const runs=[];
  93. for(let y=0;y<h;y++){let r=0;for(let x=0;x<=w;x++){const v=x<w?wm[y*w+x]:0;if(v)r++;else{if(r>=3&&r<=40)runs.push(r);r=0;}}}
  94. for(let x=0;x<w;x++){let r=0;for(let y=0;y<=h;y++){const v=y<h?wm[y*w+x]:0;if(v)r++;else{if(r>=3&&r<=40)runs.push(r);r=0;}}}
  95. if(!runs.length)return 6;runs.sort((a,b)=>a-b);return runs[runs.length>>1];}
  96. async function runAI(){
  97. $("q-ai-log").innerHTML="";overlays=[];S.redbox=false;S.scale=null;S.scaleSrc="";S.objs=[];S.ft="plan";
  98. $("q-step2-next-btn").disabled=true;
  99. setProg(0,"running");setProg(1,"");setProg(2,"");
  100. logAI("▶ AI 全图识别启动(优先级:无红框全图 &gt; 红框校准)","b");await sleep(250);
  101. const AW=500,k=AW/img.naturalWidth,AH=Math.round(img.naturalHeight*k);
  102. const oc=document.createElement("canvas");oc.width=AW;oc.height=AH;const og=oc.getContext("2d");
  103. og.drawImage(img,0,0,AW,AH);const d=og.getImageData(0,0,AW,AH).data;
  104. const ink=new Uint8Array(AW*AH);
  105. let rx0=1e9,ry0=1e9,rx1=-1,ry1=-1,rc=0;
  106. for(let i=0;i<AW*AH;i++){const r=d[i*4],g=d[i*4+1],b=d[i*4+2];
  107. if(r>140&&r-g>50&&r-b>50){rc++;if(i%AW<rx0)rx0=i%AW;if(i%AW>rx1)rx1=i%AW;const y=(i/AW)|0;if(y<ry0)ry0=y;if(y>ry1)ry1=y;}
  108. else if(.299*r+.587*g+.114*b<140)ink[i]=1;}
  109. logAI("· 步骤1 结构扫描:"+AW+"×"+AH+" 栅格化,墨线像素 "+ink.reduce((a,v)=>a+v,0));await sleep(200);
  110. if(rc>150){S.redbox=true;overlays.push({t:"red",x:rx0/k,y:ry0/k,w:(rx1-rx0)/k,h:(ry1-ry0)/k});logAI("· 检测到红框 → 场景B(有红框+标注,±2-5%)。AI 仍先执行全图识别(规则#6)","w");}
  111. else logAI("· 未检测到红框 → 场景A(无红框,AI全自动)");
  112. $("q-ai-scene-chip").textContent=S.redbox?"场景B · 红框+标注":"场景A · 无红框全自动";
  113. await sleep(200);
  114. /* 连通域(全墨线) */
  115. const cc=mask=>{const vis=new Uint8Array(mask.length),out=[];
  116. for(let s=0;s<mask.length;s++){if(!mask[s]||vis[s])continue;
  117. const q=[s];vis[s]=1;let x0=1e9,y0=1e9,x1=-1,y1=-1,area=0,hh=0;
  118. while(hh<q.length){const c=q[hh++];area++;const cx=c%AW,cy=(c/AW)|0;
  119. if(cx<x0)x0=cx;if(cx>x1)x1=cx;if(cy<y0)y0=cy;if(cy>y1)y1=cy;
  120. const nb=[c-1,c+1,c-AW,c+AW];for(const n of nb){if(n>=0&&n<mask.length&&mask[n]&&!vis[n]){vis[n]=1;q.push(n);}}}
  121. out.push({x:x0,y:y0,w:x1-x0,h:y1-y0,area});}
  122. return out;};
  123. const compsAll=cc(ink).sort((a,b)=>b.w*b.h-a.w*a.h);
  124. logAI("· 连通域分析:"+compsAll.length+" 个结构体");await sleep(200);
  125. setProg(0,"done");setProg(1,"running");
  126. /* 模式判定:示意图(实心厨房色块) vs 彩色户型图(墙体分离) */
  127. const solid=compsAll.find(c=>{const f=c.area/(c.w*c.h);return f>0.8&&c.w*c.h>0.02*AW*AH&&c.w<0.6*AW&&c.h<0.6*AH;});
  128. let kitchen=null;const wards=[];let excluded=0;
  129. if(solid){
  130. /* —— 示意图路径:探针判定 L/U + 二次方程解台面深度 —— */
  131. const c=solid,probes=[[c.x+c.w/2,c.y+3],[c.x+c.w/2,c.y+c.h-3],[c.x+3,c.y+c.h/2],[c.x+c.w-3,c.y+c.h/2]]
  132. .map(p=>ink[((p[1]|0)*AW+(p[0]|0))]?1:0).reduce((a,v)=>a+v,0);
  133. const sL=c.w+c.h,discL=sL*sL-4*c.area,sU=c.w+2*c.h,discU=sU*sU-8*c.area,sU2=2*c.w+c.h,discU2=sU2*sU2-8*c.area;
  134. let dd=0;
  135. if(probes===2&&discL>0)dd=(sL-Math.sqrt(discL))/2;
  136. else if(probes===3&&discU>0)dd=(sU-Math.sqrt(discU))/4;
  137. else if(probes===3&&discU2>0)dd=(sU2-Math.sqrt(discU2))/4;
  138. if(dd>.02*AW&&dd<.12*AW)kitchen={...c,dd};
  139. if(kitchen){
  140. const sc=0.6/(kitchen.dd/k);S.scale=sc;S.scaleSrc="AI定位厨房台面深度=600mm锚点(全图通用)";
  141. overlays.push({t:"kitchen",x:kitchen.x/k,y:kitchen.y/k,w:kitchen.w/k,h:kitchen.h/k,label:"厨房"});
  142. overlays.push({t:"anchor",x:kitchen.x/k,y:kitchen.y/k,w:kitchen.dd/k,h:0});
  143. const kt=kitchen.w>=kitchen.h?"L":"U";
  144. if(kt==="L"){$("kA").value=(Math.max(kitchen.w,kitchen.h)*sc).toFixed(2);$("kC").value=(Math.min(kitchen.w,kitchen.h)*sc).toFixed(2);$("kB").value="";}
  145. else{$("kA").value=(kitchen.h*sc).toFixed(2);$("kB").value=(kitchen.h*sc).toFixed(2);$("kC").value=(kitchen.w*sc).toFixed(2);}
  146. logAI("· 定位厨房(示意图模式):台面深度 "+Math.round(kitchen.dd)+"px = 600mm → 1px="+(sc*1000).toFixed(1)+"mm","b");
  147. }
  148. let cn=0;
  149. for(const c2 of compsAll){
  150. if(cn>=8)break;
  151. if(c2.w>.6*AW&&c2.h>.6*AH)continue;
  152. if(kitchen&&c2.x>=kitchen.x-4&&c2.y>=kitchen.y-4&&c2.x+c2.w<=kitchen.x+kitchen.w+4&&c2.y+c2.h<=kitchen.y+kitchen.h+4)continue;
  153. const f=c2.area/(c2.w*c2.h);
  154. if(c2.w>.03*AW&&c2.h>.03*AW&&Math.min(c2.w,c2.h)>=.018*AW&&f>=.10&&f<.7){
  155. cn++;wards.push(c2);}}
  156. }else{
  157. /* —— 彩色户型图路径:墙体分离 + 柜体特征分类 —— */
  158. logAI("· 彩色户型图模式:腐蚀法分离墙体(粗线)与家具(细线)","b");await sleep(200);
  159. let wm=erodeMask(ink,AW,AH,2),eit=2;
  160. if(wm.reduce((a,v)=>a+v,0)<0.005*AW*AH){wm=erodeMask(ink,AW,AH,1);eit=1;}
  161. let T=wallThickness(wm,AW,AH)+2*eit;T=Math.max(4,Math.min(24,T));
  162. logAI("· 墙厚先验 T≈"+T+"px(仅用于形状判定,不作尺寸参照,规则#11)");
  163. const wcore=dilateMask(wm,AW,AH,1);
  164. const wz=dilateMask(wm,AW,AH,Math.max(3,Math.round(T*0.7)));
  165. const thin=new Uint8Array(AW*AH);for(let i=0;i<AW*AH;i++)thin[i]=ink[i]&&!wcore[i];
  166. const comps=cc(thin).sort((a,b)=>b.w*b.h-a.w*a.h);
  167. const att=c=>{let hit=0,tot=0;
  168. for(let x=c.x;x<=c.x+c.w;x+=2){for(const y of[c.y,c.y+c.h]){tot++;if(wz[((y|0)*AW+(x|0))])hit++;}}
  169. for(let y=c.y;y<=c.y+c.h;y+=2){for(const x of[c.x,c.x+c.w]){tot++;if(wz[((y|0)*AW+(x|0))])hit++;}}
  170. return hit/Math.max(1,tot);};
  171. const hatch=c=>{let rt=0,ct=0,nr=0,nc=0;
  172. for(let y=c.y+2;y<c.y+c.h-1;y+=2){nr++;let t=0,pv=0;for(let x=c.x;x<=c.x+c.w;x++){const v=thin[y*AW+x]?1:0;if(v&&!pv)t++;pv=v;}rt+=t;}
  173. for(let x=c.x+2;x<c.x+c.w-1;x+=2){nc++;let t=0,pv=0;for(let y=c.y;y<=c.y+c.h;y++){const v=thin[y*AW+x]?1:0;if(v&&!pv)t++;pv=v;}ct+=t;}
  174. return Math.max(rt/Math.max(1,nr),ct/Math.max(1,nc));};
  175. const xpat=c=>{let h1=0,h2=0;const N=16;
  176. for(let i=1;i<N;i++){if(thin[((c.y+c.h*i/N)|0)*AW+((c.x+c.w*i/N)|0)])h1++;
  177. if(thin[((c.y+c.h-c.h*i/N)|0)*AW+((c.x+c.w*i/N)|0)])h2++;}
  178. return Math.min(h1,h2)/N;};
  179. const smalls=comps.filter(s=>s.w>=0.5*T&&s.w<=2.5*T&&s.h>=0.5*T&&s.h<=2.5*T);
  180. const kidsOf=c=>smalls.filter(s=>s!==c&&s.x>c.x&&s.y>c.y&&s.x+s.w<c.x+c.w&&s.y+s.h<c.y+c.h).length;
  181. const bands=[];
  182. for(const c of comps){
  183. if(c.w>0.55*AW||c.h>0.55*AH)continue;
  184. const short=Math.min(c.w,c.h),long=Math.max(c.w,c.h);
  185. if(short<1.2*T)continue; // 噪点/小符号
  186. const a=att(c),f=c.area/(c.w*c.h);
  187. /* 橱柜台面带:贴墙 + 长条 + 深度≈3T(600mm) + 内含水槽/灶具符号 */
  188. if(a>=0.25&&long>=2.2*short&&short>=2.2*T&&short<=3.8*T&&(kidsOf(c)>=1||(f>=0.1&&f<=0.35&&long>=3*short))){bands.push(c);continue;}
  189. /* 衣柜:贴墙 + 斜线填充 或 X交叉 */
  190. if(a>=0.3&&short>=1.5*T&&short<=5*T&&long>=1.4*short&&(hatch(c)>=4||xpat(c)>=0.55)){wards.push(c);continue;}
  191. /* 近方形 X交叉柜(玄关柜等) */
  192. if(a>=0.3&&xpat(c)>=0.55&&short>=1.5*T&&short<=6*T&&long<2.2*short){wards.push(c);continue;}
  193. excluded++;}
  194. logAI("· 柜体判定标准:贴墙率≥30% 且(斜线填充 / X交叉 / 600mm台面带+水槽灶具)","b");
  195. logAI("· 已排除床/沙发/桌椅/洁具等独立家具 "+excluded+" 个(不贴墙或特征不符)");await sleep(200);
  196. /* 厨房:台面带聚类 → 600mm 锚点 */
  197. if(bands.length){
  198. bands.sort((a,b)=>b.w*b.h-a.w*a.h);
  199. const cluster=[bands[0]];
  200. for(const b of bands.slice(1)){if(cluster.some(c=>Math.abs(c.x-b.x)<6*T&&Math.abs(c.y-b.y)<6*T||
  201. (b.x<c.x+c.w+6*T&&c.x<b.x+b.w+6*T&&b.y<c.y+c.h+6*T&&c.y<b.y+b.h+6*T)))cluster.push(b);}
  202. const b0=cluster[0],dNat=Math.min(b0.w,b0.h)/k;
  203. S.scale=0.6/dNat;S.scaleSrc="AI定位橱柜台面深度=600mm锚点(全图通用)";
  204. let ux0=1e9,uy0=1e9,ux1=-1,uy1=-1;
  205. cluster.forEach(c=>{ux0=Math.min(ux0,c.x);uy0=Math.min(uy0,c.y);ux1=Math.max(ux1,c.x+c.w);uy1=Math.max(uy1,c.y+c.h);});
  206. kitchen={x:ux0,y:uy0,w:ux1-ux0,h:uy1-uy0};
  207. overlays.push({t:"kitchen",x:ux0/k,y:uy0/k,w:(ux1-ux0)/k,h:(uy1-uy0)/k,label:"厨房"});
  208. overlays.push({t:"anchor",x:b0.x/k,y:b0.y/k,w:Math.min(b0.w,b0.h)/k,h:0});
  209. const lens=cluster.map(c=>Math.max(c.w,c.h)*S.scale).sort((a,b)=>b-a);
  210. if(lens.length===1){$("kA").value=lens[0].toFixed(2);$("kB").value="";$("kC").value="";logAI("· 厨房:检测到1条台面带 "+lens[0].toFixed(2)+"m,请在下一步补充另一侧边长","w");}
  211. else if(lens.length===2){$("kA").value=lens[0].toFixed(2);$("kC").value=lens[1].toFixed(2);$("kB").value="";}
  212. else{$("kA").value=lens[0].toFixed(2);$("kB").value=lens[1].toFixed(2);$("kC").value=lens[2].toFixed(2);}
  213. logAI("· 定位厨房:"+cluster.length+" 条台面带,深度 "+Math.round(dNat)+"px = 600mm → 1px="+(S.scale*1000).toFixed(1)+"mm","b");
  214. }else logAI("· 未能自动定位厨房台面带 → 请在下一步补充一个已知尺寸","e");
  215. }
  216. draw();await sleep(200);
  217. setProg(1,"done");setProg(2,"running");
  218. /* 柜体输出 */
  219. let cn=0;
  220. for(const c of wards){
  221. if(cn>=8)break;
  222. cn++;S.objs.push({name:"柜体"+cn,kind:"cab",src:"AI",w:Math.round(c.w/k),h:Math.round(c.h/k)});
  223. overlays.push({t:"cab",x:c.x/k,y:c.y/k,w:c.w/k,h:c.h/k,label:"柜体"+cn});}
  224. logAI("· 柜体识别:"+cn+" 个(max(W,H)×比例×2.5=投影面积)","b");await sleep(200);
  225. logAI("· 护墙板:平面图斜线填充不可靠 → <b>不自动识别</b>(规则#14),如需请在下一步手动添加","w");await sleep(150);
  226. logAI("· 尺寸计算完成。精度等级:"+precision().txt,"b");
  227. setProg(2,"done");draw();rebuildObjs();recalc();
  228. $("q-step2-next-btn").disabled=false;
  229. }
  230. /* ========== 对象表(步骤3) ========== */
  231. function rebuildObjs(){
  232. const tb=$("objBody");tb.innerHTML="";
  233. S.objs.forEach(p=>{const tr=document.createElement("tr");
  234. tr.innerHTML=`<td><input type="text" value="${p.name}" style="width:90px" data-f="name"></td><td>${p.src}</td>
  235. <td><input type="number" value="${p.w}" data-f="w"></td><td><input type="number" value="${p.h}" data-f="h"></td>
  236. <td class="text-right" data-o="len">-</td><td class="text-right" data-o="area">-</td><td class="text-right" data-o="amt">-</td>
  237. <td><button class="btn btn-danger btn-sm">删</button></td>`;
  238. tr.querySelectorAll("input").forEach(inp=>inp.oninput=()=>{p[inp.dataset.f]=inp.dataset.f==="name"?inp.value:(+inp.value||0);recalc();});
  239. tr.querySelector("button").onclick=()=>{S.objs.splice(S.objs.indexOf(p),1);rebuildObjs();recalc();};
  240. p._tr=tr;tb.appendChild(tr);});
  241. }
  242. $("addCab").onclick=()=>{S.objs.push({name:"柜体"+(S.objs.length+1),kind:"cab",src:"手动",w:0,h:0});rebuildObjs();recalc();};
  243. $("addPanel").onclick=()=>{S.objs.push({name:"护墙板"+(S.objs.filter(o=>o.kind==="panel").length+1),kind:"panel",src:"手动",w:0,h:0});rebuildObjs();recalc();};
  244. /* ========== 增值项 / 厨房分项(步骤3表格) ========== */
  245. function buildWard(){$("wardBody").innerHTML="";WARD.forEach((w,i)=>{const tr=document.createElement("tr");
  246. tr.innerHTML=`<td><input type="checkbox" style="width:16px"></td><td>${w[0]}</td><td class="text-right">${fmt(w[1])} ${w[2]}</td><td><input type="number" min="0" step="any" placeholder="0"></td><td class="text-right">¥0</td>`;
  247. const cb=tr.children[0].firstChild,inp=tr.children[3].firstChild;
  248. cb.checked=S.ward[i].on;inp.value=S.ward[i].v||"";inp.disabled=!S.ward[i].on;
  249. cb.onchange=()=>{S.ward[i].on=cb.checked;inp.disabled=!cb.checked;recalc();};
  250. inp.oninput=()=>{S.ward[i].v=+inp.value||0;recalc();};
  251. w._tr=tr;$("wardBody").appendChild(tr);});}
  252. function buildKit(){const tb=$("kitBody");tb.innerHTML="";
  253. const opts=base=>DOORS.map((d,i)=>d[4]===null&&base?null:`<option value="${i}">${d[0]}·${d[1]}·${d[2]} ${base?fmt(d[4])+"元/m":fmt(d[3])+"元/m"}</option>`).filter(Boolean).join("");
  254. const mk=h=>{const tr=document.createElement("tr");tr.innerHTML=h;tb.appendChild(tr);return tr;};
  255. mk(`<td>地柜(长=厨房可计费总长)</td><td><select id="selBase">${opts(true)}</select> <input type="number" id="lenBase" step="0.01" style="width:70px"> m</td><td class="text-right" id="pBase">-</td><td class="text-right" id="aBase">¥0</td>`);
  256. mk(`<td>吊柜</td><td><select id="selHang">${opts(false)}</select> <input type="number" id="lenHang" step="0.01" value="0" style="width:70px"> m</td><td class="text-right" id="pHang">-</td><td class="text-right" id="aHang">¥0</td>`);
  257. KIT_FIX.forEach(f=>mk(`<td>${f[1]}</td><td><input type="number" id="len_${f[0]}" step="0.01" value="0" style="width:70px"> ${f[3]==="元/㎡"?"㎡":"m"}</td><td class="text-right">${fmt(f[2])} ${f[3]}</td><td class="text-right" id="a_${f[0]}">¥0</td>`));
  258. KIT_ACC.forEach((a,i)=>mk(`<td>${a[0]}</td><td><input type="number" id="acc_${i}" step="1" value="0" style="width:70px"> 个</td><td class="text-right">${fmt(a[1])} 元/个</td><td class="text-right" id="aa_${i}">¥0</td>`));
  259. $("selBase").value=S.cfgBase;$("selHang").value=S.cfgHang;
  260. $("selBase").onchange=e=>{S.cfgBase=+e.target.value;recalc();};
  261. $("selHang").onchange=e=>{S.cfgHang=+e.target.value;recalc();};
  262. $("lenBase").oninput=e=>{S.len.base=+e.target.value||0;recalc();};
  263. $("lenHang").oninput=e=>{S.len.hang=+e.target.value||0;recalc();};
  264. KIT_FIX.forEach(f=>{const el=$("len_"+f[0]);el.value=S.len[f[0]]||"";el.oninput=e=>{S.len[f[0]]=+e.target.value||0;recalc();};});
  265. KIT_ACC.forEach((a,i)=>{const el=$("acc_"+i);el.value=S.acc[i]||"";el.oninput=e=>{S.acc[i]=+e.target.value||0;recalc();};});}
  266. /* ========== 计算引擎 ========== */
  267. let LASTQ={baseSum:0,kSum:0,wSum:0,varArea:0,baseRows:[],valueRows:[],kitLines:[],cabLines:[],panelLines:[],wardLines:[]};
  268. function recalc(){
  269. const pr=precision(),sc=dimScale();
  270. $("precBadge").className="badge "+pr.cls;$("precBadge").textContent=pr.txt;
  271. $("q-quote-prec").className="badge "+pr.cls;$("q-quote-prec").textContent=pr.txt;
  272. $("sceneChip").textContent="场景:"+(S.ft==="cad"?"CAD矢量":S.redbox?"B 有红框+标注":"A 无红框");
  273. $("scaleInfo").textContent=S.ft==="cad"?"CAD矢量直读(mm)":(S.scale?`1px=${(sc*1000).toFixed(2)}mm(${S.scaleSrc})`:"比例未建立");
  274. /* 基础项 */
  275. let baseSum=0,varArea=0;const baseRows=[],cabLines=[],panelLines=[];
  276. S.objs.forEach(p=>{const len=Math.max(p.w||0,p.h||0)*sc,area=len*2.5,amt=area*unit();baseSum+=amt;varArea+=area;
  277. baseRows.push({name:p.name,kind:p.kind,src:p.src,len,area,amt});
  278. const line=`<div class="total-line"><span>${p.name}[${p.src}]:max边 ${fmt2(len)}m ×2.5 = ${fmt2(area)}㎡ × ¥${fmt(unit())}</span><span class="num">¥${fmt(amt)}</span></div>`;
  279. (p.kind==="panel"?panelLines:cabLines).push(line);
  280. if(p._tr){p._tr.querySelector('[data-o="len"]').textContent=fmt2(len);p._tr.querySelector('[data-o="area"]').textContent=fmt2(area);p._tr.querySelector('[data-o="amt"]').textContent="¥"+fmt(amt);}});
  281. $("baseTotal").textContent="¥"+fmt(baseSum);
  282. /* 厨房判定 */
  283. const sides=[$("kA").value,$("kB").value,$("kC").value].map(v=>+v||0).filter(v=>v>0);
  284. let kT=0,kt="未判定",kF="";
  285. if(sides.length>=2){const mn=Math.min(...sides),mx=[...sides].sort((a,b)=>b-a);
  286. if(mn<=2){kt="L型";kT=Math.max(mx[0]+mx[mx.length-1]-1.6,0);kF=`L:${fmt2(mx[0])}+${fmt2(mx[mx.length-1])}−0.6−1.0`;}
  287. else if(sides.length>=3){kt="U型";kT=Math.max(mx[0]+mx[1]+mx[2]-2.2,0);kF=`U:${fmt2(mx[0])}+${fmt2(mx[1])}+${fmt2(mx[2])}−0.6−0.6−1.0`;}}
  288. $("kType").textContent=kt;$("kType").className="chip "+(kt==="U型"?"u":kt==="L型"?"l":"n");
  289. $("kFormula").textContent=kT>0?`${kF} = ${fmt2(kT)}m 可计费总长`:kF;
  290. if(document.activeElement!==$("lenBase")){$("lenBase").value=kT?kT.toFixed(2):"";S.len.base=kT;}
  291. if(document.activeElement!==$("len_counter")){$("len_counter").value=kT?kT.toFixed(2):"";S.len.counter=kT;}
  292. $("fbRow").style.display=S.scale||S.ft==="cad"?"none":"flex";
  293. /* 增值项:衣柜 */
  294. let wSum=0;const wardLines=[],valueRows=[];
  295. WARD.forEach((w,i)=>{const v=S.ward[i].on?S.ward[i].v:0,a=v*w[1];wSum+=a;
  296. if(w._tr)w._tr.children[4].textContent="¥"+fmt(a);
  297. if(v>0&&S.ward[i].on){wardLines.push(`<div class="total-line"><span>${w[0]} ×${fmt2(v)}</span><span class="num">¥${fmt(a)}</span></div>`);
  298. valueRows.push({item:"衣柜增值·"+w[0],spec:w[2],qty:fmt2(v),price:fmt(w[1])+" "+w[2],amt:a});}});
  299. /* 增值项:厨房 */
  300. let kSum=0;const kitLines=[];const db=DOORS[S.cfgBase],dh=DOORS[S.cfgHang];
  301. $("pBase").textContent=fmt(db[4]||0)+" 元/m";$("pHang").textContent=fmt(dh[3])+" 元/m";
  302. const aB=S.len.base*(db[4]||0);kSum+=aB;$("aBase").textContent="¥"+fmt(aB);
  303. if(aB){kitLines.push(`<div class="total-line"><span>地柜[${db[1]} ${db[2]}] ${fmt2(S.len.base)}m × ¥${fmt(db[4]||0)}</span><span class="num">¥${fmt(aB)}</span></div>`);
  304. valueRows.push({item:"橱柜·地柜",spec:`${db[0]}·${db[1]}·${db[2]}`,qty:fmt2(S.len.base)+" m",price:fmt(db[4]||0)+" 元/m",amt:aB});}
  305. const aH=S.len.hang*dh[3];kSum+=aH;$("aHang").textContent="¥"+fmt(aH);
  306. if(aH){kitLines.push(`<div class="total-line"><span>吊柜[${dh[1]} ${dh[2]}] ${fmt2(S.len.hang)}m × ¥${fmt(dh[3])}</span><span class="num">¥${fmt(aH)}</span></div>`);
  307. valueRows.push({item:"橱柜·吊柜",spec:`${dh[0]}·${dh[1]}·${dh[2]}`,qty:fmt2(S.len.hang)+" m",price:fmt(dh[3])+" 元/m",amt:aH});}
  308. KIT_FIX.forEach(f=>{const a=S.len[f[0]]*f[2];kSum+=a;$("a_"+f[0]).textContent="¥"+fmt(a);
  309. if(a){kitLines.push(`<div class="total-line"><span>${f[1]} ${fmt2(S.len[f[0]])}${f[3]==="元/㎡"?"㎡":"m"} × ¥${fmt(f[2])}</span><span class="num">¥${fmt(a)}</span></div>`);
  310. valueRows.push({item:"橱柜·"+f[1],spec:"—",qty:fmt2(S.len[f[0]])+(f[3]==="元/㎡"?" ㎡":" m"),price:fmt(f[2])+" "+f[3],amt:a});}});
  311. KIT_ACC.forEach((ac,i)=>{const a=S.acc[i]*ac[1];kSum+=a;$("aa_"+i).textContent="¥"+fmt(a);
  312. if(a){kitLines.push(`<div class="total-line"><span>${ac[0]} ×${S.acc[i]}</span><span class="num">¥${fmt(a)}</span></div>`);
  313. valueRows.push({item:"橱柜·"+ac[0],spec:"配件",qty:S.acc[i]+" 个",price:fmt(ac[1])+" 元/个",amt:a});}});
  314. $("valueTotal").textContent="¥"+fmt(wSum+kSum);
  315. $("grandTotal").textContent="¥"+fmt(baseSum+wSum+kSum);
  316. LASTQ={baseSum,kSum,wSum,varArea,baseRows,valueRows,kitLines,cabLines,panelLines,wardLines};
  317. renderStep4();
  318. }
  319. /* ========== 步骤4:报价单渲染 ========== */
  320. function renderTiers(){
  321. const box=$("q-ai-tier-selector");box.innerHTML="";
  322. MATS.forEach((m,i)=>{const d=document.createElement("div");
  323. d.className="tier-card"+(i===S.mat?" selected":"");
  324. d.innerHTML=`<div class="tier-name">${m.n}</div><div class="tier-price">¥${fmt(m.min)} — ¥${fmt(m.max)}/㎡</div><div class="tier-note">${i===S.mat?"✓ 已选择 · 当前单价 ¥"+fmt(unit()):"点击选择"}</div>`;
  325. d.onclick=()=>{S.mat=i;renderTiers();recalc();};
  326. box.appendChild(d);});
  327. }
  328. function qSetMat(i){S.mat=i;renderTiers();recalc();}
  329. function renderStep4(){
  330. const m=MATS[S.mat],Q=LASTQ;
  331. const fixed=Q.kSum+Q.wSum;
  332. const lo=Q.varArea*m.min+fixed,hi=Q.varArea*m.max+fixed,cur=Q.varArea*unit()+fixed;
  333. $("q-quote-meta").textContent=`生成日期:${new Date().toLocaleDateString("zh-CN")} | 材质档:${m.n} | 浮动率:${S.rate}%`;
  334. $("q-step5-range").textContent=`¥${fmt(lo)} — ¥${fmt(hi)}`;
  335. $("q-step5-rate-text").textContent=`预估总价区间(${m.n}档单价 ¥${fmt(m.min)}~¥${fmt(m.max)}/㎡) | ${precision().txt}`;
  336. $("q-ai-range-total").textContent="¥"+fmt(cur);
  337. $("q-ai-range-note").textContent=`当前参考总价(${m.n} ¥${fmt(unit())}/㎡ × ${fmt2(Q.varArea)}㎡ + 增值项 ¥${fmt(fixed)})`;
  338. $("q-ai-float-badge").textContent=S.rate+"%";
  339. $("q-ai-float-slider").value=S.rate;
  340. $("global-float-slider").value=S.rate;$("global-float-badge").textContent=S.rate+"%";
  341. document.querySelectorAll(".float-preset button[data-aifp]").forEach(b=>b.classList.toggle("active",+b.dataset.aifp===Math.round(S.rate)));
  342. $("q-ai-float-result").innerHTML=`当前单价:<b>¥${fmt2(unit())}/㎡</b>(${m.n} ${fmt(m.min)}~${fmt(m.max)},浮动率 ${S.rate}%)· 投影面积 <b>${fmt2(Q.varArea)}㎡</b> · 基础项 ¥${fmt(Q.baseSum)} + 增值项 ¥${fmt(fixed)} = 总计 <b>¥${fmt(cur)}</b>`;
  343. /* 空间卡片 */
  344. const cards=[];
  345. if(Q.kitLines.length)cards.push(["🍳 厨房","¥"+fmt(Q.kSum),Q.kitLines]);
  346. if(Q.cabLines.length)cards.push(["🗄️ 柜体(投影面积)","¥"+fmt(LASTQ.baseRows.filter(r=>r.kind==="cab").reduce((a,r)=>a+r.amt,0)),Q.cabLines]);
  347. if(Q.panelLines.length)cards.push(["🧱 护墙板(手动)","¥"+fmt(LASTQ.baseRows.filter(r=>r.kind==="panel").reduce((a,r)=>a+r.amt,0)),Q.panelLines]);
  348. if(Q.wardLines.length)cards.push(["👔 衣柜增值项","¥"+fmt(Q.wSum),Q.wardLines]);
  349. $("q-space-cards-container").innerHTML=cards.length?cards.map(c=>`<div class="space-card"><div class="space-header"><span>${c[0]}</span><span style="color:var(--primary)">${c[1]}</span></div><div class="space-lines">${c[2].join("")}</div></div>`).join(""):'<div class="note" style="text-align:center;">暂无识别结果,请先完成步骤 1-3</div>';
  350. /* 明细表 */
  351. $("q-quote-base-body").innerHTML=Q.baseRows.length?Q.baseRows.map(r=>`<tr><td>${r.kind==="panel"?"护墙板·":"柜体·"}${r.name}</td><td>${r.src}</td><td class="text-right">${fmt2(r.len)}</td><td class="text-right">${fmt2(r.area)}</td><td class="text-right">${fmt(unit())}</td><td class="text-right">¥${fmt(r.amt)}</td></tr>`).join(""):'<tr><td colspan="6" class="note">暂无</td></tr>';
  352. $("q-quote-value-body").innerHTML=Q.valueRows.length?Q.valueRows.map(r=>`<tr><td>${r.item}</td><td>${r.spec}</td><td class="text-right">${r.qty}</td><td class="text-right">${r.price}</td><td class="text-right">¥${fmt(r.amt)}</td></tr>`).join(""):'<tr><td colspan="5" class="note">暂无</td></tr>';
  353. $("q-grand-total-value").textContent="¥"+fmt(cur);
  354. $("q-grand-total-area").textContent=`投影面积 ${fmt2(Q.varArea)}㎡ · ${m.n} ¥${fmt2(unit())}/㎡`;
  355. $("q-grand-total-note").textContent=cur>0?`基础项 ¥${fmt(Q.baseSum)} + 增值项 ¥${fmt(fixed)}(${precision().txt})`:"上传图纸并完成 AI 识别后自动计算";
  356. $("q-step5-disclaimer").textContent=`⚠️ 声明:本报价为 AI 辅助生成的预估价格(${precision().txt}),最终成交价以实际量尺和确认为准。护墙板平面图不自动识别;仅「厨房台面深度600mm」为隐式参照物。`;
  357. $("global-est-range").textContent=`¥${fmt(lo)} ~ ¥${fmt(hi)}`;
  358. $("global-est-mid").textContent="参考总价: ¥"+fmt(cur);
  359. }
  360. function qOnAiFloatChange(v){S.rate=Math.min(15,Math.max(10,+v||10));renderTiers();recalc();}
  361. function qSetAiFloat(v){S.rate=Math.min(15,Math.max(10,+v||10));renderTiers();recalc();}
  362. /* ========== 报价单管理 ========== */
  363. let QUOTES=[];
  364. try{QUOTES=JSON.parse(localStorage.getItem("aiq_new_quotes")||"[]");}catch(e){QUOTES=[];}
  365. function saveQuotes(){try{localStorage.setItem("aiq_new_quotes",JSON.stringify(QUOTES));}catch(e){}}
  366. function snapshot(){return JSON.stringify({ft:S.ft,redbox:S.redbox,scale:S.scale,scaleSrc:S.scaleSrc,mat:S.mat,rate:S.rate,cfgBase:S.cfgBase,cfgHang:S.cfgHang,len:S.len,acc:S.acc,ward:S.ward,objs:S.objs.map(o=>({name:o.name,kind:o.kind,src:o.src,w:o.w,h:o.h}))});}
  367. function qSaveQuote(silent){
  368. if(!S.objs.length&&!LASTQ.baseSum&&!LASTQ.kSum&&!LASTQ.wSum){if(!silent)toast("暂无计价内容,请先上传图纸完成AI识别","toast-error");return;}
  369. const m=MATS[S.mat],fixed=LASTQ.kSum+LASTQ.wSum;
  370. const q={id:Date.now(),name:("报价单 "+(QUOTES.length+1)),date:new Date().toLocaleString("zh-CN"),mat:m.n,rate:S.rate,
  371. lo:LASTQ.varArea*m.min+fixed,hi:LASTQ.varArea*m.max+fixed,cur:LASTQ.varArea*unit()+fixed,prec:precision().txt,snap:snapshot()};
  372. QUOTES.unshift(q);saveQuotes();renderQuoteList();
  373. if(!silent)toast("已存入报价单列表 ✓","toast-success");
  374. }
  375. function renderQuoteList(){
  376. const tb=$("quote-table-body");tb.innerHTML="";
  377. $("quote-empty").style.display=QUOTES.length?"none":"block";
  378. QUOTES.forEach((q,i)=>{const tr=document.createElement("tr");
  379. tr.innerHTML=`<td><b>${q.name}</b></td><td>${q.date}</td><td>${q.mat}(${q.rate}%)</td>
  380. <td class="text-right">¥${fmt(q.lo)} — ¥${fmt(q.hi)}</td><td><span class="badge est" style="font-size:10px;">${q.prec}</span></td>
  381. <td></td>`;
  382. const td=tr.children[5];
  383. const b1=document.createElement("button");b1.className="btn btn-outline btn-sm";b1.textContent="打开";
  384. b1.onclick=e=>{e.stopPropagation();qOpenQuote(q);};
  385. const b2=document.createElement("button");b2.className="btn btn-danger btn-sm";b2.textContent="删除";b2.style.marginLeft="6px";
  386. b2.onclick=e=>{e.stopPropagation();QUOTES.splice(i,1);saveQuotes();renderQuoteList();toast("已删除");};
  387. td.appendChild(b1);td.appendChild(b2);
  388. tr.style.cursor="pointer";tr.onclick=()=>qOpenQuote(q);
  389. tb.appendChild(tr);});
  390. }
  391. function qOpenQuote(q){
  392. try{const snap=JSON.parse(q.snap);
  393. Object.assign(S,{ft:snap.ft,redbox:snap.redbox,scale:snap.scale,scaleSrc:snap.scaleSrc,mat:snap.mat,rate:snap.rate,cfgBase:snap.cfgBase,cfgHang:snap.cfgHang,len:snap.len,acc:snap.acc,ward:snap.ward,objs:snap.objs});
  394. $("kA").value="";$("kB").value="";$("kC").value="";
  395. /* 重新从对象推导厨房边(若曾识别)——此处保持手动值,直接重算 */
  396. rebuildObjs();buildWard();buildKit();recalc();
  397. $("editor-title").textContent=q.name;
  398. switchView("editor");qOpenEditorStep(4);
  399. toast("已打开:"+q.name);
  400. }catch(e){toast("打开失败","toast-error");}
  401. }
  402. /* ========== 规则配置表 ========== */
  403. function switchRuleTab(t){
  404. document.querySelectorAll("#view-rules .tab").forEach(el=>el.classList.remove("active"));
  405. event.target.classList.add("active");
  406. document.querySelectorAll(".rule-tab-content").forEach(c=>c.classList.add("hidden"));
  407. $("rule-tab-"+t).classList.remove("hidden");
  408. }
  409. function renderRules(){
  410. $("rule-table-mats").innerHTML=MATS.map(m=>`<tr><td><b>${m.n}</b></td><td class="text-right">${fmt(m.min)}</td><td class="text-right">${fmt(m.max)}</td></tr>`).join("");
  411. $("rule-table-wardrobe").innerHTML=WARD.map((w,i)=>`<tr><td>${i+1}</td><td>${w[0]}</td><td class="text-right">${fmt(w[1])}</td><td>${w[2]}</td></tr>`).join("");
  412. $("rule-table-kitchen").innerHTML=DOORS.map((d,i)=>`<tr><td>${i+1}</td><td>${d[0]}</td><td>${d[1]}</td><td>${d[2]}</td><td class="text-right">${fmt(d[3])}</td><td class="text-right">${d[4]===null?"—(装饰柜无地柜价)":fmt(d[4])}</td></tr>`).join("");
  413. $("rule-table-kitchen-fix").innerHTML=KIT_FIX.map(f=>`<tr><td>${f[1]}</td><td class="text-right">${fmt(f[2])}</td><td>${f[3]}</td></tr>`).join("")+KIT_ACC.map(a=>`<tr><td>${a[0]}(配件)</td><td class="text-right">${fmt(a[1])}</td><td>元/个</td></tr>`).join("");
  414. $("rule-table-ban").innerHTML=RULES.map((r,i)=>`<tr><td>${i+1}</td><td style="color:#C53030;">🚫 ${r[0]}</td><td style="color:#276749;">✓ ${r[1]}</td></tr>`).join("");
  415. }
  416. /* ========== 事件与初始化 ========== */
  417. $("kA").oninput=$("kB").oninput=$("kC").oninput=()=>recalc();
  418. $("btnFb").onclick=()=>{const px=+$("fbPx").value||0,mm=+$("fbMm").value||0;if(px>0&&mm>0){S.scale=(mm/1000)/px;S.scaleSrc="用户补充已知尺寸";recalc();toast("比例已建立");}else toast("请填写像素数与毫米数","toast-error");};
  419. buildWard();buildKit();renderTiers();renderRules();renderQuoteList();recalc();