app_admin.js 146 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549
  1. async function pageAdminLogin() {
  2. const username = document.getElementById("username");
  3. const password = document.getElementById("password");
  4. const btn = document.getElementById("adminLoginBtn");
  5. const msg = document.getElementById("msg");
  6. btn.addEventListener("click", async () => {
  7. msg.textContent = "";
  8. try {
  9. await apiFetch("/admin/auth/login", {
  10. method: "POST",
  11. body: { username: username.value.trim(), password: password.value },
  12. });
  13. window.location.href = "/ui/admin";
  14. } catch (e) {
  15. msg.textContent = `登录失败:${e.detail?.error || e.status || "unknown"}`;
  16. }
  17. });
  18. }
  19. function renderJsonCard(title, obj) {
  20. return el("div", { class: "card" }, el("div", {}, title), el("pre", { class: "code" }, JSON.stringify(obj, null, 2)));
  21. }
  22. async function pageAdmin() {
  23. const overviewRefreshBtn = document.getElementById("overviewRefreshBtn");
  24. const overviewUpdatedAt = document.getElementById("overviewUpdatedAt");
  25. const ovUsersTotal = document.getElementById("ovUsersTotal");
  26. const ovUsersSub = document.getElementById("ovUsersSub");
  27. const ovResourcesTotal = document.getElementById("ovResourcesTotal");
  28. const ovResourcesSub = document.getElementById("ovResourcesSub");
  29. const ovOrdersTotal = document.getElementById("ovOrdersTotal");
  30. const ovOrdersSub = document.getElementById("ovOrdersSub");
  31. const ovRevenueTotal = document.getElementById("ovRevenueTotal");
  32. const ovRevenueSub = document.getElementById("ovRevenueSub");
  33. const ovDownloadsTotal = document.getElementById("ovDownloadsTotal");
  34. const ovDownloadsSub = document.getElementById("ovDownloadsSub");
  35. const ovMessagesTotal = document.getElementById("ovMessagesTotal");
  36. const ovMessagesSub = document.getElementById("ovMessagesSub");
  37. const ovSystemInfo = document.getElementById("ovSystemInfo");
  38. const createPlanOpenBtn = document.getElementById("createPlanOpenBtn");
  39. const createResOpenBtn = document.getElementById("createResOpenBtn");
  40. const resQ = document.getElementById("resQ");
  41. const resTypeFilter = document.getElementById("resTypeFilter");
  42. const resStatusFilter = document.getElementById("resStatusFilter");
  43. const resSearchBtn = document.getElementById("resSearchBtn");
  44. const resPrevPage = document.getElementById("resPrevPage");
  45. const resNextPage = document.getElementById("resNextPage");
  46. const resPageInfo = document.getElementById("resPageInfo");
  47. const uploadsQ = document.getElementById("uploadsQ");
  48. const uploadsFilterAll = document.getElementById("uploadsFilterAll");
  49. const uploadsFilterUnused = document.getElementById("uploadsFilterUnused");
  50. const uploadsFilterUsed = document.getElementById("uploadsFilterUsed");
  51. const uploadsRefreshBtn = document.getElementById("uploadsRefreshBtn");
  52. const uploadsUploadBtn = document.getElementById("uploadsUploadBtn");
  53. const uploadsFile = document.getElementById("uploadsFile");
  54. const uploadsCleanupBtn = document.getElementById("uploadsCleanupBtn");
  55. const uploadsStats = document.getElementById("uploadsStats");
  56. const uploadTbody = document.querySelector("#uploadTable tbody");
  57. const orderQ = document.getElementById("orderQ");
  58. const orderStatusFilter = document.getElementById("orderStatusFilter");
  59. const orderCreateBtn = document.getElementById("orderCreateBtn");
  60. const orderRefreshBtn = document.getElementById("orderRefreshBtn");
  61. const orderPrevPage = document.getElementById("orderPrevPage");
  62. const orderNextPage = document.getElementById("orderNextPage");
  63. const orderPageInfo = document.getElementById("orderPageInfo");
  64. const userQ = document.getElementById("userQ");
  65. const userStatusFilter = document.getElementById("userStatusFilter");
  66. const userVipFilter = document.getElementById("userVipFilter");
  67. const userSearchBtn = document.getElementById("userSearchBtn");
  68. const userPrevPage = document.getElementById("userPrevPage");
  69. const userNextPage = document.getElementById("userNextPage");
  70. const userPageInfo = document.getElementById("userPageInfo");
  71. const dlQ = document.getElementById("dlQ");
  72. const dlTypeFilter = document.getElementById("dlTypeFilter");
  73. const dlStateFilter = document.getElementById("dlStateFilter");
  74. const dlSearchBtn = document.getElementById("dlSearchBtn");
  75. const dlPrevPage = document.getElementById("dlPrevPage");
  76. const dlNextPage = document.getElementById("dlNextPage");
  77. const dlPageInfo = document.getElementById("dlPageInfo");
  78. const msgQ = document.getElementById("msgQ");
  79. const msgReadFilter = document.getElementById("msgReadFilter");
  80. const msgSenderFilter = document.getElementById("msgSenderFilter");
  81. const msgSearchBtn = document.getElementById("msgSearchBtn");
  82. const msgSendBtn = document.getElementById("msgSendBtn");
  83. const msgBroadcastBtn = document.getElementById("msgBroadcastBtn");
  84. const msgPrevPage = document.getElementById("msgPrevPage");
  85. const msgNextPage = document.getElementById("msgNextPage");
  86. const msgPageInfo = document.getElementById("msgPageInfo");
  87. const msgTbody = document.querySelector("#msgTable tbody");
  88. const settingsRefreshBtn = document.getElementById("settingsRefreshBtn");
  89. const settingsSaveBtn = document.getElementById("settingsSaveBtn");
  90. const cfgGogsSaveBtn = document.getElementById("cfgGogsSaveBtn");
  91. const cfgGogsResetBtn = document.getElementById("cfgGogsResetBtn");
  92. const cfgGogsBaseUrl = document.getElementById("cfgGogsBaseUrl");
  93. const cfgGogsToken = document.getElementById("cfgGogsToken");
  94. const cfgClearGogsToken = document.getElementById("cfgClearGogsToken");
  95. const cfgPaySaveBtn = document.getElementById("cfgPaySaveBtn");
  96. const cfgPayResetBtn = document.getElementById("cfgPayResetBtn");
  97. const cfgPayProvider = document.getElementById("cfgPayProvider");
  98. const cfgEnableMockPay = document.getElementById("cfgEnableMockPay");
  99. const cfgPayApiKey = document.getElementById("cfgPayApiKey");
  100. const cfgClearPayApiKey = document.getElementById("cfgClearPayApiKey");
  101. const cfgAlipayFields = document.getElementById("cfgAlipayFields");
  102. const cfgAlipayAppId = document.getElementById("cfgAlipayAppId");
  103. const cfgAlipayGateway = document.getElementById("cfgAlipayGateway");
  104. const cfgAlipayNotifyUrl = document.getElementById("cfgAlipayNotifyUrl");
  105. const cfgAlipayReturnUrl = document.getElementById("cfgAlipayReturnUrl");
  106. const cfgAlipayUseCurrentNotify = document.getElementById("cfgAlipayUseCurrentNotify");
  107. const cfgAlipayUseCurrentReturn = document.getElementById("cfgAlipayUseCurrentReturn");
  108. const cfgAlipayPrivateKey = document.getElementById("cfgAlipayPrivateKey");
  109. const cfgClearAlipayPrivateKey = document.getElementById("cfgClearAlipayPrivateKey");
  110. const cfgAlipayPublicKey = document.getElementById("cfgAlipayPublicKey");
  111. const cfgClearAlipayPublicKey = document.getElementById("cfgClearAlipayPublicKey");
  112. const cfgShowAlipayPrivateKey = document.getElementById("cfgShowAlipayPrivateKey");
  113. const cfgShowAlipayPublicKey = document.getElementById("cfgShowAlipayPublicKey");
  114. const cfgLlmSaveBtn = document.getElementById("cfgLlmSaveBtn");
  115. const cfgLlmResetBtn = document.getElementById("cfgLlmResetBtn");
  116. const cfgLlmProvider = document.getElementById("cfgLlmProvider");
  117. const cfgLlmBaseUrl = document.getElementById("cfgLlmBaseUrl");
  118. const cfgLlmModel = document.getElementById("cfgLlmModel");
  119. const cfgLlmApiKey = document.getElementById("cfgLlmApiKey");
  120. const cfgClearLlmApiKey = document.getElementById("cfgClearLlmApiKey");
  121. const cfgCacheSaveBtn = document.getElementById("cfgCacheSaveBtn");
  122. const cfgCacheResetBtn = document.getElementById("cfgCacheResetBtn");
  123. const cfgRedisUrl = document.getElementById("cfgRedisUrl");
  124. const cfgClearRedisUrl = document.getElementById("cfgClearRedisUrl");
  125. const cfgRedisTestBtn = document.getElementById("cfgRedisTestBtn");
  126. const cfgCacheMsg = document.getElementById("cfgCacheMsg");
  127. const cfgStorageSaveBtn = document.getElementById("cfgStorageSaveBtn");
  128. const cfgStorageResetBtn = document.getElementById("cfgStorageResetBtn");
  129. const cfgStorageProvider = document.getElementById("cfgStorageProvider");
  130. const cfgOssEndpoint = document.getElementById("cfgOssEndpoint");
  131. const cfgOssBucket = document.getElementById("cfgOssBucket");
  132. const cfgOssAccessKeyId = document.getElementById("cfgOssAccessKeyId");
  133. const cfgOssAccessKeySecret = document.getElementById("cfgOssAccessKeySecret");
  134. const cfgClearOssAccessKeySecret = document.getElementById("cfgClearOssAccessKeySecret");
  135. const cfgOssUploadPrefix = document.getElementById("cfgOssUploadPrefix");
  136. const cfgOssPublicBaseUrl = document.getElementById("cfgOssPublicBaseUrl");
  137. const cfgDbActive = document.getElementById("cfgDbActive");
  138. const cfgDbSaveBtn = document.getElementById("cfgDbSaveBtn");
  139. const cfgDbResetBtn = document.getElementById("cfgDbResetBtn");
  140. const cfgMysqlHost = document.getElementById("cfgMysqlHost");
  141. const cfgMysqlPort = document.getElementById("cfgMysqlPort");
  142. const cfgMysqlUser = document.getElementById("cfgMysqlUser");
  143. const cfgMysqlPassword = document.getElementById("cfgMysqlPassword");
  144. const cfgClearMysqlPassword = document.getElementById("cfgClearMysqlPassword");
  145. const cfgMysqlDatabase = document.getElementById("cfgMysqlDatabase");
  146. const cfgMysqlTestBtn = document.getElementById("cfgMysqlTestBtn");
  147. const cfgDbSwitchMysqlBtn = document.getElementById("cfgDbSwitchMysqlBtn");
  148. const cfgDbSwitchSqliteBtn = document.getElementById("cfgDbSwitchSqliteBtn");
  149. const settingsMsg = document.getElementById("settingsMsg");
  150. const cfgSearch = document.getElementById("cfgSearch");
  151. const cfgGroupNav = document.getElementById("cfgGroupNav");
  152. const settingsGroupsWrap = document.getElementById("settingsGroups");
  153. const adminLogoutBtn = document.getElementById("adminLogoutBtn");
  154. const menu = document.getElementById("adminMenu");
  155. const contentTitle = document.getElementById("contentTitle");
  156. const modalBackdrop = document.getElementById("adminModalBackdrop");
  157. const modalTitle = document.getElementById("adminModalTitle");
  158. const modalHeaderActions = document.getElementById("adminModalHeaderActions");
  159. const modalClose = document.getElementById("adminModalClose");
  160. const modalBody = document.getElementById("adminModalBody");
  161. const modalFooter = document.getElementById("adminModalFooter");
  162. const modalEl = modalBackdrop ? modalBackdrop.querySelector(".modal") : null;
  163. let currentModalOnResize = null;
  164. let currentModalBeforeClose = null;
  165. let currentModalOnKeydown = null;
  166. const planMap = new Map();
  167. const resourceMap = new Map();
  168. const userMap = new Map();
  169. const orderMap = new Map();
  170. const downloadLogMap = new Map();
  171. const messageMap = new Map();
  172. const resState = { page: 1, pageSize: 20, total: 0 };
  173. const userState = { page: 1, pageSize: 20, total: 0 };
  174. const orderState = { page: 1, pageSize: 20, total: 0 };
  175. const downloadLogState = { page: 1, pageSize: 20, total: 0 };
  176. const messageState = { page: 1, pageSize: 20, total: 0 };
  177. const uploadsState = { filter: "all" };
  178. let lastSettingsSnapshot = null;
  179. function formatBytes(bytes) {
  180. const n = Number(bytes || 0);
  181. if (!Number.isFinite(n) || n <= 0) return "0 B";
  182. const units = ["B", "KB", "MB", "GB", "TB"];
  183. let v = n;
  184. let i = 0;
  185. while (v >= 1024 && i < units.length - 1) {
  186. v /= 1024;
  187. i += 1;
  188. }
  189. const fixed = i === 0 ? 0 : v >= 10 ? 1 : 2;
  190. return `${v.toFixed(fixed)} ${units[i]}`;
  191. }
  192. async function copyText(text) {
  193. const s = String(text || "");
  194. if (!s) return;
  195. try {
  196. await navigator.clipboard.writeText(s);
  197. showToastSuccess("已复制链接");
  198. return;
  199. } catch (e) {}
  200. const ta = el("textarea", { style: "position:fixed; left:-9999px; top:-9999px;" }, s);
  201. document.body.appendChild(ta);
  202. ta.select();
  203. try {
  204. document.execCommand("copy");
  205. showToastSuccess("已复制链接");
  206. } catch (e) {
  207. showToastError("复制失败");
  208. } finally {
  209. ta.remove();
  210. }
  211. }
  212. function setUploadsFilter(next) {
  213. uploadsState.filter = next;
  214. [uploadsFilterAll, uploadsFilterUnused, uploadsFilterUsed].forEach((b) => b.classList.remove("active"));
  215. if (next === "unused") uploadsFilterUnused.classList.add("active");
  216. else if (next === "used") uploadsFilterUsed.classList.add("active");
  217. else uploadsFilterAll.classList.add("active");
  218. }
  219. async function loadUploads() {
  220. uploadsStats.textContent = "";
  221. uploadTbody.innerHTML = "";
  222. try {
  223. const params = new URLSearchParams();
  224. const q = (uploadsQ.value || "").trim();
  225. if (q) params.set("q", q);
  226. if (uploadsState.filter === "unused") params.set("used", "unused");
  227. if (uploadsState.filter === "used") params.set("used", "used");
  228. const resp = await apiFetch(`/admin/uploads?${params.toString()}`);
  229. const s = resp.stats || {};
  230. uploadsStats.textContent = [
  231. `共 ${s.totalCount ?? 0} 个文件(${formatBytes(s.totalBytes ?? 0)})`,
  232. `已引用 ${s.usedCount ?? 0} 个(${formatBytes(s.usedBytes ?? 0)})`,
  233. `未引用 ${s.unusedCount ?? 0} 个(${formatBytes(s.unusedBytes ?? 0)})`,
  234. ].join(" / ");
  235. const items = Array.isArray(resp.items) ? resp.items : [];
  236. items.forEach((it) => {
  237. const name = String(it.name || "");
  238. const url = String(it.url || "");
  239. const used = Boolean(it.used);
  240. const kind = String(it.kind || "file");
  241. const preview =
  242. kind === "image"
  243. ? el("img", { class: "upload-thumb", src: url, alt: name, loading: "lazy" })
  244. : kind === "video"
  245. ? badge("视频", "badge-warning")
  246. : badge("文件");
  247. const usedBadge = used ? badge("已引用", "badge-success") : badge("未引用", "badge");
  248. const tr = el(
  249. "tr",
  250. {},
  251. el("td", {}, preview),
  252. el("td", {}, el("div", { class: "upload-name" }, name), el("div", { class: "muted upload-url" }, url)),
  253. el("td", {}, formatBytes(it.bytes || 0)),
  254. el("td", {}, formatDateTime(it.mtime || 0)),
  255. el("td", {}, usedBadge),
  256. el(
  257. "td",
  258. {},
  259. btnGroup(
  260. el("button", { class: "btn btn-sm", onclick: () => copyText(url) }, "复制链接"),
  261. el(
  262. "button",
  263. {
  264. class: "btn btn-sm btn-danger",
  265. onclick: async () => {
  266. const r = await Swal.fire({
  267. title: "删除文件?",
  268. text: `将删除:${name}`,
  269. icon: "warning",
  270. showCancelButton: true,
  271. confirmButtonText: "删除",
  272. cancelButtonText: "取消",
  273. confirmButtonColor: "var(--danger)",
  274. });
  275. if (!r.isConfirmed) return;
  276. await apiFetch(`/admin/uploads/${encodeURIComponent(name)}`, { method: "DELETE" });
  277. showToastSuccess("已删除");
  278. await loadUploads();
  279. },
  280. },
  281. "删除"
  282. )
  283. )
  284. )
  285. );
  286. uploadTbody.appendChild(tr);
  287. });
  288. if (!items.length) renderEmptyRow(uploadTbody, 6, "暂无数据");
  289. } catch (e) {
  290. uploadsStats.textContent = `加载失败:${e.detail?.error || e.status || "unknown"}`;
  291. if (e.status === 401) window.location.href = "/ui/admin/login";
  292. }
  293. }
  294. async function loadSettings() {
  295. settingsMsg.textContent = "";
  296. if (cfgCacheMsg) cfgCacheMsg.textContent = "";
  297. cfgGogsToken.value = "";
  298. cfgClearGogsToken.checked = false;
  299. cfgPayApiKey.value = "";
  300. cfgClearPayApiKey.checked = false;
  301. if (cfgAlipayPrivateKey) cfgAlipayPrivateKey.value = "";
  302. if (cfgClearAlipayPrivateKey) cfgClearAlipayPrivateKey.checked = false;
  303. if (cfgShowAlipayPrivateKey) cfgShowAlipayPrivateKey.checked = false;
  304. if (cfgAlipayPrivateKey) cfgAlipayPrivateKey.classList.remove("is-revealed");
  305. if (cfgAlipayPublicKey) cfgAlipayPublicKey.value = "";
  306. if (cfgClearAlipayPublicKey) cfgClearAlipayPublicKey.checked = false;
  307. if (cfgShowAlipayPublicKey) cfgShowAlipayPublicKey.checked = false;
  308. if (cfgAlipayPublicKey) cfgAlipayPublicKey.classList.remove("is-revealed");
  309. cfgLlmApiKey.value = "";
  310. cfgClearLlmApiKey.checked = false;
  311. if (cfgRedisUrl) cfgRedisUrl.value = "";
  312. if (cfgClearRedisUrl) cfgClearRedisUrl.checked = false;
  313. if (cfgStorageProvider) cfgStorageProvider.value = "AUTO";
  314. if (cfgOssEndpoint) cfgOssEndpoint.value = "";
  315. if (cfgOssBucket) cfgOssBucket.value = "";
  316. if (cfgOssAccessKeyId) cfgOssAccessKeyId.value = "";
  317. if (cfgOssAccessKeySecret) cfgOssAccessKeySecret.value = "";
  318. if (cfgClearOssAccessKeySecret) cfgClearOssAccessKeySecret.checked = false;
  319. if (cfgOssUploadPrefix) cfgOssUploadPrefix.value = "";
  320. if (cfgOssPublicBaseUrl) cfgOssPublicBaseUrl.value = "";
  321. if (cfgMysqlPassword) cfgMysqlPassword.value = "";
  322. if (cfgClearMysqlPassword) cfgClearMysqlPassword.checked = false;
  323. try {
  324. const resp = await apiFetch("/admin/settings");
  325. lastSettingsSnapshot = resp;
  326. cfgGogsBaseUrl.value = (resp.gogsBaseUrl || "").trim();
  327. if (resp.hasGogsToken) cfgGogsToken.placeholder = "已配置,留空保持不变";
  328. else cfgGogsToken.placeholder = "未配置,填写后保存";
  329. cfgPayProvider.value = (resp.payment?.provider || "MOCK").toUpperCase();
  330. cfgEnableMockPay.checked = Boolean(resp.payment?.enableMockPay);
  331. if (resp.payment?.hasApiKey) cfgPayApiKey.placeholder = "已配置,留空保持不变";
  332. else cfgPayApiKey.placeholder = "未配置,填写后保存";
  333. if (cfgAlipayAppId) cfgAlipayAppId.value = (resp.payment?.alipay?.appId || "").trim();
  334. if (cfgAlipayGateway) cfgAlipayGateway.value = (resp.payment?.alipay?.gateway || "").trim();
  335. if (cfgAlipayNotifyUrl) cfgAlipayNotifyUrl.value = (resp.payment?.alipay?.notifyUrl || "").trim();
  336. if (cfgAlipayReturnUrl) cfgAlipayReturnUrl.value = (resp.payment?.alipay?.returnUrl || "").trim();
  337. if (cfgAlipayPrivateKey) {
  338. if (resp.payment?.alipay?.hasPrivateKey) cfgAlipayPrivateKey.placeholder = "已配置,留空保持不变";
  339. else cfgAlipayPrivateKey.placeholder = "未配置,填写后保存";
  340. }
  341. if (cfgAlipayPublicKey) {
  342. if (resp.payment?.alipay?.hasPublicKey) cfgAlipayPublicKey.placeholder = "已配置,留空保持不变";
  343. else cfgAlipayPublicKey.placeholder = "未配置,填写后保存";
  344. }
  345. cfgLlmProvider.value = (resp.llm?.provider || "").trim();
  346. cfgLlmBaseUrl.value = (resp.llm?.baseUrl || "").trim();
  347. cfgLlmModel.value = (resp.llm?.model || "").trim();
  348. if (resp.llm?.hasApiKey) cfgLlmApiKey.placeholder = "已配置,留空保持不变";
  349. else cfgLlmApiKey.placeholder = "未配置,填写后保存";
  350. if (cfgRedisUrl) cfgRedisUrl.value = "";
  351. if (cfgClearRedisUrl) cfgClearRedisUrl.checked = false;
  352. if (cfgRedisUrl) {
  353. const safe = String(resp.cache?.redisUrl || "").trim();
  354. if (resp.cache?.hasRedisUrl) cfgRedisUrl.placeholder = safe ? `已配置,留空保持不变(${safe})` : "已配置,留空保持不变";
  355. else cfgRedisUrl.placeholder = "例如:redis://127.0.0.1:6379/0(无密码)或 redis://:password@127.0.0.1:6379/0(有密码)";
  356. }
  357. if (cfgStorageProvider) cfgStorageProvider.value = String(resp.storage?.provider || "AUTO").trim().toUpperCase() || "AUTO";
  358. if (cfgOssEndpoint) cfgOssEndpoint.value = String(resp.storage?.oss?.endpoint || "").trim();
  359. if (cfgOssBucket) cfgOssBucket.value = String(resp.storage?.oss?.bucket || "").trim();
  360. if (cfgOssAccessKeyId) cfgOssAccessKeyId.value = String(resp.storage?.oss?.accessKeyId || "").trim();
  361. if (cfgOssUploadPrefix) cfgOssUploadPrefix.value = String(resp.storage?.oss?.uploadPrefix || "").trim();
  362. if (cfgOssPublicBaseUrl) cfgOssPublicBaseUrl.value = String(resp.storage?.oss?.publicBaseUrl || "").trim();
  363. if (cfgOssAccessKeySecret) {
  364. if (resp.storage?.oss?.hasAccessKeySecret) cfgOssAccessKeySecret.placeholder = "已配置,留空保持不变";
  365. else cfgOssAccessKeySecret.placeholder = "未配置,填写后保存";
  366. }
  367. if (cfgMysqlHost) cfgMysqlHost.value = (resp.db?.mysql?.host || "").trim();
  368. if (cfgMysqlPort) cfgMysqlPort.value = String(resp.db?.mysql?.port ?? "").trim();
  369. if (cfgMysqlUser) cfgMysqlUser.value = (resp.db?.mysql?.user || "").trim();
  370. if (cfgMysqlDatabase) cfgMysqlDatabase.value = (resp.db?.mysql?.database || "").trim();
  371. if (cfgMysqlPassword) {
  372. if (resp.db?.mysql?.hasPassword) cfgMysqlPassword.placeholder = "已配置,留空保持不变";
  373. else cfgMysqlPassword.placeholder = "未配置,填写后保存";
  374. }
  375. const configActiveDb = String(resp.db?.active || "").trim().toLowerCase();
  376. let effectiveDb = configActiveDb;
  377. let connectOk = true;
  378. let connectErr = "";
  379. try {
  380. const st = await apiFetch("/admin/db/status");
  381. effectiveDb = String(st.probe?.effective || configActiveDb || "").trim().toLowerCase() || configActiveDb;
  382. connectOk = Boolean(st.probe?.connectOk);
  383. connectErr = String(st.probe?.error || "").trim();
  384. } catch (e) {}
  385. if (cfgDbActive) {
  386. const suffix = connectOk ? "(OK)" : connectErr ? `(失败:${connectErr})` : "(失败)";
  387. cfgDbActive.textContent = `当前连接:${effectiveDb || "-"}${effectiveDb ? suffix : ""}`;
  388. }
  389. const mysqlConfigured = Boolean(
  390. String(resp.db?.mysql?.host || "").trim() &&
  391. String(resp.db?.mysql?.user || "").trim() &&
  392. String(resp.db?.mysql?.database || "").trim()
  393. );
  394. const hints = [];
  395. if (configActiveDb === "sqlite" && mysqlConfigured) {
  396. hints.push("提示:已配置 MySQL,但当前仍在 SQLite。点击“切换到 MySQL(迁移)”才会生效。");
  397. }
  398. if (configActiveDb && effectiveDb && configActiveDb !== effectiveDb) {
  399. hints.push(`提示:配置显示为 ${configActiveDb},但实际连接为 ${effectiveDb},请检查 MySQL 连接是否正常。`);
  400. }
  401. if (resp.cache?.hasRedisUrl) hints.push("缓存:已配置 Redis(共享缓存已启用)");
  402. else hints.push("缓存:未配置 Redis(使用本机进程内缓存)");
  403. settingsMsg.textContent = [
  404. `Gogs Token:${resp.hasGogsToken ? "已配置" : "未配置"}`,
  405. `支付 Key:${resp.payment?.hasApiKey ? "已配置" : "未配置"}`,
  406. `支付宝私钥:${resp.payment?.alipay?.hasPrivateKey ? "已配置" : "未配置"}`,
  407. `支付宝公钥:${resp.payment?.alipay?.hasPublicKey ? "已配置" : "未配置"}`,
  408. `大模型 Key:${resp.llm?.hasApiKey ? "已配置" : "未配置"}`,
  409. `MySQL Password:${resp.db?.mysql?.hasPassword ? "已配置" : "未配置"}`,
  410. ...hints,
  411. ]
  412. .filter(Boolean)
  413. .join(" / ");
  414. updatePayProviderVisibility();
  415. applySettingsFilter();
  416. } catch (e) {
  417. settingsMsg.textContent = `加载失败:${e.detail?.error || e.status || "unknown"}`;
  418. if (e.status === 401) window.location.href = "/ui/admin/login";
  419. }
  420. }
  421. function updatePayProviderVisibility() {
  422. if (!cfgAlipayFields || !cfgPayProvider) return;
  423. const p = String(cfgPayProvider.value || "").trim().toUpperCase();
  424. cfgAlipayFields.style.display = p === "ALIPAY" ? "" : "none";
  425. }
  426. function listSettingGroups() {
  427. if (!settingsGroupsWrap) return [];
  428. return Array.from(settingsGroupsWrap.querySelectorAll(".collapse.settings-group"));
  429. }
  430. function listVisibleSettingGroups() {
  431. const groups = listSettingGroups();
  432. if (!settingsGroupsWrap) return groups;
  433. if (settingsGroupsWrap.classList.contains("is-tabs")) {
  434. const active = groups.find((g) => g.classList.contains("is-active"));
  435. return active ? [active] : [];
  436. }
  437. return groups.filter((g) => g.style.display !== "none");
  438. }
  439. function setSettingGroupOpen(groupEl, open) {
  440. if (!groupEl) return;
  441. groupEl.setAttribute("data-open", open ? "1" : "0");
  442. }
  443. function setActiveSettingsGroup(targetSel, opts) {
  444. if (!settingsGroupsWrap) return;
  445. const target = String(targetSel || "").trim();
  446. if (!target) return;
  447. const groups = listSettingGroups();
  448. groups.forEach((g) => {
  449. g.classList.remove("is-active");
  450. g.style.display = "";
  451. });
  452. const el = document.querySelector(target);
  453. if (!el) return;
  454. settingsGroupsWrap.classList.add("is-tabs");
  455. settingsGroupsWrap.classList.remove("is-searching");
  456. el.classList.add("is-active");
  457. setSettingNavActive(target);
  458. try {
  459. localStorage.setItem("adminSettingsActiveGroup", target);
  460. } catch (e) {}
  461. if (opts && opts.open) setSettingGroupOpen(el, true);
  462. if (opts && opts.scroll) el.scrollIntoView({ block: "start", behavior: "smooth" });
  463. }
  464. function getActiveSettingsGroupSel() {
  465. try {
  466. const v = localStorage.getItem("adminSettingsActiveGroup");
  467. if (v && document.querySelector(v)) return v;
  468. } catch (e) {}
  469. const first = listSettingGroups().find((g) => g && g.id);
  470. return first ? `#${first.id}` : "#cfgGroupGogs";
  471. }
  472. function setSettingNavActive(targetSel) {
  473. if (!cfgGroupNav) return;
  474. cfgGroupNav.querySelectorAll(".btn").forEach((b) => b.classList.remove("active"));
  475. const btn = cfgGroupNav.querySelector(`.btn[data-target="${targetSel}"]`);
  476. if (btn) btn.classList.add("active");
  477. }
  478. function applySettingsFilter() {
  479. const q = (cfgSearch && cfgSearch.value ? cfgSearch.value : "").trim().toLowerCase();
  480. const groups = listSettingGroups();
  481. if (!settingsGroupsWrap) return;
  482. if (!q) {
  483. settingsGroupsWrap.classList.remove("is-searching");
  484. setActiveSettingsGroup(getActiveSettingsGroupSel(), { open: true, scroll: false });
  485. return;
  486. }
  487. settingsGroupsWrap.classList.remove("is-tabs");
  488. settingsGroupsWrap.classList.add("is-searching");
  489. groups.forEach((g) => {
  490. g.classList.remove("is-active");
  491. const text = (g.textContent || "").toLowerCase();
  492. const show = text.includes(q);
  493. g.style.display = show ? "" : "none";
  494. if (show) setSettingGroupOpen(g, true);
  495. });
  496. }
  497. async function saveSettings() {
  498. settingsMsg.textContent = "";
  499. try {
  500. const mysqlPayload =
  501. cfgMysqlHost || cfgMysqlPort || cfgMysqlUser || cfgMysqlPassword || cfgMysqlDatabase || cfgClearMysqlPassword
  502. ? {
  503. host: cfgMysqlHost ? cfgMysqlHost.value.trim() : "",
  504. port: cfgMysqlPort ? cfgMysqlPort.value.trim() : "",
  505. user: cfgMysqlUser ? cfgMysqlUser.value.trim() : "",
  506. password: cfgMysqlPassword ? cfgMysqlPassword.value.trim() : "",
  507. clearPassword: cfgClearMysqlPassword ? cfgClearMysqlPassword.checked : false,
  508. database: cfgMysqlDatabase ? cfgMysqlDatabase.value.trim() : "",
  509. }
  510. : null;
  511. await apiFetch("/admin/settings", {
  512. method: "PUT",
  513. body: Object.assign(
  514. {
  515. gogsBaseUrl: cfgGogsBaseUrl.value.trim(),
  516. gogsToken: cfgGogsToken.value.trim(),
  517. clearGogsToken: cfgClearGogsToken.checked,
  518. payment: {
  519. provider: cfgPayProvider.value,
  520. enableMockPay: cfgEnableMockPay.checked,
  521. apiKey: cfgPayApiKey.value.trim(),
  522. clearApiKey: cfgClearPayApiKey.checked,
  523. alipay: {
  524. appId: cfgAlipayAppId ? cfgAlipayAppId.value.trim() : "",
  525. gateway: cfgAlipayGateway ? cfgAlipayGateway.value.trim() : "",
  526. notifyUrl: cfgAlipayNotifyUrl ? cfgAlipayNotifyUrl.value.trim() : "",
  527. returnUrl: cfgAlipayReturnUrl ? cfgAlipayReturnUrl.value.trim() : "",
  528. privateKey: cfgAlipayPrivateKey ? cfgAlipayPrivateKey.value.trim() : "",
  529. clearPrivateKey: cfgClearAlipayPrivateKey ? cfgClearAlipayPrivateKey.checked : false,
  530. publicKey: cfgAlipayPublicKey ? cfgAlipayPublicKey.value.trim() : "",
  531. clearPublicKey: cfgClearAlipayPublicKey ? cfgClearAlipayPublicKey.checked : false,
  532. },
  533. },
  534. llm: {
  535. provider: cfgLlmProvider.value.trim(),
  536. baseUrl: cfgLlmBaseUrl.value.trim(),
  537. model: cfgLlmModel.value.trim(),
  538. apiKey: cfgLlmApiKey.value.trim(),
  539. clearApiKey: cfgClearLlmApiKey.checked,
  540. },
  541. cache: {
  542. redisUrl: cfgRedisUrl ? cfgRedisUrl.value.trim() : "",
  543. clearRedisUrl: cfgClearRedisUrl ? cfgClearRedisUrl.checked : false,
  544. },
  545. storage: {
  546. provider: cfgStorageProvider ? cfgStorageProvider.value : "AUTO",
  547. oss: {
  548. endpoint: cfgOssEndpoint ? cfgOssEndpoint.value.trim() : "",
  549. bucket: cfgOssBucket ? cfgOssBucket.value.trim() : "",
  550. accessKeyId: cfgOssAccessKeyId ? cfgOssAccessKeyId.value.trim() : "",
  551. accessKeySecret: cfgOssAccessKeySecret ? cfgOssAccessKeySecret.value.trim() : "",
  552. clearAccessKeySecret: cfgClearOssAccessKeySecret ? cfgClearOssAccessKeySecret.checked : false,
  553. uploadPrefix: cfgOssUploadPrefix ? cfgOssUploadPrefix.value.trim() : "",
  554. publicBaseUrl: cfgOssPublicBaseUrl ? cfgOssPublicBaseUrl.value.trim() : "",
  555. },
  556. },
  557. },
  558. mysqlPayload ? { mysql: mysqlPayload } : {}
  559. ),
  560. });
  561. await loadSettings();
  562. settingsMsg.textContent = "保存成功";
  563. } catch (e) {
  564. settingsMsg.textContent = `保存失败:${e.detail?.error || e.status || "unknown"}`;
  565. if (e.status === 401) window.location.href = "/ui/admin/login";
  566. }
  567. }
  568. async function saveSettingsPartial(body) {
  569. settingsMsg.textContent = "";
  570. try {
  571. await apiFetch("/admin/settings", { method: "PUT", body });
  572. await loadSettings();
  573. settingsMsg.textContent = "保存成功";
  574. } catch (e) {
  575. settingsMsg.textContent = `保存失败:${e.detail?.error || e.status || "unknown"}`;
  576. if (e.status === 401) window.location.href = "/ui/admin/login";
  577. }
  578. }
  579. async function testMysqlConnection() {
  580. settingsMsg.textContent = "";
  581. try {
  582. const body = {
  583. host: cfgMysqlHost ? cfgMysqlHost.value.trim() : "",
  584. port: cfgMysqlPort ? cfgMysqlPort.value.trim() : "",
  585. user: cfgMysqlUser ? cfgMysqlUser.value.trim() : "",
  586. database: cfgMysqlDatabase ? cfgMysqlDatabase.value.trim() : "",
  587. };
  588. const pwd = cfgMysqlPassword ? cfgMysqlPassword.value.trim() : "";
  589. if (pwd) body.password = pwd;
  590. const resp = await apiFetch("/admin/mysql/test", { method: "POST", body });
  591. if (resp.ok && resp.createdDatabase) {
  592. settingsMsg.textContent = "MySQL:连接成功(已自动创建库)";
  593. } else {
  594. settingsMsg.textContent = resp.ok ? "MySQL:连接成功" : "MySQL:连接失败";
  595. }
  596. } catch (e) {
  597. const errno = e.detail?.errno ? ` errno=${e.detail.errno}` : "";
  598. settingsMsg.textContent = `MySQL:连接失败(${e.detail?.error || e.status || "unknown"}${errno})`;
  599. if (e.status === 401) window.location.href = "/ui/admin/login";
  600. }
  601. }
  602. async function testRedisConnection() {
  603. if (cfgCacheMsg) cfgCacheMsg.textContent = "";
  604. try {
  605. const url = cfgRedisUrl ? cfgRedisUrl.value.trim() : "";
  606. const resp = await apiFetch("/admin/redis/test", { method: "POST", body: url ? { url } : {} });
  607. if (resp.ok) {
  608. if (cfgCacheMsg) cfgCacheMsg.textContent = "Redis:连接成功";
  609. showToastSuccess("Redis:连接成功");
  610. } else {
  611. if (cfgCacheMsg) cfgCacheMsg.textContent = "Redis:连接失败";
  612. showToastError("Redis:连接失败");
  613. }
  614. } catch (e) {
  615. const err = e.detail?.error || e.status || "unknown";
  616. if (cfgCacheMsg) cfgCacheMsg.textContent = `Redis:连接失败(${err})`;
  617. showToastError(`Redis:连接失败(${err})`);
  618. if (e.status === 401) window.location.href = "/ui/admin/login";
  619. }
  620. }
  621. async function switchDatabase(target, force) {
  622. settingsMsg.textContent = "";
  623. if (target === "mysql") {
  624. const host = cfgMysqlHost ? cfgMysqlHost.value.trim() : "";
  625. const user = cfgMysqlUser ? cfgMysqlUser.value.trim() : "";
  626. const database = cfgMysqlDatabase ? cfgMysqlDatabase.value.trim() : "";
  627. if (!host || !user || !database) {
  628. await Swal.fire({
  629. title: "MySQL 参数不完整",
  630. text: "请先填写 Host / User / Database(可选填写 Port / Password),再切换",
  631. icon: "error",
  632. });
  633. return;
  634. }
  635. }
  636. const r = await Swal.fire({
  637. title: "切换数据库?",
  638. text: target === "mysql" ? "将迁移数据到 MySQL,并切换读写到 MySQL" : "将迁移数据到 SQLite,并切换读写到 SQLite",
  639. icon: "warning",
  640. showCancelButton: true,
  641. confirmButtonText: "继续",
  642. cancelButtonText: "取消",
  643. confirmButtonColor: "var(--danger)",
  644. });
  645. if (!r.isConfirmed) return;
  646. try {
  647. const body = { target, force: Boolean(force) };
  648. if (target === "mysql") {
  649. const mysql = {
  650. host: cfgMysqlHost ? cfgMysqlHost.value.trim() : "",
  651. port: cfgMysqlPort ? cfgMysqlPort.value.trim() : "",
  652. user: cfgMysqlUser ? cfgMysqlUser.value.trim() : "",
  653. database: cfgMysqlDatabase ? cfgMysqlDatabase.value.trim() : "",
  654. clearPassword: cfgClearMysqlPassword ? cfgClearMysqlPassword.checked : false,
  655. };
  656. const pwd = cfgMysqlPassword ? cfgMysqlPassword.value.trim() : "";
  657. if (pwd) mysql.password = pwd;
  658. body.mysql = mysql;
  659. }
  660. const resp = await apiFetch("/admin/db/switch", { method: "POST", body });
  661. settingsMsg.textContent = `切换成功:${resp.from} → ${resp.to}`;
  662. await loadSettings();
  663. } catch (e) {
  664. if (e.detail?.error === "target_not_empty") {
  665. const r2 = await Swal.fire({
  666. title: "目标库非空,是否覆盖?",
  667. text: "继续将清空目标库的表数据,然后迁移并切换(不可逆)",
  668. icon: "warning",
  669. showCancelButton: true,
  670. confirmButtonText: "覆盖并切换",
  671. cancelButtonText: "取消",
  672. confirmButtonColor: "var(--danger)",
  673. });
  674. if (!r2.isConfirmed) return;
  675. await switchDatabase(target, true);
  676. return;
  677. }
  678. settingsMsg.textContent = `切换失败:${e.detail?.error || e.status || "unknown"}`;
  679. if (e.status === 401) window.location.href = "/ui/admin/login";
  680. }
  681. }
  682. async function fillRefSelect(owner, repo, selectEl, prefer) {
  683. selectEl.innerHTML = "";
  684. selectEl.appendChild(el("option", { value: "AUTO" }, "AUTO(默认分支)"));
  685. const [branches, tags] = await Promise.all([
  686. apiFetch(`/admin/gogs/branches?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`),
  687. apiFetch(`/admin/gogs/tags?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`),
  688. ]);
  689. const branchGroup = document.createElement("optgroup");
  690. branchGroup.label = "分支";
  691. (branches.items || []).forEach((b) => {
  692. branchGroup.appendChild(el("option", { value: b.name }, b.name));
  693. });
  694. selectEl.appendChild(branchGroup);
  695. const tagGroup = document.createElement("optgroup");
  696. tagGroup.label = "标签";
  697. (tags.items || []).forEach((t) => {
  698. tagGroup.appendChild(el("option", { value: t.name }, t.name));
  699. });
  700. selectEl.appendChild(tagGroup);
  701. if (prefer) selectEl.value = prefer;
  702. }
  703. async function closeModal(force) {
  704. const isForce = force === true;
  705. if (!isForce && currentModalBeforeClose) {
  706. try {
  707. const ok = await currentModalBeforeClose();
  708. if (!ok) return;
  709. } catch (e) {}
  710. }
  711. if (currentModalOnKeydown) {
  712. try {
  713. document.removeEventListener("keydown", currentModalOnKeydown, true);
  714. } catch (e) {}
  715. }
  716. modalBackdrop.style.display = "none";
  717. modalTitle.textContent = "";
  718. modalBody.innerHTML = "";
  719. modalFooter.innerHTML = "";
  720. if (modalHeaderActions) modalHeaderActions.innerHTML = "";
  721. if (modalEl) modalEl.removeAttribute("data-size");
  722. currentModalOnResize = null;
  723. currentModalBeforeClose = null;
  724. currentModalOnKeydown = null;
  725. }
  726. function openModal(title, bodyNodes, footerNodes, icon = "ri-settings-4-line", opts = {}) {
  727. modalTitle.innerHTML = "";
  728. modalTitle.appendChild(el("i", { class: icon }));
  729. modalTitle.appendChild(document.createTextNode(title));
  730. modalBody.innerHTML = "";
  731. modalFooter.innerHTML = "";
  732. if (modalHeaderActions) modalHeaderActions.innerHTML = "";
  733. if (modalEl) modalEl.removeAttribute("data-size");
  734. currentModalOnResize = typeof opts.onResize === "function" ? opts.onResize : null;
  735. currentModalBeforeClose = typeof opts.beforeClose === "function" ? opts.beforeClose : null;
  736. if (currentModalOnKeydown) {
  737. try {
  738. document.removeEventListener("keydown", currentModalOnKeydown, true);
  739. } catch (e) {}
  740. currentModalOnKeydown = null;
  741. }
  742. if (typeof opts.onKeydown === "function") {
  743. currentModalOnKeydown = (evt) => opts.onKeydown(evt);
  744. document.addEventListener("keydown", currentModalOnKeydown, true);
  745. }
  746. bodyNodes.forEach((n) => modalBody.appendChild(n));
  747. footerNodes.forEach((n) => modalFooter.appendChild(n));
  748. modalBackdrop.style.display = "";
  749. if (modalEl && opts.resizable && modalHeaderActions) {
  750. let preferredSize = (opts.size || "").toString().trim();
  751. if (!preferredSize) {
  752. try {
  753. preferredSize = (localStorage.getItem("adminModalSize") || "").toString().trim();
  754. } catch (e) {}
  755. }
  756. if (!preferredSize) preferredSize = "sm";
  757. const btnSm = el("button", { type: "button", class: "btn btn-sm btn-toggle" }, "小");
  758. const btnLg = el("button", { type: "button", class: "btn btn-sm btn-toggle" }, "大");
  759. function applySize(size) {
  760. const s = size === "sm" ? "sm" : "lg";
  761. modalEl.setAttribute("data-size", s);
  762. btnSm.classList.toggle("active", s === "sm");
  763. btnLg.classList.toggle("active", s === "lg");
  764. try {
  765. localStorage.setItem("adminModalSize", s);
  766. } catch (e) {}
  767. if (currentModalOnResize) currentModalOnResize(s);
  768. }
  769. btnSm.addEventListener("click", () => applySize("sm"));
  770. btnLg.addEventListener("click", () => applySize("lg"));
  771. modalHeaderActions.appendChild(el("div", { class: "btn-group" }, btnSm, btnLg));
  772. applySize(preferredSize);
  773. } else if (modalEl && currentModalOnResize) {
  774. const s = (modalEl.getAttribute("data-size") || "sm").toString();
  775. currentModalOnResize(s);
  776. } else if (modalEl && opts.size) {
  777. modalEl.setAttribute("data-size", String(opts.size));
  778. }
  779. }
  780. modalClose.addEventListener("click", () => closeModal());
  781. modalBackdrop.addEventListener("click", (evt) => {
  782. if (evt.target === modalBackdrop) closeModal();
  783. });
  784. function insertAtCursor(textarea, text) {
  785. const start = textarea.selectionStart || 0;
  786. const end = textarea.selectionEnd || 0;
  787. const before = textarea.value.slice(0, start);
  788. const after = textarea.value.slice(end);
  789. textarea.value = `${before}${text}${after}`;
  790. const pos = start + text.length;
  791. textarea.setSelectionRange(pos, pos);
  792. textarea.focus();
  793. try {
  794. textarea.dispatchEvent(new Event("input", { bubbles: true }));
  795. } catch (e) {}
  796. }
  797. function parseRepoInput(raw) {
  798. let s = (raw || "").trim();
  799. if (!s) return null;
  800. s = s.replace(/\.git$/i, "");
  801. if (s.includes("://")) {
  802. try {
  803. const u = new URL(s);
  804. s = (u.pathname || "").replace(/^\/+/, "");
  805. } catch (e) {
  806. return null;
  807. }
  808. }
  809. const sshIdx = s.indexOf(":");
  810. if (s.startsWith("git@") && sshIdx !== -1) {
  811. s = s.slice(sshIdx + 1);
  812. }
  813. s = s.replace(/^\/+/, "");
  814. const parts = s.split("/").filter(Boolean);
  815. if (parts.length < 2) return null;
  816. return { owner: parts[0], repo: parts[1] };
  817. }
  818. function buildMarkdownEditor({ initialValue, msgEl }) {
  819. const summaryInput = el("textarea", {
  820. class: "input md-editor-input",
  821. style: "min-height:260px; resize:vertical",
  822. placeholder: "简介(Markdown,支持粘贴/拖拽上传图片/视频)",
  823. value: initialValue || "",
  824. });
  825. const syncReadme = el("input", { type: "checkbox" });
  826. syncReadme.checked = true;
  827. attachPasteUpload(summaryInput, msgEl);
  828. const tocWrap = el("div", { class: "md-toc", style: "display:none" });
  829. const tocTitle = el("div", { class: "md-toc-title" }, el("span", {}, "大纲"), el("span", { class: "muted", style: "font-weight:650" }, "点击跳转"));
  830. const tocItems = el("div", { class: "md-toc-items" });
  831. tocWrap.appendChild(tocTitle);
  832. tocWrap.appendChild(tocItems);
  833. const mdContent = el("div", { html: "" });
  834. const mdPreview = el("div", { class: "md md-editor-preview", html: "" }, tocWrap, mdContent);
  835. let showToc = false;
  836. function slugify(text) {
  837. const raw = (text || "").toString().trim().toLowerCase();
  838. const s = raw
  839. .replace(/[\s]+/g, "-")
  840. .replace(/[^\u4e00-\u9fa5a-z0-9\-_]/g, "")
  841. .replace(/-+/g, "-")
  842. .replace(/^-|-$/g, "");
  843. return s || "h";
  844. }
  845. function buildToc() {
  846. tocItems.innerHTML = "";
  847. const headings = Array.from(mdContent.querySelectorAll("h1,h2,h3,h4,h5,h6"));
  848. if (!showToc || !headings.length) {
  849. tocWrap.style.display = "none";
  850. return;
  851. }
  852. tocWrap.style.display = "";
  853. const used = new Map();
  854. headings.forEach((h) => {
  855. const level = Number(String(h.tagName || "H2").replace("H", "")) || 2;
  856. const base = slugify(h.textContent || "");
  857. const n = (used.get(base) || 0) + 1;
  858. used.set(base, n);
  859. const id = n === 1 ? base : `${base}-${n}`;
  860. if (!h.id) h.id = id;
  861. const btn = el("button", { type: "button", class: "md-toc-item", style: `padding-left:${Math.max(0, (level - 1) * 12)}px` }, h.textContent || "");
  862. btn.addEventListener("click", (evt) => {
  863. evt.preventDefault();
  864. try {
  865. h.scrollIntoView({ behavior: "smooth", block: "start" });
  866. } catch (e) {
  867. h.scrollIntoView();
  868. }
  869. });
  870. tocItems.appendChild(btn);
  871. });
  872. }
  873. async function updateMdPreview() {
  874. if (typeof renderMarkdown !== "function") {
  875. await loadScriptOnce("/static/app_markdown.js");
  876. }
  877. mdContent.innerHTML = renderMarkdown(summaryInput.value);
  878. buildToc();
  879. }
  880. updateMdPreview();
  881. summaryInput.addEventListener("input", () => {
  882. updateMdPreview();
  883. });
  884. function wrapSelection(textarea, left, right) {
  885. const start = textarea.selectionStart || 0;
  886. const end = textarea.selectionEnd || 0;
  887. const value = textarea.value || "";
  888. const selected = value.slice(start, end);
  889. const next = `${value.slice(0, start)}${left}${selected}${right}${value.slice(end)}`;
  890. textarea.value = next;
  891. const nextStart = start + left.length;
  892. const nextEnd = nextStart + selected.length;
  893. textarea.setSelectionRange(nextStart, nextEnd);
  894. textarea.focus();
  895. try {
  896. textarea.dispatchEvent(new Event("input", { bubbles: true }));
  897. } catch (e) {}
  898. }
  899. function prefixLines(textarea, prefix) {
  900. const start = textarea.selectionStart || 0;
  901. const end = textarea.selectionEnd || 0;
  902. const value = textarea.value || "";
  903. const selected = value.slice(start, end);
  904. const text = selected || "";
  905. const nextBlock = text
  906. .split("\n")
  907. .map((line) => (line ? `${prefix}${line}` : prefix.trimEnd()))
  908. .join("\n");
  909. const insertText = selected ? nextBlock : `\n${prefix}`;
  910. insertAtCursor(textarea, insertText);
  911. }
  912. function mdBtn(label, title, onClick) {
  913. const b = el("button", { type: "button", class: "btn btn-sm", title }, label);
  914. b.addEventListener("click", (evt) => {
  915. evt.preventDefault();
  916. onClick();
  917. });
  918. return b;
  919. }
  920. function insertSnippet(text, cursorRelStart, cursorRelEnd) {
  921. const start = summaryInput.selectionStart || 0;
  922. const end = summaryInput.selectionEnd || 0;
  923. const before = summaryInput.value.slice(0, start);
  924. const after = summaryInput.value.slice(end);
  925. summaryInput.value = `${before}${text}${after}`;
  926. const s = start + (cursorRelStart == null ? text.length : cursorRelStart);
  927. const e = start + (cursorRelEnd == null ? (cursorRelStart == null ? text.length : cursorRelStart) : cursorRelEnd);
  928. summaryInput.setSelectionRange(s, e);
  929. summaryInput.focus();
  930. try {
  931. summaryInput.dispatchEvent(new Event("input", { bubbles: true }));
  932. } catch (e2) {}
  933. }
  934. const viewEditBtn = el("button", { type: "button", class: "btn btn-sm btn-toggle active" }, "编辑");
  935. const viewPreviewBtn = el("button", { type: "button", class: "btn btn-sm btn-toggle" }, "预览");
  936. const viewSplitBtn = el("button", { type: "button", class: "btn btn-sm btn-toggle" }, "分屏");
  937. const tocBtn = el("button", { type: "button", class: "btn btn-sm btn-toggle", title: "按标题生成大纲" }, "大纲");
  938. const boldBtn = mdBtn("B", "加粗", () => wrapSelection(summaryInput, "**", "**"));
  939. const italicBtn = mdBtn("I", "斜体", () => wrapSelection(summaryInput, "*", "*"));
  940. const codeBtn = mdBtn("</>", "行内代码", () => wrapSelection(summaryInput, "`", "`"));
  941. const h2Btn = mdBtn("H2", "二级标题", () => insertAtCursor(summaryInput, "\n## "));
  942. const blockCodeBtn = mdBtn("代码块", "代码块", () => insertSnippet("\n```text\n\n```\n", "\n```text\n".length));
  943. const tableBtn = mdBtn("表格", "表格", () => {
  944. const t = "\n| 标题 | 内容 |\n| --- | --- |\n| | |\n";
  945. const cursor = t.lastIndexOf("| |") + 2;
  946. insertSnippet(t, cursor, cursor);
  947. });
  948. const imgLinkBtn = mdBtn("图片链接", "图片链接", () => {
  949. const t = "\n![]()\n";
  950. insertSnippet(t, t.indexOf("()") + 1, t.indexOf(")") );
  951. });
  952. const quoteBtn = mdBtn("引用", "引用", () => prefixLines(summaryInput, "> "));
  953. const ulBtn = mdBtn("•", "无序列表", () => insertAtCursor(summaryInput, "\n- "));
  954. const olBtn = mdBtn("1.", "有序列表", () => insertAtCursor(summaryInput, "\n1. "));
  955. const linkBtn = mdBtn("链接", "链接", () => {
  956. const start = summaryInput.selectionStart || 0;
  957. const end = summaryInput.selectionEnd || 0;
  958. const selected = (summaryInput.value || "").slice(start, end);
  959. if (selected) wrapSelection(summaryInput, "[", "](https://)");
  960. else insertAtCursor(summaryInput, "[](" + "https://)");
  961. });
  962. const imgFile = el("input", { type: "file", accept: "image/*", style: "display:none" });
  963. const videoFile = el("input", { type: "file", accept: "video/*", style: "display:none" });
  964. const uploadImgBtn = el("button", { class: "btn btn-sm" }, "上传图片");
  965. const uploadVideoBtn = el("button", { class: "btn btn-sm" }, "上传视频");
  966. uploadImgBtn.addEventListener("click", () => imgFile.click());
  967. uploadVideoBtn.addEventListener("click", () => videoFile.click());
  968. const onPickFile = async (f) => {
  969. msgEl.textContent = "上传中...";
  970. try {
  971. const url = await adminUploadFile(f);
  972. const syntax = (f.type || "").startsWith("video/") ? `\n@[video](${url})\n` : `\n![](${url})\n`;
  973. insertAtCursor(summaryInput, syntax);
  974. msgEl.textContent = "已插入上传内容";
  975. } catch (e) {
  976. msgEl.textContent = `上传失败:${e.detail?.error || e.status || "unknown"}`;
  977. if (e.status === 401) window.location.href = "/ui/admin/login";
  978. }
  979. };
  980. summaryInput.addEventListener("dragover", (evt) => {
  981. const files = evt.dataTransfer?.files ? Array.from(evt.dataTransfer.files) : [];
  982. const file = files.find((it) => (it.type || "").startsWith("image/") || (it.type || "").startsWith("video/"));
  983. if (file) evt.preventDefault();
  984. });
  985. summaryInput.addEventListener("drop", async (evt) => {
  986. const files = evt.dataTransfer?.files ? Array.from(evt.dataTransfer.files) : [];
  987. const file = files.find((it) => (it.type || "").startsWith("image/") || (it.type || "").startsWith("video/"));
  988. if (!file) return;
  989. evt.preventDefault();
  990. await onPickFile(file);
  991. });
  992. imgFile.addEventListener("change", async () => {
  993. const f = imgFile.files && imgFile.files[0];
  994. if (f) await onPickFile(f);
  995. imgFile.value = "";
  996. });
  997. videoFile.addEventListener("change", async () => {
  998. const f = videoFile.files && videoFile.files[0];
  999. if (f) await onPickFile(f);
  1000. videoFile.value = "";
  1001. });
  1002. const mdToolbar = el(
  1003. "div",
  1004. { class: "toolbar md-editor-toolbar", style: "margin:0" },
  1005. viewEditBtn,
  1006. viewPreviewBtn,
  1007. viewSplitBtn,
  1008. tocBtn,
  1009. boldBtn,
  1010. italicBtn,
  1011. codeBtn,
  1012. h2Btn,
  1013. blockCodeBtn,
  1014. tableBtn,
  1015. imgLinkBtn,
  1016. quoteBtn,
  1017. ulBtn,
  1018. olBtn,
  1019. linkBtn,
  1020. el("div", { style: "flex:1" }),
  1021. uploadImgBtn,
  1022. uploadVideoBtn,
  1023. imgFile,
  1024. videoFile,
  1025. el("label", { class: "checkbox-row", style: "margin:0" }, syncReadme, el("span", { class: "muted" }, "同步 README.md"))
  1026. );
  1027. const mdEditor = el(
  1028. "div",
  1029. { class: "md-editor", "data-view": "edit" },
  1030. mdToolbar,
  1031. el("div", { class: "md-editor-body" }, summaryInput, mdPreview)
  1032. );
  1033. function setMdView(view) {
  1034. mdEditor.setAttribute("data-view", view);
  1035. [viewEditBtn, viewPreviewBtn, viewSplitBtn].forEach((b) => b.classList.remove("active"));
  1036. if (view === "preview") viewPreviewBtn.classList.add("active");
  1037. else if (view === "split") viewSplitBtn.classList.add("active");
  1038. else viewEditBtn.classList.add("active");
  1039. updateMdPreview();
  1040. }
  1041. function setViewByModalSize(size) {
  1042. const s = size === "lg" ? "lg" : "sm";
  1043. const cur = (mdEditor.getAttribute("data-view") || "edit").toString();
  1044. if (s === "lg" && cur === "edit") setMdView("split");
  1045. if (s === "sm" && cur === "split") setMdView("edit");
  1046. updateMdPreview();
  1047. }
  1048. viewEditBtn.addEventListener("click", () => setMdView("edit"));
  1049. viewPreviewBtn.addEventListener("click", () => setMdView("preview"));
  1050. viewSplitBtn.addEventListener("click", () => setMdView("split"));
  1051. tocBtn.addEventListener("click", () => {
  1052. showToc = !showToc;
  1053. tocBtn.classList.toggle("active", showToc);
  1054. updateMdPreview();
  1055. });
  1056. function setText(text) {
  1057. summaryInput.value = String(text || "");
  1058. try {
  1059. summaryInput.dispatchEvent(new Event("input", { bubbles: true }));
  1060. } catch (e) {}
  1061. }
  1062. return { root: mdEditor, textarea: summaryInput, syncReadme, toolbarEl: mdToolbar, setText, setMdView, setViewByModalSize };
  1063. }
  1064. async function adminUploadFileMeta(file) {
  1065. const fd = new FormData();
  1066. fd.append("file", file);
  1067. const headers = {};
  1068. const csrf = getCookie("csrf_token");
  1069. if (csrf) headers["X-CSRF-Token"] = csrf;
  1070. const resp = await fetch("/admin/uploads", { method: "POST", body: fd, headers });
  1071. const contentType = resp.headers.get("content-type") || "";
  1072. const isJson = contentType.includes("application/json");
  1073. const detail = isJson ? await resp.json() : null;
  1074. if (!resp.ok) {
  1075. const err = new Error("upload_failed");
  1076. err.status = resp.status;
  1077. err.detail = detail;
  1078. throw err;
  1079. }
  1080. return detail;
  1081. }
  1082. async function adminUploadFile(file) {
  1083. const detail = await adminUploadFileMeta(file);
  1084. return detail.url;
  1085. }
  1086. function attachPasteUpload(textarea, msgEl) {
  1087. textarea.addEventListener("paste", async (evt) => {
  1088. const items = evt.clipboardData?.items ? Array.from(evt.clipboardData.items) : [];
  1089. const fileItem = items.find((it) => it.kind === "file" && (it.type || "").startsWith("image/")) || items.find((it) => it.kind === "file" && (it.type || "").startsWith("video/"));
  1090. if (!fileItem) return;
  1091. evt.preventDefault();
  1092. const file = fileItem.getAsFile();
  1093. if (!file) return;
  1094. msgEl.textContent = "上传中...";
  1095. try {
  1096. const url = await adminUploadFile(file);
  1097. const syntax = (file.type || "").startsWith("video/") ? `\n@[video](${url})\n` : `\n![](${url})\n`;
  1098. insertAtCursor(textarea, syntax);
  1099. msgEl.textContent = "已插入上传内容";
  1100. } catch (e) {
  1101. msgEl.textContent = `上传失败:${e.detail?.error || e.status || "unknown"}`;
  1102. if (e.status === 401) window.location.href = "/ui/admin/login";
  1103. }
  1104. });
  1105. }
  1106. function openRepoPicker(initialOwner, onPick) {
  1107. const ownerInput = el("input", { class: "input", placeholder: "Owner(可选:留空则列出 Token 可见仓库)", value: initialOwner || "" });
  1108. const qInput = el("input", { class: "input", placeholder: "仓库关键词(可选)" });
  1109. const searchBtn = el("button", { class: "btn" }, "搜索");
  1110. const msg = el("div", { class: "muted" }, "");
  1111. const table = el("table", { class: "table" }, el("thead", {}, el("tr", {}, el("th", {}, "仓库"), el("th", {}, "默认分支"), el("th", {}, "操作"))), el("tbody", {}));
  1112. const tbody = table.querySelector("tbody");
  1113. const tableWrap = el("div", { class: "table-wrap" }, table);
  1114. async function refresh() {
  1115. tbody.innerHTML = "";
  1116. msg.textContent = "";
  1117. try {
  1118. const params = new URLSearchParams();
  1119. if (ownerInput.value.trim()) params.set("owner", ownerInput.value.trim());
  1120. if (qInput.value.trim()) params.set("q", qInput.value.trim());
  1121. const resp = await apiFetch(`/admin/gogs/repos?${params.toString()}`);
  1122. const items = resp.items || [];
  1123. if (!items.length) {
  1124. renderEmptyRow(tbody, 3, "未找到仓库");
  1125. return;
  1126. }
  1127. items.forEach((r) => {
  1128. const ownerName = (r.owner || (r.fullName || "").split("/")[0] || "").trim();
  1129. const tr = el(
  1130. "tr",
  1131. {},
  1132. el("td", {}, r.fullName || r.name),
  1133. el("td", {}, r.defaultBranch || "-"),
  1134. el(
  1135. "td",
  1136. {},
  1137. btnGroup(
  1138. el(
  1139. "button",
  1140. {
  1141. class: "btn",
  1142. onclick: () => {
  1143. onPick({ owner: ownerName, name: r.name, fullName: r.fullName || "", defaultBranch: r.defaultBranch || "" });
  1144. closeModal();
  1145. },
  1146. },
  1147. "选择"
  1148. )
  1149. )
  1150. )
  1151. );
  1152. tbody.appendChild(tr);
  1153. });
  1154. } catch (e) {
  1155. const errCode = e.detail?.error || e.status || "unknown";
  1156. const upstream = e.detail?.status ? `(Gogs: ${e.detail.status})` : "";
  1157. if (e.detail?.error === "gogs_token_required") {
  1158. msg.textContent = "查询失败:未配置 GOGS_TOKEN,请填写 Owner 后再搜索";
  1159. return;
  1160. }
  1161. if (e.detail?.error === "gogs_unreachable" || (e.detail?.error === "gogs_failed" && Number(e.detail?.status || 0) === 599)) {
  1162. const url = (e.detail?.url || "").toString().trim();
  1163. msg.textContent = `查询失败:无法连接 Gogs,请检查 GOGS_BASE_URL/网络${url ? `(${url})` : ""}。若不配置 Token,请填写 Owner 后再搜索。`;
  1164. showToastError("无法连接 Gogs");
  1165. return;
  1166. }
  1167. msg.textContent = `查询失败:${errCode}${upstream}`;
  1168. if (e.status === 401) window.location.href = "/ui/admin/login";
  1169. }
  1170. }
  1171. searchBtn.addEventListener("click", refresh);
  1172. openModal(
  1173. "选择仓库",
  1174. [el("div", { class: "toolbar toolbar-tight" }, ownerInput, qInput, searchBtn), msg, tableWrap],
  1175. [el("button", { class: "btn", onclick: closeModal }, "关闭")],
  1176. "ri-git-repository-line"
  1177. );
  1178. refresh();
  1179. }
  1180. async function loadPlans() {
  1181. const planTbody = document.querySelector("#planTable tbody");
  1182. planTbody.innerHTML = "";
  1183. const plans = await apiFetch("/admin/plans");
  1184. planMap.clear();
  1185. plans.forEach((p) => {
  1186. planMap.set(String(p.id), p);
  1187. const tr = el(
  1188. "tr",
  1189. {},
  1190. el("td", { title: String(p.id) }, String(p.id)),
  1191. el("td", { title: p.name }, p.name),
  1192. el("td", {}, String(p.durationDays)),
  1193. el("td", {}, formatCents(p.priceCents)),
  1194. el("td", {}, p.enabled ? badge("启用", "badge-success") : badge("禁用", "badge-danger")),
  1195. el("td", {}, String(p.sort)),
  1196. el(
  1197. "td",
  1198. { class: "td-actions" },
  1199. btnGroup(
  1200. el("button", { class: "btn", "data-action": "edit-plan", "data-id": String(p.id) }, "编辑"),
  1201. el("button", { class: "btn", "data-action": "del-plan", "data-id": String(p.id) }, "删除")
  1202. )
  1203. )
  1204. );
  1205. planTbody.appendChild(tr);
  1206. });
  1207. if (!plans.length) renderEmptyRow(planTbody, 7, "暂无数据");
  1208. }
  1209. if (settingsGroupsWrap) {
  1210. settingsGroupsWrap.addEventListener("click", (evt) => {
  1211. const head = evt.target.closest(".collapse-head");
  1212. if (!head) return;
  1213. const wrap = head.closest(".collapse");
  1214. if (!wrap) return;
  1215. evt.preventDefault();
  1216. const cur = wrap.getAttribute("data-open") === "1";
  1217. setSettingGroupOpen(wrap, !cur);
  1218. if (wrap.id) setSettingNavActive(`#${wrap.id}`);
  1219. });
  1220. }
  1221. if (cfgGroupNav) {
  1222. cfgGroupNav.addEventListener("click", (evt) => {
  1223. const btn = evt.target.closest(".btn");
  1224. if (!btn) return;
  1225. const targetSel = btn.getAttribute("data-target");
  1226. if (!targetSel) return;
  1227. evt.preventDefault();
  1228. if (cfgSearch && cfgSearch.value.trim()) {
  1229. cfgSearch.value = "";
  1230. applySettingsFilter();
  1231. }
  1232. setActiveSettingsGroup(targetSel, { open: true, scroll: false });
  1233. });
  1234. }
  1235. if (cfgSearch) {
  1236. cfgSearch.addEventListener("input", () => {
  1237. applySettingsFilter();
  1238. });
  1239. cfgSearch.addEventListener("keydown", (evt) => {
  1240. if (evt.key !== "Enter") return;
  1241. evt.preventDefault();
  1242. applySettingsFilter();
  1243. const first = listSettingGroups().find((g) => g.style.display !== "none");
  1244. if (first) {
  1245. setSettingGroupOpen(first, true);
  1246. if (first.id) setSettingNavActive(`#${first.id}`);
  1247. first.scrollIntoView({ block: "start", behavior: "smooth" });
  1248. }
  1249. });
  1250. }
  1251. async function loadResources() {
  1252. const resTbody = document.querySelector("#resourceTable tbody");
  1253. resTbody.innerHTML = "";
  1254. const query = new URLSearchParams();
  1255. if (resQ.value.trim()) query.set("q", resQ.value.trim());
  1256. if (resTypeFilter.value) query.set("type", resTypeFilter.value);
  1257. if (resStatusFilter.value) query.set("status", resStatusFilter.value);
  1258. query.set("page", String(resState.page));
  1259. query.set("pageSize", String(resState.pageSize));
  1260. const resp = await apiFetch(`/admin/resources?${query.toString()}`);
  1261. const resources = resp.items || [];
  1262. resState.total = Number(resp.total || 0);
  1263. resourceMap.clear();
  1264. resources.forEach((r) => {
  1265. resourceMap.set(String(r.id), r);
  1266. const cacheCell = el("td", { id: `res-cache-${r.id}` }, badge("加载中", "badge"));
  1267. const repoRefTitle = `${r.repoOwner}/${r.repoName} @ ${r.defaultRef}`;
  1268. const repoRefCell = el(
  1269. "td",
  1270. { title: repoRefTitle },
  1271. el("div", {}, `${r.repoOwner}/${r.repoName}`),
  1272. el("div", { class: "muted" }, String(r.defaultRef || ""))
  1273. );
  1274. const tr = el(
  1275. "tr",
  1276. {},
  1277. el("td", { title: String(r.id) }, String(r.id)),
  1278. el("td", { title: r.title }, r.title),
  1279. el("td", {}, resourceTypeBadge(r.type)),
  1280. el("td", {}, resourceStatusBadge(r.status)),
  1281. repoRefCell,
  1282. el("td", { title: formatDateTime(r.updatedAt) }, formatDateTime(r.updatedAt)),
  1283. cacheCell,
  1284. el(
  1285. "td",
  1286. { class: "td-actions" },
  1287. btnGroup(
  1288. el("a", { class: "btn", href: `/ui/resources/${r.id}` }, "查看"),
  1289. el("button", { class: "btn", "data-action": "cache-res", "data-id": String(r.id) }, "缓存"),
  1290. el("button", { class: "btn", "data-action": "edit-res", "data-id": String(r.id) }, "编辑"),
  1291. el("button", { class: "btn", "data-action": "del-res", "data-id": String(r.id) }, "删除")
  1292. )
  1293. )
  1294. );
  1295. resTbody.appendChild(tr);
  1296. });
  1297. if (!resources.length) renderEmptyRow(resTbody, 8, "暂无数据");
  1298. await loadResourceCacheSummaries(resources);
  1299. const pageCount = Math.max(1, Math.ceil(resState.total / resState.pageSize));
  1300. resPageInfo.textContent = `第 ${resState.page} / ${pageCount} 页,共 ${resState.total} 条`;
  1301. resPrevPage.disabled = resState.page <= 1;
  1302. resNextPage.disabled = resState.page >= pageCount;
  1303. }
  1304. function cacheSummaryBadge(summary) {
  1305. if (!summary || !summary.ok) return badge("-", "badge");
  1306. const jobs = Array.isArray(summary.jobs) ? summary.jobs : [];
  1307. if (jobs.some((j) => j && j.state === "building")) return badge("生成中", "badge-warning");
  1308. const count = Number(summary.count || 0);
  1309. if (count <= 0) return badge("无", "badge");
  1310. return badge(`${count}份`, "badge-success");
  1311. }
  1312. async function loadResourceCacheSummaries(resources) {
  1313. const items = Array.isArray(resources) ? resources : [];
  1314. const tasks = items.map((r) => async () => {
  1315. const cell = document.getElementById(`res-cache-${r.id}`);
  1316. if (!cell) return;
  1317. try {
  1318. const summary = await apiFetch(`/admin/resources/${r.id}/download-cache/summary`);
  1319. cell.innerHTML = "";
  1320. cell.appendChild(cacheSummaryBadge(summary));
  1321. } catch (e) {
  1322. cell.innerHTML = "";
  1323. cell.appendChild(badge("失败", "badge-danger"));
  1324. if (e.status === 401) window.location.href = "/ui/admin/login";
  1325. }
  1326. });
  1327. const limit = 4;
  1328. let idx = 0;
  1329. const workers = new Array(Math.min(limit, tasks.length)).fill(0).map(async () => {
  1330. while (idx < tasks.length) {
  1331. const i = idx;
  1332. idx += 1;
  1333. await tasks[i]();
  1334. }
  1335. });
  1336. await Promise.all(workers);
  1337. }
  1338. function openResourceDownloadCacheModal(res) {
  1339. const refInput = el("input", { class: "input", value: String(res.defaultRef || "") });
  1340. const msg = el("div", { class: "muted" }, "");
  1341. const summaryBox = el("div", { class: "muted" }, "加载中…");
  1342. const statusBox = el("div", { class: "muted" }, "");
  1343. const listBox = el("div", {}, "");
  1344. async function refreshAll() {
  1345. msg.textContent = "";
  1346. summaryBox.textContent = "加载中…";
  1347. statusBox.textContent = "";
  1348. listBox.innerHTML = "";
  1349. try {
  1350. const [summary, status, list] = await Promise.all([
  1351. apiFetch(`/admin/resources/${res.id}/download-cache/summary`),
  1352. apiFetch(`/admin/resources/${res.id}/download-cache/status?${new URLSearchParams({ ref: refInput.value.trim() || String(res.defaultRef || "") }).toString()}`),
  1353. apiFetch(`/admin/resources/${res.id}/download-cache/list`),
  1354. ]);
  1355. const jobs = Array.isArray(summary.jobs) ? summary.jobs : [];
  1356. const latest = summary.latest || null;
  1357. summaryBox.textContent = [
  1358. `缓存文件:${Number(summary.count || 0)} 份`,
  1359. latest && latest.mtime ? `最近更新:${formatDateTime(latest.mtime)}` : "最近更新:-",
  1360. jobs.some((j) => j && j.state === "building") ? "生成中" : "",
  1361. ]
  1362. .filter(Boolean)
  1363. .join(" / ");
  1364. if (status && status.ready) {
  1365. statusBox.textContent = `当前ref已缓存:${String(status.commit || "").slice(0, 12) || "-"}(${String(status.ref || "")})`;
  1366. } else if (status && status.state) {
  1367. statusBox.textContent = `当前ref状态:${status.state}${status.error ? ` / ${status.error}` : ""}`;
  1368. } else {
  1369. statusBox.textContent = "当前ref状态:-";
  1370. }
  1371. const items = Array.isArray(list.items) ? list.items : [];
  1372. if (!items.length) {
  1373. listBox.appendChild(el("div", { class: "muted" }, "暂无缓存文件"));
  1374. return;
  1375. }
  1376. const thead = el(
  1377. "thead",
  1378. {},
  1379. el(
  1380. "tr",
  1381. {},
  1382. el("th", {}, "commit"),
  1383. el("th", {}, "ref"),
  1384. el("th", {}, "大小"),
  1385. el("th", {}, "更新时间"),
  1386. el("th", {}, "TTL"),
  1387. el("th", {}, "操作")
  1388. )
  1389. );
  1390. const tbody = el("tbody", {}, "");
  1391. items.forEach((it) => {
  1392. const commit = String(it.commit || "");
  1393. const ref = String(it.ref || "");
  1394. const metaText = it.meta ? JSON.stringify(it.meta, null, 2) : "";
  1395. const tr = el(
  1396. "tr",
  1397. {},
  1398. el("td", { title: commit }, commit ? commit.slice(0, 12) : "-"),
  1399. el("td", { title: ref }, ref || "-"),
  1400. el("td", {}, it.bytes != null ? formatBytes(it.bytes) : "-"),
  1401. el("td", {}, it.mtime ? formatDateTime(it.mtime) : "-"),
  1402. el("td", {}, it.ttlRemainingSeconds == null ? "-" : `${Math.max(0, Number(it.ttlRemainingSeconds || 0))}s`),
  1403. el(
  1404. "td",
  1405. {},
  1406. btnGroup(
  1407. el(
  1408. "button",
  1409. {
  1410. class: "btn btn-sm",
  1411. onclick: () => {
  1412. if (!metaText) return;
  1413. openModal("缓存元信息", [el("pre", { class: "pre" }, metaText)], [el("button", { class: "btn", onclick: closeModal }, "关闭")]);
  1414. },
  1415. },
  1416. "查看"
  1417. ),
  1418. el(
  1419. "a",
  1420. {
  1421. class: "btn btn-sm",
  1422. href: commit ? `/admin/resources/${res.id}/download-cache/file?${new URLSearchParams({ commit }).toString()}` : "#",
  1423. target: "_blank",
  1424. rel: "noreferrer",
  1425. onclick: (evt) => {
  1426. if (!commit) evt.preventDefault();
  1427. },
  1428. },
  1429. "下载"
  1430. ),
  1431. el(
  1432. "button",
  1433. {
  1434. class: "btn btn-sm btn-danger",
  1435. onclick: async () => {
  1436. if (!commit) return;
  1437. const r = await Swal.fire({
  1438. title: "清理该缓存?",
  1439. text: `commit:${commit.slice(0, 12)}`,
  1440. icon: "warning",
  1441. showCancelButton: true,
  1442. confirmButtonText: "清理",
  1443. cancelButtonText: "取消",
  1444. confirmButtonColor: "var(--danger)",
  1445. });
  1446. if (!r.isConfirmed) return;
  1447. await apiFetch(`/admin/resources/${res.id}/download-cache?${new URLSearchParams({ commit }).toString()}`, { method: "DELETE" });
  1448. showToastSuccess("已清理");
  1449. await refreshAll();
  1450. await loadResources();
  1451. },
  1452. },
  1453. "清理"
  1454. )
  1455. )
  1456. )
  1457. );
  1458. tbody.appendChild(tr);
  1459. });
  1460. listBox.appendChild(el("table", { class: "table" }, thead, tbody));
  1461. } catch (e) {
  1462. msg.textContent = `加载失败:${e.detail?.error || e.status || "unknown"}`;
  1463. if (e.status === 401) window.location.href = "/ui/admin/login";
  1464. }
  1465. }
  1466. openModal(
  1467. `下载缓存 #${res.id}`,
  1468. [
  1469. el("div", { class: "muted" }, `仓库:${res.repoOwner}/${res.repoName}`),
  1470. el("label", { class: "label" }, "ref(用于检查/刷新)"),
  1471. refInput,
  1472. summaryBox,
  1473. statusBox,
  1474. msg,
  1475. listBox,
  1476. ],
  1477. [
  1478. el("button", { class: "btn", onclick: closeModal }, "关闭"),
  1479. el(
  1480. "button",
  1481. {
  1482. class: "btn",
  1483. onclick: async () => {
  1484. await refreshAll();
  1485. },
  1486. },
  1487. "刷新"
  1488. ),
  1489. el(
  1490. "button",
  1491. {
  1492. class: "btn",
  1493. onclick: async () => {
  1494. msg.textContent = "";
  1495. try {
  1496. const resp = await apiFetch(`/admin/resources/${res.id}/download-cache/refresh`, { method: "POST", body: { ref: refInput.value.trim() || String(res.defaultRef || "") } });
  1497. showToastSuccess(resp.ready ? "已生成" : "已开始生成");
  1498. await refreshAll();
  1499. await loadResources();
  1500. } catch (e) {
  1501. msg.textContent = `刷新失败:${e.detail?.error || e.status || "unknown"}`;
  1502. if (e.status === 401) window.location.href = "/ui/admin/login";
  1503. }
  1504. },
  1505. },
  1506. "强制刷新"
  1507. ),
  1508. el(
  1509. "button",
  1510. {
  1511. class: "btn btn-danger",
  1512. onclick: async () => {
  1513. const r = await Swal.fire({
  1514. title: "清理全部缓存?",
  1515. text: "将删除该资源所有下载缓存文件。",
  1516. icon: "warning",
  1517. showCancelButton: true,
  1518. confirmButtonText: "清理",
  1519. cancelButtonText: "取消",
  1520. confirmButtonColor: "var(--danger)",
  1521. });
  1522. if (!r.isConfirmed) return;
  1523. try {
  1524. await apiFetch(`/admin/resources/${res.id}/download-cache?${new URLSearchParams({ all: "1" }).toString()}`, { method: "DELETE" });
  1525. showToastSuccess("已清理");
  1526. await refreshAll();
  1527. await loadResources();
  1528. } catch (e) {
  1529. msg.textContent = `清理失败:${e.detail?.error || e.status || "unknown"}`;
  1530. if (e.status === 401) window.location.href = "/ui/admin/login";
  1531. }
  1532. },
  1533. },
  1534. "清理全部"
  1535. ),
  1536. ],
  1537. "ri-archive-line"
  1538. );
  1539. refreshAll();
  1540. }
  1541. async function loadOrders() {
  1542. const orderTbody = document.querySelector("#orderTable tbody");
  1543. orderTbody.innerHTML = "";
  1544. const query = new URLSearchParams();
  1545. if (orderQ.value.trim()) query.set("q", orderQ.value.trim());
  1546. if (orderStatusFilter.value) query.set("status", orderStatusFilter.value);
  1547. query.set("page", String(orderState.page));
  1548. query.set("pageSize", String(orderState.pageSize));
  1549. const orders = await apiFetch(`/admin/orders?${query.toString()}`);
  1550. orderState.total = Number(orders.total || 0);
  1551. orderMap.clear();
  1552. (orders.items || []).forEach((o) => {
  1553. orderMap.set(String(o.id), o);
  1554. const isLocked = o.status === "PAID";
  1555. const delBtn = el("button", { class: "btn", "data-action": "del-order", "data-id": String(o.id) }, "删除");
  1556. if (isLocked) {
  1557. delBtn.disabled = true;
  1558. }
  1559. const tr = el(
  1560. "tr",
  1561. {},
  1562. el("td", { title: o.id }, o.id),
  1563. el("td", {}, orderStatusBadge(o.status)),
  1564. el("td", {}, formatCents(o.amountCents)),
  1565. el("td", { title: `${o.userId} / ${o.userPhone}` }, `${o.userId} / ${o.userPhone}`),
  1566. el(
  1567. "td",
  1568. { title: `${o.planSnapshot.name}(${o.planSnapshot.durationDays}天 / ${formatCents(o.planSnapshot.priceCents)})` },
  1569. o.planSnapshot.name
  1570. ),
  1571. el("td", { title: formatDateTime(o.createdAt) }, formatDateTime(o.createdAt)),
  1572. el("td", { title: formatDateTime(o.paidAt) }, formatDateTime(o.paidAt)),
  1573. el(
  1574. "td",
  1575. { class: "td-actions" },
  1576. btnGroup(
  1577. el("button", { class: "btn", "data-action": "view-order", "data-id": String(o.id) }, "查看"),
  1578. delBtn
  1579. )
  1580. )
  1581. );
  1582. orderTbody.appendChild(tr);
  1583. });
  1584. if (!(orders.items || []).length) renderEmptyRow(orderTbody, 8, "暂无数据");
  1585. const pageCount = Math.max(1, Math.ceil(orderState.total / orderState.pageSize));
  1586. orderPageInfo.textContent = `第 ${orderState.page} / ${pageCount} 页,共 ${orderState.total} 条`;
  1587. orderPrevPage.disabled = orderState.page <= 1;
  1588. orderNextPage.disabled = orderState.page >= pageCount;
  1589. }
  1590. async function loadUsers() {
  1591. const userTbody = document.querySelector("#userTable tbody");
  1592. userTbody.innerHTML = "";
  1593. const query = new URLSearchParams();
  1594. if (userQ.value.trim()) query.set("q", userQ.value.trim());
  1595. if (userStatusFilter.value) query.set("status", userStatusFilter.value);
  1596. if (userVipFilter && userVipFilter.value) query.set("vip", userVipFilter.value);
  1597. query.set("page", String(userState.page));
  1598. query.set("pageSize", String(userState.pageSize));
  1599. const resp = await apiFetch(`/admin/users?${query.toString()}`);
  1600. const users = resp.items || [];
  1601. userState.total = Number(resp.total || 0);
  1602. userMap.clear();
  1603. users.forEach((u) => {
  1604. userMap.set(String(u.id), u);
  1605. const vipBadge = u.vipActive ? badge("VIP", "badge-vip") : badge("非VIP", "badge");
  1606. const vipDays = u.vipActive && Number.isFinite(Number(u.vipRemainingDays)) ? `剩余 ${Number(u.vipRemainingDays)} 天` : "";
  1607. const vipInfo = el(
  1608. "div",
  1609. { style: "display:flex; align-items:center; gap:8px; white-space:nowrap;" },
  1610. vipBadge,
  1611. vipDays ? el("span", { class: "muted", style: "font-size: inherit;" }, vipDays) : null
  1612. );
  1613. const tr = el(
  1614. "tr",
  1615. {},
  1616. el("td", { title: String(u.id) }, String(u.id)),
  1617. el("td", { title: u.phone }, u.phone),
  1618. el("td", {}, userStatusBadge(u.status)),
  1619. el("td", {}, vipInfo),
  1620. el("td", { title: formatDateTime(u.vipExpireAt) }, formatDateTime(u.vipExpireAt)),
  1621. el("td", { title: formatDateTime(u.createdAt) }, formatDateTime(u.createdAt)),
  1622. el(
  1623. "td",
  1624. { class: "td-actions" },
  1625. btnGroup(
  1626. el("button", { class: "btn", "data-action": "user-actions", "data-id": String(u.id) }, "操作")
  1627. )
  1628. )
  1629. );
  1630. userTbody.appendChild(tr);
  1631. });
  1632. if (!users.length) renderEmptyRow(userTbody, 7, "暂无数据");
  1633. const pageCount = Math.max(1, Math.ceil(userState.total / userState.pageSize));
  1634. userPageInfo.textContent = `第 ${userState.page} / ${pageCount} 页,共 ${userState.total} 条`;
  1635. userPrevPage.disabled = userState.page <= 1;
  1636. userNextPage.disabled = userState.page >= pageCount;
  1637. }
  1638. async function loadDownloadLogs() {
  1639. const tbody = document.querySelector("#downloadLogTable tbody");
  1640. tbody.innerHTML = "";
  1641. const query = new URLSearchParams();
  1642. if (dlQ && dlQ.value.trim()) query.set("q", dlQ.value.trim());
  1643. if (dlTypeFilter && dlTypeFilter.value) query.set("type", dlTypeFilter.value);
  1644. if (dlStateFilter && dlStateFilter.value) query.set("state", dlStateFilter.value);
  1645. query.set("page", String(downloadLogState.page));
  1646. query.set("pageSize", String(downloadLogState.pageSize));
  1647. const resp = await apiFetch(`/admin/download-logs?${query.toString()}`);
  1648. downloadLogState.total = Number(resp.total || 0);
  1649. downloadLogMap.clear();
  1650. (resp.items || []).forEach((it) => {
  1651. downloadLogMap.set(String(it.id), it);
  1652. const stateBadge =
  1653. it.resourceState === "DELETED"
  1654. ? badge("已删除", "badge-danger")
  1655. : it.resourceState === "OFFLINE"
  1656. ? badge("已下架", "badge-warning")
  1657. : badge("在线", "badge-success");
  1658. const typeBadge = it.resourceType === "VIP" ? badge("VIP", "badge-vip") : badge("免费", "badge-free");
  1659. const currentTypeBadge =
  1660. it.currentResourceType === "VIP"
  1661. ? badge("VIP", "badge-vip")
  1662. : it.currentResourceType === "FREE"
  1663. ? badge("免费", "badge-free")
  1664. : badge("-", "badge");
  1665. const userCell = `${it.userId} / ${it.userPhone || "-"}`;
  1666. const titleText = String(it.resourceTitle || "");
  1667. const titleNode =
  1668. it.resourceId && it.resourceState === "ONLINE"
  1669. ? el("a", { href: `/ui/resources/${it.resourceId}`, style: "color: inherit; text-decoration: none;" }, titleText)
  1670. : el("span", { class: "muted" }, titleText);
  1671. const tr = el(
  1672. "tr",
  1673. {},
  1674. el("td", { title: String(it.id) }, String(it.id)),
  1675. el("td", { title: formatDateTime(it.downloadedAt) }, formatDateTime(it.downloadedAt)),
  1676. el("td", { title: userCell }, userCell),
  1677. el("td", { title: titleText }, titleNode),
  1678. el("td", {}, typeBadge),
  1679. el("td", {}, currentTypeBadge),
  1680. el("td", {}, stateBadge),
  1681. el("td", { title: String(it.ip || "") }, String(it.ip || "-")),
  1682. el(
  1683. "td",
  1684. { class: "td-actions" },
  1685. btnGroup(el("button", { class: "btn", "data-action": "view-download-log", "data-id": String(it.id) }, "查看"))
  1686. )
  1687. );
  1688. tbody.appendChild(tr);
  1689. });
  1690. if (!(resp.items || []).length) renderEmptyRow(tbody, 9, "暂无数据");
  1691. const pageCount = Math.max(1, Math.ceil(downloadLogState.total / downloadLogState.pageSize));
  1692. if (dlPageInfo) dlPageInfo.textContent = `第 ${downloadLogState.page} / ${pageCount} 页,共 ${downloadLogState.total} 条`;
  1693. if (dlPrevPage) dlPrevPage.disabled = downloadLogState.page <= 1;
  1694. if (dlNextPage) dlNextPage.disabled = downloadLogState.page >= pageCount;
  1695. }
  1696. async function loadAdminMessages() {
  1697. if (!msgTbody) return;
  1698. msgTbody.innerHTML = "";
  1699. const params = new URLSearchParams();
  1700. params.set("page", String(messageState.page));
  1701. params.set("pageSize", String(messageState.pageSize));
  1702. const q = (msgQ?.value || "").trim();
  1703. if (q) params.set("q", q);
  1704. const read = (msgReadFilter?.value || "").trim();
  1705. if (read) params.set("read", read);
  1706. const senderType = (msgSenderFilter?.value || "").trim();
  1707. if (senderType) params.set("senderType", senderType);
  1708. try {
  1709. const resp = await apiFetch(`/admin/messages?${params.toString()}`);
  1710. messageState.total = parseInt(resp.total || 0, 10) || 0;
  1711. messageMap.clear();
  1712. const items = Array.isArray(resp.items) ? resp.items : [];
  1713. items.forEach((m) => {
  1714. messageMap.set(String(m.id), m);
  1715. const userCell = `${m.userId} / ${m.userPhone || "-"}`;
  1716. const readBadge = m.read ? badge("已读", "badge-success") : badge("未读", "badge-warning");
  1717. const senderBadge = m.senderType === "ADMIN" ? badge("管理员", "badge-info") : badge("系统", "badge");
  1718. const titleText = String(m.title || "");
  1719. const tr = el(
  1720. "tr",
  1721. {},
  1722. el("td", { title: String(m.id) }, String(m.id)),
  1723. el("td", { title: userCell }, userCell),
  1724. el("td", { title: titleText }, titleText),
  1725. el("td", { title: formatDateTime(m.createdAt) }, formatDateTime(m.createdAt)),
  1726. el("td", {}, readBadge),
  1727. el("td", {}, senderBadge),
  1728. el(
  1729. "td",
  1730. { class: "td-actions" },
  1731. btnGroup(
  1732. el("button", { class: "btn", "data-action": "view-message", "data-id": String(m.id) }, "查看"),
  1733. el("button", { class: "btn btn-danger", "data-action": "del-message", "data-id": String(m.id) }, "删除")
  1734. )
  1735. )
  1736. );
  1737. msgTbody.appendChild(tr);
  1738. });
  1739. if (!items.length) renderEmptyRow(msgTbody, 7, "暂无数据");
  1740. const pageCount = Math.max(1, Math.ceil(messageState.total / messageState.pageSize));
  1741. if (msgPageInfo) msgPageInfo.textContent = `第 ${messageState.page} / ${pageCount} 页,共 ${messageState.total} 条`;
  1742. if (msgPrevPage) msgPrevPage.disabled = messageState.page <= 1;
  1743. if (msgNextPage) msgNextPage.disabled = messageState.page >= pageCount;
  1744. } catch (e) {
  1745. if (e.status === 401) window.location.href = "/ui/admin/login";
  1746. renderEmptyRow(msgTbody, 7, `加载失败:${e.detail?.error || e.status || "unknown"}`);
  1747. }
  1748. }
  1749. async function loadAdminOverview() {
  1750. if (!ovUsersTotal || !ovSystemInfo) return;
  1751. try {
  1752. if (overviewUpdatedAt) overviewUpdatedAt.textContent = "加载中…";
  1753. const stats = await apiFetch("/admin/stats");
  1754. if (ovUsersTotal) ovUsersTotal.textContent = String(stats?.users?.total ?? 0);
  1755. if (ovUsersSub) ovUsersSub.textContent = `活跃 ${stats?.users?.active ?? 0},VIP ${stats?.users?.vipActive ?? 0}`;
  1756. if (ovResourcesTotal) ovResourcesTotal.textContent = String(stats?.resources?.total ?? 0);
  1757. if (ovResourcesSub) ovResourcesSub.textContent = `上架 ${stats?.resources?.online ?? 0}`;
  1758. if (ovOrdersTotal) ovOrdersTotal.textContent = String(stats?.orders?.total ?? 0);
  1759. if (ovOrdersSub) ovOrdersSub.textContent = `已付 ${stats?.orders?.paid ?? 0},待付 ${stats?.orders?.pending ?? 0}`;
  1760. if (ovRevenueTotal) ovRevenueTotal.textContent = formatCents(stats?.revenue?.totalCents ?? 0);
  1761. if (ovRevenueSub) ovRevenueSub.textContent = `24h ${formatCents(stats?.revenue?.last24hCents ?? 0)}`;
  1762. if (ovDownloadsTotal) ovDownloadsTotal.textContent = String(stats?.downloads?.total ?? 0);
  1763. if (ovDownloadsSub) ovDownloadsSub.textContent = `24h ${stats?.downloads?.last24h ?? 0}`;
  1764. if (ovMessagesTotal) ovMessagesTotal.textContent = String(stats?.messages?.total ?? 0);
  1765. if (ovMessagesSub) ovMessagesSub.textContent = `24h ${stats?.messages?.last24h ?? 0}`;
  1766. if (ovSystemInfo) ovSystemInfo.textContent = `当前数据库:${stats?.backend || "-"},统计时间:${formatDateTime(stats?.now)}`;
  1767. if (overviewUpdatedAt) overviewUpdatedAt.textContent = `更新时间:${formatDateTime(stats?.now)}`;
  1768. } catch (e) {
  1769. if (e.status === 401) window.location.href = "/ui/admin/login";
  1770. if (overviewUpdatedAt) overviewUpdatedAt.textContent = `加载失败:${e.detail?.error || e.status || "unknown"}`;
  1771. }
  1772. }
  1773. async function activate(section) {
  1774. const effectiveSection = section;
  1775. document.querySelectorAll(".menu-item").forEach((a) => a.classList.remove("active"));
  1776. const link = document.querySelector(`.menu-item[data-section='${section}']`);
  1777. if (link) link.classList.add("active");
  1778. document.querySelectorAll(".content-section").forEach((s) => (s.style.display = "none"));
  1779. const sec = document.getElementById(`sec-${effectiveSection}`);
  1780. if (sec) sec.style.display = "";
  1781. if (effectiveSection === "overview") {
  1782. contentTitle.textContent = "概览";
  1783. await loadAdminOverview();
  1784. } else if (effectiveSection === "plans") {
  1785. contentTitle.textContent = "会员方案";
  1786. await loadPlans();
  1787. } else if (effectiveSection === "resources") {
  1788. contentTitle.textContent = "资源管理";
  1789. await loadResources();
  1790. } else if (effectiveSection === "uploads") {
  1791. contentTitle.textContent = "上传管理";
  1792. await loadUploads();
  1793. } else if (effectiveSection === "orders") {
  1794. contentTitle.textContent = "订单管理";
  1795. orderState.page = 1;
  1796. await loadOrders();
  1797. } else if (effectiveSection === "users") {
  1798. contentTitle.textContent = "用户管理";
  1799. await loadUsers();
  1800. } else if (effectiveSection === "download-logs") {
  1801. contentTitle.textContent = "下载记录";
  1802. downloadLogState.page = 1;
  1803. await loadDownloadLogs();
  1804. } else if (effectiveSection === "messages") {
  1805. contentTitle.textContent = "消息管理";
  1806. messageState.page = 1;
  1807. await loadAdminMessages();
  1808. } else if (effectiveSection === "settings") {
  1809. contentTitle.textContent = "第三方配置";
  1810. await loadSettings();
  1811. }
  1812. }
  1813. menu.addEventListener("click", async (evt) => {
  1814. const a = evt.target.closest(".menu-item");
  1815. if (!a) return;
  1816. evt.preventDefault();
  1817. const sec = a.getAttribute("data-section");
  1818. await activate(sec);
  1819. });
  1820. if (overviewRefreshBtn) {
  1821. overviewRefreshBtn.addEventListener("click", async () => {
  1822. await loadAdminOverview();
  1823. });
  1824. }
  1825. settingsRefreshBtn.addEventListener("click", async () => {
  1826. await loadSettings();
  1827. });
  1828. settingsSaveBtn.addEventListener("click", async () => {
  1829. await saveSettings();
  1830. });
  1831. if (cfgPayProvider) {
  1832. cfgPayProvider.addEventListener("change", () => {
  1833. updatePayProviderVisibility();
  1834. });
  1835. }
  1836. if (cfgAlipayUseCurrentNotify && cfgAlipayNotifyUrl) {
  1837. cfgAlipayUseCurrentNotify.addEventListener("click", () => {
  1838. cfgAlipayNotifyUrl.value = `${window.location.origin}/pay/callback`;
  1839. });
  1840. }
  1841. if (cfgAlipayUseCurrentReturn && cfgAlipayReturnUrl) {
  1842. cfgAlipayUseCurrentReturn.addEventListener("click", () => {
  1843. cfgAlipayReturnUrl.value = `${window.location.origin}/ui/me`;
  1844. });
  1845. }
  1846. if (cfgShowAlipayPrivateKey && cfgAlipayPrivateKey) {
  1847. cfgShowAlipayPrivateKey.addEventListener("change", () => {
  1848. cfgAlipayPrivateKey.classList.toggle("is-revealed", Boolean(cfgShowAlipayPrivateKey.checked));
  1849. });
  1850. }
  1851. if (cfgShowAlipayPublicKey && cfgAlipayPublicKey) {
  1852. cfgShowAlipayPublicKey.addEventListener("change", () => {
  1853. cfgAlipayPublicKey.classList.toggle("is-revealed", Boolean(cfgShowAlipayPublicKey.checked));
  1854. });
  1855. }
  1856. if (cfgGogsSaveBtn) {
  1857. cfgGogsSaveBtn.addEventListener("click", async () => {
  1858. await saveSettingsPartial({
  1859. gogsBaseUrl: cfgGogsBaseUrl.value.trim(),
  1860. gogsToken: cfgGogsToken.value.trim(),
  1861. clearGogsToken: cfgClearGogsToken.checked,
  1862. });
  1863. });
  1864. }
  1865. if (cfgGogsResetBtn) {
  1866. cfgGogsResetBtn.addEventListener("click", async () => {
  1867. await loadSettings();
  1868. const g = document.getElementById("cfgGroupGogs");
  1869. if (g) g.setAttribute("data-open", "1");
  1870. });
  1871. }
  1872. if (cfgPaySaveBtn) {
  1873. cfgPaySaveBtn.addEventListener("click", async () => {
  1874. await saveSettingsPartial({
  1875. payment: {
  1876. provider: cfgPayProvider.value,
  1877. enableMockPay: cfgEnableMockPay.checked,
  1878. apiKey: cfgPayApiKey.value.trim(),
  1879. clearApiKey: cfgClearPayApiKey.checked,
  1880. alipay: {
  1881. appId: cfgAlipayAppId ? cfgAlipayAppId.value.trim() : "",
  1882. gateway: cfgAlipayGateway ? cfgAlipayGateway.value.trim() : "",
  1883. notifyUrl: cfgAlipayNotifyUrl ? cfgAlipayNotifyUrl.value.trim() : "",
  1884. returnUrl: cfgAlipayReturnUrl ? cfgAlipayReturnUrl.value.trim() : "",
  1885. privateKey: cfgAlipayPrivateKey ? cfgAlipayPrivateKey.value.trim() : "",
  1886. clearPrivateKey: cfgClearAlipayPrivateKey ? cfgClearAlipayPrivateKey.checked : false,
  1887. publicKey: cfgAlipayPublicKey ? cfgAlipayPublicKey.value.trim() : "",
  1888. clearPublicKey: cfgClearAlipayPublicKey ? cfgClearAlipayPublicKey.checked : false,
  1889. },
  1890. },
  1891. });
  1892. });
  1893. }
  1894. if (cfgPayResetBtn) {
  1895. cfgPayResetBtn.addEventListener("click", async () => {
  1896. await loadSettings();
  1897. const g = document.getElementById("cfgGroupPay");
  1898. if (g) g.setAttribute("data-open", "1");
  1899. });
  1900. }
  1901. if (cfgLlmSaveBtn) {
  1902. cfgLlmSaveBtn.addEventListener("click", async () => {
  1903. await saveSettingsPartial({
  1904. llm: {
  1905. provider: cfgLlmProvider.value.trim(),
  1906. baseUrl: cfgLlmBaseUrl.value.trim(),
  1907. model: cfgLlmModel.value.trim(),
  1908. apiKey: cfgLlmApiKey.value.trim(),
  1909. clearApiKey: cfgClearLlmApiKey.checked,
  1910. },
  1911. });
  1912. });
  1913. }
  1914. if (cfgLlmResetBtn) {
  1915. cfgLlmResetBtn.addEventListener("click", async () => {
  1916. await loadSettings();
  1917. const g = document.getElementById("cfgGroupLlm");
  1918. if (g) g.setAttribute("data-open", "1");
  1919. });
  1920. }
  1921. if (cfgCacheSaveBtn) {
  1922. cfgCacheSaveBtn.addEventListener("click", async () => {
  1923. await saveSettingsPartial({
  1924. cache: {
  1925. redisUrl: cfgRedisUrl ? cfgRedisUrl.value.trim() : "",
  1926. clearRedisUrl: cfgClearRedisUrl ? cfgClearRedisUrl.checked : false,
  1927. },
  1928. });
  1929. });
  1930. }
  1931. if (cfgCacheResetBtn) {
  1932. cfgCacheResetBtn.addEventListener("click", async () => {
  1933. await loadSettings();
  1934. const g = document.getElementById("cfgGroupCache");
  1935. if (g) g.setAttribute("data-open", "1");
  1936. });
  1937. }
  1938. if (cfgRedisTestBtn) {
  1939. cfgRedisTestBtn.addEventListener("click", async () => {
  1940. await testRedisConnection();
  1941. });
  1942. }
  1943. if (cfgStorageSaveBtn) {
  1944. cfgStorageSaveBtn.addEventListener("click", async () => {
  1945. await saveSettingsPartial({
  1946. storage: {
  1947. provider: cfgStorageProvider ? cfgStorageProvider.value : "AUTO",
  1948. oss: {
  1949. endpoint: cfgOssEndpoint ? cfgOssEndpoint.value.trim() : "",
  1950. bucket: cfgOssBucket ? cfgOssBucket.value.trim() : "",
  1951. accessKeyId: cfgOssAccessKeyId ? cfgOssAccessKeyId.value.trim() : "",
  1952. accessKeySecret: cfgOssAccessKeySecret ? cfgOssAccessKeySecret.value.trim() : "",
  1953. clearAccessKeySecret: cfgClearOssAccessKeySecret ? cfgClearOssAccessKeySecret.checked : false,
  1954. uploadPrefix: cfgOssUploadPrefix ? cfgOssUploadPrefix.value.trim() : "",
  1955. publicBaseUrl: cfgOssPublicBaseUrl ? cfgOssPublicBaseUrl.value.trim() : "",
  1956. },
  1957. },
  1958. });
  1959. });
  1960. }
  1961. if (cfgStorageResetBtn) {
  1962. cfgStorageResetBtn.addEventListener("click", async () => {
  1963. await loadSettings();
  1964. const g = document.getElementById("cfgGroupStorage");
  1965. if (g) g.setAttribute("data-open", "1");
  1966. });
  1967. }
  1968. if (cfgDbSaveBtn) {
  1969. cfgDbSaveBtn.addEventListener("click", async () => {
  1970. await saveSettingsPartial({
  1971. mysql: {
  1972. host: cfgMysqlHost ? cfgMysqlHost.value.trim() : "",
  1973. port: cfgMysqlPort ? cfgMysqlPort.value.trim() : "",
  1974. user: cfgMysqlUser ? cfgMysqlUser.value.trim() : "",
  1975. password: cfgMysqlPassword ? cfgMysqlPassword.value.trim() : "",
  1976. clearPassword: cfgClearMysqlPassword ? cfgClearMysqlPassword.checked : false,
  1977. database: cfgMysqlDatabase ? cfgMysqlDatabase.value.trim() : "",
  1978. },
  1979. });
  1980. });
  1981. }
  1982. if (cfgDbResetBtn) {
  1983. cfgDbResetBtn.addEventListener("click", async () => {
  1984. await loadSettings();
  1985. const g = document.getElementById("cfgGroupDb");
  1986. if (g) g.setAttribute("data-open", "1");
  1987. });
  1988. }
  1989. if (cfgMysqlTestBtn) {
  1990. cfgMysqlTestBtn.addEventListener("click", async () => {
  1991. await testMysqlConnection();
  1992. });
  1993. }
  1994. if (cfgDbSwitchMysqlBtn) {
  1995. cfgDbSwitchMysqlBtn.addEventListener("click", async () => {
  1996. await switchDatabase("mysql", false);
  1997. });
  1998. }
  1999. if (cfgDbSwitchSqliteBtn) {
  2000. cfgDbSwitchSqliteBtn.addEventListener("click", async () => {
  2001. await switchDatabase("sqlite", false);
  2002. });
  2003. }
  2004. uploadsFilterAll.addEventListener("click", async () => {
  2005. setUploadsFilter("all");
  2006. await loadUploads();
  2007. });
  2008. uploadsFilterUnused.addEventListener("click", async () => {
  2009. setUploadsFilter("unused");
  2010. await loadUploads();
  2011. });
  2012. uploadsFilterUsed.addEventListener("click", async () => {
  2013. setUploadsFilter("used");
  2014. await loadUploads();
  2015. });
  2016. uploadsRefreshBtn.addEventListener("click", async () => {
  2017. await loadUploads();
  2018. });
  2019. uploadsQ.addEventListener("keydown", async (evt) => {
  2020. if (evt.key !== "Enter") return;
  2021. evt.preventDefault();
  2022. await loadUploads();
  2023. });
  2024. uploadsUploadBtn.addEventListener("click", () => {
  2025. uploadsFile.value = "";
  2026. uploadsFile.click();
  2027. });
  2028. uploadsFile.addEventListener("change", async () => {
  2029. const files = uploadsFile.files ? Array.from(uploadsFile.files) : [];
  2030. if (!files.length) return;
  2031. uploadsUploadBtn.disabled = true;
  2032. uploadsCleanupBtn.disabled = true;
  2033. uploadsRefreshBtn.disabled = true;
  2034. try {
  2035. for (const f of files) {
  2036. await adminUploadFileMeta(f);
  2037. }
  2038. showToastSuccess("上传成功");
  2039. await loadUploads();
  2040. } catch (e) {
  2041. showToastError(e.detail?.error || e.status || "上传失败");
  2042. if (e.status === 401) window.location.href = "/ui/admin/login";
  2043. } finally {
  2044. uploadsUploadBtn.disabled = false;
  2045. uploadsCleanupBtn.disabled = false;
  2046. uploadsRefreshBtn.disabled = false;
  2047. }
  2048. });
  2049. uploadsCleanupBtn.addEventListener("click", async () => {
  2050. const r = await Swal.fire({
  2051. title: "一键清理未使用文件?",
  2052. text: "将删除 uploads 目录中所有未被资源引用的文件。",
  2053. icon: "warning",
  2054. showCancelButton: true,
  2055. confirmButtonText: "开始清理",
  2056. cancelButtonText: "取消",
  2057. confirmButtonColor: "var(--danger)",
  2058. });
  2059. if (!r.isConfirmed) return;
  2060. try {
  2061. const resp = await apiFetch("/admin/uploads/cleanup-unused", { method: "POST" });
  2062. showToastSuccess(`已清理 ${resp.deletedCount || 0} 个文件`);
  2063. await loadUploads();
  2064. } catch (e) {
  2065. showToastError(e.detail?.error || e.status || "清理失败");
  2066. if (e.status === 401) window.location.href = "/ui/admin/login";
  2067. }
  2068. });
  2069. createPlanOpenBtn.addEventListener("click", () => {
  2070. const nameInput = el("input", { class: "input", placeholder: "名称" });
  2071. const daysInput = el("input", { class: "input", placeholder: "时长(天)" });
  2072. const priceInput = el("input", { class: "input", placeholder: "价格(分)" });
  2073. const enabledSelect = el("select", { class: "input" }, el("option", { value: "1" }, "启用"), el("option", { value: "0" }, "禁用"));
  2074. const sortInput = el("input", { class: "input", placeholder: "排序,默认 0", value: "0" });
  2075. const msg = el("div", { class: "muted" }, "");
  2076. openModal(
  2077. "新增方案",
  2078. [
  2079. el("label", { class: "label" }, "名称"),
  2080. nameInput,
  2081. el("label", { class: "label" }, "时长(天)"),
  2082. daysInput,
  2083. el("label", { class: "label" }, "价格(分)"),
  2084. priceInput,
  2085. el("label", { class: "label" }, "启用"),
  2086. enabledSelect,
  2087. el("label", { class: "label" }, "排序"),
  2088. sortInput,
  2089. msg,
  2090. ],
  2091. [
  2092. el("button", { class: "btn", onclick: closeModal }, "取消"),
  2093. el(
  2094. "button",
  2095. {
  2096. class: "btn btn-primary",
  2097. onclick: async () => {
  2098. msg.textContent = "";
  2099. try {
  2100. await apiFetch("/admin/plans", {
  2101. method: "POST",
  2102. body: {
  2103. name: nameInput.value.trim(),
  2104. durationDays: Number(daysInput.value),
  2105. priceCents: Number(priceInput.value),
  2106. enabled: enabledSelect.value === "1",
  2107. sort: Number(sortInput.value || "0"),
  2108. },
  2109. });
  2110. closeModal();
  2111. await loadPlans();
  2112. } catch (e) {
  2113. msg.textContent = `创建失败:${e.detail?.error || e.status || "unknown"}`;
  2114. if (e.status === 401) window.location.href = "/ui/admin/login";
  2115. }
  2116. },
  2117. },
  2118. "创建"
  2119. ),
  2120. ],
  2121. "ri-add-circle-line"
  2122. );
  2123. });
  2124. document.addEventListener("click", async (evt) => {
  2125. const btn = evt.target.closest("button[data-action]");
  2126. if (!btn) return;
  2127. const action = btn.getAttribute("data-action");
  2128. const id = btn.getAttribute("data-id");
  2129. const openVipAdjustModal = (u) => {
  2130. const daysInput = el("input", { class: "input", value: "30" });
  2131. const msg = el("div", { class: "muted" }, "");
  2132. openModal(
  2133. `调整会员 #${u.id}`,
  2134. [
  2135. el("div", { class: "muted" }, `手机号:${u.phone},当前到期:${formatDateTime(u.vipExpireAt)}`),
  2136. el("label", { class: "label" }, "增加天数(可为负数)"),
  2137. daysInput,
  2138. msg,
  2139. ],
  2140. [
  2141. el("button", { class: "btn", onclick: closeModal }, "取消"),
  2142. el(
  2143. "button",
  2144. {
  2145. class: "btn btn-primary",
  2146. onclick: async () => {
  2147. msg.textContent = "";
  2148. try {
  2149. await apiFetch(`/admin/users/${u.id}/vip-adjust`, { method: "POST", body: { addDays: Number(daysInput.value) } });
  2150. closeModal();
  2151. await loadUsers();
  2152. } catch (e) {
  2153. msg.textContent = `保存失败:${e.detail?.error || e.status || "unknown"}`;
  2154. if (e.status === 401) window.location.href = "/ui/admin/login";
  2155. }
  2156. },
  2157. },
  2158. "保存"
  2159. ),
  2160. ],
  2161. "ri-vip-crown-line"
  2162. );
  2163. };
  2164. const openResetUserPasswordModal = (u) => {
  2165. const msg = el("div", { class: "muted" }, "");
  2166. const passwordInput = el("input", { class: "input", type: "password" });
  2167. const confirmInput = el("input", { class: "input", type: "password" });
  2168. const generate = () => `${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`.slice(0, 12);
  2169. passwordInput.value = generate();
  2170. confirmInput.value = passwordInput.value;
  2171. openModal(
  2172. `重置密码 #${u.id}`,
  2173. [
  2174. el("div", { class: "muted" }, `手机号:${u.phone}`),
  2175. el("label", { class: "label" }, "新密码(至少 6 位)"),
  2176. passwordInput,
  2177. el("label", { class: "label" }, "确认新密码"),
  2178. confirmInput,
  2179. msg,
  2180. ],
  2181. [
  2182. el("button", { class: "btn", onclick: closeModal }, "取消"),
  2183. el(
  2184. "button",
  2185. {
  2186. class: "btn",
  2187. onclick: () => {
  2188. passwordInput.value = generate();
  2189. confirmInput.value = passwordInput.value;
  2190. },
  2191. },
  2192. "随机生成"
  2193. ),
  2194. el("button", { class: "btn", onclick: () => copyText(passwordInput.value) }, "复制密码"),
  2195. el(
  2196. "button",
  2197. {
  2198. class: "btn btn-primary",
  2199. onclick: async () => {
  2200. msg.textContent = "";
  2201. const p1 = passwordInput.value || "";
  2202. const p2 = confirmInput.value || "";
  2203. if (p1.length < 6) {
  2204. msg.textContent = "新密码至少 6 位";
  2205. return;
  2206. }
  2207. if (p1 !== p2) {
  2208. msg.textContent = "两次输入不一致";
  2209. return;
  2210. }
  2211. try {
  2212. await apiFetch(`/admin/users/${u.id}/password-reset`, { method: "POST", body: { password: p1 } });
  2213. closeModal();
  2214. showToastSuccess("已重置密码");
  2215. } catch (e) {
  2216. msg.textContent = `重置失败:${e.detail?.error || e.status || "unknown"}`;
  2217. if (e.status === 401) window.location.href = "/ui/admin/login";
  2218. }
  2219. },
  2220. },
  2221. "确认重置"
  2222. ),
  2223. ],
  2224. "ri-key-2-line"
  2225. );
  2226. };
  2227. if (action === "del-plan") {
  2228. try {
  2229. await apiFetch(`/admin/plans/${id}`, { method: "DELETE" });
  2230. await loadPlans();
  2231. } catch (e) {
  2232. if (e.status === 401) window.location.href = "/ui/admin/login";
  2233. }
  2234. }
  2235. if (action === "edit-plan") {
  2236. const plan = planMap.get(String(id));
  2237. if (!plan) return;
  2238. const nameInput = el("input", { class: "input", value: plan.name });
  2239. const daysInput = el("input", { class: "input", value: String(plan.durationDays) });
  2240. const priceInput = el("input", { class: "input", value: String(plan.priceCents) });
  2241. const enabledSelect = el(
  2242. "select",
  2243. { class: "input" },
  2244. el("option", { value: "1" }, "启用"),
  2245. el("option", { value: "0" }, "禁用")
  2246. );
  2247. enabledSelect.value = plan.enabled ? "1" : "0";
  2248. const sortInput = el("input", { class: "input", value: String(plan.sort) });
  2249. const msg = el("div", { class: "muted" }, "");
  2250. openModal(
  2251. `编辑方案 #${plan.id}`,
  2252. [
  2253. el("label", { class: "label" }, "名称"),
  2254. nameInput,
  2255. el("label", { class: "label" }, "时长(天)"),
  2256. daysInput,
  2257. el("label", { class: "label" }, "价格(分)"),
  2258. priceInput,
  2259. el("label", { class: "label" }, "启用"),
  2260. enabledSelect,
  2261. el("label", { class: "label" }, "排序"),
  2262. sortInput,
  2263. msg,
  2264. ],
  2265. [
  2266. el("button", { class: "btn", onclick: closeModal }, "取消"),
  2267. el(
  2268. "button",
  2269. {
  2270. class: "btn btn-primary",
  2271. onclick: async () => {
  2272. msg.textContent = "";
  2273. try {
  2274. await apiFetch(`/admin/plans/${plan.id}`, {
  2275. method: "PUT",
  2276. body: {
  2277. name: nameInput.value.trim(),
  2278. durationDays: Number(daysInput.value),
  2279. priceCents: Number(priceInput.value),
  2280. enabled: enabledSelect.value === "1",
  2281. sort: Number(sortInput.value),
  2282. },
  2283. });
  2284. closeModal();
  2285. await loadPlans();
  2286. } catch (e) {
  2287. msg.textContent = `保存失败:${e.detail?.error || e.status || "unknown"}`;
  2288. if (e.status === 401) window.location.href = "/ui/admin/login";
  2289. }
  2290. },
  2291. },
  2292. "保存"
  2293. ),
  2294. ],
  2295. "ri-edit-circle-line"
  2296. );
  2297. }
  2298. if (action === "del-res") {
  2299. try {
  2300. await apiFetch(`/admin/resources/${id}`, { method: "DELETE" });
  2301. await loadResources();
  2302. } catch (e) {
  2303. if (e.status === 401) window.location.href = "/ui/admin/login";
  2304. }
  2305. }
  2306. if (action === "cache-res") {
  2307. const res = resourceMap.get(String(id));
  2308. if (!res) return;
  2309. openResourceDownloadCacheModal(res);
  2310. }
  2311. if (action === "edit-res") {
  2312. const res = resourceMap.get(String(id));
  2313. if (!res) return;
  2314. openResourceEditorModal({ mode: "edit", res });
  2315. }
  2316. if (action === "toggle-user") {
  2317. const next = btn.getAttribute("data-next");
  2318. try {
  2319. await apiFetch(`/admin/users/${id}`, { method: "PUT", body: { status: next } });
  2320. await loadUsers();
  2321. } catch (e) {
  2322. if (e.status === 401) window.location.href = "/ui/admin/login";
  2323. }
  2324. }
  2325. if (action === "vip-user") {
  2326. const u = userMap.get(String(id));
  2327. if (!u) return;
  2328. openVipAdjustModal(u);
  2329. }
  2330. if (action === "reset-user-pass") {
  2331. const u = userMap.get(String(id));
  2332. if (!u) return;
  2333. openResetUserPasswordModal(u);
  2334. }
  2335. if (action === "user-actions") {
  2336. const u = userMap.get(String(id));
  2337. if (!u) return;
  2338. const nextStatus = u.status === "ACTIVE" ? "DISABLED" : "ACTIVE";
  2339. openModal(
  2340. `用户操作 #${u.id}`,
  2341. [el("div", { class: "muted" }, `手机号:${u.phone}`)],
  2342. [
  2343. el("button", { class: "btn", onclick: closeModal }, "关闭"),
  2344. el(
  2345. "button",
  2346. {
  2347. class: "btn",
  2348. onclick: async () => {
  2349. try {
  2350. await apiFetch(`/admin/users/${u.id}`, { method: "PUT", body: { status: nextStatus } });
  2351. closeModal();
  2352. await loadUsers();
  2353. } catch (e) {
  2354. if (e.status === 401) window.location.href = "/ui/admin/login";
  2355. }
  2356. },
  2357. },
  2358. nextStatus === "DISABLED" ? "禁用" : "启用"
  2359. ),
  2360. el(
  2361. "button",
  2362. {
  2363. class: "btn",
  2364. onclick: () => {
  2365. openResetUserPasswordModal(u);
  2366. },
  2367. },
  2368. "重置密码"
  2369. ),
  2370. el(
  2371. "button",
  2372. {
  2373. class: "btn",
  2374. onclick: () => {
  2375. openVipAdjustModal(u);
  2376. },
  2377. },
  2378. "调整会员"
  2379. ),
  2380. ],
  2381. "ri-settings-3-line"
  2382. );
  2383. }
  2384. if (action === "view-order") {
  2385. const box = el("div", {});
  2386. const msg = el("div", { class: "muted" }, "加载中…");
  2387. box.appendChild(msg);
  2388. openModal("订单详情", [box], [el("button", { class: "btn", onclick: closeModal }, "关闭")], "ri-file-list-3-line");
  2389. try {
  2390. const o = await apiFetch(`/admin/orders/${id}`);
  2391. box.innerHTML = "";
  2392. box.appendChild(el("div", { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" }, el("div", { class: "muted" }, "订单号"), el("div", {}, String(o.id))));
  2393. box.appendChild(
  2394. el(
  2395. "div",
  2396. { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" },
  2397. el("div", { class: "muted" }, "状态"),
  2398. el("div", {}, orderStatusBadge(o.status))
  2399. )
  2400. );
  2401. box.appendChild(el("div", { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" }, el("div", { class: "muted" }, "金额"), el("div", {}, formatCents(o.amountCents))));
  2402. box.appendChild(el("div", { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" }, el("div", { class: "muted" }, "用户"), el("div", {}, `${o.userId} / ${o.userPhone}`)));
  2403. box.appendChild(
  2404. el(
  2405. "div",
  2406. { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" },
  2407. el("div", { class: "muted" }, "方案"),
  2408. el("div", {}, `${o.planSnapshot?.name || "-"}(${o.planSnapshot?.durationDays || "-"}天 / ${formatCents(o.planSnapshot?.priceCents || 0)})`)
  2409. )
  2410. );
  2411. box.appendChild(el("div", { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" }, el("div", { class: "muted" }, "创建时间"), el("div", {}, formatDateTime(o.createdAt))));
  2412. box.appendChild(el("div", { class: "card", style: "padding:14px; border-radius: 10px;" }, el("div", { class: "muted" }, "支付时间"), el("div", {}, formatDateTime(o.paidAt))));
  2413. } catch (e) {
  2414. msg.textContent = `加载失败:${e.detail?.error || e.status || "unknown"}`;
  2415. if (e.status === 401) window.location.href = "/ui/admin/login";
  2416. }
  2417. }
  2418. if (action === "view-download-log") {
  2419. const it = downloadLogMap.get(String(id));
  2420. if (!it) return;
  2421. const userText = `${it.userId} / ${it.userPhone || "-"}`;
  2422. const resText = `${it.resourceId || "-"} / ${it.resourceTitle || "-"}`;
  2423. const stateText = it.resourceState === "DELETED" ? "资源已删除" : it.resourceState === "OFFLINE" ? "资源已下架" : "资源在线";
  2424. const typeText = it.resourceType === "VIP" ? "VIP" : "免费";
  2425. const currentTypeText = it.currentResourceType === "VIP" ? "VIP" : it.currentResourceType === "FREE" ? "免费" : "-";
  2426. const driftText = it.currentResourceType && it.currentResourceType !== it.resourceType ? "(类型已变更)" : "";
  2427. openModal(
  2428. "下载记录详情",
  2429. [
  2430. el(
  2431. "div",
  2432. { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" },
  2433. el("div", { class: "muted" }, "下载时间"),
  2434. el("div", {}, formatDateTime(it.downloadedAt))
  2435. ),
  2436. el("div", { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" }, el("div", { class: "muted" }, "用户"), el("div", {}, userText)),
  2437. el(
  2438. "div",
  2439. { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" },
  2440. el("div", { class: "muted" }, "资源"),
  2441. el("div", {}, resText)
  2442. ),
  2443. el(
  2444. "div",
  2445. { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" },
  2446. el("div", { class: "muted" }, "类型"),
  2447. el("div", {}, `下载时:${typeText} / 当前:${currentTypeText}${driftText}`)
  2448. ),
  2449. el("div", { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" }, el("div", { class: "muted" }, "资源状态"), el("div", {}, stateText)),
  2450. el("div", { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" }, el("div", { class: "muted" }, "Ref"), el("div", {}, String(it.ref || "-"))),
  2451. el("div", { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" }, el("div", { class: "muted" }, "IP"), el("div", {}, String(it.ip || "-"))),
  2452. el(
  2453. "div",
  2454. { class: "card", style: "padding:14px; border-radius: 10px;" },
  2455. el("div", { class: "muted" }, "User-Agent"),
  2456. el("div", {}, String(it.userAgent || "-"))
  2457. ),
  2458. ],
  2459. [el("button", { class: "btn", onclick: closeModal }, "关闭")],
  2460. "ri-download-cloud-line"
  2461. );
  2462. }
  2463. if (action === "view-message") {
  2464. const m = messageMap.get(String(id));
  2465. if (!m) return;
  2466. const header = el(
  2467. "div",
  2468. { class: "muted" },
  2469. `用户:${m.userId} / ${m.userPhone || "-"} · 来源:${m.senderType === "ADMIN" ? "管理员" : "系统"} · 发送:${formatDateTime(m.createdAt)} · 已读:${m.read ? formatDateTime(m.readAt) : "未读"}`
  2470. );
  2471. const titleEl = el("div", { style: "font-weight: 650; margin-top: 6px;" }, String(m.title || ""));
  2472. const contentEl = el("pre", { class: "code", style: "white-space: pre-wrap;" }, formatMessageText(m.content || ""));
  2473. openModal(
  2474. `消息 #${m.id}`,
  2475. [header, titleEl, contentEl],
  2476. [
  2477. el("button", { class: "btn", onclick: () => copyText(m.content || "") }, "复制内容"),
  2478. el("button", { class: "btn", onclick: closeModal }, "关闭"),
  2479. ],
  2480. "ri-mail-open-line",
  2481. { resizable: true, size: "lg" }
  2482. );
  2483. }
  2484. if (action === "del-message") {
  2485. const m = messageMap.get(String(id));
  2486. if (!m) return;
  2487. const r = await Swal.fire({
  2488. title: "删除消息?",
  2489. text: `将删除消息 #${m.id}(用户:${m.userPhone || m.userId})`,
  2490. icon: "warning",
  2491. showCancelButton: true,
  2492. confirmButtonText: "删除",
  2493. cancelButtonText: "取消",
  2494. confirmButtonColor: "var(--danger)",
  2495. });
  2496. if (!r.isConfirmed) return;
  2497. try {
  2498. await apiFetch(`/admin/messages/${m.id}`, { method: "DELETE" });
  2499. showToastSuccess("已删除");
  2500. await loadAdminMessages();
  2501. } catch (e) {
  2502. showToastError(e.detail?.error || e.status || "删除失败");
  2503. if (e.status === 401) window.location.href = "/ui/admin/login";
  2504. }
  2505. }
  2506. if (action === "del-order") {
  2507. Swal.fire({
  2508. title: "删除订单?",
  2509. text: `订单号:${id}`,
  2510. icon: "warning",
  2511. showCancelButton: true,
  2512. confirmButtonText: "删除",
  2513. cancelButtonText: "取消",
  2514. confirmButtonColor: "var(--danger)",
  2515. }).then(async (r) => {
  2516. if (!r.isConfirmed) return;
  2517. try {
  2518. await apiFetch(`/admin/orders/${id}`, { method: "DELETE" });
  2519. await loadOrders();
  2520. } catch (e) {
  2521. if (e.status === 401) window.location.href = "/ui/admin/login";
  2522. Swal.fire({ icon: "error", title: "删除失败", text: e.detail?.error || e.status || "未知错误" });
  2523. }
  2524. });
  2525. }
  2526. });
  2527. function openResourceEditorModal({ mode, res }) {
  2528. const isEdit = mode === "edit";
  2529. const field = (labelText, inputEl) => el("div", {}, el("div", { class: "label" }, labelText), inputEl);
  2530. const msg = el("div", { class: "form-msg muted" }, "");
  2531. const titleInput = el("input", { class: "input", placeholder: "标题", value: isEdit ? res.title : "" });
  2532. const keywordsInput = el("input", { class: "input", placeholder: "关键字(逗号分隔,可选)", value: isEdit && Array.isArray(res.tags) ? res.tags.join(",") : "" });
  2533. function makeSegmented(items, initialValue, { disabled, onChange } = {}) {
  2534. let value = String(initialValue || items[0]?.value || "");
  2535. const wrap = el("div", { class: "segmented", role: "group" });
  2536. function apply(v) {
  2537. value = String(v);
  2538. Array.from(wrap.querySelectorAll("button")).forEach((b) => b.classList.toggle("active", b.getAttribute("data-value") === value));
  2539. if (typeof onChange === "function") onChange(value);
  2540. }
  2541. items.forEach((it) => {
  2542. const b = el("button", { type: "button", class: "btn btn-sm", "data-value": String(it.value) }, String(it.label));
  2543. if (disabled) b.disabled = true;
  2544. b.addEventListener("click", (evt) => {
  2545. evt.preventDefault();
  2546. if (disabled) return;
  2547. apply(it.value);
  2548. });
  2549. wrap.appendChild(b);
  2550. });
  2551. apply(value);
  2552. return {
  2553. root: wrap,
  2554. getValue: () => value,
  2555. setValue: (v) => apply(v),
  2556. setInvalid: (bad) => wrap.classList.toggle("is-invalid", Boolean(bad)),
  2557. };
  2558. }
  2559. const typeHelp = el("div", { class: "help" }, "");
  2560. function refreshTypeHelp(v) {
  2561. const val = String(v || "");
  2562. typeHelp.textContent = val === "VIP" ? "VIP:仅会员可访问。" : "FREE:所有用户可访问。";
  2563. }
  2564. const typeSeg = makeSegmented(
  2565. [
  2566. { value: "FREE", label: "FREE" },
  2567. { value: "VIP", label: "VIP" },
  2568. ],
  2569. isEdit ? res.type : "FREE",
  2570. { onChange: refreshTypeHelp }
  2571. );
  2572. refreshTypeHelp(typeSeg.getValue());
  2573. const statusHelp = el("div", { class: "help" }, "");
  2574. function refreshStatusHelp(v) {
  2575. const val = String(v || "");
  2576. statusHelp.textContent = val === "ONLINE" ? "上线:前台可见。" : val === "OFFLINE" ? "下线:前台不可见。" : "草稿:用于编辑中,前台不可见。";
  2577. }
  2578. const statusSeg = makeSegmented(
  2579. [
  2580. { value: "ONLINE", label: "上线" },
  2581. { value: "OFFLINE", label: "下线" },
  2582. { value: "DRAFT", label: "草稿" },
  2583. ],
  2584. isEdit ? res.status : "ONLINE",
  2585. { onChange: refreshStatusHelp }
  2586. );
  2587. statusSeg.root.classList.add("nowrap");
  2588. refreshStatusHelp(statusSeg.getValue());
  2589. const defaultCoverUrl = "/static/images/resources/default.png";
  2590. const tempCoverUploads = new Set();
  2591. function extractUploadNameFromUrl(value) {
  2592. const m = String(value || "").match(/\/static\/uploads\/([0-9a-f]{32}(?:\.[a-z0-9]+)?)$/i);
  2593. return m ? String(m[1] || "") : "";
  2594. }
  2595. async function deleteUploadByName(name) {
  2596. const n = String(name || "").trim();
  2597. if (!n) return;
  2598. try {
  2599. await apiFetch(`/admin/uploads/${encodeURIComponent(n)}`, { method: "DELETE" });
  2600. } catch (e) {}
  2601. }
  2602. async function cleanupTempCoverUploads(keepUrl) {
  2603. const keepName = extractUploadNameFromUrl(keepUrl);
  2604. const tasks = [];
  2605. for (const name of Array.from(tempCoverUploads)) {
  2606. if (keepName && name.toLowerCase() === keepName.toLowerCase()) continue;
  2607. tasks.push(deleteUploadByName(name));
  2608. }
  2609. tempCoverUploads.clear();
  2610. if (tasks.length) await Promise.allSettled(tasks);
  2611. }
  2612. const coverUrlInput = el("input", { class: "input", placeholder: "封面图 URL(可选)", value: isEdit ? res.coverUrl || "" : "" });
  2613. const coverPreview = el("img", {
  2614. class: "resource-detail-cover cover-picker-img",
  2615. src: (coverUrlInput.value || "").trim() ? coverUrlInput.value : defaultCoverUrl,
  2616. alt: "cover",
  2617. role: "button",
  2618. tabindex: "0",
  2619. });
  2620. ensureImgFallback(coverPreview, defaultCoverUrl, "is-placeholder");
  2621. const coverFile = el("input", { type: "file", accept: "image/*", style: "display:none" });
  2622. coverPreview.addEventListener("click", () => coverFile.click());
  2623. coverPreview.addEventListener("keydown", (evt) => {
  2624. if (evt.key === "Enter" || evt.key === " ") {
  2625. evt.preventDefault();
  2626. coverFile.click();
  2627. }
  2628. });
  2629. coverFile.addEventListener("change", async () => {
  2630. const f = coverFile.files && coverFile.files[0];
  2631. if (!f) return;
  2632. msg.textContent = "上传中...";
  2633. try {
  2634. const detail = await adminUploadFileMeta(f);
  2635. const url = detail?.url || "";
  2636. if (detail?.name) tempCoverUploads.add(String(detail.name));
  2637. coverUrlInput.value = url;
  2638. coverPreview.src = url;
  2639. coverPreview.classList.remove("is-placeholder");
  2640. msg.textContent = "封面已更新";
  2641. } catch (e) {
  2642. msg.textContent = `上传失败:${e.detail?.error || e.status || "unknown"}`;
  2643. if (e.status === 401) window.location.href = "/ui/admin/login";
  2644. } finally {
  2645. coverFile.value = "";
  2646. }
  2647. });
  2648. let coverPreviewTimer = null;
  2649. function refreshCoverPreview() {
  2650. const url = coverUrlInput.value.trim();
  2651. if (!url) {
  2652. coverPreview.src = defaultCoverUrl;
  2653. coverPreview.classList.add("is-placeholder");
  2654. return;
  2655. }
  2656. coverPreview.src = url;
  2657. coverPreview.classList.remove("is-placeholder");
  2658. }
  2659. coverUrlInput.addEventListener("input", () => {
  2660. if (coverPreviewTimer) clearTimeout(coverPreviewTimer);
  2661. coverPreviewTimer = setTimeout(refreshCoverPreview, 250);
  2662. });
  2663. coverUrlInput.addEventListener("blur", refreshCoverPreview);
  2664. refreshCoverPreview();
  2665. function normalizeKeywordsValue(raw) {
  2666. const text = (raw || "").toString();
  2667. const parts = text
  2668. .split(/[,,\n\r\t]+/g)
  2669. .map((s) => s.trim())
  2670. .filter(Boolean);
  2671. const uniq = [];
  2672. const seen = new Set();
  2673. parts.forEach((p) => {
  2674. const key = p.toLowerCase();
  2675. if (seen.has(key)) return;
  2676. seen.add(key);
  2677. uniq.push(p);
  2678. });
  2679. return uniq.join(",");
  2680. }
  2681. keywordsInput.addEventListener("blur", () => {
  2682. keywordsInput.value = normalizeKeywordsValue(keywordsInput.value);
  2683. });
  2684. const md = buildMarkdownEditor({ initialValue: isEdit ? res.summary || "" : "", msgEl: msg });
  2685. const modeSeg = makeSegmented(
  2686. [
  2687. { value: "CREATE", label: "创建仓库" },
  2688. { value: "BIND", label: "绑定仓库" },
  2689. ],
  2690. isEdit ? "BIND" : "CREATE",
  2691. { disabled: isEdit }
  2692. );
  2693. const modeHelp = el("div", { class: "help" }, isEdit ? "编辑模式下仓库模式固定为绑定仓库。" : "创建仓库会初始化 README.md;绑定仓库可选择分支/标签。");
  2694. const createOwnerInput = el("input", { class: "input", placeholder: "仓库 Owner(可选,留空则创建到 Token 用户)" });
  2695. const createRepoInput = el("input", { class: "input", placeholder: "仓库名称(可选,留空则自动生成)" });
  2696. const createPrivateSeg = makeSegmented(
  2697. [
  2698. { value: "0", label: "公开" },
  2699. { value: "1", label: "私有" },
  2700. ],
  2701. "0"
  2702. );
  2703. const repoFullInput = el("input", { class: "input", placeholder: "仓库(owner/repo 或 URL/SSH 地址)" });
  2704. const refInput = el("input", { class: "input", placeholder: "默认引用(AUTO/分支/标签)", value: "AUTO" });
  2705. const refPickSelect = el("select", { class: "input" }, el("option", { value: "" }, "选择分支/标签(可选)"));
  2706. refPickSelect.addEventListener("change", () => {
  2707. if (refPickSelect.value) refInput.value = refPickSelect.value;
  2708. });
  2709. const pickRepoBtn = el("button", { class: "btn" }, "选择仓库");
  2710. const refreshRefBtn = el("button", { class: "btn btn-ghost" }, "刷新分支/标签");
  2711. const repoHint = el("div", { class: "help" }, "");
  2712. async function loadRepoAndRefs(prefer) {
  2713. const parsed = parseRepoInput(repoFullInput.value);
  2714. if (!parsed) {
  2715. repoHint.textContent = "请填写正确的仓库格式:owner/repo(或直接粘贴仓库地址)";
  2716. return;
  2717. }
  2718. try {
  2719. const info = await apiFetch(`/admin/gogs/repo?owner=${encodeURIComponent(parsed.owner)}&repo=${encodeURIComponent(parsed.repo)}`);
  2720. const wanted = (prefer || refInput.value || "AUTO").toString().trim() || "AUTO";
  2721. await fillRefSelect(parsed.owner, parsed.repo, refPickSelect, wanted);
  2722. if (!refInput.value.trim()) refInput.value = wanted;
  2723. repoHint.textContent = `仓库已识别:${info.fullName || `${parsed.owner}/${parsed.repo}`};默认分支:${(info.defaultBranch || "master").trim()}`;
  2724. } catch (e) {
  2725. const errCode = e.detail?.error || e.status || "unknown";
  2726. const upstream = e.detail?.status ? `(Gogs: ${e.detail.status})` : "";
  2727. repoHint.textContent = `仓库加载失败:${errCode}${upstream}`;
  2728. showToastError(`仓库加载失败:${errCode}`);
  2729. if (e.status === 401) window.location.href = "/ui/admin/login";
  2730. }
  2731. }
  2732. pickRepoBtn.addEventListener("click", () => {
  2733. const parsed = parseRepoInput(repoFullInput.value);
  2734. const initialOwner = parsed ? parsed.owner : "";
  2735. openRepoPicker(initialOwner, async ({ owner, name, fullName }) => {
  2736. if (fullName) repoFullInput.value = fullName;
  2737. else if (owner && name) repoFullInput.value = `${owner}/${name}`;
  2738. await loadRepoAndRefs("AUTO");
  2739. });
  2740. });
  2741. refreshRefBtn.addEventListener("click", async () => {
  2742. await loadRepoAndRefs(refInput.value.trim() || "AUTO");
  2743. });
  2744. repoFullInput.addEventListener("blur", async () => {
  2745. if (!repoFullInput.value.trim()) return;
  2746. await loadRepoAndRefs(refInput.value.trim() || "AUTO");
  2747. });
  2748. const createOwnerWrap = field("仓库 Owner(可选)", createOwnerInput);
  2749. const createRepoWrap = field("仓库名称(可选)", createRepoInput);
  2750. const createPrivateWrap = el("div", {}, el("div", { class: "label" }, "公开/私有"), createPrivateSeg.root, el("div", { class: "help" }, "创建仓库时生效。"));
  2751. const repoFullWrap = el("div", { style: "grid-column: 1 / -1" }, el("div", { class: "label" }, "仓库(owner/repo 或 URL/SSH)"), repoFullInput);
  2752. const refWrap = field("默认引用", refInput);
  2753. const refPickWrap = field("选择分支/标签", refPickSelect);
  2754. const repoActionsWrap = el(
  2755. "div",
  2756. { style: "grid-column: 1 / -1" },
  2757. el("div", { class: "toolbar", style: "margin:0" }, pickRepoBtn, refreshRefBtn)
  2758. );
  2759. function refreshMode() {
  2760. const isCreate = modeSeg.getValue() === "CREATE";
  2761. [createOwnerWrap, createRepoWrap, createPrivateWrap].forEach((n) => (n.style.display = isCreate ? "" : "none"));
  2762. [repoFullWrap, refWrap, refPickWrap, repoActionsWrap].forEach((n) => (n.style.display = isCreate ? "none" : ""));
  2763. repoHint.style.display = isCreate ? "none" : "";
  2764. }
  2765. modeSeg.root.addEventListener("click", refreshMode);
  2766. if (isEdit) {
  2767. repoFullInput.value = `${res.repoOwner}/${res.repoName}`;
  2768. refInput.value = (res.defaultRef || "AUTO").toString().trim() || "AUTO";
  2769. refreshMode();
  2770. setTimeout(() => loadRepoAndRefs(refInput.value.trim() || "AUTO"), 0);
  2771. } else {
  2772. refreshMode();
  2773. }
  2774. function makeCollapse(title, bodyNodes, open) {
  2775. const icon = el("i", { class: "ri-arrow-down-s-line collapse-icon" });
  2776. const head = el("button", { type: "button", class: "collapse-head" }, el("span", {}, title), icon);
  2777. const body = el("div", { class: "collapse-body" }, ...bodyNodes);
  2778. const wrap = el("div", { class: "collapse", "data-open": open ? "1" : "0" }, head, body);
  2779. function setOpen(next) {
  2780. wrap.setAttribute("data-open", next ? "1" : "0");
  2781. }
  2782. head.addEventListener("click", (evt) => {
  2783. evt.preventDefault();
  2784. const cur = wrap.getAttribute("data-open") === "1";
  2785. setOpen(!cur);
  2786. });
  2787. return { root: wrap, setOpen };
  2788. }
  2789. const baseSection = makeCollapse(
  2790. "基础属性",
  2791. [
  2792. el(
  2793. "div",
  2794. { class: "form-grid" },
  2795. el("div", { style: "grid-column: 1 / -1" }, el("div", { class: "label" }, "标题"), titleInput),
  2796. el("div", {}, el("div", { class: "label" }, "类型"), typeSeg.root, typeHelp),
  2797. el("div", {}, el("div", { class: "label" }, "状态"), statusSeg.root, statusHelp),
  2798. el("div", { style: "grid-column: 1 / -1" }, el("div", { class: "label" }, "关键字(逗号分隔,可选)"), keywordsInput, el("div", { class: "help" }, "支持中英文逗号/换行分隔,失焦时自动去重规范化。"))
  2799. ),
  2800. ],
  2801. true
  2802. );
  2803. const clearCoverBtn = el("button", { type: "button", class: "btn btn-ghost" }, "清空");
  2804. clearCoverBtn.addEventListener("click", (evt) => {
  2805. evt.preventDefault();
  2806. coverUrlInput.value = "";
  2807. try {
  2808. coverUrlInput.dispatchEvent(new Event("input", { bubbles: true }));
  2809. } catch (e) {}
  2810. refreshCoverPreview();
  2811. });
  2812. const coverSection = makeCollapse(
  2813. "封面",
  2814. [
  2815. el(
  2816. "div",
  2817. { class: "form-grid" },
  2818. el("div", { style: "grid-column: 1 / -1" }, el("div", { class: "help" }, "点击图片选择并上传封面")),
  2819. el("div", { style: "grid-column: 1 / -1" }, coverPreview),
  2820. el("div", { style: "grid-column: 1 / -1" }, el("div", { class: "label" }, "封面图 URL(可选)"), coverUrlInput),
  2821. el("div", { style: "grid-column: 1 / -1" }, el("div", { class: "toolbar", style: "margin:0" }, clearCoverBtn, coverFile))
  2822. ),
  2823. ],
  2824. false
  2825. );
  2826. const repoModeSection = makeCollapse(
  2827. "仓库",
  2828. [
  2829. el(
  2830. "div",
  2831. { class: "form-grid" },
  2832. el("div", { style: "grid-column: 1 / -1" }, el("div", { class: "label" }, "仓库模式"), modeSeg.root, modeHelp),
  2833. el("div", { style: "grid-column: 1 / -1" }, repoHint),
  2834. createOwnerWrap,
  2835. createRepoWrap,
  2836. createPrivateWrap,
  2837. repoFullWrap,
  2838. refWrap,
  2839. refPickWrap,
  2840. repoActionsWrap
  2841. ),
  2842. ],
  2843. false
  2844. );
  2845. const contentSection = el("div", { class: "form-section" }, el("div", { class: "form-section-title" }, "内容编辑"), md.root);
  2846. const side = el("div", { class: "res-form-side" }, baseSection.root, coverSection.root, repoModeSection.root);
  2847. const main = el("div", { class: "res-form-main" }, contentSection, msg);
  2848. const layout = el("div", { class: "res-form-layout" }, side, main);
  2849. function clearInvalid() {
  2850. [titleInput, keywordsInput, coverUrlInput, repoFullInput, refInput].forEach((x) => x.classList.remove("is-invalid"));
  2851. typeSeg.setInvalid(false);
  2852. statusSeg.setInvalid(false);
  2853. modeSeg.setInvalid(false);
  2854. createPrivateSeg.setInvalid(false);
  2855. }
  2856. function invalid(elOrSeg, text) {
  2857. if (elOrSeg === titleInput || elOrSeg === keywordsInput || elOrSeg === typeSeg || elOrSeg === statusSeg) baseSection.setOpen(true);
  2858. if (elOrSeg === coverUrlInput) coverSection.setOpen(true);
  2859. if (elOrSeg === repoFullInput || elOrSeg === refInput || elOrSeg === refPickSelect || elOrSeg === modeSeg || elOrSeg === createPrivateSeg) repoModeSection.setOpen(true);
  2860. if (elOrSeg && typeof elOrSeg.setInvalid === "function") elOrSeg.setInvalid(true);
  2861. else if (elOrSeg && elOrSeg.classList) elOrSeg.classList.add("is-invalid");
  2862. msg.textContent = String(text || "");
  2863. showToastError(text || "请检查填写内容");
  2864. try {
  2865. if (elOrSeg && elOrSeg.focus) elOrSeg.focus();
  2866. } catch (e) {}
  2867. }
  2868. async function fetchReadmeText() {
  2869. const parsed = parseRepoInput(repoFullInput.value);
  2870. if (!parsed) throw Object.assign(new Error("invalid_repo"), { detail: { error: "invalid_repo" } });
  2871. const ref = refInput.value.trim() || "AUTO";
  2872. const url = `/admin/gogs/file-text?owner=${encodeURIComponent(parsed.owner)}&repo=${encodeURIComponent(parsed.repo)}&ref=${encodeURIComponent(ref)}&path=${encodeURIComponent("README.md")}`;
  2873. const resp = await apiFetch(url);
  2874. return (resp.text || "").toString();
  2875. }
  2876. async function loadReadmeIntoEditor() {
  2877. msg.textContent = "";
  2878. const cur = (md.textarea.value || "").toString();
  2879. if (cur.trim()) {
  2880. try {
  2881. const r = await Swal.fire({
  2882. title: "用 README.md 覆盖当前内容?",
  2883. text: "覆盖后当前未保存内容将丢失。",
  2884. icon: "warning",
  2885. showCancelButton: true,
  2886. confirmButtonText: "覆盖",
  2887. cancelButtonText: "取消",
  2888. confirmButtonColor: "var(--danger)",
  2889. });
  2890. if (!r.isConfirmed) return;
  2891. } catch (e) {}
  2892. }
  2893. try {
  2894. msg.textContent = "加载 README.md 中...";
  2895. const text = await fetchReadmeText();
  2896. md.setText(text);
  2897. md.syncReadme.checked = true;
  2898. msg.textContent = "已从 README.md 导入";
  2899. showToastSuccess("README.md 已导入");
  2900. } catch (e) {
  2901. const code = e.detail?.error || e.status || e.message || "unknown";
  2902. msg.textContent = `README.md 加载失败:${code}`;
  2903. showToastError(`README.md 加载失败:${code}`);
  2904. if (e.status === 401) window.location.href = "/ui/admin/login";
  2905. }
  2906. }
  2907. const loadReadmeBtn = el("button", { type: "button", class: "btn btn-sm" }, "加载 README.md");
  2908. loadReadmeBtn.addEventListener("click", async (evt) => {
  2909. evt.preventDefault();
  2910. await loadReadmeIntoEditor();
  2911. });
  2912. function refreshReadmeBtn() {
  2913. const allow = isEdit || modeSeg.getValue() === "BIND";
  2914. const hasRepo = Boolean(parseRepoInput(repoFullInput.value));
  2915. loadReadmeBtn.disabled = !(allow && hasRepo);
  2916. }
  2917. repoFullInput.addEventListener("input", refreshReadmeBtn);
  2918. modeSeg.root.addEventListener("click", refreshReadmeBtn);
  2919. refreshReadmeBtn();
  2920. const spacer = Array.from(md.toolbarEl.children).find((n) => n && n.style && n.style.flex === "1");
  2921. if (spacer) md.toolbarEl.insertBefore(loadReadmeBtn, spacer);
  2922. else md.toolbarEl.appendChild(loadReadmeBtn);
  2923. if (isEdit && !(md.textarea.value || "").trim()) {
  2924. setTimeout(() => loadReadmeIntoEditor(), 0);
  2925. }
  2926. async function submitAndMaybeView(openAfter) {
  2927. msg.textContent = "";
  2928. clearInvalid();
  2929. const title = titleInput.value.trim();
  2930. if (!title) {
  2931. invalid(titleInput, "请填写标题");
  2932. return;
  2933. }
  2934. if (isEdit) {
  2935. const parsed = parseRepoInput(repoFullInput.value);
  2936. if (!parsed) {
  2937. invalid(repoFullInput, "请填写正确的仓库格式:owner/repo(或直接粘贴仓库地址)");
  2938. return;
  2939. }
  2940. msg.textContent = "保存中,请稍候...";
  2941. try {
  2942. await apiFetch(`/admin/resources/${res.id}`, {
  2943. method: "PUT",
  2944. body: {
  2945. title,
  2946. summary: md.textarea.value.trim(),
  2947. keywords: normalizeKeywordsValue(keywordsInput.value),
  2948. coverUrl: coverUrlInput.value.trim(),
  2949. type: typeSeg.getValue(),
  2950. status: statusSeg.getValue(),
  2951. repoOwner: parsed.owner,
  2952. repoName: parsed.repo,
  2953. defaultRef: refInput.value.trim() || "AUTO",
  2954. syncReadme: md.syncReadme.checked,
  2955. },
  2956. });
  2957. await cleanupTempCoverUploads(coverUrlInput.value.trim());
  2958. await closeModal(true);
  2959. await loadResources();
  2960. if (openAfter) window.open(`/ui/resources/${res.id}`, "_blank");
  2961. showToastSuccess("已保存");
  2962. } catch (e) {
  2963. msg.textContent = `保存失败:${e.detail?.error || e.status || "unknown"}`;
  2964. showToastError(e.detail?.error || e.status || "保存失败");
  2965. if (e.status === 401) window.location.href = "/ui/admin/login";
  2966. }
  2967. return;
  2968. }
  2969. msg.textContent = "创建中,请稍候...";
  2970. const body = {
  2971. title,
  2972. summary: md.textarea.value.trim(),
  2973. keywords: normalizeKeywordsValue(keywordsInput.value),
  2974. coverUrl: coverUrlInput.value.trim(),
  2975. type: typeSeg.getValue(),
  2976. status: statusSeg.getValue(),
  2977. syncReadme: md.syncReadme.checked,
  2978. };
  2979. if (modeSeg.getValue() === "CREATE") {
  2980. body.createRepo = true;
  2981. body.repoOwner = createOwnerInput.value.trim();
  2982. body.repoName = createRepoInput.value.trim();
  2983. body.repoPrivate = createPrivateSeg.getValue() === "1";
  2984. } else {
  2985. const parsed = parseRepoInput(repoFullInput.value);
  2986. if (!parsed) {
  2987. invalid(repoFullInput, "请填写正确的仓库格式:owner/repo(或直接粘贴仓库地址)");
  2988. return;
  2989. }
  2990. body.createRepo = false;
  2991. body.repoOwner = parsed.owner;
  2992. body.repoName = parsed.repo;
  2993. body.defaultRef = refInput.value.trim() || "AUTO";
  2994. }
  2995. try {
  2996. const resp = await apiFetch("/admin/resources", { method: "POST", body });
  2997. await cleanupTempCoverUploads(coverUrlInput.value.trim());
  2998. await closeModal(true);
  2999. await loadResources();
  3000. if (openAfter) window.open(`/ui/resources/${resp.id}`, "_blank");
  3001. showToastSuccess("已创建");
  3002. } catch (e) {
  3003. const code = e.detail?.error || e.status || "unknown";
  3004. const upstream = e.detail?.status ? `(Gogs: ${e.detail.status})` : "";
  3005. const detailMsg = e.detail?.message ? `\n${e.detail.message}` : "";
  3006. const detailUrl = e.detail?.url ? `\n${e.detail.url}` : "";
  3007. msg.textContent = `${isEdit ? "保存" : "创建"}失败:${code}${upstream}${detailMsg}${detailUrl}`;
  3008. showToastError(`${isEdit ? "保存" : "创建"}失败:${code}`);
  3009. if (e.status === 401) window.location.href = "/ui/admin/login";
  3010. }
  3011. }
  3012. const primaryBtn = el("button", { class: "btn btn-primary" }, isEdit ? "保存" : "创建");
  3013. const viewBtn = el("button", { class: "btn" }, isEdit ? "保存并查看" : "创建并查看");
  3014. primaryBtn.addEventListener("click", async () => {
  3015. primaryBtn.disabled = true;
  3016. viewBtn.disabled = true;
  3017. try {
  3018. await submitAndMaybeView(false);
  3019. } finally {
  3020. primaryBtn.disabled = false;
  3021. viewBtn.disabled = false;
  3022. }
  3023. });
  3024. viewBtn.addEventListener("click", async () => {
  3025. primaryBtn.disabled = true;
  3026. viewBtn.disabled = true;
  3027. try {
  3028. await submitAndMaybeView(true);
  3029. } finally {
  3030. primaryBtn.disabled = false;
  3031. viewBtn.disabled = false;
  3032. }
  3033. });
  3034. const initialDraft = JSON.stringify({
  3035. title: titleInput.value,
  3036. keywords: keywordsInput.value,
  3037. type: typeSeg.getValue(),
  3038. status: statusSeg.getValue(),
  3039. coverUrl: coverUrlInput.value,
  3040. summary: md.textarea.value,
  3041. syncReadme: md.syncReadme.checked,
  3042. repoMode: modeSeg.getValue(),
  3043. createOwner: createOwnerInput.value,
  3044. createRepo: createRepoInput.value,
  3045. createPrivate: createPrivateSeg.getValue(),
  3046. repoFull: repoFullInput.value,
  3047. ref: refInput.value,
  3048. });
  3049. function isDirty() {
  3050. const now = JSON.stringify({
  3051. title: titleInput.value,
  3052. keywords: keywordsInput.value,
  3053. type: typeSeg.getValue(),
  3054. status: statusSeg.getValue(),
  3055. coverUrl: coverUrlInput.value,
  3056. summary: md.textarea.value,
  3057. syncReadme: md.syncReadme.checked,
  3058. repoMode: modeSeg.getValue(),
  3059. createOwner: createOwnerInput.value,
  3060. createRepo: createRepoInput.value,
  3061. createPrivate: createPrivateSeg.getValue(),
  3062. repoFull: repoFullInput.value,
  3063. ref: refInput.value,
  3064. });
  3065. return now !== initialDraft;
  3066. }
  3067. openModal(isEdit ? `编辑资源 #${res.id}` : "新增资源", [layout], [el("button", { class: "btn", onclick: closeModal }, "取消"), viewBtn, primaryBtn], isEdit ? "ri-edit-circle-line" : "ri-add-box-line", {
  3068. resizable: true,
  3069. size: "lg",
  3070. onResize: (size) => md.setViewByModalSize(size),
  3071. onKeydown: (evt) => {
  3072. const key = String(evt.key || "");
  3073. if ((evt.ctrlKey || evt.metaKey) && key.toLowerCase() === "s") {
  3074. evt.preventDefault();
  3075. primaryBtn.click();
  3076. return;
  3077. }
  3078. if ((evt.ctrlKey || evt.metaKey) && key === "Enter") {
  3079. evt.preventDefault();
  3080. primaryBtn.click();
  3081. return;
  3082. }
  3083. if (key === "Escape") {
  3084. evt.preventDefault();
  3085. closeModal();
  3086. }
  3087. },
  3088. beforeClose: async () => {
  3089. if (!isDirty()) {
  3090. await cleanupTempCoverUploads("");
  3091. return true;
  3092. }
  3093. try {
  3094. const r = await Swal.fire({
  3095. title: "放弃未保存的修改?",
  3096. text: "当前内容尚未保存,关闭后将丢失。",
  3097. icon: "warning",
  3098. showCancelButton: true,
  3099. confirmButtonText: "放弃修改",
  3100. cancelButtonText: "继续编辑",
  3101. confirmButtonColor: "var(--danger)",
  3102. });
  3103. if (!r.isConfirmed) return false;
  3104. await cleanupTempCoverUploads("");
  3105. return true;
  3106. } catch (e) {
  3107. await cleanupTempCoverUploads("");
  3108. return true;
  3109. }
  3110. },
  3111. });
  3112. }
  3113. createResOpenBtn.addEventListener("click", () => {
  3114. openResourceEditorModal({ mode: "create" });
  3115. });
  3116. resSearchBtn.addEventListener("click", async () => {
  3117. resState.page = 1;
  3118. await loadResources();
  3119. });
  3120. resPrevPage.addEventListener("click", async () => {
  3121. resState.page = Math.max(1, resState.page - 1);
  3122. await loadResources();
  3123. });
  3124. resNextPage.addEventListener("click", async () => {
  3125. resState.page = resState.page + 1;
  3126. await loadResources();
  3127. });
  3128. userSearchBtn.addEventListener("click", async () => {
  3129. userState.page = 1;
  3130. await loadUsers();
  3131. });
  3132. if (dlSearchBtn) {
  3133. dlSearchBtn.addEventListener("click", async () => {
  3134. downloadLogState.page = 1;
  3135. await loadDownloadLogs();
  3136. });
  3137. }
  3138. if (dlQ) {
  3139. dlQ.addEventListener("keydown", async (evt) => {
  3140. if (evt.key !== "Enter") return;
  3141. evt.preventDefault();
  3142. downloadLogState.page = 1;
  3143. await loadDownloadLogs();
  3144. });
  3145. }
  3146. if (dlPrevPage) {
  3147. dlPrevPage.addEventListener("click", async () => {
  3148. downloadLogState.page = Math.max(1, downloadLogState.page - 1);
  3149. await loadDownloadLogs();
  3150. });
  3151. }
  3152. if (dlNextPage) {
  3153. dlNextPage.addEventListener("click", async () => {
  3154. downloadLogState.page += 1;
  3155. await loadDownloadLogs();
  3156. });
  3157. }
  3158. if (msgSearchBtn) {
  3159. msgSearchBtn.addEventListener("click", async () => {
  3160. messageState.page = 1;
  3161. await loadAdminMessages();
  3162. });
  3163. }
  3164. if (msgQ) {
  3165. msgQ.addEventListener("keydown", async (evt) => {
  3166. if (evt.key !== "Enter") return;
  3167. evt.preventDefault();
  3168. messageState.page = 1;
  3169. await loadAdminMessages();
  3170. });
  3171. }
  3172. if (msgPrevPage) {
  3173. msgPrevPage.addEventListener("click", async () => {
  3174. messageState.page = Math.max(1, messageState.page - 1);
  3175. await loadAdminMessages();
  3176. });
  3177. }
  3178. if (msgNextPage) {
  3179. msgNextPage.addEventListener("click", async () => {
  3180. messageState.page += 1;
  3181. await loadAdminMessages();
  3182. });
  3183. }
  3184. if (msgSendBtn) {
  3185. msgSendBtn.addEventListener("click", async () => {
  3186. const phoneInput = el("input", { class: "input", placeholder: "用户手机号(已注册)" });
  3187. const userIdInput = el("input", { class: "input", placeholder: "用户ID(可选,优先于手机号)" });
  3188. const titleInput = el("input", { class: "input", placeholder: "标题(必填)" });
  3189. const contentInput = el("textarea", { class: "input", style: "min-height: 180px; resize: vertical;", placeholder: "内容(必填)" });
  3190. const msg = el("div", { class: "muted" }, "");
  3191. openModal(
  3192. "发送消息",
  3193. [
  3194. el("div", { class: "muted" }, "发送给单个用户。填写用户ID或手机号即可。"),
  3195. el("label", { class: "label" }, "用户ID"),
  3196. userIdInput,
  3197. el("label", { class: "label" }, "手机号"),
  3198. phoneInput,
  3199. el("label", { class: "label" }, "标题"),
  3200. titleInput,
  3201. el("label", { class: "label" }, "内容"),
  3202. contentInput,
  3203. msg,
  3204. ],
  3205. [
  3206. el("button", { class: "btn", onclick: closeModal }, "取消"),
  3207. el(
  3208. "button",
  3209. {
  3210. class: "btn btn-primary",
  3211. onclick: async () => {
  3212. msg.textContent = "";
  3213. try {
  3214. const userId = Number((userIdInput.value || "").trim() || 0) || 0;
  3215. await apiFetch("/admin/messages/send", {
  3216. method: "POST",
  3217. body: { userId, phone: phoneInput.value.trim(), title: titleInput.value.trim(), content: contentInput.value },
  3218. });
  3219. closeModal();
  3220. showToastSuccess("发送成功");
  3221. await loadAdminMessages();
  3222. } catch (e) {
  3223. msg.textContent = `发送失败:${e.detail?.error || e.status || "unknown"}`;
  3224. if (e.status === 401) window.location.href = "/ui/admin/login";
  3225. }
  3226. },
  3227. },
  3228. "发送"
  3229. ),
  3230. ],
  3231. "ri-notification-3-line"
  3232. );
  3233. });
  3234. }
  3235. if (msgBroadcastBtn) {
  3236. msgBroadcastBtn.addEventListener("click", async () => {
  3237. const audienceSelect = el(
  3238. "select",
  3239. { class: "input" },
  3240. el("option", { value: "ALL" }, "全部用户"),
  3241. el("option", { value: "VIP" }, "仅 VIP 用户"),
  3242. el("option", { value: "NONVIP" }, "仅非 VIP 用户")
  3243. );
  3244. const titleInput = el("input", { class: "input", placeholder: "标题(必填)" });
  3245. const contentInput = el("textarea", { class: "input", style: "min-height: 200px; resize: vertical;", placeholder: "内容(必填)" });
  3246. const msg = el("div", { class: "muted" }, "");
  3247. openModal(
  3248. "群发消息",
  3249. [
  3250. el("div", { class: "muted" }, "将为符合条件的每个用户生成一条站内消息。"),
  3251. el("label", { class: "label" }, "发送范围"),
  3252. audienceSelect,
  3253. el("label", { class: "label" }, "标题"),
  3254. titleInput,
  3255. el("label", { class: "label" }, "内容"),
  3256. contentInput,
  3257. msg,
  3258. ],
  3259. [
  3260. el("button", { class: "btn", onclick: closeModal }, "取消"),
  3261. el(
  3262. "button",
  3263. {
  3264. class: "btn btn-primary",
  3265. onclick: async () => {
  3266. msg.textContent = "";
  3267. try {
  3268. const r = await Swal.fire({
  3269. title: "确认群发?",
  3270. text: "将立即发送站内消息给符合条件的用户。",
  3271. icon: "warning",
  3272. showCancelButton: true,
  3273. confirmButtonText: "确认发送",
  3274. cancelButtonText: "取消",
  3275. confirmButtonColor: "var(--brand)",
  3276. });
  3277. if (!r.isConfirmed) return;
  3278. const resp = await apiFetch("/admin/messages/broadcast", {
  3279. method: "POST",
  3280. body: { audience: audienceSelect.value, title: titleInput.value.trim(), content: contentInput.value },
  3281. });
  3282. closeModal();
  3283. showToastSuccess(`已发送 ${resp.count || 0} 条`);
  3284. await loadAdminMessages();
  3285. } catch (e) {
  3286. msg.textContent = `发送失败:${e.detail?.error || e.status || "unknown"}`;
  3287. if (e.status === 401) window.location.href = "/ui/admin/login";
  3288. }
  3289. },
  3290. },
  3291. "发送"
  3292. ),
  3293. ],
  3294. "ri-megaphone-line"
  3295. );
  3296. });
  3297. }
  3298. userPrevPage.addEventListener("click", async () => {
  3299. userState.page = Math.max(1, userState.page - 1);
  3300. await loadUsers();
  3301. });
  3302. userNextPage.addEventListener("click", async () => {
  3303. userState.page = userState.page + 1;
  3304. await loadUsers();
  3305. });
  3306. orderRefreshBtn.addEventListener("click", async () => {
  3307. orderState.page = 1;
  3308. await loadOrders();
  3309. });
  3310. orderCreateBtn.addEventListener("click", async () => {
  3311. const phoneInput = el("input", { class: "input", placeholder: "用户手机号(必须已注册)" });
  3312. const planSelect = el("select", { class: "input" }, el("option", { value: "" }, "加载中..."));
  3313. const statusSelect = el(
  3314. "select",
  3315. { class: "input" },
  3316. el("option", { value: "PENDING" }, "待支付"),
  3317. el("option", { value: "PAID" }, "已支付"),
  3318. el("option", { value: "CLOSED" }, "已关闭"),
  3319. el("option", { value: "FAILED" }, "失败")
  3320. );
  3321. const msg = el("div", { class: "muted" }, "");
  3322. openModal(
  3323. "新建订单",
  3324. [el("label", { class: "label" }, "用户手机号"), phoneInput, el("label", { class: "label" }, "方案"), planSelect, el("label", { class: "label" }, "状态"), statusSelect, el("div", { class: "muted" }, "设置为“已支付”会自动延长该用户会员。"), msg],
  3325. [
  3326. el("button", { class: "btn", onclick: closeModal }, "取消"),
  3327. el(
  3328. "button",
  3329. {
  3330. class: "btn btn-primary",
  3331. onclick: async () => {
  3332. msg.textContent = "";
  3333. const phone = phoneInput.value.trim();
  3334. const planId = Number(planSelect.value || "0");
  3335. if (!phone || planId <= 0) {
  3336. msg.textContent = "请填写手机号并选择方案";
  3337. return;
  3338. }
  3339. try {
  3340. await apiFetch("/admin/orders", { method: "POST", body: { userPhone: phone, planId, status: statusSelect.value } });
  3341. closeModal();
  3342. orderState.page = 1;
  3343. await loadOrders();
  3344. } catch (e) {
  3345. msg.textContent = `创建失败:${e.detail?.error || e.status || "unknown"}`;
  3346. if (e.status === 401) window.location.href = "/ui/admin/login";
  3347. }
  3348. },
  3349. },
  3350. "创建"
  3351. ),
  3352. ],
  3353. "ri-add-circle-line"
  3354. );
  3355. try {
  3356. const plans = await apiFetch("/admin/plans");
  3357. planSelect.innerHTML = "";
  3358. (plans || []).filter((p) => p && p.enabled).forEach((p) => {
  3359. planSelect.appendChild(el("option", { value: String(p.id) }, `${p.name}(${p.durationDays}天 / ${formatCents(p.priceCents)})`));
  3360. });
  3361. if (!planSelect.children.length) planSelect.appendChild(el("option", { value: "" }, "暂无可用方案"));
  3362. } catch (e) {
  3363. planSelect.innerHTML = "";
  3364. planSelect.appendChild(el("option", { value: "" }, "方案加载失败"));
  3365. if (e.status === 401) window.location.href = "/ui/admin/login";
  3366. }
  3367. });
  3368. orderStatusFilter.addEventListener("change", async () => {
  3369. orderState.page = 1;
  3370. await loadOrders();
  3371. });
  3372. orderPrevPage.addEventListener("click", async () => {
  3373. orderState.page = Math.max(1, orderState.page - 1);
  3374. await loadOrders();
  3375. });
  3376. orderNextPage.addEventListener("click", async () => {
  3377. orderState.page = orderState.page + 1;
  3378. await loadOrders();
  3379. });
  3380. /* inline creation handlers removed; now using modal-based creation */
  3381. adminLogoutBtn.addEventListener("click", async () => {
  3382. await apiFetch("/admin/auth/logout", { method: "POST" });
  3383. window.location.href = "/ui/admin/login";
  3384. });
  3385. try {
  3386. await activate("overview");
  3387. } catch (e) {
  3388. if (e.status === 401) window.location.href = "/ui/admin/login";
  3389. }
  3390. }
  3391. async function main() {
  3392. const page = document.body.getAttribute("data-page") || "";
  3393. try {
  3394. await initTopbar();
  3395. if (page === "admin_login") await pageAdminLogin();
  3396. if (page === "admin") await pageAdmin();
  3397. } catch (e) {
  3398. showToastError(e?.detail?.error || e?.status || e?.message || "页面初始化失败");
  3399. }
  3400. }
  3401. main();