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

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