| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587 |
- <template>
- <!-- eslint-disable vue/no-v-model-argument -->
- <div class="buff-selection-container">
- <div class="buff-card">
- <!-- 条件设置区域:可折叠 -->
- <div class="section conditions-section" :class="{ 'is-collapsed': !conditionsExpanded }">
- <!-- 折叠时的摘要行 -->
- <div class="conditions-summary-bar" @click="conditionsExpanded = !conditionsExpanded">
- <div class="conditions-summary-content">
- <span class="conditions-summary-icon">⚙️</span>
- <span class="conditions-summary-text" v-html="conditionsSummary"></span>
- </div>
- <div class="conditions-summary-toggle">
- <span class="toggle-hint">{{ conditionsExpanded ? '收起' : '展开' }}</span>
- <span class="toggle-icon">{{ conditionsExpanded ? '▲' : '▼' }}</span>
- </div>
- </div>
- <!-- 展开后的详细内容 -->
- <div v-show="conditionsExpanded" class="conditions-detail">
- <!-- 第一部分:BUFF饰品检索条件 -->
- <div class="search-section">
- <div class="section-header">
- <span class="section-icon">🔍</span>
- <span class="section-title">BUFF饰品检索条件</span>
- </div>
- <div class="section-body search-body-buttons">
- <!-- 宽度自适应:宽则一行,窄则两行或三行 -->
- <div class="filter-buttons-adaptive">
- <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>
- <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>
- <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>
- <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>
- <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>
- <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>
- <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>
- <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>
- <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>
- <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>
- <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>
- </div>
- <!-- 当前检索条件(步枪~收藏品)放在下一行,紧凑展示,类别名着色,最多 3 行+滚动 -->
- <div class="selection-summary-row">
- <span class="selection-summary-label">当前检索条件(步枪~收藏品)</span>
- <div class="selection-summary-box">
- <template v-if="selectionSummaryItems.length">
- <span v-for="(item, idx) in selectionSummaryItems" :key="item.label" class="summary-item">
- <span v-if="idx > 0" class="summary-sep"> ,</span>
- <span class="summary-label">{{ item.label }}</span><span>:{{ item.valuesText }}</span>
- </span>
- </template>
- <span v-else class="summary-empty">暂未选择任何检索条件</span>
- </div>
- </div>
- </div>
- </div>
- <!-- 第二部分:选品条件(宽度自适应:宽则多列,窄则自动换行) -->
- <div class="section condition-section">
- <div class="section-header">
- <span class="section-icon">⚙️</span>
- <span class="section-title">选品/扫货条件设置</span>
- </div>
- <div class="section-body condition-body">
- <div class="condition-col">
- <a-form-item label="Steam 卡价比例" class="form-item-condition">
- <a-input-number
- v-model:value="form.steamCardRatio"
- :min="0"
- :step="0.01"
- :precision="2"
- placeholder="小数两位"
- class="input-narrow"
- />
- </a-form-item>
- <a-form-item label="利润率(%)" class="form-item-condition">
- <a-input-number
- v-model:value="form.profitRate"
- :min="0"
- :max="100"
- :step="0.1"
- :precision="2"
- placeholder="百分比"
- class="input-narrow"
- />
- </a-form-item>
- <a-form-item label="BUFF价格区间(元)" class="form-item-condition form-item-range">
- <div class="range-inputs-row">
- <a-input-number
- v-model:value="form.buffPriceMin"
- :min="0"
- placeholder="最低"
- class="input-range"
- />
- <span class="range-sep">-</span>
- <a-input-number
- v-model:value="form.buffPriceMax"
- :min="0"
- placeholder="最高"
- class="input-range"
- />
- </div>
- </a-form-item>
- <a-form-item label="BUFF在售数量" class="form-item-condition form-item-range">
- <div class="range-inputs-row">
- <a-input-number
- v-model:value="form.buffSalesMin"
- :min="0"
- placeholder="最低"
- class="input-range"
- />
- <span class="range-sep">-</span>
- <a-input-number
- v-model:value="form.buffSalesMax"
- :min="0"
- placeholder="最高"
- class="input-range"
- />
- </div>
- </a-form-item>
- </div>
- <div class="condition-col condition-col-right">
- <a-form-item label="智能选品助手" class="form-item-condition">
- <a-radio-group v-model:value="form.assistantLevel">
- <a-radio value="low">低</a-radio>
- <a-radio value="standard">标准</a-radio>
- <a-radio value="high">高</a-radio>
- </a-radio-group>
- </a-form-item>
- <a-form-item class="form-item-condition">
- <template #label>
- <span>每天自动选品的时间</span>
- <a-tooltip title="每天这个时间,自动触发选品">
- <span class="label-tip-icon">?</span>
- </a-tooltip>
- </template>
- <a-time-picker
- :value="autoSelectionTimeDayjs"
- value-format="HH:mm"
- format="HH:mm"
- placeholder="选择时间"
- class="input-time"
- @change="onAutoSelectionTimeChange"
- />
- </a-form-item>
- <a-form-item class="form-item-condition">
- <template #label>
- <span>选品数量</span>
- <a-tooltip title="客户设置选品数量,当数量达标后,自动触发扫货">
- <span class="label-tip-icon">?</span>
- </a-tooltip>
- </template>
- <a-input-number
- v-model:value="form.selectionQuantity"
- :min="0"
- :max="configStore.config.buff_selection_max_quantity ?? 300"
- placeholder="数量"
- class="input-time"
- />
- </a-form-item>
- <a-form-item label="智能扫货助手" class="form-item-condition">
- <a-radio-group v-model:value="form.sweepAssistantLevel">
- <a-radio value="low">低</a-radio>
- <a-radio value="standard">标准</a-radio>
- <a-radio value="high">高</a-radio>
- </a-radio-group>
- </a-form-item>
- <a-form-item label="扫货进程数" class="form-item-condition">
- <a-input-number
- v-model:value="form.sweepProcessCount"
- :min="1"
- :max="configStore.config.buff_sweep_max_worker_count ?? 32"
- :precision="0"
- placeholder="并行路数"
- class="input-narrow"
- />
- </a-form-item>
- </div>
- </div>
- </div>
- <!-- 第三部分:保存配置按钮 -->
- <div class="button-section">
- <a-button
- type="primary"
- :disabled="running || switchingStatus != null"
- class="save-config-btn"
- @click="openConfirmDialog({
- title: '保存配置信息',
- message: '确定要保存当前的配置信息吗?',
- okText: '保存',
- cancelText: '取消',
- iconType: 'info',
- okDanger: false,
- onConfirm: saveConditions
- })"
- >
- 保存配置信息
- </a-button>
- </div>
- </div>
- </div>
- <!-- 弹窗:设置单个检索条件(步枪/手枪/.../收藏品) -->
- <a-modal
- v-model:open="filterModalVisible"
- :title="filterModalTitle"
- width="520px"
- wrap-class-name="filter-modal-wrap"
- @cancel="closeFilterModal"
- >
- <div v-if="filterModalType" class="filter-modal-single-body">
- <a-form-item v-if="filterModalType !== 'collection'" :label="filterModalTypeLabel" class="form-item-full">
- <a-select
- v-model:value="filterDraftSingle"
- mode="multiple"
- :placeholder="'请选择' + filterModalTypeLabel"
- allow-clear
- :options="filterModalOptions"
- class="filter-select-full"
- />
- </a-form-item>
- <a-form-item v-else label="收藏品" class="form-item-full">
- <a-tree-select
- v-model:value="filterDraftSingle"
- :tree-data="filterOptions.collectionTree"
- placeholder="请选择收藏品(可多选)"
- allow-clear
- multiple
- tree-checkable
- :show-checked-strategy="TreeSelect.SHOW_CHILD"
- tree-default-expand-all
- class="filter-select-full collection-tree-select"
- />
- </a-form-item>
- </div>
- <template #footer>
- <a-button @click="closeFilterModal">取消</a-button>
- <a-button type="primary" @click="confirmFilterModal">确定</a-button>
- </template>
- </a-modal>
- <!-- 弹窗:排除的饰品id / 指定的饰品id(上传 + 列表 + 删除) -->
- <a-modal
- v-model:open="idsListModalVisible"
- :title="idsListModalTitle"
- width="640px"
- :footer="null"
- destroy-on-close
- wrap-class-name="ids-list-modal-wrap"
- @after-open-change="onIdsModalOpenChange"
- >
- <div class="ids-modal-toolbar">
- <a-button type="primary" size="small" @click="uploadTxtInIdsModal">上传</a-button>
- <a-button danger size="small" :disabled="!idsListSelectedRowKeys.length" @click="batchRemoveIdsList">
- 批量删除
- </a-button>
- </div>
- <div class="ids-modal-body">
- <a-spin :spinning="idsListLoading">
- <a-table
- :columns="idsListColumnsWithAction"
- :data-source="idsListData"
- :pagination="false"
- :row-selection="{ selectedRowKeys: idsListSelectedRowKeys, onChange: onIdsListSelectionChange }"
- size="small"
- row-key="goods_id"
- >
- <template #bodyCell="{ column, record }">
- <template v-if="column.key === 'icon'">
- <img
- v-if="record.icon_url"
- :src="record.icon_url"
- alt=""
- class="goods-icon-img"
- @error="handleGoodsIconError"
- />
- <span v-else class="goods-icon-placeholder">—</span>
- </template>
- <template v-else-if="column.key === 'action'">
- <a-button type="link" danger size="small" @click="removeIdFromIdsList(record.goods_id)">删除</a-button>
- </template>
- </template>
- <template #emptyText>
- <a-empty description="暂无饰品 id,可点击上传添加" />
- </template>
- </a-table>
- </a-spin>
- </div>
- </a-modal>
- <!-- 第四部分:选品数据展示 -->
- <div class="section table-section">
- <div class="section-header section-header-with-action">
- <span class="section-icon">📋</span>
- <span class="section-title">选品数据</span>
- <div class="selection-search-bar">
- <a-input
- v-model:value="selectionSearchKeyword"
- placeholder="按饰品ID或名称搜索"
- class="selection-search-input"
- allow-clear
- @pressEnter="onSelectionSearch"
- />
- <span v-if="selectionSearchKeyword && filteredSelectionList.length !== selectionList.length" class="selection-search-hint">
- 匹配 {{ filteredSelectionList.length }} / {{ selectionList.length }}
- </span>
- </div>
- <div class="section-actions">
- <a-button
- type="link"
- danger
- size="small"
- :disabled="selectionSelectedRowKeys.length === 0 || selectionDeleting"
- @click="openConfirmDialog({
- title: '批量删除',
- message: `确定要删除选中的 ${selectionSelectedRowKeys.length} 条数据吗?`,
- okText: '删除',
- cancelText: '取消',
- iconType: 'danger',
- okDanger: true,
- onConfirm: handleBatchDeleteSelectionItems
- })"
- >
- 批量删除
- </a-button>
- <a-button type="link" size="small" class="btn-log" @click="openSelectionLogModal">
- 查看选品日志
- </a-button>
- </div>
- </div>
- <div class="section-body table-wrap">
- <a-table
- :columns="columns"
- :data-source="filteredSelectionList"
- :pagination="{ ...tablePagination, total: filteredSelectionList.length }"
- :loading="tableLoading"
- :row-key="rowKey"
- :row-selection="{ selectedRowKeys: selectionSelectedRowKeys, onChange: onSelectionListSelectionChange }"
- :scroll="{ y: 400 }"
- size="small"
- @change="onSelectionTableChange"
- >
- <template #bodyCell="{ column, record, index }">
- <template v-if="column.key === 'index'">
- {{ (tablePagination.current - 1) * tablePagination.pageSize + index + 1 }}
- </template>
- <template v-else-if="column.key === 'icon'">
- <img
- v-if="record.icon_url"
- :src="record.icon_url"
- alt=""
- class="selection-goods-icon"
- @error="handleGoodsIconError"
- />
- <span v-else class="goods-icon-placeholder">—</span>
- </template>
- <template v-else-if="column.key === 'price_cny'">
- {{ formatCurrencyPrice(record.price_cny, '¥') }}
- </template>
- <template v-else-if="column.key === 'price_usd'">
- {{ formatCurrencyPrice(record.price_usd, '$') }}
- </template>
- <template v-else-if="column.key === 'created_at'">
- {{ formatSelectionTime(record.created_at) }}
- </template>
- <template v-else-if="column.key === 'action'">
- <a-button type="link" danger size="small" @click="openConfirmDialog({
- title: '删除选品',
- message: '确定要删除该选品数据吗?',
- okText: '删除',
- cancelText: '取消',
- iconType: 'danger',
- okDanger: true,
- onConfirm: () => handleDeleteSelectionItem(record)
- })">
- 删除
- </a-button>
- </template>
- </template>
- <template #emptyText>
- <a-empty description="暂无选品数据" />
- </template>
- </a-table>
- </div>
- </div>
- <!-- 第五部分:扫货成功数据展示 -->
- <div class="section table-section">
- <div class="section-header section-header-with-action">
- <div class="section-header-left">
- <span class="section-icon">🧾</span>
- <span class="section-title">扫货成功数据</span>
- <div class="selection-search-bar">
- <a-input
- v-model:value="sweepSearchKeyword"
- placeholder="按饰品ID或名称搜索"
- class="selection-search-input"
- allow-clear
- />
- <span v-if="sweepSearchKeyword && filteredSweepList.length !== sweepList.length" class="selection-search-hint">
- 匹配 {{ filteredSweepList.length }} / {{ sweepList.length }}
- </span>
- </div>
- </div>
- <div class="section-actions">
- <a-button type="link" size="small" class="btn-export" @click="exportSweepTxt">
- 导出TXT
- </a-button>
- <a-button type="link" size="small" class="btn-export" :loading="exportingSweepExcel" @click="exportSweepExcel">
- 导出Excel
- </a-button>
- <a-divider type="vertical" class="btn-divider" />
- <a-button type="link" size="small" class="btn-log" @click="openSweepLogModal">
- 查看扫货日志
- </a-button>
- </div>
- </div>
- <div class="section-body table-wrap">
- <a-table
- :columns="filteredSweepColumns"
- :data-source="paginatedSweepList"
- :pagination="sweepPagination"
- :loading="sweepTableLoading"
- :row-key="sweepRowKey"
- :scroll="{ y: 400 }"
- size="small"
- @change="onSweepTableChange"
- >
- <template #bodyCell="{ column, record, index }">
- <template v-if="column.key === 'index'">
- {{ (sweepPagination.current - 1) * sweepPagination.pageSize + index + 1 }}
- </template>
- <template v-else-if="column.key === 'icon'">
- <img
- v-if="record.icon_url"
- :src="record.icon_url"
- alt=""
- class="selection-goods-icon"
- @error="handleGoodsIconError"
- />
- <span v-else class="goods-icon-placeholder">—</span>
- </template>
- <template v-else-if="column.key === 'target_price_usd'">
- {{ formatCurrencyPrice(record.target_price_usd, '$') }}
- </template>
- <template v-else-if="column.key === 'purchase_price_usd'">
- {{ formatCurrencyPrice(record.purchase_price_usd, '$') }}
- </template>
- <template v-else-if="column.key === 'purchase_price_cny'">
- {{ formatCurrencyPrice(record.purchase_price_cny, '¥') }}
- </template>
- <template v-else-if="column.key === 'purchase_wear'">
- {{ formatWearValue(record.purchase_wear) }}
- </template>
- <template v-else-if="column.key === 'steam_account'">
- {{ record.steam_account || '—' }}
- </template>
- <template v-else-if="column.key === 'created_at'">
- {{ formatSelectionTime(record.created_at) }}
- </template>
- </template>
- <template #emptyText>
- <a-empty description="暂无扫货成功数据" />
- </template>
- </a-table>
- </div>
- </div>
- <!-- 第六部分:已绑定 Steam 账号 -->
- <div class="section table-section">
- <div class="section-header section-header-with-action">
- <div class="section-header-left">
- <span class="section-icon">🎮</span>
- <span class="section-title">已绑定 Steam 账号</span>
- </div>
- <div class="section-actions">
- <a-button
- type="link"
- size="small"
- :loading="boundSteamRefreshingAll"
- :disabled="boundSteamLoading || !boundSteamAccounts.length || boundSteamRefreshingAll"
- @click="handleRefreshAllBoundSteamBalances"
- >
- 立即刷新全部余额
- </a-button>
- </div>
- </div>
- <div class="section-body table-wrap">
- <a-table
- :columns="boundSteamColumns"
- :data-source="boundSteamAccounts"
- :loading="boundSteamLoading"
- :pagination="false"
- :row-key="(record: BoundSteamRow) => record.steam_username"
- size="small"
- :scroll="{ x: 900 }"
- >
- <template #bodyCell="{ column, record }">
- <template v-if="column.key === 'balance'">
- <span v-if="isAutoRelogining(record.steam_username)">
- <a-spin size="small" /> 重新登录中...
- </span>
- <span v-else-if="boundSteamBalanceQueryUsername === record.steam_username">
- <a-spin size="small" /> 查询中...
- </span>
- <span v-else>
- {{ formatSteamBalance(record.steam_username) }}
- <span v-if="getFailCount(record.steam_username) >= 10" style="color: #faad14; margin-left: 4px;">
- <QuestionCircleOutlined title="余额连续查询失败,正在自动重新登录" />
- </span>
- </span>
- </template>
- <template v-else-if="column.key === 'is_active'">
- <a-switch
- :checked="record.is_active !== false"
- checked-children="参与"
- un-checked-children="不参与"
- size="small"
- @change="(checked: boolean) => handleBoundSteamActiveChange(record, checked)"
- />
- </template>
- <template v-else-if="column.key === 'cardRate'">
- <a-input-number
- :value="record.cardRate"
- :min="0"
- :max="10"
- :precision="2"
- size="small"
- style="width: 60px;"
- placeholder="比例"
- @change="(val: number | null) => handleBoundSteamCardRateChange(record, val)"
- />
- </template>
- <template v-else-if="column.key === 'status'">
- <a-tag v-if="isAutoRelogining(record.steam_username)" color="processing">
- 自动登录中
- </a-tag>
- <a-tag v-else-if="getFailCount(record.steam_username) >= 5 && getFailCount(record.steam_username) < 10" color="warning">
- 登录异常 ({{ getFailCount(record.steam_username) }}/10)
- </a-tag>
- <a-tag v-else-if="getFailCount(record.steam_username) >= 10 && !isAutoRelogining(record.steam_username)" color="error">
- 登录失效
- </a-tag>
- <a-tag v-else color="success">
- 正常
- </a-tag>
- </template>
- <template v-else-if="column.key === 'action'">
- <a-button
- type="link"
- size="small"
- :loading="boundSteamReloginUsername === record.steam_username"
- :disabled="boundSteamReloginUsername !== null || !record.hasSteamLoginSecure || boundSteamRefreshingAll"
- @click="handleBoundSteamRelogin(record)"
- >
- 重新登录
- </a-button>
- <a-button
- type="link"
- size="small"
- :loading="boundSteamBalanceQueryUsername === record.steam_username"
- :disabled="boundSteamBalanceQueryUsername !== null || !record.hasSteamLoginSecure || boundSteamRefreshingAll"
- @click="handleBoundSteamBalanceQuery(record)"
- >
- 查询余额
- </a-button>
- </template>
- </template>
- <template #emptyText>
- <a-empty description="暂无已绑定 Steam 账号" />
- </template>
- </a-table>
- </div>
- </div>
- <!-- 选品日志弹窗 -->
- <a-modal
- v-model:open="selectionLogModalVisible"
- width="720px"
- :footer="null"
- wrap-class-name="selection-log-modal-wrap"
- destroy-on-close
- @after-open-change="onSelectionLogModalOpenChange"
- >
- <template #title>
- <div class="log-modal-title">
- <span>选品日志</span>
- <a-switch
- v-model:checked="selectionLogAutoFollow"
- size="small"
- checked-children="跟随最新"
- un-checked-children="暂停跟随"
- @change="onSelectionLogFollowChange"
- />
- </div>
- </template>
- <div class="selection-log-body" ref="selectionLogBodyRef" @scroll="onSelectionLogBodyScroll">
- <a-spin :spinning="selectionLogLoading">
- <div v-if="!selectionLogData.started_at && !selectionLogData.logs.length" class="selection-log-empty">
- 暂无选品记录
- </div>
- <div v-else class="selection-log-lines">
- <div v-if="selectionLogData.started_at" class="log-line log-line-start">
- {{ selectionLogData.started_at }} 开始选品
- </div>
- <div
- v-for="(entry, idx) in selectionLogData.logs"
- :key="`${entry.created_at ?? 'na'}-${entry.goods_id}-${idx}`"
- class="log-line"
- :class="entry.result === 'selected' ? 'log-line-selected-strong' : (entry.result === 'stage' ? 'log-line-stage' : 'log-line-not-selected')"
- >
- {{ formatLogLine(entry) }}
- </div>
- <div v-if="selectionLogData.ended_at" class="log-line log-line-end">
- {{ selectionLogData.ended_at }} 结束选品
- </div>
- </div>
- </a-spin>
- </div>
- </a-modal>
- <!-- 扫货日志弹窗 - 按钮组 + 单一日志显示区布局 -->
- <a-modal
- v-model:open="sweepLogModalVisible"
- width="900px"
- :footer="null"
- wrap-class-name="selection-log-modal-wrap"
- destroy-on-close
- @after-open-change="onSweepLogModalOpenChange"
- >
- <template #title>
- <div class="log-modal-title">
- <span>扫货日志</span>
- <a-switch
- v-model:checked="sweepLogAutoFollow"
- size="small"
- checked-children="跟随最新"
- un-checked-children="暂停跟随"
- @change="onSweepLogFollowChange"
- />
- </div>
- </template>
- <!-- Worker 按钮组 -->
- <div class="sweep-log-worker-buttons" style="margin-bottom: 12px; display: flex; gap: 8px;">
- <a-button
- v-for="worker in sweepWorkerList"
- :key="worker.worker_index"
- :type="sweepLogActiveWorkerIndex === worker.worker_index ? 'primary' : 'default'"
- @click="onWorkerButtonClick(worker.worker_index)"
- >
- 线程 {{ worker.worker_index }}
- </a-button>
- </div>
- <!-- 日志显示区域 - tail 风格实时滚动 -->
- <div class="selection-log-body" :ref="setSweepLogBodyRef" @scroll="onSweepLogBodyScroll">
- <div class="debug-panel" style="background:#f0f0f0;padding:8px;margin-bottom:8px;font-size:12px;">
- <div>线程 {{ sweepLogActiveWorkerIndex }} - 当前日志数: {{ currentWorkerLogs.length }}</div>
- <div>request_id: {{ sweepLogData.request_id ?? 'null' }}</div>
- </div>
- <div class="selection-log-lines">
- <div
- v-for="(entry, idx) in currentWorkerLogs"
- :key="`W${sweepLogActiveWorkerIndex}-${entry.created_at ?? 'na'}-${entry.goods_id}-${idx}`"
- class="log-line sweep-log-line-dynamic"
- :style="sweepLogLineStyle(entry)"
- >
- <!-- API 详情日志可展开 -->
- <template v-if="isApiDetailLog(entry)">
- <div class="api-log-header" @click="toggleApiLogExpand(`${sweepLogActiveWorkerIndex}-${idx}`)">
- <span class="api-log-toggle">{{ expandedApiLogs.has(`${sweepLogActiveWorkerIndex}-${idx}`) ? '▼' : '▶' }}</span>
- <span class="api-log-summary">{{ formatSweepLogLine(entry) }}</span>
- </div>
- <div v-if="expandedApiLogs.has(`${sweepLogActiveWorkerIndex}-${idx}`)" class="api-log-detail">
- <div class="api-detail-section">
- <div class="api-detail-title">📋 请求信息</div>
- <div class="api-detail-row"><span class="api-detail-label">账号:</span> {{ entry.steam_account || '—' }}</div>
- <div class="api-detail-row"><span class="api-detail-label">方法:</span> {{ entry.method || '—' }}</div>
- <div class="api-detail-row"><span class="api-detail-label">URL:</span> <code class="api-detail-code">{{ entry.url || '—' }}</code></div>
- </div>
- <div class="api-detail-section">
- <div class="api-detail-title">📨 响应信息</div>
- <div class="api-detail-row">
- <span class="api-detail-label">状态码:</span>
- <span :style="{ color: isSuccessStatus(entry.response_status) ? '#52c41a' : '#ff4d4f' }">
- {{ entry.response_status || '—' }}
- </span>
- </div>
- <div class="api-detail-row"><span class="api-detail-label">耗时:</span> {{ entry.elapsed_ms || '—' }}ms</div>
- </div>
- </div>
- </template>
- <!-- 非 API 详情日志正常显示 -->
- <template v-else>
- {{ formatSweepLogLine(entry) }}
- </template>
- </div>
- </div>
- </div>
- </a-modal>
- <!-- 固定底部操作按钮 -->
- <div class="fixed-bottom-buttons">
- <div class="fixed-bottom-vip">
- <a-tag :color="authStore.isExpiringSoon ? 'orange' : (authStore.isExpired ? 'red' : (authStore.daysRemaining > 0 ? 'gold' : 'default'))">
- {{ authStore.isExpired ? '已过期' : (authStore.daysRemaining > 0 ? `VIP会员 (剩余${authStore.daysRemaining}天)` : '未激活') }}
- </a-tag>
- <span class="fixed-bottom-vip-expire" v-if="authStore.expireTime">
- 到期时间: {{ formatDate(authStore.expireTime) }}
- </span>
- </div>
- <div class="fixed-bottom-buttons-inner">
- <div class="buff-status-indicator" v-if="running || sweepRunning || switchingStatus">
- <LoadingOutlined v-if="switchingStatus" spin />
- <ShoppingOutlined v-else />
- <span class="status-text" :class="{ 'is-switching': switchingStatus, 'is-selection': running, 'is-sweep': sweepRunning }">
- <template v-if="switchingStatus">切换中</template>
- <template v-else-if="running">选品中</template>
- <template v-else-if="sweepRunning">扫货中</template>
- </span>
- </div>
- <a-button
- class="btn-start"
- type="primary"
- :loading="running || (switchingStatus === 'resetting_before_start' && buffPrepareFor === 'selection')"
- :disabled="switchingStatus != null || !canStartSelection"
- @click="openConfirmDialog({
- title: '开始选品',
- message: '确定要开始选品吗?',
- okText: '开始',
- cancelText: '取消',
- iconType: 'info',
- okDanger: false,
- onConfirm: () => handleStart('manual')
- })"
- >
- 开始选品
- </a-button>
- <a-button class="btn-stop" danger :disabled="!running" @click="openConfirmDialog({
- title: '停止选品',
- message: '确定要停止选品吗?',
- okText: '停止',
- cancelText: '取消',
- iconType: 'warning',
- okDanger: true,
- onConfirm: handleStop
- })">
- 停止选品
- </a-button>
- <a-button
- class="btn-sweep-start"
- type="primary"
- :loading="sweepRunning || (switchingStatus === 'resetting_before_start' && buffPrepareFor === 'sweep')"
- :disabled="switchingStatus != null || !canStartSweep"
- @click="openConfirmDialog({
- title: '开始扫货',
- message: '确定要开始扫货吗?',
- okText: '开始',
- cancelText: '取消',
- iconType: 'info',
- okDanger: false,
- onConfirm: () => handleStartSweep(false)
- })"
- >
- 开始扫货
- </a-button>
- <a-button class="btn-sweep-stop" danger :disabled="!sweepRunning" @click="openConfirmDialog({
- title: '停止扫货',
- message: '确定要停止扫货吗?',
- okText: '停止',
- cancelText: '取消',
- iconType: 'warning',
- okDanger: true,
- onConfirm: handleStopSweep
- })">
- 停止扫货
- </a-button>
- </div>
- </div>
- </div>
- <!-- 确认弹窗 -->
- <ConfirmDialog
- v-model:isOpen="confirmDialogVisible"
- :title="confirmDialogConfig.title"
- :message="confirmDialogConfig.message"
- :ok-text="confirmDialogConfig.okText"
- :cancel-text="confirmDialogConfig.cancelText"
- :icon-type="confirmDialogConfig.iconType"
- :ok-danger="confirmDialogConfig.okDanger"
- @confirm="handleConfirmDialog"
- @cancel="confirmDialogVisible = false"
- />
- </div>
- </template>
- <script setup lang="ts">
- /* eslint-disable vue/no-v-model-argument */
- import { ref, reactive, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
- import { message, TreeSelect } from 'ant-design-vue'
- import { LoadingOutlined, ShoppingOutlined, QuestionCircleOutlined } from '@ant-design/icons-vue'
- import dayjs, { type Dayjs } from 'dayjs'
- import { open as openDialog } from '@tauri-apps/api/dialog'
- import { invoke } from '@tauri-apps/api/tauri'
- import { secureApiClient } from '../utils/secureApiClient'
- import { useAuthStore } from '../stores/authStore'
- import { useBuffSweepStore } from '../stores/buffSweepStore'
- import { useConfigStore } from '../stores/configStore'
- import { formatDate } from '../utils/formatDate'
- import ConfirmDialog from './components/ConfirmDialog.vue'
- const STORAGE_KEY = 'buff_selection_conditions'
- const DEBOUNCE_SAVE_MS = 600
- const authStore = useAuthStore()
- const buffSweepStore = useBuffSweepStore()
- const configStore = useConfigStore()
- const emit = defineEmits<{
- (
- e: 'status-change',
- payload: {
- selectionRunning: boolean
- sweepRunning: boolean
- hasSelectionData: boolean
- switchingStatus: 'resetting_before_start' | null
- }
- ): void
- }>()
- /** 初始加载完成后才启用自动保存,避免加载云端/本地配置时误触发保存 */
- const initialLoadDone = ref(false)
- let configSaveTimer: ReturnType<typeof setTimeout> | null = null
- /** 条件设置区域展开/收起状态 */
- const conditionsExpanded = ref(false)
- // 表单:检索条件 + 选品条件
- const form = reactive({
- rifle: [] as string[],
- pistol: [] as string[],
- smg: [] as string[],
- shotgun: [] as string[],
- machinegun: [] as string[],
- quality: [] as string[],
- category: [] as string[],
- exterior: [] as string[],
- collection: [] as string[],
- excludeIds: '',
- specifiedIds: '',
- steamCardRatio: undefined as number | undefined,
- profitRate: undefined as number | undefined,
- buffPriceMin: undefined as number | undefined,
- buffPriceMax: undefined as number | undefined,
- buffSalesMin: undefined as number | undefined,
- buffSalesMax: undefined as number | undefined,
- assistantLevel: 'low' as 'low' | 'standard' | 'high',
- /** 每天自动选品的时间,格式 HH:mm */
- autoSelectionTime: undefined as string | undefined,
- /** 选品数量,达标后自动触发扫货 */
- selectionQuantity: undefined as number | undefined,
- /** 智能扫货助手:低/标准/高,对应扫货价格系数 min/mid/max,默认低 */
- sweepAssistantLevel: 'low' as 'low' | 'standard' | 'high',
- /** 扫货并行进程数(与饰品数取小),默认 3 */
- sweepProcessCount: 3 as number
- })
- // 下拉选项:从后端 buff_filter_option 表加载;收藏品为树形 collectionTree
- const filterOptions = reactive({
- rifle: [] as { label: string; value: string }[],
- pistol: [] as { label: string; value: string }[],
- smg: [] as { label: string; value: string }[],
- shotgun: [] as { label: string; value: string }[],
- machinegun: [] as { label: string; value: string }[],
- quality: [] as { label: string; value: string }[],
- category: [] as { label: string; value: string }[],
- exterior: [] as { label: string; value: string }[],
- collection: [] as { label: string; value: string }[],
- collectionTree: [] as { title: string; value: string; key: string; children?: { title: string; value: string; key: string }[] }[]
- })
- // 检索条件弹窗:当前编辑项 + 单选项草稿
- type FilterModalType = 'rifle' | 'pistol' | 'smg' | 'shotgun' | 'machinegun' | 'quality' | 'category' | 'exterior' | 'collection'
- const filterModalVisible = ref(false)
- const filterModalType = ref<FilterModalType | null>(null)
- const filterDraftSingle = ref<string[]>([])
- const FILTER_LABELS: Record<FilterModalType, string> = {
- rifle: '步枪',
- pistol: '手枪',
- smg: '微型冲锋枪',
- shotgun: '霰弹枪',
- machinegun: '机枪',
- quality: '品质',
- category: '类别',
- exterior: '外观',
- collection: '收藏品'
- }
- /** 品质、类别、外观:从数据库加载后仅保留以下 label 的选项(按 label 过滤) */
- const ALLOWED_QUALITY_LABELS = ['违禁', '隐秘', '保密', '受限', '军规级', '工业级', '消费级']
- const ALLOWED_CATEGORY_LABELS = ['普通', 'StatTrak™']
- const ALLOWED_EXTERIOR_LABELS = ['崭新出厂', '略有磨损', '久经沙场']
- function filterOptionsByLabel(
- options: { label: string; value: string }[],
- allowedLabels: string[]
- ): { label: string; value: string }[] {
- return options.filter((o) => allowedLabels.includes(o.label))
- }
- /** 每天自动选品时间:用于 TimePicker 的 dayjs 值 */
- const autoSelectionTimeDayjs = computed<Dayjs | null>(() => {
- const t = form.autoSelectionTime
- if (!t || typeof t !== 'string') return null
- const [h, m] = t.split(':').map(Number)
- if (Number.isNaN(h) || Number.isNaN(m)) return null
- return dayjs().hour(h).minute(m).second(0).millisecond(0)
- })
- function onAutoSelectionTimeChange(time: Dayjs | null, timeString: string | string[]) {
- if (timeString != null) {
- form.autoSelectionTime = Array.isArray(timeString) ? timeString[0] : (timeString as string) || undefined
- } else if (time) {
- form.autoSelectionTime = time.format('HH:mm')
- } else {
- form.autoSelectionTime = undefined
- }
- }
- const filterModalTitle = computed(() =>
- filterModalType.value ? `设置${FILTER_LABELS[filterModalType.value]}条件` : '设置检索条件'
- )
- const filterModalTypeLabel = computed(() =>
- filterModalType.value ? FILTER_LABELS[filterModalType.value] : ''
- )
- const filterModalOptions = computed(() => {
- const key = filterModalType.value
- if (!key || key === 'collection') return []
- const opts = filterOptions[key]
- return Array.isArray(opts) ? opts : []
- })
- // 排除/指定饰品 id 弹窗
- const idsListModalVisible = ref(false)
- const idsListModalType = ref<'exclude' | 'specified' | null>(null)
- const idsListModalTitle = computed(() =>
- idsListModalType.value === 'exclude' ? '排除的饰品id' : idsListModalType.value === 'specified' ? '指定的饰品id' : ''
- )
- const idsListLoading = ref(false)
- const idsListData = ref<{ goods_id: string; name: string; icon_url: string }[]>([])
- const idsListSelectedRowKeys = ref<string[]>([])
- const idsListColumnsWithAction = [
- { title: '商品id', dataIndex: 'goods_id', key: 'goods_id', width: 120 },
- { title: '商品头像', key: 'icon', width: 80 },
- { title: '商品名称', dataIndex: 'name', key: 'name', ellipsis: true },
- { title: '操作', key: 'action', width: 80 }
- ]
- /** 将 value 列表转为 label 列表(用于只读摘要) */
- function valuesToLabels(values: string[], options: { label: string; value: string }[]): string[] {
- if (!values?.length) return []
- const map = new Map(options.map((o) => [o.value, o.label]))
- return values.map((v) => map.get(v) ?? v)
- }
- /** 将 { label, value }[] 转为 string[](提取 label) */
- function extractLabels(items: { label: string; value: string }[]): string[] {
- return items?.map((i) => i.label) ?? []
- }
- /** 当前检索条件摘要:紧凑格式「步枪:xxx、xxx ,手枪:xxx」,供带类别着色的展示用 */
- const selectionSummaryItems = computed(() => {
- const items: { label: string; values: string[] }[] = [
- { label: '步枪', values: extractLabels(form.rifle) },
- { label: '手枪', values: extractLabels(form.pistol) },
- { label: '微型冲锋枪', values: extractLabels(form.smg) },
- { label: '霰弹枪', values: extractLabels(form.shotgun) },
- { label: '机枪', values: extractLabels(form.machinegun) },
- { label: '品质', values: extractLabels(form.quality) },
- { label: '类别', values: extractLabels(form.category) },
- { label: '外观', values: extractLabels(form.exterior) },
- { label: '收藏品', values: extractLabels(form.collection) }
- ]
- return items
- .filter(({ values }) => values.length > 0)
- .map(({ label, values }) => ({ label, valuesText: values.join('、') }))
- })
- /** 选品开始前必须填写 Steam 卡价比例(且>=4)和利润率 */
- const canStartSelection = computed(() => {
- return form.steamCardRatio != null && form.steamCardRatio >= 4 && form.profitRate != null
- })
- const canStartSweep = computed(() => selectionList.value.length > 0)
- /** 助手等级中文映射 */
- const ASSISTANT_LEVEL_LABELS: Record<string, string> = {
- low: '低',
- standard: '标准',
- high: '高'
- }
- /** 条件设置区域的紧凑摘要(用于收起状态展示,分行显示HTML) */
- const conditionsSummary = computed(() => {
- const lines: string[] = []
- // 第一行:BUFF检索条件
- const buffParts: string[] = []
- const buffTypeMap: Array<{ key: 'rifle' | 'pistol' | 'smg' | 'shotgun' | 'machinegun' | 'quality' | 'category' | 'exterior' | 'collection'; label: string }> = [
- { key: 'rifle', label: '步枪' },
- { key: 'pistol', label: '手枪' },
- { key: 'smg', label: '微冲' },
- { key: 'shotgun', label: '霰弹' },
- { key: 'machinegun', label: '机枪' },
- { key: 'quality', label: '品质' },
- { key: 'category', label: '类别' },
- { key: 'exterior', label: '外观' },
- { key: 'collection', label: '收藏' }
- ]
- for (const { key, label } of buffTypeMap) {
- const arr = form[key] as any[]
- if (arr && arr.length > 0) {
- // arr 是 { label, value }[] 或 string[]
- const isObjArray = arr[0] && typeof arr[0] === 'object' && 'label' in arr[0]
- if (isObjArray) {
- const names = (arr as { label: string; value: string }[]).map(i => i.label).join('、')
- buffParts.push(`${label}:${names}`)
- } else {
- buffParts.push(`${label}✓${arr.length}`)
- }
- }
- }
- if (form.excludeIds) buffParts.push(`排除✓`)
- if (form.specifiedIds) buffParts.push(`指定✓`)
- if (buffParts.length > 0) {
- lines.push(`BUFF: ${buffParts.join(' | ')}`)
- }
- // 第二行:选品条件
- const selectionParts: string[] = []
- if (form.steamCardRatio != null) selectionParts.push(`卡价 ${form.steamCardRatio}`)
- if (form.profitRate != null) selectionParts.push(`利润 ${form.profitRate}%`)
- if (form.buffPriceMin != null || form.buffPriceMax != null) {
- selectionParts.push(`价格 ${form.buffPriceMin ?? '?'}-${form.buffPriceMax ?? '?'}`)
- }
- if (form.buffSalesMin != null || form.buffSalesMax != null) {
- selectionParts.push(`销量 ${form.buffSalesMin ?? '?'}-${form.buffSalesMax ?? '?'}`)
- }
- selectionParts.push(`助手 ${ASSISTANT_LEVEL_LABELS[form.assistantLevel] || form.assistantLevel}`)
- if (form.autoSelectionTime) selectionParts.push(`定时 ${form.autoSelectionTime}`)
- if (form.selectionQuantity != null) selectionParts.push(`数量 ${form.selectionQuantity}`)
- if (selectionParts.length > 0) {
- lines.push(`选品: ${selectionParts.join(' | ')}`)
- }
- // 第三行:扫货条件
- const sweepParts: string[] = []
- sweepParts.push(`助手 ${ASSISTANT_LEVEL_LABELS[form.sweepAssistantLevel] || form.sweepAssistantLevel}`)
- sweepParts.push(`进程 ${form.sweepProcessCount}`)
- if (sweepParts.length > 0) {
- lines.push(`扫货: ${sweepParts.join(' | ')}`)
- }
- return lines.length > 0 ? lines.join('<br>') : '暂无配置'
- })
- const running = computed(() => buffSweepStore.selectionRunning)
- const tableLoading = ref(false)
- const selectionList = ref<any[]>([])
- const selectionSearchKeyword = ref('')
- const filteredSelectionList = computed(() => {
- if (!selectionSearchKeyword.value.trim()) {
- return selectionList.value
- }
- const keyword = selectionSearchKeyword.value.trim().toLowerCase()
- return selectionList.value.filter((item) => {
- const idMatch = String(item.goods_id || item.id || '').toLowerCase().includes(keyword)
- const nameMatch = (item.goods_name || item.name || '').toLowerCase().includes(keyword)
- return idMatch || nameMatch
- })
- })
- const onSelectionSearch = () => {
- // 搜索时回到第一页
- tablePagination.current = 1
- }
- const selectionSelectedRowKeys = ref<(string | number)[]>([])
- const selectionDeleting = ref(false)
- const batchImportLoading = ref(false)
- const sweepRunning = computed(() => buffSweepStore.sweepRunning)
- const switchingStatus = computed(() => buffSweepStore.switchingStatus)
- /** 开始选品 / 开始扫货 前置清理阶段,用于按钮 loading 区分 */
- const buffPrepareFor = ref<'selection' | 'sweep' | null>(null)
- const sweepTableLoading = ref(false)
- const sweepList = ref<any[]>([])
- const sweepSearchKeyword = ref('')
- const exportingSweepExcel = ref(false)
- const filteredSweepList = computed(() => {
- if (!sweepSearchKeyword.value.trim()) {
- return sweepList.value
- }
- const keyword = sweepSearchKeyword.value.trim().toLowerCase()
- return sweepList.value.filter((item) => {
- const idMatch = String(item.goods_id || item.id || '').toLowerCase().includes(keyword)
- const nameMatch = (item.goods_name || item.name || '').toLowerCase().includes(keyword)
- return idMatch || nameMatch
- })
- })
- const paginatedSweepList = computed(() => {
- const start = (sweepPagination.current - 1) * sweepPagination.pageSize
- const end = start + sweepPagination.pageSize
- return filteredSweepList.value.slice(start, end)
- })
- const filteredSweepColumns = computed(() => {
- return sweepColumns.filter(col => col.key !== 'listing_id')
- })
- const formatSweepTime = (timestamp: number) => {
- if (!timestamp) return '—'
- const date = new Date(timestamp * 1000)
- return date.toLocaleString('zh-CN', {
- year: 'numeric',
- month: '2-digit',
- day: '2-digit',
- hour: '2-digit',
- minute: '2-digit',
- second: '2-digit',
- hour12: false
- })
- }
- const exportSweepTxt = () => {
- const data = sweepList.value
- if (data.length === 0) {
- message.warning('暂无数据可导出')
- return
- }
- const exportData = data.map((item, index) => ({
- '序号': index + 1,
- '饰品ID': item.goods_id || '',
- '饰品名称': item.goods_name || '',
- '区间': item.wear_range || '',
- '已购磨损值': item.purchase_wear || '',
- '目标价格(美元)': item.target_price_usd || '',
- '成交价格(美元)': item.purchase_price_usd || '',
- '成交价格(人民币)': item.purchase_price_cny || '',
- '购买账号': item.steam_account || '',
- '扫货时间': formatSweepTime(item.created_at)
- }))
- const txtContent = JSON.stringify(exportData, null, 2)
- const blob = new Blob(['\ufeff' + txtContent], { type: 'text/plain;charset=utf-8' })
- const url = URL.createObjectURL(blob)
- const link = document.createElement('a')
- link.href = url
- link.download = `扫货成功数据_${new Date().toISOString().slice(0, 10)}.txt`
- link.click()
- URL.revokeObjectURL(url)
- message.success(`已导出 ${data.length} 条数据`)
- }
- const exportSweepExcel = async () => {
- const data = sweepList.value
- if (data.length === 0) {
- message.warning('暂无数据可导出')
- return
- }
- exportingSweepExcel.value = true
- try {
- const XLSX = await import('xlsx')
- const exportData = data.map((item, index) => ({
- '序号': index + 1,
- '饰品ID': item.goods_id || '',
- '饰品名称': item.goods_name || '',
- '区间': item.wear_range || '',
- '已购磨损值': item.purchase_wear || '',
- '目标价格(美元)': item.target_price_usd || '',
- '成交价格(美元)': item.purchase_price_usd || '',
- '成交价格(人民币)': item.purchase_price_cny || '',
- '购买账号': item.steam_account || '',
- '扫货时间': formatSweepTime(item.created_at)
- }))
- const ws = XLSX.utils.json_to_sheet(exportData)
- const wb = XLSX.utils.book_new()
- XLSX.utils.book_append_sheet(wb, ws, '扫货成功数据')
- XLSX.writeFile(wb, `扫货成功数据_${new Date().toISOString().slice(0, 10)}.xlsx`)
- message.success(`已导出 ${data.length} 条数据`)
- } catch (err) {
- console.error('导出Excel失败:', err)
- message.error('导出Excel失败')
- } finally {
- exportingSweepExcel.value = false
- }
- }
- type BoundSteamApiAccount = {
- id: number
- steam_username: string
- steam_id?: string
- is_active: boolean
- }
- type BoundSteamLocalAccount = {
- steam_account?: string
- steamLoginSecure?: string
- card_rate?: number | null
- }
- type BoundSteamRow = {
- id: number
- steam_username: string
- steam_id: string
- hasSteamLoginSecure: boolean
- cardRate?: number | null
- is_active?: boolean
- }
- const boundSteamLoading = ref(false)
- const boundSteamAccounts = ref<BoundSteamRow[]>([])
- const boundSteamBalanceMap = ref<Record<string, { amount: string; currency: string }>>({})
- const boundSteamBalanceQueryUsername = ref<string | null>(null)
- const boundSteamReloginUsername = ref<string | null>(null)
- const boundSteamRefreshingAll = ref(false)
- const boundSteamBalanceTimer = ref<ReturnType<typeof setInterval> | null>(null)
- const deferredBoundSteamLoadTimer = ref<ReturnType<typeof setTimeout> | null>(null)
- // 余额查询失败计数器:连续查询失败/余额为0的次数
- const balanceFailCountMap = ref<Record<string, number>>({})
- // 自动登录锁:防止同一账号同时触发多次自动登录
- const autoReloginLock = ref<Record<string, boolean>>({})
- // 自动登录中的账号列表,用于UI提示
- const autoReloginActiveUsernames = ref<Set<string>>(new Set())
- // 检查账号是否在自动登录中
- function isAutoRelogining(steamUsername: string): boolean {
- return autoReloginActiveUsernames.value.has(steamUsername)
- }
- // 获取账号失败计数
- function getFailCount(steamUsername: string): number {
- return balanceFailCountMap.value[steamUsername] || 0
- }
- function notifyBuffStatusChange() {
- emit('status-change', {
- selectionRunning: buffSweepStore.selectionRunning,
- sweepRunning: buffSweepStore.sweepRunning,
- hasSelectionData: selectionList.value.length > 0,
- switchingStatus: buffSweepStore.switchingStatus
- })
- }
- // 选品数据表格定时刷新:点击「开始选品」后轮询 selection-result,请求单结束后停止
- const selectionListPollTimer = ref<ReturnType<typeof setInterval> | null>(null)
- const sweepPollTimer = ref<ReturnType<typeof setInterval> | null>(null)
- const sweepPollConsecutiveErrors = ref(0) // 扫货轮询连续错误计数
- // 扫货成功列表定时刷新:每分钟刷新一次
- const sweepResultRefreshTimer = ref<ReturnType<typeof setInterval> | null>(null)
- const autoSelectionTimer = ref<ReturnType<typeof setInterval> | null>(null)
- /**
- * 允许自动扫货的选品请求单白名单:
- * 仅用户点击「开始选品」或定时自动选品创建的 request_id 才可进入白名单。
- * 历史数据加载、状态恢复等路径不会写入白名单,从而不会触发自动扫货。
- */
- const autoSweepAllowedSelectionRequestIds = ref<Set<number>>(new Set())
- // 选品日志弹窗
- const selectionLogModalVisible = ref(false)
- // 确认弹窗
- const confirmDialogVisible = ref(false)
- const confirmDialogConfig = reactive({
- title: '确认操作',
- message: '确定要执行此操作吗?',
- okText: '确定',
- cancelText: '取消',
- iconType: 'warning' as 'warning' | 'danger' | 'info',
- okDanger: false,
- onConfirm: null as (() => void) | null
- })
- function openConfirmDialog(config: {
- title: string
- message: string
- okText?: string
- cancelText?: string
- iconType?: 'warning' | 'danger' | 'info'
- okDanger?: boolean
- onConfirm: () => void
- }) {
- confirmDialogConfig.title = config.title
- confirmDialogConfig.message = config.message
- confirmDialogConfig.okText = config.okText ?? '确定'
- confirmDialogConfig.cancelText = config.cancelText ?? '取消'
- confirmDialogConfig.iconType = config.iconType ?? 'warning'
- confirmDialogConfig.okDanger = config.okDanger ?? false
- confirmDialogConfig.onConfirm = config.onConfirm
- confirmDialogVisible.value = true
- }
- function handleConfirmDialog() {
- if (confirmDialogConfig.onConfirm) {
- confirmDialogConfig.onConfirm()
- }
- confirmDialogVisible.value = false
- }
- const selectionLogLoading = ref(false)
- const selectionLogPollTimer = ref<ReturnType<typeof setInterval> | null>(null)
- const selectionLogBodyRef = ref<HTMLElement | null>(null)
- const selectionLogAutoFollow = ref(true)
- const selectionLogMaxSequenceNo = ref(0)
- const selectionLogData = reactive<{
- request_id: number | null
- status: string | null
- started_at: string | null
- ended_at: string | null
- logs: Array<{
- created_at: string | null
- goods_id: string
- goods_name: string
- result: 'selected' | 'not_selected' | 'stage'
- message: string
- wear_range?: string | null
- price_cny?: number | null
- price_usd?: number | null
- }>
- }>({
- request_id: null,
- status: null,
- started_at: null,
- ended_at: null,
- logs: []
- })
- const sweepLogModalVisible = ref(false)
- const sweepLogLoading = ref(false)
- const sweepLogPollTimer = ref<ReturnType<typeof setInterval> | null>(null)
- const sweepLogBodyRef = ref<HTMLElement | null>(null)
- const sweepLogAutoFollow = ref(true)
- // Worker 日志数据结构
- interface WorkerLogEntry {
- created_at: string | null
- goods_id: string
- goods_name: string
- result: string
- message: string
- target_price_usd?: number | null
- purchase_price_usd?: number | null
- line_color?: string | null
- purchase_wear?: number | null
- listing_id?: string | null
- api_name?: string
- steam_account?: string
- method?: string
- url?: string
- request_headers?: Record<string, string>
- request_body?: Record<string, string> | null
- response_status?: number
- response_body?: string
- elapsed_ms?: number
- error?: string
- }
- interface WorkerLogState {
- worker_index: number
- logs: WorkerLogEntry[]
- log_count: number
- hasNewLogs: boolean
- }
- // Worker 列表和日志状态
- const sweepWorkerList = reactive<WorkerLogState[]>([])
- const sweepLogActiveWorkerIndex = ref(1) // 当前选中的线程索引
- // 当前选中线程的日志(计算属性)
- const currentWorkerLogs = computed(() => {
- const worker = sweepWorkerList.find(w => w.worker_index === sweepLogActiveWorkerIndex.value)
- return worker?.logs || []
- })
- function setSweepLogBodyRef(el: any) {
- sweepLogBodyRef.value = el
- }
- const sweepLogCount = ref(0)
- // API 详细日志展开状态管理
- const expandedApiLogs = reactive(new Set<string>())
- // API 详细日志字段类型
- interface ApiLogFields {
- api_name?: string
- steam_account?: string
- method?: string
- url?: string
- request_headers?: Record<string, string>
- request_body?: Record<string, string> | null
- response_status?: number
- response_body?: string
- elapsed_ms?: number
- error?: string
- }
- const sweepLogData = reactive<{
- request_id: number | null
- status: string | null
- started_at: string | null
- ended_at: string | null
- }>({
- request_id: null,
- status: null,
- started_at: null,
- ended_at: null,
- })
- function buildSweepLogEntryKey(entry: {
- created_at: string | null
- goods_id: string
- goods_name: string
- result: string
- message: string
- target_price_usd?: number | null
- purchase_price_usd?: number | null
- listing_id?: string | null
- // API 日志字段
- api_name?: string
- steam_account?: string
- method?: string
- url?: string
- elapsed_ms?: number
- }) {
- return [
- entry.created_at ?? '',
- entry.goods_id ?? '',
- entry.goods_name ?? '',
- entry.result ?? '',
- entry.message ?? '',
- String(entry.target_price_usd ?? ''),
- String(entry.purchase_price_usd ?? ''),
- String(entry.listing_id ?? ''),
- // API 日志字段
- entry.api_name ?? '',
- entry.steam_account ?? '',
- entry.method ?? '',
- entry.url ?? '',
- String(entry.elapsed_ms ?? '')
- ].join('|')
- }
- function parseLogTimestamp(value: string | null | undefined): number {
- if (!value) return 0
- const normalized = value.includes('T') ? value : value.replace(' ', 'T')
- const ts = Date.parse(normalized)
- return Number.isNaN(ts) ? 0 : ts
- }
- function sortLogsByCreatedAtAsc<T extends { created_at: string | null }>(logs: T[]): T[] {
- return [...logs].sort((a, b) => parseLogTimestamp(a.created_at) - parseLogTimestamp(b.created_at))
- }
- function mergeSweepLogsTerminalStyle(
- currentLogs: Array<{
- created_at: string | null
- goods_id: string
- goods_name: string
- result: string
- message: string
- target_price_usd?: number | null
- purchase_price_usd?: number | null
- }>,
- incomingLogs: Array<{
- created_at: string | null
- goods_id: string
- goods_name: string
- result: string
- message: string
- target_price_usd?: number | null
- purchase_price_usd?: number | null
- }>
- ) {
- if (currentLogs.length === 0) return sortLogsByCreatedAtAsc(incomingLogs)
- const keySet = new Set(currentLogs.map(buildSweepLogEntryKey))
- const appendOnly = incomingLogs.filter((item) => !keySet.has(buildSweepLogEntryKey(item)))
- if (appendOnly.length === 0) return currentLogs
- return [...currentLogs, ...sortLogsByCreatedAtAsc(appendOnly)]
- }
- function isSweepLogNearBottom(el: HTMLElement, threshold = 24) {
- return el.scrollHeight - (el.scrollTop + el.clientHeight) <= threshold
- }
- function onSweepLogBodyScroll() {
- const el = sweepLogBodyRef.value
- if (!el) return
- sweepLogAutoFollow.value = isSweepLogNearBottom(el)
- }
- function scrollSweepLogToBottom(force = false) {
- const el = sweepLogBodyRef.value
- if (!el) return
- if (!force && !sweepLogAutoFollow.value) return
- el.scrollTop = el.scrollHeight
- }
- function onSweepLogFollowChange(checked: boolean) {
- sweepLogAutoFollow.value = checked
- if (!checked) return
- nextTick(() => {
- scrollSweepLogToBottom(true)
- })
- }
- const tablePagination = reactive({
- current: 1,
- pageSize: 10,
- total: 0,
- showSizeChanger: true,
- showTotal: (total: number) => `共 ${total} 条`
- })
- const sweepPagination = reactive({
- current: 1,
- pageSize: 10,
- total: computed(() => filteredSweepList.value.length),
- showSizeChanger: true,
- showTotal: (total: number) => `共 ${total} 条`,
- simple: false,
- showQuickJumper: true
- })
- const columns = [
- { title: '序号', key: 'index', width: 60, minWidth: 60, align: 'center' as const, resizable: true },
- { title: '饰品图片', key: 'icon', width: 80, minWidth: 80, align: 'center' as const, resizable: true },
- { title: '饰品ID', dataIndex: 'goods_id', key: 'goods_id', minWidth: 80, resizable: true },
- { title: '饰品名称', dataIndex: 'goods_name', key: 'goods_name', minWidth: 150, ellipsis: true, resizable: true },
- { title: '区间', dataIndex: 'wear_range', key: 'wear_range', minWidth: 60, resizable: true },
- { title: '价格(人民币)', dataIndex: 'price_cny', key: 'price_cny', minWidth: 80, align: 'center' as const, resizable: true },
- { title: '价格(美元)', dataIndex: 'price_usd', key: 'price_usd', minWidth: 70, align: 'center' as const, resizable: true },
- { title: '选品时间', dataIndex: 'created_at', key: 'created_at', minWidth: 160, resizable: true },
- { title: '操作', key: 'action', width: 80, minWidth: 80, align: 'center' as const, resizable: true }
- ]
- const sweepColumns = [
- { title: '序号', key: 'index', width: 60, minWidth: 60, align: 'center' as const, resizable: true },
- { title: '饰品图片', key: 'icon', width: 80, minWidth: 80, align: 'center' as const, resizable: true },
- { title: '饰品ID', dataIndex: 'goods_id', key: 'goods_id', minWidth: 100, resizable: true },
- { title: '物品id', dataIndex: 'listing_id', key: 'listing_id', minWidth: 150, resizable: true },
- { title: '饰品名称', dataIndex: 'goods_name', key: 'goods_name', minWidth: 150, ellipsis: true, resizable: true },
- { title: '区间', dataIndex: 'wear_range', key: 'wear_range', minWidth: 80, resizable: true },
- { title: '已购磨损值', dataIndex: 'purchase_wear', key: 'purchase_wear', minWidth: 100, align: 'center' as const, resizable: true },
- { title: '目标价格(美元)', dataIndex: 'target_price_usd', key: 'target_price_usd', minWidth: 110, align: 'center' as const, resizable: true },
- { title: '成交价格(美元)', dataIndex: 'purchase_price_usd', key: 'purchase_price_usd', minWidth: 110, align: 'center' as const, resizable: true },
- { title: '成交价格(人民币)', dataIndex: 'purchase_price_cny', key: 'purchase_price_cny', minWidth: 110, align: 'center' as const, resizable: true },
- { title: '购买账号', dataIndex: 'steam_account', key: 'steam_account', minWidth: 120, resizable: true },
- { title: '扫货时间', dataIndex: 'created_at', key: 'created_at', minWidth: 160, resizable: true }
- ]
- const boundSteamColumns = [
- { title: 'Steam ID', dataIndex: 'steam_id', key: 'steam_id', width: 150 },
- { title: 'Steam 账号', dataIndex: 'steam_username', key: 'steam_username', width: 120 },
- { title: '参与扫货', key: 'is_active', width: 100, align: 'center' as const },
- { title: '余额', key: 'balance', width: 120 },
- { title: '充值卡价比例', key: 'cardRate', width: 100 },
- { title: '状态', key: 'status', width: 120 },
- { title: '操作', key: 'action', width: 180 }
- ]
- /** 配置中的排除/指定 id 可能为字符串或数组,统一为换行分隔字符串 */
- function normalizeIdsField(v: unknown): string {
- if (v == null) return ''
- if (typeof v === 'string') return v
- if (Array.isArray(v)) return v.map((x) => String(x)).join('\n')
- return String(v)
- }
- function getConfigPayload() {
- // 将 { label, value }[] 转换成 value[] 后再提交
- const toValueArr = (key: string): string[] => {
- const arr = form[key as keyof typeof form] as { label: string; value: string }[]
- // 修复:先确保是普通对象(而非字符串),再使用 'in' 操作符检查属性
- if (arr && arr.length > 0 && arr[0] &&
- typeof arr[0] === 'object' && !Array.isArray(arr[0]) && 'value' in arr[0]) {
- return arr.map(i => i.value)
- }
- return arr as string[] ?? []
- }
- return {
- rifle: toValueArr('rifle'),
- pistol: toValueArr('pistol'),
- smg: toValueArr('smg'),
- shotgun: toValueArr('shotgun'),
- machinegun: toValueArr('machinegun'),
- quality: toValueArr('quality'),
- category: toValueArr('category'),
- exterior: toValueArr('exterior'),
- collection: toValueArr('collection'),
- excludeIds: normalizeIdsField(form.excludeIds),
- specifiedIds: normalizeIdsField(form.specifiedIds),
- steamCardRatio: form.steamCardRatio,
- profitRate: form.profitRate,
- buffPriceMin: form.buffPriceMin,
- buffPriceMax: form.buffPriceMax,
- buffSalesMin: form.buffSalesMin,
- buffSalesMax: form.buffSalesMax,
- assistantLevel: form.assistantLevel,
- autoSelectionTime: form.autoSelectionTime,
- selectionQuantity: form.selectionQuantity,
- sweepAssistantLevel: form.sweepAssistantLevel,
- sweepProcessCount: form.sweepProcessCount
- }
- }
- function applyConfigToForm(data: Record<string, any>) {
- if (!data) return
- // 将 string[] 转换成 { label, value }[] 用于 rifle/pistol/smg/shotgun/machinegun 等
- const convertArr = (key: string) => {
- if (!data[key]) return
- const raw = data[key]
- const options = filterOptions[key as keyof typeof filterOptions] as { label: string; value: string }[]
- if (Array.isArray(raw) && options && options.length > 0) {
- const labelMap = new Map(options.map(o => [o.value, o.label]))
- form[key as keyof typeof form] = raw.map((v: string) => ({
- label: labelMap.get(v) ?? v,
- value: v
- }))
- } else {
- form[key as keyof typeof form] = raw
- }
- }
- convertArr('rifle')
- convertArr('pistol')
- convertArr('smg')
- convertArr('shotgun')
- convertArr('machinegun')
- convertArr('quality')
- convertArr('category')
- convertArr('exterior')
- convertArr('collection')
- const excludeVal = data.excludeIds ?? data.exclude_ids ?? (data as Record<string, unknown>).excludeids
- if (excludeVal != null) form.excludeIds = normalizeIdsField(excludeVal)
- const specifiedVal = data.specifiedIds ?? data.specified_ids ?? (data as Record<string, unknown>).specifiedids
- if (specifiedVal != null) form.specifiedIds = normalizeIdsField(specifiedVal)
- if (data.steamCardRatio != null) form.steamCardRatio = data.steamCardRatio
- if (data.profitRate != null) form.profitRate = data.profitRate
- if (data.buffPriceMin != null) form.buffPriceMin = data.buffPriceMin
- if (data.buffPriceMax != null) form.buffPriceMax = data.buffPriceMax
- if (data.buffSalesMin != null) form.buffSalesMin = data.buffSalesMin
- if (data.buffSalesMax != null) form.buffSalesMax = data.buffSalesMax
- if (data.assistantLevel) form.assistantLevel = data.assistantLevel
- if (data.autoSelectionTime != null) form.autoSelectionTime = data.autoSelectionTime
- if (data.selectionQuantity != null) form.selectionQuantity = data.selectionQuantity
- if (data.sweepAssistantLevel) form.sweepAssistantLevel = data.sweepAssistantLevel
- if (data.sweepProcessCount != null && Number.isFinite(Number(data.sweepProcessCount))) {
- const n = Math.floor(Number(data.sweepProcessCount))
- const maxWorkerCount = configStore.config.buff_sweep_max_worker_count ?? 32
- form.sweepProcessCount = n >= 1 ? Math.min(maxWorkerCount, n) : 3
- }
- }
- function getStoredConditions(): string | null {
- try {
- return localStorage.getItem(STORAGE_KEY)
- } catch {
- return null
- }
- }
- async function loadConditions() {
- const sessionId = authStore.sessionId
- const machineCode = authStore.machineCode
- if (sessionId && machineCode) {
- try {
- const res = await secureApiClient.getBuffSelectionConfig(sessionId, machineCode)
- if (res.code === 200 && res.data?.defaultConfig) {
- applyConfigToForm(res.data.defaultConfig)
- return
- }
- } catch (e) {
- console.error('加载云端配置失败,回退本地:', e)
- }
- }
- const raw = getStoredConditions()
- if (!raw) return
- try {
- const data = JSON.parse(raw)
- applyConfigToForm(data)
- } catch (e) {
- console.error('加载保存的配置失败:', e)
- }
- }
- /** 延迟保存:用户修改配置后防抖写入本地/云端,避免每次输入都请求 */
- function scheduleSaveConditions() {
- if (!initialLoadDone.value) return
- if (configSaveTimer) clearTimeout(configSaveTimer)
- configSaveTimer = setTimeout(() => {
- configSaveTimer = null
- doSaveConditions(true)
- }, DEBOUNCE_SAVE_MS)
- }
- /** 执行保存;isAutoSave 为 true 时为自动保存(提示文案更简短) */
- async function doSaveConditions(isAutoSave: boolean) {
- const data = getConfigPayload()
- try {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
- const sessionId = authStore.sessionId
- const machineCode = authStore.machineCode
- if (sessionId && machineCode) {
- const res = await secureApiClient.saveBuffSelectionConfig({
- session_id: sessionId,
- machine_code: machineCode,
- name: '默认',
- config_json: data,
- is_default: true
- })
- if (res.code === 200) {
- message.success(isAutoSave ? '配置已自动保存' : '配置信息已保存并同步到云端', isAutoSave ? 1.5 : undefined)
- return
- }
- if (!isAutoSave) message.warning(res.msg || '云端保存失败,已保存到本地')
- return
- }
- message.success(
- isAutoSave ? '配置已自动保存' : '配置信息已保存到本地,登录后可同步到云端',
- isAutoSave ? 1.5 : undefined
- )
- } catch (e) {
- message.error('保存失败')
- }
- }
- /** 用户点击「保存配置信息」时立即保存 */
- async function saveConditions() {
- if (buffSweepStore.selectionRunning || buffSweepStore.switchingStatus != null) {
- return
- }
- if (form.steamCardRatio == null) {
- message.warning('请填写 Steam 卡价比例')
- return
- }
- if (form.profitRate == null) {
- message.warning('请填写利润率')
- return
- }
- if (form.steamCardRatio < 4) {
- message.warning('Steam 卡价比例不能小于 4,请重新填写')
- return
- }
- // 验证系统配置限制
- const maxWorkerCount = configStore.config.buff_sweep_max_worker_count ?? 32
- const minSteamCardRatio = configStore.config.buff_steam_card_ratio_min ?? 4
- if (form.sweepProcessCount > maxWorkerCount) {
- message.error(`保存失败:扫货线程数不能超过系统配置的最大值 ${maxWorkerCount}`)
- return
- }
- if (form.steamCardRatio < minSteamCardRatio) {
- message.error(`保存失败:Steam卡价比例不能小于系统配置的最小值 ${minSteamCardRatio}`)
- return
- }
- // 验证选品数量
- const maxSelectionQuantity = configStore.config.buff_selection_max_quantity ?? 300
- if (form.selectionQuantity != null && form.selectionQuantity > maxSelectionQuantity) {
- message.error(`保存失败:选品数量不能超过系统配置的最大值 ${maxSelectionQuantity}`)
- return
- }
- await doSaveConditions(false)
- }
- /** 监听运行状态变化 */
- watch([() => buffSweepStore.selectionRunning, () => buffSweepStore.sweepRunning, () => buffSweepStore.switchingStatus, () => selectionList.value.length], notifyBuffStatusChange, { immediate: true })
- // 监听扫货日志弹窗关闭,确保停止轮询
- watch(() => sweepLogModalVisible.value, (isOpen) => {
- if (!isOpen) {
- stopSweepLogPoll()
- sweepLogAutoFollow.value = true
- }
- })
- async function pickTxtFile(): Promise<string | null> {
- const selected = await openDialog({
- multiple: false,
- filters: [{ name: '文本', extensions: ['txt'] }]
- })
- if (selected && typeof selected === 'string') return selected
- if (Array.isArray(selected) && selected.length > 0) return selected[0]
- return null
- }
- function rowKey(record: any) {
- if (record?.id != null) return record.id
- if (record?.goods_id) {
- return `${record.goods_id}-${record.created_at ?? ''}-${record.wear_range ?? ''}`
- }
- return `${record?.goods_name ?? 'unknown'}-${record?.created_at ?? ''}`
- }
- function onSelectionListSelectionChange(keys: (string | number)[]) {
- selectionSelectedRowKeys.value = keys
- }
- function onSelectionTableChange(pagination: any) {
- tablePagination.current = pagination?.current || 1
- tablePagination.pageSize = pagination?.pageSize || 10
- }
- function getSelectionItemId(record: any): number | null {
- const itemId = Number(record?.id)
- return Number.isFinite(itemId) && itemId > 0 ? itemId : null
- }
- function sweepRowKey(record: any) {
- // 优先使用 listing_id 作为唯一行键(每个购买记录的唯一标识)
- if (record?.listing_id) {
- return record.listing_id
- }
- // 其次使用 selection_item_id + created_at 组合(选品ID + 购买时间)
- if (record?.selection_item_id && record?.created_at) {
- return `${record.selection_item_id}-${record.created_at}`
- }
- // 兜底方案:使用 goods_id + created_at + purchase_price_usd
- if (record?.goods_id) {
- return `${record.goods_id}-${record.created_at ?? ''}-${record.purchase_price_usd ?? ''}`
- }
- return `${record?.goods_name ?? 'unknown'}-${record?.created_at ?? ''}`
- }
- /** 格式化价格(人民币/美元) */
- function formatPrice(val: unknown): string {
- if (val == null || val === '') return '—'
- const n = Number(val)
- if (Number.isNaN(n)) return '—'
- return n.toFixed(2)
- }
- function formatCurrencyPrice(val: unknown, symbol: string): string {
- const price = formatPrice(val)
- return price === '—' ? '—' : `${symbol}${price}`
- }
- function formatWearValue(val: unknown): string {
- if (val == null || val === '') return '—'
- return String(val)
- }
- /** 格式化选品时间 */
- function formatSelectionTime(val: unknown): string {
- if (val == null || val === '') return '—'
- const d = dayjs(val as string)
- return d.isValid() ? d.format('YYYY-MM-DD HH:mm:ss') : String(val)
- }
- function formatSteamBalance(steamUsername: string): string {
- const balance = boundSteamBalanceMap.value[steamUsername]
- if (!balance) return '—'
- const amount = parseInt(balance.amount, 10)
- if (Number.isNaN(amount) || amount === 0) return '—'
- const dollars = (amount / 100).toFixed(2)
- return balance.currency === 'USD' ? `$${dollars}` : `${balance.currency} ${dollars}`
- }
- async function refreshSingleSteamBalance(steamUsername: string, silent: boolean): Promise<boolean> {
- try {
- const raw = await invoke<string>('get_steam_wallet_balance', { steamUsername })
- const data = JSON.parse(raw) as { amount: string; currency: string }
- boundSteamBalanceMap.value = {
- ...boundSteamBalanceMap.value,
- [steamUsername]: { amount: data.amount, currency: data.currency }
- }
- // 查询成功:判断余额是否大于0
- const amount = parseInt(data.amount, 10)
- if (amount > 0) {
- // 余额大于0:重置失败计数
- balanceFailCountMap.value = {
- ...balanceFailCountMap.value,
- [steamUsername]: 0
- }
- } else {
- // 余额为0:增加失败计数(可能是登录失效的迹象)
- const currentFail = balanceFailCountMap.value[steamUsername] || 0
- const newFail = currentFail + 1
- balanceFailCountMap.value = {
- ...balanceFailCountMap.value,
- [steamUsername]: newFail
- }
- // 达到阈值(10次),触发自动重新登录
- if (newFail >= 10 && !autoReloginLock.value[steamUsername]) {
- // 在后台静默触发自动登录,不阻塞当前流程
- tryAutoReloginIfNeeded(steamUsername)
- }
- }
- return true
- } catch (error: any) {
- // 查询异常:增加失败计数
- const currentFail = balanceFailCountMap.value[steamUsername] || 0
- const newFail = currentFail + 1
- balanceFailCountMap.value = {
- ...balanceFailCountMap.value,
- [steamUsername]: newFail
- }
- if (!silent) {
- const errText = error?.message || error?.toString() || '查询余额失败'
- message.error(errText.length > 80 ? `${errText.slice(0, 80)}...` : errText)
- }
- // 达到阈值(10次),触发自动重新登录
- if (newFail >= 10 && !autoReloginLock.value[steamUsername]) {
- tryAutoReloginIfNeeded(steamUsername)
- }
- return false
- }
- }
- /**
- * 自动尝试重新登录:当余额连续查询失败10次时触发
- * 参考 SteamBindView 的 handleRelogin 逻辑,使用 verify_steam_login
- */
- async function tryAutoReloginIfNeeded(steamUsername: string) {
- // 防止重复触发
- if (autoReloginLock.value[steamUsername]) {
- return
- }
- // 加锁
- autoReloginLock.value = { ...autoReloginLock.value, [steamUsername]: true }
- autoReloginActiveUsernames.value = new Set([...autoReloginActiveUsernames.value, steamUsername])
- console.info('[AutoRelogin] 开始自动登录账号:', steamUsername)
- try {
- const verifyResult = await invoke<string>('verify_steam_login', { steamUsername })
- const verify = JSON.parse(verifyResult)
- if (!verify?.success) {
- console.warn('[AutoRelogin] 账号', steamUsername, '自动登录失败:', verify?.message || '未知错误')
- message.warning(`账号 ${steamUsername} 自动登录失败,请手动重新登录`)
- return
- }
- // 登录成功
- console.info('[AutoRelogin] 账号', steamUsername, '自动登录成功')
- message.success(`账号 ${steamUsername} 已自动重新登录,正在刷新余额...`)
- // 重新加载绑定账号列表(更新 hasSteamLoginSecure 状态),但不自动刷新余额
- await loadBoundSteamAccounts(false)
- // 立即重新查询该账号的余额
- const success = await refreshSingleSteamBalance(steamUsername, false)
- if (success) {
- message.success(`账号 ${steamUsername} 余额已更新:${formatSteamBalance(steamUsername)}`)
- } else {
- message.warning(`账号 ${steamUsername} 登录成功,但余额查询失败,请稍后重试`)
- }
- } catch (err: any) {
- const raw = err?.message || err?.toString() || '自动登录失败'
- console.error('[AutoRelogin] 账号', steamUsername, '自动登录异常:', raw)
- message.error(`账号 ${steamUsername} 自动登录异常:${raw.length > 50 ? raw.slice(0, 50) + '...' : raw}`)
- } finally {
- // 释放锁
- const newLock = { ...autoReloginLock.value }
- delete newLock[steamUsername]
- autoReloginLock.value = newLock
- // 从活跃列表中移除
- const newActive = new Set(autoReloginActiveUsernames.value)
- newActive.delete(steamUsername)
- autoReloginActiveUsernames.value = newActive
- }
- }
- async function refreshBoundSteamBalances(silent: boolean): Promise<{ successCount: number; failCount: number }> {
- const tasks = boundSteamAccounts.value
- .filter((account) => account.hasSteamLoginSecure)
- .map((account) => refreshSingleSteamBalance(account.steam_username, silent))
- const results = await Promise.all(tasks)
- const successCount = results.filter(Boolean).length
- return {
- successCount,
- failCount: results.length - successCount
- }
- }
- async function handleRefreshAllBoundSteamBalances() {
- if (boundSteamRefreshingAll.value || boundSteamLoading.value) return
- if (!boundSteamAccounts.value.length) {
- message.info('暂无可刷新的已绑定 Steam 账号')
- return
- }
- boundSteamRefreshingAll.value = true
- try {
- const { successCount, failCount } = await refreshBoundSteamBalances(true)
- if (failCount === 0) {
- message.success(`余额刷新完成,共 ${successCount} 个账号`)
- return
- }
- if (successCount === 0) {
- message.warning('余额刷新失败,请稍后重试')
- return
- }
- message.warning(`余额刷新完成,成功 ${successCount} 个,失败 ${failCount} 个`)
- } finally {
- boundSteamRefreshingAll.value = false
- }
- }
- async function handleBoundSteamBalanceQuery(record: BoundSteamRow) {
- if (!record.hasSteamLoginSecure) {
- message.warning('该账号暂无登录凭证,请先在账号管理页完成验证绑定')
- return
- }
- boundSteamBalanceQueryUsername.value = record.steam_username
- const ok = await refreshSingleSteamBalance(record.steam_username, false)
- boundSteamBalanceQueryUsername.value = null
- if (ok) {
- message.success(`余额已更新:${formatSteamBalance(record.steam_username)}`)
- }
- }
- async function handleBoundSteamRelogin(record: BoundSteamRow) {
- if (!record.hasSteamLoginSecure) {
- message.warning('该账号暂无登录凭证,请先在账号管理页完成验证绑定')
- return
- }
- boundSteamReloginUsername.value = record.steam_username
- try {
- const verifyRaw = await invoke<string>('verify_steam_login', { steamUsername: record.steam_username })
- const verifyRes = JSON.parse(verifyRaw) as { success?: boolean; message?: string }
- if (!verifyRes?.success) {
- message.error(verifyRes?.message || '重新登录失败')
- return
- }
- // 手动重新登录成功:重置失败计数并清除自动登录锁
- balanceFailCountMap.value = {
- ...balanceFailCountMap.value,
- [record.steam_username]: 0
- }
- const newLock = { ...autoReloginLock.value }
- delete newLock[record.steam_username]
- autoReloginLock.value = newLock
- // 重新加载绑定账号列表(更新 hasSteamLoginSecure 状态),不自动刷新所有余额
- await loadBoundSteamAccounts(false)
- // 立即重新查询该账号的余额
- await refreshSingleSteamBalance(record.steam_username, true)
- message.success('重新登录成功')
- } catch (error: any) {
- const errText = error?.message || error?.toString() || '重新登录失败'
- message.error(errText.length > 80 ? `${errText.slice(0, 80)}...` : errText)
- } finally {
- boundSteamReloginUsername.value = null
- }
- }
- async function handleBoundSteamCardRateChange(record: BoundSteamRow, rate: number | null) {
- try {
- await invoke('update_account_card_rate', {
- steamUsername: record.steam_username,
- cardRate: rate
- })
- const idx = boundSteamAccounts.value.findIndex(a => a.steam_username === record.steam_username)
- if (idx !== -1) {
- boundSteamAccounts.value[idx].cardRate = rate
- }
- message.success('充值卡价比例已保存')
- } catch (err: any) {
- message.error(err?.message || err?.toString() || '保存失败')
- }
- }
- async function handleBoundSteamActiveChange(record: BoundSteamRow, isActive: boolean) {
- try {
- await invoke('update_account_is_active', {
- steamUsername: record.steam_username,
- isActive
- })
- const idx = boundSteamAccounts.value.findIndex(a => a.steam_username === record.steam_username)
- if (idx !== -1) {
- boundSteamAccounts.value[idx].is_active = isActive
- }
- message.success(isActive ? '已设置为参与扫货' : '已设置为不参与扫货')
- } catch (err: any) {
- message.error(err?.message || err?.toString() || '保存失败')
- }
- }
- async function loadBoundSteamAccounts(autoRefreshBalances: boolean = true) {
- const userId = authStore.userId
- if (!userId) {
- boundSteamAccounts.value = []
- return
- }
- boundSteamLoading.value = true
- try {
- const [apiRaw, localRaw] = await Promise.all([
- invoke<string>('get_steam_accounts', { userId }),
- invoke<string>('list_project_steam_accounts').catch(() => '[]')
- ])
- const apiRes = JSON.parse(apiRaw) as { code?: number; data?: BoundSteamApiAccount[]; msg?: string }
- if (apiRes.code !== 200) {
- boundSteamAccounts.value = []
- return
- }
- const localList = JSON.parse(localRaw) as BoundSteamLocalAccount[]
- const localSecureSet = new Set(
- localList
- .filter((item) => item.steam_account && item.steamLoginSecure)
- .map((item) => String(item.steam_account))
- )
- const localCardRateMap: Record<string, number | null> = {}
- const localIsActiveMap: Record<string, boolean> = {}
- for (const item of localList) {
- if (item.steam_account) {
- localCardRateMap[item.steam_account] = item.card_rate ?? null
- const raw = item as Record<string, any>
- localIsActiveMap[item.steam_account] = raw.is_active !== false
- }
- }
- const boundList = (apiRes.data || [])
- .filter((item) => !!item.steam_id && String(item.steam_id).trim() !== '')
- .map((item) => ({
- id: item.id,
- steam_username: item.steam_username,
- steam_id: String(item.steam_id),
- hasSteamLoginSecure: localSecureSet.has(item.steam_username),
- cardRate: localCardRateMap[item.steam_username] ?? null,
- is_active: localIsActiveMap[item.steam_username] ?? true
- }))
- boundSteamAccounts.value = boundList
- if (autoRefreshBalances) {
- await refreshBoundSteamBalances(true)
- }
- } catch (error) {
- console.error('加载已绑定 Steam 账号失败:', error)
- boundSteamAccounts.value = []
- } finally {
- boundSteamLoading.value = false
- }
- }
- function startBoundSteamBalancePoll() {
- stopBoundSteamBalancePoll()
- boundSteamBalanceTimer.value = setInterval(() => {
- refreshBoundSteamBalances(true)
- }, 3 * 60 * 1000)
- }
- function stopBoundSteamBalancePoll() {
- if (boundSteamBalanceTimer.value) {
- clearInterval(boundSteamBalanceTimer.value)
- boundSteamBalanceTimer.value = null
- }
- }
- /** 非关键模块延后加载:避免阻塞首屏核心数据渲染 */
- function scheduleDeferredBoundSteamLoad() {
- if (deferredBoundSteamLoadTimer.value) {
- clearTimeout(deferredBoundSteamLoadTimer.value)
- deferredBoundSteamLoadTimer.value = null
- }
- deferredBoundSteamLoadTimer.value = setTimeout(async () => {
- deferredBoundSteamLoadTimer.value = null
- await loadBoundSteamAccounts()
- startBoundSteamBalancePoll()
- }, 0)
- }
- /** 格式化单条选品日志:时间 , 饰品ID:xxx , 名称 , 选中/未选中 , [区间、¥、$] 或 未选中原因 */
- function formatLogLine(entry: {
- created_at: string | null
- goods_id: string
- goods_name: string
- result: string
- message: string
- wear_range?: string | null
- price_cny?: number | null
- price_usd?: number | null
- }): string {
- const time = entry.created_at || '—'
- if (entry.result === 'stage') {
- return `${time} , ${entry.message || '阶段日志'}`
- }
- const idPart = `饰品ID:${entry.goods_id}`
- const name = entry.goods_name || '—'
- if (entry.result === 'selected') {
- const range = entry.wear_range ?? '—'
- const cny = entry.price_cny != null ? `¥${Number(entry.price_cny).toFixed(2)}` : '—'
- const usd = entry.price_usd != null ? `$${Number(entry.price_usd).toFixed(2)}` : '—'
- return `${time} , ${idPart} , ${name} , 选中 , ${range} , ${cny} , ${usd}`
- }
- const reason = entry.message || '未选中原因'
- return `${time} , ${idPart} , ${name} , 未选中 , ${reason}`
- }
- function formatSweepLogLine(entry: {
- created_at: string | null
- goods_id: string
- goods_name: string
- result: string
- message: string
- target_price_usd?: number | null
- purchase_price_usd?: number | null
- purchase_wear?: number | null
- listing_id?: string | null
- // API 详细日志字段
- api_name?: string
- steam_account?: string
- method?: string
- url?: string
- request_headers?: Record<string, string>
- request_body?: Record<string, string> | null
- response_status?: number
- response_body?: string
- elapsed_ms?: number
- error?: string
- }): string {
- const time = formatSelectionTime(entry.created_at)
- // 处理 API 详细日志类型
- if (entry.result === 'api_detail' || entry.result === 'api_request' || entry.result === 'api_error') {
- const apiName = entry.api_name || 'unknown'
- const account = entry.steam_account || 'unknown'
- const method = entry.method || 'UNKNOWN'
- const status = entry.response_status ?? '—'
- const elapsed = entry.elapsed_ms != null ? `${entry.elapsed_ms}ms` : '—'
- // API 请求开始
- if (entry.result === 'api_request') {
- const urlShort = entry.url ? formatUrl(entry.url) : '—'
- return `${time} , ${account} , [API-REQ] ${apiName} ${method} ${urlShort}`
- }
- // API 错误
- if (entry.result === 'api_error') {
- const errMsg = entry.error || entry.message || '未知错误'
- return `${time} , ${account} , [API-ERR] ${apiName} ${method} -> ${status} (${elapsed}) - ${errMsg}`
- }
- // API 详情(成功/失败)
- const urlShort = entry.url ? formatUrl(entry.url) : '—'
- return `${time} , ${account} , [API] ${method} ${apiName} -> ${status} (${elapsed}) ${urlShort}`
- }
- if (entry.result === 'failed') {
- const msg = (entry.message || '').trim()
- return msg ? `${time} , ${msg}` : `${time} , 异常(无详情)`
- }
- if (entry.result === 'diag') {
- const msg = (entry.message || '').trim()
- return msg ? `${time} , ${msg}` : `${time} , (诊断日志无内容)`
- }
- if (entry.result === 'scan') {
- const idPart = `饰品ID:${entry.goods_id}`
- const name = entry.goods_name || '—'
- const msg = (entry.message || '').trim()
- return `${time} , ${idPart} , ${name} , ${msg}`
- }
- if (entry.result === 'wear') {
- const idPart = `饰品ID:${entry.goods_id}`
- const name = entry.goods_name || '—'
- const msg = (entry.message || '').trim()
- return `${time} , ${idPart} , ${name} , ${msg}`
- }
- if (entry.result === 'stage' || entry.result === 'matched') {
- const idPart = entry.goods_id ? `饰品ID:${entry.goods_id}` : ''
- const name = entry.goods_name || ''
- const message = entry.message || '阶段日志'
- if (idPart || name) {
- return `${time} , ${idPart}${name ? ` , ${name}` : ''} , ${message}`
- }
- return `${time} , ${message}`
- }
- const idPart = `饰品ID:${entry.goods_id}`
- const name = entry.goods_name || '—'
- const target = entry.target_price_usd != null ? `$${Number(entry.target_price_usd).toFixed(2)}` : '—'
- const deal = entry.purchase_price_usd != null ? `$${Number(entry.purchase_price_usd).toFixed(2)}` : '—'
- if (entry.result === 'success') {
- const detail = entry.message ? ` , ${entry.message}` : ''
- return `${time} , ${idPart} , ${name} , 扫货成功 , 目标价 ${target} , 成交价 ${deal}${detail}`
- }
- const wearHint =
- entry.purchase_wear != null && entry.purchase_wear !== undefined
- ? ` , 最近检视磨损 ${String(entry.purchase_wear)}`
- : ''
- const tail = (entry.message || '已跳过').trim()
- return `${time} , ${idPart} , ${name} , 目标价 ${target} , ${tail}${wearHint}`
- }
- /** 格式化 URL 用于显示(完整显示,不截断,方便手工调用) */
- function formatUrl(url: string): string {
- if (!url) return '—'
- return url
- }
- /** 格式化响应内容用于显示(完整保留,方便手工调用API) */
- function formatResponse(response: string): string {
- if (!response) return '—'
- return response
- }
- /** 判断是否为 API 详细日志 */
- function isApiDetailLog(entry: {
- result: string
- api_name?: string
- method?: string
- url?: string
- }): boolean {
- return entry.result === 'api_detail' || entry.result === 'api_request' || entry.result === 'api_error'
- }
- /** 切换 API 日志展开状态 */
- function toggleApiLogExpand(idx: number) {
- if (expandedApiLogs.has(idx)) {
- expandedApiLogs.delete(idx)
- } else {
- expandedApiLogs.add(idx)
- }
- }
- /** 判断 HTTP 状态码是否成功 */
- function isSuccessStatus(status?: number): boolean {
- return status !== undefined && status >= 200 && status < 300
- }
- /** 格式化 JSON 用于显示 */
- function formatJson(obj: Record<string, unknown> | string | null | undefined): string {
- if (!obj) return '—'
- if (typeof obj === 'string') {
- try {
- return JSON.stringify(JSON.parse(obj), null, 2)
- } catch {
- return obj
- }
- }
- return JSON.stringify(obj, null, 2)
- }
- function isSweepPriceWearMatchedLog(entry: {
- result: string
- message: string
- line_color?: string | null
- }) {
- const lc = (entry.line_color || '').trim().toLowerCase()
- if (lc === '#52c41a') return true
- if (entry.result === 'matched') return true
- const message = (entry.message || '').trim()
- if (!message) return false
- // 兼容本地日志 + 后端回显日志:命中价格/磨损后进入尝试购买阶段。
- if (message.includes('磨损命中目标区间')) return true
- if (message.includes('价格命中候选') && message.includes('磨损查询')) return true
- if (entry.result === 'wear' && message.includes('命中')) return true
- return false
- }
- function isSweepPurchaseSuccessLog(entry: {
- result: string
- message: string
- }) {
- const message = (entry.message || '').trim()
- if (entry.result === 'success') return true
- if (!message) return false
- // 兼容历史回显:有些旧日志可能 result 不标准,但 message 会带成功语义。
- return message.includes('购买成功') || message.includes('扫货成功')
- }
- /** 与 .selection-log-lines 中原 CSS 颜色一致,供无 line_color 的旧数据回退 */
- function sweepLogLineColorFallback(entry: {
- result: string
- message: string
- diag_level?: string
- }): string {
- if (isSweepPurchaseSuccessLog(entry)) return '#ff4d4f'
- if (isSweepPriceWearMatchedLog(entry)) return '#52c41a'
- if (entry.result === 'failed') return '#fa8c16'
- if (entry.result === 'diag') {
- const lv = (entry as { diag_level?: string }).diag_level
- if (lv === 'warn') return '#fa8c16'
- if (lv === 'error') return '#ff4d4f'
- return '#595959'
- }
- return '#000000'
- }
- function sweepLogLineStyle(entry: {
- result: string
- message: string
- line_color?: string | null
- }) {
- const c = (entry.line_color || '').trim()
- const color = c || sweepLogLineColorFallback(entry)
- const bold = isSweepPurchaseSuccessLog(entry)
- return {
- color,
- fontWeight: bold ? 600 : undefined
- } as Record<string, string | number | undefined>
- }
- function normalizeStageLogMessage(rawMessage: string): string | null {
- const messageText = (rawMessage || '').trim()
- if (!messageText) return null
- if (messageText.includes('过滤阶段:开始执行价格、智能选品与利润过滤')) return null
- if (messageText.includes('计算阶段:开始逐个计算区间与目标价格')) return null
- if (messageText.includes('计算阶段:结束,共入选')) return null
- if (messageText.includes('检索阶段:完成')) return '检索阶段:完成'
- return messageText
- }
- function sanitizeSelectionLogs(rawLogs: any[]): any[] {
- if (!Array.isArray(rawLogs)) return []
- return rawLogs
- .map((entry: any) => {
- if (entry?.result !== 'stage') return entry
- const normalizedMessage = normalizeStageLogMessage(entry?.message || '')
- if (!normalizedMessage) return null
- return {
- ...entry,
- message: normalizedMessage
- }
- })
- .filter((entry: any) => !!entry)
- }
- function applySelectionLogFallbackFromResult(resultData: any) {
- if (!resultData) return
- if (selectionLogData.started_at || selectionLogData.logs.length > 0) return
- selectionLogData.request_id = resultData.request_id ?? null
- selectionLogData.status = resultData.status ?? null
- selectionLogData.started_at = resultData.started_at ?? null
- selectionLogData.ended_at = resultData.ended_at ?? null
- const items = Array.isArray(resultData.items) ? resultData.items : []
- selectionLogData.logs = sortLogsByCreatedAtAsc(items.map((item: any) => ({
- created_at: item.created_at ?? null,
- goods_id: item.goods_id ?? '',
- goods_name: item.goods_name ?? '',
- result: 'selected',
- message: '已入选',
- wear_range: item.wear_range ?? null,
- price_cny: item.price_cny ?? null,
- price_usd: item.price_usd ?? null
- })))
- }
- function buildSelectionLogEntryKey(entry: {
- created_at: string | null
- goods_id: string
- goods_name: string
- result: 'selected' | 'not_selected' | 'stage'
- message: string
- wear_range?: string | null
- price_cny?: number | null
- price_usd?: number | null
- }) {
- return [
- entry.created_at ?? '',
- entry.goods_id ?? '',
- entry.goods_name ?? '',
- entry.result ?? '',
- entry.message ?? '',
- entry.wear_range ?? '',
- String(entry.price_cny ?? ''),
- String(entry.price_usd ?? '')
- ].join('|')
- }
- function mergeSelectionLogsTerminalStyle(
- currentLogs: Array<{
- created_at: string | null
- goods_id: string
- goods_name: string
- result: 'selected' | 'not_selected' | 'stage'
- message: string
- wear_range?: string | null
- price_cny?: number | null
- price_usd?: number | null
- }>,
- incomingLogs: Array<{
- created_at: string | null
- goods_id: string
- goods_name: string
- result: 'selected' | 'not_selected' | 'stage'
- message: string
- wear_range?: string | null
- price_cny?: number | null
- price_usd?: number | null
- }>
- ) {
- if (currentLogs.length === 0) return sortLogsByCreatedAtAsc(incomingLogs)
- const keySet = new Set(currentLogs.map(buildSelectionLogEntryKey))
- const appendOnly = incomingLogs.filter((item) => !keySet.has(buildSelectionLogEntryKey(item)))
- if (appendOnly.length === 0) return currentLogs
- return [...currentLogs, ...sortLogsByCreatedAtAsc(appendOnly)]
- }
- function isSelectionLogNearBottom(el: HTMLElement, threshold = 24) {
- return el.scrollHeight - (el.scrollTop + el.clientHeight) <= threshold
- }
- function onSelectionLogBodyScroll() {
- const el = selectionLogBodyRef.value
- if (!el) return
- selectionLogAutoFollow.value = isSelectionLogNearBottom(el)
- }
- function scrollSelectionLogToBottom(force = false) {
- const el = selectionLogBodyRef.value
- if (!el) return
- if (!force && !selectionLogAutoFollow.value) return
- el.scrollTop = el.scrollHeight
- }
- function onSelectionLogFollowChange(checked: boolean) {
- selectionLogAutoFollow.value = checked
- if (!checked) return
- nextTick(() => {
- scrollSelectionLogToBottom(true)
- })
- }
- /** 拉取选品日志并更新 selectionLogData;返回当前 status */
- async function fetchSelectionLog(showLoading = false): Promise<string | null> {
- const sessionId = authStore.sessionId
- const machineCode = authStore.machineCode
- if (!sessionId || !machineCode) return null
- if (showLoading) {
- selectionLogLoading.value = true
- }
- try {
- const res = await secureApiClient.getBuffSelectionLog(sessionId, machineCode, {
- lastSequenceNo: selectionLogMaxSequenceNo.value > 0 ? selectionLogMaxSequenceNo.value : null,
- limit: 200
- })
- if (res.code === 200 && res.data) {
- const requestIdChanged =
- selectionLogData.request_id !== null &&
- selectionLogData.request_id !== res.data.request_id
- selectionLogData.request_id = res.data.request_id
- selectionLogData.status = res.data.status
- selectionLogData.started_at = res.data.started_at
- selectionLogData.ended_at = res.data.ended_at
- const incomingLogs = sanitizeSelectionLogs(res.data.logs || [])
- if (requestIdChanged) {
- selectionLogData.logs = sortLogsByCreatedAtAsc(incomingLogs)
- selectionLogAutoFollow.value = true
- selectionLogMaxSequenceNo.value = res.data.max_sequence_no || 0
- } else {
- selectionLogData.logs = mergeSelectionLogsTerminalStyle(selectionLogData.logs, incomingLogs)
- selectionLogMaxSequenceNo.value = Math.max(selectionLogMaxSequenceNo.value, res.data.max_sequence_no || 0)
- }
- if (!selectionLogData.started_at && selectionLogData.logs.length === 0) {
- const fallbackRes = await secureApiClient.getBuffSelectionResult(sessionId, machineCode)
- if (fallbackRes.code === 200 && fallbackRes.data) {
- applySelectionLogFallbackFromResult(fallbackRes.data)
- }
- }
- await nextTick()
- scrollSelectionLogToBottom(requestIdChanged || showLoading)
- return res.data.status
- }
- const fallbackRes = await secureApiClient.getBuffSelectionResult(sessionId, machineCode)
- if (fallbackRes.code === 200 && fallbackRes.data) {
- applySelectionLogFallbackFromResult(fallbackRes.data)
- return fallbackRes.data.status
- }
- return null
- } finally {
- if (showLoading) {
- selectionLogLoading.value = false
- }
- }
- }
- function stopSelectionLogPoll() {
- if (selectionLogPollTimer.value) {
- clearInterval(selectionLogPollTimer.value)
- selectionLogPollTimer.value = null
- }
- }
- function startSelectionLogFetchAndPoll() {
- selectionLogData.request_id = null
- selectionLogData.status = null
- selectionLogData.started_at = null
- selectionLogData.ended_at = null
- selectionLogData.logs = []
- selectionLogMaxSequenceNo.value = 0
- selectionLogAutoFollow.value = true
- fetchSelectionLog(true).then((status) => {
- if (status === 'pending' || status === 'running') {
- selectionLogPollTimer.value = setInterval(() => {
- fetchSelectionLog(false).then((s) => {
- if (s !== 'pending' && s !== 'running') stopSelectionLogPoll()
- })
- }, 1000)
- }
- })
- }
- function openSelectionLogModal() {
- selectionLogModalVisible.value = true
- stopSelectionLogPoll()
- startSelectionLogFetchAndPoll()
- }
- function onSelectionLogModalOpenChange(open: boolean) {
- if (!open) {
- stopSelectionLogPoll()
- selectionLogAutoFollow.value = true
- }
- }
- async function fetchSweepLog(showLoading = false): Promise<string | null> {
- if (showLoading) {
- sweepLogLoading.value = true
- }
- try {
- // 先获取 Worker 列表
- const listRes = await secureApiClient.getSweepWorkerLogFiles()
- console.log('[BuffSelectionView] fetchSweepLog worker list:', listRes)
- if (listRes.code === 200 && listRes.data) {
- const workers = listRes.data.workers || []
- const requestId = listRes.data.request_id
- // 更新 sweepLogData
- sweepLogData.request_id = requestId
- // 如果 Worker 列表为空,使用默认一个 Worker
- if (workers.length === 0) {
- sweepWorkerList.length = 0
- sweepWorkerList.push({
- worker_index: 1,
- logs: [],
- log_count: 0,
- hasNewLogs: false
- })
- } else {
- // 更新 Worker 列表(保留之前选中线程的日志)
- const existingWorkers = new Map(sweepWorkerList.map(w => [w.worker_index, w]))
- sweepWorkerList.length = 0
- for (const w of workers) {
- const existing = existingWorkers.get(w.worker_index)
- sweepWorkerList.push({
- worker_index: w.worker_index,
- logs: existing?.logs || [],
- log_count: existing?.log_count || 0,
- hasNewLogs: false
- })
- }
- }
- // 确保当前选中的线程在列表中存在
- if (!sweepWorkerList.find(w => w.worker_index === sweepLogActiveWorkerIndex.value)) {
- sweepLogActiveWorkerIndex.value = sweepWorkerList[0]?.worker_index || 1
- }
- // 获取当前选中线程的日志(每次都从头读取完整日志)
- const currentWorker = sweepWorkerList.find(w => w.worker_index === sweepLogActiveWorkerIndex.value)
- if (currentWorker && requestId) {
- try {
- const logRes = await secureApiClient.getSweepWorkerLog({
- requestId: requestId,
- workerIndex: currentWorker.worker_index,
- lastLogCount: null, // 始终从头读取,获取所有日志
- limit: 10000 // 设置足够大的限制
- })
- if (logRes.code === 200 && logRes.data) {
- const newLogs = Array.isArray(logRes.data.logs) ? logRes.data.logs : []
- // 过滤掉 API 详细日志(result === 'api_detail' 或 'api_request'),
- // 这些日志只在 txt 文件中保留,不显示在弹窗中(方便排查问题但不影响界面显示)
- // 注意:保留 'api_error' 类型的日志,因为它表示重要错误,需要在弹窗中显示
- const filteredLogs = newLogs.filter((log: any) =>
- log.result !== 'api_detail' && log.result !== 'api_request'
- )
- // 统计原始日志数量(包含被过滤的)
- const newCount = logRes.data.log_count || 0
- // 限制日志数组大小,防止内存泄漏(只保留最近的 MAX_LOGS 条)
- const MAX_WORKER_LOGS = 5000
- currentWorker.logs = filteredLogs.length > MAX_WORKER_LOGS
- ? filteredLogs.slice(-MAX_WORKER_LOGS)
- : filteredLogs
- // 日志总数仍显示原始数量(包含被过滤的),方便排查
- currentWorker.log_count = newCount
- console.log(`[BuffSelectionView] Worker ${currentWorker.worker_index} loaded ${newLogs.length} logs (filtered: ${filteredLogs.length}, total: ${newCount})`)
- }
- } catch (e) {
- console.error(`[BuffSelectionView] fetchSweepLog worker ${currentWorker.worker_index} failed:`, e)
- }
- }
- await nextTick()
- // 滚动到最新
- const body = sweepLogBodyRef.value
- if (body && sweepLogAutoFollow.value) {
- body.scrollTop = body.scrollHeight
- }
- return listRes.data.status || null
- }
- return null
- } finally {
- if (showLoading) {
- sweepLogLoading.value = false
- }
- }
- }
- function stopSweepLogPoll() {
- if (sweepLogPollTimer.value) {
- clearInterval(sweepLogPollTimer.value)
- sweepLogPollTimer.value = null
- }
- }
- function openSweepLogModal() {
- // 重要:先停止之前的轮询,防止重复
- stopSweepLogPoll()
- sweepLogModalVisible.value = true
- sweepLogData.request_id = null
- sweepLogData.status = null
- sweepLogData.started_at = null
- sweepLogData.ended_at = null
- sweepWorkerList.length = 0
- sweepLogCount.value = 0
- sweepLogActiveWorkerIndex.value = 1 // 默认选中线程1
- sweepLogAutoFollow.value = true
- // 清空 API 日志展开状态
- expandedApiLogs.clear()
- fetchSweepLog(true).then((status) => {
- // 任务结束后停止轮询
- if (status === 'completed' || status === 'failed' || status === 'cancelled') {
- return
- }
- // 持续轮询直到结束(只轮询当前选中的线程)
- sweepLogPollTimer.value = setInterval(async () => {
- const pollStatus = await fetchSweepLog(false)
- // 任务结束后停止轮询
- if (pollStatus === 'completed' || pollStatus === 'failed' || pollStatus === 'cancelled') {
- stopSweepLogPoll()
- }
- }, 1000)
- })
- }
- // 线程按钮点击:切换显示的线程日志
- async function onWorkerButtonClick(workerIndex: number) {
- sweepLogActiveWorkerIndex.value = workerIndex
- // 加载该线程的日志
- const worker = sweepWorkerList.find(w => w.worker_index === workerIndex)
- if (worker && sweepLogData.request_id) {
- try {
- const logRes = await secureApiClient.getSweepWorkerLog({
- requestId: sweepLogData.request_id,
- workerIndex: worker.worker_index,
- lastLogCount: null, // 始终从头读取
- limit: 10000
- })
- if (logRes.code === 200 && logRes.data) {
- const newLogs = Array.isArray(logRes.data.logs) ? logRes.data.logs : []
- // 过滤掉 API 详细日志(result === 'api_detail' 或 'api_request'),
- // 这些日志只在 txt 文件中保留,不显示在弹窗中(方便排查问题但不影响界面显示)
- const filteredLogs = newLogs.filter((log: any) =>
- log.result !== 'api_detail' && log.result !== 'api_request'
- )
- // 限制日志数组大小,防止内存泄漏(只保留最近的 MAX_LOGS 条)
- const MAX_WORKER_LOGS = 5000
- worker.logs = filteredLogs.length > MAX_WORKER_LOGS
- ? filteredLogs.slice(-MAX_WORKER_LOGS)
- : filteredLogs
- worker.log_count = logRes.data.log_count || 0
- worker.hasNewLogs = false
- console.log(`[BuffSelectionView] Worker ${workerIndex} loaded ${newLogs.length} logs (filtered: ${filteredLogs.length})`)
- }
- } catch (e) {
- console.error(`[BuffSelectionView] Failed to load worker ${workerIndex} logs:`, e)
- }
- }
- nextTick(() => {
- const body = sweepLogBodyRef.value
- if (body && sweepLogAutoFollow.value) {
- body.scrollTop = body.scrollHeight
- }
- })
- }
- function onSweepLogModalOpenChange(open: boolean) {
- if (!open) {
- stopSweepLogPoll()
- sweepLogAutoFollow.value = true
- }
- }
- async function fetchSweepResult(showLoading = false): Promise<string | null> {
- // 改为纯本地读取扫货成功数据,不再调用后端 API
- if (showLoading) {
- sweepTableLoading.value = true
- }
- try {
- const res = await secureApiClient.getBuffSweepResultLocal(
- sweepPagination.current,
- sweepPagination.pageSize
- )
- if (res.code === 200 && res.data) {
- const nextItemsRaw = Array.isArray(res.data.items) ? res.data.items : []
- const nextItems = [...nextItemsRaw].sort((a: any, b: any) => {
- // 主要排序:按 created_at 时间倒序(最新在前)
- const byTime = parseLogTimestamp(b?.created_at) - parseLogTimestamp(a?.created_at)
- if (byTime !== 0) return byTime
- // 次要排序:按 listing_id 倒序(字符串比较,保持顺序一致性)
- const idA = a?.listing_id ?? ''
- const idB = b?.listing_id ?? ''
- return idB.localeCompare(idA)
- })
- // 与选品列表一致:每次拉取后整体替换,轮询时 showLoading=false,表格无整表 loading 闪烁。
- // 限制数组大小,防止内存泄漏
- const MAX_SWEEP_ITEMS = 2000
- sweepList.value = nextItems.length > MAX_SWEEP_ITEMS
- ? nextItems.slice(-MAX_SWEEP_ITEMS)
- : nextItems
- sweepPagination.total = res.data.total || 0
- // 本地数据没有 status,返回 null 让轮询继续
- return null
- }
- return null
- } finally {
- if (showLoading) {
- sweepTableLoading.value = false
- }
- }
- }
- function stopSweepPoll() {
- if (sweepPollTimer.value) {
- clearInterval(sweepPollTimer.value)
- sweepPollTimer.value = null
- }
- // 清除连续错误计数
- sweepPollConsecutiveErrors.value = 0
- }
- // 扫货成功列表定时刷新:每分钟刷新一次
- function startSweepResultRefreshTimer() {
- if (sweepResultRefreshTimer.value) return
- sweepResultRefreshTimer.value = setInterval(() => {
- fetchSweepResult(false)
- }, 60000) // 60秒 = 1分钟
- }
- function stopSweepResultRefreshTimer() {
- if (sweepResultRefreshTimer.value) {
- clearInterval(sweepResultRefreshTimer.value)
- sweepResultRefreshTimer.value = null
- }
- }
- const SWEEP_POLL_MAX_CONSECUTIVE_ERRORS = 10 // 连续 10 次错误后停止轮询(约 30 秒)
- /**
- * 扫货状态轮询:改为纯本地数据刷新,不再依赖后端 status。
- * 由于本地读取很快,可以持续轮询。
- * 增加了超时机制:连续多次错误后自动停止轮询。
- */
- function startSweepPoll(showFinishMessage = false) {
- if (sweepPollTimer.value) return
- // 重置连续错误计数
- sweepPollConsecutiveErrors.value = 0
- const pollSweepStatus = () => {
- fetchSweepResult(false)
- .then(() => {
- // 成功时重置错误计数
- sweepPollConsecutiveErrors.value = 0
- // 本地数据总是可用的,保持 running 状态
- buffSweepStore.sweepRunning = true
- })
- .catch(() => {
- sweepPollConsecutiveErrors.value++
- console.warn(`[BuffSelectionView] sweep poll error (${sweepPollConsecutiveErrors.value}/${SWEEP_POLL_MAX_CONSECUTIVE_ERRORS})`)
- // 连续错误超过阈值,停止轮询
- if (sweepPollConsecutiveErrors.value >= SWEEP_POLL_MAX_CONSECUTIVE_ERRORS) {
- console.error('[BuffSelectionView] sweep poll exceeded max consecutive errors, stopping poll')
- stopSweepPoll()
- buffSweepStore.sweepRunning = false
- }
- })
- }
- pollSweepStatus()
- // 本地读取很快,可以持续轮询,但间隔稍长一点
- sweepPollTimer.value = setInterval(pollSweepStatus, 3000)
- }
- function onSweepTableChange(pagination: any) {
- sweepPagination.current = pagination?.current || 1
- sweepPagination.pageSize = pagination?.pageSize || 10
- }
- /** 判断是否为网络相关错误(需要重试) */
- function isNetworkError(error: any): boolean {
- if (!error) return false
- const msg = error?.message ?? error?.msg ?? String(error)
- return (
- msg.includes('connection closed') ||
- msg.includes('ECONNRESET') ||
- msg.includes('ETIMEDOUT') ||
- msg.includes('network error') ||
- msg.includes('fetch failed') ||
- msg.includes('网络错误') ||
- msg.includes('网络连接')
- )
- }
- /** 拉取选品结果(状态+列表),更新 selectionList,返回当前状态与请求单ID */
- async function fetchSelectionResult(showLoading = false): Promise<{ status: string | null; requestId: number | null }> {
- const sessionId = authStore.sessionId
- const machineCode = authStore.machineCode
- if (!sessionId || !machineCode) return { status: null, requestId: null }
- if (showLoading) {
- tableLoading.value = true
- }
- const doFetch = async (): Promise<{ status: string | null; requestId: number | null }> => {
- const res = await secureApiClient.getBuffSelectionResult(sessionId, machineCode)
- if (res.code === 200 && res.data) {
- const items = Array.isArray(res.data.items) ? res.data.items : []
- selectionList.value = [...items].sort((a: any, b: any) => {
- const byTime = parseLogTimestamp(b?.created_at) - parseLogTimestamp(a?.created_at)
- if (byTime !== 0) return byTime
- return Number(b?.id ?? 0) - Number(a?.id ?? 0)
- })
- const maxPage = Math.max(1, Math.ceil(selectionList.value.length / tablePagination.pageSize))
- if (tablePagination.current > maxPage) {
- tablePagination.current = maxPage
- }
- const existsKeySet = new Set(selectionList.value.map((item: any) => rowKey(item)))
- selectionSelectedRowKeys.value = selectionSelectedRowKeys.value.filter((k) => existsKeySet.has(k))
- return { status: res.data.status, requestId: res.data.request_id ?? null }
- }
- return { status: null, requestId: null }
- }
- try {
- return await doFetch()
- } catch (error) {
- // 网络错误时重试一次
- if (isNetworkError(error)) {
- console.warn('[BuffSelectionView] fetchSelectionResult 遇到网络错误,等待后重试...')
- await new Promise<void>((resolve) => setTimeout(resolve, 1500))
- try {
- return await doFetch()
- } catch (retryError) {
- console.error('[BuffSelectionView] fetchSelectionResult 重试失败:', retryError)
- return { status: null, requestId: null }
- }
- }
- console.error('[BuffSelectionView] fetchSelectionResult 失败:', error)
- return { status: null, requestId: null }
- } finally {
- if (showLoading) {
- tableLoading.value = false
- }
- }
- }
- function stopSelectionListPoll() {
- if (selectionListPollTimer.value) {
- clearInterval(selectionListPollTimer.value)
- selectionListPollTimer.value = null
- }
- }
- function getAutoSelectionLastRunStorageKey(): string {
- const uid = authStore.userId != null ? String(authStore.userId) : 'guest'
- return `buff_selection_auto_last_run_time_${uid}`
- }
- function getAutoSelectionLastRunTime(): string | null {
- try {
- return localStorage.getItem(getAutoSelectionLastRunStorageKey())
- } catch {
- return null
- }
- }
- function setAutoSelectionLastRunTime(timeStr: string) {
- try {
- // 存储日期时间,格式:2024-04-12_08:00,用于判断同一天是否已执行过
- const now = dayjs()
- const dateTimeStr = `${now.format('YYYY-MM-DD')}_${timeStr}`
- localStorage.setItem(getAutoSelectionLastRunStorageKey(), dateTimeStr)
- } catch {
- // ignore
- }
- }
- function shouldRunAutoSelectionNow(now: Dayjs): boolean {
- const configuredTime = form.autoSelectionTime
- if (!configuredTime || !/^\d{2}:\d{2}$/.test(configuredTime)) return false
- const [h, m] = configuredTime.split(':').map(Number)
- if (Number.isNaN(h) || Number.isNaN(m)) return false
- // 同一日期的同一时间点(YYYY-MM-DD_HH:mm)仅触发一次;修改时间点或跨天后可重新触发
- const lastRunTime = getAutoSelectionLastRunTime()
- // lastRunTime 格式:2024-04-12_08:00,需同时比较日期和时间部分
- if (lastRunTime) {
- const [lastRunDatePart, lastRunTimePart] = lastRunTime.split('_')
- if (lastRunTimePart === configuredTime) {
- const today = now.format('YYYY-MM-DD')
- if (lastRunDatePart === today) return false // 同一天同一时间已执行过
- }
- }
- // 核心逻辑:只比较"小时:分钟",精确到分钟判断
- const nowMinutes = now.hour() * 60 + now.minute()
- const targetMinutes = h * 60 + m
- // 如果当前时间已超过配置时间(分钟比较),不触发,等明天
- if (nowMinutes > targetMinutes) return false
- // 当前时间未超过配置时间时,检查是否恰好到达该时间点(精确到分钟)
- return nowMinutes === targetMinutes
- }
- function startAutoSelectionScheduler() {
- stopAutoSelectionScheduler()
- autoSelectionTimer.value = setInterval(() => {
- if (!initialLoadDone.value) return
- if (running.value) return
- const now = dayjs()
- if (!shouldRunAutoSelectionNow(now)) return
- handleStart('auto')
- }, 20 * 1000)
- }
- function stopAutoSelectionScheduler() {
- if (autoSelectionTimer.value) {
- clearInterval(autoSelectionTimer.value)
- autoSelectionTimer.value = null
- }
- }
- /**
- * 开始新的选品或扫货前:结束服务端该用户所有进行中的选品单、扫货单,并停止本地扫货进程与前台轮询。
- * 顺序与产品要求一致:先停选品,再停扫货(含 Rust request_cancel)。
- */
- async function prepareBuffEnvironmentBeforeStart(
- forWhat: 'selection' | 'sweep',
- opts: { manualHint?: boolean } = {}
- ): Promise<boolean> {
- const sessionId = authStore.sessionId
- const machineCode = authStore.machineCode
- if (!sessionId || !machineCode) return false
- buffPrepareFor.value = forWhat
- buffSweepStore.switchingStatus = 'resetting_before_start'
- try {
- const stopSel = await secureApiClient.stopBuffSelection(sessionId, machineCode)
- const stopSw = await secureApiClient.stopBuffSweep(sessionId, machineCode)
- stopSelectionListPoll()
- stopSweepPoll()
- buffSweepStore.selectionRunning = false
- buffSweepStore.sweepRunning = false
- await new Promise<void>((resolve) => setTimeout(resolve, 300))
- await fetchSelectionResult(true).catch(() => {})
- await fetchSweepResult(true).catch(() => {})
- if (opts.manualHint) {
- if (stopSel.code !== 200) {
- message.warning(stopSel.msg || '结束进行中的选品任务失败,仍将尝试开始')
- }
- if (stopSw.code !== 200) {
- message.warning(stopSw.msg || '结束进行中的扫货任务失败,仍将尝试开始')
- }
- }
- return true
- } catch {
- if (opts.manualHint) {
- message.warning('结束进行中的任务时网络异常,仍将尝试开始')
- }
- stopSelectionListPoll()
- stopSweepPoll()
- buffSweepStore.selectionRunning = false
- buffSweepStore.sweepRunning = false
- return true
- } finally {
- buffSweepStore.switchingStatus = null
- buffPrepareFor.value = null
- }
- }
- async function handleStart(source: 'manual' | 'auto' = 'manual') {
- // 防止模板直接绑定函数时 click 事件对象误传入,默认按手动触发处理
- if (source !== 'manual' && source !== 'auto') {
- source = 'manual'
- }
- if (form.steamCardRatio == null) {
- if (source === 'manual') message.warning('请先填写 Steam 卡价比例')
- return
- }
- if (form.steamCardRatio < 4) {
- if (source === 'manual') message.error('选品失败:Steam 卡价比例不能小于 4,请先修改配置')
- else message.error('自动选品失败:Steam 卡价比例不能小于 4,请先修改配置')
- return
- }
- if (form.profitRate == null) {
- if (source === 'manual') message.warning('请先填写利润率')
- return
- }
- // 验证系统配置限制
- const maxWorkerCount = configStore.config.buff_sweep_max_worker_count ?? 32
- const minSteamCardRatio = configStore.config.buff_steam_card_ratio_min ?? 4
- if (form.sweepProcessCount > maxWorkerCount) {
- if (source === 'manual') {
- message.error(`选品失败:扫货线程数不能超过系统配置的最大值 ${maxWorkerCount}`)
- } else {
- message.error(`自动选品失败:扫货线程数不能超过系统配置的最大值 ${maxWorkerCount}`)
- }
- return
- }
- if (form.steamCardRatio < minSteamCardRatio) {
- if (source === 'manual') {
- message.error(`选品失败:Steam卡价比例不能小于系统配置的最小值 ${minSteamCardRatio}`)
- } else {
- message.error(`自动选品失败:Steam卡价比例不能小于系统配置的最小值 ${minSteamCardRatio}`)
- }
- return
- }
- const sessionId = authStore.sessionId
- const machineCode = authStore.machineCode
- if (!sessionId || !machineCode) {
- if (source === 'manual') message.warning('请先登录')
- return
- }
- await prepareBuffEnvironmentBeforeStart('selection', { manualHint: source === 'manual' })
- buffSweepStore.selectionRunning = true
- stopSelectionListPoll()
- secureApiClient
- .startBuffSelection(sessionId, machineCode)
- .then((res) => {
- if (res.code !== 200) {
- message.warning(res.msg || '选品启动失败')
- buffSweepStore.selectionRunning = false
- return
- }
- if (source === 'manual') {
- message.info(res.msg || '选品已开始,正在后台执行')
- } else {
- setAutoSelectionLastRunTime(form.autoSelectionTime)
- message.info(`已到自动选品时间(${form.autoSelectionTime}),开始执行选品`)
- }
- const startedRequestId = res.data?.request_id ?? null
- if (startedRequestId != null) {
- autoSweepAllowedSelectionRequestIds.value.add(startedRequestId)
- }
- // 立即拉一次结果,再启动定时刷新
- fetchSelectionResult(false).then(({ status }) => {
- if (status === 'pending' || status === 'running') {
- selectionListPollTimer.value = setInterval(() => {
- fetchSelectionResult(false).then(({ status: s, requestId }) => {
- if (s === 'pending' || s === 'running') {
- return
- }
- stopSelectionListPoll()
- buffSweepStore.selectionRunning = false
- if (s === 'completed') {
- if (source === 'manual') message.success('选品已完成')
- // 仅本次点击「开始选品」创建的请求单完成后,才允许自动触发扫货
- if (startedRequestId != null && requestId === startedRequestId) {
- handleStartSweep(true, requestId)
- }
- // 兜底:即使扫货并非由当前前端调用启动,也以服务端状态为准同步“扫货中”标签
- fetchSweepResult(false).then((sweepStatus) => {
- if (sweepStatus === 'pending' || sweepStatus === 'running') {
- buffSweepStore.sweepRunning = true
- startSweepPoll(false)
- }
- })
- }
- if (s === 'cancelled') message.info('选品已停止')
- })
- }, 6000)
- } else {
- buffSweepStore.selectionRunning = false
- }
- })
- })
- .catch(() => {
- buffSweepStore.selectionRunning = false
- })
- }
- function handleStop() {
- const sessionId = authStore.sessionId
- const machineCode = authStore.machineCode
- if (!sessionId || !machineCode) {
- stopSelectionListPoll()
- buffSweepStore.selectionRunning = false
- return
- }
- secureApiClient.stopBuffSelection(sessionId, machineCode).then((res) => {
- stopSelectionListPoll()
- buffSweepStore.selectionRunning = false
- if (res.code === 200) {
- message.success(res.msg || '已发送停止请求,选品将尽快结束')
- } else {
- message.warning(res.msg || '停止请求发送失败')
- }
- }).catch(() => {
- stopSelectionListPoll()
- buffSweepStore.selectionRunning = false
- })
- }
- async function handleDeleteSelectionItem(record: any) {
- const sessionId = authStore.sessionId
- const machineCode = authStore.machineCode
- if (!sessionId || !machineCode) {
- message.warning('请先登录')
- return
- }
- const itemId = getSelectionItemId(record)
- if (itemId == null) {
- message.warning('无效的选品数据')
- return
- }
- selectionDeleting.value = true
- try {
- const res = await secureApiClient.deleteBuffSelectionItem(sessionId, machineCode, itemId)
- if (res.code === 200) {
- message.success('删除成功,该饰品不会再参与扫货')
- await fetchSelectionResult(true)
- } else {
- message.warning(res.msg || '删除失败')
- }
- } finally {
- selectionDeleting.value = false
- }
- }
- async function handleBatchDeleteSelectionItems() {
- const sessionId = authStore.sessionId
- const machineCode = authStore.machineCode
- if (!sessionId || !machineCode) {
- message.warning('请先登录')
- return
- }
- if (selectionDeleting.value) return
- if (selectionSelectedRowKeys.value.length === 0) {
- message.info('请先勾选要删除的数据')
- return
- }
- const selectedKeySet = new Set(selectionSelectedRowKeys.value.map((k) => String(k)))
- const selectedItems = selectionList.value.filter((item) => selectedKeySet.has(String(rowKey(item))))
- const itemIds = selectedItems
- .map((item) => getSelectionItemId(item))
- .filter((id): id is number => id != null)
- if (itemIds.length === 0) {
- message.warning('未找到可删除的数据')
- return
- }
- selectionDeleting.value = true
- try {
- const results = await Promise.all(itemIds.map((itemId) => secureApiClient.deleteBuffSelectionItem(sessionId, machineCode, itemId)))
- const successCount = results.filter((r) => r.code === 200).length
- const failCount = results.length - successCount
- if (successCount > 0) {
- message.success(`已删除 ${successCount} 条数据`)
- selectionSelectedRowKeys.value = []
- await fetchSelectionResult(true)
- }
- if (failCount > 0) {
- message.warning(`有 ${failCount} 条删除失败,请稍后重试`)
- }
- } finally {
- selectionDeleting.value = false
- }
- }
- async function handleBatchImportSelectionItems() {
- const sessionId = authStore.sessionId
- const machineCode = authStore.machineCode
- if (!sessionId || !machineCode) {
- message.warning('请先登录')
- return
- }
- if (batchImportLoading.value) return
- // 确保安全客户端已初始化(避免 ts=0 导致请求被拒)
- const securityOk = await secureApiClient.checkSecurityInitialized()
- if (!securityOk) {
- console.warn('[BuffSelection] 安全客户端未初始化,正在重新初始化...')
- const reinitOk = await secureApiClient.initializeSecurityClient(authStore.user?.username)
- if (!reinitOk) {
- message.error('安全初始化失败,请重新登录')
- return
- }
- }
- const selected = await openDialog({
- multiple: true,
- filters: [{ name: 'JSON', extensions: ['json'] }],
- title: '选择要导入的选品JSON文件'
- })
- if (!selected || (Array.isArray(selected) && selected.length === 0)) {
- return
- }
- const filePaths = Array.isArray(selected) ? selected : [selected]
- if (filePaths.length === 0) return
- batchImportLoading.value = true
- try {
- const res = await secureApiClient.batchImportBuffSelectionItems(
- sessionId,
- machineCode,
- filePaths,
- form.steamCardRatio ?? 5.1
- )
- if (res.code === 200) {
- message.success(res.msg || `批量导入成功,共导入 ${res.data?.imported_count ?? 0} 条数据`)
- await fetchSelectionResult(true)
- } else {
- message.warning(res.msg || '批量导入失败')
- }
- } catch (error: any) {
- console.error('[BuffSelection] batch import error:', error)
- message.error(error?.message || '批量导入失败')
- } finally {
- batchImportLoading.value = false
- }
- }
- async function handleStartSweep(isAuto: boolean, sourceSelectionRequestId?: number | null) {
- const sessionId = authStore.sessionId
- const machineCode = authStore.machineCode
- if (!sessionId || !machineCode) {
- message.warning('请先登录')
- return
- }
- if (selectionList.value.length === 0) {
- if (!isAuto) message.warning('暂无可扫货的选品数据')
- return
- }
- // 验证至少有一个参与扫货的 Steam 账号
- const activeAccounts = boundSteamAccounts.value.filter(a => a.is_active !== false)
- if (activeAccounts.length === 0) {
- if (!isAuto) message.warning('没有参与扫货的 Steam 账号,请至少开启一个')
- return
- }
- // 构建参与扫货账号的充值卡价比例映射
- const accountCardRateMap: Record<string, number> = {}
- for (const account of activeAccounts) {
- if (account.cardRate != null) {
- accountCardRateMap[account.steam_username] = account.cardRate
- }
- }
- // 验证系统配置限制
- const maxWorkerCount = configStore.config.buff_sweep_max_worker_count ?? 32
- const minSteamCardRatio = configStore.config.buff_steam_card_ratio_min ?? 4
- if (form.sweepProcessCount > maxWorkerCount) {
- if (!isAuto) {
- message.error(`扫货启动失败:扫货线程数不能超过系统配置的最大值 ${maxWorkerCount}`)
- }
- buffSweepStore.sweepRunning = false
- return
- }
- if (form.steamCardRatio != null && form.steamCardRatio < minSteamCardRatio) {
- if (!isAuto) {
- message.error(`扫货启动失败:Steam卡价比例不能小于系统配置的最小值 ${minSteamCardRatio}`)
- }
- buffSweepStore.sweepRunning = false
- return
- }
- // 自动扫货时跳过环境准备(stopBuffSweep 会取消刚创建的扫货请求)
- // 手动扫货时保持原有逻辑:先停止旧扫货再启动新扫货
- if (!isAuto) {
- await prepareBuffEnvironmentBeforeStart('sweep', { manualHint: true })
- }
- if (isAuto) {
- const requestId = sourceSelectionRequestId ?? null
- if (requestId == null || !autoSweepAllowedSelectionRequestIds.value.has(requestId)) {
- return
- }
- // 自动扫货一次性消费,避免同一 request_id 重复触发
- autoSweepAllowedSelectionRequestIds.value.delete(requestId)
- }
- buffSweepStore.sweepRunning = true
- stopSweepPoll()
- const syncSweepRunningFromServer = () => {
- fetchSweepResult(false).then((status) => {
- if (status === 'pending' || status === 'running') {
- buffSweepStore.sweepRunning = true
- startSweepPoll(false)
- return
- }
- if (status === 'completed' || status === 'failed' || status === 'cancelled') {
- buffSweepStore.sweepRunning = false
- }
- // status 为空或未知时,保持当前状态并等待下一次轮询/同步,避免误关“扫货中”
- }).catch(() => {
- // 网络抖动时不立即关闭“扫货中”
- })
- }
- secureApiClient.startBuffSweep(sessionId, machineCode).then(async (res) => {
- if (res.code !== 200) {
- if (res.code === 409) {
- // 后端已在扫货:应保持“扫货中”状态并接管轮询,而不是关闭状态
- buffSweepStore.sweepRunning = true
- startSweepPoll(true)
- message.info(res.msg || '扫货已在进行中')
- } else if (!isAuto) {
- syncSweepRunningFromServer()
- message.warning(res.msg || '扫货启动失败')
- } else {
- syncSweepRunningFromServer()
- }
- return
- }
- const sweepRid = res.data?.request_id
- const selRid = res.data?.selection_request_id
- if (sweepRid != null && selRid != null) {
- const wc =
- form.sweepProcessCount != null && Number.isFinite(Number(form.sweepProcessCount))
- ? Math.max(1, Math.min(maxWorkerCount, Math.floor(Number(form.sweepProcessCount))))
- : 3
- const spawned = await secureApiClient.spawnBuffSweepLocalRunner(
- sessionId,
- machineCode,
- sweepRid,
- selRid,
- wc,
- activeAccounts.map(a => a.steam_username),
- accountCardRateMap
- )
- if (!spawned.ok && !isAuto) {
- message.warning(spawned.msg || '本地扫货引擎未启动,请稍后重试')
- }
- }
- if (!isAuto) message.success(res.msg || '扫货已开始')
- startSweepPoll(true)
- }).catch(() => {
- syncSweepRunningFromServer()
- })
- }
- function handleStopSweep() {
- const sessionId = authStore.sessionId
- const machineCode = authStore.machineCode
- if (!sessionId || !machineCode) {
- stopSweepPoll()
- buffSweepStore.sweepRunning = false
- return
- }
- // 发送停止请求
- const stopPromise = secureApiClient.stopBuffSweep(sessionId, machineCode)
- // 设置超时:如果 5 秒内没有响应,强制停止前端状态
- const timeoutPromise = new Promise<void>((resolve) => {
- setTimeout(() => {
- console.warn('[BuffSelectionView] stopBuffSweep timeout, forcing stop')
- resolve()
- }, 5000)
- })
- Promise.race([stopPromise, timeoutPromise]).then((res: any) => {
- stopSweepPoll()
- buffSweepStore.sweepRunning = false
- if (res?.code === 200) {
- message.success(res.msg || '已发送停止请求,扫货将尽快结束')
- } else {
- // 超时情况或失败
- message.warning(res?.msg || '停止请求超时,已强制停止前端状态')
- }
- fetchSweepResult(true)
- }).catch(() => {
- stopSweepPoll()
- buffSweepStore.sweepRunning = false
- })
- }
- function openFilterModal(type: FilterModalType) {
- filterModalType.value = type
- const key = type
- // 从 { label, value }[] 中提取 value[]
- const current = form[key] as { label: string; value: string }[]
- // 修复:先确保 current[0] 是对象(而非字符串),再检查是否有 'value' 属性
- const isLabelValueArray = current && current.length > 0 && current[0] &&
- typeof current[0] === 'object' && !Array.isArray(current[0]) && 'value' in current[0]
- filterDraftSingle.value = isLabelValueArray
- ? current.map(i => i.value)
- : (form[key] as string[]) ?? []
- filterModalVisible.value = true
- }
- function closeFilterModal() {
- filterModalVisible.value = false
- filterModalType.value = null
- }
- function confirmFilterModal() {
- const key = filterModalType.value
- if (key) {
- const draft = [...filterDraftSingle.value]
- // 将 value[] 转换成 { label, value }[]
- const options = filterOptions[key as keyof typeof filterOptions] as { label: string; value: string }[]
- const labelMap = new Map(options.map(o => [o.value, o.label]))
- const withLabels = draft.map(v => ({
- label: labelMap.get(v) ?? v,
- value: v
- }))
- if (key === 'rifle') form.rifle = withLabels
- else if (key === 'pistol') form.pistol = withLabels
- else if (key === 'smg') form.smg = withLabels
- else if (key === 'shotgun') form.shotgun = withLabels
- else if (key === 'machinegun') form.machinegun = withLabels
- else if (key === 'quality') form.quality = withLabels
- else if (key === 'category') form.category = withLabels
- else if (key === 'exterior') form.exterior = withLabels
- else if (key === 'collection') form.collection = withLabels
- }
- filterModalVisible.value = false
- filterModalType.value = null
- }
- function handleGoodsIconError(e: Event) {
- const el = e.currentTarget as HTMLImageElement
- if (el) el.style.display = 'none'
- }
- function normalizeGoodsIconUrl(url: string | null | undefined): string {
- const value = typeof url === 'string' ? url.trim() : ''
- if (!value) return ''
- // BUFF 侧常见返回 `//cdn...`,在 Tauri WebView 中需补全协议
- if (value.startsWith('//')) return `https:${value}`
- return value
- }
- function parseIdsFromText(text: string): string[] {
- if (!text || typeof text !== 'string') return []
- return text
- .split(/\r?\n/)
- .map((s) => s.trim())
- .filter(Boolean)
- }
- function openIdsListModal(type: 'exclude' | 'specified') {
- idsListModalType.value = type
- idsListData.value = []
- idsListSelectedRowKeys.value = []
- idsListModalVisible.value = true
- nextTick(() => refreshIdsListData())
- }
- function onIdsListSelectionChange(keys: (string | number)[]) {
- idsListSelectedRowKeys.value = keys.map((k) => String(k))
- }
- function batchRemoveIdsList() {
- const type = idsListModalType.value
- const keys = idsListSelectedRowKeys.value
- if (!type || !keys.length) return
- const raw = type === 'exclude' ? form.excludeIds : form.specifiedIds
- const idSet = new Set(keys)
- const ids = parseIdsFromText(raw).filter((id) => !idSet.has(id))
- if (type === 'exclude') {
- form.excludeIds = ids.join('\n')
- } else {
- form.specifiedIds = ids.join('\n')
- }
- idsListSelectedRowKeys.value = []
- refreshIdsListData()
- }
- function onIdsModalOpenChange(open: boolean) {
- if (open && idsListModalType.value) {
- refreshIdsListData()
- }
- }
- async function refreshIdsListData() {
- const type = idsListModalType.value
- if (!type) return
- const raw = type === 'exclude' ? form.excludeIds : form.specifiedIds
- const str = normalizeIdsField(raw)
- const ids = parseIdsFromText(str)
- idsListData.value = []
- if (ids.length === 0) return
- idsListLoading.value = true
- const sessionId = authStore.sessionId
- const machineCode = authStore.machineCode
- if (!sessionId || !machineCode) {
- message.warning('请先登录')
- return
- }
- try {
- const res = await secureApiClient.getBuffGoodsAttrList(ids, sessionId, machineCode)
- const list = (res.code === 200 && res.data ? res.data : []) as { goods_id: string; name: string; icon_url: string }[]
- const map = new Map(list.map((item) => [String(item.goods_id), item]))
- idsListData.value = ids.map((id) => {
- const found = map.get(id)
- return found
- ? { goods_id: String(found.goods_id), name: found.name || '—', icon_url: normalizeGoodsIconUrl(found.icon_url) }
- : { goods_id: id, name: '暂无信息', icon_url: '' }
- })
- } catch (e) {
- console.error('获取商品属性列表失败:', e)
- message.error('获取商品信息失败,请稍后重试')
- idsListData.value = ids.map((id) => ({ goods_id: id, name: '暂无信息', icon_url: '' }))
- } finally {
- idsListLoading.value = false
- }
- }
- async function uploadTxtInIdsModal() {
- const type = idsListModalType.value
- if (!type) return
- const path = await pickTxtFile()
- if (!path) return
- try {
- const content = await invoke<string>('read_text_file', { path })
- const uploadedIds = content
- .split(/\r?\n/)
- .map((s) => s.trim())
- .filter(Boolean)
- const existingIds = parseIdsFromText(type === 'exclude' ? form.excludeIds : form.specifiedIds)
- const merged = [...new Set([...existingIds, ...uploadedIds])]
- if (type === 'exclude') {
- form.excludeIds = merged.join('\n')
- } else {
- form.specifiedIds = merged.join('\n')
- }
- message.success(`已上传并剔重,共 ${merged.length} 个饰品 id`)
- await refreshIdsListData()
- } catch (e: any) {
- message.error(e?.toString() || '读取文件失败')
- }
- }
- function removeIdFromIdsList(goodsId: string) {
- const type = idsListModalType.value
- if (!type) return
- const raw = type === 'exclude' ? form.excludeIds : form.specifiedIds
- const ids = parseIdsFromText(raw).filter((id) => id !== goodsId)
- if (type === 'exclude') {
- form.excludeIds = ids.join('\n')
- } else {
- form.specifiedIds = ids.join('\n')
- }
- refreshIdsListData()
- }
- async function loadFilterOptions() {
- const sessionId = authStore.sessionId
- const machineCode = authStore.machineCode
- if (!sessionId || !machineCode) {
- return
- }
- try {
- const res = await secureApiClient.getBuffFilterOptions('csgo', sessionId, machineCode)
- if (res.code === 200 && res.data) {
- filterOptions.rifle = res.data.rifle ?? []
- filterOptions.pistol = res.data.pistol ?? []
- filterOptions.smg = res.data.smg ?? []
- filterOptions.shotgun = res.data.shotgun ?? []
- filterOptions.machinegun = res.data.machinegun ?? []
- filterOptions.quality = filterOptionsByLabel(res.data.quality ?? [], ALLOWED_QUALITY_LABELS)
- filterOptions.category = filterOptionsByLabel(res.data.category ?? [], ALLOWED_CATEGORY_LABELS)
- filterOptions.exterior = filterOptionsByLabel(res.data.exterior ?? [], ALLOWED_EXTERIOR_LABELS)
- filterOptions.collection = res.data.collection ?? []
- filterOptions.collectionTree = res.data.collectionTree ?? []
- }
- } catch (e) {
- console.error('加载BUFF筛选项失败:', e)
- message.error('筛选项加载失败,请检查网络或稍后重试')
- }
- }
- onMounted(async () => {
- // 先加载 filterOptions,再加载 conditions(因为 convertArr 依赖 filterOptions 转换 label)
- await loadFilterOptions()
- await loadConditions()
- const [selectionResult, sweepStatus] = await Promise.all([
- fetchSelectionResult(true),
- fetchSweepResult(true)
- ])
- const selectionStatus = selectionResult.status
- if (selectionStatus === 'pending' || selectionStatus === 'running') {
- buffSweepStore.selectionRunning = true
- selectionListPollTimer.value = setInterval(() => {
- fetchSelectionResult(false).then(({ status: s }) => {
- if (s === 'pending' || s === 'running') return
- stopSelectionListPoll()
- buffSweepStore.selectionRunning = false
- // 页面初始化时仅恢复选品状态,不自动触发扫货,避免历史数据误触发
- })
- }, 6000)
- }
- if (sweepStatus === 'pending' || sweepStatus === 'running') {
- buffSweepStore.sweepRunning = true
- startSweepPoll(false)
- }
- initialLoadDone.value = true
- startAutoSelectionScheduler()
- scheduleDeferredBoundSteamLoad()
- startSweepResultRefreshTimer()
- })
- onUnmounted(() => {
- autoSweepAllowedSelectionRequestIds.value.clear()
- stopAutoSelectionScheduler()
- stopSelectionListPoll()
- stopSelectionLogPoll()
- stopSweepPoll()
- stopSweepLogPoll()
- stopSweepResultRefreshTimer()
- stopBoundSteamBalancePoll()
- if (deferredBoundSteamLoadTimer.value) {
- clearTimeout(deferredBoundSteamLoadTimer.value)
- deferredBoundSteamLoadTimer.value = null
- }
- })
- </script>
- <style scoped>
- .buff-selection-container {
- height: 100%;
- width: 100%;
- min-height: 0;
- overflow: auto;
- box-sizing: border-box;
- padding: 16px;
- }
- .buff-card {
- display: flex;
- flex-direction: column;
- gap: 20px;
- width: 100%;
- max-width: 100%;
- box-sizing: border-box;
- padding-left: 20px;
- padding-right: 20px;
- padding-bottom: 80px;
- }
- /* 固定底部操作按钮 */
- .fixed-bottom-buttons {
- position: fixed;
- bottom: 0;
- left: 0;
- right: 0;
- z-index: 100;
- background: #fff;
- padding: 12px 24px;
- box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
- }
- .fixed-bottom-vip {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 8px;
- margin-bottom: 8px;
- }
- .fixed-bottom-vip-expire {
- font-size: 12px;
- color: #666;
- }
- .fixed-bottom-buttons-inner {
- display: flex;
- gap: 16px;
- align-items: center;
- justify-content: center;
- }
- .buff-status-indicator {
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 6px 12px;
- font-size: 13px;
- font-weight: 500;
- }
- .buff-status-indicator .anticon {
- font-size: 14px;
- }
- .status-text.is-switching {
- color: #fa8c16;
- }
- .status-text.is-selection {
- color: #52c41a;
- }
- .status-text.is-sweep {
- color: #1677ff;
- }
- /* 选品开始:绿色主按钮 */
- .fixed-bottom-buttons-inner .btn-start.ant-btn-primary {
- background: #52c41a;
- border-color: #52c41a;
- }
- .fixed-bottom-buttons-inner .btn-start.ant-btn-primary:hover:not(:disabled) {
- background: #73d13d;
- border-color: #73d13d;
- }
- .fixed-bottom-buttons-inner .btn-sweep-start.ant-btn-primary {
- background: #1677ff;
- border-color: #1677ff;
- }
- .fixed-bottom-buttons-inner .btn-sweep-start.ant-btn-primary:hover:not(:disabled) {
- background: #4096ff;
- border-color: #4096ff;
- }
- .section {
- flex-shrink: 0;
- background: #fff;
- border: 1px solid #e8e8e8;
- border-radius: 8px;
- padding: 16px;
- margin-bottom: 16px;
- }
- .section:last-child {
- border-bottom: 1px solid #e8e8e8;
- flex: 1;
- min-height: 0;
- display: flex;
- flex-direction: column;
- margin-bottom: 0;
- }
- /* 条件设置区域 - 可折叠 */
- .conditions-section {
- border-bottom: 1px solid #e8e8e8;
- padding-bottom: 16px;
- }
- .conditions-section.is-collapsed {
- border-bottom: 1px solid #e8e8e8;
- }
- .conditions-section .section {
- background: transparent;
- border: none;
- border-radius: 0;
- padding: 0;
- margin-bottom: 0;
- }
- .conditions-summary-bar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 10px 16px;
- background: #f5f5f5;
- border-radius: 6px;
- cursor: pointer;
- transition: background 0.2s;
- }
- .conditions-summary-bar:hover {
- background: #e8e8e8;
- }
- .conditions-summary-content {
- display: flex;
- align-items: flex-start;
- gap: 8px;
- flex: 1;
- overflow: hidden;
- }
- .conditions-summary-icon {
- font-size: 16px;
- flex-shrink: 0;
- margin-top: 2px;
- }
- .conditions-summary-text {
- font-size: 14px;
- color: #333;
- line-height: 1.6;
- }
- .conditions-summary-text :deep(br) {
- display: block;
- content: "";
- margin: 2px 0;
- }
- .conditions-summary-toggle {
- display: flex;
- align-items: center;
- gap: 4px;
- flex-shrink: 0;
- margin-left: 12px;
- }
- .toggle-hint {
- font-size: 12px;
- color: #999;
- }
- .toggle-icon {
- font-size: 10px;
- color: #999;
- }
- .conditions-detail {
- padding-top: 16px;
- }
- .section-header {
- display: flex;
- align-items: center;
- gap: 8px;
- margin-bottom: 12px;
- padding-bottom: 10px;
- border-bottom: 1px solid #f0f0f0;
- }
- .section-header-with-action {
- justify-content: space-between;
- gap: 12px;
- }
- .section-header-left {
- display: flex;
- align-items: center;
- gap: 8px;
- }
- .selection-search-bar {
- flex: 1;
- display: flex;
- align-items: center;
- gap: 8px;
- min-width: 0;
- }
- .selection-search-input {
- max-width: 260px;
- line-height: 1;
- }
- .selection-search-input :deep(.ant-input) {
- height: 30px;
- line-height: 30px;
- }
- .selection-search-hint {
- font-size: 12px;
- color: #999;
- white-space: nowrap;
- }
- .section-actions {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- flex-shrink: 0;
- }
- .section-header-with-action .btn-log {
- flex-shrink: 0;
- padding: 0 4px;
- }
- .section-icon {
- font-size: 16px;
- }
- .section-title {
- font-size: 15px;
- font-weight: 600;
- color: #333;
- }
- .section-body {
- width: 100%;
- }
- .search-body-buttons {
- display: flex;
- flex-direction: column;
- gap: 12px;
- }
- /* 宽度自适应:宽则一行,窄则自动换行 */
- .filter-buttons-adaptive {
- display: flex;
- flex-wrap: wrap;
- gap: 10px;
- }
- .filter-buttons-adaptive .ant-btn {
- min-width: 0;
- flex-shrink: 0;
- }
- /* 步枪~收藏品:统一颜色 */
- .filter-buttons-adaptive .btn-filter {
- color: #1677ff;
- border-color: #1677ff;
- background: #fff;
- }
- .filter-buttons-adaptive .btn-filter:hover {
- color: #4096ff;
- border-color: #4096ff;
- background: #e6f4ff;
- }
- /* 有选中值时的样式 */
- .filter-buttons-adaptive .btn-filter.has-selection {
- color: #52c41a;
- border-color: #52c41a;
- background: #f6ffed;
- }
- .filter-buttons-adaptive .btn-filter.has-selection:hover {
- color: #73d13d;
- border-color: #73d13d;
- background: #d9f7be;
- }
- .filter-buttons-adaptive .filter-check {
- display: inline-block;
- margin-left: 4px;
- color: #52c41a;
- font-weight: bold;
- }
- /* 排除的饰品id、指定的饰品id:另一颜色 */
- .filter-buttons-adaptive .btn-ids {
- color: #d48806;
- border-color: #faad14;
- background: #fff;
- }
- .filter-buttons-adaptive .btn-ids:hover {
- color: #d4a106;
- border-color: #ffc53d;
- background: #fffbe6;
- }
- .filter-buttons-adaptive .btn-ids.has-selection {
- color: #52c41a;
- border-color: #52c41a;
- background: #f6ffed;
- }
- .filter-buttons-adaptive .btn-ids.has-selection:hover {
- color: #73d13d;
- border-color: #73d13d;
- background: #d9f7be;
- }
- .filter-modal-single-body {
- padding: 8px 0;
- }
- .filter-modal-single-body .form-item-full {
- margin-bottom: 0;
- }
- .filter-modal-single-body .form-item-full :deep(.ant-form-item-control) {
- width: 100%;
- }
- .filter-select-full {
- width: 100%;
- }
- .filter-modal-single-body .collection-tree-select {
- width: 100%;
- }
- .ids-modal-toolbar {
- margin-bottom: 12px;
- display: flex;
- gap: 12px;
- align-items: center;
- }
- /* 弹窗内列表区域:在弹窗内滚动,不撑出整窗 */
- .ids-modal-body {
- flex: 1;
- min-height: 0;
- overflow-y: auto;
- min-height: 120px;
- }
- .goods-icon-img {
- width: 48px;
- height: 48px;
- object-fit: contain;
- vertical-align: middle;
- }
- .goods-icon-placeholder {
- display: inline-block;
- width: 48px;
- height: 48px;
- line-height: 48px;
- text-align: center;
- background: #f5f5f5;
- color: #999;
- font-size: 12px;
- }
- .search-grid {
- display: grid;
- grid-template-columns: repeat(4, 1fr);
- gap: 12px 20px;
- }
- /* 步枪~收藏品:label 与选择框同一行,宽度一致 */
- .search-grid .form-item-filter {
- margin-bottom: 0;
- display: flex;
- flex-direction: row;
- align-items: center;
- flex-wrap: nowrap;
- }
- .search-grid .form-item-filter :deep(.ant-form-item-row) {
- display: flex;
- flex-direction: row;
- align-items: center;
- flex-wrap: nowrap;
- width: 100%;
- }
- .search-grid .form-item-filter :deep(.ant-form-item-label) {
- width: 100px;
- min-width: 100px;
- padding-bottom: 0;
- flex-shrink: 0;
- }
- .search-grid .form-item-filter :deep(.ant-form-item-control) {
- width: 160px;
- min-width: 160px;
- max-width: 160px;
- flex-shrink: 0;
- }
- .search-grid .form-item-filter :deep(.filter-select) {
- width: 160px;
- }
- .selection-summary-row {
- grid-column: 1 / -1;
- display: flex;
- flex-direction: column;
- gap: 6px;
- }
- .selection-summary-label {
- font-size: 13px;
- color: #666;
- flex-shrink: 0;
- }
- /* 紧凑展示,最多 3 行,超出滚动;类别名与检索按钮同色 */
- .selection-summary-box {
- max-height: 4.5em;
- overflow-y: auto;
- line-height: 1.5;
- padding: 6px 11px;
- background-color: #fafafa;
- border: 1px solid #d9d9d9;
- border-radius: 6px;
- color: #333;
- font-size: 13px;
- cursor: default;
- }
- .selection-summary-box .summary-label {
- color: #1677ff;
- font-weight: 500;
- }
- .selection-summary-box .summary-sep {
- color: #333;
- }
- .selection-summary-box .summary-empty {
- color: #999;
- }
- .ids-row {
- grid-column: 1 / -1;
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 20px;
- align-items: start;
- }
- .ids-field {
- display: flex;
- align-items: flex-start;
- gap: 12px;
- }
- .ids-field-left {
- display: flex;
- flex-direction: column;
- gap: 8px;
- flex-shrink: 0;
- padding-top: 2px;
- }
- .ids-field-label {
- font-size: 14px;
- color: rgba(0, 0, 0, 0.88);
- line-height: 1.5;
- }
- .ids-field-right {
- flex: 1;
- min-width: 0;
- }
- .ids-field-right .ant-input {
- width: 100%;
- }
- .form-item-full {
- margin-bottom: 0;
- }
- .form-item-full :deep(.ant-form-item-label) {
- padding-bottom: 4px;
- }
- /* 选品条件:4 行 2 列,左列 4 项、右列 4 项 */
- .condition-body {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 16px 32px;
- align-items: start;
- }
- .condition-col {
- display: flex;
- flex-direction: column;
- gap: 4px;
- min-width: 0;
- }
- .condition-col-right {
- border-left: 1px solid #f0f0f0;
- padding-left: 32px;
- }
- @media (max-width: 720px) {
- .condition-body {
- grid-template-columns: 1fr;
- }
- .condition-col-right {
- border-left: none;
- padding-left: 0;
- padding-top: 16px;
- border-top: 1px solid #f0f0f0;
- }
- }
- /* 每行一项:左侧标签列(固定列宽)、右侧控件列(自适应),所有控件左对齐 */
- .form-item-condition {
- margin-bottom: 0;
- }
- .form-item-condition :deep(.ant-form-item-row) {
- display: grid;
- grid-template-columns: 180px 1fr;
- gap: 16px;
- align-items: center;
- }
- .form-item-condition :deep(.ant-form-item-label) {
- padding-bottom: 0;
- width: 100%;
- max-width: 180px;
- min-width: 0;
- }
- .form-item-condition :deep(.ant-form-item-label > label) {
- white-space: normal;
- line-height: 1.4;
- }
- .form-item-condition :deep(.ant-form-item-control) {
- min-width: 0;
- flex: 0 1 auto;
- }
- .form-item-condition :deep(.ant-form-item-control-input) {
- min-height: auto;
- }
- /* 区间类:BUFF价格区间、BUFF在售数量 — 两个输入框横向排列 */
- .range-inputs-row {
- display: inline-flex;
- flex-direction: row;
- align-items: center;
- flex-wrap: nowrap;
- gap: 0;
- }
- .range-inputs-row .range-sep {
- flex-shrink: 0;
- }
- .form-item-condition :deep(.input-narrow) {
- width: 88px;
- }
- .form-item-condition :deep(.input-narrow .ant-input-number-input) {
- font-size: 13px;
- }
- .form-item-condition :deep(.input-time) {
- width: 100%;
- max-width: 120px;
- }
- .form-item-condition :deep(.input-range) {
- width: 80px;
- }
- .form-item-condition :deep(.input-range .ant-input-number-input) {
- font-size: 13px;
- }
- .range-sep {
- margin: 0 4px;
- color: #999;
- font-size: 13px;
- }
- /* 问号说明:紧挨标签后 */
- .label-tip-icon {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- margin-left: 4px;
- width: 14px;
- height: 14px;
- border-radius: 50%;
- background: #d9d9d9;
- color: #666;
- font-size: 12px;
- font-weight: 600;
- cursor: help;
- line-height: 1;
- }
- .label-tip-icon:hover {
- background: #1677ff;
- color: #fff;
- }
- .form-item-condition :deep(.ant-form-item-label > label) {
- display: inline-flex;
- align-items: center;
- }
- .button-section {
- padding: 16px 0 12px;
- display: flex;
- justify-content: center;
- }
- .button-section .ant-btn {
- width: 200px;
- }
- .table-section .section-body {
- flex: 1;
- min-height: 0;
- overflow: auto;
- }
- .table-wrap {
- min-height: 200px;
- }
- /* 选品数据表格:饰品图片列 */
- .selection-goods-icon {
- width: 48px;
- height: 48px;
- object-fit: contain;
- display: block;
- margin: 0 auto;
- }
- /* 选品日志弹窗 */
- .log-modal-title {
- display: flex;
- align-items: center;
- justify-content: flex-start;
- gap: 8px;
- }
- .selection-log-body {
- min-height: 120px;
- max-height: 60vh;
- overflow-y: auto;
- }
- .selection-log-empty {
- color: #999;
- text-align: center;
- padding: 24px;
- }
- .selection-log-lines {
- font-family: ui-monospace, monospace;
- font-size: 13px;
- line-height: 1.6;
- color: #000000;
- }
- .selection-log-lines .log-line {
- padding: 2px 0;
- border-bottom: 1px solid #f0f0f0;
- word-break: break-all;
- }
- .selection-log-lines .log-line-start,
- .selection-log-lines .log-line-end {
- font-weight: 600;
- color: #1677ff;
- }
- .selection-log-lines .log-line-selected-strong {
- color: #1f7a1f;
- font-weight: 700;
- }
- .selection-log-lines .log-line-selected {
- color: #52c41a;
- }
- .selection-log-lines .log-line-sweep-success {
- color: #ff4d4f;
- font-weight: 600;
- }
- .selection-log-lines .log-line-stage {
- color: #1677ff;
- }
- .selection-log-lines .log-line-not-selected {
- color: #000000;
- }
- /* API 详情日志样式 */
- .selection-log-lines .api-log-header {
- cursor: pointer;
- display: flex;
- align-items: flex-start;
- gap: 4px;
- }
- .selection-log-lines .api-log-toggle {
- color: #1677ff;
- font-size: 10px;
- margin-top: 2px;
- flex-shrink: 0;
- }
- .selection-log-lines .api-log-summary {
- flex: 1;
- word-break: break-all;
- }
- .selection-log-lines .api-log-detail {
- margin-left: 18px;
- margin-top: 4px;
- padding: 8px;
- background: #f5f5f5;
- border-radius: 4px;
- font-size: 12px;
- }
- .selection-log-lines .api-detail-section {
- margin-bottom: 8px;
- }
- .selection-log-lines .api-detail-section:last-child {
- margin-bottom: 0;
- }
- .selection-log-lines .api-detail-title {
- font-weight: 600;
- color: #333;
- margin-bottom: 4px;
- border-bottom: 1px solid #ddd;
- padding-bottom: 2px;
- }
- .selection-log-lines .api-detail-row {
- margin: 2px 0;
- display: flex;
- flex-wrap: wrap;
- gap: 4px;
- }
- .selection-log-lines .api-detail-label {
- font-weight: 500;
- color: #666;
- min-width: 70px;
- }
- .selection-log-lines .api-detail-code {
- font-family: ui-monospace, monospace;
- font-size: 11px;
- background: #e8e8e8;
- padding: 1px 4px;
- border-radius: 2px;
- word-break: break-all;
- max-width: 100%;
- }
- .selection-log-lines .api-detail-pre {
- font-family: ui-monospace, monospace;
- font-size: 11px;
- background: #fff;
- padding: 4px;
- border-radius: 2px;
- margin: 2px 0 0 0;
- white-space: pre-wrap;
- word-break: break-all;
- max-width: 100%;
- overflow-x: auto;
- border: 1px solid #e8e8e8;
- }
- .selection-log-lines .api-detail-response {
- max-height: 200px;
- overflow-y: auto;
- }
- .selection-log-lines .api-detail-error {
- color: #ff4d4f;
- font-family: ui-monospace, monospace;
- font-size: 11px;
- word-break: break-all;
- }
- </style>
- <!-- 排除/指定饰品 id 弹窗:wrap 在 teleport 中,需非 scoped 才能生效 -->
- <style>
- .ids-list-modal-wrap.ant-modal-wrap .ant-modal { max-height: 90vh; }
- .ids-list-modal-wrap.ant-modal-wrap .ant-modal-content { max-height: 90vh; display: flex; flex-direction: column; }
- .ids-list-modal-wrap.ant-modal-wrap .ant-modal-body { max-height: 70vh; overflow: hidden; display: flex; flex-direction: column; padding-bottom: 16px; }
- </style>
|