tiktok-app.js 430 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517551855195520552155225523552455255526552755285529553055315532553355345535553655375538553955405541554255435544554555465547554855495550555155525553555455555556555755585559556055615562556355645565556655675568556955705571557255735574557555765577557855795580558155825583558455855586558755885589559055915592559355945595559655975598559956005601560256035604560556065607560856095610561156125613561456155616561756185619562056215622562356245625562656275628562956305631563256335634563556365637563856395640564156425643564456455646564756485649565056515652565356545655565656575658565956605661566256635664566556665667566856695670567156725673567456755676567756785679568056815682568356845685568656875688568956905691569256935694569556965697569856995700570157025703570457055706570757085709571057115712571357145715571657175718571957205721572257235724572557265727572857295730573157325733573457355736573757385739574057415742574357445745574657475748574957505751575257535754575557565757575857595760576157625763576457655766576757685769577057715772577357745775577657775778577957805781578257835784578557865787578857895790579157925793579457955796579757985799580058015802580358045805580658075808580958105811581258135814581558165817581858195820582158225823582458255826582758285829583058315832583358345835583658375838583958405841584258435844584558465847584858495850585158525853585458555856585758585859586058615862586358645865586658675868586958705871587258735874587558765877587858795880588158825883588458855886588758885889589058915892589358945895589658975898589959005901590259035904590559065907590859095910591159125913591459155916591759185919592059215922592359245925592659275928592959305931593259335934593559365937593859395940594159425943594459455946594759485949595059515952595359545955595659575958595959605961596259635964596559665967596859695970597159725973597459755976597759785979598059815982598359845985598659875988598959905991599259935994599559965997599859996000600160026003600460056006600760086009601060116012601360146015601660176018601960206021602260236024602560266027602860296030603160326033603460356036603760386039604060416042604360446045604660476048604960506051605260536054605560566057605860596060606160626063606460656066606760686069607060716072607360746075607660776078607960806081608260836084608560866087608860896090609160926093609460956096609760986099610061016102610361046105610661076108610961106111611261136114611561166117611861196120612161226123612461256126612761286129613061316132613361346135613661376138613961406141614261436144614561466147614861496150615161526153615461556156615761586159616061616162616361646165616661676168616961706171617261736174617561766177617861796180618161826183618461856186618761886189619061916192619361946195619661976198619962006201620262036204620562066207620862096210621162126213621462156216621762186219622062216222622362246225622662276228622962306231623262336234623562366237623862396240624162426243624462456246624762486249625062516252625362546255625662576258625962606261626262636264626562666267626862696270627162726273627462756276627762786279628062816282628362846285628662876288628962906291629262936294629562966297629862996300630163026303630463056306630763086309631063116312631363146315631663176318631963206321632263236324632563266327632863296330633163326333633463356336633763386339634063416342634363446345634663476348634963506351635263536354635563566357635863596360636163626363636463656366636763686369637063716372637363746375637663776378637963806381638263836384638563866387638863896390639163926393639463956396639763986399640064016402640364046405640664076408640964106411641264136414641564166417641864196420642164226423642464256426642764286429643064316432643364346435643664376438643964406441644264436444644564466447644864496450645164526453645464556456645764586459646064616462646364646465646664676468646964706471647264736474647564766477647864796480648164826483648464856486648764886489649064916492649364946495649664976498649965006501650265036504650565066507650865096510651165126513651465156516651765186519652065216522652365246525652665276528652965306531653265336534653565366537653865396540654165426543654465456546654765486549655065516552655365546555655665576558655965606561656265636564656565666567656865696570657165726573657465756576657765786579658065816582658365846585658665876588658965906591659265936594659565966597659865996600660166026603660466056606660766086609661066116612661366146615661666176618661966206621662266236624662566266627662866296630663166326633663466356636663766386639664066416642664366446645664666476648664966506651665266536654665566566657665866596660666166626663666466656666666766686669667066716672667366746675667666776678667966806681668266836684668566866687668866896690669166926693669466956696669766986699670067016702670367046705670667076708670967106711671267136714671567166717671867196720672167226723672467256726672767286729673067316732673367346735673667376738673967406741674267436744674567466747674867496750675167526753675467556756675767586759676067616762676367646765676667676768676967706771677267736774677567766777677867796780678167826783678467856786678767886789679067916792679367946795679667976798679968006801680268036804680568066807680868096810681168126813681468156816681768186819682068216822682368246825682668276828682968306831683268336834683568366837683868396840684168426843684468456846684768486849685068516852685368546855685668576858685968606861686268636864686568666867686868696870687168726873687468756876687768786879688068816882688368846885688668876888688968906891689268936894689568966897689868996900690169026903690469056906690769086909691069116912691369146915691669176918691969206921692269236924692569266927692869296930693169326933693469356936693769386939694069416942694369446945694669476948694969506951695269536954695569566957695869596960696169626963696469656966696769686969697069716972697369746975697669776978697969806981698269836984698569866987698869896990699169926993699469956996699769986999700070017002700370047005700670077008700970107011701270137014701570167017701870197020702170227023702470257026702770287029703070317032703370347035703670377038703970407041704270437044704570467047704870497050705170527053705470557056705770587059706070617062706370647065706670677068706970707071707270737074707570767077707870797080708170827083708470857086708770887089709070917092709370947095709670977098709971007101710271037104710571067107710871097110711171127113711471157116711771187119712071217122712371247125712671277128712971307131713271337134713571367137713871397140714171427143714471457146714771487149715071517152715371547155715671577158715971607161716271637164716571667167716871697170717171727173717471757176717771787179718071817182718371847185718671877188718971907191719271937194719571967197719871997200720172027203720472057206720772087209721072117212721372147215721672177218721972207221722272237224722572267227722872297230723172327233723472357236723772387239724072417242724372447245724672477248724972507251725272537254725572567257725872597260726172627263726472657266726772687269727072717272727372747275727672777278727972807281728272837284728572867287728872897290729172927293729472957296729772987299730073017302730373047305730673077308730973107311731273137314731573167317731873197320732173227323732473257326732773287329733073317332733373347335733673377338733973407341734273437344734573467347734873497350735173527353735473557356735773587359736073617362736373647365736673677368736973707371737273737374737573767377737873797380738173827383738473857386738773887389739073917392739373947395739673977398739974007401740274037404740574067407740874097410741174127413741474157416741774187419742074217422742374247425742674277428742974307431743274337434743574367437743874397440744174427443744474457446744774487449745074517452745374547455745674577458745974607461746274637464746574667467746874697470747174727473747474757476747774787479748074817482748374847485748674877488748974907491749274937494749574967497749874997500750175027503750475057506750775087509751075117512751375147515751675177518751975207521752275237524752575267527752875297530753175327533753475357536753775387539754075417542754375447545754675477548754975507551755275537554755575567557755875597560756175627563756475657566756775687569757075717572757375747575757675777578757975807581758275837584758575867587758875897590759175927593759475957596759775987599760076017602760376047605760676077608760976107611761276137614761576167617761876197620762176227623762476257626762776287629763076317632763376347635763676377638763976407641764276437644764576467647764876497650765176527653765476557656765776587659766076617662766376647665766676677668766976707671767276737674767576767677767876797680768176827683768476857686768776887689769076917692769376947695769676977698769977007701770277037704770577067707770877097710771177127713771477157716771777187719772077217722772377247725772677277728772977307731773277337734773577367737773877397740774177427743774477457746774777487749775077517752775377547755775677577758775977607761776277637764776577667767776877697770777177727773777477757776777777787779778077817782778377847785778677877788778977907791779277937794779577967797779877997800780178027803780478057806780778087809781078117812781378147815781678177818781978207821782278237824782578267827782878297830783178327833783478357836783778387839784078417842784378447845784678477848784978507851785278537854785578567857785878597860786178627863786478657866786778687869787078717872787378747875787678777878787978807881788278837884788578867887788878897890789178927893789478957896789778987899790079017902790379047905790679077908790979107911791279137914791579167917791879197920792179227923792479257926792779287929793079317932793379347935793679377938793979407941794279437944794579467947794879497950795179527953795479557956795779587959796079617962796379647965796679677968796979707971797279737974797579767977797879797980798179827983798479857986798779887989799079917992799379947995799679977998799980008001800280038004800580068007800880098010801180128013801480158016801780188019802080218022802380248025802680278028802980308031803280338034803580368037803880398040804180428043804480458046804780488049805080518052805380548055805680578058805980608061806280638064806580668067806880698070807180728073807480758076807780788079808080818082808380848085808680878088808980908091809280938094809580968097809880998100810181028103810481058106810781088109811081118112811381148115811681178118811981208121812281238124812581268127812881298130813181328133813481358136813781388139814081418142814381448145814681478148814981508151815281538154815581568157815881598160816181628163816481658166816781688169817081718172817381748175817681778178817981808181818281838184818581868187818881898190819181928193819481958196819781988199820082018202820382048205820682078208820982108211821282138214821582168217821882198220822182228223822482258226822782288229823082318232823382348235823682378238823982408241824282438244824582468247824882498250825182528253825482558256825782588259826082618262826382648265826682678268826982708271827282738274827582768277827882798280828182828283828482858286828782888289829082918292829382948295829682978298829983008301830283038304830583068307830883098310831183128313831483158316831783188319832083218322832383248325832683278328832983308331833283338334833583368337833883398340834183428343834483458346834783488349835083518352835383548355835683578358835983608361836283638364836583668367836883698370837183728373837483758376837783788379838083818382838383848385838683878388838983908391839283938394839583968397839883998400840184028403840484058406840784088409841084118412841384148415841684178418841984208421842284238424842584268427842884298430843184328433843484358436843784388439844084418442844384448445844684478448844984508451845284538454845584568457845884598460846184628463846484658466846784688469847084718472847384748475847684778478847984808481848284838484848584868487848884898490849184928493849484958496849784988499850085018502850385048505850685078508850985108511851285138514851585168517851885198520852185228523852485258526852785288529853085318532853385348535853685378538853985408541854285438544854585468547854885498550855185528553855485558556855785588559856085618562856385648565856685678568856985708571857285738574857585768577857885798580858185828583858485858586858785888589859085918592859385948595859685978598859986008601860286038604860586068607860886098610861186128613861486158616861786188619862086218622862386248625862686278628862986308631863286338634863586368637863886398640864186428643864486458646864786488649865086518652865386548655865686578658865986608661866286638664866586668667866886698670867186728673867486758676867786788679868086818682868386848685868686878688868986908691869286938694869586968697869886998700870187028703870487058706870787088709871087118712871387148715871687178718871987208721872287238724872587268727872887298730873187328733873487358736873787388739874087418742874387448745874687478748874987508751875287538754875587568757875887598760876187628763876487658766876787688769877087718772877387748775877687778778877987808781878287838784878587868787878887898790879187928793879487958796879787988799880088018802880388048805880688078808880988108811881288138814881588168817881888198820882188228823882488258826882788288829883088318832883388348835883688378838883988408841884288438844884588468847884888498850885188528853885488558856885788588859886088618862886388648865886688678868886988708871887288738874887588768877887888798880888188828883888488858886888788888889889088918892889388948895889688978898889989008901890289038904890589068907890889098910891189128913891489158916891789188919892089218922892389248925892689278928892989308931893289338934893589368937893889398940894189428943894489458946894789488949895089518952895389548955895689578958895989608961896289638964896589668967896889698970897189728973897489758976897789788979898089818982898389848985898689878988898989908991899289938994899589968997899889999000900190029003900490059006900790089009901090119012901390149015901690179018901990209021902290239024902590269027902890299030903190329033903490359036903790389039904090419042904390449045904690479048904990509051905290539054905590569057905890599060906190629063906490659066906790689069907090719072907390749075907690779078907990809081908290839084908590869087908890899090909190929093909490959096909790989099910091019102910391049105910691079108910991109111911291139114911591169117911891199120912191229123912491259126912791289129913091319132913391349135913691379138913991409141914291439144914591469147914891499150915191529153915491559156915791589159916091619162916391649165916691679168916991709171917291739174917591769177917891799180918191829183918491859186918791889189919091919192919391949195919691979198919992009201920292039204920592069207920892099210921192129213921492159216921792189219922092219222922392249225922692279228922992309231923292339234923592369237923892399240924192429243924492459246924792489249925092519252925392549255925692579258925992609261926292639264926592669267926892699270927192729273927492759276927792789279928092819282928392849285928692879288928992909291929292939294929592969297929892999300930193029303930493059306930793089309931093119312931393149315931693179318931993209321932293239324932593269327932893299330933193329333933493359336933793389339934093419342934393449345934693479348934993509351935293539354935593569357935893599360936193629363936493659366936793689369937093719372937393749375937693779378937993809381938293839384938593869387938893899390939193929393939493959396939793989399940094019402940394049405940694079408940994109411941294139414941594169417941894199420942194229423942494259426942794289429943094319432943394349435943694379438943994409441944294439444944594469447944894499450945194529453945494559456945794589459946094619462946394649465946694679468946994709471947294739474947594769477947894799480948194829483948494859486948794889489949094919492949394949495949694979498949995009501950295039504950595069507950895099510951195129513951495159516951795189519952095219522952395249525952695279528952995309531953295339534953595369537953895399540954195429543954495459546954795489549955095519552955395549555955695579558955995609561956295639564956595669567956895699570957195729573957495759576957795789579958095819582958395849585958695879588958995909591959295939594959595969597959895999600960196029603960496059606960796089609961096119612961396149615961696179618961996209621962296239624962596269627962896299630963196329633963496359636963796389639964096419642964396449645964696479648964996509651965296539654965596569657965896599660966196629663966496659666966796689669967096719672967396749675967696779678967996809681968296839684968596869687968896899690969196929693969496959696969796989699970097019702970397049705970697079708970997109711971297139714971597169717971897199720972197229723972497259726972797289729973097319732973397349735973697379738973997409741974297439744974597469747974897499750975197529753975497559756975797589759976097619762976397649765976697679768976997709771977297739774977597769777977897799780978197829783978497859786978797889789979097919792979397949795979697979798979998009801980298039804980598069807980898099810981198129813981498159816981798189819982098219822982398249825982698279828982998309831983298339834983598369837983898399840984198429843984498459846984798489849985098519852985398549855985698579858985998609861986298639864986598669867986898699870987198729873987498759876987798789879988098819882988398849885988698879888988998909891989298939894989598969897989898999900990199029903990499059906990799089909991099119912991399149915991699179918991999209921992299239924992599269927992899299930993199329933993499359936993799389939994099419942994399449945994699479948994999509951995299539954995599569957995899599960996199629963996499659966996799689969997099719972997399749975997699779978997999809981998299839984998599869987998899899990999199929993999499959996999799989999100001000110002100031000410005100061000710008100091001010011100121001310014100151001610017100181001910020100211002210023100241002510026100271002810029100301003110032100331003410035100361003710038100391004010041100421004310044100451004610047100481004910050100511005210053100541005510056100571005810059100601006110062100631006410065100661006710068100691007010071100721007310074100751007610077100781007910080100811008210083100841008510086100871008810089100901009110092100931009410095100961009710098100991010010101101021010310104101051010610107101081010910110101111011210113101141011510116101171011810119101201012110122101231012410125101261012710128101291013010131101321013310134101351013610137101381013910140101411014210143101441014510146101471014810149101501015110152101531015410155101561015710158101591016010161101621016310164101651016610167101681016910170101711017210173101741017510176101771017810179101801018110182101831018410185101861018710188101891019010191101921019310194101951019610197101981019910200102011020210203102041020510206102071020810209102101021110212102131021410215102161021710218102191022010221102221022310224102251022610227102281022910230102311023210233102341023510236102371023810239102401024110242102431024410245102461024710248102491025010251102521025310254102551025610257102581025910260102611026210263102641026510266102671026810269102701027110272102731027410275102761027710278102791028010281102821028310284102851028610287102881028910290102911029210293102941029510296102971029810299103001030110302103031030410305103061030710308103091031010311103121031310314103151031610317103181031910320103211032210323103241032510326103271032810329103301033110332103331033410335103361033710338103391034010341103421034310344103451034610347103481034910350103511035210353103541035510356103571035810359103601036110362103631036410365103661036710368103691037010371103721037310374103751037610377103781037910380103811038210383103841038510386103871038810389103901039110392103931039410395103961039710398103991040010401104021040310404104051040610407104081040910410104111041210413104141041510416104171041810419104201042110422104231042410425104261042710428104291043010431104321043310434104351043610437104381043910440104411044210443104441044510446104471044810449104501045110452104531045410455104561045710458104591046010461104621046310464104651046610467104681046910470104711047210473104741047510476104771047810479104801048110482104831048410485104861048710488104891049010491104921049310494104951049610497104981049910500105011050210503105041050510506105071050810509105101051110512105131051410515105161051710518105191052010521105221052310524105251052610527105281052910530105311053210533105341053510536105371053810539105401054110542105431054410545105461054710548105491055010551105521055310554105551055610557105581055910560105611056210563105641056510566105671056810569105701057110572105731057410575105761057710578105791058010581105821058310584105851058610587105881058910590105911059210593105941059510596105971059810599106001060110602106031060410605106061060710608106091061010611106121061310614
  1. "ui";
  2. /*! tiktok-app v2026.10.13
  3. * built 2026-10-29T10:43:51.193Z
  4. * modules: theme, store, records, sources, device, service, fingerprint, engine, session, resume, widgets, dialog, floatball, page-id, page-dm, page-video, page-tasks, page-records, page-login, page-mine, main
  5. * 由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。
  6. */
  7. (function () {
  8. /* ============================== src/theme.js ============================== */
  9. /* ============================== src/theme.js ==============================
  10. * 设计令牌 + 绘制工具。
  11. *
  12. * 所有颜色/尺寸/字号都取自 Ardot 设计稿(file 734917825641912),
  13. * 页面代码里**不允许出现裸色值或裸尺寸**——要改视觉先改这里。
  14. *
  15. * 为什么用「代码建 View」而不是 XML 布局:
  16. * ① 设计稿用了渐变、径向光晕、多层描边、按状态换色,XML 属性表达不了,
  17. * 最后仍要落到代码里 setBackground(GradientDrawable),反而两套并存;
  18. * ② XML 字面量(E4X)不是标准 JS,PC 侧无法用 node 做语法检查与离线自检,
  19. * 而 AutoX 设备端是没有 linter 的(见规范 SKILL.md「设备端没有门禁」)。
  20. * 纯代码建 View 让整个产物能在 PC 上被语法检查和桩测。
  21. */
  22. var Theme = {};
  23. /* ---------------------------------------------------------------- 颜色 */
  24. Theme.hex = {
  25. bg: "#030014",
  26. tabBar: "#0A0A17",
  27. dialogBg: "#17142B",
  28. accent: "#2EA6FF",
  29. accentText: "#7CC7FF",
  30. accentDeep: "#9381FF",
  31. /* 任务卡图标衬底的紫。设计稿里 UID私信 的图标框是 #9482FF@16%,
  32. * 与主色渐变里的紫(#9381FF)**不是同一个值** —— 不要"统一"成一个。 */
  33. violet: "#9482FF",
  34. textPrimary: "#FFFFFF",
  35. textSecondary: "#CFCCE6",
  36. /* 弹窗里的设置名用的是更亮一档的正文色(设计稿 §6:正文 #CFCCE6 / #E6E4F5) */
  37. textBody: "#E6E4F5",
  38. textMuted: "#9A97B8",
  39. textFaint: "#8E8BA8",
  40. /* 弱提示(区说明 / 「上次运行」/ 底部提示)。比 textFaint 再暗一档,
  41. * 三级文字层次来自设计稿 §6:次要 #9A97B8·#8E8BA8 / 弱提示 #6F6C8A。 */
  42. textDim: "#6F6C8A",
  43. /* 未选中页签的颜色。**不要删**:它一旦缺失,argb(undefined) 会算出 0
  44. * (全透明),非活动页签会整片消失 —— 而且 uiautomator 里它们的
  45. * bounds 是正常的,只有肉眼看得出来,极难定位。
  46. * 取值 2026-10-13 更新为 #8E8BA8:旧的 #7A7794 对比度只有 4.4:1,
  47. * 未达 WCAG AA(规格书 §9 第 5 条已统一改为新值)。 */
  48. tabIdle: "#8E8BA8",
  49. /* 警示色:设计稿里「未开启」状态标签与「确认解绑」按钮用的是两种不同的暖色。
  50. * warn 偏琥珀(权限告缺、卡密到期),danger 偏红(不可逆操作)。 */
  51. warn: "#F5C24B",
  52. danger: "#FF5470",
  53. ok: "#13DDC4",
  54. divider: "#FFFFFF"
  55. };
  56. /* 卡片与标签的填充/描边一律是「白色 + 透明度」,不是独立色值。
  57. * 这样叠在深色背景与叠在渐变光晕上的观感一致。 */
  58. Theme.alpha = {
  59. cardFill: 0.04,
  60. cardFillActive: 0.06,
  61. cardStroke: 0.08,
  62. cardStrokeActive: 0.35,
  63. chipFill: 0.06,
  64. chipStroke: 0.10,
  65. accentChipFill: 0.20,
  66. /* 全局运行条:青蓝 12% 填充(设计稿 B2 的 30:304,无描边) */
  67. runBarFill: 0.12,
  68. runBarStroke: 0.28,
  69. /* 警示图标底 / 任务图标框:设计稿里是 14% 的同色衬底 */
  70. warnFill: 0.14,
  71. iconBoxFill: 0.14,
  72. mask: 0.72,
  73. glowA: 0.30,
  74. glowB: 0.20
  75. };
  76. /* ---------------------------------------------------------------- 尺寸 */
  77. Theme.m = {
  78. screenW: 375,
  79. contentPadding: 20,
  80. cardPadding: 14,
  81. cardRadius: 16,
  82. chipRadius: 8,
  83. pillRadius: 99,
  84. dialogRadius: 24,
  85. gapPage: 16,
  86. gapCard: 12,
  87. gapTight: 8,
  88. gapInline: 10,
  89. titleSize: 22,
  90. sectionSize: 16,
  91. labelSize: 13,
  92. chipSize: 11,
  93. hintSize: 11,
  94. tabSize: 11,
  95. rowHeight: 26,
  96. chipPadV: 6,
  97. chipPadH: 6,
  98. tabBarHeight: 49,
  99. tabIconSize: 22,
  100. runBtnHeight: 48,
  101. runBtnWidth: 335,
  102. runBarRadius: 12,
  103. runBarPadV: 12,
  104. runBarPadH: 10,
  105. warnIconSize: 40,
  106. warnIconRadius: 12,
  107. loginLogoSize: 72,
  108. loginLogoRadius: 20,
  109. loginBtnHeight: 48,
  110. dialogWidth: 315,
  111. statusBarHeight: 62,
  112. safeAreaHeight: 33,
  113. touchTarget: 44,
  114. /* ===== 2026-10-13 规格书新增(§6 尺寸与圆角 / 触控标准)===== */
  115. /* 二级页返回头:高 44,返回热区 44×44,标题 21 SemiBold */
  116. backHeaderHeight: 44,
  117. backHotSize: 44,
  118. backArrowSize: 20,
  119. detailTitleSize: 21,
  120. /* 任务卡(B1/B2):图标框 40 圆角 12 / 图标 22 / 卡名 15 /
  121. * 运行按钮 68×44(触控标准明确写了 68×44) */
  122. taskIconBox: 40,
  123. taskIconSize: 22,
  124. taskIconRadius: 12,
  125. taskNameSize: 15,
  126. taskRunBtnW: 68,
  127. taskRunBtnH: 44,
  128. /* 记录条目(D1/D3):图标框 36 圆角 10 / 图标 20 / 卡片圆角 14 */
  129. recordIconBox: 36,
  130. recordIconSize: 20,
  131. recordIconRadius: 10,
  132. recordCardRadius: 14,
  133. /* 状态徽标(空闲/运行中/未配置)与结果标签:全圆角小胶囊 */
  134. badgePadH: 8,
  135. badgePadV: 3,
  136. /* 底栏整体高度 = 页签行 49 + 安全区 33(设计稿 §6) */
  137. tabBarTotal: 83,
  138. /* 区间输入框:**单框宽 56、数字居中**(§6)。比早期实现的 64 窄,
  139. * 是为了让「标签 + 56 ~ 56」一行放得下而不挤。 */
  140. rangeFieldW: 56,
  141. /* 内容区底部渐隐:y=577 h=80(§6),右侧滚动指示条 x=367 w=3 */
  142. fadeHeight: 80,
  143. indicatorW: 3,
  144. /* 弹窗按钮:行动按钮 44 / 主按钮 46(A4 的去授权是 46) */
  145. dialogBtnHeight: 44,
  146. dialogPrimaryHeight: 46,
  147. /* 日志行(D3):等宽 11 / 行距紧凑 */
  148. logSize: 11
  149. };
  150. /* ---------------------------------------------------------------- 工具 */
  151. /** dp → px。设计稿是 375 逻辑宽,必须换算后再交给原生控件。 */
  152. function dp(value) {
  153. var density = 1;
  154. try {
  155. density = context.getResources().getDisplayMetrics().density;
  156. } catch (e) {
  157. /* 离线桩测环境没有 Android context,退化为 1:1,不影响逻辑正确性 */
  158. density = 1;
  159. }
  160. return Math.round(value * density);
  161. }
  162. /**
  163. * "#2EA6FF" → Java 可用的 ARGB int(负数形式,与 colors.parseColor 同构)。
  164. *
  165. * 色值写错时返回**醒目的洋红**而不是 0。0 是全透明:
  166. * 界面上的表现是「某块东西不见了」,不报错、不留痕 ——
  167. * tabIdle 掉过一次,整排非活动页签就是这么消失的,
  168. * 而 uiautomator 里它们的 bounds 一切正常,只有肉眼能发现。
  169. * 洋红至少让错误在一眼之内就暴露出来。
  170. */
  171. function argb(hex) {
  172. var h = String(hex).replace("#", "");
  173. if (h.length === 6) { h = "FF" + h; }
  174. var value = parseInt(h, 16);
  175. if (isNaN(value)) {
  176. console.log("[theme] 非法色值(已用洋红代替): " + hex);
  177. return -65281; /* 0xFFFF00FF */
  178. }
  179. return value | 0;
  180. }
  181. /** 给 hex 色叠加不透明度,得到 ARGB int。#FFFFFF + 0.06 → 约 #0FFFFFFF */
  182. function alphaOf(hex, alpha) {
  183. var h = String(hex).replace("#", "");
  184. if (h.length === 8) { h = h.substring(2); }
  185. var a = Math.round(Math.max(0, Math.min(1, alpha)) * 255);
  186. return parseInt(("0" + a.toString(16)).slice(-2) + h, 16) | 0;
  187. }
  188. /** 纯色圆角背景。 */
  189. function solidBg(colorInt, radiusDp) {
  190. var drawable = new android.graphics.drawable.GradientDrawable();
  191. drawable.setShape(android.graphics.drawable.GradientDrawable.RECTANGLE);
  192. drawable.setColor(colorInt);
  193. if (radiusDp) { drawable.setCornerRadius(dp(radiusDp)); }
  194. return drawable;
  195. }
  196. /** 圆角 + 描边(卡片默认形态)。 */
  197. function strokeBg(fillAlpha, strokeAlpha, strokeHex, radiusDp) {
  198. var drawable = new android.graphics.drawable.GradientDrawable();
  199. drawable.setShape(android.graphics.drawable.GradientDrawable.RECTANGLE);
  200. drawable.setColor(alphaOf("#FFFFFF", fillAlpha));
  201. drawable.setCornerRadius(dp(radiusDp));
  202. drawable.setStroke(dp(1), alphaOf(strokeHex || "#FFFFFF", strokeAlpha));
  203. return drawable;
  204. }
  205. /** 线性渐变(主按钮、选中态)。默认左→右,与设计稿一致。 */
  206. function gradientBg(fromHex, toHex, radiusDp) {
  207. var drawable = new android.graphics.drawable.GradientDrawable();
  208. drawable.setShape(android.graphics.drawable.GradientDrawable.RECTANGLE);
  209. drawable.setOrientation(android.graphics.drawable.GradientDrawable.Orientation.LEFT_RIGHT);
  210. drawable.setColors([argb(fromHex), argb(toHex)]);
  211. if (radiusDp) { drawable.setCornerRadius(dp(radiusDp)); }
  212. return drawable;
  213. }
  214. /** 径向光晕:中心有色、边缘透明,用来复刻设计稿背景的两团光。 */
  215. function glowBg(hex, alpha) {
  216. var drawable = new android.graphics.drawable.GradientDrawable();
  217. var colors = [alphaOf(hex, alpha), alphaOf(hex, 0)];
  218. drawable.setShape(android.graphics.drawable.GradientDrawable.OVAL);
  219. drawable.setGradientType(android.graphics.drawable.GradientDrawable.RADIAL_GRADIENT);
  220. drawable.setGradientRadius(dp(220));
  221. drawable.setColors(colors);
  222. return drawable;
  223. }
  224. /** 页面根背景:底色 + 两团光晕,所有承载体统一从这里取,避免各自拼。 */
  225. function screenBackground() {
  226. return solidBg(argb(Theme.hex.bg));
  227. }
  228. /**
  229. * 竖向渐隐(透明 → 色),用在内容区底部(设计稿 §6「底部渐隐 y=577 h=80」)。
  230. *
  231. * 为什么要它:内容区是可滚动的,卡片被底栏"硬切"一刀会让人以为列表到头了。
  232. * 加一层渐隐,滚动的连续性一眼可见。
  233. * 颜色取**页面底色**:叠在空内容上时它等于背景,不会露出多余的色块。
  234. */
  235. function fadeBg(hex) {
  236. var drawable = new android.graphics.drawable.GradientDrawable();
  237. drawable.setShape(android.graphics.drawable.GradientDrawable.RECTANGLE);
  238. drawable.setOrientation(android.graphics.drawable.GradientDrawable.Orientation.TOP_BOTTOM);
  239. drawable.setColors([alphaOf(hex, 0), argb(hex)]);
  240. return drawable;
  241. }
  242. /** 同色衬底(任务图标框 / 警示图标底):hex + 透明度,圆角方块。 */
  243. function tintBg(hex, alpha, radiusDp) {
  244. var drawable = new android.graphics.drawable.GradientDrawable();
  245. drawable.setShape(android.graphics.drawable.GradientDrawable.RECTANGLE);
  246. drawable.setColor(alphaOf(hex, alpha));
  247. if (radiusDp) { drawable.setCornerRadius(dp(radiusDp)); }
  248. return drawable;
  249. }
  250. /* ------------------------------------------------------------- 基础控件 */
  251. /** 统一的 TextView 工厂,防止每个页面各写一遍字号字色。 */
  252. function text(content, sizeSp, hex, bold) {
  253. var view = new android.widget.TextView(context);
  254. view.setText(String(content === null || content === undefined ? "" : content));
  255. view.setTextSize(sizeSp);
  256. view.setTextColor(argb(hex));
  257. if (bold) {
  258. view.setTypeface(android.graphics.Typeface.DEFAULT_BOLD);
  259. }
  260. return view;
  261. }
  262. /**
  263. * 等宽数字(设计稿里 200 / 30、2026.10.10 用的是 JetBrains Mono)。
  264. * 与 text() 一样带 undefined 兜底——String(undefined) 会变成界面上的 "undefined"。
  265. */
  266. function monoText(content, sizeSp, hex) {
  267. var view = new android.widget.TextView(context);
  268. view.setText(content === null || content === undefined ? "" : String(content));
  269. view.setTextSize(sizeSp);
  270. view.setTextColor(argb(hex));
  271. view.setTypeface(android.graphics.Typeface.MONOSPACE);
  272. return view;
  273. }
  274. function box(widthDp, heightDp) {
  275. var view = new android.view.View(context);
  276. view.setLayoutParams(new android.widget.LinearLayout.LayoutParams(
  277. widthDp >= 0 ? dp(widthDp) : widthDp,
  278. heightDp >= 0 ? dp(heightDp) : heightDp
  279. ));
  280. return view;
  281. }
  282. /**
  283. * 垂直间距。⚠️ 只能用在**竖向** LinearLayout 里。
  284. *
  285. * 它的宽度是 MATCH_PARENT,放进横向容器会占满整行剩余空间,
  286. * 把同行后面那些 weight=1 的 View 挤成 0 宽(真机上表现为「文字全部消失」)。
  287. * 横向间距请用 hgap()。
  288. */
  289. function spacer(heightDp) {
  290. return box(-1, heightDp);
  291. }
  292. /**
  293. * 横向间距。固定宽度、高度 1px。
  294. * 与 spacer 的区别只在宽度策略,但用错了就是「文字不见了」这种难查的现象,
  295. * 所以刻意分成两个名字,让用错的时候在代码上一眼能看出来。
  296. */
  297. function hgap(widthDp) {
  298. var view = new android.view.View(context);
  299. view.setLayoutParams(new android.widget.LinearLayout.LayoutParams(dp(widthDp), 1));
  300. return view;
  301. }
  302. /** 撑开剩余空间,用于「左标题 / 右控件」两栏布局。 */
  303. function flexible() {
  304. var view = new android.view.View(context);
  305. var lp = new android.widget.LinearLayout.LayoutParams(0, 1);
  306. lp.weight = 1;
  307. view.setLayoutParams(lp);
  308. return view;
  309. }
  310. /**
  311. * 横向行。
  312. *
  313. * fill 默认为 true:横向行几乎都是「左标签(weight=1) + 右控件」的结构,
  314. * **必须显式填满宽度**,否则父容器按 WRAP_CONTENT 测量时,
  315. * weight=1 的左侧标签会分到 0 宽 —— 真机上表现为「这一行的文字不见了」。
  316. * 需要包裹内容的场合(例如作为另一行右侧的控件组)显式传 false。
  317. */
  318. function row(fill) {
  319. var layout = new android.widget.LinearLayout(context);
  320. layout.setOrientation(android.widget.LinearLayout.HORIZONTAL);
  321. layout.setGravity(android.view.Gravity.CENTER_VERTICAL);
  322. if (fill === undefined || fill) {
  323. layout.setLayoutParams(new android.widget.LinearLayout.LayoutParams(-1, -2));
  324. }
  325. return layout;
  326. }
  327. /** 竖向容器,同样默认填满宽度(卡片、页面主体都属于这类)。 */
  328. function column(fill) {
  329. var layout = new android.widget.LinearLayout(context);
  330. layout.setOrientation(android.widget.LinearLayout.VERTICAL);
  331. if (fill === undefined || fill) {
  332. layout.setLayoutParams(new android.widget.LinearLayout.LayoutParams(-1, -2));
  333. }
  334. return layout;
  335. }
  336. /** 给 LinearLayout 统一挂 LayoutParams,省掉每处的重复样板。 */
  337. function params(view, widthDp, heightDp, weight) {
  338. var lp = new android.widget.LinearLayout.LayoutParams(
  339. widthDp >= 0 ? dp(widthDp) : widthDp,
  340. heightDp >= 0 ? dp(heightDp) : heightDp
  341. );
  342. if (weight) { lp.weight = weight; }
  343. view.setLayoutParams(lp);
  344. return view;
  345. }
  346. function marginParams(view, widthDp, heightDp, l, t, r, b) {
  347. var lp = new android.widget.LinearLayout.LayoutParams(
  348. widthDp >= 0 ? dp(widthDp) : widthDp,
  349. heightDp >= 0 ? dp(heightDp) : heightDp
  350. );
  351. lp.setMargins(dp(l), dp(t), dp(r), dp(b));
  352. view.setLayoutParams(lp);
  353. return view;
  354. }
  355. Theme.util = {
  356. dp: dp,
  357. argb: argb,
  358. alphaOf: alphaOf,
  359. solidBg: solidBg,
  360. strokeBg: strokeBg,
  361. gradientBg: gradientBg,
  362. glowBg: glowBg,
  363. fadeBg: fadeBg,
  364. tintBg: tintBg,
  365. screenBackground: screenBackground,
  366. text: text,
  367. monoText: monoText,
  368. box: box,
  369. spacer: spacer,
  370. hgap: hgap,
  371. flexible: flexible,
  372. row: row,
  373. column: column,
  374. params: params,
  375. marginParams: marginParams
  376. };
  377. /* 便捷短名:页面代码里 90% 的调用都是这几个,长名前缀会淹掉结构。 */
  378. var U = Theme.util;
  379. /* ============================== src/store.js ============================== */
  380. /* ============================== src/store.js ==============================
  381. * 数据模型 + 持久化 + 校验。
  382. *
  383. * 这一层只做三件事:给默认值、读回来、写出去。**不做界面**,也不碰设备。
  384. * 页面代码永远通过 Store.get()/Store.set() 拿值,不直接碰 storages。
  385. *
  386. * ── 核心设计:默认值只有一个来源 ─────────────────────────────
  387. * 所有字段都在下面的 Store.FIELDS 里声明一次(路径 / 类型 / 范围 / 默认值),
  388. * `defaults()` 与 `validate()` 都由它推导。
  389. *
  390. * 为什么必须这样:早先的版本里默认值写在 defaults()、校验规则写在 validate()、
  391. * 范围写在另一张 LIMITS 表里——三处各写一半,结果就是
  392. * 「LIMITS 遍历时把视频页的 stopCount 也写进了私信页对象,而默认值取的是
  393. * 私信页不存在的 d.tabs.dm.stopCount」→ 真的往配置里塞了个 undefined。
  394. * 一张表就没有这种漂移。
  395. */
  396. var Store = {};
  397. /* v8(2026-10-17):ID提取页从「四张单选脚本卡」改成**三段式**
  398. * (① 取材清单 ② 只要这样的账号 ③ 输出)。
  399. *
  400. * 四张卡的 23 个字段全部删除,清单改由 `Sources` 模块单独持久化
  401. * (不定长列表进不了这张标量表)。旧配置由 `Sources.migrateLegacy()`
  402. * 翻译成清单,用户的选项一个不丢。
  403. *
  404. * v7(同日早些时候)是"清语义"那一档:删同义反复的开关、补缺输入的开关、
  405. * 功能设置分三组。那一档的成果(36 个字段、三组结构、置灰方式)v8 全部继承。
  406. *
  407. * v11(2026-10-29):补执行「私信休息时间 100~500 → 10~40」。
  408. * 9 号迁移用严格相等判断,碰上字符串型的旧值会静默放过、版本却被抬高,
  409. * 从此永久卡在旧默认。这次用 Number() 比较再补一次。
  410. */
  411. Store.SCHEMA_VERSION = 11;
  412. Store.STORAGE_NAME = "feizhou_app";
  413. Store.STORAGE_KEY = "config";
  414. /** 业务文本文件的约定目录。界面上的「选择文件」默认指向这里。 */
  415. Store.TEXT_DIR = "/sdcard/Download/tiktok_app";
  416. /* ------------------------------------------------ 下拉/开关的候选值集中定义 */
  417. Store.options = {
  418. dedupe: ["ID 模式", "UID 模式"],
  419. enterPage: ["粉丝页", "评论页", "主页", "关注页"],
  420. /* 「从哪个页面取」与「展开哪个列表」是**两件事**,v7 之前共用一个 enterPage,
  421. * 于是 s3(文本裂变)里能选到「评论页」—— 而在"展开文本里这些人的列表"这个语义下,
  422. * 评论页根本不是合法值。分开之后 s3 只能选粉丝 / 关注。 */
  423. expandList: ["粉丝列表", "关注列表"],
  424. /* 脚本二「视频从哪来」:原来只有一个"自定评论页面提 ID"开关,勾上却没说
  425. * 从哪条视频开始 —— 补上这个来源维度,那个开关才有意义。 */
  426. videoSource: ["当前屏幕", "关键词搜索"],
  427. aiGender: ["男", "女"],
  428. toggle: ["开启", "关闭"],
  429. dedupeMode: ["关注+私信去重", "仅关注去重", "仅私信去重"],
  430. filterDate: ["7 天内", "30 天内", "不限"],
  431. firstAction: ["都执行", "仅点赞", "仅评论"],
  432. langRegion: ["通杀", "仅中文", "仅英文"],
  433. nicknameCheck: ["通杀", "仅检测昵称", "仅检测个签"],
  434. /* 点赞 / 评论 / 关注概率。设计稿把它们做成固定档位 ——
  435. * 养号动作的强度直接决定账号安全,给几个安全档位比让用户随手填 90% 更负责。 */
  436. likeRate: ["10%", "20%", "30%", "50%"],
  437. commentRate: ["5%", "10%", "20%", "30%"],
  438. followRate: ["5%", "10%", "15%", "30%"]
  439. };
  440. /* 候选值 ↔ 存储值的双向映射。
  441. * 界面显示中文,存储里存稳定英文 key——改文案时不会把老数据作废。 */
  442. Store.enum = {
  443. dedupe: { "id": "ID 模式", "text": "UID 模式" },
  444. /* 「关注页」是脚本三要用的(设计稿 16:533 的下拉就是它)。
  445. * 新增候选一律**追加在末尾**:位置即下拉里的顺序,插在中间会让老用户
  446. * 下次点开下拉时发现选项换了地方。 */
  447. enterPage: { "fans": "粉丝页", "comment": "评论页", "home": "主页", "follow": "关注页" },
  448. /* v7:只含"能列出一串人的页"。s3 用它,与 enterPage 语义分开。 */
  449. expandList: { "fans": "粉丝列表", "follow": "关注列表" },
  450. videoSource: { "screen": "当前屏幕", "keyword": "关键词搜索" },
  451. aiGender: { "male": "男", "female": "女" },
  452. toggle: { "on": "开启", "off": "关闭" },
  453. dedupeMode: { "follow_dm": "关注+私信去重", "follow": "仅关注去重", "dm": "仅私信去重" },
  454. filterDate: { "7d": "7 天内", "30d": "30 天内", "all": "不限" },
  455. firstAction: { "both": "都执行", "like": "仅点赞", "comment": "仅评论" },
  456. langRegion: { "any": "通杀", "zh": "仅中文", "en": "仅英文" },
  457. nicknameCheck: { "any": "通杀", "nick": "仅检测昵称", "sign": "仅检测个签" },
  458. likeRate: { "r10": "10%", "r20": "20%", "r30": "30%", "r50": "50%" },
  459. commentRate: { "r5": "5%", "r10": "10%", "r20": "20%", "r30": "30%" },
  460. followRate: { "r5": "5%", "r10": "10%", "r15": "15%", "r30": "30%" },
  461. licenseStatus: { "none": "未登录", "active": "已登录", "expired": "已过期" },
  462. /* ★ 2026-10-13 规格书 §1:底栏只剩 **3 个 tab**(任务 / 记录 / 我的)。
  463. * 原来的 id/dm/nurture/video 四个页签降级为「任务」tab 里的四张任务卡,
  464. * 各自的详情页 (C1/C6/C8/C9) 变成二级页。v6 迁移负责把老存档的
  465. * activeTab(例如 "nurture")映射成 "tasks",否则老用户启动会落回默认页。 */
  466. tab: { "tasks": "任务", "records": "记录", "mine": "我的" }
  467. };
  468. /** 某组枚举的全部 key。 */
  469. function enumKeys(group) {
  470. var map = Store.enum[group] || {};
  471. var out = [];
  472. for (var k in map) {
  473. if (map.hasOwnProperty(k)) { out.push(k); }
  474. }
  475. return out;
  476. }
  477. /**
  478. * 枚举 key → 显示文案。
  479. *
  480. * 关键兜底:key 缺失或非法时**回退到该枚举的第一项**,绝不返回 "undefined"。
  481. * 选择框显示成空白或 "undefined" 是用户最容易看到、也最说不清的一类 bug,
  482. * 在数据层一次堵死比在每个页面各写一遍兜底可靠。
  483. */
  484. Store.label = function (group, key) {
  485. var map = Store.enum[group];
  486. if (!map) {
  487. return key === undefined || key === null ? "" : String(key);
  488. }
  489. if (key === undefined || key === null || map[key] === undefined) {
  490. var first = enumKeys(group)[0];
  491. return first === undefined ? "" : map[first];
  492. }
  493. return map[key];
  494. };
  495. /** 当前值在候选列表里的位置,供下拉框设定初始选中项。 */
  496. Store.indexOfLabel = function (group, key) {
  497. var list = Store.options[group] || [];
  498. return Math.max(0, list.indexOf(Store.label(group, key)));
  499. };
  500. /** 依位置取回 key,供下拉框选中后写回。 */
  501. Store.keyAt = function (group, index) {
  502. var list = Store.options[group] || [];
  503. var label = list[index];
  504. var map = Store.enum[group] || {};
  505. for (var k in map) {
  506. if (map.hasOwnProperty(k) && map[k] === label) { return k; }
  507. }
  508. return label;
  509. };
  510. /* ------------------------------------------------------------ 字段声明表 */
  511. /** 生成「勾选 + 展开」两个字段——所有脚本卡都有这两个开关。 */
  512. function cardFields(basePath, checked, expanded) {
  513. return [
  514. { path: basePath + ".checked", type: "bool", def: !!checked },
  515. { path: basePath + ".expanded", type: "bool", def: !!expanded }
  516. ];
  517. }
  518. var F = [];
  519. /* ——— 全局 ——— */
  520. F.push(
  521. /* values 与 Store.enum.tab 必须一致(曾经少了 "mine":
  522. * 切到「我的」后重启会落回 ID提取,因为写入端的归一化认不出这个值)。
  523. * v6 起是三个 tab:任务 / 记录 / 我的。 */
  524. { path: "activeTab", type: "enum", values: ["tasks", "records", "mine"], def: "tasks" },
  525. /* 二级页(任务详情 / 运行详情)也落盘:用户切走再回来,还停在原来那个详情页。
  526. * 存 key 而不是存 View —— 值域与「任务清单」一一对应,非法值一律回退空串(= 一级页)。 */
  527. { path: "activeDetail", type: "enum", values: ["", "id", "dm", "nurture", "video"], def: "" },
  528. { path: "license.code", type: "text", def: "" },
  529. /* 登录页输入框里的卡密**草稿**:手打 16 位卡密时被系统回收进程,重开还在。
  530. * 激活成功后**不清它**:解绑后回到登录页,上次的卡密已经填好,一键即可重新激活
  531. * (它和 `license.code` 在同一个 JSON 里,多留一份不增加暴露面)。
  532. * 顺带避开一个坑:清它会被登录页输入框的「切屏前整体提交」又写回来。 */
  533. { path: "license.draftCode", type: "text", def: "" },
  534. { path: "license.status", type: "enum", values: ["none", "active", "expired"], def: "none" },
  535. { path: "license.expireAt", type: "text", def: "" }
  536. );
  537. /* ——— 全局悬浮球(规格书 §10 / H1–H7)———
  538. * 三个字段都是「记忆用户的选择」,没有它们球每次都会跑回默认位置:
  539. * · hidden —— 用户点了「隐藏悬浮球」。没有它就只能靠"下次启动又冒出来",
  540. * 而那是最招人烦的一种行为;恢复入口在「我的」页(本项目补充,规格书未写)。
  541. * · side / yRatio —— 拖动吸附后的位置。存**比例**而不是像素:换机型、
  542. * 换分辨率、旋转后仍然落在相对合理的位置(存像素就会出现"球跑到屏幕外")。 */
  543. F.push(
  544. { path: "ball.hidden", type: "bool", def: false },
  545. { path: "ball.side", type: "enum", values: ["left", "right"], def: "left" },
  546. { path: "ball.yRatio", type: "int", min: 0, max: 1000, def: 500 }
  547. );
  548. /* ——— ID提取页 ———
  549. * ★ v8(2026-10-17):四张脚本卡的 **23 个字段全部删除**。
  550. *
  551. * 为什么删而不是继续改:四张卡是「起点 × 深度」的 4 个**不完整组合**
  552. * (各缺一块、各有别人没有的一块,合起来才完整)。这个结构本身就是"乱"的根 ——
  553. * 于是用户想同时抓两种来源时,单选约束直接不允许。
  554. * v8 把它们换成**取材清单**:可累加、每条自带起点与深度、按顺序串行执行。
  555. *
  556. * 清单**不在这张表里**:它是不定长列表,放进来就等于让 validate() 去校验数组,
  557. * 还会把「路径 → 值控件」那套审计规则带歪。它走 `Sources` 模块自己的 storage key
  558. * (照 `Records` 先例,理由见 `src/sources.js` 文件头)。
  559. *
  560. * 旧配置由 `Sources.migrateLegacy()` 翻译成清单(`Store.load` 末尾调用):
  561. * 用户原来的选择一个都不丢 —— s4 的三个开关甚至会展开成**三条**取材。
  562. */
  563. /* ID提取页的「功能设置」(设计稿 16:733)。
  564. * 门槛类字段(关注量/粉丝量/获赞量/视频量)按「一个控件一个数」拆成 min/max 两项,
  565. * 而不是设计稿里的 0 ~ 500000 一个控件 —— 与 UID私信页的上限处理保持同一套规则。 */
  566. F.push(
  567. { path: "tabs.id.features.filterPrivate", type: "bool", def: true },
  568. { path: "tabs.id.features.filterNoAvatar", type: "bool", def: true },
  569. { path: "tabs.id.features.regionCodes", type: "text", def: "JP#TW#US" },
  570. { path: "tabs.id.features.langRegion", type: "enum", group: "langRegion", def: "any" },
  571. { path: "tabs.id.features.nicknameCheck", type: "enum", group: "nicknameCheck", def: "any" },
  572. { path: "tabs.id.features.cloudDedupe", type: "bool", def: false },
  573. /* optional 标记的字段允许默认空串:云端去重默认是关的,
  574. * 「还没填表名」是一个真实且合理的状态,不该被塞一个假默认值。
  575. * 取值审计靠这个标记把它和「漏了默认值」区分开。 */
  576. { path: "tabs.id.features.cloudTable", type: "text", def: "", optional: true },
  577. /* v7:本地去重从脚本卡收到这里,全页只出现一次。
  578. * 「跨轮去重」是另一件事:与历史已提取的 ID 比对,所以单独一个开关 ——
  579. * 原来用「ID 模式 / UID 模式」两个词表达它,用户根本看不出那是在说"要不要跟历史比"。 */
  580. { path: "tabs.id.features.dedupeLocal", type: "bool", def: true },
  581. { path: "tabs.id.features.dedupeHistory", type: "bool", def: false },
  582. { path: "tabs.id.features.aiAvatar", type: "bool", def: false },
  583. { path: "tabs.id.features.aiGender", type: "enum", group: "aiGender", def: "male" },
  584. { path: "tabs.id.features.aiAgeMax", type: "int", min: 0, max: 100, def: 20 },
  585. { path: "tabs.id.features.followMin", type: "int", min: 0, max: 100000000, def: 0 },
  586. { path: "tabs.id.features.followMax", type: "int", min: 0, max: 100000000, def: 500000 },
  587. { path: "tabs.id.features.fansMin", type: "int", min: 0, max: 100000000, def: 0 },
  588. { path: "tabs.id.features.fansMax", type: "int", min: 0, max: 100000000, def: 500000 },
  589. { path: "tabs.id.features.likesMin", type: "int", min: 0, max: 100000000, def: 0 },
  590. { path: "tabs.id.features.likesMax", type: "int", min: 0, max: 100000000, def: 500000 },
  591. { path: "tabs.id.features.videosMin", type: "int", min: 0, max: 100000000, def: 0 },
  592. { path: "tabs.id.features.videosMax", type: "int", min: 0, max: 100000000, def: 500000 },
  593. /* 0 = 不限制。用「0 表示不限」而不是留空,是因为数字输入框留空无法表达。 */
  594. { path: "tabs.id.features.extractLimit", type: "int", min: 0, max: 100000, def: 0 },
  595. /**
  596. * 抓完之后是否顺手把 username 换成 TikTok 内部的数字 uid,另存一份映射文件。
  597. *
  598. * 默认开:数字 uid 在 App 界面上任何地方都读不到,不在这里换就得事后另跑一次。
  599. * 但每个要多下约 400KB 的主页 SSR(约 2.5 秒),只要 handle 的场景可以关掉。
  600. * 2026-10-28 之前这个开关只存在于引擎配置里(界面写死 true),想关得改代码。
  601. */
  602. { path: "tabs.id.features.resolveUid", type: "bool", def: true },
  603. /**
  604. * 运行时长的**下限**(分钟)。
  605. *
  606. * 它不是"最长多久",而是"至少跑这么久"—— 引擎会按提取上限自动放大:
  607. * 每个收录约 20 秒(开过滤时要逐个开用户页,含被跳过的折算),
  608. * 所以 limit=100 会自动给到约 38 分钟,limit=1000 给到 4 小时(封顶)。
  609. *
  610. * 界面上写「至少运行 __ 分钟(按提取上限自动放宽,最多 4 小时)」。
  611. */
  612. { path: "tabs.id.features.maxRuntimeMin", type: "int", min: 1, max: 240, def: 30 }
  613. );
  614. /* ——— UID私信页 ——— */
  615. F.push.apply(F, cardFields("tabs.dm.scripts.s1", true, true));
  616. F.push(
  617. { path: "tabs.dm.scripts.s1.doDm", type: "bool", def: true },
  618. { path: "tabs.dm.scripts.s1.filterDate", type: "enum", group: "filterDate", def: "7d" },
  619. { path: "tabs.dm.scripts.s1.firstAction", type: "enum", group: "firstAction", def: "both" },
  620. { path: "tabs.dm.scripts.s1.uidTextPath", type: "text", def: Store.TEXT_DIR + "/uid_list.txt" }
  621. );
  622. F.push.apply(F, cardFields("tabs.dm.scripts.s2", false, false));
  623. F.push(
  624. { path: "tabs.dm.aiGender", type: "enum", group: "aiGender", def: "male" },
  625. /* 关注与私信是两个独立动作,各自一个开关 + 一个数量区间。
  626. * 早先把它们挤成「进行关注 / 进行私信」一个开关、「200 / 30」一个控件,
  627. * 结果是想单独关掉私信就得连关注一起关掉。
  628. * 数量本身是**区间**(最少关注多少、最多关注多少),所以是一行两项。 */
  629. { path: "tabs.dm.doFollow", type: "bool", def: true },
  630. { path: "tabs.dm.followMin", type: "int", min: 0, max: 5000, def: 100 },
  631. { path: "tabs.dm.followMax", type: "int", min: 0, max: 5000, def: 200 },
  632. { path: "tabs.dm.doDm", type: "bool", def: true },
  633. { path: "tabs.dm.dmMin", type: "int", min: 0, max: 1000, def: 20 },
  634. { path: "tabs.dm.dmMax", type: "int", min: 0, max: 1000, def: 30 },
  635. { path: "tabs.dm.restMin", type: "int", min: 0, max: 3600, def: 10 },
  636. { path: "tabs.dm.restMax", type: "int", min: 0, max: 3600, def: 40 },
  637. { path: "tabs.dm.dedupeMode", type: "enum", group: "dedupeMode", def: "follow_dm" },
  638. { path: "tabs.dm.dmTextPath", type: "text", def: Store.TEXT_DIR + "/dm_text.txt" },
  639. { path: "tabs.dm.commentTextPath", type: "text", def: Store.TEXT_DIR + "/comment_text.txt" }
  640. );
  641. /* ——— 养号页 ———
  642. * 参数与脚本卡分开存:即使不勾选脚本,用户也能先把这组参数调好。
  643. * 浏览时长与单次操作都是区间(两个数),概率仍然是固定档位。 */
  644. F.push.apply(F, cardFields("tabs.nurture.scripts.s1", true, false));
  645. F.push(
  646. { path: "tabs.nurture.browseMin", type: "int", min: 0, max: 600, def: 3 },
  647. { path: "tabs.nurture.browseMax", type: "int", min: 0, max: 600, def: 8 },
  648. { path: "tabs.nurture.likeRate", type: "enum", group: "likeRate", def: "r30" },
  649. { path: "tabs.nurture.commentRate", type: "enum", group: "commentRate", def: "r10" },
  650. { path: "tabs.nurture.followRate", type: "enum", group: "followRate", def: "r15" },
  651. { path: "tabs.nurture.actionMin", type: "int", min: 0, max: 500, def: 10 },
  652. { path: "tabs.nurture.actionMax", type: "int", min: 0, max: 500, def: 30 }
  653. );
  654. /* ——— 视频功能页 ———
  655. * keyword / region 给的是**可直接跑的业务默认值**而不是空串:
  656. * 空着的输入框既看不出该填什么,也让「未配置」和「配了空值」无法区分。 */
  657. F.push.apply(F, cardFields("tabs.video.scripts.s1", false, false));
  658. F.push(
  659. { path: "tabs.video.keyword", type: "text", def: "fashion" },
  660. { path: "tabs.video.region", type: "text", def: "US" },
  661. { path: "tabs.video.stopMin", type: "int", min: 1, max: 500, def: 1 },
  662. { path: "tabs.video.stopMax", type: "int", min: 1, max: 500, def: 5 },
  663. { path: "tabs.video.intervalMin", type: "int", min: 0, max: 3600, def: 20 },
  664. { path: "tabs.video.intervalMax", type: "int", min: 0, max: 3600, def: 20 },
  665. { path: "tabs.video.actions.follow", type: "bool", def: true },
  666. { path: "tabs.video.actions.dm", type: "bool", def: true },
  667. { path: "tabs.video.actions.like", type: "bool", def: false },
  668. { path: "tabs.video.actions.comment", type: "bool", def: false },
  669. { path: "tabs.video.dmTextPath", type: "text", def: Store.TEXT_DIR + "/dm_text.txt" },
  670. { path: "tabs.video.commentTextPath", type: "text", def: Store.TEXT_DIR + "/comment_text.txt" }
  671. );
  672. Store.FIELDS = F;
  673. /**
  674. * 跨字段约束:上界不得小于下界。
  675. * 单字段的 min/max 表达不了这种关系,所以单独列出来做后处理。
  676. *
  677. * 界面上每一个 `W.rangeRow` 都必须在下面有一对 ——
  678. * 漏一条的后果是「最小值 500 / 最大值 100」这种自相矛盾的组合能存进配置,
  679. * 而且因为区间行的两个数在**同一行**,错位比拆两行时更刺眼。
  680. */
  681. Store.CROSS_RULES = [
  682. { low: "tabs.dm.followMin", high: "tabs.dm.followMax" },
  683. { low: "tabs.dm.dmMin", high: "tabs.dm.dmMax" },
  684. { low: "tabs.dm.restMin", high: "tabs.dm.restMax" },
  685. { low: "tabs.nurture.browseMin", high: "tabs.nurture.browseMax" },
  686. { low: "tabs.nurture.actionMin", high: "tabs.nurture.actionMax" },
  687. { low: "tabs.video.stopMin", high: "tabs.video.stopMax" },
  688. { low: "tabs.video.intervalMin", high: "tabs.video.intervalMax" },
  689. { low: "tabs.id.features.followMin", high: "tabs.id.features.followMax" },
  690. { low: "tabs.id.features.fansMin", high: "tabs.id.features.fansMax" },
  691. { low: "tabs.id.features.likesMin", high: "tabs.id.features.likesMax" },
  692. { low: "tabs.id.features.videosMin", high: "tabs.id.features.videosMax" }
  693. ];
  694. /* ---------------------------------------------------------------- 路径工具 */
  695. function isPlainObject(value) {
  696. return value !== null && typeof value === "object" && !(value instanceof Array);
  697. }
  698. function readPath(obj, path) {
  699. var segs = String(path).split(".");
  700. var node = obj;
  701. for (var i = 0; i < segs.length; i++) {
  702. if (node === null || typeof node !== "object") { return undefined; }
  703. node = node[segs[i]];
  704. }
  705. return node;
  706. }
  707. function assignPath(obj, path, value) {
  708. var segs = String(path).split(".");
  709. var node = obj;
  710. for (var i = 0; i < segs.length - 1; i++) {
  711. if (!isPlainObject(node[segs[i]])) { node[segs[i]] = {}; }
  712. node = node[segs[i]];
  713. }
  714. node[segs[segs.length - 1]] = value;
  715. return obj;
  716. }
  717. /* ---------------------------------------------------------------- 默认值 */
  718. /** 从字段表推导默认配置——默认值只有这一处来源,不会与校验规则漂移。 */
  719. Store.defaults = function () {
  720. var cfg = { version: Store.SCHEMA_VERSION };
  721. for (var i = 0; i < Store.FIELDS.length; i++) {
  722. var f = Store.FIELDS[i];
  723. assignPath(cfg, f.path, f.def);
  724. }
  725. return cfg;
  726. };
  727. /* ---------------------------------------------------------------- 版本迁移 */
  728. /**
  729. * 逐版本迁移。键是「从哪个版本升上来」,函数把配置就地改造成下一版的样子。
  730. *
  731. * 参数说明:
  732. * cfg —— 已经按当前字段表归一化过的配置(结构完整、类型正确)
  733. * src —— **原始输入**。有些旧字段在新版字段表里已被删除,
  734. * 归一化后 cfg 里就没有它了,只能从 src 里读回来。
  735. *
  736. * 为什么必须有迁移:类型归一化只能处理「类型不对」,
  737. * 处理不了「语义变了」——例如 v1 的空串其实等于未设置、
  738. * v2 的一个开关其实要拆成两个。这类差异只有迁移能表达。
  739. */
  740. Store.MIGRATIONS = {
  741. 1: function (cfg, problems) {
  742. /* v1 里这几项的默认值都是空串,界面上表现为空框 */
  743. var emptyInV1 = [
  744. "tabs.video.keyword",
  745. "tabs.video.region",
  746. "tabs.dm.dmTextPath",
  747. "tabs.dm.commentTextPath",
  748. "tabs.video.dmTextPath",
  749. "tabs.video.commentTextPath"
  750. ];
  751. for (var i = 0; i < emptyInV1.length; i++) {
  752. var path = emptyInV1[i];
  753. if (readPath(cfg, path) === "") {
  754. var def = Store.defaultOf(path);
  755. if (def) {
  756. assignPath(cfg, path, def);
  757. problems.push(path + " 旧版为空,已补默认值 " + def);
  758. }
  759. }
  760. }
  761. },
  762. 2: function (cfg, problems, src) {
  763. /* v2 用一个 doFollowDm 同时控制关注与私信,v3 拆成两个独立开关。
  764. * 旧值同时赋给两者,保证升级后行为与升级前一致(都是开就是都开)。 */
  765. var legacy = readPath(src, "tabs.dm.doFollowDm");
  766. if (typeof legacy === "boolean") {
  767. assignPath(cfg, "tabs.dm.doFollow", legacy);
  768. assignPath(cfg, "tabs.dm.doDm", legacy);
  769. problems.push("tabs.dm.doFollowDm 已拆为「进行关注」「进行私信」两个开关,旧值 " +
  770. legacy + " 已沿用");
  771. }
  772. },
  773. /**
  774. * v4 → v5:五处「一个值」改成「一个区间」。
  775. *
  776. * 迁移原则是**不改变原有行为**,具体分三类:
  777. * · 原本是「上限」的(关注上限 / 私信上限 / 单次操作上限)
  778. * → min 给 0(= 不设下界),max 沿用旧上限。
  779. * 旧语义就是「最多 N 个」,升级后仍然是最多 N 个。
  780. * · 原本是「精确个数」的(停止个数)
  781. * → min = max = 旧值。这跟设计稿里「间隔时间 20 ~ 20」是同一个表达:
  782. * 相等的上下界就是定值。
  783. * · 原本是「档位」的(浏览时长 3 ~ 8 这类)
  784. * → 直接取该档的上下界,一一对应。
  785. * 全新安装拿到的则是设计稿给的默认区间(例如 关注数量 100 ~ 200)。
  786. */
  787. 4: function (cfg, problems, src) {
  788. /* 旧档位 → 区间。两组档位的 key 本身不重叠(d3_8… / n10…),
  789. * 所以用一张平表就够,不必再按字段名分一层。 */
  790. var RANGE_OF = {
  791. "d3_8": [3, 8], "d5_10": [5, 10], "d8_15": [8, 15],
  792. "n10": [0, 10], "n20": [0, 20], "n30": [0, 30], "n50": [0, 50]
  793. };
  794. inheritRange(src, cfg, problems, "tabs.dm.followLimit", 0, "upper",
  795. "tabs.dm.followMin", "tabs.dm.followMax", "关注数量");
  796. inheritRange(src, cfg, problems, "tabs.dm.dmLimit", 0, "upper",
  797. "tabs.dm.dmMin", "tabs.dm.dmMax", "私信数量");
  798. inheritRange(src, cfg, problems, "tabs.nurture.actionLimit", 0, "upper",
  799. "tabs.nurture.actionMin", "tabs.nurture.actionMax", "单次操作", RANGE_OF);
  800. inheritRange(src, cfg, problems, "tabs.nurture.browseDuration", 0, "upper",
  801. "tabs.nurture.browseMin", "tabs.nurture.browseMax", "浏览时长(分钟)", RANGE_OF);
  802. inheritRange(src, cfg, problems, "tabs.video.stopCount", 0, "exact",
  803. "tabs.video.stopMin", "tabs.video.stopMax", "停止个数");
  804. },
  805. /**
  806. * v5 → v6:底栏从 5 个页签收敛成 3 个 tab(任务 / 记录 / 我的)。
  807. *
  808. * 这不是"改个名字":原来的 id/dm/nurture/video 四个页签
  809. * **降级成「任务」tab 里的四张任务卡**,各自的详情页成了二级页。
  810. * 所以老存档里 `activeTab = "nurture"` 必须映射成 `"tasks"` ——
  811. * 不映射的话写入端的枚举归一化会把非法值回退成默认值,
  812. * 表面上"没问题",但老用户会发现"我上次停在养号页,重启跑到任务列表了",
  813. * 而这恰恰是**无法解释**的那类现象。
  814. *
  815. * 四个旧页签的**配置数据一个都没动**(`tabs.id.*` / `tabs.dm.*` / … 原样保留),
  816. * 只是入口层级变了 —— 迁移只碰入口,不碰业务配置。
  817. */
  818. 5: function (cfg, problems, src) {
  819. var OLD_TABS = ["id", "dm", "nurture", "video"];
  820. var oldTab = readPath(src, "activeTab");
  821. if (OLD_TABS.indexOf(oldTab) >= 0) {
  822. assignPath(cfg, "activeTab", "tasks");
  823. /* 老用户在某个任务页上 → 直接把他带回那个任务的详情页,
  824. * 而不是只扔到任务列表让他自己再点一次。 */
  825. assignPath(cfg, "activeDetail", oldTab);
  826. problems.push("activeTab " + oldTab + " → tasks(该任务已升为二级页,已直接停在它的详情页)");
  827. } else if (oldTab === "mine") {
  828. assignPath(cfg, "activeTab", "mine");
  829. }
  830. },
  831. /**
  832. * v6 → v7:ID提取页清语义(需求重整 A 档)。
  833. *
  834. * 两件必须做的,都是"类型系统看不出来的语义差异":
  835. *
  836. * ① **s3 的「进入页面」值域收窄**。原来是共用 `enterPage` 的四个值
  837. * (粉丝页/评论页/主页/关注页),但这个字段在脚本三里的意思是
  838. * "展开文本里这些人的哪个**列表**" —— 评论页和主页根本不是列表。
  839. * 存量值是这两个的,一律落到「关注列表」(脚本三的设计稿默认值)。
  840. * 不处理的话,`normalizeField` 会把非法枚举悄悄退回默认值,
  841. * 用户只会看到"我选过的选项自己变了",而不知道发生了什么。
  842. *
  843. * ② **去重从卡片搬到功能设置**。旧的 `dedupeEnabled` 是用户对"要不要去重"
  844. * 的真实表达,不能因为控件搬家就丢掉 —— 把它继承到 `dedupeLocal`。
  845. * 旧 `dedupe`(ID 模式 / UID 模式)表达的其实是"跟不跟历史比",
  846. * 映射到新增的 `dedupeHistory`("UID 模式"= 按输入文本比 = 留了个文本基准,
  847. * 语义上更接近"跨轮")。这条映射**不完美**,所以写进 problems 里说清楚。
  848. */
  849. 6: function (cfg, problems, src) {
  850. /* ⚠️ v8 起 s3 的「进入页面」字段已经随四张卡一起删除,
  851. * 这里不再对它做归一化 —— 那段值现在由 `Sources.migrateLegacy()`
  852. * 从原始 src 直接读,翻译成取材的「展开哪个列表」。
  853. * 原来那条 assignPath 会写到一个**已不存在的路径**上,纯属幽灵写入。 */
  854. var oldDedupe = readPath(src, "tabs.id.scripts.s1.dedupeEnabled");
  855. if (typeof oldDedupe === "boolean") {
  856. assignPath(cfg, "tabs.id.features.dedupeLocal", oldDedupe);
  857. problems.push("「主播去重」已并入功能设置 →「本地去重」,旧值 " + oldDedupe + " 已沿用");
  858. }
  859. var oldMode = readPath(src, "tabs.id.scripts.s1.dedupe");
  860. if (oldMode === "text") {
  861. assignPath(cfg, "tabs.id.features.dedupeHistory", true);
  862. problems.push("旧去重模式「UID 模式」→「跨轮去重」已打开(按输入文本比对历史)");
  863. }
  864. },
  865. 9: function (cfg, problems, src) {
  866. /* v9 → v10:私信「休息时间」的默认值 100~500 秒 → 10~40 秒。
  867. *
  868. * ★ 键的意义是**"从哪个版本升上来"**,不是"升到哪个版本" ——
  869. * 执行循环是 `for (v = fromVersion; v < SCHEMA_VERSION; v++) MIGRATIONS[v]`,
  870. * 所以想从 v8 升到 v9 要写 `8:`。这条最初写成 `9:`,
  871. * 于是 fromVersion=8 时查的是不存在的 `MIGRATIONS[8]`,**静默跳过**,
  872. * 设备被标成 v9 而值仍是旧的 100~500。所以这次把版本再抬一版(v10)来补执行。
  873. *
  874. * 为什么改这个默认值:它是**每处理完一个 UID 之后**的休息,
  875. * 而关注 100~200 + 私信 20~30 是两批不同的人,合计 120~230 个 UID ——
  876. * 按旧默认一轮要跑 5~24 小时,实际根本跑不完。
  877. *
  878. * 只改**还停在旧默认值**的配置;用户手动调过的保持原样。
  879. * 全新安装(src 为空)读到的是 undefined,条件不成立,保留新默认。 */
  880. var restMin = Number(readPath(src, "tabs.dm.restMin"));
  881. var restMax = Number(readPath(src, "tabs.dm.restMax"));
  882. if (restMin === 100 && restMax === 500) {
  883. assignPath(cfg, "tabs.dm.restMin", 10);
  884. assignPath(cfg, "tabs.dm.restMax", 40);
  885. problems.push("私信「休息时间」默认值由 100~500 秒改为 10~40 秒" +
  886. "(原值会让一轮跑 5~24 小时,已跟随新默认)");
  887. }
  888. },
  889. 10: function (cfg, problems, src) {
  890. /* v10 → v11:**同一条修复的补执行**(上面的 9 号迁移在真机上漏了)。
  891. *
  892. * 为什么还要再来一次:那条迁移当时用 `restMin === 100` 做判断,
  893. * 而设备上存的可能是字符串 `"100"`(不同写入路径留下的),严格相等匹配不上 →
  894. * 迁移"跑了但什么也没改",版本却被抬到 10,于是**永久卡在 100~500**:
  895. * 之后每次启动 fromVersion 都等于 SCHEMA,这条迁移再也不会执行。
  896. * 现象是"界面上写着 100~500 秒,引擎实际按 10~40 跑"(引擎侧有一道读取兜底),
  897. * 两边不一致 —— 这比单纯显示旧默认更难查。
  898. *
  899. * 这次用 Number() 比较,并且**只认这一对精确的旧默认值**:
  900. * 用户自己调过的任何一组数都不会被覆盖。 */
  901. var lo = Number(readPath(src, "tabs.dm.restMin"));
  902. var hi = Number(readPath(src, "tabs.dm.restMax"));
  903. if (lo === 100 && hi === 500) {
  904. assignPath(cfg, "tabs.dm.restMin", 10);
  905. assignPath(cfg, "tabs.dm.restMax", 40);
  906. problems.push("私信「休息时间」仍是最初的 100~500 秒,已按 10~40 秒修正" +
  907. "(原值一轮要跑 5~24 小时)");
  908. }
  909. }
  910. };
  911. /**
  912. * 把一个**已被删除**的旧字段迁移成一对区间字段。
  913. *
  914. * 为什么必须从 src 里读:旧字段已经不在 `Store.FIELDS` 里,
  915. * 归一化之后的 cfg 里根本没有它 —— 只有原始输入还留着。
  916. *
  917. * mode:
  918. * "upper" —— 旧值是上限 → min 给 fallback(通常是 0),max = 旧值
  919. * "exact" —— 旧值是精确量 → min = max = 旧值
  920. * mapping —— 若给了映射表,旧值是里面的档位 key,直接取对应的一对数字
  921. */
  922. function inheritRange(src, cfg, problems, legacyPath, fallback, mode, minPath, maxPath, label, mapping) {
  923. var legacy = readPath(src, legacyPath);
  924. if (legacy === undefined || legacy === null) { return; }
  925. var low = fallback;
  926. var high = fallback;
  927. if (mapping) {
  928. var pair = mapping[legacy];
  929. if (!pair) { return; }
  930. low = pair[0];
  931. high = pair[1];
  932. } else {
  933. var n = Number(legacy);
  934. if (isNaN(n)) { return; }
  935. low = (mode === "exact") ? n : fallback;
  936. high = n;
  937. }
  938. assignPath(cfg, minPath, low);
  939. assignPath(cfg, maxPath, high);
  940. problems.push(legacyPath + " → " + label + " " + low + " ~ " + high +
  941. "(旧值 " + legacy + " 已沿用)");
  942. }
  943. /* ---------------------------------------------------------------- 校验 */
  944. /** 单字段归一化。只有「确实非法」才记问题——缺失属正常迁移,不刷屏。 */
  945. function normalizeField(raw, field, problems) {
  946. var missing = raw === undefined || raw === null;
  947. if (field.type === "bool") {
  948. return missing ? field.def : !!raw;
  949. }
  950. if (field.type === "int") {
  951. var n = Number(raw);
  952. if (missing || raw === "" || isNaN(n)) {
  953. if (!missing && raw !== "") {
  954. problems.push(field.path + " 不是数字(" + raw + "),已用默认值 " + field.def);
  955. }
  956. return field.def;
  957. }
  958. n = Math.round(n);
  959. if (field.min !== undefined && n < field.min) {
  960. problems.push(field.path + " " + n + " 低于下限,已抬到 " + field.min);
  961. n = field.min;
  962. }
  963. if (field.max !== undefined && n > field.max) {
  964. problems.push(field.path + " " + n + " 超过上限,已压到 " + field.max);
  965. n = field.max;
  966. }
  967. return n;
  968. }
  969. if (field.type === "enum") {
  970. var keys = field.values || enumKeys(field.group);
  971. if (keys.indexOf(raw) >= 0) { return raw; }
  972. if (!missing) {
  973. problems.push(field.path + " 非法取值(" + raw + "),已回退 " + field.def);
  974. }
  975. return field.def;
  976. }
  977. /* text:只认字符串。
  978. * 非字符串(数字/布尔/对象)一律回退默认值,**不做 String() 转换**——
  979. * 配置里出现数字只可能是手改或异常写入,把 42 静默转成 "42"
  980. * 会让一个无效的搜索条件直接生效,比回退默认危险得多。
  981. * (用户正常输入的数字也会以字符串形式存进来,不会走到这里。) */
  982. if (typeof raw === "string") { return raw; }
  983. if (!missing) {
  984. problems.push(field.path + " 不是文本(" + (typeof raw) + "),已用默认值 " + field.def);
  985. }
  986. return field.def;
  987. }
  988. /**
  989. * 把任意输入收敛成合法配置。返回 { config, problems }。
  990. *
  991. * 遍历的是**字段表**而不是输入对象 —— 表外的键会被丢弃,
  992. * 这样手改过的脏 JSON、旧版本残留字段都不会穿透到界面。
  993. *
  994. * problems 用于运行期把发现的问题打印出来:设备端没有测试框架,
  995. * 校验结果必须能被人看见,否则「悄悄用默认值跑」比报错更难查。
  996. */
  997. Store.validate = function (input) {
  998. var src = isPlainObject(input) ? input : {};
  999. var problems = [];
  1000. /* 没写 version 的(很早期的存档)按 0 处理,否则 NaN 的比较恒为 false,迁移会被跳过 */
  1001. var fromVersion = Number(input ? input.version : 0);
  1002. if (isNaN(fromVersion)) { fromVersion = 0; }
  1003. if (fromVersion !== Store.SCHEMA_VERSION) {
  1004. problems.push("version " + (input && input.version !== undefined ? input.version : "(无)") +
  1005. " → " + Store.SCHEMA_VERSION + ",缺失字段已按默认值补齐");
  1006. }
  1007. var cfg = { version: Store.SCHEMA_VERSION };
  1008. for (var i = 0; i < Store.FIELDS.length; i++) {
  1009. var f = Store.FIELDS[i];
  1010. assignPath(cfg, f.path, normalizeField(readPath(src, f.path), f, problems));
  1011. }
  1012. /* 迁移放在归一化之后:此时 cfg 已是完整结构,迁移只需处理语义差异
  1013. * (例如「v1 的空串其实等于未设置」「v2 的一个开关要拆成两个」这类
  1014. * 类型系统看不出来的事)。src 一并传入,用于读取已被删除的旧字段。 */
  1015. for (var v = fromVersion; v < Store.SCHEMA_VERSION; v++) {
  1016. var migrate = Store.MIGRATIONS[v];
  1017. if (migrate) { migrate(cfg, problems, src); }
  1018. }
  1019. for (var j = 0; j < Store.CROSS_RULES.length; j++) {
  1020. var rule = Store.CROSS_RULES[j];
  1021. var low = readPath(cfg, rule.low);
  1022. var high = readPath(cfg, rule.high);
  1023. if (typeof low === "number" && typeof high === "number" && high < low) {
  1024. problems.push(rule.high + "(" + high + ") 小于 " + rule.low + "(" + low + "),已抬齐");
  1025. assignPath(cfg, rule.high, low);
  1026. }
  1027. }
  1028. return { config: cfg, problems: problems };
  1029. };
  1030. /* ---------------------------------------------------------------- 合并 */
  1031. /**
  1032. * 深合并:patch 覆盖 base 的同名键,patch 没有的键保留 base 的值。
  1033. * 只递归「纯对象」——数组整体替换,不逐项合并(数组语义通常是「整份配置」)。
  1034. */
  1035. Store.merge = function (base, patch) {
  1036. if (!isPlainObject(patch)) { return base; }
  1037. var out = {};
  1038. var k;
  1039. for (k in base) {
  1040. if (base.hasOwnProperty(k)) { out[k] = base[k]; }
  1041. }
  1042. for (k in patch) {
  1043. if (!patch.hasOwnProperty(k)) { continue; }
  1044. if (isPlainObject(base[k]) && isPlainObject(patch[k])) {
  1045. out[k] = Store.merge(base[k], patch[k]);
  1046. } else {
  1047. out[k] = patch[k];
  1048. }
  1049. }
  1050. return out;
  1051. };
  1052. /* ---------------------------------------------------------------- 读写 */
  1053. function storage() {
  1054. return storages.create(Store.STORAGE_NAME);
  1055. }
  1056. /** 读配置;任何异常都退回默认值,绝不让界面因读取失败而起不来。 */
  1057. Store.load = function () {
  1058. var raw = null;
  1059. try {
  1060. raw = storage().get(Store.STORAGE_KEY, null);
  1061. } catch (e) {
  1062. console.log("[store] 读取失败,使用默认配置: " + e);
  1063. }
  1064. if (typeof raw === "string" && raw.length > 0) {
  1065. try {
  1066. raw = JSON.parse(raw);
  1067. } catch (e2) {
  1068. console.log("[store] JSON 解析失败,使用默认配置");
  1069. raw = null;
  1070. }
  1071. }
  1072. var result = Store.validate(raw);
  1073. if (result.problems.length > 0) {
  1074. console.log("[store] 校验修正: " + result.problems.join(" | "));
  1075. }
  1076. return result.config;
  1077. };
  1078. /** 写配置。返回是否成功——界面要据此提示用户,而不是静默丢数据。 */
  1079. Store.save = function (config) {
  1080. try {
  1081. storage().put(Store.STORAGE_KEY, JSON.stringify(config));
  1082. return true;
  1083. } catch (e) {
  1084. console.log("[store] 保存失败: " + e);
  1085. return false;
  1086. }
  1087. };
  1088. Store.reset = function () {
  1089. try {
  1090. storage().remove(Store.STORAGE_KEY);
  1091. } catch (e) {
  1092. console.log("[store] 重置失败: " + e);
  1093. }
  1094. return Store.defaults();
  1095. };
  1096. /* 运行期只持有一份实例,页面通过它读写,避免各自 load 出多份副本互相覆盖。 */
  1097. Store.state = null;
  1098. Store.init = function () {
  1099. Store.state = Store.load();
  1100. /* ★ 迁移/修正的结果必须**落盘**。
  1101. *
  1102. * `Store.load()` 内部会跑 SCHEMA 迁移并修正非法值,但它只返回内存里的 config、
  1103. * **没有写回存储**。后果是:这次启动迁移过了,下次启动又从旧值开始迁移一遍;
  1104. * 更糟的是引擎读配置时可能拿到"内存已改、存储未改"的两份不一致数据。
  1105. * (私信休息时间的迁移就是这样:逻辑明明正确,真机上却始终不生效。)
  1106. *
  1107. * 此时 `Store.state` 已经设好,`Store.save()` 不会再回头调 init,不会递归。 */
  1108. try {
  1109. Store.save(Store.state);
  1110. } catch (error) {
  1111. console.log("[store] 迁移结果落盘失败(不影响本次运行): " + error);
  1112. }
  1113. return Store.state;
  1114. };
  1115. Store.current = function () {
  1116. if (!Store.state) { Store.init(); }
  1117. return Store.state;
  1118. };
  1119. /** 按路径取字段声明;没有契约的路径返回 null(调用方据此决定要不要归一化)。 */
  1120. Store.fieldOf = function (path) {
  1121. for (var i = 0; i < Store.FIELDS.length; i++) {
  1122. if (Store.FIELDS[i].path === path) { return Store.FIELDS[i]; }
  1123. }
  1124. return null;
  1125. };
  1126. /**
  1127. * 改一个字段并立即落盘。页面唯一的状态写入出口。
  1128. *
  1129. * ★ 写之前先按字段表**归一化**(夹紧越界、回退非法枚举、纠正类型)。
  1130. * 「配置里永远没有越界值」这条不变量必须由写入端保证,而不是只靠 load 时的
  1131. * `validate()`。少了这一步,界面上直接输入的值会原样落盘:用户填 99999 越过了
  1132. * 上限 500,配置里就真的是 99999 —— 要等下一次重启加载才被纠正,
  1133. * 而用户看到的是「我填的数它照收了,可跑起来又不是那么回事」。
  1134. */
  1135. Store.set = function (path, value) {
  1136. var field = Store.fieldOf(path);
  1137. /* 表外的路径(例如未来新增的临时键)不做归一化,原样写入 */
  1138. var next = field ? normalizeField(value, field, []) : value;
  1139. assignPath(Store.current(), path, next);
  1140. return Store.save(Store.current());
  1141. };
  1142. /**
  1143. * 取字段值。第二参数是缺失时的兜底 —— **页面里凡是喂给界面的取值都应该带上它**,
  1144. * 否则 undefined 会一路飘到 String() 变成界面上的 "undefined"。
  1145. */
  1146. Store.get = function (path, fallback) {
  1147. var value = readPath(Store.current(), path);
  1148. return value === undefined ? fallback : value;
  1149. };
  1150. /* ------------------------------------------------- 类型安全的取值(界面专用) */
  1151. /** 取某字段在字段表里声明的默认值——界面兜底时用它,与数据层保持同一来源。 */
  1152. Store.defaultOf = function (path) {
  1153. for (var i = 0; i < Store.FIELDS.length; i++) {
  1154. if (Store.FIELDS[i].path === path) { return Store.FIELDS[i].def; }
  1155. }
  1156. return undefined;
  1157. };
  1158. /** groupOrValues 既接受枚举组名,也接受候选数组。 */
  1159. function keysOf(groupOrValues) {
  1160. if (groupOrValues instanceof Array) { return groupOrValues; }
  1161. return enumKeys(groupOrValues);
  1162. }
  1163. /**
  1164. * 取枚举值,**保证返回一个合法 key**。
  1165. * 缺失或非法时回退到该枚举第一项,并把这个修正回写到配置里——
  1166. * 于是「选择框总有选中项」在数据层就被保证,页面不必各自兜底。
  1167. */
  1168. Store.getEnum = function (path, groupOrValues) {
  1169. var keys = keysOf(groupOrValues);
  1170. var raw = Store.get(path, null);
  1171. if (raw !== null && keys.indexOf(raw) >= 0) { return raw; }
  1172. var fallback = keys.length > 0 ? keys[0] : "";
  1173. var declared = Store.defaultOf(path);
  1174. if (declared !== undefined && keys.indexOf(declared) >= 0) { fallback = declared; }
  1175. Store.set(path, fallback);
  1176. return fallback;
  1177. };
  1178. /** 取数值,保证返回有限数;非法时回退默认值并回写。 */
  1179. Store.getNumber = function (path, def) {
  1180. var raw = Store.get(path, null);
  1181. var n = Number(raw);
  1182. if (raw !== null && raw !== "" && !isNaN(n)) { return n; }
  1183. var fallback = def === undefined ? Store.defaultOf(path) : def;
  1184. if (typeof fallback !== "number") { fallback = 0; }
  1185. Store.set(path, fallback);
  1186. return fallback;
  1187. };
  1188. /** 取文本,保证返回字符串;缺失或非字符串时回退默认值并回写。 */
  1189. Store.getText = function (path, def) {
  1190. var raw = Store.get(path, null);
  1191. if (typeof raw === "string") { return raw; }
  1192. var fallback = def === undefined ? Store.defaultOf(path) : def;
  1193. if (typeof fallback !== "string") { fallback = ""; }
  1194. Store.set(path, fallback);
  1195. return fallback;
  1196. };
  1197. /** 取布尔,保证返回布尔值。 */
  1198. Store.getBool = function (path, def) {
  1199. var raw = Store.get(path, null);
  1200. if (typeof raw === "boolean") { return raw; }
  1201. var fallback = def === undefined ? Store.defaultOf(path) : def;
  1202. if (typeof fallback !== "boolean") { fallback = false; }
  1203. Store.set(path, fallback);
  1204. return fallback;
  1205. };
  1206. /* ============================== src/records.js ============================== */
  1207. /* ============================== src/records.js ==============================
  1208. * 运行记录(规格书 D1/D2/D3 的数据来源)。
  1209. *
  1210. * ## 为什么单独一个模块,而不是塞进 Store.FIELDS
  1211. * Store 管的是「**固定字段**的配置」——一张声明表,每个路径一个值。
  1212. * 运行记录是**不定长的列表**(跑一次多一条),塞进字段表就等于
  1213. * 让 validate() 去校验一个数组,还把「路径 → 值控件」的那套审计规则带歪。
  1214. * 所以它走同一份 storages 的**另一个 key**,自己管读写。
  1215. *
  1216. * ## 记录里存什么
  1217. * 只存**界面真的知道的东西**:任务/脚本、结果分类、起止时间、结果文案、
  1218. * 结果文件、日志行。不编造没发生过的字段(例如"实际提取了几个 ID"只有
  1219. * 引擎回传了才写;没回传就留空,D3 那一行就不显示)。
  1220. *
  1221. * ## 日志从哪来
  1222. * 引擎(tiktok-auto)通过 `--log` 通道把真实输出按行 append 到
  1223. * run-log.jsonl,界面在结束时读回来。**没有日志就不显示日志卡的空壳** ——
  1224. * 详见 `Engine.readLog()` 与 §「日志缺失时如实说明」。
  1225. */
  1226. var Records = {};
  1227. /** 与 Store 共用一份 storages,只是换一个 key(见文件头注释)。 */
  1228. Records.STORAGE_NAME = "feizhou_app";
  1229. Records.STORAGE_KEY = "records";
  1230. /**
  1231. * 最多保留多少条。
  1232. *
  1233. * 上限是**必要的**:记录里含日志行,跑几十次之后 JSON 会长到
  1234. * storages 读取明显变慢(它每次整体读字符串)。
  1235. * 50 条足够回溯,超出的从尾部丢弃(新记录永远在最前)。
  1236. */
  1237. Records.MAX = 50;
  1238. /** 结果分类 → 界面文案 + 色调。tone 直接喂给 W.pill。 */
  1239. Records.RESULT = {
  1240. success: { key: "success", label: "\u5DF2\u5B8C\u6210", tone: "ok" },
  1241. stopped: { key: "stopped", label: "\u624B\u52A8\u505C\u6B62", tone: "mute" },
  1242. failed: { key: "failed", label: "\u62A5\u9519\u4E2D\u65AD", tone: "danger" }
  1243. };
  1244. /** 归一化结果分类:引擎给的终态与记录分类不是一套词(finished/stopped/failed)。 */
  1245. Records.resultOf = function (status) {
  1246. if (status === "finished" || status === "success") { return Records.RESULT.success; }
  1247. if (status === "stopped") { return Records.RESULT.stopped; }
  1248. return Records.RESULT.failed;
  1249. };
  1250. /* ------------------------------------------------------------ 读写 */
  1251. function storage() {
  1252. return storages.create(Records.STORAGE_NAME);
  1253. }
  1254. /** 读全部记录(新的在前)。任何异常都退回空数组——记录读不出来不该让界面起不来。 */
  1255. Records.list = function () {
  1256. var raw = null;
  1257. try {
  1258. raw = storage().get(Records.STORAGE_KEY, null);
  1259. } catch (e) {
  1260. console.log("[records] 读取失败: " + e);
  1261. }
  1262. if (typeof raw === "string" && raw.length > 0) {
  1263. try {
  1264. raw = JSON.parse(raw);
  1265. } catch (e2) {
  1266. console.log("[records] JSON 解析失败,按空处理");
  1267. raw = null;
  1268. }
  1269. }
  1270. if (!(raw instanceof Array)) { return []; }
  1271. /* 逐条清洗:缺 id / 缺任务名的脏条目直接丢掉,
  1272. * 免得 D1 上出现一行点不开、也说不清是什么的东西。 */
  1273. var out = [];
  1274. for (var i = 0; i < raw.length; i++) {
  1275. var one = raw[i];
  1276. if (!one || typeof one !== "object") { continue; }
  1277. if (!one.id || !one.taskKey) { continue; }
  1278. out.push(one);
  1279. }
  1280. return out;
  1281. };
  1282. Records.save = function (list) {
  1283. try {
  1284. storage().put(Records.STORAGE_KEY, JSON.stringify(list));
  1285. return true;
  1286. } catch (e) {
  1287. console.log("[records] 保存失败: " + e);
  1288. return false;
  1289. }
  1290. };
  1291. /**
  1292. * 追加一条记录(新的在最前)。
  1293. * @returns 落盘后的那条记录(含 id)——调用方(F5 的「查看日志」)要用它跳 D3
  1294. */
  1295. Records.add = function (record) {
  1296. var list = Records.list();
  1297. var one = record || {};
  1298. one.id = one.id || ("rec-" + Date.now() + "-" + Math.floor(Math.random() * 10000));
  1299. one.taskKey = one.taskKey || "";
  1300. one.taskName = one.taskName || "";
  1301. one.scriptKey = one.scriptKey || "";
  1302. one.scriptName = one.scriptName || "";
  1303. one.result = one.result || "success";
  1304. one.startedAt = one.startedAt || 0;
  1305. one.endedAt = one.endedAt || Date.now();
  1306. one.resultText = one.resultText || "";
  1307. one.resultFile = one.resultFile || "";
  1308. /* uid 映射文件的路径(ID提取且勾了「同时解析数字 uid」时才有)。
  1309. * 老记录里没有这个字段 → 归一化成空串,渲染层据此决定要不要多一行。 */
  1310. one.mapFile = one.mapFile || "";
  1311. /* runId:记录页要靠它和断点里的 runId 对上(见 Resume.isRecordResumable)。
  1312. * 老记录没有这个字段 → 归一化成空串,于是永远不会显示「继续」。 */
  1313. one.runId = one.runId || "";
  1314. one.logs = one.logs instanceof Array ? one.logs : [];
  1315. list.unshift(one);
  1316. if (list.length > Records.MAX) { list = list.slice(0, Records.MAX); }
  1317. Records.save(list);
  1318. return one;
  1319. };
  1320. /** 按 id 取一条(D3 用)。找不到返回 null。 */
  1321. Records.find = function (id) {
  1322. var list = Records.list();
  1323. for (var i = 0; i < list.length; i++) {
  1324. if (list[i].id === id) { return list[i]; }
  1325. }
  1326. return null;
  1327. };
  1328. /** 某个任务最近一条记录(B1 任务卡的「上次…」)。没有则 null。 */
  1329. Records.latestOf = function (taskKey) {
  1330. var list = Records.list();
  1331. for (var i = 0; i < list.length; i++) {
  1332. if (list[i].taskKey === taskKey) { return list[i]; }
  1333. }
  1334. return null;
  1335. };
  1336. Records.clear = function () {
  1337. try {
  1338. storage().remove(Records.STORAGE_KEY);
  1339. } catch (e) {
  1340. console.log("[records] 清空失败: " + e);
  1341. }
  1342. };
  1343. /* ------------------------------------------------------------ 文案 */
  1344. function pad2(n) {
  1345. return n < 10 ? "0" + n : String(n);
  1346. }
  1347. /**
  1348. * 时长 → 人话。
  1349. *
  1350. * 设计稿上三种形态都出现过(「耗时 12 分 30 秒」/「已运行 41 分钟」/「运行 6 分钟」),
  1351. * 统一走这一个函数,规则是:
  1352. * < 1 分钟 → "N 秒"
  1353. * < 1 小时 → 整分钟说「N 分钟」,带零头说「N 分 M 秒」
  1354. * ≥ 1 小时 → "H 小时 M 分"
  1355. * 为什么整分钟要单独说一句:0 秒是大多数「手动停止」的真实情况,
  1356. * 写成「12 分 0 秒」会让人以为数据不准。
  1357. */
  1358. Records.formatDuration = function (ms) {
  1359. var total = Math.max(0, Math.floor((ms || 0) / 1000));
  1360. if (total < 60) { return total + " \u79D2"; }
  1361. if (total < 3600) {
  1362. var m = Math.floor(total / 60);
  1363. var s = total % 60;
  1364. return s === 0 ? (m + " \u5206\u949F") : (m + " \u5206 " + s + " \u79D2");
  1365. }
  1366. var h = Math.floor(total / 3600);
  1367. var mm = Math.floor((total % 3600) / 60);
  1368. return h + " \u5C0F\u65F6 " + mm + " \u5206";
  1369. };
  1370. /**
  1371. * 时间点 → 「今天 10:24」/「昨天 09:58」/「10-11 09:58」。
  1372. * 相对日期的判断必须用**本机日历**(不是 UTC):跨时区时 UTC 会让
  1373. * 「今天」错成昨天,而这条文案的全部意义就在于它读起来直观。
  1374. */
  1375. Records.formatDay = function (timestamp, nowMs) {
  1376. var d = new Date(timestamp || 0);
  1377. var now = new Date(nowMs === undefined ? Date.now() : nowMs);
  1378. var clock = pad2(d.getHours()) + ":" + pad2(d.getMinutes());
  1379. var sameDay = d.getFullYear() === now.getFullYear()
  1380. && d.getMonth() === now.getMonth()
  1381. && d.getDate() === now.getDate();
  1382. if (sameDay) { return "\u4ECA\u5929 " + clock; }
  1383. var yesterday = new Date(now.getTime() - 86400000);
  1384. var isYesterday = d.getFullYear() === yesterday.getFullYear()
  1385. && d.getMonth() === yesterday.getMonth()
  1386. && d.getDate() === yesterday.getDate();
  1387. if (isYesterday) { return "\u6628\u5929 " + clock; }
  1388. return pad2(d.getMonth() + 1) + "-" + pad2(d.getDate()) + " " + clock;
  1389. };
  1390. /** D1 条目的时间行:「今天 10:24 · 耗时 12 分 30 秒」 */
  1391. Records.timeLine = function (record, nowMs) {
  1392. if (!record) { return ""; }
  1393. var start = record.startedAt || record.endedAt || 0;
  1394. var end = record.endedAt || start;
  1395. var head = Records.formatDay(start, nowMs);
  1396. if (record.durationMs === undefined || record.durationMs === null) {
  1397. record.durationMs = Math.max(0, end - start);
  1398. }
  1399. return head + " \u00B7 \u8017\u65F6 " + Records.formatDuration(record.durationMs);
  1400. };
  1401. /**
  1402. * D1 条目的结果行。
  1403. * 没给出结果文案时不编 —— 返回空串,由调用方决定这一行要不要出现。
  1404. */
  1405. Records.resultLine = function (record) {
  1406. if (!record) { return ""; }
  1407. var text = record.resultText || "";
  1408. if (record.resultFile && text.indexOf(record.resultFile) < 0) {
  1409. text = text === "" ? record.resultFile : text + " \u2192 " + record.resultFile;
  1410. }
  1411. return text;
  1412. };
  1413. /**
  1414. * B1 任务卡的「上次…」一行。
  1415. * 没有记录 → 「还未运行过」(设计稿 C9 的视频功能卡就是这个文案,不是空白)。
  1416. */
  1417. Records.lastRunLine = function (taskKey, nowMs) {
  1418. var one = Records.latestOf(taskKey);
  1419. if (!one) { return "\u8FD8\u672A\u8FD0\u884C\u8FC7"; }
  1420. var clock = Records.formatDay(one.startedAt || one.endedAt, nowMs);
  1421. /* 「今天 10:24」太长,卡上只留时分;跨天的记录才带上日期。 */
  1422. var short = clock.replace("\u4ECA\u5929 ", "\u4E0A\u6B21 ").replace("\u6628\u5929 ", "\u6628\u5929 ");
  1423. var tail = Records.resultLine(one);
  1424. return short + (tail === "" ? "" : " \u00B7 " + tail);
  1425. };
  1426. /* ============================== src/sources.js ============================== */
  1427. /* ============================== src/sources.js ==============================
  1428. * ID提取的「取材清单」—— 从哪儿找人。
  1429. *
  1430. * ## 为什么单独一个模块,而不是塞进 Store.FIELDS
  1431. * Store 管的是「**固定字段**的配置」:一张声明表,一个路径一个标量值。
  1432. * 取材是**不定长列表**(可以 1 条,也可以 5 条),塞进字段表就等于
  1433. * 让 validate() 去校验一个数组,还会把「路径 → 值控件」那套审计规则带歪。
  1434. * 所以照 `Records` 的先例:**同一份 storages,另一个 key**,自己管读写。
  1435. *
  1436. * ## 为什么是「清单」而不是「四个脚本选一个」
  1437. * 旧结构里四张卡是「起点 × 深度」的 4 个**不完整组合**:各缺一块、各有别人没有的一块,
  1438. * 合起来才完整。于是用户想同时抓两种来源时,单选约束直接不允许。
  1439. * 取材是**可累加**的,多条按顺序串行执行(同一台手机只有一个前台,没有并发可言),
  1440. * 共用同一份去重集合与同一个输出文件 —— 这才对得上用户的真实用法。
  1441. *
  1442. * ## 一条取材长什么样
  1443. * `{ id, kind, ...按 kind 决定用哪几个参数..., expand }`
  1444. * 只有 5 种起点,每种需要的参数由 `FIELDS_OF` 声明;界面据此决定显示哪些输入行。
  1445. * **选了什么起点才出现什么输入**,所以不会再有"勾上却无所指"的开关。
  1446. */
  1447. var Sources = {};
  1448. /** 与 Store / Records 共用一份 storages,只是换一个 key(见文件头注释)。 */
  1449. Sources.STORAGE_NAME = "feizhou_app";
  1450. Sources.STORAGE_KEY = "sources";
  1451. /**
  1452. * 最多几条。
  1453. *
  1454. * 5 条刚好覆盖全部五种起点各一条;再多页面会长到滚不完,
  1455. * 而串行跑一轮的真实耗时也不允许(每条都要挨个打开用户页)。
  1456. */
  1457. Sources.MAX = 5;
  1458. /**
  1459. * 五种起点。顺序 = 下拉里的顺序 = 从常见到少见。
  1460. *
  1461. * `take` 是"这个起点能取到什么人",只用于界面说明 ——
  1462. * 执行引擎还没接,所以这里不写任何选择器。
  1463. */
  1464. Sources.KINDS = [
  1465. { key: "anchor", label: "指定账号", take: "它的粉丝 / 关注 / 主页展示的账号" },
  1466. { key: "search", label: "搜索结果", take: "用户搜索结果里的人 / 视频的作者" },
  1467. { key: "video", label: "某个视频的评论区", take: "评论者" },
  1468. { key: "text", label: "文本文件", take: "文件里每一行的 UID" },
  1469. { key: "screen", label: "当前屏幕", take: "此刻屏幕上这个列表里的人" }
  1470. ];
  1471. /**
  1472. * 深度:**最多 2 层**(列表里的人 → 这些人的粉丝)。
  1473. *
  1474. * 第 3 层不给选项是有原因的:100 个粉丝 × 每人 100 个粉丝 = 1 万次用户页打开,
  1475. * 在真机上不现实,也会让账号看起来异常。这是产品约束,不是实现偷懒。
  1476. */
  1477. Sources.EXPANDS = [
  1478. { key: "", label: "不展开" },
  1479. { key: "fans", label: "再取粉丝列表" },
  1480. { key: "following", label: "再取关注列表" }
  1481. ];
  1482. /** `anchor` 起点可以取哪几个列表(多选)。 */
  1483. Sources.LISTS = [
  1484. { key: "fans", label: "粉丝列表" },
  1485. { key: "following", label: "关注列表" },
  1486. { key: "home", label: "主页展示" }
  1487. ];
  1488. /** `search` 起点的两种类型。 */
  1489. Sources.SEARCH_TYPES = [
  1490. { key: "users", label: "用户" },
  1491. { key: "videos", label: "视频作者" }
  1492. ];
  1493. /** `video` 起点的两种来源。 */
  1494. Sources.VIDEO_SOURCES = [
  1495. { key: "screen", label: "当前屏幕" },
  1496. { key: "keyword", label: "关键词搜索" }
  1497. ];
  1498. /**
  1499. * 每种起点**需要哪些参数**。界面与归一化都读这一张表。
  1500. *
  1501. * `lists` 是唯一的多值参数(数组),其余都是字符串。
  1502. */
  1503. Sources.FIELDS_OF = {
  1504. anchor: ["anchors", "lists"],
  1505. search: ["searchType", "keyword"],
  1506. video: ["videoSource", "videoKeyword"],
  1507. text: ["path"],
  1508. screen: []
  1509. };
  1510. /** 该起点是否能再展开一层("当前屏幕"已经是最底了,没得展开)。 */
  1511. Sources.canExpand = function (kind) {
  1512. return kind !== "screen";
  1513. };
  1514. /* ------------------------------------------------------------ 基础工具 */
  1515. function sourceTrim(text) {
  1516. return String(text === undefined || text === null ? "" : text).replace(/^\s+|\s+$/g, "");
  1517. }
  1518. /** 留空就用业务默认值:空输入框既看不出该填什么,也让"没配"和"配了空"分不开。 */
  1519. function withDefault(value, fallback) {
  1520. var text = sourceTrim(value);
  1521. return text === "" ? fallback : text;
  1522. }
  1523. function sourceKeys(list) {
  1524. var out = [];
  1525. for (var i = 0; i < list.length; i++) { out.push(list[i].key); }
  1526. return out;
  1527. }
  1528. /** 生成一个稳定 id。删除与自检都靠它,所以不能只用下标(删除后下标会滑)。 */
  1529. Sources.newId = function () {
  1530. return "src-" + Date.now() + "-" + Math.floor(Math.random() * 10000);
  1531. };
  1532. /* ------------------------------------------------------------ 归一化 */
  1533. /**
  1534. * 把一条取材收拢成合法形状。
  1535. *
  1536. * 两条原则:
  1537. * ① **只保留与该 kind 有关的参数** —— 用户从「指定账号」切到「文本文件」时,
  1538. * 旧的 anchors 不该继续躺在数据里(导出配置时会让人以为它还有用);
  1539. * ② 非法值一律回退到该参数的默认值,**并且不抛异常** ——
  1540. * 一条坏数据不该让整页渲染不出来(与 Store.normalizeField 同一套态度)。
  1541. *
  1542. * @param raw 原始对象(可能缺字段、类型错、来自旧版本)
  1543. * @param forceKind 强制用这个 kind(新建时用),不传则读 raw.kind
  1544. */
  1545. Sources.normalize = function (raw, forceKind) {
  1546. var one = (raw && typeof raw === "object") ? raw : {};
  1547. var kinds = sourceKeys(Sources.KINDS);
  1548. var kind = sourceTrim(forceKind || one.kind);
  1549. if (kinds.indexOf(kind) < 0) { kind = "anchor"; }
  1550. var out = {
  1551. id: sourceTrim(one.id) || Sources.newId(),
  1552. kind: kind
  1553. };
  1554. var fields = Sources.FIELDS_OF[kind];
  1555. for (var i = 0; i < fields.length; i++) {
  1556. var name = fields[i];
  1557. if (name === "lists") {
  1558. var allowed = sourceKeys(Sources.LISTS);
  1559. var picked = [];
  1560. var rawLists = one.lists;
  1561. if (rawLists instanceof Array) {
  1562. for (var j = 0; j < rawLists.length; j++) {
  1563. if (allowed.indexOf(rawLists[j]) >= 0 && picked.indexOf(rawLists[j]) < 0) {
  1564. picked.push(rawLists[j]);
  1565. }
  1566. }
  1567. }
  1568. /* 一个都不选 = 这条取材取不到任何人。回退成"粉丝列表"而不是留空:
  1569. * 留空会让界面出现一条**永远跑不出结果**的取材,而用户看不出为什么。 */
  1570. if (picked.length === 0) { picked.push("fans"); }
  1571. out.lists = picked;
  1572. } else if (name === "searchType") {
  1573. out.searchType = sourceKeys(Sources.SEARCH_TYPES).indexOf(one.searchType) >= 0
  1574. ? one.searchType : "users";
  1575. } else if (name === "videoSource") {
  1576. out.videoSource = sourceKeys(Sources.VIDEO_SOURCES).indexOf(one.videoSource) >= 0
  1577. ? one.videoSource : "screen";
  1578. } else if (name === "keyword" || name === "videoKeyword") {
  1579. out[name] = withDefault(one[name], "fashion");
  1580. } else if (name === "path") {
  1581. out.path = withDefault(one.path, Store.TEXT_DIR + "/uid_list.txt");
  1582. } else if (name === "anchors") {
  1583. /* 账号可以留空:留空 = 不限定、从当前屏幕的列表直接抓。
  1584. * 这是真实且合理的状态,所以不塞假默认值(与 Store 里 anchorIds 同一套规则)。 */
  1585. out.anchors = sourceTrim(one.anchors);
  1586. }
  1587. }
  1588. /* 深度:screen 起点没有展开这一说;其余非法值一律回退"不展开" */
  1589. out.expand = "";
  1590. if (Sources.canExpand(kind)) {
  1591. var expands = sourceKeys(Sources.EXPANDS);
  1592. if (expands.indexOf(one.expand) >= 0) { out.expand = one.expand; }
  1593. }
  1594. return out;
  1595. };
  1596. /* ------------------------------------------------------------ 读写 */
  1597. function sourceStorage() {
  1598. return storages.create(Sources.STORAGE_NAME);
  1599. }
  1600. /**
  1601. * 读全部取材(按顺序)。
  1602. *
  1603. * 读不出来 → 空数组(一条坏数据不该让整个界面起不来);
  1604. * 超过 MAX → 截断(旧版本可能存过更多条)。
  1605. */
  1606. Sources.list = function () {
  1607. var raw = null;
  1608. try {
  1609. raw = sourceStorage().get(Sources.STORAGE_KEY, null);
  1610. } catch (e) {
  1611. console.log("[sources] 读取失败: " + e);
  1612. }
  1613. if (typeof raw === "string" && raw.length > 0) {
  1614. try {
  1615. raw = JSON.parse(raw);
  1616. } catch (e2) {
  1617. console.log("[sources] JSON 解析失败,按空处理");
  1618. raw = null;
  1619. }
  1620. }
  1621. if (!(raw instanceof Array)) { return []; }
  1622. var out = [];
  1623. for (var i = 0; i < raw.length && out.length < Sources.MAX; i++) {
  1624. if (raw[i] && typeof raw[i] === "object") {
  1625. out.push(Sources.normalize(raw[i]));
  1626. }
  1627. }
  1628. return out;
  1629. };
  1630. Sources.save = function (list) {
  1631. var safe = (list instanceof Array) ? list.slice(0, Sources.MAX) : [];
  1632. try {
  1633. sourceStorage().put(Sources.STORAGE_KEY, JSON.stringify(safe));
  1634. return true;
  1635. } catch (e) {
  1636. console.log("[sources] 保存失败: " + e);
  1637. return false;
  1638. }
  1639. };
  1640. /* ------------------------------------------------------------ 增删改 */
  1641. /** 按 id 取一条(界面每次操作都重新读,避免拿渲染时的旧快照覆盖新值)。 */
  1642. Sources.find = function (id) {
  1643. var list = Sources.list();
  1644. for (var i = 0; i < list.length; i++) {
  1645. if (list[i].id === id) { return list[i]; }
  1646. }
  1647. return null;
  1648. };
  1649. Sources.isEmpty = function () {
  1650. return Sources.list().length === 0;
  1651. };
  1652. Sources.count = function () {
  1653. return Sources.list().length;
  1654. };
  1655. /** 追加一条(同时告诉调用方成不成 —— 满了就不该静默失败)。 */
  1656. Sources.add = function (kind) {
  1657. var list = Sources.list();
  1658. if (list.length >= Sources.MAX) { return null; }
  1659. var one = Sources.normalize({ kind: kind || "anchor" });
  1660. list.push(one);
  1661. Sources.save(list);
  1662. return one;
  1663. };
  1664. /**
  1665. * 改一条。`patch` 只合入传进来的键,其余保持。
  1666. *
  1667. * 注意:**改 kind 要重新归一化**(新起点不该继承旧起点的参数)——
  1668. * 所以这里走 normalize(patch 合并后, 新 kind),而不是简单 assign。
  1669. */
  1670. Sources.update = function (id, patch) {
  1671. var list = Sources.list();
  1672. for (var i = 0; i < list.length; i++) {
  1673. if (list[i].id !== id) { continue; }
  1674. var merged = {};
  1675. var k;
  1676. for (k in list[i]) {
  1677. if (list[i].hasOwnProperty(k)) { merged[k] = list[i][k]; }
  1678. }
  1679. for (k in patch) {
  1680. if (patch.hasOwnProperty(k)) { merged[k] = patch[k]; }
  1681. }
  1682. merged.id = id;
  1683. list[i] = Sources.normalize(merged, merged.kind);
  1684. Sources.save(list);
  1685. return list[i];
  1686. }
  1687. return null;
  1688. };
  1689. /**
  1690. * 删第 index 条。
  1691. *
  1692. * 用**下标**而不是 id:界面是按顺序渲染的,用户点的就是"第几条"。
  1693. * 删除后其余条目自动前移,不需要重排 id。
  1694. */
  1695. Sources.removeAt = function (index) {
  1696. var list = Sources.list();
  1697. if (index < 0 || index >= list.length) { return false; }
  1698. list.splice(index, 1);
  1699. Sources.save(list);
  1700. return true;
  1701. };
  1702. Sources.clear = function () {
  1703. try {
  1704. sourceStorage().remove(Sources.STORAGE_KEY);
  1705. } catch (e) {
  1706. console.log("[sources] 清空失败: " + e);
  1707. }
  1708. };
  1709. /* ------------------------------------------------------------ 文案 */
  1710. /** 按 key 取中文名(找不到回退 key 本身 —— 显示 "zzz" 也比 "undefined" 强)。 */
  1711. function labelOf(table, key) {
  1712. for (var i = 0; i < table.length; i++) {
  1713. if (table[i].key === key) { return table[i].label; }
  1714. }
  1715. return key;
  1716. }
  1717. Sources.kindLabel = function (kind) { return labelOf(Sources.KINDS, kind); };
  1718. Sources.expandLabel = function (key) { return labelOf(Sources.EXPANDS, key); };
  1719. /**
  1720. * 一条取材 → 一句话摘要。
  1721. *
  1722. * 用在两个地方:取材卡片的标题行(收起时也要看得出这条是干什么的)、
  1723. * 以及运行记录里的「当前脚本」行(那里放不下一整段配置)。
  1724. */
  1725. Sources.describe = function (one) {
  1726. if (!one) { return ""; }
  1727. var head = "";
  1728. if (one.kind === "anchor") {
  1729. var lists = [];
  1730. for (var i = 0; i < one.lists.length; i++) {
  1731. lists.push(labelOf(Sources.LISTS, one.lists[i]).replace("列表", ""));
  1732. }
  1733. var who = sourceTrim(one.anchors) === "" ? "当前屏幕的账号" : one.anchors.split("#")[0];
  1734. head = who + " · " + lists.join("+");
  1735. } else if (one.kind === "search") {
  1736. head = (one.searchType === "videos" ? "视频作者" : "用户") + "「" + one.keyword + "」";
  1737. } else if (one.kind === "video") {
  1738. head = one.videoSource === "keyword"
  1739. ? "评论区「" + one.videoKeyword + "」"
  1740. : "当前屏幕的评论区";
  1741. } else if (one.kind === "text") {
  1742. var name = String(one.path).split("/").pop();
  1743. head = "文本 " + name;
  1744. } else {
  1745. head = "当前屏幕";
  1746. }
  1747. if (one.expand) { head += " → " + labelOf(Sources.EXPANDS, one.expand).replace("再取", ""); }
  1748. return head;
  1749. };
  1750. /**
  1751. * 整份清单 → 一句话(任务卡与运行记录用)。
  1752. * 空清单返回空串,由调用方决定显示「未配置」还是别的东西。
  1753. */
  1754. Sources.summary = function () {
  1755. var list = Sources.list();
  1756. if (list.length === 0) { return ""; }
  1757. if (list.length === 1) { return Sources.describe(list[0]); }
  1758. return Sources.describe(list[0]) + " 等 " + list.length + " 条";
  1759. };
  1760. /* ------------------------------------------------------------ 旧配置迁移 */
  1761. /** 读 Store 那份 sourceStorage 的**原始** JSON(含 v8 之前的四张卡字段)。 */
  1762. function readLegacyConfig() {
  1763. try {
  1764. var raw = storages.create(Store.STORAGE_NAME).get(Store.STORAGE_KEY, null);
  1765. if (typeof raw === "string" && raw.length > 0) { return JSON.parse(raw); }
  1766. if (raw && typeof raw === "object") { return raw; }
  1767. } catch (e) {
  1768. console.log("[sources] 读取旧配置失败: " + e);
  1769. }
  1770. return null;
  1771. }
  1772. function readPathSafe(obj, path) {
  1773. var segs = String(path).split(".");
  1774. var node = obj;
  1775. for (var i = 0; i < segs.length; i++) {
  1776. if (node === null || typeof node !== "object") { return undefined; }
  1777. node = node[segs[i]];
  1778. }
  1779. return node;
  1780. }
  1781. /**
  1782. * 旧「进入页面」→ 新的「取哪些列表」。
  1783. *
  1784. * `comment` 没有对应:在"从一个账号取它哪个列表"这个语义下,账号页没有"评论页"。
  1785. * 这是 v6 遗留的一处语义模糊,迁移时回退成粉丝列表并记一条日志 ——
  1786. * 用户的意图在 v8 里可以用「某个视频的评论区」这条取材重新表达。
  1787. */
  1788. function listsFromEnterPage(value) {
  1789. if (value === "follow") { return ["following"]; }
  1790. if (value === "home") { return ["home"]; }
  1791. if (value === "comment") {
  1792. console.log("[sources] 旧「进入页面=评论页」在取材清单里没有对应,已按「粉丝列表」迁移");
  1793. return ["fans"];
  1794. }
  1795. return ["fans"];
  1796. }
  1797. /**
  1798. * v7 → v8:把"四张卡里选了一张"翻译成取材清单。**一次性**。
  1799. *
  1800. * 三条原则:
  1801. * ① **已有清单就不动** —— 用户自己加的取材永远不被覆盖(重装/降级都安全);
  1802. * ② **能展开成多条就展开** —— 脚本四的三个开关本来就是三种取材范围,
  1803. * 旧结构只能勾在同一个脚本里,新结构下它们天然是三条独立取材;
  1804. * ③ 超出 `MAX` 要截断并**说出来**,不静默丢。
  1805. *
  1806. * @returns 迁移后的清单;没得迁移(没旧配置 / 已有清单 / 旧配置一条都没选)返回 null
  1807. */
  1808. Sources.migrateLegacy = function () {
  1809. if (Sources.list().length > 0) { return null; }
  1810. var raw = readLegacyConfig();
  1811. if (!raw) { return null; }
  1812. var scripts = readPathSafe(raw, "tabs.id.scripts") || {};
  1813. var items = [];
  1814. /* —— 脚本一:主播账号 + 可选的"搜索用户"和"文本" —— */
  1815. var s1 = scripts.s1 || {};
  1816. if (s1.checked) {
  1817. items.push({
  1818. kind: "anchor",
  1819. anchors: s1.anchorIds || "",
  1820. lists: listsFromEnterPage(s1.enterPage),
  1821. expand: ""
  1822. });
  1823. if (s1.useTextId) {
  1824. items.push({ kind: "text", path: s1.textIdPath, expand: "" });
  1825. }
  1826. if (s1.customPage) {
  1827. items.push({
  1828. kind: "search",
  1829. searchType: "users",
  1830. keyword: s1.customPageKeyword || "",
  1831. expand: ""
  1832. });
  1833. }
  1834. }
  1835. /* —— 脚本二:评论区(+ 可选展开评论者的粉丝) —— */
  1836. var s2 = scripts.s2 || {};
  1837. if (s2.checked) {
  1838. items.push({
  1839. kind: "video",
  1840. videoSource: s2.videoSource || "screen",
  1841. videoKeyword: s2.videoKeyword || "",
  1842. expand: s2.commenterFans ? "fans" : ""
  1843. });
  1844. }
  1845. /* —— 脚本三:文本裂变(旧"进入页面"就是它的展开方向) —— */
  1846. var s3 = scripts.s3 || {};
  1847. if (s3.checked) {
  1848. items.push({
  1849. kind: "text",
  1850. path: s3.fissionTextPath,
  1851. expand: (s3.enterPage === "follow") ? "following" : "fans"
  1852. });
  1853. }
  1854. /* —— 脚本四:三个开关展开成三条取材(旧结构塞不下,新结构天然支持) —— */
  1855. var s4 = scripts.s4 || {};
  1856. if (s4.checked) {
  1857. var kw = s4.keyword || "";
  1858. items.push({
  1859. kind: "search",
  1860. searchType: "videos",
  1861. keyword: kw,
  1862. expand: s4.anchorFans ? "fans" : ""
  1863. });
  1864. if (s4.commentArea) {
  1865. items.push({ kind: "video", videoSource: "keyword", videoKeyword: kw, expand: "" });
  1866. }
  1867. if (s4.commenterFans) {
  1868. items.push({ kind: "video", videoSource: "keyword", videoKeyword: kw, expand: "fans" });
  1869. }
  1870. }
  1871. if (items.length === 0) { return null; }
  1872. if (items.length > Sources.MAX) {
  1873. console.log("[sources] 旧配置翻译出 " + items.length + " 条取材,超过上限 " +
  1874. Sources.MAX + " 条,已截断(多出来的取材可在界面重新添加)");
  1875. items = items.slice(0, Sources.MAX);
  1876. }
  1877. var list = [];
  1878. for (var i = 0; i < items.length; i++) {
  1879. list.push(Sources.normalize(items[i]));
  1880. }
  1881. Sources.save(list);
  1882. return list;
  1883. };
  1884. /* ============================== src/device.js ============================== */
  1885. /* ============================== src/device.js ==============================
  1886. * 设备数据获取层。
  1887. *
  1888. * 界面上的每一项「状态」都来自这里,而不是写死的假数据:
  1889. * 无障碍 / 悬浮窗 / 通知 权限、TikTok 是否安装与版本号、屏幕尺寸、脚本版本。
  1890. *
  1891. * 设计原则(规范 R25「设备端危险能力必须显式声明依赖,不作为主路径」):
  1892. * 本文件所有探测都必须能在「能力缺失」时给出 false / 空值,绝不抛出去。
  1893. * AutoX 没有 try-catch 之外的门禁,一次未捕获异常就会让整个界面起不来。
  1894. */
  1895. var Device = {};
  1896. /* TikTok 有多个发行包名,挨个探测;顺序即优先级。 */
  1897. Device.TIKTOK_PACKAGES = [
  1898. "com.zhiliaoapp.musically",
  1899. "com.ss.android.ugc.trill",
  1900. "com.zhiliaoapp.musically.go"
  1901. ];
  1902. /** 通用取值包装:任何异常都退化为 fallback,并留一条日志便于定位。 */
  1903. function probe(tag, fn, fallback) {
  1904. try {
  1905. var value = fn();
  1906. return value === undefined || value === null ? fallback : value;
  1907. } catch (e) {
  1908. console.log("[device] " + tag + " 探测失败: " + e);
  1909. return fallback;
  1910. }
  1911. }
  1912. /* ------------------------------------------------------------ 屏幕与栏位 */
  1913. Device.screenWidth = function () {
  1914. return probe("screenWidth", function () { return device.width; }, 375);
  1915. };
  1916. Device.screenHeight = function () {
  1917. return probe("screenHeight", function () { return device.height; }, 812);
  1918. };
  1919. /** 系统状态栏高度。取不到时用 24dp 兜底,保证内容不被状态栏压住。 */
  1920. Device.statusBarHeight = function () {
  1921. return probe("statusBarHeight", function () {
  1922. var res = context.getResources();
  1923. var id = res.getIdentifier("status_bar_height", "dimen", "android");
  1924. return id > 0 ? res.getDimensionPixelSize(id) : U.dp(24);
  1925. }, U.dp(24));
  1926. };
  1927. /** 底部导航栏高度。全面屏手势机型会返回 0,此时由「安全区」常量兜底。 */
  1928. Device.navBarHeight = function () {
  1929. return probe("navBarHeight", function () {
  1930. var res = context.getResources();
  1931. var id = res.getIdentifier("navigation_bar_height", "dimen", "android");
  1932. return id > 0 ? res.getDimensionPixelSize(id) : 0;
  1933. }, 0);
  1934. };
  1935. /* ------------------------------------------------------------ 权限状态 */
  1936. /**
  1937. * 无障碍服务。AutoX 里 auto.service 为 null 即表示未连接。
  1938. * 这是本 App 的硬依赖——没有它就点不动 TikTok,所以权限弹窗首行必须显示它。
  1939. */
  1940. Device.a11yEnabled = function () {
  1941. return probe("a11y", function () { return auto.service !== null; }, false);
  1942. };
  1943. /** 悬浮窗权限。用于运行时浮层提示,缺失不影响主流程。 */
  1944. Device.overlayEnabled = function () {
  1945. return probe("overlay", function () { return floaty.checkPermission(); }, false);
  1946. };
  1947. /**
  1948. * 通知权限。Android 13(API 33) 起 POST_NOTIFICATIONS 是运行时权限,
  1949. * 13 以下默认授予——不能统一按「未授予」处理,否则老机型会误报。
  1950. */
  1951. Device.notifyEnabled = function () {
  1952. return probe("notify", function () {
  1953. var sdk = parseInt(android.os.Build.VERSION.SDK, 10);
  1954. if (isNaN(sdk) || sdk < 33) { return true; }
  1955. var granted = context.checkSelfPermission("android.permission.POST_NOTIFICATIONS");
  1956. return granted === android.content.pm.PackageManager.PERMISSION_GRANTED;
  1957. }, true);
  1958. };
  1959. /**
  1960. * 权限弹窗的展示数据,界面直接渲染,不在页面里重复拼装。
  1961. *
  1962. * 两条约定(设计稿 02 与 12 必须完全一致):
  1963. * ① 顺序固定为 无障碍服务 → 悬浮窗权限 → 通知权限;
  1964. * ② 通知标 optional —— 它只在任务完成时提醒你,缺了不影响脚本执行,
  1965. * 所以既排在最后、也不参与「能不能开始运行」的判定。
  1966. * (早先它既没标可选、又排在悬浮窗前面,用户会以为必须开。)
  1967. */
  1968. Device.permissionRows = function () {
  1969. return [
  1970. { key: "a11y", name: "无障碍服务", granted: Device.a11yEnabled() },
  1971. { key: "overlay", name: "悬浮窗权限", granted: Device.overlayEnabled() },
  1972. { key: "notify", name: "通知权限", granted: Device.notifyEnabled(), optional: true }
  1973. ];
  1974. };
  1975. Device.allPermissionsGranted = function () {
  1976. var rows = Device.permissionRows();
  1977. for (var i = 0; i < rows.length; i++) {
  1978. if (!rows[i].granted) { return false; }
  1979. }
  1980. return true;
  1981. };
  1982. /**
  1983. * 「能不能开始运行」只取决于这两个。
  1984. * 与 allPermissionsGranted 分开是必要的:通知是可选权限,
  1985. * 用它拦人会让用户被一个不影响执行的权限挡在门外。
  1986. */
  1987. Device.blockingPermissionsGranted = function () {
  1988. return Device.a11yEnabled() && Device.overlayEnabled();
  1989. };
  1990. /**
  1991. * 打开对应权限的设置页。
  1992. * 用标准 ACTION 常量而不是猜 Activity 名——猜错会抛 ActivityNotFound。
  1993. * 无障碍与悬浮窗没有公开的直跳 Action,退回「应用详情页」这个一定存在的入口。
  1994. */
  1995. Device.openPermissionSettings = function (key) {
  1996. var pkg = context.getPackageName();
  1997. var activity = null;
  1998. var action = null;
  1999. try {
  2000. if (key === "a11y") {
  2001. action = "android.settings.ACCESSIBILITY_SETTINGS";
  2002. } else if (key === "overlay") {
  2003. action = "android.settings.action.MANAGE_OVERLAY_PERMISSION";
  2004. } else if (key === "notify") {
  2005. action = "android.settings.APP_NOTIFICATION_SETTINGS";
  2006. }
  2007. if (action) {
  2008. var intent = new android.content.Intent(action);
  2009. if (key === "overlay") {
  2010. intent.setData(android.net.Uri.parse("package:" + pkg));
  2011. } else {
  2012. intent.putExtra("package", pkg);
  2013. intent.putExtra("android.provider.extra.APP_PACKAGE", pkg);
  2014. }
  2015. activity = intent;
  2016. }
  2017. } catch (e) {
  2018. console.log("[device] 构造权限 Intent 失败: " + e);
  2019. activity = null;
  2020. }
  2021. if (!activity) {
  2022. /* 兜底:应用详情页。所有 Android 版本都有。 */
  2023. activity = new android.content.Intent(
  2024. "android.settings.APPLICATION_DETAILS_SETTINGS",
  2025. android.net.Uri.parse("package:" + pkg)
  2026. );
  2027. }
  2028. return probe("openPermission", function () {
  2029. context.startActivity(activity);
  2030. return true;
  2031. }, false);
  2032. };
  2033. /* ------------------------------------------------------------ TikTok 状态 */
  2034. /** 探测 TikTok 是否安装及版本。返回 { installed, packageName, versionName }。 */
  2035. Device.tiktok = function () {
  2036. var found = { installed: false, packageName: "", versionName: "" };
  2037. for (var i = 0; i < Device.TIKTOK_PACKAGES.length; i++) {
  2038. var name = Device.TIKTOK_PACKAGES[i];
  2039. var info = probe("pkg:" + name, function () {
  2040. return context.getPackageManager().getPackageInfo(name, 0);
  2041. }, null);
  2042. if (info) {
  2043. found.installed = true;
  2044. found.packageName = name;
  2045. found.versionName = info.versionName || "";
  2046. break;
  2047. }
  2048. }
  2049. if (found.installed) {
  2050. console.log("[device] TikTok 已安装 " + found.packageName + " v" + found.versionName);
  2051. } else {
  2052. console.log("[device] 未探测到 TikTok 客户端");
  2053. }
  2054. return found;
  2055. };
  2056. /** 版本号归一化:设计稿展示成 v39.5.4 这种形态。 */
  2057. Device.formatVersion = function (versionName) {
  2058. var v = String(versionName || "").trim();
  2059. if (!v) { return ""; }
  2060. return v.charAt(0) === "v" ? v : "v" + v;
  2061. };
  2062. /* ------------------------------------------------------------ 文件选择 */
  2063. /** 文本文件的候选目录。只扫这几处,不做全盘遍历(全盘扫在真机上要几十秒)。 */
  2064. Device.TEXT_DIRS = ["/sdcard/Download", "/sdcard/Documents", "/sdcard/tiktok_data", "/sdcard"];
  2065. /**
  2066. * 列出可选的文本文件,供「选择文件」按钮弹列表用。
  2067. *
  2068. * 这里刻意**只负责列出、不负责弹窗**:展示交给 UI 层(`W.filePicker` + `Dialog.choose`)。
  2069. * 一来设备层不该依赖弹窗组件,二来 `dialogs.select` 这类阻塞式调用在 UI 模式下
  2070. * 拿不到返回值,选文件会静默失效。
  2071. *
  2072. * 只扫几个约定目录,不做全盘遍历——全盘扫在真机上要几十秒。
  2073. */
  2074. Device.listTextFiles = function () {
  2075. var out = [];
  2076. var seen = {};
  2077. for (var i = 0; i < Device.TEXT_DIRS.length; i++) {
  2078. var dir = Device.TEXT_DIRS[i];
  2079. var names = probe("ls:" + dir, function () { return files.listDir(dir); }, []);
  2080. for (var j = 0; j < names.length; j++) {
  2081. var name = names[j];
  2082. if (!/\.(txt|json|csv)$/i.test(name)) { continue; }
  2083. var full = dir + "/" + name;
  2084. if (seen[full]) { continue; }
  2085. seen[full] = true;
  2086. out.push(full);
  2087. }
  2088. }
  2089. out.sort();
  2090. return out;
  2091. };
  2092. /* ------------------------------------------------------------ 应用信息 */
  2093. Device.appVersionName = function () {
  2094. return probe("appVersion", function () {
  2095. return context.getPackageManager().getPackageInfo(context.getPackageName(), 0).versionName;
  2096. }, "-");
  2097. };
  2098. Device.deviceLabel = function () {
  2099. return probe("deviceLabel", function () {
  2100. return android.os.Build.MODEL + " / Android " + android.os.Build.VERSION.RELEASE;
  2101. }, "-");
  2102. };
  2103. /* ============================== src/service.js ============================== */
  2104. /* ============================== src/service.js ==============================
  2105. * 需要「服务端」的业务能力:卡密校验、检查更新。
  2106. *
  2107. * 这两件事的共同点是:真实场景必须联网核验,但**离线也必须能给出确定行为**。
  2108. * 所以每个能力都是「远端优先 + 本地降级」,且降级模式会在界面上如实标注,
  2109. * 不会把本地通过伪装成服务端通过(规范:不编造测试结果/接口字段)。
  2110. *
  2111. * ⚠️ 本地模式的定位必须说清楚:
  2112. * 仅校验格式,不具备防伪能力。要真防伪,把 CONFIG.licenseEndpoint
  2113. * 指向你自己的校验接口即可——请求/响应结构见 verifyRemote() 注释。
  2114. */
  2115. var Service = {};
  2116. Service.CONFIG = {
  2117. /* 留空 = 本地模式。填写后 login() 会先请求远端。 */
  2118. licenseEndpoint: "",
  2119. licenseTimeoutMs: 8000,
  2120. /* 本地模式下的默认有效期(天),仅用于让「到期时间」这一栏有真实含义。 */
  2121. localTrialDays: 30,
  2122. /* 检查更新的清单地址,留空则用内置版本号自比。 */
  2123. updateEndpoint: ""
  2124. };
  2125. /** 脚本版本。设计稿的「版本信息卡」显示的就是它;build.js 会用构建日期覆盖。 */
  2126. Service.SCRIPT_VERSION = "2026.10.13";
  2127. Service.CHANGELOG = [
  2128. "2026.10.11 数量类设置全部改为「最小值 ~ 最大值」区间(关注/私信/休息/浏览/单次/停止/间隔)",
  2129. "2026.10.11 补齐登录页 / 运行状态条 / 拦截弹窗,ID提取页改单选",
  2130. "2026.10.10 界面层按设计稿重构,配置项全部落盘",
  2131. "2026.10.03 接入设备真实状态(权限 / TikTok 版本)",
  2132. "2026.09.28 首个可运行版本"
  2133. ];
  2134. /* ---------------------------------------------------------------- 卡密 */
  2135. /** 去掉空格与连字符并转大写,得到比较用的规范形式。 */
  2136. Service.normalizeCode = function (code) {
  2137. return String(code || "").replace(/[\s-]/g, "").toUpperCase();
  2138. };
  2139. /**
  2140. * 格式校验:16 位大写字母或数字。
  2141. * 只做形态校验——真伪必须靠服务端,本地无法判定一个随机串是不是有效卡密。
  2142. */
  2143. Service.isWellFormed = function (code) {
  2144. return /^[A-Z0-9]{16}$/.test(Service.normalizeCode(code));
  2145. };
  2146. /** 展示用分组:ABCDEFGHIJKLMNOP → ABCDE-FGHI-JKLM-NOPQ 形态的 4 组。 */
  2147. Service.formatCode = function (code) {
  2148. var n = Service.normalizeCode(code);
  2149. var out = [];
  2150. for (var i = 0; i < n.length; i += 4) { out.push(n.substring(i, i + 4)); }
  2151. return out.join("-");
  2152. };
  2153. function httpPostJson(url, payload) {
  2154. var body = JSON.stringify(payload);
  2155. var response = http.post(url, body, {
  2156. headers: { "Content-Type": "application/json" },
  2157. timeout: Service.CONFIG.licenseTimeoutMs
  2158. });
  2159. if (!response || response.statusCode !== 200) {
  2160. return null;
  2161. }
  2162. try {
  2163. return JSON.parse(response.body.string());
  2164. } catch (e) {
  2165. console.log("[service] 卡密响应不是合法 JSON");
  2166. return null;
  2167. }
  2168. }
  2169. /**
  2170. * 远端校验。
  2171. * 请求:POST {endpoint} {"code":"<规范化卡密>"}
  2172. * 响应:{"ok":true,"expireAt":"2026-11-09"} 或 {"ok":false,"reason":"卡密不存在"}
  2173. * 返回 null 表示「没问到」(网络/格式问题),调用方据此决定是否降级为本地模式。
  2174. */
  2175. Service.verifyRemote = function (code) {
  2176. if (!Service.CONFIG.licenseEndpoint) { return null; }
  2177. var data = null;
  2178. try {
  2179. data = httpPostJson(Service.CONFIG.licenseEndpoint, { code: code });
  2180. } catch (e) {
  2181. console.log("[service] 卡密远端校验异常: " + e);
  2182. return null;
  2183. }
  2184. if (!data) { return null; }
  2185. return {
  2186. ok: !!data.ok,
  2187. expireAt: data.expireAt || "",
  2188. reason: data.reason || "卡密无效"
  2189. };
  2190. };
  2191. /** 今天 + N 天,格式 yyyy-MM-dd。用于本地模式生成到期时间。 */
  2192. Service.datePlusDays = function (days) {
  2193. var now = new Date();
  2194. now.setDate(now.getDate() + Number(days || 0));
  2195. var m = now.getMonth() + 1;
  2196. var d = now.getDate();
  2197. return now.getFullYear() + "-" + (m < 10 ? "0" + m : m) + "-" + (d < 10 ? "0" + d : d);
  2198. };
  2199. Service.today = function () {
  2200. return Service.datePlusDays(0);
  2201. };
  2202. /** 过期判定:只比日期字符串(yyyy-MM-dd 字典序即时间序),不引时区计算。 */
  2203. Service.isExpired = function (expireAt) {
  2204. if (!expireAt) { return false; }
  2205. return Service.today() > String(expireAt);
  2206. };
  2207. /**
  2208. * 登录。返回 { ok, status, message }。
  2209. * status: "active" | "expired" | "none"
  2210. */
  2211. Service.login = function (code) {
  2212. var normalized = Service.normalizeCode(code);
  2213. if (!Service.isWellFormed(normalized)) {
  2214. return { ok: false, status: "none", message: "卡密格式不对,应为 16 位字母或数字" };
  2215. }
  2216. var remote = Service.verifyRemote(normalized);
  2217. if (remote) {
  2218. if (!remote.ok) {
  2219. return { ok: false, status: "none", message: remote.reason };
  2220. }
  2221. if (Service.isExpired(remote.expireAt)) {
  2222. return { ok: false, status: "expired", message: "该卡密已于 " + remote.expireAt + " 到期" };
  2223. }
  2224. return {
  2225. ok: true,
  2226. status: "active",
  2227. code: normalized,
  2228. expireAt: remote.expireAt,
  2229. message: "登录成功(服务端核验)"
  2230. };
  2231. }
  2232. /* 本地模式:只有格式校验,如实告知,不伪装成服务端通过。 */
  2233. return {
  2234. ok: true,
  2235. status: "active",
  2236. code: normalized,
  2237. expireAt: Service.datePlusDays(Service.CONFIG.localTrialDays),
  2238. message: "登录成功(本地模式,未联网核验)"
  2239. };
  2240. };
  2241. /** 读当前卡密状态,并顺手处理「已过期但状态还写着 active」的情况。 */
  2242. Service.licenseStatus = function () {
  2243. var status = Store.getEnum("license.status", "licenseStatus");
  2244. var expireAt = Store.getText("license.expireAt");
  2245. var code = Store.getText("license.code");
  2246. if (status === "active" && Service.isExpired(expireAt)) {
  2247. Store.set("license.status", "expired");
  2248. status = "expired";
  2249. }
  2250. return { status: status, code: code, expireAt: expireAt };
  2251. };
  2252. /**
  2253. * 状态 → 界面文案与配色 key。集中在这里,避免页面各自判断。
  2254. *
  2255. * ⚠️ 返回值里必须带 `status` —— 页面除了文案还要按状态分支
  2256. * (已登录才显示「解绑」,否则显示「去登录」)。
  2257. * 早先只返回 text/tone/expireAt,页面写 `display.status === "active"` 永远是 false,
  2258. * 结果是「已登录」旁边挂着「去登录」,自相矛盾但没有任何报错。
  2259. */
  2260. Service.licenseDisplay = function () {
  2261. var st = Service.licenseStatus();
  2262. if (st.status === "active") {
  2263. return { status: st.status, text: "已登录", tone: "ok", expireAt: st.expireAt || "—" };
  2264. }
  2265. if (st.status === "expired") {
  2266. return { status: st.status, text: "已过期", tone: "warn", expireAt: st.expireAt || "—" };
  2267. }
  2268. return { status: st.status, text: "未登录", tone: "warn", expireAt: "—" };
  2269. };
  2270. Service.logout = function () {
  2271. Store.set("license.status", "none");
  2272. Store.set("license.code", "");
  2273. Store.set("license.expireAt", "");
  2274. };
  2275. /* ---------------------------------------------------------------- 更新 */
  2276. /**
  2277. * 检查更新。远端不可达时退化为「与内置版本号自比」,
  2278. * 结论同样如实标注来源,避免把「没连上」说成「已是最新」。
  2279. */
  2280. Service.checkUpdate = function () {
  2281. var current = Service.SCRIPT_VERSION;
  2282. if (Service.CONFIG.updateEndpoint) {
  2283. var data = null;
  2284. try {
  2285. var response = http.get(Service.CONFIG.updateEndpoint, { timeout: 8000 });
  2286. if (response && response.statusCode === 200) {
  2287. data = JSON.parse(response.body.string());
  2288. }
  2289. } catch (e) {
  2290. console.log("[service] 检查更新失败: " + e);
  2291. }
  2292. if (data && data.version) {
  2293. return {
  2294. latest: String(data.version),
  2295. hasUpdate: String(data.version) > current,
  2296. source: "remote",
  2297. note: data.note || ""
  2298. };
  2299. }
  2300. return { latest: current, hasUpdate: false, source: "unknown", note: "检查失败,请稍后重试" };
  2301. }
  2302. return { latest: current, hasUpdate: false, source: "local", note: "已是最新" };
  2303. };
  2304. /* ============================== src/fingerprint.js ============================== */
  2305. /**
  2306. * fingerprint.js — 配置指纹(界面侧)
  2307. *
  2308. * ★ **逐字节同算法的一份拷贝**,源头在 `tiktok-auto/src/fingerprint.js`。
  2309. *
  2310. * 为什么必须有两份:界面要在"点继续之前"就判断断点还有没有效(不折腾用户手机),
  2311. * 引擎要在"启动 App 之前"再判断一次(用户也可能直接改配置手起引擎)。
  2312. * 项目没有共享模块机制,只能各放一份。
  2313. *
  2314. * **改算法必须两边一起改**:两侧算出的指纹不同 = 界面说"能续跑"、引擎说"配置变了"。
  2315. * `tiktok-auto/tools/pc-selftest-id.js` 有一组断言专门比对两份实现的结果。
  2316. */
  2317. var Fingerprint = (function () {
  2318. 'use strict';
  2319. var VERSION = 1;
  2320. /**
  2321. * 是不是数组。
  2322. *
  2323. * 不用 `v instanceof Array`:跨 realm(PC 自检把 Node 造的对象喂进 vm 沙箱)时
  2324. * `instanceof` 会返回 false,那会让"数组"被当成普通对象序列化,两边算出的指纹就不一样了。
  2325. *
  2326. * 也不用 `toString` 配 `Function.prototype.call` 的写法:那个关键字加左括号
  2327. * 命中 MCP 危险调用黑名单(`tools/build.js` 的 `DANGEROUS_RULES`),会让每次下发都要人工确认。
  2328. * 优先 `Array.isArray`(ES5,AutoX 的 Rhino 支持),没有时退回 `.apply`。
  2329. */
  2330. function isArray(v) {
  2331. if (typeof Array.isArray === 'function') {
  2332. return Array.isArray(v);
  2333. }
  2334. return Object.prototype.toString.apply(v, []) === '[object Array]';
  2335. }
  2336. /** 自有键,排序后返回(键序无关 → 同一份配置换个键序不会让断点失效) */
  2337. function ownKeys(v) {
  2338. var keys = [];
  2339. for (var k in v) {
  2340. if (Object.prototype.hasOwnProperty.apply(v, [k]) === true) {
  2341. keys.push(k);
  2342. }
  2343. }
  2344. keys.sort();
  2345. return keys;
  2346. }
  2347. /**
  2348. * 稳定序列化:对象按键名排序、数组保持顺序、每个值带类型前缀。
  2349. *
  2350. * 类型前缀是必须的 —— 否则 `"1"`(字符串)和 `1`(数字)会序列化成同一个串,
  2351. * 而界面上"粉丝下限"这类字段确实可能一个是空串一个是 0。
  2352. */
  2353. function canon(v) {
  2354. if (v === null || v === undefined) {
  2355. return 'n';
  2356. }
  2357. if (isArray(v)) {
  2358. var parts = [];
  2359. for (var i = 0; i < v.length; i += 1) {
  2360. parts.push(canon(v[i]));
  2361. }
  2362. return '[' + parts.join(',') + ']';
  2363. }
  2364. if (typeof v === 'object') {
  2365. var keys = ownKeys(v);
  2366. var out = [];
  2367. for (var j = 0; j < keys.length; j += 1) {
  2368. out.push(keys[j] + ':' + canon(v[keys[j]]));
  2369. }
  2370. return '{' + out.join(',') + '}';
  2371. }
  2372. if (typeof v === 'number') {
  2373. return 'd' + String(v);
  2374. }
  2375. if (typeof v === 'boolean') {
  2376. return v ? 'T' : 'F';
  2377. }
  2378. return 's' + JSON.stringify(String(v));
  2379. }
  2380. /**
  2381. * djb2-xor 摘要。
  2382. *
  2383. * 末尾拼上原文长度:32 位散列碰撞概率本来就不高,加个长度等于多一维区分,
  2384. * 而且是"免费"的(长度本来就要算)。输出是 base36,比十六进制短。
  2385. */
  2386. function digest(text) {
  2387. var s = String(text);
  2388. var h = 5381;
  2389. for (var i = 0; i < s.length; i += 1) {
  2390. h = (((h << 5) + h) ^ s.charCodeAt(i)) | 0; /* |0 截成 32 位,避免精度丢失 */
  2391. }
  2392. return (h >>> 0).toString(36) + '-' + s.length.toString(36);
  2393. }
  2394. /** 把一条取材归一化成"只含参与指纹的字段",缺项补默认 */
  2395. function ofSource(one, index) {
  2396. var s = one || {};
  2397. return {
  2398. key: s.key || ('i' + index + ':' + (s.kind || '')),
  2399. kind: s.kind || '',
  2400. anchors: s.anchors || '',
  2401. lists: isArray(s.lists) ? s.lists : [],
  2402. searchType: s.searchType || '',
  2403. keyword: s.keyword || '',
  2404. videoSource: s.videoSource || '',
  2405. videoKeyword: s.videoKeyword || '',
  2406. path: s.path || '',
  2407. expand: s.expand || ''
  2408. };
  2409. }
  2410. /**
  2411. * 算 `id` 段的指纹。
  2412. *
  2413. * **分项存**(sources / filter / output 各一份)而不只存一个总串:
  2414. * 界面要能说清"是**取材**改过了"而不是含糊的"配置变了",用户才知道该改回去什么。
  2415. *
  2416. * @param id `config.id` 整段(界面传 `Engine.buildIdConfig().config.id`,
  2417. * 引擎传解析后的 `config.id` —— 两者是同一份数据)
  2418. */
  2419. function ofIdSection(id) {
  2420. var section = id || {};
  2421. var list = isArray(section.sources) ? section.sources : [];
  2422. var sources = [];
  2423. for (var i = 0; i < list.length; i += 1) {
  2424. sources.push(ofSource(list[i], i));
  2425. }
  2426. var f = section.filter || {};
  2427. var o = section.output || {};
  2428. var sourcesHash = digest(canon(sources));
  2429. var filterHash = digest(canon({
  2430. private: f.private === true,
  2431. noAvatar: f.noAvatar === true,
  2432. langRegion: f.langRegion || 'any',
  2433. nicknameCheck: f.nicknameCheck || 'any',
  2434. follow: f.follow || {},
  2435. fans: f.fans || {},
  2436. likes: f.likes || {}
  2437. }));
  2438. var outputHash = digest(canon({
  2439. dir: o.dir || '',
  2440. historyPath: o.historyPath || '',
  2441. dedupeHistory: o.dedupeHistory !== false
  2442. }));
  2443. return {
  2444. v: VERSION,
  2445. sources: sourcesHash,
  2446. filter: filterHash,
  2447. output: outputHash,
  2448. all: digest(sourcesHash + filterHash + outputHash)
  2449. };
  2450. }
  2451. /**
  2452. * 比两份指纹。
  2453. * @returns { same, changed: ['sources'|'filter'|'output'] }
  2454. */
  2455. function compare(a, b) {
  2456. if (!a || !b || a.v !== VERSION || b.v !== VERSION) {
  2457. return { same: false, changed: ['sources', 'filter', 'output'] };
  2458. }
  2459. var changed = [];
  2460. if (a.sources !== b.sources) {
  2461. changed.push('sources');
  2462. }
  2463. if (a.filter !== b.filter) {
  2464. changed.push('filter');
  2465. }
  2466. if (a.output !== b.output) {
  2467. changed.push('output');
  2468. }
  2469. return { same: changed.length === 0, changed: changed };
  2470. }
  2471. return {
  2472. VERSION: VERSION,
  2473. canon: canon,
  2474. digest: digest,
  2475. ofIdSection: ofIdSection,
  2476. compare: compare
  2477. };
  2478. }());
  2479. /* ============================== src/engine.js ============================== */
  2480. /* ============================== src/engine.js ==============================
  2481. * 执行引擎桥接:把界面收集的配置交给 tiktok-auto,再把它的进度读回来。
  2482. *
  2483. * ## 为什么走文件,不走内存
  2484. * 界面与被唤起的引擎是**两个独立的 ScriptEngine**:各有各的全局作用域,
  2485. * 界面里的对象传不过去(实测确认)。所以两边只通过约定好的四个文件说话:
  2486. *
  2487. * run-config.json 界面 → 人/追溯:完整配置快照(人可读)
  2488. * engine-config.json 界面 → 引擎:映射后的引擎配置
  2489. * run-state.json 引擎 → 界面:进度与终态
  2490. * stop.flag 界面 → 引擎:停止请求(内容是本次运行的 runId)
  2491. *
  2492. * 文件通道是**异步**的,所以界面这边的规则是:**所有动作以状态文件的回报为准**,
  2493. * 而不是"我点了就当我做了"。点「停止运行」后界面显示"正在收尾"、
  2494. * 等到引擎真的上报 stopped 才切回未运行 —— 否则界面说"已停止"而 TikTok 还在自己动,
  2495. * 用户不知道该信谁。
  2496. *
  2497. * ## 为什么不删 stop.flag
  2498. * 停止靠**比内容**而不是靠"文件在不在":只有内容等于本次 runId 才算数。
  2499. * 于是不必删除它(删除既有竞态,`files.remove` 又命中 MCP 危险调用黑名单),
  2500. * 上一轮残留的标志也不会误杀下一轮。
  2501. *
  2502. * ## 能力边界要如实说
  2503. * tiktok-auto 目前实现的是「搜索 → 逐个看 → 评论」。界面「执行操作」里的
  2504. * 关注 / 私信 / 点赞**尚未接入**,`buildConfig()` 会把它们逐条记进 notes,
  2505. * 由调用方决定怎么告知用户 —— **绝不静默忽略**:
  2506. * 用户勾了点赞却什么都没发生,比明确告知"尚未支持"糟得多。
  2507. */
  2508. var Engine = {};
  2509. Engine.DIR = "/sdcard/tiktok_app";
  2510. Engine.CONFIG_PATH = Engine.DIR + "/engine-config.json";
  2511. Engine.STATE_PATH = Engine.DIR + "/run-state.json";
  2512. Engine.STOP_FLAG_PATH = Engine.DIR + "/stop.flag";
  2513. /**
  2514. * 引擎的日志文件(jsonl,一行一条)。
  2515. *
  2516. * 2026-10-13 新增:D3 运行详情里的「执行日志」要看的就是它。
  2517. * 为什么走文件而不是读 console:引擎是**另一个 ScriptEngine**,
  2518. * 它的 stdout 界面根本读不到(见本文件头的通道说明)。
  2519. * 引擎侧由 `logger.setSink` → `files.append` 落盘(tiktok-auto/src/bridge.js)。
  2520. */
  2521. Engine.LOG_PATH = Engine.DIR + "/run-log.jsonl";
  2522. /** 一条记录最多带走多少行日志(记录要落盘,不能让日志把它撑爆)。 */
  2523. Engine.LOG_MAX_LINES = 200;
  2524. /** 引擎产物在项目里的相对位置(项目模式下从这个位置找) */
  2525. Engine.SCRIPT_RELATIVE = "tiktok-auto/dist/tiktok-auto.js";
  2526. /** 本次运行的标识。由界面生成并同时告诉引擎与停止标志,三方比对。 */
  2527. Engine.RUN_ID = "";
  2528. /** 上次定位到的引擎产物路径;null 表示还没找过 */
  2529. Engine.scriptPath = null;
  2530. /**
  2531. * 是否**确实在引擎列表里见过**子引擎。
  2532. *
  2533. * 用途唯一:给"引擎没写终态就没了"的兜底判定加一道保险。
  2534. * `engines.all()` 在不同运行时里的可见性不完全一致 —— 如果它从来就不报告子引擎,
  2535. * 那"看不到子引擎"这件事毫无信息量,此时宁可不判定,也不要误把正在跑的运行结束掉。
  2536. */
  2537. Engine.sawChild = false;
  2538. /* ------------------------------------------------------------ 运行标识 */
  2539. /**
  2540. * 生成本次运行的标识。
  2541. * 用「时间戳 + 随机串」:同一秒内连点两次也不会撞,
  2542. * 而且人在日志里一眼能看出这是哪一次运行。
  2543. */
  2544. Engine.newRunId = function () {
  2545. return "run-" + Date.now() + "-" + Math.floor(Math.random() * 100000);
  2546. };
  2547. /* ------------------------------------------------------------ 定位引擎 */
  2548. /** 探测用的候选路径,按「最可能正确」排序。 */
  2549. Engine.candidates = function () {
  2550. var list = [];
  2551. /* ① 入口脚本同级 —— 项目模式下最准。
  2552. * 用 myEngine().source 而不是 files.cwd():后者依赖运行方式,
  2553. * 前者永远是入口脚本的真实路径。 */
  2554. try {
  2555. var source = String(engines.myEngine().source || "");
  2556. var cut = source.lastIndexOf("/");
  2557. if (cut > 0) {
  2558. list.push(source.substring(0, cut) + "/" + Engine.SCRIPT_RELATIVE);
  2559. }
  2560. } catch (e) {
  2561. /* 取不到就靠后面的候选 */
  2562. }
  2563. /* ② 扫一层项目目录:产物可能来自另一个项目文件夹 */
  2564. try {
  2565. var names = files.listDir("/sdcard/Scripts");
  2566. for (var i = 0; i < names.length; i += 1) {
  2567. var dir = "/sdcard/Scripts/" + names[i];
  2568. if (files.isDir(dir)) {
  2569. list.push(dir + "/" + Engine.SCRIPT_RELATIVE);
  2570. }
  2571. }
  2572. } catch (e) {
  2573. /* 没这个目录就跳过 */
  2574. }
  2575. /* ③ 扁平部署:产物被单独拷到 Download(手工推送时的常见形态) */
  2576. list.push("/sdcard/Download/tiktok-auto.js");
  2577. list.push(Engine.DIR + "/tiktok-auto.js");
  2578. return list;
  2579. };
  2580. /**
  2581. * 找到引擎产物。
  2582. * @returns 路径;找不到返回空串(调用方负责给出可操作的提示)
  2583. */
  2584. Engine.locate = function () {
  2585. var list = Engine.candidates();
  2586. for (var i = 0; i < list.length; i += 1) {
  2587. try {
  2588. if (files.exists(list[i])) {
  2589. Engine.scriptPath = list[i];
  2590. return list[i];
  2591. }
  2592. } catch (e) {
  2593. /* 单个候选探测失败不该中断整轮查找 */
  2594. }
  2595. }
  2596. Engine.scriptPath = null;
  2597. return "";
  2598. };
  2599. /* ------------------------------------------------------------ 配置映射 */
  2600. function trimAll(text) {
  2601. return String(text).replace(/^\s+|\s+$/g, "");
  2602. }
  2603. /**
  2604. * 读文本文件的行,跳过空行与 `#` 开头的注释行。
  2605. * 读不到(文件不存在 / 权限问题)时返回空数组 —— 调用方按"没配"处理。
  2606. */
  2607. function readLines(path) {
  2608. var out = [];
  2609. try {
  2610. if (!path || !files.exists(path)) { return out; }
  2611. var lines = String(files.read(path)).split(/\r?\n/);
  2612. for (var i = 0; i < lines.length; i += 1) {
  2613. var text = trimAll(lines[i]);
  2614. if (text !== "" && text.charAt(0) !== "#") { out.push(text); }
  2615. }
  2616. } catch (e) {
  2617. console.log("[engine] 读取文本失败 " + path + ": " + e);
  2618. }
  2619. return out;
  2620. }
  2621. function randomInt(min, max) {
  2622. var low = Math.floor(min);
  2623. var high = Math.floor(max);
  2624. if (high < low) { high = low; }
  2625. return low + Math.floor(Math.random() * (high - low + 1));
  2626. }
  2627. /** 秒 → 毫秒,并给一个下限:0 秒会让"观看"立刻返回,等于没看 */
  2628. function seconds(ms) {
  2629. return Math.max(1000, Math.floor(ms) * 1000);
  2630. }
  2631. /** 分钟 → 毫秒。下限给 1 分钟:填 0 会让"浏览时长"立刻到点,一轮都跑不完 */
  2632. function minutesToMs(value) {
  2633. return Math.max(60000, Math.floor(value) * 60 * 1000);
  2634. }
  2635. /**
  2636. * 概率档位(`r30`)→ 小数(0.30)。
  2637. *
  2638. * 认不出来就返回 0 —— **宁可不做,不可乱做**:把非法值当成某个默认概率,
  2639. * 用户看到的档位和引擎实际执行的概率就悄悄不一致了。
  2640. */
  2641. function rateOf(key) {
  2642. var matched = String(key || "").match(/^r(\d+)$/);
  2643. if (!matched) {
  2644. return 0;
  2645. }
  2646. return Number(matched[1]) / 100;
  2647. }
  2648. /**
  2649. * 界面配置 → 引擎配置。
  2650. *
  2651. * @param task 当前要跑的任务(`App.startRun` 传进来的那个对象,含 `task` 字段)
  2652. * @returns { config, notes }
  2653. * notes 是「用户表达了意图、但引擎这次做不到」的条目,逐条是人话。
  2654. */
  2655. Engine.buildConfig = function (task, opts) {
  2656. /* 两条链路的配置形状完全不同(视频链路是"搜索→看→评",ID提取是"取材清单"),
  2657. * 所以在这里分派,而不是往一个函数里塞两套 if —— 那样两边都会变得难读且易改错。 */
  2658. if (task && task.task === "id") {
  2659. return Engine.buildIdConfig(opts);
  2660. }
  2661. if (task && task.task === "nurture") {
  2662. return Engine.buildNurtureConfig();
  2663. }
  2664. if (task && task.task === "dm") {
  2665. return Engine.buildDmConfig();
  2666. }
  2667. return Engine.buildVideoConfig();
  2668. };
  2669. /**
  2670. * UID 私信参数 → 引擎配置。
  2671. *
  2672. * 界面的「关注数量」「私信数量」都是**区间**,引擎每次运行在区间里随机取一个数 ——
  2673. * 固定不变会让每次运行发出去的量完全一样,反而更容易被看出是脚本。
  2674. *
  2675. * 两批是**完全不同的人**:前 `follow.max` 个只关注,接下来 `send.max` 个只发私信。
  2676. * 所以 UID 文件里的号要够多(至少 关注上限 + 私信上限)。
  2677. */
  2678. Engine.buildDmConfig = function () {
  2679. var notes = [];
  2680. /* 两个脚本是单选(页面侧 `toggleDmScript` 保证互斥)。万一都勾着(手改过配置、
  2681. * 或将来某次改动破坏了互斥),按脚本一处理 —— 那是"有明确输入"的那条,
  2682. * 比静默换源安全;但**必须说出来**,否则用户以为跑的是脚本二。 */
  2683. var s1On = Store.getBool("tabs.dm.scripts.s1.checked");
  2684. var s2On = Store.getBool("tabs.dm.scripts.s2.checked");
  2685. var useS2 = s2On && !s1On;
  2686. var script = useS2 ? "s2" : "s1";
  2687. if (s1On && s2On) {
  2688. notes.push("两个私信脚本都勾着,本轮按脚本一执行(这一页是单选)");
  2689. }
  2690. var followOn = Store.getBool("tabs.dm.doFollow");
  2691. var sendOn = Store.getBool("tabs.dm.doDm");
  2692. var followLo = Store.getNumber("tabs.dm.followMin");
  2693. var followHi = Store.getNumber("tabs.dm.followMax");
  2694. var sendLo = Store.getNumber("tabs.dm.dmMin");
  2695. var sendHi = Store.getNumber("tabs.dm.dmMax");
  2696. var restLo = Store.getNumber("tabs.dm.restMin");
  2697. var restHi = Store.getNumber("tabs.dm.restMax");
  2698. /* ★ 兜底:设备上可能还留着**最初的旧默认** 100~500 秒(迁移在某些历史版本上没生效)。
  2699. * 那个值会让一轮跑 5~24 小时,实际跑不完 —— 所以这里再兜一次。
  2700. * 只在"正好等于旧默认"时改,并记一条 note 让用户知道(要用旧值就显式改一下)。 */
  2701. if (restLo === 100 && restHi === 500) {
  2702. restLo = 10;
  2703. restHi = 40;
  2704. notes.push("休息时间仍是最初的 100~500 秒(一轮要跑 5~24 小时),本次按 10~40 秒执行");
  2705. }
  2706. var pool = readLines(Store.getText("tabs.dm.dmTextPath"));
  2707. if (sendOn && pool.length === 0) {
  2708. notes.push("私信文案文件是空的,本轮发不出私信(请检查「随机私信文本」)");
  2709. }
  2710. if (useS2) {
  2711. notes.push("脚本二「自己粉丝私信」:从你自己的粉丝列表取材(先回关 " +
  2712. "「关注数量」个,再给「私信数量」个发私信)");
  2713. }
  2714. var config = {
  2715. task: "dm",
  2716. dm: {
  2717. script: script,
  2718. /* 脚本二不用这个路径(人从粉丝列表采);传空串而不是留着旧路径 ——
  2719. * 留着会让日志里出现一个"看起来在用的文件",排障时误导。 */
  2720. uidPath: useS2 ? "" : Store.getText("tabs.dm.scripts.s1.uidTextPath"),
  2721. /* 每次运行在区间里随机取一个数 */
  2722. follow: { enabled: followOn, max: followOn ? randomInt(followLo, followHi) : 0 },
  2723. send: { enabled: sendOn, max: sendOn ? randomInt(sendLo, sendHi) : 0 },
  2724. rest: { minMs: seconds(restLo), maxMs: seconds(restHi) },
  2725. dedupeMode: Store.getEnum("tabs.dm.dedupeMode", "dedupeMode"),
  2726. filterDate: Store.getEnum("tabs.dm.scripts.s1.filterDate", "filterDate"),
  2727. firstAction: Store.getEnum("tabs.dm.scripts.s1.firstAction", "firstAction"),
  2728. pool: pool,
  2729. commentPool: readLines(Store.getText("tabs.dm.commentTextPath")),
  2730. guard: { maxRuntimeMs: 5 * 60 * 60 * 1000 }
  2731. },
  2732. runtime: { dryRun: false, maxRuntimeMs: 0 }
  2733. };
  2734. return { config: config, notes: notes };
  2735. };
  2736. /**
  2737. * 养号参数 → 引擎配置。
  2738. *
  2739. * 界面只有 7 个字段,其中两对是区间:
  2740. * · 浏览时长(分钟) → nurture.browse
  2741. * · 单次操作 → nurture.rounds(**是轮数,不是时间间隔**)
  2742. * · 点赞/关注概率 → 档位 r30 → 0.30
  2743. * · 评论概率 → **不接**(见下)
  2744. */
  2745. Engine.buildNurtureConfig = function () {
  2746. var notes = [];
  2747. var roundsMin = Store.getNumber("tabs.nurture.actionMin");
  2748. var roundsMax = Store.getNumber("tabs.nurture.actionMax");
  2749. var browseMin = Store.getNumber("tabs.nurture.browseMin");
  2750. var browseMax = Store.getNumber("tabs.nurture.browseMax");
  2751. var likeRate = rateOf(Store.getEnum("tabs.nurture.likeRate", "likeRate"));
  2752. var followRate = rateOf(Store.getEnum("tabs.nurture.followRate", "followRate"));
  2753. var commentWanted = rateOf(Store.getEnum("tabs.nurture.commentRate", "commentRate"));
  2754. /* ★ 评论强制 0(2026-10-28 实测结论)。
  2755. *
  2756. * 养号是在**首页信息流**里跑的。那里的评论面板一打开,右侧竖排动作栏
  2757. * (关注/点赞/评论/收藏/分享/音乐)的无障碍节点仍然留在树里,且与输入框同高;
  2758. * 而真正那个"发送"按钮**在无障碍树里根本没有对应节点**
  2759. * (把输入框整行右侧的所有节点 dump 出来,只有动作栏那六个
  2760. * + 每条评论自己的赞/表情 + 作者头像,没有发送键)。
  2761. *
  2762. * 引擎是 `execScriptFile` 起的子脚本,`gesture()` 在这个环境里无效、
  2763. * `shell('input tap')` 没有注入权限(SecurityException)——
  2764. * 唯一能用的无障碍点击打不到一个"树里不存在"的按钮。
  2765. *
  2766. * 所以这里如实传 0 并把原因写进 notes,让界面告诉用户"本轮不会评论",
  2767. * 而不是传一个会让引擎白试一轮、最后只留下一条 warning 的概率。 */
  2768. if (commentWanted > 0) {
  2769. notes.push("评论:当前 TikTok 版本不支持自动发送(发送键不在无障碍树里),本轮不会评论");
  2770. }
  2771. var config = {
  2772. task: "nurture",
  2773. nurture: {
  2774. /* 「单次操作」= 轮数(看一个视频 + 可能互动,算一轮) */
  2775. rounds: { min: roundsMin, max: roundsMax },
  2776. /* 「浏览时长」= 墙钟上限。与轮数**各自独立**,谁先到谁停 */
  2777. browse: { minMs: minutesToMs(browseMin), maxMs: minutesToMs(browseMax) },
  2778. likeRate: likeRate,
  2779. commentRate: 0,
  2780. followRate: followRate
  2781. },
  2782. comments: { enabled: false, pool: [] },
  2783. runtime: { dryRun: false, maxRuntimeMs: 0 }
  2784. };
  2785. return { config: config, notes: notes };
  2786. };
  2787. /* ------------------------------------------------------------ ID 提取 */
  2788. /** ID 提取的产物目录(与其它通道文件同根,方便一起清理/查看) */
  2789. Engine.ID_DIR = "/sdcard/Download/tiktok_app";
  2790. /**
  2791. * 断点文件(ID提取的"跑到哪了")。
  2792. *
  2793. * 与产物同目录:用户清产物时看得见它;而且它引用的是 runFile 的绝对路径,
  2794. * 放在一起语义最直白。引擎侧同一份路径由 `id.resume.path` 传下去。
  2795. */
  2796. Engine.ID_PROGRESS_PATH = Engine.ID_DIR + "/id_progress.json";
  2797. /**
  2798. * 界面 → 引擎(task = 'id')。
  2799. *
  2800. * ## 取材清单几乎是原样透传
  2801. * `Sources` 模块当初就是**照着引擎侧的契约**设计的(`kind` + 每种起点的参数 + `expand`),
  2802. * 所以这里不需要再翻译一遍字段名。少一层翻译就少一处"路径拼错=静默丢值"的机会。
  2803. *
  2804. * ## 做不到的项一律进 notes
  2805. * 这是项目一贯的规矩:用户勾了却什么都没发生,比明确告知"这次不生效"糟得多。
  2806. */
  2807. Engine.buildIdConfig = function (opts) {
  2808. var notes = [];
  2809. var base = "tabs.id.features.";
  2810. var sources = Sources.list();
  2811. var engineSources = [];
  2812. for (var i = 0; i < sources.length; i++) {
  2813. var one = sources[i];
  2814. /* `key` 就是界面的 `id`:断点按它认"哪条取材做完了"。
  2815. * 以前刻意不带(引擎不需要),但续跑必须带 —— 否则断点里的条目和配置里的
  2816. * 条目只能靠下标对齐,用户删一条就全错位。 */
  2817. engineSources.push({
  2818. key: one.id || "",
  2819. kind: one.kind,
  2820. anchors: one.anchors || "",
  2821. lists: one.lists || [],
  2822. searchType: one.searchType || "users",
  2823. keyword: one.keyword || "",
  2824. videoSource: one.videoSource || "screen",
  2825. videoKeyword: one.videoKeyword || "",
  2826. path: one.path || "",
  2827. expand: one.expand || ""
  2828. });
  2829. }
  2830. var config = {
  2831. task: "id",
  2832. id: {
  2833. sources: engineSources,
  2834. filter: {
  2835. private: Store.getBool(base + "filterPrivate"),
  2836. noAvatar: Store.getBool(base + "filterNoAvatar"),
  2837. langRegion: Store.getEnum(base + "langRegion", "langRegion"),
  2838. nicknameCheck: Store.getEnum(base + "nicknameCheck", "nicknameCheck"),
  2839. /* 四组区间:界面是"最小值 / 最大值"两格,0 表示该侧不设限 */
  2840. follow: { min: Store.getNumber(base + "followMin"), max: Store.getNumber(base + "followMax") },
  2841. fans: { min: Store.getNumber(base + "fansMin"), max: Store.getNumber(base + "fansMax") },
  2842. likes: { min: Store.getNumber(base + "likesMin"), max: Store.getNumber(base + "likesMax") },
  2843. videos: { min: Store.getNumber(base + "videosMin"), max: Store.getNumber(base + "videosMax") }
  2844. },
  2845. output: {
  2846. dir: Engine.ID_DIR,
  2847. historyPath: Engine.ID_DIR + "/uid_extracted.txt",
  2848. dedupeHistory: Store.getBool(base + "dedupeHistory"),
  2849. dryRun: false,
  2850. /* 0 = 不限。引擎另有「单次最长运行」与「解析 uid 独立预算」两道时长护栏 */
  2851. limit: Store.getNumber(base + "extractLimit"),
  2852. /* 抓完之后换数字 uid。默认开(数字 uid 在界面上读不到,不换就得事后另跑),
  2853. * 每个约多花 2.5 秒 —— 「输出」卡片上给了开关,用户可关。 */
  2854. resolveUid: Store.getBool(base + "resolveUid")
  2855. },
  2856. guard: {
  2857. /* 抓取阶段最长多久(分钟 → 毫秒)。到点收尾,已抓的不丢。 */
  2858. maxRuntimeMs: Math.max(1, Store.getNumber(base + "maxRuntimeMin")) * 60 * 1000
  2859. },
  2860. /**
  2861. * 断点续跑。**只有点「继续」时才为 true**。
  2862. *
  2863. * 引擎侧看到它才会去读断点文件;普通「开始运行」传 false,
  2864. * 于是"想从头跑一次"永远是从头跑(不会被上一轮的断点悄悄接管)。
  2865. */
  2866. resume: {
  2867. enabled: !!(opts && opts.resume),
  2868. path: Engine.ID_PROGRESS_PATH
  2869. }
  2870. },
  2871. runtime: {
  2872. configPath: Engine.CONFIG_PATH,
  2873. dryRun: false,
  2874. maxRuntimeMs: 0
  2875. }
  2876. };
  2877. notes = notes.concat(Engine.unsupportedNotes({ task: "id" }));
  2878. /* 「本地去重」关掉时要说清楚后果:它不只是"少一道过滤",
  2879. * 而是同一个 ID 会在产物文件里重复出现,下游私信会重复发。 */
  2880. if (!Store.getBool(base + "dedupeLocal")) {
  2881. notes.push("\u672C\u5730\u53BB\u91CD\uFF1A\u5DF2\u5173\u95ED\uFF0C\u540C\u4E00\u4E2A ID \u53EF\u80FD\u91CD\u590D\u5199\u5165");
  2882. }
  2883. return { config: config, notes: notes };
  2884. };
  2885. /* ------------------------------------------------------------ 视频链路 */
  2886. Engine.buildVideoConfig = function () {
  2887. var notes = [];
  2888. var keyword = Store.getText("tabs.video.keyword");
  2889. var stopMin = Store.getNumber("tabs.video.stopMin");
  2890. var stopMax = Store.getNumber("tabs.video.stopMax");
  2891. var intervalMin = Store.getNumber("tabs.video.intervalMin");
  2892. var intervalMax = Store.getNumber("tabs.video.intervalMax");
  2893. var doComment = Store.getBool("tabs.video.actions.comment");
  2894. var pool = readLines(Store.getText("tabs.video.commentTextPath"));
  2895. if (doComment && pool.length === 0) {
  2896. notes.push("\u8BC4\u8BBA\u6587\u672C\u91CC\u6CA1\u6709\u53EF\u7528\u884C\uFF0C"
  2897. + "\u672C\u6B21\u4F1A\u7528\u5185\u7F6E\u9ED8\u8BA4\u8BC4\u8BBA");
  2898. }
  2899. var config = {
  2900. target: {
  2901. keyword: keyword,
  2902. /* 「停止个数 最小值 ~ 最大值」= 每次运行在这个区间里随机停 ——
  2903. * 固定不变会让每次运行的行为完全一样,反而更容易被看出是脚本。 */
  2904. maxVideos: randomInt(stopMin, stopMax),
  2905. resultTab: "Top",
  2906. pickStrategy: "random"
  2907. },
  2908. /* 界面的「间隔时间(秒)」映射为**每个视频的观看时长**。
  2909. * 引擎目前没有单独的"两个视频之间的停顿"配置,而"看 20 秒再操作"
  2910. * 与"每 20 秒处理一个"在行为上是一回事。 */
  2911. watch: {
  2912. minMs: seconds(intervalMin),
  2913. maxMs: seconds(intervalMax)
  2914. },
  2915. /* 点赞 / 关注:与评论同级的两个动作开关。
  2916. * 界面上「执行操作」是四项多选,用户勾了才做 —— 没勾就一个都不点。 */
  2917. likes: {
  2918. enabled: Store.getBool("tabs.video.actions.like")
  2919. },
  2920. follows: {
  2921. enabled: Store.getBool("tabs.video.actions.follow")
  2922. },
  2923. comments: {
  2924. /* ★ 勾了「评论」才发评论。没勾就只是浏览 ——
  2925. * 引擎侧据此跳过评论动作,而不是照默认值照发。
  2926. * 用户的意图优先于引擎的默认行为。 */
  2927. enabled: doComment,
  2928. avoidRepeatWithinRun: true,
  2929. verifyInList: true
  2930. },
  2931. runtime: {
  2932. configPath: Engine.CONFIG_PATH,
  2933. dryRun: false,
  2934. maxRuntimeMs: 0
  2935. }
  2936. };
  2937. if (pool.length > 0) {
  2938. config.comments.pool = pool;
  2939. }
  2940. notes = notes.concat(Engine.unsupportedNotes({ task: "video" }));
  2941. return { config: config, notes: notes };
  2942. };
  2943. /**
  2944. * 引擎这次做不到、但用户可能勾了的项。
  2945. *
  2946. * 单独抽出来是因为它只跟「已实现的动作集合」有关,
  2947. * 将来引擎接入了点赞,**只改这一个函数**就够。
  2948. */
  2949. Engine.unsupportedNotes = function (task) {
  2950. var notes = [];
  2951. if (task && task.task === "id") {
  2952. return Engine.unsupportedIdNotes();
  2953. }
  2954. /* 点赞与关注已由引擎实现(src/interact.js),不再报"尚未实现"。
  2955. * 只剩私信一项还在等(见 UID私信 任务)。 */
  2956. if (Store.getBool("tabs.video.actions.dm")) {
  2957. notes.push("\u79C1\u4FE1\u52A8\u4F5C\uFF1A\u5F15\u64CE\u5C1A\u672A\u5B9E\u73B0");
  2958. }
  2959. return notes;
  2960. };
  2961. /**
  2962. * ID 提取里"用户可能配了、但这次不生效"的项。
  2963. *
  2964. * 判据是**偏离默认值**,而不是"有值" —— 这一条很关键:
  2965. * 功能设置里有好几项的默认值本身就是非空的(`regionCodes = "JP#TW#US"`、
  2966. * `aiAgeMax = 20`、`videosMax = 500000`)。若按"有值就报",
  2967. * 每次运行都会冒出一串"未生效",把真正需要注意的那条淹没 ——
  2968. * 而 note 的本意是"你表达了意图,这次做不到",默认值并不代表任何意图。
  2969. */
  2970. Engine.unsupportedIdNotes = function () {
  2971. var notes = [];
  2972. var base = "tabs.id.features.";
  2973. /** 这一项被用户改过吗(与默认值不同) */
  2974. function changed(path) {
  2975. return String(Store.get(path)) !== String(Store.defaultOf(path));
  2976. }
  2977. /* AI 三项:需要模型,本次不做。界面上是灰行,理论上改不动;
  2978. * 老配置里可能残留过值,所以仍然按"改过才报"来处理。 */
  2979. if (changed(base + "aiAvatar") && Store.getBool(base + "aiAvatar")) {
  2980. notes.push("AI \u5934\u50CF\u8BC6\u522B\uFF1A\u5C1A\u672A\u63A5\u5165");
  2981. }
  2982. if (changed(base + "aiGender") && Store.getBool(base + "aiGender")) {
  2983. notes.push("AI \u6027\u522B\u8BC6\u522B\uFF1A\u5C1A\u672A\u63A5\u5165");
  2984. }
  2985. if (changed(base + "aiAgeMax") && Store.getNumber(base + "aiAgeMax") > 0) {
  2986. notes.push("AI \u5E74\u9F84\u8BC6\u522B\uFF1A\u5C1A\u672A\u63A5\u5165");
  2987. }
  2988. /* 云端去重:契约已定但服务端没接。降级为本地去重并**说出来**,绝不静默。 */
  2989. if (Store.getBool(base + "cloudDedupe")) {
  2990. notes.push("\u4E91\u7AEF\u53BB\u91CD\uFF1A\u670D\u52A1\u672A\u63A5\u5165\uFF0C\u672C\u6B21\u53EA\u7528\u672C\u5730\u53BB\u91CD");
  2991. }
  2992. /* 视频量门槛:真机实测用户页**只显示 关注/粉丝/获赞 三组数字,没有视频数**。
  2993. * 引擎侧对这条的处理是"放行 + 告知"(见 tiktok-auto/src/filters.js 文件头)。
  2994. * 默认上限 500000 在语义上等于"不限",所以只有用户真的收窄了才报。 */
  2995. if (changed(base + "videosMin") || changed(base + "videosMax")) {
  2996. notes.push("\u89C6\u9891\u91CF\u95E8\u69DB\uFF1A\u7528\u6237\u9875\u4E0D\u663E\u793A\u8BE5\u6570\u5B57\uFF0C\u672C\u6B21\u4E0D\u53C2\u4E0E\u8FC7\u6EE4");
  2997. }
  2998. /* 无头像靠像素判定,纯黑白照片会被误判 —— 用户勾了就得先知道这个局限。 */
  2999. if (Store.getBool(base + "filterNoAvatar")) {
  3000. notes.push("\u65E0\u5934\u50CF\u68C0\u6D4B\uFF1A\u6309\u50CF\u7D20\u8272\u5F69\u5224\u5B9A\uFF0C\u7EAF\u9ED1\u767D\u7167\u7247\u4F1A\u88AB\u8BEF\u5224");
  3001. }
  3002. /* 「提取区域」刻意**不进 notes**:它在界面上是一行灰字 + 「暂不支持」胶囊,
  3003. * 用户已经看得到;每次运行再报一次只会变成噪音。它的值仍保留在配置里,
  3004. * 将来接上数据源时直接生效。 */
  3005. return notes;
  3006. };
  3007. /**
  3008. * notes → 一句能放在 toast 里的话(没有就返回空串)。
  3009. *
  3010. * 只取每条的**短名**(中文冒号前的部分)拼起来:三条完整说明拼起来有四十多个字,
  3011. * 那种长度的 toast 用户根本来不及读。完整内容在 run-config.json 里,随时可查。
  3012. */
  3013. Engine.summarizeNotes = function (notes) {
  3014. if (!notes || notes.length === 0) { return ""; }
  3015. var names = [];
  3016. for (var i = 0; i < notes.length; i += 1) {
  3017. names.push(String(notes[i]).split("\uFF1A")[0]);
  3018. }
  3019. return names.join("/") + " \u672C\u6B21\u4E0D\u4F1A\u751F\u6548";
  3020. };
  3021. /* ------------------------------------------------------------ 启动 */
  3022. /**
  3023. * 准备一次运行:定位产物 + 构建配置。
  3024. *
  3025. * @param task 当前要跑的任务(含 `task` 字段);不传按视频链路处理(向后兼容)
  3026. * @returns { ok, scriptPath, config, notes, error }
  3027. * error 是给用户看的(说清楚"缺什么、放哪"),不是技术异常串。
  3028. */
  3029. Engine.prepare = function (task, opts) {
  3030. var path = Engine.locate();
  3031. if (!path) {
  3032. return {
  3033. ok: false,
  3034. error: "\u627E\u4E0D\u5230\u6267\u884C\u5F15\u64CE\u4EA7\u7269\uFF08" + Engine.SCRIPT_RELATIVE + "\uFF09\u3002\n\n"
  3035. + "\u8BF7\u5728 PC \u4E0A\u6267\u884C node tiktok-auto/tools/build.js \u540E\uFF0C"
  3036. + "\u5C06 tiktok-auto/dist/tiktok-auto.js \u653E\u5230 \u9879\u76EE\u6839\u76EE\u5F55\u4E0B,\n"
  3037. + "\u6216\u76F4\u63A5\u63A8\u5230 /sdcard/Download/tiktok-auto.js\u3002"
  3038. };
  3039. }
  3040. var built = Engine.buildConfig(task, opts);
  3041. /* 「关键词为空」是**视频链路**的硬前提(空关键词搜索必失败)。
  3042. * ID 提取与养号都不需要关键词:前者可以只从粉丝列表或文本文件取材,
  3043. * 后者是在首页信息流里随机刷、根本没有目标。
  3044. * 所以这条拦截必须按任务分支 —— 不分支的话,养号一点运行就会
  3045. * 崩在 `built.config.target.keyword`(target 根本不存在,TypeError),
  3046. * 而且崩得**毫无提示**(AutoX 上脚本直接退出,界面回桌面)。 */
  3047. if (task && task.task === "id") {
  3048. if (!built.config.id.sources || built.config.id.sources.length === 0) {
  3049. return {
  3050. ok: false,
  3051. scriptPath: path,
  3052. error: "\u8BF7\u5148\u5728\u300CID\u63D0\u53D6\u300D\u9875\u6DFB\u52A0\u81F3\u5C11\u4E00\u6761\u53D6\u6750\u3002"
  3053. };
  3054. }
  3055. return {
  3056. ok: true,
  3057. scriptPath: path,
  3058. config: built.config,
  3059. notes: built.notes
  3060. };
  3061. }
  3062. if (task && task.task === "nurture") {
  3063. /* 养号没有"必填项"要拦:轮数与时长都有默认值,概率档位也总是合法的。
  3064. * 直接放行(真正的保护在引擎侧:config.validate 会校验 rounds/browse)。 */
  3065. return {
  3066. ok: true,
  3067. scriptPath: path,
  3068. config: built.config,
  3069. notes: built.notes
  3070. };
  3071. }
  3072. if (task && task.task === "dm") {
  3073. /* 私信也走这里早返回 —— 它的配置里同样**没有 `target`**。
  3074. * 不早返回的话会落到下面那句 `built.config.target.keyword` 上直接 TypeError,
  3075. * 而且崩得毫无提示(脚本退出、界面回桌面,只有 logcat 能看到)。 */
  3076. return {
  3077. ok: true,
  3078. scriptPath: path,
  3079. config: built.config,
  3080. notes: built.notes
  3081. };
  3082. }
  3083. if (trimAll(built.config.target.keyword) === "") {
  3084. return {
  3085. ok: false,
  3086. scriptPath: path,
  3087. error: "\u5173\u952E\u8BCD\u4E3A\u7A7A\uFF0C\u8BF7\u5148\u5728\u300C\u89C6\u9891\u529F\u80FD\u300D\u9875\u586B\u5199\u8981\u641C\u7D22\u7684\u5173\u952E\u8BCD\u3002"
  3088. };
  3089. }
  3090. return {
  3091. ok: true,
  3092. scriptPath: path,
  3093. config: built.config,
  3094. notes: built.notes
  3095. };
  3096. };
  3097. /** 把引擎配置落盘。返回是否成功。 */
  3098. Engine.writeConfig = function (config) {
  3099. try {
  3100. files.ensureDir(Engine.DIR + "/");
  3101. files.write(Engine.CONFIG_PATH, JSON.stringify(config, null, 2));
  3102. return true;
  3103. } catch (e) {
  3104. console.log("[engine] 引擎配置落盘失败: " + e);
  3105. return false;
  3106. }
  3107. };
  3108. /**
  3109. * 唤起引擎(另一个 ScriptEngine,不是当前脚本的线程)。
  3110. *
  3111. * 参数用 `--key value` 的平铺形式传:Rhino 会把数组包装成 {"0":..,"1":..},
  3112. * 桥那边按数字下标取,两边约定越简单越不容易出错。
  3113. */
  3114. Engine.launch = function (runId, scriptPath, opts) {
  3115. try {
  3116. var argv = [
  3117. "--config", Engine.CONFIG_PATH,
  3118. "--state", Engine.STATE_PATH,
  3119. "--stop-flag", Engine.STOP_FLAG_PATH,
  3120. "--log", Engine.LOG_PATH,
  3121. "--run-id", runId
  3122. ];
  3123. /* 只有点「继续」才带这两个参数。普通「开始运行」绝不能带 ——
  3124. * 不然用户想从头跑一次,结果它接着上次跑。 */
  3125. if (opts && opts.resume) {
  3126. argv.push("--resume");
  3127. argv.push("--checkpoint");
  3128. argv.push(Engine.ID_PROGRESS_PATH);
  3129. }
  3130. var handle = engines.execScriptFile(scriptPath || Engine.scriptPath, {
  3131. arguments: argv
  3132. });
  3133. if (!handle) {
  3134. return { ok: false, error: "\u5F15\u64CE\u542F\u52A8\u8FD4\u56DE\u7A7A\uFF08\u53EF\u80FD\u88AB\u7CFB\u7EDF\u62E6\u622A\uFF09" };
  3135. }
  3136. return { ok: true };
  3137. } catch (e) {
  3138. console.log("[engine] 唤起失败: " + e);
  3139. return { ok: false, error: String(e) };
  3140. }
  3141. };
  3142. /* ------------------------------------------------------------ 日志回读 */
  3143. /**
  3144. * 读引擎这次运行写下的日志(D3 的「执行日志」)。
  3145. *
  3146. * 三条原则:
  3147. * · **一行坏了只丢一行**:jsonl 逐行 parse,坏行跳过 —— 进程被杀时最后一行
  3148. * 常常是半截的,整份 JSON.parse 会直接全丢;
  3149. * · 只取**最近** LOG_MAX_LINES 行:记录要落盘,日志不能把它撑到几百 KB;
  3150. * · 读不到就返回空数组,由页面如实说明「本次没有回传日志」,
  3151. * 绝不编一行"看起来像日志"的东西。
  3152. */
  3153. Engine.readLog = function () {
  3154. var out = [];
  3155. try {
  3156. if (!files.exists(Engine.LOG_PATH)) { return out; }
  3157. var lines = String(files.read(Engine.LOG_PATH)).split("\n");
  3158. var start = Math.max(0, lines.length - 1 - Engine.LOG_MAX_LINES);
  3159. for (var i = start; i < lines.length; i++) {
  3160. var text = String(lines[i]).replace(/^\s+|\s+$/g, "");
  3161. if (text === "") { continue; }
  3162. try {
  3163. var one = JSON.parse(text);
  3164. if (one && one.text) { out.push(one); }
  3165. } catch (e) {
  3166. /* 半截行:跳过这一行,其余照收 */
  3167. }
  3168. }
  3169. } catch (e2) {
  3170. console.log("[engine] 读取运行日志失败: " + e2);
  3171. }
  3172. return out;
  3173. };
  3174. /* ------------------------------------------------------------ 状态回读 */
  3175. /** 读引擎上报的状态。没有 / 读坏了都返回 null。 */
  3176. Engine.readState = function () {
  3177. try {
  3178. if (!files.exists(Engine.STATE_PATH)) { return null; }
  3179. var parsed = JSON.parse(String(files.read(Engine.STATE_PATH)));
  3180. return parsed && typeof parsed === "object" ? parsed : null;
  3181. } catch (e) {
  3182. return null;
  3183. }
  3184. };
  3185. /** 运行中(含正在收尾)—— 界面据此继续显示状态条 */
  3186. Engine.isActiveStatus = function (status) {
  3187. return status === "starting" || status === "running" || status === "stopping";
  3188. };
  3189. /** 终态 —— 界面据此结束运行态并告知结果 */
  3190. Engine.isTerminalStatus = function (status) {
  3191. return status === "finished" || status === "stopped" || status === "failed";
  3192. };
  3193. /** 请求停止:写停止标志(内容 = runId,引擎比对通过才会停) */
  3194. Engine.requestStop = function (runId) {
  3195. try {
  3196. files.ensureDir(Engine.DIR + "/");
  3197. files.write(Engine.STOP_FLAG_PATH, String(runId));
  3198. return true;
  3199. } catch (e) {
  3200. console.log("[engine] 写停止标志失败: " + e);
  3201. return false;
  3202. }
  3203. };
  3204. /**
  3205. * 引擎是否还活着。
  3206. *
  3207. * 用途只有一个:**兜底判定"引擎没写终态就没了"**(被系统杀掉、被手工停掉)。
  3208. * 查不出来时返回 true(当作还活着)—— 宁可多显示一会儿"运行中",
  3209. * 也不要误判成"已结束"而把运行态清掉、让用户以为跑完了。
  3210. */
  3211. Engine.childAlive = function () {
  3212. try {
  3213. var mine = engines.myEngine();
  3214. var all = engines.all() || [];
  3215. for (var i = 0; i < all.length; i += 1) {
  3216. var one = all[i];
  3217. if (one && String(one.id) !== String(mine.id)) {
  3218. return true;
  3219. }
  3220. }
  3221. return false;
  3222. } catch (e) {
  3223. return true;
  3224. }
  3225. };
  3226. /** 复位内存态。**不删文件**:状态靠 runId 比对,残留自然失效。 */
  3227. Engine.reset = function () {
  3228. Engine.RUN_ID = "";
  3229. };
  3230. /* ============================== src/session.js ============================== */
  3231. /* ============================== src/session.js ==============================
  3232. * 运行会话状态:跑没跑、跑的是哪个脚本、跑了多久。
  3233. *
  3234. * 为什么单独一个模块,而不是塞进 Store:
  3235. * Store 是**持久化**的配置,而「正在运行」是**进程内**的瞬时状态。
  3236. * 把它写进配置会有两个后果:
  3237. * ① 上次没点「停止运行」就杀进程,下次启动界面会显示成「运行中」——
  3238. * 可 TikTok 那边根本没人在驱动,用户等半天以为卡住了;
  3239. * ② 计时起点会被迫落盘,进程重启后算出来的时长是错的。
  3240. * 所以这里只存在于内存里,冷启动一律是「未运行」。
  3241. *
  3242. * 真正的执行由 tiktok-auto 那套脚本负责,本模块只负责
  3243. * 「界面上的运行态」与「给执行侧的那份运行配置」保持一致。
  3244. */
  3245. var Session = {};
  3246. Session.running = false;
  3247. Session.tab = "";
  3248. Session.scriptKey = "";
  3249. Session.scriptName = "";
  3250. Session.startedAt = 0;
  3251. /** 本次运行的标识。与引擎上报的状态、停止标志三者比对,缺一不可。 */
  3252. Session.runId = "";
  3253. /** 引擎上报的阶段文案("搜索 fashion" / "已完成 2/5"…)。空串表示还没收到。 */
  3254. Session.phase = "";
  3255. /** 进度计数,由引擎上报。仅用于状态条,不参与判断。 */
  3256. Session.done = 0;
  3257. Session.total = 0;
  3258. /** 已发出停止请求、正在等引擎确认。 */
  3259. Session.stopping = false;
  3260. /** 发出停止请求的时刻,用于超时兜底(引擎不确认也不能一直等)。 */
  3261. Session.stopRequestedAt = 0;
  3262. /* 计时器句柄。用 setInterval 而不是 threads.start:
  3263. * UI 模式下 setInterval 的回调跑在主线程的 Looper 上,可以安全改 View;
  3264. * 而 threads.start + ui.run 那条链路在真机上改 TextView 会崩
  3265. * (见 page-mine.js 里 checkUpdateAsync 的注释)。 */
  3266. Session.tickHandle = null;
  3267. /**
  3268. * 启动一次运行。
  3269. *
  3270. * @param runId 引擎桥的运行标识。界面用它过滤状态文件 ——
  3271. * 设备上可能残留上一轮的状态,不比对就会把旧结果当成本轮结果。
  3272. */
  3273. Session.start = function (tab, scriptKey, scriptName, runId) {
  3274. Session.running = true;
  3275. Session.tab = tab;
  3276. Session.scriptKey = scriptKey;
  3277. Session.scriptName = scriptName;
  3278. Session.startedAt = Date.now();
  3279. Session.runId = runId || "";
  3280. Session.phase = "";
  3281. Session.done = 0;
  3282. Session.total = 0;
  3283. Session.stopping = false;
  3284. Session.stopRequestedAt = 0;
  3285. };
  3286. Session.stop = function () {
  3287. Session.running = false;
  3288. Session.tab = "";
  3289. Session.scriptKey = "";
  3290. Session.scriptName = "";
  3291. Session.startedAt = 0;
  3292. Session.runId = "";
  3293. Session.phase = "";
  3294. Session.done = 0;
  3295. Session.total = 0;
  3296. Session.stopping = false;
  3297. Session.stopRequestedAt = 0;
  3298. };
  3299. /**
  3300. * 运行条主文案(设计稿 B2:`● UID私信 · 脚本一 · 运行中`)。
  3301. *
  3302. * 三段拼装:任务名 · 脚本短名 · 状态。
  3303. * 脚本短名与任务名相同时**不重复显示**(视频功能页只有一个脚本,
  3304. * 拼出来会变成「视频功能 · 关键词视频功能 · 运行中」,白占一半宽度)。
  3305. *
  3306. * 「正在收尾」是刻意保留的一个中间态:点了停止之后引擎还要几秒才真的停手
  3307. * (要退出当前等待、回到信息流)。这时界面如果说「已停止」,
  3308. * 而 TikTok 还在自己动,用户不知道该信谁 —— 如实说"正在收尾"更对。
  3309. */
  3310. Session.barText = function () {
  3311. var taskName = Pages.taskName(Session.tab);
  3312. var short = Pages.shortScriptName(Session.scriptName);
  3313. /* 脚本短名与任务名重合(或已包含任务名,如「视频功能」⊂「关键词视频功能」)时
  3314. * 不再重复一遍:拼出来会变成「视频功能 · 关键词视频功能 · 运行中」,白占一半宽度。 */
  3315. var dup = short === "" || short === taskName || short.indexOf(taskName) >= 0;
  3316. var head = dup ? taskName : (taskName + " \u00B7 " + short);
  3317. if (Session.stopping) { return head + " \u00B7 \u6B63\u5728\u6536\u5C3E"; }
  3318. if (Session.phase) { return head + " \u00B7 " + Session.phase; }
  3319. return head + " \u00B7 \u8FD0\u884C\u4E2D";
  3320. };
  3321. /** 兼容旧调用点(自检里按这个名字断言过)。语义就是 barText。 */
  3322. Session.statusText = function () {
  3323. return Session.barText();
  3324. };
  3325. /** 已运行毫秒数。未运行时返回 0,避免调用方拿到负数。 */
  3326. Session.elapsedMs = function () {
  3327. if (!Session.running || !Session.startedAt) { return 0; }
  3328. return Math.max(0, Date.now() - Session.startedAt);
  3329. };
  3330. /** 毫秒 → 01:24:37 形态。固定两位,等宽字下不会左右跳。 */
  3331. Session.formatElapsed = function (ms) {
  3332. var total = Math.floor(ms / 1000);
  3333. var h = Math.floor(total / 3600);
  3334. var m = Math.floor((total % 3600) / 60);
  3335. var s = total % 60;
  3336. return pad2(h) + ":" + pad2(m) + ":" + pad2(s);
  3337. };
  3338. function pad2(n) {
  3339. return n < 10 ? "0" + n : String(n);
  3340. }
  3341. /**
  3342. * 每秒推一次计时。
  3343. *
  3344. * onTick 只做「改一个 TextView 的文字」,不重建界面——
  3345. * 每秒重建整页会让滚动位置归零,用户根本没法边跑边看配置。
  3346. */
  3347. Session.startTimer = function (onTick) {
  3348. Session.stopTimer();
  3349. onTick();
  3350. Session.tickHandle = setInterval(function () {
  3351. /* 回调里任何异常都不能让它继续每秒抛一次,否则日志会被刷爆 */
  3352. try {
  3353. onTick();
  3354. } catch (e) {
  3355. console.log("[session] 计时刷新失败: " + e);
  3356. Session.stopTimer();
  3357. }
  3358. }, 1000);
  3359. };
  3360. Session.stopTimer = function () {
  3361. if (Session.tickHandle !== null) {
  3362. clearInterval(Session.tickHandle);
  3363. Session.tickHandle = null;
  3364. }
  3365. };
  3366. /* ============================== src/resume.js ============================== */
  3367. /**
  3368. * resume.js — 断点续跑的界面侧(读断点、判断能不能续、清空)
  3369. *
  3370. * ## 职责边界
  3371. * 界面**不写**断点 —— 断点是引擎在跑的时候写的(它才知道"这条取材走完了")。
  3372. * 界面只做三件事:读出来、判断还能不能用、在用户点「继续」时把开关传给引擎。
  3373. * 唯一会写的一处是「清空断点」:覆盖成 `state:"none"`(**不删文件**,
  3374. * `remove(` 命中 MCP 危险调用黑名单,见 `tiktok-auto/tools/build.js`)。
  3375. *
  3376. * ## 为什么判断要放在界面
  3377. * 用户点「继续」之前就得知道"这个断点还用不用得上",否则会出现
  3378. * 点了继续 → 引擎启动 → 打开 TikTok → 才发现配置变了 → 白折腾一趟。
  3379. * 引擎侧也会再判一次(有人可能直接改配置手起引擎),两处判据一致
  3380. * (指纹算法在 `fingerprint.js`,两份逐字节相同)。
  3381. *
  3382. * ## 与记录页的关系
  3383. * 断点里带 `runId`,记录里也带 `runId` —— 只有两者相等才认为"这条记录就是那个断点"。
  3384. * 不这么做的话,用户会在一堆历史「手动停止」记录上都看到「继续」,
  3385. * 而其中只有最后一条是真的能续的。
  3386. */
  3387. var Resume = (function () {
  3388. 'use strict';
  3389. var PATH = Engine.ID_PROGRESS_PATH;
  3390. var VERSION = 1;
  3391. function nameOf(key) {
  3392. if (key === 'sources') { return '取材'; }
  3393. if (key === 'filter') { return '过滤条件'; }
  3394. if (key === 'output') { return '输出设置(目录 / 跨轮去重)'; }
  3395. return key;
  3396. }
  3397. /** 读断点。解析不了就当"没有断点"(界面不显示提示条,绝不报错吓人) */
  3398. function read() {
  3399. try {
  3400. if (!files.exists(PATH)) {
  3401. return null;
  3402. }
  3403. var text = String(files.read(PATH) || '');
  3404. if (text.replace(/\s/g, '') === '') {
  3405. return null;
  3406. }
  3407. var cp = JSON.parse(text);
  3408. if (!cp || cp.v !== VERSION) {
  3409. return null;
  3410. }
  3411. return cp;
  3412. } catch (e) {
  3413. console.log("[resume] 断点文件读不懂(当作没有断点): " + e);
  3414. return null;
  3415. }
  3416. }
  3417. function stoppedAt(cp) {
  3418. var list = cp && cp.sources ? cp.sources : [];
  3419. for (var i = 0; i < list.length; i++) {
  3420. if (list[i].status !== 'done') {
  3421. return list[i].label || '';
  3422. }
  3423. }
  3424. return '';
  3425. }
  3426. function countBy(cp) {
  3427. var list = cp && cp.sources ? cp.sources : [];
  3428. var done = 0;
  3429. for (var i = 0; i < list.length; i++) {
  3430. if (list[i].status === 'done') {
  3431. done += 1;
  3432. }
  3433. }
  3434. return { done: done, total: list.length };
  3435. }
  3436. /**
  3437. * 一次读盘算出"能不能续",供提示条与记录页共用。
  3438. *
  3439. * @returns {
  3440. * exists, cp, valid, finished, reason, message, changed[],
  3441. * extracted, runFile, stoppedAt, doneSources, totalSources
  3442. * }
  3443. */
  3444. function snapshot() {
  3445. var cp = read();
  3446. var out = {
  3447. exists: !!cp,
  3448. cp: cp,
  3449. valid: false,
  3450. finished: false,
  3451. reason: '',
  3452. message: '',
  3453. changed: [],
  3454. extracted: 0,
  3455. runFile: '',
  3456. stoppedAt: '',
  3457. doneSources: 0,
  3458. totalSources: 0
  3459. };
  3460. if (!cp) {
  3461. out.reason = 'no-checkpoint';
  3462. return out;
  3463. }
  3464. out.runFile = cp.runFile || '';
  3465. out.stoppedAt = stoppedAt(cp);
  3466. out.extracted = (cp.counts && typeof cp.counts.extracted === 'number')
  3467. ? cp.counts.extracted : 0;
  3468. var prog = countBy(cp);
  3469. out.doneSources = prog.done;
  3470. out.totalSources = prog.total;
  3471. if (cp.state === 'none') {
  3472. out.reason = 'cleared';
  3473. return out;
  3474. }
  3475. if (cp.state === 'done') {
  3476. out.finished = true;
  3477. out.reason = 'last-complete';
  3478. return out;
  3479. }
  3480. var sig = Fingerprint.ofIdSection(Engine.buildIdConfig().config.id);
  3481. var cmp = Fingerprint.compare(cp.sig, sig);
  3482. if (!cmp.same) {
  3483. var names = [];
  3484. for (var i = 0; i < cmp.changed.length; i++) {
  3485. names.push(nameOf(cmp.changed[i]));
  3486. }
  3487. out.changed = cmp.changed;
  3488. out.reason = 'config-changed';
  3489. out.message = names.join('、') + '改过了,只能重新开始';
  3490. return out;
  3491. }
  3492. if (!out.runFile || !files.exists(out.runFile)) {
  3493. out.reason = 'run-file-missing';
  3494. out.message = '上次的结果文件已被删除';
  3495. return out;
  3496. }
  3497. out.valid = true;
  3498. out.reason = 'ok';
  3499. return out;
  3500. }
  3501. /** 现在能不能点「继续」(还要叠加"当前没有别的任务在跑") */
  3502. function canResume(snap) {
  3503. var s = snap || snapshot();
  3504. return s.valid === true && Session.running !== true;
  3505. }
  3506. /**
  3507. * 这条运行记录能不能续。
  3508. *
  3509. * 判据必须精确到"就是那次运行",否则一堆历史记录上都会冒出「继续」:
  3510. * · 任务必须是 ID 提取
  3511. * · 结果必须是 stopped(手动停止)或 failed(被杀/异常退出)
  3512. * · 断点本身还得有效(配置没变、结果文件还在)
  3513. * · **记录里的 runId 必须等于断点里的 runId**
  3514. */
  3515. function isRecordResumable(record, snap) {
  3516. var s = snap || snapshot();
  3517. if (!record || record.taskKey !== 'id') {
  3518. return false;
  3519. }
  3520. if (record.result !== 'stopped' && record.result !== 'failed') {
  3521. return false;
  3522. }
  3523. if (!s.valid || !s.cp) {
  3524. return false;
  3525. }
  3526. return !!(record.runId && s.cp.runId && record.runId === s.cp.runId);
  3527. }
  3528. /** 提示条上那句副文案 */
  3529. function detail(snap) {
  3530. var s = snap || snapshot();
  3531. if (s.valid) {
  3532. var where = s.stoppedAt ? '停在「' + s.stoppedAt + '」' : '上次没跑完';
  3533. return where + ' · 继续会追加到同一个文件';
  3534. }
  3535. if (s.finished) {
  3536. return '上次已跑完';
  3537. }
  3538. return s.message || '';
  3539. }
  3540. /** 清空断点 = 覆盖成空壳。**不删文件** */
  3541. function clear() {
  3542. try {
  3543. files.write(PATH, JSON.stringify({
  3544. v: VERSION,
  3545. state: 'none',
  3546. updatedAt: Date.now()
  3547. }));
  3548. return true;
  3549. } catch (e) {
  3550. console.log("[resume] 清空断点失败: " + e);
  3551. return false;
  3552. }
  3553. }
  3554. return {
  3555. PATH: PATH,
  3556. read: read,
  3557. snapshot: snapshot,
  3558. canResume: canResume,
  3559. isRecordResumable: isRecordResumable,
  3560. detail: detail,
  3561. nameOf: nameOf,
  3562. clear: clear
  3563. };
  3564. }());
  3565. /* ============================== src/widgets.js ============================== */
  3566. /* ============================== src/widgets.js ==============================
  3567. * 通用组件工厂。
  3568. *
  3569. * 设计稿里的每一类元素(卡片 / 设置行 / 下拉 / 开关 / 胶囊按钮 / 状态标签 /
  3570. * 勾选框 / 页签 / 主按钮)在这里各有一个构造函数,页面只做「拼装」。
  3571. * 目的很直接:同一元素在 5 个页面里必须长得一样,
  3572. * 布局参数一旦散落到页面代码,就会出现「这个页的卡片圆角是 16、那个页是 12」。
  3573. *
  3574. * 所有构造函数都返回 Android View;需要后续改动的额外返回 setter。
  3575. */
  3576. var W = {};
  3577. /* ---------------------------------------------------------------- 卡片 */
  3578. /**
  3579. * 圆角卡片容器(vertical)。
  3580. * active=true 用青蓝描边 35%,false 用白 8% —— 对应设计稿里「已勾选脚本卡」的差异。
  3581. */
  3582. W.card = function (active) {
  3583. var card = U.column();
  3584. card.setBackground(active
  3585. ? U.strokeBg(Theme.alpha.cardFillActive, Theme.alpha.cardStrokeActive, Theme.hex.accent, Theme.m.cardRadius)
  3586. : U.strokeBg(Theme.alpha.cardFill, Theme.alpha.cardStroke, "#FFFFFF", Theme.m.cardRadius));
  3587. card.setPadding(U.dp(Theme.m.cardPadding), U.dp(Theme.m.cardPadding),
  3588. U.dp(Theme.m.cardPadding), U.dp(Theme.m.cardPadding));
  3589. return card;
  3590. };
  3591. /** 按序把子 View 加进容器,并按 gap 插入间距,避免每处手写 spacer。 */
  3592. W.stack = function (container, children, gapDp) {
  3593. var gap = gapDp === undefined ? 12 : gapDp;
  3594. for (var i = 0; i < children.length; i++) {
  3595. if (!children[i]) { continue; }
  3596. if (i > 0 && gap > 0) { container.addView(U.spacer(gap)); }
  3597. container.addView(children[i]);
  3598. }
  3599. return container;
  3600. };
  3601. /** 区标题(「脚本选择」「功能设置」这类分段标题)。 */
  3602. W.sectionTitle = function (label) {
  3603. return U.text(label, Theme.m.sectionSize, Theme.hex.textPrimary, true);
  3604. };
  3605. /* ------------------------------------------------------------ 顶部标题区 */
  3606. /**
  3607. * 页面顶部:大标题 + 副标题 + 右侧设置入口。
  3608. * 5 个页签共用同一个标题区,所以文案由页面传入而不是写死在这里。
  3609. */
  3610. W.pageHeader = function (title, subtitle, onTapSettings) {
  3611. var left = U.column();
  3612. U.params(left, 0, -2, 1);
  3613. left.addView(U.text(title, Theme.m.titleSize, Theme.hex.textPrimary, true));
  3614. left.addView(U.spacer(4));
  3615. left.addView(U.text(subtitle, 12, Theme.hex.textMuted, false));
  3616. var header = U.row();
  3617. header.addView(left);
  3618. if (onTapSettings) {
  3619. var gear = U.text("\u2699", 18, Theme.hex.textSecondary, false);
  3620. gear.setGravity(android.view.Gravity.CENTER);
  3621. U.params(gear, 22, 22);
  3622. gear.setOnClickListener(function () { onTapSettings(); });
  3623. header.addView(gear);
  3624. }
  3625. return header;
  3626. };
  3627. /* ------------------------------------------------------------ 脚本卡 */
  3628. /**
  3629. * 脚本卡头部一行:勾选框/单选框 + 脚本名 +(摘要)+ 展开箭头。
  3630. *
  3631. * 勾选与展开是两个独立命中区——合并成一个的话,
  3632. * 用户想展开却把脚本关掉是必然事件。
  3633. *
  3634. * mode: "checkbox"(默认)| "radio"。设计稿的 ID 提取页是单选
  3635. * (「同一时间仅可运行一个脚本」),其余页是可多选的勾选。
  3636. * 互斥由页面负责——组件只管画和报「点完应该是什么状态」。
  3637. *
  3638. * radio 的「再点一下取消」是有意为之:设计稿专门画了「未选择脚本」这一屏
  3639. * (16:23 + 「请先选择要运行的脚本」拦截弹窗),必须有一条路能回到那个状态。
  3640. * 严格单选一旦选过就再也回不去,那一屏就成了永远看不到的死代码。
  3641. *
  3642. * disabled: 运行中不允许切换脚本(设计稿 09 的底部提示就是这么写的)。
  3643. */
  3644. W.scriptHeader = function (opts) {
  3645. var isRadio = opts.mode === "radio";
  3646. var disabled = !!opts.disabled;
  3647. var box = isRadio ? W.radio(opts.checked, 18) : W.checkbox(opts.checked, 18);
  3648. /* 名称用「内容宽度 + 弹性占位顶开」,而不是 weight=1。
  3649. * weight=1 的写法在右侧存在 MATCH_PARENT 控件时会被挤成 0 宽(真机踩过),
  3650. * 用 flexible() 占位则无论同行其它控件怎么变都成立。 */
  3651. var nameView = U.text(opts.name, Theme.m.labelSize,
  3652. disabled ? Theme.hex.textMuted : Theme.hex.textPrimary, true);
  3653. nameView.setSingleLine(true);
  3654. nameView.setEllipsize(android.text.TextUtils.TruncateAt.END);
  3655. var head = U.row();
  3656. head.addView(box.root);
  3657. head.addView(U.hgap(Theme.m.gapInline));
  3658. head.addView(nameView);
  3659. head.addView(U.flexible());
  3660. if (opts.summary) {
  3661. head.addView(U.text(opts.summary, Theme.m.chipSize, Theme.hex.textFaint, false));
  3662. head.addView(U.hgap(6));
  3663. }
  3664. var arrowBox = U.row(false);
  3665. arrowBox.setGravity(android.view.Gravity.CENTER);
  3666. U.params(arrowBox, 16, 16);
  3667. var arrow = W.arrow(opts.expanded);
  3668. arrowBox.addView(arrow.root);
  3669. head.addView(arrowBox);
  3670. box.root.setOnClickListener(function () {
  3671. if (disabled) { return; }
  3672. /* 两种模式都是「取反」——单选组也允许点已选中项来取消,
  3673. * 详见上面 radio 的说明。互斥(同组只留一个)由页面处理。 */
  3674. var next = !box.isChecked();
  3675. box.set(next);
  3676. opts.onToggleCheck(next);
  3677. });
  3678. arrowBox.setOnClickListener(function () {
  3679. /* 展开只影响看到多少设置,不影响「要不要跑」,运行中也不必禁用 */
  3680. opts.onToggleExpand();
  3681. });
  3682. return { root: head, nameView: nameView, arrow: arrow, checkbox: box };
  3683. };
  3684. /* ---------------------------------------------------------------- 勾选框 */
  3685. /**
  3686. * 勾选框。选中 = 青蓝实心 + 白色对勾;未选中 = 透明底 + 灰描边。
  3687. * 用「字符当对勾」而不是 VectorDrawable:省掉资源文件,且离线桩测能直接验证状态。
  3688. */
  3689. W.checkbox = function (checked, sizeDp) {
  3690. var size = sizeDp || 16;
  3691. var radius = size <= 16 ? 4 : 5;
  3692. var view = U.text("", 11, Theme.hex.textPrimary, true);
  3693. view.setGravity(android.view.Gravity.CENTER);
  3694. U.params(view, size, size);
  3695. var state = { checked: !!checked };
  3696. function paint() {
  3697. if (state.checked) {
  3698. view.setText("\u2713");
  3699. view.setBackground(U.solidBg(U.argb(Theme.hex.accent), radius));
  3700. } else {
  3701. view.setText("");
  3702. view.setBackground(U.strokeBg(0, 1, Theme.hex.tabIdle, radius));
  3703. }
  3704. }
  3705. paint();
  3706. return {
  3707. root: view,
  3708. set: function (value) { state.checked = !!value; paint(); },
  3709. isChecked: function () { return state.checked; }
  3710. };
  3711. };
  3712. /* ---------------------------------------------------------------- 单选框 */
  3713. /**
  3714. * 单选框(设计稿 01「脚本一」的 16:1072)。
  3715. *
  3716. * 与勾选框的差别不只是外形,更是**语义**:一组里同时只能有一个被选中
  3717. * (设计稿写得很明确——「单选:同一时间仅可运行一个脚本」)。
  3718. * 所以这个组件只负责画与报状态,「互斥」由页面负责清掉同组其它项。
  3719. */
  3720. W.radio = function (checked, sizeDp) {
  3721. var size = sizeDp || 18;
  3722. var view = U.text("", Theme.m.chipSize, Theme.hex.textPrimary, true);
  3723. view.setGravity(android.view.Gravity.CENTER);
  3724. U.params(view, size, size);
  3725. /* 选中 = 青蓝实心圆 + 白色圆点;未选中 = 透明底 + 灰描边圆。
  3726. * 圆点用 "●"(U+25CF)而不是画 VectorDrawable:与勾选框同一套思路,
  3727. * 省掉资源文件,且离线桩测能直接断言状态。 */
  3728. var state = { checked: !!checked };
  3729. function paint() {
  3730. if (state.checked) {
  3731. view.setText("\u25CF");
  3732. view.setTextSize(7);
  3733. view.setBackground(U.solidBg(U.argb(Theme.hex.accent), 99));
  3734. } else {
  3735. view.setText("");
  3736. view.setBackground(U.strokeBg(0, 1, Theme.hex.tabIdle, 99));
  3737. }
  3738. }
  3739. paint();
  3740. return {
  3741. root: view,
  3742. set: function (value) { state.checked = !!value; paint(); },
  3743. isChecked: function () { return state.checked; }
  3744. };
  3745. };
  3746. /* ---------------------------------------------------------------- 箭头 */
  3747. /** 展开 / 收起箭头。字符 U+25BE / U+25B4 在 Android 默认字体里必然存在。 */
  3748. W.arrow = function (expanded) {
  3749. var view = U.text(expanded ? "\u25B4" : "\u25BE", 12, Theme.hex.textFaint, false);
  3750. view.setGravity(android.view.Gravity.CENTER);
  3751. return {
  3752. root: view,
  3753. set: function (value) { view.setText(value ? "\u25B4" : "\u25BE"); }
  3754. };
  3755. };
  3756. /**
  3757. * 指向右侧的箭头(运行记录条目 / 可进入下一级的行)。
  3758. *
  3759. * 用 U+203A(单右角引号)而不是 U+2192:后者在部分 Android 字体里带 emoji 变体,
  3760. * 会渲染成彩色箭头(页签图标踩过同一个坑)。U+203A 在默认字体里稳定是字形。
  3761. */
  3762. W.chevronRight = function (sizeSp, hex) {
  3763. var view = U.text("\u203A", sizeSp || 18, hex || Theme.hex.textFaint, false);
  3764. view.setGravity(android.view.Gravity.CENTER);
  3765. return view;
  3766. };
  3767. /* ------------------------------------------------------------ 设置行 */
  3768. /**
  3769. * 「左标签 + 右控件」设置行;右控件可省略(此时是纯文案行)。
  3770. *
  3771. * 左侧标签用内容宽度 + flexible() 顶开,**不用 weight=1**:
  3772. * 当右控件自身是 MATCH_PARENT(例如整行输入框)时,
  3773. * weight=1 的标签会被挤成 0 宽,在真机上就是「这一行的标签不见了」。
  3774. */
  3775. W.settingRow = function (label, rightView, gapDp) {
  3776. var nameView = U.text(label, Theme.m.labelSize, Theme.hex.textSecondary, false);
  3777. nameView.setSingleLine(true);
  3778. var line = U.row();
  3779. line.addView(nameView);
  3780. line.addView(U.flexible());
  3781. if (rightView) {
  3782. line.addView(U.hgap(gapDp === undefined ? 6 : gapDp));
  3783. line.addView(rightView);
  3784. }
  3785. return line;
  3786. };
  3787. /* ------------------------------------------------------------ 勾选设置行 */
  3788. /**
  3789. * 「勾选框 + 标签 +(可选右侧控件)」设置行。
  3790. *
  3791. * 设计稿里这类行有三种排布:
  3792. * · 单独一行(过滤私密账户);
  3793. * · 一行两个(过滤私密账户 | 过滤无头像账户);
  3794. * · 勾选后才出现右侧控件(云端去重 → 表名 + 连通测试)。
  3795. * 第三种由调用方按状态自己拼,前两种用这个函数。
  3796. *
  3797. * 命中区含整个标签:只让 16dp 的方框可点,真机上很难戳中。
  3798. */
  3799. /**
  3800. * 勾选行。
  3801. *
  3802. * @param disabled 置灰且不可点。**为「功能还没做,但字段要保留」的场景准备的**:
  3803. * 给了界面一个"开得起来、却什么也不会发生"的开关,比不给更糟
  3804. * (用户会以为它生效了)。置灰至少是诚实的。
  3805. * 勾选状态**照常显示**——它代表"用户上次的选择",不是"现在能改的选择"。
  3806. */
  3807. W.checkRow = function (label, checked, onToggle, disabled) {
  3808. var box = W.checkbox(checked, 16);
  3809. var off = !!disabled;
  3810. var labelView = U.text(label, Theme.m.labelSize,
  3811. off ? Theme.hex.textMuted : Theme.hex.textSecondary, false);
  3812. labelView.setSingleLine(true);
  3813. var line = U.row(false);
  3814. line.addView(box.root);
  3815. line.addView(U.hgap(8));
  3816. line.addView(labelView);
  3817. line.setPadding(0, U.dp(4), 0, U.dp(4));
  3818. if (off) {
  3819. line.setAlpha(0.55);
  3820. /* 不可点:这里挂一个**空监听**而不是不挂 —— 不挂的话父容器(卡片)
  3821. * 的点击会继续冒泡上去,用户点这一行会触发别处的动作。
  3822. * 注意 setOnClickListener(function(){}) 与 setOnClickListener(null) 行为不同:
  3823. * 后者等于"没有监听器",照样冒泡(见 README 铁律)。 */
  3824. line.setOnClickListener(function () { });
  3825. } else {
  3826. line.setOnClickListener(function () {
  3827. var next = !box.isChecked();
  3828. box.set(next);
  3829. onToggle(next);
  3830. });
  3831. }
  3832. return { root: line, isChecked: box.isChecked };
  3833. };
  3834. /**
  3835. * 灰行:**只读展示 + 「暂不支持」标记**。
  3836. *
  3837. * 专门给"设置项在界面上占着位置、但执行侧还没有能力实现"的项用
  3838. * (提取区域 / AI 头像识别 / AI 性别 / AI 年龄)。
  3839. *
  3840. * 为什么不是"灰掉的勾选框":勾选框本身就在说"这个可以改",
  3841. * 灰只是说"现在不能改",两者叠起来仍然在暗示它是个可选项。
  3842. * 而这里的事实是"它现在根本没有效果" —— 所以不给可操作控件,
  3843. * 只把用户上次填的值显示出来(保留值、将来接上就能用),点一行看原因。
  3844. */
  3845. W.mutedRow = function (label, valueText, onTap) {
  3846. var line = U.row(false);
  3847. var labelView = U.text(label, Theme.m.labelSize, Theme.hex.textMuted, false);
  3848. labelView.setSingleLine(true);
  3849. line.addView(labelView);
  3850. line.addView(U.flexible());
  3851. if (valueText) {
  3852. var valueView = U.text(valueText, Theme.m.chipSize, Theme.hex.textFaint, false);
  3853. valueView.setSingleLine(true);
  3854. line.addView(valueView);
  3855. line.addView(U.hgap(6));
  3856. }
  3857. line.addView(W.chip("暂不支持", "mute").root);
  3858. line.setPadding(0, U.dp(4), 0, U.dp(4));
  3859. if (onTap) {
  3860. line.setOnClickListener(onTap);
  3861. } else {
  3862. line.setOnClickListener(function () { });
  3863. }
  3864. return { root: line };
  3865. };
  3866. /* ------------------------------------------------------------ 胶囊 / 标签 */
  3867. /* 胶囊的四套色调集中在这里,W.chip 与 W.repaintChip 共用,
  3868. * 避免「改了一处忘了另一处」导致状态切换后颜色不更新。 */
  3869. W.TONES = {
  3870. "default": { fill: 0.06, stroke: 0.10, text: "#CFCCE6" },
  3871. "accent": { fill: 0.20, stroke: 0, text: "#7CC7FF" },
  3872. "warn": { fill: 0.16, stroke: 0, text: "#F5C24B" },
  3873. "ok": { fill: 0.16, stroke: 0, text: "#13DEC4" },
  3874. /* 不可逆操作用 danger,与「未开启」的琥珀色区分开——
  3875. * 两种暖色混用会让用户分不清「提示」和「警告」。 */
  3876. "danger": { fill: 0.16, stroke: 0, text: "#FF5470" },
  3877. "mute": { fill: 0.08, stroke: 0, text: "#8E8BA8" }
  3878. };
  3879. W.paintTone = function (view, tone, radiusDp) {
  3880. var t = W.TONES[tone] || W.TONES["default"];
  3881. view.setTextColor(U.argb(t.text));
  3882. view.setBackground(t.stroke > 0
  3883. ? U.strokeBg(t.fill, t.stroke, "#FFFFFF", radiusDp)
  3884. : U.solidBg(U.alphaOf("#FFFFFF", t.fill), radiusDp));
  3885. return view;
  3886. };
  3887. /**
  3888. * 胶囊元素的统一出口:下拉框、按钮、状态标签都用它,只是色调与圆角不同。
  3889. */
  3890. W.chip = function (label, tone, minWidthDp, radiusDp) {
  3891. var view = U.text(label, Theme.m.chipSize, W.TONES["default"].text, true);
  3892. view.setGravity(android.view.Gravity.CENTER);
  3893. view.setSingleLine(true);
  3894. W.paintTone(view, tone || "default", radiusDp === undefined ? Theme.m.chipRadius : radiusDp);
  3895. view.setPadding(U.dp(Theme.m.chipPadH), U.dp(Theme.m.chipPadV),
  3896. U.dp(Theme.m.chipPadH), U.dp(Theme.m.chipPadV));
  3897. if (minWidthDp) { view.setMinWidth(U.dp(minWidthDp)); }
  3898. return {
  3899. root: view,
  3900. setText: function (v) { view.setText(String(v)); },
  3901. setTone: function (nextTone) { W.paintTone(view, nextTone, Theme.m.chipRadius); }
  3902. };
  3903. };
  3904. /** 状态标签(全圆角),用于「未登录 / 已安装 v39.5.4 / 已是最新」。 */
  3905. W.pill = function (label, tone) {
  3906. var t = W.TONES[tone || "mute"] || W.TONES["mute"];
  3907. var view = U.text(label, Theme.m.chipSize, t.text, true);
  3908. view.setGravity(android.view.Gravity.CENTER);
  3909. view.setSingleLine(true);
  3910. view.setBackground(U.solidBg(U.alphaOf("#FFFFFF", t.fill), Theme.m.pillRadius));
  3911. view.setPadding(U.dp(Theme.m.chipPadH), U.dp(Theme.m.chipPadV),
  3912. U.dp(Theme.m.chipPadH), U.dp(Theme.m.chipPadV));
  3913. return {
  3914. root: view,
  3915. setText: function (v) { view.setText(String(v)); }
  3916. };
  3917. };
  3918. /* ------------------------------------------------------------ 下拉框 */
  3919. /**
  3920. * 下拉框。
  3921. *
  3922. * 为什么既不用 Spinner 也不用 dialogs.select:
  3923. * · Spinner 的弹出列表走系统浅色主题,在深色界面里是一块刺眼的白板,
  3924. * 要改得自定义 Adapter 与主题;
  3925. * · `dialogs.select` 在 UI 模式下**拿不到返回值**(阻塞会死锁,运行时直接返回 undefined),
  3926. * 结果是「选完写回 undefined」,配置被写坏、重启又变回默认值。
  3927. * 所以用自绘的 Dialog.choose:样式统一由 Theme 控制,结果走回调。
  3928. */
  3929. W.dropdown = function (group, valuePath, onChange) {
  3930. /* 用 getEnum 而不是 get:它保证拿回一个**合法 key**,
  3931. * 缺失或非法时回退到该枚举的默认项并回写配置。
  3932. * 于是这个胶囊在第一次渲染时就一定有值,不会出现空框。 */
  3933. var chip = W.chip(Store.label(group, Store.getEnum(valuePath, group)), "default", 60);
  3934. chip.root.setOnClickListener(function () {
  3935. Dialog.choose({
  3936. options: Store.options[group],
  3937. currentIndex: Store.indexOfLabel(group, Store.getEnum(valuePath, group)),
  3938. onConfirm: function (index) {
  3939. var key = Store.keyAt(group, index);
  3940. Store.set(valuePath, key);
  3941. chip.setText(Store.label(group, key));
  3942. if (onChange) { onChange(key); }
  3943. }
  3944. });
  3945. });
  3946. return {
  3947. root: chip.root,
  3948. set: function (key) { chip.setText(Store.label(group, key)); }
  3949. };
  3950. };
  3951. /* ------------------------------------------------------------ 开关 */
  3952. /**
  3953. * 二元开关。设计稿用的是「胶囊文字按钮」而不是滑块,
  3954. * 所以这里做成 chip:开 = 青蓝高亮,关 = 灰。
  3955. */
  3956. W.toggle = function (on, onChange) {
  3957. var chip = W.chip(on ? Store.label("toggle", "on") : Store.label("toggle", "off"),
  3958. on ? "accent" : "default", 44);
  3959. var state = !!on;
  3960. chip.root.setOnClickListener(function () {
  3961. state = !state;
  3962. chip.setText(state ? Store.label("toggle", "on") : Store.label("toggle", "off"));
  3963. chip.setTone(state ? "accent" : "default");
  3964. if (onChange) { onChange(state); }
  3965. });
  3966. return {
  3967. root: chip.root,
  3968. isOn: function () { return state; },
  3969. set: function (v) {
  3970. state = !!v;
  3971. chip.setText(state ? Store.label("toggle", "on") : Store.label("toggle", "off"));
  3972. chip.setTone(state ? "accent" : "default");
  3973. }
  3974. };
  3975. };
  3976. /* ------------------------------------------------------------ 多选开关行 */
  3977. /**
  3978. * 「执行操作」那种一行多选:关注 / 私信 / 点赞 / 评论。
  3979. * 每一项独立高亮、互不影响,所以不能复用 W.toggle(它是二元开关)。
  3980. */
  3981. W.toggleRow = function (items, onChange) {
  3982. /* 这一行是作为「设置行的右侧控件」使用的,必须包裹内容——
  3983. * 用默认的 fill=true 会占满整行,把左侧标签挤成 0 宽。 */
  3984. var line = U.row(false);
  3985. var chips = {};
  3986. for (var i = 0; i < items.length; i++) {
  3987. (function (item) {
  3988. var chip = W.chip(item.label, item.on ? "accent" : "default", 36);
  3989. var state = !!item.on;
  3990. chip.root.setOnClickListener(function () {
  3991. state = !state;
  3992. chip.setTone(state ? "accent" : "default");
  3993. if (onChange) { onChange(item.key, state); }
  3994. });
  3995. chips[item.key] = {
  3996. root: chip.root,
  3997. isOn: function () { return state; },
  3998. set: function (v) { state = !!v; chip.setTone(state ? "accent" : "default"); }
  3999. };
  4000. if (line.getChildCount() > 0) { line.addView(U.hgap(6)); }
  4001. line.addView(chip.root);
  4002. })(items[i]);
  4003. }
  4004. return { root: line, chips: chips };
  4005. };
  4006. /* ------------------------------------------------------------ 输入框 */
  4007. /**
  4008. * 常驻输入框 —— **直接在栏位里改**,不再「点一下弹出输入框再填」。
  4009. *
  4010. * 上一版为什么绕开常驻 EditText(理由已逐条失效,记在这里免得又被改回去):
  4011. * ① 怕 TextWatcher 这种 Java 接口构造不出来 —— 已在目标设备上实测通过:
  4012. * `new android.text.TextWatcher({...})` 与 `new View.OnFocusChangeListener({...})`
  4013. * 都能构造,真机逐字输入每键都触发回调。
  4014. * ② 怕忘绑监听 → 「能打字但永远不保存」(用户会报成「没有默认值」)——
  4015. * 监听现在写在本函数内部,全项目只有这一个输入框入口,不存在漏绑的余地。
  4016. *
  4017. * ★ 程序化回写必须防自触发:真机上 `setText` 会**同步**触发 TextWatcher
  4018. * (实测顺序是 before → on → after 全部跑完,setText 才返回)。
  4019. * 不挡的话「失焦正规化」会把夹紧后的值再存一遍,来回震荡。
  4020. *
  4021. * ★ 数字框在「从尾部退格」的过程中**不写配置**:把 2000 清成 2 会经过 200、20
  4022. * 这些**恰好合法**的中间值,用户清空后走开,配置就停在 2 上。
  4023. * 退格期间不写,失焦时一次性提交(那时文本已定稿;空串则回退原值)。
  4024. * 文本字段不受这条限制 —— 半截文本本身就是「用户正在输入的内容」,没有这个问题。
  4025. *
  4026. * opts:
  4027. * path 绑定配置路径 → 自动「读初值 / 输入即落盘 / 失焦正规化」
  4028. * value 未绑定 path 时的初值(一次性取值的场合,如登录页卡密)
  4029. * placeholder 空值提示
  4030. * numeric true → 数字键盘,按数值解析与落盘
  4031. * widthDp 固定宽度。横向布局里**必须**给:默认 MATCH_PARENT 会把同行标签挤成 0 宽
  4032. * fill true → 宽度撑满(独占一行的输入框,如登录页卡密)
  4033. * align "right" 等(数字在区间里居中对齐,见下)
  4034. * onLinkedChange 自己改完**连带改了别的字段**时的回调(只刷兄弟,不刷自己)
  4035. * onSubmit 软键盘回车
  4036. */
  4037. W.inlineField = function (opts) {
  4038. /* 绑定方式有两种:
  4039. * · `path` —— 绑 Store 的字段(绝大多数控件)
  4040. * · `read`/`write` —— 绑"不是 Store 字段"的值(取材清单里的账号/关键词/路径)。
  4041. * 后者必须自己提供读写,因为 Sources 是另一个储物柜。 */
  4042. var hasWriter = typeof opts.write === "function";
  4043. var bound = hasWriter || (typeof opts.path === "string" && opts.path !== "");
  4044. var numeric = !!opts.numeric;
  4045. var edit = new android.widget.EditText(context);
  4046. edit.setTextSize(Theme.m.chipSize);
  4047. edit.setTextColor(U.argb(Theme.hex.textSecondary));
  4048. edit.setHintTextColor(U.argb(Theme.hex.textFaint));
  4049. edit.setHint(opts.placeholder || "");
  4050. edit.setSingleLine(true);
  4051. edit.setPadding(U.dp(8), U.dp(7), U.dp(8), U.dp(7));
  4052. if (numeric) {
  4053. edit.setInputType(android.text.InputType.TYPE_CLASS_NUMBER);
  4054. /* 数字短、盒子窄,居中比左右对齐更稳(设计稿里两种都有,取一致的居中) */
  4055. edit.setGravity(android.view.Gravity.CENTER);
  4056. } else if (opts.align === "right") {
  4057. edit.setGravity(android.view.Gravity.RIGHT);
  4058. }
  4059. edit.setImeOptions(android.view.inputmethod.EditorInfo.IME_ACTION_DONE);
  4060. /* 横向布局里宽度必须显式给死,否则 MATCH_PARENT 会吃掉整行(真机踩过的坑)。
  4061. * fill: true 用于独占一行的输入框(登录页卡密)。 */
  4062. var width = -2;
  4063. if (opts.fill) { width = -1; }
  4064. else if (opts.widthDp) { width = U.dp(opts.widthDp); }
  4065. U.params(edit, width, -2);
  4066. var idleBg = U.strokeBg(Theme.alpha.chipFill, Theme.alpha.chipStroke, "#FFFFFF", Theme.m.chipRadius);
  4067. /* 可编辑之后必须看得出来「现在改的是哪一格」,聚焦时换青蓝描边 */
  4068. var activeBg = U.strokeBg(Theme.alpha.chipFill, 0.55, Theme.hex.accent, Theme.m.chipRadius);
  4069. edit.setBackground(idleBg);
  4070. /* 程序化回写期间置位,挡住 TextWatcher —— 见函数头第 ★ 条 */
  4071. var writing = false;
  4072. /* 上一次的栏位文字,只用来判断「这一下是不是在退格」 */
  4073. var lastText = "";
  4074. function readStore() {
  4075. if (typeof opts.read === "function") {
  4076. var fromCallback = opts.read();
  4077. return fromCallback === undefined || fromCallback === null ? "" : String(fromCallback);
  4078. }
  4079. if (bound && typeof opts.path === "string" && opts.path !== "") {
  4080. return numeric ? String(Store.getNumber(opts.path)) : Store.getText(opts.path);
  4081. }
  4082. return opts.value === undefined || opts.value === null ? "" : String(opts.value);
  4083. }
  4084. function paint() {
  4085. writing = true;
  4086. try {
  4087. edit.setText(readStore());
  4088. /* ★ 用 String(...) 取长度,不要写 `edit.getText().length()`:
  4089. * 真机上 getText() 返回 android.text.Editable(有 length() 方法),
  4090. * 但测试桩返回的是 JS 字符串(length 是属性不是函数)——
  4091. * 写成 `.length()` 会让回写在桩上**每次都抛异常**,
  4092. * 于是「回写之后光标在哪」这类问题在 PC 侧永远测不出来。 */
  4093. edit.setSelection(String(edit.getText()).length);
  4094. } catch (e) {
  4095. console.log("[input] paint 失败 " + opts.path + ": " + e);
  4096. } finally {
  4097. /* 一定要走 finally:抛异常时若把 writing 留在 true,
  4098. * 这个输入框就**永远不会再写配置**了(静默哑掉)。 */
  4099. writing = false;
  4100. }
  4101. lastText = String(edit.getText());
  4102. }
  4103. /** 把栏位文字写进配置。空串 / 非数字一律不写(保留原值,由 paint 兜底显示)。 */
  4104. function commit() {
  4105. if (!bound) { return false; }
  4106. var raw = String(edit.getText()).trim();
  4107. /* 写到哪里:`opts.write` 优先(取材清单这类不在字段表里的值),
  4108. * 否则写 Store(绝大多数控件走这条)。 */
  4109. if (hasWriter) {
  4110. opts.write(raw);
  4111. if (opts.onCommitted) { opts.onCommitted(raw); }
  4112. return true;
  4113. }
  4114. if (numeric) {
  4115. if (raw === "") { return false; }
  4116. var n = Number(raw);
  4117. if (isNaN(n)) { return false; }
  4118. Store.set(opts.path, n);
  4119. } else {
  4120. Store.set(opts.path, raw);
  4121. }
  4122. /* 上界不得小于下界。★ 只让**兄弟**刷新(回调由调用方提供),
  4123. * 绝不能刷自己这一格 —— 用户可能正在其中打字。 */
  4124. if (syncCrossRules() && opts.onLinkedChange) { opts.onLinkedChange(); }
  4125. return true;
  4126. }
  4127. edit.setText(readStore());
  4128. lastText = String(edit.getText());
  4129. edit.addTextChangedListener(new android.text.TextWatcher({
  4130. beforeTextChanged: function () { },
  4131. onTextChanged: function () { },
  4132. afterTextChanged: function (editable) {
  4133. if (writing || !bound) { return; }
  4134. var now = String(editable === null || editable === undefined ? "" : editable);
  4135. var isBackspacing = lastText.length > now.length && lastText.indexOf(now) === 0;
  4136. lastText = now;
  4137. /* 见函数头第 ★ 条:数字框退格期间不落盘,等失焦时一次提交 */
  4138. if (numeric && isBackspacing) { return; }
  4139. commit();
  4140. }
  4141. }));
  4142. /* 失焦:先提交(把退格期间跳过的编辑落地),再回写正规化后的值 ——
  4143. * 没有后一步,用户永远看不到「99999 其实被夹到了 500」。 */
  4144. edit.setOnFocusChangeListener(new android.view.View.OnFocusChangeListener({
  4145. onFocusChange: function (view, hasFocus) {
  4146. edit.setBackground(hasFocus ? activeBg : idleBg);
  4147. if (hasFocus) { return; }
  4148. commit();
  4149. paint();
  4150. if (opts.onBlur) { opts.onBlur(); }
  4151. }
  4152. }));
  4153. var handle = {
  4154. root: edit,
  4155. edit: edit,
  4156. paint: paint,
  4157. commit: commit,
  4158. value: function () { return String(edit.getText()).trim(); }
  4159. };
  4160. /* 登记进「本页待提交输入框」:切页签 / 按开始运行这类**不会先失焦**的动作
  4161. * 之前要整体提交一次,否则「只退格、没失焦就走」的那次编辑会丢。
  4162. * 绑 Store 字段的进 pendingFields(审计会据此判断"这个字段有控件"),
  4163. * 绑 Sources 之类非字段值的进 pendingExtra(不进审计口径)。 */
  4164. if (bound) {
  4165. if (typeof opts.path === "string" && opts.path !== "") {
  4166. W.pendingFields[opts.path] = handle;
  4167. } else {
  4168. W.pendingExtra.push(handle);
  4169. }
  4170. }
  4171. return handle;
  4172. };
  4173. /* path → 输入框。**每次渲染页面开始时清空**(见 W.beginPage)——
  4174. * 留成全局累积的话,切走的那一页的输入框还挂在表里,
  4175. * 后面任意一次提交都会把它的旧文字重新写回配置,把别处刚改的值顶掉。 */
  4176. W.pendingFields = {};
  4177. /** 开始渲染一个新页面:清空待提交表。调用方要先 `commitPendingInputs()` 再调这个。 */
  4178. W.beginPage = function () {
  4179. W.pendingFields = {};
  4180. W.pendingExtra = [];
  4181. };
  4182. /* 不绑定配置路径的输入框(例如 ID提取「取材清单」里的账号/关键词/路径)。
  4183. * 与 pendingFields 分开是**必须的**:pendingFields 同时被取值审计当作
  4184. * 「这个字段有界面控件」的证据,而取材字段根本不在字段表里 ——
  4185. * 混在一起会让审计报「绑定了表外路径」。 */
  4186. W.pendingExtra = [];
  4187. /**
  4188. * 提交当前页面上所有绑定过配置的输入框。
  4189. *
  4190. * 什么时候必须调:**页面要被重建 / 配置要被读取,而用户可能还停在某个输入框里**。
  4191. * Android 里点一个不可聚焦的按钮并不会让 EditText 失焦,所以「按开始运行」时
  4192. * 栏位内容可能还没落盘 —— 这正是「界面显示的值」和「真正跑起来的值」不一致的来源。
  4193. */
  4194. W.commitPendingInputs = function () {
  4195. for (var path in W.pendingFields) {
  4196. if (W.pendingFields.hasOwnProperty(path)) {
  4197. W.pendingFields[path].commit();
  4198. }
  4199. }
  4200. for (var i = 0; i < W.pendingExtra.length; i++) {
  4201. W.pendingExtra[i].commit();
  4202. }
  4203. };
  4204. /**
  4205. * 通用单选胶囊:**不绑 Store 路径**,选完只回调一个下标。
  4206. *
  4207. * 为什么需要它:`W.dropdown` 绑的是 `Store` 的一个枚举路径,
  4208. * 而取材清单存在 `Sources` 里(不是字段),它的"起点 / 深度"这类选择只能走回调。
  4209. *
  4210. * @param opts.label 当前显示的文案
  4211. * @param opts.options 候选文案数组
  4212. * @param opts.index 当前下标(**函数**,点击时现算 —— 渲染之后状态可能已经变了)
  4213. * @param opts.onPick 选中回调 (index)
  4214. */
  4215. W.chooser = function (opts) {
  4216. var chip = W.chip(opts.label, opts.tone || "default", opts.minWidthDp || 76);
  4217. chip.root.setOnClickListener(function () {
  4218. var current = typeof opts.index === "function" ? opts.index() : opts.index;
  4219. if (current === undefined || current === null || current < 0) { current = 0; }
  4220. Dialog.choose({
  4221. options: opts.options,
  4222. currentIndex: current,
  4223. onConfirm: function (index) {
  4224. chip.setText(opts.options[index]);
  4225. opts.onPick(index);
  4226. }
  4227. });
  4228. });
  4229. return {
  4230. root: chip.root,
  4231. set: function (text) { chip.setText(text); }
  4232. };
  4233. };
  4234. /**
  4235. * 文本输入(关键词 / 主播区域 / 国家简码 / 云表名 / 文件路径)。
  4236. *
  4237. * 用 EditText 直接输入而不是 `dialogs.rawInput`:后者在 `"ui";` 模式下
  4238. * 是阻塞式调用、拿不到返回值(老坑),而且多点一步。
  4239. */
  4240. W.textField = function (path, placeholder, widthDp) {
  4241. return W.inlineField({
  4242. path: path,
  4243. placeholder: placeholder,
  4244. widthDp: widthDp || 108
  4245. });
  4246. };
  4247. /**
  4248. * 让跨字段约束立即生效(上界不得小于下界)。
  4249. *
  4250. * 拆成独立的上下限输入框之后,这个约束更容易被触发:
  4251. * 用户可能先把上限改成 50、再回头改下限。没有这一步,界面上就会出现
  4252. * 「下限 500 / 上限 50」这种自相矛盾的组合,要等下一次 load 才被纠正。
  4253. *
  4254. * 返回**是否发生了连带修改** —— 调用方据此决定要不要重绘整页。
  4255. * 大多数情况下只改了自己那一项,不必重绘。
  4256. */
  4257. function syncCrossRules() {
  4258. var changed = false;
  4259. for (var i = 0; i < Store.CROSS_RULES.length; i++) {
  4260. var rule = Store.CROSS_RULES[i];
  4261. var low = Store.getNumber(rule.low);
  4262. var high = Store.getNumber(rule.high);
  4263. if (high < low) {
  4264. Store.set(rule.high, low);
  4265. changed = true;
  4266. }
  4267. }
  4268. return changed;
  4269. }
  4270. /**
  4271. * 数字输入(设计稿里的「停止个数 1 ~ 5」「关注数量 100 ~ 200」都用它)。
  4272. *
  4273. * 第 4 个参数 onLinkedChange 用于「改这一项**连带改了别项**」的情形
  4274. * (见 syncCrossRules):把休息时间下限调到 2000,被抬齐的上限那一格也得刷新。
  4275. * ★ 回调里只刷**兄弟**那一格,不刷自己 —— 自己这一格正被用户编辑着。
  4276. */
  4277. W.numberField = function (path, hint, widthDp, onLinkedChange) {
  4278. return W.inlineField({
  4279. path: path,
  4280. placeholder: hint,
  4281. numeric: true,
  4282. widthDp: widthDp || 56,
  4283. onLinkedChange: onLinkedChange
  4284. });
  4285. };
  4286. /**
  4287. * 「最小值 ~ 最大值」——一行一个区间,两个数**各自独立、直接在栏位里改**。
  4288. *
  4289. * 与「上下限各占一行」的区别只在排版:
  4290. * · 两个数各自一个输入框,互不干扰(改最小值不会顺带动最大值);
  4291. * · `~` 只是把「这两个数是一对」表达出来,不是把两个值塞进一个控件。
  4292. * 一行放下之后功能设置卡短了一半,四个门槛不用滚很久才看完。
  4293. *
  4294. * 为什么不像开关那样拆成两行:开关是**两个独立的动作**(进行关注、进行私信),
  4295. * 而这里的 min/max 是**同一个量的下界与上界**,分开写会让人以为
  4296. * 「关注量下限」和「关注量上限」是两个互不相干的设置。
  4297. *
  4298. * ★ 联动(syncCrossRules)只刷**兄弟**那一格:用户正在输的那一格绝不能被回写抢输入。
  4299. * 联动也不重建整页 —— 重建会把滚动位置清零,用户刚滚到功能设置区就被弹回顶部。
  4300. */
  4301. W.rangeRow = function (pathMin, pathMax, label, separator, widthDp) {
  4302. var minField = null;
  4303. var maxField = null;
  4304. var w = widthDp || 56;
  4305. minField = W.numberField(pathMin, "", w, function () {
  4306. if (maxField) { maxField.paint(); }
  4307. });
  4308. maxField = W.numberField(pathMax, "", w, function () {
  4309. if (minField) { minField.paint(); }
  4310. });
  4311. var line = U.row();
  4312. line.addView(U.text(label, Theme.m.labelSize, Theme.hex.textSecondary, false));
  4313. line.addView(U.flexible());
  4314. line.addView(minField.root);
  4315. line.addView(U.hgap(6));
  4316. line.addView(U.text(separator || "~", Theme.m.labelSize, Theme.hex.textMuted, false));
  4317. line.addView(U.hgap(6));
  4318. line.addView(maxField.root);
  4319. return {
  4320. root: line,
  4321. values: function () {
  4322. return { min: Store.getNumber(pathMin), max: Store.getNumber(pathMax) };
  4323. }
  4324. };
  4325. };
  4326. /* ------------------------------------------------------------ 文件选择行 */
  4327. /**
  4328. * 弹出文件列表并把选中结果写进 store,然后回调刷新。
  4329. *
  4330. * 列文件交给 Device,弹窗交给 Dialog —— 用 `dialogs.select` 的话
  4331. * 在 UI 模式下拿不到返回值,选文件会静默失效(老坑)。
  4332. */
  4333. function pickTextFileInto(storePath, onPicked) {
  4334. var list = Device.listTextFiles();
  4335. if (list.length === 0) {
  4336. Dialog.alert("未找到可用文件",
  4337. "把 .txt / .json / .csv 文件放到 /sdcard/Download 下,\n" +
  4338. "回到这里就能选到了。");
  4339. return;
  4340. }
  4341. Dialog.choose({
  4342. title: "选择文件",
  4343. options: list,
  4344. currentIndex: 0,
  4345. onConfirm: function (index) {
  4346. var picked = list[index];
  4347. /* storePath 可以为空:取材清单这类"不在字段表里的路径"只走回调,
  4348. * 不写 Store(否则会往配置里塞一个表外字段)。 */
  4349. if (storePath) { Store.set(storePath, picked); }
  4350. if (onPicked) { onPicked(picked); }
  4351. toast("已选择:" + picked);
  4352. }
  4353. });
  4354. }
  4355. /**
  4356. * 独立的「选择文件」按钮。
  4357. *
  4358. * 单独抽出来是因为 ID提取页的「使用文本 ID」那一行是
  4359. * 「勾选框 + 弹性占位 + 路径框 + 按钮」的结构,而 filePicker 会自带一个左侧标签,
  4360. * 直接复用会出现同一行两个标签。
  4361. */
  4362. W.filePickerButton = function (storePath, onPicked, label) {
  4363. var button = W.chip(label || "\u9009\u62E9\u6587\u4EF6", "accent");
  4364. button.root.setOnClickListener(function () { pickTextFileInto(storePath, onPicked); });
  4365. return button;
  4366. };
  4367. /**
  4368. * 「路径框 + 选择文件」组合行。设计稿里凡是「随机私信文本 / 已筛 UID 文本」都用它。
  4369. *
  4370. * 路径本身就是个文本值,所以它也是**可直接编辑**的输入框(可以手输/粘贴路径,
  4371. * 不一定非得走「选择文件」)。文件不存在时把字调暗,让「配了路径但文件没放」
  4372. * 一眼看得出来。
  4373. */
  4374. W.filePicker = function (label, storePath, buttonLabel) {
  4375. var field = W.inlineField({
  4376. path: storePath,
  4377. placeholder: "\u6587\u672C\u8DEF\u5F84",
  4378. widthDp: 108,
  4379. align: "right"
  4380. });
  4381. function refreshPath() {
  4382. field.paint();
  4383. var p = Store.getText(storePath);
  4384. var exists = false;
  4385. try {
  4386. exists = p !== "" && files.exists(p);
  4387. } catch (e) {
  4388. exists = false;
  4389. }
  4390. field.edit.setTextColor(U.argb(p === ""
  4391. ? Theme.hex.textFaint
  4392. : (exists ? Theme.hex.textSecondary : Theme.hex.textMuted)));
  4393. }
  4394. refreshPath();
  4395. var button = W.filePickerButton(storePath, refreshPath, buttonLabel);
  4396. var nameView = U.text(label, Theme.m.labelSize, Theme.hex.textSecondary, false);
  4397. nameView.setSingleLine(true);
  4398. var line = U.row();
  4399. line.addView(nameView);
  4400. line.addView(U.flexible());
  4401. line.addView(field.root);
  4402. line.addView(U.hgap(6));
  4403. line.addView(button.root);
  4404. return { root: line, field: field, refreshPath: refreshPath };
  4405. };
  4406. /* ------------------------------------------------------------ 底部渐隐 */
  4407. /**
  4408. * 内容区底部渐隐(规格书 §6:y=577 h=80)。
  4409. *
  4410. * 放在内容区与底栏之间,从透明渐变到**页面底色**:
  4411. * · 内容超出屏幕时,卡片被底栏硬切一刀的观感消失,一眼看出还能滚;
  4412. * · 内容没超出时它叠在空背景上(透明 → 背景色 = 背景色),看不出多余色块。
  4413. * 绝对定位、不吃点击(否则底栏上面那 80dp 就点不动了)。
  4414. */
  4415. W.bottomFade = function () {
  4416. var view = new android.view.View(context);
  4417. view.setBackground(U.fadeBg(Theme.hex.bg));
  4418. view.setClickable(false);
  4419. view.setFocusable(false);
  4420. var lp = new android.widget.FrameLayout.LayoutParams(-1, U.dp(Theme.m.fadeHeight));
  4421. lp.gravity = android.view.Gravity.BOTTOM;
  4422. view.setLayoutParams(lp);
  4423. return view;
  4424. };
  4425. /** 空态(记录 D2):图标框 + 主文案 + 副文案,整块居中。 */
  4426. W.emptyState = function (iconChar, title, subtitle) {
  4427. var box = U.column();
  4428. box.setGravity(android.view.Gravity.CENTER);
  4429. var iconBox = U.text(iconChar, 28, Theme.hex.tabIdle, false);
  4430. iconBox.setGravity(android.view.Gravity.CENTER);
  4431. iconBox.setBackground(U.tintBg("#FFFFFF", 0.05, 20));
  4432. U.params(iconBox, 64, 64);
  4433. box.addView(iconBox);
  4434. box.addView(U.spacer(16));
  4435. var head = U.text(title, 14, Theme.hex.tabIdle, false);
  4436. head.setGravity(android.view.Gravity.CENTER);
  4437. box.addView(head);
  4438. box.addView(U.spacer(6));
  4439. var sub = U.text(subtitle, Theme.m.hintSize, Theme.hex.textDim, false);
  4440. sub.setGravity(android.view.Gravity.CENTER);
  4441. box.addView(sub);
  4442. /* weight=1 撑满剩余高度 → 整块垂直居中(不能靠 gravity,父容器是滚动的) */
  4443. var outer = U.column();
  4444. U.params(outer, -1, 0, 1);
  4445. outer.setGravity(android.view.Gravity.CENTER);
  4446. outer.addView(box);
  4447. return outer;
  4448. };
  4449. /* ------------------------------------------------------------ 返回头 */
  4450. /**
  4451. * 二级页返回头(规格书 §6:高 44,返回热区 44×44,标题 21 SemiBold)。
  4452. *
  4453. * 热区必须做满 44×44:箭头字形只有 20dp,只让它可点的话真机上很难戳中
  4454. * (触控标准里专门写了「返回热区 44×44」)。
  4455. *
  4456. * @param onBack 返回回调
  4457. * @param onSettings 右侧设置入口(只有 ID提取 C1 有),不传则不渲染
  4458. */
  4459. W.backHeader = function (title, onBack, onSettings) {
  4460. var arrow = U.text("\u2039", 22, Theme.hex.textPrimary, false);
  4461. arrow.setGravity(android.view.Gravity.CENTER);
  4462. var hot = U.row(false);
  4463. hot.setGravity(android.view.Gravity.CENTER);
  4464. U.params(hot, Theme.m.backHotSize, Theme.m.backHotSize);
  4465. hot.addView(arrow);
  4466. hot.setOnClickListener(function () { if (onBack) { onBack(); } });
  4467. var head = U.row();
  4468. head.addView(hot);
  4469. head.addView(U.hgap(4));
  4470. var titleView = U.text(title, Theme.m.detailTitleSize, Theme.hex.textPrimary, true);
  4471. titleView.setSingleLine(true);
  4472. titleView.setEllipsize(android.text.TextUtils.TruncateAt.END);
  4473. head.addView(titleView);
  4474. head.addView(U.flexible());
  4475. if (onSettings) {
  4476. var gear = U.text("\u2699", 18, Theme.hex.textSecondary, false);
  4477. gear.setGravity(android.view.Gravity.CENTER);
  4478. U.params(gear, 22, 22);
  4479. gear.setOnClickListener(function () { onSettings(); });
  4480. head.addView(gear);
  4481. }
  4482. return head;
  4483. };
  4484. /* ------------------------------------------------------------ 状态徽标 */
  4485. /**
  4486. * 环形小徽标(任务卡的状态:「空闲 / 运行中 / 未配置」)。
  4487. *
  4488. * 与 W.pill 分开是因为**内边距不同**:pill 是 6/6(给「已安装 v39.5.4」这类
  4489. * 有内容的标签),徽标是 8/3 —— 三四个字的小标签用 6dp 上下留白会胖得像个按钮。
  4490. */
  4491. W.badge = function (label, tone) {
  4492. var t = W.TONES[tone || "mute"] || W.TONES["mute"];
  4493. var view = U.text(label, Theme.m.chipSize, t.text, true);
  4494. view.setGravity(android.view.Gravity.CENTER);
  4495. view.setSingleLine(true);
  4496. view.setBackground(U.solidBg(U.alphaOf("#FFFFFF", t.fill), Theme.m.pillRadius));
  4497. view.setPadding(U.dp(Theme.m.badgePadH), U.dp(Theme.m.badgePadV),
  4498. U.dp(Theme.m.badgePadH), U.dp(Theme.m.badgePadV));
  4499. return {
  4500. root: view,
  4501. setText: function (v) { view.setText(String(v)); },
  4502. setTone: function (next) { W.paintTone(view, next, Theme.m.pillRadius); }
  4503. };
  4504. };
  4505. /* ------------------------------------------------------------ 任务卡 / 记录条目 */
  4506. /**
  4507. * 同色图标框(任务卡 40 / 记录条目 36)。
  4508. * tint 传 hex,alpha 固定取 Theme.alpha.iconBoxFill(14%,设计稿取值)。
  4509. */
  4510. function iconBoxTint(hex, sizeDp, radiusDp, iconChar, iconSp) {
  4511. var box = U.text(iconChar, iconSp, hex, false);
  4512. box.setGravity(android.view.Gravity.CENTER);
  4513. box.setBackground(U.tintBg(hex, Theme.alpha.iconBoxFill, radiusDp));
  4514. U.params(box, sizeDp, sizeDp);
  4515. return box;
  4516. }
  4517. W.taskIconBox = function (hex, iconChar) {
  4518. return iconBoxTint(hex, Theme.m.taskIconBox, Theme.m.taskIconRadius,
  4519. iconChar, Theme.m.taskIconSize);
  4520. };
  4521. W.recordIconBox = function (hex, iconChar) {
  4522. return iconBoxTint(hex, Theme.m.recordIconBox, Theme.m.recordIconRadius,
  4523. iconChar, Theme.m.recordIconSize);
  4524. };
  4525. /**
  4526. * 任务卡(B1/B2)。
  4527. *
  4528. * 结构:图标框 · [任务名 + 状态徽标] / 当前脚本 / 上次运行 · 运行按钮。
  4529. *
  4530. * 两个必须做对的地方:
  4531. * ① 点卡片主体 = 进详情页;点「运行」按钮 = 直接跑 —— **两个命中区**。
  4532. * 合成一个的话,用户想进去改配置却把脚本跑起来了。
  4533. * ② 运行中**不禁用**其他任务的按钮(规格书 §2.3:「按钮仍然可点,
  4534. * 点击后弹 F3 确认,不做禁用」)。禁用会让用户以为"卡死了"。
  4535. *
  4536. * @param opts.onOpen 点卡片主体
  4537. * @param opts.onRun 点运行/停止按钮
  4538. */
  4539. W.taskCard = function (opts) {
  4540. var badge = W.badge(opts.badge, opts.badgeTone);
  4541. var nameLine = U.row(false);
  4542. nameLine.addView(U.text(opts.name, Theme.m.taskNameSize, Theme.hex.textPrimary, true));
  4543. nameLine.addView(U.hgap(8));
  4544. nameLine.addView(badge.root);
  4545. var textGroup = U.column();
  4546. textGroup.addView(nameLine);
  4547. textGroup.addView(U.spacer(4));
  4548. textGroup.addView(U.text(opts.scriptLine, Theme.m.chipSize, Theme.hex.textFaint, false));
  4549. textGroup.addView(U.spacer(4));
  4550. textGroup.addView(U.text(opts.lastLine, Theme.m.chipSize, Theme.hex.textDim, false));
  4551. /* 运行按钮:默认渐变,运行中那一个换成次级样式(设计稿 B2 的「停止」)。 */
  4552. var btnLabel = U.text(opts.running ? "\u505C\u6B62" : "\u8FD0\u884C",
  4553. Theme.m.sectionSize - 1, "#FFFFFF", true);
  4554. btnLabel.setGravity(android.view.Gravity.CENTER);
  4555. var button = new android.widget.FrameLayout(context);
  4556. button.setBackground(opts.running
  4557. ? U.solidBg(U.alphaOf("#FFFFFF", 0.08), Theme.m.pillRadius)
  4558. : U.gradientBg(Theme.hex.accent, Theme.hex.accentDeep, Theme.m.pillRadius));
  4559. U.params(button, Theme.m.taskRunBtnW, Theme.m.taskRunBtnH);
  4560. button.addView(btnLabel);
  4561. if (opts.onRun) { button.setOnClickListener(function () { opts.onRun(); }); }
  4562. var card = U.column();
  4563. card.setBackground(U.strokeBg(Theme.alpha.cardFill, Theme.alpha.cardStroke,
  4564. "#FFFFFF", Theme.m.cardRadius));
  4565. card.setPadding(U.dp(Theme.m.cardPadding), U.dp(Theme.m.cardPadding),
  4566. U.dp(Theme.m.cardPadding), U.dp(Theme.m.cardPadding));
  4567. var line = U.row();
  4568. var iconBox = W.taskIconBox(opts.colorHex, opts.iconChar);
  4569. line.addView(iconBox);
  4570. line.addView(U.hgap(12));
  4571. /* 文本组用 weight=1 撑开:右侧是固定宽 68 的按钮,不会把标签挤成 0 宽
  4572. * (0 宽那类问题只在右侧控件是 MATCH_PARENT 时出现,见 W.settingRow 注释)。 */
  4573. U.params(textGroup, 0, -2, 1);
  4574. line.addView(textGroup);
  4575. line.addView(U.hgap(12));
  4576. line.addView(button);
  4577. card.addView(line);
  4578. /**
  4579. * ★ 点卡片 = 进详情页。**每一块可点区域都要显式挂一次**,不能只挂根容器。
  4580. *
  4581. * 真机踩过(2026-10-13,用户反馈「点任务卡上的脚本进不去参数设置」):
  4582. * 原先这里写了一句 `line.setOnClickListener(null)`,本意是"行内不挂监听、让气泡到卡片"。
  4583. * 但 AutoX 的桥对 `null` 并不保证是"清掉监听"—— 它很可能装上一个
  4584. * **吃掉点击却什么都不做**的空监听。于是:
  4585. * · 点「运行」按钮 → 有反应(按钮是它自己的子视图,装着真监听);
  4586. * · 点卡片的其他任何地方 → 毫无反应(触摸被那一层吞了,到不了 card)。
  4587. * 两件事实拼起来才知道问题在哪 —— 单看"按钮能用"会以为点击链路是好的。
  4588. *
  4589. * 所以现在 card / 图标框 / 文本组 各挂一次同一个回调:
  4590. * 谁接到都做同一件事,既不依赖气泡顺序,也不怕中间某层被点成 clickable。
  4591. */
  4592. if (opts.onOpen) {
  4593. var openDetail = function () { opts.onOpen(); };
  4594. card.setOnClickListener(openDetail);
  4595. iconBox.setOnClickListener(openDetail);
  4596. textGroup.setOnClickListener(openDetail);
  4597. }
  4598. return { root: card, badge: badge, button: button, iconBox: iconBox, textGroup: textGroup };
  4599. };
  4600. /**
  4601. * 记录条目(D1)。
  4602. *
  4603. * 结构:图标框 · [任务 · 脚本 + 结果标签] / 时间行 / 结果行 · 右箭头。
  4604. * 结果行按结果分类换色(报错那条是玫瑰红,一眼能在列表里挑出来)。
  4605. */
  4606. W.recordRow = function (opts) {
  4607. var titleLine = U.row(false);
  4608. titleLine.addView(U.text(opts.title, Theme.m.labelSize, Theme.hex.textPrimary, true));
  4609. titleLine.addView(U.hgap(8));
  4610. titleLine.addView(W.badge(opts.resultLabel, opts.resultTone).root);
  4611. /* 可选动作槽(现在只有「继续」用它)。
  4612. * 按钮作为子 View 单独挂监听 —— 子 View 会自己消费点击,
  4613. * 不会冒泡到整卡的 onOpen,所以"点按钮续跑、点别处进详情"两件事互不干扰。
  4614. * `W.checkRow` 里处理同一问题的手法可以对照着看。 */
  4615. if (opts.actionLabel) {
  4616. var action = W.smallButton(opts.actionLabel, opts.actionTone || "accent",
  4617. function () {
  4618. if (opts.onAction) { opts.onAction(); }
  4619. });
  4620. titleLine.addView(U.flexible());
  4621. titleLine.addView(action.root);
  4622. }
  4623. var resultColor = opts.resultTone === "danger" ? Theme.hex.danger : Theme.hex.textDim;
  4624. var textGroup = U.column();
  4625. textGroup.addView(titleLine);
  4626. textGroup.addView(U.spacer(4));
  4627. textGroup.addView(U.text(opts.timeLine, Theme.m.chipSize, Theme.hex.textFaint, false));
  4628. if (opts.resultLine) {
  4629. textGroup.addView(U.spacer(4));
  4630. textGroup.addView(U.text(opts.resultLine, Theme.m.chipSize, resultColor, false));
  4631. }
  4632. var line = U.row();
  4633. line.addView(W.recordIconBox(opts.colorHex, opts.iconChar));
  4634. line.addView(U.hgap(12));
  4635. U.params(textGroup, 0, -2, 1);
  4636. line.addView(textGroup);
  4637. line.addView(U.hgap(8));
  4638. line.addView(W.chevronRight(18, Theme.hex.textFaint));
  4639. var card = U.column();
  4640. card.setBackground(U.strokeBg(Theme.alpha.cardFill, Theme.alpha.cardStroke,
  4641. "#FFFFFF", Theme.m.recordCardRadius));
  4642. card.setPadding(U.dp(Theme.m.cardPadding), U.dp(Theme.m.cardPadding),
  4643. U.dp(Theme.m.cardPadding), U.dp(Theme.m.cardPadding));
  4644. card.addView(line);
  4645. if (opts.onOpen) { card.setOnClickListener(function () { opts.onOpen(); }); }
  4646. return card;
  4647. };
  4648. /* ------------------------------------------------------------ 小按钮 / 标签按钮 */
  4649. /**
  4650. * 小按钮(「打开 / 分享 / 复制 / 选择」这类行内操作)。
  4651. * 尺寸 26 高 + 8 圆角,与胶囊(全圆角)区分开:胶囊是"状态/开关",方块是"动作"。
  4652. */
  4653. W.smallButton = function (label, tone, onClick, minWidthDp) {
  4654. var chip = W.chip(label, tone || "default", minWidthDp, Theme.m.chipRadius);
  4655. chip.root.setTextSize(Theme.m.chipSize);
  4656. if (onClick) { chip.root.setOnClickListener(function () { onClick(); }); }
  4657. return chip;
  4658. };
  4659. /* ------------------------------------------------------------ 主按钮 */
  4660. /**
  4661. * 底部主按钮:青蓝→紫渐变,48 高,全圆角。文案随运行态在「开始运行 / 停止运行」间切换。
  4662. */
  4663. W.runButton = function (onClick, text) {
  4664. var label = U.text(text || "\u5F00\u59CB\u8FD0\u884C", Theme.m.sectionSize, "#FFFFFF", true);
  4665. label.setGravity(android.view.Gravity.CENTER);
  4666. var button = new android.widget.FrameLayout(context);
  4667. button.setBackground(U.gradientBg(Theme.hex.accent, Theme.hex.accentDeep, Theme.m.pillRadius));
  4668. U.params(button, -1, Theme.m.runBtnHeight);
  4669. button.addView(label);
  4670. if (onClick) { button.setOnClickListener(function () { onClick(); }); }
  4671. return { root: button, label: label };
  4672. };
  4673. /** 次要说明文字(页面底部提示)。 */
  4674. W.hint = function (content, center) {
  4675. var view = U.text(content, Theme.m.hintSize, Theme.hex.textFaint, false);
  4676. if (center) { view.setGravity(android.view.Gravity.CENTER); }
  4677. return view;
  4678. };
  4679. /**
  4680. * 提示条:标题 + 一行说明 + 右侧动作按钮。
  4681. *
  4682. * 为什么单独做一个组件、而不是用 `W.hint` + `W.smallButton` 临时拼:
  4683. * 同一种提示要出现在「ID提取页顶部」与「记录详情页」两处,拼两次必然长歪;
  4684. * 而且 `W.hint` 没有描边,直接在玻璃背景上会"浮"在卡片外 ——
  4685. * 这个界面的分层靠的就是 `U.strokeBg` 的描边(`W.card` 同理)。
  4686. *
  4687. * @param opts { title, detail, tone:'warn'|'accent'|'mute'|'ok', actions:[{label,tone,onClick}] }
  4688. * @returns { root, setTitle, setDetail }
  4689. */
  4690. W.noticeBar = function (opts) {
  4691. var o = opts || {};
  4692. var tone = o.tone || "warn";
  4693. var t = W.TONES[tone] || W.TONES["warn"];
  4694. var box = U.column();
  4695. box.setBackground(U.strokeBg(0.10, 0.24, t.text, Theme.m.cardRadius));
  4696. box.setPadding(U.dp(12), U.dp(12), U.dp(12), U.dp(12));
  4697. var title = U.text(o.title || "", Theme.m.labelSize, t.text, true);
  4698. box.addView(title);
  4699. var detail = null;
  4700. if (o.detail) {
  4701. box.addView(U.spacer(4));
  4702. detail = U.text(o.detail, Theme.m.chipSize, Theme.hex.textDim, false);
  4703. box.addView(detail);
  4704. }
  4705. var actions = o.actions || [];
  4706. if (actions.length > 0) {
  4707. var row = U.row();
  4708. row.addView(U.flexible());
  4709. for (var i = 0; i < actions.length; i++) {
  4710. if (i > 0) { row.addView(U.hgap(8)); }
  4711. /* 用闭包把当前这一项绑住:直接在循环里引用 actions[i] 会因为
  4712. * 点击时才求值而全指向最后一项(这类"全点成同一个"极难发现)。 */
  4713. var btn = W.smallButton(actions[i].label, actions[i].tone || "accent",
  4714. (function (one) {
  4715. return function () {
  4716. if (one && one.onClick) { one.onClick(); }
  4717. };
  4718. }(actions[i])));
  4719. row.addView(btn.root);
  4720. }
  4721. box.addView(U.spacer(10));
  4722. box.addView(row);
  4723. }
  4724. return {
  4725. root: box,
  4726. setTitle: function (text) { title.setText(text); },
  4727. setDetail: function (text) { if (detail) { detail.setText(text); } }
  4728. };
  4729. };
  4730. /* ------------------------------------------------------------ 底部页签 */
  4731. /* 页签顺序即设计稿从左到右的顺序。
  4732. *
  4733. * ★ 2026-10-13 规格书 §1:底栏只有 **3 个 tab**(任务 / 记录 / 我的)。
  4734. * 原来的「ID提取 / UID私信 / 养号 / 视频功能」四个页签
  4735. * 降级为「任务」tab 里的四张任务卡(见 Pages.TASKS)。
  4736. *
  4737. * 图标一律用 U+25xx 几何块字符,**不要用 U+2709 这类有 emoji 变体的码位**——
  4738. * Android 上即使追加 U+FE0E(文本变体选择符)也常被渲染成彩色 emoji,
  4739. * 在深色界面里非常突兀(实测踩过)。换成 PNG 图标只需改这里的 icon 字段。 */
  4740. W.TABS = [
  4741. { key: "tasks", icon: "\u25A3", label: "\u4EFB\u52A1" },
  4742. { key: "records", icon: "\u25F4", label: "\u8BB0\u5F55" },
  4743. { key: "mine", icon: "\u25C9", label: "\u6211\u7684" }
  4744. ];
  4745. W.tabBar = function (activeKey, onSelect) {
  4746. var bar = U.row();
  4747. bar.setGravity(android.view.Gravity.CENTER);
  4748. for (var i = 0; i < W.TABS.length; i++) {
  4749. var tab = W.TABS[i];
  4750. var color = tab.key === activeKey ? Theme.hex.accent : Theme.hex.tabIdle;
  4751. var icon = U.text(tab.icon, 16, color, false);
  4752. icon.setGravity(android.view.Gravity.CENTER);
  4753. var label = U.text(tab.label, Theme.m.tabSize, color, true);
  4754. label.setGravity(android.view.Gravity.CENTER);
  4755. var item = U.column();
  4756. item.setGravity(android.view.Gravity.CENTER);
  4757. item.addView(icon);
  4758. item.addView(U.spacer(4));
  4759. item.addView(label);
  4760. U.params(item, 0, Theme.m.tabBarHeight, 1);
  4761. (function (key) {
  4762. item.setOnClickListener(function () { onSelect(key); });
  4763. })(tab.key);
  4764. bar.addView(item);
  4765. }
  4766. return bar;
  4767. };
  4768. /* ------------------------------------------------------------ 全局运行条 */
  4769. /**
  4770. * 全局运行条(规格书 §2.4 / 设计稿 B2 的 30:304)。
  4771. *
  4772. * 位置:内容区**上方**(状态栏之下),仅「运行中」时出现,空闲/完成整条隐藏。
  4773. * 内容:运行点 · {任务名} · 脚本X · 运行中 + 右侧等宽计时。
  4774. * 报错态:整条变玫瑰红、文案换成「执行出错已停止」、右侧「已终止」。
  4775. *
  4776. * 分工是刻意的:**运行条只管状态与导航(可点击跳回运行中的任务),
  4777. * 停止由页面底部的 CTA 负责** —— 条上不放停止按钮,
  4778. * 否则"点一下条"到底会停止还是跳转,用户没法预期。
  4779. *
  4780. * 计时用等宽字:数字每秒在变,比例字宽会让整条轻微抖动。
  4781. */
  4782. W.runBar = function (text, elapsed, tone) {
  4783. /* tone: "ok"(运行中)/ "warn"(正在收尾)/ "danger"(报错中断) */
  4784. function toneColor(value) {
  4785. if (value === "warn") { return Theme.hex.warn; }
  4786. if (value === "danger") { return Theme.hex.danger; }
  4787. return Theme.hex.ok;
  4788. }
  4789. var dot = U.text("\u25CF", 8, toneColor(tone), false);
  4790. var label = U.text(text, 12, Theme.hex.textBody, true);
  4791. label.setSingleLine(true);
  4792. label.setEllipsize(android.text.TextUtils.TruncateAt.END);
  4793. var time = U.monoText(elapsed, Theme.m.chipSize, toneColor(tone));
  4794. var bar = U.row();
  4795. bar.setGravity(android.view.Gravity.CENTER_VERTICAL);
  4796. bar.setBackground(U.solidBg(U.alphaOf(tone === "danger" ? Theme.hex.danger : Theme.hex.accent,
  4797. Theme.alpha.runBarFill), Theme.m.runBarRadius));
  4798. bar.setPadding(U.dp(Theme.m.runBarPadH), U.dp(Theme.m.runBarPadV),
  4799. U.dp(Theme.m.runBarPadH), U.dp(Theme.m.runBarPadV));
  4800. bar.addView(dot);
  4801. bar.addView(U.hgap(8));
  4802. /* 文案用 flexible 顶开:计时贴右端,长任务名先被省略号吃掉 */
  4803. U.params(label, 0, -2, 1);
  4804. bar.addView(label);
  4805. bar.addView(U.hgap(8));
  4806. bar.addView(time);
  4807. return {
  4808. root: bar,
  4809. setText: function (v) { label.setText(String(v)); },
  4810. setElapsed: function (v) { time.setText(String(v)); },
  4811. /* 色调要能中途换:运行中是绿的,正在收尾是琥珀,报错中断是玫瑰红 ——
  4812. * 三者都是"还在界面上",但用户需要一眼看出它们不是一回事。 */
  4813. setTone: function (v) {
  4814. dot.setTextColor(U.argb(toneColor(v)));
  4815. time.setTextColor(U.argb(toneColor(v)));
  4816. bar.setBackground(U.solidBg(
  4817. U.alphaOf(v === "danger" ? Theme.hex.danger : Theme.hex.accent,
  4818. Theme.alpha.runBarFill), Theme.m.runBarRadius));
  4819. }
  4820. };
  4821. };
  4822. /* ------------------------------------------------------------ 警示图标 */
  4823. /**
  4824. * 弹窗顶部的警示图标(设计稿 16:952 / 16:1481)。
  4825. *
  4826. * 设计稿是一个描边矢量图标。这里改用字符 "!" —— 换成 ⚠(U+26A0) 在 Android 上
  4827. * 会被渲染成彩色 emoji,加 U+FE0E 也压不住(页签图标踩过同一个坑)。
  4828. * 底衬用同色 14% 圆角方块,视觉层级与设计稿一致。
  4829. */
  4830. W.warnIcon = function (hex) {
  4831. var view = U.text("!", 20, hex, true);
  4832. view.setGravity(android.view.Gravity.CENTER);
  4833. U.params(view, Theme.m.warnIconSize, Theme.m.warnIconSize);
  4834. view.setBackground(U.solidBg(U.alphaOf(hex, Theme.alpha.warnFill), Theme.m.warnIconRadius));
  4835. return view;
  4836. };
  4837. /* ------------------------------------------------------------ 弹窗按钮 */
  4838. /**
  4839. * 主按钮(渐变色,弹窗内的「去授权 / 知道了 / 登录激活 / 确认」)。
  4840. *
  4841. * 与 W.runButton 的区别只在尺寸:主按钮吃满卡片宽度但只有 46~48 高,
  4842. * 而开始运行按钮固定在页面底部。两者视觉必须一致,所以共用同一套渐变。
  4843. */
  4844. W.primaryButton = function (label, heightDp, onClick) {
  4845. var text = U.text(label, 15, "#FFFFFF", true);
  4846. text.setGravity(android.view.Gravity.CENTER);
  4847. var button = new android.widget.FrameLayout(context);
  4848. button.setBackground(U.gradientBg(Theme.hex.accent, Theme.hex.accentDeep, Theme.m.pillRadius));
  4849. U.params(button, -1, heightDp || 46);
  4850. button.addView(text);
  4851. if (onClick) { button.setOnClickListener(function () { onClick(); }); }
  4852. return { root: button, label: text };
  4853. };
  4854. /** 实心危险按钮(「确认解绑」):不可逆操作用纯色而不是渐变,视觉上更"重"。 */
  4855. W.dangerButton = function (label, heightDp, onClick) {
  4856. var text = U.text(label, 15, "#FFFFFF", true);
  4857. text.setGravity(android.view.Gravity.CENTER);
  4858. var button = new android.widget.FrameLayout(context);
  4859. button.setBackground(U.solidBg(U.argb(Theme.hex.danger), Theme.m.pillRadius));
  4860. U.params(button, -1, heightDp || 44);
  4861. button.addView(text);
  4862. if (onClick) { button.setOnClickListener(function () { onClick(); }); }
  4863. return { root: button, label: text };
  4864. };
  4865. /** 次级按钮(「取消」):白 6% 填充 + 10% 描边。 */
  4866. W.ghostButton = function (label, heightDp, onClick) {
  4867. var text = U.text(label, 15, Theme.hex.textSecondary, true);
  4868. text.setGravity(android.view.Gravity.CENTER);
  4869. var button = new android.widget.FrameLayout(context);
  4870. button.setBackground(U.strokeBg(0.06, 0.10, "#FFFFFF", Theme.m.pillRadius));
  4871. U.params(button, -1, heightDp || 44);
  4872. button.addView(text);
  4873. if (onClick) { button.setOnClickListener(function () { onClick(); }); }
  4874. return { root: button, label: text };
  4875. };
  4876. /** 居中的次要文字链(「稍后再说」「购买卡密 · 联系客服」「退出应用」)。 */
  4877. W.link = function (label, hex, onClick) {
  4878. var view = U.text(label, 13, hex, false);
  4879. view.setGravity(android.view.Gravity.CENTER);
  4880. /* 触摸目标补到 44dp:纯文字只有 ~16dp 高,直接点很难命中 */
  4881. view.setPadding(U.dp(4), U.dp(12), U.dp(4), U.dp(12));
  4882. if (onClick) { view.setOnClickListener(function () { onClick(); }); }
  4883. return view;
  4884. };
  4885. /* ------------------------------------------------------------ 遮罩 */
  4886. W.mask = function (alpha) {
  4887. var view = new android.view.View(context);
  4888. view.setBackground(U.solidBg(U.alphaOf("#000000",
  4889. alpha === undefined ? Theme.alpha.mask : alpha)));
  4890. return view;
  4891. };
  4892. /* ------------------------------------------------------------ 容器工具 */
  4893. /** 重建页面用。 */
  4894. W.clear = function (container) {
  4895. container.removeAllViews();
  4896. return container;
  4897. };
  4898. /** 竖向滚动容器,页面内容区统一用它兜住超长内容。 */
  4899. W.scroller = function () {
  4900. var scroll = new android.widget.ScrollView(context);
  4901. scroll.setVerticalScrollBarEnabled(false);
  4902. scroll.setOverScrollMode(android.view.View.OVER_SCROLL_NEVER);
  4903. return scroll;
  4904. };
  4905. /* ============================== src/dialog.js ============================== */
  4906. /* ============================== src/dialog.js ==============================
  4907. * 自绘弹窗体系:输入、选择、确认、提示、权限拦截。
  4908. *
  4909. * ── 为什么不用 AutoX 的 dialogs 模块 ───────────────────────────────
  4910. * `"ui";` 模式下主线程就是 UI 线程。`dialogs.select(...)` / `dialogs.rawInput(...)`
  4911. * 这类**不带 callback 的阻塞式调用**在这里拿不到返回值 —— 阻塞会死锁,
  4912. * 所以运行时直接返回 undefined。表现是一连串很难查的现象:
  4913. * · 输入框「只能输一个值」:第一次调用返回 undefined,被当成「取消」,流程直接中断;
  4914. * · 下拉框选完写回 undefined(`Store.keyAt(group, undefined)`),
  4915. * 配置被写坏,重启后又变回默认值;
  4916. * · 弹出来的是系统浅色对话框,跟深色界面完全不是一套视觉。
  4917. *
  4918. * 自己画弹窗同时解决这三点:取值走「确定」按钮的回调,
  4919. * 不存在拿不到返回值的问题;样式也统一由 Theme 控制。
  4920. *
  4921. * 弹窗分两类,命名上刻意区分开:
  4922. * · 询问类(choose / confirm)—— 用户点了才知道结果;
  4923. * · 拦截类(runBlocked / permissionBlocked)—— 告诉用户「为什么跑不起来」。
  4924. * 后者的标题统一是「无法开始运行」,与设计稿 08 / 12 一致。
  4925. *
  4926. * ⚠️ 这里**没有**输入类弹窗(askText / askNumber 已删除)。
  4927. * 文本与数字一律用页面上的常驻输入框(`W.inlineField`)**直接改**——
  4928. * 少一次点击,也少一层「弹窗里的值有没有被写回去」的不确定性。
  4929. * 保留下来的弹窗都是**非输入**的:选一项、确认一次、被告知一次。
  4930. *
  4931. * 所有弹窗都挂在 Dialog.host(由 main.js 注入)上,同时只允许存在一个。
  4932. */
  4933. var Dialog = {};
  4934. Dialog.WIDTH = 315;
  4935. /* 宿主容器与当前打开的弹窗 */
  4936. Dialog.host = null;
  4937. Dialog.active = null;
  4938. /** 由 main.js 在搭好骨架后注入浮层容器。 */
  4939. Dialog.mount = function (host) { Dialog.host = host; };
  4940. /**
  4941. * 把弹窗挂到宿主上。
  4942. * 挂之前先关掉已有弹窗——两层弹窗叠着关不掉,是这类自绘 UI 最容易出的岔子。
  4943. */
  4944. function present(overlay) {
  4945. if (!Dialog.host) {
  4946. console.log("[dialog] 宿主未注入,弹窗无法显示");
  4947. return null;
  4948. }
  4949. if (Dialog.active) { Dialog.active.close(); }
  4950. var handle = {
  4951. root: overlay,
  4952. close: function () {
  4953. var parent = overlay.getParent();
  4954. if (parent) { parent.removeView(overlay); }
  4955. if (Dialog.active === handle) { Dialog.active = null; }
  4956. }
  4957. };
  4958. Dialog.active = handle;
  4959. Dialog.host.addView(overlay);
  4960. return handle;
  4961. }
  4962. /* ---------------------------------------------------------------- 骨架 */
  4963. /**
  4964. * 弹窗底部按钮行。
  4965. *
  4966. * 设计稿里的弹窗按钮是**等宽铺满**的两枚(16:960 取消 / 确认解绑),
  4967. * 或整行一枚(16:1068 知道了)。所以这里不接受「几个胶囊靠右排」的写法,
  4968. * 一律按等分宽度布局——按钮宽度不等会让两个弹窗看起来不像一套。
  4969. *
  4970. * specs: [{ label, variant: "primary"|"danger"|"ghost", onClick }]
  4971. */
  4972. function dialogButtons(specs) {
  4973. var line = U.row();
  4974. for (var i = 0; i < specs.length; i++) {
  4975. var spec = specs[i];
  4976. var height = spec.height || 46;
  4977. var button = spec.variant === "danger" ? W.dangerButton(spec.label, height, spec.onClick)
  4978. : spec.variant === "ghost" ? W.ghostButton(spec.label, height, spec.onClick)
  4979. : W.primaryButton(spec.label, height, spec.onClick);
  4980. /* 宽度 0 + weight 1 = 与同行的兄弟等分剩余宽度 */
  4981. U.params(button.root, 0, height, 1);
  4982. if (i > 0) { line.addView(U.hgap(12)); }
  4983. line.addView(button.root);
  4984. }
  4985. return line;
  4986. }
  4987. /**
  4988. * 取消 + 确定 的成对按钮,绝大多数询问类弹窗都用它。
  4989. *
  4990. * ⚠️ 「确定」**不预先关窗**,是否关窗交给 onConfirm 自己决定。
  4991. * 早先写成「先 close() 再 onConfirm()」,直接毁掉了输入类弹窗的关键行为:
  4992. * 用户输了非法值,窗口已经关了,只能在原地提示的意图落空 ——
  4993. * 他得重新点开、重新输入。校验逻辑写对了也没用,因为关窗发生得更早。
  4994. */
  4995. function cancelConfirmRow(close, onConfirm, confirmLabel, confirmVariant) {
  4996. return dialogButtons([
  4997. { label: "\u53D6\u6D88", variant: "ghost", height: 44, onClick: close },
  4998. { label: confirmLabel || "\u786E\u5B9A", variant: confirmVariant || "primary", height: 44,
  4999. onClick: onConfirm }
  5000. ]);
  5001. }
  5002. /** 单枚「知道了」。 */
  5003. function acknowledgeRow(close, label) {
  5004. return dialogButtons([
  5005. { label: label || "\u77E5\u9053\u4E86", variant: "primary", height: 46,
  5006. onClick: function () { close(); } }
  5007. ]);
  5008. }
  5009. /**
  5010. * 弹窗骨架:遮罩 + 居中卡片。
  5011. * 返回 { overlay, card },调用方往 card 里塞内容。
  5012. */
  5013. function buildSheet(title) {
  5014. var overlay = new android.widget.FrameLayout(context);
  5015. overlay.setLayoutParams(new android.widget.FrameLayout.LayoutParams(-1, -1));
  5016. overlay.addView(W.mask(), new android.widget.FrameLayout.LayoutParams(-1, -1));
  5017. var card = U.column();
  5018. card.setBackground(U.strokeBg(0.06, 0.12, "#FFFFFF", Theme.m.dialogRadius));
  5019. card.setPadding(U.dp(24), U.dp(24), U.dp(24), U.dp(24));
  5020. /* 卡片必须吞掉点击:否则点卡片会冒泡到遮罩,把弹窗直接关掉 */
  5021. card.setClickable(true);
  5022. var lp = new android.widget.FrameLayout.LayoutParams(U.dp(Dialog.WIDTH), -2);
  5023. lp.gravity = android.view.Gravity.CENTER;
  5024. overlay.addView(card, lp);
  5025. if (title) {
  5026. card.addView(U.text(title, 18, Theme.hex.textPrimary, true));
  5027. card.addView(U.spacer(10));
  5028. }
  5029. return { overlay: overlay, card: card };
  5030. }
  5031. /** 弹窗副标题(说明文字)。字号与设计稿一致:12sp / 行距 17。 */
  5032. function sheetNote(text) {
  5033. var view = U.text(text, 12, Theme.hex.textMuted, false);
  5034. view.setLineSpacing(U.dp(3), 1.0);
  5035. return view;
  5036. }
  5037. /** 多行说明文字:弹窗里长文案要能换行。 */
  5038. function wrappedText(message) {
  5039. var view = U.text(message, 12, Theme.hex.textMuted, false);
  5040. view.setLineSpacing(U.dp(4), 1.0);
  5041. return view;
  5042. }
  5043. /* ---------------------------------------------------------------- 提示 */
  5044. /** 极简提示:标题 + 说明 + 「知道了」。 */
  5045. Dialog.alert = function (title, message) {
  5046. var sheet = buildSheet(title);
  5047. var handle = null;
  5048. sheet.card.addView(wrappedText(message));
  5049. sheet.card.addView(U.spacer(20));
  5050. sheet.card.addView(acknowledgeRow(function () { if (handle) { handle.close(); } }));
  5051. handle = present(sheet.overlay);
  5052. return handle;
  5053. };
  5054. /**
  5055. * 「无法开始运行」拦截弹窗(单按钮形态,设计稿 16:1065)。
  5056. * 与 alert 分开是因为**语义不同**:alert 是告知,这个是拦下了一次操作。
  5057. * 分开之后调用点读起来就是「被拦了,因为……」,不用去猜 title 里该写什么。
  5058. */
  5059. Dialog.runBlocked = function (message, onAcknowledge) {
  5060. var sheet = buildSheet("\u65E0\u6CD5\u5F00\u59CB\u8FD0\u884C");
  5061. var handle = null;
  5062. sheet.card.addView(sheetNote(message));
  5063. sheet.card.addView(U.spacer(20));
  5064. sheet.card.addView(acknowledgeRow(function () {
  5065. if (handle) { handle.close(); }
  5066. if (onAcknowledge) { onAcknowledge(); }
  5067. }));
  5068. handle = present(sheet.overlay);
  5069. return handle;
  5070. };
  5071. /**
  5072. * 「该脚本的执行引擎尚未接入」(拦截弹窗)。
  5073. *
  5074. * 与 runBlocked 分开写,是因为两者要用户做的事完全不同:
  5075. * runBlocked 是"你现在就能修好"(去勾一个脚本),
  5076. * 这个是"暂时做不了"(引擎还没写)。混用一个弹窗会让用户白找一圈。
  5077. */
  5078. Dialog.engineMissing = function (scriptName) {
  5079. return Dialog.runBlocked(
  5080. "\u300C" + scriptName + "\u300D\u7684\u6267\u884C\u5F15\u64CE\u5C1A\u672A\u63A5\u5165\u3002\n\n"
  5081. + "\u76EE\u524D\u5DF2\u63A5\u5165\u5F15\u64CE\u7684\u662F\u300C\u89C6\u9891\u529F\u80FD\u300D"
  5082. + "\u9875\u7684\u300C\u5173\u952E\u8BCD\u89C6\u9891\u529F\u80FD\u300D\u3002");
  5083. };
  5084. /** 「引擎起不来」——缺产物、缺关键词、启动报错。message 里带可操作的指引。 */
  5085. Dialog.engineUnavailable = function (message) {
  5086. return Dialog.runBlocked(message);
  5087. };
  5088. /**
  5089. * F3 切换任务确认(设计稿 30:1011)。
  5090. *
  5091. * 全局单跑(规格书 §2.3):运行中点别的任务的「运行」**不禁用按钮**,
  5092. * 而是弹这个确认。禁用按钮会让用户以为"卡死了",
  5093. * 而"先停旧的再起新的"是一个明确的、可以自己决定的后果。
  5094. *
  5095. * 标题里的运行中任务名是**真实读出来的**(Pages.taskName(Session.tab)),
  5096. * 不是固定文案 —— 用户需要知道到底是"谁"要被停掉。
  5097. */
  5098. Dialog.switchTask = function (runningName, targetName, onConfirm) {
  5099. var sheet = buildSheet(runningName + "\u6B63\u5728\u8FD0\u884C");
  5100. var handle = null;
  5101. sheet.card.addView(sheetNote(
  5102. "\u5F00\u59CB " + targetName + " \u5C06\u5148\u505C\u6B62\u5F53\u524D\u811A\u672C\uFF08\u5168\u5C40\u5355\u8DD1\uFF09"));
  5103. sheet.card.addView(U.spacer(20));
  5104. sheet.card.addView(cancelConfirmRow(
  5105. function () { if (handle) { handle.close(); } },
  5106. function () {
  5107. if (handle) { handle.close(); }
  5108. /* 先停旧的、再起新的:停止是**请求式**的(写 stop.flag),
  5109. * 不等它回执就起新引擎 —— 两边 runId 不同,
  5110. * 旧引擎的终态回报会被界面按 runId 过滤掉,不会污染新一轮。 */
  5111. App.stopRun();
  5112. if (onConfirm) { onConfirm(); }
  5113. },
  5114. "\u505C\u6B62\u5E76\u5F00\u59CB"
  5115. ));
  5116. handle = present(sheet.overlay);
  5117. return handle;
  5118. };
  5119. /**
  5120. * H5 停止二次确认(规格书 §10.6)。
  5121. *
  5122. * 悬浮球上的「停止」一定要确认:它浮在别的应用上,误触的概率远高于 App 内的按钮,
  5123. * 而停止的后果是**进度不保留**(下次从开头重跑)。
  5124. * 反过来「运行」不确认 —— 起跑是可逆的(再点一次停止即可),
  5125. * 两个动作的风险不对称,确认门槛也就不该一样。
  5126. */
  5127. Dialog.stopScript = function (scriptName, elapsedText, onConfirm) {
  5128. var sheet = buildSheet("\u505C\u6B62\u5F53\u524D\u811A\u672C\uFF1F");
  5129. var handle = null;
  5130. sheet.card.addView(sheetNote(String(scriptName) + " \u5DF2\u8FD0\u884C " + String(elapsedText)));
  5131. sheet.card.addView(U.spacer(6));
  5132. sheet.card.addView(sheetNote("\u505C\u6B62\u540E\u8FDB\u5EA6\u4E0D\u4FDD\u7559\uFF0C\u4E0B\u6B21\u5C06\u4ECE\u5F00\u5934\u91CD\u65B0\u5F00\u59CB"));
  5133. sheet.card.addView(U.spacer(20));
  5134. sheet.card.addView(cancelConfirmRow(
  5135. function () { if (handle) { handle.close(); } },
  5136. function () {
  5137. if (handle) { handle.close(); }
  5138. if (onConfirm) { onConfirm(); }
  5139. },
  5140. "\u505C\u6B62\u8FD0\u884C",
  5141. "danger"
  5142. ));
  5143. handle = present(sheet.overlay);
  5144. return handle;
  5145. };
  5146. /** H6 退出应用二次确认(规格书 §10.6 / §10.7:停脚本 + 关球 + 退到桌面)。 */
  5147. Dialog.exitApp = function (onConfirm) {
  5148. var sheet = buildSheet("\u9000\u51FA\u98DE\u821F\u811A\u672C\uFF1F");
  5149. var handle = null;
  5150. sheet.card.addView(sheetNote("\u5C06\u505C\u6B62\u6B63\u5728\u8FD0\u884C\u7684\u811A\u672C\uFF0C\u5E76\u5173\u95ED\u60AC\u6D6E\u7403"));
  5151. sheet.card.addView(U.spacer(6));
  5152. sheet.card.addView(sheetNote("\u811A\u672C\u8FDB\u5EA6\u4E0D\u4FDD\u7559\uFF0C\u4E0B\u6B21\u8FD0\u884C\u5C06\u4ECE\u5F00\u5934\u91CD\u65B0\u5F00\u59CB"));
  5153. sheet.card.addView(U.spacer(20));
  5154. sheet.card.addView(cancelConfirmRow(
  5155. function () { if (handle) { handle.close(); } },
  5156. function () {
  5157. if (handle) { handle.close(); }
  5158. if (onConfirm) { onConfirm(); }
  5159. },
  5160. "\u9000\u51FA\u5E94\u7528",
  5161. "danger"
  5162. ));
  5163. handle = present(sheet.overlay);
  5164. return handle;
  5165. };
  5166. /**
  5167. * F5 脚本报错(设计稿 30:1228)。
  5168. *
  5169. * 与 runBlocked / alert 都不同:它有两个出口,而且**两个出口都要给**——
  5170. * 「查看日志」直接进 D3(用户想弄清楚为什么挂了),
  5171. * 「知道了」只是收掉。合成一个按钮会逼用户在"看不到原因"和"被迫跳页"之间二选一。
  5172. */
  5173. Dialog.scriptError = function (message, onViewLogs, onDismiss) {
  5174. var sheet = buildSheet();
  5175. var handle = null;
  5176. /* 顶部警示图标:字符 "!" + 玫瑰色 14% 衬底(矢量图标在字符方案里见 W.warnIcon) */
  5177. sheet.card.addView(W.warnIcon(Theme.hex.danger));
  5178. sheet.card.addView(U.spacer(12));
  5179. sheet.card.addView(U.text("\u811A\u672C\u6267\u884C\u51FA\u9519", 18, Theme.hex.textPrimary, true));
  5180. sheet.card.addView(U.spacer(10));
  5181. sheet.card.addView(sheetNote(message || "\u811A\u672C\u5DF2\u505C\u6B62\u8FD0\u884C"));
  5182. sheet.card.addView(U.spacer(20));
  5183. sheet.card.addView(dialogButtons([
  5184. { label: "\u67E5\u770B\u65E5\u5FD7", variant: "ghost", height: 44, onClick: function () {
  5185. if (handle) { handle.close(); }
  5186. if (onViewLogs) { onViewLogs(); }
  5187. } },
  5188. { label: "\u77E5\u9053\u4E86", variant: "primary", height: 44, onClick: function () {
  5189. if (handle) { handle.close(); }
  5190. if (onDismiss) { onDismiss(); }
  5191. } }
  5192. ]));
  5193. handle = present(sheet.overlay);
  5194. return handle;
  5195. };
  5196. /** 确认:取消 / 确定,结果走回调。 */
  5197. Dialog.confirm = function (title, message, onConfirm) {
  5198. var sheet = buildSheet(title);
  5199. var handle = null;
  5200. sheet.card.addView(wrappedText(message));
  5201. sheet.card.addView(U.spacer(20));
  5202. sheet.card.addView(cancelConfirmRow(
  5203. function () { if (handle) { handle.close(); } },
  5204. function () {
  5205. if (handle) { handle.close(); }
  5206. if (onConfirm) { onConfirm(); }
  5207. }
  5208. ));
  5209. handle = present(sheet.overlay);
  5210. return handle;
  5211. };
  5212. /* ---------------------------------------------------------------- 单选 */
  5213. /**
  5214. * 单选弹窗。选项竖排,当前项高亮。
  5215. * 比下拉列表更适合深色界面,也避免了下拉框「选完写回 undefined」那一类问题。
  5216. */
  5217. Dialog.choose = function (opts) {
  5218. var sheet = buildSheet(opts.title);
  5219. var handle = null;
  5220. var options = opts.options || [];
  5221. for (var i = 0; i < options.length; i++) {
  5222. var selected = i === opts.currentIndex;
  5223. var item = U.text(options[i], Theme.m.labelSize,
  5224. selected ? Theme.hex.accentText : Theme.hex.textSecondary,
  5225. selected);
  5226. item.setGravity(android.view.Gravity.CENTER_VERTICAL);
  5227. item.setBackground(selected
  5228. ? U.solidBg(U.alphaOf("#FFFFFF", Theme.alpha.accentChipFill), Theme.m.chipRadius)
  5229. : U.solidBg(U.alphaOf("#FFFFFF", 0), Theme.m.chipRadius));
  5230. item.setPadding(U.dp(12), U.dp(11), U.dp(12), U.dp(11));
  5231. (function (index, view) {
  5232. view.setOnClickListener(function () {
  5233. if (handle) { handle.close(); }
  5234. if (opts.onConfirm) { opts.onConfirm(index); }
  5235. });
  5236. })(i, item);
  5237. if (i > 0) { sheet.card.addView(U.spacer(6)); }
  5238. sheet.card.addView(item);
  5239. }
  5240. sheet.card.addView(U.spacer(20));
  5241. sheet.card.addView(dialogButtons([
  5242. { label: "\u53D6\u6D88", variant: "ghost", height: 44,
  5243. onClick: function () { if (handle) { handle.close(); } } }
  5244. ]));
  5245. handle = present(sheet.overlay);
  5246. return handle;
  5247. };
  5248. /* ---------------------------------------------------------------- 权限 */
  5249. /**
  5250. * 单行权限:名称 + 状态标签。整行可点,点了直接跳对应设置页。
  5251. *
  5252. * 状态文案只有三种,集中在 W.pill 的 tone 里表达:
  5253. * 已开启 → ok(青绿)
  5254. * 未开启 → warn(琥珀)—— 这是阻断项
  5255. * 未开启(可选) → mute(灰)—— 不阻断,故意不抢注意力
  5256. * 把「可选」写进标签本身,而不是靠颜色让用户猜,是这里最要紧的一点。
  5257. */
  5258. function permissionRow(row) {
  5259. var nameView = U.text(row.name, Theme.m.labelSize, Theme.hex.textPrimary, false);
  5260. nameView.setSingleLine(true);
  5261. var tone = row.granted ? "ok" : (row.optional ? "mute" : "warn");
  5262. var text = row.granted ? "\u5DF2\u5F00\u542F"
  5263. : (row.optional ? "\u672A\u5F00\u542F\uFF08\u53EF\u9009\uFF09" : "\u672A\u5F00\u542F");
  5264. var status = W.pill(text, tone);
  5265. var line = U.row();
  5266. line.addView(nameView);
  5267. line.addView(U.flexible());
  5268. line.addView(U.hgap(Theme.m.gapInline));
  5269. line.addView(status.root);
  5270. /* 整行都算命中区:可视高度只有 26dp 左右,
  5271. * 不加高会低于 44dp 的最小触摸目标。 */
  5272. line.setPadding(0, U.dp(9), 0, U.dp(9));
  5273. line.setMinimumHeight(U.dp(Theme.m.touchTarget));
  5274. line.setOnClickListener(function () {
  5275. Device.openPermissionSettings(row.key);
  5276. });
  5277. return line;
  5278. }
  5279. /** 把三行权限按固定顺序铺进卡片。两个权限弹窗共用,避免顺序各写各的。 */
  5280. function stackPermissionRows(card) {
  5281. var rows = Device.permissionRows();
  5282. for (var i = 0; i < rows.length; i++) {
  5283. if (i > 0) { card.addView(U.spacer(8)); }
  5284. card.addView(permissionRow(rows[i]));
  5285. }
  5286. return rows;
  5287. }
  5288. /**
  5289. * 「去授权」该跳哪一项。
  5290. *
  5291. * 规则:先找**阻断项**(未开启且非可选),都开了再退而处理可选的通知。
  5292. * 早先的实现直接取第一个未授权项,会在「无障碍已开、通知没开」时
  5293. * 把用户带到通知设置页 —— 而他真正缺的是悬浮窗。
  5294. */
  5295. function firstPendingPermission() {
  5296. var rows = Device.permissionRows();
  5297. var i;
  5298. for (i = 0; i < rows.length; i++) {
  5299. if (!rows[i].granted && !rows[i].optional) { return rows[i]; }
  5300. }
  5301. for (i = 0; i < rows.length; i++) {
  5302. if (!rows[i].granted) { return rows[i]; }
  5303. }
  5304. return null;
  5305. }
  5306. /** 权限行 + 去授权/稍后再说 的公共尾部。 */
  5307. function buildPermissionSheet(title, note) {
  5308. var sheet = buildSheet(title);
  5309. var handle = null;
  5310. sheet.card.addView(sheetNote(note));
  5311. sheet.card.addView(U.spacer(18));
  5312. stackPermissionRows(sheet.card);
  5313. sheet.card.addView(U.spacer(20));
  5314. var go = W.primaryButton("\u53BB\u6388\u6743", 46, function () {
  5315. var pending = firstPendingPermission();
  5316. if (!pending) {
  5317. toast("\u6743\u9650\u5DF2\u9F50\u5168");
  5318. if (handle) { handle.close(); }
  5319. return;
  5320. }
  5321. Device.openPermissionSettings(pending.key);
  5322. /* 跳出去后不自动关闭:用户开了权限再退回来,
  5323. * 留在弹窗上能让他看到状态已经变绿。 */
  5324. toast("\u8BF7\u5F00\u542F\u300C" + pending.name + "\u300D\u540E\u8FD4\u56DE");
  5325. });
  5326. sheet.card.addView(go.root);
  5327. /* 「稍后再说」按设计稿是纯文字,不占按钮位 */
  5328. sheet.card.addView(W.link("\u7A0D\u540E\u518D\u8BF4", Theme.hex.textFaint, function () {
  5329. if (handle) { handle.close(); }
  5330. }));
  5331. handle = present(sheet.overlay);
  5332. return handle;
  5333. }
  5334. /**
  5335. * 权限检测弹窗(设计稿 7:260)。
  5336. * 只做展示与跳转,不自己改任何状态(检测与执行分离)。
  5337. */
  5338. Dialog.showPermissions = function () {
  5339. return buildPermissionSheet("\u9700\u8981\u5F00\u542F\u6743\u9650",
  5340. "\u4E3A\u6B63\u5E38\u6267\u884C\u811A\u672C\uFF0C\u8BF7\u5148\u6388\u4E88\u4EE5\u4E0B\u6743\u9650");
  5341. };
  5342. /**
  5343. * 权限拦截弹窗(设计稿 16:436)。
  5344. *
  5345. * 与 showPermissions 的区别不是文案,而是**时机与语气**:
  5346. * 前者是进 App 时的软引导,后者是用户按了「开始运行」却被挡回来;
  5347. * 所以标题是「无法开始运行」,说明也直接点名缺哪两个权限。
  5348. */
  5349. Dialog.permissionBlocked = function () {
  5350. return buildPermissionSheet("\u65E0\u6CD5\u5F00\u59CB\u8FD0\u884C",
  5351. "\u65E0\u969C\u788D\u4E0E\u60AC\u6D6E\u7A97\u6743\u9650\u672A\u5F00\u542F\uFF0C\u811A\u672C\u65E0\u6CD5\u6267\u884C");
  5352. };
  5353. /* ------------------------------------------------------------ 危险操作确认 */
  5354. /**
  5355. * 解绑卡密确认(设计稿 16:951)。
  5356. *
  5357. * 三件事必须同时说清,否则用户点完才后悔:
  5358. * ① 后果是什么(要重新输入卡密);② 有没有更严重的连带影响(正在跑的会停)。
  5359. * 确认按钮用实心红而不是渐变青蓝 —— 渐变在这套界面里代表「推荐动作」。
  5360. */
  5361. Dialog.confirmUnbind = function (onConfirm) {
  5362. var sheet = buildSheet(null);
  5363. var handle = null;
  5364. sheet.card.addView(W.warnIcon(Theme.hex.danger));
  5365. sheet.card.addView(U.spacer(16));
  5366. sheet.card.addView(U.text("\u786E\u8BA4\u89E3\u7ED1\u5361\u5BC6\uFF1F", 18, Theme.hex.textPrimary, true));
  5367. sheet.card.addView(U.spacer(10));
  5368. sheet.card.addView(sheetNote("\u89E3\u7ED1\u540E\u9700\u91CD\u65B0\u8F93\u5165\u5361\u5BC6\u624D\u80FD\u4F7F\u7528"));
  5369. sheet.card.addView(U.spacer(4));
  5370. sheet.card.addView(sheetNote("\u6B63\u5728\u8FD0\u884C\u7684\u811A\u672C\u5C06\u7ACB\u5373\u505C\u6B62"));
  5371. sheet.card.addView(U.spacer(20));
  5372. sheet.card.addView(cancelConfirmRow(
  5373. function () { if (handle) { handle.close(); } },
  5374. function () {
  5375. if (handle) { handle.close(); }
  5376. if (onConfirm) { onConfirm(); }
  5377. },
  5378. "\u786E\u8BA4\u89E3\u7ED1",
  5379. "danger"
  5380. ));
  5381. handle = present(sheet.overlay);
  5382. return handle;
  5383. };
  5384. /**
  5385. * 卡密到期弹窗(设计稿 16:1480)。
  5386. * 唯一的出口是「确认」,确认后回到登录页 —— 到期后留在主界面没有任何可做的事。
  5387. */
  5388. Dialog.licenseExpired = function (onConfirm) {
  5389. var sheet = buildSheet(null);
  5390. var handle = null;
  5391. sheet.card.addView(W.warnIcon(Theme.hex.warn));
  5392. sheet.card.addView(U.spacer(16));
  5393. sheet.card.addView(U.text("\u6B63\u5728\u8FD0\u884C\u7684\u811A\u672C\u5DF2\u505C\u6B62", 18, Theme.hex.textPrimary, true));
  5394. sheet.card.addView(U.spacer(10));
  5395. sheet.card.addView(sheetNote("\u5361\u5BC6\u5DF2\u5230\u671F\uFF0C\u9700\u91CD\u65B0\u767B\u5F55\u540E\u7EE7\u7EED\u4F7F\u7528"));
  5396. sheet.card.addView(U.spacer(20));
  5397. sheet.card.addView(dialogButtons([
  5398. { label: "\u786E\u8BA4", variant: "primary", height: 46, onClick: function () {
  5399. if (handle) { handle.close(); }
  5400. if (onConfirm) { onConfirm(); }
  5401. } }
  5402. ]));
  5403. sheet.card.addView(U.spacer(10));
  5404. var tail = U.text("\u786E\u8BA4\u540E\u5C06\u8FD4\u56DE\u5361\u5BC6\u767B\u5F55\u754C\u9762",
  5405. 11, Theme.hex.textFaint, false);
  5406. tail.setGravity(android.view.Gravity.CENTER);
  5407. sheet.card.addView(tail);
  5408. handle = present(sheet.overlay);
  5409. return handle;
  5410. };
  5411. /* ============================== src/floatball.js ============================== */
  5412. /* ============================== src/floatball.js ==============================
  5413. * 全局悬浮球(规格书 §10 / 设计稿 H1–H7 `34:*`)。
  5414. *
  5415. * ## 它解决什么
  5416. * 引擎必须待在 TikTok 前台才认得出页面,所以 App 内的「全局运行条」在真正
  5417. * 干活的时候是看不见的。悬浮球浮在所有应用之上,是**跨 App 唯一的控制入口
  5418. * 与运行状态可视处**(规格书 §10 的定位)。
  5419. *
  5420. * ## ★ 窗口层:两个 overlay 窗口,尺寸与位置恒定(2026-10-15 重做)
  5421. *
  5422. * **不要改回"一个窗口、展开时改尺寸"。** 那条路必然"跳",而且顺序怎么排都躲不掉:
  5423. * 改窗口尺寸/位置由系统(WindowManager,另一个进程)**立刻生效**,
  5424. * 而我们这边的视图重排要等 App 下一帧重绘;中间那几帧,合成器显示的是
  5425. * **上一帧的旧画面被摆到新的窗口矩形里**。用录屏逐帧验证过:展开那一刻球会
  5426. * 从下往上"爬"几级才停 —— 就是用户说的"跳动"。
  5427. * 两次失败的补救(都在真机上试过):
  5428. * · 同一个 JS 调用里"先藏球、再改尺寸" —— 藏只是标记,改尺寸已经发出去了,照样跳;
  5429. * · "藏起来 → 等这一帧画完(onPreDraw)→ 再改尺寸" —— 合成器有 2~3 帧缓冲队列,
  5430. * 它照样能把"藏之前"那一帧摆到新位置上去。
  5431. * ⇒ 结论:只能**根本不改窗口几何**。两个窗口各自建好就永不动窝
  5432. * (只有用户拖动时球窗口才移,那是用户主动的动作):
  5433. * · 球窗口 88×88dp:环 + 球 + 透明热区,永远可触摸;
  5434. * · 扇形窗口 308×264dp:4 个按钮 + 4 个标签,**折叠时整窗 NOT_TOUCHABLE**
  5435. * (触摸自动穿透到下面的 App,所以它的透明区不是死区),展开时才可触摸。
  5436. * 于是"展开/收起"= 改显隐 + 改一个 flag,没有任何几何变化,没有任何东西会跳。
  5437. *
  5438. * ## ⚠️ 其他实测出来的约束(踩完才知道)
  5439. * 1. 走**裸 WindowManager**(`addView` / `updateViewLayout` / `removeView`),
  5440. * 而不是 AutoX 的 `floaty.window`:
  5441. * · `floaty.rawWindow(代码建的 View)` 直接不可用(缺 `toXMLString()`);
  5442. * · `floaty` 的窗口会在内容外再套一圈 **12dp 内边距**(去不掉),几何全靠反推;
  5443. * · `floaty` 同时只能有一个窗口真正绘制(第二个建得出来但画不出来);
  5444. * · `floaty` 默认还带一个调整手柄小点。
  5445. * 裸窗口没有这些问题 —— 两个窗口能同时绘制是实测过的(`_probe/fb_two_windows.js`)。
  5446. * 本模块用视图自己的 `setPadding` 复现了那圈 12dp 内边距,
  5447. * 所以"球心在根视图里的坐标"这套算法一个字没改。
  5448. * 2. **窗口的透明区同样会吃掉触摸**(实测:点它透明区里的输入框键盘不弹,
  5449. * 点窗口外就弹)。所以折叠时扇形窗口必须 NOT_TOUCHABLE,否则那是一片死区。
  5450. * 3. 覆盖窗口必须 `FLAG_NOT_FOCUSABLE`(§10.9④),否则会抢 TikTok 的键盘焦点。
  5451. * 4. `WindowManager.LayoutParams` 的 x/y/w/h 都是**像素**,dp 换算只在 `dp()` 里做。
  5452. * 5. **UI 线程禁止 `sleep()`** —— 本模块所有时序一律 `setTimeout` / `setInterval`。
  5453. * 6. 图片只能 `images.read(path).bitmap` + `setImageBitmap(bmp)`;
  5454. * XML 的 `src="…"` 走的是 drawable 查找,**不吃文件路径**(会 InflateException)。
  5455. *
  5456. * ## 与规格书的对应
  5457. * §10.1 Ø48 球 / 透明热区 64 / 扇形 R=96 / 子按钮 Ø48 / 夹角 60°20°−20°−60°
  5458. * §10.3 扇形四个按钮:运行·停止 / 回到 App / 隐藏悬浮球 / 退出应用
  5459. * §10.4 三态靠**外环**表达(空闲无环 / 运行中青绿 22% / 报错玫瑰 20%),
  5460. * 球内**恒定**是品牌图标(避免"球里是播放键 → 以为点了就运行"的歧义)
  5461. * §10.5 单击 = 展开收起;拖动 > 8px = 移动;松手吸附最近边缘并记忆位置
  5462. * §10.11 球在右侧时扇形**镜像向左展开**、标签移到按钮左侧
  5463. */
  5464. var FB = {};
  5465. /* ------------------------------------------------------------ 常量(对齐 §10.1) */
  5466. FB.DIAM = 48; // 球体直径
  5467. FB.HOT = 64; // 透明热区(球的触控目标比视觉大)
  5468. FB.R = 96; // 扇形半径
  5469. FB.SUB = 48; // 子按钮直径
  5470. FB.LABEL_H = 24; // 标签胶囊高
  5471. FB.ANGLES = [60, 20, -20, -60]; // 扇形夹角(以球心为原点,向右展开)
  5472. FB.PAD = 12; // ⚠️ 悬浮窗根视图的固定内边距(实测,不能改)
  5473. FB.SLOP = 8; // 拖动判定阈值:位移 > 8px 才算拖动,否则是单击
  5474. FB.EDGE = 12; // 贴边留白(§10.1 左边距 12)
  5475. FB.BRAND = "/sdcard/tiktok_app/brand/FluxRpa_mark_only_412.png";
  5476. /**
  5477. * 球的窗口矩形发布给引擎(规格书 §10.9③ 的另一半)。
  5478. *
  5479. * 为什么需要它:球浮在所有应用之上,引擎的**坐标手势**点到球位就点到了球
  5480. * (实测:搜索建议列表的一条正好压住球位 → 点出了扇形面板)。
  5481. * 引擎侧 `ui.js` 的 clickNode 在读这个文件做避让,所以界面这边必须**如实发布**
  5482. * 球的窗口矩形(比球大一圈,因为窗口的透明区同样会吃掉触摸)。
  5483. */
  5484. FB.RECT_PATH = "/sdcard/tiktok_app/ball-rect.json";
  5485. /* 动画节奏。规格书 §10.1 给的是 180ms 展开动画;这里用「分帧位移」实现,
  5486. * 帧数 × 帧间隔 ≈ 180ms(不用 ValueAnimator:多引一层 Java 接口包装,
  5487. * 换 AutoX 版本就可能失效,而这只是 6 帧的位置插值)。 */
  5488. FB.ANIM_FRAMES = 6;
  5489. FB.ANIM_STEP_MS = 30;
  5490. FB.BREATH_MS = 900; // 运行中外环呼吸周期
  5491. /* ------------------------------------------------------------ 运行期状态 */
  5492. /* ------------------- 窗口层:两个 overlay,尺寸恒定 ------------------- */
  5493. FB.ballWin = null; // 球窗口(88×88dp):常驻、永远可触摸、尺寸只在拖动时变
  5494. FB.fanWin = null; // 扇形窗口句柄占位(真正的视图是 FB.fanRoot)
  5495. FB.window = null; // 兼容别名 = 球窗口的根视图(dumpLayout / 自检里用它)
  5496. FB.root = null; // 球窗口里的根 FrameLayout
  5497. FB.fanRoot = null; // 扇形窗口里的根 FrameLayout
  5498. FB.ballLp = null; // 球窗口 LayoutParams(拖动改它)
  5499. FB.fanLp = null; // 扇形窗口 LayoutParams(改位置与 NOT_TOUCHABLE)
  5500. FB.open = false; // 扇形是否展开
  5501. FB.side = "left"; // 球贴哪一边(决定扇形方向)
  5502. FB.anchorX = 0; // 球左上角 X(像素,绝对屏幕坐标)
  5503. FB.anchorCy = 0; // 球心 Y(像素)
  5504. FB.dragging = false; // 本次触摸是否已判定为拖动
  5505. FB.breathTimer = null;
  5506. FB.breathOn = false;
  5507. FB.views = {}; // id → 视图
  5508. FB.lastTone = ""; // 上一次的三态,用于避免无谓重建扇形
  5509. FB.lastRectKey = ""; // 上一次发布的球位(避免重复写盘)
  5510. FB.firstBtn = null; // 扇形第 1 个按钮(运行/停止)的视图引用,用于就地更新
  5511. /* ------------------------------------------------------------ 调试开关 */
  5512. /** 调试模式:`/sdcard/tiktok_app/ball-debug` 存在即开启(每 2 秒最多查一次)。 */
  5513. FB.debugLastCheck = 0;
  5514. FB.debugOn = false;
  5515. FB.debugEnabled = function () {
  5516. var now = Date.now();
  5517. if (now - FB.debugLastCheck > 2000) {
  5518. FB.debugLastCheck = now;
  5519. try { FB.debugOn = files.exists("/sdcard/tiktok_app/ball-debug"); } catch (e) { FB.debugOn = false; }
  5520. }
  5521. return FB.debugOn;
  5522. };
  5523. function logTouch(action, x, y) {
  5524. try {
  5525. files.append('/sdcard/tiktok_app/ball-touch.log',
  5526. Date.now() + ' action=' + action + ' (' + Math.round(x) + ',' + Math.round(y) + ')' +
  5527. ' open=' + FB.open + ' anchor=' + FB.anchorX + ',' + FB.anchorCy + '\n');
  5528. } catch (e) { }
  5529. }
  5530. /** 一般轨迹(点按钮、走确认、拉前台…),同样只在 ball-debug 打开时落盘。 */
  5531. function trace(msg) {
  5532. if (!FB.debugEnabled()) { return; }
  5533. try { files.append('/sdcard/tiktok_app/ball-touch.log', Date.now() + ' ' + msg + '\n'); } catch (e) { }
  5534. }
  5535. /**
  5536. * 异常轨迹。
  5537. *
  5538. * ★ 悬浮球这一层的异常**不会自己冒出来**:UI 回调里抛错既不结束脚本、也不弹提示,
  5539. * 用户看到的就是"点了没反应"。所以凡是"点了应该发生什么"的路径,都要有人接住
  5540. * 并把异常写下来 —— 否则下一次还是只能靠猜。
  5541. */
  5542. function traceError(where, error) {
  5543. var text = where + ' 抛错: ' + error + '\n' + (error && error.stack ? error.stack : '');
  5544. try { console.log('[fb] ' + text); } catch (e0) { }
  5545. if (!FB.debugEnabled()) { return; }
  5546. try { files.append('/sdcard/tiktok_app/ball-touch.log', Date.now() + ' ' + text + '\n'); } catch (e1) { }
  5547. try { files.write('/sdcard/tiktok_app/ball-error.txt', text); } catch (e2) { }
  5548. }
  5549. /* ------------------------------------------------------------ 布局轨迹(调试用) */
  5550. /**
  5551. * 记录「**这一帧球实际画在哪**」。
  5552. *
  5553. * 为什么需要它:展开前后**球心是设计成不动的**(窗口变大、球在窗口内的偏移同步变大,
  5554. * 两者相抵)。所以任何肉眼可见的位移,都只可能来自"两半改动没落在同一帧"——
  5555. * 即窗口已经换了尺寸、而视图还按旧布局摆(或反过来)。那种中间态只存在一两帧,
  5556. * 连拍截图(~250ms/帧)根本抓不到。
  5557. *
  5558. * 而 `getLocationOnScreen()` 读的是**布局结果**,不是我们设进去的值,所以它能把这个
  5559. * 中间态如实反映出来 —— 这正是"能不能看见"的判据。
  5560. */
  5561. function absPosOf(view) {
  5562. if (!view) { return "?"; }
  5563. try {
  5564. /* ⚠️ 必须是**真的 Java int[]**:Rhino 把 JS 数组按值转成 Java 数组,
  5565. * 方法写进去的值读不回来 —— 传 [0,0] 永远得到 0,0(白测一轮才发现)。 */
  5566. var loc = java.lang.reflect.Array.newInstance(java.lang.Integer.TYPE, 2);
  5567. view.getLocationOnScreen(loc);
  5568. return java.lang.reflect.Array.getInt(loc, 0) + "," + java.lang.reflect.Array.getInt(loc, 1);
  5569. } catch (e) {
  5570. try { return view.getLeft() + "," + view.getTop(); } catch (e2) { return "?"; }
  5571. }
  5572. }
  5573. /** 打一张当前快照:窗口矩形 + 球/热区的屏幕绝对位置 + 根视图透明度。 */
  5574. function traceFrame(tag) {
  5575. if (!FB.debugEnabled() || !FB.window) { return; }
  5576. var parts = [tag];
  5577. try {
  5578. parts.push("win=" + FB.window.getWidth() + "x" + FB.window.getHeight() +
  5579. "@" + FB.window.getX() + "," + FB.window.getY());
  5580. } catch (e) { parts.push("win=?"); }
  5581. if (FB.views.ball) {
  5582. parts.push("ballAbs=" + absPosOf(FB.views.ball) +
  5583. " " + FB.views.ball.getWidth() + "x" + FB.views.ball.getHeight());
  5584. }
  5585. if (FB.views.hot) {
  5586. parts.push("hotAbs=" + absPosOf(FB.views.hot) +
  5587. " " + FB.views.hot.getWidth() + "x" + FB.views.hot.getHeight());
  5588. }
  5589. try { parts.push("rootAlpha=" + FB.root.getAlpha()); } catch (e2) { }
  5590. trace("frame " + parts.join(" | "));
  5591. }
  5592. /** 挂一次布局监听:这个视图**每次真正被布局**(含首次)都打一张快照。 */
  5593. function watchLayout(tag, view) {
  5594. if (!FB.debugEnabled() || !view) { return; }
  5595. try {
  5596. view.addOnLayoutChangeListener(new android.view.View.OnLayoutChangeListener({
  5597. onLayoutChange: function () { traceFrame(tag); }
  5598. }));
  5599. } catch (e) {
  5600. trace("watchLayout(" + tag + ") 失败: " + e);
  5601. }
  5602. }
  5603. /* ------------------------------------------------------------ 小工具 */
  5604. function dp(v) {
  5605. return Math.round(v * (context.getResources().getDisplayMetrics().density || 1));
  5606. }
  5607. function screenW() { return device.width; }
  5608. function screenH() { return device.height; }
  5609. /**
  5610. * 折叠态窗口边长(dp):热区 + 两侧内边距。
  5611. *
  5612. * ★ 命名带 Dp 后缀是刻意的:这套坐标算错过一次 —— `fanSize()` 原先返回**像素**
  5613. * 而调用方把它当 dp 再用(`place()` 会再乘一次密度),于是展开时球心被算到
  5614. * 根视图外面:球整体下移、四个按钮挤在一起。dp / px 混用不会有任何报错,
  5615. * 只会"看起来不太对",所以这里用名字把单位写死。
  5616. */
  5617. function collapsedSizeDp() { return FB.HOT + FB.PAD * 2; }
  5618. /** 扇形展开后窗口需要多大(dp) */
  5619. function fanSizeDp() {
  5620. /* 水平:内边距 + 热区 + 扇形半径 + 子按钮半径 + 4dp 间隙 + 最长标签 + 内边距 */
  5621. var labelMax = 96;
  5622. var w = FB.PAD + FB.HOT + FB.R + FB.SUB / 2 + 4 + labelMax + FB.PAD;
  5623. /* 垂直:球心上下各「半径 + 子按钮半径 + 余量」 */
  5624. var h = 2 * (FB.R + FB.SUB / 2 + 12);
  5625. return { w: w, h: h };
  5626. }
  5627. /**
  5628. * 球心在窗口**根视图**里的坐标(dp)—— 展开/折叠共用这一个公式。
  5629. *
  5630. * 折叠态球心在热区正中;展开态球心垂直居中(扇形上下都要地方)、
  5631. * 水平仍贴原来那一侧(右侧时取镜像)。
  5632. */
  5633. function ballCenterInRoot(winWDp, winHDp) {
  5634. var half = FB.HOT / 2;
  5635. var rootW = winWDp - FB.PAD * 2; /* 根视图被内边距挤掉一圈 */
  5636. var rootH = winHDp - FB.PAD * 2;
  5637. var x = (FB.side === "right") ? (rootW - half) : half;
  5638. /* ⚠️ 是**根视图**的中心,不是窗口的中心:漏掉这个 PAD 会让球整体下移 12dp */
  5639. return { x: x, y: rootH / 2 };
  5640. }
  5641. /**
  5642. * 把当前悬浮窗的**真实布局**写盘(排障用)。
  5643. *
  5644. * 这条是踩坑之后加的:球的坐标算错过两次(dp/px 混用、根视图中心漏算内边距),
  5645. * 两次都表现为"看起来只是有点歪",靠肉眼量截图量不准。
  5646. * 有了它,下一次任何"位置不对"都能一眼看出是窗口、根还是某个子视图算错了。
  5647. */
  5648. FB.dumpLayout = function (tag) {
  5649. if (!FB.root || !FB.ballLp) { return; }
  5650. var out = ['# ' + tag];
  5651. function winLine(name, lp, view) {
  5652. if (!lp) { return name + ' (无)'; }
  5653. var touchable = !(lp.flags & FLAG_NOT_TOUCHABLE);
  5654. return name + ' ' + lp.width + 'x' + lp.height + ' @ ' + lp.x + ',' + lp.y +
  5655. ' touchable=' + touchable + ' root=' +
  5656. (view ? view.getWidth() + 'x' + view.getHeight() : '?');
  5657. }
  5658. try {
  5659. out.push(winLine('ballWin', FB.ballLp, FB.root));
  5660. out.push(winLine('fanWin ', FB.fanLp, FB.fanRoot));
  5661. out.push('anchor ' + FB.anchorX + ',' + FB.anchorCy + ' side=' + FB.side + ' open=' + FB.open);
  5662. function dumpChildren(prefix, view) {
  5663. if (!view) { return; }
  5664. for (var i = 0; i < view.getChildCount(); i++) {
  5665. var v = view.getChildAt(i);
  5666. var text = '';
  5667. var vis = v.getVisibility();
  5668. try { text = (typeof v.getText === 'function') ? String(v.getText()) : ''; } catch (e) { text = '?'; }
  5669. var shown = (vis === android.view.View.GONE) ? ' GONE' :
  5670. (vis === android.view.View.INVISIBLE ? ' INVIS' : '');
  5671. out.push(' ' + prefix + '#' + i + ' ' + v.getLeft() + ',' + v.getTop() + ' ' +
  5672. v.getWidth() + 'x' + v.getHeight() + shown + ' "' + text + '"');
  5673. }
  5674. }
  5675. dumpChildren('ball ', FB.root);
  5676. dumpChildren('fan ', FB.fanRoot);
  5677. } catch (error) {
  5678. out.push('dump 失败: ' + error);
  5679. }
  5680. try { files.write('/sdcard/tiktok_app/ball-layout.txt', out.join('\n')); } catch (e2) { }
  5681. };
  5682. /* ------------------------------------------------------------ 落盘(记忆位置) */
  5683. /** 当前垂直位置的比例(0~1000),换屏幕比例后仍然合理。 */
  5684. FB.yRatio = function () {
  5685. var h = screenH();
  5686. if (!h) { return 500; }
  5687. return Math.max(0, Math.min(1000, Math.round(FB.anchorCy / h * 1000)));
  5688. };
  5689. FB.savePosition = function () {
  5690. Store.set("ball.side", FB.side);
  5691. Store.set("ball.yRatio", FB.yRatio());
  5692. };
  5693. /** 从配置恢复锚点位置(球心 X / Y 像素)。 */
  5694. FB.restoreAnchor = function () {
  5695. FB.side = Store.getEnum("ball.side", ["left", "right"]);
  5696. var ratio = Store.getNumber("ball.yRatio");
  5697. if (ratio < 100 || ratio > 900) { ratio = 500; } /* 越界值当默认,别把球顶到屏幕外 */
  5698. FB.anchorCy = Math.round(screenH() * ratio / 1000);
  5699. FB.anchorX = (FB.side === "right")
  5700. ? screenW() - dp(FB.EDGE) - dp(FB.DIAM)
  5701. : dp(FB.EDGE);
  5702. FB.clampAnchor();
  5703. };
  5704. /** 锚点必须留在屏幕内:用户手滑拖出去,球就再也点不到了。 */
  5705. /**
  5706. * 把锚点夹进「两个窗口都能完整显示」的范围。
  5707. *
  5708. * ★ 夹的必须是**锚点**而不是窗口:两个窗口的位置都由锚点推导,分别夹会让它们错位。
  5709. * 代价是球不能被拖到离上下边缘 120dp 以内(扇形半高)—— 这是物理上必须的,
  5710. * 否则扇形窗口会出屏、下面两个按钮点不到。横向不用管:球本来就吸附在左右边缘上。
  5711. */
  5712. FB.clampAnchor = function () {
  5713. var maxX = Math.max(0, screenW() - dp(FB.DIAM));
  5714. FB.anchorX = Math.max(0, Math.min(maxX, FB.anchorX));
  5715. var m = dp(fanSizeDp().h / 2) - dp(FB.PAD); /* 球心到扇形窗口上/下边的最小距离 */
  5716. var minY = Math.max(dp(FB.DIAM) / 2, m);
  5717. var maxY = Math.max(minY, screenH() - m);
  5718. FB.anchorCy = Math.max(minY, Math.min(maxY, FB.anchorCy));
  5719. };
  5720. /* ------------------------------------------------------------ 视图构建 */
  5721. /** 圆形图标按钮(扇形里的四个,或球体本身)。 */
  5722. function circleButton(hexFill, gradient, iconChar, iconHex, iconSize) {
  5723. var view = U.text(iconChar, iconSize || 15, iconHex || "#FFFFFF", false);
  5724. view.setGravity(android.view.Gravity.CENTER);
  5725. view.setBackground(gradient
  5726. ? U.gradientBg(Theme.hex.accent, Theme.hex.accentDeep, Theme.m.pillRadius)
  5727. : U.solidBg(U.alphaOf(hexFill, hexFill.indexOf("#FFFFFF") === 0 ? 0.08 : 0.14), Theme.m.pillRadius));
  5728. U.params(view, FB.SUB, FB.SUB);
  5729. return view;
  5730. }
  5731. /** 标签胶囊(§10.1:高 24、圆角 99、距按钮 4px、11px 字)。 */
  5732. function labelPill(text, textHex, bgHex, bgAlpha) {
  5733. var view = U.text(text, Theme.m.chipSize, textHex, true);
  5734. view.setGravity(android.view.Gravity.CENTER);
  5735. view.setBackground(U.solidBg(U.alphaOf(bgHex, bgAlpha), Theme.m.pillRadius));
  5736. view.setPadding(U.dp(10), 0, U.dp(10), 0);
  5737. var lp = new android.widget.FrameLayout.LayoutParams(-2, dp(FB.LABEL_H));
  5738. view.setLayoutParams(lp);
  5739. return view;
  5740. }
  5741. /** 绝对定位(根是 FrameLayout,用 margin 当坐标)。 */
  5742. function place(view, leftDp, topDp, wDp, hDp) {
  5743. var lp = new android.widget.FrameLayout.LayoutParams(
  5744. wDp > 0 ? dp(wDp) : wDp, hDp > 0 ? dp(hDp) : hDp);
  5745. lp.leftMargin = dp(leftDp);
  5746. lp.topMargin = dp(topDp);
  5747. view.setLayoutParams(lp);
  5748. return view;
  5749. }
  5750. /** 球能不能建:卡密有效 + 悬浮窗权限 + 用户没主动隐藏。 */
  5751. FB.canShow = function () {
  5752. if (Store.getBool("ball.hidden")) { return false; }
  5753. if (Service.licenseStatus().status !== "active") { return false; }
  5754. return Device.overlayEnabled();
  5755. };
  5756. /* ------------------------------------------------------------ 窗口层(两个 overlay,尺寸恒定) */
  5757. var TYPE_APPLICATION_OVERLAY = 2038;
  5758. var FLAG_NOT_FOCUSABLE = 0x00000008;
  5759. var FLAG_NOT_TOUCHABLE = 0x00000010;
  5760. var FLAG_NOT_TOUCH_MODAL = 0x00000020;
  5761. /* ★ 必须带上它:否则窗口会被状态栏内缩,整套坐标整体下移一个状态栏高度
  5762. * (实测球比锚点低了 48px,正好是状态栏高。AutoX 的 floaty 窗口带这个 flag,所以以前没暴露) */
  5763. var FLAG_LAYOUT_IN_SCREEN = 0x00000100;
  5764. var FLAG_LAYOUT_NO_LIMITS = 0x00000200;
  5765. var FLAG_HARDWARE_ACCELERATED = 0x01000000;
  5766. var wmService = null;
  5767. function wm() {
  5768. if (!wmService) { wmService = context.getSystemService("window"); }
  5769. return wmService;
  5770. }
  5771. function overlayFlags(touchable) {
  5772. var f = FLAG_NOT_FOCUSABLE | FLAG_NOT_TOUCH_MODAL | FLAG_LAYOUT_NO_LIMITS |
  5773. FLAG_LAYOUT_IN_SCREEN | FLAG_HARDWARE_ACCELERATED;
  5774. /* §10.9④:不抢输入焦点,否则会影响 TikTok 的键盘 */
  5775. if (!touchable) { f = f | FLAG_NOT_TOUCHABLE; }
  5776. return f;
  5777. }
  5778. /** 建一个覆盖窗口(尺寸取 px)。返回它的 LayoutParams,后面拖动/改触摸要用。 */
  5779. function addOverlay(view, x, y, w, h, touchable) {
  5780. var lp = new android.view.WindowManager.LayoutParams();
  5781. lp.type = TYPE_APPLICATION_OVERLAY;
  5782. lp.format = -3; /* PixelFormat.TRANSLUCENT */
  5783. lp.flags = overlayFlags(touchable);
  5784. lp.gravity = 51; /* TOP | LEFT,于是 x/y 就是屏幕绝对坐标 */
  5785. lp.x = x; lp.y = y;
  5786. lp.width = w; lp.height = h;
  5787. wm().addView(view, lp);
  5788. return lp;
  5789. }
  5790. /**
  5791. * ★★ 为什么是**两个**窗口(2026-10-15 重做;这一版之前的做法都错了)
  5792. *
  5793. * 之前是"一个窗口,展开时改尺寸"。它必然"跳",且**顺序怎么排都躲不掉**:
  5794. * 改窗口尺寸/位置由系统(WindowManager)**立刻生效**,
  5795. * 而我们这边的视图重排要等 App 下一帧重绘;
  5796. * 中间那几帧,合成器显示的是**上一帧的旧画面被摆到新的窗口矩形里**。
  5797. * 用录屏逐帧看得很清楚:展开那一刻球会从下往上"爬"几级才停 —— 就是用户说的"跳动"。
  5798. * 试过的两条补救都不行:
  5799. * · 先在同一个 JS 调用里把球藏起来再改尺寸 —— 藏只是标记,改尺寸已经发出去了,照样跳;
  5800. * · 藏起来之后**等这一帧画完**(`onPreDraw`)再改尺寸 —— 合成器有 2~3 帧的缓冲队列,
  5801. * 它照样能把"藏之前"的那一帧摆到新位置上去(实测仍然跳)。
  5802. *
  5803. * ⇒ 唯一干净的办法是**根本不改窗口几何**:
  5804. * · **球窗口** 88×88dp:常驻、永远可触摸、尺寸与位置只在拖动时变;
  5805. * · **扇形窗口** 308×264dp:位置随球算好,**折叠时整窗设成 NOT_TOUCHABLE**
  5806. * (触摸自动穿透到下面的 App,所以它的透明区不会是死区),展开时才可触摸。
  5807. * 于是"展开/收起"只改**显隐**与一个 flag —— 没有任何几何变化,也就没有任何东西会跳。
  5808. * 附带好处:折叠态的"死区"从 176×176px 缩到球窗口那么大,引擎要绕开的范围也小了(见 publishRect)。
  5809. *
  5810. * 两个窗口能同时绘制是**实测过的**(`_probe/fb_two_windows.js`:红块与绿块都出现)。
  5811. * AutoX 的 `floaty.window` 做不到这一点(第二个建得出来但画不出来),所以这里走裸 WindowManager。
  5812. */
  5813. FB.ensure = function () {
  5814. if (FB.ballWin) { FB.sync(); return; }
  5815. if (!FB.canShow()) { return; }
  5816. try {
  5817. FB.restoreAnchor();
  5818. /* ① 先建两棵视图树。内边距用视图自己的 padding 复现那圈 12dp,
  5819. * 于是"球心在根视图里的坐标"这套算法一个字都不用改 */
  5820. FB.root = new android.widget.FrameLayout(context);
  5821. FB.root.setPadding(dp(FB.PAD), dp(FB.PAD), dp(FB.PAD), dp(FB.PAD));
  5822. buildBallViews();
  5823. FB.fanRoot = new android.widget.FrameLayout(context);
  5824. FB.fanRoot.setPadding(dp(FB.PAD), dp(FB.PAD), dp(FB.PAD), dp(FB.PAD));
  5825. buildFanViews();
  5826. /* ② 再加窗口:**扇形先加、球后加**。同类型窗口按加入顺序叠放,后加的在上;
  5827. * 球必须在最上层 —— 扇形展开时球的矩形整个落在扇形窗口里,
  5828. * 球若在下层,点它就会被扇形窗口吃掉,**再点一次收不起来**(真机踩过)。
  5829. * 两层的子视图不重叠(扇形按钮离球心 96dp,球窗口只有 88dp),所以不会有遮挡问题。 */
  5830. var f = fanSizeDp();
  5831. FB.fanLp = addOverlay(FB.fanRoot, 0, 0, dp(f.w), dp(f.h), false);
  5832. var c = collapsedSizeDp();
  5833. FB.ballLp = addOverlay(FB.root, 0, 0, dp(c), dp(c), true);
  5834. FB.ballWin = FB.root; /* 兼容别名:dumpLayout / 自检里用它 */
  5835. FB.window = FB.root;
  5836. positionBallWindow();
  5837. positionFanWindow();
  5838. FB.open = false;
  5839. FB.publishRect(); /* 启动就把球位告诉引擎,别让它去点球 */
  5840. FB.sync();
  5841. attach();
  5842. } catch (e) {
  5843. console.log("[fb] 创建悬浮窗失败: " + e);
  5844. traceError('ensure', e);
  5845. FB.destroy();
  5846. }
  5847. };
  5848. /** 窗口挂上去之后的收尾(定位、调试监听、留一份布局快照)。 */
  5849. function attach() {
  5850. setTimeout(function () {
  5851. if (!FB.ballWin) { return; }
  5852. if (FB.debugEnabled() && FB.root) {
  5853. FB.root.setOnTouchListener(new android.view.View.OnTouchListener({
  5854. onTouch: function (v, event) {
  5855. trace('root-touch action=' + event.getAction() + ' @' +
  5856. Math.round(event.getRawX()) + ',' + Math.round(event.getRawY()));
  5857. return false;
  5858. }
  5859. }));
  5860. }
  5861. FB.dumpLayout('collapsed');
  5862. console.log("[fb] 悬浮球已就绪 side=" + FB.side + " pos=" + FB.anchorX + "," + FB.anchorCy);
  5863. }, 60);
  5864. }
  5865. FB.destroy = function () {
  5866. FB.stopBreath();
  5867. /* 先告诉引擎"球没了"再关窗:反过来的话,关窗与写文件之间那一瞬
  5868. * 引擎可能刚好读到旧矩形,于是白白绕开一个已经不存在的球。 */
  5869. try {
  5870. files.write(FB.RECT_PATH, JSON.stringify({ hidden: true, updatedAt: Date.now() }));
  5871. FB.lastRectKey = "";
  5872. } catch (e0) { /* 写不进就算了,引擎读不到会当没有球 */ }
  5873. try { if (FB.root) { wm().removeView(FB.root); } } catch (e) { console.log("[fb] 移除球窗口失败: " + e); }
  5874. try { if (FB.fanRoot) { wm().removeView(FB.fanRoot); } } catch (e2) { console.log("[fb] 移除扇形窗口失败: " + e2); }
  5875. FB.ballWin = null;
  5876. FB.fanWin = null;
  5877. FB.window = null;
  5878. FB.ballLp = null;
  5879. FB.fanLp = null;
  5880. FB.root = null;
  5881. FB.fanRoot = null;
  5882. FB.open = false;
  5883. FB.views = {};
  5884. FB.firstBtn = null;
  5885. };
  5886. /* ------------------------------------------------------------ 扇形几何 */
  5887. /**
  5888. * 第 index 个按钮相对「球心」的偏移(dp)。
  5889. * 球在右侧时整体镜像(x 取负)—— 规格书 §10.11:不镜像整组按钮会出屏。
  5890. */
  5891. function fanOffset(index) {
  5892. var rad = FB.ANGLES[index] * Math.PI / 180;
  5893. var dx = FB.R * Math.cos(rad);
  5894. var dy = -FB.R * Math.sin(rad); /* 屏幕 y 向下,故取负 sin */
  5895. return { x: FB.side === "right" ? -dx : dx, y: dy };
  5896. }
  5897. /* ------------------------------------------------------------ 视图构建(只建一次) */
  5898. /**
  5899. * 品牌图标位图**只解码一次**。
  5900. * `images.read()` 要把 412×412 的 PNG 解码成位图,是这一层最贵的一步。
  5901. */
  5902. FB.brandBitmap = null;
  5903. function brandBitmap() {
  5904. if (FB.brandBitmap) { return FB.brandBitmap; }
  5905. try {
  5906. var img = images.read(FB.BRAND);
  5907. if (img) { FB.brandBitmap = img.bitmap; }
  5908. else { console.log("[fb] 品牌图不存在: " + FB.BRAND); }
  5909. } catch (e) {
  5910. console.log("[fb] 读品牌图失败: " + e);
  5911. }
  5912. return FB.brandBitmap;
  5913. }
  5914. /**
  5915. * 球窗口里就三件套:呼吸环(Ø60)/ 球体(Ø48)/ 透明热区(Ø64)。
  5916. *
  5917. * 位置**建一次就够**:折叠态与展开态球在根视图里的坐标是同一个
  5918. * (球心永远在球窗口的正中),所以没有"换状态要重摆"这回事。
  5919. * 顺序:环 → 球 → 热区,热区最后加所以盖在最上层收手势。
  5920. */
  5921. function buildBallViews() {
  5922. var root = FB.root;
  5923. root.removeAllViews();
  5924. var ring = U.text("", 1, "#FFFFFF", false);
  5925. ring.setBackground(U.solidBg(U.alphaOf(Theme.hex.ok, 0.22), Theme.m.pillRadius));
  5926. /* 建的时候先藏起来:环的颜色/有无完全由 sync() 按运行态决定。
  5927. * 默认可见会让空闲态也挂着一个绿环。 */
  5928. ring.setVisibility(android.view.View.GONE);
  5929. place(ring, (FB.HOT - 60) / 2, (FB.HOT - 60) / 2, 60, 60);
  5930. root.addView(ring);
  5931. var ball = new android.widget.ImageView(context);
  5932. var bmp = brandBitmap();
  5933. if (bmp) { ball.setImageBitmap(bmp); }
  5934. ball.setScaleType(android.widget.ImageView.ScaleType.FIT_XY);
  5935. ball.setBackground(U.solidBg(U.argb(Theme.hex.bg), Theme.m.pillRadius));
  5936. ball.setClipToOutline(true);
  5937. place(ball, (FB.HOT - FB.DIAM) / 2, (FB.HOT - FB.DIAM) / 2, FB.DIAM, FB.DIAM);
  5938. root.addView(ball);
  5939. /* 透明热区:球 Ø48、热区 Ø64 —— §10.1 的"视觉小、触控大" */
  5940. var hot = new android.view.View(context);
  5941. hot.setBackground(U.solidBg(0x00000000, 0));
  5942. place(hot, 0, 0, FB.HOT, FB.HOT);
  5943. hot.setOnTouchListener(new android.view.View.OnTouchListener({
  5944. onTouch: function (v, event) { return onTouch(event); }
  5945. }));
  5946. root.addView(hot);
  5947. FB.views.ring = ring;
  5948. FB.views.ball = ball;
  5949. FB.views.hot = hot;
  5950. watchLayout("ball", ball);
  5951. watchLayout("hot", hot);
  5952. }
  5953. /**
  5954. * 扇形窗口里是 4 个按钮 + 4 个标签,**建一次、位置也只算一次**
  5955. * (它们只取决于扇形几何与球贴哪一边;换边时由 layoutFanViews() 重摆)。
  5956. */
  5957. function buildFanViews() {
  5958. var root = FB.fanRoot;
  5959. root.removeAllViews();
  5960. FB.views.fan = [];
  5961. FB.firstBtn = null;
  5962. var specs = fanSpecs();
  5963. var i;
  5964. for (i = 0; i < specs.length; i++) {
  5965. var spec = specs[i];
  5966. var shape = (spec.kind === "primary") ? primaryState() : spec;
  5967. var danger = !!shape.danger;
  5968. var btn = U.text(shape.icon, 15, shape.iconHex, false);
  5969. btn.setGravity(android.view.Gravity.CENTER);
  5970. btn.setBackground(shape.gradient
  5971. ? U.gradientBg(Theme.hex.accent, Theme.hex.accentDeep, Theme.m.pillRadius)
  5972. : (danger
  5973. ? U.solidBg(U.alphaOf(Theme.hex.danger, shape.solid ? 1 : 0.14), Theme.m.pillRadius)
  5974. : U.solidBg(U.alphaOf("#FFFFFF", 0.08), Theme.m.pillRadius)));
  5975. btn.setVisibility(android.view.View.GONE);
  5976. /* 轨迹里用「标签」而不是 kind:只有第 1 个按钮有 kind,
  5977. * 其余三个会打成 kind=undefined —— 排障时最需要看清"点中的是哪一个"。 */
  5978. var fanTag = (spec.kind === "primary") ? "primary" : String(spec.label);
  5979. (function (action, tag, idx) {
  5980. /* 点按钮 → 执行动作。
  5981. * ⚠️ try/catch 是必须的:UI 回调里抛错在 AutoX 上**不报错、不弹窗、
  5982. * 不结束脚本**,症状就是"点了没反应"。这里把它转成一条可读的轨迹。 */
  5983. btn.setOnClickListener(function () {
  5984. trace('click fan#' + idx + ' ' + tag + ' open=' + FB.open);
  5985. try {
  5986. action();
  5987. } catch (e) {
  5988. traceError('扇形按钮#' + idx + '(' + tag + ')', e);
  5989. }
  5990. /* 选完就收起:扇形是个**选择器**,点完还摊着,正好挡住用户此刻要看的东西。
  5991. * 「停止 / 退出」在 confirmThenAct 里已经收过一次,这里是幂等的。 */
  5992. try { FB.collapse(); } catch (e2) { traceError('收起扇形', e2); }
  5993. });
  5994. })(spec.action, fanTag, i);
  5995. root.addView(btn);
  5996. var pill = labelPill(shape.label, danger ? Theme.hex.danger : "#FFFFFF",
  5997. danger ? Theme.hex.danger : "#FFFFFF", danger ? 0.14 : 0.10);
  5998. pill.setVisibility(android.view.View.GONE);
  5999. root.addView(pill);
  6000. FB.views.fan.push({ btn: btn, pill: pill });
  6001. if (spec.kind === "primary") { FB.firstBtn = { view: btn, pill: pill }; }
  6002. }
  6003. layoutFanViews();
  6004. }
  6005. /**
  6006. * 摆扇形里那 8 个视图。只在**建视图**与**换了贴边方向**时调用。
  6007. *
  6008. * ★ 必须走 `place()`:它内部统一做 dp→px。这里原先调的是另一个自己写的 pillParams(),
  6009. * 那里面漏了 dp() —— 于是标签的坐标被当成像素用、全部挤到左上角(按钮是对的)。
  6010. */
  6011. function layoutFanViews() {
  6012. var size = fanSizeDp();
  6013. var center = ballCenterInRoot(size.w, size.h);
  6014. var i;
  6015. for (i = 0; i < FB.views.fan.length; i++) {
  6016. var off = fanOffset(i);
  6017. var cx = center.x + off.x;
  6018. var cy = center.y + off.y;
  6019. var item = FB.views.fan[i];
  6020. place(item.btn, cx - FB.SUB / 2, cy - FB.SUB / 2, FB.SUB, FB.SUB);
  6021. /* 标签:向右展开时贴按钮右侧,镜像时贴左侧(§10.11) */
  6022. var label = "";
  6023. try { label = String(item.pill.getText()); } catch (e) { label = ""; }
  6024. var pw = measurePillWidth(item.pill, label);
  6025. var px = (FB.side === "right") ? (cx - FB.SUB / 2 - 4 - pw) : (cx + FB.SUB / 2 + 4);
  6026. place(item.pill, px, cy - FB.LABEL_H / 2, -2, FB.LABEL_H);
  6027. }
  6028. }
  6029. /**
  6030. * 扇形 8 个视图的显隐。
  6031. * 用 **GONE 而不是 INVISIBLE**:INVISIBLE 仍然占着位置、也仍然会吃掉触摸。
  6032. */
  6033. function setFanVisible(on) {
  6034. var vis = on ? android.view.View.VISIBLE : android.view.View.GONE;
  6035. for (var i = 0; i < FB.views.fan.length; i++) {
  6036. FB.views.fan[i].btn.setVisibility(vis);
  6037. FB.views.fan[i].pill.setVisibility(vis);
  6038. }
  6039. }
  6040. /**
  6041. * 扇形**窗口**能不能吃触摸。
  6042. *
  6043. * 折叠时必须设成不可触摸:扇形窗口有一大片透明区,而**窗口的透明区同样会吃掉触摸**
  6044. * (实测:点它透明区里的输入框,键盘不弹;点窗口外,键盘就弹)。
  6045. * 设成 NOT_TOUCHABLE 之后触摸会穿透到下面的 App —— 于是折叠态没有任何死区。
  6046. */
  6047. function setFanTouchable(on) {
  6048. if (!FB.fanLp) { return; }
  6049. FB.fanLp.flags = overlayFlags(on);
  6050. try {
  6051. wm().updateViewLayout(FB.fanRoot, FB.fanLp);
  6052. } catch (e) {
  6053. traceError('setFanTouchable', e);
  6054. }
  6055. }
  6056. /** 标签宽度:项目里没有测量工具,按字数估算(11px 字 + 两侧 10dp 内边距)。 */
  6057. function measurePillWidth(pill, text) {
  6058. var len = String(text).length;
  6059. var ascii = 0, wide = 0, j;
  6060. for (j = 0; j < len; j++) {
  6061. if (String(text).charCodeAt(j) < 128) { ascii += 1; } else { wide += 1; }
  6062. }
  6063. return dp(20) + ascii * dp(7) + wide * dp(11);
  6064. }
  6065. /**
  6066. * 四个按钮的定义(§10.3)。
  6067. * 第 1 个随运行态切换:空闲/完成后是「运行」,运行中是「停止」。
  6068. */
  6069. function fanSpecs() {
  6070. return [
  6071. /* ★ 第 1 个按钮的动作**在点击那一刻现取**当前状态(见 primaryState)。
  6072. *
  6073. * ⚠️ 这里踩过一个"界面全对、日志正常、就是点了没反应"的大坑,务必别再改回去:
  6074. * 早期用一个可变 holder(`FB.firstAction.fn`)存这个动作,由 `applyFirstButton()`
  6075. * 在**三态发生变化时**写入。而"展开扇形"这一步通常是 idle → idle ——
  6076. * 状态根本没变,`sync()` 判定 `changed === false`,于是对**新按钮实例**
  6077. * 从不调用 `applyFirstButton()`,holder 里始终是最初那个空函数。
  6078. * 表现:「运行 / 停止」点了毫无反应,而其余三个按钮(动作内联在下面几行)都正常;
  6079. * 触摸事件收到了、界面也完全正确 —— 只是回调是个空壳,最难查的一类。
  6080. * ⇒ 动作一律**现算**,不存可变引用,"忘记接线"这种可能就根本不存在了。 */
  6081. { kind: "primary",
  6082. action: function () { primaryState().fn(); } },
  6083. { icon: "\u25A6", label: "\u56DE\u5230 App", iconHex: "#FFFFFF",
  6084. action: function () { App.bringToFront(); } },
  6085. { icon: "\u2298", label: "\u9690\u85CF\u60AC\u6D6E\u7403", iconHex: "#FFFFFF",
  6086. action: function () { FB.hide(); } },
  6087. { icon: "\u2715", label: "\u9000\u51FA\u5E94\u7528", iconHex: Theme.hex.danger, danger: true,
  6088. action: function () { askExit(); } }
  6089. ];
  6090. }
  6091. /** 第 1 个按钮当前的形态(也定义了它现在该做什么)。 */
  6092. function primaryState() {
  6093. var running = !!Session.running;
  6094. return running
  6095. ? { icon: "\u25A0", label: "\u505C\u6B62", iconHex: "#FFFFFF", danger: true, solid: true,
  6096. fn: function () { askStop(); } }
  6097. : { icon: "\u25B6", label: "\u8FD0\u884C", iconHex: "#FFFFFF", gradient: true,
  6098. fn: function () { askRun(); } };
  6099. }
  6100. /* --------------------------------------------- 展开 / 收起(只改显隐与一个 flag) */
  6101. /**
  6102. * 展开 / 收起**只做两件事**:扇形视图的显隐 + 扇形窗口的 NOT_TOUCHABLE 开关。
  6103. *
  6104. * 没有尺寸变化、没有位置变化、没有等待、没有动画 —— 所以不存在"跳"的可能。
  6105. * 这一点是本模块的核心结论:只要**窗口几何不变**,合成器拿到的每一帧缓冲都画在同一个
  6106. * 矩形里,就不可能出现"旧画面被摆到新位置"。详见窗口层那段注释(含两次失败的尝试)。
  6107. *
  6108. * ★ **不做任何过渡动画**(用户 2026-10-14 明确要求去掉)。
  6109. * 规格书 §10.1 写了"展开动画 180ms",这里是有意识的偏离:那 180ms 淡入会让
  6110. * "展开"在点击后近 0.3 秒才看清,而这个球的用法是"点一下就到位"。
  6111. * 将来若要恢复动画,在 `setFanVisible(true)` 之后做透明度渐变 —— ⚠️ 但那时
  6112. * **透明度的写者必须只有这一处**,否则会出现"两个动画互相顶掉"的鬼影(不报错、只有肉眼看得见)。
  6113. */
  6114. FB.expand = function () {
  6115. if (FB.open || !FB.ballWin) { return; }
  6116. try {
  6117. FB.open = true;
  6118. positionFanWindow(); /* 位置随球算(拖动过就更新一下) */
  6119. if (FB.firstBtn) { applyFirstButton(); } /* 第 1 个按钮跟上当前运行态 */
  6120. setFanVisible(true);
  6121. setFanTouchable(true);
  6122. FB.sync(); /* 三态外环 */
  6123. FB.publishRect();
  6124. FB.dumpLayout('fan'); /* 切换之后的真实布局,留一份给排障 */
  6125. trace("expand 完成");
  6126. } catch (error) {
  6127. /* 展开路径上任何一步抛错都必须留下痕迹:真机上它表现为"点了没反应",
  6128. * 而 AutoX 的 UI 回调异常不会让脚本退出、也不会弹任何提示。 */
  6129. traceError('expand', error);
  6130. }
  6131. };
  6132. FB.collapse = function () {
  6133. if (!FB.ballWin || !FB.open) { return; }
  6134. try {
  6135. FB.open = false;
  6136. setFanVisible(false);
  6137. setFanTouchable(false); /* 整窗不可触摸 → 触摸穿透,不留死区 */
  6138. FB.sync();
  6139. FB.publishRect();
  6140. FB.dumpLayout('collapsed');
  6141. trace("collapse 完成");
  6142. } catch (error) {
  6143. traceError('collapse', error);
  6144. }
  6145. };
  6146. FB.toggleOpen = function () {
  6147. if (FB.open) { FB.collapse(); } else { FB.expand(); }
  6148. };
  6149. /* ------------------------------------------------------------ 位置计算 */
  6150. /**
  6151. * 由「球锚点」反推某个窗口的左上角(像素)。**两个窗口共用这一处换算。**
  6152. *
  6153. * 球心的屏幕绝对坐标必须始终等于锚点:
  6154. * 球心 = 窗口位置 + 内边距(PAD) + 球心在根视图里的偏移
  6155. * 所以 窗口位置 = 锚点 − PAD − 根内偏移。折叠态与展开态的"根内偏移"不同
  6156. * (折叠在正中、展开要垂直居中给扇形腾地方),于是两个窗口算出来的位置也不同 ——
  6157. * 这没关系:**窗口各自建好之后就再也不动了**。
  6158. */
  6159. function originFor(winWDp, winHDp) {
  6160. var c = ballCenterInRoot(winWDp, winHDp);
  6161. return {
  6162. x: Math.round(FB.anchorX + dp(FB.DIAM) / 2 - dp(FB.PAD) - dp(c.x)),
  6163. y: Math.round(FB.anchorCy - dp(FB.PAD) - dp(c.y)),
  6164. w: dp(winWDp),
  6165. h: dp(winHDp)
  6166. };
  6167. }
  6168. function moveWindow(lp, view, o) {
  6169. if (!lp || !view) { return; }
  6170. lp.x = o.x; lp.y = o.y; lp.width = o.w; lp.height = o.h;
  6171. try {
  6172. wm().updateViewLayout(view, lp);
  6173. } catch (e) {
  6174. traceError('updateViewLayout', e);
  6175. }
  6176. }
  6177. /** 把球窗口摆到锚点(拖动中与吸附后调用)。尺寸不变,只改位置。 */
  6178. function positionBallWindow() {
  6179. var c = collapsedSizeDp();
  6180. moveWindow(FB.ballLp, FB.root, originFor(c, c));
  6181. }
  6182. /** 把扇形窗口摆到锚点(展开前调用;拖动过之后位置要跟上)。 */
  6183. function positionFanWindow() {
  6184. var f = fanSizeDp();
  6185. moveWindow(FB.fanLp, FB.fanRoot, originFor(f.w, f.h));
  6186. }
  6187. /**
  6188. * 把「引擎必须绕开的矩形」写盘(`ball-rect.json`)。
  6189. *
  6190. * 折叠态**只发球窗口**:扇形窗口那时是 NOT_TOUCHABLE,触摸会穿透到下面的 App,
  6191. * 引擎不必绕开它 —— 这比之前"永远绕开一大块"更准,引擎的点击成功率也更高。
  6192. * 展开态发两个窗口的并集。
  6193. */
  6194. FB.publishRect = function () {
  6195. var ball = originFor(collapsedSizeDp(), collapsedSizeDp());
  6196. var rect;
  6197. if (FB.open) {
  6198. var fan = originFor(fanSizeDp().w, fanSizeDp().h);
  6199. var x0 = Math.min(ball.x, fan.x), y0 = Math.min(ball.y, fan.y);
  6200. var x1 = Math.max(ball.x + ball.w, fan.x + fan.w);
  6201. var y1 = Math.max(ball.y + ball.h, fan.y + fan.h);
  6202. rect = { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
  6203. } else {
  6204. rect = { x: ball.x, y: ball.y, w: ball.w, h: ball.h };
  6205. }
  6206. rect.open = !!FB.open;
  6207. rect.updatedAt = Date.now();
  6208. var key = [rect.x, rect.y, rect.w, rect.h, rect.open ? 1 : 0].join(",");
  6209. if (key === FB.lastRectKey) { return; } /* 只在真的变了才写盘 */
  6210. FB.lastRectKey = key;
  6211. try {
  6212. files.write(FB.RECT_PATH, JSON.stringify(rect));
  6213. } catch (e) {
  6214. console.log("[fb] 发布球位失败: " + e);
  6215. }
  6216. };
  6217. /* ------------------------------------------------------------ 手势:单击 / 拖动 */
  6218. var touchState = { x: 0, y: 0, ax: 0, ay: 0, moved: false, downAt: 0 };
  6219. function onTouch(event) {
  6220. var action = event.getAction();
  6221. var rawX = event.getRawX(), rawY = event.getRawY();
  6222. /* 触摸事件日志:**默认关闭**。
  6223. *
  6224. * 球的手势属于"点不出来就完全没线索"的那类问题,所以留了这条日志 ——
  6225. * 但每次触摸都往盘上追加是白白 IO,于是用「调试开关文件」控制:
  6226. * `adb shell touch /sdcard/tiktok_app/ball-debug` 打开,删掉即关闭。
  6227. * 排查这类问题时它是救命的(CANCEL 与 UP 混在一起那次就是靠它定位的)。 */
  6228. if (FB.debugEnabled()) { logTouch(action, rawX, rawY); }
  6229. if (action === android.view.MotionEvent.ACTION_DOWN) {
  6230. touchState.x = rawX;
  6231. touchState.y = rawY;
  6232. touchState.ax = FB.anchorX;
  6233. touchState.ay = FB.anchorCy;
  6234. touchState.moved = false;
  6235. touchState.downAt = Date.now();
  6236. return true;
  6237. }
  6238. if (action === android.view.MotionEvent.ACTION_MOVE) {
  6239. var dx = rawX - touchState.x;
  6240. var dy = rawY - touchState.y;
  6241. if (!touchState.moved &&
  6242. (Math.abs(dx) > dp(FB.SLOP) || Math.abs(dy) > dp(FB.SLOP))) {
  6243. touchState.moved = true;
  6244. FB.dragging = true;
  6245. /* 拖动时先收起:展开态的窗口很大,拖着走会挡住半个屏幕 */
  6246. if (FB.open) { FB.collapse(true); }
  6247. }
  6248. if (touchState.moved) {
  6249. FB.anchorX = touchState.ax + dx;
  6250. FB.anchorCy = touchState.ay + dy;
  6251. FB.clampAnchor();
  6252. positionBallWindow();
  6253. }
  6254. return true;
  6255. }
  6256. if (action === android.view.MotionEvent.ACTION_CANCEL) {
  6257. /* ★ CANCEL **不是** UP,不能当点击处理 —— 这个坑很隐蔽:
  6258. * `setSize()` 改窗口尺寸会让 WindowManager 把正在进行的这条触摸流
  6259. * 判为取消,于是每次展开都会紧跟一个 CANCEL。
  6260. * 早期版本把 CANCEL 与 UP 一视同仁,结果就是「展开 → 立刻又收起」,
  6261. * 用户看到的是"点了完全没反应",而日志里一切正常(事件都收到了)。
  6262. * 取消时只复位状态,不做任何动作。 */
  6263. FB.dragging = false;
  6264. touchState.moved = false;
  6265. return true;
  6266. }
  6267. if (action === android.view.MotionEvent.ACTION_UP) {
  6268. FB.dragging = false;
  6269. var moved = touchState.moved;
  6270. touchState.moved = false;
  6271. if (moved) {
  6272. FB.snapToEdge();
  6273. } else {
  6274. FB.toggleOpen();
  6275. }
  6276. return true;
  6277. }
  6278. return false;
  6279. }
  6280. /** 松手吸附最近边缘并记忆(§10.5)。 */
  6281. FB.snapToEdge = function () {
  6282. var half = screenW() / 2;
  6283. FB.side = (FB.anchorX + dp(FB.DIAM) / 2 < half) ? "left" : "right";
  6284. FB.anchorX = (FB.side === "right")
  6285. ? screenW() - dp(FB.EDGE) - dp(FB.DIAM)
  6286. : dp(FB.EDGE);
  6287. FB.clampAnchor();
  6288. FB.savePosition();
  6289. positionBallWindow();
  6290. positionFanWindow(); /* 位置随球算,早点跟上 */
  6291. layoutFanViews(); /* 贴边方向变了 → 扇形要重新镜像,标签也得换到另一侧 */
  6292. FB.sync();
  6293. FB.publishRect();
  6294. };
  6295. /* ------------------------------------------------------------ 状态同步(三态外环) */
  6296. /**
  6297. * 把球刷成当前运行状态(§10.4):
  6298. * 空闲 → 无外环;运行中 → 青绿 22% + 呼吸;报错 → 玫瑰 20% 静态。
  6299. * 球内**永远**是品牌图标(不随状态换),状态只靠环表达。
  6300. */
  6301. FB.sync = function () {
  6302. if (!FB.window) { return; }
  6303. /* 状态切换期间不改环:此刻它还在展开态的位置上,而 sync() 会把环设成 VISIBLE
  6304. * (运行中),于是切换的空档里会冒出一个"位置不对的绿环"。等落地后再刷。 */
  6305. var ring = FB.views.ring;
  6306. if (!ring) { return; }
  6307. var tone = "idle";
  6308. if (App.failBar) { tone = "error"; }
  6309. else if (Session.running) { tone = "running"; }
  6310. if (tone === "running") {
  6311. ring.setBackground(U.solidBg(U.alphaOf(Theme.hex.ok, 0.22), Theme.m.pillRadius));
  6312. ring.setVisibility(android.view.View.VISIBLE);
  6313. FB.startBreath();
  6314. } else if (tone === "error") {
  6315. FB.stopBreath();
  6316. ring.setBackground(U.solidBg(U.alphaOf(Theme.hex.danger, 0.20), Theme.m.pillRadius));
  6317. ring.setVisibility(android.view.View.VISIBLE);
  6318. } else {
  6319. FB.stopBreath();
  6320. ring.setVisibility(android.view.View.GONE);
  6321. }
  6322. /* 扇形开着时,第 1 个按钮要跟着状态变(运行 ↔ 停止)。
  6323. *
  6324. * ★ 这里是**就地改那一个按钮**,不重建扇形 —— 真机上踩过两次:
  6325. * ① 无条件重建:本函数每秒被 tickTimer 调一次,用户点下去那一刻正好被重建掉,
  6326. * 点击就丢了("偶尔点不动",极难查);
  6327. * ② 只在状态变化时重建:仍然会与旧视图叠在一起(建到一半就与残留的叠着),
  6328. * 真机上表现为「运行」和「停止」两个标签同时出现、位置还错位。 */
  6329. var changed = (tone !== FB.lastTone);
  6330. FB.lastTone = tone;
  6331. if (FB.open && changed && FB.firstBtn) {
  6332. applyFirstButton();
  6333. }
  6334. };
  6335. /**
  6336. * 就地更新扇形第 1 个按钮(图标 / 文字 / 底色)。
  6337. *
  6338. * ★ 它**只改外观,不改行为** —— 行为由 `primaryState()` 在点击时现算
  6339. * (曾经在这里写 `FB.firstAction.fn = shape.fn`,造成"状态没变就永远没接线"的空回调,
  6340. * 详见 `fanSpecs()` 上的注释)。
  6341. */
  6342. function applyFirstButton() {
  6343. var shape = primaryState();
  6344. var view = FB.firstBtn.view;
  6345. view.setText(shape.icon);
  6346. view.setBackground(shape.gradient
  6347. ? U.gradientBg(Theme.hex.accent, Theme.hex.accentDeep, Theme.m.pillRadius)
  6348. : U.solidBg(U.alphaOf(Theme.hex.danger, shape.solid ? 1 : 0.14), Theme.m.pillRadius));
  6349. var pill = FB.firstBtn.pill;
  6350. pill.setText(shape.label);
  6351. var danger = !!shape.danger;
  6352. pill.setTextColor(U.argb(danger ? Theme.hex.danger : "#FFFFFF"));
  6353. pill.setBackground(U.solidBg(U.alphaOf(danger ? Theme.hex.danger : "#FFFFFF",
  6354. danger ? 0.14 : 0.10), Theme.m.pillRadius));
  6355. /* 就地改完再落一份布局快照 —— 否则 ball-layout.txt 永远停在"展开那一刻",
  6356. * 事后回看会以为按钮一直是「运行」(实际它已经变成「停止」,只是没重新 dump)。 */
  6357. }
  6358. /**
  6359. * 呼吸动效:外环在 22% ↔ 6% 之间循环。
  6360. * 用 setInterval 而不是 ValueAnimator —— 少引一层 Java 接口包装,
  6361. * 而且这里只需要"看起来在呼吸",不需要精确的插值曲线。
  6362. */
  6363. FB.startBreath = function () {
  6364. if (FB.breathTimer) { return; }
  6365. FB.breathTimer = setInterval(function () {
  6366. if (!FB.window || !FB.views.ring) { FB.stopBreath(); return; }
  6367. FB.breathOn = !FB.breathOn;
  6368. FB.views.ring.setBackground(U.solidBg(
  6369. U.alphaOf(Theme.hex.ok, FB.breathOn ? 0.22 : 0.06), Theme.m.pillRadius));
  6370. }, FB.BREATH_MS);
  6371. };
  6372. FB.stopBreath = function () {
  6373. if (FB.breathTimer) {
  6374. clearInterval(FB.breathTimer);
  6375. FB.breathTimer = null;
  6376. }
  6377. };
  6378. /**
  6379. * 呼吸环是否正在跑。
  6380. *
  6381. * 存在的理由:它占一个 `setInterval`,而自检里「运行计时器该起 / 该停」是按
  6382. * interval 总数断言的 —— 球一被建出来(就是它在说"正在运行"),总数就会多一个。
  6383. * 有了这个查询,断言可以写成「总数 − 呼吸环 = 运行计时器」,
  6384. * 而不是把期望值改成 2 这种"把噪声当基准"的写法。
  6385. */
  6386. FB.breathing = function () { return !!FB.breathTimer; };
  6387. /* ------------------------------------------------------------ 四个按钮的动作 */
  6388. /** ① 运行:走与 App 内完全相同的校验链(悬浮球不给自己开后门)。 */
  6389. function askRun() {
  6390. var taskKey = App.detail || Session.tab;
  6391. if (!taskKey) {
  6392. /* 没有"当前任务"时(停在任务列表):回 App 让用户自己挑一个,
  6393. * 比在这里猜一个任务跑起来安全得多(规格书 §2.5 的"已选脚本"校验同理)。 */
  6394. toast("\u8BF7\u5148\u5728\u4EFB\u52A1\u5217\u8868\u9009\u4E00\u4E2A\u811A\u672C");
  6395. App.bringToFront();
  6396. return;
  6397. }
  6398. App.requestRun(taskKey);
  6399. }
  6400. /* ------------------------------------------------------------ 二次确认(H5 / H6) */
  6401. /**
  6402. * ⚠️ 这里做了一处**有意识的偏离**,原因写清楚免得后来者白折腾:
  6403. *
  6404. * 规格书 §10.6 把 H5/H6 画成悬浮球自己的弹窗,我也照做了 —— 用第二个 floaty 窗
  6405. * 承载遮罩 + 卡片。真机上它**建得出来但画不出来**:同屏已经有一个浮窗(球)时,
  6406. * 再开一个全屏浮窗只剩 AutoX 的调整手柄小点,遮罩与卡片都不显示(试过补
  6407. * `setSize`/`setAdjustEnabled(false)`/延后 150ms 创建,都没解决)。
  6408. *
  6409. * 「停止」是球上最要紧的一个键,**绝不能出现点了没反应**。所以改成确定可行的路径:
  6410. * 点停止/退出 → 把 App 切到前台 → 用 App 里那套**已经在真机验证过的**弹窗确认。
  6411. * 代价是多一次"App 跳到前台",换来的是"这个键一定能用"。
  6412. *
  6413. * 后续若要回到规格书的浮窗形态:要么查清双浮窗的绘制问题(大概率与 AutoX 的
  6414. * 窗口尺寸协商有关),要么把确认做成**在扇形内联**(按钮变成"确认停止?"),
  6415. * 后者完全不需要第二个窗口,其实更稳。
  6416. */
  6417. function confirmThenAct(opts) {
  6418. trace('confirmThenAct 进入');
  6419. FB.collapse(true); /* 先收起扇形:确认期间不该留着一排按钮 */
  6420. setTimeout(function () {
  6421. /* 用户必须看得见确认弹窗,而弹窗画在 App 窗口里。
  6422. * `bringToFront` 失败时 App 不在前台 → 弹窗建得出来但没人看得见
  6423. * (外部表现为"点了停止,什么都没发生")。所以把成败记进轨迹。 */
  6424. var ok = false;
  6425. try { ok = App.bringToFront(); } catch (e) { traceError('bringToFront', e); }
  6426. trace('confirmThenAct bringToFront=' + ok);
  6427. try {
  6428. opts.dialog();
  6429. trace('confirmThenAct 弹窗已弹出');
  6430. } catch (e2) {
  6431. traceError('confirmThenAct 弹窗', e2);
  6432. }
  6433. }, 150);
  6434. }
  6435. /** ② 停止:H5 二次确认(规格书 §10.6,停止必须确认、运行不用)。 */
  6436. function askStop() {
  6437. var name = Session.scriptName || Pages.taskName(Session.tab);
  6438. var elapsed = Records.formatDuration(Session.elapsedMs());
  6439. confirmThenAct({
  6440. dialog: function () {
  6441. Dialog.stopScript(name, elapsed, function () {
  6442. App.stopRun();
  6443. App.renderScreen();
  6444. });
  6445. }
  6446. });
  6447. }
  6448. /** ③ 退出应用:H6 二次确认(§10.7:停止脚本 + 关闭悬浮球 + 退出到桌面)。 */
  6449. function askExit() {
  6450. confirmThenAct({
  6451. dialog: function () {
  6452. Dialog.exitApp(function () {
  6453. if (Session.running) { App.stopRun(); }
  6454. FB.destroy();
  6455. App.exit();
  6456. });
  6457. }
  6458. });
  6459. }
  6460. /**
  6461. * 隐藏悬浮球(§10.3 第 3 项)。
  6462. *
  6463. * 规格书没写"隐藏之后怎么再显示"—— 没有恢复入口的话用户就再也找不回来了。
  6464. * 所以这里落盘 `ball.hidden=true`,并在「我的」页给一个开关把它打开(本项目补充)。
  6465. */
  6466. FB.hide = function () {
  6467. Store.set("ball.hidden", true);
  6468. FB.destroy();
  6469. toast("\u5DF2\u9690\u85CF\u60AC\u6D6E\u7403\uFF08\u53EF\u5728\u300C\u6211\u7684\u300D\u91CC\u91CD\u65B0\u6253\u5F00\uFF09");
  6470. };
  6471. /* ------------------------------------------------- hidden 开关的双向同步 */
  6472. /** 「我的」页的开关调它:hidden=true 时立刻关掉球。 */
  6473. FB.setHidden = function (hidden) {
  6474. Store.set("ball.hidden", !!hidden);
  6475. if (hidden) { FB.destroy(); } else { FB.ensure(); }
  6476. };
  6477. /* ============================== src/page-id.js ============================== */
  6478. /* ============================== src/page-id.js ==============================
  6479. * ID提取页 —— **三段式**(v8 / 2026-10-17)。
  6480. *
  6481. * ① 取材 从哪儿找人 —— 可累加的取材清单(每条自带起点与深度)
  6482. * ② 只要这样的账号 —— 过滤条件(所有取材共用)
  6483. * ③ 输出 上限 / 去重 / 文件
  6484. *
  6485. * ## 为什么不再是"四张脚本卡选一个"
  6486. * v7 及以前是四张卡单选。那四张卡其实是「起点(人从哪来)」×「深度(要不要再展开
  6487. * 一层)」的 4 个**不完整组合**:各缺一块、各有别人没有的一块,合起来才完整。
  6488. * 于是「同一时间仅可运行一个脚本」这条约束本身就在跟用户较劲 ——
  6489. * 想同时抓"关键词视频的作者"和"文本里这批 uid 的粉丝"时,它直接不允许。
  6490. *
  6491. * 现在的模型:
  6492. * · **五个起点**(指定账号 / 搜索结果 / 某个视频的评论区 / 文本文件 / 当前屏幕)
  6493. * · **一个深度选项**(不展开 / 再取粉丝 / 再取关注),最多 2 层
  6494. * · 取材可加多条,**按顺序串行执行**(真机只有一个前台,没有并发可言),
  6495. * 共用同一份去重集合与同一个输出文件
  6496. *
  6497. * ## 每个控件都有唯一含义
  6498. * 选了什么起点才出现什么输入 —— 不会再有"勾上却无所指"的开关。
  6499. * 这是 v7 那次"清语义"留下来的规矩,v8 继续守。
  6500. */
  6501. var Pages = Pages || {};
  6502. /**
  6503. * 各页脚本清单的集中注册表(main.js 用 key → 中文名翻译)。
  6504. * ID提取已经没有"脚本"这个概念,但仍然注册一个占位条目:
  6505. * 导出配置、运行日志、拦截提示里都会把 key 翻成中文名,
  6506. * 让那套通用机制不必为 id 开特例。
  6507. */
  6508. Pages.SCRIPTS_OF = {};
  6509. Pages.registerScripts = function (tab, list) {
  6510. Pages.SCRIPTS_OF[tab] = list;
  6511. };
  6512. /* ★ 引擎声明:`engine` 一填,运行前置校验的第 4 步才会放行。
  6513. * 没有它时点「开始运行」会被 Dialog.engineMissing 拦下 —— 这是有意的设计:
  6514. * **绝不静默跑一个不相关的东西**。ID 提取的引擎侧(task = 'id')已接入。 */
  6515. Pages.ID_SCRIPTS = [{ key: "id", name: "ID提取", engine: "tiktok-auto" }];
  6516. Pages.registerScripts("id", Pages.ID_SCRIPTS);
  6517. /* ============================================================ ① 取材 */
  6518. /** 候选文案数组(下拉要的是纯文案,顺序与 Sources 里的表一致)。 */
  6519. function labelsOf(table) {
  6520. var out = [];
  6521. for (var i = 0; i < table.length; i++) { out.push(table[i].label); }
  6522. return out;
  6523. }
  6524. /** 取某个表里 key 的下标(选了之后要把文案换掉,回调给的是下标)。 */
  6525. function indexOfKey(table, key) {
  6526. for (var i = 0; i < table.length; i++) {
  6527. if (table[i].key === key) { return i; }
  6528. }
  6529. return 0;
  6530. }
  6531. /**
  6532. * 切换"取哪些列表"里的某一项。
  6533. *
  6534. * **最后一个不许取消**:三个都取消,这条取材就没有任何来源可抓 ——
  6535. * 它会变成一条永远跑不出结果的取材,而用户看不出为什么。
  6536. */
  6537. function toggleListOf(itemId, key, next) {
  6538. var cur = Sources.find(itemId);
  6539. if (!cur) { return; }
  6540. var list = cur.lists.slice();
  6541. var at = list.indexOf(key);
  6542. if (next && at < 0) {
  6543. list.push(key);
  6544. } else if (!next && at >= 0) {
  6545. if (list.length === 1) {
  6546. Dialog.alert("至少要留一个列表",
  6547. "这条取材要取哪个列表?\n\n三个都取消的话,它就没有任何来源可抓了。");
  6548. Pages.requestRerender();
  6549. return;
  6550. }
  6551. list.splice(at, 1);
  6552. } else {
  6553. return; /* 状态没变,不必写盘也不必重建 */
  6554. }
  6555. Sources.update(itemId, { lists: list });
  6556. Pages.requestRerender();
  6557. }
  6558. /** 「取哪些列表」多选行。 */
  6559. function buildListPicker(item) {
  6560. var row = U.row();
  6561. for (var i = 0; i < Sources.LISTS.length; i++) {
  6562. (function (entry, at) {
  6563. var on = item.lists.indexOf(entry.key) >= 0;
  6564. var box = W.checkRow(entry.label, on, function (next) {
  6565. toggleListOf(item.id, entry.key, next);
  6566. });
  6567. if (at > 0) { row.addView(U.hgap(10)); }
  6568. row.addView(box.root);
  6569. })(Sources.LISTS[i], i);
  6570. }
  6571. return row;
  6572. }
  6573. /** 起点下拉(选完要重排输入行,所以必须重建整页)。 */
  6574. function buildKindChooser(item) {
  6575. return W.chooser({
  6576. label: Sources.kindLabel(item.kind),
  6577. options: labelsOf(Sources.KINDS),
  6578. index: function () {
  6579. var cur = Sources.find(item.id);
  6580. return indexOfKey(Sources.KINDS, cur ? cur.kind : "anchor");
  6581. },
  6582. onPick: function (i) {
  6583. /* 换起点 → normalize 会把旧起点特有的参数丢掉(见 Sources.normalize 的第 ① 条) */
  6584. Sources.update(item.id, { kind: Sources.KINDS[i].key });
  6585. Pages.requestRerender();
  6586. },
  6587. minWidthDp: 110
  6588. });
  6589. }
  6590. /** 深度下拉。`screen` 起点已经是最底层,不给这个选项。 */
  6591. function buildExpandChooser(item) {
  6592. if (!Sources.canExpand(item.kind)) { return null; }
  6593. return W.chooser({
  6594. label: Sources.expandLabel(item.expand),
  6595. options: labelsOf(Sources.EXPANDS),
  6596. index: function () {
  6597. var cur = Sources.find(item.id);
  6598. return indexOfKey(Sources.EXPANDS, cur ? cur.expand : "");
  6599. },
  6600. onPick: function (i) {
  6601. Sources.update(item.id, { expand: Sources.EXPANDS[i].key });
  6602. Pages.requestRerender();
  6603. },
  6604. minWidthDp: 104
  6605. });
  6606. }
  6607. /** 一个绑到取材字段的输入框(不是 Store 字段,所以用 read/write 而不是 path)。 */
  6608. function sourceField(item, key, placeholder, widthDp) {
  6609. return W.inlineField({
  6610. read: function () {
  6611. var cur = Sources.find(item.id);
  6612. return cur ? cur[key] : "";
  6613. },
  6614. write: function (value) {
  6615. var patch = {};
  6616. patch[key] = value;
  6617. Sources.update(item.id, patch);
  6618. },
  6619. placeholder: placeholder,
  6620. widthDp: widthDp
  6621. });
  6622. }
  6623. /** 一条取材的输入区 —— **按起点分派**,选了什么才出现什么。 */
  6624. function buildSourceInputs(item) {
  6625. var rows = [];
  6626. /* —— 指定账号 —— */
  6627. if (item.kind === "anchor") {
  6628. rows.push(W.settingRow("账号",
  6629. sourceField(item, "anchors", "账号1#账号2#账号3", 148).root, 8));
  6630. rows.push(W.settingRow("取哪些列表", null, 0));
  6631. rows.push(buildListPicker(item));
  6632. rows.push(W.hint("账号留空 = 从当前屏幕上的列表直接抓", false));
  6633. }
  6634. /* —— 搜索结果 —— */
  6635. if (item.kind === "search") {
  6636. rows.push(W.settingRow("搜什么", W.chooser({
  6637. label: item.searchType === "videos" ? "视频作者" : "用户",
  6638. options: labelsOf(Sources.SEARCH_TYPES),
  6639. index: function () {
  6640. var cur = Sources.find(item.id);
  6641. return indexOfKey(Sources.SEARCH_TYPES, cur ? cur.searchType : "users");
  6642. },
  6643. onPick: function (i) {
  6644. Sources.update(item.id, { searchType: Sources.SEARCH_TYPES[i].key });
  6645. Pages.requestRerender();
  6646. },
  6647. minWidthDp: 96
  6648. }).root, 8));
  6649. rows.push(W.settingRow("关键词",
  6650. sourceField(item, "keyword", "关键词", 148).root, 8));
  6651. }
  6652. /* —— 某个视频的评论区 —— */
  6653. if (item.kind === "video") {
  6654. rows.push(W.settingRow("视频从哪来", W.chooser({
  6655. label: item.videoSource === "keyword" ? "关键词搜索" : "当前屏幕",
  6656. options: labelsOf(Sources.VIDEO_SOURCES),
  6657. index: function () {
  6658. var cur = Sources.find(item.id);
  6659. return indexOfKey(Sources.VIDEO_SOURCES, cur ? cur.videoSource : "screen");
  6660. },
  6661. onPick: function (i) {
  6662. Sources.update(item.id, { videoSource: Sources.VIDEO_SOURCES[i].key });
  6663. Pages.requestRerender();
  6664. },
  6665. minWidthDp: 104
  6666. }).root, 8));
  6667. if (item.videoSource === "keyword") {
  6668. rows.push(W.settingRow("搜索关键词",
  6669. sourceField(item, "videoKeyword", "关键词", 148).root, 8));
  6670. } else {
  6671. rows.push(W.hint("当前屏幕 = 用你此刻停在的那条视频的评论区", false));
  6672. }
  6673. }
  6674. /* —— 文本文件 —— */
  6675. if (item.kind === "text") {
  6676. var field = sourceField(item, "path", "文本路径", 108);
  6677. var pathRow = U.row();
  6678. pathRow.addView(U.text("文件", Theme.m.labelSize, Theme.hex.textSecondary, false));
  6679. pathRow.addView(U.flexible());
  6680. pathRow.addView(field.root);
  6681. pathRow.addView(U.hgap(6));
  6682. /* storePath 传 null:只让选择器回调路径,不写 Store(取材字段不在字段表里) */
  6683. pathRow.addView(W.filePickerButton(null, function (picked) {
  6684. Sources.update(item.id, { path: picked });
  6685. field.paint();
  6686. }).root);
  6687. rows.push(pathRow);
  6688. rows.push(W.hint("一行一个 UID;空行与 # 开头的行会被跳过", false));
  6689. }
  6690. /* —— 当前屏幕 —— */
  6691. if (item.kind === "screen") {
  6692. rows.push(W.hint("用你此刻停在的那个列表(粉丝页 / 关注页 / 评论区都行)", false));
  6693. }
  6694. /* —— 深度(所有起点共用,screen 除外) —— */
  6695. var expand = buildExpandChooser(item);
  6696. if (expand) {
  6697. rows.push(W.settingRow("深度", expand.root, 8));
  6698. }
  6699. return rows;
  6700. }
  6701. /** 一条取材卡片。 */
  6702. function buildSourceCard(item, index) {
  6703. var card = W.card(false);
  6704. var head = U.row();
  6705. head.addView(U.text("第 " + (index + 1) + " 条", Theme.m.labelSize, Theme.hex.textPrimary, true));
  6706. head.addView(U.flexible());
  6707. var remove = W.smallButton("删除", "danger", function () {
  6708. Sources.removeAt(index);
  6709. Pages.requestRerender();
  6710. });
  6711. head.addView(remove.root);
  6712. var rows = [head];
  6713. rows.push(U.spacer(4));
  6714. rows.push(W.settingRow("起点", buildKindChooser(item).root, 8));
  6715. var inputs = buildSourceInputs(item);
  6716. for (var i = 0; i < inputs.length; i++) { rows.push(inputs[i]); }
  6717. W.stack(card, rows, 10);
  6718. return card;
  6719. }
  6720. /** 一条取材都没有时的占位 —— 不用 `W.emptyState`(它靠 weight 撑满整屏,在滚动页里不成立)。 */
  6721. function buildSourcesEmpty() {
  6722. var box = U.column();
  6723. box.setBackground(U.strokeBg(0.03, 0.10, "#FFFFFF", Theme.m.cardRadius));
  6724. box.setPadding(U.dp(16), U.dp(22), U.dp(16), U.dp(22));
  6725. var head = U.text("还没有取材", 13, Theme.hex.textSecondary, true);
  6726. head.setGravity(android.view.Gravity.CENTER);
  6727. box.addView(head);
  6728. box.addView(U.spacer(6));
  6729. var sub = U.text("点下面的「添加一条取材」,选从哪儿找人", Theme.m.hintSize,
  6730. Theme.hex.textFaint, false);
  6731. sub.setGravity(android.view.Gravity.CENTER);
  6732. box.addView(sub);
  6733. return box;
  6734. }
  6735. /* ============================================== ② 只要这样的账号 */
  6736. /** 卡内分组标题(与 v7 一致:比 sectionTitle 轻一档)。 */
  6737. function featureGroup(title) {
  6738. var view = U.text(title, 11, Theme.hex.textMuted, true);
  6739. view.setPadding(0, U.dp(8), 0, 0);
  6740. return view;
  6741. }
  6742. /**
  6743. * ② 过滤:所有取材共用。
  6744. *
  6745. * 这里**一项都不删**(v7 的成果):私密 / 无头像 / 语言 / 取样范围 / 四组数量门槛。
  6746. * 只有「提取区域」「AI 画像识别」两项当前没有承载者 → 灰行 + 暂不支持
  6747. * (不给一个能开却什么也不会发生的开关)。
  6748. */
  6749. function buildAccountFilters() {
  6750. var card = W.card(false);
  6751. var base = "tabs.id.features.";
  6752. var blocks = [];
  6753. blocks.push(featureGroup("账号特征"));
  6754. var privateRow = W.checkRow("过滤私密账户", Store.getBool(base + "filterPrivate"),
  6755. function (on) { Store.set(base + "filterPrivate", on); });
  6756. var noAvatarRow = W.checkRow("过滤无头像账户", Store.getBool(base + "filterNoAvatar"),
  6757. function (on) { Store.set(base + "filterNoAvatar", on); });
  6758. var filterLine = U.row();
  6759. U.params(privateRow.root, 0, -2, 1);
  6760. U.params(noAvatarRow.root, 0, -2, 1);
  6761. filterLine.addView(privateRow.root);
  6762. filterLine.addView(noAvatarRow.root);
  6763. blocks.push(filterLine);
  6764. /* 语言区域 + 取样范围:后者是前者的**取样字段**,不是独立的过滤条件 */
  6765. var langRow = U.row();
  6766. langRow.addView(U.text("语言区域", Theme.m.labelSize, Theme.hex.textSecondary, false));
  6767. langRow.addView(U.hgap(8));
  6768. langRow.addView(W.dropdown("langRegion", base + "langRegion").root);
  6769. langRow.addView(U.flexible());
  6770. langRow.addView(U.text("取样范围", Theme.m.labelSize, Theme.hex.textSecondary, false));
  6771. langRow.addView(U.hgap(8));
  6772. langRow.addView(W.dropdown("nicknameCheck", base + "nicknameCheck").root);
  6773. blocks.push(langRow);
  6774. blocks.push(W.mutedRow("提取区域", Store.getText(base + "regionCodes"), function () {
  6775. Dialog.alert("提取区域 · 暂不支持",
  6776. "TikTok 的用户页**不显示账号所属国家**,没有可靠的地方能读到它,"
  6777. + "所以这一项目前不会生效。\n\n"
  6778. + "你填的国家简码(" + Store.getText(base + "regionCodes") + ")已经保留,"
  6779. + "将来接入地区数据源后会直接生效,不需要重新填。\n\n"
  6780. + "当前版本的过滤只有:私密、无头像、语言,以及下面的四组数量门槛。");
  6781. }).root);
  6782. blocks.push(W.mutedRow("AI 画像识别", "头像 · 性别 · 年龄", function () {
  6783. Dialog.alert("AI 画像识别 · 暂不支持",
  6784. "AI 头像识别 / AI 性别 / AI 年龄需要人脸与属性模型(本地模型或云端接口),"
  6785. + "当前版本没有承载者。\n\n"
  6786. + "这三项原来的设置值都已保留,将来接入后直接生效。\n\n"
  6787. + "把关掉的道理和「提取区域」一样:与其给一个开了也不发生任何事的开关,"
  6788. + "不如明确告诉你它现在不参与。");
  6789. }).root);
  6790. blocks.push(featureGroup("数值门槛"));
  6791. var metrics = [
  6792. { key: "follow", label: "关注量" },
  6793. { key: "fans", label: "粉丝量" },
  6794. { key: "likes", label: "获赞量" },
  6795. { key: "videos", label: "视频量" }
  6796. ];
  6797. for (var i = 0; i < metrics.length; i++) {
  6798. var m = metrics[i];
  6799. blocks.push(W.rangeRow(base + m.key + "Min", base + m.key + "Max", m.label, "~").root);
  6800. }
  6801. W.stack(card, blocks, 12);
  6802. return card;
  6803. }
  6804. /* ============================================== ③ 输出 */
  6805. function buildOutput() {
  6806. var card = W.card(false);
  6807. var base = "tabs.id.features.";
  6808. var blocks = [];
  6809. var localBox = W.checkRow("本地去重", Store.getBool(base + "dedupeLocal"),
  6810. function (on) { Store.set(base + "dedupeLocal", on); });
  6811. var historyBox = W.checkRow("跨轮去重", Store.getBool(base + "dedupeHistory"),
  6812. function (on) {
  6813. Store.set(base + "dedupeHistory", on);
  6814. Pages.requestRerender(); /* 打开后会多一行说明 */
  6815. });
  6816. var dedupeRow = U.row();
  6817. U.params(localBox.root, 0, -2, 1);
  6818. U.params(historyBox.root, 0, -2, 1);
  6819. dedupeRow.addView(localBox.root);
  6820. dedupeRow.addView(historyBox.root);
  6821. blocks.push(dedupeRow);
  6822. if (Store.getBool(base + "dedupeHistory")) {
  6823. blocks.push(W.hint("跨轮 = 与历史已提取的 ID 比对,重复的跳过", false));
  6824. }
  6825. /* 云端去重:勾选后才出现表名与连通测试 */
  6826. var cloudBox = W.checkRow("云端去重", Store.getBool(base + "cloudDedupe"),
  6827. function (on) {
  6828. Store.set(base + "cloudDedupe", on);
  6829. Pages.requestRerender();
  6830. });
  6831. if (Store.getBool(base + "cloudDedupe")) {
  6832. var cloudRow = U.row();
  6833. cloudRow.addView(cloudBox.root);
  6834. cloudRow.addView(U.flexible());
  6835. cloudRow.addView(W.textField(base + "cloudTable", "表名:字符", 100).root);
  6836. cloudRow.addView(U.hgap(6));
  6837. var testBtn = W.chip("连通测试", "accent");
  6838. testBtn.root.setOnClickListener(function () {
  6839. var table = Store.getText(base + "cloudTable");
  6840. Dialog.alert("连通测试",
  6841. table === "" ? "请先填写表名。"
  6842. : "表名:" + table + "\n\n当前未配置云端去重服务地址,无法真实测试。");
  6843. });
  6844. cloudRow.addView(testBtn.root);
  6845. blocks.push(cloudRow);
  6846. } else {
  6847. blocks.push(cloudBox.root);
  6848. }
  6849. /* 提取上限:0 的语义写在文案里 —— 只说"0",用户会以为是"提取 0 个" */
  6850. var stopRow = U.row();
  6851. stopRow.addView(U.text("提取满", Theme.m.labelSize, Theme.hex.textSecondary, false));
  6852. stopRow.addView(U.flexible());
  6853. stopRow.addView(W.numberField(base + "extractLimit", "提取符合").root);
  6854. stopRow.addView(U.hgap(6));
  6855. stopRow.addView(U.text("个 ID 后停止(0 = 不限)", Theme.m.labelSize, Theme.hex.textMuted, false));
  6856. blocks.push(stopRow);
  6857. /* 运行时长:它现在是**下限**而不是上限 —— 引擎会按提取上限自动放宽
  6858. * (每个收录约 20 秒,见 tiktok-auto/src/idflow.js 的 budgetFor)。
  6859. * 设 100 个却只给 30 分钟,会永远抓不满;所以文案必须说清"会自动放宽"。 */
  6860. var runtimeRow = U.row();
  6861. runtimeRow.addView(U.text("至少运行", Theme.m.labelSize, Theme.hex.textSecondary, false));
  6862. runtimeRow.addView(U.flexible());
  6863. runtimeRow.addView(W.numberField(base + "maxRuntimeMin", "30").root);
  6864. runtimeRow.addView(U.hgap(6));
  6865. runtimeRow.addView(U.text("分钟(按提取上限自动放宽,最多 4 小时)",
  6866. Theme.m.labelSize, Theme.hex.textMuted, false));
  6867. blocks.push(runtimeRow);
  6868. /* 数字 uid:默认开。它要额外下一遍用户主页(约 2.5 秒/个),
  6869. * 只要 handle 的场景可以关掉省时间 —— 以前没有开关,想关只能改配置。 */
  6870. var resolveBox = W.checkRow("同时解析数字 uid", Store.getBool(base + "resolveUid"),
  6871. function (on) { Store.set(base + "resolveUid", on); });
  6872. blocks.push(resolveBox.root);
  6873. if (Store.getBool(base + "resolveUid")) {
  6874. blocks.push(W.hint("抓完再逐个换成 TikTok 内部的数字 uid,另存一份 uid_map 文件;每个约多花 2~3 秒", false));
  6875. }
  6876. blocks.push(W.hint("结果文件自动命名 uid_日期_时分.txt,存放在 /sdcard/Download/tiktok_app", false));
  6877. W.stack(card, blocks, 12);
  6878. return card;
  6879. }
  6880. /* ============================================== ⓪ 断点提示条 */
  6881. /**
  6882. * 「上次没跑完」的提示条。三种状态各自一套文案与动作:
  6883. *
  6884. * | 状态 | 标题 | 动作 |
  6885. * |---|---|---|
  6886. * | 可续跑 | 上次运行没跑完(已收 N 个) | 继续 / 清空 |
  6887. * | 配置变了 | 上次运行无法继续 | 清空断点 |
  6888. * | 结果文件没了 | 上次运行无法继续 | 清空断点 |
  6889. *
  6890. * 后两种只给「清空断点」——没有别的能做的,说清原因比给一个点了没用的按钮强。
  6891. */
  6892. function buildResumeNotice(snap) {
  6893. if (snap.valid) {
  6894. return W.noticeBar({
  6895. title: "上次运行没跑完(已收 " + snap.extracted + " 个)",
  6896. detail: Resume.detail(snap),
  6897. tone: "accent",
  6898. actions: [
  6899. { label: "继续", tone: "accent", onClick: function () { App.resumeRun(); } },
  6900. { label: "清空", tone: "mute", onClick: function () { App.clearCheckpoint(); } }
  6901. ]
  6902. }).root;
  6903. }
  6904. return W.noticeBar({
  6905. title: "上次运行无法继续",
  6906. detail: snap.message || "断点已失效",
  6907. tone: "warn",
  6908. actions: [
  6909. { label: "清空断点", tone: "mute", onClick: function () { App.clearCheckpoint(); } }
  6910. ]
  6911. }).root;
  6912. }
  6913. /* ============================================== 页面 */
  6914. Pages.id = function (rerender) {
  6915. var inner = U.column();
  6916. var blocks = [];
  6917. /* ——— ⓪ 断点提示(有未跑完的才出现)———
  6918. * 放在最上面:用户进这个页最想知道的就是"我上次那轮还没跑完"。
  6919. * 只在没有运行中时出现 —— 跑着的时候点"继续"只会撞上"已有任务在跑"。
  6920. * snapshot 一次读盘,不在下面各处重复读。 */
  6921. var snap = Resume.snapshot();
  6922. if (!Session.running && snap.exists && !snap.finished && snap.reason !== 'cleared') {
  6923. blocks.push(buildResumeNotice(snap));
  6924. blocks.push(U.spacer(Theme.m.gapCard));
  6925. }
  6926. /* ——— ① 取材 ——— */
  6927. blocks.push(W.sectionTitle("取材"));
  6928. blocks.push(W.hint("从哪儿找人。可添加多条,按顺序执行;共用同一份去重与输出", false));
  6929. var list = Sources.list();
  6930. if (list.length === 0) {
  6931. blocks.push(buildSourcesEmpty());
  6932. } else {
  6933. for (var i = 0; i < list.length; i++) {
  6934. blocks.push(buildSourceCard(list[i], i));
  6935. }
  6936. }
  6937. if (list.length < Sources.MAX) {
  6938. var add = W.ghostButton("+ 添加一条取材", 44, function () {
  6939. if (Sources.count() >= Sources.MAX) { return; }
  6940. Sources.add("anchor");
  6941. Pages.requestRerender();
  6942. });
  6943. blocks.push(add.root);
  6944. } else {
  6945. blocks.push(W.hint("已达上限 " + Sources.MAX + " 条 —— 想加新的先删一条", false));
  6946. }
  6947. /* ——— ② 只要这样的账号 ——— */
  6948. blocks.push(U.spacer(4));
  6949. blocks.push(W.sectionTitle("只要这样的账号"));
  6950. blocks.push(buildAccountFilters());
  6951. /* ——— ③ 输出 ——— */
  6952. blocks.push(U.spacer(4));
  6953. blocks.push(W.sectionTitle("输出"));
  6954. blocks.push(buildOutput());
  6955. /* 运行中的约束提示留在页尾:它约束的是整页,而它只在运行中出现 */
  6956. if (Session.running) {
  6957. blocks.push(U.spacer(4));
  6958. blocks.push(W.hint("运行中不可修改取材 · 请保持无障碍权限开启", true));
  6959. }
  6960. W.stack(inner, blocks, Theme.m.gapPage);
  6961. var scroller = W.scroller();
  6962. scroller.addView(inner);
  6963. return scroller;
  6964. };
  6965. /* ============================== src/page-dm.js ============================== */
  6966. /* ============================== src/page-dm.js ==============================
  6967. * UID私信页(设计稿 3:534 与展开态 7:11)。
  6968. *
  6969. * 结构:脚本选择(2 张卡)→ 功能设置 → 私信 / 评论设置。
  6970. *
  6971. * ⚠️ 设计稿两版的功能设置卡内容不一致:
  6972. * 3:579(折叠态页面):AI 性别 / 关注上限 / 私信上限 / 休息间隔
  6973. * 7:79 (展开态页面):进行关注私信 / 关注上限 / 私信上限 / 去重方式
  6974. * 逐版核对过:**「关注上限」与「私信上限」两版都是分开的两行**,
  6975. * 差异只在「有没有那个进行关注/私信的合并开关」。这里两张卡的设置都保留。
  6976. */
  6977. var Pages = Pages || {};
  6978. Pages.DM_SCRIPTS = [
  6979. { key: "s1", name: "脚本一 \u00B7 已提 UID 私信", engine: "tiktok-auto" },
  6980. { key: "s2", name: "脚本二 \u00B7 自己粉丝私信", engine: "tiktok-auto" }
  6981. ];
  6982. Pages.registerScripts("dm", Pages.DM_SCRIPTS);
  6983. /**
  6984. * 脚本二展开后的说明。
  6985. *
  6986. * 它**没有自己的配置项**:取材就发生在「自己的粉丝列表」里,
  6987. * 要多少人由共用参数决定(关注数量 + 私信数量)。所以这里只解释规则,
  6988. * 不放任何控件 —— 放一个"取材上限"之类的假选项,反而会让人以为能调。
  6989. */
  6990. function buildDmScript2Detail() {
  6991. var detail = U.column();
  6992. var rows = [
  6993. W.hint("\u4ECE\u4F60\u81EA\u5DF1\u7684\u7C89\u4E1D\u5217\u8868\u53D6\u4EBA\uFF1A" +
  6994. "\u5148\u53D6\u300C\u5173\u6CE8\u6570\u91CF\u300D\u4E2A\u56DE\u5173\uFF0C" +
  6995. "\u518D\u53D6\u300C\u79C1\u4FE1\u6570\u91CF\u300D\u4E2A\u53D1\u79C1\u4FE1\u3002", false),
  6996. W.hint("\u4E24\u6279\u4E0D\u91CD\u53E0\uFF0C\u6240\u4EE5\u7C89\u4E1D\u6570\u8981\u4E0D\u5C11\u4E8E\u4E24\u8005\u4E4B\u548C\uFF1B" +
  6997. "\u4E0D\u591F\u65F6\u6309\u5B9E\u9645\u6570\u91CF\u6267\u884C\uFF0C\u5E76\u5728\u7ED3\u679C\u91CC\u8BF4\u660E\u3002", false)
  6998. ];
  6999. W.stack(detail, rows, 8);
  7000. return detail;
  7001. }
  7002. /** 脚本一展开后的四行:按什么条件筛、筛完做什么、从哪儿读 UID。 */
  7003. function buildDmScript1Detail(rerender) {
  7004. var base = "tabs.dm.scripts.s1.";
  7005. var detail = U.column();
  7006. var rows = [];
  7007. /* UID 私信:勾选后对筛出的 UID 逐个发私信 */
  7008. var doDmBox = W.checkbox(Store.get(base + "doDm", true), 16);
  7009. doDmBox.root.setOnClickListener(function () {
  7010. var next = !doDmBox.isChecked();
  7011. doDmBox.set(next);
  7012. Store.set(base + "doDm", next);
  7013. });
  7014. var doDmLabel = U.text("UID \u79C1\u4FE1", Theme.m.labelSize, Theme.hex.textSecondary, false);
  7015. var doDmRow = U.row();
  7016. doDmRow.addView(doDmBox.root);
  7017. doDmRow.addView(U.hgap(8));
  7018. doDmRow.addView(doDmLabel);
  7019. doDmRow.addView(U.flexible());
  7020. rows.push(doDmRow);
  7021. rows.push(W.settingRow("\u7B5B\u9996\u89C6\u9891\u65E5\u671F",
  7022. W.dropdown("filterDate", base + "filterDate").root, 8));
  7023. rows.push(W.settingRow("\u9996\u89C6\u9891\u70B9\u8D5E / \u9996\u89C6\u9891\u8BC4\u8BBA",
  7024. W.dropdown("firstAction", base + "firstAction").root, 6));
  7025. rows.push(W.filePicker("\u5DF2\u7B5B\u7B26\u5408\u7684 UID \u6587\u672C", base + "uidTextPath", "\u9009\u62E9").root);
  7026. W.stack(detail, rows, 12);
  7027. return detail;
  7028. }
  7029. /**
  7030. * 脚本勾选:**单选**(同一时间只能跑一个)。
  7031. *
  7032. * `W.scriptHeader` 只负责画和报"点完该是什么状态",互斥必须由页面做
  7033. * (组件注释里写明了这一点)。漏了这一步的后果很具体:
  7034. * 两个脚本都勾着时,`buildDmConfig` 只能挑一个跑 ——
  7035. * 用户以为跑的是「自己粉丝私信」,实际跑的是「已提 UID 私信」,
  7036. * 拿一份可能过期的 UID 文件发出去,而且界面上看不出任何异常。
  7037. */
  7038. function toggleDmScript(scriptKey, next) {
  7039. for (var i = 0; i < Pages.DM_SCRIPTS.length; i += 1) {
  7040. var key = Pages.DM_SCRIPTS[i].key;
  7041. var path = "tabs.dm.scripts." + key + ".checked";
  7042. if (key === scriptKey) {
  7043. Store.set(path, next);
  7044. } else if (next) {
  7045. /* 只在新勾上时才清别人:取消自己不该顺手把别人也取消掉 */
  7046. Store.set(path, false);
  7047. }
  7048. }
  7049. }
  7050. /* 暴露给自检:互斥是"数据层的不变量",不该只能靠点 UI 才能验 */
  7051. Pages.toggleDmScript = toggleDmScript;
  7052. function buildDmScriptCard(script, rerender) {
  7053. var base = "tabs.dm.scripts." + script.key + ".";
  7054. var checked = Store.getBool(base + "checked");
  7055. var expanded = Store.getBool(base + "expanded");
  7056. var card = W.card(checked);
  7057. card.addView(W.scriptHeader({
  7058. checked: checked,
  7059. expanded: expanded,
  7060. name: script.name,
  7061. summary: null,
  7062. disabled: Session.running,
  7063. onToggleCheck: function (next) {
  7064. toggleDmScript(script.key, next);
  7065. rerender();
  7066. },
  7067. onToggleExpand: function () {
  7068. Store.set(base + "expanded", !expanded);
  7069. rerender();
  7070. }
  7071. }).root);
  7072. if (expanded && script.key === "s1") {
  7073. card.addView(U.spacer(12));
  7074. card.addView(buildDmScript1Detail(rerender));
  7075. } else if (expanded && script.key === "s2") {
  7076. card.addView(U.spacer(12));
  7077. card.addView(buildDmScript2Detail());
  7078. }
  7079. return card;
  7080. }
  7081. /**
  7082. * 功能设置卡(设计稿 3:579 折叠态 / 7:79 展开态)。
  7083. *
  7084. * 三处刻意保留的写法:
  7085. * · **进行关注 / 进行私信是两个独立开关**(稿子 7:79 把它们合成一个)。
  7086. * 合成一个的话,想单独关掉私信就得连关注一起关。
  7087. * · **数量用区间**(关注数量 / 私信数量 / 休息时间):一个量的下界与上界,
  7088. * 一行两项、两个数各自可改。
  7089. * · **去重方式只出现在展开态卡片上**,这里一并保留(两版稿的并集)。
  7090. */
  7091. function buildDmSettings() {
  7092. var card = W.card(false);
  7093. var rows = [
  7094. W.settingRow("AI 性别",
  7095. W.dropdown("aiGender", "tabs.dm.aiGender").root, 6),
  7096. /* ——— 关注 ——— */
  7097. W.settingRow("进行关注",
  7098. W.toggle(Store.getBool("tabs.dm.doFollow"), function (on) {
  7099. Store.set("tabs.dm.doFollow", on);
  7100. }).root, 6),
  7101. W.rangeRow("tabs.dm.followMin", "tabs.dm.followMax", "关注数量", "~").root,
  7102. /* ——— 私信 ——— */
  7103. W.settingRow("进行私信",
  7104. W.toggle(Store.getBool("tabs.dm.doDm"), function (on) {
  7105. Store.set("tabs.dm.doDm", on);
  7106. }).root, 6),
  7107. W.rangeRow("tabs.dm.dmMin", "tabs.dm.dmMax", "私信数量", "~").root,
  7108. /* ——— 休息时间:一个区间的两个端点 ——— */
  7109. W.rangeRow("tabs.dm.restMin", "tabs.dm.restMax", "休息时间(秒)", "~").root,
  7110. W.settingRow("去重方式",
  7111. W.dropdown("dedupeMode", "tabs.dm.dedupeMode").root, 6)
  7112. ];
  7113. W.stack(card, rows, 12);
  7114. return card;
  7115. }
  7116. /** 私信 / 评论文本设置卡。 */
  7117. function buildDmTexts() {
  7118. var card = W.card(false);
  7119. var rows = [
  7120. W.filePicker("\u968F\u673A\u79C1\u4FE1\u6587\u672C", "tabs.dm.dmTextPath").root,
  7121. W.filePicker("\u968F\u673A\u8BC4\u8BBA\u6587\u672C", "tabs.dm.commentTextPath").root
  7122. ];
  7123. W.stack(card, rows, 12);
  7124. return card;
  7125. }
  7126. Pages.dm = function (rerender) {
  7127. var inner = U.column();
  7128. var blocks = [];
  7129. blocks.push(W.sectionTitle("\u811A\u672C\u9009\u62E9"));
  7130. for (var i = 0; i < Pages.DM_SCRIPTS.length; i++) {
  7131. blocks.push(buildDmScriptCard(Pages.DM_SCRIPTS[i], rerender));
  7132. }
  7133. blocks.push(U.spacer(4));
  7134. blocks.push(W.sectionTitle("\u529F\u80FD\u8BBE\u7F6E"));
  7135. blocks.push(buildDmSettings());
  7136. blocks.push(U.spacer(4));
  7137. blocks.push(W.sectionTitle("\u79C1\u4FE1 / \u8BC4\u8BBA\u8BBE\u7F6E"));
  7138. blocks.push(buildDmTexts());
  7139. /* 本页内容会超出屏幕,末尾留一点余量,避免最后一张卡贴着滚动边界 */
  7140. blocks.push(U.spacer(8));
  7141. W.stack(inner, blocks, Theme.m.gapCard);
  7142. var scroller = W.scroller();
  7143. scroller.addView(inner);
  7144. return scroller;
  7145. };
  7146. /* ============================== src/page-nurture.js ======================
  7147. * 养号页(设计稿 3:469)。
  7148. *
  7149. * 结构:脚本选择(1 张卡)→ 养号参数卡 → 底部提示(左对齐)。
  7150. *
  7151. * 概率仍是固定档位(下拉),浏览时长与单次操作则是区间:
  7152. * 养号动作的强度直接决定账号安全,概率给几个安全档位比让用户随手填 90% 更负责;
  7153. * 而时长与次数天然有「最少/最多」两个端点,用区间表达最贴切。
  7154. */
  7155. Pages.NURTURE_SCRIPTS = [
  7156. { key: "s1", name: "\u968F\u673A\u64CD\u4F5C\u517B\u53F7", engine: "tiktok-auto" }
  7157. ];
  7158. Pages.registerScripts("nurture", Pages.NURTURE_SCRIPTS);
  7159. /** 养号参数卡(设计稿 16:2)。 */
  7160. function buildNurtureParams() {
  7161. var card = W.card(false);
  7162. var base = "tabs.nurture.";
  7163. var rows = [
  7164. W.rangeRow(base + "browseMin", base + "browseMax", "\u6D4F\u89C8\u65F6\u957F\uFF08\u5206\u949F\uFF09", "~").root,
  7165. W.settingRow("\u70B9\u8D5E\u6982\u7387",
  7166. W.dropdown("likeRate", base + "likeRate").root, 8),
  7167. /* ★ 评论概率:当前做不到,所以**不做成下拉**。
  7168. *
  7169. * 首页信息流的评论面板里,那个"发送"按钮不在无障碍树里
  7170. * (把输入框整行右侧节点全 dump 过,只有动作栏那六个和评论自己的赞/表情)。
  7171. * 而引擎是子脚本,gesture 无效、shell 没有注入权限 —— 点不到就发不出去。
  7172. *
  7173. * 一个点不动的下拉,用户会以为"选了 10% 就会评论",比直接说"不支持"更糟。 */
  7174. W.settingRow("\u8BC4\u8BBA\u6982\u7387",
  7175. U.text("\u5F53\u524D\u7248\u672C\u4E0D\u652F\u6301", Theme.m.hintSize,
  7176. Theme.hex.textFaint, false), 8),
  7177. W.settingRow("\u5173\u6CE8\u6982\u7387",
  7178. W.dropdown("followRate", base + "followRate").root, 8),
  7179. W.rangeRow(base + "actionMin", base + "actionMax", "\u5355\u6B21\u64CD\u4F5C", "~").root
  7180. ];
  7181. W.stack(card, rows, 12);
  7182. return card;
  7183. }
  7184. Pages.nurture = function (rerender) {
  7185. var inner = U.column();
  7186. var blocks = [];
  7187. blocks.push(W.sectionTitle("\u811A\u672C\u9009\u62E9"));
  7188. for (var i = 0; i < Pages.NURTURE_SCRIPTS.length; i++) {
  7189. var script = Pages.NURTURE_SCRIPTS[i];
  7190. var base = "tabs.nurture.scripts." + script.key + ".";
  7191. var checked = Store.getBool(base + "checked");
  7192. var expanded = Store.getBool(base + "expanded");
  7193. var card = W.card(checked);
  7194. card.addView(W.scriptHeader({
  7195. checked: checked,
  7196. expanded: expanded,
  7197. name: script.name,
  7198. summary: null,
  7199. disabled: Session.running,
  7200. onToggleCheck: function (next) {
  7201. Store.set(base + "checked", next);
  7202. rerender();
  7203. },
  7204. onToggleExpand: function () {
  7205. Store.set(base + "expanded", !expanded);
  7206. rerender();
  7207. }
  7208. }).root);
  7209. blocks.push(card);
  7210. }
  7211. blocks.push(U.spacer(4));
  7212. blocks.push(W.sectionTitle("\u517B\u53F7\u53C2\u6570"));
  7213. blocks.push(buildNurtureParams());
  7214. blocks.push(U.spacer(8));
  7215. /* 设计稿 3:506:这行是左对齐的,不像其它页居中。
  7216. *
  7217. * 文案要交代两件规格书里没写清的事(不写用户就会猜错):
  7218. * ① 「单次操作」是**轮数**,不是间隔秒数 —— 与「浏览时长」各自独立,谁先到谁停
  7219. * ② 不含评论(那个当前做不到,见上面评论概率那行的注释) */
  7220. blocks.push(W.hint("\u968F\u673A\u6D4F\u89C8\u3001\u70B9\u8D5E\u3001\u5173\u6CE8\u6A21\u62DF\u771F\u5B9E\u6D3B\u8DC3\u5EA6\uFF1B"
  7221. + "\u300C\u5355\u6B21\u64CD\u4F5C\u300D\u662F\u8F6E\u6570\uFF0C\u4E0E\u6D4F\u89C8\u65F6\u957F\u8C01\u5148\u5230\u8C01\u505C", false));
  7222. W.stack(inner, blocks, Theme.m.gapPage);
  7223. var scroller = W.scroller();
  7224. scroller.addView(inner);
  7225. return scroller;
  7226. };
  7227. /* ============================== src/page-video.js ============================== */
  7228. /* ============================== src/page-video.js ==============================
  7229. * 视频功能页(设计稿 3:629)。
  7230. *
  7231. * 结构:脚本选择(1 张卡)→ 功能设置 → 私信 / 评论设置。
  7232. *
  7233. * 与 UID私信页的差别在「执行操作」这一行:它是一行多选(关注/私信/点赞/评论),
  7234. * 所以用的是 W.toggleRow 而不是 W.toggle。
  7235. */
  7236. var Pages = Pages || {};
  7237. /**
  7238. * 视频功能页的脚本。
  7239. *
  7240. * `engine` 指名由哪个执行引擎驱动 —— 目前只有这一页接了 tiktok-auto。
  7241. * 其它页的脚本没有这个字段,点「开始运行」会被拦下并说明原因
  7242. * (见 App.toggleRun 与 Dialog.engineMissing),而不是静默跑一个不相关的东西。
  7243. */
  7244. Pages.VIDEO_SCRIPTS = [
  7245. { key: "s1", name: "\u5173\u952E\u8BCD\u89C6\u9891\u529F\u80FD", engine: "tiktok-auto" }
  7246. ];
  7247. Pages.registerScripts("video", Pages.VIDEO_SCRIPTS);
  7248. /** 「执行操作」四项的定义。顺序即设计稿从左到右的顺序。 */
  7249. Pages.VIDEO_ACTIONS = [
  7250. { key: "follow", label: "\u5173\u6CE8" },
  7251. { key: "dm", label: "\u79C1\u4FE1" },
  7252. { key: "like", label: "\u70B9\u8D5E" },
  7253. { key: "comment", label: "\u8BC4\u8BBA" }
  7254. ];
  7255. function buildVideoScriptCard(rerender) {
  7256. var base = "tabs.video.scripts.s1.";
  7257. var checked = Store.getBool(base + "checked");
  7258. var expanded = Store.getBool(base + "expanded");
  7259. var card = W.card(checked);
  7260. card.addView(W.scriptHeader({
  7261. checked: checked,
  7262. expanded: expanded,
  7263. name: Pages.VIDEO_SCRIPTS[0].name,
  7264. summary: null,
  7265. disabled: Session.running,
  7266. onToggleCheck: function (next) {
  7267. Store.set(base + "checked", next);
  7268. rerender();
  7269. },
  7270. onToggleExpand: function () {
  7271. Store.set(base + "expanded", !expanded);
  7272. rerender();
  7273. }
  7274. }).root);
  7275. return card;
  7276. }
  7277. /** 功能设置卡(设计稿 3:667):搜索条件 + 节奏 + 动作开关。 */
  7278. function buildVideoSettings() {
  7279. var card = W.card(false);
  7280. var rows = [];
  7281. rows.push(W.settingRow("\u5173\u952E\u8BCD",
  7282. W.textField("tabs.video.keyword", "\u641C\u7D22", 100).root, 6));
  7283. rows.push(W.settingRow("\u4E3B\u64AD\u533A\u57DF",
  7284. W.textField("tabs.video.region", "\u56FD\u5BB6\u7B80\u7801", 100).root, 6));
  7285. /* 停止个数与间隔时间都是区间:一行两项,两个数各自可改 */
  7286. rows.push(W.rangeRow("tabs.video.stopMin", "tabs.video.stopMax",
  7287. "\u505C\u6B62\u4E2A\u6570", "~").root);
  7288. rows.push(W.rangeRow("tabs.video.intervalMin", "tabs.video.intervalMax",
  7289. "\u95F4\u9694\u65F6\u95F4\uFF08\u79D2\uFF09", "~").root);
  7290. /* 动作开关:四个独立,改了立即落盘 */
  7291. var actionItems = [];
  7292. for (var i = 0; i < Pages.VIDEO_ACTIONS.length; i++) {
  7293. var action = Pages.VIDEO_ACTIONS[i];
  7294. actionItems.push({
  7295. key: action.key,
  7296. label: action.label,
  7297. on: Store.getBool("tabs.video.actions." + action.key)
  7298. });
  7299. }
  7300. rows.push(W.settingRow("\u6267\u884C\u64CD\u4F5C",
  7301. W.toggleRow(actionItems, function (key, on) {
  7302. Store.set("tabs.video.actions." + key, on);
  7303. }).root, 6));
  7304. W.stack(card, rows, 12);
  7305. return card;
  7306. }
  7307. /** 文本设置卡:私信与评论内容文件。 */
  7308. function buildVideoTexts() {
  7309. var card = W.card(false);
  7310. var rows = [
  7311. W.filePicker("随机私信内容 1-3", "tabs.video.dmTextPath").root,
  7312. W.filePicker("随机评论内容 1-3", "tabs.video.commentTextPath").root
  7313. ];
  7314. W.stack(card, rows, 12);
  7315. return card;
  7316. }
  7317. Pages.video = function (rerender) {
  7318. var inner = U.column();
  7319. var blocks = [];
  7320. blocks.push(W.sectionTitle("\u811A\u672C\u9009\u62E9"));
  7321. blocks.push(buildVideoScriptCard(rerender));
  7322. blocks.push(U.spacer(4));
  7323. blocks.push(W.sectionTitle("\u529F\u80FD\u8BBE\u7F6E"));
  7324. blocks.push(buildVideoSettings());
  7325. blocks.push(U.spacer(4));
  7326. blocks.push(W.sectionTitle("\u79C1\u4FE1 / \u8BC4\u8BBA\u8BBE\u7F6E"));
  7327. blocks.push(buildVideoTexts());
  7328. /* 内容超出屏幕,末尾留余量避免最后一张卡贴着滚动边界 */
  7329. blocks.push(U.spacer(8));
  7330. W.stack(inner, blocks, Theme.m.gapCard);
  7331. var scroller = W.scroller();
  7332. scroller.addView(inner);
  7333. return scroller;
  7334. };
  7335. /* ============================== src/page-tasks.js ============================== */
  7336. /* ============================== src/page-tasks.js ==============================
  7337. * 任务列表(规格书 B1 / B2,设计稿 30:102 / 30:203)。
  7338. *
  7339. * ## 这一页是整个改版的核心
  7340. * 底栏从「5 个页签」变成「3 个 tab」之后,原来的 ID提取 / UID私信 / 养号 / 视频功能
  7341. * **降级为这里的四张任务卡**,各自的页面成了二级页(见 Pages.DETAILS)。
  7342. * 所以这一页不只是"多了一个列表":它是新的入口层级。
  7343. *
  7344. * ## 任务清单与「脚本清单」是两张表
  7345. * · `Pages.TASKS` —— 四张卡(名称 / 图标 / 颜色),**顺序即设计稿顺序**;
  7346. * · `Pages.SCRIPTS_OF`—— 每个任务下的脚本(各页自己注册,见 page-id/page-dm/page-video)。
  7347. * 任务卡要显示「当前:xxx」,就得把两张表拼起来 —— 拼接逻辑集中在
  7348. * `Pages.checkedScript()`,`App.currentTask()` 也复用它,避免两处各判一遍单选。
  7349. */
  7350. var Pages = Pages || {};
  7351. /**
  7352. * 四个任务。key 与旧的页签 key、以及配置路径 `tabs.<key>.*` **完全一致** ——
  7353. * 这不是巧合:老用户的配置一条都不用搬家(见 Store.MIGRATIONS[5])。
  7354. *
  7355. * 颜色取设计稿任务卡图标框的衬底色(14% 透明底 + 同色字形):
  7356. * ID提取 #2EA6FF / UID私信 #9482FF / 养号 #12DEC4 / 视频功能 #F5C24A
  7357. * 图标一律用 U+25xx 几何块字符(彩色 emoji 的坑见 W.TABS 注释)。
  7358. */
  7359. Pages.TASKS = [
  7360. { key: "id", name: "ID\u63D0\u53D6", icon: "\u25A4", colorHex: Theme.hex.accent },
  7361. { key: "dm", name: "UID\u79C1\u4FE1", icon: "\u25A5", colorHex: Theme.hex.violet },
  7362. { key: "nurture", name: "\u517B\u53F7", icon: "\u25D4", colorHex: Theme.hex.ok },
  7363. { key: "video", name: "\u89C6\u9891\u529F\u80FD", icon: "\u25B6", colorHex: Theme.hex.warn }
  7364. ];
  7365. /** 二级页的标题(返回头上显示的就是它)。 */
  7366. Pages.DETAIL_TITLES = {
  7367. id: "ID\u63D0\u53D6",
  7368. dm: "UID\u79C1\u4FE1",
  7369. nurture: "\u517B\u53F7",
  7370. video: "\u89C6\u9891\u529F\u80FD"
  7371. };
  7372. Pages.taskOf = function (key) {
  7373. for (var i = 0; i < Pages.TASKS.length; i++) {
  7374. if (Pages.TASKS[i].key === key) { return Pages.TASKS[i]; }
  7375. }
  7376. return null;
  7377. };
  7378. Pages.taskName = function (key) {
  7379. var task = Pages.taskOf(key);
  7380. return task ? task.name : String(key || "");
  7381. };
  7382. /**
  7383. * 某个任务**当前选中的脚本**(没选返回 null)。
  7384. *
  7385. * 单选规则由各页自己保证(ID提取页是 `Pages.toggleIdScript`),
  7386. * 这里只做"第一个勾上的就是它"的读取 —— 与 `App.currentTask()` 的旧逻辑一致,
  7387. * 但接受任意任务 key,而不是只认当前 tab。
  7388. */
  7389. Pages.checkedScript = function (taskKey) {
  7390. /* ★ v8:ID提取没有"脚本"这个概念了 —— 它的配置是**取材清单**。
  7391. * 所以"已配置" = 清单非空,而不是"某张卡的 checked 为真"(那些字段已删除)。
  7392. * 两个名字分开给:
  7393. * · `name` 给拦截弹窗用(「ID提取的执行引擎尚未接入」比塞一整段摘要更像人话);
  7394. * · `detail` 给任务卡的「当前:…」用(那里正需要"从哪儿取材"这个信息)。 */
  7395. if (taskKey === "id") {
  7396. if (Sources.isEmpty()) { return null; }
  7397. /* 引擎声明仍然从 SCRIPTS_OF 读:将来 ID提取 接入执行引擎时,
  7398. * 在 `Pages.ID_SCRIPTS` 上加一个 `engine` 字段就够了,这里不用改。 */
  7399. var idEntry = (Pages.SCRIPTS_OF["id"] || [])[0] || {};
  7400. return {
  7401. key: "id",
  7402. name: Pages.taskName("id"),
  7403. detail: Sources.summary(),
  7404. engine: idEntry.engine || "",
  7405. task: "id",
  7406. taskName: Pages.taskName("id")
  7407. };
  7408. }
  7409. var list = Pages.SCRIPTS_OF[taskKey] || [];
  7410. for (var i = 0; i < list.length; i++) {
  7411. var entry = Store.getBool("tabs." + taskKey + ".scripts." + list[i].key + ".checked");
  7412. if (entry) {
  7413. return {
  7414. key: list[i].key,
  7415. name: list[i].name,
  7416. detail: list[i].name,
  7417. engine: list[i].engine || "",
  7418. task: taskKey,
  7419. taskName: Pages.taskName(taskKey)
  7420. };
  7421. }
  7422. }
  7423. return null;
  7424. };
  7425. /** 该任务是否正在被驱动。运行态是全局单跑,所以只要 Session.tab 对上就是它。 */
  7426. Pages.taskIsRunning = function (taskKey) {
  7427. return !!Session.running && Session.tab === taskKey;
  7428. };
  7429. /**
  7430. * 任务卡的状态徽标(设计稿:空闲 灰 / 运行中 绿 / 未配置 琥珀)。
  7431. *
  7432. * 「未配置」= 这个任务当前没有任何脚本被选中。ID提取页默认一个都不选
  7433. * (设计稿 16:23 专门画了"未选择脚本"那一屏),所以它一进来就是琥珀色 ——
  7434. * 这正是要给用户的信号:想跑先选一个。
  7435. */
  7436. Pages.taskState = function (taskKey) {
  7437. if (Pages.taskIsRunning(taskKey)) {
  7438. return { badge: "\u8FD0\u884C\u4E2D", tone: "ok" };
  7439. }
  7440. if (!Pages.checkedScript(taskKey)) {
  7441. return { badge: "\u672A\u914D\u7F6E", tone: "warn" };
  7442. }
  7443. return { badge: "\u7A7A\u95F2", tone: "mute" };
  7444. };
  7445. /** 任务卡的「当前:xxx」一行。 */
  7446. Pages.taskScriptLine = function (taskKey) {
  7447. var script = Pages.checkedScript(taskKey);
  7448. if (!script) {
  7449. /* v8:ID提取没有"脚本"了,它的空态是"还没添加取材" */
  7450. return "\u5F53\u524D\uFF1A" + (taskKey === "id" ? "\u672A\u6DFB\u52A0\u53D6\u6750" : "\u672A\u9009\u62E9\u811A\u672C");
  7451. }
  7452. /* detail 才是"从哪儿取材"(ID提取用清单摘要,其它任务与 name 相同) */
  7453. return "\u5F53\u524D\uFF1A" + (script.detail || script.name);
  7454. };
  7455. /* ------------------------------------------------------------ 任务卡 */
  7456. function buildTaskCard(task) {
  7457. var state = Pages.taskState(task.key);
  7458. var running = Pages.taskIsRunning(task.key);
  7459. return W.taskCard({
  7460. name: task.name,
  7461. iconChar: task.icon,
  7462. colorHex: task.colorHex,
  7463. badge: state.badge,
  7464. badgeTone: state.tone,
  7465. scriptLine: Pages.taskScriptLine(task.key),
  7466. lastLine: Records.lastRunLine(task.key),
  7467. running: running,
  7468. /* 点卡片主体 → 进该任务的详情页 */
  7469. onOpen: function () {
  7470. App.openDetail(task.key);
  7471. },
  7472. /* 点「运行」→ 直接跑这个任务当前选中的脚本(不必先切页签再点底部按钮)。
  7473. * 运行中的那一个按钮是「停止」:它停的是**全局运行**,
  7474. * 点别的任务的「运行」也不会被禁用 —— 由 App.requestRun 弹 F3 确认(规格书 §2.3)。 */
  7475. onRun: function () {
  7476. if (running) {
  7477. App.stopRun();
  7478. App.renderScreen();
  7479. return;
  7480. }
  7481. App.requestRun(task.key);
  7482. }
  7483. }).root;
  7484. }
  7485. /* ------------------------------------------------------------ 页面 */
  7486. /** B1/B2:任务列表(运行态只是同一个页面的另一种状态)。
  7487. *
  7488. * ⚠️ 这里**不画**页面标题区:标题区由路由层统一加(App.renderContent 的
  7489. * 「一级 tab」分支),页面自己再加一次就会渲染出两遍。
  7490. * 页面只负责"这一 tab 的内容"。
  7491. */
  7492. Pages.tasks = function () {
  7493. var inner = U.column();
  7494. var blocks = [];
  7495. var section = U.column();
  7496. section.addView(W.sectionTitle("\u4EFB\u52A1"));
  7497. section.addView(U.spacer(4));
  7498. /* 区说明是刻意保留的:全局单跑是这一页最需要被知道的一条规则 ——
  7499. * 四张卡上都有「运行」按钮,不写清楚没人知道它们互斥。 */
  7500. section.addView(U.text("\u5168\u5C40\u5355\u8DD1\uFF1A\u540C\u4E00\u65F6\u95F4\u4EC5\u53EF\u8FD0\u884C\u4E00\u4E2A\u811A\u672C",
  7501. Theme.m.hintSize, Theme.hex.textDim, false));
  7502. blocks.push(section);
  7503. blocks.push(U.spacer(Theme.m.gapCard));
  7504. for (var i = 0; i < Pages.TASKS.length; i++) {
  7505. blocks.push(buildTaskCard(Pages.TASKS[i]));
  7506. if (i < Pages.TASKS.length - 1) { blocks.push(U.spacer(Theme.m.gapCard)); }
  7507. }
  7508. /* 末尾余量:最后一张卡贴着底栏时会被渐隐压住 */
  7509. blocks.push(U.spacer(8));
  7510. W.stack(inner, blocks, 0);
  7511. var scroller = W.scroller();
  7512. scroller.addView(inner);
  7513. return scroller;
  7514. };
  7515. /* ============================== src/page-records.js ============================== */
  7516. /* ============================== src/page-records.js ==============================
  7517. * 运行记录(规格书 D1/D2/D3,设计稿 30:871 / 30:958 / 30:1241)。
  7518. *
  7519. * D1 是「记录」tab 的一级页,D3 是它的二级页(点任一条进)。
  7520. * D2 是 D1 的空态 —— **空态是设计稿专门画的一屏**,不是"没数据就不显示":
  7521. * 一个空页面看起来像坏了,空态告诉用户"去哪能产生记录"。
  7522. *
  7523. * 记录本身由 `Records` 模块负责读写(见 src/records.js),
  7524. * 这一层只负责画:不猜字段、不编文案 —— 没有的东西就不渲染那一行。
  7525. */
  7526. var Pages = Pages || {};
  7527. /** 「脚本一 · 粉丝页提 ID」→「脚本一」。卡上只放得下短名,全名在详情页。 */
  7528. Pages.shortScriptName = function (name) {
  7529. var text = String(name || "");
  7530. var cut = text.indexOf(" \u00B7 ");
  7531. return cut > 0 ? text.substring(0, cut) : text;
  7532. };
  7533. /** 记录条目 / 详情页统一用「任务 · 脚本」当标题。 */
  7534. Pages.recordTitle = function (record) {
  7535. var task = record.taskName || Pages.taskName(record.taskKey);
  7536. var script = Pages.shortScriptName(record.scriptName);
  7537. return script === "" ? task : (task + " \u00B7 " + script);
  7538. };
  7539. /** 记录用的任务色/图标(与任务卡一致,列表里一眼能对上号)。 */
  7540. function recordVisual(record) {
  7541. var task = Pages.taskOf(record.taskKey);
  7542. return {
  7543. colorHex: task ? task.colorHex : Theme.hex.accent,
  7544. iconChar: task ? task.icon : "\u25A3"
  7545. };
  7546. }
  7547. /* ------------------------------------------------------------ 日志行 */
  7548. /**
  7549. * 日志行的配色(规格书:INFO 灰 / WARN 琥珀 / ERROR 玫瑰红)。
  7550. * STEP 归到 INFO:它是流程节点,不是异常。
  7551. */
  7552. function logColor(level) {
  7553. if (level === "WARN") { return Theme.hex.warn; }
  7554. if (level === "ERROR") { return Theme.hex.danger; }
  7555. return Theme.hex.textFaint;
  7556. }
  7557. function pad2(n) { return n < 10 ? "0" + n : String(n); }
  7558. /** 时间戳 → HH:MM:SS(日志行前缀,等宽显示才对齐)。 */
  7559. function logClock(ms) {
  7560. var d = new Date(ms || 0);
  7561. return pad2(d.getHours()) + ":" + pad2(d.getMinutes()) + ":" + pad2(d.getSeconds());
  7562. }
  7563. /**
  7564. * 一条日志行。
  7565. *
  7566. * 用**等宽字体**并且手动补空格对齐 level:设计稿里 `10:24:01 INFO 启动脚本`
  7567. * 的 level 是定宽的,用比例字体会让每一行的正文起点参差不齐。
  7568. */
  7569. function buildLogLine(entry) {
  7570. var raw = String(entry.level || "INFO").toUpperCase();
  7571. var level = raw;
  7572. while (level.length < 5) { level += " "; }
  7573. var text = logClock(entry.t) + " " + level + " " + String(entry.text || "");
  7574. var view = U.monoText(text, Theme.m.logSize, logColor(raw));
  7575. U.params(view, -1, -2, 0);
  7576. return view;
  7577. }
  7578. /* ------------------------------------------------------------ D1 记录列表 */
  7579. function buildRecordRow(record, snap) {
  7580. var visual = recordVisual(record);
  7581. var result = Records.RESULT[record.result] || Records.RESULT.failed;
  7582. var resumable = Resume.isRecordResumable(record, snap);
  7583. return W.recordRow({
  7584. title: Pages.recordTitle(record),
  7585. iconChar: visual.iconChar,
  7586. colorHex: visual.colorHex,
  7587. resultLabel: result.label,
  7588. resultTone: result.tone,
  7589. timeLine: Records.timeLine(record),
  7590. resultLine: Records.resultLine(record),
  7591. /* 只有"这条记录就是那个没跑完的断点"时才给「继续」。
  7592. * 判据在 Resume.isRecordResumable 里(含 runId 精确匹配)——
  7593. * 否则一串历史「手动停止」记录上都会冒出「继续」,而只有最后一条能真续。 */
  7594. actionLabel: resumable ? "\u7EE7\u7EED" : "",
  7595. actionTone: "accent",
  7596. onAction: function () { App.resumeRun(record); },
  7597. onOpen: function () { App.openRecord(record.id); }
  7598. });
  7599. }
  7600. /** D1:记录列表(有记录时)。 */
  7601. Pages.records = function () {
  7602. var inner = U.column();
  7603. var blocks = [];
  7604. var list = Records.list();
  7605. /* 断点只读一次:50 条记录各读一次盘是没必要的浪费 */
  7606. var snap = Resume.snapshot();
  7607. /* 标题区由路由层统一加(见 App.renderContent)——页面再加一次就会渲染两遍 */
  7608. var section = U.column();
  7609. section.addView(W.sectionTitle("\u8FD0\u884C\u8BB0\u5F55"));
  7610. section.addView(U.spacer(4));
  7611. /* 「共 N 条 · 点任一条查看执行日志」——条数是真实的,空列表走 D2 那一屏 */
  7612. section.addView(U.text("\u5171 " + list.length + " \u6761 \u00B7 \u70B9\u4EFB\u4E00\u6761\u67E5\u770B\u6267\u884C\u65E5\u5FD7",
  7613. Theme.m.hintSize, Theme.hex.textDim, false));
  7614. blocks.push(section);
  7615. blocks.push(U.spacer(Theme.m.gapCard));
  7616. for (var i = 0; i < list.length; i++) {
  7617. blocks.push(buildRecordRow(list[i], snap));
  7618. if (i < list.length - 1) { blocks.push(U.spacer(Theme.m.gapCard)); }
  7619. }
  7620. blocks.push(U.spacer(8));
  7621. W.stack(inner, blocks, 0);
  7622. var scroller = W.scroller();
  7623. scroller.addView(inner);
  7624. return scroller;
  7625. };
  7626. /** D2:记录空态。 */
  7627. Pages.recordsEmpty = function () {
  7628. var inner = U.column();
  7629. /* 标题区同样由路由层提供;空态只剩「区标题 + 空态块」 */
  7630. inner.addView(W.sectionTitle("\u8FD0\u884C\u8BB0\u5F55"));
  7631. inner.addView(W.emptyState("\u25F4",
  7632. "\u8FD8\u6CA1\u6709\u8FD0\u884C\u8BB0\u5F55",
  7633. "\u53BB\u300C\u4EFB\u52A1\u300D\u6807\u7B7E\u9009\u4E00\u4E2A\u811A\u672C\u8DD1\u8D77\u6765"));
  7634. return inner;
  7635. };
  7636. /* ------------------------------------------------------------ D3 运行详情 */
  7637. /**
  7638. * 结果摘要卡。
  7639. * 三行都是**有条件渲染**的:结果文案/结果文件只有引擎回传了才有,
  7640. * 没有就不画那一行 —— 空着的一行会让用户以为"数据丢了"。
  7641. */
  7642. function buildSummaryCard(record) {
  7643. var card = W.card(false);
  7644. var rows = [];
  7645. var result = Records.RESULT[record.result] || Records.RESULT.failed;
  7646. var titleRow = U.row();
  7647. var title = U.text(Pages.recordTitle(record), Theme.m.taskNameSize,
  7648. Theme.hex.textPrimary, true);
  7649. title.setSingleLine(true);
  7650. title.setEllipsize(android.text.TextUtils.TruncateAt.END);
  7651. U.params(title, 0, -2, 1);
  7652. titleRow.addView(title);
  7653. titleRow.addView(U.hgap(8));
  7654. titleRow.addView(W.badge(result.label, result.tone).root);
  7655. rows.push(titleRow);
  7656. rows.push(U.text(Records.timeLine(record), Theme.m.chipSize, Theme.hex.textFaint, false));
  7657. if (record.resultText) {
  7658. rows.push(U.text(record.resultText, Theme.m.chipSize, Theme.hex.textDim, false));
  7659. }
  7660. if (record.resultFile) {
  7661. pushFileRows(rows, "\u7ED3\u679C\u6587\u4EF6", record.resultFile);
  7662. }
  7663. if (record.mapFile) {
  7664. pushFileRows(rows, "uid \u6620\u5C04\uFF08username\u2192\u6570\u5B57 uid\uFF09", record.mapFile);
  7665. }
  7666. W.stack(card, rows, 10);
  7667. return card;
  7668. }
  7669. /**
  7670. * 一份产物文件:说明行 + 路径行(点路径打开)+ 打开/分享两个按钮。
  7671. *
  7672. * 抽成函数是因为现在有两份产物(结果文件 + uid 映射文件),
  7673. * 各自都要能打开和分享 —— 光在界面上写一句「已生成 uid 映射」而取不到文件,
  7674. * 等于让用户自己去翻 /sdcard。
  7675. */
  7676. function pushFileRows(rows, caption, path) {
  7677. rows.push(U.text(caption, Theme.m.chipSize, Theme.hex.textFaint, false));
  7678. var fileRow = U.row();
  7679. var pathText = U.text(path, Theme.m.chipSize, Theme.hex.accentText, false);
  7680. pathText.setSingleLine(true);
  7681. pathText.setEllipsize(android.text.TextUtils.TruncateAt.MIDDLE);
  7682. U.params(pathText, 0, -2, 1);
  7683. fileRow.addView(pathText);
  7684. fileRow.addView(U.hgap(8));
  7685. fileRow.addView(W.smallButton("\u6253\u5F00", "accent", function () {
  7686. App.openResultFile(path);
  7687. }).root);
  7688. fileRow.addView(U.hgap(6));
  7689. fileRow.addView(W.smallButton("\u5206\u4EAB", "default", function () {
  7690. App.shareResultFile(path);
  7691. }).root);
  7692. rows.push(fileRow);
  7693. }
  7694. /** 日志卡:标题行(执行日志 + 复制)+ 日志行。 */
  7695. function buildLogCard(record) {
  7696. var card = W.card(false);
  7697. var logs = record.logs || [];
  7698. var head = U.row();
  7699. head.addView(U.text("\u6267\u884C\u65E5\u5FD7", Theme.m.labelSize, Theme.hex.textSecondary, true));
  7700. head.addView(U.flexible());
  7701. head.addView(W.smallButton("\u590D\u5236", "default", function () {
  7702. App.copyLogs(record);
  7703. }).root);
  7704. card.addView(head);
  7705. card.addView(U.spacer(10));
  7706. if (logs.length === 0) {
  7707. /* 如实说明,而不是画一张空卡片。日志来自引擎的 --log 通道(见 Engine.readLog),
  7708. * 没接引擎的任务本来就不会有日志。 */
  7709. card.addView(U.text("\u672C\u6B21\u8FD0\u884C\u6CA1\u6709\u56DE\u4F20\u65E5\u5FD7",
  7710. Theme.m.chipSize, Theme.hex.textDim, false));
  7711. return card;
  7712. }
  7713. for (var i = 0; i < logs.length; i++) {
  7714. if (i > 0) { card.addView(U.spacer(6)); }
  7715. card.addView(buildLogLine(logs[i]));
  7716. }
  7717. return card;
  7718. }
  7719. /**
  7720. * D3:运行详情。
  7721. * 记录被清理掉(超过 Records.MAX 或被手工清空)时,**不能白屏** ——
  7722. * 给一句"这条记录已不在本地"和一条回到列表的路。
  7723. */
  7724. Pages.recordDetail = function (record) {
  7725. var inner = U.column();
  7726. inner.addView(W.backHeader("\u8FD0\u884C\u8BE6\u60C5", function () { App.closeRecord(); }));
  7727. if (!record) {
  7728. inner.addView(U.spacer(Theme.m.gapPage));
  7729. inner.addView(W.emptyState("\u25F4",
  7730. "\u8FD9\u6761\u8BB0\u5F55\u5DF2\u4E0D\u5728\u672C\u5730",
  7731. "\u8BB0\u5F55\u4EC5\u4FDD\u7559\u6700\u8FD1 " + Records.MAX + " \u6761"));
  7732. var scroller0 = W.scroller();
  7733. scroller0.addView(inner);
  7734. return scroller0;
  7735. }
  7736. inner.addView(U.spacer(Theme.m.gapCard));
  7737. /* 这条记录还能接着跑 → 在看日志的地方也放一个入口(列表行那个是"一眼可见",
  7738. * 这个是"看完日志顺手继续")。判据与列表行同一份。 */
  7739. var snap = Resume.snapshot();
  7740. if (Resume.isRecordResumable(record, snap)) {
  7741. inner.addView(W.noticeBar({
  7742. title: "\u8FD9\u6B21\u8FD0\u884C\u6CA1\u8DD1\u5B8C\uFF08\u5DF2\u6536 " + snap.extracted + " \u4E2A\uFF09",
  7743. detail: Resume.detail(snap),
  7744. tone: "accent",
  7745. actions: [{
  7746. label: "\u7EE7\u7EED",
  7747. tone: "accent",
  7748. onClick: function () { App.resumeRun(record); }
  7749. }]
  7750. }).root);
  7751. inner.addView(U.spacer(Theme.m.gapCard));
  7752. }
  7753. inner.addView(buildSummaryCard(record));
  7754. inner.addView(U.spacer(Theme.m.gapCard));
  7755. inner.addView(buildLogCard(record));
  7756. inner.addView(U.spacer(8));
  7757. var scroller = W.scroller();
  7758. scroller.addView(inner);
  7759. return scroller;
  7760. };
  7761. /* ============================== src/page-login.js ============================== */
  7762. /* ============================== src/page-login.js ==============================
  7763. * 卡密登录页(设计稿 16:257 登录 / 16:288 校验失败 / 16:1491 校验中)。
  7764. *
  7765. * 它不是某个页签,而是**未激活时的整个界面**:
  7766. * 没有页签栏、没有开始运行按钮,唯一的出口是登录成功或退出应用。
  7767. * 所以由 main.js 的 screen 路由决定显示它还是显示主界面。
  7768. *
  7769. * 三态共用一个构建函数,靠 Pages.LICENSE_VIEW.state 分叉:
  7770. * idle 输入框灰底、按钮「登录激活」、提示「卡密为一次性激活凭证…」
  7771. * verifying 按钮半透明 + 「登录中…」、提示换成「正在校验卡密,请稍候…」
  7772. * failed 输入框红边、下方红字「卡密无效或已过期,请检查后重试」
  7773. * 三态的差异全部落在「样式」上,结构完全一致 —— 拆成三个函数会立刻产生三份漂移。
  7774. *
  7775. * 输入框与页面上的其它输入框是**同一个组件**(`W.inlineField`),
  7776. * 并且**同样绑配置路径**(`license.draftCode`):
  7777. * 手打 16 位卡密时被系统回收进程,重开还能接着填。
  7778. * 提交成功后草稿会被清掉,`license.code` 成为唯一副本。
  7779. */
  7780. var Pages = Pages || {};
  7781. /** 登录页的界面状态。三态与失败文案只活在内存里:重启后回到 idle 是对的。 */
  7782. Pages.LICENSE_VIEW = {
  7783. state: "idle", /* idle | verifying | failed */
  7784. message: ""
  7785. };
  7786. Pages.LICENSE_RESET = function () {
  7787. Pages.LICENSE_VIEW.state = "idle";
  7788. Pages.LICENSE_VIEW.message = "";
  7789. };
  7790. /* ------------------------------------------------------------ 子块 */
  7791. /** 品牌区:渐变 Logo + 应用名 + 副标题。 */
  7792. function buildBrandBlock() {
  7793. var logoText = U.text("\u25B6", 26, "#FFFFFF", true);
  7794. logoText.setGravity(android.view.Gravity.CENTER);
  7795. var logo = new android.widget.FrameLayout(context);
  7796. logo.setBackground(U.gradientBg(Theme.hex.accent, Theme.hex.accentDeep, Theme.m.loginLogoRadius));
  7797. U.params(logo, Theme.m.loginLogoSize, Theme.m.loginLogoSize);
  7798. logo.addView(logoText);
  7799. var brand = U.column(false);
  7800. brand.addView(logo);
  7801. brand.addView(U.spacer(12));
  7802. brand.addView(U.text("\u98DE\u821F\u811A\u672C", 28, Theme.hex.textPrimary, true));
  7803. brand.addView(U.spacer(6));
  7804. brand.addView(U.text("TikTok \u81EA\u52A8\u5316\u811A\u672C \u00B7 \u8BF7\u767B\u5F55\u540E\u4F7F\u7528",
  7805. 12, Theme.hex.textMuted, false));
  7806. return brand;
  7807. }
  7808. /**
  7809. * 卡密输入框。失败态换成红边 + 红底,其余与常态一致。
  7810. *
  7811. * ★ 绑定 `license.draftCode`:**没提交的半截输入也要落盘**。
  7812. * 之前的做法是「只在点登录时读一次、不进配置」,代价是手打 16 位卡密时
  7813. * 被系统回收进程就得从头再打一遍。
  7814. */
  7815. function buildLicenseInput(state) {
  7816. var input = W.inlineField({
  7817. path: "license.draftCode",
  7818. placeholder: "\u8BF7\u8F93\u5165\u5361\u5BC6",
  7819. fill: true
  7820. });
  7821. /* 卡密是分组的大写字母数字串,等宽字更容易逐位核对 */
  7822. input.root.setTypeface(android.graphics.Typeface.MONOSPACE);
  7823. input.root.setTextSize(14);
  7824. if (state === "failed") {
  7825. input.root.setBackground(U.strokeBg(0.06, 0.45, Theme.hex.danger, 10));
  7826. } else {
  7827. input.root.setBackground(U.strokeBg(0.05, 0.12, "#FFFFFF", 10));
  7828. }
  7829. return input;
  7830. }
  7831. /**
  7832. * 登录 / 校验中 按钮。
  7833. * 校验中时把渐变整体压到 45% 透明度,并在文字前加一个「◌」——
  7834. * 不加这个视觉差,用户会以为可以连点。
  7835. */
  7836. function buildSubmitButton(state) {
  7837. var verifying = state === "verifying";
  7838. var label = verifying ? "\u25CC \u767B\u5F55\u4E2D\u2026" : "\u767B\u5F55\u6FC0\u6D3B";
  7839. var text = U.text(label, 16, "#FFFFFF", true);
  7840. text.setGravity(android.view.Gravity.CENTER);
  7841. var button = new android.widget.FrameLayout(context);
  7842. /* 不用 W.primaryButton:它没有「禁用态」这个概念,而这里必须有 */
  7843. var bg = U.gradientBg(Theme.hex.accent, Theme.hex.accentDeep, Theme.m.pillRadius);
  7844. if (verifying) { bg.setAlpha(Math.round(255 * 0.45)); }
  7845. button.setBackground(bg);
  7846. U.params(button, -1, Theme.m.loginBtnHeight);
  7847. button.addView(text);
  7848. if (!verifying) {
  7849. button.setOnClickListener(function () {
  7850. App.submitLicense(inputCode());
  7851. });
  7852. }
  7853. return { root: button, label: text };
  7854. }
  7855. /* 取输入框里的原始串。登录页只有一个输入框,用页面级引用比层层回传更省事。 */
  7856. var licenseInputRef = null;
  7857. function inputCode() {
  7858. return licenseInputRef ? licenseInputRef.value() : "";
  7859. }
  7860. /* ------------------------------------------------------------ 页面 */
  7861. /**
  7862. * 登录页主体。
  7863. * 不接收 rerender —— 三态切换由 App 统一走 renderScreen(),
  7864. * 页面自己重绘会绕开「切屏」这条唯一的路径。
  7865. */
  7866. Pages.login = function () {
  7867. var view = Pages.LICENSE_VIEW;
  7868. var state = view.state;
  7869. var inner = U.column();
  7870. inner.setPadding(U.dp(20), U.dp(28), U.dp(20), U.dp(20));
  7871. inner.addView(buildBrandBlock());
  7872. inner.addView(U.spacer(28));
  7873. /* —— 卡密输入卡 —— */
  7874. var card = U.column();
  7875. card.setBackground(U.strokeBg(0.04, 0.08, "#FFFFFF", Theme.m.cardRadius));
  7876. card.setPadding(U.dp(16), U.dp(16), U.dp(16), U.dp(16));
  7877. var input = buildLicenseInput(state);
  7878. licenseInputRef = input;
  7879. card.addView(input.root);
  7880. card.addView(U.spacer(14));
  7881. card.addView(buildSubmitButton(state).root);
  7882. card.addView(U.spacer(14));
  7883. /* 提示位在三态里说三件不同的事,共用同一行位置 */
  7884. if (state === "failed") {
  7885. var err = U.text(view.message || "\u5361\u5BC6\u65E0\u6548\u6216\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u68C0\u67E5\u540E\u91CD\u8BD5",
  7886. 11, Theme.hex.danger, false);
  7887. err.setGravity(android.view.Gravity.CENTER);
  7888. card.addView(err);
  7889. } else if (state === "verifying") {
  7890. var waiting = U.text("\u6B63\u5728\u6821\u9A8C\u5361\u5BC6\uFF0C\u8BF7\u7A0D\u5019\u2026",
  7891. 11, Theme.hex.accentText, false);
  7892. waiting.setGravity(android.view.Gravity.CENTER);
  7893. card.addView(waiting);
  7894. } else {
  7895. var safe = U.text("\u5361\u5BC6\u4E3A\u4E00\u6B21\u6027\u6FC0\u6D3B\u51ED\u8BC1\uFF0C\u8BF7\u52FF\u6CC4\u9732\u7ED9\u4ED6\u4EBA",
  7896. 11, Theme.hex.textFaint, false);
  7897. safe.setGravity(android.view.Gravity.CENTER);
  7898. card.addView(safe);
  7899. }
  7900. card.addView(U.spacer(12));
  7901. card.addView(W.link("\u8D2D\u4E70\u5361\u5BC6 \u00B7 \u8054\u7CFB\u5BA2\u670D",
  7902. Theme.hex.accentText, function () { App.showPurchaseInfo(); }));
  7903. card.addView(U.spacer(4));
  7904. card.addView(W.link("\u9000\u51FA\u5E94\u7528", Theme.hex.textMuted, function () { App.exit(); }));
  7905. inner.addView(card);
  7906. var scroller = W.scroller();
  7907. scroller.addView(inner);
  7908. return scroller;
  7909. };
  7910. /* ============================== src/page-mine.js ============================== */
  7911. /* ============================== src/page-mine.js ==============================
  7912. * 我的页(设计稿 7:289)。
  7913. *
  7914. * 结构:卡密 → 应用 → 关于 三段。
  7915. * 与其它页签的差别:本页没有「开始运行」按钮,底部只有页签栏。
  7916. *
  7917. * 数据来源都是真实的:
  7918. * · 卡密状态/到期时间 → Service.licenseStatus()
  7919. * · TikTok 安装与版本 → Device.tiktok()
  7920. * · 脚本版本 → Service.SCRIPT_VERSION(构建时注入)
  7921. * · 检查更新 → Service.checkUpdate()
  7922. */
  7923. var Pages = Pages || {};
  7924. /** 卡密登录卡:已登录给脱敏卡密 + 解绑;未登录/已过期给「去登录」。 */
  7925. function buildLicenseCard(rerender) {
  7926. var card = W.card(false);
  7927. var rows = [];
  7928. var display = Service.licenseDisplay();
  7929. rows.push(W.settingRow("\u5361\u5BC6\u72B6\u6001",
  7930. W.pill(display.text, display.tone).root, 6));
  7931. rows.push(W.settingRow("\u5361\u5BC6\u5230\u671F\u65F6\u95F4",
  7932. monoChip(display.expireAt, display.expireAt === "\u2014").root, 6));
  7933. if (display.status === "active") {
  7934. /* 已登录:脱敏展示,只露前 4 后 4 —— 卡片码不该完整出现在截图里 */
  7935. var masked = Service.formatCode(Service.normalizeCode(Service.licenseStatus().code));
  7936. if (masked.length > 9) {
  7937. masked = masked.substring(0, 4) + "-****-****-" + masked.substring(masked.length - 4);
  7938. }
  7939. var codeView = U.text(masked, Theme.m.chipSize, Theme.hex.textSecondary, false);
  7940. codeView.setSingleLine(true);
  7941. codeView.setBackground(U.strokeBg(Theme.alpha.chipFill, Theme.alpha.chipStroke, "#FFFFFF", Theme.m.chipRadius));
  7942. codeView.setPadding(U.dp(6), U.dp(6), U.dp(6), U.dp(6));
  7943. /* 「解绑」而不是「退出登录」:这个动作的后果比退出更重
  7944. * (正在跑的脚本会被停掉),文案要与弹窗里的说法一致。 */
  7945. var unbind = W.chip("\u89E3\u7ED1", "danger");
  7946. unbind.root.setOnClickListener(function () {
  7947. Dialog.confirmUnbind(function () {
  7948. /* 顺序有意义:先停运行,再清卡密,最后切回登录页。
  7949. * 反过来的话,停运行那一刻界面已经不在主界面上了。 */
  7950. App.stopRun();
  7951. Service.logout();
  7952. toast("\u5DF2\u89E3\u7ED1\u5361\u5BC6");
  7953. App.showLogin();
  7954. });
  7955. });
  7956. var row = U.row();
  7957. row.addView(codeView);
  7958. row.addView(U.flexible());
  7959. row.addView(U.hgap(6));
  7960. row.addView(unbind.root);
  7961. rows.push(row);
  7962. } else {
  7963. /* 未登录 / 已过期:主界面里没有可做的事,引导回登录页 */
  7964. var go = W.chip("\u53BB\u767B\u5F55", "accent");
  7965. go.root.setOnClickListener(function () { App.showLogin(); });
  7966. rows.push(W.settingRow("\u5361\u5BC6", go.root, 6));
  7967. }
  7968. W.stack(card, rows, 12);
  7969. return card;
  7970. }
  7971. /** 等宽小胶囊,用于日期 / 版本号这类需要对齐阅读的值。 */
  7972. function monoChip(value, muted) {
  7973. var view = U.monoText(value, Theme.m.chipSize,
  7974. muted ? Theme.hex.textFaint : Theme.hex.textSecondary);
  7975. view.setSingleLine(true);
  7976. view.setBackground(U.strokeBg(Theme.alpha.chipFill, Theme.alpha.chipStroke, "#FFFFFF", Theme.m.chipRadius));
  7977. view.setPadding(U.dp(6), U.dp(6), U.dp(6), U.dp(6));
  7978. return { root: view, setText: function (v) { view.setText(String(v)); } };
  7979. }
  7980. /**
  7981. * 悬浮球开关卡(规格书 §10 的补充项)。
  7982. *
  7983. * 规格书 §10.3 里有「隐藏悬浮球」,但**没写隐藏之后怎么再显示** ——
  7984. * 没有恢复入口的话,用户点一次隐藏就再也找不回这个跨 App 唯一的控制入口了。
  7985. * 所以这里补一个开关,并且它是**双向**的:
  7986. * · 关掉 → 立刻关球 + 落盘 `ball.hidden`(下次启动也不会再冒出来);
  7987. * · 打开 → 立刻 `FB.ensure()`(当场就能看到球出现在屏幕上)。
  7988. *
  7989. * 权限没给时会如实说明球建不出来 —— 规格书 §10.9① 写着悬浮窗权限
  7990. * "唯一实际用途"就是这个球,所以这里必须讲清楚,而不是让用户对着开关纳闷。
  7991. */
  7992. function buildFloatBallCard() {
  7993. var card = W.card(false);
  7994. var rows = [];
  7995. var allowed = Device.overlayEnabled();
  7996. var shown = !Store.getBool("ball.hidden") && allowed;
  7997. var toggle = W.toggle(shown, function (on) {
  7998. if (on && !Device.overlayEnabled()) {
  7999. /* 没权限就别落一个"开了但看不见"的状态:直接引导去授权 */
  8000. toast("\u8BF7\u5148\u5F00\u542F\u60AC\u6D6E\u7A97\u6743\u9650");
  8001. Device.openPermissionSettings("overlay");
  8002. return;
  8003. }
  8004. FB.setHidden(!on);
  8005. toast(on ? "\u5DF2\u663E\u793A\u60AC\u6D6E\u7403" : "\u5DF2\u9690\u85CF\u60AC\u6D6E\u7403");
  8006. });
  8007. rows.push(W.settingRow("\u60AC\u6D6E\u7403", toggle.root, 6));
  8008. /* 权限缺失时补一行说明(否则用户会以为开关坏了) */
  8009. if (!allowed) {
  8010. rows.push(W.hint("\u9700\u5F00\u542F\u300C\u60AC\u6D6E\u7A97\u6743\u9650\u300D\u624D\u80FD\u663E\u793A\u60AC\u6D6E\u7403", false));
  8011. } else {
  8012. /* 球的位置是用户拖出来的,写清"可拖动"能省掉一轮困惑 */
  8013. rows.push(W.hint("\u53EF\u62D6\u52A8\u5230\u4EFB\u610F\u8FB9\u7F18\uFF0C\u677E\u624B\u81EA\u52A8\u5438\u9644\u5E76\u8BB0\u4F4F\u4F4D\u7F6E", false));
  8014. }
  8015. W.stack(card, rows, 10);
  8016. return card;
  8017. }
  8018. /** 应用下载卡:按 TikTok 的真实安装状态显示「未安装/下载」或「已安装版本/更新」。 */
  8019. function buildAppCard() {
  8020. var card = W.card(false);
  8021. var info = Device.tiktok();
  8022. var label = U.text("TikTok \u5BA2\u6237\u7AEF", Theme.m.labelSize, Theme.hex.textSecondary, false);
  8023. var statusView = info.installed
  8024. ? W.pill("\u5DF2\u5B89\u88C5 " + Device.formatVersion(info.versionName), "ok")
  8025. : W.pill("\u672A\u5B89\u88C5", "mute");
  8026. var action = W.chip(info.installed ? "\u66F4\u65B0" : "\u4E0B\u8F7D", "accent");
  8027. action.root.setOnClickListener(function () {
  8028. installFromLocalApk(info);
  8029. });
  8030. /* 左侧标签用内容宽度,中间用 flexible() 顶开,右侧控件自然靠边 */
  8031. var row = U.row();
  8032. row.addView(label);
  8033. row.addView(U.flexible());
  8034. row.addView(statusView.root);
  8035. row.addView(U.hgap(6));
  8036. row.addView(action.root);
  8037. card.addView(row);
  8038. return card;
  8039. }
  8040. /**
  8041. * 从本机 APK 安装/更新 TikTok。
  8042. *
  8043. * 为什么不做成「点一下自动下载」:没有可依赖的官方下载源,
  8044. * 硬塞一个第三方地址既不安全也会失效。改成扫描本机已有的 APK,
  8045. * 让用户自己放包——这是能真正跑通的路径。
  8046. */
  8047. function installFromLocalApk(info) {
  8048. var dirs = ["/sdcard/Download", "/sdcard/Download/apk", "/sdcard/apk"];
  8049. var found = [];
  8050. for (var i = 0; i < dirs.length; i++) {
  8051. var names = [];
  8052. try {
  8053. names = files.listDir(dirs[i]);
  8054. } catch (e) {
  8055. names = [];
  8056. }
  8057. for (var j = 0; j < names.length; j++) {
  8058. if (/\.apk$/i.test(names[j])) { found.push(dirs[i] + "/" + names[j]); }
  8059. }
  8060. }
  8061. if (found.length === 0) {
  8062. Dialog.alert("未找到安装包",
  8063. "把 TikTok 的 APK 放到 /sdcard/Download 下即可在这里安装。\n\n" +
  8064. (info.installed ? "当前已安装:" + Device.formatVersion(info.versionName) : "当前未安装。"));
  8065. return;
  8066. }
  8067. Dialog.choose({
  8068. title: info.installed ? "选择更新包" : "选择安装包",
  8069. options: found,
  8070. currentIndex: 0,
  8071. onConfirm: function (index) {
  8072. launchInstaller(found[index]);
  8073. }
  8074. });
  8075. }
  8076. /** 把选中的 APK 交给系统安装器。 */
  8077. function launchInstaller(apkPath) {
  8078. var ok = false;
  8079. try {
  8080. /* app.viewFile 交给系统安装器处理;不用 FileUriExposed 的裸 file:// Intent,
  8081. * 那在 Android 7+ 会直接抛异常。 */
  8082. app.viewFile(apkPath);
  8083. ok = true;
  8084. } catch (e) {
  8085. console.log("[mine] 调起安装器失败: " + e);
  8086. }
  8087. toast(ok ? "已交给系统安装器" : "无法调起安装器,请手动点击该 APK");
  8088. }
  8089. /** 版本信息卡:脚本版本 / 更新日志 / 检查更新。 */
  8090. function buildVersionCard() {
  8091. var card = W.card(false);
  8092. var rows = [];
  8093. rows.push(W.settingRow("\u811A\u672C\u7248\u672C",
  8094. monoChip(Service.SCRIPT_VERSION, false).root, 6));
  8095. var changelog = W.chip("\u67E5\u770B", "accent");
  8096. changelog.root.setOnClickListener(function () {
  8097. Dialog.alert("更新日志", Service.CHANGELOG.join("\n"));
  8098. });
  8099. rows.push(W.settingRow("\u66F4\u65B0\u65E5\u5FD7", changelog.root, 6));
  8100. var statusPill = W.pill("\u68C0\u67E5\u4E2D\u2026", "mute");
  8101. rows.push(W.settingRow("\u68C0\u67E5\u66F4\u65B0", statusPill.root, 6));
  8102. function refresh() {
  8103. statusPill.setText("\u68C0\u67E5\u4E2D\u2026");
  8104. checkUpdateAsync(function (result) {
  8105. statusPill.setText(updateText(result));
  8106. });
  8107. }
  8108. statusPill.root.setOnClickListener(refresh);
  8109. /* 进页面自动查一次:设计稿这里直接显示结论(「已是最新」),
  8110. * 停在「检查中…」会让用户以为卡住了。走独立线程,不阻塞界面。 */
  8111. refresh();
  8112. W.stack(card, rows, 12);
  8113. return card;
  8114. }
  8115. /** 检查更新的结论文案。集中一处,避免「自动查」和「手动查」两条路径说法不一致。 */
  8116. function updateText(result) {
  8117. if (result.hasUpdate) { return "\u53D1\u73B0 " + result.latest; }
  8118. if (result.source === "unknown") { return "\u68C0\u67E5\u5931\u8D25"; }
  8119. return "\u5DF2\u662F\u6700\u65B0";
  8120. }
  8121. /**
  8122. * 检查更新。
  8123. *
  8124. * ⚠️ 默认路径**必须同步返回**,不能无脑起线程:
  8125. * 真机实测,`threads.start` + `ui.run` 这条链路里改 View 会直接崩,
  8126. * 报 `Cannot find function setAndNotify in object P7.b@...`(TextView 的内部通知)。
  8127. * 而 updateEndpoint 为空时 checkUpdate() 是纯本地计算、不发网络请求,
  8128. * 本来就没什么可异步的——起线程只是白白引入跨线程改 UI 的风险。
  8129. * 只有真配了远端端点、会发 HTTP 时,才走线程。
  8130. */
  8131. function checkUpdateAsync(callback) {
  8132. if (!Service.CONFIG.updateEndpoint) {
  8133. callback(Service.checkUpdate());
  8134. return;
  8135. }
  8136. threads.start(function () {
  8137. var result = Service.checkUpdate();
  8138. ui.run(function () { callback(result); });
  8139. });
  8140. }
  8141. /**
  8142. * 我的页。
  8143. * 与其它四页不同,本页**不需要 rerender** —— 页内唯一的动态刷新是
  8144. * 「检查更新」的状态标签,它是就地改文字(改完不重建,滚动位置才不会丢)。
  8145. */
  8146. Pages.mine = function () {
  8147. var inner = U.column();
  8148. var blocks = [];
  8149. blocks.push(W.sectionTitle("\u5361\u5BC6"));
  8150. blocks.push(buildLicenseCard());
  8151. blocks.push(U.spacer(4));
  8152. blocks.push(W.sectionTitle("\u5E94\u7528"));
  8153. blocks.push(buildAppCard());
  8154. /* 悬浮球单独一张卡:它是"跨 App 控制入口"的开关,
  8155. * 混进 TikTok 客户端那张卡里会被当成"TikTok 的设置"(规格书 §10 补的恢复入口) */
  8156. blocks.push(U.spacer(4));
  8157. blocks.push(W.sectionTitle("\u60AC\u6D6E\u7403"));
  8158. blocks.push(buildFloatBallCard());
  8159. blocks.push(U.spacer(4));
  8160. blocks.push(W.sectionTitle("\u5173\u4E8E"));
  8161. blocks.push(buildVersionCard());
  8162. /* 设备信息放在最后一行,排障时一眼能看到当前跑在什么机器上 */
  8163. blocks.push(W.hint(Device.deviceLabel() + " \u00B7 " + Device.appVersionName(), false));
  8164. /* —— 退出应用 ——
  8165. * ★ 为什么要有这个入口:以前主界面里**没有**退出,用户想关掉只能按返回或 HOME。
  8166. * 而按 HOME 是"退到后台",不是关闭 —— 悬浮球会继续浮在桌面和别的 App 上面
  8167. * (它本来就是为了在 TikTok 里操作才存在的)。用户看到球还在,就会认为"关不掉"。
  8168. * 给一个明确的出口:停脚本 → 关球 → 退到桌面,三件事一次做完。
  8169. * 靠 `events.on("exit")` 和 Activity 轮询也能清,但那些是兜底,不该是用户唯一的办法。 */
  8170. blocks.push(U.spacer(8));
  8171. var exitLink = W.link("\u9000\u51FA\u5E94\u7528\uFF08\u505C\u811A\u672C \u00B7 \u5173\u60AC\u6D6E\u7403\uFF09",
  8172. Theme.hex.textMuted, function () {
  8173. Dialog.exitApp(function () {
  8174. FB.destroy();
  8175. App.exit();
  8176. });
  8177. });
  8178. blocks.push(exitLink);
  8179. W.stack(inner, blocks, Theme.m.gapCard);
  8180. var scroller = W.scroller();
  8181. scroller.addView(inner);
  8182. return scroller;
  8183. };
  8184. /* ============================== src/main.js ============================== */
  8185. /* ============================== src/main.js ==============================
  8186. * 入口:搭骨架、路由(tab + 二级页)、运行态、启动流程。
  8187. *
  8188. * 骨架只搭一次(根 FrameLayout + 光晕 + 浮层宿主),之后所有刷新都只重建 body:
  8189. * · 主界面:全局运行条 / 页面内容 / 开始运行按钮 / 页签栏 四个宿主容器;
  8190. * · 登录页:整块 body 就是登录内容,没有页签也没有按钮。
  8191. * 不整体重建根布局——那会让光晕图层反复重建,肉眼可见地闪。
  8192. *
  8193. * ## 路由只有两层(2026-10-13 改版)
  8194. * 一级:三个 tab —— 任务 / 记录 / 我的(设计稿底栏只有三个图标)
  8195. * 二级:任务详情(id/dm/nurture/video 四个任务,各自一页)/ 运行详情(某条记录)
  8196. * 二级页保留底栏与「开始运行」CTA;运行详情没有 CTA(它只是看,不驱动)。
  8197. *
  8198. * ⚠️ 本脚本必须以 "ui"; 指令开头(build.js 负责写在第一行)。
  8199. */
  8200. var App = {};
  8201. App.ROOT_TAG = "feizhou-root";
  8202. App.RUN_CONFIG_PATH = "/sdcard/tiktok_app/run-config.json";
  8203. /** 当前显示的是哪一屏:主界面还是卡密登录页。 */
  8204. App.screen = "app";
  8205. /**
  8206. * 一级页:tab → 页面构建函数。顺序与 W.TABS 一致。
  8207. *
  8208. * 记录页在这里做「有记录 / 空态」的分流:两者是同一页的两种状态
  8209. * (设计稿 D1/D2),分流点放在路由层而不是页面内部 ——
  8210. * 页面函数只管画自己那一种,不必先判断"我有数据吗"。
  8211. */
  8212. App.TAB_PAGES = {
  8213. tasks: function () { return Pages.tasks(); },
  8214. records: function () { return Records.list().length > 0 ? Pages.records() : Pages.recordsEmpty(); },
  8215. /* 我的页不需要 rerender:页内唯一会变的是「检查更新」的状态标签,
  8216. * 它是就地改文字,不重建 —— 重建会把滚动位置清零。 */
  8217. mine: function () { return Pages.mine(); }
  8218. };
  8219. /** 一级页的标题区文案(设计稿:副标题各不相同)。 */
  8220. App.TAB_HEADERS = {
  8221. tasks: ["\u98DE\u821F\u811A\u672C", "TikTok \u81EA\u52A8\u5316\u811A\u672C \u00B7 \u9009\u62E9\u4EFB\u52A1"],
  8222. records: ["\u98DE\u821F\u811A\u672C", "TikTok \u81EA\u52A8\u5316\u811A\u672C \u00B7 \u8FD0\u884C\u8BB0\u5F55"],
  8223. mine: ["\u98DE\u821F\u811A\u672C", "TikTok \u81EA\u52A8\u5316\u811A\u672C \u00B7 \u6211\u7684"]
  8224. };
  8225. /** 二级页:任务详情。每个任务一页,都是可滚动的长页。 */
  8226. App.DETAIL_PAGES = {
  8227. id: function (rerender) { return Pages.id(rerender); },
  8228. dm: function (rerender) { return Pages.dm(rerender); },
  8229. nurture: function (rerender) { return Pages.nurture(rerender); },
  8230. video: function (rerender) { return Pages.video(rerender); }
  8231. };
  8232. App.body = null;
  8233. App.overlayHost = null;
  8234. App.runBar = null;
  8235. /**
  8236. * 上一次运行的终态(内存,不落盘)。
  8237. * 布局与状态条同位:未运行时它就是那一条"上次运行:…"。
  8238. * 不落盘的理由与运行态相同 —— 冷启动后该是干净的。
  8239. */
  8240. App.lastRunResult = null;
  8241. /**
  8242. * 报错态运行条(设计稿 F5 的 30:1119:整条变玫瑰红 + 文案「执行出错已停止」)。
  8243. *
  8244. * 只在「失败结束、且用户还没关掉 F5」这一刻存在。
  8245. * 关掉之后就整条隐藏 —— 与规格书 §2.4 的「空闲/完成时整条隐藏」一致,
  8246. * 但报错那一刻必须留着:它是"这次跑挂了"最直观的证据。
  8247. */
  8248. App.failBar = null;
  8249. /**
  8250. * 二级页的运行详情(记录 id)。**只存内存、不落盘**:
  8251. * 冷启动后回到"某一条历史记录"没有意义,而记录本身可能已被清理。
  8252. */
  8253. App.recordId = "";
  8254. /**
  8255. * 引擎"没写终态就没了"的判定宽限期。
  8256. * 引擎刚起来时还没出现在引擎列表里,头几秒不判定,免得刚启动就被判成已退出。
  8257. */
  8258. App.CHILD_GRACE_MS = 4000;
  8259. /**
  8260. * 发出停止请求后等引擎确认的上限。
  8261. * 超时就当它已经停了:用户按了停止,界面不能一直卡在"正在收尾"上。
  8262. */
  8263. App.STOP_TIMEOUT_MS = 8000;
  8264. /**
  8265. * 本次运行的 runId,在唤起引擎之前就定下来。
  8266. *
  8267. * 单独存一份是因为它要在两处用到 —— 写进 run-config(追溯)与传给引擎(比对),
  8268. * 而 `Session.start` 只在引擎**已经起来之后**才会被调用,顺序上不能倒过来。
  8269. */
  8270. App.pendingRunId = "";
  8271. /** 本次运行是否已提示过「引擎需要 TikTok 保持前台」。来回切窗口时只提醒一次。 */
  8272. App.backgroundWarned = false;
  8273. /* ------------------------------------------------------------ 光晕背景 */
  8274. /**
  8275. * 复刻设计稿背景的两团径向光晕。
  8276. * 用绝对定位的圆形 View 叠在根 FrameLayout 上,位置取自设计稿坐标。
  8277. */
  8278. function buildGlowLayer() {
  8279. var layer = new android.widget.FrameLayout(context);
  8280. layer.setLayoutParams(new android.widget.FrameLayout.LayoutParams(-1, -1));
  8281. var specs = [
  8282. { hex: Theme.hex.accent, alpha: Theme.alpha.glowA, size: 440, x: -40, y: -70 },
  8283. { hex: Theme.hex.accentDeep, alpha: Theme.alpha.glowB, size: 420, x: 40, y: 120 }
  8284. ];
  8285. for (var i = 0; i < specs.length; i++) {
  8286. var spec = specs[i];
  8287. var glow = new android.view.View(context);
  8288. glow.setBackground(U.glowBg(spec.hex, spec.alpha));
  8289. var lp = new android.widget.FrameLayout.LayoutParams(U.dp(spec.size), U.dp(spec.size));
  8290. lp.leftMargin = U.dp(spec.x);
  8291. lp.topMargin = U.dp(spec.y);
  8292. layer.addView(glow, lp);
  8293. }
  8294. /* 光晕只是氛围,不能吃掉点击事件 */
  8295. layer.setClickable(false);
  8296. layer.setFocusable(false);
  8297. return layer;
  8298. }
  8299. /* ------------------------------------------------------------ 页面骨架 */
  8300. /**
  8301. * 定高占位块。注意入参是**像素**而不是 dp ——
  8302. * 状态栏与导航栏高度从系统取回来就已经是 px,再 dp() 一次会放大两倍多。
  8303. */
  8304. function pxPlaceholder(heightPx) {
  8305. var view = new android.view.View(context);
  8306. view.setLayoutParams(new android.widget.LinearLayout.LayoutParams(-1, Math.round(heightPx)));
  8307. return view;
  8308. }
  8309. /** 竖向铺开并撑满剩余高度,供内容区使用。 */
  8310. function fillColumn() {
  8311. var column = U.column();
  8312. var lp = new android.widget.LinearLayout.LayoutParams(-1, 0);
  8313. lp.weight = 1;
  8314. column.setLayoutParams(lp);
  8315. return column;
  8316. }
  8317. App.build = function () {
  8318. var root = new android.widget.FrameLayout(context);
  8319. root.setBackground(U.screenBackground());
  8320. root.addView(buildGlowLayer());
  8321. App.body = U.column();
  8322. App.body.setLayoutParams(new android.widget.FrameLayout.LayoutParams(-1, -1));
  8323. root.addView(App.body);
  8324. App.overlayHost = new android.widget.FrameLayout(context);
  8325. root.addView(App.overlayHost);
  8326. return root;
  8327. };
  8328. /* ------------------------------------------------------------ 主界面 */
  8329. /**
  8330. * 上一次运行的结论行。点一下消失 —— 确认过了就不必一直占着位置。
  8331. */
  8332. function buildLastResultRow() {
  8333. var row = U.row();
  8334. row.setPadding(U.dp(Theme.m.contentPadding), U.dp(10),
  8335. U.dp(Theme.m.contentPadding), 0);
  8336. var text = U.text("\u4E0A\u6B21\u8FD0\u884C\uFF1A" + (App.lastRunResult.message || ""),
  8337. 11, Theme.hex.textFaint, false);
  8338. text.setSingleLine(true);
  8339. text.setEllipsize(android.text.TextUtils.TruncateAt.END);
  8340. row.addView(text);
  8341. row.addView(U.flexible());
  8342. row.setOnClickListener(function () {
  8343. App.lastRunResult = null;
  8344. App.renderScreen();
  8345. });
  8346. return row;
  8347. }
  8348. /**
  8349. * 把正在被驱动的目标应用切回前台。
  8350. *
  8351. * 引擎靠"看到 TikTok 在前台"来定位页面,所以**它在跑的时候 TikTok 必须留在前台**。
  8352. * 用户切回界面看进度这件事本身很自然,但它会让引擎瞬间"瞎掉"。
  8353. * 与其在文档里写"运行中请勿切走"(没人会记得),不如给一个一键切回去的入口。
  8354. */
  8355. App.backToTarget = function () {
  8356. var info = Device.tiktok();
  8357. if (!info.installed) {
  8358. toast("\u672A\u68C0\u6D4B\u5230 TikTok \u5BA2\u6237\u7AEF");
  8359. return false;
  8360. }
  8361. try {
  8362. app.launchPackage(info.packageName);
  8363. return true;
  8364. } catch (e) {
  8365. console.log("[app] 切回目标应用失败: " + e);
  8366. toast("\u5207\u56DE TikTok \u5931\u8D25");
  8367. return false;
  8368. }
  8369. };
  8370. /**
  8371. * 界面回到前台时的提醒。
  8372. *
  8373. * 只在**引擎正在跑**且**本次运行还没提醒过**时说一次:
  8374. * 用户来回切窗口时反复弹同一个提示是纯打扰。
  8375. *
  8376. * ⚠️ 2026-10-13 文案改了:运行条现在点一下是**跳回运行中的任务详情页**
  8377. * (规格书 §2.4「运行条 = 状态与导航」),不再是「切回 TikTok」。
  8378. * 旧的提示语会让用户点着运行条去找 TikTok,越点越糊涂。
  8379. */
  8380. App.noticeIfRunningInBackground = function () {
  8381. if (!Session.running || App.backgroundWarned) { return; }
  8382. App.backgroundWarned = true;
  8383. toast("\u8FD0\u884C\u4E2D\uFF1A\u5F15\u64CE\u9700\u8981 TikTok \u4FDD\u6301\u5728\u524D\u53F0\uFF0C"
  8384. + "\u5207\u5230\u672C\u754C\u9762\u4F1A\u8BA9\u5B83\u8BA4\u4E0D\u51FA\u9875\u9762");
  8385. };
  8386. /**
  8387. * 主界面:全局运行条 → 页面内容 → 开始运行 → 页签栏。
  8388. *
  8389. * 运行条放在内容区**上面**(设计稿 §2.4:x=20, y=70, w=335, h=44,状态栏之下):
  8390. * 它要在任何时候都可见 —— 包括用户切到别的 tab 去看记录的时候,
  8391. * 这正是"全局单跑"的可见性来源。
  8392. */
  8393. App.renderAppScreen = function (body) {
  8394. body.addView(pxPlaceholder(Device.statusBarHeight()));
  8395. /* 运行条:两种来源,同一个位置。
  8396. * ① 正在跑 → 绿点 + 任务名/脚本 + 计时(点击跳回运行中那个任务的详情页);
  8397. * ② 刚跑挂且用户还没关掉 F5 → 整条玫瑰红 +「执行出错已停止」+「已终止」。
  8398. * 空闲/正常完成时整条不存在,连那 10dp 间距也一并省掉 ——
  8399. * 一个空容器带着 padding 仍然会占位,是很容易忽略的一处空隙。 */
  8400. App.runBar = null;
  8401. var barText = "";
  8402. var barTime = "";
  8403. var barTone = "ok";
  8404. if (Session.running) {
  8405. barText = Session.barText();
  8406. barTime = Session.formatElapsed(Session.elapsedMs());
  8407. barTone = Session.stopping ? "warn" : "ok";
  8408. } else if (App.failBar) {
  8409. barText = App.failBar.text;
  8410. barTime = App.failBar.time;
  8411. barTone = "danger";
  8412. }
  8413. if (barText !== "") {
  8414. var statusHost = U.column();
  8415. statusHost.setPadding(U.dp(Theme.m.contentPadding), U.dp(10),
  8416. U.dp(Theme.m.contentPadding), 0);
  8417. App.runBar = W.runBar(barText, barTime, barTone);
  8418. /* ★ 状态条整条可点击 = 「跳回运行中的任务」。
  8419. * 引擎是靠"看到 TikTok 在前台"来定位页面的;用户切到记录 tab 看一眼,
  8420. * 前台就变了。与其在文档里写"运行中请勿切走",不如给一个一键跳回去的入口。 */
  8421. App.runBar.root.setOnClickListener(function () { App.backToRunning(); });
  8422. statusHost.addView(App.runBar.root);
  8423. body.addView(statusHost);
  8424. } else if (App.lastRunResult) {
  8425. /* 未运行但上一轮刚结束:留一行结果。
  8426. * 人守在手机前看的是 TikTok 自动操作,跑完那一刻他多半没看着界面 ——
  8427. * 切回来能立刻知道"上一轮跑成什么样了",而不是面对一个干净的界面猜。 */
  8428. body.addView(buildLastResultRow());
  8429. }
  8430. /* 内容区外面套一层 FrameLayout:底部渐隐要**叠**在内容上(设计稿 §6:
  8431. * y=577 h=80),直接加进竖向 body 会变成另起一行、占掉 80dp 高度。 */
  8432. var contentWrap = new android.widget.FrameLayout(context);
  8433. var wrapParams = new android.widget.LinearLayout.LayoutParams(-1, 0);
  8434. wrapParams.weight = 1;
  8435. contentWrap.setLayoutParams(wrapParams);
  8436. var contentHost = U.column();
  8437. contentHost.setLayoutParams(new android.widget.FrameLayout.LayoutParams(-1, -1));
  8438. contentHost.setPadding(U.dp(Theme.m.contentPadding), U.dp(Theme.m.contentPadding),
  8439. U.dp(Theme.m.contentPadding), 0);
  8440. contentWrap.addView(contentHost);
  8441. contentWrap.addView(W.bottomFade());
  8442. body.addView(contentWrap);
  8443. var runHost = new android.widget.FrameLayout(context);
  8444. body.addView(runHost);
  8445. var tabHost = new android.widget.FrameLayout(context);
  8446. body.addView(tabHost);
  8447. /* 底部导航背景横跨整宽,所以背景挂在 tabHost 上而不是 body */
  8448. tabHost.setBackground(U.solidBg(U.alphaOf(Theme.hex.tabBar, 0.96)));
  8449. App.contentHost = contentHost;
  8450. App.runHost = runHost;
  8451. App.tabHost = tabHost;
  8452. App.renderContent();
  8453. App.renderRunArea();
  8454. App.renderTabs();
  8455. };
  8456. /**
  8457. * 重建页面内容区。
  8458. *
  8459. * 三条分支对应三层东西:
  8460. * ① 运行详情(App.recordId)—— 它自带返回头,不再加标题区;
  8461. * ② 任务详情(App.detail)—— 返回头 + 该任务的长页;
  8462. * ③ 一级 tab —— 标题区 + 该 tab 的页面。
  8463. *
  8464. * ★ 重建前先 `commitPendingInputs()`:输入框现在是可以**直接编辑**的常驻控件,
  8465. * 用户完全可能还停在某一格里就点了别处(切 tab、勾复选框触发重建)。
  8466. * 不先提交就重建,那一格的编辑会随视图一起消失 —— 而这正是
  8467. * 「界面显示的值」和「真正跑起来的值」不一致的来源。
  8468. */
  8469. App.renderContent = function () {
  8470. W.commitPendingInputs();
  8471. W.beginPage();
  8472. var rerender = function () { ui.run(function () { App.renderContent(); }); };
  8473. /* 页面内部(例如「使用文本 ID」勾选后右端多出按钮)需要整页重建时用它 */
  8474. Pages.requestRerender = rerender;
  8475. var host = App.contentHost;
  8476. host.removeAllViews();
  8477. /* —— ② 运行详情 —— */
  8478. if (App.recordId) {
  8479. var detailPage = Pages.recordDetail(Records.find(App.recordId));
  8480. U.params(detailPage, -1, 0, 1);
  8481. host.addView(detailPage);
  8482. return;
  8483. }
  8484. /* —— ② 任务详情 —— */
  8485. if (App.detail) {
  8486. host.addView(W.backHeader(Pages.DETAIL_TITLES[App.detail] || "",
  8487. function () { App.closeDetail(); },
  8488. function () { Device.openPermissionSettings("a11y"); }));
  8489. host.addView(U.spacer(Theme.m.gapTight));
  8490. var builder = App.DETAIL_PAGES[App.detail];
  8491. if (!builder) {
  8492. /* 不认识的详情 key(配置被手改过)→ 直接回任务列表,
  8493. * 而不是渲染一个空白页让用户以为界面坏了。 */
  8494. App.detail = "";
  8495. Store.set("activeDetail", "");
  8496. App.renderContent();
  8497. return;
  8498. }
  8499. var page = builder(rerender);
  8500. var pageParams = new android.widget.LinearLayout.LayoutParams(-1, 0);
  8501. pageParams.weight = 1;
  8502. page.setLayoutParams(pageParams);
  8503. host.addView(page);
  8504. return;
  8505. }
  8506. /* —— ③ 一级 tab —— */
  8507. var header = App.TAB_HEADERS[App.activeTab] || App.TAB_HEADERS.tasks;
  8508. host.addView(W.pageHeader(header[0], header[1], function () {
  8509. Device.openPermissionSettings("a11y");
  8510. }));
  8511. host.addView(U.spacer(Theme.m.gapPage));
  8512. var tabBuilder = App.TAB_PAGES[App.activeTab] || App.TAB_PAGES.tasks;
  8513. var tabPage = tabBuilder();
  8514. var tabParams = new android.widget.LinearLayout.LayoutParams(-1, 0);
  8515. tabParams.weight = 1;
  8516. tabPage.setLayoutParams(tabParams);
  8517. host.addView(tabPage);
  8518. };
  8519. /**
  8520. * 重建「开始运行 / 停止运行」按钮区。
  8521. *
  8522. * 只有**任务详情页**有这一块(设计稿:B1/B2/D1/D2/D3/E1 都没有)。
  8523. * 分工是刻意的:任务卡上的「运行」按钮负责"直接从列表起跑",
  8524. * 底部 CTA 负责"在详情页调好参数后起跑/停止"。 */
  8525. App.renderRunArea = function () {
  8526. var host = App.runHost;
  8527. host.removeAllViews();
  8528. if (App.recordId || !App.detail) { return; }
  8529. var wrapper = U.column();
  8530. wrapper.setPadding(U.dp(Theme.m.contentPadding), U.dp(12), U.dp(Theme.m.contentPadding), U.dp(12));
  8531. var button = W.runButton(function () { App.toggleRun(); },
  8532. Session.running ? "\u505C\u6B62\u8FD0\u884C" : "\u5F00\u59CB\u8FD0\u884C");
  8533. wrapper.addView(button.root);
  8534. host.addView(wrapper);
  8535. };
  8536. /** 重建页签栏(含底部安全区)。 */
  8537. App.renderTabs = function () {
  8538. var host = App.tabHost;
  8539. host.removeAllViews();
  8540. var column = U.column();
  8541. column.addView(W.tabBar(App.activeTab, function (key) {
  8542. if (key === App.activeTab) { return; }
  8543. App.switchTab(key);
  8544. }));
  8545. /* 全面屏手势机型 navBarHeight() 返回 0,此时给 24dp 余量,
  8546. * 否则页签文字会贴到手势条上。 */
  8547. var safe = Device.navBarHeight();
  8548. column.addView(pxPlaceholder(safe > 0 ? safe : U.dp(24)));
  8549. host.addView(column);
  8550. };
  8551. /** 全屏重建。切屏与运行态变化都走它,不在别处零散重建。 */
  8552. App.renderScreen = function () {
  8553. var body = App.body;
  8554. body.removeAllViews();
  8555. if (App.screen === "login") {
  8556. body.addView(pxPlaceholder(Device.statusBarHeight()));
  8557. var login = Pages.login();
  8558. var lp = new android.widget.LinearLayout.LayoutParams(-1, 0);
  8559. lp.weight = 1;
  8560. login.setLayoutParams(lp);
  8561. body.addView(login);
  8562. return;
  8563. }
  8564. App.renderAppScreen(body);
  8565. };
  8566. App.switchTab = function (key) {
  8567. /* 切 tab = 退出二级页:底栏是"这一层"的导航,
  8568. * 按了它还停在详情页里会让人以为导航坏了。 */
  8569. App.detail = "";
  8570. App.recordId = "";
  8571. Store.set("activeDetail", "");
  8572. App.activeTab = key;
  8573. Store.set("activeTab", key);
  8574. App.renderScreen();
  8575. console.log("[app] 切换到 tab " + key);
  8576. };
  8577. /* ------------------------------------------------------------ 二级页导航 */
  8578. /** 进某个任务的详情页(C1/C6/C8/C9)。 */
  8579. App.openDetail = function (taskKey) {
  8580. if (!App.DETAIL_PAGES[taskKey]) { return; }
  8581. App.recordId = "";
  8582. App.detail = taskKey;
  8583. if (App.activeTab !== "tasks") {
  8584. App.activeTab = "tasks";
  8585. Store.set("activeTab", "tasks");
  8586. }
  8587. Store.set("activeDetail", taskKey);
  8588. App.renderScreen();
  8589. console.log("[app] 打开任务详情 " + taskKey);
  8590. };
  8591. App.closeDetail = function () {
  8592. App.detail = "";
  8593. Store.set("activeDetail", "");
  8594. App.renderScreen();
  8595. };
  8596. /** 进某条运行记录的详情(D3)。 */
  8597. App.openRecord = function (recordId) {
  8598. if (!recordId) { return; }
  8599. App.recordId = recordId;
  8600. App.detail = "";
  8601. if (App.activeTab !== "records") {
  8602. App.activeTab = "records";
  8603. Store.set("activeTab", "records");
  8604. }
  8605. Store.set("activeDetail", "");
  8606. App.renderScreen();
  8607. };
  8608. App.closeRecord = function () {
  8609. App.recordId = "";
  8610. App.renderScreen();
  8611. };
  8612. /**
  8613. * 点运行条 → 跳回**正在运行的那个任务**的详情页。
  8614. *
  8615. * 为什么是"跳回任务页"而不是"切回 TikTok":运行条是全局的,
  8616. * 用户在记录 tab 上看到它、点一下,最想问的是"谁在跑 / 跑成什么样了"。
  8617. * 至于把 TikTok 拉回前台 —— 那是**引擎自己的事**(它必须待在前台),
  8618. * 界面这一下只解决"我在哪"。
  8619. */
  8620. App.backToRunning = function () {
  8621. if (Session.running && Session.tab) {
  8622. App.openDetail(Session.tab);
  8623. return;
  8624. }
  8625. /* 报错态的那一下:直接去看日志(F5 的「查看日志」走的是同一个入口) */
  8626. var failedId = App.failBar ? App.failBar.recordId : "";
  8627. App.failBar = null;
  8628. if (failedId) {
  8629. App.openRecord(failedId);
  8630. return;
  8631. }
  8632. App.renderScreen();
  8633. };
  8634. /* ------------------------------------------------------------ 运行态 */
  8635. /**
  8636. * 每秒刷新计时,并顺带做两件巡检。
  8637. *
  8638. * 只改状态条上的那两个 TextView,不重建界面 ——
  8639. * 每秒重建整页会让滚动位置归零,用户根本没法边跑边看配置。
  8640. *
  8641. * 两件巡检:
  8642. * ① 卡密到期(设计稿 F4):到期那一刻停掉运行并弹窗,而不是等用户下一次操作才发现。
  8643. * ② **引擎状态**:这是"界面上的运行态"与"真正在跑的东西"唯一的一致性来源。
  8644. * 两者都放在这里而不是另起定时器,因为「运行中」本来就是它们唯一需要的触发条件。
  8645. */
  8646. App.tickTimer = function () {
  8647. if (!Session.running) { return; }
  8648. if (Service.licenseStatus().status !== "active") {
  8649. /* 先让引擎停手(否则它会继续操作 TikTok),然后**立刻复位界面**:
  8650. * 这一刻要传递的信息是"你已经不能跑了",不是"正在收尾",
  8651. * 再挂一个转圈的运行态反而让人以为还有救。 */
  8652. Engine.requestStop(Session.runId);
  8653. Session.stop();
  8654. Session.stopTimer();
  8655. App.renderScreen();
  8656. Dialog.licenseExpired(function () { App.showLogin(); });
  8657. return;
  8658. }
  8659. App.pollEngine();
  8660. FB.sync();
  8661. /* pollEngine 可能已经把运行结束了(终态),此时状态条已经不存在 */
  8662. if (App.runBar && Session.running) {
  8663. App.runBar.setElapsed(Session.formatElapsed(Session.elapsedMs()));
  8664. App.runBar.setText(Session.barText());
  8665. App.runBar.setTone(Session.stopping ? "warn" : "ok");
  8666. }
  8667. };
  8668. /**
  8669. * 读引擎上报的状态,驱动界面的运行态。
  8670. *
  8671. * ★ 必须比对 runId:状态文件是设备上的共享文件,上一轮运行留下的内容还在。
  8672. * 不比对就会出现「刚点开始运行,界面立刻显示上一轮的进度」。
  8673. */
  8674. App.pollEngine = function () {
  8675. var state = Engine.readState();
  8676. if (state && state.runId === Session.runId) {
  8677. /* 看到过它,才有资格在后来说"它不见了" —— 见下面的兜底判定 */
  8678. if (Engine.childAlive()) { Engine.sawChild = true; }
  8679. if (Engine.isTerminalStatus(state.status)) {
  8680. App.finishRun(state);
  8681. return;
  8682. }
  8683. if (state.phase) { Session.phase = state.phase; }
  8684. Session.done = state.done || 0;
  8685. Session.total = state.total || 0;
  8686. if (state.status === "stopping") { Session.stopping = true; }
  8687. } else if (Engine.childAlive()) {
  8688. /* 引擎在跑但还没写出状态:正常的启动间隙,什么都不做 */
  8689. Engine.sawChild = true;
  8690. }
  8691. /* —— 兜底一:点了停止但引擎迟迟不确认 —— */
  8692. if (Session.stopping && Date.now() - Session.stopRequestedAt > App.STOP_TIMEOUT_MS) {
  8693. App.finishRun({
  8694. status: "stopped",
  8695. message: "\u5DF2\u53D1\u51FA\u505C\u6B62\u8BF7\u6C42\uFF08\u5F15\u64CE\u672A\u786E\u8BA4\uFF09"
  8696. });
  8697. return;
  8698. }
  8699. /* —— 兜底二:引擎没写终态就没了(被系统杀掉 / 被人手工停掉)——
  8700. * 只在"确实看到过它、现在它不见了"时判定。`engines.all()` 在不同运行时里
  8701. * 的可见性不完全一致,没看到过就当作"查不出来",宁可多显示一会儿运行中,
  8702. * 也不要误判成已结束把运行态清掉。 */
  8703. if (!Session.stopping &&
  8704. Engine.sawChild &&
  8705. Date.now() - Session.startedAt > App.CHILD_GRACE_MS &&
  8706. !Engine.childAlive()) {
  8707. App.finishRun({
  8708. status: "failed",
  8709. message: "\u5F15\u64CE\u5DF2\u9000\u51FA\uFF08\u672A\u6536\u5230\u7ED3\u675F\u4FE1\u53F7\uFF09"
  8710. });
  8711. }
  8712. };
  8713. /**
  8714. * 把界面拉回前台。
  8715. *
  8716. * 引擎跑起来时前台是 TikTok(它在那边操作),用户看不到界面上的进度;
  8717. * 跑完把界面切回来,用户才不用自己去最近任务里把它翻出来。
  8718. *
  8719. * 为什么不用 `app.launchPackage(包名)`:那会打开 AutoX 的**主界面**,
  8720. * 而不是"正在运行的这个脚本界面"。用当前 Activity 的类名 + REORDER_TO_FRONT
  8721. * 才能精准命中同一个界面;它本来就在前台时,该调用无副作用。
  8722. *
  8723. * 失败不抛:拉不回前台只是少了点便利,不该影响"运行已结束"这件正事。
  8724. */
  8725. App.bringToFront = function () {
  8726. try {
  8727. var className = activity.getClass().getName();
  8728. app.startActivity({
  8729. action: "android.intent.action.VIEW",
  8730. packageName: String(context.getPackageName()),
  8731. className: className,
  8732. flags: android.content.Intent.FLAG_ACTIVITY_REORDER_TO_FRONT
  8733. });
  8734. return true;
  8735. } catch (e) {
  8736. console.log("[app] 拉回前台失败: " + e);
  8737. return false;
  8738. }
  8739. };
  8740. /**
  8741. * 结束运行态。
  8742. *
  8743. * 顺序有讲究:先**攒一条运行记录**(此刻 Session 里还有本次运行的信息,
  8744. * `Session.stop()` 一跑就全没了),再复位状态并重建界面(状态条消失、
  8745. * 按钮变回"开始运行"),然后把界面拉回前台,最后提示结果 ——
  8746. * 反过来的话提示会挂在一个还没被拆掉的界面上。
  8747. *
  8748. * 失败终态额外做两件事(规格书 §2.3 / §2.4):
  8749. * · 运行条留在原地**变成玫瑰红**(「执行出错已停止」+「已终止」),
  8750. * 用户关掉 F5 之后才隐藏;
  8751. * · 弹 F5,并把「查看日志」接到刚写下的那条记录上。
  8752. */
  8753. App.finishRun = function (state) {
  8754. var result = {
  8755. status: state.status || "failed",
  8756. message: state.message || "",
  8757. summary: state.summary || null
  8758. };
  8759. /* ★ 记录必须在 Session.stop() 之前攒:任务/脚本/起始时间都在 Session 里。 */
  8760. var record = App.writeRecord(result);
  8761. App.lastRunResult = result;
  8762. Session.stop();
  8763. Session.stopTimer();
  8764. if (result.status === "failed" && record) {
  8765. App.failBar = {
  8766. text: Pages.shortScriptName(record.scriptName) + " \u00B7 \u6267\u884C\u51FA\u9519\u5DF2\u505C\u6B62",
  8767. time: "\u5DF2\u7EC8\u6B62",
  8768. recordId: record.id
  8769. };
  8770. } else {
  8771. App.failBar = null;
  8772. }
  8773. App.renderScreen();
  8774. FB.sync(); /* 失败 → 红环;正常 → 回空闲无环 */
  8775. App.bringToFront();
  8776. if (result.status === "failed") {
  8777. Dialog.scriptError(result.message || "", function () {
  8778. /* 「查看日志」→ 清掉报错条,再进 D3 */
  8779. var id = App.failBar ? App.failBar.recordId : "";
  8780. App.failBar = null;
  8781. if (id) { App.openRecord(id); } else { App.renderScreen(); }
  8782. }, function () {
  8783. /* 「知道了」→ 报错条也随之消失(这一次的事已经被告知过了) */
  8784. App.failBar = null;
  8785. App.renderScreen();
  8786. });
  8787. console.log("[app] 运行结束(失败):" + result.message);
  8788. return;
  8789. }
  8790. /* toast 而不是弹窗:万一前台没能切回来(用户在别的应用里),
  8791. * 系统 Toast 仍然看得见;而弹窗会挡着用户正在做的事。 */
  8792. toast(result.message || "\u8FD0\u884C\u7ED3\u675F");
  8793. console.log("[app] 运行结束:" + result.status + " / " + result.message);
  8794. };
  8795. /**
  8796. * 把这次运行写进运行记录(D1/D3 的数据来源)。
  8797. *
  8798. * 只写**真实知道的东西**:任务/脚本来自 Session,结果文案来自引擎终态,
  8799. * 日志来自引擎的 --log 通道(`Engine.readLog()`)。引擎没回传的字段留空,
  8800. * D3 那边对应的一行就不渲染 —— 不编一个看起来合理的值。
  8801. */
  8802. App.writeRecord = function (result) {
  8803. if (!Session.tab) { return null; }
  8804. var endedAt = Date.now();
  8805. var record = Records.add({
  8806. taskKey: Session.tab,
  8807. taskName: Pages.taskName(Session.tab),
  8808. scriptKey: Session.scriptKey,
  8809. scriptName: Session.scriptName,
  8810. result: Records.resultOf(result.status).key,
  8811. startedAt: Session.startedAt,
  8812. endedAt: endedAt,
  8813. durationMs: Math.max(0, endedAt - Session.startedAt),
  8814. resultText: result.message || "",
  8815. resultFile: result.summary && result.summary.resultFile ? result.summary.resultFile : "",
  8816. /* ID提取勾了「同时解析数字 uid」时,引擎还会另写一份 username→uid 的映射文件。
  8817. * 以前这里只传 resultFile,映射文件虽然在手机上,界面上却看不到路径、
  8818. * 点不开也分享不了 —— 等于白生成。 */
  8819. mapFile: result.summary && result.summary.mapFile ? result.summary.mapFile : "",
  8820. /* runId:记录页要靠它和断点里的 runId 对上,才知道"这条记录就是那个没跑完的"。
  8821. * 不带的话,一串历史「手动停止」记录上都会冒出「继续」。 */
  8822. runId: Session.runId || "",
  8823. logs: Engine.readLog()
  8824. });
  8825. console.log("[records] 已记录本次运行:" + record.id + " / " + record.result);
  8826. return record;
  8827. };
  8828. /**
  8829. * 取当前任务详情页里选中的那个脚本,没选返回 null。
  8830. *
  8831. * 从 v6 起「当前任务」= **当前打开的那个任务详情页**(App.detail)。
  8832. * 一级页(任务列表)没有"当前任务"这回事 —— 四张卡各有各的「运行」按钮,
  8833. * 各带各的 taskKey(见 App.requestRun)。这正是"每个任务单独启动"的落点。
  8834. */
  8835. App.currentTask = function () {
  8836. /* ★ 读配置之前先把「用户可能还在编辑」的输入框内容落地。
  8837. * Android 里点一个不可聚焦的按钮**不会**让 EditText 失焦,
  8838. * 所以「按开始运行」这一刻栏位内容很可能还没进配置。 */
  8839. W.commitPendingInputs();
  8840. if (!App.detail) { return null; }
  8841. return Pages.checkedScript(App.detail);
  8842. };
  8843. /**
  8844. * 把当前配置落盘成交给执行侧的运行配置。
  8845. *
  8846. * 这里写的是**给人看、给事后追溯用**的快照(含界面完整配置、本次生效的引擎配置、
  8847. * 以及"哪些勾了但本次不会生效")。引擎真正读的是 engine-config.json ——
  8848. * 分成两份是因为两边的读者不同:一个排查问题的人需要全貌,引擎只需要它能懂的那部分。
  8849. */
  8850. App.exportRunConfig = function (checked, plan) {
  8851. var payload = {
  8852. generatedAt: Service.today(),
  8853. scriptVersion: Service.SCRIPT_VERSION,
  8854. licenseStatus: Service.licenseStatus().status,
  8855. tasks: checked,
  8856. engine: {
  8857. /* ★ 用 pendingRunId 而不是 Session.runId:
  8858. * 本函数在 `Session.start()` **之前**被调用,那时会话里还是上一轮的 runId。
  8859. * 记错这一次的标识,事后追溯就会被误导到别的运行记录上。 */
  8860. runId: App.pendingRunId,
  8861. scriptPath: plan ? plan.scriptPath : "",
  8862. configPath: Engine.CONFIG_PATH,
  8863. statePath: Engine.STATE_PATH,
  8864. stopFlagPath: Engine.STOP_FLAG_PATH,
  8865. config: plan ? plan.config : null,
  8866. notes: plan ? plan.notes : []
  8867. },
  8868. config: Store.current(),
  8869. /* ★ v8:取材清单**不在** `Store.FIELDS` 里(它是不定长列表,走独立 storage key),
  8870. * 所以 `config: Store.current()` 里没有它。但"这一轮到底从哪取的材"
  8871. * 恰恰是事后追溯最需要的一条,必须单独带上。 */
  8872. sources: Sources.list()
  8873. };
  8874. try {
  8875. var dir = App.RUN_CONFIG_PATH.substring(0, App.RUN_CONFIG_PATH.lastIndexOf("/"));
  8876. files.ensureDir(dir + "/");
  8877. files.write(App.RUN_CONFIG_PATH, JSON.stringify(payload, null, 2));
  8878. return true;
  8879. } catch (e) {
  8880. console.log("[app] 运行配置落盘失败: " + e);
  8881. return false;
  8882. }
  8883. };
  8884. /**
  8885. * **所有**「开始运行」的唯一入口(底部 CTA 与四张任务卡上的「运行」都走它)。
  8886. *
  8887. * 前置校验顺序**照规格书 §2.5 实现**,任一步不过就拦截:
  8888. * ① 卡密有效? → 否:不做提示,直接回 A1
  8889. * ② 无障碍+悬浮窗?→ 否:弹 F2
  8890. * ③ 已选脚本? → 否:弹 F1
  8891. * ④ 引擎接入? → 否:弹「引擎尚未接入」(**本项目自己的补丁**:
  8892. * 规格书假设所有脚本都已实现,而这里有四个任务还没接引擎。
  8893. * 静默跑一个不相关的东西比明确告知糟糕得多,所以保留这一关)
  8894. * ⑤ 已有脚本在跑? → 是:弹 F3「停止并开始」;否:直接启动
  8895. *
  8896. * 顺序不能随手改:卡密排第一是因为它不通时**别的都无从谈起**(且要回登录页),
  8897. * 权限排第二是因为它比"选脚本"更难自己发现(缺口在系统设置里)。
  8898. *
  8899. * @param taskKey 要运行哪个任务(任务卡上的按钮直接给,不必先切到那个页)
  8900. */
  8901. App.requestRun = function (taskKey, opts) {
  8902. /* ① 卡密:失效就直接回登录页,不弹提示 —— 那里才有可做的事 */
  8903. if (Service.licenseStatus().status !== "active") {
  8904. App.showLogin();
  8905. return;
  8906. }
  8907. /* ② 阻断性权限(无障碍 + 悬浮窗) */
  8908. if (!Device.blockingPermissionsGranted()) {
  8909. Dialog.permissionBlocked();
  8910. return;
  8911. }
  8912. /* ③ 该任务当前选中的脚本 */
  8913. var script = Pages.checkedScript(taskKey);
  8914. if (!script) {
  8915. /* v8:ID提取没有"脚本"了,它的空态是"还没添加取材" ——
  8916. * 对用户说"请先选择要运行的脚本"会让他到处找那不存在的脚本。 */
  8917. Dialog.runBlocked(taskKey === "id"
  8918. ? "\u8BF7\u5148\u6DFB\u52A0\u4E00\u6761\u53D6\u6750"
  8919. : "\u8BF7\u5148\u9009\u62E9\u8981\u8FD0\u884C\u7684\u811A\u672C");
  8920. return;
  8921. }
  8922. /* ④ 引擎是否接入(见函数头注释) */
  8923. if (!script.engine) {
  8924. Dialog.engineMissing(script.name);
  8925. return;
  8926. }
  8927. /* ⑤ 全局单跑:运行中点别的任务不禁用按钮,而是弹 F3 确认(规格书 §2.3) */
  8928. if (Session.running) {
  8929. Dialog.switchTask(Pages.taskName(Session.tab), Pages.taskName(taskKey), function () {
  8930. App.startRun(script, opts);
  8931. });
  8932. return;
  8933. }
  8934. /* ⑦ 有没跑完的断点、而用户点的是普通「开始运行」→ 先问一句。
  8935. *
  8936. * 不问的话,用户会静默丢掉上一轮的进度(断点被新一轮覆盖),
  8937. * 而且界面上完全看不出来发生过这件事。用「选一个」而不是「确认/取消」:
  8938. * 两个选项都是用户可能真心想要的,只给"确定/取消"会让人没法表达"我要从零开始"。
  8939. *
  8940. * 只有 ID 提取有断点,别的任务直接走原路。
  8941. */
  8942. if (taskKey === "id" && !(opts && opts.resume)) {
  8943. var pending = Resume.snapshot();
  8944. if (pending.valid) {
  8945. Dialog.choose({
  8946. title: "\u4E0A\u6B21\u8FD0\u884C\u8FD8\u6CA1\u8DD1\u5B8C",
  8947. options: [
  8948. "\u7EE7\u7EED\u4E0A\u6B21\uFF08\u5DF2\u6536 " + pending.extracted + " \u4E2A\uFF09",
  8949. "\u4ECE\u96F6\u5F00\u59CB\uFF08\u65AD\u70B9\u4F5C\u5E9F\uFF09"
  8950. ],
  8951. currentIndex: 0,
  8952. onConfirm: function (index) {
  8953. if (index === 0) {
  8954. App.resumeRun();
  8955. return;
  8956. }
  8957. App.startRun(script, opts);
  8958. }
  8959. });
  8960. return;
  8961. }
  8962. }
  8963. App.startRun(script, opts);
  8964. };
  8965. /**
  8966. * 继续上次没跑完的那一轮(提示条、记录行、记录详情都走这里)。
  8967. *
  8968. * **不自己重跑一遍卡密/权限检查** —— 交给 `requestRun` 统一做。
  8969. * 续跑如果绕过卡密校验,就等于开了一个"卡密过期也能跑"的后门。
  8970. *
  8971. * @param record 可选。从某条运行记录点进来时传,用来核对"这条记录是不是那个断点"
  8972. */
  8973. App.resumeRun = function (record) {
  8974. if (Session.running) {
  8975. toast("\u5DF2\u6709\u4EFB\u52A1\u5728\u8FD0\u884C\uFF0C\u8BF7\u5148\u505C\u6B62");
  8976. return;
  8977. }
  8978. var snap = Resume.snapshot();
  8979. if (!snap.exists || snap.finished || snap.reason === "cleared") {
  8980. Dialog.alert("\u6CA1\u6709\u53EF\u7EE7\u7EED\u7684\u8FD0\u884C",
  8981. "\u4E0A\u6B21\u7684\u8FD0\u884C\u5DF2\u7ECF\u7ED3\u675F\uFF0C\u6216\u65AD\u70B9\u5DF2\u88AB\u6E05\u7A7A\u3002");
  8982. return;
  8983. }
  8984. if (!snap.valid) {
  8985. Dialog.alert("\u65AD\u70B9\u65E0\u6CD5\u7EE7\u7EED",
  8986. (snap.message || "\u65AD\u70B9\u5DF2\u5931\u6548") +
  8987. "\n\n\u6E05\u7A7A\u65AD\u70B9\u540E\u53EF\u4EE5\u91CD\u65B0\u5F00\u59CB\u3002");
  8988. return;
  8989. }
  8990. /* 从记录点进来时必须核对 runId:断点只保留最后一份,可能是后来那一轮的 */
  8991. if (record && (!record.runId || !snap.cp || record.runId !== snap.cp.runId)) {
  8992. Dialog.alert("\u8FD9\u6761\u8BB0\u5F55\u4E0D\u80FD\u7EE7\u7EED",
  8993. "\u65AD\u70B9\u5DF2\u88AB\u4E4B\u540E\u7684\u8FD0\u884C\u8986\u76D6\u3002");
  8994. return;
  8995. }
  8996. App.requestRun("id", { resume: true });
  8997. };
  8998. /** 清空断点(覆盖成 state:'none',不删文件) */
  8999. App.clearCheckpoint = function () {
  9000. if (!Resume.clear()) {
  9001. toast("\u6E05\u7A7A\u65AD\u70B9\u5931\u8D25");
  9002. return;
  9003. }
  9004. App.renderScreen();
  9005. toast("\u65AD\u70B9\u5DF2\u6E05\u7A7A\uFF0C\u4E0B\u6B21\u4ECE\u5934\u5F00\u59CB");
  9006. };
  9007. /**
  9008. * 底部 CTA 的唯一入口:没在跑就尝试开始(跑当前详情页那个任务的脚本),在跑就停。
  9009. * 校验逻辑与任务卡共用 `App.requestRun`,避免两处各写一遍顺序。
  9010. */
  9011. App.toggleRun = function () {
  9012. if (Session.running) {
  9013. App.stopRun();
  9014. App.renderScreen();
  9015. return;
  9016. }
  9017. if (!App.detail) { return; }
  9018. App.requestRun(App.detail);
  9019. };
  9020. /**
  9021. * 真正进入运行态:准备引擎 → 落盘配置 → **唤起引擎** → 建状态条 → 起计时。
  9022. *
  9023. * 顺序不能换:配置必须先落盘(引擎一起来就读它),
  9024. * 计时必须在唤起成功之后才开始(否则引擎没起来,界面却在计时,是最难解释的一种状态)。
  9025. */
  9026. App.startRun = function (task, opts) {
  9027. var plan = Engine.prepare(task, opts);
  9028. if (!plan.ok) {
  9029. Dialog.engineUnavailable(plan.error);
  9030. return;
  9031. }
  9032. /* runId 要在写配置之前定下来:它会被同时写进 run-config(追溯)与引擎参数(比对) */
  9033. App.pendingRunId = Engine.newRunId();
  9034. if (!App.exportRunConfig([{ tab: task.task, script: task.key, name: task.name }], plan)) {
  9035. toast("\u8FD0\u884C\u914D\u7F6E\u5199\u5165\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u5B58\u50A8\u6743\u9650");
  9036. return;
  9037. }
  9038. if (!Engine.writeConfig(plan.config)) {
  9039. toast("\u5F15\u64CE\u914D\u7F6E\u5199\u5165\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u5B58\u50A8\u6743\u9650");
  9040. return;
  9041. }
  9042. var launched = Engine.launch(App.pendingRunId, plan.scriptPath, opts);
  9043. if (!launched.ok) {
  9044. Dialog.engineUnavailable("\u5F15\u64CE\u542F\u52A8\u5931\u8D25\uFF1A" + launched.error);
  9045. return;
  9046. }
  9047. Session.start(task.task, task.key, task.name, App.pendingRunId);
  9048. Engine.sawChild = false;
  9049. App.lastRunResult = null;
  9050. App.failBar = null;
  9051. App.backgroundWarned = false;
  9052. /* 运行条要能点击跳回**正在跑的那个任务**,所以顺手把它带进那个任务的详情页:
  9053. * 用户刚点了「运行」,看到的应该是"它跑起来了",而不是停在列表上猜。 */
  9054. if (App.detail !== task.task || App.activeTab !== "tasks") {
  9055. App.recordId = "";
  9056. App.detail = task.task;
  9057. App.activeTab = "tasks";
  9058. Store.set("activeTab", "tasks");
  9059. Store.set("activeDetail", task.task);
  9060. }
  9061. console.log("[app] 已唤起引擎 " + plan.scriptPath + " runId=" + App.pendingRunId);
  9062. App.renderScreen();
  9063. FB.sync(); /* 球的外环变青绿 + 开始呼吸(规格书 §10.4) */
  9064. Session.startTimer(App.tickTimer);
  9065. /* notes 是"你勾了、但引擎这次做不到"的项。**如实告知**,
  9066. * 完整内容也写进了 run-config.json 供事后核对。 */
  9067. var summary = Engine.summarizeNotes(plan.notes);
  9068. if (summary) { toast(summary); }
  9069. };
  9070. /**
  9071. * 停运行。
  9072. *
  9073. * 分两步、且**不立刻复位**:先向引擎发出停止请求,界面进入「正在收尾」,
  9074. * 等引擎上报 stopped(或超时)才真正结束。
  9075. *
  9076. * 为什么不做成"点一下就当停了":引擎收到请求后还要退出当前等待、把 TikTok
  9077. * 带回信息流,通常 1 秒内完成,但它确实还在动。这时界面说「已停止」,
  9078. * 用户看到 TikTok 仍在操作,只能怀疑"到底停没停"。
  9079. *
  9080. * **不负责重建界面** —— 调用方要么接着切屏,要么自己补一次重建。
  9081. */
  9082. App.stopRun = function () {
  9083. if (!Session.running) { return; }
  9084. if (Session.stopping) { return; }
  9085. if (Engine.requestStop(Session.runId)) {
  9086. Session.stopping = true;
  9087. Session.stopRequestedAt = Date.now();
  9088. console.log("[app] 已发出停止请求 runId=" + Session.runId);
  9089. return;
  9090. }
  9091. /* 写标志都失败了就没什么可等的,直接复位 */
  9092. App.finishRun({ status: "stopped", message: "\u5DF2\u505C\u6B62" });
  9093. };
  9094. /* ------------------------------------------------------------ 权限弹窗 */
  9095. /** 打开权限弹窗。弹窗自己也保证同时只存在一个,这里再挡一道更直观。 */
  9096. App.openPermissions = function () {
  9097. if (Dialog.active) { return; }
  9098. Dialog.showPermissions();
  9099. };
  9100. /* ------------------------------------------------------------ 卡密登录 */
  9101. /** 切到登录页。重置三态,避免把上一次的失败提示带过来。 */
  9102. App.showLogin = function () {
  9103. App.screen = "login";
  9104. /* 登录页不该有球:卡密都没了就没什么可控制的,留在屏幕上只是挡路 */
  9105. FB.destroy();
  9106. Pages.LICENSE_RESET();
  9107. App.renderScreen();
  9108. };
  9109. /** 登录成功:回主界面,并顺手把还缺的权限提示一次。 */
  9110. App.enterApp = function () {
  9111. App.screen = "app";
  9112. /* 进主界面就把悬浮球挂上(它只在"卡密有效 + 有悬浮窗权限"时才会真的建,见 FB.canShow) */
  9113. FB.ensure();
  9114. /* 把登录页三态复位。不复位的话它会停在 verifying ——
  9115. * 一旦有别的路径重新渲染登录页(而不是走 showLogin),
  9116. * 用户看到的就是一个永远「登录中…」的界面。 */
  9117. Pages.LICENSE_RESET();
  9118. App.renderScreen();
  9119. if (!Device.allPermissionsGranted()) { App.openPermissions(); }
  9120. };
  9121. /**
  9122. * 提交卡密。
  9123. *
  9124. * 为什么中间要留 450ms:校验走本地时是毫秒级的,界面会从「登录激活」
  9125. * 直接跳到主界面,用户根本看不到反馈——会以为按钮没反应于是连点。
  9126. * 留这一帧让「登录中…」真的被画出来,是**交互正确性**而不是动画点缀。
  9127. */
  9128. App.submitLicense = function (code) {
  9129. var view = Pages.LICENSE_VIEW;
  9130. if (typeof code !== "string" || code.replace(/[\s-]/g, "") === "") {
  9131. toast("\u8BF7\u8F93\u5165\u5361\u5BC6");
  9132. return;
  9133. }
  9134. view.state = "verifying";
  9135. App.renderScreen();
  9136. setTimeout(function () {
  9137. var result = Service.login(code);
  9138. if (result.ok) {
  9139. Store.set("license.status", result.status);
  9140. Store.set("license.code", result.code);
  9141. Store.set("license.expireAt", result.expireAt);
  9142. /* ★ 这里**不要**去清 `license.draftCode`。
  9143. * 试过「激活成功后清空草稿」,真机上立刻被打回来:
  9144. * `App.enterApp()` → `renderContent()` 会先跑 `commitPendingInputs()`,
  9145. * 而登录页那个输入框此刻**还在待提交表里**,于是刚清掉的草稿
  9146. * 又被它自己的文字写回去(顺序反了,清理等于没做)。
  9147. * 保留草稿也更顺手:解绑后回到登录页,上次的卡密已经填好,一键即可重新激活;
  9148. * 它和 `license.code` 本来就在同一个 JSON 里,多留一份不增加暴露面。 */
  9149. toast(result.message);
  9150. App.enterApp();
  9151. return;
  9152. }
  9153. if (result.status === "expired") {
  9154. Store.set("license.status", "expired");
  9155. Store.set("license.expireAt", result.expireAt || "");
  9156. }
  9157. view.state = "failed";
  9158. view.message = result.message;
  9159. App.renderScreen();
  9160. }, 450);
  9161. };
  9162. /** 购买入口。没有可用的客服渠道就如实说,不编一个不存在的联系方式。 */
  9163. App.showPurchaseInfo = function () {
  9164. Dialog.alert("\u8D2D\u4E70\u5361\u5BC6",
  9165. "\u5F53\u524D\u672A\u914D\u7F6E\u5728\u7EBF\u8D2D\u4E70\u6E20\u9053\u3002\n\n" +
  9166. "\u914D\u7F6E\u540E\uFF0C\u8FD9\u91CC\u4F1A\u76F4\u63A5\u6253\u5F00\u5BA2\u670D\u4F1A\u8BDD\u6216\u8D2D\u4E70\u9875\u3002");
  9167. };
  9168. /** 退出应用。优先用 ui.finish(),它才是 UI 模式下的正确出口。 */
  9169. App.exit = function () {
  9170. /* 退出前必须关掉悬浮窗:脚本结束不会自动带走它,留着就是一个"孤儿球" */
  9171. FB.destroy();
  9172. try {
  9173. if (typeof ui.finish === "function") { ui.finish(); return; }
  9174. } catch (e) {
  9175. console.log("[app] ui.finish 不可用: " + e);
  9176. }
  9177. try {
  9178. activity.finish();
  9179. } catch (e2) {
  9180. console.log("[app] 退出失败: " + e2);
  9181. }
  9182. };
  9183. /* ------------------------------------------------------------ 结果文件 / 日志动作 */
  9184. /**
  9185. * 打开结果文件(D3 的「打开」)。
  9186. *
  9187. * 文件可能已经不在了(被清理/被移走),所以先 exists 再动手 ——
  9188. * 直接 startActivity 一个不存在的 file:// 在部分 ROM 上会抛 FileUriExposedException。
  9189. */
  9190. App.openResultFile = function (path) {
  9191. if (!path) { return; }
  9192. try {
  9193. if (!files.exists(path)) {
  9194. Dialog.alert("\u6587\u4EF6\u4E0D\u5B58\u5728",
  9195. "\u8FD9\u4E2A\u7ED3\u679C\u6587\u4EF6\u5DF2\u4E0D\u5728\u672C\u673A\uFF1A\n\n" + path);
  9196. return;
  9197. }
  9198. app.viewFile(path);
  9199. } catch (e) {
  9200. console.log("[app] 打开结果文件失败: " + e);
  9201. toast("\u65E0\u6CD5\u6253\u5F00\u8BE5\u6587\u4EF6");
  9202. }
  9203. };
  9204. /** 分享结果文件(D3 的「分享」)。系统分享面板由 ROM 提供,不自己造一个。 */
  9205. App.shareResultFile = function (path) {
  9206. if (!path) { return; }
  9207. try {
  9208. if (!files.exists(path)) {
  9209. toast("\u6587\u4EF6\u4E0D\u5B58\u5728\uFF0C\u65E0\u6CD5\u5206\u4EAB");
  9210. return;
  9211. }
  9212. app.sendIntent({
  9213. action: "android.intent.action.SEND",
  9214. type: "*/*",
  9215. extra: { "android.intent.extra.STREAM": path }
  9216. });
  9217. } catch (e) {
  9218. console.log("[app] 分享失败: " + e);
  9219. toast("\u5206\u4EAB\u5931\u8D25");
  9220. }
  9221. };
  9222. /**
  9223. * 复制整段日志(D3 的「复制」)。
  9224. *
  9225. * 拼成纯文本而不是 JSON:用户多半是要粘到聊天窗口里给人看,
  9226. * 带花括号的 JSON 反而难读。
  9227. */
  9228. App.copyLogs = function (record) {
  9229. if (!record) { return; }
  9230. var logs = record.logs || [];
  9231. if (logs.length === 0) {
  9232. toast("\u672C\u6B21\u8FD0\u884C\u6CA1\u6709\u65E5\u5FD7");
  9233. return;
  9234. }
  9235. var lines = [];
  9236. for (var i = 0; i < logs.length; i++) {
  9237. var e = logs[i];
  9238. lines.push(String(e.text || ""));
  9239. }
  9240. var text = Pages.recordTitle(record) + "\n" + Records.timeLine(record) + "\n\n" + lines.join("\n");
  9241. try {
  9242. /* setClip 是 AutoX 的全局函数;桩测环境没有它,所以带类型判断 */
  9243. if (typeof setClip === "function") {
  9244. setClip(text);
  9245. toast("\u65E5\u5FD7\u5DF2\u590D\u5236");
  9246. } else {
  9247. toast("\u5F53\u524D\u8FD0\u884C\u65F6\u4E0D\u652F\u6301\u526A\u8D34\u677F");
  9248. }
  9249. } catch (e2) {
  9250. console.log("[app] 复制日志失败: " + e2);
  9251. toast("\u590D\u5236\u5931\u8D25");
  9252. }
  9253. };
  9254. /* ------------------------------------------------------------ 启动 */
  9255. /**
  9256. * 挂载根视图。
  9257. * ui.setContentView 是标准 API;若运行时确实没有,宁可明确报错,
  9258. * 也不要退回一个半可用的挂载方式——那样界面起不来还找不到原因。
  9259. */
  9260. App.mount = function (root) {
  9261. if (typeof ui.setContentView !== "function") {
  9262. throw new Error("当前 AutoX 运行时缺少 ui.setContentView,无法挂载界面");
  9263. }
  9264. ui.setContentView(root);
  9265. };
  9266. /**
  9267. * 界面被关掉时,把悬浮球一起带走。
  9268. *
  9269. * ★ 为什么必须有这个:AutoX 的 UI 模式下**「Activity 关闭」不等于「脚本结束」**。
  9270. * 界面脚本跑在 `:script` 进程的线程里,用户从最近任务划掉界面(或按返回退出)后,
  9271. * 脚本仍在跑 —— 而悬浮球窗口是**脚本**持有的,于是球留在别的 App 上面,
  9272. * 看起来就是"应用都关了,球还浮着"。
  9273. * 实测(2026-10-22):前台已是 AutoX 的 MainActivity、`:script` 进程存活、
  9274. * dumpsys 里还有 8 个 autojs 窗口残留。
  9275. *
  9276. * 两条兜底,各自覆盖一种情况:
  9277. * ① `events.on("exit")` —— 脚本正常结束或抛异常时清一次。
  9278. * 进程被系统杀掉时走不到这里,但那种情况窗口会随进程一起被回收,不用我们管。
  9279. * ② 轮询 `activity` 是否已 finish —— **唯一能覆盖"界面关了但脚本还活着"的路径**,
  9280. * 也就是用户报的这个场景。
  9281. */
  9282. function bindLifecycleCleanup() {
  9283. try {
  9284. events.on("exit", function () {
  9285. try { FB.destroy(); } catch (e) { /* 退出路径上不能再抛 */ }
  9286. });
  9287. } catch (e) {
  9288. console.log("[app] events.on(exit) 不可用(不影响其它清理路径): " + e);
  9289. }
  9290. /* 只有"曾经拿到过 activity、后来它没了/在销毁"才算关闭。
  9291. * 这样在 PC 侧 smoke(没有 activity 这个全局)里不会误触发 exit()。 */
  9292. var sawActivity = false;
  9293. App.lifecycleTick = setInterval(function () {
  9294. var gone = false;
  9295. try {
  9296. if (typeof activity !== "undefined" && activity !== null) {
  9297. sawActivity = true;
  9298. gone = activity.isFinishing() || activity.isDestroyed();
  9299. } else if (sawActivity) {
  9300. gone = true;
  9301. }
  9302. } catch (e) {
  9303. /* 拿 activity 时抛异常 = 它已经没了 */
  9304. gone = sawActivity;
  9305. }
  9306. if (!gone) {
  9307. return;
  9308. }
  9309. try { clearInterval(App.lifecycleTick); } catch (ignored) { }
  9310. App.lifecycleTick = null;
  9311. console.log("[app] 界面已关闭,清理悬浮球并结束脚本");
  9312. try { FB.destroy(); } catch (e2) { /* 同上 */ }
  9313. try { exit(); } catch (e3) { /* 老版本 AutoX 可能没有 exit() */ }
  9314. }, 1500);
  9315. }
  9316. App.main = function () {
  9317. Store.init();
  9318. /* v8:把旧的四张卡配置翻译成「取材清单」(一次性,已有清单就不动)。
  9319. *
  9320. * 为什么放在这里而不是 Store.load 里:Store 不该知道 Sources 存在
  9321. * (清单是另一个储物柜),而且这里能保证**所有模块都已加载完**再动它。
  9322. * 迁移失败不许拦住界面 —— 最坏情况是用户看到空清单,自己加一条即可。 */
  9323. try {
  9324. var migrated = Sources.migrateLegacy();
  9325. if (migrated && migrated.length > 0) {
  9326. console.log("[app] 旧的四张卡配置已翻译成 " + migrated.length + " 条取材");
  9327. }
  9328. } catch (eMig) {
  9329. console.log("[app] 取材清单迁移失败(不影响使用,清单为空时可手动添加): " + eMig);
  9330. }
  9331. /* getEnum 保证拿回合法 tab key:旧配置里存了已删除的页签也不会白屏。
  9332. * 老页签(id/dm/nurture/video)由 Store.MIGRATIONS[5] 映射成 tasks(见 store.js)。 */
  9333. var configuredTab = Store.getEnum("activeTab", "tab");
  9334. App.activeTab = App.TAB_PAGES[configuredTab] ? configuredTab : "tasks";
  9335. /* 二级页也要能冷启动恢复 —— 但必须是**认识的那个任务**。
  9336. * 配置被手改过时回退到一级页,而不是渲染一个空白详情页。 */
  9337. var configuredDetail = Store.getEnum("activeDetail", ["", "id", "dm", "nurture", "video"]);
  9338. App.detail = App.DETAIL_PAGES[configuredDetail] ? configuredDetail : "";
  9339. /* 返回键:有弹窗就先关弹窗,其次退二级页,都没有则交给系统(退出界面)。
  9340. * 用 emitter 而不是 View.OnKeyListener —— 后者要构造一个 Java 接口实例,
  9341. * 写法依赖运行时的接口包装能力,换个 AutoX 版本就可能失效。 */
  9342. ui.emitter.on("back_pressed", function (event) {
  9343. if (Dialog.active) {
  9344. event.consumed = true;
  9345. Dialog.active.close();
  9346. return;
  9347. }
  9348. if (App.recordId) {
  9349. event.consumed = true;
  9350. App.closeRecord();
  9351. return;
  9352. }
  9353. if (App.detail) {
  9354. event.consumed = true;
  9355. App.closeDetail();
  9356. }
  9357. });
  9358. /* 界面回到前台时提醒一句(只在引擎正在跑时)。
  9359. * 这是 UI 生命周期事件,不是轮询:用户切走再切回才会触发,代价为零。 */
  9360. ui.emitter.on("resume", function () {
  9361. App.noticeIfRunningInBackground();
  9362. /* 补建:用户多半是先去系统设置里开了悬浮窗权限、再切回来的。
  9363. * ensure() 幂等,已经建好时只做一次状态同步。 */
  9364. FB.ensure();
  9365. });
  9366. ui.statusBarColor(U.argb(Theme.hex.bg));
  9367. App.mount(App.build());
  9368. /* 弹窗需要一个浮层宿主,骨架搭好之后注入 */
  9369. Dialog.mount(App.overlayHost);
  9370. /* 启动时的屏:卡密没激活就直接进登录页,不让用户先进一个什么都点不动的主界面 */
  9371. App.screen = Service.licenseStatus().status === "active" ? "app" : "login";
  9372. /* 冷启动也是"要用球"的场景:已激活时直接把它挂上(未激活时 canShow 自己会拦住) */
  9373. FB.ensure();
  9374. /* 界面关了要把球带走 —— 见 bindLifecycleCleanup 的注释 */
  9375. bindLifecycleCleanup();
  9376. App.renderScreen();
  9377. console.log("[app] 界面已就绪,screen=" + App.screen + " 页签=" + App.activeTab);
  9378. console.log("[app] 无障碍=" + Device.a11yEnabled() +
  9379. " 悬浮窗=" + Device.overlayEnabled() +
  9380. " 通知=" + Device.notifyEnabled());
  9381. if (App.screen === "app" && !Device.allPermissionsGranted()) {
  9382. App.openPermissions();
  9383. }
  9384. /* 落盘一份启动结论,便于 PC 侧离线核验(日志链路可能丢) */
  9385. try {
  9386. files.write("/sdcard/tiktok_app_ui_result.txt",
  9387. "OK_UI_STARTED\nscreen=" + App.screen +
  9388. "\ntab=" + App.activeTab +
  9389. "\na11y=" + Device.a11yEnabled() +
  9390. "\nlicense=" + Service.licenseStatus().status +
  9391. "\ntiktok=" + JSON.stringify(Device.tiktok()));
  9392. } catch (e) {
  9393. console.log("[app] 结果文件写入失败: " + e);
  9394. }
  9395. };
  9396. /* ---------------------------------------------------------------------------
  9397. * 测试钩子(规范 R16:让纯函数与内部状态可被单独驱动)
  9398. *
  9399. * PC 侧 tools/smoke.js 会先设 __FEIZHOU_TEST__ 再加载产物,
  9400. * 借此拿到 IIFE 内部的对象做断言。设备端不会设这个标志,
  9401. * 因此对实际运行零影响、零开销。
  9402. * ------------------------------------------------------------------------- */
  9403. if (typeof globalThis !== "undefined" && globalThis.__FEIZHOU_TEST__) {
  9404. globalThis.__FEIZHOU__ = {
  9405. Theme: Theme,
  9406. Store: Store,
  9407. Records: Records,
  9408. Sources: Sources,
  9409. Device: Device,
  9410. Service: Service,
  9411. Engine: Engine,
  9412. Session: Session,
  9413. Resume: Resume,
  9414. Fingerprint: Fingerprint,
  9415. W: W,
  9416. Dialog: Dialog,
  9417. Pages: Pages,
  9418. FB: FB,
  9419. App: App
  9420. };
  9421. }
  9422. /* 入口 */
  9423. App.main();
  9424. }());