_inline_script.js 747 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517551855195520552155225523552455255526552755285529553055315532553355345535553655375538553955405541554255435544554555465547554855495550555155525553555455555556555755585559556055615562556355645565556655675568556955705571557255735574557555765577557855795580558155825583558455855586558755885589559055915592559355945595559655975598559956005601560256035604560556065607560856095610561156125613561456155616561756185619562056215622562356245625562656275628562956305631563256335634563556365637563856395640564156425643564456455646564756485649565056515652565356545655565656575658565956605661566256635664566556665667566856695670567156725673567456755676567756785679568056815682568356845685568656875688568956905691569256935694569556965697569856995700570157025703570457055706570757085709571057115712571357145715571657175718571957205721572257235724572557265727572857295730573157325733573457355736573757385739574057415742574357445745574657475748574957505751575257535754575557565757575857595760576157625763576457655766576757685769577057715772577357745775577657775778577957805781578257835784578557865787578857895790579157925793579457955796579757985799580058015802580358045805580658075808580958105811581258135814581558165817581858195820582158225823582458255826582758285829583058315832583358345835583658375838583958405841584258435844584558465847584858495850585158525853585458555856585758585859586058615862586358645865586658675868586958705871587258735874587558765877587858795880588158825883588458855886588758885889589058915892589358945895589658975898589959005901590259035904590559065907590859095910591159125913591459155916591759185919592059215922592359245925592659275928592959305931593259335934593559365937593859395940594159425943594459455946594759485949595059515952595359545955595659575958595959605961596259635964596559665967596859695970597159725973597459755976597759785979598059815982598359845985598659875988598959905991599259935994599559965997599859996000600160026003600460056006600760086009601060116012601360146015601660176018601960206021602260236024602560266027602860296030603160326033603460356036603760386039604060416042604360446045604660476048604960506051605260536054605560566057605860596060606160626063606460656066606760686069607060716072607360746075607660776078607960806081608260836084608560866087608860896090609160926093609460956096609760986099610061016102610361046105610661076108610961106111611261136114611561166117611861196120612161226123612461256126612761286129613061316132613361346135613661376138613961406141614261436144614561466147614861496150615161526153615461556156615761586159616061616162616361646165616661676168616961706171617261736174617561766177617861796180618161826183618461856186618761886189619061916192619361946195619661976198619962006201620262036204620562066207620862096210621162126213621462156216621762186219622062216222622362246225622662276228622962306231623262336234623562366237623862396240624162426243624462456246624762486249625062516252625362546255625662576258625962606261626262636264626562666267626862696270627162726273627462756276627762786279628062816282628362846285628662876288628962906291629262936294629562966297629862996300630163026303630463056306630763086309631063116312631363146315631663176318631963206321632263236324632563266327632863296330633163326333633463356336633763386339634063416342634363446345634663476348634963506351635263536354635563566357635863596360636163626363636463656366636763686369637063716372637363746375637663776378637963806381638263836384638563866387638863896390639163926393639463956396639763986399640064016402640364046405640664076408640964106411641264136414641564166417641864196420642164226423642464256426642764286429643064316432643364346435643664376438643964406441644264436444644564466447644864496450645164526453645464556456645764586459646064616462646364646465646664676468646964706471647264736474647564766477647864796480648164826483648464856486648764886489649064916492649364946495649664976498649965006501650265036504650565066507650865096510651165126513651465156516651765186519652065216522652365246525652665276528652965306531653265336534653565366537653865396540654165426543654465456546654765486549655065516552655365546555655665576558655965606561656265636564656565666567656865696570657165726573657465756576657765786579658065816582658365846585658665876588658965906591659265936594659565966597659865996600660166026603660466056606660766086609661066116612661366146615661666176618661966206621662266236624662566266627662866296630663166326633663466356636663766386639664066416642664366446645664666476648664966506651665266536654665566566657665866596660666166626663666466656666666766686669667066716672667366746675667666776678667966806681668266836684668566866687668866896690669166926693669466956696669766986699670067016702670367046705670667076708670967106711671267136714671567166717671867196720672167226723672467256726672767286729673067316732673367346735673667376738673967406741674267436744674567466747674867496750675167526753675467556756675767586759676067616762676367646765676667676768676967706771677267736774677567766777677867796780678167826783678467856786678767886789679067916792679367946795679667976798679968006801680268036804680568066807680868096810681168126813681468156816681768186819682068216822682368246825682668276828682968306831683268336834683568366837683868396840684168426843684468456846684768486849685068516852685368546855685668576858685968606861686268636864686568666867686868696870687168726873687468756876687768786879688068816882688368846885688668876888688968906891689268936894689568966897689868996900690169026903690469056906690769086909691069116912691369146915691669176918691969206921692269236924692569266927692869296930693169326933693469356936693769386939694069416942694369446945694669476948694969506951695269536954695569566957695869596960696169626963696469656966696769686969697069716972697369746975697669776978697969806981698269836984698569866987698869896990699169926993699469956996699769986999700070017002700370047005700670077008700970107011701270137014701570167017701870197020702170227023702470257026702770287029703070317032703370347035703670377038703970407041704270437044704570467047704870497050705170527053705470557056705770587059706070617062706370647065706670677068706970707071707270737074707570767077707870797080708170827083708470857086708770887089709070917092709370947095709670977098709971007101710271037104710571067107710871097110711171127113711471157116711771187119712071217122712371247125712671277128712971307131713271337134713571367137713871397140714171427143714471457146714771487149715071517152715371547155715671577158715971607161716271637164716571667167716871697170717171727173717471757176717771787179718071817182718371847185718671877188718971907191719271937194719571967197719871997200720172027203720472057206720772087209721072117212721372147215721672177218721972207221722272237224722572267227722872297230723172327233723472357236723772387239724072417242724372447245724672477248724972507251725272537254725572567257725872597260726172627263726472657266726772687269727072717272727372747275727672777278727972807281728272837284728572867287728872897290729172927293729472957296729772987299730073017302730373047305730673077308730973107311731273137314731573167317731873197320732173227323732473257326732773287329733073317332733373347335733673377338733973407341734273437344734573467347734873497350735173527353735473557356735773587359736073617362736373647365736673677368736973707371737273737374737573767377737873797380738173827383738473857386738773887389739073917392739373947395739673977398739974007401740274037404740574067407740874097410741174127413741474157416741774187419742074217422742374247425742674277428742974307431743274337434743574367437743874397440744174427443744474457446744774487449745074517452745374547455745674577458745974607461746274637464746574667467746874697470747174727473747474757476747774787479748074817482748374847485748674877488748974907491749274937494749574967497749874997500750175027503750475057506750775087509751075117512751375147515751675177518751975207521752275237524752575267527752875297530753175327533753475357536753775387539754075417542754375447545754675477548754975507551755275537554755575567557755875597560756175627563756475657566756775687569757075717572757375747575757675777578757975807581758275837584758575867587758875897590759175927593759475957596759775987599760076017602760376047605760676077608760976107611761276137614761576167617761876197620762176227623762476257626762776287629763076317632763376347635763676377638763976407641764276437644764576467647764876497650765176527653765476557656765776587659766076617662766376647665766676677668766976707671767276737674767576767677767876797680768176827683768476857686768776887689769076917692769376947695769676977698769977007701770277037704770577067707770877097710771177127713771477157716771777187719772077217722772377247725772677277728772977307731773277337734773577367737773877397740774177427743774477457746774777487749775077517752775377547755775677577758775977607761776277637764776577667767776877697770777177727773777477757776777777787779778077817782778377847785778677877788778977907791779277937794779577967797779877997800780178027803780478057806780778087809781078117812781378147815781678177818781978207821782278237824782578267827782878297830783178327833783478357836783778387839784078417842784378447845784678477848784978507851785278537854785578567857785878597860786178627863786478657866786778687869787078717872787378747875787678777878787978807881788278837884788578867887788878897890789178927893789478957896789778987899790079017902790379047905790679077908790979107911791279137914791579167917791879197920792179227923792479257926792779287929793079317932793379347935793679377938793979407941794279437944794579467947794879497950795179527953795479557956795779587959796079617962796379647965796679677968796979707971797279737974797579767977797879797980798179827983798479857986798779887989799079917992799379947995799679977998799980008001800280038004800580068007800880098010801180128013801480158016801780188019802080218022802380248025802680278028802980308031803280338034803580368037803880398040804180428043804480458046804780488049805080518052805380548055805680578058805980608061806280638064806580668067806880698070807180728073807480758076807780788079808080818082808380848085808680878088808980908091809280938094809580968097809880998100810181028103810481058106810781088109811081118112811381148115811681178118811981208121812281238124812581268127812881298130813181328133813481358136813781388139814081418142814381448145814681478148814981508151815281538154815581568157815881598160816181628163816481658166816781688169817081718172817381748175817681778178817981808181818281838184818581868187818881898190819181928193819481958196819781988199820082018202820382048205820682078208820982108211821282138214821582168217821882198220822182228223822482258226822782288229823082318232823382348235823682378238823982408241824282438244824582468247824882498250825182528253825482558256825782588259826082618262826382648265826682678268826982708271827282738274827582768277827882798280828182828283828482858286828782888289829082918292829382948295829682978298829983008301830283038304830583068307830883098310831183128313831483158316831783188319832083218322832383248325832683278328832983308331833283338334833583368337833883398340834183428343834483458346834783488349835083518352835383548355835683578358835983608361836283638364836583668367836883698370837183728373837483758376837783788379838083818382838383848385838683878388838983908391839283938394839583968397839883998400840184028403840484058406840784088409841084118412841384148415841684178418841984208421842284238424842584268427842884298430843184328433843484358436843784388439844084418442844384448445844684478448844984508451845284538454845584568457845884598460846184628463846484658466846784688469847084718472847384748475847684778478847984808481848284838484848584868487848884898490849184928493849484958496849784988499850085018502850385048505850685078508850985108511851285138514851585168517851885198520852185228523852485258526852785288529853085318532853385348535853685378538853985408541854285438544854585468547854885498550855185528553855485558556855785588559856085618562856385648565856685678568856985708571857285738574857585768577857885798580858185828583858485858586858785888589859085918592859385948595859685978598859986008601860286038604860586068607860886098610861186128613861486158616861786188619862086218622862386248625862686278628862986308631863286338634863586368637863886398640864186428643864486458646864786488649865086518652865386548655865686578658865986608661866286638664866586668667866886698670867186728673867486758676867786788679868086818682868386848685868686878688868986908691869286938694869586968697869886998700870187028703870487058706870787088709871087118712871387148715871687178718871987208721872287238724872587268727872887298730873187328733873487358736873787388739874087418742874387448745874687478748874987508751875287538754875587568757875887598760876187628763876487658766876787688769877087718772877387748775877687778778877987808781878287838784878587868787878887898790879187928793879487958796879787988799880088018802880388048805880688078808880988108811881288138814881588168817881888198820882188228823882488258826882788288829883088318832883388348835883688378838883988408841884288438844884588468847884888498850885188528853885488558856885788588859886088618862886388648865886688678868886988708871887288738874887588768877887888798880888188828883888488858886888788888889889088918892889388948895889688978898889989008901890289038904890589068907890889098910891189128913891489158916891789188919892089218922892389248925892689278928892989308931893289338934893589368937893889398940894189428943894489458946894789488949895089518952895389548955895689578958895989608961896289638964896589668967896889698970897189728973897489758976897789788979898089818982898389848985898689878988898989908991899289938994899589968997899889999000900190029003900490059006900790089009901090119012901390149015901690179018901990209021902290239024902590269027902890299030903190329033903490359036903790389039904090419042904390449045904690479048904990509051905290539054905590569057905890599060906190629063906490659066906790689069907090719072907390749075907690779078907990809081908290839084908590869087908890899090909190929093909490959096909790989099910091019102910391049105910691079108910991109111911291139114911591169117911891199120912191229123912491259126912791289129913091319132913391349135913691379138913991409141914291439144914591469147914891499150915191529153915491559156915791589159916091619162916391649165916691679168916991709171917291739174917591769177917891799180918191829183918491859186918791889189919091919192919391949195919691979198919992009201920292039204920592069207920892099210921192129213921492159216921792189219922092219222922392249225922692279228922992309231923292339234923592369237923892399240924192429243924492459246924792489249925092519252925392549255925692579258925992609261926292639264926592669267926892699270927192729273927492759276927792789279928092819282928392849285928692879288928992909291929292939294929592969297929892999300930193029303930493059306930793089309931093119312931393149315931693179318931993209321932293239324932593269327932893299330933193329333933493359336933793389339934093419342934393449345934693479348934993509351935293539354935593569357935893599360936193629363936493659366936793689369937093719372937393749375937693779378937993809381938293839384938593869387938893899390939193929393939493959396939793989399940094019402940394049405940694079408940994109411941294139414941594169417941894199420942194229423942494259426942794289429943094319432943394349435943694379438943994409441944294439444944594469447944894499450945194529453945494559456945794589459946094619462946394649465946694679468946994709471947294739474947594769477947894799480948194829483948494859486948794889489949094919492949394949495949694979498949995009501950295039504950595069507950895099510951195129513951495159516951795189519952095219522952395249525952695279528952995309531953295339534953595369537953895399540954195429543954495459546954795489549955095519552955395549555955695579558955995609561956295639564956595669567956895699570957195729573957495759576957795789579958095819582958395849585958695879588958995909591959295939594959595969597959895999600960196029603960496059606960796089609961096119612961396149615961696179618961996209621962296239624962596269627962896299630963196329633963496359636963796389639964096419642964396449645964696479648964996509651965296539654965596569657965896599660966196629663966496659666966796689669967096719672967396749675967696779678967996809681968296839684968596869687968896899690969196929693969496959696969796989699970097019702970397049705970697079708970997109711971297139714971597169717971897199720972197229723972497259726972797289729973097319732973397349735973697379738973997409741974297439744974597469747974897499750975197529753975497559756975797589759976097619762976397649765976697679768976997709771977297739774977597769777977897799780978197829783978497859786978797889789979097919792979397949795979697979798979998009801980298039804980598069807980898099810981198129813981498159816981798189819982098219822982398249825982698279828982998309831983298339834983598369837983898399840984198429843984498459846984798489849985098519852985398549855985698579858985998609861986298639864986598669867986898699870987198729873987498759876987798789879988098819882988398849885988698879888988998909891989298939894989598969897989898999900990199029903990499059906990799089909991099119912991399149915991699179918991999209921992299239924992599269927992899299930993199329933993499359936993799389939994099419942994399449945994699479948994999509951995299539954995599569957995899599960996199629963996499659966996799689969997099719972997399749975997699779978997999809981998299839984998599869987998899899990999199929993999499959996999799989999100001000110002100031000410005100061000710008100091001010011100121001310014100151001610017100181001910020100211002210023100241002510026100271002810029100301003110032100331003410035100361003710038100391004010041100421004310044100451004610047100481004910050100511005210053100541005510056100571005810059100601006110062100631006410065100661006710068100691007010071100721007310074100751007610077100781007910080100811008210083100841008510086100871008810089100901009110092100931009410095100961009710098100991010010101101021010310104101051010610107101081010910110101111011210113101141011510116101171011810119101201012110122101231012410125101261012710128101291013010131101321013310134101351013610137101381013910140101411014210143101441014510146101471014810149101501015110152101531015410155101561015710158101591016010161101621016310164101651016610167101681016910170101711017210173101741017510176101771017810179101801018110182101831018410185101861018710188101891019010191101921019310194101951019610197101981019910200102011020210203102041020510206102071020810209102101021110212102131021410215102161021710218102191022010221102221022310224102251022610227102281022910230102311023210233102341023510236102371023810239102401024110242102431024410245102461024710248102491025010251102521025310254102551025610257102581025910260102611026210263102641026510266102671026810269102701027110272102731027410275102761027710278102791028010281102821028310284102851028610287102881028910290102911029210293102941029510296102971029810299103001030110302103031030410305103061030710308103091031010311103121031310314103151031610317103181031910320103211032210323103241032510326103271032810329103301033110332103331033410335103361033710338103391034010341103421034310344103451034610347103481034910350103511035210353103541035510356103571035810359103601036110362103631036410365103661036710368103691037010371103721037310374103751037610377103781037910380103811038210383103841038510386103871038810389103901039110392103931039410395103961039710398103991040010401104021040310404104051040610407104081040910410104111041210413104141041510416104171041810419104201042110422104231042410425104261042710428104291043010431104321043310434104351043610437104381043910440104411044210443104441044510446104471044810449104501045110452104531045410455104561045710458104591046010461104621046310464104651046610467104681046910470104711047210473104741047510476104771047810479104801048110482104831048410485104861048710488104891049010491104921049310494104951049610497104981049910500105011050210503105041050510506105071050810509105101051110512105131051410515105161051710518105191052010521105221052310524105251052610527105281052910530105311053210533105341053510536105371053810539105401054110542105431054410545105461054710548105491055010551105521055310554105551055610557105581055910560105611056210563105641056510566105671056810569105701057110572105731057410575105761057710578105791058010581105821058310584105851058610587105881058910590105911059210593105941059510596105971059810599106001060110602106031060410605106061060710608106091061010611106121061310614106151061610617106181061910620106211062210623106241062510626106271062810629106301063110632106331063410635106361063710638106391064010641106421064310644106451064610647106481064910650106511065210653106541065510656106571065810659106601066110662106631066410665106661066710668106691067010671106721067310674106751067610677106781067910680106811068210683106841068510686106871068810689106901069110692106931069410695106961069710698106991070010701107021070310704107051070610707107081070910710107111071210713107141071510716107171071810719107201072110722107231072410725107261072710728107291073010731107321073310734107351073610737107381073910740107411074210743107441074510746107471074810749107501075110752107531075410755107561075710758107591076010761107621076310764107651076610767107681076910770107711077210773107741077510776107771077810779107801078110782107831078410785107861078710788107891079010791107921079310794107951079610797107981079910800108011080210803108041080510806108071080810809108101081110812108131081410815108161081710818108191082010821108221082310824108251082610827108281082910830108311083210833108341083510836108371083810839108401084110842108431084410845108461084710848108491085010851108521085310854108551085610857108581085910860108611086210863108641086510866108671086810869108701087110872108731087410875108761087710878108791088010881108821088310884108851088610887108881088910890108911089210893108941089510896108971089810899109001090110902109031090410905109061090710908109091091010911109121091310914109151091610917109181091910920109211092210923109241092510926109271092810929109301093110932109331093410935109361093710938109391094010941109421094310944109451094610947109481094910950109511095210953109541095510956109571095810959109601096110962109631096410965109661096710968109691097010971109721097310974109751097610977109781097910980109811098210983109841098510986109871098810989109901099110992109931099410995109961099710998109991100011001110021100311004110051100611007110081100911010110111101211013110141101511016110171101811019110201102111022110231102411025110261102711028110291103011031110321103311034110351103611037110381103911040110411104211043110441104511046110471104811049110501105111052110531105411055110561105711058110591106011061110621106311064110651106611067110681106911070110711107211073110741107511076110771107811079110801108111082110831108411085110861108711088110891109011091110921109311094110951109611097110981109911100111011110211103111041110511106111071110811109111101111111112111131111411115111161111711118111191112011121111221112311124111251112611127111281112911130111311113211133111341113511136111371113811139111401114111142111431114411145111461114711148111491115011151111521115311154111551115611157111581115911160111611116211163111641116511166111671116811169111701117111172111731117411175111761117711178111791118011181111821118311184111851118611187111881118911190111911119211193111941119511196111971119811199112001120111202112031120411205112061120711208112091121011211112121121311214112151121611217112181121911220112211122211223112241122511226112271122811229112301123111232112331123411235112361123711238112391124011241112421124311244112451124611247112481124911250112511125211253112541125511256112571125811259112601126111262112631126411265112661126711268112691127011271112721127311274112751127611277112781127911280112811128211283112841128511286112871128811289112901129111292112931129411295112961129711298112991130011301113021130311304113051130611307113081130911310113111131211313113141131511316113171131811319113201132111322113231132411325113261132711328113291133011331113321133311334113351133611337113381133911340113411134211343113441134511346113471134811349113501135111352113531135411355113561135711358113591136011361113621136311364113651136611367113681136911370113711137211373113741137511376113771137811379113801138111382113831138411385113861138711388113891139011391113921139311394113951139611397113981139911400114011140211403114041140511406114071140811409114101141111412114131141411415114161141711418114191142011421114221142311424114251142611427114281142911430114311143211433114341143511436114371143811439114401144111442114431144411445114461144711448114491145011451114521145311454114551145611457114581145911460114611146211463114641146511466114671146811469114701147111472114731147411475114761147711478114791148011481114821148311484114851148611487114881148911490114911149211493114941149511496114971149811499115001150111502115031150411505115061150711508115091151011511115121151311514115151151611517115181151911520115211152211523115241152511526115271152811529115301153111532115331153411535115361153711538115391154011541115421154311544115451154611547115481154911550115511155211553115541155511556115571155811559115601156111562115631156411565115661156711568115691157011571115721157311574115751157611577115781157911580115811158211583115841158511586115871158811589115901159111592115931159411595115961159711598115991160011601116021160311604116051160611607116081160911610116111161211613116141161511616116171161811619116201162111622116231162411625116261162711628116291163011631116321163311634116351163611637116381163911640116411164211643116441164511646116471164811649116501165111652116531165411655116561165711658116591166011661116621166311664116651166611667116681166911670116711167211673116741167511676116771167811679116801168111682116831168411685116861168711688116891169011691116921169311694116951169611697116981169911700117011170211703117041170511706117071170811709117101171111712117131171411715117161171711718117191172011721117221172311724117251172611727117281172911730117311173211733117341173511736117371173811739117401174111742117431174411745117461174711748117491175011751117521175311754117551175611757117581175911760117611176211763117641176511766117671176811769117701177111772117731177411775117761177711778117791178011781117821178311784117851178611787117881178911790117911179211793117941179511796117971179811799118001180111802118031180411805118061180711808118091181011811118121181311814118151181611817118181181911820118211182211823118241182511826118271182811829118301183111832118331183411835118361183711838118391184011841118421184311844118451184611847118481184911850118511185211853118541185511856118571185811859118601186111862118631186411865118661186711868118691187011871118721187311874118751187611877118781187911880118811188211883118841188511886118871188811889118901189111892118931189411895118961189711898118991190011901119021190311904119051190611907119081190911910119111191211913119141191511916119171191811919119201192111922119231192411925119261192711928119291193011931119321193311934119351193611937119381193911940119411194211943119441194511946119471194811949119501195111952119531195411955119561195711958119591196011961119621196311964119651196611967119681196911970119711197211973119741197511976119771197811979119801198111982119831198411985119861198711988119891199011991119921199311994119951199611997119981199912000120011200212003120041200512006120071200812009120101201112012120131201412015120161201712018120191202012021120221202312024120251202612027120281202912030120311203212033120341203512036120371203812039120401204112042120431204412045120461204712048120491205012051120521205312054120551205612057120581205912060120611206212063120641206512066120671206812069120701207112072120731207412075120761207712078120791208012081120821208312084120851208612087120881208912090120911209212093120941209512096120971209812099121001210112102121031210412105121061210712108121091211012111121121211312114121151211612117121181211912120121211212212123121241212512126121271212812129121301213112132121331213412135121361213712138121391214012141121421214312144121451214612147121481214912150121511215212153121541215512156121571215812159121601216112162121631216412165121661216712168121691217012171121721217312174121751217612177121781217912180121811218212183121841218512186121871218812189121901219112192121931219412195121961219712198121991220012201122021220312204122051220612207122081220912210122111221212213122141221512216122171221812219122201222112222122231222412225122261222712228122291223012231122321223312234122351223612237122381223912240122411224212243122441224512246122471224812249122501225112252122531225412255122561225712258122591226012261122621226312264122651226612267122681226912270122711227212273122741227512276122771227812279122801228112282122831228412285122861228712288122891229012291122921229312294122951229612297122981229912300123011230212303123041230512306123071230812309123101231112312123131231412315123161231712318123191232012321123221232312324123251232612327123281232912330123311233212333123341233512336123371233812339123401234112342123431234412345123461234712348123491235012351123521235312354123551235612357123581235912360123611236212363123641236512366123671236812369123701237112372123731237412375123761237712378123791238012381123821238312384123851238612387123881238912390123911239212393123941239512396123971239812399124001240112402124031240412405124061240712408124091241012411124121241312414124151241612417124181241912420124211242212423124241242512426124271242812429124301243112432124331243412435124361243712438124391244012441124421244312444124451244612447124481244912450124511245212453124541245512456124571245812459124601246112462124631246412465124661246712468124691247012471124721247312474124751247612477124781247912480124811248212483124841248512486124871248812489124901249112492124931249412495124961249712498124991250012501125021250312504125051250612507125081250912510125111251212513125141251512516125171251812519125201252112522125231252412525125261252712528125291253012531125321253312534125351253612537125381253912540125411254212543125441254512546125471254812549125501255112552125531255412555125561255712558125591256012561125621256312564125651256612567125681256912570125711257212573125741257512576125771257812579125801258112582125831258412585125861258712588125891259012591125921259312594125951259612597125981259912600126011260212603126041260512606126071260812609126101261112612126131261412615126161261712618126191262012621126221262312624126251262612627126281262912630126311263212633126341263512636126371263812639126401264112642126431264412645126461264712648126491265012651126521265312654126551265612657126581265912660126611266212663126641266512666126671266812669126701267112672126731267412675126761267712678126791268012681126821268312684126851268612687126881268912690126911269212693126941269512696126971269812699127001270112702127031270412705127061270712708127091271012711127121271312714127151271612717127181271912720127211272212723127241272512726127271272812729127301273112732127331273412735127361273712738127391274012741127421274312744127451274612747127481274912750127511275212753127541275512756127571275812759127601276112762127631276412765127661276712768127691277012771127721277312774127751277612777127781277912780127811278212783127841278512786127871278812789127901279112792127931279412795127961279712798127991280012801128021280312804128051280612807128081280912810128111281212813128141281512816128171281812819128201282112822128231282412825128261282712828128291283012831128321283312834128351283612837128381283912840128411284212843128441284512846128471284812849128501285112852128531285412855128561285712858128591286012861128621286312864128651286612867128681286912870128711287212873128741287512876128771287812879128801288112882128831288412885128861288712888128891289012891128921289312894128951289612897128981289912900129011290212903129041290512906129071290812909129101291112912129131291412915129161291712918129191292012921129221292312924129251292612927129281292912930129311293212933129341293512936129371293812939129401294112942129431294412945129461294712948129491295012951129521295312954129551295612957129581295912960129611296212963129641296512966129671296812969129701297112972129731297412975129761297712978129791298012981129821298312984129851298612987129881298912990129911299212993129941299512996129971299812999130001300113002130031300413005130061300713008130091301013011130121301313014130151301613017130181301913020130211302213023130241302513026130271302813029130301303113032130331303413035130361303713038130391304013041130421304313044130451304613047130481304913050130511305213053130541305513056130571305813059130601306113062130631306413065130661306713068130691307013071130721307313074130751307613077130781307913080130811308213083130841308513086130871308813089130901309113092130931309413095130961309713098130991310013101131021310313104131051310613107131081310913110131111311213113131141311513116131171311813119131201312113122131231312413125131261312713128131291313013131131321313313134131351313613137131381313913140131411314213143131441314513146131471314813149131501315113152131531315413155131561315713158131591316013161131621316313164131651316613167131681316913170131711317213173131741317513176131771317813179131801318113182131831318413185131861318713188131891319013191131921319313194131951319613197131981319913200132011320213203132041320513206132071320813209132101321113212132131321413215132161321713218132191322013221132221322313224132251322613227132281322913230132311323213233132341323513236132371323813239132401324113242132431324413245132461324713248132491325013251132521325313254132551325613257132581325913260132611326213263132641326513266132671326813269132701327113272132731327413275132761327713278132791328013281132821328313284132851328613287132881328913290132911329213293132941329513296132971329813299133001330113302133031330413305133061330713308133091331013311133121331313314133151331613317133181331913320133211332213323133241332513326133271332813329133301333113332133331333413335133361333713338133391334013341133421334313344133451334613347133481334913350133511335213353133541335513356133571335813359133601336113362133631336413365133661336713368133691337013371133721337313374133751337613377133781337913380133811338213383133841338513386133871338813389133901339113392133931339413395133961339713398133991340013401134021340313404134051340613407134081340913410134111341213413134141341513416134171341813419134201342113422134231342413425134261342713428134291343013431134321343313434134351343613437134381343913440134411344213443134441344513446134471344813449134501345113452134531345413455134561345713458134591346013461134621346313464134651346613467134681346913470134711347213473134741347513476134771347813479134801348113482134831348413485134861348713488134891349013491134921349313494134951349613497134981349913500135011350213503135041350513506135071350813509135101351113512135131351413515135161351713518135191352013521135221352313524135251352613527135281352913530135311353213533135341353513536135371353813539135401354113542135431354413545135461354713548135491355013551135521355313554135551355613557135581355913560135611356213563135641356513566135671356813569135701357113572135731357413575135761357713578135791358013581135821358313584135851358613587135881358913590135911359213593135941359513596135971359813599136001360113602136031360413605136061360713608136091361013611136121361313614136151361613617136181361913620136211362213623136241362513626136271362813629136301363113632136331363413635136361363713638136391364013641136421364313644136451364613647136481364913650136511365213653136541365513656136571365813659136601366113662136631366413665136661366713668136691367013671136721367313674136751367613677136781367913680136811368213683136841368513686136871368813689136901369113692136931369413695136961369713698136991370013701137021370313704137051370613707137081370913710137111371213713137141371513716137171371813719137201372113722137231372413725137261372713728137291373013731137321373313734137351373613737137381373913740137411374213743137441374513746137471374813749137501375113752137531375413755137561375713758137591376013761137621376313764137651376613767137681376913770137711377213773137741377513776137771377813779137801378113782137831378413785137861378713788137891379013791137921379313794137951379613797137981379913800138011380213803138041380513806138071380813809138101381113812138131381413815138161381713818138191382013821138221382313824138251382613827138281382913830138311383213833138341383513836138371383813839138401384113842138431384413845138461384713848138491385013851138521385313854138551385613857138581385913860138611386213863138641386513866138671386813869138701387113872138731387413875138761387713878138791388013881138821388313884138851388613887138881388913890138911389213893138941389513896138971389813899139001390113902139031390413905139061390713908139091391013911139121391313914139151391613917139181391913920139211392213923139241392513926139271392813929139301393113932139331393413935139361393713938139391394013941139421394313944139451394613947139481394913950139511395213953139541395513956139571395813959139601396113962139631396413965139661396713968139691397013971139721397313974139751397613977139781397913980139811398213983139841398513986139871398813989139901399113992139931399413995139961399713998139991400014001140021400314004140051400614007140081400914010140111401214013140141401514016140171401814019140201402114022140231402414025140261402714028140291403014031140321403314034140351403614037140381403914040140411404214043140441404514046140471404814049140501405114052140531405414055140561405714058140591406014061140621406314064140651406614067140681406914070140711407214073140741407514076140771407814079140801408114082140831408414085140861408714088140891409014091140921409314094140951409614097140981409914100141011410214103141041410514106141071410814109141101411114112141131411414115141161411714118141191412014121141221412314124141251412614127141281412914130141311413214133141341413514136141371413814139141401414114142141431414414145141461414714148141491415014151141521415314154141551415614157141581415914160141611416214163141641416514166141671416814169141701417114172141731417414175141761417714178141791418014181141821418314184141851418614187141881418914190141911419214193141941419514196141971419814199142001420114202142031420414205142061420714208142091421014211142121421314214142151421614217142181421914220142211422214223142241422514226142271422814229142301423114232142331423414235142361423714238142391424014241142421424314244142451424614247142481424914250142511425214253142541425514256142571425814259142601426114262142631426414265142661426714268142691427014271142721427314274142751427614277142781427914280142811428214283142841428514286142871428814289142901429114292142931429414295142961429714298142991430014301143021430314304143051430614307143081430914310143111431214313143141431514316143171431814319143201432114322143231432414325143261432714328143291433014331143321433314334143351433614337
  1. // ====================== DATA STORE ======================
  2. const DEFAULT_RULES = {
  3. wardrobe: [
  4. { name:'生态澳松板柜体', displayName:'柜体', method:'按投影面积', value:'1852', unit:'元/㎡', desc:'生态澳松板柜体(衣柜类默认)' },
  5. { name:'铝格栅板柜体', displayName:'柜体', method:'按投影面积', value:'2532', unit:'元/㎡', desc:'铝格栅板柜体(主卧衣柜默认)' },
  6. { name:'板门补差价', method:'按投影面积', value:'538', unit:'元/㎡', desc:'双饰面板门补差价' },
  7. { name:'玻璃门补差价', method:'按投影面积', value:'1852', unit:'元/㎡', desc:'客厅展示柜玻璃门补差价' },
  8. { name:'灯带', method:'按投影面积', value:'386', unit:'元/㎡', desc:'3000K暖光灯' },
  9. { name:'变压器+开关', method:'按投影面积', value:'536', unit:'元/㎡', desc:'功能五金' },
  10. { name:'自弹器', method:'按投影面积', value:'115', unit:'元/㎡', desc:'海蒂诗自弹器' },
  11. { name:'见光板', method:'按投影面积', value:'1138', unit:'元/㎡', desc:'双饰面见光板' },
  12. { name:'铰链', method:'按投影面积', value:'40', unit:'元/㎡', desc:'奥地利百隆阻尼铰链' },
  13. { name:'护墙板', method:'按投影面积', value:'1852', unit:'元/㎡', desc:'双饰面护墙板' },
  14. { name:'拉手', method:'按投影面积', value:'226', unit:'元/㎡', desc:'金属拉手' },
  15. { name:'抽屉', method:'按投影面积', value:'1300', unit:'元/㎡', desc:'奥地利百隆导轨抽屉' },
  16. { name:'金属拉手', method:'按投影面积', value:'225', unit:'元/㎡', desc:'生态澳松板柜体金属拉手' },
  17. { name:'铝格栅金属拉手', method:'按投影面积', value:'680', unit:'元/㎡', desc:'铝格栅板柜体金属拉手' },
  18. { name:'衣杆', method:'按投影面积', value:'288', unit:'元/㎡', desc:'金属衣杆' },
  19. { name:'圆弧', method:'按投影面积', value:'1560', unit:'元/㎡', desc:'铝格栅板圆弧工艺' }
  20. ],
  21. kitchen: [
  22. { name:'地柜', method:'按延米', value:'2380', unit:'元/m', desc:'' },
  23. { name:'吊柜', method:'按延米', value:'1748', unit:'元/m', desc:'' },
  24. { name:'冰箱吊柜', method:'按延米', value:'2622', unit:'元/m', desc:'' },
  25. { name:'高柜', method:'按延米', value:'5950', unit:'元/m', desc:'' },
  26. { name:'见光板', method:'按投影面积', value:'1138', unit:'元/㎡', desc:'' },
  27. { name:'台面', method:'按延米', value:'1480', unit:'元/m', desc:'' },
  28. { name:'台面超出部分', method:'按延米', value:'2480', unit:'元/m', desc:'' },
  29. { name:'碗篮', method:'按个数', value:'1268', unit:'元/个', desc:'' },
  30. { name:'锅篮', method:'按个数', value:'1628', unit:'元/个', desc:'' },
  31. { name:'自弹', method:'按个数', value:'115', unit:'元/个', desc:'' },
  32. { name:'切角', method:'按个数', value:'500', unit:'元/个', desc:'' },
  33. { name:'台下盆', method:'按个数', value:'500', unit:'元/个', desc:'' },
  34. { name:'包管', method:'按个数', value:'500', unit:'元/个', desc:'' }
  35. ],
  36. base: [
  37. { name:'柜体投影单价', method:'按投影面积', value:'1852', price:'1852', unit:'元/m²', desc:'柜体投影面积单价' },
  38. { name:'生态澳松板柜体单价', method:'按投影面积', value:'1852', price:'1852', unit:'元/m²', desc:'衣柜类生态澳松板投影单价' },
  39. { name:'铝格栅板柜体单价', method:'按投影面积', value:'2532', price:'2532', unit:'元/m²', desc:'主卧衣柜铝格栅板投影单价' },
  40. { name:'橱柜3米基础套餐', method:'按套餐', value:'11199', price:'11199', unit:'元/套', desc:'厨房橱柜3延米基础套餐' },
  41. { name:'橱柜超3米延米单价', method:'按延米', value:'3733', price:'3733', unit:'元/m', desc:'厨房橱柜超出3米部分单价' },
  42. ],
  43. extra: [
  44. { name:'见光板', method:'按投影面积', price:'1138', unit:'元/m²', desc:'同色见光板' },
  45. { name:'自弹器', method:'按个数', price:'115', unit:'元/个', desc:'反弹器/自弹器' },
  46. { name:'铰链', method:'按个数', price:'40', unit:'元/个', desc:'阻尼铰链' },
  47. { name:'灯带', method:'按延米', price:'386', unit:'元/m', desc:'LED灯带' },
  48. { name:'变压器+开关', method:'按个数', price:'536', unit:'元/个', desc:'LED电源+感应开关' },
  49. { name:'拉手', method:'按个数', price:'226', unit:'元/个', desc:'普通拉手' },
  50. { name:'金属拉手', method:'按个数', price:'225', unit:'元/个', desc:'金属材质拉手' },
  51. { name:'抽屉', method:'按个数', price:'1300', unit:'元/个', desc:'导轨抽屉' },
  52. { name:'衣杆', method:'按延米', price:'288', unit:'元/m', desc:'挂衣杆' },
  53. { name:'护墙板', method:'按投影面积', price:'1852', unit:'元/m²', desc:'护墙板' },
  54. { name:'裤架领带格', method:'按个数', price:'0', unit:'元/个', desc:'多功能裤架+领带格' },
  55. { name:'拉篮', method:'按个数', price:'0', unit:'元/个', desc:'拉篮' },
  56. { name:'碗篮', method:'按个数', price:'1268', unit:'元/个', desc:'碗篮' },
  57. { name:'锅篮', method:'按个数', price:'1628', unit:'元/个', desc:'锅篮' },
  58. { name:'圆弧', method:'按个数', price:'1560', unit:'元/个', desc:'圆弧工艺' },
  59. { name:'换鞋凳', method:'按个数', price:'0', unit:'元/个', desc:'换鞋凳' },
  60. { name:'玻璃门升级', method:'按投影面积', price:'0', unit:'元/m²', desc:'钢化玻璃门板加价' },
  61. { name:'石材台面', method:'按延米', price:'1480', unit:'元/m', desc:'石英石台面' },
  62. ]
  63. };
  64. // ── 柜体增项模板映射(根据欧阳路Excel整理) ──
  65. // 说明:key为柜体名称关键词,value为该类柜体默认包含的增项列表(不含补差价、石材台面)
  66. const CABINET_EXTRA_TEMPLATES = {
  67. // 玄关柜
  68. '玄关': ['灯带', '变压器+开关', '自弹器', '见光板', '铰链'],
  69. '鞋柜': ['灯带', '变压器+开关', '自弹器', '见光板', '铰链', '换鞋凳'],
  70. // 客厅电视柜
  71. '电视柜': ['自弹器', '见光板', '铰链'],
  72. // 洗衣机柜
  73. '洗衣机': ['自弹器', '护墙板', '见光板', '铰链'],
  74. // 客厅展示柜/酒柜
  75. '展示柜': ['灯带', '变压器+开关', '拉手', '见光板', '铰链', '自弹器'],
  76. '酒柜': ['灯带', '变压器+开关', '拉手', '见光板', '铰链', '自弹器', '玻璃门升级'],
  77. // 阳台柜
  78. '阳台': ['自弹器', '见光板', '铰链'],
  79. // 餐边柜
  80. '餐边': ['抽屉', '拉手', '自弹器', '金属拉手', '灯带', '变压器+开关', '见光板', '铰链'],
  81. // 次卧/衣柜
  82. '次卧': ['衣杆', '金属拉手', '抽屉', '见光板', '铰链'],
  83. '儿童房': ['衣杆', '金属拉手', '抽屉', '见光板', '铰链'],
  84. '书房': ['衣杆', '金属拉手', '抽屉', '见光板', '铰链'],
  85. // 主卧/衣柜
  86. '主卧': ['金属拉手', '抽屉', '衣杆', '自弹器', '圆弧', '铰链'],
  87. '衣帽间': ['金属拉手', '抽屉', '衣杆', '自弹器', '圆弧', '铰链', '裤架领带格', '拉篮'],
  88. // 厨房
  89. '厨房': ['见光板', '碗篮', '锅篮', '自弹器'],
  90. // 默认通用(兜底)
  91. '_default': ['自弹器', '见光板', '铰链']
  92. };
  93. // 欧阳路预估报价 Excel:逐柜体增值项及其对应单价。
  94. // 同名但单价不同的项目保持为独立行,避免使用通用模板时发生串价。
  95. const CABINET_EXCEL_EXTRA_DETAILS = {
  96. '玄关': [
  97. ['板门补差价',538,'按投影面积'],['灯带',386,'按延米'],['变压器+开关',536,'按个数'],['自弹器',115,'按个数'],['见光板',1138,'按投影面积'],['铰链',40,'按个数']
  98. ],
  99. '电视柜': [
  100. ['板门补差价',538,'按投影面积'],['自弹器',115,'按个数'],['见光板',1138,'按投影面积'],['铰链',40,'按个数']
  101. ],
  102. '洗衣机': [
  103. ['板门补差价',538,'按投影面积'],['自弹器',115,'按个数'],['护墙板',1852,'按投影面积'],['见光板',1138,'按投影面积'],['铰链',40,'按个数']
  104. ],
  105. '展示柜': [
  106. ['玻璃门补差价',1852,'按投影面积'],['灯带',386,'按延米'],['变压器+开关',536,'按个数'],['拉手',226,'按个数'],['见光板',1138,'按投影面积'],['铰链',40,'按个数'],['玻璃门补差价',1852,'按投影面积'],['自弹器',115,'按个数']
  107. ],
  108. '阳台': [
  109. ['板门补差价',538,'按投影面积'],['自弹器',115,'按个数'],['见光板',1138,'按投影面积'],['铰链',40,'按个数']
  110. ],
  111. '餐边': [
  112. ['板门补差价',538,'按投影面积'],['板门补差价',538,'按投影面积'],['抽屉',1300,'按个数'],['拉手',226,'按个数'],['自弹器',115,'按个数'],['金属拉手',225,'按延米'],['灯带',386,'按延米'],['变压器+开关',536,'按个数'],['见光板',1138,'按投影面积'],['铰链',40,'按个数']
  113. ],
  114. '次卧': [
  115. ['板门补差价',538,'按投影面积'],['衣杆',288,'按延米'],['金属拉手',680,'按个数'],['抽屉',1300,'按个数'],['金属拉手',225,'按延米'],['见光板',1138,'按投影面积'],['铰链',40,'按个数']
  116. ],
  117. '主卧': [
  118. ['板门补差价',1380,'按投影面积'],['金属拉手',680,'按个数'],['抽屉',1300,'按个数'],['衣杆',288,'按延米'],['自弹器',115,'按个数'],['圆弧',1560,'按延米'],['铰链',40,'按个数']
  119. ],
  120. '衣帽间': [
  121. ['板门补差价',1380,'按投影面积'],['金属拉手',680,'按个数'],['抽屉',1300,'按个数'],['衣杆',288,'按延米'],['自弹器',115,'按个数'],['圆弧',1560,'按延米'],['铰链',40,'按个数']
  122. ],
  123. '厨房': [
  124. ['地柜',2380,'按延米'],['吊柜',1748,'按延米'],['冰箱吊柜',2622,'按延米'],['高柜',5950,'按延米'],['见光板',1138,'按投影面积'],['台面',1480,'按延米'],['台面超出部分',2480,'按延米'],['碗篮',1268,'按个数'],['锅篮',1628,'按个数'],['自弹',115,'按个数'],['切角',500,'按个数'],['台下盆',500,'按个数'],['包管',500,'按个数']
  125. ],
  126. '_default': [['自弹器',115,'按个数'],['见光板',1138,'按投影面积'],['铰链',40,'按个数']]
  127. };
  128. // 根据柜体名称获取对应的增项模板列表
  129. function qGetExtrasForCabinet(cabinetName){
  130. if(!cabinetName) return CABINET_EXCEL_EXTRA_DETAILS._default.map(function(v){return {name:v[0],price:v[1],method:v[2]};});
  131. var name = String(cabinetName);
  132. // 按关键词匹配(优先匹配更长的关键词)
  133. var keys = Object.keys(CABINET_EXCEL_EXTRA_DETAILS).filter(function(k){ return k !== '_default'; });
  134. keys.sort(function(a,b){ return b.length - a.length; });
  135. for(var i=0; i<keys.length; i++){
  136. if(name.indexOf(keys[i]) !== -1){
  137. return CABINET_EXCEL_EXTRA_DETAILS[keys[i]].map(function(v){return {name:v[0],price:v[1],method:v[2]};});
  138. }
  139. }
  140. return CABINET_EXCEL_EXTRA_DETAILS._default.map(function(v){return {name:v[0],price:v[1],method:v[2]};});
  141. }
  142. // 增项名称到计价方式映射
  143. const EXTRA_METHOD_MAP = {
  144. '见光板': '按投影面积',
  145. '护墙板': '按投影面积',
  146. '玻璃门升级': '按投影面积',
  147. '灯带': '按延米',
  148. '衣杆': '按延米',
  149. '自弹器': '按个数',
  150. '铰链': '按个数',
  151. '变压器+开关': '按个数',
  152. '拉手': '按个数',
  153. '金属拉手': '按个数',
  154. '抽屉': '按个数',
  155. '裤架领带格': '按个数',
  156. '拉篮': '按个数',
  157. '碗篮': '按个数',
  158. '锅篮': '按个数',
  159. '圆弧': '按个数',
  160. '换鞋凳': '按个数'
  161. };
  162. // ── 从规则配置中获取指定项目的单价(用户配置的默认值优先) ──
  163. function qGetConfiguredPrice(itemName){
  164. if(!itemName) return 0;
  165. // 柜体单价
  166. if(itemName === '柜体'){
  167. if(DEFAULT_RULES && DEFAULT_RULES.base && DEFAULT_RULES.base[0]){
  168. return parseFloat(DEFAULT_RULES.base[0].price || DEFAULT_RULES.base[0].value) || 0;
  169. }
  170. return 0;
  171. }
  172. // 增项单价
  173. if(DEFAULT_RULES && DEFAULT_RULES.extra){
  174. var found = DEFAULT_RULES.extra.find(function(ex){ return ex.name === itemName; });
  175. if(found){
  176. return parseFloat(found.price || found.value) || 0;
  177. }
  178. }
  179. return 0;
  180. }
  181. // ── 同步配置单价到所有柜体详情表格(只更新未被用户手动修改过的单价) ──
  182. function qSyncConfigPricesToCabinets(){
  183. try {
  184. var rooms = (window._qLastAnalysisData && window._qLastAnalysisData.rooms) ? window._qLastAnalysisData.rooms : qRooms;
  185. if(!rooms || !rooms.length) return;
  186. rooms.forEach(function(r){
  187. if(!r._detailItems) return;
  188. r._detailItems.forEach(function(item){
  189. // 跳过用户手动修改过单价的项目(标记_priceModified=true)
  190. if(item._priceModified) return;
  191. // 从配置获取最新单价
  192. var newPrice = qGetConfiguredPrice(item.name);
  193. if(item.price !== newPrice){
  194. item.price = newPrice;
  195. // 重新计算总价
  196. var areaVal = item.area || item.qty || 0;
  197. item.total = parseFloat(((areaVal||0) * (item.price||0)).toFixed(2));
  198. }
  199. });
  200. });
  201. } catch(e) {
  202. console.warn('qSyncConfigPricesToCabinets error:', e);
  203. }
  204. }
  205. const DEFAULT_SPACES = [
  206. { name:'主卧', length:4.2, width:3.5, height:2.8, cabinetRatio:0.60, fillRate:0.65, doorRatio:0.55, bayWindow:false, irregular:false },
  207. { name:'次卧', length:3.8, width:3.2, height:2.8, cabinetRatio:0.55, fillRate:0.50, doorRatio:0.50, bayWindow:false, irregular:false },
  208. { name:'客厅', length:5.5, width:4.5, height:2.8, cabinetRatio:0.45, fillRate:0.60, doorRatio:0.60, bayWindow:false, irregular:false },
  209. { name:'厨房', length:3.5, width:2.4, height:2.8, cabinetRatio:0.70, fillRate:0.70, doorRatio:0.40, bayWindow:false, irregular:false },
  210. ];
  211. const DEFAULT_EXTRA_ITEMS = [
  212. ];
  213. const DEFAULT_QUOTATION_PLANS = [
  214. {
  215. id: 'plan-economy',
  216. name: '经济型方案',
  217. description: '适合预算敏感型客户,主打性价比',
  218. customerTier: 'price',
  219. tierLevel: 'p1',
  220. floatRatio: 0.10,
  221. discountRate: 0.98,
  222. isDefault: false,
  223. enabled: true,
  224. createdAt: '2026-07-25 10:00',
  225. updatedAt: '2026-07-25 10:00'
  226. },
  227. {
  228. id: 'plan-standard',
  229. name: '标准型方案',
  230. description: '默认方案,标准配置,适合大多数客户',
  231. customerTier: 'standard',
  232. tierLevel: 's1',
  233. floatRatio: 0.15,
  234. discountRate: 1.00,
  235. isDefault: true,
  236. enabled: true,
  237. createdAt: '2026-07-25 10:00',
  238. updatedAt: '2026-07-25 10:00'
  239. },
  240. {
  241. id: 'plan-quality',
  242. name: '品质型方案',
  243. description: '高端配置,适合追求品质的客户',
  244. customerTier: 'quality',
  245. tierLevel: 'q1',
  246. floatRatio: 0.20,
  247. discountRate: 1.00,
  248. isDefault: false,
  249. enabled: true,
  250. createdAt: '2026-07-25 10:00',
  251. updatedAt: '2026-07-25 10:00'
  252. }
  253. ];
  254. let state = {
  255. quotes: [],
  256. quotationPlans: JSON.parse(JSON.stringify(DEFAULT_QUOTATION_PLANS)),
  257. activePlanId: null,
  258. currentQuoteId: null,
  259. currentStep: 1,
  260. currentView: 'quotes',
  261. activeRuleTab: 'wardrobe',
  262. editingRule: null,
  263. globalFloatRatio: 0.15,
  264. currentFloatRatio: 0.15,
  265. // 规则模块一:厨房尺寸扣减参数(总长度统一扣减)
  266. ruleKitchenOverlapCount: 2,
  267. ruleKitchenOverlapMm: 600,
  268. ruleKitchenFridgeMm: 1000,
  269. defaultPricingMode: 'package',
  270. defaultFixedUnitPrice: 0,
  271. allowModeSwitch: true,
  272. fixedPriceNote: '',
  273. includeExtrasInFixed: true,
  274. currentPricingMode: 'package',
  275. currentFixedUnitPrice: 0,
  276. rules: { base: [], extra: [] },
  277. spaces: [],
  278. extraItems: [],
  279. uploads: [],
  280. pricingResult: null,
  281. templates: [
  282. { id:1, name:'公司标准报价模板 V2', type:'Excel', size:'245KB', isDefault:true, uploadedAt:'2026-06-20' },
  283. { id:2, name:'简约报价模板', type:'Excel', size:'180KB', isDefault:false, uploadedAt:'2026-06-20' },
  284. { id:3, name:'详细报价模板', type:'PDF', size:'320KB', isDefault:false, uploadedAt:'2026-07-01' },
  285. ]
  286. };
  287. // ====================== PRICING ENGINE ======================
  288. const PricingEngine = {
  289. getFillRateCoefficient(fillRate) {
  290. return 1.0;
  291. },
  292. calcProjectionArea(space) {
  293. const lengthM = space.length || 0;
  294. const heightM = space.height || 2.8;
  295. return parseFloat((lengthM * heightM).toFixed(3));
  296. },
  297. calcSpacePrice(space, extraItems, rules) {
  298. const projArea = this.calcProjectionArea(space);
  299. const area = parseFloat(((space.length * space.width)).toFixed(2));
  300. function getRulePrice(name, defVal) {
  301. defVal = defVal || 0;
  302. var all = (rules.base||[]).concat(rules.extra||[]);
  303. for (var i = 0; i < all.length; i++) {
  304. if (all[i].name === name) {
  305. var v = parseFloat(all[i].value !== undefined ? all[i].value : all[i].price);
  306. return isNaN(v) ? defVal : v;
  307. }
  308. }
  309. return defVal;
  310. }
  311. const cabinetUnitPrice = getRulePrice('柜体投影单价', 0);
  312. const doorUnitPrice = getRulePrice('标配门板单价', 380);
  313. const visiblePanelPrice = getRulePrice('见光板', 350);
  314. const cabinetPrice = parseFloat((projArea * cabinetUnitPrice).toFixed(2));
  315. const doorPrice = parseFloat((projArea * doorUnitPrice).toFixed(2));
  316. const spaceExtras = extraItems.filter(e => e.spaceIdx !== undefined && e.spaceIdx === space._idx);
  317. const extraTotal = spaceExtras.reduce((sum, e) => sum + (e.count * e.unitPrice), 0);
  318. const spaceTotal = parseFloat((cabinetPrice + doorPrice + extraTotal).toFixed(2));
  319. return {
  320. name: space.name,
  321. length: space.length,
  322. width: space.width,
  323. height: space.height,
  324. area,
  325. projectArea: projArea,
  326. expandArea: projArea,
  327. cabinetPrice,
  328. doorPrice,
  329. extraItems: spaceExtras,
  330. extraTotal,
  331. spaceTotal
  332. };
  333. },
  334. calculate(spaces, extraItems, floatRatio) {
  335. floatRatio = floatRatio || 0.15;
  336. const spaceDetails = spaces.map((s, idx) => {
  337. const sp = {...s, _idx: idx};
  338. return this.calcSpacePrice(sp, extraItems, DEFAULT_RULES);
  339. });
  340. const grandTotal = parseFloat(spaceDetails.reduce((sum, s) => sum + s.spaceTotal, 0).toFixed(2));
  341. const floorPrice = parseFloat((grandTotal * (1 - floatRatio)).toFixed(2));
  342. const ceilingPrice = parseFloat((grandTotal * (1 + floatRatio)).toFixed(2));
  343. return {
  344. spaces: spaceDetails,
  345. grandTotal,
  346. floorPrice,
  347. ceilingPrice,
  348. floatRatio
  349. };
  350. }
  351. };
  352. // ====================== RENDER FUNCTIONS ======================
  353. function renderQuoteList() {
  354. const tbody = document.getElementById('quote-list-body');
  355. const empty = document.getElementById('quote-empty');
  356. // 报价模式隔离:列表仅展示当前模式的报价单
  357. const curMode = state.quoteMode || 'ai';
  358. const tabAi = document.getElementById('mode-tab-ai');
  359. const tabManual = document.getElementById('mode-tab-manual');
  360. if (tabAi) { tabAi.style.background = curMode === 'ai' ? 'var(--primary)' : '#fff'; tabAi.style.color = curMode === 'ai' ? '#fff' : 'var(--primary)'; }
  361. if (tabManual) { tabManual.style.background = curMode === 'manual' ? 'var(--primary)' : '#fff'; tabManual.style.color = curMode === 'manual' ? '#fff' : 'var(--primary)'; }
  362. const modeQuotes = state.quotes.filter(q => (q.quoteMode || 'ai') === curMode);
  363. if (modeQuotes.length === 0) {
  364. tbody.innerHTML = '';
  365. empty.style.display = 'block';
  366. updateGlobalEstimate(0, state.globalFloatRatio, 0);
  367. if(typeof syncBatchDeleteUI==='function') syncBatchDeleteUI();
  368. return;
  369. }
  370. empty.style.display = 'none';
  371. // Aggregate all quotes for the global estimate card
  372. let totalGrand = 0;
  373. state.quotes.forEach(q => { if (q.grandTotal) totalGrand += q.grandTotal; });
  374. updateGlobalEstimate(totalGrand, state.globalFloatRatio, state.quotes.filter(q => q.grandTotal).length);
  375. tbody.innerHTML = modeQuotes.map(q => {
  376. const statusMap = {0:'草稿',1:'已确认',2:'已导出',3:'已归档'};
  377. const statusColor = {0:'#DD6B20',1:'#3182CE',2:'#38A169',3:'#A0AEC0'};
  378. const fr = q.floatRatio != null ? q.floatRatio : state.globalFloatRatio;
  379. const floor = q.grandTotal ? (q.grandTotal * (1 - fr)).toFixed(0) : null;
  380. const ceil = q.grandTotal ? (q.grandTotal * (1 + fr)).toFixed(0) : null;
  381. var isDraft = q.status === 0 || q.grandTotal == null;
  382. var actionBtns = '';
  383. if (isDraft) {
  384. actionBtns = '<button class="btn btn-primary btn-sm" onclick="event.stopPropagation();resumeDraftQuote(\''+q.id+'\')" style="white-space:nowrap;">▶ 继续编辑</button>'+
  385. '<button class="btn btn-danger btn-sm" onclick="event.stopPropagation();deleteQuote(\''+q.id+'\')" style="margin-left:4px;">删除</button>';
  386. } else {
  387. actionBtns = '<button class="btn btn-outline btn-sm" onclick="event.stopPropagation();editQuote(\''+q.id+'\')">查看</button>'+
  388. '<button class="btn btn-danger btn-sm" onclick="event.stopPropagation();deleteQuote(\''+q.id+'\')" style="margin-left:4px;">删除</button>';
  389. }
  390. return `
  391. <tr onclick="editQuote('${q.id}')" title="${isDraft?'点击继续编辑':'点击查看详情'}" style="${isDraft?'background:#FFFAF0;':''}">
  392. <td onclick="event.stopPropagation()" style="text-align:center;"><input type="checkbox" class="quote-select-cb" value="${q.id}" onchange="syncBatchDeleteUI()"></td>
  393. <td class="text-mono">${q.quoteNo}${isDraft?' <span style="font-size:10px;background:#FEEBC8;color:#C05621;padding:1px 6px;border-radius:8px;margin-left:4px;">草稿</span>':''}</td>
  394. <td>${q.houseType || '-'}</td>
  395. <td class="text-right text-mono">${q.grandTotal ? '¥'+q.grandTotal.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2}) : '<span style="color:#DD6B20;">未完成</span>'}</td>
  396. <td class="price-range-cell">
  397. ${q.grandTotal
  398. ? `<span class="min">¥${Number(floor).toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2})}</span><span class="sep">~</span><span class="max">¥${Number(ceil).toLocaleString()}</span>`
  399. : '<span style="color:var(--text-muted);">—</span>'}
  400. </td>
  401. <td class="text-center"><span class="float-badge">±${(fr*100).toFixed(1)}%</span></td>
  402. <td><span style="color:${statusColor[q.status]||statusColor[0]};font-weight:600;">${isDraft?'草稿':statusMap[q.status]}</span></td>
  403. <td>${q.createdAt}</td>
  404. <td style="white-space:nowrap;">${actionBtns}</td>
  405. </tr>`;
  406. }).join('');
  407. if(typeof syncBatchDeleteUI==='function') syncBatchDeleteUI();
  408. }
  409. function filterQuoteList(keyword) {
  410. const tbody = document.getElementById('quote-list-body');
  411. const rows = tbody.querySelectorAll('tr');
  412. rows.forEach(row => {
  413. const text = row.textContent.toLowerCase();
  414. row.style.display = text.includes(keyword.toLowerCase()) ? '' : 'none';
  415. });
  416. }
  417. function filterQuoteListByStatus(status) {
  418. const tbody = document.getElementById('quote-list-body');
  419. const rows = tbody.querySelectorAll('tr');
  420. rows.forEach(row => {
  421. if (!status) { row.style.display = ''; return; }
  422. const cells = row.querySelectorAll('td');
  423. const statusCell = cells[6];
  424. const statusMap = {'0':'草稿','1':'已确认','2':'已导出','3':'已归档'};
  425. row.style.display = statusCell?.textContent.trim() === statusMap[status] ? '' : 'none';
  426. });
  427. }
  428. function renderSpaceTable() {
  429. const tbody = document.getElementById('space-table-body');
  430. tbody.innerHTML = state.spaces.map((s, i) => `
  431. <tr>
  432. <td><input type="text" value="${s.name}" onchange="updateSpace(${i},'name',this.value)"></td>
  433. <td><input type="number" value="${s.length}" onchange="updateSpace(${i},'length',parseFloat(this.value))" min="500"></td>
  434. <td><input type="number" value="${s.width}" onchange="updateSpace(${i},'width',parseFloat(this.value))" min="500"></td>
  435. <td><input type="number" value="${s.height}" onchange="updateSpace(${i},'height',parseFloat(this.value))" min="2000"></td>
  436. <td><input type="number" value="${s.cabinetRatio}" onchange="updateSpace(${i},'cabinetRatio',parseFloat(this.value))" step="0.05" min="0" max="1"></td>
  437. <td><input type="number" value="${s.fillRate}" onchange="updateSpace(${i},'fillRate',parseFloat(this.value))" step="0.05" min="0" max="1"></td>
  438. <td><input type="number" value="${s.doorRatio}" onchange="updateSpace(${i},'doorRatio',parseFloat(this.value))" step="0.05" min="0" max="1"></td>
  439. <td class="text-center"><input type="checkbox" ${s.bayWindow?'checked':''} onchange="updateSpace(${i},'bayWindow',this.checked)"></td>
  440. <td class="text-center"><input type="checkbox" ${s.irregular?'checked':''} onchange="updateSpace(${i},'irregular',this.checked)"></td>
  441. <td><button class="btn btn-danger btn-sm" onclick="removeSpace(${i})">✕</button></td>
  442. </tr>
  443. `).join('');
  444. }
  445. function renderPricingResult() {
  446. if (!state.pricingResult) return;
  447. const result = state.pricingResult;
  448. // Sync float ratio UI
  449. syncStep3FloatUI(result.floatRatio);
  450. document.getElementById('grand-total-display').textContent = `¥${result.grandTotal.toLocaleString('zh-CN',{minimumFractionDigits:2})}`;
  451. document.getElementById('floor-price-display').textContent = `¥${result.floorPrice.toLocaleString('zh-CN',{minimumFractionDigits:2})}`;
  452. document.getElementById('ceiling-price-display').textContent = `¥${result.ceilingPrice.toLocaleString('zh-CN',{minimumFractionDigits:2})}`;
  453. // Space breakdown
  454. let breakdownHTML = '';
  455. result.spaces.forEach(s => {
  456. breakdownHTML += `
  457. <div class="space-card">
  458. <div class="space-header">
  459. <span>🏠 ${s.name}</span>
  460. <span>小计: <strong>¥${s.spaceTotal.toLocaleString('zh-CN',{minimumFractionDigits:2})}</strong></span>
  461. </div>
  462. <div class="space-details">
  463. <div class="item"><span>平面面积</span><span class="val">${s.area} m²</span></div>
  464. <div class="item"><span>展开面积</span><span class="val">${s.expandArea} m²</span></div>
  465. <div class="item"><span>柜体占比</span><span class="val">${(s.cabinetRatio*100).toFixed(0)}%</span></div>
  466. <div class="item"><span>填充率</span><span class="val">${(s.fillRate*100).toFixed(0)}% (系数${s.fillCoefficient})</span></div>
  467. <div class="item"><span>柜体基础价</span><span class="val">¥${s.cabinetPrice.toLocaleString('zh-CN',{minimumFractionDigits:2})}</span></div>
  468. <div class="item"><span>门板补差</span><span class="val">¥${s.doorPanelDiff.toLocaleString('zh-CN',{minimumFractionDigits:2})}</span></div>
  469. <div class="item"><span>非套餐增项</span><span class="val">¥${s.extraTotal.toLocaleString('zh-CN',{minimumFractionDigits:2})}</span></div>
  470. ${s.nonStandard > 0 ? `<div class="item"><span>非标加价(${(s.nonStandardRatio*100).toFixed(0)}%)</span><span class="val" style="color:var(--danger);">¥${s.nonStandard.toLocaleString('zh-CN',{minimumFractionDigits:2})}</span></div>` : ''}
  471. </div>
  472. ${s.extraItems.length > 0 ? `
  473. <div style="margin-top:8px;padding-top:8px;border-top:1px dotted var(--border);font-size:12px;color:var(--text-muted);">
  474. 增项明细: ${s.extraItems.map(e => `${e.name} ×${e.count} @¥${e.unitPrice}`).join(' / ')}
  475. </div>` : ''}
  476. </div>`;
  477. });
  478. document.getElementById('space-breakdown').innerHTML = breakdownHTML;
  479. // Summary
  480. const summaryItems = [
  481. { label:'柜体基础价合计', value: result.spaces.reduce((s, sp) => s + sp.cabinetPrice, 0) },
  482. { label:'门板补差合计', value: result.spaces.reduce((s, sp) => s + sp.doorPanelDiff, 0) },
  483. { label:'非套餐增项合计', value: result.spaces.reduce((s, sp) => s + sp.extraTotal, 0) },
  484. { label:'非标加价合计', value: result.spaces.reduce((s, sp) => s + sp.nonStandard, 0) },
  485. { label:'方案总价', value: result.grandTotal, highlight:true },
  486. { label:'底价 (-15%)', value: result.floorPrice, highlight:true, color:'var(--success)' },
  487. { label:'顶价 (+15%)', value: result.ceilingPrice, highlight:true, color:'var(--danger)' },
  488. ];
  489. document.getElementById('summary-table-body').innerHTML = summaryItems.map(item => `
  490. <tr style="${item.highlight?'font-weight:700;':''}${item.color?`color:${item.color};`:''}">
  491. <td>${item.label}</td>
  492. <td class="text-right text-mono">¥${item.value.toLocaleString('zh-CN',{minimumFractionDigits:2})}</td>
  493. </tr>
  494. `).join('');
  495. }
  496. function renderExportPreview() {
  497. if (!state.pricingResult) return;
  498. const r = state.pricingResult;
  499. const q = state.quotes.find(q => q.id === state.currentQuoteId);
  500. if (!q) return;
  501. const spaceRows = r.spaces.map(s => `
  502. <tr>
  503. <td>${s.name}</td>
  504. <td class="text-right">${s.area}</td>
  505. <td class="text-right">${s.expandArea}</td>
  506. <td class="text-right">¥${s.cabinetPrice.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2})}</td>
  507. <td class="text-right">¥${s.extraTotal.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2})}</td>
  508. <td class="text-right"><strong>¥${s.spaceTotal.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2})}</strong></td>
  509. </tr>
  510. `).join('');
  511. document.getElementById('export-preview').innerHTML = `
  512. <div style="text-align:center;font-size:16px;font-weight:700;margin-bottom:12px;">XX装饰工程有限公司 — 报价单</div>
  513. <div style="display:flex;justify-content:space-between;margin-bottom:8px;">
  514. <span>报价单号: ${q.quoteNo}</span><span>日期: ${new Date().toISOString().slice(0,10)}</span>
  515. </div>
  516. <div style="margin-bottom:8px;">户型: ${q.houseType || '-'}</div>
  517. <table style="font-size:12px;">
  518. <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>
  519. <tbody>${spaceRows}</tbody>
  520. <tfoot>
  521. <tr style="font-weight:700;background:#F7FAFC;">
  522. <td colspan="5">合计</td>
  523. <td class="text-right">¥${r.grandTotal.toLocaleString('zh-CN',{minimumFractionDigits:2})}</td>
  524. </tr>
  525. <tr><td colspan="6" style="color:var(--text-muted);text-align:center;">底价区间: ¥${r.floorPrice.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2})} ~ ¥${r.ceilingPrice.toLocaleString()} (±${(r.floatRatio*100).toFixed(0)}%) | 备注: 本报价不含运输安装费,有效期为30天</td></tr>
  526. </tfoot>
  527. </table>`;
  528. }
  529. function isEditing(type, idx) {
  530. return state.editingRule && state.editingRule.type === type && state.editingRule.index === idx;
  531. }
  532. // 计价方式选项及对应默认单位映射
  533. const Q_METHOD_OPTIONS = [
  534. { value:'按投影面积', unit:'元/m²', label:'按投影面积(m²)' },
  535. { value:'按个数', unit:'元/个', label:'按个数(个)' },
  536. { value:'按延米', unit:'元/m', label:'按延米(m)' }
  537. ];
  538. // 根据计价方式获取对应默认单位
  539. function qGetUnitByMethod(method) {
  540. for (var i = 0; i < Q_METHOD_OPTIONS.length; i++) {
  541. if (Q_METHOD_OPTIONS[i].value === method) return Q_METHOD_OPTIONS[i].unit;
  542. }
  543. return '元/个';
  544. }
  545. // 计价方式下拉变化时自动联动更新单位(独立函数,避免内联引号问题)
  546. function qOnMethodChange(sel) {
  547. var method = sel.value;
  548. var unit = qGetUnitByMethod(method);
  549. // 找到同行的单位输入框(tr → 所有td → 第4个td(索引3)→ 其中的input)
  550. var tr = sel.closest('tr');
  551. if (!tr) return;
  552. var tds = tr.children;
  553. // 列顺序:0名称, 1计价方式, 2单价, 3单位, 4说明, 5操作
  554. var unitTd = tds[3];
  555. if (!unitTd) return;
  556. var unitInput = unitTd.querySelector('input');
  557. if (unitInput) {
  558. unitInput.value = unit;
  559. }
  560. }
  561. // 计价方式下拉选择单元格
  562. function methodSelectCell(currentMethod, type, idx) {
  563. var opts = Q_METHOD_OPTIONS.map(function(opt){
  564. return '<option value="'+opt.value+'"'+(opt.value===currentMethod?' selected':'')+'>'+opt.label+'</option>';
  565. }).join('');
  566. return '<select onchange="qOnMethodChange(this)" class="method-sel" style="width:100%;padding:4px 6px;border:1px solid var(--border);border-radius:4px;font-size:12px;">'+opts+'</select>';
  567. }
  568. function editCell(value, cls) {
  569. return `<input type="text" value="${String(value).replace(/"/g,'&quot;')}" class="inline-edit ${cls||''}" style="width:100%;">`;
  570. }
  571. function editBtnRow(type, idx) {
  572. return `
  573. <button class="btn btn-success btn-sm" onclick="saveRuleEdit('${type}',${idx})">💾 保存</button>
  574. <button class="btn btn-outline btn-sm" onclick="cancelRuleEdit()">取消</button>`;
  575. }
  576. // 橱柜门板配置价格(图片中吊柜/地柜对应的统一零售价)。
  577. // 规则配置与柜体产品覆盖信息共用这一份数据,避免两处价格不一致。
  578. const Q_KITCHEN_DOOR_CONFIGS = [
  579. {id:'open',type:'1-开放',frame:'开放柜',panel:'无门',wall:1748,base:2380},
  580. {id:'decor',type:'1-开放',frame:'装饰柜',panel:'无门',wall:868,base:null},
  581. {id:'flat18',type:'2-双饰面板门',frame:'双饰面-平板门',panel:'18MM',wall:1748,base:2380},
  582. {id:'flat25',type:'2-双饰面板门',frame:'双饰面-平板门',panel:'25MM',wall:1918,base:2550},
  583. {id:'flatstrip18',type:'2-双饰面板门',frame:'双饰面+轻奢卡条',panel:'18MM',wall:2216,base:2848},
  584. {id:'flatstrip25',type:'2-双饰面板门',frame:'双饰面+轻奢卡条',panel:'25MM',wall:2386,base:3018},
  585. {id:'crystal18',type:'3-水晶板门',frame:'水晶板门',panel:'18MM',wall:2120,base:2960},
  586. {id:'crystal25',type:'3-水晶板门',frame:'水晶板门',panel:'25MM',wall:2290,base:3130},
  587. {id:'crystalstrip18',type:'3-水晶板门',frame:'水晶板门+轻奢卡条',panel:'18MM',wall:2590,base:3430},
  588. {id:'crystalstrip25',type:'3-水晶板门',frame:'水晶板门+轻奢卡条',panel:'25MM',wall:2758,base:3598},
  589. {id:'km2611',type:'4-铝框门',frame:'KM2611门板',panel:'生态板 / PET水晶板',wall:2488,base:3360},
  590. {id:'aluglass',type:'4-铝框门',frame:'铝框玻璃门(有缝)',panel:'灰玻/茶玻/黑玻/欧洲茶玻',wall:2488,base:3248}
  591. ];
  592. function qKitchenDoorKind(name){return name==='吊柜'?'wall':(name==='地柜'?'base':'');}
  593. function qKitchenDoorLabel(c,kind){var size=kind==='wall'?'吊柜:150≤H≤1000MM,D≤400MM':'地柜:150≤H≤1000MM,D大于400MM且D≤600MM';return size+' | 类型:'+c.type+' | 门板配置:'+c.frame+' | 厚度/门芯颜色:'+c.panel;}
  594. function qKitchenDoorMaterialLabel(c){return '类型:'+c.type+' | 门板配置:'+c.frame+' | 厚度/门芯颜色:'+c.panel;}
  595. function qKitchenDoorOptionsHtml(kind,selected){return Q_KITCHEN_DOOR_CONFIGS.filter(function(c){return c[kind]!=null;}).map(function(c){return '<option value="'+c.id+'" '+(c.id===selected?'selected':'')+'>'+qKitchenDoorMaterialLabel(c)+'</option>';}).join('');}
  596. function qKitchenDoorPriceOptionsHtml(kind,selected){return Q_KITCHEN_DOOR_CONFIGS.filter(function(c){return c[kind]!=null;}).map(function(c){return '<option value="'+c.id+'" '+(c.id===selected?'selected':'')+'>¥'+c[kind]+'</option>';}).join('');}
  597. function qKitchenDoorFind(id){return Q_KITCHEN_DOOR_CONFIGS.find(function(c){return c.id===id;});}
  598. function qKitchenRuleSelection(rule){var kind=qKitchenDoorKind(rule.name),found=qKitchenDoorFind(rule.doorConfigId);if(found&&found[kind]!=null)return found;found=Q_KITCHEN_DOOR_CONFIGS.find(function(c){return c[kind]!=null&&Number(c[kind])===Number(rule.value);});return found||Q_KITCHEN_DOOR_CONFIGS[0];}
  599. function qKitchenDoorRuleChange(index,select){var rule=DEFAULT_RULES.kitchen[index],kind=qKitchenDoorKind(rule&&rule.name),cfg=qKitchenDoorFind(select.value);if(!rule||!kind||!cfg)return;rule.doorConfigId=cfg.id;rule.value=String(cfg[kind]);rule.desc=qKitchenDoorMaterialLabel(cfg);persistState();renderRules();qRefreshAfterRuleChange();showToast('已更新'+rule.name+'门板配置与单价','success');}
  600. function renderRules() {
  601. try {
  602. const ed = state.editingRule;
  603. ['wardrobe','kitchen'].forEach(function(group){
  604. var table=document.getElementById('rule-table-'+group);
  605. if(!table)return;
  606. table.innerHTML=(DEFAULT_RULES[group]||[]).map(function(r,i){
  607. var shownName=r.displayName||r.name;
  608. var kitchenKind=group==='kitchen'?qKitchenDoorKind(r.name):'';
  609. if(kitchenKind){
  610. var cfg=qKitchenRuleSelection(r);r.doorConfigId=cfg.id;r.value=String(cfg[kitchenKind]);r.desc=qKitchenDoorMaterialLabel(cfg);
  611. return '<tr><td style="font-weight:600;">'+shownName+'</td><td>'+r.method+'</td><td><select style="width:100%;min-width:180px;padding:7px;border:1px solid #90CDF4;border-radius:6px;color:#2563A6;font-weight:700;" onchange="qKitchenDoorRuleChange('+i+',this)">'+qKitchenDoorPriceOptionsHtml(kitchenKind,r.doorConfigId)+'</select></td><td>'+r.unit+'</td><td><select style="width:100%;min-width:360px;padding:7px;border:1px solid #90CDF4;border-radius:6px;" onchange="qKitchenDoorRuleChange('+i+',this)">'+qKitchenDoorOptionsHtml(kitchenKind,r.doorConfigId)+'</select></td><td><button class="btn btn-outline btn-sm" onclick="startRuleEdit(\''+group+'\','+i+')">编辑</button> <button class="btn btn-danger btn-sm" onclick="deleteRule(\''+group+'\','+i+')">删除</button></td></tr>';
  612. }
  613. if(ed && ed.type===group && ed.index===i){
  614. return '<tr style="background:#FFFFF0;">'+
  615. '<td>'+editCell(shownName)+'</td><td>'+methodSelectCell(r.method,group,i)+'</td><td>'+editCell(r.value,'text-mono')+'</td><td>'+editCell(r.unit)+'</td><td>'+editCell(r.desc)+'</td>'+
  616. '<td>'+editBtnRow(group,i)+' <button class="btn btn-danger btn-sm" onclick="deleteRule(\''+group+'\','+i+')">🗑 删除</button></td></tr>';
  617. }
  618. return '<tr><td style="font-weight:600;">'+shownName+'</td><td>'+r.method+'</td><td class="text-mono">'+r.value+'</td><td>'+r.unit+'</td><td>'+r.desc+'</td><td><button class="btn btn-outline btn-sm" onclick="startRuleEdit(\''+group+'\','+i+')">编辑</button> <button class="btn btn-danger btn-sm" onclick="deleteRule(\''+group+'\','+i+')">🗑 删除</button></td></tr>';
  619. }).join('');
  620. });
  621. // Base rules - can edit, add, delete
  622. var baseTable = document.getElementById('rule-table-base');
  623. if (baseTable) {
  624. baseTable.innerHTML = DEFAULT_RULES.base.map((r,i) => {
  625. if (ed && ed.type==='base' && ed.index===i) {
  626. return `<tr style="background:#FFFFF0;">
  627. <td>${editCell(r.name)}</td><td>${methodSelectCell(r.method,'base',i)}</td><td>${editCell(r.value,'text-mono')}</td><td>${editCell(r.unit)}</td>
  628. <td>${editCell(r.desc)}</td>
  629. <td>${editBtnRow('base',i)} <button class="btn btn-danger btn-sm" onclick="deleteRule('base',${i})">🗑 删除</button></td></tr>`;
  630. }
  631. return `<tr>
  632. <td>${r.name}</td><td>${r.method}</td><td class="text-mono">${r.value}</td><td>${r.unit}</td>
  633. <td>${r.desc}</td>
  634. <td>
  635. <button class="btn btn-outline btn-sm" onclick="startRuleEdit('base',${i})">编辑</button>
  636. <button class="btn btn-danger btn-sm" onclick="deleteRule('base',${i})">🗑 删除</button>
  637. </td></tr>`;
  638. }).join('');
  639. }
  640. // Extra rules - can edit, add, delete
  641. var extraTable = document.getElementById('rule-table-extra');
  642. if (extraTable) {
  643. extraTable.innerHTML = DEFAULT_RULES.extra.map((r,i) => {
  644. if (ed && ed.type==='extra' && ed.index===i) {
  645. return `<tr style="background:#FFFFF0;">
  646. <td>${editCell(r.name)}</td><td>${methodSelectCell(r.method,'extra',i)}</td><td>${editCell(r.price,'text-mono')}</td><td>${editCell(r.unit)}</td>
  647. <td>${editCell(r.desc)}</td>
  648. <td>${editBtnRow('extra',i)} <button class="btn btn-danger btn-sm" onclick="deleteRule('extra',${i})">🗑 删除</button></td></tr>`;
  649. }
  650. return `<tr>
  651. <td>${r.name}</td><td>${r.method}</td><td class="text-mono">${r.price}</td><td>${r.unit}</td>
  652. <td>${r.desc}</td>
  653. <td>
  654. <button class="btn btn-outline btn-sm" onclick="startRuleEdit('extra',${i})">编辑</button>
  655. <button class="btn btn-danger btn-sm" onclick="deleteRule('extra',${i})">🗑 删除</button>
  656. </td></tr>`;
  657. }).join('');
  658. }
  659. } catch(e) {
  660. console.error('renderRules error:', e);
  661. }
  662. }
  663. function renderTemplates() {
  664. document.getElementById('template-table-body').innerHTML = state.templates.map(t => `
  665. <tr>
  666. <td>${t.name}</td><td>${t.type}</td><td>${t.size}</td>
  667. <td>${t.isDefault?'<span style="color:var(--accent);font-weight:600;">⭐ 默认</span>':'<button class="btn btn-sm btn-outline" onclick="setDefaultTemplate(${t.id})">设为默认</button>'}</td>
  668. <td>${t.uploadedAt}</td>
  669. <td>
  670. <button class="btn btn-outline btn-sm" onclick="downloadTemplate(${t.id})">下载</button>
  671. <button class="btn btn-danger btn-sm" onclick="deleteTemplate(${t.id})">删除</button>
  672. </td>
  673. </tr>
  674. `).join('');
  675. }
  676. // ====================== NAVIGATION ======================
  677. function switchView(view) {
  678. // 离开编辑器时始终记录所在步骤与滚动位置,确保从规则配置/模板管理/报价管理都能原路返回。
  679. if (state.currentView === 'editor' && view !== 'editor') {
  680. state._editorReturnContext = {
  681. quoteId: state.currentQuoteId || null,
  682. step: Number(state.currentStep) || 1,
  683. scrollY: window.scrollY || 0
  684. };
  685. }
  686. // 进入编辑器后清除返回上下文,避免残留旧返回点
  687. if (view === 'editor') state._editorReturnContext = null;
  688. // 记录进入前的来源视图和步骤,用于"返回"按钮
  689. if (view !== state.currentView) {
  690. state._prevView = state.currentView;
  691. state._prevStep = state.currentStep;
  692. }
  693. // 模式二返回键:离开工作页(编辑/AI解析/自动计价)时记录返回点,供顶部返回键原路恢复
  694. if (state.quoteMode === 'manual' && view !== state.currentView && (state.currentView === 'editor' || state.currentView === 'analysis' || state.currentView === 'pricing')) {
  695. state._manualReturnContext = { view: state.currentView, step: Number(state.currentStep) || 1 };
  696. }
  697. // 离开编辑器时自动保存,但保留编辑状态
  698. if (state.currentView === 'editor' && view !== 'editor' && state.currentQuoteId) {
  699. try { saveCurrentQuote(); } catch(e) { console.warn('Auto-save on view switch failed:', e); }
  700. try { persistState(); } catch(e) { console.warn('Persist on view switch failed:', e); }
  701. }
  702. state.currentView = view;
  703. ['quotes','editor','rules','templates','analysis','pricing'].forEach(v => {
  704. const el = document.getElementById(`view-${v}`);
  705. if (el) el.classList.toggle('hidden', v !== view);
  706. });
  707. document.querySelectorAll('.header-nav button').forEach(b => b.classList.remove('active'));
  708. const navBtn = document.getElementById(`nav-${view}`);
  709. if (navBtn) navBtn.classList.add('active');
  710. // 模式二(手动拖拽精准报价)主场景记忆:从规则/模板返回时回到最后一次所在的主页面
  711. if (state.quoteMode === 'manual' && (view === 'analysis' || view === 'pricing')) state._manualMainView = view;
  712. // 更新返回编辑按钮显示
  713. updateBackToEditorButton();
  714. // 更新页面内返回按钮
  715. updatePageBackButton();
  716. if (view === 'quotes') renderQuoteList();
  717. if (view === 'rules') { renderRules(); if (typeof renderModuleCards === 'function') renderModuleCards(); if (typeof qRenderRuleModule1 === 'function') qRenderRuleModule1(); }
  718. if (view === 'templates') renderTemplates();
  719. if (view === 'analysis') {
  720. // 将第二步解析卡片挂载到独立模块窗口,可点击编辑
  721. var hostA = document.getElementById('view-analysis-host'), s2 = document.getElementById('step-2');
  722. if (hostA && s2) { hostA.appendChild(s2); s2.classList.remove('hidden'); }
  723. if (typeof qRenderStep2 === 'function') qRenderStep2();
  724. // 手动拖拽精准报价:同步原户型图并绑定家具拖放、尺寸线按钮事件(幂等,手动模式直接进入时也生效)
  725. if (typeof qSyncOriginalDrawingToLayout === 'function') qSyncOriginalDrawingToLayout();
  726. if (typeof qFurnitureV3Init === 'function') qFurnitureV3Init();
  727. // 模式二(手动拖拽)的上传入口已在解析页左上角,隐藏「返回上一步:上传资料」;模式一保持显示
  728. var backUploadBtn=document.getElementById('q-back-to-upload');
  729. if(backUploadBtn) backUploadBtn.style.display = (state.quoteMode==='manual') ? 'none' : '';
  730. }
  731. if (view === 'pricing') {
  732. // 将第三步计价卡片挂载到独立模块窗口,可点击编辑
  733. var hostP = document.getElementById('view-pricing-host'), s3 = document.getElementById('step-3');
  734. if (hostP && s3) { hostP.appendChild(s3); s3.classList.remove('hidden'); }
  735. if (typeof qSyncStep2DetailsToPricing === 'function') qSyncStep2DetailsToPricing();
  736. if (typeof qRenderStep4 === 'function') qRenderStep4();
  737. }
  738. }
  739. // 从模块窗口(AI解析/自动计价)打开编辑器指定步骤
  740. function qOpenEditorStep(step){ switchView('editor'); goToStep(step); }
  741. // 报价单(步骤4)「上一步」:第二套报价模式返回自动计价页面,第一套报价模式保持返回上传资料
  742. function qQuoteBackPrev(){
  743. if (state.quoteMode === 'manual') {
  744. goToStep(3);
  745. switchView('pricing');
  746. } else {
  747. goToStep(1);
  748. }
  749. }
  750. // ── 报价模式选择前置弹窗:两种模式数据与计算逻辑隔离,切换互不干扰 ──
  751. function qShowModeSelect(){ var ov=document.getElementById('mode-select-overlay'); if(ov) ov.style.display='flex'; }
  752. function qHideModeSelect(){ var ov=document.getElementById('mode-select-overlay'); if(ov) ov.style.display='none'; }
  753. function qSelectQuoteMode(mode){
  754. state.quoteMode = mode;
  755. qHideModeSelect();
  756. renderQuoteList();
  757. createNewQuote();
  758. if (mode === 'manual') {
  759. // 选项2:直接进入AI解析手动拖拽布局页面
  760. switchView('analysis');
  761. }
  762. // 模式选择入口不携带返回点,清除途中(如 createNewQuote 经过编辑器)产生的记录
  763. state._manualReturnContext = null;
  764. updateManualBackButton();
  765. }
  766. function qEnterQuoteListOnly(){
  767. if(!state.quoteMode) state.quoteMode = 'ai';
  768. qHideModeSelect();
  769. switchView('quotes');
  770. renderQuoteList();
  771. }
  772. function qSwitchListMode(mode){
  773. state.quoteMode = mode;
  774. state._manualReturnContext = null;
  775. renderQuoteList();
  776. updateManualBackButton();
  777. }
  778. function qNewQuoteClick(){
  779. // 新建报价单必须先选择报价模式
  780. qShowModeSelect();
  781. }
  782. // 返回上一个页面(智能返回:优先返回编辑中的报价单,否则返回报价管理)
  783. function goBackFromRules() {
  784. var returnContext = state._editorReturnContext;
  785. if (returnContext) {
  786. var targetStep = Number(returnContext.step) || 1;
  787. if (targetStep === 2 || targetStep === 3) targetStep = (qRooms && qRooms.length > 0) ? 4 : 1;
  788. if (returnContext.quoteId) state.currentQuoteId = returnContext.quoteId;
  789. switchView('editor');
  790. goToStep(targetStep);
  791. updateBackToEditorButton();
  792. setTimeout(function(){ window.scrollTo(0, Number(returnContext.scrollY) || 0); }, 0);
  793. } else if (state.currentQuoteId && (qRooms && qRooms.length > 0 || state.spaces.length > 0 || state.currentStep > 1)) {
  794. // 先读取目标步骤(在switchView覆盖_prevStep之前)
  795. var quote = state.quotes.find(function(q){ return q.id === state.currentQuoteId; });
  796. var targetStep = state._prevStep || state.currentStep || (quote && quote.currentStep) || 1;
  797. if (targetStep === 2 || targetStep === 3) targetStep = (qRooms && qRooms.length > 0) ? 4 : 1;
  798. var isNew = state.currentQuoteId.startsWith('new_');
  799. // 有正在编辑的报价单 → 返回到编辑器对应步骤
  800. switchView('editor');
  801. var titleEl = document.getElementById('editor-title');
  802. if (titleEl) titleEl.textContent = isNew ? '新建报价单(草稿)' : ('编辑报价单 - ' + (quote ? quote.quoteNo : ''));
  803. goToStep(targetStep);
  804. updateBackToEditorButton();
  805. } else {
  806. // 没有编辑中的报价单 → 返回报价管理
  807. switchView('quotes');
  808. renderQuoteList();
  809. }
  810. }
  811. // 更新页面内(非顶部导航)的返回按钮
  812. // 校验返回上下文是否仍有效:对应报价单已被删除(如全部清空)则失效,不再显示返回按钮
  813. function qGetValidReturnContext() {
  814. var ctx = state._editorReturnContext;
  815. if (!ctx) return null;
  816. if (ctx.quoteId) {
  817. var alive = state.currentQuoteId === ctx.quoteId || state.quotes.some(function(q){ return q.id === ctx.quoteId; });
  818. if (!alive) { state._editorReturnContext = null; return null; }
  819. }
  820. return ctx;
  821. }
  822. function updatePageBackButton() {
  823. var btns = document.querySelectorAll('.page-back-btn');
  824. if (!btns.length) return;
  825. var showBtn = (state.currentView === 'rules' || state.currentView === 'templates' || state.currentView === 'analysis' || state.currentView === 'pricing');
  826. // 模式二(手动拖拽)已有独立的 nav-back-manual 返回键,且解析页自带上传入口,不再显示模式一的「返回编辑(步骤1·上传资料)」页内按键
  827. if (state.quoteMode === 'manual') showBtn = false;
  828. var returnContext = qGetValidReturnContext();
  829. var hasDraft = !!returnContext || !!(state.currentQuoteId && (qRooms && qRooms.length > 0 || state.spaces.length > 0 || state.currentStep > 1));
  830. var btnText;
  831. if (hasDraft) {
  832. var step = returnContext ? (Number(returnContext.step) || 1) : (state.currentStep || 1);
  833. var stepNames = {1:'上传资料',2:'AI解析',3:'自动计价',4:'报价单'};
  834. btnText = '← 返回编辑(步骤'+step+' · '+stepNames[step]+')';
  835. } else {
  836. btnText = '← 返回报价管理';
  837. }
  838. btns.forEach(function(btn){
  839. if (showBtn) {
  840. btn.innerHTML = btnText;
  841. btn.style.display = 'inline-flex';
  842. } else {
  843. btn.style.display = 'none';
  844. }
  845. });
  846. }
  847. function backToList() {
  848. saveCurrentQuote();
  849. switchView('quotes');
  850. renderQuoteList();
  851. }
  852. // 从列表继续编辑草稿
  853. function resumeDraftQuote(id) {
  854. var quote = state.quotes.find(function(q){ return q.id === id; });
  855. if (!quote) { switchView('quotes'); renderQuoteList(); return; }
  856. editQuote(id);
  857. var targetStep = quote.currentStep || (quote.qRooms && quote.qRooms.length > 0 ? 4 : 1);
  858. if (targetStep === 2 || targetStep === 3) targetStep = (quote.qRooms && quote.qRooms.length > 0) ? 4 : 1;
  859. goToStep(targetStep);
  860. updateBackToEditorButton();
  861. }
  862. function resumeEditing() {
  863. if (!state.currentQuoteId) {
  864. switchView('quotes');
  865. renderQuoteList();
  866. return;
  867. }
  868. switchView('editor');
  869. var titleEl = document.getElementById('editor-title');
  870. var isNew = state.currentQuoteId.startsWith('new_');
  871. var quote = state.quotes.find(function(q){ return q.id === state.currentQuoteId; });
  872. if (titleEl) titleEl.textContent = isNew ? '新建报价单(草稿)' : ('编辑报价单 - ' + (quote ? quote.quoteNo : ''));
  873. var targetStep = state.currentStep || (quote && quote.currentStep) || 1;
  874. if (targetStep === 2 || targetStep === 3) targetStep = (qRooms && qRooms.length > 0) ? 4 : 1;
  875. goToStep(targetStep);
  876. updateBackToEditorButton();
  877. }
  878. function updateBackToEditorButton() {
  879. var btn = document.getElementById('nav-back-editor');
  880. if (!btn) return;
  881. var stepNames = {1:'上传资料',2:'AI解析',3:'自动计价',4:'报价单'};
  882. var returnContext = qGetValidReturnContext();
  883. // 返回上下文有效或当前会话确有草稿时,才显示返回按钮(仅模式一快速粗略报价使用;模式二用独立的 nav-back-manual)
  884. var hasDraft = !!returnContext || !!(state.currentQuoteId && (qRooms && qRooms.length > 0 || state.spaces.length > 0 || state.currentStep > 1));
  885. // 报价管理页且当前模式没有任何报价单时,说明没有未完成的工作页,隐藏「返回上页」按键
  886. if (hasDraft && state.currentView === 'quotes') {
  887. var hasAiQuote = (state.quotes || []).some(function(q){ return (q.quoteMode || 'ai') === 'ai'; });
  888. if (!hasAiQuote) hasDraft = false;
  889. }
  890. if (hasDraft && state.currentView !== 'editor' && state.quoteMode !== 'manual') {
  891. var step = (returnContext && Number(returnContext.step)) || state.currentStep || 1;
  892. if (step === 2 || step === 3) step = 4;
  893. btn.innerHTML = '↩ 返回上页(步骤'+(step===4?2:1)+'/2 · '+stepNames[step]+')';
  894. btn.style.display = 'inline-flex';
  895. btn.style.animation = 'pulse 2s infinite';
  896. } else {
  897. btn.style.display = 'none';
  898. btn.style.animation = '';
  899. }
  900. // 同步刷新模式二独立返回键,保证两个返回键各自只在所属模式下出现
  901. updateManualBackButton();
  902. }
  903. // 模式二(手动拖拽精准报价)独立返回键:与模式一返回键完全独立,不共用逻辑
  904. // 基于返回点记忆:中途切到报价管理/规则配置/模板管理等页面时,可一键返回上一个未完成操作的工作页
  905. function updateManualBackButton(){
  906. var btn = document.getElementById('nav-back-manual');
  907. if (!btn) return;
  908. var ctx = state._manualReturnContext;
  909. var show = state.quoteMode === 'manual' && !!ctx && ctx.view !== state.currentView;
  910. // 报价管理页且当前模式没有任何报价单时,说明没有未完成的工作页,隐藏「返回上页」按键
  911. if (show && state.currentView === 'quotes') {
  912. var hasManualQuote = (state.quotes || []).some(function(q){ return (q.quoteMode || 'ai') === 'manual'; });
  913. if (!hasManualQuote) show = false;
  914. }
  915. if (show) {
  916. var stepNames = {1:'上传资料',2:'AI解析',3:'自动计价',4:'报价单'};
  917. var s = Number(ctx.step) || 1;
  918. var txt;
  919. if (ctx.view === 'editor') txt = '↩ 返回上页(步骤 ' + s + '/4 · ' + (stepNames[s] || '') + ')';
  920. else if (ctx.view === 'analysis') txt = '↩ 返回上页(AI解析 · 家具布局)';
  921. else if (ctx.view === 'pricing') txt = '↩ 返回上页(自动计价)';
  922. else txt = '↩ 返回上页';
  923. btn.innerHTML = txt;
  924. btn.style.display = 'inline-flex';
  925. } else {
  926. btn.style.display = 'none';
  927. }
  928. }
  929. // 模式二独立返回逻辑:优先恢复记录的返回点(原路返回未完成操作的工作页);无记录时兜底
  930. function goBackFromManual(){
  931. var ctx = state._manualReturnContext;
  932. if (ctx && ctx.view !== state.currentView) {
  933. var tv = ctx.view, ts = Number(ctx.step) || 1;
  934. state._manualReturnContext = null;
  935. switchView(tv);
  936. goToStep(ts);
  937. return;
  938. }
  939. var v = state.currentView;
  940. if (v === 'pricing') { switchView('analysis'); return; }
  941. if (v === 'rules' || v === 'templates') { switchView(state._manualMainView === 'pricing' ? 'pricing' : 'analysis'); return; }
  942. switchView('quotes');
  943. renderQuoteList();
  944. }
  945. function goToStep(step) {
  946. state.currentStep = step;
  947. var stepNames = {1:'上传资料',2:'AI解析',3:'自动计价',4:'报价单'};
  948. // Update step indicators
  949. document.querySelectorAll('.step-dot').forEach(dot => {
  950. const s = parseInt(dot.dataset.step);
  951. dot.classList.remove('active', 'done');
  952. if (s === step) dot.classList.add('active');
  953. if (s < step) dot.classList.add('done');
  954. });
  955. // Update breadcrumb
  956. var breadcrumb = document.getElementById('editor-step-breadcrumb');
  957. if (breadcrumb) {
  958. if (state.quoteMode === 'manual') {
  959. // 第二套报价模式:沿用源文件四步骤文案(步骤 X/4 · 上传资料/AI解析/自动计价/报价单)
  960. breadcrumb.textContent = '步骤 '+step+'/4 · '+(stepNames[step]||'');
  961. } else {
  962. var displayCrumb = {1:'步骤 1/2 · 上传资料', 2:'AI后台自动运行 · 解析', 3:'AI后台自动运行 · 计价', 4:'步骤 2/2 · 报价单'};
  963. breadcrumb.textContent = displayCrumb[step] || ('步骤 '+step+'/4 · '+(stepNames[step]||''));
  964. }
  965. }
  966. if (step === 1) {
  967. var analyzeBtn = document.getElementById('q-btn-analyze');
  968. if (analyzeBtn && !qAiAnalyzing) analyzeBtn.textContent = (state.quoteMode === 'manual') ? '🤖 开始 AI 解析' : '下一步:报价单 →';
  969. if (typeof qEnsureFloorHeightEditable === 'function') qEnsureFloorHeightEditable();
  970. if (typeof qSyncCadRuleCardVisibility === 'function') qSyncCadRuleCardVisibility();
  971. }
  972. // Show/hide step content
  973. for (let i = 1; i <= 4; i++) {
  974. const el = document.getElementById(`step-${i}`);
  975. if (el) el.classList.toggle('hidden', i !== step);
  976. }
  977. // Render step-specific content
  978. if (step === 2) {
  979. // 同步Step2层高显示(米)
  980. var fhDisplay = document.getElementById('q-step2-fh-display');
  981. if (fhDisplay) fhDisplay.textContent = (qFloorHeight > 0) ? qFloorHeight.toFixed(2) + 'm' : '—';
  982. qRenderStep2();
  983. }
  984. if (step === 3) {
  985. // 每次从第二步进入自动计价,都以第二步当前已填写明细为准。
  986. qSyncStep2DetailsToPricing();
  987. qRenderStep4();
  988. }
  989. if (step === 4) {
  990. // 报价单「上一步」:模式二返回自动计价页,模式一返回上传资料
  991. var step4BackBtn = document.getElementById('q-step4-back-btn');
  992. if (step4BackBtn) step4BackBtn.textContent = (state.quoteMode === 'manual') ? '← 上一步:自动计价' : '← 上一步:上传资料';
  993. qRenderStep5();
  994. if (typeof saveCurrentQuote === 'function') {
  995. try { saveCurrentQuote({markComplete:true}); } catch(e) { console.warn('Auto-save failed:', e); }
  996. }
  997. if (typeof persistState === 'function') {
  998. try { persistState(); } catch(e) { console.warn('Persist failed:', e); }
  999. }
  1000. } else {
  1001. // 非最终步骤自动保存为草稿
  1002. if (typeof saveCurrentQuote === 'function' && state.currentQuoteId && (qRooms.length > 0 || state.currentStep > 1)) {
  1003. try { saveCurrentQuote(); } catch(e) { console.warn('Draft auto-save failed:', e); }
  1004. }
  1005. }
  1006. updateBackToEditorButton();
  1007. }
  1008. // ==================== 4-STEP QUOTATION SYSTEM ====================
  1009. // 从规则配置中读取单价(禁止硬编码,所有价格来源于本地配置)
  1010. function qGetRulePrice(ruleName, defaultVal) {
  1011. defaultVal = defaultVal || 0;
  1012. var allRules = (DEFAULT_RULES.wardrobe || []).concat(DEFAULT_RULES.kitchen || [], DEFAULT_RULES.base || [], DEFAULT_RULES.extra || []);
  1013. for (var i = 0; i < allRules.length; i++) {
  1014. if (allRules[i].name === ruleName) {
  1015. var v = parseFloat(allRules[i].value !== undefined ? allRules[i].value : allRules[i].price);
  1016. return isNaN(v) ? defaultVal : v;
  1017. }
  1018. }
  1019. return defaultVal;
  1020. }
  1021. function qGetCabinetUnitPrice() { return qGetRulePrice('柜体投影单价', 0); }
  1022. function qIsKitchenCabinet(cabinetType){ return /厨房|橱柜/.test(String(cabinetType||'')); }
  1023. function qIsMasterWardrobe(cabinetType){ return /主卧/.test(String(cabinetType||'')); }
  1024. function qGetCabinetBusinessCategory(cabinetType){ return qIsKitchenCabinet(cabinetType) ? '橱柜类' : '衣柜类'; }
  1025. const Q_KITCHEN_PACKAGE_METERS=3;
  1026. // ── 规则模块一:厨房尺寸抓取与类型判定/总米数计算规则 ──
  1027. // AI自动判定厨房类型:四边最小边 ≤2m → L型(总长=长+宽−0.6×1−1);最小边 >2m → U型(总长=长+长+宽−0.6×2−1)
  1028. function qGetKitchenBillableMeters(room){
  1029. if(!room) return null;
  1030. var walls=[];
  1031. if(Array.isArray(room.kitchenWalls)&&room.kitchenWalls.length) walls=room.kitchenWalls.map(Number).filter(function(v){return v>0;});
  1032. var L=Number(room.length)||0,W=Number(room.width)||0;
  1033. if(!walls.length&&L>0&&W>0) walls=[L,W,L,W];
  1034. if(!walls.length) return null;
  1035. var total=walls.reduce(function(a,b){return a+b;},0);
  1036. var minWall=Math.min.apply(null,walls); // 宽(最小边,类型判定阈值2m)
  1037. var maxWall=Math.max.apply(null,walls); // 长(最大边)
  1038. var kType=minWall<=2?'L':'U'; // AI自动判定:最小边≤2m为L型,>2m为U型
  1039. var om=Number(state.ruleKitchenOverlapMm!=null?state.ruleKitchenOverlapMm:600);
  1040. var fm=Number(state.ruleKitchenFridgeMm!=null?state.ruleKitchenFridgeMm:1000);
  1041. var oc=kType==='L'?1:2; // L型1处重叠转角,U型2处重叠转角
  1042. var usedLen=kType==='L'?+(maxWall+minWall).toFixed(2):+(maxWall+maxWall+minWall).toFixed(2);
  1043. var billable=Math.max(0, usedLen-oc*om/1000-fm/1000);
  1044. return {walls:walls,total:+total.toFixed(2),minWall:+minWall.toFixed(2),maxWall:+maxWall.toFixed(2),kitchenType:kType,overlapCount:oc,overlapMm:om,fridgeMm:fm,usedLen:usedLen,billable:+billable.toFixed(2)};
  1045. }
  1046. function qUpdateKitchenRuleField(field,val){
  1047. val=parseFloat(val);
  1048. if(isNaN(val)||val<0){ qRenderRuleModule1(); return; }
  1049. if(field==='overlapCount') state.ruleKitchenOverlapCount=Math.round(val);
  1050. else if(field==='overlapMm') state.ruleKitchenOverlapMm=val;
  1051. else if(field==='fridgeMm') state.ruleKitchenFridgeMm=val;
  1052. try{ persistState(); }catch(e){}
  1053. qRenderRuleModule1();
  1054. showToast('✅ 尺寸扣减规则已更新','success');
  1055. }
  1056. function qRenderRuleModule1(){
  1057. var el;
  1058. el=document.getElementById('q-rule-kitchen-overlap-mm'); if(el) el.value=(state.ruleKitchenOverlapMm!=null?state.ruleKitchenOverlapMm:600);
  1059. el=document.getElementById('q-rule-kitchen-fridge-mm'); if(el) el.value=(state.ruleKitchenFridgeMm!=null?state.ruleKitchenFridgeMm:1000);
  1060. var chip=document.getElementById('q-rule-color-chip'); if(chip) chip.style.background=qCadRuleColor;
  1061. var nm=document.getElementById('q-rule-color-name'); if(nm){ nm.textContent=qCadRuleColorName; nm.style.color=qCadRuleColor; }
  1062. var pv=document.getElementById('q-rule-kitchen-preview');
  1063. if(pv){
  1064. var kitchen=null,rooms=Array.isArray(qRooms)?qRooms:[];
  1065. for(var i=0;i<rooms.length;i++){ if(qIsKitchenCabinet(rooms[i].cabinetType||rooms[i].roomName)){ kitchen=rooms[i]; break; } }
  1066. var kb=kitchen?qGetKitchenBillableMeters(kitchen):null;
  1067. if(kb){ pv.style.display='block';
  1068. pv.innerHTML='🍳 当前厨房空间计价长度示例:AI判定 <b>'+kb.kitchenType+'型厨房</b>(最小边 '+kb.minWall+'m '+(kb.kitchenType==='L'?'≤':'>')+' 2m):'+(kb.kitchenType==='L'?'长 '+kb.maxWall+'m + 宽 '+kb.minWall+'m':'长 '+kb.maxWall+'m + 长 '+kb.maxWall+'m + 宽 '+kb.minWall+'m')+' = '+kb.usedLen+'m − '+kb.overlapCount+'×'+(kb.overlapMm/1000).toFixed(1)+'m 重叠 − '+(kb.fridgeMm/1000).toFixed(1)+'m 冰箱预留 = <b>'+kb.billable+'m</b>';
  1069. } else { pv.style.display='none'; }
  1070. }
  1071. // 三档材质单价区间(规则模块一·第5条)
  1072. var tiers=qGetAiPriceTiers();
  1073. for(var ti=0;ti<3;ti++){
  1074. var imin=document.getElementById('q-rule-tier-'+ti+'-min'); if(imin) imin.value=tiers[ti].min;
  1075. var imax=document.getElementById('q-rule-tier-'+ti+'-max'); if(imax) imax.value=tiers[ti].max;
  1076. }
  1077. var sel=document.getElementById('q-rule-tier-default'); if(sel) sel.value=qGetAiDefaultTier();
  1078. }
  1079. // ── 规则模块一·第5条:三档材质单价区间报价(报价模块1:AI快速粗略报价) ──
  1080. // 统计投影面积后按三档材质单价报区间价;厨房按总长×单价,其余按投影面积×单价;默认PET档
  1081. function qGetAiPriceTiers(){
  1082. if(!Array.isArray(state.aiPriceTiers)||state.aiPriceTiers.length!==3){
  1083. state.aiPriceTiers=[{name:'双饰面',min:3000,max:3500},{name:'PET',min:4500,max:5000},{name:'混油',min:6000,max:7000}];
  1084. }
  1085. return state.aiPriceTiers;
  1086. }
  1087. function qGetAiDefaultTier(){ return state.aiDefaultTier||'PET'; }
  1088. // 模式一当前计价单价:浮动率10%对应该档最低单价、15%对应最高单价,中间线性插值;默认15%即按最高单价计价
  1089. function qGetAiUnitPrice(){
  1090. var t=qGetAiTier();
  1091. var pct=qGetAiFloatPct();
  1092. var k=(pct-10)/5; if(k<0)k=0; if(k>1)k=1;
  1093. return +(t.min + k*(t.max-t.min)).toFixed(2);
  1094. }
  1095. function qGetAiTier(name){
  1096. var tiers=qGetAiPriceTiers();
  1097. var target=name||qGetAiDefaultTier();
  1098. for(var i=0;i<tiers.length;i++){ if(tiers[i].name===target) return tiers[i]; }
  1099. return tiers[1];
  1100. }
  1101. function qUpdateAiTierField(idx,field,val){
  1102. val=parseFloat(val);
  1103. if(isNaN(val)||val<0){ qRenderRuleModule1(); return; }
  1104. var tiers=qGetAiPriceTiers();
  1105. if(!tiers[idx]) return;
  1106. tiers[idx][field]=val;
  1107. try{ persistState(); }catch(e){}
  1108. qRenderRuleModule1();
  1109. if(typeof qRenderStep4==='function') qRenderStep4();
  1110. if(typeof qRenderStep5==='function') qRenderStep5();
  1111. showToast('✅ 材质单价区间已更新:'+tiers[idx].name+' '+tiers[idx].min+'-'+tiers[idx].max+' 元','success');
  1112. }
  1113. function qUpdateAiDefaultTier(val){
  1114. state.aiDefaultTier=String(val||'PET');
  1115. try{ persistState(); }catch(e){}
  1116. qRebuildAiRoomItems();
  1117. qRenderRuleModule1();
  1118. if(typeof qRenderStep4==='function') qRenderStep4();
  1119. if(typeof qRenderStep5==='function') qRenderStep5();
  1120. showToast('✅ 默认计价档位已切换:'+state.aiDefaultTier,'success');
  1121. }
  1122. // AI模式单空间计价:厨房=总长(规则2/3扣减后)×当前单价;其余=投影面积×当前单价(只输出单一总价,不再输出区间)
  1123. function qGetAiRoomRangePrice(r){
  1124. var tier=qGetAiTier();
  1125. var U=qGetAiUnitPrice();
  1126. var isK=qIsKitchenCabinet(r.cabinetType||r.roomName);
  1127. if(isK){
  1128. var kb=qGetKitchenBillableMeters(r);
  1129. var m=kb?kb.billable:(parseFloat(r.cabinetWidth)||parseFloat(r.length)||0);
  1130. return {isKitchen:true,qty:+m.toFixed(2),unit:'m',minPrice:U,maxPrice:U,minTotal:+(m*U).toFixed(2),maxTotal:+(m*U).toFixed(2),tierName:tier.name,kb:kb};
  1131. }
  1132. var w=parseFloat(r.cabinetWidth)||parseFloat(r.length)||0,h=parseFloat(r.cabinetHeight)||parseFloat(r.height)||0;
  1133. var a=r.projectArea||r.area||(w>0&&h>0?+(w*h).toFixed(3):0);
  1134. return {isKitchen:false,qty:+(a||0).toFixed(3),unit:'㎡',minPrice:U,maxPrice:U,minTotal:+((a||0)*U).toFixed(2),maxTotal:+((a||0)*U).toFixed(2),tierName:tier.name,w:+w.toFixed(2),h:+h.toFixed(2)};
  1135. }
  1136. function qGetAiGrandRange(){
  1137. var min=0,max=0;
  1138. (Array.isArray(qRooms)?qRooms:[]).forEach(function(r,i){ if(qIsRoomExcluded(i)) return; var p=qGetAiRoomRangePrice(r); min+=p.minTotal; max+=p.maxTotal; });
  1139. return {min:+min.toFixed(2),max:+max.toFixed(2)};
  1140. }
  1141. // ── 计入/不计入计算开关:取消勾选的空间不计入各空间面积总价、全屋总价及导出表格 ──
  1142. function qIsRoomExcluded(i){ return !!(state.qRoomExcl && state.qRoomExcl[i]); }
  1143. function qToggleRoomInclude(i, checked){
  1144. if(!state.qRoomExcl) state.qRoomExcl={};
  1145. if(checked) delete state.qRoomExcl[i]; else state.qRoomExcl[i]=true;
  1146. try{ persistState(); }catch(e){}
  1147. var r=(qRooms||[])[i];
  1148. showToast(checked?'✅ 「'+(r?(r.cabinetType||r.roomName||'柜体'):'')+'」已计入总价与导出表格':'⛔ 「'+(r?(r.cabinetType||r.roomName||'柜体'):'')+'」不计入总价与导出表格','info');
  1149. if(typeof qRenderStep5==='function') qRenderStep5();
  1150. }
  1151. function qRoomIncludeToggleHtml(i){
  1152. var excl=qIsRoomExcluded(i);
  1153. return '<label title="勾选=计入总价与导出表格;取消=不计入" style="display:inline-flex;align-items:center;gap:5px;margin-right:10px;padding:3px 8px;border:2px solid '+(excl?'#CBD5E0':'#38A169')+';border-radius:6px;background:'+(excl?'#EDF2F7':'#F0FFF4')+';cursor:pointer;user-select:none;">'+
  1154. '<input type="checkbox" '+(excl?'':'checked')+' onchange="qToggleRoomInclude('+i+',this.checked)" style="width:15px;height:15px;cursor:pointer;accent-color:#38A169;">'+
  1155. '<span style="font-size:11px;font-weight:700;color:'+(excl?'#A0AEC0':'#276749')+';white-space:nowrap;">'+(excl?'不计入':'计入')+'</span></label>';
  1156. }
  1157. // ── 识别配置:标准规则设置(隐藏编辑框,点击打开编辑预览,规则文本可手动编辑) ──
  1158. var Q_RECOG_DEFAULT_TEXTS = {
  1159. r1: 'AI自动识别推算出这张户型图的图像比例大小。',
  1160. r2: '(户型图上标注的任意一边真实尺寸或者录入的真实尺寸)推算出这张户型图的尺寸单位(1像素是多少米)。',
  1161. r3: '最后计算出所有绿色框的宽、高,以及投影面积(除厨房外,厨房不需要算投影面积,只需要算出每条边的长度以及总长度)。',
  1162. r4: '系统自动识别户型图上所有红框(规则色框)内标注的柜子,并与红框旁的红字标注配对命名(如主卧衣柜、次卧衣柜、餐边柜、电视柜、阳台柜等);同名柜子自动编号为阳台柜1、阳台柜2。厨房默认一定需要识别:优先识别红框内的厨房,若红框内没有厨房,则自动补充默认厨房参与计价。'
  1163. };
  1164. function qGetRecogCfg(){
  1165. if(!state.qRecogConfig) state.qRecogConfig={source:'标注尺寸'};
  1166. var c=state.qRecogConfig;
  1167. if(!c.texts) c.texts={};
  1168. ['r1','r2','r3','r4'].forEach(function(k){ if(!c.texts[k]) c.texts[k]=Q_RECOG_DEFAULT_TEXTS[k]; });
  1169. return c;
  1170. }
  1171. function qToggleRecogConfig(){
  1172. var p=document.getElementById('q-recog-config-panel'); if(!p) return;
  1173. if(p.style.display==='none'){ p.style.display='block'; qRenderRecogConfig(); }
  1174. else { p.style.display='none'; }
  1175. }
  1176. function qAutoGrow(t){ t.style.height='auto'; t.style.height=(t.scrollHeight+2)+'px'; }
  1177. function qRenderRecogConfig(){
  1178. var cfg=qGetRecogCfg();
  1179. var el=document.getElementById('q-rc-source'); if(el) el.value=cfg.source||'标注尺寸';
  1180. ['r1','r2','r3','r4'].forEach(function(k){
  1181. var t=document.getElementById('q-rc-text-'+k);
  1182. if(t){ t.value=cfg.texts[k]||''; qAutoGrow(t); }
  1183. });
  1184. }
  1185. function qUpdateRecogText(key,val){
  1186. var cfg=qGetRecogCfg();
  1187. cfg.texts[key]=String(val||'');
  1188. try{ persistState(); }catch(e){}
  1189. showToast('✅ 识别配置规则已保存','success');
  1190. }
  1191. function qUpdateRecogSource(val){
  1192. var cfg=qGetRecogCfg();
  1193. cfg.source=String(val||'标注尺寸');
  1194. try{ persistState(); }catch(e){}
  1195. showToast('✅ 识别配置标准规则已更新:参考'+cfg.source+'推算尺寸单位','success');
  1196. }
  1197. // ── 千问模型默认调用(无需手动填写,内置密钥;识别时辅助命名红框内柜子) ──
  1198. var Q_QWEN_API={
  1199. url:'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions',
  1200. key:'sk-8fce4791ad4b47248a7ef9b417b96af8',
  1201. model:'qwen-vl-max'
  1202. };
  1203. // 压缩图纸为base64(blob地址模型侧无法访问),限制最长边保证请求体积可控
  1204. function qDownscaleImageForApi(img,maxW){
  1205. try{
  1206. var w=img.naturalWidth,h=img.naturalHeight;
  1207. var s=Math.min(1,(maxW||1280)/Math.max(w,h));
  1208. var cv=document.createElement('canvas');
  1209. cv.width=Math.max(1,Math.round(w*s)); cv.height=Math.max(1,Math.round(h*s));
  1210. cv.getContext('2d').drawImage(img,0,0,cv.width,cv.height);
  1211. return cv.toDataURL('image/jpeg',0.85);
  1212. }catch(e){ return null; }
  1213. }
  1214. async function qCallQwenVision(prompt,imageDataUrl){
  1215. try{
  1216. var resp=await fetch(Q_QWEN_API.url,{
  1217. method:'POST',
  1218. headers:{'Content-Type':'application/json','Authorization':'Bearer '+Q_QWEN_API.key},
  1219. body:JSON.stringify({model:Q_QWEN_API.model,messages:[{role:'user',content:[{type:'text',text:prompt},{type:'image_url',image_url:{url:imageDataUrl}}]}],max_tokens:800})
  1220. });
  1221. if(!resp.ok) return null;
  1222. var data=await resp.json();
  1223. var txt=data&&data.choices&&data.choices[0]&&data.choices[0].message&&data.choices[0].message.content;
  1224. return (typeof txt==='string')?txt:null;
  1225. }catch(e){ return null; }
  1226. }
  1227. function qParseQwenCabinets(txt){
  1228. if(!txt) return null;
  1229. var m=txt.match(/\{[\s\S]*\}/); if(!m) return null;
  1230. try{ return JSON.parse(m[0]); }catch(e){ return null; }
  1231. }
  1232. // ── 重新抓取:按识别配置(比例推算/尺寸单位/色框宽高)与规则配置(解析规则、厨房扣减、单价)重新识别各柜体长宽,
  1233. // 并联动重算投影面积、厨房四边总长度与价格 ──
  1234. async function qReFetchDimensions(){
  1235. var src=typeof qResolveOriginalDrawingSource==='function'?qResolveOriginalDrawingSource():null;
  1236. if(!src){ showToast('⚠️ 请先上传户型图,再重新抓取柜体尺寸','warning'); return; }
  1237. showToast('🔄 正在按识别配置与规则配置重新抓取各柜体长宽…','info');
  1238. var res=null;
  1239. try{ res=await qRunRecognitionAndApply(); }catch(e){ console.warn('重新抓取失败:',e); }
  1240. // 下游联动重算:投影面积=w×h、厨房总长按规则模块一扣减、明细单价与总价全部从规则配置读取
  1241. if(typeof qSyncRoomItemsWithRules==='function') qSyncRoomItemsWithRules();
  1242. if(typeof qRenderRuleModule1==='function') qRenderRuleModule1();
  1243. if(typeof qRenderStep3==='function') qRenderStep3();
  1244. if(typeof qRenderStep4==='function') qRenderStep4();
  1245. if(typeof qRenderStep5==='function') qRenderStep5();
  1246. try{ persistState(); }catch(e){}
  1247. var ap=res&&res.applied!=null?res.applied:0, cabN=Array.isArray(qRooms)?qRooms.length:0;
  1248. if(ap>0){ showToast('✅ 重新抓取完成:'+cabN+' 组柜体中 '+ap+' 组宽高来自规则色块识别,面积/厨房总长/价格已按规则配置重算','success'); }
  1249. else if(cabN>0){ showToast('⚠️ 未识别到规则色块:请用'+(typeof qCadRuleColorName!=='undefined'?qCadRuleColorName:'绿色')+'框标注柜体并绘制尺寸线后再次抓取,当前沿用贴纸估算尺寸','warning'); }
  1250. else { showToast('⚠️ 未抓取到柜体:请先放置家具贴纸或用规则色框标注柜体','warning'); }
  1251. }
  1252. function qCalculateKitchenItem(itemName,totalWidth,height,count){
  1253. itemName=String(itemName||'地柜');totalWidth=Math.max(0,Number(totalWidth)||0);height=Math.max(0,Number(height)||0);count=Math.max(0,Number(count)||0);
  1254. var packageMeters=Q_KITCHEN_PACKAGE_METERS;
  1255. var excessMeters=Math.max(0,totalWidth-packageMeters);
  1256. var price=qGetRulePrice(itemName,0),quantity=0,total=0,method='',unit='';
  1257. if(itemName==='地柜'||itemName==='吊柜'){
  1258. quantity=Math.max(1,excessMeters);total=quantity*price;method='max(总宽-3米,1米)×单价';unit='延米';
  1259. }else if(itemName==='冰箱吊柜'||itemName==='高柜'){
  1260. quantity=totalWidth;total=quantity*price;method='实际米数×单价';unit='延米';
  1261. }else if(itemName==='见光板'||itemName==='见光板×2'){
  1262. itemName='见光板';price=qGetRulePrice('见光板',1138);quantity=totalWidth*height;total=quantity*price;method='投影面积×单价';unit='㎡';
  1263. }else if(itemName==='台面'){
  1264. var insideMeters=Math.min(totalWidth,packageMeters),insidePrice=qGetRulePrice('台面',1480);
  1265. quantity=insideMeters;price=insidePrice;total=insideMeters*insidePrice;method='3米内台面米数×1480';unit='延米';
  1266. }else if(itemName==='台面超出部分'){
  1267. quantity=excessMeters;total=quantity*price;method='(总宽-3米)×单价';unit='延米';
  1268. }else{
  1269. quantity=count;total=quantity*price;method='个数×单价';unit='个';
  1270. }
  1271. return {name:itemName,quantity:+quantity.toFixed(3),unitPrice:price,total:+total.toFixed(2),method:method,unit:unit,packageMeters:packageMeters,excessMeters:+excessMeters.toFixed(3)};
  1272. }
  1273. function qGetCabinetPricingRule(room, width, height){
  1274. var type=String((room&&room.cabinetType)||(room&&room.roomName)||'');
  1275. width=Number(width)||0;height=Number(height)||0;
  1276. if(qIsKitchenCabinet(type)){
  1277. // 报价模块1(AI快速报价):厨房按四边总米数统一扣减后计价;其余空间按投影面积
  1278. if(state.quoteMode==='ai'){ var _kb=qGetKitchenBillableMeters(room); if(_kb&&_kb.billable>0) width=_kb.billable; }
  1279. var kitchenItem=String((room&&room.kitchenItem)||(room&&room.material)||'地柜');
  1280. var calc=qCalculateKitchenItem(kitchenItem,width,height,Number(room&&room.quantity)||0);
  1281. // 模式一:厨房按计费米数×当前材质单价计价(与页面每空间总价一致),单价随浮动率联动
  1282. if(state.quoteMode==='ai'){ var _kU=qGetAiUnitPrice(); calc.unitPrice=_kU; calc.quantity=+width.toFixed(3); calc.method='计费米数×单价'; calc.unit='延米'; calc.total=+(width*_kU).toFixed(2); }
  1283. return {category:'橱柜类',method:calc.method,material:calc.name,quantity:calc.quantity,unit:calc.unit,unitPrice:calc.unitPrice,total:calc.total,ruleKey:calc.name,packageMeters:calc.packageMeters,excessMeters:calc.excessMeters};
  1284. }
  1285. var selectedMaterial=String((room&&room.material)||(room&&room.cabinetMaterial)||'');
  1286. var useAluminum=selectedMaterial ? /铝格栅/.test(selectedMaterial) : qIsMasterWardrobe(type);
  1287. var material=useAluminum?'铝格栅板':'生态澳松板';
  1288. // 模式一:柜体单价使用当前材质单价(随浮动率联动),与系统页面/导出保持一致
  1289. var price=(state.quoteMode==='ai')?qGetAiUnitPrice():qGetRulePrice(useAluminum?'铝格栅板柜体':'生态澳松板柜体',useAluminum?2532:1852);
  1290. var area=width*height;
  1291. return {category:'衣柜类',method:'按投影面积',material:material,quantity:area,unit:'m²',unitPrice:price,total:area*price};
  1292. }
  1293. // 内置门板基础单价(不显示在增项配置中,作为系统默认)
  1294. const Q_DOOR_DEFAULT_PRICES = {
  1295. '标配门板单价': 380,
  1296. '烤漆门板单价': 580,
  1297. '吸塑门板单价': 480,
  1298. '实木门板单价': 880,
  1299. '铝格栅门板单价': 680,
  1300. 'PET门板单价': 450
  1301. };
  1302. function qGetDoorPriceByType(doorType) {
  1303. if (doorType === '风影门板' || doorType === 'PET门板') return qGetRulePrice('烤漆门板单价', Q_DOOR_DEFAULT_PRICES['烤漆门板单价']);
  1304. if (doorType === '实木贴皮') return qGetRulePrice('实木门板单价', Q_DOOR_DEFAULT_PRICES['实木门板单价']);
  1305. if (doorType === '吸塑门板') return qGetRulePrice('吸塑门板单价', Q_DOOR_DEFAULT_PRICES['吸塑门板单价']);
  1306. if (doorType === '烤漆门板') return qGetRulePrice('烤漆门板单价', Q_DOOR_DEFAULT_PRICES['烤漆门板单价']);
  1307. if (doorType === '玻璃门' || doorType === '百叶门' || doorType === '无门板') return qGetRulePrice('标配门板单价', Q_DOOR_DEFAULT_PRICES['标配门板单价']);
  1308. return qGetRulePrice('标配门板单价', Q_DOOR_DEFAULT_PRICES['标配门板单价']);
  1309. }
  1310. function qGetVisiblePanelPrice() { return qGetRulePrice('见光板', 0); }
  1311. function qGetExtraPrice(extraName) {
  1312. return qGetRulePrice(extraName, 0);
  1313. }
  1314. const Q_LAYOUT_CONFIG = {
  1315. '一字型':{unfoldRatio:1.0,layoutCoef:1.0},
  1316. 'L型':{unfoldRatio:1.0,layoutCoef:1.0},
  1317. 'U型':{unfoldRatio:1.0,layoutCoef:1.0},
  1318. '二字型':{unfoldRatio:1.0,layoutCoef:1.0}
  1319. };
  1320. // ── 自定义类型库 ──
  1321. const Q_CUSTOM_TYPES_PRESET = {
  1322. cabinetType: ['主卧衣柜','次卧衣柜','儿童房衣柜','老人房衣柜','客厅电视柜','厨房橱柜','开放式厨房','餐厅餐边柜','书房书柜','玄关鞋柜','阳台柜','衣帽间','储物柜','酒柜','浴室柜','榻榻米'],
  1323. layoutForm: ['一字型','L型','U型','二字型','不规则'],
  1324. doorPanelType:['普通平板','风影门板','PET门板','实木贴皮','烤漆门板','吸塑门板','玻璃门','百叶门','无门板']
  1325. };
  1326. // 柜体类型 → 对应空间位置映射(精确匹配:每个细分柜体类型唯一对应一个空间)
  1327. const Q_CABINET_TO_SPACE_MAP = {
  1328. '主卧衣柜': '主卧',
  1329. '次卧衣柜': '次卧',
  1330. '儿童房衣柜': '儿童房',
  1331. '老人房衣柜': '老人房',
  1332. '衣柜': '卧室',
  1333. '客厅电视柜': '客厅',
  1334. '电视柜': '客厅',
  1335. '厨房橱柜': '厨房',
  1336. '橱柜': '厨房',
  1337. '开放式厨房': '开放式厨房',
  1338. '餐厅餐边柜': '餐厅',
  1339. '餐边柜': '餐厅',
  1340. '书房书柜': '书房',
  1341. '书柜': '书房',
  1342. '玄关鞋柜': '玄关',
  1343. '鞋柜': '玄关',
  1344. '玄关柜': '玄关',
  1345. '阳台柜': '阳台',
  1346. '衣帽间': '衣帽间',
  1347. '储物柜': '储物间',
  1348. '酒柜': '餐厅',
  1349. '浴室柜': '卫生间',
  1350. '榻榻米': '次卧'
  1351. };
  1352. // 空间位置 → 默认柜体类型映射(当空间名已知但柜体类型笼统时补全)
  1353. const Q_SPACE_TO_DEFAULT_CABINET = {
  1354. '主卧': '主卧衣柜',
  1355. '次卧': '次卧衣柜',
  1356. '儿童房': '儿童房衣柜',
  1357. '老人房': '老人房衣柜',
  1358. '客厅': '客厅电视柜',
  1359. '厨房': '厨房橱柜',
  1360. '开放式厨房':'开放式厨房',
  1361. '餐厅': '餐厅餐边柜',
  1362. '客餐厅': '餐厅餐边柜',
  1363. '书房': '书房书柜',
  1364. '玄关': '玄关鞋柜',
  1365. '阳台': '阳台柜',
  1366. '衣帽间': '衣帽间',
  1367. '储物间': '储物柜',
  1368. '卫生间': '浴室柜',
  1369. '主卫': '浴室柜'
  1370. };
  1371. // 根据柜体类型推断空间位置名称
  1372. function qInferSpaceName(cabinetType, existingNames) {
  1373. // 精确匹配
  1374. if (Q_CABINET_TO_SPACE_MAP[cabinetType]) {
  1375. return Q_CABINET_TO_SPACE_MAP[cabinetType];
  1376. }
  1377. // 尝试模糊匹配:柜体类型中包含关键词
  1378. var keys = Object.keys(Q_CABINET_TO_SPACE_MAP);
  1379. for (var i = 0; i < keys.length; i++) {
  1380. if (cabinetType.indexOf(keys[i]) >= 0 || keys[i].indexOf(cabinetType) >= 0) {
  1381. return Q_CABINET_TO_SPACE_MAP[keys[i]];
  1382. }
  1383. }
  1384. return cabinetType;
  1385. }
  1386. // 修正空间数据:双向智能匹配空间位置与柜体类型
  1387. // 1) roomName是柜体类型名 → 替换为对应空间名
  1388. // 2) cabinetType是笼统的"衣柜"且roomName是已知卧室空间 → 自动细化为"主卧衣柜"等
  1389. // 3) 确保两列内容不重复撞名
  1390. function qFixRoomSpaceNames(rooms) {
  1391. if (!rooms || !rooms.length) return rooms;
  1392. var allCabinetTypes = Q_CUSTOM_TYPES_PRESET.cabinetType;
  1393. var usedSpaces = [];
  1394. rooms.forEach(function(r) {
  1395. var rn = (r.roomName || '').trim();
  1396. var ct = (r.cabinetType || '').trim();
  1397. // ── 情况1:roomName是柜体类型(撞名),需要替换为正确的空间名 ──
  1398. var isRoomNameCabinetType = allCabinetTypes.indexOf(rn) >= 0 || Q_CABINET_TO_SPACE_MAP[rn];
  1399. if (isRoomNameCabinetType) {
  1400. var inferredSpace = qInferSpaceName(rn, usedSpaces);
  1401. // 如果推断出的空间名已被使用,尝试找下一个可用空间
  1402. if (usedSpaces.indexOf(inferredSpace) >= 0) {
  1403. // 对于衣柜类,尝试分配到其他卧室
  1404. var bedroomSpaces = ['主卧','次卧','儿童房','老人房'];
  1405. for (var bi = 0; bi < bedroomSpaces.length; bi++) {
  1406. if (usedSpaces.indexOf(bedroomSpaces[bi]) < 0 && (rn === '衣柜' || rn.indexOf('衣柜') >= 0)) {
  1407. inferredSpace = bedroomSpaces[bi];
  1408. break;
  1409. }
  1410. }
  1411. }
  1412. r.roomName = inferredSpace;
  1413. rn = inferredSpace;
  1414. }
  1415. // ── 情况2:柜体类型是笼统/旧名称,且空间位置已知,自动细化为细分柜体类型 ──
  1416. if (Q_SPACE_TO_DEFAULT_CABINET[rn]) {
  1417. var expectedCabinet = Q_SPACE_TO_DEFAULT_CABINET[rn];
  1418. // 如果当前cabinetType是旧笼统名称或为空,替换为细分名称
  1419. var oldGenericNames = ['衣柜','电视柜','橱柜','餐边柜','书柜','鞋柜','玄关柜','卫浴柜',''];
  1420. if (oldGenericNames.indexOf(ct) >= 0 || ct !== expectedCabinet) {
  1421. // 只在ct是旧名称/笼统名时才替换,保留用户手动选的正确细分名
  1422. if (allCabinetTypes.indexOf(ct) < 0 || oldGenericNames.indexOf(ct) >= 0 || ct.indexOf('衣柜')<0 && ct.length<=3) {
  1423. r.cabinetType = expectedCabinet;
  1424. ct = r.cabinetType;
  1425. }
  1426. }
  1427. }
  1428. // ── 情况3:开放式厨房特殊处理 ──
  1429. if (ct === '开放式厨房') {
  1430. r.roomName = '开放式厨房';
  1431. rn = '开放式厨房';
  1432. }
  1433. if (rn === '开放式厨房' && (!ct || ct === '衣柜' || ct === '橱柜')) {
  1434. r.cabinetType = '开放式厨房';
  1435. ct = '开放式厨房';
  1436. }
  1437. usedSpaces.push(r.roomName);
  1438. });
  1439. return rooms;
  1440. }
  1441. // 运行时可变副本(用户自定义类型追加到这里)
  1442. let qCustomTypes = {
  1443. cabinetType: [...Q_CUSTOM_TYPES_PRESET.cabinetType],
  1444. layoutForm: [...Q_CUSTOM_TYPES_PRESET.layoutForm],
  1445. doorPanelType:[...Q_CUSTOM_TYPES_PRESET.doorPanelType]
  1446. };
  1447. const Q_CUSTOM_TYPE_LABELS = {
  1448. cabinetType: '柜体类型',
  1449. layoutForm: '布局形式',
  1450. doorPanelType: '门板类型'
  1451. };
  1452. const Q_MOCK_ROOMS = [
  1453. {roomName:'主卧',cabinetType:'主卧衣柜',layoutStyle:'一字型',projectArea:5.2,doorType:'风影门板', length:4.2, width:3.5, height:2.8, area:14.7, cabinetRatio:0.60, fillRate:0.65, doorRatio:0.55, bayWindow:false},
  1454. {roomName:'次卧',cabinetType:'次卧衣柜',layoutStyle:'一字型',projectArea:4.0,doorType:'普通平板', length:3.8, width:3.2, height:2.8, area:12.2, cabinetRatio:0.55, fillRate:0.50, doorRatio:0.50, bayWindow:false},
  1455. {roomName:'客厅',cabinetType:'客厅电视柜',layoutStyle:'L型',projectArea:3.5,doorType:'风影门板', length:5.5, width:4.5, height:2.8, area:24.8, cabinetRatio:0.45, fillRate:0.60, doorRatio:0.60, bayWindow:false},
  1456. {roomName:'厨房',cabinetType:'厨房橱柜',layoutStyle:'U型',projectArea:6.8,doorType:'普通平板', length:3.5, width:2.4, height:2.8, area:8.4, cabinetRatio:0.70, fillRate:0.70, doorRatio:0.40, bayWindow:false},
  1457. {roomName:'餐厅',cabinetType:'餐厅餐边柜',layoutStyle:'一字型',projectArea:2.2,doorType:'PET门板', length:2.8, width:2.8, height:2.6, area:7.8, cabinetRatio:0.50, fillRate:0.55, doorRatio:0.60, bayWindow:false},
  1458. {roomName:'阳台',cabinetType:'阳台柜',layoutStyle:'一字型',projectArea:1.5,doorType:'普通平板', length:3.0, width:1.5, height:2.6, area:4.5, cabinetRatio:0.50, fillRate:0.50, doorRatio:0.50, bayWindow:false},
  1459. {roomName:'书房',cabinetType:'书房书柜',layoutStyle:'一字型',projectArea:2.8,doorType:'玻璃门', length:2.5, width:2.5, height:2.8, area:6.3, cabinetRatio:0.65, fillRate:0.60, doorRatio:0.45, bayWindow:false},
  1460. {roomName:'玄关',cabinetType:'玄关鞋柜',layoutStyle:'一字型',projectArea:1.2,doorType:'普通平板', length:1.5, width:1.8, height:2.6, area:2.7, cabinetRatio:0.55, fillRate:0.50, doorRatio:0.50, bayWindow:false},
  1461. {roomName:'开放式厨房',cabinetType:'开放式厨房',layoutStyle:'L型',projectArea:4.5,doorType:'PET门板', length:3.2, width:2.8, height:2.8, area:9.0, cabinetRatio:0.60, fillRate:0.65, doorRatio:0.50, bayWindow:false}
  1462. ];
  1463. const Q_MOCK_EXTRAS = [
  1464. {roomIndex:0,items:[{name:'抽屉',spec:'600mm',qty:3,unit:'个',price:0},{name:'灯带',spec:'暖白3000K',qty:4.2,unit:'米',price:0},{name:'拉手',spec:'铝合金黑色',qty:6,unit:'个',price:0}]},
  1465. {roomIndex:1,items:[{name:'抽屉',spec:'600mm',qty:2,unit:'个',price:0},{name:'层板',spec:'标准',qty:4,unit:'块',price:0}]},
  1466. {roomIndex:2,items:[{name:'灯带',spec:'暖白3000K',qty:3.5,unit:'米',price:0},{name:'拉手',spec:'铝合金黑色',qty:5,unit:'个',price:0},{name:'玻璃门',spec:'钢化玻璃',qty:2,unit:'扇',price:0}]},
  1467. {roomIndex:3,items:[{name:'抽屉',spec:'600mm',qty:5,unit:'个',price:0},{name:'拉手',spec:'铝合金黑色',qty:8,unit:'个',price:0},{name:'灯带',spec:'暖白3000K',qty:5.0,unit:'米',price:0}]},
  1468. {roomIndex:4,items:[{name:'抽屉',spec:'600mm',qty:2,unit:'个',price:0},{name:'灯带',spec:'暖白3000K',qty:2.8,unit:'米',price:0},{name:'玻璃门',spec:'钢化玻璃',qty:1,unit:'扇',price:0},{name:'拉手',spec:'铝合金黑色',qty:4,unit:'个',price:0}]},
  1469. {roomIndex:5,items:[{name:'拉手',spec:'铝合金银色',qty:3,unit:'个',price:0},{name:'层板',spec:'防水',qty:3,unit:'块',price:0}]},
  1470. {roomIndex:6,items:[{name:'灯带',spec:'暖白3000K',qty:2.5,unit:'米',price:0},{name:'玻璃门',spec:'钢化玻璃',qty:3,unit:'扇',price:0},{name:'层板',spec:'标准',qty:5,unit:'块',price:0}]},
  1471. {roomIndex:7,items:[{name:'抽屉',spec:'600mm',qty:2,unit:'个',price:0},{name:'层板',spec:'标准',qty:3,unit:'块',price:0},{name:'拉手',spec:'铝合金黑色',qty:3,unit:'个',price:0}]},
  1472. {roomIndex:8,items:[{name:'抽屉',spec:'600mm',qty:3,unit:'个',price:0},{name:'拉手',spec:'铝合金黑色',qty:6,unit:'个',price:0},{name:'灯带',spec:'暖白3000K',qty:3.2,unit:'米',price:0},{name:'碗篮',spec:'不锈钢',qty:1,unit:'套',price:0}]}
  1473. ];
  1474. // ── 按Excel预算表结构生成的房间子项明细(匹配万骊报价格式,单价默认0,由规则配置决定)──
  1475. const Q_MOCK_ROOM_ITEMS = [
  1476. // 0: 玄关柜
  1477. [{name:'柜体',width:0.79,height:2.5,area:1.98,unitPrice:0,note:''},
  1478. {name:'板门补差价',width:0.79,height:2.5,area:1.98,unitPrice:0,note:''},
  1479. {name:'灯带',width:0,height:0,area:1.58,unitPrice:0,note:''},
  1480. {name:'变压器+开关',width:0,height:0,area:1,unitPrice:0,note:''},
  1481. {name:'自弹器',width:0,height:0,area:4,unitPrice:0,note:''},
  1482. {name:'见光板',width:0.54,height:2.5,area:1.35,unitPrice:0,note:''},
  1483. {name:'铰链',width:0,height:0,area:12,unitPrice:0,note:''}],
  1484. // 1: 客厅电视柜
  1485. [{name:'柜体',width:4.85,height:2.5,area:3.50,unitPrice:0,note:''},
  1486. {name:'板门补差价',width:4.85,height:2.5,area:3.50,unitPrice:0,note:''},
  1487. {name:'灯带',width:0,height:0,area:5.5,unitPrice:0,note:''},
  1488. {name:'自弹器',width:0,height:0,area:12,unitPrice:0,note:''},
  1489. {name:'见光板',width:0.35,height:2.5,area:0.88,unitPrice:0,note:''},
  1490. {name:'铰链',width:0,height:0,area:30,unitPrice:0,note:''}],
  1491. // 2: 次卧衣柜
  1492. [{name:'柜体',width:3.74,height:2.5,area:4.00,unitPrice:0,note:''},
  1493. {name:'板门补差价',width:3.74,height:2.5,area:4.00,unitPrice:0,note:''},
  1494. {name:'衣杆',width:0,height:0,area:4,unitPrice:0,note:''},
  1495. {name:'金属拉手',width:0,height:0,area:6,unitPrice:0,note:''},
  1496. {name:'抽屉',width:0,height:0,area:3,unitPrice:0,note:''},
  1497. {name:'自弹器',width:0,height:0,area:6,unitPrice:0,note:''},
  1498. {name:'见光板',width:0.6,height:2.5,area:1.50,unitPrice:0,note:''},
  1499. {name:'铰链',width:0,height:0,area:25,unitPrice:0,note:''}],
  1500. // 3: 餐边柜
  1501. [{name:'柜体',width:2,height:2.4,area:2.80,unitPrice:0,note:''},
  1502. {name:'板门补差价',width:2,height:2.4,area:2.80,unitPrice:0,note:''},
  1503. {name:'抽屉',width:0,height:0,area:3,unitPrice:0,note:''},
  1504. {name:'金属拉手',width:0,height:0,area:4,unitPrice:0,note:''},
  1505. {name:'自弹器',width:0,height:0,area:5,unitPrice:0,note:''},
  1506. {name:'灯带',width:0,height:0,area:3.5,unitPrice:0,note:''},
  1507. {name:'变压器+开关',width:0,height:0,area:1,unitPrice:0,note:''},
  1508. {name:'见光板',width:0.45,height:2.4,area:1.08,unitPrice:0,note:''},
  1509. {name:'铰链',width:0,height:0,area:20,unitPrice:0,note:''}],
  1510. // 4: 厨房 — 橱柜特殊计价(地柜/吊柜/台面分别计价)
  1511. [{name:'地柜',width:4.18,height:0.85,area:4.18,unitPrice:0,note:''},
  1512. {name:'吊柜',width:3.21,height:0.7,area:3.21,unitPrice:0,note:''},
  1513. {name:'台面',width:3.21,height:0,area:3.21,unitPrice:0,note:''},
  1514. {name:'见光板×2',width:0.6,height:2.5,area:1.50,unitPrice:0,note:''},
  1515. {name:'碗篮',width:0,height:0,area:1,unitPrice:0,note:''},
  1516. {name:'锅篮',width:0,height:0,area:1,unitPrice:0,note:''},
  1517. {name:'自弹器',width:0,height:0,area:4,unitPrice:0,note:''},
  1518. {name:'台下盆',width:0,height:0,area:1,unitPrice:0,note:''},
  1519. {name:'包管',width:0,height:0,area:1,unitPrice:0,note:''}],
  1520. // 5: 阳台柜
  1521. [{name:'柜体',width:1.5,height:2.6,area:1.50,unitPrice:0,note:''},
  1522. {name:'板门补差价',width:1.5,height:2.6,area:1.50,unitPrice:0,note:''},
  1523. {name:'自弹器',width:0,height:0,area:3,unitPrice:0,note:''},
  1524. {name:'见光板',width:0.6,height:1.0,area:0.60,unitPrice:0,note:''},
  1525. {name:'铰链',width:0,height:0,area:9,unitPrice:0,note:''}]
  1526. ];
  1527. let qRooms = [];
  1528. let qExtras = [];
  1529. // ── 柜体投影面积基础单价表(所有价格从规则配置读取,此处仅为映射)──
  1530. const Q_CABINET_BASE_PRICE = {
  1531. // 所有柜体类型统一使用规则配置中的"柜体投影单价"
  1532. '主卧衣柜': 0,
  1533. '次卧衣柜': 0,
  1534. '儿童房衣柜': 0,
  1535. '老人房衣柜': 0,
  1536. '客厅电视柜': 0,
  1537. '厨房橱柜': 0,
  1538. '开放式厨房': 0,
  1539. '餐厅餐边柜': 0,
  1540. '书房书柜': 0,
  1541. '玄关鞋柜': 0,
  1542. '阳台柜': 0,
  1543. '衣帽间': 0,
  1544. '储物柜': 0,
  1545. '酒柜': 0,
  1546. '浴室柜': 0,
  1547. '榻榻米': 0,
  1548. // 旧名称兼容
  1549. '玄关柜': 0,
  1550. '衣柜': 0,
  1551. '电视柜': 0,
  1552. '餐边柜': 0,
  1553. '橱柜': 0,
  1554. '书柜': 0,
  1555. '鞋柜': 0,
  1556. '洗衣机柜': 0,
  1557. '展示柜': 0,
  1558. };
  1559. // 门板类型补差单价表(所有价格从规则配置读取,此处仅为映射)
  1560. const Q_DOOR_DIFF_PRICE = {
  1561. '普通平板': 0,
  1562. '风影门板': 0,
  1563. 'PET门板': 0,
  1564. '实木贴皮': 0,
  1565. '烤漆门板': 0,
  1566. '吸塑门板': 0,
  1567. '玻璃门': 0,
  1568. '百叶门': 0,
  1569. '无门板': 0,
  1570. '铝格栅板': 0,
  1571. };
  1572. // 根据柜体类型获取默认投影单价
  1573. function qGetCabinetBasePrice(cabinetType){
  1574. // 柜体投影单价:统一从规则配置读取"柜体投影单价"
  1575. return qGetRulePrice('柜体投影单价', 0);
  1576. }
  1577. // 根据门板类型获取补差单价(从规则配置读取,规则中无配置时使用内置默认)
  1578. function qGetDoorDiffPrice(doorType){
  1579. if(doorType === '铝格栅板' || doorType === '铝格栅门板') return qGetRulePrice('铝格栅门板单价', Q_DOOR_DEFAULT_PRICES['铝格栅门板单价']);
  1580. if(doorType === 'PET门板') return qGetRulePrice('PET门板单价', Q_DOOR_DEFAULT_PRICES['PET门板单价']);
  1581. if(doorType === '风影门板') return qGetRulePrice('烤漆门板单价', Q_DOOR_DEFAULT_PRICES['烤漆门板单价']);
  1582. if(doorType === '烤漆门板') return qGetRulePrice('烤漆门板单价', Q_DOOR_DEFAULT_PRICES['烤漆门板单价']);
  1583. if(doorType === '实木贴皮') return qGetRulePrice('实木门板单价', Q_DOOR_DEFAULT_PRICES['实木门板单价']);
  1584. if(doorType === '吸塑门板') return qGetRulePrice('吸塑门板单价', Q_DOOR_DEFAULT_PRICES['吸塑门板单价']);
  1585. if(doorType === '玻璃门') return qGetRulePrice('玻璃门升级', 280);
  1586. if(doorType === '无门板') return 0;
  1587. return qGetRulePrice('标配门板单价', Q_DOOR_DEFAULT_PRICES['标配门板单价']);
  1588. }
  1589. // ── 空间明细子项(参照万骊全屋定制预算表结构)──
  1590. // 每个房间的子项列表: {name, width, height, area, unitPrice, total, note, isSubtotal, isDiscounted}
  1591. let qRoomItems = []; // qRoomItems[roomIdx] = [{name,width,height,area,unitPrice,total,note}, ...]
  1592. let qDiscountRate = 1.00; // 默认原价
  1593. let qFloorHeight = 2.5; // 层高预填2.5m,仅作为参考,不强制等于柜体高度
  1594. // 从Excel提取的标准子项模板(各空间通用配件库)— 所有单价初始为0,用户配置后从规则读取
  1595. const Q_PRESET_ITEMS = {
  1596. cabinet: {name:'柜体', width:0,height:0,area:0,unitPrice:0,total:0,note:''},
  1597. doorDiff: {name:'板门补差价', width:0,height:0,area:0,unitPrice:0,total:0,note:''},
  1598. lightBelt: {name:'灯带', width:0,height:0,area:0,unitPrice:0,total:0,note:'',qtyType:'length'},
  1599. transformer:{name:'变压器+开关',width:0,height:0,area:0,unitPrice:0,total:0,note:'',qtyType:'count'},
  1600. selfClose: {name:'自弹器', width:0,height:0,area:0,unitPrice:0,total:0,note:'',qtyType:'count'},
  1601. visiblePanel:{name:'见光板', width:0.5,height:2.5,area:0,unitPrice:0,total:0,note:''},
  1602. hinge: {name:'铰链', width:0,height:0,area:0,unitPrice:0,total:0,note:'',qtyType:'count'},
  1603. drawer: {name:'抽屉', width:0,height:0,area:0,unitPrice:0,total:0,note:'',qtyType:'count'},
  1604. handle: {name:'金属拉手', width:0,height:0,area:0,unitPrice:0,total:0,note:'',qtyType:'count'},
  1605. rod: {name:'衣杆', width:0,height:0,area:0,unitPrice:0,total:0,note:'',qtyType:'count'},
  1606. glassDr: {name:'玻璃门补差价',width:0,height:0,area:0,unitPrice:0,total:0,note:''},
  1607. };
  1608. // 数量类配件默认数量
  1609. const Q_ITEM_DEFAULT_QTY = {自弹器:2,铰链:8,抽屉:0,金属拉手:0,衣杆:0,灯带:0,'变压器+开关':0};
  1610. let qAiProcessed = false;
  1611. let qAiAnalyzing = false; // 是否正在AI解析中
  1612. let qUploadedDrawing = null; // File object (户型图) — single-sheet mode
  1613. let qUploadedDrawingUrl = null; // Object URL for preview
  1614. let qUploadedCAD = null; // File object (CAD图纸) — single-sheet mode
  1615. let qUploadedCADUrl = null; // Object URL for CAD preview
  1616. let qUploadedRequirement = null;
  1617. let qUploadedRequirementUrl = null; // 需求记录图片URL
  1618. let qCurrentFloatRatio = 0.15; // 每个报价单独立的浮动率
  1619. let qRenderEnabled = false; // 空间效果图结构识别开关
  1620. let qRenderFiles = []; // 已上传的效果图文件
  1621. let qRenderResults = null; // AI识别结果
  1622. let qAiEditMode = false; // AI分析卡片编辑模式
  1623. let qAiEditableData = null; // 可编辑的分析数据缓存(保存用户手动修改)
  1624. let qActivityEnabled = false; // 活动总开关
  1625. let qActivityNextId = 1; // 自增ID计数器
  1626. let qCustomerTier = 'standard'; // 当前客户分层:price(看重价格) / standard(标准) / quality(品质导向)
  1627. let qMultiMode = false; // 多图纸合并模式开关
  1628. let qSheets = []; // 多图纸模式下的图纸列表 [{id,name,drawing,drawingUrl,cad,cadUrl,status,rooms,analysisData,totalArea}]
  1629. let qMergedAnalysisData = null;// 合并后的分析数据
  1630. let qSheetNextId = 1; // 图纸自增ID
  1631. const Q_CUSTOMER_TIERS = {
  1632. price: {
  1633. label: '看重价格型',
  1634. icon: '🏷️',
  1635. color: '#C05621',
  1636. bgColor: '#FFFAF0',
  1637. borderColor: '#FBD38D',
  1638. badgeBg: '#FEEBC8',
  1639. autoEnableActivity: true,
  1640. doorType: '标配',
  1641. hardware: '经济实用款',
  1642. service: '标准测量+安装',
  1643. warranty: '3年质保',
  1644. gifts: '送基础五金配件',
  1645. panelLabel: '进口爱格板(加价升级)',
  1646. discounts: [
  1647. { id:'p1', label:'投影面积85折', rate:0.85, deletable:false },
  1648. { id:'p2', label:'投影面积8折特惠', rate:0.80, deletable:true },
  1649. { id:'p3', label:'团购78折', rate:0.78, deletable:true }
  1650. ]
  1651. },
  1652. standard: {
  1653. label: '标准客户',
  1654. icon: '🏠',
  1655. color: '#2B6CB0',
  1656. bgColor: '#EBF8FF',
  1657. borderColor: '#3182CE',
  1658. badgeBg: '#BEE3F8',
  1659. autoEnableActivity: false,
  1660. doorType: '标配门板',
  1661. hardware: '国产优质铰链/拉手',
  1662. service: '标准测量安装',
  1663. warranty: '5年质保',
  1664. gifts: '可参与活动优惠',
  1665. panelLabel: '国产E0级颗粒板',
  1666. discounts: [
  1667. { id:'s1', label:'原价', rate:1.00, deletable:false },
  1668. { id:'s2', label:'投影面积9折', rate:0.90, deletable:true },
  1669. { id:'s3', label:'投影面积95折', rate:0.95, deletable:true }
  1670. ]
  1671. },
  1672. quality: {
  1673. label: '品质导向型',
  1674. icon: '💎',
  1675. color: '#6B46C1',
  1676. bgColor: '#FAF5FF',
  1677. borderColor: '#9F7AEA',
  1678. badgeBg: '#E9D8FD',
  1679. autoEnableActivity: false,
  1680. doorType: '烤漆/吸塑高端门板',
  1681. hardware: '进口百隆/海蒂诗五金',
  1682. service: 'VIP专属设计师+金牌安装',
  1683. warranty: '10年质保+终身维护',
  1684. gifts: '赠灯带+抽屉升级包',
  1685. panelLabel: '进口爱格板/可丽芙(推荐)',
  1686. premiumRate: 1.15,
  1687. discounts: [
  1688. { id:'q1', label:'投影面积95折(保利润)', rate:0.95, deletable:false },
  1689. { id:'q2', label:'原价(高端不打折)', rate:1.00, deletable:true },
  1690. { id:'q3', label:'VIP专享92折', rate:0.92, deletable:true }
  1691. ]
  1692. }
  1693. };
  1694. let qTierSelectedDiscount = { price:'p1', standard:'s1', quality:'q1' };
  1695. // 活动模块列表 — 核心数据结构 (动态数组,支持增删)
  1696. let qActivityModuleList = qGetDefaultActivityModules();
  1697. // 生成预设的8个模块(基于618活动PDF)
  1698. function qGetDefaultActivityModules() {
  1699. return [
  1700. { id:'01', name:'初见双享礼', tag:'进店礼', enabled:false, deletable:false, type:'coupon',
  1701. desc:'邀约进店即得「进店专属礼」;参观样板间送全屋定制抵用券',
  1702. params:{coupon:500, threshold:30000, customMode:false, customDeduction:500, customThreshold:30000} },
  1703. { id:'02', name:'订金增值礼', tag:'订金膨胀', enabled:false, deletable:false, type:'deposit',
  1704. desc:'交订膨胀 + 货款抵用券。定制品类订单(含橱柜)满3万以上可使用。',
  1705. params:{level:'5000', customMode:false, customDeposit:5000, customExpansion:8000, customCoupon:500} },
  1706. { id:'03', name:'满额抵五金', tag:'代金券', enabled:false, deletable:false, type:'coupon',
  1707. desc:'合同金额满10万元,送价值1500元五金件专用代金券(限铰链、拉手、滑轨、灯带)。',
  1708. params:{coupon:1500, threshold:100000, customMode:false, customDeduction:1500, customThreshold:100000} },
  1709. { id:'04', name:'预付享豪礼', tag:'返现/WiFi', enabled:false, deletable:false, type:'tiered_cashback',
  1710. desc:'大额预付尊享返现或智能家居特权(一个地址限参与一次)',
  1711. params:{tier1:50000,cash1:1500, tier2:100000,cash2:3000, tier3:200000,cash3:6000, customMode:false, customDeduction:1500} },
  1712. { id:'05', name:'特供精选套餐', tag:'衣柜/橱柜/移门', enabled:false, deletable:false, type:'package_select',
  1713. desc:'衣柜、橱柜、移门、精装厨房微改套餐选择',
  1714. params:{wardrobe:'none', sliding:'none', cabinet:'none', customMode:false, customDeduction:5000} },
  1715. { id:'06', name:'挚友同行礼', tag:'拼团', enabled:false, deletable:false, type:'coupon',
  1716. desc:'两人成团,各得1000元全屋定制通用券(支付尾款时抵扣,满10万可用)',
  1717. params:{coupon:1000, threshold:100000, customMode:false, customDeduction:1000, customThreshold:100000} },
  1718. { id:'07', name:'老客专属礼', tag:'赠品', enabled:false, deletable:false, type:'display_only',
  1719. desc:'历史消费≥5万的老客户专享(限本品牌柜体,可叠加其他活动)',
  1720. params:{note:'', customMode:false, customDeduction:0} },
  1721. { id:'08', name:'成品折上折', tag:'阶梯折扣', enabled:false, deletable:false, type:'tiered_discount',
  1722. desc:'定制满额,成品享阶梯折扣(不用于特价产品,仅正价产品在原价基础上折扣)',
  1723. params:{tier1:100000,disc1:72, tier2:150000,disc2:70, tier3:200000,disc3:68, customMode:false, customDeduction:0} }
  1724. ];
  1725. }
  1726. function qFormatMoney(num){var n=parseFloat(num)||0;return '¥'+n.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2});}
  1727. function qGetUnfold(room){var cfg=Q_LAYOUT_CONFIG[room.layoutStyle];return room.projectArea*(cfg?cfg.unfoldRatio:1.5);}
  1728. function qGetLayoutCoef(room){var cfg=Q_LAYOUT_CONFIG[room.layoutStyle];return cfg?cfg.layoutCoef:1.0;}
  1729. function qGetDoorPremium(room){return qGetDoorPriceByType(room.doorType);}
  1730. // ── 价格计算引擎:所有金额以qRoomItems明细中的total字段为准 ──
  1731. // 单个柜体小计:qRoomItems[ri]中所有配件行total之和(包含柜体+门板+五金+见光板+灯带等所有子项)
  1732. // ── 配件名→规则键名映射表(用于规则变更后同步单价) ──
  1733. var Q_ITEM_NAME_TO_RULEKEY = {
  1734. '柜体':'柜体投影单价','橱柜主体':'柜体投影单价','电视柜主体':'柜体投影单价',
  1735. '阳台柜主体':'柜体投影单价','书柜主体(到顶)':'柜体投影单价','鞋柜主体(到顶)':'柜体投影单价',
  1736. '浴室柜主体':'柜体投影单价','餐边柜主体(到顶)':'柜体投影单价',
  1737. '双饰面门板':'标配门板单价','防潮门板':'标配门板单价',
  1738. '风影门板':'烤漆门板单价','风影门板补差价':'烤漆门板单价','PET门板':'烤漆门板单价','烤漆门板':'烤漆门板单价',
  1739. '铝格栅门板':'铝格栅门板单价','铝格栅门板补差价':'铝格栅门板单价',
  1740. '见光板':'见光板','裤架+领带格':'裤架领带格','裤架领带格':'裤架领带格',
  1741. '抽屉':'抽屉','金属拉手':'金属拉手','拉手':'拉手','铰链(阻尼)':'铰链',
  1742. '灯带(LED)':'灯带','灯带':'灯带','衣通(衣杆)':'衣杆','衣杆':'衣杆','变压器+开关':'变压器+开关',
  1743. '石英石台面':'石材台面','开放格/操作台面':'石材台面','换鞋凳':'换鞋凳','拉篮':'拉篮','玻璃门升级':'玻璃门升级',
  1744. // 带"单价"后缀的映射(兼容旧数据)
  1745. '标配门板单价':'标配门板单价','烤漆门板单价':'烤漆门板单价','吸塑门板单价':'吸塑门板单价',
  1746. '实木门板单价':'实木门板单价','PET门板单价':'PET门板单价','见光板单价':'见光板',
  1747. '裤架领带格单价':'裤架领带格'
  1748. };
  1749. // 规则变更后,将qRoomItems中已有明细项的单价和total同步到规则配置最新值
  1750. function qSyncRoomItemsWithRules(){
  1751. if(!qRoomItems||!qRoomItems.length) return;
  1752. // 模式一(AI快速报价):柜体单价始终与系统当前材质单价保持一致(随浮动率联动),不使用手动规则配置单价,确保页面与Excel导出数据一致
  1753. if(state.quoteMode==='ai'){
  1754. var _U=qGetAiUnitPrice();
  1755. for(var _ri=0; _ri<qRoomItems.length; _ri++){
  1756. var _items=qRoomItems[_ri]; if(!_items) continue;
  1757. var _room=qRooms&&qRooms[_ri];
  1758. for(var _ii=0; _ii<_items.length; _ii++){
  1759. var _it=_items[_ii]; if(!_it) continue;
  1760. var _isKitchen=_room&&qIsKitchenCabinet(_room.cabinetType||_room.roomName);
  1761. var _isCab=_it.itemType==='cabinet'||_it.name==='柜体'||_it.billingMethod==='kitchen_item_formula';
  1762. if(!_isCab) continue;
  1763. // 厨房按总长度(计费米数)×单价计价,其余柜体按投影面积×单价计价
  1764. var _qty;
  1765. if(_isKitchen){
  1766. var _kb=qGetKitchenBillableMeters(_room);
  1767. _qty=(_kb&&_kb.billable>0)?_kb.billable:(parseFloat(_room.length)||parseFloat(_room.cabinetWidth)||0);
  1768. }else{
  1769. _qty=parseFloat(_it.area)||parseFloat(_it.width)||1;
  1770. }
  1771. if(_qty<=0) _qty=parseFloat(_it.area)||1;
  1772. var _newTotal=parseFloat((_qty*_U).toFixed(2));
  1773. if(Math.abs((_it.unitPrice||0)-_U)<0.001 && Math.abs((_it.total||0)-_newTotal)<0.01) continue;
  1774. _it.unitPrice=_U;
  1775. _it.total=_newTotal;
  1776. _it._priceEdited=false;
  1777. }
  1778. }
  1779. return;
  1780. }
  1781. for(var ri=0; ri<qRoomItems.length; ri++){
  1782. var items = qRoomItems[ri];
  1783. if(!items) continue;
  1784. for(var ii=0; ii<items.length; ii++){
  1785. var it = items[ii];
  1786. if(!it) continue;
  1787. if(it._priceEdited) continue; // 用户手动改过单价则保留
  1788. var room=qRooms&&qRooms[ri];
  1789. if(room&&qIsKitchenCabinet(room.cabinetType||room.roomName)){
  1790. var totalWidth=parseFloat(it.width)||parseFloat(it.kitchenTotalWidth)||parseFloat(room.cabinetWidth)||parseFloat(room.length)||0;
  1791. var itemHeight=parseFloat(it.height)||parseFloat(room.cabinetHeight)||parseFloat(room.height)||0;
  1792. var itemCount=parseFloat(it.count);
  1793. if(isNaN(itemCount)) itemCount=parseFloat(it.area)||0;
  1794. var kitchenCalc=qCalculateKitchenItem(it.ruleKey||it.name,totalWidth,itemHeight,itemCount);
  1795. it.name=kitchenCalc.name;it.ruleKey=kitchenCalc.name;it.unitPrice=kitchenCalc.unitPrice;it.area=kitchenCalc.quantity;it.total=kitchenCalc.total;it.note=kitchenCalc.method;it.billingMethod='kitchen_item_formula';it.kitchenTotalWidth=totalWidth;
  1796. continue;
  1797. }
  1798. // 优先使用ruleKey字段,其次通过名称映射查找
  1799. var ruleKey = it.ruleKey || Q_ITEM_NAME_TO_RULEKEY[it.name];
  1800. if(!ruleKey) continue;
  1801. var newPrice = qGetRulePrice(ruleKey, NaN);
  1802. if(isNaN(newPrice)) continue; // 规则中找不到则保留原价
  1803. if(Math.abs((it.unitPrice||0) - newPrice) < 0.001) continue; // 价格未变则跳过
  1804. it.unitPrice = newPrice;
  1805. var qty = parseFloat(it.area)||parseFloat(it.width)||1;
  1806. it.total = parseFloat((qty * newPrice).toFixed(2));
  1807. }
  1808. }
  1809. }
  1810. function qCalcRoomItemsTotal(ri){
  1811. var items = qRoomItems[ri];
  1812. if(!items || !items.length) return 0;
  1813. return items.reduce(function(s, it){ return s + (parseFloat(it.total)||0); }, 0);
  1814. }
  1815. function qCalcCabinet(room){return (room.projectArea||0) * qGetCabinetUnitPrice();}
  1816. function qCalcDoor(room){
  1817. var doorPrice = qGetDoorPriceByType(room.doorType);
  1818. return (room.projectArea||0) * doorPrice;
  1819. }
  1820. function qCalcDoorDiff(room){return qCalcDoor(room);}
  1821. function qCalcExtras(ri){return(qExtras[ri]?.items||[]).reduce((s,i)=>s+(i.price||0)*(i.qty||1),0);}
  1822. // 房间总计 = qRoomItems明细配件小计 + 手动非标增项(qExtras)
  1823. function qCalcRoomTotal(room,ri){
  1824. var itemsTotal = qCalcRoomItemsTotal(ri);
  1825. var manualExtras = qCalcExtras(ri);
  1826. return parseFloat((itemsTotal + manualExtras).toFixed(2));
  1827. }
  1828. function qCalcRoomDiscounted(room,ri){
  1829. var t = qCalcRoomTotal(room,ri);
  1830. var tier = Q_CUSTOMER_TIERS[qCustomerTier];
  1831. if (tier && tier.premiumRate) t = t * tier.premiumRate;
  1832. return parseFloat((t * qDiscountRate).toFixed(2));
  1833. }
  1834. function qCalcBaseTotal(){
  1835. // AI快速报价模式:总价取三档材质区间(默认档)中值,1852算法已去除
  1836. if (state.quoteMode === 'ai') {
  1837. var _rg = qGetAiGrandRange();
  1838. return parseFloat((((_rg.min + _rg.max) / 2)).toFixed(2));
  1839. }
  1840. if (state.currentPricingMode === 'fixedPrice') {
  1841. var totalArea = getTotalProjectArea();
  1842. var unitPrice = state.currentFixedUnitPrice || state.defaultFixedUnitPrice || 0;
  1843. var base = totalArea * unitPrice;
  1844. if (state.includeExtrasInFixed !== false) {
  1845. base += qCalcAllExtrasTotal();
  1846. }
  1847. return parseFloat(base.toFixed(2));
  1848. }
  1849. // 投影面积模式:对每个房间的qRoomItems明细配件+手动增项求和
  1850. var base = qRooms.reduce(function(s,r,i){ return s + (qIsRoomExcluded(i)?0:qCalcRoomTotal(r,i)); }, 0);
  1851. var tier = Q_CUSTOMER_TIERS[qCustomerTier];
  1852. if (tier && tier.premiumRate) {
  1853. base = base * tier.premiumRate;
  1854. }
  1855. return parseFloat(base.toFixed(2));
  1856. }
  1857. function qCalcAllExtrasTotal() {
  1858. var sum = 0;
  1859. qExtras.forEach(function(ex) {
  1860. (ex.items||[]).forEach(function(item) {
  1861. sum += (item.price||0) * (item.qty||1);
  1862. });
  1863. });
  1864. var tier = Q_CUSTOMER_TIERS[qCustomerTier];
  1865. var premiumMult = (tier && tier.premiumRate) ? tier.premiumRate : 1.0;
  1866. return sum * premiumMult;
  1867. }
  1868. function qCalcGrandTotal(){
  1869. return parseFloat((qCalcBaseTotal() * qDiscountRate).toFixed(2));
  1870. }
  1871. function qCalcRange(total){const rate=qCurrentFloatRatio;return{low:Math.round(total*(1-rate)),high:Math.round(total*(1+rate)),rate};}
  1872. // ==================== ACTIVITY PRICING ENGINE ====================
  1873. // Find a module by id
  1874. function qFindModule(id){ return qActivityModuleList.find(function(m){return m.id===id;}); }
  1875. // Calculate single module's deduction
  1876. function qCalcModuleDeduction(mod, originalPrice) {
  1877. var p = mod.params;
  1878. // --- Custom mode: use custom inputs ---
  1879. if (p.customMode) {
  1880. if (mod.type === 'deposit') {
  1881. var dedAmt = (p.customExpansion||8000) - (p.customDeposit||5000) + (p.customCoupon||0);
  1882. if (originalPrice < 30000) return null;
  1883. return {amount:dedAmt>0?dedAmt:0, desc:'自定义:交¥'+p.customDeposit.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2})+'→膨胀¥'+p.customExpansion.toLocaleString()+' + ¥'+(p.customCoupon||0)+'抵用券'};
  1884. }
  1885. if (mod.type === 'coupon') {
  1886. var ct = p.customThreshold || 0;
  1887. if (ct > 0 && originalPrice < ct) return null;
  1888. }
  1889. var ded = p.customDeduction || 0;
  1890. if (ded > 0) return {amount:ded, desc:mod.name+'(自定义抵扣¥'+ded.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2})+')'};
  1891. return {amount:0, desc:mod.name+'(自定义模式,无抵扣金额)', displayOnly:true};
  1892. }
  1893. // --- Preset mode ---
  1894. switch (mod.type) {
  1895. case 'coupon':
  1896. if (originalPrice >= p.threshold && p.coupon > 0) return {amount:p.coupon, desc:mod.name+'(满¥'+p.threshold.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2})+'可用)'};
  1897. return null;
  1898. case 'deposit':
  1899. if (originalPrice < 30000) return null;
  1900. if (p.level === 'custom') {
  1901. var dedAmt = (p.customExpansion||8000) - (p.customDeposit||5000) + (p.customCoupon||0);
  1902. return {amount:dedAmt, desc:'自定义:交¥'+p.customDeposit.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2})+'→膨胀¥'+p.customExpansion.toLocaleString()+' + ¥'+(p.customCoupon||0)+'抵用券'};
  1903. }
  1904. if (p.level === '5000') return {amount:3500, desc:'交¥5,000→膨胀¥8,000 + ¥500抵用券'};
  1905. return {amount:3800, desc:'交¥10,000→膨胀¥13,000 + ¥800抵用券'};
  1906. case 'tiered_cashback':
  1907. var cb = 0, desc = '';
  1908. if (originalPrice >= p.tier3) { cb = p.cash3; desc = '满¥'+p.tier3.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2}); }
  1909. else if (originalPrice >= p.tier2) { cb = p.cash2; desc = '满¥'+p.tier2.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2}); }
  1910. else if (originalPrice >= p.tier1) { cb = p.cash1; desc = '满¥'+p.tier1.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2}); }
  1911. if (cb > 0) return {amount:cb, desc:'预付'+desc+'返现'};
  1912. return null;
  1913. case 'package_select':
  1914. var s = 0;
  1915. if (p.wardrobe !== 'none') { var w = parseInt(p.wardrobe)===29888?5000:8000; s += w; }
  1916. if (p.sliding !== 'none') s += 2000;
  1917. if (p.cabinet !== 'none') s += 4000;
  1918. if (s > 0) return {amount:s, desc:'套餐优惠合计'};
  1919. return null;
  1920. case 'display_only':
  1921. return {amount:0, desc:p.note||'', displayOnly:true};
  1922. case 'tiered_discount':
  1923. var d = p.disc3; if (originalPrice < p.tier2) d = p.disc1; else if (originalPrice < p.tier3) d = p.disc2;
  1924. return {amount:0, desc:'成品享'+d+'折(正价产品)', displayOnly:true};
  1925. case 'custom':
  1926. if (originalPrice >= (p.threshold||0) && p.deduction > 0) return {amount:p.deduction, desc:mod.name+'(抵扣)'};
  1927. return null;
  1928. default: return null;
  1929. }
  1930. }
  1931. function qCalcActivityPrice(originalPrice) {
  1932. if (!qActivityEnabled) return { enabled: false, originalPrice, activityPrice: originalPrice, saving: 0, totalDeduction:0, deductions:[], details:[] };
  1933. var totalDeduction = 0;
  1934. var deductions = [];
  1935. var details = [];
  1936. qActivityModuleList.forEach(function(mod) {
  1937. if (!mod.enabled) return;
  1938. var d = qCalcModuleDeduction(mod, originalPrice);
  1939. if (!d) return;
  1940. if (d.displayOnly) {
  1941. details.push(d.desc);
  1942. } else if (d.amount > 0) {
  1943. totalDeduction += d.amount;
  1944. deductions.push({module:mod.id, label:mod.name, amount:d.amount, desc:d.desc});
  1945. }
  1946. });
  1947. var activityPrice = Math.max(0, originalPrice - totalDeduction);
  1948. return {
  1949. enabled: true,
  1950. originalPrice,
  1951. activityPrice,
  1952. saving: originalPrice - activityPrice,
  1953. totalDeduction,
  1954. deductions,
  1955. details
  1956. };
  1957. }
  1958. // ==================== ACTIVITY UI ENGINE ====================
  1959. // Master toggle
  1960. function qToggleActivity() {
  1961. qActivityEnabled = !qActivityEnabled;
  1962. qSyncActivityUI();
  1963. qRenderStep4();
  1964. }
  1965. // ── 折扣选项ID计数器
  1966. let _qDiscIdCounter = 100;
  1967. function qNextDiscId() { return 'custom_' + (++_qDiscIdCounter); }
  1968. // ── 渲染三档客户卡片
  1969. function qRenderTierCards() {
  1970. var container = document.getElementById('q-customer-tier-cards');
  1971. if (!container) return;
  1972. var tiers = ['price', 'standard', 'quality'];
  1973. var featureMap = {
  1974. price: ['自动启用活动优惠模块', '推荐团购/拼团方案', '强调满减和赠品策略'],
  1975. standard: ['标准定价体系计算', '灵活选择活动方案', '可手动调整折扣率'],
  1976. quality: ['推荐高端门板/五金', '自动匹配进口板材', '增值服务优先推荐']
  1977. };
  1978. var autoLinkMap = {
  1979. price: '全部活动模块',
  1980. standard: '手动选择活动',
  1981. quality: '高端配置升级包'
  1982. };
  1983. var html = '';
  1984. tiers.forEach(function(t) {
  1985. var cfg = Q_CUSTOMER_TIERS[t];
  1986. var isSelected = (qCustomerTier === t);
  1987. var selectedDiscId = qTierSelectedDiscount[t];
  1988. var selStyle = isSelected
  1989. ? 'border:2px solid ' + cfg.borderColor + ';box-shadow:0 4px 14px ' + cfg.color + '30;'
  1990. : 'border:2px solid #E2E8F0;box-shadow:none;';
  1991. var hoverBorder = cfg.borderColor;
  1992. html += '<div id="q-tier-card-' + t + '" style="' + selStyle + 'border-radius:10px;padding:12px;background:#fff;position:relative;transition:all .2s;cursor:default;" ' +
  1993. 'onmouseover="if(!this.classList.contains(\'selected\')){this.style.borderColor=\'' + hoverBorder + '\';this.style.transform=\'translateY(-2px)\';this.style.boxShadow=\'0 4px 12px ' + cfg.color + '30\'}" ' +
  1994. 'onmouseout="if(!this.classList.contains(\'selected\')){this.style.borderColor=\'#E2E8F0\';this.style.transform=\'\';this.style.boxShadow=\'\'}">' +
  1995. '<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;cursor:pointer;" onclick="qSelectCustomerTier(\'' + t + '\')">' +
  1996. '<span style="font-size:26px;">' + cfg.icon + '</span>' +
  1997. '<div style="flex:1;">' +
  1998. '<div style="font-weight:700;font-size:14px;color:' + cfg.color + ';">' + cfg.label + '</div>' +
  1999. '<div style="font-size:11px;color:' + cfg.color + ';opacity:0.8;">' + (t==='price'?'侧重性价比 / 优惠活动':t==='standard'?'常规定价 / 标准服务':'追求品质 / 高端配置') + '</div>' +
  2000. '</div>' +
  2001. '<div id="q-tier-check-' + t + '" style="width:22px;height:22px;border-radius:50%;background:' + (isSelected?cfg.color:'#CBD5E0') + ';color:#fff;display:' + (isSelected?'flex':'none') + ';align-items:center;justify-content:center;font-size:13px;font-weight:700;flex-shrink:0;">✓</div>' +
  2002. '</div>';
  2003. // Features
  2004. html += '<div style="font-size:11px;color:#718096;line-height:1.5;margin-bottom:8px;cursor:pointer;" onclick="qSelectCustomerTier(\'' + t + '\')">';
  2005. (featureMap[t]||[]).forEach(function(f){ html += '<div>✦ ' + f + '</div>'; });
  2006. html += '</div>';
  2007. // Discount options list
  2008. html += '<div style="background:' + cfg.bgColor + ';border-radius:6px;padding:8px 10px;">' +
  2009. '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;">' +
  2010. '<span style="font-size:11px;font-weight:600;color:' + cfg.color + ';">💲 折扣方案</span>' +
  2011. '<button onclick="qAddDiscountOption(\'' + t + '\')" style="font-size:10px;padding:1px 6px;border-radius:3px;border:1px solid ' + cfg.borderColor + ';background:#fff;color:' + cfg.color + ';cursor:pointer;font-weight:600;">+ 新增</button>' +
  2012. '</div>' +
  2013. '<div id="q-tier-discounts-' + t + '" style="display:flex;flex-direction:column;gap:4px;">';
  2014. cfg.discounts.forEach(function(d) {
  2015. var checked = (d.id === selectedDiscId);
  2016. html += '<div style="display:flex;align-items:center;gap:6px;' + (checked?'background:#fff;border-radius:4px;padding:2px 4px;':'') + '">' +
  2017. '<input type="radio" name="q-disc-' + t + '" id="q-disc-' + d.id + '" ' + (checked?'checked':'') +
  2018. ' onchange="qSelectDiscountOption(\'' + t + '\',\'' + d.id + '\')" style="cursor:pointer;accent-color:' + cfg.color + ';">' +
  2019. '<label for="q-disc-' + d.id + '" style="font-size:11px;cursor:pointer;flex:1;color:' + (checked?cfg.color:'#4A5568') + ';font-weight:' + (checked?'600':'400') + ';' +
  2020. '" onclick="qSelectDiscountOption(\'' + t + '\',\'' + d.id + '\')">' + d.label +
  2021. ' <span style="color:' + cfg.color + ';opacity:0.7;">(' + (d.rate===1?'无折扣':(Math.round(d.rate*1000)/10)+'折') + ')</span></label>' +
  2022. (d.deletable
  2023. ? '<button onclick="event.stopPropagation();qDeleteDiscountOption(\'' + t + '\',\'' + d.id + '\')" style="font-size:10px;width:18px;height:18px;border-radius:3px;border:none;background:transparent;color:#CBD5E0;cursor:pointer;padding:0;line-height:1;" onmouseover="this.style.color=\'#E53E3E\';this.style.background=\'#FED7D7\'" onmouseout="this.style.color=\'#CBD5E0\';this.style.background=\'transparent\'">✕</button>'
  2024. : '<span style="width:18px;display:inline-block;"></span>') +
  2025. '</div>';
  2026. });
  2027. html += '</div>'; // end discounts list
  2028. html += '<div style="margin-top:6px;font-size:10px;color:' + cfg.color + ';opacity:0.7;">' +
  2029. '🔗 联动:' + autoLinkMap[t] + '</div>';
  2030. html += '</div>'; // end discount box
  2031. html += '</div>'; // end card
  2032. });
  2033. container.innerHTML = html;
  2034. }
  2035. // ── 获取当前选中折扣
  2036. function qGetSelectedDiscount(tier) {
  2037. var cfg = Q_CUSTOMER_TIERS[tier];
  2038. if (!cfg) return null;
  2039. var selId = qTierSelectedDiscount[tier];
  2040. return cfg.discounts.find(function(d){return d.id===selId;}) || cfg.discounts[0];
  2041. }
  2042. // ── 选择客户分层
  2043. function qSelectCustomerTier(tier) {
  2044. qCustomerTier = tier;
  2045. var cfg = Q_CUSTOMER_TIERS[tier];
  2046. if (!cfg) return;
  2047. qRenderTierCards();
  2048. qUpdateTierDetail();
  2049. qApplyTierPricing();
  2050. showToast('✅ 已切换为「' + cfg.label + '」计价策略', 'success');
  2051. }
  2052. // ── 选择折扣选项
  2053. function qSelectDiscountOption(tier, discId) {
  2054. qTierSelectedDiscount[tier] = discId;
  2055. qRenderTierCards();
  2056. if (tier === qCustomerTier) {
  2057. qUpdateTierDetail();
  2058. qApplyTierPricing();
  2059. }
  2060. }
  2061. // ── 新增折扣选项
  2062. function qAddDiscountOption(tier) {
  2063. var cfg = Q_CUSTOMER_TIERS[tier];
  2064. if (!cfg) return;
  2065. qPromptDialog('请输入新折扣率(数字,例如 0.88 表示88折,1 表示原价):', '0.88', function(rateStr){
  2066. var rate = parseFloat(rateStr);
  2067. if (isNaN(rate) || rate <= 0 || rate > 1.5) { showToast('请输入有效的折扣率(0~1.5之间)', 'warning'); return; }
  2068. var defLabel = rate < 1 ? '投影面积' + (Math.round(rate*1000)/10) + '折' : '原价';
  2069. qPromptDialog('请输入折扣方案名称:', defLabel, function(labelStr){
  2070. if (!labelStr || !labelStr.trim()) return;
  2071. var newId = qNextDiscId();
  2072. cfg.discounts.push({ id:newId, label:labelStr.trim(), rate:rate, deletable:true });
  2073. qTierSelectedDiscount[tier] = newId;
  2074. qRenderTierCards();
  2075. if (tier === qCustomerTier) {
  2076. qUpdateTierDetail();
  2077. qApplyTierPricing();
  2078. }
  2079. showToast('✅ 已添加折扣方案:' + labelStr.trim(), 'success');
  2080. });
  2081. });
  2082. }
  2083. // ── 删除折扣选项
  2084. function qDeleteDiscountOption(tier, discId) {
  2085. var cfg = Q_CUSTOMER_TIERS[tier];
  2086. if (!cfg) return;
  2087. var disc = cfg.discounts.find(function(d){return d.id===discId;});
  2088. if (!disc) return;
  2089. if (!disc.deletable) { showToast('⚠️ 系统默认方案不可删除', 'info'); return; }
  2090. if (cfg.discounts.length <= 1) { showToast('⚠️ 至少保留一个折扣方案', 'info'); return; }
  2091. qConfirmDialog('确定删除折扣方案「' + disc.label + '」吗?', function(){
  2092. cfg.discounts = cfg.discounts.filter(function(d){return d.id!==discId;});
  2093. if (qTierSelectedDiscount[tier] === discId) {
  2094. qTierSelectedDiscount[tier] = cfg.discounts[0].id;
  2095. }
  2096. qRenderTierCards();
  2097. if (tier === qCustomerTier) {
  2098. qUpdateTierDetail();
  2099. qApplyTierPricing();
  2100. }
  2101. showToast('🗑️ 已删除折扣方案', 'info');
  2102. });
  2103. }
  2104. // ── 更新详情面板
  2105. function qUpdateTierDetail() {
  2106. var cfg = Q_CUSTOMER_TIERS[qCustomerTier];
  2107. if (!cfg) return;
  2108. var disc = qGetSelectedDiscount(qCustomerTier);
  2109. var rateText = disc ? (disc.rate===1?'原价':(Math.round(disc.rate*1000)/10)+'折') : '';
  2110. var badge = document.getElementById('q-customer-tier-badge');
  2111. if (badge) {
  2112. badge.textContent = cfg.label + (disc?' · '+rateText:'');
  2113. badge.style.background = cfg.badgeBg;
  2114. badge.style.color = cfg.color;
  2115. }
  2116. var title = document.getElementById('q-tier-detail-title');
  2117. var desc = document.getElementById('q-tier-detail-desc');
  2118. var panel = document.getElementById('q-tier-detail-panel');
  2119. if (title) title.textContent = '📋 ' + cfg.label + '策略(当前:' + (disc?disc.label:'') + ')';
  2120. if (panel) panel.style.borderLeftColor = cfg.color;
  2121. if (desc) {
  2122. var extraInfo = '';
  2123. if (qCustomerTier === 'price') {
  2124. extraInfo = '<span style="color:#C05621;">🎁 ' + cfg.gifts + '</span>' +
  2125. '<span style="color:#C05621;">📢 自动启用全部活动优惠</span>';
  2126. } else if (qCustomerTier === 'quality') {
  2127. extraInfo = '<span style="color:#6B46C1;">🎁 ' + cfg.gifts + '</span>' +
  2128. '<span style="color:#6B46C1;">⭐ 高端配置自动加价15%</span>';
  2129. } else {
  2130. extraInfo = '<span style="color:#2B6CB0;">💡 ' + cfg.gifts + '</span>';
  2131. }
  2132. desc.innerHTML = '<div style="display:flex;gap:20px;flex-wrap:wrap;">' +
  2133. '<span><strong>推荐板材:</strong>' + cfg.panelLabel + '</span>' +
  2134. '<span><strong>推荐门板:</strong>' + cfg.doorType + '</span>' +
  2135. '<span><strong>推荐五金:</strong>' + cfg.hardware + '</span>' +
  2136. '<span><strong>服务方案:</strong>' + cfg.service + '</span>' +
  2137. '<span><strong>质保政策:</strong>' + cfg.warranty + '</span>' +
  2138. '<span style="color:' + cfg.color + ';"><strong>当前折扣:</strong>' + (disc?disc.label:'') + '</span>' +
  2139. extraInfo +
  2140. '</div>';
  2141. }
  2142. }
  2143. // ── 应用分层定价到全局折扣率
  2144. function qApplyTierPricing() {
  2145. var cfg = Q_CUSTOMER_TIERS[qCustomerTier];
  2146. var disc = qGetSelectedDiscount(qCustomerTier);
  2147. if (!cfg || !disc) return;
  2148. qDiscountRate = disc.rate;
  2149. var discountInput = document.getElementById('q-global-discount');
  2150. if (discountInput) discountInput.value = disc.rate.toFixed(2);
  2151. if (cfg.autoEnableActivity) {
  2152. qActivityEnabled = true;
  2153. qActivityModuleList.forEach(function(m) { m.enabled = true; });
  2154. } else if (qCustomerTier === 'quality') {
  2155. qActivityEnabled = false;
  2156. qActivityModuleList.forEach(function(m) { m.enabled = false; });
  2157. }
  2158. qSyncActivityUI();
  2159. if (typeof qRenderStep3 === 'function') qRenderStep3();
  2160. if (typeof qRenderStep4 === 'function') qRenderStep4();
  2161. }
  2162. // Individual module toggle
  2163. function qToggleModule(id) {
  2164. var mod = qFindModule(id);
  2165. if (mod) { mod.enabled = !mod.enabled; }
  2166. qOnModuleChange();
  2167. }
  2168. // Toggle custom mode for a module
  2169. function qToggleCustomMode(id) {
  2170. var mod = qFindModule(id);
  2171. if (!mod) return;
  2172. mod.params.customMode = !mod.params.customMode;
  2173. qRenderActivityModules();
  2174. qUpdateDualPriceDisplay();
  2175. qUpdateActivitySummaryBar();
  2176. }
  2177. // Delete module (only deletable/custom)
  2178. function qDeleteModule(id) {
  2179. var mod = qFindModule(id);
  2180. if (!mod) return;
  2181. qConfirmDialog('确定删除"' + mod.name + '"吗?此操作不可恢复。', function(){
  2182. qActivityModuleList = qActivityModuleList.filter(function(m){return m.id !== id;});
  2183. qOnModuleChange();
  2184. });
  2185. }
  2186. // Read all module params from DOM → JS
  2187. function qOnModuleChange() {
  2188. qActivityModuleList.forEach(function(mod) {
  2189. var p = mod.params;
  2190. var el;
  2191. switch (mod.type) {
  2192. case 'coupon':
  2193. el = document.getElementById('qm-'+mod.id+'-coupon'); if (el) p.coupon = parseInt(el.value)||0;
  2194. el = document.getElementById('qm-'+mod.id+'-threshold'); if (el) p.threshold = parseInt(el.value)||0;
  2195. // Custom mode params
  2196. el = document.getElementById('qm-'+mod.id+'-cd'); if (el) p.customDeduction = parseInt(el.value)||0;
  2197. el = document.getElementById('qm-'+mod.id+'-ct'); if (el) p.customThreshold = parseInt(el.value)||0;
  2198. break;
  2199. case 'deposit':
  2200. el = document.getElementById('qm-'+mod.id+'-level'); if (el) p.level = el.value;
  2201. el = document.getElementById('qm-'+mod.id+'-cdep'); if (el) p.customDeposit = parseInt(el.value)||5000;
  2202. el = document.getElementById('qm-'+mod.id+'-cexp'); if (el) p.customExpansion = parseInt(el.value)||8000;
  2203. el = document.getElementById('qm-'+mod.id+'-ccpn'); if (el) p.customCoupon = parseInt(el.value)||500;
  2204. break;
  2205. case 'tiered_cashback':
  2206. el = document.getElementById('qm-'+mod.id+'-t1'); if (el) p.tier1 = parseInt(el.value)||50000;
  2207. el = document.getElementById('qm-'+mod.id+'-c1'); if (el) p.cash1 = parseInt(el.value)||0;
  2208. el = document.getElementById('qm-'+mod.id+'-t2'); if (el) p.tier2 = parseInt(el.value)||100000;
  2209. el = document.getElementById('qm-'+mod.id+'-c2'); if (el) p.cash2 = parseInt(el.value)||0;
  2210. el = document.getElementById('qm-'+mod.id+'-t3'); if (el) p.tier3 = parseInt(el.value)||200000;
  2211. el = document.getElementById('qm-'+mod.id+'-c3'); if (el) p.cash3 = parseInt(el.value)||0;
  2212. el = document.getElementById('qm-'+mod.id+'-cd'); if (el) p.customDeduction = parseInt(el.value)||0;
  2213. break;
  2214. case 'package_select':
  2215. el = document.getElementById('qm-'+mod.id+'-wardrobe'); if (el) p.wardrobe = el.value;
  2216. el = document.getElementById('qm-'+mod.id+'-sliding'); if (el) p.sliding = el.value;
  2217. el = document.getElementById('qm-'+mod.id+'-cabinet'); if (el) p.cabinet = el.value;
  2218. el = document.getElementById('qm-'+mod.id+'-cd'); if (el) p.customDeduction = parseInt(el.value)||0;
  2219. break;
  2220. case 'tiered_discount':
  2221. el = document.getElementById('qm-'+mod.id+'-t1'); if (el) p.tier1 = parseInt(el.value)||0;
  2222. el = document.getElementById('qm-'+mod.id+'-d1'); if (el) p.disc1 = parseInt(el.value)||72;
  2223. el = document.getElementById('qm-'+mod.id+'-t2'); if (el) p.tier2 = parseInt(el.value)||0;
  2224. el = document.getElementById('qm-'+mod.id+'-d2'); if (el) p.disc2 = parseInt(el.value)||70;
  2225. el = document.getElementById('qm-'+mod.id+'-t3'); if (el) p.tier3 = parseInt(el.value)||0;
  2226. el = document.getElementById('qm-'+mod.id+'-d3'); if (el) p.disc3 = parseInt(el.value)||68;
  2227. el = document.getElementById('qm-'+mod.id+'-cd'); if (el) p.customDeduction = parseInt(el.value)||0;
  2228. break;
  2229. case 'display_only':
  2230. el = document.getElementById('qm-'+mod.id+'-cd'); if (el) p.customDeduction = parseInt(el.value)||0;
  2231. break;
  2232. case 'custom':
  2233. el = document.getElementById('qm-'+mod.id+'-deduction'); if (el) p.deduction = parseInt(el.value)||0;
  2234. el = document.getElementById('qm-'+mod.id+'-threshold'); if (el) p.threshold = parseInt(el.value)||0;
  2235. break;
  2236. }
  2237. });
  2238. // Re-render modules to reflect enabled state / header count
  2239. qRenderActivityModulesDOM();
  2240. qUpdateDualPriceDisplay();
  2241. qUpdateActivitySummaryBar();
  2242. // Step 5 sync
  2243. var actSection = document.getElementById('q-step5-activity-section');
  2244. if (actSection && qActivityEnabled) {
  2245. var r = qCalcActivityPrice(qCalcGrandTotal());
  2246. var el1 = document.getElementById('q-step5-activity-price');
  2247. var el2 = document.getElementById('q-step5-activity-saving');
  2248. if (actSection) actSection.style.display = 'block';
  2249. if (el1) el1.textContent = qFormatMoney(r.activityPrice);
  2250. if (el2) el2.textContent = '💰 比原价省 ' + qFormatMoney(r.saving);
  2251. }
  2252. }
  2253. // Render all modules into the container — full rebuild
  2254. function qRenderActivityModules() {
  2255. var container = document.getElementById('q-activity-modules-container');
  2256. if (!container) return;
  2257. container.innerHTML = qActivityModuleList.map(function(mod, idx) {
  2258. return qBuildModuleHTML(mod, idx);
  2259. }).join('');
  2260. // Update header count
  2261. var hdr = document.getElementById('q-activity-header-count');
  2262. if (hdr) hdr.textContent = '共 ' + qActivityModuleList.length + ' 个模块';
  2263. }
  2264. // Re-sync enabled class only (lightweight, no full rebuild)
  2265. function qRenderActivityModulesDOM() {
  2266. qActivityModuleList.forEach(function(mod) {
  2267. var el = document.getElementById('q-mod-' + mod.id);
  2268. if (el) {
  2269. if (mod.enabled) el.classList.add('enabled'); else el.classList.remove('enabled');
  2270. }
  2271. });
  2272. var hdr = document.getElementById('q-activity-header-count');
  2273. if (hdr) hdr.textContent = '共 ' + qActivityModuleList.length + ' 个模块';
  2274. }
  2275. // Build single module HTML
  2276. function qBuildModuleHTML(mod, idx) {
  2277. var h = '';
  2278. var id = mod.id;
  2279. h += '<div class="activity-module'+(mod.enabled?' enabled':'')+'" id="q-mod-'+id+'">';
  2280. h += '<div class="am-header" onclick="qToggleModule(\''+id+'\')">';
  2281. h += '<div class="am-left"><span class="am-num">'+(idx+1)+'</span><span class="am-name">'+mod.name+'</span><span class="am-tag">'+mod.tag+'</span></div>';
  2282. h += '<button class="am-delete-btn" onclick="event.stopPropagation();qDeleteModule(\''+id+'\')" title="删除模块">🗑</button>';
  2283. h += '<div class="am-toggle"></div></div>';
  2284. h += '<div class="am-detail"><div class="am-desc">'+mod.desc+'</div>';
  2285. var p = mod.params;
  2286. var isCustom = p.customMode;
  2287. // Custom mode toggle button
  2288. h += '<div style="margin:8px 0;display:flex;align-items:center;gap:8px;">'+
  2289. '<span style="font-size:11px;color:var(--text-muted);">模式:</span>'+
  2290. '<button class="btn btn-sm '+(isCustom?'btn-primary':'btn-outline')+'" onclick="event.stopPropagation();qToggleCustomMode(\''+id+'\')" style="font-size:11px;padding:3px 10px;">'+
  2291. (isCustom?'✏️ 自定义中':'📋 预设')+
  2292. '</button>'+
  2293. '<span style="font-size:10px;color:var(--text-muted);">'+
  2294. (isCustom?'点击切回预设模式':'点击切换为自定义输入')+
  2295. '</span></div>';
  2296. if (isCustom) {
  2297. // ---- CUSTOM MODE: generic inputs ----
  2298. h += '<div style="background:#F0FFF4;border:1px dashed #68D391;border-radius:6px;padding:10px 12px;margin-bottom:6px;">';
  2299. h += '<div style="font-size:11px;color:#38A169;font-weight:600;margin-bottom:6px;">✏️ 自定义模式</div>';
  2300. if (mod.type === 'deposit') {
  2301. h += '<div class="am-rule-row">💵 订金金额:<input type="number" id="qm-'+id+'-cdep" value="'+(p.customDeposit||5000)+'" min="0" step="500" onchange="qOnModuleChange()" style="width:90px;"> 元</div>';
  2302. h += '<div class="am-rule-row">📈 膨胀后金额:<input type="number" id="qm-'+id+'-cexp" value="'+(p.customExpansion||8000)+'" min="0" step="500" onchange="qOnModuleChange()" style="width:90px;"> 元</div>';
  2303. h += '<div class="am-rule-row">🎫 货款抵用券:<input type="number" id="qm-'+id+'-ccpn" value="'+(p.customCoupon||500)+'" min="0" step="100" onchange="qOnModuleChange()" style="width:90px;"> 元</div>';
  2304. h += '<div style="font-size:10px;color:var(--text-muted);margin-top:2px;">抵扣 = (膨胀后金额 − 订金金额) + 抵用券金额</div>';
  2305. } else if (mod.type === 'coupon') {
  2306. h += '<div class="am-rule-row">💰 抵扣金额:<input type="number" id="qm-'+id+'-cd" value="'+(p.customDeduction||p.coupon||500)+'" min="0" step="50" onchange="qOnModuleChange()" style="width:90px;"> 元</div>';
  2307. h += '<div class="am-rule-row">🚪 使用门槛:<input type="number" id="qm-'+id+'-ct" value="'+(p.customThreshold||p.threshold||30000)+'" min="0" step="5000" onchange="qOnModuleChange()" style="width:90px;"> 元(填0则无门槛)</div>';
  2308. } else {
  2309. h += '<div class="am-rule-row">💰 抵扣金额:<input type="number" id="qm-'+id+'-cd" value="'+(p.customDeduction||0)+'" min="0" step="50" onchange="qOnModuleChange()" style="width:90px;"> 元</div>';
  2310. }
  2311. h += '</div>';
  2312. } else {
  2313. // ---- PRESET MODE: original controls ----
  2314. switch (mod.type) {
  2315. case 'coupon':
  2316. h += '<div class="am-rule-row">🎫 抵用券:<input type="number" id="qm-'+id+'-coupon" value="'+p.coupon+'" min="0" step="50" onchange="qOnModuleChange()" onfocus="this._prev=this.value" onblur="if(this.value!==this._prev)qOnModuleChange()"> 元(满 <input type="number" id="qm-'+id+'-threshold" value="'+p.threshold+'" min="0" step="5000" onchange="qOnModuleChange()" onfocus="this._prev=this.value" onblur="if(this.value!==this._prev)qOnModuleChange()"> 元可用)</div>';
  2317. break;
  2318. case 'deposit':
  2319. h += '<div class="am-rule-row"><select id="qm-'+id+'-level" onchange="qOnModuleChange()">'+
  2320. '<option value="5000"'+(p.level==='5000'?' selected':'')+'>交订 ¥5,000 → 膨胀至 ¥8,000 + ¥500货款抵用券</option>'+
  2321. '<option value="10000"'+(p.level==='10000'?' selected':'')+'>交订 ¥10,000 → 膨胀至 ¥13,000 + ¥800货款抵用券/验房服务</option></select></div>'+
  2322. '<div class="am-rule-row" style="color:var(--text-muted);font-size:11px;">注:膨胀金额超出原订金部分可用于抵扣货款。货款抵用券于支付尾款时使用。</div>';
  2323. break;
  2324. case 'tiered_cashback':
  2325. h += '<div class="am-rule-row" style="font-size:12px;color:var(--text-muted);">'+
  2326. '预付满 <input type="number" id="qm-'+id+'-t1" value="'+p.tier1+'" min="0" step="10000" onchange="qOnModuleChange()" style="width:70px;"> 返 <input type="number" id="qm-'+id+'-c1" value="'+p.cash1+'" min="0" step="100" onchange="qOnModuleChange()" style="width:70px;"> 元'+
  2327. ' | 满 <input type="number" id="qm-'+id+'-t2" value="'+p.tier2+'" min="0" step="10000" onchange="qOnModuleChange()" style="width:70px;"> 返 <input type="number" id="qm-'+id+'-c2" value="'+p.cash2+'" min="0" step="100" onchange="qOnModuleChange()" style="width:70px;"> 元'+
  2328. ' | 满 <input type="number" id="qm-'+id+'-t3" value="'+p.tier3+'" min="0" step="10000" onchange="qOnModuleChange()" style="width:70px;"> 返 <input type="number" id="qm-'+id+'-c3" value="'+p.cash3+'" min="0" step="100" onchange="qOnModuleChange()" style="width:70px;"> 元</div>';
  2329. break;
  2330. case 'package_select':
  2331. h += '<div class="am-rule-row"><label style="font-size:12px;min-width:60px;">衣柜套餐:</label><select id="qm-'+id+'-wardrobe" onchange="qOnModuleChange()" style="width:auto;">'+
  2332. '<option value="none"'+(p.wardrobe==='none'?' selected':'')+'>不选用套餐</option>'+
  2333. '<option value="29888"'+(p.wardrobe==='29888'?' selected':'')+'>生态澳松板双饰面套餐 ¥29,888/20㎡</option>'+
  2334. '<option value="39888"'+(p.wardrobe==='39888'?' selected':'')+'>PET肤感膜套餐 ¥39,888/20㎡</option></select></div>'+
  2335. '<div class="am-rule-row"><label style="font-size:12px;min-width:60px;">移门:</label><select id="qm-'+id+'-sliding" onchange="qOnModuleChange()" style="width:auto;">'+
  2336. '<option value="none"'+(p.sliding==='none'?' selected':'')+'>不选用</option>'+
  2337. '<option value="1398"'+(p.sliding==='1398'?' selected':'')+'>B-1227 活动价 ¥1,398/㎡(原¥1,780)</option></select></div>'+
  2338. '<div class="am-rule-row"><label style="font-size:12px;min-width:60px;">橱柜套餐:</label><select id="qm-'+id+'-cabinet" onchange="qOnModuleChange()" style="width:auto;">'+
  2339. '<option value="none"'+(p.cabinet==='none'?' selected':'')+'>不选用套餐</option>'+
  2340. '<option value="11199"'+(p.cabinet==='11199'?' selected':'')+'>双饰面板 ¥11,199/套</option>'+
  2341. '<option value="13888"'+(p.cabinet==='13888'?' selected':'')+'>PET水晶板 ¥13,888/套</option>'+
  2342. '<option value="16888"'+(p.cabinet==='16888'?' selected':'')+'>PET肤感板 ¥16,888/套</option>'+
  2343. '<option value="20388"'+(p.cabinet==='20388'?' selected':'')+'>烤漆玻璃 ¥20,388/套</option></select></div>';
  2344. break;
  2345. case 'display_only':
  2346. h += '<div class="am-rule-row" style="color:var(--text-muted);font-size:11px;">'+p.note+'</div>';
  2347. break;
  2348. case 'tiered_discount':
  2349. h += '<div class="am-rule-row">定制满 <input type="number" id="qm-'+id+'-t1" value="'+p.tier1+'" min="0" step="10000" onchange="qOnModuleChange()" style="width:80px;"> → 成品 <input type="number" id="qm-'+id+'-d1" value="'+p.disc1+'" min="1" max="100" onchange="qOnModuleChange()" style="width:60px;">折</div>'+
  2350. '<div class="am-rule-row">定制满 <input type="number" id="qm-'+id+'-t2" value="'+p.tier2+'" min="0" step="10000" onchange="qOnModuleChange()" style="width:80px;"> → 成品 <input type="number" id="qm-'+id+'-d2" value="'+p.disc2+'" min="1" max="100" onchange="qOnModuleChange()" style="width:60px;">折</div>'+
  2351. '<div class="am-rule-row">定制满 <input type="number" id="qm-'+id+'-t3" value="'+p.tier3+'" min="0" step="10000" onchange="qOnModuleChange()" style="width:80px;"> → 成品 <input type="number" id="qm-'+id+'-d3" value="'+p.disc3+'" min="1" max="100" onchange="qOnModuleChange()" style="width:60px;">折</div>'+
  2352. '<div class="am-rule-row" style="font-size:11px;color:var(--text-muted);">注:仅适用于正价成品家具</div>';
  2353. break;
  2354. case 'custom':
  2355. h += '<div class="am-rule-row">💰 抵扣金额:<input type="number" id="qm-'+id+'-deduction" value="'+p.deduction+'" min="0" step="100" onchange="qOnModuleChange()" onfocus="this._prev=this.value" onblur="if(this.value!==this._prev)qOnModuleChange()"> 元'+
  2356. '(满 <input type="number" id="qm-'+id+'-threshold" value="'+(p.threshold||0)+'" min="0" step="5000" onchange="qOnModuleChange()" onfocus="this._prev=this.value" onblur="if(this.value!==this._prev)qOnModuleChange()"> 元可用,填0则无门槛)</div>';
  2357. break;
  2358. default:
  2359. h += '<div class="am-rule-row" style="color:var(--text-muted);">—</div>';
  2360. }
  2361. }
  2362. h += '</div></div>';
  2363. return h;
  2364. }
  2365. // Add custom module modal
  2366. function qShowAddModuleModal() {
  2367. var existing = document.getElementById('q-add-module-modal');
  2368. if (existing) existing.remove();
  2369. var modal = document.createElement('div');
  2370. modal.id = 'q-add-module-modal';
  2371. modal.className = 'modal-overlay';
  2372. modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:1100;';
  2373. modal.setAttribute('onclick', 'if(event.target===this)qCloseAddModuleModal()');
  2374. modal.innerHTML = '<div class="modal" style="min-width:480px;" onclick="event.stopPropagation();">'+
  2375. '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">'+
  2376. '<h3 style="margin:0;">+ 自定义添加活动模块</h3>'+
  2377. '<button onclick="qCloseAddModuleModal()" style="background:none;border:none;font-size:22px;cursor:pointer;color:var(--text-muted);">&times;</button>'+
  2378. '</div>'+
  2379. '<div class="form-grid">'+
  2380. '<div class="form-group"><label>模块名称 *</label><input type="text" id="q-new-mod-name" placeholder="如:元旦满减"></div>'+
  2381. '<div class="form-group"><label>标签</label><input type="text" id="q-new-mod-tag" placeholder="如:满减"></div>'+
  2382. '<div class="form-group" style="grid-column:1/-1;"><label>描述</label><input type="text" id="q-new-mod-desc" placeholder="如:元旦期间满额立减优惠"></div>'+
  2383. '<div class="form-group"><label>抵扣金额(元)*</label><input type="number" id="q-new-mod-deduction" value="1000" min="0" step="100"></div>'+
  2384. '<div class="form-group"><label>使用门槛(元)</label><input type="number" id="q-new-mod-threshold" value="0" min="0" step="5000"><span class="hint">填0则无门槛</span></div>'+
  2385. '</div>'+
  2386. '<div style="display:flex;gap:12px;justify-content:flex-end;margin-top:20px;">'+
  2387. '<button class="btn btn-outline" onclick="qCloseAddModuleModal()">取消</button>'+
  2388. '<button class="btn btn-primary btn-lg" onclick="qAddCustomModule()">✅ 确认添加</button>'+
  2389. '</div></div>';
  2390. document.body.appendChild(modal);
  2391. setTimeout(function(){ var el = document.getElementById('q-new-mod-name'); if (el) el.focus(); }, 100);
  2392. }
  2393. function qCloseAddModuleModal() {
  2394. var m = document.getElementById('q-add-module-modal');
  2395. if (m) m.remove();
  2396. }
  2397. function qAddCustomModule() {
  2398. var name = (document.getElementById('q-new-mod-name')?.value||'').trim();
  2399. if (!name) { showToast('请输入模块名称', 'info'); return; }
  2400. var tag = (document.getElementById('q-new-mod-tag')?.value||'').trim() || '自定义';
  2401. var desc = (document.getElementById('q-new-mod-desc')?.value||'').trim() || '用户自定义优惠';
  2402. var deduction = parseInt(document.getElementById('q-new-mod-deduction')?.value) || 0;
  2403. var threshold = parseInt(document.getElementById('q-new-mod-threshold')?.value) || 0;
  2404. var id = 'custom_' + (qActivityNextId++);
  2405. qActivityModuleList.push({
  2406. id:id, name:name, tag:tag, enabled:true, deletable:true, type:'custom',
  2407. desc:desc, params:{deduction:deduction, threshold:threshold}
  2408. });
  2409. qCloseAddModuleModal();
  2410. qRenderActivityModules();
  2411. qUpdateDualPriceDisplay();
  2412. qUpdateActivitySummaryBar();
  2413. showToast('✅ 自定义模块已添加', 'success');
  2414. }
  2415. // Summary bar
  2416. function qUpdateActivitySummaryBar() {
  2417. var summaryEl = document.getElementById('q-activity-summary-text');
  2418. if (!summaryEl) return;
  2419. var enabledMods = qActivityModuleList.filter(function(m){return m.enabled;});
  2420. if (enabledMods.length === 0) {
  2421. summaryEl.textContent = '暂无';
  2422. summaryEl.style.color = 'var(--text-muted)';
  2423. } else {
  2424. var result = qCalcActivityPrice(qCalcGrandTotal());
  2425. summaryEl.innerHTML = enabledMods.map(function(m){
  2426. return '<span style="background:#FEEBC8;color:#C05621;padding:1px 8px;border-radius:10px;font-size:11px;">'+m.name+'</span>';
  2427. }).join(' ') + ' <span style="margin-left:6px;font-weight:700;color:#C05621;">合计抵扣 ¥' + result.totalDeduction.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2}) + '</span>';
  2428. }
  2429. }
  2430. // Main activity UI sync
  2431. function qSyncActivityUI() {
  2432. var track = document.getElementById('q-activity-toggle-track');
  2433. var label = document.getElementById('q-activity-toggle-label');
  2434. var panel = document.getElementById('q-activity-panel');
  2435. var dualRow = document.getElementById('q-dual-price-row');
  2436. if (qActivityEnabled) {
  2437. if (track) track.classList.add('on');
  2438. if (label) { label.textContent = '已启用'; label.style.color = '#E53E3E'; }
  2439. if (panel) panel.style.display = 'block';
  2440. if (dualRow) dualRow.style.display = 'grid';
  2441. } else {
  2442. if (track) track.classList.remove('on');
  2443. if (label) { label.textContent = '未启用'; label.style.color = ''; }
  2444. if (panel) panel.style.display = 'none';
  2445. if (dualRow) dualRow.style.display = 'none';
  2446. }
  2447. qRenderActivityModules();
  2448. }
  2449. function qUpdateDualPriceDisplay() {
  2450. var baseTotal = qCalcBaseTotal();
  2451. var finalTotal = qCalcGrandTotal();
  2452. var originalRange = qCalcRange(finalTotal);
  2453. var activityResult = qCalcActivityPrice(finalTotal);
  2454. var origPriceEl = document.getElementById('q-original-price-display');
  2455. var origRangeEl = document.getElementById('q-original-range-display');
  2456. var actPriceEl = document.getElementById('q-activity-price-display');
  2457. var actRangeEl = document.getElementById('q-activity-range-display');
  2458. var actSavingEl = document.getElementById('q-activity-saving-display');
  2459. var actDetailEl = document.getElementById('q-activity-detail');
  2460. var origDetailEl = document.getElementById('q-original-detail');
  2461. var disc = qGetSelectedDiscount(qCustomerTier);
  2462. var discLabel = disc ? disc.label : '';
  2463. var isNoDiscount = (qDiscountRate >= 0.999);
  2464. if (origPriceEl) origPriceEl.textContent = qFormatMoney(finalTotal);
  2465. if (origRangeEl) origRangeEl.textContent = '区间 ' + qFormatMoney(originalRange.low) + ' — ' + qFormatMoney(originalRange.high);
  2466. if (origDetailEl) {
  2467. var discRow = isNoDiscount
  2468. ? '<div class="dpd-item"><span>单柜体折扣</span><span class="dpd-val" style="color:#38A169;">'+discLabel+'</span></div>'
  2469. : '<div class="dpd-item"><span>单柜体投影面积折扣</span><span class="dpd-val" style="color:#C05621;">'+discLabel+' ('+(qDiscountRate*10).toFixed(1)+'折)</span></div>';
  2470. origDetailEl.innerHTML = '<div class="dpd-item"><span>各柜体+门板+增项合计</span><span class="dpd-val">'+qFormatMoney(baseTotal)+'</span></div>' +
  2471. discRow +
  2472. '<div class="dpd-item"><span>各柜体折扣后累加</span><span class="dpd-val" style="font-weight:700;">'+qFormatMoney(finalTotal)+'</span></div>' +
  2473. '<div class="dpd-item"><span>浮动率(作用于各柜体)</span><span class="dpd-val">±' + Math.round(qCurrentFloatRatio*100) + '%</span></div>';
  2474. }
  2475. if (activityResult.enabled) {
  2476. if (actPriceEl) actPriceEl.textContent = qFormatMoney(activityResult.activityPrice);
  2477. var actLow = Math.round(activityResult.activityPrice * (1 - qCurrentFloatRatio));
  2478. var actHigh = Math.round(activityResult.activityPrice * (1 + qCurrentFloatRatio));
  2479. if (actRangeEl) actRangeEl.textContent = '区间 ' + qFormatMoney(actLow) + ' — ' + qFormatMoney(actHigh);
  2480. if (actSavingEl) actSavingEl.textContent = '💰 比折后价再省 ' + qFormatMoney(activityResult.saving);
  2481. if (actDetailEl && activityResult.deductions) {
  2482. var html = '';
  2483. activityResult.deductions.forEach(function(d) {
  2484. html += '<div class="dpd-item"><span>🔹 ' + d.label + '</span><span class="dpd-val" style="color:#E53E3E;">-¥' + d.amount.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2}) + '</span></div>';
  2485. });
  2486. activityResult.details.forEach(function(d) {
  2487. html += '<div class="dpd-item" style="color:var(--text-muted);font-size:11px;">' + d + '</div>';
  2488. });
  2489. actDetailEl.innerHTML = html || '<div class="dpd-item" style="color:var(--text-muted);">暂无活动明细</div>';
  2490. }
  2491. }
  2492. // Also update calc rules price display
  2493. qUpdateCalcRulesPriceDisplay();
  2494. }
  2495. // ==================== CALCULATION RULES ENGINE ====================
  2496. let qCalcRulesEnabled = false;
  2497. let qCalcRulesNextId = 1;
  2498. let qCalcRulesList = JSON.parse(JSON.stringify(qGetSunLaoshiRules())); // 默认加载孙老师规则
  2499. let qCalcRulesPreset = 'sun'; // 当前选中的规则集
  2500. // 预设规则集
  2501. function qGetSunLaoshiRules() {
  2502. // 孙老师报价规则 — 示例
  2503. return [
  2504. { id:'sr01', name:'柜体基础单价', field:'baseCabinetPrice', operator:'set', value:720, unit:'元/m²', desc:'孙老师柜体基准单价(覆写标准680)', deletable:false },
  2505. { id:'sr02', name:'门板标准单价', field:'baseDoorPrice', operator:'set', value:150, unit:'元/m²', desc:'门板基准单价(覆写标准120)', deletable:false },
  2506. { id:'sr03', name:'烤漆门板加价系数', field:'paintDoorMult', operator:'multiply', value:1.08, unit:'倍', desc:'烤漆门板在原价基础上×1.08', deletable:false },
  2507. { id:'sr04', name:'展开面积系数', field:'expandAreaCoef', operator:'set', value:0.88, unit:'', desc:'展开面积折算系数(默认0.85→孙氏0.88)', deletable:false },
  2508. { id:'sr05', name:'单空间投影面积折扣', field:'globalDiscount', operator:'multiply', value:0.95, unit:'折', desc:'各空间投影面积×0.95(95折)后累加', deletable:false },
  2509. { id:'sr06', name:'主卧空间额外系数', field:'masterBedroomCoef', operator:'multiply', value:1.05, unit:'倍', desc:'主卧柜体×1.05倍(工艺复杂度)', deletable:false },
  2510. { id:'sr07', name:'厨房防水加价', field:'kitchenWaterproof', operator:'add', value:800, unit:'元', desc:'厨房柜体统一加800元防水处理费', deletable:false },
  2511. { id:'sr08', name:'阳台柜补差', field:'balconySuppl', operator:'add', value:500, unit:'元', desc:'阳台柜水槽单面防水补差', deletable:false },
  2512. ];
  2513. }
  2514. function qGetLiShifuRules() {
  2515. return [
  2516. { id:'lr01', name:'柜体基础单价', field:'baseCabinetPrice', operator:'set', value:650, unit:'元/m²', desc:'李师傅柜体基准单价', deletable:false },
  2517. { id:'lr02', name:'门板标准单价', field:'baseDoorPrice', operator:'set', value:110, unit:'元/m²', desc:'门板基准单价', deletable:false },
  2518. { id:'lr03', name:'单空间投影面积折扣', field:'globalDiscount', operator:'multiply', value:0.92, unit:'折', desc:'各空间投影面积×0.92后累加', deletable:false },
  2519. ];
  2520. }
  2521. // 动态预设管理
  2522. let qCalcRulesAvailablePresets = [
  2523. { value:'custom', label:'✏️ 自定义空白', fn:null },
  2524. { value:'sun', label:'👨‍🏫 孙老师报价规则', fn:qGetSunLaoshiRules },
  2525. { value:'li', label:'👩‍💼 李师傅报价规则', fn:qGetLiShifuRules }
  2526. ];
  2527. // Rebuild select options from available presets
  2528. function qRebuildPresetSelect(keepValue) {
  2529. var sel = document.getElementById('q-calc-rules-preset');
  2530. if (!sel) return;
  2531. sel.innerHTML = qCalcRulesAvailablePresets.map(function(p) {
  2532. return '<option value="'+p.value+'"'+(p.value===keepValue?' selected':'')+'>'+p.label+'</option>';
  2533. }).join('');
  2534. if (keepValue) qCalcRulesPreset = keepValue;
  2535. }
  2536. // Delete current rule set
  2537. function qDeleteCalcRulesPreset() {
  2538. var sel = document.getElementById('q-calc-rules-preset');
  2539. if (!sel) return;
  2540. var curValue = sel.value;
  2541. var preset = qCalcRulesAvailablePresets.find(function(p){return p.value===curValue;});
  2542. if (!preset) return;
  2543. if (curValue === 'custom') { showToast('自定义空白不可删除', 'info'); return; }
  2544. qConfirmDialog('确定删除规则集"'+preset.label+'"吗?删除后可通过刷新页面恢复预设。', function(){
  2545. // Remove from available list
  2546. qCalcRulesAvailablePresets = qCalcRulesAvailablePresets.filter(function(p){return p.value!==curValue;});
  2547. // If this was the active set, switch to first available
  2548. if (qCalcRulesPreset === curValue) {
  2549. var first = qCalcRulesAvailablePresets[0];
  2550. qCalcRulesPreset = first.value;
  2551. if (first.fn) {
  2552. qCalcRulesList = JSON.parse(JSON.stringify(first.fn()));
  2553. } else {
  2554. qCalcRulesList = [];
  2555. }
  2556. }
  2557. qRebuildPresetSelect(qCalcRulesPreset);
  2558. qRenderCalcRulesList();
  2559. qOnCalcRuleParamChange();
  2560. showToast('已删除规则集:'+preset.label, 'info');
  2561. });
  2562. }
  2563. // Toggle master switch
  2564. function qToggleCalcRules() {
  2565. qCalcRulesEnabled = !qCalcRulesEnabled;
  2566. qSyncCalcRulesUI();
  2567. }
  2568. // Preset change
  2569. function qOnCalcRulesPresetChange() {
  2570. var sel = document.getElementById('q-calc-rules-preset');
  2571. if (!sel) return;
  2572. qCalcRulesPreset = sel.value;
  2573. var preset = qCalcRulesAvailablePresets.find(function(p){return p.value===sel.value;});
  2574. if (preset && preset.fn) {
  2575. qCalcRulesList = JSON.parse(JSON.stringify(preset.fn()));
  2576. } else {
  2577. qCalcRulesList = [];
  2578. }
  2579. qRenderCalcRulesList();
  2580. qOnCalcRuleParamChange();
  2581. }
  2582. // Param change handler
  2583. function qOnCalcRuleParamChange() {
  2584. var el = document.getElementById('q-calc-rules-version');
  2585. var ver = el ? el.value : '';
  2586. var label = document.getElementById('q-calc-rules-label');
  2587. var presetName = qCalcRulesPreset === 'sun' ? '孙老师报价规则' : qCalcRulesPreset === 'li' ? '李师傅报价规则' : '自定义规则';
  2588. if (label) label.textContent = presetName + (ver ? ' [' + ver + ']' : '');
  2589. qUpdateCalcRulesSummary();
  2590. qUpdateDualPriceDisplay();
  2591. }
  2592. // Render rule list into container
  2593. function qRenderCalcRulesList() {
  2594. var container = document.getElementById('q-calc-rules-list-container');
  2595. if (!container) return;
  2596. var hdr = document.getElementById('q-calc-rules-header-count');
  2597. if (hdr) hdr.textContent = '共 ' + qCalcRulesList.length + ' 条规则';
  2598. if (qCalcRulesList.length === 0) {
  2599. container.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text-muted);font-size:13px;">暂无规则,请选择预设或手动添加</div>';
  2600. return;
  2601. }
  2602. container.innerHTML = qCalcRulesList.map(function(r, idx) {
  2603. var opLabel = r.operator === 'set' ? '设为' : r.operator === 'multiply' ? '乘以' : r.operator === 'add' ? '加' : r.operator;
  2604. var valInput = '<input type="number" id="qcr-val-'+r.id+'" value="'+r.value+'" step="0.01" style="width:80px;" onchange="qOnCalcRuleValueChange(\''+r.id+'\')" onfocus="this._prev=this.value" onblur="if(this.value!==this._prev)qOnCalcRuleValueChange(\''+r.id+'\')">';
  2605. return '<div style="display:flex;align-items:center;gap:10px;padding:9px 12px;border-bottom:1px solid var(--border);font-size:13px;background:#fff;" id="qcr-row-'+r.id+'">'+
  2606. '<span style="min-width:22px;text-align:center;color:var(--text-muted);font-weight:600;">'+(idx+1)+'</span>'+
  2607. '<span style="font-weight:600;min-width:110px;">'+r.name+'</span>'+
  2608. '<span style="color:var(--accent);min-width:50px;font-size:12px;">'+opLabel+'</span>'+
  2609. valInput+
  2610. '<span style="color:var(--text-muted);font-size:12px;">'+r.unit+'</span>'+
  2611. '<span style="flex:1;color:var(--text-muted);font-size:11px;margin-left:4px;">'+r.desc+'</span>'+
  2612. '<button class="btn btn-danger btn-sm" style="padding:2px 7px;font-size:10px;" onclick="qDeleteCalcRule(\''+r.id+'\')" title="删除">🗑</button>'+
  2613. '</div>';
  2614. }).join('');
  2615. }
  2616. // Single rule value change
  2617. function qOnCalcRuleValueChange(id) {
  2618. var el = document.getElementById('qcr-val-'+id);
  2619. if (!el) return;
  2620. var rule = qCalcRulesList.find(function(r){return r.id===id;});
  2621. if (!rule) return;
  2622. rule.value = parseFloat(el.value) || 0;
  2623. qUpdateCalcRulesSummary();
  2624. qUpdateDualPriceDisplay();
  2625. }
  2626. // Delete a calc rule
  2627. function qDeleteCalcRule(id) {
  2628. qConfirmDialog('确定删除该规则吗?', function(){
  2629. qCalcRulesList = qCalcRulesList.filter(function(r){return r.id !== id;});
  2630. qRenderCalcRulesList();
  2631. qUpdateCalcRulesSummary();
  2632. qUpdateDualPriceDisplay();
  2633. });
  2634. }
  2635. // Add custom rule modal
  2636. function qShowAddCalcRuleModal() {
  2637. var existing = document.getElementById('q-add-calc-rule-modal');
  2638. if (existing) existing.remove();
  2639. var modal = document.createElement('div');
  2640. modal.id = 'q-add-calc-rule-modal';
  2641. modal.className = 'modal-overlay';
  2642. modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:1100;';
  2643. modal.setAttribute('onclick', 'if(event.target===this)qCloseAddCalcRuleModal()');
  2644. modal.innerHTML = '<div class="modal" style="min-width:520px;" onclick="event.stopPropagation();">'+
  2645. '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">'+
  2646. '<h3 style="margin:0;">+ 添加核算规则</h3>'+
  2647. '<button onclick="qCloseAddCalcRuleModal()" style="background:none;border:none;font-size:22px;cursor:pointer;color:var(--text-muted);">&times;</button>'+
  2648. '</div>'+
  2649. '<div class="form-grid">'+
  2650. '<div class="form-group"><label>规则名称 *</label><input type="text" id="q-new-cr-name" placeholder="如:台面单价覆写"></div>'+
  2651. '<div class="form-group"><label>字段标识</label><input type="text" id="q-new-cr-field" placeholder="如:countertopPrice"><span class="hint">用于内部匹配</span></div>'+
  2652. '<div class="form-group"><label>运算符</label><select id="q-new-cr-operator">'+
  2653. '<option value="set">设为(覆写)</option><option value="multiply">乘以(系数)</option><option value="add">加(固定金额)</option></select></div>'+
  2654. '<div class="form-group"><label>数值 *</label><input type="number" id="q-new-cr-value" value="0" step="0.01"></div>'+
  2655. '<div class="form-group"><label>单位</label><input type="text" id="q-new-cr-unit" placeholder="元/m²"></div>'+
  2656. '<div class="form-group" style="grid-column:1/-1;"><label>说明</label><textarea id="q-new-cr-desc" rows="2" style="width:100%;" placeholder="请填写规则说明"></textarea></div>'+
  2657. '</div>'+
  2658. '<div style="display:flex;gap:12px;justify-content:flex-end;margin-top:20px;">'+
  2659. '<button class="btn btn-outline" onclick="qCloseAddCalcRuleModal()">取消</button>'+
  2660. '<button class="btn btn-primary btn-lg" onclick="qAddCalcRule()">✅ 确认添加</button>'+
  2661. '</div></div>';
  2662. document.body.appendChild(modal);
  2663. setTimeout(function(){ var el = document.getElementById('q-new-cr-name'); if (el) el.focus(); }, 100);
  2664. }
  2665. function qCloseAddCalcRuleModal() {
  2666. var m = document.getElementById('q-add-calc-rule-modal');
  2667. if (m) m.remove();
  2668. }
  2669. function qAddCalcRule() {
  2670. var name = (document.getElementById('q-new-cr-name')?.value||'').trim();
  2671. if (!name) { showToast('请输入规则名称', 'info'); return; }
  2672. var field = (document.getElementById('q-new-cr-field')?.value||'').trim() || 'custom_'+Date.now();
  2673. var operator = document.getElementById('q-new-cr-operator')?.value || 'set';
  2674. var value = parseFloat(document.getElementById('q-new-cr-value')?.value) || 0;
  2675. var unit = (document.getElementById('q-new-cr-unit')?.value||'').trim() || '';
  2676. var desc = (document.getElementById('q-new-cr-desc')?.value||'').trim() || '用户自定义';
  2677. var id = 'cr' + (qCalcRulesNextId++);
  2678. qCalcRulesList.push({ id:id, name:name, field:field, operator:operator, value:value, unit:unit, desc:desc, deletable:true });
  2679. qCloseAddCalcRuleModal();
  2680. qRenderCalcRulesList();
  2681. qUpdateCalcRulesSummary();
  2682. qUpdateDualPriceDisplay();
  2683. showToast('✅ 核算规则已添加', 'success');
  2684. }
  2685. // Summary bar
  2686. function qUpdateCalcRulesSummary() {
  2687. var el = document.getElementById('q-calc-rules-summary-text');
  2688. if (!el) return;
  2689. if (!qCalcRulesEnabled || qCalcRulesList.length === 0) {
  2690. el.textContent = qCalcRulesEnabled ? '无规则' : '未启用';
  2691. el.style.color = 'var(--text-muted)';
  2692. return;
  2693. }
  2694. el.innerHTML = qCalcRulesList.map(function(r, idx) {
  2695. var op = r.operator === 'set' ? '=' : r.operator === 'multiply' ? '×' : '+';
  2696. return '<span style="background:#E1EFFE;color:var(--accent);padding:1px 6px;border-radius:8px;font-size:10px;">'+(idx+1)+'. '+r.name+' '+op+r.value+'</span>';
  2697. }).join(' ');
  2698. }
  2699. // Apply calc rules to the original pricing result
  2700. function qApplyCalcRules(baseResult) {
  2701. if (!qCalcRulesEnabled || qCalcRulesList.length === 0) return null;
  2702. // Clone the base result
  2703. var result = JSON.parse(JSON.stringify(baseResult));
  2704. var appliedRules = [];
  2705. qCalcRulesList.forEach(function(rule) {
  2706. var applied = false;
  2707. // Apply to each space
  2708. result.spaces.forEach(function(space, si) {
  2709. switch (rule.field) {
  2710. case 'baseCabinetPrice':
  2711. if (rule.operator === 'set') {
  2712. var old = space.cabinetPrice;
  2713. var area = space.expandArea * space.cabinetRatio;
  2714. var coef = space.fillCoefficient;
  2715. space.cabinetPrice = parseFloat((area * rule.value * coef).toFixed(2));
  2716. applied = true;
  2717. }
  2718. break;
  2719. case 'baseDoorPrice':
  2720. // Applied via adjustment later
  2721. break;
  2722. case 'paintDoorMult':
  2723. space.doorPanelDiff = parseFloat((space.doorPanelDiff * rule.value).toFixed(2));
  2724. applied = true;
  2725. break;
  2726. case 'expandAreaCoef':
  2727. if (rule.operator === 'set') {
  2728. var oldExpand = space.expandArea;
  2729. // Recalc expand area with new coef
  2730. var grossArea = 2 * (space.length + space.width) * space.height;
  2731. var netArea = Math.max(0, grossArea - 3.5);
  2732. space.expandArea = parseFloat((netArea * rule.value).toFixed(2));
  2733. // Recalc cabinet price proportionally
  2734. space.cabinetPrice = parseFloat((space.cabinetPrice * (space.expandArea / (oldExpand || 1))).toFixed(2));
  2735. applied = true;
  2736. }
  2737. break;
  2738. case 'globalDiscount':
  2739. if (rule.operator === 'multiply') {
  2740. applied = true;
  2741. }
  2742. break;
  2743. case 'masterBedroomCoef':
  2744. if (space.name === '主卧' && rule.operator === 'multiply') {
  2745. space.cabinetPrice = parseFloat((space.cabinetPrice * rule.value).toFixed(2));
  2746. applied = true;
  2747. }
  2748. break;
  2749. case 'kitchenWaterproof':
  2750. if (space.name === '厨房' && rule.operator === 'add') {
  2751. space.cabinetPrice = parseFloat((space.cabinetPrice + rule.value).toFixed(2));
  2752. applied = true;
  2753. }
  2754. break;
  2755. case 'balconySuppl':
  2756. if (space.name && space.name.includes('阳台') && rule.operator === 'add') {
  2757. space.cabinetPrice = parseFloat((space.cabinetPrice + rule.value).toFixed(2));
  2758. applied = true;
  2759. }
  2760. break;
  2761. default:
  2762. // Custom fields: try simple apply
  2763. if (rule.operator === 'multiply' && typeof space.cabinetPrice === 'number') {
  2764. // Generic multiply
  2765. }
  2766. break;
  2767. }
  2768. });
  2769. // Recalc space totals
  2770. result.spaces.forEach(function(space) {
  2771. space.spaceTotal = parseFloat((space.cabinetPrice + space.doorPanelDiff + space.extraTotal + space.nonStandard).toFixed(2));
  2772. });
  2773. if (applied) appliedRules.push(rule);
  2774. // Global discount applied at end
  2775. if (rule.field === 'globalDiscount' && rule.operator === 'multiply') {
  2776. result.spaces.forEach(function(space) {
  2777. space.cabinetPrice = parseFloat((space.cabinetPrice * rule.value).toFixed(2));
  2778. space.doorPanelDiff = parseFloat((space.doorPanelDiff * rule.value).toFixed(2));
  2779. space.spaceTotal = parseFloat((space.cabinetPrice + space.doorPanelDiff + space.extraTotal + space.nonStandard).toFixed(2));
  2780. });
  2781. appliedRules.push(rule);
  2782. }
  2783. });
  2784. // Recalc grand total
  2785. result.grandTotal = parseFloat(result.spaces.reduce(function(s, sp) { return s + sp.spaceTotal; }, 0).toFixed(2));
  2786. result.floorPrice = parseFloat((result.grandTotal * (1 - (result.floatRatio || qCurrentFloatRatio))).toFixed(2));
  2787. result.ceilingPrice = parseFloat((result.grandTotal * (1 + (result.floatRatio || qCurrentFloatRatio))).toFixed(2));
  2788. result._calcRulesApplied = true;
  2789. result._calcRulesCount = appliedRules.length;
  2790. return result;
  2791. }
  2792. // Main UI sync
  2793. function qSyncCalcRulesUI() {
  2794. var track = document.getElementById('q-calc-rules-toggle-track');
  2795. var label = document.getElementById('q-calc-rules-toggle-label');
  2796. var panel = document.getElementById('q-calc-rules-panel');
  2797. var priceRow = document.getElementById('q-calc-rules-price-row');
  2798. if (qCalcRulesEnabled) {
  2799. if (track) track.classList.add('on');
  2800. if (label) { label.textContent = '已启用'; label.style.color = 'var(--accent)'; }
  2801. if (panel) panel.style.display = 'block';
  2802. if (priceRow) priceRow.style.display = 'block';
  2803. } else {
  2804. if (track) track.classList.remove('on');
  2805. if (label) { label.textContent = '未启用'; label.style.color = ''; }
  2806. if (panel) panel.style.display = 'none';
  2807. if (priceRow) priceRow.style.display = 'none';
  2808. }
  2809. qRenderCalcRulesList();
  2810. qOnCalcRuleParamChange();
  2811. // Sync preset dropdown
  2812. var sel = document.getElementById('q-calc-rules-preset');
  2813. if (sel) sel.value = qCalcRulesPreset;
  2814. }
  2815. // Update calc rules price display
  2816. function qUpdateCalcRulesPriceDisplay() {
  2817. var priceEl = document.getElementById('q-calc-rules-price-display');
  2818. var diffEl = document.getElementById('q-calc-rules-diff-display');
  2819. var priceRow = document.getElementById('q-calc-rules-price-row');
  2820. if (!qCalcRulesEnabled) {
  2821. if (priceRow) priceRow.style.display = 'none';
  2822. return;
  2823. }
  2824. if (priceRow) priceRow.style.display = 'block';
  2825. // Build a mock base result to apply rules to
  2826. var baseTotal = qCalcGrandTotal();
  2827. var mockResult = {
  2828. spaces: qRooms.map(function(r, i) {
  2829. return {
  2830. name: r.roomName,
  2831. length: 3.5, width: 3.0, height: 2.8,
  2832. expandArea: qGetUnfold(r),
  2833. cabinetPrice: qCalcCabinet(r),
  2834. doorPanelDiff: qCalcDoorDiff(r),
  2835. extraTotal: qCalcExtras(i),
  2836. nonStandard: 0,
  2837. cabinetRatio: 0.55, fillCoefficient: 1.0,
  2838. spaceTotal: qCalcRoomTotal(r, i)
  2839. };
  2840. }),
  2841. grandTotal: baseTotal,
  2842. floatRatio: qCurrentFloatRatio
  2843. };
  2844. var result = qApplyCalcRules(mockResult);
  2845. if (result && priceEl) {
  2846. priceEl.textContent = qFormatMoney(result.grandTotal);
  2847. var diff = result.grandTotal - mockResult.grandTotal;
  2848. var diffText = diff >= 0 ? '+¥' + Math.abs(diff).toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2}) : '−¥' + Math.abs(diff).toLocaleString();
  2849. var diffColor = diff >= 0 ? 'var(--danger)' : 'var(--success)';
  2850. if (diffEl) {
  2851. diffEl.innerHTML = '与标准计价差异:<span style="font-weight:700;color:'+diffColor+';">'+diffText+'</span>(应用了'+result._calcRulesCount+'条规则)';
  2852. }
  2853. }
  2854. }
  2855. // Upload handlers
  2856. function qUpdateUploadStatusBar(){
  2857. var sd = document.getElementById('q-status-drawing');
  2858. var sc = document.getElementById('q-status-cad');
  2859. var sr = document.getElementById('q-status-req');
  2860. if(sd) sd.innerHTML = qUploadedDrawing ? '✅ 图纸:'+(qUploadedDrawing.name||'已上传') : '⬜ 图纸:未上传';
  2861. if(sd) sd.style.background = qUploadedDrawing ? '#C6F6D5' : '#EDF2F7';
  2862. if(sd) sd.style.color = qUploadedDrawing ? '#276749' : '';
  2863. if(sc) sc.innerHTML = qUploadedCAD ? '✅ CAD:'+(qUploadedCAD.name||'已上传') : '⬜ CAD:未上传';
  2864. if(sc) sc.style.background = qUploadedCAD ? '#C6F6D5' : '#EDF2F7';
  2865. if(sc) sc.style.color = qUploadedCAD ? '#276749' : '';
  2866. if(sr) sr.innerHTML = qUploadedRequirement ? '✅ 需求记录:'+(qUploadedRequirement.name||'已上传') : '⬜ 需求记录:未上传';
  2867. if(sr) sr.style.background = qUploadedRequirement ? '#C6F6D5' : '#EDF2F7';
  2868. if(sr) sr.style.color = qUploadedRequirement ? '#276749' : '';
  2869. }
  2870. function qCheckUploadReady(){
  2871. const btn=document.getElementById('q-btn-analyze');
  2872. var fileReady = false;
  2873. if(qMultiMode){
  2874. fileReady = qSheets.length > 0 && qSheets.every(function(s){ return (s.drawing||s.isMock||s.cad); });
  2875. } else {
  2876. fileReady = !!(qUploadedDrawing||qUploadedCAD||qUploadedRequirement);
  2877. }
  2878. // 层高仅为参考信息(可删除),不阻断流程;为空时下游按默认2.5m兜底
  2879. var ready = fileReady;
  2880. if(btn){
  2881. btn.disabled = !ready;
  2882. btn.style.opacity = ready ? '1' : '0.5';
  2883. btn.style.cursor = ready ? 'pointer' : 'not-allowed';
  2884. }
  2885. qUpdateUploadStatusBar();
  2886. // 上传状态变化后同步刷新解析页的「开始AI解析」动态按钮(该按钮由 qRenderStep2 渲染;
  2887. // 手动模式的上传入口在解析页,若只更新编辑器静态按钮会导致此按钮一直灰着无法点击)
  2888. if(typeof qRenderStep2==='function') qRenderStep2();
  2889. }
  2890. // ==================== 通用可缩放灯箱 ====================
  2891. function qOpenZoomableLightbox(src, title){
  2892. // Remove any existing lightbox
  2893. var existing = document.getElementById('q-zoom-lightbox');
  2894. if(existing) existing.remove();
  2895. var scale = 1, panX = 0, panY = 0;
  2896. var isDragging = false, lastX = 0, lastY = 0;
  2897. var _mousemove, _mouseup;
  2898. var overlay = document.createElement('div');
  2899. overlay.id = 'q-zoom-lightbox';
  2900. overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.93);display:flex;align-items:center;justify-content:center;z-index:2500;';
  2901. overlay.onclick = function(e){ if(e.target===overlay) overlay.remove(); };
  2902. var img = document.createElement('img');
  2903. img.src = src;
  2904. img.style.cssText = 'max-width:90vw;max-height:85vh;object-fit:contain;cursor:grab;user-select:none;border-radius:4px;box-shadow:0 8px 40px rgba(0,0,0,.5);';
  2905. img.onclick = function(e){ e.stopPropagation(); };
  2906. img.onerror = function(){
  2907. img.src = 'data:image/svg+xml,'+encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300"><rect fill="#EDF2F7" width="400" height="300"/><text x="140" y="155" font-size="16" fill="#a0aec0">图片加载失败</text></svg>');
  2908. img.style.cursor = 'default';
  2909. };
  2910. var zoomPct = document.createElement('span');
  2911. zoomPct.style.cssText = 'color:#fff;font-size:13px;min-width:44px;text-align:center;font-weight:500;';
  2912. function applyTransform(){ img.style.transform = 'translate('+panX+'px,'+panY+'px) scale('+scale+')'; zoomPct.textContent = Math.round(scale*100)+'%'; }
  2913. // Wheel zoom
  2914. overlay.addEventListener('wheel', function(e){ e.preventDefault(); e.stopPropagation(); scale += e.deltaY > 0 ? -0.2 : 0.2; scale = Math.max(0.25, Math.min(5, scale)); panX=0;panY=0; applyTransform(); }, {passive:false});
  2915. // Drag
  2916. img.addEventListener('mousedown', function(e){ if(scale<=1)return; e.preventDefault(); isDragging=true; lastX=e.clientX; lastY=e.clientY; img.style.cursor='grabbing'; });
  2917. _mousemove = function(e){ if(!isDragging)return; panX+=e.clientX-lastX; panY+=e.clientY-lastY; lastX=e.clientX; lastY=e.clientY; applyTransform(); };
  2918. _mouseup = function(){ isDragging=false; img.style.cursor=scale>1?'grab':'default'; };
  2919. document.addEventListener('mousemove', _mousemove);
  2920. document.addEventListener('mouseup', _mouseup);
  2921. // Cleanup on close
  2922. var cleanup = function(){
  2923. document.removeEventListener('mousemove', _mousemove);
  2924. document.removeEventListener('mouseup', _mouseup);
  2925. document.removeEventListener('keydown', escHandler);
  2926. };
  2927. overlay.addEventListener('remove', cleanup, {once:true});
  2928. // Backup: cleanup via MutationObserver
  2929. var obs = new MutationObserver(function(muts){
  2930. if(!document.body.contains(overlay)){ cleanup(); obs.disconnect(); }
  2931. });
  2932. obs.observe(document.body, {childList:true});
  2933. // Toolbar
  2934. var bar = document.createElement('div');
  2935. bar.style.cssText = 'position:absolute;bottom:24px;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:6px;background:rgba(0,0,0,.65);backdrop-filter:blur(12px);border-radius:28px;padding:8px 12px;z-index:10;';
  2936. function makeBtn(txt, w, h, fs, fn){
  2937. var b = document.createElement('button');
  2938. b.textContent = txt;
  2939. b.style.cssText = 'background:rgba(255,255,255,.15);color:#fff;border:none;width:'+w+'px;height:'+h+'px;border-radius:50%;font-size:'+fs+'px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s;';
  2940. b.onmouseover=function(){b.style.background='rgba(255,255,255,.3)';};
  2941. b.onmouseout=function(){b.style.background='rgba(255,255,255,.15)';};
  2942. b.onclick = function(e){ e.stopPropagation(); fn(); };
  2943. return b;
  2944. }
  2945. bar.appendChild(makeBtn('−',34,34,20, function(){ scale=Math.max(0.25,scale-0.25); panX=0;panY=0; applyTransform(); }));
  2946. bar.appendChild(makeBtn('1:1',48,34,12, function(){ scale=1;panX=0;panY=0; applyTransform(); }));
  2947. bar.appendChild(makeBtn('+',34,34,20, function(){ scale=Math.min(5,scale+0.25); panX=0;panY=0; applyTransform(); }));
  2948. bar.appendChild(zoomPct);
  2949. var closeBtn = document.createElement('button');
  2950. closeBtn.textContent = '✕';
  2951. closeBtn.style.cssText = 'position:absolute;top:20px;right:24px;width:42px;height:42px;border-radius:50%;background:rgba(255,255,255,.12);color:#fff;border:none;font-size:20px;cursor:pointer;z-index:10;transition:background .15s;';
  2952. closeBtn.onmouseover=function(){closeBtn.style.background='rgba(255,255,255,.25)';};
  2953. closeBtn.onmouseout=function(){closeBtn.style.background='rgba(255,255,255,.12)';};
  2954. closeBtn.onclick = function(e){ e.stopPropagation(); cleanup(); overlay.remove(); };
  2955. var info = document.createElement('div');
  2956. info.style.cssText = 'position:absolute;bottom:85px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,.7);color:#fff;padding:6px 18px;border-radius:16px;font-size:13px;z-index:10;white-space:nowrap;pointer-events:none;';
  2957. info.textContent = title || '';
  2958. overlay.appendChild(img);
  2959. overlay.appendChild(bar);
  2960. overlay.appendChild(closeBtn);
  2961. overlay.appendChild(info);
  2962. document.body.appendChild(overlay);
  2963. applyTransform();
  2964. var escHandler = function(e){ if(e.key==='Escape'){ cleanup(); overlay.remove(); } };
  2965. document.addEventListener('keydown', escHandler);
  2966. }
  2967. function qMakePreviewThumb(src, name){
  2968. return '<div style="position:relative;display:inline-block;margin:4px;cursor:pointer;" onclick="event.stopPropagation();qOpenZoomableLightbox(\''+src+'\',\''+name.replace(/'/g,"\\'")+'\')">'+
  2969. '<img src="'+src+'" style="width:200px;height:130px;object-fit:cover;display:block;border-radius:6px;box-shadow:0 2px 8px rgba(0,0,0,.15);border:2px solid var(--success);" onerror="this.onerror=null;this.style.background=\'#E2E8F0\';this.style.objectFit=\'contain\';this.src=\'data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22200%22 height=%22130%22%3E%3Crect fill=%22%23E2E8F0%22 width=%22200%22 height=%22130%22/%3E%3Ctext x=%22100%22 y=%2265%22 text-anchor=%22middle%22 font-size=%2214%22 fill=%22%23718096%22%3E🏠 图片预览%3C/text%3E%3Ctext x=%22100%22 y=%2285%22 text-anchor=%22middle%22 font-size=%2210%22 fill=%22%23A0AEC0%22%3E点击可放大查看%3C/text%3E%3C/svg%3E\';">'+
  2970. '<div style="position:absolute;bottom:0;left:0;right:0;background:rgba(0,0,0,.55);color:#fff;font-size:10px;padding:3px 6px;text-align:center;pointer-events:none;border-radius:0 0 6px 6px;">🔍 点击放大</div>'+
  2971. '</div>';
  2972. }
  2973. function qUseMockDrawing(){
  2974. var canvas = document.createElement('canvas');
  2975. canvas.width = 1200; canvas.height = 900;
  2976. var ctx = canvas.getContext('2d');
  2977. // 浅色背景
  2978. ctx.fillStyle = '#FFFEF7'; ctx.fillRect(0, 0, 1200, 900);
  2979. // 顶部标题区域:总计投影
  2980. ctx.fillStyle = '#E53E3E'; ctx.font = 'bold 26px "Microsoft YaHei", sans-serif';
  2981. ctx.fillText('【总计投影:39.375㎡】', 420, 50);
  2982. // 绘制外框(图纸边框)
  2983. ctx.strokeStyle = '#2D3748'; ctx.lineWidth = 3;
  2984. ctx.strokeRect(50, 70, 1100, 780);
  2985. // 内部墙体(简化户型布局)
  2986. ctx.strokeStyle = '#2D3748'; ctx.lineWidth = 2.5;
  2987. // 主分隔线
  2988. ctx.beginPath(); ctx.moveTo(450, 70); ctx.lineTo(450, 500); ctx.stroke(); // 竖线1
  2989. ctx.beginPath(); ctx.moveTo(750, 70); ctx.lineTo(750, 350); ctx.stroke(); // 竖线2
  2990. ctx.beginPath(); ctx.moveTo(50, 500); ctx.lineTo(450, 500); ctx.stroke(); // 横线1
  2991. ctx.beginPath(); ctx.moveTo(450, 350); ctx.lineTo(1150, 350); ctx.stroke(); // 横线2
  2992. ctx.beginPath(); ctx.moveTo(750, 600); ctx.lineTo(1150, 600); ctx.stroke(); // 横线3
  2993. // 门洞
  2994. ctx.strokeStyle = '#FFFEF7'; ctx.lineWidth = 8;
  2995. ctx.beginPath(); ctx.moveTo(250, 500); ctx.lineTo(320, 500); ctx.stroke(); // 次卧门
  2996. ctx.beginPath(); ctx.moveTo(450, 180); ctx.lineTo(450, 250); ctx.stroke(); // 主卧门
  2997. ctx.beginPath(); ctx.moveTo(750, 160); ctx.lineTo(750, 230); ctx.stroke(); // 衣帽间门
  2998. ctx.beginPath(); ctx.moveTo(820, 350); ctx.lineTo(900, 350); ctx.stroke(); // 厨房门
  2999. ctx.beginPath(); ctx.moveTo(1000, 600); ctx.lineTo(1080, 600); ctx.stroke(); // 阳台门
  3000. // ========== 绘制柜体(用彩色矩形表示) ==========
  3001. // 柜体颜色(淡蓝色填充,深蓝色边框)
  3002. ctx.fillStyle = '#BEE3F8'; ctx.strokeStyle = '#2B6CB0'; ctx.lineWidth = 2;
  3003. // ① 餐边柜:餐厅区域,宽2.0m(约160px),高2.5m
  3004. ctx.fillRect(1000, 90, 130, 40); ctx.strokeRect(1000, 90, 130, 40);
  3005. ctx.fillStyle = '#2D3748'; ctx.font = 'bold 16px "Microsoft YaHei", sans-serif';
  3006. ctx.fillText('餐边柜', 1020, 115);
  3007. ctx.fillStyle = '#E53E3E'; ctx.font = '14px "Microsoft YaHei", sans-serif';
  3008. ctx.fillText('2.0m宽×2.5m高=5㎡', 995, 148);
  3009. // ② 电视柜:客厅区域,宽3.45m(约220px),高2.5m
  3010. ctx.fillStyle = '#BEE3F8'; ctx.fillRect(80, 90, 230, 40); ctx.strokeRect(80, 90, 230, 40);
  3011. ctx.fillStyle = '#2D3748'; ctx.font = 'bold 16px "Microsoft YaHei", sans-serif';
  3012. ctx.fillText('电视柜', 150, 115);
  3013. ctx.fillStyle = '#E53E3E'; ctx.font = '14px "Microsoft YaHei", sans-serif';
  3014. ctx.fillText('3.45m宽×2.5m高=8.625㎡', 70, 148);
  3015. // ③ 阳台柜:阳台区域,宽1.4m(约95px),高2.5m
  3016. ctx.fillStyle = '#BEE3F8'; ctx.fillRect(900, 620, 95, 40); ctx.strokeRect(900, 620, 95, 40);
  3017. ctx.fillStyle = '#2D3748'; ctx.font = 'bold 16px "Microsoft YaHei", sans-serif';
  3018. ctx.fillText('阳台柜', 910, 645);
  3019. ctx.fillStyle = '#E53E3E'; ctx.font = '14px "Microsoft YaHei", sans-serif';
  3020. ctx.fillText('1.4m宽×2.5m高=3.5㎡', 875, 678);
  3021. // ④ 开放式厨房柜:厨房区域,宽2.5m(约165px),高1.8m
  3022. ctx.fillStyle = '#BEE3F8'; ctx.fillRect(950, 370, 165, 35); ctx.strokeRect(950, 370, 165, 35);
  3023. ctx.fillStyle = '#2D3748'; ctx.font = 'bold 15px "Microsoft YaHei", sans-serif';
  3024. ctx.fillText('开放式厨房柜', 955, 393);
  3025. ctx.fillStyle = '#E53E3E'; ctx.font = '13px "Microsoft YaHei", sans-serif';
  3026. ctx.fillText('2.5m宽×1.8m高=4.5㎡', 940, 423);
  3027. // ⑤ 主卧衣柜:主卧区域,宽1.2m(约80px),高2.5m
  3028. ctx.fillStyle = '#BEE3F8'; ctx.fillRect(470, 90, 80, 40); ctx.strokeRect(470, 90, 80, 40);
  3029. ctx.fillStyle = '#2D3748'; ctx.font = 'bold 15px "Microsoft YaHei", sans-serif';
  3030. ctx.fillText('主卧衣柜', 473, 115);
  3031. ctx.fillStyle = '#E53E3E'; ctx.font = '13px "Microsoft YaHei", sans-serif';
  3032. ctx.fillText('1.2m宽×2.5m高=3㎡', 455, 148);
  3033. // ⑥ 衣帽间:宽3.9m(约255px),高2.5m
  3034. ctx.fillStyle = '#BEE3F8'; ctx.fillRect(480, 370, 255, 40); ctx.strokeRect(480, 370, 255, 40);
  3035. ctx.fillStyle = '#2D3748'; ctx.font = 'bold 16px "Microsoft YaHei", sans-serif';
  3036. ctx.fillText('衣帽间(次卧衣帽间)', 510, 395);
  3037. ctx.fillStyle = '#E53E3E'; ctx.font = '14px "Microsoft YaHei", sans-serif';
  3038. ctx.fillText('3.9m宽×2.5m高=9.75㎡', 490, 428);
  3039. // ⑦ 次卧衣柜:次卧区域,宽2.0m(约130px),高2.5m
  3040. ctx.fillStyle = '#BEE3F8'; ctx.fillRect(80, 530, 130, 40); ctx.strokeRect(80, 530, 130, 40);
  3041. ctx.fillStyle = '#2D3748'; ctx.font = 'bold 16px "Microsoft YaHei", sans-serif';
  3042. ctx.fillText('次卧衣柜', 100, 555);
  3043. ctx.fillStyle = '#E53E3E'; ctx.font = '14px "Microsoft YaHei", sans-serif';
  3044. ctx.fillText('2.0m宽×2.5m高=5㎡', 75, 588);
  3045. // ========== 标注房间名称 ==========
  3046. ctx.fillStyle = '#718096'; ctx.font = '18px "Microsoft YaHei", sans-serif';
  3047. ctx.fillText('客厅', 220, 250);
  3048. ctx.fillText('主卧', 580, 200);
  3049. ctx.fillText('次卧', 230, 680);
  3050. ctx.fillText('餐厅', 920, 220);
  3051. ctx.fillText('厨房', 900, 500);
  3052. ctx.fillText('阳台', 850, 720);
  3053. ctx.fillText('过道', 580, 500);
  3054. // 指北针
  3055. ctx.strokeStyle = '#2D3748'; ctx.lineWidth = 2;
  3056. ctx.beginPath(); ctx.arc(120, 120, 20, 0, Math.PI * 2); ctx.stroke();
  3057. ctx.beginPath(); ctx.moveTo(120, 85); ctx.lineTo(120, 105); ctx.lineTo(112, 97); ctx.moveTo(120, 105); ctx.lineTo(128, 97); ctx.stroke();
  3058. ctx.fillStyle = '#2D3748'; ctx.font = 'bold 15px "Microsoft YaHei", sans-serif'; ctx.fillText('N', 113, 80);
  3059. // 入户门标注
  3060. ctx.strokeStyle = '#E53E3E'; ctx.lineWidth = 2; ctx.setLineDash([5, 3]);
  3061. ctx.beginPath(); ctx.arc(110, 830, 60, Math.PI * 0.85, Math.PI * 1.85); ctx.stroke();
  3062. ctx.setLineDash([]);
  3063. ctx.fillStyle = '#E53E3E'; ctx.font = '14px "Microsoft YaHei", sans-serif'; ctx.fillText('入户门', 50, 855);
  3064. // 底部尺寸标注
  3065. ctx.strokeStyle = '#718096'; ctx.lineWidth = 1;
  3066. ctx.beginPath(); ctx.moveTo(50, 870); ctx.lineTo(1150, 870); ctx.stroke();
  3067. ctx.fillStyle = '#718096'; ctx.font = '13px "Microsoft YaHei", sans-serif';
  3068. ctx.fillText('手写标注柜体清单 · 共7组柜体', 480, 880);
  3069. // 清除可能存在的旧图像分析结果
  3070. _qImageAnalysisResult = null;
  3071. qUploadedDrawing = { name:'120㎡三房两厅户型图.jpg', type:'image/jpeg', size:canvas.toDataURL().length };
  3072. if (qUploadedDrawingUrl) { URL.revokeObjectURL(qUploadedDrawingUrl); }
  3073. qUploadedDrawingUrl = canvas.toDataURL('image/jpeg', 0.9);
  3074. var zone = document.getElementById('q-upload-zone-drawing');
  3075. zone.style.borderColor = 'var(--success)'; zone.style.background = '#F0FFF4';
  3076. zone.onclick = function(){ document.getElementById('q-file-drawing').click(); };
  3077. var inner = document.getElementById('q-drawing-inner');
  3078. if(inner) inner.innerHTML = '<div class="icon">✅</div><div class="text">户型图已上传(示例)</div><div class="types">7组柜体手写标注 | 总计投影39.375㎡</div>'+qMakePreviewThumb(qUploadedDrawingUrl, '户型图:7组柜体手写标注');
  3079. document.getElementById('q-upload-files-drawing').innerHTML = '<span class="q-file-chip">📐 120㎡三房两厅户型图.jpg <span style="cursor:pointer;margin-left:4px" onclick="event.stopPropagation();qClearDrawing()">×</span></span>';
  3080. if(typeof qRunRecognitionAndApply==='function') qRunRecognitionAndApply(); // 上传后即刻推算比例并识别规则色块
  3081. // Show replace button
  3082. var rpBtn = document.getElementById('q-btn-replace-drawing');
  3083. if(rpBtn) rpBtn.style.display = 'inline-block';
  3084. qSyncUnifiedZone();
  3085. qCheckUploadReady();
  3086. }
  3087. function qUseMockRequirement(){
  3088. qUploadedRequirement={name:'客户沟通记录.txt',type:'text/plain'};
  3089. var zone = document.getElementById('q-upload-zone-req');
  3090. zone.style.borderColor='var(--success)';zone.style.background='#F0FFF4';
  3091. var inner = document.getElementById('q-req-inner');
  3092. if(inner) inner.innerHTML='<div class="icon">✅</div><div class="text">需求记录已上传(示例)</div><div class="types">客户沟通记录.txt</div>';
  3093. document.getElementById('q-upload-files-req').innerHTML='<span class="q-file-chip">📄 客户沟通记录.txt <span style="cursor:pointer;margin-left:4px" onclick="event.stopPropagation();qClearRequirement()">×</span></span>';
  3094. qCheckUploadReady();
  3095. }
  3096. function qClearDrawing(){
  3097. if(qUploadedDrawingUrl){ URL.revokeObjectURL(qUploadedDrawingUrl); qUploadedDrawingUrl=null; }
  3098. qUploadedDrawingDataUrl = null;
  3099. qUploadedDrawing=null;
  3100. const zone=document.getElementById('q-upload-zone-drawing');
  3101. zone.style.borderColor='';zone.style.background='';
  3102. zone.onclick = function(){ if(!qUploadedDrawing)document.getElementById('q-file-drawing').click(); };
  3103. var inner = document.getElementById('q-drawing-inner');
  3104. if(inner) inner.innerHTML='<div class="icon">🏠</div><div class="text">点击上传户型图</div><div class="types">支持 JPG、PNG、WEBP | 点击区域选择文件</div>';
  3105. document.getElementById('q-file-drawing').value='';
  3106. document.getElementById('q-upload-files-drawing').innerHTML='';
  3107. var rpBtn = document.getElementById('q-btn-replace-drawing');
  3108. if(rpBtn) rpBtn.style.display = 'none';
  3109. qSyncUnifiedZone();
  3110. qCheckUploadReady();
  3111. }
  3112. function qClearCAD(){
  3113. if(qUploadedCADUrl){ URL.revokeObjectURL(qUploadedCADUrl); qUploadedCADUrl=null; }
  3114. qUploadedCAD=null;
  3115. const zone=document.getElementById('q-upload-zone-cad');
  3116. zone.style.borderColor='';zone.style.background='';
  3117. zone.onclick = function(){ if(!qUploadedCAD)document.getElementById('q-file-cad').click(); };
  3118. var inner = document.getElementById('q-cad-inner');
  3119. if(inner) inner.innerHTML='<div class="icon">📏</div><div class="text">点击上传CAD图纸</div><div class="types">支持 DWG、DXF、PDF | 可单独上传,也可与户型图一起上传</div>';
  3120. document.getElementById('q-file-cad').value='';
  3121. document.getElementById('q-upload-files-cad').innerHTML='';
  3122. var rpBtn = document.getElementById('q-btn-replace-cad');
  3123. if(rpBtn) rpBtn.style.display = 'none';
  3124. qSyncUnifiedZone();
  3125. qCheckUploadReady();
  3126. }
  3127. function qClearRequirement(){
  3128. qUploadedRequirement=null;
  3129. if(qUploadedRequirementUrl){ URL.revokeObjectURL(qUploadedRequirementUrl); qUploadedRequirementUrl=null; }
  3130. const zone=document.getElementById('q-upload-zone-req');
  3131. zone.style.borderColor='';zone.style.background='';
  3132. var inner = document.getElementById('q-req-inner');
  3133. if(inner) inner.innerHTML='<div class="icon">📝</div><div class="text">点击上传客户需求记录</div><div class="types">支持图片、文本、PDF格式</div>';
  3134. document.getElementById('q-file-req').value='';
  3135. document.getElementById('q-upload-files-req').innerHTML='';
  3136. qCheckUploadReady();
  3137. }
  3138. // 全局变量:存储图片的base64 data URL(用于图像处理)
  3139. var qUploadedDrawingDataUrl = null;
  3140. function qHandleDrawing(input){
  3141. const file=input.files[0];if(!file)return;
  3142. qUploadedDrawing=file;
  3143. if(qUploadedDrawingUrl){ URL.revokeObjectURL(qUploadedDrawingUrl); }
  3144. qUploadedDrawingUrl = URL.createObjectURL(file);
  3145. // 更换图纸:清空上一张图的识别残留(规则色块结果/标注/已放置柜体),避免旧柜体混入新图识别
  3146. state.qRecogResult=null;
  3147. qAIPlacedCabinets=[];
  3148. qDimensionAnnotations=[];
  3149. qAutoDimensionDetectionDone=false;
  3150. qAutoDimensionDetectionPromise=null;
  3151. var _annLayer=document.getElementById('q-ai-annotations-layer'); if(_annLayer) _annLayer.innerHTML='';
  3152. qSyncOriginalDrawingToLayout();
  3153. // 捕获图片宽高
  3154. if(file.type.startsWith('image/')){
  3155. var _img = new Image();
  3156. _img.onload = function(){ qAIDrawingWidth = _img.width; qAIDrawingHeight = _img.height; };
  3157. _img.src = qUploadedDrawingUrl;
  3158. // 用FileReader直接将File转为base64 data URL(最可靠的方式,避免Canvas污染问题)
  3159. var _reader = new FileReader();
  3160. _reader.onload = function(){
  3161. qUploadedDrawingDataUrl = _reader.result;
  3162. qSyncOriginalDrawingToLayout();
  3163. console.log('[图片上传] base64转换成功,长度:' + (qUploadedDrawingDataUrl||'').length + ',前60字符:' + (qUploadedDrawingDataUrl||'').substring(0, 60));
  3164. };
  3165. _reader.onerror = function(){
  3166. console.error('[图片上传] FileReader读取失败');
  3167. qUploadedDrawingDataUrl = null;
  3168. };
  3169. _reader.readAsDataURL(file);
  3170. }
  3171. const zone=document.getElementById('q-upload-zone-drawing');
  3172. zone.style.borderColor='var(--success)';zone.style.background='#F0FFF4';
  3173. zone.onclick = function(){ document.getElementById('q-file-drawing').click(); };
  3174. var inner = document.getElementById('q-drawing-inner');
  3175. if(file.type.startsWith('image/')){
  3176. if(inner) inner.innerHTML='<div class="icon">✅</div><div class="text">户型图已上传</div><div class="types">'+file.name+'('+(file.size/1024).toFixed(0)+'KB)| 点击可更换</div>'+qMakePreviewThumb(qUploadedDrawingUrl, file.name);
  3177. }else{
  3178. if(inner) inner.innerHTML='<div class="icon">✅</div><div class="text">文件已上传</div><div class="types">'+file.name+'('+(file.size/1024).toFixed(0)+'KB)| 点击可更换</div>';
  3179. }
  3180. document.getElementById('q-upload-files-drawing').innerHTML='<span class="q-file-chip">📐 '+file.name+' <span style="cursor:pointer;margin-left:4px" onclick="event.stopPropagation();qClearDrawing()">×</span></span>';
  3181. var rpBtn = document.getElementById('q-btn-replace-drawing');
  3182. if(rpBtn) rpBtn.style.display = 'inline-block';
  3183. input.value = '';
  3184. qSyncUnifiedZone();
  3185. qCheckUploadReady();
  3186. if(typeof qRunRecognitionAndApply==='function') qRunRecognitionAndApply(); // 上传后即刻推算比例并识别规则色块
  3187. }
  3188. function qHandleRequirement(input){
  3189. const file=input.files[0];if(!file)return;
  3190. qUploadedRequirement=file;
  3191. if(qUploadedRequirementUrl){ URL.revokeObjectURL(qUploadedRequirementUrl); qUploadedRequirementUrl=null; }
  3192. const zone=document.getElementById('q-upload-zone-req');
  3193. zone.style.borderColor='var(--success)';zone.style.background='#F0FFF4';
  3194. var inner = document.getElementById('q-req-inner');
  3195. if(file.type.startsWith('image/')){
  3196. var url = URL.createObjectURL(file);
  3197. qUploadedRequirementUrl = url;
  3198. if(inner) inner.innerHTML='<div class="icon">✅</div><div class="text">需求记录已上传</div><div class="types">'+file.name+'</div>'+qMakePreviewThumb(url, file.name);
  3199. }else{
  3200. if(inner) inner.innerHTML='<div class="icon">✅</div><div class="text">文件已上传</div><div class="types">'+file.name+'</div>';
  3201. }
  3202. document.getElementById('q-upload-files-req').innerHTML='<span class="q-file-chip">📄 '+file.name+' <span style="cursor:pointer;margin-left:4px" onclick="event.stopPropagation();qClearRequirement()">×</span></span>';
  3203. qCheckUploadReady();
  3204. }
  3205. // ── CAD图纸上传处理 ──
  3206. function qHandleCAD(input){
  3207. const file=input.files[0];if(!file)return;
  3208. qUploadedCAD=file;
  3209. if(qUploadedCADUrl){ URL.revokeObjectURL(qUploadedCADUrl); }
  3210. const zone=document.getElementById('q-upload-zone-cad');
  3211. zone.style.borderColor='var(--success)';zone.style.background='#F0FFF4';
  3212. zone.onclick = function(){ document.getElementById('q-file-cad').click(); };
  3213. var icon = file.name.match(/\.(dwg|dxf)$/i) ? '📐' : '📄';
  3214. var inner = document.getElementById('q-cad-inner');
  3215. if(inner) inner.innerHTML='<div class="icon">✅</div><div class="text">CAD图纸已上传</div><div class="types">'+file.name+'('+(file.size/1024).toFixed(0)+'KB)| '+icon+' 点击可更换</div>';
  3216. document.getElementById('q-upload-files-cad').innerHTML='<span class="q-file-chip">'+icon+' '+file.name+' <span style="cursor:pointer;margin-left:4px" onclick="event.stopPropagation();qClearCAD()">×</span></span>';
  3217. var rpBtn = document.getElementById('q-btn-replace-cad');
  3218. if(rpBtn) rpBtn.style.display = 'inline-block';
  3219. input.value = '';
  3220. qSyncUnifiedZone();
  3221. qCheckUploadReady();
  3222. }
  3223. // ── 统一上传框:按文件类型自动路由到户型图或CAD,二者不冲突 ──
  3224. function qHandleUnified(input){
  3225. var file=input.files[0];if(!file)return;
  3226. var shim={files:[file],value:''};
  3227. var isImage=(file.type||'').startsWith('image/')||/\.(jpe?g|png|webp|gif|bmp)$/i.test(file.name);
  3228. if(isImage){ qHandleDrawing(shim); }else{ qHandleCAD(shim); }
  3229. input.value='';
  3230. qSyncUnifiedZone();
  3231. }
  3232. function qSyncUnifiedZone(){
  3233. var zone=document.getElementById('q-upload-zone-unified');
  3234. var inner=document.getElementById('q-unified-inner');
  3235. if(!zone||!inner)return;
  3236. var hasD=!!qUploadedDrawing,hasC=!!qUploadedCAD;
  3237. if(!hasD&&!hasC){
  3238. zone.style.borderColor='';zone.style.background='';
  3239. inner.innerHTML='<div class="icon">📐</div><div class="text">点击上传户型图 / CAD图纸</div><div class="types">户型图支持 JPG、PNG、WEBP;CAD支持 DWG、DXF、PDF | 两者可同时上传,互不冲突</div>';
  3240. return;
  3241. }
  3242. zone.style.borderColor='var(--success)';zone.style.background='#F0FFF4';
  3243. var html='<div class="icon">✅</div>';
  3244. if(hasD)html+='<div class="text">户型图已上传:'+(qUploadedDrawing.name||'')+'</div>';
  3245. if(hasC)html+='<div class="text">CAD已上传:'+(qUploadedCAD.name||'')+'</div>';
  3246. var tip=(hasD&&hasC)?'两类均已上传 | 点击可更换':(hasD?'还可继续上传CAD(DWG/DXF/PDF)':'还可继续上传户型图(JPG/PNG/WEBP)');
  3247. html+='<div class="types">'+tip+' | 点击选择文件</div>';
  3248. if(hasD&&qUploadedDrawingUrl&&(qUploadedDrawing.type||'').indexOf('image')===0)html+=qMakePreviewThumb(qUploadedDrawingUrl,'户型图:'+(qUploadedDrawing.name||''));
  3249. inner.innerHTML=html;
  3250. }
  3251. // ── 图纸识别标准规则:标注颜色设置(平面户型图/CAD通用) ──
  3252. // 该规则设置仅在报价模式1(AI快速粗略报价)显示,手动精准模式隐藏
  3253. function qSyncCadRuleCardVisibility(){
  3254. var card = document.getElementById('q-cad-rule-card');
  3255. if (card) card.style.display = (state.quoteMode === 'manual') ? 'none' : '';
  3256. }
  3257. var qCadRuleColor = '#EF4444';
  3258. var qCadRuleColorName = '红色';
  3259. function qSetCadRuleColor(color, name, fromPicker){
  3260. qCadRuleColor = color;
  3261. qCadRuleColorName = name || color;
  3262. // 同步下拉框选中项(绿色/红色)
  3263. var sel = document.getElementById('q-cad-rule-select');
  3264. if(sel){
  3265. var v = String(color).toLowerCase()==='#ef4444' ? 'red' : 'green';
  3266. if(sel.value!==v) sel.value=v;
  3267. }
  3268. var sum = document.getElementById('q-cad-rule-summary');
  3269. if(sum) sum.innerHTML = '当前规则:平面户型图/CAD图纸中统一用 <b style="color:'+color+';">'+qCadRuleColorName+'</b> 框标注柜体,AI抓取 <b style="color:'+color+';">'+qCadRuleColorName+'</b> 色块自动识别柜体。';
  3270. qSyncCadRuleCardVisibility();
  3271. if(typeof qRenderRuleModule1==='function') qRenderRuleModule1();
  3272. }
  3273. qSetCadRuleColor(qCadRuleColor, qCadRuleColorName);
  3274. function qUseMockCAD(){
  3275. qUploadedCAD = { name:'120㎡三房两厅施工图.dwg', type:'application/dwg', size:850000 };
  3276. var zone = document.getElementById('q-upload-zone-cad');
  3277. zone.style.borderColor = 'var(--success)'; zone.style.background = '#F0FFF4';
  3278. zone.onclick = function(){ document.getElementById('q-file-cad').click(); };
  3279. var inner = document.getElementById('q-cad-inner');
  3280. if(inner) inner.innerHTML = '<div class="icon">✅</div><div class="text">CAD图纸已上传(示例)</div><div class="types">120㎡三房两厅施工图.dwg | 📐 点击可更换</div>'+
  3281. '<div style="margin-top:8px;padding:10px 14px;background:#fff;border:1px solid var(--border);border-radius:6px;font-size:11px;text-align:left;">'+
  3282. '<div style="font-weight:600;margin-bottom:4px;">📐 CAD图纸信息(模拟)</div>'+
  3283. '<div style="color:var(--text-muted);">图层:墙体 / 门窗 / 尺寸标注 / 家具布置</div>'+
  3284. '<div style="color:var(--text-muted);">比例:1:100 | 版本:AutoCAD 2024 | 单位:毫米</div>'+
  3285. '<div style="color:var(--text-muted);">参考标注:总面宽12400mm</div>'+
  3286. '</div>';
  3287. document.getElementById('q-upload-files-cad').innerHTML='<span class="q-file-chip">📐 120㎡三房两厅施工图.dwg <span style="cursor:pointer;margin-left:4px" onclick="event.stopPropagation();qClearCAD()">×</span></span>';
  3288. var rpBtn = document.getElementById('q-btn-replace-cad');
  3289. if(rpBtn) rpBtn.style.display = 'inline-block';
  3290. qSyncUnifiedZone();
  3291. qCheckUploadReady();
  3292. }
  3293. // ──────────── 多图纸合并模式管理 ────────────
  3294. var _qActiveSheetIdForUpload = null; // 当前等待上传的sheet id
  3295. function qToggleMultiMode(){
  3296. qMultiMode = !qMultiMode;
  3297. var bg = document.getElementById('q-multi-toggle-bg');
  3298. var dot = document.getElementById('q-multi-toggle-dot');
  3299. var lbl = document.getElementById('q-multi-toggle-label');
  3300. var singleArea = document.getElementById('q-single-sheet-area');
  3301. var multiArea = document.getElementById('q-multi-sheet-area');
  3302. var statusMulti = document.getElementById('q-status-multi');
  3303. var statusDrawing = document.getElementById('q-status-drawing');
  3304. var statusCad = document.getElementById('q-status-cad');
  3305. if(qMultiMode){
  3306. if(bg){ bg.style.background = 'linear-gradient(135deg,#4299E1,#9F7AEA)'; }
  3307. if(dot){ dot.style.left = '24px'; }
  3308. if(lbl){ lbl.textContent = '已开启'; lbl.style.color = 'var(--primary)'; }
  3309. if(singleArea) singleArea.style.display = 'none';
  3310. if(multiArea) multiArea.style.display = 'block';
  3311. if(statusMulti) statusMulti.style.display = 'inline-block';
  3312. if(statusDrawing) statusDrawing.style.display = 'none';
  3313. if(statusCad) statusCad.style.display = 'none';
  3314. // 初始化第一张图纸
  3315. if(qSheets.length === 0) qAddSheet(true);
  3316. } else {
  3317. if(bg){ bg.style.background = 'var(--border)'; }
  3318. if(dot){ dot.style.left = '2px'; }
  3319. if(lbl){ lbl.textContent = '关闭'; lbl.style.color = 'var(--text-muted)'; }
  3320. if(singleArea) singleArea.style.display = 'block';
  3321. if(multiArea) multiArea.style.display = 'none';
  3322. if(statusMulti) statusMulti.style.display = 'none';
  3323. if(statusDrawing) statusDrawing.style.display = 'inline-block';
  3324. if(statusCad) statusCad.style.display = 'inline-block';
  3325. }
  3326. qCheckUploadReady();
  3327. }
  3328. function qAddSheet(silent){
  3329. var id = 'sheet_' + (qSheetNextId++);
  3330. var defaultName = '图纸' + (qSheets.length + 1);
  3331. var sheet = {
  3332. id: id,
  3333. name: defaultName,
  3334. drawing: null,
  3335. drawingUrl: null,
  3336. cad: null,
  3337. cadUrl: null,
  3338. isMock: false,
  3339. mockType: null,
  3340. status: 'empty' // empty / ready
  3341. };
  3342. qSheets.push(sheet);
  3343. qRenderSheetList();
  3344. qCheckUploadReady();
  3345. return sheet;
  3346. }
  3347. function qRemoveSheet(id){
  3348. var idx = qSheets.findIndex(function(s){return s.id===id;});
  3349. if(idx<0) return;
  3350. var s = qSheets[idx];
  3351. if(s.drawingUrl) URL.revokeObjectURL(s.drawingUrl);
  3352. if(s.cadUrl) URL.revokeObjectURL(s.cadUrl);
  3353. qSheets.splice(idx,1);
  3354. qRenderSheetList();
  3355. qCheckUploadReady();
  3356. }
  3357. function qRenameSheet(id, newName){
  3358. var s = qSheets.find(function(x){return x.id===id;});
  3359. if(!s) return;
  3360. s.name = (newName||'').trim() || s.name;
  3361. qRenderSheetList();
  3362. }
  3363. function qRenderSheetList(){
  3364. var listEl = document.getElementById('q-sheet-list');
  3365. var emptyEl = document.getElementById('q-sheet-empty-tip');
  3366. if(!listEl) return;
  3367. if(qSheets.length === 0){
  3368. listEl.innerHTML = '';
  3369. if(emptyEl) emptyEl.style.display = 'block';
  3370. var sm = document.getElementById('q-status-multi');
  3371. if(sm) sm.textContent = '📚 图纸数:0张';
  3372. sm && (sm.style.background = '#EDF2F7');
  3373. sm && (sm.style.color = 'var(--text-muted)');
  3374. return;
  3375. }
  3376. if(emptyEl) emptyEl.style.display = 'none';
  3377. var html = '';
  3378. var readyCount = 0;
  3379. var palette = ['#EBF8FF','#F0FFF4','#FFFAF0','#FAF5FF','#FFF5F5','#FFFFF0'];
  3380. var borderPalette = ['#90CDF4','#9AE6B4','#FBD38D','#D6BCFA','#FEB2B2','#FAF089'];
  3381. var iconPalette = ['🏠','🏢','🏘️','🏡','🛋️','🛏️'];
  3382. qSheets.forEach(function(s, idx){
  3383. var bg = palette[idx % palette.length];
  3384. var bd = borderPalette[idx % borderPalette.length];
  3385. var ic = iconPalette[idx % iconPalette.length];
  3386. var hasDrawing = !!(s.drawing || s.isMock);
  3387. var hasCAD = !!s.cad;
  3388. var ready = hasDrawing || hasCAD;
  3389. if(ready) readyCount++;
  3390. var thumbHtml = '';
  3391. if(s.isMock && s.mockType === 'floor1'){
  3392. thumbHtml = '<div style="width:72px;height:54px;background:linear-gradient(135deg,#EBF8FF,#BEE3F8);border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:11px;color:#2B6CB0;font-weight:600;border:1px solid #BEE3F8;">一楼示例</div>';
  3393. } else if(s.isMock && s.mockType === 'floor2'){
  3394. thumbHtml = '<div style="width:72px;height:54px;background:linear-gradient(135deg,#FAF5FF,#E9D8FD);border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:11px;color:#6B46C1;font-weight:600;border:1px solid #D6BCFA;">二楼示例</div>';
  3395. } else if(s.drawingUrl){
  3396. thumbHtml = '<img src="'+s.drawingUrl+'" style="width:72px;height:54px;object-fit:cover;border-radius:6px;border:1px solid var(--border);" />';
  3397. } else if(hasCAD){
  3398. thumbHtml = '<div style="width:72px;height:54px;background:#FFF5F5;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:22px;border:1px solid #FEB2B2;">📐</div>';
  3399. } else {
  3400. thumbHtml = '<div style="width:72px;height:54px;background:#F7FAFC;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:22px;color:#CBD5E0;border:2px dashed var(--border);">'+ic+'</div>';
  3401. }
  3402. var filesHtml = '';
  3403. if(s.isMock && s.mockType === 'floor1'){
  3404. filesHtml = '<span class="q-file-chip" style="background:#BEE3F8;color:#2C5282;">📋 示例:120㎡三房两厅一楼</span>';
  3405. } else if(s.isMock && s.mockType === 'floor2'){
  3406. filesHtml = '<span class="q-file-chip" style="background:#E9D8FD;color:#553C9A;">📋 示例:二楼户型图</span>';
  3407. } else {
  3408. if(s.drawing) filesHtml += '<span class="q-file-chip">🏠 '+s.drawing.name+' <span style="cursor:pointer;margin-left:4px" onclick="event.stopPropagation();qSheetClearDrawing(\''+s.id+'\')">×</span></span>';
  3409. if(s.cad) filesHtml += '<span class="q-file-chip">📐 '+s.cad.name+' <span style="cursor:pointer;margin-left:4px" onclick="event.stopPropagation();qSheetClearCAD(\''+s.id+'\')">×</span></span>';
  3410. }
  3411. var statusBadge = ready
  3412. ? '<span style="padding:2px 8px;border-radius:10px;background:#C6F6D5;color:#276749;font-size:11px;font-weight:600;">✓ 已就绪</span>'
  3413. : '<span style="padding:2px 8px;border-radius:10px;background:#FED7D7;color:#9B2C2C;font-size:11px;font-weight:600;">待上传</span>';
  3414. html += '<div style="background:'+bg+';border:1.5px solid '+bd+';border-radius:var(--radius);padding:12px;display:flex;gap:12px;align-items:flex-start;">'
  3415. + '<div style="flex-shrink:0;">'+thumbHtml+'</div>'
  3416. + '<div style="flex:1;min-width:0;">'
  3417. + '<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">'
  3418. + '<input type="text" value="'+s.name.replace(/"/g,'&quot;')+'" onchange="qRenameSheet(\''+s.id+'\',this.value)" style="flex:1;min-width:80px;padding:3px 8px;border:1px solid var(--border);border-radius:6px;font-size:13px;font-weight:600;background:#fff;" />'
  3419. + statusBadge
  3420. + '<button class="btn btn-outline btn-sm" style="padding:2px 8px;font-size:11px;color:#C53030;border-color:#FEB2B2;" onclick="qRemoveSheet(\''+s.id+'\')">🗑 删除</button>'
  3421. + '</div>'
  3422. + '<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:6px;">'+filesHtml+'</div>'
  3423. + '<div style="display:flex;gap:6px;flex-wrap:wrap;">'
  3424. + '<button class="btn btn-outline btn-sm" style="padding:3px 10px;font-size:11px;" onclick="qSheetTriggerUpload(\''+s.id+'\',\'drawing\')">🏠 '+(s.drawing||s.isMock?'更换户型图':'上传户型图')+'</button>'
  3425. + '<button class="btn btn-outline btn-sm" style="padding:3px 10px;font-size:11px;" onclick="qSheetTriggerUpload(\''+s.id+'\',\'cad\')">📐 '+(s.cad?'更换CAD':'上传CAD(可选)')+'</button>'
  3426. + (!ready ? '<button class="btn btn-outline btn-sm" style="padding:3px 10px;font-size:11px;background:#EBF8FF;color:#2B6CB0;border-color:#BEE3F8;" onclick="qSheetLoadMock(\''+s.id+'\',\'floor1\')">📋 示例一楼</button>' : '')
  3427. + (!ready ? '<button class="btn btn-outline btn-sm" style="padding:3px 10px;font-size:11px;background:#FAF5FF;color:#6B46C1;border-color:#D6BCFA;" onclick="qSheetLoadMock(\''+s.id+'\',\'floor2\')">📋 示例二楼</button>' : '')
  3428. + '</div>'
  3429. + '</div>'
  3430. + '</div>';
  3431. });
  3432. listEl.innerHTML = html;
  3433. var sm = document.getElementById('q-status-multi');
  3434. if(sm){
  3435. sm.textContent = '📚 图纸数:'+qSheets.length+'张('+readyCount+'张已就绪)';
  3436. sm.style.background = readyCount === qSheets.length && qSheets.length>0 ? '#C6F6D5' : '#FED7D7';
  3437. sm.style.color = readyCount === qSheets.length && qSheets.length>0 ? '#276749' : '#9B2C2C';
  3438. }
  3439. }
  3440. function qSheetTriggerUpload(id, type){
  3441. _qActiveSheetIdForUpload = id;
  3442. if(type==='drawing'){
  3443. document.getElementById('q-file-sheet-drawing').value = '';
  3444. document.getElementById('q-file-sheet-drawing').click();
  3445. } else {
  3446. document.getElementById('q-file-sheet-cad').value = '';
  3447. document.getElementById('q-file-sheet-cad').click();
  3448. }
  3449. }
  3450. function qHandleSheetDrawing(input){
  3451. var file = input.files[0]; if(!file) return;
  3452. var s = qSheets.find(function(x){return x.id===_qActiveSheetIdForUpload;});
  3453. if(!s) return;
  3454. s.isMock = false; s.mockType = null;
  3455. if(s.drawingUrl) URL.revokeObjectURL(s.drawingUrl);
  3456. s.drawing = file;
  3457. s.drawingUrl = URL.createObjectURL(file);
  3458. s.status = 'ready';
  3459. qRenderSheetList();
  3460. qCheckUploadReady();
  3461. }
  3462. function qHandleSheetCAD(input){
  3463. var file = input.files[0]; if(!file) return;
  3464. var s = qSheets.find(function(x){return x.id===_qActiveSheetIdForUpload;});
  3465. if(!s) return;
  3466. if(s.cadUrl) URL.revokeObjectURL(s.cadUrl);
  3467. s.cad = file;
  3468. s.status = 'ready';
  3469. qRenderSheetList();
  3470. qCheckUploadReady();
  3471. }
  3472. function qSheetClearDrawing(id){
  3473. var s = qSheets.find(function(x){return x.id===id;}); if(!s) return;
  3474. if(s.drawingUrl) URL.revokeObjectURL(s.drawingUrl);
  3475. s.drawing = null; s.drawingUrl = null; s.isMock = false; s.mockType = null;
  3476. if(!s.cad) s.status = 'empty';
  3477. qRenderSheetList(); qCheckUploadReady();
  3478. }
  3479. function qSheetClearCAD(id){
  3480. var s = qSheets.find(function(x){return x.id===id;}); if(!s) return;
  3481. if(s.cadUrl) URL.revokeObjectURL(s.cadUrl);
  3482. s.cad = null; s.cadUrl = null;
  3483. if(!s.drawing && !s.isMock) s.status = 'empty';
  3484. qRenderSheetList(); qCheckUploadReady();
  3485. }
  3486. function qAddMockSheet(type){
  3487. var sheet = qAddSheet(true);
  3488. sheet.isMock = true;
  3489. sheet.mockType = type;
  3490. sheet.status = 'ready';
  3491. if(type === 'floor1'){
  3492. sheet.name = '一楼(客厅+厨房+主卧)';
  3493. } else if(type === 'floor2'){
  3494. sheet.name = '二楼(次卧+书房+卫生间)';
  3495. }
  3496. qRenderSheetList();
  3497. qCheckUploadReady();
  3498. }
  3499. function qSheetLoadMock(id, type){
  3500. var s = qSheets.find(function(x){return x.id===id;});
  3501. if(!s) return;
  3502. if(s.drawingUrl) URL.revokeObjectURL(s.drawingUrl);
  3503. if(s.cadUrl) URL.revokeObjectURL(s.cadUrl);
  3504. s.drawing = null; s.drawingUrl = null; s.cad = null; s.cadUrl = null;
  3505. s.isMock = true;
  3506. s.mockType = type;
  3507. s.status = 'ready';
  3508. if(type === 'floor1'){
  3509. s.name = s.name === '图纸'+qSheets.indexOf(s) || s.name.indexOf('图纸')===0 ? '一楼(客厅+厨房+主卧)' : s.name;
  3510. } else if(type === 'floor2'){
  3511. s.name = s.name === '图纸'+qSheets.indexOf(s) || s.name.indexOf('图纸')===0 ? '二楼(次卧+书房+卫生间)' : s.name;
  3512. }
  3513. qRenderSheetList();
  3514. qCheckUploadReady();
  3515. }
  3516. function qQuickAddDemoFloors(){
  3517. qSheets.forEach(function(s){
  3518. if(s.drawingUrl) URL.revokeObjectURL(s.drawingUrl);
  3519. if(s.cadUrl) URL.revokeObjectURL(s.cadUrl);
  3520. });
  3521. qSheets = [];
  3522. qAddMockSheet('floor1');
  3523. qAddMockSheet('floor2');
  3524. }
  3525. // 空间效果图结构识别 — Toggle
  3526. function qToggleOCRPanel(){
  3527. var panel = document.getElementById('q-ocr-panel');
  3528. if(!panel) return;
  3529. var btn = document.getElementById('q-btn-toggle-ocr');
  3530. if(panel.classList.contains('hidden')){
  3531. panel.classList.remove('hidden');
  3532. if(btn) btn.textContent = '🔤 收起OCR识别详情';
  3533. } else {
  3534. panel.classList.add('hidden');
  3535. if(btn) btn.textContent = '🔤 查看OCR识别详情';
  3536. }
  3537. }
  3538. function qToggleSpaceOptions(idx){
  3539. var opts = document.getElementById('q-space-opts-'+idx);
  3540. var btn = document.getElementById('q-space-toggle-'+idx);
  3541. if(!opts) return;
  3542. if(opts.classList.contains('hidden')){
  3543. opts.classList.remove('hidden');
  3544. if(btn) btn.textContent = '▲ 收起选配产品';
  3545. } else {
  3546. opts.classList.add('hidden');
  3547. if(btn) btn.textContent = '▼ 展开选配产品(五金/配件)';
  3548. }
  3549. }
  3550. function qToggleRenderRecognition(){
  3551. qRenderEnabled = !qRenderEnabled;
  3552. const bg = document.getElementById('q-render-toggle-bg');
  3553. const dot = document.getElementById('q-render-toggle-dot');
  3554. const label = document.getElementById('q-render-toggle-label');
  3555. const area = document.getElementById('q-render-upload-area');
  3556. if(qRenderEnabled){
  3557. bg.style.background = 'var(--accent)';
  3558. dot.style.left = '22px';
  3559. label.textContent = '已开启';
  3560. label.style.color = 'var(--accent)';
  3561. if(area) area.style.display = 'block';
  3562. }else{
  3563. bg.style.background = 'var(--border)';
  3564. dot.style.left = '2px';
  3565. label.textContent = '关闭';
  3566. label.style.color = 'var(--text-muted)';
  3567. if(area) area.style.display = 'none';
  3568. qRenderFiles = [];
  3569. qRenderResults = null;
  3570. document.getElementById('q-upload-files-render').innerHTML = '';
  3571. const zone = document.getElementById('q-upload-zone-render');
  3572. if(zone){zone.style.borderColor='';zone.style.background='';zone.innerHTML='<div class="icon">🖼️</div><div class="text">点击上传空间效果图</div><div class="types">支持 JPG / PNG / WEBP(≤20MB),可多张上传</div>';}
  3573. document.getElementById('q-file-render').value = '';
  3574. }
  3575. qCheckUploadReady();
  3576. }
  3577. // 空间效果图结构识别 — Upload handlers
  3578. function qHandleRenderUpload(input){
  3579. const files = Array.from(input.files).filter(f => f.type.startsWith('image/'));
  3580. if(files.length === 0) return;
  3581. qRenderFiles = files.slice(0, 10);
  3582. const zone = document.getElementById('q-upload-zone-render');
  3583. zone.style.borderColor = 'var(--success)'; zone.style.background = '#F0FFF4';
  3584. zone.innerHTML = '<div class="icon">✅</div><div class="text">已选择 '+qRenderFiles.length+' 张效果图</div><div class="types">'+qRenderFiles.map(f=>f.name).join('、')+'</div>';
  3585. renderRenderPreviews();
  3586. qCheckUploadReady();
  3587. }
  3588. function renderRenderPreviews(){
  3589. const container = document.getElementById('q-upload-files-render');
  3590. if(!container) return;
  3591. container.innerHTML = qRenderFiles.map(function(f, idx){
  3592. // For real File objects use createObjectURL, for mock objects use thumb
  3593. var url = (f instanceof File) ? URL.createObjectURL(f) : (f.thumb || f.preview || '');
  3594. return '<div style="position:relative;width:100px;height:75px;border-radius:6px;overflow:hidden;border:1px solid var(--border);cursor:pointer;" onclick="qPreviewRenderImage('+idx+')">'+
  3595. '<img src="'+url+'" style="width:100%;height:100%;object-fit:cover;" onerror="this.src=\'data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22100%22 height=%2275%22%3E%3Crect fill=%22%23EDF2F7%22 width=%22100%22 height=%2275%22/%3E%3Ctext x=%2225%22 y=%2242%22 font-size=%2212%22 fill=%22%23999%22%3E'+encodeURIComponent(f.name.slice(0,8))+'%3C/text%3E%3C/svg%3E\'">'+
  3596. '<div onclick="event.stopPropagation();qRemoveRenderFile(\''+f.name.replace(/'/g,"\\'")+'\')" style="position:absolute;top:2px;right:2px;width:18px;height:18px;border-radius:50%;background:rgba(0,0,0,.6);color:#fff;font-size:10px;display:flex;align-items:center;justify-content:center;cursor:pointer;">×</div>'+
  3597. '<div style="position:absolute;bottom:0;left:0;right:0;background:rgba(0,0,0,.6);color:#fff;font-size:9px;padding:2px 4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">'+f.name+'</div>'+
  3598. '</div>';
  3599. }).join('');
  3600. }
  3601. function qRemoveRenderFile(name){
  3602. qRenderFiles = qRenderFiles.filter(f => f.name !== name);
  3603. if(qRenderFiles.length === 0){
  3604. const zone = document.getElementById('q-upload-zone-render');
  3605. zone.style.borderColor = ''; zone.style.background = '';
  3606. zone.innerHTML = '<div class="icon">🖼️</div><div class="text">点击上传空间效果图</div><div class="types">支持 JPG / PNG / WEBP(≤20MB),可多张上传</div>';
  3607. document.getElementById('q-file-render').value = '';
  3608. }else{
  3609. const zone = document.getElementById('q-upload-zone-render');
  3610. zone.innerHTML = '<div class="icon">✅</div><div class="text">已选择 '+qRenderFiles.length+' 张效果图</div><div class="types">'+qRenderFiles.map(f=>f.name).join('、')+'</div>';
  3611. }
  3612. renderRenderPreviews();
  3613. qCheckUploadReady();
  3614. }
  3615. function qUseMockRender(){
  3616. // Inline SVG — Living room rendering (奶油风)
  3617. var lrSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="600" height="400">'+
  3618. '<defs><linearGradient id="wall" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#FDF6EC"/><stop offset="100%" stop-color="#F8EDDC"/></linearGradient>'+
  3619. '<linearGradient id="floor" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#D4C5B2"/><stop offset="100%" stop-color="#C4B5A2"/></linearGradient>'+
  3620. '<linearGradient id="cabinet" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#F5F0E8"/><stop offset="100%" stop-color="#EBE3D7"/></linearGradient>'+
  3621. '<linearGradient id="ceiling" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#FFFFFF"/><stop offset="100%" stop-color="#FAFAF8"/></linearGradient>'+
  3622. '</defs>'+
  3623. '<!-- Ceiling --><rect fill="url(#ceiling)" width="600" height="120"/>'+
  3624. '<!-- Ceiling cove light --><rect x="0" y="116" width="600" height="4" fill="#FFF8E7" opacity="0.8"/>'+
  3625. '<!-- Back wall --><rect fill="url(#wall)" x="0" y="120" width="600" height="200"/>'+
  3626. '<!-- Floor --><rect fill="url(#floor)" x="0" y="320" width="600" height="80"/>'+
  3627. '<!-- Baseboard --><rect x="0" y="315" width="600" height="5" fill="#FFFFFF" opacity="0.9"/>'+
  3628. '<!-- TV Feature wall --><rect x="180" y="130" width="240" height="180" fill="#E8E4DF" rx="2" stroke="#DDD5C8" stroke-width="1"/>'+
  3629. '<rect x="200" y="145" width="200" height="120" fill="#F0EBE3" rx="2"/>'+
  3630. '<!-- TV screen --><rect x="220" y="155" width="160" height="95" fill="#1A1A1A" rx="4"/>'+
  3631. '<rect x="225" y="160" width="150" height="85" fill="#2D2D3F" rx="2"/>'+
  3632. '<!-- TV stand / floating cabinet --><rect x="190" y="265" width="220" height="24" fill="url(#cabinet)" rx="3" stroke="#DDD5C8" stroke-width="1"/>'+
  3633. '<line x1="190" y1="277" x2="410" y2="277" stroke="#DDD5C8" stroke-width="0.5"/>'+
  3634. '<circle cx="210" cy="277" r="2" fill="#C4B5A2"/>'+
  3635. '<circle cx="390" cy="277" r="2" fill="#C4B5A2"/>'+
  3636. '<!-- Left cabinet (wine / display) --><rect x="50" y="135" width="80" height="36" fill="url(#cabinet)" rx="2" stroke="#DDD5C8" stroke-width="1"/>'+
  3637. '<line x1="50" y1="153" x2="130" y2="153" stroke="#DDD5C8" stroke-width="0.5"/>'+
  3638. '<rect x="50" y="175" width="80" height="36" fill="url(#cabinet)" rx="2" stroke="#DDD5C8" stroke-width="1"/>'+
  3639. '<rect x="50" y="215" width="80" height="54" fill="url(#cabinet)" rx="2" stroke="#DDD5C8" stroke-width="1"/>'+
  3640. '<!-- Sofa --><rect x="420" y="250" width="150" height="35" fill="#D4C5B2" rx="6"/>'+
  3641. '<rect x="415" y="285" width="30" height="30" fill="#D4C5B2" rx="4"/>'+
  3642. '<rect x="540" y="285" width="30" height="30" fill="#D4C5B2" rx="4"/>'+
  3643. '<rect x="420" y="248" width="150" height="8" fill="#E8D5C0" rx="4"/>'+
  3644. '<!-- Coffee table --><rect x="440" y="305" width="100" height="12" fill="#EBE3D7" rx="2" stroke="#DDD5C8" stroke-width="0.5"/>'+
  3645. '<rect x="450" y="317" width="8" height="8" fill="#C4B5A2"/>'+
  3646. '<rect x="522" y="317" width="8" height="8" fill="#C4B5A2"/>'+
  3647. '<!-- Rug --><rect x="400" y="320" width="180" height="8" fill="#E8DED0" rx="2" opacity="0.6"/>'+
  3648. '<!-- Curtain left --><rect x="2" y="120" width="14" height="200" fill="#F0EBE3" opacity="0.7"/>'+
  3649. '<!-- Curtain right --><rect x="584" y="120" width="14" height="200" fill="#F0EBE3" opacity="0.7"/>'+
  3650. '<!-- Ceiling spotlights --><circle cx="150" cy="50" r="8" fill="#FFF8E7" opacity="0.5"/><circle cx="150" cy="50" r="4" fill="#FFF"/>'+
  3651. '<circle cx="300" cy="50" r="8" fill="#FFF8E7" opacity="0.5"/><circle cx="300" cy="50" r="4" fill="#FFF"/>'+
  3652. '<circle cx="450" cy="50" r="8" fill="#FFF8E7" opacity="0.5"/><circle cx="450" cy="50" r="4" fill="#FFF"/>'+
  3653. '<!-- Potted plant --><circle cx="565" cy="310" r="12" fill="#48BB78" opacity="0.7"/>'+
  3654. '<circle cx="565" cy="305" r="8" fill="#68D391" opacity="0.6"/>'+
  3655. '<rect x="561" y="320" width="10" height="12" fill="#C4A97D" rx="2"/>'+
  3656. '<!-- Labels -->'+
  3657. '<rect x="210" y="370" width="180" height="20" fill="rgba(0,0,0,0.55)" rx="10"/>'+
  3658. '<text x="300" y="384" text-anchor="middle" font-size="11" fill="#FFF" font-family="sans-serif" font-weight="bold">🛋️ 客厅 — 奶油风 · 双饰面 SSM-023 浅橡木</text>'+
  3659. '</svg>';
  3660. // Inline SVG — Master bedroom rendering (现代简约)
  3661. var brSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="600" height="400">'+
  3662. '<defs><linearGradient id="br-wall" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#F7F8FA"/><stop offset="100%" stop-color="#EEF0F4"/></linearGradient>'+
  3663. '<linearGradient id="br-floor2" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#C4A97D"/><stop offset="100%" stop-color="#B89B6E"/></linearGradient>'+
  3664. '<linearGradient id="wardrobe" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stop-color="#E8E4DF"/><stop offset="100%" stop-color="#F5F0E8"/></linearGradient>'+
  3665. '<linearGradient id="br-ceiling" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#FFFFFF"/><stop offset="100%" stop-color="#F8F9FB"/></linearGradient>'+
  3666. '</defs>'+
  3667. '<!-- Ceiling --><rect fill="url(#br-ceiling)" width="600" height="110"/>'+
  3668. '<!-- Back wall --><rect fill="url(#br-wall)" x="0" y="110" width="600" height="210"/>'+
  3669. '<!-- Floor --><rect fill="url(#br-floor2)" x="0" y="320" width="600" height="80"/>'+
  3670. '<!-- Baseboard --><rect x="0" y="315" width="600" height="5" fill="#FFFFFF" opacity="0.9"/>'+
  3671. '<!-- Headboard wall accent --><rect x="150" y="115" width="300" height="195" fill="#E8EDF2" opacity="0.5"/>'+
  3672. '<!-- Bed frame --><rect x="200" y="220" width="200" height="65" fill="#D4C5B2" rx="6"/>'+
  3673. '<!-- Mattress --><rect x="210" y="200" width="180" height="25" fill="#FFFFFF" rx="4"/>'+
  3674. '<rect x="210" y="205" width="180" height="6" fill="#F7F8FA" rx="2"/>'+
  3675. '<!-- Pillows --><rect x="220" y="210" width="35" height="14" fill="#FFFFFF" rx="6" stroke="#E2E8F0" stroke-width="0.5"/>'+
  3676. '<rect x="265" y="210" width="35" height="14" fill="#FFFFFF" rx="6" stroke="#E2E8F0" stroke-width="0.5"/>'+
  3677. '<!-- Headboard --><rect x="195" y="140" width="210" height="65" fill="#D4C5B2" rx="4"/>'+
  3678. '<rect x="205" y="148" width="190" height="50" fill="#E8D5C0" rx="2"/>'+
  3679. '<!-- Wardrobe (large, full wall) --><rect x="430" y="125" width="150" height="195" fill="url(#wardrobe)" rx="2" stroke="#DDD5C8" stroke-width="1"/>'+
  3680. '<line x1="505" y1="125" x2="505" y2="320" stroke="#DDD5C8" stroke-width="0.5"/>'+
  3681. '<line x1="430" y1="190" x2="580" y2="190" stroke="#DDD5C8" stroke-width="0.5"/>'+
  3682. '<circle cx="467" cy="157" r="3" fill="#C4B5A2"/>'+
  3683. '<circle cx="542" cy="157" r="3" fill="#C4B5A2"/>'+
  3684. '<circle cx="467" cy="220" r="3" fill="#C4B5A2"/>'+
  3685. '<circle cx="542" cy="220" r="3" fill="#C4B5A2"/>'+
  3686. '<!-- Bedside table left --><rect x="170" y="245" width="28" height="28" fill="#EBE3D7" rx="3" stroke="#DDD5C8" stroke-width="0.5"/>'+
  3687. '<circle cx="184" cy="255" r="6" fill="#FFFFFF" opacity="0.5"/>'+
  3688. '<!-- Bedside table right --><rect x="402" y="245" width="24" height="28" fill="#EBE3D7" rx="3" stroke="#DDD5C8" stroke-width="0.5"/>'+
  3689. '<!-- Nightstand lamp --><rect x="410" y="233" width="4" height="14" fill="#C4A97D"/>'+
  3690. '<ellipse cx="412" cy="231" rx="7" ry="4" fill="#FFF8E7" opacity="0.6"/>'+
  3691. '<!-- Dressing table / small desk --><rect x="25" y="240" width="80" height="30" fill="#EBE3D7" rx="2" stroke="#DDD5C8" stroke-width="0.5"/>'+
  3692. '<rect x="40" y="270" width="6" height="25" fill="#C4B5A2"/>'+
  3693. '<rect x="85" y="270" width="6" height="25" fill="#C4B5A2"/>'+
  3694. '<circle cx="65" cy="252" r="10" fill="#FFFFFF" opacity="0.4"/>'+
  3695. '<!-- Rug --><rect x="190" y="325" width="160" height="6" fill="#E8DED0" rx="2" opacity="0.5"/>'+
  3696. '<!-- Curtains --><rect x="2" y="110" width="12" height="210" fill="#E8E4DF" opacity="0.6"/>'+
  3697. '<rect x="586" y="110" width="12" height="210" fill="#E8E4DF" opacity="0.6"/>'+
  3698. '<!-- Ceiling light --><rect x="270" y="55" width="60" height="6" fill="#C4B5A2" rx="3"/>'+
  3699. '<ellipse cx="300" cy="60" rx="20" ry="8" fill="#FFF8E7" opacity="0.4"/>'+
  3700. '<circle cx="300" cy="50" r="4" fill="#FFF"/>'+
  3701. '<!-- Labels -->'+
  3702. '<rect x="200" y="370" width="200" height="20" fill="rgba(0,0,0,0.55)" rx="10"/>'+
  3703. '<text x="300" y="384" text-anchor="middle" font-size="11" fill="#FFF" font-family="sans-serif" font-weight="bold">🛏️ 主卧 — 现代简约 · 影木 YM-001 胡桃木</text>'+
  3704. '</svg>';
  3705. var mockThumbs = [
  3706. 'data:image/svg+xml,' + encodeURIComponent(lrSvg),
  3707. 'data:image/svg+xml,' + encodeURIComponent(brSvg)
  3708. ];
  3709. qRenderFiles = [
  3710. {name:'客厅效果图_奶油风.jpg',type:'image/jpeg',thumb:mockThumbs[0]},
  3711. {name:'主卧效果图_现代简约.jpg',type:'image/jpeg',thumb:mockThumbs[1]}
  3712. ];
  3713. var zone = document.getElementById('q-upload-zone-render');
  3714. zone.style.borderColor = 'var(--success)'; zone.style.background = '#F0FFF4';
  3715. zone.innerHTML = '<div class="icon">✅</div><div class="text">已选择 2 张示例效果图</div><div class="types">客厅效果图_奶油风.jpg、主卧效果图_现代简约.jpg</div>';
  3716. var container = document.getElementById('q-upload-files-render');
  3717. if(container){
  3718. container.innerHTML = qRenderFiles.map(function(f, idx){
  3719. return '<div style="position:relative;width:100px;height:75px;border-radius:6px;overflow:hidden;border:1px solid var(--border);cursor:pointer;" onclick="qPreviewRenderImage('+idx+')">'+
  3720. '<img src="'+f.thumb+'" style="width:100%;height:100%;object-fit:cover;" onerror="this.src=\'data:image/svg+xml,'+encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="100" height="75"><rect fill="#EDF2F7" width="100" height="75"/><text x="50" y="42" text-anchor="middle" font-size="12" fill="#A0AEC0">效果图</text></svg>')+'\'">'+
  3721. '<div onclick="event.stopPropagation();qRemoveMockRenderFile(\''+f.name.replace(/'/g,"\\'")+'\')" style="position:absolute;top:2px;right:2px;width:18px;height:18px;border-radius:50%;background:rgba(0,0,0,.6);color:#fff;font-size:10px;display:flex;align-items:center;justify-content:center;cursor:pointer;">×</div>'+
  3722. '<div style="position:absolute;bottom:0;left:0;right:0;background:rgba(0,0,0,.6);color:#fff;font-size:9px;padding:2px 4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">'+f.name+'</div>'+
  3723. '</div>';
  3724. }).join('');
  3725. }
  3726. qCheckUploadReady();
  3727. }
  3728. // Remove individual mock render file
  3729. function qRemoveMockRenderFile(name){
  3730. qRenderFiles = qRenderFiles.filter(function(f){return f.name !== name;});
  3731. if(qRenderFiles.length === 0){
  3732. qClearRenderZone();
  3733. }else{
  3734. var zone = document.getElementById('q-upload-zone-render');
  3735. zone.innerHTML = '<div class="icon">✅</div><div class="text">已选择 '+qRenderFiles.length+' 张效果图</div><div class="types">'+qRenderFiles.map(function(f){return f.name;}).join('、')+'</div>';
  3736. // Re-render with mock thumbs
  3737. qRefreshMockRenderPreviews();
  3738. }
  3739. }
  3740. function qRefreshMockRenderPreviews(){
  3741. var container = document.getElementById('q-upload-files-render');
  3742. if(!container) return;
  3743. container.innerHTML = qRenderFiles.map(function(f, idx){
  3744. var thumb = f.thumb || ('data:image/svg+xml,'+encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="100" height="75"><rect fill="#EDF2F7" width="100" height="75"/><text x="50" y="40" text-anchor="middle" font-size="11" fill="#A0AEC0">'+f.name.slice(0,6)+'</text></svg>'));
  3745. return '<div style="position:relative;width:100px;height:75px;border-radius:6px;overflow:hidden;border:1px solid var(--border);cursor:pointer;" onclick="qPreviewRenderImage('+idx+')">'+
  3746. '<img src="'+thumb+'" style="width:100%;height:100%;object-fit:cover;" onerror="this.onerror=null;this.src=\'data:image/svg+xml,'+encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="100" height="75"><rect fill="#EDF2F7" width="100" height="75"/><text x="50" y="42" text-anchor="middle" font-size="12" fill="#A0AEC0">效果图</text></svg>')+'\'">'+
  3747. '<div onclick="event.stopPropagation();qRemoveMockRenderFile(\''+f.name.replace(/'/g,"\\'")+'\')" style="position:absolute;top:2px;right:2px;width:18px;height:18px;border-radius:50%;background:rgba(0,0,0,.6);color:#fff;font-size:10px;display:flex;align-items:center;justify-content:center;cursor:pointer;">×</div>'+
  3748. '<div style="position:absolute;bottom:0;left:0;right:0;background:rgba(0,0,0,.6);color:#fff;font-size:9px;padding:2px 4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">'+f.name+'</div>'+
  3749. '</div>';
  3750. }).join('');
  3751. }
  3752. // Lightbox preview for render images
  3753. function qPreviewRenderImage(idx){
  3754. var f = qRenderFiles[idx];
  3755. if(!f) return;
  3756. var src = '';
  3757. if(f instanceof File && f.type.startsWith('image/')){ src = URL.createObjectURL(f); }
  3758. else if(f.thumb){ src = f.thumb; }
  3759. else if(f.preview){ src = f.preview; }
  3760. else { src = 'data:image/svg+xml,'+encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="600" height="400"><rect fill="#EDF2F7" width="600" height="400"/><text x="300" y="210" text-anchor="middle" font-size="20" fill="#A0AEC0">'+f.name+'</text></svg>'); }
  3761. qOpenZoomableLightbox(src, f.name);
  3762. }
  3763. function qClearRenderZone(){
  3764. qRenderFiles = [];
  3765. const zone = document.getElementById('q-upload-zone-render');
  3766. if(zone){zone.style.borderColor='';zone.style.background='';zone.innerHTML='<div class="icon">🖼️</div><div class="text">点击上传空间效果图</div><div class="types">支持 JPG / PNG / WEBP(≤20MB),可多张上传</div>';}
  3767. document.getElementById('q-upload-files-render').innerHTML = '';
  3768. document.getElementById('q-file-render').value = '';
  3769. }
  3770. // ====================== 五维AI分析引擎 ======================
  3771. // 精确的示例图纸数据(与Canvas绘制的标注严格对应)
  3772. function qBuildMockExactData(){
  3773. // 数据与Canvas绘制的示例户型图上文字标注严格对应:
  3774. // 客厅5.5×4.5=24.8㎡、主卧4.2×3.5=14.7㎡、次卧3.8×3.2=12.2㎡
  3775. // 餐厅3.0×3.2=9.6㎡、厨房3.5×2.4=8.4㎡、阳台3.0×1.5=4.5㎡
  3776. return {
  3777. houseType: '三房两厅一厨一卫',
  3778. houseLabel: '三房两厅',
  3779. totalArea: 74.2, // 24.8+14.7+12.2+9.6+8.4+4.5=74.2㎡(套内面积)
  3780. grossArea: 120, // 建筑面积
  3781. orientation: '坐北朝南',
  3782. rooms: [
  3783. // 注意:length=房间面宽,height=房间进深(墙体标注的两个尺寸)
  3784. {roomName:'客厅', cabinetType:'客厅电视柜', layoutStyle:'L型', projectArea:24.8, doorType:'风影门板', length:5.5, width:4.5, height:2.8, area:24.8, cabinetRatio:0.45, fillRate:0.60, doorRatio:0.60, bayWindow:true},
  3785. {roomName:'主卧', cabinetType:'主卧衣柜', layoutStyle:'一字型',projectArea:14.7, doorType:'铝格栅板', length:4.2, width:3.5, height:2.8, area:14.7, cabinetRatio:0.60, fillRate:0.65, doorRatio:0.55, bayWindow:true},
  3786. {roomName:'次卧', cabinetType:'次卧衣柜', layoutStyle:'一字型',projectArea:12.2, doorType:'普通平板', length:3.8, width:3.2, height:2.8, area:12.2, cabinetRatio:0.55, fillRate:0.50, doorRatio:0.50, bayWindow:false},
  3787. {roomName:'餐厅', cabinetType:'餐厅餐边柜', layoutStyle:'一字型',projectArea:9.6, doorType:'普通平板', length:3.0, width:3.2, height:2.8, area:9.6, cabinetRatio:0.40, fillRate:0.45, doorRatio:0.45, bayWindow:false},
  3788. {roomName:'厨房', cabinetType:'厨房橱柜', layoutStyle:'U型', projectArea:8.4, doorType:'普通平板', length:3.5, width:2.4, height:2.8, area:8.4, cabinetRatio:0.70, fillRate:0.70, doorRatio:0.40, bayWindow:false},
  3789. {roomName:'阳台', cabinetType:'阳台柜', layoutStyle:'一字型',projectArea:4.5, doorType:'普通平板', length:3.0, width:1.5, height:2.6, area:4.5, cabinetRatio:0.50, fillRate:0.50, doorRatio:0.50, bayWindow:false},
  3790. {roomName:'书房', cabinetType:'书房书柜', layoutStyle:'一字型',projectArea:6.3, doorType:'玻璃门', length:2.5, width:2.5, height:2.8, area:6.3, cabinetRatio:0.65, fillRate:0.60, doorRatio:0.45, bayWindow:false},
  3791. {roomName:'玄关', cabinetType:'玄关鞋柜', layoutStyle:'一字型',projectArea:2.7, doorType:'普通平板', length:1.5, width:1.8, height:2.6, area:2.7, cabinetRatio:0.55, fillRate:0.50, doorRatio:0.50, bayWindow:false},
  3792. {roomName:'开放式厨房', cabinetType:'开放式厨房', layoutStyle:'L型',projectArea:9.0, doorType:'PET门板', length:3.2, width:2.8, height:2.8, area:9.0, cabinetRatio:0.60, fillRate:0.65, doorRatio:0.50, bayWindow:false}
  3793. ],
  3794. walls: [
  3795. {id:'W1',type:'外墙',orientation:'北',length:12.4,thickness:0.24,startX:0,startY:0,endX:12.4,endY:0},
  3796. {id:'W2',type:'外墙',orientation:'南',length:12.4,thickness:0.24,startX:0,startY:9.2,endX:12.4,endY:9.2},
  3797. {id:'W3',type:'外墙',orientation:'东',length:9.2,thickness:0.24,startX:12.4,startY:0,endX:12.4,endY:9.2},
  3798. {id:'W4',type:'外墙',orientation:'西',length:9.2,thickness:0.24,startX:0,startY:0,endX:0,endY:9.2},
  3799. {id:'W5',type:'内墙',orientation:'纵向',length:5.2,thickness:0.12,startX:5.0,startY:0,endX:5.0,endY:5.2,desc:'分隔客厅与主卧/次卧'},
  3800. {id:'W6',type:'内墙',orientation:'纵向',length:3.1,thickness:0.12,startX:8.5,startY:0,endX:8.5,endY:3.1,desc:'分隔主卧与餐厅'},
  3801. {id:'W7',type:'内墙',orientation:'横向',length:5.0,thickness:0.12,startX:0,startY:5.2,endX:5.0,endY:5.2,desc:'分隔客厅与次卧'},
  3802. {id:'W8',type:'内墙',orientation:'横向',length:6.5,thickness:0.12,startX:5.0,startY:3.1,endX:11.5,endY:3.1,desc:'分隔主卧/次卧与厨房/餐厅'},
  3803. {id:'W9',type:'内墙',orientation:'横向',length:3.5,thickness:0.12,startX:8.5,startY:6.0,endX:12.0,endY:6.0,desc:'分隔餐厅与厨房'}
  3804. ],
  3805. doors: [
  3806. {id:'D1',type:'入户门',room:'客厅',width:0.9,position:'南墙偏东',opening:'内开右'},
  3807. {id:'D2',type:'室内门',room:'主卧→客厅',width:0.8,position:'东侧内墙',opening:'内开左'},
  3808. {id:'D3',type:'室内门',room:'次卧→客厅',width:0.8,position:'北侧内墙',opening:'内开左'},
  3809. {id:'D4',type:'室内门',room:'厨房→餐厅',width:0.8,position:'南侧内墙',opening:'内开右'},
  3810. {id:'D5',type:'室内门',room:'餐厅→客厅',width:0.8,position:'内墙通道',opening:'推拉门'},
  3811. {id:'D6',type:'推拉门',room:'阳台→客厅',width:1.5,position:'东侧外墙',opening:'推拉门'}
  3812. ],
  3813. windows: [
  3814. {id:'WIN1',room:'客厅',type:'飘窗',width:3.0,orientation:'南'},
  3815. {id:'WIN2',room:'主卧',type:'飘窗',width:2.4,orientation:'南'},
  3816. {id:'WIN3',room:'次卧',type:'普通窗',width:1.8,orientation:'北'},
  3817. {id:'WIN4',room:'餐厅',type:'普通窗',width:1.5,orientation:'东'},
  3818. {id:'WIN5',room:'厨房',type:'普通窗',width:1.2,orientation:'北'},
  3819. {id:'WIN6',room:'阳台',type:'普通窗',width:2.0,orientation:'东'}
  3820. ],
  3821. structType: '砖混结构 / 框架剪力墙',
  3822. outerWallThickness: '240mm(砖混结构)',
  3823. innerWallThickness: '120mm(轻质隔墙)',
  3824. floorHeight: '2.8m(标准层高)',
  3825. totalWidth: '12.4m(东西向)'
  3826. };
  3827. }
  3828. // Step 2: Sync AI analysis UI state (增强版)
  3829. function qRenderStep2(){
  3830. var loadingArea = document.getElementById('q-ai-loading-area');
  3831. var spinner = document.getElementById('q-ai-spinner');
  3832. var statusText = document.getElementById('q-ai-status-text');
  3833. var statusDetail = document.getElementById('q-ai-status-detail');
  3834. var actionArea = document.getElementById('q-ai-action-area');
  3835. var progressBar = document.getElementById('q-ai-progress-bar');
  3836. var analysisGrid = document.getElementById('q-ai-analysis-grid');
  3837. var summaryBanner = document.getElementById('q-ai-summary-banner');
  3838. // ── 立即显示已上传的图片预览 ──
  3839. var previewDiv = document.getElementById('q-ai-image-preview');
  3840. var drawingThumb = document.getElementById('q-ai-drawing-thumb');
  3841. var drawingImg = document.getElementById('q-ai-drawing-img');
  3842. var reqThumb = document.getElementById('q-ai-req-thumb');
  3843. var reqImg = document.getElementById('q-ai-req-img');
  3844. var fileInfo = document.getElementById('q-ai-file-info');
  3845. // 户型图原图统一在「第一步:原图家具布局」工作区展示,顶部仅在生成家具布局合成图后显示合成结果,避免原图重复出现
  3846. if (qUploadedDrawing && qUploadedDrawingUrl && qFurnitureCompositeDataUrl) {
  3847. if (previewDiv) previewDiv.style.display = 'block';
  3848. if (drawingThumb) drawingThumb.style.display = 'block';
  3849. if (drawingImg) {
  3850. var previewSrc = qFurnitureCompositeDataUrl;
  3851. drawingImg.src = previewSrc;
  3852. drawingImg.style.display = 'block';
  3853. drawingImg.onclick = function(){ qOpenZoomableLightbox(previewSrc, '家具布局户型图'); };
  3854. var previewTitle=document.getElementById('q-ai-drawing-preview-title');
  3855. if(previewTitle)previewTitle.textContent='🛋️ 家具布局户型图(贴纸结果)';
  3856. }
  3857. var infoParts = [];
  3858. if (qUploadedDrawing.name) infoParts.push('📁 文件:'+qUploadedDrawing.name);
  3859. if (qUploadedDrawing.size) infoParts.push('📦 大小:'+(qUploadedDrawing.size/1024).toFixed(0)+'KB');
  3860. if (fileInfo && !qAiProcessed) fileInfo.innerHTML = infoParts.join(' | ');
  3861. } else {
  3862. if (previewDiv) previewDiv.style.display = 'none';
  3863. }
  3864. // 需求记录图片
  3865. if (qUploadedRequirement && qUploadedRequirementUrl) {
  3866. if (reqThumb) reqThumb.style.display = 'block';
  3867. if (reqImg) {
  3868. reqImg.src = qUploadedRequirementUrl;
  3869. reqImg.onclick = function(){ qOpenZoomableLightbox(qUploadedRequirementUrl, qUploadedRequirement.name||'需求记录'); };
  3870. }
  3871. }
  3872. if (qAiProcessed) {
  3873. if (spinner) spinner.style.display = 'none';
  3874. if (loadingArea) loadingArea.style.display = 'none';
  3875. if (statusText) { statusText.textContent = '✅ AI 解析完成!'; statusText.style.color = 'var(--success)'; }
  3876. if (progressBar) progressBar.style.display = 'flex';
  3877. if (summaryBanner) summaryBanner.classList.remove('hidden');
  3878. // Mark all progress steps as done
  3879. for (var i = 0; i < 5; i++) {
  3880. var step = document.getElementById('q-ai-prog-'+i);
  3881. if (step) { step.className = 'ai-progress-step done'; }
  3882. }
  3883. var extraBtns = '';
  3884. if (qRenderResults && qRenderResults.length > 0) {
  3885. extraBtns += '<button class="btn btn-outline btn-lg" onclick="qShowRenderResults()" style="margin-left:8px;">🖼️ 查看结构识别结果</button>';
  3886. }
  3887. if (actionArea) {
  3888. actionArea.innerHTML = extraBtns+'<button class="btn btn-outline btn-lg" onclick="qStartAnalysis()">🔄 重新解析</button>';
  3889. }
  3890. // 重新添加图纸标注
  3891. var isMockDrawing = (qUploadedDrawing && qUploadedDrawing.name && qUploadedDrawing.name.indexOf('120㎡三房两厅') !== -1) || (qUploadedCAD && qUploadedCAD.name && qUploadedCAD.name.indexOf('120㎡三房两厅') !== -1);
  3892. setTimeout(function(){ qAddDrawingAnnotations(isMockDrawing); }, 300);
  3893. // 重新渲染AI卡片(确保下拉框单价从规则配置最新值读取)
  3894. var cardData = qAiEditableData || window._qLastAnalysisData;
  3895. if (cardData) {
  3896. // 同步已选增项价格到最新规则值
  3897. if(cardData.rooms){
  3898. cardData.rooms.forEach(function(r){
  3899. if(r._selectedExtras){
  3900. r._selectedExtras.forEach(function(se){
  3901. var latestEx = DEFAULT_RULES.extra.find(function(e){return e.name === se.name;});
  3902. if(latestEx){ se.price = parseFloat(latestEx.price)||0; se.method=latestEx.method; se.unit=latestEx.unit; }
  3903. });
  3904. }
  3905. });
  3906. }
  3907. qRenderAICards(cardData, isMockDrawing);
  3908. }
  3909. } else if (qAiAnalyzing) {
  3910. // 正在解析中
  3911. if (loadingArea) loadingArea.style.display = 'flex';
  3912. if (spinner) spinner.style.display = 'block';
  3913. if (statusText) { statusText.textContent = 'AI 正在分析中...'; statusText.style.color = ''; }
  3914. if (actionArea) actionArea.innerHTML = '';
  3915. if (progressBar) {
  3916. // Reset all progress steps
  3917. for (var i = 0; i < 5; i++) {
  3918. var step = document.getElementById('q-ai-prog-'+i);
  3919. if (step) { step.className = 'ai-progress-step'; }
  3920. }
  3921. }
  3922. if (analysisGrid) analysisGrid.innerHTML = '';
  3923. if (summaryBanner) summaryBanner.classList.add('hidden');
  3924. // 清除标注
  3925. var annLayer = document.getElementById('q-ai-annotations-layer');
  3926. if (annLayer) annLayer.innerHTML = '';
  3927. } else {
  3928. // 等待开始解析:显示图片 + 开始按钮
  3929. if (loadingArea) loadingArea.style.display = 'flex';
  3930. if (spinner) spinner.style.display = 'none';
  3931. if (statusText) { statusText.textContent = (state.quoteMode === 'manual') ? '📋 图纸已就绪,添加家具贴纸并调整布局后点击「下一步,柜体尺寸计算」' : '📋 图纸已就绪,点击下方按钮开始AI解析'; statusText.style.color = 'var(--accent)'; }
  3932. if (statusDetail) { var sd = document.getElementById('q-ai-status-detail'); if(sd) sd.textContent = (state.quoteMode === 'manual') ? '手动拖拽家具贴纸定位柜体,尺寸可逐个编辑复核' : 'AI将自动识别图纸中的柜体尺寸、房间名称等信息'; }
  3933. if (progressBar) progressBar.style.display = 'none';
  3934. if (analysisGrid) analysisGrid.innerHTML = '';
  3935. if (summaryBanner) summaryBanner.classList.add('hidden');
  3936. // 清除标注
  3937. var annLayer2 = document.getElementById('q-ai-annotations-layer');
  3938. if (annLayer2) annLayer2.innerHTML = '';
  3939. // 显示开始解析按钮(第二套报价模式已有「下一步,柜体尺寸计算」按键,隐藏此动态按钮避免重复)
  3940. if (actionArea) {
  3941. if (state.quoteMode === 'manual') {
  3942. actionArea.innerHTML = '';
  3943. } else {
  3944. var canAnalyze = (qUploadedDrawing || qUploadedCAD);
  3945. actionArea.innerHTML = '<button class="btn btn-primary btn-lg" onclick="qStartAnalysis()" '+(canAnalyze?'':'disabled style="opacity:0.5;cursor:not-allowed;"')+'>🤖 开始 AI 解析</button>';
  3946. }
  3947. }
  3948. }
  3949. }
  3950. // 更新分析进度条
  3951. function qUpdateProgressStep(index){
  3952. for (var i = 0; i < 5; i++) {
  3953. var step = document.getElementById('q-ai-prog-'+i);
  3954. if (!step) continue;
  3955. if (i < index) { step.className = 'ai-progress-step done'; }
  3956. else if (i === index) { step.className = 'ai-progress-step running'; }
  3957. else { step.className = 'ai-progress-step'; }
  3958. }
  3959. }
  3960. // ── 渲染五维分析结果卡片 ──
  3961. // ==================== 可编辑AI分析卡片 ====================
  3962. // 编辑模式开关
  3963. function qToggleAiEditMode(){
  3964. qAiEditMode = !qAiEditMode;
  3965. var toggleEl = document.getElementById('q-ai-edit-toggle');
  3966. var indicatorEl = document.getElementById('q-ai-edit-indicator');
  3967. if (toggleEl) toggleEl.textContent = qAiEditMode ? '🔒 退出编辑' : '✏️ 编辑数据';
  3968. if (toggleEl) toggleEl.className = qAiEditMode ? 'btn btn-outline btn-sm' : 'btn btn-outline btn-sm';
  3969. if (indicatorEl) indicatorEl.style.display = qAiEditMode ? 'inline-block' : 'none';
  3970. // 重新渲染卡片以切换编辑/查看模式
  3971. if (qAiEditableData) {
  3972. var isMock = qUploadedDrawing && qUploadedDrawing.name && qUploadedDrawing.name.indexOf('120㎡三房两厅') !== -1;
  3973. qRenderAICards(qAiEditableData, isMock);
  3974. }
  3975. }
  3976. // 内联编辑辅助:生成可编辑的数据值HTML
  3977. function qEditableVal(cardIdx, rowIdx, value, editType, options){
  3978. if (!qAiEditMode) return value;
  3979. var uid = 'ai-edit-'+cardIdx+'-'+rowIdx;
  3980. var encodedVal = value.replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/'/g,'&#39;');
  3981. var clickHandler = 'qStartInlineEdit('+cardIdx+','+rowIdx+')';
  3982. return '<span class="ai-data-value editable" id="'+uid+'" onclick="event.stopPropagation();'+clickHandler+'" title="点击编辑">'+value+'</span>';
  3983. }
  3984. // 开始内联编辑
  3985. function qStartInlineEdit(cardIdx, rowIdx){
  3986. if (!qAiEditMode) return;
  3987. if (!qAiEditableData) return;
  3988. var uid = 'ai-edit-'+cardIdx+'-'+rowIdx;
  3989. var el = document.getElementById(uid);
  3990. if (!el) return;
  3991. // 获取当前值和元数据
  3992. var meta = qGetEditableMeta(cardIdx, rowIdx);
  3993. if (!meta) return;
  3994. var currentVal = meta.getter();
  3995. // 防止重复编辑
  3996. if (el.classList.contains('editing')) return;
  3997. el.classList.add('editing');
  3998. var inputHtml = '';
  3999. if (meta.editType === 'select') {
  4000. var opts = (meta.options || []).map(function(o){
  4001. return '<option value="'+o+'"'+(o===currentVal?' selected':'')+'>'+o+'</option>';
  4002. }).join('');
  4003. inputHtml = '<select class="ai-inline-select" id="'+uid+'-input">'+opts+'</select>';
  4004. } else if (meta.editType === 'number') {
  4005. inputHtml = '<input type="number" step="0.01" class="ai-inline-input" id="'+uid+'-input" value="'+currentVal+'" style="width:80px;">';
  4006. } else if (meta.editType === 'color') {
  4007. inputHtml = '<input type="text" class="ai-inline-input" id="'+uid+'-input" value="'+currentVal+'" style="width:120px;">';
  4008. } else {
  4009. inputHtml = '<input type="text" class="ai-inline-input" id="'+uid+'-input" value="'+currentVal+'" style="min-width:100px;">';
  4010. }
  4011. var actionsHtml = '<span class="ai-edit-actions"><button class="ai-btn-save" onclick="event.stopPropagation();qSaveInlineEdit('+cardIdx+','+rowIdx+')" title="保存">✓</button><button class="ai-btn-cancel" onclick="event.stopPropagation();qCancelInlineEdit('+cardIdx+','+rowIdx+')" title="取消">✕</button></span>';
  4012. el.innerHTML = inputHtml + actionsHtml;
  4013. var input = document.getElementById(uid+'-input');
  4014. if (input) {
  4015. input.focus();
  4016. if (meta.editType !== 'select') input.select();
  4017. input.addEventListener('keydown', function(e){
  4018. if (e.key === 'Enter') { e.preventDefault(); qSaveInlineEdit(cardIdx, rowIdx); }
  4019. if (e.key === 'Escape') { e.preventDefault(); qCancelInlineEdit(cardIdx, rowIdx); }
  4020. });
  4021. }
  4022. }
  4023. // 保存内联编辑
  4024. function qSaveInlineEdit(cardIdx, rowIdx){
  4025. if (!qAiEditableData) return;
  4026. var uid = 'ai-edit-'+cardIdx+'-'+rowIdx;
  4027. var input = document.getElementById(uid+'-input');
  4028. if (!input) return;
  4029. var newVal = input.value;
  4030. var meta = qGetEditableMeta(cardIdx, rowIdx);
  4031. if (!meta) { qCancelInlineEdit(cardIdx, rowIdx); return; }
  4032. // 类型转换
  4033. if (meta.editType === 'number') {
  4034. newVal = parseFloat(newVal);
  4035. if (isNaN(newVal) || newVal < 0) { qCancelInlineEdit(cardIdx, rowIdx); return; }
  4036. }
  4037. // 写入数据
  4038. meta.setter(newVal);
  4039. // 如果有对应 qRooms 同步,立即写入
  4040. if (meta.roomIdx != null && qRooms && qRooms[meta.roomIdx]) {
  4041. qRooms[meta.roomIdx][meta.roomField] = newVal;
  4042. // 如果修改了长/宽,重算面积
  4043. if (meta.roomField === 'length' || meta.roomField === 'width') {
  4044. var r = qRooms[meta.roomIdx];
  4045. r.area = null; r.expandArea = null;
  4046. qRooms[meta.roomIdx] = r;
  4047. }
  4048. }
  4049. // 重新渲染卡片
  4050. var isMock = qUploadedDrawing && qUploadedDrawing.name && qUploadedDrawing.name.indexOf('120㎡三房两厅') !== -1;
  4051. qRenderAICards(qAiEditableData, isMock);
  4052. // 立即更新汇总横幅
  4053. qUpdateSummaryBanner(qAiEditableData);
  4054. // 通知用户
  4055. qFlashEditSaved(uid);
  4056. }
  4057. // 取消内联编辑
  4058. function qCancelInlineEdit(cardIdx, rowIdx){
  4059. if (!qAiEditableData) return;
  4060. var isMock = qUploadedDrawing && qUploadedDrawing.name && qUploadedDrawing.name.indexOf('120㎡三房两厅') !== -1;
  4061. qRenderAICards(qAiEditableData, isMock);
  4062. }
  4063. // 短暂闪烁提示保存成功
  4064. function qFlashEditSaved(uid){
  4065. var row = document.getElementById(uid);
  4066. if (!row) return;
  4067. row = row.closest('.ai-data-row');
  4068. if (!row) return;
  4069. row.style.transition = 'background 0.3s';
  4070. row.style.background = '#C6F6D5';
  4071. setTimeout(function(){ row.style.background = ''; }, 800);
  4072. }
  4073. // 保障层高输入框始终可手动编辑:清除任何禁用/只读/遮挡状态
  4074. function qEnsureFloorHeightEditable() {
  4075. var el = document.getElementById('q-floor-height');
  4076. if (!el) return;
  4077. if (el.disabled) el.disabled = false;
  4078. if (el.readOnly) el.readOnly = false;
  4079. el.removeAttribute('disabled');
  4080. el.removeAttribute('readonly');
  4081. el.style.pointerEvents = 'auto';
  4082. el.style.position = 'relative';
  4083. el.style.zIndex = '5';
  4084. el.tabIndex = 0;
  4085. }
  4086. document.addEventListener('mousedown', function(e){
  4087. if (e.target && e.target.id === 'q-floor-height') { e.stopPropagation(); }
  4088. }, true);
  4089. document.addEventListener('click', function(e){
  4090. if (e.target && e.target.id === 'q-floor-height') { e.stopPropagation(); qEnsureFloorHeightEditable(); }
  4091. }, true);
  4092. // 更新层高(独立输入框,位于Step1)并同步到所有房间
  4093. function qUpdateFloorHeight(val) {
  4094. var input1 = document.getElementById('q-floor-height');
  4095. var strVal = (val !== undefined && val !== null) ? String(val).trim() : (input1 ? input1.value : '');
  4096. var isEmpty = strVal === '';
  4097. // 输入为米,直接存储
  4098. var h = parseFloat(strVal);
  4099. var isValid = !isEmpty && !isNaN(h) && h >= 2.0 && h <= 4.0;
  4100. if (!isValid) {
  4101. // 层高为空/越界仅显示"—",不禁用下一步按钮(层高为参考信息,不阻断流程)
  4102. qFloorHeight = 0;
  4103. var fhDisplay = document.getElementById('q-step2-fh-display');
  4104. if (fhDisplay) fhDisplay.textContent = '—';
  4105. qCheckCanAnalyze();
  4106. return;
  4107. }
  4108. qFloorHeight = h;
  4109. var fhDisplay2 = document.getElementById('q-step2-fh-display');
  4110. if (fhDisplay2) fhDisplay2.textContent = h.toFixed(2) + 'm';
  4111. // 层高仅作为参考,不强制更新柜体高度
  4112. if (qAiProcessed && qRooms && qRooms.length) {
  4113. qRenderStep2();
  4114. if (state.currentStep >= 3) qRenderStep4();
  4115. if (state.currentStep >= 4) qRenderStep5();
  4116. }
  4117. qCheckCanAnalyze();
  4118. var nextBtn2 = document.getElementById('q-step2-next-btn');
  4119. if (nextBtn2 && qAiProcessed) { nextBtn2.disabled = false; nextBtn2.style.opacity='1'; nextBtn2.style.cursor='pointer'; }
  4120. }
  4121. function qFilterFloorHeight(input) {
  4122. var v = input.value;
  4123. if (v === '') return;
  4124. var filtered = v.replace(/[^\d.]/g, '');
  4125. var firstDot = filtered.indexOf('.');
  4126. if (firstDot !== -1) {
  4127. var intPart = filtered.substring(0, firstDot);
  4128. var decPart = filtered.substring(firstDot + 1).replace(/\./g, '');
  4129. if (decPart.length > 2) decPart = decPart.substring(0, 2);
  4130. filtered = intPart + '.' + decPart;
  4131. }
  4132. if (filtered !== v) {
  4133. input.value = filtered;
  4134. }
  4135. }
  4136. function qFormatFloorHeight() {
  4137. var input1 = document.getElementById('q-floor-height');
  4138. if (!input1) return;
  4139. var strVal = input1.value.trim();
  4140. if (strVal === '') {
  4141. input1.value = '';
  4142. qUpdateFloorHeight('');
  4143. return;
  4144. }
  4145. // 允许数字和小数点(米为单位,保留2位小数)
  4146. var filtered = strVal.replace(/[^\d.]/g, '');
  4147. var firstDot = filtered.indexOf('.');
  4148. if (firstDot !== -1) {
  4149. var intPart = filtered.substring(0, firstDot);
  4150. var decPart = filtered.substring(firstDot + 1).replace(/\./g, '');
  4151. if (decPart.length > 2) decPart = decPart.substring(0, 2);
  4152. filtered = intPart + '.' + decPart;
  4153. }
  4154. var h = parseFloat(filtered);
  4155. if (isNaN(h) || filtered === '') {
  4156. input1.value = '';
  4157. qUpdateFloorHeight('');
  4158. return;
  4159. }
  4160. if (h < 2.0) h = 2.0;
  4161. if (h > 4.0) h = 4.0;
  4162. input1.value = h.toFixed(2);
  4163. qUpdateFloorHeight(h);
  4164. }
  4165. // Step1:检查是否满足开始AI解析条件(层高有效 + 至少上传一种图纸)
  4166. function qCheckCanAnalyze() {
  4167. qCheckUploadReady();
  4168. }
  4169. // Step2 -> Step3(层高仅作参考,不再强制校验)
  4170. function qGoToStep3FromStep2() {
  4171. // 确保Step2卡片中选择的增项已同步到qRooms
  4172. if(window._qLastAnalysisData && window._qLastAnalysisData.rooms){
  4173. window._qLastAnalysisData.rooms.forEach(function(ar, i){
  4174. if(qRooms[i] && ar._selectedExtras){
  4175. qRooms[i]._selectedExtras = JSON.parse(JSON.stringify(ar._selectedExtras));
  4176. }
  4177. });
  4178. }
  4179. // 应用层高到所有房间,重建qRoomItems
  4180. if (qFloorHeight >= 2.0 && qFloorHeight <= 4.0) {
  4181. qApplyFloorHeightToRooms(qFloorHeight);
  4182. }
  4183. // 将Step2卡片中选择的增项追加到qRoomItems
  4184. for(var ri=0; ri<qRooms.length; ri++){
  4185. var extras = qRooms[ri]._selectedExtras;
  4186. if(extras && extras.length > 0){
  4187. if(!qRoomItems[ri]) qRoomItems[ri] = qBuildRoomItems(ri);
  4188. extras.forEach(function(se){
  4189. var seType = qClassifyMethod(se.method);
  4190. var wM = se.widthM || 0;
  4191. var hM = se.heightM || 0;
  4192. var multiplier = se.multiplier || 1;
  4193. var itemArea = se.qty; // qty已经是计价数量(已乘数量乘数):个/m/㎡
  4194. var dimNote = '';
  4195. if(seType === 'count') dimNote = multiplier + '个';
  4196. else if(seType === 'length') dimNote = (hM||0).toFixed(2)+'m × '+multiplier+' = '+se.qty.toFixed(2)+'m';
  4197. else if(seType === 'width') dimNote = (wM||0).toFixed(2)+'m × '+multiplier+' = '+se.qty.toFixed(2)+'m(宽)';
  4198. else if(seType === 'area') dimNote = (wM||0).toFixed(2)+'×'+(hM||0).toFixed(2)+'m='+(wM*hM).toFixed(3)+'㎡ × '+multiplier+' = '+itemArea.toFixed(3)+'㎡';
  4199. qRoomItems[ri].push({
  4200. name: se.name,
  4201. ruleKey: se.name,
  4202. itemType: 'extra',
  4203. isExtra: true,
  4204. multiplier: multiplier,
  4205. width: wM,
  4206. height: hM,
  4207. area: itemArea,
  4208. unitPrice: se.price,
  4209. total: parseFloat((itemArea * se.price).toFixed(2)),
  4210. note: se.method + '(Step2增项:' + dimNote + ')'
  4211. });
  4212. });
  4213. }
  4214. }
  4215. goToStep(3);
  4216. }
  4217. // 层高联动核心函数:将上传层高应用到所有房间的柜体高度和投影面积
  4218. function qApplyFloorHeightToRooms(h) {
  4219. if (!qRooms || !qRooms.length) return;
  4220. var cabH = parseFloat(h.toFixed(2));
  4221. if (cabH < 2.0) cabH = 2.0;
  4222. if (cabH > 4.0) cabH = 4.0;
  4223. qRooms.forEach(function(r) {
  4224. var cabW = r.cabinetWidth || r.length || 0;
  4225. r.cabinetHeight = cabH;
  4226. r.height = cabH;
  4227. r.width = cabW;
  4228. // 重新计算投影面积 = 宽 × 柜体高(基于上传层高)
  4229. if (cabW > 0) {
  4230. var newArea = parseFloat((cabW * cabH).toFixed(3));
  4231. r.projectArea = newArea;
  4232. r.area = newArea;
  4233. }
  4234. // 更新数据来源标记
  4235. if (r._dataSource) {
  4236. r._dataSource.cabinetHeight = '图纸标注/AI识别';
  4237. r._dataSource.height = r._dataSource.cabinetHeight;
  4238. r._dataSource.width = r._dataSource.cabinetWidth || '图纸文字标注';
  4239. r._dataSource.area = '宽×上传层高计算';
  4240. r._dataSource.projectArea = '宽×上传层高计算';
  4241. }
  4242. });
  4243. // 同步重建房间子项(因为面积变了,柜体小计会变)
  4244. qRoomItems = [];
  4245. for (var i = 0; i < qRooms.length; i++) {
  4246. qRoomItems[i] = qBuildRoomItems(i);
  4247. }
  4248. }
  4249. // (已废弃)层高调节保留接口:Step3不再提供层高输入,层高以Step1填写为准
  4250. function qApplyUniformFloorHeight(val) {
  4251. // Step3层高输入框已移除,层高统一从Step1获取,此函数保留兼容
  4252. }
  4253. // ── 获取可编辑字段的元数据 ──
  4254. // 定义每个卡片每行在数据模型中的映射关系
  4255. function qGetEditableMeta(cardIdx, rowIdx){
  4256. if (!qAiEditableData) return null;
  4257. var d = qAiEditableData;
  4258. var roomIdx = null, roomField = null, editType = 'text', options = null;
  4259. switch(cardIdx){
  4260. case 0: // 户型识别
  4261. editType = 'text';
  4262. return {
  4263. getter: function(){
  4264. var vals = [d.houseType||d.houseLabel, (d.grossArea||d.totalArea)+'', (d.totalArea||0).toFixed(1), d.orientation||'南北通透', d.rooms.length+' 个功能区', d.houseLabel||'标准户型'];
  4265. return vals[rowIdx] || '';
  4266. },
  4267. setter: function(v){
  4268. if (rowIdx === 0) { d.houseType = v; d.houseLabel = v; }
  4269. else if (rowIdx === 1) { d.grossArea = parseFloat(v) || d.grossArea; }
  4270. else if (rowIdx === 2) { d.totalArea = parseFloat(v) || d.totalArea; }
  4271. else if (rowIdx === 3) d.orientation = v;
  4272. else if (rowIdx === 5) { d.houseLabel = v; d.houseType = v; }
  4273. },
  4274. editType: rowIdx < 4 ? (rowIdx === 1 || rowIdx === 2 ? 'number' : 'text') : 'text',
  4275. roomIdx: null, roomField: null
  4276. };
  4277. case 1: // 尺寸提取
  4278. if (rowIdx < d.rooms.length) {
  4279. editType = 'number';
  4280. return {
  4281. getter: function(){
  4282. var r = d.rooms[rowIdx];
  4283. return r.length; // 整行值,但我们允许每个独立编辑
  4284. },
  4285. setter: function(v){
  4286. // 解析 "5.5m × 4.5m = 24.8㎡" → 我们编辑第一个维度值
  4287. // 为简化,这里编辑的是完整渲染值中的长度
  4288. },
  4289. editType: 'text',
  4290. roomIdx: null, roomField: null
  4291. };
  4292. }
  4293. return { getter: function(){return '';}, setter: function(){}, editType:'text' };
  4294. case 2: // 面积计算 — 编辑投影面积
  4295. if (rowIdx < d.rooms.length) {
  4296. roomIdx = rowIdx; roomField = 'projectArea'; editType = 'number';
  4297. return {
  4298. getter: function(){ return d.rooms[rowIdx].projectArea; },
  4299. setter: function(v){ d.rooms[rowIdx].projectArea = parseFloat(v)||d.rooms[rowIdx].projectArea; },
  4300. editType: 'number', roomIdx: rowIdx, roomField: 'projectArea'
  4301. };
  4302. }
  4303. return { getter: function(){return '';}, setter: function(){}, editType:'text' };
  4304. case 5: // 柜体推荐 — cabinetType / layoutStyle / projectArea
  4305. if (rowIdx < d.rooms.length) {
  4306. roomIdx = rowIdx;
  4307. return {
  4308. getter: function(){ return d.rooms[rowIdx].cabinetType; },
  4309. setter: function(v){ d.rooms[rowIdx].cabinetType = v; },
  4310. editType: 'select',
  4311. options: (qCustomTypes && qCustomTypes.cabinetType) ? qCustomTypes.cabinetType : ['衣柜','电视柜','橱柜','书柜','鞋柜','餐边柜','储物柜'],
  4312. roomIdx: rowIdx, roomField: 'cabinetType'
  4313. };
  4314. }
  4315. return { getter: function(){return '';}, setter: function(){}, editType:'text' };
  4316. case 6: // 材质匹配 — material / color
  4317. if (rowIdx < d.rooms.length) {
  4318. roomIdx = rowIdx;
  4319. return {
  4320. getter: function(){
  4321. var r = d.rooms[rowIdx];
  4322. var doorType = r.doorType || '普通平板';
  4323. if (doorType === '风影门板') return '双饰面 / SSM-023 浅橡木 — 现代简约';
  4324. if (doorType === '实木贴皮') return '影木 / YM-001 胡桃木 — 现代简约';
  4325. return '双饰面 / 标准暖白 — 现代简约';
  4326. },
  4327. setter: function(v){
  4328. // 根据选择的材质映射到 doorType
  4329. if (v.indexOf('影木')>=0 || v.indexOf('胡桃')>=0) d.rooms[rowIdx].doorType = '实木贴皮';
  4330. else if (v.indexOf('SSM')>=0 || v.indexOf('风影')>=0) d.rooms[rowIdx].doorType = '风影门板';
  4331. else if (v.indexOf('PET')>=0 || v.indexOf('烤漆')>=0) d.rooms[rowIdx].doorType = 'PET门板';
  4332. else d.rooms[rowIdx].doorType = '普通平板';
  4333. },
  4334. editType: 'select',
  4335. options: ['双饰面 / SSM-023 浅橡木 — 现代简约','双饰面 / 标准暖白 — 现代简约','影木 / YM-001 胡桃木 — 现代简约','PET / 亮光白 — 现代简约','烤漆 / 哑光灰 — 轻奢风','实木贴皮 / 北美黑胡桃 — 新中式'],
  4336. roomIdx: rowIdx, roomField: 'doorType'
  4337. };
  4338. }
  4339. return { getter: function(){return '';}, setter: function(){}, editType:'text' };
  4340. default: return { getter: function(){return '';}, setter: function(){}, editType:'text' };
  4341. }
  4342. }
  4343. // ── 同步编辑数据到 qRooms(一键应用) ──
  4344. function qSyncEditsToRooms(){
  4345. if (!qAiEditableData || !qAiEditableData.rooms) return;
  4346. qRooms = JSON.parse(JSON.stringify(qAiEditableData.rooms));
  4347. qExtras = qGenerateExtrasFromRooms(qRooms);
  4348. showToast('✅ AI分析数据已同步到报价空间,可进入下一步查看', 'success');
  4349. }
  4350. // ── 柜体材质:自定义选项全局保存,各柜体独立选择 ──
  4351. var Q_CABINET_MATERIALS_KEY = 'q_cabinet_material_options_v1';
  4352. function qGetCabinetMaterials(){
  4353. var defaults = ['生态澳松板','铝格栅板'];
  4354. try {
  4355. var saved = JSON.parse(localStorage.getItem(Q_CABINET_MATERIALS_KEY) || '[]');
  4356. if(Array.isArray(saved)) saved.forEach(function(v){
  4357. v = String(v || '').trim();
  4358. if(v && defaults.indexOf(v) < 0) defaults.push(v);
  4359. });
  4360. } catch(e) {}
  4361. return defaults;
  4362. }
  4363. function qMaterialOptionsHtml(selected){
  4364. var current = String(selected || '生态澳松板');
  4365. var materials = qGetCabinetMaterials();
  4366. if(current && materials.indexOf(current) < 0) materials.push(current);
  4367. return materials.map(function(v){
  4368. return '<option value="'+qEscapeHtml(v)+'"'+(v===current?' selected':'')+'>'+qEscapeHtml(v)+'</option>';
  4369. }).join('') + '<option value="__custom__">+ 自定义编辑...</option>';
  4370. }
  4371. function qChangeCabinetMaterial(selectEl, roomIndex){
  4372. var rooms = window._qLastAnalysisData && window._qLastAnalysisData.rooms;
  4373. if(!rooms || !rooms[roomIndex]) return;
  4374. var value = selectEl.value;
  4375. function qApplyMaterialValue(v){
  4376. rooms[roomIndex].material = v;
  4377. rooms[roomIndex].cabinetMaterial = v;
  4378. if(qAiEditableData && qAiEditableData.rooms && qAiEditableData.rooms[roomIndex]){
  4379. qAiEditableData.rooms[roomIndex].material = v;
  4380. qAiEditableData.rooms[roomIndex].cabinetMaterial = v;
  4381. }
  4382. if(qRooms && qRooms[roomIndex]){
  4383. qRooms[roomIndex].material = v;
  4384. qRooms[roomIndex].cabinetMaterial = v;
  4385. }
  4386. qRenderAICards(window._qLastAnalysisData, window._qLastIsMock);
  4387. showToast('已选择柜体材质:' + v, 'success');
  4388. }
  4389. if(value === '__custom__'){
  4390. var prev = rooms[roomIndex].material || '生态澳松板';
  4391. qPromptDialog('请输入新的柜体材质名称:', '', function(custom){
  4392. custom = String(custom || '').trim();
  4393. if(!custom){ selectEl.value = prev; return; }
  4394. var materials = qGetCabinetMaterials();
  4395. if(materials.indexOf(custom) < 0){
  4396. materials.push(custom);
  4397. try { localStorage.setItem(Q_CABINET_MATERIALS_KEY, JSON.stringify(materials)); } catch(e) {}
  4398. }
  4399. qApplyMaterialValue(custom);
  4400. }, function(){ selectEl.value = prev; });
  4401. return;
  4402. }
  4403. qApplyMaterialValue(value);
  4404. }
  4405. // ── 渲染AI识别结果(有序卡片式,只读展示,仅柜体定制相关) ──
  4406. function qRenderAICards(analysisData, isMock){
  4407. // 保存最近一次的分析数据,用于卡片增项操作后局部刷新
  4408. window._qLastAnalysisData = analysisData;
  4409. window._qLastIsMock = isMock;
  4410. var grid = document.getElementById('q-ai-analysis-grid');
  4411. var binaryGrid = document.getElementById('q-step2-binary-grid');
  4412. // 隐藏旧卡片网格,使用二分布局
  4413. if (grid) grid.style.display = 'none';
  4414. var d = analysisData;
  4415. var h = qFloorHeight;
  4416. var hasValidHeight = !isNaN(h) && h >= 2.0 && h <= 4.0;
  4417. // 显示OCR按钮和OCR面板数据
  4418. var ocrBtn = document.getElementById('q-btn-toggle-ocr');
  4419. if (ocrBtn) ocrBtn.style.display = 'inline-block';
  4420. // 渲染OCR面板
  4421. var ocr = window._qLastOCRData;
  4422. if (ocr) {
  4423. var ocrSourceEl = document.getElementById('q-ocr-source-val');
  4424. var ocrConfEl = document.getElementById('q-ocr-confidence-val');
  4425. var ocrRoomEl = document.getElementById('q-ocr-room-count');
  4426. var ocrDimEl = document.getElementById('q-ocr-dim-count');
  4427. var ocrTextList = document.getElementById('q-ocr-text-list');
  4428. var ocrBadge = document.getElementById('q-ocr-source-badge');
  4429. if(ocrSourceEl) ocrSourceEl.textContent = ocr.source || '图纸标注';
  4430. if(ocrConfEl) ocrConfEl.textContent = (ocr.confidence || 98) + '%';
  4431. var cabinetTags = (ocr.roomTags && ocr.roomTags.length) ? ocr.roomTags.length : (d.rooms||[]).length;
  4432. var dimCount = (ocr.dimensions && ocr.dimensions.length) ? ocr.dimensions.length : (d.rooms||[]).length * 2;
  4433. if(ocrRoomEl) ocrRoomEl.textContent = cabinetTags + ' 个';
  4434. if(ocrDimEl) ocrDimEl.textContent = dimCount + ' 条';
  4435. if(ocrBadge) ocrBadge.textContent = ocr.source || '图纸文字标注';
  4436. if(ocrTextList){
  4437. var textItems = [];
  4438. if(ocr.roomTags) ocr.roomTags.forEach(function(t){ textItems.push('<div class="q-ocr-text-item"><span class="text">'+t+'</span><span class="type room">柜体</span></div>'); });
  4439. if(ocr.dimensions) ocr.dimensions.slice(0,14).forEach(function(dim){ textItems.push('<div class="q-ocr-text-item"><span class="text">'+dim+'</span><span class="type dim">尺寸</span></div>'); });
  4440. if(textItems.length === 0){
  4441. (d.rooms||[]).forEach(function(r){ textItems.push('<div class="q-ocr-text-item"><span class="text">'+(r.cabinetType||r.roomName)+' · 宽'+(r.cabinetWidth||r.length||0)+'m · 高'+(r.cabinetHeight||r.height||0)+'m · '+(r.projectArea||r.area||0)+'㎡</span><span class="type room">柜体</span></div>'); });
  4442. }
  4443. ocrTextList.innerHTML = textItems.join('');
  4444. }
  4445. }
  4446. // ── 二分布局渲染 ──
  4447. if (binaryGrid) {
  4448. binaryGrid.classList.remove('hidden');
  4449. var rooms = d.rooms || [];
  4450. // 总投影面积:严格使用图纸标注的各柜体投影面积之和,禁止自动计算
  4451. var totalProjArea = rooms.reduce(function(s,r){ return s + (r.projectArea||r.area||0); }, 0);
  4452. // 左列:空间基础信息
  4453. var leftHtml = '<div class="q-binary-col">' +
  4454. '<div class="q-col-title">📋 柜体清单汇总</div>';
  4455. leftHtml += '<div class="q-info-card">' +
  4456. '<div class="q-info-row"><span class="q-info-label">柜体总数</span><span class="q-info-value highlight">'+rooms.length+' 组</span></div>' +
  4457. '<div class="q-info-row"><span class="q-info-label">总投影面积</span><span class="q-info-value highlight" style="color:#E53E3E;font-size:16px;font-weight:700;">'+totalProjArea.toFixed(3)+' ㎡</span></div>' +
  4458. '<div class="q-info-row"><span class="q-info-label">识别置信度</span><span class="q-info-value success">'+(isMock?'99':'92')+'%</span></div>' +
  4459. '<div class="q-info-row"><span class="q-info-label">数据来源</span><span class="q-info-value" style="color:#38A169;">📝 图纸标注+AI识别</span></div>' +
  4460. '<div class="q-info-row"><span class="q-info-label">层高参考</span><span class="q-info-value '+(hasValidHeight?'success':'warning')+'">'+(hasValidHeight?h.toFixed(2)+'m':'未填写')+'</span></div>' +
  4461. '</div>';
  4462. // 柜体名称标签列表
  4463. leftHtml += '<div class="q-space-tags">';
  4464. rooms.forEach(function(r){
  4465. leftHtml += '<span class="q-space-tag">'+(r.cabinetType||r.roomName)+'</span>';
  4466. });
  4467. leftHtml += '</div>';
  4468. // OCR来源提示
  4469. leftHtml += '<div style="margin-top:12px;padding:8px 12px;background:#F0FFF4;border:1px dashed #68D391;border-radius:6px;font-size:11px;color:#276749;">✅ 柜体名称、宽度、高度严格按照图纸标注或AI识别提取;投影面积=宽度×柜体高度</div>';
  4470. leftHtml += '</div>';
  4471. // 右列:柜体产品覆盖信息
  4472. var rightHtml = '<div class="q-binary-col">' +
  4473. '<div class="q-col-title">🗄️ 柜体产品覆盖信息</div>';
  4474. rooms.forEach(function(r, i){
  4475. var cabW = r.cabinetWidth || r.length || 0;
  4476. if(qIsKitchenCabinet(r.cabinetType||r.roomName) && state.quoteMode==='ai'){ var _kb2=qGetKitchenBillableMeters(r); if(_kb2&&_kb2.billable>0) cabW=_kb2.billable; }
  4477. var cabH = r.cabinetHeight || r.height || 0;
  4478. var projA = r.projectArea || r.area || 0;
  4479. if(!r.material) r.material = r.cabinetMaterial || (qIsMasterWardrobe(r.cabinetType||r.roomName)?'铝格栅板':'生态澳松板');
  4480. var roomPricing=qGetCabinetPricingRule(r,cabW,cabH);
  4481. var isMulti = r._isMulti;
  4482. var borderStyle = isMulti ? 'border-left:4px solid '+(r._sheetColor||'#3182CE')+';' : 'border-left:4px solid #38A169;';
  4483. // 初始化柜体详情表格数据(Excel模板样式:根据柜体类型加载对应增项模板)
  4484. if(!r._detailItems || r._detailItemsExcelVersion!=='ouyang_20260618_v1') {
  4485. r._detailItems = [];
  4486. // 默认柜体行(浅绿色背景)- 从配置读取单价
  4487. var basePrice = roomPricing.unitPrice;
  4488. r._detailItems.push({
  4489. type: 'cabinet',
  4490. name: '柜体',
  4491. width: cabW,
  4492. height: cabH,
  4493. area: projA,
  4494. price: basePrice,
  4495. total: parseFloat(roomPricing.total.toFixed(2)),
  4496. note: qMaterialOnlyNote(roomPricing.material, r, {isBase:true,width:cabW,height:cabH}),
  4497. isBase: true,
  4498. _priceModified: false
  4499. });
  4500. // ── 根据柜体名称从模板映射加载对应的增项(与Excel保持一致) ──
  4501. var cabinetName = r.cabinetType || r.roomName || '';
  4502. var extraDetails = qGetExtrasForCabinet(cabinetName);
  4503. extraDetails.forEach(function(extraDetail){
  4504. var extraName=extraDetail.name;
  4505. var method=extraDetail.method||EXTRA_METHOD_MAP[extraName]||'按个数';
  4506. var exPrice=Number(extraDetail.price)||0;
  4507. r._detailItems.push({
  4508. type: 'extra',
  4509. name: extraName,
  4510. width: 0,
  4511. height: 0,
  4512. qty: 0,
  4513. area: 0,
  4514. price: exPrice,
  4515. total: 0,
  4516. note: '',
  4517. method: method,
  4518. _excelPrice: exPrice,
  4519. isBase: false,
  4520. _priceModified: false
  4521. });
  4522. });
  4523. r._detailItemsExcelVersion='ouyang_20260618_v1';
  4524. } else {
  4525. // 已存在的详情:同步配置中的单价(仅未手动修改过的项目)
  4526. r._detailItems.forEach(function(item){
  4527. if(!item._priceModified){
  4528. var cfgPrice = item.isBase ? roomPricing.unitPrice : (item._excelPrice!==undefined?Number(item._excelPrice):qGetConfiguredPrice(item.name));
  4529. if(item.price !== cfgPrice){
  4530. item.price = cfgPrice;
  4531. }
  4532. }
  4533. });
  4534. }
  4535. // 兼容旧报价:历史厨房明细漏了“地柜”时自动补齐,并保留已填的其他项数据。
  4536. if(qIsKitchenCabinet(r.cabinetType||r.roomName) && !r._detailItems.some(function(detail){return detail&&detail.name==='地柜';})){
  4537. var groundPrice=qGetConfiguredPrice('地柜')||2380;
  4538. r._detailItems.splice(1,0,{type:'extra',name:'地柜',width:0,height:0,qty:0,area:0,price:groundPrice,total:0,note:'',method:'按延米',_excelPrice:groundPrice,isBase:false,_priceModified:false});
  4539. }
  4540. // 确保柜体行的宽高与当前一致,并重新计算总价
  4541. if(r._detailItems[0] && r._detailItems[0].isBase) {
  4542. r._detailItems[0].width = cabW;
  4543. r._detailItems[0].height = cabH;
  4544. r._detailItems[0].area = projA;
  4545. r._detailItems[0].note = qMaterialOnlyNote(roomPricing.material, r, r._detailItems[0]);
  4546. r._detailItems[0].total = parseFloat(roomPricing.total.toFixed(2));
  4547. }
  4548. // 渲染详情表格
  4549. var tableHtml = '<table class="q-cabinet-detail-table" id="q-detail-table-'+i+'">'+
  4550. '<colgroup><col style="width:14%"><col style="width:10%"><col style="width:10%"><col style="width:10%"><col style="width:7%"><col style="width:13%"><col style="width:12%"><col style="width:19%"><col style="width:5%"></colgroup>';
  4551. tableHtml += '<thead><tr>';
  4552. tableHtml += '<th style="width:14%;">项目</th>';
  4553. tableHtml += '<th style="width:10%;">宽(M)</th>';
  4554. tableHtml += '<th style="width:10%;">高(M)</th>';
  4555. tableHtml += '<th style="width:10%;">面积(M²)</th>';
  4556. tableHtml += '<th style="width:7%;">数量</th>';
  4557. tableHtml += '<th style="width:13%;">单价</th>';
  4558. tableHtml += '<th style="width:12%;">总价</th>';
  4559. tableHtml += '<th style="width:19%;">备注</th>';
  4560. tableHtml += '<th style="width:5%;">操作</th>';
  4561. tableHtml += '</tr></thead><tbody>';
  4562. var cabinetTotal = 0;
  4563. r._detailItems.forEach(function(item, di){
  4564. var rowClass = item.isBase ? 'cabinet-row' : 'extra-row';
  4565. var itemType = item.method ? qClassifyMethod(item.method) : (item.isBase ? 'area' : 'count');
  4566. // 面积显示逻辑:只要填写了宽和高,一律按宽×高计算;无尺寸时才兼容原计数方式
  4567. var areaVal = 0;
  4568. if(itemType === 'count'){
  4569. // 按数量计价的项目不使用宽、高和面积,只按数量×单价。
  4570. item.width = 0;
  4571. item.height = 0;
  4572. item.area = 0;
  4573. areaVal = item.qty || 0;
  4574. } else if((item.width||0) > 0 && (item.height||0) > 0){
  4575. item.area = parseFloat(((item.width||0) * (item.height||0)).toFixed(3));
  4576. item.qty = item.area;
  4577. areaVal = item.area;
  4578. } else if(itemType === 'area' || item.isBase){
  4579. areaVal = item.area || 0;
  4580. } else if(itemType === 'length'){
  4581. areaVal = item.qty || item.height || 0;
  4582. } else {
  4583. areaVal = item.qty || 0;
  4584. }
  4585. // 重新计算总价确保准确
  4586. var calcTotal = 0;
  4587. calcTotal = parseFloat(((areaVal||0) * (item.price||0)).toFixed(2));
  4588. item.total = calcTotal;
  4589. cabinetTotal += calcTotal;
  4590. // 格式化显示值:0值显示为空
  4591. var wVal = (item.width && item.width > 0) ? item.width.toFixed(2) : '';
  4592. var hVal = (item.height && item.height > 0) ? item.height.toFixed(2) : '';
  4593. var aVal = (areaVal && areaVal > 0) ? areaVal.toFixed(3) : '';
  4594. var qtyVal = (itemType === 'count' && item.qty > 0) ? item.qty : '';
  4595. var priceVal = (item.price && item.price > 0) ? item.price.toFixed(2) : '';
  4596. var totalDisplay = calcTotal > 0 ? '¥'+calcTotal.toFixed(2) : '—';
  4597. var kitchenDoorKind = qKitchenDoorKind(item.name);
  4598. var kitchenDoorCfg = null;
  4599. if(kitchenDoorKind){
  4600. var previousDoorTotal=calcTotal;
  4601. kitchenDoorCfg=qKitchenRuleSelection({name:item.name,value:item.price,doorConfigId:item.doorConfigId});
  4602. item.doorConfigId=kitchenDoorCfg.id;
  4603. item.price=Number(kitchenDoorCfg[kitchenDoorKind])||0;
  4604. item.note=qKitchenDoorMaterialLabel(kitchenDoorCfg);
  4605. priceVal=item.price.toFixed(2);
  4606. calcTotal=parseFloat(((areaVal||0)*item.price).toFixed(2));
  4607. item.total=calcTotal;
  4608. cabinetTotal += (calcTotal-previousDoorTotal);
  4609. totalDisplay=calcTotal>0?'¥'+calcTotal.toFixed(2):'—';
  4610. }
  4611. tableHtml += '<tr class="'+rowClass+'">';
  4612. // 项目名称:所有行都是可编辑文本框(柜体行浅绿色加粗)
  4613. tableHtml += '<td class="item-name-col"><input type="text" value="'+(item.name||'')+'" data-room="'+i+'" data-item="'+di+'" data-field="name" onchange="qUpdateDetailItem(this)" style="'+(item.isBase?'font-weight:600;':'')+'"></td>';
  4614. // 宽
  4615. tableHtml += itemType === 'count' ? '<td class="calc-col">—</td>' : '<td><input type="number" step="0.01" min="0" placeholder="—" value="'+wVal+'" data-room="'+i+'" data-item="'+di+'" data-field="width" onchange="qUpdateDetailItem(this)"></td>';
  4616. // 高
  4617. tableHtml += itemType === 'count' ? '<td class="calc-col">—</td>' : '<td><input type="number" step="0.01" min="0" placeholder="—" value="'+hVal+'" data-room="'+i+'" data-item="'+di+'" data-field="height" onchange="qUpdateDetailItem(this)"></td>';
  4618. // 所有项目面积均由宽×高自动计算,只读显示
  4619. tableHtml += '<td class="calc-col"><span class="readonly-value" title="由宽度×高度自动计算">'+(itemType === 'count' ? '—' : (aVal||'—'))+'</span></td>';
  4620. // 数量栏:仅按数量计价的项目可编辑。
  4621. tableHtml += itemType === 'count' ? '<td><input type="number" step="1" min="0" placeholder="0" value="'+qtyVal+'" data-room="'+i+'" data-item="'+di+'" data-field="qty" onchange="qUpdateDetailItem(this)"></td>' : '<td class="calc-col">—</td>';
  4622. // 单价
  4623. tableHtml += kitchenDoorKind ? '<td><select title="选择门板配置单价" style="width:100%;color:#2563A6;font-weight:700;" onchange="qKitchenDoorDetailChange('+i+','+di+',this)">'+qKitchenDoorPriceOptionsHtml(kitchenDoorKind,item.doorConfigId)+'</select></td>' : '<td><input type="number" step="0.01" min="0" placeholder="—" value="'+priceVal+'" data-room="'+i+'" data-item="'+di+'" data-field="price" onchange="qUpdateDetailItem(this)"></td>';
  4624. // 总价(自动计算)
  4625. tableHtml += '<td class="calc-col total-price-col" title="由面积/数量×单价自动计算">'+totalDisplay+'</td>';
  4626. // 备注
  4627. tableHtml += kitchenDoorKind ? '<td class="note-col"><select title="选择门板配置" style="width:100%;text-align:left;" onchange="qKitchenDoorDetailChange('+i+','+di+',this)">'+qKitchenDoorOptionsHtml(kitchenDoorKind,item.doorConfigId)+'</select></td>' : '<td class="note-col"><input type="text" value="'+(item.note||'')+'" placeholder="备注" data-room="'+i+'" data-item="'+di+'" data-field="note" onchange="qUpdateDetailItem(this)"></td>';
  4628. // 删除按钮(柜体行不可删除)
  4629. tableHtml += '<td>';
  4630. if(!item.isBase){
  4631. tableHtml += '<button class="del-btn" onclick="qDeleteDetailItem('+i+','+di+')" title="删除此项">✕</button>';
  4632. }
  4633. tableHtml += '</td>';
  4634. tableHtml += '</tr>';
  4635. });
  4636. // 合计行
  4637. tableHtml += '<tr class="total-row"><td colspan="6" style="text-align:right;padding-right:8px;">合计</td><td>'+(cabinetTotal>0?'¥'+cabinetTotal.toFixed(2):'—')+'</td><td colspan="2"></td></tr>';
  4638. tableHtml += '</tbody></table>';
  4639. // 添加行工具栏(简化:只保留添加自定义行按钮,无需选择增项类型)
  4640. var addBarHtml = '<div class="q-add-row-bar" style="justify-content:center;">';
  4641. addBarHtml += '<button onclick="qAddEmptyDetailItem('+i+')" style="width:100%;background:#718096;">+ 添加自定义项目行</button>';
  4642. addBarHtml += '</div>';
  4643. // 判断是否默认展开
  4644. var isExpanded = r._detailExpanded !== false; // 默认展开
  4645. rightHtml += '<div class="q-cabinet-card" style="'+borderStyle+'">' +
  4646. '<div class="q-cabinet-header">' +
  4647. '<div class="q-cabinet-title-group"><span class="q-cabinet-name">'+(i+1)+'. '+(r.cabinetType||r.roomName||'柜体')+'</span>' +
  4648. '<select class="q-material-select" aria-label="选择柜体材质" onchange="qChangeCabinetMaterial(this,'+i+')">'+qMaterialOptionsHtml(r.material)+'</select></div>' +
  4649. (isMulti?'<span class="q-sheet-badge" style="background:'+(r._sheetColor||'#3182CE')+'22;color:'+(r._sheetColor||'#3182CE')+';">📄 '+r.sourceSheetName+'</span>':'') +
  4650. '</div>' +
  4651. '<div class="q-cabinet-dims">' +
  4652. '<div class="q-dim-item"><span class="dim-label">宽度</span><span class="dim-value">'+cabW.toFixed(2)+'m</span></div>' +
  4653. '<div class="q-dim-item"><span class="dim-label">柜体高度</span><span class="dim-value">'+cabH.toFixed(2)+'m</span></div>' +
  4654. '<div class="q-dim-item highlight"><span class="dim-label">投影面积</span><span class="dim-value" style="color:#E53E3E;">'+projA.toFixed(3)+'㎡</span></div>' +
  4655. '</div>' +
  4656. '<div style="display:flex;justify-content:space-between;align-items:center;">' +
  4657. '<div class="q-cabinet-meta">布局:'+(r.layoutStyle||'一字型')+' · 数据来源:AI识别填充+可编辑 · 不需要的项可点✕删除</div>' +
  4658. '<span class="q-cabinet-toggle '+(isExpanded?'expanded':'')+'" onclick="qToggleCabinetDetail('+i+')">' +
  4659. '<span class="arrow">▶</span> '+(isExpanded?'收起详情':'展开详情')+
  4660. '</span>' +
  4661. '</div>' +
  4662. '<div id="q-cabinet-detail-'+i+'" style="'+(isExpanded?'':'display:none;')+'">' +
  4663. tableHtml +
  4664. addBarHtml +
  4665. '</div>' +
  4666. '</div>';
  4667. });
  4668. rightHtml += '</div>';
  4669. binaryGrid.innerHTML = leftHtml + rightHtml;
  4670. }
  4671. // 层高有效且AI处理完成时启用下一步按钮(层高为参考信息,不影响柜体尺寸)
  4672. var nextBtn = document.getElementById('q-step2-next-btn');
  4673. if (nextBtn) {
  4674. if (hasValidHeight) {
  4675. nextBtn.disabled = false;
  4676. nextBtn.style.opacity='1';
  4677. nextBtn.style.cursor='pointer';
  4678. } else {
  4679. nextBtn.disabled = true;
  4680. nextBtn.style.opacity='0.5';
  4681. nextBtn.style.cursor='not-allowed';
  4682. }
  4683. }
  4684. // 同步Step2层高显示
  4685. var fhDisplay = document.getElementById('q-step2-fh-display');
  4686. if (fhDisplay) fhDisplay.textContent = hasValidHeight ? h.toFixed(2) : '—';
  4687. // 更新汇总横幅
  4688. qUpdateSummaryBanner(d);
  4689. }
  4690. // ── Step2柜体卡片增项操作 ──
  4691. // 根据计价方式文字归类:count(按个数) / length(按延米) / area(按面积) / width(按宽度)
  4692. function qClassifyMethod(method){
  4693. if(!method) return 'count';
  4694. var m = String(method);
  4695. if(/面积|平方|㎡|m²/.test(m)) return 'area';
  4696. if(/宽度|宽/.test(m) && !/高度|高/.test(m)) return 'width';
  4697. if(/延米|米|长度/.test(m) && !/面积|平方|宽度/.test(m)) return 'length';
  4698. if(/个|件|套|只|支/.test(m)) return 'count';
  4699. return 'count';
  4700. }
  4701. // 当选择增项时,根据计价方式动态显示对应尺寸输入框(数量框已默认显示)
  4702. function qCardExtraSelChange(roomIdx){
  4703. var sel = document.getElementById('q-card-extra-sel-'+roomIdx);
  4704. var inputsDiv = document.getElementById('q-card-extra-inputs-'+roomIdx);
  4705. if(!sel || !inputsDiv) return;
  4706. var ei = parseInt(sel.value);
  4707. if(isNaN(ei) || ei < 0 || !DEFAULT_RULES.extra[ei]){
  4708. inputsDiv.innerHTML = '';
  4709. inputsDiv.style.display = 'none';
  4710. return;
  4711. }
  4712. var ex = DEFAULT_RULES.extra[ei];
  4713. var type = qClassifyMethod(ex.method);
  4714. var html = '';
  4715. if(type === 'count'){
  4716. // 按个数:数量输入框已默认显示,无需额外输入
  4717. inputsDiv.innerHTML = '';
  4718. inputsDiv.style.display = 'none';
  4719. return;
  4720. } else if(type === 'length'){
  4721. html = '<span style="font-size:10px;color:#718096;">长度:</span>'+
  4722. '<input type="number" id="q-card-extra-len-'+roomIdx+'" value="" min="0.01" step="0.01" placeholder="长m" oninput="qCardExtraCalcLen('+roomIdx+')" style="width:62px;font-size:11px;padding:3px 4px;border:1px solid #CBD5E0;border-radius:4px;text-align:center;outline:none;" title="长度(米)">'+
  4723. '<span style="font-size:10px;color:#2D3748;font-weight:600;min-width:32px;text-align:right;" id="q-card-extra-lenm-'+roomIdx+'">0.00</span><span style="font-size:10px;color:#718096;">m</span>';
  4724. } else if(type === 'width'){
  4725. html = '<span style="font-size:10px;color:#718096;">宽度:</span>'+
  4726. '<input type="number" id="q-card-extra-w-'+roomIdx+'" value="" min="0.01" step="0.01" placeholder="宽m" oninput="qCardExtraCalcLen('+roomIdx+')" style="width:62px;font-size:11px;padding:3px 4px;border:1px solid #CBD5E0;border-radius:4px;text-align:center;outline:none;" title="宽度(米)">'+
  4727. '<span style="font-size:10px;color:#2D3748;font-weight:600;min-width:32px;text-align:right;" id="q-card-extra-lenm-'+roomIdx+'">0.00</span><span style="font-size:10px;color:#718096;">m</span>';
  4728. } else if(type === 'area'){
  4729. html = '<span style="font-size:10px;color:#718096;">宽:</span>'+
  4730. '<input type="number" id="q-card-extra-w-'+roomIdx+'" value="" min="0.01" step="0.01" placeholder="宽m" oninput="qCardExtraCalcArea('+roomIdx+')" style="width:56px;font-size:11px;padding:3px 4px;border:1px solid #CBD5E0;border-radius:4px;text-align:center;outline:none;" title="宽度(米)">'+
  4731. '<span style="font-size:10px;color:#A0AEC0;">×</span>'+
  4732. '<span style="font-size:10px;color:#718096;">高:</span>'+
  4733. '<input type="number" id="q-card-extra-h-'+roomIdx+'" value="" min="0.01" step="0.01" placeholder="高m" oninput="qCardExtraCalcArea('+roomIdx+')" style="width:56px;font-size:11px;padding:3px 4px;border:1px solid #CBD5E0;border-radius:4px;text-align:center;outline:none;" title="高度(米)">'+
  4734. '<span style="font-size:10px;color:#2D3748;font-weight:600;min-width:36px;text-align:right;" id="q-card-extra-area-'+roomIdx+'">0.00</span><span style="font-size:10px;color:#718096;">㎡</span>';
  4735. }
  4736. inputsDiv.innerHTML = html;
  4737. inputsDiv.style.display = 'inline-flex';
  4738. inputsDiv.style.alignItems = 'center';
  4739. inputsDiv.style.gap = '3px';
  4740. }
  4741. // 按延米/宽度:实时计算米数
  4742. function qCardExtraCalcLen(roomIdx){
  4743. var lenInput = document.getElementById('q-card-extra-len-'+roomIdx);
  4744. var wInput = document.getElementById('q-card-extra-w-'+roomIdx);
  4745. var lenmSpan = document.getElementById('q-card-extra-lenm-'+roomIdx);
  4746. if(!lenmSpan) return;
  4747. var m = 0;
  4748. if(lenInput) m = parseFloat(lenInput.value) || 0;
  4749. else if(wInput) m = parseFloat(wInput.value) || 0;
  4750. lenmSpan.textContent = m.toFixed(2);
  4751. }
  4752. // 按面积:实时计算面积
  4753. function qCardExtraCalcArea(roomIdx){
  4754. var wInput = document.getElementById('q-card-extra-w-'+roomIdx);
  4755. var hInput = document.getElementById('q-card-extra-h-'+roomIdx);
  4756. var areaSpan = document.getElementById('q-card-extra-area-'+roomIdx);
  4757. if(!areaSpan) return;
  4758. var w = parseFloat(wInput ? wInput.value : 0) || 0;
  4759. var h = parseFloat(hInput ? hInput.value : 0) || 0;
  4760. var area = w * h; // m*m → m²
  4761. areaSpan.textContent = area.toFixed(3);
  4762. }
  4763. function qAddCardExtra(roomIdx){
  4764. var rooms = (window._qLastAnalysisData && window._qLastAnalysisData.rooms) ? window._qLastAnalysisData.rooms : qRooms;
  4765. var r = rooms[roomIdx];
  4766. if(!r) return;
  4767. if(!r._selectedExtras) r._selectedExtras = [];
  4768. var sel = document.getElementById('q-card-extra-sel-'+roomIdx);
  4769. var qtyInput = document.getElementById('q-card-extra-qty-'+roomIdx);
  4770. if(!sel) return;
  4771. var ei = parseInt(sel.value);
  4772. if(isNaN(ei) || ei < 0 || !DEFAULT_RULES.extra[ei]){
  4773. showToast('请先选择增值项', 'info');
  4774. return;
  4775. }
  4776. var ex = DEFAULT_RULES.extra[ei];
  4777. var price = parseFloat(ex.value !== undefined ? ex.value : ex.price) || 0;
  4778. var type = qClassifyMethod(ex.method);
  4779. var multiplier = qtyInput ? (parseFloat(qtyInput.value) || 1) : 1;
  4780. if(multiplier <= 0) multiplier = 1;
  4781. var baseQty = 0;
  4782. var widthM = 0, heightM = 0;
  4783. var dimDesc = '';
  4784. if(type === 'count'){
  4785. // 按个数:数量直接就是个数
  4786. baseQty = multiplier;
  4787. dimDesc = multiplier + '个';
  4788. } else if(type === 'length'){
  4789. var lenInput = document.getElementById('q-card-extra-len-'+roomIdx);
  4790. var lenM = lenInput ? (parseFloat(lenInput.value) || 0) : 0;
  4791. if(lenM <= 0){ showToast('请输入长度(m)', 'info'); return; }
  4792. widthM = 0;
  4793. heightM = lenM;
  4794. baseQty = parseFloat((lenM * multiplier).toFixed(3)); // 长度 × 数量
  4795. dimDesc = lenM.toFixed(2)+'m × '+multiplier+' = '+baseQty.toFixed(2)+'m';
  4796. } else if(type === 'width'){
  4797. var wInput = document.getElementById('q-card-extra-w-'+roomIdx);
  4798. var wM = wInput ? (parseFloat(wInput.value) || 0) : 0;
  4799. if(wM <= 0){ showToast('请输入宽度(m)', 'info'); return; }
  4800. widthM = wM;
  4801. heightM = 0;
  4802. baseQty = parseFloat((wM * multiplier).toFixed(3)); // 宽度 × 数量
  4803. dimDesc = wM.toFixed(2)+'m × '+multiplier+' = '+baseQty.toFixed(2)+'m(宽)';
  4804. } else if(type === 'area'){
  4805. var wInp = document.getElementById('q-card-extra-w-'+roomIdx);
  4806. var hInp = document.getElementById('q-card-extra-h-'+roomIdx);
  4807. var wM = wInp ? (parseFloat(wInp.value) || 0) : 0;
  4808. var hM = hInp ? (parseFloat(hInp.value) || 0) : 0;
  4809. if(wM <= 0 || hM <= 0){ showToast('请输入宽度和高度(m)', 'info'); return; }
  4810. widthM = wM;
  4811. heightM = hM;
  4812. var singleArea = parseFloat((wM * hM).toFixed(4));
  4813. baseQty = parseFloat((singleArea * multiplier).toFixed(4)); // 面积 × 数量
  4814. dimDesc = wM.toFixed(2)+'×'+hM.toFixed(2)+'m='+singleArea.toFixed(3)+'㎡ × '+multiplier+' = '+baseQty.toFixed(3)+'㎡';
  4815. }
  4816. var total = parseFloat((baseQty * price).toFixed(2));
  4817. r._selectedExtras.push({
  4818. extraIdx: ei,
  4819. name: ex.name,
  4820. method: ex.method,
  4821. price: price,
  4822. unit: ex.unit,
  4823. qty: baseQty,
  4824. multiplier: multiplier,
  4825. widthM: widthM,
  4826. heightM: heightM,
  4827. total: total
  4828. });
  4829. // 重置选择和输入
  4830. sel.value = '';
  4831. if(qtyInput) qtyInput.value = '1';
  4832. var inputsDiv = document.getElementById('q-card-extra-inputs-'+roomIdx);
  4833. if(inputsDiv){ inputsDiv.innerHTML = ''; inputsDiv.style.display = 'none'; }
  4834. // 重新渲染卡片区域
  4835. qRenderAICards(window._qLastAnalysisData, window._qLastIsMock);
  4836. showToast('✅ 已添加:' + ex.name + ' ' + dimDesc + ' ¥' + total.toFixed(0), 'success');
  4837. }
  4838. function qRemoveCardExtra(roomIdx, extraIdx){
  4839. var rooms = (window._qLastAnalysisData && window._qLastAnalysisData.rooms) ? window._qLastAnalysisData.rooms : qRooms;
  4840. var r = rooms[roomIdx];
  4841. if(!r || !r._selectedExtras) return;
  4842. r._selectedExtras.splice(extraIdx, 1);
  4843. qRenderAICards(window._qLastAnalysisData, window._qLastIsMock);
  4844. }
  4845. // ── 柜体详情表格(Excel模板样式)操作函数 ──
  4846. // 展开/折叠柜体详情
  4847. function qToggleCabinetDetail(roomIdx){
  4848. var rooms = (window._qLastAnalysisData && window._qLastAnalysisData.rooms) ? window._qLastAnalysisData.rooms : qRooms;
  4849. var r = rooms[roomIdx];
  4850. if(!r) return;
  4851. r._detailExpanded = !r._detailExpanded;
  4852. qRenderAICards(window._qLastAnalysisData, window._qLastIsMock);
  4853. }
  4854. // 更新详情表格中的数据项
  4855. function qKitchenDoorDetailChange(roomIdx,itemIdx,select){
  4856. var rooms=(window._qLastAnalysisData&&window._qLastAnalysisData.rooms)?window._qLastAnalysisData.rooms:qRooms;
  4857. var room=rooms[roomIdx],item=room&&room._detailItems&&room._detailItems[itemIdx];
  4858. var kind=qKitchenDoorKind(item&&item.name),cfg=qKitchenDoorFind(select.value);
  4859. if(!item||!kind||!cfg||cfg[kind]==null)return;
  4860. item.doorConfigId=cfg.id;
  4861. item.price=Number(cfg[kind])||0;
  4862. item.note=qKitchenDoorMaterialLabel(cfg);
  4863. item._priceModified=true;
  4864. item._excelPrice=item.price;
  4865. qSyncDetailItemsToExtras(room);
  4866. qRenderAICards(window._qLastAnalysisData,window._qLastIsMock);
  4867. showToast('已切换'+item.name+'门板配置,单价已自动更新为 ¥'+item.price+'/m','success');
  4868. }
  4869. function qUpdateDetailItem(el){
  4870. var rooms = (window._qLastAnalysisData && window._qLastAnalysisData.rooms) ? window._qLastAnalysisData.rooms : qRooms;
  4871. var roomIdx = parseInt(el.getAttribute('data-room'));
  4872. var itemIdx = parseInt(el.getAttribute('data-item'));
  4873. var field = el.getAttribute('data-field');
  4874. var r = rooms[roomIdx];
  4875. if(!r || !r._detailItems || !r._detailItems[itemIdx]) return;
  4876. var item = r._detailItems[itemIdx];
  4877. var val = el.value;
  4878. // 数值字段转换
  4879. if(['width','height','qty','qtyarea','price'].indexOf(field) !== -1){
  4880. val = parseFloat(val) || 0;
  4881. }
  4882. // 根据字段更新对应属性
  4883. if(field === 'name'){
  4884. item.name = val;
  4885. // 如果输入了已知增项名称,且用户未手动修改过单价,则自动填充配置单价
  4886. if(!item.isBase && val){
  4887. var cfgPrice = qGetConfiguredPrice(val);
  4888. if(cfgPrice > 0 && !item._priceModified){
  4889. item.price = cfgPrice;
  4890. }
  4891. // 查找计价方式
  4892. var method = EXTRA_METHOD_MAP[val] || '按个数';
  4893. if(!item._priceModified){
  4894. item.method = method;
  4895. }
  4896. }
  4897. } else if(field === 'width'){
  4898. item.width = val;
  4899. if((item.width||0) > 0 && (item.height||0) > 0){
  4900. item.area = parseFloat((item.width * item.height).toFixed(3));
  4901. item.qty = item.area;
  4902. }
  4903. } else if(field === 'height'){
  4904. item.height = val;
  4905. if(item.isBase){
  4906. // 柜体行:同步更新柜体高度
  4907. r.cabinetHeight = val;
  4908. r.height = val;
  4909. item.area = parseFloat((item.width * item.height).toFixed(3));
  4910. item.qty = item.area;
  4911. } else if((item.width||0) > 0 && (item.height||0) > 0){
  4912. item.area = parseFloat((item.width * item.height).toFixed(3));
  4913. item.qty = item.area;
  4914. }
  4915. } else if(field === 'qtyarea'){
  4916. // 统一按面积处理,直接写入area字段
  4917. item.area = val;
  4918. item.qty = val;
  4919. } else if(field === 'qty'){
  4920. // 按数量计价:不保留宽、高和面积,总价由数量×单价自动得出。
  4921. item.qty = val;
  4922. item.width = 0;
  4923. item.height = 0;
  4924. item.area = 0;
  4925. } else if(field === 'price'){
  4926. item.price = val;
  4927. // 标记用户手动修改过单价,后续配置更新不会覆盖此值
  4928. item._priceModified = true;
  4929. } else if(field === 'note'){
  4930. item.note = val;
  4931. }
  4932. // 同步柜体基础数据(柜体行)
  4933. if(item.isBase){
  4934. r.cabinetWidth = item.width;
  4935. r.length = item.width;
  4936. r.projectArea = item.area;
  4937. r.area = item.area;
  4938. }
  4939. // 同步旧的_selectedExtras数据结构(保持兼容性)
  4940. qSyncDetailItemsToExtras(r);
  4941. // 重新渲染
  4942. qRenderAICards(window._qLastAnalysisData, window._qLastIsMock);
  4943. }
  4944. // 添加新的详情行
  4945. function qAddDetailItem(roomIdx){
  4946. var rooms = (window._qLastAnalysisData && window._qLastAnalysisData.rooms) ? window._qLastAnalysisData.rooms : qRooms;
  4947. var r = rooms[roomIdx];
  4948. if(!r) return;
  4949. if(!r._detailItems) r._detailItems = [];
  4950. var sel = document.getElementById('q-add-extra-sel-'+roomIdx);
  4951. if(!sel || !sel.value){
  4952. showToast('请先选择增项类型', 'info');
  4953. return;
  4954. }
  4955. var ei = parseInt(sel.value);
  4956. var ex = DEFAULT_RULES.extra[ei];
  4957. if(!ex) return;
  4958. var itemType = qClassifyMethod(ex.method);
  4959. var newItem = {
  4960. type: 'extra',
  4961. name: ex.name,
  4962. width: 0,
  4963. height: 0,
  4964. qty: 1,
  4965. area: 0,
  4966. price: parseFloat(ex.price)||0,
  4967. total: 0,
  4968. note: ex.desc || '',
  4969. method: ex.method,
  4970. isBase: false
  4971. };
  4972. // 根据计价方式初始化默认值
  4973. if(itemType === 'area'){
  4974. newItem.width = r.cabinetWidth || r.length || 0;
  4975. newItem.height = r.cabinetHeight || r.height || 0;
  4976. newItem.area = newItem.width * newItem.height;
  4977. } else if(itemType === 'length'){
  4978. newItem.qty = r.cabinetWidth || r.length || 0;
  4979. newItem.height = newItem.qty;
  4980. }
  4981. r._detailItems.push(newItem);
  4982. // 展开详情
  4983. r._detailExpanded = true;
  4984. // 同步旧数据结构
  4985. qSyncDetailItemsToExtras(r);
  4986. // 重新渲染
  4987. qRenderAICards(window._qLastAnalysisData, window._qLastIsMock);
  4988. showToast('✅ 已添加:' + ex.name, 'success');
  4989. }
  4990. // 添加空的自定义项目行(用户手动填写项目名称和数据)
  4991. function qAddEmptyDetailItem(roomIdx){
  4992. var rooms = (window._qLastAnalysisData && window._qLastAnalysisData.rooms) ? window._qLastAnalysisData.rooms : qRooms;
  4993. var r = rooms[roomIdx];
  4994. if(!r) return;
  4995. if(!r._detailItems) r._detailItems = [];
  4996. r._detailItems.push({
  4997. type: 'extra',
  4998. name: '',
  4999. width: 0,
  5000. height: 0,
  5001. qty: 0,
  5002. area: 0,
  5003. price: 0,
  5004. total: 0,
  5005. note: '',
  5006. method: '按个数',
  5007. isBase: false,
  5008. _priceModified: false
  5009. });
  5010. // 展开详情
  5011. r._detailExpanded = true;
  5012. // 同步旧数据结构
  5013. qSyncDetailItemsToExtras(r);
  5014. // 重新渲染
  5015. qRenderAICards(window._qLastAnalysisData, window._qLastIsMock);
  5016. }
  5017. // 删除详情行
  5018. function qDeleteDetailItem(roomIdx, itemIdx){
  5019. var rooms = (window._qLastAnalysisData && window._qLastAnalysisData.rooms) ? window._qLastAnalysisData.rooms : qRooms;
  5020. var r = rooms[roomIdx];
  5021. if(!r || !r._detailItems || !r._detailItems[itemIdx]) return;
  5022. if(r._detailItems[itemIdx].isBase){
  5023. showToast('柜体行不可删除', 'info');
  5024. return;
  5025. }
  5026. r._detailItems.splice(itemIdx, 1);
  5027. // 同步旧数据结构
  5028. qSyncDetailItemsToExtras(r);
  5029. // 重新渲染
  5030. qRenderAICards(window._qLastAnalysisData, window._qLastIsMock);
  5031. }
  5032. // 将新的_detailItems同步到旧的_selectedExtras结构(保持Step3等功能兼容)
  5033. function qSyncDetailItemsToExtras(r){
  5034. if(!r._detailItems) return;
  5035. r._selectedExtras = [];
  5036. r._detailItems.forEach(function(item){
  5037. if(item.isBase) return; // 跳过柜体行(柜体是基础项)
  5038. var itemType = item.method ? qClassifyMethod(item.method) : 'count';
  5039. var extra = {
  5040. name: item.name,
  5041. method: item.method || '按个数',
  5042. price: item.price,
  5043. unit: '元/' + (itemType === 'area' ? '㎡' : (itemType === 'length' ? 'm' : '个')),
  5044. qty: 0,
  5045. widthM: item.width || 0,
  5046. heightM: item.height || 0,
  5047. multiplier: 1,
  5048. total: item.total || 0
  5049. };
  5050. if(itemType === 'area'){
  5051. extra.qty = item.area || (item.width * item.height);
  5052. } else if(itemType === 'length'){
  5053. extra.qty = item.qty || item.height || 0;
  5054. } else {
  5055. extra.qty = item.qty || 1;
  5056. }
  5057. r._selectedExtras.push(extra);
  5058. });
  5059. }
  5060. // ── 子字段编辑(尺寸、柜体等含多个可编辑字段的行) ──
  5061. // 全局子字段定义表,由渲染时填充
  5062. var qAiSubFieldDefs = []; // [cardIdx][rowIdx][subFieldIdx] = {val, get, set, editType, options}
  5063. function qStartSubFieldEdit(cardIdx, rowIdx, subFieldIdx){
  5064. if (!qAiEditMode || !qAiEditableData) return;
  5065. var uid = 'ai-edit-sub-'+cardIdx+'-'+rowIdx+'-'+subFieldIdx;
  5066. var el = document.getElementById(uid);
  5067. if (!el || el.querySelector('input') || el.querySelector('select')) return;
  5068. // 从全局定义表读取
  5069. var defs = (qAiSubFieldDefs[cardIdx]||[])[rowIdx];
  5070. if (!defs || !defs[subFieldIdx]) return;
  5071. var sf = defs[subFieldIdx];
  5072. // 刷新当前值
  5073. sf.val = sf.get();
  5074. var inputHtml = '';
  5075. if (sf.editType === 'select') {
  5076. var opts = (sf.options||[]).map(function(o){return '<option value="'+o+'"'+(o==sf.val?' selected':'')+'>'+o+'</option>';}).join('');
  5077. inputHtml = '<select class="ai-inline-select" id="'+uid+'-input">'+opts+'</select>';
  5078. } else if (sf.editType === 'number') {
  5079. inputHtml = '<input type="number" step="0.01" class="ai-inline-input" id="'+uid+'-input" value="'+sf.val+'" style="width:90px;">';
  5080. } else {
  5081. inputHtml = '<input type="text" class="ai-inline-input" id="'+uid+'-input" value="'+(sf.val||'').replace(/"/g,'&quot;')+'" style="min-width:80px;">';
  5082. }
  5083. var actionsHtml = '<span class="ai-edit-actions"><button class="ai-btn-save" onclick="event.stopPropagation();qSaveSubFieldEdit('+cardIdx+','+rowIdx+','+subFieldIdx+')" title="保存">✓</button><button class="ai-btn-cancel" onclick="event.stopPropagation();qCancelSubFieldEdit()" title="取消">✕</button></span>';
  5084. el.innerHTML = inputHtml + actionsHtml;
  5085. var input = document.getElementById(uid+'-input');
  5086. if (input) {
  5087. input.focus();
  5088. if (sf.editType !== 'select') input.select();
  5089. input.addEventListener('keydown', function(e){
  5090. if (e.key === 'Enter') { e.preventDefault(); qSaveSubFieldEdit(cardIdx, rowIdx, subFieldIdx); }
  5091. if (e.key === 'Escape') { e.preventDefault(); qCancelSubFieldEdit(); }
  5092. });
  5093. }
  5094. }
  5095. function qSaveSubFieldEdit(cardIdx, rowIdx, subFieldIdx){
  5096. if (!qAiEditableData) return;
  5097. var uid = 'ai-edit-sub-'+cardIdx+'-'+rowIdx+'-'+subFieldIdx;
  5098. var input = document.getElementById(uid+'-input');
  5099. if (!input) { qCancelSubFieldEdit(); return; }
  5100. var newVal = input.value;
  5101. var defs = (qAiSubFieldDefs[cardIdx]||[])[rowIdx];
  5102. if (!defs || !defs[subFieldIdx]) { qCancelSubFieldEdit(); return; }
  5103. var sf = defs[subFieldIdx];
  5104. // 类型转换
  5105. if (sf.editType === 'number') {
  5106. newVal = parseFloat(newVal);
  5107. if (isNaN(newVal) || newVal < 0) { qCancelSubFieldEdit(); return; }
  5108. }
  5109. // 写入数据
  5110. sf.set(newVal);
  5111. // 附属操作:如果修改了房间的长/宽,自动重算面积
  5112. if (cardIdx === 1 && (subFieldIdx === 0 || subFieldIdx === 1)) {
  5113. var r = qAiEditableData.rooms[rowIdx];
  5114. if (r) { r.area = parseFloat((r.length * r.width).toFixed(2)); }
  5115. if (qRooms && qRooms[rowIdx]) { qRooms[rowIdx].area = null; qRooms[rowIdx].expandArea = null; }
  5116. }
  5117. // 同步到 qRooms
  5118. if (sf.roomIdx != null && qRooms && qRooms[sf.roomIdx] && sf.field) {
  5119. qRooms[sf.roomIdx][sf.field] = newVal;
  5120. }
  5121. // 重新渲染
  5122. var isMock = qUploadedDrawing && qUploadedDrawing.name && qUploadedDrawing.name.indexOf('120㎡三房两厅') !== -1;
  5123. qRenderAICards(qAiEditableData, isMock);
  5124. qUpdateSummaryBanner(qAiEditableData);
  5125. }
  5126. function qCancelSubFieldEdit(){
  5127. if (!qAiEditableData) return;
  5128. var isMock = qUploadedDrawing && qUploadedDrawing.name && qUploadedDrawing.name.indexOf('120㎡三房两厅') !== -1;
  5129. qRenderAICards(qAiEditableData, isMock);
  5130. }
  5131. // 更新汇总横幅
  5132. function qUpdateSummaryBanner(d){
  5133. var banner = document.getElementById('q-ai-summary-banner');
  5134. var title = document.getElementById('q-ai-summary-title');
  5135. var meta = document.getElementById('q-ai-summary-meta');
  5136. var roomsEl = document.getElementById('q-ai-summary-rooms');
  5137. var areaEl = document.getElementById('q-ai-summary-area');
  5138. var confEl = document.getElementById('q-ai-summary-conf');
  5139. if (banner) banner.classList.remove('hidden');
  5140. // 总投影面积:严格使用图纸标注的各柜体投影面积之和
  5141. var totalProjArea = ((d.rooms||[]).reduce(function(s,r){return s+(r.projectArea||r.area||0);},0));
  5142. if (title) title.textContent = (d.houseType||d.houseLabel)+' · '+totalProjArea.toFixed(3)+'㎡(柜体投影总计)';
  5143. if (meta) meta.innerHTML = '<span>📏 层高参考 '+qFloorHeight.toFixed(2)+'m</span><span>🗄️ '+(d.rooms||[]).length+' 组柜体</span><span>✅ 数据来源:图纸标注+AI识别</span>';
  5144. if (roomsEl) roomsEl.textContent = (d.rooms||[]).length;
  5145. if (areaEl) areaEl.textContent = totalProjArea.toFixed(3);
  5146. if (confEl) confEl.textContent = (qUploadedDrawing ? '99' : '92')+'%';
  5147. }
  5148. // ── 基于上传图片尺寸动态生成房间数据(增强版)──
  5149. // ── OCR文字标注识别模块 ──
  5150. // 模拟从图纸中提取文字标注信息(优先)
  5151. // ── 真实图像像素分析模块 ──
  5152. // 通过Canvas加载用户上传的图片,进行像素级分析以识别房间区域
  5153. var _qImageAnalysisResult = null; // 存储图像分析结果
  5154. function qAnalyzeImagePixels(imgSrc, imgWidth, imgHeight) {
  5155. return new Promise(function(resolve) {
  5156. if (!imgSrc) {
  5157. _qImageAnalysisResult = null;
  5158. resolve(null);
  5159. return;
  5160. }
  5161. var img = new Image();
  5162. img.crossOrigin = 'anonymous';
  5163. img.onload = function() {
  5164. try {
  5165. // 创建分析Canvas(缩放至最大400px以提高性能)
  5166. var maxSize = 400;
  5167. var scale = Math.min(maxSize / img.naturalWidth, maxSize / img.naturalHeight, 1);
  5168. var w = Math.round(img.naturalWidth * scale);
  5169. var h = Math.round(img.naturalHeight * scale);
  5170. var canvas = document.createElement('canvas');
  5171. canvas.width = w; canvas.height = h;
  5172. var ctx = canvas.getContext('2d');
  5173. ctx.drawImage(img, 0, 0, w, h);
  5174. var imgData = ctx.getImageData(0, 0, w, h);
  5175. var data = imgData.data;
  5176. // 二值化:检测深色像素(墙体线条)
  5177. // 户型图中墙体通常是深色(黑/深蓝),背景是浅色(白/浅灰)
  5178. var binary = new Uint8Array(w * h);
  5179. var darkCount = 0;
  5180. for (var y = 0; y < h; y++) {
  5181. for (var x = 0; x < w; x++) {
  5182. var idx = (y * w + x) * 4;
  5183. var r = data[idx], g = data[idx+1], b = data[idx+2];
  5184. var brightness = (r * 299 + g * 587 + b * 114) / 1000;
  5185. // 亮度低于阈值认为是墙体/线条
  5186. binary[y * w + x] = brightness < 100 ? 1 : 0;
  5187. if (brightness < 100) darkCount++;
  5188. }
  5189. }
  5190. // 洪水填充算法检测连通区域(白色/浅色区域即房间)
  5191. var visited = new Uint8Array(w * h);
  5192. var rooms = [];
  5193. var totalLightPixels = w * h - darkCount;
  5194. for (var y = 2; y < h - 2; y++) {
  5195. for (var x = 2; x < w - 2; x++) {
  5196. var pos = y * w + x;
  5197. if (!visited[pos] && binary[pos] === 0) {
  5198. // BFS洪水填充
  5199. var queue = [[x, y]];
  5200. var region = [];
  5201. var minX = x, maxX = x, minY = y, maxY = y;
  5202. visited[pos] = 1;
  5203. while (queue.length > 0) {
  5204. var pt = queue.shift();
  5205. var cx = pt[0], cy = pt[1];
  5206. region.push([cx, cy]);
  5207. if (cx < minX) minX = cx;
  5208. if (cx > maxX) maxX = cx;
  5209. if (cy < minY) minY = cy;
  5210. if (cy > maxY) maxY = cy;
  5211. // 4方向连通
  5212. [[0,-1],[0,1],[-1,0],[1,0]].forEach(function(d) {
  5213. var nx = cx + d[0], ny = cy + d[1];
  5214. if (nx >= 1 && nx < w-1 && ny >= 1 && ny < h-1) {
  5215. var npos = ny * w + nx;
  5216. if (!visited[npos] && binary[npos] === 0) {
  5217. visited[npos] = 1;
  5218. queue.push([nx, ny]);
  5219. }
  5220. }
  5221. });
  5222. }
  5223. // 计算区域大小,过滤太小的区域(可能是噪声)
  5224. var regionArea = region.length;
  5225. var bboxArea = (maxX - minX + 1) * (maxY - minY + 1);
  5226. if (regionArea > totalLightPixels * 0.01 && bboxArea > 100) {
  5227. rooms.push({
  5228. pixelArea: regionArea,
  5229. bboxWidth: maxX - minX + 1,
  5230. bboxHeight: maxY - minY + 1,
  5231. bboxArea: bboxArea,
  5232. fillRatio: regionArea / bboxArea,
  5233. centerX: (minX + maxX) / 2 / w, // 归一化坐标 0-1
  5234. centerY: (minY + maxY) / 2 / h
  5235. });
  5236. }
  5237. }
  5238. }
  5239. }
  5240. // 按面积从大到小排序
  5241. rooms.sort(function(a, b) { return b.pixelArea - a.pixelArea; });
  5242. // 计算图像特征
  5243. var aspectRatio = img.naturalWidth / Math.max(img.naturalHeight, 1);
  5244. var darkRatio = darkCount / (w * h);
  5245. var isLandscape = aspectRatio > 1.1;
  5246. var isPortrait = aspectRatio < 0.9;
  5247. // 过滤掉最外围的大区域(通常是背景/图纸外框),保留内部房间
  5248. var innerRooms = rooms.filter(function(r, i) {
  5249. // 第一个最大的区域通常是整体背景,排除
  5250. // 但如果区域填充率高且在图片中心区域,可能就是房间
  5251. return i > 0 || r.fillRatio < 0.85;
  5252. });
  5253. // 合理的房间数量:根据检测到的区域数量,但限制在2-10个
  5254. var roomCount = Math.min(Math.max(innerRooms.length || 3, 2), 10);
  5255. // 如果检测到的区域太少,根据图片特征估算
  5256. if (roomCount < 3) {
  5257. roomCount = darkRatio > 0.15 ? (isLandscape ? 4 : 3) : (isLandscape ? 3 : 2);
  5258. }
  5259. // 计算总面积(用于比例尺估算)
  5260. var totalRoomArea = innerRooms.slice(0, roomCount).reduce(function(s, r) { return s + r.pixelArea; }, 0);
  5261. _qImageAnalysisResult = {
  5262. naturalWidth: img.naturalWidth,
  5263. naturalHeight: img.naturalHeight,
  5264. analyzedWidth: w,
  5265. analyzedHeight: h,
  5266. aspectRatio: aspectRatio,
  5267. darkRatio: darkRatio,
  5268. isLandscape: isLandscape,
  5269. isPortrait: isPortrait,
  5270. detectedRoomCount: roomCount,
  5271. rooms: innerRooms.slice(0, roomCount).map(function(r, idx) {
  5272. return {
  5273. index: idx,
  5274. pixelArea: r.pixelArea,
  5275. relativeWidth: r.bboxWidth / w,
  5276. relativeHeight: r.bboxHeight / h,
  5277. relativeArea: r.pixelArea / Math.max(totalRoomArea, 1),
  5278. centerX: r.centerX,
  5279. centerY: r.centerY,
  5280. fillRatio: r.fillRatio
  5281. };
  5282. }),
  5283. scaleHint: isLandscape ? (aspectRatio > 1.4 ? 12.4 : 10.0) : (isPortrait ? 8.0 : 10.0)
  5284. };
  5285. resolve(_qImageAnalysisResult);
  5286. } catch(e) {
  5287. console.warn('图像像素分析失败:', e);
  5288. _qImageAnalysisResult = null;
  5289. resolve(null);
  5290. }
  5291. };
  5292. img.onerror = function() {
  5293. _qImageAnalysisResult = null;
  5294. resolve(null);
  5295. };
  5296. img.src = imgSrc;
  5297. });
  5298. }
  5299. // 根据图像分析结果生成伪随机但确定性的参数
  5300. function qPseudoRandom(seed) {
  5301. var x = Math.sin(seed) * 10000;
  5302. return x - Math.floor(x);
  5303. }
  5304. // 基于图像特征生成房间类型
  5305. function qGenerateRoomTypes(count, isLandscape, aspectRatio) {
  5306. var types = [];
  5307. var defaultTypes = ['客厅', '主卧', '次卧', '餐厅', '厨房', '阳台', '书房', '卫生间', '玄关', '储物间'];
  5308. // 确保基本功能间存在
  5309. var essential = ['客厅', '主卧', '厨房'];
  5310. for (var i = 0; i < Math.min(essential.length, count); i++) {
  5311. types.push(essential[i]);
  5312. }
  5313. // 其余根据数量补充
  5314. var secondary = ['次卧', '餐厅', '阳台', '书房', '卫生间', '玄关', '储物间'];
  5315. for (var j = types.length; j < count; j++) {
  5316. types.push(secondary[j - essential.length] || ('房间'+(j+1)));
  5317. }
  5318. return types;
  5319. }
  5320. function qOCRExtractTextAnnotations(fileName, hasCAD, mockType) {
  5321. var isMock = (fileName && fileName.indexOf('120㎡三房两厅') !== -1) || mockType === 'floor1' || mockType === 'floor2';
  5322. var isFloor2 = mockType === 'floor2';
  5323. // 🔴 强制规则:如果是示例图纸(isMock=true),完全忽略图像分析结果,使用手写标注的精确mock数据
  5324. // 确保尺寸100%匹配图纸标注,禁止AI估算
  5325. if (isMock) {
  5326. _qImageAnalysisResult = null;
  5327. }
  5328. // 如果有图像分析结果(真实上传的图片),基于图像分析动态生成OCR数据
  5329. if (!isMock && _qImageAnalysisResult) {
  5330. var analysis = _qImageAnalysisResult;
  5331. var roomCount = analysis.detectedRoomCount;
  5332. var roomNames = qGenerateRoomTypes(roomCount, analysis.isLandscape, analysis.aspectRatio);
  5333. var totalWidth = analysis.scaleHint;
  5334. var totalDepth = totalWidth / Math.max(analysis.aspectRatio, 0.8);
  5335. var textAnnotations = {
  5336. source: '图纸文字标注(OCR)+图形识别',
  5337. confidence: 0.75,
  5338. extractedText: [],
  5339. roomLabels: [],
  5340. dimensionLabels: [],
  5341. areaLabels: [],
  5342. materialLabels: [],
  5343. matched: true
  5344. };
  5345. // 根据检测到的房间区域生成动态数据
  5346. var rooms = analysis.rooms;
  5347. var defaultSizes = [
  5348. {w: 4.2, h: 3.5}, {w: 3.8, h: 3.2}, {w: 3.5, h: 3.0}, {w: 3.2, h: 2.8},
  5349. {w: 3.0, h: 2.8}, {w: 2.8, h: 2.5}, {w: 2.5, h: 2.2}, {w: 2.0, h: 2.0},
  5350. {w: 3.5, h: 2.4}, {w: 1.8, h: 1.5}
  5351. ];
  5352. var cabinetTypes = ['客厅电视柜','主卧衣柜','次卧衣柜','餐厅餐边柜','厨房橱柜','阳台柜','书房书柜','浴室柜','玄关鞋柜','储物柜'];
  5353. var layoutTypes = ['L型','一字型','一字型','一字型','U型','一字型','一字型','一字型','L型','一字型'];
  5354. var totalArea = 0;
  5355. for (var i = 0; i < roomCount; i++) {
  5356. var r = rooms[i] || {relativeWidth:1/roomCount, relativeHeight:1/Math.ceil(roomCount/2), relativeArea:1/roomCount};
  5357. var name = roomNames[i] || ('房间'+(i+1));
  5358. var seed = (analysis.naturalWidth * 31 + analysis.naturalHeight * 17 + i * 131) % 10000;
  5359. var sizeIdx = Math.min(i, defaultSizes.length - 1);
  5360. var w = defaultSizes[sizeIdx].w + (qPseudoRandom(seed) - 0.5) * 0.8;
  5361. var h = defaultSizes[sizeIdx].h + (qPseudoRandom(seed + 100) - 0.5) * 0.6;
  5362. w = parseFloat(w.toFixed(1));
  5363. h = parseFloat(h.toFixed(1));
  5364. var area = parseFloat((w * h).toFixed(1));
  5365. totalArea += area;
  5366. var confBase = 0.65 + qPseudoRandom(seed + 200) * 0.2;
  5367. textAnnotations.roomLabels.push({text:name, x:r.centerX*1000, y:r.centerY*800, confidence:parseFloat(confBase.toFixed(2))});
  5368. textAnnotations.dimensionLabels.push({room:name, text:w+'m', type:'width', value:w, confidence:parseFloat((confBase-0.05).toFixed(2))});
  5369. textAnnotations.dimensionLabels.push({room:name, text:h+'m', type:'height', value:h, confidence:parseFloat((confBase-0.05).toFixed(2))});
  5370. textAnnotations.areaLabels.push({room:name, text:area+'㎡', value:area, confidence:parseFloat((confBase-0.1).toFixed(2))});
  5371. // 为主要房间添加柜体标注(模拟OCR识别到的柜体文字)
  5372. if (i < cabinetTypes.length) {
  5373. var cabText = cabinetTypes[sizeIdx] + ' ' + layoutTypes[sizeIdx];
  5374. textAnnotations.materialLabels.push({
  5375. room: name,
  5376. text: cabText,
  5377. type: 'cabinet',
  5378. cabinetType: cabinetTypes[sizeIdx],
  5379. layoutStyle: layoutTypes[sizeIdx],
  5380. confidence: parseFloat((confBase-0.15).toFixed(2))
  5381. });
  5382. }
  5383. }
  5384. // 添加总尺寸标注
  5385. textAnnotations.dimensionLabels.push({room:'全屋', text:totalWidth.toFixed(1)+'m', type:'totalWidth', value:parseFloat(totalWidth.toFixed(1)), confidence:0.8});
  5386. textAnnotations.extractedText = [
  5387. roomCount+'房户型',
  5388. '建筑面积约'+Math.round(totalArea*1.5)+'㎡',
  5389. ].concat(
  5390. textAnnotations.roomLabels.map(function(r){return r.text;}),
  5391. textAnnotations.dimensionLabels.filter(function(d){return d.room!=='全屋';}).map(function(d){return d.text;})
  5392. );
  5393. textAnnotations.roomCount = roomCount;
  5394. textAnnotations.dimensionCount = textAnnotations.dimensionLabels.length;
  5395. textAnnotations.areaCount = textAnnotations.areaLabels.length;
  5396. textAnnotations._dynamicFromImage = true;
  5397. textAnnotations._detectedRoomCount = roomCount;
  5398. return textAnnotations;
  5399. }
  5400. // 示例户型图(Mock数据):与Canvas绘制的图纸标注严格对应
  5401. var textAnnotations = {
  5402. source: hasCAD ? 'CAD文字标注(OCR)' : '图纸文字标注(OCR)',
  5403. confidence: hasCAD ? 0.98 : 0.92,
  5404. extractedText: [],
  5405. roomLabels: [],
  5406. dimensionLabels: [],
  5407. areaLabels: [],
  5408. materialLabels: [],
  5409. matched: false
  5410. };
  5411. var mockAnnotations;
  5412. if (isFloor2) {
  5413. mockAnnotations = {
  5414. roomLabels: [
  5415. {text:'次卧二', x:250, y:160, confidence:0.98},
  5416. {text:'书房', x:680, y:150, confidence:0.97},
  5417. {text:'卫生间', x:960, y:160, confidence:0.98},
  5418. {text:'衣帽间', x:280, y:540, confidence:0.96},
  5419. {text:'起居室', x:680, y:540, confidence:0.97}
  5420. ],
  5421. dimensionLabels: [
  5422. {room:'次卧二', text:'3.6m', type:'width', value:3.6, confidence:0.98},
  5423. {room:'次卧二', text:'3.2m', type:'height', value:3.2, confidence:0.98},
  5424. {room:'书房', text:'3.2m', type:'width', value:3.2, confidence:0.97},
  5425. {room:'书房', text:'2.8m', type:'height', value:2.8, confidence:0.97},
  5426. {room:'卫生间', text:'2.8m', type:'width', value:2.8, confidence:0.98},
  5427. {room:'卫生间', text:'2.4m', type:'height', value:2.4, confidence:0.98},
  5428. {room:'衣帽间', text:'2.6m', type:'width', value:2.6, confidence:0.96},
  5429. {room:'衣帽间', text:'2.0m', type:'height', value:2.0, confidence:0.96},
  5430. {room:'起居室', text:'4.8m', type:'width', value:4.8, confidence:0.97},
  5431. {room:'起居室', text:'3.0m', type:'height', value:3.0, confidence:0.97},
  5432. {room:'全屋', text:'9.6m', type:'totalWidth', value:9.6, confidence:0.98}
  5433. ],
  5434. areaLabels: [
  5435. {room:'次卧二', text:'11.5㎡', value:11.5, confidence:0.98},
  5436. {room:'书房', text:'9.0㎡', value:9.0, confidence:0.97},
  5437. {room:'卫生间', text:'6.7㎡', value:6.7, confidence:0.98},
  5438. {room:'衣帽间', text:'5.2㎡', value:5.2, confidence:0.96},
  5439. {room:'起居室', text:'14.4㎡', value:14.4, confidence:0.97}
  5440. ],
  5441. materialLabels: [
  5442. {room:'次卧二', text:'儿童房衣柜 一字型', type:'cabinet', cabinetType:'儿童房衣柜', layoutStyle:'一字型', confidence:0.97},
  5443. {room:'书房', text:'书房书柜 L型', type:'cabinet', cabinetType:'书房书柜', layoutStyle:'L型', confidence:0.95},
  5444. {room:'卫生间', text:'浴室柜 一字型', type:'cabinet', cabinetType:'浴室柜', layoutStyle:'一字型', confidence:0.98},
  5445. {room:'衣帽间', text:'衣帽间 U型', type:'cabinet', cabinetType:'衣帽间', layoutStyle:'U型', confidence:0.96},
  5446. {room:'起居室', text:'储物柜 一字型', type:'cabinet', cabinetType:'储物柜', layoutStyle:'一字型', confidence:0.96}
  5447. ]
  5448. };
  5449. } else {
  5450. mockAnnotations = {
  5451. roomLabels: [
  5452. {text:'餐厅', x:260, y:160, confidence:0.99},
  5453. {text:'客厅', x:450, y:140, confidence:0.99},
  5454. {text:'阳台', x:650, y:630, confidence:0.97},
  5455. {text:'开放式厨房', x:800, y:490, confidence:0.98},
  5456. {text:'主卧', x:350, y:350, confidence:0.99},
  5457. {text:'衣帽间', x:550, y:350, confidence:0.96},
  5458. {text:'次卧', x:750, y:630, confidence:0.99}
  5459. ],
  5460. dimensionLabels: [
  5461. {room:'餐厅', text:'2.0m', type:'width', value:2.0, confidence:0.99},
  5462. {room:'餐厅', text:'2.5m', type:'cabinetHeight', value:2.5, confidence:0.99},
  5463. {room:'客厅', text:'3.45m', type:'width', value:3.45, confidence:0.99},
  5464. {room:'客厅', text:'2.5m', type:'cabinetHeight', value:2.5, confidence:0.99},
  5465. {room:'阳台', text:'1.4m', type:'width', value:1.4, confidence:0.97},
  5466. {room:'阳台', text:'2.5m', type:'cabinetHeight', value:2.5, confidence:0.97},
  5467. {room:'开放式厨房', text:'2.5m', type:'width', value:2.5, confidence:0.98},
  5468. {room:'开放式厨房', text:'1.8m', type:'cabinetHeight', value:1.8, confidence:0.98},
  5469. {room:'主卧', text:'1.2m', type:'width', value:1.2, confidence:0.99},
  5470. {room:'主卧', text:'2.5m', type:'cabinetHeight', value:2.5, confidence:0.99},
  5471. {room:'衣帽间', text:'3.9m', type:'width', value:3.9, confidence:0.96},
  5472. {room:'衣帽间', text:'2.5m', type:'cabinetHeight', value:2.5, confidence:0.96},
  5473. {room:'次卧', text:'2.0m', type:'width', value:2.0, confidence:0.99},
  5474. {room:'次卧', text:'2.5m', type:'cabinetHeight', value:2.5, confidence:0.99},
  5475. {room:'总计', text:'39.375㎡', type:'totalArea', value:39.375, confidence:0.99}
  5476. ],
  5477. areaLabels: [
  5478. {room:'餐厅', text:'5㎡', value:5.0, confidence:0.99},
  5479. {room:'客厅', text:'8.625㎡', value:8.625, confidence:0.99},
  5480. {room:'阳台', text:'3.5㎡', value:3.5, confidence:0.97},
  5481. {room:'开放式厨房', text:'4.5㎡', value:4.5, confidence:0.98},
  5482. {room:'主卧', text:'3㎡', value:3.0, confidence:0.99},
  5483. {room:'衣帽间', text:'9.75㎡', value:9.75, confidence:0.96},
  5484. {room:'次卧', text:'5㎡', value:5.0, confidence:0.99}
  5485. ],
  5486. materialLabels: [
  5487. {room:'餐厅', text:'餐厅餐边柜 一字型', type:'cabinet', cabinetType:'餐厅餐边柜', layoutStyle:'一字型', confidence:0.99},
  5488. {room:'客厅', text:'客厅电视柜 一字型', type:'cabinet', cabinetType:'客厅电视柜', layoutStyle:'一字型', confidence:0.99},
  5489. {room:'阳台', text:'阳台柜 一字型', type:'cabinet', cabinetType:'阳台柜', layoutStyle:'一字型', confidence:0.97},
  5490. {room:'开放式厨房', text:'开放式厨房 L型', type:'cabinet', cabinetType:'开放式厨房', layoutStyle:'L型', confidence:0.98},
  5491. {room:'主卧', text:'主卧衣柜 一字型', type:'cabinet', cabinetType:'主卧衣柜', layoutStyle:'一字型', confidence:0.99},
  5492. {room:'衣帽间', text:'衣帽间 U型', type:'cabinet', cabinetType:'衣帽间', layoutStyle:'U型', confidence:0.96},
  5493. {room:'次卧', text:'次卧衣柜 一字型', type:'cabinet', cabinetType:'次卧衣柜', layoutStyle:'一字型', confidence:0.99}
  5494. ]
  5495. };
  5496. }
  5497. textAnnotations.roomLabels = mockAnnotations.roomLabels;
  5498. textAnnotations.dimensionLabels = mockAnnotations.dimensionLabels;
  5499. textAnnotations.areaLabels = mockAnnotations.areaLabels;
  5500. textAnnotations.materialLabels = mockAnnotations.materialLabels;
  5501. var houseDesc = isFloor2 ? '二楼五房(次卧二/书房/卫生间/衣帽间/起居室)' : '全屋定制7组柜体(餐厅/客厅/阳台/开放式厨房/主卧/衣帽间/次卧)';
  5502. var grossAreaDesc = isFloor2 ? '二楼使用面积约47㎡' : '总投影面积39.375㎡';
  5503. textAnnotations.extractedText = [
  5504. houseDesc, grossAreaDesc, '总计投影:39.375',
  5505. ...mockAnnotations.roomLabels.map(function(r){return r.text;}),
  5506. ...mockAnnotations.dimensionLabels.map(function(d){return d.text;})
  5507. ];
  5508. textAnnotations.matched = true;
  5509. textAnnotations.roomCount = mockAnnotations.roomLabels.length;
  5510. textAnnotations.dimensionCount = mockAnnotations.dimensionLabels.length;
  5511. textAnnotations.areaCount = mockAnnotations.areaLabels.length;
  5512. textAnnotations._isFloor2 = isFloor2;
  5513. return textAnnotations;
  5514. }
  5515. // ── 图形尺寸测量模块 ──
  5516. // 模拟通过图形轮廓、比例尺测量尺寸(作为文字标注的补充/验证)
  5517. function qMeasureGraphicDimensions(imgWidth, imgHeight, ocrData) {
  5518. var isMock = ocrData && !ocrData._dynamicFromImage;
  5519. // 动态模式:根据图像分析结果生成图形测量数据
  5520. if (_qImageAnalysisResult && ocrData && ocrData._dynamicFromImage) {
  5521. var analysis = _qImageAnalysisResult;
  5522. var graphicData = {
  5523. source: '图形尺寸测量(轮廓识别)',
  5524. confidence: 0.72,
  5525. scaleRatio: 100 / analysis.scaleHint * 0.8,
  5526. measuredRooms: [],
  5527. calibrationFrom: 'OCR识别校准'
  5528. };
  5529. var roomCount = ocrData.roomLabels.length;
  5530. graphicData.measuredRooms = ocrData.roomLabels.map(function(rl, idx) {
  5531. var wDim = ocrData.dimensionLabels.find(function(d){return d.room===rl.text && d.type==='width';});
  5532. var hDim = ocrData.dimensionLabels.find(function(d){return d.room===rl.text && d.type==='height';});
  5533. var aDim = ocrData.areaLabels.find(function(a){return a.room===rl.text;});
  5534. var w = wDim ? wDim.value : 3.0;
  5535. var h = hDim ? hDim.value : 2.8;
  5536. var a = aDim ? aDim.value : parseFloat((w*h).toFixed(1));
  5537. var seed = (idx * 7919 + analysis.naturalWidth + analysis.naturalHeight) % 10000;
  5538. // 图形测量值与OCR有微小偏差(模拟真实测量误差±1-3cm)
  5539. var gw = parseFloat((w + (qPseudoRandom(seed) - 0.5) * 0.06).toFixed(2));
  5540. var gh = parseFloat((h + (qPseudoRandom(seed + 500) - 0.5) * 0.06).toFixed(2));
  5541. var ga = parseFloat((a + (qPseudoRandom(seed + 1000) - 0.5) * 0.2).toFixed(2));
  5542. return {
  5543. roomName: rl.text,
  5544. graphicWidth: gw,
  5545. graphicHeight: gh,
  5546. graphicArea: ga,
  5547. shape: idx === 4 ? 'U-shape' : 'rectangle',
  5548. measurementQuality: qPseudoRandom(seed+200) > 0.3 ? 'high' : 'medium',
  5549. deviationFromOCR: parseFloat((Math.abs(gw - w) * 1000).toFixed(0)),
  5550. dataSource: Math.abs(gw - w) < 0.05 ? 'OCR文字标注(已验证)' : 'OCR标注(微小偏差)'
  5551. };
  5552. });
  5553. graphicData.roomCount = graphicData.measuredRooms.length;
  5554. var devs = graphicData.measuredRooms.map(function(r){return r.deviationFromOCR||0;}).filter(function(d){return d!=null;});
  5555. graphicData.avgDeviation = devs.length > 0 ? devs.reduce(function(s,d){return s+d;},0)/devs.length : 0;
  5556. return graphicData;
  5557. }
  5558. // 示例户型图模式
  5559. var graphicData = {
  5560. source: '图形尺寸测量(轮廓识别)',
  5561. confidence: 0.82,
  5562. scaleRatio: 100 / 2.28,
  5563. measuredRooms: [],
  5564. calibrationFrom: 'OCR标注'
  5565. };
  5566. if (ocrData && ocrData.dimensionLabels && ocrData.dimensionLabels.length > 0) {
  5567. var firstDim = ocrData.dimensionLabels[0];
  5568. graphicData.scaleRatio = 85 / firstDim.value;
  5569. graphicData.calibrationFrom = 'OCR文字标注校准';
  5570. graphicData.confidence = 0.88;
  5571. }
  5572. var roomDefs;
  5573. if (ocrData && ocrData._isFloor2) {
  5574. roomDefs = [
  5575. {name:'次卧二', graphicWidth:3.62, graphicHeight:3.18, boxArea:11.51, shape:'rectangle', quality:'high'},
  5576. {name:'书房', graphicWidth:3.18, graphicHeight:2.82, boxArea:8.97, shape:'rectangle', quality:'high'},
  5577. {name:'卫生间', graphicWidth:2.78, graphicHeight:2.42, boxArea:6.73, shape:'rectangle', quality:'medium'},
  5578. {name:'衣帽间', graphicWidth:2.62, graphicHeight:1.98, boxArea:5.19, shape:'U-shape', quality:'medium'},
  5579. {name:'起居室', graphicWidth:4.82, graphicHeight:2.98, boxArea:14.36, shape:'rectangle', quality:'high'}
  5580. ];
  5581. } else {
  5582. roomDefs = [
  5583. {name:'餐边柜', graphicWidth:2.0, graphicHeight:2.5, boxArea:5.0, shape:'rectangle', quality:'high'},
  5584. {name:'电视柜', graphicWidth:3.45, graphicHeight:2.5, boxArea:8.625, shape:'rectangle', quality:'high'},
  5585. {name:'阳台柜', graphicWidth:1.4, graphicHeight:2.5, boxArea:3.5, shape:'rectangle', quality:'high'},
  5586. {name:'开放式厨房柜', graphicWidth:2.5, graphicHeight:1.8, boxArea:4.5, shape:'rectangle', quality:'high'},
  5587. {name:'主卧衣柜', graphicWidth:1.2, graphicHeight:2.5, boxArea:3.0, shape:'rectangle', quality:'high'},
  5588. {name:'衣帽间', graphicWidth:3.9, graphicHeight:2.5, boxArea:9.75, shape:'U-shape', quality:'high'},
  5589. {name:'次卧衣柜', graphicWidth:2.0, graphicHeight:2.5, boxArea:5.0, shape:'rectangle', quality:'high'}
  5590. ];
  5591. }
  5592. graphicData.measuredRooms = roomDefs.map(function(r) {
  5593. var ocrDim = ocrData ? ocrData.dimensionLabels.find(function(d){return d.room===r.name;}) : null;
  5594. var ocrArea = ocrData ? ocrData.areaLabels.find(function(a){return a.room===r.name;}) : null;
  5595. return {
  5596. roomName: r.name,
  5597. graphicWidth: parseFloat(r.graphicWidth.toFixed(2)),
  5598. graphicHeight: parseFloat(r.graphicHeight.toFixed(2)),
  5599. graphicArea: parseFloat(r.boxArea.toFixed(2)),
  5600. shape: r.shape,
  5601. measurementQuality: r.quality,
  5602. deviationFromOCR: ocrDim ? Math.abs(r.graphicWidth - ocrDim.value) * 1000 : null,
  5603. dataSource: ocrDim ? (Math.abs(r.graphicWidth - ocrDim.value) < 0.05 ? 'OCR文字标注(已验证)' : 'OCR标注(需复核图形偏差)') : '图形测量(无文字标注)'
  5604. };
  5605. });
  5606. graphicData.roomCount = graphicData.measuredRooms.length;
  5607. var validDevs = graphicData.measuredRooms.filter(function(r){return r.deviationFromOCR!=null;});
  5608. graphicData.avgDeviation = validDevs.length > 0 ? validDevs.reduce(function(s, r){return s + r.deviationFromOCR;}, 0) / validDevs.length : 0;
  5609. return graphicData;
  5610. }
  5611. // ── 数据融合模块 ──
  5612. // 优先使用OCR文字标注,其次用图形尺寸补充/验证
  5613. function qFuseAnnotationData(ocrData, graphicData) {
  5614. var fusedRooms = [];
  5615. // 默认柜体类型映射(包含常见柜体类型,支持动态识别)
  5616. var defaultCabinetTypeMap = {
  5617. // 按细分柜体类型名直接映射
  5618. '主卧衣柜': {cabinetType:'主卧衣柜', layout:'一字型', defaultDoor:'铝格栅板'},
  5619. '次卧衣柜': {cabinetType:'次卧衣柜', layout:'一字型', defaultDoor:'普通平板'},
  5620. '儿童房衣柜': {cabinetType:'儿童房衣柜', layout:'一字型', defaultDoor:'普通平板'},
  5621. '老人房衣柜': {cabinetType:'老人房衣柜', layout:'一字型', defaultDoor:'普通平板'},
  5622. '客厅电视柜': {cabinetType:'客厅电视柜', layout:'一字型', defaultDoor:'风影门板'},
  5623. '厨房橱柜': {cabinetType:'厨房橱柜', layout:'U型', defaultDoor:'普通平板'},
  5624. '开放式厨房': {cabinetType:'开放式厨房', layout:'L型', defaultDoor:'PET门板'},
  5625. '餐厅餐边柜': {cabinetType:'餐厅餐边柜', layout:'一字型', defaultDoor:'风影门板'},
  5626. '书房书柜': {cabinetType:'书房书柜', layout:'一字型', defaultDoor:'玻璃门'},
  5627. '玄关鞋柜': {cabinetType:'玄关鞋柜', layout:'一字型', defaultDoor:'普通平板'},
  5628. '阳台柜': {cabinetType:'阳台柜', layout:'一字型', defaultDoor:'普通平板'},
  5629. '衣帽间': {cabinetType:'衣帽间', layout:'U型', defaultDoor:'普通平板'},
  5630. '储物柜': {cabinetType:'储物柜', layout:'一字型', defaultDoor:'普通平板'},
  5631. '酒柜': {cabinetType:'酒柜', layout:'一字型', defaultDoor:'玻璃门'},
  5632. '浴室柜': {cabinetType:'浴室柜', layout:'一字型', defaultDoor:'普通平板'},
  5633. '榻榻米': {cabinetType:'榻榻米', layout:'一字型', defaultDoor:'普通平板'},
  5634. // 按空间位置名映射到默认柜体类型
  5635. '客厅': {cabinetType:'客厅电视柜', layout:'L型', defaultDoor:'风影门板'},
  5636. '主卧': {cabinetType:'主卧衣柜', layout:'一字型', defaultDoor:'铝格栅板'},
  5637. '次卧': {cabinetType:'次卧衣柜', layout:'一字型', defaultDoor:'普通平板'},
  5638. '儿童房': {cabinetType:'儿童房衣柜', layout:'一字型', defaultDoor:'普通平板'},
  5639. '老人房': {cabinetType:'老人房衣柜', layout:'一字型', defaultDoor:'普通平板'},
  5640. '餐厅': {cabinetType:'餐厅餐边柜', layout:'一字型', defaultDoor:'普通平板'},
  5641. '客餐厅': {cabinetType:'餐厅餐边柜', layout:'一字型', defaultDoor:'普通平板'},
  5642. '厨房': {cabinetType:'厨房橱柜', layout:'U型', defaultDoor:'普通平板'},
  5643. '阳台': {cabinetType:'阳台柜', layout:'一字型', defaultDoor:'普通平板'},
  5644. '书房': {cabinetType:'书房书柜', layout:'一字型', defaultDoor:'玻璃门'},
  5645. '卫生间': {cabinetType:'浴室柜', layout:'一字型', defaultDoor:'普通平板'},
  5646. '主卫': {cabinetType:'浴室柜', layout:'一字型', defaultDoor:'普通平板'},
  5647. '玄关': {cabinetType:'玄关鞋柜', layout:'L型', defaultDoor:'普通平板'},
  5648. '储物间': {cabinetType:'储物柜', layout:'一字型', defaultDoor:'普通平板'},
  5649. '次卧二': {cabinetType:'儿童房衣柜', layout:'一字型', defaultDoor:'普通平板'},
  5650. '起居室': {cabinetType:'储物柜', layout:'一字型', defaultDoor:'普通平板'},
  5651. // 兼容旧标签名(自动映射到新名称)
  5652. '衣柜': {cabinetType:'主卧衣柜', layout:'一字型', defaultDoor:'铝格栅板'},
  5653. '电视柜': {cabinetType:'客厅电视柜', layout:'一字型', defaultDoor:'风影门板'},
  5654. '橱柜': {cabinetType:'厨房橱柜', layout:'U型', defaultDoor:'普通平板'},
  5655. '餐边柜': {cabinetType:'餐厅餐边柜', layout:'一字型', defaultDoor:'风影门板'},
  5656. '书柜': {cabinetType:'书房书柜', layout:'一字型', defaultDoor:'玻璃门'},
  5657. '鞋柜': {cabinetType:'玄关鞋柜', layout:'L型', defaultDoor:'普通平板'},
  5658. '卫浴柜': {cabinetType:'浴室柜', layout:'一字型', defaultDoor:'普通平板'},
  5659. '开放式厨房柜': {cabinetType:'开放式厨房', layout:'L型', defaultDoor:'PET门板'}
  5660. };
  5661. // 动态获取柜体列表(从OCR识别结果中提取,而非硬编码)
  5662. var detectedCabinets = ocrData.roomLabels.map(function(r){return r.text;});
  5663. // 如果是Mock示例数据,使用固定顺序保证显示一致
  5664. var isMock = !ocrData._dynamicFromImage;
  5665. var cabinetOrder;
  5666. if (isMock) {
  5667. cabinetOrder = ocrData._isFloor2
  5668. ? ['次卧二', '书房', '卫生间', '衣帽间', '起居室']
  5669. : ['餐厅', '客厅', '阳台', '开放式厨房', '主卧', '衣帽间', '次卧'];
  5670. } else {
  5671. // 动态模式:按柜体投影面积从大到小排列
  5672. var cabsWithArea = detectedCabinets.map(function(name, idx) {
  5673. var areaLabel = ocrData.areaLabels.find(function(a){return a.room===name;});
  5674. return {name: name, area: areaLabel ? areaLabel.value : 0, idx: idx};
  5675. });
  5676. cabsWithArea.sort(function(a, b) { return b.area - a.area; });
  5677. cabinetOrder = cabsWithArea.map(function(r){return r.name;});
  5678. }
  5679. cabinetOrder.forEach(function(labelName, idx) {
  5680. var stdName = labelName;
  5681. var typeInfo = defaultCabinetTypeMap[stdName] || {cabinetType:'储物柜', layout:'一字型', defaultDoor:'普通平板'};
  5682. // 查找OCR尺寸标注 - 优先使用cabinetHeight(柜体高度),兼容旧的height(房间高度)
  5683. var widthLabel = ocrData.dimensionLabels.find(function(d){return d.room===stdName && d.type==='width';});
  5684. var cabinetHeightLabel = ocrData.dimensionLabels.find(function(d){return d.room===stdName && (d.type==='cabinetHeight' || d.type==='height');});
  5685. var areaLabel = ocrData.areaLabels.find(function(a){return a.room===stdName;});
  5686. // 查找柜体标注
  5687. var cabinetLabel = ocrData.materialLabels.find(function(m){return m.room===stdName && m.type==='cabinet';});
  5688. // 查找门板标注
  5689. var doorLabel = ocrData.materialLabels.find(function(m){return m.room===stdName && m.type==='door';});
  5690. var roomLabel = ocrData.roomLabels.find(function(r){return r.text===labelName;});
  5691. // 查找图形测量数据
  5692. var graphicRoom = graphicData.measuredRooms.find(function(g){return g.roomName===stdName;});
  5693. // 融合策略:宽度使用图纸标注,柜体高度统一以上传层高为准
  5694. var finalWidth = widthLabel ? widthLabel.value : (graphicRoom ? graphicRoom.graphicWidth : 0);
  5695. // 柜体高度 = Step1上传层高,无默认值,完全以Step1输入为准
  5696. var uploadedFloorH = qFloorHeight || 0;
  5697. var finalCabinetHeight = uploadedFloorH > 0 ? parseFloat(uploadedFloorH.toFixed(2)) : 0;
  5698. // 投影面积:统一使用 柜体宽度 × 上传层高对应的柜体高度(不以户型图标注面积为准)
  5699. var finalArea = finalWidth > 0 && finalCabinetHeight > 0 ? parseFloat((finalWidth*finalCabinetHeight).toFixed(3)) : (areaLabel ? areaLabel.value : 0);
  5700. var finalCabinetType = cabinetLabel ? cabinetLabel.cabinetType : typeInfo.cabinetType;
  5701. var finalLayoutStyle = cabinetLabel ? cabinetLabel.layoutStyle : typeInfo.layout;
  5702. var finalDoor = doorLabel ? doorLabel.text : typeInfo.defaultDoor;
  5703. // 数据来源标记
  5704. var widthSource = widthLabel ? '图纸文字标注' : (graphicRoom ? '图形测量' : '待确认');
  5705. var heightSource = '图纸标注/AI识别';
  5706. var areaSource = '宽×柜体高度';
  5707. var cabinetSource = cabinetLabel ? '图纸文字标注' : 'AI识别';
  5708. var layoutSource = cabinetLabel ? '图纸文字标注' : 'AI识别';
  5709. var doorSource = doorLabel ? '图纸文字标注' : '默认门板';
  5710. var needReview = false;
  5711. if (widthLabel && graphicRoom && Math.abs(graphicRoom.graphicWidth - widthLabel.value) > 0.05) {
  5712. needReview = true;
  5713. }
  5714. fusedRooms.push({
  5715. roomName: stdName,
  5716. cabinetType: finalCabinetType,
  5717. layoutStyle: finalLayoutStyle,
  5718. doorType: finalDoor,
  5719. projectArea: parseFloat(finalArea.toFixed(3)),
  5720. length: parseFloat(finalWidth.toFixed(2)),
  5721. cabinetWidth: parseFloat(finalWidth.toFixed(2)),
  5722. cabinetHeight: parseFloat(finalCabinetHeight.toFixed(2)),
  5723. width: parseFloat(finalWidth.toFixed(2)),
  5724. height: parseFloat(finalCabinetHeight.toFixed(2)),
  5725. area: parseFloat(finalArea.toFixed(3)),
  5726. cabinetRatio: 0.55,
  5727. fillRate: 0.55,
  5728. doorRatio: 0.50,
  5729. bayWindow: false,
  5730. _isCabinet: true,
  5731. _dataSource: {
  5732. length: widthSource,
  5733. cabinetWidth: widthSource,
  5734. cabinetHeight: heightSource,
  5735. width: heightSource,
  5736. height: heightSource,
  5737. area: areaSource,
  5738. projectArea: areaSource,
  5739. cabinetType: cabinetSource,
  5740. layoutStyle: layoutSource,
  5741. doorType: doorSource,
  5742. ocrConfidence: roomLabel ? roomLabel.confidence : null,
  5743. graphicQuality: graphicRoom ? graphicRoom.measurementQuality : null,
  5744. needReview: needReview
  5745. }
  5746. });
  5747. });
  5748. return {
  5749. rooms: fusedRooms,
  5750. ocrStats: {
  5751. cabinetLabelsFound: ocrData.roomLabels.length,
  5752. dimensionsFound: ocrData.dimensionLabels.length,
  5753. areasFound: ocrData.areaLabels.length,
  5754. avgConfidence: ocrData.roomLabels.length > 0
  5755. ? (ocrData.roomLabels.reduce(function(s,r){return s+r.confidence;},0)/ocrData.roomLabels.length).toFixed(2)
  5756. : '0.00'
  5757. },
  5758. graphicStats: {
  5759. cabinetsMeasured: graphicData.measuredRooms.length,
  5760. avgDeviationMm: graphicData.avgDeviation ? graphicData.avgDeviation.toFixed(0) : 0,
  5761. scaleCalibration: graphicData.calibrationFrom
  5762. },
  5763. fusionStats: {
  5764. totalCabinets: fusedRooms.length,
  5765. totalProjectArea: parseFloat(fusedRooms.reduce(function(s,r){return s+(r.projectArea||0);},0).toFixed(3)),
  5766. ocrSourcedCabinets: fusedRooms.filter(function(r){return r._dataSource.length==='图纸文字标注';}).length,
  5767. graphicSourcedCabinets: fusedRooms.filter(function(r){return r._dataSource.length==='图形测量';}).length,
  5768. needReviewCabinets: fusedRooms.filter(function(r){return r._dataSource.needReview;}).length
  5769. }
  5770. };
  5771. }
  5772. // ── 旧视觉识别兼容配置(外部调用已停用) ──
  5773. // 图像编辑统一通过本机豆包/火山方舟安全代理。
  5774. const QWEN_API_URL = '';
  5775. const QWEN_MODEL = 'qwen-vl-plus';
  5776. const QWEN_PROMPT_KEY = 'qwen_custom_prompt_v1';
  5777. // ── 默认提示词(首次加载时使用) ──
  5778. const QWEN_DEFAULT_PROMPT = 'AI自动识别推算出这张户型图的图像比例大小,且根据户型图上标注的真实尺寸或者录入的真实尺寸推算出这张户型图的尺寸单位(1像素是多少米)。最后计算出所有贴纸柜体的最长宽度。';
  5779. // ── 获取当前提示词(从localStorage或默认值) ──
  5780. function qGetPromptText() {
  5781. try {
  5782. var saved = localStorage.getItem(QWEN_PROMPT_KEY);
  5783. if (saved && saved.trim().length > 0) return saved;
  5784. } catch(e) {}
  5785. return QWEN_DEFAULT_PROMPT;
  5786. }
  5787. // ── 打开提示词配置弹窗 ──
  5788. function qOpenPromptConfig() {
  5789. var existing = document.getElementById('q-prompt-config-modal');
  5790. if (existing) existing.remove();
  5791. var currentPrompt = qGetPromptText();
  5792. var overlay = document.createElement('div');
  5793. overlay.id = 'q-prompt-config-modal';
  5794. overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:99999;display:flex;align-items:center;justify-content:center;';
  5795. overlay.onclick = function(e) { if (e.target === overlay) qClosePromptConfig(); };
  5796. var modal = document.createElement('div');
  5797. modal.style.cssText = 'background:#fff;border-radius:12px;padding:24px;max-width:700px;width:90%;max-height:80vh;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.3);';
  5798. modal.innerHTML = ''
  5799. + '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">'
  5800. + ' <div style="font-size:16px;font-weight:700;color:#2D3748;">⚙️ AI识别提示词配置</div>'
  5801. + ' <button onclick="qClosePromptConfig()" style="border:none;background:none;font-size:20px;cursor:pointer;color:#718096;padding:4px 8px;">✕</button>'
  5802. + '</div>'
  5803. + '<div style="font-size:12px;color:#718096;margin-bottom:12px;padding:8px 12px;background:#F7FAFC;border-radius:6px;border:1px solid #E2E8F0;">'
  5804. + ' 💡 此处保留旧版识别提示词配置,仅用于兼容历史数据;当前户型图优化由豆包图像模型完成。'
  5805. + '</div>'
  5806. + '<textarea id="q-prompt-textarea" style="flex:1;min-height:300px;max-height:50vh;width:100%;padding:12px;border:2px solid #E2E8F0;border-radius:8px;font-size:13px;font-family:monospace;line-height:1.6;resize:vertical;box-sizing:border-box;">' + qEscapeHtml(currentPrompt) + '</textarea>'
  5807. + '<div style="display:flex;justify-content:space-between;align-items:center;margin-top:16px;gap:12px;">'
  5808. + ' <button onclick="qResetPromptToDefault()" style="padding:8px 16px;border:1px solid #E2E8F0;border-radius:6px;background:#fff;color:#718096;font-size:13px;cursor:pointer;">🔄 恢复默认</button>'
  5809. + ' <div style="display:flex;gap:8px;">'
  5810. + ' <button onclick="qClosePromptConfig()" style="padding:8px 20px;border:1px solid #E2E8F0;border-radius:6px;background:#fff;color:#4A5568;font-size:13px;cursor:pointer;">取消</button>'
  5811. + ' <button onclick="qSavePromptConfig()" style="padding:8px 20px;border:none;border-radius:6px;background:#3182CE;color:#fff;font-size:13px;font-weight:600;cursor:pointer;">💾 保存配置</button>'
  5812. + ' </div>'
  5813. + '</div>';
  5814. overlay.appendChild(modal);
  5815. document.body.appendChild(overlay);
  5816. // 聚焦到文本域
  5817. setTimeout(function() {
  5818. var ta = document.getElementById('q-prompt-textarea');
  5819. if (ta) ta.focus();
  5820. }, 100);
  5821. }
  5822. function qEscapeHtml(str) {
  5823. return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
  5824. }
  5825. // ── 关闭提示词配置弹窗 ──
  5826. function qClosePromptConfig() {
  5827. var modal = document.getElementById('q-prompt-config-modal');
  5828. if (modal) modal.remove();
  5829. }
  5830. // ── 保存提示词配置 ──
  5831. function qSavePromptConfig() {
  5832. var ta = document.getElementById('q-prompt-textarea');
  5833. if (!ta) return;
  5834. var val = ta.value.trim();
  5835. if (!val) {
  5836. showToast('提示词不能为空,请输入有效的提示词内容', 'warning');
  5837. return;
  5838. }
  5839. try {
  5840. localStorage.setItem(QWEN_PROMPT_KEY, val);
  5841. showToast('✅ 识别提示词已保存', 'success');
  5842. } catch(e) {
  5843. showToast('⚠️ 保存失败:' + e.message, 'error');
  5844. return;
  5845. }
  5846. qClosePromptConfig();
  5847. }
  5848. // ── 恢复默认提示词 ──
  5849. function qResetPromptToDefault() {
  5850. var ta = document.getElementById('q-prompt-textarea');
  5851. if (ta) {
  5852. ta.value = QWEN_DEFAULT_PROMPT;
  5853. showToast('已恢复为默认提示词(未保存,请点击保存)', 'info');
  5854. }
  5855. }
  5856. // ── 旧云端视觉识别入口:已停用,仅触发本地分析回退 ──
  5857. async function qCallQwenVLAPI(imageDataUrl, floorHeight){
  5858. throw new Error('旧云端视觉识别已停用');
  5859. var prompt = qGetPromptText();
  5860. console.log('[千问API] 接收到的图片参数类型:', typeof imageDataUrl, '长度:', (imageDataUrl||'').length, '前80字符:', (imageDataUrl||'').substring(0, 80));
  5861. try {
  5862. var response = await fetch(QWEN_API_URL, {
  5863. method: 'POST',
  5864. headers: {
  5865. 'Content-Type': 'application/json'
  5866. },
  5867. body: JSON.stringify({
  5868. model: QWEN_MODEL,
  5869. messages: [
  5870. {
  5871. role: 'user',
  5872. content: [
  5873. { type: 'image_url', image_url: { url: imageDataUrl } },
  5874. { type: 'text', text: prompt }
  5875. ]
  5876. }
  5877. ],
  5878. max_tokens: 4096,
  5879. temperature: 0.1
  5880. })
  5881. });
  5882. if (!response.ok) {
  5883. var errText = await response.text();
  5884. throw new Error('API请求失败: ' + response.status + ' - ' + errText);
  5885. }
  5886. var data = await response.json();
  5887. if (!data.choices || !data.choices[0] || !data.choices[0].message) {
  5888. throw new Error('API返回格式异常');
  5889. }
  5890. var content = data.choices[0].message.content;
  5891. // 提取JSON内容(可能被```json```包裹)
  5892. var jsonStr = content;
  5893. var jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/);
  5894. if (jsonMatch) jsonStr = jsonMatch[1].trim();
  5895. // 尝试直接解析
  5896. var parsed = null;
  5897. try { parsed = JSON.parse(jsonStr); } catch(e1) {
  5898. // 尝试提取第一个{...}块
  5899. var braceMatch = jsonStr.match(/\{[\s\S]*\}/);
  5900. if (braceMatch) {
  5901. try { parsed = JSON.parse(braceMatch[0]); } catch(e2) { /* 解析失败 */ }
  5902. }
  5903. }
  5904. if (!parsed || !parsed.rooms) {
  5905. throw new Error('AI返回内容无法解析为有效JSON,原始内容:' + content.substring(0, 200));
  5906. }
  5907. return parsed;
  5908. } catch(err) {
  5909. console.error('qCallQwenVLAPI error:', err);
  5910. throw err;
  5911. }
  5912. }
  5913. // ── 将千问API返回数据转换为系统标准格式 ──
  5914. function qConvertQwenResult(apiResult, floorHeight){
  5915. var fh = floorHeight || 2.5;
  5916. var rooms = (apiResult.rooms || []).map(function(r){
  5917. var cabW = parseFloat(r.cabinetWidth) || 0;
  5918. // 柜体高度:优先使用AI识别的cabinetHeight,其次用图纸标注的height,最后fallback到层高
  5919. var cabH = r.cabinetHeight ? parseFloat(r.cabinetHeight) : (r.height ? parseFloat(r.height) : fh);
  5920. var projA = r.projectArea ? parseFloat(r.projectArea) : parseFloat((cabW * cabH).toFixed(3));
  5921. var roomLen = parseFloat(r.length) || 0;
  5922. var roomWid = parseFloat(r.width) || 0;
  5923. var roomArea = r.area ? parseFloat(r.area) : parseFloat((roomLen * roomWid).toFixed(1));
  5924. return {
  5925. roomName: r.roomName || '未知房间',
  5926. cabinetType: r.cabinetType || '柜体',
  5927. layoutStyle: (['一字型','L型','U型'].indexOf(r.layoutStyle) !== -1) ? r.layoutStyle : '一字型',
  5928. projectArea: projA,
  5929. doorType: r.doorType || '普通平板',
  5930. length: roomLen || cabW,
  5931. width: roomWid || 3.0,
  5932. height: cabH,
  5933. area: roomArea || 10,
  5934. cabinetWidth: cabW,
  5935. cabinetHeight: cabH,
  5936. cabinetRatio: 0.50,
  5937. fillRate: 0.50,
  5938. doorRatio: 0.50,
  5939. bayWindow: false,
  5940. _qwenSource: true
  5941. };
  5942. });
  5943. var totalArea = rooms.reduce(function(s,r){ return s + r.projectArea; }, 0);
  5944. return {
  5945. rooms: rooms,
  5946. houseType: apiResult.houseLabel || ('全屋定制' + rooms.length + '组柜体'),
  5947. houseLabel: apiResult.houseLabel || ('全屋定制' + rooms.length + '组柜体'),
  5948. totalArea: parseFloat(totalArea.toFixed(1)),
  5949. grossArea: apiResult.totalGrossArea || parseFloat(totalArea.toFixed(1)),
  5950. orientation: '南北通透',
  5951. ratio: 1.0,
  5952. roomCount: rooms.length,
  5953. walls: [],
  5954. doors: [],
  5955. windows: [],
  5956. structType: 'AI识别',
  5957. outerWallThickness: '',
  5958. innerWallThickness: '',
  5959. floorHeight: fh + 'm',
  5960. totalWidth: ''
  5961. };
  5962. }
  5963. function qGenerateRoomsFromImage(imgWidth, imgHeight, fileName, opts){
  5964. opts = opts || {};
  5965. var mockType = opts.mockType || null;
  5966. var sheetName = opts.sheetName || null;
  5967. var sheetId = opts.sheetId || null;
  5968. var ratio = imgWidth / Math.max(imgHeight, 1);
  5969. var orientation = ratio >= 1.0 ? '横版' : '竖版';
  5970. var isMock = (fileName && fileName.indexOf('120㎡三房两厅') !== -1) || mockType === 'floor1' || mockType === 'floor2';
  5971. var isFloor2 = mockType === 'floor2';
  5972. var hasCAD = !!(qUploadedCAD || opts.hasCAD);
  5973. // ── 第一步:优先执行OCR文字标注识别 ──
  5974. var ocrData = qOCRExtractTextAnnotations(fileName, hasCAD, mockType);
  5975. // 根据OCR识别结果动态生成柜体清单标签和总投影面积
  5976. var houseLabel, grossArea;
  5977. if (isMock) {
  5978. houseLabel = isFloor2 ? '二楼柜体清单' : '全屋定制7组柜体';
  5979. grossArea = isFloor2 ? 47 : 39.375;
  5980. } else {
  5981. var cabinetCount = ocrData.roomLabels.length;
  5982. var totalProjectArea = ocrData.areaLabels.reduce(function(s,a){return s+(a.value||0);},0);
  5983. // 提取柜体名称列表生成标签
  5984. var cabinetNames = ocrData.roomLabels.map(function(r){return r.text;});
  5985. houseLabel = '全屋定制'+cabinetCount+'组柜体('+cabinetNames.slice(0,3).join('/')+(cabinetCount>3?'...':'')+')';
  5986. grossArea = totalProjectArea > 0 ? totalProjectArea : 0;
  5987. }
  5988. // ── 第二步:执行图形尺寸测量(作为补充/验证) ──
  5989. var graphicData = qMeasureGraphicDimensions(imgWidth, imgHeight, ocrData);
  5990. // ── 第三步:数据融合 - 优先使用OCR文字标注,图形测量作为补充验证 ──
  5991. var fusedResult = qFuseAnnotationData(ocrData, graphicData);
  5992. var rooms = fusedResult.rooms;
  5993. // 为每个房间标记来源图纸
  5994. if (sheetId) {
  5995. rooms.forEach(function(r){
  5996. r.sourceSheetId = sheetId;
  5997. r.sourceSheetName = sheetName;
  5998. });
  5999. }
  6000. // 保存融合统计数据供日志展示
  6001. window._qLastOCRStats = fusedResult.ocrStats;
  6002. window._qLastGraphicStats = fusedResult.graphicStats;
  6003. window._qLastFusionStats = fusedResult.fusionStats;
  6004. window._qLastOCRData = ocrData;
  6005. window._qLastGraphicData = graphicData;
  6006. var totalArea = rooms.reduce(function(sum, r) { return sum + r.projectArea; }, 0);
  6007. // 生成模拟的墙体和门窗数据
  6008. var walls = [
  6009. {id:'W1',type:'外墙',orientation:'北',length:parseFloat((totalArea*0.35).toFixed(1)),thickness:0.24},
  6010. {id:'W2',type:'外墙',orientation:'南',length:parseFloat((totalArea*0.35).toFixed(1)),thickness:0.24},
  6011. {id:'W3',type:'外墙',orientation:'东',length:parseFloat((totalArea*0.28).toFixed(1)),thickness:0.24},
  6012. {id:'W4',type:'外墙',orientation:'西',length:parseFloat((totalArea*0.28).toFixed(1)),thickness:0.24}
  6013. ];
  6014. for (var wi = 0; wi < rooms.length - 1; wi++) {
  6015. walls.push({id:'W'+(5+wi),type:'内墙',orientation:'隔墙',length:parseFloat((3+Math.random()*3).toFixed(1)),thickness:0.12,desc:'分隔 '+rooms[wi].roomName});
  6016. }
  6017. var doors = rooms.map(function(r,i){
  6018. return {id:'D'+(i+1),type:i===0?'入户门':'室内门',room:r.roomName,width:0.8+(i===0?0.1:0),position:'墙面',opening:i===0?'内开右':'内开左'};
  6019. });
  6020. var windows = rooms.map(function(r,i){
  6021. return {id:'WIN'+(i+1),room:r.roomName,type:i===0?'飘窗':'普通窗',width:parseFloat((1.5+Math.random()*1.5).toFixed(1)),orientation:['南','北','东','西'][i%4]};
  6022. });
  6023. return {
  6024. rooms: rooms,
  6025. houseType: houseLabel,
  6026. houseLabel: houseLabel,
  6027. totalArea: parseFloat(totalArea.toFixed(1)),
  6028. grossArea: grossArea,
  6029. orientation: '南北通透',
  6030. ratio: parseFloat(ratio.toFixed(2)),
  6031. roomCount: rooms.length,
  6032. walls: walls,
  6033. doors: doors,
  6034. windows: windows,
  6035. structType: '砖混结构 / 框架剪力墙',
  6036. outerWallThickness: '240mm(砖混结构)',
  6037. innerWallThickness: '120mm(轻质隔墙)',
  6038. floorHeight: '2.8m(标准层高)',
  6039. totalWidth: '12.4m(东西向)'
  6040. };
  6041. }
  6042. // 根据房间列表动态生成增项(已禁用:不自动生成任何增项,所有增项需用户手动添加)
  6043. function qGenerateExtrasFromRooms(rooms){
  6044. return [];
  6045. }
  6046. // ══════════════════════════════════════════════════════════════
  6047. // 全新三步AI柜体识别流程 - 全局状态
  6048. // ══════════════════════════════════════════════════════════════
  6049. var qAICurrentStep = 0;
  6050. var qAICleanedImageData = null;
  6051. var qAIPlacedCabinets = [];
  6052. var qAIScaleRatio = 0;
  6053. var qAIDrawingWidth = 0;
  6054. var qAIDrawingHeight = 0;
  6055. var qAIDrawingRealWidth = 0;
  6056. var qFurnitureLayout = [];
  6057. var qFurnitureDragPayload = null;
  6058. var qFurnitureCompositeDataUrl = '';
  6059. var qDimensionAnnotations = [];
  6060. var qDimensionDrawMode = false;
  6061. var qAutoDimensionDetectionDone = false;
  6062. var qAutoDimensionDetectionPromise = null;
  6063. var qScaleCalibrationMode = '';
  6064. function qDimensionSvgNode(name, attrs){
  6065. var n=document.createElementNS('http://www.w3.org/2000/svg',name);
  6066. Object.keys(attrs||{}).forEach(function(k){n.setAttribute(k,attrs[k]);});
  6067. return n;
  6068. }
  6069. function qDimensionEnsureLayer(){
  6070. var workspace=document.getElementById('q-floorplan-layout-workspace');
  6071. if(!workspace)return null;
  6072. var svg=document.getElementById('q-dimension-layer');
  6073. if(!svg){svg=qDimensionSvgNode('svg',{id:'q-dimension-layer'});workspace.appendChild(svg);}
  6074. return svg;
  6075. }
  6076. function qDimensionAlign(){
  6077. var workspace=document.getElementById('q-floorplan-layout-workspace'),drawing=document.getElementById('q-ai-cleaned-img'),svg=qDimensionEnsureLayer();
  6078. if(!workspace||!drawing||!svg)return;
  6079. var wr=workspace.getBoundingClientRect(),ir=drawing.getBoundingClientRect();
  6080. if(!ir.width||!ir.height)return;
  6081. svg.style.left=(ir.left-wr.left)+'px';svg.style.top=(ir.top-wr.top)+'px';svg.style.width=ir.width+'px';svg.style.height=ir.height+'px';
  6082. svg.setAttribute('viewBox','0 0 '+ir.width+' '+ir.height);qDimensionRender();
  6083. }
  6084. function qDimensionEdit(item){
  6085. qPromptDialog('请输入真实尺寸(例如 3150 mm)',item.value||'',function(value){
  6086. if(value&&value.trim()){item.value=value.trim();qCalculateImageScale();qDimensionRender();qFurnitureV3Render();}
  6087. });
  6088. }
  6089. function qDimensionRender(){
  6090. var svg=document.getElementById('q-dimension-layer');if(!svg)return;
  6091. var w=svg.clientWidth||Number((svg.getAttribute('viewBox')||'0 0 1 1').split(' ')[2]),h=svg.clientHeight||Number((svg.getAttribute('viewBox')||'0 0 1 1').split(' ')[3]);
  6092. svg.innerHTML='';
  6093. var defs=qDimensionSvgNode('defs',{}),marker=qDimensionSvgNode('marker',{id:'q-dimension-arrow',markerWidth:'8',markerHeight:'8',refX:'4',refY:'4',orient:'auto-start-reverse',markerUnits:'strokeWidth'});
  6094. marker.appendChild(qDimensionSvgNode('path',{d:'M 8 0 L 0 4 L 8 8 z',fill:'#129CE7'}));defs.appendChild(marker);svg.appendChild(defs);
  6095. qDimensionAnnotations.forEach(function(item){
  6096. var x1=item.x1/100*w,y1=item.y1/100*h,x2=item.x2/100*w,y2=item.y2/100*h,mx=(x1+x2)/2,my=(y1+y2)/2;
  6097. var g=qDimensionSvgNode('g',{'class':'q-dimension-item','data-id':item.id});
  6098. var line=qDimensionSvgNode('line',{x1:x1,y1:y1,x2:x2,y2:y2,'class':'q-dimension-line','marker-start':'url(#q-dimension-arrow)','marker-end':'url(#q-dimension-arrow)'});g.appendChild(line);
  6099. var labelWidth=Math.max(54,(item.value||'').length*9+16);
  6100. var bg=qDimensionSvgNode('rect',{x:mx-labelWidth/2,y:my-13,width:labelWidth,height:26,rx:5,'class':'q-dimension-label-bg'});
  6101. var text=qDimensionSvgNode('text',{x:mx,y:my+1,'class':'q-dimension-label'});text.textContent=item.value||'未填写';
  6102. g.appendChild(bg);g.appendChild(text);
  6103. [['start',x1,y1],['end',x2,y2]].forEach(function(p){var c=qDimensionSvgNode('circle',{cx:p[1],cy:p[2],r:6,'class':'q-dimension-handle'});c.onpointerdown=function(e){qDimensionStartHandleDrag(e,item,p[0]);};g.appendChild(c);});
  6104. bg.onclick=text.onclick=function(e){e.stopPropagation();qDimensionEdit(item);};
  6105. g.ondblclick=function(e){if(e.target===bg||e.target===text)return;e.preventDefault();qDimensionAnnotations=qDimensionAnnotations.filter(function(v){return v.id!==item.id;});qDimensionRender();};
  6106. svg.appendChild(g);
  6107. });
  6108. }
  6109. function qDimensionStartHandleDrag(e,item,end){
  6110. e.preventDefault();e.stopPropagation();var svg=document.getElementById('q-dimension-layer');
  6111. function move(ev){var r=svg.getBoundingClientRect(),x=Math.max(0,Math.min(100,(ev.clientX-r.left)/r.width*100)),y=Math.max(0,Math.min(100,(ev.clientY-r.top)/r.height*100));if(end==='start'){item.x1=x;item.y1=y;}else{item.x2=x;item.y2=y;}qDimensionRender();}
  6112. function up(){document.removeEventListener('pointermove',move);document.removeEventListener('pointerup',up);}
  6113. document.addEventListener('pointermove',move);document.addEventListener('pointerup',up);
  6114. }
  6115. function qDimensionSetMode(on){
  6116. qDimensionDrawMode=!!on;var svg=qDimensionEnsureLayer(),btn=document.getElementById('q-add-dimension-btn');
  6117. if(svg)svg.classList.toggle('drawing',qDimensionDrawMode);if(btn){btn.classList.toggle('active',qDimensionDrawMode);btn.textContent=qDimensionDrawMode?'在图上拖出尺寸线…':'↔ 标注真实尺寸';}
  6118. }
  6119. // 本地生成结构底图:去除高饱和彩色标注(含烘焙的红·蓝尺寸线),并增强深色墙体/轮廓。
  6120. // 该步骤完全在浏览器中运行,不依赖外部AI接口,因此不会因网络请求一直等待。
  6121. function qBuildLocalStructureDrawing(dataUrl){
  6122. return new Promise(function(resolve, reject){
  6123. var img = new Image();
  6124. img.onload = function(){
  6125. try {
  6126. var maxSide = 1400;
  6127. var scale = Math.min(1, maxSide / Math.max(img.naturalWidth, img.naturalHeight));
  6128. var canvas = document.createElement('canvas');
  6129. canvas.width = Math.max(1, Math.round(img.naturalWidth * scale));
  6130. canvas.height = Math.max(1, Math.round(img.naturalHeight * scale));
  6131. var w = canvas.width, h = canvas.height, count = w * h;
  6132. var ctx = canvas.getContext('2d', { willReadFrequently: true });
  6133. ctx.fillStyle = '#fff';
  6134. ctx.fillRect(0, 0, w, h);
  6135. ctx.drawImage(img, 0, 0, w, h);
  6136. var imageData = ctx.getImageData(0, 0, w, h);
  6137. var px = imageData.data;
  6138. // 1) 提取深色中性结构线。彩色标注、彩色家具及浅色水印不进入墙体候选。
  6139. var rawWall = new Uint8Array(count);
  6140. for (var p = 0; p < count; p++) {
  6141. var i = p * 4;
  6142. var r = px[i], g = px[i+1], b = px[i+2];
  6143. var max = Math.max(r, g, b), min = Math.min(r, g, b);
  6144. var chroma = max - min;
  6145. var lum = 0.299*r + 0.587*g + 0.114*b;
  6146. rawWall[p] = (lum < 112 && chroma < 58) ? 1 : 0;
  6147. }
  6148. // 2) 形态学开运算:细文字/水印会被滤掉,粗墙体得以保留。
  6149. function erode(src){
  6150. var out = new Uint8Array(count);
  6151. for(var y=1;y<h-1;y++) for(var x=1;x<w-1;x++){
  6152. var n=y*w+x;
  6153. if(src[n] && src[n-1] && src[n+1] && src[n-w] && src[n+w] &&
  6154. src[n-w-1] && src[n-w+1] && src[n+w-1] && src[n+w+1]) out[n]=1;
  6155. }
  6156. return out;
  6157. }
  6158. function dilate(src){
  6159. var out = new Uint8Array(count);
  6160. for(var y=1;y<h-1;y++) for(var x=1;x<w-1;x++){
  6161. var n=y*w+x;
  6162. if(src[n] || src[n-1] || src[n+1] || src[n-w] || src[n+w] ||
  6163. src[n-w-1] || src[n-w+1] || src[n+w-1] || src[n+w+1]) out[n]=1;
  6164. }
  6165. return out;
  6166. }
  6167. var wall = dilate(dilate(erode(rawWall)));
  6168. // 3) 先绘制干净白底和深色墙体。
  6169. for(var p2=0;p2<count;p2++){
  6170. var j=p2*4;
  6171. var v=wall[p2] ? 48 : 250;
  6172. px[j]=px[j+1]=px[j+2]=v; px[j+3]=255;
  6173. }
  6174. // 4) 对墙体围合区域做连通域填充,生成柔和彩平效果;外部背景保持白色。
  6175. var visited = new Uint8Array(count);
  6176. var queue = new Int32Array(count);
  6177. var palette = [
  6178. [226,240,255],[255,239,210],[232,247,226],[244,232,255],
  6179. [255,231,235],[226,247,244],[248,244,213],[232,237,247]
  6180. ];
  6181. var colorIndex=0, minRoomArea=Math.max(500, Math.round(count*0.0008));
  6182. for(var seed=0;seed<count;seed++){
  6183. if(wall[seed] || visited[seed]) continue;
  6184. var head=0,tail=0,touchesBorder=false;
  6185. queue[tail++]=seed; visited[seed]=1;
  6186. while(head<tail){
  6187. var n=queue[head++], x=n%w, y=(n/w)|0;
  6188. if(x===0 || y===0 || x===w-1 || y===h-1) touchesBorder=true;
  6189. var nb;
  6190. if(x>0){nb=n-1;if(!wall[nb]&&!visited[nb]){visited[nb]=1;queue[tail++]=nb;}}
  6191. if(x<w-1){nb=n+1;if(!wall[nb]&&!visited[nb]){visited[nb]=1;queue[tail++]=nb;}}
  6192. if(y>0){nb=n-w;if(!wall[nb]&&!visited[nb]){visited[nb]=1;queue[tail++]=nb;}}
  6193. if(y<h-1){nb=n+w;if(!wall[nb]&&!visited[nb]){visited[nb]=1;queue[tail++]=nb;}}
  6194. }
  6195. if(!touchesBorder && tail>=minRoomArea){
  6196. var col=palette[colorIndex++%palette.length];
  6197. for(var q=0;q<tail;q++){
  6198. var k=queue[q]*4;
  6199. px[k]=col[0];px[k+1]=col[1];px[k+2]=col[2];px[k+3]=255;
  6200. }
  6201. }
  6202. }
  6203. ctx.putImageData(imageData, 0, 0);
  6204. resolve(canvas.toDataURL('image/png'));
  6205. } catch(err) { reject(err); }
  6206. };
  6207. img.onerror = function(){ reject(new Error('原始图纸加载失败')); };
  6208. img.src = dataUrl;
  6209. });
  6210. }
  6211. function qDimensionInit(){
  6212. var svg=qDimensionEnsureLayer(),btn=document.getElementById('q-add-dimension-btn'),clear=document.getElementById('q-clear-dimensions-btn');if(!svg)return;
  6213. if(btn)btn.onclick=function(){qDimensionSetMode(!qDimensionDrawMode);};
  6214. if(clear)clear.onclick=function(){qConfirmDialog('确定清空手动标注的尺寸线吗?\n仅清除在图上补充的蓝色尺寸标注,户型原图保持不变。',function(){qDimensionAnnotations=[];qDimensionRender();showToast('已清空尺寸线标注','success');});};
  6215. svg.onpointerdown=function(e){
  6216. if(!qDimensionDrawMode||e.target!==svg)return;e.preventDefault();var r=svg.getBoundingClientRect(),startX=(e.clientX-r.left)/r.width*100,startY=(e.clientY-r.top)/r.height*100;
  6217. var preview=qDimensionSvgNode('line',{x1:e.clientX-r.left,y1:e.clientY-r.top,x2:e.clientX-r.left,y2:e.clientY-r.top,'class':'q-dimension-line'});svg.appendChild(preview);
  6218. function move(ev){preview.setAttribute('x2',Math.max(0,Math.min(r.width,ev.clientX-r.left)));preview.setAttribute('y2',Math.max(0,Math.min(r.height,ev.clientY-r.top)));}
  6219. function up(ev){document.removeEventListener('pointermove',move);document.removeEventListener('pointerup',up);var endX=Math.max(0,Math.min(100,(ev.clientX-r.left)/r.width*100)),endY=Math.max(0,Math.min(100,(ev.clientY-r.top)/r.height*100));var dist=Math.hypot((endX-startX)*r.width/100,(endY-startY)*r.height/100);qDimensionSetMode(false);function qDimFinish(){qCalculateImageScale();qDimensionRender();qFurnitureV3Render();}if(dist>12){qPromptDialog('请输入这条尺寸线代表的真实尺寸(例如 3150 mm)','3150 mm',function(value){if(value&&value.trim())qDimensionAnnotations.push({id:'dim_'+Date.now(),x1:startX,y1:startY,x2:endX,y2:endY,value:value.trim()});qDimFinish();});}else{qDimFinish();}}
  6220. document.addEventListener('pointermove',move);document.addEventListener('pointerup',up);
  6221. };
  6222. if(!window.__qDimResizeBound){window.__qDimResizeBound=true;window.addEventListener('resize',qDimensionAlign);}qDimensionAlign();
  6223. }
  6224. // 将任意图片来源(blob:/data:/同源URL)规范化为模型API可接受的标准 data:image/jpeg;base64 数据URI,
  6225. // 并压缩到安全尺寸,避免把 blob 地址/超大原图直传模型导致 400 invalid_parameter_error(URL 无效)。
  6226. function qMakeApiImageDataUrl(src, maxSide){
  6227. maxSide = maxSide || 1400;
  6228. return new Promise(function(resolve, reject){
  6229. if(!src){ reject(new Error('无可用户型图')); return; }
  6230. var img = new Image();
  6231. img.onload = function(){
  6232. try{
  6233. var scale = Math.min(1, maxSide / Math.max(img.naturalWidth, img.naturalHeight));
  6234. var canvas = document.createElement('canvas');
  6235. canvas.width = Math.max(1, Math.round(img.naturalWidth * scale));
  6236. canvas.height = Math.max(1, Math.round(img.naturalHeight * scale));
  6237. var ctx = canvas.getContext('2d');
  6238. ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, canvas.width, canvas.height);
  6239. ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
  6240. resolve(canvas.toDataURL('image/jpeg', 0.85));
  6241. }catch(e){ reject(e); }
  6242. };
  6243. img.onerror = function(){ reject(new Error('图片加载失败,无法生成标准图像数据')); };
  6244. img.src = src;
  6245. });
  6246. }
  6247. function qResolveOriginalDrawingSource(){
  6248. if(qUploadedDrawingUrl) return qUploadedDrawingUrl;
  6249. if(qUploadedDrawingDataUrl) return qUploadedDrawingDataUrl;
  6250. var analysisPreview=document.getElementById('q-ai-drawing-img');
  6251. if(analysisPreview && analysisPreview.getAttribute('src')) return analysisPreview.src;
  6252. var uploadPreview=document.querySelector('#q-drawing-inner img');
  6253. if(uploadPreview && uploadPreview.getAttribute('src')) return uploadPreview.src;
  6254. return '';
  6255. }
  6256. function qSyncOriginalDrawingToLayout(){
  6257. var src=qResolveOriginalDrawingSource();
  6258. var layoutImg=document.getElementById('q-ai-cleaned-img');
  6259. var placeholder=document.getElementById('q-ai-cleaned-placeholder');
  6260. var cabinetImg=document.getElementById('q-ai-workspace-img');
  6261. if(src){
  6262. if(layoutImg){layoutImg.onload=function(){if(window.qAlignFurnitureOverlayToImage)window.qAlignFurnitureOverlayToImage();if(typeof qFurnitureV3Render==='function')qFurnitureV3Render();qDimensionAlign();setTimeout(function(){qAutoDetectOriginalDimensions(false);},250);};layoutImg.src=src;layoutImg.style.display='block';}
  6263. if(cabinetImg) cabinetImg.src=src;
  6264. if(placeholder) placeholder.style.display='none';
  6265. }else{
  6266. if(layoutImg) layoutImg.style.display='none';
  6267. if(placeholder){placeholder.style.display='block';placeholder.textContent='未读取到前面上传的户型图,请返回上传资料检查';}
  6268. }
  6269. return src;
  6270. }
  6271. // 家具布局 V2:全局实现,确保内联事件、拖放事件和动态贴纸始终可访问。
  6272. function qFurnitureV2Render(){
  6273. var overlay=document.getElementById('q-furniture-overlay');
  6274. if(!overlay) return;
  6275. if(window.qAlignFurnitureOverlayToImage) window.qAlignFurnitureOverlayToImage();
  6276. overlay.innerHTML='';
  6277. overlay.style.pointerEvents='none';
  6278. qFurnitureLayout.forEach(function(item){
  6279. var el=document.createElement('div');
  6280. el.className='q-placed-furniture';
  6281. el.draggable=true;
  6282. el.style.left=item.x+'%';
  6283. el.style.top=item.y+'%';
  6284. el.style.width=(58*(item.widthScale||1))+'px';
  6285. el.style.minHeight=(48*(item.heightScale||1))+'px';
  6286. el.style.pointerEvents='auto';
  6287. el.style.zIndex='20';
  6288. el.title='拖动调整位置,双击删除';
  6289. var icon=document.createElement('div'); icon.className='icon'; icon.textContent=item.icon||'▣';
  6290. var name=document.createElement('div'); name.className='name'; name.textContent=item.type||'家具';
  6291. var controls=document.createElement('div'); controls.className='q-furniture-size-controls';
  6292. [['宽−','width',-.15],['宽+','width',.15],['高−','height',-.15],['高+','height',.15]].forEach(function(cfg){
  6293. var btn=document.createElement('button'); btn.type='button'; btn.textContent=cfg[0]; btn.draggable=false;
  6294. btn.addEventListener('mousedown',function(e){e.stopPropagation();});
  6295. btn.addEventListener('click',function(e){qFurnitureV2Resize(item.id,cfg[1],cfg[2],e);});
  6296. controls.appendChild(btn);
  6297. });
  6298. el.appendChild(icon); el.appendChild(name); el.appendChild(controls);
  6299. el.addEventListener('dragstart',function(e){qFurnitureV2DragStart(e,item.type,item.icon,item.id);});
  6300. el.addEventListener('dblclick',function(e){e.preventDefault();qFurnitureV2Remove(item.id);});
  6301. overlay.appendChild(el);
  6302. });
  6303. }
  6304. function qFurnitureV2DragStart(e,type,icon,existingId){
  6305. qFurnitureDragPayload={type:type||'家具',icon:icon||'▣',existingId:existingId||null};
  6306. if(e && e.dataTransfer){
  6307. e.dataTransfer.effectAllowed=existingId?'move':'copy';
  6308. e.dataTransfer.setData('text/plain',JSON.stringify(qFurnitureDragPayload));
  6309. }
  6310. }
  6311. function qFurnitureV2Drop(e){
  6312. if(e){e.preventDefault();e.stopPropagation();}
  6313. var workspace=document.getElementById('q-floorplan-layout-workspace');
  6314. var drawing=document.getElementById('q-ai-cleaned-img');
  6315. if(!workspace || !drawing || drawing.style.display==='none'){showToast('未读取到原户型图','error');return false;}
  6316. var payload=qFurnitureDragPayload;
  6317. try{var raw=e.dataTransfer.getData('text/plain');if(raw)payload=JSON.parse(raw);}catch(ignore){}
  6318. if(!payload){showToast('未读取到家具贴纸,请重新拖动','warning');return false;}
  6319. qFurnitureV2Align();
  6320. var rect=drawing.getBoundingClientRect();
  6321. var x=Math.max(2,Math.min(98,((e.clientX-rect.left)/rect.width)*100));
  6322. var y=Math.max(2,Math.min(98,((e.clientY-rect.top)/rect.height)*100));
  6323. if(payload.existingId){
  6324. var old=qFurnitureLayout.find(function(f){return f.id===payload.existingId;});
  6325. if(old){old.x=x;old.y=y;}
  6326. }else{
  6327. qFurnitureLayout.push({id:'fur_'+Date.now()+'_'+Math.random().toString(36).slice(2,6),type:payload.type,icon:payload.icon,x:x,y:y,widthScale:1,heightScale:1});
  6328. }
  6329. qFurnitureDragPayload=null;
  6330. qFurnitureV2Render();
  6331. workspace.style.outline='3px solid #38A169';
  6332. setTimeout(function(){workspace.style.outline='';},450);
  6333. showToast('家具贴纸已放置','success');
  6334. return false;
  6335. }
  6336. function qFurnitureV2AddCenter(type,icon){
  6337. qFurnitureLayout.push({id:'fur_'+Date.now()+'_'+Math.random().toString(36).slice(2,6),type:type||'家具',icon:icon||'▣',x:50,y:50,widthScale:1,heightScale:1});
  6338. qFurnitureV2Align(); qFurnitureV2Render(); showToast('家具贴纸已添加到图纸中央','success');
  6339. }
  6340. function qFurnitureV2Remove(id){qFurnitureLayout=qFurnitureLayout.filter(function(f){return f.id!==id;});qFurnitureV2Render();}
  6341. function qFurnitureV2Resize(id,axis,delta,e){if(e){e.preventDefault();e.stopPropagation();}var f=qFurnitureLayout.find(function(v){return v.id===id;});if(!f)return;var k=axis==='height'?'heightScale':'widthScale';f[k]=Math.max(.5,Math.min(3,(f[k]||1)+delta));qFurnitureV2Render();}
  6342. function qFurnitureV2Align(){
  6343. var workspace=document.getElementById('q-floorplan-layout-workspace'),drawing=document.getElementById('q-ai-cleaned-img'),overlay=document.getElementById('q-furniture-overlay');
  6344. if(!workspace||!drawing||!overlay)return;var wr=workspace.getBoundingClientRect(),ir=drawing.getBoundingClientRect();if(!ir.width||!ir.height)return;
  6345. overlay.style.inset='auto';overlay.style.left=(ir.left-wr.left)+'px';overlay.style.top=(ir.top-wr.top)+'px';overlay.style.width=ir.width+'px';overlay.style.height=ir.height+'px';overlay.style.zIndex='10';
  6346. }
  6347. function qFurnitureV2Init(){
  6348. var workspace=document.getElementById('q-floorplan-layout-workspace');if(!workspace)return;
  6349. document.querySelectorAll('.q-furniture-tool').forEach(function(tool){
  6350. var nameEl=tool.querySelector('span'),type=nameEl?nameEl.textContent.trim():'家具',icon='▣';
  6351. for(var i=0;i<tool.childNodes.length;i++){if(tool.childNodes[i].nodeType===3&&tool.childNodes[i].textContent.trim()){icon=tool.childNodes[i].textContent.trim();break;}}
  6352. tool.ondragstart=function(e){qFurnitureV2DragStart(e,type,icon);};
  6353. tool.onclick=function(){qFurnitureV2AddCenter(type,icon);};
  6354. });
  6355. workspace.ondragenter=function(e){e.preventDefault();workspace.style.background='#BEE3F8';};
  6356. workspace.ondragover=function(e){e.preventDefault();e.dataTransfer.dropEffect='copy';workspace.style.background='#BEE3F8';return false;};
  6357. workspace.ondrop=function(e){workspace.style.background='#FAFAFA';return qFurnitureV2Drop(e);};
  6358. window.addEventListener('resize',qFurnitureV2Align);
  6359. qFurnitureV2Align();qFurnitureV2Render();
  6360. }
  6361. // 家具布局 V3:使用 Pointer Events,贴纸直接放进原图工作区,不依赖 HTML5 drag/drop 与覆盖层。
  6362. function qFurnitureBaseMeters(type){var m={'餐边柜':[1.8,2.1],'客厅电视柜':[3.2,2.4],'玄关柜':[1.5,2.4],'洗衣机柜':[1.8,2.4],'客厅展示柜':[1.2,2.4],'阳台柜':[1.2,2.4],'次卧衣柜':[1.8,2.4],'主卧衣柜':[2.8,2.4],'厨房':[3.0,2.4],'衣帽间':[3.2,2.4]};return m[type]||[1.5,2.0];}
  6363. function qLoadCompositeImage(src){return new Promise(function(resolve){var img=new Image();img.onload=function(){resolve(img);};img.onerror=function(){resolve(null);};img.src=src;});}
  6364. async function qBuildFurnitureCompositeImage(){
  6365. var base=document.getElementById('q-ai-cleaned-img');if(!base||!base.naturalWidth||!base.naturalHeight)return '';
  6366. var canvas=document.createElement('canvas');canvas.width=base.naturalWidth;canvas.height=base.naturalHeight;var ctx=canvas.getContext('2d');
  6367. ctx.drawImage(base,0,0,canvas.width,canvas.height);
  6368. var rendered=base.getBoundingClientRect(),factor=canvas.width/Math.max(1,rendered.width);
  6369. var loaded=await Promise.all(qFurnitureLayout.map(function(item){return item.image?qLoadCompositeImage(item.image):Promise.resolve(null);}));
  6370. qFurnitureLayout.forEach(function(item,index){
  6371. var cardW=Math.max(70,90*(item.widthScale||1)*factor),cardH=Math.max(62,78*(item.heightScale||1)*factor),cx=item.x/100*canvas.width,cy=item.y/100*canvas.height,x=cx-cardW/2,y=cy-cardH/2;
  6372. ctx.save();ctx.shadowColor='rgba(0,0,0,.20)';ctx.shadowBlur=Math.max(3,4*factor);ctx.fillStyle='rgba(255,255,255,.94)';ctx.strokeStyle='#3182CE';ctx.lineWidth=Math.max(2,1.5*factor);ctx.beginPath();ctx.roundRect(x,y,cardW,cardH,Math.max(6,7*factor));ctx.fill();ctx.stroke();ctx.shadowColor='transparent';
  6373. var photo=loaded[index],photoH=cardH*.67;if(photo){var ratio=Math.min((cardW*.88)/photo.width,(photoH*.9)/photo.height),dw=photo.width*ratio,dh=photo.height*ratio;ctx.drawImage(photo,cx-dw/2,y+(photoH-dh)/2,dw,dh);}else{ctx.fillStyle='#2B6CB0';ctx.font='bold '+Math.max(18,22*factor)+'px sans-serif';ctx.textAlign='center';ctx.fillText(item.icon||'▣',cx,y+photoH*.62);}
  6374. ctx.fillStyle='#1A202C';ctx.font='bold '+Math.max(10,9*factor)+'px sans-serif';ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillText(item.type||'家具',cx,y+photoH+(cardH-photoH)*.34);
  6375. var dim=qFurnitureDimensionText(item);ctx.font='bold '+Math.max(9,8*factor)+'px sans-serif';var pillW=ctx.measureText(dim).width+Math.max(12,10*factor),pillH=Math.max(18,17*factor),pillY=y+cardH-pillH*.35;ctx.fillStyle='#1A365D';ctx.beginPath();ctx.roundRect(cx-pillW/2,pillY,pillW,pillH,pillH/2);ctx.fill();ctx.fillStyle='#fff';ctx.fillText(dim,cx,pillY+pillH/2);ctx.restore();
  6376. });
  6377. try{qFurnitureCompositeDataUrl=canvas.toDataURL('image/jpeg',.92);}catch(e){qFurnitureCompositeDataUrl='';}
  6378. return qFurnitureCompositeDataUrl;
  6379. }
  6380. function qDimensionValueToMeters(value){
  6381. var s=String(value||'').trim().toLowerCase().replace(/,/g,'.'),m=s.match(/-?\d+(?:\.\d+)?/);if(!m)return 0;
  6382. var n=parseFloat(m[0]);if(!(n>0))return 0;
  6383. if(/毫米|mm/.test(s))return n/1000;if(/厘米|cm/.test(s))return n/100;if(/米|\bm\b/.test(s))return n;
  6384. return n>=50?n/1000:n;
  6385. }
  6386. function qExtractArkJson(content){
  6387. var s=String(content||'').replace(/```json/gi,'').replace(/```/g,'').trim();
  6388. var first=s.indexOf('{'),last=s.lastIndexOf('}');if(first>=0&&last>first)s=s.slice(first,last+1);return JSON.parse(s);
  6389. }
  6390. async function qAutoDetectOriginalDimensions(force){
  6391. if(qAutoDimensionDetectionDone&&!force)return qDimensionAnnotations.filter(function(d){return d.source==='ai';}).length;
  6392. if(qAutoDimensionDetectionPromise&&!force)return qAutoDimensionDetectionPromise;
  6393. var status=document.getElementById('q-ai-dimension-status'),src=qResolveOriginalDrawingSource();
  6394. if(!src)return 0;
  6395. if(status){status.textContent='🤖 AI正在读取原图已有尺寸…';status.style.color='#805AD5';}
  6396. qAutoDimensionDetectionPromise=(async function(){
  6397. try{
  6398. var apiImage=await qMakeApiImageDataUrl(src,1400); // 规范化为标准data URI,防止模型报URL无效
  6399. var response=await fetch('/api/ark/dimension-detect',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({image:apiImage})});
  6400. var data=await response.json();if(!response.ok)throw new Error((data&&data.message)||('HTTP '+response.status));
  6401. var content=data&&data.choices&&data.choices[0]&&data.choices[0].message&&data.choices[0].message.content;
  6402. if(Array.isArray(content))content=content.map(function(v){return v.text||'';}).join('');
  6403. var parsed=qExtractArkJson(content),items=Array.isArray(parsed.dimensions)?parsed.dimensions:[];
  6404. var detected=[];items.forEach(function(v,index){
  6405. var confidence=Number(v.confidence==null?0.8:v.confidence),coords=[v.x1,v.y1,v.x2,v.y2].map(Number),meters=qDimensionValueToMeters(String(v.value||'')+' '+String(v.unit||''));
  6406. if(confidence<0.6||!meters||coords.some(function(n){return !isFinite(n)||n<0||n>100;})||Math.hypot(coords[2]-coords[0],coords[3]-coords[1])<1)return;
  6407. detected.push({id:'ai_dim_'+Date.now()+'_'+index,x1:coords[0],y1:coords[1],x2:coords[2],y2:coords[3],value:String(v.value)+' '+String(v.unit||'mm'),confidence:confidence,source:'ai'});
  6408. });
  6409. qDimensionAnnotations=qDimensionAnnotations.filter(function(d){return d.source!=='ai';}).concat(detected);qAutoDimensionDetectionDone=true;qCalculateImageScale();qDimensionRender();qFurnitureV3Render();
  6410. if(status){if(detected.length){status.textContent='✓ AI已读取原图 '+detected.length+' 条真实尺寸,可直接下一步';status.style.color='#25855A';}else{status.textContent='未读取到尺寸,仍可直接下一步并复核结果';status.style.color='#C05621';}}
  6411. return detected.length;
  6412. }catch(error){qAutoDimensionDetectionDone=true;if(status){status.textContent='AI尺寸识别暂不可用,仍可直接下一步';status.style.color='#C05621';status.title=error.message;}return 0;}
  6413. finally{qAutoDimensionDetectionPromise=null;}
  6414. })();
  6415. return qAutoDimensionDetectionPromise;
  6416. }
  6417. function qCalculateImageScale(){
  6418. var img=document.getElementById('q-ai-cleaned-img');if(!img||!img.naturalWidth||!img.naturalHeight)return {ok:false,reason:'户型图尚未加载完成'};
  6419. qAIDrawingWidth=img.naturalWidth;qAIDrawingHeight=img.naturalHeight;
  6420. // 识别配置规则2:按所选来源推算尺寸单位 —— 「标注尺寸」优先AI读取的原图标注,「录入尺寸」优先手动绘制录入;所选来源无数据时回退全部标注
  6421. var srcPref='';try{ srcPref=qGetRecogCfg().source||'标注尺寸'; }catch(e){ srcPref='标注尺寸'; }
  6422. var anns=qDimensionAnnotations;
  6423. var pref=srcPref==='录入尺寸'?anns.filter(function(d){return d.source!=='ai';}):anns.filter(function(d){return d.source==='ai';});
  6424. if(pref.length) anns=pref;
  6425. var samples=anns.map(function(d){var meters=qDimensionValueToMeters(d.value),px=Math.hypot((d.x2-d.x1)/100*img.naturalWidth,(d.y2-d.y1)/100*img.naturalHeight);return meters>0&&px>3?meters/px:0;}).filter(function(v){return v>0&&isFinite(v);}).sort(function(a,b){return a-b;});
  6426. if(!samples.length){qAIScaleRatio=0;return {ok:false,reason:'请先在原户型图上画一条真实尺寸线并填写尺寸'};}
  6427. var mid=Math.floor(samples.length/2);qAIScaleRatio=samples.length%2?samples[mid]:(samples[mid-1]+samples[mid])/2;
  6428. return {ok:true,count:samples.length,ratio:qAIScaleRatio,source:'尺寸标注智能校准('+srcPref+')'};
  6429. }
  6430. // ── 识别配置落地:规则色块自动抓取柜体宽/高(规则模块一第1、4条) ──
  6431. function qHexToRgb(hex){ var m=/^#?([0-9a-f]{6})$/i.exec(String(hex||'').trim()); if(!m) return null; var n=parseInt(m[1],16); return [(n>>16)&255,(n>>8)&255,n&255]; }
  6432. function qBuildRuleMask(img,maxW){
  6433. var nw=img.naturalWidth,nh=img.naturalHeight; if(!nw||!nh) return null;
  6434. maxW=maxW||1000; var s=Math.min(1,maxW/nw), W=Math.max(1,Math.round(nw*s)), H=Math.max(1,Math.round(nh*s));
  6435. var cv=document.createElement('canvas'); cv.width=W; cv.height=H;
  6436. var ctx=cv.getContext('2d',{willReadFrequently:true}); ctx.drawImage(img,0,0,W,H);
  6437. var data=ctx.getImageData(0,0,W,H).data;
  6438. var rule=qHexToRgb(qCadRuleColor)||[34,197,94];
  6439. var tol=160*160;
  6440. // 规则色主导通道:规则色改为红/蓝等时,主导判定随色相自动切换
  6441. var ruleMaxCh = rule[0]>=rule[1]&&rule[0]>=rule[2] ? 0 : (rule[1]>=rule[2]?1:2);
  6442. var mask=new Uint8Array(W*H);
  6443. for(var i=0;i<W*H;i++){
  6444. var r=data[i*4],g=data[i*4+1],b=data[i*4+2];
  6445. var dr=r-rule[0],dg=g-rule[1],db=b-rule[2];
  6446. var mx=Math.max(r,g,b),mn=Math.min(r,g,b);
  6447. var nearRule = dr*dr+dg*dg+db*db<=tol;
  6448. var pxc0=r,pxc1=g,pxc2=b;
  6449. var pxMaxCh = pxc0>=pxc1&&pxc0>=pxc2 ? 0 : (pxc1>=pxc2?1:2);
  6450. var otherMax = pxMaxCh===0 ? Math.max(pxc1,pxc2) : (pxMaxCh===1 ? Math.max(pxc0,pxc2) : Math.max(pxc0,pxc1));
  6451. var ruleDominant = (pxMaxCh===ruleMaxCh && (mx-otherMax)>40);
  6452. // 饱和度>90:真实标注笔画饱和度高(#22C55E=163),墙体边蓝黄混合的偏绿杂色(≈90以下)被排除
  6453. // 非主导通道上限0.55*mx:排除橙色房间填充(如#F5A25D, otherMax/mx≈0.66)等暖色误入,
  6454. // 保留纯红/深红笔画(otherMax/mx≈0.1~0.3)与绿色规则色(≈0.48)
  6455. if((nearRule||ruleDominant) && mx-mn>90 && otherMax<=0.55*mx){ mask[i]=1; }
  6456. }
  6457. return {mask:mask,W:W,H:H,s:s};
  6458. }
  6459. function qDetectRuleColorBoxes(img){
  6460. try{
  6461. var mm=qBuildRuleMask(img); if(!mm) return [];
  6462. var mask=mm.mask,W=mm.W,H=mm.H,s=mm.s;
  6463. // ── 线段配对矩形法:提取竖/横边框线 → 两两配对组装矩形 ──
  6464. // 优势:① 散点杂色像素(浅绿房间填充边缘等)因长度不足被过滤,不会撑歪包围盒;
  6465. // ② 相贴的多个框(共线边/共角)各自四边独立成线,可分别还原,不合并、不遗漏。
  6466. var GAP=8, MINRUN=20, COV=0.65, MAXSTROKE=14;
  6467. var vruns=[];
  6468. for(var x=0;x<W;x++){
  6469. var y=0;
  6470. while(y<H){
  6471. if(!mask[y*W+x]){ y++; continue; }
  6472. var y0=y, last=y, gap=0, yy=y+1;
  6473. while(yy<H){
  6474. if(mask[yy*W+x]){ last=yy; gap=0; } else { gap++; if(gap>GAP) break; }
  6475. yy++;
  6476. }
  6477. var len=last-y0+1;
  6478. if(len>=MINRUN){
  6479. var cnt=0; for(var t=y0;t<=last;t++) if(mask[t*W+x]) cnt++;
  6480. if(cnt/len>=COV) vruns.push({x:x,y0:y0,y1:last});
  6481. }
  6482. y=yy+1;
  6483. }
  6484. }
  6485. var vlines=[];
  6486. for(var vi=0;vi<vruns.length;vi++){
  6487. var rn=vruns[vi], placed=false;
  6488. for(var li=vlines.length-1;li>=0&&li>=vlines.length-40;li--){
  6489. var VL0=vlines[li];
  6490. if(rn.x-VL0.x1<=2 && rn.x-VL0.x1>=0 && Math.abs(rn.y0-VL0.y0)<=6 && Math.abs(rn.y1-VL0.y1)<=6){ VL0.x1=rn.x; placed=true; break; }
  6491. }
  6492. if(!placed) vlines.push({x0:rn.x,x1:rn.x,y0:rn.y0,y1:rn.y1});
  6493. }
  6494. vlines=vlines.filter(function(L){ return (L.x1-L.x0+1)<=MAXSTROKE && (L.y1-L.y0+1)>=MINRUN; });
  6495. var hruns=[];
  6496. for(y=0;y<H;y++){
  6497. x=0;
  6498. while(x<W){
  6499. if(!mask[y*W+x]){ x++; continue; }
  6500. var x0=x, last2=x, gap2=0, xx=x+1;
  6501. while(xx<W){
  6502. if(mask[y*W+xx]){ last2=xx; gap2=0; } else { gap2++; if(gap2>GAP) break; }
  6503. xx++;
  6504. }
  6505. var len2=last2-x0+1;
  6506. if(len2>=MINRUN){
  6507. var cnt2=0; for(t=x0;t<=last2;t++) if(mask[y*W+t]) cnt2++;
  6508. if(cnt2/len2>=COV) hruns.push({y:y,x0:x0,x1:last2});
  6509. }
  6510. x=xx+1;
  6511. }
  6512. }
  6513. var hlines=[];
  6514. for(var hi=0;hi<hruns.length;hi++){
  6515. var rn2=hruns[hi], placed2=false;
  6516. for(li=hlines.length-1;li>=0&&li>=hlines.length-40;li--){
  6517. var HL0=hlines[li];
  6518. if(rn2.y-HL0.y1<=2 && rn2.y-HL0.y1>=0 && Math.abs(rn2.x0-HL0.x0)<=6 && Math.abs(rn2.x1-HL0.x1)<=6){ HL0.y1=rn2.y; placed2=true; break; }
  6519. }
  6520. if(!placed2) hlines.push({y0:rn2.y,y1:rn2.y,x0:rn2.x0,x1:rn2.x1});
  6521. }
  6522. hlines=hlines.filter(function(L){ return (L.y1-L.y0+1)<=MAXSTROKE && (L.x1-L.x0+1)>=MINRUN; });
  6523. // 组装矩形:左竖线+右竖线(顶/底对齐)+ 顶横线+底横线(覆盖左右边)
  6524. var rects=[];
  6525. for(var a2=0;a2<vlines.length;a2++){
  6526. for(var b3=0;b3<vlines.length;b3++){
  6527. var VL=vlines[a2], VR=vlines[b3];
  6528. if(VR.x0-VL.x1<6) continue;
  6529. var wpx=VR.x1-VL.x0+1;
  6530. if(wpx<10||wpx>W*0.85) continue;
  6531. // 重叠区间配对:两竖线取 y 重叠段为矩形顶/底(长线覆盖即可),
  6532. // 兼容 D/E 这类共用一条连续左边的上下相贴框
  6533. var oT=Math.max(VL.y0,VR.y0), oB=Math.min(VL.y1,VR.y1);
  6534. var minLen=Math.min(VL.y1-VL.y0, VR.y1-VR.y0);
  6535. if(oB-oT < 0.7*minLen) continue;
  6536. var yT=oT, yB=oB, hpx=yB-yT+1;
  6537. if(hpx<10||hpx>H*0.85) continue;
  6538. var T=null,B2=null;
  6539. for(var h2=0;h2<hlines.length;h2++){
  6540. var HL=hlines[h2];
  6541. if(HL.x0<=VL.x0+6 && HL.x1>=VR.x1-6){
  6542. if(Math.abs(HL.y0-yT)<=8){ if(!T||Math.abs(HL.y0-yT)<Math.abs(T.y0-yT)) T=HL; }
  6543. if(Math.abs(HL.y0-yB)<=8){ if(!B2||Math.abs(HL.y0-yB)<Math.abs(B2.y0-yB)) B2=HL; }
  6544. }
  6545. }
  6546. if(T&&B2) rects.push({x:VL.x0,y:T.y0,w:VR.x1-VL.x0+1,h:B2.y1-T.y0+1});
  6547. }
  6548. }
  6549. // 去重(同一框的重复组合)
  6550. var ded=[];
  6551. for(var i=0;i<rects.length;i++){
  6552. var R2=rects[i], dup=false, rep=-1;
  6553. for(var d2=0;d2<ded.length;d2++){
  6554. var D2=ded[d2];
  6555. var sameXY=Math.abs(R2.x-D2.x)<=6&&Math.abs(R2.y-D2.y)<=6;
  6556. // 完全相近=重复组合;同左上且同宽/同高但另一边不同=边框被邻近红字拉长的重复框,保留更贴合者
  6557. if(sameXY&&(Math.abs(R2.w-D2.w)<=6||Math.abs(R2.h-D2.h)<=6)){ dup=true; if(R2.w*R2.h<D2.w*D2.h) rep=d2; break; }
  6558. }
  6559. if(!dup) ded.push(R2); else if(rep>=0) ded[rep]=R2;
  6560. }
  6561. // 包含去重:完全落在另一框内部的小框是"半截边框"误组装(如粗角点+邻边拼出的假框),剔除
  6562. var M=6;
  6563. ded=ded.filter(function(Ra,ia){
  6564. for(var ib=0;ib<ded.length;ib++){
  6565. if(ib===ia) continue;
  6566. var Rb=ded[ib];
  6567. if(Ra.w*Ra.h>=Rb.w*Rb.h) continue; // 只剔除更小者
  6568. if(Ra.x>=Rb.x-M&&Ra.y>=Rb.y-M&&Ra.x+Ra.w<=Rb.x+Rb.w+M&&Ra.y+Ra.h<=Rb.y+Rb.h+M) return false;
  6569. }
  6570. return true;
  6571. });
  6572. ded=ded.filter(function(R3){
  6573. if(R3.w<20||R3.h<20) return false;
  6574. if(R3.w>W*0.85&&R3.h>H*0.85) return false;
  6575. if(R3.w/R3.h>12||R3.h/R3.w>12) return false;
  6576. return true;
  6577. });
  6578. return ded.map(function(bb){ return {x:bb.x/s,y:bb.y/s,w:bb.w/s,h:bb.h/s}; });
  6579. }catch(e){ console.warn('色块识别失败:',e); return []; }
  6580. }
  6581. // ── 红字标注读取:对规则色文字做整串模板匹配OCR,识别每个色块旁的柜体名称(如"餐边柜""阳台柜2") ──
  6582. var Q_LABEL_VOCAB=['开放式厨房柜','厨房柜','主卧衣柜','次卧衣柜','儿童房衣柜','老人房衣柜','衣帽间','客厅电视柜','电视柜','餐边柜','阳台柜2','阳台柜','玄关柜','鞋柜','书柜','书桌','酒柜','榻榻米','衣柜','橱柜','储物柜','浴室柜'];
  6583. function qCharTemplates(){
  6584. if(qCharTemplates._t) return qCharTemplates._t;
  6585. var chars={};
  6586. Q_LABEL_VOCAB.forEach(function(w){ w.split('').forEach(function(c){ chars[c]=1; }); });
  6587. var tc=document.createElement('canvas'); tc.width=48; tc.height=48;
  6588. var tctx=tc.getContext('2d',{willReadFrequently:true});
  6589. var t={};
  6590. // 多字体×多字号模板:图纸文字缩放后字号不一,饱和度侵蚀下小字号细笔画字形(如"主")
  6591. // 与20px模板差异大易误配,覆盖12/14/16/20px源字号后同源匹配得分接近1
  6592. ['sans-serif','Microsoft YaHei','PingFang SC','Noto Sans SC','SimHei','SimSun'].forEach(function(fnt){
  6593. [12,14,16,20].forEach(function(fs){
  6594. Object.keys(chars).forEach(function(ch){
  6595. tctx.clearRect(0,0,48,48); tctx.fillStyle='#000'; tctx.font=fs+'px '+fnt; tctx.textBaseline='top'; tctx.fillText(ch,6,2);
  6596. var td=tctx.getImageData(0,0,48,48).data, mnx=48,mny=48,mxx=-1,mxy=-1;
  6597. for(var y=0;y<48;y++)for(var x=0;x<48;x++){ if(td[(y*48+x)*4+3]>100){ if(x<mnx)mnx=x; if(x>mxx)mxx=x; if(y<mny)mny=y; if(y>mxy)mxy=y; } }
  6598. if(mxx<0) return;
  6599. if(!t[ch]) t[ch]=[];
  6600. t[ch].push(qOcrNorm(function(gx,gy){ return td[((gy+mny)*48+(gx+mnx))*4+3]>100?1:0; }, mxx-mnx+1, mxy-mny+1));
  6601. });
  6602. });
  6603. });
  6604. qCharTemplates._t=t; return t;
  6605. }
  6606. function qReadRuleColorLabels(img){
  6607. try{
  6608. // 高分辨率掩膜:橙底红字等细笔画在低分辨率下断裂导致OCR失败,2000宽可保留字形
  6609. var m=qBuildRuleMask(img,2000); if(!m) return [];
  6610. var mask=m.mask,W=m.W,H=m.H,s=m.s;
  6611. // 剔除长线条(色块边框/箭头杆/尺寸线):竖/横连续长度≥30的像素视为线条,只保留文字短笔画。
  6612. // 阈值取30而非更小:大字号标注(20-30px)的长横/长竖笔画可达28px,阈值过小会把笔画当线条删掉导致缺字
  6613. var line=new Uint8Array(W*H), x, y, i;
  6614. for(x=0;x<W;x++){
  6615. y=0;
  6616. while(y<H){
  6617. if(!mask[y*W+x]){ y++; continue; }
  6618. var y0=y; while(y+1<H&&mask[(y+1)*W+x]) y++;
  6619. if(y-y0+1>=30) for(var t=y0;t<=y;t++) line[t*W+x]=1;
  6620. y++;
  6621. }
  6622. }
  6623. for(y=0;y<H;y++){
  6624. x=0;
  6625. while(x<W){
  6626. if(!mask[y*W+x]){ x++; continue; }
  6627. var x0=x; while(x+1<W&&mask[y*W+x+1]) x++;
  6628. if(x-x0+1>=30) for(var t2=x0;t2<=x;t2++) line[y*W+t2]=1;
  6629. x++;
  6630. }
  6631. }
  6632. var text=new Uint8Array(W*H);
  6633. for(i=0;i<W*H;i++) text[i]=(mask[i]&&!line[i])?1:0;
  6634. // 连通域(8邻域)→ 字符碎片
  6635. var seen=new Uint8Array(W*H), comps=[];
  6636. for(i=0;i<W*H;i++){
  6637. if(!text[i]||seen[i]) continue;
  6638. var st=[i], mnx=i%W, mxx=mnx, mny=(i/W)|0, mxy=mny, cnt=0;
  6639. seen[i]=1;
  6640. while(st.length){
  6641. var p=st.pop(), cx=p%W, cy=(p/W)|0; cnt++;
  6642. if(cx<mnx)mnx=cx; if(cx>mxx)mxx=cx; if(cy<mny)mny=cy; if(cy>mxy)mxy=cy;
  6643. for(var dy=-1;dy<=1;dy++)for(var dx=-1;dx<=1;dx++){
  6644. var nx=cx+dx, ny=cy+dy;
  6645. if(nx<0||ny<0||nx>=W||ny>=H) continue;
  6646. var np=ny*W+nx;
  6647. if(text[np]&&!seen[np]){ seen[np]=1; st.push(np); }
  6648. }
  6649. }
  6650. if(cnt>=3) comps.push({x:mnx,y:mny,w:mxx-mnx+1,h:mxy-mny+1,cx:(mnx+mxx)/2,cy:(mny+mxy)/2});
  6651. }
  6652. // 按"水平相近+竖直对齐"合并成文本行
  6653. comps.sort(function(a,b){ return a.cx-b.cx; });
  6654. var clusters=[];
  6655. comps.forEach(function(c){
  6656. var hit=null;
  6657. for(var k=0;k<clusters.length;k++){
  6658. var cl=clusters[k];
  6659. var gap=Math.max(c.x-(cl.x+cl.w), cl.x-(c.x+c.w));
  6660. if(gap<=7 && Math.abs(c.cy-cl.cy)<=Math.max(6,Math.min(c.h,cl.h)*0.8)){ hit=cl; break; }
  6661. }
  6662. if(hit){
  6663. var rx2=Math.max(hit.x+hit.w,c.x+c.w), ry2=Math.max(hit.y+hit.h,c.y+c.h);
  6664. hit.x=Math.min(hit.x,c.x); hit.y=Math.min(hit.y,c.y);
  6665. hit.w=rx2-hit.x; hit.h=ry2-hit.y;
  6666. hit.cx=hit.x+hit.w/2; hit.cy=hit.y+hit.h/2;
  6667. }else clusters.push({x:c.x,y:c.y,w:c.w,h:c.h,cx:c.cx,cy:c.cy});
  6668. });
  6669. // 逐字切分模板匹配 → 拼串后与词表对照("2150mm"等非词表文字自然被过滤)
  6670. // 切分策略:等宽初始边界 ±3px 吸附到列墨迹投影谷点(字间空隙),段内再紧裁剪到墨迹包围盒,
  6671. // 避免邻字混入与切片留白导致的归一化变形
  6672. var tpls=qCharTemplates(), out=[];
  6673. clusters.forEach(function(cl){
  6674. if(cl.h<6||cl.h>48||cl.w<10||cl.w>320) return;
  6675. if(cl.w>cl.h*8||cl.h>cl.w*8) return; // 极细长簇=残余线条,不是文字
  6676. var colsum=[];
  6677. for(var xx=0;xx<cl.w;xx++){ var cs=0; for(var yy=0;yy<cl.h;yy++) cs+=text[(cl.y+yy)*W+(cl.x+xx)]; colsum.push(cs); }
  6678. var baseN=Math.max(1,Math.round(cl.w/cl.h)), found=null, foundScore=0;
  6679. var valleyR=Math.max(3,Math.round(cl.h*0.25)); // 大字号字隙更宽,谷点搜索窗口随字高放大
  6680. [baseN,baseN-1,baseN+1].forEach(function(n){
  6681. if(found||n<1||n>8) return;
  6682. var bounds=[0];
  6683. for(var k=1;k<n;k++){
  6684. var est=Math.round(k*cl.w/n), best=est, bv=Infinity;
  6685. for(var d2=-valleyR;d2<=valleyR;d2++){ var p=est+d2; if(p>0&&p<cl.w&&colsum[p]<bv){ bv=colsum[p]; best=p; } }
  6686. bounds.push(best);
  6687. }
  6688. bounds.push(cl.w);
  6689. var seq='', tot=0;
  6690. for(var k2=0;k2<n;k2++){
  6691. var segW=bounds[k2+1]-bounds[k2];
  6692. if(segW<3) return;
  6693. // 段内紧裁剪(与模板构建方式一致)
  6694. var mnx=segW, mxx=-1, mny=cl.h, mxy=-1, cnt=0;
  6695. for(var yy2=0;yy2<cl.h;yy2++)for(var xx2=0;xx2<segW;xx2++){
  6696. if(text[(cl.y+yy2)*W+(cl.x+bounds[k2]+xx2)]){ cnt++; if(xx2<mnx)mnx=xx2; if(xx2>mxx)mxx=xx2; if(yy2<mny)mny=yy2; if(yy2>mxy)mxy=yy2; }
  6697. }
  6698. if(cnt<3) return;
  6699. var bx=cl.x+bounds[k2]+mnx, by=cl.y+mny, cw=mxx-mnx+1, chh=mxy-mny+1;
  6700. var g=qOcrNorm(function(lx,ly){ return text[(by+ly)*W+(bx+lx)]?1:0; }, cw, chh);
  6701. var bc='?', bs2=-1;
  6702. for(var ch in tpls){ var vs=tpls[ch]; for(var v2=0;v2<vs.length;v2++){ var sc2=qOcrScore(g,vs[v2]); if(sc2>bs2){ bs2=sc2; bc=ch; } } }
  6703. if(bs2<0.45) return; // 任一字置信不足 → 该切分方案作废
  6704. seq+=bc; tot+=bs2;
  6705. }
  6706. if(Q_LABEL_VOCAB.indexOf(seq)>=0){ found=seq; foundScore=tot/n; }
  6707. });
  6708. out.push({cx:cl.cx/s,cy:cl.cy/s,x:cl.x/s,y:cl.y/s,w:cl.w/s,h:cl.h/s,text:found,score:+foundScore.toFixed(3)});
  6709. });
  6710. return out;
  6711. }catch(e){ console.warn('红字标注读取失败:',e); return []; }
  6712. }
  6713. function qGetRecogRatio(){
  6714. if(qAIScaleRatio>0) return {ratio:qAIScaleRatio,source:'尺寸标注校准'};
  6715. return {ratio:0,source:''};
  6716. }
  6717. // ── 离线尺寸OCR:读取图纸红/橙红尺寸线旁的数值,为每个绿框提供真实尺寸(适配非均匀比例图纸) ──
  6718. function qOcrTemplates(){
  6719. if(qOcrTemplates._t) return qOcrTemplates._t;
  6720. var tc=document.createElement('canvas'); tc.width=24; tc.height=28;
  6721. var tctx=tc.getContext('2d',{willReadFrequently:true});
  6722. var t={};
  6723. ['Arial','Segoe UI','Tahoma','sans-serif'].forEach(function(fnt){
  6724. '0123456789'.split('').forEach(function(ch){
  6725. tctx.clearRect(0,0,24,28); tctx.fillStyle='#000'; tctx.font='18px '+fnt; tctx.textBaseline='top'; tctx.fillText(ch,3,3);
  6726. var td=tctx.getImageData(0,0,24,28).data, mnx=24,mny=28,mxx=-1,mxy=-1;
  6727. for(var y=0;y<28;y++)for(var x=0;x<24;x++){ if(td[(y*24+x)*4+3]>100){ if(x<mnx)mnx=x; if(x>mxx)mxx=x; if(y<mny)mny=y; if(y>mxy)mxy=y; } }
  6728. if(mxx<0) return;
  6729. if(!t[ch]) t[ch]=[];
  6730. t[ch].push(qOcrNorm(function(gx,gy){ return td[((gy+mny)*24+(gx+mnx))*4+3]/255; }, mxx-mnx+1, mxy-mny+1));
  6731. });
  6732. });
  6733. qOcrTemplates._t=t; return t;
  6734. }
  6735. function qOcrNorm(get,w,h){
  6736. var GW=12,GH=16,g=new Float32Array(GW*GH);
  6737. for(var y=0;y<h;y++)for(var x=0;x<w;x++){ var wgt=get(x,y); if(wgt>0){ var gx=Math.min(GW-1,(x*GW/w)|0), gy=Math.min(GH-1,(y*GH/h)|0); g[gy*GW+gx]+=wgt; } }
  6738. return g;
  6739. }
  6740. function qOcrScore(a,b){ var sab=0,sa=0,sb=0; for(var i=0;i<a.length;i++){ sab+=a[i]*b[i]; sa+=a[i]*a[i]; sb+=b[i]*b[i]; } return (sa&&sb)?sab/Math.sqrt(sa*sb):0; }
  6741. function qOcrGlyphGrid(gl,rot){
  6742. // rot: 0=不旋转, 1=顺时针90°, -1=逆时针90°(把竖排文字字符转正)
  6743. var W2=gl.w,H2=gl.h;
  6744. return qOcrNorm(function(x,y){
  6745. var ox,oy;
  6746. if(rot===1){ ox=y; oy=W2-1-x; } // 目标(x,y)→原图
  6747. else if(rot===-1){ ox=H2-1-y; oy=x; }
  6748. else { ox=x; oy=y; }
  6749. return gl.grid[oy*W2+ox]||0;
  6750. }, rot?H2:W2, rot?W2:H2);
  6751. }
  6752. function qOcrRecognize(gl,rot){
  6753. var t=qOcrTemplates(), g=qOcrGlyphGrid(gl,rot), best='?',bs=-1;
  6754. for(var ch in t){
  6755. var vars=t[ch];
  6756. for(var v2=0;v2<vars.length;v2++){ var sc=qOcrScore(g,vars[v2]); if(sc>bs){ bs=sc; best=ch; } }
  6757. }
  6758. return [best,bs];
  6759. }
  6760. // 单位字符(m/mm等)跳过判定:转正后宽>高的为字母而非数字
  6761. function qOcrIsUnitGlyph(gl,rot){
  6762. var rw=rot?gl.h:gl.w, rh=rot?gl.w:gl.h;
  6763. return rw>rh*1.15 || rw<4;
  6764. }
  6765. // 粘连字符切分:竖排粘连按空行拆、横排粘连按空列拆(递归至单字符尺寸)
  6766. function qSplitMergedGlyph(gl,out,depth){
  6767. if(gl.dot||depth>=3||!(gl.w>=12||gl.h>=14)){ out.push(gl); return; }
  6768. var rows=[],cols=[],r,c;
  6769. for(r=0;r<gl.h;r++){ var s=0; for(c=0;c<gl.w;c++) s+=gl.grid[r*gl.w+c]; rows.push(s); }
  6770. for(c=0;c<gl.w;c++){ var s2=0; for(r=0;r<gl.h;r++) s2+=gl.grid[r*gl.w+c]; cols.push(s2); }
  6771. var cutAxis=0; // 1=按行拆(竖排粘连), 2=按列拆(横排粘连)
  6772. if(gl.h>=14&&gl.h>gl.w*1.2) cutAxis=1;
  6773. else if(gl.w>=12&&gl.w>gl.h*1.2) cutAxis=2;
  6774. if(!cutAxis){ out.push(gl); return; }
  6775. var lens=cutAxis===1?rows:cols, L=lens.length, Wd=cutAxis===1?gl.w:gl.h;
  6776. var segs=[],st=-1;
  6777. for(var i2=0;i2<=L;i2++){
  6778. var empty = i2===L || lens[i2]<=1;
  6779. if(!empty&&st<0) st=i2;
  6780. if(empty&&st>=0){ if(i2-st>=5) segs.push([st,i2-1]); st=-1; }
  6781. }
  6782. if(segs.length<2){ out.push(gl); return; }
  6783. segs.forEach(function(sg){
  6784. var a=sg[0],b2=sg[1], nl=b2-a+1;
  6785. var nw2=cutAxis===1?gl.w:nl, nh2=cutAxis===1?nl:gl.h;
  6786. var ng=new Float32Array(nw2*nh2);
  6787. for(var y=0;y<gl.h;y++)for(var x=0;x<gl.w;x++){
  6788. var wgt=gl.grid[y*gl.w+x]; if(wgt<=0.05)continue;
  6789. var idx=cutAxis===1?(y-a)*gl.w+x:(y)*nl+(x-a);
  6790. if(cutAxis===1){ if(y<a||y>b2)return; ng[idx]=wgt; }
  6791. else { if(x<a||x>b2)return; ng[idx]=wgt; }
  6792. }
  6793. var nx=cutAxis===1?gl.x:gl.x+a, ny=cutAxis===1?gl.y+a:gl.y;
  6794. qSplitMergedGlyph({x:nx,y:ny,w:nw2,h:nh2,cx:nx+nw2/2,cy:ny+nh2/2,dot:false,grid:ng},out,depth+1);
  6795. });
  6796. }
  6797. function qReadDrawingDimensions(img){
  6798. try{
  6799. var nw=img.naturalWidth,nh=img.naturalHeight;
  6800. var s=Math.min(1,1000/nw), W=Math.max(1,Math.round(nw*s)), H=Math.max(1,Math.round(nh*s));
  6801. var cv=document.createElement('canvas'); cv.width=W; cv.height=H;
  6802. var ctx=cv.getContext('2d',{willReadFrequently:true}); ctx.drawImage(img,0,0,W,H);
  6803. var data=ctx.getImageData(0,0,W,H).data;
  6804. var lineMask=new Uint8Array(W*H), textMask=new Uint8Array(W*H);
  6805. for(var i=0;i<W*H;i++){
  6806. var r=data[i*4],g=data[i*4+1],b=data[i*4+2];
  6807. if(r>140&&g<110&&b<110&&r-g>60&&r-b>60) lineMask[i]=1; // 纯红尺寸线
  6808. if(r>170&&g<150&&b<120&&r-g>60&&r-b>80) textMask[i]=1; // 橙红尺寸文字
  6809. }
  6810. // 尺寸线(长直线段)
  6811. var lines=[];
  6812. for(var x=0;x<W;x++){ var y=0; while(y<H){ if(!lineMask[y*W+x]){y++;continue;} var y0=y; while(y<H&&lineMask[y*W+x])y++; if(y-y0>=50) lines.push({o:'V',x0:x,x1:x,y0:y0,y1:y-1,px:y-y0}); } }
  6813. for(y=0;y<H;y++){ var xx=0; while(xx<W){ if(!lineMask[y*W+xx]){xx++;continue;} var x0=xx; while(xx<W&&lineMask[y*W+xx])xx++; if(xx-x0>=50) lines.push({o:'H',x0:x0,x1:xx-1,y0:y,y1:y,px:xx-x0}); } }
  6814. var mlines=[];
  6815. lines.forEach(function(L){
  6816. for(var k=0;k<mlines.length;k++){ var M=mlines[k];
  6817. if(M.o==='V'&&L.o==='V'&&Math.abs(M.x0-L.x0)<=6&&Math.abs(M.y0-L.y0)<=8&&Math.abs(M.y1-L.y1)<=8){ M.x1=Math.max(M.x1,L.x1); return; }
  6818. if(M.o==='H'&&L.o==='H'&&Math.abs(M.y0-L.y0)<=6&&Math.abs(M.x0-L.x0)<=8&&Math.abs(M.x1-L.x1)<=8){ M.y1=Math.max(M.y1,L.y1); return; }
  6819. }
  6820. mlines.push({o:L.o,x0:L.x0,x1:L.x1,y0:L.y0,y1:L.y1,px:L.px});
  6821. });
  6822. // 文字字符组件(含小数点)
  6823. var seen=new Uint8Array(W*H), glyphs=[];
  6824. for(var st=0;st<W*H;st++){
  6825. if(!textMask[st]||seen[st])continue;
  6826. var stack=[st],pts=[]; seen[st]=1; var px=0,minX=W,minY=H,maxX=0,maxY=0;
  6827. while(stack.length){ var p=stack.pop(),pxx=p%W,pyy=(p/W)|0; pts.push(p); px++; if(pxx<minX)minX=pxx; if(pxx>maxX)maxX=pxx; if(pyy<minY)minY=pyy; if(pyy>maxY)maxY=pyy;
  6828. if(pxx>0&&!seen[p-1]&&textMask[p-1]){seen[p-1]=1;stack.push(p-1);}
  6829. if(pxx<W-1&&!seen[p+1]&&textMask[p+1]){seen[p+1]=1;stack.push(p+1);}
  6830. if(pyy>0&&!seen[p-W]&&textMask[p-W]){seen[p-W]=1;stack.push(p-W);}
  6831. if(pyy<H-1&&!seen[p+W]&&textMask[p+W]){seen[p+W]=1;stack.push(p+W);} }
  6832. var bw=maxX-minX+1,bh=maxY-minY+1;
  6833. var isDot = px>=3&&bw<=6&&bh<=6;
  6834. if(isDot){
  6835. // 排除尺寸线箭头碎片:真实小数点附近无尺寸线像素
  6836. var nearLine=false;
  6837. for(var dy2=Math.max(0,minY-5);dy2<=Math.min(H-1,maxY+5)&&!nearLine;dy2++)
  6838. for(var dx2=Math.max(0,minX-5);dx2<=Math.min(W-1,maxX+5);dx2++)
  6839. if(lineMask[dy2*W+dx2]){ nearLine=true; break; }
  6840. if(nearLine) isDot=false;
  6841. }
  6842. if((px>=6&&bw>=3&&bw<=24&&bh>=7&&bh<=40)||isDot) {
  6843. var grid=new Float32Array(bw*bh);
  6844. for(var q2=0;q2<pts.length;q2++){ var pp2=pts[q2];
  6845. var ri=pp2*4, redness=Math.max(0,Math.min(1,(data[ri]-Math.max(data[ri+1],data[ri+2]))/100));
  6846. grid[(((pp2/W)|0)-minY)*bw+((pp2%W)-minX)]=Math.max(redness,0.2); }
  6847. var base={x:minX,y:minY,w:bw,h:bh,cx:minX+bw/2,cy:minY+bh/2,dot:isDot,grid:grid};
  6848. qSplitMergedGlyph(base,glyphs,0);
  6849. }
  6850. }
  6851. // 分组:先竖排(同x带、y递增),剩余横排
  6852. var used=new Uint8Array(glyphs.length), tlines=[];
  6853. var gi;
  6854. for(gi=0;gi<glyphs.length;gi++){
  6855. if(used[gi])continue;
  6856. var members=[gi]; used[gi]=1;
  6857. for(var gj=0;gj<glyphs.length;gj++){ if(used[gj])continue;
  6858. var last=members[members.length-1];
  6859. if(Math.abs(glyphs[gj].cx-glyphs[gi].cx)<=5 && glyphs[gj].cy>glyphs[last].cy && glyphs[gj].cy-glyphs[last].cy<=20){ members.push(gj); used[gj]=1; } }
  6860. if(members.length>=2){ tlines.push({vert:true,g:members.map(function(m){return glyphs[m];})}); }
  6861. else used[gi]=0;
  6862. }
  6863. for(gi=0;gi<glyphs.length;gi++){
  6864. if(used[gi])continue;
  6865. var members2=[gi]; used[gi]=1;
  6866. for(gj=0;gj<glyphs.length;gj++){ if(used[gj])continue;
  6867. var last2=members2[members2.length-1];
  6868. if(Math.abs(glyphs[gj].cy-glyphs[gi].cy)<=5 && glyphs[gj].cx>glyphs[last2].cx && glyphs[gj].cx-glyphs[last2].cx<=14){ members2.push(gj); used[gj]=1; } }
  6869. if(members2.length>=2){ tlines.push({vert:false,g:members2.map(function(m){return glyphs[m];})}); }
  6870. else used[gi]=0;
  6871. }
  6872. // 识别每条文字线(竖排尝试两种旋转×两种顺序,横排两种顺序,取可解析为数值且置信度最高者)
  6873. var parseSeq=function(seq){
  6874. var str='';
  6875. for(var k2=0;k2<seq.length;k2++){
  6876. var gl2=seq[k2];
  6877. if(gl2.dot){ str+='.'; continue; }
  6878. var rec=qOcrRecognize(gl2, seq.rot||0);
  6879. if(rec[1]<0.72) return null;
  6880. str+=rec[0];
  6881. }
  6882. if(str.indexOf('.')<0){
  6883. if(str.length===3) str=str[0]+'.'+str.slice(1);
  6884. else if(str.length===4) str=(+str)/1000+'';
  6885. else if(str.length===2) str=str[0]+'.'+str[1];
  6886. else return null;
  6887. }
  6888. var v=parseFloat(str);
  6889. return (isFinite(v)&&v>0&&v<20)?v:null;
  6890. };
  6891. tlines.forEach(function(T){
  6892. var combos=[];
  6893. if(T.vert){
  6894. var asc=T.g.slice().sort(function(a,b){return a.cy-b.cy;});
  6895. var desc=asc.slice().reverse();
  6896. asc.rot=1; desc.rot=1; combos.push([asc,1],[desc,1]);
  6897. var asc2=T.g.slice().sort(function(a,b){return a.cy-b.cy;});
  6898. var desc2=asc2.slice().reverse();
  6899. asc2.rot=-1; desc2.rot=-1; combos.push([asc2,-1],[desc2,-1]);
  6900. } else {
  6901. var lr=T.g.slice().sort(function(a,b){return a.cx-b.cx;}); lr.rot=0;
  6902. var rl=lr.slice().reverse(); rl.rot=0;
  6903. combos.push([lr,0],[rl,0]);
  6904. }
  6905. var bestV=null,bestS=-1;
  6906. combos.forEach(function(cb){
  6907. var seq=cb[0], tot=0, ok=true, str='';
  6908. for(var k2=0;k2<seq.length;k2++){
  6909. var gl2=seq[k2];
  6910. if(gl2.dot){ str+='.'; continue; }
  6911. if(qOcrIsUnitGlyph(gl2,cb[1])) continue; // 跳过单位字母 m
  6912. var rec=qOcrRecognize(gl2,cb[1]);
  6913. tot+=rec[1];
  6914. if(rec[1]<0.6){ ok=false; break; }
  6915. str+=rec[0];
  6916. }
  6917. if(!ok) return;
  6918. var avg=tot/Math.max(1,seq.length);
  6919. var v=null;
  6920. if(str.indexOf('.')>=0){ var f=parseFloat(str); if(isFinite(f)&&f>0&&f<20) v=f; }
  6921. else if(str.length===3){ v=parseFloat(str[0]+'.'+str.slice(1)); }
  6922. else if(str.length===4){ v=(+str)/1000; }
  6923. if(v&&avg>bestS){ bestS=avg; bestV=v; }
  6924. });
  6925. if(bestV){
  6926. var xs=T.g.map(function(q3){return q3.cx;}), ys=T.g.map(function(q3){return q3.cy;});
  6927. T.value=bestV; T.cx=(Math.min.apply(null,xs)+Math.max.apply(null,xs))/2; T.cy=(Math.min.apply(null,ys)+Math.max.apply(null,ys))/2;
  6928. }
  6929. T._dbg=combos.map(function(cb){ return cb[1]+'|'+cb[0].map(function(gl2){ return gl2.dot?'.':qOcrRecognize(gl2,cb[1])[0]; }).join(''); }).join(' ; ');
  6930. });
  6931. qReadDrawingDimensions._dbg=tlines.map(function(T){ return {vert:T.vert,n:T.g.length,v:T.value||0,x:Math.round(T.cx||0),y:Math.round(T.cy||0),dbg:T._dbg}; });
  6932. // 文字线 ↔ 尺寸线 关联
  6933. var dims=[];
  6934. mlines.forEach(function(L){
  6935. var lx=(L.x0+L.x1)/2, ly=(L.y0+L.y1)/2;
  6936. var best=null,bd=1e9;
  6937. tlines.forEach(function(T){
  6938. if(!T.value) return;
  6939. var d = L.o==='V' ? Math.abs(T.cx-lx)+Math.abs(T.cy-ly)*0.3 : Math.abs(T.cy-ly)+Math.abs(T.cx-lx)*0.3;
  6940. var near = L.o==='V' ? (Math.abs(T.cx-lx)<=22 && T.cy>=L.y0-25 && T.cy<=L.y1+25) : (Math.abs(T.cy-ly)<=22 && T.cx>=L.x0-25 && T.cx<=L.x1+25);
  6941. if(near&&d<bd){ bd=d; best=T; }
  6942. });
  6943. if(best) dims.push({o:L.o,x0:L.x0,x1:L.x1,y0:L.y0,y1:L.y1,px:L.px,m:best.value});
  6944. });
  6945. return dims.map(function(d2){ return {o:d2.o,x0:d2.x0/s,x1:d2.x1/s,y0:d2.y0/s,y1:d2.y1/s,px:d2.px/s,m:d2.m}; });
  6946. }catch(e){ console.warn('尺寸OCR失败:',e); return []; }
  6947. }
  6948. // ── 规则配置驱动:从解析模块读取识别参数(pa_02最小识别尺寸、pa_06标注识别容差),识别不再写死 ──
  6949. function qGetParseRuleParams(){
  6950. var p={minDimM:0.05,snapM:0.005};
  6951. try{
  6952. var rules=(window.moduleRulesData&&moduleRulesData.parse&&moduleRulesData.parse.rules)||[];
  6953. rules.forEach(function(r){
  6954. if(!r||r.enabled===false) return;
  6955. if(r.id==='pa_02'){ var v=parseFloat(r.value); if(v>0) p.minDimM=v/1000; } // 小于该尺寸的标注视为杂线忽略
  6956. else if(r.id==='pa_06'){ var t=parseFloat(String(r.value).replace(/[±~\s]/g,'')); if(t>0) p.snapM=Math.min(0.05,Math.max(0.001,t/1000)); } // 结果按容差吸附
  6957. });
  6958. }catch(e){}
  6959. return p;
  6960. }
  6961. function qMedian(nums){ if(!nums.length) return 0; var a=nums.slice().sort(function(x,y){return x-y;}); var m=Math.floor(a.length/2); return a.length%2?a[m]:(a[m-1]+a[m])/2; }
  6962. function qSnapToTolerance(m,snapM){ if(!(m>0)||!(snapM>0)) return m; return +(Math.round(m/snapM)*snapM).toFixed(3); }
  6963. // 为每个规则色框匹配真实尺寸(识别配置规则3:计算所有框的宽、高,尽量精准、误差最小):
  6964. // ① 优先框内/贴边的同向尺寸线直读(线长≈框边长时误差最小);
  6965. // ② 其次取最近≤3条同向尺寸线的换算值中位数(避免单条无关尺寸线带偏);
  6966. // ③ 无同向线时按分方向(竖线管高/横线管宽)全局中位比例换算,兼容非均匀比例图纸;
  6967. // ④ 最终按规则配置pa_06容差吸附(如±5mm),消除小数尾差。
  6968. function qApplyDimsToBox(b,dims,nw,nh,fallbackRatio){
  6969. var res={wm:0,hm:0,srcW:'',srcH:''};
  6970. var P=qGetParseRuleParams();
  6971. var usable=dims.filter(function(d){return d.m>0&&d.px>10&&d.m>=P.minDimM;});
  6972. var rV=qMedian(usable.filter(function(d){return d.o==='V';}).map(function(d){return d.m/d.px;}));
  6973. var rH=qMedian(usable.filter(function(d){return d.o==='H';}).map(function(d){return d.m/d.px;}));
  6974. var rAll=qMedian(usable.map(function(d){return d.m/d.px;}));
  6975. // 尺寸线常画在框边外侧:匹配窗口按边长15%放宽(至少8px),提高命中率
  6976. var mxW=Math.max(8,b.w*0.15), mxH=Math.max(8,b.h*0.15);
  6977. // 高(竖线)
  6978. var vCand=usable.filter(function(d){ return d.o==='V' && d.x0>=b.x-mxW && d.x1<=b.x+b.w+mxW && Math.min(d.y1,b.y+b.h)-Math.max(d.y0,b.y) > 0.4*Math.min(d.y1-d.y0,b.h); });
  6979. vCand.sort(function(a,b2){ return Math.abs((a.y1-a.y0)-b.h)-Math.abs((b2.y1-b2.y0)-b.h); });
  6980. if(vCand.length){
  6981. var dv=vCand[0];
  6982. if(Math.abs((dv.y1-dv.y0)-b.h)<=0.3*b.h){ res.hm=dv.m; res.srcH='尺寸线直读'+dv.m+'m'; }
  6983. else { res.hm=+qMedian(vCand.slice(0,3).map(function(d){return b.h*d.m/d.px;})).toFixed(3); res.srcH='邻近尺寸线中位换算'; }
  6984. } else { var fV=fallbackRatio>0?fallbackRatio:(rV||rAll); if(fV>0){ res.hm=+(b.h*fV).toFixed(3); res.srcH=rV?'全局竖向比例换算':'全局比例换算'; } }
  6985. // 宽(横线)
  6986. var hCand=usable.filter(function(d){ return d.o==='H' && d.y0>=b.y-mxH && d.y1<=b.y+b.h+mxH && Math.min(d.x1,b.x+b.w)-Math.max(d.x0,b.x) > 0.4*Math.min(d.x1-d.x0,b.w); });
  6987. hCand.sort(function(a,b2){ return Math.abs((a.x1-a.x0)-b.w)-Math.abs((b2.x1-b2.x0)-b.w); });
  6988. if(hCand.length){
  6989. var dh=hCand[0];
  6990. if(Math.abs((dh.x1-dh.x0)-b.w)<=0.3*b.w){ res.wm=dh.m; res.srcW='尺寸线直读'+dh.m+'m'; }
  6991. else { res.wm=+qMedian(hCand.slice(0,3).map(function(d){return b.w*d.m/d.px;})).toFixed(3); res.srcW='邻近尺寸线中位换算'; }
  6992. } else { var fH=fallbackRatio>0?fallbackRatio:(rH||rAll); if(fH>0){ res.wm=+(b.w*fH).toFixed(3); res.srcW=rH?'全局横向比例换算':'全局比例换算'; } }
  6993. // 按规则配置容差吸附,输出整齐的毫米级数值
  6994. res.wm=qSnapToTolerance(res.wm,P.snapM); res.hm=qSnapToTolerance(res.hm,P.snapM);
  6995. return res;
  6996. }
  6997. function qUpdateRecogStatus(){
  6998. var el=document.getElementById('q-recog-status'); if(!el) return;
  6999. var res=state.qRecogResult;
  7000. if(!res||!res.boxes){ el.innerHTML='📐 待上传图纸:上传后系统自动推算尺寸单位(1像素是多少米),并按规则色块自动抓取每个柜体的宽、高(厨房抓取四边总长度)。'; return; }
  7001. el.innerHTML='📐 尺寸来源:图纸尺寸线OCR直读 <b>'+(res.dimCount||0)+'</b> 条'+(res.ratio>0?' | 1像素 ≈ <b>'+Number(res.ratio).toFixed(4)+'</b> 米('+(res.source||'')+')':'')+' | 识别规则色块 <b>'+res.boxes.length+'</b> 个,已应用宽高 <b>'+(res.applied||0)+'</b> 组 | 红字标注命名 <b>'+(res.labelCount||0)+'</b> 个 | 千问模型:'+(res.qwen==='ok'?'✅ 已调用,辅助命名 <b>'+(res.qwenNamed||0)+'</b> 个':'⚪ 未接通(本地识别)')+' | 当前规则色:<b style="color:'+qCadRuleColor+'">■</b>';
  7002. }
  7003. async function qRunRecognitionAndApply(){
  7004. // 始终从"当前上传"的图纸源新建临时图像做识别,不复用 #q-ai-cleaned-img 元素:
  7005. // 更换图纸后该元素的新 src 可能尚未解码完成,元素内仍是上一张图的旧位图,
  7006. // 直接复用会把上一张图的柜体识别出来(残留根因)。
  7007. // 优先用同步更新的 qUploadedDrawingUrl:qUploadedDrawingDataUrl 在 FileReader 异步回调里才写回,
  7008. // 换图后即刻识别时它仍是上一张图的 dataUrl,会导致识别旧图残留。
  7009. var src=qUploadedDrawingUrl||qUploadedDrawingDataUrl||qResolveOriginalDrawingSource();
  7010. if(!src){ qUpdateRecogStatus(); return null; }
  7011. var img=new Image();
  7012. await new Promise(function(res){ img.onload=res; img.onerror=res; img.src=src; });
  7013. if(!img||!img.naturalWidth){ qUpdateRecogStatus(); return null; }
  7014. qAIDrawingWidth=img.naturalWidth; qAIDrawingHeight=img.naturalHeight;
  7015. qCalculateImageScale();
  7016. var sc=qGetRecogRatio();
  7017. var nw=img.naturalWidth,nh=img.naturalHeight;
  7018. // 离线OCR读取图纸尺寸线数值(非均匀比例图纸下保证每框尺寸精准)
  7019. var dims=qReadDrawingDimensions(img);
  7020. // 识别全部绿色框;尺寸优先尺寸线直读,其次邻近线换算,最后全局比例
  7021. var boxes=qDetectRuleColorBoxes(img).map(function(b){
  7022. var bb={x:Math.round(b.x),y:Math.round(b.y),w:Math.round(b.w),h:Math.round(b.h)};
  7023. var ap=qApplyDimsToBox(bb,dims,nw,nh,sc.ratio>0?sc.ratio:0.015);
  7024. bb.wm=+ap.wm.toFixed(2); bb.hm=+ap.hm.toFixed(2); bb.srcW=ap.srcW; bb.srcH=ap.srcH;
  7025. return bb;
  7026. }).filter(function(b){ return b.wm>=0.3&&b.hm>=0.3; });
  7027. // 按阅读顺序(从上到下、从左到右)排序,保证编号稳定
  7028. boxes.sort(function(a,b){ var ra=Math.round((a.y+a.h/2)/(nh/10)), rb=Math.round((b.y+b.h/2)/(nh/10)); return (ra-rb)||((a.x+a.w/2)-(b.x+b.w/2)); });
  7029. // 读取规则色文字标注(柜体名称),为色块精准命名
  7030. var labels=qReadRuleColorLabels(img);
  7031. // 假框抑制:与文字簇大面积重叠的"色块"是字符笔画围成的闭合区域(如"柜/视"的目部首),
  7032. // 不是用户标注的真实柜体框,剔除——只保留红框标注的柜体,不产生无关柜体
  7033. boxes=boxes.filter(function(b){
  7034. for(var li0=0;li0<labels.length;li0++){
  7035. var L0=labels[li0];
  7036. var ox=Math.max(0,Math.min(b.x+b.w,L0.x+L0.w)-Math.max(b.x,L0.x));
  7037. var oy=Math.max(0,Math.min(b.y+b.h,L0.y+L0.h)-Math.max(b.y,L0.y));
  7038. if(ox*oy>0.55*b.w*b.h) return false;
  7039. }
  7040. return true;
  7041. });
  7042. state.qRecogResult={ratio:sc.ratio,source:sc.source,boxes:boxes,applied:0,ts:Date.now(),dimCount:dims.length,labelCount:labels.filter(function(L){return L.text;}).length};
  7043. // 标签↔色块全局贪心配对:所有(框,标注)候选对按距离升序、互斥一一分配,
  7044. // 避免逐框"就近先占"导致相邻框抢夺同一标注的错配,保证柜体与名字一一对应
  7045. function qDistBoxLabel(b,L){
  7046. var ddx=Math.max(b.x-L.cx,0,L.cx-(b.x+b.w)), ddy=Math.max(b.y-L.cy,0,L.cy-(b.y+b.h));
  7047. return Math.hypot(ddx,ddy);
  7048. }
  7049. var boxLabelText=new Array(boxes.length), labelUsed={}, pairs=[];
  7050. boxes.forEach(function(b,bi){ labels.forEach(function(L,li){ if(!L.text) return; pairs.push({d:qDistBoxLabel(b,L),bi:bi,li:li}); }); });
  7051. pairs.sort(function(p1,p2){ return p1.d-p2.d; });
  7052. pairs.forEach(function(p){
  7053. if(boxLabelText[p.bi]||labelUsed[p.li]) return;
  7054. var bb=boxes[p.bi], cap=Math.max(120,Math.hypot(bb.w,bb.h)*1.5);
  7055. if(p.d>cap) return;
  7056. boxLabelText[p.bi]=labels[p.li].text; labelUsed[p.li]=true;
  7057. });
  7058. // 千问模型默认调用:为本地红字OCR未读出的红框柜体辅助命名(本地命名优先;模型失败静默回退本地结果)
  7059. var qwenNamed=0, qwenState='fail';
  7060. var _apiImg=qDownscaleImageForApi(img,1280);
  7061. if(_apiImg){
  7062. var _qwenTxt=await qCallQwenVision('你是全屋定制图纸识别助手。图中用'+qCadRuleColorName+'框标注了柜子,旁边有'+qCadRuleColorName+'字名称。请识别每个'+qCadRuleColorName+'框内柜子的名称及所在区域(区域取值:左上/中上/右上/左中/正中/右中/左下/中下/右下),以及图中是否有厨房。严格只输出JSON:{"cabinets":[{"name":"主卧衣柜","pos":"右中"}],"kitchen":true}', _apiImg);
  7063. var _qwenObj=qParseQwenCabinets(_qwenTxt);
  7064. if(_qwenObj){
  7065. qwenState='ok';
  7066. if(Array.isArray(_qwenObj.cabinets)){
  7067. var _qwenUsed={};
  7068. boxLabelText.forEach(function(t){ if(t) _qwenUsed[t]=true; });
  7069. boxes.forEach(function(b,bi){
  7070. if(boxLabelText[bi]) return;
  7071. var bz=qBoxPosLabel((b.x+b.w/2)/nw,(b.y+b.h/2)/nh);
  7072. for(var qi=0;qi<_qwenObj.cabinets.length;qi++){
  7073. var qc=_qwenObj.cabinets[qi];
  7074. if(!qc||!qc.name||_qwenUsed[qc.name]) continue;
  7075. if(qc.pos&&String(qc.pos)!==bz) continue;
  7076. boxLabelText[bi]=String(qc.name); _qwenUsed[qc.name]=true; qwenNamed++; break;
  7077. }
  7078. });
  7079. }
  7080. }
  7081. }
  7082. state.qRecogResult.qwen=qwenState; state.qRecogResult.qwenNamed=qwenNamed;
  7083. // 厨房柜体保留(厨房按四边总长度计价,规则模块一第2条),其余柜体一律以绿色框为准重建,确保所有绿框完整抓取
  7084. var srcCabs=qAIPlacedCabinets.length?qAIPlacedCabinets:qFurnitureLayout.map(function(f,i){ var sz=qFurnitureMeters(f); return {id:'fur_'+i,type:f.type,x:f.x,y:f.y,width:+(+sz[0]).toFixed(2),height:+(+sz[1]).toFixed(2),source:'家具贴纸'}; });
  7085. var kitchenCabs=srcCabs.filter(function(c){ return qIsKitchenCabinet(c.type); }).map(function(c){
  7086. var kc={id:c.id||('k_'+Date.now()),type:c.type,x:c.x||50,y:c.y||30,width:c.width||0,height:c.height||0,source:c.source||'家具贴纸',widthScale:c.widthScale,heightScale:c.heightScale,image:c.image||''};
  7087. if(kc.width>0&&kc.height>0) kc.kitchenWalls=[kc.width,kc.height,kc.width,kc.height];
  7088. return kc;
  7089. });
  7090. // 规则模块一第2条:厨房同样按规则色块重新抓取宽/高 —— 若厨房贴纸落在某个规则色框内,
  7091. // 用该框识别出的宽/高更新厨房四边尺寸(总长度=2×(宽+高)),不再沿用贴纸默认尺寸
  7092. var usedByKitchen={};
  7093. kitchenCabs.forEach(function(kc){
  7094. var kcx=(kc.x||50)/100*nw, kcy=(kc.y||30)/100*nh, bestIdx=null, bestD=1e9;
  7095. boxes.forEach(function(b,bi){
  7096. var lt=boxLabelText[bi];
  7097. if(lt&&!qIsKitchenCabinet(lt)) return; // 该色块已配对非厨房红字标注,属其它柜体,禁止厨房占用
  7098. var inside=kcx>=b.x-b.w*0.35&&kcx<=b.x+b.w*1.35&&kcy>=b.y-b.h*0.35&&kcy<=b.y+b.h*1.35;
  7099. var d=Math.hypot(kcx-(b.x+b.w/2),kcy-(b.y+b.h/2));
  7100. if(inside&&d<bestD){ bestD=d; bestIdx=bi; }
  7101. });
  7102. if(bestIdx!=null){
  7103. var kb=boxes[bestIdx];
  7104. kc.width=kb.wm; kc.height=kb.hm; kc.kitchenWalls=[kb.wm,kb.hm,kb.wm,kb.hm]; kc.source='规则色块识别'; usedByKitchen[bestIdx]=true;
  7105. }
  7106. });
  7107. // 厨房默认必定识别:无厨房贴纸时,若某色框已配对厨房红字标注,直接用该色框作为厨房(不重复生成柜体)
  7108. if(!kitchenCabs.length){
  7109. var kLabelIdx=null;
  7110. boxes.forEach(function(b,bi){
  7111. if(kLabelIdx!=null||usedByKitchen[bi]) return;
  7112. var lt=boxLabelText[bi];
  7113. if(lt&&qIsKitchenCabinet(lt)) kLabelIdx=bi;
  7114. });
  7115. if(kLabelIdx!=null){
  7116. var kb2=boxes[kLabelIdx];
  7117. kitchenCabs=[{id:'k_'+Date.now(),type:'厨房',x:+((kb2.x+kb2.w/2)/nw*100).toFixed(2),y:+((kb2.y+kb2.h/2)/nh*100).toFixed(2),width:kb2.wm,height:kb2.hm,source:'规则色块识别',kitchenWalls:[kb2.wm,kb2.hm,kb2.wm,kb2.hm]}];
  7118. usedByKitchen[kLabelIdx]=true;
  7119. }
  7120. }
  7121. // 已被厨房引用的色框不再单独生成柜体,避免厨房重复计价;名字沿用全局配对结果,保证柜体与标注一一对应
  7122. var remainBoxes=[], remainTypes=[];
  7123. boxes.forEach(function(b,bi){ if(!usedByKitchen[bi]){ remainBoxes.push(b); remainTypes.push(boxLabelText[bi]); } });
  7124. // 精准命名:采用全局配对的红字标注文本,读取失败时回退位置锚点
  7125. var boxCabs=remainBoxes.map(function(b,idx){
  7126. var bcx=b.x+b.w/2, bcy=b.y+b.h/2, type=remainTypes[idx], bestD=1e9;
  7127. if(!type){
  7128. Q_BOX_NAME_ANCHORS.forEach(function(an){
  7129. var dp=Math.hypot(bcx-an[1]/100*nw, bcy-an[2]/100*nh);
  7130. if(dp<bestD){ bestD=dp; type=an[0]; }
  7131. });
  7132. if(bestD>Math.hypot(nw,nh)*0.12) type='柜体'+(idx+1);
  7133. }
  7134. var posX=+(bcx/nw*100).toFixed(0), posY=+(bcy/nh*100).toFixed(0);
  7135. return {id:'box_'+idx+'_'+Date.now(),type:type,x:+(bcx/nw*100).toFixed(2),y:+(bcy/nh*100).toFixed(2),width:b.wm,height:b.hm,source:'规则色块识别',posX:posX,posY:posY,posLabel:qBoxPosLabel(bcx/nw,bcy/nh)};
  7136. });
  7137. // 同名柜体编号:存在多个同名时依次编号1、2…(用户习惯"阳台柜1/阳台柜2")
  7138. var _nameCnt={};
  7139. boxCabs.forEach(function(bc){ _nameCnt[bc.type]=(_nameCnt[bc.type]||0)+1; });
  7140. var _nameSeq={};
  7141. boxCabs.forEach(function(bc){ if(_nameCnt[bc.type]>1){ _nameSeq[bc.type]=(_nameSeq[bc.type]||0)+1; bc.type=bc.type+_nameSeq[bc.type]; } });
  7142. // 每次识别 = 颜色框标注的柜体 + 厨房(厨房默认必定识别,不依赖色块标注):
  7143. // 色块=图上标注的柜体(精准完整),不注入其它默认家具,避免多出/重复柜体;
  7144. // 厨房按总长度计价、其余柜体按投影面积计价;无任何厨房来源时补默认厨房,保证厨房始终参与计价。
  7145. var kFinal=kitchenCabs;
  7146. if(!kFinal.length) kFinal=[{id:'k_default_'+Date.now(),type:'厨房',x:70,y:20,width:3,height:2.4,source:'默认识别',kitchenWalls:[3,2.4,3,2.4]}];
  7147. qAIPlacedCabinets=kFinal.concat(boxCabs);
  7148. qPrepareRoomsFromPlacedCabinets();
  7149. for(var i=0;i<qRooms.length&&i<qAIPlacedCabinets.length;i++){
  7150. var c2=qAIPlacedCabinets[i];
  7151. qRooms[i]._dataSource={cabinetWidth:c2.source||'规则色块识别',cabinetHeight:c2.source||'规则色块识别'};
  7152. if(qIsKitchenCabinet(qRooms[i].cabinetType||qRooms[i].roomName)){
  7153. var walls=Array.isArray(c2.kitchenWalls)?c2.kitchenWalls:[c2.width||0,c2.height||0,c2.width||0,c2.height||0];
  7154. qRooms[i].kitchenWalls=walls;
  7155. qRooms[i].length=walls[0]||qRooms[i].length; qRooms[i].width=walls[1]||qRooms[i].width;
  7156. }
  7157. }
  7158. var kitchenApplied=Object.keys(usedByKitchen).length;
  7159. state.qRecogResult.applied=boxCabs.length+kitchenApplied;
  7160. state.qRoomExcl={}; // 柜体列表已重建,清空旧的计入/不计入开关记录,避免索引错位
  7161. try{ persistState(); }catch(e){}
  7162. qUpdateRecogStatus();
  7163. // 联动重算:明细项单价全部从规则配置读取,同步刷新规则模块一的厨房计价预览
  7164. if(typeof qSyncRoomItemsWithRules==='function') qSyncRoomItemsWithRules();
  7165. if(typeof qRenderRuleModule1==='function') qRenderRuleModule1();
  7166. return state.qRecogResult;
  7167. }
  7168. // 图纸柜体标注位置锚点(%坐标),用于给识别到的绿色框命名;距离过远时回退为"柜体N"
  7169. var Q_BOX_NAME_ANCHORS=[
  7170. ['餐边柜',33,75],['电视柜',69,68],['阳台柜',68,91],['开放式厨房柜',49,30],
  7171. ['主卧衣柜',91,61],['衣帽间',87,19],['衣帽间',79,33],['次卧衣柜',15,67]
  7172. ];
  7173. function qBoxPosLabel(px,py){
  7174. var hx=px<0.33?'左':(px<0.66?'中':'右');
  7175. var vy=py<0.33?'上':(py<0.66?'中':'下');
  7176. return vy+hx;
  7177. }
  7178. function qFurnitureMeters(item){var b=qFurnitureBaseMeters(item.type),w=b[0]*(item.widthScale||1),h=b[1]*(item.heightScale||1),img=document.getElementById('q-ai-cleaned-img'),workspace=document.getElementById('q-floorplan-layout-workspace');if(qAIScaleRatio>0&&img&&img.naturalWidth&&img.getBoundingClientRect().width){var r=img.getBoundingClientRect(),wr=workspace?workspace.getBoundingClientRect():null,zx=workspace&&workspace.offsetWidth?wr.width/workspace.offsetWidth:1,zy=workspace&&workspace.offsetHeight?wr.height/workspace.offsetHeight:zx;if(!(zx>0))zx=1;if(!(zy>0))zy=zx;w=(90*zx*(item.widthScale||1)/r.width)*img.naturalWidth*qAIScaleRatio;h=(78*zy*(item.heightScale||1)/r.height)*img.naturalHeight*qAIScaleRatio;}return [Math.max(.01,w),Math.max(.01,h)];}
  7179. function qFurnitureDimensionText(item){var s=qFurnitureMeters(item);return s[0].toFixed(2)+'m × '+s[1].toFixed(2)+'m';}
  7180. function qFurnitureEditDimensions(item,e){
  7181. if(e){e.preventDefault();e.stopPropagation();}
  7182. var current=qFurnitureMeters(item);
  7183. qPromptDialog('请输入柜体宽度和高度(米),例如:1.80 × 2.40',current[0].toFixed(2)+' × '+current[1].toFixed(2),function(input){
  7184. var values=String(input||'').replace(/,/g,'.').match(/\d+(?:\.\d+)?/g)||[];
  7185. var width=parseFloat(values[0]),height=parseFloat(values[1]);
  7186. if(!(width>0)||!(height>0)){showToast('请输入正确的宽度和高度,例如:1.80 × 2.40','warning');return;}
  7187. item.widthScale=Math.max(.1,Math.min(10,(item.widthScale||1)*(width/current[0])));
  7188. item.heightScale=Math.max(.1,Math.min(10,(item.heightScale||1)*(height/current[1])));
  7189. qFurnitureCompositeDataUrl='';
  7190. qFurnitureV3Render();
  7191. showToast('已修改 '+(item.type||'柜体')+' 尺寸:'+width.toFixed(2)+'m × '+height.toFixed(2)+'m','success');
  7192. });
  7193. }
  7194. function qPrepareRoomsFromPlacedCabinets(){
  7195. // 第二套报价模式沿用源文件设置:柜高缺省兜底为层高,数据来源标签按源文件;模式一保持原行为
  7196. var m2=state.quoteMode==='manual';var defH=m2?(qFloorHeight||2.5):0;
  7197. qRooms=qAIPlacedCabinets.map(function(c){
  7198. var roomName=(typeof Q_CABINET_TO_SPACE_MAP!=='undefined'&&Q_CABINET_TO_SPACE_MAP[c.type])||c.type||'柜体';
  7199. return {roomName:roomName,cabinetType:c.type,layoutStyle:'一字型',projectArea:(c.width||0)*(c.height||0),doorType:'普通平板',length:c.width||0,width:c.width||0,height:c.height||defH,area:(c.width||0)*(c.height||0),cabinetWidth:c.width||0,cabinetHeight:c.height||defH,cabinetRatio:.5,fillRate:.5,doorRatio:.5,bayWindow:false,posX:c.posX,posY:c.posY,posLabel:c.posLabel||'',_dataSource:m2?{cabinetWidth:'家具贴纸尺寸',cabinetHeight:'尺寸计算/可编辑'}:{cabinetWidth:c.source||'家具贴纸尺寸',cabinetHeight:c.source||'规则色块识别/贴纸尺寸'}};
  7200. });
  7201. qFixRoomSpaceNames(qRooms);qExtras=qGenerateExtrasFromRooms(qRooms);qRoomItems=[];for(var i=0;i<qRooms.length;i++)qRoomItems[i]=qBuildRoomItems(i);
  7202. }
  7203. async function qProceedFurnitureToDimensions(){
  7204. if(!qFurnitureLayout.length){showToast('请先在原户型图上放置至少一个家具柜体','warning');return;}
  7205. if(!qDimensionAnnotations.length)await qAutoDetectOriginalDimensions(false);
  7206. var scaleResult=qCalculateImageScale();
  7207. qScaleCalibrationMode=scaleResult.ok?'image-dimension':'original-annotation-estimate';
  7208. if(!scaleResult.ok)showToast('原图已有尺寸标注:直接进入柜体计算,结果可在下一步复核编辑','info');
  7209. await qBuildFurnitureCompositeImage();
  7210. // 顶部预览块仅在生成家具布局合成图后显示(原图统一由第一步工作区展示)
  7211. var previewImg=document.getElementById('q-ai-drawing-img'),previewTitle=document.getElementById('q-ai-drawing-preview-title');
  7212. if(qFurnitureCompositeDataUrl){
  7213. var previewBlock=document.getElementById('q-ai-image-preview'),previewThumb=document.getElementById('q-ai-drawing-thumb');
  7214. if(previewBlock)previewBlock.style.display='block';
  7215. if(previewThumb)previewThumb.style.display='block';
  7216. if(previewImg){previewImg.style.display='block';previewImg.src=qFurnitureCompositeDataUrl;previewImg.onclick=function(){qOpenZoomableLightbox(qFurnitureCompositeDataUrl,'家具布局户型图');};}
  7217. }
  7218. if(previewTitle&&qFurnitureCompositeDataUrl)previewTitle.textContent='🛋️ 家具布局户型图(贴纸结果)';
  7219. qAIPlacedCabinets=qFurnitureLayout.map(function(item,index){var size=qFurnitureMeters(item);return {id:Date.now()+index,type:item.type,x:item.x,y:item.y,width:Number(size[0].toFixed(2)),height:Number(size[1].toFixed(2)),source:'家具贴纸',furnitureId:item.id,widthScale:item.widthScale||1,heightScale:item.heightScale||1,image:item.image||''};});
  7220. qPrepareRoomsFromPlacedCabinets();
  7221. if(typeof window.qAIGoToStep3==='function'){window.qAIGoToStep3();qRenderAICards({rooms:qRooms},false);showToast('已生成柜体尺寸与产品覆盖表,请核对后确认计价','success');}
  7222. else if(state.quoteMode==='manual'){
  7223. // 手动模式始终显示「柜体尺寸计算 / 柜体清单汇总 / 柜体产品覆盖信息」三部分页面(同源文件),确认后才进入自动计价
  7224. var m2s1=document.getElementById('q-ai-step1-area'),m2s2=document.getElementById('q-ai-step2-area'),m2s3=document.getElementById('q-ai-step3-area');
  7225. if(m2s1)m2s1.style.display='none';if(m2s2)m2s2.style.display='none';if(m2s3)m2s3.style.display='block';
  7226. if(typeof window.qAIUpdateProgress==='function')window.qAIUpdateProgress(2);
  7227. var m2ScaleInfo=document.getElementById('q-ai-scale-info'),m2ScaleText=document.getElementById('q-ai-scale-text'),m2DrawingSize=document.getElementById('q-ai-drawing-size'),m2ScaleSource=document.getElementById('q-ai-scale-source');
  7228. if(m2ScaleInfo)m2ScaleInfo.style.display='block';
  7229. var m2Scale=qCalculateImageScale();
  7230. if(m2ScaleText)m2ScaleText.textContent=qAIScaleRatio>0?('1像素 = '+qAIScaleRatio.toFixed(6)+' 米(1米 ≈ '+Math.round(1/qAIScaleRatio)+'像素)'):'原图尺寸标注识别结果待复核';
  7231. if(m2ScaleSource)m2ScaleSource.textContent=m2Scale.ok?('✓ '+m2Scale.source+' · 使用 '+m2Scale.count+' 条原图尺寸线'):('已直接采用家具贴纸比例估算,可编辑柜体尺寸');
  7232. if(m2DrawingSize)m2DrawingSize.textContent=qAIDrawingWidth+'×'+qAIDrawingHeight+'px';
  7233. if(typeof window.qAIRenderDimensionTable==='function')window.qAIRenderDimensionTable();
  7234. if(typeof qRenderAICards==='function')qRenderAICards({rooms:qRooms},false);
  7235. goToStep(2);
  7236. switchView('analysis');
  7237. showToast('已生成柜体尺寸与产品覆盖表,请核对后确认计价','success');
  7238. }
  7239. else{
  7240. // 模式一兜底:直接同步数据并跳转自动计价,避免按键无反应
  7241. if(typeof qSyncStep2DetailsToPricing==='function')qSyncStep2DetailsToPricing();
  7242. if(typeof qRenderAICards==='function')qRenderAICards({rooms:qRooms},false);
  7243. goToStep(3);
  7244. switchView('pricing');
  7245. showToast('已生成柜体尺寸,进入自动计价核对','success');
  7246. }
  7247. }
  7248. function qFurnitureV3Render(){
  7249. var workspace=document.getElementById('q-floorplan-layout-workspace'),drawing=document.getElementById('q-ai-cleaned-img');if(!workspace||!drawing)return;
  7250. // 工作区隐藏(尺寸为0)时直接返回,不清除已有贴纸节点,避免返回家具布局时贴纸丢失
  7251. var wr=workspace.getBoundingClientRect(),ir=drawing.getBoundingClientRect();if(!ir.width||!ir.height)return;
  7252. workspace.querySelectorAll('.q-furniture-v3').forEach(function(n){n.remove();});
  7253. // getBoundingClientRect 使用屏幕缩放后的像素,而 absolute left/top 使用缩放前的 CSS 像素。
  7254. // 按工作区实际缩放比例换算,避免 body zoom 或浏览器页面放大后贴纸位置偏移。
  7255. var zoomX=workspace.offsetWidth?wr.width/workspace.offsetWidth:1,zoomY=workspace.offsetHeight?wr.height/workspace.offsetHeight:1;
  7256. if(!(zoomX>0))zoomX=1;if(!(zoomY>0))zoomY=zoomX;
  7257. qFurnitureLayout.forEach(function(item){
  7258. var el=document.createElement('div');el.className='q-placed-furniture q-furniture-v3';
  7259. el.style.left=((ir.left-wr.left+(item.x/100)*ir.width)/zoomX)+'px';el.style.top=((ir.top-wr.top+(item.y/100)*ir.height)/zoomY)+'px';el.style.width=(90*(item.widthScale||1))+'px';el.style.height=(78*(item.heightScale||1))+'px';
  7260. el.style.zIndex='999';el.style.pointerEvents='auto';el.style.touchAction='none';el.style.position='absolute';
  7261. el.innerHTML=(item.image?'<img class="furniture-photo" alt="">':'<div class="icon"></div>')+'<div class="name"></div><div class="q-furniture-dimension"></div><i class="q-furniture-handle handle-w"></i><i class="q-furniture-handle handle-h"></i><i class="q-furniture-handle handle-both"></i>';
  7262. if(item.image){el.querySelector('.furniture-photo').src=item.image;el.querySelector('.furniture-photo').alt=item.type||'家具';el.querySelector('.furniture-photo').style.height='calc(100% - 17px)';}else{el.querySelector('.icon').textContent=item.icon||'▣';}
  7263. el.querySelector('.name').textContent=item.type||'家具';
  7264. var dimensionEl=el.querySelector('.q-furniture-dimension');
  7265. dimensionEl.textContent=qFurnitureDimensionText(item);
  7266. dimensionEl.title='点击直接编辑宽度和高度';
  7267. dimensionEl.onpointerdown=function(e){e.stopPropagation();};
  7268. dimensionEl.onclick=function(e){qFurnitureEditDimensions(item,e);};
  7269. function startResize(e,mode){e.preventDefault();e.stopPropagation();var sx=e.clientX,sy=e.clientY,sw=item.widthScale||1,sh=item.heightScale||1;el.setPointerCapture(e.pointerId);el.onpointermove=function(ev){qFurnitureCompositeDataUrl='';if(mode!=='h')item.widthScale=Math.max(.4,Math.min(4,sw+(ev.clientX-sx)/(90*zoomX)));if(mode!=='w')item.heightScale=Math.max(.4,Math.min(4,sh+(ev.clientY-sy)/(78*zoomY)));el.style.width=(90*item.widthScale)+'px';el.style.height=(78*item.heightScale)+'px';el.querySelector('.q-furniture-dimension').textContent=qFurnitureDimensionText(item);};el.onpointerup=function(){el.onpointermove=null;el.onpointerup=null;qFurnitureV3Render();};}
  7270. el.querySelector('.handle-w').onpointerdown=function(e){startResize(e,'w');};el.querySelector('.handle-h').onpointerdown=function(e){startResize(e,'h');};el.querySelector('.handle-both').onpointerdown=function(e){startResize(e,'both');};
  7271. el.ondblclick=function(e){e.preventDefault();qFurnitureV3Remove(item.id);};
  7272. el.onpointerdown=function(e){if(e.target.classList.contains('q-furniture-handle'))return;e.preventDefault();el.setPointerCapture(e.pointerId);var startClientX=e.clientX,startClientY=e.clientY,hasMoved=false;var wr2=workspace.getBoundingClientRect(),ir2=drawing.getBoundingClientRect(),zx=workspace.offsetWidth?wr2.width/workspace.offsetWidth:1,zy=workspace.offsetHeight?wr2.height/workspace.offsetHeight:1;if(!(zx>0))zx=1;if(!(zy>0))zy=zx;el.onpointermove=function(ev){if(Math.hypot(ev.clientX-startClientX,ev.clientY-startClientY)>5)hasMoved=true;qFurnitureCompositeDataUrl='';var x=Math.max(1,Math.min(99,((ev.clientX-ir2.left)/ir2.width)*100)),y=Math.max(1,Math.min(99,((ev.clientY-ir2.top)/ir2.height)*100));el.style.left=((ir2.left-wr2.left+x/100*ir2.width)/zx)+'px';el.style.top=((ir2.top-wr2.top+y/100*ir2.height)/zy)+'px';item.x=x;item.y=y;};el.onpointerup=function(ev){el.onpointermove=null;el.onpointerup=null;var now=Date.now();if(!hasMoved&&item._lastPointerTap&&now-item._lastPointerTap<420){item._lastPointerTap=0;qFurnitureV3Remove(item.id);showToast('已删除 '+(item.type||'家具')+'贴纸','success');return;}item._lastPointerTap=hasMoved?0:now;qFurnitureV3Render();};};
  7273. workspace.appendChild(el);
  7274. });
  7275. }
  7276. function qFurnitureV3Add(type,icon,x,y,image){qFurnitureCompositeDataUrl='';qFurnitureLayout.push({id:'fur_'+Date.now()+'_'+Math.random().toString(36).slice(2,6),type:type||'家具',icon:icon||'▣',image:image||'',x:x==null?50:x,y:y==null?50:y,widthScale:1,heightScale:1});qFurnitureV3Render();showToast('家具贴纸已放置,可拖动和调整长宽','success');}
  7277. function qFurnitureV3Remove(id){qFurnitureCompositeDataUrl='';qFurnitureLayout=qFurnitureLayout.filter(function(f){return f.id!==id;});qFurnitureV3Render();}
  7278. function qFurnitureV3Resize(id,axis,delta){var f=qFurnitureLayout.find(function(v){return v.id===id;});if(!f)return;var k=axis==='height'?'heightScale':'widthScale';f[k]=Math.max(.5,Math.min(3,(f[k]||1)+delta));qFurnitureV3Render();}
  7279. function qFurnitureV3Init(){
  7280. var workspace=document.getElementById('q-floorplan-layout-workspace'),drawing=document.getElementById('q-ai-cleaned-img');if(!workspace||!drawing)return;
  7281. var active=null,ghost=null;
  7282. document.querySelectorAll('.q-furniture-tool').forEach(function(tool){
  7283. tool.draggable=false;var name=tool.querySelector('span'),type=tool.dataset.furnitureType||(name?name.textContent.trim():'家具'),image=tool.dataset.furnitureImage||'',icon='▣';
  7284. for(var i=0;i<tool.childNodes.length;i++){if(tool.childNodes[i].nodeType===3&&tool.childNodes[i].textContent.trim()){icon=tool.childNodes[i].textContent.trim();break;}}
  7285. tool.onclick=function(){if(Date.now()<(window.__qFurnitureSuppressClickUntil||0))return;qFurnitureV3Add(type,icon,50,50,image);};
  7286. tool.ondragstart=function(e){
  7287. window.__qFurnitureNativePayload={type:type,icon:icon,image:image};
  7288. if(e.dataTransfer){e.dataTransfer.effectAllowed='copy';e.dataTransfer.setData('text/plain',type);}
  7289. tool.style.opacity='.55';
  7290. };
  7291. tool.ondragend=function(){tool.style.opacity='1';drawing.style.outline='';};
  7292. tool.onpointerdown=function(e){
  7293. e.preventDefault();active={type:type,icon:icon,image:image,pointerId:e.pointerId};
  7294. ghost=document.createElement('div');ghost.style.cssText='position:fixed;z-index:99999;width:100px;height:90px;padding:5px;background:#fff;border:2px solid #3182CE;border-radius:8px;pointer-events:none;box-shadow:0 4px 14px rgba(0,0,0,.25);';if(image){var gi=document.createElement('img');gi.src=image;gi.style.cssText='width:100%;height:100%;object-fit:contain';ghost.appendChild(gi);}else{ghost.textContent=icon;ghost.style.fontSize='34px';}document.body.appendChild(ghost);
  7295. ghost.style.left=(e.clientX+10)+'px';ghost.style.top=(e.clientY+10)+'px';
  7296. };
  7297. });
  7298. workspace.ondragenter=function(e){e.preventDefault();drawing.style.outline='3px solid #38A169';};
  7299. workspace.ondragover=function(e){e.preventDefault();if(e.dataTransfer)e.dataTransfer.dropEffect='copy';drawing.style.outline='3px solid #38A169';return false;};
  7300. workspace.ondragleave=function(e){if(!workspace.contains(e.relatedTarget))drawing.style.outline='';};
  7301. workspace.ondrop=function(e){
  7302. e.preventDefault();e.stopPropagation();drawing.style.outline='';
  7303. var p=window.__qFurnitureNativePayload;if(!p){showToast('未读取到家具图片,请重新拖动','warning');return false;}
  7304. var r=drawing.getBoundingClientRect(),inside=e.clientX>=r.left&&e.clientX<=r.right&&e.clientY>=r.top&&e.clientY<=r.bottom;
  7305. if(!inside){showToast('请将家具放在原户型图范围内','warning');return false;}
  7306. var x=Math.max(1,Math.min(99,((e.clientX-r.left)/r.width)*100)),y=Math.max(1,Math.min(99,((e.clientY-r.top)/r.height)*100));
  7307. qFurnitureV3Add(p.type,p.icon,x,y,p.image);window.__qFurnitureNativePayload=null;return false;
  7308. };
  7309. document.onpointermove=function(e){if(!active||!ghost)return;ghost.style.left=(e.clientX+10)+'px';ghost.style.top=(e.clientY+10)+'px';var r=drawing.getBoundingClientRect();drawing.style.outline=(e.clientX>=r.left&&e.clientX<=r.right&&e.clientY>=r.top&&e.clientY<=r.bottom)?'3px solid #38A169':'none';};
  7310. document.onpointerup=function(e){
  7311. if(!active)return;var r=drawing.getBoundingClientRect(),inside=e.clientX>=r.left&&e.clientX<=r.right&&e.clientY>=r.top&&e.clientY<=r.bottom;
  7312. if(ghost)ghost.remove();ghost=null;drawing.style.outline='';
  7313. if(inside){var x=Math.max(1,Math.min(99,((e.clientX-r.left)/r.width)*100)),y=Math.max(1,Math.min(99,((e.clientY-r.top)/r.height)*100));qFurnitureV3Add(active.type,active.icon,x,y,active.image);window.__qFurnitureSuppressClickUntil=Date.now()+350;}active=null;
  7314. };
  7315. var overlay=document.getElementById('q-furniture-overlay');if(overlay)overlay.style.display='none';
  7316. qFurnitureV3Render();qDimensionInit();
  7317. }
  7318. // ── 后台自动流水线:自动完成解析与计价,直接展示报价单 ──
  7319. function qAutoPlaceDefaultFurniture(){
  7320. if(qFurnitureLayout.length) return;
  7321. var defaults=[
  7322. ['厨房','🍳','assets/furniture/kitchen-cabinet.png',70,20],
  7323. ['玄关柜','🚪','assets/furniture/entry-cabinet.png',20,15],
  7324. ['客厅电视柜','📺','assets/furniture/living-tv-cabinet.png',45,55],
  7325. ['主卧衣柜','🛏️','assets/furniture/master-wardrobe.png',80,70],
  7326. ['次卧衣柜','👕','assets/furniture/secondary-wardrobe.png',25,75],
  7327. ['阳台柜','🌤️','assets/furniture/balcony-cabinet.png',55,88]
  7328. ];
  7329. defaults.forEach(function(d){ qFurnitureLayout.push({id:'fur_auto_'+d[0],type:d[0],icon:d[1],image:d[2],x:d[3],y:d[4],widthScale:1,heightScale:1}); });
  7330. }
  7331. async function qAutoRunBackground(){
  7332. var btn=document.getElementById('q-btn-analyze');
  7333. if(btn){btn.disabled=true;btn.textContent='🤖 AI 后台自动解析与计价中...';}
  7334. showToast('🤖 AI 正在后台自动运行解析与计价,完成后直接展示报价单','info');
  7335. try{
  7336. qAutoPlaceDefaultFurniture();
  7337. await qProceedFurnitureToDimensions();
  7338. await qRunRecognitionAndApply(); // 识别配置+规则模块一第1/4条:按规则色块自动抓取宽/高,覆盖默认尺寸(不再用层高兜底)
  7339. qFinishAnalysisNew();
  7340. goToStep(4);
  7341. showToast('✅ 解析与计价已完成,已生成报价单','success');
  7342. }catch(e){
  7343. console.warn('Auto pipeline failed:', e);
  7344. showToast('❌ 自动解析失败:'+(e && e.message || e),'error');
  7345. }finally{
  7346. if(btn){btn.disabled=false;btn.textContent='下一步:报价单 →';}
  7347. }
  7348. }
  7349. // ── AI Analysis 主函数:三步柜体识别流程 ─
  7350. function qStartAnalysis(){
  7351. // 层高仅作为参考信息,不强制要求有效值
  7352. var h = qFloorHeight;
  7353. var inputEl = document.getElementById('q-floor-height');
  7354. if (inputEl && inputEl.value) { qFormatFloorHeight(); h = qFloorHeight; }
  7355. // 层高仅作参考:未填或越界时按默认2.5m参与展示,不阻断解析流程
  7356. if (!(h >= 2.0 && h <= 4.0)) h = 2.5;
  7357. qFloorHeight = h;
  7358. qAiProcessed = false;
  7359. qAiAnalyzing = true;
  7360. qAiEditMode = false;
  7361. qAiEditableData = null;
  7362. // 步骤2/3不再上屏显示,AI后台自动运行,最终直接展示报价单
  7363. // 初始化三步流程
  7364. qAICurrentStep = 0;
  7365. // 不做模型清理,家具布局和后续定位均直接使用上传的原户型图。
  7366. var originalDrawingSrc = qResolveOriginalDrawingSource();
  7367. qAICleanedImageData = originalDrawingSrc || null;
  7368. qAIPlacedCabinets = [];
  7369. qFurnitureLayout = [];
  7370. qFurnitureCompositeDataUrl = '';
  7371. qDimensionAnnotations = [];
  7372. qAutoDimensionDetectionDone = false;
  7373. qAutoDimensionDetectionPromise = null;
  7374. qScaleCalibrationMode = '';
  7375. qRenderFurnitureLayout();
  7376. qAIScaleRatio = 0;
  7377. var step1Area = document.getElementById('q-ai-step1-area');
  7378. var step2Area = document.getElementById('q-ai-step2-area');
  7379. var step3Area = document.getElementById('q-ai-step3-area');
  7380. var finalBtnRow = document.getElementById('q-ai-final-btn-row');
  7381. var binaryGrid = document.getElementById('q-step2-binary-grid');
  7382. var progressBar = document.getElementById('q-ai-progress-bar');
  7383. if (step1Area) step1Area.style.display = 'block';
  7384. if (step2Area) step2Area.style.display = 'none';
  7385. if (step3Area) step3Area.style.display = 'none';
  7386. if (finalBtnRow) finalBtnRow.style.display = 'none';
  7387. if (binaryGrid) binaryGrid.classList.add('hidden');
  7388. if (progressBar) progressBar.style.display = 'flex';
  7389. for (var pi = 0; pi < 3; pi++) {
  7390. var ps = document.getElementById('q-ai-prog-'+pi);
  7391. if (ps) ps.className = 'ai-progress-step' + (pi === 0 ? ' active' : '');
  7392. }
  7393. var rawImg = document.getElementById('q-ai-raw-img');
  7394. var rawPlaceholder = document.getElementById('q-ai-raw-placeholder');
  7395. if (qUploadedDrawingUrl) {
  7396. if (rawImg) { rawImg.src = qUploadedDrawingUrl; rawImg.style.display = 'block'; }
  7397. if (rawPlaceholder) rawPlaceholder.style.display = 'none';
  7398. } else {
  7399. if (rawImg) rawImg.style.display = 'none';
  7400. if (rawPlaceholder) rawPlaceholder.style.display = 'block';
  7401. }
  7402. var cleanedImg = document.getElementById('q-ai-cleaned-img');
  7403. var cleanedPlaceholder = document.getElementById('q-ai-cleaned-placeholder');
  7404. if (originalDrawingSrc) {
  7405. if (cleanedImg) { cleanedImg.src=originalDrawingSrc; cleanedImg.style.display='block'; }
  7406. if (cleanedPlaceholder) cleanedPlaceholder.style.display='none';
  7407. } else {
  7408. if (cleanedImg) cleanedImg.style.display='none';
  7409. if (cleanedPlaceholder) { cleanedPlaceholder.style.display='block'; cleanedPlaceholder.textContent='请先上传户型图'; }
  7410. }
  7411. var step1Status = document.getElementById('q-ai-step1-status');
  7412. var step1Log = document.getElementById('q-ai-step1-log');
  7413. if (step1Status) step1Status.style.display = 'none';
  7414. if (step1Log) { step1Log.style.display = 'none'; step1Log.innerHTML = ''; }
  7415. var workspaceImg = document.getElementById('q-ai-workspace-img');
  7416. if (workspaceImg && originalDrawingSrc) {
  7417. workspaceImg.src = originalDrawingSrc;
  7418. workspaceImg.style.opacity = '1';
  7419. if (workspaceImg.parentElement && workspaceImg.parentElement.previousElementSibling) {
  7420. workspaceImg.parentElement.previousElementSibling.textContent = '🏠 原户型图工作区(拖放柜体贴纸到此处)';
  7421. }
  7422. }
  7423. qAIUpdatePlacedList();
  7424. // 原图加载完成后即可进行家具贴纸布局,不触发图像清理、OCR 或模型分析。
  7425. // 内联拖放事件在全局作用域执行,将家具布局操作显式暴露给页面。
  7426. window.qFurnitureDragStart=qFurnitureV2DragStart;
  7427. window.qFurnitureDrop=qFurnitureV2Drop;
  7428. window.qAddFurnitureCenter=qFurnitureV2AddCenter;
  7429. window.qRemoveFurniture=qFurnitureV2Remove;
  7430. window.qResizeFurniture=qFurnitureV2Resize;
  7431. window.qAlignFurnitureOverlayToImage=qFurnitureV2Align;
  7432. window.qAIGoToStep1=qAIGoToStep1;
  7433. window.qAIGoToStep2=qAIGoToStep2;
  7434. window.qAIGoToStep3=qAIGoToStep3;
  7435. window.qAIRenderDimensionTable=qAIRenderDimensionTable;
  7436. window.qAIUpdateProgress=qAIUpdateProgress;
  7437. window.qAIUpdateCabinetSize=qAIUpdateCabinetSize;
  7438. window.qFinishAnalysisNew=qFinishAnalysisNew;
  7439. qFurnitureV3Init();
  7440. qAIInitDragDrop();
  7441. qAiAnalyzing = false;
  7442. if (state.quoteMode === 'manual') {
  7443. // 精准模式(第二套报价模式):沿用源文件入口设置——先进入步骤2「AI解析」,再进入AI解析模块窗口
  7444. goToStep(2);
  7445. showToast('原户型图已载入,请添加家具贴纸并调整布局','success');
  7446. switchView('analysis');
  7447. return;
  7448. }
  7449. qAutoRunBackground();
  7450. return;
  7451. var dimTbody = document.getElementById('q-ai-dimension-tbody');
  7452. if (dimTbody) dimTbody.innerHTML = '';
  7453. var scaleInfo = document.getElementById('q-ai-scale-info');
  7454. if (scaleInfo) scaleInfo.style.display = 'none';
  7455. var manualArea = document.getElementById('q-ai-manual-size-area');
  7456. if (manualArea) manualArea.style.display = 'none';
  7457. var goStep3Btn = document.getElementById('q-btn-go-step3');
  7458. if (goStep3Btn) goStep3Btn.disabled = true;
  7459. var loadingArea = document.getElementById('q-ai-loading-area');
  7460. var spinner = document.getElementById('q-ai-spinner');
  7461. var statusText = document.getElementById('q-ai-status-text');
  7462. var statusDetail = document.getElementById('q-ai-status-detail');
  7463. var log = document.getElementById('q-ai-log');
  7464. var actionArea = document.getElementById('q-ai-action-area');
  7465. var previewDiv = document.getElementById('q-ai-image-preview');
  7466. var drawingThumb = document.getElementById('q-ai-drawing-thumb');
  7467. var drawingImg = document.getElementById('q-ai-drawing-img');
  7468. var fileInfo = document.getElementById('q-ai-file-info');
  7469. var disclaimer = document.getElementById('q-ai-disclaimer');
  7470. var analysisGrid = document.getElementById('q-ai-analysis-grid');
  7471. var summaryBanner = document.getElementById('q-ai-summary-banner');
  7472. var progressBar = document.getElementById('q-ai-progress-bar');
  7473. // 重置UI(图片预览保持显示,不隐藏)
  7474. if (loadingArea) loadingArea.style.display = 'flex';
  7475. if (spinner) spinner.style.display = 'block';
  7476. if (statusText) { statusText.textContent = 'AI 正在分析中...'; statusText.style.color = ''; }
  7477. if (statusDetail) statusDetail.textContent = '正在加载图纸数据...';
  7478. if (log) log.innerHTML = '';
  7479. if (actionArea) actionArea.innerHTML = '';
  7480. // 图片预览区域保持显示,不隐藏
  7481. if (disclaimer) disclaimer.style.display = 'none';
  7482. if (analysisGrid) analysisGrid.innerHTML = '';
  7483. if (summaryBanner) summaryBanner.classList.add('hidden');
  7484. if (progressBar) progressBar.style.display = 'flex';
  7485. // Reset all progress steps (5 steps)
  7486. for (var pi = 0; pi < 5; pi++) {
  7487. var ps = document.getElementById('q-ai-prog-'+pi);
  7488. if (ps) ps.className = 'ai-progress-step';
  7489. }
  7490. // ─── 五步分析步骤定义 ───
  7491. var analysisSteps = [
  7492. {name:'图纸预处理', icon:'🖼️', detail:'正在加载图纸,进行图像增强与去噪处理...'},
  7493. {name:'标注识别', icon:'🔤', detail:'正在OCR识别图纸中的文字标注(房间名、尺寸数字、面积标注)...'},
  7494. {name:'尺寸提取', icon:'📐', detail:'正在提取图形轮廓与尺寸,融合校验数据...'},
  7495. {name:'柜体识别', icon:'🗄️', detail:'正在识别柜体类型与布局...'},
  7496. {name:'识别完成', icon:'✅', detail:'柜体尺寸识别完成!'}
  7497. ];
  7498. // ─── 核心分析逻辑(支持异步千问API调用) ───
  7499. function runAnalysis(imgWidth, imgHeight, imgSrc, fileName, fileSize){
  7500. var isMock = fileName && fileName.indexOf('120㎡三房两厅') !== -1;
  7501. var hasCAD = !!(qUploadedCAD);
  7502. var hasDrawing = !!(qUploadedDrawing && qUploadedDrawingUrl);
  7503. var isJointAnalysis = hasDrawing && hasCAD;
  7504. // 显示上传图片
  7505. if (previewDiv) previewDiv.style.display = 'block';
  7506. if (imgSrc && drawingThumb && drawingImg) {
  7507. drawingThumb.style.display = 'block';
  7508. drawingImg.src = imgSrc;
  7509. drawingImg.onclick = function(){ qOpenZoomableLightbox(imgSrc, fileName||'户型图'); };
  7510. } else if (!imgSrc && hasCAD) {
  7511. // 仅有CAD时也显示区域,标注CAD信息
  7512. drawingThumb.style.display = 'block';
  7513. if (drawingImg) drawingImg.style.display = 'none';
  7514. }
  7515. var infoParts = [];
  7516. if (fileName) infoParts.push('📁 文件:'+fileName);
  7517. if (fileSize) infoParts.push('📦 大小:'+(fileSize/1024).toFixed(0)+'KB');
  7518. infoParts.push('📐 分辨率:'+imgWidth+'×'+imgHeight+'px');
  7519. if (isJointAnalysis) infoParts.push('🔗 模式:户型图+CAD联合分析');
  7520. else if (hasCAD && !hasDrawing) infoParts.push('📏 模式:CAD精确分析');
  7521. else if (hasDrawing) infoParts.push('📐 模式:户型图视觉分析');
  7522. if (fileInfo) fileInfo.innerHTML = infoParts.join(' | ');
  7523. // ── 非示例图纸且有图片 → 使用本地图像结构分析 ──
  7524. if (!isMock && imgSrc) {
  7525. if (statusText) statusText.textContent = '🤖 正在进行本地图纸结构分析...';
  7526. if (statusDetail) statusDetail.textContent = 'AI正在分析图纸中的柜体信息,请稍候...';
  7527. addLogLine('🤖 正在进行本地图纸结构分析...');
  7528. // 使用base64 data URL进入兼容分析流程
  7529. var apiImgSrc = qUploadedDrawingDataUrl || imgSrc;
  7530. if (qUploadedDrawingDataUrl) {
  7531. addLogLine(' 使用base64数据(长度:' + qUploadedDrawingDataUrl.length + '字符)');
  7532. } else {
  7533. addLogLine('⚠️ base64未就绪,尝试即时转换...');
  7534. }
  7535. qCallQwenVLAPI(apiImgSrc, qFloorHeight).then(function(apiResult){
  7536. addLogLine('✅ 图纸结构识别完成,共识别到 ' + (apiResult.rooms||[]).length + ' 个柜体');
  7537. var analysisData = qConvertQwenResult(apiResult, qFloorHeight);
  7538. analysisData._qwenParsed = true;
  7539. _runAnalysisWith(analysisData, imgWidth, imgHeight, imgSrc, fileName, fileSize, false, hasCAD, hasDrawing, isJointAnalysis);
  7540. }).catch(function(err){
  7541. addLogLine('ℹ️ 使用本地图像分析:' + err.message);
  7542. var analysisData = qGenerateRoomsFromImage(imgWidth, imgHeight, fileName);
  7543. _runAnalysisWith(analysisData, imgWidth, imgHeight, imgSrc, fileName, fileSize, false, hasCAD, hasDrawing, isJointAnalysis);
  7544. });
  7545. return; // 异步流程,由回调继续
  7546. }
  7547. // ── 示例图纸或无图片 → 走原有本地分析 ──
  7548. var analysisData;
  7549. if (isMock) {
  7550. analysisData = qGenerateRoomsFromImage(imgWidth, imgHeight, fileName);
  7551. qRooms = analysisData.rooms;
  7552. } else {
  7553. analysisData = qGenerateRoomsFromImage(imgWidth, imgHeight, fileName);
  7554. qRooms = analysisData.rooms;
  7555. }
  7556. _runAnalysisWith(analysisData, imgWidth, imgHeight, imgSrc, fileName, fileSize, isMock, hasCAD, hasDrawing, isJointAnalysis);
  7557. }
  7558. // ─── 获取到分析数据后的统一处理流程 ───
  7559. function _runAnalysisWith(analysisData, imgWidth, imgHeight, imgSrc, fileName, fileSize, isMock, hasCAD, hasDrawing, isJointAnalysis){
  7560. qRooms = analysisData.rooms;
  7561. // 如果是联合分析模式,标记数据来源
  7562. if (isJointAnalysis) {
  7563. analysisData.jointAnalysis = true;
  7564. analysisData.sourceDrawing = qUploadedDrawing ? qUploadedDrawing.name : null;
  7565. analysisData.sourceCAD = qUploadedCAD ? qUploadedCAD.name : null;
  7566. } else if (hasCAD && !hasDrawing) {
  7567. analysisData.cadOnly = true;
  7568. analysisData.sourceCAD = qUploadedCAD ? qUploadedCAD.name : null;
  7569. }
  7570. qExtras = qGenerateExtrasFromRooms(qRooms);
  7571. qRoomItems = []; for(var _i=0;_i<qRooms.length;_i++){qRoomItems[_i]=qBuildRoomItems(_i);}
  7572. // 层高仅作为参考信息,不再强制将柜体高度设为等于层高
  7573. // 柜体高度由图纸标注或AI识别决定,保持原始数据
  7574. // 效果图识别(如果开启)
  7575. if (qRenderEnabled && qRenderFiles.length > 0) {
  7576. qRenderResults = [];
  7577. for (var ri = 0; ri < Math.min(qRenderFiles.length, qRooms.length); ri++) {
  7578. qRenderResults.push({
  7579. fileName: qRenderFiles[ri].name,
  7580. space: qRooms[ri].roomName,
  7581. cabinetType: qRooms[ri].cabinetType,
  7582. material: qRooms[ri].doorType === '风影门板' ? '双饰面' : (qRooms[ri].doorType === '实木贴皮' ? '影木' : '双饰面'),
  7583. materialColor: qRooms[ri].doorType === '风影门板' ? 'SSM-023 浅橡木' : (qRooms[ri].doorType === '实木贴皮' ? 'YM-001 胡桃木' : '标准暖白'),
  7584. style: qRooms[ri].doorType === '风影门板' ? '现代简约·奶油风' : '现代简约',
  7585. confidence: 85 + Math.floor(Math.random() * 11)
  7586. });
  7587. }
  7588. }
  7589. // ─── 逐步执行五维分析 ───
  7590. var stepIdx = 0;
  7591. var allLogLines = [];
  7592. function runNextStep(){
  7593. if (stepIdx >= analysisSteps.length) {
  7594. // 全部完成
  7595. qFinishAnalysis(analysisData, isMock);
  7596. return;
  7597. }
  7598. var step = analysisSteps[stepIdx];
  7599. qUpdateProgressStep(stepIdx);
  7600. if (statusDetail) statusDetail.textContent = step.detail;
  7601. if (statusText) statusText.textContent = step.icon+' 正在进行:'+step.name+'...';
  7602. // 生成该步骤的日志
  7603. var logLines = qGenerateStepLogs(stepIdx, analysisData, isMock);
  7604. allLogLines = allLogLines.concat(logLines);
  7605. // 追加日志
  7606. logLines.forEach(function(line){
  7607. if (log) {
  7608. var div = document.createElement('div');
  7609. div.className = 'log-line';
  7610. div.textContent = line;
  7611. log.appendChild(div);
  7612. log.scrollTop = log.scrollHeight;
  7613. }
  7614. });
  7615. stepIdx++;
  7616. // 模拟分析耗时(10步,逐步递增,总时长控制在合理范围)
  7617. var delay = 200 + stepIdx * 60 + Math.floor(Math.random() * 100);
  7618. setTimeout(runNextStep, delay);
  7619. }
  7620. setTimeout(runNextStep, 300);
  7621. }
  7622. // 生成每个分析步骤的日志
  7623. function qGenerateStepLogs(stepIdx, d, isMock){
  7624. var ocrStats = window._qLastOCRStats || null;
  7625. var graphicStats = window._qLastGraphicStats || null;
  7626. var fusionStats = window._qLastFusionStats || null;
  7627. var ocrData = window._qLastOCRData || null;
  7628. switch(stepIdx){
  7629. case 0: // 图纸预处理
  7630. var _imgW = (typeof imgWidth !== 'undefined') ? imgWidth : (qUploadedDrawing ? (qUploadedDrawing.width||'?') : '?');
  7631. var _imgH = (typeof imgHeight !== 'undefined') ? imgHeight : (qUploadedDrawing ? (qUploadedDrawing.height||'?') : '?');
  7632. return [
  7633. '🖼️ 加载图纸文件...',
  7634. '🔧 执行图像预处理:灰度化 → 二值化 → 去噪点 → 倾斜校正',
  7635. '📏 检测图纸方向:横版/竖版自适应识别',
  7636. '✅ 图纸预处理完成 — 图像质量已优化'
  7637. ];
  7638. case 1: // 标注识别
  7639. var ocrLines = ['🔤 启动OCR文字识别引擎...【优先使用文字标注】'];
  7640. if (d.cadOnly) {
  7641. ocrLines.push('📏 检测到CAD文件:DWG文字层标注精度可达毫米级');
  7642. }
  7643. if (ocrStats) {
  7644. ocrLines.push(' ├ 识别房间名称标签:'+ocrStats.roomLabelsFound+'个(平均置信度'+(ocrStats.avgConfidence*100).toFixed(0)+'%)');
  7645. ocrLines.push(' ├ 识别尺寸标注:'+ocrStats.dimensionsFound+'个');
  7646. ocrLines.push(' ├ 识别面积标注:'+ocrStats.areasFound+'个');
  7647. } else {
  7648. var roomNames = d.rooms.map(function(r){return r.roomName;}).join('、');
  7649. ocrLines.push(' ├ 识别房间标签:'+roomNames);
  7650. ocrLines.push(' └ 识别尺寸数字标注:'+d.rooms.length+'组');
  7651. }
  7652. ocrLines.push('✅ 标注识别完成 — 高精度数据源已获取');
  7653. return ocrLines;
  7654. case 2: // 尺寸提取(合并图形测量+数据融合)
  7655. var extractLines = ['📐 启动图形轮廓识别与尺寸提取...'];
  7656. extractLines.push(' ├ 识别闭合矩形轮廓,确定各空间边界');
  7657. if (graphicStats) {
  7658. extractLines.push(' ├ 比例尺校准来源:'+graphicStats.scaleCalibration);
  7659. extractLines.push(' ├ 图形测量完成:'+graphicStats.roomsMeasured+'个空间');
  7660. } else {
  7661. extractLines.push(' ├ 通过已知标注校准像素比例尺');
  7662. extractLines.push(' └ 测量各空间图形尺寸');
  7663. }
  7664. extractLines.push('🔗 执行数据融合校验... 宽度采用标注/图形数据,柜体高度统一使用上传层高');
  7665. if (fusionStats) {
  7666. extractLines.push(' └ 数据融合完成,偏差校验通过');
  7667. } else {
  7668. extractLines.push(' └ 数据融合完成');
  7669. }
  7670. extractLines.push('✅ 尺寸提取完成');
  7671. return extractLines;
  7672. case 3: // 柜体识别
  7673. var roomNames = d.rooms.map(function(r){return r.roomName;}).join('、');
  7674. var cabLines = ['🗄️ 识别柜体类型与布局...'];
  7675. if (d.jointAnalysis) cabLines.push('🔗 联合分析模式:户型图视觉识别 + CAD精确尺寸提取');
  7676. else if (d.cadOnly) cabLines.push('📏 CAD精确模式:基于DWG尺寸标注的高精度分析');
  7677. cabLines.push('📋 识别 '+d.rooms.length+' 组柜体:'+roomNames);
  7678. d.rooms.forEach(function(r){
  7679. cabLines.push(' ├ '+r.roomName+':'+r.cabinetType+'('+r.layoutStyle+'布局)');
  7680. });
  7681. cabLines.push('✅ 柜体识别完成');
  7682. return cabLines;
  7683. case 4: // 识别完成
  7684. return [
  7685. '🎉 柜体尺寸识别全部完成!',
  7686. '✅ 共识别 '+d.rooms.length+' 组柜体数据',
  7687. '📝 柜体名称、宽度严格按照图纸手写标注提取;柜体高度=Step1填写层高'
  7688. ];
  7689. default: return [];
  7690. }
  7691. }
  7692. // 分析完成
  7693. function qFinishAnalysis(analysisData, isMock){
  7694. // 使用尺寸页当前可编辑值重新生成柜体清单,保证下方覆盖表与后续计价一致。
  7695. qPrepareRoomsFromPlacedCabinets();
  7696. qAiProcessed = true;
  7697. qAiAnalyzing = false; // 解析完成
  7698. // 缓存可编辑数据
  7699. qAiEditableData = JSON.parse(JSON.stringify(analysisData));
  7700. qAiEditMode = false; // 每次新分析默认关闭编辑模式
  7701. // Mark all progress steps as done
  7702. for (var i = 0; i < 5; i++) {
  7703. qUpdateProgressStep(5); // all done
  7704. }
  7705. if (spinner) spinner.style.display = 'none';
  7706. if (loadingArea) loadingArea.style.display = 'none';
  7707. if (statusText) {
  7708. statusText.textContent = '✅ AI 柜体尺寸识别完成!数据'+(isMock?'与图纸标注严格一致':'已提取,请人工复核');
  7709. statusText.style.color = 'var(--success)';
  7710. }
  7711. if (statusDetail) statusDetail.textContent = '';
  7712. // 追加完成日志
  7713. if (log) {
  7714. var finalDiv = document.createElement('div');
  7715. finalDiv.className = 'log-line';
  7716. finalDiv.innerHTML = '✅ <strong>柜体尺寸识别全部完成</strong> — '+
  7717. (isMock?'数据与图纸标注精确匹配,零偏差':'综合置信度 87%,建议人工复核关键尺寸');
  7718. log.appendChild(finalDiv);
  7719. log.scrollTop = log.scrollHeight;
  7720. }
  7721. // 渲染分析结果卡片
  7722. qRenderAICards(analysisData, isMock);
  7723. // 显示汇总横幅
  7724. qUpdateSummaryBanner(analysisData);
  7725. // 添加图纸标注叠加层
  7726. setTimeout(function(){ qAddDrawingAnnotations(isMock); }, 500);
  7727. // 显示免责声明
  7728. if (disclaimer) disclaimer.style.display = 'block';
  7729. // 操作按钮
  7730. var extraBtns = '';
  7731. if (qRenderResults && qRenderResults.length > 0) {
  7732. extraBtns += '<button class="btn btn-outline btn-lg" onclick="qShowRenderResults()" style="margin-left:8px;">🖼️ 查看结构识别结果</button>';
  7733. }
  7734. if (actionArea) {
  7735. actionArea.innerHTML = extraBtns+'<button class="btn btn-outline btn-lg" onclick="qStartAnalysis()">🔄 重新解析</button>';
  7736. }
  7737. }
  7738. // ══════════════════════════════════════════════════════════════
  7739. // 三步流程辅助函数
  7740. // ══════════════════════════════════════════════════════════════
  7741. function qAICleanDrawing(){
  7742. if (!qUploadedDrawingUrl) { showToast('⚠️ 请先上传户型图', 'error'); return; }
  7743. qConfirmDialog('为生成优化墙体图,当前户型图将上传至火山方舟豆包图像服务处理。图片可能包含户型与尺寸信息。是否同意并继续?', function(){ qAICleanDrawingCore(); }, function(){ showToast('已取消上传,未调用豆包图像模型', 'info'); });
  7744. }
  7745. function qAICleanDrawingCore(){
  7746. var step1Status = document.getElementById('q-ai-step1-status');
  7747. var step1StatusText = document.getElementById('q-ai-step1-status-text');
  7748. var step1Log = document.getElementById('q-ai-step1-log');
  7749. var cleanBtn = document.getElementById('q-btn-clean-drawing');
  7750. if (step1Status) step1Status.style.display = 'block';
  7751. if (step1StatusText) step1StatusText.textContent = '正在本地处理图纸,去除彩色标注并增强墙体...';
  7752. if (cleanBtn) { cleanBtn.disabled = true; cleanBtn.style.opacity = '0.5'; }
  7753. if (step1Log) { step1Log.style.display = 'block'; step1Log.innerHTML = ''; }
  7754. qAILog('🖼️ 开始图纸预处理...');
  7755. qAILog('📐 图纸分辨率:' + qAIDrawingWidth + '×' + qAIDrawingHeight + 'px');
  7756. var cleanPrompt = '请分析这张户型图,执行以下操作:\n1. 识别并描述图中的墙体结构\n2. 识别图中标注的所有真实尺寸(房间长宽、柜体尺寸等)\n3. 计算图像比例:根据图中标注的真实尺寸,推算出1像素等于多少米\n4. 列出图中所有柜体/家具的位置和大致尺寸\n\n请严格按以下JSON格式输出:\n```json\n{\n "scaleRatio": 0.00X,\n "drawingRealWidth": X.X,\n "drawingRealHeight": X.X,\n "walls": ["描述墙体"],\n "cabinets": [{"name": "柜体名称", "room": "房间", "width": X.X, "height": X.X}],\n "dimensions": ["标注的尺寸列表"]\n}\n```\n注意:scaleRatio是1像素等于多少米,必须根据图中标注的真实尺寸计算。';
  7757. // 调用模型前统一规范化:转为标准 data:image/jpeg;base64 数据URI并控制尺寸,
  7758. // 避免 blob 地址/超大原图/非标准格式直传模型导致 400 invalid_parameter_error(URL 无效)
  7759. var dataUrlForAPI = qUploadedDrawingDataUrl || qResolveOriginalDrawingSource();
  7760. if (!dataUrlForAPI) { showToast('请先上传户型图', 'error'); return; }
  7761. qMakeApiImageDataUrl(dataUrlForAPI, 1600).then(function(norm){
  7762. qUploadedDrawingDataUrl = norm;
  7763. qAILog('📦 图像已规范化为标准data URI,长度:' + norm.length + ' 字符');
  7764. qAICallAPIWithImage(norm);
  7765. }).catch(function(e){
  7766. qAILog('❌ 图像规范化失败:' + e.message);
  7767. showToast('图片读取失败,请重新上传', 'error');
  7768. });
  7769. }
  7770. var qArkKeyConfigured=false;
  7771. async function qSaveArkKey(){
  7772. var input=document.getElementById('q-ark-key-input');
  7773. var status=document.getElementById('q-ark-key-status');
  7774. var key=input?input.value.trim():'';
  7775. if(!key || key.indexOf('ark-')!==0){
  7776. if(status){status.textContent='格式不正确';status.style.color='#E53E3E';}
  7777. showToast('请输入有效的方舟模型 Key', 'warning');
  7778. return false;
  7779. }
  7780. if(status){status.textContent='保存中...';status.style.color='#718096';}
  7781. try{
  7782. var response=await fetch('/api/ark/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({api_key:key})});
  7783. var data=await response.json();
  7784. if(!response.ok) throw new Error(data.message||('HTTP '+response.status));
  7785. if(input) input.value='';
  7786. qArkKeyConfigured=true;
  7787. if(status){status.textContent='✅ 已配置';status.style.color='#38A169';}
  7788. var startBtn=document.getElementById('q-btn-clean-drawing');
  7789. if(startBtn){startBtn.disabled=false;startBtn.style.opacity='1';startBtn.style.cursor='pointer';startBtn.title='点击调用豆包优化户型图';}
  7790. showToast('✅ 模型 Key 已保存到本机服务内存', 'success');
  7791. qAutoDimensionDetectionDone=false;
  7792. qAutoDimensionDetectionPromise=null;
  7793. qAutoDetectOriginalDimensions(true);
  7794. return true;
  7795. }catch(err){
  7796. qArkKeyConfigured=false;
  7797. if(status){status.textContent='保存失败';status.style.color='#E53E3E';}
  7798. showToast('模型 Key 保存失败:'+err.message, 'error');
  7799. return false;
  7800. }
  7801. }
  7802. async function qStartOptimization(){
  7803. var input=document.getElementById('q-ark-key-input');
  7804. if(!qArkKeyConfigured){
  7805. if(!input || !input.value.trim()){
  7806. showToast('请先输入模型 Key,再点击启动优化', 'warning');
  7807. if(input) input.focus();
  7808. return;
  7809. }
  7810. var saved=await qSaveArkKey();
  7811. if(!saved) return;
  7812. }
  7813. qAICleanDrawing();
  7814. }
  7815. async function qCallDoubaoImageEdit(dataUrl){
  7816. var controller = new AbortController();
  7817. var timer = setTimeout(function(){controller.abort();},120000);
  7818. try {
  7819. var response = await fetch('/api/ark/image-edit',{
  7820. method:'POST',
  7821. signal:controller.signal,
  7822. headers:{'Content-Type':'application/json'},
  7823. body:JSON.stringify({image:dataUrl})
  7824. });
  7825. var data = await response.json();
  7826. if(!response.ok || data.code) throw new Error((data.code?data.code+': ':'')+(data.message||('HTTP '+response.status)));
  7827. var item=data.data&&data.data[0];
  7828. if(item&&item.b64_json) return 'data:image/jpeg;base64,'+item.b64_json;
  7829. if(item&&item.url) return item.url;
  7830. throw new Error('豆包图像编辑未返回图片数据');
  7831. } catch(err){
  7832. if(err&&err.name==='AbortError') throw new Error('豆包图像编辑超过120秒未返回');
  7833. throw err;
  7834. } finally { clearTimeout(timer); }
  7835. }
  7836. function qAICallAPIWithImage(dataUrl){
  7837. qAILog('🤖 正在调用豆包图像模型 Seedream 4.5...');
  7838. qAILog('🎨 正在删除文字、标注、家具和水印,并保持墙体结构...');
  7839. qCallDoubaoImageEdit(dataUrl).then(function(cleanedDataUrl){
  7840. qAILog('✅ 豆包优化图片已返回!');
  7841. qAICleanedImageData = cleanedDataUrl;
  7842. var cleanedImg = document.getElementById('q-ai-cleaned-img');
  7843. var cleanedPlaceholder = document.getElementById('q-ai-cleaned-placeholder');
  7844. if (cleanedImg) { cleanedImg.src = qAICleanedImageData; cleanedImg.style.display = 'block'; }
  7845. if (cleanedPlaceholder) cleanedPlaceholder.style.display = 'none';
  7846. qAIUpdateProgress(0);
  7847. var cleanBtn = document.getElementById('q-btn-clean-drawing');
  7848. if (cleanBtn) { cleanBtn.disabled = false; cleanBtn.style.opacity = '1'; }
  7849. var step1StatusEl = document.getElementById('q-ai-step1-status');
  7850. if (step1StatusEl) step1StatusEl.style.display = 'none';
  7851. qAILog('✅ 图纸预处理完成,可进入产品定位');
  7852. showToast('✅ 豆包户型图优化完成', 'success');
  7853. }).catch(function(err){
  7854. qAILog('⚠️ 豆包图像编辑失败:' + err.message);
  7855. qAILog('🎨 正在使用本地墙体提取作为备用方案...');
  7856. return qBuildLocalStructureDrawing(dataUrl).then(function(fallbackImage){
  7857. qAICleanedImageData = fallbackImage;
  7858. var cleanedImg = document.getElementById('q-ai-cleaned-img');
  7859. var cleanedPlaceholder = document.getElementById('q-ai-cleaned-placeholder');
  7860. if (cleanedImg) { cleanedImg.src = fallbackImage; cleanedImg.style.display = 'block'; }
  7861. if (cleanedPlaceholder) cleanedPlaceholder.style.display = 'none';
  7862. qAILog('✅ 本地备用结构底图已生成');
  7863. var cleanBtn = document.getElementById('q-btn-clean-drawing');
  7864. if (cleanBtn) { cleanBtn.disabled=false; cleanBtn.style.opacity='1'; }
  7865. var step1StatusEl = document.getElementById('q-ai-step1-status');
  7866. if (step1StatusEl) step1StatusEl.style.display='none';
  7867. qAIUpdateProgress(0);
  7868. showToast('⚠️ 豆包调用失败,已显示本地备用结果', 'warning');
  7869. });
  7870. }).catch(function(err){
  7871. qAILog('❌ 图纸处理失败:'+err.message);
  7872. qAICleanedImageData = qUploadedDrawingUrl;
  7873. var cleanedImg = document.getElementById('q-ai-cleaned-img');
  7874. var cleanedPlaceholder = document.getElementById('q-ai-cleaned-placeholder');
  7875. if (cleanedImg) { cleanedImg.src = qAICleanedImageData; cleanedImg.style.display = 'block'; }
  7876. if (cleanedPlaceholder) cleanedPlaceholder.style.display = 'none';
  7877. var cleanBtn = document.getElementById('q-btn-clean-drawing');
  7878. if (cleanBtn) { cleanBtn.disabled = false; cleanBtn.style.opacity = '1'; }
  7879. var step1StatusEl = document.getElementById('q-ai-step1-status');
  7880. if (step1StatusEl) step1StatusEl.style.display = 'none';
  7881. qAIUpdateProgress(0);
  7882. showToast('❌ 图纸处理失败,已恢复原图', 'error');
  7883. });
  7884. }
  7885. function qAIGoToStep1(){
  7886. qAICurrentStep = 0;
  7887. document.getElementById('q-ai-step1-area').style.display = 'block';
  7888. document.getElementById('q-ai-step2-area').style.display = 'none';
  7889. document.getElementById('q-ai-step3-area').style.display = 'none';
  7890. qAIUpdateProgress(0);
  7891. qSyncOriginalDrawingToLayout();
  7892. // 返回家具布局时按布局数据恢复渲染已有家具贴纸,保持先前布局原样
  7893. if(typeof qFurnitureV3Render==='function') qFurnitureV3Render();
  7894. setTimeout(function(){ if(typeof qFurnitureV3Render==='function') qFurnitureV3Render(); }, 300);
  7895. }
  7896. function qAIGoToStep2(){
  7897. qAICurrentStep = 1;
  7898. document.getElementById('q-ai-step1-area').style.display = 'none';
  7899. document.getElementById('q-ai-step2-area').style.display = 'block';
  7900. document.getElementById('q-ai-step3-area').style.display = 'none';
  7901. qAIUpdateProgress(1);
  7902. var workspaceImg = document.getElementById('q-ai-workspace-img');
  7903. var originalSrc=qSyncOriginalDrawingToLayout();
  7904. if (workspaceImg && originalSrc) workspaceImg.src = originalSrc;
  7905. }
  7906. function qAIGoToStep3(){
  7907. if (qAIPlacedCabinets.length === 0) { showToast('️ 请至少放置一个柜体', 'error'); return; }
  7908. qAICurrentStep = 2;
  7909. document.getElementById('q-ai-step1-area').style.display = 'none';
  7910. document.getElementById('q-ai-step2-area').style.display = 'none';
  7911. document.getElementById('q-ai-step3-area').style.display = 'block';
  7912. qAIUpdateProgress(2);
  7913. var scaleInfo = document.getElementById('q-ai-scale-info');
  7914. var scaleText = document.getElementById('q-ai-scale-text');
  7915. var drawingSize = document.getElementById('q-ai-drawing-size');
  7916. var scaleSource = document.getElementById('q-ai-scale-source');
  7917. if (scaleInfo) scaleInfo.style.display = 'block';
  7918. var scaleResult=qCalculateImageScale();
  7919. if (scaleText) scaleText.textContent = qAIScaleRatio > 0 ? ('1像素 = '+qAIScaleRatio.toFixed(6)+' 米(1米 ≈ '+Math.round(1/qAIScaleRatio)+'像素)') : '原图尺寸标注识别结果待复核';
  7920. if (scaleSource) scaleSource.textContent=scaleResult.ok?('✓ '+scaleResult.source+' · 使用 '+scaleResult.count+' 条原图尺寸线'):('已直接采用家具贴纸比例估算,可编辑柜体尺寸');
  7921. if (drawingSize) drawingSize.textContent = qAIDrawingWidth + '×' + qAIDrawingHeight + 'px';
  7922. qAIRenderDimensionTable();
  7923. }
  7924. function qAIUpdateProgress(currentStep){
  7925. for (var i = 0; i < 3; i++) {
  7926. var ps = document.getElementById('q-ai-prog-'+i);
  7927. if (ps) {
  7928. if (i < currentStep) ps.className = 'ai-progress-step done';
  7929. else if (i === currentStep) ps.className = 'ai-progress-step active';
  7930. else ps.className = 'ai-progress-step';
  7931. }
  7932. }
  7933. }
  7934. function qAILog(msg){
  7935. var step1Log = document.getElementById('q-ai-step1-log');
  7936. if (step1Log) {
  7937. step1Log.style.display = 'block';
  7938. var div = document.createElement('div');
  7939. div.style.cssText = 'padding:2px 0;opacity:0;animation:q-fadeInUp .3s forwards;';
  7940. div.textContent = msg;
  7941. step1Log.appendChild(div);
  7942. step1Log.scrollTop = step1Log.scrollHeight;
  7943. }
  7944. console.log('[AI柜体识别] ' + msg);
  7945. }
  7946. // ── 家具二次空间布局(独立于柜体报价数据) ──
  7947. function qFurnitureDragStart(e, type, icon, existingId){
  7948. qFurnitureDragPayload = { type:type, icon:icon, existingId:existingId || null };
  7949. if(e && e.dataTransfer){
  7950. e.dataTransfer.effectAllowed = existingId ? 'move' : 'copy';
  7951. e.dataTransfer.setData('application/json', JSON.stringify(qFurnitureDragPayload));
  7952. }
  7953. }
  7954. function qFurnitureDrop(e){
  7955. e.preventDefault();
  7956. e.stopPropagation();
  7957. var workspace = document.getElementById('q-floorplan-layout-workspace');
  7958. if(!workspace) return;
  7959. var payload = qFurnitureDragPayload;
  7960. try { payload = JSON.parse(e.dataTransfer.getData('application/json')) || payload; } catch(ignore){}
  7961. if(!payload) return;
  7962. var drawing=document.getElementById('q-ai-cleaned-img');
  7963. qAlignFurnitureOverlayToImage();
  7964. var rect=(drawing && drawing.style.display!=='none' && drawing.getBoundingClientRect().width) ? drawing.getBoundingClientRect() : workspace.getBoundingClientRect();
  7965. var x = Math.max(2,Math.min(98,((e.clientX-rect.left)/rect.width)*100));
  7966. var y = Math.max(2,Math.min(98,((e.clientY-rect.top)/rect.height)*100));
  7967. if(payload.existingId){
  7968. var old = qFurnitureLayout.find(function(item){return item.id===payload.existingId;});
  7969. if(old){old.x=x;old.y=y;}
  7970. } else {
  7971. qFurnitureLayout.push({id:'fur_'+Date.now()+'_'+Math.random().toString(36).slice(2,6),type:payload.type,icon:payload.icon,x:x,y:y,widthScale:1,heightScale:1});
  7972. }
  7973. qFurnitureDragPayload=null;
  7974. qRenderFurnitureLayout();
  7975. workspace.style.outline='2px solid #38A169';
  7976. setTimeout(function(){workspace.style.outline='';},350);
  7977. showToast('家具贴纸已放置,可继续拖动或调整长宽', 'success');
  7978. }
  7979. function qAlignFurnitureOverlayToImage(){
  7980. var workspace=document.getElementById('q-floorplan-layout-workspace');
  7981. var drawing=document.getElementById('q-ai-cleaned-img');
  7982. var overlay=document.getElementById('q-furniture-overlay');
  7983. if(!workspace || !drawing || !overlay || drawing.style.display==='none') return;
  7984. var wr=workspace.getBoundingClientRect(), ir=drawing.getBoundingClientRect();
  7985. if(!ir.width || !ir.height) return;
  7986. overlay.style.inset='auto';
  7987. overlay.style.left=(ir.left-wr.left)+'px';
  7988. overlay.style.top=(ir.top-wr.top)+'px';
  7989. overlay.style.width=ir.width+'px';
  7990. overlay.style.height=ir.height+'px';
  7991. }
  7992. function qInitFurnitureDragDrop(){
  7993. var workspace=document.getElementById('q-floorplan-layout-workspace');
  7994. if(!workspace || workspace.dataset.furnitureDropReady==='1') return;
  7995. workspace.dataset.furnitureDropReady='1';
  7996. document.querySelectorAll('.q-furniture-tool').forEach(function(tool){
  7997. tool.addEventListener('dragstart',function(e){
  7998. var nameEl=tool.querySelector('span');
  7999. var type=nameEl?nameEl.textContent.trim():'家具';
  8000. var icon='';
  8001. for(var i=0;i<tool.childNodes.length;i++){
  8002. if(tool.childNodes[i].nodeType===3 && tool.childNodes[i].textContent.trim()){icon=tool.childNodes[i].textContent.trim();break;}
  8003. }
  8004. qFurnitureDragStart(e,type,icon||'▣');
  8005. tool.style.opacity='.55';
  8006. });
  8007. tool.addEventListener('dragend',function(){tool.style.opacity='1';workspace.style.background='#FAFAFA';});
  8008. });
  8009. workspace.addEventListener('dragenter',function(e){e.preventDefault();workspace.style.background='#EBF8FF';});
  8010. workspace.addEventListener('dragover',function(e){e.preventDefault();e.dataTransfer.dropEffect='copy';workspace.style.background='#EBF8FF';});
  8011. workspace.addEventListener('dragleave',function(e){if(!workspace.contains(e.relatedTarget))workspace.style.background='#FAFAFA';});
  8012. workspace.addEventListener('drop',function(e){workspace.style.background='#FAFAFA';qFurnitureDrop(e);});
  8013. }
  8014. function qAddFurnitureCenter(type,icon){
  8015. var offset=(qFurnitureLayout.length%6)*4;
  8016. qFurnitureLayout.push({id:'fur_'+Date.now()+'_'+Math.random().toString(36).slice(2,6),type:type,icon:icon,x:46+offset,y:46+offset,widthScale:1,heightScale:1});
  8017. qRenderFurnitureLayout();
  8018. }
  8019. function qRemoveFurniture(id){
  8020. qFurnitureLayout=qFurnitureLayout.filter(function(item){return item.id!==id;});
  8021. qRenderFurnitureLayout();
  8022. }
  8023. function qResizeFurniture(id, axis, delta, e){
  8024. if(e){e.preventDefault();e.stopPropagation();}
  8025. var item=qFurnitureLayout.find(function(f){return f.id===id;});
  8026. if(!item) return;
  8027. var key=axis==='height'?'heightScale':'widthScale';
  8028. item[key]=Math.max(0.55,Math.min(2.8,(item[key]||item.size||1)+delta));
  8029. qRenderFurnitureLayout();
  8030. }
  8031. function qRenderFurnitureLayout(){
  8032. var overlay=document.getElementById('q-furniture-overlay');
  8033. if(!overlay) return;
  8034. overlay.innerHTML=qFurnitureLayout.map(function(item){
  8035. var widthScale=item.widthScale||item.size||1;
  8036. var heightScale=item.heightScale||item.size||1;
  8037. return '<div class="q-placed-furniture" draggable="true" style="left:'+item.x+'%;top:'+item.y+'%;width:'+(58*widthScale)+'px;min-height:'+(48*heightScale)+'px;" '+
  8038. 'ondragstart="qFurnitureDragStart(event,\''+item.type+'\',\''+item.icon+'\',\''+item.id+'\')" '+
  8039. 'ondblclick="qRemoveFurniture(\''+item.id+'\')" title="拖动调整位置,双击删除">'+
  8040. '<div class="icon">'+item.icon+'</div><div class="name">'+item.type+'</div>'+
  8041. '<div class="q-furniture-size-controls"><button type="button" draggable="false" onclick="qResizeFurniture(\''+item.id+'\',\'width\',-0.15,event)">宽−</button><button type="button" draggable="false" onclick="qResizeFurniture(\''+item.id+'\',\'width\',0.15,event)">宽+</button><button type="button" draggable="false" onclick="qResizeFurniture(\''+item.id+'\',\'height\',-0.15,event)">高−</button><button type="button" draggable="false" onclick="qResizeFurniture(\''+item.id+'\',\'height\',0.15,event)">高+</button></div></div>';
  8042. }).join('');
  8043. // 从草稿恢复、返回编辑器或重新渲染第二步后,必须重新绑定家具库拖放事件。
  8044. clearTimeout(window.__qFurnitureReinitTimer);
  8045. window.__qFurnitureReinitTimer=setTimeout(function(){
  8046. if(typeof qFurnitureV3Init==='function')qFurnitureV3Init();
  8047. },0);
  8048. }
  8049. function qAIInitDragDrop(){
  8050. var stickers = document.querySelectorAll('.q-cabinet-sticker');
  8051. stickers.forEach(function(sticker){
  8052. sticker.addEventListener('dragstart', function(e){
  8053. e.dataTransfer.setData('text/plain', sticker.getAttribute('data-type'));
  8054. e.dataTransfer.effectAllowed = 'copy';
  8055. });
  8056. });
  8057. var workspace = document.getElementById('q-ai-workspace');
  8058. if (workspace) {
  8059. workspace.addEventListener('dragover', function(e){ e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; });
  8060. workspace.addEventListener('drop', function(e){
  8061. e.preventDefault();
  8062. var cabinetType = e.dataTransfer.getData('text/plain');
  8063. if (cabinetType) qAIAddCabinet(cabinetType, e.offsetX, e.offsetY);
  8064. });
  8065. }
  8066. }
  8067. function qAIAddCabinet(cabinetType, x, y){
  8068. var cabinet = { id: Date.now(), type: cabinetType, x: x, y: y, width: 0, height: 0 };
  8069. qAIPlacedCabinets.push(cabinet);
  8070. qAIUpdatePlacedList();
  8071. qAIRenderPlacedCabinets();
  8072. var goStep3Btn = document.getElementById('q-btn-go-step3');
  8073. if (goStep3Btn) goStep3Btn.disabled = false;
  8074. }
  8075. function qAIRemoveCabinet(id){
  8076. qAIPlacedCabinets = qAIPlacedCabinets.filter(function(c){ return c.id !== id; });
  8077. qAIUpdatePlacedList();
  8078. qAIRenderPlacedCabinets();
  8079. if (qAIPlacedCabinets.length === 0) {
  8080. var goStep3Btn = document.getElementById('q-btn-go-step3');
  8081. if (goStep3Btn) goStep3Btn.disabled = true;
  8082. }
  8083. }
  8084. function qAIUpdatePlacedList(){
  8085. var placedList = document.getElementById('q-ai-placed-list');
  8086. var placedCount = document.getElementById('q-ai-placed-count');
  8087. var placedItems = document.getElementById('q-ai-placed-items');
  8088. if (qAIPlacedCabinets.length === 0) { if (placedList) placedList.style.display = 'none'; return; }
  8089. if (placedList) placedList.style.display = 'block';
  8090. if (placedCount) placedCount.textContent = qAIPlacedCabinets.length;
  8091. if (placedItems) {
  8092. placedItems.innerHTML = qAIPlacedCabinets.map(function(c){
  8093. return '<div style="display:flex;align-items:center;gap:6px;padding:6px 10px;background:#EBF8FF;border:1px solid #90CDF4;border-radius:6px;font-size:12px;">'
  8094. + '<span style="font-weight:600;color:#2B6CB0;">' + c.type + '</span>'
  8095. + '<button onclick="qAIRemoveCabinet(' + c.id + ')" style="border:none;background:none;color:#E53E3E;cursor:pointer;font-size:14px;padding:0 4px;">✕</button>'
  8096. + '</div>';
  8097. }).join('');
  8098. }
  8099. }
  8100. function qAIRenderPlacedCabinets(){
  8101. var container = document.getElementById('q-ai-placed-cabinets');
  8102. if (!container) return;
  8103. container.innerHTML = qAIPlacedCabinets.map(function(c){
  8104. return '<div style="position:absolute;left:' + c.x + 'px;top:' + c.y + 'px;background:rgba(49,130,206,0.2);border:2px dashed #3182CE;border-radius:4px;padding:4px 8px;font-size:11px;color:#2B6CB0;font-weight:600;pointer-events:auto;cursor:move;white-space:nowrap;">' + c.type + '</div>';
  8105. }).join('');
  8106. }
  8107. function qAIRenderDimensionTable(){
  8108. var tbody = document.getElementById('q-ai-dimension-tbody');
  8109. if (!tbody) return;
  8110. tbody.innerHTML = qAIPlacedCabinets.map(function(c, idx){
  8111. var width = c.width > 0 ? c.width.toFixed(2) : '—';
  8112. var height = c.height > 0 ? c.height.toFixed(2) : '—';
  8113. var area = (c.width > 0 && c.height > 0) ? (c.width * c.height).toFixed(3) : '—';
  8114. var source = c.width > 0 ? (c.source === '家具贴纸' ? '贴纸比例换算' : 'AI识别') : '待录入';
  8115. return '<tr>'
  8116. + '<td style="padding:8px 10px;border:1px solid #E2E8F0;">' + (idx + 1) + '</td>'
  8117. + '<td style="padding:8px 10px;border:1px solid #E2E8F0;font-weight:600;">' + c.type + '</td>'
  8118. + '<td style="padding:8px 10px;border:1px solid #E2E8F0;text-align:center;"><input type="number" step="0.01" min="0" value="' + (c.width > 0 ? c.width.toFixed(2) : '') + '" style="width:70px;padding:4px 6px;border:1px solid #E2E8F0;border-radius:4px;font-size:12px;text-align:center;" onfocus="this.select()" onkeydown="if(event.key===\'Enter\')this.blur()" onchange="qAIUpdateCabinetSize(' + c.id + ',\'width\',this.value)"></td>'
  8119. + '<td style="padding:8px 10px;border:1px solid #E2E8F0;text-align:center;"><input type="number" step="0.01" min="0" value="' + (c.height > 0 ? c.height.toFixed(2) : '') + '" style="width:70px;padding:4px 6px;border:1px solid #E2E8F0;border-radius:4px;font-size:12px;text-align:center;" onfocus="this.select()" onkeydown="if(event.key===\'Enter\')this.blur()" onchange="qAIUpdateCabinetSize(' + c.id + ',\'height\',this.value)"></td>'
  8120. + '<td style="padding:8px 10px;border:1px solid #E2E8F0;text-align:center;font-weight:600;color:#3182CE;">' + area + '</td>'
  8121. + '<td style="padding:8px 10px;border:1px solid #E2E8F0;font-size:11px;color:' + (source !== '待录入' ? '#38A169' : '#E53E3E') + ';">' + source + '</td>'
  8122. + '</tr>';
  8123. }).join('');
  8124. }
  8125. function qAIUpdateCabinetSize(id, field, value){
  8126. var cabinet = qAIPlacedCabinets.find(function(c){ return c.id === id; });
  8127. if (cabinet) { cabinet[field] = parseFloat(value) || 0; qAIRenderDimensionTable(); qPrepareRoomsFromPlacedCabinets(); qRenderAICards({rooms:qRooms},false); }
  8128. }
  8129. function qFinishAnalysisNew(){
  8130. var previousRooms = (window._qLastAnalysisData && window._qLastAnalysisData.rooms) || qRooms || [];
  8131. // 第二套报价模式沿用源文件设置:柜高缺省兜底为层高,数据来源标签按源文件;模式一保持原行为
  8132. var m2f = state.quoteMode === 'manual';
  8133. var defHf = m2f ? (qFloorHeight || 2.5) : 0;
  8134. qRooms = qAIPlacedCabinets.map(function(c){
  8135. var placedIndex = qAIPlacedCabinets.indexOf(c);
  8136. var previous = previousRooms[placedIndex] || {};
  8137. var nextRoom = {
  8138. roomName: c.type.replace(/柜$/, '').replace(/橱柜/, '厨房').replace(/衣柜/, '卧室').replace(/电视柜/, '客厅').replace(/鞋柜/, '玄关').replace(/餐边柜/, '餐厅').replace(/书房柜/, '书房'),
  8139. cabinetType: c.type,
  8140. layoutStyle: '一字型',
  8141. projectArea: (c.width || 0) * (c.height || 0),
  8142. doorType: '普通平板',
  8143. length: c.width || 3.0,
  8144. width: 3.0,
  8145. height: c.height || defHf,
  8146. area: 10,
  8147. cabinetWidth: c.width || 0,
  8148. cabinetHeight: c.height || defHf,
  8149. cabinetRatio: 0.50,
  8150. fillRate: 0.50,
  8151. doorRatio: 0.50,
  8152. bayWindow: false,
  8153. posX: c.posX, posY: c.posY, posLabel: c.posLabel || '',
  8154. _dataSource: m2f ? { cabinetWidth: '用户定位', cabinetHeight: '用户录入/默认' } : { cabinetWidth: c.source || '用户定位', cabinetHeight: c.source || '规则色块识别' }
  8155. };
  8156. if(Array.isArray(previous._detailItems)) nextRoom._detailItems = JSON.parse(JSON.stringify(previous._detailItems));
  8157. if(Array.isArray(previous._selectedExtras)) nextRoom._selectedExtras = JSON.parse(JSON.stringify(previous._selectedExtras));
  8158. if(previous._detailItemsExcelVersion) nextRoom._detailItemsExcelVersion = previous._detailItemsExcelVersion;
  8159. return nextRoom;
  8160. });
  8161. // 厨房四边尺寸同步空间/布局尺寸(规则模块一第2条:抓取厨房空间四边总尺寸)
  8162. for(var ki=0; ki<qRooms.length && ki<qAIPlacedCabinets.length; ki++){
  8163. var kc=qAIPlacedCabinets[ki];
  8164. if(qIsKitchenCabinet(qRooms[ki].cabinetType||qRooms[ki].roomName)){
  8165. var kw=Array.isArray(kc.kitchenWalls)?kc.kitchenWalls:[kc.width||0,kc.height||0,kc.width||0,kc.height||0];
  8166. qRooms[ki].kitchenWalls=kw;
  8167. if(kw[0]) qRooms[ki].length=kw[0];
  8168. if(kw[1]) qRooms[ki].width=kw[1];
  8169. }
  8170. }
  8171. qAiProcessed = true;
  8172. qAiAnalyzing = false;
  8173. qAiEditableData = JSON.parse(JSON.stringify({ rooms: qRooms }));
  8174. qAiEditMode = false;
  8175. qExtras = qGenerateExtrasFromRooms(qRooms);
  8176. qSyncStep2DetailsToPricing();
  8177. qFixRoomSpaceNames(qRooms);
  8178. qSyncRoomItemsWithRules();
  8179. // 尺寸确认后直接进入主流程第三步“自动计价”,不再回到旧版第二步解析卡片。
  8180. goToStep(3);
  8181. if (state.quoteMode === 'manual') {
  8182. // 精准模式:跳转到自动计价模块窗口核对并生成报价单
  8183. switchView('pricing');
  8184. }
  8185. showToast('✅ 尺寸已确认,已按规则配置单价完成自动计价', 'success');
  8186. }
  8187. // 初始化拖拽
  8188. qAIInitDragDrop();
  8189. // ─── 判断图片来源并启动分析(支持户型图/CAD二选一或同时上传)───
  8190. var hasDrawing = !!(qUploadedDrawing && qUploadedDrawingUrl);
  8191. var hasCAD = !!(qUploadedCAD);
  8192. var isMockDrawing = qUploadedDrawing && qUploadedDrawing.name && qUploadedDrawing.name.indexOf('120㎡三房两厅') !== -1;
  8193. var isMockCAD = qUploadedCAD && qUploadedCAD.name && qUploadedCAD.name.indexOf('120㎡三房两厅') !== -1;
  8194. // ─── 多图纸合并模式 ───
  8195. if (qMultiMode) {
  8196. var readySheets = qSheets.filter(function(s){ return s.drawing || s.isMock; });
  8197. if (readySheets.length === 0) {
  8198. if (statusText) { statusText.textContent = '⚠️ 请先添加图纸'; statusText.style.color = 'var(--danger)'; }
  8199. if (statusDetail) statusDetail.textContent = '多图纸模式下,请至少为一张图纸上传户型图或选择示例';
  8200. if (spinner) spinner.style.display = 'none';
  8201. if (progressBar) progressBar.style.display = 'none';
  8202. return;
  8203. }
  8204. qRunMultiSheetAnalysis(addLogLine, runAnalysis, qFinishAnalysis, qGenerateStepLogs);
  8205. return;
  8206. }
  8207. if (hasDrawing && hasCAD) {
  8208. // 同时上传了户型图和CAD → 优先使用CAD精确尺寸,户型图用于空间识别
  8209. addLogLine('📐 检测到户型图 + CAD图纸双上传,启动联合分析...');
  8210. addLogLine('🎨 图纸识别规则(户型图/CAD通用):抓取'+qCadRuleColorName+'色块('+qCadRuleColor+')自动识别柜体');
  8211. if (qUploadedDrawing.type && qUploadedDrawing.type.startsWith('image/')) {
  8212. var img = new Image();
  8213. img.onload = function(){
  8214. var fileName = '【联合分析】'+(qUploadedDrawing.name||'户型图')+' + '+(qUploadedCAD.name||'CAD');
  8215. var fsize = (qUploadedDrawing.size||0)+(qUploadedCAD.size||0);
  8216. addLogLine('🔍 正在分析户型图像素特征...');
  8217. qAnalyzeImagePixels(qUploadedDrawingUrl, img.naturalWidth, img.naturalHeight).then(function(ar){
  8218. if(ar) addLogLine('✅ 图像分析完成:检测到约'+ar.detectedRoomCount+'个空间区域');
  8219. runAnalysis(img.naturalWidth, img.naturalHeight, qUploadedDrawingUrl, fileName, fsize);
  8220. }).catch(function(){
  8221. runAnalysis(img.naturalWidth, img.naturalHeight, qUploadedDrawingUrl, fileName, fsize);
  8222. });
  8223. };
  8224. img.onerror = function(){ _qImageAnalysisResult=null; runAnalysis(1920, 1080, qUploadedDrawingUrl, '联合分析', 0); };
  8225. img.src = qUploadedDrawingUrl;
  8226. } else if (isMockDrawing) {
  8227. _qImageAnalysisResult = null;
  8228. runAnalysis(1400, 920, qUploadedDrawingUrl, '【联合分析】120㎡三房两厅户型图 + '+qUploadedCAD.name, 45000);
  8229. } else {
  8230. _qImageAnalysisResult = null;
  8231. runAnalysis(1600, 1200, null, '【联合分析】'+qUploadedDrawing.name+' + '+qUploadedCAD.name, 0);
  8232. }
  8233. } else if (hasDrawing) {
  8234. // 仅户型图
  8235. if (qUploadedDrawing.type && qUploadedDrawing.type.startsWith('image/')) {
  8236. var img2 = new Image();
  8237. img2.onload = function(){
  8238. addLogLine('🔍 正在分析户型图像素特征...');
  8239. qAnalyzeImagePixels(qUploadedDrawingUrl, img2.naturalWidth, img2.naturalHeight).then(function(ar){
  8240. if(ar) addLogLine('✅ 图像分析完成:检测到约'+ar.detectedRoomCount+'个空间区域');
  8241. runAnalysis(img2.naturalWidth, img2.naturalHeight, qUploadedDrawingUrl, qUploadedDrawing.name, qUploadedDrawing.size);
  8242. }).catch(function(){
  8243. runAnalysis(img2.naturalWidth, img2.naturalHeight, qUploadedDrawingUrl, qUploadedDrawing.name, qUploadedDrawing.size);
  8244. });
  8245. };
  8246. img2.onerror = function(){ _qImageAnalysisResult=null; runAnalysis(1920, 1080, qUploadedDrawingUrl, qUploadedDrawing.name, qUploadedDrawing.size); };
  8247. img2.src = qUploadedDrawingUrl;
  8248. } else if (isMockDrawing) {
  8249. _qImageAnalysisResult = null;
  8250. runAnalysis(1400, 920, qUploadedDrawingUrl, qUploadedDrawing.name, 45000);
  8251. } else {
  8252. _qImageAnalysisResult = null;
  8253. runAnalysis(1600, 1200, null, qUploadedDrawing.name, qUploadedDrawing.size);
  8254. }
  8255. } else if (hasCAD) {
  8256. // 仅CAD图纸 → 基于CAD尺寸精确分析
  8257. addLogLine('📏 使用CAD图纸进行精确尺寸分析...');
  8258. addLogLine('🎨 图纸识别规则(户型图/CAD通用):抓取'+qCadRuleColorName+'色块('+qCadRuleColor+')自动识别柜体');
  8259. _qImageAnalysisResult = null;
  8260. if (isMockCAD) {
  8261. runAnalysis(1600, 1200, null, qUploadedCAD.name, qUploadedCAD.size);
  8262. } else {
  8263. runAnalysis(1600, 1200, null, qUploadedCAD.name, qUploadedCAD.size||0);
  8264. }
  8265. } else {
  8266. // 无图纸上传 → 基于需求文本
  8267. _qImageAnalysisResult = null;
  8268. if (loadingArea) loadingArea.style.display = 'flex';
  8269. runAnalysis(1200, 800, null, '未上传图纸(仅使用需求文本)', 0);
  8270. }
  8271. }
  8272. // ─── 多图纸逐张解析+合并 ───
  8273. function qRunMultiSheetAnalysis(addLogLine, runAnalysis, qFinishAnalysis, qGenerateStepLogs){
  8274. // 获取DOM元素(自包含,不依赖外层闭包变量)
  8275. var loadingArea = document.getElementById('q-ai-loading-area');
  8276. var spinner = document.getElementById('q-ai-spinner');
  8277. var statusText = document.getElementById('q-ai-status-text');
  8278. var statusDetail = document.getElementById('q-ai-status-detail');
  8279. var previewDiv = document.getElementById('q-ai-image-preview');
  8280. var drawingThumb = document.getElementById('q-ai-drawing-thumb');
  8281. var drawingImg = document.getElementById('q-ai-drawing-img');
  8282. var fileInfo = document.getElementById('q-ai-file-info');
  8283. var disclaimer = document.getElementById('q-ai-disclaimer');
  8284. var analysisGrid = document.getElementById('q-ai-analysis-grid');
  8285. var summaryBanner = document.getElementById('q-ai-summary-banner');
  8286. var progressBar = document.getElementById('q-ai-progress-bar');
  8287. var actionArea = document.getElementById('q-ai-action-area');
  8288. var sheets = qSheets.slice();
  8289. var totalSheets = sheets.length;
  8290. var allRooms = [];
  8291. var allSheetResults = [];
  8292. var sheetColors = ['#3182CE','#38A169','#DD6B20','#805AD5','#D53F8C','#B7791F'];
  8293. var currentSheetIdx = 0;
  8294. if (fileInfo) {
  8295. fileInfo.innerHTML = '📚 多图纸合并模式 | 共'+totalSheets+'张图纸 | 逐张解析后合并房间数据';
  8296. }
  8297. if (previewDiv) previewDiv.style.display = 'block';
  8298. if (drawingThumb) {
  8299. drawingThumb.style.display = 'block';
  8300. if (drawingImg) { drawingImg.style.display = 'none'; }
  8301. }
  8302. addLogLine('📚 启动【多图纸合并报价】模式,共'+totalSheets+'张图纸');
  8303. addLogLine('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
  8304. function processNextSheet(){
  8305. if (currentSheetIdx >= totalSheets) {
  8306. finishMerge();
  8307. return;
  8308. }
  8309. var sheet = sheets[currentSheetIdx];
  8310. var color = sheetColors[currentSheetIdx % sheetColors.length];
  8311. var sheetNum = currentSheetIdx + 1;
  8312. addLogLine('');
  8313. addLogLine('📄 【'+sheetNum+'/'+totalSheets+'】正在解析:'+sheet.name+' ...');
  8314. // 构造opts
  8315. var opts = {
  8316. sheetId: sheet.id,
  8317. sheetName: sheet.name,
  8318. hasCAD: !!sheet.cad
  8319. };
  8320. var imgW = 1400, imgH = 920, fileName = sheet.name, fsize = 45000, imgSrc = null;
  8321. if (sheet.isMock) {
  8322. opts.mockType = sheet.mockType;
  8323. if (sheet.mockType === 'floor2') {
  8324. fileName = sheet.name;
  8325. imgW = 1200; imgH = 900;
  8326. } else {
  8327. fileName = '120㎡三房两厅户型图('+sheet.name+')';
  8328. }
  8329. } else if (sheet.drawingUrl) {
  8330. imgSrc = sheet.drawingUrl;
  8331. fileName = sheet.drawing ? sheet.drawing.name : sheet.name;
  8332. fsize = sheet.drawing ? sheet.drawing.size : 0;
  8333. } else if (sheet.cad) {
  8334. fileName = sheet.cad.name;
  8335. fsize = sheet.cad.size || 0;
  8336. }
  8337. // 临时保存并替换全局上传变量,保证qGenerateRoomsFromImage与runAnalysis一致
  8338. var savedDrawing = qUploadedDrawing, savedDrawingUrl = qUploadedDrawingUrl, savedCAD = qUploadedCAD;
  8339. qUploadedDrawing = sheet.drawing || (sheet.isMock ? {name:fileName, type:'image/jpeg'} : null);
  8340. qUploadedDrawingUrl = sheet.drawingUrl || null;
  8341. qUploadedCAD = sheet.cad || null;
  8342. var analysisData = qGenerateRoomsFromImage(imgW, imgH, fileName, opts);
  8343. // 恢复
  8344. qUploadedDrawing = savedDrawing; qUploadedDrawingUrl = savedDrawingUrl; qUploadedCAD = savedCAD;
  8345. // 为每个房间附加来源颜色和图纸序号
  8346. analysisData.rooms.forEach(function(r){
  8347. r._sheetColor = color;
  8348. r._sheetIndex = sheetNum;
  8349. r._isMulti = true;
  8350. });
  8351. allRooms = allRooms.concat(analysisData.rooms);
  8352. sheet.rooms = analysisData.rooms;
  8353. sheet.analysisData = analysisData;
  8354. allSheetResults.push({
  8355. sheet: sheet,
  8356. data: analysisData,
  8357. color: color,
  8358. sheetNum: sheetNum
  8359. });
  8360. addLogLine(' ✅ 识别出 '+analysisData.rooms.length+' 个空间:'+analysisData.rooms.map(function(r){return r.roomName;}).join('、'));
  8361. addLogLine(' 📐 合计投影面积:'+analysisData.totalArea+'㎡');
  8362. currentSheetIdx++;
  8363. setTimeout(processNextSheet, 300);
  8364. }
  8365. function finishMerge(){
  8366. qRooms = allRooms;
  8367. qExtras = qGenerateExtrasFromRooms(qRooms);
  8368. qRoomItems = [];
  8369. for (var _i = 0; _i < qRooms.length; _i++) { qRoomItems[_i] = qBuildRoomItems(_i); }
  8370. // 🔑 多图纸合并后,强制以上传层高为准更新所有柜体高度和投影面积
  8371. if (qFloorHeight >= 2.0 && qFloorHeight <= 4.0) {
  8372. qApplyFloorHeightToRooms(qFloorHeight);
  8373. }
  8374. var totalArea = allRooms.reduce(function(s,r){return s + r.projectArea;}, 0);
  8375. var mergedData = {
  8376. rooms: allRooms,
  8377. houseType: '多图纸合并('+totalSheets+'张)',
  8378. houseLabel: '多图纸合并报价',
  8379. totalArea: parseFloat(totalArea.toFixed(1)),
  8380. grossArea: parseFloat((totalArea*1.4).toFixed(0)),
  8381. multiSheet: true,
  8382. sheetCount: totalSheets,
  8383. sheets: allSheetResults.map(function(sr){
  8384. return {
  8385. name: sr.sheet.name,
  8386. roomCount: sr.data.rooms.length,
  8387. totalArea: sr.data.totalArea,
  8388. color: sr.color,
  8389. houseType: sr.data.houseType
  8390. };
  8391. }),
  8392. orientation: '多户型综合',
  8393. ratio: 0,
  8394. roomCount: allRooms.length,
  8395. walls: [],
  8396. doors: allRooms.map(function(r,i){return {id:'D'+(i+1),type:i===0?'入户门':'室内门',room:r.roomName,width:0.8,position:'墙面',opening:'内开左'};}),
  8397. windows: allRooms.map(function(r,i){return {id:'WIN'+(i+1),room:r.roomName,type:'普通窗',width:1.5,orientation:['南','北','东','西'][i%4]};}),
  8398. structType: '多图纸综合',
  8399. outerWallThickness: '240mm',
  8400. innerWallThickness: '120mm',
  8401. floorHeight: '2.8m',
  8402. totalWidth: '-',
  8403. totalDepth: '-'
  8404. };
  8405. qMergedAnalysisData = mergedData;
  8406. // 更新fileInfo显示合并概览
  8407. if (fileInfo) {
  8408. var sheetSummary = allSheetResults.map(function(sr){
  8409. return '<span style="color:'+sr.color+';font-weight:600;">'+sr.sheet.name+'</span>('+sr.data.rooms.length+'房/'+sr.data.totalArea+'㎡)';
  8410. }).join(' + ');
  8411. fileInfo.innerHTML = '📚 多图纸合并完成 | '+sheetSummary+' | <strong>合计'+allRooms.length+'房 / '+totalArea.toFixed(1)+'㎡</strong>';
  8412. }
  8413. // 生成合并模式的日志
  8414. addLogLine('');
  8415. addLogLine('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
  8416. addLogLine('🔗 数据合并完成:');
  8417. allSheetResults.forEach(function(sr){
  8418. addLogLine(' 📄 ['+sr.sheetNum+'] '+sr.sheet.name+' | '+sr.data.rooms.length+'房 | '+sr.data.totalArea+'㎡');
  8419. });
  8420. addLogLine(' ✅ 合并后总计:'+allRooms.length+'个空间,投影面积合计'+totalArea.toFixed(1)+'㎡');
  8421. addLogLine('');
  8422. addLogLine('✅ <strong>多图纸合并解析全部完成</strong> — 共'+totalSheets+'张图纸已合并至统一报价单');
  8423. // 调用完成处理
  8424. if (spinner) spinner.style.display = 'none';
  8425. if (loadingArea) loadingArea.style.display = 'none';
  8426. if (statusText) { statusText.textContent = '✅ 多图纸合并解析完成('+totalSheets+'张图纸 / '+allRooms.length+'个空间)'; statusText.style.color='var(--success)'; }
  8427. if (statusDetail) statusDetail.textContent = '';
  8428. for (var pi = 0; pi < 7; pi++) {
  8429. var ps = document.getElementById('q-ai-prog-'+pi);
  8430. if (ps) ps.className = 'ai-progress-step done';
  8431. }
  8432. qRenderAICards(mergedData, false);
  8433. qUpdateSummaryBanner(mergedData);
  8434. setTimeout(function(){ qAddDrawingAnnotations(false); }, 500);
  8435. if (disclaimer) disclaimer.style.display = 'block';
  8436. if (actionArea) {
  8437. var extraBtns = '';
  8438. if (qRenderResults && qRenderResults.length > 0) {
  8439. extraBtns += '<button class="btn btn-outline btn-lg" onclick="qShowRenderResults()" style="margin-left:8px;">🖼️ 查看结构识别结果</button>';
  8440. }
  8441. actionArea.innerHTML = extraBtns+'<button class="btn btn-outline btn-lg" onclick="qStartAnalysis()">🔄 重新解析</button>';
  8442. }
  8443. qAiProcessed = true;
  8444. qAiAnalyzing = false; // 解析完成
  8445. qAiEditableData = JSON.parse(JSON.stringify(mergedData));
  8446. qAiEditMode = false;
  8447. setTimeout(function(){ qUpdateFloorHeight(qFloorHeight.toFixed(2)); }, 100);
  8448. }
  8449. setTimeout(processNextSheet, 300);
  8450. }
  8451. // 添加日志行辅助函数
  8452. function addLogLine(msg){
  8453. var log = document.getElementById('q-ai-log');
  8454. if (log) {
  8455. var div = document.createElement('div');
  8456. div.className = 'log-line';
  8457. div.textContent = msg;
  8458. log.appendChild(div);
  8459. log.scrollTop = log.scrollHeight;
  8460. }
  8461. }
  8462. // 图纸标注叠加层:不再在原图上绘制标注,保持原图展示
  8463. function qAddDrawingAnnotations(isMock){
  8464. var annLayer = document.getElementById('q-ai-annotations-layer');
  8465. if (!annLayer) return;
  8466. annLayer.innerHTML = '';
  8467. }
  8468. // 房间柜体/门板类型选择自定义输入
  8469. function qCtsChange(typeKey, roomIndex, onChangeField) {
  8470. var uid = 'q-cts-'+typeKey+'-'+roomIndex;
  8471. var sel = document.getElementById(uid+'-sel');
  8472. var s = document.getElementById(uid);
  8473. var inp = document.getElementById(uid+'-input');
  8474. var hint = document.getElementById(uid+'-hint');
  8475. var del = document.getElementById(uid+'-del');
  8476. if (!sel) return;
  8477. var val = sel.value;
  8478. if (val === '__custom__') {
  8479. if (s) s.style.display = 'none';
  8480. if (inp) { inp.style.display = 'flex'; var txt = document.getElementById(uid+'-text'); if(txt){txt.value='';txt.focus();} }
  8481. if (hint) hint.style.display = '';
  8482. if (del) del.style.display = '';
  8483. } else if (val === '__delete__') {
  8484. // 删除自定义项由qCtsDelete处理
  8485. } else {
  8486. qRooms[roomIndex][onChangeField] = val;
  8487. qRenderStep3();
  8488. }
  8489. }
  8490. // 确认自定义
  8491. function qCtsConfirm(typeKey, roomIndex, onChangeField) {
  8492. var uid = 'q-cts-'+typeKey+'-'+roomIndex;
  8493. var txt = document.getElementById(uid+'-text');
  8494. if (!txt) return;
  8495. var v = txt.value.trim();
  8496. if (v.length < 2) { showToast('类型名称至少2个字符', 'warning'); return; }
  8497. if (v.length > 32) v = v.substring(0, 32);
  8498. if (/[<>{}[\]"'\\]/.test(v)) { showToast('不能包含特殊字符:<>{}[]"\'\\', 'warning'); return; }
  8499. var types = qCustomTypes[typeKey] || [];
  8500. var dup = types.find(function(t){ return t.toLowerCase() === v.toLowerCase(); });
  8501. if (dup) {
  8502. qRooms[roomIndex][onChangeField] = dup;
  8503. } else {
  8504. qCustomTypes[typeKey].push(v);
  8505. qRooms[roomIndex][onChangeField] = v;
  8506. }
  8507. qRenderStep3();
  8508. }
  8509. // 取消自定义
  8510. function qCtsCancel(typeKey, roomIndex) {
  8511. var uid = 'q-cts-'+typeKey+'-'+roomIndex;
  8512. var s = document.getElementById(uid);
  8513. var inp = document.getElementById(uid+'-input');
  8514. var hint = document.getElementById(uid+'-hint');
  8515. var del = document.getElementById(uid+'-del');
  8516. var txt = document.getElementById(uid+'-text');
  8517. if (s) s.style.display = '';
  8518. if (inp) inp.style.display = 'none';
  8519. if (hint) hint.style.display = 'none';
  8520. if (txt) txt.value = '';
  8521. // 恢复到未选择 __custom__ 前的值或第一个
  8522. var types = qCustomTypes[typeKey] || [];
  8523. if (types.length > 0) {
  8524. s.value = types[0];
  8525. qUpdateCtsDelBtn(typeKey, roomIndex);
  8526. }
  8527. }
  8528. // 删除自定义类型(从下拉旁的 × 按钮触发)
  8529. function qDeleteCtsType(typeKey, roomIndex, onChangeField) {
  8530. var uid = 'q-cts-'+typeKey+'-'+roomIndex;
  8531. var sel = document.getElementById(uid);
  8532. if (!sel) return;
  8533. var typeName = sel.value;
  8534. var presetTypes = Q_CUSTOM_TYPES_PRESET[typeKey] || [];
  8535. if (!typeName || presetTypes.indexOf(typeName) !== -1) {
  8536. showToast('系统预设类型不可删除', 'warning');
  8537. return;
  8538. }
  8539. qConfirmDialog('确定删除自定义类型「'+typeName+'」?\n\n已使用该类型的空间不受影响。', function(){
  8540. // 从类型库中移除
  8541. var idx = qCustomTypes[typeKey].indexOf(typeName);
  8542. if (idx !== -1) qCustomTypes[typeKey].splice(idx, 1);
  8543. // 重绘
  8544. qRenderStep3();
  8545. });
  8546. }
  8547. // ── 空间尺寸明细表 ──
  8548. // 平面面积:优先取手动值 r.area,否则从 length×width 计算(单位均为 m)
  8549. function qGetRoomArea(r){
  8550. // 优先使用图纸标注的投影面积
  8551. if (r.projectArea != null && r.projectArea !== '') return parseFloat(r.projectArea);
  8552. if (r.area != null && r.area !== '') return parseFloat(r.area);
  8553. // 其次使用柜体宽×柜体高计算
  8554. var w = parseFloat(r.cabinetWidth) || parseFloat(r.length) || 0;
  8555. var h = parseFloat(r.cabinetHeight) || parseFloat(r.height) || 0;
  8556. if (w > 0 && h > 0) {
  8557. return parseFloat((w * h).toFixed(3));
  8558. }
  8559. return 0;
  8560. }
  8561. // 立面展开面积:优先取手动值 r.expandArea,否则计算(单位均为 m)
  8562. function qGetRoomExpandArea(r){
  8563. if (r.expandArea != null && r.expandArea !== '') return parseFloat(r.expandArea);
  8564. var h = (r.height || 2.8);
  8565. var l = (r.length || 3.0);
  8566. var w = (r.width || 3.0);
  8567. var perimeter = 2 * (l + w);
  8568. var grossArea = perimeter * h;
  8569. var netArea = Math.max(0, grossArea - 3.5) * 0.85;
  8570. return parseFloat(netArea.toFixed(2));
  8571. }
  8572. // 防抖 re-render:避免频繁innerHTML导致输入框光标跳动
  8573. var qDebounceTimer = null;
  8574. function qDebouncedRenderStep3(){
  8575. if (qDebounceTimer) clearTimeout(qDebounceTimer);
  8576. qDebounceTimer = setTimeout(function(){
  8577. qRenderStep3();
  8578. }, 250);
  8579. }
  8580. // 立即更新面积/立面展开显示(不重绘整表,避免光标丢失)
  8581. function qUpdateDimsDisplay(i){
  8582. var r = qRooms[i];
  8583. if (!r) return;
  8584. var area = qGetRoomArea(r);
  8585. var unfold = qGetRoomExpandArea(r);
  8586. var areaCell = document.getElementById('q-dim-area-'+i);
  8587. var unfoldCell = document.getElementById('q-dim-unfold-'+i);
  8588. if (areaCell) areaCell.value = area.toFixed(2);
  8589. if (unfoldCell) unfoldCell.value = unfold.toFixed(2);
  8590. }
  8591. function qRenderSpaceDimensions(){
  8592. var tbody = document.getElementById('q-space-dims-tbody');
  8593. if (!tbody) return;
  8594. var rows = qRooms.map(function(r, i){
  8595. var area = qGetRoomArea(r);
  8596. var h = r.cabinetHeight || r.height || (qFloorHeight > 0 ? parseFloat(qFloorHeight.toFixed(2)) : 0);
  8597. return '<tr>'+
  8598. '<td><input value="'+r.roomName+'" style="width:72px;padding:2px 4px;border:1px solid var(--border);border-radius:3px;font-size:11px;text-align:center;" oninput="qRooms['+i+'].roomName=this.value;qSyncRoomItemsFromRoom('+i+')" onchange="qRenderStep3()"></td>'+
  8599. '<td><input type="number" step="0.01" value="'+((r.length||0).toFixed(2))+'" style="width:64px;padding:2px 4px;border:1px solid var(--border);border-radius:3px;font-size:11px;text-align:center;" oninput="qRooms['+i+'].length=parseFloat(this.value)||0;qRooms['+i+'].area=null;qSyncRoomItemsFromRoom('+i+');qAutoRecalcAreaFromFloor('+i+')" onchange="qRenderStep3()"></td>'+
  8600. '<td><input type="number" step="0.01" value="'+(h>0?h.toFixed(2):'')+'" style="width:58px;padding:2px 4px;border:1px solid var(--border);border-radius:3px;font-size:11px;text-align:center;" title="柜体高度,可由图纸/AI识别或手动修改" oninput="qRooms['+i+'].cabinetHeight=parseFloat(this.value)||0;qRooms['+i+'].height=qRooms['+i+'].cabinetHeight;qSyncRoomItemsFromRoom('+i+');qAutoRecalcAreaFromFloor('+i+')" onchange="qRenderStep3()"></td>'+
  8601. '<td><span style="display:inline-block;width:58px;padding:2px 4px;border:1px solid #E2E8F0;border-radius:3px;font-size:11px;text-align:center;background:#EBF8FF;color:#2B6CB0;font-weight:600;" title="投影面积=柜体面宽×柜体高度(联动层高)">'+(area.toFixed(2))+'</span></td>'+
  8602. '<td style="text-align:center;"><span style="cursor:pointer;font-size:14px;color:var(--danger);" onclick="qDeleteRoom('+i+')">🗑️</span></td>'+
  8603. '</tr>';
  8604. }).join('');
  8605. tbody.innerHTML = rows;
  8606. }
  8607. // 宽度修改时,根据Step1层高自动重算投影面积(宽 × 层高)
  8608. function qAutoRecalcAreaFromFloor(ri) {
  8609. var r = qRooms[ri]; if(!r) return;
  8610. var cabW = parseFloat(r.cabinetWidth) || parseFloat(r.length) || 0;
  8611. var cabH = qFloorHeight > 0 ? parseFloat(qFloorHeight.toFixed(2)) : (parseFloat(r.cabinetHeight) || parseFloat(r.height) || 0);
  8612. if (cabW > 0 && cabH > 0) {
  8613. var newArea = parseFloat((cabW * cabH).toFixed(3));
  8614. r.projectArea = newArea;
  8615. r.area = newArea;
  8616. r.cabinetHeight = cabH;
  8617. r.height = cabH;
  8618. r.cabinetWidth = cabW;
  8619. }
  8620. qSyncRoomItemsFromRoom(ri);
  8621. }
  8622. function qSyncRoomItemsFromRoom(ri){
  8623. var r = qRooms[ri]; if(!r) return;
  8624. var items = qRoomItems[ri]; if(!items||!items.length) return;
  8625. // 柜体宽度用图纸标注,柜体高度由图纸/AI决定(不强制等于层高),面积=宽×高
  8626. var cabW = parseFloat(r.cabinetWidth) || parseFloat(r.length) || 0;
  8627. var cabH = parseFloat(r.cabinetHeight) || parseFloat(r.height) || 0;
  8628. var cabArea = parseFloat(r.projectArea) || parseFloat(r.area) || (cabW > 0 && cabH > 0 ? parseFloat((cabW*cabH).toFixed(3)) : 0);
  8629. var isKitchenRoom = qIsKitchenCabinet(r.cabinetType||r.roomName);
  8630. items.forEach(function(it){
  8631. if(it.name==='柜体'){
  8632. it.width = cabW; it.height = cabH;
  8633. if(state.quoteMode==='ai' && isKitchenRoom){
  8634. // 厨房按总长度计价:同步计费米数并重算总价(总长度×单价)
  8635. var kb = qGetKitchenBillableMeters(r);
  8636. var len = (kb&&kb.billable>0)?kb.billable:(cabW>0?cabW:0);
  8637. if(len>0){
  8638. it.area = +len.toFixed(3);
  8639. it.total = parseFloat((len*it.unitPrice).toFixed(2));
  8640. }
  8641. }else{
  8642. it.area = parseFloat(cabArea.toFixed(3));
  8643. it.total = parseFloat((cabArea*it.unitPrice).toFixed(2));
  8644. }
  8645. }
  8646. });
  8647. }
  8648. function qBuildRoomItems(ri){
  8649. var r = qRooms[ri]; if(!r) return [];
  8650. var name = r.roomName;
  8651. // 柜体宽度用图纸标注,柜体高度由图纸/AI决定(不强制等于层高),不使用硬编码默认高度
  8652. var defaultCabH = (qFloorHeight > 0) ? parseFloat(qFloorHeight.toFixed(2)) : 0;
  8653. var cabW = parseFloat(r.cabinetWidth) || parseFloat(r.length) || 0;
  8654. var cabH = parseFloat(r.cabinetHeight) || parseFloat(r.height) || defaultCabH;
  8655. var projArea = parseFloat(r.projectArea) || parseFloat(r.area) || (cabW > 0 && cabH > 0 ? parseFloat((cabW*cabH).toFixed(3)) : 0);
  8656. var pricing = qGetCabinetPricingRule(r,cabW,cabH);
  8657. var P_CAB = pricing.unitPrice;
  8658. // 默认只包含柜体投影一项,其他配件(门板、五金、抽屉、拉手等)由用户手动添加
  8659. var cabType = r.cabinetType || '柜体';
  8660. if(pricing.category==='橱柜类'){
  8661. return [{name:pricing.material||'地柜',width:+cabW.toFixed(2),height:+cabH.toFixed(2),area:+pricing.quantity.toFixed(3),unitPrice:P_CAB,total:+pricing.total.toFixed(2),note:pricing.method,itemType:'cabinet',billingMethod:'kitchen_item_formula',ruleKey:pricing.ruleKey||'地柜',kitchenTotalWidth:+cabW.toFixed(2)}];
  8662. }
  8663. return [{name:'柜体',width:+cabW.toFixed(2),height:+cabH.toFixed(2),area:+projArea.toFixed(3),unitPrice:P_CAB,total:+pricing.total.toFixed(2),note:pricing.material+' · 按投影面积',itemType:'cabinet',material:pricing.material,ruleKey:pricing.material==='铝格栅板'?'铝格栅板柜体':'生态澳松板柜体'}];
  8664. }
  8665. function qResetAllRoomItems(){
  8666. qConfirmDialog('确定恢复所有房间为默认配件?当前修改将丢失。', function(){
  8667. qRoomItems = [];
  8668. for(var i=0;i<qRooms.length;i++){ qRoomItems[i] = qBuildRoomItems(i); }
  8669. qRenderStep3();
  8670. });
  8671. }
  8672. function qRenderRoomItemTable(ri){
  8673. var r = qRooms[ri]; if(!r) return '';
  8674. if(!qRoomItems[ri]||!qRoomItems[ri].length) qRoomItems[ri] = qBuildRoomItems(ri);
  8675. var items = qRoomItems[ri];
  8676. // 分离柜体基础项和增项
  8677. var cabinetItems = items.filter(function(it){ return !it.isExtra && !it.itemType || it.itemType === 'cabinet' || it.name === '柜体'; });
  8678. var extraItems = items.filter(function(it){ return it.isExtra || it.itemType === 'extra'; });
  8679. // 计算柜体小计
  8680. var cabinetSubTotal = 0;
  8681. cabinetItems.forEach(function(it){
  8682. var t = (it.total !== undefined && it.total !== null) ? it.total : parseFloat((it.area*it.unitPrice).toFixed(2));
  8683. cabinetSubTotal += (t||0);
  8684. });
  8685. cabinetSubTotal = parseFloat(cabinetSubTotal.toFixed(2));
  8686. // 计算增项小计
  8687. var extraSubTotal = 0;
  8688. extraItems.forEach(function(it){
  8689. var t = (it.total !== undefined && it.total !== null) ? it.total : parseFloat((it.area*it.unitPrice).toFixed(2));
  8690. extraSubTotal += (t||0);
  8691. });
  8692. extraSubTotal = parseFloat(extraSubTotal.toFixed(2));
  8693. // 总计
  8694. var subTotal = parseFloat((cabinetSubTotal + extraSubTotal).toFixed(2));
  8695. var roomArea = r.projectArea || 0;
  8696. var ratePct = (qDiscountRate*100).toFixed(0);
  8697. var discounted = parseFloat((subTotal * qDiscountRate).toFixed(2));
  8698. var floatRate = qCurrentFloatRatio || 0;
  8699. var floatLow = parseFloat((discounted * (1 - floatRate)).toFixed(2));
  8700. var floatHigh = parseFloat((discounted * (1 + floatRate)).toFixed(2));
  8701. var isNoDiscount = (qDiscountRate >= 0.999);
  8702. // 渲染单行的函数
  8703. function renderRow(it, idx, isExtraItem){
  8704. var ttl = (it.total !== undefined && it.total !== null) ? parseFloat(it.total) : parseFloat((it.area*it.unitPrice).toFixed(2))||0;
  8705. var wM = (it.width||0);
  8706. var hM = (it.height||0);
  8707. var areaVal = (it.area||0);
  8708. var canDelete = cabinetItems.length + extraItems.length > 1; // 至少保留一行
  8709. var rowStyle = '';
  8710. var namePrefix = isExtraItem ? '<span style="color:#666;font-size:10px;margin-right:3px;">[增项]</span>' : '';
  8711. // 数量显示
  8712. var qtyDisplay = '';
  8713. if(it.multiplier && it.multiplier > 1){
  8714. qtyDisplay = ' ×' + it.multiplier;
  8715. }
  8716. return '<tr style="'+rowStyle+'">'+
  8717. '<td class="name-col">'+namePrefix+'<input value="'+it.name+'" oninput="qRoomItems['+ri+']['+idx+'].name=this.value" onchange="qRenderStep4()"></td>'+
  8718. '<td><input type="text" value="'+wM.toFixed(2)+'" onfocus="this.select()" oninput="var v=parseFloat(this.value);if(!isNaN(v)&&v>=0){var itm=qRoomItems['+ri+']['+idx+'];itm.width=v;if(itm.width>0&&itm.height>0)itm.area=parseFloat((v*itm.height).toFixed(3));itm.total=parseFloat((itm.area*itm.unitPrice).toFixed(2));}" onchange="var v=parseFloat(this.value);if(!isNaN(v)&&v>=0){var itm=qRoomItems['+ri+']['+idx+'];itm.width=v;if(itm.width>0&&itm.height>0)itm.area=parseFloat((v*itm.height).toFixed(3));itm.total=parseFloat((itm.area*itm.unitPrice).toFixed(2));qRenderStep4();}else{this.value=(qRoomItems['+ri+']['+idx+'].width||0).toFixed(2);}" style="width:65px;" placeholder="m"></td>'+
  8719. '<td><input type="text" value="'+hM.toFixed(2)+'" onfocus="this.select()" oninput="var v=parseFloat(this.value);if(!isNaN(v)&&v>=0){var itm=qRoomItems['+ri+']['+idx+'];itm.height=v;if(itm.width>0&&v>0)itm.area=parseFloat((itm.width*v).toFixed(3));itm.total=parseFloat((itm.area*itm.unitPrice).toFixed(2));}" onchange="var v=parseFloat(this.value);if(!isNaN(v)&&v>=0){var itm=qRoomItems['+ri+']['+idx+'];itm.height=v;if(itm.width>0&&v>0)itm.area=parseFloat((itm.width*v).toFixed(3));itm.total=parseFloat((itm.area*itm.unitPrice).toFixed(2));qRenderStep4();}else{this.value=(qRoomItems['+ri+']['+idx+'].height||0).toFixed(2);}" style="width:65px;" placeholder="m"></td>'+
  8720. '<td><input type="text" value="'+(areaVal.toFixed(2))+'" onfocus="this.select()" oninput="var v=parseFloat(this.value);if(!isNaN(v)&&v>=0){var itm=qRoomItems['+ri+']['+idx+'];itm.area=v;itm.total=parseFloat((v*itm.unitPrice).toFixed(2));}" onchange="var v=parseFloat(this.value);if(!isNaN(v)&&v>=0){var itm=qRoomItems['+ri+']['+idx+'];itm.area=v;itm.total=parseFloat((v*itm.unitPrice).toFixed(2));qRenderStep4();}else{this.value=(qRoomItems['+ri+']['+idx+'].area||0).toFixed(2);}" style="width:70px;font-weight:600;">'+qtyDisplay+'</td>'+
  8721. '<td><input type="text" value="'+it.unitPrice+'" onfocus="this.select()" oninput="var v=parseFloat(this.value);if(!isNaN(v)&&v>=0){var itm=qRoomItems['+ri+']['+idx+'];itm.unitPrice=v;itm.total=parseFloat((itm.area*v).toFixed(2));itm._priceEdited=true;}" onchange="var v=parseFloat(this.value);if(!isNaN(v)&&v>=0){var itm=qRoomItems['+ri+']['+idx+'];itm.unitPrice=v;itm.total=parseFloat((itm.area*v).toFixed(2));itm._priceEdited=true;qRenderStep4();}else{this.value=qRoomItems['+ri+']['+idx+'].unitPrice;}" style="width:70px;"></td>'+
  8722. '<td style="font-weight:600;color:#333;">¥'+ttl.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2})+'</td>'+
  8723. '<td class="note-col"><input value="'+(it.note||'')+'" oninput="qRoomItems['+ri+']['+idx+'].note=this.value" style="font-size:10px;color:var(--text-muted);width:100%;min-width:80px;"></td>'+
  8724. '<td class="row-actions">'+(canDelete?'<button class="btn-del-row" onclick="qDeleteRoomItem('+ri+','+idx+')" title="删除此行">✕</button>':'')+'</td>'+
  8725. '</tr>';
  8726. }
  8727. // 渲染柜体行
  8728. var cabinetRows = cabinetItems.map(function(it, idx){
  8729. // 找到在原始items数组中的索引
  8730. var realIdx = items.indexOf(it);
  8731. return renderRow(it, realIdx, false);
  8732. }).join('');
  8733. // 渲染增项分隔标题和增项行
  8734. var extraSectionHtml = '';
  8735. var extraRows = '';
  8736. if(extraItems.length > 0){
  8737. extraItems.forEach(function(it){
  8738. var realIdx = items.indexOf(it);
  8739. extraRows += renderRow(it, realIdx, true);
  8740. });
  8741. extraSectionHtml =
  8742. '<tr style="background:#F5F5F5;"><td colspan="8" style="padding:6px 10px;font-weight:600;color:#555;font-size:12px;border-top:1px solid #ddd;">' +
  8743. '增值项(五金/配件)' +
  8744. '</td></tr>' +
  8745. extraRows +
  8746. '<tr class="row-subtotal" style="background:#FAFAFA;"><td colspan="5" style="text-align:right;font-weight:600;color:#555;">增值项小计:</td><td style="font-weight:700;color:#333;">¥'+extraSubTotal.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2})+'</td><td colspan="2"></td></tr>';
  8747. }
  8748. // 生成品类下拉框:厨房使用橱柜类延米规则,其他柜体使用衣柜类投影规则。
  8749. var extraOptions = '<option value="">-- 选择增值项 --</option>';
  8750. var roomRuleGroup = qIsKitchenCabinet(r.cabinetType||r.roomName) ? 'kitchen' : 'wardrobe';
  8751. var roomRuleList = (DEFAULT_RULES && DEFAULT_RULES[roomRuleGroup]) || [];
  8752. roomRuleList.forEach(function(ex, ei){
  8753. if(roomRuleGroup==='wardrobe' && (ex.name==='生态澳松板柜体'||ex.name==='铝格栅板柜体')) return;
  8754. var price = parseFloat(ex.value!==undefined?ex.value:ex.price)||0;
  8755. extraOptions += '<option value="'+roomRuleGroup+':'+ei+'" data-price="'+price+'" data-method="'+ex.method+'" data-unit="'+ex.unit+'">'+ex.name+'('+ex.method+':¥'+price+'/'+ex.unit.replace('元/','')+')</option>';
  8756. });
  8757. var sourceBadge = '';
  8758. if (r._isMulti && r.sourceSheetName) {
  8759. sourceBadge = '<span style="font-size:10px;padding:1px 7px;border-radius:4px;background:#eee;color:#555;font-weight:500;">📄 '+r.sourceSheetName+'</span>';
  8760. }
  8761. var discountArrow = isNoDiscount
  8762. ? '<span style="color:#666;">原价</span>'
  8763. : '<span style="color:#666;">×'+ratePct+'%</span>';
  8764. return '<div style="background:#fff;border:1px solid #ddd;border-radius:6px;margin-bottom:10px;overflow:hidden;">'+
  8765. '<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 14px;background:#F9F9F9;border-bottom:1px solid #ddd;flex-wrap:wrap;gap:8px;">'+
  8766. '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">'+
  8767. '<span style="font-weight:700;font-size:15px;">'+ (r.cabinetType || r.roomName || '柜体') +'</span>'+
  8768. sourceBadge+
  8769. '<span style="font-size:11px;color:#888;">| '+r.layoutStyle+' | 投影面积 '+(roomArea||0).toFixed(2)+'㎡ |</span>'+
  8770. '<span style="font-size:11px;background:#eee;color:#555;padding:1px 8px;border-radius:4px;">投影单价 ¥'+qGetCabinetBasePrice(r.cabinetType)+'/㎡</span>'+
  8771. '</div>'+
  8772. '<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">'+
  8773. '<span style="font-size:12px;font-weight:600;color:#333;">柜体:<span style="color:#333;">¥'+cabinetSubTotal.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2})+'</span></span>'+
  8774. (extraSubTotal>0?'<span style="font-size:11px;color:#666;">+增项 ¥'+extraSubTotal.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2})+'</span>':'')+
  8775. '<span style="font-size:12px;font-weight:700;color:#333;">= ¥'+subTotal.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2})+'</span>'+
  8776. discountArrow+
  8777. '<span style="font-size:13px;font-weight:700;color:#222;">最终价:¥'+discounted.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2})+'</span>'+
  8778. '<div style="display:flex;align-items:center;gap:4px;background:#fff;border:1px solid var(--border);border-radius:6px;padding:2px;">'+
  8779. '<select id="q-extra-sel-'+ri+'" style="border:none;outline:none;font-size:12px;padding:3px 6px;background:transparent;min-width:140px;max-width:200px;">'+extraOptions+'</select>'+
  8780. '<span style="font-size:10px;color:#718096;">数量:</span>'+
  8781. '<input type="number" id="q-extra-qty-'+ri+'" value="1" min="0.1" step="0.1" style="width:45px;border:none;outline:none;font-size:12px;padding:3px 4px;text-align:center;border-left:1px solid var(--border);" title="数量">'+
  8782. '<button class="btn btn-sm btn-primary" onclick="qAddExtraFromRule('+ri+')" style="padding:3px 10px;font-size:12px;border-radius:4px;">+添加</button>'+
  8783. '</div>'+
  8784. '<button class="btn btn-sm btn-outline" onclick="qAddRoomItem('+ri+')" title="手动添加空白行">✏️自定义</button>'+
  8785. '<span style="cursor:pointer;font-size:16px;color:var(--danger);" onclick="qDeleteRoom('+ri+')">🗑️</span>'+
  8786. '</div>'+
  8787. '</div>'+
  8788. '<div class="detail-table-wrap">'+
  8789. '<table class="detail-table"><thead><tr>'+
  8790. '<th class="name-col">品名</th><th style="width:65px;">宽(m)</th><th style="width:65px;">高(m)</th><th style="width:70px;">面积/数量</th><th style="width:65px;">单价</th><th style="width:70px;">小计</th><th class="note-col">备注</th><th style="width:30px;"></th>'+
  8791. '</tr></thead><tbody>'+
  8792. // ① 柜体基础项
  8793. '<tr style="background:#F5F5F5;"><td colspan="8" style="padding:6px 10px;font-weight:600;color:#555;font-size:12px;border-top:1px solid #ddd;">' +
  8794. '柜体基础项(投影面积 '+(roomArea||0).toFixed(2)+'㎡)' +
  8795. '</td></tr>'+
  8796. cabinetRows+
  8797. '<tr class="row-subtotal" style="background:#FAFAFA;"><td colspan="5" style="text-align:right;font-weight:600;color:#555;">柜体基础小计:</td><td style="font-weight:700;color:#333;">¥'+cabinetSubTotal.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2})+'</td><td colspan="2"></td></tr>'+
  8798. // ② 增值项
  8799. extraSectionHtml+
  8800. // ③ 合计与折扣
  8801. '<tr style="background:#F0F0F0;border-top:1px solid #ddd;"><td colspan="5" style="text-align:right;font-weight:700;color:#333;">本柜体合计金额(柜体+增项):</td><td style="font-weight:700;color:#333;font-size:14px;">¥'+subTotal.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2})+'</td><td colspan="2"></td></tr>'+
  8802. (isNoDiscount
  8803. ? '<tr style="background:#FAFAFA;"><td colspan="5" style="text-align:right;font-weight:600;color:#666;">折扣:</td><td style="font-weight:600;color:#666;">原价(无折扣)</td><td colspan="2"></td></tr>'
  8804. : '<tr style="background:#FAFAFA;"><td colspan="5" style="text-align:right;font-weight:600;color:#666;">折扣(×'+ratePct+'%):</td><td style="font-weight:600;color:#666;">-¥'+(subTotal-discounted).toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2})+'</td><td colspan="2"></td></tr>'
  8805. )+
  8806. '<tr class="row-discounted" style="background:#E8E8E8;"><td colspan="5" style="text-align:right;font-weight:700;color:#222;">本柜体最终价:</td><td style="font-weight:700;color:#222;font-size:14px;">¥'+discounted.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2})+'</td><td colspan="2"></td></tr>'+
  8807. '</tbody></table>'+
  8808. '</div>'+
  8809. '</div>';
  8810. }
  8811. /* 第三步柜体明细新版排版:基础项与增值项在同一张规范表格中展示 */
  8812. function qRenderRoomItemTable(ri){
  8813. var r = qRooms[ri];
  8814. if(!r) return '';
  8815. if(!qRoomItems[ri] || !qRoomItems[ri].length) qRoomItems[ri] = qBuildRoomItems(ri);
  8816. var items = qRoomItems[ri];
  8817. var cabinetItems = items.filter(function(it){
  8818. return it.itemType === 'cabinet' || it.name === '柜体' || (!it.isExtra && !it.itemType);
  8819. });
  8820. var extraItems = items.filter(function(it){ return items.indexOf(it) >= 0 && cabinetItems.indexOf(it) < 0; });
  8821. var money = function(v){ return qFormatMoney ? qFormatMoney(Number(v)||0) : '¥'+(Number(v)||0).toFixed(2); };
  8822. var itemTotal = function(it){
  8823. var saved = Number(it.total);
  8824. return isFinite(saved) ? saved : (Number(it.area)||0) * (Number(it.unitPrice)||0);
  8825. };
  8826. var cabinetTotal = cabinetItems.reduce(function(s,it){ return s + itemTotal(it); },0);
  8827. var extraTotal = extraItems.reduce(function(s,it){ return s + itemTotal(it); },0);
  8828. var subtotal = cabinetTotal + extraTotal;
  8829. var finalTotal = subtotal * (Number(qDiscountRate)||1);
  8830. var roomWidth = Number(r.width || (cabinetItems[0] && cabinetItems[0].width) || 0);
  8831. var roomHeight = Number(r.height || (cabinetItems[0] && cabinetItems[0].height) || 0);
  8832. var roomArea = Number(r.projectArea || (cabinetItems[0] && cabinetItems[0].area) || roomWidth*roomHeight || 0);
  8833. var esc = function(v){ return qEscapeHtml(String(v == null ? '' : v)); };
  8834. function editNumber(idx, field, value, widthHeight, isBase){
  8835. var code = "var v=parseFloat(this.value);if(!isNaN(v)&&v>=0){var x=qRoomItems["+ri+"]["+idx+"];x."+field+"=v;";
  8836. if(widthHeight) code += "if((x.width||0)>0&&(x.height||0)>0)x.area=parseFloat((x.width*x.height).toFixed(3));";
  8837. if(widthHeight && isBase) code += "qRooms["+ri+"].width=x.width;qRooms["+ri+"].length=x.width;qRooms["+ri+"].height=x.height;qRooms["+ri+"].cabinetWidth=x.width;qRooms["+ri+"].cabinetHeight=x.height;qRooms["+ri+"].projectArea=x.area;qRooms["+ri+"].area=x.area;";
  8838. code += "x.total=parseFloat(((x.area||0)*(x.unitPrice||0)).toFixed(2));qRenderStep4();}else{qRenderStep4();}";
  8839. return '<input type="text" inputmode="decimal" value="'+Number(value||0).toFixed(field==='area'?3:2)+'" onfocus="this.select()" onchange="'+code+'">';
  8840. }
  8841. function row(it, isExtra){
  8842. var idx = items.indexOf(it);
  8843. var total = itemTotal(it);
  8844. var billingType = it.billingType || (it.billingMethod ? qClassifyMethod(it.billingMethod) : (((it.width||0)>0 && (it.height||0)>0) ? 'area' : 'count'));
  8845. var areaText = (billingType === 'area' || !isExtra) && Number(it.area||0) > 0 ? Number(it.area||0).toFixed(3) : '—';
  8846. var qtyValue = Number(it.count);
  8847. if(!isFinite(qtyValue) || qtyValue <= 0) qtyValue = Number(it.multiplier||0);
  8848. if((!isFinite(qtyValue) || qtyValue <= 0) && billingType !== 'area') qtyValue = Number(it.area||0);
  8849. var qtyText = billingType === 'area' || qtyValue <= 0 ? '—' : (Number.isInteger(qtyValue) ? String(qtyValue) : qtyValue.toFixed(2));
  8850. return '<tr class="'+(isExtra?'q-extra-row':'q-base-row')+'">'+
  8851. '<td><input class="q-name-input" value="'+esc(it.name || (isExtra?'增值项':'柜体'))+'" onfocus="this.select()" onchange="qRoomItems['+ri+']['+idx+'].name=this.value;qRenderStep4()"></td>'+
  8852. '<td>'+editNumber(idx,'width',it.width,true,!isExtra)+'</td>'+
  8853. '<td>'+editNumber(idx,'height',it.height,true,!isExtra)+'</td>'+
  8854. '<td><span class="readonly-value">'+areaText+'</span></td>'+
  8855. '<td><span class="readonly-value">'+qtyText+'</span></td>'+
  8856. '<td>'+editNumber(idx,'unitPrice',it.unitPrice,false,!isExtra)+'</td>'+
  8857. '<td style="font-weight:700;color:'+(isExtra?'#C05621':'#E53E3E')+'">'+money(total)+'</td>'+
  8858. '<td><input class="q-note-input" value="'+esc(qMaterialOnlyNote(it.note,r,it))+'" onfocus="this.select()" onchange="qRoomItems['+ri+']['+idx+'].note=qMaterialOnlyNote(this.value,qRooms['+ri+'],qRoomItems['+ri+']['+idx+']);this.value=qRoomItems['+ri+']['+idx+'].note"></td>'+
  8859. '<td>'+(isExtra?'<button class="btn-del-row" onclick="qDeleteRoomItem('+ri+','+idx+')" title="删除此增值项">✕</button>':'—')+'</td>'+
  8860. '</tr>';
  8861. }
  8862. var rows = cabinetItems.map(function(it){ return row(it,false); }).join('') +
  8863. extraItems.map(function(it){ return row(it,true); }).join('');
  8864. var extraOptions = '<option value="">-- 选择增值项 --</option>';
  8865. if(DEFAULT_RULES && DEFAULT_RULES.extra){
  8866. DEFAULT_RULES.extra.forEach(function(ex,ei){
  8867. var price = Number(ex.value !== undefined ? ex.value : ex.price) || 0;
  8868. extraOptions += '<option value="'+ei+'">'+esc(ex.name)+'('+esc(ex.method||'按个数')+','+money(price)+'/'+esc(ex.unit||'个')+')</option>';
  8869. });
  8870. }
  8871. var discountText = qDiscountRate >= .999 ? '原价(无折扣)' : '折扣 '+Math.round(qDiscountRate*100)+'%,优惠 '+money(subtotal-finalTotal);
  8872. var title = esc(r.cabinetType || r.roomName || '柜体');
  8873. return '<section class="q-price-room-card">'+
  8874. '<div class="q-price-room-title"><strong>'+(ri+1)+'. '+title+'</strong><button class="btn btn-sm btn-outline" onclick="qDeleteRoom('+ri+')">删除柜体</button></div>'+
  8875. '<div class="q-price-room-metrics">'+
  8876. '<div class="q-price-metric"><span>宽度</span><b>'+roomWidth.toFixed(2)+'m</b></div>'+
  8877. '<div class="q-price-metric"><span>柜体高度</span><b>'+roomHeight.toFixed(2)+'m</b></div>'+
  8878. '<div class="q-price-metric area"><span>投影面积</span><b>'+roomArea.toFixed(3)+'m²</b></div>'+
  8879. '</div>'+
  8880. '<div class="q-price-room-meta"><span>布局:'+esc(r.layoutStyle||'一字型')+' · 已从第二步导入基础项 '+cabinetItems.length+' 项、增值项 '+extraItems.length+' 项</span></div>'+
  8881. '<div class="q-price-detail-wrap"><table class="q-price-detail-table"><colgroup><col style="width:15%"><col style="width:8%"><col style="width:8%"><col style="width:10%"><col style="width:9%"><col style="width:10%"><col style="width:13%"><col style="width:21%"><col style="width:6%"></colgroup>'+
  8882. '<thead><tr><th>项目</th><th>宽(M)</th><th>高(M)</th><th>面积(㎡)</th><th>数量</th><th>单价</th><th>总价</th><th>备注</th><th>操作</th></tr></thead><tbody>'+rows+
  8883. '<tr class="q-price-total-row"><td colspan="6" style="text-align:right">本柜体合计(基础项 '+money(cabinetTotal)+(extraItems.length?' + 增值项 '+money(extraTotal):'')+')</td><td>'+money(subtotal)+'</td><td colspan="2"></td></tr>'+
  8884. '<tr class="q-price-discount-row"><td colspan="6" style="text-align:right">'+discountText+'</td><td>'+money(finalTotal)+'</td><td colspan="2"></td></tr>'+
  8885. '</tbody></table></div>'+
  8886. '</section>';
  8887. }
  8888. // 第二步“柜体产品覆盖信息”是当前报价单明细的唯一数据源。
  8889. // 将第二步已经填写的基础项/增值项完整转换给第三步,避免第三步再次选择增值项。
  8890. function qBuildPricingItemsFromStep2(room, ri){
  8891. if(!room || !Array.isArray(room._detailItems) || room._detailItems.length === 0){
  8892. return qBuildRoomItems(ri);
  8893. }
  8894. return room._detailItems.filter(function(item){
  8895. if(item && item.isBase) return true;
  8896. if(!item) return false;
  8897. return (parseFloat(item.width)||0) > 0 ||
  8898. (parseFloat(item.height)||0) > 0 ||
  8899. (parseFloat(item.area)||0) > 0 ||
  8900. (parseFloat(item.qty)||0) > 0 ||
  8901. (parseFloat(item.total)||0) > 0;
  8902. }).map(function(item){
  8903. var methodType = item.method ? qClassifyMethod(item.method) : (item.isBase ? 'area' : 'count');
  8904. var width = parseFloat(item.width) || 0;
  8905. var height = parseFloat(item.height) || 0;
  8906. var qty = parseFloat(item.qty);
  8907. if(!isFinite(qty)) qty = 0;
  8908. var area = parseFloat(item.area) || 0;
  8909. var billingQty = methodType === 'area' ? (area || width * height) : qty;
  8910. var unitPrice = parseFloat(item.price) || 0;
  8911. var total = parseFloat(item.total);
  8912. if(!isFinite(total)) total = billingQty * unitPrice;
  8913. return {
  8914. name: item.name || (item.isBase ? '柜体' : '增值项'),
  8915. ruleKey: item.ruleKey || item.name,
  8916. itemType: item.isBase ? 'cabinet' : 'extra',
  8917. isExtra: !item.isBase,
  8918. width: width,
  8919. height: height,
  8920. area: parseFloat((billingQty || 0).toFixed(3)),
  8921. count: methodType === 'count' ? qty : undefined,
  8922. multiplier: qty || 1,
  8923. unitPrice: unitPrice,
  8924. total: parseFloat((total || 0).toFixed(2)),
  8925. note: qMaterialOnlyNote(item.note, room, item),
  8926. billingMethod: item.method || '',
  8927. billingType: methodType,
  8928. _priceEdited: true,
  8929. _fromStep2: true
  8930. };
  8931. });
  8932. }
  8933. // 备注只保留实际使用的材质名称,计价方式、公式和门板类型配置不写入备注。
  8934. function qMaterialOnlyNote(note, room, item){
  8935. var source = String(note || '');
  8936. var materials = ['生态澳松板','铝格栅板','PET板','PET','水晶板','岩板','石英石','不锈钢','实木','烤漆板','烤漆','双饰面板','双饰面','玻璃','铝框'];
  8937. var found = [];
  8938. materials.forEach(function(material){
  8939. if(source.indexOf(material) !== -1 && found.indexOf(material) === -1) found.push(material);
  8940. });
  8941. if(found.length) return found.join('、');
  8942. if(item && item.isBase && room){
  8943. var pricing = qGetCabinetPricingRule(room, Number(item.width)||0, Number(item.height)||0);
  8944. if(pricing && pricing.material) return qMaterialOnlyNote(pricing.material, null, null) || pricing.material;
  8945. }
  8946. if(source && source.length <= 18 && !/(按个数|按投影面积|按延米|单价|类型|门板配置|厚度|门芯|\*|×|公式)/.test(source)) return source;
  8947. return '';
  8948. }
  8949. function qSyncStep2DetailsToPricing(){
  8950. if(!Array.isArray(qRooms)) return;
  8951. qRoomItems = qRooms.map(function(room, ri){
  8952. return qBuildPricingItemsFromStep2(room, ri);
  8953. });
  8954. }
  8955. function qDeleteRoomItem(ri, idx){
  8956. if(!qRoomItems[ri]||qRoomItems[ri].length<=1) return;
  8957. qRoomItems[ri].splice(idx,1); qRenderStep4();
  8958. }
  8959. function qAddRoomItem(ri){
  8960. if(!qRoomItems[ri]) qRoomItems[ri] = [];
  8961. qRoomItems[ri].push({name:'新配件',width:0,height:0,area:1,unitPrice:0,total:0,note:''});
  8962. qRenderStep4();
  8963. }
  8964. // 从规则配置增项中选择添加到柜体配件
  8965. function qAddExtraFromRule(ri){
  8966. var sel = document.getElementById('q-extra-sel-'+ri);
  8967. var qtyInput = document.getElementById('q-extra-qty-'+ri);
  8968. if(!sel || !qtyInput) return;
  8969. var parts=String(sel.value||'').split(':');
  8970. var ruleGroup=parts[0];
  8971. var ei=parseInt(parts[1]);
  8972. var ruleList=(DEFAULT_RULES&&DEFAULT_RULES[ruleGroup])||[];
  8973. if((ruleGroup!=='wardrobe'&&ruleGroup!=='kitchen') || isNaN(ei) || ei < 0 || !ruleList[ei]){
  8974. showToast('请先选择增值项', 'info');
  8975. return;
  8976. }
  8977. var multiplier = parseFloat(qtyInput.value) || 1;
  8978. if(multiplier <= 0) multiplier = 1;
  8979. var ex = ruleList[ei];
  8980. var price = parseFloat(ex.value!==undefined?ex.value:ex.price) || 0;
  8981. var area = multiplier; // 默认按个数
  8982. var wM = 0, hM = 0;
  8983. var type = qClassifyMethod(ex.method);
  8984. if(type === 'area'){
  8985. // 按面积:需要宽高,这里暂时给一个默认1㎡×数量
  8986. area = 1 * multiplier;
  8987. wM = 1; hM = 1;
  8988. } else if(type === 'length'){
  8989. // 按延米:默认1m×数量
  8990. area = 1 * multiplier;
  8991. hM = 1;
  8992. }
  8993. var total = parseFloat((area * price).toFixed(2));
  8994. var kitchenTotalWidth=0;
  8995. if(ruleGroup==='kitchen'){
  8996. var kitchenRoom=qRooms&&qRooms[ri]||{};
  8997. kitchenTotalWidth=parseFloat(kitchenRoom.cabinetWidth)||parseFloat(kitchenRoom.length)||0;
  8998. if(state.quoteMode==='ai'){ var _kb3=qGetKitchenBillableMeters(kitchenRoom); if(_kb3&&_kb3.billable>0) kitchenTotalWidth=_kb3.billable; }
  8999. var kitchenHeight=parseFloat(kitchenRoom.cabinetHeight)||parseFloat(kitchenRoom.height)||parseFloat(qFloorHeight)||0;
  9000. var kitchenCalc=qCalculateKitchenItem(ex.name,kitchenTotalWidth,kitchenHeight,multiplier);
  9001. area=kitchenCalc.quantity;price=kitchenCalc.unitPrice;total=kitchenCalc.total;
  9002. wM=kitchenTotalWidth;hM=(ex.name==='见光板'?kitchenHeight:0);
  9003. }
  9004. if(!qRoomItems[ri]) qRoomItems[ri] = [];
  9005. qRoomItems[ri].push({
  9006. name: ex.name,
  9007. ruleKey: ex.name,
  9008. itemType: 'extra',
  9009. isExtra: true,
  9010. multiplier: multiplier,
  9011. width: wM,
  9012. height: hM,
  9013. area: area,
  9014. unitPrice: price,
  9015. total: total,
  9016. count: ruleGroup==='kitchen' ? multiplier : undefined,
  9017. kitchenTotalWidth: ruleGroup==='kitchen' ? kitchenTotalWidth : undefined,
  9018. billingMethod: ruleGroup==='kitchen' ? 'kitchen_item_formula' : undefined,
  9019. note: ruleGroup==='kitchen' ? qCalculateKitchenItem(ex.name,kitchenTotalWidth,hM,multiplier).method : (ex.method + '(Step3添加:×' + multiplier + ')')
  9020. });
  9021. // 重置选择
  9022. sel.value = '';
  9023. qtyInput.value = '1';
  9024. qRenderStep4();
  9025. showToast('✅ 已添加:' + ex.name + ' ×' + multiplier, 'success');
  9026. }
  9027. // Step 3: Confirm & Correct (重写 — 详细子项明细)
  9028. function qRenderStep3(){
  9029. // 修正空间位置名称:当roomName与cabinetType撞名时自动替换为对应空间名
  9030. qFixRoomSpaceNames(qRooms);
  9031. qRenderSpaceDimensions();
  9032. // 检测qRoomItems是否为新格式:每个房间应至少有"柜体"一项
  9033. // 允许增项(isExtra=true或itemType==='extra')存在
  9034. var needRebuild = false;
  9035. if(!qRoomItems || !qRoomItems.length){
  9036. needRebuild = true;
  9037. } else {
  9038. for(var _chk=0; _chk<qRoomItems.length; _chk++){
  9039. var _items = qRoomItems[_chk];
  9040. if(!_items || _items.length === 0){ needRebuild = true; break; }
  9041. // 检查是否有柜体项
  9042. var hasCabinet = false;
  9043. for(var _ci=0; _ci<_items.length; _ci++){
  9044. var _it = _items[_ci];
  9045. if(!_it) continue;
  9046. if(_it.itemType === 'cabinet' || _it.name === '柜体' || (!_it.isExtra && !_it.itemType)){
  9047. hasCabinet = true;
  9048. break;
  9049. }
  9050. }
  9051. if(!hasCabinet){ needRebuild = true; break; }
  9052. }
  9053. }
  9054. if(needRebuild){
  9055. // 先检查是否有_selectedExtras需要保留
  9056. var savedExtras = [];
  9057. for(var _se=0; _se<qRooms.length; _se++){
  9058. if(qRooms[_se] && qRooms[_se]._selectedExtras){
  9059. savedExtras[_se] = JSON.parse(JSON.stringify(qRooms[_se]._selectedExtras));
  9060. }
  9061. }
  9062. qRoomItems = [];
  9063. for(var _rb=0; _rb<qRooms.length; _rb++){
  9064. qRoomItems[_rb] = qBuildRoomItems(_rb);
  9065. // 恢复已选增项
  9066. if(savedExtras[_rb] && savedExtras[_rb].length > 0){
  9067. savedExtras[_rb].forEach(function(se){
  9068. var seType = qClassifyMethod(se.method);
  9069. var wM = se.widthM || 0;
  9070. var hM = se.heightM || 0;
  9071. var multiplier = se.multiplier || 1;
  9072. var itemArea = se.qty;
  9073. qRoomItems[_rb].push({
  9074. name: se.name,
  9075. ruleKey: se.name,
  9076. itemType: 'extra',
  9077. isExtra: true,
  9078. multiplier: multiplier,
  9079. width: wM,
  9080. height: hM,
  9081. area: itemArea,
  9082. unitPrice: se.price,
  9083. total: parseFloat((itemArea * se.price).toFixed(2)),
  9084. note: se.method + '(Step2增项)'
  9085. });
  9086. });
  9087. }
  9088. }
  9089. }
  9090. qSyncRoomItemsWithRules(); // 规则配置变更后同步最新单价
  9091. var section = document.getElementById('q-room-items-section');
  9092. if(section) section.innerHTML = qRooms.map(function(r,i){ return qRenderRoomItemTable(i); }).join('');
  9093. var grandOriginal = 0;
  9094. var grandDiscounted = 0;
  9095. for(var i=0;i<qRooms.length;i++){
  9096. var items = qRoomItems[i]||[];
  9097. var roomSub = 0;
  9098. items.forEach(function(it){ roomSub += parseFloat((it.total||0).toFixed(2)); });
  9099. grandOriginal += parseFloat(roomSub.toFixed(2));
  9100. grandDiscounted += parseFloat((roomSub * qDiscountRate).toFixed(2));
  9101. }
  9102. grandOriginal = parseFloat(grandOriginal.toFixed(2));
  9103. grandDiscounted = parseFloat(grandDiscounted.toFixed(2));
  9104. var totalDiscount = parseFloat((grandOriginal - grandDiscounted).toFixed(2));
  9105. var gtOrig = document.getElementById('q-gt-original'); if(gtOrig) gtOrig.textContent = '¥'+grandOriginal.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2});
  9106. var gtDisc = document.getElementById('q-gt-discounted'); if(gtDisc) gtDisc.textContent = '¥'+grandDiscounted.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2});
  9107. var gtRate = document.getElementById('q-gt-rate'); if(gtRate) gtRate.textContent = (qDiscountRate*100).toFixed(0)+'%';
  9108. var gtCount = document.getElementById('q-gt-room-count'); if(gtCount) gtCount.textContent = qRooms.length;
  9109. var discInput = document.getElementById('q-global-discount');
  9110. if(discInput&&parseFloat(discInput.value)!==qDiscountRate) discInput.value = (Math.round(qDiscountRate*100)/100).toFixed(2);
  9111. var gtSaving = document.getElementById('q-gt-saving');
  9112. if(gtSaving) gtSaving.textContent = '¥'+totalDiscount.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2});
  9113. }
  9114. function qDeleteRoom(idx){
  9115. if(qRooms.length<=1){return;}
  9116. qRooms.splice(idx,1);qExtras.splice(idx,1);qRoomItems.splice(idx,1);
  9117. qRenderStep3();
  9118. }
  9119. function qAddRoom(){
  9120. var newCabH = qFloorHeight > 0 ? parseFloat(qFloorHeight.toFixed(2)) : 0;
  9121. var newW = 2.0;
  9122. var newArea = newCabH > 0 ? parseFloat((newW * newCabH).toFixed(3)) : 0;
  9123. qRooms.push({roomName:'新空间',cabinetType:'储物柜',layoutStyle:'一字型',projectArea:newArea,doorType:'普通平板', length:newW, cabinetWidth:newW, cabinetHeight:newCabH, width:newW, height:newCabH, area:newArea, cabinetRatio:0.55, fillRate:0.50, doorRatio:0.50, bayWindow:false, _isCabinet:true});
  9124. qExtras.push({roomIndex:qRooms.length-1,items:[]});
  9125. qRoomItems.push(qBuildRoomItems(qRooms.length-1));
  9126. qRenderStep3();
  9127. }
  9128. // Extras Modal
  9129. function qOpenExtrasModal(ri){
  9130. const modal=document.createElement('div');
  9131. modal.className='modal-overlay';modal.id='q-extras-modal';
  9132. modal.setAttribute('onclick','if(event.target===this)qCloseExtrasModal()');
  9133. modal.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:1000;';
  9134. const items=qExtras[ri]?.items||[];
  9135. const list=items.length>0?items.map((item,idx)=>
  9136. '<div style="display:flex;align-items:center;gap:10px;padding:8px 12px;border-bottom:1px solid var(--border);">'+
  9137. '<div style="flex:1;"><div style="font-weight:600;font-size:13px;">'+item.name+'</div><div style="font-size:11px;color:var(--text-muted);">'+item.spec+' × '+item.qty+item.unit+' @ ¥'+item.price+'</div></div>'+
  9138. '<div style="font-weight:600;color:var(--primary);font-size:13px;">¥'+(item.price*item.qty).toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2})+'</div>'+
  9139. '<button class="btn btn-danger btn-sm" onclick="qRemoveExtra('+ri+','+idx+');qRefreshExtrasModal()">删除</button>'+
  9140. '</div>'
  9141. ).join(''):'<div style="text-align:center;padding:20px;color:var(--text-muted);font-size:13px;">暂无非标增项</div>';
  9142. modal.innerHTML=
  9143. '<div class="modal-content" style="background:#fff;border-radius:12px;width:560px;max-height:80vh;overflow:hidden;box-shadow:0 20px 60px rgba(0,0,0,0.3);" onclick="event.stopPropagation();">'+
  9144. '<div style="padding:14px 18px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;"><h3 style="margin:0;font-size:15px;">🔧 非标增项管理</h3><button onclick="qCloseExtrasModal()" style="background:none;border:none;font-size:18px;cursor:pointer;color:var(--text-muted);">×</button></div>'+
  9145. '<div style="padding:18px;overflow-y:auto;max-height:calc(80vh - 100px);">'+
  9146. '<div style="margin-bottom:12px;"><label style="font-size:12px;color:var(--text-muted);margin-bottom:3px;display:block;">选择空间</label><select id="q-extras-room-select" onchange="qRefreshExtrasModal()" style="width:100%;padding:6px 8px;border:1px solid var(--border);border-radius:6px;font-size:13px;">'+qRooms.map((r,i)=>'<option value="'+i+'"'+(i===ri?' selected':'')+'>'+r.roomName+'</option>').join('')+'</select></div>'+
  9147. '<div style="margin-bottom:16px;padding:12px;background:#f8f9fa;border-radius:8px;">'+
  9148. '<div style="font-size:13px;font-weight:600;margin-bottom:10px;">📝 添加新增项</div>'+
  9149. '<div style="display:grid;grid-template-columns:1fr 1fr 60px 50px 60px;gap:6px;">'+
  9150. '<input type="text" id="q-extras-name" placeholder="项目名称" style="padding:4px 6px;border:1px solid var(--border);border-radius:4px;font-size:12px;">'+
  9151. '<input type="text" id="q-extras-spec" placeholder="规格" style="padding:4px 6px;border:1px solid var(--border);border-radius:4px;font-size:12px;">'+
  9152. '<input type="number" id="q-extras-qty" placeholder="数量" value="1" style="padding:4px 6px;border:1px solid var(--border);border-radius:4px;font-size:12px;">'+
  9153. '<input type="text" id="q-extras-unit" placeholder="单位" value="个" style="padding:4px 6px;border:1px solid var(--border);border-radius:4px;font-size:12px;">'+
  9154. '<input type="number" id="q-extras-price" placeholder="单价" style="padding:4px 6px;border:1px solid var(--border);border-radius:4px;font-size:12px;">'+
  9155. '</div>'+
  9156. '<button class="btn btn-primary btn-sm" onclick="qAddExtraItem()" style="margin-top:8px;">+ 添加</button>'+
  9157. '</div>'+
  9158. '<div style="border:1px solid var(--border);border-radius:8px;overflow:hidden;">'+
  9159. '<div style="padding:8px 10px;background:#F7FAFC;font-size:12px;font-weight:600;display:flex;justify-content:space-between;"><span>已添加增项</span><span id="q-extras-subtotal">小计:¥'+items.reduce((s,i)=>s+i.price*i.qty,0).toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2})+'</span></div>'+
  9160. '<div id="q-extras-list">'+list+'</div>'+
  9161. '</div>'+
  9162. '</div>'+
  9163. '<div style="padding:12px 18px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:10px;">'+
  9164. '<button class="btn" onclick="qCloseExtrasModal()">取消</button>'+
  9165. '<button class="btn btn-primary" onclick="qCloseExtrasModal();qRenderStep3();">保存</button>'+
  9166. '</div>'+
  9167. '</div>';
  9168. document.body.appendChild(modal);
  9169. }
  9170. function qCloseExtrasModal(){const m=document.getElementById('q-extras-modal');if(m)m.remove();}
  9171. function qRefreshExtrasModal(){
  9172. const ri=parseInt(document.getElementById('q-extras-room-select').value);
  9173. const items=qExtras[ri]?.items||[];
  9174. const list=items.length>0?items.map((item,idx)=>
  9175. '<div style="display:flex;align-items:center;gap:10px;padding:8px 12px;border-bottom:1px solid var(--border);">'+
  9176. '<div style="flex:1;"><div style="font-weight:600;font-size:13px;">'+item.name+'</div><div style="font-size:11px;color:var(--text-muted);">'+item.spec+' × '+item.qty+item.unit+' @ ¥'+item.price+'</div></div>'+
  9177. '<div style="font-weight:600;color:var(--primary);font-size:13px;">¥'+(item.price*item.qty).toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2})+'</div>'+
  9178. '<button class="btn btn-danger btn-sm" onclick="qRemoveExtra('+ri+','+idx+');qRefreshExtrasModal()">删除</button>'+
  9179. '</div>'
  9180. ).join(''):'<div style="text-align:center;padding:20px;color:var(--text-muted);font-size:13px;">暂无非标增项</div>';
  9181. document.getElementById('q-extras-list').innerHTML=list;
  9182. const total=items.reduce((s,i)=>s+i.price*i.qty,0);
  9183. document.getElementById('q-extras-subtotal').textContent='小计:¥'+total.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2});
  9184. }
  9185. function qAddExtraItem(){
  9186. const ri=parseInt(document.getElementById('q-extras-room-select').value);
  9187. const name=document.getElementById('q-extras-name').value.trim();
  9188. const spec=document.getElementById('q-extras-spec').value.trim();
  9189. const qty=parseFloat(document.getElementById('q-extras-qty').value)||1;
  9190. const unit=document.getElementById('q-extras-unit').value.trim()||'个';
  9191. const price=parseFloat(document.getElementById('q-extras-price').value)||0;
  9192. if(!name||price<=0)return;
  9193. if(!qExtras[ri])qExtras[ri]={roomIndex:ri,items:[]};
  9194. qExtras[ri].items.push({name,spec,qty,unit,price});
  9195. document.getElementById('q-extras-name').value='';
  9196. document.getElementById('q-extras-spec').value='';
  9197. document.getElementById('q-extras-qty').value='1';
  9198. document.getElementById('q-extras-unit').value='个';
  9199. document.getElementById('q-extras-price').value='';
  9200. qRefreshExtrasModal();
  9201. }
  9202. function qRemoveExtra(ri,idx){if(qExtras[ri]?.items){qExtras[ri].items.splice(idx,1);}}
  9203. // Step 4: Pricing
  9204. function qRenderStep4(){
  9205. try { refreshQStep4ModeUI(); } catch(e){}
  9206. try { renderPlanCardsInStep4(); } catch(e){}
  9207. try { qRenderTierCards(); } catch(e){}
  9208. try { qUpdateTierDetail(); } catch(e){}
  9209. // 修正空间位置名称:当roomName与cabinetType撞名时自动替换为对应空间名
  9210. qFixRoomSpaceNames(qRooms);
  9211. var h = qFloorHeight;
  9212. var hasValidHeight = !isNaN(h) && h >= 2.0 && h <= 4.0;
  9213. var total = 0;
  9214. var baseTotal = 0;
  9215. var isFixedMode = (state.currentPricingMode === 'fixedPrice');
  9216. var fixedUnitP = state.currentFixedUnitPrice || state.defaultFixedUnitPrice || 0;
  9217. // 检测qRoomItems是否为新格式:每个房间应至少有"柜体"一项
  9218. // 允许增项(isExtra=true或itemType==='extra')存在
  9219. var needRebuild = false;
  9220. if(!qRoomItems || !qRoomItems.length){
  9221. needRebuild = true;
  9222. } else {
  9223. for(var _chk=0; _chk<qRoomItems.length; _chk++){
  9224. var _items = qRoomItems[_chk];
  9225. if(!_items || _items.length === 0){ needRebuild = true; break; }
  9226. // 检查是否有柜体项
  9227. var hasCabinet = false;
  9228. for(var _ci=0; _ci<_items.length; _ci++){
  9229. var _it = _items[_ci];
  9230. if(!_it) continue;
  9231. if(_it.itemType === 'cabinet' || _it.name === '柜体' || (!_it.isExtra && !_it.itemType)){
  9232. hasCabinet = true;
  9233. break;
  9234. }
  9235. }
  9236. if(!hasCabinet){ needRebuild = true; break; }
  9237. }
  9238. }
  9239. if(needRebuild){
  9240. // 先检查是否有_selectedExtras需要保留
  9241. var savedExtras = [];
  9242. for(var _se=0; _se<qRooms.length; _se++){
  9243. if(qRooms[_se] && qRooms[_se]._selectedExtras){
  9244. savedExtras[_se] = JSON.parse(JSON.stringify(qRooms[_se]._selectedExtras));
  9245. }
  9246. }
  9247. qRoomItems = [];
  9248. for(var _rb=0; _rb<qRooms.length; _rb++){
  9249. qRoomItems[_rb] = qBuildRoomItems(_rb);
  9250. // 恢复已选增项
  9251. if(savedExtras[_rb] && savedExtras[_rb].length > 0){
  9252. savedExtras[_rb].forEach(function(se){
  9253. qRoomItems[_rb].push({
  9254. name: se.name,
  9255. ruleKey: se.name,
  9256. itemType: 'extra',
  9257. isExtra: true,
  9258. multiplier: se.multiplier || 1,
  9259. width: se.widthM || 0,
  9260. height: se.heightM || 0,
  9261. area: se.qty,
  9262. unitPrice: se.price,
  9263. total: parseFloat((se.qty * se.price).toFixed(2)),
  9264. note: se.method + '(Step2增项)'
  9265. });
  9266. });
  9267. }
  9268. }
  9269. }
  9270. qSyncRoomItemsWithRules(); // 规则配置变更后同步最新单价
  9271. var roomRows = qRooms.map(function(r,i){
  9272. // 柜体宽度用图纸标注,柜体高度由图纸/AI决定(不强制等于层高),面积=宽×高
  9273. var cabW = r.cabinetWidth || r.length || 0;
  9274. var cabH = r.cabinetHeight || r.height || 0;
  9275. var projArea = r.projectArea || r.area || (cabW > 0 && cabH > 0 ? parseFloat((cabW * cabH).toFixed(3)) : 0);
  9276. var items = qRoomItems[i] || [];
  9277. var cabItem = items.find(function(it){ return it.name === '柜体' || it.itemType === 'cabinet'; });
  9278. var unitPrice = isFixedMode ? fixedUnitP : qGetCabinetUnitPrice();
  9279. if(cabItem && cabItem.unitPrice && !isFixedMode){ unitPrice = cabItem.unitPrice; }
  9280. // 柜体小计:qRoomItems所有配件明细total之和 + 手动非标增项(qExtras)
  9281. var roomDisc = projArea > 0 ? qCalcRoomTotal(r, i) : 0;
  9282. if(projArea > 0) { total += roomDisc; baseTotal += roomDisc; }
  9283. var cabinetName = (r.cabinetType || r.roomName || '柜体');
  9284. if (r._isMulti && r.sourceSheetName) {
  9285. cabinetName = '<div>'+cabinetName+'</div><span style="font-size:9px;padding:0px 5px;border-radius:6px;background:'+(r._sheetColor||'#3182CE')+'22;color:'+(r._sheetColor||'#3182CE')+';font-weight:600;display:inline-block;margin-top:2px;">📄 '+r.sourceSheetName+'</span>';
  9286. }
  9287. var areaCell = projArea > 0 ? projArea.toFixed(3)+'㎡' : '待确认';
  9288. var unitCell = projArea > 0 ? unitPrice.toLocaleString('zh-CN') : '—';
  9289. var subtotalCell = projArea > 0 ? qFormatMoney(roomDisc) : '待确认';
  9290. var seq = i + 1;
  9291. return '<tr'+(r._isMulti?' style="background:'+(r._sheetColor||'#3182CE')+'08;"':'')+'>'+
  9292. '<td style="text-align:center;color:var(--text-muted);">'+seq+'</td>'+
  9293. '<td>'+cabinetName+'</td>'+
  9294. '<td>'+cabW.toFixed(2)+'m</td>'+
  9295. '<td>'+(cabH>0?cabH.toFixed(2)+'m':'—')+'</td>'+
  9296. '<td>'+areaCell+'</td>'+
  9297. '<td>'+unitCell+'</td>'+
  9298. '<td style="font-weight:700;color:var(--primary);">'+subtotalCell+'</td>'+
  9299. '</tr>';
  9300. }).join('');
  9301. var tbody = document.getElementById('q-pricing-tbody');
  9302. if(tbody) tbody.innerHTML = roomRows;
  9303. // 添加合计行
  9304. var tfoot = document.getElementById('q-pricing-tfoot');
  9305. if(!tfoot && tbody){
  9306. var table = tbody.closest('table');
  9307. if(table){
  9308. tfoot = document.createElement('tfoot');
  9309. tfoot.id = 'q-pricing-tfoot';
  9310. table.appendChild(tfoot);
  9311. }
  9312. }
  9313. if(tfoot){
  9314. // 总投影面积:严格使用图纸标注的各柜体投影面积之和
  9315. var totalArea = qRooms.reduce(function(s,r){ return s + (r.projectArea||r.area||0); },0);
  9316. tfoot.innerHTML = '<tr style="background:#F0FFF4;font-weight:700;">'+
  9317. '<td colspan="5" style="text-align:right;font-size:13px;">合计</td>'+
  9318. '<td style="color:#E53E3E;font-size:14px;">'+totalArea.toFixed(3)+'㎡</td>'+
  9319. '<td style="color:var(--text-muted);font-size:12px;">—</td>'+
  9320. '<td style="color:var(--primary);font-size:15px;">'+qFormatMoney(total)+'</td>'+
  9321. '</tr>';
  9322. }
  9323. // ── 渲染各柜体明细表格(柜体基础项与增值项分开展示) ──
  9324. var detailSection = document.getElementById('q-room-items-section');
  9325. if(detailSection){
  9326. if(state.quoteMode === 'ai'){
  9327. // AI快速报价模式:不展示1852算法的配件明细,改为三档材质区间计价表
  9328. detailSection.innerHTML = '';
  9329. detailSection.style.display = 'none';
  9330. } else {
  9331. detailSection.style.display = '';
  9332. detailSection.innerHTML = qRooms.map(function(r,i){ return qRenderRoomItemTable(i); }).join('');
  9333. }
  9334. }
  9335. // ── AI快速报价模式:表格改为三档材质单价区间展示 ──
  9336. if(state.quoteMode === 'ai'){
  9337. var rg4 = qGetAiGrandRange();
  9338. var rows4 = (Array.isArray(qRooms)?qRooms:[]).map(function(r,i){
  9339. var p=qGetAiRoomRangePrice(r);
  9340. var cabW4=parseFloat(r.cabinetWidth)||parseFloat(r.length)||0;
  9341. var cabH4=parseFloat(r.cabinetHeight)||parseFloat(r.height)||0;
  9342. return '<tr>'+
  9343. '<td style="text-align:center;color:var(--text-muted);">'+(i+1)+'</td>'+
  9344. '<td>'+(r.cabinetType||r.roomName||'柜体')+(p.isKitchen?' <span style="font-size:10px;background:#FEEBC8;color:#C05621;border-radius:6px;padding:1px 6px;">厨房·按米</span>':'')+'</td>'+
  9345. '<td>'+cabW4.toFixed(2)+'m</td>'+
  9346. '<td>'+(cabH4>0?cabH4.toFixed(2)+'m':'—')+'</td>'+
  9347. '<td>'+p.qty+(p.isKitchen?'m':'㎡')+'</td>'+
  9348. '<td>'+p.minPrice.toLocaleString('zh-CN')+' 元/'+(p.isKitchen?'m':'㎡')+'</td>'+
  9349. '<td style="font-weight:700;color:var(--primary);">'+(p.qty>0?qFormatMoney(p.minTotal):'待确认')+'</td>'+
  9350. '</tr>';
  9351. }).join('');
  9352. var tb4=document.getElementById('q-pricing-tbody'); if(tb4) tb4.innerHTML=rows4;
  9353. var tf4=document.getElementById('q-pricing-tfoot');
  9354. if(tf4){
  9355. var ta4=(Array.isArray(qRooms)?qRooms:[]).reduce(function(s,r){ return s+(r.projectArea||r.area||0); },0);
  9356. tf4.innerHTML='<tr style="background:#F0FFF4;font-weight:700;">'+
  9357. '<td colspan="5" style="text-align:right;font-size:13px;">合计</td>'+
  9358. '<td style="color:#E53E3E;font-size:14px;">'+ta4.toFixed(3)+'㎡</td>'+
  9359. '<td style="color:var(--primary);font-size:15px;">'+qFormatMoney(rg4.min)+'</td>'+
  9360. '</tr>';
  9361. }
  9362. }
  9363. try {
  9364. var range = (state.quoteMode==='ai') ? (function(){ var rg=qGetAiGrandRange(); return {low:Math.round(rg.min),high:Math.round(rg.max)}; })() : (hasValidHeight ? qCalcRange(total) : {low:0,high:0});
  9365. qSyncStep4FloatUI(range, baseTotal, total);
  9366. } catch(e){}
  9367. try { qSyncActivityUI(); } catch(e){}
  9368. try { qUpdateDualPriceDisplay(); } catch(e){}
  9369. }
  9370. // Float rate sync for Step 4
  9371. function qSyncStep4FloatUI(range, baseTotal, finalTotal){
  9372. const pct = Math.round(qCurrentFloatRatio * 100);
  9373. // AI快速报价模式:区间=三档材质单价区间(默认档)算出的最低/最高总价
  9374. if(state.quoteMode === 'ai'){
  9375. var rgAi4 = qGetAiGrandRange();
  9376. var tAi4 = qGetAiTier();
  9377. var rangeText4 = document.getElementById('q-step4-range-text');
  9378. var rateText4 = document.getElementById('q-step4-rate-text');
  9379. if(rangeText4) rangeText4.textContent = qFormatMoney(rgAi4.min);
  9380. if(rateText4) rateText4.textContent = '各空间预估总价(累加·原价) · '+tAi4.name+'档单价 '+qGetAiUnitPrice().toLocaleString('zh-CN')+'元 | 浮动率 '+qGetAiFloatPct()+'%(10%=最低单价,15%=最高单价)';
  9381. document.querySelectorAll('#step-4 .float-preset button').forEach(b=>b.classList.remove('active'));
  9382. const activeBtn4 = document.getElementById('q-sfp-'+pct);
  9383. if(activeBtn4) activeBtn4.classList.add('active');
  9384. return;
  9385. }
  9386. const slider = document.getElementById('q-step4-float-slider');
  9387. const badge = document.getElementById('q-step4-float-badge');
  9388. const rangeText = document.getElementById('q-step4-range-text');
  9389. const rateText = document.getElementById('q-step4-rate-text');
  9390. if(slider) slider.value = pct;
  9391. if(badge) badge.textContent = '±'+pct+'%';
  9392. var disc = qGetSelectedDiscount(qCustomerTier);
  9393. var discInfo = disc ? ('('+disc.label+')') : '';
  9394. if (state.currentPricingMode === 'fixedPrice') {
  9395. var totalFixed = finalTotal || qCalcGrandTotal();
  9396. var unitP = state.currentFixedUnitPrice || state.defaultFixedUnitPrice || 0;
  9397. var totalArea = getTotalProjectArea();
  9398. if(rangeText) rangeText.textContent = qFormatMoney(totalFixed);
  9399. if(rateText) rateText.textContent = '一口价:'+unitP+'元/㎡ × '+totalArea+'㎡'+discInfo;
  9400. } else {
  9401. if(range&&rangeText) rangeText.textContent=qFormatMoney(range.low)+' — '+qFormatMoney(range.high);
  9402. if(rateText) rateText.textContent = '预估区间'+discInfo+' | 误差范围 ±'+pct+'%';
  9403. }
  9404. // Update preset buttons
  9405. document.querySelectorAll('#step-4 .float-preset button').forEach(b=>b.classList.remove('active'));
  9406. const activeBtn = document.getElementById('q-sfp-'+pct);
  9407. if(activeBtn) activeBtn.classList.add('active');
  9408. }
  9409. function qOnStep4FloatChange(val){
  9410. qCurrentFloatRatio = parseFloat(val) / 100;
  9411. const total = qCalcGrandTotal();
  9412. const baseTotal = qCalcBaseTotal();
  9413. const range = qCalcRange(total);
  9414. qSyncStep4FloatUI(range, baseTotal, total);
  9415. qUpdateDualPriceDisplay();
  9416. }
  9417. function qSetStep4Float(pct){
  9418. qCurrentFloatRatio = pct / 100;
  9419. const total = qCalcGrandTotal();
  9420. const baseTotal = qCalcBaseTotal();
  9421. const range = qCalcRange(total);
  9422. qSyncStep4FloatUI(range, baseTotal, total);
  9423. qUpdateDualPriceDisplay();
  9424. }
  9425. // Step 5: Final Quote
  9426. function qRenderStep5(){
  9427. // 修正空间位置名称:当roomName与cabinetType撞名时自动替换为对应空间名
  9428. qFixRoomSpaceNames(qRooms);
  9429. var multiNote = '';
  9430. if (qMultiMode && qMergedAnalysisData) {
  9431. var sheetsInfo = qMergedAnalysisData.sheets.map(function(s){
  9432. return '<span style="color:'+s.color+';font-weight:600;">'+s.name+'</span>('+s.roomCount+'房/'+s.totalArea+'㎡)';
  9433. }).join(' + ');
  9434. multiNote = '<div style="margin-top:6px;padding:8px 12px;background:#FAF5FF;border:1px solid #E9D8FD;border-radius:8px;font-size:12px;color:#553C9A;">📚 多图纸合并报价:'+sheetsInfo+' | 合计'+qMergedAnalysisData.roomCount+'房 / '+qMergedAnalysisData.totalArea+'㎡</div>';
  9435. }
  9436. var metaEl = document.getElementById('q-quote-meta');
  9437. if(metaEl) metaEl.innerHTML='报价单编号:QJ-'+new Date().toISOString().slice(0,10).replace(/-/g,'')+'-001 | 报价日期:'+new Date().toLocaleDateString('zh-CN')+multiNote;
  9438. var modeBadge = document.getElementById('q-step5-mode-badge');
  9439. var isFixedMode = (state.currentPricingMode === 'fixedPrice');
  9440. var isAiMode = (state.quoteMode === 'ai');
  9441. var aiTier = qGetAiTier();
  9442. var unitP = state.currentFixedUnitPrice || state.defaultFixedUnitPrice || 0;
  9443. if (modeBadge) {
  9444. if (isAiMode) {
  9445. modeBadge.style.display = 'block';
  9446. modeBadge.innerHTML = '<span style="background:linear-gradient(135deg,#3182CE,#2B6CB0);color:#fff;padding:6px 16px;border-radius:20px;font-size:13px;font-weight:600;">📦 AI快速粗略报价 · 三档材质单价模式(当前 '+aiTier.name+' 档,按所选档最高单价 '+qGetAiUnitPrice().toLocaleString('zh-CN')+'元 计价)</span>';
  9447. } else if (isFixedMode) {
  9448. modeBadge.style.display = 'block';
  9449. modeBadge.innerHTML = '<span style="background:linear-gradient(135deg,#38A169,#2F855A);color:#fff;padding:6px 16px;border-radius:20px;font-size:13px;font-weight:600;">📌 固定单价一口价模式 · '+unitP+'元/㎡</span>';
  9450. } else {
  9451. modeBadge.style.display = 'block';
  9452. modeBadge.innerHTML = '<span style="background:linear-gradient(135deg,#3182CE,#2B6CB0);color:#fff;padding:6px 16px;border-radius:20px;font-size:13px;font-weight:600;">📦 投影面积计价模式</span>';
  9453. }
  9454. }
  9455. var h = qFloorHeight;
  9456. var hasValidHeight = !isNaN(h) && h >= 2.0 && h <= 4.0;
  9457. var grandTotal = 0;
  9458. var cardsHtml = '';
  9459. var aiMin = 0, aiMax = 0;
  9460. // AI快速报价模式:隐藏顶部报价区间与浮动率微调(区间展示移至页面最下方材质选择横幅)
  9461. var rangeBlock5=document.getElementById('q-step5-range-block'); if(rangeBlock5) rangeBlock5.style.display=isAiMode?'none':'';
  9462. var floatBlock5=document.getElementById('q-step5-float-block'); if(floatBlock5) floatBlock5.style.display=isAiMode?'none':'';
  9463. if(!qRoomItems||!qRoomItems.length){ for(var i=0;i<qRooms.length;i++){qRoomItems[i]=qBuildRoomItems(i);} }
  9464. qSyncRoomItemsWithRules(); // 规则配置变更后同步最新单价
  9465. qRooms.forEach(function(r, i){
  9466. // 严格使用图纸标注的柜体尺寸和投影面积
  9467. var cabW = r.cabinetWidth || r.length || 0;
  9468. var cabH = r.cabinetHeight || r.height || 0;
  9469. var projArea = r.projectArea || r.area || (cabW > 0 && cabH > 0 ? parseFloat((cabW * cabH).toFixed(3)) : 0);
  9470. // ── AI快速报价模式:三档材质单价区间计价(厨房=总长×单价,其余=投影面积×单价) ──
  9471. if(isAiMode){
  9472. var rp=qGetAiRoomRangePrice(r);
  9473. var exclAi=qIsRoomExcluded(i);
  9474. if(!exclAi){ aiMin+=rp.minTotal; aiMax+=rp.maxTotal; }
  9475. var rpRangeTxt = exclAi ? '<span style="color:#A0AEC0;">不计入计算</span>' : (rp.qty>0 ? qFormatMoney(rp.minTotal) : '待确认');
  9476. cardsHtml += '<div class="card" style="margin-bottom:12px;border-left:4px solid '+(exclAi?'#CBD5E0':(rp.isKitchen?'#DD6B20':'#38A169'))+';'+(exclAi?'opacity:0.65;':'')+'">'+
  9477. '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;flex-wrap:wrap;gap:8px;">'+
  9478. '<div style="font-weight:700;font-size:16px;display:flex;align-items:center;flex-wrap:wrap;gap:4px;">'+qRoomIncludeToggleHtml(i)+'🗄️ '+(r.cabinetType||r.roomName||'柜体')+' <span style="font-size:11px;background:'+(rp.isKitchen?'#FEEBC8;color:#C05621':'#C6F6D5;color:#276749')+';border-radius:6px;padding:2px 8px;">'+(rp.isKitchen?'厨房 · 总长度×单价':'投影面积×单价')+'</span></div>'+
  9479. '<div style="font-size:16px;font-weight:700;color:'+(exclAi?'#A0AEC0':'var(--primary)')+';">'+rpRangeTxt+'</div>'+
  9480. '</div>'+
  9481. '<div style="font-size:13px;color:var(--text-muted);margin-bottom:10px;padding:10px;background:#F7FAFC;border-radius:6px;line-height:1.9;">'+
  9482. (rp.isKitchen
  9483. ? '<div>📏 <b style="color:#2D3748;">厨房计价总长度计算过程</b>(规则模块一第2、3条·AI自动判定厨房类型):</div>'+
  9484. '<div style="padding:6px 10px;background:#FFF;border:1px dashed #CBD5E0;border-radius:6px;margin:4px 0;">四边总尺寸 '+(rp.kb&&rp.kb.walls.length?rp.kb.walls.join(' + '):'—')+' = <b style="color:#2D3748;">'+(rp.kb?rp.kb.total:rp.qty)+'m</b>,最小边 '+(rp.kb?'<b style="color:#2D3748;">'+rp.kb.minWall+'m</b> '+(rp.kb.kitchenType==='L'?'≤':'>')+' 2m':'')+' → AI判定为 <b style="color:#C05621;">'+(rp.kb?rp.kb.kitchenType:'—')+'型厨房</b></div>'+
  9485. (rp.kb?'<div style="padding:6px 10px;background:#FFF;border:1px dashed #CBD5E0;border-radius:6px;margin:4px 0;">'+(rp.kb.kitchenType==='L'?'L型公式:长 '+rp.kb.maxWall+'m + 宽 '+rp.kb.minWall+'m':'U型公式:长 '+rp.kb.maxWall+'m + 长 '+rp.kb.maxWall+'m + 宽 '+rp.kb.minWall+'m')+' = <b style="color:#2D3748;">'+rp.kb.usedLen+'m</b> − 重叠 <b style="color:#C05621;">'+rp.kb.overlapCount+'×'+(rp.kb.overlapMm/1000).toFixed(1)+'m</b> − 冰箱预留 <b style="color:#C05621;">'+(rp.kb.fridgeMm/1000).toFixed(1)+'m</b> = 计价总长度 <b style="color:#2D3748;">'+rp.qty+'m</b></div>':'')+
  9486. '<div>计价总长度 <b style="color:#2D3748;">'+rp.qty+'m</b> × 单价 '+rp.minPrice.toLocaleString('zh-CN')+' 元/m</div>'
  9487. : (function(){
  9488. var whProd=+(((rp.w||0)*(rp.h||0)).toFixed(3));
  9489. var useMult=(rp.w>0&&rp.h>0&&Math.abs(whProd-rp.qty)<0.011);
  9490. var procBox=useMult
  9491. ? '<div style="padding:6px 10px;background:#FFF;border:1px dashed #CBD5E0;border-radius:6px;margin:4px 0;">柜宽 <b style="color:#2D3748;">'+rp.w+'m</b> × 柜高 <b style="color:#2D3748;">'+rp.h+'m</b> = 投影面积 <b style="color:#2D3748;">'+rp.qty+'㎡</b></div>'
  9492. : '<div style="padding:6px 10px;background:#FFF;border:1px dashed #CBD5E0;border-radius:6px;margin:4px 0;">投影面积(图纸标注/AI识别直接读取):<b style="color:#2D3748;">'+rp.qty+'㎡</b>'+((rp.w>0||rp.h>0)?'(柜宽 '+(rp.w>0?rp.w+'m':'—')+' · 柜高 '+(rp.h>0?rp.h+'m':'—')+',宽×高='+whProd+'㎡,与标注不一致时以标注面积为准)':')')+'</div>';
  9493. return '<div>📏 <b style="color:#2D3748;">柜体投影面积计算过程</b>:</div>'+procBox+
  9494. '<div>投影面积 <b style="color:#2D3748;">'+rp.qty+'㎡</b> × 单价 '+rp.minPrice.toLocaleString('zh-CN')+' 元/㎡</div>';
  9495. })())+
  9496. '<div>总价(按 '+rp.tierName+' 档单价 '+rp.minPrice.toLocaleString('zh-CN')+' 元计算):<b style="color:#2B6CB0;">'+qFormatMoney(rp.minTotal)+'</b></div>'+
  9497. '</div>'+
  9498. '</div>';
  9499. return;
  9500. }
  9501. var items = qRoomItems[i] || [];
  9502. var cabItem = items.find(function(it){ return it.name === '柜体' || it.itemType === 'cabinet'; });
  9503. var unitPrice = isFixedMode ? unitP : qGetCabinetUnitPrice();
  9504. if(cabItem && cabItem.unitPrice && !isFixedMode){
  9505. unitPrice = cabItem.unitPrice;
  9506. }
  9507. // 房间总计:直接使用qRoomItems明细配件total之和 + 手动非标增项
  9508. var roomTotal = projArea > 0 ? qCalcRoomTotal(r, i) : 0;
  9509. var exclN = qIsRoomExcluded(i);
  9510. if(!exclN) grandTotal += roomTotal;
  9511. // 柜体投影价 = qRoomItems中"柜体"行的total(准确)
  9512. var cabinetSubtotal = cabItem ? (parseFloat(cabItem.total)||0) : (projArea > 0 ? parseFloat((projArea * unitPrice).toFixed(2)) : 0);
  9513. // 门板行
  9514. var doorNames = ['双饰面门板','铝格栅门板','风影门板','风影门板补差价','铝格栅门板补差价','PET门板','防潮门板','烤漆门板'];
  9515. var doorItems = items.filter(function(it){ return doorNames.indexOf(it.name) !== -1; });
  9516. var doorSubtotal = doorItems.reduce(function(s,it){ return s + (parseFloat(it.total)||0); }, 0);
  9517. // 选配产品(非柜体、非门板的配件行 + 手动增项)
  9518. var optionItems = items.filter(function(it){
  9519. if(it.name === '柜体' || it.itemType === 'cabinet') return false;
  9520. if(doorNames.indexOf(it.name) !== -1) return false;
  9521. return true;
  9522. });
  9523. var extras = (qExtras[i] && qExtras[i].items) ? qExtras[i].items : [];
  9524. var hasOptions = optionItems.length > 0 || extras.length > 0;
  9525. var optionsTotal = optionItems.reduce(function(s,it){ return s + (parseFloat(it.total)||0); }, 0);
  9526. extras.forEach(function(e){ optionsTotal += (e.price * e.qty); });
  9527. var cabDetail = '';
  9528. if(projArea > 0){
  9529. cabDetail = '<div style="font-size:13px;color:var(--text-muted);margin-bottom:10px;padding:10px;background:#F7FAFC;border-radius:6px;">'+
  9530. '<div><span style="font-weight:600;color:var(--text);">柜体投影价</span>:'+
  9531. cabW.toFixed(2)+'m宽 × '+cabH.toFixed(2)+'m高 = '+projArea.toFixed(3)+'㎡ × '+unitPrice.toLocaleString('zh-CN')+'元/㎡ = '+
  9532. '<span style="color:var(--primary);font-weight:700;font-size:15px;">'+qFormatMoney(cabinetSubtotal)+'</span></div>'+
  9533. (doorSubtotal > 0 ? '<div style="margin-top:4px;"><span style="font-weight:600;color:var(--text);">门板价</span>:'+
  9534. '<span style="color:#2B6CB0;font-weight:600;">'+qFormatMoney(doorSubtotal)+'</span></div>' : '')+
  9535. '<div style="margin-top:4px;font-size:11px;color:#68D391;">📝 数据来源:图纸手写标注(100%匹配),单价来自规则配置</div>'+
  9536. '</div>';
  9537. } else {
  9538. cabDetail = '<div style="font-size:13px;color:#E53E3E;margin-bottom:10px;padding:10px;background:#FFF5F5;border-radius:6px;">⚠️ 待确认尺寸</div>';
  9539. }
  9540. var optionsHtml = '';
  9541. if(hasOptions){
  9542. var optRows = '';
  9543. optionItems.forEach(function(it){
  9544. optRows += '<div style="display:flex;justify-content:space-between;padding:6px 0;border-bottom:1px dashed #EDF2F7;font-size:12px;">'+
  9545. '<span style="color:var(--text-muted);">'+it.name+(qMaterialOnlyNote(it.note,r,it)?' <span style="color:#A0AEC0;font-size:10px;">('+qMaterialOnlyNote(it.note,r,it)+')</span>':'')+'</span>'+
  9546. '<span style="font-weight:500;">'+qFormatMoney(it.total||0)+'</span>'+
  9547. '</div>';
  9548. });
  9549. extras.forEach(function(e){
  9550. var sub = e.price * e.qty;
  9551. optRows += '<div style="display:flex;justify-content:space-between;padding:6px 0;border-bottom:1px dashed #EDF2F7;font-size:12px;">'+
  9552. '<span style="color:var(--text-muted);">'+e.name+' × '+e.qty+e.unit+'(手动增项)</span>'+
  9553. '<span style="font-weight:500;">'+qFormatMoney(sub)+'</span>'+
  9554. '</div>';
  9555. });
  9556. optRows += '<div style="display:flex;justify-content:space-between;padding:8px 0 4px;font-size:13px;font-weight:600;">'+
  9557. '<span>选配小计</span>'+
  9558. '<span style="color:var(--primary);">'+qFormatMoney(optionsTotal)+'</span>'+
  9559. '</div>';
  9560. optionsHtml = '<div style="margin-top:10px;padding-top:10px;border-top:1px solid #EDF2F7;">'+
  9561. '<button id="q-space-toggle-'+i+'" onclick="qToggleSpaceOptions('+i+')" style="width:100%;background:#F7FAFC;border:1px solid #E2E8F0;border-radius:6px;padding:8px;font-size:12px;color:#4A5568;cursor:pointer;">▼ 展开选配产品(五金/配件)</button>'+
  9562. '<div id="q-space-opts-'+i+'" class="hidden" style="margin-top:8px;padding:10px;background:#FAFBFC;border-radius:6px;">'+optRows+'</div>'+
  9563. '</div>';
  9564. }
  9565. var cardBorder = exclN ? 'border-left:4px solid #CBD5E0;' : (r._isMulti ? 'border-left:4px solid '+(r._sheetColor||'#3182CE')+';' : 'border-left:4px solid #38A169;');
  9566. cardsHtml += '<div class="card" style="margin-bottom:12px;'+cardBorder+(exclN?'opacity:0.65;':'')+'">'+
  9567. '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">'+
  9568. '<div style="font-weight:700;font-size:16px;display:flex;align-items:center;flex-wrap:wrap;gap:4px;">'+qRoomIncludeToggleHtml(i)+'🗄️ '+(r.cabinetType||r.roomName||'柜体')+'</div>'+
  9569. '<div style="font-size:18px;font-weight:700;color:'+(exclN?'#A0AEC0':'var(--primary)')+';">'+(exclN?'不计入计算':(projArea>0?qFormatMoney(roomTotal):'待确认'))+'</div>'+
  9570. '</div>'+
  9571. cabDetail+
  9572. '<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;font-size:12px;color:var(--text-muted);">'+
  9573. '<div style="background:#F7FAFC;padding:8px 10px;border-radius:6px;"><span style="color:#718096;">布局形式:</span><span style="color:var(--text);font-weight:600;">'+(r.layoutStyle||'一字型')+'</span></div>'+
  9574. '<div style="background:#F7FAFC;padding:8px 10px;border-radius:6px;"><span style="color:#718096;">柜体宽度:</span><span style="color:var(--text);font-weight:600;">'+cabW.toFixed(2)+'m</span></div>'+
  9575. '<div style="background:#F7FAFC;padding:8px 10px;border-radius:6px;"><span style="color:#718096;">柜体高度:</span><span style="color:var(--text);font-weight:600;">'+(cabH>0?cabH.toFixed(2)+'m(图纸标注/层高)':'待确认')+'</span></div>'+
  9576. '</div>'+
  9577. optionsHtml+
  9578. '</div>';
  9579. });
  9580. var container = document.getElementById('q-space-cards-container');
  9581. if(container) container.innerHTML = cardsHtml;
  9582. var totalValEl = document.getElementById('q-grand-total-value');
  9583. var totalNoteEl = document.getElementById('q-grand-total-note');
  9584. var rangeEl = document.getElementById('q-step5-range');
  9585. var rateTextEl = document.getElementById('q-step5-rate-text');
  9586. // 总投影面积:严格使用图纸标注的各柜体投影面积之和
  9587. var totalArea = qGetTotalProjectArea();
  9588. var hasValidData = totalArea > 0;
  9589. var areaTagEl = document.getElementById('q-grand-total-area');
  9590. if(areaTagEl) areaTagEl.textContent = hasValidData ? ('总投影面积 '+totalArea.toFixed(2)+'㎡') : '';
  9591. if(hasValidData){
  9592. if(isAiMode){
  9593. var rgAi={min:+aiMin.toFixed(2),max:+aiMax.toFixed(2)};
  9594. grandTotal=rgAi.min;
  9595. if(totalValEl) totalValEl.textContent = qFormatMoney(rgAi.min);
  9596. if(totalNoteEl) totalNoteEl.textContent = '厨房按总长度×单价 + 其余柜体按投影面积×单价 · '+aiTier.name+'档单价 '+qGetAiUnitPrice().toLocaleString('zh-CN')+'元(浮动率 '+qGetAiFloatPct()+'%)';
  9597. if(rangeEl) rangeEl.textContent = qFormatMoney(rgAi.min);
  9598. if(rateTextEl) rateTextEl.textContent = '各空间预估总价(累加·原价) | 浮动率 '+qGetAiFloatPct()+'%(10%=最低单价,15%=最高单价)';
  9599. } else {
  9600. if(totalValEl) totalValEl.textContent = qFormatMoney(grandTotal);
  9601. if(totalNoteEl) totalNoteEl.textContent = '共'+qRooms.length+'组柜体 · 总投影面积'+totalArea.toFixed(3)+'㎡(宽×上传层高计算) · 层高'+(hasValidHeight?h.toFixed(2)+'m(Step1填写·柜体高度基准)':'待填写');
  9602. if(rangeEl) rangeEl.textContent = qFormatMoney(grandTotal);
  9603. if(rateTextEl) {
  9604. if(isFixedMode){
  9605. rateTextEl.textContent = '一口价:'+unitP+'元/㎡ × '+totalArea.toFixed(3)+'㎡';
  9606. } else {
  9607. rateTextEl.textContent = '各柜体投影面积折扣后累加 · 所有尺寸严格匹配图纸标注';
  9608. }
  9609. }
  9610. }
  9611. } else {
  9612. if(totalValEl) totalValEl.textContent = '—';
  9613. if(totalNoteEl) totalNoteEl.textContent = '共'+qRooms.length+'个柜体 · 待确认尺寸';
  9614. if(rangeEl) rangeEl.textContent = '—';
  9615. if(rateTextEl) rateTextEl.textContent = '待确认尺寸后计算总价';
  9616. }
  9617. try {
  9618. const total = hasValidData ? grandTotal : 0;
  9619. const range = isAiMode ? {low:Math.round(aiMin),high:Math.round(aiMax)} : qCalcRange(total);
  9620. qSyncStep5FloatUI(range);
  9621. } catch(e){}
  9622. // ── AI快速报价:页面最下方三档材质单价区间报价横幅 ──
  9623. try {
  9624. var banner=document.getElementById('q-ai-range-banner');
  9625. if(banner){
  9626. if(isAiMode && hasValidData){
  9627. banner.style.display='block';
  9628. var rgB={min:+aiMin.toFixed(2),max:+aiMax.toFixed(2)};
  9629. var tEl=document.getElementById('q-ai-range-total'); if(tEl) tEl.textContent=qFormatMoney(rgB.min);
  9630. var nEl=document.getElementById('q-ai-range-note'); if(nEl) nEl.textContent='各空间预估总价(累加·原价) | 浮动率 '+qGetAiFloatPct()+'%';
  9631. // 材质选择器:点击选择,总价区间按所选材质单价区间自动调整
  9632. var selBox=document.getElementById('q-ai-tier-selector');
  9633. if(selBox){
  9634. var tiersArr=qGetAiPriceTiers();
  9635. selBox.innerHTML=tiersArr.map(function(t){
  9636. var active=t.name===aiTier.name;
  9637. return '<button onclick="qUpdateAiDefaultTier(\''+t.name+'\')" style="padding:10px 22px;border-radius:999px;border:2px solid '+(active?'#2B6CB0':'#CBD5E0')+';background:'+(active?'linear-gradient(135deg,#3182CE,#2B6CB0)':'#fff')+';color:'+(active?'#fff':'#4A5568')+';font-size:13px;font-weight:700;cursor:pointer;transition:all .2s;box-shadow:'+(active?'0 4px 12px rgba(43,108,176,0.35)':'none')+';">'+(active?'✓ ':'')+t.name+' '+t.min+'-'+t.max+'元</button>';
  9638. }).join('');
  9639. }
  9640. var dEl=document.getElementById('q-ai-range-detail');
  9641. if(dEl){
  9642. var kitchenLen=0,otherArea=0;
  9643. qRooms.forEach(function(r,idx){ if(qIsRoomExcluded(idx)) return; var p=qGetAiRoomRangePrice(r); if(p.isKitchen) kitchenLen+=p.qty; else otherArea+=p.qty; });
  9644. dEl.innerHTML='📐 计价规则:厨房总长度 '+kitchenLen.toFixed(2)+'m × 单价 + 其余柜体总投影面积 '+otherArea.toFixed(3)+'㎡ × 单价 = 户型图总价(厨房总价与所有柜体总价相加)。当前材质:<b style="color:#2B6CB0;">'+aiTier.name+' '+aiTier.min+'-'+aiTier.max+'元</b>,按所选档最高单价计价;浮动率10%对应最低单价、15%对应最高单价。';
  9645. }
  9646. var aiFloatBlock=document.getElementById('q-ai-float-block');
  9647. if(aiFloatBlock) aiFloatBlock.style.display='block';
  9648. qSyncAiFloatUI();
  9649. } else {
  9650. banner.style.display='none';
  9651. var aiFloatBlockH=document.getElementById('q-ai-float-block');
  9652. if(aiFloatBlockH) aiFloatBlockH.style.display='none';
  9653. }
  9654. }
  9655. } catch(e){}
  9656. try {
  9657. const total = hasValidData ? grandTotal : 0;
  9658. const activityResult = qCalcActivityPrice(total);
  9659. const actSection = document.getElementById('q-step5-activity-section');
  9660. if (activityResult.enabled && actSection) {
  9661. actSection.style.display = 'block';
  9662. const actPriceEl = document.getElementById('q-step5-activity-price');
  9663. const actSavingEl = document.getElementById('q-step5-activity-saving');
  9664. if (actPriceEl) actPriceEl.textContent = qFormatMoney(activityResult.activityPrice);
  9665. var dedTotal = activityResult.totalDeduction || activityResult.saving;
  9666. if (actSavingEl) actSavingEl.textContent = '💰 比折后价再省 ' + qFormatMoney(dedTotal) + '(含' + (activityResult.deductions||[]).length + '项优惠)';
  9667. } else if(actSection) {
  9668. actSection.style.display = 'none';
  9669. }
  9670. } catch(e){}
  9671. }
  9672. // Float rate sync for Step 5
  9673. function qSyncStep5FloatUI(range){
  9674. const pct = Math.round(qCurrentFloatRatio * 100);
  9675. // AI快速报价模式:只显示按当前材质单价(浮动率联动)算出的单一总价
  9676. if(state.quoteMode === 'ai'){
  9677. var rgAi5 = qGetAiGrandRange();
  9678. var rangeEl5 = document.getElementById('q-step5-range');
  9679. var rateText5 = document.getElementById('q-step5-rate-text');
  9680. if(rangeEl5) rangeEl5.textContent = qFormatMoney(rgAi5.min);
  9681. if(rateText5) rateText5.textContent = '各空间预估总价(累加·原价) | 浮动率 '+qGetAiFloatPct()+'%(10%=最低单价,15%=最高单价)';
  9682. return;
  9683. }
  9684. const slider = document.getElementById('q-step5-float-slider');
  9685. const badge = document.getElementById('q-step5-float-badge');
  9686. const rangeEl = document.getElementById('q-step5-range');
  9687. const rateText = document.getElementById('q-step5-rate-text');
  9688. const disclaimer = document.getElementById('q-step5-disclaimer');
  9689. if(slider) slider.value = pct;
  9690. if(badge) badge.textContent = '±'+pct+'%';
  9691. var discS5 = qGetSelectedDiscount(qCustomerTier);
  9692. var discInfoS5 = discS5 ? '('+discS5.label+')' : '';
  9693. if (state.currentPricingMode === 'fixedPrice') {
  9694. var totalFixed = qCalcGrandTotal();
  9695. var unitP = state.currentFixedUnitPrice || state.defaultFixedUnitPrice || 0;
  9696. var totalArea = getTotalProjectArea();
  9697. if(rangeEl) rangeEl.textContent = qFormatMoney(totalFixed);
  9698. if(rateText) rateText.textContent = '一口价(固定单价 '+unitP+'元/㎡ × '+totalArea+'㎡'+discInfoS5+')';
  9699. if(disclaimer) disclaimer.textContent = '⚠️ 声明:本报价为固定单价一口价模式报价,按投影面积×固定单价计算,最终成交价以实际量尺确认为准。';
  9700. } else {
  9701. if(range&&rangeEl) rangeEl.textContent=qFormatMoney(range.low)+' — '+qFormatMoney(range.high);
  9702. if(rateText) rateText.textContent = '各空间预估总价区间'+discInfoS5+' | 误差范围 ±'+pct+'%';
  9703. if(disclaimer) disclaimer.textContent = '⚠️ 声明:本报价为 AI 辅助生成的预估价格,存在 ±'+pct+'% 误差,最终成交价以实际量尺和确认为准。报价定价数据来源于飞书后台。';
  9704. }
  9705. // Update preset buttons
  9706. document.querySelectorAll('#step-4 .float-preset button').forEach(b=>b.classList.remove('active'));
  9707. const activeBtn = document.getElementById('q-sfp5-'+pct);
  9708. if(activeBtn) activeBtn.classList.add('active');
  9709. }
  9710. function qAiGrandRangeForFloat(){ var rg=qGetAiGrandRange(); return {low:Math.round(rg.min),high:Math.round(rg.max)}; }
  9711. function qOnStep5FloatChange(val){
  9712. qCurrentFloatRatio = parseFloat(val) / 100;
  9713. const range = (state.quoteMode==='ai') ? qAiGrandRangeForFloat() : qCalcRange(qCalcGrandTotal());
  9714. qSyncStep5FloatUI(range);
  9715. // Also update activity display in step 5 if visible
  9716. const activityResult = qCalcActivityPrice(qCalcGrandTotal());
  9717. if (activityResult.enabled) {
  9718. const actPriceEl = document.getElementById('q-step5-activity-price');
  9719. if (actPriceEl) actPriceEl.textContent = qFormatMoney(activityResult.activityPrice);
  9720. }
  9721. }
  9722. function qSetStep5Float(pct){
  9723. qCurrentFloatRatio = pct / 100;
  9724. const range = (state.quoteMode==='ai') ? qAiGrandRangeForFloat() : qCalcRange(qCalcGrandTotal());
  9725. qSyncStep5FloatUI(range);
  9726. const activityResult = qCalcActivityPrice(qCalcGrandTotal());
  9727. if (activityResult.enabled) {
  9728. const actPriceEl = document.getElementById('q-step5-activity-price');
  9729. if (actPriceEl) actPriceEl.textContent = qFormatMoney(activityResult.activityPrice);
  9730. }
  9731. }
  9732. // ── AI快速报价:页面最低端浮动率微调(仅内部可见,可手动拉动调节) ──
  9733. // 浮动率取值范围10%-15%:10%=三档材质最低单价,15%=三档材质最高单价
  9734. function qGetAiFloatPct(){ var v=parseFloat(state.aiFloatPct); if(isNaN(v)) v=15; if(v<10)v=10; if(v>15)v=15; return +v.toFixed(1); }
  9735. // 总投影面积:各柜体投影面积之和(排除不计价空间),供页面展示与导出报价单使用
  9736. function qGetTotalProjectArea(){
  9737. var s=0;
  9738. for(var i=0;i<qRooms.length;i++){
  9739. if(qIsRoomExcluded(i)) continue;
  9740. var r=qRooms[i];
  9741. s+=(parseFloat(r.projectArea)||parseFloat(r.area)||0);
  9742. }
  9743. return +s.toFixed(2);
  9744. }
  9745. function qSyncAiFloatUI(){
  9746. var pct=qGetAiFloatPct();
  9747. var slider=document.getElementById('q-ai-float-slider');
  9748. var badge=document.getElementById('q-ai-float-badge');
  9749. if(slider) slider.value=pct;
  9750. if(badge) badge.textContent=pct+'%';
  9751. document.querySelectorAll('#q-ai-float-block .float-preset button').forEach(function(b){
  9752. b.classList.toggle('active', parseFloat(b.getAttribute('data-aifp'))===pct);
  9753. });
  9754. var rg=qGetAiGrandRange();
  9755. var U=qGetAiUnitPrice();
  9756. var t=qGetAiTier();
  9757. var res=document.getElementById('q-ai-float-result');
  9758. if(res) res.innerHTML='浮动率 <b>'+pct+'%</b> · '+t.name+'档 → 单价:<b style="color:#2B6CB0;">'+U.toLocaleString('zh-CN')+'元</b>|总价(内部参考):<b style="color:#2B6CB0;">'+qFormatMoney(rg.min)+'</b>';
  9759. var nEl=document.getElementById('q-ai-range-note');
  9760. if(nEl) nEl.textContent='各空间预估总价(累加) | 浮动率 '+pct+'%';
  9761. if(state.quoteMode==='ai'){
  9762. var disclaimer=document.getElementById('q-step5-disclaimer');
  9763. if(disclaimer) disclaimer.textContent='⚠️ 声明:本报价为 AI 辅助生成的预估价格,浮动率10%-15%对应材质最低-最高单价,最终成交价以实际量尺和确认为准。报价定价数据来源于飞书后台。';
  9764. }
  9765. }
  9766. // 浮动率/材质变化后重建明细,保证页面、Excel、打印版单价与总价一致
  9767. function qRebuildAiRoomItems(){
  9768. if(state.quoteMode!=='ai'||!Array.isArray(qRooms)||!qRooms.length) return;
  9769. qRoomItems=[]; for(var i=0;i<qRooms.length;i++) qRoomItems[i]=qBuildRoomItems(i);
  9770. }
  9771. function qOnAiFloatChange(val){
  9772. state.aiFloatPct=parseFloat(val);
  9773. if(typeof persistState==='function') persistState();
  9774. qRebuildAiRoomItems();
  9775. if(typeof qRenderStep4==='function') qRenderStep4();
  9776. if(typeof qRenderStep5==='function') qRenderStep5();
  9777. qSyncAiFloatUI();
  9778. }
  9779. function qSetAiFloat(pct){
  9780. state.aiFloatPct=pct;
  9781. if(typeof persistState==='function') persistState();
  9782. qRebuildAiRoomItems();
  9783. if(typeof qRenderStep4==='function') qRenderStep4();
  9784. if(typeof qRenderStep5==='function') qRenderStep5();
  9785. qSyncAiFloatUI();
  9786. }
  9787. // 显示空间效果图结构识别结果
  9788. function qShowRenderResults(){
  9789. if(!qRenderResults){return;}
  9790. var modal = document.createElement('div');
  9791. modal.className = 'modal-overlay'; modal.id = 'q-render-modal';
  9792. modal.setAttribute('onclick', 'if(event.target===this)qCloseRenderModal()');
  9793. modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.55);display:flex;align-items:center;justify-content:center;z-index:1010;';
  9794. var resultCards = qRenderResults.map((r,idx) =>
  9795. '<div style="background:#FAFBFC;border:1px solid var(--border);border-radius:8px;padding:16px;margin-bottom:10px;">'+
  9796. '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">'+
  9797. '<div style="font-weight:600;font-size:14px;">🖼️ '+r.fileName+'</div>'+
  9798. '<span style="font-size:11px;padding:2px 8px;border-radius:10px;font-weight:500;'+(r.confidence>=85?'background:#E6F4FF;color:#1F497D':'background:#FFF3E0;color:#D69E2E')+'">置信度 '+r.confidence+'%</span>'+
  9799. '</div>'+
  9800. '<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:10px;font-size:13px;">'+
  9801. '<div><div style="font-size:11px;color:var(--text-muted);">识别空间</div><div style="font-weight:600;">🏠 '+r.space+'</div></div>'+
  9802. '<div><div style="font-size:11px;color:var(--text-muted);">柜体类型</div><div style="font-weight:600;">🗄️ '+r.cabinetType+'</div></div>'+
  9803. '<div><div style="font-size:11px;color:var(--text-muted);">材质/色号</div><div style="font-weight:600;">🪵 '+r.material+' / '+r.materialColor+'</div></div>'+
  9804. '<div><div style="font-size:11px;color:var(--text-muted);">风格</div><div style="font-weight:600;">🎨 '+r.style+'</div></div>'+
  9805. '</div>'+
  9806. '<div style="margin-top:10px;padding-top:10px;border-top:1px dashed var(--border);display:flex;gap:8px;">'+
  9807. '<button class="btn btn-sm btn-primary-outline" onclick="qApplyRenderRoom('+idx+');qCloseRenderModal();">✅ 应用到报价空间</button>'+
  9808. '<button class="btn btn-sm btn-outline" onclick="qCloseRenderModal()">忽略</button>'+
  9809. '</div>'+
  9810. '</div>'
  9811. ).join('');
  9812. modal.innerHTML =
  9813. '<div class="modal-content" style="background:#fff;border-radius:12px;width:680px;max-height:85vh;overflow:hidden;box-shadow:0 20px 60px rgba(0,0,0,0.3);" onclick="event.stopPropagation();">'+
  9814. '<div style="padding:14px 18px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;">'+
  9815. '<h3 style="margin:0;font-size:16px;">🖼️ 空间效果图结构识别结果</h3>'+
  9816. '<button onclick="qCloseRenderModal()" style="background:none;border:none;font-size:20px;cursor:pointer;color:var(--text-muted);">×</button>'+
  9817. '</div>'+
  9818. '<div style="padding:18px;overflow-y:auto;max-height:calc(85vh - 80px);">'+
  9819. '<div style="margin-bottom:12px;padding:10px 14px;background:#FFFFF0;border:1px solid #F6E05E;border-radius:6px;font-size:12px;color:#975A16;">'+
  9820. '💡 <strong>提示:</strong>AI已从效果图中识别出柜体结构信息,点击"应用到报价空间"可将识别结果自动填入对应空间。'+
  9821. '</div>'+
  9822. resultCards+
  9823. '</div>'+
  9824. '<div style="padding:12px 18px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;">'+
  9825. '<button class="btn" onclick="qCloseRenderModal()">关闭</button>'+
  9826. '</div>'+
  9827. '</div>';
  9828. document.body.appendChild(modal);
  9829. }
  9830. function qCloseRenderModal(){
  9831. var m = document.getElementById('q-render-modal');
  9832. if(m) m.remove();
  9833. }
  9834. function qApplyRenderRoom(idx){
  9835. var r = qRenderResults[idx];
  9836. if(!r) return;
  9837. // Find matching room by space name or add new
  9838. var found = false;
  9839. for(var i=0;i<qRooms.length;i++){
  9840. if(qRooms[i].roomName === r.space){
  9841. qRooms[i].cabinetType = r.cabinetType;
  9842. // Map material to door type
  9843. if(r.materialColor && r.materialColor.includes('影木')||r.materialColor.includes('胡桃')) qRooms[i].doorType = '实木贴皮';
  9844. else if(r.materialColor && (r.materialColor.includes('SSM')||r.materialColor.includes('PET'))) qRooms[i].doorType = '风影门板';
  9845. found = true;
  9846. break;
  9847. }
  9848. }
  9849. if(!found){
  9850. qRooms.push({roomName:r.space,cabinetType:r.cabinetType,layoutStyle:'一字型',projectArea:4.0,doorType:'风影门板', length:3.5, width:3.0, height:2.8, area:10.5, cabinetRatio:0.55, fillRate:0.55, doorRatio:0.50, bayWindow:false});
  9851. qExtras.push({roomIndex:qRooms.length-1,items:[]});
  9852. }
  9853. }
  9854. function qSaveTemplate(){showToast('已保存到模板库(Demo)','success');}
  9855. // ── 从文件名中智能提取项目名称 ──
  9856. function qExtractProjectName(){
  9857. // 优先从上传的CAD/图纸文件名中提取
  9858. var names = [];
  9859. if (qUploadedCAD && qUploadedCAD.name) names.push(qUploadedCAD.name);
  9860. if (qUploadedDrawing && qUploadedDrawing.name && qUploadedDrawing.name.indexOf('120㎡三房两厅')===-1) names.push(qUploadedDrawing.name);
  9861. var rawName = names[0] || '';
  9862. if (!rawName) return '全屋定制预算表';
  9863. // 去掉文件扩展名
  9864. rawName = rawName.replace(/\.(dwg|dxf|pdf|jpg|jpeg|png|webp|xlsx|xls|csv)$/i, '');
  9865. // 截图类文件名(屏幕截图_2026-08-08_094705 / Screenshot_xxx)不是有效项目名,直接弃用默认名,避免导出的Excel报价单被误认为截图
  9866. if (/^(屏幕截图|截图|screenshot)/i.test(rawName)) return '全屋定制预算表';
  9867. // 去掉常见前缀标记
  9868. rawName = rawName.replace(/^(施工图|图纸|CAD|户型图|效果图)[_\-]*/i, '');
  9869. // 去掉日期前缀如 "6.18号" "2026-06-18" 但保留项目名
  9870. rawName = rawName.replace(/^\d{1,2}\.\d{1,2}号/i, '');
  9871. rawName = rawName.replace(/^\d{4}[-/]\d{1,2}[-/]\d{1,2}[_\-]*/i, '');
  9872. // 去掉常见后缀
  9873. rawName = rawName.replace(/[_\-]*(预估报价|报价单|预算|施工图|方案|效果图|户型图|CAD图纸|图纸|CAD)$/i, '');
  9874. // 清理空白
  9875. rawName = rawName.trim();
  9876. if (!rawName || rawName.length < 2) return '全屋定制预算表';
  9877. // 如果包含"路"字或以地名结尾,大概率是项目地址
  9878. if (/路|号|弄|巷|街|小区|花园|公馆|苑|庭|府|园|城|湾|邸/.test(rawName)){
  9879. return rawName + ' · 全屋定制预算表';
  9880. }
  9881. return rawName + ' · 全屋定制预算表';
  9882. }
  9883. // ── 构建导出用的完整项目信息行 ──
  9884. function qBuildExportMeta(){
  9885. var parts = [];
  9886. // 来源图纸信息
  9887. var cadName = qUploadedCAD ? qUploadedCAD.name : '';
  9888. var drawingName = qUploadedDrawing ? qUploadedDrawing.name : '';
  9889. if (cadName && drawingName) parts.push('来源:户型图「'+drawingName+'」+ CAD「'+cadName+'」');
  9890. else if (cadName) parts.push('来源:CAD「'+cadName+'」');
  9891. else if (drawingName) parts.push('来源:户型图「'+drawingName+'」');
  9892. else parts.push('来源:AI户型分析');
  9893. // AI解析结果
  9894. if (qAiEditableData) {
  9895. var d = qAiEditableData;
  9896. var hInfo = (d.houseType||d.houseLabel||'') + ' · ' + (d.grossArea||d.totalArea||'').toFixed(0) + '㎡';
  9897. if (d.orientation) hInfo += ' · ' + d.orientation;
  9898. parts.push(hInfo);
  9899. }
  9900. // 户型选择
  9901. var houseTypeVal = (typeof getHouseTypeValue === 'function') ? getHouseTypeValue() : (document.getElementById('house-type')?.value || '');
  9902. if (houseTypeVal) parts.push('户型:'+houseTypeVal);
  9903. return parts.join(' | ');
  9904. }
  9905. function qExportPDF(){
  9906. // ── 提取项目名称 ──
  9907. var projectTitle = qExtractProjectName();
  9908. var metaInfo = qBuildExportMeta();
  9909. var today = new Date().toLocaleDateString('zh-CN');
  9910. var ratePct = Math.round(qDiscountRate * 100);
  9911. // 文件名与项目关联
  9912. var safeTitle = projectTitle.replace(/[\\/:*?"<>|]/g, '-').replace(/[\s·]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '').substring(0, 60);
  9913. var exportFilename = '报价单_' + safeTitle + '_' + today.replace(/\//g,'') + '.html';
  9914. var seq = 0;
  9915. var grandOriginal = 0, grandDiscounted = 0;
  9916. // ── 构建空间尺寸明细 ──
  9917. var dimSectionHtml = '<div style="margin-bottom:24px;"><h3 style="font-size:15px;font-weight:600;color:#333;margin-bottom:10px;padding-bottom:6px;border-bottom:2px solid #1F497D;">一、空间尺寸明细</h3>'+
  9918. '<table style="width:100%;border-collapse:collapse;font-size:12px;"><thead><tr style="background:#F0F4F8;">'+
  9919. '<th style="border:1px solid #ccc;padding:8px;text-align:center;">房间名称</th>'+
  9920. '<th style="border:1px solid #ccc;padding:8px;text-align:center;">长(m)</th>'+
  9921. '<th style="border:1px solid #ccc;padding:8px;text-align:center;">宽(m)</th>'+
  9922. '<th style="border:1px solid #ccc;padding:8px;text-align:center;">高(m)</th>'+
  9923. '<th style="border:1px solid #ccc;padding:8px;text-align:center;">投影面积(m²)</th>'+
  9924. '<th style="border:1px solid #ccc;padding:8px;text-align:center;">柜体类型</th>'+
  9925. '<th style="border:1px solid #ccc;padding:8px;text-align:center;">布局样式</th>'+
  9926. '<th style="border:1px solid #ccc;padding:8px;text-align:center;">门板类型</th>'+
  9927. '</tr></thead><tbody>';
  9928. qRooms.forEach(function(r, rxi){
  9929. if(qIsRoomExcluded(rxi)) return; // 不计入计算的空间不导出
  9930. dimSectionHtml += '<tr>'+
  9931. '<td style="border:1px solid #ccc;padding:6px 8px;text-align:center;font-weight:600;">'+r.roomName+'</td>'+
  9932. '<td style="border:1px solid #ccc;padding:6px 8px;text-align:center;">'+(r.length||0).toFixed(2)+'</td>'+
  9933. '<td style="border:1px solid #ccc;padding:6px 8px;text-align:center;">'+(r.width||0).toFixed(2)+'</td>'+
  9934. '<td style="border:1px solid #ccc;padding:6px 8px;text-align:center;">'+(r.height||0).toFixed(2)+'</td>'+
  9935. '<td style="border:1px solid #ccc;padding:6px 8px;text-align:center;">'+(r.projectArea||0).toFixed(2)+'</td>'+
  9936. '<td style="border:1px solid #ccc;padding:6px 8px;text-align:center;">'+(r.cabinetType||'')+'</td>'+
  9937. '<td style="border:1px solid #ccc;padding:6px 8px;text-align:center;">'+(r.layoutStyle||'')+'</td>'+
  9938. '<td style="border:1px solid #ccc;padding:6px 8px;text-align:center;">'+(r.doorType||'')+'</td>'+
  9939. '</tr>';
  9940. });
  9941. dimSectionHtml += '</tbody></table></div>';
  9942. // ── 构建每个房间的子项行 ──
  9943. var roomSections = '';
  9944. qRooms.forEach(function(r, ri){
  9945. if(qIsRoomExcluded(ri)) return; // 不计入计算的空间不导出
  9946. var items = (qRoomItems && qRoomItems[ri]) ? qRoomItems[ri] : [];
  9947. if (!items.length) return;
  9948. seq++;
  9949. var isFirst = true;
  9950. var roomOriginal = 0;
  9951. var roomItemsHtml = '';
  9952. items.forEach(function(it){
  9953. var ttl = it.total || parseFloat((it.area*it.unitPrice).toFixed(0)) || 0;
  9954. roomOriginal += ttl;
  9955. var wVal = it.width > 0 ? it.width.toFixed(2) : '';
  9956. var hVal = it.height > 0 ? it.height.toFixed(2) : '';
  9957. var billingType = it.billingType || (it.billingMethod ? qClassifyMethod(it.billingMethod) : ((it.width>0&&it.height>0)?'area':'count'));
  9958. var aVal = billingType==='area' && it.area > 0 ? it.area.toFixed(2) : '';
  9959. var qtyNum = Number(it.count);
  9960. if(!isFinite(qtyNum)||qtyNum<=0) qtyNum = Number(it.multiplier||0);
  9961. if((!isFinite(qtyNum)||qtyNum<=0) && billingType!=='area') qtyNum = Number(it.area||0);
  9962. var qtyVal = billingType==='area'||qtyNum<=0 ? '' : (Number.isInteger(qtyNum)?String(qtyNum):qtyNum.toFixed(2));
  9963. var upVal = it.unitPrice > 0 ? it.unitPrice.toFixed(0) : '';
  9964. var nVal = qMaterialOnlyNote(it.note, r, it);
  9965. if (isFirst){
  9966. roomItemsHtml += '<tr>'+
  9967. '<td>'+seq+'</td>'+
  9968. '<td contenteditable="true" style="text-align:left;font-weight:600;">'+r.roomName+'</td>'+
  9969. '<td contenteditable="true" style="text-align:left;">'+it.name+'</td>'+
  9970. '<td contenteditable="true">'+wVal+'</td>'+
  9971. '<td contenteditable="true">'+hVal+'</td>'+
  9972. '<td contenteditable="true">'+aVal+'</td>'+
  9973. '<td contenteditable="true">'+qtyVal+'</td>'+
  9974. '<td contenteditable="true">'+upVal+'</td>'+
  9975. '<td contenteditable="true" style="text-align:right;">'+ttl.toLocaleString()+'</td>'+
  9976. '<td contenteditable="true" style="font-size:10px;color:#666;">'+nVal+'</td>'+
  9977. '</tr>';
  9978. isFirst = false;
  9979. } else {
  9980. roomItemsHtml += '<tr>'+
  9981. '<td></td><td contenteditable="true" style="text-align:left;font-weight:600;">'+r.roomName+'</td>'+
  9982. '<td contenteditable="true" style="text-align:left;">'+it.name+'</td>'+
  9983. '<td contenteditable="true">'+wVal+'</td>'+
  9984. '<td contenteditable="true">'+hVal+'</td>'+
  9985. '<td contenteditable="true">'+aVal+'</td>'+
  9986. '<td contenteditable="true">'+qtyVal+'</td>'+
  9987. '<td contenteditable="true">'+upVal+'</td>'+
  9988. '<td contenteditable="true" style="text-align:right;">'+ttl.toLocaleString()+'</td>'+
  9989. '<td contenteditable="true" style="font-size:10px;color:#666;">'+nVal+'</td>'+
  9990. '</tr>';
  9991. }
  9992. });
  9993. var expTier = Q_CUSTOMER_TIERS[qCustomerTier];
  9994. var expPremium = (expTier && expTier.premiumRate) ? expTier.premiumRate : 1.0;
  9995. var roomBase = Math.round(roomOriginal * expPremium);
  9996. var roomDiscounted = Math.round(roomBase * qDiscountRate);
  9997. grandOriginal += roomBase;
  9998. grandDiscounted += roomDiscounted;
  9999. roomItemsHtml += '<tr style="font-weight:700;background:#F7FAFC;border-top:2px solid #ccc;">'+
  10000. '<td></td><td></td>'+
  10001. '<td style="text-align:right;">' + (expPremium>1?'加价后本空间原价:':'本空间原价:') + '</td>'+
  10002. '<td></td><td></td><td></td><td></td><td></td>'+
  10003. '<td contenteditable="true" style="text-align:right;color:#1F497D;">'+roomBase.toLocaleString()+'</td>'+
  10004. '<td></td></tr>'+
  10005. '<tr style="font-weight:700;background:'+(qDiscountRate<0.999?'#FFFFF0':'#F0FFF4')+';">'+
  10006. '<td></td><td></td>'+
  10007. '<td style="text-align:right;color:'+(qDiscountRate<0.999?'#C05621':'#38A169')+';">'+(qDiscountRate<0.999?'本空间投影面积折扣 (×'+ratePct+'%):':'✓ 本空间原价(无折扣):')+'</td>'+
  10008. '<td></td><td></td><td></td><td></td><td></td>'+
  10009. '<td contenteditable="true" style="text-align:right;color:'+(qDiscountRate<0.999?'#C05621':'#38A169')+';">'+roomDiscounted.toLocaleString()+'</td>'+
  10010. '<td></td></tr>';
  10011. roomSections += roomItemsHtml;
  10012. });
  10013. var html = '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>'+projectTitle+'</title>'+
  10014. '<style>'+
  10015. 'body{font-family:"Microsoft YaHei","PingFang SC",sans-serif;padding:30px 40px;color:#333;line-height:1.6;max-width:1100px;margin:0 auto;}'+
  10016. 'h1{text-align:center;font-size:22px;margin-bottom:4px;padding-bottom:10px;border-bottom:2px solid #333;}'+
  10017. '.meta{text-align:center;font-size:12px;color:#666;margin-bottom:16px;line-height:1.8;}'+
  10018. '.note-bar{text-align:center;font-size:11px;color:#999;margin-bottom:10px;padding:4px 10px;background:#FFF8E1;border:1px solid #FFE082;border-radius:4px;}'+
  10019. 'table{width:100%;border-collapse:collapse;margin:12px 0;font-size:12px;}'+
  10020. 'th,td{border:1px solid #ccc;padding:5px 7px;text-align:center;}'+
  10021. 'th{background:#f0f0f0;font-weight:600;font-size:11px;}'+
  10022. 'td[contenteditable="true"]{background:#FFFAF0;cursor:text;transition:background 0.15s;}'+
  10023. 'td[contenteditable="true"]:hover{background:#FFF3CD;}'+
  10024. 'td[contenteditable="true"]:focus{background:#FFF;outline:2px solid #3182CE;outline-offset:-2px;}'+
  10025. '.total-row td{font-weight:700;font-size:13px;}'+
  10026. '.footer{font-size:11px;color:#999;text-align:left;margin-top:24px;border-top:1px solid #ddd;padding-top:12px;}'+
  10027. '.footer div{margin:2px 0;}'+
  10028. '@media print{'+
  10029. 'body{padding:15px 25px;}'+
  10030. 'td[contenteditable="true"]{background:transparent;}'+
  10031. '.note-bar{display:none;}'+
  10032. '}'+
  10033. '</style></head><body>'+
  10034. '<h1 contenteditable="true">'+projectTitle+'</h1>'+
  10035. '<div class="meta" contenteditable="true">'+metaInfo+'</div>'+
  10036. '<div style="text-align:center;margin-bottom:12px;">'+
  10037. '<span style="font-size:12px;color:var(--text-muted);">报价日期:'+today+' | 单空间投影面积折扣:'+ratePct+'%</span>'+
  10038. '</div>'+
  10039. '<div class="note-bar">💡 所有浅黄色单元格均可直接点击编辑,修改后可用浏览器打印(Ctrl+P)导出PDF。报价按各空间投影面积独立折扣后累加计算</div>'+
  10040. dimSectionHtml +
  10041. '<h3 style="font-size:15px;font-weight:600;color:#333;margin:20px 0 10px;padding-bottom:6px;border-bottom:2px solid #1F497D;">二、空间子项明细</h3>'+
  10042. '<table><thead><tr>'+
  10043. '<th style="width:40px;">序号</th><th style="width:85px;">空间</th><th style="width:110px;">品名</th>'+
  10044. '<th style="width:55px;">宽(m)</th><th style="width:55px;">高(m)</th>'+
  10045. '<th style="width:65px;">面积(㎡)</th><th style="width:55px;">数量</th><th style="width:65px;">投影单价</th>'+
  10046. '<th style="width:80px;">总价</th><th style="min-width:130px;">备注</th></tr></thead><tbody>'+
  10047. roomSections +
  10048. '</tbody><tfoot>'+
  10049. '<tr class="total-row"><td colspan="8" style="text-align:right;">总投影面积:</td>'+
  10050. '<td style="text-align:right;">'+qGetTotalProjectArea().toFixed(2)+'</td><td>㎡</td></tr>'+
  10051. '<tr class="total-row"><td colspan="8" style="text-align:right;">各空间原价合计:</td>'+
  10052. '<td contenteditable="true" style="text-align:right;">'+grandOriginal.toLocaleString()+'</td><td></td></tr>'+
  10053. '<tr class="total-row"><td colspan="8" style="text-align:right;color:#C05621;">各空间投影面积折扣后合计(累加):</td>'+
  10054. '<td contenteditable="true" style="text-align:right;color:#C05621;"></td><td></td></tr>'+
  10055. '</tfoot></table>'+
  10056. '<div class="footer">'+
  10057. '<div contenteditable="true"><strong>注意:</strong>此份报价仅作为前期与客户沟通的预估价格,具体以实际设计方案为主,此份仅作参考。</div>'+
  10058. '<div contenteditable="true"><strong>家居顾问:</strong></div>'+
  10059. '<div contenteditable="true"><strong>门店地址:</strong></div>'+
  10060. '<div><strong>当日日期:</strong>'+today+'</div>'+
  10061. '</div></body></html>';
  10062. const blob = new Blob([html], {type:'text/html;charset=utf-8'});
  10063. const url = URL.createObjectURL(blob);
  10064. const a = document.createElement('a');
  10065. a.href = url;
  10066. a.download = exportFilename;
  10067. document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url);
  10068. qAlertDialog('已导出报价单:'+exportFilename+'\n\n💡 标题/数据行均可点击编辑,按 Ctrl+P 打印为PDF','导出成功');
  10069. }
  10070. // 第三步自动计价页专用导出:先同步规则配置中的最新单价,再导出所有柜体基础项与增值项。
  10071. async function qExportCabinetDetailsExcel(){
  10072. if(!qRooms||!qRooms.length){showToast('暂无可导出的柜体明细','warning');return;}
  10073. qSyncRoomItemsWithRules();
  10074. try{qRenderStep4();}catch(e){}
  10075. showToast('正在生成各柜体Excel明细表…','info');
  10076. await qExportExcel();
  10077. }
  10078. // ── Excel导出功能(按空间分类排版,参考万骊报价单格式)──
  10079. async function qExportExcel(){
  10080. if(typeof ExcelJS === 'undefined'){
  10081. showToast('Excel库加载中,请稍后重试...','warning');
  10082. return;
  10083. }
  10084. var projectTitle = qExtractProjectName();
  10085. var today = new Date();
  10086. var _mm = String(today.getMonth()+1); if(_mm.length<2) _mm='0'+_mm;
  10087. var _dd = String(today.getDate()); if(_dd.length<2) _dd='0'+_dd;
  10088. var dateStr = today.getFullYear() + '.' + _mm + '.' + _dd;
  10089. var ratePct = Math.round(qDiscountRate * 100);
  10090. var safeTitle = projectTitle.replace(/[\\/:*?"<>|]/g, '-').replace(/[\s·]/g, '_').substring(0, 50);
  10091. var exportFilename = safeTitle + '_' + dateStr.replace(/\./g,'') + '.xlsx';
  10092. var wb = new ExcelJS.Workbook();
  10093. wb.creator = 'AI快速报价系统';
  10094. wb.created = today;
  10095. var ws = wb.addWorksheet('全屋柜类报价', {
  10096. pageSetup: { paperSize: 9, orientation: 'portrait', fitToPage: true, fitToWidth: 1, fitToHeight: 0, margins: { left: 0.7, right: 0.7, top: 0.75, bottom: 0.75, header: 0.3, footer: 0.3 } },
  10097. views: [{ zoomScale: 130, zoomScaleNormal: 130 }]
  10098. });
  10099. // ── 样式定义(完全对标参考表《6.18号欧阳路预估报价.xlsx》:字体/字号/单元格颜色/边框)──
  10100. // 参考表字体:微软雅黑;正文色=深灰404040(theme1 tint0.25),标题18pt加粗,表头11pt加粗
  10101. var QX_COLOR = { argb: 'FF404040' }; // 参考表正文深灰字色
  10102. var QX_FILL_BEIGE = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFD6D4CA' } }; // 参考表标题/表头/左侧列底色(theme2加深10%)
  10103. var QX_FILL_BLUE = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFD2DBE5' } }; // 参考表折后合计浅蓝
  10104. var QX_FILL_RED = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF2DCDB' } }; // 参考表全屋合计浅红
  10105. var QX_FILL_TITLE = QX_FILL_BEIGE, QX_FILL_HEADER = QX_FILL_BEIGE;
  10106. var fontTitle = { name: '微软雅黑', size: 18, bold: true, color: QX_COLOR };
  10107. var fontHeader = { name: '微软雅黑', size: 11, bold: true, color: QX_COLOR };
  10108. var fontNormal = { name: '微软雅黑', size: 11, color: QX_COLOR };
  10109. var fontBold = { name: '微软雅黑', size: 11, bold: true, color: QX_COLOR };
  10110. var fontRed = { name: '微软雅黑', size: 11, color: { argb: 'FFFF0000' } };
  10111. var fontFooter = { name: '微软雅黑', size: 11, bold: true, color: QX_COLOR };
  10112. var alignCenter = { horizontal: 'center', vertical: 'middle', wrapText: true };
  10113. var alignRight = { horizontal: 'right', vertical: 'middle' };
  10114. var alignLeft = { horizontal: 'left', vertical: 'middle', wrapText: true };
  10115. // 参考表边框:thin + auto(黑色)细线全框
  10116. var borderThin = {
  10117. top: { style: 'thin' },
  10118. bottom: { style: 'thin' },
  10119. left: { style: 'thin' },
  10120. right: { style: 'thin' }
  10121. };
  10122. // 参考表列宽(与《6.18号欧阳路预估报价.xlsx》逐列一致)
  10123. ws.columns = [
  10124. { width: 6.18 }, // A: 序号
  10125. { width: 13.02 }, // B: 品名(空间名称)
  10126. { width: 15.07 }, // C: 子项
  10127. { width: 8.88 }, // D: 宽(米)
  10128. { width: 12.37 }, // E: 高(米)
  10129. { width: 9.25 }, // F: 面积(m²)
  10130. { width: 9.11 }, // G: 数量
  10131. { width: 11.91 }, // H: 投影单价
  10132. { width: 22.56 }, // I: 总价
  10133. { width: 22.75 }, // J: 备注
  10134. { width: 12.63 } // K: 图片占位(用户可插入图片)
  10135. ];
  10136. var curRow = 1;
  10137. // ── 第1行:标题(参考表:A:J合并、18pt加粗微软雅黑、米灰加深底色、细线边框、行高28)──
  10138. ws.mergeCells('A1:K1');
  10139. var titleCell = ws.getCell('A1');
  10140. titleCell.value = projectTitle;
  10141. titleCell.font = fontTitle;
  10142. titleCell.alignment = alignCenter;
  10143. titleCell.border = borderThin;
  10144. titleCell.fill = QX_FILL_TITLE;
  10145. for(var tci=2; tci<=11; tci++){ var tc2=ws.getCell(1,tci); tc2.border=borderThin; tc2.fill=QX_FILL_TITLE; }
  10146. ws.getRow(1).height = 28;
  10147. curRow = 2;
  10148. var seq = 0;
  10149. var totalRowRefs = []; // 记录每个空间"总价"行的H列单元格引用
  10150. // ── 遍历每个空间/柜体 ──
  10151. qRooms.forEach(function(r, ri){
  10152. if(qIsRoomExcluded(ri)) return; // 不计入计算的空间不导出
  10153. var items = (qRoomItems && qRoomItems[ri]) ? qRoomItems[ri] : [];
  10154. if(!items.length) return;
  10155. seq++;
  10156. var spaceName = r.roomName || r.cabinetType || ('柜体'+seq);
  10157. var itemCount = items.length;
  10158. var blockStartRow = curRow; // 表头行
  10159. var dataStartRow = curRow + 1;
  10160. var dataEndRow = dataStartRow + itemCount - 1;
  10161. var totalRow = dataEndRow + 1;
  10162. var discRow = totalRow + 1;
  10163. var blockEndRow = discRow;
  10164. // 表头行(模式一厨房按总长度计价:面积列标题改为"总长度");参考表表头:11pt加粗+米灰底色
  10165. var isKitchenBlock = state.quoteMode==='ai' && qIsKitchenCabinet(r.cabinetType || r.roomName);
  10166. var headers = ['序号', '品名', '', '宽(米)', '高(米)', isKitchenBlock?'总长度':'面积(m²)', '数量', '投影单价', '总价', '备注', ''];
  10167. headers.forEach(function(h, ci){
  10168. var cell = ws.getCell(curRow, ci+1);
  10169. cell.value = h;
  10170. cell.font = fontHeader;
  10171. cell.alignment = alignCenter;
  10172. cell.border = borderThin;
  10173. cell.fill = QX_FILL_HEADER;
  10174. });
  10175. ws.mergeCells(curRow, 2, curRow, 3); // 品名跨B:C(参考表表头合并)
  10176. ws.mergeCells(curRow, 10, curRow, 11); // 备注跨J:K
  10177. ws.getRow(curRow).height = 18;
  10178. // 数据行(所有数值直接取qRoomItems,与系统界面显示完全一致,不做二次计算/舍入)
  10179. var roomTotalH = [];
  10180. items.forEach(function(it, ii){
  10181. var rIdx = dataStartRow + ii;
  10182. var wVal = (it.width && it.width > 0) ? +Number(it.width).toFixed(2) : null;
  10183. var hVal = (it.height && it.height > 0) ? +Number(it.height).toFixed(2) : null;
  10184. var aVal = (it.area && it.area > 0) ? +Number(it.area).toFixed(2) : null;
  10185. var upVal = (it.unitPrice && it.unitPrice > 0) ? +Number(it.unitPrice).toFixed(2) : null;
  10186. var ttlVal = (it.total !== undefined && it.total !== null) ? +Number(it.total).toFixed(2) : null;
  10187. var billingType = it.billingType || (it.billingMethod ? qClassifyMethod(it.billingMethod) : ((wVal !== null && hVal !== null)?'area':'count'));
  10188. var isAreaItem = billingType === 'area';
  10189. var qtyVal = Number(it.count);
  10190. if(!isFinite(qtyVal)||qtyVal<=0) qtyVal = Number(it.multiplier||0);
  10191. if((!isFinite(qtyVal)||qtyVal<=0) && !isAreaItem) qtyVal = Number(it.area||0);
  10192. if(isAreaItem || qtyVal<=0) qtyVal = null;
  10193. // 厨房按总长度计价:"总长度"列直接使用明细中存储的计费米数(=系统界面值),宽/高与系统柜体宽/高一致,数量列留空
  10194. var kitchenLenVal = null;
  10195. if(state.quoteMode==='ai' && qIsKitchenCabinet(r.cabinetType || r.roomName) && (it.itemType==='cabinet'||it.name==='柜体'||it.billingMethod==='kitchen_item_formula')){
  10196. if(aVal !== null){ kitchenLenVal = aVal; isAreaItem=false; qtyVal=null; }
  10197. }
  10198. // A列: 序号(合并后显示)
  10199. var cellA = ws.getCell(rIdx, 1);
  10200. cellA.border = borderThin;
  10201. cellA.alignment = alignCenter;
  10202. cellA.font = fontNormal;
  10203. cellA.fill = QX_FILL_HEADER;
  10204. // B列: 品名(合并后显示空间名)
  10205. var cellB = ws.getCell(rIdx, 2);
  10206. cellB.border = borderThin;
  10207. cellB.alignment = alignCenter;
  10208. cellB.font = fontNormal;
  10209. cellB.fill = QX_FILL_HEADER;
  10210. // C列: 子项名称
  10211. var cellC = ws.getCell(rIdx, 3);
  10212. cellC.value = it.name || '';
  10213. cellC.font = fontNormal;
  10214. cellC.alignment = alignCenter;
  10215. cellC.border = borderThin;
  10216. cellC.fill = QX_FILL_HEADER;
  10217. // D列: 宽(米)— 面积项/厨房总长度项有值
  10218. var cellD = ws.getCell(rIdx, 4);
  10219. if(isAreaItem || kitchenLenVal !== null) cellD.value = wVal;
  10220. cellD.font = fontNormal;
  10221. cellD.alignment = alignCenter;
  10222. cellD.border = borderThin;
  10223. // 宽(米):参考表为常规格式,不设数字格式
  10224. // E列: 高(米)
  10225. var cellE = ws.getCell(rIdx, 5);
  10226. if((isAreaItem || kitchenLenVal !== null) && hVal !== null) cellE.value = hVal;
  10227. cellE.font = fontNormal;
  10228. cellE.alignment = alignCenter;
  10229. cellE.border = borderThin;
  10230. // 高(米):参考表为常规格式,不设数字格式
  10231. // F列: 面积(m²)/总长度 — 直接取系统明细值,保证与界面一致
  10232. var cellF = ws.getCell(rIdx, 6);
  10233. if(isAreaItem && aVal !== null){
  10234. cellF.value = aVal;
  10235. cellF.numFmt = '0.00';
  10236. } else if(kitchenLenVal !== null){
  10237. cellF.value = kitchenLenVal;
  10238. cellF.numFmt = '0.00';
  10239. }
  10240. cellF.font = fontNormal;
  10241. cellF.alignment = alignCenter;
  10242. cellF.border = borderThin;
  10243. // G列: 数量(与面积分列)
  10244. var cellG = ws.getCell(rIdx, 7);
  10245. if(qtyVal !== null) cellG.value = qtyVal;
  10246. cellG.font = fontNormal;
  10247. cellG.alignment = alignCenter;
  10248. cellG.border = borderThin;
  10249. cellG.numFmt = qtyVal && qtyVal % 1 !== 0 ? '0.00' : '0';
  10250. // H列: 单价 — 直接取系统单价(不取整,含小数时按两位小数显示)
  10251. var cellH = ws.getCell(rIdx, 8);
  10252. if(upVal !== null) cellH.value = upVal;
  10253. cellH.font = fontNormal;
  10254. cellH.alignment = alignCenter;
  10255. cellH.border = borderThin;
  10256. cellH.numFmt = (upVal !== null && upVal % 1 !== 0) ? '0.00' : '0';
  10257. // I列: 总价 — 直接取系统小计,确保与界面一致
  10258. var cellI = ws.getCell(rIdx, 9);
  10259. if(ttlVal !== null){
  10260. cellI.value = ttlVal;
  10261. } else if(upVal !== null){
  10262. if(kitchenLenVal !== null) cellI.value = +(upVal*kitchenLenVal).toFixed(2);
  10263. else if(isAreaItem && aVal !== null) cellI.value = +(upVal*aVal).toFixed(2);
  10264. else if(qtyVal !== null) cellI.value = +(upVal*qtyVal).toFixed(2);
  10265. }
  10266. cellI.font = fontNormal;
  10267. cellI.alignment = alignCenter;
  10268. cellI.border = borderThin;
  10269. cellI.numFmt = '0.00';
  10270. roomTotalH.push('I'+rIdx);
  10271. // J列: 备注
  10272. var cellJ = ws.getCell(rIdx, 10);
  10273. cellJ.value = qMaterialOnlyNote(it.note, r, it);
  10274. cellJ.font = fontNormal;
  10275. cellJ.alignment = alignLeft;
  10276. cellJ.border = borderThin;
  10277. // K列: 图片占位
  10278. var cellK = ws.getCell(rIdx, 11);
  10279. cellK.border = borderThin;
  10280. cellK.alignment = alignCenter;
  10281. // 数值部分(宽/高/面积·总长度/数量/单价/总价/备注/图片)统一白底、不填充颜色
  10282. ws.getRow(rIdx).height = 18;
  10283. });
  10284. // 总价行(参考表:整行米灰底色,C:H合并写"总价",总价列0.00格式)
  10285. var totalHRef = 'I'+totalRow;
  10286. ws.mergeCells(totalRow, 3, totalRow, 8); // C到H合并
  10287. for(var ci=1; ci<=11; ci++){
  10288. var tc3 = ws.getCell(totalRow, ci);
  10289. tc3.border = borderThin;
  10290. tc3.fill = QX_FILL_HEADER;
  10291. }
  10292. var tCellC = ws.getCell(totalRow, 3);
  10293. tCellC.value = '总价 ';
  10294. tCellC.font = fontBold;
  10295. tCellC.alignment = alignRight;
  10296. var tCellH = ws.getCell(totalRow, 9);
  10297. tCellH.value = { formula: 'SUM(I'+dataStartRow+':I'+dataEndRow+')' };
  10298. tCellH.font = fontBold;
  10299. tCellH.alignment = alignCenter;
  10300. tCellH.numFmt = '0.00';
  10301. var tCellI = ws.getCell(totalRow, 10);
  10302. tCellI.value = '/';
  10303. tCellI.font = fontBold;
  10304. tCellI.alignment = alignCenter;
  10305. ws.getRow(totalRow).height = 18;
  10306. // 折后合计行(参考表:A/B米灰、C~J浅蓝D2DBE5;系统单价为原价、数据单元格留空)
  10307. ws.mergeCells(discRow, 3, discRow, 8);
  10308. for(var ci=1; ci<=11; ci++){
  10309. var dc3 = ws.getCell(discRow, ci);
  10310. dc3.border = borderThin;
  10311. if(ci<=2) dc3.fill = QX_FILL_HEADER;
  10312. else if(ci<=10) dc3.fill = QX_FILL_BLUE;
  10313. }
  10314. var dCellC = ws.getCell(discRow, 3);
  10315. dCellC.value = '折后合计 ';
  10316. dCellC.font = fontBold;
  10317. dCellC.alignment = alignRight;
  10318. var dCellH = ws.getCell(discRow, 9);
  10319. dCellH.font = fontBold;
  10320. dCellH.alignment = alignCenter;
  10321. dCellH.numFmt = '0.00';
  10322. var dCellI = ws.getCell(discRow, 10);
  10323. dCellI.value = '/';
  10324. dCellI.font = fontBold;
  10325. dCellI.alignment = alignCenter;
  10326. ws.getRow(discRow).height = 18;
  10327. // 合并A列和B列(参考表:竖向合并从第一数据行开始,不含表头行)
  10328. ws.mergeCells(dataStartRow, 1, blockEndRow, 1);
  10329. ws.mergeCells(dataStartRow, 2, blockEndRow, 2);
  10330. ws.getCell(dataStartRow, 1).value = seq;
  10331. ws.getCell(dataStartRow, 1).font = fontNormal;
  10332. ws.getCell(dataStartRow, 1).alignment = alignCenter;
  10333. ws.getCell(dataStartRow, 1).border = borderThin;
  10334. ws.getCell(dataStartRow, 1).fill = QX_FILL_HEADER;
  10335. ws.getCell(dataStartRow, 2).value = spaceName;
  10336. ws.getCell(dataStartRow, 2).font = fontNormal;
  10337. ws.getCell(dataStartRow, 2).alignment = alignCenter;
  10338. ws.getCell(dataStartRow, 2).border = borderThin;
  10339. ws.getCell(dataStartRow, 2).fill = QX_FILL_HEADER;
  10340. // 给合并区域中其余单元格也设置边框
  10341. for(var rr=dataStartRow+1; rr<=blockEndRow; rr++){
  10342. ws.getCell(rr, 1).border = borderThin;
  10343. ws.getCell(rr, 2).border = borderThin;
  10344. }
  10345. totalRowRefs.push(totalHRef);
  10346. curRow = blockEndRow + 1;
  10347. });
  10348. // ── 汇总区域 ──
  10349. // 总投影面积行:显示在全屋原价合计之前
  10350. var areaSumRow = curRow;
  10351. ws.mergeCells(areaSumRow, 1, areaSumRow, 8);
  10352. var asA = ws.getCell(areaSumRow, 1);
  10353. asA.value = '总投影面积:';
  10354. asA.font = fontFooter;
  10355. asA.alignment = alignRight;
  10356. asA.border = borderThin;
  10357. for(var aci=2; aci<=11; aci++){ ws.getCell(areaSumRow, aci).border = borderThin; }
  10358. var asH = ws.getCell(areaSumRow, 9);
  10359. asH.value = qGetTotalProjectArea();
  10360. asH.font = fontFooter;
  10361. asH.alignment = alignCenter;
  10362. asH.border = borderThin;
  10363. asH.numFmt = '#,##0.00';
  10364. asH.fill = QX_FILL_RED;
  10365. var asJ = ws.getCell(areaSumRow, 10);
  10366. asJ.value = '㎡';
  10367. asJ.font = fontFooter;
  10368. asJ.alignment = alignCenter;
  10369. asJ.border = borderThin;
  10370. asJ.fill = QX_FILL_RED;
  10371. ws.getRow(areaSumRow).height = 20;
  10372. curRow++;
  10373. var sumRow1 = curRow;
  10374. ws.mergeCells(sumRow1, 1, sumRow1, 8);
  10375. var s1a = ws.getCell(sumRow1, 1);
  10376. s1a.value = '全屋原价合计:';
  10377. s1a.font = fontFooter;
  10378. s1a.alignment = alignRight;
  10379. s1a.border = borderThin;
  10380. for(var ci=2; ci<=11; ci++){
  10381. ws.getCell(sumRow1, ci).border = borderThin;
  10382. }
  10383. var s1h = ws.getCell(sumRow1, 9);
  10384. s1h.value = { formula: totalRowRefs.join('+') };
  10385. s1h.font = fontFooter;
  10386. s1h.alignment = alignCenter;
  10387. s1h.border = borderThin;
  10388. s1h.numFmt = '#,##0.00';
  10389. s1h.fill = QX_FILL_RED;
  10390. var s1j = ws.getCell(sumRow1, 10);
  10391. s1j.border = borderThin;
  10392. s1j.fill = QX_FILL_RED;
  10393. ws.getRow(sumRow1).height = 20;
  10394. curRow++;
  10395. var sumRow2 = curRow;
  10396. ws.mergeCells(sumRow2, 1, sumRow2, 8);
  10397. var s2a = ws.getCell(sumRow2, 1);
  10398. s2a.value = '全屋折后合计(×'+ratePct+'%):';
  10399. s2a.font = fontFooter;
  10400. s2a.alignment = alignRight;
  10401. s2a.border = borderThin;
  10402. for(var ci=1; ci<=11; ci++){
  10403. var s2c = ws.getCell(sumRow2, ci);
  10404. s2c.border = borderThin;
  10405. s2c.fill = QX_FILL_RED;
  10406. }
  10407. var s2h = ws.getCell(sumRow2, 9);
  10408. s2h.font = fontFooter;
  10409. s2h.alignment = alignCenter;
  10410. s2h.numFmt = '0';
  10411. var s2j = ws.getCell(sumRow2, 10);
  10412. ws.getRow(sumRow2).height = 20;
  10413. // 注意事项
  10414. curRow++;
  10415. var noteRow = curRow;
  10416. ws.mergeCells(noteRow, 1, noteRow, 11);
  10417. var nc = ws.getCell(noteRow, 1);
  10418. nc.value = '注意:此份报价仅作为前期与客户沟通的预估价格,具体以实际设计方案为主,此份仅作参考';
  10419. nc.font = fontRed;
  10420. nc.alignment = alignLeft;
  10421. nc.border = borderThin;
  10422. for(var nci=2; nci<=11; nci++){ ws.getCell(noteRow, nci).border = borderThin; }
  10423. ws.getRow(noteRow).height = 18;
  10424. // 家居顾问
  10425. curRow++;
  10426. var advisorRow = curRow;
  10427. ws.mergeCells(advisorRow, 1, advisorRow, 11);
  10428. var ac = ws.getCell(advisorRow, 1);
  10429. ac.value = '家居顾问:';
  10430. ac.font = fontFooter;
  10431. ac.alignment = alignLeft;
  10432. ac.border = borderThin;
  10433. for(var fci=2; fci<=11; fci++){ ws.getCell(advisorRow, fci).border = borderThin; }
  10434. ws.getRow(advisorRow).height = 18;
  10435. // 门店地址
  10436. curRow++;
  10437. var addrRow = curRow;
  10438. ws.mergeCells(addrRow, 1, addrRow, 11);
  10439. var adc = ws.getCell(addrRow, 1);
  10440. adc.value = '门店地址:万骊总部旗舰店';
  10441. adc.font = fontFooter;
  10442. adc.alignment = alignLeft;
  10443. adc.border = borderThin;
  10444. for(var fci=2; fci<=11; fci++){ ws.getCell(addrRow, fci).border = borderThin; }
  10445. ws.getRow(addrRow).height = 18;
  10446. // 当日日期
  10447. curRow++;
  10448. var dateRow = curRow;
  10449. ws.mergeCells(dateRow, 1, dateRow, 11);
  10450. var dc = ws.getCell(dateRow, 1);
  10451. dc.value = '当日日期:' + dateStr;
  10452. dc.font = fontFooter;
  10453. dc.alignment = alignLeft;
  10454. dc.border = borderThin;
  10455. for(var fci=2; fci<=11; fci++){ ws.getCell(dateRow, fci).border = borderThin; }
  10456. ws.getRow(dateRow).height = 18;
  10457. // ── 生成文件并下载 ──
  10458. var buffer = await wb.xlsx.writeBuffer();
  10459. var blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
  10460. // 本地预览模式下优先由安全后端直接保存到当前Windows用户桌面。
  10461. try {
  10462. var bytes = new Uint8Array(buffer);
  10463. var binary = '';
  10464. var chunkSize = 0x8000;
  10465. for(var bi=0; bi<bytes.length; bi+=chunkSize){
  10466. binary += String.fromCharCode.apply(null, bytes.subarray(bi, Math.min(bi+chunkSize, bytes.length)));
  10467. }
  10468. var saveResponse = await fetch('/api/export/excel', {
  10469. method:'POST',
  10470. headers:{'Content-Type':'application/json'},
  10471. body:JSON.stringify({filename:exportFilename,data:btoa(binary)})
  10472. });
  10473. if(saveResponse.ok){
  10474. var savedResult = await saveResponse.json();
  10475. qAlertDialog('✅ Excel报价单已自动保存到桌面:'+savedResult.filename+'\n\n📌 备注仅保留实际使用的材质。\n📌 所有数据与系统界面完全一致。','导出成功');
  10476. return;
  10477. }
  10478. } catch(saveError) {
  10479. console.warn('自动保存桌面失败,已回退浏览器下载:', saveError);
  10480. }
  10481. // 非本地预览或接口不可用时,回退为浏览器标准下载。
  10482. var url = URL.createObjectURL(blob);
  10483. var a = document.createElement('a');
  10484. a.href = url;
  10485. a.download = exportFilename;
  10486. document.body.appendChild(a);
  10487. a.click();
  10488. document.body.removeChild(a);
  10489. URL.revokeObjectURL(url);
  10490. qAlertDialog('✅ Excel报价单已导出:'+exportFilename+'\n\n📌 本地桌面保存接口不可用,文件已使用浏览器下载。\n📌 备注仅保留实际使用的材质。','导出成功');
  10491. }
  10492. function qResetUploadZones(){
  10493. qUploadedDrawing=null;qUploadedCAD=null;qUploadedRequirement=null;
  10494. if(qUploadedDrawingUrl){URL.revokeObjectURL(qUploadedDrawingUrl);qUploadedDrawingUrl=null;}
  10495. if(qUploadedCADUrl){URL.revokeObjectURL(qUploadedCADUrl);qUploadedCADUrl=null;}
  10496. qRenderEnabled=false;qRenderFiles=[];qRenderResults=null;
  10497. const zd=document.getElementById('q-upload-zone-drawing');
  10498. if(zd){zd.style.borderColor='';zd.style.background='';zd.onclick=function(){if(!qUploadedDrawing)document.getElementById('q-file-drawing').click();};}
  10499. var di = document.getElementById('q-drawing-inner');
  10500. if(di) di.innerHTML='<div class="icon">🏠</div><div class="text">点击上传户型图</div><div class="types">支持 JPG、PNG、WEBP | 点击区域选择文件</div>';
  10501. const zc=document.getElementById('q-upload-zone-cad');
  10502. if(zc){zc.style.borderColor='';zc.style.background='';zc.onclick=function(){if(!qUploadedCAD)document.getElementById('q-file-cad').click();};}
  10503. var ci = document.getElementById('q-cad-inner');
  10504. if(ci) ci.innerHTML='<div class="icon">📏</div><div class="text">点击上传CAD图纸</div><div class="types">支持 DWG、DXF、PDF | 可单独上传,也可与户型图一起上传</div>';
  10505. const zr=document.getElementById('q-upload-zone-req');
  10506. if(zr){zr.style.borderColor='';zr.style.background='';}
  10507. var ri = document.getElementById('q-req-inner');
  10508. if(ri) ri.innerHTML='<div class="icon">📝</div><div class="text">点击上传客户需求记录</div><div class="types">支持图片、文本、PDF格式</div>';
  10509. const f1=document.getElementById('q-upload-files-drawing');if(f1)f1.innerHTML='';
  10510. const fc=document.getElementById('q-upload-files-cad');if(fc)fc.innerHTML='';
  10511. const f2=document.getElementById('q-upload-files-req');if(f2)f2.innerHTML='';
  10512. document.getElementById('q-file-drawing').value='';
  10513. document.getElementById('q-file-cad').value='';
  10514. var fu=document.getElementById('q-file-unified');if(fu)fu.value='';
  10515. var rpD = document.getElementById('q-btn-replace-drawing'); if(rpD) rpD.style.display = 'none';
  10516. var rpC = document.getElementById('q-btn-replace-cad'); if(rpC) rpC.style.display = 'none';
  10517. // Reset render toggle UI
  10518. const bg=document.getElementById('q-render-toggle-bg');if(bg)bg.style.background='var(--border)';
  10519. const dot=document.getElementById('q-render-toggle-dot');if(dot)dot.style.left='2px';
  10520. const lbl=document.getElementById('q-render-toggle-label');if(lbl){lbl.textContent='关闭';lbl.style.color='var(--text-muted)';}
  10521. const area=document.getElementById('q-render-upload-area');if(area)area.style.display='none';
  10522. const f3=document.getElementById('q-upload-files-render');if(f3)f3.innerHTML='';
  10523. const z3=document.getElementById('q-upload-zone-render');
  10524. if(z3){z3.style.borderColor='';z3.style.background='';z3.innerHTML='<div class="icon">🖼️</div><div class="text">点击上传空间效果图</div><div class="types">支持 JPG / PNG / WEBP(≤20MB),可多张上传</div>';}
  10525. qSyncUnifiedZone();
  10526. qCheckUploadReady();
  10527. }
  10528. function createNewQuote() {
  10529. state.currentQuoteId = 'new_' + Date.now();
  10530. state.currentStep = 1;
  10531. state.currentFloatRatio = state.globalFloatRatio;
  10532. state.currentPricingMode = state.defaultPricingMode || 'package';
  10533. state.currentFixedUnitPrice = state.defaultFixedUnitPrice || 0;
  10534. state.spaces = JSON.parse(JSON.stringify(DEFAULT_SPACES));
  10535. state.extraItems = JSON.parse(JSON.stringify(DEFAULT_EXTRA_ITEMS));
  10536. state.uploads = [];
  10537. state.pricingResult = null;
  10538. // Reset 5-step quotation state
  10539. qRooms = [];
  10540. qExtras = [];
  10541. qRoomItems = [];
  10542. qDiscountRate = 1.00;
  10543. qFloorHeight = 2.5;
  10544. qAiProcessed = false;
  10545. qAiAnalyzing = false;
  10546. qUploadedDrawing = null;
  10547. qUploadedDrawingUrl = null;
  10548. qUploadedCAD = null;
  10549. qUploadedCADUrl = null;
  10550. qUploadedRequirement = null;
  10551. if(qUploadedRequirementUrl){ URL.revokeObjectURL(qUploadedRequirementUrl); }
  10552. qUploadedRequirementUrl = null;
  10553. qCurrentFloatRatio = 0.15;
  10554. qCustomerTier = 'standard';
  10555. qTierSelectedDiscount = { price:'p1', standard:'s1', quality:'q1' };
  10556. qRenderEnabled = false;
  10557. qRenderFiles = [];
  10558. qRenderResults = null;
  10559. qActivityEnabled = false;
  10560. qActivityModuleList = qGetDefaultActivityModules();
  10561. qActivityNextId = 1;
  10562. qCalcRulesEnabled = false;
  10563. qCalcRulesList = JSON.parse(JSON.stringify(qGetSunLaoshiRules()));
  10564. qCalcRulesPreset = 'sun';
  10565. qCalcRulesNextId = 1;
  10566. // 多图纸模式重置
  10567. qMultiMode = false;
  10568. qSheets.forEach(function(s){
  10569. if(s.drawingUrl) URL.revokeObjectURL(s.drawingUrl);
  10570. if(s.cadUrl) URL.revokeObjectURL(s.cadUrl);
  10571. });
  10572. qSheets = [];
  10573. qMergedAnalysisData = null;
  10574. qSheetNextId = 1;
  10575. // 重置多图纸toggle UI
  10576. var mbg = document.getElementById('q-multi-toggle-bg');
  10577. var mdot = document.getElementById('q-multi-toggle-dot');
  10578. var mlbl = document.getElementById('q-multi-toggle-label');
  10579. if(mbg) mbg.style.background = 'var(--border)';
  10580. if(mdot) mdot.style.left = '2px';
  10581. if(mlbl){ mlbl.textContent = '关闭'; mlbl.style.color = 'var(--text-muted)'; }
  10582. var sArea = document.getElementById('q-single-sheet-area');
  10583. var mArea = document.getElementById('q-multi-sheet-area');
  10584. if(sArea) sArea.style.display = 'block';
  10585. if(mArea) mArea.style.display = 'none';
  10586. var sm = document.getElementById('q-status-multi');
  10587. if(sm) sm.style.display = 'none';
  10588. var sd2 = document.getElementById('q-status-drawing');
  10589. var sc2 = document.getElementById('q-status-cad');
  10590. if(sd2) sd2.style.display = 'inline-block';
  10591. if(sc2) sc2.style.display = 'inline-block';
  10592. if(document.getElementById('q-sheet-list')) document.getElementById('q-sheet-list').innerHTML = '';
  10593. state.uploads = [];
  10594. var titleEl = document.getElementById('editor-title');
  10595. if (titleEl) titleEl.textContent = '新建报价单';
  10596. var htEl = document.getElementById('house-type');
  10597. if (htEl) htEl.value = '三室两厅';
  10598. var customWrap = document.getElementById('house-type-custom-wrap');
  10599. if (customWrap) customWrap.style.display = 'none';
  10600. var customInput = document.getElementById('house-type-custom');
  10601. if (customInput) customInput.value = '';
  10602. switchView('editor');
  10603. goToStep(1);
  10604. // Reset upload zones
  10605. qResetUploadZones();
  10606. // 新建报价默认层高2.5m,输入框仍可直接点击修改。
  10607. var fhInput = document.getElementById('q-floor-height');
  10608. if (fhInput) fhInput.value = '2.5';
  10609. qFloorHeight = 2.5;
  10610. var fhDisplay = document.getElementById('q-step2-fh-display');
  10611. if (fhDisplay) fhDisplay.textContent = '2.50m';
  10612. }
  10613. function getHouseTypeValue() {
  10614. var sel = document.getElementById('house-type');
  10615. if (!sel) return '三室两厅';
  10616. if (sel.value === '__custom__') {
  10617. var customInput = document.getElementById('house-type-custom');
  10618. var customVal = customInput ? customInput.value.trim() : '';
  10619. return customVal || '其他';
  10620. }
  10621. return sel.value;
  10622. }
  10623. function onHouseTypeChange() {
  10624. var sel = document.getElementById('house-type');
  10625. var customWrap = document.getElementById('house-type-custom-wrap');
  10626. var customInput = document.getElementById('house-type-custom');
  10627. if (!sel || !customWrap) return;
  10628. if (sel.value === '__custom__') {
  10629. customWrap.style.display = 'block';
  10630. if (customInput) {
  10631. customInput.value = '';
  10632. setTimeout(function(){ customInput.focus(); }, 100);
  10633. }
  10634. } else {
  10635. customWrap.style.display = 'none';
  10636. if (customInput) customInput.value = '';
  10637. }
  10638. }
  10639. function setHouseTypeValue(value) {
  10640. var sel = document.getElementById('house-type');
  10641. var customWrap = document.getElementById('house-type-custom-wrap');
  10642. var customInput = document.getElementById('house-type-custom');
  10643. if (!sel) return;
  10644. var presetValues = ['两室一厅','两室两厅','三室一厅','三室两厅','四室两厅','复式/别墅'];
  10645. if (presetValues.indexOf(value) >= 0) {
  10646. sel.value = value;
  10647. if (customWrap) customWrap.style.display = 'none';
  10648. if (customInput) customInput.value = '';
  10649. } else {
  10650. sel.value = '__custom__';
  10651. if (customWrap) customWrap.style.display = 'block';
  10652. if (customInput) customInput.value = value || '';
  10653. }
  10654. }
  10655. function openEditorDirectly() {
  10656. state.currentQuoteId = null;
  10657. state.currentStep = 1;
  10658. state.currentFloatRatio = state.globalFloatRatio;
  10659. state.spaces = JSON.parse(JSON.stringify(DEFAULT_SPACES));
  10660. state.extraItems = JSON.parse(JSON.stringify(DEFAULT_EXTRA_ITEMS));
  10661. state.uploads = [];
  10662. state.pricingResult = null;
  10663. // Reset 5-step quotation state
  10664. qRooms = [];
  10665. qExtras = [];
  10666. qRoomItems = [];
  10667. qDiscountRate = 1.00;
  10668. qFloorHeight = 2.5;
  10669. qAiProcessed = false;
  10670. qAiAnalyzing = false;
  10671. qUploadedDrawing = null;
  10672. qUploadedDrawingUrl = null;
  10673. qUploadedCAD = null;
  10674. qUploadedCADUrl = null;
  10675. qUploadedRequirement = null;
  10676. if(qUploadedRequirementUrl){ URL.revokeObjectURL(qUploadedRequirementUrl); }
  10677. qUploadedRequirementUrl = null;
  10678. qCurrentFloatRatio = 0.15;
  10679. qCustomerTier = 'standard';
  10680. qTierSelectedDiscount = { price:'p1', standard:'s1', quality:'q1' };
  10681. qActivityEnabled = false;
  10682. qActivityModuleList = qGetDefaultActivityModules();
  10683. qActivityNextId = 1;
  10684. qCalcRulesEnabled = false;
  10685. qCalcRulesList = JSON.parse(JSON.stringify(qGetSunLaoshiRules()));
  10686. qCalcRulesPreset = 'sun';
  10687. qCalcRulesNextId = 1;
  10688. var defaultPlan = getDefaultPlan();
  10689. state.activePlanId = defaultPlan ? defaultPlan.id : null;
  10690. state.currentPricingMode = state.defaultPricingMode || 'package';
  10691. state.currentFixedUnitPrice = state.defaultFixedUnitPrice || 0;
  10692. if (defaultPlan) {
  10693. qCustomerTier = defaultPlan.customerTier;
  10694. qTierSelectedDiscount[defaultPlan.customerTier] = defaultPlan.tierLevel;
  10695. qCurrentFloatRatio = defaultPlan.floatRatio;
  10696. var tierCfg0 = Q_CUSTOMER_TIERS[defaultPlan.customerTier];
  10697. var discOpt0 = tierCfg0 && tierCfg0.discounts ? tierCfg0.discounts.find(function(d) { return d.id === defaultPlan.tierLevel; }) : null;
  10698. qDiscountRate = discOpt0 ? discOpt0.rate : defaultPlan.discountRate;
  10699. if (tierCfg0 && tierCfg0.autoEnableActivity) {
  10700. qActivityEnabled = true;
  10701. qActivityModuleList.forEach(function(m) { m.enabled = true; });
  10702. }
  10703. }
  10704. // 草稿/历史报价恢复时,把保存的层高同步回可编辑输入框。
  10705. var restoredFloorInput = document.getElementById('q-floor-height');
  10706. if (!(qFloorHeight > 0)) qFloorHeight = 2.5;
  10707. if (restoredFloorInput) restoredFloorInput.value = String(qFloorHeight);
  10708. var restoredFloorDisplay = document.getElementById('q-step2-fh-display');
  10709. if (restoredFloorDisplay) restoredFloorDisplay.textContent = qFloorHeight.toFixed(2) + 'm';
  10710. var planBadge = document.getElementById('q-active-plan-badge');
  10711. if (planBadge && defaultPlan) planBadge.textContent = defaultPlan.name;
  10712. var titleEl = document.getElementById('editor-title');
  10713. if (titleEl) titleEl.textContent = '新建报价单';
  10714. if (typeof setHouseTypeValue === 'function') setHouseTypeValue('三室两厅');
  10715. switchView('editor');
  10716. goToStep(1);
  10717. qResetUploadZones();
  10718. var fhInput = document.getElementById('q-floor-height');
  10719. if (fhInput) { fhInput.value = '2.5'; }
  10720. qFloorHeight = 2.5;
  10721. var fhDisplay = document.getElementById('q-step2-fh-display');
  10722. if (fhDisplay) fhDisplay.textContent = '2.50m';
  10723. var btnAnalyze = document.getElementById('q-btn-analyze');
  10724. if (btnAnalyze) { btnAnalyze.disabled = true; btnAnalyze.style.opacity='0.5'; btnAnalyze.style.cursor='not-allowed'; }
  10725. }
  10726. function editQuote(id) {
  10727. const quote = state.quotes.find(q => q.id === id);
  10728. if (!quote) return;
  10729. state.quoteMode = quote.quoteMode || 'ai';
  10730. state.currentQuoteId = id;
  10731. state.activePlanId = quote.activePlanId || null;
  10732. state.currentPricingMode = quote.pricingMode || state.defaultPricingMode || 'package';
  10733. state.currentFixedUnitPrice = quote.fixedUnitPrice || state.defaultFixedUnitPrice || 0;
  10734. state.spaces = JSON.parse(JSON.stringify(quote.spaces || []));
  10735. state.extraItems = JSON.parse(JSON.stringify(quote.extraItems || []));
  10736. state.uploads = JSON.parse(JSON.stringify(quote.uploads || []));
  10737. state.pricingResult = quote.pricingResult || null;
  10738. state.currentFloatRatio = quote.floatRatio != null ? quote.floatRatio : state.globalFloatRatio;
  10739. var isDraft = quote.status === 0 || quote.grandTotal == null;
  10740. document.getElementById('editor-title').textContent = isDraft ? ('继续编辑 - ' + quote.quoteNo + '(草稿)') : ('编辑报价单 - ' + quote.quoteNo);
  10741. if (typeof setHouseTypeValue === 'function') {
  10742. setHouseTypeValue(quote.houseType || '三室两厅');
  10743. } else {
  10744. var elHouseType = document.getElementById('house-type');
  10745. if (elHouseType) elHouseType.value = quote.houseType || '三室两厅';
  10746. }
  10747. if (typeof renderUploadPreviews === 'function') renderUploadPreviews();
  10748. // 优先使用新版数据结构
  10749. if (quote.qRooms && quote.qRooms.length > 0) {
  10750. qRooms = JSON.parse(JSON.stringify(quote.qRooms));
  10751. qExtras = JSON.parse(JSON.stringify(quote.qExtras || []));
  10752. qDiscountRate = quote.qDiscountRate != null ? quote.qDiscountRate : 1.00;
  10753. qFloorHeight = quote.qFloorHeight != null ? quote.qFloorHeight : 2.5;
  10754. qCustomerTier = quote.qCustomerTier || 'standard';
  10755. qCurrentFloatRatio = quote.qCurrentFloatRatio != null ? quote.qCurrentFloatRatio : (quote.floatRatio || 0.15);
  10756. if (quote.qTierSelectedDiscount) qTierSelectedDiscount = Object.assign(qTierSelectedDiscount, quote.qTierSelectedDiscount);
  10757. qRenderTierCards();
  10758. qUpdateTierDetail();
  10759. // 恢复整份报价单已经保存的基础项与增值项;旧草稿没有明细缓存时才从第二步重建。
  10760. if (quote.qRoomItems && quote.qRoomItems.length > 0) {
  10761. qRoomItems = JSON.parse(JSON.stringify(quote.qRoomItems));
  10762. } else {
  10763. qSyncStep2DetailsToPricing();
  10764. }
  10765. qAiEditableData = { rooms: qRooms };
  10766. window._qLastAnalysisData = qAiEditableData;
  10767. } else {
  10768. qRooms = state.spaces.map((s, i) => ({
  10769. roomName: s.name || s.roomName || '空间'+(i+1),
  10770. cabinetType: s.cabinetType || '衣柜',
  10771. layoutStyle: s.layoutStyle || '一字型',
  10772. projectArea: s.projectArea || parseFloat(((s.length||3.0)*(s.width||2.4)).toFixed(2)) || 3.0,
  10773. doorType: s.doorType || '普通平板',
  10774. length: s.length || 3.0,
  10775. width: s.width || 2.4,
  10776. height: s.height || 2.4
  10777. }));
  10778. qRoomItems = [];
  10779. qExtras = (state.extraItems || []).map(e => ({
  10780. roomIndex: e.roomIndex != null ? e.roomIndex : e.spaceIdx,
  10781. items: (e.items||[]).map(item => ({
  10782. name: item.name, spec: item.spec||'标准', qty: item.qty||item.count||1,
  10783. unit: item.unit||'个', price: item.price||item.unitPrice||0
  10784. }))
  10785. }));
  10786. qDiscountRate = 1.00;
  10787. qFloorHeight = quote.qFloorHeight != null ? quote.qFloorHeight : 2.5;
  10788. qCurrentFloatRatio = quote.floatRatio || 0.15;
  10789. qCustomerTier = 'standard';
  10790. qTierSelectedDiscount = { price:'p1', standard:'s1', quality:'q1' };
  10791. }
  10792. // 草稿/历史报价恢复时,把保存的层高同步回可编辑输入框。
  10793. var editFloorInput = document.getElementById('q-floor-height');
  10794. if (!(qFloorHeight > 0)) qFloorHeight = 2.5;
  10795. if (editFloorInput) editFloorInput.value = String(qFloorHeight);
  10796. var editFloorDisplay = document.getElementById('q-step2-fh-display');
  10797. if (editFloorDisplay) editFloorDisplay.textContent = qFloorHeight.toFixed(2) + 'm';
  10798. var planBadge = document.getElementById('q-active-plan-badge');
  10799. if (planBadge) {
  10800. var activePlan = state.activePlanId ? getPlanById(state.activePlanId) : null;
  10801. planBadge.textContent = activePlan ? activePlan.name : '自定义';
  10802. }
  10803. qAiProcessed = true;
  10804. qAiAnalyzing = false; // 已加载数据,非解析中状态
  10805. // 原始附件存放在 IndexedDB 中,稍后异步恢复。不要用虚假的“已上传”对象占位。
  10806. qUploadedDrawing = null;
  10807. qUploadedDrawingDataUrl = null;
  10808. qUploadedDrawingUrl = null;
  10809. qUploadedCAD = null;
  10810. qUploadedCADUrl = null;
  10811. qUploadedRequirement = null;
  10812. qUploadedRequirementUrl = null;
  10813. switchView('editor');
  10814. // 根据状态决定进入哪个步骤
  10815. var targetStep;
  10816. if (isDraft) {
  10817. targetStep = quote.currentStep || (qRooms.length > 0 ? 3 : 2);
  10818. } else {
  10819. targetStep = 5;
  10820. }
  10821. goToStep(targetStep);
  10822. qRestoreQuoteAttachments(id).catch(function(err) {
  10823. console.warn('报价附件恢复失败:', err);
  10824. showToast('⚠️ 报价数据已恢复,但原始附件读取失败', 'warning');
  10825. });
  10826. if (targetStep === 5) {
  10827. setTimeout(() => qRenderStep5(), 100);
  10828. }
  10829. updateBackToEditorButton();
  10830. }
  10831. function deleteQuote(id) {
  10832. var quote = state.quotes.find(q => q.id === id);
  10833. var quoteName = quote ? (quote.quoteNo || '该报价单') : '该报价单';
  10834. showConfirmModal('确认删除', '确定删除报价单「' + quoteName + '」吗?此操作不可恢复。', function() {
  10835. state.quotes = state.quotes.filter(q => q.id !== id);
  10836. if (state.currentQuoteId === id) {
  10837. // 当前会话报价单被删除:清除返回上下文与会话数据,禁止再返回上页
  10838. state.currentQuoteId = null;
  10839. state._editorReturnContext = null;
  10840. if (state._manualReturnContext && state._manualReturnContext.view === 'editor') state._manualReturnContext = null;
  10841. state.currentStep = 1;
  10842. qRooms = [];
  10843. if (typeof showQuoteList === 'function') showQuoteList();
  10844. }
  10845. renderQuoteList();
  10846. updateBackToEditorButton();
  10847. updatePageBackButton();
  10848. showToast('报价单已删除', 'success');
  10849. setTimeout(function() {
  10850. if (typeof persistState === 'function') persistState();
  10851. }, 0);
  10852. });
  10853. }
  10854. // ====================== 批量删除报价单(两种报价模式通用,列表按模式隔离) ======================
  10855. function getSelectedQuoteIds(){
  10856. var ids=[];
  10857. document.querySelectorAll('#quote-list-body .quote-select-cb').forEach(function(cb){ if(cb.checked) ids.push(cb.value); });
  10858. return ids;
  10859. }
  10860. function syncBatchDeleteUI(){
  10861. var ids=getSelectedQuoteIds();
  10862. var btn=document.getElementById('batch-delete-btn');
  10863. var cnt=document.getElementById('batch-delete-count');
  10864. if(btn) btn.disabled = ids.length===0;
  10865. if(cnt) cnt.textContent = '已选 '+ids.length+' 项';
  10866. var all=document.getElementById('quote-select-all');
  10867. if(all){
  10868. var cbs=document.querySelectorAll('#quote-list-body .quote-select-cb');
  10869. all.checked = cbs.length>0 && ids.length===cbs.length;
  10870. }
  10871. }
  10872. function toggleSelectAllQuotes(checked){
  10873. document.querySelectorAll('#quote-list-body .quote-select-cb').forEach(function(cb){ cb.checked=checked; });
  10874. syncBatchDeleteUI();
  10875. }
  10876. function batchDeleteQuotes(){
  10877. var ids=getSelectedQuoteIds();
  10878. if(!ids.length){ showToast('请先勾选要删除的报价单','warning'); return; }
  10879. showConfirmModal('确认批量删除', '确定删除所选 '+ids.length+' 个报价单吗?此操作不可恢复。', function(){
  10880. state.quotes = state.quotes.filter(function(q){ return ids.indexOf(q.id)===-1; });
  10881. if(ids.indexOf(state.currentQuoteId)!==-1){
  10882. // 当前会话报价单被删除:清除返回上下文与会话数据,禁止再返回上页
  10883. state.currentQuoteId = null;
  10884. state._editorReturnContext = null;
  10885. if(state._manualReturnContext && state._manualReturnContext.view==='editor') state._manualReturnContext = null;
  10886. state.currentStep = 1;
  10887. qRooms = [];
  10888. if(typeof showQuoteList==='function') showQuoteList();
  10889. }
  10890. renderQuoteList();
  10891. updateBackToEditorButton();
  10892. updatePageBackButton();
  10893. showToast('已删除 '+ids.length+' 个报价单','success');
  10894. setTimeout(function(){ if(typeof persistState==='function') persistState(); },0);
  10895. });
  10896. }
  10897. // ====================== SPACE MANAGEMENT ======================
  10898. function addSpace() {
  10899. state.spaces.push({
  10900. name: '新空间', length: 3.5, width: 3.0, height: 2.8,
  10901. cabinetRatio: 0.50, fillRate: 0.55, doorRatio: 0.50,
  10902. bayWindow: false, irregular: false
  10903. });
  10904. renderSpaceTable();
  10905. }
  10906. function removeSpace(idx) {
  10907. if (state.spaces.length <= 1) { showToast('至少保留一个空间', 'info'); return; }
  10908. state.spaces.splice(idx, 1);
  10909. // Also remove associated extra items
  10910. state.extraItems = state.extraItems.filter(e => e.spaceIdx !== idx);
  10911. // Re-index remaining extra items
  10912. state.extraItems.forEach(e => { if (e.spaceIdx > idx) e.spaceIdx--; });
  10913. renderSpaceTable();
  10914. }
  10915. function updateSpace(idx, field, value) {
  10916. state.spaces[idx][field] = value;
  10917. // Reset pricing result on change
  10918. state.pricingResult = null;
  10919. }
  10920. // ====================== CALCULATION ======================
  10921. function calculateAndGoToStep3() {
  10922. const fr = state.currentFloatRatio;
  10923. state.pricingResult = PricingEngine.calculate(state.spaces, state.extraItems, fr);
  10924. // Auto-save
  10925. saveCurrentQuote();
  10926. goToStep(2);
  10927. renderPricingResult();
  10928. syncStep3FloatUI(fr);
  10929. // Wait for DOM then render export preview
  10930. setTimeout(() => {
  10931. if (state.currentStep === 4) renderExportPreview();
  10932. }, 100);
  10933. }
  10934. // ---- Step 3 float ratio controls ----
  10935. function syncStep3FloatUI(fr) {
  10936. const pct = (fr * 100).toFixed(1);
  10937. const slider = document.getElementById('step3-float-slider');
  10938. const badge = document.getElementById('step3-float-badge');
  10939. const midLabel = document.getElementById('step3-mid-label');
  10940. const marker = document.getElementById('step3-range-marker');
  10941. const customInput = document.getElementById('step3-float-custom');
  10942. if (slider) slider.value = pct;
  10943. if (badge) badge.textContent = `±${pct}%`;
  10944. if (midLabel) midLabel.textContent = `← ±${pct}% →`;
  10945. if (customInput) customInput.value = pct;
  10946. // Update range marker position across 5%-30% range
  10947. if (marker) {
  10948. const pos = ((fr - 0.05) / 0.25) * 100; // 5%=0%, 30%=100%
  10949. marker.style.left = `${Math.min(100, Math.max(0, pos))}%`;
  10950. }
  10951. // Update preset buttons
  10952. document.querySelectorAll('#step-3 .float-preset button').forEach(btn => {
  10953. btn.classList.remove('active');
  10954. const btnVal = parseFloat(btn.textContent.replace(/[^0-9.]/g, ''));
  10955. if (Math.abs(btnVal - parseFloat(pct)) < 0.01) btn.classList.add('active');
  10956. });
  10957. // Also update the price range if there's a result
  10958. if (state.pricingResult) {
  10959. recalcWithNewFloat(fr);
  10960. }
  10961. }
  10962. function onStep3FloatChange(val) {
  10963. const fr = parseFloat(val) / 100;
  10964. state.currentFloatRatio = fr;
  10965. syncStep3FloatUI(fr);
  10966. }
  10967. function setStep3Float(pct) {
  10968. const fr = parseFloat(pct) / 100;
  10969. state.currentFloatRatio = fr;
  10970. const slider = document.getElementById('step3-float-slider');
  10971. if (slider) slider.value = pct;
  10972. syncStep3FloatUI(fr);
  10973. }
  10974. function adjustFloatRatio(delta) {
  10975. const newFr = Math.min(0.30, Math.max(0.05, state.currentFloatRatio + delta / 100));
  10976. state.currentFloatRatio = parseFloat(newFr.toFixed(3));
  10977. syncStep3FloatUI(state.currentFloatRatio);
  10978. }
  10979. // ---- Range bar drag functionality ----
  10980. let rangeBarDragging = false;
  10981. let rangeBarDragInitialized = false;
  10982. function initRangeBarDrag() {
  10983. if (rangeBarDragInitialized) return;
  10984. const rangeBar = document.querySelector('#step-3 .range-bar');
  10985. const marker = document.getElementById('step3-range-marker');
  10986. if (!rangeBar || !marker) return;
  10987. rangeBarDragInitialized = true;
  10988. function getFloatRatioFromPos(clientX) {
  10989. const rect = rangeBar.getBoundingClientRect();
  10990. const percent = (clientX - rect.left) / rect.width;
  10991. const clampedPercent = Math.max(0, Math.min(1, percent));
  10992. const floatRatio = 0.05 + clampedPercent * 0.25; // 5% to 30%
  10993. return parseFloat(floatRatio.toFixed(3));
  10994. }
  10995. function onMouseDown(e) {
  10996. rangeBarDragging = true;
  10997. marker.classList.add('dragging');
  10998. document.addEventListener('mousemove', onMouseMove);
  10999. document.addEventListener('mouseup', onMouseUp);
  11000. const fr = getFloatRatioFromPos(e.clientX);
  11001. state.currentFloatRatio = fr;
  11002. syncStep3FloatUI(fr);
  11003. }
  11004. function onMouseMove(e) {
  11005. if (!rangeBarDragging) return;
  11006. e.preventDefault();
  11007. const fr = getFloatRatioFromPos(e.clientX);
  11008. state.currentFloatRatio = fr;
  11009. syncStep3FloatUI(fr);
  11010. }
  11011. function onMouseUp() {
  11012. if (!rangeBarDragging) return;
  11013. rangeBarDragging = false;
  11014. marker.classList.remove('dragging');
  11015. document.removeEventListener('mousemove', onMouseMove);
  11016. document.removeEventListener('mouseup', onMouseUp);
  11017. }
  11018. marker.addEventListener('mousedown', function(e) {
  11019. e.stopPropagation();
  11020. onMouseDown(e);
  11021. });
  11022. rangeBar.addEventListener('mousedown', onMouseDown);
  11023. function onTouchStart(e) {
  11024. e.preventDefault();
  11025. const touch = e.touches[0];
  11026. rangeBarDragging = true;
  11027. marker.classList.add('dragging');
  11028. document.addEventListener('touchmove', onTouchMove, { passive: false });
  11029. document.addEventListener('touchend', onTouchEnd);
  11030. const fr = getFloatRatioFromPos(touch.clientX);
  11031. state.currentFloatRatio = fr;
  11032. syncStep3FloatUI(fr);
  11033. }
  11034. function onTouchMove(e) {
  11035. if (!rangeBarDragging) return;
  11036. e.preventDefault();
  11037. const touch = e.touches[0];
  11038. const fr = getFloatRatioFromPos(touch.clientX);
  11039. state.currentFloatRatio = fr;
  11040. syncStep3FloatUI(fr);
  11041. }
  11042. function onTouchEnd() {
  11043. if (!rangeBarDragging) return;
  11044. rangeBarDragging = false;
  11045. marker.classList.remove('dragging');
  11046. document.removeEventListener('touchmove', onTouchMove);
  11047. document.removeEventListener('touchend', onTouchEnd);
  11048. }
  11049. marker.addEventListener('touchstart', onTouchStart, { passive: false });
  11050. rangeBar.addEventListener('touchstart', onTouchStart, { passive: false });
  11051. }
  11052. function resetFloatRatio() {
  11053. state.currentFloatRatio = state.globalFloatRatio;
  11054. syncStep3FloatUI(state.currentFloatRatio);
  11055. }
  11056. function recalcWithNewFloat(fr) {
  11057. if (!state.pricingResult) return;
  11058. const gt = state.pricingResult.grandTotal;
  11059. const floorPrice = parseFloat((gt * (1 - fr)).toFixed(2));
  11060. const ceilingPrice = parseFloat((gt * (1 + fr)).toFixed(2));
  11061. state.pricingResult.floorPrice = floorPrice;
  11062. state.pricingResult.ceilingPrice = ceilingPrice;
  11063. state.pricingResult.floatRatio = fr;
  11064. document.getElementById('floor-price-display').textContent = `¥${floorPrice.toLocaleString('zh-CN',{minimumFractionDigits:2})}`;
  11065. document.getElementById('ceiling-price-display').textContent = `¥${ceilingPrice.toLocaleString('zh-CN',{minimumFractionDigits:2})}`;
  11066. // Update summary table
  11067. const summaryBody = document.getElementById('summary-table-body');
  11068. if (summaryBody) {
  11069. const rows = summaryBody.querySelectorAll('tr');
  11070. for (const row of rows) {
  11071. const cells = row.querySelectorAll('td');
  11072. if (cells.length >= 2) {
  11073. const label = cells[0].textContent.trim();
  11074. if (label.startsWith('底价')) {
  11075. cells[1].innerHTML = `¥${floorPrice.toLocaleString('zh-CN',{minimumFractionDigits:2})}`;
  11076. } else if (label.startsWith('顶价')) {
  11077. cells[1].innerHTML = `¥${ceilingPrice.toLocaleString('zh-CN',{minimumFractionDigits:2})}`;
  11078. }
  11079. }
  11080. }
  11081. }
  11082. }
  11083. // ====================== EXPORT ======================
  11084. function exportQuote(format) {
  11085. if (!state.pricingResult) return;
  11086. var quote = state.quotes.find(function(q) { return q.id === state.currentQuoteId; });
  11087. if (!quote) { showToast('请先保存报价单', 'info'); return; }
  11088. var r = state.pricingResult;
  11089. var ext = format === 'EXCEL' ? '.xlsx' : '.pdf';
  11090. var filename = '报价单_' + quote.quoteNo + '_' + (quote.houseType || '定制') + ext;
  11091. var content, mime;
  11092. if (format === 'EXCEL') {
  11093. content = buildExcelContent(quote, r);
  11094. mime = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
  11095. // CSV as fallback for broad compatibility — Excel/WPS can open directly
  11096. filename = '报价单_' + quote.quoteNo + '_' + (quote.houseType || '定制') + '.csv';
  11097. mime = 'text/csv;charset=utf-8';
  11098. } else {
  11099. content = buildPdfHtmlContent(quote, r);
  11100. mime = 'text/html;charset=utf-8';
  11101. filename = '报价单_' + quote.quoteNo + '_' + (quote.houseType || '定制') + '.html';
  11102. }
  11103. triggerDownload(content, filename, mime);
  11104. quote.status = 2;
  11105. saveCurrentQuote();
  11106. renderQuoteList();
  11107. showToast('✅ 报价单已生成,请查看浏览器下载', 'success');
  11108. }
  11109. function buildExcelContent(quote, r) {
  11110. var BOM = '';
  11111. var lines = [];
  11112. // Title
  11113. lines.push(BOM + 'XX装饰工程有限公司 — 报价单');
  11114. lines.push('');
  11115. lines.push('报价单号:,' + quote.quoteNo + ',日期:,' + new Date().toLocaleDateString('zh-CN'));
  11116. lines.push('户型:,' + (quote.houseType || ''));
  11117. lines.push('');
  11118. // 空间尺寸明细
  11119. lines.push('===== 空间尺寸明细 =====');
  11120. lines.push('房间名称,长(m),宽(m),高(m),投影面积(m²),柜体类型,布局样式,门板类型');
  11121. if (qRooms && qRooms.length > 0) {
  11122. qRooms.forEach(function(room) {
  11123. lines.push([
  11124. room.roomName,
  11125. (room.length || 0).toFixed(2),
  11126. (room.width || 0).toFixed(2),
  11127. (room.height || 0).toFixed(2),
  11128. (room.projectArea || 0).toFixed(2),
  11129. room.cabinetType || '',
  11130. room.layoutStyle || '',
  11131. room.doorType || ''
  11132. ].join(','));
  11133. });
  11134. }
  11135. lines.push('');
  11136. // 空间子项明细
  11137. lines.push('===== 空间子项明细 =====');
  11138. lines.push('房间名称,品名,宽(m),高(m),面积(㎡),数量,单价(元),总价(元),备注');
  11139. if (qRoomItems && qRoomItems.length > 0) {
  11140. qRoomItems.forEach(function(items, ri) {
  11141. if (items && items.length > 0) {
  11142. var roomName = qRooms[ri] ? qRooms[ri].roomName : '';
  11143. items.forEach(function(it) {
  11144. var csvBillingType = it.billingType || (it.billingMethod ? qClassifyMethod(it.billingMethod) : (((it.width||0)>0&&(it.height||0)>0)?'area':'count'));
  11145. var csvQty = Number(it.count);
  11146. if(!isFinite(csvQty)||csvQty<=0) csvQty = Number(it.multiplier||0);
  11147. if((!isFinite(csvQty)||csvQty<=0) && csvBillingType!=='area') csvQty = Number(it.area||0);
  11148. lines.push([
  11149. roomName,
  11150. it.name,
  11151. (it.width || 0).toFixed(2),
  11152. (it.height || 0).toFixed(2),
  11153. csvBillingType==='area' ? (it.area || 0).toFixed(2) : '',
  11154. csvBillingType==='area' || csvQty<=0 ? '' : csvQty,
  11155. it.unitPrice,
  11156. (it.total || 0).toFixed(2),
  11157. qMaterialOnlyNote(it.note, qRooms[ri], it)
  11158. ].join(','));
  11159. });
  11160. }
  11161. });
  11162. }
  11163. lines.push('');
  11164. // 空间汇总报价
  11165. lines.push('===== 空间汇总报价 =====');
  11166. lines.push('空间,平面面积(m²),展开面积(m²),柜体基础价(元),门板补差(元),增项合计(元),非标加价(元),空间小计(元)');
  11167. r.spaces.forEach(function(s) {
  11168. lines.push([
  11169. s.name,
  11170. s.area.toFixed(2),
  11171. s.expandArea.toFixed(2),
  11172. s.cabinetPrice.toFixed(2),
  11173. s.doorPanelDiff.toFixed(2),
  11174. s.extraTotal.toFixed(2),
  11175. s.nonStandard.toFixed(2),
  11176. s.spaceTotal.toFixed(2)
  11177. ].join(','));
  11178. });
  11179. lines.push('');
  11180. // 总价
  11181. lines.push('方案总价,,' + r.grandTotal.toFixed(2));
  11182. lines.push('底价 (-' + Math.round(r.floatRatio * 100) + '%),,' + r.floorPrice.toFixed(2));
  11183. lines.push('顶价 (+' + Math.round(r.floatRatio * 100) + '%),,' + r.ceilingPrice.toFixed(2));
  11184. lines.push('');
  11185. lines.push('—— 本报价为AI辅助生成,仅供参考,最终以实际量尺为准 ——');
  11186. return lines.join('\n');
  11187. }
  11188. function buildPdfHtmlContent(quote, r) {
  11189. var rows = '';
  11190. r.spaces.forEach(function(s) {
  11191. rows += '<tr><td>' + s.name + '</td><td>' + s.area.toFixed(2) + '</td><td>' + s.expandArea.toFixed(2) + '</td><td>¥' + s.cabinetPrice.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2}) + '</td><td>¥' + s.doorPanelDiff.toLocaleString() + '</td><td>¥' + s.extraTotal.toLocaleString() + '</td><td>¥' + s.nonStandard.toLocaleString() + '</td><td>¥' + s.spaceTotal.toLocaleString() + '</td></tr>';
  11192. });
  11193. var extraRows = '';
  11194. r.spaces.forEach(function(s) {
  11195. if (s.extraItems && s.extraItems.length > 0) {
  11196. s.extraItems.forEach(function(e) {
  11197. extraRows += '<tr><td>' + s.name + '</td><td>' + e.name + '</td><td>' + e.count + ' 个</td><td>¥' + e.unitPrice + '</td><td>¥' + (e.count * e.unitPrice).toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2}) + '</td></tr>';
  11198. });
  11199. }
  11200. });
  11201. // 空间尺寸明细
  11202. var roomDimRows = '';
  11203. if (qRooms && qRooms.length > 0) {
  11204. qRooms.forEach(function(room) {
  11205. roomDimRows += '<tr><td>' + room.roomName + '</td><td>' + (room.length || 0).toFixed(2) + '</td><td>' + (room.width || 0).toFixed(2) + '</td><td>' + (room.height || 0).toFixed(2) + '</td><td>' + (room.projectArea || 0).toFixed(2) + '</td><td>' + (room.cabinetType || '') + '</td><td>' + (room.layoutStyle || '') + '</td><td>' + (room.doorType || '') + '</td></tr>';
  11206. });
  11207. }
  11208. // 空间子项明细
  11209. var roomItemRows = '';
  11210. if (qRoomItems && qRoomItems.length > 0) {
  11211. qRoomItems.forEach(function(items, ri) {
  11212. if (items && items.length > 0) {
  11213. var roomName = qRooms[ri] ? qRooms[ri].roomName : '';
  11214. items.forEach(function(it) {
  11215. var htmlBillingType = it.billingType || (it.billingMethod ? qClassifyMethod(it.billingMethod) : (((it.width||0)>0&&(it.height||0)>0)?'area':'count'));
  11216. var htmlQty = Number(it.count);
  11217. if(!isFinite(htmlQty)||htmlQty<=0) htmlQty = Number(it.multiplier||0);
  11218. if((!isFinite(htmlQty)||htmlQty<=0) && htmlBillingType!=='area') htmlQty = Number(it.area||0);
  11219. roomItemRows += '<tr><td>' + roomName + '</td><td>' + it.name + '</td><td>' + (it.width || 0).toFixed(2) + '</td><td>' + (it.height || 0).toFixed(2) + '</td><td>' + (htmlBillingType==='area'?(it.area || 0).toFixed(2):'') + '</td><td>' + (htmlBillingType==='area'||htmlQty<=0?'':htmlQty) + '</td><td>¥' + it.unitPrice + '</td><td>¥' + (it.total || 0).toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2}) + '</td><td>' + qMaterialOnlyNote(it.note, qRooms[ri], it) + '</td></tr>';
  11220. });
  11221. }
  11222. });
  11223. }
  11224. return '<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"UTF-8\"><title>报价单_' + quote.quoteNo + '</title><style>body{font-family:\"Microsoft YaHei\",\"PingFang SC\",sans-serif;padding:40px 60px;color:#333;line-height:1.6;max-width:900px;margin:0 auto}h1{text-align:center;font-size:24px;border-bottom:2px solid #333;padding-bottom:12px;margin-bottom:24px}.meta{font-size:14px;margin-bottom:20px}.meta div{margin:4px 0}table{width:100%;border-collapse:collapse;margin:16px 0;font-size:13px}th,td{border:1px solid #ccc;padding:8px 10px;text-align:center}th{background:#f5f5f5;font-weight:600}.total-row{font-weight:700;font-size:15px}.highlight{color:#1F497D}.range{font-size:14px;margin:16px 0;padding:12px;background:#f0f7ff;border-radius:4px;text-align:center}.disclaimer{font-size:12px;color:#999;margin-top:30px;text-align:center;border-top:1px solid #ddd;padding-top:16px}.section-title{font-size:16px;font-weight:700;margin:20px 0 10px;padding-bottom:6px;border-bottom:1px solid #eee}@media print{body{padding:20px}button{display:none}}</style></head><body><h1>🏠 项目预算报价单</h1><div class="meta"><div><strong>报价单编号:</strong>' + quote.quoteNo + '</div><div><strong>户型类型:</strong>' + (quote.houseType || '') + '</div><div><strong>报价日期:</strong>' + new Date().toLocaleDateString('zh-CN') + '</div></div><div class="range"><div style="font-size:14px;color:var(--text-muted)">全屋预估总价</div><div style="font-size:32px;font-weight:700;color:#1F497D">¥' + r.grandTotal.toLocaleString('zh-CN',{minimumFractionDigits:2}) + '</div><div style="font-size:13px;color:#666">价格区间:¥' + r.floorPrice.toLocaleString('zh-CN',{minimumFractionDigits:2}) + ' — ¥' + r.ceilingPrice.toLocaleString('zh-CN',{minimumFractionDigits:2}) + '(±' + Math.round(r.floatRatio*100) + '%)</div></div><div class="section-title">📐 空间尺寸明细</div><table><thead><tr><th>房间名称</th><th>长(m)</th><th>宽(m)</th><th>高(m)</th><th>投影面积(m²)</th><th>柜体类型</th><th>布局样式</th><th>门板类型</th></tr></thead><tbody>' + roomDimRows + '</tbody></table><div class="section-title">🔧 空间子项明细</div><table><thead><tr><th>房间名称</th><th>品名</th><th>宽(m)</th><th>高(m)</th><th>面积/数量</th><th>单价</th><th>总价</th><th>备注</th></tr></thead><tbody>' + roomItemRows + '</tbody></table><div class="section-title">💰 空间汇总报价</div><table><thead><tr><th>空间</th><th>平面面积(m²)</th><th>展开面积(m²)</th><th>柜体基础价</th><th>门板补差</th><th>增项合计</th><th>非标加价</th><th>空间小计</th></tr></thead><tbody>' + rows + '</tbody></table>' + (extraRows ? '<div class="section-title">⚙️ 非标增项明细</div><table><thead><tr><th>所属空间</th><th>项目</th><th>数量</th><th>单价</th><th>小计</th></tr></thead><tbody>' + extraRows + '</tbody></table>' : '') + '<table style="margin-top:20px"><tr class="total-row"><td colspan="7" style="text-align:right">方案总价</td><td class="highlight">¥' + r.grandTotal.toLocaleString('zh-CN',{minimumFractionDigits:2}) + '</td></tr><tr><td colspan="7" style="text-align:right">底价 (-' + Math.round(r.floatRatio*100) + '%)</td><td style="color:#38A169">¥' + r.floorPrice.toLocaleString('zh-CN',{minimumFractionDigits:2}) + '</td></tr><tr><td colspan="7" style="text-align:right">顶价 (+' + Math.round(r.floatRatio*100) + '%)</td><td style="color:#E53E3E">¥' + r.ceilingPrice.toLocaleString('zh-CN',{minimumFractionDigits:2}) + '</td></tr></table><div class="disclaimer">⚠️ 声明:本报价为 AI 辅助生成的预估价格,误差范围 ±' + Math.round(r.floatRatio*100) + '%,最终成交价以实际量尺和确认为准。</div></body></html>';
  11225. }
  11226. // ====================== AI DRAWING ANALYSIS ======================
  11227. function simulateAIAnalysis() {
  11228. const btn = document.getElementById('btn-ai-analyze');
  11229. const noticeText = document.getElementById('ai-notice-text');
  11230. const noticeBox = document.getElementById('ai-notice-box');
  11231. // Disable button and show processing
  11232. btn.disabled = true;
  11233. btn.innerHTML = '⏳ 分析中...';
  11234. noticeText.innerHTML = '🤖 <strong>AI智能识别</strong> — 正在分析图纸,请稍候...';
  11235. // Check if user has uploaded files
  11236. const hasFiles = state.uploads && state.uploads.length > 0;
  11237. const fileNames = hasFiles ? state.uploads.map(u => u.name).join('、') : '示例户型图';
  11238. // Simulate multi-step AI analysis
  11239. const steps = hasFiles
  11240. ? [
  11241. `🔍 正在加载已上传图纸:${fileNames}...`,
  11242. '📐 正在检测户型轮廓与房间边界...',
  11243. '📏 识别到空间布局,正在提取各空间尺寸...',
  11244. '✅ AI识别完成!已提取 ' + (state.spaces.length || 4) + ' 个空间数据',
  11245. '💡 请检查下方表格中的数据,确认无误后进入下一步。',
  11246. ]
  11247. : [
  11248. '🔍 未检测到上传图纸,使用示例户型图进行模拟分析...',
  11249. '📐 正在检测户型轮廓与房间边界...',
  11250. '📏 识别到三室两厅布局,正在提取各空间尺寸...',
  11251. '✅ AI模拟识别完成!已填充 ' + DEFAULT_SPACES.length + ' 个空间数据',
  11252. '💡 提示:上传真实户型图可提高AI识别精度。请检查下方表格数据。',
  11253. ];
  11254. let stepIdx = 0;
  11255. const interval = setInterval(function() {
  11256. if (stepIdx < steps.length) {
  11257. noticeText.innerHTML = '🤖 <strong>AI智能识别</strong> — ' + steps[stepIdx];
  11258. stepIdx++;
  11259. } else {
  11260. clearInterval(interval);
  11261. // Populate spaces with AI-recognized data
  11262. if (hasFiles) {
  11263. state.spaces = [
  11264. { name:'主卧', length:4.5, width:3.8, height:2.8, cabinetRatio:0.60, fillRate:0.65, doorRatio:0.55, bayWindow:false, irregular:false },
  11265. { name:'次卧', length:3.6, width:3.0, height:2.8, cabinetRatio:0.55, fillRate:0.50, doorRatio:0.50, bayWindow:false, irregular:false },
  11266. { name:'客厅', length:5.8, width:4.8, height:2.8, cabinetRatio:0.45, fillRate:0.60, doorRatio:0.60, bayWindow:false, irregular:false },
  11267. { name:'厨房', length:3.4, width:2.2, height:2.8, cabinetRatio:0.70, fillRate:0.70, doorRatio:0.40, bayWindow:false, irregular:false },
  11268. ];
  11269. state.extraItems = JSON.parse(JSON.stringify(DEFAULT_EXTRA_ITEMS));
  11270. } else {
  11271. state.spaces = JSON.parse(JSON.stringify(DEFAULT_SPACES));
  11272. state.extraItems = JSON.parse(JSON.stringify(DEFAULT_EXTRA_ITEMS));
  11273. }
  11274. renderSpaceTable();
  11275. // Update notice to success
  11276. noticeText.innerHTML = '✅ <strong>AI智能识别完成</strong> — 已从图纸中提取 ' + state.spaces.length + ' 个空间尺寸,请人工复核确认。';
  11277. showToast('✅ AI识别完成!请检查并确认空间尺寸', 'success');
  11278. btn.innerHTML = '🔄 重新分析';
  11279. btn.disabled = false;
  11280. }
  11281. }, 800);
  11282. }
  11283. // ====================== FILE UPLOAD ======================
  11284. const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB
  11285. function handleRealFileUpload(fileList) {
  11286. if (!fileList || fileList.length === 0) return;
  11287. Array.from(fileList).forEach(file => {
  11288. // Check duplicate
  11289. if (state.uploads.find(u => u.name === file.name)) {
  11290. showToast(`"${file.name}" 已上传`, 'info');
  11291. return;
  11292. }
  11293. // Check size
  11294. if (file.size > MAX_FILE_SIZE) {
  11295. showToast(`"${file.name}" 超过20MB大小限制`, 'info');
  11296. return;
  11297. }
  11298. const isImage = file.type.startsWith('image/');
  11299. const uploadItem = { name: file.name, time: new Date().toLocaleString(), type: isImage ? 'image' : 'cad', dataURL: null };
  11300. if (isImage) {
  11301. const reader = new FileReader();
  11302. reader.onload = function(e) {
  11303. uploadItem.dataURL = e.target.result;
  11304. // Update state then re-render
  11305. renderUploadPreviews();
  11306. };
  11307. reader.readAsDataURL(file);
  11308. }
  11309. state.uploads.push(uploadItem);
  11310. renderUploadPreviews();
  11311. showToast(`✅ "${file.name}" 上传成功`, 'success');
  11312. });
  11313. // Reset input so same file can be re-uploaded if removed
  11314. document.getElementById('real-file-input').value = '';
  11315. }
  11316. function renderUploadPreviews() {
  11317. const container = document.getElementById('upload-preview');
  11318. if (!container) return;
  11319. if (state.uploads.length === 0) {
  11320. container.innerHTML = '';
  11321. return;
  11322. }
  11323. container.innerHTML = state.uploads.map((u, i) => {
  11324. const isImage = u.type === 'image' && u.dataURL;
  11325. const thumbContent = isImage
  11326. ? `<img class="thumb" src="${u.dataURL}" alt="${u.name}" title="点击查看大图" onclick="event.stopPropagation();openLightbox('${u.name}')">`
  11327. : `<div class="file-icon">${u.name.endsWith('.dwg') || u.name.endsWith('.dxf') ? '📐' : '📄'}</div>`;
  11328. return `
  11329. <div class="file-card">
  11330. <button class="remove-btn" onclick="event.stopPropagation();removeUpload(${i})">&times;</button>
  11331. ${thumbContent}
  11332. <div class="file-name" title="${u.name}">${u.name}</div>
  11333. </div>`;
  11334. }).join('');
  11335. }
  11336. function removeUpload(index) {
  11337. const removed = state.uploads[index];
  11338. state.uploads.splice(index, 1);
  11339. renderUploadPreviews();
  11340. if (removed) showToast(`已移除 "${removed.name}"`, 'info');
  11341. }
  11342. // ---- Lightbox (zoom & pan) ----
  11343. var lb = { scale: 1, dx: 0, dy: 0, dragging: false, sx: 0, sy: 0, ox: 0, oy: 0 };
  11344. function openLightbox(fileName) {
  11345. var upload = state.uploads.find(function(u) { return u.name === fileName; });
  11346. if (!upload || !upload.dataURL) return;
  11347. document.getElementById('lightbox-img').src = upload.dataURL;
  11348. document.getElementById('lightbox-info').textContent = upload.name;
  11349. document.getElementById('lightbox-overlay').style.display = 'flex';
  11350. document.body.style.overflow = 'hidden';
  11351. lbReset();
  11352. }
  11353. function closeLightbox() {
  11354. document.getElementById('lightbox-overlay').style.display = 'none';
  11355. document.body.style.overflow = '';
  11356. }
  11357. function lbApply() {
  11358. var wrap = document.getElementById('lightbox-img-wrap');
  11359. wrap.style.transform = 'translate(' + lb.dx + 'px,' + lb.dy + 'px) scale(' + lb.scale + ')';
  11360. document.getElementById('lb-zoom-pct').textContent = Math.round(lb.scale * 100) + '%';
  11361. }
  11362. function lbReset() {
  11363. lb.scale = 1; lb.dx = 0; lb.dy = 0;
  11364. var wrap = document.getElementById('lightbox-img-wrap');
  11365. wrap.classList.remove('zoomed'); wrap.classList.remove('grabbing');
  11366. lbApply();
  11367. }
  11368. function lbUpdateZoomed() {
  11369. // .zoomed removes max-width/max-height so transform scale() can actually overflow.
  11370. // Only set it when the image truly needs panning (scale > 1 with a small tolerance).
  11371. var wrap = document.getElementById('lightbox-img-wrap');
  11372. lb.scale > 1.001 ? wrap.classList.add('zoomed') : wrap.classList.remove('zoomed');
  11373. }
  11374. function lbZoom(delta) {
  11375. lb.scale = Math.max(0.25, Math.min(8, lb.scale + delta));
  11376. lbUpdateZoomed();
  11377. lbApply();
  11378. }
  11379. // --- mouse wheel ---
  11380. document.getElementById('lightbox-viewport').addEventListener('wheel', function(e) {
  11381. if (document.getElementById('lightbox-overlay').style.display !== 'flex') return;
  11382. e.preventDefault();
  11383. lb.scale += e.deltaY < 0 ? 0.12 : -0.12;
  11384. lb.scale = Math.max(0.25, Math.min(8, lb.scale));
  11385. lbUpdateZoomed();
  11386. lbApply();
  11387. }, { passive: false });
  11388. // --- drag (on overlay — allow drag whenever .zoomed is active) ---
  11389. var lbOverlay = document.getElementById('lightbox-overlay');
  11390. lbOverlay.addEventListener('mousedown', function(e) {
  11391. if (lbOverlay.style.display !== 'flex') return;
  11392. // Don't start drag on toolbar buttons or close button
  11393. if (e.target.closest('.lightbox-toolbar') || e.target.closest('.lightbox-close')) return;
  11394. // Only allow drag when the image actually overflows (zoomed in)
  11395. if (!document.getElementById('lightbox-img-wrap').classList.contains('zoomed')) return;
  11396. lb.dragging = true;
  11397. lb.sx = e.clientX; lb.sy = e.clientY;
  11398. lb.ox = lb.dx; lb.oy = lb.dy;
  11399. document.getElementById('lightbox-img-wrap').classList.add('grabbing');
  11400. e.preventDefault();
  11401. });
  11402. window.addEventListener('mousemove', function(e) {
  11403. if (!lb.dragging) return;
  11404. lb.dx = lb.ox + (e.clientX - lb.sx);
  11405. lb.dy = lb.oy + (e.clientY - lb.sy);
  11406. lbApply();
  11407. });
  11408. window.addEventListener('mouseup', function(e) {
  11409. if (!lb.dragging) return;
  11410. lb.dragging = false;
  11411. document.getElementById('lightbox-img-wrap').classList.remove('grabbing');
  11412. });
  11413. // --- double-click toggle 1:1 / fit ---
  11414. lbOverlay.addEventListener('dblclick', function(e) {
  11415. if (lbOverlay.style.display !== 'flex') return;
  11416. // Don't toggle on toolbar or close button
  11417. if (e.target.closest('.lightbox-toolbar') || e.target.closest('.lightbox-close')) return;
  11418. if (lb.scale > 1) { lbReset(); } else { lbZoom(0.2); }
  11419. });
  11420. // --- keyboard ---
  11421. document.addEventListener('keydown', function(e) {
  11422. if (document.getElementById('lightbox-overlay').style.display !== 'flex') return;
  11423. if (e.key === 'Escape') { closeLightbox(); }
  11424. if (e.key === '+' || e.key === '=') { lbZoom(0.15); e.preventDefault(); }
  11425. if (e.key === '-') { lbZoom(-0.15); e.preventDefault(); }
  11426. if (e.key === '0') { lbReset(); e.preventDefault(); }
  11427. // arrow keys pan (only when zoomed in)
  11428. if (lb.scale > 1) {
  11429. var step = e.shiftKey ? 80 : 20; // hold Shift for faster pan
  11430. if (e.key === 'ArrowLeft') { lb.dx += step; lbApply(); e.preventDefault(); }
  11431. if (e.key === 'ArrowRight') { lb.dx -= step; lbApply(); e.preventDefault(); }
  11432. if (e.key === 'ArrowUp') { lb.dy += step; lbApply(); e.preventDefault(); }
  11433. if (e.key === 'ArrowDown') { lb.dy -= step; lbApply(); e.preventDefault(); }
  11434. }
  11435. });
  11436. // ====================== SAVE ======================
  11437. function saveCurrentQuote(options) {
  11438. options = options || {};
  11439. var markComplete = options.markComplete === true;
  11440. if (!state.currentQuoteId) {
  11441. state.currentQuoteId = 'new_' + Date.now();
  11442. }
  11443. const houseType = (typeof getHouseTypeValue === 'function') ? getHouseTypeValue() : (document.getElementById('house-type')?.value || '三室两厅');
  11444. const existing = state.quotes.find(q => q.id === state.currentQuoteId);
  11445. if (existing) {
  11446. existing.houseType = houseType;
  11447. existing.spaces = JSON.parse(JSON.stringify(state.spaces));
  11448. existing.extraItems = JSON.parse(JSON.stringify(state.extraItems));
  11449. existing.uploads = JSON.parse(JSON.stringify(state.uploads));
  11450. existing.qRooms = JSON.parse(JSON.stringify(qRooms));
  11451. existing.qRoomItems = JSON.parse(JSON.stringify(qRoomItems));
  11452. existing.qExtras = JSON.parse(JSON.stringify(qExtras));
  11453. existing.qDiscountRate = qDiscountRate;
  11454. existing.qFloorHeight = qFloorHeight;
  11455. existing.qCustomerTier = qCustomerTier;
  11456. existing.qTierSelectedDiscount = JSON.parse(JSON.stringify(qTierSelectedDiscount));
  11457. existing.qCurrentFloatRatio = qCurrentFloatRatio;
  11458. existing.activePlanId = state.activePlanId;
  11459. existing.pricingMode = state.currentPricingMode;
  11460. existing.fixedUnitPrice = state.currentFixedUnitPrice;
  11461. existing.currentStep = state.currentStep || 1;
  11462. if (state.pricingResult) {
  11463. existing.grandTotal = state.pricingResult.grandTotal;
  11464. existing.floorPrice = state.pricingResult.floorPrice;
  11465. existing.ceilingPrice = state.pricingResult.ceilingPrice;
  11466. existing.pricingResult = state.pricingResult;
  11467. existing.pricingResult.floatRatio = state.currentFloatRatio;
  11468. }
  11469. existing.floatRatio = state.currentFloatRatio;
  11470. if (markComplete) existing.status = 1;
  11471. else if (existing.status == null) existing.status = 0;
  11472. existing.updatedAt = new Date().toISOString().slice(0,16).replace('T',' ');
  11473. existing.quoteMode = state.quoteMode || existing.quoteMode || 'ai';
  11474. } else if (qRooms.length > 0 || state.spaces.length > 0 || state.currentStep > 1) {
  11475. const now = new Date();
  11476. const seq = String(state.quotes.length + 1).padStart(3, '0');
  11477. const quoteNo = `QJ-${now.getFullYear()}${String(now.getMonth()+1).padStart(2,'0')}${String(now.getDate()).padStart(2,'0')}-${seq}`;
  11478. state.quotes.push({
  11479. id: state.currentQuoteId,
  11480. quoteNo,
  11481. quoteMode: state.quoteMode || 'ai',
  11482. houseType,
  11483. spaces: JSON.parse(JSON.stringify(state.spaces)),
  11484. extraItems: JSON.parse(JSON.stringify(state.extraItems)),
  11485. uploads: JSON.parse(JSON.stringify(state.uploads)),
  11486. qRooms: JSON.parse(JSON.stringify(qRooms)),
  11487. qRoomItems: JSON.parse(JSON.stringify(qRoomItems)),
  11488. qExtras: JSON.parse(JSON.stringify(qExtras)),
  11489. qDiscountRate: qDiscountRate,
  11490. qFloorHeight: qFloorHeight,
  11491. qCustomerTier: qCustomerTier,
  11492. qTierSelectedDiscount: JSON.parse(JSON.stringify(qTierSelectedDiscount)),
  11493. qCurrentFloatRatio: qCurrentFloatRatio,
  11494. activePlanId: state.activePlanId,
  11495. pricingMode: state.currentPricingMode,
  11496. fixedUnitPrice: state.currentFixedUnitPrice,
  11497. grandTotal: state.pricingResult?.grandTotal || null,
  11498. floorPrice: state.pricingResult?.floorPrice || null,
  11499. ceilingPrice: state.pricingResult?.ceilingPrice || null,
  11500. floatRatio: state.currentFloatRatio,
  11501. pricingResult: state.pricingResult || null,
  11502. currentStep: state.currentStep || 1,
  11503. status: markComplete ? 1 : 0,
  11504. createdAt: now.toISOString().slice(0,16).replace('T',' '),
  11505. updatedAt: now.toISOString().slice(0,16).replace('T',' '),
  11506. });
  11507. }
  11508. renderQuoteList();
  11509. if (typeof persistState === 'function') persistState();
  11510. // 附件体积较大,单独写入 IndexedDB,避免占满 localStorage。
  11511. qPersistQuoteAttachments(state.currentQuoteId).catch(function(err) {
  11512. console.warn('报价附件保存失败:', err);
  11513. showToast('⚠️ 报价已保存,但原始附件保存失败', 'warning');
  11514. });
  11515. }
  11516. // ====================== RULES MANAGEMENT ======================
  11517. function switchRuleTab(tab) {
  11518. state.activeRuleTab = tab;
  11519. state.editingRule = null; // exit edit mode on tab switch
  11520. document.querySelectorAll('.tabs .tab').forEach(t => t.classList.remove('active'));
  11521. document.querySelectorAll('.rule-tab-content').forEach(c => c.classList.add('hidden'));
  11522. const tabEl = document.querySelector(`.tab[onclick*="${tab}"]`);
  11523. if (tabEl) tabEl.classList.add('active');
  11524. const contentEl = document.getElementById(`rule-tab-${tab}`);
  11525. if (contentEl) contentEl.classList.remove('hidden');
  11526. }
  11527. // ---- Enter / cancel / save edit mode ----
  11528. function startRuleEdit(type, idx) {
  11529. state.editingRule = { type, index: idx };
  11530. renderRules();
  11531. }
  11532. function cancelRuleEdit() {
  11533. state.editingRule = null;
  11534. renderRules();
  11535. }
  11536. // 规则配置变更后,刷新报价编辑页面的增项下拉框(联动更新)
  11537. function qRefreshAfterRuleChange() {
  11538. try {
  11539. // 1. 先同步qRoomItems中所有明细项的单价到规则配置最新值(无论当前在哪个页面都执行)
  11540. qSyncRoomItemsWithRules();
  11541. // 2. 同步AI解析数据(qAiEditableData)中已选增项的价格
  11542. var aiData = qAiEditableData || window._qLastAnalysisData;
  11543. if (aiData && aiData.rooms) {
  11544. aiData.rooms.forEach(function(r){
  11545. if(r._selectedExtras){
  11546. r._selectedExtras.forEach(function(se){
  11547. var latestEx = DEFAULT_RULES.extra.find(function(e){return e.name === se.name;});
  11548. if(latestEx){
  11549. se.price = parseFloat(latestEx.price) || 0;
  11550. se.method = latestEx.method;
  11551. se.unit = latestEx.unit;
  11552. }
  11553. });
  11554. }
  11555. });
  11556. }
  11557. // 同步window._qLastAnalysisData(如果和qAiEditableData不同)
  11558. if (window._qLastAnalysisData && window._qLastAnalysisData !== aiData && window._qLastAnalysisData.rooms) {
  11559. window._qLastAnalysisData.rooms.forEach(function(r){
  11560. if(r._selectedExtras){
  11561. r._selectedExtras.forEach(function(se){
  11562. var latestEx = DEFAULT_RULES.extra.find(function(e){return e.name === se.name;});
  11563. if(latestEx){
  11564. se.price = parseFloat(latestEx.price) || 0;
  11565. se.method = latestEx.method;
  11566. se.unit = latestEx.unit;
  11567. }
  11568. });
  11569. }
  11570. });
  11571. }
  11572. // 3. 仅在编辑器视图中刷新当前可见页面
  11573. if (state.currentView === 'editor') {
  11574. if (state.currentStep === 2 && aiData) {
  11575. // Step2 AI解析页:刷新柜体卡片(下拉框选项从DEFAULT_RULES.extra实时构建,已选增项价格已同步)
  11576. var isMock = window._qLastIsMock || false;
  11577. if (qUploadedDrawing && qUploadedDrawing.name && qUploadedDrawing.name.indexOf('120㎡三房两厅') !== -1) isMock = true;
  11578. qRenderAICards(aiData, isMock);
  11579. }
  11580. // Step3 自动计价页:刷新(自动调用qSyncRoomItemsWithRules)
  11581. if (state.currentStep === 3) {
  11582. if (typeof qRenderStep4 === 'function') qRenderStep4();
  11583. }
  11584. // Step4 报价单页:刷新(自动调用qSyncRoomItemsWithRules)
  11585. if (state.currentStep === 4) {
  11586. if (typeof qRenderStep5 === 'function') qRenderStep5();
  11587. }
  11588. }
  11589. } catch(e) { console.warn('qRefreshAfterRuleChange error:', e); }
  11590. }
  11591. function saveRuleEdit(type, idx) {
  11592. const tableId = `rule-table-${type}`;
  11593. const tbody = document.getElementById(tableId);
  11594. if (!tbody) { cancelRuleEdit(); return; }
  11595. const editRow = tbody.querySelector('tr[style*="FFFFF0"]');
  11596. if (!editRow) { cancelRuleEdit(); return; }
  11597. // 读取各单元格值:td[0]=name(input), td[1]=method(select), td[2]=price(input), td[3]=unit(input), td[4]=desc(input)
  11598. var cells = editRow.children;
  11599. var nameInput = cells[0].querySelector('input');
  11600. var methodSel = cells[1].querySelector('select');
  11601. var priceInput = cells[2].querySelector('input');
  11602. var unitInput = cells[3].querySelector('input');
  11603. var descInput = cells[4].querySelector('input');
  11604. if (!nameInput || !methodSel || !priceInput || !unitInput || !descInput) { cancelRuleEdit(); return; }
  11605. var v_name = nameInput.value.trim();
  11606. var v_method = methodSel.value;
  11607. var v_price = priceInput.value.trim();
  11608. var v_unit = unitInput.value.trim() || qGetUnitByMethod(v_method);
  11609. var v_desc = descInput.value.trim();
  11610. const rule = DEFAULT_RULES[type][idx];
  11611. if (!rule) { cancelRuleEdit(); return; }
  11612. switch (type) {
  11613. case 'wardrobe':
  11614. rule.displayName = v_name || '柜体'; rule.method = v_method; rule.value = v_price; rule.unit = v_unit; rule.desc = v_desc;
  11615. break;
  11616. case 'kitchen':
  11617. rule.name = v_name; rule.method = v_method; rule.value = v_price; rule.unit = v_unit; rule.desc = v_desc; rule._descEdited = true;
  11618. break;
  11619. case 'base':
  11620. rule.name = v_name; rule.method = v_method; rule.value = v_price; rule.unit = v_unit; rule.desc = v_desc;
  11621. break;
  11622. case 'extra':
  11623. rule.name = v_name; rule.method = v_method; rule.price = v_price; rule.unit = v_unit; rule.desc = v_desc;
  11624. break;
  11625. }
  11626. state.editingRule = null;
  11627. renderRules();
  11628. persistState();
  11629. qRefreshAfterRuleChange();
  11630. showToast('✅ 单价已保存', 'success');
  11631. }
  11632. // ---- Add new rule ----
  11633. function addRule(group) {
  11634. let newRule;
  11635. switch (group) {
  11636. case 'wardrobe':
  11637. newRule = { name:'自定义衣柜单价_'+Date.now(), displayName:'柜体', method:'按投影面积', value:'0', unit:'元/㎡', desc:'请填写材质或说明', custom:true };
  11638. break;
  11639. case 'kitchen':
  11640. newRule = { name:'自定义橱柜单价_'+Date.now(), displayName:'自定义单价', method:'按延米', value:'0', unit:'元/m', desc:'请填写说明', custom:true };
  11641. break;
  11642. case 'base':
  11643. newRule = { name:'新单价项', method:'按投影面积', value:'0', unit:'元/m²', desc:'请填写说明' };
  11644. break;
  11645. case 'extra':
  11646. newRule = { name:'新增项', method:'按个数', price:'0', unit:'元/个', desc:'请填写说明' };
  11647. break;
  11648. default: return;
  11649. }
  11650. DEFAULT_RULES[group].push(newRule);
  11651. const newIdx = DEFAULT_RULES[group].length - 1;
  11652. renderRules();
  11653. qRefreshAfterRuleChange();
  11654. startRuleEdit(group, newIdx);
  11655. showToast('➕ 新单价项已添加,请编辑后保存', 'info');
  11656. }
  11657. // ---- Add Rule Modal ----
  11658. function openAddRuleModal() {
  11659. document.getElementById('add-rule-modal').style.display = 'flex';
  11660. document.getElementById('add-rule-category').value = 'base';
  11661. document.getElementById('add-rule-name').value = '';
  11662. document.getElementById('add-rule-value').value = '';
  11663. document.getElementById('add-rule-unit').value = '';
  11664. document.getElementById('add-rule-desc').value = '';
  11665. document.getElementById('add-rule-method').value = '按展开面积';
  11666. onAddRuleCategoryChange();
  11667. document.getElementById('add-rule-name').focus();
  11668. }
  11669. function closeAddRuleModal() {
  11670. document.getElementById('add-rule-modal').style.display = 'none';
  11671. }
  11672. function onAddRuleCategoryChange() {
  11673. const cat = document.getElementById('add-rule-category').value;
  11674. const isExtra = cat === 'extra';
  11675. document.getElementById('add-rule-name-group').style.display = '';
  11676. document.getElementById('add-rule-method-group').style.display = '';
  11677. document.getElementById('add-rule-value-group').style.display = '';
  11678. document.getElementById('add-rule-unit-group').style.display = '';
  11679. // Update labels
  11680. const valueLabel = document.querySelector('#add-rule-value-group label');
  11681. valueLabel.textContent = '数值/公式 *';
  11682. const nameLabel = document.querySelector('#add-rule-name-group label');
  11683. nameLabel.textContent = '规则名称 *';
  11684. // Update value placeholder
  11685. const valInput = document.getElementById('add-rule-value');
  11686. if (isExtra) valInput.placeholder = '如: 150';
  11687. else valInput.placeholder = '如: 680 或 basePrice*1.1';
  11688. }
  11689. function submitAddRule() {
  11690. const cat = document.getElementById('add-rule-category').value;
  11691. const name = document.getElementById('add-rule-name').value.trim();
  11692. const value = document.getElementById('add-rule-value').value.trim();
  11693. const unit = document.getElementById('add-rule-unit').value.trim();
  11694. const desc = document.getElementById('add-rule-desc').value.trim();
  11695. const method = document.getElementById('add-rule-method').value;
  11696. // Validate
  11697. if (!name) { showToast('请输入规则名称', 'info'); return; }
  11698. if (!value) { showToast('请输入数值/公式', 'info'); return; }
  11699. let newRule;
  11700. switch (cat) {
  11701. case 'base':
  11702. newRule = { name, method, value, unit: unit||'元/m²', desc: desc||'用户自定义', enabled: true };
  11703. break;
  11704. case 'extra':
  11705. newRule = { name, method, price: value, unit: unit||'元/个', desc: desc||'用户自定义', enabled: true };
  11706. break;
  11707. default: return;
  11708. }
  11709. DEFAULT_RULES[cat].push(newRule);
  11710. closeAddRuleModal();
  11711. // Switch to the right tab
  11712. switchRuleTab(cat);
  11713. renderRules();
  11714. // Enter edit mode for the new rule
  11715. const newIdx = DEFAULT_RULES[cat].length - 1;
  11716. startRuleEdit(cat, newIdx);
  11717. showToast('✅ 计价标准已添加,可继续编辑后保存', 'success');
  11718. }
  11719. // ---- Toggle / Delete ----
  11720. function toggleRule(group, idx) {
  11721. DEFAULT_RULES[group][idx].enabled = !DEFAULT_RULES[group][idx].enabled;
  11722. renderRules();
  11723. qRefreshAfterRuleChange();
  11724. showToast(`规则已${DEFAULT_RULES[group][idx].enabled ? '启用' : '禁用'}`, 'info');
  11725. }
  11726. function deleteRule(group, idx) {
  11727. try {
  11728. var rule = DEFAULT_RULES[group][idx];
  11729. if (!rule) return;
  11730. qConfirmDialog('确定删除规则「' + rule.name + '」吗?删除后无法恢复。', function(){
  11731. DEFAULT_RULES[group].splice(idx, 1);
  11732. state.editingRule = null;
  11733. renderRules();
  11734. persistState();
  11735. qRefreshAfterRuleChange();
  11736. showToast('🗑 规则已删除', 'info');
  11737. });
  11738. } catch(e) {
  11739. console.error('删除规则失败:', e);
  11740. showToast('删除失败,请刷新页面重试', 'error');
  11741. // 尝试恢复视图状态
  11742. try {
  11743. if (state.currentView === 'rules') {
  11744. renderRules();
  11745. }
  11746. } catch(e2) {
  11747. console.error('恢复视图失败:', e2);
  11748. }
  11749. }
  11750. }
  11751. // ====================== TEMPLATE MANAGEMENT ======================
  11752. function simulateTemplateUpload() {
  11753. const name = `报价模板_${new Date().toISOString().slice(0,10)}.xlsx`;
  11754. state.templates.push({
  11755. id: Date.now(), name, type:'Excel', size:'210KB', isDefault:false,
  11756. uploadedAt: new Date().toISOString().slice(0,10)
  11757. });
  11758. renderTemplates();
  11759. showToast(`✅ ${name} 上传成功`, 'success');
  11760. }
  11761. function setDefaultTemplate(id) {
  11762. state.templates.forEach(t => t.isDefault = (t.id === id));
  11763. renderTemplates();
  11764. showToast('默认模板已更新', 'success');
  11765. }
  11766. function downloadTemplate(id) {
  11767. const tpl = state.templates.find(t => t.id === id);
  11768. if (!tpl) { showToast('模板不存在', 'info'); return; }
  11769. if (tpl.type === 'Excel') {
  11770. // 生成Excel格式文件 (CSV with BOM for Excel compatibility)
  11771. const BOM = '';
  11772. const rows = [
  11773. ['XX装饰工程有限公司 — 报价单', '', '', '', '', ''],
  11774. ['', '', '', '', '', ''],
  11775. ['报价单号: {{quoteNo}}', '', '日期: {{createDate}}', '', '', ''],
  11776. ['户型: {{houseType}}', '', '', '', '', ''],
  11777. ['', '', '', '', '', ''],
  11778. ['空间', '平面面积(m²)', '展开面积(m²)', '柜体基础价(元)', '增项合计(元)', '空间小计(元)'],
  11779. ['{{#each spaces}}{{spaceName}}', '{{area}}', '{{expandArea}}', '{{cabinetPrice}}', '{{extraTotal}}', '{{spaceTotal}}'],
  11780. ['{{/each}}', '', '', '', '', ''],
  11781. ['合计', '', '', '', '', '{{grandTotal}}'],
  11782. ['', '', '', '', '', ''],
  11783. ['底价区间: {{floorPrice}} ~ {{ceilingPrice}} (±15%)', '', '', '', '', ''],
  11784. ['备注: 本报价不含运输安装费,有效期为30天', '', '', '', '', ''],
  11785. ];
  11786. const csv = BOM + rows.map(r => r.map(c => `"${c}"`).join(',')).join('\n');
  11787. triggerDownload(csv, tpl.name + '.csv', 'text/csv;charset=utf-8');
  11788. } else {
  11789. // 生成PDF模板预览文件 (HTML格式)
  11790. const html = `<!DOCTYPE html>
  11791. <html lang="zh-CN">
  11792. <head><meta charset="UTF-8"><title>${tpl.name}</title>
  11793. <style>
  11794. body { font-family: "Microsoft YaHei",sans-serif; padding: 40px; color: #333; }
  11795. h1 { text-align: center; font-size: 20px; margin-bottom: 8px; }
  11796. .meta { display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 16px; }
  11797. table { width: 100%; border-collapse: collapse; font-size: 12px; }
  11798. th, td { border: 1px solid #ccc; padding: 8px; text-align: center; }
  11799. th { background: #4472C4; color: #fff; }
  11800. .footer { margin-top: 16px; font-size: 12px; color: #666; }
  11801. .placeholder { color: #E53E3E; font-weight: 600; }
  11802. </style></head>
  11803. <body>
  11804. <h1>XX装饰工程有限公司 — 报价单</h1>
  11805. <div class="meta">
  11806. <span>报价单号: <span class="placeholder">{{quoteNo}}</span></span>
  11807. <span>日期: <span class="placeholder">{{createDate}}</span></span>
  11808. <span>户型: <span class="placeholder">{{houseType}}</span></span>
  11809. </div>
  11810. <table>
  11811. <thead><tr><th>空间</th><th>平面面积(m²)</th><th>展开面积(m²)</th><th>柜体基础价(元)</th><th>增项合计(元)</th><th>空间小计(元)</th></tr></thead>
  11812. <tbody>
  11813. <tr><td class="placeholder">{{#each spaces}}{{spaceName}}</td><td>{{area}}</td><td>{{expandArea}}</td><td>{{cabinetPrice}}</td><td>{{extraTotal}}</td><td>{{spaceTotal}}</td></tr>
  11814. <tr><td colspan="5" style="text-align:right;font-weight:700;">合计</td><td class="placeholder" style="font-weight:700;">{{grandTotal}}</td></tr>
  11815. </tbody>
  11816. </table>
  11817. <div class="footer">
  11818. <p>底价区间: <span class="placeholder">{{floorPrice}}</span> ~ <span class="placeholder">{{ceilingPrice}}</span> (±15%)</p>
  11819. <p>备注: 本报价不含运输安装费,有效期为30天</p>
  11820. <p style="margin-top:20px;color:#999;">提示:导出时占位符将替换为实际报价数据</p>
  11821. </div>
  11822. </body></html>`;
  11823. triggerDownload(html, tpl.name + '.html', 'text/html;charset=utf-8');
  11824. }
  11825. showToast(`✅ ${tpl.name} 下载成功`, 'success');
  11826. }
  11827. function triggerDownload(content, filename, mimeType) {
  11828. const blob = new Blob([content], { type: mimeType });
  11829. const url = URL.createObjectURL(blob);
  11830. const a = document.createElement('a');
  11831. a.href = url;
  11832. a.download = filename;
  11833. document.body.appendChild(a);
  11834. a.click();
  11835. document.body.removeChild(a);
  11836. URL.revokeObjectURL(url);
  11837. }
  11838. function deleteTemplate(id) {
  11839. qConfirmDialog('确定删除该模板吗?', function(){
  11840. state.templates = state.templates.filter(t => t.id !== id);
  11841. renderTemplates();
  11842. showToast('模板已删除', 'info');
  11843. });
  11844. }
  11845. // ====================== GLOBAL FLOAT & ESTIMATE ======================
  11846. function updateGlobalEstimate(totalGrand, floatRatio, count) {
  11847. const rangeEl = document.getElementById('global-est-range');
  11848. const midEl = document.getElementById('global-est-mid');
  11849. if (!rangeEl || !midEl) return;
  11850. if (!totalGrand || count === 0) {
  11851. rangeEl.textContent = '暂无报价数据';
  11852. midEl.textContent = '请先创建报价单';
  11853. return;
  11854. }
  11855. const floor = (totalGrand * (1 - floatRatio)).toFixed(0);
  11856. const ceil = (totalGrand * (1 + floatRatio)).toFixed(0);
  11857. rangeEl.innerHTML = `<span style="color:#68D391;">¥${Number(floor).toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2})}</span> ~ <span style="color:#FC8181;">¥${Number(ceil).toLocaleString()}</span>`;
  11858. midEl.textContent = `参考总价: ¥${totalGrand.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2})} (${count}份报价单)`;
  11859. }
  11860. function onGlobalFloatChange(val) {
  11861. const fr = parseFloat(val) / 100;
  11862. state.globalFloatRatio = fr;
  11863. syncGlobalFloatUI(fr);
  11864. renderQuoteList(); // re-render with new float ratio
  11865. }
  11866. function setGlobalFloat(pct) {
  11867. const fr = parseFloat(pct) / 100;
  11868. state.globalFloatRatio = fr;
  11869. syncGlobalFloatUI(fr);
  11870. renderQuoteList();
  11871. }
  11872. function syncGlobalFloatUI(fr) {
  11873. const pct = (fr * 100).toFixed(1);
  11874. const slider = document.getElementById('global-float-slider');
  11875. const badge = document.getElementById('global-float-badge');
  11876. const customInput = document.getElementById('float-custom');
  11877. if (slider) slider.value = pct;
  11878. if (badge) badge.textContent = `±${pct}%`;
  11879. if (customInput) customInput.value = pct;
  11880. // Update preset buttons in global card
  11881. document.querySelectorAll('#global-estimate-card .float-preset button').forEach(btn => {
  11882. btn.classList.remove('active');
  11883. const btnVal = parseFloat(btn.textContent.replace(/[^0-9.]/g, ''));
  11884. if (Math.abs(btnVal - parseFloat(pct)) < 0.01) btn.classList.add('active');
  11885. });
  11886. }
  11887. // ====================== TOAST ======================
  11888. function showToast(msg, type) {
  11889. const container = document.getElementById('toast-container');
  11890. const toast = document.createElement('div');
  11891. toast.className = `toast toast-${type}`;
  11892. toast.textContent = msg;
  11893. container.appendChild(toast);
  11894. setTimeout(() => { toast.remove(); }, 2500);
  11895. }
  11896. // ====================== 通用页内对话框(替代原生 prompt/confirm/alert,预览环境不支持原生对话框) ======================
  11897. function qDialogShow(opts){
  11898. var mask=document.getElementById('q-dialog-mask');
  11899. if(!mask){ if(opts.onOk) opts.onOk(opts.type==='prompt'?(opts.defaultValue||''):undefined); return; }
  11900. var titleEl=document.getElementById('q-dialog-title'),msgEl=document.getElementById('q-dialog-message'),inputWrap=document.getElementById('q-dialog-input-wrap'),input=document.getElementById('q-dialog-input'),okBtn=document.getElementById('q-dialog-ok'),cancelBtn=document.getElementById('q-dialog-cancel');
  11901. titleEl.textContent=opts.title||'提示';
  11902. msgEl.textContent=opts.message||'';msgEl.style.display=opts.message?'':'none';
  11903. var isPrompt=opts.type==='prompt';
  11904. inputWrap.style.display=isPrompt?'':'none';
  11905. cancelBtn.style.display=(opts.type==='alert')?'none':'';
  11906. okBtn.textContent=opts.okText||'确定';cancelBtn.textContent=opts.cancelText||'取消';
  11907. if(isPrompt){input.value=opts.defaultValue||'';}
  11908. mask.style.display='flex';
  11909. function onKey(e){if(e.key==='Escape'){e.preventDefault();e.stopPropagation();doCancel();}}
  11910. function cleanup(){mask.style.display='none';okBtn.onclick=null;cancelBtn.onclick=null;mask.onclick=null;document.removeEventListener('keydown',onKey,true);if(isPrompt)input.onkeydown=null;}
  11911. function doOk(){var v=isPrompt?input.value:null;cleanup();if(opts.onOk)opts.onOk(v);}
  11912. function doCancel(){cleanup();if(opts.onCancel)opts.onCancel();}
  11913. okBtn.onclick=doOk;
  11914. cancelBtn.onclick=doCancel;
  11915. mask.onclick=function(e){if(e.target===mask)doCancel();};
  11916. document.addEventListener('keydown',onKey,true);
  11917. if(isPrompt){input.focus();input.select();input.onkeydown=function(e){if(e.key==='Enter'){e.preventDefault();doOk();}};}
  11918. else{okBtn.focus();}
  11919. }
  11920. function qAlertDialog(message,title){qDialogShow({type:'alert',title:title||'提示',message:message});}
  11921. function qConfirmDialog(message,onOk,onCancel){qDialogShow({type:'confirm',title:'确认操作',message:message,onOk:onOk,onCancel:onCancel});}
  11922. function qPromptDialog(message,defaultValue,onOk,onCancel){qDialogShow({type:'prompt',title:'请输入',message:message,defaultValue:defaultValue,onOk:onOk,onCancel:onCancel});}
  11923. // ====================== CUSTOM CONFIRM MODAL ======================
  11924. var _confirmCallback = null;
  11925. function showConfirmModal(title, message, onConfirm) {
  11926. var modal = document.getElementById('confirm-modal');
  11927. var titleEl = document.getElementById('confirm-title');
  11928. var msgEl = document.getElementById('confirm-message');
  11929. if (!modal || !titleEl || !msgEl) {
  11930. if (onConfirm) onConfirm();
  11931. return;
  11932. }
  11933. titleEl.textContent = title || '确认操作';
  11934. msgEl.textContent = message || '确定要执行此操作吗?';
  11935. _confirmCallback = onConfirm;
  11936. modal.style.display = 'flex';
  11937. }
  11938. function handleConfirmOk() {
  11939. if (_confirmCallback) {
  11940. var cb = _confirmCallback;
  11941. _confirmCallback = null;
  11942. closeConfirmModal();
  11943. cb();
  11944. } else {
  11945. closeConfirmModal();
  11946. }
  11947. }
  11948. function closeConfirmModal() {
  11949. var modal = document.getElementById('confirm-modal');
  11950. if (modal) modal.style.display = 'none';
  11951. _confirmCallback = null;
  11952. }
  11953. document.addEventListener('keydown', function(e) {
  11954. if (e.key === 'Escape') closeConfirmModal();
  11955. });
  11956. // ====================== INIT ======================
  11957. (function init() {
  11958. // Create a demo quote if empty
  11959. if (state.quotes.length === 0) {
  11960. const demoSpaces = JSON.parse(JSON.stringify(DEFAULT_SPACES));
  11961. const demoExtras = JSON.parse(JSON.stringify(DEFAULT_EXTRA_ITEMS));
  11962. const demoResult = PricingEngine.calculate(demoSpaces, demoExtras, state.globalFloatRatio);
  11963. state.quotes.push({
  11964. id: 'demo-001',
  11965. quoteNo: 'QJ-20260714-001',
  11966. quoteMode: 'ai',
  11967. houseType: '三室两厅',
  11968. spaces: demoSpaces,
  11969. extraItems: demoExtras,
  11970. uploads: [],
  11971. grandTotal: demoResult.grandTotal,
  11972. floorPrice: demoResult.floorPrice,
  11973. ceilingPrice: demoResult.ceilingPrice,
  11974. floatRatio: state.globalFloatRatio,
  11975. pricingResult: demoResult,
  11976. status: 0,
  11977. createdAt: '2026-07-14 10:30',
  11978. updatedAt: '2026-07-14 10:30',
  11979. });
  11980. }
  11981. renderQuoteList();
  11982. renderRules();
  11983. renderTemplates();
  11984. syncGlobalFloatUI(state.globalFloatRatio);
  11985. // Open directly to editor (quote flow) like the first file
  11986. openEditorDirectly();
  11987. })();
  11988. // Auto-save on leaving editor view
  11989. window.addEventListener('beforeunload', () => {
  11990. if (state.currentQuoteId && state.currentView === 'editor') {
  11991. saveCurrentQuote();
  11992. }
  11993. });
  11994. // ==================== QUOTE ATTACHMENT PERSISTENCE (IndexedDB) ====================
  11995. const Q_ATTACHMENT_DB = 'ai_quote_attachment_db_v1';
  11996. const Q_ATTACHMENT_STORE = 'attachments';
  11997. function qOpenAttachmentDB() {
  11998. return new Promise(function(resolve, reject) {
  11999. if (!window.indexedDB) return reject(new Error('当前浏览器不支持 IndexedDB'));
  12000. var request = indexedDB.open(Q_ATTACHMENT_DB, 1);
  12001. request.onupgradeneeded = function() {
  12002. var db = request.result;
  12003. if (!db.objectStoreNames.contains(Q_ATTACHMENT_STORE)) {
  12004. var store = db.createObjectStore(Q_ATTACHMENT_STORE, { keyPath: 'key' });
  12005. store.createIndex('quoteId', 'quoteId', { unique: false });
  12006. }
  12007. };
  12008. request.onsuccess = function() { resolve(request.result); };
  12009. request.onerror = function() { reject(request.error || new Error('附件数据库打开失败')); };
  12010. });
  12011. }
  12012. function qSourceToBlob(source, url) {
  12013. if (source instanceof Blob) return Promise.resolve(source);
  12014. if (!url) return Promise.resolve(null);
  12015. return fetch(url).then(function(res) {
  12016. if (!res.ok) throw new Error('附件内容读取失败');
  12017. return res.blob();
  12018. });
  12019. }
  12020. function qBlobToDataUrl(blob) {
  12021. return new Promise(function(resolve, reject) {
  12022. var reader = new FileReader();
  12023. reader.onload = function() { resolve(reader.result); };
  12024. reader.onerror = function() { reject(reader.error || new Error('图片读取失败')); };
  12025. reader.readAsDataURL(blob);
  12026. });
  12027. }
  12028. async function qPersistQuoteAttachments(quoteId) {
  12029. if (!quoteId) return;
  12030. var slots = [
  12031. { slot: 'drawing', source: qUploadedDrawing, url: qUploadedDrawingDataUrl || qUploadedDrawingUrl },
  12032. { slot: 'cad', source: qUploadedCAD, url: qUploadedCADUrl },
  12033. { slot: 'requirement', source: qUploadedRequirement, url: qUploadedRequirementUrl }
  12034. ];
  12035. var prepared = [];
  12036. for (var i = 0; i < slots.length; i++) {
  12037. var item = slots[i];
  12038. var blob = await qSourceToBlob(item.source, item.url);
  12039. prepared.push({ item: item, blob: blob });
  12040. }
  12041. var db = await qOpenAttachmentDB();
  12042. await new Promise(function(resolve, reject) {
  12043. var tx = db.transaction(Q_ATTACHMENT_STORE, 'readwrite');
  12044. var store = tx.objectStore(Q_ATTACHMENT_STORE);
  12045. prepared.forEach(function(entry) {
  12046. var item = entry.item;
  12047. var key = quoteId + ':' + item.slot;
  12048. if (!entry.blob) {
  12049. store.delete(key);
  12050. return;
  12051. }
  12052. store.put({
  12053. key: key,
  12054. quoteId: quoteId,
  12055. slot: item.slot,
  12056. name: (item.source && item.source.name) || (item.slot + '.bin'),
  12057. type: (item.source && item.source.type) || entry.blob.type || 'application/octet-stream',
  12058. size: entry.blob.size,
  12059. blob: entry.blob,
  12060. savedAt: new Date().toISOString()
  12061. });
  12062. });
  12063. tx.oncomplete = resolve;
  12064. tx.onerror = function() { reject(tx.error || new Error('附件保存事务失败')); };
  12065. tx.onabort = function() { reject(tx.error || new Error('附件保存事务中止')); };
  12066. });
  12067. db.close();
  12068. }
  12069. function qReadQuoteAttachment(db, quoteId, slot) {
  12070. return new Promise(function(resolve, reject) {
  12071. var tx = db.transaction(Q_ATTACHMENT_STORE, 'readonly');
  12072. var req = tx.objectStore(Q_ATTACHMENT_STORE).get(quoteId + ':' + slot);
  12073. req.onsuccess = function() { resolve(req.result || null); };
  12074. req.onerror = function() { reject(req.error || new Error('附件读取失败')); };
  12075. });
  12076. }
  12077. function qRestoreAttachmentUI(slot, file, url) {
  12078. var safeName = String(file.name || '已上传文件').replace(/</g, '&lt;').replace(/>/g, '&gt;');
  12079. if (slot === 'drawing') {
  12080. var drawingZone = document.getElementById('q-upload-zone-drawing');
  12081. if (drawingZone) { drawingZone.style.borderColor='var(--success)'; drawingZone.style.background='#F0FFF4'; }
  12082. var drawingInner = document.getElementById('q-drawing-inner');
  12083. if (drawingInner) drawingInner.innerHTML='<div class="icon">✅</div><div class="text">户型图已恢复</div><div class="types">'+safeName+'</div>'+qMakePreviewThumb(url, file.name || '户型图');
  12084. var drawingFiles = document.getElementById('q-upload-files-drawing');
  12085. if (drawingFiles) drawingFiles.innerHTML='<span class="q-file-chip">📐 '+safeName+' <span style="cursor:pointer;margin-left:4px" onclick="event.stopPropagation();qClearDrawing()">×</span></span>';
  12086. var drawingReplace = document.getElementById('q-btn-replace-drawing');
  12087. if (drawingReplace) drawingReplace.style.display='inline-block';
  12088. } else if (slot === 'cad') {
  12089. var cadZone = document.getElementById('q-upload-zone-cad');
  12090. if (cadZone) { cadZone.style.borderColor='var(--success)'; cadZone.style.background='#F0FFF4'; }
  12091. var cadInner = document.getElementById('q-cad-inner');
  12092. if (cadInner) cadInner.innerHTML='<div class="icon">✅</div><div class="text">CAD图纸已恢复</div><div class="types">'+safeName+'</div>';
  12093. var cadFiles = document.getElementById('q-upload-files-cad');
  12094. if (cadFiles) cadFiles.innerHTML='<span class="q-file-chip">📏 '+safeName+' <span style="cursor:pointer;margin-left:4px" onclick="event.stopPropagation();qClearCAD()">×</span></span>';
  12095. var cadReplace = document.getElementById('q-btn-replace-cad');
  12096. if (cadReplace) cadReplace.style.display='inline-block';
  12097. } else {
  12098. var reqZone = document.getElementById('q-upload-zone-req');
  12099. if (reqZone) { reqZone.style.borderColor='var(--success)'; reqZone.style.background='#F0FFF4'; }
  12100. var reqInner = document.getElementById('q-req-inner');
  12101. var preview = file.type && file.type.indexOf('image/') === 0 ? qMakePreviewThumb(url, file.name || '需求记录') : '';
  12102. if (reqInner) reqInner.innerHTML='<div class="icon">✅</div><div class="text">需求记录已恢复</div><div class="types">'+safeName+'</div>'+preview;
  12103. var reqFiles = document.getElementById('q-upload-files-req');
  12104. if (reqFiles) reqFiles.innerHTML='<span class="q-file-chip">📄 '+safeName+' <span style="cursor:pointer;margin-left:4px" onclick="event.stopPropagation();qClearRequirement()">×</span></span>';
  12105. }
  12106. }
  12107. async function qRestoreQuoteAttachments(quoteId) {
  12108. if (!quoteId) return;
  12109. var db = await qOpenAttachmentDB();
  12110. var records = await Promise.all([
  12111. qReadQuoteAttachment(db, quoteId, 'drawing'),
  12112. qReadQuoteAttachment(db, quoteId, 'cad'),
  12113. qReadQuoteAttachment(db, quoteId, 'requirement')
  12114. ]);
  12115. db.close();
  12116. for (var i = 0; i < records.length; i++) {
  12117. var rec = records[i];
  12118. if (!rec || !rec.blob) continue;
  12119. var file;
  12120. try { file = new File([rec.blob], rec.name, { type: rec.type, lastModified: Date.now() }); }
  12121. catch(e) { file = rec.blob; file.name = rec.name; }
  12122. var url = URL.createObjectURL(rec.blob);
  12123. if (rec.slot === 'drawing') {
  12124. qUploadedDrawing = file;
  12125. qUploadedDrawingUrl = url;
  12126. qUploadedDrawingDataUrl = await qBlobToDataUrl(rec.blob);
  12127. } else if (rec.slot === 'cad') {
  12128. qUploadedCAD = file;
  12129. qUploadedCADUrl = url;
  12130. } else {
  12131. qUploadedRequirement = file;
  12132. qUploadedRequirementUrl = url;
  12133. }
  12134. qRestoreAttachmentUI(rec.slot, file, url);
  12135. if (rec.slot === 'drawing') qSyncOriginalDrawingToLayout();
  12136. }
  12137. qCheckUploadReady();
  12138. qSyncOriginalDrawingToLayout();
  12139. if (records.some(function(r){ return !!r; })) showToast('✅ 原始上传资料已恢复', 'success');
  12140. }
  12141. // ==================== LOCALSTORAGE PERSISTENCE ====================
  12142. const STORAGE_KEY = 'ai_quote_system_data_v18';
  12143. var moduleRulesData = {};
  12144. function persistState() {
  12145. try {
  12146. const dataToSave = {
  12147. quotes: state.quotes,
  12148. rules: state.rules,
  12149. priceRules: { wardrobe: DEFAULT_RULES.wardrobe, kitchen: DEFAULT_RULES.kitchen, base: DEFAULT_RULES.base, extra: DEFAULT_RULES.extra },
  12150. templates: state.templates,
  12151. quotationPlans: state.quotationPlans,
  12152. defaultPricingMode: state.defaultPricingMode,
  12153. defaultFixedUnitPrice: state.defaultFixedUnitPrice,
  12154. allowModeSwitch: state.allowModeSwitch,
  12155. fixedPriceNote: state.fixedPriceNote,
  12156. includeExtrasInFixed: state.includeExtrasInFixed,
  12157. customTypes: (typeof qCustomTypes !== 'undefined') ? qCustomTypes : null,
  12158. moduleRules: moduleRulesData,
  12159. savedAt: new Date().toISOString()
  12160. };
  12161. localStorage.setItem(STORAGE_KEY, JSON.stringify(dataToSave));
  12162. } catch(e) {
  12163. console.warn('Failed to persist state:', e);
  12164. }
  12165. }
  12166. function loadState() {
  12167. try {
  12168. const saved = localStorage.getItem(STORAGE_KEY);
  12169. if (saved) {
  12170. const data = JSON.parse(saved);
  12171. if (data.quotes && Array.isArray(data.quotes) && data.quotes.length > 0) {
  12172. state.quotes = data.quotes;
  12173. console.log('Loaded', data.quotes.length, 'quotes from localStorage');
  12174. }
  12175. // 智能合并价格规则:以代码中的DEFAULT_RULES为基准,用用户保存的价格覆盖
  12176. if (data.priceRules && typeof data.priceRules === 'object') {
  12177. // 构建保存规则的名称映射
  12178. var savedBaseMap = {};
  12179. var savedExtraMap = {};
  12180. if (Array.isArray(data.priceRules.base)) {
  12181. data.priceRules.base.forEach(function(r){ if(r && r.name) savedBaseMap[r.name] = r; });
  12182. }
  12183. if (Array.isArray(data.priceRules.extra)) {
  12184. data.priceRules.extra.forEach(function(r){ if(r && r.name) savedExtraMap[r.name] = r; });
  12185. }
  12186. // 合并base:代码中base只有"柜体投影单价"一项,确保它存在
  12187. var mergedBase = [];
  12188. DEFAULT_RULES.base.forEach(function(defRule){
  12189. var saved = savedBaseMap[defRule.name] || savedExtraMap[defRule.name];
  12190. if(saved){
  12191. // 用户之前保存过,使用保存的价格值
  12192. mergedBase.push({
  12193. name: defRule.name,
  12194. method: defRule.method,
  12195. value: saved.value !== undefined ? saved.value : (saved.price !== undefined ? saved.price : defRule.value),
  12196. unit: saved.unit || defRule.unit,
  12197. desc: saved.desc || defRule.desc
  12198. });
  12199. } else {
  12200. mergedBase.push(JSON.parse(JSON.stringify(defRule)));
  12201. }
  12202. });
  12203. DEFAULT_RULES.base = mergedBase;
  12204. // 合并extra:代码中extra有所有配件/门板单价,确保它们都存在
  12205. var mergedExtra = [];
  12206. DEFAULT_RULES.extra.forEach(function(defRule){
  12207. var saved = savedExtraMap[defRule.name] || savedBaseMap[defRule.name];
  12208. if(saved){
  12209. mergedExtra.push({
  12210. name: defRule.name,
  12211. method: defRule.method,
  12212. price: saved.price !== undefined ? saved.price : (saved.value !== undefined ? saved.value : defRule.price),
  12213. unit: saved.unit || defRule.unit,
  12214. desc: saved.desc || defRule.desc
  12215. });
  12216. } else {
  12217. mergedExtra.push(JSON.parse(JSON.stringify(defRule)));
  12218. }
  12219. });
  12220. // 保留用户新增的自定义extra项(不在默认列表中的)
  12221. if (Array.isArray(data.priceRules.extra)) {
  12222. data.priceRules.extra.forEach(function(r){
  12223. if(r && r.name && !mergedExtra.find(function(m){ return m.name === r.name; })){
  12224. mergedExtra.push({
  12225. name: r.name,
  12226. method: r.method || '按个数',
  12227. price: r.price !== undefined ? r.price : (r.value || '0'),
  12228. unit: r.unit || '元/个',
  12229. desc: r.desc || ''
  12230. });
  12231. }
  12232. });
  12233. }
  12234. DEFAULT_RULES.extra = mergedExtra;
  12235. ['wardrobe','kitchen'].forEach(function(group){
  12236. var savedList=Array.isArray(data.priceRules[group])?data.priceRules[group]:[];
  12237. if(group==='kitchen') savedList=savedList.filter(function(r){return r&&r.name!=='厨房3米基础套餐'&&r.name!=='厨房超3米部分'&&r.name!=='见光板×2';});
  12238. var savedMap={};savedList.forEach(function(r){if(r&&r.name)savedMap[r.name]=r;});
  12239. var merged=(DEFAULT_RULES[group]||[]).map(function(defRule){
  12240. var old=savedMap[defRule.name];
  12241. return old?{name:defRule.name,displayName:old.displayName||defRule.displayName,method:group==='kitchen'?defRule.method:(old.method||defRule.method),value:old.value!==undefined?old.value:(old.price!==undefined?old.price:defRule.value),unit:group==='kitchen'?defRule.unit:(old.unit||defRule.unit),desc:group==='kitchen'?(old._descEdited?old.desc:defRule.desc):(old.desc||defRule.desc),custom:!!old.custom,_descEdited:!!old._descEdited}:JSON.parse(JSON.stringify(defRule));
  12242. });
  12243. savedList.forEach(function(r){if(r&&r.name&&!merged.some(function(m){return m.name===r.name;})&&(group!=='wardrobe'||r.custom===true))merged.push(r);});
  12244. DEFAULT_RULES[group]=merged;
  12245. });
  12246. }
  12247. if (data.rules && typeof data.rules === 'object') {
  12248. state.rules = {
  12249. base: Array.isArray(data.rules.base) ? data.rules.base : [],
  12250. extra: Array.isArray(data.rules.extra) ? data.rules.extra : []
  12251. };
  12252. }
  12253. if (data.templates && Array.isArray(data.templates)) {
  12254. state.templates = data.templates;
  12255. }
  12256. if (data.quotationPlans && Array.isArray(data.quotationPlans) && data.quotationPlans.length > 0) {
  12257. state.quotationPlans = data.quotationPlans;
  12258. } else {
  12259. state.quotationPlans = JSON.parse(JSON.stringify(DEFAULT_QUOTATION_PLANS));
  12260. }
  12261. if (data.defaultPricingMode) state.defaultPricingMode = data.defaultPricingMode;
  12262. if (data.defaultFixedUnitPrice) state.defaultFixedUnitPrice = data.defaultFixedUnitPrice;
  12263. if (typeof data.allowModeSwitch === 'boolean') state.allowModeSwitch = data.allowModeSwitch;
  12264. if (typeof data.fixedPriceNote === 'string') state.fixedPriceNote = data.fixedPriceNote;
  12265. if (typeof data.includeExtrasInFixed === 'boolean') state.includeExtrasInFixed = data.includeExtrasInFixed;
  12266. if (data.customTypes && typeof qCustomTypes !== 'undefined') {
  12267. qCustomTypes = data.customTypes;
  12268. }
  12269. if (data.moduleRules && typeof data.moduleRules === 'object') {
  12270. for (var key in data.moduleRules) {
  12271. if (data.moduleRules.hasOwnProperty(key) && moduleRulesData.hasOwnProperty(key)) {
  12272. moduleRulesData[key] = data.moduleRules[key];
  12273. }
  12274. }
  12275. }
  12276. } else {
  12277. state.quotationPlans = JSON.parse(JSON.stringify(DEFAULT_QUOTATION_PLANS));
  12278. }
  12279. } catch(e) {
  12280. console.warn('Failed to load state:', e);
  12281. state.quotationPlans = JSON.parse(JSON.stringify(DEFAULT_QUOTATION_PLANS));
  12282. }
  12283. }
  12284. // ==================== QUOTATION PLANS MANAGEMENT ====================
  12285. const PLAN_TIER_MAP = {
  12286. economy: { key: 'price', label: '经济型', color: '#C05621', bgColor: '#FFFAF0', borderColor: '#FBD38D' },
  12287. standard: { key: 'standard', label: '标准型', color: '#2B6CB0', bgColor: '#EBF8FF', borderColor: '#3182CE' },
  12288. quality: { key: 'quality', label: '品质型', color: '#6B46C1', bgColor: '#FAF5FF', borderColor: '#9F7AEA' }
  12289. };
  12290. function getDefaultPlan() {
  12291. return state.quotationPlans.find(p => p.isDefault && p.enabled) || state.quotationPlans.find(p => p.enabled) || state.quotationPlans[0] || null;
  12292. }
  12293. function getPlanById(id) {
  12294. return state.quotationPlans.find(p => p.id === id);
  12295. }
  12296. function getTierLevelOptions(tierKey) {
  12297. var internalTier = PLAN_TIER_MAP[tierKey] ? PLAN_TIER_MAP[tierKey].key : tierKey;
  12298. var cfg = Q_CUSTOMER_TIERS[internalTier];
  12299. if (!cfg || !cfg.discounts) return [];
  12300. return cfg.discounts.map(function(d) {
  12301. return { id: d.id, label: d.label, rate: d.rate };
  12302. });
  12303. }
  12304. function populateTierLevelSelect(tierKey, selectedLevel) {
  12305. var sel = document.getElementById('plan-tier-level');
  12306. if (!sel) return;
  12307. var opts = getTierLevelOptions(tierKey);
  12308. sel.innerHTML = opts.map(function(o) {
  12309. return '<option value="' + o.id + '"' + (o.id === selectedLevel ? ' selected' : '') + '>' + o.label + '</option>';
  12310. }).join('');
  12311. }
  12312. function onPlanTierChange() {
  12313. var tier = document.getElementById('plan-tier').value;
  12314. var opts = getTierLevelOptions(tier);
  12315. populateTierLevelSelect(tier, opts[0] ? opts[0].id : '');
  12316. }
  12317. function genPlanId() {
  12318. return 'plan-' + Date.now() + '-' + Math.random().toString(36).substr(2, 5);
  12319. }
  12320. function openPlanModal(planId) {
  12321. var modal = document.getElementById('plan-modal');
  12322. var title = document.getElementById('plan-modal-title');
  12323. document.getElementById('plan-edit-id').value = planId || '';
  12324. if (planId) {
  12325. var plan = getPlanById(planId);
  12326. if (!plan) return;
  12327. title.textContent = '✏️ 编辑报价方案';
  12328. document.getElementById('plan-name').value = plan.name;
  12329. document.getElementById('plan-desc').value = plan.description || '';
  12330. var tierKey = Object.keys(PLAN_TIER_MAP).find(function(k) { return PLAN_TIER_MAP[k].key === plan.customerTier; }) || plan.customerTier;
  12331. document.getElementById('plan-tier').value = tierKey;
  12332. populateTierLevelSelect(tierKey, plan.tierLevel);
  12333. document.getElementById('plan-float-slider').value = Math.round(plan.floatRatio * 100);
  12334. document.getElementById('plan-float-val').textContent = '±' + Math.round(plan.floatRatio * 100) + '%';
  12335. document.getElementById('plan-discount').value = plan.discountRate;
  12336. document.getElementById('plan-discount-pct').textContent = (plan.discountRate >= 1 ? '原价' : Math.round(plan.discountRate*100)/10 + '折');
  12337. document.getElementById('plan-enabled').checked = plan.enabled !== false;
  12338. document.getElementById('plan-default').checked = plan.isDefault === true;
  12339. } else {
  12340. title.textContent = '+ 新增报价方案';
  12341. document.getElementById('plan-name').value = '';
  12342. document.getElementById('plan-desc').value = '';
  12343. document.getElementById('plan-tier').value = 'standard';
  12344. populateTierLevelSelect('standard', 's1');
  12345. document.getElementById('plan-float-slider').value = 15;
  12346. document.getElementById('plan-float-val').textContent = '±15%';
  12347. document.getElementById('plan-discount').value = 1;
  12348. document.getElementById('plan-discount-pct').textContent = '原价';
  12349. document.getElementById('plan-enabled').checked = true;
  12350. document.getElementById('plan-default').checked = false;
  12351. }
  12352. modal.style.display = 'flex';
  12353. }
  12354. function closePlanModal() {
  12355. document.getElementById('plan-modal').style.display = 'none';
  12356. }
  12357. function savePlan() {
  12358. var editId = document.getElementById('plan-edit-id').value;
  12359. var name = document.getElementById('plan-name').value.trim();
  12360. if (!name) { showToast('⚠️ 请输入方案名称', 'warning'); return; }
  12361. var desc = document.getElementById('plan-desc').value.trim();
  12362. var uiTier = document.getElementById('plan-tier').value;
  12363. var internalTier = PLAN_TIER_MAP[uiTier] ? PLAN_TIER_MAP[uiTier].key : uiTier;
  12364. var tierLevel = document.getElementById('plan-tier-level').value;
  12365. var floatRatio = parseFloat(document.getElementById('plan-float-slider').value) / 100;
  12366. var discountRate = parseFloat(document.getElementById('plan-discount').value);
  12367. var enabled = document.getElementById('plan-enabled').checked;
  12368. var isDefault = document.getElementById('plan-default').checked;
  12369. if (isDefault) {
  12370. state.quotationPlans.forEach(function(p) { p.isDefault = false; });
  12371. }
  12372. if (editId) {
  12373. var plan = getPlanById(editId);
  12374. if (plan) {
  12375. plan.name = name;
  12376. plan.description = desc;
  12377. plan.customerTier = internalTier;
  12378. plan.tierLevel = tierLevel;
  12379. plan.floatRatio = floatRatio;
  12380. plan.discountRate = discountRate;
  12381. plan.enabled = enabled;
  12382. plan.isDefault = isDefault;
  12383. plan.updatedAt = new Date().toLocaleString('zh-CN', {year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}).replace(/\//g, '-');
  12384. }
  12385. showToast('✅ 方案「' + name + '」已更新', 'success');
  12386. } else {
  12387. var newPlan = {
  12388. id: genPlanId(),
  12389. name: name,
  12390. description: desc,
  12391. customerTier: internalTier,
  12392. tierLevel: tierLevel,
  12393. floatRatio: floatRatio,
  12394. discountRate: discountRate,
  12395. isDefault: isDefault,
  12396. enabled: enabled,
  12397. createdAt: new Date().toLocaleString('zh-CN', {year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}).replace(/\//g, '-'),
  12398. updatedAt: new Date().toLocaleString('zh-CN', {year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}).replace(/\//g, '-')
  12399. };
  12400. state.quotationPlans.push(newPlan);
  12401. showToast('✅ 方案「' + name + '」已创建', 'success');
  12402. }
  12403. var hasDefault = state.quotationPlans.some(function(p) { return p.isDefault; });
  12404. if (!hasDefault && state.quotationPlans.length > 0) {
  12405. state.quotationPlans[0].isDefault = true;
  12406. }
  12407. persistState();
  12408. closePlanModal();
  12409. renderPlanList();
  12410. }
  12411. function editPlan(id) {
  12412. openPlanModal(id);
  12413. }
  12414. function deletePlan(id) {
  12415. var plan = getPlanById(id);
  12416. if (!plan) return;
  12417. qConfirmDialog('确定删除报价方案「' + plan.name + '」吗?此操作不可恢复。', function(){
  12418. state.quotationPlans = state.quotationPlans.filter(function(p) { return p.id !== id; });
  12419. if (plan.isDefault && state.quotationPlans.length > 0) {
  12420. state.quotationPlans[0].isDefault = true;
  12421. }
  12422. persistState();
  12423. renderPlanList();
  12424. showToast('🗑️ 方案已删除', 'info');
  12425. });
  12426. }
  12427. function duplicatePlan(id) {
  12428. var plan = getPlanById(id);
  12429. if (!plan) return;
  12430. var copy = JSON.parse(JSON.stringify(plan));
  12431. copy.id = genPlanId();
  12432. copy.name = plan.name + ' (副本)';
  12433. copy.isDefault = false;
  12434. copy.createdAt = new Date().toLocaleString('zh-CN', {year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}).replace(/\//g, '-');
  12435. copy.updatedAt = copy.createdAt;
  12436. state.quotationPlans.push(copy);
  12437. persistState();
  12438. renderPlanList();
  12439. showToast('📋 方案「' + copy.name + '」已复制', 'success');
  12440. }
  12441. function togglePlanDefault(id) {
  12442. var plan = getPlanById(id);
  12443. if (!plan) return;
  12444. state.quotationPlans.forEach(function(p) { p.isDefault = false; });
  12445. plan.isDefault = true;
  12446. persistState();
  12447. renderPlanList();
  12448. showToast('⭐ 已将「' + plan.name + '」设为默认方案', 'success');
  12449. }
  12450. function togglePlanEnabled(id) {
  12451. var plan = getPlanById(id);
  12452. if (!plan) return;
  12453. plan.enabled = !plan.enabled;
  12454. if (!plan.enabled && plan.isDefault) {
  12455. var nextEnabled = state.quotationPlans.find(function(p) { return p.enabled && p.id !== id; });
  12456. if (nextEnabled) nextEnabled.isDefault = true;
  12457. }
  12458. if (!state.quotationPlans.some(function(p) { return p.isDefault; })) {
  12459. var firstEnabled = state.quotationPlans.find(function(p) { return p.enabled; });
  12460. if (firstEnabled) firstEnabled.isDefault = true;
  12461. }
  12462. persistState();
  12463. renderPlanList();
  12464. }
  12465. // ====================== 报价模式管理 ======================
  12466. function selectDefaultPricingMode(mode) {
  12467. state.defaultPricingMode = mode;
  12468. var pkgCard = document.getElementById('mode-card-package');
  12469. var fixCard = document.getElementById('mode-card-fixed');
  12470. var pkgTitle = document.getElementById('mode-title-package');
  12471. var fixTitle = document.getElementById('mode-title-fixed');
  12472. var pkgCheck = document.getElementById('mode-check-package');
  12473. var fixCheck = document.getElementById('mode-check-fixed');
  12474. var fixConfig = document.getElementById('fixed-price-config');
  12475. if (!pkgCard || !fixCard || !pkgTitle || !fixTitle) return;
  12476. if (mode === 'package') {
  12477. pkgCard.style.borderColor = '#3182CE';
  12478. pkgCard.style.background = 'linear-gradient(135deg,#EBF8FF,#fff)';
  12479. pkgTitle.style.color = '#2B6CB0';
  12480. fixCard.style.borderColor = '#E2E8F0';
  12481. fixCard.style.background = '#fff';
  12482. fixTitle.style.color = '#2D3748';
  12483. if (pkgCheck) { pkgCheck.style.display = 'flex'; pkgCheck.style.background = '#3182CE'; }
  12484. if (fixCheck) fixCheck.style.display = 'none';
  12485. if (fixConfig) fixConfig.style.display = 'none';
  12486. } else {
  12487. fixCard.style.borderColor = '#38A169';
  12488. fixCard.style.background = 'linear-gradient(135deg,#F0FFF4,#fff)';
  12489. fixTitle.style.color = '#276749';
  12490. pkgCard.style.borderColor = '#E2E8F0';
  12491. pkgCard.style.background = '#fff';
  12492. pkgTitle.style.color = '#2D3748';
  12493. if (fixCheck) { fixCheck.style.display = 'flex'; fixCheck.style.background = '#38A169'; }
  12494. if (pkgCheck) pkgCheck.style.display = 'none';
  12495. if (fixConfig) fixConfig.style.display = 'block';
  12496. }
  12497. }
  12498. function savePricingModeConfig() {
  12499. var priceInput = document.getElementById('default-fixed-price');
  12500. var noteInput = document.getElementById('fixed-price-note');
  12501. var allowSwitch = document.getElementById('allow-mode-switch');
  12502. var includeExtras = document.getElementById('include-extras-fixed');
  12503. var price = priceInput ? parseFloat(priceInput.value) : 3500;
  12504. if (isNaN(price) || price < 1000 || price > 20000) {
  12505. showToast('固定单价需在1000-20000元/㎡之间');
  12506. return;
  12507. }
  12508. state.defaultFixedUnitPrice = price;
  12509. state.fixedPriceNote = noteInput ? noteInput.value.trim() : '';
  12510. state.allowModeSwitch = allowSwitch ? allowSwitch.checked : true;
  12511. state.includeExtrasInFixed = includeExtras ? includeExtras.checked : true;
  12512. persistState();
  12513. showToast('✅ 报价模式设置已保存');
  12514. refreshPricingModeUI();
  12515. }
  12516. function resetPricingModeConfig() {
  12517. state.defaultPricingMode = 'package';
  12518. state.defaultFixedUnitPrice = 3500;
  12519. state.allowModeSwitch = true;
  12520. state.fixedPriceNote = '';
  12521. state.includeExtrasInFixed = true;
  12522. loadPricingModeConfigUI();
  12523. showToast('已恢复默认设置');
  12524. }
  12525. function loadPricingModeConfigUI() {
  12526. selectDefaultPricingMode(state.defaultPricingMode || 'package');
  12527. var priceInput = document.getElementById('default-fixed-price');
  12528. var noteInput = document.getElementById('fixed-price-note');
  12529. var allowSwitch = document.getElementById('allow-mode-switch');
  12530. var includeExtras = document.getElementById('include-extras-fixed');
  12531. if (priceInput) priceInput.value = state.defaultFixedUnitPrice || 0;
  12532. if (noteInput) noteInput.value = state.fixedPriceNote || '';
  12533. if (allowSwitch) allowSwitch.checked = state.allowModeSwitch !== false;
  12534. if (includeExtras) includeExtras.checked = state.includeExtrasInFixed !== false;
  12535. }
  12536. function switchPricingMode(mode) {
  12537. state.currentPricingMode = mode;
  12538. state.currentFixedUnitPrice = mode === 'fixedPrice' ? (state.defaultFixedUnitPrice || 0) : state.currentFixedUnitPrice;
  12539. refreshQStep4ModeUI();
  12540. if (state.currentStep === 3 && typeof qRenderStep4 === 'function') {
  12541. qRenderStep4();
  12542. }
  12543. if (state.currentStep === 4 && typeof qRenderStep5 === 'function') {
  12544. qRenderStep5();
  12545. }
  12546. }
  12547. function setQFixedPrice(price) {
  12548. var input = document.getElementById('q-fixed-price-input');
  12549. if (input) input.value = price;
  12550. state.currentFixedUnitPrice = price;
  12551. if (state.currentStep === 3 && typeof qRenderStep4 === 'function') {
  12552. qRenderStep4();
  12553. }
  12554. if (state.currentStep === 4 && typeof qRenderStep5 === 'function') {
  12555. qRenderStep5();
  12556. }
  12557. }
  12558. function onQFixedPriceChange() {
  12559. var input = document.getElementById('q-fixed-price-input');
  12560. if (!input) return;
  12561. var val = parseFloat(input.value);
  12562. if (isNaN(val) || val < 1000) val = 1000;
  12563. if (val > 20000) val = 20000;
  12564. input.value = val;
  12565. state.currentFixedUnitPrice = val;
  12566. if (state.currentStep === 3 && typeof qRenderStep4 === 'function') {
  12567. qRenderStep4();
  12568. }
  12569. if (state.currentStep === 4 && typeof qRenderStep5 === 'function') {
  12570. qRenderStep5();
  12571. }
  12572. }
  12573. function refreshPricingModeUI() {
  12574. loadPricingModeConfigUI();
  12575. }
  12576. function refreshQStep4ModeUI() {
  12577. var mode = state.currentPricingMode || 'package';
  12578. var badge = document.getElementById('q-pricing-mode-badge');
  12579. var desc = document.getElementById('q-pricing-mode-desc');
  12580. var toggle = document.getElementById('q-pricing-mode-toggle');
  12581. var fixConfig = document.getElementById('q-fixed-price-config');
  12582. var btnPkg = document.getElementById('q-mode-btn-package');
  12583. var btnFix = document.getElementById('q-mode-btn-fixed');
  12584. var fixNote = document.getElementById('q-fixed-price-note');
  12585. var fixInput = document.getElementById('q-fixed-price-input');
  12586. if (badge) {
  12587. if (mode === 'fixedPrice') {
  12588. badge.textContent = '📌 固定单价报';
  12589. badge.style.background = '#38A169';
  12590. } else {
  12591. badge.textContent = '📦 套餐原价报';
  12592. badge.style.background = '#3182CE';
  12593. }
  12594. }
  12595. if (desc) {
  12596. desc.textContent = mode === 'fixedPrice'
  12597. ? '一口价快速报价,总投影面积×固定单价'
  12598. : '按明细累加报价,展示各空间柜体价格';
  12599. }
  12600. if (toggle) {
  12601. toggle.style.display = state.allowModeSwitch ? 'flex' : 'none';
  12602. }
  12603. if (badge) {
  12604. badge.style.display = state.allowModeSwitch ? 'none' : 'inline-block';
  12605. }
  12606. if (btnPkg && btnFix) {
  12607. if (mode === 'package') {
  12608. btnPkg.style.borderColor = '#3182CE';
  12609. btnPkg.style.background = '#EBF8FF';
  12610. btnPkg.style.color = '#2B6CB0';
  12611. btnFix.style.borderColor = '#E2E8F0';
  12612. btnFix.style.background = '#fff';
  12613. btnFix.style.color = '#4A5568';
  12614. } else {
  12615. btnFix.style.borderColor = '#38A169';
  12616. btnFix.style.background = '#F0FFF4';
  12617. btnFix.style.color = '#276749';
  12618. btnPkg.style.borderColor = '#E2E8F0';
  12619. btnPkg.style.background = '#fff';
  12620. btnPkg.style.color = '#4A5568';
  12621. }
  12622. }
  12623. if (fixConfig) {
  12624. fixConfig.style.display = mode === 'fixedPrice' ? 'block' : 'none';
  12625. }
  12626. if (fixInput && mode === 'fixedPrice') {
  12627. fixInput.value = state.currentFixedUnitPrice || state.defaultFixedUnitPrice || 0;
  12628. }
  12629. if (fixNote) {
  12630. var note = state.fixedPriceNote || '';
  12631. fixNote.textContent = note;
  12632. fixNote.style.display = note ? 'inline' : 'none';
  12633. }
  12634. }
  12635. function getTotalProjectArea() {
  12636. var total = 0;
  12637. qRooms.forEach(function(r) {
  12638. total += (r.projectArea || 0);
  12639. });
  12640. return total;
  12641. }
  12642. function getTierBadgeStyle(plan) {
  12643. var map = { price:'#C05621', standard:'#2B6CB0', quality:'#6B46C1' };
  12644. var color = map[plan.customerTier] || '#2B6CB0';
  12645. var bgMap = { price:'#FEEBC8', standard:'#BEE3F8', quality:'#E9D8FD' };
  12646. return 'background:' + (bgMap[plan.customerTier] || '#BEE3F8') + ';color:' + color + ';';
  12647. }
  12648. function getTierLabel(plan) {
  12649. var map = { price:'经济型', standard:'标准型', quality:'品质型' };
  12650. return map[plan.customerTier] || plan.customerTier;
  12651. }
  12652. function renderPlanList() {
  12653. var container = document.getElementById('plan-list-container');
  12654. if (!container) return;
  12655. var plans = state.quotationPlans;
  12656. if (!plans || plans.length === 0) {
  12657. container.innerHTML = '<div style="grid-column:1/-1;text-align:center;padding:40px;color:var(--text-muted);">暂无报价方案,点击右上角"+ 新增方案"创建</div>';
  12658. return;
  12659. }
  12660. var html = '';
  12661. plans.forEach(function(plan) {
  12662. var borderColor = plan.enabled ? (plan.customerTier === 'price' ? '#F6AD55' : plan.customerTier === 'quality' ? '#B794F4' : '#63B3ED') : '#E2E8F0';
  12663. var opacity = plan.enabled ? '1' : '0.6';
  12664. var defaultBadge = plan.isDefault ? '<span style="font-size:10px;padding:2px 8px;border-radius:10px;background:#FBD38D;color:#744210;font-weight:600;margin-left:6px;">默认</span>' : '';
  12665. var statusBadge = plan.enabled
  12666. ? '<span style="font-size:10px;padding:2px 8px;border-radius:10px;background:#C6F6D5;color:#276749;font-weight:600;">已启用</span>'
  12667. : '<span style="font-size:10px;padding:2px 8px;border-radius:10px;background:#E2E8F0;color:#718096;font-weight:600;">已停用</span>';
  12668. html += '<div style="border:2px solid ' + borderColor + ';border-radius:12px;padding:16px;background:#fff;opacity:' + opacity + ';transition:all .2s;box-shadow:0 2px 8px rgba(0,0,0,0.04);" ' +
  12669. 'onmouseover="this.style.transform=\'translateY(-2px)\';this.style.boxShadow=\'0 6px 16px rgba(0,0,0,0.1)\'" onmouseout="this.style.transform=\'\';this.style.boxShadow=\'0 2px 8px rgba(0,0,0,0.04)\'">' +
  12670. '<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:10px;">' +
  12671. '<div style="flex:1;">' +
  12672. '<div style="font-weight:700;font-size:15px;color:#2D3748;display:flex;align-items:center;">' + plan.name + defaultBadge + '</div>' +
  12673. '<div style="font-size:11px;color:#718096;margin-top:4px;line-height:1.5;">' + (plan.description || '暂无说明') + '</div>' +
  12674. '</div>' +
  12675. '</div>' +
  12676. '<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:12px;">' +
  12677. '<span style="font-size:11px;padding:3px 8px;border-radius:6px;' + getTierBadgeStyle(plan) + ';font-weight:600;">' + getTierLabel(plan) + '</span>' +
  12678. '<span style="font-size:11px;padding:3px 8px;border-radius:6px;background:#EDF2F7;color:#4A5568;">浮动 ±' + Math.round(plan.floatRatio*100) + '%</span>' +
  12679. '<span style="font-size:11px;padding:3px 8px;border-radius:6px;background:#EDF2F7;color:#4A5568;">' + (plan.discountRate >= 1 ? '原价' : Math.round(plan.discountRate*100)/10 + '折') + '</span>' +
  12680. statusBadge +
  12681. '</div>' +
  12682. '<div style="display:flex;gap:6px;flex-wrap:wrap;padding-top:10px;border-top:1px solid #F0F4F8;">' +
  12683. '<button class="btn btn-outline btn-sm" onclick="editPlan(\'' + plan.id + '\')" style="font-size:11px;padding:4px 10px;">✏️ 编辑</button>' +
  12684. '<button class="btn btn-outline btn-sm" onclick="duplicatePlan(\'' + plan.id + '\')" style="font-size:11px;padding:4px 10px;">📋 复制</button>' +
  12685. (plan.isDefault ? '' : '<button class="btn btn-outline btn-sm" onclick="togglePlanDefault(\'' + plan.id + '\')" style="font-size:11px;padding:4px 10px;">⭐ 设默认</button>') +
  12686. '<button class="btn btn-outline btn-sm" onclick="togglePlanEnabled(\'' + plan.id + '\')" style="font-size:11px;padding:4px 10px;">' + (plan.enabled ? '🚫 停用' : '✅ 启用') + '</button>' +
  12687. '<button class="btn btn-outline btn-sm" onclick="deletePlan(\'' + plan.id + '\')" style="font-size:11px;padding:4px 10px;color:#E53E3E;border-color:#FEB2B2;">🗑️ 删除</button>' +
  12688. '</div>' +
  12689. '</div>';
  12690. });
  12691. container.innerHTML = html;
  12692. }
  12693. function renderPlanCardsInStep4() {
  12694. var container = document.getElementById('q-plan-cards');
  12695. if (!container) return;
  12696. var plans = state.quotationPlans.filter(function(p) { return p.enabled; });
  12697. var activeId = state.activePlanId;
  12698. var html = '';
  12699. plans.forEach(function(plan) {
  12700. var isActive = (activeId === plan.id);
  12701. var color = plan.customerTier === 'price' ? '#C05621' : plan.customerTier === 'quality' ? '#6B46C1' : '#2B6CB0';
  12702. var bgColor = plan.customerTier === 'price' ? '#FFFAF0' : plan.customerTier === 'quality' ? '#FAF5FF' : '#EBF8FF';
  12703. var borderColor = isActive ? color : '#E2E8F0';
  12704. var shadow = isActive ? '0 4px 12px ' + color + '30' : 'none';
  12705. var checkIcon = isActive ? '<span style="position:absolute;top:8px;right:8px;width:20px;height:20px;border-radius:50%;background:' + color + ';color:#fff;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;">✓</span>' : '';
  12706. html += '<div onclick="applyPlan(\'' + plan.id + '\')" style="position:relative;cursor:pointer;border:2px solid ' + borderColor + ';border-radius:10px;padding:10px 12px;background:' + (isActive ? bgColor : '#fff') + ';transition:all .15s;box-shadow:' + shadow + ';" ' +
  12707. 'onmouseover="if(!this.dataset.active){this.style.borderColor=\'' + color + '\';this.style.transform=\'translateY(-1px)\'}" onmouseout="if(!this.dataset.active){this.style.borderColor=\'#E2E8F0\';this.style.transform=\'\'}" data-active="' + (isActive ? '1' : '0') + '">' +
  12708. checkIcon +
  12709. '<div style="font-weight:700;font-size:13px;color:' + color + ';margin-bottom:4px;">' + plan.name + '</div>' +
  12710. '<div style="font-size:10px;color:#718096;line-height:1.4;display:flex;gap:6px;flex-wrap:wrap;">' +
  12711. '<span>' + getTierLabel(plan) + '</span>' +
  12712. '<span>±' + Math.round(plan.floatRatio*100) + '%</span>' +
  12713. '<span>' + (plan.discountRate >= 1 ? '原价' : Math.round(plan.discountRate*100)/10 + '折') + '</span>' +
  12714. '</div>' +
  12715. '</div>';
  12716. });
  12717. if (plans.length === 0) {
  12718. html = '<div style="grid-column:1/-1;text-align:center;padding:12px;color:#A0AEC0;font-size:12px;">暂无启用的方案,请在高级报价参数中添加</div>';
  12719. }
  12720. container.innerHTML = html;
  12721. }
  12722. function applyPlan(planId) {
  12723. var plan = getPlanById(planId);
  12724. if (!plan) return;
  12725. state.activePlanId = planId;
  12726. qCustomerTier = plan.customerTier;
  12727. qTierSelectedDiscount[plan.customerTier] = plan.tierLevel;
  12728. var discOpt = null;
  12729. var tierCfg = Q_CUSTOMER_TIERS[plan.customerTier];
  12730. if (tierCfg && tierCfg.discounts) {
  12731. discOpt = tierCfg.discounts.find(function(d) { return d.id === plan.tierLevel; });
  12732. }
  12733. qDiscountRate = discOpt ? discOpt.rate : plan.discountRate;
  12734. qCurrentFloatRatio = plan.floatRatio;
  12735. if (tierCfg && tierCfg.autoEnableActivity) {
  12736. qActivityEnabled = true;
  12737. qActivityModuleList.forEach(function(m) { m.enabled = true; });
  12738. } else if (plan.customerTier === 'quality') {
  12739. qActivityEnabled = false;
  12740. qActivityModuleList.forEach(function(m) { m.enabled = false; });
  12741. }
  12742. qSyncActivityUI();
  12743. qRenderTierCards();
  12744. qUpdateTierDetail();
  12745. var slider = document.getElementById('q-step4-float-slider');
  12746. if (slider) {
  12747. var pct = Math.round(plan.floatRatio * 100);
  12748. qSetStep4Float(pct);
  12749. slider.value = pct;
  12750. document.getElementById('q-step4-float-badge').textContent = '±' + pct + '%';
  12751. }
  12752. var discountInput = document.getElementById('q-global-discount');
  12753. if (discountInput) discountInput.value = qDiscountRate.toFixed(2);
  12754. var badge = document.getElementById('q-active-plan-badge');
  12755. if (badge) badge.textContent = plan.name;
  12756. if (typeof qRenderStep3 === 'function') qRenderStep3();
  12757. if (typeof qRenderStep4 === 'function') qRenderStep4();
  12758. qUpdateDualPriceDisplay();
  12759. renderPlanCardsInStep4();
  12760. showToast('💼 已套用「' + plan.name + '」', 'success');
  12761. }
  12762. // ==================== 三大模块规则配置系统 ====================
  12763. const MODULE_RULES_STORAGE_KEY = 'ai_quote_module_rules_v1';
  12764. var DEFAULT_MODULE_RULES = {
  12765. upload: {
  12766. name: '上传模块',
  12767. icon: '📤',
  12768. color: '#3182CE',
  12769. bgColor: '#EBF8FF',
  12770. borderColor: '#90CDF4',
  12771. desc: '控制图纸上传阶段的识别行为:支持文件格式、图片分辨率、OCR引擎选择、自动旋转、图纸比例识别等参数配置。所有识别逻辑均通过此模块的规则驱动,不再写死在代码中。',
  12772. ruleTypes: [
  12773. { value: 'file_format', label: '文件格式限制' },
  12774. { value: 'image_quality', label: '图像质量参数' },
  12775. { value: 'ocr_config', label: 'OCR识别配置' },
  12776. { value: 'scale_detect', label: '比例识别规则' },
  12777. { value: 'auto_rotate', label: '自动旋转校准' },
  12778. { value: 'batch_upload', label: '批量上传设置' },
  12779. { value: 'custom', label: '自定义规则' }
  12780. ],
  12781. rules: [
  12782. { id: 'up_01', name: '支持文件格式', type: 'file_format', value: 'jpg,png,pdf,dwg,cad', unit: '扩展名', desc: '允许上传的图纸文件格式列表', enabled: true, deletable: false },
  12783. { id: 'up_02', name: '图片最小分辨率', type: 'image_quality', value: '1920×1080', unit: 'px', desc: '低于此分辨率的图片将提示用户重新上传', enabled: true, deletable: false },
  12784. { id: 'up_03', name: 'OCR识别引擎', type: 'ocr_config', value: '混合模式(云端+本地)', unit: '', desc: '选择文字识别的处理引擎', enabled: true, deletable: false },
  12785. { id: 'up_04', name: '自动识别图纸比例', type: 'scale_detect', value: '启用', unit: '', desc: '自动从图框/标注中识别绘图比例(1:50/1:100等)', enabled: true, deletable: true },
  12786. { id: 'up_05', name: '图片自动旋转校正', type: 'auto_rotate', value: '启用', unit: '', desc: '检测方向并自动旋转至正向', enabled: true, deletable: true },
  12787. { id: 'up_06', name: '单批次最大文件数', type: 'batch_upload', value: '20', unit: '个', desc: '单次上传图纸最大数量限制', enabled: true, deletable: true }
  12788. ]
  12789. },
  12790. parse: {
  12791. name: '解析模块',
  12792. icon: '🔍',
  12793. color: '#805AD5',
  12794. bgColor: '#FAF5FF',
  12795. borderColor: '#D6BCFA',
  12796. desc: '控制图纸信息提取阶段的解析规则:尺寸标注识别、空间名称提取、柜体类型分类、面积计算方式、标注容差等参数。所有提取逻辑均由规则配置驱动。',
  12797. ruleTypes: [
  12798. { value: 'dimension_parse', label: '尺寸标注解析' },
  12799. { value: 'space_detect', label: '空间识别规则' },
  12800. { value: 'cabinet_classify', label: '柜体分类规则' },
  12801. { value: 'area_calc', label: '面积计算方式' },
  12802. { value: 'tolerance', label: '识别容差设置' },
  12803. { value: 'field_mapping', label: '字段映射规则' },
  12804. { value: 'custom', label: '自定义规则' }
  12805. ],
  12806. rules: [
  12807. { id: 'pa_01', name: '尺寸标注识别正则', type: 'dimension_parse', value: '(?:(?:宽|W|w)[::]?\\s*)?(\\d+(?:\\.\\d+)?)\\s*[xX**×\\-]\\s*(?:(?:高|深|H|h|D|d)[::]?\\s*)?(\\d+(?:\\.\\d+)?)\\s*(?:mm|毫米|MM)?', unit: '正则', desc: '匹配"宽×高"格式尺寸标注,支持W/H/宽/高前缀、mm/毫米单位后缀、x/X/*/×/-等分隔符', enabled: true, deletable: false },
  12808. { id: 'pa_02', name: '最小识别尺寸', type: 'dimension_parse', value: '50', unit: 'mm', desc: '小于此值的标注将被忽略(防止误识别杂线)', enabled: true, deletable: false },
  12809. { id: 'pa_03', name: '空间名称关键词', type: 'space_detect', value: '主卧,次卧,客厅,餐厅,厨房,书房,阳台,衣帽间,卫生间', unit: '关键词', desc: '用于识别房间名称的关键词列表', enabled: true, deletable: false },
  12810. { id: 'pa_04', name: '柜体类型关键词', type: 'cabinet_classify', value: '衣柜,橱柜,鞋柜,书柜,酒柜,电视柜,榻榻米', unit: '关键词', desc: '柜体类型识别关键词', enabled: true, deletable: true },
  12811. { id: 'pa_05', name: '面积计算方式', type: 'area_calc', value: '长×宽×投影系数', unit: '', desc: '平面面积到展开面积的换算方式', enabled: true, deletable: false },
  12812. { id: 'pa_06', name: '标注识别容差', type: 'tolerance', value: '±5', unit: 'mm', desc: '线段闭合/端点匹配的容差范围', enabled: true, deletable: true },
  12813. { id: 'pa_07', name: '忽略文字区域', type: 'field_mapping', value: '图框/标题栏/图例', unit: '区域', desc: '这些区域内的文字不纳入柜体信息提取', enabled: false, deletable: true }
  12814. ]
  12815. },
  12816. pricing: {
  12817. name: '计价模块',
  12818. icon: '🧮',
  12819. color: '#38A169',
  12820. bgColor: '#F0FFF4',
  12821. borderColor: '#9AE6B4',
  12822. desc: '控制报价计算阶段的计价规则:展开面积/投影面积换算系数、五金计价、门板加价、损耗系数、税费运费等。计算逻辑完全由规则驱动,支持新增、编辑、删除和启停。',
  12823. ruleTypes: [
  12824. { value: 'area_convert', label: '面积换算' },
  12825. { value: 'material_price', label: '材料单价' },
  12826. { value: 'hardware', label: '五金计价' },
  12827. { value: 'door_panel', label: '门板加价' },
  12828. { value: 'loss_factor', label: '损耗系数' },
  12829. { value: 'tax_fee', label: '税费/运费' },
  12830. { value: 'discount', label: '折扣/优惠' },
  12831. { value: 'custom', label: '自定义规则' }
  12832. ],
  12833. rules: [
  12834. { id: 'pr_01', name: '投影→展开面积系数', type: 'area_convert', value: '3.2', unit: '倍', desc: '投影面积乘以该系数得到展开面积', enabled: false, deletable: false },
  12835. { id: 'pr_02', name: '柜体基础单价(颗粒板)', type: 'material_price', value: '680', unit: '元/m²(展开)', desc: '标准颗粒板柜体展开面积单价', enabled: false, deletable: false },
  12836. { id: 'pr_03', name: '柜体基础单价(多层板)', type: 'material_price', value: '880', unit: '元/m²(展开)', desc: '多层实木板柜体展开面积单价', enabled: false, deletable: false },
  12837. { id: 'pr_04', name: '基础五金(铰链/滑轨)', type: 'hardware', value: '80', unit: '元/m²', desc: '每平方米展开面积含基础五金费用', enabled: false, deletable: true },
  12838. { id: 'pr_05', name: '门板基础加价(烤漆)', type: 'door_panel', value: '280', unit: '元/m²', desc: '烤漆门板相对基础门板加价', enabled: false, deletable: true },
  12839. { id: 'pr_06', name: '标准损耗系数', type: 'loss_factor', value: '1.08', unit: '倍', desc: '材料切割损耗系数,应用到材料费用', enabled: false, deletable: false },
  12840. { id: 'pr_07', name: '标准折扣率', type: 'discount', value: '0.90', unit: '', desc: '标准客户默认折扣(9折)', enabled: false, deletable: true },
  12841. { id: 'pr_08', name: '运输安装费', type: 'tax_fee', value: '8', unit: '%', desc: '按总价百分比计算运输安装费', enabled: false, deletable: true }
  12842. ]
  12843. }
  12844. };
  12845. var currentEditingModule = null;
  12846. var currentEditingRuleIndex = -1;
  12847. function loadModuleRules() {
  12848. try {
  12849. var saved = localStorage.getItem(MODULE_RULES_STORAGE_KEY);
  12850. if (saved) {
  12851. var parsed = JSON.parse(saved);
  12852. for (var key in DEFAULT_MODULE_RULES) {
  12853. if (DEFAULT_MODULE_RULES.hasOwnProperty(key)) {
  12854. moduleRulesData[key] = parsed[key] ? parsed[key] : JSON.parse(JSON.stringify(DEFAULT_MODULE_RULES[key]));
  12855. }
  12856. }
  12857. } else {
  12858. for (var key2 in DEFAULT_MODULE_RULES) {
  12859. if (DEFAULT_MODULE_RULES.hasOwnProperty(key2)) {
  12860. moduleRulesData[key2] = JSON.parse(JSON.stringify(DEFAULT_MODULE_RULES[key2]));
  12861. }
  12862. }
  12863. }
  12864. } catch(e) {
  12865. console.warn('Failed to load module rules:', e);
  12866. for (var key3 in DEFAULT_MODULE_RULES) {
  12867. if (DEFAULT_MODULE_RULES.hasOwnProperty(key3)) {
  12868. moduleRulesData[key3] = JSON.parse(JSON.stringify(DEFAULT_MODULE_RULES[key3]));
  12869. }
  12870. }
  12871. }
  12872. // 一次性迁移:首次应用本版本时,将计价模块全部设为禁用;之后尊重用户手动启用并保存的状态。
  12873. var pricingDisabledMigrationKey = 'ai_pricing_rules_default_disabled_v1';
  12874. if (!localStorage.getItem(pricingDisabledMigrationKey) && moduleRulesData.pricing && Array.isArray(moduleRulesData.pricing.rules)) {
  12875. moduleRulesData.pricing.rules.forEach(function(rule){ rule.enabled = false; });
  12876. saveModuleRulesToStorage();
  12877. localStorage.setItem(pricingDisabledMigrationKey, '1');
  12878. }
  12879. }
  12880. function saveModuleRulesToStorage() {
  12881. try {
  12882. localStorage.setItem(MODULE_RULES_STORAGE_KEY, JSON.stringify(moduleRulesData));
  12883. } catch(e) {
  12884. console.warn('Failed to save module rules:', e);
  12885. }
  12886. }
  12887. function renderModuleCards() {
  12888. var modules = ['upload', 'parse', 'pricing'];
  12889. modules.forEach(function(mod) {
  12890. var cfg = moduleRulesData[mod];
  12891. if (!cfg) return;
  12892. var rulesEl = document.getElementById('mcc-' + mod + '-rules');
  12893. var countEl = document.getElementById('mcc-' + mod + '-count');
  12894. if (!rulesEl || !countEl) return;
  12895. var enabledRules = cfg.rules.filter(function(r){ return r.enabled !== false; });
  12896. countEl.textContent = enabledRules.length + ' / ' + cfg.rules.length + ' 条启用';
  12897. var preview = enabledRules.slice(0, 3).map(function(r) {
  12898. var val = r.value;
  12899. var displayVal;
  12900. if (r.unit === '正则') {
  12901. displayVal = '<span style="font-family:Consolas,monospace;font-size:11px;background:rgba(0,0,0,0.06);padding:1px 6px;border-radius:3px;color:#6B7280;">[点击查看正则表达式]</span>';
  12902. } else {
  12903. if (val && val.length > 20) val = val.substring(0, 20) + '…';
  12904. displayVal = '<strong>' + escapeHtml(val) + '</strong>';
  12905. }
  12906. return '• ' + r.name + ':' + displayVal + (r.unit && r.unit !== '正则' ? ' ' + r.unit : '');
  12907. }).join('<br>');
  12908. if (enabledRules.length > 3) {
  12909. preview += '<br>… 还有 ' + (enabledRules.length - 3) + ' 条';
  12910. }
  12911. if (!preview) preview = '<span style="color:#A0AEC0;">暂无启用的规则</span>';
  12912. rulesEl.innerHTML = preview;
  12913. });
  12914. }
  12915. function openModuleConfig(modKey) {
  12916. currentEditingModule = modKey;
  12917. var cfg = moduleRulesData[modKey];
  12918. if (!cfg) return;
  12919. document.getElementById('mc-modal-title').textContent = cfg.icon + ' ' + cfg.name + ' — 规则配置';
  12920. document.getElementById('mc-modal-title').style.color = cfg.color;
  12921. document.getElementById('mc-modal-desc').innerHTML = '<span style="display:inline-block;padding:2px 10px;border-radius:4px;background:' + cfg.bgColor + ';color:' + cfg.color + ';font-weight:600;margin-bottom:6px;">独立配置窗口</span><br>' + cfg.desc;
  12922. renderModuleConfigToolbar(cfg);
  12923. renderModuleConfigRules(cfg);
  12924. document.getElementById('module-config-modal').style.display = 'flex';
  12925. }
  12926. function renderModuleConfigToolbar(cfg) {
  12927. var toolbar = document.getElementById('mc-toolbar');
  12928. toolbar.innerHTML =
  12929. '<button class="btn btn-primary btn-sm" onclick="openMcRuleAdd()">+ 添加规则</button>' +
  12930. '<button class="btn btn-outline btn-sm" onclick="toggleAllModuleRules(true)">✅ 全部启用</button>' +
  12931. '<button class="btn btn-outline btn-sm" onclick="toggleAllModuleRules(false)">⏸ 全部禁用</button>' +
  12932. '<div style="flex:1;"></div>' +
  12933. '<span style="font-size:12px;color:#718096;align-self:center;">共 ' + cfg.rules.length + ' 条规则</span>';
  12934. }
  12935. function renderModuleConfigRules(cfg) {
  12936. var listEl = document.getElementById('mc-rules-list');
  12937. if (!cfg.rules || cfg.rules.length === 0) {
  12938. listEl.innerHTML = '<div style="text-align:center;padding:40px;color:#A0AEC0;">暂无规则,点击「+ 添加规则」开始配置</div>';
  12939. return;
  12940. }
  12941. var html = '';
  12942. cfg.rules.forEach(function(rule, idx) {
  12943. var typeLabel = rule.type;
  12944. (cfg.ruleTypes || []).forEach(function(rt) {
  12945. if (rt.value === rule.type) typeLabel = rt.label;
  12946. });
  12947. var isEnabled = rule.enabled !== false;
  12948. var canDelete = rule.deletable !== false;
  12949. var valStr = String(rule.value || '');
  12950. var isRegex = rule.unit === '正则';
  12951. var isLongVal = valStr.length > 40 || isRegex;
  12952. var codeFont = 'Consolas, "Courier New", "SF Mono", Monaco, "Microsoft YaHei Mono", "Source Han Mono SC", "Noto Sans Mono CJK SC", monospace';
  12953. var valueHtml;
  12954. if (isLongVal) {
  12955. var labelText = isRegex ? '🔤 正则表达式' : '规则值';
  12956. var safeVal = valStr.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
  12957. var copyBtn = isRegex ? '<button onclick="navigator.clipboard.writeText(\'' + safeVal + '\');this.textContent=\'✓ 已复制\';var b=this;setTimeout(function(){b.textContent=\'📋 复制\'},1500)" style="margin-left:auto;font-size:11px;padding:3px 10px;border-radius:4px;border:1px solid #475569;background:transparent;color:#94A3B8;cursor:pointer;">📋 复制</button>' : '';
  12958. var exampleHtml = '';
  12959. if (isRegex && rule.name.indexOf('尺寸') > -1) {
  12960. exampleHtml = '<div style="margin-top:8px;padding:10px 14px;background:#0F172A;border-radius:6px;border-left:3px solid #38BDF8;">' +
  12961. '<div style="font-size:11px;color:#38BDF8;margin-bottom:6px;font-weight:600;">📋 匹配格式示例:</div>' +
  12962. '<div style="font-family:' + codeFont + ';font-size:13px;color:#CBD5E1;line-height:2;">' +
  12963. '<div>✅ <span style="color:#4ADE80;">宽1200×高800</span></div>' +
  12964. '<div>✅ <span style="color:#4ADE80;">W800*H600mm</span></div>' +
  12965. '<div>✅ <span style="color:#4ADE80;">1200x800 毫米</span></div>' +
  12966. '<div>✅ <span style="color:#4ADE80;">800*600</span></div>' +
  12967. '<div style="margin-top:4px;font-size:11px;color:#64748B;line-height:1.5;">支持前缀:宽/W、高/深/H/D &nbsp;|&nbsp; 分隔符:x/X/*/*/×/- &nbsp;|&nbsp; 单位:mm/毫米/MM</div>' +
  12968. '</div></div>';
  12969. }
  12970. valueHtml = '<div style="margin-top:8px;">' +
  12971. '<div style="display:flex;align-items:center;margin-bottom:6px;">' +
  12972. '<span style="font-size:12px;color:#94A3B8;font-weight:500;">' + labelText + (rule.unit && !isRegex ? '(' + escapeHtml(rule.unit) + ')' : '') + ':</span>' +
  12973. copyBtn +
  12974. '</div>' +
  12975. '<div style="background:#0F172A;color:#E2E8F0;padding:14px 16px;border-radius:8px;font-family:' + codeFont + ';font-size:14px;line-height:1.8;word-break:break-all;white-space:pre-wrap;overflow-x:auto;letter-spacing:0;border:1px solid #1E293B;">' + escapeHtml(valStr) + '</div>' +
  12976. exampleHtml +
  12977. '</div>';
  12978. } else {
  12979. valueHtml = '<div style="font-size:13px;color:#4A5568;margin-bottom:4px;">' +
  12980. '规则值:<code style="background:#EDF2F7;padding:2px 8px;border-radius:4px;font-family:' + codeFont + ';font-size:13px;color:' + cfg.color + ';">' + escapeHtml(valStr) + '</code>' +
  12981. (rule.unit ? ' <span style="color:#718096;font-size:12px;">(' + escapeHtml(rule.unit) + ')</span>' : '') +
  12982. '</div>';
  12983. }
  12984. html += '<div style="background:' + (isEnabled ? '#fff' : '#F7FAFC') + ';border:1px solid ' + (isEnabled ? '#E2E8F0' : '#EDF2F7') + ';border-left:3px solid ' + (isEnabled ? cfg.color : '#CBD5E0') + ';border-radius:8px;padding:12px 14px;margin-bottom:8px;display:flex;align-items:flex-start;gap:12px;' + (isEnabled ? '' : 'opacity:0.65;') + '">' +
  12985. '<div style="display:flex;align-items:center;padding-top:2px;">' +
  12986. '<label style="cursor:pointer;display:flex;align-items:center;">' +
  12987. '<input type="checkbox" ' + (isEnabled ? 'checked' : '') + ' onchange="toggleModuleRule(' + idx + ',this.checked)" style="width:16px;height:16px;cursor:pointer;">' +
  12988. '</label>' +
  12989. '</div>' +
  12990. '<div style="flex:1;min-width:0;">' +
  12991. '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:4px;">' +
  12992. '<span style="font-weight:600;font-size:14px;color:' + (isEnabled ? '#1A202C' : '#718096') + ';">' + rule.name + '</span>' +
  12993. '<span style="font-size:11px;padding:1px 7px;border-radius:3px;background:' + cfg.bgColor + ';color:' + cfg.color + ';font-weight:600;">' + typeLabel + '</span>' +
  12994. (isEnabled ? '' : '<span style="font-size:11px;padding:1px 6px;border-radius:3px;background:#EDF2F7;color:#718096;">已禁用</span>') +
  12995. (canDelete ? '' : '<span style="font-size:10px;padding:1px 6px;border-radius:3px;background:#FED7D7;color:#C53030;">系统规则</span>') +
  12996. '</div>' +
  12997. valueHtml +
  12998. (rule.desc ? '<div style="font-size:12px;color:#718096;margin-top:6px;">💬 ' + escapeHtml(rule.desc) + '</div>' : '') +
  12999. '</div>' +
  13000. '<div style="display:flex;gap:4px;flex-shrink:0;">' +
  13001. '<button onclick="openMcRuleEdit(' + idx + ')" style="padding:4px 10px;border:1px solid #E2E8F0;border-radius:4px;background:#fff;cursor:pointer;font-size:12px;color:#4A5568;" title="编辑">✏️</button>' +
  13002. (canDelete ? '<button onclick="deleteModuleRule(' + idx + ')" style="padding:4px 10px;border:1px solid #FED7D7;border-radius:4px;background:#FFF5F5;cursor:pointer;font-size:12px;color:#C53030;" title="删除">🗑️</button>' : '') +
  13003. '</div>' +
  13004. '</div>';
  13005. });
  13006. listEl.innerHTML = html;
  13007. }
  13008. function escapeHtml(str) {
  13009. var d = document.createElement('div');
  13010. d.textContent = str;
  13011. return d.innerHTML;
  13012. }
  13013. function toggleModuleRule(idx, enabled) {
  13014. if (!currentEditingModule) return;
  13015. moduleRulesData[currentEditingModule].rules[idx].enabled = enabled;
  13016. renderModuleConfigRules(moduleRulesData[currentEditingModule]);
  13017. renderModuleConfigToolbar(moduleRulesData[currentEditingModule]);
  13018. saveModuleRulesToStorage();
  13019. }
  13020. function toggleAllModuleRules(enabled) {
  13021. if (!currentEditingModule) return;
  13022. moduleRulesData[currentEditingModule].rules.forEach(function(r){ r.enabled = enabled; });
  13023. renderModuleConfigRules(moduleRulesData[currentEditingModule]);
  13024. renderModuleConfigToolbar(moduleRulesData[currentEditingModule]);
  13025. saveModuleRulesToStorage();
  13026. }
  13027. function deleteModuleRule(idx) {
  13028. if (!currentEditingModule) return;
  13029. var rule = moduleRulesData[currentEditingModule].rules[idx];
  13030. if (rule.deletable === false) return;
  13031. qConfirmDialog('确定删除规则「' + rule.name + '」吗?', function(){
  13032. moduleRulesData[currentEditingModule].rules.splice(idx, 1);
  13033. renderModuleConfigRules(moduleRulesData[currentEditingModule]);
  13034. renderModuleConfigToolbar(moduleRulesData[currentEditingModule]);
  13035. saveModuleRulesToStorage();
  13036. });
  13037. }
  13038. function openMcRuleAdd() {
  13039. if (!currentEditingModule) return;
  13040. currentEditingRuleIndex = -1;
  13041. var cfg = moduleRulesData[currentEditingModule];
  13042. document.getElementById('mc-edit-title').textContent = '+ 添加规则';
  13043. document.getElementById('mc-edit-name').value = '';
  13044. document.getElementById('mc-edit-value').value = '';
  13045. document.getElementById('mc-edit-unit').value = '';
  13046. document.getElementById('mc-edit-desc').value = '';
  13047. document.getElementById('mc-edit-enabled').checked = true;
  13048. var typeSel = document.getElementById('mc-edit-type');
  13049. typeSel.innerHTML = '';
  13050. (cfg.ruleTypes || []).forEach(function(rt) {
  13051. var opt = document.createElement('option');
  13052. opt.value = rt.value;
  13053. opt.textContent = rt.label;
  13054. typeSel.appendChild(opt);
  13055. });
  13056. document.getElementById('mc-edit-value-label').textContent = '规则值 *';
  13057. document.getElementById('mc-rule-edit-modal').style.display = 'flex';
  13058. setTimeout(function(){ document.getElementById('mc-edit-name').focus(); }, 100);
  13059. }
  13060. function openMcRuleEdit(idx) {
  13061. if (!currentEditingModule) return;
  13062. currentEditingRuleIndex = idx;
  13063. var cfg = moduleRulesData[currentEditingModule];
  13064. var rule = cfg.rules[idx];
  13065. document.getElementById('mc-edit-title').textContent = '✏️ 编辑规则';
  13066. document.getElementById('mc-edit-name').value = rule.name || '';
  13067. document.getElementById('mc-edit-value').value = rule.value || '';
  13068. document.getElementById('mc-edit-unit').value = rule.unit || '';
  13069. document.getElementById('mc-edit-desc').value = rule.desc || '';
  13070. document.getElementById('mc-edit-enabled').checked = rule.enabled !== false;
  13071. var typeSel = document.getElementById('mc-edit-type');
  13072. typeSel.innerHTML = '';
  13073. (cfg.ruleTypes || []).forEach(function(rt) {
  13074. var opt = document.createElement('option');
  13075. opt.value = rt.value;
  13076. opt.textContent = rt.label;
  13077. if (rt.value === rule.type) opt.selected = true;
  13078. typeSel.appendChild(opt);
  13079. });
  13080. document.getElementById('mc-rule-edit-modal').style.display = 'flex';
  13081. }
  13082. function closeMcRuleEdit() {
  13083. document.getElementById('mc-rule-edit-modal').style.display = 'none';
  13084. }
  13085. function submitMcRuleEdit() {
  13086. if (!currentEditingModule) return;
  13087. var name = document.getElementById('mc-edit-name').value.trim();
  13088. var type = document.getElementById('mc-edit-type').value;
  13089. var value = document.getElementById('mc-edit-value').value.trim();
  13090. var unit = document.getElementById('mc-edit-unit').value.trim();
  13091. var desc = document.getElementById('mc-edit-desc').value.trim();
  13092. var enabled = document.getElementById('mc-edit-enabled').checked;
  13093. if (!name) { showToast('请输入规则名称', 'warning'); return; }
  13094. if (!value) { showToast('请输入规则值', 'warning'); return; }
  13095. var cfg = moduleRulesData[currentEditingModule];
  13096. if (currentEditingRuleIndex >= 0) {
  13097. var rule = cfg.rules[currentEditingRuleIndex];
  13098. rule.name = name; rule.type = type; rule.value = value;
  13099. rule.unit = unit; rule.desc = desc; rule.enabled = enabled;
  13100. } else {
  13101. var newId = currentEditingModule.substring(0,2) + '_' + Date.now().toString(36);
  13102. cfg.rules.push({
  13103. id: newId, name: name, type: type, value: value,
  13104. unit: unit, desc: desc, enabled: enabled, deletable: true
  13105. });
  13106. }
  13107. closeMcRuleEdit();
  13108. renderModuleConfigRules(cfg);
  13109. renderModuleConfigToolbar(cfg);
  13110. saveModuleRulesToStorage();
  13111. }
  13112. function closeModuleConfig() {
  13113. document.getElementById('module-config-modal').style.display = 'none';
  13114. closeMcRuleEdit();
  13115. currentEditingModule = null;
  13116. renderModuleCards();
  13117. }
  13118. function saveModuleConfig() {
  13119. saveModuleRulesToStorage();
  13120. persistState();
  13121. renderModuleCards();
  13122. var cfg = moduleRulesData[currentEditingModule];
  13123. showToast('✅ ' + (cfg ? cfg.name : '模块') + ' 规则配置已保存', 'success');
  13124. closeModuleConfig();
  13125. }
  13126. function resetModuleRules() {
  13127. if (!currentEditingModule) return;
  13128. qConfirmDialog('确定恢复该模块为默认规则?所有自定义修改将丢失。', function(){
  13129. moduleRulesData[currentEditingModule] = JSON.parse(JSON.stringify(DEFAULT_MODULE_RULES[currentEditingModule]));
  13130. saveModuleRulesToStorage();
  13131. renderModuleConfigRules(moduleRulesData[currentEditingModule]);
  13132. renderModuleConfigToolbar(moduleRulesData[currentEditingModule]);
  13133. showToast('🔄 已恢复为默认规则', 'success');
  13134. });
  13135. }
  13136. // ========== 高级报价参数右侧抽屉面板 ==========
  13137. var drawerCurrentPlanId = null;
  13138. function updateAdvancedButtonVisibility() {
  13139. var btn = document.getElementById('btn-advanced-params');
  13140. if (btn) btn.style.display = 'inline-flex';
  13141. }
  13142. function openAdvancedDrawer() {
  13143. var drawer = document.getElementById('advanced-drawer');
  13144. var overlay = document.getElementById('drawer-overlay');
  13145. if (!drawer || !overlay) return;
  13146. syncDrawerFromCurrentState();
  13147. renderDrawerPlanList();
  13148. renderDrawerModeSelector();
  13149. drawer.classList.add('open');
  13150. overlay.classList.add('visible');
  13151. }
  13152. function closeAdvancedDrawer() {
  13153. var drawer = document.getElementById('advanced-drawer');
  13154. var overlay = document.getElementById('drawer-overlay');
  13155. if (!drawer || !overlay) return;
  13156. drawer.classList.remove('open');
  13157. overlay.classList.remove('visible');
  13158. }
  13159. function toggleAdvancedDrawer() {
  13160. var drawer = document.getElementById('advanced-drawer');
  13161. if (drawer && drawer.classList.contains('open')) {
  13162. closeAdvancedDrawer();
  13163. } else {
  13164. openAdvancedDrawer();
  13165. }
  13166. }
  13167. function syncDrawerFromCurrentState() {
  13168. var defaultPlan = getDefaultPlan();
  13169. var currentPlan = state.activePlanId ? getPlanById(state.activePlanId) : null;
  13170. drawerCurrentPlanId = (currentPlan && currentPlan.id) || (defaultPlan && defaultPlan.id) || null;
  13171. if (drawerCurrentPlanId && !state.activePlanId) state.activePlanId = drawerCurrentPlanId;
  13172. var modeSelect = document.getElementById('drawer-mode-select');
  13173. if (modeSelect && state.currentPricingMode) {
  13174. modeSelect.value = state.currentPricingMode;
  13175. }
  13176. updateDrawerModeParams();
  13177. markDrawerPlanSelected();
  13178. }
  13179. function renderDrawerPlanList() {
  13180. var container = document.getElementById('drawer-plan-list');
  13181. var select = document.getElementById('drawer-plan-select');
  13182. if (!container) return;
  13183. var plans = state.quotationPlans || [];
  13184. if (select) {
  13185. select.innerHTML = '<option value="">-- 选择报价方案 --</option>' + plans.filter(function(p) { return p.enabled !== false; }).map(function(plan) {
  13186. return '<option value="' + plan.id + '"' + (plan.id === drawerCurrentPlanId ? ' selected' : '') + '>' + plan.name + (plan.isDefault ? ' (默认)' : '') + '</option>';
  13187. }).join('');
  13188. }
  13189. container.innerHTML = plans.map(function(plan) {
  13190. var isSel = plan.id === drawerCurrentPlanId;
  13191. var isDefault = plan.isDefault === true;
  13192. var isDisabled = plan.enabled === false;
  13193. var floatPct = Math.round((plan.floatRatio || 0) * 100);
  13194. var discountPct = (plan.discountRate || 1) >= 0.999 ? '原价' : (Math.round((plan.discountRate||1)*100)/10) + '折';
  13195. return '<div class="drawer-plan-mini-card ' + (isSel ? 'selected ' : '') + (isDefault ? 'default-plan ' : '') + (isDisabled ? 'disabled ' : '') + '" data-plan-id="' + plan.id + '" onclick="selectDrawerPlan(\'' + plan.id + '\')">' +
  13196. '<div class="drawer-plan-mini-top">' +
  13197. '<span class="drawer-plan-mini-name">' + (plan.name || '未命名') + '</span>' +
  13198. '<div class="drawer-plan-mini-badges">' +
  13199. (isDefault ? '<span class="drawer-plan-mini-badge default">默认</span>' : '') +
  13200. (isDisabled ? '<span class="drawer-plan-mini-badge disabled-badge">停用</span>' : '') +
  13201. '</div>' +
  13202. '</div>' +
  13203. '<div class="drawer-plan-mini-params">' +
  13204. '<span>浮动 ±' + floatPct + '%</span>' +
  13205. '<span>折扣 ' + discountPct + '</span>' +
  13206. '</div>' +
  13207. '<div class="drawer-plan-mini-actions" onclick="event.stopPropagation();">' +
  13208. '<button onclick="openPlanModal(\'' + plan.id + '\'); closeAdvancedDrawer();">编辑</button>' +
  13209. '<button onclick="copyDrawerPlan(\'' + plan.id + '\')">复制</button>' +
  13210. (isDefault ? '' : '<button onclick="setDefaultDrawerPlan(\'' + plan.id + '\')">设默认</button>') +
  13211. (isDisabled ? '<button onclick="toggleDrawerPlanEnabled(\'' + plan.id + '\',true)">启用</button>' : '<button onclick="toggleDrawerPlanEnabled(\'' + plan.id + '\',false)">停用</button>') +
  13212. '<button class="danger" onclick="deleteDrawerPlan(\'' + plan.id + '\')">删除</button>' +
  13213. '</div>' +
  13214. '</div>';
  13215. }).join('');
  13216. updateDrawerPlanInfo();
  13217. }
  13218. function updateDrawerPlanInfo() {
  13219. var plan = drawerCurrentPlanId ? getPlanById(drawerCurrentPlanId) : null;
  13220. var infoEl = document.getElementById('drawer-current-plan-info');
  13221. var nameEl = document.getElementById('drawer-plan-name');
  13222. var floatEl = document.getElementById('drawer-plan-float');
  13223. var discountEl = document.getElementById('drawer-plan-discount');
  13224. var select = document.getElementById('drawer-plan-select');
  13225. if (!infoEl || !nameEl || !floatEl || !discountEl) return;
  13226. if (plan) {
  13227. nameEl.textContent = plan.name;
  13228. floatEl.textContent = Math.round((plan.floatRatio||0)*100);
  13229. discountEl.textContent = (plan.discountRate||1) >= 0.999 ? '原价' : (Math.round((plan.discountRate||1)*100)/10) + '折';
  13230. if (select) select.value = drawerCurrentPlanId;
  13231. }
  13232. }
  13233. function onDrawerPlanDropdownChange(planId) {
  13234. if (!planId) return;
  13235. selectDrawerPlan(planId);
  13236. }
  13237. function markDrawerPlanSelected() {
  13238. var cards = document.querySelectorAll('.drawer-plan-mini-card');
  13239. cards.forEach(function(card) {
  13240. if (card.getAttribute('data-plan-id') === drawerCurrentPlanId) {
  13241. card.classList.add('selected');
  13242. } else {
  13243. card.classList.remove('selected');
  13244. }
  13245. });
  13246. }
  13247. function selectDrawerPlan(planId) {
  13248. var plan = getPlanById(planId);
  13249. if (!plan) return;
  13250. if (plan.enabled === false) {
  13251. showToast('⚠️ 该方案已停用,无法选择', 'warning');
  13252. return;
  13253. }
  13254. drawerCurrentPlanId = planId;
  13255. state.activePlanId = planId;
  13256. var floatPct = Math.round((plan.floatRatio || 0) * 100);
  13257. if (typeof window.setGlobalFloat === 'function') {
  13258. window.setGlobalFloat(floatPct);
  13259. }
  13260. qDiscountRate = plan.discountRate || 1.0;
  13261. if (plan.customerTier) state.currentCustomerTier = plan.customerTier;
  13262. markDrawerPlanSelected();
  13263. updateDrawerPlanInfo();
  13264. updateGlobalEstimate();
  13265. if (typeof renderModuleCards === 'function') renderModuleCards();
  13266. if (typeof renderSummary === 'function') renderSummary();
  13267. persistState();
  13268. showToast('✅ 已切换方案:' + plan.name, 'success');
  13269. }
  13270. function copyDrawerPlan(planId) {
  13271. var plan = getPlanById(planId);
  13272. if (!plan) return;
  13273. var newPlan = JSON.parse(JSON.stringify(plan));
  13274. newPlan.id = genPlanId();
  13275. newPlan.name = plan.name + ' (副本)';
  13276. newPlan.isDefault = false;
  13277. state.quotationPlans.push(newPlan);
  13278. renderDrawerPlanList();
  13279. if (typeof renderPlanList === 'function') renderPlanList();
  13280. persistState();
  13281. showToast('📋 已复制方案:' + newPlan.name, 'success');
  13282. }
  13283. function setDefaultDrawerPlan(planId) {
  13284. var plan = getPlanById(planId);
  13285. if (!plan) return;
  13286. state.quotationPlans.forEach(function(p) { p.isDefault = false; });
  13287. plan.isDefault = true;
  13288. plan.enabled = true;
  13289. renderDrawerPlanList();
  13290. if (typeof renderPlanList === 'function') renderPlanList();
  13291. persistState();
  13292. showToast('⭐ 已设为默认方案:' + plan.name, 'success');
  13293. }
  13294. function toggleDrawerPlanEnabled(planId, enabled) {
  13295. var plan = getPlanById(planId);
  13296. if (!plan) return;
  13297. plan.enabled = enabled;
  13298. if (enabled && !state.quotationPlans.some(function(p) { return p.isDefault && p.enabled; })) {
  13299. plan.isDefault = true;
  13300. }
  13301. if (!enabled && plan.isDefault) {
  13302. var firstEnabled = state.quotationPlans.find(function(p) { return p.enabled && p.id !== planId; });
  13303. if (firstEnabled) firstEnabled.isDefault = true;
  13304. }
  13305. renderDrawerPlanList();
  13306. if (typeof renderPlanList === 'function') renderPlanList();
  13307. persistState();
  13308. showToast(enabled ? '✅ 已启用方案' : '⏸️ 已停用方案', 'info');
  13309. }
  13310. function deleteDrawerPlan(planId) {
  13311. var plan = getPlanById(planId);
  13312. if (!plan) return;
  13313. if (state.quotationPlans.length <= 1) {
  13314. showToast('⚠️ 至少保留一个报价方案', 'warning');
  13315. return;
  13316. }
  13317. qConfirmDialog('确定删除方案「' + plan.name + '」吗?', function(){
  13318. state.quotationPlans = state.quotationPlans.filter(function(p) { return p.id !== planId; });
  13319. if (plan.isDefault) {
  13320. var firstEnabled = state.quotationPlans.find(function(p) { return p.enabled; });
  13321. if (firstEnabled) firstEnabled.isDefault = true;
  13322. }
  13323. if (drawerCurrentPlanId === planId) {
  13324. var defaultP = getDefaultPlan();
  13325. if (defaultP) {
  13326. drawerCurrentPlanId = defaultP.id;
  13327. state.activePlanId = defaultP.id;
  13328. var floatPct = Math.round((defaultP.floatRatio||0)*100);
  13329. if (typeof window.setGlobalFloat === 'function') window.setGlobalFloat(floatPct);
  13330. qDiscountRate = defaultP.discountRate || 1.0;
  13331. }
  13332. }
  13333. renderDrawerPlanList();
  13334. if (typeof renderPlanList === 'function') renderPlanList();
  13335. updateGlobalEstimate();
  13336. if (typeof renderModuleCards === 'function') renderModuleCards();
  13337. persistState();
  13338. showToast('🗑️ 方案已删除', 'success');
  13339. });
  13340. }
  13341. function renderDrawerModeSelector() {
  13342. var sel = document.getElementById('drawer-mode-select');
  13343. if (!sel) return;
  13344. var modes = [
  13345. { key: 'package', name: '套餐价', desc: '按空间套餐组合报价(柜体明细累加)' },
  13346. { key: 'fixedPrice', name: '固定单价', desc: '总投影面积 × 固定单价(一口价快速报价)' }
  13347. ];
  13348. sel.innerHTML = modes.map(function(m) {
  13349. return '<option value="' + m.key + '">' + m.name + ' - ' + m.desc + '</option>';
  13350. }).join('');
  13351. if (state.currentPricingMode) sel.value = state.currentPricingMode;
  13352. updateDrawerModeParams();
  13353. }
  13354. function onDrawerModeChange(mode) {
  13355. state.currentPricingMode = mode;
  13356. state.defaultPricingMode = mode;
  13357. if (typeof switchPricingMode === 'function') {
  13358. switchPricingMode(mode);
  13359. }
  13360. updateDrawerModeParams();
  13361. updateGlobalEstimate();
  13362. if (typeof renderModuleCards === 'function') renderModuleCards();
  13363. if (typeof renderSummary === 'function') renderSummary();
  13364. persistState();
  13365. var modeNames = { package: '套餐价', fixedPrice: '固定单价' };
  13366. showToast('🔄 已切换报价模式:' + (modeNames[mode] || mode), 'success');
  13367. }
  13368. function updateDrawerModeParams() {
  13369. var detail = document.getElementById('drawer-mode-param-detail');
  13370. if (!detail) return;
  13371. var mode = state.currentPricingMode || 'package';
  13372. var unitPrice = state.currentFixedUnitPrice || state.defaultFixedUnitPrice || 0;
  13373. var modeNames = { package: '套餐价模式', fixedPrice: '固定单价模式' };
  13374. var modeDescs = {
  13375. package: '各空间按预设套餐报价,每个柜体独立计价(投影面积×对应单价),五金配件、灯带等加价项可单独选择,适合标准定制报价,展示详细明细。',
  13376. fixedPrice: '按总投影面积(各空间累加)× 固定单价计算,一口价快速报价,无需逐项配置,适合初步估算和快速出单。'
  13377. };
  13378. var extraHtml = '';
  13379. if (mode === 'fixedPrice') {
  13380. extraHtml = '<div style="margin-top:10px;display:flex;align-items:center;gap:8px;">' +
  13381. '<span style="font-size:12px;color:#718096;">固定单价:</span>' +
  13382. '<input type="number" id="drawer-unit-price" value="' + unitPrice + '" min="1000" max="20000" step="100" style="width:120px;padding:6px 10px;border:1px solid #E2E8F0;border-radius:6px;font-size:13px;font-weight:600;" onchange="onDrawerUnitPriceChange(this.value)">' +
  13383. '<span style="font-size:12px;color:#718096;">元/㎡</span>' +
  13384. '</div>' +
  13385. '<div style="margin-top:6px;font-size:11px;color:#A0AEC0;">提示:修改后立即生效,影响各空间预估总价累加计算</div>';
  13386. }
  13387. detail.innerHTML = '<div style="font-size:13px;font-weight:600;color:#2D3748;margin-bottom:6px;">' + (modeNames[mode]||mode) + '</div>' +
  13388. '<div style="font-size:12px;color:#718096;line-height:1.6;">' + (modeDescs[mode]||'') + '</div>' + extraHtml;
  13389. }
  13390. function onDrawerUnitPriceChange(val) {
  13391. var price = parseFloat(val) || 0;
  13392. state.currentFixedUnitPrice = price;
  13393. state.defaultFixedUnitPrice = price;
  13394. updateGlobalEstimate();
  13395. if (typeof renderModuleCards === 'function') renderModuleCards();
  13396. persistState();
  13397. }
  13398. window.toggleAdvancedDrawer = toggleAdvancedDrawer;
  13399. window.openAdvancedDrawer = openAdvancedDrawer;
  13400. window.closeAdvancedDrawer = closeAdvancedDrawer;
  13401. window.selectDrawerPlan = selectDrawerPlan;
  13402. window.copyDrawerPlan = copyDrawerPlan;
  13403. window.setDefaultDrawerPlan = setDefaultDrawerPlan;
  13404. window.toggleDrawerPlanEnabled = toggleDrawerPlanEnabled;
  13405. window.deleteDrawerPlan = deleteDrawerPlan;
  13406. window.onDrawerModeChange = onDrawerModeChange;
  13407. window.onDrawerUnitPriceChange = onDrawerUnitPriceChange;
  13408. window.onDrawerPlanDropdownChange = onDrawerPlanDropdownChange;
  13409. window.updateAdvancedButtonVisibility = updateAdvancedButtonVisibility;
  13410. // 监听方案编辑弹窗保存后刷新抽屉
  13411. var _origSavePlan = window.savePlan;
  13412. window.savePlan = function() {
  13413. if (_origSavePlan) _origSavePlan();
  13414. setTimeout(function() {
  13415. if (document.getElementById('advanced-drawer') && document.getElementById('advanced-drawer').classList.contains('open')) {
  13416. renderDrawerPlanList();
  13417. }
  13418. }, 200);
  13419. };
  13420. // 页面完全加载后恢复localStorage数据
  13421. window.addEventListener('load', function() {
  13422. setTimeout(function() {
  13423. loadModuleRules();
  13424. loadState();
  13425. renderModuleCards();
  13426. if (typeof renderQuoteList === 'function') renderQuoteList();
  13427. updateAdvancedButtonVisibility();
  13428. updateGlobalEstimate();
  13429. qShowModeSelect(); // 首页入口:报价模式选择前置弹窗
  13430. if (typeof qEnsureFloorHeightEditable === 'function') qEnsureFloorHeightEditable();
  13431. }, 100);
  13432. });