BuffSelectionView.vue 156 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587
  1. <template>
  2. <!-- eslint-disable vue/no-v-model-argument -->
  3. <div class="buff-selection-container">
  4. <div class="buff-card">
  5. <!-- 条件设置区域:可折叠 -->
  6. <div class="section conditions-section" :class="{ 'is-collapsed': !conditionsExpanded }">
  7. <!-- 折叠时的摘要行 -->
  8. <div class="conditions-summary-bar" @click="conditionsExpanded = !conditionsExpanded">
  9. <div class="conditions-summary-content">
  10. <span class="conditions-summary-icon">⚙️</span>
  11. <span class="conditions-summary-text" v-html="conditionsSummary"></span>
  12. </div>
  13. <div class="conditions-summary-toggle">
  14. <span class="toggle-hint">{{ conditionsExpanded ? '收起' : '展开' }}</span>
  15. <span class="toggle-icon">{{ conditionsExpanded ? '▲' : '▼' }}</span>
  16. </div>
  17. </div>
  18. <!-- 展开后的详细内容 -->
  19. <div v-show="conditionsExpanded" class="conditions-detail">
  20. <!-- 第一部分:BUFF饰品检索条件 -->
  21. <div class="search-section">
  22. <div class="section-header">
  23. <span class="section-icon">🔍</span>
  24. <span class="section-title">BUFF饰品检索条件</span>
  25. </div>
  26. <div class="section-body search-body-buttons">
  27. <!-- 宽度自适应:宽则一行,窄则两行或三行 -->
  28. <div class="filter-buttons-adaptive">
  29. <a-button class="btn-filter" :class="{ 'has-selection': form.rifle.length > 0 }" @click="openFilterModal('rifle')">步枪<span v-if="form.rifle.length > 0" class="filter-check">✓</span></a-button>
  30. <a-button class="btn-filter" :class="{ 'has-selection': form.pistol.length > 0 }" @click="openFilterModal('pistol')">手枪<span v-if="form.pistol.length > 0" class="filter-check">✓</span></a-button>
  31. <a-button class="btn-filter" :class="{ 'has-selection': form.smg.length > 0 }" @click="openFilterModal('smg')">微型冲锋枪<span v-if="form.smg.length > 0" class="filter-check">✓</span></a-button>
  32. <a-button class="btn-filter" :class="{ 'has-selection': form.shotgun.length > 0 }" @click="openFilterModal('shotgun')">霰弹枪<span v-if="form.shotgun.length > 0" class="filter-check">✓</span></a-button>
  33. <a-button class="btn-filter" :class="{ 'has-selection': form.machinegun.length > 0 }" @click="openFilterModal('machinegun')">机枪<span v-if="form.machinegun.length > 0" class="filter-check">✓</span></a-button>
  34. <a-button class="btn-filter" :class="{ 'has-selection': form.quality.length > 0 }" @click="openFilterModal('quality')">品质<span v-if="form.quality.length > 0" class="filter-check">✓</span></a-button>
  35. <a-button class="btn-filter" :class="{ 'has-selection': form.category.length > 0 }" @click="openFilterModal('category')">类别<span v-if="form.category.length > 0" class="filter-check">✓</span></a-button>
  36. <a-button class="btn-filter" :class="{ 'has-selection': form.exterior.length > 0 }" @click="openFilterModal('exterior')">外观<span v-if="form.exterior.length > 0" class="filter-check">✓</span></a-button>
  37. <a-button class="btn-filter" :class="{ 'has-selection': form.collection.length > 0 }" @click="openFilterModal('collection')">收藏品<span v-if="form.collection.length > 0" class="filter-check">✓</span></a-button>
  38. <a-button class="btn-ids" :class="{ 'has-selection': form.excludeIds.length > 0 }" @click="openIdsListModal('exclude')">排除的饰品id<span v-if="form.excludeIds.length > 0" class="filter-check">✓</span></a-button>
  39. <a-button class="btn-ids" :class="{ 'has-selection': form.specifiedIds.length > 0 }" @click="openIdsListModal('specified')">指定的饰品id<span v-if="form.specifiedIds.length > 0" class="filter-check">✓</span></a-button>
  40. </div>
  41. <!-- 当前检索条件(步枪~收藏品)放在下一行,紧凑展示,类别名着色,最多 3 行+滚动 -->
  42. <div class="selection-summary-row">
  43. <span class="selection-summary-label">当前检索条件(步枪~收藏品)</span>
  44. <div class="selection-summary-box">
  45. <template v-if="selectionSummaryItems.length">
  46. <span v-for="(item, idx) in selectionSummaryItems" :key="item.label" class="summary-item">
  47. <span v-if="idx > 0" class="summary-sep"> ,</span>
  48. <span class="summary-label">{{ item.label }}</span><span>:{{ item.valuesText }}</span>
  49. </span>
  50. </template>
  51. <span v-else class="summary-empty">暂未选择任何检索条件</span>
  52. </div>
  53. </div>
  54. </div>
  55. </div>
  56. <!-- 第二部分:选品条件(宽度自适应:宽则多列,窄则自动换行) -->
  57. <div class="section condition-section">
  58. <div class="section-header">
  59. <span class="section-icon">⚙️</span>
  60. <span class="section-title">选品/扫货条件设置</span>
  61. </div>
  62. <div class="section-body condition-body">
  63. <div class="condition-col">
  64. <a-form-item label="Steam 卡价比例" class="form-item-condition">
  65. <a-input-number
  66. v-model:value="form.steamCardRatio"
  67. :min="0"
  68. :step="0.01"
  69. :precision="2"
  70. placeholder="小数两位"
  71. class="input-narrow"
  72. />
  73. </a-form-item>
  74. <a-form-item label="利润率(%)" class="form-item-condition">
  75. <a-input-number
  76. v-model:value="form.profitRate"
  77. :min="0"
  78. :max="100"
  79. :step="0.1"
  80. :precision="2"
  81. placeholder="百分比"
  82. class="input-narrow"
  83. />
  84. </a-form-item>
  85. <a-form-item label="BUFF价格区间(元)" class="form-item-condition form-item-range">
  86. <div class="range-inputs-row">
  87. <a-input-number
  88. v-model:value="form.buffPriceMin"
  89. :min="0"
  90. placeholder="最低"
  91. class="input-range"
  92. />
  93. <span class="range-sep">-</span>
  94. <a-input-number
  95. v-model:value="form.buffPriceMax"
  96. :min="0"
  97. placeholder="最高"
  98. class="input-range"
  99. />
  100. </div>
  101. </a-form-item>
  102. <a-form-item label="BUFF在售数量" class="form-item-condition form-item-range">
  103. <div class="range-inputs-row">
  104. <a-input-number
  105. v-model:value="form.buffSalesMin"
  106. :min="0"
  107. placeholder="最低"
  108. class="input-range"
  109. />
  110. <span class="range-sep">-</span>
  111. <a-input-number
  112. v-model:value="form.buffSalesMax"
  113. :min="0"
  114. placeholder="最高"
  115. class="input-range"
  116. />
  117. </div>
  118. </a-form-item>
  119. </div>
  120. <div class="condition-col condition-col-right">
  121. <a-form-item label="智能选品助手" class="form-item-condition">
  122. <a-radio-group v-model:value="form.assistantLevel">
  123. <a-radio value="low">低</a-radio>
  124. <a-radio value="standard">标准</a-radio>
  125. <a-radio value="high">高</a-radio>
  126. </a-radio-group>
  127. </a-form-item>
  128. <a-form-item class="form-item-condition">
  129. <template #label>
  130. <span>每天自动选品的时间</span>
  131. <a-tooltip title="每天这个时间,自动触发选品">
  132. <span class="label-tip-icon">?</span>
  133. </a-tooltip>
  134. </template>
  135. <a-time-picker
  136. :value="autoSelectionTimeDayjs"
  137. value-format="HH:mm"
  138. format="HH:mm"
  139. placeholder="选择时间"
  140. class="input-time"
  141. @change="onAutoSelectionTimeChange"
  142. />
  143. </a-form-item>
  144. <a-form-item class="form-item-condition">
  145. <template #label>
  146. <span>选品数量</span>
  147. <a-tooltip title="客户设置选品数量,当数量达标后,自动触发扫货">
  148. <span class="label-tip-icon">?</span>
  149. </a-tooltip>
  150. </template>
  151. <a-input-number
  152. v-model:value="form.selectionQuantity"
  153. :min="0"
  154. :max="configStore.config.buff_selection_max_quantity ?? 300"
  155. placeholder="数量"
  156. class="input-time"
  157. />
  158. </a-form-item>
  159. <a-form-item label="智能扫货助手" class="form-item-condition">
  160. <a-radio-group v-model:value="form.sweepAssistantLevel">
  161. <a-radio value="low">低</a-radio>
  162. <a-radio value="standard">标准</a-radio>
  163. <a-radio value="high">高</a-radio>
  164. </a-radio-group>
  165. </a-form-item>
  166. <a-form-item label="扫货进程数" class="form-item-condition">
  167. <a-input-number
  168. v-model:value="form.sweepProcessCount"
  169. :min="1"
  170. :max="configStore.config.buff_sweep_max_worker_count ?? 32"
  171. :precision="0"
  172. placeholder="并行路数"
  173. class="input-narrow"
  174. />
  175. </a-form-item>
  176. </div>
  177. </div>
  178. </div>
  179. <!-- 第三部分:保存配置按钮 -->
  180. <div class="button-section">
  181. <a-button
  182. type="primary"
  183. :disabled="running || switchingStatus != null"
  184. class="save-config-btn"
  185. @click="openConfirmDialog({
  186. title: '保存配置信息',
  187. message: '确定要保存当前的配置信息吗?',
  188. okText: '保存',
  189. cancelText: '取消',
  190. iconType: 'info',
  191. okDanger: false,
  192. onConfirm: saveConditions
  193. })"
  194. >
  195. 保存配置信息
  196. </a-button>
  197. </div>
  198. </div>
  199. </div>
  200. <!-- 弹窗:设置单个检索条件(步枪/手枪/.../收藏品) -->
  201. <a-modal
  202. v-model:open="filterModalVisible"
  203. :title="filterModalTitle"
  204. width="520px"
  205. wrap-class-name="filter-modal-wrap"
  206. @cancel="closeFilterModal"
  207. >
  208. <div v-if="filterModalType" class="filter-modal-single-body">
  209. <a-form-item v-if="filterModalType !== 'collection'" :label="filterModalTypeLabel" class="form-item-full">
  210. <a-select
  211. v-model:value="filterDraftSingle"
  212. mode="multiple"
  213. :placeholder="'请选择' + filterModalTypeLabel"
  214. allow-clear
  215. :options="filterModalOptions"
  216. class="filter-select-full"
  217. />
  218. </a-form-item>
  219. <a-form-item v-else label="收藏品" class="form-item-full">
  220. <a-tree-select
  221. v-model:value="filterDraftSingle"
  222. :tree-data="filterOptions.collectionTree"
  223. placeholder="请选择收藏品(可多选)"
  224. allow-clear
  225. multiple
  226. tree-checkable
  227. :show-checked-strategy="TreeSelect.SHOW_CHILD"
  228. tree-default-expand-all
  229. class="filter-select-full collection-tree-select"
  230. />
  231. </a-form-item>
  232. </div>
  233. <template #footer>
  234. <a-button @click="closeFilterModal">取消</a-button>
  235. <a-button type="primary" @click="confirmFilterModal">确定</a-button>
  236. </template>
  237. </a-modal>
  238. <!-- 弹窗:排除的饰品id / 指定的饰品id(上传 + 列表 + 删除) -->
  239. <a-modal
  240. v-model:open="idsListModalVisible"
  241. :title="idsListModalTitle"
  242. width="640px"
  243. :footer="null"
  244. destroy-on-close
  245. wrap-class-name="ids-list-modal-wrap"
  246. @after-open-change="onIdsModalOpenChange"
  247. >
  248. <div class="ids-modal-toolbar">
  249. <a-button type="primary" size="small" @click="uploadTxtInIdsModal">上传</a-button>
  250. <a-button danger size="small" :disabled="!idsListSelectedRowKeys.length" @click="batchRemoveIdsList">
  251. 批量删除
  252. </a-button>
  253. </div>
  254. <div class="ids-modal-body">
  255. <a-spin :spinning="idsListLoading">
  256. <a-table
  257. :columns="idsListColumnsWithAction"
  258. :data-source="idsListData"
  259. :pagination="false"
  260. :row-selection="{ selectedRowKeys: idsListSelectedRowKeys, onChange: onIdsListSelectionChange }"
  261. size="small"
  262. row-key="goods_id"
  263. >
  264. <template #bodyCell="{ column, record }">
  265. <template v-if="column.key === 'icon'">
  266. <img
  267. v-if="record.icon_url"
  268. :src="record.icon_url"
  269. alt=""
  270. class="goods-icon-img"
  271. @error="handleGoodsIconError"
  272. />
  273. <span v-else class="goods-icon-placeholder">—</span>
  274. </template>
  275. <template v-else-if="column.key === 'action'">
  276. <a-button type="link" danger size="small" @click="removeIdFromIdsList(record.goods_id)">删除</a-button>
  277. </template>
  278. </template>
  279. <template #emptyText>
  280. <a-empty description="暂无饰品 id,可点击上传添加" />
  281. </template>
  282. </a-table>
  283. </a-spin>
  284. </div>
  285. </a-modal>
  286. <!-- 第四部分:选品数据展示 -->
  287. <div class="section table-section">
  288. <div class="section-header section-header-with-action">
  289. <span class="section-icon">📋</span>
  290. <span class="section-title">选品数据</span>
  291. <div class="selection-search-bar">
  292. <a-input
  293. v-model:value="selectionSearchKeyword"
  294. placeholder="按饰品ID或名称搜索"
  295. class="selection-search-input"
  296. allow-clear
  297. @pressEnter="onSelectionSearch"
  298. />
  299. <span v-if="selectionSearchKeyword && filteredSelectionList.length !== selectionList.length" class="selection-search-hint">
  300. 匹配 {{ filteredSelectionList.length }} / {{ selectionList.length }}
  301. </span>
  302. </div>
  303. <div class="section-actions">
  304. <a-button
  305. type="link"
  306. danger
  307. size="small"
  308. :disabled="selectionSelectedRowKeys.length === 0 || selectionDeleting"
  309. @click="openConfirmDialog({
  310. title: '批量删除',
  311. message: `确定要删除选中的 ${selectionSelectedRowKeys.length} 条数据吗?`,
  312. okText: '删除',
  313. cancelText: '取消',
  314. iconType: 'danger',
  315. okDanger: true,
  316. onConfirm: handleBatchDeleteSelectionItems
  317. })"
  318. >
  319. 批量删除
  320. </a-button>
  321. <a-button type="link" size="small" class="btn-log" @click="openSelectionLogModal">
  322. 查看选品日志
  323. </a-button>
  324. </div>
  325. </div>
  326. <div class="section-body table-wrap">
  327. <a-table
  328. :columns="columns"
  329. :data-source="filteredSelectionList"
  330. :pagination="{ ...tablePagination, total: filteredSelectionList.length }"
  331. :loading="tableLoading"
  332. :row-key="rowKey"
  333. :row-selection="{ selectedRowKeys: selectionSelectedRowKeys, onChange: onSelectionListSelectionChange }"
  334. :scroll="{ y: 400 }"
  335. size="small"
  336. @change="onSelectionTableChange"
  337. >
  338. <template #bodyCell="{ column, record, index }">
  339. <template v-if="column.key === 'index'">
  340. {{ (tablePagination.current - 1) * tablePagination.pageSize + index + 1 }}
  341. </template>
  342. <template v-else-if="column.key === 'icon'">
  343. <img
  344. v-if="record.icon_url"
  345. :src="record.icon_url"
  346. alt=""
  347. class="selection-goods-icon"
  348. @error="handleGoodsIconError"
  349. />
  350. <span v-else class="goods-icon-placeholder">—</span>
  351. </template>
  352. <template v-else-if="column.key === 'price_cny'">
  353. {{ formatCurrencyPrice(record.price_cny, '¥') }}
  354. </template>
  355. <template v-else-if="column.key === 'price_usd'">
  356. {{ formatCurrencyPrice(record.price_usd, '$') }}
  357. </template>
  358. <template v-else-if="column.key === 'created_at'">
  359. {{ formatSelectionTime(record.created_at) }}
  360. </template>
  361. <template v-else-if="column.key === 'action'">
  362. <a-button type="link" danger size="small" @click="openConfirmDialog({
  363. title: '删除选品',
  364. message: '确定要删除该选品数据吗?',
  365. okText: '删除',
  366. cancelText: '取消',
  367. iconType: 'danger',
  368. okDanger: true,
  369. onConfirm: () => handleDeleteSelectionItem(record)
  370. })">
  371. 删除
  372. </a-button>
  373. </template>
  374. </template>
  375. <template #emptyText>
  376. <a-empty description="暂无选品数据" />
  377. </template>
  378. </a-table>
  379. </div>
  380. </div>
  381. <!-- 第五部分:扫货成功数据展示 -->
  382. <div class="section table-section">
  383. <div class="section-header section-header-with-action">
  384. <div class="section-header-left">
  385. <span class="section-icon">🧾</span>
  386. <span class="section-title">扫货成功数据</span>
  387. <div class="selection-search-bar">
  388. <a-input
  389. v-model:value="sweepSearchKeyword"
  390. placeholder="按饰品ID或名称搜索"
  391. class="selection-search-input"
  392. allow-clear
  393. />
  394. <span v-if="sweepSearchKeyword && filteredSweepList.length !== sweepList.length" class="selection-search-hint">
  395. 匹配 {{ filteredSweepList.length }} / {{ sweepList.length }}
  396. </span>
  397. </div>
  398. </div>
  399. <div class="section-actions">
  400. <a-button type="link" size="small" class="btn-export" @click="exportSweepTxt">
  401. 导出TXT
  402. </a-button>
  403. <a-button type="link" size="small" class="btn-export" :loading="exportingSweepExcel" @click="exportSweepExcel">
  404. 导出Excel
  405. </a-button>
  406. <a-divider type="vertical" class="btn-divider" />
  407. <a-button type="link" size="small" class="btn-log" @click="openSweepLogModal">
  408. 查看扫货日志
  409. </a-button>
  410. </div>
  411. </div>
  412. <div class="section-body table-wrap">
  413. <a-table
  414. :columns="filteredSweepColumns"
  415. :data-source="paginatedSweepList"
  416. :pagination="sweepPagination"
  417. :loading="sweepTableLoading"
  418. :row-key="sweepRowKey"
  419. :scroll="{ y: 400 }"
  420. size="small"
  421. @change="onSweepTableChange"
  422. >
  423. <template #bodyCell="{ column, record, index }">
  424. <template v-if="column.key === 'index'">
  425. {{ (sweepPagination.current - 1) * sweepPagination.pageSize + index + 1 }}
  426. </template>
  427. <template v-else-if="column.key === 'icon'">
  428. <img
  429. v-if="record.icon_url"
  430. :src="record.icon_url"
  431. alt=""
  432. class="selection-goods-icon"
  433. @error="handleGoodsIconError"
  434. />
  435. <span v-else class="goods-icon-placeholder">—</span>
  436. </template>
  437. <template v-else-if="column.key === 'target_price_usd'">
  438. {{ formatCurrencyPrice(record.target_price_usd, '$') }}
  439. </template>
  440. <template v-else-if="column.key === 'purchase_price_usd'">
  441. {{ formatCurrencyPrice(record.purchase_price_usd, '$') }}
  442. </template>
  443. <template v-else-if="column.key === 'purchase_price_cny'">
  444. {{ formatCurrencyPrice(record.purchase_price_cny, '¥') }}
  445. </template>
  446. <template v-else-if="column.key === 'purchase_wear'">
  447. {{ formatWearValue(record.purchase_wear) }}
  448. </template>
  449. <template v-else-if="column.key === 'steam_account'">
  450. {{ record.steam_account || '—' }}
  451. </template>
  452. <template v-else-if="column.key === 'created_at'">
  453. {{ formatSelectionTime(record.created_at) }}
  454. </template>
  455. </template>
  456. <template #emptyText>
  457. <a-empty description="暂无扫货成功数据" />
  458. </template>
  459. </a-table>
  460. </div>
  461. </div>
  462. <!-- 第六部分:已绑定 Steam 账号 -->
  463. <div class="section table-section">
  464. <div class="section-header section-header-with-action">
  465. <div class="section-header-left">
  466. <span class="section-icon">🎮</span>
  467. <span class="section-title">已绑定 Steam 账号</span>
  468. </div>
  469. <div class="section-actions">
  470. <a-button
  471. type="link"
  472. size="small"
  473. :loading="boundSteamRefreshingAll"
  474. :disabled="boundSteamLoading || !boundSteamAccounts.length || boundSteamRefreshingAll"
  475. @click="handleRefreshAllBoundSteamBalances"
  476. >
  477. 立即刷新全部余额
  478. </a-button>
  479. </div>
  480. </div>
  481. <div class="section-body table-wrap">
  482. <a-table
  483. :columns="boundSteamColumns"
  484. :data-source="boundSteamAccounts"
  485. :loading="boundSteamLoading"
  486. :pagination="false"
  487. :row-key="(record: BoundSteamRow) => record.steam_username"
  488. size="small"
  489. :scroll="{ x: 900 }"
  490. >
  491. <template #bodyCell="{ column, record }">
  492. <template v-if="column.key === 'balance'">
  493. <span v-if="isAutoRelogining(record.steam_username)">
  494. <a-spin size="small" /> 重新登录中...
  495. </span>
  496. <span v-else-if="boundSteamBalanceQueryUsername === record.steam_username">
  497. <a-spin size="small" /> 查询中...
  498. </span>
  499. <span v-else>
  500. {{ formatSteamBalance(record.steam_username) }}
  501. <span v-if="getFailCount(record.steam_username) >= 10" style="color: #faad14; margin-left: 4px;">
  502. <QuestionCircleOutlined title="余额连续查询失败,正在自动重新登录" />
  503. </span>
  504. </span>
  505. </template>
  506. <template v-else-if="column.key === 'is_active'">
  507. <a-switch
  508. :checked="record.is_active !== false"
  509. checked-children="参与"
  510. un-checked-children="不参与"
  511. size="small"
  512. @change="(checked: boolean) => handleBoundSteamActiveChange(record, checked)"
  513. />
  514. </template>
  515. <template v-else-if="column.key === 'cardRate'">
  516. <a-input-number
  517. :value="record.cardRate"
  518. :min="0"
  519. :max="10"
  520. :precision="2"
  521. size="small"
  522. style="width: 60px;"
  523. placeholder="比例"
  524. @change="(val: number | null) => handleBoundSteamCardRateChange(record, val)"
  525. />
  526. </template>
  527. <template v-else-if="column.key === 'status'">
  528. <a-tag v-if="isAutoRelogining(record.steam_username)" color="processing">
  529. 自动登录中
  530. </a-tag>
  531. <a-tag v-else-if="getFailCount(record.steam_username) >= 5 && getFailCount(record.steam_username) < 10" color="warning">
  532. 登录异常 ({{ getFailCount(record.steam_username) }}/10)
  533. </a-tag>
  534. <a-tag v-else-if="getFailCount(record.steam_username) >= 10 && !isAutoRelogining(record.steam_username)" color="error">
  535. 登录失效
  536. </a-tag>
  537. <a-tag v-else color="success">
  538. 正常
  539. </a-tag>
  540. </template>
  541. <template v-else-if="column.key === 'action'">
  542. <a-button
  543. type="link"
  544. size="small"
  545. :loading="boundSteamReloginUsername === record.steam_username"
  546. :disabled="boundSteamReloginUsername !== null || !record.hasSteamLoginSecure || boundSteamRefreshingAll"
  547. @click="handleBoundSteamRelogin(record)"
  548. >
  549. 重新登录
  550. </a-button>
  551. <a-button
  552. type="link"
  553. size="small"
  554. :loading="boundSteamBalanceQueryUsername === record.steam_username"
  555. :disabled="boundSteamBalanceQueryUsername !== null || !record.hasSteamLoginSecure || boundSteamRefreshingAll"
  556. @click="handleBoundSteamBalanceQuery(record)"
  557. >
  558. 查询余额
  559. </a-button>
  560. </template>
  561. </template>
  562. <template #emptyText>
  563. <a-empty description="暂无已绑定 Steam 账号" />
  564. </template>
  565. </a-table>
  566. </div>
  567. </div>
  568. <!-- 选品日志弹窗 -->
  569. <a-modal
  570. v-model:open="selectionLogModalVisible"
  571. width="720px"
  572. :footer="null"
  573. wrap-class-name="selection-log-modal-wrap"
  574. destroy-on-close
  575. @after-open-change="onSelectionLogModalOpenChange"
  576. >
  577. <template #title>
  578. <div class="log-modal-title">
  579. <span>选品日志</span>
  580. <a-switch
  581. v-model:checked="selectionLogAutoFollow"
  582. size="small"
  583. checked-children="跟随最新"
  584. un-checked-children="暂停跟随"
  585. @change="onSelectionLogFollowChange"
  586. />
  587. </div>
  588. </template>
  589. <div class="selection-log-body" ref="selectionLogBodyRef" @scroll="onSelectionLogBodyScroll">
  590. <a-spin :spinning="selectionLogLoading">
  591. <div v-if="!selectionLogData.started_at && !selectionLogData.logs.length" class="selection-log-empty">
  592. 暂无选品记录
  593. </div>
  594. <div v-else class="selection-log-lines">
  595. <div v-if="selectionLogData.started_at" class="log-line log-line-start">
  596. {{ selectionLogData.started_at }} 开始选品
  597. </div>
  598. <div
  599. v-for="(entry, idx) in selectionLogData.logs"
  600. :key="`${entry.created_at ?? 'na'}-${entry.goods_id}-${idx}`"
  601. class="log-line"
  602. :class="entry.result === 'selected' ? 'log-line-selected-strong' : (entry.result === 'stage' ? 'log-line-stage' : 'log-line-not-selected')"
  603. >
  604. {{ formatLogLine(entry) }}
  605. </div>
  606. <div v-if="selectionLogData.ended_at" class="log-line log-line-end">
  607. {{ selectionLogData.ended_at }} 结束选品
  608. </div>
  609. </div>
  610. </a-spin>
  611. </div>
  612. </a-modal>
  613. <!-- 扫货日志弹窗 - 按钮组 + 单一日志显示区布局 -->
  614. <a-modal
  615. v-model:open="sweepLogModalVisible"
  616. width="900px"
  617. :footer="null"
  618. wrap-class-name="selection-log-modal-wrap"
  619. destroy-on-close
  620. @after-open-change="onSweepLogModalOpenChange"
  621. >
  622. <template #title>
  623. <div class="log-modal-title">
  624. <span>扫货日志</span>
  625. <a-switch
  626. v-model:checked="sweepLogAutoFollow"
  627. size="small"
  628. checked-children="跟随最新"
  629. un-checked-children="暂停跟随"
  630. @change="onSweepLogFollowChange"
  631. />
  632. </div>
  633. </template>
  634. <!-- Worker 按钮组 -->
  635. <div class="sweep-log-worker-buttons" style="margin-bottom: 12px; display: flex; gap: 8px;">
  636. <a-button
  637. v-for="worker in sweepWorkerList"
  638. :key="worker.worker_index"
  639. :type="sweepLogActiveWorkerIndex === worker.worker_index ? 'primary' : 'default'"
  640. @click="onWorkerButtonClick(worker.worker_index)"
  641. >
  642. 线程 {{ worker.worker_index }}
  643. </a-button>
  644. </div>
  645. <!-- 日志显示区域 - tail 风格实时滚动 -->
  646. <div class="selection-log-body" :ref="setSweepLogBodyRef" @scroll="onSweepLogBodyScroll">
  647. <div class="debug-panel" style="background:#f0f0f0;padding:8px;margin-bottom:8px;font-size:12px;">
  648. <div>线程 {{ sweepLogActiveWorkerIndex }} - 当前日志数: {{ currentWorkerLogs.length }}</div>
  649. <div>request_id: {{ sweepLogData.request_id ?? 'null' }}</div>
  650. </div>
  651. <div class="selection-log-lines">
  652. <div
  653. v-for="(entry, idx) in currentWorkerLogs"
  654. :key="`W${sweepLogActiveWorkerIndex}-${entry.created_at ?? 'na'}-${entry.goods_id}-${idx}`"
  655. class="log-line sweep-log-line-dynamic"
  656. :style="sweepLogLineStyle(entry)"
  657. >
  658. <!-- API 详情日志可展开 -->
  659. <template v-if="isApiDetailLog(entry)">
  660. <div class="api-log-header" @click="toggleApiLogExpand(`${sweepLogActiveWorkerIndex}-${idx}`)">
  661. <span class="api-log-toggle">{{ expandedApiLogs.has(`${sweepLogActiveWorkerIndex}-${idx}`) ? '▼' : '▶' }}</span>
  662. <span class="api-log-summary">{{ formatSweepLogLine(entry) }}</span>
  663. </div>
  664. <div v-if="expandedApiLogs.has(`${sweepLogActiveWorkerIndex}-${idx}`)" class="api-log-detail">
  665. <div class="api-detail-section">
  666. <div class="api-detail-title">📋 请求信息</div>
  667. <div class="api-detail-row"><span class="api-detail-label">账号:</span> {{ entry.steam_account || '—' }}</div>
  668. <div class="api-detail-row"><span class="api-detail-label">方法:</span> {{ entry.method || '—' }}</div>
  669. <div class="api-detail-row"><span class="api-detail-label">URL:</span> <code class="api-detail-code">{{ entry.url || '—' }}</code></div>
  670. </div>
  671. <div class="api-detail-section">
  672. <div class="api-detail-title">📨 响应信息</div>
  673. <div class="api-detail-row">
  674. <span class="api-detail-label">状态码:</span>
  675. <span :style="{ color: isSuccessStatus(entry.response_status) ? '#52c41a' : '#ff4d4f' }">
  676. {{ entry.response_status || '—' }}
  677. </span>
  678. </div>
  679. <div class="api-detail-row"><span class="api-detail-label">耗时:</span> {{ entry.elapsed_ms || '—' }}ms</div>
  680. </div>
  681. </div>
  682. </template>
  683. <!-- 非 API 详情日志正常显示 -->
  684. <template v-else>
  685. {{ formatSweepLogLine(entry) }}
  686. </template>
  687. </div>
  688. </div>
  689. </div>
  690. </a-modal>
  691. <!-- 固定底部操作按钮 -->
  692. <div class="fixed-bottom-buttons">
  693. <div class="fixed-bottom-vip">
  694. <a-tag :color="authStore.isExpiringSoon ? 'orange' : (authStore.isExpired ? 'red' : (authStore.daysRemaining > 0 ? 'gold' : 'default'))">
  695. {{ authStore.isExpired ? '已过期' : (authStore.daysRemaining > 0 ? `VIP会员 (剩余${authStore.daysRemaining}天)` : '未激活') }}
  696. </a-tag>
  697. <span class="fixed-bottom-vip-expire" v-if="authStore.expireTime">
  698. 到期时间: {{ formatDate(authStore.expireTime) }}
  699. </span>
  700. </div>
  701. <div class="fixed-bottom-buttons-inner">
  702. <div class="buff-status-indicator" v-if="running || sweepRunning || switchingStatus">
  703. <LoadingOutlined v-if="switchingStatus" spin />
  704. <ShoppingOutlined v-else />
  705. <span class="status-text" :class="{ 'is-switching': switchingStatus, 'is-selection': running, 'is-sweep': sweepRunning }">
  706. <template v-if="switchingStatus">切换中</template>
  707. <template v-else-if="running">选品中</template>
  708. <template v-else-if="sweepRunning">扫货中</template>
  709. </span>
  710. </div>
  711. <a-button
  712. class="btn-start"
  713. type="primary"
  714. :loading="running || (switchingStatus === 'resetting_before_start' && buffPrepareFor === 'selection')"
  715. :disabled="switchingStatus != null || !canStartSelection"
  716. @click="openConfirmDialog({
  717. title: '开始选品',
  718. message: '确定要开始选品吗?',
  719. okText: '开始',
  720. cancelText: '取消',
  721. iconType: 'info',
  722. okDanger: false,
  723. onConfirm: () => handleStart('manual')
  724. })"
  725. >
  726. 开始选品
  727. </a-button>
  728. <a-button class="btn-stop" danger :disabled="!running" @click="openConfirmDialog({
  729. title: '停止选品',
  730. message: '确定要停止选品吗?',
  731. okText: '停止',
  732. cancelText: '取消',
  733. iconType: 'warning',
  734. okDanger: true,
  735. onConfirm: handleStop
  736. })">
  737. 停止选品
  738. </a-button>
  739. <a-button
  740. class="btn-sweep-start"
  741. type="primary"
  742. :loading="sweepRunning || (switchingStatus === 'resetting_before_start' && buffPrepareFor === 'sweep')"
  743. :disabled="switchingStatus != null || !canStartSweep"
  744. @click="openConfirmDialog({
  745. title: '开始扫货',
  746. message: '确定要开始扫货吗?',
  747. okText: '开始',
  748. cancelText: '取消',
  749. iconType: 'info',
  750. okDanger: false,
  751. onConfirm: () => handleStartSweep(false)
  752. })"
  753. >
  754. 开始扫货
  755. </a-button>
  756. <a-button class="btn-sweep-stop" danger :disabled="!sweepRunning" @click="openConfirmDialog({
  757. title: '停止扫货',
  758. message: '确定要停止扫货吗?',
  759. okText: '停止',
  760. cancelText: '取消',
  761. iconType: 'warning',
  762. okDanger: true,
  763. onConfirm: handleStopSweep
  764. })">
  765. 停止扫货
  766. </a-button>
  767. </div>
  768. </div>
  769. </div>
  770. <!-- 确认弹窗 -->
  771. <ConfirmDialog
  772. v-model:isOpen="confirmDialogVisible"
  773. :title="confirmDialogConfig.title"
  774. :message="confirmDialogConfig.message"
  775. :ok-text="confirmDialogConfig.okText"
  776. :cancel-text="confirmDialogConfig.cancelText"
  777. :icon-type="confirmDialogConfig.iconType"
  778. :ok-danger="confirmDialogConfig.okDanger"
  779. @confirm="handleConfirmDialog"
  780. @cancel="confirmDialogVisible = false"
  781. />
  782. </div>
  783. </template>
  784. <script setup lang="ts">
  785. /* eslint-disable vue/no-v-model-argument */
  786. import { ref, reactive, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
  787. import { message, TreeSelect } from 'ant-design-vue'
  788. import { LoadingOutlined, ShoppingOutlined, QuestionCircleOutlined } from '@ant-design/icons-vue'
  789. import dayjs, { type Dayjs } from 'dayjs'
  790. import { open as openDialog } from '@tauri-apps/api/dialog'
  791. import { invoke } from '@tauri-apps/api/tauri'
  792. import { secureApiClient } from '../utils/secureApiClient'
  793. import { useAuthStore } from '../stores/authStore'
  794. import { useBuffSweepStore } from '../stores/buffSweepStore'
  795. import { useConfigStore } from '../stores/configStore'
  796. import { formatDate } from '../utils/formatDate'
  797. import ConfirmDialog from './components/ConfirmDialog.vue'
  798. const STORAGE_KEY = 'buff_selection_conditions'
  799. const DEBOUNCE_SAVE_MS = 600
  800. const authStore = useAuthStore()
  801. const buffSweepStore = useBuffSweepStore()
  802. const configStore = useConfigStore()
  803. const emit = defineEmits<{
  804. (
  805. e: 'status-change',
  806. payload: {
  807. selectionRunning: boolean
  808. sweepRunning: boolean
  809. hasSelectionData: boolean
  810. switchingStatus: 'resetting_before_start' | null
  811. }
  812. ): void
  813. }>()
  814. /** 初始加载完成后才启用自动保存,避免加载云端/本地配置时误触发保存 */
  815. const initialLoadDone = ref(false)
  816. let configSaveTimer: ReturnType<typeof setTimeout> | null = null
  817. /** 条件设置区域展开/收起状态 */
  818. const conditionsExpanded = ref(false)
  819. // 表单:检索条件 + 选品条件
  820. const form = reactive({
  821. rifle: [] as string[],
  822. pistol: [] as string[],
  823. smg: [] as string[],
  824. shotgun: [] as string[],
  825. machinegun: [] as string[],
  826. quality: [] as string[],
  827. category: [] as string[],
  828. exterior: [] as string[],
  829. collection: [] as string[],
  830. excludeIds: '',
  831. specifiedIds: '',
  832. steamCardRatio: undefined as number | undefined,
  833. profitRate: undefined as number | undefined,
  834. buffPriceMin: undefined as number | undefined,
  835. buffPriceMax: undefined as number | undefined,
  836. buffSalesMin: undefined as number | undefined,
  837. buffSalesMax: undefined as number | undefined,
  838. assistantLevel: 'low' as 'low' | 'standard' | 'high',
  839. /** 每天自动选品的时间,格式 HH:mm */
  840. autoSelectionTime: undefined as string | undefined,
  841. /** 选品数量,达标后自动触发扫货 */
  842. selectionQuantity: undefined as number | undefined,
  843. /** 智能扫货助手:低/标准/高,对应扫货价格系数 min/mid/max,默认低 */
  844. sweepAssistantLevel: 'low' as 'low' | 'standard' | 'high',
  845. /** 扫货并行进程数(与饰品数取小),默认 3 */
  846. sweepProcessCount: 3 as number
  847. })
  848. // 下拉选项:从后端 buff_filter_option 表加载;收藏品为树形 collectionTree
  849. const filterOptions = reactive({
  850. rifle: [] as { label: string; value: string }[],
  851. pistol: [] as { label: string; value: string }[],
  852. smg: [] as { label: string; value: string }[],
  853. shotgun: [] as { label: string; value: string }[],
  854. machinegun: [] as { label: string; value: string }[],
  855. quality: [] as { label: string; value: string }[],
  856. category: [] as { label: string; value: string }[],
  857. exterior: [] as { label: string; value: string }[],
  858. collection: [] as { label: string; value: string }[],
  859. collectionTree: [] as { title: string; value: string; key: string; children?: { title: string; value: string; key: string }[] }[]
  860. })
  861. // 检索条件弹窗:当前编辑项 + 单选项草稿
  862. type FilterModalType = 'rifle' | 'pistol' | 'smg' | 'shotgun' | 'machinegun' | 'quality' | 'category' | 'exterior' | 'collection'
  863. const filterModalVisible = ref(false)
  864. const filterModalType = ref<FilterModalType | null>(null)
  865. const filterDraftSingle = ref<string[]>([])
  866. const FILTER_LABELS: Record<FilterModalType, string> = {
  867. rifle: '步枪',
  868. pistol: '手枪',
  869. smg: '微型冲锋枪',
  870. shotgun: '霰弹枪',
  871. machinegun: '机枪',
  872. quality: '品质',
  873. category: '类别',
  874. exterior: '外观',
  875. collection: '收藏品'
  876. }
  877. /** 品质、类别、外观:从数据库加载后仅保留以下 label 的选项(按 label 过滤) */
  878. const ALLOWED_QUALITY_LABELS = ['违禁', '隐秘', '保密', '受限', '军规级', '工业级', '消费级']
  879. const ALLOWED_CATEGORY_LABELS = ['普通', 'StatTrak™']
  880. const ALLOWED_EXTERIOR_LABELS = ['崭新出厂', '略有磨损', '久经沙场']
  881. function filterOptionsByLabel(
  882. options: { label: string; value: string }[],
  883. allowedLabels: string[]
  884. ): { label: string; value: string }[] {
  885. return options.filter((o) => allowedLabels.includes(o.label))
  886. }
  887. /** 每天自动选品时间:用于 TimePicker 的 dayjs 值 */
  888. const autoSelectionTimeDayjs = computed<Dayjs | null>(() => {
  889. const t = form.autoSelectionTime
  890. if (!t || typeof t !== 'string') return null
  891. const [h, m] = t.split(':').map(Number)
  892. if (Number.isNaN(h) || Number.isNaN(m)) return null
  893. return dayjs().hour(h).minute(m).second(0).millisecond(0)
  894. })
  895. function onAutoSelectionTimeChange(time: Dayjs | null, timeString: string | string[]) {
  896. if (timeString != null) {
  897. form.autoSelectionTime = Array.isArray(timeString) ? timeString[0] : (timeString as string) || undefined
  898. } else if (time) {
  899. form.autoSelectionTime = time.format('HH:mm')
  900. } else {
  901. form.autoSelectionTime = undefined
  902. }
  903. }
  904. const filterModalTitle = computed(() =>
  905. filterModalType.value ? `设置${FILTER_LABELS[filterModalType.value]}条件` : '设置检索条件'
  906. )
  907. const filterModalTypeLabel = computed(() =>
  908. filterModalType.value ? FILTER_LABELS[filterModalType.value] : ''
  909. )
  910. const filterModalOptions = computed(() => {
  911. const key = filterModalType.value
  912. if (!key || key === 'collection') return []
  913. const opts = filterOptions[key]
  914. return Array.isArray(opts) ? opts : []
  915. })
  916. // 排除/指定饰品 id 弹窗
  917. const idsListModalVisible = ref(false)
  918. const idsListModalType = ref<'exclude' | 'specified' | null>(null)
  919. const idsListModalTitle = computed(() =>
  920. idsListModalType.value === 'exclude' ? '排除的饰品id' : idsListModalType.value === 'specified' ? '指定的饰品id' : ''
  921. )
  922. const idsListLoading = ref(false)
  923. const idsListData = ref<{ goods_id: string; name: string; icon_url: string }[]>([])
  924. const idsListSelectedRowKeys = ref<string[]>([])
  925. const idsListColumnsWithAction = [
  926. { title: '商品id', dataIndex: 'goods_id', key: 'goods_id', width: 120 },
  927. { title: '商品头像', key: 'icon', width: 80 },
  928. { title: '商品名称', dataIndex: 'name', key: 'name', ellipsis: true },
  929. { title: '操作', key: 'action', width: 80 }
  930. ]
  931. /** 将 value 列表转为 label 列表(用于只读摘要) */
  932. function valuesToLabels(values: string[], options: { label: string; value: string }[]): string[] {
  933. if (!values?.length) return []
  934. const map = new Map(options.map((o) => [o.value, o.label]))
  935. return values.map((v) => map.get(v) ?? v)
  936. }
  937. /** 将 { label, value }[] 转为 string[](提取 label) */
  938. function extractLabels(items: { label: string; value: string }[]): string[] {
  939. return items?.map((i) => i.label) ?? []
  940. }
  941. /** 当前检索条件摘要:紧凑格式「步枪:xxx、xxx ,手枪:xxx」,供带类别着色的展示用 */
  942. const selectionSummaryItems = computed(() => {
  943. const items: { label: string; values: string[] }[] = [
  944. { label: '步枪', values: extractLabels(form.rifle) },
  945. { label: '手枪', values: extractLabels(form.pistol) },
  946. { label: '微型冲锋枪', values: extractLabels(form.smg) },
  947. { label: '霰弹枪', values: extractLabels(form.shotgun) },
  948. { label: '机枪', values: extractLabels(form.machinegun) },
  949. { label: '品质', values: extractLabels(form.quality) },
  950. { label: '类别', values: extractLabels(form.category) },
  951. { label: '外观', values: extractLabels(form.exterior) },
  952. { label: '收藏品', values: extractLabels(form.collection) }
  953. ]
  954. return items
  955. .filter(({ values }) => values.length > 0)
  956. .map(({ label, values }) => ({ label, valuesText: values.join('、') }))
  957. })
  958. /** 选品开始前必须填写 Steam 卡价比例(且>=4)和利润率 */
  959. const canStartSelection = computed(() => {
  960. return form.steamCardRatio != null && form.steamCardRatio >= 4 && form.profitRate != null
  961. })
  962. const canStartSweep = computed(() => selectionList.value.length > 0)
  963. /** 助手等级中文映射 */
  964. const ASSISTANT_LEVEL_LABELS: Record<string, string> = {
  965. low: '低',
  966. standard: '标准',
  967. high: '高'
  968. }
  969. /** 条件设置区域的紧凑摘要(用于收起状态展示,分行显示HTML) */
  970. const conditionsSummary = computed(() => {
  971. const lines: string[] = []
  972. // 第一行:BUFF检索条件
  973. const buffParts: string[] = []
  974. const buffTypeMap: Array<{ key: 'rifle' | 'pistol' | 'smg' | 'shotgun' | 'machinegun' | 'quality' | 'category' | 'exterior' | 'collection'; label: string }> = [
  975. { key: 'rifle', label: '步枪' },
  976. { key: 'pistol', label: '手枪' },
  977. { key: 'smg', label: '微冲' },
  978. { key: 'shotgun', label: '霰弹' },
  979. { key: 'machinegun', label: '机枪' },
  980. { key: 'quality', label: '品质' },
  981. { key: 'category', label: '类别' },
  982. { key: 'exterior', label: '外观' },
  983. { key: 'collection', label: '收藏' }
  984. ]
  985. for (const { key, label } of buffTypeMap) {
  986. const arr = form[key] as any[]
  987. if (arr && arr.length > 0) {
  988. // arr 是 { label, value }[] 或 string[]
  989. const isObjArray = arr[0] && typeof arr[0] === 'object' && 'label' in arr[0]
  990. if (isObjArray) {
  991. const names = (arr as { label: string; value: string }[]).map(i => i.label).join('、')
  992. buffParts.push(`${label}:${names}`)
  993. } else {
  994. buffParts.push(`${label}✓${arr.length}`)
  995. }
  996. }
  997. }
  998. if (form.excludeIds) buffParts.push(`排除✓`)
  999. if (form.specifiedIds) buffParts.push(`指定✓`)
  1000. if (buffParts.length > 0) {
  1001. lines.push(`BUFF: ${buffParts.join(' | ')}`)
  1002. }
  1003. // 第二行:选品条件
  1004. const selectionParts: string[] = []
  1005. if (form.steamCardRatio != null) selectionParts.push(`卡价 ${form.steamCardRatio}`)
  1006. if (form.profitRate != null) selectionParts.push(`利润 ${form.profitRate}%`)
  1007. if (form.buffPriceMin != null || form.buffPriceMax != null) {
  1008. selectionParts.push(`价格 ${form.buffPriceMin ?? '?'}-${form.buffPriceMax ?? '?'}`)
  1009. }
  1010. if (form.buffSalesMin != null || form.buffSalesMax != null) {
  1011. selectionParts.push(`销量 ${form.buffSalesMin ?? '?'}-${form.buffSalesMax ?? '?'}`)
  1012. }
  1013. selectionParts.push(`助手 ${ASSISTANT_LEVEL_LABELS[form.assistantLevel] || form.assistantLevel}`)
  1014. if (form.autoSelectionTime) selectionParts.push(`定时 ${form.autoSelectionTime}`)
  1015. if (form.selectionQuantity != null) selectionParts.push(`数量 ${form.selectionQuantity}`)
  1016. if (selectionParts.length > 0) {
  1017. lines.push(`选品: ${selectionParts.join(' | ')}`)
  1018. }
  1019. // 第三行:扫货条件
  1020. const sweepParts: string[] = []
  1021. sweepParts.push(`助手 ${ASSISTANT_LEVEL_LABELS[form.sweepAssistantLevel] || form.sweepAssistantLevel}`)
  1022. sweepParts.push(`进程 ${form.sweepProcessCount}`)
  1023. if (sweepParts.length > 0) {
  1024. lines.push(`扫货: ${sweepParts.join(' | ')}`)
  1025. }
  1026. return lines.length > 0 ? lines.join('<br>') : '暂无配置'
  1027. })
  1028. const running = computed(() => buffSweepStore.selectionRunning)
  1029. const tableLoading = ref(false)
  1030. const selectionList = ref<any[]>([])
  1031. const selectionSearchKeyword = ref('')
  1032. const filteredSelectionList = computed(() => {
  1033. if (!selectionSearchKeyword.value.trim()) {
  1034. return selectionList.value
  1035. }
  1036. const keyword = selectionSearchKeyword.value.trim().toLowerCase()
  1037. return selectionList.value.filter((item) => {
  1038. const idMatch = String(item.goods_id || item.id || '').toLowerCase().includes(keyword)
  1039. const nameMatch = (item.goods_name || item.name || '').toLowerCase().includes(keyword)
  1040. return idMatch || nameMatch
  1041. })
  1042. })
  1043. const onSelectionSearch = () => {
  1044. // 搜索时回到第一页
  1045. tablePagination.current = 1
  1046. }
  1047. const selectionSelectedRowKeys = ref<(string | number)[]>([])
  1048. const selectionDeleting = ref(false)
  1049. const batchImportLoading = ref(false)
  1050. const sweepRunning = computed(() => buffSweepStore.sweepRunning)
  1051. const switchingStatus = computed(() => buffSweepStore.switchingStatus)
  1052. /** 开始选品 / 开始扫货 前置清理阶段,用于按钮 loading 区分 */
  1053. const buffPrepareFor = ref<'selection' | 'sweep' | null>(null)
  1054. const sweepTableLoading = ref(false)
  1055. const sweepList = ref<any[]>([])
  1056. const sweepSearchKeyword = ref('')
  1057. const exportingSweepExcel = ref(false)
  1058. const filteredSweepList = computed(() => {
  1059. if (!sweepSearchKeyword.value.trim()) {
  1060. return sweepList.value
  1061. }
  1062. const keyword = sweepSearchKeyword.value.trim().toLowerCase()
  1063. return sweepList.value.filter((item) => {
  1064. const idMatch = String(item.goods_id || item.id || '').toLowerCase().includes(keyword)
  1065. const nameMatch = (item.goods_name || item.name || '').toLowerCase().includes(keyword)
  1066. return idMatch || nameMatch
  1067. })
  1068. })
  1069. const paginatedSweepList = computed(() => {
  1070. const start = (sweepPagination.current - 1) * sweepPagination.pageSize
  1071. const end = start + sweepPagination.pageSize
  1072. return filteredSweepList.value.slice(start, end)
  1073. })
  1074. const filteredSweepColumns = computed(() => {
  1075. return sweepColumns.filter(col => col.key !== 'listing_id')
  1076. })
  1077. const formatSweepTime = (timestamp: number) => {
  1078. if (!timestamp) return '—'
  1079. const date = new Date(timestamp * 1000)
  1080. return date.toLocaleString('zh-CN', {
  1081. year: 'numeric',
  1082. month: '2-digit',
  1083. day: '2-digit',
  1084. hour: '2-digit',
  1085. minute: '2-digit',
  1086. second: '2-digit',
  1087. hour12: false
  1088. })
  1089. }
  1090. const exportSweepTxt = () => {
  1091. const data = sweepList.value
  1092. if (data.length === 0) {
  1093. message.warning('暂无数据可导出')
  1094. return
  1095. }
  1096. const exportData = data.map((item, index) => ({
  1097. '序号': index + 1,
  1098. '饰品ID': item.goods_id || '',
  1099. '饰品名称': item.goods_name || '',
  1100. '区间': item.wear_range || '',
  1101. '已购磨损值': item.purchase_wear || '',
  1102. '目标价格(美元)': item.target_price_usd || '',
  1103. '成交价格(美元)': item.purchase_price_usd || '',
  1104. '成交价格(人民币)': item.purchase_price_cny || '',
  1105. '购买账号': item.steam_account || '',
  1106. '扫货时间': formatSweepTime(item.created_at)
  1107. }))
  1108. const txtContent = JSON.stringify(exportData, null, 2)
  1109. const blob = new Blob(['\ufeff' + txtContent], { type: 'text/plain;charset=utf-8' })
  1110. const url = URL.createObjectURL(blob)
  1111. const link = document.createElement('a')
  1112. link.href = url
  1113. link.download = `扫货成功数据_${new Date().toISOString().slice(0, 10)}.txt`
  1114. link.click()
  1115. URL.revokeObjectURL(url)
  1116. message.success(`已导出 ${data.length} 条数据`)
  1117. }
  1118. const exportSweepExcel = async () => {
  1119. const data = sweepList.value
  1120. if (data.length === 0) {
  1121. message.warning('暂无数据可导出')
  1122. return
  1123. }
  1124. exportingSweepExcel.value = true
  1125. try {
  1126. const XLSX = await import('xlsx')
  1127. const exportData = data.map((item, index) => ({
  1128. '序号': index + 1,
  1129. '饰品ID': item.goods_id || '',
  1130. '饰品名称': item.goods_name || '',
  1131. '区间': item.wear_range || '',
  1132. '已购磨损值': item.purchase_wear || '',
  1133. '目标价格(美元)': item.target_price_usd || '',
  1134. '成交价格(美元)': item.purchase_price_usd || '',
  1135. '成交价格(人民币)': item.purchase_price_cny || '',
  1136. '购买账号': item.steam_account || '',
  1137. '扫货时间': formatSweepTime(item.created_at)
  1138. }))
  1139. const ws = XLSX.utils.json_to_sheet(exportData)
  1140. const wb = XLSX.utils.book_new()
  1141. XLSX.utils.book_append_sheet(wb, ws, '扫货成功数据')
  1142. XLSX.writeFile(wb, `扫货成功数据_${new Date().toISOString().slice(0, 10)}.xlsx`)
  1143. message.success(`已导出 ${data.length} 条数据`)
  1144. } catch (err) {
  1145. console.error('导出Excel失败:', err)
  1146. message.error('导出Excel失败')
  1147. } finally {
  1148. exportingSweepExcel.value = false
  1149. }
  1150. }
  1151. type BoundSteamApiAccount = {
  1152. id: number
  1153. steam_username: string
  1154. steam_id?: string
  1155. is_active: boolean
  1156. }
  1157. type BoundSteamLocalAccount = {
  1158. steam_account?: string
  1159. steamLoginSecure?: string
  1160. card_rate?: number | null
  1161. }
  1162. type BoundSteamRow = {
  1163. id: number
  1164. steam_username: string
  1165. steam_id: string
  1166. hasSteamLoginSecure: boolean
  1167. cardRate?: number | null
  1168. is_active?: boolean
  1169. }
  1170. const boundSteamLoading = ref(false)
  1171. const boundSteamAccounts = ref<BoundSteamRow[]>([])
  1172. const boundSteamBalanceMap = ref<Record<string, { amount: string; currency: string }>>({})
  1173. const boundSteamBalanceQueryUsername = ref<string | null>(null)
  1174. const boundSteamReloginUsername = ref<string | null>(null)
  1175. const boundSteamRefreshingAll = ref(false)
  1176. const boundSteamBalanceTimer = ref<ReturnType<typeof setInterval> | null>(null)
  1177. const deferredBoundSteamLoadTimer = ref<ReturnType<typeof setTimeout> | null>(null)
  1178. // 余额查询失败计数器:连续查询失败/余额为0的次数
  1179. const balanceFailCountMap = ref<Record<string, number>>({})
  1180. // 自动登录锁:防止同一账号同时触发多次自动登录
  1181. const autoReloginLock = ref<Record<string, boolean>>({})
  1182. // 自动登录中的账号列表,用于UI提示
  1183. const autoReloginActiveUsernames = ref<Set<string>>(new Set())
  1184. // 检查账号是否在自动登录中
  1185. function isAutoRelogining(steamUsername: string): boolean {
  1186. return autoReloginActiveUsernames.value.has(steamUsername)
  1187. }
  1188. // 获取账号失败计数
  1189. function getFailCount(steamUsername: string): number {
  1190. return balanceFailCountMap.value[steamUsername] || 0
  1191. }
  1192. function notifyBuffStatusChange() {
  1193. emit('status-change', {
  1194. selectionRunning: buffSweepStore.selectionRunning,
  1195. sweepRunning: buffSweepStore.sweepRunning,
  1196. hasSelectionData: selectionList.value.length > 0,
  1197. switchingStatus: buffSweepStore.switchingStatus
  1198. })
  1199. }
  1200. // 选品数据表格定时刷新:点击「开始选品」后轮询 selection-result,请求单结束后停止
  1201. const selectionListPollTimer = ref<ReturnType<typeof setInterval> | null>(null)
  1202. const sweepPollTimer = ref<ReturnType<typeof setInterval> | null>(null)
  1203. const sweepPollConsecutiveErrors = ref(0) // 扫货轮询连续错误计数
  1204. // 扫货成功列表定时刷新:每分钟刷新一次
  1205. const sweepResultRefreshTimer = ref<ReturnType<typeof setInterval> | null>(null)
  1206. const autoSelectionTimer = ref<ReturnType<typeof setInterval> | null>(null)
  1207. /**
  1208. * 允许自动扫货的选品请求单白名单:
  1209. * 仅用户点击「开始选品」或定时自动选品创建的 request_id 才可进入白名单。
  1210. * 历史数据加载、状态恢复等路径不会写入白名单,从而不会触发自动扫货。
  1211. */
  1212. const autoSweepAllowedSelectionRequestIds = ref<Set<number>>(new Set())
  1213. // 选品日志弹窗
  1214. const selectionLogModalVisible = ref(false)
  1215. // 确认弹窗
  1216. const confirmDialogVisible = ref(false)
  1217. const confirmDialogConfig = reactive({
  1218. title: '确认操作',
  1219. message: '确定要执行此操作吗?',
  1220. okText: '确定',
  1221. cancelText: '取消',
  1222. iconType: 'warning' as 'warning' | 'danger' | 'info',
  1223. okDanger: false,
  1224. onConfirm: null as (() => void) | null
  1225. })
  1226. function openConfirmDialog(config: {
  1227. title: string
  1228. message: string
  1229. okText?: string
  1230. cancelText?: string
  1231. iconType?: 'warning' | 'danger' | 'info'
  1232. okDanger?: boolean
  1233. onConfirm: () => void
  1234. }) {
  1235. confirmDialogConfig.title = config.title
  1236. confirmDialogConfig.message = config.message
  1237. confirmDialogConfig.okText = config.okText ?? '确定'
  1238. confirmDialogConfig.cancelText = config.cancelText ?? '取消'
  1239. confirmDialogConfig.iconType = config.iconType ?? 'warning'
  1240. confirmDialogConfig.okDanger = config.okDanger ?? false
  1241. confirmDialogConfig.onConfirm = config.onConfirm
  1242. confirmDialogVisible.value = true
  1243. }
  1244. function handleConfirmDialog() {
  1245. if (confirmDialogConfig.onConfirm) {
  1246. confirmDialogConfig.onConfirm()
  1247. }
  1248. confirmDialogVisible.value = false
  1249. }
  1250. const selectionLogLoading = ref(false)
  1251. const selectionLogPollTimer = ref<ReturnType<typeof setInterval> | null>(null)
  1252. const selectionLogBodyRef = ref<HTMLElement | null>(null)
  1253. const selectionLogAutoFollow = ref(true)
  1254. const selectionLogMaxSequenceNo = ref(0)
  1255. const selectionLogData = reactive<{
  1256. request_id: number | null
  1257. status: string | null
  1258. started_at: string | null
  1259. ended_at: string | null
  1260. logs: Array<{
  1261. created_at: string | null
  1262. goods_id: string
  1263. goods_name: string
  1264. result: 'selected' | 'not_selected' | 'stage'
  1265. message: string
  1266. wear_range?: string | null
  1267. price_cny?: number | null
  1268. price_usd?: number | null
  1269. }>
  1270. }>({
  1271. request_id: null,
  1272. status: null,
  1273. started_at: null,
  1274. ended_at: null,
  1275. logs: []
  1276. })
  1277. const sweepLogModalVisible = ref(false)
  1278. const sweepLogLoading = ref(false)
  1279. const sweepLogPollTimer = ref<ReturnType<typeof setInterval> | null>(null)
  1280. const sweepLogBodyRef = ref<HTMLElement | null>(null)
  1281. const sweepLogAutoFollow = ref(true)
  1282. // Worker 日志数据结构
  1283. interface WorkerLogEntry {
  1284. created_at: string | null
  1285. goods_id: string
  1286. goods_name: string
  1287. result: string
  1288. message: string
  1289. target_price_usd?: number | null
  1290. purchase_price_usd?: number | null
  1291. line_color?: string | null
  1292. purchase_wear?: number | null
  1293. listing_id?: string | null
  1294. api_name?: string
  1295. steam_account?: string
  1296. method?: string
  1297. url?: string
  1298. request_headers?: Record<string, string>
  1299. request_body?: Record<string, string> | null
  1300. response_status?: number
  1301. response_body?: string
  1302. elapsed_ms?: number
  1303. error?: string
  1304. }
  1305. interface WorkerLogState {
  1306. worker_index: number
  1307. logs: WorkerLogEntry[]
  1308. log_count: number
  1309. hasNewLogs: boolean
  1310. }
  1311. // Worker 列表和日志状态
  1312. const sweepWorkerList = reactive<WorkerLogState[]>([])
  1313. const sweepLogActiveWorkerIndex = ref(1) // 当前选中的线程索引
  1314. // 当前选中线程的日志(计算属性)
  1315. const currentWorkerLogs = computed(() => {
  1316. const worker = sweepWorkerList.find(w => w.worker_index === sweepLogActiveWorkerIndex.value)
  1317. return worker?.logs || []
  1318. })
  1319. function setSweepLogBodyRef(el: any) {
  1320. sweepLogBodyRef.value = el
  1321. }
  1322. const sweepLogCount = ref(0)
  1323. // API 详细日志展开状态管理
  1324. const expandedApiLogs = reactive(new Set<string>())
  1325. // API 详细日志字段类型
  1326. interface ApiLogFields {
  1327. api_name?: string
  1328. steam_account?: string
  1329. method?: string
  1330. url?: string
  1331. request_headers?: Record<string, string>
  1332. request_body?: Record<string, string> | null
  1333. response_status?: number
  1334. response_body?: string
  1335. elapsed_ms?: number
  1336. error?: string
  1337. }
  1338. const sweepLogData = reactive<{
  1339. request_id: number | null
  1340. status: string | null
  1341. started_at: string | null
  1342. ended_at: string | null
  1343. }>({
  1344. request_id: null,
  1345. status: null,
  1346. started_at: null,
  1347. ended_at: null,
  1348. })
  1349. function buildSweepLogEntryKey(entry: {
  1350. created_at: string | null
  1351. goods_id: string
  1352. goods_name: string
  1353. result: string
  1354. message: string
  1355. target_price_usd?: number | null
  1356. purchase_price_usd?: number | null
  1357. listing_id?: string | null
  1358. // API 日志字段
  1359. api_name?: string
  1360. steam_account?: string
  1361. method?: string
  1362. url?: string
  1363. elapsed_ms?: number
  1364. }) {
  1365. return [
  1366. entry.created_at ?? '',
  1367. entry.goods_id ?? '',
  1368. entry.goods_name ?? '',
  1369. entry.result ?? '',
  1370. entry.message ?? '',
  1371. String(entry.target_price_usd ?? ''),
  1372. String(entry.purchase_price_usd ?? ''),
  1373. String(entry.listing_id ?? ''),
  1374. // API 日志字段
  1375. entry.api_name ?? '',
  1376. entry.steam_account ?? '',
  1377. entry.method ?? '',
  1378. entry.url ?? '',
  1379. String(entry.elapsed_ms ?? '')
  1380. ].join('|')
  1381. }
  1382. function parseLogTimestamp(value: string | null | undefined): number {
  1383. if (!value) return 0
  1384. const normalized = value.includes('T') ? value : value.replace(' ', 'T')
  1385. const ts = Date.parse(normalized)
  1386. return Number.isNaN(ts) ? 0 : ts
  1387. }
  1388. function sortLogsByCreatedAtAsc<T extends { created_at: string | null }>(logs: T[]): T[] {
  1389. return [...logs].sort((a, b) => parseLogTimestamp(a.created_at) - parseLogTimestamp(b.created_at))
  1390. }
  1391. function mergeSweepLogsTerminalStyle(
  1392. currentLogs: Array<{
  1393. created_at: string | null
  1394. goods_id: string
  1395. goods_name: string
  1396. result: string
  1397. message: string
  1398. target_price_usd?: number | null
  1399. purchase_price_usd?: number | null
  1400. }>,
  1401. incomingLogs: Array<{
  1402. created_at: string | null
  1403. goods_id: string
  1404. goods_name: string
  1405. result: string
  1406. message: string
  1407. target_price_usd?: number | null
  1408. purchase_price_usd?: number | null
  1409. }>
  1410. ) {
  1411. if (currentLogs.length === 0) return sortLogsByCreatedAtAsc(incomingLogs)
  1412. const keySet = new Set(currentLogs.map(buildSweepLogEntryKey))
  1413. const appendOnly = incomingLogs.filter((item) => !keySet.has(buildSweepLogEntryKey(item)))
  1414. if (appendOnly.length === 0) return currentLogs
  1415. return [...currentLogs, ...sortLogsByCreatedAtAsc(appendOnly)]
  1416. }
  1417. function isSweepLogNearBottom(el: HTMLElement, threshold = 24) {
  1418. return el.scrollHeight - (el.scrollTop + el.clientHeight) <= threshold
  1419. }
  1420. function onSweepLogBodyScroll() {
  1421. const el = sweepLogBodyRef.value
  1422. if (!el) return
  1423. sweepLogAutoFollow.value = isSweepLogNearBottom(el)
  1424. }
  1425. function scrollSweepLogToBottom(force = false) {
  1426. const el = sweepLogBodyRef.value
  1427. if (!el) return
  1428. if (!force && !sweepLogAutoFollow.value) return
  1429. el.scrollTop = el.scrollHeight
  1430. }
  1431. function onSweepLogFollowChange(checked: boolean) {
  1432. sweepLogAutoFollow.value = checked
  1433. if (!checked) return
  1434. nextTick(() => {
  1435. scrollSweepLogToBottom(true)
  1436. })
  1437. }
  1438. const tablePagination = reactive({
  1439. current: 1,
  1440. pageSize: 10,
  1441. total: 0,
  1442. showSizeChanger: true,
  1443. showTotal: (total: number) => `共 ${total} 条`
  1444. })
  1445. const sweepPagination = reactive({
  1446. current: 1,
  1447. pageSize: 10,
  1448. total: computed(() => filteredSweepList.value.length),
  1449. showSizeChanger: true,
  1450. showTotal: (total: number) => `共 ${total} 条`,
  1451. simple: false,
  1452. showQuickJumper: true
  1453. })
  1454. const columns = [
  1455. { title: '序号', key: 'index', width: 60, minWidth: 60, align: 'center' as const, resizable: true },
  1456. { title: '饰品图片', key: 'icon', width: 80, minWidth: 80, align: 'center' as const, resizable: true },
  1457. { title: '饰品ID', dataIndex: 'goods_id', key: 'goods_id', minWidth: 80, resizable: true },
  1458. { title: '饰品名称', dataIndex: 'goods_name', key: 'goods_name', minWidth: 150, ellipsis: true, resizable: true },
  1459. { title: '区间', dataIndex: 'wear_range', key: 'wear_range', minWidth: 60, resizable: true },
  1460. { title: '价格(人民币)', dataIndex: 'price_cny', key: 'price_cny', minWidth: 80, align: 'center' as const, resizable: true },
  1461. { title: '价格(美元)', dataIndex: 'price_usd', key: 'price_usd', minWidth: 70, align: 'center' as const, resizable: true },
  1462. { title: '选品时间', dataIndex: 'created_at', key: 'created_at', minWidth: 160, resizable: true },
  1463. { title: '操作', key: 'action', width: 80, minWidth: 80, align: 'center' as const, resizable: true }
  1464. ]
  1465. const sweepColumns = [
  1466. { title: '序号', key: 'index', width: 60, minWidth: 60, align: 'center' as const, resizable: true },
  1467. { title: '饰品图片', key: 'icon', width: 80, minWidth: 80, align: 'center' as const, resizable: true },
  1468. { title: '饰品ID', dataIndex: 'goods_id', key: 'goods_id', minWidth: 100, resizable: true },
  1469. { title: '物品id', dataIndex: 'listing_id', key: 'listing_id', minWidth: 150, resizable: true },
  1470. { title: '饰品名称', dataIndex: 'goods_name', key: 'goods_name', minWidth: 150, ellipsis: true, resizable: true },
  1471. { title: '区间', dataIndex: 'wear_range', key: 'wear_range', minWidth: 80, resizable: true },
  1472. { title: '已购磨损值', dataIndex: 'purchase_wear', key: 'purchase_wear', minWidth: 100, align: 'center' as const, resizable: true },
  1473. { title: '目标价格(美元)', dataIndex: 'target_price_usd', key: 'target_price_usd', minWidth: 110, align: 'center' as const, resizable: true },
  1474. { title: '成交价格(美元)', dataIndex: 'purchase_price_usd', key: 'purchase_price_usd', minWidth: 110, align: 'center' as const, resizable: true },
  1475. { title: '成交价格(人民币)', dataIndex: 'purchase_price_cny', key: 'purchase_price_cny', minWidth: 110, align: 'center' as const, resizable: true },
  1476. { title: '购买账号', dataIndex: 'steam_account', key: 'steam_account', minWidth: 120, resizable: true },
  1477. { title: '扫货时间', dataIndex: 'created_at', key: 'created_at', minWidth: 160, resizable: true }
  1478. ]
  1479. const boundSteamColumns = [
  1480. { title: 'Steam ID', dataIndex: 'steam_id', key: 'steam_id', width: 150 },
  1481. { title: 'Steam 账号', dataIndex: 'steam_username', key: 'steam_username', width: 120 },
  1482. { title: '参与扫货', key: 'is_active', width: 100, align: 'center' as const },
  1483. { title: '余额', key: 'balance', width: 120 },
  1484. { title: '充值卡价比例', key: 'cardRate', width: 100 },
  1485. { title: '状态', key: 'status', width: 120 },
  1486. { title: '操作', key: 'action', width: 180 }
  1487. ]
  1488. /** 配置中的排除/指定 id 可能为字符串或数组,统一为换行分隔字符串 */
  1489. function normalizeIdsField(v: unknown): string {
  1490. if (v == null) return ''
  1491. if (typeof v === 'string') return v
  1492. if (Array.isArray(v)) return v.map((x) => String(x)).join('\n')
  1493. return String(v)
  1494. }
  1495. function getConfigPayload() {
  1496. // 将 { label, value }[] 转换成 value[] 后再提交
  1497. const toValueArr = (key: string): string[] => {
  1498. const arr = form[key as keyof typeof form] as { label: string; value: string }[]
  1499. // 修复:先确保是普通对象(而非字符串),再使用 'in' 操作符检查属性
  1500. if (arr && arr.length > 0 && arr[0] &&
  1501. typeof arr[0] === 'object' && !Array.isArray(arr[0]) && 'value' in arr[0]) {
  1502. return arr.map(i => i.value)
  1503. }
  1504. return arr as string[] ?? []
  1505. }
  1506. return {
  1507. rifle: toValueArr('rifle'),
  1508. pistol: toValueArr('pistol'),
  1509. smg: toValueArr('smg'),
  1510. shotgun: toValueArr('shotgun'),
  1511. machinegun: toValueArr('machinegun'),
  1512. quality: toValueArr('quality'),
  1513. category: toValueArr('category'),
  1514. exterior: toValueArr('exterior'),
  1515. collection: toValueArr('collection'),
  1516. excludeIds: normalizeIdsField(form.excludeIds),
  1517. specifiedIds: normalizeIdsField(form.specifiedIds),
  1518. steamCardRatio: form.steamCardRatio,
  1519. profitRate: form.profitRate,
  1520. buffPriceMin: form.buffPriceMin,
  1521. buffPriceMax: form.buffPriceMax,
  1522. buffSalesMin: form.buffSalesMin,
  1523. buffSalesMax: form.buffSalesMax,
  1524. assistantLevel: form.assistantLevel,
  1525. autoSelectionTime: form.autoSelectionTime,
  1526. selectionQuantity: form.selectionQuantity,
  1527. sweepAssistantLevel: form.sweepAssistantLevel,
  1528. sweepProcessCount: form.sweepProcessCount
  1529. }
  1530. }
  1531. function applyConfigToForm(data: Record<string, any>) {
  1532. if (!data) return
  1533. // 将 string[] 转换成 { label, value }[] 用于 rifle/pistol/smg/shotgun/machinegun 等
  1534. const convertArr = (key: string) => {
  1535. if (!data[key]) return
  1536. const raw = data[key]
  1537. const options = filterOptions[key as keyof typeof filterOptions] as { label: string; value: string }[]
  1538. if (Array.isArray(raw) && options && options.length > 0) {
  1539. const labelMap = new Map(options.map(o => [o.value, o.label]))
  1540. form[key as keyof typeof form] = raw.map((v: string) => ({
  1541. label: labelMap.get(v) ?? v,
  1542. value: v
  1543. }))
  1544. } else {
  1545. form[key as keyof typeof form] = raw
  1546. }
  1547. }
  1548. convertArr('rifle')
  1549. convertArr('pistol')
  1550. convertArr('smg')
  1551. convertArr('shotgun')
  1552. convertArr('machinegun')
  1553. convertArr('quality')
  1554. convertArr('category')
  1555. convertArr('exterior')
  1556. convertArr('collection')
  1557. const excludeVal = data.excludeIds ?? data.exclude_ids ?? (data as Record<string, unknown>).excludeids
  1558. if (excludeVal != null) form.excludeIds = normalizeIdsField(excludeVal)
  1559. const specifiedVal = data.specifiedIds ?? data.specified_ids ?? (data as Record<string, unknown>).specifiedids
  1560. if (specifiedVal != null) form.specifiedIds = normalizeIdsField(specifiedVal)
  1561. if (data.steamCardRatio != null) form.steamCardRatio = data.steamCardRatio
  1562. if (data.profitRate != null) form.profitRate = data.profitRate
  1563. if (data.buffPriceMin != null) form.buffPriceMin = data.buffPriceMin
  1564. if (data.buffPriceMax != null) form.buffPriceMax = data.buffPriceMax
  1565. if (data.buffSalesMin != null) form.buffSalesMin = data.buffSalesMin
  1566. if (data.buffSalesMax != null) form.buffSalesMax = data.buffSalesMax
  1567. if (data.assistantLevel) form.assistantLevel = data.assistantLevel
  1568. if (data.autoSelectionTime != null) form.autoSelectionTime = data.autoSelectionTime
  1569. if (data.selectionQuantity != null) form.selectionQuantity = data.selectionQuantity
  1570. if (data.sweepAssistantLevel) form.sweepAssistantLevel = data.sweepAssistantLevel
  1571. if (data.sweepProcessCount != null && Number.isFinite(Number(data.sweepProcessCount))) {
  1572. const n = Math.floor(Number(data.sweepProcessCount))
  1573. const maxWorkerCount = configStore.config.buff_sweep_max_worker_count ?? 32
  1574. form.sweepProcessCount = n >= 1 ? Math.min(maxWorkerCount, n) : 3
  1575. }
  1576. }
  1577. function getStoredConditions(): string | null {
  1578. try {
  1579. return localStorage.getItem(STORAGE_KEY)
  1580. } catch {
  1581. return null
  1582. }
  1583. }
  1584. async function loadConditions() {
  1585. const sessionId = authStore.sessionId
  1586. const machineCode = authStore.machineCode
  1587. if (sessionId && machineCode) {
  1588. try {
  1589. const res = await secureApiClient.getBuffSelectionConfig(sessionId, machineCode)
  1590. if (res.code === 200 && res.data?.defaultConfig) {
  1591. applyConfigToForm(res.data.defaultConfig)
  1592. return
  1593. }
  1594. } catch (e) {
  1595. console.error('加载云端配置失败,回退本地:', e)
  1596. }
  1597. }
  1598. const raw = getStoredConditions()
  1599. if (!raw) return
  1600. try {
  1601. const data = JSON.parse(raw)
  1602. applyConfigToForm(data)
  1603. } catch (e) {
  1604. console.error('加载保存的配置失败:', e)
  1605. }
  1606. }
  1607. /** 延迟保存:用户修改配置后防抖写入本地/云端,避免每次输入都请求 */
  1608. function scheduleSaveConditions() {
  1609. if (!initialLoadDone.value) return
  1610. if (configSaveTimer) clearTimeout(configSaveTimer)
  1611. configSaveTimer = setTimeout(() => {
  1612. configSaveTimer = null
  1613. doSaveConditions(true)
  1614. }, DEBOUNCE_SAVE_MS)
  1615. }
  1616. /** 执行保存;isAutoSave 为 true 时为自动保存(提示文案更简短) */
  1617. async function doSaveConditions(isAutoSave: boolean) {
  1618. const data = getConfigPayload()
  1619. try {
  1620. localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
  1621. const sessionId = authStore.sessionId
  1622. const machineCode = authStore.machineCode
  1623. if (sessionId && machineCode) {
  1624. const res = await secureApiClient.saveBuffSelectionConfig({
  1625. session_id: sessionId,
  1626. machine_code: machineCode,
  1627. name: '默认',
  1628. config_json: data,
  1629. is_default: true
  1630. })
  1631. if (res.code === 200) {
  1632. message.success(isAutoSave ? '配置已自动保存' : '配置信息已保存并同步到云端', isAutoSave ? 1.5 : undefined)
  1633. return
  1634. }
  1635. if (!isAutoSave) message.warning(res.msg || '云端保存失败,已保存到本地')
  1636. return
  1637. }
  1638. message.success(
  1639. isAutoSave ? '配置已自动保存' : '配置信息已保存到本地,登录后可同步到云端',
  1640. isAutoSave ? 1.5 : undefined
  1641. )
  1642. } catch (e) {
  1643. message.error('保存失败')
  1644. }
  1645. }
  1646. /** 用户点击「保存配置信息」时立即保存 */
  1647. async function saveConditions() {
  1648. if (buffSweepStore.selectionRunning || buffSweepStore.switchingStatus != null) {
  1649. return
  1650. }
  1651. if (form.steamCardRatio == null) {
  1652. message.warning('请填写 Steam 卡价比例')
  1653. return
  1654. }
  1655. if (form.profitRate == null) {
  1656. message.warning('请填写利润率')
  1657. return
  1658. }
  1659. if (form.steamCardRatio < 4) {
  1660. message.warning('Steam 卡价比例不能小于 4,请重新填写')
  1661. return
  1662. }
  1663. // 验证系统配置限制
  1664. const maxWorkerCount = configStore.config.buff_sweep_max_worker_count ?? 32
  1665. const minSteamCardRatio = configStore.config.buff_steam_card_ratio_min ?? 4
  1666. if (form.sweepProcessCount > maxWorkerCount) {
  1667. message.error(`保存失败:扫货线程数不能超过系统配置的最大值 ${maxWorkerCount}`)
  1668. return
  1669. }
  1670. if (form.steamCardRatio < minSteamCardRatio) {
  1671. message.error(`保存失败:Steam卡价比例不能小于系统配置的最小值 ${minSteamCardRatio}`)
  1672. return
  1673. }
  1674. // 验证选品数量
  1675. const maxSelectionQuantity = configStore.config.buff_selection_max_quantity ?? 300
  1676. if (form.selectionQuantity != null && form.selectionQuantity > maxSelectionQuantity) {
  1677. message.error(`保存失败:选品数量不能超过系统配置的最大值 ${maxSelectionQuantity}`)
  1678. return
  1679. }
  1680. await doSaveConditions(false)
  1681. }
  1682. /** 监听运行状态变化 */
  1683. watch([() => buffSweepStore.selectionRunning, () => buffSweepStore.sweepRunning, () => buffSweepStore.switchingStatus, () => selectionList.value.length], notifyBuffStatusChange, { immediate: true })
  1684. // 监听扫货日志弹窗关闭,确保停止轮询
  1685. watch(() => sweepLogModalVisible.value, (isOpen) => {
  1686. if (!isOpen) {
  1687. stopSweepLogPoll()
  1688. sweepLogAutoFollow.value = true
  1689. }
  1690. })
  1691. async function pickTxtFile(): Promise<string | null> {
  1692. const selected = await openDialog({
  1693. multiple: false,
  1694. filters: [{ name: '文本', extensions: ['txt'] }]
  1695. })
  1696. if (selected && typeof selected === 'string') return selected
  1697. if (Array.isArray(selected) && selected.length > 0) return selected[0]
  1698. return null
  1699. }
  1700. function rowKey(record: any) {
  1701. if (record?.id != null) return record.id
  1702. if (record?.goods_id) {
  1703. return `${record.goods_id}-${record.created_at ?? ''}-${record.wear_range ?? ''}`
  1704. }
  1705. return `${record?.goods_name ?? 'unknown'}-${record?.created_at ?? ''}`
  1706. }
  1707. function onSelectionListSelectionChange(keys: (string | number)[]) {
  1708. selectionSelectedRowKeys.value = keys
  1709. }
  1710. function onSelectionTableChange(pagination: any) {
  1711. tablePagination.current = pagination?.current || 1
  1712. tablePagination.pageSize = pagination?.pageSize || 10
  1713. }
  1714. function getSelectionItemId(record: any): number | null {
  1715. const itemId = Number(record?.id)
  1716. return Number.isFinite(itemId) && itemId > 0 ? itemId : null
  1717. }
  1718. function sweepRowKey(record: any) {
  1719. // 优先使用 listing_id 作为唯一行键(每个购买记录的唯一标识)
  1720. if (record?.listing_id) {
  1721. return record.listing_id
  1722. }
  1723. // 其次使用 selection_item_id + created_at 组合(选品ID + 购买时间)
  1724. if (record?.selection_item_id && record?.created_at) {
  1725. return `${record.selection_item_id}-${record.created_at}`
  1726. }
  1727. // 兜底方案:使用 goods_id + created_at + purchase_price_usd
  1728. if (record?.goods_id) {
  1729. return `${record.goods_id}-${record.created_at ?? ''}-${record.purchase_price_usd ?? ''}`
  1730. }
  1731. return `${record?.goods_name ?? 'unknown'}-${record?.created_at ?? ''}`
  1732. }
  1733. /** 格式化价格(人民币/美元) */
  1734. function formatPrice(val: unknown): string {
  1735. if (val == null || val === '') return '—'
  1736. const n = Number(val)
  1737. if (Number.isNaN(n)) return '—'
  1738. return n.toFixed(2)
  1739. }
  1740. function formatCurrencyPrice(val: unknown, symbol: string): string {
  1741. const price = formatPrice(val)
  1742. return price === '—' ? '—' : `${symbol}${price}`
  1743. }
  1744. function formatWearValue(val: unknown): string {
  1745. if (val == null || val === '') return '—'
  1746. return String(val)
  1747. }
  1748. /** 格式化选品时间 */
  1749. function formatSelectionTime(val: unknown): string {
  1750. if (val == null || val === '') return '—'
  1751. const d = dayjs(val as string)
  1752. return d.isValid() ? d.format('YYYY-MM-DD HH:mm:ss') : String(val)
  1753. }
  1754. function formatSteamBalance(steamUsername: string): string {
  1755. const balance = boundSteamBalanceMap.value[steamUsername]
  1756. if (!balance) return '—'
  1757. const amount = parseInt(balance.amount, 10)
  1758. if (Number.isNaN(amount) || amount === 0) return '—'
  1759. const dollars = (amount / 100).toFixed(2)
  1760. return balance.currency === 'USD' ? `$${dollars}` : `${balance.currency} ${dollars}`
  1761. }
  1762. async function refreshSingleSteamBalance(steamUsername: string, silent: boolean): Promise<boolean> {
  1763. try {
  1764. const raw = await invoke<string>('get_steam_wallet_balance', { steamUsername })
  1765. const data = JSON.parse(raw) as { amount: string; currency: string }
  1766. boundSteamBalanceMap.value = {
  1767. ...boundSteamBalanceMap.value,
  1768. [steamUsername]: { amount: data.amount, currency: data.currency }
  1769. }
  1770. // 查询成功:判断余额是否大于0
  1771. const amount = parseInt(data.amount, 10)
  1772. if (amount > 0) {
  1773. // 余额大于0:重置失败计数
  1774. balanceFailCountMap.value = {
  1775. ...balanceFailCountMap.value,
  1776. [steamUsername]: 0
  1777. }
  1778. } else {
  1779. // 余额为0:增加失败计数(可能是登录失效的迹象)
  1780. const currentFail = balanceFailCountMap.value[steamUsername] || 0
  1781. const newFail = currentFail + 1
  1782. balanceFailCountMap.value = {
  1783. ...balanceFailCountMap.value,
  1784. [steamUsername]: newFail
  1785. }
  1786. // 达到阈值(10次),触发自动重新登录
  1787. if (newFail >= 10 && !autoReloginLock.value[steamUsername]) {
  1788. // 在后台静默触发自动登录,不阻塞当前流程
  1789. tryAutoReloginIfNeeded(steamUsername)
  1790. }
  1791. }
  1792. return true
  1793. } catch (error: any) {
  1794. // 查询异常:增加失败计数
  1795. const currentFail = balanceFailCountMap.value[steamUsername] || 0
  1796. const newFail = currentFail + 1
  1797. balanceFailCountMap.value = {
  1798. ...balanceFailCountMap.value,
  1799. [steamUsername]: newFail
  1800. }
  1801. if (!silent) {
  1802. const errText = error?.message || error?.toString() || '查询余额失败'
  1803. message.error(errText.length > 80 ? `${errText.slice(0, 80)}...` : errText)
  1804. }
  1805. // 达到阈值(10次),触发自动重新登录
  1806. if (newFail >= 10 && !autoReloginLock.value[steamUsername]) {
  1807. tryAutoReloginIfNeeded(steamUsername)
  1808. }
  1809. return false
  1810. }
  1811. }
  1812. /**
  1813. * 自动尝试重新登录:当余额连续查询失败10次时触发
  1814. * 参考 SteamBindView 的 handleRelogin 逻辑,使用 verify_steam_login
  1815. */
  1816. async function tryAutoReloginIfNeeded(steamUsername: string) {
  1817. // 防止重复触发
  1818. if (autoReloginLock.value[steamUsername]) {
  1819. return
  1820. }
  1821. // 加锁
  1822. autoReloginLock.value = { ...autoReloginLock.value, [steamUsername]: true }
  1823. autoReloginActiveUsernames.value = new Set([...autoReloginActiveUsernames.value, steamUsername])
  1824. console.info('[AutoRelogin] 开始自动登录账号:', steamUsername)
  1825. try {
  1826. const verifyResult = await invoke<string>('verify_steam_login', { steamUsername })
  1827. const verify = JSON.parse(verifyResult)
  1828. if (!verify?.success) {
  1829. console.warn('[AutoRelogin] 账号', steamUsername, '自动登录失败:', verify?.message || '未知错误')
  1830. message.warning(`账号 ${steamUsername} 自动登录失败,请手动重新登录`)
  1831. return
  1832. }
  1833. // 登录成功
  1834. console.info('[AutoRelogin] 账号', steamUsername, '自动登录成功')
  1835. message.success(`账号 ${steamUsername} 已自动重新登录,正在刷新余额...`)
  1836. // 重新加载绑定账号列表(更新 hasSteamLoginSecure 状态),但不自动刷新余额
  1837. await loadBoundSteamAccounts(false)
  1838. // 立即重新查询该账号的余额
  1839. const success = await refreshSingleSteamBalance(steamUsername, false)
  1840. if (success) {
  1841. message.success(`账号 ${steamUsername} 余额已更新:${formatSteamBalance(steamUsername)}`)
  1842. } else {
  1843. message.warning(`账号 ${steamUsername} 登录成功,但余额查询失败,请稍后重试`)
  1844. }
  1845. } catch (err: any) {
  1846. const raw = err?.message || err?.toString() || '自动登录失败'
  1847. console.error('[AutoRelogin] 账号', steamUsername, '自动登录异常:', raw)
  1848. message.error(`账号 ${steamUsername} 自动登录异常:${raw.length > 50 ? raw.slice(0, 50) + '...' : raw}`)
  1849. } finally {
  1850. // 释放锁
  1851. const newLock = { ...autoReloginLock.value }
  1852. delete newLock[steamUsername]
  1853. autoReloginLock.value = newLock
  1854. // 从活跃列表中移除
  1855. const newActive = new Set(autoReloginActiveUsernames.value)
  1856. newActive.delete(steamUsername)
  1857. autoReloginActiveUsernames.value = newActive
  1858. }
  1859. }
  1860. async function refreshBoundSteamBalances(silent: boolean): Promise<{ successCount: number; failCount: number }> {
  1861. const tasks = boundSteamAccounts.value
  1862. .filter((account) => account.hasSteamLoginSecure)
  1863. .map((account) => refreshSingleSteamBalance(account.steam_username, silent))
  1864. const results = await Promise.all(tasks)
  1865. const successCount = results.filter(Boolean).length
  1866. return {
  1867. successCount,
  1868. failCount: results.length - successCount
  1869. }
  1870. }
  1871. async function handleRefreshAllBoundSteamBalances() {
  1872. if (boundSteamRefreshingAll.value || boundSteamLoading.value) return
  1873. if (!boundSteamAccounts.value.length) {
  1874. message.info('暂无可刷新的已绑定 Steam 账号')
  1875. return
  1876. }
  1877. boundSteamRefreshingAll.value = true
  1878. try {
  1879. const { successCount, failCount } = await refreshBoundSteamBalances(true)
  1880. if (failCount === 0) {
  1881. message.success(`余额刷新完成,共 ${successCount} 个账号`)
  1882. return
  1883. }
  1884. if (successCount === 0) {
  1885. message.warning('余额刷新失败,请稍后重试')
  1886. return
  1887. }
  1888. message.warning(`余额刷新完成,成功 ${successCount} 个,失败 ${failCount} 个`)
  1889. } finally {
  1890. boundSteamRefreshingAll.value = false
  1891. }
  1892. }
  1893. async function handleBoundSteamBalanceQuery(record: BoundSteamRow) {
  1894. if (!record.hasSteamLoginSecure) {
  1895. message.warning('该账号暂无登录凭证,请先在账号管理页完成验证绑定')
  1896. return
  1897. }
  1898. boundSteamBalanceQueryUsername.value = record.steam_username
  1899. const ok = await refreshSingleSteamBalance(record.steam_username, false)
  1900. boundSteamBalanceQueryUsername.value = null
  1901. if (ok) {
  1902. message.success(`余额已更新:${formatSteamBalance(record.steam_username)}`)
  1903. }
  1904. }
  1905. async function handleBoundSteamRelogin(record: BoundSteamRow) {
  1906. if (!record.hasSteamLoginSecure) {
  1907. message.warning('该账号暂无登录凭证,请先在账号管理页完成验证绑定')
  1908. return
  1909. }
  1910. boundSteamReloginUsername.value = record.steam_username
  1911. try {
  1912. const verifyRaw = await invoke<string>('verify_steam_login', { steamUsername: record.steam_username })
  1913. const verifyRes = JSON.parse(verifyRaw) as { success?: boolean; message?: string }
  1914. if (!verifyRes?.success) {
  1915. message.error(verifyRes?.message || '重新登录失败')
  1916. return
  1917. }
  1918. // 手动重新登录成功:重置失败计数并清除自动登录锁
  1919. balanceFailCountMap.value = {
  1920. ...balanceFailCountMap.value,
  1921. [record.steam_username]: 0
  1922. }
  1923. const newLock = { ...autoReloginLock.value }
  1924. delete newLock[record.steam_username]
  1925. autoReloginLock.value = newLock
  1926. // 重新加载绑定账号列表(更新 hasSteamLoginSecure 状态),不自动刷新所有余额
  1927. await loadBoundSteamAccounts(false)
  1928. // 立即重新查询该账号的余额
  1929. await refreshSingleSteamBalance(record.steam_username, true)
  1930. message.success('重新登录成功')
  1931. } catch (error: any) {
  1932. const errText = error?.message || error?.toString() || '重新登录失败'
  1933. message.error(errText.length > 80 ? `${errText.slice(0, 80)}...` : errText)
  1934. } finally {
  1935. boundSteamReloginUsername.value = null
  1936. }
  1937. }
  1938. async function handleBoundSteamCardRateChange(record: BoundSteamRow, rate: number | null) {
  1939. try {
  1940. await invoke('update_account_card_rate', {
  1941. steamUsername: record.steam_username,
  1942. cardRate: rate
  1943. })
  1944. const idx = boundSteamAccounts.value.findIndex(a => a.steam_username === record.steam_username)
  1945. if (idx !== -1) {
  1946. boundSteamAccounts.value[idx].cardRate = rate
  1947. }
  1948. message.success('充值卡价比例已保存')
  1949. } catch (err: any) {
  1950. message.error(err?.message || err?.toString() || '保存失败')
  1951. }
  1952. }
  1953. async function handleBoundSteamActiveChange(record: BoundSteamRow, isActive: boolean) {
  1954. try {
  1955. await invoke('update_account_is_active', {
  1956. steamUsername: record.steam_username,
  1957. isActive
  1958. })
  1959. const idx = boundSteamAccounts.value.findIndex(a => a.steam_username === record.steam_username)
  1960. if (idx !== -1) {
  1961. boundSteamAccounts.value[idx].is_active = isActive
  1962. }
  1963. message.success(isActive ? '已设置为参与扫货' : '已设置为不参与扫货')
  1964. } catch (err: any) {
  1965. message.error(err?.message || err?.toString() || '保存失败')
  1966. }
  1967. }
  1968. async function loadBoundSteamAccounts(autoRefreshBalances: boolean = true) {
  1969. const userId = authStore.userId
  1970. if (!userId) {
  1971. boundSteamAccounts.value = []
  1972. return
  1973. }
  1974. boundSteamLoading.value = true
  1975. try {
  1976. const [apiRaw, localRaw] = await Promise.all([
  1977. invoke<string>('get_steam_accounts', { userId }),
  1978. invoke<string>('list_project_steam_accounts').catch(() => '[]')
  1979. ])
  1980. const apiRes = JSON.parse(apiRaw) as { code?: number; data?: BoundSteamApiAccount[]; msg?: string }
  1981. if (apiRes.code !== 200) {
  1982. boundSteamAccounts.value = []
  1983. return
  1984. }
  1985. const localList = JSON.parse(localRaw) as BoundSteamLocalAccount[]
  1986. const localSecureSet = new Set(
  1987. localList
  1988. .filter((item) => item.steam_account && item.steamLoginSecure)
  1989. .map((item) => String(item.steam_account))
  1990. )
  1991. const localCardRateMap: Record<string, number | null> = {}
  1992. const localIsActiveMap: Record<string, boolean> = {}
  1993. for (const item of localList) {
  1994. if (item.steam_account) {
  1995. localCardRateMap[item.steam_account] = item.card_rate ?? null
  1996. const raw = item as Record<string, any>
  1997. localIsActiveMap[item.steam_account] = raw.is_active !== false
  1998. }
  1999. }
  2000. const boundList = (apiRes.data || [])
  2001. .filter((item) => !!item.steam_id && String(item.steam_id).trim() !== '')
  2002. .map((item) => ({
  2003. id: item.id,
  2004. steam_username: item.steam_username,
  2005. steam_id: String(item.steam_id),
  2006. hasSteamLoginSecure: localSecureSet.has(item.steam_username),
  2007. cardRate: localCardRateMap[item.steam_username] ?? null,
  2008. is_active: localIsActiveMap[item.steam_username] ?? true
  2009. }))
  2010. boundSteamAccounts.value = boundList
  2011. if (autoRefreshBalances) {
  2012. await refreshBoundSteamBalances(true)
  2013. }
  2014. } catch (error) {
  2015. console.error('加载已绑定 Steam 账号失败:', error)
  2016. boundSteamAccounts.value = []
  2017. } finally {
  2018. boundSteamLoading.value = false
  2019. }
  2020. }
  2021. function startBoundSteamBalancePoll() {
  2022. stopBoundSteamBalancePoll()
  2023. boundSteamBalanceTimer.value = setInterval(() => {
  2024. refreshBoundSteamBalances(true)
  2025. }, 3 * 60 * 1000)
  2026. }
  2027. function stopBoundSteamBalancePoll() {
  2028. if (boundSteamBalanceTimer.value) {
  2029. clearInterval(boundSteamBalanceTimer.value)
  2030. boundSteamBalanceTimer.value = null
  2031. }
  2032. }
  2033. /** 非关键模块延后加载:避免阻塞首屏核心数据渲染 */
  2034. function scheduleDeferredBoundSteamLoad() {
  2035. if (deferredBoundSteamLoadTimer.value) {
  2036. clearTimeout(deferredBoundSteamLoadTimer.value)
  2037. deferredBoundSteamLoadTimer.value = null
  2038. }
  2039. deferredBoundSteamLoadTimer.value = setTimeout(async () => {
  2040. deferredBoundSteamLoadTimer.value = null
  2041. await loadBoundSteamAccounts()
  2042. startBoundSteamBalancePoll()
  2043. }, 0)
  2044. }
  2045. /** 格式化单条选品日志:时间 , 饰品ID:xxx , 名称 , 选中/未选中 , [区间、¥、$] 或 未选中原因 */
  2046. function formatLogLine(entry: {
  2047. created_at: string | null
  2048. goods_id: string
  2049. goods_name: string
  2050. result: string
  2051. message: string
  2052. wear_range?: string | null
  2053. price_cny?: number | null
  2054. price_usd?: number | null
  2055. }): string {
  2056. const time = entry.created_at || '—'
  2057. if (entry.result === 'stage') {
  2058. return `${time} , ${entry.message || '阶段日志'}`
  2059. }
  2060. const idPart = `饰品ID:${entry.goods_id}`
  2061. const name = entry.goods_name || '—'
  2062. if (entry.result === 'selected') {
  2063. const range = entry.wear_range ?? '—'
  2064. const cny = entry.price_cny != null ? `¥${Number(entry.price_cny).toFixed(2)}` : '—'
  2065. const usd = entry.price_usd != null ? `$${Number(entry.price_usd).toFixed(2)}` : '—'
  2066. return `${time} , ${idPart} , ${name} , 选中 , ${range} , ${cny} , ${usd}`
  2067. }
  2068. const reason = entry.message || '未选中原因'
  2069. return `${time} , ${idPart} , ${name} , 未选中 , ${reason}`
  2070. }
  2071. function formatSweepLogLine(entry: {
  2072. created_at: string | null
  2073. goods_id: string
  2074. goods_name: string
  2075. result: string
  2076. message: string
  2077. target_price_usd?: number | null
  2078. purchase_price_usd?: number | null
  2079. purchase_wear?: number | null
  2080. listing_id?: string | null
  2081. // API 详细日志字段
  2082. api_name?: string
  2083. steam_account?: string
  2084. method?: string
  2085. url?: string
  2086. request_headers?: Record<string, string>
  2087. request_body?: Record<string, string> | null
  2088. response_status?: number
  2089. response_body?: string
  2090. elapsed_ms?: number
  2091. error?: string
  2092. }): string {
  2093. const time = formatSelectionTime(entry.created_at)
  2094. // 处理 API 详细日志类型
  2095. if (entry.result === 'api_detail' || entry.result === 'api_request' || entry.result === 'api_error') {
  2096. const apiName = entry.api_name || 'unknown'
  2097. const account = entry.steam_account || 'unknown'
  2098. const method = entry.method || 'UNKNOWN'
  2099. const status = entry.response_status ?? '—'
  2100. const elapsed = entry.elapsed_ms != null ? `${entry.elapsed_ms}ms` : '—'
  2101. // API 请求开始
  2102. if (entry.result === 'api_request') {
  2103. const urlShort = entry.url ? formatUrl(entry.url) : '—'
  2104. return `${time} , ${account} , [API-REQ] ${apiName} ${method} ${urlShort}`
  2105. }
  2106. // API 错误
  2107. if (entry.result === 'api_error') {
  2108. const errMsg = entry.error || entry.message || '未知错误'
  2109. return `${time} , ${account} , [API-ERR] ${apiName} ${method} -> ${status} (${elapsed}) - ${errMsg}`
  2110. }
  2111. // API 详情(成功/失败)
  2112. const urlShort = entry.url ? formatUrl(entry.url) : '—'
  2113. return `${time} , ${account} , [API] ${method} ${apiName} -> ${status} (${elapsed}) ${urlShort}`
  2114. }
  2115. if (entry.result === 'failed') {
  2116. const msg = (entry.message || '').trim()
  2117. return msg ? `${time} , ${msg}` : `${time} , 异常(无详情)`
  2118. }
  2119. if (entry.result === 'diag') {
  2120. const msg = (entry.message || '').trim()
  2121. return msg ? `${time} , ${msg}` : `${time} , (诊断日志无内容)`
  2122. }
  2123. if (entry.result === 'scan') {
  2124. const idPart = `饰品ID:${entry.goods_id}`
  2125. const name = entry.goods_name || '—'
  2126. const msg = (entry.message || '').trim()
  2127. return `${time} , ${idPart} , ${name} , ${msg}`
  2128. }
  2129. if (entry.result === 'wear') {
  2130. const idPart = `饰品ID:${entry.goods_id}`
  2131. const name = entry.goods_name || '—'
  2132. const msg = (entry.message || '').trim()
  2133. return `${time} , ${idPart} , ${name} , ${msg}`
  2134. }
  2135. if (entry.result === 'stage' || entry.result === 'matched') {
  2136. const idPart = entry.goods_id ? `饰品ID:${entry.goods_id}` : ''
  2137. const name = entry.goods_name || ''
  2138. const message = entry.message || '阶段日志'
  2139. if (idPart || name) {
  2140. return `${time} , ${idPart}${name ? ` , ${name}` : ''} , ${message}`
  2141. }
  2142. return `${time} , ${message}`
  2143. }
  2144. const idPart = `饰品ID:${entry.goods_id}`
  2145. const name = entry.goods_name || '—'
  2146. const target = entry.target_price_usd != null ? `$${Number(entry.target_price_usd).toFixed(2)}` : '—'
  2147. const deal = entry.purchase_price_usd != null ? `$${Number(entry.purchase_price_usd).toFixed(2)}` : '—'
  2148. if (entry.result === 'success') {
  2149. const detail = entry.message ? ` , ${entry.message}` : ''
  2150. return `${time} , ${idPart} , ${name} , 扫货成功 , 目标价 ${target} , 成交价 ${deal}${detail}`
  2151. }
  2152. const wearHint =
  2153. entry.purchase_wear != null && entry.purchase_wear !== undefined
  2154. ? ` , 最近检视磨损 ${String(entry.purchase_wear)}`
  2155. : ''
  2156. const tail = (entry.message || '已跳过').trim()
  2157. return `${time} , ${idPart} , ${name} , 目标价 ${target} , ${tail}${wearHint}`
  2158. }
  2159. /** 格式化 URL 用于显示(完整显示,不截断,方便手工调用) */
  2160. function formatUrl(url: string): string {
  2161. if (!url) return '—'
  2162. return url
  2163. }
  2164. /** 格式化响应内容用于显示(完整保留,方便手工调用API) */
  2165. function formatResponse(response: string): string {
  2166. if (!response) return '—'
  2167. return response
  2168. }
  2169. /** 判断是否为 API 详细日志 */
  2170. function isApiDetailLog(entry: {
  2171. result: string
  2172. api_name?: string
  2173. method?: string
  2174. url?: string
  2175. }): boolean {
  2176. return entry.result === 'api_detail' || entry.result === 'api_request' || entry.result === 'api_error'
  2177. }
  2178. /** 切换 API 日志展开状态 */
  2179. function toggleApiLogExpand(idx: number) {
  2180. if (expandedApiLogs.has(idx)) {
  2181. expandedApiLogs.delete(idx)
  2182. } else {
  2183. expandedApiLogs.add(idx)
  2184. }
  2185. }
  2186. /** 判断 HTTP 状态码是否成功 */
  2187. function isSuccessStatus(status?: number): boolean {
  2188. return status !== undefined && status >= 200 && status < 300
  2189. }
  2190. /** 格式化 JSON 用于显示 */
  2191. function formatJson(obj: Record<string, unknown> | string | null | undefined): string {
  2192. if (!obj) return '—'
  2193. if (typeof obj === 'string') {
  2194. try {
  2195. return JSON.stringify(JSON.parse(obj), null, 2)
  2196. } catch {
  2197. return obj
  2198. }
  2199. }
  2200. return JSON.stringify(obj, null, 2)
  2201. }
  2202. function isSweepPriceWearMatchedLog(entry: {
  2203. result: string
  2204. message: string
  2205. line_color?: string | null
  2206. }) {
  2207. const lc = (entry.line_color || '').trim().toLowerCase()
  2208. if (lc === '#52c41a') return true
  2209. if (entry.result === 'matched') return true
  2210. const message = (entry.message || '').trim()
  2211. if (!message) return false
  2212. // 兼容本地日志 + 后端回显日志:命中价格/磨损后进入尝试购买阶段。
  2213. if (message.includes('磨损命中目标区间')) return true
  2214. if (message.includes('价格命中候选') && message.includes('磨损查询')) return true
  2215. if (entry.result === 'wear' && message.includes('命中')) return true
  2216. return false
  2217. }
  2218. function isSweepPurchaseSuccessLog(entry: {
  2219. result: string
  2220. message: string
  2221. }) {
  2222. const message = (entry.message || '').trim()
  2223. if (entry.result === 'success') return true
  2224. if (!message) return false
  2225. // 兼容历史回显:有些旧日志可能 result 不标准,但 message 会带成功语义。
  2226. return message.includes('购买成功') || message.includes('扫货成功')
  2227. }
  2228. /** 与 .selection-log-lines 中原 CSS 颜色一致,供无 line_color 的旧数据回退 */
  2229. function sweepLogLineColorFallback(entry: {
  2230. result: string
  2231. message: string
  2232. diag_level?: string
  2233. }): string {
  2234. if (isSweepPurchaseSuccessLog(entry)) return '#ff4d4f'
  2235. if (isSweepPriceWearMatchedLog(entry)) return '#52c41a'
  2236. if (entry.result === 'failed') return '#fa8c16'
  2237. if (entry.result === 'diag') {
  2238. const lv = (entry as { diag_level?: string }).diag_level
  2239. if (lv === 'warn') return '#fa8c16'
  2240. if (lv === 'error') return '#ff4d4f'
  2241. return '#595959'
  2242. }
  2243. return '#000000'
  2244. }
  2245. function sweepLogLineStyle(entry: {
  2246. result: string
  2247. message: string
  2248. line_color?: string | null
  2249. }) {
  2250. const c = (entry.line_color || '').trim()
  2251. const color = c || sweepLogLineColorFallback(entry)
  2252. const bold = isSweepPurchaseSuccessLog(entry)
  2253. return {
  2254. color,
  2255. fontWeight: bold ? 600 : undefined
  2256. } as Record<string, string | number | undefined>
  2257. }
  2258. function normalizeStageLogMessage(rawMessage: string): string | null {
  2259. const messageText = (rawMessage || '').trim()
  2260. if (!messageText) return null
  2261. if (messageText.includes('过滤阶段:开始执行价格、智能选品与利润过滤')) return null
  2262. if (messageText.includes('计算阶段:开始逐个计算区间与目标价格')) return null
  2263. if (messageText.includes('计算阶段:结束,共入选')) return null
  2264. if (messageText.includes('检索阶段:完成')) return '检索阶段:完成'
  2265. return messageText
  2266. }
  2267. function sanitizeSelectionLogs(rawLogs: any[]): any[] {
  2268. if (!Array.isArray(rawLogs)) return []
  2269. return rawLogs
  2270. .map((entry: any) => {
  2271. if (entry?.result !== 'stage') return entry
  2272. const normalizedMessage = normalizeStageLogMessage(entry?.message || '')
  2273. if (!normalizedMessage) return null
  2274. return {
  2275. ...entry,
  2276. message: normalizedMessage
  2277. }
  2278. })
  2279. .filter((entry: any) => !!entry)
  2280. }
  2281. function applySelectionLogFallbackFromResult(resultData: any) {
  2282. if (!resultData) return
  2283. if (selectionLogData.started_at || selectionLogData.logs.length > 0) return
  2284. selectionLogData.request_id = resultData.request_id ?? null
  2285. selectionLogData.status = resultData.status ?? null
  2286. selectionLogData.started_at = resultData.started_at ?? null
  2287. selectionLogData.ended_at = resultData.ended_at ?? null
  2288. const items = Array.isArray(resultData.items) ? resultData.items : []
  2289. selectionLogData.logs = sortLogsByCreatedAtAsc(items.map((item: any) => ({
  2290. created_at: item.created_at ?? null,
  2291. goods_id: item.goods_id ?? '',
  2292. goods_name: item.goods_name ?? '',
  2293. result: 'selected',
  2294. message: '已入选',
  2295. wear_range: item.wear_range ?? null,
  2296. price_cny: item.price_cny ?? null,
  2297. price_usd: item.price_usd ?? null
  2298. })))
  2299. }
  2300. function buildSelectionLogEntryKey(entry: {
  2301. created_at: string | null
  2302. goods_id: string
  2303. goods_name: string
  2304. result: 'selected' | 'not_selected' | 'stage'
  2305. message: string
  2306. wear_range?: string | null
  2307. price_cny?: number | null
  2308. price_usd?: number | null
  2309. }) {
  2310. return [
  2311. entry.created_at ?? '',
  2312. entry.goods_id ?? '',
  2313. entry.goods_name ?? '',
  2314. entry.result ?? '',
  2315. entry.message ?? '',
  2316. entry.wear_range ?? '',
  2317. String(entry.price_cny ?? ''),
  2318. String(entry.price_usd ?? '')
  2319. ].join('|')
  2320. }
  2321. function mergeSelectionLogsTerminalStyle(
  2322. currentLogs: Array<{
  2323. created_at: string | null
  2324. goods_id: string
  2325. goods_name: string
  2326. result: 'selected' | 'not_selected' | 'stage'
  2327. message: string
  2328. wear_range?: string | null
  2329. price_cny?: number | null
  2330. price_usd?: number | null
  2331. }>,
  2332. incomingLogs: Array<{
  2333. created_at: string | null
  2334. goods_id: string
  2335. goods_name: string
  2336. result: 'selected' | 'not_selected' | 'stage'
  2337. message: string
  2338. wear_range?: string | null
  2339. price_cny?: number | null
  2340. price_usd?: number | null
  2341. }>
  2342. ) {
  2343. if (currentLogs.length === 0) return sortLogsByCreatedAtAsc(incomingLogs)
  2344. const keySet = new Set(currentLogs.map(buildSelectionLogEntryKey))
  2345. const appendOnly = incomingLogs.filter((item) => !keySet.has(buildSelectionLogEntryKey(item)))
  2346. if (appendOnly.length === 0) return currentLogs
  2347. return [...currentLogs, ...sortLogsByCreatedAtAsc(appendOnly)]
  2348. }
  2349. function isSelectionLogNearBottom(el: HTMLElement, threshold = 24) {
  2350. return el.scrollHeight - (el.scrollTop + el.clientHeight) <= threshold
  2351. }
  2352. function onSelectionLogBodyScroll() {
  2353. const el = selectionLogBodyRef.value
  2354. if (!el) return
  2355. selectionLogAutoFollow.value = isSelectionLogNearBottom(el)
  2356. }
  2357. function scrollSelectionLogToBottom(force = false) {
  2358. const el = selectionLogBodyRef.value
  2359. if (!el) return
  2360. if (!force && !selectionLogAutoFollow.value) return
  2361. el.scrollTop = el.scrollHeight
  2362. }
  2363. function onSelectionLogFollowChange(checked: boolean) {
  2364. selectionLogAutoFollow.value = checked
  2365. if (!checked) return
  2366. nextTick(() => {
  2367. scrollSelectionLogToBottom(true)
  2368. })
  2369. }
  2370. /** 拉取选品日志并更新 selectionLogData;返回当前 status */
  2371. async function fetchSelectionLog(showLoading = false): Promise<string | null> {
  2372. const sessionId = authStore.sessionId
  2373. const machineCode = authStore.machineCode
  2374. if (!sessionId || !machineCode) return null
  2375. if (showLoading) {
  2376. selectionLogLoading.value = true
  2377. }
  2378. try {
  2379. const res = await secureApiClient.getBuffSelectionLog(sessionId, machineCode, {
  2380. lastSequenceNo: selectionLogMaxSequenceNo.value > 0 ? selectionLogMaxSequenceNo.value : null,
  2381. limit: 200
  2382. })
  2383. if (res.code === 200 && res.data) {
  2384. const requestIdChanged =
  2385. selectionLogData.request_id !== null &&
  2386. selectionLogData.request_id !== res.data.request_id
  2387. selectionLogData.request_id = res.data.request_id
  2388. selectionLogData.status = res.data.status
  2389. selectionLogData.started_at = res.data.started_at
  2390. selectionLogData.ended_at = res.data.ended_at
  2391. const incomingLogs = sanitizeSelectionLogs(res.data.logs || [])
  2392. if (requestIdChanged) {
  2393. selectionLogData.logs = sortLogsByCreatedAtAsc(incomingLogs)
  2394. selectionLogAutoFollow.value = true
  2395. selectionLogMaxSequenceNo.value = res.data.max_sequence_no || 0
  2396. } else {
  2397. selectionLogData.logs = mergeSelectionLogsTerminalStyle(selectionLogData.logs, incomingLogs)
  2398. selectionLogMaxSequenceNo.value = Math.max(selectionLogMaxSequenceNo.value, res.data.max_sequence_no || 0)
  2399. }
  2400. if (!selectionLogData.started_at && selectionLogData.logs.length === 0) {
  2401. const fallbackRes = await secureApiClient.getBuffSelectionResult(sessionId, machineCode)
  2402. if (fallbackRes.code === 200 && fallbackRes.data) {
  2403. applySelectionLogFallbackFromResult(fallbackRes.data)
  2404. }
  2405. }
  2406. await nextTick()
  2407. scrollSelectionLogToBottom(requestIdChanged || showLoading)
  2408. return res.data.status
  2409. }
  2410. const fallbackRes = await secureApiClient.getBuffSelectionResult(sessionId, machineCode)
  2411. if (fallbackRes.code === 200 && fallbackRes.data) {
  2412. applySelectionLogFallbackFromResult(fallbackRes.data)
  2413. return fallbackRes.data.status
  2414. }
  2415. return null
  2416. } finally {
  2417. if (showLoading) {
  2418. selectionLogLoading.value = false
  2419. }
  2420. }
  2421. }
  2422. function stopSelectionLogPoll() {
  2423. if (selectionLogPollTimer.value) {
  2424. clearInterval(selectionLogPollTimer.value)
  2425. selectionLogPollTimer.value = null
  2426. }
  2427. }
  2428. function startSelectionLogFetchAndPoll() {
  2429. selectionLogData.request_id = null
  2430. selectionLogData.status = null
  2431. selectionLogData.started_at = null
  2432. selectionLogData.ended_at = null
  2433. selectionLogData.logs = []
  2434. selectionLogMaxSequenceNo.value = 0
  2435. selectionLogAutoFollow.value = true
  2436. fetchSelectionLog(true).then((status) => {
  2437. if (status === 'pending' || status === 'running') {
  2438. selectionLogPollTimer.value = setInterval(() => {
  2439. fetchSelectionLog(false).then((s) => {
  2440. if (s !== 'pending' && s !== 'running') stopSelectionLogPoll()
  2441. })
  2442. }, 1000)
  2443. }
  2444. })
  2445. }
  2446. function openSelectionLogModal() {
  2447. selectionLogModalVisible.value = true
  2448. stopSelectionLogPoll()
  2449. startSelectionLogFetchAndPoll()
  2450. }
  2451. function onSelectionLogModalOpenChange(open: boolean) {
  2452. if (!open) {
  2453. stopSelectionLogPoll()
  2454. selectionLogAutoFollow.value = true
  2455. }
  2456. }
  2457. async function fetchSweepLog(showLoading = false): Promise<string | null> {
  2458. if (showLoading) {
  2459. sweepLogLoading.value = true
  2460. }
  2461. try {
  2462. // 先获取 Worker 列表
  2463. const listRes = await secureApiClient.getSweepWorkerLogFiles()
  2464. console.log('[BuffSelectionView] fetchSweepLog worker list:', listRes)
  2465. if (listRes.code === 200 && listRes.data) {
  2466. const workers = listRes.data.workers || []
  2467. const requestId = listRes.data.request_id
  2468. // 更新 sweepLogData
  2469. sweepLogData.request_id = requestId
  2470. // 如果 Worker 列表为空,使用默认一个 Worker
  2471. if (workers.length === 0) {
  2472. sweepWorkerList.length = 0
  2473. sweepWorkerList.push({
  2474. worker_index: 1,
  2475. logs: [],
  2476. log_count: 0,
  2477. hasNewLogs: false
  2478. })
  2479. } else {
  2480. // 更新 Worker 列表(保留之前选中线程的日志)
  2481. const existingWorkers = new Map(sweepWorkerList.map(w => [w.worker_index, w]))
  2482. sweepWorkerList.length = 0
  2483. for (const w of workers) {
  2484. const existing = existingWorkers.get(w.worker_index)
  2485. sweepWorkerList.push({
  2486. worker_index: w.worker_index,
  2487. logs: existing?.logs || [],
  2488. log_count: existing?.log_count || 0,
  2489. hasNewLogs: false
  2490. })
  2491. }
  2492. }
  2493. // 确保当前选中的线程在列表中存在
  2494. if (!sweepWorkerList.find(w => w.worker_index === sweepLogActiveWorkerIndex.value)) {
  2495. sweepLogActiveWorkerIndex.value = sweepWorkerList[0]?.worker_index || 1
  2496. }
  2497. // 获取当前选中线程的日志(每次都从头读取完整日志)
  2498. const currentWorker = sweepWorkerList.find(w => w.worker_index === sweepLogActiveWorkerIndex.value)
  2499. if (currentWorker && requestId) {
  2500. try {
  2501. const logRes = await secureApiClient.getSweepWorkerLog({
  2502. requestId: requestId,
  2503. workerIndex: currentWorker.worker_index,
  2504. lastLogCount: null, // 始终从头读取,获取所有日志
  2505. limit: 10000 // 设置足够大的限制
  2506. })
  2507. if (logRes.code === 200 && logRes.data) {
  2508. const newLogs = Array.isArray(logRes.data.logs) ? logRes.data.logs : []
  2509. // 过滤掉 API 详细日志(result === 'api_detail' 或 'api_request'),
  2510. // 这些日志只在 txt 文件中保留,不显示在弹窗中(方便排查问题但不影响界面显示)
  2511. // 注意:保留 'api_error' 类型的日志,因为它表示重要错误,需要在弹窗中显示
  2512. const filteredLogs = newLogs.filter((log: any) =>
  2513. log.result !== 'api_detail' && log.result !== 'api_request'
  2514. )
  2515. // 统计原始日志数量(包含被过滤的)
  2516. const newCount = logRes.data.log_count || 0
  2517. // 限制日志数组大小,防止内存泄漏(只保留最近的 MAX_LOGS 条)
  2518. const MAX_WORKER_LOGS = 5000
  2519. currentWorker.logs = filteredLogs.length > MAX_WORKER_LOGS
  2520. ? filteredLogs.slice(-MAX_WORKER_LOGS)
  2521. : filteredLogs
  2522. // 日志总数仍显示原始数量(包含被过滤的),方便排查
  2523. currentWorker.log_count = newCount
  2524. console.log(`[BuffSelectionView] Worker ${currentWorker.worker_index} loaded ${newLogs.length} logs (filtered: ${filteredLogs.length}, total: ${newCount})`)
  2525. }
  2526. } catch (e) {
  2527. console.error(`[BuffSelectionView] fetchSweepLog worker ${currentWorker.worker_index} failed:`, e)
  2528. }
  2529. }
  2530. await nextTick()
  2531. // 滚动到最新
  2532. const body = sweepLogBodyRef.value
  2533. if (body && sweepLogAutoFollow.value) {
  2534. body.scrollTop = body.scrollHeight
  2535. }
  2536. return listRes.data.status || null
  2537. }
  2538. return null
  2539. } finally {
  2540. if (showLoading) {
  2541. sweepLogLoading.value = false
  2542. }
  2543. }
  2544. }
  2545. function stopSweepLogPoll() {
  2546. if (sweepLogPollTimer.value) {
  2547. clearInterval(sweepLogPollTimer.value)
  2548. sweepLogPollTimer.value = null
  2549. }
  2550. }
  2551. function openSweepLogModal() {
  2552. // 重要:先停止之前的轮询,防止重复
  2553. stopSweepLogPoll()
  2554. sweepLogModalVisible.value = true
  2555. sweepLogData.request_id = null
  2556. sweepLogData.status = null
  2557. sweepLogData.started_at = null
  2558. sweepLogData.ended_at = null
  2559. sweepWorkerList.length = 0
  2560. sweepLogCount.value = 0
  2561. sweepLogActiveWorkerIndex.value = 1 // 默认选中线程1
  2562. sweepLogAutoFollow.value = true
  2563. // 清空 API 日志展开状态
  2564. expandedApiLogs.clear()
  2565. fetchSweepLog(true).then((status) => {
  2566. // 任务结束后停止轮询
  2567. if (status === 'completed' || status === 'failed' || status === 'cancelled') {
  2568. return
  2569. }
  2570. // 持续轮询直到结束(只轮询当前选中的线程)
  2571. sweepLogPollTimer.value = setInterval(async () => {
  2572. const pollStatus = await fetchSweepLog(false)
  2573. // 任务结束后停止轮询
  2574. if (pollStatus === 'completed' || pollStatus === 'failed' || pollStatus === 'cancelled') {
  2575. stopSweepLogPoll()
  2576. }
  2577. }, 1000)
  2578. })
  2579. }
  2580. // 线程按钮点击:切换显示的线程日志
  2581. async function onWorkerButtonClick(workerIndex: number) {
  2582. sweepLogActiveWorkerIndex.value = workerIndex
  2583. // 加载该线程的日志
  2584. const worker = sweepWorkerList.find(w => w.worker_index === workerIndex)
  2585. if (worker && sweepLogData.request_id) {
  2586. try {
  2587. const logRes = await secureApiClient.getSweepWorkerLog({
  2588. requestId: sweepLogData.request_id,
  2589. workerIndex: worker.worker_index,
  2590. lastLogCount: null, // 始终从头读取
  2591. limit: 10000
  2592. })
  2593. if (logRes.code === 200 && logRes.data) {
  2594. const newLogs = Array.isArray(logRes.data.logs) ? logRes.data.logs : []
  2595. // 过滤掉 API 详细日志(result === 'api_detail' 或 'api_request'),
  2596. // 这些日志只在 txt 文件中保留,不显示在弹窗中(方便排查问题但不影响界面显示)
  2597. const filteredLogs = newLogs.filter((log: any) =>
  2598. log.result !== 'api_detail' && log.result !== 'api_request'
  2599. )
  2600. // 限制日志数组大小,防止内存泄漏(只保留最近的 MAX_LOGS 条)
  2601. const MAX_WORKER_LOGS = 5000
  2602. worker.logs = filteredLogs.length > MAX_WORKER_LOGS
  2603. ? filteredLogs.slice(-MAX_WORKER_LOGS)
  2604. : filteredLogs
  2605. worker.log_count = logRes.data.log_count || 0
  2606. worker.hasNewLogs = false
  2607. console.log(`[BuffSelectionView] Worker ${workerIndex} loaded ${newLogs.length} logs (filtered: ${filteredLogs.length})`)
  2608. }
  2609. } catch (e) {
  2610. console.error(`[BuffSelectionView] Failed to load worker ${workerIndex} logs:`, e)
  2611. }
  2612. }
  2613. nextTick(() => {
  2614. const body = sweepLogBodyRef.value
  2615. if (body && sweepLogAutoFollow.value) {
  2616. body.scrollTop = body.scrollHeight
  2617. }
  2618. })
  2619. }
  2620. function onSweepLogModalOpenChange(open: boolean) {
  2621. if (!open) {
  2622. stopSweepLogPoll()
  2623. sweepLogAutoFollow.value = true
  2624. }
  2625. }
  2626. async function fetchSweepResult(showLoading = false): Promise<string | null> {
  2627. // 改为纯本地读取扫货成功数据,不再调用后端 API
  2628. if (showLoading) {
  2629. sweepTableLoading.value = true
  2630. }
  2631. try {
  2632. const res = await secureApiClient.getBuffSweepResultLocal(
  2633. sweepPagination.current,
  2634. sweepPagination.pageSize
  2635. )
  2636. if (res.code === 200 && res.data) {
  2637. const nextItemsRaw = Array.isArray(res.data.items) ? res.data.items : []
  2638. const nextItems = [...nextItemsRaw].sort((a: any, b: any) => {
  2639. // 主要排序:按 created_at 时间倒序(最新在前)
  2640. const byTime = parseLogTimestamp(b?.created_at) - parseLogTimestamp(a?.created_at)
  2641. if (byTime !== 0) return byTime
  2642. // 次要排序:按 listing_id 倒序(字符串比较,保持顺序一致性)
  2643. const idA = a?.listing_id ?? ''
  2644. const idB = b?.listing_id ?? ''
  2645. return idB.localeCompare(idA)
  2646. })
  2647. // 与选品列表一致:每次拉取后整体替换,轮询时 showLoading=false,表格无整表 loading 闪烁。
  2648. // 限制数组大小,防止内存泄漏
  2649. const MAX_SWEEP_ITEMS = 2000
  2650. sweepList.value = nextItems.length > MAX_SWEEP_ITEMS
  2651. ? nextItems.slice(-MAX_SWEEP_ITEMS)
  2652. : nextItems
  2653. sweepPagination.total = res.data.total || 0
  2654. // 本地数据没有 status,返回 null 让轮询继续
  2655. return null
  2656. }
  2657. return null
  2658. } finally {
  2659. if (showLoading) {
  2660. sweepTableLoading.value = false
  2661. }
  2662. }
  2663. }
  2664. function stopSweepPoll() {
  2665. if (sweepPollTimer.value) {
  2666. clearInterval(sweepPollTimer.value)
  2667. sweepPollTimer.value = null
  2668. }
  2669. // 清除连续错误计数
  2670. sweepPollConsecutiveErrors.value = 0
  2671. }
  2672. // 扫货成功列表定时刷新:每分钟刷新一次
  2673. function startSweepResultRefreshTimer() {
  2674. if (sweepResultRefreshTimer.value) return
  2675. sweepResultRefreshTimer.value = setInterval(() => {
  2676. fetchSweepResult(false)
  2677. }, 60000) // 60秒 = 1分钟
  2678. }
  2679. function stopSweepResultRefreshTimer() {
  2680. if (sweepResultRefreshTimer.value) {
  2681. clearInterval(sweepResultRefreshTimer.value)
  2682. sweepResultRefreshTimer.value = null
  2683. }
  2684. }
  2685. const SWEEP_POLL_MAX_CONSECUTIVE_ERRORS = 10 // 连续 10 次错误后停止轮询(约 30 秒)
  2686. /**
  2687. * 扫货状态轮询:改为纯本地数据刷新,不再依赖后端 status。
  2688. * 由于本地读取很快,可以持续轮询。
  2689. * 增加了超时机制:连续多次错误后自动停止轮询。
  2690. */
  2691. function startSweepPoll(showFinishMessage = false) {
  2692. if (sweepPollTimer.value) return
  2693. // 重置连续错误计数
  2694. sweepPollConsecutiveErrors.value = 0
  2695. const pollSweepStatus = () => {
  2696. fetchSweepResult(false)
  2697. .then(() => {
  2698. // 成功时重置错误计数
  2699. sweepPollConsecutiveErrors.value = 0
  2700. // 本地数据总是可用的,保持 running 状态
  2701. buffSweepStore.sweepRunning = true
  2702. })
  2703. .catch(() => {
  2704. sweepPollConsecutiveErrors.value++
  2705. console.warn(`[BuffSelectionView] sweep poll error (${sweepPollConsecutiveErrors.value}/${SWEEP_POLL_MAX_CONSECUTIVE_ERRORS})`)
  2706. // 连续错误超过阈值,停止轮询
  2707. if (sweepPollConsecutiveErrors.value >= SWEEP_POLL_MAX_CONSECUTIVE_ERRORS) {
  2708. console.error('[BuffSelectionView] sweep poll exceeded max consecutive errors, stopping poll')
  2709. stopSweepPoll()
  2710. buffSweepStore.sweepRunning = false
  2711. }
  2712. })
  2713. }
  2714. pollSweepStatus()
  2715. // 本地读取很快,可以持续轮询,但间隔稍长一点
  2716. sweepPollTimer.value = setInterval(pollSweepStatus, 3000)
  2717. }
  2718. function onSweepTableChange(pagination: any) {
  2719. sweepPagination.current = pagination?.current || 1
  2720. sweepPagination.pageSize = pagination?.pageSize || 10
  2721. }
  2722. /** 判断是否为网络相关错误(需要重试) */
  2723. function isNetworkError(error: any): boolean {
  2724. if (!error) return false
  2725. const msg = error?.message ?? error?.msg ?? String(error)
  2726. return (
  2727. msg.includes('connection closed') ||
  2728. msg.includes('ECONNRESET') ||
  2729. msg.includes('ETIMEDOUT') ||
  2730. msg.includes('network error') ||
  2731. msg.includes('fetch failed') ||
  2732. msg.includes('网络错误') ||
  2733. msg.includes('网络连接')
  2734. )
  2735. }
  2736. /** 拉取选品结果(状态+列表),更新 selectionList,返回当前状态与请求单ID */
  2737. async function fetchSelectionResult(showLoading = false): Promise<{ status: string | null; requestId: number | null }> {
  2738. const sessionId = authStore.sessionId
  2739. const machineCode = authStore.machineCode
  2740. if (!sessionId || !machineCode) return { status: null, requestId: null }
  2741. if (showLoading) {
  2742. tableLoading.value = true
  2743. }
  2744. const doFetch = async (): Promise<{ status: string | null; requestId: number | null }> => {
  2745. const res = await secureApiClient.getBuffSelectionResult(sessionId, machineCode)
  2746. if (res.code === 200 && res.data) {
  2747. const items = Array.isArray(res.data.items) ? res.data.items : []
  2748. selectionList.value = [...items].sort((a: any, b: any) => {
  2749. const byTime = parseLogTimestamp(b?.created_at) - parseLogTimestamp(a?.created_at)
  2750. if (byTime !== 0) return byTime
  2751. return Number(b?.id ?? 0) - Number(a?.id ?? 0)
  2752. })
  2753. const maxPage = Math.max(1, Math.ceil(selectionList.value.length / tablePagination.pageSize))
  2754. if (tablePagination.current > maxPage) {
  2755. tablePagination.current = maxPage
  2756. }
  2757. const existsKeySet = new Set(selectionList.value.map((item: any) => rowKey(item)))
  2758. selectionSelectedRowKeys.value = selectionSelectedRowKeys.value.filter((k) => existsKeySet.has(k))
  2759. return { status: res.data.status, requestId: res.data.request_id ?? null }
  2760. }
  2761. return { status: null, requestId: null }
  2762. }
  2763. try {
  2764. return await doFetch()
  2765. } catch (error) {
  2766. // 网络错误时重试一次
  2767. if (isNetworkError(error)) {
  2768. console.warn('[BuffSelectionView] fetchSelectionResult 遇到网络错误,等待后重试...')
  2769. await new Promise<void>((resolve) => setTimeout(resolve, 1500))
  2770. try {
  2771. return await doFetch()
  2772. } catch (retryError) {
  2773. console.error('[BuffSelectionView] fetchSelectionResult 重试失败:', retryError)
  2774. return { status: null, requestId: null }
  2775. }
  2776. }
  2777. console.error('[BuffSelectionView] fetchSelectionResult 失败:', error)
  2778. return { status: null, requestId: null }
  2779. } finally {
  2780. if (showLoading) {
  2781. tableLoading.value = false
  2782. }
  2783. }
  2784. }
  2785. function stopSelectionListPoll() {
  2786. if (selectionListPollTimer.value) {
  2787. clearInterval(selectionListPollTimer.value)
  2788. selectionListPollTimer.value = null
  2789. }
  2790. }
  2791. function getAutoSelectionLastRunStorageKey(): string {
  2792. const uid = authStore.userId != null ? String(authStore.userId) : 'guest'
  2793. return `buff_selection_auto_last_run_time_${uid}`
  2794. }
  2795. function getAutoSelectionLastRunTime(): string | null {
  2796. try {
  2797. return localStorage.getItem(getAutoSelectionLastRunStorageKey())
  2798. } catch {
  2799. return null
  2800. }
  2801. }
  2802. function setAutoSelectionLastRunTime(timeStr: string) {
  2803. try {
  2804. // 存储日期时间,格式:2024-04-12_08:00,用于判断同一天是否已执行过
  2805. const now = dayjs()
  2806. const dateTimeStr = `${now.format('YYYY-MM-DD')}_${timeStr}`
  2807. localStorage.setItem(getAutoSelectionLastRunStorageKey(), dateTimeStr)
  2808. } catch {
  2809. // ignore
  2810. }
  2811. }
  2812. function shouldRunAutoSelectionNow(now: Dayjs): boolean {
  2813. const configuredTime = form.autoSelectionTime
  2814. if (!configuredTime || !/^\d{2}:\d{2}$/.test(configuredTime)) return false
  2815. const [h, m] = configuredTime.split(':').map(Number)
  2816. if (Number.isNaN(h) || Number.isNaN(m)) return false
  2817. // 同一日期的同一时间点(YYYY-MM-DD_HH:mm)仅触发一次;修改时间点或跨天后可重新触发
  2818. const lastRunTime = getAutoSelectionLastRunTime()
  2819. // lastRunTime 格式:2024-04-12_08:00,需同时比较日期和时间部分
  2820. if (lastRunTime) {
  2821. const [lastRunDatePart, lastRunTimePart] = lastRunTime.split('_')
  2822. if (lastRunTimePart === configuredTime) {
  2823. const today = now.format('YYYY-MM-DD')
  2824. if (lastRunDatePart === today) return false // 同一天同一时间已执行过
  2825. }
  2826. }
  2827. // 核心逻辑:只比较"小时:分钟",精确到分钟判断
  2828. const nowMinutes = now.hour() * 60 + now.minute()
  2829. const targetMinutes = h * 60 + m
  2830. // 如果当前时间已超过配置时间(分钟比较),不触发,等明天
  2831. if (nowMinutes > targetMinutes) return false
  2832. // 当前时间未超过配置时间时,检查是否恰好到达该时间点(精确到分钟)
  2833. return nowMinutes === targetMinutes
  2834. }
  2835. function startAutoSelectionScheduler() {
  2836. stopAutoSelectionScheduler()
  2837. autoSelectionTimer.value = setInterval(() => {
  2838. if (!initialLoadDone.value) return
  2839. if (running.value) return
  2840. const now = dayjs()
  2841. if (!shouldRunAutoSelectionNow(now)) return
  2842. handleStart('auto')
  2843. }, 20 * 1000)
  2844. }
  2845. function stopAutoSelectionScheduler() {
  2846. if (autoSelectionTimer.value) {
  2847. clearInterval(autoSelectionTimer.value)
  2848. autoSelectionTimer.value = null
  2849. }
  2850. }
  2851. /**
  2852. * 开始新的选品或扫货前:结束服务端该用户所有进行中的选品单、扫货单,并停止本地扫货进程与前台轮询。
  2853. * 顺序与产品要求一致:先停选品,再停扫货(含 Rust request_cancel)。
  2854. */
  2855. async function prepareBuffEnvironmentBeforeStart(
  2856. forWhat: 'selection' | 'sweep',
  2857. opts: { manualHint?: boolean } = {}
  2858. ): Promise<boolean> {
  2859. const sessionId = authStore.sessionId
  2860. const machineCode = authStore.machineCode
  2861. if (!sessionId || !machineCode) return false
  2862. buffPrepareFor.value = forWhat
  2863. buffSweepStore.switchingStatus = 'resetting_before_start'
  2864. try {
  2865. const stopSel = await secureApiClient.stopBuffSelection(sessionId, machineCode)
  2866. const stopSw = await secureApiClient.stopBuffSweep(sessionId, machineCode)
  2867. stopSelectionListPoll()
  2868. stopSweepPoll()
  2869. buffSweepStore.selectionRunning = false
  2870. buffSweepStore.sweepRunning = false
  2871. await new Promise<void>((resolve) => setTimeout(resolve, 300))
  2872. await fetchSelectionResult(true).catch(() => {})
  2873. await fetchSweepResult(true).catch(() => {})
  2874. if (opts.manualHint) {
  2875. if (stopSel.code !== 200) {
  2876. message.warning(stopSel.msg || '结束进行中的选品任务失败,仍将尝试开始')
  2877. }
  2878. if (stopSw.code !== 200) {
  2879. message.warning(stopSw.msg || '结束进行中的扫货任务失败,仍将尝试开始')
  2880. }
  2881. }
  2882. return true
  2883. } catch {
  2884. if (opts.manualHint) {
  2885. message.warning('结束进行中的任务时网络异常,仍将尝试开始')
  2886. }
  2887. stopSelectionListPoll()
  2888. stopSweepPoll()
  2889. buffSweepStore.selectionRunning = false
  2890. buffSweepStore.sweepRunning = false
  2891. return true
  2892. } finally {
  2893. buffSweepStore.switchingStatus = null
  2894. buffPrepareFor.value = null
  2895. }
  2896. }
  2897. async function handleStart(source: 'manual' | 'auto' = 'manual') {
  2898. // 防止模板直接绑定函数时 click 事件对象误传入,默认按手动触发处理
  2899. if (source !== 'manual' && source !== 'auto') {
  2900. source = 'manual'
  2901. }
  2902. if (form.steamCardRatio == null) {
  2903. if (source === 'manual') message.warning('请先填写 Steam 卡价比例')
  2904. return
  2905. }
  2906. if (form.steamCardRatio < 4) {
  2907. if (source === 'manual') message.error('选品失败:Steam 卡价比例不能小于 4,请先修改配置')
  2908. else message.error('自动选品失败:Steam 卡价比例不能小于 4,请先修改配置')
  2909. return
  2910. }
  2911. if (form.profitRate == null) {
  2912. if (source === 'manual') message.warning('请先填写利润率')
  2913. return
  2914. }
  2915. // 验证系统配置限制
  2916. const maxWorkerCount = configStore.config.buff_sweep_max_worker_count ?? 32
  2917. const minSteamCardRatio = configStore.config.buff_steam_card_ratio_min ?? 4
  2918. if (form.sweepProcessCount > maxWorkerCount) {
  2919. if (source === 'manual') {
  2920. message.error(`选品失败:扫货线程数不能超过系统配置的最大值 ${maxWorkerCount}`)
  2921. } else {
  2922. message.error(`自动选品失败:扫货线程数不能超过系统配置的最大值 ${maxWorkerCount}`)
  2923. }
  2924. return
  2925. }
  2926. if (form.steamCardRatio < minSteamCardRatio) {
  2927. if (source === 'manual') {
  2928. message.error(`选品失败:Steam卡价比例不能小于系统配置的最小值 ${minSteamCardRatio}`)
  2929. } else {
  2930. message.error(`自动选品失败:Steam卡价比例不能小于系统配置的最小值 ${minSteamCardRatio}`)
  2931. }
  2932. return
  2933. }
  2934. const sessionId = authStore.sessionId
  2935. const machineCode = authStore.machineCode
  2936. if (!sessionId || !machineCode) {
  2937. if (source === 'manual') message.warning('请先登录')
  2938. return
  2939. }
  2940. await prepareBuffEnvironmentBeforeStart('selection', { manualHint: source === 'manual' })
  2941. buffSweepStore.selectionRunning = true
  2942. stopSelectionListPoll()
  2943. secureApiClient
  2944. .startBuffSelection(sessionId, machineCode)
  2945. .then((res) => {
  2946. if (res.code !== 200) {
  2947. message.warning(res.msg || '选品启动失败')
  2948. buffSweepStore.selectionRunning = false
  2949. return
  2950. }
  2951. if (source === 'manual') {
  2952. message.info(res.msg || '选品已开始,正在后台执行')
  2953. } else {
  2954. setAutoSelectionLastRunTime(form.autoSelectionTime)
  2955. message.info(`已到自动选品时间(${form.autoSelectionTime}),开始执行选品`)
  2956. }
  2957. const startedRequestId = res.data?.request_id ?? null
  2958. if (startedRequestId != null) {
  2959. autoSweepAllowedSelectionRequestIds.value.add(startedRequestId)
  2960. }
  2961. // 立即拉一次结果,再启动定时刷新
  2962. fetchSelectionResult(false).then(({ status }) => {
  2963. if (status === 'pending' || status === 'running') {
  2964. selectionListPollTimer.value = setInterval(() => {
  2965. fetchSelectionResult(false).then(({ status: s, requestId }) => {
  2966. if (s === 'pending' || s === 'running') {
  2967. return
  2968. }
  2969. stopSelectionListPoll()
  2970. buffSweepStore.selectionRunning = false
  2971. if (s === 'completed') {
  2972. if (source === 'manual') message.success('选品已完成')
  2973. // 仅本次点击「开始选品」创建的请求单完成后,才允许自动触发扫货
  2974. if (startedRequestId != null && requestId === startedRequestId) {
  2975. handleStartSweep(true, requestId)
  2976. }
  2977. // 兜底:即使扫货并非由当前前端调用启动,也以服务端状态为准同步“扫货中”标签
  2978. fetchSweepResult(false).then((sweepStatus) => {
  2979. if (sweepStatus === 'pending' || sweepStatus === 'running') {
  2980. buffSweepStore.sweepRunning = true
  2981. startSweepPoll(false)
  2982. }
  2983. })
  2984. }
  2985. if (s === 'cancelled') message.info('选品已停止')
  2986. })
  2987. }, 6000)
  2988. } else {
  2989. buffSweepStore.selectionRunning = false
  2990. }
  2991. })
  2992. })
  2993. .catch(() => {
  2994. buffSweepStore.selectionRunning = false
  2995. })
  2996. }
  2997. function handleStop() {
  2998. const sessionId = authStore.sessionId
  2999. const machineCode = authStore.machineCode
  3000. if (!sessionId || !machineCode) {
  3001. stopSelectionListPoll()
  3002. buffSweepStore.selectionRunning = false
  3003. return
  3004. }
  3005. secureApiClient.stopBuffSelection(sessionId, machineCode).then((res) => {
  3006. stopSelectionListPoll()
  3007. buffSweepStore.selectionRunning = false
  3008. if (res.code === 200) {
  3009. message.success(res.msg || '已发送停止请求,选品将尽快结束')
  3010. } else {
  3011. message.warning(res.msg || '停止请求发送失败')
  3012. }
  3013. }).catch(() => {
  3014. stopSelectionListPoll()
  3015. buffSweepStore.selectionRunning = false
  3016. })
  3017. }
  3018. async function handleDeleteSelectionItem(record: any) {
  3019. const sessionId = authStore.sessionId
  3020. const machineCode = authStore.machineCode
  3021. if (!sessionId || !machineCode) {
  3022. message.warning('请先登录')
  3023. return
  3024. }
  3025. const itemId = getSelectionItemId(record)
  3026. if (itemId == null) {
  3027. message.warning('无效的选品数据')
  3028. return
  3029. }
  3030. selectionDeleting.value = true
  3031. try {
  3032. const res = await secureApiClient.deleteBuffSelectionItem(sessionId, machineCode, itemId)
  3033. if (res.code === 200) {
  3034. message.success('删除成功,该饰品不会再参与扫货')
  3035. await fetchSelectionResult(true)
  3036. } else {
  3037. message.warning(res.msg || '删除失败')
  3038. }
  3039. } finally {
  3040. selectionDeleting.value = false
  3041. }
  3042. }
  3043. async function handleBatchDeleteSelectionItems() {
  3044. const sessionId = authStore.sessionId
  3045. const machineCode = authStore.machineCode
  3046. if (!sessionId || !machineCode) {
  3047. message.warning('请先登录')
  3048. return
  3049. }
  3050. if (selectionDeleting.value) return
  3051. if (selectionSelectedRowKeys.value.length === 0) {
  3052. message.info('请先勾选要删除的数据')
  3053. return
  3054. }
  3055. const selectedKeySet = new Set(selectionSelectedRowKeys.value.map((k) => String(k)))
  3056. const selectedItems = selectionList.value.filter((item) => selectedKeySet.has(String(rowKey(item))))
  3057. const itemIds = selectedItems
  3058. .map((item) => getSelectionItemId(item))
  3059. .filter((id): id is number => id != null)
  3060. if (itemIds.length === 0) {
  3061. message.warning('未找到可删除的数据')
  3062. return
  3063. }
  3064. selectionDeleting.value = true
  3065. try {
  3066. const results = await Promise.all(itemIds.map((itemId) => secureApiClient.deleteBuffSelectionItem(sessionId, machineCode, itemId)))
  3067. const successCount = results.filter((r) => r.code === 200).length
  3068. const failCount = results.length - successCount
  3069. if (successCount > 0) {
  3070. message.success(`已删除 ${successCount} 条数据`)
  3071. selectionSelectedRowKeys.value = []
  3072. await fetchSelectionResult(true)
  3073. }
  3074. if (failCount > 0) {
  3075. message.warning(`有 ${failCount} 条删除失败,请稍后重试`)
  3076. }
  3077. } finally {
  3078. selectionDeleting.value = false
  3079. }
  3080. }
  3081. async function handleBatchImportSelectionItems() {
  3082. const sessionId = authStore.sessionId
  3083. const machineCode = authStore.machineCode
  3084. if (!sessionId || !machineCode) {
  3085. message.warning('请先登录')
  3086. return
  3087. }
  3088. if (batchImportLoading.value) return
  3089. // 确保安全客户端已初始化(避免 ts=0 导致请求被拒)
  3090. const securityOk = await secureApiClient.checkSecurityInitialized()
  3091. if (!securityOk) {
  3092. console.warn('[BuffSelection] 安全客户端未初始化,正在重新初始化...')
  3093. const reinitOk = await secureApiClient.initializeSecurityClient(authStore.user?.username)
  3094. if (!reinitOk) {
  3095. message.error('安全初始化失败,请重新登录')
  3096. return
  3097. }
  3098. }
  3099. const selected = await openDialog({
  3100. multiple: true,
  3101. filters: [{ name: 'JSON', extensions: ['json'] }],
  3102. title: '选择要导入的选品JSON文件'
  3103. })
  3104. if (!selected || (Array.isArray(selected) && selected.length === 0)) {
  3105. return
  3106. }
  3107. const filePaths = Array.isArray(selected) ? selected : [selected]
  3108. if (filePaths.length === 0) return
  3109. batchImportLoading.value = true
  3110. try {
  3111. const res = await secureApiClient.batchImportBuffSelectionItems(
  3112. sessionId,
  3113. machineCode,
  3114. filePaths,
  3115. form.steamCardRatio ?? 5.1
  3116. )
  3117. if (res.code === 200) {
  3118. message.success(res.msg || `批量导入成功,共导入 ${res.data?.imported_count ?? 0} 条数据`)
  3119. await fetchSelectionResult(true)
  3120. } else {
  3121. message.warning(res.msg || '批量导入失败')
  3122. }
  3123. } catch (error: any) {
  3124. console.error('[BuffSelection] batch import error:', error)
  3125. message.error(error?.message || '批量导入失败')
  3126. } finally {
  3127. batchImportLoading.value = false
  3128. }
  3129. }
  3130. async function handleStartSweep(isAuto: boolean, sourceSelectionRequestId?: number | null) {
  3131. const sessionId = authStore.sessionId
  3132. const machineCode = authStore.machineCode
  3133. if (!sessionId || !machineCode) {
  3134. message.warning('请先登录')
  3135. return
  3136. }
  3137. if (selectionList.value.length === 0) {
  3138. if (!isAuto) message.warning('暂无可扫货的选品数据')
  3139. return
  3140. }
  3141. // 验证至少有一个参与扫货的 Steam 账号
  3142. const activeAccounts = boundSteamAccounts.value.filter(a => a.is_active !== false)
  3143. if (activeAccounts.length === 0) {
  3144. if (!isAuto) message.warning('没有参与扫货的 Steam 账号,请至少开启一个')
  3145. return
  3146. }
  3147. // 构建参与扫货账号的充值卡价比例映射
  3148. const accountCardRateMap: Record<string, number> = {}
  3149. for (const account of activeAccounts) {
  3150. if (account.cardRate != null) {
  3151. accountCardRateMap[account.steam_username] = account.cardRate
  3152. }
  3153. }
  3154. // 验证系统配置限制
  3155. const maxWorkerCount = configStore.config.buff_sweep_max_worker_count ?? 32
  3156. const minSteamCardRatio = configStore.config.buff_steam_card_ratio_min ?? 4
  3157. if (form.sweepProcessCount > maxWorkerCount) {
  3158. if (!isAuto) {
  3159. message.error(`扫货启动失败:扫货线程数不能超过系统配置的最大值 ${maxWorkerCount}`)
  3160. }
  3161. buffSweepStore.sweepRunning = false
  3162. return
  3163. }
  3164. if (form.steamCardRatio != null && form.steamCardRatio < minSteamCardRatio) {
  3165. if (!isAuto) {
  3166. message.error(`扫货启动失败:Steam卡价比例不能小于系统配置的最小值 ${minSteamCardRatio}`)
  3167. }
  3168. buffSweepStore.sweepRunning = false
  3169. return
  3170. }
  3171. // 自动扫货时跳过环境准备(stopBuffSweep 会取消刚创建的扫货请求)
  3172. // 手动扫货时保持原有逻辑:先停止旧扫货再启动新扫货
  3173. if (!isAuto) {
  3174. await prepareBuffEnvironmentBeforeStart('sweep', { manualHint: true })
  3175. }
  3176. if (isAuto) {
  3177. const requestId = sourceSelectionRequestId ?? null
  3178. if (requestId == null || !autoSweepAllowedSelectionRequestIds.value.has(requestId)) {
  3179. return
  3180. }
  3181. // 自动扫货一次性消费,避免同一 request_id 重复触发
  3182. autoSweepAllowedSelectionRequestIds.value.delete(requestId)
  3183. }
  3184. buffSweepStore.sweepRunning = true
  3185. stopSweepPoll()
  3186. const syncSweepRunningFromServer = () => {
  3187. fetchSweepResult(false).then((status) => {
  3188. if (status === 'pending' || status === 'running') {
  3189. buffSweepStore.sweepRunning = true
  3190. startSweepPoll(false)
  3191. return
  3192. }
  3193. if (status === 'completed' || status === 'failed' || status === 'cancelled') {
  3194. buffSweepStore.sweepRunning = false
  3195. }
  3196. // status 为空或未知时,保持当前状态并等待下一次轮询/同步,避免误关“扫货中”
  3197. }).catch(() => {
  3198. // 网络抖动时不立即关闭“扫货中”
  3199. })
  3200. }
  3201. secureApiClient.startBuffSweep(sessionId, machineCode).then(async (res) => {
  3202. if (res.code !== 200) {
  3203. if (res.code === 409) {
  3204. // 后端已在扫货:应保持“扫货中”状态并接管轮询,而不是关闭状态
  3205. buffSweepStore.sweepRunning = true
  3206. startSweepPoll(true)
  3207. message.info(res.msg || '扫货已在进行中')
  3208. } else if (!isAuto) {
  3209. syncSweepRunningFromServer()
  3210. message.warning(res.msg || '扫货启动失败')
  3211. } else {
  3212. syncSweepRunningFromServer()
  3213. }
  3214. return
  3215. }
  3216. const sweepRid = res.data?.request_id
  3217. const selRid = res.data?.selection_request_id
  3218. if (sweepRid != null && selRid != null) {
  3219. const wc =
  3220. form.sweepProcessCount != null && Number.isFinite(Number(form.sweepProcessCount))
  3221. ? Math.max(1, Math.min(maxWorkerCount, Math.floor(Number(form.sweepProcessCount))))
  3222. : 3
  3223. const spawned = await secureApiClient.spawnBuffSweepLocalRunner(
  3224. sessionId,
  3225. machineCode,
  3226. sweepRid,
  3227. selRid,
  3228. wc,
  3229. activeAccounts.map(a => a.steam_username),
  3230. accountCardRateMap
  3231. )
  3232. if (!spawned.ok && !isAuto) {
  3233. message.warning(spawned.msg || '本地扫货引擎未启动,请稍后重试')
  3234. }
  3235. }
  3236. if (!isAuto) message.success(res.msg || '扫货已开始')
  3237. startSweepPoll(true)
  3238. }).catch(() => {
  3239. syncSweepRunningFromServer()
  3240. })
  3241. }
  3242. function handleStopSweep() {
  3243. const sessionId = authStore.sessionId
  3244. const machineCode = authStore.machineCode
  3245. if (!sessionId || !machineCode) {
  3246. stopSweepPoll()
  3247. buffSweepStore.sweepRunning = false
  3248. return
  3249. }
  3250. // 发送停止请求
  3251. const stopPromise = secureApiClient.stopBuffSweep(sessionId, machineCode)
  3252. // 设置超时:如果 5 秒内没有响应,强制停止前端状态
  3253. const timeoutPromise = new Promise<void>((resolve) => {
  3254. setTimeout(() => {
  3255. console.warn('[BuffSelectionView] stopBuffSweep timeout, forcing stop')
  3256. resolve()
  3257. }, 5000)
  3258. })
  3259. Promise.race([stopPromise, timeoutPromise]).then((res: any) => {
  3260. stopSweepPoll()
  3261. buffSweepStore.sweepRunning = false
  3262. if (res?.code === 200) {
  3263. message.success(res.msg || '已发送停止请求,扫货将尽快结束')
  3264. } else {
  3265. // 超时情况或失败
  3266. message.warning(res?.msg || '停止请求超时,已强制停止前端状态')
  3267. }
  3268. fetchSweepResult(true)
  3269. }).catch(() => {
  3270. stopSweepPoll()
  3271. buffSweepStore.sweepRunning = false
  3272. })
  3273. }
  3274. function openFilterModal(type: FilterModalType) {
  3275. filterModalType.value = type
  3276. const key = type
  3277. // 从 { label, value }[] 中提取 value[]
  3278. const current = form[key] as { label: string; value: string }[]
  3279. // 修复:先确保 current[0] 是对象(而非字符串),再检查是否有 'value' 属性
  3280. const isLabelValueArray = current && current.length > 0 && current[0] &&
  3281. typeof current[0] === 'object' && !Array.isArray(current[0]) && 'value' in current[0]
  3282. filterDraftSingle.value = isLabelValueArray
  3283. ? current.map(i => i.value)
  3284. : (form[key] as string[]) ?? []
  3285. filterModalVisible.value = true
  3286. }
  3287. function closeFilterModal() {
  3288. filterModalVisible.value = false
  3289. filterModalType.value = null
  3290. }
  3291. function confirmFilterModal() {
  3292. const key = filterModalType.value
  3293. if (key) {
  3294. const draft = [...filterDraftSingle.value]
  3295. // 将 value[] 转换成 { label, value }[]
  3296. const options = filterOptions[key as keyof typeof filterOptions] as { label: string; value: string }[]
  3297. const labelMap = new Map(options.map(o => [o.value, o.label]))
  3298. const withLabels = draft.map(v => ({
  3299. label: labelMap.get(v) ?? v,
  3300. value: v
  3301. }))
  3302. if (key === 'rifle') form.rifle = withLabels
  3303. else if (key === 'pistol') form.pistol = withLabels
  3304. else if (key === 'smg') form.smg = withLabels
  3305. else if (key === 'shotgun') form.shotgun = withLabels
  3306. else if (key === 'machinegun') form.machinegun = withLabels
  3307. else if (key === 'quality') form.quality = withLabels
  3308. else if (key === 'category') form.category = withLabels
  3309. else if (key === 'exterior') form.exterior = withLabels
  3310. else if (key === 'collection') form.collection = withLabels
  3311. }
  3312. filterModalVisible.value = false
  3313. filterModalType.value = null
  3314. }
  3315. function handleGoodsIconError(e: Event) {
  3316. const el = e.currentTarget as HTMLImageElement
  3317. if (el) el.style.display = 'none'
  3318. }
  3319. function normalizeGoodsIconUrl(url: string | null | undefined): string {
  3320. const value = typeof url === 'string' ? url.trim() : ''
  3321. if (!value) return ''
  3322. // BUFF 侧常见返回 `//cdn...`,在 Tauri WebView 中需补全协议
  3323. if (value.startsWith('//')) return `https:${value}`
  3324. return value
  3325. }
  3326. function parseIdsFromText(text: string): string[] {
  3327. if (!text || typeof text !== 'string') return []
  3328. return text
  3329. .split(/\r?\n/)
  3330. .map((s) => s.trim())
  3331. .filter(Boolean)
  3332. }
  3333. function openIdsListModal(type: 'exclude' | 'specified') {
  3334. idsListModalType.value = type
  3335. idsListData.value = []
  3336. idsListSelectedRowKeys.value = []
  3337. idsListModalVisible.value = true
  3338. nextTick(() => refreshIdsListData())
  3339. }
  3340. function onIdsListSelectionChange(keys: (string | number)[]) {
  3341. idsListSelectedRowKeys.value = keys.map((k) => String(k))
  3342. }
  3343. function batchRemoveIdsList() {
  3344. const type = idsListModalType.value
  3345. const keys = idsListSelectedRowKeys.value
  3346. if (!type || !keys.length) return
  3347. const raw = type === 'exclude' ? form.excludeIds : form.specifiedIds
  3348. const idSet = new Set(keys)
  3349. const ids = parseIdsFromText(raw).filter((id) => !idSet.has(id))
  3350. if (type === 'exclude') {
  3351. form.excludeIds = ids.join('\n')
  3352. } else {
  3353. form.specifiedIds = ids.join('\n')
  3354. }
  3355. idsListSelectedRowKeys.value = []
  3356. refreshIdsListData()
  3357. }
  3358. function onIdsModalOpenChange(open: boolean) {
  3359. if (open && idsListModalType.value) {
  3360. refreshIdsListData()
  3361. }
  3362. }
  3363. async function refreshIdsListData() {
  3364. const type = idsListModalType.value
  3365. if (!type) return
  3366. const raw = type === 'exclude' ? form.excludeIds : form.specifiedIds
  3367. const str = normalizeIdsField(raw)
  3368. const ids = parseIdsFromText(str)
  3369. idsListData.value = []
  3370. if (ids.length === 0) return
  3371. idsListLoading.value = true
  3372. const sessionId = authStore.sessionId
  3373. const machineCode = authStore.machineCode
  3374. if (!sessionId || !machineCode) {
  3375. message.warning('请先登录')
  3376. return
  3377. }
  3378. try {
  3379. const res = await secureApiClient.getBuffGoodsAttrList(ids, sessionId, machineCode)
  3380. const list = (res.code === 200 && res.data ? res.data : []) as { goods_id: string; name: string; icon_url: string }[]
  3381. const map = new Map(list.map((item) => [String(item.goods_id), item]))
  3382. idsListData.value = ids.map((id) => {
  3383. const found = map.get(id)
  3384. return found
  3385. ? { goods_id: String(found.goods_id), name: found.name || '—', icon_url: normalizeGoodsIconUrl(found.icon_url) }
  3386. : { goods_id: id, name: '暂无信息', icon_url: '' }
  3387. })
  3388. } catch (e) {
  3389. console.error('获取商品属性列表失败:', e)
  3390. message.error('获取商品信息失败,请稍后重试')
  3391. idsListData.value = ids.map((id) => ({ goods_id: id, name: '暂无信息', icon_url: '' }))
  3392. } finally {
  3393. idsListLoading.value = false
  3394. }
  3395. }
  3396. async function uploadTxtInIdsModal() {
  3397. const type = idsListModalType.value
  3398. if (!type) return
  3399. const path = await pickTxtFile()
  3400. if (!path) return
  3401. try {
  3402. const content = await invoke<string>('read_text_file', { path })
  3403. const uploadedIds = content
  3404. .split(/\r?\n/)
  3405. .map((s) => s.trim())
  3406. .filter(Boolean)
  3407. const existingIds = parseIdsFromText(type === 'exclude' ? form.excludeIds : form.specifiedIds)
  3408. const merged = [...new Set([...existingIds, ...uploadedIds])]
  3409. if (type === 'exclude') {
  3410. form.excludeIds = merged.join('\n')
  3411. } else {
  3412. form.specifiedIds = merged.join('\n')
  3413. }
  3414. message.success(`已上传并剔重,共 ${merged.length} 个饰品 id`)
  3415. await refreshIdsListData()
  3416. } catch (e: any) {
  3417. message.error(e?.toString() || '读取文件失败')
  3418. }
  3419. }
  3420. function removeIdFromIdsList(goodsId: string) {
  3421. const type = idsListModalType.value
  3422. if (!type) return
  3423. const raw = type === 'exclude' ? form.excludeIds : form.specifiedIds
  3424. const ids = parseIdsFromText(raw).filter((id) => id !== goodsId)
  3425. if (type === 'exclude') {
  3426. form.excludeIds = ids.join('\n')
  3427. } else {
  3428. form.specifiedIds = ids.join('\n')
  3429. }
  3430. refreshIdsListData()
  3431. }
  3432. async function loadFilterOptions() {
  3433. const sessionId = authStore.sessionId
  3434. const machineCode = authStore.machineCode
  3435. if (!sessionId || !machineCode) {
  3436. return
  3437. }
  3438. try {
  3439. const res = await secureApiClient.getBuffFilterOptions('csgo', sessionId, machineCode)
  3440. if (res.code === 200 && res.data) {
  3441. filterOptions.rifle = res.data.rifle ?? []
  3442. filterOptions.pistol = res.data.pistol ?? []
  3443. filterOptions.smg = res.data.smg ?? []
  3444. filterOptions.shotgun = res.data.shotgun ?? []
  3445. filterOptions.machinegun = res.data.machinegun ?? []
  3446. filterOptions.quality = filterOptionsByLabel(res.data.quality ?? [], ALLOWED_QUALITY_LABELS)
  3447. filterOptions.category = filterOptionsByLabel(res.data.category ?? [], ALLOWED_CATEGORY_LABELS)
  3448. filterOptions.exterior = filterOptionsByLabel(res.data.exterior ?? [], ALLOWED_EXTERIOR_LABELS)
  3449. filterOptions.collection = res.data.collection ?? []
  3450. filterOptions.collectionTree = res.data.collectionTree ?? []
  3451. }
  3452. } catch (e) {
  3453. console.error('加载BUFF筛选项失败:', e)
  3454. message.error('筛选项加载失败,请检查网络或稍后重试')
  3455. }
  3456. }
  3457. onMounted(async () => {
  3458. // 先加载 filterOptions,再加载 conditions(因为 convertArr 依赖 filterOptions 转换 label)
  3459. await loadFilterOptions()
  3460. await loadConditions()
  3461. const [selectionResult, sweepStatus] = await Promise.all([
  3462. fetchSelectionResult(true),
  3463. fetchSweepResult(true)
  3464. ])
  3465. const selectionStatus = selectionResult.status
  3466. if (selectionStatus === 'pending' || selectionStatus === 'running') {
  3467. buffSweepStore.selectionRunning = true
  3468. selectionListPollTimer.value = setInterval(() => {
  3469. fetchSelectionResult(false).then(({ status: s }) => {
  3470. if (s === 'pending' || s === 'running') return
  3471. stopSelectionListPoll()
  3472. buffSweepStore.selectionRunning = false
  3473. // 页面初始化时仅恢复选品状态,不自动触发扫货,避免历史数据误触发
  3474. })
  3475. }, 6000)
  3476. }
  3477. if (sweepStatus === 'pending' || sweepStatus === 'running') {
  3478. buffSweepStore.sweepRunning = true
  3479. startSweepPoll(false)
  3480. }
  3481. initialLoadDone.value = true
  3482. startAutoSelectionScheduler()
  3483. scheduleDeferredBoundSteamLoad()
  3484. startSweepResultRefreshTimer()
  3485. })
  3486. onUnmounted(() => {
  3487. autoSweepAllowedSelectionRequestIds.value.clear()
  3488. stopAutoSelectionScheduler()
  3489. stopSelectionListPoll()
  3490. stopSelectionLogPoll()
  3491. stopSweepPoll()
  3492. stopSweepLogPoll()
  3493. stopSweepResultRefreshTimer()
  3494. stopBoundSteamBalancePoll()
  3495. if (deferredBoundSteamLoadTimer.value) {
  3496. clearTimeout(deferredBoundSteamLoadTimer.value)
  3497. deferredBoundSteamLoadTimer.value = null
  3498. }
  3499. })
  3500. </script>
  3501. <style scoped>
  3502. .buff-selection-container {
  3503. height: 100%;
  3504. width: 100%;
  3505. min-height: 0;
  3506. overflow: auto;
  3507. box-sizing: border-box;
  3508. padding: 16px;
  3509. }
  3510. .buff-card {
  3511. display: flex;
  3512. flex-direction: column;
  3513. gap: 20px;
  3514. width: 100%;
  3515. max-width: 100%;
  3516. box-sizing: border-box;
  3517. padding-left: 20px;
  3518. padding-right: 20px;
  3519. padding-bottom: 80px;
  3520. }
  3521. /* 固定底部操作按钮 */
  3522. .fixed-bottom-buttons {
  3523. position: fixed;
  3524. bottom: 0;
  3525. left: 0;
  3526. right: 0;
  3527. z-index: 100;
  3528. background: #fff;
  3529. padding: 12px 24px;
  3530. box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
  3531. }
  3532. .fixed-bottom-vip {
  3533. display: flex;
  3534. align-items: center;
  3535. justify-content: center;
  3536. gap: 8px;
  3537. margin-bottom: 8px;
  3538. }
  3539. .fixed-bottom-vip-expire {
  3540. font-size: 12px;
  3541. color: #666;
  3542. }
  3543. .fixed-bottom-buttons-inner {
  3544. display: flex;
  3545. gap: 16px;
  3546. align-items: center;
  3547. justify-content: center;
  3548. }
  3549. .buff-status-indicator {
  3550. display: flex;
  3551. align-items: center;
  3552. gap: 6px;
  3553. padding: 6px 12px;
  3554. font-size: 13px;
  3555. font-weight: 500;
  3556. }
  3557. .buff-status-indicator .anticon {
  3558. font-size: 14px;
  3559. }
  3560. .status-text.is-switching {
  3561. color: #fa8c16;
  3562. }
  3563. .status-text.is-selection {
  3564. color: #52c41a;
  3565. }
  3566. .status-text.is-sweep {
  3567. color: #1677ff;
  3568. }
  3569. /* 选品开始:绿色主按钮 */
  3570. .fixed-bottom-buttons-inner .btn-start.ant-btn-primary {
  3571. background: #52c41a;
  3572. border-color: #52c41a;
  3573. }
  3574. .fixed-bottom-buttons-inner .btn-start.ant-btn-primary:hover:not(:disabled) {
  3575. background: #73d13d;
  3576. border-color: #73d13d;
  3577. }
  3578. .fixed-bottom-buttons-inner .btn-sweep-start.ant-btn-primary {
  3579. background: #1677ff;
  3580. border-color: #1677ff;
  3581. }
  3582. .fixed-bottom-buttons-inner .btn-sweep-start.ant-btn-primary:hover:not(:disabled) {
  3583. background: #4096ff;
  3584. border-color: #4096ff;
  3585. }
  3586. .section {
  3587. flex-shrink: 0;
  3588. background: #fff;
  3589. border: 1px solid #e8e8e8;
  3590. border-radius: 8px;
  3591. padding: 16px;
  3592. margin-bottom: 16px;
  3593. }
  3594. .section:last-child {
  3595. border-bottom: 1px solid #e8e8e8;
  3596. flex: 1;
  3597. min-height: 0;
  3598. display: flex;
  3599. flex-direction: column;
  3600. margin-bottom: 0;
  3601. }
  3602. /* 条件设置区域 - 可折叠 */
  3603. .conditions-section {
  3604. border-bottom: 1px solid #e8e8e8;
  3605. padding-bottom: 16px;
  3606. }
  3607. .conditions-section.is-collapsed {
  3608. border-bottom: 1px solid #e8e8e8;
  3609. }
  3610. .conditions-section .section {
  3611. background: transparent;
  3612. border: none;
  3613. border-radius: 0;
  3614. padding: 0;
  3615. margin-bottom: 0;
  3616. }
  3617. .conditions-summary-bar {
  3618. display: flex;
  3619. align-items: center;
  3620. justify-content: space-between;
  3621. padding: 10px 16px;
  3622. background: #f5f5f5;
  3623. border-radius: 6px;
  3624. cursor: pointer;
  3625. transition: background 0.2s;
  3626. }
  3627. .conditions-summary-bar:hover {
  3628. background: #e8e8e8;
  3629. }
  3630. .conditions-summary-content {
  3631. display: flex;
  3632. align-items: flex-start;
  3633. gap: 8px;
  3634. flex: 1;
  3635. overflow: hidden;
  3636. }
  3637. .conditions-summary-icon {
  3638. font-size: 16px;
  3639. flex-shrink: 0;
  3640. margin-top: 2px;
  3641. }
  3642. .conditions-summary-text {
  3643. font-size: 14px;
  3644. color: #333;
  3645. line-height: 1.6;
  3646. }
  3647. .conditions-summary-text :deep(br) {
  3648. display: block;
  3649. content: "";
  3650. margin: 2px 0;
  3651. }
  3652. .conditions-summary-toggle {
  3653. display: flex;
  3654. align-items: center;
  3655. gap: 4px;
  3656. flex-shrink: 0;
  3657. margin-left: 12px;
  3658. }
  3659. .toggle-hint {
  3660. font-size: 12px;
  3661. color: #999;
  3662. }
  3663. .toggle-icon {
  3664. font-size: 10px;
  3665. color: #999;
  3666. }
  3667. .conditions-detail {
  3668. padding-top: 16px;
  3669. }
  3670. .section-header {
  3671. display: flex;
  3672. align-items: center;
  3673. gap: 8px;
  3674. margin-bottom: 12px;
  3675. padding-bottom: 10px;
  3676. border-bottom: 1px solid #f0f0f0;
  3677. }
  3678. .section-header-with-action {
  3679. justify-content: space-between;
  3680. gap: 12px;
  3681. }
  3682. .section-header-left {
  3683. display: flex;
  3684. align-items: center;
  3685. gap: 8px;
  3686. }
  3687. .selection-search-bar {
  3688. flex: 1;
  3689. display: flex;
  3690. align-items: center;
  3691. gap: 8px;
  3692. min-width: 0;
  3693. }
  3694. .selection-search-input {
  3695. max-width: 260px;
  3696. line-height: 1;
  3697. }
  3698. .selection-search-input :deep(.ant-input) {
  3699. height: 30px;
  3700. line-height: 30px;
  3701. }
  3702. .selection-search-hint {
  3703. font-size: 12px;
  3704. color: #999;
  3705. white-space: nowrap;
  3706. }
  3707. .section-actions {
  3708. display: inline-flex;
  3709. align-items: center;
  3710. gap: 4px;
  3711. flex-shrink: 0;
  3712. }
  3713. .section-header-with-action .btn-log {
  3714. flex-shrink: 0;
  3715. padding: 0 4px;
  3716. }
  3717. .section-icon {
  3718. font-size: 16px;
  3719. }
  3720. .section-title {
  3721. font-size: 15px;
  3722. font-weight: 600;
  3723. color: #333;
  3724. }
  3725. .section-body {
  3726. width: 100%;
  3727. }
  3728. .search-body-buttons {
  3729. display: flex;
  3730. flex-direction: column;
  3731. gap: 12px;
  3732. }
  3733. /* 宽度自适应:宽则一行,窄则自动换行 */
  3734. .filter-buttons-adaptive {
  3735. display: flex;
  3736. flex-wrap: wrap;
  3737. gap: 10px;
  3738. }
  3739. .filter-buttons-adaptive .ant-btn {
  3740. min-width: 0;
  3741. flex-shrink: 0;
  3742. }
  3743. /* 步枪~收藏品:统一颜色 */
  3744. .filter-buttons-adaptive .btn-filter {
  3745. color: #1677ff;
  3746. border-color: #1677ff;
  3747. background: #fff;
  3748. }
  3749. .filter-buttons-adaptive .btn-filter:hover {
  3750. color: #4096ff;
  3751. border-color: #4096ff;
  3752. background: #e6f4ff;
  3753. }
  3754. /* 有选中值时的样式 */
  3755. .filter-buttons-adaptive .btn-filter.has-selection {
  3756. color: #52c41a;
  3757. border-color: #52c41a;
  3758. background: #f6ffed;
  3759. }
  3760. .filter-buttons-adaptive .btn-filter.has-selection:hover {
  3761. color: #73d13d;
  3762. border-color: #73d13d;
  3763. background: #d9f7be;
  3764. }
  3765. .filter-buttons-adaptive .filter-check {
  3766. display: inline-block;
  3767. margin-left: 4px;
  3768. color: #52c41a;
  3769. font-weight: bold;
  3770. }
  3771. /* 排除的饰品id、指定的饰品id:另一颜色 */
  3772. .filter-buttons-adaptive .btn-ids {
  3773. color: #d48806;
  3774. border-color: #faad14;
  3775. background: #fff;
  3776. }
  3777. .filter-buttons-adaptive .btn-ids:hover {
  3778. color: #d4a106;
  3779. border-color: #ffc53d;
  3780. background: #fffbe6;
  3781. }
  3782. .filter-buttons-adaptive .btn-ids.has-selection {
  3783. color: #52c41a;
  3784. border-color: #52c41a;
  3785. background: #f6ffed;
  3786. }
  3787. .filter-buttons-adaptive .btn-ids.has-selection:hover {
  3788. color: #73d13d;
  3789. border-color: #73d13d;
  3790. background: #d9f7be;
  3791. }
  3792. .filter-modal-single-body {
  3793. padding: 8px 0;
  3794. }
  3795. .filter-modal-single-body .form-item-full {
  3796. margin-bottom: 0;
  3797. }
  3798. .filter-modal-single-body .form-item-full :deep(.ant-form-item-control) {
  3799. width: 100%;
  3800. }
  3801. .filter-select-full {
  3802. width: 100%;
  3803. }
  3804. .filter-modal-single-body .collection-tree-select {
  3805. width: 100%;
  3806. }
  3807. .ids-modal-toolbar {
  3808. margin-bottom: 12px;
  3809. display: flex;
  3810. gap: 12px;
  3811. align-items: center;
  3812. }
  3813. /* 弹窗内列表区域:在弹窗内滚动,不撑出整窗 */
  3814. .ids-modal-body {
  3815. flex: 1;
  3816. min-height: 0;
  3817. overflow-y: auto;
  3818. min-height: 120px;
  3819. }
  3820. .goods-icon-img {
  3821. width: 48px;
  3822. height: 48px;
  3823. object-fit: contain;
  3824. vertical-align: middle;
  3825. }
  3826. .goods-icon-placeholder {
  3827. display: inline-block;
  3828. width: 48px;
  3829. height: 48px;
  3830. line-height: 48px;
  3831. text-align: center;
  3832. background: #f5f5f5;
  3833. color: #999;
  3834. font-size: 12px;
  3835. }
  3836. .search-grid {
  3837. display: grid;
  3838. grid-template-columns: repeat(4, 1fr);
  3839. gap: 12px 20px;
  3840. }
  3841. /* 步枪~收藏品:label 与选择框同一行,宽度一致 */
  3842. .search-grid .form-item-filter {
  3843. margin-bottom: 0;
  3844. display: flex;
  3845. flex-direction: row;
  3846. align-items: center;
  3847. flex-wrap: nowrap;
  3848. }
  3849. .search-grid .form-item-filter :deep(.ant-form-item-row) {
  3850. display: flex;
  3851. flex-direction: row;
  3852. align-items: center;
  3853. flex-wrap: nowrap;
  3854. width: 100%;
  3855. }
  3856. .search-grid .form-item-filter :deep(.ant-form-item-label) {
  3857. width: 100px;
  3858. min-width: 100px;
  3859. padding-bottom: 0;
  3860. flex-shrink: 0;
  3861. }
  3862. .search-grid .form-item-filter :deep(.ant-form-item-control) {
  3863. width: 160px;
  3864. min-width: 160px;
  3865. max-width: 160px;
  3866. flex-shrink: 0;
  3867. }
  3868. .search-grid .form-item-filter :deep(.filter-select) {
  3869. width: 160px;
  3870. }
  3871. .selection-summary-row {
  3872. grid-column: 1 / -1;
  3873. display: flex;
  3874. flex-direction: column;
  3875. gap: 6px;
  3876. }
  3877. .selection-summary-label {
  3878. font-size: 13px;
  3879. color: #666;
  3880. flex-shrink: 0;
  3881. }
  3882. /* 紧凑展示,最多 3 行,超出滚动;类别名与检索按钮同色 */
  3883. .selection-summary-box {
  3884. max-height: 4.5em;
  3885. overflow-y: auto;
  3886. line-height: 1.5;
  3887. padding: 6px 11px;
  3888. background-color: #fafafa;
  3889. border: 1px solid #d9d9d9;
  3890. border-radius: 6px;
  3891. color: #333;
  3892. font-size: 13px;
  3893. cursor: default;
  3894. }
  3895. .selection-summary-box .summary-label {
  3896. color: #1677ff;
  3897. font-weight: 500;
  3898. }
  3899. .selection-summary-box .summary-sep {
  3900. color: #333;
  3901. }
  3902. .selection-summary-box .summary-empty {
  3903. color: #999;
  3904. }
  3905. .ids-row {
  3906. grid-column: 1 / -1;
  3907. display: grid;
  3908. grid-template-columns: 1fr 1fr;
  3909. gap: 20px;
  3910. align-items: start;
  3911. }
  3912. .ids-field {
  3913. display: flex;
  3914. align-items: flex-start;
  3915. gap: 12px;
  3916. }
  3917. .ids-field-left {
  3918. display: flex;
  3919. flex-direction: column;
  3920. gap: 8px;
  3921. flex-shrink: 0;
  3922. padding-top: 2px;
  3923. }
  3924. .ids-field-label {
  3925. font-size: 14px;
  3926. color: rgba(0, 0, 0, 0.88);
  3927. line-height: 1.5;
  3928. }
  3929. .ids-field-right {
  3930. flex: 1;
  3931. min-width: 0;
  3932. }
  3933. .ids-field-right .ant-input {
  3934. width: 100%;
  3935. }
  3936. .form-item-full {
  3937. margin-bottom: 0;
  3938. }
  3939. .form-item-full :deep(.ant-form-item-label) {
  3940. padding-bottom: 4px;
  3941. }
  3942. /* 选品条件:4 行 2 列,左列 4 项、右列 4 项 */
  3943. .condition-body {
  3944. display: grid;
  3945. grid-template-columns: 1fr 1fr;
  3946. gap: 16px 32px;
  3947. align-items: start;
  3948. }
  3949. .condition-col {
  3950. display: flex;
  3951. flex-direction: column;
  3952. gap: 4px;
  3953. min-width: 0;
  3954. }
  3955. .condition-col-right {
  3956. border-left: 1px solid #f0f0f0;
  3957. padding-left: 32px;
  3958. }
  3959. @media (max-width: 720px) {
  3960. .condition-body {
  3961. grid-template-columns: 1fr;
  3962. }
  3963. .condition-col-right {
  3964. border-left: none;
  3965. padding-left: 0;
  3966. padding-top: 16px;
  3967. border-top: 1px solid #f0f0f0;
  3968. }
  3969. }
  3970. /* 每行一项:左侧标签列(固定列宽)、右侧控件列(自适应),所有控件左对齐 */
  3971. .form-item-condition {
  3972. margin-bottom: 0;
  3973. }
  3974. .form-item-condition :deep(.ant-form-item-row) {
  3975. display: grid;
  3976. grid-template-columns: 180px 1fr;
  3977. gap: 16px;
  3978. align-items: center;
  3979. }
  3980. .form-item-condition :deep(.ant-form-item-label) {
  3981. padding-bottom: 0;
  3982. width: 100%;
  3983. max-width: 180px;
  3984. min-width: 0;
  3985. }
  3986. .form-item-condition :deep(.ant-form-item-label > label) {
  3987. white-space: normal;
  3988. line-height: 1.4;
  3989. }
  3990. .form-item-condition :deep(.ant-form-item-control) {
  3991. min-width: 0;
  3992. flex: 0 1 auto;
  3993. }
  3994. .form-item-condition :deep(.ant-form-item-control-input) {
  3995. min-height: auto;
  3996. }
  3997. /* 区间类:BUFF价格区间、BUFF在售数量 — 两个输入框横向排列 */
  3998. .range-inputs-row {
  3999. display: inline-flex;
  4000. flex-direction: row;
  4001. align-items: center;
  4002. flex-wrap: nowrap;
  4003. gap: 0;
  4004. }
  4005. .range-inputs-row .range-sep {
  4006. flex-shrink: 0;
  4007. }
  4008. .form-item-condition :deep(.input-narrow) {
  4009. width: 88px;
  4010. }
  4011. .form-item-condition :deep(.input-narrow .ant-input-number-input) {
  4012. font-size: 13px;
  4013. }
  4014. .form-item-condition :deep(.input-time) {
  4015. width: 100%;
  4016. max-width: 120px;
  4017. }
  4018. .form-item-condition :deep(.input-range) {
  4019. width: 80px;
  4020. }
  4021. .form-item-condition :deep(.input-range .ant-input-number-input) {
  4022. font-size: 13px;
  4023. }
  4024. .range-sep {
  4025. margin: 0 4px;
  4026. color: #999;
  4027. font-size: 13px;
  4028. }
  4029. /* 问号说明:紧挨标签后 */
  4030. .label-tip-icon {
  4031. display: inline-flex;
  4032. align-items: center;
  4033. justify-content: center;
  4034. margin-left: 4px;
  4035. width: 14px;
  4036. height: 14px;
  4037. border-radius: 50%;
  4038. background: #d9d9d9;
  4039. color: #666;
  4040. font-size: 12px;
  4041. font-weight: 600;
  4042. cursor: help;
  4043. line-height: 1;
  4044. }
  4045. .label-tip-icon:hover {
  4046. background: #1677ff;
  4047. color: #fff;
  4048. }
  4049. .form-item-condition :deep(.ant-form-item-label > label) {
  4050. display: inline-flex;
  4051. align-items: center;
  4052. }
  4053. .button-section {
  4054. padding: 16px 0 12px;
  4055. display: flex;
  4056. justify-content: center;
  4057. }
  4058. .button-section .ant-btn {
  4059. width: 200px;
  4060. }
  4061. .table-section .section-body {
  4062. flex: 1;
  4063. min-height: 0;
  4064. overflow: auto;
  4065. }
  4066. .table-wrap {
  4067. min-height: 200px;
  4068. }
  4069. /* 选品数据表格:饰品图片列 */
  4070. .selection-goods-icon {
  4071. width: 48px;
  4072. height: 48px;
  4073. object-fit: contain;
  4074. display: block;
  4075. margin: 0 auto;
  4076. }
  4077. /* 选品日志弹窗 */
  4078. .log-modal-title {
  4079. display: flex;
  4080. align-items: center;
  4081. justify-content: flex-start;
  4082. gap: 8px;
  4083. }
  4084. .selection-log-body {
  4085. min-height: 120px;
  4086. max-height: 60vh;
  4087. overflow-y: auto;
  4088. }
  4089. .selection-log-empty {
  4090. color: #999;
  4091. text-align: center;
  4092. padding: 24px;
  4093. }
  4094. .selection-log-lines {
  4095. font-family: ui-monospace, monospace;
  4096. font-size: 13px;
  4097. line-height: 1.6;
  4098. color: #000000;
  4099. }
  4100. .selection-log-lines .log-line {
  4101. padding: 2px 0;
  4102. border-bottom: 1px solid #f0f0f0;
  4103. word-break: break-all;
  4104. }
  4105. .selection-log-lines .log-line-start,
  4106. .selection-log-lines .log-line-end {
  4107. font-weight: 600;
  4108. color: #1677ff;
  4109. }
  4110. .selection-log-lines .log-line-selected-strong {
  4111. color: #1f7a1f;
  4112. font-weight: 700;
  4113. }
  4114. .selection-log-lines .log-line-selected {
  4115. color: #52c41a;
  4116. }
  4117. .selection-log-lines .log-line-sweep-success {
  4118. color: #ff4d4f;
  4119. font-weight: 600;
  4120. }
  4121. .selection-log-lines .log-line-stage {
  4122. color: #1677ff;
  4123. }
  4124. .selection-log-lines .log-line-not-selected {
  4125. color: #000000;
  4126. }
  4127. /* API 详情日志样式 */
  4128. .selection-log-lines .api-log-header {
  4129. cursor: pointer;
  4130. display: flex;
  4131. align-items: flex-start;
  4132. gap: 4px;
  4133. }
  4134. .selection-log-lines .api-log-toggle {
  4135. color: #1677ff;
  4136. font-size: 10px;
  4137. margin-top: 2px;
  4138. flex-shrink: 0;
  4139. }
  4140. .selection-log-lines .api-log-summary {
  4141. flex: 1;
  4142. word-break: break-all;
  4143. }
  4144. .selection-log-lines .api-log-detail {
  4145. margin-left: 18px;
  4146. margin-top: 4px;
  4147. padding: 8px;
  4148. background: #f5f5f5;
  4149. border-radius: 4px;
  4150. font-size: 12px;
  4151. }
  4152. .selection-log-lines .api-detail-section {
  4153. margin-bottom: 8px;
  4154. }
  4155. .selection-log-lines .api-detail-section:last-child {
  4156. margin-bottom: 0;
  4157. }
  4158. .selection-log-lines .api-detail-title {
  4159. font-weight: 600;
  4160. color: #333;
  4161. margin-bottom: 4px;
  4162. border-bottom: 1px solid #ddd;
  4163. padding-bottom: 2px;
  4164. }
  4165. .selection-log-lines .api-detail-row {
  4166. margin: 2px 0;
  4167. display: flex;
  4168. flex-wrap: wrap;
  4169. gap: 4px;
  4170. }
  4171. .selection-log-lines .api-detail-label {
  4172. font-weight: 500;
  4173. color: #666;
  4174. min-width: 70px;
  4175. }
  4176. .selection-log-lines .api-detail-code {
  4177. font-family: ui-monospace, monospace;
  4178. font-size: 11px;
  4179. background: #e8e8e8;
  4180. padding: 1px 4px;
  4181. border-radius: 2px;
  4182. word-break: break-all;
  4183. max-width: 100%;
  4184. }
  4185. .selection-log-lines .api-detail-pre {
  4186. font-family: ui-monospace, monospace;
  4187. font-size: 11px;
  4188. background: #fff;
  4189. padding: 4px;
  4190. border-radius: 2px;
  4191. margin: 2px 0 0 0;
  4192. white-space: pre-wrap;
  4193. word-break: break-all;
  4194. max-width: 100%;
  4195. overflow-x: auto;
  4196. border: 1px solid #e8e8e8;
  4197. }
  4198. .selection-log-lines .api-detail-response {
  4199. max-height: 200px;
  4200. overflow-y: auto;
  4201. }
  4202. .selection-log-lines .api-detail-error {
  4203. color: #ff4d4f;
  4204. font-family: ui-monospace, monospace;
  4205. font-size: 11px;
  4206. word-break: break-all;
  4207. }
  4208. </style>
  4209. <!-- 排除/指定饰品 id 弹窗:wrap 在 teleport 中,需非 scoped 才能生效 -->
  4210. <style>
  4211. .ids-list-modal-wrap.ant-modal-wrap .ant-modal { max-height: 90vh; }
  4212. .ids-list-modal-wrap.ant-modal-wrap .ant-modal-content { max-height: 90vh; display: flex; flex-direction: column; }
  4213. .ids-list-modal-wrap.ant-modal-wrap .ant-modal-body { max-height: 70vh; overflow: hidden; display: flex; flex-direction: column; padding-bottom: 16px; }
  4214. </style>