AI快速报价_Demo2.optimizing.html 172 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>AI快速报价系统 - 新规则版</title>
  7. <style>
  8. :root {
  9. --primary: #1F497D;
  10. --primary-light: #2B6CB0;
  11. --accent: #3182CE;
  12. --bg: #F5F7FA;
  13. --card: #FFFFFF;
  14. --border: #E2E8F0;
  15. --text: #2D3748;
  16. --text-muted: #718096;
  17. --success: #38A169;
  18. --warning: #D69E2E;
  19. --danger: #E53E3E;
  20. --shadow: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.06);
  21. --shadow-md: 0 4px 6px rgba(0,0,0,0.07), 0 2px 4px rgba(0,0,0,0.06);
  22. --radius: 8px;
  23. --radius-lg: 12px;
  24. }
  25. * { box-sizing: border-box; margin: 0; padding: 0; }
  26. body {
  27. font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
  28. background: var(--bg); color: var(--text); line-height: 1.6; min-height: 100vh;
  29. }
  30. /* Header */
  31. .header {
  32. background: var(--primary); color: #fff; padding: 0 24px; height: 56px;
  33. display: flex; align-items: center; justify-content: space-between; box-shadow: 0 2px 8px rgba(0,0,0,0.15);
  34. position: sticky; top: 0; z-index: 100;
  35. }
  36. .header-logo { font-size: 18px; font-weight: 700; letter-spacing: 0.5px; }
  37. .header-logo span { color: #63B3ED; }
  38. .header-nav { display: flex; gap: 4px; }
  39. .header-nav button {
  40. background: transparent; color: rgba(255,255,255,0.8); border: none; padding: 8px 16px;
  41. border-radius: 6px; cursor: pointer; font-size: 14px; transition: all 0.2s;
  42. }
  43. .header-nav button:hover, .header-nav button.active { background: rgba(255,255,255,0.15); color: #fff; }
  44. /* Layout */
  45. .container { max-width: 1280px; margin: 0 auto; padding: 24px; }
  46. /* Steps indicator */
  47. .steps-bar { display: flex; gap: 0; margin-bottom: 28px; }
  48. .step-dot {
  49. flex: 1; display: flex; align-items: center; gap: 10px; padding: 14px 16px;
  50. background: var(--card); border: 2px solid var(--border); border-radius: 0;
  51. font-size: 14px; font-weight: 500; color: var(--text-muted); transition: all 0.3s;
  52. position: relative; cursor: pointer;
  53. }
  54. .step-dot:not(:first-child) { border-left: none; }
  55. .step-dot:first-child { border-radius: var(--radius) 0 0 var(--radius); }
  56. .step-dot:last-child { border-radius: 0 var(--radius) var(--radius) 0; }
  57. .step-dot.active { border-color: var(--accent); color: var(--accent); background: #EBF8FF; z-index: 1; }
  58. .step-dot.done { border-color: var(--success); color: var(--success); background: #F0FFF4; }
  59. .step-num {
  60. width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center;
  61. justify-content: center; font-size: 13px; font-weight: 700; background: var(--border); color: var(--text-muted);
  62. }
  63. .step-dot.active .step-num { background: var(--accent); color: #fff; }
  64. .step-dot.done .step-num { background: var(--success); color: #fff; }
  65. /* Cards */
  66. .card { background: var(--card); border-radius: var(--radius-lg); padding: 24px; box-shadow: var(--shadow); margin-bottom: 20px; }
  67. .card-title { font-size: 16px; font-weight: 600; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid var(--border); color: var(--primary); }
  68. /* Form */
  69. input, select, textarea {
  70. padding: 9px 12px; border: 1px solid var(--border); border-radius: 6px; font-size: 14px;
  71. font-family: inherit; transition: border-color 0.2s; background: #FAFBFC;
  72. }
  73. input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(49,130,206,0.1); }
  74. /* Buttons */
  75. .btn {
  76. display: inline-flex; align-items: center; gap: 6px; padding: 9px 20px;
  77. border: none; border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer;
  78. transition: all 0.2s; font-family: inherit;
  79. }
  80. .btn-primary { background: var(--accent); color: #fff; }
  81. .btn-primary:hover { background: var(--primary-light); }
  82. .btn-outline { background: #fff; color: var(--accent); border: 1px solid var(--accent); }
  83. .btn-outline:hover { background: #EBF8FF; }
  84. .btn-danger { background: #fff; color: var(--danger); border: 1px solid var(--danger); }
  85. .btn-danger:hover { background: #FFF5F5; }
  86. .btn-sm { padding: 5px 12px; font-size: 12px; }
  87. .btn-lg { padding: 12px 28px; font-size: 16px; }
  88. .btn:disabled { opacity: 0.5; cursor: not-allowed; }
  89. .btn-row { display: flex; gap: 12px; justify-content: space-between; align-items: center; margin-top: 20px; flex-wrap: wrap; }
  90. /* Table */
  91. table { width: 100%; border-collapse: collapse; font-size: 13px; }
  92. th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border); }
  93. th { background: #F7FAFC; color: var(--text-muted); font-weight: 600; font-size: 12px; letter-spacing: 0.5px; white-space: nowrap; }
  94. td input[type="number"] { width: 90px; padding: 6px 8px; font-size: 13px; text-align: center; }
  95. td input[type="text"] { padding: 6px 8px; font-size: 13px; }
  96. td select { padding: 6px 8px; font-size: 13px; }
  97. .text-right { text-align: right; }
  98. /* Price display */
  99. .price-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin: 16px 0; }
  100. .price-card { background: #F7FAFC; border-radius: var(--radius); padding: 16px; text-align: center; border: 1px solid var(--border); }
  101. .price-card .label { font-size: 12px; color: var(--text-muted); margin-bottom: 4px; }
  102. .price-card .value { font-size: 22px; font-weight: 700; }
  103. .price-card.floor .value { color: var(--success); }
  104. .price-card.ceiling .value { color: var(--danger); }
  105. .price-card.grand .value { color: var(--primary); }
  106. /* Tags & badges */
  107. .tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
  108. .tag-internal { background: #FEFCBF; color: #975A16; border: 1px solid #F6E05E; }
  109. .badge { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
  110. .badge.exact { background: #C6F6D5; color: #276749; }
  111. .badge.est { background: #FEEBC8; color: #9C4221; }
  112. .badge.na { background: #EDF2F7; color: #718096; }
  113. .chip { display: inline-block; padding: 2px 10px; border-radius: 10px; font-size: 12px; font-weight: 700; }
  114. .chip.l { background: #EBF8FF; color: #2B6CB0; }
  115. .chip.u { background: #FAF5FF; color: #6B46C1; }
  116. .chip.n { background: #EDF2F7; color: #718096; }
  117. /* Upload area */
  118. .upload-area {
  119. border: 2px dashed var(--border); border-radius: var(--radius); padding: 32px;
  120. text-align: center; cursor: pointer; transition: all 0.2s; background: #FAFBFC;
  121. }
  122. .upload-area:hover { border-color: var(--accent); background: #EBF8FF; }
  123. .upload-area.dragover { border-color: var(--primary); background: #EBF8FF; }
  124. .drop-zone { position: relative; }
  125. .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; }
  126. .drop-zone.dragover .drop-hint { display:flex; }
  127. .upload-area .icon { font-size: 36px; margin-bottom: 8px; }
  128. .upload-area .text { font-size: 14px; color: var(--text-muted); }
  129. .upload-area .types { font-size: 12px; color: #A0AEC0; margin-top: 4px; }
  130. /* Tabs */
  131. .tabs { display: flex; gap: 0; border-bottom: 2px solid var(--border); margin-bottom: 16px; flex-wrap: wrap; }
  132. .tab {
  133. padding: 10px 18px; cursor: pointer; font-size: 13px; font-weight: 500;
  134. color: var(--text-muted); border-bottom: 2px solid transparent; margin-bottom: -2px; transition: all 0.2s;
  135. }
  136. .tab:hover { color: var(--text); }
  137. .tab.active { color: var(--accent); border-bottom-color: var(--accent); }
  138. /* AI notice */
  139. .ai-notice {
  140. display: flex; align-items: center; gap: 8px; padding: 10px 14px;
  141. background: #FFFFF0; border: 1px solid #F6E05E; border-radius: 6px; font-size: 13px;
  142. color: #975A16; margin: 12px 0;
  143. }
  144. /* Toast */
  145. .toast {
  146. position: fixed; top: 20px; right: 20px; padding: 12px 20px; border-radius: 8px;
  147. color: #fff; font-size: 14px; z-index: 300; animation: fadeIn 0.3s; max-width: 360px;
  148. }
  149. .toast-success { background: var(--success); }
  150. .toast-info { background: var(--accent); }
  151. .toast-error { background: var(--danger); }
  152. /* Animations */
  153. @keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
  154. .fade-in { animation: fadeIn 0.3s ease; }
  155. .hidden { display: none !important; }
  156. /* Float rate slider */
  157. .float-slider-group { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
  158. .float-slider-group input[type="range"] {
  159. -webkit-appearance: none; appearance: none; width: 180px; height: 6px;
  160. background: linear-gradient(to right, var(--success) 0%, #ECC94B 50%, var(--danger) 100%);
  161. border-radius: 3px; outline: none; cursor: pointer; padding: 0; border: none;
  162. }
  163. .float-slider-group input[type="range"]::-webkit-slider-thumb {
  164. -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%;
  165. background: var(--primary); border: 2px solid #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.3); cursor: pointer;
  166. }
  167. .float-badge {
  168. display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 13px; font-weight: 700;
  169. background: #EDF2F7; color: var(--primary); min-width: 56px; text-align: center;
  170. }
  171. .float-preset { display: flex; gap: 6px; flex-wrap: wrap; }
  172. .float-preset button {
  173. padding: 4px 10px; border: 1px solid var(--border); border-radius: 14px;
  174. background: #fff; color: var(--text); cursor: pointer; font-size: 12px; transition: all 0.15s; font-family: inherit;
  175. }
  176. .float-preset button:hover { background: #EBF8FF; border-color: var(--accent); color: var(--primary); }
  177. .float-preset button.active { background: var(--primary); border-color: var(--primary); color: #fff; }
  178. /* Estimate card */
  179. .estimate-card {
  180. background: linear-gradient(135deg, #1A365D 0%, #2B4C7E 100%); color: #fff;
  181. border-radius: var(--radius-lg); padding: 20px 24px; margin-bottom: 16px;
  182. display: flex; align-items: center; gap: 24px; flex-wrap: wrap;
  183. }
  184. .estimate-card .est-label { font-size: 12px; opacity: 0.75; }
  185. .estimate-card .est-range { font-size: 24px; font-weight: 700; }
  186. .estimate-card .est-mid { font-size: 13px; opacity: 0.6; }
  187. .estimate-card .est-slider { flex: 1; min-width: 200px; }
  188. .estimate-card input[type="range"] {
  189. -webkit-appearance: none; appearance: none; width: 100%; height: 6px;
  190. background: rgba(255,255,255,0.25); border-radius: 3px; outline: none; cursor: pointer; padding: 0; border: none;
  191. }
  192. .estimate-card input[type="range"]::-webkit-slider-thumb {
  193. -webkit-appearance: none; width: 20px; height: 20px; border-radius: 50%;
  194. background: #63B3ED; border: 2px solid #fff; box-shadow: 0 1px 4px rgba(0,0,0,0.3); cursor: pointer;
  195. }
  196. /* Space cards */
  197. .space-card {
  198. border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 12px; background: #FAFBFC;
  199. }
  200. .space-card .space-header {
  201. display: flex; justify-content: space-between; align-items: center; font-weight: 600;
  202. margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid var(--border);
  203. }
  204. .space-card .space-lines { font-size: 13px; }
  205. .total-line { display: flex; justify-content: space-between; gap: 12px; padding: 5px 0; border-bottom: 1px dotted var(--border); font-size: 13px; }
  206. .total-line:last-child { border-bottom: none; }
  207. .num { text-align: right; white-space: nowrap; }
  208. .note { color: var(--text-muted); font-size: 12px; padding: 6px 0; }
  209. /* AI progress stepper */
  210. .ai-progress-bar { display: flex; gap: 4px; margin-bottom: 16px; flex-wrap: wrap; }
  211. .ai-progress-step {
  212. flex: 1; min-width: 100px; padding: 8px 10px; border-radius: 6px;
  213. background: #F7FAFC; border: 1px solid var(--border); text-align: center;
  214. font-size: 11px; color: var(--text-muted); transition: all 0.3s; position: relative;
  215. }
  216. .ai-progress-step.running {
  217. background: #EBF8FF; border-color: var(--accent); color: var(--accent);
  218. animation: ai-progress-pulse 1.5s ease-in-out infinite;
  219. }
  220. .ai-progress-step.done { background: #F0FFF4; border-color: var(--success); color: var(--success); }
  221. .ai-progress-step .step-icon { font-size: 16px; display: block; margin-bottom: 2px; }
  222. @keyframes ai-progress-pulse {
  223. 0%, 100% { box-shadow: 0 0 0 0 rgba(49,130,206,0.3); }
  224. 50% { box-shadow: 0 0 0 6px rgba(49,130,206,0); }
  225. }
  226. /* AI log */
  227. .q-ai-log { font-family: "SF Mono", Consolas, monospace; line-height: 1.7; }
  228. .q-ai-log span { display: block; animation: fadeIn .3s; }
  229. .q-ai-log .b { color: #63B3ED; }
  230. .q-ai-log .w { color: #F6E05E; }
  231. .q-ai-log .e { color: #FC8181; }
  232. /* Material tier cards */
  233. .tier-card {
  234. border: 2px solid var(--border); border-radius: var(--radius-lg); padding: 14px 18px;
  235. cursor: pointer; background: #fff; min-width: 180px; text-align: center; transition: all 0.2s;
  236. }
  237. .tier-card:hover { border-color: var(--accent); }
  238. .tier-card.selected { border-color: var(--accent); background: #EBF8FF; box-shadow: 0 0 0 3px rgba(49,130,206,0.15); }
  239. .tier-card .tier-name { font-weight: 700; color: var(--text); font-size: 14px; }
  240. .tier-card .tier-price { font-size: 15px; font-weight: 700; color: var(--primary); margin-top: 4px; }
  241. .tier-card .tier-note { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
  242. /* Quote list */
  243. .quote-list-table tr { transition: background 0.15s; }
  244. .quote-list-table tbody tr:hover { background: #F7FAFC; }
  245. @media print {
  246. .header, .steps-bar, .btn-row, .header-nav, #q-ai-float-block, .no-print { display: none !important; }
  247. body { background: #fff; }
  248. .card { box-shadow: none; border: 1px solid #ddd; }
  249. }
  250. </style>
  251. </head>
  252. <body>
  253. <!-- HEADER -->
  254. <div class="header">
  255. <div class="header-logo">🏠 <span>AI</span> 快速报价系统 <span style="font-size:11px;opacity:0.7;margin-left:8px;">完整规则手册版</span></div>
  256. <div class="header-nav">
  257. <button onclick="switchView('quotes')" id="nav-quotes">📋 报价管理</button>
  258. <button onclick="switchView('editor')" id="nav-editor" class="active">✏️ 报价编辑</button>
  259. <button onclick="switchView('rules')" id="nav-rules">⚙️ 规则配置</button>
  260. </div>
  261. </div>
  262. <div class="container">
  263. <!-- ======================== VIEW: 报价单管理 ======================== -->
  264. <div id="view-quotes" class="fade-in hidden">
  265. <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
  266. <h2 style="color:var(--primary);">📋 报价单管理</h2>
  267. <button class="btn btn-primary btn-lg" onclick="qNewQuote()">+ 新建报价单</button>
  268. </div>
  269. <div class="estimate-card">
  270. <div style="min-width:160px;">
  271. <div class="est-label">🏠 当前报价总价区间(按所选材质档)</div>
  272. <div class="est-range" id="global-est-range">¥0 ~ ¥0</div>
  273. <div class="est-mid" id="global-est-mid">参考总价: ¥0</div>
  274. </div>
  275. <div class="est-slider">
  276. <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">
  277. <span style="font-size:12px;opacity:0.7;">浮动率微调(当前单价 = 最低 + (最高−最低)×(浮动率−10%)÷5%)</span>
  278. <span class="float-badge" style="background:rgba(255,255,255,0.2);color:#fff;" id="global-float-badge">10%</span>
  279. </div>
  280. <input type="range" id="global-float-slider" min="10" max="15" value="15" step="0.1" oninput="qSetAiFloat(this.value)">
  281. </div>
  282. </div>
  283. <div class="card">
  284. <table class="quote-list-table">
  285. <thead><tr><th>名称</th><th>保存时间</th><th>材质档</th><th>总价区间</th><th>精度</th><th style="width:140px;">操作</th></tr></thead>
  286. <tbody id="quote-table-body"></tbody>
  287. </table>
  288. <div id="quote-empty" style="text-align:center;padding:36px;color:var(--text-muted);font-size:13px;">暂无报价单。点击右上角「+ 新建报价单」上传图纸开始 AI 识别报价。</div>
  289. </div>
  290. </div>
  291. <!-- ======================== VIEW: 报价编辑(四步向导) ======================== -->
  292. <div id="view-editor" class="fade-in">
  293. <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;padding:12px 16px;background:#F7FAFC;border-radius:var(--radius-lg);border:1px solid var(--border);">
  294. <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
  295. <button class="btn btn-outline btn-sm" onclick="backToList()" style="display:inline-flex;align-items:center;gap:4px;">💾 保存草稿返回列表</button>
  296. <span style="color:var(--text-muted);font-size:13px;">/</span>
  297. <h3 id="editor-title" style="color:var(--primary);margin:0;font-size:16px;">新建报价单</h3>
  298. <span id="editor-step-breadcrumb" style="font-size:12px;color:#fff;background:linear-gradient(135deg,var(--primary),#5A67D8);padding:3px 12px;border-radius:12px;font-weight:600;">步骤 1/4 · 上传资料</span>
  299. </div>
  300. <div style="display:flex;gap:8px;align-items:center;">
  301. <span style="font-size:12px;color:var(--text-muted);">💡 四步向导:上传平面户型图/CAD图 → AI解析(图纸类型→红框判定)→ 自动计价 → 报价单</span>
  302. </div>
  303. </div>
  304. <!-- Steps -->
  305. <div class="steps-bar" id="steps-bar" style="margin-bottom:20px;">
  306. <div class="step-dot active" data-step="1" onclick="qOpenEditorStep(1)"><span class="step-num">1</span>上传平面户型图/CAD图</div>
  307. <div class="step-dot" data-step="2" onclick="qOpenEditorStep(2)"><span class="step-num">2</span>AI解析</div>
  308. <div class="step-dot" data-step="3" onclick="qOpenEditorStep(3)"><span class="step-num">3</span>自动计价</div>
  309. <div class="step-dot" data-step="4" onclick="qOpenEditorStep(4)"><span class="step-num">4</span>报价单</div>
  310. </div>
  311. <!-- Step 1: 上传资料 -->
  312. <div class="card step-content" id="step-1">
  313. <div class="card-title">📁 第一步 · 上传平面户型图 / CAD图</div>
  314. <div style="font-size:13px;color:var(--text-muted);margin-top:-8px;margin-bottom:12px;">
  315. 第一步只负责上传图纸:彩色平面户型图(JPG/PNG/WEBP)或 CAD 导出的黑白线图。上传后自动进入第二步,AI 先判定「户型图 / CAD图」,再判定「无红框 / 有红框」,并执行对应规则。
  316. </div>
  317. <!-- 层高手动输入 -->
  318. <div style="background:linear-gradient(135deg,#FFF5F5 0%,#FFFAF0 100%);border:2px solid #FC8181;border-radius:12px;padding:16px 20px;margin-bottom:14px;">
  319. <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
  320. <span style="font-size:24px;">📏</span>
  321. <div style="flex:1;min-width:200px;">
  322. <div style="font-weight:700;font-size:15px;color:#C53030;">
  323. 层高(所有柜体均为到顶高柜)
  324. <span style="background:#ED8936;color:#fff;font-size:10px;padding:2px 8px;border-radius:10px;margin-left:8px;font-weight:600;">人工填写</span>
  325. </div>
  326. <div style="font-size:12px;color:#742A2A;margin-top:2px;">🏠 所有柜体均为到顶高柜,柜体高度 = 层高(默认 2.5m,可手动修改)。柜体面积 = 最长边 × 层高。</div>
  327. </div>
  328. <div style="display:flex;align-items:center;gap:8px;">
  329. <input type="text" id="q-floor-height" value="2.5" placeholder="例如 2.5" inputmode="decimal" oninput="qFhChanged()"
  330. style="width:140px;padding:10px 14px;border:2px solid #FC8181;border-radius:8px;font-size:18px;font-weight:700;text-align:center;color:#C53030;background:#fff;">
  331. <span style="font-size:16px;font-weight:600;color:#C53030;">米(m)</span>
  332. </div>
  333. </div>
  334. </div>
  335. <!-- 上传状态 -->
  336. <div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;font-size:12px;">
  337. <span style="padding:4px 10px;border-radius:12px;background:#EDF2F7;color:var(--text-muted);" id="q-status-drawing">⬜ 图纸:未上传</span>
  338. </div>
  339. <input type="file" id="q-file-unified" accept="image/*,.pdf" style="display:none" onchange="qHandleUnified(this)">
  340. <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
  341. <!-- 左:图纸统一上传 -->
  342. <div style="border:1px solid var(--border);border-radius:var(--radius-lg);padding:16px;background:#FAFBFC;">
  343. <div style="margin-bottom:12px;display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;">
  344. <div style="font-size:14px;font-weight:600;color:var(--primary);">📐 图纸上传(户型图 / CAD 一个框兼容)</div>
  345. <button id="q-recog-config-btn" onclick="qToggleRecogConfig()" style="display:inline-flex;align-items:center;gap:6px;background:#fff;border:1px solid var(--border);border-radius:8px;padding:6px 14px;font-size:12px;font-weight:600;color:var(--text-muted);cursor:pointer;">⚙️ 识别配置</button>
  346. </div>
  347. <!-- 识别配置面板 -->
  348. <div id="q-recog-config-panel" style="display:none;margin-bottom:12px;border:1px solid #90CDF4;background:#F8FBFF;border-radius:10px;padding:14px;">
  349. <div style="font-size:13px;font-weight:700;color:#2C5282;margin-bottom:10px;">🎯 识别配置 · 标准规则(完整规则手册内置)</div>
  350. <div style="display:grid;gap:10px;font-size:13px;color:#4A5568;line-height:1.9;">
  351. <div style="background:#FFF;border:1px solid #E2E8F0;border-radius:8px;padding:10px 14px;"><b style="color:#2D3748;">1、图像比例推算:</b>优先使用图纸标注;无标注时以厨房台面深度 600mm 为全图锚点自动推算比例(误差 ±5-8%)。</div>
  352. <div style="background:#FFF;border:1px solid #E2E8F0;border-radius:8px;padding:10px 14px;"><b style="color:#2D3748;">2、尺寸来源优先级:</b>CAD矢量(精确值 ±0.1-1%) &gt; 红框+标注(±2-5%,标注单位与600mm锚点交叉推断) &gt; 600mm锚点估算(±5-8%),UI 必须标注精度等级。</div>
  353. <div style="background:#FFF;border:1px solid #E2E8F0;border-radius:8px;padding:10px 14px;"><b style="color:#2D3748;">3、柜体/护墙板面积:</b>最长边 × 层高 = 投影面积(均为到顶高柜,层高默认2.5m可改);护墙板与柜体同公式。</div>
  354. <div style="background:#FFF;border:1px solid #E2E8F0;border-radius:8px;padding:10px 14px;"><b style="color:#EF4444;">4、红框规则:</b>有红框时柜体边界直接取图纸红框,系统不二次红框标注/抓取;平面图斜线填充一律不自动识别为护墙板(规则#14)。</div>
  355. </div>
  356. </div>
  357. <div id="q-upload-zone-unified" class="upload-area" onclick="document.getElementById('q-file-unified').click()" style="padding:16px;margin-bottom:10px;">
  358. <div class="icon">📐</div>
  359. <div class="text">点击或拖拽上传户型图 / CAD图纸</div>
  360. <div class="types">户型图支持 JPG、PNG、WEBP;CAD支持 DWG、DXF、PDF | 上传后自动进入 AI 解析</div>
  361. </div>
  362. <div style="display:flex;gap:6px;flex-wrap:wrap;">
  363. <button class="btn btn-outline btn-sm" onclick="event.stopPropagation();qUseMockDrawing(false)">📋 示例户型图(L型厨房+红框)</button>
  364. <button class="btn btn-outline btn-sm" onclick="event.stopPropagation();qUseMockDrawing(true)">📋 示例户型图(U型厨房+红框)</button>
  365. <button class="btn btn-outline btn-sm" onclick="event.stopPropagation();qUseMockDrawing(false,true)">📋 示例CAD图(黑白线图)</button>
  366. </div>
  367. </div>
  368. <!-- 右:图纸识别标准规则设置 -->
  369. <div style="border:1px solid var(--border);border-radius:var(--radius-lg);padding:16px;background:#FAFBFC;">
  370. <div style="margin-bottom:12px;">
  371. <div style="font-size:14px;font-weight:600;color:var(--primary);">🎨 图纸识别标准规则设置(户型图 / CAD 通用)</div>
  372. </div>
  373. <div style="font-size:12px;color:var(--text-muted);margin-bottom:12px;">平面户型图与CAD图纸可统一用红色框标注范围,AI抓取红框边界做完整测量;无红框时 AI 全自动识别;尺寸标注统一为红色(红箭头+红色数字自动 OCR 读数)。</div>
  374. <div style="font-size:12px;font-weight:600;color:#2D3748;margin-bottom:8px;">标注颜色(红框圈注 / 红色尺寸标注):</div>
  375. <div style="margin-bottom:14px;">
  376. <select style="width:100%;" disabled title="红框=柜体圈注;红色箭头/红色数字=尺寸标注(OCR 自动读数)">
  377. <option value="red" selected>红色框(柜体圈注)+红色尺寸标注(箭头+数字 OCR 自动读数)· 隐式参照物为厨房台面深度600mm</option>
  378. </select>
  379. </div>
  380. <div style="padding:10px 12px;background:#F0FFF4;border:1px solid #9AE6B4;border-radius:8px;font-size:12px;color:#276749;line-height:1.8;">
  381. 当前规则:上传图纸后 AI 自动执行 —— ① 定位厨房(L/U型);② 以台面深度 <b>600mm</b> 为全图锚点推算比例;③ 识别各柜体并按 最长边×层高 计算投影面积(均为到顶高柜)。
  382. </div>
  383. </div>
  384. </div>
  385. <div class="btn-row">
  386. <div></div>
  387. <button class="btn btn-primary btn-lg" id="q-btn-analyze" disabled onclick="qStartAnalysis()">下一步:AI解析 →</button>
  388. </div>
  389. </div>
  390. <!-- Step 2: AI解析 -->
  391. <div class="card step-content hidden" id="step-2">
  392. <div class="card-title">🤖 第二步 · AI解析 — 图纸类型 → 红框判定 → 尺寸计算</div>
  393. <div style="display:flex;gap:8px;margin:-2px 0 10px;">
  394. <button class="btn btn-outline btn-sm" onclick="qOpenEditorStep(1);document.getElementById('q-file-unified').click()">🔄 更换图纸</button>
  395. <button class="btn btn-outline btn-sm" style="color:#C53030;border-color:#FED7D7;background:#FFF5F5;" onclick="qClearDrawing()">🗑 删除图纸</button>
  396. </div>
  397. <div style="font-size:13px;color:var(--text-muted);margin-top:-8px;margin-bottom:12px;">判定①:上传的是平面户型图还是CAD图?判定②(户型图):无红框还是有红框(无红框 &gt; 有红框)。再按各分支对应规则识别尺寸并自动计算。</div>
  398. <!-- 层高信息(可编辑,与第一步同步) -->
  399. <div style="background:#F0FFF4;border:1px solid #9AE6B4;border-radius:10px;padding:12px 18px;margin-bottom:14px;display:flex;align-items:center;gap:10px;">
  400. <span style="font-size:20px;">📏</span>
  401. <div style="flex:1;display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
  402. <span style="font-weight:600;font-size:14px;color:#276749;">层高:</span>
  403. <input type="text" id="q-floor-height2" value="2.5" placeholder="例如 2.5" inputmode="decimal" oninput="qFhChanged(this)"
  404. style="width:90px;padding:6px 10px;border:2px solid #9AE6B4;border-radius:8px;font-size:16px;font-weight:700;text-align:center;color:#276749;background:#fff;">
  405. <span style="font-size:14px;font-weight:600;color:#276749;">米(m)</span>
  406. <span style="font-size:12px;color:#48BB78;margin-left:8px;">(所有柜体均为到顶高柜:柜高=层高,面积=最长边×层高;可手动修改)</span>
  407. </div>
  408. </div>
  409. <!-- 决策链 -->
  410. <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:10px;font-size:12px;">
  411. <span style="background:#EBF8FF;border:1px solid #90CDF4;color:#2B6CB0;border-radius:14px;padding:4px 12px;">判定① 图纸类型:<b id="dc-type">—</b></span>
  412. <span style="color:var(--text-muted);">→</span>
  413. <span style="background:#FFF5F5;border:1px solid #FED7D7;color:#C53030;border-radius:14px;padding:4px 12px;">判定② 红框:<b id="dc-red">—</b></span>
  414. <span style="color:var(--text-muted);">→</span>
  415. <span style="background:#F0FFF4;border:1px solid #9AE6B4;color:#276749;border-radius:14px;padding:4px 12px;">执行规则:<b id="dc-rule">—</b></span>
  416. </div>
  417. <div style="font-size:11px;color:var(--text-muted);margin-bottom:12px;line-height:1.7;">
  418. 规则对应:无红框规则 = 全图自动识别 + 600mm锚点(±5-8%)|有红框规则 = 直接取图纸红框为柜体边界+标注+600mm锚点交叉推断尺寸单位(±2-5%,不二次标注/抓取)|CAD规则 = 矢量直读(±0.1-1%)|规则#14 护墙板不自动识别
  419. </div>
  420. <div style="display:flex;gap:16px;flex-wrap:wrap;align-items:stretch;">
  421. <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;">
  422. <div class="drop-hint">📥 松开即更换户型图(JPG / PNG / WEBP,自动重新识别)</div>
  423. <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;">
  424. <span>📐 图纸识别结果叠加(<span style="color:#805AD5;">厨房</span> / <span style="color:#3182CE;">柜体</span>;图纸自带红框不二次叠加)</span>
  425. <span style="display:flex;align-items:center;gap:8px;">
  426. <button class="btn btn-outline btn-sm" onclick="document.getElementById('q-file-unified').click()" title="点击选择新图纸,或直接将图纸拖拽到本区域">🔄 更换户型图</button>
  427. <span id="q-ai-scene-chip" style="font-size:11px;font-weight:600;color:#2B6CB0;">等待识别…</span>
  428. </span>
  429. </div>
  430. <canvas id="q-ai-canvas" style="width:100%;display:block;background:#fff;"></canvas>
  431. </div>
  432. <div style="flex:2;min-width:280px;display:flex;flex-direction:column;">
  433. <div class="ai-progress-bar">
  434. <div class="ai-progress-step" id="q-ai-prog-0"><span class="step-icon">🖼️</span>判定①:户型图 or CAD图</div>
  435. <div class="ai-progress-step" id="q-ai-prog-1"><span class="step-icon">🟥</span>判定②:无红框 or 有红框(户型图)</div>
  436. <div class="ai-progress-step" id="q-ai-prog-2"><span class="step-icon">📦</span>按对应规则识别尺寸并计算</div>
  437. </div>
  438. <div class="q-ai-log" id="q-ai-log" style="flex:1;background:#1A365D;color:#E2E8F0;border-radius:8px;padding:12px 14px;font-size:12px;min-height:200px;max-height:340px;overflow-y:auto;"></div>
  439. </div>
  440. </div>
  441. <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;">
  442. 🎯 红色标注箭头:长度已检测,标注值 = <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>
  443. </div>
  444. <div id="q-ai-results" style="display:none;gap:16px;flex-wrap:wrap;margin-top:16px;align-items:stretch;">
  445. <div style="flex:3;min-width:380px;border:1px solid var(--border);border-radius:8px;background:#fff;overflow:hidden;">
  446. <div style="padding:8px 12px;background:#EBF8FF;border-bottom:1px solid #90CDF4;display:flex;justify-content:space-between;font-size:13px;font-weight:600;color:#2B6CB0;">
  447. <span>🗄️ 识别到的柜体(尺寸 / 投影面积)</span><span id="q-ai-cab-count">0 个</span>
  448. </div>
  449. <div id="q-ai-cab-body" style="padding:10px 14px;"></div>
  450. </div>
  451. <div style="flex:2;min-width:280px;border:1px solid var(--border);border-radius:8px;background:#fff;overflow:hidden;">
  452. <div style="padding:8px 12px;background:#FAF5FF;border-bottom:1px solid #D6BCFA;display:flex;justify-content:space-between;font-size:13px;font-weight:600;color:#6B46C1;">
  453. <span>🍳 厨房识别结果(尺寸 / 总长度)</span><span id="q-ai-kit-type">—</span>
  454. </div>
  455. <div id="q-ai-kit-body" style="padding:10px 14px;"></div>
  456. </div>
  457. </div>
  458. <div class="btn-row">
  459. <button class="btn btn-outline" onclick="qOpenEditorStep(1)">← 返回第一步:上传平面户型图/CAD图</button>
  460. <button class="btn btn-primary btn-lg" id="q-step2-next-btn" onclick="qOpenEditorStep(3)" disabled>下一步:自动计价 →</button>
  461. </div>
  462. </div>
  463. <!-- Step 3: 自动计价 -->
  464. <div class="card step-content hidden" id="step-3">
  465. <div class="card-title">🧮 第三步 · 自动计价 — 第二步尺寸 × 计价规则</div>
  466. <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:14px;">
  467. <span class="badge na" id="precBadge">精度:待识别</span>
  468. <span class="chip n" id="sceneChip">场景:—</span>
  469. <span style="font-size:12px;color:var(--text-muted);" id="scaleInfo">比例未建立</span>
  470. </div>
  471. <div style="font-size:11px;color:var(--text-muted);margin-bottom:12px;line-height:1.7;">本步对应规则:投影面积 = 最长边×层高(到顶高柜,层高默认2.5m可改)|单价默认 = 所选材质档最高单价(双饰面3500),各行可手动改|厨房总长 L型 = 长+短−1.6、U型 = 长+长+短−2.2|增值项 = 各柜体下拉勾选(预设源自 6.18 欧阳路预估报价表;门板补差价 / 厨房地柜 / 吊柜默认后台保留,可在规则配置加入)|🔒 生态澳松板1852 / 铝格栅板2532</div>
  472. <!-- 柜体/厨房/护墙板 合并表 -->
  473. <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;flex-wrap:wrap;gap:8px;">
  474. <b style="color:var(--primary);">🗄️ 柜体 / 厨房 / 护墙板(柜体面积 = 最长边 × 层高;厨房 = 总长度 × 单价)</b>
  475. <div style="display:flex;gap:6px;">
  476. <button class="btn btn-outline btn-sm" id="addKit">➕ 添加厨房</button>
  477. <button class="btn btn-outline btn-sm" id="addCab">➕ 添加柜体</button>
  478. <button class="btn btn-outline btn-sm" id="addPanel">➕ 添加护墙板(手动)</button>
  479. </div>
  480. </div>
  481. <div class="ai-notice">⚠️ 规则#14:平面图上的斜线填充一律不自动识别为护墙板;如需护墙板请手动添加,与柜体同公式(最长边×层高)。厨房高=最短边,总长度:L型=长+短−1.6,U型=长+长+短−2.2。</div>
  482. <div style="overflow-x:auto;margin-bottom:14px;">
  483. <table>
  484. <thead><tr><th>名称</th><th class="text-right">宽(最长边)(m)</th><th class="text-right">高(m)</th><th class="text-right">面积(㎡)</th><th class="text-right">总长度(m)</th><th class="text-right">单价(元)</th><th class="text-right">总价(元)</th><th></th></tr></thead>
  485. <tbody id="objBody"></tbody>
  486. </table>
  487. </div>
  488. <!-- 比例补充 -->
  489. <div id="fbRow" style="display:none;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:14px;font-size:13px;background:#FFF5F5;border:1px solid #FED7D7;border-radius:8px;padding:10px 14px;">
  490. ⚠️ 未能自动建立比例,请提供一个已知尺寸:
  491. <input type="number" id="fbPx" placeholder="像素数" style="width:90px"> px =
  492. <input type="number" id="fbMm" placeholder="毫米数" style="width:90px"> mm
  493. <button class="btn btn-outline btn-sm" id="btnFb">建立比例</button>
  494. </div>
  495. <div class="price-grid">
  496. <div class="price-card floor"><div class="label">基础项小计(柜体+厨房+护墙板)</div><div class="value" id="baseTotal">¥0</div></div>
  497. <div class="price-card ceiling"><div class="label">增值项小计(各柜体勾选)</div><div class="value" id="valueTotal">¥0</div></div>
  498. <div class="price-card grand"><div class="label">参考总计(单价默认材质档最高,可手动调)</div><div class="value" id="grandTotal">¥0</div></div>
  499. </div>
  500. <div class="btn-row">
  501. <button class="btn btn-outline" onclick="qOpenEditorStep(2)">← 上一步:AI解析</button>
  502. <button class="btn btn-primary btn-lg" onclick="qOpenEditorStep(4)">生成报价单 →</button>
  503. </div>
  504. </div>
  505. <!-- Step 4: 报价单 -->
  506. <div class="card step-content hidden" id="step-4">
  507. <div class="card-title">📋 第四步 · 报价单 — 整合第一~三步生成详细完整报价单</div>
  508. <div style="font-size:13px;color:var(--text-muted);margin-top:-8px;margin-bottom:16px;">整合第一步图纸、第二步识别尺寸与精度、第三步计价结果生成报价单;AI辅助生成的预估价格,仅供参考;增值项列于所属空间下方、按空间归为一类</div>
  509. <div style="text-align:center;padding:16px;border-bottom:2px solid var(--primary);margin-bottom:16px;">
  510. <h2 style="font-size:22px;font-weight:700;color:var(--text);margin-bottom:8px;" id="q-quote-title">项目预算报价单</h2>
  511. <div style="font-size:13px;color:var(--text-muted);margin-bottom:8px;" id="q-quote-addr"></div>
  512. <div style="font-size:12px;color:var(--text-muted);display:flex;gap:10px;justify-content:center;align-items:center;flex-wrap:wrap;">
  513. <span id="q-quote-meta"></span>
  514. <span class="badge na" id="q-quote-prec">精度:待识别</span>
  515. </div>
  516. </div>
  517. <!-- 三档材质选择横幅 -->
  518. <div id="q-ai-range-banner" style="text-align:center;margin-top:16px;margin-bottom:16px;">
  519. <div style="border-top:3px solid var(--primary);margin-bottom:18px;"></div>
  520. <div style="display:inline-block;background:linear-gradient(135deg,#3182CE,#2B6CB0);color:#fff;padding:8px 24px;border-radius:20px;font-size:13px;font-weight:600;margin-bottom:14px;">📦 投影面积计价模式 · 三档材质单价(当前单价 = 最低 + (最高−最低)×(浮动率−10%)÷5%)</div>
  521. <div style="font-size:12px;color:#4A5568;margin-bottom:10px;">选择柜体材质(点击选择,总价按所选材质单价自动调整):</div>
  522. <div id="q-ai-tier-selector" style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap;margin-bottom:16px;"></div>
  523. </div>
  524. <!-- 浮动率微调(仅内部可见) -->
  525. <div id="q-ai-float-block" style="background:#F7FAFC;border-radius:8px;padding:14px 18px;margin-top:16px;">
  526. <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
  527. <span style="font-weight:600;">📊 浮动率微调</span>
  528. <span class="tag tag-internal">👁 仅内部可见</span>
  529. </div>
  530. <div class="float-slider-group" style="margin-bottom:8px;">
  531. <span style="font-size:13px;color:var(--text-muted);white-space:nowrap;">调整: 10% −</span>
  532. <input type="range" id="q-ai-float-slider" min="10" max="15" value="15" step="0.1" oninput="qOnAiFloatChange(this.value)" style="width:180px;">
  533. <span style="font-size:13px;color:var(--text-muted);white-space:nowrap;">+ 15%</span>
  534. <span class="float-badge" id="q-ai-float-badge">10%</span>
  535. </div>
  536. <div class="float-preset">
  537. <button data-aifp="10" onclick="qSetAiFloat(10)">10%</button>
  538. <button data-aifp="11" onclick="qSetAiFloat(11)">11%</button>
  539. <button data-aifp="12" onclick="qSetAiFloat(12)">12%</button>
  540. <button data-aifp="13" onclick="qSetAiFloat(13)">13%</button>
  541. <button data-aifp="14" onclick="qSetAiFloat(14)">14%</button>
  542. <button data-aifp="15" onclick="qSetAiFloat(15)">15%</button>
  543. </div>
  544. <div id="q-ai-float-result" style="margin-top:10px;font-size:13px;color:#2C5282;background:#EBF8FF;border:1px solid #BEE3F8;border-radius:6px;padding:8px 12px;"></div>
  545. </div>
  546. <!-- 总价汇总 -->
  547. <div style="text-align:center;padding:20px;background:linear-gradient(135deg,#F0FFF4,#EBF8FF);border:2px solid #9AE6B4;border-radius:12px;margin-bottom:16px;">
  548. <div style="font-size:13px;color:#276749;margin-bottom:6px;">项目报价总计(基础项投影面积 + 增值项,当前单价)</div>
  549. <div style="display:flex;align-items:baseline;justify-content:center;gap:14px;">
  550. <div style="font-size:32px;font-weight:700;color:var(--primary);" id="q-grand-total-value">¥0</div>
  551. <div style="font-size:13px;color:#276749;font-weight:600;" id="q-grand-total-area"></div>
  552. </div>
  553. <div style="font-size:12px;color:var(--text-muted);margin-top:4px;" id="q-grand-total-note">上传图纸并完成 AI 识别后自动计算</div>
  554. </div>
  555. <!-- 报价明细:按空间归组,增值项列于所属空间下方 -->
  556. <b style="color:var(--primary);display:block;margin-bottom:8px;">📄 报价明细(柜体 / 厨房 / 护墙板 · 增值项列于所属空间下方)</b>
  557. <div style="overflow-x:auto;margin-bottom:16px;">
  558. <table>
  559. <thead><tr><th>名称</th><th class="text-right">宽(m)</th><th class="text-right">高(m)</th><th class="text-right">面积/总长度</th><th class="text-right">单价(元)</th><th class="text-right">总价(元)</th></tr></thead>
  560. <tbody id="q-quote-base-body"><tr><td colspan="6" class="note">暂无</td></tr></tbody>
  561. </table>
  562. </div>
  563. <!-- 后台保留 -->
  564. <div style="background:#FFFBEB;border:1px solid #F6E05E;border-radius:8px;padding:12px 16px;margin-bottom:16px;font-size:12px;color:#744210;display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
  565. <span class="tag tag-internal">🔒 后台保留 · 仅内部可见</span>
  566. 柜体材料价:生态澳松板 ¥1,852/㎡ · 铝格栅板 ¥2,532/㎡(不对外展示,仅用于内部成本核算)
  567. </div>
  568. <div style="text-align:center;padding:12px;background:#F7FAFC;border-radius:6px;margin-top:16px;font-size:12px;color:var(--text-muted);" id="q-step5-disclaimer">
  569. ⚠️ 声明:本报价为 AI 辅助生成的预估价格,最终成交价以实际量尺和确认为准。
  570. </div>
  571. <div class="btn-row">
  572. <button class="btn btn-outline" onclick="qOpenEditorStep(3)">← 上一步:自动计价</button>
  573. <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
  574. <select id="q-brand-select" title="导出报价单的标题与门店地址" style="padding:8px 10px;border:1px solid var(--primary);border-radius:8px;color:var(--primary);font-weight:600;background:#fff;font-size:13px;" onchange="S.brand=+this.value;applyBrand();">
  575. <option value="0">万骊全屋定制预算表 · 万骊总部旗舰店</option>
  576. <option value="1">华拓全屋定制预算表 · 华拓总部店</option>
  577. </select>
  578. <button class="btn btn-outline" onclick="qSaveQuote()">💾 存入报价单列表</button>
  579. <button class="btn btn-outline" onclick="qExportExcel()">📊 导出为Excel表格</button>
  580. </div>
  581. </div>
  582. </div>
  583. </div>
  584. <!-- ======================== VIEW: 规则配置 ======================== -->
  585. <div id="view-rules" class="fade-in hidden">
  586. <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;flex-wrap:wrap;gap:10px;">
  587. <h2 style="color:var(--primary);">⚙️ 规则配置(完整规则手册内置)</h2>
  588. <button class="btn btn-outline btn-sm" onclick="switchView('editor')">← 返回报价编辑</button>
  589. </div>
  590. <div class="tabs">
  591. <div class="tab active" onclick="switchRuleTab('formula')">📐 计价公式</div>
  592. <div class="tab" onclick="switchRuleTab('wardrobe')">👔 增值项配置</div>
  593. <div class="tab" onclick="switchRuleTab('kitchen')">🍳 橱柜类(12种门板)</div>
  594. <div class="tab" onclick="switchRuleTab('ban')">🚫 14条禁止事项</div>
  595. </div>
  596. <div class="card rule-tab-content" id="rule-tab-formula">
  597. <div class="card-title">📐 三档材质与浮动公式</div>
  598. <table style="margin-bottom:14px;">
  599. <thead><tr><th>材质档</th><th class="text-right">最低单价(元/㎡)</th><th class="text-right">最高单价(元/㎡)</th></tr></thead>
  600. <tbody id="rule-table-mats"></tbody>
  601. </table>
  602. <div style="background:#F0FFF4;border:1px solid #9AE6B4;border-radius:8px;padding:12px 16px;font-size:13px;color:#276749;line-height:2;">
  603. <b>浮动公式:</b>当前单价 = 最低单价 + (最高单价 − 最低单价) × (浮动率 − 10%) ÷ 5%(浮动率范围 10%~15%)<br>
  604. <b>柜体/护墙板面积:</b>最长边 × 层高(均为到顶高柜,柜高=层高,默认2.5m可修改;护墙板与柜体同公式,规则#8)<br>
  605. <b>厨房L型:</b>长 + 短 − 0.6 − 1.0(最短边≤2m判定为L型)<br>
  606. <b>厨房U型:</b>A + B + 短 − 0.6 − 0.6 − 1.0
  607. </div>
  608. <div style="background:#FFFBEB;border:1px solid #F6E05E;border-radius:8px;padding:12px 16px;margin-top:12px;font-size:12px;color:#744210;">
  609. <span class="tag tag-internal">🔒 后台保留</span> 柜体材料价:生态澳松板 ¥1,852/㎡ · 铝格栅板 ¥2,532/㎡(锁定,不参与对外报价,仅内部成本核算)
  610. </div>
  611. </div>
  612. <div class="card rule-tab-content hidden" id="rule-tab-wardrobe">
  613. <div class="card-title">👔 增值项配置(预设来源:6.18 欧阳路预估报价表 · 单价取表内值)</div>
  614. <div style="font-size:12px;color:var(--text-muted);margin-bottom:8px;line-height:1.7;">勾选「加入下拉」后,该增值项才会出现在步骤3对应柜体的下拉框中供勾选计价;未勾选的仅后台保留、不参与计价(门板补差价、厨房地柜/吊柜默认后台保留),需要时随时加入。</div>
  615. <div style="overflow-x:auto;">
  616. <table>
  617. <thead><tr><th>#</th><th>项目</th><th class="text-right">单价</th><th>单位</th><th>适用范围</th><th style="width:80px;">加入下拉</th></tr></thead>
  618. <tbody id="rule-table-wardrobe"></tbody>
  619. </table>
  620. </div>
  621. </div>
  622. <div class="card rule-tab-content hidden" id="rule-tab-kitchen">
  623. <div class="card-title">🍳 橱柜门板配置(12 种 · 地柜/吊柜联动价)</div>
  624. <div style="overflow-x:auto;margin-bottom:18px;">
  625. <table>
  626. <thead><tr><th>#</th><th>类型</th><th>名称</th><th>厚度/说明</th><th class="text-right">吊柜(元/m)</th><th class="text-right">地柜(元/m)</th></tr></thead>
  627. <tbody id="rule-table-kitchen"></tbody>
  628. </table>
  629. </div>
  630. <div class="card-title">🔧 橱柜固定项与配件</div>
  631. <div style="overflow-x:auto;">
  632. <table>
  633. <thead><tr><th>项目</th><th class="text-right">单价</th><th>单位</th></tr></thead>
  634. <tbody id="rule-table-kitchen-fix"></tbody>
  635. </table>
  636. </div>
  637. </div>
  638. <div class="card rule-tab-content hidden" id="rule-tab-ban">
  639. <div class="card-title">🚫 14 条绝对禁止事项</div>
  640. <div style="overflow-x:auto;">
  641. <table>
  642. <thead><tr><th style="width:40px;">#</th><th>禁止事项</th><th>正确做法</th></tr></thead>
  643. <tbody id="rule-table-ban"></tbody>
  644. </table>
  645. </div>
  646. </div>
  647. </div>
  648. </div><!-- /container -->
  649. <script>
  650. "use strict";
  651. /* ========== 规则数据(完整规则手册) ========== */
  652. const MATS=[{n:"双饰面",min:3000,max:3500},{n:"PET",min:4500,max:5000},{n:"混油",min:6000,max:7000}];
  653. /* 增值项目录(单价/单位/备注 取自 6.18 欧阳路预估报价表;kit=厨房适用 both=两者适用 off=默认仅后台保留不加入下拉) */
  654. const EXTRA=[
  655. {id:"dd",n:"灯带",p:386,u:"元/m",sc:"3000K暖光灯"},
  656. {id:"byq",n:"变压器+开关",p:536,u:"元/套",sc:"功能五金"},
  657. {id:"ztq",n:"自弹器",p:115,u:"元/个",sc:"海蒂诗自弹器",both:1},
  658. {id:"jgb",n:"见光板",p:1138,u:"元/㎡",sc:"双饰面",both:1,c:"area"},
  659. {id:"jl",n:"铰链",p:40,u:"元/个",sc:"奥地利百隆阻尼铰链"},
  660. {id:"hqb",n:"护墙板",p:1852,u:"元/㎡",sc:"双饰面",c:"area"},
  661. {id:"ls",n:"拉手",p:226,u:"元/个",sc:"金属拉手"},
  662. {id:"ct",n:"抽屉",p:1300,u:"元/个",sc:"奥地利百隆导轨"},
  663. {id:"jsls",n:"金属拉手",p:225,u:"元/m",sc:"金属"},
  664. {id:"jslsL",n:"金属拉手(铝格栅)",p:680,u:"元/个",sc:"金属拉手"},
  665. {id:"yg",n:"衣杆",p:288,u:"元/m",sc:"金属"},
  666. {id:"yh",n:"圆弧",p:1560,u:"元/m",sc:"金属"},
  667. {id:"bm",n:"板门补差价",p:538,u:"元/㎡",sc:"门板补差价·后台保留",off:1,c:"area"},
  668. {id:"bl",n:"玻璃门补差价",p:1852,u:"元/㎡",sc:"门板补差价·后台保留",off:1,c:"area"},
  669. {id:"dg",n:"地柜",p:2380,u:"元/m",sc:"厨房·后台保留",kit:1,off:1,c:"over"},
  670. {id:"dg2",n:"吊柜",p:1748,u:"元/m",sc:"厨房·后台保留",kit:1,off:1,c:"over"},
  671. {id:"bx",n:"冰箱吊柜",p:2622,u:"元/m",sc:"厨房",kit:1},
  672. {id:"gg",n:"高柜",p:5950,u:"元/m",sc:"厨房",kit:1},
  673. {id:"tm",n:"台面(石英石)",p:1480,u:"元/m",sc:"厨房",kit:1},
  674. {id:"tmc",n:"台面超出部分",p:2480,u:"元/m",sc:"厨房",kit:1,c:"over"},
  675. {id:"wl",n:"碗篮",p:1268,u:"元/个",sc:"厨房",kit:1},
  676. {id:"gl",n:"锅篮",p:1628,u:"元/个",sc:"厨房",kit:1},
  677. {id:"qj",n:"切角",p:500,u:"元/个",sc:"厨房",kit:1},
  678. {id:"txp",n:"台下盆",p:500,u:"元/个",sc:"厨房",kit:1},
  679. {id:"bg",n:"包管",p:500,u:"元/个",sc:"厨房",kit:1}];
  680. /* 各柜体预设增值项(名称关键字 → 表内该项目下的增值项与默认数量) */
  681. const EXTRA_PRESETS=[
  682. ["主卧",[["jslsL",5],["ct",2],["yg",4],["ztq",2],["yh",1.8],["jl",31]]],
  683. ["次卧",[["yg",6],["jslsL",8],["ct",3],["jsls",2.4],["jgb",1.5],["jl",40]]],
  684. ["玄关",[["dd",1.58],["byq",1],["ztq",4],["jgb",1.35],["jl",12]]],
  685. ["电视柜",[["ztq",19],["jgb",0.875],["jl",55]]],
  686. ["洗衣机",[["ztq",2],["hqb",0.7],["jgb",1.5],["jl",7]]],
  687. ["展示柜",[["dd",5.34],["byq",1],["ls",2],["jgb",0.75],["jl",15],["ztq",2]]],
  688. ["阳台",[["ztq",3],["jgb",0.6],["jl",9]]],
  689. ["餐边",[["ct",3],["ls",1],["ztq",5],["jsls",3.6],["dd",3.5],["byq",1],["jgb",0.45],["jl",24]]]];
  690. const EXTRA_DEF_CAB=[["ct",1],["yg",1],["jl",4],["jgb",0.5],["dd",1],["byq",1],["ztq",2],["ls",1]];
  691. const EXTRA_DEF_KIT=[["bx",0.97],["gg",0.88],["jgb",1.5],["tm",3.21],["tmc",0.21],["wl",1],["gl",1],["ztq",4],["qj",1],["txp",1],["bg",1]];
  692. function mkExtras(name,isKit){const hit=isKit?null:EXTRA_PRESETS.find(p=>name&&name.includes(p[0]));
  693. const list=isKit?EXTRA_DEF_KIT:(hit?hit[1]:EXTRA_DEF_CAB);const o={};list.forEach(([id])=>{o[id]={on:false,v:0};});return o;/* 数量默认清零、编辑框空,可手动输入 */}
  694. 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]];
  695. const KIT_FIX=[["fridge","冰箱吊柜",2622,"元/m"],["tall","高柜",5950,"元/m"],["counter","台面(石英石)",1480,"元/m"],["over","台面超出部分",2480,"元/m"],["side","见光板",1138,"元/㎡"]];
  696. const KIT_ACC=[["碗篮",1268],["锅篮",1628],["自弹器",115],["切角",500],["台下盆",500],["包管",500]];
  697. const RULES=[["目测估算像素/毫米比例","用图纸标注(如有)或厨房台面深度600mm(自动模式)"],["只看到红框部分","完整测量整个红框边界"],["厨房尺寸目测估算","用量尺/标准图/标注/CAD数据"],["混淆W/H方向","取 max(W,H) 为沿墙长度"],["厨房按面积整体计价","分项按延米/按个计价"],["有红框时跳过AI识别","AI始终先执行"],["平面图上假装能识别护墙板","平面图只能识别柜体和厨房,护墙板需手动指定或CAD图"],["护墙板用不同公式","与柜体相同:最长边×层高=面积(到顶高柜,层高默认2.5)"],["报价单不区分基础项和增值项","必须分为两部分"],["橱柜地柜/吊柜固定死单价","支持12种门板配置下拉选择"],["使用门洞/床等不可靠隐式参照物","只保留「厨房台面深度600mm」"],["无标注时要求用户手动标注尺寸","AI全自动:600mm全图锚点,无需用户标注"],["不告知用户精度等级","UI必须标注「精确值」或「估算值」"],["平面图斜线填充误判为护墙板","一律不自动识别为护墙板"]];
  698. /* ========== 状态 ========== */
  699. /* rate 默认15 → 单价=所选材质档最高单价(双饰面3500);厨房=宽(最长边)/高(最短边)/总长度/单价 均可调 */
  700. const S={ft:"plan",redbox:false,scale:null,scale600:null,scaleSrc:"",objs:[],mat:0,rate:15,
  701. 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};
  702. /* 状态兜底:旧版存档/异常恢复可能缺失 objs 等数组字段 → 统一回退空数组,杜绝 undefined.length / undefined.forEach 崩溃 */
  703. function ensureS(){if(!Array.isArray(S.objs))S.objs=[];if(!Array.isArray(S.redRects))S.redRects=[];if(!Array.isArray(S.redLabels))S.redLabels=[];}
  704. const $=id=>document.getElementById(id);
  705. const fmt=n=>(Math.round(n)||0).toLocaleString("zh-CN");
  706. const fmt2=n=>(Math.round(n*100)/100).toLocaleString("zh-CN",{maximumFractionDigits:2});
  707. const unit=()=>{const m=MATS[S.mat];return m.min+(m.max-m.min)*(S.rate-10)/5;};
  708. const dimScale=()=>S.scale||(S.ft==="cad"?0.001:0);
  709. /* 场景B 尺寸单位推断:标注值与 600mm 台面深度锚点(S.scale600)交叉校验,自动判定 mm/cm/m */
  710. function annoInfer(sc600){if(!S.redArrow||!S.redArrow.len)return null;
  711. const v=+($("q-red-anno").value||0);if(!(v>0))return null;
  712. const estM=sc600>0?S.redArrow.len*sc600:0;let unit="mm",vm=v/1000;
  713. if(estM>0){let bd=1e9;for(const c of[["mm",v/1000],["cm",v/100],["m",v]]){const dd=Math.abs(Math.log(c[1]/estM));if(dd<bd){bd=dd;unit=c[0];vm=c[1];}}}
  714. return {unit,vm,estM,scale:vm/S.redArrow.len};}
  715. function annoScale(){const r=annoInfer(S.scale600||0);return r?r.scale:0;}
  716. function renderAnnoInfer(){const el=$("q-red-anno-infer");if(!el)return;const inf=S.redArrow?annoInfer(S.scale600||0):null;
  717. el.textContent=inf?(inf.estM>0?"|推断单位:"+inf.unit+"(标注 "+fmt2(inf.vm)+"m vs 600mm锚点反推 "+fmt2(inf.estM)+"m)":"|600mm锚点未定位,标注默认按 mm"):"";}
  718. /* OCR 含糊候选复核:存在多个候选值时,取与 600mm 锚点反推箭头实长偏差最小的候选(改善须≥0.05对数距离才改判,避免噪音翻转) */
  719. function annoPick(sc600){if(!S.redArrow||!S.redAnnoCands||S.redAnnoCands.length<2||!(sc600>0))return;
  720. const estM=S.redArrow.len*sc600;if(!(estM>0))return;
  721. 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;};
  722. let bi=0,bd=dist(S.redAnnoCands[0]);
  723. for(let i=1;i<S.redAnnoCands.length;i++){const d=dist(S.redAnnoCands[i]);if(d+0.05<bd){bd=d;bi=i;}}
  724. if(bi>0){$("q-red-anno").value=S.redAnnoCands[bi];
  725. logAI("· OCR 候选含糊("+S.redAnnoCands.join("/")+")→ 按 600mm 锚点反推箭头实长 "+fmt2(estM)+"m 复核,改判为 <b>"+S.redAnnoCands[bi]+"</b>","w");}}
  726. const sleep=ms=>new Promise(r=>setTimeout(r,ms));
  727. 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:"精度:待识别"};}
  728. 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);}
  729. /* ========== 视图/步骤导航 ========== */
  730. function switchView(v){
  731. ["quotes","editor","rules"].forEach(k=>{
  732. $("view-"+k).classList.toggle("hidden",k!==v);
  733. $("nav-"+k).classList.toggle("active",k===v);
  734. });
  735. }
  736. const STEP_NAMES=["上传平面户型图/CAD图","AI解析","自动计价","报价单"];
  737. function qOpenEditorStep(n){
  738. for(let i=1;i<=4;i++)$("step-"+i).classList.toggle("hidden",i!==n);
  739. document.querySelectorAll("#steps-bar .step-dot").forEach(d=>{
  740. const s=+d.dataset.step;
  741. d.classList.toggle("active",s===n);
  742. d.classList.toggle("done",s<n);
  743. });
  744. $("editor-step-breadcrumb").textContent=`步骤 ${n}/4 · ${STEP_NAMES[n-1]}`;
  745. if(n===2)$("q-floor-height2").value=$("q-floor-height").value||"2.5";
  746. if(n===3)recalc();
  747. if(n===4){recalc();renderTiers();}
  748. window.scrollTo({top:0,behavior:"smooth"});
  749. }
  750. function qNewQuote(){qResetEditor();switchView("editor");qOpenEditorStep(1);}
  751. /* 新建报价单:完整清空上一单状态与识别UI,避免携带旧数据 */
  752. function qResetEditor(){
  753. img=null;overlays=[];ctx.clearRect(0,0,cv.width,cv.height);
  754. 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});
  755. S.ruleX=Object.fromEntries(EXTRA.map(e=>[e.id,!e.off]));S.kitExtras=mkExtras("",true);
  756. $("q-file-unified").value="";$("q-btn-analyze").disabled=true;$("q-step2-next-btn").disabled=true;
  757. $("q-status-drawing").textContent="⬜ 图纸:未上传";$("q-status-drawing").style.background="#EDF2F7";$("q-status-drawing").style.color="var(--text-muted)";
  758. ["dc-type","dc-red","dc-rule"].forEach(id=>$(id).textContent="—");
  759. $("q-ai-log").innerHTML="";$("q-ai-results").style.display="none";$("q-red-anno-row").style.display="none";$("q-ai-scene-chip").textContent="等待识别…";
  760. for(let i=0;i<3;i++)setProg(i,"");
  761. $("q-floor-height").value="2.5";$("q-floor-height2").value="2.5";
  762. $("editor-title").textContent="新建报价单";$("q-brand-select").value="0";
  763. rebuildObjs();recalc();
  764. }
  765. function backToList(){qSaveQuote(true);switchView("quotes");}
  766. function qToggleRecogConfig(){const p=$("q-recog-config-panel");p.style.display=p.style.display==="none"?"block":"none";}
  767. /* ========== 画布与叠加 ========== */
  768. let img=null,overlays=[];
  769. const cv=$("q-ai-canvas"),ctx=cv.getContext("2d");
  770. 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);
  771. overlays.forEach(o=>{ctx.lineWidth=3;
  772. 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();}
  773. else if(o.t==="red"){ctx.strokeStyle="#E53E3E";ctx.strokeRect(o.x*k,o.y*k,o.w*k,o.h*k);}
  774. else{ctx.strokeStyle=o.t==="panel"?"#38A169":"#E53E3E";ctx.strokeRect(o.x*k,o.y*k,o.w*k,o.h*k);
  775. /* 名称放大并标注在柜体旁边(不覆盖柜体):优先右侧,其次左/上/下 */
  776. const lb=o.label||"";ctx.font="bold 22px sans-serif";
  777. const tw=ctx.measureText(lb).width,rx=o.x*k,ry=o.y*k,rw=o.w*k,rh=o.h*k;
  778. let lx,ly;ctx.textAlign="left";
  779. if(rx+rw+8+tw<=cv.width-4){lx=rx+rw+8;ly=ry+24;}
  780. else if(rx-8-tw>=4){ctx.textAlign="right";lx=rx-8;ly=ry+24;}
  781. else if(ry-12>=22){lx=rx;ly=ry-10;}
  782. else{lx=rx;ly=ry+rh+28;}
  783. ctx.lineWidth=6;ctx.strokeStyle="rgba(255,255,255,.92)";ctx.strokeText(lb,lx,ly);
  784. ctx.fillStyle=o.t==="panel"?"#38A169":"#E53E3E";ctx.fillText(lb,lx,ly);
  785. ctx.textAlign="left";}});}
  786. function showImg(im){img=im;overlays=[];cv.width=1000;cv.height=Math.round(1000*im.naturalHeight/im.naturalWidth);draw();
  787. $("q-btn-analyze").disabled=false;
  788. $("q-status-drawing").textContent="✅ 图纸:已上传("+im.naturalWidth+"×"+im.naturalHeight+")";
  789. $("q-status-drawing").style.background="#C6F6D5";$("q-status-drawing").style.color="#276749";}
  790. /* 删除图纸:清空图像与识别结果,回到上传状态 */
  791. function qClearDrawing(){qResetEditor();switchView("editor");qOpenEditorStep(1);toast("已删除图纸,请重新上传或选择示例");}
  792. /* ========== 示例图纸生成 ========== */
  793. function genSample(u,cad){
  794. /* 真实比例:1600px≈12m → 133px/m;台面/柜深600mm=80px */
  795. const c=document.createElement("canvas");c.width=1600;c.height=1100;const g=c.getContext("2d");
  796. g.fillStyle="#fff";g.fillRect(0,0,1600,1100);
  797. if(!cad){g.fillStyle="#DDEEDD";g.fillRect(50,50,650,450);g.fillStyle="#BFDCF9";g.fillRect(700,50,850,450);g.fillStyle="#F7E8DC";g.fillRect(50,500,1500,550);} /* 卧室底色需达蓝色阈值(b-r>25且g-r>15) */
  798. g.strokeStyle="#000";g.lineWidth=16;g.strokeRect(50,50,1500,1000);
  799. g.lineWidth=6;g.strokeStyle="#000";
  800. g.beginPath();g.moveTo(700,50);g.lineTo(700,450);g.stroke(); // 内墙
  801. g.beginPath();g.moveTo(50,500);g.lineTo(1550,500);g.stroke();
  802. g.fillStyle="#333"; // 厨房(深色填充带,AI可解算台面深度)
  803. if(u){g.beginPath();g.moveTo(100,100);g.lineTo(500,100);g.lineTo(500,400);g.lineTo(420,400);g.lineTo(420,180);g.lineTo(180,180);g.lineTo(180,400);g.lineTo(100,400);g.closePath();g.fill();}
  804. else{g.beginPath();g.moveTo(100,100);g.lineTo(500,100);g.lineTo(500,180);g.lineTo(180,180);g.lineTo(180,340);g.lineTo(100,340);g.closePath();g.fill();}
  805. g.strokeStyle="#000";g.lineWidth=4; // 柜体(矩形+叉线,2m×0.6m)
  806. [[1200,120,266,80],[1200,850,266,80],[400,900,266,80]].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();});
  807. g.lineWidth=2; // 护墙板斜线填充(AI 必须忽略,规则#14)
  808. for(let i=0;i<12;i++){g.beginPath();g.moveTo(800+i*26,120);g.lineTo(820+i*26,170);g.stroke();}
  809. g.fillStyle="#999";g.font="26px sans-serif";g.fillText("厨房",260,300);g.fillText("主卧",1250,450);g.fillText("客厅",700,750);
  810. if(!cad){g.strokeStyle="#E53E3E";g.lineWidth=8;g.strokeRect(80,80,440,340); // 红框(厨房校准框,L/U型示例均有)
  811. g.lineWidth=6;[[1200,120,266,80],[1200,850,266,80],[400,900,266,80]].forEach(r=>{g.strokeRect(r[0],r[1],r[2],r[3]);}); // 红框柜体圈注(场景B:系统直接取红框为柜体边界,不二次标注/抓取)
  812. g.lineWidth=5;g.beginPath();g.moveTo(1200,70);g.lineTo(1352,70);g.stroke();g.beginPath();g.moveTo(1368,70);g.lineTo(1520,70);g.stroke();} // 红箭头标注(320px=2400mm,场景B 单位推断演示)
  813. const im=new Image();im.src=c.toDataURL("image/png");return im;
  814. }
  815. function qUseMockDrawing(isU,isCad){const im=genSample(isU,isCad);im.onload=()=>{showImg(im);toast(isCad?"已载入示例CAD图(黑白线图)":isU?"已载入示例户型图(U型厨房+红框)":"已载入示例户型图(L型厨房+红框)");qStartAnalysis();};}
  816. /* 统一图纸载入入口:点击上传 / 拖拽上传 / 更换户型图 共用,载入后自动重新识别 */
  817. function qLoadFile(f){if(!f)return;
  818. if(/\.(pdf|dwg|dxf)$/i.test(f.name)){alert("PDF/DWG 请先转 JPG/PNG(Demo 限制)");return;}
  819. if(f.type&&f.type.indexOf("image")!==0&&!/\.(jpe?g|png|webp|bmp)$/i.test(f.name)){alert("仅支持图片格式:JPG / PNG / WEBP");return;}
  820. const rd=new FileReader();rd.onload=()=>{const im=new Image();im.onload=()=>{showImg(im);qStartAnalysis();};im.src=rd.result;};rd.readAsDataURL(f);}
  821. function qHandleUnified(inp){qLoadFile(inp.files[0]);inp.value="";} /* 清空 value:同名文件可重复选择触发 onchange */
  822. /* 拖拽上传绑定:dragover 高亮提示,drop 载入并自动重新识别 */
  823. function qBindDrop(el){if(!el)return;
  824. ["dragenter","dragover"].forEach(ev=>el.addEventListener(ev,e=>{e.preventDefault();e.stopPropagation();el.classList.add("dragover");}));
  825. ["dragleave","dragend"].forEach(ev=>el.addEventListener(ev,e=>{e.preventDefault();e.stopPropagation();el.classList.remove("dragover");}));
  826. el.addEventListener("drop",e=>{e.preventDefault();e.stopPropagation();el.classList.remove("dragover");
  827. const f=e.dataTransfer&&e.dataTransfer.files&&e.dataTransfer.files[0];qLoadFile(f);});}
  828. function qStartAnalysis(){if(!img){toast("请先上传图纸或选择示例","toast-error");return;}qOpenEditorStep(2);
  829. runAI().catch(e=>{ /* 识别异常兜底:清叠加、重绘画布、提示重试,避免留下错位/半残页面 */
  830. overlays=[];if(img)draw();setProg(0,"");setProg(1,"");setProg(2,"");
  831. logAI("⚠ AI 识别引擎异常:"+(e&&e.message?e.message:e)+"。画布已重置,请重试或更换图纸。","e");
  832. toast("AI 识别异常,请重试","toast-error");});}
  833. /* ========== AI 识别引擎 v3(墙体分离 + 柜体特征分类) ========== */
  834. 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;}
  835. function setProg(i,st){$("q-ai-prog-"+i).className="ai-progress-step "+(st||"");}
  836. function erodeMask(m,w,h,it){let cur=m;for(let t=0;t<it;t++){const o=new Uint8Array(w*h);
  837. for(let y=1;y<h-1;y++)for(let x=1;x<w-1;x++){const i=y*w+x;
  838. 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;}
  839. cur=o;}return cur;}
  840. function dilateMask(m,w,h,it){let cur=m;for(let t=0;t<it;t++){const o=new Uint8Array(cur);
  841. for(let y=1;y<h-1;y++)for(let x=1;x<w-1;x++){const i=y*w+x;
  842. if(cur[i]){o[i-1]=1;o[i+1]=1;o[i-w]=1;o[i+w]=1;}}
  843. cur=o;}return cur;}
  844. /* ========== 标注数字 OCR(模板匹配 0-9,支持 90° 旋转,用于红色尺寸标注自动读数) ========== */
  845. 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;}}
  846. if(x1<0)return null;const W=x1-x0+1,H=y1-y0+1,a=new Uint8Array(W*H);
  847. 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};}
  848. function bmRot(b){const o=new Uint8Array(b.w*b.h);for(let y=0;y<b.h;y++)for(let x=0;x<b.w;x++)o[x*b.h+(b.h-1-y)]=b.a[y*b.w+x];return {w:b.h,h:b.w,a:o};}
  849. function bmNorm(b,W,H){const c=document.createElement("canvas");c.width=b.w;c.height=b.h;const g=c.getContext("2d");
  850. const id=g.createImageData(b.w,b.h);for(let i=0;i<b.a.length;i++)if(b.a[i])id.data[i*4+3]=255;g.putImageData(id,0,0);
  851. const c2=document.createElement("canvas");c2.width=W;c2.height=H;const g2=c2.getContext("2d");
  852. g2.drawImage(c,0,0,W,H);const d2=g2.getImageData(0,0,W,H).data;const o=new Uint8Array(W*H);
  853. for(let i=0;i<W*H;i++)o[i]=d2[i*4+3]>100?1:0;return o;}
  854. function bmIou(a,b){let u=0,n=0;for(let i=0;i<a.length;i++){if(a[i]||b[i])u++;if(a[i]&&b[i])n++;}return u?n/u:0;}
  855. let DIG_T=null;
  856. function digTemplates(){if(DIG_T)return DIG_T;DIG_T=[];const c=document.createElement("canvas");c.width=36;c.height=48;const g=c.getContext("2d");
  857. /* 每个数字多字体变体(匹配时取变体间最佳 IoU);"7" 另加手绘风格化变体(顶横+斜杠 / 带中横杠 / 带底钩),适配图纸手写风格 */
  858. const fonts=["bold 40px Arial","bold 40px Verdana","bold 40px Tahoma","bold 40px 'Segoe UI'","bold 40px 'Microsoft YaHei'"];
  859. for(let dg=0;dg<10;dg++){const vs=[];
  860. for(const f of fonts){g.clearRect(0,0,36,48);g.fillStyle="#000";g.font=f;g.textBaseline="top";g.fillText(""+dg,2,0);
  861. const cr=bmCrop(g.getImageData(0,0,36,48).data,36,48);if(cr)vs.push(bmNorm(cr,9,12));}
  862. if(dg===7){const stroke=fn=>{g.clearRect(0,0,36,48);g.strokeStyle="#000";g.lineWidth=6;g.lineCap="butt";g.beginPath();fn(g);g.stroke();
  863. const cr=bmCrop(g.getImageData(0,0,36,48).data,36,48);if(cr)vs.push(bmNorm(cr,9,12));};
  864. stroke(q=>{q.moveTo(4,5);q.lineTo(31,5);q.moveTo(29,5);q.lineTo(13,45);});
  865. stroke(q=>{q.moveTo(4,5);q.lineTo(31,5);q.moveTo(29,5);q.lineTo(13,45);q.moveTo(10,26);q.lineTo(27,26);});
  866. stroke(q=>{q.moveTo(4,5);q.lineTo(31,5);q.moveTo(29,5);q.lineTo(14,40);q.lineTo(21,45);});}
  867. DIG_T.push(vs);}
  868. return DIG_T;}
  869. function mkCC(W){return mask=>{const vis=new Uint8Array(mask.length),out=[];
  870. for(let s=0;s<mask.length;s++){if(!mask[s]||vis[s])continue;
  871. const q=[s];vis[s]=1;let x0=1e9,y0=1e9,x1=-1,y1=-1,area=0,hh=0;
  872. while(hh<q.length){const c=q[hh++];area++;const cx=c%W,cy=(c/W)|0;
  873. if(cx<x0)x0=cx;if(cx>x1)x1=cx;if(cy<y0)y0=cy;if(cy>y1)y1=cy;
  874. const nb=[c-1,c+1,c-W,c+W];for(const n of nb){if(n>=0&&n<mask.length&&mask[n]&&!vis[n]){vis[n]=1;q.push(n);}}}
  875. out.push({x:x0,y:y0,w:x1-x0,h:y1-y0,area});}
  876. return out;};}
  877. /* 读取箭头旁的数字标注(如 3150):vert 由箭头方向决定排布;返回数值,失败返回 0。F=掩码相对 AW 的放大倍数(高分裁剪传 2) */
  878. function ocrNumber(mask,W,ar,cc,F){F=F||1;
  879. const comps0=cc(mask).filter(c=>c.area>=5*F*F&&Math.max(c.w,c.h)>=3*F&&Math.max(c.w,c.h)<=22*F);
  880. /* 剔除极细长线(尺寸界线/引出线,非数字笔画);粘连数字按列投影谷点拆分(如"17"连成方块) */
  881. const comps=[];
  882. for(const c of comps0){
  883. if(c.area<8*F*F)continue; /* 孤立微小碎块(手写断笔/字脚)不作数字,避免 1700 被插读成 15700 */
  884. if((c.w<=2*F&&c.h>5*c.w)||(c.h<=2*F&&c.w>5*c.h))continue;
  885. if(c.w>0.85*c.h&&c.w>=8*F){
  886. const colS=[];
  887. for(let x=0;x<=c.w;x++){let s2=0;for(let y=0;y<=c.h;y++)if(mask[(c.y+y)*W+(c.x+x)])s2++;colS.push(s2);}
  888. const mxv=Math.max.apply(null,colS);let bx=-1,bv=1e9;
  889. /* 优先在左半区(0.25~0.6 宽)找局部最小谷点(窄字"1"后接宽字时分界偏左),否则退回全域最深谷 */
  890. const lo=Math.max(2,Math.floor(c.w*0.25)),hi=Math.min(c.w-2,Math.ceil(c.w*0.6));
  891. for(let x=lo;x<=hi;x++){if(colS[x]<=0.5*mxv&&(x===lo||colS[x]<=colS[x-1])&&colS[x]<=colS[x+1]){bx=x;break;}}
  892. if(bx<0){for(let x=Math.max(2,Math.floor(c.w*0.25));x<=Math.min(c.w-2,Math.floor(c.w*0.75));x++)if(colS[x]<bv){bv=colS[x];bx=x;}if(bv>0.35*mxv)bx=-1;}
  893. if(bx>0){comps.push({x:c.x,y:c.y,w:bx,h:c.h,area:c.area});comps.push({x:c.x+bx,y:c.y,w:c.w-bx,h:c.h,area:c.area});continue;}
  894. }
  895. comps.push(c);
  896. }
  897. const ax=ar.x+ar.w/2,ay=ar.y+ar.h/2,vert=ar.h>=ar.w;
  898. const near=comps.filter(c=>{const cx=c.x+c.w/2,cy=c.y+c.h/2;
  899. 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; /* 剔除竖箭头两端箭头头 */
  900. 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; /* 剔除横箭头两端箭头头 */
  901. return Math.abs(cx-ax)<=(vert?60*F:ar.w/2+60*F)&&Math.abs(cy-ay)<=(vert?ar.h/2+25*F:ar.h/2+20*F);}); /* 横箭头:横向容差沿箭头全长+60F,纵向仅取紧邻带(20F),排除远处无关红字碎片 */
  902. if(near.length<3||near.length>6)return 0;
  903. const T=digTemplates();
  904. /* [排序轴, 排序方向, 90°旋转次数] 覆盖两类排布:数字沿箭头旋转90°(竖排旋1/3、横排旋0/2)与数字水平写在箭头旁(自左而右旋0 / 自右而左旋2) */
  905. 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]];
  906. let res=[];
  907. for(const cb of combos){const dir=cb[1],rq=cb[2];
  908. const list=near.slice().sort((a,b)=>dir*((cb[0]==="y"?a.y:a.x)-(cb[0]==="y"?b.y:b.x)));
  909. let s="",sc=0,ok=true;const amb=[];
  910. for(const c of list){let b={w:c.w+1,h:c.h+1,a:new Uint8Array((c.w+1)*(c.h+1))};
  911. 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)];
  912. for(let q=0;q<rq;q++)b=bmRot(b);
  913. const nb=bmNorm(b,9,12);let v1=-1,d1=0,v2=-1,d2=-1;
  914. for(let dg=0;dg<10;dg++){let v=0;for(const tv of T[dg]){const iv=bmIou(nb,tv);if(iv>v)v=iv;}
  915. if(v>v1){v2=v1;d2=d1;v1=v;d1=dg;}else if(v>v2){v2=v;d2=dg;}}
  916. if(v1<0.45){ok=false;break;}
  917. /* 风格化"7"结构校正:图纸手写 7 底部左扫、右下留空;而"4"的竖笔必贯穿横杠延伸至右下 → 低置信"4"按底部墨量结构改判 7 */
  918. if(d1===4&&v1<0.62){let br=0,bl=0;for(let yy=9;yy<12;yy++)for(let xx=6;xx<9;xx++)br+=nb[yy*9+xx];
  919. for(let yy2=9;yy2<12;yy2++)for(let xx2=0;xx2<3;xx2++)bl+=nb[yy2*9+xx2];
  920. if(br<=3&&bl>=6){let v7=0;for(const tv of T[7]){const iv=bmIou(nb,tv);if(iv>v7)v7=iv;}sc+=v7;d1=7;v1=v7;}
  921. else sc+=v1;}
  922. else sc+=v1;
  923. s+=d1;
  924. if(d2>=0&&d2!==d1&&v1-v2<=0.03)amb.push([s.length-1,d2]);} /* 数字含糊(前两模板得分差≤0.03)→ 记录备选位 */
  925. if(!ok)continue;
  926. /* 含糊位枚举变体(最多3位),返回候选数组:首选为最高模板得分,其余交由 600mm 锚点交叉校验复核 */
  927. 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);}
  928. for(const it of vars){const n=+it.s;if(it.s.length>=3&&n>=100&&n<=99999)res.push(it);}}
  929. if(!res.length)return [];
  930. 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);}
  931. return out;}
  932. /* ---- 图纸红字柜体名称识别(如"2-电视柜"):柜种词汇表 + 汉字模板匹配 ---- */
  933. const CAB_NAMES=["餐边柜","电视柜","阳台柜","衣柜","衣帽间","书柜","书桌","储物柜","玄关柜","卫浴柜","榻榻米","鞋柜","酒柜","橱柜","开放柜","吊柜","地柜","开放式厨房"];
  934. /* 图纸红字标注候选柜名池:单独柜种 + 空间前缀组合(支持"5-主卧衣柜"式标注) */
  935. const CAB_POOL=CAB_NAMES.concat(["主卧衣柜","次卧衣柜","儿童衣柜","老人衣柜","主卧衣帽间","次卧衣帽间","儿童衣帽间","书房书柜","书房书桌"]);
  936. let WORD_T=null;
  937. function wordTemplates(){if(WORD_T)return WORD_T;
  938. /* 整词灰度模板:整词连写渲染(4字体)裁墨界存连续 alpha;匹配时缩放到样本条带尺寸做归一化相关(NCC)——
  939. 粗笔迹二值化会把字填成实心块丢失字腔结构,连续灰度保留抗锯齿明暗,相关法可辨 */
  940. const fonts=["bold 40px 'Microsoft YaHei',sans-serif","bold 40px KaiTi,'KaiTi_GB2312','楷体',serif","bold 40px SimSun,'宋体',serif","bold 40px 'Segoe UI',sans-serif"];
  941. WORD_T={};const c=document.createElement("canvas");const g=c.getContext("2d");
  942. for(const nm of CAB_POOL){const vs=[];
  943. for(const f of fonts){const wpx=48*nm.length+16;c.width=wpx;c.height=56;g.clearRect(0,0,wpx,56);g.fillStyle="#000";g.font=f;g.textBaseline="top";g.fillText(nm,4,4);
  944. const gd=g.getImageData(0,0,wpx,56).data;let x0=1e9,y0=1e9,x1=-1,y1=-1;
  945. for(let y=0;y<56;y++)for(let x=0;x<wpx;x++)if(gd[(y*wpx+x)*4+3]>60){if(x<x0)x0=x;if(x>x1)x1=x;if(y<y0)y0=y;if(y>y1)y1=y;}
  946. if(x1<0)continue;const W2=x1-x0+1,H2=y1-y0+1,al=new Float32Array(W2*H2);
  947. for(let y=0;y<H2;y++)for(let x=0;x<W2;x++)al[y*W2+x]=gd[((y0+y)*wpx+x0+x)*4+3]/255;
  948. vs.push({w:W2,h:H2,a:al});}
  949. WORD_T[nm]=vs;}
  950. return WORD_T;}
  951. /* 读取红框旁的红字柜体名称标注:高分裁剪→红掩码→分字→编号前缀→整词灰度NCC匹配;返回 {pre,nm,sc,tops},失败返回 null */
  952. function readRedLabel(b0,AW,AH,k){
  953. const OF=Math.max(2,Math.min(4,Math.round(1/k))),OM=6; /* 尽量按原图分辨率裁剪,避免降采样糊化笔画 */
  954. const ox0=Math.max(0,b0.x-OM),oy0=Math.max(0,b0.y-OM);
  955. const ow=Math.min(AW-ox0,b0.w+2*OM),oh=Math.min(AH-oy0,b0.h+2*OM);
  956. const oc=document.createElement("canvas");oc.width=ow*OF;oc.height=oh*OF;
  957. const og=oc.getContext("2d");og.imageSmoothingEnabled=true;
  958. og.drawImage(img,ox0/k,oy0/k,ow/k,oh/k,0,0,ow*OF,oh*OF);
  959. const dd=og.getImageData(0,0,ow*OF,oh*OF).data;
  960. const W=ow*OF,H=oh*OF,rm2=new Uint8Array(W*H);
  961. /* 手写红字笔画浅细 → 用比红框提取更灵敏的阈值取墨;redv 保留连续红度(抗锯齿字腔明暗),供整词灰度相关匹配 */
  962. const redv=new Float32Array(W*H);
  963. for(let i=0;i<W*H;i++){const r=dd[i*4],g2=dd[i*4+1],b2=dd[i*4+2];const rv=r-Math.max(g2,b2)-5;redv[i]=rv>0?(rv>200?200:rv):0;if(r>125&&r-g2>35&&r-b2>35)rm2[i]=1;}
  964. /* 长直线清除:裁剪窗可能兜进红框边线/下划线(整行/整列占比>70% 的墨带)→ 整行整列抹掉,只留手写笔画 */
  965. for(let y=0;y<H;y++){let c0=0;for(let x=0;x<W;x++)if(rm2[y*W+x])c0++;if(c0>0.7*W)for(let x=0;x<W;x++){rm2[y*W+x]=0;redv[y*W+x]=0;}}
  966. for(let x=0;x<W;x++){let c0=0;for(let y=0;y<H;y++)if(rm2[y*W+x])c0++;if(c0>0.7*H)for(let y=0;y<H;y++){rm2[y*W+x]=0;redv[y*W+x]=0;}}
  967. /* 墨密度判别:手写标注裁剪窗内墨量占比≤0.15;家具斜纹填充/红框交叉/大段备注占比显著更高 → 判非柜名标注 */
  968. let ink0=0;for(let i=0;i<W*H;i++)if(rm2[i])ink0++;
  969. if(ink0<20||ink0/(W*H)>0.15)return null;
  970. const proj=new Int32Array(W);
  971. for(let x=0;x<W;x++){let s=0;for(let y=0;y<H;y++)if(rm2[y*W+x])s++;proj[x]=s;}
  972. /* 分字:字间浅连墨丝的列投影远低于笔画主峰 → 谷值<30%峰值且连续≥2列视为字隙 */
  973. let maxP=0;for(let x=0;x<W;x++)if(proj[x]>maxP)maxP=proj[x];
  974. const act=maxP>0?Math.max(2,Math.round(0.3*maxP)):1;
  975. const segs=[];let s0=-1,zc=0;
  976. for(let x=0;x<W;x++){if(proj[x]>=act){if(s0<0)s0=x;zc=0;}else if(s0>=0){zc++;if(zc>=2){segs.push({x0:s0,x1:x-zc});s0=-1;zc=0;}}}
  977. if(s0>=0)segs.push({x0:s0,x1:W-1});
  978. let maxH=0;const raw0=[];
  979. for(const sg of segs){let y0=H,y1=-1,area=0;
  980. for(let y=0;y<H;y++)for(let x=sg.x0;x<=sg.x1;x++)if(rm2[y*W+x]){area++;if(y<y0)y0=y;if(y>y1)y1=y;}
  981. if(area<4)continue;const p={x:sg.x0,y:y0,w:sg.x1-sg.x0+1,h:y1-y0+1};if(p.h>maxH)maxH=p.h;raw0.push(p);}
  982. const raw=raw0.filter(p=>p.h>=0.5*maxH); /* 碎墨过滤:高度不足主字一半的段为笔画残片 */
  983. /* 强制拆分:段宽>1.2倍字高 = 多字粘连 → 按字高近似方形等宽切槽 */
  984. const parts=[];
  985. for(const p0 of raw){
  986. if(p0.w>1.2*p0.h){const n=Math.max(2,Math.round(p0.w/p0.h)),swd=p0.w/n;
  987. for(let ci=0;ci<n;ci++){const sx0=p0.x+Math.round(ci*swd),sx1=Math.min(W-1,p0.x+Math.round((ci+1)*swd)-1);
  988. let yy0=H,yy1=-1,aa=0;
  989. for(let y=0;y<H;y++)for(let x=sx0;x<=sx1;x++)if(rm2[y*W+x]){aa++;if(y<yy0)yy0=y;if(y>yy1)yy1=y;}
  990. if(aa>=4)parts.push({x:sx0,y:yy0,w:sx1-sx0+1,h:yy1-yy0+1});}}
  991. else parts.push(p0);}
  992. if(parts.length<2||parts.length>12)return null;
  993. if(maxH<8||maxH>30)return null; /* 字高越界:大字是计算备注、巨字是家具图元,均非柜名标注 */
  994. const DT=digTemplates();
  995. const info=[];
  996. for(const p of parts){
  997. const bmp={w:p.w,h:p.h,a:new Uint8Array(p.w*p.h)};
  998. for(let y=0;y<p.h;y++)for(let x=0;x<p.w;x++)bmp.a[y*p.w+x]=rm2[(p.y+y)*W+(p.x+x)];
  999. const dn=bmNorm(bmp,9,12);let bD=-1,bDd=0;
  1000. for(let dg=0;dg<10;dg++)for(const tv of DT[dg]){const v=bmIou(dn,tv);if(v>bD){bD=v;bDd=dg;}}
  1001. if(p.w<=0.45*maxH&&bD<0.4)continue; /* 窄部件:仅编号数字等细字保留,其余跳过 */
  1002. info.push({p,digV:bD,digC:bDd});}
  1003. if(!info.length)return null;
  1004. /* 首部编号数字("2-电视柜"):仅首个部件可为编号(标注至多一个数字前缀,窄身+高数字分),防把窄汉字误剥 */
  1005. let st=0,preOut="";
  1006. if(info.length&&info[0].p.w<=0.4*maxH&&info[0].digV>=0.5){st=1;preOut+=info[0].digC;}
  1007. /* 整词灰度 NCC:连续红度条带 × 整词字体模板缩放到同尺寸做归一化互相关——
  1008. 不做逐字分割(粗笔迹粘连分字不可靠),连续灰度保留字腔结构,相关性可辨词形 */
  1009. const gate=dilateMask(rm2,W,H,2); /* 墨门控:只在笔画邻域内取红度,裁掉纸纹背景对相关度的稀释 */
  1010. const tryMatch=ws=>{if(!ws.length)return null;let ix0=1e9,iy0=1e9,ix1=-1,iy1=-1;
  1011. for(const it of ws){const p=it.p;if(p.x<ix0)ix0=p.x;if(p.y<iy0)iy0=p.y;if(p.x+p.w-1>ix1)ix1=p.x+p.w-1;if(p.y+p.h-1>iy1)iy1=p.y+p.h-1;}
  1012. const ibw=ix1-ix0+1,ibh=iy1-iy0+1;if(ibh<12)return null; /* 字高过小=碎墨,非柜名 */
  1013. const G=new Float32Array(ibw*ibh);let gm=0;
  1014. for(let y=0;y<ibh;y++)for(let x=0;x<ibw;x++)G[y*ibw+x]=gate[(iy0+y)*W+(ix0+x)]?redv[(iy0+y)*W+(ix0+x)]:0;
  1015. for(let i=0;i<G.length;i++)gm+=G[i];gm/=G.length;
  1016. let gv=0;for(let i=0;i<G.length;i++){const q=G[i]-gm;gv+=q*q;}
  1017. if(gv<1)return null;
  1018. const scored=[],WT=wordTemplates();
  1019. const tCan=document.createElement("canvas"),tG=tCan.getContext("2d");
  1020. const tCan2=document.createElement("canvas"),tG2=tCan2.getContext("2d");
  1021. for(const nm0 of CAB_POOL){const n=nm0.length;
  1022. /* 字数约束:部件数≈字数(容忍粘连并字±);字宽多档压缩比+整条带兜底,滑窗局部 NCC 容忍编号/杂墨占位 */
  1023. if(n<ws.length-1||n>ws.length+2)continue;
  1024. const tws=[];for(const wf of [1,0.85,0.65]){const t=Math.round(ibh*n*wf);if(t>=8&&t<=ibw&&tws.indexOf(t)<0)tws.push(t);}
  1025. if(tws.indexOf(ibw)<0)tws.push(ibw);
  1026. let sc=-1;
  1027. for(const tv of WT[nm0]){
  1028. tCan.width=tv.w;tCan.height=tv.h;
  1029. const id=tG.createImageData(tv.w,tv.h);
  1030. for(let i=0;i<tv.a.length;i++)id.data[i*4+3]=Math.round(tv.a[i]*255);
  1031. tG.putImageData(id,0,0);
  1032. for(const tW of tws){
  1033. tCan2.width=tW;tCan2.height=ibh;tG2.imageSmoothingEnabled=true;
  1034. tG2.clearRect(0,0,tW,ibh);tG2.drawImage(tCan,0,0,tW,ibh);
  1035. const td=tG2.getImageData(0,0,tW,ibh).data;
  1036. const T=new Float32Array(tW*ibh);let tm=0;
  1037. for(let i=0;i<T.length;i++){T[i]=td[i*4+3]/255;tm+=T[i];}tm/=T.length;
  1038. let tvv=0;for(let i=0;i<T.length;i++){const q=T[i]-tm;tvv+=q*q;}
  1039. if(tvv<1e-6)continue;
  1040. const step=Math.max(1,Math.floor(tW/10));
  1041. for(let x0=0;x0+tW<=ibw;x0+=step){
  1042. let wm=0;for(let y2=0;y2<ibh;y2++)for(let x2=0;x2<tW;x2++)wm+=G[y2*ibw+x0+x2];wm/=T.length;
  1043. let num=0,wvv=0;
  1044. for(let y2=0;y2<ibh;y2++)for(let x2=0;x2<tW;x2++){const a1=G[y2*ibw+x0+x2]-wm,b1=T[y2*tW+x2]-tm;num+=a1*b1;wvv+=a1*a1;}
  1045. if(wvv<1)continue; /* 空窗口(无墨)跳过,不影响其余窗口 */
  1046. const v=num/Math.sqrt(wvv*tvv);if(v>sc)sc=v;}}}
  1047. scored.push({nm:nm0,sc});}
  1048. scored.sort((a,b)=>b.sc-a.sc);
  1049. if(!scored.length)return null;
  1050. return {nm:scored[0].nm,sc:scored[0].sc,top:scored.slice(0,5)};};
  1051. /* 编号前缀两种剥法(严格/宽松)都试,取相关得分最高者 */
  1052. const cands=[];const m1=tryMatch(info.slice(st));if(m1)cands.push({pre:preOut,m:m1});
  1053. if(st===0&&info.length>1&&info[0].p.w<=0.55*maxH&&info[0].digV>=0.32){const m2=tryMatch(info.slice(1));if(m2)cands.push({pre:info[0].digV>=0.6?""+info[0].digC:"",m:m2});}
  1054. cands.sort((a,b)=>b.m.sc-a.m.sc);const pick=cands.length?cands[0]:null;
  1055. if(!pick||pick.m.sc<0.35)return null; /* 词形置信不足:宁缺毋错,交由调用方按图纸编号/红框序号中性命名 */
  1056. return {pre:pick.pre,nm:pick.m.nm,sc:pick.m.sc,tops:pick.m.top};}
  1057. function wallThickness(wm,w,h){const runs=[];
  1058. 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;}}}
  1059. 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;}}}
  1060. if(!runs.length)return 6;runs.sort((a,b)=>a-b);return runs[runs.length>>1];}
  1061. async function runAI(){
  1062. $("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;
  1063. if(img)draw(); /* 重置后立即重绘干净画布:避免新图纸复用上一张图的叠加红框,出现“错位页面” */
  1064. $("q-ai-results").style.display="none";
  1065. $("q-red-anno-row").style.display="none";
  1066. $("q-step2-next-btn").disabled=true;
  1067. setProg(0,"running");setProg(1,"");setProg(2,"");
  1068. logAI("▶ AI 全图识别启动(优先级:无红框全图 &gt; 红框校准)","b");await sleep(250);
  1069. /* 真实计算计时:ph() 记录每阶段纯计算耗时,S2() 为界面进度等待(不计入计算) */
  1070. const T0=performance.now();let TP=T0,COMP=0;
  1071. const ph=m=>{const x=Math.round(performance.now()-TP);TP=performance.now();COMP+=x;return m+" · 实算"+x+"ms";};
  1072. const S2=async ms=>{await sleep(ms);TP=performance.now();};
  1073. const AW=500,k=AW/img.naturalWidth,AH=Math.round(img.naturalHeight*k);
  1074. const oc=document.createElement("canvas");oc.width=AW;oc.height=AH;const og=oc.getContext("2d");
  1075. og.drawImage(img,0,0,AW,AH);const d=og.getImageData(0,0,AW,AH).data;
  1076. const ink=new Uint8Array(AW*AH),redm=new Uint8Array(AW*AH);
  1077. let rx0=1e9,ry0=1e9,rx1=-1,ry1=-1,rc=0,col=0,fp=0;
  1078. for(let i=0;i<AW*AH;i++){const r=d[i*4],g=d[i*4+1],b=d[i*4+2];fp=(fp+r*3+g*5+b*7)%1000000;
  1079. if(.299*r+.587*g+.114*b<140)ink[i]=1;
  1080. else if(Math.max(r,g,b)-Math.min(r,g,b)>15)col++;}
  1081. /* 红判定改在原生分辨率做:高分辨率源图直接降采样会把细红线稀释成粉红而漏检(箭头断裂/红框缺失/OCR 误读);
  1082. 原生分辨率下红芯不稀释。笔画红有两类:纯红(237,28,36)与橙红(237,70,20),G 均<130;
  1083. 而橙/黄/粉色块 G≥150 → 用 g<130 排除色块、保留两类红笔画;再按格 any-pool 投到 AW 栅格,细线保持连续 */
  1084. const NW=img.naturalWidth,NH=img.naturalHeight;
  1085. const nc=document.createElement("canvas");nc.width=NW;nc.height=NH;const ng=nc.getContext("2d");
  1086. ng.drawImage(img,0,0);const nd=ng.getImageData(0,0,NW,NH).data;
  1087. for(let y=0;y<NH;y++){const cy=Math.min(AH-1,(y*k)|0);
  1088. for(let x=0;x<NW;x++){const j=y*NW+x;const r=nd[j*4],g=nd[j*4+1],b=nd[j*4+2];
  1089. if(r>140&&r-g>50&&r-b>50&&g<130){const cx=Math.min(AW-1,(x*k)|0);const c2=cy*AW+cx;
  1090. if(!redm[c2]){redm[c2]=1;rc++;if(cx<rx0)rx0=cx;if(cx>rx1)rx1=cx;if(cy<ry0)ry0=cy;if(cy>ry1)ry1=cy;}}}}
  1091. logAI(ph("· 结构扫描:"+AW+"×"+AH+" 栅格化("+(AW*AH)+"像素),墨线像素 "+ink.reduce((a,v)=>a+v,0)+",图像指纹 #"+fp));await S2(200);
  1092. /* 判定① 图纸类型:平面户型图(彩色填充) vs CAD图(黑白线图) */
  1093. const colR=col/(AW*AH);
  1094. S.ft=colR>0.005?"plan":"cad";
  1095. logAI("· 判定① 图纸类型:彩色填充占比 "+(colR*100).toFixed(1)+"% → "+(S.ft==="plan"?"平面户型图(有彩色填充)":"CAD图(黑白线图)"),"b");
  1096. $("dc-type").textContent=S.ft==="plan"?"平面户型图":"CAD图";
  1097. setProg(0,"done");setProg(1,"running");await S2(200);
  1098. /* 判定② 红框(户型图:无红框 > 有红框);CAD 直接执行对应规则 */
  1099. if(rc>150){S.redbox=true;} /* 图纸已带红框:系统不作二次红框标注(不叠加 red overlay) */
  1100. if(S.ft==="plan"){
  1101. if(S.redbox){logAI("· 判定② 红框:有红框 → 场景B(红框+标注 ±2-5%),柜体边界直接取图纸红框,系统不二次红框标注/抓取","w");$("dc-red").textContent="有红框 · 场景B";$("dc-rule").textContent="有红框规则 ±2-5%";}
  1102. else{logAI("· 判定② 红框:无红框 → 场景A(无红框,AI全自动 ±5-8%,优先级:无红框 &gt; 有红框)");$("dc-red").textContent="无红框 · 场景A";$("dc-rule").textContent="无红框规则 ±5-8%";}
  1103. }else{
  1104. logAI("· 判定② 红框:不适用(CAD图无户型图场景之分)"+(S.redbox?";检测到红框校准(规则#6)":""));
  1105. logAI("· CAD规则:尺寸标注矢量直读 ±0.1-1%(Demo 以600mm锚点/已知尺寸建立比例)","b");
  1106. $("dc-red").textContent=S.redbox?"红框校准":"不适用";$("dc-rule").textContent="CAD规则 ±0.1-1%";
  1107. }
  1108. $("q-ai-scene-chip").textContent=S.ft==="cad"?"CAD图 · "+(S.redbox?"红框校准":"矢量直读"):(S.redbox?"户型图 · 场景B 有红框":"户型图 · 场景A 无红框");
  1109. await S2(200);
  1110. /* 连通域(全墨线) */
  1111. const cc=mask=>{const vis=new Uint8Array(mask.length),out=[];
  1112. for(let s=0;s<mask.length;s++){if(!mask[s]||vis[s])continue;
  1113. const q=[s];vis[s]=1;let x0=1e9,y0=1e9,x1=-1,y1=-1,area=0,hh=0;
  1114. while(hh<q.length){const c=q[hh++];area++;const cx=c%AW,cy=(c/AW)|0;
  1115. if(cx<x0)x0=cx;if(cx>x1)x1=cx;if(cy<y0)y0=cy;if(cy>y1)y1=cy;
  1116. 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);}}}
  1117. out.push({x:x0,y:y0,w:x1-x0,h:y1-y0,area});}
  1118. return out;};
  1119. const compsAll=cc(ink).sort((a,b)=>b.w*b.h-a.w*a.h);
  1120. logAI(ph("· 连通域分析:"+compsAll.length+" 个结构体"));await S2(200);
  1121. /* 场景B:红框矩形提取(实线段过滤,剔除虚线箭头/文字) */
  1122. const runMask=mk=>{const s=new Uint8Array(AW*AH);
  1123. for(let y=0;y<AH;y++){let run=0,st=0;for(let x=0;x<=AW;x++){const v=x<AW?mk[y*AW+x]:0;if(v){if(!run)st=x;run++;}else{if(run>=12)for(let x2=st;x2<x;x2++)s[y*AW+x2]=1;run=0;}}}
  1124. 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;}}}
  1125. return s;};
  1126. if(S.redbox){
  1127. /* 闭合红圈/红框 → 内部"孔洞"即柜体边界(兼容粗笔圈注与细线矩形) */
  1128. const ext=new Uint8Array(AW*AH),stk=[];
  1129. 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);}
  1130. for(let y=0;y<AH;y++){if(!redm[y*AW])stk.push(y*AW);if(!redm[y*AW+AW-1])stk.push(y*AW+AW-1);}
  1131. while(stk.length){const p=stk.pop();if(ext[p])continue;ext[p]=1;const px=p%AW,py=(p/AW)|0;
  1132. for(let dy=-1;dy<=1;dy++)for(let dx=-1;dx<=1;dx++){const nx=px+dx,ny=py+dy;
  1133. if(nx<0||ny<0||nx>=AW||ny>=AH)continue;const q=ny*AW+nx;if(!redm[q]&&!ext[q])stk.push(q);}}
  1134. const hole=new Uint8Array(AW*AH);for(let i2=0;i2<AW*AH;i2++)hole[i2]=(!redm[i2]&&!ext[i2])?1:0;
  1135. /* 孔洞膨胀2层:① 红框内含红色X/斜线细纹时,被割裂的孔洞重连为整框(避免碎块柜体);② 窄红框(如电视柜)降采样后孔洞过窄被 min>=10 过滤的兜底 */
  1136. for(const h of cc(dilateMask(hole,AW,AH,2))){
  1137. if(h.area>=200&&Math.min(h.w,h.h)>=10&&h.w<=0.45*AW&&h.h<=0.45*AH)
  1138. 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)});}
  1139. S.redRects.sort((a,b)=>b.w*b.h-a.w*a.h);
  1140. logAI(ph("· 红框矩形提取:"+S.redRects.length+" 个柜体边界框(直接取图纸红框为柜体边界,不二次收紧;已过滤虚线箭头/标注文字)"),"w");await S2(200);
  1141. if(!S.redRects.length){S.redbox=false; /* 有红候选像素但无闭合红框矩形(图纸自带零散红线/色块)→ 降级场景A 全自动,避免"场景B 却 0 柜体" */
  1142. logAI("· 判定② 修正:检测到红色像素但无闭合红框矩形 → 降级为场景A(无红框,AI全自动 ±5-8%)","w");
  1143. $("dc-red").textContent="无红框 · 场景A";$("dc-rule").textContent="无红框规则 ±5-8%";$("q-ai-scene-chip").textContent="户型图 · 场景A 无红框";}
  1144. if(S.redbox){
  1145. /* 红色尺寸标注检测:先剥离红框笔画(避免贴着红框的尺寸箭头因连通域合并被过滤),共线实线段合并为双头箭头,单长条亦视为箭头 */
  1146. S.redArrow=null;
  1147. /* 剥红框:红框笔画整体位于其 bbox 内 → 按 bbox±1 整带清零即可除净框笔画/框内杂线;
  1148. 尺寸箭头在框 bbox 外(常距框2-3px),±1 不外扩伤箭(旧 ±3 外扩会误删贴框箭头) */
  1149. const redm2=redm.slice();
  1150. for(const rc of S.redRects)for(let yy=Math.max(0,rc.y-1);yy<=Math.min(AH-1,rc.y+rc.h+1);yy++)for(let xx=Math.max(0,rc.x-1);xx<=Math.min(AW-1,rc.x+rc.w+1);xx++)redm2[yy*AW+xx]=0;
  1151. const arrows=cc(runMask(redm2)).filter(c=>Math.max(c.w,c.h)>=16&&c.area/Math.max(c.w,c.h)<=6); /* 厚度判据(面积/最长边≤6):兼容高分辨率宽箭头头及箭头与框边残迹并联,仍拒绝窄条红框等实心块 */
  1152. 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)});
  1153. /* 候选箭头列表:单长条 + 共线合并双头箭头;按长降序逐个 OCR,首个读出纯整数者胜出(橙色小数标注含 . / m 无法读成纯整数 → 自然排除) */
  1154. const candArrows=[];
  1155. for(const c of arrows)candArrows.push({len:Math.max(c.w,c.h)/k,x:c.x,y:c.y,w:c.w,h:c.h});
  1156. for(let i1=0;i1<arrows.length;i1++)for(let j=i1+1;j<arrows.length;j++){const a1=arrows[i1],a2=arrows[j];
  1157. 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);
  1158. if(gap>=0&&gap<=40)candArrows.push({len:(Math.max(a1.y+a1.h,a2.y+a2.h)-Math.min(a1.y,a2.y))/k,...abb(a1,a2)});}
  1159. 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);
  1160. if(gap>=0&&gap<=40)candArrows.push({len:(Math.max(a1.x+a1.w,a2.x+a2.w)-Math.min(a1.x,a2.x))/k,...abb(a1,a2)});}}
  1161. candArrows.sort((a,b)=>b.len-a.len);
  1162. S.redArrow=candArrows[0]||null;
  1163. if(candArrows.length){
  1164. /* OCR 高分裁剪:降采样后数字过小易误读 → 从原图按 2 倍 AW 比例裁出箭头邻域,用红色掩码识别数字;逐候选尝试 */
  1165. const OM=60,CS=Math.max(2,Math.min(6,1/k));let ocrHit=null; /* CS:裁剪倍率取 1/k → 裁剪≈原生分辨率,数字不稀释;F=CS 使 ocrNumber 容差与 AW 坐标成比例 */
  1166. for(const ar of candArrows){
  1167. const ox0=Math.max(0,ar.x-OM),oy0=Math.max(0,ar.y-OM);
  1168. const ow=Math.min(AW-ox0,ar.w+2*OM),oh=Math.min(AH-oy0,ar.h+2*OM);
  1169. const oc2=document.createElement("canvas");oc2.width=Math.round(ow*CS);oc2.height=Math.round(oh*CS);
  1170. const og2=oc2.getContext("2d");og2.imageSmoothingEnabled=true;
  1171. og2.drawImage(img,ox0/k,oy0/k,ow/k,oh/k,0,0,oc2.width,oc2.height);
  1172. const d2=og2.getImageData(0,0,oc2.width,oc2.height).data;
  1173. const rm2=new Uint8Array(oc2.width*oc2.height);
  1174. 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&&g2<130)rm2[i2]=1;}
  1175. const cands=ocrNumber(rm2,oc2.width,{x:(ar.x-ox0)*CS,y:(ar.y-oy0)*CS,w:ar.w*CS,h:ar.h*CS},mkCC(oc2.width),CS);
  1176. if(cands.length){ocrHit={ar,cands};break;}}
  1177. if(ocrHit){S.redArrow=ocrHit.ar;$("q-red-anno").value=ocrHit.cands[0];S.redAnnoCands=ocrHit.cands;
  1178. logAI("· 红色尺寸标注检测:箭头长 "+Math.round(ocrHit.ar.len)+"px → OCR 读出:<b>"+ocrHit.cands[0]+"</b>"+(ocrHit.cands.length>1?"(存在含糊候选 "+ocrHit.cands.join("/")+",将以 600mm 锚点交叉复核)":"")+"(已自动填入,如有出入可在输入框修改)→ 据此建立全图比例","b");}
  1179. else{$("q-red-anno").value="";S.redAnnoCands=[]; /* 清空默认值:OCR 失败时不拿假设值校准,回退 600mm 锚点比例 */
  1180. logAI("· 红色尺寸标注检测:箭头长 "+Math.round(S.redArrow.len)+"px,OCR 未能置信读出标注值 → 暂用 600mm 锚点比例;如知标注数值请在输入框手动填入以校准","e");}}}
  1181. }
  1182. /* 图纸已用红字标注柜体名称(如"2-电视柜")→ 识别之,对应红框柜体直接采用图纸名称,系统不再二次命名 */
  1183. if(S.redbox){const LM=dilateMask(redm,AW,AH,3);
  1184. /* 剥离红框轮廓:红字标注常紧贴红框,膨胀后与框连成一片被尺寸过滤漏检 → 沿每个红框周界清除 4px 边带,使标注独立成块 */
  1185. for(const r0 of S.redRects){const bw2=4;
  1186. for(let xx=Math.max(0,r0.x-bw2);xx<=Math.min(AW-1,r0.x+r0.w+bw2);xx++){
  1187. for(let yy=Math.max(0,r0.y-bw2);yy<=Math.min(AH-1,r0.y+bw2);yy++)LM[yy*AW+xx]=0;
  1188. for(let yy=Math.max(0,r0.y+r0.h-bw2);yy<=Math.min(AH-1,r0.y+r0.h+bw2);yy++)LM[yy*AW+xx]=0;}
  1189. for(let yy=Math.max(0,r0.y-bw2);yy<=Math.min(AH-1,r0.y+r0.h+bw2);yy++){
  1190. for(let xx=Math.max(0,r0.x-bw2);xx<=Math.min(AW-1,r0.x+bw2);xx++)LM[yy*AW+xx]=0;
  1191. for(let xx=Math.max(0,r0.x+r0.w-bw2);xx<=Math.min(AW-1,r0.x+r0.w+bw2);xx++)LM[yy*AW+xx]=0;}}
  1192. const labC=[];
  1193. for(const b0 of cc(LM)){
  1194. if(b0.w<12||b0.w>100||b0.h<6||b0.h>44||b0.area<30)continue;
  1195. const rr=readRedLabel(b0,AW,AH,k);if(rr)labC.push({b0,rr});}
  1196. /* 采纳策略:高置信整词名直接采用(显著领先次优才信);词形读不准时不猜柜种,
  1197. 回退"柜体N"沿用图纸自身编号——宁缺毋错,避免给柜体安上不存在的柜种名 */
  1198. labC.sort((a,b)=>b.rr.sc-a.rr.sc);const usedNm={},usedNum={};
  1199. for(const c of labC){const r=c.rr;let text=null;
  1200. const conf=r.sc>=0.55&&(!r.tops[1]||r.sc-r.tops[1].sc>=0.05);
  1201. if(conf&&!usedNm[r.nm]){usedNm[r.nm]=1;text=(r.pre?r.pre+"-":"")+r.nm;}
  1202. else if(r.pre&&!usedNum[r.pre]){usedNum[r.pre]=1;text="柜体"+r.pre;}
  1203. if(text)S.redLabels.push({x:c.b0.x,y:c.b0.y,w:c.b0.w,h:c.b0.h,text});}
  1204. if(S.redLabels.length)logAI("· 图纸红字柜体名称识别:"+S.redLabels.length+" 处("+S.redLabels.map(l=>l.text).join("、")+")→ 对应红框柜体直接按图纸标注命名,系统不二次命名","b");}
  1205. setProg(0,"done");setProg(1,"running");
  1206. /* 模式判定:示意图(实心厨房色块) vs 彩色户型图(墙体分离) */
  1207. const er1=erodeMask(ink,AW,AH,1);
  1208. const erFill=c=>{let hit=0,tot=0;for(let y=c.y;y<=c.y+c.h;y+=2)for(let x=c.x;x<=c.x+c.w;x+=2){tot++;if(er1[(y|0)*AW+(x|0)])hit++;}return hit/Math.max(1,tot);};
  1209. const solid=compsAll.find(c=>{const f=c.area/(c.w*c.h);return f>0.15&&erFill(c)>0.15&&c.w*c.h>0.02*AW*AH&&c.w<0.6*AW&&c.h<0.6*AH;});
  1210. let kitchen=null;const wards=[];let excluded=0;
  1211. /* 柜体具体命名(两路径通用):房间底色(蓝=卧室→衣柜 / 黄=书房→书柜 / 粉=卫浴柜 / 绿=橱柜 / 米色=客餐厅玄关)+ 形态(近方X=玄关柜) */
  1212. let gInk=ink,gThin=ink;
  1213. const xpat=c=>{let h1=0,h2=0;const N=16;
  1214. const hit9=(X,Y)=>{for(let dy=-1;dy<=1;dy++)for(let dx=-1;dx<=1;dx++){const XX=X+dx,YY=Y+dy;if(XX>=0&&YY>=0&&XX<AW&&YY<AH&&gThin[YY*AW+XX])return true;}return false;};
  1215. for(let i=1;i<N;i++){if(hit9((c.x+c.w*i/N)|0,(c.y+c.h*i/N)|0))h1++;
  1216. if(hit9((c.x+c.w*i/N)|0,(c.y+c.h-c.h*i/N)|0))h2++;}
  1217. return Math.min(h1,h2)/N;};
  1218. const roomHue=c=>{let bl=0,be=0,yl=0,pk=0,gr=0,or=0,tot=0;
  1219. const chk=(x,y)=>{if(x<0||y<0||x>=AW||y>=AH)return;const i=y*AW+x;if(gInk[i])return;tot++;const r=d[i*4],g=d[i*4+1],b2=d[i*4+2];
  1220. if(g-r>8&&g-b2>8)gr++;else if(r-g>20&&b2-g>=5)pk++;else if(b2-r>8&&b2-g>8)bl++;else if(r-g>20&&g-b2>20)or++;else if(g-b2>30)yl++;else if(r-b2>=8)be++;};
  1221. for(let x=c.x-5;x<=c.x+c.w+5;x++){chk(x,c.y-5);chk(x,c.y-4);chk(x,c.y+c.h+4);chk(x,c.y+c.h+5);}
  1222. for(let y=c.y-5;y<=c.y+c.h+5;y++){chk(c.x-5,y);chk(c.x-4,y);chk(c.x+c.w+4,y);chk(c.x+c.w+5,y);}
  1223. const m=Math.max(1,tot);return{bl:bl/m,be:be/m,yl:yl/m,pk:pk/m,gr:gr/m,or:or/m};};
  1224. const cabType=c=>{const h=roomHue(c),long=Math.max(c.w,c.h),short=Math.min(c.w,c.h),sq=long<2.8*short;
  1225. if(h.pk>=0.2)return"卫浴柜";if(h.gr>=0.2)return"橱柜";if(h.bl>=0.2||h.or>=0.2)return"衣柜";if(h.yl>=0.2)return"书柜";
  1226. if(h.be>=0.15)return sq?"玄关柜":"边柜";
  1227. return sq?"玄关柜":"储物柜";};
  1228. /* —— 空间识别:按地面底色洪填充分间(绿=厨房/粉=卫生间/蓝=卧室/黄=书房/灰=阳台/米=客餐厅),柜体映射到空间命名 —— */
  1229. let trimFn=null,growFn=null,xrects=[];
  1230. const lab=new Uint8Array(AW*AH);
  1231. const rooms=[];{
  1232. const cls=i=>{const r=d[i*4],g=d[i*4+1],b2=d[i*4+2];
  1233. if(g-r>8&&g-b2>8)return 1;if(r-g>20&&r-b2>15&&b2-g>-6)return 2;if(r-b2>25&&g-b2>25)return 4;
  1234. if(b2-r>25&&g-r>15)return 3;if(b2-r>12&&g-r<=12&&r>150&&r<230)return 5;
  1235. if(r-b2>=20&&r-g>=10&&r-g<=24&&r>225)return 6;return 0;};
  1236. const seen=new Uint8Array(AW*AH),buf=new Int32Array(AW*AH);
  1237. for(let s=0;s<AW*AH;s++){const c0=cls(s);if(!c0||seen[s])continue;seen[s]=1;
  1238. let qh=0,qt=0;buf[qt++]=s;let n=0,x0=1e9,y0=1e9,x1=-1,y1=-1;
  1239. while(qh<qt){const i=buf[qh++],x=i%AW,y=(i/AW)|0;n++;
  1240. if(x<x0)x0=x;if(x>x1)x1=x;if(y<y0)y0=y;if(y>y1)y1=y;
  1241. for(const j of[i-1,i+1,i-AW,i+AW]){if(j<0||j>=AW*AH||seen[j])continue;if(cls(j)===c0){seen[j]=1;buf[qt++]=j;}}}
  1242. if(n>0.012*AW*AH){rooms.push({c:c0,x:x0,y:y0,w:x1-x0,h:y1-y0,a:n});const rid=rooms.length;for(let q=0;q<qt;q++)lab[buf[q]]=rid;}}
  1243. const ord=rooms.slice().sort((a,b)=>b.a-a.a);
  1244. const cnt={};for(const r of ord){const base=r.c===1?"厨房":r.c===2?"卫生间":r.c===3?"卧室":r.c===4?"书房":r.c===5?"阳台":"客餐厅";cnt[base]=(cnt[base]||0)+1;r.base=base;r.idx=cnt[base];}
  1245. let bi=0;for(const r of ord)if(r.base==="卧室"){bi++;r.name=bi===1?"主卧":"次卧"+(bi>2?bi-1:"");}
  1246. for(const r of ord)if(r.base!=="卧室")r.name=r.base+(r.idx>1?r.idx:"");
  1247. if(rooms.length)logAI(ph("· 空间识别(地面底色洪填充分间):"+ord.map(r=>r.name).join("、")),"b");
  1248. }
  1249. /* 柜体归属空间:柜体内部为白填充,取框外环带(8-14px,跨墙仍靠多数表决)的地面标签多数表决 */
  1250. const roomOf=c=>{const cm={};let tot=0;
  1251. const chk=(x,y)=>{if(x<0||y<0||x>=AW||y>=AH)return;const r=lab[(y|0)*AW+(x|0)];if(r){tot++;cm[r]=(cm[r]||0)+1;}};
  1252. for(const off of[8,10,12,14]){
  1253. for(let x=c.x-off;x<=c.x+c.w+off;x++){chk(x,c.y-off);chk(x,c.y+c.h+off);}
  1254. for(let y=c.y-off;y<=c.y+c.h+off;y++){chk(c.x-off,y);chk(c.x+c.w+off,y);}}
  1255. if(!tot)return null;let bi=0,bm=0;for(const r in cm)if(cm[r]>bm){bm=cm[r];bi=+r;}
  1256. return rooms[bi-1]||null;};
  1257. const cabName=(c,r)=>{const long=Math.max(c.w,c.h),short=Math.min(c.w,c.h),sq=long<2.8*short;
  1258. if(r.c===1)return"橱柜";if(r.c===2)return"卫浴柜";
  1259. if(r.c===4)return sq?"储物柜":"书柜";if(r.c===3)return sq?"边柜":"衣柜";
  1260. if(r.c===5)return"储物柜";return sq?"玄关柜":"边柜";};
  1261. if(solid){
  1262. /* —— 示意图路径:探针判定 L/U + 二次方程解台面深度 —— */
  1263. 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]]
  1264. .map(p=>ink[((p[1]|0)*AW+(p[0]|0))]?1:0).reduce((a,v)=>a+v,0);
  1265. 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;
  1266. let dd=0,uvar=0;
  1267. if(probes===2&&discL>0)dd=(sL-Math.sqrt(discL))/2;
  1268. else if(probes===3&&discU>0){dd=(sU-Math.sqrt(discU))/4;uvar=1;}
  1269. else if(probes===3&&discU2>0){dd=(sU2-Math.sqrt(discU2))/4;uvar=2;}
  1270. if(dd>.02*AW&&dd<.12*AW)kitchen={...c,dd};
  1271. if(kitchen){
  1272. /* 600mm 隐式参照物先建立图像比例;场景B 有红箭头标注时,标注值与锚点交叉校验推断尺寸单位后再校准 */
  1273. const sc=0.6/(kitchen.dd/k);S.scale600=sc;S.scale=sc;S.scaleSrc="AI定位厨房台面深度=600mm锚点(全图通用)";
  1274. annoPick(sc);
  1275. if(S.redArrow){const inf=annoInfer(sc);if(inf){S.scale=inf.scale;S.scaleSrc="红箭头标注校准(推断单位"+inf.unit+" · 与600mm锚点交叉校验)";
  1276. 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");}}
  1277. overlays.push({t:"kitchen",x:kitchen.x/k,y:kitchen.y/k,w:kitchen.w/k,h:kitchen.h/k,label:"厨房"});
  1278. overlays.push({t:"anchor",x:kitchen.x/k,y:kitchen.y/k,w:kitchen.dd/k,h:0});
  1279. const kt=probes>=3?"U":"L";
  1280. S.kitW=+(Math.max(kitchen.w,kitchen.h)/k*S.scale).toFixed(2);S.kitH=+(Math.min(kitchen.w,kitchen.h)/k*S.scale).toFixed(2);
  1281. S.kitBox={wM:kitchen.w/k*S.scale,hM:kitchen.h/k*S.scale};
  1282. logAI("· 定位厨房(示意图模式):台面深度 "+Math.round(kitchen.dd)+"px = 600mm → 1px="+(sc*1000).toFixed(1)+"mm"+(S.redArrow?"(比例已按标注校准)":""),"b");
  1283. }
  1284. let cn=0;
  1285. for(const c2 of compsAll){
  1286. if(cn>=8)break;
  1287. if(c2.w>.6*AW&&c2.h>.6*AH)continue;
  1288. 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;
  1289. const f=c2.area/(c2.w*c2.h);
  1290. if(c2.w>.03*AW&&c2.h>.03*AW&&Math.min(c2.w,c2.h)>=.018*AW&&f>=.10&&f<.7){
  1291. cn++;wards.push(c2);}}
  1292. }else{
  1293. /* —— 彩色户型图路径:墙体分离 + 柜体特征分类 —— */
  1294. logAI("· 彩色户型图模式:腐蚀法分离墙体(粗线)与家具(细线)","b");await S2(200);
  1295. /* 墨线放宽:降采样抗锯齿使细线变灰,阈值178回收 X交叉/斜线/台面细线 */
  1296. const inkC=new Uint8Array(AW*AH);
  1297. for(let i=0;i<AW*AH;i++){if(ink[i])inkC[i]=1;else if(!redm[i]){const r=d[i*4],g=d[i*4+1],b2=d[i*4+2];if(.299*r+.587*g+.114*b2<178)inkC[i]=1;}}
  1298. let wm=erodeMask(inkC,AW,AH,2),eit=2;
  1299. if(wm.reduce((a,v)=>a+v,0)<0.005*AW*AH){
  1300. /* 空心双线墙:平行双线对检测 + 填缝(适配细双线墙体户型图) */
  1301. const dw=new Uint8Array(AW*AH);
  1302. const hruns=[];for(let y=0;y<AH;y++){let run=0,st=0;for(let x=0;x<=AW;x++){const v=x<AW?inkC[y*AW+x]:0;if(v){if(!run)st=x;run++;}else{if(run>=20)hruns.push([y,st,x-1]);run=0;}}}
  1303. for(const r1 of hruns){for(let g=2;g<=9;g++){const y2=r1[0]+g;if(y2>=AH)break;
  1304. let x=r1[1];while(x<=r1[2]){if(inkC[y2*AW+x]){let s2=x;while(x<=r1[2]&&inkC[y2*AW+x])x++;const e2=x-1;
  1305. const o0=Math.max(s2,r1[1]),o1=Math.min(e2,r1[2]);
  1306. if(o1-o0>=0.7*(r1[2]-r1[1]))for(let xx=o0;xx<=o1;xx++)for(let yy=r1[0];yy<=y2;yy++)dw[yy*AW+xx]=1;}else x++;}}}
  1307. const vruns=[];for(let x=0;x<AW;x++){let run=0,st=0;for(let y=0;y<=AH;y++){const v=y<AH?inkC[y*AW+x]:0;if(v){if(!run)st=y;run++;}else{if(run>=20)vruns.push([x,st,y-1]);run=0;}}}
  1308. for(const r1 of vruns){for(let g=2;g<=9;g++){const x2=r1[0]+g;if(x2>=AW)break;
  1309. let y=r1[1];while(y<=r1[2]){if(inkC[y*AW+x2]){let s2=y;while(y<=r1[2]&&inkC[y*AW+x2])y++;const e2=y-1;
  1310. const o0=Math.max(s2,r1[1]),o1=Math.min(e2,r1[2]);
  1311. if(o1-o0>=0.7*(r1[2]-r1[1]))for(let yy=o0;yy<=o1;yy++)for(let xx=r1[0];xx<=x2;xx++)dw[yy*AW+xx]=1;}else y++;}}}
  1312. if(dw.reduce((a,v)=>a+v,0)>=0.004*AW*AH){wm=dw;eit=1;}
  1313. else{wm=erodeMask(inkC,AW,AH,1);eit=1;}
  1314. }
  1315. let T=wallThickness(wm,AW,AH)+2*eit;T=Math.max(4,Math.min(24,T));
  1316. logAI(ph("· 墙体分离与墙厚先验 T≈"+T+"px(仅用于形状判定,不作尺寸参照,规则#11)"));
  1317. const wcut=dilateMask(wm,AW,AH,1);
  1318. /* 贴墙判定掩码:墙掩码 + 长直线(回收未入墙掩码的粗实外墙) */
  1319. const wl=new Uint8Array(AW*AH);
  1320. for(let y=0;y<AH;y++){let run=0,st=0;for(let x=0;x<=AW;x++){const v=x<AW?inkC[y*AW+x]:0;if(v){if(!run)st=x;run++;}else{if(run>=40)for(let x2=st;x2<x;x2++)wl[y*AW+x2]=1;run=0;}}}
  1321. for(let x=0;x<AW;x++){let run=0,st=0;for(let y=0;y<=AH;y++){const v=y<AH?inkC[y*AW+x]:0;if(v){if(!run)st=y;run++;}else{if(run>=40)for(let y2=st;y2<y;y2++)wl[y2*AW+x]=1;run=0;}}}
  1322. const wz0=dilateMask(wm,AW,AH,Math.max(3,Math.round(T*0.7))),wz1=dilateMask(wl,AW,AH,3);
  1323. const wz=new Uint8Array(AW*AH);for(let i=0;i<AW*AH;i++)wz[i]=wz0[i]||wz1[i];
  1324. const thin=new Uint8Array(AW*AH);for(let i=0;i<AW*AH;i++)thin[i]=inkC[i]&&!wcut[i];
  1325. gInk=inkC;gThin=thin;
  1326. /* 门洞切除:共线墙段缺口=门洞 → 清除门扇/门弧细线,避免跨房间粘连 */
  1327. const wrH=[];for(let y=0;y<AH;y++){let run=0,st=0;for(let x=0;x<=AW;x++){const v=x<AW?wm[y*AW+x]:0;if(v){if(!run)st=x;run++;}else{if(run>=12)wrH.push([y,st,x-1]);run=0;}}}
  1328. const wrV=[];for(let x=0;x<AW;x++){let run=0,st=0;for(let y=0;y<=AH;y++){const v=y<AH?wm[y*AW+x]:0;if(v){if(!run)st=y;run++;}else{if(run>=12)wrV.push([x,st,y-1]);run=0;}}}
  1329. for(let i=0;i<wrH.length;i++)for(let j=i+1;j<wrH.length;j++){const A=wrH[i],B=wrH[j];if(Math.abs(A[0]-B[0])>2)continue;const gap=B[1]-A[2]-1;if(gap<4||gap>40)continue;const g2=gap+3;
  1330. for(let y=Math.max(0,A[0]-g2);y<=Math.min(AH-1,A[0]+g2);y++)for(let x=A[2];x<=B[1];x++)thin[y*AW+x]=0;}
  1331. for(let i=0;i<wrV.length;i++)for(let j=i+1;j<wrV.length;j++){const A=wrV[i],B=wrV[j];if(Math.abs(A[0]-B[0])>2)continue;const gap=B[1]-A[2]-1;if(gap<4||gap>40)continue;const g2=gap+3;
  1332. for(let x=Math.max(0,A[0]-g2);x<=Math.min(AW-1,A[0]+g2);x++)for(let y=A[2];y<=B[1];y++)thin[y*AW+x]=0;}
  1333. const comps=cc(thin).sort((a,b)=>b.w*b.h-a.w*a.h);
  1334. const att=c=>{let hit=0,tot=0;
  1335. 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++;}}
  1336. 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++;}}
  1337. return hit/Math.max(1,tot);};
  1338. const hatch=c=>{let rt=0,ct=0,nr=0,nc=0;
  1339. 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;}
  1340. 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;}
  1341. return Math.max(rt/Math.max(1,nr),ct/Math.max(1,nc));};
  1342. /* 实际最长45°连续段(X交叉柜的对角线残段也计分,不依赖bbox对齐) */
  1343. const diagRun=c=>{let m1=0,m2=0;const td=(x,y)=>x>=0&&x<AW&&y>=0&&y<AH&&(thin[y*AW+x]||thin[y*AW+x+1]||thin[y*AW+x-1]);
  1344. const walk=(x0,y0,dx,dy,k)=>{let cnt=0,x=x0,y=y0;
  1345. while(x>=c.x-1&&x<=c.x+c.w+1&&y>=c.y&&y<=c.y+c.h){if(td(x,y))cnt++;else cnt=0;if(k===1){if(cnt>m1)m1=cnt;}else if(cnt>m2)m2=cnt;x+=dx;y+=dy;}};
  1346. for(let x=c.x;x<=c.x+c.w;x++){walk(x,c.y,1,1,1);walk(x,c.y+c.h,1,-1,2);}
  1347. for(let y=c.y;y<=c.y+c.h;y++){walk(c.x,y,1,1,1);walk(c.x+c.w,y,-1,1,1);walk(c.x,y,1,-1,2);walk(c.x+c.w,y,-1,-1,2);}
  1348. return Math.min(m1,m2);};
  1349. /* bbox外圈粉色占比(卫生间洁具排除) */
  1350. const ringPink=c=>{let p=0,t=0;const chk=(x,y)=>{if(x<0||y<0||x>=AW||y>=AH)return;const i=y*AW+x;if(inkC[i])return;t++;const r=d[i*4],g=d[i*4+1],b2=d[i*4+2];if(r-g>20&&r-b2>10)p++;};
  1351. for(let x=c.x-4;x<=c.x+c.w+4;x++){chk(x,c.y-4);chk(x,c.y-3);chk(x,c.y+c.h+3);chk(x,c.y+c.h+4);}
  1352. for(let y=c.y-4;y<=c.y+c.h+4;y++){chk(c.x-4,y);chk(c.x-3,y);chk(c.x+c.w+3,y);chk(c.x+c.w+4,y);}
  1353. return p/Math.max(1,t);};
  1354. 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);
  1355. 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;
  1356. const bigsOf=c=>comps.filter(s=>s!==c&&Math.max(s.w,s.h)>3*T&&s.x>=c.x-2&&s.y>=c.y-2&&s.x+s.w<=c.x+c.w+2&&s.y+s.h<=c.y+c.h+2).length;
  1357. /* 填充色语义:绿=厨房 / 粉=卫生间(辅助台面带与衣柜判定) */
  1358. const fillHue=(c,mg)=>{mg=mg||0;let gN=0,pN=0,tot=0;for(let y=Math.max(0,c.y-mg);y<=Math.min(AH-1,c.y+c.h+mg);y+=2)for(let x=Math.max(0,c.x-mg);x<=Math.min(AW-1,c.x+c.w+mg);x+=2){const i=(y|0)*AW+(x|0);if(inkC[i])continue;tot++;const r=d[i*4],g=d[i*4+1],b2=d[i*4+2];if(g-r>8&&g-b2>8)gN++;else if(r-g>20&&r-b2>10)pN++;}return[gN/Math.max(1,tot),pN/Math.max(1,tot)];};
  1359. /* 收紧柜体包围盒:行/列墨线密度剖面按"空白间隙≥2"分段,取墨量最大段=柜体本体,剥离粘连杂线(门弧/邻家具),使红框贴合柜体本身 */
  1360. trimFn=c=>{const W=c.w+1,H=c.h+1;
  1361. const colD=new Float32Array(W),rowD=new Float32Array(H);
  1362. for(let x=0;x<W;x++){let t=0;for(let y=c.y;y<=c.y+c.h;y++)if(thin[y*AW+c.x+x])t++;colD[x]=t/H;}
  1363. for(let y=0;y<H;y++){let t=0;for(let x=c.x;x<=c.x+c.w;x++)if(thin[y*AW+x])t++;rowD[y]=t/W;}
  1364. const pick=(D,L)=>{const v=new Uint8Array(L);for(let i=0;i<L;i++)v[i]=D[i]>0.06?1:0;
  1365. for(let i=1;i<L-1;i++)if(!v[i]&&v[i-1]&&v[i+1])v[i]=1;
  1366. let bs=0,be=L-1,bm=-1,s=-1;
  1367. for(let i=0;i<=L;i++){const on=i<L&&v[i];
  1368. if(on&&s<0)s=i;
  1369. if(!on&&s>=0){let m=0;for(let q=s;q<i;q++)m+=D[q];
  1370. if(m>bm){bm=m;bs=s;be=i-1;}s=-1;}}
  1371. return be>bs?[bs,be]:[0,L-1];};
  1372. const cx=pick(colD,W),ry=pick(rowD,H);
  1373. const t={x:c.x+cx[0],y:c.y+ry[0],w:cx[1]-cx[0],h:ry[1]-ry[0]};
  1374. /* 安全回退:斜线填充剖面易碎段,若收紧后显著缩水则保留原框 */
  1375. if(t.w<8||t.h<8)return c;
  1376. if(Math.max(t.w,t.h)<0.6*Math.max(c.w,c.h)||t.w*t.h<0.45*c.w*c.h)return c;
  1377. return t;};
  1378. /* 边框回归:柜体边框线被墙填缝/降采样混叠吞掉时,自当前框向外扫描inkC(≤14px),遇覆盖≥70%的直线段即吸附最近者,恢复柜体真实轮廓(最长边=柜体本身最长边) */
  1379. growFn=c=>{const t={x:c.x,y:c.y,w:c.w,h:c.h};
  1380. const cv=(x,y0,y1)=>{let n=0;for(let y=y0;y<=y1;y++){const X=(y|0)*AW+(x|0);if(inkC[X]||inkC[X-1]||inkC[X+1])n++;}return n/(y1-y0+1);};
  1381. const ch=(y,x0,x1)=>{let n=0;for(let x=x0;x<=x1;x++){const X=(y|0)*AW+(x|0);if(inkC[X]||inkC[X-AW]||inkC[X+AW])n++;}return n/(x1-x0+1);};
  1382. /* 墙线判别:候选线在柜体两端外仍长延伸(>6px)=墙线 → 不吸附 */
  1383. const hext=y=>{let el=0;for(let x=t.x-2;x>=t.x-40;x--){const X=(y|0)*AW+x;if(inkC[X]||inkC[X-AW]||inkC[X+AW])el++;else break;}let er=0;for(let x=t.x+t.w+2;x<=t.x+t.w+40;x++){const X=(y|0)*AW+x;if(inkC[X]||inkC[X-AW]||inkC[X+AW])er++;else break;}return Math.max(el,er);};
  1384. const vext=x=>{let eu=0;for(let y=t.y-2;y>=t.y-40;y--){const X=y*AW+(x|0);if(inkC[X]||inkC[X-1]||inkC[X+1])eu++;else break;}let ed=0;for(let y=t.y+t.h+2;y<=t.y+t.h+40;y++){const X=y*AW+(x|0);if(inkC[X]||inkC[X-1]||inkC[X+1])ed++;else break;}return Math.max(eu,ed);};
  1385. for(let o=1;o<=26;o++){const y=t.y-o;if(ch(y,t.x,t.x+t.w)>=0.7&&hext(y)<=6){t.h+=o;t.y-=o;break;}}
  1386. for(let o=1;o<=26;o++){const y=t.y+t.h+o;if(ch(y,t.x,t.x+t.w)>=0.7&&hext(y)<=6){t.h+=o;break;}}
  1387. for(let o=1;o<=26;o++){const x=t.x-o;if(cv(x,t.y,t.y+t.h)>=0.7&&vext(x)<=6){t.w+=o;t.x-=o;break;}}
  1388. for(let o=1;o<=26;o++){const x=t.x+t.w+o;if(cv(x,t.y,t.y+t.h)>=0.7&&vext(x)<=6){t.w+=o;break;}}
  1389. return t;};
  1390. /* X交叉柜矩形回归:X的四条对角线角对角且中心相交,在thin上(长直边框线run≥5已排除)构成独立连通域;
  1391. 取对角线bbox后四边吸附inkC最近边框线,得到柜体真实矩形(不受连通域bbox混入墙线影响) */
  1392. {const dgm=new Uint8Array(AW*AH);
  1393. for(let y=1;y<AH-1;y++)for(let x=1;x<AW-1;x++){const X=y*AW+x;if(!thin[X])continue;
  1394. let h=1;for(let xx=x+1;xx<AW&&thin[y*AW+xx];xx++)h++;for(let xx=x-1;xx>=0&&thin[y*AW+xx];xx--)h++;
  1395. let v=1;for(let yy=y+1;yy<AH&&thin[yy*AW+x];yy++)v++;for(let yy=y-1;yy>=0&&thin[yy*AW+x];yy--)v++;
  1396. if(h<5&&v<5)dgm[X]=1;}
  1397. const seenX=new Uint8Array(AW*AH),bufX=new Int32Array(AW*AH),raw=[];
  1398. for(let s=0;s<AW*AH;s++){if(!dgm[s]||seenX[s])continue;seenX[s]=1;
  1399. let qh=0,qt=0;bufX[qt++]=s;let n=0,x0=1e9,y0=1e9,x1=-1,y1=-1;
  1400. while(qh<qt){const i=bufX[qh++],x=i%AW,y=(i/AW)|0;n++;
  1401. if(x<x0)x0=x;if(x>x1)x1=x;if(y<y0)y0=y;if(y>y1)y1=y;
  1402. for(let dy=-1;dy<=1;dy++)for(let dx=-1;dx<=1;dx++){if(!dx&&!dy)continue;const j=i+dy*AW+dx;if(j<0||j>=AW*AH||seenX[j]||!dgm[j])continue;seenX[j]=1;bufX[qt++]=j;}}
  1403. if(n>=12)raw.push({x0,y0,x1,y1,n});}
  1404. for(let m=1;m;){m=0;for(let i=0;i<raw.length;i++)for(let j=i+1;j<raw.length;j++){const a=raw[i],b=raw[j];
  1405. if(a.x0-3<=b.x1&&b.x0-3<=a.x1&&a.y0-3<=b.y1&&b.y0-3<=a.y1){a.x0=Math.min(a.x0,b.x0);a.y0=Math.min(a.y0,b.y0);a.x1=Math.max(a.x1,b.x1);a.y1=Math.max(a.y1,b.y1);a.n+=b.n;raw.splice(j,1);m=1;break;}}}
  1406. for(const r of raw){const w=r.x1-r.x0,h=r.y1-r.y0;
  1407. if(r.n<40||w<12||h<12||w>2.2*h||h>2.2*w)continue;
  1408. const cvv=x=>{let k=0;for(let y=r.y0;y<=r.y1;y++){if(inkC[y*AW+x])k++;}return k/(h+1);};
  1409. const chh=y=>{let k=0;for(let x=r.x0;x<=r.x1;x++){if(inkC[y*AW+x])k++;}return k/(w+1);};
  1410. const pickV=xc=>{let bx=-1,bd=1e9;for(let x=Math.max(1,xc-8);x<=Math.min(AW-2,xc+4);x++){if(cvv(x)>=0.8){const dd=Math.abs(x-xc);if(dd<bd){bd=dd;bx=x;}}}return bx;};
  1411. const pickH=yc=>{let by=-1,bd=1e9;for(let y=Math.max(1,yc-8);y<=Math.min(AH-2,yc+4);y++){if(chh(y)>=0.8){const dd=Math.abs(y-yc);if(dd<bd){bd=dd;by=y;}}}return by;};
  1412. let L=pickV(r.x0),R=pickV(r.x1),Tp=pickH(r.y0),B=pickH(r.y1);
  1413. if(L<0)L=r.x0;if(R<0)R=r.x1;if(Tp<0)Tp=r.y0;if(B<0)B=r.y1;
  1414. /* 双线边框向外扩≤2px:柜体尺寸取外沿(外边到外边);墙线距边框≥3px不受影响 */
  1415. for(let e=0;e<2&&L>1&&cvv(L-1)>=0.8;e++)L--;
  1416. for(let e=0;e<2&&R<AW-2&&cvv(R+1)>=0.8;e++)R++;
  1417. for(let e=0;e<2&&Tp>1&&chh(Tp-1)>=0.8;e++)Tp--;
  1418. for(let e=0;e<2&&B<AH-2&&chh(B+1)>=0.8;e++)B++;
  1419. xrects.push({x:L,y:Tp,w:R-L,h:B-Tp});}
  1420. }
  1421. const bands=[];
  1422. for(const c of comps){
  1423. if(c.w>0.55*AW||c.h>0.55*AH)continue;
  1424. const short=Math.min(c.w,c.h),long=Math.max(c.w,c.h);
  1425. if(short<1.2*T)continue; // 噪点/小符号
  1426. const a=att(c),f=c.area/(c.w*c.h),kd=kidsOf(c);c.kids=kd;
  1427. const hue=fillHue(c),gR=hue[0],pR=hue[1];c.gR=gR;
  1428. /* L/U型合并台面带(转角相连):贴墙 + 内含≥2小符号 + 低填充空芯 + 不含床等大家具 */
  1429. if(a>=0.2&&kd>=2&&f<=0.25&&short>=2.2*T&&long<=3*short&&long<=0.45*AW&&bigsOf(c)===0){c.merged=1;bands.push(c);continue;}
  1430. /* 台面带(含近方段):贴墙 + 内含水槽/灶具小符号 + 深度2.2-5.5T */
  1431. if(a>=0.25&&kd>=1&&f<=0.35&&short>=Math.min(2.2*T,12)&&short<=5.5*T&&long<=3.5*short){bands.push(c);continue;}
  1432. /* 绿色填充台面带(厨房底色上的水槽/灶具段) */
  1433. if(a>=0.25&&gR>=0.25&&pR<0.2&&short>=Math.min(2.2*T,12)&&short<=5.5*T&&long<=3.5*short){bands.push(c);continue;}
  1434. /* 橱柜台面带:贴墙 + 长条 + 深度≈3T(600mm) + 内含水槽/灶具符号 */
  1435. if(a>=0.25&&long>=2.2*short&&short>=Math.min(2.2*T,14)&&short<=5.5*T&&(kidsOf(c)>=1||(f>=0.1&&f<=0.65&&hatch(c)<4&&xpat(c)<0.5&&(long>=3*short||f>=0.3)))){bands.push(c);continue;}
  1436. /* 衣柜:贴墙 + 斜线填充 或 X交叉(排除厨房/卫生间底色上的台面) */
  1437. if(a>=0.3&&fillHue(c,10)[1]<0.2&&gR<0.2&&short>=Math.min(1.5*T,12)&&short<=6*T&&long>=Math.max(24,1.4*short)&&long<=15*T&&((f>=0.08&&ringPink(c)<0.25&&(hatch(c)>=5||xpat(c)>=0.55))||(f>=0.06&&ringPink(c)<0.3&&diagRun(c)>=6))){c.xp=xpat(c);wards.push(c);continue;}
  1438. /* 近方形 X交叉柜(玄关柜等) */
  1439. if(a>=0.3&&pR<0.2&&gR<0.2&&ringPink(c)<0.25&&f<=0.25&&xpat(c)>=0.45&&long>=24&&short>=Math.min(1.5*T,12)&&short<=6*T&&long<2.2*short){c.xp=xpat(c);wards.push(c);continue;}
  1440. excluded++;}
  1441. logAI("· 柜体判定标准:贴墙率≥30% 且(斜线填充 / X交叉 / 600mm台面带+水槽灶具)","b");
  1442. logAI(ph("· 柜体特征分类:命中 "+(wards.length+bands.length)+" 个候选,排除床/沙发/桌椅/洁具等独立家具 "+excluded+" 个"));await S2(200);
  1443. /* 厨房:台面带聚类 → 600mm 锚点 */
  1444. if(bands.length){
  1445. bands.sort((a,b)=>b.w*b.h-a.w*a.h);
  1446. /* 多聚类 → 选最优簇(带数×2 + 水槽灶具符号数 + 合并带加成),避免把卫生间台面误当厨房 */
  1447. const clusters=[];
  1448. for(const b of bands){const cl=clusters.find(g=>g.some(c=>Math.abs(c.x-b.x)<6*T&&Math.abs(c.y-b.y)<6*T||
  1449. (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)));
  1450. if(cl)cl.push(b);else clusters.push([b]);}
  1451. const score=g=>g.length*2+g.reduce((s,c)=>s+(c.kids||0)+(c.merged?2:0)+((c.gR||0)>=0.25?3:0),0);
  1452. clusters.sort((a,b)=>score(b)-score(a));
  1453. const cluster=clusters[0];
  1454. const b0=cluster[0];let dNat;
  1455. if(b0.merged){const med=arr=>{arr.sort((p,q)=>p-q);return arr.length?arr[arr.length>>1]:0;};
  1456. const sc2=[],sr2=[];
  1457. for(let x=(b0.x+0.6*b0.w)|0;x<=b0.x+0.95*b0.w;x+=2){let mn=1e9,mx=-1;for(let y=b0.y;y<=b0.y+b0.h;y++)if(ink[y*AW+x]){if(y<mn)mn=y;if(y>mx)mx=y;}if(mx>=0)sc2.push(mx-mn);}
  1458. for(let y=(b0.y+0.6*b0.h)|0;y<=b0.y+0.95*b0.h;y+=2){let mn=1e9,mx=-1;for(let x2=b0.x;x2<=b0.x+b0.w;x2++)if(ink[y*AW+x2]){if(x2<mn)mn=x2;if(x2>mx)mx=x2;}if(mx>=0)sr2.push(mx-mn);}
  1459. dNat=(Math.min(med(sc2),med(sr2))||Math.min(b0.w,b0.h))/k;}
  1460. else dNat=Math.min(b0.w,b0.h)/k;
  1461. /* 600mm 隐式参照物应用于场景A/B:先建锚点比例;场景B 标注值与锚点交叉校验推断尺寸单位(mm/cm/m)后校准 */
  1462. S.scale600=0.6/dNat;
  1463. annoPick(S.scale600);
  1464. const inf=S.redArrow?annoInfer(S.scale600):null;
  1465. if(inf){S.scale=inf.scale;S.scaleSrc="红箭头标注校准(推断单位"+inf.unit+" · 与600mm锚点交叉校验)";
  1466. 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");}
  1467. else{S.scale=S.scale600;S.scaleSrc="AI定位橱柜台面深度=600mm锚点(全图通用)";}
  1468. let ux0=1e9,uy0=1e9,ux1=-1,uy1=-1;
  1469. 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);});
  1470. /* 绿色底色最大连通块 = 厨房房间;房间内贴墙白色台面条游程 = 各边台面真实长度 */
  1471. const isG=i=>{const r=d[i*4],g=d[i*4+1],b2=d[i*4+2];return g-r>8&&g-b2>8;};
  1472. let gbox=null;{const seen=new Uint8Array(AW*AH);let bestA=Math.max(300,0.004*AW*AH);
  1473. for(let s=0;s<AW*AH;s++){if(seen[s]||!isG(s))continue;const q=[s];seen[s]=1;let x0=1e9,y0=1e9,x1=-1,y1=-1,n=0;
  1474. while(q.length){const i=q.pop(),x=i%AW,y=(i/AW)|0;n++;if(x<x0)x0=x;if(x>x1)x1=x;if(y<y0)y0=y;if(y>y1)y1=y;
  1475. for(const j of[i-1,i+1,i-AW,i+AW])if(j>=0&&j<AW*AH&&!seen[j]&&isG(j)){seen[j]=1;q.push(j);}}
  1476. if(n>bestA){bestA=n;gbox={x:x0,y:y0,w:x1-x0,h:y1-y0};}}}
  1477. if(gbox&&gbox.x<ux1+30&&ux0<gbox.x+gbox.w+30&&gbox.y<uy1+30&&uy0<gbox.y+gbox.h+30){ux0=gbox.x;uy0=gbox.y;ux1=gbox.x+gbox.w;uy1=gbox.y+gbox.h;}
  1478. kitchen={x:ux0,y:uy0,w:ux1-ux0,h:uy1-uy0};
  1479. S.kitBox={wM:kitchen.w/k*S.scale,hM:kitchen.h/k*S.scale};
  1480. overlays.push({t:"kitchen",x:ux0/k,y:uy0/k,w:(ux1-ux0)/k,h:(uy1-uy0)/k,label:"厨房"});
  1481. overlays.push({t:"anchor",x:b0.x/k,y:b0.y/k,w:dNat,h:0});
  1482. /* 厨房尺寸=厨房房间尺寸;按规则直接计算总长并展示过程(最短边≤2m→L型,否则U型) */
  1483. const kW=kitchen.w/k*S.scale,kH=kitchen.h/k*S.scale,kLong=Math.max(kW,kH),kShort=Math.min(kW,kH);
  1484. if(kLong>0){
  1485. S.kitW=+kLong.toFixed(2);S.kitH=+kShort.toFixed(2);
  1486. if(kShort<=2){
  1487. logAI("· 厨房尺寸:"+fmt2(kLong)+"m × "+fmt2(kShort)+"m(最短边≤2m → L型)","b");
  1488. logAI("· 厨房总长:L型 "+fmt2(kLong)+" + "+fmt2(kShort)+" − 1.6 = <b>"+fmt2(Math.max(kLong+kShort-1.6,0))+"m</b>(可计费总长)","b");}
  1489. else{
  1490. logAI("· 厨房尺寸:"+fmt2(kLong)+"m × "+fmt2(kShort)+"m(最短边&gt;2m → U型)","b");
  1491. logAI("· 厨房总长:U型 "+fmt2(kLong)+" + "+fmt2(kLong)+" + "+fmt2(kShort)+" − 2.2 = <b>"+fmt2(Math.max(kLong*2+kShort-2.2,0))+"m</b>(可计费总长)","b");}
  1492. }
  1493. logAI(ph("· 厨房台面带聚类→比例建立:"+S.scaleSrc+" → 1px="+(S.scale*1000).toFixed(2)+"mm"),"b");
  1494. }else{logAI("· 未能自动定位厨房台面带 → 请在下一步补充一个已知尺寸","e");}
  1495. }
  1496. /* 比例兜底(示意图/彩色两路径通用):600mm 锚点未定位时,用红箭头标注建立比例(默认按 mm),确保柜体尺寸可换算 */
  1497. if(!S.scale&&S.redArrow){S.scale=annoScale();if(S.scale){S.scaleSrc="红箭头标注校准(600mm锚点未定位,标注默认按 mm)";logAI("· 已用红箭头标注建立比例:1px="+(S.scale*1000).toFixed(2)+"mm(柜体尺寸可换算)","b");}}
  1498. draw();await S2(200);
  1499. setProg(1,"done");setProg(2,"running");
  1500. /* 柜体输出(场景B:直接取图纸红框为柜体边界,不二次抓取;场景A:特征识别) */
  1501. let cn=0;const tpCnt={};
  1502. const pushCab=(c0,src,nameOv)=>{let c=c0;if(src==="AI"){
  1503. /* AI路径:特征抓取范围收紧到柜体轮廓(xrect真实矩形→trimFn收紧);红框路径直接取图纸红框为柜体边界,不二次收紧 */
  1504. if(xrects.length){const cx=c0.x+c0.w/2,cy=c0.y+c0.h/2;let best=null,bd=1e9;
  1505. for(const r of xrects){const ov=Math.max(0,Math.min(c0.x+c0.w,r.x+r.w)-Math.max(c0.x,r.x))*Math.max(0,Math.min(c0.y+c0.h,r.y+r.h)-Math.max(c0.y,r.y));
  1506. if(ov<0.25*Math.min(c0.w*c0.h,r.w*r.h))continue;
  1507. const dd=Math.abs(r.x+r.w/2-cx)+Math.abs(r.y+r.h/2-cy);if(dd<bd){bd=dd;best=r;}}
  1508. if(best&&bd<=Math.max(c0.w,c0.h)){c={x:best.x,y:best.y,w:best.w,h:best.h};c.xr=1;}else if(src==="AI"&&growFn)c=growFn(c);}
  1509. else if(src==="AI"&&growFn)c=growFn(c);
  1510. if(trimFn&&!c.xr)c=trimFn(c);}cn++;
  1511. /* nameOv=图纸红字标注名称:直接采用,系统不二次命名(不做空间判定) */
  1512. const rm=nameOv?null:roomOf(c),tp0=nameOv||(rm?cabName(c,rm):cabType(c)),rn=rm?rm.name:"";
  1513. const tk=(rn||"未识别")+"|"+tp0;tpCnt[tk]=(tpCnt[tk]||0)+1;const tp=tpCnt[tk]>1?tp0+tpCnt[tk]:tp0;
  1514. /* 名称直接为“空间+柜种”(如:主卧衣柜),不带柜体编号;红字标注名则原样采用 */
  1515. const nm=(rn?rn:"")+tp;
  1516. S.objs.push({name:nm,kind:"cab",src,w:Math.round(c.w/k),h:Math.round(c.h/k),extras:mkExtras(nm,false)});
  1517. overlays.push({t:"cab",x:c.x/k,y:c.y/k,w:c.w/k,h:c.h/k,label:cn+"·"+tp});
  1518. const tag=nameOv?(nameOv.indexOf("红框柜体")===0?"图纸红字未能置信读出 → 按红框序号命名(可在报价编辑中改名)":"名称取自图纸红字标注(系统不二次命名)"):"空间="+(rn||"未识别")+" → <b>"+tp+"</b>";
  1519. if(S.scale||S.ft==="cad"){const wM=c.w/k*dimScale(),hM=c.h/k*dimScale();
  1520. logAI("· "+nm+"["+src+"] "+tag+":实测 "+Math.round(c.w/k)+"×"+Math.round(c.h/k)+"px × "+(dimScale()*1000).toFixed(2)+"mm/px → 最长边 "+fmt2(Math.max(wM,hM))+"m × 高 "+fmt2(fh())+"m → 投影面积 = <b>"+fmt2(Math.max(wM,hM)*fh())+"㎡</b>");}
  1521. else logAI("· "+nm+"["+src+"] "+tag+":"+Math.round(c.w/k)+"px × "+Math.round(c.h/k)+"px(比例未建立,补充已知尺寸后自动换算)","w");};
  1522. /* 红字标签↔红框全局最近配对:bbox 间隙距离 ≤60px,按距离升序贪心,一框一标签 */
  1523. const rcName=new Map();
  1524. if(S.redLabels&&S.redLabels.length){const pairs=[];
  1525. S.redRects.forEach((c,ri)=>{for(let li=0;li<S.redLabels.length;li++){const lb=S.redLabels[li];
  1526. const dx=Math.max(c.x-(lb.x+lb.w),lb.x-(c.x+c.w),0),dy=Math.max(c.y-(lb.y+lb.h),lb.y-(c.y+c.h),0);
  1527. const dd2=Math.hypot(dx,dy);if(dd2<=60)pairs.push({d:dd2,ri,li});}});
  1528. pairs.sort((a,b)=>a.d-b.d);const usedR=new Set(),usedL=new Set();
  1529. for(const p of pairs){if(usedR.has(p.ri)||usedL.has(p.li))continue;usedR.add(p.ri);usedL.add(p.li);rcName.set(p.ri,S.redLabels[p.li].text);}}
  1530. for(let ri=0;ri<S.redRects.length;ri++){if(cn>=8)break;const c=S.redRects[ri];
  1531. if(kitchen){const ov=Math.max(0,Math.min(c.x+c.w,kitchen.x+kitchen.w)-Math.max(c.x,kitchen.x))*Math.max(0,Math.min(c.y+c.h,kitchen.y+kitchen.h)-Math.max(c.y,kitchen.y));
  1532. if(ov>=0.9*kitchen.w*kitchen.h)continue;} // 红框整包厨房台面带=校准框,非柜体圈注
  1533. /* 场景B 无配对红字标注的红框:不按空间底色猜柜种(户型图房间色块判读不可靠),按红框序号中性命名,用户可在报价编辑中改名 */
  1534. pushCab(c,"红框",rcName.get(ri)||(S.redbox?"红框柜体"+(ri+1):null));}
  1535. if(S.redbox)logAI("· 场景B:柜体边界直接取图纸红框,系统不二次抓取(不合并 AI 特征抓取、不二次红框标注)","w");
  1536. else for(const c of wards){if(cn>=8)break;pushCab(c,"AI");} /* 场景A 无红框,无需红框去重 */
  1537. logAI(ph("· 柜体识别:"+cn+" 个(最长边×层高=投影面积,均为到顶高柜)"),"b");await S2(200);
  1538. logAI("· 护墙板:平面图斜线填充不可靠 → <b>不自动识别</b>(规则#14),如需请在下一步手动添加","w");await sleep(150);
  1539. logAI("· 尺寸计算完成。精度等级:"+precision().txt+" · AI 纯计算合计 "+COMP+"ms(其余为界面进度等待,非计算)","b");
  1540. setProg(2,"done");draw();rebuildObjs();recalc();renderAIResults();
  1541. $("q-step2-next-btn").disabled=false;
  1542. }
  1543. function fh(){return parseFloat($("q-floor-height").value)||2.5;}
  1544. function qFhChanged(src){ensureS();const v=(src?src.value:$("q-floor-height").value)||"2.5";
  1545. $("q-floor-height").value=v;const i2=$("q-floor-height2");if(i2)i2.value=v;
  1546. if(S.objs.length||S.kitBox){renderAIResults();recalc();}}
  1547. function renderAIResults(){ensureS();
  1548. const sc=dimScale();
  1549. $("q-red-anno-row").style.display=S.redArrow?"block":"none";
  1550. renderAnnoInfer();
  1551. $("q-ai-results").style.display="flex";
  1552. $("q-ai-cab-count").textContent=S.objs.length+" 个";
  1553. const cabTot=S.objs.filter(p=>p.kind!=="panel").reduce((a,p)=>a+Math.max(p.w*sc,p.h*sc)*fh(),0);
  1554. $("q-ai-cab-body").innerHTML=S.objs.length?S.objs.map(p=>{const wM=p.w*sc,hM=p.h*sc;
  1555. return `<div class="total-line"><span>${p.name}[${p.src}]:${sc?"最长边 "+fmt2(Math.max(wM,hM))+" m × 高 "+fmt2(fh())+" m":p.w+" px × "+p.h+" px"}</span><span class="num">${sc?fmt2(Math.max(wM,hM))+" × "+fmt2(fh())+" = "+fmt2(Math.max(wM,hM)*fh())+" ㎡":"—"}</span></div>`;
  1556. }).join("")+(sc?`<div class="total-line" style="border-top:2px solid var(--primary);margin-top:6px;padding-top:9px;font-weight:700;"><span>柜体总投影面积合计</span><span class="num"><b>${fmt2(cabTot)} ㎡</b></span></div>`:""):'<div class="total-line"><span>未识别到柜体(可在下一步手动添加)</span></div>';
  1557. const sides=[S.kitW,S.kitH].map(v=>+v||0).filter(v=>v>0);
  1558. let kt="",kT=0,kF="";
  1559. if(sides.length>=2){const w=Math.max(...sides),h=Math.min(...sides);
  1560. if(h<=2){kt="L型";kT=Math.max(w+h-1.6,0);kF="L型:"+fmt2(w)+" + "+fmt2(h)+" − 1.6";}
  1561. else{kt="U型";kT=Math.max(w*2+h-2.2,0);kF="U型:"+fmt2(w)+" + "+fmt2(w)+" + "+fmt2(h)+" − 2.2";}}
  1562. $("q-ai-kit-type").textContent=kT>0?kt+" · 总长 "+fmt2(kT)+"m":(S.kitBox?"已定位":"未定位");
  1563. const rows=[];
  1564. if(S.kitBox)rows.push(`<div class="total-line"><span>厨房尺寸:${fmt2(Math.max(S.kitBox.wM,S.kitBox.hM))} m × ${fmt2(Math.min(S.kitBox.wM,S.kitBox.hM))} m</span></div>`);
  1565. if(kT>0)rows.push(`<div class="total-line"><span>${kF}</span><span class="num">可计费总长 <b>${fmt2(kT)} m</b></span></div>`);
  1566. if(!rows.length)rows.push('<div class="total-line"><span>未能自动定位厨房台面带 → 请在下一步补充一个已知尺寸</span></div>');
  1567. $("q-ai-kit-body").innerHTML=rows.join("");
  1568. }
  1569. /* ========== 对象表(步骤3) ========== */
  1570. let KIT_TR=null,TOTAL_TR=null;
  1571. function rebuildObjs(){ensureS();
  1572. const tb=$("objBody");tb.innerHTML="";KIT_TR=null;TOTAL_TR=null;
  1573. /* 厨房行:与柜体合并同表;宽=最长边、高=最短边、总长度(L/U型自动算,可改)、单价(元/m,默认材质档最高,可改) */
  1574. if(S.kitW>0&&S.kitH>0){const tr=document.createElement("tr");KIT_TR=tr;
  1575. tr.innerHTML=`<td><b> 厨房</b></td>
  1576. <td class="text-right"><input type="number" step="0.01" min="0" value="${S.kitW}" style="width:86px;text-align:right" data-f="kitW" title="宽(最长边),单位米,可修改"></td>
  1577. <td class="text-right"><input type="number" step="0.01" min="0" value="${S.kitH}" style="width:86px;text-align:right" data-f="kitH" title="高=最短边,单位米,可修改"></td>
  1578. <td class="text-right">—</td>
  1579. <td class="text-right" data-o="klen" title="总长度由宽/高自动计算:L型=长+短−1.6,U型=长+长+短−2.2">—</td>
  1580. <td class="text-right"><input type="number" step="50" min="0" style="width:86px;text-align:right" data-f="kitPrice" title="单价 元/m,默认材质档最高单价,可修改"> <small style="color:var(--text-muted)">/m</small></td>
  1581. <td class="text-right" data-o="kamt">-</td>
  1582. <td><button class="btn btn-danger btn-sm">删</button></td>`;
  1583. tr.querySelectorAll("input").forEach(inp=>inp.oninput=()=>{S[inp.dataset.f]=inp.value===""?null:(+inp.value||0);recalc();/* 改宽/高→总长度自动重算 */});
  1584. tr.querySelector("button").onclick=()=>{S.kitW=null;S.kitH=null;S.kitLen=null;S.kitPrice=null;S.kitBox=null;rebuildObjs();recalc();};
  1585. tb.appendChild(tr);tb.appendChild(buildExtraRow(S,true));}
  1586. S.objs.forEach(p=>{const tr=document.createElement("tr");
  1587. tr.innerHTML=`<td><input type="text" value="${p.name}" style="width:120px" data-f="name"></td>
  1588. <td class="text-right"><input type="number" step="0.01" min="0" style="width:86px;text-align:right" data-f="lenM" title="宽(最长边),单位米,可修改"></td>
  1589. <td class="text-right"><input type="number" step="0.01" min="0" style="width:86px;text-align:right" data-f="hgtM" title="高,单位米,默认层高,可修改"></td><td class="text-right" data-o="area">-</td><td class="text-right">—</td>
  1590. <td class="text-right"><input type="number" step="50" min="0" style="width:86px;text-align:right" data-f="price" title="单价 元/㎡,默认材质档最高单价,可修改"> <small style="color:var(--text-muted)">/㎡</small></td>
  1591. <td class="text-right" data-o="amt">-</td>
  1592. <td><button class="btn btn-danger btn-sm">删</button></td>`;
  1593. tr.querySelectorAll("input").forEach(inp=>inp.oninput=()=>{if(inp.dataset.f==="name")p.name=inp.value;else if(inp.dataset.f==="lenM")p.lenM=inp.value===""?null:(+inp.value||0);else if(inp.dataset.f==="hgtM")p.hgtM=inp.value===""?null:(+inp.value||0);else p.price=inp.value===""?null:(+inp.value||0);recalc();});
  1594. tr.querySelector("button").onclick=()=>{S.objs.splice(S.objs.indexOf(p),1);rebuildObjs();recalc();};
  1595. p._tr=tr;tb.appendChild(tr);tb.appendChild(buildExtraRow(p,false));});
  1596. /* 合计行:总面积 + 全部总价(含勾选增值项) */
  1597. const ttr=document.createElement("tr");TOTAL_TR=ttr;ttr.style.cssText="font-weight:700;background:#F7FAFC;";
  1598. ttr.innerHTML=`<td>合计(含勾选增值)</td><td></td><td></td><td class="text-right" data-o="tArea">-</td><td></td><td></td><td class="text-right" data-o="tAmt">-</td><td></td>`;
  1599. tb.appendChild(ttr);
  1600. }
  1601. $("addKit").onclick=()=>{if(!(S.kitW>0))S.kitW=3;if(!(S.kitH>0))S.kitH=2;rebuildObjs();recalc();};
  1602. $("addCab").onclick=()=>{S.objs.push({name:"衣柜",kind:"cab",src:"手动",w:0,h:0,lenM:null,hgtM:null,price:null,extras:mkExtras("衣柜",false)});rebuildObjs();recalc();};
  1603. $("addPanel").onclick=()=>{const nm="护墙板"+(S.objs.filter(o=>o.kind==="panel").length+1);S.objs.push({name:nm,kind:"panel",src:"手动",w:0,h:0,lenM:null,hgtM:null,price:null,extras:mkExtras(nm,false)});rebuildObjs();recalc();};
  1604. /* ========== 增值项下拉(每个柜体/厨房行下方,勾选计入计价) ========== */
  1605. function availExtras(isKit){return EXTRA.filter(e=>S.ruleX[e.id]&&(isKit?(e.kit||e.both):(!e.kit||e.both)));}
  1606. /* 门板配置联动单价(规则#10):厨房地柜/吊柜默认价随所选门板配置变化,其余增值项取目录价 */
  1607. function priceOf(e,isKit){const d=DOORS[S.door==null?2:S.door]||DOORS[2];
  1608. if(isKit&&e.id==="dg"&&d[4]!=null)return d[4];
  1609. if(isKit&&e.id==="dg2"&&d[3]!=null)return d[3];
  1610. return e.p;}
  1611. function extraStore(isKit,p){return isKit?S.kitExtras:(p.extras||(p.extras=mkExtras(p.name,false)));}
  1612. function buildExtraRow(p,isKit){
  1613. const tr=document.createElement("tr");tr.style.background="#FBFDFF";
  1614. const td=document.createElement("td");td.colSpan=8;td.style.cssText="padding:2px 12px 10px 34px;";
  1615. const btn=document.createElement("button");btn.className="btn btn-outline btn-sm";btn.style.fontSize="12px";
  1616. /* 增值项选项默认折叠,点击按钮才展开下拉选项(再点收起) */
  1617. const panel=document.createElement("div");panel.className="hidden";
  1618. panel.style.cssText="margin-top:8px;border:1px solid #E2E8F0;border-radius:8px;background:#fff;padding:6px 14px;max-width:860px;box-shadow:0 4px 12px rgba(0,0,0,.06);";
  1619. btn.onclick=()=>{panel.classList.toggle("hidden");recalc();};
  1620. td.appendChild(btn);td.appendChild(panel);tr.appendChild(td);
  1621. p._ex={btn,panel,rows:{}};
  1622. /* 厨房:12种门板配置下拉(规则#10),切换后联动增值项「地柜/吊柜」默认单价 */
  1623. if(isKit){const drow=document.createElement("div");drow.style.cssText="display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:6px 0 8px;border-bottom:1px solid #E2E8F0;font-size:12px;";
  1624. const lb=document.createElement("span");lb.style.cssText="font-weight:700;color:#276749;";lb.textContent="🚪 门板配置(12种)";
  1625. const sel=document.createElement("select");sel.style.cssText="padding:4px 8px;border:1px solid #9AE6B4;border-radius:6px;font-size:12px;background:#fff;max-width:420px;";
  1626. DOORS.forEach((d,i)=>{const op=document.createElement("option");op.value=i;op.textContent=`${d[1]}(${d[2]})— 吊柜 ¥${fmt(d[3])}/m${d[4]!=null?" · 地柜 ¥"+fmt(d[4])+"/m":" · 无地柜价"}`;if(i===(S.door==null?2:S.door))op.selected=true;sel.appendChild(op);});
  1627. sel.onchange=()=>{S.door=+sel.value;rebuildObjs();recalc();toast("门板配置已切换:"+DOORS[S.door][1]+"(地柜/吊柜单价已联动)");};
  1628. const hint=document.createElement("small");hint.style.cssText="color:var(--text-muted);";hint.textContent="联动增值项「地柜/吊柜」单价;若下拉中未见地柜/吊柜,请先在 规则配置→增值项配置 勾选「加入下拉」";
  1629. drow.appendChild(lb);drow.appendChild(sel);drow.appendChild(hint);panel.appendChild(drow);}
  1630. const store=extraStore(isKit,p),presetIds=Object.keys(store);
  1631. const list=availExtras(isKit).sort((a,b)=>(presetIds.indexOf(b.id)>=0?1:0)-(presetIds.indexOf(a.id)>=0?1:0));
  1632. if(!list.length)panel.innerHTML='<div class="note" style="padding:6px 0;">后台规则配置中暂无可加入的增值项(规则配置 → 增值项配置 勾选「加入下拉」)。</div>';
  1633. if(presetIds.length&&list.length>presetIds.length){const d0=document.createElement("div");d0.style.cssText="font-size:11px;color:var(--primary);font-weight:700;padding:4px 0 2px;";d0.textContent="▣ 该柜体预设增值项(源自报价表)";panel.appendChild(d0);}
  1634. list.forEach(e=>{if(presetIds.length&&presetIds.indexOf(e.id)<0&&panel.children.length===presetIds.length+1+(isKit?1:0)){const d1=document.createElement("div");d1.style.cssText="font-size:11px;color:var(--text-muted);padding:6px 0 2px;border-top:1px dashed #E2E8F0;margin-top:4px;";d1.textContent="▣ 其他可用增值项";panel.appendChild(d1);}
  1635. const st=store[e.id]||(store[e.id]={on:false,v:0});
  1636. if(!st.d)st.d=["","",""]; /* 尺寸三框:默认空,可手动编辑 */
  1637. const row=document.createElement("div");row.style.cssText="display:flex;gap:10px;align-items:center;flex-wrap:wrap;padding:5px 0;border-bottom:1px dashed #EDF2F7;font-size:12px;";
  1638. const meta=dimMeta(e,isKit),sch=dimScheme(e,isKit);
  1639. row.innerHTML=`<input type="checkbox" style="width:15px"><span style="min-width:118px;font-weight:600">${e.n}</span><span style="display:inline-flex;align-items:center;gap:2px;"><input type="number" min="0" step="any" style="width:70px;text-align:right" data-x="q" title="数量,可手动输入"><small style="color:var(--text-muted)">数量</small></span>`+
  1640. meta[0].map((lb,i)=>`<span style="display:inline-flex;align-items:center;gap:2px;"><input type="number" step="any" data-d="${i}" placeholder="${lb}" title="${lb},单位${meta[1][i]}${i===2&&sch==="wha"?",由 宽×高 自动算出":i===2&&sch==="wte"?",由 宽−套餐 自动算出":""},可手动编辑" style="width:62px;text-align:right"><small style="color:var(--text-muted)">${meta[1][i]}</small></span>`).join("")+
  1641. `<span style="display:inline-flex;align-items:center;gap:2px;"><input type="number" min="0" step="any" data-x="p" title="单价,默认${fmt(priceOf(e,isKit))},可手动修改" style="width:74px;text-align:right"><small style="color:var(--text-muted)">${e.u}</small></span><span class="text-right" data-x="amt" style="min-width:84px;color:var(--primary);font-weight:600;">¥0</span><input type="text" placeholder="备注" title="备注(可手动编辑)" data-x="rm" style="flex:1;min-width:140px;border:1px solid #E2E8F0;border-radius:6px;padding:4px 8px;font-size:11px;color:var(--text-muted);background:#FAFBFC;">`;
  1642. const cb=row.querySelector('input[type="checkbox"]'),inp=row.querySelector('[data-x="q"]'),amt=row.querySelector('[data-x="amt"]'),rm=row.querySelector('[data-x="rm"]'),pin=row.querySelector('[data-x="p"]');
  1643. if(st.rm==null)st.rm=e.sc||""; /* 备注默认填入原规格说明,可手动修改 */
  1644. cb.checked=st.on;inp.value=st.v||"";rm.value=st.rm;pin.value=st.p!=null?st.p:priceOf(e,isKit); /* 单价默认目录价(厨房地柜/吊柜随门板配置联动),可手动改 */
  1645. row.querySelectorAll("[data-d]").forEach(di=>{di.value=st.d[+di.dataset.d]||"";di.oninput=()=>{st.d[+di.dataset.d]=di.value;autoDim(e,st,row,isKit);recalc();};});
  1646. cb.onchange=()=>{st.on=cb.checked;recalc();};
  1647. inp.oninput=()=>{st.v=+inp.value||0;recalc();};
  1648. pin.oninput=()=>{st.p=pin.value===""?null:(+pin.value||0);recalc();};
  1649. rm.oninput=()=>{st.rm=rm.value;};
  1650. p._ex.rows[e.id]={amt,d2:row.querySelector('[data-d="2"]')};
  1651. panel.appendChild(row);});
  1652. return tr;
  1653. }
  1654. /* 增值项尺寸三框的标签与单位,按计算分类(源自6.18欧阳路预估报价表公式):
  1655. 投影面积类(c=area:见光板/护墙板等)=宽/高/面积;超出米数类(c=over:台面超出部分等)=宽/套餐/超出;
  1656. 数量类=厨房默认宽/套餐/超出、其余默认宽/高/面积(仅作记录,不影响总价) */
  1657. function dimMeta(e,kit){if(e.c==="area")return[["宽","高","面积"],["m","m","㎡"]];if(e.c==="over")return[["宽","套餐","超出"],["m","m","m"]];return kit?[["宽","套餐","超出"],["m","m","m"]]:[["宽","高","面积"],["m","m","㎡"]];}
  1658. function dimScheme(e,kit){return e.c==="area"?"wha":e.c==="over"?"wte":(kit?"wte":"wha");}
  1659. /* 自动计算第三框(所有增值项行均联动):宽/高/面积口径 面积=宽×高;宽/套餐/超出口径 超出=宽−套餐(写回 st.d 与输入框) */
  1660. function autoDim(e,st,row,kit){const sch=dimScheme(e,kit);const d=st.d;if(d[0]===""||d[1]==="")return;
  1661. const v=+((sch==="wha"?(+d[0])*(+d[1]):(+d[0])-(+d[1])).toFixed(4));
  1662. st.d[2]=String(v);const el=row&&row.querySelector('[data-d="2"]');if(el&&document.activeElement!==el)el.value=v;}
  1663. /* 计算某柜体(或厨房)已勾选增值项:勾选计入、未勾选不计;同步更新下拉内金额与按钮标签
  1664. 总价口径:数量类=数量×单价;投影面积类=面积(宽×高)×单价;超出米数类=超出(宽−套餐)×单价 */
  1665. function calcExtras(p,isKit){
  1666. const store=extraStore(isKit,p);let sum=0;const lines=[];
  1667. for(const id in store){if(!S.ruleX[id])continue;const st=store[id];if(!st||!st.on)continue;
  1668. const e=EXTRA.find(x=>x.id===id);if(!e)continue;
  1669. const d=(st.d||["","",""]).slice();const sch=dimScheme(e,isKit);
  1670. if(d[2]===""&&d[0]!==""&&d[1]!==""){d[2]=String(+((sch==="wha"?(+d[0])*(+d[1]):(+d[0])-(+d[1])).toFixed(4)));st.d=d;}
  1671. const v=+st.v||0,price=st.p!=null?+st.p:priceOf(e,isKit);
  1672. const a=(e.c==="area"||e.c==="over")?((d[2]!==""&&d[2]!=null?+d[2]||0:0)*price):(v*price);
  1673. sum+=a;lines.push({e,v,a,d,kit:isKit,price,scheme:dimScheme(e,isKit)});
  1674. if(p._ex&&p._ex.rows[id]){p._ex.rows[id].amt.textContent="¥"+fmt(a);
  1675. const d2=p._ex.rows[id].d2;if(d2&&document.activeElement!==d2&&d2.value!==(d[2]||""))d2.value=d[2]||"";}}
  1676. if(p._ex){for(const rid in p._ex.rows){if(!lines.some(L=>L.e.id===rid))p._ex.rows[rid].amt.textContent="¥0";}
  1677. const open=!p._ex.panel.classList.contains("hidden");
  1678. p._ex.btn.textContent="🧩 增值项"+(lines.length?"(已勾 "+lines.length+" 项 · ¥"+fmt(sum)+")":"(点击下拉勾选预设增值项)")+(open?" ▴":" ");}
  1679. return {sum,lines};
  1680. }
  1681. /* ========== 计算引擎 ========== */
  1682. let LASTQ={baseSum:0,kSum:0,wSum:0,varArea:0,cabArea:0,cabAmt:0,baseRows:[],valueRows:[]};
  1683. function recalc(){ensureS();
  1684. const pr=precision(),sc=dimScale();
  1685. $("precBadge").className="badge "+pr.cls;$("precBadge").textContent=pr.txt;
  1686. $("q-quote-prec").className="badge "+pr.cls;$("q-quote-prec").textContent=pr.txt;
  1687. $("sceneChip").textContent="场景:"+(S.ft==="cad"?"CAD图":S.redbox?"户型图 · B 有红框":"户型图 · A 无红框");
  1688. $("scaleInfo").textContent=S.ft==="cad"?"CAD矢量直读(mm)":(S.scale?`1px=${(sc*1000).toFixed(2)}mm(${S.scaleSrc})`:"比例未建立");
  1689. /* 基础项:厨房行(合并表,总价=总长度×单价) */
  1690. let baseSum=0,varArea=0,cabArea=0,cabAmt=0,kSum=0;const baseRows=[];
  1691. const ks=[S.kitW,S.kitH].map(v=>+v||0).filter(v=>v>0);let kT=0,kt="未判定";
  1692. if(ks.length>=2){const w=Math.max(...ks),h=Math.min(...ks);
  1693. if(h<=2){kt="L型";kT=Math.max(w+h-1.6,0);}else{kt="U型";kT=Math.max(w*2+h-2.2,0);}}
  1694. const kitLen=kT,kitPrice=S.kitPrice!=null?S.kitPrice:unit(),kAmt=kitLen*kitPrice; /* 总长度只读:随宽/高自动变化 */
  1695. if(KIT_TR){const kl=KIT_TR.querySelector('[data-o="klen"]');if(kl)kl.textContent=kitLen?fmt2(kitLen):"—";}
  1696. if(ks.length>=2){kSum=kAmt;baseSum+=kAmt;
  1697. baseRows.push({name:"厨房·"+kt,kind:"kitchen",src:S.kitBox?"AI":"手动",len:kitLen,w:Math.max(...ks),h:Math.min(...ks),area:null,amt:kAmt,price:kitPrice,pm:"m"});
  1698. if(KIT_TR){const pi=KIT_TR.querySelector('[data-f="kitPrice"]');if(pi&&document.activeElement!==pi)pi.value=Math.round(kitPrice);
  1699. KIT_TR.querySelector('[data-o="kamt"]').textContent="¥"+fmt(kAmt);}}
  1700. /* 基础项:柜体/护墙板(总价=面积×单价,单价默认材质档最高、可手动改) */
  1701. S.objs.forEach(p=>{const len=p.lenM!=null?p.lenM:Math.max(p.w||0,p.h||0)*sc,hgt=p.hgtM!=null?p.hgtM:fh(),price=p.price!=null?p.price:unit(),area=len*hgt,amt=area*price;baseSum+=amt;varArea+=area;if(p.kind!=="panel"){cabArea+=area;cabAmt+=amt;}
  1702. baseRows.push({name:p.name,kind:p.kind,src:p.src,len,w:len,h:hgt,area,amt,price,pm:"㎡"});
  1703. if(p._tr){const li=p._tr.querySelector('[data-f="lenM"]');if(li&&document.activeElement!==li)li.value=len?+len.toFixed(2):"";
  1704. const hi=p._tr.querySelector('[data-f="hgtM"]');if(hi&&document.activeElement!==hi)hi.value=hgt?+hgt.toFixed(2):"";
  1705. const pi=p._tr.querySelector('[data-f="price"]');if(pi&&document.activeElement!==pi)pi.value=Math.round(price);
  1706. p._tr.querySelector('[data-o="area"]').textContent=fmt2(area);
  1707. p._tr.querySelector('[data-o="amt"]').textContent="¥"+fmt(amt);}});
  1708. $("baseTotal").textContent="¥"+fmt(baseSum);
  1709. $("fbRow").style.display=S.scale||S.ft==="cad"?"none":"flex";
  1710. /* 增值项:各柜体/厨房下拉勾选(勾选计入、未勾选不计) */
  1711. let wSum=0;const valueRows=[];
  1712. const pushEx=(label,res)=>{wSum+=res.sum;res.lines.forEach(L=>{
  1713. valueRows.push({item:label+"·"+L.e.n,label:label,sub:L.e.n,spec:L.e.sc||L.e.u,qty:fmt2(L.v),price:fmt(L.price)+" "+L.e.u,amt:L.a,kit:L.kit,d:L.d,scheme:L.scheme,c:L.e.c});});};
  1714. if(S.kitW>0&&S.kitH>0)pushEx("厨房",calcExtras(S,true));
  1715. S.objs.forEach(p=>pushEx(p.name,calcExtras(p,false)));
  1716. if(TOTAL_TR){TOTAL_TR.querySelector('[data-o="tArea"]').textContent=fmt2(varArea);TOTAL_TR.querySelector('[data-o="tAmt"]').textContent="¥"+fmt(baseSum+wSum);}
  1717. $("valueTotal").textContent="¥"+fmt(wSum);
  1718. $("grandTotal").textContent="¥"+fmt(baseSum+wSum);
  1719. LASTQ={baseSum,kSum,wSum,varArea,cabArea,cabAmt,kitLen,baseRows,valueRows};
  1720. renderStep4();
  1721. }
  1722. /* ========== 步骤4:报价单渲染 ========== */
  1723. function renderTiers(){
  1724. const box=$("q-ai-tier-selector");box.innerHTML="";
  1725. MATS.forEach((m,i)=>{const d=document.createElement("div");
  1726. d.className="tier-card"+(i===S.mat?" selected":"");
  1727. 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>`;
  1728. d.onclick=()=>{S.mat=i;renderTiers();recalc();};
  1729. box.appendChild(d);});
  1730. }
  1731. function renderStep4(){
  1732. const m=MATS[S.mat],Q=LASTQ;
  1733. const fixed=Q.wSum;
  1734. const lo=Q.varArea*m.min+Q.kitLen*m.min+fixed,hi=Q.varArea*m.max+Q.kitLen*m.max+fixed,cur=Q.baseSum+fixed;
  1735. $("q-quote-meta").textContent=`生成日期:${new Date().toLocaleDateString("zh-CN")} | 材质档:${m.n} | 浮动率:${S.rate}%`;
  1736. $("q-ai-float-badge").textContent=S.rate+"%";
  1737. $("q-ai-float-slider").value=S.rate;
  1738. $("global-float-slider").value=S.rate;$("global-float-badge").textContent=S.rate+"%";
  1739. document.querySelectorAll(".float-preset button[data-aifp]").forEach(b=>b.classList.toggle("active",+b.dataset.aifp===Math.round(S.rate)));
  1740. $("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>`;
  1741. /* 明细表:按空间归为一类,增值项列于所属空间行下方 */
  1742. /* 宽/高列:厨房=最长边/最短边,其余柜体=最长边/层高;面积/总长度列:厨房(pm=m)显示总长度,其余柜体显示面积 */
  1743. const dv=x=>x!==""&&x!=null?x:"";
  1744. const vrow6=v=>`<tr style="background:#F7FAFC;color:#4A5568;"><td style="padding-left:26px;">└ ${v.sub}${v.c!=="area"&&v.c!=="over"&&+v.qty?` ×${v.qty}`:""}${v.spec&&!/^元/.test(v.spec)?`(${v.spec})`:""}</td><td class="text-right">${dv(v.d&&v.d[0])}</td><td class="text-right">${dv(v.d&&v.d[1])}</td><td class="text-right">${dv(v.d&&v.d[2])}</td><td class="text-right">${v.price}</td><td class="text-right">¥${fmt(v.amt)}</td></tr>`;
  1745. $("q-quote-base-body").innerHTML=Q.baseRows.length?Q.baseRows.map(r=>{
  1746. const vrs=Q.valueRows.filter(v=>v.label===(r.kind==="kitchen"?"厨房":r.name));
  1747. return `<tr><td>${r.name}</td><td class="text-right">${fmt2(r.w)}</td><td class="text-right">${fmt2(r.h)}</td><td class="text-right">${r.pm==="m"?fmt2(r.len):(r.area==null?"—":fmt2(r.area))}</td><td class="text-right">¥${fmt(r.price)}/${r.pm}</td><td class="text-right">¥${fmt(r.amt)}</td></tr>`+vrs.map(vrow6).join("");
  1748. }).join("")+`<tr style="font-weight:700;background:#F7FAFC;"><td colspan="3">柜体总投影面积合计</td><td class="text-right">${fmt2(Q.cabArea)}</td><td></td><td class="text-right">¥${fmt(Q.cabAmt)}</td></tr>`:'<tr><td colspan="6" class="note">暂无</td></tr>';
  1749. $("q-grand-total-value").textContent="¥"+fmt(cur);
  1750. $("q-grand-total-area").textContent=`柜体总投影面积 ${fmt2(Q.cabArea)}㎡ · 总投影面积 ${fmt2(Q.varArea)}㎡ · ${m.n} ¥${fmt2(unit())}/㎡`;
  1751. applyBrand();
  1752. $("q-grand-total-note").textContent=cur>0?`基础项 ¥${fmt(Q.baseSum)} + 增值项 ¥${fmt(fixed)}(${precision().txt})`:"上传图纸并完成 AI 识别后自动计算";
  1753. $("q-step5-disclaimer").textContent=`⚠️ 声明:本报价为 AI 辅助生成的预估价格(${precision().txt}),最终成交价以实际量尺和确认为准。护墙板平面图不自动识别;「厨房台面深度600mm」隐式参照物适用于场景A/B,场景B 标注尺寸单位由该锚点交叉推断。`;
  1754. $("global-est-range").textContent=`¥${fmt(lo)} ~ ¥${fmt(hi)}`;
  1755. $("global-est-mid").textContent="参考总价: ¥"+fmt(cur);
  1756. }
  1757. function qOnAiFloatChange(v){S.rate=Math.min(15,Math.max(10,+v||10));renderTiers();recalc();}
  1758. function qSetAiFloat(v){S.rate=Math.min(15,Math.max(10,+v||10));renderTiers();recalc();}
  1759. /* ========== 报价单管理 ========== */
  1760. let QUOTES=[];
  1761. try{QUOTES=JSON.parse(localStorage.getItem("aiq_new_quotes")||"[]");}catch(e){QUOTES=[];}
  1762. function saveQuotes(){try{localStorage.setItem("aiq_new_quotes",JSON.stringify(QUOTES));}catch(e){}}
  1763. 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||{}}))});}
  1764. function qSaveQuote(silent){ensureS();
  1765. if(!S.objs.length&&!LASTQ.baseSum&&!LASTQ.wSum){if(!silent)toast("暂无计价内容,请先上传图纸完成AI识别","toast-error");return;}
  1766. const m=MATS[S.mat],fixed=LASTQ.wSum;
  1767. const q={id:Date.now(),name:("报价单 "+(QUOTES.length+1)),date:new Date().toLocaleString("zh-CN"),mat:m.n,rate:S.rate,
  1768. lo:LASTQ.varArea*m.min+LASTQ.kitLen*m.min+fixed,hi:LASTQ.varArea*m.max+LASTQ.kitLen*m.max+fixed,cur:LASTQ.baseSum+fixed,prec:precision().txt,snap:snapshot()};
  1769. QUOTES.unshift(q);saveQuotes();renderQuoteList();
  1770. if(!silent)toast("已存入报价单列表 ✓","toast-success");
  1771. }
  1772. function renderQuoteList(){
  1773. const tb=$("quote-table-body");tb.innerHTML="";
  1774. $("quote-empty").style.display=QUOTES.length?"none":"block";
  1775. QUOTES.forEach((q,i)=>{const tr=document.createElement("tr");
  1776. tr.innerHTML=`<td><b>${q.name}</b></td><td>${q.date}</td><td>${q.mat}(${q.rate}%)</td>
  1777. <td class="text-right">¥${fmt(q.lo)} — ¥${fmt(q.hi)}</td><td><span class="badge est" style="font-size:10px;">${q.prec}</span></td>
  1778. <td></td>`;
  1779. const td=tr.children[5];
  1780. const b1=document.createElement("button");b1.className="btn btn-outline btn-sm";b1.textContent="打开";
  1781. b1.onclick=e=>{e.stopPropagation();qOpenQuote(q);};
  1782. const b2=document.createElement("button");b2.className="btn btn-danger btn-sm";b2.textContent="删除";b2.style.marginLeft="6px";
  1783. b2.onclick=e=>{e.stopPropagation();QUOTES.splice(i,1);saveQuotes();renderQuoteList();toast("已删除");};
  1784. td.appendChild(b1);td.appendChild(b2);
  1785. tr.style.cursor="pointer";tr.onclick=()=>qOpenQuote(q);
  1786. tb.appendChild(tr);});
  1787. }
  1788. function qOpenQuote(q){
  1789. try{const snap=JSON.parse(q.snap);
  1790. 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});
  1791. ensureS(); /* 旧版存档可能缺 objs 等字段 → 回退空数组,避免恢复后 undefined 崩溃 */
  1792. S.kitExtras=snap.kitEx||mkExtras("",true);
  1793. S.ruleX=Object.assign(Object.fromEntries(EXTRA.map(e=>[e.id,!e.off])),snap.ruleX||{});
  1794. S.brand=snap.brand!=null?snap.brand:0;
  1795. S.door=snap.door!=null?snap.door:2;
  1796. /* 原图与识别过程不随报价单保存:清掉上一单残留的画布/识别中间态,避免误导 */
  1797. S.kitBox=null;S.redArrow=null;img=null;overlays=[];ctx.clearRect(0,0,cv.width,cv.height);
  1798. $("q-ai-log").innerHTML="";logAI("📂 已打开存档报价单:原图与识别过程不随单保存,计价数据已恢复。","b");
  1799. $("dc-type").textContent=snap.ft==="cad"?"CAD图":"平面户型图";
  1800. $("dc-red").textContent=snap.ft==="cad"?"—":(snap.redbox?"有红框":"无红框");
  1801. $("dc-rule").textContent="存档恢复(原图未保存)";
  1802. $("q-ai-scene-chip").textContent="存档报价单(原图未保存)";$("q-status-drawing").textContent="⬜ 图纸:未随单保存";
  1803. $("q-btn-analyze").disabled=true;$("q-step2-next-btn").disabled=false;
  1804. /* 存档→打开闭环:存档内增值项勾选/数量/尺寸/备注(ex)原样恢复,未存档时才重建默认,避免用户勾选丢失 */
  1805. S.objs.forEach(o=>{if(o.ex){o.extras=o.ex;delete o.ex;}if(!o.extras||!Object.keys(o.extras).length)o.extras=mkExtras(o.name||"",false);});
  1806. rebuildObjs();recalc();renderAIResults();
  1807. $("editor-title").textContent=q.name;
  1808. switchView("editor");qOpenEditorStep(4);
  1809. toast("已打开:"+q.name);
  1810. }catch(e){toast("打开失败","toast-error");}
  1811. }
  1812. /* ========== 品牌报价抬头 & 导出Excel ========== */
  1813. const BRANDS=[{t:"万骊全屋定制预算表",a:"万骊总部旗舰店"},{t:"华拓全屋定制预算表",a:"华拓总部店"}];
  1814. function applyBrand(){const b=BRANDS[S.brand]||BRANDS[0];
  1815. if($("q-quote-title"))$("q-quote-title").textContent=b.t;
  1816. if($("q-quote-addr"))$("q-quote-addr").textContent="门店地址:"+b.a;
  1817. const sel=$("q-brand-select");if(sel&&+sel.value!==S.brand)sel.value=String(S.brand);}
  1818. function _crc32(u8){let T=_crc32.t;if(!T){T=_crc32.t=new Uint32Array(256);for(let n=0;n<256;n++){let c=n;for(let k=0;k<8;k++)c=c&1?0xEDB88320^(c>>>1):c>>>1;T[n]=c;}}
  1819. let crc=0xFFFFFFFF;for(let i=0;i<u8.length;i++)crc=(crc>>>8)^T[(crc^u8[i])&0xFF];return (crc^0xFFFFFFFF)>>>0;}
  1820. function _zipStore(files){ /* 最小ZIP(stored不压缩):本地头+中心目录+EOCD */
  1821. const enc=new TextEncoder(),parts=[],cd=[];let off=0;
  1822. files.forEach(f=>{const nm=enc.encode(f.name),data=f.data,crc=_crc32(data);
  1823. const lh=new DataView(new ArrayBuffer(30));
  1824. lh.setUint32(0,0x04034b50,true);lh.setUint16(4,20,true);
  1825. lh.setUint32(14,crc,true);lh.setUint32(18,data.length,true);lh.setUint32(22,data.length,true);
  1826. lh.setUint16(26,nm.length,true);
  1827. parts.push(new Uint8Array(lh.buffer),nm,data);
  1828. cd.push({nm,crc,size:data.length,off});off+=30+nm.length+data.length;});
  1829. let cdSize=0;const cdParts=[];
  1830. cd.forEach(c=>{const dv=new DataView(new ArrayBuffer(46));
  1831. dv.setUint32(0,0x02014b50,true);dv.setUint16(4,20,true);dv.setUint16(6,20,true);
  1832. dv.setUint32(16,c.crc,true);dv.setUint32(20,c.size,true);dv.setUint32(24,c.size,true);
  1833. dv.setUint16(28,c.nm.length,true);dv.setUint32(42,c.off,true);
  1834. cdParts.push(new Uint8Array(dv.buffer),c.nm);cdSize+=46+c.nm.length;});
  1835. const eo=new DataView(new ArrayBuffer(22));
  1836. eo.setUint32(0,0x06054b50,true);eo.setUint16(8,cd.length,true);eo.setUint16(10,cd.length,true);
  1837. eo.setUint32(12,cdSize,true);eo.setUint32(16,off,true);
  1838. const all=parts.concat(cdParts,[new Uint8Array(eo.buffer)]);
  1839. let tot=0;all.forEach(a=>tot+=a.length);const out=new Uint8Array(tot);let p=0;
  1840. all.forEach(a=>{out.set(a,p);p+=a.length;});return out;}
  1841. /* 导出格式完全复刻 6.18号欧阳路预估报价.xlsx:styles.xml/theme.xml 原样复制(微软雅黑;标题18加粗;
  1842. 表头/合计橙底th2;10列布局/列宽/行高/合并/每格样式索引与参考表一致) */
  1843. function xlsxBuild(doc){ /* doc={rows:[[{v,s}]],merges:[],cols:[{w,st}],heights:{},stylesRaw,themeRaw} → .xlsx字节 */
  1844. const esc=s=>String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
  1845. const col=i=>{let s="";i++;while(i>0){s=String.fromCharCode(65+(i-1)%26)+s;i=Math.floor((i-1)/26);}return s;};
  1846. let body="";doc.rows.forEach((row,ri)=>{const ht=doc.heights&&doc.heights[ri];
  1847. let cells="";row.forEach((c,ci)=>{const v=c&&c.v!==undefined?c.v:c,s=c&&c.s!==undefined?c.s:0,ref=col(ci)+(ri+1);
  1848. if(typeof v==="number"&&isFinite(v))cells+='<c r="'+ref+'" s="'+s+'"><v>'+v+'</v></c>';
  1849. else if(v!==""&&v!=null)cells+='<c r="'+ref+'" s="'+s+'" t="inlineStr"><is><t xml:space="preserve">'+esc(v)+'</t></is></c>';
  1850. else cells+='<c r="'+ref+'" s="'+s+'"/>';});
  1851. body+='<row r="'+(ri+1)+'" customFormat="1"'+(ht?' ht="'+ht+'" customHeight="1"':'')+' spans="1:'+row.length+'">'+cells+'</row>';});
  1852. const colsXml=(doc.cols||[]).map((c,i)=>'<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+c.w+'" style="'+(c.st!=null?c.st:3)+'" customWidth="1"/>').join("");
  1853. const mergeXml=doc.merges&&doc.merges.length?'<mergeCells count="'+doc.merges.length+'">'+doc.merges.map(m=>'<mergeCell ref="'+m+'"/>').join("")+'</mergeCells>':"";
  1854. const enc=new TextEncoder();
  1855. const hasTheme=!!doc.themeRaw;
  1856. const STYLES=doc.stylesRaw||'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><fonts count="5"><font><sz val="11"/><color rgb="FF000000"/><name val="宋体"/><charset val="134"/></font><font><b/><sz val="14"/><color rgb="FF000000"/><name val="宋体"/><charset val="134"/></font><font><b/><sz val="12"/><color rgb="FF000000"/><name val="宋体"/><charset val="134"/></font><font><sz val="11"/><color rgb="FF9C6500"/><name val="宋体"/><charset val="134"/></font><font><b/><sz val="11"/><color rgb="FF9C6500"/><name val="宋体"/><charset val="134"/></font></fonts><fills count="3"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill><fill><patternFill patternType="solid"><fgColor rgb="FFFFCC99"/><bgColor indexed="64"/></patternFill></fill></fills><borders count="4"><border><left/><right/><top/><bottom/><diagonal/></border><border><left style="thin"><color rgb="FFB2B2B2"/></left><right style="thin"><color rgb="FFB2B2B2"/></right><top style="thin"><color rgb="FFB2B2B2"/></top><bottom style="thin"><color rgb="FFB2B2B2"/></bottom><diagonal/></border><border><left/><right/><top/><bottom style="thin"><color rgb="FFFF8001"/></bottom><diagonal/></border><border><left/><right/><top/><bottom style="thin"><color rgb="FFFF8001"/></bottom><diagonal/></border></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"><alignment vertical="center"/></xf></cellStyleXfs><cellXfs count="7"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"><alignment vertical="center"/></xf><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"><alignment horizontal="center" vertical="center"/></xf><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"><alignment vertical="center"/></xf><xf numFmtId="0" fontId="0" fillId="0" borderId="1" xfId="0" applyBorder="1"><alignment vertical="center"/></xf><xf numFmtId="0" fontId="2" fillId="2" borderId="2" xfId="0" applyFont="1" applyFill="1" applyBorder="1"><alignment horizontal="center" vertical="center"/></xf><xf numFmtId="0" fontId="3" fillId="0" borderId="3" xfId="0" applyFont="1" applyBorder="1"><alignment vertical="center"/></xf><xf numFmtId="0" fontId="4" fillId="0" borderId="3" xfId="0" applyFont="1" applyBorder="1"><alignment vertical="center"/></xf></cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>';
  1857. const CT='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>'+(hasTheme?'<Override PartName="/xl/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>':'')+'</Types>';
  1858. const RELS='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>';
  1859. const WB='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="报价单" sheetId="1" r:id="rId1"/></sheets></workbook>';
  1860. const WBRELS='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>'+(hasTheme?'<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="theme/theme1.xml"/>':'')+'</Relationships>';
  1861. /* 视图与参考表一致:打开即130%显示比例、默认行高16.5,保证观感与参考表相同 */
  1862. const SHEET='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetViews><sheetView tabSelected="1" zoomScale="130" zoomScaleNormal="130" workbookViewId="0"/></sheetViews><sheetFormatPr defaultColWidth="9" defaultRowHeight="16.5"/>'+(colsXml?'<cols>'+colsXml+'</cols>':'')+'<sheetData>'+body+'</sheetData>'+mergeXml+'</worksheet>';
  1863. const files=[["[Content_Types].xml",CT],["_rels/.rels",RELS],["xl/workbook.xml",WB],["xl/_rels/workbook.xml.rels",WBRELS],["xl/styles.xml",STYLES],["xl/worksheets/sheet1.xml",SHEET]];
  1864. if(hasTheme)files.push(["xl/theme/theme1.xml",doc.themeRaw]);
  1865. return _zipStore(files.map(x=>({name:x[0],data:enc.encode(x[1])})));}
  1866. async function qExportExcel(){
  1867. if(!LASTQ.baseRows.length&&!LASTQ.valueRows.length){toast("暂无报价内容,请先上传图纸完成AI识别","toast-error");return;}
  1868. const b=BRANDS[S.brand]||BRANDS[0];
  1869. /* 取参考表 styles.xml/theme.xml 原样嵌入,保证字体/字号/颜色与参考表完全一致 */
  1870. let stylesRaw="",themeRaw="";
  1871. try{const rp=await fetch("/ref_parts",{cache:"no-store"});
  1872. if(rp.ok){const j=await rp.json();
  1873. if(j.styles){const dec=s=>new TextDecoder().decode(Uint8Array.from(atob(s),c=>c.charCodeAt(0)));
  1874. stylesRaw=dec(j.styles);themeRaw=j.theme?dec(j.theme):"";}}
  1875. }catch(e){}
  1876. /* 样式闭环:参考表样式取不到(部署环境无 /ref_parts 或桌面无参考表)→ 回退 xlsxBuild 内置默认样式继续导出,不再中断 */
  1877. const t=(x,s)=>({v:x,s:s});
  1878. const rows=[],merges=[],heights={};
  1879. const R=arr=>{rows.push(arr);heights[rows.length-1]=18;};
  1880. /* 行样式索引与参考表逐格一致 */
  1881. const BODY=[9,10,9,11,11,12,13,14,11,15]; /* 柜体行 */
  1882. const ADD =[9,10,19,17,17,24,17,21,20,18]; /* 增值项行 */
  1883. const hdr=(first,dl)=>[t("序号",7),t("品名",first?7:36),t("",first?7:37),t(dl?dl[0]:"宽(米)",7),t(dl?dl[1]:"高(米)",7),t(dl?dl[2]:"面积/总长度(m²/m)",8),t("投影单价",7),t("总价",7),t("备注",7),t("",7)];
  1884. /* 标题行(参考表R1:ht28、s5、F为s6、合并A1:J1) */
  1885. rows.push([t(b.t,5),t("",5),t("",5),t("",5),t("",5),t("",6),t("",5),t("",5),t("",5),t("",5)]);heights[0]=28;merges.push("A1:J1");
  1886. /* 按空间分组(复刻参考表图一):每个空间前重复表头行;B列合并空间柜体名称;C列子项(柜体+该柜增值项);块尾空间“总价”行 */
  1887. const f=n=>typeof n==="number"&&isFinite(n)?+n.toFixed(2):"";
  1888. const dvx=x=>x!==""&&x!=null?(+x||0):"";
  1889. const SUBT=[25,22,57,58,58,59,46,29,19,30]; /* 空间总价行样式:与参考表“总价”行逐格一致 */
  1890. LASTQ.baseRows.forEach((r,bi)=>{
  1891. const vrs=LASTQ.valueRows.filter(v=>v.label===(r.kind==="kitchen"?"厨房":r.name));
  1892. const h=rows.length+1;R(bi===0?hdr(true):hdr(false));merges.push("B"+h+":C"+h,"I"+h+":J"+h);
  1893. const g=rows.length+1;
  1894. /* 柜体基础行(宽/高/面积|总长度 与系统步骤4逐列一致;单位已并入表头) */
  1895. R([t(bi+1,BODY[0]),t(r.name,BODY[1]),t("柜体",BODY[2]),t(f(r.w),BODY[3]),t(f(r.h),BODY[4]),t(r.pm==="m"?f(r.len):(r.area==null?"":f(+r.area)),BODY[5]),t(r.price,BODY[6]),t(Math.round(r.amt),BODY[7]),t("",BODY[8]),t("",BODY[9])]);
  1896. /* 增值子项行:排在所属空间下,C列仅写子项名 */
  1897. vrs.forEach(v=>{const q=parseFloat(v.qty)||"";
  1898. R([t("",ADD[0]),t("",ADD[1]),t(v.sub||v.item,ADD[2]),t(dvx(v.d&&v.d[0]),ADD[3]),t(dvx(v.d&&v.d[1]),ADD[4]),t(dvx(v.d&&v.d[2]),ADD[5]),t(v.price,ADD[6]),t(Math.round(v.amt),ADD[7]),t((q!==""?"×"+q+" ":"")+(v.spec||""),ADD[8]),t("",ADD[9])]);});
  1899. /* 空间总价行:柜体+该空间增值项合计(C:G合并标签、H总价、I“/”) */
  1900. const rn=rows.length+1;
  1901. R([t("",SUBT[0]),t("",SUBT[1]),t("总价",SUBT[2]),t("",SUBT[3]),t("",SUBT[4]),t("",SUBT[5]),t("",SUBT[6]),t(Math.round(r.amt+vrs.reduce((a,v)=>a+v.amt,0)),SUBT[7]),t("/",SUBT[8]),t("",SUBT[9])]);
  1902. merges.push("C"+rn+":G"+rn);
  1903. /* 序号/品名 跨整块合并(含增值行与总价行) */
  1904. if(rows.length>g)merges.push("A"+g+":A"+rows.length,"B"+g+":B"+rows.length);
  1905. });
  1906. /* 尾部:仅保留 总投影面积 + 全屋折后合计 两行(单价已含浮动率,grand 即折后口径) */
  1907. const grand=Math.round(LASTQ.baseSum+LASTQ.wSum);
  1908. /* 注意:合并区行号=rows.length+1(R()推入后的1基行号),少+1会压到上一数据行导致Excel修复丢格 */
  1909. let n=rows.length+1;
  1910. R([t("总投影面积(㎡):",80),t("",80),t("",80),t("",80),t("",80),t("",81),t("",80),t("",80),t(+LASTQ.varArea.toFixed(2),82),t("",82)]);heights[rows.length-1]=20;merges.push("A"+n+":H"+n,"I"+n+":J"+n);
  1911. n=rows.length+1;
  1912. R([t("全屋折后合计:",83),t("",84),t("",84),t("",84),t("",84),t("",85),t("",84),t("",86),t(grand,87),t("",87)]);heights[rows.length-1]=20;merges.push("A"+n+":H"+n,"I"+n+":J"+n);
  1913. const FULL=[88,88,88,88,88,89,88,88,88,88],SIGN=[90,90,90,90,90,91,90,90,90,90];
  1914. const fullRow=(txt,st)=>{const m=rows.length+1;R(st.map((s,i)=>t(i===0?txt:"",s)));merges.push("A"+m+":J"+m);};
  1915. fullRow("注意:此份报价仅作为前期与客户沟通参考,最终以实际签约合同为准。",FULL);
  1916. /* 家居顾问默认留空,导出后可在Excel中手动填写 */
  1917. fullRow("家居顾问:",SIGN);
  1918. fullRow("门店地址:"+b.a,SIGN);
  1919. const d=new Date(),p2=x=>String(x).padStart(2,"0");
  1920. fullRow("当日日期:"+d.getFullYear()+"."+(d.getMonth()+1)+"."+d.getDate(),SIGN);
  1921. /* 列宽/样式与参考表完全一致(A~J) */
  1922. const COLS=[{w:6.18333333333333,st:3},{w:13.0166666666667,st:3},{w:15.0666666666667,st:3},{w:8.88333333333333,st:3},{w:12.3666666666667,st:3},{w:22,st:4}, /* F列加宽:完整显示“面积/总长度(m²/m)”表头 */{w:9.10833333333333,st:3},{w:11.9083333333333,st:3},{w:22.5583333333333,st:3},{w:22.75,st:3}];
  1923. const bytes=xlsxBuild({rows,merges,heights,cols:COLS,stylesRaw,themeRaw});
  1924. let bin="";for(let i=0;i<bytes.length;i++)bin+=String.fromCharCode(bytes[i]);
  1925. const fname=b.t+"_"+d.getFullYear()+p2(d.getMonth()+1)+p2(d.getDate())+"_"+p2(d.getHours())+p2(d.getMinutes())+".xlsx";
  1926. /* 导出闭环:服务端写桌面(/save_xlsx)→ 备用接口(/api/export/excel)→ 浏览器下载,三级兜底保证任何部署环境都能导出 */
  1927. const b64=btoa(bin);
  1928. const download=()=>{const a=document.createElement("a");
  1929. a.href=URL.createObjectURL(new Blob([bytes],{type:"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"}));
  1930. a.download=fname;document.body.appendChild(a);a.click();
  1931. setTimeout(()=>{URL.revokeObjectURL(a.href);a.remove();},800);
  1932. toast("已导出(浏览器下载):"+fname,"toast-success");};
  1933. fetch("/save_xlsx",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:fname,data:b64})})
  1934. .then(r=>{if(!r.ok)throw new Error("http"+r.status);return r.json();})
  1935. .then(j=>{if(j.ok)toast("已导出到桌面 ✓ "+fname,"toast-success");else throw new Error(j.err||"write failed");})
  1936. .catch(()=>fetch("/api/export/excel",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({filename:fname,data:b64})})
  1937. .then(r=>{if(!r.ok)throw new Error("http"+r.status);return r.json();})
  1938. .then(j=>{toast("已导出到桌面 ✓ "+(j.filename||fname),"toast-success");})
  1939. .catch(download));
  1940. }
  1941. /* ========== 规则配置表 ========== */
  1942. function switchRuleTab(t){
  1943. document.querySelectorAll("#view-rules .tab").forEach(el=>el.classList.remove("active"));
  1944. event.target.classList.add("active");
  1945. document.querySelectorAll(".rule-tab-content").forEach(c=>c.classList.add("hidden"));
  1946. $("rule-tab-"+t).classList.remove("hidden");
  1947. }
  1948. function renderRules(){
  1949. $("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("");
  1950. $("rule-table-wardrobe").innerHTML=EXTRA.map((e,i)=>`<tr><td>${i+1}</td><td>${e.n}</td><td class="text-right">${fmt(e.p)}</td><td>${e.u}</td><td>${e.both?"柜体+厨房":(e.kit?"厨房":"柜体")}</td><td><input type="checkbox" data-x="${e.id}" style="width:16px" ${S.ruleX[e.id]?"checked":""}></td></tr>`).join("");
  1951. $("rule-table-wardrobe").querySelectorAll("input[data-x]").forEach(cb=>cb.onchange=()=>{S.ruleX[cb.dataset.x]=cb.checked;rebuildObjs();recalc();});
  1952. $("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("");
  1953. $("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("");
  1954. $("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("");
  1955. }
  1956. /* ========== 事件与初始化 ========== */
  1957. $("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();
  1958. if(S.kitBox&&old&&nw){S.kitBox={wM:S.kitBox.wM/old*nw,hM:S.kitBox.hM/old*nw};S.kitW=+Math.max(S.kitBox.wM,S.kitBox.hM).toFixed(2);S.kitH=+Math.min(S.kitBox.wM,S.kitBox.hM).toFixed(2);} /* 厨房宽/高随新比例同步重算,总长度闭环 */
  1959. renderAnnoInfer();recalc();renderAIResults();}};
  1960. $("btnFb").onclick=()=>{const px=+$("fbPx").value||0,mm=+$("fbMm").value||0;if(px>0&&mm>0){S.scale=(mm/1000)/px;S.scaleSrc="用户补充已知尺寸";recalc();renderAIResults();toast("比例已建立,柜体尺寸已换算为米");}else toast("请填写像素数与毫米数","toast-error");}; /* 补比例后同步刷新第二步结果面板(px→m),兑现"补充已知尺寸后自动换算" */
  1961. qBindDrop($("q-upload-zone-unified"));qBindDrop($("q-canvas-drop")); /* 点击 + 拖拽双通道上传/更换图纸 */
  1962. renderTiers();renderRules();renderQuoteList();rebuildObjs();recalc();
  1963. </script>
  1964. </body>
  1965. </html>