|
@@ -0,0 +1,432 @@
|
|
|
|
|
+
|
|
|
|
|
+"use strict";
|
|
|
|
|
+/* ========== 规则数据(完整规则手册) ========== */
|
|
|
|
|
+const MATS=[{n:"双饰面",min:3000,max:3500},{n:"PET",min:4500,max:5000},{n:"混油",min:6000,max:7000}];
|
|
|
|
|
+const WARD=[["板门补差价",538,"元/㎡"],["玻璃门补差价",1852,"元/㎡"],["灯带",386,"元/㎡或元/m"],["变压器+开关",536,"元/套"],["自弹器",115,"元/个"],["见光板",1138,"元/㎡"],["铰链",40,"元/个"],["护墙板",1852,"元/㎡"],["拉手",226,"元/个"],["抽屉",1300,"元/个"],["金属拉手(普通)",225,"元/m"],["金属拉手(铝格栅)",680,"元/个"],["衣杆",288,"元/m"],["圆弧",1560,"元/m"]];
|
|
|
|
|
+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]];
|
|
|
|
|
+const KIT_FIX=[["fridge","冰箱吊柜",2622,"元/m"],["tall","高柜",5950,"元/m"],["counter","台面(石英石)",1480,"元/m"],["over","台面超出部分",2480,"元/m"],["side","见光板",1138,"元/㎡"]];
|
|
|
|
|
+const KIT_ACC=[["碗篮",1268],["锅篮",1628],["自弹器",115],["切角",500],["台下盆",500],["包管",500]];
|
|
|
|
|
+const RULES=[["目测估算像素/毫米比例","用图纸标注(如有)或厨房台面深度600mm(自动模式)"],["只看到红框部分","完整测量整个红框边界"],["厨房尺寸目测估算","用量尺/标准图/标注/CAD数据"],["混淆W/H方向","取 max(W,H) 为沿墙长度"],["厨房按面积整体计价","分项按延米/按个计价"],["有红框时跳过AI识别","AI始终先执行"],["平面图上假装能识别护墙板","平面图只能识别柜体和厨房,护墙板需手动指定或CAD图"],["护墙板用不同公式","与柜体相同:最长边×2.5=面积"],["报价单不区分基础项和增值项","必须分为两部分"],["橱柜地柜/吊柜固定死单价","支持12种门板配置下拉选择"],["使用门洞/床等不可靠隐式参照物","只保留「厨房台面深度600mm」"],["无标注时要求用户手动标注尺寸","AI全自动:600mm全图锚点,无需用户标注"],["不告知用户精度等级","UI必须标注「精确值」或「估算值」"],["平面图斜线填充误判为护墙板","一律不自动识别为护墙板"]];
|
|
|
|
|
+
|
|
|
|
|
+/* ========== 状态 ========== */
|
|
|
|
|
+const S={ft:"plan",redbox:false,scale:null,scaleSrc:"",objs:[],mat:0,rate:10,
|
|
|
|
|
+ ward:WARD.map(()=>({on:false,v:0})),cfgBase:2,cfgHang:2,
|
|
|
|
|
+ len:{base:0,hang:0,fridge:0,tall:0,counter:0,over:0,side:0},acc:KIT_ACC.map(()=>0)};
|
|
|
|
|
+const $=id=>document.getElementById(id);
|
|
|
|
|
+const fmt=n=>(Math.round(n)||0).toLocaleString("zh-CN");
|
|
|
|
|
+const fmt2=n=>(Math.round(n*100)/100).toLocaleString("zh-CN",{maximumFractionDigits:2});
|
|
|
|
|
+const unit=()=>{const m=MATS[S.mat];return m.min+(m.max-m.min)*(S.rate-10)/5;};
|
|
|
|
|
+const dimScale=()=>S.ft==="cad"?0.001:(S.scale||0);
|
|
|
|
|
+const sleep=ms=>new Promise(r=>setTimeout(r,ms));
|
|
|
|
|
+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:"精度:待识别"};}
|
|
|
|
|
+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);}
|
|
|
|
|
+
|
|
|
|
|
+/* ========== 视图/步骤导航 ========== */
|
|
|
|
|
+function switchView(v){
|
|
|
|
|
+ ["quotes","editor","rules"].forEach(k=>{
|
|
|
|
|
+ $("view-"+k).classList.toggle("hidden",k!==v);
|
|
|
|
|
+ $("nav-"+k).classList.toggle("active",k===v);
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+const STEP_NAMES=["上传资料","AI解析","自动计价","报价单"];
|
|
|
|
|
+function qOpenEditorStep(n){
|
|
|
|
|
+ for(let i=1;i<=4;i++)$("step-"+i).classList.toggle("hidden",i!==n);
|
|
|
|
|
+ document.querySelectorAll("#steps-bar .step-dot").forEach(d=>{
|
|
|
|
|
+ const s=+d.dataset.step;
|
|
|
|
|
+ d.classList.toggle("active",s===n);
|
|
|
|
|
+ d.classList.toggle("done",s<n);
|
|
|
|
|
+ });
|
|
|
|
|
+ $("editor-step-breadcrumb").textContent=`步骤 ${n}/4 · ${STEP_NAMES[n-1]}`;
|
|
|
|
|
+ if(n===2)$("q-step2-fh-display").textContent=($("q-floor-height").value||"—")+" m";
|
|
|
|
|
+ if(n===3)recalc();
|
|
|
|
|
+ if(n===4){recalc();renderTiers();}
|
|
|
|
|
+ window.scrollTo({top:0,behavior:"smooth"});
|
|
|
|
|
+}
|
|
|
|
|
+function qNewQuote(){switchView("editor");qOpenEditorStep(1);}
|
|
|
|
|
+function backToList(){qSaveQuote(true);switchView("quotes");}
|
|
|
|
|
+function qToggleRecogConfig(){const p=$("q-recog-config-panel");p.style.display=p.style.display==="none"?"block":"none";}
|
|
|
|
|
+
|
|
|
|
|
+/* ========== 画布与叠加 ========== */
|
|
|
|
|
+let img=null,overlays=[];
|
|
|
|
|
+const cv=$("q-ai-canvas"),ctx=cv.getContext("2d");
|
|
|
|
|
+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);
|
|
|
|
|
+ overlays.forEach(o=>{ctx.lineWidth=3;
|
|
|
|
|
+ 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();}
|
|
|
|
|
+ else if(o.t==="red"){ctx.strokeStyle="#E53E3E";ctx.strokeRect(o.x*k,o.y*k,o.w*k,o.h*k);}
|
|
|
|
|
+ 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);
|
|
|
|
|
+ ctx.fillStyle=ctx.strokeStyle;ctx.font="bold 14px sans-serif";ctx.fillText(o.label||"",o.x*k+4,o.y*k+16);}});}
|
|
|
|
|
+function showImg(im){img=im;cv.width=1000;cv.height=Math.round(1000*im.naturalHeight/im.naturalWidth);draw();
|
|
|
|
|
+ $("q-btn-analyze").disabled=false;
|
|
|
|
|
+ $("q-status-drawing").textContent="✅ 图纸:已上传("+im.naturalWidth+"×"+im.naturalHeight+")";
|
|
|
|
|
+ $("q-status-drawing").style.background="#C6F6D5";$("q-status-drawing").style.color="#276749";}
|
|
|
|
|
+
|
|
|
|
|
+/* ========== 示例图纸生成 ========== */
|
|
|
|
|
+function genSample(u){
|
|
|
|
|
+ const c=document.createElement("canvas");c.width=1600;c.height=1100;const g=c.getContext("2d");
|
|
|
|
|
+ g.fillStyle="#fff";g.fillRect(0,0,1600,1100);
|
|
|
|
|
+ g.strokeStyle="#000";g.lineWidth=10;g.strokeRect(60,60,1480,980);
|
|
|
|
|
+ g.lineWidth=4;g.strokeStyle="#000";
|
|
|
|
|
+ g.beginPath();g.moveTo(800,60);g.lineTo(800,400);g.stroke(); // 内墙
|
|
|
|
|
+ g.fillStyle="#333"; // 厨房(深色填充带,AI可解算台面深度)
|
|
|
|
|
+ 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();}
|
|
|
|
|
+ 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();}
|
|
|
|
|
+ g.strokeStyle="#000";g.lineWidth=3; // 柜体(矩形+叉线)
|
|
|
|
|
+ [[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();});
|
|
|
|
|
+ g.lineWidth=2; // 护墙板斜线填充(AI 必须忽略,规则#14)
|
|
|
|
|
+ for(let i=0;i<12;i++){g.beginPath();g.moveTo(620+i*26,120);g.lineTo(640+i*26,160);g.stroke();}
|
|
|
|
|
+ g.fillStyle="#999";g.font="26px sans-serif";g.fillText("厨房",220,320);g.fillText("主卧",1250,450);g.fillText("客厅",600,600);
|
|
|
|
|
+ if(u){g.strokeStyle="#E53E3E";g.lineWidth=8;g.strokeRect(80,80,360,200);} // 红框
|
|
|
|
|
+ const im=new Image();im.src=c.toDataURL("image/png");return im;
|
|
|
|
|
+}
|
|
|
|
|
+function qUseMockDrawing(isU){const im=genSample(isU);im.onload=()=>{showImg(im);toast(isU?"已载入示例户型图(U型厨房+红框)":"已载入示例户型图(L型厨房)");qStartAnalysis();};}
|
|
|
|
|
+function qHandleUnified(inp){const f=inp.files[0];if(!f)return;
|
|
|
|
|
+ if(/\.(pdf|dwg|dxf)$/i.test(f.name)){alert("PDF/DWG 请先转 JPG/PNG(Demo 限制)");return;}
|
|
|
|
|
+ const rd=new FileReader();rd.onload=()=>{const im=new Image();im.onload=()=>{showImg(im);qStartAnalysis();};im.src=rd.result;};rd.readAsDataURL(f);}
|
|
|
|
|
+function qStartAnalysis(){if(!img){toast("请先上传图纸或选择示例","toast-error");return;}qOpenEditorStep(2);runAI();}
|
|
|
|
|
+/* ========== AI 识别引擎 v3(墙体分离 + 柜体特征分类) ========== */
|
|
|
|
|
+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;}
|
|
|
|
|
+function setProg(i,st){$("q-ai-prog-"+i).className="ai-progress-step "+(st||"");}
|
|
|
|
|
+function erodeMask(m,w,h,it){let cur=m;for(let t=0;t<it;t++){const o=new Uint8Array(w*h);
|
|
|
|
|
+ for(let y=1;y<h-1;y++)for(let x=1;x<w-1;x++){const i=y*w+x;
|
|
|
|
|
+ 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;}
|
|
|
|
|
+ cur=o;}return cur;}
|
|
|
|
|
+function dilateMask(m,w,h,it){let cur=m;for(let t=0;t<it;t++){const o=new Uint8Array(cur);
|
|
|
|
|
+ for(let y=1;y<h-1;y++)for(let x=1;x<w-1;x++){const i=y*w+x;
|
|
|
|
|
+ if(cur[i]){o[i-1]=1;o[i+1]=1;o[i-w]=1;o[i+w]=1;}}
|
|
|
|
|
+ cur=o;}return cur;}
|
|
|
|
|
+function wallThickness(wm,w,h){const runs=[];
|
|
|
|
|
+ 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;}}}
|
|
|
|
|
+ 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;}}}
|
|
|
|
|
+ if(!runs.length)return 6;runs.sort((a,b)=>a-b);return runs[runs.length>>1];}
|
|
|
|
|
+async function runAI(){
|
|
|
|
|
+ $("q-ai-log").innerHTML="";overlays=[];S.redbox=false;S.scale=null;S.scaleSrc="";S.objs=[];S.ft="plan";
|
|
|
|
|
+ $("q-step2-next-btn").disabled=true;
|
|
|
|
|
+ setProg(0,"running");setProg(1,"");setProg(2,"");
|
|
|
|
|
+ logAI("▶ AI 全图识别启动(优先级:无红框全图 > 红框校准)","b");await sleep(250);
|
|
|
|
|
+ const AW=500,k=AW/img.naturalWidth,AH=Math.round(img.naturalHeight*k);
|
|
|
|
|
+ const oc=document.createElement("canvas");oc.width=AW;oc.height=AH;const og=oc.getContext("2d");
|
|
|
|
|
+ og.drawImage(img,0,0,AW,AH);const d=og.getImageData(0,0,AW,AH).data;
|
|
|
|
|
+ const ink=new Uint8Array(AW*AH);
|
|
|
|
|
+ let rx0=1e9,ry0=1e9,rx1=-1,ry1=-1,rc=0;
|
|
|
|
|
+ for(let i=0;i<AW*AH;i++){const r=d[i*4],g=d[i*4+1],b=d[i*4+2];
|
|
|
|
|
+ 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;}
|
|
|
|
|
+ else if(.299*r+.587*g+.114*b<140)ink[i]=1;}
|
|
|
|
|
+ logAI("· 步骤1 结构扫描:"+AW+"×"+AH+" 栅格化,墨线像素 "+ink.reduce((a,v)=>a+v,0));await sleep(200);
|
|
|
|
|
+ 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");}
|
|
|
|
|
+ else logAI("· 未检测到红框 → 场景A(无红框,AI全自动)");
|
|
|
|
|
+ $("q-ai-scene-chip").textContent=S.redbox?"场景B · 红框+标注":"场景A · 无红框全自动";
|
|
|
|
|
+ await sleep(200);
|
|
|
|
|
+ /* 连通域(全墨线) */
|
|
|
|
|
+ const cc=mask=>{const vis=new Uint8Array(mask.length),out=[];
|
|
|
|
|
+ for(let s=0;s<mask.length;s++){if(!mask[s]||vis[s])continue;
|
|
|
|
|
+ const q=[s];vis[s]=1;let x0=1e9,y0=1e9,x1=-1,y1=-1,area=0,hh=0;
|
|
|
|
|
+ while(hh<q.length){const c=q[hh++];area++;const cx=c%AW,cy=(c/AW)|0;
|
|
|
|
|
+ if(cx<x0)x0=cx;if(cx>x1)x1=cx;if(cy<y0)y0=cy;if(cy>y1)y1=cy;
|
|
|
|
|
+ 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);}}}
|
|
|
|
|
+ out.push({x:x0,y:y0,w:x1-x0,h:y1-y0,area});}
|
|
|
|
|
+ return out;};
|
|
|
|
|
+ const compsAll=cc(ink).sort((a,b)=>b.w*b.h-a.w*a.h);
|
|
|
|
|
+ logAI("· 连通域分析:"+compsAll.length+" 个结构体");await sleep(200);
|
|
|
|
|
+ setProg(0,"done");setProg(1,"running");
|
|
|
|
|
+ /* 模式判定:示意图(实心厨房色块) vs 彩色户型图(墙体分离) */
|
|
|
|
|
+ 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;});
|
|
|
|
|
+ let kitchen=null;const wards=[];let excluded=0;
|
|
|
|
|
+ if(solid){
|
|
|
|
|
+ /* —— 示意图路径:探针判定 L/U + 二次方程解台面深度 —— */
|
|
|
|
|
+ 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]]
|
|
|
|
|
+ .map(p=>ink[((p[1]|0)*AW+(p[0]|0))]?1:0).reduce((a,v)=>a+v,0);
|
|
|
|
|
+ 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;
|
|
|
|
|
+ let dd=0;
|
|
|
|
|
+ if(probes===2&&discL>0)dd=(sL-Math.sqrt(discL))/2;
|
|
|
|
|
+ else if(probes===3&&discU>0)dd=(sU-Math.sqrt(discU))/4;
|
|
|
|
|
+ else if(probes===3&&discU2>0)dd=(sU2-Math.sqrt(discU2))/4;
|
|
|
|
|
+ if(dd>.02*AW&&dd<.12*AW)kitchen={...c,dd};
|
|
|
|
|
+ if(kitchen){
|
|
|
|
|
+ const sc=0.6/(kitchen.dd/k);S.scale=sc;S.scaleSrc="AI定位厨房台面深度=600mm锚点(全图通用)";
|
|
|
|
|
+ overlays.push({t:"kitchen",x:kitchen.x/k,y:kitchen.y/k,w:kitchen.w/k,h:kitchen.h/k,label:"厨房"});
|
|
|
|
|
+ overlays.push({t:"anchor",x:kitchen.x/k,y:kitchen.y/k,w:kitchen.dd/k,h:0});
|
|
|
|
|
+ const kt=kitchen.w>=kitchen.h?"L":"U";
|
|
|
|
|
+ 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="";}
|
|
|
|
|
+ else{$("kA").value=(kitchen.h*sc).toFixed(2);$("kB").value=(kitchen.h*sc).toFixed(2);$("kC").value=(kitchen.w*sc).toFixed(2);}
|
|
|
|
|
+ logAI("· 定位厨房(示意图模式):台面深度 "+Math.round(kitchen.dd)+"px = 600mm → 1px="+(sc*1000).toFixed(1)+"mm","b");
|
|
|
|
|
+ }
|
|
|
|
|
+ let cn=0;
|
|
|
|
|
+ for(const c2 of compsAll){
|
|
|
|
|
+ if(cn>=8)break;
|
|
|
|
|
+ if(c2.w>.6*AW&&c2.h>.6*AH)continue;
|
|
|
|
|
+ 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;
|
|
|
|
|
+ const f=c2.area/(c2.w*c2.h);
|
|
|
|
|
+ if(c2.w>.03*AW&&c2.h>.03*AW&&Math.min(c2.w,c2.h)>=.018*AW&&f>=.10&&f<.7){
|
|
|
|
|
+ cn++;wards.push(c2);}}
|
|
|
|
|
+ }else{
|
|
|
|
|
+ /* —— 彩色户型图路径:墙体分离 + 柜体特征分类 —— */
|
|
|
|
|
+ logAI("· 彩色户型图模式:腐蚀法分离墙体(粗线)与家具(细线)","b");await sleep(200);
|
|
|
|
|
+ let wm=erodeMask(ink,AW,AH,2),eit=2;
|
|
|
|
|
+ if(wm.reduce((a,v)=>a+v,0)<0.005*AW*AH){wm=erodeMask(ink,AW,AH,1);eit=1;}
|
|
|
|
|
+ let T=wallThickness(wm,AW,AH)+2*eit;T=Math.max(4,Math.min(24,T));
|
|
|
|
|
+ logAI("· 墙厚先验 T≈"+T+"px(仅用于形状判定,不作尺寸参照,规则#11)");
|
|
|
|
|
+ const wcore=dilateMask(wm,AW,AH,1);
|
|
|
|
|
+ const wz=dilateMask(wm,AW,AH,Math.max(3,Math.round(T*0.7)));
|
|
|
|
|
+ const thin=new Uint8Array(AW*AH);for(let i=0;i<AW*AH;i++)thin[i]=ink[i]&&!wcore[i];
|
|
|
|
|
+ const comps=cc(thin).sort((a,b)=>b.w*b.h-a.w*a.h);
|
|
|
|
|
+ const att=c=>{let hit=0,tot=0;
|
|
|
|
|
+ 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++;}}
|
|
|
|
|
+ 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++;}}
|
|
|
|
|
+ return hit/Math.max(1,tot);};
|
|
|
|
|
+ const hatch=c=>{let rt=0,ct=0,nr=0,nc=0;
|
|
|
|
|
+ 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;}
|
|
|
|
|
+ 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;}
|
|
|
|
|
+ return Math.max(rt/Math.max(1,nr),ct/Math.max(1,nc));};
|
|
|
|
|
+ const xpat=c=>{let h1=0,h2=0;const N=16;
|
|
|
|
|
+ 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++;
|
|
|
|
|
+ if(thin[((c.y+c.h-c.h*i/N)|0)*AW+((c.x+c.w*i/N)|0)])h2++;}
|
|
|
|
|
+ return Math.min(h1,h2)/N;};
|
|
|
|
|
+ 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);
|
|
|
|
|
+ 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;
|
|
|
|
|
+ const bands=[];
|
|
|
|
|
+ for(const c of comps){
|
|
|
|
|
+ if(c.w>0.55*AW||c.h>0.55*AH)continue;
|
|
|
|
|
+ const short=Math.min(c.w,c.h),long=Math.max(c.w,c.h);
|
|
|
|
|
+ if(short<1.2*T)continue; // 噪点/小符号
|
|
|
|
|
+ const a=att(c),f=c.area/(c.w*c.h);
|
|
|
|
|
+ /* 橱柜台面带:贴墙 + 长条 + 深度≈3T(600mm) + 内含水槽/灶具符号 */
|
|
|
|
|
+ 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;}
|
|
|
|
|
+ /* 衣柜:贴墙 + 斜线填充 或 X交叉 */
|
|
|
|
|
+ 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;}
|
|
|
|
|
+ /* 近方形 X交叉柜(玄关柜等) */
|
|
|
|
|
+ if(a>=0.3&&xpat(c)>=0.55&&short>=1.5*T&&short<=6*T&&long<2.2*short){wards.push(c);continue;}
|
|
|
|
|
+ excluded++;}
|
|
|
|
|
+ logAI("· 柜体判定标准:贴墙率≥30% 且(斜线填充 / X交叉 / 600mm台面带+水槽灶具)","b");
|
|
|
|
|
+ logAI("· 已排除床/沙发/桌椅/洁具等独立家具 "+excluded+" 个(不贴墙或特征不符)");await sleep(200);
|
|
|
|
|
+ /* 厨房:台面带聚类 → 600mm 锚点 */
|
|
|
|
|
+ if(bands.length){
|
|
|
|
|
+ bands.sort((a,b)=>b.w*b.h-a.w*a.h);
|
|
|
|
|
+ const cluster=[bands[0]];
|
|
|
|
|
+ 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||
|
|
|
|
|
+ (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);}
|
|
|
|
|
+ const b0=cluster[0],dNat=Math.min(b0.w,b0.h)/k;
|
|
|
|
|
+ S.scale=0.6/dNat;S.scaleSrc="AI定位橱柜台面深度=600mm锚点(全图通用)";
|
|
|
|
|
+ let ux0=1e9,uy0=1e9,ux1=-1,uy1=-1;
|
|
|
|
|
+ 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);});
|
|
|
|
|
+ kitchen={x:ux0,y:uy0,w:ux1-ux0,h:uy1-uy0};
|
|
|
|
|
+ overlays.push({t:"kitchen",x:ux0/k,y:uy0/k,w:(ux1-ux0)/k,h:(uy1-uy0)/k,label:"厨房"});
|
|
|
|
|
+ overlays.push({t:"anchor",x:b0.x/k,y:b0.y/k,w:Math.min(b0.w,b0.h)/k,h:0});
|
|
|
|
|
+ const lens=cluster.map(c=>Math.max(c.w,c.h)*S.scale).sort((a,b)=>b-a);
|
|
|
|
|
+ if(lens.length===1){$("kA").value=lens[0].toFixed(2);$("kB").value="";$("kC").value="";logAI("· 厨房:检测到1条台面带 "+lens[0].toFixed(2)+"m,请在下一步补充另一侧边长","w");}
|
|
|
|
|
+ else if(lens.length===2){$("kA").value=lens[0].toFixed(2);$("kC").value=lens[1].toFixed(2);$("kB").value="";}
|
|
|
|
|
+ else{$("kA").value=lens[0].toFixed(2);$("kB").value=lens[1].toFixed(2);$("kC").value=lens[2].toFixed(2);}
|
|
|
|
|
+ logAI("· 定位厨房:"+cluster.length+" 条台面带,深度 "+Math.round(dNat)+"px = 600mm → 1px="+(S.scale*1000).toFixed(1)+"mm","b");
|
|
|
|
|
+ }else logAI("· 未能自动定位厨房台面带 → 请在下一步补充一个已知尺寸","e");
|
|
|
|
|
+ }
|
|
|
|
|
+ draw();await sleep(200);
|
|
|
|
|
+ setProg(1,"done");setProg(2,"running");
|
|
|
|
|
+ /* 柜体输出 */
|
|
|
|
|
+ let cn=0;
|
|
|
|
|
+ for(const c of wards){
|
|
|
|
|
+ if(cn>=8)break;
|
|
|
|
|
+ cn++;S.objs.push({name:"柜体"+cn,kind:"cab",src:"AI",w:Math.round(c.w/k),h:Math.round(c.h/k)});
|
|
|
|
|
+ overlays.push({t:"cab",x:c.x/k,y:c.y/k,w:c.w/k,h:c.h/k,label:"柜体"+cn});}
|
|
|
|
|
+ logAI("· 柜体识别:"+cn+" 个(max(W,H)×比例×2.5=投影面积)","b");await sleep(200);
|
|
|
|
|
+ logAI("· 护墙板:平面图斜线填充不可靠 → <b>不自动识别</b>(规则#14),如需请在下一步手动添加","w");await sleep(150);
|
|
|
|
|
+ logAI("· 尺寸计算完成。精度等级:"+precision().txt,"b");
|
|
|
|
|
+ setProg(2,"done");draw();rebuildObjs();recalc();
|
|
|
|
|
+ $("q-step2-next-btn").disabled=false;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ========== 对象表(步骤3) ========== */
|
|
|
|
|
+function rebuildObjs(){
|
|
|
|
|
+ const tb=$("objBody");tb.innerHTML="";
|
|
|
|
|
+ S.objs.forEach(p=>{const tr=document.createElement("tr");
|
|
|
|
|
+ tr.innerHTML=`<td><input type="text" value="${p.name}" style="width:90px" data-f="name"></td><td>${p.src}</td>
|
|
|
|
|
+ <td><input type="number" value="${p.w}" data-f="w"></td><td><input type="number" value="${p.h}" data-f="h"></td>
|
|
|
|
|
+ <td class="text-right" data-o="len">-</td><td class="text-right" data-o="area">-</td><td class="text-right" data-o="amt">-</td>
|
|
|
|
|
+ <td><button class="btn btn-danger btn-sm">删</button></td>`;
|
|
|
|
|
+ tr.querySelectorAll("input").forEach(inp=>inp.oninput=()=>{p[inp.dataset.f]=inp.dataset.f==="name"?inp.value:(+inp.value||0);recalc();});
|
|
|
|
|
+ tr.querySelector("button").onclick=()=>{S.objs.splice(S.objs.indexOf(p),1);rebuildObjs();recalc();};
|
|
|
|
|
+ p._tr=tr;tb.appendChild(tr);});
|
|
|
|
|
+}
|
|
|
|
|
+$("addCab").onclick=()=>{S.objs.push({name:"柜体"+(S.objs.length+1),kind:"cab",src:"手动",w:0,h:0});rebuildObjs();recalc();};
|
|
|
|
|
+$("addPanel").onclick=()=>{S.objs.push({name:"护墙板"+(S.objs.filter(o=>o.kind==="panel").length+1),kind:"panel",src:"手动",w:0,h:0});rebuildObjs();recalc();};
|
|
|
|
|
+
|
|
|
|
|
+/* ========== 增值项 / 厨房分项(步骤3表格) ========== */
|
|
|
|
|
+function buildWard(){$("wardBody").innerHTML="";WARD.forEach((w,i)=>{const tr=document.createElement("tr");
|
|
|
|
|
+ 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>`;
|
|
|
|
|
+ const cb=tr.children[0].firstChild,inp=tr.children[3].firstChild;
|
|
|
|
|
+ cb.checked=S.ward[i].on;inp.value=S.ward[i].v||"";inp.disabled=!S.ward[i].on;
|
|
|
|
|
+ cb.onchange=()=>{S.ward[i].on=cb.checked;inp.disabled=!cb.checked;recalc();};
|
|
|
|
|
+ inp.oninput=()=>{S.ward[i].v=+inp.value||0;recalc();};
|
|
|
|
|
+ w._tr=tr;$("wardBody").appendChild(tr);});}
|
|
|
|
|
+function buildKit(){const tb=$("kitBody");tb.innerHTML="";
|
|
|
|
|
+ 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("");
|
|
|
|
|
+ const mk=h=>{const tr=document.createElement("tr");tr.innerHTML=h;tb.appendChild(tr);return tr;};
|
|
|
|
|
+ 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>`);
|
|
|
|
|
+ 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>`);
|
|
|
|
|
+ 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>`));
|
|
|
|
|
+ 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>`));
|
|
|
|
|
+ $("selBase").value=S.cfgBase;$("selHang").value=S.cfgHang;
|
|
|
|
|
+ $("selBase").onchange=e=>{S.cfgBase=+e.target.value;recalc();};
|
|
|
|
|
+ $("selHang").onchange=e=>{S.cfgHang=+e.target.value;recalc();};
|
|
|
|
|
+ $("lenBase").oninput=e=>{S.len.base=+e.target.value||0;recalc();};
|
|
|
|
|
+ $("lenHang").oninput=e=>{S.len.hang=+e.target.value||0;recalc();};
|
|
|
|
|
+ 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();};});
|
|
|
|
|
+ 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();};});}
|
|
|
|
|
+
|
|
|
|
|
+/* ========== 计算引擎 ========== */
|
|
|
|
|
+let LASTQ={baseSum:0,kSum:0,wSum:0,varArea:0,baseRows:[],valueRows:[],kitLines:[],cabLines:[],panelLines:[],wardLines:[]};
|
|
|
|
|
+function recalc(){
|
|
|
|
|
+ const pr=precision(),sc=dimScale();
|
|
|
|
|
+ $("precBadge").className="badge "+pr.cls;$("precBadge").textContent=pr.txt;
|
|
|
|
|
+ $("q-quote-prec").className="badge "+pr.cls;$("q-quote-prec").textContent=pr.txt;
|
|
|
|
|
+ $("sceneChip").textContent="场景:"+(S.ft==="cad"?"CAD矢量":S.redbox?"B 有红框+标注":"A 无红框");
|
|
|
|
|
+ $("scaleInfo").textContent=S.ft==="cad"?"CAD矢量直读(mm)":(S.scale?`1px=${(sc*1000).toFixed(2)}mm(${S.scaleSrc})`:"比例未建立");
|
|
|
|
|
+ /* 基础项 */
|
|
|
|
|
+ let baseSum=0,varArea=0;const baseRows=[],cabLines=[],panelLines=[];
|
|
|
|
|
+ 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;
|
|
|
|
|
+ baseRows.push({name:p.name,kind:p.kind,src:p.src,len,area,amt});
|
|
|
|
|
+ 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>`;
|
|
|
|
|
+ (p.kind==="panel"?panelLines:cabLines).push(line);
|
|
|
|
|
+ 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);}});
|
|
|
|
|
+ $("baseTotal").textContent="¥"+fmt(baseSum);
|
|
|
|
|
+ /* 厨房判定 */
|
|
|
|
|
+ const sides=[$("kA").value,$("kB").value,$("kC").value].map(v=>+v||0).filter(v=>v>0);
|
|
|
|
|
+ let kT=0,kt="未判定",kF="";
|
|
|
|
|
+ if(sides.length>=2){const mn=Math.min(...sides),mx=[...sides].sort((a,b)=>b-a);
|
|
|
|
|
+ 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`;}
|
|
|
|
|
+ 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`;}}
|
|
|
|
|
+ $("kType").textContent=kt;$("kType").className="chip "+(kt==="U型"?"u":kt==="L型"?"l":"n");
|
|
|
|
|
+ $("kFormula").textContent=kT>0?`${kF} = ${fmt2(kT)}m 可计费总长`:kF;
|
|
|
|
|
+ if(document.activeElement!==$("lenBase")){$("lenBase").value=kT?kT.toFixed(2):"";S.len.base=kT;}
|
|
|
|
|
+ if(document.activeElement!==$("len_counter")){$("len_counter").value=kT?kT.toFixed(2):"";S.len.counter=kT;}
|
|
|
|
|
+ $("fbRow").style.display=S.scale||S.ft==="cad"?"none":"flex";
|
|
|
|
|
+ /* 增值项:衣柜 */
|
|
|
|
|
+ let wSum=0;const wardLines=[],valueRows=[];
|
|
|
|
|
+ WARD.forEach((w,i)=>{const v=S.ward[i].on?S.ward[i].v:0,a=v*w[1];wSum+=a;
|
|
|
|
|
+ if(w._tr)w._tr.children[4].textContent="¥"+fmt(a);
|
|
|
|
|
+ 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>`);
|
|
|
|
|
+ valueRows.push({item:"衣柜增值·"+w[0],spec:w[2],qty:fmt2(v),price:fmt(w[1])+" "+w[2],amt:a});}});
|
|
|
|
|
+ /* 增值项:厨房 */
|
|
|
|
|
+ let kSum=0;const kitLines=[];const db=DOORS[S.cfgBase],dh=DOORS[S.cfgHang];
|
|
|
|
|
+ $("pBase").textContent=fmt(db[4]||0)+" 元/m";$("pHang").textContent=fmt(dh[3])+" 元/m";
|
|
|
|
|
+ const aB=S.len.base*(db[4]||0);kSum+=aB;$("aBase").textContent="¥"+fmt(aB);
|
|
|
|
|
+ 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>`);
|
|
|
|
|
+ valueRows.push({item:"橱柜·地柜",spec:`${db[0]}·${db[1]}·${db[2]}`,qty:fmt2(S.len.base)+" m",price:fmt(db[4]||0)+" 元/m",amt:aB});}
|
|
|
|
|
+ const aH=S.len.hang*dh[3];kSum+=aH;$("aHang").textContent="¥"+fmt(aH);
|
|
|
|
|
+ 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>`);
|
|
|
|
|
+ valueRows.push({item:"橱柜·吊柜",spec:`${dh[0]}·${dh[1]}·${dh[2]}`,qty:fmt2(S.len.hang)+" m",price:fmt(dh[3])+" 元/m",amt:aH});}
|
|
|
|
|
+ KIT_FIX.forEach(f=>{const a=S.len[f[0]]*f[2];kSum+=a;$("a_"+f[0]).textContent="¥"+fmt(a);
|
|
|
|
|
+ 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>`);
|
|
|
|
|
+ valueRows.push({item:"橱柜·"+f[1],spec:"—",qty:fmt2(S.len[f[0]])+(f[3]==="元/㎡"?" ㎡":" m"),price:fmt(f[2])+" "+f[3],amt:a});}});
|
|
|
|
|
+ KIT_ACC.forEach((ac,i)=>{const a=S.acc[i]*ac[1];kSum+=a;$("aa_"+i).textContent="¥"+fmt(a);
|
|
|
|
|
+ if(a){kitLines.push(`<div class="total-line"><span>${ac[0]} ×${S.acc[i]}</span><span class="num">¥${fmt(a)}</span></div>`);
|
|
|
|
|
+ valueRows.push({item:"橱柜·"+ac[0],spec:"配件",qty:S.acc[i]+" 个",price:fmt(ac[1])+" 元/个",amt:a});}});
|
|
|
|
|
+ $("valueTotal").textContent="¥"+fmt(wSum+kSum);
|
|
|
|
|
+ $("grandTotal").textContent="¥"+fmt(baseSum+wSum+kSum);
|
|
|
|
|
+ LASTQ={baseSum,kSum,wSum,varArea,baseRows,valueRows,kitLines,cabLines,panelLines,wardLines};
|
|
|
|
|
+ renderStep4();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ========== 步骤4:报价单渲染 ========== */
|
|
|
|
|
+function renderTiers(){
|
|
|
|
|
+ const box=$("q-ai-tier-selector");box.innerHTML="";
|
|
|
|
|
+ MATS.forEach((m,i)=>{const d=document.createElement("div");
|
|
|
|
|
+ d.className="tier-card"+(i===S.mat?" selected":"");
|
|
|
|
|
+ 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>`;
|
|
|
|
|
+ d.onclick=()=>{S.mat=i;renderTiers();recalc();};
|
|
|
|
|
+ box.appendChild(d);});
|
|
|
|
|
+}
|
|
|
|
|
+function qSetMat(i){S.mat=i;renderTiers();recalc();}
|
|
|
|
|
+function renderStep4(){
|
|
|
|
|
+ const m=MATS[S.mat],Q=LASTQ;
|
|
|
|
|
+ const fixed=Q.kSum+Q.wSum;
|
|
|
|
|
+ const lo=Q.varArea*m.min+fixed,hi=Q.varArea*m.max+fixed,cur=Q.varArea*unit()+fixed;
|
|
|
|
|
+ $("q-quote-meta").textContent=`生成日期:${new Date().toLocaleDateString("zh-CN")} | 材质档:${m.n} | 浮动率:${S.rate}%`;
|
|
|
|
|
+ $("q-step5-range").textContent=`¥${fmt(lo)} — ¥${fmt(hi)}`;
|
|
|
|
|
+ $("q-step5-rate-text").textContent=`预估总价区间(${m.n}档单价 ¥${fmt(m.min)}~¥${fmt(m.max)}/㎡) | ${precision().txt}`;
|
|
|
|
|
+ $("q-ai-range-total").textContent="¥"+fmt(cur);
|
|
|
|
|
+ $("q-ai-range-note").textContent=`当前参考总价(${m.n} ¥${fmt(unit())}/㎡ × ${fmt2(Q.varArea)}㎡ + 增值项 ¥${fmt(fixed)})`;
|
|
|
|
|
+ $("q-ai-float-badge").textContent=S.rate+"%";
|
|
|
|
|
+ $("q-ai-float-slider").value=S.rate;
|
|
|
|
|
+ $("global-float-slider").value=S.rate;$("global-float-badge").textContent=S.rate+"%";
|
|
|
|
|
+ document.querySelectorAll(".float-preset button[data-aifp]").forEach(b=>b.classList.toggle("active",+b.dataset.aifp===Math.round(S.rate)));
|
|
|
|
|
+ $("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>`;
|
|
|
|
|
+ /* 空间卡片 */
|
|
|
|
|
+ const cards=[];
|
|
|
|
|
+ if(Q.kitLines.length)cards.push(["🍳 厨房","¥"+fmt(Q.kSum),Q.kitLines]);
|
|
|
|
|
+ if(Q.cabLines.length)cards.push(["🗄️ 柜体(投影面积)","¥"+fmt(LASTQ.baseRows.filter(r=>r.kind==="cab").reduce((a,r)=>a+r.amt,0)),Q.cabLines]);
|
|
|
|
|
+ if(Q.panelLines.length)cards.push(["🧱 护墙板(手动)","¥"+fmt(LASTQ.baseRows.filter(r=>r.kind==="panel").reduce((a,r)=>a+r.amt,0)),Q.panelLines]);
|
|
|
|
|
+ if(Q.wardLines.length)cards.push(["👔 衣柜增值项","¥"+fmt(Q.wSum),Q.wardLines]);
|
|
|
|
|
+ $("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>';
|
|
|
|
|
+ /* 明细表 */
|
|
|
|
|
+ $("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>';
|
|
|
|
|
+ $("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>';
|
|
|
|
|
+ $("q-grand-total-value").textContent="¥"+fmt(cur);
|
|
|
|
|
+ $("q-grand-total-area").textContent=`投影面积 ${fmt2(Q.varArea)}㎡ · ${m.n} ¥${fmt2(unit())}/㎡`;
|
|
|
|
|
+ $("q-grand-total-note").textContent=cur>0?`基础项 ¥${fmt(Q.baseSum)} + 增值项 ¥${fmt(fixed)}(${precision().txt})`:"上传图纸并完成 AI 识别后自动计算";
|
|
|
|
|
+ $("q-step5-disclaimer").textContent=`⚠️ 声明:本报价为 AI 辅助生成的预估价格(${precision().txt}),最终成交价以实际量尺和确认为准。护墙板平面图不自动识别;仅「厨房台面深度600mm」为隐式参照物。`;
|
|
|
|
|
+ $("global-est-range").textContent=`¥${fmt(lo)} ~ ¥${fmt(hi)}`;
|
|
|
|
|
+ $("global-est-mid").textContent="参考总价: ¥"+fmt(cur);
|
|
|
|
|
+}
|
|
|
|
|
+function qOnAiFloatChange(v){S.rate=Math.min(15,Math.max(10,+v||10));renderTiers();recalc();}
|
|
|
|
|
+function qSetAiFloat(v){S.rate=Math.min(15,Math.max(10,+v||10));renderTiers();recalc();}
|
|
|
|
|
+
|
|
|
|
|
+/* ========== 报价单管理 ========== */
|
|
|
|
|
+let QUOTES=[];
|
|
|
|
|
+try{QUOTES=JSON.parse(localStorage.getItem("aiq_new_quotes")||"[]");}catch(e){QUOTES=[];}
|
|
|
|
|
+function saveQuotes(){try{localStorage.setItem("aiq_new_quotes",JSON.stringify(QUOTES));}catch(e){}}
|
|
|
|
|
+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}))});}
|
|
|
|
|
+function qSaveQuote(silent){
|
|
|
|
|
+ if(!S.objs.length&&!LASTQ.baseSum&&!LASTQ.kSum&&!LASTQ.wSum){if(!silent)toast("暂无计价内容,请先上传图纸完成AI识别","toast-error");return;}
|
|
|
|
|
+ const m=MATS[S.mat],fixed=LASTQ.kSum+LASTQ.wSum;
|
|
|
|
|
+ const q={id:Date.now(),name:("报价单 "+(QUOTES.length+1)),date:new Date().toLocaleString("zh-CN"),mat:m.n,rate:S.rate,
|
|
|
|
|
+ lo:LASTQ.varArea*m.min+fixed,hi:LASTQ.varArea*m.max+fixed,cur:LASTQ.varArea*unit()+fixed,prec:precision().txt,snap:snapshot()};
|
|
|
|
|
+ QUOTES.unshift(q);saveQuotes();renderQuoteList();
|
|
|
|
|
+ if(!silent)toast("已存入报价单列表 ✓","toast-success");
|
|
|
|
|
+}
|
|
|
|
|
+function renderQuoteList(){
|
|
|
|
|
+ const tb=$("quote-table-body");tb.innerHTML="";
|
|
|
|
|
+ $("quote-empty").style.display=QUOTES.length?"none":"block";
|
|
|
|
|
+ QUOTES.forEach((q,i)=>{const tr=document.createElement("tr");
|
|
|
|
|
+ tr.innerHTML=`<td><b>${q.name}</b></td><td>${q.date}</td><td>${q.mat}(${q.rate}%)</td>
|
|
|
|
|
+ <td class="text-right">¥${fmt(q.lo)} — ¥${fmt(q.hi)}</td><td><span class="badge est" style="font-size:10px;">${q.prec}</span></td>
|
|
|
|
|
+ <td></td>`;
|
|
|
|
|
+ const td=tr.children[5];
|
|
|
|
|
+ const b1=document.createElement("button");b1.className="btn btn-outline btn-sm";b1.textContent="打开";
|
|
|
|
|
+ b1.onclick=e=>{e.stopPropagation();qOpenQuote(q);};
|
|
|
|
|
+ const b2=document.createElement("button");b2.className="btn btn-danger btn-sm";b2.textContent="删除";b2.style.marginLeft="6px";
|
|
|
|
|
+ b2.onclick=e=>{e.stopPropagation();QUOTES.splice(i,1);saveQuotes();renderQuoteList();toast("已删除");};
|
|
|
|
|
+ td.appendChild(b1);td.appendChild(b2);
|
|
|
|
|
+ tr.style.cursor="pointer";tr.onclick=()=>qOpenQuote(q);
|
|
|
|
|
+ tb.appendChild(tr);});
|
|
|
|
|
+}
|
|
|
|
|
+function qOpenQuote(q){
|
|
|
|
|
+ try{const snap=JSON.parse(q.snap);
|
|
|
|
|
+ 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});
|
|
|
|
|
+ $("kA").value="";$("kB").value="";$("kC").value="";
|
|
|
|
|
+ /* 重新从对象推导厨房边(若曾识别)——此处保持手动值,直接重算 */
|
|
|
|
|
+ rebuildObjs();buildWard();buildKit();recalc();
|
|
|
|
|
+ $("editor-title").textContent=q.name;
|
|
|
|
|
+ switchView("editor");qOpenEditorStep(4);
|
|
|
|
|
+ toast("已打开:"+q.name);
|
|
|
|
|
+ }catch(e){toast("打开失败","toast-error");}
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ========== 规则配置表 ========== */
|
|
|
|
|
+function switchRuleTab(t){
|
|
|
|
|
+ document.querySelectorAll("#view-rules .tab").forEach(el=>el.classList.remove("active"));
|
|
|
|
|
+ event.target.classList.add("active");
|
|
|
|
|
+ document.querySelectorAll(".rule-tab-content").forEach(c=>c.classList.add("hidden"));
|
|
|
|
|
+ $("rule-tab-"+t).classList.remove("hidden");
|
|
|
|
|
+}
|
|
|
|
|
+function renderRules(){
|
|
|
|
|
+ $("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("");
|
|
|
|
|
+ $("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("");
|
|
|
|
|
+ $("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("");
|
|
|
|
|
+ $("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("");
|
|
|
|
|
+ $("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("");
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ========== 事件与初始化 ========== */
|
|
|
|
|
+$("kA").oninput=$("kB").oninput=$("kC").oninput=()=>recalc();
|
|
|
|
|
+$("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");};
|
|
|
|
|
+
|
|
|
|
|
+buildWard();buildKit();renderTiers();renderRules();renderQuoteList();recalc();
|