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

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880
  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. for(let dg=0;dg<10;dg++){g.clearRect(0,0,36,48);g.fillStyle="#000";g.font="bold 40px Arial";g.textBaseline="top";g.fillText(""+dg,2,0);
  858. DIG_T.push(bmNorm(bmCrop(g.getImageData(0,0,36,48).data,36,48),9,12));}
  859. return DIG_T;}
  860. function mkCC(W){return mask=>{const vis=new Uint8Array(mask.length),out=[];
  861. for(let s=0;s<mask.length;s++){if(!mask[s]||vis[s])continue;
  862. const q=[s];vis[s]=1;let x0=1e9,y0=1e9,x1=-1,y1=-1,area=0,hh=0;
  863. while(hh<q.length){const c=q[hh++];area++;const cx=c%W,cy=(c/W)|0;
  864. if(cx<x0)x0=cx;if(cx>x1)x1=cx;if(cy<y0)y0=cy;if(cy>y1)y1=cy;
  865. 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);}}}
  866. out.push({x:x0,y:y0,w:x1-x0,h:y1-y0,area});}
  867. return out;};}
  868. /* 读取箭头旁的数字标注(如 3150):vert 由箭头方向决定排布;返回数值,失败返回 0。F=掩码相对 AW 的放大倍数(高分裁剪传 2) */
  869. function ocrNumber(mask,W,ar,cc,F){F=F||1;
  870. const comps=cc(mask).filter(c=>c.area>=5*F*F&&Math.max(c.w,c.h)>=3*F&&Math.max(c.w,c.h)<=16*F);
  871. const ax=ar.x+ar.w/2,ay=ar.y+ar.h/2,vert=ar.h>=ar.w;
  872. const near=comps.filter(c=>{const cx=c.x+c.w/2,cy=c.y+c.h/2;
  873. 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; /* 剔除竖箭头两端箭头头 */
  874. 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; /* 剔除横箭头两端箭头头 */
  875. return Math.abs(cx-ax)<=60*F&&Math.abs(cy-ay)<=(vert?ar.h:ar.w)/2+25*F;}); /* 水平容差扩至60F:兼容数字水平写在箭头较远处 */
  876. if(near.length<3||near.length>6)return 0;
  877. const T=digTemplates();
  878. /* [排序轴, 排序方向, 90°旋转次数] 覆盖两类排布:数字沿箭头旋转90°(竖排旋1/3、横排旋0/2)与数字水平写在箭头旁(自左而右旋0 / 自右而左旋2) */
  879. 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]];
  880. let res=[];
  881. for(const cb of combos){const dir=cb[1],rq=cb[2];
  882. const list=near.slice().sort((a,b)=>dir*((cb[0]==="y"?a.y:a.x)-(cb[0]==="y"?b.y:b.x)));
  883. let s="",sc=0,ok=true;const amb=[];
  884. for(const c of list){let b={w:c.w+1,h:c.h+1,a:new Uint8Array((c.w+1)*(c.h+1))};
  885. 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)];
  886. for(let q=0;q<rq;q++)b=bmRot(b);
  887. const nb=bmNorm(b,9,12);let v1=-1,d1=0,v2=-1,d2=-1;
  888. for(let dg=0;dg<10;dg++){const v=bmIou(nb,T[dg]);if(v>v1){v2=v1;d2=d1;v1=v;d1=dg;}else if(v>v2){v2=v;d2=dg;}}
  889. if(v1<0.45){ok=false;break;}sc+=v1;s+=d1;
  890. if(d2>=0&&d2!==d1&&v1-v2<=0.03)amb.push([s.length-1,d2]);} /* 数字含糊(前两模板得分差≤0.03)→ 记录备选位 */
  891. if(!ok)continue;
  892. /* 含糊位枚举变体(最多3位),返回候选数组:首选为最高模板得分,其余交由 600mm 锚点交叉校验复核 */
  893. 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);}
  894. for(const it of vars){const n=+it.s;if(it.s.length>=3&&n>=100&&n<=99999)res.push(it);}}
  895. if(!res.length)return [];
  896. 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);}
  897. return out;}
  898. /* ---- 图纸红字柜体名称识别(如"2-电视柜"):柜种词汇表 + 汉字模板匹配 ---- */
  899. const CAB_NAMES=["餐边柜","电视柜","阳台柜","衣柜","衣帽间","书柜","书桌","储物柜","玄关柜","卫浴柜","榻榻米","鞋柜","酒柜","橱柜","开放柜","吊柜","地柜"];
  900. let CHR_T=null;
  901. function chrTemplates(){if(CHR_T)return CHR_T;
  902. const vocab=CAB_NAMES.join("")+"主卧室次客餐厅玄关门书房儿童老人浴厨卫间";
  903. const seen={};CHR_T={};const c=document.createElement("canvas");c.width=48;c.height=48;const g=c.getContext("2d");
  904. for(const ch of vocab){if(seen[ch])continue;seen[ch]=1;
  905. g.clearRect(0,0,48,48);g.fillStyle="#000";g.font="bold 40px 'Microsoft YaHei',sans-serif";g.textBaseline="top";g.fillText(ch,4,2);
  906. CHR_T[ch]=bmNorm(bmCrop(g.getImageData(0,0,48,48).data,48,48),20,20);}
  907. return CHR_T;}
  908. /* 读取红框旁的红字柜体名称标注:高分裁剪→红掩码→列投影分字→数字/短横前缀跳过→汉字模板匹配;返回名称串,失败返回 null */
  909. function readRedLabel(b0,AW,AH,k){
  910. const OF=Math.max(2,Math.min(4,Math.round(1/k))),OM=6; /* 尽量按原图分辨率裁剪,避免降采样糊化笔画 */
  911. const ox0=Math.max(0,b0.x-OM),oy0=Math.max(0,b0.y-OM);
  912. const ow=Math.min(AW-ox0,b0.w+2*OM),oh=Math.min(AH-oy0,b0.h+2*OM);
  913. const oc=document.createElement("canvas");oc.width=ow*OF;oc.height=oh*OF;
  914. const og=oc.getContext("2d");og.imageSmoothingEnabled=true;
  915. og.drawImage(img,ox0/k,oy0/k,ow/k,oh/k,0,0,ow*OF,oh*OF);
  916. const dd=og.getImageData(0,0,ow*OF,oh*OF).data;
  917. const W=ow*OF,H=oh*OF,rm2=new Uint8Array(W*H);
  918. for(let i=0;i<W*H;i++){const r=dd[i*4],g2=dd[i*4+1],b2=dd[i*4+2];if(r>140&&r-g2>50&&r-b2>50)rm2[i]=1;}
  919. const proj=new Int32Array(W);
  920. 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;}
  921. const segs=[];let s0=-1,zc=0;
  922. for(let x=0;x<W;x++){if(proj[x]>0){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;}}}
  923. if(s0>=0)segs.push({x0:s0,x1:W-1});
  924. let maxH=0;const parts=[];
  925. for(const sg of segs){let y0=H,y1=-1,area=0;
  926. 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;}
  927. 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;parts.push(p);}
  928. const dbg={bx:b0.x,by:b0.y,bw:b0.w,bh:b0.h,parts:[]};
  929. if(parts.length<2||parts.length>8){(window.__lblDbg=window.__lblDbg||[]).push({rej:"count",n:parts.length,...dbg});return null;}
  930. const CT=chrTemplates(),DT=digTemplates();let name="";
  931. for(const p of parts){
  932. const bmp={w:p.w,h:p.h,a:new Uint8Array(p.w*p.h)};
  933. 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)];
  934. if(p.w<=0.45*maxH){dbg.parts.push({w:p.w,h:p.h,cls:"narrow"});continue;} /* 窄部件(短横/数字前缀/断裂笔画):柜名汉字均为全宽字,一律跳过 */
  935. const dn=bmNorm(bmp,9,12);let bD=-1;
  936. for(let dg=0;dg<10;dg++){const v=bmIou(dn,DT[dg]);if(v>bD)bD=v;}
  937. const cn2=bmNorm(bmp,20,20);let bC=-1,bCh="";
  938. for(const ch in CT){const v=bmIou(cn2,CT[ch]);if(v>bC){bC=v;bCh=ch;}}
  939. dbg.parts.push({w:p.w,h:p.h,bD:+bD.toFixed(2),bC:+bC.toFixed(2),ch:bCh});
  940. if(bD>=0.55&&bD>=bC)continue; /* 编号数字前缀,跳过 */
  941. if(bC>=0.5){name+=bCh;continue;}
  942. (window.__lblDbg=window.__lblDbg||[]).push({...dbg,rej:"char"});
  943. return null;} /* 存在无法识别的字符 → 该标注整体不采信 */
  944. (window.__lblDbg=window.__lblDbg||[]).push({...dbg,name});
  945. if(!name)return null;
  946. let hit="";for(const nm0 of CAB_NAMES)if(name.endsWith(nm0)&&nm0.length>hit.length)hit=nm0;
  947. return hit?name:null;}
  948. function wallThickness(wm,w,h){const runs=[];
  949. 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;}}}
  950. 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;}}}
  951. if(!runs.length)return 6;runs.sort((a,b)=>a-b);return runs[runs.length>>1];}
  952. async function runAI(){
  953. $("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;
  954. if(img)draw(); /* 重置后立即重绘干净画布:避免新图纸复用上一张图的叠加红框,出现“错位页面” */
  955. $("q-ai-results").style.display="none";
  956. $("q-red-anno-row").style.display="none";
  957. $("q-step2-next-btn").disabled=true;
  958. setProg(0,"running");setProg(1,"");setProg(2,"");
  959. logAI("▶ AI 全图识别启动(优先级:无红框全图 &gt; 红框校准)","b");await sleep(250);
  960. /* 真实计算计时:ph() 记录每阶段纯计算耗时,S2() 为界面进度等待(不计入计算) */
  961. const T0=performance.now();let TP=T0,COMP=0;
  962. const ph=m=>{const x=Math.round(performance.now()-TP);TP=performance.now();COMP+=x;return m+" · 实算"+x+"ms";};
  963. const S2=async ms=>{await sleep(ms);TP=performance.now();};
  964. const AW=500,k=AW/img.naturalWidth,AH=Math.round(img.naturalHeight*k);
  965. const oc=document.createElement("canvas");oc.width=AW;oc.height=AH;const og=oc.getContext("2d");
  966. og.drawImage(img,0,0,AW,AH);const d=og.getImageData(0,0,AW,AH).data;
  967. const ink=new Uint8Array(AW*AH),redm=new Uint8Array(AW*AH);
  968. let rx0=1e9,ry0=1e9,rx1=-1,ry1=-1,rc=0,col=0,fp=0;
  969. 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;
  970. if(r>140&&r-g>50&&r-b>50){rc++;redm[i]=1;if(i%AW<rx0)rx0=i%AW;if(i%AW>rx1)rx1=i%AW;const y=(i/AW)|0;if(y<ry0)ry0=y;if(y>ry1)ry1=y;}
  971. else if(.299*r+.587*g+.114*b<140)ink[i]=1;
  972. else if(Math.max(r,g,b)-Math.min(r,g,b)>15)col++;}
  973. logAI(ph("· 结构扫描:"+AW+"×"+AH+" 栅格化("+(AW*AH)+"像素),墨线像素 "+ink.reduce((a,v)=>a+v,0)+",图像指纹 #"+fp));await S2(200);
  974. /* 判定① 图纸类型:平面户型图(彩色填充) vs CAD图(黑白线图) */
  975. const colR=col/(AW*AH);
  976. S.ft=colR>0.005?"plan":"cad";
  977. logAI("· 判定① 图纸类型:彩色填充占比 "+(colR*100).toFixed(1)+"% → "+(S.ft==="plan"?"平面户型图(有彩色填充)":"CAD图(黑白线图)"),"b");
  978. $("dc-type").textContent=S.ft==="plan"?"平面户型图":"CAD图";
  979. setProg(0,"done");setProg(1,"running");await S2(200);
  980. /* 判定② 红框(户型图:无红框 > 有红框);CAD 直接执行对应规则 */
  981. if(rc>150){S.redbox=true;} /* 图纸已带红框:系统不作二次红框标注(不叠加 red overlay) */
  982. if(S.ft==="plan"){
  983. if(S.redbox){logAI("· 判定② 红框:有红框 → 场景B(红框+标注 ±2-5%),柜体边界直接取图纸红框,系统不二次红框标注/抓取","w");$("dc-red").textContent="有红框 · 场景B";$("dc-rule").textContent="有红框规则 ±2-5%";}
  984. else{logAI("· 判定② 红框:无红框 → 场景A(无红框,AI全自动 ±5-8%,优先级:无红框 &gt; 有红框)");$("dc-red").textContent="无红框 · 场景A";$("dc-rule").textContent="无红框规则 ±5-8%";}
  985. }else{
  986. logAI("· 判定② 红框:不适用(CAD图无户型图场景之分)"+(S.redbox?";检测到红框校准(规则#6)":""));
  987. logAI("· CAD规则:尺寸标注矢量直读 ±0.1-1%(Demo 以600mm锚点/已知尺寸建立比例)","b");
  988. $("dc-red").textContent=S.redbox?"红框校准":"不适用";$("dc-rule").textContent="CAD规则 ±0.1-1%";
  989. }
  990. $("q-ai-scene-chip").textContent=S.ft==="cad"?"CAD图 · "+(S.redbox?"红框校准":"矢量直读"):(S.redbox?"户型图 · 场景B 有红框":"户型图 · 场景A 无红框");
  991. await S2(200);
  992. /* 连通域(全墨线) */
  993. const cc=mask=>{const vis=new Uint8Array(mask.length),out=[];
  994. for(let s=0;s<mask.length;s++){if(!mask[s]||vis[s])continue;
  995. const q=[s];vis[s]=1;let x0=1e9,y0=1e9,x1=-1,y1=-1,area=0,hh=0;
  996. while(hh<q.length){const c=q[hh++];area++;const cx=c%AW,cy=(c/AW)|0;
  997. if(cx<x0)x0=cx;if(cx>x1)x1=cx;if(cy<y0)y0=cy;if(cy>y1)y1=cy;
  998. 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);}}}
  999. out.push({x:x0,y:y0,w:x1-x0,h:y1-y0,area});}
  1000. return out;};
  1001. const compsAll=cc(ink).sort((a,b)=>b.w*b.h-a.w*a.h);
  1002. logAI(ph("· 连通域分析:"+compsAll.length+" 个结构体"));await S2(200);
  1003. /* 场景B:红框矩形提取(实线段过滤,剔除虚线箭头/文字) */
  1004. const runMask=mk=>{const s=new Uint8Array(AW*AH);
  1005. 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;}}}
  1006. 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;}}}
  1007. return s;};
  1008. if(S.redbox){
  1009. /* 闭合红圈/红框 → 内部"孔洞"即柜体边界(兼容粗笔圈注与细线矩形) */
  1010. const ext=new Uint8Array(AW*AH),stk=[];
  1011. 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);}
  1012. 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);}
  1013. while(stk.length){const p=stk.pop();if(ext[p])continue;ext[p]=1;const px=p%AW,py=(p/AW)|0;
  1014. for(let dy=-1;dy<=1;dy++)for(let dx=-1;dx<=1;dx++){const nx=px+dx,ny=py+dy;
  1015. if(nx<0||ny<0||nx>=AW||ny>=AH)continue;const q=ny*AW+nx;if(!redm[q]&&!ext[q])stk.push(q);}}
  1016. const hole=new Uint8Array(AW*AH);for(let i2=0;i2<AW*AH;i2++)hole[i2]=(!redm[i2]&&!ext[i2])?1:0;
  1017. /* 孔洞膨胀2层:① 红框内含红色X/斜线细纹时,被割裂的孔洞重连为整框(避免碎块柜体);② 窄红框(如电视柜)降采样后孔洞过窄被 min>=10 过滤的兜底 */
  1018. for(const h of cc(dilateMask(hole,AW,AH,2))){
  1019. if(h.area>=200&&Math.min(h.w,h.h)>=10&&h.w<=0.45*AW&&h.h<=0.45*AH)
  1020. 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)});}
  1021. S.redRects.sort((a,b)=>b.w*b.h-a.w*a.h);
  1022. logAI(ph("· 红框矩形提取:"+S.redRects.length+" 个柜体边界框(直接取图纸红框为柜体边界,不二次收紧;已过滤虚线箭头/标注文字)"),"w");await S2(200);
  1023. /* 红色尺寸标注检测:先剥离红框笔画(避免贴着红框的尺寸箭头因连通域合并被过滤),共线实线段合并为双头箭头,单长条亦视为箭头 */
  1024. S.redArrow=null;
  1025. const redm2=redm.slice();
  1026. for(const rc of S.redRects)for(let yy=Math.max(0,rc.y-3);yy<=Math.min(AH-1,rc.y+rc.h+3);yy++)for(let xx=Math.max(0,rc.x-3);xx<=Math.min(AW-1,rc.x+rc.w+3);xx++)redm2[yy*AW+xx]=0;
  1027. const arrows=cc(runMask(redm2)).filter(c=>Math.max(c.w,c.h)>=18&&Math.min(c.w,c.h)<=6);
  1028. 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)});
  1029. for(const c of arrows){const len=Math.max(c.w,c.h)/k;if(!S.redArrow||len>S.redArrow.len)S.redArrow={len,x:c.x,y:c.y,w:c.w,h:c.h};}
  1030. for(let i1=0;i1<arrows.length;i1++)for(let j=i1+1;j<arrows.length;j++){const a1=arrows[i1],a2=arrows[j];
  1031. 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);
  1032. if(gap>=0&&gap<=40){const len=(Math.max(a1.y+a1.h,a2.y+a2.h)-Math.min(a1.y,a2.y))/k;
  1033. if(!S.redArrow||len>S.redArrow.len)S.redArrow={len,...abb(a1,a2)};}}
  1034. 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);
  1035. if(gap>=0&&gap<=40){const len=(Math.max(a1.x+a1.w,a2.x+a2.w)-Math.min(a1.x,a2.x))/k;
  1036. if(!S.redArrow||len>S.redArrow.len)S.redArrow={len,...abb(a1,a2)};}}}
  1037. if(S.redArrow){logAI("· 红色尺寸标注检测:箭头长 "+Math.round(S.redArrow.len)+"px → 尝试 OCR 读取邻近红色数字","w");
  1038. /* OCR 高分裁剪:降采样后数字过小易误读 → 从原图按 2 倍 AW 比例裁出箭头邻域,用红色掩码识别数字 */
  1039. const OF=2,OM=60,ar=S.redArrow; /* 邻域扩至60:兼容数字水平写在箭头较远处的排布 */
  1040. const ox0=Math.max(0,ar.x-OM),oy0=Math.max(0,ar.y-OM);
  1041. const ow=Math.min(AW-ox0,ar.w+2*OM),oh=Math.min(AH-oy0,ar.h+2*OM);
  1042. const oc2=document.createElement("canvas");oc2.width=ow*OF;oc2.height=oh*OF;
  1043. const og2=oc2.getContext("2d");og2.imageSmoothingEnabled=true;
  1044. og2.drawImage(img,ox0/k,oy0/k,ow/k,oh/k,0,0,ow*OF,oh*OF);
  1045. const d2=og2.getImageData(0,0,ow*OF,oh*OF).data;
  1046. const rm2=new Uint8Array(ow*OF*oh*OF);
  1047. for(let i2=0;i2<rm2.length;i2++){const r2=d2[i2*4],g2=d2[i2*4+1],b3=d2[i2*4+2];if(r2>140&&r2-g2>50&&r2-b3>50)rm2[i2]=1;}
  1048. const cands=ocrNumber(rm2,ow*OF,{x:(ar.x-ox0)*OF,y:(ar.y-oy0)*OF,w:ar.w*OF,h:ar.h*OF},mkCC(ow*OF),OF);
  1049. if(cands.length){$("q-red-anno").value=cands[0];S.redAnnoCands=cands;
  1050. logAI("· 红色尺寸标注 OCR 读出:<b>"+cands[0]+"</b>"+(cands.length>1?"(存在含糊候选 "+cands.join("/")+",将以 600mm 锚点交叉复核)":"")+"(已自动填入,如有出入可在输入框修改)→ 据此建立全图比例","b");}
  1051. else logAI("· OCR 未能置信读出红色标注值 → 请在下方输入框确认标注数值(默认 2400,单位 mm)","e");}
  1052. }
  1053. /* 图纸已用红字标注柜体名称(如"2-电视柜")→ 识别之,对应红框柜体直接采用图纸名称,系统不再二次命名 */
  1054. if(S.redbox){const LM=dilateMask(redm,AW,AH,3);
  1055. for(const b0 of cc(LM)){
  1056. if(b0.w<18||b0.w>0.4*AW||b0.h<7||b0.h>40||b0.area<40)continue;
  1057. const txt=readRedLabel(b0,AW,AH,k);if(txt)S.redLabels.push({x:b0.x,y:b0.y,w:b0.w,h:b0.h,text:txt});}
  1058. if(S.redLabels.length)logAI("· 图纸红字柜体名称识别:"+S.redLabels.length+" 处("+S.redLabels.map(l=>l.text).join("、")+")→ 对应红框柜体直接按图纸标注命名,系统不二次命名","b");}
  1059. setProg(0,"done");setProg(1,"running");
  1060. /* 模式判定:示意图(实心厨房色块) vs 彩色户型图(墙体分离) */
  1061. const er1=erodeMask(ink,AW,AH,1);
  1062. 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);};
  1063. 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;});
  1064. let kitchen=null;const wards=[];let excluded=0;
  1065. /* 柜体具体命名(两路径通用):房间底色(蓝=卧室→衣柜 / 黄=书房→书柜 / 粉=卫浴柜 / 绿=橱柜 / 米色=客餐厅玄关)+ 形态(近方X=玄关柜) */
  1066. let gInk=ink,gThin=ink;
  1067. const xpat=c=>{let h1=0,h2=0;const N=16;
  1068. 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;};
  1069. for(let i=1;i<N;i++){if(hit9((c.x+c.w*i/N)|0,(c.y+c.h*i/N)|0))h1++;
  1070. if(hit9((c.x+c.w*i/N)|0,(c.y+c.h-c.h*i/N)|0))h2++;}
  1071. return Math.min(h1,h2)/N;};
  1072. const roomHue=c=>{let bl=0,be=0,yl=0,pk=0,gr=0,or=0,tot=0;
  1073. 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];
  1074. 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++;};
  1075. 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);}
  1076. 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);}
  1077. 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};};
  1078. 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;
  1079. 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"书柜";
  1080. if(h.be>=0.15)return sq?"玄关柜":"边柜";
  1081. return sq?"玄关柜":"储物柜";};
  1082. /* —— 空间识别:按地面底色洪填充分间(绿=厨房/粉=卫生间/蓝=卧室/黄=书房/灰=阳台/米=客餐厅),柜体映射到空间命名 —— */
  1083. let trimFn=null,growFn=null,xrects=[];
  1084. const lab=new Uint8Array(AW*AH);
  1085. const rooms=[];{
  1086. const cls=i=>{const r=d[i*4],g=d[i*4+1],b2=d[i*4+2];
  1087. 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;
  1088. if(b2-r>25&&g-r>15)return 3;if(b2-r>12&&g-r<=12&&r>150&&r<230)return 5;
  1089. if(r-b2>=20&&r-g>=10&&r-g<=24&&r>225)return 6;return 0;};
  1090. const seen=new Uint8Array(AW*AH),buf=new Int32Array(AW*AH);
  1091. for(let s=0;s<AW*AH;s++){const c0=cls(s);if(!c0||seen[s])continue;seen[s]=1;
  1092. let qh=0,qt=0;buf[qt++]=s;let n=0,x0=1e9,y0=1e9,x1=-1,y1=-1;
  1093. while(qh<qt){const i=buf[qh++],x=i%AW,y=(i/AW)|0;n++;
  1094. if(x<x0)x0=x;if(x>x1)x1=x;if(y<y0)y0=y;if(y>y1)y1=y;
  1095. 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;}}}
  1096. 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;}}
  1097. const ord=rooms.slice().sort((a,b)=>b.a-a.a);
  1098. 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];}
  1099. let bi=0;for(const r of ord)if(r.base==="卧室"){bi++;r.name=bi===1?"主卧":"次卧"+(bi>2?bi-1:"");}
  1100. for(const r of ord)if(r.base!=="卧室")r.name=r.base+(r.idx>1?r.idx:"");
  1101. if(rooms.length)logAI(ph("· 空间识别(地面底色洪填充分间):"+ord.map(r=>r.name).join("、")),"b");
  1102. }
  1103. /* 柜体归属空间:柜体内部为白填充,取框外环带(8-14px,跨墙仍靠多数表决)的地面标签多数表决 */
  1104. const roomOf=c=>{const cm={};let tot=0;
  1105. 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;}};
  1106. for(const off of[8,10,12,14]){
  1107. 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);}
  1108. 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);}}
  1109. if(!tot)return null;let bi=0,bm=0;for(const r in cm)if(cm[r]>bm){bm=cm[r];bi=+r;}
  1110. return rooms[bi-1]||null;};
  1111. const cabName=(c,r)=>{const long=Math.max(c.w,c.h),short=Math.min(c.w,c.h),sq=long<2.8*short;
  1112. if(r.c===1)return"橱柜";if(r.c===2)return"卫浴柜";
  1113. if(r.c===4)return sq?"储物柜":"书柜";if(r.c===3)return sq?"边柜":"衣柜";
  1114. if(r.c===5)return"储物柜";return sq?"玄关柜":"边柜";};
  1115. if(solid){
  1116. /* —— 示意图路径:探针判定 L/U + 二次方程解台面深度 —— */
  1117. 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]]
  1118. .map(p=>ink[((p[1]|0)*AW+(p[0]|0))]?1:0).reduce((a,v)=>a+v,0);
  1119. 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;
  1120. let dd=0,uvar=0;
  1121. if(probes===2&&discL>0)dd=(sL-Math.sqrt(discL))/2;
  1122. else if(probes===3&&discU>0){dd=(sU-Math.sqrt(discU))/4;uvar=1;}
  1123. else if(probes===3&&discU2>0){dd=(sU2-Math.sqrt(discU2))/4;uvar=2;}
  1124. if(dd>.02*AW&&dd<.12*AW)kitchen={...c,dd};
  1125. if(kitchen){
  1126. /* 600mm 隐式参照物先建立图像比例;场景B 有红箭头标注时,标注值与锚点交叉校验推断尺寸单位后再校准 */
  1127. const sc=0.6/(kitchen.dd/k);S.scale600=sc;S.scale=sc;S.scaleSrc="AI定位厨房台面深度=600mm锚点(全图通用)";
  1128. annoPick(sc);
  1129. if(S.redArrow){const inf=annoInfer(sc);if(inf){S.scale=inf.scale;S.scaleSrc="红箭头标注校准(推断单位"+inf.unit+" · 与600mm锚点交叉校验)";
  1130. 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");}}
  1131. overlays.push({t:"kitchen",x:kitchen.x/k,y:kitchen.y/k,w:kitchen.w/k,h:kitchen.h/k,label:"厨房"});
  1132. overlays.push({t:"anchor",x:kitchen.x/k,y:kitchen.y/k,w:kitchen.dd/k,h:0});
  1133. const kt=probes>=3?"U":"L";
  1134. 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);
  1135. S.kitBox={wM:kitchen.w/k*S.scale,hM:kitchen.h/k*S.scale};
  1136. logAI("· 定位厨房(示意图模式):台面深度 "+Math.round(kitchen.dd)+"px = 600mm → 1px="+(sc*1000).toFixed(1)+"mm"+(S.redArrow?"(比例已按标注校准)":""),"b");
  1137. }
  1138. let cn=0;
  1139. for(const c2 of compsAll){
  1140. if(cn>=8)break;
  1141. if(c2.w>.6*AW&&c2.h>.6*AH)continue;
  1142. 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;
  1143. const f=c2.area/(c2.w*c2.h);
  1144. if(c2.w>.03*AW&&c2.h>.03*AW&&Math.min(c2.w,c2.h)>=.018*AW&&f>=.10&&f<.7){
  1145. cn++;wards.push(c2);}}
  1146. }else{
  1147. /* —— 彩色户型图路径:墙体分离 + 柜体特征分类 —— */
  1148. logAI("· 彩色户型图模式:腐蚀法分离墙体(粗线)与家具(细线)","b");await S2(200);
  1149. /* 墨线放宽:降采样抗锯齿使细线变灰,阈值178回收 X交叉/斜线/台面细线 */
  1150. const inkC=new Uint8Array(AW*AH);
  1151. 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;}}
  1152. let wm=erodeMask(inkC,AW,AH,2),eit=2;
  1153. if(wm.reduce((a,v)=>a+v,0)<0.005*AW*AH){
  1154. /* 空心双线墙:平行双线对检测 + 填缝(适配细双线墙体户型图) */
  1155. const dw=new Uint8Array(AW*AH);
  1156. 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;}}}
  1157. for(const r1 of hruns){for(let g=2;g<=9;g++){const y2=r1[0]+g;if(y2>=AH)break;
  1158. 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;
  1159. const o0=Math.max(s2,r1[1]),o1=Math.min(e2,r1[2]);
  1160. 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++;}}}
  1161. 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;}}}
  1162. for(const r1 of vruns){for(let g=2;g<=9;g++){const x2=r1[0]+g;if(x2>=AW)break;
  1163. 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;
  1164. const o0=Math.max(s2,r1[1]),o1=Math.min(e2,r1[2]);
  1165. 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++;}}}
  1166. if(dw.reduce((a,v)=>a+v,0)>=0.004*AW*AH){wm=dw;eit=1;}
  1167. else{wm=erodeMask(inkC,AW,AH,1);eit=1;}
  1168. }
  1169. let T=wallThickness(wm,AW,AH)+2*eit;T=Math.max(4,Math.min(24,T));
  1170. logAI(ph("· 墙体分离与墙厚先验 T≈"+T+"px(仅用于形状判定,不作尺寸参照,规则#11)"));
  1171. const wcut=dilateMask(wm,AW,AH,1);
  1172. /* 贴墙判定掩码:墙掩码 + 长直线(回收未入墙掩码的粗实外墙) */
  1173. const wl=new Uint8Array(AW*AH);
  1174. 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;}}}
  1175. 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;}}}
  1176. const wz0=dilateMask(wm,AW,AH,Math.max(3,Math.round(T*0.7))),wz1=dilateMask(wl,AW,AH,3);
  1177. const wz=new Uint8Array(AW*AH);for(let i=0;i<AW*AH;i++)wz[i]=wz0[i]||wz1[i];
  1178. const thin=new Uint8Array(AW*AH);for(let i=0;i<AW*AH;i++)thin[i]=inkC[i]&&!wcut[i];
  1179. gInk=inkC;gThin=thin;
  1180. /* 门洞切除:共线墙段缺口=门洞 → 清除门扇/门弧细线,避免跨房间粘连 */
  1181. 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;}}}
  1182. 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;}}}
  1183. 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;
  1184. 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;}
  1185. 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;
  1186. 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;}
  1187. const comps=cc(thin).sort((a,b)=>b.w*b.h-a.w*a.h);
  1188. const att=c=>{let hit=0,tot=0;
  1189. 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++;}}
  1190. 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++;}}
  1191. return hit/Math.max(1,tot);};
  1192. const hatch=c=>{let rt=0,ct=0,nr=0,nc=0;
  1193. 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;}
  1194. 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;}
  1195. return Math.max(rt/Math.max(1,nr),ct/Math.max(1,nc));};
  1196. /* 实际最长45°连续段(X交叉柜的对角线残段也计分,不依赖bbox对齐) */
  1197. 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]);
  1198. const walk=(x0,y0,dx,dy,k)=>{let cnt=0,x=x0,y=y0;
  1199. 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;}};
  1200. 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);}
  1201. 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);}
  1202. return Math.min(m1,m2);};
  1203. /* bbox外圈粉色占比(卫生间洁具排除) */
  1204. 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++;};
  1205. 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);}
  1206. 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);}
  1207. return p/Math.max(1,t);};
  1208. 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);
  1209. 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;
  1210. 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;
  1211. /* 填充色语义:绿=厨房 / 粉=卫生间(辅助台面带与衣柜判定) */
  1212. 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)];};
  1213. /* 收紧柜体包围盒:行/列墨线密度剖面按"空白间隙≥2"分段,取墨量最大段=柜体本体,剥离粘连杂线(门弧/邻家具),使红框贴合柜体本身 */
  1214. trimFn=c=>{const W=c.w+1,H=c.h+1;
  1215. const colD=new Float32Array(W),rowD=new Float32Array(H);
  1216. 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;}
  1217. 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;}
  1218. const pick=(D,L)=>{const v=new Uint8Array(L);for(let i=0;i<L;i++)v[i]=D[i]>0.06?1:0;
  1219. for(let i=1;i<L-1;i++)if(!v[i]&&v[i-1]&&v[i+1])v[i]=1;
  1220. let bs=0,be=L-1,bm=-1,s=-1;
  1221. for(let i=0;i<=L;i++){const on=i<L&&v[i];
  1222. if(on&&s<0)s=i;
  1223. if(!on&&s>=0){let m=0;for(let q=s;q<i;q++)m+=D[q];
  1224. if(m>bm){bm=m;bs=s;be=i-1;}s=-1;}}
  1225. return be>bs?[bs,be]:[0,L-1];};
  1226. const cx=pick(colD,W),ry=pick(rowD,H);
  1227. const t={x:c.x+cx[0],y:c.y+ry[0],w:cx[1]-cx[0],h:ry[1]-ry[0]};
  1228. /* 安全回退:斜线填充剖面易碎段,若收紧后显著缩水则保留原框 */
  1229. if(t.w<8||t.h<8)return c;
  1230. 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;
  1231. return t;};
  1232. /* 边框回归:柜体边框线被墙填缝/降采样混叠吞掉时,自当前框向外扫描inkC(≤14px),遇覆盖≥70%的直线段即吸附最近者,恢复柜体真实轮廓(最长边=柜体本身最长边) */
  1233. growFn=c=>{const t={x:c.x,y:c.y,w:c.w,h:c.h};
  1234. 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);};
  1235. 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);};
  1236. /* 墙线判别:候选线在柜体两端外仍长延伸(>6px)=墙线 → 不吸附 */
  1237. 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);};
  1238. 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);};
  1239. 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;}}
  1240. 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;}}
  1241. 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;}}
  1242. 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;}}
  1243. return t;};
  1244. /* X交叉柜矩形回归:X的四条对角线角对角且中心相交,在thin上(长直边框线run≥5已排除)构成独立连通域;
  1245. 取对角线bbox后四边吸附inkC最近边框线,得到柜体真实矩形(不受连通域bbox混入墙线影响) */
  1246. {const dgm=new Uint8Array(AW*AH);
  1247. 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;
  1248. 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++;
  1249. 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++;
  1250. if(h<5&&v<5)dgm[X]=1;}
  1251. const seenX=new Uint8Array(AW*AH),bufX=new Int32Array(AW*AH),raw=[];
  1252. for(let s=0;s<AW*AH;s++){if(!dgm[s]||seenX[s])continue;seenX[s]=1;
  1253. let qh=0,qt=0;bufX[qt++]=s;let n=0,x0=1e9,y0=1e9,x1=-1,y1=-1;
  1254. while(qh<qt){const i=bufX[qh++],x=i%AW,y=(i/AW)|0;n++;
  1255. if(x<x0)x0=x;if(x>x1)x1=x;if(y<y0)y0=y;if(y>y1)y1=y;
  1256. 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;}}
  1257. if(n>=12)raw.push({x0,y0,x1,y1,n});}
  1258. 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];
  1259. 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;}}}
  1260. for(const r of raw){const w=r.x1-r.x0,h=r.y1-r.y0;
  1261. if(r.n<40||w<12||h<12||w>2.2*h||h>2.2*w)continue;
  1262. 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);};
  1263. 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);};
  1264. 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;};
  1265. 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;};
  1266. let L=pickV(r.x0),R=pickV(r.x1),Tp=pickH(r.y0),B=pickH(r.y1);
  1267. if(L<0)L=r.x0;if(R<0)R=r.x1;if(Tp<0)Tp=r.y0;if(B<0)B=r.y1;
  1268. /* 双线边框向外扩≤2px:柜体尺寸取外沿(外边到外边);墙线距边框≥3px不受影响 */
  1269. for(let e=0;e<2&&L>1&&cvv(L-1)>=0.8;e++)L--;
  1270. for(let e=0;e<2&&R<AW-2&&cvv(R+1)>=0.8;e++)R++;
  1271. for(let e=0;e<2&&Tp>1&&chh(Tp-1)>=0.8;e++)Tp--;
  1272. for(let e=0;e<2&&B<AH-2&&chh(B+1)>=0.8;e++)B++;
  1273. xrects.push({x:L,y:Tp,w:R-L,h:B-Tp});}
  1274. }
  1275. const bands=[];
  1276. for(const c of comps){
  1277. if(c.w>0.55*AW||c.h>0.55*AH)continue;
  1278. const short=Math.min(c.w,c.h),long=Math.max(c.w,c.h);
  1279. if(short<1.2*T)continue; // 噪点/小符号
  1280. const a=att(c),f=c.area/(c.w*c.h),kd=kidsOf(c);c.kids=kd;
  1281. const hue=fillHue(c),gR=hue[0],pR=hue[1];c.gR=gR;
  1282. /* L/U型合并台面带(转角相连):贴墙 + 内含≥2小符号 + 低填充空芯 + 不含床等大家具 */
  1283. 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;}
  1284. /* 台面带(含近方段):贴墙 + 内含水槽/灶具小符号 + 深度2.2-5.5T */
  1285. 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;}
  1286. /* 绿色填充台面带(厨房底色上的水槽/灶具段) */
  1287. 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;}
  1288. /* 橱柜台面带:贴墙 + 长条 + 深度≈3T(600mm) + 内含水槽/灶具符号 */
  1289. 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;}
  1290. /* 衣柜:贴墙 + 斜线填充 或 X交叉(排除厨房/卫生间底色上的台面) */
  1291. 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;}
  1292. /* 近方形 X交叉柜(玄关柜等) */
  1293. 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;}
  1294. excluded++;}
  1295. logAI("· 柜体判定标准:贴墙率≥30% 且(斜线填充 / X交叉 / 600mm台面带+水槽灶具)","b");
  1296. logAI(ph("· 柜体特征分类:命中 "+(wards.length+bands.length)+" 个候选,排除床/沙发/桌椅/洁具等独立家具 "+excluded+" 个"));await S2(200);
  1297. /* 厨房:台面带聚类 → 600mm 锚点 */
  1298. if(bands.length){
  1299. bands.sort((a,b)=>b.w*b.h-a.w*a.h);
  1300. /* 多聚类 → 选最优簇(带数×2 + 水槽灶具符号数 + 合并带加成),避免把卫生间台面误当厨房 */
  1301. const clusters=[];
  1302. 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||
  1303. (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)));
  1304. if(cl)cl.push(b);else clusters.push([b]);}
  1305. 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);
  1306. clusters.sort((a,b)=>score(b)-score(a));
  1307. const cluster=clusters[0];
  1308. const b0=cluster[0];let dNat;
  1309. if(b0.merged){const med=arr=>{arr.sort((p,q)=>p-q);return arr.length?arr[arr.length>>1]:0;};
  1310. const sc2=[],sr2=[];
  1311. 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);}
  1312. 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);}
  1313. dNat=(Math.min(med(sc2),med(sr2))||Math.min(b0.w,b0.h))/k;}
  1314. else dNat=Math.min(b0.w,b0.h)/k;
  1315. /* 600mm 隐式参照物应用于场景A/B:先建锚点比例;场景B 标注值与锚点交叉校验推断尺寸单位(mm/cm/m)后校准 */
  1316. S.scale600=0.6/dNat;
  1317. annoPick(S.scale600);
  1318. const inf=S.redArrow?annoInfer(S.scale600):null;
  1319. if(inf){S.scale=inf.scale;S.scaleSrc="红箭头标注校准(推断单位"+inf.unit+" · 与600mm锚点交叉校验)";
  1320. 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");}
  1321. else{S.scale=S.scale600;S.scaleSrc="AI定位橱柜台面深度=600mm锚点(全图通用)";}
  1322. let ux0=1e9,uy0=1e9,ux1=-1,uy1=-1;
  1323. 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);});
  1324. /* 绿色底色最大连通块 = 厨房房间;房间内贴墙白色台面条游程 = 各边台面真实长度 */
  1325. 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;};
  1326. let gbox=null;{const seen=new Uint8Array(AW*AH);let bestA=Math.max(300,0.004*AW*AH);
  1327. 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;
  1328. 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;
  1329. 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);}}
  1330. if(n>bestA){bestA=n;gbox={x:x0,y:y0,w:x1-x0,h:y1-y0};}}}
  1331. 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;}
  1332. kitchen={x:ux0,y:uy0,w:ux1-ux0,h:uy1-uy0};
  1333. S.kitBox={wM:kitchen.w/k*S.scale,hM:kitchen.h/k*S.scale};
  1334. overlays.push({t:"kitchen",x:ux0/k,y:uy0/k,w:(ux1-ux0)/k,h:(uy1-uy0)/k,label:"厨房"});
  1335. overlays.push({t:"anchor",x:b0.x/k,y:b0.y/k,w:dNat,h:0});
  1336. /* 厨房尺寸=厨房房间尺寸;按规则直接计算总长并展示过程(最短边≤2m→L型,否则U型) */
  1337. const kW=kitchen.w/k*S.scale,kH=kitchen.h/k*S.scale,kLong=Math.max(kW,kH),kShort=Math.min(kW,kH);
  1338. if(kLong>0){
  1339. S.kitW=+kLong.toFixed(2);S.kitH=+kShort.toFixed(2);
  1340. if(kShort<=2){
  1341. logAI("· 厨房尺寸:"+fmt2(kLong)+"m × "+fmt2(kShort)+"m(最短边≤2m → L型)","b");
  1342. logAI("· 厨房总长:L型 "+fmt2(kLong)+" + "+fmt2(kShort)+" − 1.6 = <b>"+fmt2(Math.max(kLong+kShort-1.6,0))+"m</b>(可计费总长)","b");}
  1343. else{
  1344. logAI("· 厨房尺寸:"+fmt2(kLong)+"m × "+fmt2(kShort)+"m(最短边&gt;2m → U型)","b");
  1345. logAI("· 厨房总长:U型 "+fmt2(kLong)+" + "+fmt2(kLong)+" + "+fmt2(kShort)+" − 2.2 = <b>"+fmt2(Math.max(kLong*2+kShort-2.2,0))+"m</b>(可计费总长)","b");}
  1346. }
  1347. logAI(ph("· 厨房台面带聚类→比例建立:"+S.scaleSrc+" → 1px="+(S.scale*1000).toFixed(2)+"mm"),"b");
  1348. }else{logAI("· 未能自动定位厨房台面带 → 请在下一步补充一个已知尺寸","e");
  1349. if(!S.scale&&S.redArrow){S.scale=annoScale();S.scaleSrc="红箭头标注校准(场景B)";logAI("· 已用红箭头标注建立比例(600mm锚点未定位,标注默认按 mm):1px="+(S.scale*1000).toFixed(2)+"mm(柜体尺寸可换算)","b");}}
  1350. }
  1351. draw();await S2(200);
  1352. setProg(1,"done");setProg(2,"running");
  1353. /* 柜体输出(场景B:直接取图纸红框为柜体边界,不二次抓取;场景A:特征识别) */
  1354. let cn=0;const tpCnt={};
  1355. const pushCab=(c0,src,nameOv)=>{let c=c0;if(src==="AI"){
  1356. /* AI路径:特征抓取范围收紧到柜体轮廓(xrect真实矩形→trimFn收紧);红框路径直接取图纸红框为柜体边界,不二次收紧 */
  1357. if(xrects.length){const cx=c0.x+c0.w/2,cy=c0.y+c0.h/2;let best=null,bd=1e9;
  1358. 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));
  1359. if(ov<0.25*Math.min(c0.w*c0.h,r.w*r.h))continue;
  1360. 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;}}
  1361. 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);}
  1362. else if(src==="AI"&&growFn)c=growFn(c);
  1363. if(trimFn&&!c.xr)c=trimFn(c);}cn++;
  1364. /* nameOv=图纸红字标注名称:直接采用,系统不二次命名(不做空间判定) */
  1365. const rm=nameOv?null:roomOf(c),tp0=nameOv||(rm?cabName(c,rm):cabType(c)),rn=rm?rm.name:"";
  1366. const tk=(rn||"未识别")+"|"+tp0;tpCnt[tk]=(tpCnt[tk]||0)+1;const tp=tpCnt[tk]>1?tp0+tpCnt[tk]:tp0;
  1367. /* 名称直接为“空间+柜种”(如:主卧衣柜),不带柜体编号;红字标注名则原样采用 */
  1368. const nm=(rn?rn:"")+tp;
  1369. S.objs.push({name:nm,kind:"cab",src,w:Math.round(c.w/k),h:Math.round(c.h/k),extras:mkExtras(nm,false)});
  1370. overlays.push({t:"cab",x:c.x/k,y:c.y/k,w:c.w/k,h:c.h/k,label:cn+"·"+tp});
  1371. const tag=nameOv?"名称取自图纸红字标注(系统不二次命名)":"空间="+(rn||"未识别")+" → <b>"+tp+"</b>";
  1372. if(S.scale||S.ft==="cad"){const wM=c.w/k*dimScale(),hM=c.h/k*dimScale();
  1373. 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>");}
  1374. else logAI("· "+nm+"["+src+"] "+tag+":"+Math.round(c.w/k)+"px × "+Math.round(c.h/k)+"px(比例未建立,补充已知尺寸后自动换算)","w");};
  1375. /* 红字标签↔红框全局最近配对:bbox 间隙距离 ≤60px,按距离升序贪心,一框一标签 */
  1376. const rcName=new Map();
  1377. if(S.redLabels&&S.redLabels.length){const pairs=[];
  1378. S.redRects.forEach((c,ri)=>{for(let li=0;li<S.redLabels.length;li++){const lb=S.redLabels[li];
  1379. 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);
  1380. const dd2=Math.hypot(dx,dy);if(dd2<=60)pairs.push({d:dd2,ri,li});}});
  1381. pairs.sort((a,b)=>a.d-b.d);const usedR=new Set(),usedL=new Set();
  1382. 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);}}
  1383. for(let ri=0;ri<S.redRects.length;ri++){if(cn>=8)break;const c=S.redRects[ri];
  1384. 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));
  1385. if(ov>=0.9*kitchen.w*kitchen.h)continue;} // 红框整包厨房台面带=校准框,非柜体圈注
  1386. pushCab(c,"红框",rcName.get(ri)||null);}
  1387. if(S.redbox)logAI("· 场景B:柜体边界直接取图纸红框,系统不二次抓取(不合并 AI 特征抓取、不二次红框标注)","w");
  1388. else for(const c of wards){if(cn>=8)break;pushCab(c,"AI");} /* 场景A 无红框,无需红框去重 */
  1389. logAI(ph("· 柜体识别:"+cn+" 个(最长边×层高=投影面积,均为到顶高柜)"),"b");await S2(200);
  1390. logAI("· 护墙板:平面图斜线填充不可靠 → <b>不自动识别</b>(规则#14),如需请在下一步手动添加","w");await sleep(150);
  1391. logAI("· 尺寸计算完成。精度等级:"+precision().txt+" · AI 纯计算合计 "+COMP+"ms(其余为界面进度等待,非计算)","b");
  1392. setProg(2,"done");draw();rebuildObjs();recalc();renderAIResults();
  1393. $("q-step2-next-btn").disabled=false;
  1394. }
  1395. function fh(){return parseFloat($("q-floor-height").value)||2.5;}
  1396. function qFhChanged(src){ensureS();const v=(src?src.value:$("q-floor-height").value)||"2.5";
  1397. $("q-floor-height").value=v;const i2=$("q-floor-height2");if(i2)i2.value=v;
  1398. if(S.objs.length||S.kitBox){renderAIResults();recalc();}}
  1399. function renderAIResults(){ensureS();
  1400. const sc=dimScale();
  1401. $("q-red-anno-row").style.display=S.redArrow?"block":"none";
  1402. renderAnnoInfer();
  1403. $("q-ai-results").style.display="flex";
  1404. $("q-ai-cab-count").textContent=S.objs.length+" 个";
  1405. const cabTot=S.objs.filter(p=>p.kind!=="panel").reduce((a,p)=>a+Math.max(p.w*sc,p.h*sc)*fh(),0);
  1406. $("q-ai-cab-body").innerHTML=S.objs.length?S.objs.map(p=>{const wM=p.w*sc,hM=p.h*sc;
  1407. 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>`;
  1408. }).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>';
  1409. const sides=[S.kitW,S.kitH].map(v=>+v||0).filter(v=>v>0);
  1410. let kt="",kT=0,kF="";
  1411. if(sides.length>=2){const w=Math.max(...sides),h=Math.min(...sides);
  1412. if(h<=2){kt="L型";kT=Math.max(w+h-1.6,0);kF="L型:"+fmt2(w)+" + "+fmt2(h)+" − 1.6";}
  1413. else{kt="U型";kT=Math.max(w*2+h-2.2,0);kF="U型:"+fmt2(w)+" + "+fmt2(w)+" + "+fmt2(h)+" − 2.2";}}
  1414. $("q-ai-kit-type").textContent=kT>0?kt+" · 总长 "+fmt2(kT)+"m":(S.kitBox?"已定位":"未定位");
  1415. const rows=[];
  1416. 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>`);
  1417. if(kT>0)rows.push(`<div class="total-line"><span>${kF}</span><span class="num">可计费总长 <b>${fmt2(kT)} m</b></span></div>`);
  1418. if(!rows.length)rows.push('<div class="total-line"><span>未能自动定位厨房台面带 → 请在下一步补充一个已知尺寸</span></div>');
  1419. $("q-ai-kit-body").innerHTML=rows.join("");
  1420. }
  1421. /* ========== 对象表(步骤3) ========== */
  1422. let KIT_TR=null,TOTAL_TR=null;
  1423. function rebuildObjs(){ensureS();
  1424. const tb=$("objBody");tb.innerHTML="";KIT_TR=null;TOTAL_TR=null;
  1425. /* 厨房行:与柜体合并同表;宽=最长边、高=最短边、总长度(L/U型自动算,可改)、单价(元/m,默认材质档最高,可改) */
  1426. if(S.kitW>0&&S.kitH>0){const tr=document.createElement("tr");KIT_TR=tr;
  1427. tr.innerHTML=`<td><b> 厨房</b></td>
  1428. <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>
  1429. <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>
  1430. <td class="text-right">—</td>
  1431. <td class="text-right" data-o="klen" title="总长度由宽/高自动计算:L型=长+短−1.6,U型=长+长+短−2.2">—</td>
  1432. <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>
  1433. <td class="text-right" data-o="kamt">-</td>
  1434. <td><button class="btn btn-danger btn-sm">删</button></td>`;
  1435. tr.querySelectorAll("input").forEach(inp=>inp.oninput=()=>{S[inp.dataset.f]=inp.value===""?null:(+inp.value||0);recalc();/* 改宽/高→总长度自动重算 */});
  1436. tr.querySelector("button").onclick=()=>{S.kitW=null;S.kitH=null;S.kitLen=null;S.kitPrice=null;S.kitBox=null;rebuildObjs();recalc();};
  1437. tb.appendChild(tr);tb.appendChild(buildExtraRow(S,true));}
  1438. S.objs.forEach(p=>{const tr=document.createElement("tr");
  1439. tr.innerHTML=`<td><input type="text" value="${p.name}" style="width:120px" data-f="name"></td>
  1440. <td class="text-right"><input type="number" step="0.01" min="0" style="width:86px;text-align:right" data-f="lenM" title="宽(最长边),单位米,可修改"></td>
  1441. <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>
  1442. <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>
  1443. <td class="text-right" data-o="amt">-</td>
  1444. <td><button class="btn btn-danger btn-sm">删</button></td>`;
  1445. 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();});
  1446. tr.querySelector("button").onclick=()=>{S.objs.splice(S.objs.indexOf(p),1);rebuildObjs();recalc();};
  1447. p._tr=tr;tb.appendChild(tr);tb.appendChild(buildExtraRow(p,false));});
  1448. /* 合计行:总面积 + 全部总价(含勾选增值项) */
  1449. const ttr=document.createElement("tr");TOTAL_TR=ttr;ttr.style.cssText="font-weight:700;background:#F7FAFC;";
  1450. 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>`;
  1451. tb.appendChild(ttr);
  1452. }
  1453. $("addKit").onclick=()=>{if(!(S.kitW>0))S.kitW=3;if(!(S.kitH>0))S.kitH=2;rebuildObjs();recalc();};
  1454. $("addCab").onclick=()=>{S.objs.push({name:"衣柜",kind:"cab",src:"手动",w:0,h:0,lenM:null,hgtM:null,price:null,extras:mkExtras("衣柜",false)});rebuildObjs();recalc();};
  1455. $("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();};
  1456. /* ========== 增值项下拉(每个柜体/厨房行下方,勾选计入计价) ========== */
  1457. function availExtras(isKit){return EXTRA.filter(e=>S.ruleX[e.id]&&(isKit?(e.kit||e.both):(!e.kit||e.both)));}
  1458. /* 门板配置联动单价(规则#10):厨房地柜/吊柜默认价随所选门板配置变化,其余增值项取目录价 */
  1459. function priceOf(e,isKit){const d=DOORS[S.door==null?2:S.door]||DOORS[2];
  1460. if(isKit&&e.id==="dg"&&d[4]!=null)return d[4];
  1461. if(isKit&&e.id==="dg2"&&d[3]!=null)return d[3];
  1462. return e.p;}
  1463. function extraStore(isKit,p){return isKit?S.kitExtras:(p.extras||(p.extras=mkExtras(p.name,false)));}
  1464. function buildExtraRow(p,isKit){
  1465. const tr=document.createElement("tr");tr.style.background="#FBFDFF";
  1466. const td=document.createElement("td");td.colSpan=8;td.style.cssText="padding:2px 12px 10px 34px;";
  1467. const btn=document.createElement("button");btn.className="btn btn-outline btn-sm";btn.style.fontSize="12px";
  1468. /* 增值项选项默认折叠,点击按钮才展开下拉选项(再点收起) */
  1469. const panel=document.createElement("div");panel.className="hidden";
  1470. 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);";
  1471. btn.onclick=()=>{panel.classList.toggle("hidden");recalc();};
  1472. td.appendChild(btn);td.appendChild(panel);tr.appendChild(td);
  1473. p._ex={btn,panel,rows:{}};
  1474. /* 厨房:12种门板配置下拉(规则#10),切换后联动增值项「地柜/吊柜」默认单价 */
  1475. 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;";
  1476. const lb=document.createElement("span");lb.style.cssText="font-weight:700;color:#276749;";lb.textContent="🚪 门板配置(12种)";
  1477. 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;";
  1478. 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);});
  1479. sel.onchange=()=>{S.door=+sel.value;rebuildObjs();recalc();toast("门板配置已切换:"+DOORS[S.door][1]+"(地柜/吊柜单价已联动)");};
  1480. const hint=document.createElement("small");hint.style.cssText="color:var(--text-muted);";hint.textContent="联动增值项「地柜/吊柜」单价;若下拉中未见地柜/吊柜,请先在 规则配置→增值项配置 勾选「加入下拉」";
  1481. drow.appendChild(lb);drow.appendChild(sel);drow.appendChild(hint);panel.appendChild(drow);}
  1482. const store=extraStore(isKit,p),presetIds=Object.keys(store);
  1483. const list=availExtras(isKit).sort((a,b)=>(presetIds.indexOf(b.id)>=0?1:0)-(presetIds.indexOf(a.id)>=0?1:0));
  1484. if(!list.length)panel.innerHTML='<div class="note" style="padding:6px 0;">后台规则配置中暂无可加入的增值项(规则配置 → 增值项配置 勾选「加入下拉」)。</div>';
  1485. 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);}
  1486. 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);}
  1487. const st=store[e.id]||(store[e.id]={on:false,v:0});
  1488. if(!st.d)st.d=["","",""]; /* 尺寸三框:默认空,可手动编辑 */
  1489. 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;";
  1490. const meta=dimMeta(e,isKit),sch=dimScheme(e,isKit);
  1491. 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>`+
  1492. 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("")+
  1493. `<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;">`;
  1494. 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"]');
  1495. if(st.rm==null)st.rm=e.sc||""; /* 备注默认填入原规格说明,可手动修改 */
  1496. cb.checked=st.on;inp.value=st.v||"";rm.value=st.rm;pin.value=st.p!=null?st.p:priceOf(e,isKit); /* 单价默认目录价(厨房地柜/吊柜随门板配置联动),可手动改 */
  1497. 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();};});
  1498. cb.onchange=()=>{st.on=cb.checked;recalc();};
  1499. inp.oninput=()=>{st.v=+inp.value||0;recalc();};
  1500. pin.oninput=()=>{st.p=pin.value===""?null:(+pin.value||0);recalc();};
  1501. rm.oninput=()=>{st.rm=rm.value;};
  1502. p._ex.rows[e.id]={amt,d2:row.querySelector('[data-d="2"]')};
  1503. panel.appendChild(row);});
  1504. return tr;
  1505. }
  1506. /* 增值项尺寸三框的标签与单位,按计算分类(源自6.18欧阳路预估报价表公式):
  1507. 投影面积类(c=area:见光板/护墙板等)=宽/高/面积;超出米数类(c=over:台面超出部分等)=宽/套餐/超出;
  1508. 数量类=厨房默认宽/套餐/超出、其余默认宽/高/面积(仅作记录,不影响总价) */
  1509. 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","㎡"]];}
  1510. function dimScheme(e,kit){return e.c==="area"?"wha":e.c==="over"?"wte":(kit?"wte":"wha");}
  1511. /* 自动计算第三框(所有增值项行均联动):宽/高/面积口径 面积=宽×高;宽/套餐/超出口径 超出=宽−套餐(写回 st.d 与输入框) */
  1512. function autoDim(e,st,row,kit){const sch=dimScheme(e,kit);const d=st.d;if(d[0]===""||d[1]==="")return;
  1513. const v=+((sch==="wha"?(+d[0])*(+d[1]):(+d[0])-(+d[1])).toFixed(4));
  1514. st.d[2]=String(v);const el=row&&row.querySelector('[data-d="2"]');if(el&&document.activeElement!==el)el.value=v;}
  1515. /* 计算某柜体(或厨房)已勾选增值项:勾选计入、未勾选不计;同步更新下拉内金额与按钮标签
  1516. 总价口径:数量类=数量×单价;投影面积类=面积(宽×高)×单价;超出米数类=超出(宽−套餐)×单价 */
  1517. function calcExtras(p,isKit){
  1518. const store=extraStore(isKit,p);let sum=0;const lines=[];
  1519. for(const id in store){if(!S.ruleX[id])continue;const st=store[id];if(!st||!st.on)continue;
  1520. const e=EXTRA.find(x=>x.id===id);if(!e)continue;
  1521. const d=(st.d||["","",""]).slice();const sch=dimScheme(e,isKit);
  1522. 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;}
  1523. const v=+st.v||0,price=st.p!=null?+st.p:priceOf(e,isKit);
  1524. const a=(e.c==="area"||e.c==="over")?((d[2]!==""&&d[2]!=null?+d[2]||0:0)*price):(v*price);
  1525. sum+=a;lines.push({e,v,a,d,kit:isKit,price,scheme:dimScheme(e,isKit)});
  1526. if(p._ex&&p._ex.rows[id]){p._ex.rows[id].amt.textContent="¥"+fmt(a);
  1527. const d2=p._ex.rows[id].d2;if(d2&&document.activeElement!==d2&&d2.value!==(d[2]||""))d2.value=d[2]||"";}}
  1528. 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";}
  1529. const open=!p._ex.panel.classList.contains("hidden");
  1530. p._ex.btn.textContent="🧩 增值项"+(lines.length?"(已勾 "+lines.length+" 项 · ¥"+fmt(sum)+")":"(点击下拉勾选预设增值项)")+(open?" ▴":" ");}
  1531. return {sum,lines};
  1532. }
  1533. /* ========== 计算引擎 ========== */
  1534. let LASTQ={baseSum:0,kSum:0,wSum:0,varArea:0,cabArea:0,cabAmt:0,baseRows:[],valueRows:[]};
  1535. function recalc(){ensureS();
  1536. const pr=precision(),sc=dimScale();
  1537. $("precBadge").className="badge "+pr.cls;$("precBadge").textContent=pr.txt;
  1538. $("q-quote-prec").className="badge "+pr.cls;$("q-quote-prec").textContent=pr.txt;
  1539. $("sceneChip").textContent="场景:"+(S.ft==="cad"?"CAD图":S.redbox?"户型图 · B 有红框":"户型图 · A 无红框");
  1540. $("scaleInfo").textContent=S.ft==="cad"?"CAD矢量直读(mm)":(S.scale?`1px=${(sc*1000).toFixed(2)}mm(${S.scaleSrc})`:"比例未建立");
  1541. /* 基础项:厨房行(合并表,总价=总长度×单价) */
  1542. let baseSum=0,varArea=0,cabArea=0,cabAmt=0,kSum=0;const baseRows=[];
  1543. const ks=[S.kitW,S.kitH].map(v=>+v||0).filter(v=>v>0);let kT=0,kt="未判定";
  1544. if(ks.length>=2){const w=Math.max(...ks),h=Math.min(...ks);
  1545. 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);}}
  1546. const kitLen=kT,kitPrice=S.kitPrice!=null?S.kitPrice:unit(),kAmt=kitLen*kitPrice; /* 总长度只读:随宽/高自动变化 */
  1547. if(KIT_TR){const kl=KIT_TR.querySelector('[data-o="klen"]');if(kl)kl.textContent=kitLen?fmt2(kitLen):"—";}
  1548. if(ks.length>=2){kSum=kAmt;baseSum+=kAmt;
  1549. 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"});
  1550. if(KIT_TR){const pi=KIT_TR.querySelector('[data-f="kitPrice"]');if(pi&&document.activeElement!==pi)pi.value=Math.round(kitPrice);
  1551. KIT_TR.querySelector('[data-o="kamt"]').textContent="¥"+fmt(kAmt);}}
  1552. /* 基础项:柜体/护墙板(总价=面积×单价,单价默认材质档最高、可手动改) */
  1553. 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;}
  1554. baseRows.push({name:p.name,kind:p.kind,src:p.src,len,w:len,h:hgt,area,amt,price,pm:"㎡"});
  1555. if(p._tr){const li=p._tr.querySelector('[data-f="lenM"]');if(li&&document.activeElement!==li)li.value=len?+len.toFixed(2):"";
  1556. const hi=p._tr.querySelector('[data-f="hgtM"]');if(hi&&document.activeElement!==hi)hi.value=hgt?+hgt.toFixed(2):"";
  1557. const pi=p._tr.querySelector('[data-f="price"]');if(pi&&document.activeElement!==pi)pi.value=Math.round(price);
  1558. p._tr.querySelector('[data-o="area"]').textContent=fmt2(area);
  1559. p._tr.querySelector('[data-o="amt"]').textContent="¥"+fmt(amt);}});
  1560. $("baseTotal").textContent="¥"+fmt(baseSum);
  1561. $("fbRow").style.display=S.scale||S.ft==="cad"?"none":"flex";
  1562. /* 增值项:各柜体/厨房下拉勾选(勾选计入、未勾选不计) */
  1563. let wSum=0;const valueRows=[];
  1564. const pushEx=(label,res)=>{wSum+=res.sum;res.lines.forEach(L=>{
  1565. 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});});};
  1566. if(S.kitW>0&&S.kitH>0)pushEx("厨房",calcExtras(S,true));
  1567. S.objs.forEach(p=>pushEx(p.name,calcExtras(p,false)));
  1568. if(TOTAL_TR){TOTAL_TR.querySelector('[data-o="tArea"]').textContent=fmt2(varArea);TOTAL_TR.querySelector('[data-o="tAmt"]').textContent="¥"+fmt(baseSum+wSum);}
  1569. $("valueTotal").textContent="¥"+fmt(wSum);
  1570. $("grandTotal").textContent="¥"+fmt(baseSum+wSum);
  1571. LASTQ={baseSum,kSum,wSum,varArea,cabArea,cabAmt,kitLen,baseRows,valueRows};
  1572. renderStep4();
  1573. }
  1574. /* ========== 步骤4:报价单渲染 ========== */
  1575. function renderTiers(){
  1576. const box=$("q-ai-tier-selector");box.innerHTML="";
  1577. MATS.forEach((m,i)=>{const d=document.createElement("div");
  1578. d.className="tier-card"+(i===S.mat?" selected":"");
  1579. 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>`;
  1580. d.onclick=()=>{S.mat=i;renderTiers();recalc();};
  1581. box.appendChild(d);});
  1582. }
  1583. function renderStep4(){
  1584. const m=MATS[S.mat],Q=LASTQ;
  1585. const fixed=Q.wSum;
  1586. 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;
  1587. $("q-quote-meta").textContent=`生成日期:${new Date().toLocaleDateString("zh-CN")} | 材质档:${m.n} | 浮动率:${S.rate}%`;
  1588. $("q-ai-float-badge").textContent=S.rate+"%";
  1589. $("q-ai-float-slider").value=S.rate;
  1590. $("global-float-slider").value=S.rate;$("global-float-badge").textContent=S.rate+"%";
  1591. document.querySelectorAll(".float-preset button[data-aifp]").forEach(b=>b.classList.toggle("active",+b.dataset.aifp===Math.round(S.rate)));
  1592. $("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>`;
  1593. /* 明细表:按空间归为一类,增值项列于所属空间行下方 */
  1594. /* 宽/高列:厨房=最长边/最短边,其余柜体=最长边/层高;面积/总长度列:厨房(pm=m)显示总长度,其余柜体显示面积 */
  1595. const dv=x=>x!==""&&x!=null?x:"";
  1596. 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>`;
  1597. $("q-quote-base-body").innerHTML=Q.baseRows.length?Q.baseRows.map(r=>{
  1598. const vrs=Q.valueRows.filter(v=>v.label===(r.kind==="kitchen"?"厨房":r.name));
  1599. 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("");
  1600. }).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>';
  1601. $("q-grand-total-value").textContent="¥"+fmt(cur);
  1602. $("q-grand-total-area").textContent=`柜体总投影面积 ${fmt2(Q.cabArea)}㎡ · 总投影面积 ${fmt2(Q.varArea)}㎡ · ${m.n} ¥${fmt2(unit())}/㎡`;
  1603. applyBrand();
  1604. $("q-grand-total-note").textContent=cur>0?`基础项 ¥${fmt(Q.baseSum)} + 增值项 ¥${fmt(fixed)}(${precision().txt})`:"上传图纸并完成 AI 识别后自动计算";
  1605. $("q-step5-disclaimer").textContent=`⚠️ 声明:本报价为 AI 辅助生成的预估价格(${precision().txt}),最终成交价以实际量尺和确认为准。护墙板平面图不自动识别;「厨房台面深度600mm」隐式参照物适用于场景A/B,场景B 标注尺寸单位由该锚点交叉推断。`;
  1606. $("global-est-range").textContent=`¥${fmt(lo)} ~ ¥${fmt(hi)}`;
  1607. $("global-est-mid").textContent="参考总价: ¥"+fmt(cur);
  1608. }
  1609. function qOnAiFloatChange(v){S.rate=Math.min(15,Math.max(10,+v||10));renderTiers();recalc();}
  1610. function qSetAiFloat(v){S.rate=Math.min(15,Math.max(10,+v||10));renderTiers();recalc();}
  1611. /* ========== 报价单管理 ========== */
  1612. let QUOTES=[];
  1613. try{QUOTES=JSON.parse(localStorage.getItem("aiq_new_quotes")||"[]");}catch(e){QUOTES=[];}
  1614. function saveQuotes(){try{localStorage.setItem("aiq_new_quotes",JSON.stringify(QUOTES));}catch(e){}}
  1615. 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||{}}))});}
  1616. function qSaveQuote(silent){ensureS();
  1617. if(!S.objs.length&&!LASTQ.baseSum&&!LASTQ.wSum){if(!silent)toast("暂无计价内容,请先上传图纸完成AI识别","toast-error");return;}
  1618. const m=MATS[S.mat],fixed=LASTQ.wSum;
  1619. const q={id:Date.now(),name:("报价单 "+(QUOTES.length+1)),date:new Date().toLocaleString("zh-CN"),mat:m.n,rate:S.rate,
  1620. 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()};
  1621. QUOTES.unshift(q);saveQuotes();renderQuoteList();
  1622. if(!silent)toast("已存入报价单列表 ✓","toast-success");
  1623. }
  1624. function renderQuoteList(){
  1625. const tb=$("quote-table-body");tb.innerHTML="";
  1626. $("quote-empty").style.display=QUOTES.length?"none":"block";
  1627. QUOTES.forEach((q,i)=>{const tr=document.createElement("tr");
  1628. tr.innerHTML=`<td><b>${q.name}</b></td><td>${q.date}</td><td>${q.mat}(${q.rate}%)</td>
  1629. <td class="text-right">¥${fmt(q.lo)} — ¥${fmt(q.hi)}</td><td><span class="badge est" style="font-size:10px;">${q.prec}</span></td>
  1630. <td></td>`;
  1631. const td=tr.children[5];
  1632. const b1=document.createElement("button");b1.className="btn btn-outline btn-sm";b1.textContent="打开";
  1633. b1.onclick=e=>{e.stopPropagation();qOpenQuote(q);};
  1634. const b2=document.createElement("button");b2.className="btn btn-danger btn-sm";b2.textContent="删除";b2.style.marginLeft="6px";
  1635. b2.onclick=e=>{e.stopPropagation();QUOTES.splice(i,1);saveQuotes();renderQuoteList();toast("已删除");};
  1636. td.appendChild(b1);td.appendChild(b2);
  1637. tr.style.cursor="pointer";tr.onclick=()=>qOpenQuote(q);
  1638. tb.appendChild(tr);});
  1639. }
  1640. function qOpenQuote(q){
  1641. try{const snap=JSON.parse(q.snap);
  1642. 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});
  1643. ensureS(); /* 旧版存档可能缺 objs 等字段 → 回退空数组,避免恢复后 undefined 崩溃 */
  1644. S.kitExtras=snap.kitEx||mkExtras("",true);
  1645. S.ruleX=Object.assign(Object.fromEntries(EXTRA.map(e=>[e.id,!e.off])),snap.ruleX||{});
  1646. S.brand=snap.brand!=null?snap.brand:0;
  1647. S.door=snap.door!=null?snap.door:2;
  1648. /* 原图与识别过程不随报价单保存:清掉上一单残留的画布/识别中间态,避免误导 */
  1649. S.kitBox=null;S.redArrow=null;img=null;overlays=[];ctx.clearRect(0,0,cv.width,cv.height);
  1650. $("q-ai-log").innerHTML="";logAI("📂 已打开存档报价单:原图与识别过程不随单保存,计价数据已恢复。","b");
  1651. $("dc-type").textContent=snap.ft==="cad"?"CAD图":"平面户型图";
  1652. $("dc-red").textContent=snap.ft==="cad"?"—":(snap.redbox?"有红框":"无红框");
  1653. $("dc-rule").textContent="存档恢复(原图未保存)";
  1654. $("q-ai-scene-chip").textContent="存档报价单(原图未保存)";$("q-status-drawing").textContent="⬜ 图纸:未随单保存";
  1655. $("q-btn-analyze").disabled=true;$("q-step2-next-btn").disabled=false;
  1656. S.objs.forEach(o=>{if(!o.extras)o.extras=mkExtras(o.name||"",false);});
  1657. rebuildObjs();recalc();renderAIResults();
  1658. $("editor-title").textContent=q.name;
  1659. switchView("editor");qOpenEditorStep(4);
  1660. toast("已打开:"+q.name);
  1661. }catch(e){toast("打开失败","toast-error");}
  1662. }
  1663. /* ========== 品牌报价抬头 & 导出Excel ========== */
  1664. const BRANDS=[{t:"万骊全屋定制预算表",a:"万骊总部旗舰店"},{t:"华拓全屋定制预算表",a:"华拓总部店"}];
  1665. function applyBrand(){const b=BRANDS[S.brand]||BRANDS[0];
  1666. if($("q-quote-title"))$("q-quote-title").textContent=b.t;
  1667. if($("q-quote-addr"))$("q-quote-addr").textContent="门店地址:"+b.a;
  1668. const sel=$("q-brand-select");if(sel&&+sel.value!==S.brand)sel.value=String(S.brand);}
  1669. 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;}}
  1670. let crc=0xFFFFFFFF;for(let i=0;i<u8.length;i++)crc=(crc>>>8)^T[(crc^u8[i])&0xFF];return (crc^0xFFFFFFFF)>>>0;}
  1671. function _zipStore(files){ /* 最小ZIP(stored不压缩):本地头+中心目录+EOCD */
  1672. const enc=new TextEncoder(),parts=[],cd=[];let off=0;
  1673. files.forEach(f=>{const nm=enc.encode(f.name),data=f.data,crc=_crc32(data);
  1674. const lh=new DataView(new ArrayBuffer(30));
  1675. lh.setUint32(0,0x04034b50,true);lh.setUint16(4,20,true);
  1676. lh.setUint32(14,crc,true);lh.setUint32(18,data.length,true);lh.setUint32(22,data.length,true);
  1677. lh.setUint16(26,nm.length,true);
  1678. parts.push(new Uint8Array(lh.buffer),nm,data);
  1679. cd.push({nm,crc,size:data.length,off});off+=30+nm.length+data.length;});
  1680. let cdSize=0;const cdParts=[];
  1681. cd.forEach(c=>{const dv=new DataView(new ArrayBuffer(46));
  1682. dv.setUint32(0,0x02014b50,true);dv.setUint16(4,20,true);dv.setUint16(6,20,true);
  1683. dv.setUint32(16,c.crc,true);dv.setUint32(20,c.size,true);dv.setUint32(24,c.size,true);
  1684. dv.setUint16(28,c.nm.length,true);dv.setUint32(42,c.off,true);
  1685. cdParts.push(new Uint8Array(dv.buffer),c.nm);cdSize+=46+c.nm.length;});
  1686. const eo=new DataView(new ArrayBuffer(22));
  1687. eo.setUint32(0,0x06054b50,true);eo.setUint16(8,cd.length,true);eo.setUint16(10,cd.length,true);
  1688. eo.setUint32(12,cdSize,true);eo.setUint32(16,off,true);
  1689. const all=parts.concat(cdParts,[new Uint8Array(eo.buffer)]);
  1690. let tot=0;all.forEach(a=>tot+=a.length);const out=new Uint8Array(tot);let p=0;
  1691. all.forEach(a=>{out.set(a,p);p+=a.length;});return out;}
  1692. /* 导出格式完全复刻 6.18号欧阳路预估报价.xlsx:styles.xml/theme.xml 原样复制(微软雅黑;标题18加粗;
  1693. 表头/合计橙底th2;10列布局/列宽/行高/合并/每格样式索引与参考表一致) */
  1694. function xlsxBuild(doc){ /* doc={rows:[[{v,s}]],merges:[],cols:[{w,st}],heights:{},stylesRaw,themeRaw} → .xlsx字节 */
  1695. const esc=s=>String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
  1696. 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;};
  1697. let body="";doc.rows.forEach((row,ri)=>{const ht=doc.heights&&doc.heights[ri];
  1698. 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);
  1699. if(typeof v==="number"&&isFinite(v))cells+='<c r="'+ref+'" s="'+s+'"><v>'+v+'</v></c>';
  1700. else if(v!==""&&v!=null)cells+='<c r="'+ref+'" s="'+s+'" t="inlineStr"><is><t xml:space="preserve">'+esc(v)+'</t></is></c>';
  1701. else cells+='<c r="'+ref+'" s="'+s+'"/>';});
  1702. body+='<row r="'+(ri+1)+'" customFormat="1"'+(ht?' ht="'+ht+'" customHeight="1"':'')+' spans="1:'+row.length+'">'+cells+'</row>';});
  1703. 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("");
  1704. const mergeXml=doc.merges&&doc.merges.length?'<mergeCells count="'+doc.merges.length+'">'+doc.merges.map(m=>'<mergeCell ref="'+m+'"/>').join("")+'</mergeCells>':"";
  1705. const enc=new TextEncoder();
  1706. const hasTheme=!!doc.themeRaw;
  1707. 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>';
  1708. 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>';
  1709. 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>';
  1710. 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>';
  1711. 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>';
  1712. /* 视图与参考表一致:打开即130%显示比例、默认行高16.5,保证观感与参考表相同 */
  1713. 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>';
  1714. 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]];
  1715. if(hasTheme)files.push(["xl/theme/theme1.xml",doc.themeRaw]);
  1716. return _zipStore(files.map(x=>({name:x[0],data:enc.encode(x[1])})));}
  1717. async function qExportExcel(){
  1718. if(!LASTQ.baseRows.length&&!LASTQ.valueRows.length){toast("暂无报价内容,请先上传图纸完成AI识别","toast-error");return;}
  1719. const b=BRANDS[S.brand]||BRANDS[0];
  1720. /* 取参考表 styles.xml/theme.xml 原样嵌入,保证字体/字号/颜色与参考表完全一致 */
  1721. let stylesRaw="",themeRaw="";
  1722. try{const rp=await fetch("/ref_parts",{cache:"no-store"});
  1723. if(rp.ok){const j=await rp.json();
  1724. if(j.styles){const dec=s=>new TextDecoder().decode(Uint8Array.from(atob(s),c=>c.charCodeAt(0)));
  1725. stylesRaw=dec(j.styles);themeRaw=j.theme?dec(j.theme):"";}}
  1726. }catch(e){}
  1727. if(!stylesRaw){toast("导出失败:无法读取参考表样式(请确认桌面存在 6.18号欧阳路预估报价.xlsx 且服务已启动)","toast-error");return;}
  1728. const t=(x,s)=>({v:x,s:s});
  1729. const rows=[],merges=[],heights={};
  1730. const R=arr=>{rows.push(arr);heights[rows.length-1]=18;};
  1731. /* 行样式索引与参考表逐格一致 */
  1732. const BODY=[9,10,9,11,11,12,13,14,11,15]; /* 柜体行 */
  1733. const ADD =[9,10,19,17,17,24,17,21,20,18]; /* 增值项行 */
  1734. 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)];
  1735. /* 标题行(参考表R1:ht28、s5、F为s6、合并A1:J1) */
  1736. 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");
  1737. /* 按空间分组(复刻参考表图一):每个空间前重复表头行;B列合并空间柜体名称;C列子项(柜体+该柜增值项);块尾空间“总价”行 */
  1738. const f=n=>typeof n==="number"&&isFinite(n)?+n.toFixed(2):"";
  1739. const dvx=x=>x!==""&&x!=null?(+x||0):"";
  1740. const SUBT=[25,22,57,58,58,59,46,29,19,30]; /* 空间总价行样式:与参考表“总价”行逐格一致 */
  1741. LASTQ.baseRows.forEach((r,bi)=>{
  1742. const vrs=LASTQ.valueRows.filter(v=>v.label===(r.kind==="kitchen"?"厨房":r.name));
  1743. const h=rows.length+1;R(bi===0?hdr(true):hdr(false));merges.push("B"+h+":C"+h,"I"+h+":J"+h);
  1744. const g=rows.length+1;
  1745. /* 柜体基础行(宽/高/面积|总长度 与系统步骤4逐列一致;单位已并入表头) */
  1746. 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])]);
  1747. /* 增值子项行:排在所属空间下,C列仅写子项名 */
  1748. vrs.forEach(v=>{const q=parseFloat(v.qty)||"";
  1749. 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])]);});
  1750. /* 空间总价行:柜体+该空间增值项合计(C:G合并标签、H总价、I“/”) */
  1751. const rn=rows.length+1;
  1752. 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])]);
  1753. merges.push("C"+rn+":G"+rn);
  1754. /* 序号/品名 跨整块合并(含增值行与总价行) */
  1755. if(rows.length>g)merges.push("A"+g+":A"+rows.length,"B"+g+":B"+rows.length);
  1756. });
  1757. /* 尾部:仅保留 总投影面积 + 全屋折后合计 两行(单价已含浮动率,grand 即折后口径) */
  1758. const grand=Math.round(LASTQ.baseSum+LASTQ.wSum);
  1759. /* 注意:合并区行号=rows.length+1(R()推入后的1基行号),少+1会压到上一数据行导致Excel修复丢格 */
  1760. let n=rows.length+1;
  1761. 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);
  1762. n=rows.length+1;
  1763. 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);
  1764. const FULL=[88,88,88,88,88,89,88,88,88,88],SIGN=[90,90,90,90,90,91,90,90,90,90];
  1765. 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);};
  1766. fullRow("注意:此份报价仅作为前期与客户沟通参考,最终以实际签约合同为准。",FULL);
  1767. /* 家居顾问默认留空,导出后可在Excel中手动填写 */
  1768. fullRow("家居顾问:",SIGN);
  1769. fullRow("门店地址:"+b.a,SIGN);
  1770. const d=new Date(),p2=x=>String(x).padStart(2,"0");
  1771. fullRow("当日日期:"+d.getFullYear()+"."+(d.getMonth()+1)+"."+d.getDate(),SIGN);
  1772. /* 列宽/样式与参考表完全一致(A~J) */
  1773. 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}];
  1774. const bytes=xlsxBuild({rows,merges,heights,cols:COLS,stylesRaw,themeRaw});
  1775. let bin="";for(let i=0;i<bytes.length;i++)bin+=String.fromCharCode(bytes[i]);
  1776. const fname=b.t+"_"+d.getFullYear()+p2(d.getMonth()+1)+p2(d.getDate())+"_"+p2(d.getHours())+p2(d.getMinutes())+".xlsx";
  1777. fetch("/save_xlsx",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:fname,data:btoa(bin)})})
  1778. .then(r=>r.json()).then(j=>{toast(j.ok?"已导出到桌面 ✓ "+fname:"导出失败:"+j.err,j.ok?"toast-success":"toast-error");})
  1779. .catch(e=>toast("导出失败:"+e.message,"toast-error"));
  1780. }
  1781. /* ========== 规则配置表 ========== */
  1782. function switchRuleTab(t){
  1783. document.querySelectorAll("#view-rules .tab").forEach(el=>el.classList.remove("active"));
  1784. event.target.classList.add("active");
  1785. document.querySelectorAll(".rule-tab-content").forEach(c=>c.classList.add("hidden"));
  1786. $("rule-tab-"+t).classList.remove("hidden");
  1787. }
  1788. function renderRules(){
  1789. $("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("");
  1790. $("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("");
  1791. $("rule-table-wardrobe").querySelectorAll("input[data-x]").forEach(cb=>cb.onchange=()=>{S.ruleX[cb.dataset.x]=cb.checked;rebuildObjs();recalc();});
  1792. $("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("");
  1793. $("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("");
  1794. $("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("");
  1795. }
  1796. /* ========== 事件与初始化 ========== */
  1797. $("q-red-anno").oninput=()=>{if(S.redArrow){const old=dimScale();const inf=annoInfer(S.scale600||0);if(!inf)return;S.scale=inf.scale;S.scaleSrc="红标注箭头校准(推断单位"+inf.unit+" · 与600mm锚点交叉校验)";const nw=dimScale();if(S.kitBox&&old&&nw)S.kitBox={wM:S.kitBox.wM/old*nw,hM:S.kitBox.hM/old*nw};renderAnnoInfer();recalc();renderAIResults();}};
  1798. $("btnFb").onclick=()=>{const px=+$("fbPx").value||0,mm=+$("fbMm").value||0;if(px>0&&mm>0){S.scale=(mm/1000)/px;S.scaleSrc="用户补充已知尺寸";recalc();toast("比例已建立");}else toast("请填写像素数与毫米数","toast-error");};
  1799. qBindDrop($("q-upload-zone-unified"));qBindDrop($("q-canvas-drop")); /* 点击 + 拖拽双通道上传/更换图纸 */
  1800. renderTiers();renderRules();renderQuoteList();rebuildObjs();recalc();
  1801. </script>
  1802. </body>
  1803. </html>