"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{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=3&&r<=40)runs.push(r);r=0;}}} for(let x=0;x=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;i140&&r-g>50&&r-b>50){rc++;if(i%AWrx1)rx1=i%AW;const y=(i/AW)|0;if(yry1)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;sx1)x1=cx;if(cyy1)y1=cy; const nb=[c-1,c+1,c-AW,c+AW];for(const n of nb){if(n>=0&&nb.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;ib.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{let h1=0,h2=0;const N=16; for(let i=1;is.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.w0.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{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("· 护墙板:平面图斜线填充不可靠 → 不自动识别(规则#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=`${p.src} --- `; 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=`${w[0]}${fmt(w[1])} ${w[2]}¥0`; 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:``).filter(Boolean).join(""); const mk=h=>{const tr=document.createElement("tr");tr.innerHTML=h;tb.appendChild(tr);return tr;}; mk(`地柜(长=厨房可计费总长) m-¥0`); mk(`吊柜 m-¥0`); KIT_FIX.forEach(f=>mk(`${f[1]} ${f[3]==="元/㎡"?"㎡":"m"}${fmt(f[2])} ${f[3]}¥0`)); KIT_ACC.forEach((a,i)=>mk(`${a[0]} 个${fmt(a[1])} 元/个¥0`)); $("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=`
${p.name}[${p.src}]:max边 ${fmt2(len)}m ×2.5 = ${fmt2(area)}㎡ × ¥${fmt(unit())}¥${fmt(amt)}
`; (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(`
${w[0]} ×${fmt2(v)}¥${fmt(a)}
`); 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(`
地柜[${db[1]} ${db[2]}] ${fmt2(S.len.base)}m × ¥${fmt(db[4]||0)}¥${fmt(aB)}
`); 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(`
吊柜[${dh[1]} ${dh[2]}] ${fmt2(S.len.hang)}m × ¥${fmt(dh[3])}¥${fmt(aH)}
`); 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(`
${f[1]} ${fmt2(S.len[f[0]])}${f[3]==="元/㎡"?"㎡":"m"} × ¥${fmt(f[2])}¥${fmt(a)}
`); 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(`
${ac[0]} ×${S.acc[i]}¥${fmt(a)}
`); 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=`
${m.n}
¥${fmt(m.min)} — ¥${fmt(m.max)}/㎡
${i===S.mat?"✓ 已选择 · 当前单价 ¥"+fmt(unit()):"点击选择"}
`; 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=`当前单价:¥${fmt2(unit())}/㎡(${m.n} ${fmt(m.min)}~${fmt(m.max)},浮动率 ${S.rate}%)· 投影面积 ${fmt2(Q.varArea)}㎡ · 基础项 ¥${fmt(Q.baseSum)} + 增值项 ¥${fmt(fixed)} = 总计 ¥${fmt(cur)}`; /* 空间卡片 */ 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=>`
${c[0]}${c[1]}
${c[2].join("")}
`).join(""):'
暂无识别结果,请先完成步骤 1-3
'; /* 明细表 */ $("q-quote-base-body").innerHTML=Q.baseRows.length?Q.baseRows.map(r=>`${r.kind==="panel"?"护墙板·":"柜体·"}${r.name}${r.src}${fmt2(r.len)}${fmt2(r.area)}${fmt(unit())}¥${fmt(r.amt)}`).join(""):'暂无'; $("q-quote-value-body").innerHTML=Q.valueRows.length?Q.valueRows.map(r=>`${r.item}${r.spec}${r.qty}${r.price}¥${fmt(r.amt)}`).join(""):'暂无'; $("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=`${q.name}${q.date}${q.mat}(${q.rate}%) ¥${fmt(q.lo)} — ¥${fmt(q.hi)}${q.prec} `; 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=>`${m.n}${fmt(m.min)}${fmt(m.max)}`).join(""); $("rule-table-wardrobe").innerHTML=WARD.map((w,i)=>`${i+1}${w[0]}${fmt(w[1])}${w[2]}`).join(""); $("rule-table-kitchen").innerHTML=DOORS.map((d,i)=>`${i+1}${d[0]}${d[1]}${d[2]}${fmt(d[3])}${d[4]===null?"—(装饰柜无地柜价)":fmt(d[4])}`).join(""); $("rule-table-kitchen-fix").innerHTML=KIT_FIX.map(f=>`${f[1]}${fmt(f[2])}${f[3]}`).join("")+KIT_ACC.map(a=>`${a[0]}(配件)${fmt(a[1])}元/个`).join(""); $("rule-table-ban").innerHTML=RULES.map((r,i)=>`${i+1}🚫 ${r[0]}✓ ${r[1]}`).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();