Преглед на файлове

Merge branch 'main' of https://git.fmode.cn/lami/floorplan-quotation

Hermes Agent преди 2 седмици
родител
ревизия
c45b0ef
променени са 3 файла, в които са добавени 164 реда и са изтрити 60 реда
  1. 96 60
      AI快速报价_Demo2.optimizing.html
  2. 35 0
      changelog/user/2026-08-31-同步Gitee仓库协作记录.md
  3. 33 0
      secure_preview_server.py

+ 96 - 60
AI快速报价_Demo2.optimizing.html

@@ -130,6 +130,10 @@
     text-align: center; cursor: pointer; transition: all 0.2s; background: #FAFBFC;
   }
   .upload-area:hover { border-color: var(--accent); background: #EBF8FF; }
+  .upload-area.dragover { border-color: var(--primary); background: #EBF8FF; }
+  .drop-zone { position: relative; }
+  .drop-zone .drop-hint { display:none; position:absolute; inset:0; background:rgba(49,130,206,.14); border:2px dashed var(--primary); color:#2B6CB0; font-weight:700; font-size:14px; align-items:center; justify-content:center; pointer-events:none; z-index:5; }
+  .drop-zone.dragover .drop-hint { display:flex; }
   .upload-area .icon { font-size: 36px; margin-bottom: 8px; }
   .upload-area .text { font-size: 14px; color: var(--text-muted); }
   .upload-area .types { font-size: 12px; color: #A0AEC0; margin-top: 4px; }
@@ -388,7 +392,7 @@
 
           <div id="q-upload-zone-unified" class="upload-area" onclick="document.getElementById('q-file-unified').click()" style="padding:16px;margin-bottom:10px;">
             <div class="icon">📐</div>
-            <div class="text">点击上传户型图 / CAD图纸</div>
+            <div class="text">点击或拖拽上传户型图 / CAD图纸</div>
             <div class="types">户型图支持 JPG、PNG、WEBP;CAD支持 DWG、DXF、PDF | 上传后自动进入 AI 解析</div>
           </div>
           <div style="display:flex;gap:6px;flex-wrap:wrap;">
@@ -403,11 +407,11 @@
           <div style="margin-bottom:12px;">
             <div style="font-size:14px;font-weight:600;color:var(--primary);">🎨 图纸识别标准规则设置(户型图 / CAD 通用)</div>
           </div>
-          <div style="font-size:12px;color:var(--text-muted);margin-bottom:12px;">平面户型图与CAD图纸可统一用红色框标注范围,AI抓取红框边界做完整测量;无红框时 AI 全自动识别;尺寸标注箭头支持红/蓝两色(蓝色自动 OCR 读数)。</div>
-          <div style="font-size:12px;font-weight:600;color:#2D3748;margin-bottom:8px;">标注颜色(红框圈注 / 色尺寸标注):</div>
+          <div style="font-size:12px;color:var(--text-muted);margin-bottom:12px;">平面户型图与CAD图纸可统一用红色框标注范围,AI抓取红框边界做完整测量;无红框时 AI 全自动识别;尺寸标注统一为红色(红箭头+红色数字自动 OCR 读数)。</div>
+          <div style="font-size:12px;font-weight:600;color:#2D3748;margin-bottom:8px;">标注颜色(红框圈注 / 色尺寸标注):</div>
           <div style="margin-bottom:14px;">
-            <select style="width:100%;" disabled title="红框=柜体圈注;蓝色箭头=尺寸标注(OCR 自动读数)">
-              <option value="red" selected>红色框(柜体圈注)+蓝色箭头(尺寸标注)· 隐式参照物为厨房台面深度600mm</option>
+            <select style="width:100%;" disabled title="红框=柜体圈注;红色箭头/红色数字=尺寸标注(OCR 自动读数)">
+              <option value="red" selected>红色框(柜体圈注)+红色尺寸标注(箭头+数字 OCR 自动读数)· 隐式参照物为厨房台面深度600mm</option>
             </select>
           </div>
           <div style="padding:10px 12px;background:#F0FFF4;border:1px solid #9AE6B4;border-radius:8px;font-size:12px;color:#276749;line-height:1.8;">
@@ -455,10 +459,14 @@
       </div>
 
       <div style="display:flex;gap:16px;flex-wrap:wrap;align-items:stretch;">
-        <div style="flex:3;min-width:380px;border:1px solid var(--border);border-radius:8px;overflow:hidden;background:#fff;">
-          <div style="font-size:11px;color:var(--text-muted);padding:7px 10px;background:#F7FAFC;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;">
+        <div id="q-canvas-drop" class="drop-zone" style="flex:3;min-width:380px;border:1px solid var(--border);border-radius:8px;overflow:hidden;background:#fff;">
+          <div class="drop-hint">📥 松开即更换户型图(JPG / PNG / WEBP,自动重新识别)</div>
+          <div style="font-size:11px;color:var(--text-muted);padding:7px 10px;background:#F7FAFC;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;gap:8px;">
             <span>📐 图纸识别结果叠加(<span style="color:#805AD5;">厨房</span> / <span style="color:#3182CE;">柜体</span>;图纸自带红框不二次叠加)</span>
-            <span id="q-ai-scene-chip" style="font-size:11px;font-weight:600;color:#2B6CB0;">等待识别…</span>
+            <span style="display:flex;align-items:center;gap:8px;">
+              <button class="btn btn-outline btn-sm" onclick="document.getElementById('q-file-unified').click()" title="点击选择新图纸,或直接将图纸拖拽到本区域">🔄 更换户型图</button>
+              <span id="q-ai-scene-chip" style="font-size:11px;font-weight:600;color:#2B6CB0;">等待识别…</span>
+            </span>
           </div>
           <canvas id="q-ai-canvas" style="width:100%;display:block;background:#fff;"></canvas>
         </div>
@@ -473,7 +481,7 @@
       </div>
 
       <div id="q-red-anno-row" style="display:none;margin-top:14px;background:#FFF5F5;border:1px solid #FED7D7;border-radius:8px;padding:8px 12px;font-size:12px;color:#C53030;">
-        🎯 标注箭头(红/蓝):长度已检测,标注值 = <input type="number" id="q-red-anno" value="2400" style="width:80px;padding:2px 6px;">(色标注会先尝试 OCR 自动读数;单位自动推断:与 600mm 台面深度锚点交叉校验 mm/cm/m,锚点未定位默认 mm)→ 据此建立全图比例(±2-5%)<span id="q-red-anno-infer" style="color:#97266D;font-weight:600;"></span>
+        🎯 红色标注箭头:长度已检测,标注值 = <input type="number" id="q-red-anno" value="2400" style="width:80px;padding:2px 6px;">(色标注会先尝试 OCR 自动读数;单位自动推断:与 600mm 台面深度锚点交叉校验 mm/cm/m,锚点未定位默认 mm)→ 据此建立全图比例(±2-5%)<span id="q-red-anno-infer" style="color:#97266D;font-weight:600;"></span>
       </div>
       <div id="q-ai-results" style="display:none;gap:16px;flex-wrap:wrap;margin-top:16px;align-items:stretch;">
         <div style="flex:3;min-width:380px;border:1px solid var(--border);border-radius:8px;background:#fff;overflow:hidden;">
@@ -755,6 +763,8 @@ const RULES=[["目测估算像素/毫米比例","用图纸标注(如有)或
 /* rate 默认15 → 单价=所选材质档最高单价(双饰面3500);厨房=宽(最长边)/高(最短边)/总长度/单价 均可调 */
 const S={ft:"plan",redbox:false,scale:null,scale600:null,scaleSrc:"",objs:[],mat:0,rate:15,
   ruleX:Object.fromEntries(EXTRA.map(e=>[e.id,!e.off])),kitExtras:mkExtras("",true),kitW:null,kitH:null,kitLen:null,kitPrice:null,brand:0,door:2};
+/* 状态兜底:旧版存档/异常恢复可能缺失 objs 等数组字段 → 统一回退空数组,杜绝 undefined.length / undefined.forEach 崩溃 */
+function ensureS(){if(!Array.isArray(S.objs))S.objs=[];if(!Array.isArray(S.redRects))S.redRects=[];if(!Array.isArray(S.redLabels))S.redLabels=[];}
 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});
@@ -769,6 +779,14 @@ function annoInfer(sc600){if(!S.redArrow||!S.redArrow.len)return null;
 function annoScale(){const r=annoInfer(S.scale600||0);return r?r.scale:0;}
 function renderAnnoInfer(){const el=$("q-red-anno-infer");if(!el)return;const inf=S.redArrow?annoInfer(S.scale600||0):null;
   el.textContent=inf?(inf.estM>0?"|推断单位:"+inf.unit+"(标注 "+fmt2(inf.vm)+"m vs 600mm锚点反推 "+fmt2(inf.estM)+"m)":"|600mm锚点未定位,标注默认按 mm"):"";}
+/* OCR 含糊候选复核:存在多个候选值时,取与 600mm 锚点反推箭头实长偏差最小的候选(改善须≥0.05对数距离才改判,避免噪音翻转) */
+function annoPick(sc600){if(!S.redArrow||!S.redAnnoCands||S.redAnnoCands.length<2||!(sc600>0))return;
+  const estM=S.redArrow.len*sc600;if(!(estM>0))return;
+  const dist=v=>{let bd=1e9;for(const c of[v/1000,v/100,v])bd=Math.min(bd,Math.abs(Math.log(c/estM)));return bd;};
+  let bi=0,bd=dist(S.redAnnoCands[0]);
+  for(let i=1;i<S.redAnnoCands.length;i++){const d=dist(S.redAnnoCands[i]);if(d+0.05<bd){bd=d;bi=i;}}
+  if(bi>0){$("q-red-anno").value=S.redAnnoCands[bi];
+    logAI("· OCR 候选含糊("+S.redAnnoCands.join("/")+")→ 按 600mm 锚点反推箭头实长 "+fmt2(estM)+"m 复核,改判为 <b>"+S.redAnnoCands[bi]+"</b>","w");}}
 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);}
@@ -798,7 +816,7 @@ function qNewQuote(){qResetEditor();switchView("editor");qOpenEditorStep(1);}
 /* 新建报价单:完整清空上一单状态与识别UI,避免携带旧数据 */
 function qResetEditor(){
   img=null;overlays=[];ctx.clearRect(0,0,cv.width,cv.height);
-  Object.assign(S,{ft:"plan",redbox:false,scale:null,scale600:null,scaleSrc:"",objs:[],mat:0,rate:15,kitW:null,kitH:null,kitLen:null,kitPrice:null,kitBox:null,redArrow:null,brand:0,door:2});
+  Object.assign(S,{ft:"plan",redbox:false,scale:null,scale600:null,scaleSrc:"",objs:[],mat:0,rate:15,kitW:null,kitH:null,kitLen:null,kitPrice:null,kitBox:null,redArrow:null,redRects:[],redLabels:[],brand:0,door:2});
   S.ruleX=Object.fromEntries(EXTRA.map(e=>[e.id,!e.off]));S.kitExtras=mkExtras("",true);
   $("q-file-unified").value="";$("q-btn-analyze").disabled=true;$("q-step2-next-btn").disabled=true;
   $("q-status-drawing").textContent="⬜ 图纸:未上传";$("q-status-drawing").style.background="#EDF2F7";$("q-status-drawing").style.color="var(--text-muted)";
@@ -831,7 +849,7 @@ function draw(){if(!img)return;const k=cv.width/img.naturalWidth;ctx.clearRect(0
       ctx.lineWidth=6;ctx.strokeStyle="rgba(255,255,255,.92)";ctx.strokeText(lb,lx,ly);
       ctx.fillStyle=o.t==="panel"?"#38A169":"#E53E3E";ctx.fillText(lb,lx,ly);
       ctx.textAlign="left";}});}
-function showImg(im){img=im;cv.width=1000;cv.height=Math.round(1000*im.naturalHeight/im.naturalWidth);draw();
+function showImg(im){img=im;overlays=[];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";}
@@ -862,10 +880,23 @@ function genSample(u,cad){
   const im=new Image();im.src=c.toDataURL("image/png");return im;
 }
 function qUseMockDrawing(isU,isCad){const im=genSample(isU,isCad);im.onload=()=>{showImg(im);toast(isCad?"已载入示例CAD图(黑白线图)":isU?"已载入示例户型图(U型厨房+红框)":"已载入示例户型图(L型厨房+红框)");qStartAnalysis();};}
-function qHandleUnified(inp){const f=inp.files[0];if(!f)return;
+/* 统一图纸载入入口:点击上传 / 拖拽上传 / 更换户型图 共用,载入后自动重新识别 */
+function qLoadFile(f){if(!f)return;
   if(/\.(pdf|dwg|dxf)$/i.test(f.name)){alert("PDF/DWG 请先转 JPG/PNG(Demo 限制)");return;}
+  if(f.type&&f.type.indexOf("image")!==0&&!/\.(jpe?g|png|webp|bmp)$/i.test(f.name)){alert("仅支持图片格式:JPG / PNG / WEBP");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();}
+function qHandleUnified(inp){qLoadFile(inp.files[0]);inp.value="";}   /* 清空 value:同名文件可重复选择触发 onchange */
+/* 拖拽上传绑定:dragover 高亮提示,drop 载入并自动重新识别 */
+function qBindDrop(el){if(!el)return;
+  ["dragenter","dragover"].forEach(ev=>el.addEventListener(ev,e=>{e.preventDefault();e.stopPropagation();el.classList.add("dragover");}));
+  ["dragleave","dragend"].forEach(ev=>el.addEventListener(ev,e=>{e.preventDefault();e.stopPropagation();el.classList.remove("dragover");}));
+  el.addEventListener("drop",e=>{e.preventDefault();e.stopPropagation();el.classList.remove("dragover");
+    const f=e.dataTransfer&&e.dataTransfer.files&&e.dataTransfer.files[0];qLoadFile(f);});}
+function qStartAnalysis(){if(!img){toast("请先上传图纸或选择示例","toast-error");return;}qOpenEditorStep(2);
+  runAI().catch(e=>{ /* 识别异常兜底:清叠加、重绘画布、提示重试,避免留下错位/半残页面 */
+    overlays=[];if(img)draw();setProg(0,"");setProg(1,"");setProg(2,"");
+    logAI("⚠ AI 识别引擎异常:"+(e&&e.message?e.message:e)+"。画布已重置,请重试或更换图纸。","e");
+    toast("AI 识别异常,请重试","toast-error");});}
 /* ========== 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||"");}
@@ -877,7 +908,7 @@ function dilateMask(m,w,h,it){let cur=m;for(let t=0;t<it;t++){const o=new Uint8A
   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;}
-/* ========== 标注数字 OCR(模板匹配 0-9,支持 90° 旋转,用于色尺寸标注自动读数) ========== */
+/* ========== 标注数字 OCR(模板匹配 0-9,支持 90° 旋转,用于色尺寸标注自动读数) ========== */
 function bmCrop(data,w,h){let x0=1e9,y0=1e9,x1=-1,y1=-1;for(let y=0;y<h;y++)for(let x=0;x<w;x++){if(data[(y*w+x)*4+3]>120){if(x<x0)x0=x;if(x>x1)x1=x;if(y<y0)y0=y;if(y>y1)y1=y;}}
   if(x1<0)return null;const W=x1-x0+1,H=y1-y0+1,a=new Uint8Array(W*H);
   for(let y=0;y<H;y++)for(let x=0;x<W;x++)a[y*W+x]=data[((y0+y)*w+x0+x)*4+3]>120?1:0;return {w:W,h:H,a};}
@@ -905,22 +936,32 @@ function mkCC(W){return mask=>{const vis=new Uint8Array(mask.length),out=[];
 function ocrNumber(mask,W,ar,cc,F){F=F||1;
   const comps=cc(mask).filter(c=>c.area>=5*F*F&&Math.max(c.w,c.h)>=3*F&&Math.max(c.w,c.h)<=16*F);
   const ax=ar.x+ar.w/2,ay=ar.y+ar.h/2,vert=ar.h>=ar.w;
-  const near=comps.filter(c=>{const cx=c.x+c.w/2,cy=c.y+c.h/2;return Math.abs(cx-ax)<=25*F&&Math.abs(cy-ay)<=(vert?ar.h:ar.w)/2+25*F;});
+  const near=comps.filter(c=>{const cx=c.x+c.w/2,cy=c.y+c.h/2;
+    if(vert&&cx>=ar.x-2*F&&cx<=ar.x+ar.w+2*F&&(cy<=ar.y+8*F||cy>=ar.y+ar.h-8*F))return false;   /* 剔除竖箭头两端箭头头 */
+    if(!vert&&cy>=ar.y-2*F&&cy<=ar.y+ar.h+2*F&&(cx<=ar.x+8*F||cx>=ar.x+ar.w-8*F))return false;   /* 剔除横箭头两端箭头头 */
+    return Math.abs(cx-ax)<=60*F&&Math.abs(cy-ay)<=(vert?ar.h:ar.w)/2+25*F;});   /* 水平容差扩至60F:兼容数字水平写在箭头较远处 */
   if(near.length<3||near.length>6)return 0;
   const T=digTemplates();
-  const combos=vert?[[-1,1],[1,3]]:[[1,0],[-1,2]];   /* [排序方向, 90°旋转次数] 仅几何自洽组合:逆时针90°标注=自下而上+旋1;顺时针90°=自上而下+旋3;水平=自左而右+旋0 / 倒置=自右而左+旋2 */
-  let best=null;
-  for(const cb of combos){const dir=cb[0],rq=cb[1];
-    const list=near.slice().sort((a,b)=>dir*((vert?a.y:a.x)-(vert?b.y:b.x)));
-    let s="",sc=0,ok=true;
+  /* [排序轴, 排序方向, 90°旋转次数] 覆盖两类排布:数字沿箭头旋转90°(竖排旋1/3、横排旋0/2)与数字水平写在箭头旁(自左而右旋0 / 自右而左旋2) */
+  const combos=vert?[["y",-1,1],["y",1,3],["x",1,0],["x",-1,2]]:[["x",1,0],["x",-1,2],["y",-1,1],["y",1,3]];
+  let res=[];
+  for(const cb of combos){const dir=cb[1],rq=cb[2];
+    const list=near.slice().sort((a,b)=>dir*((cb[0]==="y"?a.y:a.x)-(cb[0]==="y"?b.y:b.x)));
+    let s="",sc=0,ok=true;const amb=[];
     for(const c of list){let b={w:c.w+1,h:c.h+1,a:new Uint8Array((c.w+1)*(c.h+1))};
       for(let y=0;y<=c.h;y++)for(let x=0;x<=c.w;x++)b.a[y*b.w+x]=mask[(c.y+y)*W+(c.x+x)];
       for(let q=0;q<rq;q++)b=bmRot(b);
-      const nb=bmNorm(b,9,12);let bd=-1,bi=0;
-      for(let dg=0;dg<10;dg++){const v=bmIou(nb,T[dg]);if(v>bd){bd=v;bi=dg;}}
-      if(bd<0.45){ok=false;break;}sc+=bd;s+=bi;}
-    if(ok&&(!best||sc>best.sc))best={s,sc};}
-  if(!best)return 0;const n=+best.s;return best.s.length>=3&&n>=100&&n<=99999?n:0;}
+      const nb=bmNorm(b,9,12);let v1=-1,d1=0,v2=-1,d2=-1;
+      for(let dg=0;dg<10;dg++){const v=bmIou(nb,T[dg]);if(v>v1){v2=v1;d2=d1;v1=v;d1=dg;}else if(v>v2){v2=v;d2=dg;}}
+      if(v1<0.45){ok=false;break;}sc+=v1;s+=d1;
+      if(d2>=0&&d2!==d1&&v1-v2<=0.03)amb.push([s.length-1,d2]);}   /* 数字含糊(前两模板得分差≤0.03)→ 记录备选位 */
+    if(!ok)continue;
+    /* 含糊位枚举变体(最多3位),返回候选数组:首选为最高模板得分,其余交由 600mm 锚点交叉校验复核 */
+    let vars=[{s,sc}];for(const[p,dg]of amb.slice(0,3)){const ex=[];for(const it of vars)ex.push({s:it.s.slice(0,p)+dg+it.s.slice(p+1),sc:it.sc});vars=vars.concat(ex);}
+    for(const it of vars){const n=+it.s;if(it.s.length>=3&&n>=100&&n<=99999)res.push(it);}}
+  if(!res.length)return [];
+  res.sort((a,b)=>b.sc-a.sc);const out=[];for(const it of res){if(out.indexOf(+it.s)<0)out.push(+it.s);}
+  return out;}
 /* ---- 图纸红字柜体名称识别(如"2-电视柜"):柜种词汇表 + 汉字模板匹配 ---- */
 const CAB_NAMES=["餐边柜","电视柜","阳台柜","衣柜","衣帽间","书柜","书桌","储物柜","玄关柜","卫浴柜","榻榻米","鞋柜","酒柜","橱柜","开放柜","吊柜","地柜"];
 let CHR_T=null;
@@ -977,6 +1018,7 @@ function wallThickness(wm,w,h){const runs=[];
   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.scale600=null;S.scaleSrc="";S.objs=[];S.ft="plan";S.redRects=[];S.redLabels=[];S.kitBox=null;S.redArrow=null;S.kitW=null;S.kitH=null;S.kitLen=null;S.kitPrice=null;
+  if(img)draw();   /* 重置后立即重绘干净画布:避免新图纸复用上一张图的叠加红框,出现“错位页面” */
   $("q-ai-results").style.display="none";
   $("q-red-anno-row").style.display="none";
   $("q-step2-next-btn").disabled=true;
@@ -1031,7 +1073,6 @@ async function runAI(){
     for(let x=0;x<AW;x++){let run=0,st=0;for(let y2=0;y2<=AH;y2++){const v=y2<AH?mk[y2*AW+x]:0;if(v){if(!run)st=y2;run++;}else{if(run>=12)for(let y3=st;y3<y2;y3++)s[y3*AW+x]=1;run=0;}}}
     return s;};
   if(S.redbox){
-    const solid=runMask(redm);
     /* 闭合红圈/红框 → 内部"孔洞"即柜体边界(兼容粗笔圈注与细线矩形) */
     const ext=new Uint8Array(AW*AH),stk=[];
     for(let x=0;x<AW;x++){if(!redm[x])stk.push(x);if(!redm[(AH-1)*AW+x])stk.push((AH-1)*AW+x);}
@@ -1046,44 +1087,35 @@ async function runAI(){
         S.redRects.push({x:Math.max(0,h.x-2),y:Math.max(0,h.y-2),w:Math.min(AW-h.x-2,h.w+4),h:Math.min(AH-h.y-2,h.h+4)});}
     S.redRects.sort((a,b)=>b.w*b.h-a.w*a.h);
     logAI(ph("· 红框矩形提取:"+S.redRects.length+" 个柜体边界框(直接取图纸红框为柜体边界,不二次收紧;已过滤虚线箭头/标注文字)"),"w");await S2(200);
-    /* 红箭头标注检测(共线实线段合并为双头箭头) */
+    /* 红色尺寸标注检测:先剥离红框笔画(避免贴着红框的尺寸箭头因连通域合并被过滤),共线实线段合并为双头箭头,单长条亦视为箭头 */
     S.redArrow=null;
-    const arrows=cc(solid).filter(c=>Math.max(c.w,c.h)>=18&&Math.min(c.w,c.h)<=6);
+    const redm2=redm.slice();
+    for(const rc of S.redRects)for(let yy=Math.max(0,rc.y-3);yy<=Math.min(AH-1,rc.y+rc.h+3);yy++)for(let xx=Math.max(0,rc.x-3);xx<=Math.min(AW-1,rc.x+rc.w+3);xx++)redm2[yy*AW+xx]=0;
+    const arrows=cc(runMask(redm2)).filter(c=>Math.max(c.w,c.h)>=18&&Math.min(c.w,c.h)<=6);
+    const abb=(a1,a2)=>({x:Math.min(a1.x,a2.x),y:Math.min(a1.y,a2.y),w:Math.max(a1.x+a1.w,a2.x+a2.w)-Math.min(a1.x,a2.x),h:Math.max(a1.y+a1.h,a2.y+a2.h)-Math.min(a1.y,a2.y)});
+    for(const c of arrows){const len=Math.max(c.w,c.h)/k;if(!S.redArrow||len>S.redArrow.len)S.redArrow={len,x:c.x,y:c.y,w:c.w,h:c.h};}
     for(let i1=0;i1<arrows.length;i1++)for(let j=i1+1;j<arrows.length;j++){const a1=arrows[i1],a2=arrows[j];
       if(a1.h>a1.w&&a2.h>a2.w&&Math.abs(a1.x-a2.x)<=4){const gap=Math.max(a1.y,a2.y)-Math.min(a1.y+a1.h,a2.y+a2.h);
         if(gap>=0&&gap<=40){const len=(Math.max(a1.y+a1.h,a2.y+a2.h)-Math.min(a1.y,a2.y))/k;
-          if(!S.redArrow||len>S.redArrow.len)S.redArrow={len};}}
+          if(!S.redArrow||len>S.redArrow.len)S.redArrow={len,...abb(a1,a2)};}}
       else if(a1.w>a1.h&&a2.w>a2.h&&Math.abs(a1.y-a2.y)<=4){const gap=Math.max(a1.x,a2.x)-Math.min(a1.x+a1.w,a2.x+a2.w);
         if(gap>=0&&gap<=40){const len=(Math.max(a1.x+a1.w,a2.x+a2.w)-Math.min(a1.x,a2.x))/k;
-          if(!S.redArrow||len>S.redArrow.len)S.redArrow={len};}}}
-    if(S.redArrow)logAI("· 红箭头标注检测:长 "+Math.round(S.redArrow.len)+"px → 请在结果面板确认标注值,尺寸单位由 600mm 台面深度锚点交叉推断(场景B ±2-5%)","w");
-  }
-  /* 蓝色尺寸标注箭头(部分图纸标注为蓝色双头箭头,如 3150):红箭头优先;单长条亦视为箭头 */
-  if(!S.redArrow){
-    const bm=new Uint8Array(AW*AH);for(let i=0;i<AW*AH;i++){const r=d[i*4],g=d[i*4+1],b2=d[i*4+2];if(r<150&&b2>140&&b2-r>60&&b2-g>25)bm[i]=1;}
-    const bcomps=cc(runMask(bm)).filter(c=>Math.max(c.w,c.h)>=18&&Math.min(c.w,c.h)<=8);
-    let bl=0,bcomp=null;
-    for(const c of bcomps){const len=Math.max(c.w,c.h)/k;if(len>bl){bl=len;bcomp=c;}}
-    for(let i1=0;i1<bcomps.length;i1++)for(let j=i1+1;j<bcomps.length;j++){const a1=bcomps[i1],a2=bcomps[j];
-      if(a1.h>a1.w&&a2.h>a2.w&&Math.abs(a1.x-a2.x)<=4){const gap=Math.max(a1.y,a2.y)-Math.min(a1.y+a1.h,a2.y+a2.h);
-        if(gap>=0&&gap<=40){const len=(Math.max(a1.y+a1.h,a2.y+a2.h)-Math.min(a1.y,a2.y))/k;if(len>bl){bl=len;bcomp=a1;}}}
-      else if(a1.w>a1.h&&a2.w>a2.h&&Math.abs(a1.y-a2.y)<=4){const gap=Math.max(a1.x,a2.x)-Math.min(a1.x+a1.w,a2.x+a2.w);
-        if(gap>=0&&gap<=40){const len=(Math.max(a1.x+a1.w,a2.x+a2.w)-Math.min(a1.x,a2.x))/k;if(len>bl){bl=len;bcomp=a1;}}}}
-    if(bcomp){S.redArrow={len:bl,blue:true};$("q-red-anno").value="";   /* 蓝色标注值未知:清空默认值,避免误套 2400 */
-      logAI("· 蓝色尺寸标注箭头检测:长 "+Math.round(bl)+"px(本图标注为蓝色双头箭头)→ 尝试 OCR 读取邻近数字","w");
-      /* OCR 高分裁剪:降采样后数字过小易误读 → 从原图按 2 倍 AW 比例裁出箭头邻域再识别 */
-      const OF=2,OM=30;
-      const ox0=Math.max(0,bcomp.x-OM),oy0=Math.max(0,bcomp.y-OM);
-      const ow=Math.min(AW-ox0,bcomp.w+2*OM),oh=Math.min(AH-oy0,bcomp.h+2*OM);
+          if(!S.redArrow||len>S.redArrow.len)S.redArrow={len,...abb(a1,a2)};}}}
+    if(S.redArrow){logAI("· 红色尺寸标注检测:箭头长 "+Math.round(S.redArrow.len)+"px → 尝试 OCR 读取邻近红色数字","w");
+      /* OCR 高分裁剪:降采样后数字过小易误读 → 从原图按 2 倍 AW 比例裁出箭头邻域,用红色掩码识别数字 */
+      const OF=2,OM=60,ar=S.redArrow;   /* 邻域扩至60:兼容数字水平写在箭头较远处的排布 */
+      const ox0=Math.max(0,ar.x-OM),oy0=Math.max(0,ar.y-OM);
+      const ow=Math.min(AW-ox0,ar.w+2*OM),oh=Math.min(AH-oy0,ar.h+2*OM);
       const oc2=document.createElement("canvas");oc2.width=ow*OF;oc2.height=oh*OF;
       const og2=oc2.getContext("2d");og2.imageSmoothingEnabled=true;
       og2.drawImage(img,ox0/k,oy0/k,ow/k,oh/k,0,0,ow*OF,oh*OF);
       const d2=og2.getImageData(0,0,ow*OF,oh*OF).data;
-      const bm2=new Uint8Array(ow*OF*oh*OF);
-      for(let i2=0;i2<bm2.length;i2++){const r2=d2[i2*4],g2=d2[i2*4+1],b3=d2[i2*4+2];if(r2<150&&b3>140&&b3-r2>60&&b3-g2>25)bm2[i2]=1;}
-      const num=ocrNumber(bm2,ow*OF,{x:(bcomp.x-ox0)*OF,y:(bcomp.y-oy0)*OF,w:bcomp.w*OF,h:bcomp.h*OF},mkCC(ow*OF),OF);
-      if(num){$("q-red-anno").value=num;logAI("· 蓝色标注 OCR 读出:<b>"+num+"</b>(已自动填入,如有出入可在输入框修改)→ 据此建立全图比例","b");}
-      else logAI("· OCR 未能置信读出蓝色标注值 → 请在下方输入框手动填写标注数值(默认按 mm)","e");}
+      const rm2=new Uint8Array(ow*OF*oh*OF);
+      for(let i2=0;i2<rm2.length;i2++){const r2=d2[i2*4],g2=d2[i2*4+1],b3=d2[i2*4+2];if(r2>140&&r2-g2>50&&r2-b3>50)rm2[i2]=1;}
+      const cands=ocrNumber(rm2,ow*OF,{x:(ar.x-ox0)*OF,y:(ar.y-oy0)*OF,w:ar.w*OF,h:ar.h*OF},mkCC(ow*OF),OF);
+      if(cands.length){$("q-red-anno").value=cands[0];S.redAnnoCands=cands;
+        logAI("· 红色尺寸标注 OCR 读出:<b>"+cands[0]+"</b>"+(cands.length>1?"(存在含糊候选 "+cands.join("/")+",将以 600mm 锚点交叉复核)":"")+"(已自动填入,如有出入可在输入框修改)→ 据此建立全图比例","b");}
+      else logAI("· OCR 未能置信读出红色标注值 → 请在下方输入框确认标注数值(默认 2400,单位 mm)","e");}
   }
   /* 图纸已用红字标注柜体名称(如"2-电视柜")→ 识别之,对应红框柜体直接采用图纸名称,系统不再二次命名 */
   if(S.redbox){const LM=dilateMask(redm,AW,AH,3);
@@ -1160,6 +1192,7 @@ async function runAI(){
     if(kitchen){
       /* 600mm 隐式参照物先建立图像比例;场景B 有红箭头标注时,标注值与锚点交叉校验推断尺寸单位后再校准 */
       const sc=0.6/(kitchen.dd/k);S.scale600=sc;S.scale=sc;S.scaleSrc="AI定位厨房台面深度=600mm锚点(全图通用)";
+      annoPick(sc);
       if(S.redArrow){const inf=annoInfer(sc);if(inf){S.scale=inf.scale;S.scaleSrc="红箭头标注校准(推断单位"+inf.unit+" · 与600mm锚点交叉校验)";
         logAI("· 尺寸单位推断(场景B):标注值 "+(+$("q-red-anno").value)+" → 推断单位 "+inf.unit+"("+fmt2(inf.vm)+"m) vs 600mm锚点反推箭头长 "+fmt2(inf.estM)+"m → 1px="+(inf.scale*1000).toFixed(2)+"mm","w");}}
       overlays.push({t:"kitchen",x:kitchen.x/k,y:kitchen.y/k,w:kitchen.w/k,h:kitchen.h/k,label:"厨房"});
@@ -1348,6 +1381,7 @@ async function runAI(){
       else dNat=Math.min(b0.w,b0.h)/k;
       /* 600mm 隐式参照物应用于场景A/B:先建锚点比例;场景B 标注值与锚点交叉校验推断尺寸单位(mm/cm/m)后校准 */
       S.scale600=0.6/dNat;
+      annoPick(S.scale600);
       const inf=S.redArrow?annoInfer(S.scale600):null;
       if(inf){S.scale=inf.scale;S.scaleSrc="红箭头标注校准(推断单位"+inf.unit+" · 与600mm锚点交叉校验)";
         logAI("· 尺寸单位推断(场景B):标注值 "+(+$("q-red-anno").value)+" → 推断单位 "+inf.unit+"("+fmt2(inf.vm)+"m) vs 600mm锚点反推箭头长 "+fmt2(inf.estM)+"m,偏差 "+Math.round(Math.abs(inf.vm-inf.estM)/inf.estM*100)+"% → 1px="+(inf.scale*1000).toFixed(2)+"mm","w");}
@@ -1426,10 +1460,10 @@ async function runAI(){
   $("q-step2-next-btn").disabled=false;
 }
 function fh(){return parseFloat($("q-floor-height").value)||2.5;}
-function qFhChanged(src){const v=(src?src.value:$("q-floor-height").value)||"2.5";
+function qFhChanged(src){ensureS();const v=(src?src.value:$("q-floor-height").value)||"2.5";
   $("q-floor-height").value=v;const i2=$("q-floor-height2");if(i2)i2.value=v;
   if(S.objs.length||S.kitBox){renderAIResults();recalc();}}
-function renderAIResults(){
+function renderAIResults(){ensureS();
   const sc=dimScale();
   $("q-red-anno-row").style.display=S.redArrow?"block":"none";
   renderAnnoInfer();
@@ -1454,7 +1488,7 @@ function renderAIResults(){
 
 /* ========== 对象表(步骤3) ========== */
 let KIT_TR=null,TOTAL_TR=null;
-function rebuildObjs(){
+function rebuildObjs(){ensureS();
   const tb=$("objBody");tb.innerHTML="";KIT_TR=null;TOTAL_TR=null;
   /* 厨房行:与柜体合并同表;宽=最长边、高=最短边、总长度(L/U型自动算,可改)、单价(元/m,默认材质档最高,可改) */
   if(S.kitW>0&&S.kitH>0){const tr=document.createElement("tr");KIT_TR=tr;
@@ -1569,7 +1603,7 @@ function calcExtras(p,isKit){
 
 /* ========== 计算引擎 ========== */
 let LASTQ={baseSum:0,kSum:0,wSum:0,varArea:0,cabArea:0,cabAmt:0,baseRows:[],valueRows:[]};
-function recalc(){
+function recalc(){ensureS();
   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;
@@ -1652,7 +1686,7 @@ 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,kitW:S.kitW,kitH:S.kitH,kitLen:S.kitLen,kitPrice:S.kitPrice,kitEx:S.kitExtras,ruleX:S.ruleX,brand:S.brand,door:S.door,objs:S.objs.map(o=>({name:o.name,kind:o.kind,src:o.src,w:o.w,h:o.h,lenM:o.lenM!=null?o.lenM:null,hgtM:o.hgtM!=null?o.hgtM:null,price:o.price!=null?o.price:null,ex:o.extras||{}}))});}
-function qSaveQuote(silent){
+function qSaveQuote(silent){ensureS();
   if(!S.objs.length&&!LASTQ.baseSum&&!LASTQ.wSum){if(!silent)toast("暂无计价内容,请先上传图纸完成AI识别","toast-error");return;}
   const m=MATS[S.mat],fixed=LASTQ.wSum;
   const q={id:Date.now(),name:("报价单 "+(QUOTES.length+1)),date:new Date().toLocaleString("zh-CN"),mat:m.n,rate:S.rate,
@@ -1679,6 +1713,7 @@ function renderQuoteList(){
 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,kitW:snap.kitW!=null?snap.kitW:null,kitH:snap.kitH!=null?snap.kitH:null,kitLen:snap.kitLen!=null?snap.kitLen:null,kitPrice:snap.kitPrice!=null?snap.kitPrice:null,objs:snap.objs});
+    ensureS();   /* 旧版存档可能缺 objs 等字段 → 回退空数组,避免恢复后 undefined 崩溃 */
     S.kitExtras=snap.kitEx||mkExtras("",true);
     S.ruleX=Object.assign(Object.fromEntries(EXTRA.map(e=>[e.id,!e.off])),snap.ruleX||{});
     S.brand=snap.brand!=null?snap.brand:0;
@@ -1835,9 +1870,10 @@ function renderRules(){
 }
 
 /* ========== 事件与初始化 ========== */
-$("q-red-anno").oninput=()=>{if(S.redArrow){const old=dimScale();const inf=annoInfer(S.scale600||0);if(!inf)return;S.scale=inf.scale;S.scaleSrc=(S.redArrow.blue?"蓝":"红")+"标注箭头校准(推断单位"+inf.unit+" · 与600mm锚点交叉校验)";const nw=dimScale();if(S.kitBox&&old&&nw)S.kitBox={wM:S.kitBox.wM/old*nw,hM:S.kitBox.hM/old*nw};renderAnnoInfer();recalc();renderAIResults();}};
+$("q-red-anno").oninput=()=>{if(S.redArrow){const old=dimScale();const inf=annoInfer(S.scale600||0);if(!inf)return;S.scale=inf.scale;S.scaleSrc="红标注箭头校准(推断单位"+inf.unit+" · 与600mm锚点交叉校验)";const nw=dimScale();if(S.kitBox&&old&&nw)S.kitBox={wM:S.kitBox.wM/old*nw,hM:S.kitBox.hM/old*nw};renderAnnoInfer();recalc();renderAIResults();}};
 $("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");};
 
+qBindDrop($("q-upload-zone-unified"));qBindDrop($("q-canvas-drop"));   /* 点击 + 拖拽双通道上传/更换图纸 */
 renderTiers();renderRules();renderQuoteList();rebuildObjs();recalc();
 </script>
 </body>

+ 35 - 0
changelog/user/2026-08-31-同步Gitee仓库协作记录.md

@@ -0,0 +1,35 @@
+# 协作记录 · 2026-08-31 · 同步到 Gitee 仓库
+
+## 一、需求
+
+用户要求将本项目同步到 Gitee 仓库:
+
+```
+https://gitee.com/hui-huaizhu/miao-ziyis-budget-quote.git
+```
+
+## 二、执行过程与讨论
+
+1. 已将该地址添加为第二个远程,名称 `gitee`(保留原 `origin` 指向 git.fmode.cn):
+   - `git remote add gitee https://gitee.com/hui-huaizhu/miao-ziyis-budget-quote.git`
+2. 尝试推送时发现:**Gitee URL 中未内嵌凭据**(不同于 fmode 仓库),推送卡在凭据等待阶段超 1 分钟无输出,已终止该进程。
+3. 与用户确认凭据方式:
+   - 用户确认登录用户名为 `hui-huaizhu`;
+   - 经询问,用户决定**不提供密码/令牌,改为自行在终端手动推送**。
+
+## 三、结论与后续操作
+
+- 远程 `gitee` 已配置就绪,由用户在本地终端手动执行:
+
+```bash
+git push -u gitee main
+# 按提示输入 hui-huaizhu 的 Gitee 密码(或令牌)
+```
+
+- 推送前的本地状态:`main` 分支已包含功能开发提交(红色标注识别、上传更换户型图等),工作区另有 `AI快速报价_Demo2.optimizing.html` 未提交改动(保持原样,不在本次处理)。
+- 期间已将本地领先 origin 的提交一并推送到 `origin`(git.fmode.cn),保证两个远程进度一致。
+
+## 四、备注
+
+- 若后续希望免交互推送 Gitee,可将凭据嵌入远程 URL(格式:`https://用户名:密码或令牌@gitee.com/...`),或由用户提供私人令牌后代为配置。
+- 本环境 GCM 凭据管理器写全局配置受沙箱限制,代推送时需使用 `git -c credential.helper=` 绕过。

+ 33 - 0
secure_preview_server.py

@@ -54,16 +54,49 @@ def _normalize_image(value):
 
 
 class Handler(SimpleHTTPRequestHandler):
+    # HTTP/1.1 keep-alive:避免 HTTP/1.0 每请求关连接,预览面板复用连接时遇到 RST 而 ERR_ABORTED
+    protocol_version = "HTTP/1.1"
+
+    def _vite(self):
+        # Trae 预览会注入 /@vite/client 热更新脚本:返回 200 空JS,避免 404 中断主文档加载
+        self.send_response(200)
+        self.send_header("Content-Type", "text/javascript")
+        self.send_header("Cache-Control", "no-store")
+        self.send_header("Content-Length", "2")
+        self.end_headers()
+        self.wfile.write(b"//")
+
+    def _strip_cond(self):
+        # 剥离条件请求头 → 永远 200 全量正文:
+        # 预览面板需改写 HTML 正文注入热更新脚本,304 无正文会使其放弃本次加载并重发(net::ERR_ABORTED)
+        for h in ("If-Modified-Since", "If-None-Match"):
+            if h in self.headers:
+                del self.headers[h]
+
+    def send_header(self, keyword, value):
+        # Python 3.14 的 send_head 用 "Content-type"(小写t),比较必须忽略大小写
+        if keyword.lower() == "content-type" and "text/html" in value:
+            super().send_header("Cache-Control", "no-store")
+        super().send_header(keyword, value)
+
     def do_GET(self):
         # 【加固】静态文件服务遇到浏览器中断连接(预览重载/取消)时静默忽略,
         # 避免异常冒泡导致线程 traceback 刷屏甚至进程退出
         try:
+            if self.path.split("?", 1)[0].startswith("/@vite/"):
+                self._vite()
+                return
+            self._strip_cond()
             super().do_GET()
         except (BrokenPipeError, ConnectionResetError, TimeoutError):
             pass
 
     def do_HEAD(self):
         try:
+            if self.path.split("?", 1)[0].startswith("/@vite/"):
+                self._vite()
+                return
+            self._strip_cond()
             super().do_HEAD()
         except (BrokenPipeError, ConnectionResetError, TimeoutError):
             pass