tiktok-app.js 349 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132413341344135413641374138413941404141414241434144414541464147414841494150415141524153415441554156415741584159416041614162416341644165416641674168416941704171417241734174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198419942004201420242034204420542064207420842094210421142124213421442154216421742184219422042214222422342244225422642274228422942304231423242334234423542364237423842394240424142424243424442454246424742484249425042514252425342544255425642574258425942604261426242634264426542664267426842694270427142724273427442754276427742784279428042814282428342844285428642874288428942904291429242934294429542964297429842994300430143024303430443054306430743084309431043114312431343144315431643174318431943204321432243234324432543264327432843294330433143324333433443354336433743384339434043414342434343444345434643474348434943504351435243534354435543564357435843594360436143624363436443654366436743684369437043714372437343744375437643774378437943804381438243834384438543864387438843894390439143924393439443954396439743984399440044014402440344044405440644074408440944104411441244134414441544164417441844194420442144224423442444254426442744284429443044314432443344344435443644374438443944404441444244434444444544464447444844494450445144524453445444554456445744584459446044614462446344644465446644674468446944704471447244734474447544764477447844794480448144824483448444854486448744884489449044914492449344944495449644974498449945004501450245034504450545064507450845094510451145124513451445154516451745184519452045214522452345244525452645274528452945304531453245334534453545364537453845394540454145424543454445454546454745484549455045514552455345544555455645574558455945604561456245634564456545664567456845694570457145724573457445754576457745784579458045814582458345844585458645874588458945904591459245934594459545964597459845994600460146024603460446054606460746084609461046114612461346144615461646174618461946204621462246234624462546264627462846294630463146324633463446354636463746384639464046414642464346444645464646474648464946504651465246534654465546564657465846594660466146624663466446654666466746684669467046714672467346744675467646774678467946804681468246834684468546864687468846894690469146924693469446954696469746984699470047014702470347044705470647074708470947104711471247134714471547164717471847194720472147224723472447254726472747284729473047314732473347344735473647374738473947404741474247434744474547464747474847494750475147524753475447554756475747584759476047614762476347644765476647674768476947704771477247734774477547764777477847794780478147824783478447854786478747884789479047914792479347944795479647974798479948004801480248034804480548064807480848094810481148124813481448154816481748184819482048214822482348244825482648274828482948304831483248334834483548364837483848394840484148424843484448454846484748484849485048514852485348544855485648574858485948604861486248634864486548664867486848694870487148724873487448754876487748784879488048814882488348844885488648874888488948904891489248934894489548964897489848994900490149024903490449054906490749084909491049114912491349144915491649174918491949204921492249234924492549264927492849294930493149324933493449354936493749384939494049414942494349444945494649474948494949504951495249534954495549564957495849594960496149624963496449654966496749684969497049714972497349744975497649774978497949804981498249834984498549864987498849894990499149924993499449954996499749984999500050015002500350045005500650075008500950105011501250135014501550165017501850195020502150225023502450255026502750285029503050315032503350345035503650375038503950405041504250435044504550465047504850495050505150525053505450555056505750585059506050615062506350645065506650675068506950705071507250735074507550765077507850795080508150825083508450855086508750885089509050915092509350945095509650975098509951005101510251035104510551065107510851095110511151125113511451155116511751185119512051215122512351245125512651275128512951305131513251335134513551365137513851395140514151425143514451455146514751485149515051515152515351545155515651575158515951605161516251635164516551665167516851695170517151725173517451755176517751785179518051815182518351845185518651875188518951905191519251935194519551965197519851995200520152025203520452055206520752085209521052115212521352145215521652175218521952205221522252235224522552265227522852295230523152325233523452355236523752385239524052415242524352445245524652475248524952505251525252535254525552565257525852595260526152625263526452655266526752685269527052715272527352745275527652775278527952805281528252835284528552865287528852895290529152925293529452955296529752985299530053015302530353045305530653075308530953105311531253135314531553165317531853195320532153225323532453255326532753285329533053315332533353345335533653375338533953405341534253435344534553465347534853495350535153525353535453555356535753585359536053615362536353645365536653675368536953705371537253735374537553765377537853795380538153825383538453855386538753885389539053915392539353945395539653975398539954005401540254035404540554065407540854095410541154125413541454155416541754185419542054215422542354245425542654275428542954305431543254335434543554365437543854395440544154425443544454455446544754485449545054515452545354545455545654575458545954605461546254635464546554665467546854695470547154725473547454755476547754785479548054815482548354845485548654875488548954905491549254935494549554965497549854995500550155025503550455055506550755085509551055115512551355145515551655175518551955205521552255235524552555265527552855295530553155325533553455355536553755385539554055415542554355445545554655475548554955505551555255535554555555565557555855595560556155625563556455655566556755685569557055715572557355745575557655775578557955805581558255835584558555865587558855895590559155925593559455955596559755985599560056015602560356045605560656075608560956105611561256135614561556165617561856195620562156225623562456255626562756285629563056315632563356345635563656375638563956405641564256435644564556465647564856495650565156525653565456555656565756585659566056615662566356645665566656675668566956705671567256735674567556765677567856795680568156825683568456855686568756885689569056915692569356945695569656975698569957005701570257035704570557065707570857095710571157125713571457155716571757185719572057215722572357245725572657275728572957305731573257335734573557365737573857395740574157425743574457455746574757485749575057515752575357545755575657575758575957605761576257635764576557665767576857695770577157725773577457755776577757785779578057815782578357845785578657875788578957905791579257935794579557965797579857995800580158025803580458055806580758085809581058115812581358145815581658175818581958205821582258235824582558265827582858295830583158325833583458355836583758385839584058415842584358445845584658475848584958505851585258535854585558565857585858595860586158625863586458655866586758685869587058715872587358745875587658775878587958805881588258835884588558865887588858895890589158925893589458955896589758985899590059015902590359045905590659075908590959105911591259135914591559165917591859195920592159225923592459255926592759285929593059315932593359345935593659375938593959405941594259435944594559465947594859495950595159525953595459555956595759585959596059615962596359645965596659675968596959705971597259735974597559765977597859795980598159825983598459855986598759885989599059915992599359945995599659975998599960006001600260036004600560066007600860096010601160126013601460156016601760186019602060216022602360246025602660276028602960306031603260336034603560366037603860396040604160426043604460456046604760486049605060516052605360546055605660576058605960606061606260636064606560666067606860696070607160726073607460756076607760786079608060816082608360846085608660876088608960906091609260936094609560966097609860996100610161026103610461056106610761086109611061116112611361146115611661176118611961206121612261236124612561266127612861296130613161326133613461356136613761386139614061416142614361446145614661476148614961506151615261536154615561566157615861596160616161626163616461656166616761686169617061716172617361746175617661776178617961806181618261836184618561866187618861896190619161926193619461956196619761986199620062016202620362046205620662076208620962106211621262136214621562166217621862196220622162226223622462256226622762286229623062316232623362346235623662376238623962406241624262436244624562466247624862496250625162526253625462556256625762586259626062616262626362646265626662676268626962706271627262736274627562766277627862796280628162826283628462856286628762886289629062916292629362946295629662976298629963006301630263036304630563066307630863096310631163126313631463156316631763186319632063216322632363246325632663276328632963306331633263336334633563366337633863396340634163426343634463456346634763486349635063516352635363546355635663576358635963606361636263636364636563666367636863696370637163726373637463756376637763786379638063816382638363846385638663876388638963906391639263936394639563966397639863996400640164026403640464056406640764086409641064116412641364146415641664176418641964206421642264236424642564266427642864296430643164326433643464356436643764386439644064416442644364446445644664476448644964506451645264536454645564566457645864596460646164626463646464656466646764686469647064716472647364746475647664776478647964806481648264836484648564866487648864896490649164926493649464956496649764986499650065016502650365046505650665076508650965106511651265136514651565166517651865196520652165226523652465256526652765286529653065316532653365346535653665376538653965406541654265436544654565466547654865496550655165526553655465556556655765586559656065616562656365646565656665676568656965706571657265736574657565766577657865796580658165826583658465856586658765886589659065916592659365946595659665976598659966006601660266036604660566066607660866096610661166126613661466156616661766186619662066216622662366246625662666276628662966306631663266336634663566366637663866396640664166426643664466456646664766486649665066516652665366546655665666576658665966606661666266636664666566666667666866696670667166726673667466756676667766786679668066816682668366846685668666876688668966906691669266936694669566966697669866996700670167026703670467056706670767086709671067116712671367146715671667176718671967206721672267236724672567266727672867296730673167326733673467356736673767386739674067416742674367446745674667476748674967506751675267536754675567566757675867596760676167626763676467656766676767686769677067716772677367746775677667776778677967806781678267836784678567866787678867896790679167926793679467956796679767986799680068016802680368046805680668076808680968106811681268136814681568166817681868196820682168226823682468256826682768286829683068316832683368346835683668376838683968406841684268436844684568466847684868496850685168526853685468556856685768586859686068616862686368646865686668676868686968706871687268736874687568766877687868796880688168826883688468856886688768886889689068916892689368946895689668976898689969006901690269036904690569066907690869096910691169126913691469156916691769186919692069216922692369246925692669276928692969306931693269336934693569366937693869396940694169426943694469456946694769486949695069516952695369546955695669576958695969606961696269636964696569666967696869696970697169726973697469756976697769786979698069816982698369846985698669876988698969906991699269936994699569966997699869997000700170027003700470057006700770087009701070117012701370147015701670177018701970207021702270237024702570267027702870297030703170327033703470357036703770387039704070417042704370447045704670477048704970507051705270537054705570567057705870597060706170627063706470657066706770687069707070717072707370747075707670777078707970807081708270837084708570867087708870897090709170927093709470957096709770987099710071017102710371047105710671077108710971107111711271137114711571167117711871197120712171227123712471257126712771287129713071317132713371347135713671377138713971407141714271437144714571467147714871497150715171527153715471557156715771587159716071617162716371647165716671677168716971707171717271737174717571767177717871797180718171827183718471857186718771887189719071917192719371947195719671977198719972007201720272037204720572067207720872097210721172127213721472157216721772187219722072217222722372247225722672277228722972307231723272337234723572367237723872397240724172427243724472457246724772487249725072517252725372547255725672577258725972607261726272637264726572667267726872697270727172727273727472757276727772787279728072817282728372847285728672877288728972907291729272937294729572967297729872997300730173027303730473057306730773087309731073117312731373147315731673177318731973207321732273237324732573267327732873297330733173327333733473357336733773387339734073417342734373447345734673477348734973507351735273537354735573567357735873597360736173627363736473657366736773687369737073717372737373747375737673777378737973807381738273837384738573867387738873897390739173927393739473957396739773987399740074017402740374047405740674077408740974107411741274137414741574167417741874197420742174227423742474257426742774287429743074317432743374347435743674377438743974407441744274437444744574467447744874497450745174527453745474557456745774587459746074617462746374647465746674677468746974707471747274737474747574767477747874797480748174827483748474857486748774887489749074917492749374947495749674977498749975007501750275037504750575067507750875097510751175127513751475157516751775187519752075217522752375247525752675277528752975307531753275337534753575367537753875397540754175427543754475457546754775487549755075517552755375547555755675577558755975607561756275637564756575667567756875697570757175727573757475757576757775787579758075817582758375847585758675877588758975907591759275937594759575967597759875997600760176027603760476057606760776087609761076117612761376147615761676177618761976207621762276237624762576267627762876297630763176327633763476357636763776387639764076417642764376447645764676477648764976507651765276537654765576567657765876597660766176627663766476657666766776687669767076717672767376747675767676777678767976807681768276837684768576867687768876897690769176927693769476957696769776987699770077017702770377047705770677077708770977107711771277137714771577167717771877197720772177227723772477257726772777287729773077317732773377347735773677377738773977407741774277437744774577467747774877497750775177527753775477557756775777587759776077617762776377647765776677677768776977707771777277737774777577767777777877797780778177827783778477857786778777887789779077917792779377947795779677977798779978007801780278037804780578067807780878097810781178127813781478157816781778187819782078217822782378247825782678277828782978307831783278337834783578367837783878397840784178427843784478457846784778487849785078517852785378547855785678577858785978607861786278637864786578667867786878697870787178727873787478757876787778787879788078817882788378847885788678877888788978907891789278937894789578967897789878997900790179027903790479057906790779087909791079117912791379147915791679177918791979207921792279237924792579267927792879297930793179327933793479357936793779387939794079417942794379447945794679477948794979507951795279537954795579567957795879597960796179627963796479657966796779687969797079717972797379747975797679777978797979807981798279837984798579867987798879897990799179927993799479957996799779987999800080018002800380048005800680078008800980108011801280138014801580168017801880198020802180228023802480258026802780288029803080318032803380348035803680378038803980408041804280438044804580468047804880498050805180528053805480558056805780588059806080618062806380648065806680678068806980708071807280738074807580768077807880798080808180828083808480858086808780888089809080918092809380948095809680978098809981008101810281038104810581068107810881098110811181128113811481158116811781188119812081218122812381248125812681278128812981308131813281338134813581368137813881398140814181428143814481458146814781488149815081518152815381548155815681578158815981608161816281638164816581668167816881698170817181728173817481758176817781788179818081818182818381848185818681878188818981908191819281938194819581968197819881998200820182028203820482058206820782088209821082118212821382148215821682178218821982208221822282238224822582268227822882298230823182328233823482358236823782388239824082418242824382448245824682478248824982508251825282538254825582568257825882598260826182628263826482658266826782688269827082718272827382748275827682778278827982808281828282838284828582868287828882898290829182928293829482958296829782988299830083018302830383048305830683078308830983108311831283138314831583168317831883198320832183228323832483258326832783288329833083318332833383348335833683378338833983408341834283438344834583468347834883498350835183528353835483558356835783588359836083618362836383648365836683678368836983708371837283738374837583768377837883798380838183828383838483858386838783888389839083918392839383948395839683978398839984008401840284038404840584068407840884098410841184128413841484158416841784188419842084218422842384248425842684278428842984308431843284338434843584368437843884398440844184428443844484458446844784488449845084518452845384548455845684578458845984608461846284638464846584668467846884698470847184728473847484758476847784788479848084818482848384848485848684878488848984908491849284938494849584968497849884998500850185028503850485058506850785088509851085118512851385148515851685178518851985208521852285238524852585268527852885298530853185328533853485358536853785388539854085418542854385448545854685478548854985508551855285538554855585568557855885598560856185628563856485658566856785688569857085718572857385748575857685778578857985808581858285838584858585868587858885898590859185928593859485958596859785988599860086018602860386048605860686078608860986108611861286138614861586168617861886198620862186228623862486258626862786288629863086318632863386348635863686378638863986408641864286438644864586468647864886498650865186528653865486558656865786588659
  1. "ui";
  2. /*! tiktok-app v2026.10.13
  3. * built 2026-10-15T10:12:23.961Z
  4. * modules: theme, store, records, device, service, engine, session, 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. Store.SCHEMA_VERSION = 6;
  398. Store.STORAGE_NAME = "feizhou_app";
  399. Store.STORAGE_KEY = "config";
  400. /** 业务文本文件的约定目录。界面上的「选择文件」默认指向这里。 */
  401. Store.TEXT_DIR = "/sdcard/Download/tiktok_app";
  402. /* ------------------------------------------------ 下拉/开关的候选值集中定义 */
  403. Store.options = {
  404. dedupe: ["ID 模式", "UID 模式"],
  405. enterPage: ["粉丝页", "评论页", "主页", "关注页"],
  406. aiGender: ["男", "女"],
  407. toggle: ["开启", "关闭"],
  408. dedupeMode: ["关注+私信去重", "仅关注去重", "仅私信去重"],
  409. filterDate: ["7 天内", "30 天内", "不限"],
  410. firstAction: ["都执行", "仅点赞", "仅评论"],
  411. langRegion: ["通杀", "仅中文", "仅英文"],
  412. nicknameCheck: ["通杀", "仅检测昵称", "仅检测个签"],
  413. /* 点赞 / 评论 / 关注概率。设计稿把它们做成固定档位 ——
  414. * 养号动作的强度直接决定账号安全,给几个安全档位比让用户随手填 90% 更负责。 */
  415. likeRate: ["10%", "20%", "30%", "50%"],
  416. commentRate: ["5%", "10%", "20%", "30%"],
  417. followRate: ["5%", "10%", "15%", "30%"]
  418. };
  419. /* 候选值 ↔ 存储值的双向映射。
  420. * 界面显示中文,存储里存稳定英文 key——改文案时不会把老数据作废。 */
  421. Store.enum = {
  422. dedupe: { "id": "ID 模式", "text": "UID 模式" },
  423. /* 「关注页」是脚本三要用的(设计稿 16:533 的下拉就是它)。
  424. * 新增候选一律**追加在末尾**:位置即下拉里的顺序,插在中间会让老用户
  425. * 下次点开下拉时发现选项换了地方。 */
  426. enterPage: { "fans": "粉丝页", "comment": "评论页", "home": "主页", "follow": "关注页" },
  427. aiGender: { "male": "男", "female": "女" },
  428. toggle: { "on": "开启", "off": "关闭" },
  429. dedupeMode: { "follow_dm": "关注+私信去重", "follow": "仅关注去重", "dm": "仅私信去重" },
  430. filterDate: { "7d": "7 天内", "30d": "30 天内", "all": "不限" },
  431. firstAction: { "both": "都执行", "like": "仅点赞", "comment": "仅评论" },
  432. langRegion: { "any": "通杀", "zh": "仅中文", "en": "仅英文" },
  433. nicknameCheck: { "any": "通杀", "nick": "仅检测昵称", "sign": "仅检测个签" },
  434. likeRate: { "r10": "10%", "r20": "20%", "r30": "30%", "r50": "50%" },
  435. commentRate: { "r5": "5%", "r10": "10%", "r20": "20%", "r30": "30%" },
  436. followRate: { "r5": "5%", "r10": "10%", "r15": "15%", "r30": "30%" },
  437. licenseStatus: { "none": "未登录", "active": "已登录", "expired": "已过期" },
  438. /* ★ 2026-10-13 规格书 §1:底栏只剩 **3 个 tab**(任务 / 记录 / 我的)。
  439. * 原来的 id/dm/nurture/video 四个页签降级为「任务」tab 里的四张任务卡,
  440. * 各自的详情页 (C1/C6/C8/C9) 变成二级页。v6 迁移负责把老存档的
  441. * activeTab(例如 "nurture")映射成 "tasks",否则老用户启动会落回默认页。 */
  442. tab: { "tasks": "任务", "records": "记录", "mine": "我的" }
  443. };
  444. /** 某组枚举的全部 key。 */
  445. function enumKeys(group) {
  446. var map = Store.enum[group] || {};
  447. var out = [];
  448. for (var k in map) {
  449. if (map.hasOwnProperty(k)) { out.push(k); }
  450. }
  451. return out;
  452. }
  453. /**
  454. * 枚举 key → 显示文案。
  455. *
  456. * 关键兜底:key 缺失或非法时**回退到该枚举的第一项**,绝不返回 "undefined"。
  457. * 选择框显示成空白或 "undefined" 是用户最容易看到、也最说不清的一类 bug,
  458. * 在数据层一次堵死比在每个页面各写一遍兜底可靠。
  459. */
  460. Store.label = function (group, key) {
  461. var map = Store.enum[group];
  462. if (!map) {
  463. return key === undefined || key === null ? "" : String(key);
  464. }
  465. if (key === undefined || key === null || map[key] === undefined) {
  466. var first = enumKeys(group)[0];
  467. return first === undefined ? "" : map[first];
  468. }
  469. return map[key];
  470. };
  471. /** 当前值在候选列表里的位置,供下拉框设定初始选中项。 */
  472. Store.indexOfLabel = function (group, key) {
  473. var list = Store.options[group] || [];
  474. return Math.max(0, list.indexOf(Store.label(group, key)));
  475. };
  476. /** 依位置取回 key,供下拉框选中后写回。 */
  477. Store.keyAt = function (group, index) {
  478. var list = Store.options[group] || [];
  479. var label = list[index];
  480. var map = Store.enum[group] || {};
  481. for (var k in map) {
  482. if (map.hasOwnProperty(k) && map[k] === label) { return k; }
  483. }
  484. return label;
  485. };
  486. /* ------------------------------------------------------------ 字段声明表 */
  487. /** 生成「勾选 + 展开」两个字段——所有脚本卡都有这两个开关。 */
  488. function cardFields(basePath, checked, expanded) {
  489. return [
  490. { path: basePath + ".checked", type: "bool", def: !!checked },
  491. { path: basePath + ".expanded", type: "bool", def: !!expanded }
  492. ];
  493. }
  494. var F = [];
  495. /* ——— 全局 ——— */
  496. F.push(
  497. /* values 与 Store.enum.tab 必须一致(曾经少了 "mine":
  498. * 切到「我的」后重启会落回 ID提取,因为写入端的归一化认不出这个值)。
  499. * v6 起是三个 tab:任务 / 记录 / 我的。 */
  500. { path: "activeTab", type: "enum", values: ["tasks", "records", "mine"], def: "tasks" },
  501. /* 二级页(任务详情 / 运行详情)也落盘:用户切走再回来,还停在原来那个详情页。
  502. * 存 key 而不是存 View —— 值域与「任务清单」一一对应,非法值一律回退空串(= 一级页)。 */
  503. { path: "activeDetail", type: "enum", values: ["", "id", "dm", "nurture", "video"], def: "" },
  504. { path: "license.code", type: "text", def: "" },
  505. /* 登录页输入框里的卡密**草稿**:手打 16 位卡密时被系统回收进程,重开还在。
  506. * 激活成功后**不清它**:解绑后回到登录页,上次的卡密已经填好,一键即可重新激活
  507. * (它和 `license.code` 在同一个 JSON 里,多留一份不增加暴露面)。
  508. * 顺带避开一个坑:清它会被登录页输入框的「切屏前整体提交」又写回来。 */
  509. { path: "license.draftCode", type: "text", def: "" },
  510. { path: "license.status", type: "enum", values: ["none", "active", "expired"], def: "none" },
  511. { path: "license.expireAt", type: "text", def: "" }
  512. );
  513. /* ——— 全局悬浮球(规格书 §10 / H1–H7)———
  514. * 三个字段都是「记忆用户的选择」,没有它们球每次都会跑回默认位置:
  515. * · hidden —— 用户点了「隐藏悬浮球」。没有它就只能靠"下次启动又冒出来",
  516. * 而那是最招人烦的一种行为;恢复入口在「我的」页(本项目补充,规格书未写)。
  517. * · side / yRatio —— 拖动吸附后的位置。存**比例**而不是像素:换机型、
  518. * 换分辨率、旋转后仍然落在相对合理的位置(存像素就会出现"球跑到屏幕外")。 */
  519. F.push(
  520. { path: "ball.hidden", type: "bool", def: false },
  521. { path: "ball.side", type: "enum", values: ["left", "right"], def: "left" },
  522. { path: "ball.yRatio", type: "int", min: 0, max: 1000, def: 500 }
  523. );
  524. /* ——— ID提取页 ———
  525. * 四张卡是**单选组**,默认一个都不选:设计稿 16:23 专门画了「未选择脚本」
  526. * 这一屏(并配了「请先选择要运行的脚本」的拦截弹窗),说明"还没选"是一个
  527. * 真实且合法的初始状态。四个选项里替用户随便挑一个,反而是越权。
  528. * 脚本一默认展开,因为它承载的设置行最多,收起会让人以为它没内容。 */
  529. F.push.apply(F, cardFields("tabs.id.scripts.s1", false, true));
  530. F.push(
  531. /* ★ 2026-10-13 规格书 §3 新增的两项(画布 C1 的脚本一展开态):
  532. * 「自定关注或粉丝页面提 ID」默认✓、「自定页面提 ID」默认✗。
  533. * 它们是**取材入口**的两个开关,与「进入页面」下拉不是一回事:
  534. * 前者决定要不要走自定义入口,后者决定进哪一页。 */
  535. { path: "tabs.id.scripts.s1.customFansPage", type: "bool", def: true },
  536. { path: "tabs.id.scripts.s1.customPage", type: "bool", def: false },
  537. /* 去重是个开关,去重方式才是值 —— 早先只有后者,用户没法把去重整个关掉。 */
  538. { path: "tabs.id.scripts.s1.dedupeEnabled", type: "bool", def: true },
  539. { path: "tabs.id.scripts.s1.dedupe", type: "enum", group: "dedupe", def: "id" },
  540. { path: "tabs.id.scripts.s1.enterPage", type: "enum", group: "enterPage", def: "fans" },
  541. /* 主播 ID:可填多个,用 # 隔开(设计稿 16:479 的输入提示)。
  542. * 留空 = 不限定主播、从页面现场取 —— 这是真实且合理的状态,所以标 optional,
  543. * 而不是塞一个假默认值(审计靠这个标记把「合理的空」和「漏了默认值」分开)。 */
  544. { path: "tabs.id.scripts.s1.anchorIds", type: "text", def: "", optional: true },
  545. { path: "tabs.id.scripts.s1.useTextId", type: "bool", def: false },
  546. { path: "tabs.id.scripts.s1.textIdPath", type: "text", def: Store.TEXT_DIR + "/uid_list.txt" }
  547. );
  548. /* ——— 脚本二 · 评论页提 ID(设计稿 16:483)——— */
  549. F.push.apply(F, cardFields("tabs.id.scripts.s2", false, false));
  550. F.push(
  551. { path: "tabs.id.scripts.s2.customCommentPage", type: "bool", def: true },
  552. { path: "tabs.id.scripts.s2.commenterFans", type: "bool", def: false }
  553. );
  554. /* ——— 脚本三 · 文本 UID 裂变提 ID(设计稿 16:483)——— */
  555. F.push.apply(F, cardFields("tabs.id.scripts.s3", false, false));
  556. F.push(
  557. { path: "tabs.id.scripts.s3.uidFission", type: "bool", def: true },
  558. { path: "tabs.id.scripts.s3.uidDedupe", type: "bool", def: true },
  559. /* 脚本三默认从「关注页」进 —— 与脚本一的「粉丝页」不同,这是设计稿的取值 */
  560. { path: "tabs.id.scripts.s3.enterPage", type: "enum", group: "enterPage", def: "follow" },
  561. { path: "tabs.id.scripts.s3.fissionTextPath", type: "text", def: Store.TEXT_DIR + "/uid_list.txt" }
  562. );
  563. /* ——— 脚本四 · 关键词视频提 ID(设计稿 16:589)———
  564. * 「关键词」给的是可直接跑的业务默认值而不是空串:空着的输入框既看不出该填什么,
  565. * 也让「未配置」和「配了空值」无法区分(与视频功能页的关键词同一套规则)。 */
  566. F.push.apply(F, cardFields("tabs.id.scripts.s4", false, false));
  567. F.push(
  568. { path: "tabs.id.scripts.s4.keywordEnabled", type: "bool", def: true },
  569. { path: "tabs.id.scripts.s4.keyword", type: "text", def: "fashion" },
  570. /* 三个「提取…」是额外的取材范围,默认都关:默认行为只剩「提视频作者的 ID」这一件事,
  571. * 最可预期。要更多来源自己勾 —— 反过来(默认全开)会让人不知道它到底抓了什么。 */
  572. { path: "tabs.id.scripts.s4.anchorFans", type: "bool", def: false },
  573. { path: "tabs.id.scripts.s4.commentArea", type: "bool", def: false },
  574. { path: "tabs.id.scripts.s4.commenterFans", type: "bool", def: false }
  575. );
  576. /* ID提取页的「功能设置」(设计稿 16:733)。
  577. * 门槛类字段(关注量/粉丝量/获赞量/视频量)按「一个控件一个数」拆成 min/max 两项,
  578. * 而不是设计稿里的 0 ~ 500000 一个控件 —— 与 UID私信页的上限处理保持同一套规则。 */
  579. F.push(
  580. { path: "tabs.id.features.filterPrivate", type: "bool", def: true },
  581. { path: "tabs.id.features.filterNoAvatar", type: "bool", def: true },
  582. { path: "tabs.id.features.regionCodes", type: "text", def: "JP#TW#US" },
  583. { path: "tabs.id.features.langRegion", type: "enum", group: "langRegion", def: "any" },
  584. { path: "tabs.id.features.nicknameCheck", type: "enum", group: "nicknameCheck", def: "any" },
  585. { path: "tabs.id.features.cloudDedupe", type: "bool", def: false },
  586. /* optional 标记的字段允许默认空串:云端去重默认是关的,
  587. * 「还没填表名」是一个真实且合理的状态,不该被塞一个假默认值。
  588. * 取值审计靠这个标记把它和「漏了默认值」区分开。 */
  589. { path: "tabs.id.features.cloudTable", type: "text", def: "", optional: true },
  590. { path: "tabs.id.features.aiAvatar", type: "bool", def: false },
  591. { path: "tabs.id.features.aiGender", type: "enum", group: "aiGender", def: "male" },
  592. { path: "tabs.id.features.aiAgeMax", type: "int", min: 0, max: 100, def: 20 },
  593. { path: "tabs.id.features.followMin", type: "int", min: 0, max: 100000000, def: 0 },
  594. { path: "tabs.id.features.followMax", type: "int", min: 0, max: 100000000, def: 500000 },
  595. { path: "tabs.id.features.fansMin", type: "int", min: 0, max: 100000000, def: 0 },
  596. { path: "tabs.id.features.fansMax", type: "int", min: 0, max: 100000000, def: 500000 },
  597. { path: "tabs.id.features.likesMin", type: "int", min: 0, max: 100000000, def: 0 },
  598. { path: "tabs.id.features.likesMax", type: "int", min: 0, max: 100000000, def: 500000 },
  599. { path: "tabs.id.features.videosMin", type: "int", min: 0, max: 100000000, def: 0 },
  600. { path: "tabs.id.features.videosMax", type: "int", min: 0, max: 100000000, def: 500000 },
  601. /* 0 = 不限制。用「0 表示不限」而不是留空,是因为数字输入框留空无法表达。 */
  602. { path: "tabs.id.features.extractLimit", type: "int", min: 0, max: 100000, def: 0 }
  603. );
  604. /* ——— UID私信页 ——— */
  605. F.push.apply(F, cardFields("tabs.dm.scripts.s1", true, true));
  606. F.push(
  607. { path: "tabs.dm.scripts.s1.doDm", type: "bool", def: true },
  608. { path: "tabs.dm.scripts.s1.filterDate", type: "enum", group: "filterDate", def: "7d" },
  609. { path: "tabs.dm.scripts.s1.firstAction", type: "enum", group: "firstAction", def: "both" },
  610. { path: "tabs.dm.scripts.s1.uidTextPath", type: "text", def: Store.TEXT_DIR + "/uid_list.txt" }
  611. );
  612. F.push.apply(F, cardFields("tabs.dm.scripts.s2", false, false));
  613. F.push(
  614. { path: "tabs.dm.aiGender", type: "enum", group: "aiGender", def: "male" },
  615. /* 关注与私信是两个独立动作,各自一个开关 + 一个数量区间。
  616. * 早先把它们挤成「进行关注 / 进行私信」一个开关、「200 / 30」一个控件,
  617. * 结果是想单独关掉私信就得连关注一起关掉。
  618. * 数量本身是**区间**(最少关注多少、最多关注多少),所以是一行两项。 */
  619. { path: "tabs.dm.doFollow", type: "bool", def: true },
  620. { path: "tabs.dm.followMin", type: "int", min: 0, max: 5000, def: 100 },
  621. { path: "tabs.dm.followMax", type: "int", min: 0, max: 5000, def: 200 },
  622. { path: "tabs.dm.doDm", type: "bool", def: true },
  623. { path: "tabs.dm.dmMin", type: "int", min: 0, max: 1000, def: 20 },
  624. { path: "tabs.dm.dmMax", type: "int", min: 0, max: 1000, def: 30 },
  625. { path: "tabs.dm.restMin", type: "int", min: 0, max: 3600, def: 100 },
  626. { path: "tabs.dm.restMax", type: "int", min: 0, max: 3600, def: 500 },
  627. { path: "tabs.dm.dedupeMode", type: "enum", group: "dedupeMode", def: "follow_dm" },
  628. { path: "tabs.dm.dmTextPath", type: "text", def: Store.TEXT_DIR + "/dm_text.txt" },
  629. { path: "tabs.dm.commentTextPath", type: "text", def: Store.TEXT_DIR + "/comment_text.txt" }
  630. );
  631. /* ——— 养号页 ———
  632. * 参数与脚本卡分开存:即使不勾选脚本,用户也能先把这组参数调好。
  633. * 浏览时长与单次操作都是区间(两个数),概率仍然是固定档位。 */
  634. F.push.apply(F, cardFields("tabs.nurture.scripts.s1", true, false));
  635. F.push(
  636. { path: "tabs.nurture.browseMin", type: "int", min: 0, max: 600, def: 3 },
  637. { path: "tabs.nurture.browseMax", type: "int", min: 0, max: 600, def: 8 },
  638. { path: "tabs.nurture.likeRate", type: "enum", group: "likeRate", def: "r30" },
  639. { path: "tabs.nurture.commentRate", type: "enum", group: "commentRate", def: "r10" },
  640. { path: "tabs.nurture.followRate", type: "enum", group: "followRate", def: "r15" },
  641. { path: "tabs.nurture.actionMin", type: "int", min: 0, max: 500, def: 10 },
  642. { path: "tabs.nurture.actionMax", type: "int", min: 0, max: 500, def: 30 }
  643. );
  644. /* ——— 视频功能页 ———
  645. * keyword / region 给的是**可直接跑的业务默认值**而不是空串:
  646. * 空着的输入框既看不出该填什么,也让「未配置」和「配了空值」无法区分。 */
  647. F.push.apply(F, cardFields("tabs.video.scripts.s1", false, false));
  648. F.push(
  649. { path: "tabs.video.keyword", type: "text", def: "fashion" },
  650. { path: "tabs.video.region", type: "text", def: "US" },
  651. { path: "tabs.video.stopMin", type: "int", min: 1, max: 500, def: 1 },
  652. { path: "tabs.video.stopMax", type: "int", min: 1, max: 500, def: 5 },
  653. { path: "tabs.video.intervalMin", type: "int", min: 0, max: 3600, def: 20 },
  654. { path: "tabs.video.intervalMax", type: "int", min: 0, max: 3600, def: 20 },
  655. { path: "tabs.video.actions.follow", type: "bool", def: true },
  656. { path: "tabs.video.actions.dm", type: "bool", def: true },
  657. { path: "tabs.video.actions.like", type: "bool", def: false },
  658. { path: "tabs.video.actions.comment", type: "bool", def: false },
  659. { path: "tabs.video.dmTextPath", type: "text", def: Store.TEXT_DIR + "/dm_text.txt" },
  660. { path: "tabs.video.commentTextPath", type: "text", def: Store.TEXT_DIR + "/comment_text.txt" }
  661. );
  662. Store.FIELDS = F;
  663. /**
  664. * 跨字段约束:上界不得小于下界。
  665. * 单字段的 min/max 表达不了这种关系,所以单独列出来做后处理。
  666. *
  667. * 界面上每一个 `W.rangeRow` 都必须在下面有一对 ——
  668. * 漏一条的后果是「最小值 500 / 最大值 100」这种自相矛盾的组合能存进配置,
  669. * 而且因为区间行的两个数在**同一行**,错位比拆两行时更刺眼。
  670. */
  671. Store.CROSS_RULES = [
  672. { low: "tabs.dm.followMin", high: "tabs.dm.followMax" },
  673. { low: "tabs.dm.dmMin", high: "tabs.dm.dmMax" },
  674. { low: "tabs.dm.restMin", high: "tabs.dm.restMax" },
  675. { low: "tabs.nurture.browseMin", high: "tabs.nurture.browseMax" },
  676. { low: "tabs.nurture.actionMin", high: "tabs.nurture.actionMax" },
  677. { low: "tabs.video.stopMin", high: "tabs.video.stopMax" },
  678. { low: "tabs.video.intervalMin", high: "tabs.video.intervalMax" },
  679. { low: "tabs.id.features.followMin", high: "tabs.id.features.followMax" },
  680. { low: "tabs.id.features.fansMin", high: "tabs.id.features.fansMax" },
  681. { low: "tabs.id.features.likesMin", high: "tabs.id.features.likesMax" },
  682. { low: "tabs.id.features.videosMin", high: "tabs.id.features.videosMax" }
  683. ];
  684. /* ---------------------------------------------------------------- 路径工具 */
  685. function isPlainObject(value) {
  686. return value !== null && typeof value === "object" && !(value instanceof Array);
  687. }
  688. function readPath(obj, path) {
  689. var segs = String(path).split(".");
  690. var node = obj;
  691. for (var i = 0; i < segs.length; i++) {
  692. if (node === null || typeof node !== "object") { return undefined; }
  693. node = node[segs[i]];
  694. }
  695. return node;
  696. }
  697. function assignPath(obj, path, value) {
  698. var segs = String(path).split(".");
  699. var node = obj;
  700. for (var i = 0; i < segs.length - 1; i++) {
  701. if (!isPlainObject(node[segs[i]])) { node[segs[i]] = {}; }
  702. node = node[segs[i]];
  703. }
  704. node[segs[segs.length - 1]] = value;
  705. return obj;
  706. }
  707. /* ---------------------------------------------------------------- 默认值 */
  708. /** 从字段表推导默认配置——默认值只有这一处来源,不会与校验规则漂移。 */
  709. Store.defaults = function () {
  710. var cfg = { version: Store.SCHEMA_VERSION };
  711. for (var i = 0; i < Store.FIELDS.length; i++) {
  712. var f = Store.FIELDS[i];
  713. assignPath(cfg, f.path, f.def);
  714. }
  715. return cfg;
  716. };
  717. /* ---------------------------------------------------------------- 版本迁移 */
  718. /**
  719. * 逐版本迁移。键是「从哪个版本升上来」,函数把配置就地改造成下一版的样子。
  720. *
  721. * 参数说明:
  722. * cfg —— 已经按当前字段表归一化过的配置(结构完整、类型正确)
  723. * src —— **原始输入**。有些旧字段在新版字段表里已被删除,
  724. * 归一化后 cfg 里就没有它了,只能从 src 里读回来。
  725. *
  726. * 为什么必须有迁移:类型归一化只能处理「类型不对」,
  727. * 处理不了「语义变了」——例如 v1 的空串其实等于未设置、
  728. * v2 的一个开关其实要拆成两个。这类差异只有迁移能表达。
  729. */
  730. Store.MIGRATIONS = {
  731. 1: function (cfg, problems) {
  732. /* v1 里这几项的默认值都是空串,界面上表现为空框 */
  733. var emptyInV1 = [
  734. "tabs.video.keyword",
  735. "tabs.video.region",
  736. "tabs.id.scripts.s1.textIdPath",
  737. "tabs.dm.scripts.s1.uidTextPath",
  738. "tabs.dm.dmTextPath",
  739. "tabs.dm.commentTextPath",
  740. "tabs.video.dmTextPath",
  741. "tabs.video.commentTextPath"
  742. ];
  743. for (var i = 0; i < emptyInV1.length; i++) {
  744. var path = emptyInV1[i];
  745. if (readPath(cfg, path) === "") {
  746. var def = Store.defaultOf(path);
  747. if (def) {
  748. assignPath(cfg, path, def);
  749. problems.push(path + " 旧版为空,已补默认值 " + def);
  750. }
  751. }
  752. }
  753. },
  754. 2: function (cfg, problems, src) {
  755. /* v2 用一个 doFollowDm 同时控制关注与私信,v3 拆成两个独立开关。
  756. * 旧值同时赋给两者,保证升级后行为与升级前一致(都是开就是都开)。 */
  757. var legacy = readPath(src, "tabs.dm.doFollowDm");
  758. if (typeof legacy === "boolean") {
  759. assignPath(cfg, "tabs.dm.doFollow", legacy);
  760. assignPath(cfg, "tabs.dm.doDm", legacy);
  761. problems.push("tabs.dm.doFollowDm 已拆为「进行关注」「进行私信」两个开关,旧值 " +
  762. legacy + " 已沿用");
  763. }
  764. },
  765. /**
  766. * v4 → v5:五处「一个值」改成「一个区间」。
  767. *
  768. * 迁移原则是**不改变原有行为**,具体分三类:
  769. * · 原本是「上限」的(关注上限 / 私信上限 / 单次操作上限)
  770. * → min 给 0(= 不设下界),max 沿用旧上限。
  771. * 旧语义就是「最多 N 个」,升级后仍然是最多 N 个。
  772. * · 原本是「精确个数」的(停止个数)
  773. * → min = max = 旧值。这跟设计稿里「间隔时间 20 ~ 20」是同一个表达:
  774. * 相等的上下界就是定值。
  775. * · 原本是「档位」的(浏览时长 3 ~ 8 这类)
  776. * → 直接取该档的上下界,一一对应。
  777. * 全新安装拿到的则是设计稿给的默认区间(例如 关注数量 100 ~ 200)。
  778. */
  779. 4: function (cfg, problems, src) {
  780. /* 旧档位 → 区间。两组档位的 key 本身不重叠(d3_8… / n10…),
  781. * 所以用一张平表就够,不必再按字段名分一层。 */
  782. var RANGE_OF = {
  783. "d3_8": [3, 8], "d5_10": [5, 10], "d8_15": [8, 15],
  784. "n10": [0, 10], "n20": [0, 20], "n30": [0, 30], "n50": [0, 50]
  785. };
  786. inheritRange(src, cfg, problems, "tabs.dm.followLimit", 0, "upper",
  787. "tabs.dm.followMin", "tabs.dm.followMax", "关注数量");
  788. inheritRange(src, cfg, problems, "tabs.dm.dmLimit", 0, "upper",
  789. "tabs.dm.dmMin", "tabs.dm.dmMax", "私信数量");
  790. inheritRange(src, cfg, problems, "tabs.nurture.actionLimit", 0, "upper",
  791. "tabs.nurture.actionMin", "tabs.nurture.actionMax", "单次操作", RANGE_OF);
  792. inheritRange(src, cfg, problems, "tabs.nurture.browseDuration", 0, "upper",
  793. "tabs.nurture.browseMin", "tabs.nurture.browseMax", "浏览时长(分钟)", RANGE_OF);
  794. inheritRange(src, cfg, problems, "tabs.video.stopCount", 0, "exact",
  795. "tabs.video.stopMin", "tabs.video.stopMax", "停止个数");
  796. },
  797. /**
  798. * v5 → v6:底栏从 5 个页签收敛成 3 个 tab(任务 / 记录 / 我的)。
  799. *
  800. * 这不是"改个名字":原来的 id/dm/nurture/video 四个页签
  801. * **降级成「任务」tab 里的四张任务卡**,各自的详情页成了二级页。
  802. * 所以老存档里 `activeTab = "nurture"` 必须映射成 `"tasks"` ——
  803. * 不映射的话写入端的枚举归一化会把非法值回退成默认值,
  804. * 表面上"没问题",但老用户会发现"我上次停在养号页,重启跑到任务列表了",
  805. * 而这恰恰是**无法解释**的那类现象。
  806. *
  807. * 四个旧页签的**配置数据一个都没动**(`tabs.id.*` / `tabs.dm.*` / … 原样保留),
  808. * 只是入口层级变了 —— 迁移只碰入口,不碰业务配置。
  809. */
  810. 5: function (cfg, problems, src) {
  811. var OLD_TABS = ["id", "dm", "nurture", "video"];
  812. var oldTab = readPath(src, "activeTab");
  813. if (OLD_TABS.indexOf(oldTab) >= 0) {
  814. assignPath(cfg, "activeTab", "tasks");
  815. /* 老用户在某个任务页上 → 直接把他带回那个任务的详情页,
  816. * 而不是只扔到任务列表让他自己再点一次。 */
  817. assignPath(cfg, "activeDetail", oldTab);
  818. problems.push("activeTab " + oldTab + " → tasks(该任务已升为二级页,已直接停在它的详情页)");
  819. } else if (oldTab === "mine") {
  820. assignPath(cfg, "activeTab", "mine");
  821. }
  822. }
  823. };
  824. /**
  825. * 把一个**已被删除**的旧字段迁移成一对区间字段。
  826. *
  827. * 为什么必须从 src 里读:旧字段已经不在 `Store.FIELDS` 里,
  828. * 归一化之后的 cfg 里根本没有它 —— 只有原始输入还留着。
  829. *
  830. * mode:
  831. * "upper" —— 旧值是上限 → min 给 fallback(通常是 0),max = 旧值
  832. * "exact" —— 旧值是精确量 → min = max = 旧值
  833. * mapping —— 若给了映射表,旧值是里面的档位 key,直接取对应的一对数字
  834. */
  835. function inheritRange(src, cfg, problems, legacyPath, fallback, mode, minPath, maxPath, label, mapping) {
  836. var legacy = readPath(src, legacyPath);
  837. if (legacy === undefined || legacy === null) { return; }
  838. var low = fallback;
  839. var high = fallback;
  840. if (mapping) {
  841. var pair = mapping[legacy];
  842. if (!pair) { return; }
  843. low = pair[0];
  844. high = pair[1];
  845. } else {
  846. var n = Number(legacy);
  847. if (isNaN(n)) { return; }
  848. low = (mode === "exact") ? n : fallback;
  849. high = n;
  850. }
  851. assignPath(cfg, minPath, low);
  852. assignPath(cfg, maxPath, high);
  853. problems.push(legacyPath + " → " + label + " " + low + " ~ " + high +
  854. "(旧值 " + legacy + " 已沿用)");
  855. }
  856. /* ---------------------------------------------------------------- 校验 */
  857. /** 单字段归一化。只有「确实非法」才记问题——缺失属正常迁移,不刷屏。 */
  858. function normalizeField(raw, field, problems) {
  859. var missing = raw === undefined || raw === null;
  860. if (field.type === "bool") {
  861. return missing ? field.def : !!raw;
  862. }
  863. if (field.type === "int") {
  864. var n = Number(raw);
  865. if (missing || raw === "" || isNaN(n)) {
  866. if (!missing && raw !== "") {
  867. problems.push(field.path + " 不是数字(" + raw + "),已用默认值 " + field.def);
  868. }
  869. return field.def;
  870. }
  871. n = Math.round(n);
  872. if (field.min !== undefined && n < field.min) {
  873. problems.push(field.path + " " + n + " 低于下限,已抬到 " + field.min);
  874. n = field.min;
  875. }
  876. if (field.max !== undefined && n > field.max) {
  877. problems.push(field.path + " " + n + " 超过上限,已压到 " + field.max);
  878. n = field.max;
  879. }
  880. return n;
  881. }
  882. if (field.type === "enum") {
  883. var keys = field.values || enumKeys(field.group);
  884. if (keys.indexOf(raw) >= 0) { return raw; }
  885. if (!missing) {
  886. problems.push(field.path + " 非法取值(" + raw + "),已回退 " + field.def);
  887. }
  888. return field.def;
  889. }
  890. /* text:只认字符串。
  891. * 非字符串(数字/布尔/对象)一律回退默认值,**不做 String() 转换**——
  892. * 配置里出现数字只可能是手改或异常写入,把 42 静默转成 "42"
  893. * 会让一个无效的搜索条件直接生效,比回退默认危险得多。
  894. * (用户正常输入的数字也会以字符串形式存进来,不会走到这里。) */
  895. if (typeof raw === "string") { return raw; }
  896. if (!missing) {
  897. problems.push(field.path + " 不是文本(" + (typeof raw) + "),已用默认值 " + field.def);
  898. }
  899. return field.def;
  900. }
  901. /**
  902. * 把任意输入收敛成合法配置。返回 { config, problems }。
  903. *
  904. * 遍历的是**字段表**而不是输入对象 —— 表外的键会被丢弃,
  905. * 这样手改过的脏 JSON、旧版本残留字段都不会穿透到界面。
  906. *
  907. * problems 用于运行期把发现的问题打印出来:设备端没有测试框架,
  908. * 校验结果必须能被人看见,否则「悄悄用默认值跑」比报错更难查。
  909. */
  910. Store.validate = function (input) {
  911. var src = isPlainObject(input) ? input : {};
  912. var problems = [];
  913. /* 没写 version 的(很早期的存档)按 0 处理,否则 NaN 的比较恒为 false,迁移会被跳过 */
  914. var fromVersion = Number(input ? input.version : 0);
  915. if (isNaN(fromVersion)) { fromVersion = 0; }
  916. if (fromVersion !== Store.SCHEMA_VERSION) {
  917. problems.push("version " + (input && input.version !== undefined ? input.version : "(无)") +
  918. " → " + Store.SCHEMA_VERSION + ",缺失字段已按默认值补齐");
  919. }
  920. var cfg = { version: Store.SCHEMA_VERSION };
  921. for (var i = 0; i < Store.FIELDS.length; i++) {
  922. var f = Store.FIELDS[i];
  923. assignPath(cfg, f.path, normalizeField(readPath(src, f.path), f, problems));
  924. }
  925. /* 迁移放在归一化之后:此时 cfg 已是完整结构,迁移只需处理语义差异
  926. * (例如「v1 的空串其实等于未设置」「v2 的一个开关要拆成两个」这类
  927. * 类型系统看不出来的事)。src 一并传入,用于读取已被删除的旧字段。 */
  928. for (var v = fromVersion; v < Store.SCHEMA_VERSION; v++) {
  929. var migrate = Store.MIGRATIONS[v];
  930. if (migrate) { migrate(cfg, problems, src); }
  931. }
  932. for (var j = 0; j < Store.CROSS_RULES.length; j++) {
  933. var rule = Store.CROSS_RULES[j];
  934. var low = readPath(cfg, rule.low);
  935. var high = readPath(cfg, rule.high);
  936. if (typeof low === "number" && typeof high === "number" && high < low) {
  937. problems.push(rule.high + "(" + high + ") 小于 " + rule.low + "(" + low + "),已抬齐");
  938. assignPath(cfg, rule.high, low);
  939. }
  940. }
  941. return { config: cfg, problems: problems };
  942. };
  943. /* ---------------------------------------------------------------- 合并 */
  944. /**
  945. * 深合并:patch 覆盖 base 的同名键,patch 没有的键保留 base 的值。
  946. * 只递归「纯对象」——数组整体替换,不逐项合并(数组语义通常是「整份配置」)。
  947. */
  948. Store.merge = function (base, patch) {
  949. if (!isPlainObject(patch)) { return base; }
  950. var out = {};
  951. var k;
  952. for (k in base) {
  953. if (base.hasOwnProperty(k)) { out[k] = base[k]; }
  954. }
  955. for (k in patch) {
  956. if (!patch.hasOwnProperty(k)) { continue; }
  957. if (isPlainObject(base[k]) && isPlainObject(patch[k])) {
  958. out[k] = Store.merge(base[k], patch[k]);
  959. } else {
  960. out[k] = patch[k];
  961. }
  962. }
  963. return out;
  964. };
  965. /* ---------------------------------------------------------------- 读写 */
  966. function storage() {
  967. return storages.create(Store.STORAGE_NAME);
  968. }
  969. /** 读配置;任何异常都退回默认值,绝不让界面因读取失败而起不来。 */
  970. Store.load = function () {
  971. var raw = null;
  972. try {
  973. raw = storage().get(Store.STORAGE_KEY, null);
  974. } catch (e) {
  975. console.log("[store] 读取失败,使用默认配置: " + e);
  976. }
  977. if (typeof raw === "string" && raw.length > 0) {
  978. try {
  979. raw = JSON.parse(raw);
  980. } catch (e2) {
  981. console.log("[store] JSON 解析失败,使用默认配置");
  982. raw = null;
  983. }
  984. }
  985. var result = Store.validate(raw);
  986. if (result.problems.length > 0) {
  987. console.log("[store] 校验修正: " + result.problems.join(" | "));
  988. }
  989. return result.config;
  990. };
  991. /** 写配置。返回是否成功——界面要据此提示用户,而不是静默丢数据。 */
  992. Store.save = function (config) {
  993. try {
  994. storage().put(Store.STORAGE_KEY, JSON.stringify(config));
  995. return true;
  996. } catch (e) {
  997. console.log("[store] 保存失败: " + e);
  998. return false;
  999. }
  1000. };
  1001. Store.reset = function () {
  1002. try {
  1003. storage().remove(Store.STORAGE_KEY);
  1004. } catch (e) {
  1005. console.log("[store] 重置失败: " + e);
  1006. }
  1007. return Store.defaults();
  1008. };
  1009. /* 运行期只持有一份实例,页面通过它读写,避免各自 load 出多份副本互相覆盖。 */
  1010. Store.state = null;
  1011. Store.init = function () {
  1012. Store.state = Store.load();
  1013. return Store.state;
  1014. };
  1015. Store.current = function () {
  1016. if (!Store.state) { Store.init(); }
  1017. return Store.state;
  1018. };
  1019. /** 按路径取字段声明;没有契约的路径返回 null(调用方据此决定要不要归一化)。 */
  1020. Store.fieldOf = function (path) {
  1021. for (var i = 0; i < Store.FIELDS.length; i++) {
  1022. if (Store.FIELDS[i].path === path) { return Store.FIELDS[i]; }
  1023. }
  1024. return null;
  1025. };
  1026. /**
  1027. * 改一个字段并立即落盘。页面唯一的状态写入出口。
  1028. *
  1029. * ★ 写之前先按字段表**归一化**(夹紧越界、回退非法枚举、纠正类型)。
  1030. * 「配置里永远没有越界值」这条不变量必须由写入端保证,而不是只靠 load 时的
  1031. * `validate()`。少了这一步,界面上直接输入的值会原样落盘:用户填 99999 越过了
  1032. * 上限 500,配置里就真的是 99999 —— 要等下一次重启加载才被纠正,
  1033. * 而用户看到的是「我填的数它照收了,可跑起来又不是那么回事」。
  1034. */
  1035. Store.set = function (path, value) {
  1036. var field = Store.fieldOf(path);
  1037. /* 表外的路径(例如未来新增的临时键)不做归一化,原样写入 */
  1038. var next = field ? normalizeField(value, field, []) : value;
  1039. assignPath(Store.current(), path, next);
  1040. return Store.save(Store.current());
  1041. };
  1042. /**
  1043. * 取字段值。第二参数是缺失时的兜底 —— **页面里凡是喂给界面的取值都应该带上它**,
  1044. * 否则 undefined 会一路飘到 String() 变成界面上的 "undefined"。
  1045. */
  1046. Store.get = function (path, fallback) {
  1047. var value = readPath(Store.current(), path);
  1048. return value === undefined ? fallback : value;
  1049. };
  1050. /* ------------------------------------------------- 类型安全的取值(界面专用) */
  1051. /** 取某字段在字段表里声明的默认值——界面兜底时用它,与数据层保持同一来源。 */
  1052. Store.defaultOf = function (path) {
  1053. for (var i = 0; i < Store.FIELDS.length; i++) {
  1054. if (Store.FIELDS[i].path === path) { return Store.FIELDS[i].def; }
  1055. }
  1056. return undefined;
  1057. };
  1058. /** groupOrValues 既接受枚举组名,也接受候选数组。 */
  1059. function keysOf(groupOrValues) {
  1060. if (groupOrValues instanceof Array) { return groupOrValues; }
  1061. return enumKeys(groupOrValues);
  1062. }
  1063. /**
  1064. * 取枚举值,**保证返回一个合法 key**。
  1065. * 缺失或非法时回退到该枚举第一项,并把这个修正回写到配置里——
  1066. * 于是「选择框总有选中项」在数据层就被保证,页面不必各自兜底。
  1067. */
  1068. Store.getEnum = function (path, groupOrValues) {
  1069. var keys = keysOf(groupOrValues);
  1070. var raw = Store.get(path, null);
  1071. if (raw !== null && keys.indexOf(raw) >= 0) { return raw; }
  1072. var fallback = keys.length > 0 ? keys[0] : "";
  1073. var declared = Store.defaultOf(path);
  1074. if (declared !== undefined && keys.indexOf(declared) >= 0) { fallback = declared; }
  1075. Store.set(path, fallback);
  1076. return fallback;
  1077. };
  1078. /** 取数值,保证返回有限数;非法时回退默认值并回写。 */
  1079. Store.getNumber = function (path, def) {
  1080. var raw = Store.get(path, null);
  1081. var n = Number(raw);
  1082. if (raw !== null && raw !== "" && !isNaN(n)) { return n; }
  1083. var fallback = def === undefined ? Store.defaultOf(path) : def;
  1084. if (typeof fallback !== "number") { fallback = 0; }
  1085. Store.set(path, fallback);
  1086. return fallback;
  1087. };
  1088. /** 取文本,保证返回字符串;缺失或非字符串时回退默认值并回写。 */
  1089. Store.getText = function (path, def) {
  1090. var raw = Store.get(path, null);
  1091. if (typeof raw === "string") { return raw; }
  1092. var fallback = def === undefined ? Store.defaultOf(path) : def;
  1093. if (typeof fallback !== "string") { fallback = ""; }
  1094. Store.set(path, fallback);
  1095. return fallback;
  1096. };
  1097. /** 取布尔,保证返回布尔值。 */
  1098. Store.getBool = function (path, def) {
  1099. var raw = Store.get(path, null);
  1100. if (typeof raw === "boolean") { return raw; }
  1101. var fallback = def === undefined ? Store.defaultOf(path) : def;
  1102. if (typeof fallback !== "boolean") { fallback = false; }
  1103. Store.set(path, fallback);
  1104. return fallback;
  1105. };
  1106. /* ============================== src/records.js ============================== */
  1107. /* ============================== src/records.js ==============================
  1108. * 运行记录(规格书 D1/D2/D3 的数据来源)。
  1109. *
  1110. * ## 为什么单独一个模块,而不是塞进 Store.FIELDS
  1111. * Store 管的是「**固定字段**的配置」——一张声明表,每个路径一个值。
  1112. * 运行记录是**不定长的列表**(跑一次多一条),塞进字段表就等于
  1113. * 让 validate() 去校验一个数组,还把「路径 → 值控件」的那套审计规则带歪。
  1114. * 所以它走同一份 storages 的**另一个 key**,自己管读写。
  1115. *
  1116. * ## 记录里存什么
  1117. * 只存**界面真的知道的东西**:任务/脚本、结果分类、起止时间、结果文案、
  1118. * 结果文件、日志行。不编造没发生过的字段(例如"实际提取了几个 ID"只有
  1119. * 引擎回传了才写;没回传就留空,D3 那一行就不显示)。
  1120. *
  1121. * ## 日志从哪来
  1122. * 引擎(tiktok-auto)通过 `--log` 通道把真实输出按行 append 到
  1123. * run-log.jsonl,界面在结束时读回来。**没有日志就不显示日志卡的空壳** ——
  1124. * 详见 `Engine.readLog()` 与 §「日志缺失时如实说明」。
  1125. */
  1126. var Records = {};
  1127. /** 与 Store 共用一份 storages,只是换一个 key(见文件头注释)。 */
  1128. Records.STORAGE_NAME = "feizhou_app";
  1129. Records.STORAGE_KEY = "records";
  1130. /**
  1131. * 最多保留多少条。
  1132. *
  1133. * 上限是**必要的**:记录里含日志行,跑几十次之后 JSON 会长到
  1134. * storages 读取明显变慢(它每次整体读字符串)。
  1135. * 50 条足够回溯,超出的从尾部丢弃(新记录永远在最前)。
  1136. */
  1137. Records.MAX = 50;
  1138. /** 结果分类 → 界面文案 + 色调。tone 直接喂给 W.pill。 */
  1139. Records.RESULT = {
  1140. success: { key: "success", label: "\u5DF2\u5B8C\u6210", tone: "ok" },
  1141. stopped: { key: "stopped", label: "\u624B\u52A8\u505C\u6B62", tone: "mute" },
  1142. failed: { key: "failed", label: "\u62A5\u9519\u4E2D\u65AD", tone: "danger" }
  1143. };
  1144. /** 归一化结果分类:引擎给的终态与记录分类不是一套词(finished/stopped/failed)。 */
  1145. Records.resultOf = function (status) {
  1146. if (status === "finished" || status === "success") { return Records.RESULT.success; }
  1147. if (status === "stopped") { return Records.RESULT.stopped; }
  1148. return Records.RESULT.failed;
  1149. };
  1150. /* ------------------------------------------------------------ 读写 */
  1151. function storage() {
  1152. return storages.create(Records.STORAGE_NAME);
  1153. }
  1154. /** 读全部记录(新的在前)。任何异常都退回空数组——记录读不出来不该让界面起不来。 */
  1155. Records.list = function () {
  1156. var raw = null;
  1157. try {
  1158. raw = storage().get(Records.STORAGE_KEY, null);
  1159. } catch (e) {
  1160. console.log("[records] 读取失败: " + e);
  1161. }
  1162. if (typeof raw === "string" && raw.length > 0) {
  1163. try {
  1164. raw = JSON.parse(raw);
  1165. } catch (e2) {
  1166. console.log("[records] JSON 解析失败,按空处理");
  1167. raw = null;
  1168. }
  1169. }
  1170. if (!(raw instanceof Array)) { return []; }
  1171. /* 逐条清洗:缺 id / 缺任务名的脏条目直接丢掉,
  1172. * 免得 D1 上出现一行点不开、也说不清是什么的东西。 */
  1173. var out = [];
  1174. for (var i = 0; i < raw.length; i++) {
  1175. var one = raw[i];
  1176. if (!one || typeof one !== "object") { continue; }
  1177. if (!one.id || !one.taskKey) { continue; }
  1178. out.push(one);
  1179. }
  1180. return out;
  1181. };
  1182. Records.save = function (list) {
  1183. try {
  1184. storage().put(Records.STORAGE_KEY, JSON.stringify(list));
  1185. return true;
  1186. } catch (e) {
  1187. console.log("[records] 保存失败: " + e);
  1188. return false;
  1189. }
  1190. };
  1191. /**
  1192. * 追加一条记录(新的在最前)。
  1193. * @returns 落盘后的那条记录(含 id)——调用方(F5 的「查看日志」)要用它跳 D3
  1194. */
  1195. Records.add = function (record) {
  1196. var list = Records.list();
  1197. var one = record || {};
  1198. one.id = one.id || ("rec-" + Date.now() + "-" + Math.floor(Math.random() * 10000));
  1199. one.taskKey = one.taskKey || "";
  1200. one.taskName = one.taskName || "";
  1201. one.scriptKey = one.scriptKey || "";
  1202. one.scriptName = one.scriptName || "";
  1203. one.result = one.result || "success";
  1204. one.startedAt = one.startedAt || 0;
  1205. one.endedAt = one.endedAt || Date.now();
  1206. one.resultText = one.resultText || "";
  1207. one.resultFile = one.resultFile || "";
  1208. one.logs = one.logs instanceof Array ? one.logs : [];
  1209. list.unshift(one);
  1210. if (list.length > Records.MAX) { list = list.slice(0, Records.MAX); }
  1211. Records.save(list);
  1212. return one;
  1213. };
  1214. /** 按 id 取一条(D3 用)。找不到返回 null。 */
  1215. Records.find = function (id) {
  1216. var list = Records.list();
  1217. for (var i = 0; i < list.length; i++) {
  1218. if (list[i].id === id) { return list[i]; }
  1219. }
  1220. return null;
  1221. };
  1222. /** 某个任务最近一条记录(B1 任务卡的「上次…」)。没有则 null。 */
  1223. Records.latestOf = function (taskKey) {
  1224. var list = Records.list();
  1225. for (var i = 0; i < list.length; i++) {
  1226. if (list[i].taskKey === taskKey) { return list[i]; }
  1227. }
  1228. return null;
  1229. };
  1230. Records.clear = function () {
  1231. try {
  1232. storage().remove(Records.STORAGE_KEY);
  1233. } catch (e) {
  1234. console.log("[records] 清空失败: " + e);
  1235. }
  1236. };
  1237. /* ------------------------------------------------------------ 文案 */
  1238. function pad2(n) {
  1239. return n < 10 ? "0" + n : String(n);
  1240. }
  1241. /**
  1242. * 时长 → 人话。
  1243. *
  1244. * 设计稿上三种形态都出现过(「耗时 12 分 30 秒」/「已运行 41 分钟」/「运行 6 分钟」),
  1245. * 统一走这一个函数,规则是:
  1246. * < 1 分钟 → "N 秒"
  1247. * < 1 小时 → 整分钟说「N 分钟」,带零头说「N 分 M 秒」
  1248. * ≥ 1 小时 → "H 小时 M 分"
  1249. * 为什么整分钟要单独说一句:0 秒是大多数「手动停止」的真实情况,
  1250. * 写成「12 分 0 秒」会让人以为数据不准。
  1251. */
  1252. Records.formatDuration = function (ms) {
  1253. var total = Math.max(0, Math.floor((ms || 0) / 1000));
  1254. if (total < 60) { return total + " \u79D2"; }
  1255. if (total < 3600) {
  1256. var m = Math.floor(total / 60);
  1257. var s = total % 60;
  1258. return s === 0 ? (m + " \u5206\u949F") : (m + " \u5206 " + s + " \u79D2");
  1259. }
  1260. var h = Math.floor(total / 3600);
  1261. var mm = Math.floor((total % 3600) / 60);
  1262. return h + " \u5C0F\u65F6 " + mm + " \u5206";
  1263. };
  1264. /**
  1265. * 时间点 → 「今天 10:24」/「昨天 09:58」/「10-11 09:58」。
  1266. * 相对日期的判断必须用**本机日历**(不是 UTC):跨时区时 UTC 会让
  1267. * 「今天」错成昨天,而这条文案的全部意义就在于它读起来直观。
  1268. */
  1269. Records.formatDay = function (timestamp, nowMs) {
  1270. var d = new Date(timestamp || 0);
  1271. var now = new Date(nowMs === undefined ? Date.now() : nowMs);
  1272. var clock = pad2(d.getHours()) + ":" + pad2(d.getMinutes());
  1273. var sameDay = d.getFullYear() === now.getFullYear()
  1274. && d.getMonth() === now.getMonth()
  1275. && d.getDate() === now.getDate();
  1276. if (sameDay) { return "\u4ECA\u5929 " + clock; }
  1277. var yesterday = new Date(now.getTime() - 86400000);
  1278. var isYesterday = d.getFullYear() === yesterday.getFullYear()
  1279. && d.getMonth() === yesterday.getMonth()
  1280. && d.getDate() === yesterday.getDate();
  1281. if (isYesterday) { return "\u6628\u5929 " + clock; }
  1282. return pad2(d.getMonth() + 1) + "-" + pad2(d.getDate()) + " " + clock;
  1283. };
  1284. /** D1 条目的时间行:「今天 10:24 · 耗时 12 分 30 秒」 */
  1285. Records.timeLine = function (record, nowMs) {
  1286. if (!record) { return ""; }
  1287. var start = record.startedAt || record.endedAt || 0;
  1288. var end = record.endedAt || start;
  1289. var head = Records.formatDay(start, nowMs);
  1290. if (record.durationMs === undefined || record.durationMs === null) {
  1291. record.durationMs = Math.max(0, end - start);
  1292. }
  1293. return head + " \u00B7 \u8017\u65F6 " + Records.formatDuration(record.durationMs);
  1294. };
  1295. /**
  1296. * D1 条目的结果行。
  1297. * 没给出结果文案时不编 —— 返回空串,由调用方决定这一行要不要出现。
  1298. */
  1299. Records.resultLine = function (record) {
  1300. if (!record) { return ""; }
  1301. var text = record.resultText || "";
  1302. if (record.resultFile && text.indexOf(record.resultFile) < 0) {
  1303. text = text === "" ? record.resultFile : text + " \u2192 " + record.resultFile;
  1304. }
  1305. return text;
  1306. };
  1307. /**
  1308. * B1 任务卡的「上次…」一行。
  1309. * 没有记录 → 「还未运行过」(设计稿 C9 的视频功能卡就是这个文案,不是空白)。
  1310. */
  1311. Records.lastRunLine = function (taskKey, nowMs) {
  1312. var one = Records.latestOf(taskKey);
  1313. if (!one) { return "\u8FD8\u672A\u8FD0\u884C\u8FC7"; }
  1314. var clock = Records.formatDay(one.startedAt || one.endedAt, nowMs);
  1315. /* 「今天 10:24」太长,卡上只留时分;跨天的记录才带上日期。 */
  1316. var short = clock.replace("\u4ECA\u5929 ", "\u4E0A\u6B21 ").replace("\u6628\u5929 ", "\u6628\u5929 ");
  1317. var tail = Records.resultLine(one);
  1318. return short + (tail === "" ? "" : " \u00B7 " + tail);
  1319. };
  1320. /* ============================== src/device.js ============================== */
  1321. /* ============================== src/device.js ==============================
  1322. * 设备数据获取层。
  1323. *
  1324. * 界面上的每一项「状态」都来自这里,而不是写死的假数据:
  1325. * 无障碍 / 悬浮窗 / 通知 权限、TikTok 是否安装与版本号、屏幕尺寸、脚本版本。
  1326. *
  1327. * 设计原则(规范 R25「设备端危险能力必须显式声明依赖,不作为主路径」):
  1328. * 本文件所有探测都必须能在「能力缺失」时给出 false / 空值,绝不抛出去。
  1329. * AutoX 没有 try-catch 之外的门禁,一次未捕获异常就会让整个界面起不来。
  1330. */
  1331. var Device = {};
  1332. /* TikTok 有多个发行包名,挨个探测;顺序即优先级。 */
  1333. Device.TIKTOK_PACKAGES = [
  1334. "com.zhiliaoapp.musically",
  1335. "com.ss.android.ugc.trill",
  1336. "com.zhiliaoapp.musically.go"
  1337. ];
  1338. /** 通用取值包装:任何异常都退化为 fallback,并留一条日志便于定位。 */
  1339. function probe(tag, fn, fallback) {
  1340. try {
  1341. var value = fn();
  1342. return value === undefined || value === null ? fallback : value;
  1343. } catch (e) {
  1344. console.log("[device] " + tag + " 探测失败: " + e);
  1345. return fallback;
  1346. }
  1347. }
  1348. /* ------------------------------------------------------------ 屏幕与栏位 */
  1349. Device.screenWidth = function () {
  1350. return probe("screenWidth", function () { return device.width; }, 375);
  1351. };
  1352. Device.screenHeight = function () {
  1353. return probe("screenHeight", function () { return device.height; }, 812);
  1354. };
  1355. /** 系统状态栏高度。取不到时用 24dp 兜底,保证内容不被状态栏压住。 */
  1356. Device.statusBarHeight = function () {
  1357. return probe("statusBarHeight", function () {
  1358. var res = context.getResources();
  1359. var id = res.getIdentifier("status_bar_height", "dimen", "android");
  1360. return id > 0 ? res.getDimensionPixelSize(id) : U.dp(24);
  1361. }, U.dp(24));
  1362. };
  1363. /** 底部导航栏高度。全面屏手势机型会返回 0,此时由「安全区」常量兜底。 */
  1364. Device.navBarHeight = function () {
  1365. return probe("navBarHeight", function () {
  1366. var res = context.getResources();
  1367. var id = res.getIdentifier("navigation_bar_height", "dimen", "android");
  1368. return id > 0 ? res.getDimensionPixelSize(id) : 0;
  1369. }, 0);
  1370. };
  1371. /* ------------------------------------------------------------ 权限状态 */
  1372. /**
  1373. * 无障碍服务。AutoX 里 auto.service 为 null 即表示未连接。
  1374. * 这是本 App 的硬依赖——没有它就点不动 TikTok,所以权限弹窗首行必须显示它。
  1375. */
  1376. Device.a11yEnabled = function () {
  1377. return probe("a11y", function () { return auto.service !== null; }, false);
  1378. };
  1379. /** 悬浮窗权限。用于运行时浮层提示,缺失不影响主流程。 */
  1380. Device.overlayEnabled = function () {
  1381. return probe("overlay", function () { return floaty.checkPermission(); }, false);
  1382. };
  1383. /**
  1384. * 通知权限。Android 13(API 33) 起 POST_NOTIFICATIONS 是运行时权限,
  1385. * 13 以下默认授予——不能统一按「未授予」处理,否则老机型会误报。
  1386. */
  1387. Device.notifyEnabled = function () {
  1388. return probe("notify", function () {
  1389. var sdk = parseInt(android.os.Build.VERSION.SDK, 10);
  1390. if (isNaN(sdk) || sdk < 33) { return true; }
  1391. var granted = context.checkSelfPermission("android.permission.POST_NOTIFICATIONS");
  1392. return granted === android.content.pm.PackageManager.PERMISSION_GRANTED;
  1393. }, true);
  1394. };
  1395. /**
  1396. * 权限弹窗的展示数据,界面直接渲染,不在页面里重复拼装。
  1397. *
  1398. * 两条约定(设计稿 02 与 12 必须完全一致):
  1399. * ① 顺序固定为 无障碍服务 → 悬浮窗权限 → 通知权限;
  1400. * ② 通知标 optional —— 它只在任务完成时提醒你,缺了不影响脚本执行,
  1401. * 所以既排在最后、也不参与「能不能开始运行」的判定。
  1402. * (早先它既没标可选、又排在悬浮窗前面,用户会以为必须开。)
  1403. */
  1404. Device.permissionRows = function () {
  1405. return [
  1406. { key: "a11y", name: "无障碍服务", granted: Device.a11yEnabled() },
  1407. { key: "overlay", name: "悬浮窗权限", granted: Device.overlayEnabled() },
  1408. { key: "notify", name: "通知权限", granted: Device.notifyEnabled(), optional: true }
  1409. ];
  1410. };
  1411. Device.allPermissionsGranted = function () {
  1412. var rows = Device.permissionRows();
  1413. for (var i = 0; i < rows.length; i++) {
  1414. if (!rows[i].granted) { return false; }
  1415. }
  1416. return true;
  1417. };
  1418. /**
  1419. * 「能不能开始运行」只取决于这两个。
  1420. * 与 allPermissionsGranted 分开是必要的:通知是可选权限,
  1421. * 用它拦人会让用户被一个不影响执行的权限挡在门外。
  1422. */
  1423. Device.blockingPermissionsGranted = function () {
  1424. return Device.a11yEnabled() && Device.overlayEnabled();
  1425. };
  1426. /**
  1427. * 打开对应权限的设置页。
  1428. * 用标准 ACTION 常量而不是猜 Activity 名——猜错会抛 ActivityNotFound。
  1429. * 无障碍与悬浮窗没有公开的直跳 Action,退回「应用详情页」这个一定存在的入口。
  1430. */
  1431. Device.openPermissionSettings = function (key) {
  1432. var pkg = context.getPackageName();
  1433. var activity = null;
  1434. var action = null;
  1435. try {
  1436. if (key === "a11y") {
  1437. action = "android.settings.ACCESSIBILITY_SETTINGS";
  1438. } else if (key === "overlay") {
  1439. action = "android.settings.action.MANAGE_OVERLAY_PERMISSION";
  1440. } else if (key === "notify") {
  1441. action = "android.settings.APP_NOTIFICATION_SETTINGS";
  1442. }
  1443. if (action) {
  1444. var intent = new android.content.Intent(action);
  1445. if (key === "overlay") {
  1446. intent.setData(android.net.Uri.parse("package:" + pkg));
  1447. } else {
  1448. intent.putExtra("package", pkg);
  1449. intent.putExtra("android.provider.extra.APP_PACKAGE", pkg);
  1450. }
  1451. activity = intent;
  1452. }
  1453. } catch (e) {
  1454. console.log("[device] 构造权限 Intent 失败: " + e);
  1455. activity = null;
  1456. }
  1457. if (!activity) {
  1458. /* 兜底:应用详情页。所有 Android 版本都有。 */
  1459. activity = new android.content.Intent(
  1460. "android.settings.APPLICATION_DETAILS_SETTINGS",
  1461. android.net.Uri.parse("package:" + pkg)
  1462. );
  1463. }
  1464. return probe("openPermission", function () {
  1465. context.startActivity(activity);
  1466. return true;
  1467. }, false);
  1468. };
  1469. /* ------------------------------------------------------------ TikTok 状态 */
  1470. /** 探测 TikTok 是否安装及版本。返回 { installed, packageName, versionName }。 */
  1471. Device.tiktok = function () {
  1472. var found = { installed: false, packageName: "", versionName: "" };
  1473. for (var i = 0; i < Device.TIKTOK_PACKAGES.length; i++) {
  1474. var name = Device.TIKTOK_PACKAGES[i];
  1475. var info = probe("pkg:" + name, function () {
  1476. return context.getPackageManager().getPackageInfo(name, 0);
  1477. }, null);
  1478. if (info) {
  1479. found.installed = true;
  1480. found.packageName = name;
  1481. found.versionName = info.versionName || "";
  1482. break;
  1483. }
  1484. }
  1485. if (found.installed) {
  1486. console.log("[device] TikTok 已安装 " + found.packageName + " v" + found.versionName);
  1487. } else {
  1488. console.log("[device] 未探测到 TikTok 客户端");
  1489. }
  1490. return found;
  1491. };
  1492. /** 版本号归一化:设计稿展示成 v39.5.4 这种形态。 */
  1493. Device.formatVersion = function (versionName) {
  1494. var v = String(versionName || "").trim();
  1495. if (!v) { return ""; }
  1496. return v.charAt(0) === "v" ? v : "v" + v;
  1497. };
  1498. /* ------------------------------------------------------------ 文件选择 */
  1499. /** 文本文件的候选目录。只扫这几处,不做全盘遍历(全盘扫在真机上要几十秒)。 */
  1500. Device.TEXT_DIRS = ["/sdcard/Download", "/sdcard/Documents", "/sdcard/tiktok_data", "/sdcard"];
  1501. /**
  1502. * 列出可选的文本文件,供「选择文件」按钮弹列表用。
  1503. *
  1504. * 这里刻意**只负责列出、不负责弹窗**:展示交给 UI 层(`W.filePicker` + `Dialog.choose`)。
  1505. * 一来设备层不该依赖弹窗组件,二来 `dialogs.select` 这类阻塞式调用在 UI 模式下
  1506. * 拿不到返回值,选文件会静默失效。
  1507. *
  1508. * 只扫几个约定目录,不做全盘遍历——全盘扫在真机上要几十秒。
  1509. */
  1510. Device.listTextFiles = function () {
  1511. var out = [];
  1512. var seen = {};
  1513. for (var i = 0; i < Device.TEXT_DIRS.length; i++) {
  1514. var dir = Device.TEXT_DIRS[i];
  1515. var names = probe("ls:" + dir, function () { return files.listDir(dir); }, []);
  1516. for (var j = 0; j < names.length; j++) {
  1517. var name = names[j];
  1518. if (!/\.(txt|json|csv)$/i.test(name)) { continue; }
  1519. var full = dir + "/" + name;
  1520. if (seen[full]) { continue; }
  1521. seen[full] = true;
  1522. out.push(full);
  1523. }
  1524. }
  1525. out.sort();
  1526. return out;
  1527. };
  1528. /* ------------------------------------------------------------ 应用信息 */
  1529. Device.appVersionName = function () {
  1530. return probe("appVersion", function () {
  1531. return context.getPackageManager().getPackageInfo(context.getPackageName(), 0).versionName;
  1532. }, "-");
  1533. };
  1534. Device.deviceLabel = function () {
  1535. return probe("deviceLabel", function () {
  1536. return android.os.Build.MODEL + " / Android " + android.os.Build.VERSION.RELEASE;
  1537. }, "-");
  1538. };
  1539. /* ============================== src/service.js ============================== */
  1540. /* ============================== src/service.js ==============================
  1541. * 需要「服务端」的业务能力:卡密校验、检查更新。
  1542. *
  1543. * 这两件事的共同点是:真实场景必须联网核验,但**离线也必须能给出确定行为**。
  1544. * 所以每个能力都是「远端优先 + 本地降级」,且降级模式会在界面上如实标注,
  1545. * 不会把本地通过伪装成服务端通过(规范:不编造测试结果/接口字段)。
  1546. *
  1547. * ⚠️ 本地模式的定位必须说清楚:
  1548. * 仅校验格式,不具备防伪能力。要真防伪,把 CONFIG.licenseEndpoint
  1549. * 指向你自己的校验接口即可——请求/响应结构见 verifyRemote() 注释。
  1550. */
  1551. var Service = {};
  1552. Service.CONFIG = {
  1553. /* 留空 = 本地模式。填写后 login() 会先请求远端。 */
  1554. licenseEndpoint: "",
  1555. licenseTimeoutMs: 8000,
  1556. /* 本地模式下的默认有效期(天),仅用于让「到期时间」这一栏有真实含义。 */
  1557. localTrialDays: 30,
  1558. /* 检查更新的清单地址,留空则用内置版本号自比。 */
  1559. updateEndpoint: ""
  1560. };
  1561. /** 脚本版本。设计稿的「版本信息卡」显示的就是它;build.js 会用构建日期覆盖。 */
  1562. Service.SCRIPT_VERSION = "2026.10.13";
  1563. Service.CHANGELOG = [
  1564. "2026.10.11 数量类设置全部改为「最小值 ~ 最大值」区间(关注/私信/休息/浏览/单次/停止/间隔)",
  1565. "2026.10.11 补齐登录页 / 运行状态条 / 拦截弹窗,ID提取页改单选",
  1566. "2026.10.10 界面层按设计稿重构,配置项全部落盘",
  1567. "2026.10.03 接入设备真实状态(权限 / TikTok 版本)",
  1568. "2026.09.28 首个可运行版本"
  1569. ];
  1570. /* ---------------------------------------------------------------- 卡密 */
  1571. /** 去掉空格与连字符并转大写,得到比较用的规范形式。 */
  1572. Service.normalizeCode = function (code) {
  1573. return String(code || "").replace(/[\s-]/g, "").toUpperCase();
  1574. };
  1575. /**
  1576. * 格式校验:16 位大写字母或数字。
  1577. * 只做形态校验——真伪必须靠服务端,本地无法判定一个随机串是不是有效卡密。
  1578. */
  1579. Service.isWellFormed = function (code) {
  1580. return /^[A-Z0-9]{16}$/.test(Service.normalizeCode(code));
  1581. };
  1582. /** 展示用分组:ABCDEFGHIJKLMNOP → ABCDE-FGHI-JKLM-NOPQ 形态的 4 组。 */
  1583. Service.formatCode = function (code) {
  1584. var n = Service.normalizeCode(code);
  1585. var out = [];
  1586. for (var i = 0; i < n.length; i += 4) { out.push(n.substring(i, i + 4)); }
  1587. return out.join("-");
  1588. };
  1589. function httpPostJson(url, payload) {
  1590. var body = JSON.stringify(payload);
  1591. var response = http.post(url, body, {
  1592. headers: { "Content-Type": "application/json" },
  1593. timeout: Service.CONFIG.licenseTimeoutMs
  1594. });
  1595. if (!response || response.statusCode !== 200) {
  1596. return null;
  1597. }
  1598. try {
  1599. return JSON.parse(response.body.string());
  1600. } catch (e) {
  1601. console.log("[service] 卡密响应不是合法 JSON");
  1602. return null;
  1603. }
  1604. }
  1605. /**
  1606. * 远端校验。
  1607. * 请求:POST {endpoint} {"code":"<规范化卡密>"}
  1608. * 响应:{"ok":true,"expireAt":"2026-11-09"} 或 {"ok":false,"reason":"卡密不存在"}
  1609. * 返回 null 表示「没问到」(网络/格式问题),调用方据此决定是否降级为本地模式。
  1610. */
  1611. Service.verifyRemote = function (code) {
  1612. if (!Service.CONFIG.licenseEndpoint) { return null; }
  1613. var data = null;
  1614. try {
  1615. data = httpPostJson(Service.CONFIG.licenseEndpoint, { code: code });
  1616. } catch (e) {
  1617. console.log("[service] 卡密远端校验异常: " + e);
  1618. return null;
  1619. }
  1620. if (!data) { return null; }
  1621. return {
  1622. ok: !!data.ok,
  1623. expireAt: data.expireAt || "",
  1624. reason: data.reason || "卡密无效"
  1625. };
  1626. };
  1627. /** 今天 + N 天,格式 yyyy-MM-dd。用于本地模式生成到期时间。 */
  1628. Service.datePlusDays = function (days) {
  1629. var now = new Date();
  1630. now.setDate(now.getDate() + Number(days || 0));
  1631. var m = now.getMonth() + 1;
  1632. var d = now.getDate();
  1633. return now.getFullYear() + "-" + (m < 10 ? "0" + m : m) + "-" + (d < 10 ? "0" + d : d);
  1634. };
  1635. Service.today = function () {
  1636. return Service.datePlusDays(0);
  1637. };
  1638. /** 过期判定:只比日期字符串(yyyy-MM-dd 字典序即时间序),不引时区计算。 */
  1639. Service.isExpired = function (expireAt) {
  1640. if (!expireAt) { return false; }
  1641. return Service.today() > String(expireAt);
  1642. };
  1643. /**
  1644. * 登录。返回 { ok, status, message }。
  1645. * status: "active" | "expired" | "none"
  1646. */
  1647. Service.login = function (code) {
  1648. var normalized = Service.normalizeCode(code);
  1649. if (!Service.isWellFormed(normalized)) {
  1650. return { ok: false, status: "none", message: "卡密格式不对,应为 16 位字母或数字" };
  1651. }
  1652. var remote = Service.verifyRemote(normalized);
  1653. if (remote) {
  1654. if (!remote.ok) {
  1655. return { ok: false, status: "none", message: remote.reason };
  1656. }
  1657. if (Service.isExpired(remote.expireAt)) {
  1658. return { ok: false, status: "expired", message: "该卡密已于 " + remote.expireAt + " 到期" };
  1659. }
  1660. return {
  1661. ok: true,
  1662. status: "active",
  1663. code: normalized,
  1664. expireAt: remote.expireAt,
  1665. message: "登录成功(服务端核验)"
  1666. };
  1667. }
  1668. /* 本地模式:只有格式校验,如实告知,不伪装成服务端通过。 */
  1669. return {
  1670. ok: true,
  1671. status: "active",
  1672. code: normalized,
  1673. expireAt: Service.datePlusDays(Service.CONFIG.localTrialDays),
  1674. message: "登录成功(本地模式,未联网核验)"
  1675. };
  1676. };
  1677. /** 读当前卡密状态,并顺手处理「已过期但状态还写着 active」的情况。 */
  1678. Service.licenseStatus = function () {
  1679. var status = Store.getEnum("license.status", "licenseStatus");
  1680. var expireAt = Store.getText("license.expireAt");
  1681. var code = Store.getText("license.code");
  1682. if (status === "active" && Service.isExpired(expireAt)) {
  1683. Store.set("license.status", "expired");
  1684. status = "expired";
  1685. }
  1686. return { status: status, code: code, expireAt: expireAt };
  1687. };
  1688. /**
  1689. * 状态 → 界面文案与配色 key。集中在这里,避免页面各自判断。
  1690. *
  1691. * ⚠️ 返回值里必须带 `status` —— 页面除了文案还要按状态分支
  1692. * (已登录才显示「解绑」,否则显示「去登录」)。
  1693. * 早先只返回 text/tone/expireAt,页面写 `display.status === "active"` 永远是 false,
  1694. * 结果是「已登录」旁边挂着「去登录」,自相矛盾但没有任何报错。
  1695. */
  1696. Service.licenseDisplay = function () {
  1697. var st = Service.licenseStatus();
  1698. if (st.status === "active") {
  1699. return { status: st.status, text: "已登录", tone: "ok", expireAt: st.expireAt || "—" };
  1700. }
  1701. if (st.status === "expired") {
  1702. return { status: st.status, text: "已过期", tone: "warn", expireAt: st.expireAt || "—" };
  1703. }
  1704. return { status: st.status, text: "未登录", tone: "warn", expireAt: "—" };
  1705. };
  1706. Service.logout = function () {
  1707. Store.set("license.status", "none");
  1708. Store.set("license.code", "");
  1709. Store.set("license.expireAt", "");
  1710. };
  1711. /* ---------------------------------------------------------------- 更新 */
  1712. /**
  1713. * 检查更新。远端不可达时退化为「与内置版本号自比」,
  1714. * 结论同样如实标注来源,避免把「没连上」说成「已是最新」。
  1715. */
  1716. Service.checkUpdate = function () {
  1717. var current = Service.SCRIPT_VERSION;
  1718. if (Service.CONFIG.updateEndpoint) {
  1719. var data = null;
  1720. try {
  1721. var response = http.get(Service.CONFIG.updateEndpoint, { timeout: 8000 });
  1722. if (response && response.statusCode === 200) {
  1723. data = JSON.parse(response.body.string());
  1724. }
  1725. } catch (e) {
  1726. console.log("[service] 检查更新失败: " + e);
  1727. }
  1728. if (data && data.version) {
  1729. return {
  1730. latest: String(data.version),
  1731. hasUpdate: String(data.version) > current,
  1732. source: "remote",
  1733. note: data.note || ""
  1734. };
  1735. }
  1736. return { latest: current, hasUpdate: false, source: "unknown", note: "检查失败,请稍后重试" };
  1737. }
  1738. return { latest: current, hasUpdate: false, source: "local", note: "已是最新" };
  1739. };
  1740. /* ============================== src/engine.js ============================== */
  1741. /* ============================== src/engine.js ==============================
  1742. * 执行引擎桥接:把界面收集的配置交给 tiktok-auto,再把它的进度读回来。
  1743. *
  1744. * ## 为什么走文件,不走内存
  1745. * 界面与被唤起的引擎是**两个独立的 ScriptEngine**:各有各的全局作用域,
  1746. * 界面里的对象传不过去(实测确认)。所以两边只通过约定好的四个文件说话:
  1747. *
  1748. * run-config.json 界面 → 人/追溯:完整配置快照(人可读)
  1749. * engine-config.json 界面 → 引擎:映射后的引擎配置
  1750. * run-state.json 引擎 → 界面:进度与终态
  1751. * stop.flag 界面 → 引擎:停止请求(内容是本次运行的 runId)
  1752. *
  1753. * 文件通道是**异步**的,所以界面这边的规则是:**所有动作以状态文件的回报为准**,
  1754. * 而不是"我点了就当我做了"。点「停止运行」后界面显示"正在收尾"、
  1755. * 等到引擎真的上报 stopped 才切回未运行 —— 否则界面说"已停止"而 TikTok 还在自己动,
  1756. * 用户不知道该信谁。
  1757. *
  1758. * ## 为什么不删 stop.flag
  1759. * 停止靠**比内容**而不是靠"文件在不在":只有内容等于本次 runId 才算数。
  1760. * 于是不必删除它(删除既有竞态,`files.remove` 又命中 MCP 危险调用黑名单),
  1761. * 上一轮残留的标志也不会误杀下一轮。
  1762. *
  1763. * ## 能力边界要如实说
  1764. * tiktok-auto 目前实现的是「搜索 → 逐个看 → 评论」。界面「执行操作」里的
  1765. * 关注 / 私信 / 点赞**尚未接入**,`buildConfig()` 会把它们逐条记进 notes,
  1766. * 由调用方决定怎么告知用户 —— **绝不静默忽略**:
  1767. * 用户勾了点赞却什么都没发生,比明确告知"尚未支持"糟得多。
  1768. */
  1769. var Engine = {};
  1770. Engine.DIR = "/sdcard/tiktok_app";
  1771. Engine.CONFIG_PATH = Engine.DIR + "/engine-config.json";
  1772. Engine.STATE_PATH = Engine.DIR + "/run-state.json";
  1773. Engine.STOP_FLAG_PATH = Engine.DIR + "/stop.flag";
  1774. /**
  1775. * 引擎的日志文件(jsonl,一行一条)。
  1776. *
  1777. * 2026-10-13 新增:D3 运行详情里的「执行日志」要看的就是它。
  1778. * 为什么走文件而不是读 console:引擎是**另一个 ScriptEngine**,
  1779. * 它的 stdout 界面根本读不到(见本文件头的通道说明)。
  1780. * 引擎侧由 `logger.setSink` → `files.append` 落盘(tiktok-auto/src/bridge.js)。
  1781. */
  1782. Engine.LOG_PATH = Engine.DIR + "/run-log.jsonl";
  1783. /** 一条记录最多带走多少行日志(记录要落盘,不能让日志把它撑爆)。 */
  1784. Engine.LOG_MAX_LINES = 200;
  1785. /** 引擎产物在项目里的相对位置(项目模式下从这个位置找) */
  1786. Engine.SCRIPT_RELATIVE = "tiktok-auto/dist/tiktok-auto.js";
  1787. /** 本次运行的标识。由界面生成并同时告诉引擎与停止标志,三方比对。 */
  1788. Engine.RUN_ID = "";
  1789. /** 上次定位到的引擎产物路径;null 表示还没找过 */
  1790. Engine.scriptPath = null;
  1791. /**
  1792. * 是否**确实在引擎列表里见过**子引擎。
  1793. *
  1794. * 用途唯一:给"引擎没写终态就没了"的兜底判定加一道保险。
  1795. * `engines.all()` 在不同运行时里的可见性不完全一致 —— 如果它从来就不报告子引擎,
  1796. * 那"看不到子引擎"这件事毫无信息量,此时宁可不判定,也不要误把正在跑的运行结束掉。
  1797. */
  1798. Engine.sawChild = false;
  1799. /* ------------------------------------------------------------ 运行标识 */
  1800. /**
  1801. * 生成本次运行的标识。
  1802. * 用「时间戳 + 随机串」:同一秒内连点两次也不会撞,
  1803. * 而且人在日志里一眼能看出这是哪一次运行。
  1804. */
  1805. Engine.newRunId = function () {
  1806. return "run-" + Date.now() + "-" + Math.floor(Math.random() * 100000);
  1807. };
  1808. /* ------------------------------------------------------------ 定位引擎 */
  1809. /** 探测用的候选路径,按「最可能正确」排序。 */
  1810. Engine.candidates = function () {
  1811. var list = [];
  1812. /* ① 入口脚本同级 —— 项目模式下最准。
  1813. * 用 myEngine().source 而不是 files.cwd():后者依赖运行方式,
  1814. * 前者永远是入口脚本的真实路径。 */
  1815. try {
  1816. var source = String(engines.myEngine().source || "");
  1817. var cut = source.lastIndexOf("/");
  1818. if (cut > 0) {
  1819. list.push(source.substring(0, cut) + "/" + Engine.SCRIPT_RELATIVE);
  1820. }
  1821. } catch (e) {
  1822. /* 取不到就靠后面的候选 */
  1823. }
  1824. /* ② 扫一层项目目录:产物可能来自另一个项目文件夹 */
  1825. try {
  1826. var names = files.listDir("/sdcard/Scripts");
  1827. for (var i = 0; i < names.length; i += 1) {
  1828. var dir = "/sdcard/Scripts/" + names[i];
  1829. if (files.isDir(dir)) {
  1830. list.push(dir + "/" + Engine.SCRIPT_RELATIVE);
  1831. }
  1832. }
  1833. } catch (e) {
  1834. /* 没这个目录就跳过 */
  1835. }
  1836. /* ③ 扁平部署:产物被单独拷到 Download(手工推送时的常见形态) */
  1837. list.push("/sdcard/Download/tiktok-auto.js");
  1838. list.push(Engine.DIR + "/tiktok-auto.js");
  1839. return list;
  1840. };
  1841. /**
  1842. * 找到引擎产物。
  1843. * @returns 路径;找不到返回空串(调用方负责给出可操作的提示)
  1844. */
  1845. Engine.locate = function () {
  1846. var list = Engine.candidates();
  1847. for (var i = 0; i < list.length; i += 1) {
  1848. try {
  1849. if (files.exists(list[i])) {
  1850. Engine.scriptPath = list[i];
  1851. return list[i];
  1852. }
  1853. } catch (e) {
  1854. /* 单个候选探测失败不该中断整轮查找 */
  1855. }
  1856. }
  1857. Engine.scriptPath = null;
  1858. return "";
  1859. };
  1860. /* ------------------------------------------------------------ 配置映射 */
  1861. function trimAll(text) {
  1862. return String(text).replace(/^\s+|\s+$/g, "");
  1863. }
  1864. /**
  1865. * 读文本文件的行,跳过空行与 `#` 开头的注释行。
  1866. * 读不到(文件不存在 / 权限问题)时返回空数组 —— 调用方按"没配"处理。
  1867. */
  1868. function readLines(path) {
  1869. var out = [];
  1870. try {
  1871. if (!path || !files.exists(path)) { return out; }
  1872. var lines = String(files.read(path)).split(/\r?\n/);
  1873. for (var i = 0; i < lines.length; i += 1) {
  1874. var text = trimAll(lines[i]);
  1875. if (text !== "" && text.charAt(0) !== "#") { out.push(text); }
  1876. }
  1877. } catch (e) {
  1878. console.log("[engine] 读取文本失败 " + path + ": " + e);
  1879. }
  1880. return out;
  1881. }
  1882. function randomInt(min, max) {
  1883. var low = Math.floor(min);
  1884. var high = Math.floor(max);
  1885. if (high < low) { high = low; }
  1886. return low + Math.floor(Math.random() * (high - low + 1));
  1887. }
  1888. /** 秒 → 毫秒,并给一个下限:0 秒会让"观看"立刻返回,等于没看 */
  1889. function seconds(ms) {
  1890. return Math.max(1000, Math.floor(ms) * 1000);
  1891. }
  1892. /**
  1893. * 界面配置 → 引擎配置。
  1894. *
  1895. * @returns { config, notes }
  1896. * notes 是「用户表达了意图、但引擎这次做不到」的条目,逐条是人话。
  1897. */
  1898. Engine.buildConfig = function () {
  1899. var notes = [];
  1900. var keyword = Store.getText("tabs.video.keyword");
  1901. var stopMin = Store.getNumber("tabs.video.stopMin");
  1902. var stopMax = Store.getNumber("tabs.video.stopMax");
  1903. var intervalMin = Store.getNumber("tabs.video.intervalMin");
  1904. var intervalMax = Store.getNumber("tabs.video.intervalMax");
  1905. var doComment = Store.getBool("tabs.video.actions.comment");
  1906. var pool = readLines(Store.getText("tabs.video.commentTextPath"));
  1907. if (doComment && pool.length === 0) {
  1908. notes.push("\u8BC4\u8BBA\u6587\u672C\u91CC\u6CA1\u6709\u53EF\u7528\u884C\uFF0C"
  1909. + "\u672C\u6B21\u4F1A\u7528\u5185\u7F6E\u9ED8\u8BA4\u8BC4\u8BBA");
  1910. }
  1911. var config = {
  1912. target: {
  1913. keyword: keyword,
  1914. /* 「停止个数 最小值 ~ 最大值」= 每次运行在这个区间里随机停 ——
  1915. * 固定不变会让每次运行的行为完全一样,反而更容易被看出是脚本。 */
  1916. maxVideos: randomInt(stopMin, stopMax),
  1917. resultTab: "Top",
  1918. pickStrategy: "random"
  1919. },
  1920. /* 界面的「间隔时间(秒)」映射为**每个视频的观看时长**。
  1921. * 引擎目前没有单独的"两个视频之间的停顿"配置,而"看 20 秒再操作"
  1922. * 与"每 20 秒处理一个"在行为上是一回事。 */
  1923. watch: {
  1924. minMs: seconds(intervalMin),
  1925. maxMs: seconds(intervalMax)
  1926. },
  1927. comments: {
  1928. /* ★ 勾了「评论」才发评论。没勾就只是浏览 ——
  1929. * 引擎侧据此跳过评论动作,而不是照默认值照发。
  1930. * 用户的意图优先于引擎的默认行为。 */
  1931. enabled: doComment,
  1932. avoidRepeatWithinRun: true,
  1933. verifyInList: true
  1934. },
  1935. runtime: {
  1936. configPath: Engine.CONFIG_PATH,
  1937. dryRun: false,
  1938. maxRuntimeMs: 0
  1939. }
  1940. };
  1941. if (pool.length > 0) {
  1942. config.comments.pool = pool;
  1943. }
  1944. notes = notes.concat(Engine.unsupportedNotes());
  1945. return { config: config, notes: notes };
  1946. };
  1947. /**
  1948. * 引擎这次做不到、但用户可能勾了的项。
  1949. *
  1950. * 单独抽出来是因为它只跟「已实现的动作集合」有关,
  1951. * 将来引擎接入了点赞,**只改这一个函数**就够。
  1952. */
  1953. Engine.unsupportedNotes = function () {
  1954. var notes = [];
  1955. if (Store.getBool("tabs.video.actions.like")) {
  1956. notes.push("\u70B9\u8D5E\u52A8\u4F5C\uFF1A\u5F15\u64CE\u5C1A\u672A\u5B9E\u73B0");
  1957. }
  1958. if (Store.getBool("tabs.video.actions.follow")) {
  1959. notes.push("\u5173\u6CE8\u52A8\u4F5C\uFF1A\u5F15\u64CE\u5C1A\u672A\u5B9E\u73B0");
  1960. }
  1961. if (Store.getBool("tabs.video.actions.dm")) {
  1962. notes.push("\u79C1\u4FE1\u52A8\u4F5C\uFF1A\u5F15\u64CE\u5C1A\u672A\u5B9E\u73B0");
  1963. }
  1964. return notes;
  1965. };
  1966. /**
  1967. * notes → 一句能放在 toast 里的话(没有就返回空串)。
  1968. *
  1969. * 只取每条的**短名**(中文冒号前的部分)拼起来:三条完整说明拼起来有四十多个字,
  1970. * 那种长度的 toast 用户根本来不及读。完整内容在 run-config.json 里,随时可查。
  1971. */
  1972. Engine.summarizeNotes = function (notes) {
  1973. if (!notes || notes.length === 0) { return ""; }
  1974. var names = [];
  1975. for (var i = 0; i < notes.length; i += 1) {
  1976. names.push(String(notes[i]).split("\uFF1A")[0]);
  1977. }
  1978. return names.join("/") + " \u672C\u6B21\u4E0D\u4F1A\u751F\u6548";
  1979. };
  1980. /* ------------------------------------------------------------ 启动 */
  1981. /**
  1982. * 准备一次运行:定位产物 + 构建配置。
  1983. *
  1984. * @returns { ok, scriptPath, config, notes, error }
  1985. * error 是给用户看的(说清楚"缺什么、放哪"),不是技术异常串。
  1986. */
  1987. Engine.prepare = function () {
  1988. var path = Engine.locate();
  1989. if (!path) {
  1990. return {
  1991. ok: false,
  1992. error: "\u627E\u4E0D\u5230\u6267\u884C\u5F15\u64CE\u4EA7\u7269\uFF08" + Engine.SCRIPT_RELATIVE + "\uFF09\u3002\n\n"
  1993. + "\u8BF7\u5728 PC \u4E0A\u6267\u884C node tiktok-auto/tools/build.js \u540E\uFF0C"
  1994. + "\u5C06 tiktok-auto/dist/tiktok-auto.js \u653E\u5230 \u9879\u76EE\u6839\u76EE\u5F55\u4E0B,\n"
  1995. + "\u6216\u76F4\u63A5\u63A8\u5230 /sdcard/Download/tiktok-auto.js\u3002"
  1996. };
  1997. }
  1998. var built = Engine.buildConfig();
  1999. /* 关键词是引擎的硬前提:空关键词会导致搜索失败,与其让它跑一半报错,
  2000. * 不如在这里挡住并告诉用户填哪里。 */
  2001. if (trimAll(built.config.target.keyword) === "") {
  2002. return {
  2003. ok: false,
  2004. scriptPath: path,
  2005. 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"
  2006. };
  2007. }
  2008. return {
  2009. ok: true,
  2010. scriptPath: path,
  2011. config: built.config,
  2012. notes: built.notes
  2013. };
  2014. };
  2015. /** 把引擎配置落盘。返回是否成功。 */
  2016. Engine.writeConfig = function (config) {
  2017. try {
  2018. files.ensureDir(Engine.DIR + "/");
  2019. files.write(Engine.CONFIG_PATH, JSON.stringify(config, null, 2));
  2020. return true;
  2021. } catch (e) {
  2022. console.log("[engine] 引擎配置落盘失败: " + e);
  2023. return false;
  2024. }
  2025. };
  2026. /**
  2027. * 唤起引擎(另一个 ScriptEngine,不是当前脚本的线程)。
  2028. *
  2029. * 参数用 `--key value` 的平铺形式传:Rhino 会把数组包装成 {"0":..,"1":..},
  2030. * 桥那边按数字下标取,两边约定越简单越不容易出错。
  2031. */
  2032. Engine.launch = function (runId, scriptPath) {
  2033. try {
  2034. var handle = engines.execScriptFile(scriptPath || Engine.scriptPath, {
  2035. arguments: [
  2036. "--config", Engine.CONFIG_PATH,
  2037. "--state", Engine.STATE_PATH,
  2038. "--stop-flag", Engine.STOP_FLAG_PATH,
  2039. "--log", Engine.LOG_PATH,
  2040. "--run-id", runId
  2041. ]
  2042. });
  2043. if (!handle) {
  2044. return { ok: false, error: "\u5F15\u64CE\u542F\u52A8\u8FD4\u56DE\u7A7A\uFF08\u53EF\u80FD\u88AB\u7CFB\u7EDF\u62E6\u622A\uFF09" };
  2045. }
  2046. return { ok: true };
  2047. } catch (e) {
  2048. console.log("[engine] 唤起失败: " + e);
  2049. return { ok: false, error: String(e) };
  2050. }
  2051. };
  2052. /* ------------------------------------------------------------ 日志回读 */
  2053. /**
  2054. * 读引擎这次运行写下的日志(D3 的「执行日志」)。
  2055. *
  2056. * 三条原则:
  2057. * · **一行坏了只丢一行**:jsonl 逐行 parse,坏行跳过 —— 进程被杀时最后一行
  2058. * 常常是半截的,整份 JSON.parse 会直接全丢;
  2059. * · 只取**最近** LOG_MAX_LINES 行:记录要落盘,日志不能把它撑到几百 KB;
  2060. * · 读不到就返回空数组,由页面如实说明「本次没有回传日志」,
  2061. * 绝不编一行"看起来像日志"的东西。
  2062. */
  2063. Engine.readLog = function () {
  2064. var out = [];
  2065. try {
  2066. if (!files.exists(Engine.LOG_PATH)) { return out; }
  2067. var lines = String(files.read(Engine.LOG_PATH)).split("\n");
  2068. var start = Math.max(0, lines.length - 1 - Engine.LOG_MAX_LINES);
  2069. for (var i = start; i < lines.length; i++) {
  2070. var text = String(lines[i]).replace(/^\s+|\s+$/g, "");
  2071. if (text === "") { continue; }
  2072. try {
  2073. var one = JSON.parse(text);
  2074. if (one && one.text) { out.push(one); }
  2075. } catch (e) {
  2076. /* 半截行:跳过这一行,其余照收 */
  2077. }
  2078. }
  2079. } catch (e2) {
  2080. console.log("[engine] 读取运行日志失败: " + e2);
  2081. }
  2082. return out;
  2083. };
  2084. /* ------------------------------------------------------------ 状态回读 */
  2085. /** 读引擎上报的状态。没有 / 读坏了都返回 null。 */
  2086. Engine.readState = function () {
  2087. try {
  2088. if (!files.exists(Engine.STATE_PATH)) { return null; }
  2089. var parsed = JSON.parse(String(files.read(Engine.STATE_PATH)));
  2090. return parsed && typeof parsed === "object" ? parsed : null;
  2091. } catch (e) {
  2092. return null;
  2093. }
  2094. };
  2095. /** 运行中(含正在收尾)—— 界面据此继续显示状态条 */
  2096. Engine.isActiveStatus = function (status) {
  2097. return status === "starting" || status === "running" || status === "stopping";
  2098. };
  2099. /** 终态 —— 界面据此结束运行态并告知结果 */
  2100. Engine.isTerminalStatus = function (status) {
  2101. return status === "finished" || status === "stopped" || status === "failed";
  2102. };
  2103. /** 请求停止:写停止标志(内容 = runId,引擎比对通过才会停) */
  2104. Engine.requestStop = function (runId) {
  2105. try {
  2106. files.ensureDir(Engine.DIR + "/");
  2107. files.write(Engine.STOP_FLAG_PATH, String(runId));
  2108. return true;
  2109. } catch (e) {
  2110. console.log("[engine] 写停止标志失败: " + e);
  2111. return false;
  2112. }
  2113. };
  2114. /**
  2115. * 引擎是否还活着。
  2116. *
  2117. * 用途只有一个:**兜底判定"引擎没写终态就没了"**(被系统杀掉、被手工停掉)。
  2118. * 查不出来时返回 true(当作还活着)—— 宁可多显示一会儿"运行中",
  2119. * 也不要误判成"已结束"而把运行态清掉、让用户以为跑完了。
  2120. */
  2121. Engine.childAlive = function () {
  2122. try {
  2123. var mine = engines.myEngine();
  2124. var all = engines.all() || [];
  2125. for (var i = 0; i < all.length; i += 1) {
  2126. var one = all[i];
  2127. if (one && String(one.id) !== String(mine.id)) {
  2128. return true;
  2129. }
  2130. }
  2131. return false;
  2132. } catch (e) {
  2133. return true;
  2134. }
  2135. };
  2136. /** 复位内存态。**不删文件**:状态靠 runId 比对,残留自然失效。 */
  2137. Engine.reset = function () {
  2138. Engine.RUN_ID = "";
  2139. };
  2140. /* ============================== src/session.js ============================== */
  2141. /* ============================== src/session.js ==============================
  2142. * 运行会话状态:跑没跑、跑的是哪个脚本、跑了多久。
  2143. *
  2144. * 为什么单独一个模块,而不是塞进 Store:
  2145. * Store 是**持久化**的配置,而「正在运行」是**进程内**的瞬时状态。
  2146. * 把它写进配置会有两个后果:
  2147. * ① 上次没点「停止运行」就杀进程,下次启动界面会显示成「运行中」——
  2148. * 可 TikTok 那边根本没人在驱动,用户等半天以为卡住了;
  2149. * ② 计时起点会被迫落盘,进程重启后算出来的时长是错的。
  2150. * 所以这里只存在于内存里,冷启动一律是「未运行」。
  2151. *
  2152. * 真正的执行由 tiktok-auto 那套脚本负责,本模块只负责
  2153. * 「界面上的运行态」与「给执行侧的那份运行配置」保持一致。
  2154. */
  2155. var Session = {};
  2156. Session.running = false;
  2157. Session.tab = "";
  2158. Session.scriptKey = "";
  2159. Session.scriptName = "";
  2160. Session.startedAt = 0;
  2161. /** 本次运行的标识。与引擎上报的状态、停止标志三者比对,缺一不可。 */
  2162. Session.runId = "";
  2163. /** 引擎上报的阶段文案("搜索 fashion" / "已完成 2/5"…)。空串表示还没收到。 */
  2164. Session.phase = "";
  2165. /** 进度计数,由引擎上报。仅用于状态条,不参与判断。 */
  2166. Session.done = 0;
  2167. Session.total = 0;
  2168. /** 已发出停止请求、正在等引擎确认。 */
  2169. Session.stopping = false;
  2170. /** 发出停止请求的时刻,用于超时兜底(引擎不确认也不能一直等)。 */
  2171. Session.stopRequestedAt = 0;
  2172. /* 计时器句柄。用 setInterval 而不是 threads.start:
  2173. * UI 模式下 setInterval 的回调跑在主线程的 Looper 上,可以安全改 View;
  2174. * 而 threads.start + ui.run 那条链路在真机上改 TextView 会崩
  2175. * (见 page-mine.js 里 checkUpdateAsync 的注释)。 */
  2176. Session.tickHandle = null;
  2177. /**
  2178. * 启动一次运行。
  2179. *
  2180. * @param runId 引擎桥的运行标识。界面用它过滤状态文件 ——
  2181. * 设备上可能残留上一轮的状态,不比对就会把旧结果当成本轮结果。
  2182. */
  2183. Session.start = function (tab, scriptKey, scriptName, runId) {
  2184. Session.running = true;
  2185. Session.tab = tab;
  2186. Session.scriptKey = scriptKey;
  2187. Session.scriptName = scriptName;
  2188. Session.startedAt = Date.now();
  2189. Session.runId = runId || "";
  2190. Session.phase = "";
  2191. Session.done = 0;
  2192. Session.total = 0;
  2193. Session.stopping = false;
  2194. Session.stopRequestedAt = 0;
  2195. };
  2196. Session.stop = function () {
  2197. Session.running = false;
  2198. Session.tab = "";
  2199. Session.scriptKey = "";
  2200. Session.scriptName = "";
  2201. Session.startedAt = 0;
  2202. Session.runId = "";
  2203. Session.phase = "";
  2204. Session.done = 0;
  2205. Session.total = 0;
  2206. Session.stopping = false;
  2207. Session.stopRequestedAt = 0;
  2208. };
  2209. /**
  2210. * 运行条主文案(设计稿 B2:`● UID私信 · 脚本一 · 运行中`)。
  2211. *
  2212. * 三段拼装:任务名 · 脚本短名 · 状态。
  2213. * 脚本短名与任务名相同时**不重复显示**(视频功能页只有一个脚本,
  2214. * 拼出来会变成「视频功能 · 关键词视频功能 · 运行中」,白占一半宽度)。
  2215. *
  2216. * 「正在收尾」是刻意保留的一个中间态:点了停止之后引擎还要几秒才真的停手
  2217. * (要退出当前等待、回到信息流)。这时界面如果说「已停止」,
  2218. * 而 TikTok 还在自己动,用户不知道该信谁 —— 如实说"正在收尾"更对。
  2219. */
  2220. Session.barText = function () {
  2221. var taskName = Pages.taskName(Session.tab);
  2222. var short = Pages.shortScriptName(Session.scriptName);
  2223. /* 脚本短名与任务名重合(或已包含任务名,如「视频功能」⊂「关键词视频功能」)时
  2224. * 不再重复一遍:拼出来会变成「视频功能 · 关键词视频功能 · 运行中」,白占一半宽度。 */
  2225. var dup = short === "" || short === taskName || short.indexOf(taskName) >= 0;
  2226. var head = dup ? taskName : (taskName + " \u00B7 " + short);
  2227. if (Session.stopping) { return head + " \u00B7 \u6B63\u5728\u6536\u5C3E"; }
  2228. if (Session.phase) { return head + " \u00B7 " + Session.phase; }
  2229. return head + " \u00B7 \u8FD0\u884C\u4E2D";
  2230. };
  2231. /** 兼容旧调用点(自检里按这个名字断言过)。语义就是 barText。 */
  2232. Session.statusText = function () {
  2233. return Session.barText();
  2234. };
  2235. /** 已运行毫秒数。未运行时返回 0,避免调用方拿到负数。 */
  2236. Session.elapsedMs = function () {
  2237. if (!Session.running || !Session.startedAt) { return 0; }
  2238. return Math.max(0, Date.now() - Session.startedAt);
  2239. };
  2240. /** 毫秒 → 01:24:37 形态。固定两位,等宽字下不会左右跳。 */
  2241. Session.formatElapsed = function (ms) {
  2242. var total = Math.floor(ms / 1000);
  2243. var h = Math.floor(total / 3600);
  2244. var m = Math.floor((total % 3600) / 60);
  2245. var s = total % 60;
  2246. return pad2(h) + ":" + pad2(m) + ":" + pad2(s);
  2247. };
  2248. function pad2(n) {
  2249. return n < 10 ? "0" + n : String(n);
  2250. }
  2251. /**
  2252. * 每秒推一次计时。
  2253. *
  2254. * onTick 只做「改一个 TextView 的文字」,不重建界面——
  2255. * 每秒重建整页会让滚动位置归零,用户根本没法边跑边看配置。
  2256. */
  2257. Session.startTimer = function (onTick) {
  2258. Session.stopTimer();
  2259. onTick();
  2260. Session.tickHandle = setInterval(function () {
  2261. /* 回调里任何异常都不能让它继续每秒抛一次,否则日志会被刷爆 */
  2262. try {
  2263. onTick();
  2264. } catch (e) {
  2265. console.log("[session] 计时刷新失败: " + e);
  2266. Session.stopTimer();
  2267. }
  2268. }, 1000);
  2269. };
  2270. Session.stopTimer = function () {
  2271. if (Session.tickHandle !== null) {
  2272. clearInterval(Session.tickHandle);
  2273. Session.tickHandle = null;
  2274. }
  2275. };
  2276. /* ============================== src/widgets.js ============================== */
  2277. /* ============================== src/widgets.js ==============================
  2278. * 通用组件工厂。
  2279. *
  2280. * 设计稿里的每一类元素(卡片 / 设置行 / 下拉 / 开关 / 胶囊按钮 / 状态标签 /
  2281. * 勾选框 / 页签 / 主按钮)在这里各有一个构造函数,页面只做「拼装」。
  2282. * 目的很直接:同一元素在 5 个页面里必须长得一样,
  2283. * 布局参数一旦散落到页面代码,就会出现「这个页的卡片圆角是 16、那个页是 12」。
  2284. *
  2285. * 所有构造函数都返回 Android View;需要后续改动的额外返回 setter。
  2286. */
  2287. var W = {};
  2288. /* ---------------------------------------------------------------- 卡片 */
  2289. /**
  2290. * 圆角卡片容器(vertical)。
  2291. * active=true 用青蓝描边 35%,false 用白 8% —— 对应设计稿里「已勾选脚本卡」的差异。
  2292. */
  2293. W.card = function (active) {
  2294. var card = U.column();
  2295. card.setBackground(active
  2296. ? U.strokeBg(Theme.alpha.cardFillActive, Theme.alpha.cardStrokeActive, Theme.hex.accent, Theme.m.cardRadius)
  2297. : U.strokeBg(Theme.alpha.cardFill, Theme.alpha.cardStroke, "#FFFFFF", Theme.m.cardRadius));
  2298. card.setPadding(U.dp(Theme.m.cardPadding), U.dp(Theme.m.cardPadding),
  2299. U.dp(Theme.m.cardPadding), U.dp(Theme.m.cardPadding));
  2300. return card;
  2301. };
  2302. /** 按序把子 View 加进容器,并按 gap 插入间距,避免每处手写 spacer。 */
  2303. W.stack = function (container, children, gapDp) {
  2304. var gap = gapDp === undefined ? 12 : gapDp;
  2305. for (var i = 0; i < children.length; i++) {
  2306. if (!children[i]) { continue; }
  2307. if (i > 0 && gap > 0) { container.addView(U.spacer(gap)); }
  2308. container.addView(children[i]);
  2309. }
  2310. return container;
  2311. };
  2312. /** 区标题(「脚本选择」「功能设置」这类分段标题)。 */
  2313. W.sectionTitle = function (label) {
  2314. return U.text(label, Theme.m.sectionSize, Theme.hex.textPrimary, true);
  2315. };
  2316. /* ------------------------------------------------------------ 顶部标题区 */
  2317. /**
  2318. * 页面顶部:大标题 + 副标题 + 右侧设置入口。
  2319. * 5 个页签共用同一个标题区,所以文案由页面传入而不是写死在这里。
  2320. */
  2321. W.pageHeader = function (title, subtitle, onTapSettings) {
  2322. var left = U.column();
  2323. U.params(left, 0, -2, 1);
  2324. left.addView(U.text(title, Theme.m.titleSize, Theme.hex.textPrimary, true));
  2325. left.addView(U.spacer(4));
  2326. left.addView(U.text(subtitle, 12, Theme.hex.textMuted, false));
  2327. var header = U.row();
  2328. header.addView(left);
  2329. if (onTapSettings) {
  2330. var gear = U.text("\u2699", 18, Theme.hex.textSecondary, false);
  2331. gear.setGravity(android.view.Gravity.CENTER);
  2332. U.params(gear, 22, 22);
  2333. gear.setOnClickListener(function () { onTapSettings(); });
  2334. header.addView(gear);
  2335. }
  2336. return header;
  2337. };
  2338. /* ------------------------------------------------------------ 脚本卡 */
  2339. /**
  2340. * 脚本卡头部一行:勾选框/单选框 + 脚本名 +(摘要)+ 展开箭头。
  2341. *
  2342. * 勾选与展开是两个独立命中区——合并成一个的话,
  2343. * 用户想展开却把脚本关掉是必然事件。
  2344. *
  2345. * mode: "checkbox"(默认)| "radio"。设计稿的 ID 提取页是单选
  2346. * (「同一时间仅可运行一个脚本」),其余页是可多选的勾选。
  2347. * 互斥由页面负责——组件只管画和报「点完应该是什么状态」。
  2348. *
  2349. * radio 的「再点一下取消」是有意为之:设计稿专门画了「未选择脚本」这一屏
  2350. * (16:23 + 「请先选择要运行的脚本」拦截弹窗),必须有一条路能回到那个状态。
  2351. * 严格单选一旦选过就再也回不去,那一屏就成了永远看不到的死代码。
  2352. *
  2353. * disabled: 运行中不允许切换脚本(设计稿 09 的底部提示就是这么写的)。
  2354. */
  2355. W.scriptHeader = function (opts) {
  2356. var isRadio = opts.mode === "radio";
  2357. var disabled = !!opts.disabled;
  2358. var box = isRadio ? W.radio(opts.checked, 18) : W.checkbox(opts.checked, 18);
  2359. /* 名称用「内容宽度 + 弹性占位顶开」,而不是 weight=1。
  2360. * weight=1 的写法在右侧存在 MATCH_PARENT 控件时会被挤成 0 宽(真机踩过),
  2361. * 用 flexible() 占位则无论同行其它控件怎么变都成立。 */
  2362. var nameView = U.text(opts.name, Theme.m.labelSize,
  2363. disabled ? Theme.hex.textMuted : Theme.hex.textPrimary, true);
  2364. nameView.setSingleLine(true);
  2365. nameView.setEllipsize(android.text.TextUtils.TruncateAt.END);
  2366. var head = U.row();
  2367. head.addView(box.root);
  2368. head.addView(U.hgap(Theme.m.gapInline));
  2369. head.addView(nameView);
  2370. head.addView(U.flexible());
  2371. if (opts.summary) {
  2372. head.addView(U.text(opts.summary, Theme.m.chipSize, Theme.hex.textFaint, false));
  2373. head.addView(U.hgap(6));
  2374. }
  2375. var arrowBox = U.row(false);
  2376. arrowBox.setGravity(android.view.Gravity.CENTER);
  2377. U.params(arrowBox, 16, 16);
  2378. var arrow = W.arrow(opts.expanded);
  2379. arrowBox.addView(arrow.root);
  2380. head.addView(arrowBox);
  2381. box.root.setOnClickListener(function () {
  2382. if (disabled) { return; }
  2383. /* 两种模式都是「取反」——单选组也允许点已选中项来取消,
  2384. * 详见上面 radio 的说明。互斥(同组只留一个)由页面处理。 */
  2385. var next = !box.isChecked();
  2386. box.set(next);
  2387. opts.onToggleCheck(next);
  2388. });
  2389. arrowBox.setOnClickListener(function () {
  2390. /* 展开只影响看到多少设置,不影响「要不要跑」,运行中也不必禁用 */
  2391. opts.onToggleExpand();
  2392. });
  2393. return { root: head, nameView: nameView, arrow: arrow, checkbox: box };
  2394. };
  2395. /* ---------------------------------------------------------------- 勾选框 */
  2396. /**
  2397. * 勾选框。选中 = 青蓝实心 + 白色对勾;未选中 = 透明底 + 灰描边。
  2398. * 用「字符当对勾」而不是 VectorDrawable:省掉资源文件,且离线桩测能直接验证状态。
  2399. */
  2400. W.checkbox = function (checked, sizeDp) {
  2401. var size = sizeDp || 16;
  2402. var radius = size <= 16 ? 4 : 5;
  2403. var view = U.text("", 11, Theme.hex.textPrimary, true);
  2404. view.setGravity(android.view.Gravity.CENTER);
  2405. U.params(view, size, size);
  2406. var state = { checked: !!checked };
  2407. function paint() {
  2408. if (state.checked) {
  2409. view.setText("\u2713");
  2410. view.setBackground(U.solidBg(U.argb(Theme.hex.accent), radius));
  2411. } else {
  2412. view.setText("");
  2413. view.setBackground(U.strokeBg(0, 1, Theme.hex.tabIdle, radius));
  2414. }
  2415. }
  2416. paint();
  2417. return {
  2418. root: view,
  2419. set: function (value) { state.checked = !!value; paint(); },
  2420. isChecked: function () { return state.checked; }
  2421. };
  2422. };
  2423. /* ---------------------------------------------------------------- 单选框 */
  2424. /**
  2425. * 单选框(设计稿 01「脚本一」的 16:1072)。
  2426. *
  2427. * 与勾选框的差别不只是外形,更是**语义**:一组里同时只能有一个被选中
  2428. * (设计稿写得很明确——「单选:同一时间仅可运行一个脚本」)。
  2429. * 所以这个组件只负责画与报状态,「互斥」由页面负责清掉同组其它项。
  2430. */
  2431. W.radio = function (checked, sizeDp) {
  2432. var size = sizeDp || 18;
  2433. var view = U.text("", Theme.m.chipSize, Theme.hex.textPrimary, true);
  2434. view.setGravity(android.view.Gravity.CENTER);
  2435. U.params(view, size, size);
  2436. /* 选中 = 青蓝实心圆 + 白色圆点;未选中 = 透明底 + 灰描边圆。
  2437. * 圆点用 "●"(U+25CF)而不是画 VectorDrawable:与勾选框同一套思路,
  2438. * 省掉资源文件,且离线桩测能直接断言状态。 */
  2439. var state = { checked: !!checked };
  2440. function paint() {
  2441. if (state.checked) {
  2442. view.setText("\u25CF");
  2443. view.setTextSize(7);
  2444. view.setBackground(U.solidBg(U.argb(Theme.hex.accent), 99));
  2445. } else {
  2446. view.setText("");
  2447. view.setBackground(U.strokeBg(0, 1, Theme.hex.tabIdle, 99));
  2448. }
  2449. }
  2450. paint();
  2451. return {
  2452. root: view,
  2453. set: function (value) { state.checked = !!value; paint(); },
  2454. isChecked: function () { return state.checked; }
  2455. };
  2456. };
  2457. /* ---------------------------------------------------------------- 箭头 */
  2458. /** 展开 / 收起箭头。字符 U+25BE / U+25B4 在 Android 默认字体里必然存在。 */
  2459. W.arrow = function (expanded) {
  2460. var view = U.text(expanded ? "\u25B4" : "\u25BE", 12, Theme.hex.textFaint, false);
  2461. view.setGravity(android.view.Gravity.CENTER);
  2462. return {
  2463. root: view,
  2464. set: function (value) { view.setText(value ? "\u25B4" : "\u25BE"); }
  2465. };
  2466. };
  2467. /**
  2468. * 指向右侧的箭头(运行记录条目 / 可进入下一级的行)。
  2469. *
  2470. * 用 U+203A(单右角引号)而不是 U+2192:后者在部分 Android 字体里带 emoji 变体,
  2471. * 会渲染成彩色箭头(页签图标踩过同一个坑)。U+203A 在默认字体里稳定是字形。
  2472. */
  2473. W.chevronRight = function (sizeSp, hex) {
  2474. var view = U.text("\u203A", sizeSp || 18, hex || Theme.hex.textFaint, false);
  2475. view.setGravity(android.view.Gravity.CENTER);
  2476. return view;
  2477. };
  2478. /* ------------------------------------------------------------ 设置行 */
  2479. /**
  2480. * 「左标签 + 右控件」设置行;右控件可省略(此时是纯文案行)。
  2481. *
  2482. * 左侧标签用内容宽度 + flexible() 顶开,**不用 weight=1**:
  2483. * 当右控件自身是 MATCH_PARENT(例如整行输入框)时,
  2484. * weight=1 的标签会被挤成 0 宽,在真机上就是「这一行的标签不见了」。
  2485. */
  2486. W.settingRow = function (label, rightView, gapDp) {
  2487. var nameView = U.text(label, Theme.m.labelSize, Theme.hex.textSecondary, false);
  2488. nameView.setSingleLine(true);
  2489. var line = U.row();
  2490. line.addView(nameView);
  2491. line.addView(U.flexible());
  2492. if (rightView) {
  2493. line.addView(U.hgap(gapDp === undefined ? 6 : gapDp));
  2494. line.addView(rightView);
  2495. }
  2496. return line;
  2497. };
  2498. /* ------------------------------------------------------------ 勾选设置行 */
  2499. /**
  2500. * 「勾选框 + 标签 +(可选右侧控件)」设置行。
  2501. *
  2502. * 设计稿里这类行有三种排布:
  2503. * · 单独一行(过滤私密账户);
  2504. * · 一行两个(过滤私密账户 | 过滤无头像账户);
  2505. * · 勾选后才出现右侧控件(云端去重 → 表名 + 连通测试)。
  2506. * 第三种由调用方按状态自己拼,前两种用这个函数。
  2507. *
  2508. * 命中区含整个标签:只让 16dp 的方框可点,真机上很难戳中。
  2509. */
  2510. W.checkRow = function (label, checked, onToggle) {
  2511. var box = W.checkbox(checked, 16);
  2512. var labelView = U.text(label, Theme.m.labelSize, Theme.hex.textSecondary, false);
  2513. labelView.setSingleLine(true);
  2514. var line = U.row(false);
  2515. line.addView(box.root);
  2516. line.addView(U.hgap(8));
  2517. line.addView(labelView);
  2518. line.setPadding(0, U.dp(4), 0, U.dp(4));
  2519. line.setOnClickListener(function () {
  2520. var next = !box.isChecked();
  2521. box.set(next);
  2522. onToggle(next);
  2523. });
  2524. return { root: line, isChecked: box.isChecked };
  2525. };
  2526. /* ------------------------------------------------------------ 胶囊 / 标签 */
  2527. /* 胶囊的四套色调集中在这里,W.chip 与 W.repaintChip 共用,
  2528. * 避免「改了一处忘了另一处」导致状态切换后颜色不更新。 */
  2529. W.TONES = {
  2530. "default": { fill: 0.06, stroke: 0.10, text: "#CFCCE6" },
  2531. "accent": { fill: 0.20, stroke: 0, text: "#7CC7FF" },
  2532. "warn": { fill: 0.16, stroke: 0, text: "#F5C24B" },
  2533. "ok": { fill: 0.16, stroke: 0, text: "#13DEC4" },
  2534. /* 不可逆操作用 danger,与「未开启」的琥珀色区分开——
  2535. * 两种暖色混用会让用户分不清「提示」和「警告」。 */
  2536. "danger": { fill: 0.16, stroke: 0, text: "#FF5470" },
  2537. "mute": { fill: 0.08, stroke: 0, text: "#8E8BA8" }
  2538. };
  2539. W.paintTone = function (view, tone, radiusDp) {
  2540. var t = W.TONES[tone] || W.TONES["default"];
  2541. view.setTextColor(U.argb(t.text));
  2542. view.setBackground(t.stroke > 0
  2543. ? U.strokeBg(t.fill, t.stroke, "#FFFFFF", radiusDp)
  2544. : U.solidBg(U.alphaOf("#FFFFFF", t.fill), radiusDp));
  2545. return view;
  2546. };
  2547. /**
  2548. * 胶囊元素的统一出口:下拉框、按钮、状态标签都用它,只是色调与圆角不同。
  2549. */
  2550. W.chip = function (label, tone, minWidthDp, radiusDp) {
  2551. var view = U.text(label, Theme.m.chipSize, W.TONES["default"].text, true);
  2552. view.setGravity(android.view.Gravity.CENTER);
  2553. view.setSingleLine(true);
  2554. W.paintTone(view, tone || "default", radiusDp === undefined ? Theme.m.chipRadius : radiusDp);
  2555. view.setPadding(U.dp(Theme.m.chipPadH), U.dp(Theme.m.chipPadV),
  2556. U.dp(Theme.m.chipPadH), U.dp(Theme.m.chipPadV));
  2557. if (minWidthDp) { view.setMinWidth(U.dp(minWidthDp)); }
  2558. return {
  2559. root: view,
  2560. setText: function (v) { view.setText(String(v)); },
  2561. setTone: function (nextTone) { W.paintTone(view, nextTone, Theme.m.chipRadius); }
  2562. };
  2563. };
  2564. /** 状态标签(全圆角),用于「未登录 / 已安装 v39.5.4 / 已是最新」。 */
  2565. W.pill = function (label, tone) {
  2566. var t = W.TONES[tone || "mute"] || W.TONES["mute"];
  2567. var view = U.text(label, Theme.m.chipSize, t.text, true);
  2568. view.setGravity(android.view.Gravity.CENTER);
  2569. view.setSingleLine(true);
  2570. view.setBackground(U.solidBg(U.alphaOf("#FFFFFF", t.fill), Theme.m.pillRadius));
  2571. view.setPadding(U.dp(Theme.m.chipPadH), U.dp(Theme.m.chipPadV),
  2572. U.dp(Theme.m.chipPadH), U.dp(Theme.m.chipPadV));
  2573. return {
  2574. root: view,
  2575. setText: function (v) { view.setText(String(v)); }
  2576. };
  2577. };
  2578. /* ------------------------------------------------------------ 下拉框 */
  2579. /**
  2580. * 下拉框。
  2581. *
  2582. * 为什么既不用 Spinner 也不用 dialogs.select:
  2583. * · Spinner 的弹出列表走系统浅色主题,在深色界面里是一块刺眼的白板,
  2584. * 要改得自定义 Adapter 与主题;
  2585. * · `dialogs.select` 在 UI 模式下**拿不到返回值**(阻塞会死锁,运行时直接返回 undefined),
  2586. * 结果是「选完写回 undefined」,配置被写坏、重启又变回默认值。
  2587. * 所以用自绘的 Dialog.choose:样式统一由 Theme 控制,结果走回调。
  2588. */
  2589. W.dropdown = function (group, valuePath, onChange) {
  2590. /* 用 getEnum 而不是 get:它保证拿回一个**合法 key**,
  2591. * 缺失或非法时回退到该枚举的默认项并回写配置。
  2592. * 于是这个胶囊在第一次渲染时就一定有值,不会出现空框。 */
  2593. var chip = W.chip(Store.label(group, Store.getEnum(valuePath, group)), "default", 60);
  2594. chip.root.setOnClickListener(function () {
  2595. Dialog.choose({
  2596. options: Store.options[group],
  2597. currentIndex: Store.indexOfLabel(group, Store.getEnum(valuePath, group)),
  2598. onConfirm: function (index) {
  2599. var key = Store.keyAt(group, index);
  2600. Store.set(valuePath, key);
  2601. chip.setText(Store.label(group, key));
  2602. if (onChange) { onChange(key); }
  2603. }
  2604. });
  2605. });
  2606. return {
  2607. root: chip.root,
  2608. set: function (key) { chip.setText(Store.label(group, key)); }
  2609. };
  2610. };
  2611. /* ------------------------------------------------------------ 开关 */
  2612. /**
  2613. * 二元开关。设计稿用的是「胶囊文字按钮」而不是滑块,
  2614. * 所以这里做成 chip:开 = 青蓝高亮,关 = 灰。
  2615. */
  2616. W.toggle = function (on, onChange) {
  2617. var chip = W.chip(on ? Store.label("toggle", "on") : Store.label("toggle", "off"),
  2618. on ? "accent" : "default", 44);
  2619. var state = !!on;
  2620. chip.root.setOnClickListener(function () {
  2621. state = !state;
  2622. chip.setText(state ? Store.label("toggle", "on") : Store.label("toggle", "off"));
  2623. chip.setTone(state ? "accent" : "default");
  2624. if (onChange) { onChange(state); }
  2625. });
  2626. return {
  2627. root: chip.root,
  2628. isOn: function () { return state; },
  2629. set: function (v) {
  2630. state = !!v;
  2631. chip.setText(state ? Store.label("toggle", "on") : Store.label("toggle", "off"));
  2632. chip.setTone(state ? "accent" : "default");
  2633. }
  2634. };
  2635. };
  2636. /* ------------------------------------------------------------ 多选开关行 */
  2637. /**
  2638. * 「执行操作」那种一行多选:关注 / 私信 / 点赞 / 评论。
  2639. * 每一项独立高亮、互不影响,所以不能复用 W.toggle(它是二元开关)。
  2640. */
  2641. W.toggleRow = function (items, onChange) {
  2642. /* 这一行是作为「设置行的右侧控件」使用的,必须包裹内容——
  2643. * 用默认的 fill=true 会占满整行,把左侧标签挤成 0 宽。 */
  2644. var line = U.row(false);
  2645. var chips = {};
  2646. for (var i = 0; i < items.length; i++) {
  2647. (function (item) {
  2648. var chip = W.chip(item.label, item.on ? "accent" : "default", 36);
  2649. var state = !!item.on;
  2650. chip.root.setOnClickListener(function () {
  2651. state = !state;
  2652. chip.setTone(state ? "accent" : "default");
  2653. if (onChange) { onChange(item.key, state); }
  2654. });
  2655. chips[item.key] = {
  2656. root: chip.root,
  2657. isOn: function () { return state; },
  2658. set: function (v) { state = !!v; chip.setTone(state ? "accent" : "default"); }
  2659. };
  2660. if (line.getChildCount() > 0) { line.addView(U.hgap(6)); }
  2661. line.addView(chip.root);
  2662. })(items[i]);
  2663. }
  2664. return { root: line, chips: chips };
  2665. };
  2666. /* ------------------------------------------------------------ 输入框 */
  2667. /**
  2668. * 常驻输入框 —— **直接在栏位里改**,不再「点一下弹出输入框再填」。
  2669. *
  2670. * 上一版为什么绕开常驻 EditText(理由已逐条失效,记在这里免得又被改回去):
  2671. * ① 怕 TextWatcher 这种 Java 接口构造不出来 —— 已在目标设备上实测通过:
  2672. * `new android.text.TextWatcher({...})` 与 `new View.OnFocusChangeListener({...})`
  2673. * 都能构造,真机逐字输入每键都触发回调。
  2674. * ② 怕忘绑监听 → 「能打字但永远不保存」(用户会报成「没有默认值」)——
  2675. * 监听现在写在本函数内部,全项目只有这一个输入框入口,不存在漏绑的余地。
  2676. *
  2677. * ★ 程序化回写必须防自触发:真机上 `setText` 会**同步**触发 TextWatcher
  2678. * (实测顺序是 before → on → after 全部跑完,setText 才返回)。
  2679. * 不挡的话「失焦正规化」会把夹紧后的值再存一遍,来回震荡。
  2680. *
  2681. * ★ 数字框在「从尾部退格」的过程中**不写配置**:把 2000 清成 2 会经过 200、20
  2682. * 这些**恰好合法**的中间值,用户清空后走开,配置就停在 2 上。
  2683. * 退格期间不写,失焦时一次性提交(那时文本已定稿;空串则回退原值)。
  2684. * 文本字段不受这条限制 —— 半截文本本身就是「用户正在输入的内容」,没有这个问题。
  2685. *
  2686. * opts:
  2687. * path 绑定配置路径 → 自动「读初值 / 输入即落盘 / 失焦正规化」
  2688. * value 未绑定 path 时的初值(一次性取值的场合,如登录页卡密)
  2689. * placeholder 空值提示
  2690. * numeric true → 数字键盘,按数值解析与落盘
  2691. * widthDp 固定宽度。横向布局里**必须**给:默认 MATCH_PARENT 会把同行标签挤成 0 宽
  2692. * fill true → 宽度撑满(独占一行的输入框,如登录页卡密)
  2693. * align "right" 等(数字在区间里居中对齐,见下)
  2694. * onLinkedChange 自己改完**连带改了别的字段**时的回调(只刷兄弟,不刷自己)
  2695. * onSubmit 软键盘回车
  2696. */
  2697. W.inlineField = function (opts) {
  2698. var bound = typeof opts.path === "string" && opts.path !== "";
  2699. var numeric = !!opts.numeric;
  2700. var edit = new android.widget.EditText(context);
  2701. edit.setTextSize(Theme.m.chipSize);
  2702. edit.setTextColor(U.argb(Theme.hex.textSecondary));
  2703. edit.setHintTextColor(U.argb(Theme.hex.textFaint));
  2704. edit.setHint(opts.placeholder || "");
  2705. edit.setSingleLine(true);
  2706. edit.setPadding(U.dp(8), U.dp(7), U.dp(8), U.dp(7));
  2707. if (numeric) {
  2708. edit.setInputType(android.text.InputType.TYPE_CLASS_NUMBER);
  2709. /* 数字短、盒子窄,居中比左右对齐更稳(设计稿里两种都有,取一致的居中) */
  2710. edit.setGravity(android.view.Gravity.CENTER);
  2711. } else if (opts.align === "right") {
  2712. edit.setGravity(android.view.Gravity.RIGHT);
  2713. }
  2714. edit.setImeOptions(android.view.inputmethod.EditorInfo.IME_ACTION_DONE);
  2715. /* 横向布局里宽度必须显式给死,否则 MATCH_PARENT 会吃掉整行(真机踩过的坑)。
  2716. * fill: true 用于独占一行的输入框(登录页卡密)。 */
  2717. var width = -2;
  2718. if (opts.fill) { width = -1; }
  2719. else if (opts.widthDp) { width = U.dp(opts.widthDp); }
  2720. U.params(edit, width, -2);
  2721. var idleBg = U.strokeBg(Theme.alpha.chipFill, Theme.alpha.chipStroke, "#FFFFFF", Theme.m.chipRadius);
  2722. /* 可编辑之后必须看得出来「现在改的是哪一格」,聚焦时换青蓝描边 */
  2723. var activeBg = U.strokeBg(Theme.alpha.chipFill, 0.55, Theme.hex.accent, Theme.m.chipRadius);
  2724. edit.setBackground(idleBg);
  2725. /* 程序化回写期间置位,挡住 TextWatcher —— 见函数头第 ★ 条 */
  2726. var writing = false;
  2727. /* 上一次的栏位文字,只用来判断「这一下是不是在退格」 */
  2728. var lastText = "";
  2729. function readStore() {
  2730. if (bound) {
  2731. return numeric ? String(Store.getNumber(opts.path)) : Store.getText(opts.path);
  2732. }
  2733. return opts.value === undefined || opts.value === null ? "" : String(opts.value);
  2734. }
  2735. function paint() {
  2736. writing = true;
  2737. try {
  2738. edit.setText(readStore());
  2739. /* ★ 用 String(...) 取长度,不要写 `edit.getText().length()`:
  2740. * 真机上 getText() 返回 android.text.Editable(有 length() 方法),
  2741. * 但测试桩返回的是 JS 字符串(length 是属性不是函数)——
  2742. * 写成 `.length()` 会让回写在桩上**每次都抛异常**,
  2743. * 于是「回写之后光标在哪」这类问题在 PC 侧永远测不出来。 */
  2744. edit.setSelection(String(edit.getText()).length);
  2745. } catch (e) {
  2746. console.log("[input] paint 失败 " + opts.path + ": " + e);
  2747. } finally {
  2748. /* 一定要走 finally:抛异常时若把 writing 留在 true,
  2749. * 这个输入框就**永远不会再写配置**了(静默哑掉)。 */
  2750. writing = false;
  2751. }
  2752. lastText = String(edit.getText());
  2753. }
  2754. /** 把栏位文字写进配置。空串 / 非数字一律不写(保留原值,由 paint 兜底显示)。 */
  2755. function commit() {
  2756. if (!bound) { return false; }
  2757. var raw = String(edit.getText()).trim();
  2758. if (numeric) {
  2759. if (raw === "") { return false; }
  2760. var n = Number(raw);
  2761. if (isNaN(n)) { return false; }
  2762. Store.set(opts.path, n);
  2763. } else {
  2764. Store.set(opts.path, raw);
  2765. }
  2766. /* 上界不得小于下界。★ 只让**兄弟**刷新(回调由调用方提供),
  2767. * 绝不能刷自己这一格 —— 用户可能正在其中打字。 */
  2768. if (syncCrossRules() && opts.onLinkedChange) { opts.onLinkedChange(); }
  2769. return true;
  2770. }
  2771. edit.setText(readStore());
  2772. lastText = String(edit.getText());
  2773. edit.addTextChangedListener(new android.text.TextWatcher({
  2774. beforeTextChanged: function () { },
  2775. onTextChanged: function () { },
  2776. afterTextChanged: function (editable) {
  2777. if (writing || !bound) { return; }
  2778. var now = String(editable === null || editable === undefined ? "" : editable);
  2779. var isBackspacing = lastText.length > now.length && lastText.indexOf(now) === 0;
  2780. lastText = now;
  2781. /* 见函数头第 ★ 条:数字框退格期间不落盘,等失焦时一次提交 */
  2782. if (numeric && isBackspacing) { return; }
  2783. commit();
  2784. }
  2785. }));
  2786. /* 失焦:先提交(把退格期间跳过的编辑落地),再回写正规化后的值 ——
  2787. * 没有后一步,用户永远看不到「99999 其实被夹到了 500」。 */
  2788. edit.setOnFocusChangeListener(new android.view.View.OnFocusChangeListener({
  2789. onFocusChange: function (view, hasFocus) {
  2790. edit.setBackground(hasFocus ? activeBg : idleBg);
  2791. if (hasFocus) { return; }
  2792. commit();
  2793. paint();
  2794. if (opts.onBlur) { opts.onBlur(); }
  2795. }
  2796. }));
  2797. var handle = {
  2798. root: edit,
  2799. edit: edit,
  2800. paint: paint,
  2801. commit: commit,
  2802. value: function () { return String(edit.getText()).trim(); }
  2803. };
  2804. /* 登记进「本页待提交输入框」:切页签 / 按开始运行这类**不会先失焦**的动作
  2805. * 之前要整体提交一次,否则「只退格、没失焦就走」的那次编辑会丢。 */
  2806. if (bound) { W.pendingFields[opts.path] = handle; }
  2807. return handle;
  2808. };
  2809. /* path → 输入框。**每次渲染页面开始时清空**(见 W.beginPage)——
  2810. * 留成全局累积的话,切走的那一页的输入框还挂在表里,
  2811. * 后面任意一次提交都会把它的旧文字重新写回配置,把别处刚改的值顶掉。 */
  2812. W.pendingFields = {};
  2813. /** 开始渲染一个新页面:清空待提交表。调用方要先 `commitPendingInputs()` 再调这个。 */
  2814. W.beginPage = function () {
  2815. W.pendingFields = {};
  2816. };
  2817. /**
  2818. * 提交当前页面上所有绑定过配置的输入框。
  2819. *
  2820. * 什么时候必须调:**页面要被重建 / 配置要被读取,而用户可能还停在某个输入框里**。
  2821. * Android 里点一个不可聚焦的按钮并不会让 EditText 失焦,所以「按开始运行」时
  2822. * 栏位内容可能还没落盘 —— 这正是「界面显示的值」和「真正跑起来的值」不一致的来源。
  2823. */
  2824. W.commitPendingInputs = function () {
  2825. for (var path in W.pendingFields) {
  2826. if (W.pendingFields.hasOwnProperty(path)) {
  2827. W.pendingFields[path].commit();
  2828. }
  2829. }
  2830. };
  2831. /**
  2832. * 文本输入(关键词 / 主播区域 / 国家简码 / 云表名 / 文件路径)。
  2833. *
  2834. * 用 EditText 直接输入而不是 `dialogs.rawInput`:后者在 `"ui";` 模式下
  2835. * 是阻塞式调用、拿不到返回值(老坑),而且多点一步。
  2836. */
  2837. W.textField = function (path, placeholder, widthDp) {
  2838. return W.inlineField({
  2839. path: path,
  2840. placeholder: placeholder,
  2841. widthDp: widthDp || 108
  2842. });
  2843. };
  2844. /**
  2845. * 让跨字段约束立即生效(上界不得小于下界)。
  2846. *
  2847. * 拆成独立的上下限输入框之后,这个约束更容易被触发:
  2848. * 用户可能先把上限改成 50、再回头改下限。没有这一步,界面上就会出现
  2849. * 「下限 500 / 上限 50」这种自相矛盾的组合,要等下一次 load 才被纠正。
  2850. *
  2851. * 返回**是否发生了连带修改** —— 调用方据此决定要不要重绘整页。
  2852. * 大多数情况下只改了自己那一项,不必重绘。
  2853. */
  2854. function syncCrossRules() {
  2855. var changed = false;
  2856. for (var i = 0; i < Store.CROSS_RULES.length; i++) {
  2857. var rule = Store.CROSS_RULES[i];
  2858. var low = Store.getNumber(rule.low);
  2859. var high = Store.getNumber(rule.high);
  2860. if (high < low) {
  2861. Store.set(rule.high, low);
  2862. changed = true;
  2863. }
  2864. }
  2865. return changed;
  2866. }
  2867. /**
  2868. * 数字输入(设计稿里的「停止个数 1 ~ 5」「关注数量 100 ~ 200」都用它)。
  2869. *
  2870. * 第 4 个参数 onLinkedChange 用于「改这一项**连带改了别项**」的情形
  2871. * (见 syncCrossRules):把休息时间下限调到 2000,被抬齐的上限那一格也得刷新。
  2872. * ★ 回调里只刷**兄弟**那一格,不刷自己 —— 自己这一格正被用户编辑着。
  2873. */
  2874. W.numberField = function (path, hint, widthDp, onLinkedChange) {
  2875. return W.inlineField({
  2876. path: path,
  2877. placeholder: hint,
  2878. numeric: true,
  2879. widthDp: widthDp || 56,
  2880. onLinkedChange: onLinkedChange
  2881. });
  2882. };
  2883. /**
  2884. * 「最小值 ~ 最大值」——一行一个区间,两个数**各自独立、直接在栏位里改**。
  2885. *
  2886. * 与「上下限各占一行」的区别只在排版:
  2887. * · 两个数各自一个输入框,互不干扰(改最小值不会顺带动最大值);
  2888. * · `~` 只是把「这两个数是一对」表达出来,不是把两个值塞进一个控件。
  2889. * 一行放下之后功能设置卡短了一半,四个门槛不用滚很久才看完。
  2890. *
  2891. * 为什么不像开关那样拆成两行:开关是**两个独立的动作**(进行关注、进行私信),
  2892. * 而这里的 min/max 是**同一个量的下界与上界**,分开写会让人以为
  2893. * 「关注量下限」和「关注量上限」是两个互不相干的设置。
  2894. *
  2895. * ★ 联动(syncCrossRules)只刷**兄弟**那一格:用户正在输的那一格绝不能被回写抢输入。
  2896. * 联动也不重建整页 —— 重建会把滚动位置清零,用户刚滚到功能设置区就被弹回顶部。
  2897. */
  2898. W.rangeRow = function (pathMin, pathMax, label, separator, widthDp) {
  2899. var minField = null;
  2900. var maxField = null;
  2901. var w = widthDp || 56;
  2902. minField = W.numberField(pathMin, "", w, function () {
  2903. if (maxField) { maxField.paint(); }
  2904. });
  2905. maxField = W.numberField(pathMax, "", w, function () {
  2906. if (minField) { minField.paint(); }
  2907. });
  2908. var line = U.row();
  2909. line.addView(U.text(label, Theme.m.labelSize, Theme.hex.textSecondary, false));
  2910. line.addView(U.flexible());
  2911. line.addView(minField.root);
  2912. line.addView(U.hgap(6));
  2913. line.addView(U.text(separator || "~", Theme.m.labelSize, Theme.hex.textMuted, false));
  2914. line.addView(U.hgap(6));
  2915. line.addView(maxField.root);
  2916. return {
  2917. root: line,
  2918. values: function () {
  2919. return { min: Store.getNumber(pathMin), max: Store.getNumber(pathMax) };
  2920. }
  2921. };
  2922. };
  2923. /* ------------------------------------------------------------ 文件选择行 */
  2924. /**
  2925. * 弹出文件列表并把选中结果写进 store,然后回调刷新。
  2926. *
  2927. * 列文件交给 Device,弹窗交给 Dialog —— 用 `dialogs.select` 的话
  2928. * 在 UI 模式下拿不到返回值,选文件会静默失效(老坑)。
  2929. */
  2930. function pickTextFileInto(storePath, onPicked) {
  2931. var list = Device.listTextFiles();
  2932. if (list.length === 0) {
  2933. Dialog.alert("未找到可用文件",
  2934. "把 .txt / .json / .csv 文件放到 /sdcard/Download 下,\n" +
  2935. "回到这里就能选到了。");
  2936. return;
  2937. }
  2938. Dialog.choose({
  2939. title: "选择文件",
  2940. options: list,
  2941. currentIndex: 0,
  2942. onConfirm: function (index) {
  2943. var picked = list[index];
  2944. Store.set(storePath, picked);
  2945. if (onPicked) { onPicked(); }
  2946. toast("已选择:" + picked);
  2947. }
  2948. });
  2949. }
  2950. /**
  2951. * 独立的「选择文件」按钮。
  2952. *
  2953. * 单独抽出来是因为 ID提取页的「使用文本 ID」那一行是
  2954. * 「勾选框 + 弹性占位 + 路径框 + 按钮」的结构,而 filePicker 会自带一个左侧标签,
  2955. * 直接复用会出现同一行两个标签。
  2956. */
  2957. W.filePickerButton = function (storePath, onPicked, label) {
  2958. var button = W.chip(label || "\u9009\u62E9\u6587\u4EF6", "accent");
  2959. button.root.setOnClickListener(function () { pickTextFileInto(storePath, onPicked); });
  2960. return button;
  2961. };
  2962. /**
  2963. * 「路径框 + 选择文件」组合行。设计稿里凡是「随机私信文本 / 已筛 UID 文本」都用它。
  2964. *
  2965. * 路径本身就是个文本值,所以它也是**可直接编辑**的输入框(可以手输/粘贴路径,
  2966. * 不一定非得走「选择文件」)。文件不存在时把字调暗,让「配了路径但文件没放」
  2967. * 一眼看得出来。
  2968. */
  2969. W.filePicker = function (label, storePath, buttonLabel) {
  2970. var field = W.inlineField({
  2971. path: storePath,
  2972. placeholder: "\u6587\u672C\u8DEF\u5F84",
  2973. widthDp: 108,
  2974. align: "right"
  2975. });
  2976. function refreshPath() {
  2977. field.paint();
  2978. var p = Store.getText(storePath);
  2979. var exists = false;
  2980. try {
  2981. exists = p !== "" && files.exists(p);
  2982. } catch (e) {
  2983. exists = false;
  2984. }
  2985. field.edit.setTextColor(U.argb(p === ""
  2986. ? Theme.hex.textFaint
  2987. : (exists ? Theme.hex.textSecondary : Theme.hex.textMuted)));
  2988. }
  2989. refreshPath();
  2990. var button = W.filePickerButton(storePath, refreshPath, buttonLabel);
  2991. var nameView = U.text(label, Theme.m.labelSize, Theme.hex.textSecondary, false);
  2992. nameView.setSingleLine(true);
  2993. var line = U.row();
  2994. line.addView(nameView);
  2995. line.addView(U.flexible());
  2996. line.addView(field.root);
  2997. line.addView(U.hgap(6));
  2998. line.addView(button.root);
  2999. return { root: line, field: field, refreshPath: refreshPath };
  3000. };
  3001. /* ------------------------------------------------------------ 底部渐隐 */
  3002. /**
  3003. * 内容区底部渐隐(规格书 §6:y=577 h=80)。
  3004. *
  3005. * 放在内容区与底栏之间,从透明渐变到**页面底色**:
  3006. * · 内容超出屏幕时,卡片被底栏硬切一刀的观感消失,一眼看出还能滚;
  3007. * · 内容没超出时它叠在空背景上(透明 → 背景色 = 背景色),看不出多余色块。
  3008. * 绝对定位、不吃点击(否则底栏上面那 80dp 就点不动了)。
  3009. */
  3010. W.bottomFade = function () {
  3011. var view = new android.view.View(context);
  3012. view.setBackground(U.fadeBg(Theme.hex.bg));
  3013. view.setClickable(false);
  3014. view.setFocusable(false);
  3015. var lp = new android.widget.FrameLayout.LayoutParams(-1, U.dp(Theme.m.fadeHeight));
  3016. lp.gravity = android.view.Gravity.BOTTOM;
  3017. view.setLayoutParams(lp);
  3018. return view;
  3019. };
  3020. /** 空态(记录 D2):图标框 + 主文案 + 副文案,整块居中。 */
  3021. W.emptyState = function (iconChar, title, subtitle) {
  3022. var box = U.column();
  3023. box.setGravity(android.view.Gravity.CENTER);
  3024. var iconBox = U.text(iconChar, 28, Theme.hex.tabIdle, false);
  3025. iconBox.setGravity(android.view.Gravity.CENTER);
  3026. iconBox.setBackground(U.tintBg("#FFFFFF", 0.05, 20));
  3027. U.params(iconBox, 64, 64);
  3028. box.addView(iconBox);
  3029. box.addView(U.spacer(16));
  3030. var head = U.text(title, 14, Theme.hex.tabIdle, false);
  3031. head.setGravity(android.view.Gravity.CENTER);
  3032. box.addView(head);
  3033. box.addView(U.spacer(6));
  3034. var sub = U.text(subtitle, Theme.m.hintSize, Theme.hex.textDim, false);
  3035. sub.setGravity(android.view.Gravity.CENTER);
  3036. box.addView(sub);
  3037. /* weight=1 撑满剩余高度 → 整块垂直居中(不能靠 gravity,父容器是滚动的) */
  3038. var outer = U.column();
  3039. U.params(outer, -1, 0, 1);
  3040. outer.setGravity(android.view.Gravity.CENTER);
  3041. outer.addView(box);
  3042. return outer;
  3043. };
  3044. /* ------------------------------------------------------------ 返回头 */
  3045. /**
  3046. * 二级页返回头(规格书 §6:高 44,返回热区 44×44,标题 21 SemiBold)。
  3047. *
  3048. * 热区必须做满 44×44:箭头字形只有 20dp,只让它可点的话真机上很难戳中
  3049. * (触控标准里专门写了「返回热区 44×44」)。
  3050. *
  3051. * @param onBack 返回回调
  3052. * @param onSettings 右侧设置入口(只有 ID提取 C1 有),不传则不渲染
  3053. */
  3054. W.backHeader = function (title, onBack, onSettings) {
  3055. var arrow = U.text("\u2039", 22, Theme.hex.textPrimary, false);
  3056. arrow.setGravity(android.view.Gravity.CENTER);
  3057. var hot = U.row(false);
  3058. hot.setGravity(android.view.Gravity.CENTER);
  3059. U.params(hot, Theme.m.backHotSize, Theme.m.backHotSize);
  3060. hot.addView(arrow);
  3061. hot.setOnClickListener(function () { if (onBack) { onBack(); } });
  3062. var head = U.row();
  3063. head.addView(hot);
  3064. head.addView(U.hgap(4));
  3065. var titleView = U.text(title, Theme.m.detailTitleSize, Theme.hex.textPrimary, true);
  3066. titleView.setSingleLine(true);
  3067. titleView.setEllipsize(android.text.TextUtils.TruncateAt.END);
  3068. head.addView(titleView);
  3069. head.addView(U.flexible());
  3070. if (onSettings) {
  3071. var gear = U.text("\u2699", 18, Theme.hex.textSecondary, false);
  3072. gear.setGravity(android.view.Gravity.CENTER);
  3073. U.params(gear, 22, 22);
  3074. gear.setOnClickListener(function () { onSettings(); });
  3075. head.addView(gear);
  3076. }
  3077. return head;
  3078. };
  3079. /* ------------------------------------------------------------ 状态徽标 */
  3080. /**
  3081. * 环形小徽标(任务卡的状态:「空闲 / 运行中 / 未配置」)。
  3082. *
  3083. * 与 W.pill 分开是因为**内边距不同**:pill 是 6/6(给「已安装 v39.5.4」这类
  3084. * 有内容的标签),徽标是 8/3 —— 三四个字的小标签用 6dp 上下留白会胖得像个按钮。
  3085. */
  3086. W.badge = function (label, tone) {
  3087. var t = W.TONES[tone || "mute"] || W.TONES["mute"];
  3088. var view = U.text(label, Theme.m.chipSize, t.text, true);
  3089. view.setGravity(android.view.Gravity.CENTER);
  3090. view.setSingleLine(true);
  3091. view.setBackground(U.solidBg(U.alphaOf("#FFFFFF", t.fill), Theme.m.pillRadius));
  3092. view.setPadding(U.dp(Theme.m.badgePadH), U.dp(Theme.m.badgePadV),
  3093. U.dp(Theme.m.badgePadH), U.dp(Theme.m.badgePadV));
  3094. return {
  3095. root: view,
  3096. setText: function (v) { view.setText(String(v)); },
  3097. setTone: function (next) { W.paintTone(view, next, Theme.m.pillRadius); }
  3098. };
  3099. };
  3100. /* ------------------------------------------------------------ 任务卡 / 记录条目 */
  3101. /**
  3102. * 同色图标框(任务卡 40 / 记录条目 36)。
  3103. * tint 传 hex,alpha 固定取 Theme.alpha.iconBoxFill(14%,设计稿取值)。
  3104. */
  3105. function iconBoxTint(hex, sizeDp, radiusDp, iconChar, iconSp) {
  3106. var box = U.text(iconChar, iconSp, hex, false);
  3107. box.setGravity(android.view.Gravity.CENTER);
  3108. box.setBackground(U.tintBg(hex, Theme.alpha.iconBoxFill, radiusDp));
  3109. U.params(box, sizeDp, sizeDp);
  3110. return box;
  3111. }
  3112. W.taskIconBox = function (hex, iconChar) {
  3113. return iconBoxTint(hex, Theme.m.taskIconBox, Theme.m.taskIconRadius,
  3114. iconChar, Theme.m.taskIconSize);
  3115. };
  3116. W.recordIconBox = function (hex, iconChar) {
  3117. return iconBoxTint(hex, Theme.m.recordIconBox, Theme.m.recordIconRadius,
  3118. iconChar, Theme.m.recordIconSize);
  3119. };
  3120. /**
  3121. * 任务卡(B1/B2)。
  3122. *
  3123. * 结构:图标框 · [任务名 + 状态徽标] / 当前脚本 / 上次运行 · 运行按钮。
  3124. *
  3125. * 两个必须做对的地方:
  3126. * ① 点卡片主体 = 进详情页;点「运行」按钮 = 直接跑 —— **两个命中区**。
  3127. * 合成一个的话,用户想进去改配置却把脚本跑起来了。
  3128. * ② 运行中**不禁用**其他任务的按钮(规格书 §2.3:「按钮仍然可点,
  3129. * 点击后弹 F3 确认,不做禁用」)。禁用会让用户以为"卡死了"。
  3130. *
  3131. * @param opts.onOpen 点卡片主体
  3132. * @param opts.onRun 点运行/停止按钮
  3133. */
  3134. W.taskCard = function (opts) {
  3135. var badge = W.badge(opts.badge, opts.badgeTone);
  3136. var nameLine = U.row(false);
  3137. nameLine.addView(U.text(opts.name, Theme.m.taskNameSize, Theme.hex.textPrimary, true));
  3138. nameLine.addView(U.hgap(8));
  3139. nameLine.addView(badge.root);
  3140. var textGroup = U.column();
  3141. textGroup.addView(nameLine);
  3142. textGroup.addView(U.spacer(4));
  3143. textGroup.addView(U.text(opts.scriptLine, Theme.m.chipSize, Theme.hex.textFaint, false));
  3144. textGroup.addView(U.spacer(4));
  3145. textGroup.addView(U.text(opts.lastLine, Theme.m.chipSize, Theme.hex.textDim, false));
  3146. /* 运行按钮:默认渐变,运行中那一个换成次级样式(设计稿 B2 的「停止」)。 */
  3147. var btnLabel = U.text(opts.running ? "\u505C\u6B62" : "\u8FD0\u884C",
  3148. Theme.m.sectionSize - 1, "#FFFFFF", true);
  3149. btnLabel.setGravity(android.view.Gravity.CENTER);
  3150. var button = new android.widget.FrameLayout(context);
  3151. button.setBackground(opts.running
  3152. ? U.solidBg(U.alphaOf("#FFFFFF", 0.08), Theme.m.pillRadius)
  3153. : U.gradientBg(Theme.hex.accent, Theme.hex.accentDeep, Theme.m.pillRadius));
  3154. U.params(button, Theme.m.taskRunBtnW, Theme.m.taskRunBtnH);
  3155. button.addView(btnLabel);
  3156. if (opts.onRun) { button.setOnClickListener(function () { opts.onRun(); }); }
  3157. var card = U.column();
  3158. card.setBackground(U.strokeBg(Theme.alpha.cardFill, Theme.alpha.cardStroke,
  3159. "#FFFFFF", Theme.m.cardRadius));
  3160. card.setPadding(U.dp(Theme.m.cardPadding), U.dp(Theme.m.cardPadding),
  3161. U.dp(Theme.m.cardPadding), U.dp(Theme.m.cardPadding));
  3162. var line = U.row();
  3163. var iconBox = W.taskIconBox(opts.colorHex, opts.iconChar);
  3164. line.addView(iconBox);
  3165. line.addView(U.hgap(12));
  3166. /* 文本组用 weight=1 撑开:右侧是固定宽 68 的按钮,不会把标签挤成 0 宽
  3167. * (0 宽那类问题只在右侧控件是 MATCH_PARENT 时出现,见 W.settingRow 注释)。 */
  3168. U.params(textGroup, 0, -2, 1);
  3169. line.addView(textGroup);
  3170. line.addView(U.hgap(12));
  3171. line.addView(button);
  3172. card.addView(line);
  3173. /**
  3174. * ★ 点卡片 = 进详情页。**每一块可点区域都要显式挂一次**,不能只挂根容器。
  3175. *
  3176. * 真机踩过(2026-10-13,用户反馈「点任务卡上的脚本进不去参数设置」):
  3177. * 原先这里写了一句 `line.setOnClickListener(null)`,本意是"行内不挂监听、让气泡到卡片"。
  3178. * 但 AutoX 的桥对 `null` 并不保证是"清掉监听"—— 它很可能装上一个
  3179. * **吃掉点击却什么都不做**的空监听。于是:
  3180. * · 点「运行」按钮 → 有反应(按钮是它自己的子视图,装着真监听);
  3181. * · 点卡片的其他任何地方 → 毫无反应(触摸被那一层吞了,到不了 card)。
  3182. * 两件事实拼起来才知道问题在哪 —— 单看"按钮能用"会以为点击链路是好的。
  3183. *
  3184. * 所以现在 card / 图标框 / 文本组 各挂一次同一个回调:
  3185. * 谁接到都做同一件事,既不依赖气泡顺序,也不怕中间某层被点成 clickable。
  3186. */
  3187. if (opts.onOpen) {
  3188. var openDetail = function () { opts.onOpen(); };
  3189. card.setOnClickListener(openDetail);
  3190. iconBox.setOnClickListener(openDetail);
  3191. textGroup.setOnClickListener(openDetail);
  3192. }
  3193. return { root: card, badge: badge, button: button, iconBox: iconBox, textGroup: textGroup };
  3194. };
  3195. /**
  3196. * 记录条目(D1)。
  3197. *
  3198. * 结构:图标框 · [任务 · 脚本 + 结果标签] / 时间行 / 结果行 · 右箭头。
  3199. * 结果行按结果分类换色(报错那条是玫瑰红,一眼能在列表里挑出来)。
  3200. */
  3201. W.recordRow = function (opts) {
  3202. var titleLine = U.row(false);
  3203. titleLine.addView(U.text(opts.title, Theme.m.labelSize, Theme.hex.textPrimary, true));
  3204. titleLine.addView(U.hgap(8));
  3205. titleLine.addView(W.badge(opts.resultLabel, opts.resultTone).root);
  3206. var resultColor = opts.resultTone === "danger" ? Theme.hex.danger : Theme.hex.textDim;
  3207. var textGroup = U.column();
  3208. textGroup.addView(titleLine);
  3209. textGroup.addView(U.spacer(4));
  3210. textGroup.addView(U.text(opts.timeLine, Theme.m.chipSize, Theme.hex.textFaint, false));
  3211. if (opts.resultLine) {
  3212. textGroup.addView(U.spacer(4));
  3213. textGroup.addView(U.text(opts.resultLine, Theme.m.chipSize, resultColor, false));
  3214. }
  3215. var line = U.row();
  3216. line.addView(W.recordIconBox(opts.colorHex, opts.iconChar));
  3217. line.addView(U.hgap(12));
  3218. U.params(textGroup, 0, -2, 1);
  3219. line.addView(textGroup);
  3220. line.addView(U.hgap(8));
  3221. line.addView(W.chevronRight(18, Theme.hex.textFaint));
  3222. var card = U.column();
  3223. card.setBackground(U.strokeBg(Theme.alpha.cardFill, Theme.alpha.cardStroke,
  3224. "#FFFFFF", Theme.m.recordCardRadius));
  3225. card.setPadding(U.dp(Theme.m.cardPadding), U.dp(Theme.m.cardPadding),
  3226. U.dp(Theme.m.cardPadding), U.dp(Theme.m.cardPadding));
  3227. card.addView(line);
  3228. if (opts.onOpen) { card.setOnClickListener(function () { opts.onOpen(); }); }
  3229. return card;
  3230. };
  3231. /* ------------------------------------------------------------ 小按钮 / 标签按钮 */
  3232. /**
  3233. * 小按钮(「打开 / 分享 / 复制 / 选择」这类行内操作)。
  3234. * 尺寸 26 高 + 8 圆角,与胶囊(全圆角)区分开:胶囊是"状态/开关",方块是"动作"。
  3235. */
  3236. W.smallButton = function (label, tone, onClick, minWidthDp) {
  3237. var chip = W.chip(label, tone || "default", minWidthDp, Theme.m.chipRadius);
  3238. chip.root.setTextSize(Theme.m.chipSize);
  3239. if (onClick) { chip.root.setOnClickListener(function () { onClick(); }); }
  3240. return chip;
  3241. };
  3242. /* ------------------------------------------------------------ 主按钮 */
  3243. /**
  3244. * 底部主按钮:青蓝→紫渐变,48 高,全圆角。文案随运行态在「开始运行 / 停止运行」间切换。
  3245. */
  3246. W.runButton = function (onClick, text) {
  3247. var label = U.text(text || "\u5F00\u59CB\u8FD0\u884C", Theme.m.sectionSize, "#FFFFFF", true);
  3248. label.setGravity(android.view.Gravity.CENTER);
  3249. var button = new android.widget.FrameLayout(context);
  3250. button.setBackground(U.gradientBg(Theme.hex.accent, Theme.hex.accentDeep, Theme.m.pillRadius));
  3251. U.params(button, -1, Theme.m.runBtnHeight);
  3252. button.addView(label);
  3253. if (onClick) { button.setOnClickListener(function () { onClick(); }); }
  3254. return { root: button, label: label };
  3255. };
  3256. /** 次要说明文字(页面底部提示)。 */
  3257. W.hint = function (content, center) {
  3258. var view = U.text(content, Theme.m.hintSize, Theme.hex.textFaint, false);
  3259. if (center) { view.setGravity(android.view.Gravity.CENTER); }
  3260. return view;
  3261. };
  3262. /* ------------------------------------------------------------ 底部页签 */
  3263. /* 页签顺序即设计稿从左到右的顺序。
  3264. *
  3265. * ★ 2026-10-13 规格书 §1:底栏只有 **3 个 tab**(任务 / 记录 / 我的)。
  3266. * 原来的「ID提取 / UID私信 / 养号 / 视频功能」四个页签
  3267. * 降级为「任务」tab 里的四张任务卡(见 Pages.TASKS)。
  3268. *
  3269. * 图标一律用 U+25xx 几何块字符,**不要用 U+2709 这类有 emoji 变体的码位**——
  3270. * Android 上即使追加 U+FE0E(文本变体选择符)也常被渲染成彩色 emoji,
  3271. * 在深色界面里非常突兀(实测踩过)。换成 PNG 图标只需改这里的 icon 字段。 */
  3272. W.TABS = [
  3273. { key: "tasks", icon: "\u25A3", label: "\u4EFB\u52A1" },
  3274. { key: "records", icon: "\u25F4", label: "\u8BB0\u5F55" },
  3275. { key: "mine", icon: "\u25C9", label: "\u6211\u7684" }
  3276. ];
  3277. W.tabBar = function (activeKey, onSelect) {
  3278. var bar = U.row();
  3279. bar.setGravity(android.view.Gravity.CENTER);
  3280. for (var i = 0; i < W.TABS.length; i++) {
  3281. var tab = W.TABS[i];
  3282. var color = tab.key === activeKey ? Theme.hex.accent : Theme.hex.tabIdle;
  3283. var icon = U.text(tab.icon, 16, color, false);
  3284. icon.setGravity(android.view.Gravity.CENTER);
  3285. var label = U.text(tab.label, Theme.m.tabSize, color, true);
  3286. label.setGravity(android.view.Gravity.CENTER);
  3287. var item = U.column();
  3288. item.setGravity(android.view.Gravity.CENTER);
  3289. item.addView(icon);
  3290. item.addView(U.spacer(4));
  3291. item.addView(label);
  3292. U.params(item, 0, Theme.m.tabBarHeight, 1);
  3293. (function (key) {
  3294. item.setOnClickListener(function () { onSelect(key); });
  3295. })(tab.key);
  3296. bar.addView(item);
  3297. }
  3298. return bar;
  3299. };
  3300. /* ------------------------------------------------------------ 全局运行条 */
  3301. /**
  3302. * 全局运行条(规格书 §2.4 / 设计稿 B2 的 30:304)。
  3303. *
  3304. * 位置:内容区**上方**(状态栏之下),仅「运行中」时出现,空闲/完成整条隐藏。
  3305. * 内容:运行点 · {任务名} · 脚本X · 运行中 + 右侧等宽计时。
  3306. * 报错态:整条变玫瑰红、文案换成「执行出错已停止」、右侧「已终止」。
  3307. *
  3308. * 分工是刻意的:**运行条只管状态与导航(可点击跳回运行中的任务),
  3309. * 停止由页面底部的 CTA 负责** —— 条上不放停止按钮,
  3310. * 否则"点一下条"到底会停止还是跳转,用户没法预期。
  3311. *
  3312. * 计时用等宽字:数字每秒在变,比例字宽会让整条轻微抖动。
  3313. */
  3314. W.runBar = function (text, elapsed, tone) {
  3315. /* tone: "ok"(运行中)/ "warn"(正在收尾)/ "danger"(报错中断) */
  3316. function toneColor(value) {
  3317. if (value === "warn") { return Theme.hex.warn; }
  3318. if (value === "danger") { return Theme.hex.danger; }
  3319. return Theme.hex.ok;
  3320. }
  3321. var dot = U.text("\u25CF", 8, toneColor(tone), false);
  3322. var label = U.text(text, 12, Theme.hex.textBody, true);
  3323. label.setSingleLine(true);
  3324. label.setEllipsize(android.text.TextUtils.TruncateAt.END);
  3325. var time = U.monoText(elapsed, Theme.m.chipSize, toneColor(tone));
  3326. var bar = U.row();
  3327. bar.setGravity(android.view.Gravity.CENTER_VERTICAL);
  3328. bar.setBackground(U.solidBg(U.alphaOf(tone === "danger" ? Theme.hex.danger : Theme.hex.accent,
  3329. Theme.alpha.runBarFill), Theme.m.runBarRadius));
  3330. bar.setPadding(U.dp(Theme.m.runBarPadH), U.dp(Theme.m.runBarPadV),
  3331. U.dp(Theme.m.runBarPadH), U.dp(Theme.m.runBarPadV));
  3332. bar.addView(dot);
  3333. bar.addView(U.hgap(8));
  3334. /* 文案用 flexible 顶开:计时贴右端,长任务名先被省略号吃掉 */
  3335. U.params(label, 0, -2, 1);
  3336. bar.addView(label);
  3337. bar.addView(U.hgap(8));
  3338. bar.addView(time);
  3339. return {
  3340. root: bar,
  3341. setText: function (v) { label.setText(String(v)); },
  3342. setElapsed: function (v) { time.setText(String(v)); },
  3343. /* 色调要能中途换:运行中是绿的,正在收尾是琥珀,报错中断是玫瑰红 ——
  3344. * 三者都是"还在界面上",但用户需要一眼看出它们不是一回事。 */
  3345. setTone: function (v) {
  3346. dot.setTextColor(U.argb(toneColor(v)));
  3347. time.setTextColor(U.argb(toneColor(v)));
  3348. bar.setBackground(U.solidBg(
  3349. U.alphaOf(v === "danger" ? Theme.hex.danger : Theme.hex.accent,
  3350. Theme.alpha.runBarFill), Theme.m.runBarRadius));
  3351. }
  3352. };
  3353. };
  3354. /* ------------------------------------------------------------ 警示图标 */
  3355. /**
  3356. * 弹窗顶部的警示图标(设计稿 16:952 / 16:1481)。
  3357. *
  3358. * 设计稿是一个描边矢量图标。这里改用字符 "!" —— 换成 ⚠(U+26A0) 在 Android 上
  3359. * 会被渲染成彩色 emoji,加 U+FE0E 也压不住(页签图标踩过同一个坑)。
  3360. * 底衬用同色 14% 圆角方块,视觉层级与设计稿一致。
  3361. */
  3362. W.warnIcon = function (hex) {
  3363. var view = U.text("!", 20, hex, true);
  3364. view.setGravity(android.view.Gravity.CENTER);
  3365. U.params(view, Theme.m.warnIconSize, Theme.m.warnIconSize);
  3366. view.setBackground(U.solidBg(U.alphaOf(hex, Theme.alpha.warnFill), Theme.m.warnIconRadius));
  3367. return view;
  3368. };
  3369. /* ------------------------------------------------------------ 弹窗按钮 */
  3370. /**
  3371. * 主按钮(渐变色,弹窗内的「去授权 / 知道了 / 登录激活 / 确认」)。
  3372. *
  3373. * 与 W.runButton 的区别只在尺寸:主按钮吃满卡片宽度但只有 46~48 高,
  3374. * 而开始运行按钮固定在页面底部。两者视觉必须一致,所以共用同一套渐变。
  3375. */
  3376. W.primaryButton = function (label, heightDp, onClick) {
  3377. var text = U.text(label, 15, "#FFFFFF", true);
  3378. text.setGravity(android.view.Gravity.CENTER);
  3379. var button = new android.widget.FrameLayout(context);
  3380. button.setBackground(U.gradientBg(Theme.hex.accent, Theme.hex.accentDeep, Theme.m.pillRadius));
  3381. U.params(button, -1, heightDp || 46);
  3382. button.addView(text);
  3383. if (onClick) { button.setOnClickListener(function () { onClick(); }); }
  3384. return { root: button, label: text };
  3385. };
  3386. /** 实心危险按钮(「确认解绑」):不可逆操作用纯色而不是渐变,视觉上更"重"。 */
  3387. W.dangerButton = function (label, heightDp, onClick) {
  3388. var text = U.text(label, 15, "#FFFFFF", true);
  3389. text.setGravity(android.view.Gravity.CENTER);
  3390. var button = new android.widget.FrameLayout(context);
  3391. button.setBackground(U.solidBg(U.argb(Theme.hex.danger), Theme.m.pillRadius));
  3392. U.params(button, -1, heightDp || 44);
  3393. button.addView(text);
  3394. if (onClick) { button.setOnClickListener(function () { onClick(); }); }
  3395. return { root: button, label: text };
  3396. };
  3397. /** 次级按钮(「取消」):白 6% 填充 + 10% 描边。 */
  3398. W.ghostButton = function (label, heightDp, onClick) {
  3399. var text = U.text(label, 15, Theme.hex.textSecondary, true);
  3400. text.setGravity(android.view.Gravity.CENTER);
  3401. var button = new android.widget.FrameLayout(context);
  3402. button.setBackground(U.strokeBg(0.06, 0.10, "#FFFFFF", Theme.m.pillRadius));
  3403. U.params(button, -1, heightDp || 44);
  3404. button.addView(text);
  3405. if (onClick) { button.setOnClickListener(function () { onClick(); }); }
  3406. return { root: button, label: text };
  3407. };
  3408. /** 居中的次要文字链(「稍后再说」「购买卡密 · 联系客服」「退出应用」)。 */
  3409. W.link = function (label, hex, onClick) {
  3410. var view = U.text(label, 13, hex, false);
  3411. view.setGravity(android.view.Gravity.CENTER);
  3412. /* 触摸目标补到 44dp:纯文字只有 ~16dp 高,直接点很难命中 */
  3413. view.setPadding(U.dp(4), U.dp(12), U.dp(4), U.dp(12));
  3414. if (onClick) { view.setOnClickListener(function () { onClick(); }); }
  3415. return view;
  3416. };
  3417. /* ------------------------------------------------------------ 遮罩 */
  3418. W.mask = function (alpha) {
  3419. var view = new android.view.View(context);
  3420. view.setBackground(U.solidBg(U.alphaOf("#000000",
  3421. alpha === undefined ? Theme.alpha.mask : alpha)));
  3422. return view;
  3423. };
  3424. /* ------------------------------------------------------------ 容器工具 */
  3425. /** 重建页面用。 */
  3426. W.clear = function (container) {
  3427. container.removeAllViews();
  3428. return container;
  3429. };
  3430. /** 竖向滚动容器,页面内容区统一用它兜住超长内容。 */
  3431. W.scroller = function () {
  3432. var scroll = new android.widget.ScrollView(context);
  3433. scroll.setVerticalScrollBarEnabled(false);
  3434. scroll.setOverScrollMode(android.view.View.OVER_SCROLL_NEVER);
  3435. return scroll;
  3436. };
  3437. /* ============================== src/dialog.js ============================== */
  3438. /* ============================== src/dialog.js ==============================
  3439. * 自绘弹窗体系:输入、选择、确认、提示、权限拦截。
  3440. *
  3441. * ── 为什么不用 AutoX 的 dialogs 模块 ───────────────────────────────
  3442. * `"ui";` 模式下主线程就是 UI 线程。`dialogs.select(...)` / `dialogs.rawInput(...)`
  3443. * 这类**不带 callback 的阻塞式调用**在这里拿不到返回值 —— 阻塞会死锁,
  3444. * 所以运行时直接返回 undefined。表现是一连串很难查的现象:
  3445. * · 输入框「只能输一个值」:第一次调用返回 undefined,被当成「取消」,流程直接中断;
  3446. * · 下拉框选完写回 undefined(`Store.keyAt(group, undefined)`),
  3447. * 配置被写坏,重启后又变回默认值;
  3448. * · 弹出来的是系统浅色对话框,跟深色界面完全不是一套视觉。
  3449. *
  3450. * 自己画弹窗同时解决这三点:取值走「确定」按钮的回调,
  3451. * 不存在拿不到返回值的问题;样式也统一由 Theme 控制。
  3452. *
  3453. * 弹窗分两类,命名上刻意区分开:
  3454. * · 询问类(choose / confirm)—— 用户点了才知道结果;
  3455. * · 拦截类(runBlocked / permissionBlocked)—— 告诉用户「为什么跑不起来」。
  3456. * 后者的标题统一是「无法开始运行」,与设计稿 08 / 12 一致。
  3457. *
  3458. * ⚠️ 这里**没有**输入类弹窗(askText / askNumber 已删除)。
  3459. * 文本与数字一律用页面上的常驻输入框(`W.inlineField`)**直接改**——
  3460. * 少一次点击,也少一层「弹窗里的值有没有被写回去」的不确定性。
  3461. * 保留下来的弹窗都是**非输入**的:选一项、确认一次、被告知一次。
  3462. *
  3463. * 所有弹窗都挂在 Dialog.host(由 main.js 注入)上,同时只允许存在一个。
  3464. */
  3465. var Dialog = {};
  3466. Dialog.WIDTH = 315;
  3467. /* 宿主容器与当前打开的弹窗 */
  3468. Dialog.host = null;
  3469. Dialog.active = null;
  3470. /** 由 main.js 在搭好骨架后注入浮层容器。 */
  3471. Dialog.mount = function (host) { Dialog.host = host; };
  3472. /**
  3473. * 把弹窗挂到宿主上。
  3474. * 挂之前先关掉已有弹窗——两层弹窗叠着关不掉,是这类自绘 UI 最容易出的岔子。
  3475. */
  3476. function present(overlay) {
  3477. if (!Dialog.host) {
  3478. console.log("[dialog] 宿主未注入,弹窗无法显示");
  3479. return null;
  3480. }
  3481. if (Dialog.active) { Dialog.active.close(); }
  3482. var handle = {
  3483. root: overlay,
  3484. close: function () {
  3485. var parent = overlay.getParent();
  3486. if (parent) { parent.removeView(overlay); }
  3487. if (Dialog.active === handle) { Dialog.active = null; }
  3488. }
  3489. };
  3490. Dialog.active = handle;
  3491. Dialog.host.addView(overlay);
  3492. return handle;
  3493. }
  3494. /* ---------------------------------------------------------------- 骨架 */
  3495. /**
  3496. * 弹窗底部按钮行。
  3497. *
  3498. * 设计稿里的弹窗按钮是**等宽铺满**的两枚(16:960 取消 / 确认解绑),
  3499. * 或整行一枚(16:1068 知道了)。所以这里不接受「几个胶囊靠右排」的写法,
  3500. * 一律按等分宽度布局——按钮宽度不等会让两个弹窗看起来不像一套。
  3501. *
  3502. * specs: [{ label, variant: "primary"|"danger"|"ghost", onClick }]
  3503. */
  3504. function dialogButtons(specs) {
  3505. var line = U.row();
  3506. for (var i = 0; i < specs.length; i++) {
  3507. var spec = specs[i];
  3508. var height = spec.height || 46;
  3509. var button = spec.variant === "danger" ? W.dangerButton(spec.label, height, spec.onClick)
  3510. : spec.variant === "ghost" ? W.ghostButton(spec.label, height, spec.onClick)
  3511. : W.primaryButton(spec.label, height, spec.onClick);
  3512. /* 宽度 0 + weight 1 = 与同行的兄弟等分剩余宽度 */
  3513. U.params(button.root, 0, height, 1);
  3514. if (i > 0) { line.addView(U.hgap(12)); }
  3515. line.addView(button.root);
  3516. }
  3517. return line;
  3518. }
  3519. /**
  3520. * 取消 + 确定 的成对按钮,绝大多数询问类弹窗都用它。
  3521. *
  3522. * ⚠️ 「确定」**不预先关窗**,是否关窗交给 onConfirm 自己决定。
  3523. * 早先写成「先 close() 再 onConfirm()」,直接毁掉了输入类弹窗的关键行为:
  3524. * 用户输了非法值,窗口已经关了,只能在原地提示的意图落空 ——
  3525. * 他得重新点开、重新输入。校验逻辑写对了也没用,因为关窗发生得更早。
  3526. */
  3527. function cancelConfirmRow(close, onConfirm, confirmLabel, confirmVariant) {
  3528. return dialogButtons([
  3529. { label: "\u53D6\u6D88", variant: "ghost", height: 44, onClick: close },
  3530. { label: confirmLabel || "\u786E\u5B9A", variant: confirmVariant || "primary", height: 44,
  3531. onClick: onConfirm }
  3532. ]);
  3533. }
  3534. /** 单枚「知道了」。 */
  3535. function acknowledgeRow(close, label) {
  3536. return dialogButtons([
  3537. { label: label || "\u77E5\u9053\u4E86", variant: "primary", height: 46,
  3538. onClick: function () { close(); } }
  3539. ]);
  3540. }
  3541. /**
  3542. * 弹窗骨架:遮罩 + 居中卡片。
  3543. * 返回 { overlay, card },调用方往 card 里塞内容。
  3544. */
  3545. function buildSheet(title) {
  3546. var overlay = new android.widget.FrameLayout(context);
  3547. overlay.setLayoutParams(new android.widget.FrameLayout.LayoutParams(-1, -1));
  3548. overlay.addView(W.mask(), new android.widget.FrameLayout.LayoutParams(-1, -1));
  3549. var card = U.column();
  3550. card.setBackground(U.strokeBg(0.06, 0.12, "#FFFFFF", Theme.m.dialogRadius));
  3551. card.setPadding(U.dp(24), U.dp(24), U.dp(24), U.dp(24));
  3552. /* 卡片必须吞掉点击:否则点卡片会冒泡到遮罩,把弹窗直接关掉 */
  3553. card.setClickable(true);
  3554. var lp = new android.widget.FrameLayout.LayoutParams(U.dp(Dialog.WIDTH), -2);
  3555. lp.gravity = android.view.Gravity.CENTER;
  3556. overlay.addView(card, lp);
  3557. if (title) {
  3558. card.addView(U.text(title, 18, Theme.hex.textPrimary, true));
  3559. card.addView(U.spacer(10));
  3560. }
  3561. return { overlay: overlay, card: card };
  3562. }
  3563. /** 弹窗副标题(说明文字)。字号与设计稿一致:12sp / 行距 17。 */
  3564. function sheetNote(text) {
  3565. var view = U.text(text, 12, Theme.hex.textMuted, false);
  3566. view.setLineSpacing(U.dp(3), 1.0);
  3567. return view;
  3568. }
  3569. /** 多行说明文字:弹窗里长文案要能换行。 */
  3570. function wrappedText(message) {
  3571. var view = U.text(message, 12, Theme.hex.textMuted, false);
  3572. view.setLineSpacing(U.dp(4), 1.0);
  3573. return view;
  3574. }
  3575. /* ---------------------------------------------------------------- 提示 */
  3576. /** 极简提示:标题 + 说明 + 「知道了」。 */
  3577. Dialog.alert = function (title, message) {
  3578. var sheet = buildSheet(title);
  3579. var handle = null;
  3580. sheet.card.addView(wrappedText(message));
  3581. sheet.card.addView(U.spacer(20));
  3582. sheet.card.addView(acknowledgeRow(function () { if (handle) { handle.close(); } }));
  3583. handle = present(sheet.overlay);
  3584. return handle;
  3585. };
  3586. /**
  3587. * 「无法开始运行」拦截弹窗(单按钮形态,设计稿 16:1065)。
  3588. * 与 alert 分开是因为**语义不同**:alert 是告知,这个是拦下了一次操作。
  3589. * 分开之后调用点读起来就是「被拦了,因为……」,不用去猜 title 里该写什么。
  3590. */
  3591. Dialog.runBlocked = function (message, onAcknowledge) {
  3592. var sheet = buildSheet("\u65E0\u6CD5\u5F00\u59CB\u8FD0\u884C");
  3593. var handle = null;
  3594. sheet.card.addView(sheetNote(message));
  3595. sheet.card.addView(U.spacer(20));
  3596. sheet.card.addView(acknowledgeRow(function () {
  3597. if (handle) { handle.close(); }
  3598. if (onAcknowledge) { onAcknowledge(); }
  3599. }));
  3600. handle = present(sheet.overlay);
  3601. return handle;
  3602. };
  3603. /**
  3604. * 「该脚本的执行引擎尚未接入」(拦截弹窗)。
  3605. *
  3606. * 与 runBlocked 分开写,是因为两者要用户做的事完全不同:
  3607. * runBlocked 是"你现在就能修好"(去勾一个脚本),
  3608. * 这个是"暂时做不了"(引擎还没写)。混用一个弹窗会让用户白找一圈。
  3609. */
  3610. Dialog.engineMissing = function (scriptName) {
  3611. return Dialog.runBlocked(
  3612. "\u300C" + scriptName + "\u300D\u7684\u6267\u884C\u5F15\u64CE\u5C1A\u672A\u63A5\u5165\u3002\n\n"
  3613. + "\u76EE\u524D\u5DF2\u63A5\u5165\u5F15\u64CE\u7684\u662F\u300C\u89C6\u9891\u529F\u80FD\u300D"
  3614. + "\u9875\u7684\u300C\u5173\u952E\u8BCD\u89C6\u9891\u529F\u80FD\u300D\u3002");
  3615. };
  3616. /** 「引擎起不来」——缺产物、缺关键词、启动报错。message 里带可操作的指引。 */
  3617. Dialog.engineUnavailable = function (message) {
  3618. return Dialog.runBlocked(message);
  3619. };
  3620. /**
  3621. * F3 切换任务确认(设计稿 30:1011)。
  3622. *
  3623. * 全局单跑(规格书 §2.3):运行中点别的任务的「运行」**不禁用按钮**,
  3624. * 而是弹这个确认。禁用按钮会让用户以为"卡死了",
  3625. * 而"先停旧的再起新的"是一个明确的、可以自己决定的后果。
  3626. *
  3627. * 标题里的运行中任务名是**真实读出来的**(Pages.taskName(Session.tab)),
  3628. * 不是固定文案 —— 用户需要知道到底是"谁"要被停掉。
  3629. */
  3630. Dialog.switchTask = function (runningName, targetName, onConfirm) {
  3631. var sheet = buildSheet(runningName + "\u6B63\u5728\u8FD0\u884C");
  3632. var handle = null;
  3633. sheet.card.addView(sheetNote(
  3634. "\u5F00\u59CB " + targetName + " \u5C06\u5148\u505C\u6B62\u5F53\u524D\u811A\u672C\uFF08\u5168\u5C40\u5355\u8DD1\uFF09"));
  3635. sheet.card.addView(U.spacer(20));
  3636. sheet.card.addView(cancelConfirmRow(
  3637. function () { if (handle) { handle.close(); } },
  3638. function () {
  3639. if (handle) { handle.close(); }
  3640. /* 先停旧的、再起新的:停止是**请求式**的(写 stop.flag),
  3641. * 不等它回执就起新引擎 —— 两边 runId 不同,
  3642. * 旧引擎的终态回报会被界面按 runId 过滤掉,不会污染新一轮。 */
  3643. App.stopRun();
  3644. if (onConfirm) { onConfirm(); }
  3645. },
  3646. "\u505C\u6B62\u5E76\u5F00\u59CB"
  3647. ));
  3648. handle = present(sheet.overlay);
  3649. return handle;
  3650. };
  3651. /**
  3652. * H5 停止二次确认(规格书 §10.6)。
  3653. *
  3654. * 悬浮球上的「停止」一定要确认:它浮在别的应用上,误触的概率远高于 App 内的按钮,
  3655. * 而停止的后果是**进度不保留**(下次从开头重跑)。
  3656. * 反过来「运行」不确认 —— 起跑是可逆的(再点一次停止即可),
  3657. * 两个动作的风险不对称,确认门槛也就不该一样。
  3658. */
  3659. Dialog.stopScript = function (scriptName, elapsedText, onConfirm) {
  3660. var sheet = buildSheet("\u505C\u6B62\u5F53\u524D\u811A\u672C\uFF1F");
  3661. var handle = null;
  3662. sheet.card.addView(sheetNote(String(scriptName) + " \u5DF2\u8FD0\u884C " + String(elapsedText)));
  3663. sheet.card.addView(U.spacer(6));
  3664. sheet.card.addView(sheetNote("\u505C\u6B62\u540E\u8FDB\u5EA6\u4E0D\u4FDD\u7559\uFF0C\u4E0B\u6B21\u5C06\u4ECE\u5F00\u5934\u91CD\u65B0\u5F00\u59CB"));
  3665. sheet.card.addView(U.spacer(20));
  3666. sheet.card.addView(cancelConfirmRow(
  3667. function () { if (handle) { handle.close(); } },
  3668. function () {
  3669. if (handle) { handle.close(); }
  3670. if (onConfirm) { onConfirm(); }
  3671. },
  3672. "\u505C\u6B62\u8FD0\u884C",
  3673. "danger"
  3674. ));
  3675. handle = present(sheet.overlay);
  3676. return handle;
  3677. };
  3678. /** H6 退出应用二次确认(规格书 §10.6 / §10.7:停脚本 + 关球 + 退到桌面)。 */
  3679. Dialog.exitApp = function (onConfirm) {
  3680. var sheet = buildSheet("\u9000\u51FA\u98DE\u821F\u811A\u672C\uFF1F");
  3681. var handle = null;
  3682. sheet.card.addView(sheetNote("\u5C06\u505C\u6B62\u6B63\u5728\u8FD0\u884C\u7684\u811A\u672C\uFF0C\u5E76\u5173\u95ED\u60AC\u6D6E\u7403"));
  3683. sheet.card.addView(U.spacer(6));
  3684. sheet.card.addView(sheetNote("\u811A\u672C\u8FDB\u5EA6\u4E0D\u4FDD\u7559\uFF0C\u4E0B\u6B21\u8FD0\u884C\u5C06\u4ECE\u5F00\u5934\u91CD\u65B0\u5F00\u59CB"));
  3685. sheet.card.addView(U.spacer(20));
  3686. sheet.card.addView(cancelConfirmRow(
  3687. function () { if (handle) { handle.close(); } },
  3688. function () {
  3689. if (handle) { handle.close(); }
  3690. if (onConfirm) { onConfirm(); }
  3691. },
  3692. "\u9000\u51FA\u5E94\u7528",
  3693. "danger"
  3694. ));
  3695. handle = present(sheet.overlay);
  3696. return handle;
  3697. };
  3698. /**
  3699. * F5 脚本报错(设计稿 30:1228)。
  3700. *
  3701. * 与 runBlocked / alert 都不同:它有两个出口,而且**两个出口都要给**——
  3702. * 「查看日志」直接进 D3(用户想弄清楚为什么挂了),
  3703. * 「知道了」只是收掉。合成一个按钮会逼用户在"看不到原因"和"被迫跳页"之间二选一。
  3704. */
  3705. Dialog.scriptError = function (message, onViewLogs, onDismiss) {
  3706. var sheet = buildSheet();
  3707. var handle = null;
  3708. /* 顶部警示图标:字符 "!" + 玫瑰色 14% 衬底(矢量图标在字符方案里见 W.warnIcon) */
  3709. sheet.card.addView(W.warnIcon(Theme.hex.danger));
  3710. sheet.card.addView(U.spacer(12));
  3711. sheet.card.addView(U.text("\u811A\u672C\u6267\u884C\u51FA\u9519", 18, Theme.hex.textPrimary, true));
  3712. sheet.card.addView(U.spacer(10));
  3713. sheet.card.addView(sheetNote(message || "\u811A\u672C\u5DF2\u505C\u6B62\u8FD0\u884C"));
  3714. sheet.card.addView(U.spacer(20));
  3715. sheet.card.addView(dialogButtons([
  3716. { label: "\u67E5\u770B\u65E5\u5FD7", variant: "ghost", height: 44, onClick: function () {
  3717. if (handle) { handle.close(); }
  3718. if (onViewLogs) { onViewLogs(); }
  3719. } },
  3720. { label: "\u77E5\u9053\u4E86", variant: "primary", height: 44, onClick: function () {
  3721. if (handle) { handle.close(); }
  3722. if (onDismiss) { onDismiss(); }
  3723. } }
  3724. ]));
  3725. handle = present(sheet.overlay);
  3726. return handle;
  3727. };
  3728. /** 确认:取消 / 确定,结果走回调。 */
  3729. Dialog.confirm = function (title, message, onConfirm) {
  3730. var sheet = buildSheet(title);
  3731. var handle = null;
  3732. sheet.card.addView(wrappedText(message));
  3733. sheet.card.addView(U.spacer(20));
  3734. sheet.card.addView(cancelConfirmRow(
  3735. function () { if (handle) { handle.close(); } },
  3736. function () {
  3737. if (handle) { handle.close(); }
  3738. if (onConfirm) { onConfirm(); }
  3739. }
  3740. ));
  3741. handle = present(sheet.overlay);
  3742. return handle;
  3743. };
  3744. /* ---------------------------------------------------------------- 单选 */
  3745. /**
  3746. * 单选弹窗。选项竖排,当前项高亮。
  3747. * 比下拉列表更适合深色界面,也避免了下拉框「选完写回 undefined」那一类问题。
  3748. */
  3749. Dialog.choose = function (opts) {
  3750. var sheet = buildSheet(opts.title);
  3751. var handle = null;
  3752. var options = opts.options || [];
  3753. for (var i = 0; i < options.length; i++) {
  3754. var selected = i === opts.currentIndex;
  3755. var item = U.text(options[i], Theme.m.labelSize,
  3756. selected ? Theme.hex.accentText : Theme.hex.textSecondary,
  3757. selected);
  3758. item.setGravity(android.view.Gravity.CENTER_VERTICAL);
  3759. item.setBackground(selected
  3760. ? U.solidBg(U.alphaOf("#FFFFFF", Theme.alpha.accentChipFill), Theme.m.chipRadius)
  3761. : U.solidBg(U.alphaOf("#FFFFFF", 0), Theme.m.chipRadius));
  3762. item.setPadding(U.dp(12), U.dp(11), U.dp(12), U.dp(11));
  3763. (function (index, view) {
  3764. view.setOnClickListener(function () {
  3765. if (handle) { handle.close(); }
  3766. if (opts.onConfirm) { opts.onConfirm(index); }
  3767. });
  3768. })(i, item);
  3769. if (i > 0) { sheet.card.addView(U.spacer(6)); }
  3770. sheet.card.addView(item);
  3771. }
  3772. sheet.card.addView(U.spacer(20));
  3773. sheet.card.addView(dialogButtons([
  3774. { label: "\u53D6\u6D88", variant: "ghost", height: 44,
  3775. onClick: function () { if (handle) { handle.close(); } } }
  3776. ]));
  3777. handle = present(sheet.overlay);
  3778. return handle;
  3779. };
  3780. /* ---------------------------------------------------------------- 权限 */
  3781. /**
  3782. * 单行权限:名称 + 状态标签。整行可点,点了直接跳对应设置页。
  3783. *
  3784. * 状态文案只有三种,集中在 W.pill 的 tone 里表达:
  3785. * 已开启 → ok(青绿)
  3786. * 未开启 → warn(琥珀)—— 这是阻断项
  3787. * 未开启(可选) → mute(灰)—— 不阻断,故意不抢注意力
  3788. * 把「可选」写进标签本身,而不是靠颜色让用户猜,是这里最要紧的一点。
  3789. */
  3790. function permissionRow(row) {
  3791. var nameView = U.text(row.name, Theme.m.labelSize, Theme.hex.textPrimary, false);
  3792. nameView.setSingleLine(true);
  3793. var tone = row.granted ? "ok" : (row.optional ? "mute" : "warn");
  3794. var text = row.granted ? "\u5DF2\u5F00\u542F"
  3795. : (row.optional ? "\u672A\u5F00\u542F\uFF08\u53EF\u9009\uFF09" : "\u672A\u5F00\u542F");
  3796. var status = W.pill(text, tone);
  3797. var line = U.row();
  3798. line.addView(nameView);
  3799. line.addView(U.flexible());
  3800. line.addView(U.hgap(Theme.m.gapInline));
  3801. line.addView(status.root);
  3802. /* 整行都算命中区:可视高度只有 26dp 左右,
  3803. * 不加高会低于 44dp 的最小触摸目标。 */
  3804. line.setPadding(0, U.dp(9), 0, U.dp(9));
  3805. line.setMinimumHeight(U.dp(Theme.m.touchTarget));
  3806. line.setOnClickListener(function () {
  3807. Device.openPermissionSettings(row.key);
  3808. });
  3809. return line;
  3810. }
  3811. /** 把三行权限按固定顺序铺进卡片。两个权限弹窗共用,避免顺序各写各的。 */
  3812. function stackPermissionRows(card) {
  3813. var rows = Device.permissionRows();
  3814. for (var i = 0; i < rows.length; i++) {
  3815. if (i > 0) { card.addView(U.spacer(8)); }
  3816. card.addView(permissionRow(rows[i]));
  3817. }
  3818. return rows;
  3819. }
  3820. /**
  3821. * 「去授权」该跳哪一项。
  3822. *
  3823. * 规则:先找**阻断项**(未开启且非可选),都开了再退而处理可选的通知。
  3824. * 早先的实现直接取第一个未授权项,会在「无障碍已开、通知没开」时
  3825. * 把用户带到通知设置页 —— 而他真正缺的是悬浮窗。
  3826. */
  3827. function firstPendingPermission() {
  3828. var rows = Device.permissionRows();
  3829. var i;
  3830. for (i = 0; i < rows.length; i++) {
  3831. if (!rows[i].granted && !rows[i].optional) { return rows[i]; }
  3832. }
  3833. for (i = 0; i < rows.length; i++) {
  3834. if (!rows[i].granted) { return rows[i]; }
  3835. }
  3836. return null;
  3837. }
  3838. /** 权限行 + 去授权/稍后再说 的公共尾部。 */
  3839. function buildPermissionSheet(title, note) {
  3840. var sheet = buildSheet(title);
  3841. var handle = null;
  3842. sheet.card.addView(sheetNote(note));
  3843. sheet.card.addView(U.spacer(18));
  3844. stackPermissionRows(sheet.card);
  3845. sheet.card.addView(U.spacer(20));
  3846. var go = W.primaryButton("\u53BB\u6388\u6743", 46, function () {
  3847. var pending = firstPendingPermission();
  3848. if (!pending) {
  3849. toast("\u6743\u9650\u5DF2\u9F50\u5168");
  3850. if (handle) { handle.close(); }
  3851. return;
  3852. }
  3853. Device.openPermissionSettings(pending.key);
  3854. /* 跳出去后不自动关闭:用户开了权限再退回来,
  3855. * 留在弹窗上能让他看到状态已经变绿。 */
  3856. toast("\u8BF7\u5F00\u542F\u300C" + pending.name + "\u300D\u540E\u8FD4\u56DE");
  3857. });
  3858. sheet.card.addView(go.root);
  3859. /* 「稍后再说」按设计稿是纯文字,不占按钮位 */
  3860. sheet.card.addView(W.link("\u7A0D\u540E\u518D\u8BF4", Theme.hex.textFaint, function () {
  3861. if (handle) { handle.close(); }
  3862. }));
  3863. handle = present(sheet.overlay);
  3864. return handle;
  3865. }
  3866. /**
  3867. * 权限检测弹窗(设计稿 7:260)。
  3868. * 只做展示与跳转,不自己改任何状态(检测与执行分离)。
  3869. */
  3870. Dialog.showPermissions = function () {
  3871. return buildPermissionSheet("\u9700\u8981\u5F00\u542F\u6743\u9650",
  3872. "\u4E3A\u6B63\u5E38\u6267\u884C\u811A\u672C\uFF0C\u8BF7\u5148\u6388\u4E88\u4EE5\u4E0B\u6743\u9650");
  3873. };
  3874. /**
  3875. * 权限拦截弹窗(设计稿 16:436)。
  3876. *
  3877. * 与 showPermissions 的区别不是文案,而是**时机与语气**:
  3878. * 前者是进 App 时的软引导,后者是用户按了「开始运行」却被挡回来;
  3879. * 所以标题是「无法开始运行」,说明也直接点名缺哪两个权限。
  3880. */
  3881. Dialog.permissionBlocked = function () {
  3882. return buildPermissionSheet("\u65E0\u6CD5\u5F00\u59CB\u8FD0\u884C",
  3883. "\u65E0\u969C\u788D\u4E0E\u60AC\u6D6E\u7A97\u6743\u9650\u672A\u5F00\u542F\uFF0C\u811A\u672C\u65E0\u6CD5\u6267\u884C");
  3884. };
  3885. /* ------------------------------------------------------------ 危险操作确认 */
  3886. /**
  3887. * 解绑卡密确认(设计稿 16:951)。
  3888. *
  3889. * 三件事必须同时说清,否则用户点完才后悔:
  3890. * ① 后果是什么(要重新输入卡密);② 有没有更严重的连带影响(正在跑的会停)。
  3891. * 确认按钮用实心红而不是渐变青蓝 —— 渐变在这套界面里代表「推荐动作」。
  3892. */
  3893. Dialog.confirmUnbind = function (onConfirm) {
  3894. var sheet = buildSheet(null);
  3895. var handle = null;
  3896. sheet.card.addView(W.warnIcon(Theme.hex.danger));
  3897. sheet.card.addView(U.spacer(16));
  3898. sheet.card.addView(U.text("\u786E\u8BA4\u89E3\u7ED1\u5361\u5BC6\uFF1F", 18, Theme.hex.textPrimary, true));
  3899. sheet.card.addView(U.spacer(10));
  3900. sheet.card.addView(sheetNote("\u89E3\u7ED1\u540E\u9700\u91CD\u65B0\u8F93\u5165\u5361\u5BC6\u624D\u80FD\u4F7F\u7528"));
  3901. sheet.card.addView(U.spacer(4));
  3902. sheet.card.addView(sheetNote("\u6B63\u5728\u8FD0\u884C\u7684\u811A\u672C\u5C06\u7ACB\u5373\u505C\u6B62"));
  3903. sheet.card.addView(U.spacer(20));
  3904. sheet.card.addView(cancelConfirmRow(
  3905. function () { if (handle) { handle.close(); } },
  3906. function () {
  3907. if (handle) { handle.close(); }
  3908. if (onConfirm) { onConfirm(); }
  3909. },
  3910. "\u786E\u8BA4\u89E3\u7ED1",
  3911. "danger"
  3912. ));
  3913. handle = present(sheet.overlay);
  3914. return handle;
  3915. };
  3916. /**
  3917. * 卡密到期弹窗(设计稿 16:1480)。
  3918. * 唯一的出口是「确认」,确认后回到登录页 —— 到期后留在主界面没有任何可做的事。
  3919. */
  3920. Dialog.licenseExpired = function (onConfirm) {
  3921. var sheet = buildSheet(null);
  3922. var handle = null;
  3923. sheet.card.addView(W.warnIcon(Theme.hex.warn));
  3924. sheet.card.addView(U.spacer(16));
  3925. sheet.card.addView(U.text("\u6B63\u5728\u8FD0\u884C\u7684\u811A\u672C\u5DF2\u505C\u6B62", 18, Theme.hex.textPrimary, true));
  3926. sheet.card.addView(U.spacer(10));
  3927. sheet.card.addView(sheetNote("\u5361\u5BC6\u5DF2\u5230\u671F\uFF0C\u9700\u91CD\u65B0\u767B\u5F55\u540E\u7EE7\u7EED\u4F7F\u7528"));
  3928. sheet.card.addView(U.spacer(20));
  3929. sheet.card.addView(dialogButtons([
  3930. { label: "\u786E\u8BA4", variant: "primary", height: 46, onClick: function () {
  3931. if (handle) { handle.close(); }
  3932. if (onConfirm) { onConfirm(); }
  3933. } }
  3934. ]));
  3935. sheet.card.addView(U.spacer(10));
  3936. var tail = U.text("\u786E\u8BA4\u540E\u5C06\u8FD4\u56DE\u5361\u5BC6\u767B\u5F55\u754C\u9762",
  3937. 11, Theme.hex.textFaint, false);
  3938. tail.setGravity(android.view.Gravity.CENTER);
  3939. sheet.card.addView(tail);
  3940. handle = present(sheet.overlay);
  3941. return handle;
  3942. };
  3943. /* ============================== src/floatball.js ============================== */
  3944. /* ============================== src/floatball.js ==============================
  3945. * 全局悬浮球(规格书 §10 / 设计稿 H1–H7 `34:*`)。
  3946. *
  3947. * ## 它解决什么
  3948. * 引擎必须待在 TikTok 前台才认得出页面,所以 App 内的「全局运行条」在真正
  3949. * 干活的时候是看不见的。悬浮球浮在所有应用之上,是**跨 App 唯一的控制入口
  3950. * 与运行状态可视处**(规格书 §10 的定位)。
  3951. *
  3952. * ## ★ 窗口层:两个 overlay 窗口,尺寸与位置恒定(2026-10-15 重做)
  3953. *
  3954. * **不要改回"一个窗口、展开时改尺寸"。** 那条路必然"跳",而且顺序怎么排都躲不掉:
  3955. * 改窗口尺寸/位置由系统(WindowManager,另一个进程)**立刻生效**,
  3956. * 而我们这边的视图重排要等 App 下一帧重绘;中间那几帧,合成器显示的是
  3957. * **上一帧的旧画面被摆到新的窗口矩形里**。用录屏逐帧验证过:展开那一刻球会
  3958. * 从下往上"爬"几级才停 —— 就是用户说的"跳动"。
  3959. * 两次失败的补救(都在真机上试过):
  3960. * · 同一个 JS 调用里"先藏球、再改尺寸" —— 藏只是标记,改尺寸已经发出去了,照样跳;
  3961. * · "藏起来 → 等这一帧画完(onPreDraw)→ 再改尺寸" —— 合成器有 2~3 帧缓冲队列,
  3962. * 它照样能把"藏之前"那一帧摆到新位置上去。
  3963. * ⇒ 结论:只能**根本不改窗口几何**。两个窗口各自建好就永不动窝
  3964. * (只有用户拖动时球窗口才移,那是用户主动的动作):
  3965. * · 球窗口 88×88dp:环 + 球 + 透明热区,永远可触摸;
  3966. * · 扇形窗口 308×264dp:4 个按钮 + 4 个标签,**折叠时整窗 NOT_TOUCHABLE**
  3967. * (触摸自动穿透到下面的 App,所以它的透明区不是死区),展开时才可触摸。
  3968. * 于是"展开/收起"= 改显隐 + 改一个 flag,没有任何几何变化,没有任何东西会跳。
  3969. *
  3970. * ## ⚠️ 其他实测出来的约束(踩完才知道)
  3971. * 1. 走**裸 WindowManager**(`addView` / `updateViewLayout` / `removeView`),
  3972. * 而不是 AutoX 的 `floaty.window`:
  3973. * · `floaty.rawWindow(代码建的 View)` 直接不可用(缺 `toXMLString()`);
  3974. * · `floaty` 的窗口会在内容外再套一圈 **12dp 内边距**(去不掉),几何全靠反推;
  3975. * · `floaty` 同时只能有一个窗口真正绘制(第二个建得出来但画不出来);
  3976. * · `floaty` 默认还带一个调整手柄小点。
  3977. * 裸窗口没有这些问题 —— 两个窗口能同时绘制是实测过的(`_probe/fb_two_windows.js`)。
  3978. * 本模块用视图自己的 `setPadding` 复现了那圈 12dp 内边距,
  3979. * 所以"球心在根视图里的坐标"这套算法一个字没改。
  3980. * 2. **窗口的透明区同样会吃掉触摸**(实测:点它透明区里的输入框键盘不弹,
  3981. * 点窗口外就弹)。所以折叠时扇形窗口必须 NOT_TOUCHABLE,否则那是一片死区。
  3982. * 3. 覆盖窗口必须 `FLAG_NOT_FOCUSABLE`(§10.9④),否则会抢 TikTok 的键盘焦点。
  3983. * 4. `WindowManager.LayoutParams` 的 x/y/w/h 都是**像素**,dp 换算只在 `dp()` 里做。
  3984. * 5. **UI 线程禁止 `sleep()`** —— 本模块所有时序一律 `setTimeout` / `setInterval`。
  3985. * 6. 图片只能 `images.read(path).bitmap` + `setImageBitmap(bmp)`;
  3986. * XML 的 `src="…"` 走的是 drawable 查找,**不吃文件路径**(会 InflateException)。
  3987. *
  3988. * ## 与规格书的对应
  3989. * §10.1 Ø48 球 / 透明热区 64 / 扇形 R=96 / 子按钮 Ø48 / 夹角 60°20°−20°−60°
  3990. * §10.3 扇形四个按钮:运行·停止 / 回到 App / 隐藏悬浮球 / 退出应用
  3991. * §10.4 三态靠**外环**表达(空闲无环 / 运行中青绿 22% / 报错玫瑰 20%),
  3992. * 球内**恒定**是品牌图标(避免"球里是播放键 → 以为点了就运行"的歧义)
  3993. * §10.5 单击 = 展开收起;拖动 > 8px = 移动;松手吸附最近边缘并记忆位置
  3994. * §10.11 球在右侧时扇形**镜像向左展开**、标签移到按钮左侧
  3995. */
  3996. var FB = {};
  3997. /* ------------------------------------------------------------ 常量(对齐 §10.1) */
  3998. FB.DIAM = 48; // 球体直径
  3999. FB.HOT = 64; // 透明热区(球的触控目标比视觉大)
  4000. FB.R = 96; // 扇形半径
  4001. FB.SUB = 48; // 子按钮直径
  4002. FB.LABEL_H = 24; // 标签胶囊高
  4003. FB.ANGLES = [60, 20, -20, -60]; // 扇形夹角(以球心为原点,向右展开)
  4004. FB.PAD = 12; // ⚠️ 悬浮窗根视图的固定内边距(实测,不能改)
  4005. FB.SLOP = 8; // 拖动判定阈值:位移 > 8px 才算拖动,否则是单击
  4006. FB.EDGE = 12; // 贴边留白(§10.1 左边距 12)
  4007. FB.BRAND = "/sdcard/tiktok_app/brand/FluxRpa_mark_only_412.png";
  4008. /**
  4009. * 球的窗口矩形发布给引擎(规格书 §10.9③ 的另一半)。
  4010. *
  4011. * 为什么需要它:球浮在所有应用之上,引擎的**坐标手势**点到球位就点到了球
  4012. * (实测:搜索建议列表的一条正好压住球位 → 点出了扇形面板)。
  4013. * 引擎侧 `ui.js` 的 clickNode 在读这个文件做避让,所以界面这边必须**如实发布**
  4014. * 球的窗口矩形(比球大一圈,因为窗口的透明区同样会吃掉触摸)。
  4015. */
  4016. FB.RECT_PATH = "/sdcard/tiktok_app/ball-rect.json";
  4017. /* 动画节奏。规格书 §10.1 给的是 180ms 展开动画;这里用「分帧位移」实现,
  4018. * 帧数 × 帧间隔 ≈ 180ms(不用 ValueAnimator:多引一层 Java 接口包装,
  4019. * 换 AutoX 版本就可能失效,而这只是 6 帧的位置插值)。 */
  4020. FB.ANIM_FRAMES = 6;
  4021. FB.ANIM_STEP_MS = 30;
  4022. FB.BREATH_MS = 900; // 运行中外环呼吸周期
  4023. /* ------------------------------------------------------------ 运行期状态 */
  4024. /* ------------------- 窗口层:两个 overlay,尺寸恒定 ------------------- */
  4025. FB.ballWin = null; // 球窗口(88×88dp):常驻、永远可触摸、尺寸只在拖动时变
  4026. FB.fanWin = null; // 扇形窗口句柄占位(真正的视图是 FB.fanRoot)
  4027. FB.window = null; // 兼容别名 = 球窗口的根视图(dumpLayout / 自检里用它)
  4028. FB.root = null; // 球窗口里的根 FrameLayout
  4029. FB.fanRoot = null; // 扇形窗口里的根 FrameLayout
  4030. FB.ballLp = null; // 球窗口 LayoutParams(拖动改它)
  4031. FB.fanLp = null; // 扇形窗口 LayoutParams(改位置与 NOT_TOUCHABLE)
  4032. FB.open = false; // 扇形是否展开
  4033. FB.side = "left"; // 球贴哪一边(决定扇形方向)
  4034. FB.anchorX = 0; // 球左上角 X(像素,绝对屏幕坐标)
  4035. FB.anchorCy = 0; // 球心 Y(像素)
  4036. FB.dragging = false; // 本次触摸是否已判定为拖动
  4037. FB.breathTimer = null;
  4038. FB.breathOn = false;
  4039. FB.views = {}; // id → 视图
  4040. FB.lastTone = ""; // 上一次的三态,用于避免无谓重建扇形
  4041. FB.lastRectKey = ""; // 上一次发布的球位(避免重复写盘)
  4042. FB.firstBtn = null; // 扇形第 1 个按钮(运行/停止)的视图引用,用于就地更新
  4043. /* ------------------------------------------------------------ 调试开关 */
  4044. /** 调试模式:`/sdcard/tiktok_app/ball-debug` 存在即开启(每 2 秒最多查一次)。 */
  4045. FB.debugLastCheck = 0;
  4046. FB.debugOn = false;
  4047. FB.debugEnabled = function () {
  4048. var now = Date.now();
  4049. if (now - FB.debugLastCheck > 2000) {
  4050. FB.debugLastCheck = now;
  4051. try { FB.debugOn = files.exists("/sdcard/tiktok_app/ball-debug"); } catch (e) { FB.debugOn = false; }
  4052. }
  4053. return FB.debugOn;
  4054. };
  4055. function logTouch(action, x, y) {
  4056. try {
  4057. files.append('/sdcard/tiktok_app/ball-touch.log',
  4058. Date.now() + ' action=' + action + ' (' + Math.round(x) + ',' + Math.round(y) + ')' +
  4059. ' open=' + FB.open + ' anchor=' + FB.anchorX + ',' + FB.anchorCy + '\n');
  4060. } catch (e) { }
  4061. }
  4062. /** 一般轨迹(点按钮、走确认、拉前台…),同样只在 ball-debug 打开时落盘。 */
  4063. function trace(msg) {
  4064. if (!FB.debugEnabled()) { return; }
  4065. try { files.append('/sdcard/tiktok_app/ball-touch.log', Date.now() + ' ' + msg + '\n'); } catch (e) { }
  4066. }
  4067. /**
  4068. * 异常轨迹。
  4069. *
  4070. * ★ 悬浮球这一层的异常**不会自己冒出来**:UI 回调里抛错既不结束脚本、也不弹提示,
  4071. * 用户看到的就是"点了没反应"。所以凡是"点了应该发生什么"的路径,都要有人接住
  4072. * 并把异常写下来 —— 否则下一次还是只能靠猜。
  4073. */
  4074. function traceError(where, error) {
  4075. var text = where + ' 抛错: ' + error + '\n' + (error && error.stack ? error.stack : '');
  4076. try { console.log('[fb] ' + text); } catch (e0) { }
  4077. if (!FB.debugEnabled()) { return; }
  4078. try { files.append('/sdcard/tiktok_app/ball-touch.log', Date.now() + ' ' + text + '\n'); } catch (e1) { }
  4079. try { files.write('/sdcard/tiktok_app/ball-error.txt', text); } catch (e2) { }
  4080. }
  4081. /* ------------------------------------------------------------ 布局轨迹(调试用) */
  4082. /**
  4083. * 记录「**这一帧球实际画在哪**」。
  4084. *
  4085. * 为什么需要它:展开前后**球心是设计成不动的**(窗口变大、球在窗口内的偏移同步变大,
  4086. * 两者相抵)。所以任何肉眼可见的位移,都只可能来自"两半改动没落在同一帧"——
  4087. * 即窗口已经换了尺寸、而视图还按旧布局摆(或反过来)。那种中间态只存在一两帧,
  4088. * 连拍截图(~250ms/帧)根本抓不到。
  4089. *
  4090. * 而 `getLocationOnScreen()` 读的是**布局结果**,不是我们设进去的值,所以它能把这个
  4091. * 中间态如实反映出来 —— 这正是"能不能看见"的判据。
  4092. */
  4093. function absPosOf(view) {
  4094. if (!view) { return "?"; }
  4095. try {
  4096. /* ⚠️ 必须是**真的 Java int[]**:Rhino 把 JS 数组按值转成 Java 数组,
  4097. * 方法写进去的值读不回来 —— 传 [0,0] 永远得到 0,0(白测一轮才发现)。 */
  4098. var loc = java.lang.reflect.Array.newInstance(java.lang.Integer.TYPE, 2);
  4099. view.getLocationOnScreen(loc);
  4100. return java.lang.reflect.Array.getInt(loc, 0) + "," + java.lang.reflect.Array.getInt(loc, 1);
  4101. } catch (e) {
  4102. try { return view.getLeft() + "," + view.getTop(); } catch (e2) { return "?"; }
  4103. }
  4104. }
  4105. /** 打一张当前快照:窗口矩形 + 球/热区的屏幕绝对位置 + 根视图透明度。 */
  4106. function traceFrame(tag) {
  4107. if (!FB.debugEnabled() || !FB.window) { return; }
  4108. var parts = [tag];
  4109. try {
  4110. parts.push("win=" + FB.window.getWidth() + "x" + FB.window.getHeight() +
  4111. "@" + FB.window.getX() + "," + FB.window.getY());
  4112. } catch (e) { parts.push("win=?"); }
  4113. if (FB.views.ball) {
  4114. parts.push("ballAbs=" + absPosOf(FB.views.ball) +
  4115. " " + FB.views.ball.getWidth() + "x" + FB.views.ball.getHeight());
  4116. }
  4117. if (FB.views.hot) {
  4118. parts.push("hotAbs=" + absPosOf(FB.views.hot) +
  4119. " " + FB.views.hot.getWidth() + "x" + FB.views.hot.getHeight());
  4120. }
  4121. try { parts.push("rootAlpha=" + FB.root.getAlpha()); } catch (e2) { }
  4122. trace("frame " + parts.join(" | "));
  4123. }
  4124. /** 挂一次布局监听:这个视图**每次真正被布局**(含首次)都打一张快照。 */
  4125. function watchLayout(tag, view) {
  4126. if (!FB.debugEnabled() || !view) { return; }
  4127. try {
  4128. view.addOnLayoutChangeListener(new android.view.View.OnLayoutChangeListener({
  4129. onLayoutChange: function () { traceFrame(tag); }
  4130. }));
  4131. } catch (e) {
  4132. trace("watchLayout(" + tag + ") 失败: " + e);
  4133. }
  4134. }
  4135. /* ------------------------------------------------------------ 小工具 */
  4136. function dp(v) {
  4137. return Math.round(v * (context.getResources().getDisplayMetrics().density || 1));
  4138. }
  4139. function screenW() { return device.width; }
  4140. function screenH() { return device.height; }
  4141. /**
  4142. * 折叠态窗口边长(dp):热区 + 两侧内边距。
  4143. *
  4144. * ★ 命名带 Dp 后缀是刻意的:这套坐标算错过一次 —— `fanSize()` 原先返回**像素**
  4145. * 而调用方把它当 dp 再用(`place()` 会再乘一次密度),于是展开时球心被算到
  4146. * 根视图外面:球整体下移、四个按钮挤在一起。dp / px 混用不会有任何报错,
  4147. * 只会"看起来不太对",所以这里用名字把单位写死。
  4148. */
  4149. function collapsedSizeDp() { return FB.HOT + FB.PAD * 2; }
  4150. /** 扇形展开后窗口需要多大(dp) */
  4151. function fanSizeDp() {
  4152. /* 水平:内边距 + 热区 + 扇形半径 + 子按钮半径 + 4dp 间隙 + 最长标签 + 内边距 */
  4153. var labelMax = 96;
  4154. var w = FB.PAD + FB.HOT + FB.R + FB.SUB / 2 + 4 + labelMax + FB.PAD;
  4155. /* 垂直:球心上下各「半径 + 子按钮半径 + 余量」 */
  4156. var h = 2 * (FB.R + FB.SUB / 2 + 12);
  4157. return { w: w, h: h };
  4158. }
  4159. /**
  4160. * 球心在窗口**根视图**里的坐标(dp)—— 展开/折叠共用这一个公式。
  4161. *
  4162. * 折叠态球心在热区正中;展开态球心垂直居中(扇形上下都要地方)、
  4163. * 水平仍贴原来那一侧(右侧时取镜像)。
  4164. */
  4165. function ballCenterInRoot(winWDp, winHDp) {
  4166. var half = FB.HOT / 2;
  4167. var rootW = winWDp - FB.PAD * 2; /* 根视图被内边距挤掉一圈 */
  4168. var rootH = winHDp - FB.PAD * 2;
  4169. var x = (FB.side === "right") ? (rootW - half) : half;
  4170. /* ⚠️ 是**根视图**的中心,不是窗口的中心:漏掉这个 PAD 会让球整体下移 12dp */
  4171. return { x: x, y: rootH / 2 };
  4172. }
  4173. /**
  4174. * 把当前悬浮窗的**真实布局**写盘(排障用)。
  4175. *
  4176. * 这条是踩坑之后加的:球的坐标算错过两次(dp/px 混用、根视图中心漏算内边距),
  4177. * 两次都表现为"看起来只是有点歪",靠肉眼量截图量不准。
  4178. * 有了它,下一次任何"位置不对"都能一眼看出是窗口、根还是某个子视图算错了。
  4179. */
  4180. FB.dumpLayout = function (tag) {
  4181. if (!FB.root || !FB.ballLp) { return; }
  4182. var out = ['# ' + tag];
  4183. function winLine(name, lp, view) {
  4184. if (!lp) { return name + ' (无)'; }
  4185. var touchable = !(lp.flags & FLAG_NOT_TOUCHABLE);
  4186. return name + ' ' + lp.width + 'x' + lp.height + ' @ ' + lp.x + ',' + lp.y +
  4187. ' touchable=' + touchable + ' root=' +
  4188. (view ? view.getWidth() + 'x' + view.getHeight() : '?');
  4189. }
  4190. try {
  4191. out.push(winLine('ballWin', FB.ballLp, FB.root));
  4192. out.push(winLine('fanWin ', FB.fanLp, FB.fanRoot));
  4193. out.push('anchor ' + FB.anchorX + ',' + FB.anchorCy + ' side=' + FB.side + ' open=' + FB.open);
  4194. function dumpChildren(prefix, view) {
  4195. if (!view) { return; }
  4196. for (var i = 0; i < view.getChildCount(); i++) {
  4197. var v = view.getChildAt(i);
  4198. var text = '';
  4199. var vis = v.getVisibility();
  4200. try { text = (typeof v.getText === 'function') ? String(v.getText()) : ''; } catch (e) { text = '?'; }
  4201. var shown = (vis === android.view.View.GONE) ? ' GONE' :
  4202. (vis === android.view.View.INVISIBLE ? ' INVIS' : '');
  4203. out.push(' ' + prefix + '#' + i + ' ' + v.getLeft() + ',' + v.getTop() + ' ' +
  4204. v.getWidth() + 'x' + v.getHeight() + shown + ' "' + text + '"');
  4205. }
  4206. }
  4207. dumpChildren('ball ', FB.root);
  4208. dumpChildren('fan ', FB.fanRoot);
  4209. } catch (error) {
  4210. out.push('dump 失败: ' + error);
  4211. }
  4212. try { files.write('/sdcard/tiktok_app/ball-layout.txt', out.join('\n')); } catch (e2) { }
  4213. };
  4214. /* ------------------------------------------------------------ 落盘(记忆位置) */
  4215. /** 当前垂直位置的比例(0~1000),换屏幕比例后仍然合理。 */
  4216. FB.yRatio = function () {
  4217. var h = screenH();
  4218. if (!h) { return 500; }
  4219. return Math.max(0, Math.min(1000, Math.round(FB.anchorCy / h * 1000)));
  4220. };
  4221. FB.savePosition = function () {
  4222. Store.set("ball.side", FB.side);
  4223. Store.set("ball.yRatio", FB.yRatio());
  4224. };
  4225. /** 从配置恢复锚点位置(球心 X / Y 像素)。 */
  4226. FB.restoreAnchor = function () {
  4227. FB.side = Store.getEnum("ball.side", ["left", "right"]);
  4228. var ratio = Store.getNumber("ball.yRatio");
  4229. if (ratio < 100 || ratio > 900) { ratio = 500; } /* 越界值当默认,别把球顶到屏幕外 */
  4230. FB.anchorCy = Math.round(screenH() * ratio / 1000);
  4231. FB.anchorX = (FB.side === "right")
  4232. ? screenW() - dp(FB.EDGE) - dp(FB.DIAM)
  4233. : dp(FB.EDGE);
  4234. FB.clampAnchor();
  4235. };
  4236. /** 锚点必须留在屏幕内:用户手滑拖出去,球就再也点不到了。 */
  4237. /**
  4238. * 把锚点夹进「两个窗口都能完整显示」的范围。
  4239. *
  4240. * ★ 夹的必须是**锚点**而不是窗口:两个窗口的位置都由锚点推导,分别夹会让它们错位。
  4241. * 代价是球不能被拖到离上下边缘 120dp 以内(扇形半高)—— 这是物理上必须的,
  4242. * 否则扇形窗口会出屏、下面两个按钮点不到。横向不用管:球本来就吸附在左右边缘上。
  4243. */
  4244. FB.clampAnchor = function () {
  4245. var maxX = Math.max(0, screenW() - dp(FB.DIAM));
  4246. FB.anchorX = Math.max(0, Math.min(maxX, FB.anchorX));
  4247. var m = dp(fanSizeDp().h / 2) - dp(FB.PAD); /* 球心到扇形窗口上/下边的最小距离 */
  4248. var minY = Math.max(dp(FB.DIAM) / 2, m);
  4249. var maxY = Math.max(minY, screenH() - m);
  4250. FB.anchorCy = Math.max(minY, Math.min(maxY, FB.anchorCy));
  4251. };
  4252. /* ------------------------------------------------------------ 视图构建 */
  4253. /** 圆形图标按钮(扇形里的四个,或球体本身)。 */
  4254. function circleButton(hexFill, gradient, iconChar, iconHex, iconSize) {
  4255. var view = U.text(iconChar, iconSize || 15, iconHex || "#FFFFFF", false);
  4256. view.setGravity(android.view.Gravity.CENTER);
  4257. view.setBackground(gradient
  4258. ? U.gradientBg(Theme.hex.accent, Theme.hex.accentDeep, Theme.m.pillRadius)
  4259. : U.solidBg(U.alphaOf(hexFill, hexFill.indexOf("#FFFFFF") === 0 ? 0.08 : 0.14), Theme.m.pillRadius));
  4260. U.params(view, FB.SUB, FB.SUB);
  4261. return view;
  4262. }
  4263. /** 标签胶囊(§10.1:高 24、圆角 99、距按钮 4px、11px 字)。 */
  4264. function labelPill(text, textHex, bgHex, bgAlpha) {
  4265. var view = U.text(text, Theme.m.chipSize, textHex, true);
  4266. view.setGravity(android.view.Gravity.CENTER);
  4267. view.setBackground(U.solidBg(U.alphaOf(bgHex, bgAlpha), Theme.m.pillRadius));
  4268. view.setPadding(U.dp(10), 0, U.dp(10), 0);
  4269. var lp = new android.widget.FrameLayout.LayoutParams(-2, dp(FB.LABEL_H));
  4270. view.setLayoutParams(lp);
  4271. return view;
  4272. }
  4273. /** 绝对定位(根是 FrameLayout,用 margin 当坐标)。 */
  4274. function place(view, leftDp, topDp, wDp, hDp) {
  4275. var lp = new android.widget.FrameLayout.LayoutParams(
  4276. wDp > 0 ? dp(wDp) : wDp, hDp > 0 ? dp(hDp) : hDp);
  4277. lp.leftMargin = dp(leftDp);
  4278. lp.topMargin = dp(topDp);
  4279. view.setLayoutParams(lp);
  4280. return view;
  4281. }
  4282. /** 球能不能建:卡密有效 + 悬浮窗权限 + 用户没主动隐藏。 */
  4283. FB.canShow = function () {
  4284. if (Store.getBool("ball.hidden")) { return false; }
  4285. if (Service.licenseStatus().status !== "active") { return false; }
  4286. return Device.overlayEnabled();
  4287. };
  4288. /* ------------------------------------------------------------ 窗口层(两个 overlay,尺寸恒定) */
  4289. var TYPE_APPLICATION_OVERLAY = 2038;
  4290. var FLAG_NOT_FOCUSABLE = 0x00000008;
  4291. var FLAG_NOT_TOUCHABLE = 0x00000010;
  4292. var FLAG_NOT_TOUCH_MODAL = 0x00000020;
  4293. /* ★ 必须带上它:否则窗口会被状态栏内缩,整套坐标整体下移一个状态栏高度
  4294. * (实测球比锚点低了 48px,正好是状态栏高。AutoX 的 floaty 窗口带这个 flag,所以以前没暴露) */
  4295. var FLAG_LAYOUT_IN_SCREEN = 0x00000100;
  4296. var FLAG_LAYOUT_NO_LIMITS = 0x00000200;
  4297. var FLAG_HARDWARE_ACCELERATED = 0x01000000;
  4298. var wmService = null;
  4299. function wm() {
  4300. if (!wmService) { wmService = context.getSystemService("window"); }
  4301. return wmService;
  4302. }
  4303. function overlayFlags(touchable) {
  4304. var f = FLAG_NOT_FOCUSABLE | FLAG_NOT_TOUCH_MODAL | FLAG_LAYOUT_NO_LIMITS |
  4305. FLAG_LAYOUT_IN_SCREEN | FLAG_HARDWARE_ACCELERATED;
  4306. /* §10.9④:不抢输入焦点,否则会影响 TikTok 的键盘 */
  4307. if (!touchable) { f = f | FLAG_NOT_TOUCHABLE; }
  4308. return f;
  4309. }
  4310. /** 建一个覆盖窗口(尺寸取 px)。返回它的 LayoutParams,后面拖动/改触摸要用。 */
  4311. function addOverlay(view, x, y, w, h, touchable) {
  4312. var lp = new android.view.WindowManager.LayoutParams();
  4313. lp.type = TYPE_APPLICATION_OVERLAY;
  4314. lp.format = -3; /* PixelFormat.TRANSLUCENT */
  4315. lp.flags = overlayFlags(touchable);
  4316. lp.gravity = 51; /* TOP | LEFT,于是 x/y 就是屏幕绝对坐标 */
  4317. lp.x = x; lp.y = y;
  4318. lp.width = w; lp.height = h;
  4319. wm().addView(view, lp);
  4320. return lp;
  4321. }
  4322. /**
  4323. * ★★ 为什么是**两个**窗口(2026-10-15 重做;这一版之前的做法都错了)
  4324. *
  4325. * 之前是"一个窗口,展开时改尺寸"。它必然"跳",且**顺序怎么排都躲不掉**:
  4326. * 改窗口尺寸/位置由系统(WindowManager)**立刻生效**,
  4327. * 而我们这边的视图重排要等 App 下一帧重绘;
  4328. * 中间那几帧,合成器显示的是**上一帧的旧画面被摆到新的窗口矩形里**。
  4329. * 用录屏逐帧看得很清楚:展开那一刻球会从下往上"爬"几级才停 —— 就是用户说的"跳动"。
  4330. * 试过的两条补救都不行:
  4331. * · 先在同一个 JS 调用里把球藏起来再改尺寸 —— 藏只是标记,改尺寸已经发出去了,照样跳;
  4332. * · 藏起来之后**等这一帧画完**(`onPreDraw`)再改尺寸 —— 合成器有 2~3 帧的缓冲队列,
  4333. * 它照样能把"藏之前"的那一帧摆到新位置上去(实测仍然跳)。
  4334. *
  4335. * ⇒ 唯一干净的办法是**根本不改窗口几何**:
  4336. * · **球窗口** 88×88dp:常驻、永远可触摸、尺寸与位置只在拖动时变;
  4337. * · **扇形窗口** 308×264dp:位置随球算好,**折叠时整窗设成 NOT_TOUCHABLE**
  4338. * (触摸自动穿透到下面的 App,所以它的透明区不会是死区),展开时才可触摸。
  4339. * 于是"展开/收起"只改**显隐**与一个 flag —— 没有任何几何变化,也就没有任何东西会跳。
  4340. * 附带好处:折叠态的"死区"从 176×176px 缩到球窗口那么大,引擎要绕开的范围也小了(见 publishRect)。
  4341. *
  4342. * 两个窗口能同时绘制是**实测过的**(`_probe/fb_two_windows.js`:红块与绿块都出现)。
  4343. * AutoX 的 `floaty.window` 做不到这一点(第二个建得出来但画不出来),所以这里走裸 WindowManager。
  4344. */
  4345. FB.ensure = function () {
  4346. if (FB.ballWin) { FB.sync(); return; }
  4347. if (!FB.canShow()) { return; }
  4348. try {
  4349. FB.restoreAnchor();
  4350. /* ① 先建两棵视图树。内边距用视图自己的 padding 复现那圈 12dp,
  4351. * 于是"球心在根视图里的坐标"这套算法一个字都不用改 */
  4352. FB.root = new android.widget.FrameLayout(context);
  4353. FB.root.setPadding(dp(FB.PAD), dp(FB.PAD), dp(FB.PAD), dp(FB.PAD));
  4354. buildBallViews();
  4355. FB.fanRoot = new android.widget.FrameLayout(context);
  4356. FB.fanRoot.setPadding(dp(FB.PAD), dp(FB.PAD), dp(FB.PAD), dp(FB.PAD));
  4357. buildFanViews();
  4358. /* ② 再加窗口:**扇形先加、球后加**。同类型窗口按加入顺序叠放,后加的在上;
  4359. * 球必须在最上层 —— 扇形展开时球的矩形整个落在扇形窗口里,
  4360. * 球若在下层,点它就会被扇形窗口吃掉,**再点一次收不起来**(真机踩过)。
  4361. * 两层的子视图不重叠(扇形按钮离球心 96dp,球窗口只有 88dp),所以不会有遮挡问题。 */
  4362. var f = fanSizeDp();
  4363. FB.fanLp = addOverlay(FB.fanRoot, 0, 0, dp(f.w), dp(f.h), false);
  4364. var c = collapsedSizeDp();
  4365. FB.ballLp = addOverlay(FB.root, 0, 0, dp(c), dp(c), true);
  4366. FB.ballWin = FB.root; /* 兼容别名:dumpLayout / 自检里用它 */
  4367. FB.window = FB.root;
  4368. positionBallWindow();
  4369. positionFanWindow();
  4370. FB.open = false;
  4371. FB.publishRect(); /* 启动就把球位告诉引擎,别让它去点球 */
  4372. FB.sync();
  4373. attach();
  4374. } catch (e) {
  4375. console.log("[fb] 创建悬浮窗失败: " + e);
  4376. traceError('ensure', e);
  4377. FB.destroy();
  4378. }
  4379. };
  4380. /** 窗口挂上去之后的收尾(定位、调试监听、留一份布局快照)。 */
  4381. function attach() {
  4382. setTimeout(function () {
  4383. if (!FB.ballWin) { return; }
  4384. if (FB.debugEnabled() && FB.root) {
  4385. FB.root.setOnTouchListener(new android.view.View.OnTouchListener({
  4386. onTouch: function (v, event) {
  4387. trace('root-touch action=' + event.getAction() + ' @' +
  4388. Math.round(event.getRawX()) + ',' + Math.round(event.getRawY()));
  4389. return false;
  4390. }
  4391. }));
  4392. }
  4393. FB.dumpLayout('collapsed');
  4394. console.log("[fb] 悬浮球已就绪 side=" + FB.side + " pos=" + FB.anchorX + "," + FB.anchorCy);
  4395. }, 60);
  4396. }
  4397. FB.destroy = function () {
  4398. FB.stopBreath();
  4399. /* 先告诉引擎"球没了"再关窗:反过来的话,关窗与写文件之间那一瞬
  4400. * 引擎可能刚好读到旧矩形,于是白白绕开一个已经不存在的球。 */
  4401. try {
  4402. files.write(FB.RECT_PATH, JSON.stringify({ hidden: true, updatedAt: Date.now() }));
  4403. FB.lastRectKey = "";
  4404. } catch (e0) { /* 写不进就算了,引擎读不到会当没有球 */ }
  4405. try { if (FB.root) { wm().removeView(FB.root); } } catch (e) { console.log("[fb] 移除球窗口失败: " + e); }
  4406. try { if (FB.fanRoot) { wm().removeView(FB.fanRoot); } } catch (e2) { console.log("[fb] 移除扇形窗口失败: " + e2); }
  4407. FB.ballWin = null;
  4408. FB.fanWin = null;
  4409. FB.window = null;
  4410. FB.ballLp = null;
  4411. FB.fanLp = null;
  4412. FB.root = null;
  4413. FB.fanRoot = null;
  4414. FB.open = false;
  4415. FB.views = {};
  4416. FB.firstBtn = null;
  4417. };
  4418. /* ------------------------------------------------------------ 扇形几何 */
  4419. /**
  4420. * 第 index 个按钮相对「球心」的偏移(dp)。
  4421. * 球在右侧时整体镜像(x 取负)—— 规格书 §10.11:不镜像整组按钮会出屏。
  4422. */
  4423. function fanOffset(index) {
  4424. var rad = FB.ANGLES[index] * Math.PI / 180;
  4425. var dx = FB.R * Math.cos(rad);
  4426. var dy = -FB.R * Math.sin(rad); /* 屏幕 y 向下,故取负 sin */
  4427. return { x: FB.side === "right" ? -dx : dx, y: dy };
  4428. }
  4429. /* ------------------------------------------------------------ 视图构建(只建一次) */
  4430. /**
  4431. * 品牌图标位图**只解码一次**。
  4432. * `images.read()` 要把 412×412 的 PNG 解码成位图,是这一层最贵的一步。
  4433. */
  4434. FB.brandBitmap = null;
  4435. function brandBitmap() {
  4436. if (FB.brandBitmap) { return FB.brandBitmap; }
  4437. try {
  4438. var img = images.read(FB.BRAND);
  4439. if (img) { FB.brandBitmap = img.bitmap; }
  4440. else { console.log("[fb] 品牌图不存在: " + FB.BRAND); }
  4441. } catch (e) {
  4442. console.log("[fb] 读品牌图失败: " + e);
  4443. }
  4444. return FB.brandBitmap;
  4445. }
  4446. /**
  4447. * 球窗口里就三件套:呼吸环(Ø60)/ 球体(Ø48)/ 透明热区(Ø64)。
  4448. *
  4449. * 位置**建一次就够**:折叠态与展开态球在根视图里的坐标是同一个
  4450. * (球心永远在球窗口的正中),所以没有"换状态要重摆"这回事。
  4451. * 顺序:环 → 球 → 热区,热区最后加所以盖在最上层收手势。
  4452. */
  4453. function buildBallViews() {
  4454. var root = FB.root;
  4455. root.removeAllViews();
  4456. var ring = U.text("", 1, "#FFFFFF", false);
  4457. ring.setBackground(U.solidBg(U.alphaOf(Theme.hex.ok, 0.22), Theme.m.pillRadius));
  4458. /* 建的时候先藏起来:环的颜色/有无完全由 sync() 按运行态决定。
  4459. * 默认可见会让空闲态也挂着一个绿环。 */
  4460. ring.setVisibility(android.view.View.GONE);
  4461. place(ring, (FB.HOT - 60) / 2, (FB.HOT - 60) / 2, 60, 60);
  4462. root.addView(ring);
  4463. var ball = new android.widget.ImageView(context);
  4464. var bmp = brandBitmap();
  4465. if (bmp) { ball.setImageBitmap(bmp); }
  4466. ball.setScaleType(android.widget.ImageView.ScaleType.FIT_XY);
  4467. ball.setBackground(U.solidBg(U.argb(Theme.hex.bg), Theme.m.pillRadius));
  4468. ball.setClipToOutline(true);
  4469. place(ball, (FB.HOT - FB.DIAM) / 2, (FB.HOT - FB.DIAM) / 2, FB.DIAM, FB.DIAM);
  4470. root.addView(ball);
  4471. /* 透明热区:球 Ø48、热区 Ø64 —— §10.1 的"视觉小、触控大" */
  4472. var hot = new android.view.View(context);
  4473. hot.setBackground(U.solidBg(0x00000000, 0));
  4474. place(hot, 0, 0, FB.HOT, FB.HOT);
  4475. hot.setOnTouchListener(new android.view.View.OnTouchListener({
  4476. onTouch: function (v, event) { return onTouch(event); }
  4477. }));
  4478. root.addView(hot);
  4479. FB.views.ring = ring;
  4480. FB.views.ball = ball;
  4481. FB.views.hot = hot;
  4482. watchLayout("ball", ball);
  4483. watchLayout("hot", hot);
  4484. }
  4485. /**
  4486. * 扇形窗口里是 4 个按钮 + 4 个标签,**建一次、位置也只算一次**
  4487. * (它们只取决于扇形几何与球贴哪一边;换边时由 layoutFanViews() 重摆)。
  4488. */
  4489. function buildFanViews() {
  4490. var root = FB.fanRoot;
  4491. root.removeAllViews();
  4492. FB.views.fan = [];
  4493. FB.firstBtn = null;
  4494. var specs = fanSpecs();
  4495. var i;
  4496. for (i = 0; i < specs.length; i++) {
  4497. var spec = specs[i];
  4498. var shape = (spec.kind === "primary") ? primaryState() : spec;
  4499. var danger = !!shape.danger;
  4500. var btn = U.text(shape.icon, 15, shape.iconHex, false);
  4501. btn.setGravity(android.view.Gravity.CENTER);
  4502. btn.setBackground(shape.gradient
  4503. ? U.gradientBg(Theme.hex.accent, Theme.hex.accentDeep, Theme.m.pillRadius)
  4504. : (danger
  4505. ? U.solidBg(U.alphaOf(Theme.hex.danger, shape.solid ? 1 : 0.14), Theme.m.pillRadius)
  4506. : U.solidBg(U.alphaOf("#FFFFFF", 0.08), Theme.m.pillRadius)));
  4507. btn.setVisibility(android.view.View.GONE);
  4508. /* 轨迹里用「标签」而不是 kind:只有第 1 个按钮有 kind,
  4509. * 其余三个会打成 kind=undefined —— 排障时最需要看清"点中的是哪一个"。 */
  4510. var fanTag = (spec.kind === "primary") ? "primary" : String(spec.label);
  4511. (function (action, tag, idx) {
  4512. /* 点按钮 → 执行动作。
  4513. * ⚠️ try/catch 是必须的:UI 回调里抛错在 AutoX 上**不报错、不弹窗、
  4514. * 不结束脚本**,症状就是"点了没反应"。这里把它转成一条可读的轨迹。 */
  4515. btn.setOnClickListener(function () {
  4516. trace('click fan#' + idx + ' ' + tag + ' open=' + FB.open);
  4517. try {
  4518. action();
  4519. } catch (e) {
  4520. traceError('扇形按钮#' + idx + '(' + tag + ')', e);
  4521. }
  4522. /* 选完就收起:扇形是个**选择器**,点完还摊着,正好挡住用户此刻要看的东西。
  4523. * 「停止 / 退出」在 confirmThenAct 里已经收过一次,这里是幂等的。 */
  4524. try { FB.collapse(); } catch (e2) { traceError('收起扇形', e2); }
  4525. });
  4526. })(spec.action, fanTag, i);
  4527. root.addView(btn);
  4528. var pill = labelPill(shape.label, danger ? Theme.hex.danger : "#FFFFFF",
  4529. danger ? Theme.hex.danger : "#FFFFFF", danger ? 0.14 : 0.10);
  4530. pill.setVisibility(android.view.View.GONE);
  4531. root.addView(pill);
  4532. FB.views.fan.push({ btn: btn, pill: pill });
  4533. if (spec.kind === "primary") { FB.firstBtn = { view: btn, pill: pill }; }
  4534. }
  4535. layoutFanViews();
  4536. }
  4537. /**
  4538. * 摆扇形里那 8 个视图。只在**建视图**与**换了贴边方向**时调用。
  4539. *
  4540. * ★ 必须走 `place()`:它内部统一做 dp→px。这里原先调的是另一个自己写的 pillParams(),
  4541. * 那里面漏了 dp() —— 于是标签的坐标被当成像素用、全部挤到左上角(按钮是对的)。
  4542. */
  4543. function layoutFanViews() {
  4544. var size = fanSizeDp();
  4545. var center = ballCenterInRoot(size.w, size.h);
  4546. var i;
  4547. for (i = 0; i < FB.views.fan.length; i++) {
  4548. var off = fanOffset(i);
  4549. var cx = center.x + off.x;
  4550. var cy = center.y + off.y;
  4551. var item = FB.views.fan[i];
  4552. place(item.btn, cx - FB.SUB / 2, cy - FB.SUB / 2, FB.SUB, FB.SUB);
  4553. /* 标签:向右展开时贴按钮右侧,镜像时贴左侧(§10.11) */
  4554. var label = "";
  4555. try { label = String(item.pill.getText()); } catch (e) { label = ""; }
  4556. var pw = measurePillWidth(item.pill, label);
  4557. var px = (FB.side === "right") ? (cx - FB.SUB / 2 - 4 - pw) : (cx + FB.SUB / 2 + 4);
  4558. place(item.pill, px, cy - FB.LABEL_H / 2, -2, FB.LABEL_H);
  4559. }
  4560. }
  4561. /**
  4562. * 扇形 8 个视图的显隐。
  4563. * 用 **GONE 而不是 INVISIBLE**:INVISIBLE 仍然占着位置、也仍然会吃掉触摸。
  4564. */
  4565. function setFanVisible(on) {
  4566. var vis = on ? android.view.View.VISIBLE : android.view.View.GONE;
  4567. for (var i = 0; i < FB.views.fan.length; i++) {
  4568. FB.views.fan[i].btn.setVisibility(vis);
  4569. FB.views.fan[i].pill.setVisibility(vis);
  4570. }
  4571. }
  4572. /**
  4573. * 扇形**窗口**能不能吃触摸。
  4574. *
  4575. * 折叠时必须设成不可触摸:扇形窗口有一大片透明区,而**窗口的透明区同样会吃掉触摸**
  4576. * (实测:点它透明区里的输入框,键盘不弹;点窗口外,键盘就弹)。
  4577. * 设成 NOT_TOUCHABLE 之后触摸会穿透到下面的 App —— 于是折叠态没有任何死区。
  4578. */
  4579. function setFanTouchable(on) {
  4580. if (!FB.fanLp) { return; }
  4581. FB.fanLp.flags = overlayFlags(on);
  4582. try {
  4583. wm().updateViewLayout(FB.fanRoot, FB.fanLp);
  4584. } catch (e) {
  4585. traceError('setFanTouchable', e);
  4586. }
  4587. }
  4588. /** 标签宽度:项目里没有测量工具,按字数估算(11px 字 + 两侧 10dp 内边距)。 */
  4589. function measurePillWidth(pill, text) {
  4590. var len = String(text).length;
  4591. var ascii = 0, wide = 0, j;
  4592. for (j = 0; j < len; j++) {
  4593. if (String(text).charCodeAt(j) < 128) { ascii += 1; } else { wide += 1; }
  4594. }
  4595. return dp(20) + ascii * dp(7) + wide * dp(11);
  4596. }
  4597. /**
  4598. * 四个按钮的定义(§10.3)。
  4599. * 第 1 个随运行态切换:空闲/完成后是「运行」,运行中是「停止」。
  4600. */
  4601. function fanSpecs() {
  4602. return [
  4603. /* ★ 第 1 个按钮的动作**在点击那一刻现取**当前状态(见 primaryState)。
  4604. *
  4605. * ⚠️ 这里踩过一个"界面全对、日志正常、就是点了没反应"的大坑,务必别再改回去:
  4606. * 早期用一个可变 holder(`FB.firstAction.fn`)存这个动作,由 `applyFirstButton()`
  4607. * 在**三态发生变化时**写入。而"展开扇形"这一步通常是 idle → idle ——
  4608. * 状态根本没变,`sync()` 判定 `changed === false`,于是对**新按钮实例**
  4609. * 从不调用 `applyFirstButton()`,holder 里始终是最初那个空函数。
  4610. * 表现:「运行 / 停止」点了毫无反应,而其余三个按钮(动作内联在下面几行)都正常;
  4611. * 触摸事件收到了、界面也完全正确 —— 只是回调是个空壳,最难查的一类。
  4612. * ⇒ 动作一律**现算**,不存可变引用,"忘记接线"这种可能就根本不存在了。 */
  4613. { kind: "primary",
  4614. action: function () { primaryState().fn(); } },
  4615. { icon: "\u25A6", label: "\u56DE\u5230 App", iconHex: "#FFFFFF",
  4616. action: function () { App.bringToFront(); } },
  4617. { icon: "\u2298", label: "\u9690\u85CF\u60AC\u6D6E\u7403", iconHex: "#FFFFFF",
  4618. action: function () { FB.hide(); } },
  4619. { icon: "\u2715", label: "\u9000\u51FA\u5E94\u7528", iconHex: Theme.hex.danger, danger: true,
  4620. action: function () { askExit(); } }
  4621. ];
  4622. }
  4623. /** 第 1 个按钮当前的形态(也定义了它现在该做什么)。 */
  4624. function primaryState() {
  4625. var running = !!Session.running;
  4626. return running
  4627. ? { icon: "\u25A0", label: "\u505C\u6B62", iconHex: "#FFFFFF", danger: true, solid: true,
  4628. fn: function () { askStop(); } }
  4629. : { icon: "\u25B6", label: "\u8FD0\u884C", iconHex: "#FFFFFF", gradient: true,
  4630. fn: function () { askRun(); } };
  4631. }
  4632. /* --------------------------------------------- 展开 / 收起(只改显隐与一个 flag) */
  4633. /**
  4634. * 展开 / 收起**只做两件事**:扇形视图的显隐 + 扇形窗口的 NOT_TOUCHABLE 开关。
  4635. *
  4636. * 没有尺寸变化、没有位置变化、没有等待、没有动画 —— 所以不存在"跳"的可能。
  4637. * 这一点是本模块的核心结论:只要**窗口几何不变**,合成器拿到的每一帧缓冲都画在同一个
  4638. * 矩形里,就不可能出现"旧画面被摆到新位置"。详见窗口层那段注释(含两次失败的尝试)。
  4639. *
  4640. * ★ **不做任何过渡动画**(用户 2026-10-14 明确要求去掉)。
  4641. * 规格书 §10.1 写了"展开动画 180ms",这里是有意识的偏离:那 180ms 淡入会让
  4642. * "展开"在点击后近 0.3 秒才看清,而这个球的用法是"点一下就到位"。
  4643. * 将来若要恢复动画,在 `setFanVisible(true)` 之后做透明度渐变 —— ⚠️ 但那时
  4644. * **透明度的写者必须只有这一处**,否则会出现"两个动画互相顶掉"的鬼影(不报错、只有肉眼看得见)。
  4645. */
  4646. FB.expand = function () {
  4647. if (FB.open || !FB.ballWin) { return; }
  4648. try {
  4649. FB.open = true;
  4650. positionFanWindow(); /* 位置随球算(拖动过就更新一下) */
  4651. if (FB.firstBtn) { applyFirstButton(); } /* 第 1 个按钮跟上当前运行态 */
  4652. setFanVisible(true);
  4653. setFanTouchable(true);
  4654. FB.sync(); /* 三态外环 */
  4655. FB.publishRect();
  4656. FB.dumpLayout('fan'); /* 切换之后的真实布局,留一份给排障 */
  4657. trace("expand 完成");
  4658. } catch (error) {
  4659. /* 展开路径上任何一步抛错都必须留下痕迹:真机上它表现为"点了没反应",
  4660. * 而 AutoX 的 UI 回调异常不会让脚本退出、也不会弹任何提示。 */
  4661. traceError('expand', error);
  4662. }
  4663. };
  4664. FB.collapse = function () {
  4665. if (!FB.ballWin || !FB.open) { return; }
  4666. try {
  4667. FB.open = false;
  4668. setFanVisible(false);
  4669. setFanTouchable(false); /* 整窗不可触摸 → 触摸穿透,不留死区 */
  4670. FB.sync();
  4671. FB.publishRect();
  4672. FB.dumpLayout('collapsed');
  4673. trace("collapse 完成");
  4674. } catch (error) {
  4675. traceError('collapse', error);
  4676. }
  4677. };
  4678. FB.toggleOpen = function () {
  4679. if (FB.open) { FB.collapse(); } else { FB.expand(); }
  4680. };
  4681. /* ------------------------------------------------------------ 位置计算 */
  4682. /**
  4683. * 由「球锚点」反推某个窗口的左上角(像素)。**两个窗口共用这一处换算。**
  4684. *
  4685. * 球心的屏幕绝对坐标必须始终等于锚点:
  4686. * 球心 = 窗口位置 + 内边距(PAD) + 球心在根视图里的偏移
  4687. * 所以 窗口位置 = 锚点 − PAD − 根内偏移。折叠态与展开态的"根内偏移"不同
  4688. * (折叠在正中、展开要垂直居中给扇形腾地方),于是两个窗口算出来的位置也不同 ——
  4689. * 这没关系:**窗口各自建好之后就再也不动了**。
  4690. */
  4691. function originFor(winWDp, winHDp) {
  4692. var c = ballCenterInRoot(winWDp, winHDp);
  4693. return {
  4694. x: Math.round(FB.anchorX + dp(FB.DIAM) / 2 - dp(FB.PAD) - dp(c.x)),
  4695. y: Math.round(FB.anchorCy - dp(FB.PAD) - dp(c.y)),
  4696. w: dp(winWDp),
  4697. h: dp(winHDp)
  4698. };
  4699. }
  4700. function moveWindow(lp, view, o) {
  4701. if (!lp || !view) { return; }
  4702. lp.x = o.x; lp.y = o.y; lp.width = o.w; lp.height = o.h;
  4703. try {
  4704. wm().updateViewLayout(view, lp);
  4705. } catch (e) {
  4706. traceError('updateViewLayout', e);
  4707. }
  4708. }
  4709. /** 把球窗口摆到锚点(拖动中与吸附后调用)。尺寸不变,只改位置。 */
  4710. function positionBallWindow() {
  4711. var c = collapsedSizeDp();
  4712. moveWindow(FB.ballLp, FB.root, originFor(c, c));
  4713. }
  4714. /** 把扇形窗口摆到锚点(展开前调用;拖动过之后位置要跟上)。 */
  4715. function positionFanWindow() {
  4716. var f = fanSizeDp();
  4717. moveWindow(FB.fanLp, FB.fanRoot, originFor(f.w, f.h));
  4718. }
  4719. /**
  4720. * 把「引擎必须绕开的矩形」写盘(`ball-rect.json`)。
  4721. *
  4722. * 折叠态**只发球窗口**:扇形窗口那时是 NOT_TOUCHABLE,触摸会穿透到下面的 App,
  4723. * 引擎不必绕开它 —— 这比之前"永远绕开一大块"更准,引擎的点击成功率也更高。
  4724. * 展开态发两个窗口的并集。
  4725. */
  4726. FB.publishRect = function () {
  4727. var ball = originFor(collapsedSizeDp(), collapsedSizeDp());
  4728. var rect;
  4729. if (FB.open) {
  4730. var fan = originFor(fanSizeDp().w, fanSizeDp().h);
  4731. var x0 = Math.min(ball.x, fan.x), y0 = Math.min(ball.y, fan.y);
  4732. var x1 = Math.max(ball.x + ball.w, fan.x + fan.w);
  4733. var y1 = Math.max(ball.y + ball.h, fan.y + fan.h);
  4734. rect = { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
  4735. } else {
  4736. rect = { x: ball.x, y: ball.y, w: ball.w, h: ball.h };
  4737. }
  4738. rect.open = !!FB.open;
  4739. rect.updatedAt = Date.now();
  4740. var key = [rect.x, rect.y, rect.w, rect.h, rect.open ? 1 : 0].join(",");
  4741. if (key === FB.lastRectKey) { return; } /* 只在真的变了才写盘 */
  4742. FB.lastRectKey = key;
  4743. try {
  4744. files.write(FB.RECT_PATH, JSON.stringify(rect));
  4745. } catch (e) {
  4746. console.log("[fb] 发布球位失败: " + e);
  4747. }
  4748. };
  4749. /* ------------------------------------------------------------ 手势:单击 / 拖动 */
  4750. var touchState = { x: 0, y: 0, ax: 0, ay: 0, moved: false, downAt: 0 };
  4751. function onTouch(event) {
  4752. var action = event.getAction();
  4753. var rawX = event.getRawX(), rawY = event.getRawY();
  4754. /* 触摸事件日志:**默认关闭**。
  4755. *
  4756. * 球的手势属于"点不出来就完全没线索"的那类问题,所以留了这条日志 ——
  4757. * 但每次触摸都往盘上追加是白白 IO,于是用「调试开关文件」控制:
  4758. * `adb shell touch /sdcard/tiktok_app/ball-debug` 打开,删掉即关闭。
  4759. * 排查这类问题时它是救命的(CANCEL 与 UP 混在一起那次就是靠它定位的)。 */
  4760. if (FB.debugEnabled()) { logTouch(action, rawX, rawY); }
  4761. if (action === android.view.MotionEvent.ACTION_DOWN) {
  4762. touchState.x = rawX;
  4763. touchState.y = rawY;
  4764. touchState.ax = FB.anchorX;
  4765. touchState.ay = FB.anchorCy;
  4766. touchState.moved = false;
  4767. touchState.downAt = Date.now();
  4768. return true;
  4769. }
  4770. if (action === android.view.MotionEvent.ACTION_MOVE) {
  4771. var dx = rawX - touchState.x;
  4772. var dy = rawY - touchState.y;
  4773. if (!touchState.moved &&
  4774. (Math.abs(dx) > dp(FB.SLOP) || Math.abs(dy) > dp(FB.SLOP))) {
  4775. touchState.moved = true;
  4776. FB.dragging = true;
  4777. /* 拖动时先收起:展开态的窗口很大,拖着走会挡住半个屏幕 */
  4778. if (FB.open) { FB.collapse(true); }
  4779. }
  4780. if (touchState.moved) {
  4781. FB.anchorX = touchState.ax + dx;
  4782. FB.anchorCy = touchState.ay + dy;
  4783. FB.clampAnchor();
  4784. positionBallWindow();
  4785. }
  4786. return true;
  4787. }
  4788. if (action === android.view.MotionEvent.ACTION_CANCEL) {
  4789. /* ★ CANCEL **不是** UP,不能当点击处理 —— 这个坑很隐蔽:
  4790. * `setSize()` 改窗口尺寸会让 WindowManager 把正在进行的这条触摸流
  4791. * 判为取消,于是每次展开都会紧跟一个 CANCEL。
  4792. * 早期版本把 CANCEL 与 UP 一视同仁,结果就是「展开 → 立刻又收起」,
  4793. * 用户看到的是"点了完全没反应",而日志里一切正常(事件都收到了)。
  4794. * 取消时只复位状态,不做任何动作。 */
  4795. FB.dragging = false;
  4796. touchState.moved = false;
  4797. return true;
  4798. }
  4799. if (action === android.view.MotionEvent.ACTION_UP) {
  4800. FB.dragging = false;
  4801. var moved = touchState.moved;
  4802. touchState.moved = false;
  4803. if (moved) {
  4804. FB.snapToEdge();
  4805. } else {
  4806. FB.toggleOpen();
  4807. }
  4808. return true;
  4809. }
  4810. return false;
  4811. }
  4812. /** 松手吸附最近边缘并记忆(§10.5)。 */
  4813. FB.snapToEdge = function () {
  4814. var half = screenW() / 2;
  4815. FB.side = (FB.anchorX + dp(FB.DIAM) / 2 < half) ? "left" : "right";
  4816. FB.anchorX = (FB.side === "right")
  4817. ? screenW() - dp(FB.EDGE) - dp(FB.DIAM)
  4818. : dp(FB.EDGE);
  4819. FB.clampAnchor();
  4820. FB.savePosition();
  4821. positionBallWindow();
  4822. positionFanWindow(); /* 位置随球算,早点跟上 */
  4823. layoutFanViews(); /* 贴边方向变了 → 扇形要重新镜像,标签也得换到另一侧 */
  4824. FB.sync();
  4825. FB.publishRect();
  4826. };
  4827. /* ------------------------------------------------------------ 状态同步(三态外环) */
  4828. /**
  4829. * 把球刷成当前运行状态(§10.4):
  4830. * 空闲 → 无外环;运行中 → 青绿 22% + 呼吸;报错 → 玫瑰 20% 静态。
  4831. * 球内**永远**是品牌图标(不随状态换),状态只靠环表达。
  4832. */
  4833. FB.sync = function () {
  4834. if (!FB.window) { return; }
  4835. /* 状态切换期间不改环:此刻它还在展开态的位置上,而 sync() 会把环设成 VISIBLE
  4836. * (运行中),于是切换的空档里会冒出一个"位置不对的绿环"。等落地后再刷。 */
  4837. var ring = FB.views.ring;
  4838. if (!ring) { return; }
  4839. var tone = "idle";
  4840. if (App.failBar) { tone = "error"; }
  4841. else if (Session.running) { tone = "running"; }
  4842. if (tone === "running") {
  4843. ring.setBackground(U.solidBg(U.alphaOf(Theme.hex.ok, 0.22), Theme.m.pillRadius));
  4844. ring.setVisibility(android.view.View.VISIBLE);
  4845. FB.startBreath();
  4846. } else if (tone === "error") {
  4847. FB.stopBreath();
  4848. ring.setBackground(U.solidBg(U.alphaOf(Theme.hex.danger, 0.20), Theme.m.pillRadius));
  4849. ring.setVisibility(android.view.View.VISIBLE);
  4850. } else {
  4851. FB.stopBreath();
  4852. ring.setVisibility(android.view.View.GONE);
  4853. }
  4854. /* 扇形开着时,第 1 个按钮要跟着状态变(运行 ↔ 停止)。
  4855. *
  4856. * ★ 这里是**就地改那一个按钮**,不重建扇形 —— 真机上踩过两次:
  4857. * ① 无条件重建:本函数每秒被 tickTimer 调一次,用户点下去那一刻正好被重建掉,
  4858. * 点击就丢了("偶尔点不动",极难查);
  4859. * ② 只在状态变化时重建:仍然会与旧视图叠在一起(建到一半就与残留的叠着),
  4860. * 真机上表现为「运行」和「停止」两个标签同时出现、位置还错位。 */
  4861. var changed = (tone !== FB.lastTone);
  4862. FB.lastTone = tone;
  4863. if (FB.open && changed && FB.firstBtn) {
  4864. applyFirstButton();
  4865. }
  4866. };
  4867. /**
  4868. * 就地更新扇形第 1 个按钮(图标 / 文字 / 底色)。
  4869. *
  4870. * ★ 它**只改外观,不改行为** —— 行为由 `primaryState()` 在点击时现算
  4871. * (曾经在这里写 `FB.firstAction.fn = shape.fn`,造成"状态没变就永远没接线"的空回调,
  4872. * 详见 `fanSpecs()` 上的注释)。
  4873. */
  4874. function applyFirstButton() {
  4875. var shape = primaryState();
  4876. var view = FB.firstBtn.view;
  4877. view.setText(shape.icon);
  4878. view.setBackground(shape.gradient
  4879. ? U.gradientBg(Theme.hex.accent, Theme.hex.accentDeep, Theme.m.pillRadius)
  4880. : U.solidBg(U.alphaOf(Theme.hex.danger, shape.solid ? 1 : 0.14), Theme.m.pillRadius));
  4881. var pill = FB.firstBtn.pill;
  4882. pill.setText(shape.label);
  4883. var danger = !!shape.danger;
  4884. pill.setTextColor(U.argb(danger ? Theme.hex.danger : "#FFFFFF"));
  4885. pill.setBackground(U.solidBg(U.alphaOf(danger ? Theme.hex.danger : "#FFFFFF",
  4886. danger ? 0.14 : 0.10), Theme.m.pillRadius));
  4887. /* 就地改完再落一份布局快照 —— 否则 ball-layout.txt 永远停在"展开那一刻",
  4888. * 事后回看会以为按钮一直是「运行」(实际它已经变成「停止」,只是没重新 dump)。 */
  4889. }
  4890. /**
  4891. * 呼吸动效:外环在 22% ↔ 6% 之间循环。
  4892. * 用 setInterval 而不是 ValueAnimator —— 少引一层 Java 接口包装,
  4893. * 而且这里只需要"看起来在呼吸",不需要精确的插值曲线。
  4894. */
  4895. FB.startBreath = function () {
  4896. if (FB.breathTimer) { return; }
  4897. FB.breathTimer = setInterval(function () {
  4898. if (!FB.window || !FB.views.ring) { FB.stopBreath(); return; }
  4899. FB.breathOn = !FB.breathOn;
  4900. FB.views.ring.setBackground(U.solidBg(
  4901. U.alphaOf(Theme.hex.ok, FB.breathOn ? 0.22 : 0.06), Theme.m.pillRadius));
  4902. }, FB.BREATH_MS);
  4903. };
  4904. FB.stopBreath = function () {
  4905. if (FB.breathTimer) {
  4906. clearInterval(FB.breathTimer);
  4907. FB.breathTimer = null;
  4908. }
  4909. };
  4910. /**
  4911. * 呼吸环是否正在跑。
  4912. *
  4913. * 存在的理由:它占一个 `setInterval`,而自检里「运行计时器该起 / 该停」是按
  4914. * interval 总数断言的 —— 球一被建出来(就是它在说"正在运行"),总数就会多一个。
  4915. * 有了这个查询,断言可以写成「总数 − 呼吸环 = 运行计时器」,
  4916. * 而不是把期望值改成 2 这种"把噪声当基准"的写法。
  4917. */
  4918. FB.breathing = function () { return !!FB.breathTimer; };
  4919. /* ------------------------------------------------------------ 四个按钮的动作 */
  4920. /** ① 运行:走与 App 内完全相同的校验链(悬浮球不给自己开后门)。 */
  4921. function askRun() {
  4922. var taskKey = App.detail || Session.tab;
  4923. if (!taskKey) {
  4924. /* 没有"当前任务"时(停在任务列表):回 App 让用户自己挑一个,
  4925. * 比在这里猜一个任务跑起来安全得多(规格书 §2.5 的"已选脚本"校验同理)。 */
  4926. toast("\u8BF7\u5148\u5728\u4EFB\u52A1\u5217\u8868\u9009\u4E00\u4E2A\u811A\u672C");
  4927. App.bringToFront();
  4928. return;
  4929. }
  4930. App.requestRun(taskKey);
  4931. }
  4932. /* ------------------------------------------------------------ 二次确认(H5 / H6) */
  4933. /**
  4934. * ⚠️ 这里做了一处**有意识的偏离**,原因写清楚免得后来者白折腾:
  4935. *
  4936. * 规格书 §10.6 把 H5/H6 画成悬浮球自己的弹窗,我也照做了 —— 用第二个 floaty 窗
  4937. * 承载遮罩 + 卡片。真机上它**建得出来但画不出来**:同屏已经有一个浮窗(球)时,
  4938. * 再开一个全屏浮窗只剩 AutoX 的调整手柄小点,遮罩与卡片都不显示(试过补
  4939. * `setSize`/`setAdjustEnabled(false)`/延后 150ms 创建,都没解决)。
  4940. *
  4941. * 「停止」是球上最要紧的一个键,**绝不能出现点了没反应**。所以改成确定可行的路径:
  4942. * 点停止/退出 → 把 App 切到前台 → 用 App 里那套**已经在真机验证过的**弹窗确认。
  4943. * 代价是多一次"App 跳到前台",换来的是"这个键一定能用"。
  4944. *
  4945. * 后续若要回到规格书的浮窗形态:要么查清双浮窗的绘制问题(大概率与 AutoX 的
  4946. * 窗口尺寸协商有关),要么把确认做成**在扇形内联**(按钮变成"确认停止?"),
  4947. * 后者完全不需要第二个窗口,其实更稳。
  4948. */
  4949. function confirmThenAct(opts) {
  4950. trace('confirmThenAct 进入');
  4951. FB.collapse(true); /* 先收起扇形:确认期间不该留着一排按钮 */
  4952. setTimeout(function () {
  4953. /* 用户必须看得见确认弹窗,而弹窗画在 App 窗口里。
  4954. * `bringToFront` 失败时 App 不在前台 → 弹窗建得出来但没人看得见
  4955. * (外部表现为"点了停止,什么都没发生")。所以把成败记进轨迹。 */
  4956. var ok = false;
  4957. try { ok = App.bringToFront(); } catch (e) { traceError('bringToFront', e); }
  4958. trace('confirmThenAct bringToFront=' + ok);
  4959. try {
  4960. opts.dialog();
  4961. trace('confirmThenAct 弹窗已弹出');
  4962. } catch (e2) {
  4963. traceError('confirmThenAct 弹窗', e2);
  4964. }
  4965. }, 150);
  4966. }
  4967. /** ② 停止:H5 二次确认(规格书 §10.6,停止必须确认、运行不用)。 */
  4968. function askStop() {
  4969. var name = Session.scriptName || Pages.taskName(Session.tab);
  4970. var elapsed = Records.formatDuration(Session.elapsedMs());
  4971. confirmThenAct({
  4972. dialog: function () {
  4973. Dialog.stopScript(name, elapsed, function () {
  4974. App.stopRun();
  4975. App.renderScreen();
  4976. });
  4977. }
  4978. });
  4979. }
  4980. /** ③ 退出应用:H6 二次确认(§10.7:停止脚本 + 关闭悬浮球 + 退出到桌面)。 */
  4981. function askExit() {
  4982. confirmThenAct({
  4983. dialog: function () {
  4984. Dialog.exitApp(function () {
  4985. if (Session.running) { App.stopRun(); }
  4986. FB.destroy();
  4987. App.exit();
  4988. });
  4989. }
  4990. });
  4991. }
  4992. /**
  4993. * 隐藏悬浮球(§10.3 第 3 项)。
  4994. *
  4995. * 规格书没写"隐藏之后怎么再显示"—— 没有恢复入口的话用户就再也找不回来了。
  4996. * 所以这里落盘 `ball.hidden=true`,并在「我的」页给一个开关把它打开(本项目补充)。
  4997. */
  4998. FB.hide = function () {
  4999. Store.set("ball.hidden", true);
  5000. FB.destroy();
  5001. toast("\u5DF2\u9690\u85CF\u60AC\u6D6E\u7403\uFF08\u53EF\u5728\u300C\u6211\u7684\u300D\u91CC\u91CD\u65B0\u6253\u5F00\uFF09");
  5002. };
  5003. /* ------------------------------------------------- hidden 开关的双向同步 */
  5004. /** 「我的」页的开关调它:hidden=true 时立刻关掉球。 */
  5005. FB.setHidden = function (hidden) {
  5006. Store.set("ball.hidden", !!hidden);
  5007. if (hidden) { FB.destroy(); } else { FB.ensure(); }
  5008. };
  5009. /* ============================== src/page-id.js ============================== */
  5010. /* ============================== src/page-id.js ==============================
  5011. * ID提取页(设计稿 3:1 / 16:23 / 16:319 / 16:706)。
  5012. *
  5013. * 结构:当前脚本提示 → 「脚本选择」(**单选**)→ 单选说明 → 4 张脚本卡
  5014. * → 滚动指引(指向下方功能设置)→ 「功能设置」→ 运行约束提示(仅运行时)。
  5015. *
  5016. * ⚠️ 本页的脚本卡是**单选**,不是多选。
  5017. * 设计稿写得很明确:「单选:同一时间仅可运行一个脚本」。
  5018. * 互斥逻辑放在页面里(Pages.toggleIdScript),因为它是这一页的业务规则,
  5019. * 放组件里会让别的页面被迫接受同一条规则。
  5020. */
  5021. var Pages = Pages || {};
  5022. /**
  5023. * 各页脚本清单的集中注册表。
  5024. * main.js 需要把「tab + scriptKey」翻译成界面上显示的中文名
  5025. * (运行状态条、拦截提示、导出配置的日志都要用它)。
  5026. * 让每个页面文件自己注册,比在 main.js 里再维护一份映射表更难漏。
  5027. */
  5028. Pages.SCRIPTS_OF = {};
  5029. Pages.registerScripts = function (tab, list) {
  5030. Pages.SCRIPTS_OF[tab] = list;
  5031. };
  5032. /* 四张脚本卡的文案与摘要。摘要只在折叠态显示,展开后由具体设置行替代。 */
  5033. Pages.ID_SCRIPTS = [
  5034. { key: "s1", name: "\u811A\u672C\u4E00 \u00B7 \u7C89\u4E1D\u9875\u63D0 ID" },
  5035. { key: "s2", name: "\u811A\u672C\u4E8C \u00B7 \u8BC4\u8BBA\u9875\u63D0 ID", summary: "\u8BC4\u8BBA\u88C2\u53D8" },
  5036. { key: "s3", name: "\u811A\u672C\u4E09 \u00B7 \u6587\u672C UID \u88C2\u53D8\u63D0 ID", summary: "\u6587\u672C UID" },
  5037. { key: "s4", name: "\u811A\u672C\u56DB \u00B7 \u5173\u952E\u8BCD\u89C6\u9891\u63D0 ID", summary: "\u5173\u952E\u8BCD\u89C6\u9891" }
  5038. ];
  5039. Pages.registerScripts("id", Pages.ID_SCRIPTS);
  5040. /** 取当前选中的脚本 key(未选返回空串)。 */
  5041. Pages.checkedIdScript = function () {
  5042. for (var i = 0; i < Pages.ID_SCRIPTS.length; i++) {
  5043. var key = Pages.ID_SCRIPTS[i].key;
  5044. if (Store.getBool("tabs.id.scripts." + key + ".checked")) { return key; }
  5045. }
  5046. return "";
  5047. };
  5048. /** 取某个脚本的显示名,用于「当前脚本」提示与运行状态条。 */
  5049. Pages.scriptName = function (tab, key) {
  5050. var list = Pages.SCRIPTS_OF[tab] || [];
  5051. for (var i = 0; i < list.length; i++) {
  5052. if (list[i].key === key) { return list[i].name; }
  5053. }
  5054. /* 找不到就回退成 key 本身:显示 "s2" 也比显示 "undefined" 强 */
  5055. return key;
  5056. };
  5057. /**
  5058. * 单选:选中 key 的同时把同组其它脚本清掉;on=false 则整组清空
  5059. * (回到设计稿 16:23 的「未选择脚本」状态)。
  5060. *
  5061. * 为什么要在页面里做而不是让组件做:单选是**本页的业务规则**(设计稿的
  5062. * 「同一时间仅可运行一个脚本」),而 UID私信 / 养号 / 视频功能三页是可以多选的。
  5063. * 把规则放进组件,别的页面就被迫一起变成单选。
  5064. */
  5065. Pages.toggleIdScript = function (key, on) {
  5066. for (var i = 0; i < Pages.ID_SCRIPTS.length; i++) {
  5067. var k = Pages.ID_SCRIPTS[i].key;
  5068. Store.set("tabs.id.scripts." + k + ".checked", on ? k === key : false);
  5069. }
  5070. };
  5071. /* ------------------------------------------------------------ 脚本卡 */
  5072. /**
  5073. * 「开关 + 标签 +(勾上才出现的一个控件)」。
  5074. *
  5075. * 四个脚本里反复出现这种形态(主播去重 / 使用文本 ID / AI 头像识别…),
  5076. * 抽出来是因为它有一个**必须一起做对**的细节:右侧控件只在开关打开时才 addView。
  5077. * 各写各的很容易漏掉「关掉后右侧还留着」,那会让用户以为没关掉。
  5078. *
  5079. * @param opts.path 开关绑定的配置路径
  5080. * @param opts.label 标签
  5081. * @param opts.rightOf (on) => View|null,返回开关打开时要显示在右侧的控件
  5082. * @param opts.rerender true → 切换后整页重建(右侧控件的出现/消失需要它)
  5083. */
  5084. function buildToggleRow(opts) {
  5085. var on = Store.getBool(opts.path);
  5086. var line = U.row();
  5087. var box = W.checkRow(opts.label, on, function (next) {
  5088. Store.set(opts.path, next);
  5089. if (opts.rerender) { Pages.requestRerender(); }
  5090. });
  5091. line.addView(box.root);
  5092. var right = on && opts.rightOf ? opts.rightOf(on) : null;
  5093. if (right) {
  5094. line.addView(U.flexible());
  5095. line.addView(U.hgap(8));
  5096. line.addView(right);
  5097. }
  5098. return line;
  5099. }
  5100. /**
  5101. * 脚本一 · 粉丝页提 ID(设计稿 3:244,2026-10-13 按新稿补齐两项)。
  5102. *
  5103. * 七项:自定关注或粉丝页面提 ID(✓)+ 自定页面提 ID(✗)→ 主播去重(开关 + 去重模式)
  5104. * → 进入页面 → 主播 ID → 使用文本 ID(开关 + 路径 + 选择)。
  5105. *
  5106. * 前两项是**取材入口**的开关,与「进入页面」下拉不是一回事:
  5107. * 前者决定"要不要走自定义入口",后者决定"进哪一页"。
  5108. * 稿子把它们排在主播去重之前,顺序照抄 —— 取材范围先于去重策略。
  5109. *
  5110. * 「主播去重」和「进入页面」在稿子里同属主播去重那一组的两个下拉,
  5111. * 这里仍拆成两行:一个开关只关自己的下拉,否则用户分不清
  5112. * 「去重方式 = ID 模式」到底属于哪个设置。
  5113. */
  5114. function buildIdScript1Detail() {
  5115. var base = "tabs.id.scripts.s1.";
  5116. var rows = [];
  5117. rows.push(W.checkRow("\u81EA\u5B9A\u5173\u6CE8\u6216\u7C89\u4E1D\u9875\u9762\u63D0 ID",
  5118. Store.getBool(base + "customFansPage"),
  5119. function (on) { Store.set(base + "customFansPage", on); }).root);
  5120. rows.push(W.checkRow("\u81EA\u5B9A\u9875\u9762\u63D0 ID",
  5121. Store.getBool(base + "customPage"),
  5122. function (on) { Store.set(base + "customPage", on); }).root);
  5123. rows.push(buildToggleRow({
  5124. path: base + "dedupeEnabled",
  5125. label: "\u4E3B\u64AD\u53BB\u91CD",
  5126. rerender: true,
  5127. rightOf: function () { return W.dropdown("dedupe", base + "dedupe").root; }
  5128. }));
  5129. rows.push(W.settingRow("\u8FDB\u5165\u9875\u9762",
  5130. W.dropdown("enterPage", base + "enterPage").root, 8));
  5131. /* 主播 ID:可填多个,用 # 隔开;留空表示不限定、从页面现场取 */
  5132. rows.push(W.settingRow("\u4E3B\u64AD ID",
  5133. W.inlineField({
  5134. path: base + "anchorIds",
  5135. placeholder: "ID1#ID2#ID3",
  5136. widthDp: 148
  5137. }).root, 8));
  5138. /* 使用文本 ID:勾上后从本地文本读,不再现场抽取。
  5139. * 这一行的右侧是「路径输入框 + 选择按钮」,比其它行多一个控件,
  5140. * 所以不用 buildToggleRow(它只接受一个右侧控件)。 */
  5141. var useBox = W.checkRow("\u4F7F\u7528\u6587\u672C ID",
  5142. Store.getBool(base + "useTextId"),
  5143. function (on) {
  5144. Store.set(base + "useTextId", on);
  5145. /* 勾上之后右侧要多出「选择文件」,只能重建这一页 */
  5146. Pages.requestRerender();
  5147. });
  5148. /* 路径本身就可在栏位里直接改(可以手输,不一定非走「选择文件」) */
  5149. var pathField = W.inlineField({
  5150. path: base + "textIdPath",
  5151. placeholder: "\u6587\u672C\u8DEF\u5F84",
  5152. widthDp: 110,
  5153. align: "right"
  5154. });
  5155. var useRow = U.row();
  5156. useRow.addView(useBox.root);
  5157. useRow.addView(U.flexible());
  5158. useRow.addView(pathField.root);
  5159. if (Store.getBool(base + "useTextId")) {
  5160. useRow.addView(U.hgap(6));
  5161. useRow.addView(W.filePickerButton(base + "textIdPath", pathField.paint).root);
  5162. }
  5163. rows.push(useRow);
  5164. var detail = U.column();
  5165. W.stack(detail, rows, 12);
  5166. return detail;
  5167. }
  5168. /** 脚本二 · 评论页提 ID(设计稿 16:483)。两项都是开关,都改变「去哪取材」。 */
  5169. function buildIdScript2Detail() {
  5170. var base = "tabs.id.scripts.s2.";
  5171. var rows = [
  5172. buildToggleRow({
  5173. path: base + "customCommentPage",
  5174. label: "\u81EA\u5B9A\u8BC4\u8BBA\u9875\u9762\u63D0 ID",
  5175. rerender: false
  5176. }),
  5177. buildToggleRow({
  5178. path: base + "commenterFans",
  5179. label: "\u63D0\u53D6\u8BC4\u8BBA\u7684\u4EBA\u7684\u7C89\u4E1D ID",
  5180. rerender: false
  5181. })
  5182. ];
  5183. var detail = U.column();
  5184. W.stack(detail, rows, 12);
  5185. return detail;
  5186. }
  5187. /**
  5188. * 脚本三 · 文本 UID 裂变提 ID(设计稿 16:483)。
  5189. *
  5190. * 「裂变文本」用的是 `W.filePicker` —— 设计稿那一行就是「标签 + 路径 + 选择」,
  5191. * 与 UID私信页的「随机私信内容」完全同形,没必要再写一个。
  5192. */
  5193. function buildIdScript3Detail() {
  5194. var base = "tabs.id.scripts.s3.";
  5195. var rows = [
  5196. buildToggleRow({
  5197. path: base + "uidFission",
  5198. label: "\u6587\u672C UID \u88C2\u53D8\u63D0 ID",
  5199. rerender: false
  5200. }),
  5201. buildToggleRow({
  5202. path: base + "uidDedupe",
  5203. label: "UID \u535A\u4E3B\u53BB\u91CD",
  5204. rerender: false
  5205. }),
  5206. W.settingRow("\u8FDB\u5165\u9875\u9762",
  5207. W.dropdown("enterPage", base + "enterPage").root, 8),
  5208. W.filePicker("\u88C2\u53D8\u6587\u672C", base + "fissionTextPath", "\u9009\u62E9").root
  5209. ];
  5210. var detail = U.column();
  5211. W.stack(detail, rows, 12);
  5212. return detail;
  5213. }
  5214. /**
  5215. * 脚本四 · 关键词视频提 ID(设计稿 16:589)。
  5216. *
  5217. * 一行关键词 + 四个开关。四个开关是「取材范围」的层层扩大:
  5218. * 视频作者 → 作者的粉丝 → 评论区的人 → 评论人的粉丝。
  5219. * 层层嵌套的措辞容易看糊,所以顺序按「从近到远」排,与设计稿一致。
  5220. */
  5221. function buildIdScript4Detail() {
  5222. var base = "tabs.id.scripts.s4.";
  5223. var rows = [
  5224. buildToggleRow({
  5225. path: base + "keywordEnabled",
  5226. label: "\u5173\u952E\u8BCD\u89C6\u9891\u63D0 ID",
  5227. rerender: false
  5228. }),
  5229. W.settingRow("\u5173\u952E\u8BCD\u4E3B\u64AD",
  5230. W.inlineField({
  5231. path: base + "keyword",
  5232. placeholder: "\u5173\u952E\u8BCD",
  5233. widthDp: 148
  5234. }).root, 8),
  5235. buildToggleRow({
  5236. path: base + "anchorFans",
  5237. label: "\u5173\u952E\u8BCD\u63D0\u53D6\u4E3B\u64AD\u7C89\u4E1D ID",
  5238. rerender: false
  5239. }),
  5240. buildToggleRow({
  5241. path: base + "commentArea",
  5242. label: "\u5173\u952E\u8BCD\u63D0\u53D6\u8BC4\u8BBA\u533A ID",
  5243. rerender: false
  5244. }),
  5245. buildToggleRow({
  5246. path: base + "commenterFans",
  5247. label: "\u5173\u952E\u8BCD\u63D0\u53D6\u8BC4\u8BBA\u7684\u4EBA\u7684\u7C89\u4E1D ID",
  5248. rerender: false
  5249. })
  5250. ];
  5251. var detail = U.column();
  5252. W.stack(detail, rows, 12);
  5253. return detail;
  5254. }
  5255. /**
  5256. * key → 展开后的设置区。
  5257. *
  5258. * 四个脚本做的事各不相同(粉丝页 / 评论页 / 文本 UID / 关键词视频),
  5259. * 设置项也就各不相同 —— 早先只有脚本一有内容,另外三张展开是空的,
  5260. * 用户点开折叠箭头会以为坏了。
  5261. */
  5262. Pages.ID_DETAILS = {
  5263. s1: buildIdScript1Detail,
  5264. s2: buildIdScript2Detail,
  5265. s3: buildIdScript3Detail,
  5266. s4: buildIdScript4Detail
  5267. };
  5268. /** 单张脚本卡。折叠态只出头部一行,展开态追加设置区。 */
  5269. function buildIdScriptCard(script) {
  5270. var base = "tabs.id.scripts." + script.key + ".";
  5271. var checked = Store.getBool(base + "checked");
  5272. var expanded = Store.getBool(base + "expanded");
  5273. var running = Session.running;
  5274. var card = W.card(checked);
  5275. card.addView(W.scriptHeader({
  5276. checked: checked,
  5277. expanded: expanded,
  5278. name: script.name,
  5279. mode: "radio",
  5280. /* 运行中不允许换脚本:设计稿 09 的底部提示就是这么写的,
  5281. * 而且真换了脚本,状态条上显示的名字就与正在跑的不是同一个了。 */
  5282. disabled: running,
  5283. /* 展开后摘要位置让给设置行,避免一行里挤三样东西 */
  5284. summary: expanded ? null : script.summary,
  5285. onToggleCheck: function (next) {
  5286. Pages.toggleIdScript(script.key, next);
  5287. Pages.requestRerender();
  5288. },
  5289. onToggleExpand: function () {
  5290. Store.set(base + "expanded", !expanded);
  5291. Pages.requestRerender();
  5292. }
  5293. }).root);
  5294. if (expanded) {
  5295. var build = Pages.ID_DETAILS[script.key];
  5296. if (build) {
  5297. card.addView(U.spacer(12));
  5298. card.addView(build());
  5299. }
  5300. }
  5301. return card;
  5302. }
  5303. /* ------------------------------------------------------------ 功能设置 */
  5304. /**
  5305. * 功能设置卡(设计稿 16:733)。
  5306. *
  5307. * 说明两处刻意的处理:
  5308. * · 「提取区域」的输入提示与两行说明都保留,因为国家简码的写法(# 分隔)
  5309. * 不看说明是猜不出来的;
  5310. * · 关注量 / 粉丝量 / 获赞量 / 视频量用 `W.rangeRow` ——
  5311. * 「最小值 ~ 最大值」一个区间占一行,但**两个数各自一个胶囊、各自输入**。
  5312. * 这与 UID私信页的「关注上限 / 私信上限」并不冲突:那边是两个**各自独立成立**的量,
  5313. * 该分开;这里的 min/max 是**同一个量的下界与上界**,拆两行反而像两个不相干的设置。
  5314. * 一行放下也让这张卡短了一半,四个门槛不用滚很久才看完。
  5315. */
  5316. function buildIdFeatures() {
  5317. var card = W.card(false);
  5318. var base = "tabs.id.features.";
  5319. var rows = [];
  5320. /* —— 过滤账户:设计稿里是两个勾选框并排 —— */
  5321. var privateRow = W.checkRow("\u8FC7\u6EE4\u79C1\u5BC6\u8D26\u6237",
  5322. Store.getBool(base + "filterPrivate"),
  5323. function (on) { Store.set(base + "filterPrivate", on); });
  5324. var noAvatarRow = W.checkRow("\u8FC7\u6EE4\u65E0\u5934\u50CF\u8D26\u6237",
  5325. Store.getBool(base + "filterNoAvatar"),
  5326. function (on) { Store.set(base + "filterNoAvatar", on); });
  5327. var filterLine = U.row();
  5328. U.params(privateRow.root, 0, -2, 1);
  5329. U.params(noAvatarRow.root, 0, -2, 1);
  5330. filterLine.addView(privateRow.root);
  5331. filterLine.addView(noAvatarRow.root);
  5332. rows.push(filterLine);
  5333. /* —— 提取区域:国家简码 + 条款按钮在同一行(设计稿 16:743) —— */
  5334. var regionNote = W.chip("\u542B\u5BF9\u5E94\u533A\u57DFID\u6761\u6B3E", "accent");
  5335. regionNote.root.setOnClickListener(function () {
  5336. Dialog.alert("\u533A\u57DF ID \u6761\u6B3E",
  5337. "\u52FE\u9009\u540E\uFF0C\u53EA\u63D0\u53D6\u7B26\u5408\u4E0A\u65B9\u56FD\u5BB6\u7B80\u7801\u7684\u8D26\u53F7\u3002\n\n" +
  5338. "\u56FD\u5BB6\u7B80\u7801\uFF1A\u5982\u65E5\u672C\u586B JP\uFF1B\u4E2D\u56FD\u53F0\u6E7E\uFF1ATW\uFF1B" +
  5339. "\u591A\u56FD\u7528 # \u9694\u5F00\u3002\n\u7559\u7A7A\u5219\u5339\u914D\u6240\u6709\u56FD\u5BB6\u3002");
  5340. });
  5341. var regionRow = U.row();
  5342. regionRow.addView(U.text("\u63D0\u53D6\u533A\u57DF", Theme.m.labelSize, Theme.hex.textSecondary, false));
  5343. regionRow.addView(U.flexible());
  5344. regionRow.addView(W.textField(base + "regionCodes", "\u56FD\u5BB6\u7B80\u7801", 108).root);
  5345. regionRow.addView(U.hgap(6));
  5346. regionRow.addView(regionNote.root);
  5347. rows.push(regionRow);
  5348. rows.push(W.hint("\u56FD\u5BB6\u7B80\u7801\uFF1A\u5982\u65E5\u672C\u586B JP\uFF1B\u4E2D\u56FD\u53F0\u6E7E\uFF1ATW\uFF0C\u591A\u56FD\u5219\u7528 # \u9694\u5F00", false));
  5349. rows.push(W.hint("\u5982\uFF1AJP#TW#CN#US\uFF0C\u4E0D\u586B\u5219\u5339\u914D\u6240\u6709\u56FD\u5BB6", false));
  5350. /* —— 语言 / 昵称检测:两组「标签 + 下拉」并排。
  5351. * 弹性占位放在**两组之间**,而不是每组各放一个 ——
  5352. * 后者会把标签和它自己的下拉框拆到两端,读起来不知道哪个配哪个。 */
  5353. var langRow = U.row();
  5354. langRow.addView(U.text("\u8BED\u8A00\u533A\u57DF", Theme.m.labelSize, Theme.hex.textSecondary, false));
  5355. langRow.addView(U.hgap(8));
  5356. langRow.addView(W.dropdown("langRegion", base + "langRegion").root);
  5357. langRow.addView(U.flexible());
  5358. langRow.addView(U.text("\u6635\u79F0\u4E2A\u7B7E\u68C0\u6D4B", Theme.m.labelSize, Theme.hex.textSecondary, false));
  5359. langRow.addView(U.hgap(8));
  5360. langRow.addView(W.dropdown("nicknameCheck", base + "nicknameCheck").root);
  5361. rows.push(langRow);
  5362. /* —— 云端去重:勾选后才出现表名与连通测试 —— */
  5363. var cloudBox = W.checkRow("\u4E91\u7AEF\u53BB\u91CD", Store.getBool(base + "cloudDedupe"),
  5364. function (on) {
  5365. Store.set(base + "cloudDedupe", on);
  5366. Pages.requestRerender();
  5367. });
  5368. if (Store.getBool(base + "cloudDedupe")) {
  5369. var cloudRow = U.row();
  5370. cloudRow.addView(cloudBox.root);
  5371. cloudRow.addView(U.flexible());
  5372. cloudRow.addView(W.textField(base + "cloudTable", "\u8868\u540D\uFF1A\u5B57\u7B26", 100).root);
  5373. cloudRow.addView(U.hgap(6));
  5374. var testBtn = W.chip("\u8FDE\u901A\u6D4B\u8BD5", "accent");
  5375. testBtn.root.setOnClickListener(function () {
  5376. var table = Store.getText(base + "cloudTable");
  5377. /* 如实报告:没有服务端地址时就说没配,不假装测试通过 */
  5378. Dialog.alert("\u8FDE\u901A\u6D4B\u8BD5",
  5379. table === "" ? "\u8BF7\u5148\u586B\u5199\u8868\u540D\u3002"
  5380. : "\u8868\u540D\uFF1A" + table + "\n\n\u5F53\u524D\u672A\u914D\u7F6E\u4E91\u7AEF\u53BB\u91CD\u670D\u52A1\u5730\u5740\uFF0C\u65E0\u6CD5\u771F\u5B9E\u6D4B\u8BD5\u3002");
  5381. });
  5382. cloudRow.addView(testBtn.root);
  5383. rows.push(cloudRow);
  5384. } else {
  5385. rows.push(cloudBox.root);
  5386. }
  5387. /* —— AI 头像识别:勾选后 AI 性别才可改 —— */
  5388. var aiRow = U.row();
  5389. var aiBox = W.checkRow("AI \u5934\u50CF\u8BC6\u522B", Store.getBool(base + "aiAvatar"),
  5390. function (on) {
  5391. Store.set(base + "aiAvatar", on);
  5392. Pages.requestRerender();
  5393. });
  5394. aiRow.addView(aiBox.root);
  5395. aiRow.addView(U.flexible());
  5396. if (Store.getBool(base + "aiAvatar")) {
  5397. aiRow.addView(U.text("AI\u6027\u522B", Theme.m.labelSize, Theme.hex.textSecondary, false));
  5398. aiRow.addView(U.hgap(8));
  5399. aiRow.addView(W.dropdown("aiGender", base + "aiGender").root);
  5400. }
  5401. rows.push(aiRow);
  5402. rows.push(W.settingRow("AI \u5E74\u9F84 >",
  5403. W.numberField(base + "aiAgeMax", "AI \u5E74\u9F84").root, 6));
  5404. /* —— 四组数量门槛:一行一个区间,两个数各自独立可改(设计稿 16:781) —— */
  5405. var metrics = [
  5406. { key: "follow", label: "\u5173\u6CE8\u91CF" },
  5407. { key: "fans", label: "\u7C89\u4E1D\u91CF" },
  5408. { key: "likes", label: "\u83B7\u8D5E\u91CF" },
  5409. { key: "videos", label: "\u89C6\u9891\u91CF" }
  5410. ];
  5411. for (var i = 0; i < metrics.length; i++) {
  5412. var m = metrics[i];
  5413. rows.push(W.rangeRow(base + m.key + "Min", base + m.key + "Max", m.label, "~").root);
  5414. }
  5415. /* —— 提取数量上限 —— */
  5416. var stopRow = U.row();
  5417. stopRow.addView(U.text("\u63D0\u53D6\u7B26\u5408", Theme.m.labelSize, Theme.hex.textSecondary, false));
  5418. stopRow.addView(U.flexible());
  5419. stopRow.addView(W.numberField(base + "extractLimit", "\u63D0\u53D6\u7B26\u5408").root);
  5420. stopRow.addView(U.hgap(6));
  5421. stopRow.addView(U.text("\u4E2A ID \u540E\u505C\u6B62", Theme.m.labelSize, Theme.hex.textMuted, false));
  5422. rows.push(stopRow);
  5423. W.stack(card, rows, 12);
  5424. return card;
  5425. }
  5426. /* ------------------------------------------------------------ 页面 */
  5427. /** 页面主体。rerender 由 main.js 注入,用于交互后重建当前页。 */
  5428. Pages.id = function (rerender) {
  5429. var inner = U.column();
  5430. var blocks = [];
  5431. /* 「当前脚本」提示(设计稿 16:587):只在选了脚本之后出现。
  5432. * 放在最上面是因为它会随滚动被推走,不会长期占地方。 */
  5433. var current = Pages.checkedIdScript();
  5434. if (current) {
  5435. var tip = U.column();
  5436. tip.setBackground(U.solidBg(U.alphaOf("#FFFFFF", Theme.alpha.chipFill), 10));
  5437. tip.setPadding(U.dp(8), U.dp(8), U.dp(8), U.dp(8));
  5438. var tipText = U.text("\u5F53\u524D\u811A\u672C\uFF1A" + Pages.scriptName("id", current),
  5439. 11, Theme.hex.textMuted, false);
  5440. tipText.setGravity(android.view.Gravity.CENTER);
  5441. tip.addView(tipText);
  5442. blocks.push(tip);
  5443. }
  5444. blocks.push(W.sectionTitle("\u811A\u672C\u9009\u62E9"));
  5445. /* 单选说明。放在标题下面而不是塞进卡片:它约束的是「整组」,
  5446. * 写进某一张卡会让人以为只对那张卡生效。 */
  5447. var rule = U.text("\u5355\u9009\uFF1A\u540C\u4E00\u65F6\u95F4\u4EC5\u53EF\u8FD0\u884C\u4E00\u4E2A\u811A\u672C",
  5448. 11, Theme.hex.textFaint, false);
  5449. blocks.push(rule);
  5450. for (var i = 0; i < Pages.ID_SCRIPTS.length; i++) {
  5451. blocks.push(buildIdScriptCard(Pages.ID_SCRIPTS[i]));
  5452. }
  5453. /* ★ 滚动指引放在**脚本四之后、功能设置之前**(2026-10-13 用户指定位置)。
  5454. * 它指向的就是紧接在下面的那一屏内容,贴着被指向的东西才读得懂;
  5455. * 原先放在页尾时它是自相矛盾的:滚到功能设置的人已经不需要它,
  5456. * 而真正需要它的(还没往下滚的人)恰恰也看不到它 —— 它自己也被折在屏幕外。 */
  5457. blocks.push(W.hint("\u2193 \u4E0B\u65B9\u8FD8\u6709\u300C\u529F\u80FD\u8BBE\u7F6E\u300D\uFF0C\u5411\u4E0B\u6EDA\u52A8\u67E5\u770B", true));
  5458. blocks.push(U.spacer(4));
  5459. blocks.push(W.sectionTitle("\u529F\u80FD\u8BBE\u7F6E"));
  5460. blocks.push(buildIdFeatures());
  5461. blocks.push(U.spacer(8));
  5462. /* 运行中的约束提示留在页尾:它约束的是**整页**(不只是脚本卡那一组),
  5463. * 而且它只在运行中出现 —— 与上面的滚动指引不会同时抢同一个位置。 */
  5464. if (Session.running) {
  5465. blocks.push(W.hint("\u8FD0\u884C\u4E2D\u4E0D\u53EF\u5207\u6362\u811A\u672C \u00B7 \u8BF7\u4FDD\u6301\u65E0\u969C\u788D\u6743\u9650\u5F00\u542F", true));
  5466. }
  5467. W.stack(inner, blocks, Theme.m.gapPage);
  5468. var scroller = W.scroller();
  5469. scroller.addView(inner);
  5470. return scroller;
  5471. };
  5472. /* ============================== src/page-dm.js ============================== */
  5473. /* ============================== src/page-dm.js ==============================
  5474. * UID私信页(设计稿 3:534 与展开态 7:11)。
  5475. *
  5476. * 结构:脚本选择(2 张卡)→ 功能设置 → 私信 / 评论设置。
  5477. *
  5478. * ⚠️ 设计稿两版的功能设置卡内容不一致:
  5479. * 3:579(折叠态页面):AI 性别 / 关注上限 / 私信上限 / 休息间隔
  5480. * 7:79 (展开态页面):进行关注私信 / 关注上限 / 私信上限 / 去重方式
  5481. * 逐版核对过:**「关注上限」与「私信上限」两版都是分开的两行**,
  5482. * 差异只在「有没有那个进行关注/私信的合并开关」。这里两张卡的设置都保留。
  5483. */
  5484. var Pages = Pages || {};
  5485. Pages.DM_SCRIPTS = [
  5486. { key: "s1", name: "脚本一 \u00B7 已提 UID 私信" },
  5487. { key: "s2", name: "脚本二 \u00B7 自己粉丝私信" }
  5488. ];
  5489. Pages.registerScripts("dm", Pages.DM_SCRIPTS);
  5490. /** 脚本一展开后的四行:按什么条件筛、筛完做什么、从哪儿读 UID。 */
  5491. function buildDmScript1Detail(rerender) {
  5492. var base = "tabs.dm.scripts.s1.";
  5493. var detail = U.column();
  5494. var rows = [];
  5495. /* UID 私信:勾选后对筛出的 UID 逐个发私信 */
  5496. var doDmBox = W.checkbox(Store.get(base + "doDm", true), 16);
  5497. doDmBox.root.setOnClickListener(function () {
  5498. var next = !doDmBox.isChecked();
  5499. doDmBox.set(next);
  5500. Store.set(base + "doDm", next);
  5501. });
  5502. var doDmLabel = U.text("UID \u79C1\u4FE1", Theme.m.labelSize, Theme.hex.textSecondary, false);
  5503. var doDmRow = U.row();
  5504. doDmRow.addView(doDmBox.root);
  5505. doDmRow.addView(U.hgap(8));
  5506. doDmRow.addView(doDmLabel);
  5507. doDmRow.addView(U.flexible());
  5508. rows.push(doDmRow);
  5509. rows.push(W.settingRow("\u7B5B\u9996\u89C6\u9891\u65E5\u671F",
  5510. W.dropdown("filterDate", base + "filterDate").root, 8));
  5511. rows.push(W.settingRow("\u9996\u89C6\u9891\u70B9\u8D5E / \u9996\u89C6\u9891\u8BC4\u8BBA",
  5512. W.dropdown("firstAction", base + "firstAction").root, 6));
  5513. rows.push(W.filePicker("\u5DF2\u7B5B\u7B26\u5408\u7684 UID \u6587\u672C", base + "uidTextPath", "\u9009\u62E9").root);
  5514. W.stack(detail, rows, 12);
  5515. return detail;
  5516. }
  5517. function buildDmScriptCard(script, rerender) {
  5518. var base = "tabs.dm.scripts." + script.key + ".";
  5519. var checked = Store.getBool(base + "checked");
  5520. var expanded = Store.getBool(base + "expanded");
  5521. var card = W.card(checked);
  5522. card.addView(W.scriptHeader({
  5523. checked: checked,
  5524. expanded: expanded,
  5525. name: script.name,
  5526. summary: null,
  5527. disabled: Session.running,
  5528. onToggleCheck: function (next) {
  5529. Store.set(base + "checked", next);
  5530. rerender();
  5531. },
  5532. onToggleExpand: function () {
  5533. Store.set(base + "expanded", !expanded);
  5534. rerender();
  5535. }
  5536. }).root);
  5537. if (expanded && script.key === "s1") {
  5538. card.addView(U.spacer(12));
  5539. card.addView(buildDmScript1Detail(rerender));
  5540. }
  5541. return card;
  5542. }
  5543. /**
  5544. * 功能设置卡(设计稿 3:579 折叠态 / 7:79 展开态)。
  5545. *
  5546. * 三处刻意保留的写法:
  5547. * · **进行关注 / 进行私信是两个独立开关**(稿子 7:79 把它们合成一个)。
  5548. * 合成一个的话,想单独关掉私信就得连关注一起关。
  5549. * · **数量用区间**(关注数量 / 私信数量 / 休息时间):一个量的下界与上界,
  5550. * 一行两项、两个数各自可改。
  5551. * · **去重方式只出现在展开态卡片上**,这里一并保留(两版稿的并集)。
  5552. */
  5553. function buildDmSettings() {
  5554. var card = W.card(false);
  5555. var rows = [
  5556. W.settingRow("AI 性别",
  5557. W.dropdown("aiGender", "tabs.dm.aiGender").root, 6),
  5558. /* ——— 关注 ——— */
  5559. W.settingRow("进行关注",
  5560. W.toggle(Store.getBool("tabs.dm.doFollow"), function (on) {
  5561. Store.set("tabs.dm.doFollow", on);
  5562. }).root, 6),
  5563. W.rangeRow("tabs.dm.followMin", "tabs.dm.followMax", "关注数量", "~").root,
  5564. /* ——— 私信 ——— */
  5565. W.settingRow("进行私信",
  5566. W.toggle(Store.getBool("tabs.dm.doDm"), function (on) {
  5567. Store.set("tabs.dm.doDm", on);
  5568. }).root, 6),
  5569. W.rangeRow("tabs.dm.dmMin", "tabs.dm.dmMax", "私信数量", "~").root,
  5570. /* ——— 休息时间:一个区间的两个端点 ——— */
  5571. W.rangeRow("tabs.dm.restMin", "tabs.dm.restMax", "休息时间(秒)", "~").root,
  5572. W.settingRow("去重方式",
  5573. W.dropdown("dedupeMode", "tabs.dm.dedupeMode").root, 6)
  5574. ];
  5575. W.stack(card, rows, 12);
  5576. return card;
  5577. }
  5578. /** 私信 / 评论文本设置卡。 */
  5579. function buildDmTexts() {
  5580. var card = W.card(false);
  5581. var rows = [
  5582. W.filePicker("\u968F\u673A\u79C1\u4FE1\u6587\u672C", "tabs.dm.dmTextPath").root,
  5583. W.filePicker("\u968F\u673A\u8BC4\u8BBA\u6587\u672C", "tabs.dm.commentTextPath").root
  5584. ];
  5585. W.stack(card, rows, 12);
  5586. return card;
  5587. }
  5588. Pages.dm = function (rerender) {
  5589. var inner = U.column();
  5590. var blocks = [];
  5591. blocks.push(W.sectionTitle("\u811A\u672C\u9009\u62E9"));
  5592. for (var i = 0; i < Pages.DM_SCRIPTS.length; i++) {
  5593. blocks.push(buildDmScriptCard(Pages.DM_SCRIPTS[i], rerender));
  5594. }
  5595. blocks.push(U.spacer(4));
  5596. blocks.push(W.sectionTitle("\u529F\u80FD\u8BBE\u7F6E"));
  5597. blocks.push(buildDmSettings());
  5598. blocks.push(U.spacer(4));
  5599. blocks.push(W.sectionTitle("\u79C1\u4FE1 / \u8BC4\u8BBA\u8BBE\u7F6E"));
  5600. blocks.push(buildDmTexts());
  5601. /* 本页内容会超出屏幕,末尾留一点余量,避免最后一张卡贴着滚动边界 */
  5602. blocks.push(U.spacer(8));
  5603. W.stack(inner, blocks, Theme.m.gapCard);
  5604. var scroller = W.scroller();
  5605. scroller.addView(inner);
  5606. return scroller;
  5607. };
  5608. /* ============================== src/page-nurture.js ======================
  5609. * 养号页(设计稿 3:469)。
  5610. *
  5611. * 结构:脚本选择(1 张卡)→ 养号参数卡 → 底部提示(左对齐)。
  5612. *
  5613. * 概率仍是固定档位(下拉),浏览时长与单次操作则是区间:
  5614. * 养号动作的强度直接决定账号安全,概率给几个安全档位比让用户随手填 90% 更负责;
  5615. * 而时长与次数天然有「最少/最多」两个端点,用区间表达最贴切。
  5616. */
  5617. Pages.NURTURE_SCRIPTS = [
  5618. { key: "s1", name: "\u968F\u673A\u64CD\u4F5C\u517B\u53F7" }
  5619. ];
  5620. Pages.registerScripts("nurture", Pages.NURTURE_SCRIPTS);
  5621. /** 养号参数卡(设计稿 16:2)。 */
  5622. function buildNurtureParams() {
  5623. var card = W.card(false);
  5624. var base = "tabs.nurture.";
  5625. var rows = [
  5626. W.rangeRow(base + "browseMin", base + "browseMax", "\u6D4F\u89C8\u65F6\u957F\uFF08\u5206\u949F\uFF09", "~").root,
  5627. W.settingRow("\u70B9\u8D5E\u6982\u7387",
  5628. W.dropdown("likeRate", base + "likeRate").root, 8),
  5629. W.settingRow("\u8BC4\u8BBA\u6982\u7387",
  5630. W.dropdown("commentRate", base + "commentRate").root, 8),
  5631. W.settingRow("\u5173\u6CE8\u6982\u7387",
  5632. W.dropdown("followRate", base + "followRate").root, 8),
  5633. W.rangeRow(base + "actionMin", base + "actionMax", "\u5355\u6B21\u64CD\u4F5C", "~").root
  5634. ];
  5635. W.stack(card, rows, 12);
  5636. return card;
  5637. }
  5638. Pages.nurture = function (rerender) {
  5639. var inner = U.column();
  5640. var blocks = [];
  5641. blocks.push(W.sectionTitle("\u811A\u672C\u9009\u62E9"));
  5642. for (var i = 0; i < Pages.NURTURE_SCRIPTS.length; i++) {
  5643. var script = Pages.NURTURE_SCRIPTS[i];
  5644. var base = "tabs.nurture.scripts." + script.key + ".";
  5645. var checked = Store.getBool(base + "checked");
  5646. var expanded = Store.getBool(base + "expanded");
  5647. var card = W.card(checked);
  5648. card.addView(W.scriptHeader({
  5649. checked: checked,
  5650. expanded: expanded,
  5651. name: script.name,
  5652. summary: null,
  5653. disabled: Session.running,
  5654. onToggleCheck: function (next) {
  5655. Store.set(base + "checked", next);
  5656. rerender();
  5657. },
  5658. onToggleExpand: function () {
  5659. Store.set(base + "expanded", !expanded);
  5660. rerender();
  5661. }
  5662. }).root);
  5663. blocks.push(card);
  5664. }
  5665. blocks.push(U.spacer(4));
  5666. blocks.push(W.sectionTitle("\u517B\u53F7\u53C2\u6570"));
  5667. blocks.push(buildNurtureParams());
  5668. blocks.push(U.spacer(8));
  5669. /* 设计稿 3:506:这行是左对齐的,不像其它页居中 */
  5670. blocks.push(W.hint("\u968F\u673A\u6D4F\u89C8\u3001\u70B9\u8D5E\u3001\u8BC4\u8BBA\u6A21\u62DF\u771F\u5B9E\u6D3B\u8DC3\u5EA6", false));
  5671. W.stack(inner, blocks, Theme.m.gapPage);
  5672. var scroller = W.scroller();
  5673. scroller.addView(inner);
  5674. return scroller;
  5675. };
  5676. /* ============================== src/page-video.js ============================== */
  5677. /* ============================== src/page-video.js ==============================
  5678. * 视频功能页(设计稿 3:629)。
  5679. *
  5680. * 结构:脚本选择(1 张卡)→ 功能设置 → 私信 / 评论设置。
  5681. *
  5682. * 与 UID私信页的差别在「执行操作」这一行:它是一行多选(关注/私信/点赞/评论),
  5683. * 所以用的是 W.toggleRow 而不是 W.toggle。
  5684. */
  5685. var Pages = Pages || {};
  5686. /**
  5687. * 视频功能页的脚本。
  5688. *
  5689. * `engine` 指名由哪个执行引擎驱动 —— 目前只有这一页接了 tiktok-auto。
  5690. * 其它页的脚本没有这个字段,点「开始运行」会被拦下并说明原因
  5691. * (见 App.toggleRun 与 Dialog.engineMissing),而不是静默跑一个不相关的东西。
  5692. */
  5693. Pages.VIDEO_SCRIPTS = [
  5694. { key: "s1", name: "\u5173\u952E\u8BCD\u89C6\u9891\u529F\u80FD", engine: "tiktok-auto" }
  5695. ];
  5696. Pages.registerScripts("video", Pages.VIDEO_SCRIPTS);
  5697. /** 「执行操作」四项的定义。顺序即设计稿从左到右的顺序。 */
  5698. Pages.VIDEO_ACTIONS = [
  5699. { key: "follow", label: "\u5173\u6CE8" },
  5700. { key: "dm", label: "\u79C1\u4FE1" },
  5701. { key: "like", label: "\u70B9\u8D5E" },
  5702. { key: "comment", label: "\u8BC4\u8BBA" }
  5703. ];
  5704. function buildVideoScriptCard(rerender) {
  5705. var base = "tabs.video.scripts.s1.";
  5706. var checked = Store.getBool(base + "checked");
  5707. var expanded = Store.getBool(base + "expanded");
  5708. var card = W.card(checked);
  5709. card.addView(W.scriptHeader({
  5710. checked: checked,
  5711. expanded: expanded,
  5712. name: Pages.VIDEO_SCRIPTS[0].name,
  5713. summary: null,
  5714. disabled: Session.running,
  5715. onToggleCheck: function (next) {
  5716. Store.set(base + "checked", next);
  5717. rerender();
  5718. },
  5719. onToggleExpand: function () {
  5720. Store.set(base + "expanded", !expanded);
  5721. rerender();
  5722. }
  5723. }).root);
  5724. return card;
  5725. }
  5726. /** 功能设置卡(设计稿 3:667):搜索条件 + 节奏 + 动作开关。 */
  5727. function buildVideoSettings() {
  5728. var card = W.card(false);
  5729. var rows = [];
  5730. rows.push(W.settingRow("\u5173\u952E\u8BCD",
  5731. W.textField("tabs.video.keyword", "\u641C\u7D22", 100).root, 6));
  5732. rows.push(W.settingRow("\u4E3B\u64AD\u533A\u57DF",
  5733. W.textField("tabs.video.region", "\u56FD\u5BB6\u7B80\u7801", 100).root, 6));
  5734. /* 停止个数与间隔时间都是区间:一行两项,两个数各自可改 */
  5735. rows.push(W.rangeRow("tabs.video.stopMin", "tabs.video.stopMax",
  5736. "\u505C\u6B62\u4E2A\u6570", "~").root);
  5737. rows.push(W.rangeRow("tabs.video.intervalMin", "tabs.video.intervalMax",
  5738. "\u95F4\u9694\u65F6\u95F4\uFF08\u79D2\uFF09", "~").root);
  5739. /* 动作开关:四个独立,改了立即落盘 */
  5740. var actionItems = [];
  5741. for (var i = 0; i < Pages.VIDEO_ACTIONS.length; i++) {
  5742. var action = Pages.VIDEO_ACTIONS[i];
  5743. actionItems.push({
  5744. key: action.key,
  5745. label: action.label,
  5746. on: Store.getBool("tabs.video.actions." + action.key)
  5747. });
  5748. }
  5749. rows.push(W.settingRow("\u6267\u884C\u64CD\u4F5C",
  5750. W.toggleRow(actionItems, function (key, on) {
  5751. Store.set("tabs.video.actions." + key, on);
  5752. }).root, 6));
  5753. W.stack(card, rows, 12);
  5754. return card;
  5755. }
  5756. /** 文本设置卡:私信与评论内容文件。 */
  5757. function buildVideoTexts() {
  5758. var card = W.card(false);
  5759. var rows = [
  5760. W.filePicker("随机私信内容 1-3", "tabs.video.dmTextPath").root,
  5761. W.filePicker("随机评论内容 1-3", "tabs.video.commentTextPath").root
  5762. ];
  5763. W.stack(card, rows, 12);
  5764. return card;
  5765. }
  5766. Pages.video = function (rerender) {
  5767. var inner = U.column();
  5768. var blocks = [];
  5769. blocks.push(W.sectionTitle("\u811A\u672C\u9009\u62E9"));
  5770. blocks.push(buildVideoScriptCard(rerender));
  5771. blocks.push(U.spacer(4));
  5772. blocks.push(W.sectionTitle("\u529F\u80FD\u8BBE\u7F6E"));
  5773. blocks.push(buildVideoSettings());
  5774. blocks.push(U.spacer(4));
  5775. blocks.push(W.sectionTitle("\u79C1\u4FE1 / \u8BC4\u8BBA\u8BBE\u7F6E"));
  5776. blocks.push(buildVideoTexts());
  5777. /* 内容超出屏幕,末尾留余量避免最后一张卡贴着滚动边界 */
  5778. blocks.push(U.spacer(8));
  5779. W.stack(inner, blocks, Theme.m.gapCard);
  5780. var scroller = W.scroller();
  5781. scroller.addView(inner);
  5782. return scroller;
  5783. };
  5784. /* ============================== src/page-tasks.js ============================== */
  5785. /* ============================== src/page-tasks.js ==============================
  5786. * 任务列表(规格书 B1 / B2,设计稿 30:102 / 30:203)。
  5787. *
  5788. * ## 这一页是整个改版的核心
  5789. * 底栏从「5 个页签」变成「3 个 tab」之后,原来的 ID提取 / UID私信 / 养号 / 视频功能
  5790. * **降级为这里的四张任务卡**,各自的页面成了二级页(见 Pages.DETAILS)。
  5791. * 所以这一页不只是"多了一个列表":它是新的入口层级。
  5792. *
  5793. * ## 任务清单与「脚本清单」是两张表
  5794. * · `Pages.TASKS` —— 四张卡(名称 / 图标 / 颜色),**顺序即设计稿顺序**;
  5795. * · `Pages.SCRIPTS_OF`—— 每个任务下的脚本(各页自己注册,见 page-id/page-dm/page-video)。
  5796. * 任务卡要显示「当前:xxx」,就得把两张表拼起来 —— 拼接逻辑集中在
  5797. * `Pages.checkedScript()`,`App.currentTask()` 也复用它,避免两处各判一遍单选。
  5798. */
  5799. var Pages = Pages || {};
  5800. /**
  5801. * 四个任务。key 与旧的页签 key、以及配置路径 `tabs.<key>.*` **完全一致** ——
  5802. * 这不是巧合:老用户的配置一条都不用搬家(见 Store.MIGRATIONS[5])。
  5803. *
  5804. * 颜色取设计稿任务卡图标框的衬底色(14% 透明底 + 同色字形):
  5805. * ID提取 #2EA6FF / UID私信 #9482FF / 养号 #12DEC4 / 视频功能 #F5C24A
  5806. * 图标一律用 U+25xx 几何块字符(彩色 emoji 的坑见 W.TABS 注释)。
  5807. */
  5808. Pages.TASKS = [
  5809. { key: "id", name: "ID\u63D0\u53D6", icon: "\u25A4", colorHex: Theme.hex.accent },
  5810. { key: "dm", name: "UID\u79C1\u4FE1", icon: "\u25A5", colorHex: Theme.hex.violet },
  5811. { key: "nurture", name: "\u517B\u53F7", icon: "\u25D4", colorHex: Theme.hex.ok },
  5812. { key: "video", name: "\u89C6\u9891\u529F\u80FD", icon: "\u25B6", colorHex: Theme.hex.warn }
  5813. ];
  5814. /** 二级页的标题(返回头上显示的就是它)。 */
  5815. Pages.DETAIL_TITLES = {
  5816. id: "ID\u63D0\u53D6",
  5817. dm: "UID\u79C1\u4FE1",
  5818. nurture: "\u517B\u53F7",
  5819. video: "\u89C6\u9891\u529F\u80FD"
  5820. };
  5821. Pages.taskOf = function (key) {
  5822. for (var i = 0; i < Pages.TASKS.length; i++) {
  5823. if (Pages.TASKS[i].key === key) { return Pages.TASKS[i]; }
  5824. }
  5825. return null;
  5826. };
  5827. Pages.taskName = function (key) {
  5828. var task = Pages.taskOf(key);
  5829. return task ? task.name : String(key || "");
  5830. };
  5831. /**
  5832. * 某个任务**当前选中的脚本**(没选返回 null)。
  5833. *
  5834. * 单选规则由各页自己保证(ID提取页是 `Pages.toggleIdScript`),
  5835. * 这里只做"第一个勾上的就是它"的读取 —— 与 `App.currentTask()` 的旧逻辑一致,
  5836. * 但接受任意任务 key,而不是只认当前 tab。
  5837. */
  5838. Pages.checkedScript = function (taskKey) {
  5839. var list = Pages.SCRIPTS_OF[taskKey] || [];
  5840. for (var i = 0; i < list.length; i++) {
  5841. var entry = Store.getBool("tabs." + taskKey + ".scripts." + list[i].key + ".checked");
  5842. if (entry) {
  5843. return {
  5844. key: list[i].key,
  5845. name: list[i].name,
  5846. engine: list[i].engine || "",
  5847. task: taskKey,
  5848. taskName: Pages.taskName(taskKey)
  5849. };
  5850. }
  5851. }
  5852. return null;
  5853. };
  5854. /** 该任务是否正在被驱动。运行态是全局单跑,所以只要 Session.tab 对上就是它。 */
  5855. Pages.taskIsRunning = function (taskKey) {
  5856. return !!Session.running && Session.tab === taskKey;
  5857. };
  5858. /**
  5859. * 任务卡的状态徽标(设计稿:空闲 灰 / 运行中 绿 / 未配置 琥珀)。
  5860. *
  5861. * 「未配置」= 这个任务当前没有任何脚本被选中。ID提取页默认一个都不选
  5862. * (设计稿 16:23 专门画了"未选择脚本"那一屏),所以它一进来就是琥珀色 ——
  5863. * 这正是要给用户的信号:想跑先选一个。
  5864. */
  5865. Pages.taskState = function (taskKey) {
  5866. if (Pages.taskIsRunning(taskKey)) {
  5867. return { badge: "\u8FD0\u884C\u4E2D", tone: "ok" };
  5868. }
  5869. if (!Pages.checkedScript(taskKey)) {
  5870. return { badge: "\u672A\u914D\u7F6E", tone: "warn" };
  5871. }
  5872. return { badge: "\u7A7A\u95F2", tone: "mute" };
  5873. };
  5874. /** 任务卡的「当前:xxx」一行。 */
  5875. Pages.taskScriptLine = function (taskKey) {
  5876. var script = Pages.checkedScript(taskKey);
  5877. var name = script ? script.name : "\u672A\u9009\u62E9\u811A\u672C";
  5878. return "\u5F53\u524D\uFF1A" + name;
  5879. };
  5880. /* ------------------------------------------------------------ 任务卡 */
  5881. function buildTaskCard(task) {
  5882. var state = Pages.taskState(task.key);
  5883. var running = Pages.taskIsRunning(task.key);
  5884. return W.taskCard({
  5885. name: task.name,
  5886. iconChar: task.icon,
  5887. colorHex: task.colorHex,
  5888. badge: state.badge,
  5889. badgeTone: state.tone,
  5890. scriptLine: Pages.taskScriptLine(task.key),
  5891. lastLine: Records.lastRunLine(task.key),
  5892. running: running,
  5893. /* 点卡片主体 → 进该任务的详情页 */
  5894. onOpen: function () {
  5895. App.openDetail(task.key);
  5896. },
  5897. /* 点「运行」→ 直接跑这个任务当前选中的脚本(不必先切页签再点底部按钮)。
  5898. * 运行中的那一个按钮是「停止」:它停的是**全局运行**,
  5899. * 点别的任务的「运行」也不会被禁用 —— 由 App.requestRun 弹 F3 确认(规格书 §2.3)。 */
  5900. onRun: function () {
  5901. if (running) {
  5902. App.stopRun();
  5903. App.renderScreen();
  5904. return;
  5905. }
  5906. App.requestRun(task.key);
  5907. }
  5908. }).root;
  5909. }
  5910. /* ------------------------------------------------------------ 页面 */
  5911. /** B1/B2:任务列表(运行态只是同一个页面的另一种状态)。
  5912. *
  5913. * ⚠️ 这里**不画**页面标题区:标题区由路由层统一加(App.renderContent 的
  5914. * 「一级 tab」分支),页面自己再加一次就会渲染出两遍。
  5915. * 页面只负责"这一 tab 的内容"。
  5916. */
  5917. Pages.tasks = function () {
  5918. var inner = U.column();
  5919. var blocks = [];
  5920. var section = U.column();
  5921. section.addView(W.sectionTitle("\u4EFB\u52A1"));
  5922. section.addView(U.spacer(4));
  5923. /* 区说明是刻意保留的:全局单跑是这一页最需要被知道的一条规则 ——
  5924. * 四张卡上都有「运行」按钮,不写清楚没人知道它们互斥。 */
  5925. section.addView(U.text("\u5168\u5C40\u5355\u8DD1\uFF1A\u540C\u4E00\u65F6\u95F4\u4EC5\u53EF\u8FD0\u884C\u4E00\u4E2A\u811A\u672C",
  5926. Theme.m.hintSize, Theme.hex.textDim, false));
  5927. blocks.push(section);
  5928. blocks.push(U.spacer(Theme.m.gapCard));
  5929. for (var i = 0; i < Pages.TASKS.length; i++) {
  5930. blocks.push(buildTaskCard(Pages.TASKS[i]));
  5931. if (i < Pages.TASKS.length - 1) { blocks.push(U.spacer(Theme.m.gapCard)); }
  5932. }
  5933. /* 末尾余量:最后一张卡贴着底栏时会被渐隐压住 */
  5934. blocks.push(U.spacer(8));
  5935. W.stack(inner, blocks, 0);
  5936. var scroller = W.scroller();
  5937. scroller.addView(inner);
  5938. return scroller;
  5939. };
  5940. /* ============================== src/page-records.js ============================== */
  5941. /* ============================== src/page-records.js ==============================
  5942. * 运行记录(规格书 D1/D2/D3,设计稿 30:871 / 30:958 / 30:1241)。
  5943. *
  5944. * D1 是「记录」tab 的一级页,D3 是它的二级页(点任一条进)。
  5945. * D2 是 D1 的空态 —— **空态是设计稿专门画的一屏**,不是"没数据就不显示":
  5946. * 一个空页面看起来像坏了,空态告诉用户"去哪能产生记录"。
  5947. *
  5948. * 记录本身由 `Records` 模块负责读写(见 src/records.js),
  5949. * 这一层只负责画:不猜字段、不编文案 —— 没有的东西就不渲染那一行。
  5950. */
  5951. var Pages = Pages || {};
  5952. /** 「脚本一 · 粉丝页提 ID」→「脚本一」。卡上只放得下短名,全名在详情页。 */
  5953. Pages.shortScriptName = function (name) {
  5954. var text = String(name || "");
  5955. var cut = text.indexOf(" \u00B7 ");
  5956. return cut > 0 ? text.substring(0, cut) : text;
  5957. };
  5958. /** 记录条目 / 详情页统一用「任务 · 脚本」当标题。 */
  5959. Pages.recordTitle = function (record) {
  5960. var task = record.taskName || Pages.taskName(record.taskKey);
  5961. var script = Pages.shortScriptName(record.scriptName);
  5962. return script === "" ? task : (task + " \u00B7 " + script);
  5963. };
  5964. /** 记录用的任务色/图标(与任务卡一致,列表里一眼能对上号)。 */
  5965. function recordVisual(record) {
  5966. var task = Pages.taskOf(record.taskKey);
  5967. return {
  5968. colorHex: task ? task.colorHex : Theme.hex.accent,
  5969. iconChar: task ? task.icon : "\u25A3"
  5970. };
  5971. }
  5972. /* ------------------------------------------------------------ 日志行 */
  5973. /**
  5974. * 日志行的配色(规格书:INFO 灰 / WARN 琥珀 / ERROR 玫瑰红)。
  5975. * STEP 归到 INFO:它是流程节点,不是异常。
  5976. */
  5977. function logColor(level) {
  5978. if (level === "WARN") { return Theme.hex.warn; }
  5979. if (level === "ERROR") { return Theme.hex.danger; }
  5980. return Theme.hex.textFaint;
  5981. }
  5982. function pad2(n) { return n < 10 ? "0" + n : String(n); }
  5983. /** 时间戳 → HH:MM:SS(日志行前缀,等宽显示才对齐)。 */
  5984. function logClock(ms) {
  5985. var d = new Date(ms || 0);
  5986. return pad2(d.getHours()) + ":" + pad2(d.getMinutes()) + ":" + pad2(d.getSeconds());
  5987. }
  5988. /**
  5989. * 一条日志行。
  5990. *
  5991. * 用**等宽字体**并且手动补空格对齐 level:设计稿里 `10:24:01 INFO 启动脚本`
  5992. * 的 level 是定宽的,用比例字体会让每一行的正文起点参差不齐。
  5993. */
  5994. function buildLogLine(entry) {
  5995. var raw = String(entry.level || "INFO").toUpperCase();
  5996. var level = raw;
  5997. while (level.length < 5) { level += " "; }
  5998. var text = logClock(entry.t) + " " + level + " " + String(entry.text || "");
  5999. var view = U.monoText(text, Theme.m.logSize, logColor(raw));
  6000. U.params(view, -1, -2, 0);
  6001. return view;
  6002. }
  6003. /* ------------------------------------------------------------ D1 记录列表 */
  6004. function buildRecordRow(record) {
  6005. var visual = recordVisual(record);
  6006. var result = Records.RESULT[record.result] || Records.RESULT.failed;
  6007. return W.recordRow({
  6008. title: Pages.recordTitle(record),
  6009. iconChar: visual.iconChar,
  6010. colorHex: visual.colorHex,
  6011. resultLabel: result.label,
  6012. resultTone: result.tone,
  6013. timeLine: Records.timeLine(record),
  6014. resultLine: Records.resultLine(record),
  6015. onOpen: function () { App.openRecord(record.id); }
  6016. });
  6017. }
  6018. /** D1:记录列表(有记录时)。 */
  6019. Pages.records = function () {
  6020. var inner = U.column();
  6021. var blocks = [];
  6022. var list = Records.list();
  6023. /* 标题区由路由层统一加(见 App.renderContent)——页面再加一次就会渲染两遍 */
  6024. var section = U.column();
  6025. section.addView(W.sectionTitle("\u8FD0\u884C\u8BB0\u5F55"));
  6026. section.addView(U.spacer(4));
  6027. /* 「共 N 条 · 点任一条查看执行日志」——条数是真实的,空列表走 D2 那一屏 */
  6028. section.addView(U.text("\u5171 " + list.length + " \u6761 \u00B7 \u70B9\u4EFB\u4E00\u6761\u67E5\u770B\u6267\u884C\u65E5\u5FD7",
  6029. Theme.m.hintSize, Theme.hex.textDim, false));
  6030. blocks.push(section);
  6031. blocks.push(U.spacer(Theme.m.gapCard));
  6032. for (var i = 0; i < list.length; i++) {
  6033. blocks.push(buildRecordRow(list[i]));
  6034. if (i < list.length - 1) { blocks.push(U.spacer(Theme.m.gapCard)); }
  6035. }
  6036. blocks.push(U.spacer(8));
  6037. W.stack(inner, blocks, 0);
  6038. var scroller = W.scroller();
  6039. scroller.addView(inner);
  6040. return scroller;
  6041. };
  6042. /** D2:记录空态。 */
  6043. Pages.recordsEmpty = function () {
  6044. var inner = U.column();
  6045. /* 标题区同样由路由层提供;空态只剩「区标题 + 空态块」 */
  6046. inner.addView(W.sectionTitle("\u8FD0\u884C\u8BB0\u5F55"));
  6047. inner.addView(W.emptyState("\u25F4",
  6048. "\u8FD8\u6CA1\u6709\u8FD0\u884C\u8BB0\u5F55",
  6049. "\u53BB\u300C\u4EFB\u52A1\u300D\u6807\u7B7E\u9009\u4E00\u4E2A\u811A\u672C\u8DD1\u8D77\u6765"));
  6050. return inner;
  6051. };
  6052. /* ------------------------------------------------------------ D3 运行详情 */
  6053. /**
  6054. * 结果摘要卡。
  6055. * 三行都是**有条件渲染**的:结果文案/结果文件只有引擎回传了才有,
  6056. * 没有就不画那一行 —— 空着的一行会让用户以为"数据丢了"。
  6057. */
  6058. function buildSummaryCard(record) {
  6059. var card = W.card(false);
  6060. var rows = [];
  6061. var result = Records.RESULT[record.result] || Records.RESULT.failed;
  6062. var titleRow = U.row();
  6063. var title = U.text(Pages.recordTitle(record), Theme.m.taskNameSize,
  6064. Theme.hex.textPrimary, true);
  6065. title.setSingleLine(true);
  6066. title.setEllipsize(android.text.TextUtils.TruncateAt.END);
  6067. U.params(title, 0, -2, 1);
  6068. titleRow.addView(title);
  6069. titleRow.addView(U.hgap(8));
  6070. titleRow.addView(W.badge(result.label, result.tone).root);
  6071. rows.push(titleRow);
  6072. rows.push(U.text(Records.timeLine(record), Theme.m.chipSize, Theme.hex.textFaint, false));
  6073. if (record.resultText) {
  6074. rows.push(U.text(record.resultText, Theme.m.chipSize, Theme.hex.textDim, false));
  6075. }
  6076. if (record.resultFile) {
  6077. var fileRow = U.row();
  6078. var path = U.text(record.resultFile, Theme.m.chipSize, Theme.hex.accentText, false);
  6079. path.setSingleLine(true);
  6080. path.setEllipsize(android.text.TextUtils.TruncateAt.MIDDLE);
  6081. U.params(path, 0, -2, 1);
  6082. fileRow.addView(path);
  6083. fileRow.addView(U.hgap(8));
  6084. fileRow.addView(W.smallButton("\u6253\u5F00", "accent", function () {
  6085. App.openResultFile(record.resultFile);
  6086. }).root);
  6087. fileRow.addView(U.hgap(6));
  6088. fileRow.addView(W.smallButton("\u5206\u4EAB", "default", function () {
  6089. App.shareResultFile(record.resultFile);
  6090. }).root);
  6091. rows.push(fileRow);
  6092. }
  6093. W.stack(card, rows, 10);
  6094. return card;
  6095. }
  6096. /** 日志卡:标题行(执行日志 + 复制)+ 日志行。 */
  6097. function buildLogCard(record) {
  6098. var card = W.card(false);
  6099. var logs = record.logs || [];
  6100. var head = U.row();
  6101. head.addView(U.text("\u6267\u884C\u65E5\u5FD7", Theme.m.labelSize, Theme.hex.textSecondary, true));
  6102. head.addView(U.flexible());
  6103. head.addView(W.smallButton("\u590D\u5236", "default", function () {
  6104. App.copyLogs(record);
  6105. }).root);
  6106. card.addView(head);
  6107. card.addView(U.spacer(10));
  6108. if (logs.length === 0) {
  6109. /* 如实说明,而不是画一张空卡片。日志来自引擎的 --log 通道(见 Engine.readLog),
  6110. * 没接引擎的任务本来就不会有日志。 */
  6111. card.addView(U.text("\u672C\u6B21\u8FD0\u884C\u6CA1\u6709\u56DE\u4F20\u65E5\u5FD7",
  6112. Theme.m.chipSize, Theme.hex.textDim, false));
  6113. return card;
  6114. }
  6115. for (var i = 0; i < logs.length; i++) {
  6116. if (i > 0) { card.addView(U.spacer(6)); }
  6117. card.addView(buildLogLine(logs[i]));
  6118. }
  6119. return card;
  6120. }
  6121. /**
  6122. * D3:运行详情。
  6123. * 记录被清理掉(超过 Records.MAX 或被手工清空)时,**不能白屏** ——
  6124. * 给一句"这条记录已不在本地"和一条回到列表的路。
  6125. */
  6126. Pages.recordDetail = function (record) {
  6127. var inner = U.column();
  6128. inner.addView(W.backHeader("\u8FD0\u884C\u8BE6\u60C5", function () { App.closeRecord(); }));
  6129. if (!record) {
  6130. inner.addView(U.spacer(Theme.m.gapPage));
  6131. inner.addView(W.emptyState("\u25F4",
  6132. "\u8FD9\u6761\u8BB0\u5F55\u5DF2\u4E0D\u5728\u672C\u5730",
  6133. "\u8BB0\u5F55\u4EC5\u4FDD\u7559\u6700\u8FD1 " + Records.MAX + " \u6761"));
  6134. var scroller0 = W.scroller();
  6135. scroller0.addView(inner);
  6136. return scroller0;
  6137. }
  6138. inner.addView(U.spacer(Theme.m.gapCard));
  6139. inner.addView(buildSummaryCard(record));
  6140. inner.addView(U.spacer(Theme.m.gapCard));
  6141. inner.addView(buildLogCard(record));
  6142. inner.addView(U.spacer(8));
  6143. var scroller = W.scroller();
  6144. scroller.addView(inner);
  6145. return scroller;
  6146. };
  6147. /* ============================== src/page-login.js ============================== */
  6148. /* ============================== src/page-login.js ==============================
  6149. * 卡密登录页(设计稿 16:257 登录 / 16:288 校验失败 / 16:1491 校验中)。
  6150. *
  6151. * 它不是某个页签,而是**未激活时的整个界面**:
  6152. * 没有页签栏、没有开始运行按钮,唯一的出口是登录成功或退出应用。
  6153. * 所以由 main.js 的 screen 路由决定显示它还是显示主界面。
  6154. *
  6155. * 三态共用一个构建函数,靠 Pages.LICENSE_VIEW.state 分叉:
  6156. * idle 输入框灰底、按钮「登录激活」、提示「卡密为一次性激活凭证…」
  6157. * verifying 按钮半透明 + 「登录中…」、提示换成「正在校验卡密,请稍候…」
  6158. * failed 输入框红边、下方红字「卡密无效或已过期,请检查后重试」
  6159. * 三态的差异全部落在「样式」上,结构完全一致 —— 拆成三个函数会立刻产生三份漂移。
  6160. *
  6161. * 输入框与页面上的其它输入框是**同一个组件**(`W.inlineField`),
  6162. * 并且**同样绑配置路径**(`license.draftCode`):
  6163. * 手打 16 位卡密时被系统回收进程,重开还能接着填。
  6164. * 提交成功后草稿会被清掉,`license.code` 成为唯一副本。
  6165. */
  6166. var Pages = Pages || {};
  6167. /** 登录页的界面状态。三态与失败文案只活在内存里:重启后回到 idle 是对的。 */
  6168. Pages.LICENSE_VIEW = {
  6169. state: "idle", /* idle | verifying | failed */
  6170. message: ""
  6171. };
  6172. Pages.LICENSE_RESET = function () {
  6173. Pages.LICENSE_VIEW.state = "idle";
  6174. Pages.LICENSE_VIEW.message = "";
  6175. };
  6176. /* ------------------------------------------------------------ 子块 */
  6177. /** 品牌区:渐变 Logo + 应用名 + 副标题。 */
  6178. function buildBrandBlock() {
  6179. var logoText = U.text("\u25B6", 26, "#FFFFFF", true);
  6180. logoText.setGravity(android.view.Gravity.CENTER);
  6181. var logo = new android.widget.FrameLayout(context);
  6182. logo.setBackground(U.gradientBg(Theme.hex.accent, Theme.hex.accentDeep, Theme.m.loginLogoRadius));
  6183. U.params(logo, Theme.m.loginLogoSize, Theme.m.loginLogoSize);
  6184. logo.addView(logoText);
  6185. var brand = U.column(false);
  6186. brand.addView(logo);
  6187. brand.addView(U.spacer(12));
  6188. brand.addView(U.text("\u98DE\u821F\u811A\u672C", 28, Theme.hex.textPrimary, true));
  6189. brand.addView(U.spacer(6));
  6190. brand.addView(U.text("TikTok \u81EA\u52A8\u5316\u811A\u672C \u00B7 \u8BF7\u767B\u5F55\u540E\u4F7F\u7528",
  6191. 12, Theme.hex.textMuted, false));
  6192. return brand;
  6193. }
  6194. /**
  6195. * 卡密输入框。失败态换成红边 + 红底,其余与常态一致。
  6196. *
  6197. * ★ 绑定 `license.draftCode`:**没提交的半截输入也要落盘**。
  6198. * 之前的做法是「只在点登录时读一次、不进配置」,代价是手打 16 位卡密时
  6199. * 被系统回收进程就得从头再打一遍。
  6200. */
  6201. function buildLicenseInput(state) {
  6202. var input = W.inlineField({
  6203. path: "license.draftCode",
  6204. placeholder: "\u8BF7\u8F93\u5165\u5361\u5BC6",
  6205. fill: true
  6206. });
  6207. /* 卡密是分组的大写字母数字串,等宽字更容易逐位核对 */
  6208. input.root.setTypeface(android.graphics.Typeface.MONOSPACE);
  6209. input.root.setTextSize(14);
  6210. if (state === "failed") {
  6211. input.root.setBackground(U.strokeBg(0.06, 0.45, Theme.hex.danger, 10));
  6212. } else {
  6213. input.root.setBackground(U.strokeBg(0.05, 0.12, "#FFFFFF", 10));
  6214. }
  6215. return input;
  6216. }
  6217. /**
  6218. * 登录 / 校验中 按钮。
  6219. * 校验中时把渐变整体压到 45% 透明度,并在文字前加一个「◌」——
  6220. * 不加这个视觉差,用户会以为可以连点。
  6221. */
  6222. function buildSubmitButton(state) {
  6223. var verifying = state === "verifying";
  6224. var label = verifying ? "\u25CC \u767B\u5F55\u4E2D\u2026" : "\u767B\u5F55\u6FC0\u6D3B";
  6225. var text = U.text(label, 16, "#FFFFFF", true);
  6226. text.setGravity(android.view.Gravity.CENTER);
  6227. var button = new android.widget.FrameLayout(context);
  6228. /* 不用 W.primaryButton:它没有「禁用态」这个概念,而这里必须有 */
  6229. var bg = U.gradientBg(Theme.hex.accent, Theme.hex.accentDeep, Theme.m.pillRadius);
  6230. if (verifying) { bg.setAlpha(Math.round(255 * 0.45)); }
  6231. button.setBackground(bg);
  6232. U.params(button, -1, Theme.m.loginBtnHeight);
  6233. button.addView(text);
  6234. if (!verifying) {
  6235. button.setOnClickListener(function () {
  6236. App.submitLicense(inputCode());
  6237. });
  6238. }
  6239. return { root: button, label: text };
  6240. }
  6241. /* 取输入框里的原始串。登录页只有一个输入框,用页面级引用比层层回传更省事。 */
  6242. var licenseInputRef = null;
  6243. function inputCode() {
  6244. return licenseInputRef ? licenseInputRef.value() : "";
  6245. }
  6246. /* ------------------------------------------------------------ 页面 */
  6247. /**
  6248. * 登录页主体。
  6249. * 不接收 rerender —— 三态切换由 App 统一走 renderScreen(),
  6250. * 页面自己重绘会绕开「切屏」这条唯一的路径。
  6251. */
  6252. Pages.login = function () {
  6253. var view = Pages.LICENSE_VIEW;
  6254. var state = view.state;
  6255. var inner = U.column();
  6256. inner.setPadding(U.dp(20), U.dp(28), U.dp(20), U.dp(20));
  6257. inner.addView(buildBrandBlock());
  6258. inner.addView(U.spacer(28));
  6259. /* —— 卡密输入卡 —— */
  6260. var card = U.column();
  6261. card.setBackground(U.strokeBg(0.04, 0.08, "#FFFFFF", Theme.m.cardRadius));
  6262. card.setPadding(U.dp(16), U.dp(16), U.dp(16), U.dp(16));
  6263. var input = buildLicenseInput(state);
  6264. licenseInputRef = input;
  6265. card.addView(input.root);
  6266. card.addView(U.spacer(14));
  6267. card.addView(buildSubmitButton(state).root);
  6268. card.addView(U.spacer(14));
  6269. /* 提示位在三态里说三件不同的事,共用同一行位置 */
  6270. if (state === "failed") {
  6271. var err = U.text(view.message || "\u5361\u5BC6\u65E0\u6548\u6216\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u68C0\u67E5\u540E\u91CD\u8BD5",
  6272. 11, Theme.hex.danger, false);
  6273. err.setGravity(android.view.Gravity.CENTER);
  6274. card.addView(err);
  6275. } else if (state === "verifying") {
  6276. var waiting = U.text("\u6B63\u5728\u6821\u9A8C\u5361\u5BC6\uFF0C\u8BF7\u7A0D\u5019\u2026",
  6277. 11, Theme.hex.accentText, false);
  6278. waiting.setGravity(android.view.Gravity.CENTER);
  6279. card.addView(waiting);
  6280. } else {
  6281. var safe = U.text("\u5361\u5BC6\u4E3A\u4E00\u6B21\u6027\u6FC0\u6D3B\u51ED\u8BC1\uFF0C\u8BF7\u52FF\u6CC4\u9732\u7ED9\u4ED6\u4EBA",
  6282. 11, Theme.hex.textFaint, false);
  6283. safe.setGravity(android.view.Gravity.CENTER);
  6284. card.addView(safe);
  6285. }
  6286. card.addView(U.spacer(12));
  6287. card.addView(W.link("\u8D2D\u4E70\u5361\u5BC6 \u00B7 \u8054\u7CFB\u5BA2\u670D",
  6288. Theme.hex.accentText, function () { App.showPurchaseInfo(); }));
  6289. card.addView(U.spacer(4));
  6290. card.addView(W.link("\u9000\u51FA\u5E94\u7528", Theme.hex.textMuted, function () { App.exit(); }));
  6291. inner.addView(card);
  6292. var scroller = W.scroller();
  6293. scroller.addView(inner);
  6294. return scroller;
  6295. };
  6296. /* ============================== src/page-mine.js ============================== */
  6297. /* ============================== src/page-mine.js ==============================
  6298. * 我的页(设计稿 7:289)。
  6299. *
  6300. * 结构:卡密 → 应用 → 关于 三段。
  6301. * 与其它页签的差别:本页没有「开始运行」按钮,底部只有页签栏。
  6302. *
  6303. * 数据来源都是真实的:
  6304. * · 卡密状态/到期时间 → Service.licenseStatus()
  6305. * · TikTok 安装与版本 → Device.tiktok()
  6306. * · 脚本版本 → Service.SCRIPT_VERSION(构建时注入)
  6307. * · 检查更新 → Service.checkUpdate()
  6308. */
  6309. var Pages = Pages || {};
  6310. /** 卡密登录卡:已登录给脱敏卡密 + 解绑;未登录/已过期给「去登录」。 */
  6311. function buildLicenseCard(rerender) {
  6312. var card = W.card(false);
  6313. var rows = [];
  6314. var display = Service.licenseDisplay();
  6315. rows.push(W.settingRow("\u5361\u5BC6\u72B6\u6001",
  6316. W.pill(display.text, display.tone).root, 6));
  6317. rows.push(W.settingRow("\u5361\u5BC6\u5230\u671F\u65F6\u95F4",
  6318. monoChip(display.expireAt, display.expireAt === "\u2014").root, 6));
  6319. if (display.status === "active") {
  6320. /* 已登录:脱敏展示,只露前 4 后 4 —— 卡片码不该完整出现在截图里 */
  6321. var masked = Service.formatCode(Service.normalizeCode(Service.licenseStatus().code));
  6322. if (masked.length > 9) {
  6323. masked = masked.substring(0, 4) + "-****-****-" + masked.substring(masked.length - 4);
  6324. }
  6325. var codeView = U.text(masked, Theme.m.chipSize, Theme.hex.textSecondary, false);
  6326. codeView.setSingleLine(true);
  6327. codeView.setBackground(U.strokeBg(Theme.alpha.chipFill, Theme.alpha.chipStroke, "#FFFFFF", Theme.m.chipRadius));
  6328. codeView.setPadding(U.dp(6), U.dp(6), U.dp(6), U.dp(6));
  6329. /* 「解绑」而不是「退出登录」:这个动作的后果比退出更重
  6330. * (正在跑的脚本会被停掉),文案要与弹窗里的说法一致。 */
  6331. var unbind = W.chip("\u89E3\u7ED1", "danger");
  6332. unbind.root.setOnClickListener(function () {
  6333. Dialog.confirmUnbind(function () {
  6334. /* 顺序有意义:先停运行,再清卡密,最后切回登录页。
  6335. * 反过来的话,停运行那一刻界面已经不在主界面上了。 */
  6336. App.stopRun();
  6337. Service.logout();
  6338. toast("\u5DF2\u89E3\u7ED1\u5361\u5BC6");
  6339. App.showLogin();
  6340. });
  6341. });
  6342. var row = U.row();
  6343. row.addView(codeView);
  6344. row.addView(U.flexible());
  6345. row.addView(U.hgap(6));
  6346. row.addView(unbind.root);
  6347. rows.push(row);
  6348. } else {
  6349. /* 未登录 / 已过期:主界面里没有可做的事,引导回登录页 */
  6350. var go = W.chip("\u53BB\u767B\u5F55", "accent");
  6351. go.root.setOnClickListener(function () { App.showLogin(); });
  6352. rows.push(W.settingRow("\u5361\u5BC6", go.root, 6));
  6353. }
  6354. W.stack(card, rows, 12);
  6355. return card;
  6356. }
  6357. /** 等宽小胶囊,用于日期 / 版本号这类需要对齐阅读的值。 */
  6358. function monoChip(value, muted) {
  6359. var view = U.monoText(value, Theme.m.chipSize,
  6360. muted ? Theme.hex.textFaint : Theme.hex.textSecondary);
  6361. view.setSingleLine(true);
  6362. view.setBackground(U.strokeBg(Theme.alpha.chipFill, Theme.alpha.chipStroke, "#FFFFFF", Theme.m.chipRadius));
  6363. view.setPadding(U.dp(6), U.dp(6), U.dp(6), U.dp(6));
  6364. return { root: view, setText: function (v) { view.setText(String(v)); } };
  6365. }
  6366. /**
  6367. * 悬浮球开关卡(规格书 §10 的补充项)。
  6368. *
  6369. * 规格书 §10.3 里有「隐藏悬浮球」,但**没写隐藏之后怎么再显示** ——
  6370. * 没有恢复入口的话,用户点一次隐藏就再也找不回这个跨 App 唯一的控制入口了。
  6371. * 所以这里补一个开关,并且它是**双向**的:
  6372. * · 关掉 → 立刻关球 + 落盘 `ball.hidden`(下次启动也不会再冒出来);
  6373. * · 打开 → 立刻 `FB.ensure()`(当场就能看到球出现在屏幕上)。
  6374. *
  6375. * 权限没给时会如实说明球建不出来 —— 规格书 §10.9① 写着悬浮窗权限
  6376. * "唯一实际用途"就是这个球,所以这里必须讲清楚,而不是让用户对着开关纳闷。
  6377. */
  6378. function buildFloatBallCard() {
  6379. var card = W.card(false);
  6380. var rows = [];
  6381. var allowed = Device.overlayEnabled();
  6382. var shown = !Store.getBool("ball.hidden") && allowed;
  6383. var toggle = W.toggle(shown, function (on) {
  6384. if (on && !Device.overlayEnabled()) {
  6385. /* 没权限就别落一个"开了但看不见"的状态:直接引导去授权 */
  6386. toast("\u8BF7\u5148\u5F00\u542F\u60AC\u6D6E\u7A97\u6743\u9650");
  6387. Device.openPermissionSettings("overlay");
  6388. return;
  6389. }
  6390. FB.setHidden(!on);
  6391. toast(on ? "\u5DF2\u663E\u793A\u60AC\u6D6E\u7403" : "\u5DF2\u9690\u85CF\u60AC\u6D6E\u7403");
  6392. });
  6393. rows.push(W.settingRow("\u60AC\u6D6E\u7403", toggle.root, 6));
  6394. /* 权限缺失时补一行说明(否则用户会以为开关坏了) */
  6395. if (!allowed) {
  6396. rows.push(W.hint("\u9700\u5F00\u542F\u300C\u60AC\u6D6E\u7A97\u6743\u9650\u300D\u624D\u80FD\u663E\u793A\u60AC\u6D6E\u7403", false));
  6397. } else {
  6398. /* 球的位置是用户拖出来的,写清"可拖动"能省掉一轮困惑 */
  6399. 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));
  6400. }
  6401. W.stack(card, rows, 10);
  6402. return card;
  6403. }
  6404. /** 应用下载卡:按 TikTok 的真实安装状态显示「未安装/下载」或「已安装版本/更新」。 */
  6405. function buildAppCard() {
  6406. var card = W.card(false);
  6407. var info = Device.tiktok();
  6408. var label = U.text("TikTok \u5BA2\u6237\u7AEF", Theme.m.labelSize, Theme.hex.textSecondary, false);
  6409. var statusView = info.installed
  6410. ? W.pill("\u5DF2\u5B89\u88C5 " + Device.formatVersion(info.versionName), "ok")
  6411. : W.pill("\u672A\u5B89\u88C5", "mute");
  6412. var action = W.chip(info.installed ? "\u66F4\u65B0" : "\u4E0B\u8F7D", "accent");
  6413. action.root.setOnClickListener(function () {
  6414. installFromLocalApk(info);
  6415. });
  6416. /* 左侧标签用内容宽度,中间用 flexible() 顶开,右侧控件自然靠边 */
  6417. var row = U.row();
  6418. row.addView(label);
  6419. row.addView(U.flexible());
  6420. row.addView(statusView.root);
  6421. row.addView(U.hgap(6));
  6422. row.addView(action.root);
  6423. card.addView(row);
  6424. return card;
  6425. }
  6426. /**
  6427. * 从本机 APK 安装/更新 TikTok。
  6428. *
  6429. * 为什么不做成「点一下自动下载」:没有可依赖的官方下载源,
  6430. * 硬塞一个第三方地址既不安全也会失效。改成扫描本机已有的 APK,
  6431. * 让用户自己放包——这是能真正跑通的路径。
  6432. */
  6433. function installFromLocalApk(info) {
  6434. var dirs = ["/sdcard/Download", "/sdcard/Download/apk", "/sdcard/apk"];
  6435. var found = [];
  6436. for (var i = 0; i < dirs.length; i++) {
  6437. var names = [];
  6438. try {
  6439. names = files.listDir(dirs[i]);
  6440. } catch (e) {
  6441. names = [];
  6442. }
  6443. for (var j = 0; j < names.length; j++) {
  6444. if (/\.apk$/i.test(names[j])) { found.push(dirs[i] + "/" + names[j]); }
  6445. }
  6446. }
  6447. if (found.length === 0) {
  6448. Dialog.alert("未找到安装包",
  6449. "把 TikTok 的 APK 放到 /sdcard/Download 下即可在这里安装。\n\n" +
  6450. (info.installed ? "当前已安装:" + Device.formatVersion(info.versionName) : "当前未安装。"));
  6451. return;
  6452. }
  6453. Dialog.choose({
  6454. title: info.installed ? "选择更新包" : "选择安装包",
  6455. options: found,
  6456. currentIndex: 0,
  6457. onConfirm: function (index) {
  6458. launchInstaller(found[index]);
  6459. }
  6460. });
  6461. }
  6462. /** 把选中的 APK 交给系统安装器。 */
  6463. function launchInstaller(apkPath) {
  6464. var ok = false;
  6465. try {
  6466. /* app.viewFile 交给系统安装器处理;不用 FileUriExposed 的裸 file:// Intent,
  6467. * 那在 Android 7+ 会直接抛异常。 */
  6468. app.viewFile(apkPath);
  6469. ok = true;
  6470. } catch (e) {
  6471. console.log("[mine] 调起安装器失败: " + e);
  6472. }
  6473. toast(ok ? "已交给系统安装器" : "无法调起安装器,请手动点击该 APK");
  6474. }
  6475. /** 版本信息卡:脚本版本 / 更新日志 / 检查更新。 */
  6476. function buildVersionCard() {
  6477. var card = W.card(false);
  6478. var rows = [];
  6479. rows.push(W.settingRow("\u811A\u672C\u7248\u672C",
  6480. monoChip(Service.SCRIPT_VERSION, false).root, 6));
  6481. var changelog = W.chip("\u67E5\u770B", "accent");
  6482. changelog.root.setOnClickListener(function () {
  6483. Dialog.alert("更新日志", Service.CHANGELOG.join("\n"));
  6484. });
  6485. rows.push(W.settingRow("\u66F4\u65B0\u65E5\u5FD7", changelog.root, 6));
  6486. var statusPill = W.pill("\u68C0\u67E5\u4E2D\u2026", "mute");
  6487. rows.push(W.settingRow("\u68C0\u67E5\u66F4\u65B0", statusPill.root, 6));
  6488. function refresh() {
  6489. statusPill.setText("\u68C0\u67E5\u4E2D\u2026");
  6490. checkUpdateAsync(function (result) {
  6491. statusPill.setText(updateText(result));
  6492. });
  6493. }
  6494. statusPill.root.setOnClickListener(refresh);
  6495. /* 进页面自动查一次:设计稿这里直接显示结论(「已是最新」),
  6496. * 停在「检查中…」会让用户以为卡住了。走独立线程,不阻塞界面。 */
  6497. refresh();
  6498. W.stack(card, rows, 12);
  6499. return card;
  6500. }
  6501. /** 检查更新的结论文案。集中一处,避免「自动查」和「手动查」两条路径说法不一致。 */
  6502. function updateText(result) {
  6503. if (result.hasUpdate) { return "\u53D1\u73B0 " + result.latest; }
  6504. if (result.source === "unknown") { return "\u68C0\u67E5\u5931\u8D25"; }
  6505. return "\u5DF2\u662F\u6700\u65B0";
  6506. }
  6507. /**
  6508. * 检查更新。
  6509. *
  6510. * ⚠️ 默认路径**必须同步返回**,不能无脑起线程:
  6511. * 真机实测,`threads.start` + `ui.run` 这条链路里改 View 会直接崩,
  6512. * 报 `Cannot find function setAndNotify in object P7.b@...`(TextView 的内部通知)。
  6513. * 而 updateEndpoint 为空时 checkUpdate() 是纯本地计算、不发网络请求,
  6514. * 本来就没什么可异步的——起线程只是白白引入跨线程改 UI 的风险。
  6515. * 只有真配了远端端点、会发 HTTP 时,才走线程。
  6516. */
  6517. function checkUpdateAsync(callback) {
  6518. if (!Service.CONFIG.updateEndpoint) {
  6519. callback(Service.checkUpdate());
  6520. return;
  6521. }
  6522. threads.start(function () {
  6523. var result = Service.checkUpdate();
  6524. ui.run(function () { callback(result); });
  6525. });
  6526. }
  6527. /**
  6528. * 我的页。
  6529. * 与其它四页不同,本页**不需要 rerender** —— 页内唯一的动态刷新是
  6530. * 「检查更新」的状态标签,它是就地改文字(改完不重建,滚动位置才不会丢)。
  6531. */
  6532. Pages.mine = function () {
  6533. var inner = U.column();
  6534. var blocks = [];
  6535. blocks.push(W.sectionTitle("\u5361\u5BC6"));
  6536. blocks.push(buildLicenseCard());
  6537. blocks.push(U.spacer(4));
  6538. blocks.push(W.sectionTitle("\u5E94\u7528"));
  6539. blocks.push(buildAppCard());
  6540. /* 悬浮球单独一张卡:它是"跨 App 控制入口"的开关,
  6541. * 混进 TikTok 客户端那张卡里会被当成"TikTok 的设置"(规格书 §10 补的恢复入口) */
  6542. blocks.push(U.spacer(4));
  6543. blocks.push(W.sectionTitle("\u60AC\u6D6E\u7403"));
  6544. blocks.push(buildFloatBallCard());
  6545. blocks.push(U.spacer(4));
  6546. blocks.push(W.sectionTitle("\u5173\u4E8E"));
  6547. blocks.push(buildVersionCard());
  6548. /* 设备信息放在最后一行,排障时一眼能看到当前跑在什么机器上 */
  6549. blocks.push(W.hint(Device.deviceLabel() + " \u00B7 " + Device.appVersionName(), false));
  6550. W.stack(inner, blocks, Theme.m.gapCard);
  6551. var scroller = W.scroller();
  6552. scroller.addView(inner);
  6553. return scroller;
  6554. };
  6555. /* ============================== src/main.js ============================== */
  6556. /* ============================== src/main.js ==============================
  6557. * 入口:搭骨架、路由(tab + 二级页)、运行态、启动流程。
  6558. *
  6559. * 骨架只搭一次(根 FrameLayout + 光晕 + 浮层宿主),之后所有刷新都只重建 body:
  6560. * · 主界面:全局运行条 / 页面内容 / 开始运行按钮 / 页签栏 四个宿主容器;
  6561. * · 登录页:整块 body 就是登录内容,没有页签也没有按钮。
  6562. * 不整体重建根布局——那会让光晕图层反复重建,肉眼可见地闪。
  6563. *
  6564. * ## 路由只有两层(2026-10-13 改版)
  6565. * 一级:三个 tab —— 任务 / 记录 / 我的(设计稿底栏只有三个图标)
  6566. * 二级:任务详情(id/dm/nurture/video 四个任务,各自一页)/ 运行详情(某条记录)
  6567. * 二级页保留底栏与「开始运行」CTA;运行详情没有 CTA(它只是看,不驱动)。
  6568. *
  6569. * ⚠️ 本脚本必须以 "ui"; 指令开头(build.js 负责写在第一行)。
  6570. */
  6571. var App = {};
  6572. App.ROOT_TAG = "feizhou-root";
  6573. App.RUN_CONFIG_PATH = "/sdcard/tiktok_app/run-config.json";
  6574. /** 当前显示的是哪一屏:主界面还是卡密登录页。 */
  6575. App.screen = "app";
  6576. /**
  6577. * 一级页:tab → 页面构建函数。顺序与 W.TABS 一致。
  6578. *
  6579. * 记录页在这里做「有记录 / 空态」的分流:两者是同一页的两种状态
  6580. * (设计稿 D1/D2),分流点放在路由层而不是页面内部 ——
  6581. * 页面函数只管画自己那一种,不必先判断"我有数据吗"。
  6582. */
  6583. App.TAB_PAGES = {
  6584. tasks: function () { return Pages.tasks(); },
  6585. records: function () { return Records.list().length > 0 ? Pages.records() : Pages.recordsEmpty(); },
  6586. /* 我的页不需要 rerender:页内唯一会变的是「检查更新」的状态标签,
  6587. * 它是就地改文字,不重建 —— 重建会把滚动位置清零。 */
  6588. mine: function () { return Pages.mine(); }
  6589. };
  6590. /** 一级页的标题区文案(设计稿:副标题各不相同)。 */
  6591. App.TAB_HEADERS = {
  6592. tasks: ["\u98DE\u821F\u811A\u672C", "TikTok \u81EA\u52A8\u5316\u811A\u672C \u00B7 \u9009\u62E9\u4EFB\u52A1"],
  6593. records: ["\u98DE\u821F\u811A\u672C", "TikTok \u81EA\u52A8\u5316\u811A\u672C \u00B7 \u8FD0\u884C\u8BB0\u5F55"],
  6594. mine: ["\u98DE\u821F\u811A\u672C", "TikTok \u81EA\u52A8\u5316\u811A\u672C \u00B7 \u6211\u7684"]
  6595. };
  6596. /** 二级页:任务详情。每个任务一页,都是可滚动的长页。 */
  6597. App.DETAIL_PAGES = {
  6598. id: function (rerender) { return Pages.id(rerender); },
  6599. dm: function (rerender) { return Pages.dm(rerender); },
  6600. nurture: function (rerender) { return Pages.nurture(rerender); },
  6601. video: function (rerender) { return Pages.video(rerender); }
  6602. };
  6603. App.body = null;
  6604. App.overlayHost = null;
  6605. App.runBar = null;
  6606. /**
  6607. * 上一次运行的终态(内存,不落盘)。
  6608. * 布局与状态条同位:未运行时它就是那一条"上次运行:…"。
  6609. * 不落盘的理由与运行态相同 —— 冷启动后该是干净的。
  6610. */
  6611. App.lastRunResult = null;
  6612. /**
  6613. * 报错态运行条(设计稿 F5 的 30:1119:整条变玫瑰红 + 文案「执行出错已停止」)。
  6614. *
  6615. * 只在「失败结束、且用户还没关掉 F5」这一刻存在。
  6616. * 关掉之后就整条隐藏 —— 与规格书 §2.4 的「空闲/完成时整条隐藏」一致,
  6617. * 但报错那一刻必须留着:它是"这次跑挂了"最直观的证据。
  6618. */
  6619. App.failBar = null;
  6620. /**
  6621. * 二级页的运行详情(记录 id)。**只存内存、不落盘**:
  6622. * 冷启动后回到"某一条历史记录"没有意义,而记录本身可能已被清理。
  6623. */
  6624. App.recordId = "";
  6625. /**
  6626. * 引擎"没写终态就没了"的判定宽限期。
  6627. * 引擎刚起来时还没出现在引擎列表里,头几秒不判定,免得刚启动就被判成已退出。
  6628. */
  6629. App.CHILD_GRACE_MS = 4000;
  6630. /**
  6631. * 发出停止请求后等引擎确认的上限。
  6632. * 超时就当它已经停了:用户按了停止,界面不能一直卡在"正在收尾"上。
  6633. */
  6634. App.STOP_TIMEOUT_MS = 8000;
  6635. /**
  6636. * 本次运行的 runId,在唤起引擎之前就定下来。
  6637. *
  6638. * 单独存一份是因为它要在两处用到 —— 写进 run-config(追溯)与传给引擎(比对),
  6639. * 而 `Session.start` 只在引擎**已经起来之后**才会被调用,顺序上不能倒过来。
  6640. */
  6641. App.pendingRunId = "";
  6642. /** 本次运行是否已提示过「引擎需要 TikTok 保持前台」。来回切窗口时只提醒一次。 */
  6643. App.backgroundWarned = false;
  6644. /* ------------------------------------------------------------ 光晕背景 */
  6645. /**
  6646. * 复刻设计稿背景的两团径向光晕。
  6647. * 用绝对定位的圆形 View 叠在根 FrameLayout 上,位置取自设计稿坐标。
  6648. */
  6649. function buildGlowLayer() {
  6650. var layer = new android.widget.FrameLayout(context);
  6651. layer.setLayoutParams(new android.widget.FrameLayout.LayoutParams(-1, -1));
  6652. var specs = [
  6653. { hex: Theme.hex.accent, alpha: Theme.alpha.glowA, size: 440, x: -40, y: -70 },
  6654. { hex: Theme.hex.accentDeep, alpha: Theme.alpha.glowB, size: 420, x: 40, y: 120 }
  6655. ];
  6656. for (var i = 0; i < specs.length; i++) {
  6657. var spec = specs[i];
  6658. var glow = new android.view.View(context);
  6659. glow.setBackground(U.glowBg(spec.hex, spec.alpha));
  6660. var lp = new android.widget.FrameLayout.LayoutParams(U.dp(spec.size), U.dp(spec.size));
  6661. lp.leftMargin = U.dp(spec.x);
  6662. lp.topMargin = U.dp(spec.y);
  6663. layer.addView(glow, lp);
  6664. }
  6665. /* 光晕只是氛围,不能吃掉点击事件 */
  6666. layer.setClickable(false);
  6667. layer.setFocusable(false);
  6668. return layer;
  6669. }
  6670. /* ------------------------------------------------------------ 页面骨架 */
  6671. /**
  6672. * 定高占位块。注意入参是**像素**而不是 dp ——
  6673. * 状态栏与导航栏高度从系统取回来就已经是 px,再 dp() 一次会放大两倍多。
  6674. */
  6675. function pxPlaceholder(heightPx) {
  6676. var view = new android.view.View(context);
  6677. view.setLayoutParams(new android.widget.LinearLayout.LayoutParams(-1, Math.round(heightPx)));
  6678. return view;
  6679. }
  6680. /** 竖向铺开并撑满剩余高度,供内容区使用。 */
  6681. function fillColumn() {
  6682. var column = U.column();
  6683. var lp = new android.widget.LinearLayout.LayoutParams(-1, 0);
  6684. lp.weight = 1;
  6685. column.setLayoutParams(lp);
  6686. return column;
  6687. }
  6688. App.build = function () {
  6689. var root = new android.widget.FrameLayout(context);
  6690. root.setBackground(U.screenBackground());
  6691. root.addView(buildGlowLayer());
  6692. App.body = U.column();
  6693. App.body.setLayoutParams(new android.widget.FrameLayout.LayoutParams(-1, -1));
  6694. root.addView(App.body);
  6695. App.overlayHost = new android.widget.FrameLayout(context);
  6696. root.addView(App.overlayHost);
  6697. return root;
  6698. };
  6699. /* ------------------------------------------------------------ 主界面 */
  6700. /**
  6701. * 上一次运行的结论行。点一下消失 —— 确认过了就不必一直占着位置。
  6702. */
  6703. function buildLastResultRow() {
  6704. var row = U.row();
  6705. row.setPadding(U.dp(Theme.m.contentPadding), U.dp(10),
  6706. U.dp(Theme.m.contentPadding), 0);
  6707. var text = U.text("\u4E0A\u6B21\u8FD0\u884C\uFF1A" + (App.lastRunResult.message || ""),
  6708. 11, Theme.hex.textFaint, false);
  6709. text.setSingleLine(true);
  6710. text.setEllipsize(android.text.TextUtils.TruncateAt.END);
  6711. row.addView(text);
  6712. row.addView(U.flexible());
  6713. row.setOnClickListener(function () {
  6714. App.lastRunResult = null;
  6715. App.renderScreen();
  6716. });
  6717. return row;
  6718. }
  6719. /**
  6720. * 把正在被驱动的目标应用切回前台。
  6721. *
  6722. * 引擎靠"看到 TikTok 在前台"来定位页面,所以**它在跑的时候 TikTok 必须留在前台**。
  6723. * 用户切回界面看进度这件事本身很自然,但它会让引擎瞬间"瞎掉"。
  6724. * 与其在文档里写"运行中请勿切走"(没人会记得),不如给一个一键切回去的入口。
  6725. */
  6726. App.backToTarget = function () {
  6727. var info = Device.tiktok();
  6728. if (!info.installed) {
  6729. toast("\u672A\u68C0\u6D4B\u5230 TikTok \u5BA2\u6237\u7AEF");
  6730. return false;
  6731. }
  6732. try {
  6733. app.launchPackage(info.packageName);
  6734. return true;
  6735. } catch (e) {
  6736. console.log("[app] 切回目标应用失败: " + e);
  6737. toast("\u5207\u56DE TikTok \u5931\u8D25");
  6738. return false;
  6739. }
  6740. };
  6741. /**
  6742. * 界面回到前台时的提醒。
  6743. *
  6744. * 只在**引擎正在跑**且**本次运行还没提醒过**时说一次:
  6745. * 用户来回切窗口时反复弹同一个提示是纯打扰。
  6746. *
  6747. * ⚠️ 2026-10-13 文案改了:运行条现在点一下是**跳回运行中的任务详情页**
  6748. * (规格书 §2.4「运行条 = 状态与导航」),不再是「切回 TikTok」。
  6749. * 旧的提示语会让用户点着运行条去找 TikTok,越点越糊涂。
  6750. */
  6751. App.noticeIfRunningInBackground = function () {
  6752. if (!Session.running || App.backgroundWarned) { return; }
  6753. App.backgroundWarned = true;
  6754. toast("\u8FD0\u884C\u4E2D\uFF1A\u5F15\u64CE\u9700\u8981 TikTok \u4FDD\u6301\u5728\u524D\u53F0\uFF0C"
  6755. + "\u5207\u5230\u672C\u754C\u9762\u4F1A\u8BA9\u5B83\u8BA4\u4E0D\u51FA\u9875\u9762");
  6756. };
  6757. /**
  6758. * 主界面:全局运行条 → 页面内容 → 开始运行 → 页签栏。
  6759. *
  6760. * 运行条放在内容区**上面**(设计稿 §2.4:x=20, y=70, w=335, h=44,状态栏之下):
  6761. * 它要在任何时候都可见 —— 包括用户切到别的 tab 去看记录的时候,
  6762. * 这正是"全局单跑"的可见性来源。
  6763. */
  6764. App.renderAppScreen = function (body) {
  6765. body.addView(pxPlaceholder(Device.statusBarHeight()));
  6766. /* 运行条:两种来源,同一个位置。
  6767. * ① 正在跑 → 绿点 + 任务名/脚本 + 计时(点击跳回运行中那个任务的详情页);
  6768. * ② 刚跑挂且用户还没关掉 F5 → 整条玫瑰红 +「执行出错已停止」+「已终止」。
  6769. * 空闲/正常完成时整条不存在,连那 10dp 间距也一并省掉 ——
  6770. * 一个空容器带着 padding 仍然会占位,是很容易忽略的一处空隙。 */
  6771. App.runBar = null;
  6772. var barText = "";
  6773. var barTime = "";
  6774. var barTone = "ok";
  6775. if (Session.running) {
  6776. barText = Session.barText();
  6777. barTime = Session.formatElapsed(Session.elapsedMs());
  6778. barTone = Session.stopping ? "warn" : "ok";
  6779. } else if (App.failBar) {
  6780. barText = App.failBar.text;
  6781. barTime = App.failBar.time;
  6782. barTone = "danger";
  6783. }
  6784. if (barText !== "") {
  6785. var statusHost = U.column();
  6786. statusHost.setPadding(U.dp(Theme.m.contentPadding), U.dp(10),
  6787. U.dp(Theme.m.contentPadding), 0);
  6788. App.runBar = W.runBar(barText, barTime, barTone);
  6789. /* ★ 状态条整条可点击 = 「跳回运行中的任务」。
  6790. * 引擎是靠"看到 TikTok 在前台"来定位页面的;用户切到记录 tab 看一眼,
  6791. * 前台就变了。与其在文档里写"运行中请勿切走",不如给一个一键跳回去的入口。 */
  6792. App.runBar.root.setOnClickListener(function () { App.backToRunning(); });
  6793. statusHost.addView(App.runBar.root);
  6794. body.addView(statusHost);
  6795. } else if (App.lastRunResult) {
  6796. /* 未运行但上一轮刚结束:留一行结果。
  6797. * 人守在手机前看的是 TikTok 自动操作,跑完那一刻他多半没看着界面 ——
  6798. * 切回来能立刻知道"上一轮跑成什么样了",而不是面对一个干净的界面猜。 */
  6799. body.addView(buildLastResultRow());
  6800. }
  6801. /* 内容区外面套一层 FrameLayout:底部渐隐要**叠**在内容上(设计稿 §6:
  6802. * y=577 h=80),直接加进竖向 body 会变成另起一行、占掉 80dp 高度。 */
  6803. var contentWrap = new android.widget.FrameLayout(context);
  6804. var wrapParams = new android.widget.LinearLayout.LayoutParams(-1, 0);
  6805. wrapParams.weight = 1;
  6806. contentWrap.setLayoutParams(wrapParams);
  6807. var contentHost = U.column();
  6808. contentHost.setLayoutParams(new android.widget.FrameLayout.LayoutParams(-1, -1));
  6809. contentHost.setPadding(U.dp(Theme.m.contentPadding), U.dp(Theme.m.contentPadding),
  6810. U.dp(Theme.m.contentPadding), 0);
  6811. contentWrap.addView(contentHost);
  6812. contentWrap.addView(W.bottomFade());
  6813. body.addView(contentWrap);
  6814. var runHost = new android.widget.FrameLayout(context);
  6815. body.addView(runHost);
  6816. var tabHost = new android.widget.FrameLayout(context);
  6817. body.addView(tabHost);
  6818. /* 底部导航背景横跨整宽,所以背景挂在 tabHost 上而不是 body */
  6819. tabHost.setBackground(U.solidBg(U.alphaOf(Theme.hex.tabBar, 0.96)));
  6820. App.contentHost = contentHost;
  6821. App.runHost = runHost;
  6822. App.tabHost = tabHost;
  6823. App.renderContent();
  6824. App.renderRunArea();
  6825. App.renderTabs();
  6826. };
  6827. /**
  6828. * 重建页面内容区。
  6829. *
  6830. * 三条分支对应三层东西:
  6831. * ① 运行详情(App.recordId)—— 它自带返回头,不再加标题区;
  6832. * ② 任务详情(App.detail)—— 返回头 + 该任务的长页;
  6833. * ③ 一级 tab —— 标题区 + 该 tab 的页面。
  6834. *
  6835. * ★ 重建前先 `commitPendingInputs()`:输入框现在是可以**直接编辑**的常驻控件,
  6836. * 用户完全可能还停在某一格里就点了别处(切 tab、勾复选框触发重建)。
  6837. * 不先提交就重建,那一格的编辑会随视图一起消失 —— 而这正是
  6838. * 「界面显示的值」和「真正跑起来的值」不一致的来源。
  6839. */
  6840. App.renderContent = function () {
  6841. W.commitPendingInputs();
  6842. W.beginPage();
  6843. var rerender = function () { ui.run(function () { App.renderContent(); }); };
  6844. /* 页面内部(例如「使用文本 ID」勾选后右端多出按钮)需要整页重建时用它 */
  6845. Pages.requestRerender = rerender;
  6846. var host = App.contentHost;
  6847. host.removeAllViews();
  6848. /* —— ② 运行详情 —— */
  6849. if (App.recordId) {
  6850. var detailPage = Pages.recordDetail(Records.find(App.recordId));
  6851. U.params(detailPage, -1, 0, 1);
  6852. host.addView(detailPage);
  6853. return;
  6854. }
  6855. /* —— ② 任务详情 —— */
  6856. if (App.detail) {
  6857. host.addView(W.backHeader(Pages.DETAIL_TITLES[App.detail] || "",
  6858. function () { App.closeDetail(); },
  6859. function () { Device.openPermissionSettings("a11y"); }));
  6860. host.addView(U.spacer(Theme.m.gapTight));
  6861. var builder = App.DETAIL_PAGES[App.detail];
  6862. if (!builder) {
  6863. /* 不认识的详情 key(配置被手改过)→ 直接回任务列表,
  6864. * 而不是渲染一个空白页让用户以为界面坏了。 */
  6865. App.detail = "";
  6866. Store.set("activeDetail", "");
  6867. App.renderContent();
  6868. return;
  6869. }
  6870. var page = builder(rerender);
  6871. var pageParams = new android.widget.LinearLayout.LayoutParams(-1, 0);
  6872. pageParams.weight = 1;
  6873. page.setLayoutParams(pageParams);
  6874. host.addView(page);
  6875. return;
  6876. }
  6877. /* —— ③ 一级 tab —— */
  6878. var header = App.TAB_HEADERS[App.activeTab] || App.TAB_HEADERS.tasks;
  6879. host.addView(W.pageHeader(header[0], header[1], function () {
  6880. Device.openPermissionSettings("a11y");
  6881. }));
  6882. host.addView(U.spacer(Theme.m.gapPage));
  6883. var tabBuilder = App.TAB_PAGES[App.activeTab] || App.TAB_PAGES.tasks;
  6884. var tabPage = tabBuilder();
  6885. var tabParams = new android.widget.LinearLayout.LayoutParams(-1, 0);
  6886. tabParams.weight = 1;
  6887. tabPage.setLayoutParams(tabParams);
  6888. host.addView(tabPage);
  6889. };
  6890. /**
  6891. * 重建「开始运行 / 停止运行」按钮区。
  6892. *
  6893. * 只有**任务详情页**有这一块(设计稿:B1/B2/D1/D2/D3/E1 都没有)。
  6894. * 分工是刻意的:任务卡上的「运行」按钮负责"直接从列表起跑",
  6895. * 底部 CTA 负责"在详情页调好参数后起跑/停止"。 */
  6896. App.renderRunArea = function () {
  6897. var host = App.runHost;
  6898. host.removeAllViews();
  6899. if (App.recordId || !App.detail) { return; }
  6900. var wrapper = U.column();
  6901. wrapper.setPadding(U.dp(Theme.m.contentPadding), U.dp(12), U.dp(Theme.m.contentPadding), U.dp(12));
  6902. var button = W.runButton(function () { App.toggleRun(); },
  6903. Session.running ? "\u505C\u6B62\u8FD0\u884C" : "\u5F00\u59CB\u8FD0\u884C");
  6904. wrapper.addView(button.root);
  6905. host.addView(wrapper);
  6906. };
  6907. /** 重建页签栏(含底部安全区)。 */
  6908. App.renderTabs = function () {
  6909. var host = App.tabHost;
  6910. host.removeAllViews();
  6911. var column = U.column();
  6912. column.addView(W.tabBar(App.activeTab, function (key) {
  6913. if (key === App.activeTab) { return; }
  6914. App.switchTab(key);
  6915. }));
  6916. /* 全面屏手势机型 navBarHeight() 返回 0,此时给 24dp 余量,
  6917. * 否则页签文字会贴到手势条上。 */
  6918. var safe = Device.navBarHeight();
  6919. column.addView(pxPlaceholder(safe > 0 ? safe : U.dp(24)));
  6920. host.addView(column);
  6921. };
  6922. /** 全屏重建。切屏与运行态变化都走它,不在别处零散重建。 */
  6923. App.renderScreen = function () {
  6924. var body = App.body;
  6925. body.removeAllViews();
  6926. if (App.screen === "login") {
  6927. body.addView(pxPlaceholder(Device.statusBarHeight()));
  6928. var login = Pages.login();
  6929. var lp = new android.widget.LinearLayout.LayoutParams(-1, 0);
  6930. lp.weight = 1;
  6931. login.setLayoutParams(lp);
  6932. body.addView(login);
  6933. return;
  6934. }
  6935. App.renderAppScreen(body);
  6936. };
  6937. App.switchTab = function (key) {
  6938. /* 切 tab = 退出二级页:底栏是"这一层"的导航,
  6939. * 按了它还停在详情页里会让人以为导航坏了。 */
  6940. App.detail = "";
  6941. App.recordId = "";
  6942. Store.set("activeDetail", "");
  6943. App.activeTab = key;
  6944. Store.set("activeTab", key);
  6945. App.renderScreen();
  6946. console.log("[app] 切换到 tab " + key);
  6947. };
  6948. /* ------------------------------------------------------------ 二级页导航 */
  6949. /** 进某个任务的详情页(C1/C6/C8/C9)。 */
  6950. App.openDetail = function (taskKey) {
  6951. if (!App.DETAIL_PAGES[taskKey]) { return; }
  6952. App.recordId = "";
  6953. App.detail = taskKey;
  6954. if (App.activeTab !== "tasks") {
  6955. App.activeTab = "tasks";
  6956. Store.set("activeTab", "tasks");
  6957. }
  6958. Store.set("activeDetail", taskKey);
  6959. App.renderScreen();
  6960. console.log("[app] 打开任务详情 " + taskKey);
  6961. };
  6962. App.closeDetail = function () {
  6963. App.detail = "";
  6964. Store.set("activeDetail", "");
  6965. App.renderScreen();
  6966. };
  6967. /** 进某条运行记录的详情(D3)。 */
  6968. App.openRecord = function (recordId) {
  6969. if (!recordId) { return; }
  6970. App.recordId = recordId;
  6971. App.detail = "";
  6972. if (App.activeTab !== "records") {
  6973. App.activeTab = "records";
  6974. Store.set("activeTab", "records");
  6975. }
  6976. Store.set("activeDetail", "");
  6977. App.renderScreen();
  6978. };
  6979. App.closeRecord = function () {
  6980. App.recordId = "";
  6981. App.renderScreen();
  6982. };
  6983. /**
  6984. * 点运行条 → 跳回**正在运行的那个任务**的详情页。
  6985. *
  6986. * 为什么是"跳回任务页"而不是"切回 TikTok":运行条是全局的,
  6987. * 用户在记录 tab 上看到它、点一下,最想问的是"谁在跑 / 跑成什么样了"。
  6988. * 至于把 TikTok 拉回前台 —— 那是**引擎自己的事**(它必须待在前台),
  6989. * 界面这一下只解决"我在哪"。
  6990. */
  6991. App.backToRunning = function () {
  6992. if (Session.running && Session.tab) {
  6993. App.openDetail(Session.tab);
  6994. return;
  6995. }
  6996. /* 报错态的那一下:直接去看日志(F5 的「查看日志」走的是同一个入口) */
  6997. var failedId = App.failBar ? App.failBar.recordId : "";
  6998. App.failBar = null;
  6999. if (failedId) {
  7000. App.openRecord(failedId);
  7001. return;
  7002. }
  7003. App.renderScreen();
  7004. };
  7005. /* ------------------------------------------------------------ 运行态 */
  7006. /**
  7007. * 每秒刷新计时,并顺带做两件巡检。
  7008. *
  7009. * 只改状态条上的那两个 TextView,不重建界面 ——
  7010. * 每秒重建整页会让滚动位置归零,用户根本没法边跑边看配置。
  7011. *
  7012. * 两件巡检:
  7013. * ① 卡密到期(设计稿 F4):到期那一刻停掉运行并弹窗,而不是等用户下一次操作才发现。
  7014. * ② **引擎状态**:这是"界面上的运行态"与"真正在跑的东西"唯一的一致性来源。
  7015. * 两者都放在这里而不是另起定时器,因为「运行中」本来就是它们唯一需要的触发条件。
  7016. */
  7017. App.tickTimer = function () {
  7018. if (!Session.running) { return; }
  7019. if (Service.licenseStatus().status !== "active") {
  7020. /* 先让引擎停手(否则它会继续操作 TikTok),然后**立刻复位界面**:
  7021. * 这一刻要传递的信息是"你已经不能跑了",不是"正在收尾",
  7022. * 再挂一个转圈的运行态反而让人以为还有救。 */
  7023. Engine.requestStop(Session.runId);
  7024. Session.stop();
  7025. Session.stopTimer();
  7026. App.renderScreen();
  7027. Dialog.licenseExpired(function () { App.showLogin(); });
  7028. return;
  7029. }
  7030. App.pollEngine();
  7031. FB.sync();
  7032. /* pollEngine 可能已经把运行结束了(终态),此时状态条已经不存在 */
  7033. if (App.runBar && Session.running) {
  7034. App.runBar.setElapsed(Session.formatElapsed(Session.elapsedMs()));
  7035. App.runBar.setText(Session.barText());
  7036. App.runBar.setTone(Session.stopping ? "warn" : "ok");
  7037. }
  7038. };
  7039. /**
  7040. * 读引擎上报的状态,驱动界面的运行态。
  7041. *
  7042. * ★ 必须比对 runId:状态文件是设备上的共享文件,上一轮运行留下的内容还在。
  7043. * 不比对就会出现「刚点开始运行,界面立刻显示上一轮的进度」。
  7044. */
  7045. App.pollEngine = function () {
  7046. var state = Engine.readState();
  7047. if (state && state.runId === Session.runId) {
  7048. /* 看到过它,才有资格在后来说"它不见了" —— 见下面的兜底判定 */
  7049. if (Engine.childAlive()) { Engine.sawChild = true; }
  7050. if (Engine.isTerminalStatus(state.status)) {
  7051. App.finishRun(state);
  7052. return;
  7053. }
  7054. if (state.phase) { Session.phase = state.phase; }
  7055. Session.done = state.done || 0;
  7056. Session.total = state.total || 0;
  7057. if (state.status === "stopping") { Session.stopping = true; }
  7058. } else if (Engine.childAlive()) {
  7059. /* 引擎在跑但还没写出状态:正常的启动间隙,什么都不做 */
  7060. Engine.sawChild = true;
  7061. }
  7062. /* —— 兜底一:点了停止但引擎迟迟不确认 —— */
  7063. if (Session.stopping && Date.now() - Session.stopRequestedAt > App.STOP_TIMEOUT_MS) {
  7064. App.finishRun({
  7065. status: "stopped",
  7066. message: "\u5DF2\u53D1\u51FA\u505C\u6B62\u8BF7\u6C42\uFF08\u5F15\u64CE\u672A\u786E\u8BA4\uFF09"
  7067. });
  7068. return;
  7069. }
  7070. /* —— 兜底二:引擎没写终态就没了(被系统杀掉 / 被人手工停掉)——
  7071. * 只在"确实看到过它、现在它不见了"时判定。`engines.all()` 在不同运行时里
  7072. * 的可见性不完全一致,没看到过就当作"查不出来",宁可多显示一会儿运行中,
  7073. * 也不要误判成已结束把运行态清掉。 */
  7074. if (!Session.stopping &&
  7075. Engine.sawChild &&
  7076. Date.now() - Session.startedAt > App.CHILD_GRACE_MS &&
  7077. !Engine.childAlive()) {
  7078. App.finishRun({
  7079. status: "failed",
  7080. message: "\u5F15\u64CE\u5DF2\u9000\u51FA\uFF08\u672A\u6536\u5230\u7ED3\u675F\u4FE1\u53F7\uFF09"
  7081. });
  7082. }
  7083. };
  7084. /**
  7085. * 把界面拉回前台。
  7086. *
  7087. * 引擎跑起来时前台是 TikTok(它在那边操作),用户看不到界面上的进度;
  7088. * 跑完把界面切回来,用户才不用自己去最近任务里把它翻出来。
  7089. *
  7090. * 为什么不用 `app.launchPackage(包名)`:那会打开 AutoX 的**主界面**,
  7091. * 而不是"正在运行的这个脚本界面"。用当前 Activity 的类名 + REORDER_TO_FRONT
  7092. * 才能精准命中同一个界面;它本来就在前台时,该调用无副作用。
  7093. *
  7094. * 失败不抛:拉不回前台只是少了点便利,不该影响"运行已结束"这件正事。
  7095. */
  7096. App.bringToFront = function () {
  7097. try {
  7098. var className = activity.getClass().getName();
  7099. app.startActivity({
  7100. action: "android.intent.action.VIEW",
  7101. packageName: String(context.getPackageName()),
  7102. className: className,
  7103. flags: android.content.Intent.FLAG_ACTIVITY_REORDER_TO_FRONT
  7104. });
  7105. return true;
  7106. } catch (e) {
  7107. console.log("[app] 拉回前台失败: " + e);
  7108. return false;
  7109. }
  7110. };
  7111. /**
  7112. * 结束运行态。
  7113. *
  7114. * 顺序有讲究:先**攒一条运行记录**(此刻 Session 里还有本次运行的信息,
  7115. * `Session.stop()` 一跑就全没了),再复位状态并重建界面(状态条消失、
  7116. * 按钮变回"开始运行"),然后把界面拉回前台,最后提示结果 ——
  7117. * 反过来的话提示会挂在一个还没被拆掉的界面上。
  7118. *
  7119. * 失败终态额外做两件事(规格书 §2.3 / §2.4):
  7120. * · 运行条留在原地**变成玫瑰红**(「执行出错已停止」+「已终止」),
  7121. * 用户关掉 F5 之后才隐藏;
  7122. * · 弹 F5,并把「查看日志」接到刚写下的那条记录上。
  7123. */
  7124. App.finishRun = function (state) {
  7125. var result = {
  7126. status: state.status || "failed",
  7127. message: state.message || "",
  7128. summary: state.summary || null
  7129. };
  7130. /* ★ 记录必须在 Session.stop() 之前攒:任务/脚本/起始时间都在 Session 里。 */
  7131. var record = App.writeRecord(result);
  7132. App.lastRunResult = result;
  7133. Session.stop();
  7134. Session.stopTimer();
  7135. if (result.status === "failed" && record) {
  7136. App.failBar = {
  7137. text: Pages.shortScriptName(record.scriptName) + " \u00B7 \u6267\u884C\u51FA\u9519\u5DF2\u505C\u6B62",
  7138. time: "\u5DF2\u7EC8\u6B62",
  7139. recordId: record.id
  7140. };
  7141. } else {
  7142. App.failBar = null;
  7143. }
  7144. App.renderScreen();
  7145. FB.sync(); /* 失败 → 红环;正常 → 回空闲无环 */
  7146. App.bringToFront();
  7147. if (result.status === "failed") {
  7148. Dialog.scriptError(result.message || "", function () {
  7149. /* 「查看日志」→ 清掉报错条,再进 D3 */
  7150. var id = App.failBar ? App.failBar.recordId : "";
  7151. App.failBar = null;
  7152. if (id) { App.openRecord(id); } else { App.renderScreen(); }
  7153. }, function () {
  7154. /* 「知道了」→ 报错条也随之消失(这一次的事已经被告知过了) */
  7155. App.failBar = null;
  7156. App.renderScreen();
  7157. });
  7158. console.log("[app] 运行结束(失败):" + result.message);
  7159. return;
  7160. }
  7161. /* toast 而不是弹窗:万一前台没能切回来(用户在别的应用里),
  7162. * 系统 Toast 仍然看得见;而弹窗会挡着用户正在做的事。 */
  7163. toast(result.message || "\u8FD0\u884C\u7ED3\u675F");
  7164. console.log("[app] 运行结束:" + result.status + " / " + result.message);
  7165. };
  7166. /**
  7167. * 把这次运行写进运行记录(D1/D3 的数据来源)。
  7168. *
  7169. * 只写**真实知道的东西**:任务/脚本来自 Session,结果文案来自引擎终态,
  7170. * 日志来自引擎的 --log 通道(`Engine.readLog()`)。引擎没回传的字段留空,
  7171. * D3 那边对应的一行就不渲染 —— 不编一个看起来合理的值。
  7172. */
  7173. App.writeRecord = function (result) {
  7174. if (!Session.tab) { return null; }
  7175. var endedAt = Date.now();
  7176. var record = Records.add({
  7177. taskKey: Session.tab,
  7178. taskName: Pages.taskName(Session.tab),
  7179. scriptKey: Session.scriptKey,
  7180. scriptName: Session.scriptName,
  7181. result: Records.resultOf(result.status).key,
  7182. startedAt: Session.startedAt,
  7183. endedAt: endedAt,
  7184. durationMs: Math.max(0, endedAt - Session.startedAt),
  7185. resultText: result.message || "",
  7186. resultFile: result.summary && result.summary.resultFile ? result.summary.resultFile : "",
  7187. logs: Engine.readLog()
  7188. });
  7189. console.log("[records] 已记录本次运行:" + record.id + " / " + record.result);
  7190. return record;
  7191. };
  7192. /**
  7193. * 取当前任务详情页里选中的那个脚本,没选返回 null。
  7194. *
  7195. * 从 v6 起「当前任务」= **当前打开的那个任务详情页**(App.detail)。
  7196. * 一级页(任务列表)没有"当前任务"这回事 —— 四张卡各有各的「运行」按钮,
  7197. * 各带各的 taskKey(见 App.requestRun)。这正是"每个任务单独启动"的落点。
  7198. */
  7199. App.currentTask = function () {
  7200. /* ★ 读配置之前先把「用户可能还在编辑」的输入框内容落地。
  7201. * Android 里点一个不可聚焦的按钮**不会**让 EditText 失焦,
  7202. * 所以「按开始运行」这一刻栏位内容很可能还没进配置。 */
  7203. W.commitPendingInputs();
  7204. if (!App.detail) { return null; }
  7205. return Pages.checkedScript(App.detail);
  7206. };
  7207. /**
  7208. * 把当前配置落盘成交给执行侧的运行配置。
  7209. *
  7210. * 这里写的是**给人看、给事后追溯用**的快照(含界面完整配置、本次生效的引擎配置、
  7211. * 以及"哪些勾了但本次不会生效")。引擎真正读的是 engine-config.json ——
  7212. * 分成两份是因为两边的读者不同:一个排查问题的人需要全貌,引擎只需要它能懂的那部分。
  7213. */
  7214. App.exportRunConfig = function (checked, plan) {
  7215. var payload = {
  7216. generatedAt: Service.today(),
  7217. scriptVersion: Service.SCRIPT_VERSION,
  7218. licenseStatus: Service.licenseStatus().status,
  7219. tasks: checked,
  7220. engine: {
  7221. /* ★ 用 pendingRunId 而不是 Session.runId:
  7222. * 本函数在 `Session.start()` **之前**被调用,那时会话里还是上一轮的 runId。
  7223. * 记错这一次的标识,事后追溯就会被误导到别的运行记录上。 */
  7224. runId: App.pendingRunId,
  7225. scriptPath: plan ? plan.scriptPath : "",
  7226. configPath: Engine.CONFIG_PATH,
  7227. statePath: Engine.STATE_PATH,
  7228. stopFlagPath: Engine.STOP_FLAG_PATH,
  7229. config: plan ? plan.config : null,
  7230. notes: plan ? plan.notes : []
  7231. },
  7232. config: Store.current()
  7233. };
  7234. try {
  7235. var dir = App.RUN_CONFIG_PATH.substring(0, App.RUN_CONFIG_PATH.lastIndexOf("/"));
  7236. files.ensureDir(dir + "/");
  7237. files.write(App.RUN_CONFIG_PATH, JSON.stringify(payload, null, 2));
  7238. return true;
  7239. } catch (e) {
  7240. console.log("[app] 运行配置落盘失败: " + e);
  7241. return false;
  7242. }
  7243. };
  7244. /**
  7245. * **所有**「开始运行」的唯一入口(底部 CTA 与四张任务卡上的「运行」都走它)。
  7246. *
  7247. * 前置校验顺序**照规格书 §2.5 实现**,任一步不过就拦截:
  7248. * ① 卡密有效? → 否:不做提示,直接回 A1
  7249. * ② 无障碍+悬浮窗?→ 否:弹 F2
  7250. * ③ 已选脚本? → 否:弹 F1
  7251. * ④ 引擎接入? → 否:弹「引擎尚未接入」(**本项目自己的补丁**:
  7252. * 规格书假设所有脚本都已实现,而这里有四个任务还没接引擎。
  7253. * 静默跑一个不相关的东西比明确告知糟糕得多,所以保留这一关)
  7254. * ⑤ 已有脚本在跑? → 是:弹 F3「停止并开始」;否:直接启动
  7255. *
  7256. * 顺序不能随手改:卡密排第一是因为它不通时**别的都无从谈起**(且要回登录页),
  7257. * 权限排第二是因为它比"选脚本"更难自己发现(缺口在系统设置里)。
  7258. *
  7259. * @param taskKey 要运行哪个任务(任务卡上的按钮直接给,不必先切到那个页)
  7260. */
  7261. App.requestRun = function (taskKey) {
  7262. /* ① 卡密:失效就直接回登录页,不弹提示 —— 那里才有可做的事 */
  7263. if (Service.licenseStatus().status !== "active") {
  7264. App.showLogin();
  7265. return;
  7266. }
  7267. /* ② 阻断性权限(无障碍 + 悬浮窗) */
  7268. if (!Device.blockingPermissionsGranted()) {
  7269. Dialog.permissionBlocked();
  7270. return;
  7271. }
  7272. /* ③ 该任务当前选中的脚本 */
  7273. var script = Pages.checkedScript(taskKey);
  7274. if (!script) {
  7275. Dialog.runBlocked("\u8BF7\u5148\u9009\u62E9\u8981\u8FD0\u884C\u7684\u811A\u672C");
  7276. return;
  7277. }
  7278. /* ④ 引擎是否接入(见函数头注释) */
  7279. if (!script.engine) {
  7280. Dialog.engineMissing(script.name);
  7281. return;
  7282. }
  7283. /* ⑤ 全局单跑:运行中点别的任务不禁用按钮,而是弹 F3 确认(规格书 §2.3) */
  7284. if (Session.running) {
  7285. Dialog.switchTask(Pages.taskName(Session.tab), Pages.taskName(taskKey), function () {
  7286. App.startRun(script);
  7287. });
  7288. return;
  7289. }
  7290. App.startRun(script);
  7291. };
  7292. /**
  7293. * 底部 CTA 的唯一入口:没在跑就尝试开始(跑当前详情页那个任务的脚本),在跑就停。
  7294. * 校验逻辑与任务卡共用 `App.requestRun`,避免两处各写一遍顺序。
  7295. */
  7296. App.toggleRun = function () {
  7297. if (Session.running) {
  7298. App.stopRun();
  7299. App.renderScreen();
  7300. return;
  7301. }
  7302. if (!App.detail) { return; }
  7303. App.requestRun(App.detail);
  7304. };
  7305. /**
  7306. * 真正进入运行态:准备引擎 → 落盘配置 → **唤起引擎** → 建状态条 → 起计时。
  7307. *
  7308. * 顺序不能换:配置必须先落盘(引擎一起来就读它),
  7309. * 计时必须在唤起成功之后才开始(否则引擎没起来,界面却在计时,是最难解释的一种状态)。
  7310. */
  7311. App.startRun = function (task) {
  7312. var plan = Engine.prepare();
  7313. if (!plan.ok) {
  7314. Dialog.engineUnavailable(plan.error);
  7315. return;
  7316. }
  7317. /* runId 要在写配置之前定下来:它会被同时写进 run-config(追溯)与引擎参数(比对) */
  7318. App.pendingRunId = Engine.newRunId();
  7319. if (!App.exportRunConfig([{ tab: task.task, script: task.key, name: task.name }], plan)) {
  7320. toast("\u8FD0\u884C\u914D\u7F6E\u5199\u5165\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u5B58\u50A8\u6743\u9650");
  7321. return;
  7322. }
  7323. if (!Engine.writeConfig(plan.config)) {
  7324. toast("\u5F15\u64CE\u914D\u7F6E\u5199\u5165\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u5B58\u50A8\u6743\u9650");
  7325. return;
  7326. }
  7327. var launched = Engine.launch(App.pendingRunId, plan.scriptPath);
  7328. if (!launched.ok) {
  7329. Dialog.engineUnavailable("\u5F15\u64CE\u542F\u52A8\u5931\u8D25\uFF1A" + launched.error);
  7330. return;
  7331. }
  7332. Session.start(task.task, task.key, task.name, App.pendingRunId);
  7333. Engine.sawChild = false;
  7334. App.lastRunResult = null;
  7335. App.failBar = null;
  7336. App.backgroundWarned = false;
  7337. /* 运行条要能点击跳回**正在跑的那个任务**,所以顺手把它带进那个任务的详情页:
  7338. * 用户刚点了「运行」,看到的应该是"它跑起来了",而不是停在列表上猜。 */
  7339. if (App.detail !== task.task || App.activeTab !== "tasks") {
  7340. App.recordId = "";
  7341. App.detail = task.task;
  7342. App.activeTab = "tasks";
  7343. Store.set("activeTab", "tasks");
  7344. Store.set("activeDetail", task.task);
  7345. }
  7346. console.log("[app] 已唤起引擎 " + plan.scriptPath + " runId=" + App.pendingRunId);
  7347. App.renderScreen();
  7348. FB.sync(); /* 球的外环变青绿 + 开始呼吸(规格书 §10.4) */
  7349. Session.startTimer(App.tickTimer);
  7350. /* notes 是"你勾了、但引擎这次做不到"的项。**如实告知**,
  7351. * 完整内容也写进了 run-config.json 供事后核对。 */
  7352. var summary = Engine.summarizeNotes(plan.notes);
  7353. if (summary) { toast(summary); }
  7354. };
  7355. /**
  7356. * 停运行。
  7357. *
  7358. * 分两步、且**不立刻复位**:先向引擎发出停止请求,界面进入「正在收尾」,
  7359. * 等引擎上报 stopped(或超时)才真正结束。
  7360. *
  7361. * 为什么不做成"点一下就当停了":引擎收到请求后还要退出当前等待、把 TikTok
  7362. * 带回信息流,通常 1 秒内完成,但它确实还在动。这时界面说「已停止」,
  7363. * 用户看到 TikTok 仍在操作,只能怀疑"到底停没停"。
  7364. *
  7365. * **不负责重建界面** —— 调用方要么接着切屏,要么自己补一次重建。
  7366. */
  7367. App.stopRun = function () {
  7368. if (!Session.running) { return; }
  7369. if (Session.stopping) { return; }
  7370. if (Engine.requestStop(Session.runId)) {
  7371. Session.stopping = true;
  7372. Session.stopRequestedAt = Date.now();
  7373. console.log("[app] 已发出停止请求 runId=" + Session.runId);
  7374. return;
  7375. }
  7376. /* 写标志都失败了就没什么可等的,直接复位 */
  7377. App.finishRun({ status: "stopped", message: "\u5DF2\u505C\u6B62" });
  7378. };
  7379. /* ------------------------------------------------------------ 权限弹窗 */
  7380. /** 打开权限弹窗。弹窗自己也保证同时只存在一个,这里再挡一道更直观。 */
  7381. App.openPermissions = function () {
  7382. if (Dialog.active) { return; }
  7383. Dialog.showPermissions();
  7384. };
  7385. /* ------------------------------------------------------------ 卡密登录 */
  7386. /** 切到登录页。重置三态,避免把上一次的失败提示带过来。 */
  7387. App.showLogin = function () {
  7388. App.screen = "login";
  7389. /* 登录页不该有球:卡密都没了就没什么可控制的,留在屏幕上只是挡路 */
  7390. FB.destroy();
  7391. Pages.LICENSE_RESET();
  7392. App.renderScreen();
  7393. };
  7394. /** 登录成功:回主界面,并顺手把还缺的权限提示一次。 */
  7395. App.enterApp = function () {
  7396. App.screen = "app";
  7397. /* 进主界面就把悬浮球挂上(它只在"卡密有效 + 有悬浮窗权限"时才会真的建,见 FB.canShow) */
  7398. FB.ensure();
  7399. /* 把登录页三态复位。不复位的话它会停在 verifying ——
  7400. * 一旦有别的路径重新渲染登录页(而不是走 showLogin),
  7401. * 用户看到的就是一个永远「登录中…」的界面。 */
  7402. Pages.LICENSE_RESET();
  7403. App.renderScreen();
  7404. if (!Device.allPermissionsGranted()) { App.openPermissions(); }
  7405. };
  7406. /**
  7407. * 提交卡密。
  7408. *
  7409. * 为什么中间要留 450ms:校验走本地时是毫秒级的,界面会从「登录激活」
  7410. * 直接跳到主界面,用户根本看不到反馈——会以为按钮没反应于是连点。
  7411. * 留这一帧让「登录中…」真的被画出来,是**交互正确性**而不是动画点缀。
  7412. */
  7413. App.submitLicense = function (code) {
  7414. var view = Pages.LICENSE_VIEW;
  7415. if (typeof code !== "string" || code.replace(/[\s-]/g, "") === "") {
  7416. toast("\u8BF7\u8F93\u5165\u5361\u5BC6");
  7417. return;
  7418. }
  7419. view.state = "verifying";
  7420. App.renderScreen();
  7421. setTimeout(function () {
  7422. var result = Service.login(code);
  7423. if (result.ok) {
  7424. Store.set("license.status", result.status);
  7425. Store.set("license.code", result.code);
  7426. Store.set("license.expireAt", result.expireAt);
  7427. /* ★ 这里**不要**去清 `license.draftCode`。
  7428. * 试过「激活成功后清空草稿」,真机上立刻被打回来:
  7429. * `App.enterApp()` → `renderContent()` 会先跑 `commitPendingInputs()`,
  7430. * 而登录页那个输入框此刻**还在待提交表里**,于是刚清掉的草稿
  7431. * 又被它自己的文字写回去(顺序反了,清理等于没做)。
  7432. * 保留草稿也更顺手:解绑后回到登录页,上次的卡密已经填好,一键即可重新激活;
  7433. * 它和 `license.code` 本来就在同一个 JSON 里,多留一份不增加暴露面。 */
  7434. toast(result.message);
  7435. App.enterApp();
  7436. return;
  7437. }
  7438. if (result.status === "expired") {
  7439. Store.set("license.status", "expired");
  7440. Store.set("license.expireAt", result.expireAt || "");
  7441. }
  7442. view.state = "failed";
  7443. view.message = result.message;
  7444. App.renderScreen();
  7445. }, 450);
  7446. };
  7447. /** 购买入口。没有可用的客服渠道就如实说,不编一个不存在的联系方式。 */
  7448. App.showPurchaseInfo = function () {
  7449. Dialog.alert("\u8D2D\u4E70\u5361\u5BC6",
  7450. "\u5F53\u524D\u672A\u914D\u7F6E\u5728\u7EBF\u8D2D\u4E70\u6E20\u9053\u3002\n\n" +
  7451. "\u914D\u7F6E\u540E\uFF0C\u8FD9\u91CC\u4F1A\u76F4\u63A5\u6253\u5F00\u5BA2\u670D\u4F1A\u8BDD\u6216\u8D2D\u4E70\u9875\u3002");
  7452. };
  7453. /** 退出应用。优先用 ui.finish(),它才是 UI 模式下的正确出口。 */
  7454. App.exit = function () {
  7455. /* 退出前必须关掉悬浮窗:脚本结束不会自动带走它,留着就是一个"孤儿球" */
  7456. FB.destroy();
  7457. try {
  7458. if (typeof ui.finish === "function") { ui.finish(); return; }
  7459. } catch (e) {
  7460. console.log("[app] ui.finish 不可用: " + e);
  7461. }
  7462. try {
  7463. activity.finish();
  7464. } catch (e2) {
  7465. console.log("[app] 退出失败: " + e2);
  7466. }
  7467. };
  7468. /* ------------------------------------------------------------ 结果文件 / 日志动作 */
  7469. /**
  7470. * 打开结果文件(D3 的「打开」)。
  7471. *
  7472. * 文件可能已经不在了(被清理/被移走),所以先 exists 再动手 ——
  7473. * 直接 startActivity 一个不存在的 file:// 在部分 ROM 上会抛 FileUriExposedException。
  7474. */
  7475. App.openResultFile = function (path) {
  7476. if (!path) { return; }
  7477. try {
  7478. if (!files.exists(path)) {
  7479. Dialog.alert("\u6587\u4EF6\u4E0D\u5B58\u5728",
  7480. "\u8FD9\u4E2A\u7ED3\u679C\u6587\u4EF6\u5DF2\u4E0D\u5728\u672C\u673A\uFF1A\n\n" + path);
  7481. return;
  7482. }
  7483. app.viewFile(path);
  7484. } catch (e) {
  7485. console.log("[app] 打开结果文件失败: " + e);
  7486. toast("\u65E0\u6CD5\u6253\u5F00\u8BE5\u6587\u4EF6");
  7487. }
  7488. };
  7489. /** 分享结果文件(D3 的「分享」)。系统分享面板由 ROM 提供,不自己造一个。 */
  7490. App.shareResultFile = function (path) {
  7491. if (!path) { return; }
  7492. try {
  7493. if (!files.exists(path)) {
  7494. toast("\u6587\u4EF6\u4E0D\u5B58\u5728\uFF0C\u65E0\u6CD5\u5206\u4EAB");
  7495. return;
  7496. }
  7497. app.sendIntent({
  7498. action: "android.intent.action.SEND",
  7499. type: "*/*",
  7500. extra: { "android.intent.extra.STREAM": path }
  7501. });
  7502. } catch (e) {
  7503. console.log("[app] 分享失败: " + e);
  7504. toast("\u5206\u4EAB\u5931\u8D25");
  7505. }
  7506. };
  7507. /**
  7508. * 复制整段日志(D3 的「复制」)。
  7509. *
  7510. * 拼成纯文本而不是 JSON:用户多半是要粘到聊天窗口里给人看,
  7511. * 带花括号的 JSON 反而难读。
  7512. */
  7513. App.copyLogs = function (record) {
  7514. if (!record) { return; }
  7515. var logs = record.logs || [];
  7516. if (logs.length === 0) {
  7517. toast("\u672C\u6B21\u8FD0\u884C\u6CA1\u6709\u65E5\u5FD7");
  7518. return;
  7519. }
  7520. var lines = [];
  7521. for (var i = 0; i < logs.length; i++) {
  7522. var e = logs[i];
  7523. lines.push(String(e.text || ""));
  7524. }
  7525. var text = Pages.recordTitle(record) + "\n" + Records.timeLine(record) + "\n\n" + lines.join("\n");
  7526. try {
  7527. /* setClip 是 AutoX 的全局函数;桩测环境没有它,所以带类型判断 */
  7528. if (typeof setClip === "function") {
  7529. setClip(text);
  7530. toast("\u65E5\u5FD7\u5DF2\u590D\u5236");
  7531. } else {
  7532. toast("\u5F53\u524D\u8FD0\u884C\u65F6\u4E0D\u652F\u6301\u526A\u8D34\u677F");
  7533. }
  7534. } catch (e2) {
  7535. console.log("[app] 复制日志失败: " + e2);
  7536. toast("\u590D\u5236\u5931\u8D25");
  7537. }
  7538. };
  7539. /* ------------------------------------------------------------ 启动 */
  7540. /**
  7541. * 挂载根视图。
  7542. * ui.setContentView 是标准 API;若运行时确实没有,宁可明确报错,
  7543. * 也不要退回一个半可用的挂载方式——那样界面起不来还找不到原因。
  7544. */
  7545. App.mount = function (root) {
  7546. if (typeof ui.setContentView !== "function") {
  7547. throw new Error("当前 AutoX 运行时缺少 ui.setContentView,无法挂载界面");
  7548. }
  7549. ui.setContentView(root);
  7550. };
  7551. App.main = function () {
  7552. Store.init();
  7553. /* getEnum 保证拿回合法 tab key:旧配置里存了已删除的页签也不会白屏。
  7554. * 老页签(id/dm/nurture/video)由 Store.MIGRATIONS[5] 映射成 tasks(见 store.js)。 */
  7555. var configuredTab = Store.getEnum("activeTab", "tab");
  7556. App.activeTab = App.TAB_PAGES[configuredTab] ? configuredTab : "tasks";
  7557. /* 二级页也要能冷启动恢复 —— 但必须是**认识的那个任务**。
  7558. * 配置被手改过时回退到一级页,而不是渲染一个空白详情页。 */
  7559. var configuredDetail = Store.getEnum("activeDetail", ["", "id", "dm", "nurture", "video"]);
  7560. App.detail = App.DETAIL_PAGES[configuredDetail] ? configuredDetail : "";
  7561. /* 返回键:有弹窗就先关弹窗,其次退二级页,都没有则交给系统(退出界面)。
  7562. * 用 emitter 而不是 View.OnKeyListener —— 后者要构造一个 Java 接口实例,
  7563. * 写法依赖运行时的接口包装能力,换个 AutoX 版本就可能失效。 */
  7564. ui.emitter.on("back_pressed", function (event) {
  7565. if (Dialog.active) {
  7566. event.consumed = true;
  7567. Dialog.active.close();
  7568. return;
  7569. }
  7570. if (App.recordId) {
  7571. event.consumed = true;
  7572. App.closeRecord();
  7573. return;
  7574. }
  7575. if (App.detail) {
  7576. event.consumed = true;
  7577. App.closeDetail();
  7578. }
  7579. });
  7580. /* 界面回到前台时提醒一句(只在引擎正在跑时)。
  7581. * 这是 UI 生命周期事件,不是轮询:用户切走再切回才会触发,代价为零。 */
  7582. ui.emitter.on("resume", function () {
  7583. App.noticeIfRunningInBackground();
  7584. /* 补建:用户多半是先去系统设置里开了悬浮窗权限、再切回来的。
  7585. * ensure() 幂等,已经建好时只做一次状态同步。 */
  7586. FB.ensure();
  7587. });
  7588. ui.statusBarColor(U.argb(Theme.hex.bg));
  7589. App.mount(App.build());
  7590. /* 弹窗需要一个浮层宿主,骨架搭好之后注入 */
  7591. Dialog.mount(App.overlayHost);
  7592. /* 启动时的屏:卡密没激活就直接进登录页,不让用户先进一个什么都点不动的主界面 */
  7593. App.screen = Service.licenseStatus().status === "active" ? "app" : "login";
  7594. /* 冷启动也是"要用球"的场景:已激活时直接把它挂上(未激活时 canShow 自己会拦住) */
  7595. FB.ensure();
  7596. App.renderScreen();
  7597. console.log("[app] 界面已就绪,screen=" + App.screen + " 页签=" + App.activeTab);
  7598. console.log("[app] 无障碍=" + Device.a11yEnabled() +
  7599. " 悬浮窗=" + Device.overlayEnabled() +
  7600. " 通知=" + Device.notifyEnabled());
  7601. if (App.screen === "app" && !Device.allPermissionsGranted()) {
  7602. App.openPermissions();
  7603. }
  7604. /* 落盘一份启动结论,便于 PC 侧离线核验(日志链路可能丢) */
  7605. try {
  7606. files.write("/sdcard/tiktok_app_ui_result.txt",
  7607. "OK_UI_STARTED\nscreen=" + App.screen +
  7608. "\ntab=" + App.activeTab +
  7609. "\na11y=" + Device.a11yEnabled() +
  7610. "\nlicense=" + Service.licenseStatus().status +
  7611. "\ntiktok=" + JSON.stringify(Device.tiktok()));
  7612. } catch (e) {
  7613. console.log("[app] 结果文件写入失败: " + e);
  7614. }
  7615. };
  7616. /* ---------------------------------------------------------------------------
  7617. * 测试钩子(规范 R16:让纯函数与内部状态可被单独驱动)
  7618. *
  7619. * PC 侧 tools/smoke.js 会先设 __FEIZHOU_TEST__ 再加载产物,
  7620. * 借此拿到 IIFE 内部的对象做断言。设备端不会设这个标志,
  7621. * 因此对实际运行零影响、零开销。
  7622. * ------------------------------------------------------------------------- */
  7623. if (typeof globalThis !== "undefined" && globalThis.__FEIZHOU_TEST__) {
  7624. globalThis.__FEIZHOU__ = {
  7625. Theme: Theme,
  7626. Store: Store,
  7627. Records: Records,
  7628. Device: Device,
  7629. Service: Service,
  7630. Engine: Engine,
  7631. Session: Session,
  7632. W: W,
  7633. Dialog: Dialog,
  7634. Pages: Pages,
  7635. FB: FB,
  7636. App: App
  7637. };
  7638. }
  7639. /* 入口 */
  7640. App.main();
  7641. }());