app.js 195 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132413341344135413641374138413941404141414241434144414541464147414841494150415141524153415441554156415741584159416041614162416341644165416641674168416941704171417241734174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198419942004201420242034204420542064207420842094210421142124213421442154216421742184219422042214222422342244225422642274228422942304231423242334234423542364237423842394240424142424243424442454246424742484249425042514252425342544255425642574258425942604261426242634264426542664267426842694270427142724273427442754276427742784279428042814282428342844285428642874288428942904291429242934294429542964297429842994300430143024303430443054306430743084309431043114312431343144315431643174318431943204321432243234324432543264327432843294330433143324333433443354336433743384339434043414342434343444345434643474348434943504351435243534354435543564357435843594360436143624363436443654366436743684369437043714372437343744375437643774378437943804381438243834384438543864387438843894390439143924393439443954396439743984399440044014402440344044405440644074408440944104411441244134414441544164417441844194420442144224423442444254426442744284429443044314432443344344435443644374438443944404441444244434444444544464447444844494450445144524453445444554456445744584459446044614462446344644465446644674468446944704471447244734474447544764477447844794480448144824483448444854486448744884489449044914492449344944495449644974498449945004501450245034504450545064507450845094510451145124513451445154516451745184519452045214522452345244525452645274528452945304531453245334534453545364537453845394540454145424543454445454546454745484549455045514552455345544555455645574558455945604561456245634564456545664567456845694570457145724573457445754576457745784579458045814582458345844585458645874588458945904591459245934594459545964597459845994600460146024603460446054606460746084609461046114612461346144615461646174618461946204621462246234624462546264627462846294630463146324633463446354636463746384639464046414642464346444645464646474648464946504651465246534654465546564657465846594660466146624663466446654666466746684669467046714672467346744675467646774678467946804681468246834684468546864687468846894690469146924693469446954696469746984699470047014702470347044705470647074708470947104711471247134714471547164717471847194720472147224723472447254726472747284729473047314732473347344735473647374738473947404741474247434744474547464747474847494750475147524753475447554756475747584759476047614762476347644765476647674768476947704771477247734774477547764777477847794780478147824783478447854786478747884789479047914792479347944795479647974798479948004801480248034804480548064807480848094810481148124813481448154816481748184819482048214822482348244825482648274828482948304831483248334834483548364837483848394840484148424843
  1. function getCookie(name) {
  2. const raw = document.cookie || "";
  3. const parts = raw.split(";");
  4. for (const p of parts) {
  5. const s = p.trim();
  6. if (!s) continue;
  7. const idx = s.indexOf("=");
  8. if (idx <= 0) continue;
  9. const k = s.slice(0, idx).trim();
  10. if (k !== name) continue;
  11. return decodeURIComponent(s.slice(idx + 1));
  12. }
  13. return "";
  14. }
  15. async function apiFetch(url, { method = "GET", body } = {}) {
  16. const init = { method, headers: {} };
  17. if (!["GET", "HEAD", "OPTIONS", "TRACE"].includes(String(method || "GET").toUpperCase())) {
  18. const csrf = getCookie("csrf_token");
  19. if (csrf) init.headers["X-CSRF-Token"] = csrf;
  20. }
  21. if (body !== undefined) {
  22. init.headers["Content-Type"] = "application/json";
  23. init.body = JSON.stringify(body);
  24. }
  25. const resp = await fetch(url, init);
  26. const contentType = resp.headers.get("content-type") || "";
  27. const isJson = contentType.includes("application/json");
  28. if (!resp.ok) {
  29. let detail = null;
  30. if (isJson) {
  31. try {
  32. detail = await resp.json();
  33. } catch (e) {
  34. detail = null;
  35. }
  36. }
  37. const err = new Error("request_failed");
  38. err.status = resp.status;
  39. err.detail = detail;
  40. throw err;
  41. }
  42. if (isJson) return await resp.json();
  43. return resp;
  44. }
  45. const Toast = Swal.mixin({
  46. toast: true,
  47. position: 'top-end',
  48. showConfirmButton: false,
  49. timer: 3000,
  50. timerProgressBar: true,
  51. didOpen: (toast) => {
  52. toast.addEventListener('mouseenter', Swal.stopTimer)
  53. toast.addEventListener('mouseleave', Swal.resumeTimer)
  54. }
  55. });
  56. function showToastError(text) {
  57. Toast.fire({
  58. icon: 'error',
  59. title: String(text || "未知错误")
  60. });
  61. }
  62. function showToastSuccess(text) {
  63. Toast.fire({
  64. icon: 'success',
  65. title: String(text || "操作成功")
  66. });
  67. }
  68. function currentNextParam() {
  69. return encodeURIComponent(window.location.pathname + window.location.search);
  70. }
  71. function nextFromQuery() {
  72. const p = new URLSearchParams(window.location.search || "");
  73. const next = (p.get("next") || "").trim();
  74. if (!next) return "";
  75. if (!next.startsWith("/")) return "";
  76. if (next.startsWith("//")) return "";
  77. return next;
  78. }
  79. async function initTopbar() {
  80. const navAuth = document.getElementById("navAuth");
  81. const navLogin = document.getElementById("navLogin");
  82. const navRegister = document.getElementById("navRegister");
  83. if (navLogin) navLogin.setAttribute("href", `/ui/login?next=${currentNextParam()}`);
  84. if (navRegister) navRegister.setAttribute("href", `/ui/register?next=${currentNextParam()}`);
  85. if (!navAuth) return;
  86. let msgBadge = null;
  87. async function refreshMessageBadge() {
  88. if (!msgBadge) return;
  89. try {
  90. const data = await apiFetch("/me/messages?page=1&pageSize=1");
  91. const cnt = parseInt(data?.unreadCount || 0, 10) || 0;
  92. if (cnt > 0) {
  93. msgBadge.style.display = "inline-flex";
  94. msgBadge.textContent = cnt > 99 ? "99+" : String(cnt);
  95. } else {
  96. msgBadge.style.display = "none";
  97. msgBadge.textContent = "";
  98. }
  99. } catch (e) {
  100. msgBadge.style.display = "none";
  101. msgBadge.textContent = "";
  102. }
  103. }
  104. let me = null;
  105. try {
  106. me = await apiFetch("/me");
  107. } catch (e) {
  108. me = { user: null };
  109. }
  110. const user = me && me.user ? me.user : null;
  111. if (!user) return;
  112. navAuth.innerHTML = "";
  113. const userEl = el(
  114. "a",
  115. { class: "nav-user", href: "/ui/me", style: "display:flex; align-items:center; gap:4px;" },
  116. el("i", { class: "ri-user-smile-line" }),
  117. el("span", {}, String(user.phone || "我的"))
  118. );
  119. if (user.vipActive) userEl.appendChild(el("span", { class: "nav-badge nav-badge-vip" }, "VIP"));
  120. msgBadge = el("span", { class: "nav-msg-badge" }, "");
  121. const msgLink = el("a", { href: "/ui/messages", class: "btn-ghost nav-msg", title: "消息通知" }, el("i", { class: "ri-notification-3-line" }), msgBadge);
  122. const logout = el("a", { href: "#", class: "btn-ghost", style: "padding:6px 10px; border-radius: 8px; display:flex; align-items:center; gap:4px;" }, el("i", {class: "ri-logout-box-r-line"}), "退出");
  123. logout.addEventListener("click", async (evt) => {
  124. evt.preventDefault();
  125. try {
  126. await apiFetch("/auth/logout", { method: "POST" });
  127. } catch (e) {}
  128. window.location.href = "/";
  129. });
  130. navAuth.appendChild(userEl);
  131. navAuth.appendChild(msgLink);
  132. navAuth.appendChild(logout);
  133. window.__refreshMessageBadge = refreshMessageBadge;
  134. await refreshMessageBadge();
  135. }
  136. window.addEventListener("error", (evt) => {
  137. const msg = evt?.error?.message || evt?.message || "页面脚本错误";
  138. showToastError(msg);
  139. });
  140. window.addEventListener("unhandledrejection", (evt) => {
  141. const msg = evt?.reason?.message || String(evt?.reason || "异步错误");
  142. showToastError(msg);
  143. });
  144. function el(tag, attrs = {}, ...children) {
  145. const node = document.createElement(tag);
  146. Object.entries(attrs).forEach(([k, v]) => {
  147. if (k === "class") node.className = v;
  148. else if (k === "html") node.innerHTML = v;
  149. else if (k.startsWith("on") && typeof v === "function") node.addEventListener(k.slice(2), v);
  150. else node.setAttribute(k, v);
  151. });
  152. children.forEach((c) => {
  153. if (c === null || c === undefined) return;
  154. if (typeof c === "string") node.appendChild(document.createTextNode(c));
  155. else node.appendChild(c);
  156. });
  157. return node;
  158. }
  159. function btnGroup(...children) {
  160. return el("div", { class: "btn-group" }, ...children);
  161. }
  162. function renderEmptyRow(tbody, colCount, text) {
  163. tbody.appendChild(el("tr", {}, el("td", { colspan: String(colCount), class: "table-empty muted" }, text)));
  164. }
  165. function badge(text, variantClass = "") {
  166. const cls = ["badge", variantClass].filter(Boolean).join(" ");
  167. return el("span", { class: cls }, text);
  168. }
  169. function resourceTypeBadge(type) {
  170. if (type === "VIP") return badge("VIP", "badge-vip");
  171. return badge("免费", "badge-free");
  172. }
  173. function resourceStatusBadge(status) {
  174. if (status === "ONLINE") return badge("上架", "badge-success");
  175. if (status === "OFFLINE") return badge("下架", "badge-danger");
  176. return badge("草稿", "badge");
  177. }
  178. function orderStatusBadge(status) {
  179. if (status === "PAID") return badge("已支付", "badge-success");
  180. if (status === "PENDING") return badge("待支付", "badge-warning");
  181. if (status === "FAILED") return badge("失败", "badge-danger");
  182. return badge("已关闭", "badge");
  183. }
  184. function userStatusBadge(status) {
  185. if (status === "ACTIVE") return badge("启用", "badge-success");
  186. return badge("禁用", "badge-danger");
  187. }
  188. function formatCents(cents) {
  189. return `¥${(cents / 100).toFixed(2)}`;
  190. }
  191. function formatDateTime(value) {
  192. if (value === null || value === undefined) return "-";
  193. if (value === "-") return "-";
  194. if (typeof value === "number") {
  195. const ms = value < 1e12 ? value * 1000 : value;
  196. const d = new Date(ms);
  197. if (Number.isNaN(d.getTime())) return String(value);
  198. const pad2 = (n) => String(n).padStart(2, "0");
  199. return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
  200. }
  201. const s = String(value).trim();
  202. if (!s) return "-";
  203. if (/^\d+$/.test(s)) return formatDateTime(Number(s));
  204. const d = new Date(s);
  205. if (Number.isNaN(d.getTime())) return s;
  206. const pad2 = (n) => String(n).padStart(2, "0");
  207. return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
  208. }
  209. function formatMessageText(text) {
  210. const s = String(text || "");
  211. const isoRe = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})/g;
  212. return s.replace(isoRe, (m) => formatDateTime(m));
  213. }
  214. function escapeHtml(text) {
  215. return String(text || "")
  216. .replace(/&/g, "&amp;")
  217. .replace(/</g, "&lt;")
  218. .replace(/>/g, "&gt;")
  219. .replace(/"/g, "&quot;")
  220. .replace(/'/g, "&#39;");
  221. }
  222. function sanitizeMarkdownUrl(url) {
  223. const s = String(url || "").trim();
  224. if (!s) return "";
  225. if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(s)) {
  226. const scheme = s.split(":", 1)[0].toLowerCase();
  227. if (!["http", "https", "mailto"].includes(scheme)) return "";
  228. }
  229. return s;
  230. }
  231. function renderMarkdown(md) {
  232. const raw = String(md || "");
  233. const escaped = escapeHtml(raw);
  234. const blocks = [];
  235. const placeholder = (i) => `@@BLOCK_${i}@@`;
  236. const fenced = escaped.replace(/```([\s\S]*?)```/g, (_m, code) => {
  237. const html = `<pre class="code"><code>${code.replace(/^\n+|\n+$/g, "")}</code></pre>`;
  238. blocks.push(html);
  239. return placeholder(blocks.length - 1);
  240. });
  241. let html = fenced
  242. .replace(/^### (.*)$/gm, "<h3>$1</h3>")
  243. .replace(/^## (.*)$/gm, "<h2>$1</h2>")
  244. .replace(/^# (.*)$/gm, "<h1>$1</h1>")
  245. .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_m, alt, url) => {
  246. const safeUrl = sanitizeMarkdownUrl(url);
  247. if (!safeUrl) return `<span class="muted">[图片已拦截]</span>`;
  248. return `<img class="md-img" alt="${alt}" src="${escapeHtml(safeUrl)}" />`;
  249. })
  250. .replace(/@\[(video)\]\(([^)]+)\)/g, (_m, _t, url) => {
  251. const safeUrl = sanitizeMarkdownUrl(url);
  252. if (!safeUrl) return `<span class="muted">[视频已拦截]</span>`;
  253. return `<video class="md-video" controls src="${escapeHtml(safeUrl)}"></video>`;
  254. })
  255. .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, text, url) => {
  256. const safeUrl = sanitizeMarkdownUrl(url);
  257. if (!safeUrl) return `<span>${text}</span>`;
  258. return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener">${text}</a>`;
  259. })
  260. .replace(/`([^`]+)`/g, "<code>$1</code>")
  261. .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
  262. .replace(/\*([^*\n]+)\*/g, "<em>$1</em>");
  263. html = html.replace(/\n{2,}/g, "\n\n");
  264. html = html
  265. .split("\n\n")
  266. .map((p) => {
  267. if (p.startsWith("@@BLOCK_")) return p;
  268. if (/^<h[1-3]>/.test(p.trim()) || /^<pre /.test(p.trim())) return p;
  269. const lines = p.split("\n").join("<br>");
  270. return `<p>${lines}</p>`;
  271. })
  272. .join("\n");
  273. blocks.forEach((b, i) => {
  274. html = html.replaceAll(placeholder(i), b);
  275. });
  276. return html;
  277. }
  278. async function pageIndex() {
  279. const pageMode = document.body.getAttribute("data-page") || "";
  280. const isHome = pageMode === "index";
  281. const qInput = document.getElementById("q");
  282. const typeSelect = document.getElementById("type");
  283. const sortSelect = document.getElementById("sort");
  284. const list = document.getElementById("resourceList");
  285. const prevBtn = document.getElementById("prevPage");
  286. const nextBtn = document.getElementById("nextPage");
  287. const pageInfo = document.getElementById("pageInfo");
  288. const searchBtn = document.getElementById("searchBtn");
  289. const pager = document.getElementById("pager");
  290. const homeMore = document.getElementById("homeMore");
  291. let page = 1;
  292. function setLoading(loading) {
  293. if (searchBtn) searchBtn.disabled = loading;
  294. if (prevBtn) prevBtn.disabled = loading || page <= 1;
  295. if (nextBtn) nextBtn.disabled = loading;
  296. if (qInput) qInput.disabled = loading;
  297. if (typeSelect) typeSelect.disabled = loading;
  298. if (sortSelect) sortSelect.disabled = loading;
  299. }
  300. function skeletonCard() {
  301. const cover = el("div", { class: "resource-card-cover skeleton" });
  302. const line1 = el("div", { class: "skeleton skeleton-line" });
  303. const line2 = el("div", { class: "skeleton skeleton-line" });
  304. line1.style.width = "70%";
  305. line2.style.width = "90%";
  306. const stats = el("div", { class: "toolbar" }, el("div", { class: "skeleton skeleton-pill" }), el("div", { class: "skeleton skeleton-pill" }));
  307. return el("div", { class: "card" }, cover, line1, line2, stats);
  308. }
  309. function renderSkeleton(count) {
  310. list.innerHTML = "";
  311. for (let i = 0; i < count; i += 1) list.appendChild(skeletonCard());
  312. }
  313. function renderEmpty(text) {
  314. list.innerHTML = "";
  315. list.appendChild(
  316. el(
  317. "div",
  318. { class: "card", style: "grid-column: 1 / -1; text-align:center" },
  319. el("div", {}, text || "暂无数据"),
  320. el(
  321. "div",
  322. { class: "toolbar", style: "justify-content:center" },
  323. el(
  324. "button",
  325. {
  326. class: "btn",
  327. onclick: async () => {
  328. if (qInput) qInput.value = "";
  329. if (typeSelect) typeSelect.value = "";
  330. if (sortSelect) sortSelect.value = "latest";
  331. page = 1;
  332. await load();
  333. },
  334. },
  335. "清空筛选"
  336. )
  337. )
  338. )
  339. );
  340. }
  341. async function load() {
  342. if (!list) return;
  343. renderSkeleton(isHome ? 6 : 9);
  344. setLoading(true);
  345. const q = (qInput ? qInput.value : "").trim();
  346. const type = (typeSelect ? typeSelect.value : "").trim();
  347. const sort = (sortSelect ? sortSelect.value : "").trim() || "latest";
  348. const params = new URLSearchParams();
  349. if (q) params.set("q", q);
  350. if (type) params.set("type", type);
  351. if (!isHome) params.set("sort", sort);
  352. params.set("page", String(page));
  353. params.set("pageSize", isHome ? "9" : "12");
  354. let data = null;
  355. try {
  356. data = await apiFetch(`/resources?${params.toString()}`);
  357. } finally {
  358. setLoading(false);
  359. }
  360. list.innerHTML = "";
  361. const items = (data && data.items) || [];
  362. if (!items.length) {
  363. renderEmpty(q || type ? "没有找到匹配的资源" : "暂无资源");
  364. if (pageInfo) pageInfo.textContent = "";
  365. if (pager) pager.style.display = isHome ? "none" : "";
  366. if (homeMore) homeMore.style.display = isHome ? "" : "none";
  367. return;
  368. }
  369. items.forEach((r) => {
  370. const badgeClass = r.type === "VIP" ? "badge badge-vip" : "badge badge-free";
  371. const badgeIcon = r.type === "VIP" ? "ri-vip-crown-line" : "ri-price-tag-3-line";
  372. const cover = r.coverUrl
  373. ? el("div", { class: "resource-card-cover" }, el("img", { class: "resource-card-cover-img", src: r.coverUrl, alt: r.title }))
  374. : null;
  375. const tags = Array.isArray(r.tags) ? r.tags.slice(0, 4) : [];
  376. const tagRow = tags.length ? el("div", { class: "resource-card-tags" }, ...tags.map((t) => badge(t, "badge"))) : null;
  377. list.appendChild(
  378. el(
  379. "div",
  380. { class: "card", style: "display:flex; flex-direction:column; height:100%;" },
  381. cover,
  382. el(
  383. "div",
  384. { style: "flex:1;" },
  385. el("span", { class: badgeClass, style: "float:right; display:flex; align-items:center; gap:4px;" }, el("i", {class: badgeIcon}), r.type === "VIP" ? "VIP" : "FREE"),
  386. el("h3", { style: "margin-top:0;" }, r.title)
  387. ),
  388. tagRow,
  389. el("div", { class: "muted", style: "margin-bottom:16px; flex:1;" }, (r.summary || "").slice(0, 80)),
  390. el(
  391. "div",
  392. { class: "toolbar", style: "margin-top:auto;" },
  393. el("a", { class: "btn btn-primary", style: "display:flex; align-items:center; gap:4px;", href: `/ui/resources/${r.id}` }, el("i", {class: "ri-eye-line"}), "查看详情"),
  394. el("span", { class: "muted", style: "display:flex; align-items:center; gap:8px; font-size:0.9rem;" },
  395. el("span", {style:"display:flex; align-items:center; gap:2px;"}, el("i", {class: "ri-bar-chart-box-line"}), String(r.viewCount)),
  396. el("span", {style:"display:flex; align-items:center; gap:2px;"}, el("i", {class: "ri-download-cloud-2-line"}), String(r.downloadCount))
  397. )
  398. )
  399. )
  400. );
  401. });
  402. const totalPages = Math.max(Math.ceil((data.total || 0) / (data.pageSize || 12)), 1);
  403. if (pageInfo) pageInfo.textContent = isHome ? "" : `第 ${data.page} / ${totalPages} 页,共 ${data.total} 条`;
  404. if (prevBtn) prevBtn.disabled = isHome || page <= 1;
  405. if (nextBtn) nextBtn.disabled = isHome || page >= totalPages;
  406. if (pager) pager.style.display = isHome ? "none" : "";
  407. if (homeMore) homeMore.style.display = isHome ? "" : "none";
  408. }
  409. if (prevBtn)
  410. prevBtn.addEventListener("click", async () => {
  411. page = Math.max(page - 1, 1);
  412. await load();
  413. });
  414. if (nextBtn)
  415. nextBtn.addEventListener("click", async () => {
  416. page += 1;
  417. await load();
  418. });
  419. if (searchBtn)
  420. searchBtn.addEventListener("click", async () => {
  421. page = 1;
  422. await load();
  423. });
  424. if (sortSelect)
  425. sortSelect.addEventListener("change", async () => {
  426. page = 1;
  427. await load();
  428. });
  429. if (qInput)
  430. qInput.addEventListener("keydown", async (e) => {
  431. if (e.key !== "Enter") return;
  432. page = 1;
  433. await load();
  434. });
  435. await load();
  436. }
  437. async function pageLogin() {
  438. const phone = document.getElementById("phone");
  439. const password = document.getElementById("password");
  440. const btn = document.getElementById("loginBtn");
  441. const msg = document.getElementById("msg");
  442. const toRegister = document.getElementById("toRegister");
  443. const next = nextFromQuery();
  444. if (toRegister) toRegister.setAttribute("href", `/ui/register?next=${next ? encodeURIComponent(next) : currentNextParam()}`);
  445. function setMsg(text) {
  446. if (!msg) return;
  447. if (!text) {
  448. msg.style.display = "none";
  449. msg.textContent = "";
  450. return;
  451. }
  452. msg.style.display = "";
  453. msg.textContent = String(text);
  454. }
  455. async function submit() {
  456. setMsg("");
  457. const phoneVal = String(phone.value || "").trim();
  458. const passwordVal = String(password.value || "");
  459. if (!/^\d{6,20}$/.test(phoneVal)) {
  460. setMsg("请输入正确的手机号");
  461. phone.focus();
  462. return;
  463. }
  464. if (!passwordVal) {
  465. setMsg("请输入密码");
  466. password.focus();
  467. return;
  468. }
  469. const original = btn.textContent;
  470. btn.disabled = true;
  471. btn.textContent = "登录中…";
  472. try {
  473. await apiFetch("/auth/login", {
  474. method: "POST",
  475. body: { phone: phoneVal, password: passwordVal },
  476. });
  477. showToastSuccess("登录成功");
  478. window.location.href = next || "/ui/me";
  479. } catch (e) {
  480. setMsg(`登录失败:${e.detail?.error || e.status || "unknown"}`);
  481. } finally {
  482. btn.disabled = false;
  483. btn.textContent = original;
  484. }
  485. }
  486. try {
  487. const me = await apiFetch("/me");
  488. if (me && me.user) {
  489. window.location.href = next || "/ui/me";
  490. return;
  491. }
  492. } catch (e) {}
  493. btn.addEventListener("click", submit);
  494. phone.addEventListener("keydown", (e) => {
  495. if (e.key === "Enter") submit();
  496. });
  497. password.addEventListener("keydown", (e) => {
  498. if (e.key === "Enter") submit();
  499. });
  500. }
  501. async function pageRegister() {
  502. const phone = document.getElementById("phone");
  503. const password = document.getElementById("password");
  504. const btn = document.getElementById("registerBtn");
  505. const msg = document.getElementById("msg");
  506. const toLogin = document.getElementById("toLogin");
  507. const next = nextFromQuery();
  508. if (toLogin) toLogin.setAttribute("href", `/ui/login?next=${next ? encodeURIComponent(next) : currentNextParam()}`);
  509. function setMsg(text) {
  510. if (!msg) return;
  511. if (!text) {
  512. msg.style.display = "none";
  513. msg.textContent = "";
  514. return;
  515. }
  516. msg.style.display = "";
  517. msg.textContent = String(text);
  518. }
  519. async function submit() {
  520. setMsg("");
  521. const phoneVal = String(phone.value || "").trim();
  522. const passwordVal = String(password.value || "");
  523. if (!/^\d{6,20}$/.test(phoneVal)) {
  524. setMsg("请输入正确的手机号");
  525. phone.focus();
  526. return;
  527. }
  528. if (String(passwordVal).length < 6) {
  529. setMsg("密码至少 6 位");
  530. password.focus();
  531. return;
  532. }
  533. const original = btn.textContent;
  534. btn.disabled = true;
  535. btn.textContent = "注册中…";
  536. try {
  537. await apiFetch("/auth/register", {
  538. method: "POST",
  539. body: { phone: phoneVal, password: passwordVal },
  540. });
  541. showToastSuccess("注册成功");
  542. window.location.href = next || "/ui/me";
  543. } catch (e) {
  544. setMsg(`注册失败:${e.detail?.error || e.status || "unknown"}`);
  545. } finally {
  546. btn.disabled = false;
  547. btn.textContent = original;
  548. }
  549. }
  550. btn.addEventListener("click", submit);
  551. phone.addEventListener("keydown", (e) => {
  552. if (e.key === "Enter") submit();
  553. });
  554. password.addEventListener("keydown", (e) => {
  555. if (e.key === "Enter") submit();
  556. });
  557. }
  558. async function pageMe() {
  559. const meInfo = document.getElementById("meInfo");
  560. const orderList = document.getElementById("orderList");
  561. const logoutBtn = document.getElementById("logoutBtn");
  562. const orderSection = document.getElementById("orderSection");
  563. const downloadSection = document.getElementById("downloadSection");
  564. const downloadList = document.getElementById("downloadList");
  565. const downloadPager = document.getElementById("downloadPager");
  566. const meMsg = document.getElementById("meMsg");
  567. let downloadPage = 1;
  568. const downloadPageSize = 10;
  569. async function loadDownloads(page) {
  570. if (!downloadList) return;
  571. downloadPage = Math.max(1, parseInt(page || 1, 10) || 1);
  572. downloadList.innerHTML = "";
  573. downloadList.appendChild(el("div", { class: "card skeleton", style: "height: 72px;" }));
  574. downloadList.appendChild(el("div", { class: "card skeleton", style: "height: 72px;" }));
  575. if (downloadPager) downloadPager.innerHTML = "";
  576. const q = new URLSearchParams();
  577. q.set("page", String(downloadPage));
  578. q.set("pageSize", String(downloadPageSize));
  579. const data = await apiFetch(`/me/downloads?${q.toString()}`);
  580. const items = (data && data.items) || [];
  581. const total = parseInt(data.total || 0, 10) || 0;
  582. const totalPages = Math.max(1, Math.ceil(total / downloadPageSize));
  583. downloadList.innerHTML = "";
  584. if (!items.length) {
  585. downloadList.appendChild(
  586. el(
  587. "div",
  588. { style: "text-align: center; padding: 28px 16px; color: var(--muted);" },
  589. el("i", { class: "ri-inbox-line", style: "font-size: 2.2rem; margin-bottom: 12px; opacity: 0.5;" }),
  590. el("div", { style: "font-size: 1.05rem; margin-bottom: 6px;" }, "暂无下载记录"),
  591. el("div", { style: "font-size: 0.9rem;" }, "下载过资源后会显示在这里。")
  592. )
  593. );
  594. } else {
  595. items.forEach((it) => {
  596. let stateBadge = el("span", { class: "badge badge-success" }, "可用");
  597. if (it.resourceState === "DELETED") stateBadge = el("span", { class: "badge badge-danger" }, "资源已删除");
  598. else if (it.resourceState === "OFFLINE") stateBadge = el("span", { class: "badge badge-warning" }, "资源已下架");
  599. const typeBadge = el(
  600. "span",
  601. { class: `badge ${it.resourceType === "VIP" ? "badge-vip" : "badge-free"}` },
  602. `下载时:${it.resourceType === "VIP" ? "VIP" : "免费"}`
  603. );
  604. const currentType = it.currentResourceType || "";
  605. const currentTypeBadge =
  606. currentType && it.resourceState !== "DELETED"
  607. ? el(
  608. "span",
  609. { class: `badge ${currentType === "VIP" ? "badge-vip" : "badge-free"}` },
  610. `当前:${currentType === "VIP" ? "VIP" : "免费"}`
  611. )
  612. : null;
  613. const driftBadge =
  614. currentType && it.resourceType && currentType !== it.resourceType && it.resourceState === "ONLINE"
  615. ? el("span", { class: "badge badge-warning" }, "类型已变更")
  616. : null;
  617. const canOpenDetail = !!it.resourceId && it.resourceState === "ONLINE";
  618. const titleNode = canOpenDetail
  619. ? el(
  620. "a",
  621. { href: `/ui/resources/${it.resourceId}`, style: "color: inherit; text-decoration: none;" },
  622. it.resourceTitle || ""
  623. )
  624. : el("span", { class: "muted" }, it.resourceTitle || "");
  625. downloadList.appendChild(
  626. el(
  627. "div",
  628. { style: "border: 1px solid var(--border); border-radius: 12px; padding: 16px; background: var(--bg); display: flex; flex-direction: column; gap: 10px;" },
  629. el(
  630. "div",
  631. { style: "display: flex; justify-content: space-between; align-items: center; gap: 12px;" },
  632. el("div", { style: "font-weight: 500; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" }, titleNode),
  633. stateBadge
  634. ),
  635. el(
  636. "div",
  637. { class: "muted", style: "display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 0.9rem;" },
  638. typeBadge,
  639. currentTypeBadge,
  640. driftBadge,
  641. el("span", {}, `Ref:${it.ref || ""}`),
  642. el("span", {}, `下载:${formatDateTime(it.downloadedAt)}`)
  643. )
  644. )
  645. );
  646. });
  647. }
  648. if (downloadPager) {
  649. const prevBtn = el("button", { class: "btn", disabled: downloadPage <= 1 }, "上一页");
  650. const nextBtn = el("button", { class: "btn", disabled: downloadPage >= totalPages }, "下一页");
  651. prevBtn.addEventListener("click", async () => loadDownloads(downloadPage - 1));
  652. nextBtn.addEventListener("click", async () => loadDownloads(downloadPage + 1));
  653. downloadPager.appendChild(el("div", { class: "muted" }, `第 ${downloadPage} / ${totalPages} 页 · 共 ${total} 条`));
  654. downloadPager.appendChild(el("div", { style: "display: flex; gap: 8px;" }, prevBtn, nextBtn));
  655. }
  656. }
  657. async function load() {
  658. if (meMsg) {
  659. meMsg.style.display = "none";
  660. meMsg.textContent = "";
  661. }
  662. meInfo.innerHTML = "";
  663. meInfo.appendChild(el("div", { class: "skeleton skeleton-line", style: "width: 60%;" }));
  664. meInfo.appendChild(el("div", { class: "skeleton skeleton-line", style: "width: 45%;" }));
  665. if (orderList) {
  666. orderList.innerHTML = "";
  667. orderList.appendChild(el("div", { class: "card skeleton", style: "height: 96px;" }));
  668. orderList.appendChild(el("div", { class: "card skeleton", style: "height: 96px;" }));
  669. }
  670. if (downloadList) {
  671. downloadList.innerHTML = "";
  672. downloadList.appendChild(el("div", { class: "card skeleton", style: "height: 72px;" }));
  673. downloadList.appendChild(el("div", { class: "card skeleton", style: "height: 72px;" }));
  674. }
  675. if (downloadPager) downloadPager.innerHTML = "";
  676. const data = await apiFetch("/me");
  677. if (!data.user) {
  678. meInfo.innerHTML = "";
  679. meInfo.appendChild(el("div", {}, "未登录"));
  680. meInfo.appendChild(el("div", { class: "muted" }, "登录后可查看会员状态与订单记录。"));
  681. meInfo.appendChild(el("div", { class: "toolbar" }, el("a", { class: "btn btn-primary", href: `/ui/login?next=${currentNextParam()}` }, "去登录"), el("a", { class: "btn", href: `/ui/register?next=${currentNextParam()}` }, "去注册")));
  682. if (orderSection) orderSection.style.display = "none";
  683. if (logoutBtn) logoutBtn.style.display = "none";
  684. if (downloadSection) downloadSection.style.display = "none";
  685. if (meMsg) {
  686. meMsg.style.display = "";
  687. meMsg.textContent = "提示:未登录";
  688. }
  689. if (orderList) orderList.innerHTML = "";
  690. if (downloadList) downloadList.innerHTML = "";
  691. if (downloadPager) downloadPager.innerHTML = "";
  692. return;
  693. }
  694. if (orderSection) orderSection.style.display = "";
  695. if (logoutBtn) logoutBtn.style.display = "";
  696. if (downloadSection) downloadSection.style.display = "";
  697. meInfo.innerHTML = "";
  698. meInfo.appendChild(el("div", { style: "display: flex; align-items: center; gap: 8px; margin-bottom: 8px;" }, el("i", { class: "ri-smartphone-line", style: "color: var(--muted);" }), `手机号:${data.user.phone}`));
  699. meInfo.appendChild(el("div", { style: "display: flex; align-items: center; gap: 8px; margin-bottom: 8px;" }, el("i", { class: "ri-vip-crown-line", style: "color: var(--muted);" }), `状态:`, el("span", { class: data.user.vipActive ? "badge badge-success" : "badge badge-danger", style: "margin-left: -4px;" }, data.user.vipActive ? "VIP 有效" : "无/已过期")));
  700. meInfo.appendChild(el("div", { class: "muted", style: "display: flex; align-items: center; gap: 8px;" }, el("i", { class: "ri-calendar-event-line" }), `到期时间:${formatDateTime(data.user.vipExpireAt)}`));
  701. const orders = await apiFetch("/orders");
  702. orderList.innerHTML = "";
  703. const items = (orders && orders.items) || [];
  704. if (!items.length) {
  705. orderList.appendChild(
  706. el("div", { style: "text-align: center; padding: 40px 20px; color: var(--muted);" },
  707. el("i", { class: "ri-inbox-line", style: "font-size: 3rem; margin-bottom: 16px; opacity: 0.5;" }),
  708. el("div", { style: "font-size: 1.1rem; margin-bottom: 8px;" }, "暂无订单"),
  709. el("div", { style: "font-size: 0.9rem;" }, "购买会员后将显示订单记录。")
  710. )
  711. );
  712. }
  713. if (items.length) {
  714. items.forEach((o) => {
  715. let statusBadge;
  716. if (o.status === "PAID") statusBadge = el("span", { class: "badge badge-success" }, "已支付");
  717. else if (o.status === "CLOSED") statusBadge = el("span", { class: "badge badge-danger" }, "已关闭");
  718. else statusBadge = el("span", { class: "badge badge-info" }, o.status);
  719. orderList.appendChild(
  720. el(
  721. "div",
  722. { style: "border: 1px solid var(--border); border-radius: 12px; padding: 20px; display: flex; flex-direction: column; gap: 12px; background: var(--bg); transition: transform 0.2s, box-shadow 0.2s;" },
  723. el("div", { style: "display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border); padding-bottom: 12px;" },
  724. el("div", { style: "font-weight: 500;" }, `订单号:${o.id}`),
  725. statusBadge
  726. ),
  727. el("div", { style: "display: flex; justify-content: space-between; align-items: center;" },
  728. el("div", { style: "display: flex; align-items: center; gap: 8px;" }, el("i", { class: "ri-vip-crown-fill", style: "color: var(--brand);" }), el("span", { style: "font-weight: 500;" }, o.planSnapshot.name), el("span", { class: "muted", style: "font-size: 0.9rem;" }, `(${o.planSnapshot.durationDays} 天)`)),
  729. el("div", { style: "font-weight: bold; color: var(--brand); font-size: 1.1rem;" }, formatCents(o.amountCents))
  730. ),
  731. el("div", { class: "muted", style: "font-size: 0.9rem; display: flex; justify-content: space-between; margin-top: 4px;" },
  732. el("span", { style: "display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-time-line" }), `创建:${formatDateTime(o.createdAt)}`),
  733. el("span", { style: "display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-check-double-line" }), `支付:${formatDateTime(o.paidAt)}`)
  734. )
  735. )
  736. );
  737. });
  738. }
  739. await loadDownloads(1);
  740. }
  741. logoutBtn.addEventListener("click", async () => {
  742. await apiFetch("/auth/logout", { method: "POST" });
  743. window.location.reload();
  744. });
  745. await load();
  746. }
  747. async function pageMessages() {
  748. const messageList = document.getElementById("messageList");
  749. const messagePager = document.getElementById("messagePager");
  750. const messageUnreadBadge = document.getElementById("messageUnreadBadge");
  751. const messageUnreadOnlyBtn = document.getElementById("messageUnreadOnlyBtn");
  752. const messageAllBtn = document.getElementById("messageAllBtn");
  753. const messageMsg = document.getElementById("messageMsg");
  754. let page = 1;
  755. const pageSize = 10;
  756. let unreadOnly = false;
  757. function setMsg(text, isError = true) {
  758. if (!messageMsg) return;
  759. messageMsg.style.display = "";
  760. messageMsg.className = `form-msg ${isError ? "form-msg-error" : "form-msg-success"}`;
  761. messageMsg.textContent = String(text || "");
  762. }
  763. function clearMsg() {
  764. if (!messageMsg) return;
  765. messageMsg.style.display = "none";
  766. messageMsg.textContent = "";
  767. }
  768. function updateUnreadBadge(cnt) {
  769. if (!messageUnreadBadge) return;
  770. const n = parseInt(cnt || 0, 10) || 0;
  771. if (n > 0) {
  772. messageUnreadBadge.style.display = "";
  773. messageUnreadBadge.textContent = `${n} 未读`;
  774. } else {
  775. messageUnreadBadge.style.display = "none";
  776. messageUnreadBadge.textContent = "";
  777. }
  778. }
  779. function updateToggleButtons() {
  780. if (messageUnreadOnlyBtn) messageUnreadOnlyBtn.className = `btn btn-sm ${unreadOnly ? "btn-primary" : ""}`.trim();
  781. if (messageAllBtn) messageAllBtn.className = `btn btn-sm ${!unreadOnly ? "btn-primary" : ""}`.trim();
  782. }
  783. async function loadMessages(targetPage) {
  784. if (!messageList) return;
  785. page = Math.max(1, parseInt(targetPage || 1, 10) || 1);
  786. clearMsg();
  787. messageList.innerHTML = "";
  788. messageList.appendChild(el("div", { class: "card skeleton", style: "height: 72px;" }));
  789. messageList.appendChild(el("div", { class: "card skeleton", style: "height: 72px;" }));
  790. if (messagePager) messagePager.innerHTML = "";
  791. const q = new URLSearchParams();
  792. q.set("page", String(page));
  793. q.set("pageSize", String(pageSize));
  794. if (unreadOnly) q.set("unread", "1");
  795. let data;
  796. try {
  797. data = await apiFetch(`/me/messages?${q.toString()}`);
  798. } catch (e) {
  799. if (e.status === 401) {
  800. setMsg("未登录,无法查看消息。");
  801. messageList.innerHTML = "";
  802. messageList.appendChild(el("div", { class: "toolbar" }, el("a", { class: "btn btn-primary", href: `/ui/login?next=${currentNextParam()}` }, "去登录")));
  803. return;
  804. }
  805. setMsg(e.detail?.error || e.status || "消息加载失败");
  806. messageList.innerHTML = "";
  807. return;
  808. }
  809. const items = (data && data.items) || [];
  810. const total = parseInt(data.total || 0, 10) || 0;
  811. const unreadCount = parseInt(data.unreadCount || 0, 10) || 0;
  812. const totalPages = Math.max(1, Math.ceil(total / pageSize));
  813. updateUnreadBadge(unreadCount);
  814. updateToggleButtons();
  815. if (typeof window.__refreshMessageBadge === "function") {
  816. try {
  817. await window.__refreshMessageBadge();
  818. } catch (e) {}
  819. }
  820. messageList.innerHTML = "";
  821. if (!items.length) {
  822. messageList.appendChild(
  823. el(
  824. "div",
  825. { style: "text-align: center; padding: 28px 16px; color: var(--muted);" },
  826. el("i", { class: "ri-inbox-line", style: "font-size: 2.2rem; margin-bottom: 12px; opacity: 0.5;" }),
  827. el("div", { style: "font-size: 1.05rem; margin-bottom: 6px;" }, unreadOnly ? "暂无未读消息" : "暂无消息"),
  828. el("div", { style: "font-size: 0.9rem;" }, "系统通知会显示在这里。")
  829. )
  830. );
  831. } else {
  832. items.forEach((m) => {
  833. const read = !!m.read;
  834. const statusBadge = read ? el("span", { class: "badge" }, "已读") : el("span", { class: "badge badge-warning" }, "未读");
  835. const actions = !read
  836. ? el(
  837. "button",
  838. {
  839. class: "btn btn-sm",
  840. onclick: async () => {
  841. try {
  842. await apiFetch(`/me/messages/${m.id}/read`, { method: "PUT" });
  843. await loadMessages(page);
  844. } catch (e) {
  845. showToastError(e?.detail?.error || e?.status || "标记失败");
  846. }
  847. },
  848. },
  849. "标记已读"
  850. )
  851. : null;
  852. messageList.appendChild(
  853. el(
  854. "div",
  855. { style: "border: 1px solid var(--border); border-radius: 12px; padding: 16px; background: var(--bg); display: flex; flex-direction: column; gap: 10px;" },
  856. el(
  857. "div",
  858. { style: "display: flex; justify-content: space-between; align-items: center; gap: 12px;" },
  859. el("div", { style: "font-weight: 500; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" }, m.title || "通知"),
  860. el("div", { style: "display: flex; align-items: center; gap: 8px; flex: 0 0 auto;" }, statusBadge, actions)
  861. ),
  862. el("div", { class: "muted", style: "white-space: pre-wrap;" }, formatMessageText(m.content || "")),
  863. el("div", { class: "muted", style: "font-size: 0.9rem;" }, `发送时间:${formatDateTime(m.createdAt)}`)
  864. )
  865. );
  866. });
  867. }
  868. if (messagePager) {
  869. const prevBtn = el("button", { class: "btn", disabled: page <= 1 }, "上一页");
  870. const nextBtn = el("button", { class: "btn", disabled: page >= totalPages }, "下一页");
  871. prevBtn.addEventListener("click", async () => loadMessages(page - 1));
  872. nextBtn.addEventListener("click", async () => loadMessages(page + 1));
  873. messagePager.appendChild(el("div", { class: "muted" }, `第 ${page} / ${totalPages} 页 · 共 ${total} 条`));
  874. messagePager.appendChild(el("div", { style: "display: flex; gap: 8px;" }, prevBtn, nextBtn));
  875. }
  876. }
  877. if (messageUnreadOnlyBtn) {
  878. messageUnreadOnlyBtn.addEventListener("click", async () => {
  879. unreadOnly = true;
  880. await loadMessages(1);
  881. });
  882. }
  883. if (messageAllBtn) {
  884. messageAllBtn.addEventListener("click", async () => {
  885. unreadOnly = false;
  886. await loadMessages(1);
  887. });
  888. }
  889. updateToggleButtons();
  890. await loadMessages(1);
  891. }
  892. async function pageVip() {
  893. const planList = document.getElementById("planList");
  894. const vipMsg = document.getElementById("vipMsg");
  895. const vipStatus = document.getElementById("vipStatus");
  896. let me = null;
  897. try {
  898. me = await apiFetch("/me");
  899. } catch (e) {
  900. me = { user: null };
  901. }
  902. const user = me && me.user ? me.user : null;
  903. if (vipStatus) {
  904. vipStatus.style.display = "";
  905. vipStatus.innerHTML = "";
  906. if (!user) {
  907. vipStatus.appendChild(
  908. el("div", { style: "display: flex; flex-direction: column; align-items: center; padding: 16px;" },
  909. el("div", { style: "margin-bottom: 16px; font-size: 1.1rem; color: var(--brand);" }, "未登录。登录后可购买/续费会员,并查看权益状态。"),
  910. el("a", { class: "btn btn-primary", href: `/ui/login?next=${currentNextParam()}`, style: "display: inline-flex; align-items: center; gap: 8px; border-radius: 20px; padding: 8px 24px;" },
  911. el("i", { class: "ri-login-box-line" }), "去登录"
  912. )
  913. )
  914. );
  915. } else {
  916. vipStatus.appendChild(
  917. el("div", { style: "display: flex; justify-content: space-around; flex-wrap: wrap; gap: 16px; padding: 16px;" },
  918. el("div", { style: "display: flex; flex-direction: column; align-items: center; gap: 8px;" },
  919. el("div", { class: "muted", style: "font-size: 0.9rem;" }, "当前账号"),
  920. el("div", { style: "font-weight: 500; display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-smartphone-line", style: "color: var(--brand);" }), user.phone)
  921. ),
  922. el("div", { style: "display: flex; flex-direction: column; align-items: center; gap: 8px;" },
  923. el("div", { class: "muted", style: "font-size: 0.9rem;" }, "会员状态"),
  924. el("div", { style: "font-weight: 500; display: flex; align-items: center; gap: 4px;" },
  925. el("i", { class: user.vipActive ? "ri-vip-crown-fill" : "ri-vip-crown-line", style: user.vipActive ? "color: #ffd700;" : "color: var(--muted);" }),
  926. el("span", { class: user.vipActive ? "badge badge-success" : "badge badge-danger", style: "margin-left: 4px;" }, user.vipActive ? "VIP 有效" : "无/已过期")
  927. )
  928. ),
  929. el("div", { style: "display: flex; flex-direction: column; align-items: center; gap: 8px;" },
  930. el("div", { class: "muted", style: "font-size: 0.9rem;" }, "到期时间"),
  931. el("div", { style: "font-weight: 500; display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-calendar-event-line", style: "color: var(--brand);" }), formatDateTime(user.vipExpireAt))
  932. )
  933. )
  934. );
  935. }
  936. }
  937. planList.innerHTML = "";
  938. planList.appendChild(el("div", { class: "card skeleton", style: "grid-column: 1 / -1; height: 120px;" }));
  939. const plans = await apiFetch("/plans");
  940. planList.innerHTML = "";
  941. plans.forEach((p) => {
  942. const isRecommended = plans[0] && p.id === plans[0].id;
  943. planList.appendChild(
  944. el(
  945. "div",
  946. { class: "card", style: `position: relative; overflow: hidden; padding: 32px 24px; border: 2px solid ${isRecommended ? 'var(--brand)' : 'transparent'}; box-shadow: var(--shadow-md); transition: transform 0.3s, box-shadow 0.3s; display: flex; flex-direction: column; height: 100%;` },
  947. isRecommended ? el("div", { style: "position: absolute; top: 16px; right: -32px; background: var(--brand); color: white; padding: 4px 40px; transform: rotate(45deg); font-size: 0.85rem; font-weight: bold; box-shadow: var(--shadow);" }, "推荐") : null,
  948. el(
  949. "div",
  950. { style: "text-align: center; margin-bottom: 24px;" },
  951. el("h3", { style: "margin: 0 0 8px 0; font-size: 1.5rem; color: var(--text);" }, p.name),
  952. el("div", { class: "muted" }, `有效期 ${p.durationDays} 天`)
  953. ),
  954. el(
  955. "div",
  956. { style: "text-align: center; margin-bottom: 32px;" },
  957. el("span", { style: "font-size: 1.25rem; color: var(--brand); font-weight: bold;" }, "¥ "),
  958. el("span", { style: "font-size: 2.5rem; color: var(--brand); font-weight: bold; line-height: 1;" }, (p.priceCents / 100).toFixed(2))
  959. ),
  960. el(
  961. "div",
  962. { style: "margin-top: auto;" },
  963. el(
  964. "button",
  965. {
  966. class: isRecommended ? "btn btn-primary" : "btn",
  967. style: "width: 100%; height: 48px; font-size: 1.1rem; border-radius: 24px; display: flex; justify-content: center; align-items: center; gap: 8px;",
  968. "data-plan-id": String(p.id),
  969. },
  970. el("i", { class: "ri-shopping-cart-2-line" }),
  971. "立即开通"
  972. )
  973. )
  974. )
  975. );
  976. });
  977. planList.addEventListener("click", async (evt) => {
  978. const btn = evt.target.closest("button[data-plan-id]");
  979. if (!btn) return;
  980. vipMsg.textContent = "";
  981. const originalText = btn.textContent;
  982. btn.disabled = true;
  983. btn.textContent = "处理中…";
  984. const planId = Number(btn.getAttribute("data-plan-id"));
  985. try {
  986. const order = await apiFetch("/orders", { method: "POST", body: { planId } });
  987. const payResp = await apiFetch(`/orders/${order.id}/pay`, { method: "POST" });
  988. if (payResp && payResp.payUrl) {
  989. vipMsg.textContent = "已发起支付宝支付,正在跳转…";
  990. window.location.href = payResp.payUrl;
  991. return;
  992. }
  993. vipMsg.textContent = "支付成功(模拟),已发放会员权益。";
  994. showToastSuccess("支付成功,会员权益已生效");
  995. setTimeout(() => {
  996. window.location.href = "/ui/me";
  997. }, 300);
  998. } catch (e) {
  999. if (e.status === 401) window.location.href = `/ui/login?next=${currentNextParam()}`;
  1000. vipMsg.textContent = `下单/支付失败:${e.detail?.error || e.status || "unknown"}`;
  1001. } finally {
  1002. btn.disabled = false;
  1003. btn.textContent = originalText;
  1004. }
  1005. });
  1006. }
  1007. async function pageResourceDetail() {
  1008. const root = document.getElementById("resourceDetail");
  1009. const resourceId = Number(root.getAttribute("data-resource-id"));
  1010. const descRoot = document.getElementById("resourceDescription");
  1011. const refSelect = document.getElementById("refSelect");
  1012. const reloadRepo = document.getElementById("reloadRepo");
  1013. const treeEl = document.getElementById("tree");
  1014. const fileContent = document.getElementById("fileContent");
  1015. const breadcrumb = document.getElementById("breadcrumb");
  1016. const downloadBtn = document.getElementById("downloadBtn");
  1017. const repoModalBackdrop = document.getElementById("repoModalBackdrop");
  1018. const repoModalTitle = document.getElementById("repoModalTitle");
  1019. const repoModalClose = document.getElementById("repoModalClose");
  1020. const repoModalBody = document.getElementById("repoModalBody");
  1021. const repoModalFooter = document.getElementById("repoModalFooter");
  1022. let currentRef = "";
  1023. let currentPath = "";
  1024. let canEditRepo = false;
  1025. let refKinds = { branches: new Set(), tags: new Set() };
  1026. let selectedFilePath = "";
  1027. let selectedFileContent = "";
  1028. let detail = null;
  1029. let me = null;
  1030. let inlineDownloadBtn = null;
  1031. const repoWriteActionsEnabled = false;
  1032. function closeRepoModal() {
  1033. repoModalBackdrop.style.display = "none";
  1034. repoModalTitle.textContent = "";
  1035. repoModalBody.innerHTML = "";
  1036. repoModalFooter.innerHTML = "";
  1037. }
  1038. function openRepoModal(title, bodyNodes, footerNodes, icon = "ri-code-line") {
  1039. repoModalTitle.innerHTML = "";
  1040. repoModalTitle.appendChild(el("i", { class: icon }));
  1041. repoModalTitle.appendChild(document.createTextNode(title));
  1042. repoModalBody.innerHTML = "";
  1043. repoModalFooter.innerHTML = "";
  1044. bodyNodes.forEach((n) => repoModalBody.appendChild(n));
  1045. footerNodes.forEach((n) => repoModalFooter.appendChild(n));
  1046. repoModalBackdrop.style.display = "";
  1047. }
  1048. repoModalClose.addEventListener("click", closeRepoModal);
  1049. repoModalBackdrop.addEventListener("click", (evt) => {
  1050. if (evt.target === repoModalBackdrop) closeRepoModal();
  1051. });
  1052. function isBranchRef(ref) {
  1053. return refKinds.branches.has(ref);
  1054. }
  1055. function setDownloadButtonLabel(btn, label) {
  1056. if (!btn) return;
  1057. const text = String(label || "").trim() || "下载 ZIP";
  1058. const iconClass = text.includes("开通会员") ? "ri-vip-crown-line" : "ri-download-cloud-2-line";
  1059. btn.innerHTML = "";
  1060. btn.appendChild(el("i", { class: iconClass }));
  1061. btn.appendChild(document.createTextNode(text));
  1062. }
  1063. function updateDownloadButton() {
  1064. const user = me && me.user ? me.user : null;
  1065. if (!user) {
  1066. setDownloadButtonLabel(downloadBtn, "下载 ZIP");
  1067. setDownloadButtonLabel(inlineDownloadBtn, "下载 ZIP");
  1068. return;
  1069. }
  1070. if (detail && detail.type === "VIP" && !user.vipActive) {
  1071. setDownloadButtonLabel(downloadBtn, "开通会员下载");
  1072. setDownloadButtonLabel(inlineDownloadBtn, "开通会员下载");
  1073. return;
  1074. }
  1075. setDownloadButtonLabel(downloadBtn, "下载 ZIP");
  1076. setDownloadButtonLabel(inlineDownloadBtn, "下载 ZIP");
  1077. }
  1078. async function loadMe() {
  1079. try {
  1080. me = await apiFetch("/me");
  1081. } catch (e) {
  1082. me = null;
  1083. }
  1084. updateDownloadButton();
  1085. }
  1086. function setBreadcrumb(path) {
  1087. breadcrumb.innerHTML = "";
  1088. const parts = path ? path.split("/") : [];
  1089. const items = [{ name: "根目录", path: "" }];
  1090. let acc = "";
  1091. parts.forEach((p) => {
  1092. acc = acc ? `${acc}/${p}` : p;
  1093. items.push({ name: p, path: acc });
  1094. });
  1095. items.forEach((it, idx) => {
  1096. const a = el("a", { href: "#" }, it.name);
  1097. a.addEventListener("click", async (e) => {
  1098. e.preventDefault();
  1099. currentPath = it.path;
  1100. await loadTree();
  1101. });
  1102. breadcrumb.appendChild(a);
  1103. if (idx < items.length - 1) breadcrumb.appendChild(el("span", { class: "muted" }, "/"));
  1104. });
  1105. }
  1106. async function loadDetail() {
  1107. const r = await apiFetch(`/resources/${resourceId}`);
  1108. detail = r;
  1109. inlineDownloadBtn = null;
  1110. root.innerHTML = "";
  1111. if (descRoot) descRoot.innerHTML = "";
  1112. const coverCol = r.coverUrl ? el("img", { src: r.coverUrl, alt: r.title, style: "width: 100%; max-width: 320px; height: 240px; object-fit: cover; border-radius: 12px; box-shadow: var(--shadow);" }) : null;
  1113. inlineDownloadBtn = el(
  1114. "button",
  1115. { class: "btn btn-primary", style: "margin-top: 14px; border-radius: 10px; display: inline-flex; align-items: center; gap: 6px; width: fit-content;" },
  1116. el("i", { class: "ri-download-cloud-2-line" }),
  1117. "下载 ZIP"
  1118. );
  1119. inlineDownloadBtn.addEventListener("click", downloadZip);
  1120. const metaCol = el(
  1121. "div",
  1122. { style: "flex: 1; display: flex; flex-direction: column;" },
  1123. el("h1", { style: "margin: 0 0 16px 0; font-size: 2rem; color: var(--text);" }, r.title),
  1124. el(
  1125. "div",
  1126. { style: "display: flex; gap: 12px; margin-bottom: 24px; flex-wrap: wrap;" },
  1127. r.type === "VIP" ? el("span", { class: "badge badge-danger", style: "padding: 6px 12px; font-size: 0.9rem;" }, el("i", { class: "ri-vip-crown-fill", style: "margin-right: 4px;" }), "VIP 专享") : el("span", { class: "badge badge-success", style: "padding: 6px 12px; font-size: 0.9rem;" }, el("i", { class: "ri-check-line", style: "margin-right: 4px;" }), "免费资源"),
  1128. el("span", { class: "badge badge-info", style: "padding: 6px 12px; font-size: 0.9rem;" }, el("i", { class: "ri-hashtag", style: "margin-right: 4px;" }), `资源 ID: ${r.id}`),
  1129. ...((r.tags || []).slice(0, 8).map((t) => el("span", { class: "badge", style: "padding: 6px 12px; font-size: 0.9rem; background: var(--bg); border: 1px solid var(--border);" }, t)))
  1130. ),
  1131. el("div", { class: "muted", style: "display: flex; gap: 16px; margin-bottom: 8px;" },
  1132. el("span", { style: "display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-eye-line" }), `浏览 ${r.viewCount}`),
  1133. el("span", { style: "display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-download-cloud-2-line" }), `下载 ${r.downloadCount}`)
  1134. ),
  1135. el("div", { class: "muted", style: "display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-github-line" }), `仓库: ${r.repo.owner}/${r.repo.name}${r.repo.private ? " (私有)" : ""}`),
  1136. inlineDownloadBtn
  1137. );
  1138. const headerRow = el("div", { style: "display: flex; gap: 32px; flex-wrap: wrap; margin-bottom: 32px;" }, coverCol, metaCol);
  1139. root.appendChild(
  1140. el(
  1141. "div",
  1142. { class: "card", style: "padding: 32px; border-radius: 16px; box-shadow: var(--shadow-md);" },
  1143. headerRow
  1144. )
  1145. );
  1146. if (descRoot) {
  1147. const summary = el("div", { class: "md", style: "line-height: 1.8; color: var(--text-light);", html: renderMarkdown(r.summary || "暂无描述") });
  1148. descRoot.appendChild(
  1149. el(
  1150. "div",
  1151. { class: "card", style: "margin-top:24px; padding: 32px; border-radius: 16px; box-shadow: var(--shadow-md);" },
  1152. el("h3", { style: "display: flex; align-items: center; gap: 8px; margin-bottom: 16px;" }, el("i", { class: "ri-file-text-line", style: "color: var(--brand);" }), "资源描述"),
  1153. summary
  1154. )
  1155. );
  1156. }
  1157. updateDownloadButton();
  1158. }
  1159. async function loadRefs() {
  1160. const refs = await apiFetch(`/resources/${resourceId}/repo/refs`);
  1161. refSelect.innerHTML = "";
  1162. refKinds = { branches: new Set(), tags: new Set() };
  1163. const branchGroup = document.createElement("optgroup");
  1164. branchGroup.label = "分支";
  1165. (refs.branches || []).forEach((b) => {
  1166. const name = (b.name || "").trim();
  1167. if (!name) return;
  1168. refKinds.branches.add(name);
  1169. branchGroup.appendChild(el("option", { value: name }, name));
  1170. });
  1171. const tagGroup = document.createElement("optgroup");
  1172. tagGroup.label = "标签";
  1173. (refs.tags || []).forEach((t) => {
  1174. const name = (t.name || "").trim();
  1175. if (!name) return;
  1176. refKinds.tags.add(name);
  1177. tagGroup.appendChild(el("option", { value: name }, name));
  1178. });
  1179. if (branchGroup.children.length) refSelect.appendChild(branchGroup);
  1180. if (tagGroup.children.length) refSelect.appendChild(tagGroup);
  1181. currentRef = refSelect.value;
  1182. }
  1183. async function loadTree() {
  1184. fileContent.textContent = "";
  1185. selectedFilePath = "";
  1186. selectedFileContent = "";
  1187. setBreadcrumb(currentPath);
  1188. treeEl.innerHTML = "";
  1189. const params = new URLSearchParams();
  1190. params.set("ref", currentRef);
  1191. params.set("path", currentPath);
  1192. const data = await apiFetch(`/resources/${resourceId}/repo/tree?${params.toString()}`);
  1193. data.items.forEach((it) => {
  1194. const rightText = String(it.path || "");
  1195. const isLocked = it.type !== "dir" && it.guestAllowed === false;
  1196. const rightNode = isLocked
  1197. ? el(
  1198. "div",
  1199. { class: "muted tree-locked", style: "font-size: 0.85rem; display: flex; align-items: center; gap: 6px;" },
  1200. el("i", { class: "ri-lock-2-line" }),
  1201. "需登录"
  1202. )
  1203. : rightText && rightText !== it.name
  1204. ? el("div", { class: "muted", style: "font-size: 0.85rem;" }, rightText)
  1205. : null;
  1206. const row = el(
  1207. "div",
  1208. { class: `card${isLocked ? " is-locked" : ""}` },
  1209. el("div", { style: "display: flex; align-items: center; gap: 8px; font-weight: 500;" },
  1210. el("i", { class: it.type === "dir" ? "ri-folder-3-fill" : "ri-file-text-line", style: `font-size: 1.2rem; color: ${it.type === "dir" ? "#fbbf24" : "var(--muted)"};` }),
  1211. it.name
  1212. ),
  1213. rightNode
  1214. );
  1215. row.addEventListener("click", async () => {
  1216. if (it.type === "dir") {
  1217. currentPath = it.path;
  1218. await loadTree();
  1219. return;
  1220. }
  1221. if (it.guestAllowed === false) {
  1222. Swal.fire({
  1223. title: '需要登录',
  1224. text: '未登录仅可预览文档/配置等普通文本文件',
  1225. icon: 'info',
  1226. showCancelButton: true,
  1227. confirmButtonText: '去登录',
  1228. cancelButtonText: '取消',
  1229. confirmButtonColor: 'var(--brand)'
  1230. }).then((result) => {
  1231. if (result.isConfirmed) {
  1232. window.location.href = `/ui/login?next=${currentNextParam()}`;
  1233. }
  1234. });
  1235. return;
  1236. }
  1237. const p = new URLSearchParams();
  1238. p.set("ref", currentRef);
  1239. p.set("path", it.path);
  1240. try {
  1241. const f = await apiFetch(`/resources/${resourceId}/repo/file?${p.toString()}`);
  1242. fileContent.textContent = f.content;
  1243. selectedFilePath = it.path;
  1244. selectedFileContent = f.content;
  1245. } catch (e) {
  1246. if (e.status === 401 && e.detail?.error === "login_required") {
  1247. Swal.fire({
  1248. title: '需要登录',
  1249. text: '未登录仅可预览文档/配置等普通文本文件',
  1250. icon: 'info',
  1251. showCancelButton: true,
  1252. confirmButtonText: '去登录',
  1253. cancelButtonText: '取消',
  1254. confirmButtonColor: 'var(--brand)'
  1255. }).then((result) => {
  1256. if (result.isConfirmed) {
  1257. window.location.href = `/ui/login?next=${currentNextParam()}`;
  1258. }
  1259. });
  1260. return;
  1261. }
  1262. fileContent.textContent = `无法预览:${e.detail?.error || e.status || "unknown"}`;
  1263. }
  1264. });
  1265. treeEl.appendChild(row);
  1266. });
  1267. }
  1268. async function downloadZip() {
  1269. const user = me && me.user ? me.user : null;
  1270. if (!user) {
  1271. Swal.fire({
  1272. title: '未登录',
  1273. text: '下载资源需要先登录账户',
  1274. icon: 'info',
  1275. showCancelButton: true,
  1276. confirmButtonText: '去登录',
  1277. cancelButtonText: '取消',
  1278. confirmButtonColor: 'var(--brand)'
  1279. }).then((result) => {
  1280. if (result.isConfirmed) {
  1281. window.location.href = `/ui/login?next=${currentNextParam()}`;
  1282. }
  1283. });
  1284. return;
  1285. }
  1286. if (detail && detail.type === "VIP" && !user.vipActive) {
  1287. Swal.fire({
  1288. title: 'VIP 专享资源',
  1289. text: '该资源为 VIP 专属,需要开通会员后才能下载。',
  1290. icon: 'warning',
  1291. showCancelButton: true,
  1292. confirmButtonText: '去开通会员',
  1293. cancelButtonText: '取消',
  1294. confirmButtonColor: '#ffd700',
  1295. iconColor: '#ffd700'
  1296. }).then((result) => {
  1297. if (result.isConfirmed) {
  1298. window.location.href = "/ui/vip";
  1299. }
  1300. });
  1301. return;
  1302. }
  1303. if (downloadBtn) downloadBtn.disabled = true;
  1304. if (inlineDownloadBtn) inlineDownloadBtn.disabled = true;
  1305. Swal.fire({
  1306. title: "正在准备下载",
  1307. text: "请稍候…",
  1308. allowOutsideClick: false,
  1309. allowEscapeKey: false,
  1310. showConfirmButton: false,
  1311. didOpen: () => {
  1312. Swal.showLoading();
  1313. },
  1314. });
  1315. try {
  1316. const resp = await apiFetch(`/resources/${resourceId}/download`, {
  1317. method: "POST",
  1318. body: { ref: currentRef },
  1319. });
  1320. const blob = await resp.blob();
  1321. const url = URL.createObjectURL(blob);
  1322. const a = document.createElement("a");
  1323. a.href = url;
  1324. a.download = `resource-${resourceId}-${currentRef}.zip`;
  1325. document.body.appendChild(a);
  1326. a.click();
  1327. a.remove();
  1328. URL.revokeObjectURL(url);
  1329. Swal.close();
  1330. } catch (e) {
  1331. Swal.close();
  1332. if (e.status === 401) window.location.href = `/ui/login?next=${currentNextParam()}`;
  1333. if (e.status === 403 && e.detail?.error === "vip_required") {
  1334. Swal.fire({
  1335. title: 'VIP 专享资源',
  1336. text: '该资源为 VIP 专属,需要开通会员后才能下载。',
  1337. icon: 'warning',
  1338. showCancelButton: true,
  1339. confirmButtonText: '去开通会员',
  1340. cancelButtonText: '取消',
  1341. confirmButtonColor: '#ffd700',
  1342. iconColor: '#ffd700'
  1343. }).then((result) => {
  1344. if (result.isConfirmed) {
  1345. window.location.href = "/ui/vip";
  1346. }
  1347. });
  1348. return;
  1349. }
  1350. Swal.fire({
  1351. icon: 'error',
  1352. title: '下载失败',
  1353. text: e.detail?.error || e.status || "未知错误"
  1354. });
  1355. } finally {
  1356. if (downloadBtn) downloadBtn.disabled = false;
  1357. if (inlineDownloadBtn) inlineDownloadBtn.disabled = false;
  1358. }
  1359. }
  1360. refSelect.addEventListener("change", async () => {
  1361. currentRef = refSelect.value;
  1362. currentPath = "";
  1363. await loadTree();
  1364. });
  1365. reloadRepo.addEventListener("click", async () => {
  1366. await loadRefs();
  1367. currentPath = "";
  1368. await loadTree();
  1369. });
  1370. downloadBtn.addEventListener("click", downloadZip);
  1371. const toolbar = downloadBtn.closest(".toolbar");
  1372. const commitsBtn = el("button", { class: "btn", style: "border-radius: 8px; display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-history-line" }), "提交历史");
  1373. toolbar.insertBefore(commitsBtn, downloadBtn);
  1374. async function showCommits() {
  1375. const p = new URLSearchParams();
  1376. p.set("ref", currentRef);
  1377. const focusPath = selectedFilePath || currentPath || "";
  1378. if (focusPath) p.set("path", focusPath);
  1379. p.set("limit", "20");
  1380. const msg = el("div", { class: "muted" }, "加载中…");
  1381. openRepoModal("提交历史", [msg], [el("button", { class: "btn", onclick: closeRepoModal }, "关闭")], "ri-history-line");
  1382. try {
  1383. const data = await apiFetch(`/resources/${resourceId}/repo/commits?${p.toString()}`);
  1384. const items = data.items || [];
  1385. if (!items.length) {
  1386. msg.textContent = "没有找到提交记录";
  1387. return;
  1388. }
  1389. msg.remove();
  1390. const list = el("div", {});
  1391. items.forEach((it) => {
  1392. const sha = String(it.sha || "");
  1393. list.appendChild(
  1394. el(
  1395. "div",
  1396. { class: "card", style: "margin-bottom:12px; padding: 16px; border-left: 3px solid var(--brand); border-radius: 8px;" },
  1397. el("div", { style: "display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;" },
  1398. el("div", { style: "font-weight: 500; font-size: 1.05rem;" }, String(it.subject || "")),
  1399. el("span", { class: "badge", style: "font-family: monospace; font-size: 0.85rem;" }, sha.slice(0, 7))
  1400. ),
  1401. el("div", { class: "muted", style: "display: flex; align-items: center; gap: 8px; font-size: 0.9rem;" },
  1402. el("span", { style: "display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-user-line" }), it.authorName || ""),
  1403. el("span", { style: "display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-time-line" }), formatDateTime(it.authorDate))
  1404. )
  1405. )
  1406. );
  1407. });
  1408. repoModalBody.appendChild(list);
  1409. } catch (e) {
  1410. msg.textContent = `加载失败:${e.detail?.error || e.status || "unknown"}${e.detail?.message ? `\n${e.detail.message}` : ""}`;
  1411. }
  1412. }
  1413. commitsBtn.addEventListener("click", showCommits);
  1414. try {
  1415. await apiFetch("/admin/settings");
  1416. canEditRepo = true;
  1417. } catch (e) {
  1418. canEditRepo = false;
  1419. }
  1420. if (canEditRepo && repoWriteActionsEnabled) {
  1421. const createBtn = el("button", { class: "btn", style: "border-radius: 8px; display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-file-add-line" }), "新建文件");
  1422. const editBtn = el("button", { class: "btn", style: "border-radius: 8px; display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-edit-line" }), "在线编辑");
  1423. const delBtn = el("button", { class: "btn btn-danger", style: "border-radius: 8px; display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-delete-bin-line" }), "删除");
  1424. function requireBranchOrToast() {
  1425. if (isBranchRef(currentRef)) return true;
  1426. Swal.fire({
  1427. icon: 'warning',
  1428. title: '操作受限',
  1429. text: '仅支持在分支上进行编辑或提交操作'
  1430. });
  1431. return false;
  1432. }
  1433. function requireSelectedFileOrToast() {
  1434. if (selectedFilePath) return true;
  1435. Swal.fire({
  1436. icon: 'info',
  1437. title: '未选择文件',
  1438. text: '请先在左侧目录结构中选择一个文件'
  1439. });
  1440. return false;
  1441. }
  1442. async function createFile() {
  1443. if (!requireBranchOrToast()) return;
  1444. const pathInput = el("input", { class: "input", placeholder: "例如:README.md 或 docs/intro.md" });
  1445. const defaultPath = currentPath ? `${currentPath.replace(/\\/g, "/").replace(/\/+$/, "")}/new-file.txt` : "new-file.txt";
  1446. pathInput.value = defaultPath;
  1447. const msgInput = el("input", { class: "input", placeholder: "提交信息,例如:Add new file" });
  1448. const ta = el("textarea", { class: "input", style: "min-height:260px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;" });
  1449. const msg = el("div", { class: "muted" });
  1450. const saveBtn = el("button", { class: "btn btn-primary" }, "提交");
  1451. saveBtn.addEventListener("click", async () => {
  1452. msg.textContent = "";
  1453. saveBtn.disabled = true;
  1454. try {
  1455. const res = await apiFetch(`/resources/${resourceId}/repo/file`, {
  1456. method: "POST",
  1457. body: { ref: currentRef, path: pathInput.value.trim(), content: ta.value, message: msgInput.value.trim() },
  1458. });
  1459. msg.textContent = `提交成功:${String(res.commit || "").slice(0, 10)}`;
  1460. await loadTree();
  1461. setTimeout(closeRepoModal, 600);
  1462. } catch (e) {
  1463. msg.textContent = `提交失败:${e.detail?.error || e.status || "unknown"}${e.detail?.message ? `\n${e.detail.message}` : ""}`;
  1464. } finally {
  1465. saveBtn.disabled = false;
  1466. }
  1467. });
  1468. openRepoModal(
  1469. "新建文件",
  1470. [
  1471. el("div", { style: "display: flex; flex-direction: column; gap: 16px;" },
  1472. el("div", { style: "display: flex; flex-direction: column; gap: 8px;" }, el("div", { class: "label", style: "font-weight: 600;" }, "文件路径"), pathInput),
  1473. el("div", { style: "display: flex; flex-direction: column; gap: 8px;" }, el("div", { class: "label", style: "font-weight: 600;" }, "提交信息"), msgInput),
  1474. el("div", { style: "display: flex; flex-direction: column; gap: 8px;" }, el("div", { class: "label", style: "font-weight: 600;" }, "文件内容"), ta),
  1475. msg
  1476. )
  1477. ],
  1478. [el("button", { class: "btn", onclick: closeRepoModal }, "取消"), saveBtn],
  1479. "ri-file-add-line"
  1480. );
  1481. }
  1482. async function editFile() {
  1483. if (!requireBranchOrToast()) return;
  1484. if (!requireSelectedFileOrToast()) return;
  1485. const pathText = el("input", { class: "input", value: selectedFilePath, disabled: true, style: "background: #f1f5f9; color: var(--muted); cursor: not-allowed;" });
  1486. const msgInput = el("input", { class: "input", placeholder: "提交信息,例如:Update README" });
  1487. const ta = el("textarea", { class: "input", style: "min-height:320px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;" }, "");
  1488. ta.value = selectedFileContent || "";
  1489. const msg = el("div", { class: "muted" });
  1490. const saveBtn = el("button", { class: "btn btn-primary" }, "提交");
  1491. saveBtn.addEventListener("click", async () => {
  1492. msg.textContent = "";
  1493. saveBtn.disabled = true;
  1494. try {
  1495. const res = await apiFetch(`/resources/${resourceId}/repo/file`, {
  1496. method: "PUT",
  1497. body: { ref: currentRef, path: selectedFilePath, content: ta.value, message: msgInput.value.trim() },
  1498. });
  1499. selectedFileContent = ta.value;
  1500. msg.textContent = `提交成功:${String(res.commit || "").slice(0, 10)}`;
  1501. await loadTree();
  1502. setTimeout(closeRepoModal, 600);
  1503. } catch (e) {
  1504. msg.textContent = `提交失败:${e.detail?.error || e.status || "unknown"}${e.detail?.message ? `\n${e.detail.message}` : ""}`;
  1505. } finally {
  1506. saveBtn.disabled = false;
  1507. }
  1508. });
  1509. openRepoModal(
  1510. "在线编辑",
  1511. [
  1512. el("div", { style: "display: flex; flex-direction: column; gap: 16px;" },
  1513. el("div", { style: "display: flex; flex-direction: column; gap: 8px;" }, el("div", { class: "label", style: "font-weight: 600;" }, "文件路径"), pathText),
  1514. el("div", { style: "display: flex; flex-direction: column; gap: 8px;" }, el("div", { class: "label", style: "font-weight: 600;" }, "提交信息"), msgInput),
  1515. el("div", { style: "display: flex; flex-direction: column; gap: 8px;" }, el("div", { class: "label", style: "font-weight: 600;" }, "文件内容"), ta),
  1516. msg
  1517. )
  1518. ],
  1519. [el("button", { class: "btn", onclick: closeRepoModal }, "取消"), saveBtn],
  1520. "ri-edit-line"
  1521. );
  1522. }
  1523. async function deleteFile() {
  1524. if (!requireBranchOrToast()) return;
  1525. if (!requireSelectedFileOrToast()) return;
  1526. Swal.fire({
  1527. title: '确认删除?',
  1528. text: `您即将删除文件:${selectedFilePath}`,
  1529. icon: 'warning',
  1530. input: 'text',
  1531. inputPlaceholder: '提交信息,例如:Delete file',
  1532. showCancelButton: true,
  1533. confirmButtonColor: '#d33',
  1534. cancelButtonColor: 'var(--border)',
  1535. confirmButtonText: '<i class="ri-delete-bin-line"></i> 确认删除',
  1536. cancelButtonText: '取消',
  1537. showLoaderOnConfirm: true,
  1538. customClass: {
  1539. cancelButton: 'btn',
  1540. confirmButton: 'btn btn-danger'
  1541. },
  1542. preConfirm: async (message) => {
  1543. try {
  1544. const res = await apiFetch(`/resources/${resourceId}/repo/file`, {
  1545. method: "DELETE",
  1546. body: { ref: currentRef, path: selectedFilePath, message: (message || "").trim() },
  1547. });
  1548. return res;
  1549. } catch (e) {
  1550. Swal.showValidationMessage(`删除失败:${e.detail?.error || e.status || "unknown"}${e.detail?.message ? `<br>${e.detail.message}` : ""}`);
  1551. }
  1552. },
  1553. allowOutsideClick: () => !Swal.isLoading()
  1554. }).then(async (result) => {
  1555. if (result.isConfirmed) {
  1556. selectedFilePath = "";
  1557. selectedFileContent = "";
  1558. Swal.fire({
  1559. title: '删除成功!',
  1560. text: `提交 ID:${String(result.value.commit || "").slice(0, 10)}`,
  1561. icon: 'success',
  1562. timer: 1500,
  1563. showConfirmButton: false
  1564. });
  1565. await loadTree();
  1566. }
  1567. });
  1568. }
  1569. createBtn.addEventListener("click", createFile);
  1570. editBtn.addEventListener("click", editFile);
  1571. delBtn.addEventListener("click", deleteFile);
  1572. toolbar.insertBefore(btnGroup(createBtn, editBtn, delBtn), commitsBtn);
  1573. }
  1574. try {
  1575. await loadDetail();
  1576. } catch (e) {
  1577. root.innerHTML = "";
  1578. root.appendChild(el("div", { class: "card" }, `加载失败:${e.detail?.error || e.status || "unknown"}`));
  1579. return;
  1580. }
  1581. await loadMe();
  1582. try {
  1583. await loadRefs();
  1584. await loadTree();
  1585. } catch (e) {
  1586. breadcrumb.textContent = "仓库加载失败";
  1587. treeEl.innerHTML = "";
  1588. treeEl.appendChild(el("div", { class: "card", style: "margin: 8px; padding: 16px; border-radius: 12px;" }, `加载失败:${e.detail?.error || e.status || "unknown"}`));
  1589. fileContent.textContent = "";
  1590. }
  1591. }
  1592. async function pageAdminLogin() {
  1593. const username = document.getElementById("username");
  1594. const password = document.getElementById("password");
  1595. const btn = document.getElementById("adminLoginBtn");
  1596. const msg = document.getElementById("msg");
  1597. btn.addEventListener("click", async () => {
  1598. msg.textContent = "";
  1599. try {
  1600. await apiFetch("/admin/auth/login", {
  1601. method: "POST",
  1602. body: { username: username.value.trim(), password: password.value },
  1603. });
  1604. window.location.href = "/ui/admin";
  1605. } catch (e) {
  1606. msg.textContent = `登录失败:${e.detail?.error || e.status || "unknown"}`;
  1607. }
  1608. });
  1609. }
  1610. function renderJsonCard(title, obj) {
  1611. return el("div", { class: "card" }, el("div", {}, title), el("pre", { class: "code" }, JSON.stringify(obj, null, 2)));
  1612. }
  1613. async function pageAdmin() {
  1614. const overviewRefreshBtn = document.getElementById("overviewRefreshBtn");
  1615. const overviewUpdatedAt = document.getElementById("overviewUpdatedAt");
  1616. const ovUsersTotal = document.getElementById("ovUsersTotal");
  1617. const ovUsersSub = document.getElementById("ovUsersSub");
  1618. const ovResourcesTotal = document.getElementById("ovResourcesTotal");
  1619. const ovResourcesSub = document.getElementById("ovResourcesSub");
  1620. const ovOrdersTotal = document.getElementById("ovOrdersTotal");
  1621. const ovOrdersSub = document.getElementById("ovOrdersSub");
  1622. const ovRevenueTotal = document.getElementById("ovRevenueTotal");
  1623. const ovRevenueSub = document.getElementById("ovRevenueSub");
  1624. const ovDownloadsTotal = document.getElementById("ovDownloadsTotal");
  1625. const ovDownloadsSub = document.getElementById("ovDownloadsSub");
  1626. const ovMessagesTotal = document.getElementById("ovMessagesTotal");
  1627. const ovMessagesSub = document.getElementById("ovMessagesSub");
  1628. const ovSystemInfo = document.getElementById("ovSystemInfo");
  1629. const createPlanOpenBtn = document.getElementById("createPlanOpenBtn");
  1630. const createResOpenBtn = document.getElementById("createResOpenBtn");
  1631. const resQ = document.getElementById("resQ");
  1632. const resTypeFilter = document.getElementById("resTypeFilter");
  1633. const resStatusFilter = document.getElementById("resStatusFilter");
  1634. const resSearchBtn = document.getElementById("resSearchBtn");
  1635. const resPrevPage = document.getElementById("resPrevPage");
  1636. const resNextPage = document.getElementById("resNextPage");
  1637. const resPageInfo = document.getElementById("resPageInfo");
  1638. const uploadsQ = document.getElementById("uploadsQ");
  1639. const uploadsFilterAll = document.getElementById("uploadsFilterAll");
  1640. const uploadsFilterUnused = document.getElementById("uploadsFilterUnused");
  1641. const uploadsFilterUsed = document.getElementById("uploadsFilterUsed");
  1642. const uploadsRefreshBtn = document.getElementById("uploadsRefreshBtn");
  1643. const uploadsUploadBtn = document.getElementById("uploadsUploadBtn");
  1644. const uploadsFile = document.getElementById("uploadsFile");
  1645. const uploadsCleanupBtn = document.getElementById("uploadsCleanupBtn");
  1646. const uploadsStats = document.getElementById("uploadsStats");
  1647. const uploadTbody = document.querySelector("#uploadTable tbody");
  1648. const orderQ = document.getElementById("orderQ");
  1649. const orderStatusFilter = document.getElementById("orderStatusFilter");
  1650. const orderCreateBtn = document.getElementById("orderCreateBtn");
  1651. const orderRefreshBtn = document.getElementById("orderRefreshBtn");
  1652. const orderPrevPage = document.getElementById("orderPrevPage");
  1653. const orderNextPage = document.getElementById("orderNextPage");
  1654. const orderPageInfo = document.getElementById("orderPageInfo");
  1655. const userQ = document.getElementById("userQ");
  1656. const userStatusFilter = document.getElementById("userStatusFilter");
  1657. const userVipFilter = document.getElementById("userVipFilter");
  1658. const userSearchBtn = document.getElementById("userSearchBtn");
  1659. const userPrevPage = document.getElementById("userPrevPage");
  1660. const userNextPage = document.getElementById("userNextPage");
  1661. const userPageInfo = document.getElementById("userPageInfo");
  1662. const dlQ = document.getElementById("dlQ");
  1663. const dlTypeFilter = document.getElementById("dlTypeFilter");
  1664. const dlStateFilter = document.getElementById("dlStateFilter");
  1665. const dlSearchBtn = document.getElementById("dlSearchBtn");
  1666. const dlPrevPage = document.getElementById("dlPrevPage");
  1667. const dlNextPage = document.getElementById("dlNextPage");
  1668. const dlPageInfo = document.getElementById("dlPageInfo");
  1669. const msgQ = document.getElementById("msgQ");
  1670. const msgReadFilter = document.getElementById("msgReadFilter");
  1671. const msgSenderFilter = document.getElementById("msgSenderFilter");
  1672. const msgSearchBtn = document.getElementById("msgSearchBtn");
  1673. const msgSendBtn = document.getElementById("msgSendBtn");
  1674. const msgBroadcastBtn = document.getElementById("msgBroadcastBtn");
  1675. const msgPrevPage = document.getElementById("msgPrevPage");
  1676. const msgNextPage = document.getElementById("msgNextPage");
  1677. const msgPageInfo = document.getElementById("msgPageInfo");
  1678. const msgTbody = document.querySelector("#msgTable tbody");
  1679. const settingsRefreshBtn = document.getElementById("settingsRefreshBtn");
  1680. const settingsSaveBtn = document.getElementById("settingsSaveBtn");
  1681. const cfgGogsSaveBtn = document.getElementById("cfgGogsSaveBtn");
  1682. const cfgGogsResetBtn = document.getElementById("cfgGogsResetBtn");
  1683. const cfgGogsBaseUrl = document.getElementById("cfgGogsBaseUrl");
  1684. const cfgGogsToken = document.getElementById("cfgGogsToken");
  1685. const cfgClearGogsToken = document.getElementById("cfgClearGogsToken");
  1686. const cfgPaySaveBtn = document.getElementById("cfgPaySaveBtn");
  1687. const cfgPayResetBtn = document.getElementById("cfgPayResetBtn");
  1688. const cfgPayProvider = document.getElementById("cfgPayProvider");
  1689. const cfgEnableMockPay = document.getElementById("cfgEnableMockPay");
  1690. const cfgPayApiKey = document.getElementById("cfgPayApiKey");
  1691. const cfgClearPayApiKey = document.getElementById("cfgClearPayApiKey");
  1692. const cfgAlipayFields = document.getElementById("cfgAlipayFields");
  1693. const cfgAlipayAppId = document.getElementById("cfgAlipayAppId");
  1694. const cfgAlipayGateway = document.getElementById("cfgAlipayGateway");
  1695. const cfgAlipayNotifyUrl = document.getElementById("cfgAlipayNotifyUrl");
  1696. const cfgAlipayReturnUrl = document.getElementById("cfgAlipayReturnUrl");
  1697. const cfgAlipayUseCurrentNotify = document.getElementById("cfgAlipayUseCurrentNotify");
  1698. const cfgAlipayUseCurrentReturn = document.getElementById("cfgAlipayUseCurrentReturn");
  1699. const cfgAlipayPrivateKey = document.getElementById("cfgAlipayPrivateKey");
  1700. const cfgClearAlipayPrivateKey = document.getElementById("cfgClearAlipayPrivateKey");
  1701. const cfgAlipayPublicKey = document.getElementById("cfgAlipayPublicKey");
  1702. const cfgClearAlipayPublicKey = document.getElementById("cfgClearAlipayPublicKey");
  1703. const cfgShowAlipayPrivateKey = document.getElementById("cfgShowAlipayPrivateKey");
  1704. const cfgShowAlipayPublicKey = document.getElementById("cfgShowAlipayPublicKey");
  1705. const cfgLlmSaveBtn = document.getElementById("cfgLlmSaveBtn");
  1706. const cfgLlmResetBtn = document.getElementById("cfgLlmResetBtn");
  1707. const cfgLlmProvider = document.getElementById("cfgLlmProvider");
  1708. const cfgLlmBaseUrl = document.getElementById("cfgLlmBaseUrl");
  1709. const cfgLlmModel = document.getElementById("cfgLlmModel");
  1710. const cfgLlmApiKey = document.getElementById("cfgLlmApiKey");
  1711. const cfgClearLlmApiKey = document.getElementById("cfgClearLlmApiKey");
  1712. const cfgDbActive = document.getElementById("cfgDbActive");
  1713. const cfgDbSaveBtn = document.getElementById("cfgDbSaveBtn");
  1714. const cfgDbResetBtn = document.getElementById("cfgDbResetBtn");
  1715. const cfgMysqlHost = document.getElementById("cfgMysqlHost");
  1716. const cfgMysqlPort = document.getElementById("cfgMysqlPort");
  1717. const cfgMysqlUser = document.getElementById("cfgMysqlUser");
  1718. const cfgMysqlPassword = document.getElementById("cfgMysqlPassword");
  1719. const cfgClearMysqlPassword = document.getElementById("cfgClearMysqlPassword");
  1720. const cfgMysqlDatabase = document.getElementById("cfgMysqlDatabase");
  1721. const cfgMysqlTestBtn = document.getElementById("cfgMysqlTestBtn");
  1722. const cfgDbSwitchMysqlBtn = document.getElementById("cfgDbSwitchMysqlBtn");
  1723. const cfgDbSwitchSqliteBtn = document.getElementById("cfgDbSwitchSqliteBtn");
  1724. const settingsMsg = document.getElementById("settingsMsg");
  1725. const cfgSearch = document.getElementById("cfgSearch");
  1726. const cfgGroupNav = document.getElementById("cfgGroupNav");
  1727. const settingsGroupsWrap = document.getElementById("settingsGroups");
  1728. const adminLogoutBtn = document.getElementById("adminLogoutBtn");
  1729. const menu = document.getElementById("adminMenu");
  1730. const contentTitle = document.getElementById("contentTitle");
  1731. const modalBackdrop = document.getElementById("adminModalBackdrop");
  1732. const modalTitle = document.getElementById("adminModalTitle");
  1733. const modalHeaderActions = document.getElementById("adminModalHeaderActions");
  1734. const modalClose = document.getElementById("adminModalClose");
  1735. const modalBody = document.getElementById("adminModalBody");
  1736. const modalFooter = document.getElementById("adminModalFooter");
  1737. const modalEl = modalBackdrop ? modalBackdrop.querySelector(".modal") : null;
  1738. let currentModalOnResize = null;
  1739. let currentModalBeforeClose = null;
  1740. let currentModalOnKeydown = null;
  1741. const planMap = new Map();
  1742. const resourceMap = new Map();
  1743. const userMap = new Map();
  1744. const orderMap = new Map();
  1745. const downloadLogMap = new Map();
  1746. const messageMap = new Map();
  1747. const resState = { page: 1, pageSize: 20, total: 0 };
  1748. const userState = { page: 1, pageSize: 20, total: 0 };
  1749. const orderState = { page: 1, pageSize: 20, total: 0 };
  1750. const downloadLogState = { page: 1, pageSize: 20, total: 0 };
  1751. const messageState = { page: 1, pageSize: 20, total: 0 };
  1752. const uploadsState = { filter: "all" };
  1753. let lastSettingsSnapshot = null;
  1754. function formatBytes(bytes) {
  1755. const n = Number(bytes || 0);
  1756. if (!Number.isFinite(n) || n <= 0) return "0 B";
  1757. const units = ["B", "KB", "MB", "GB", "TB"];
  1758. let v = n;
  1759. let i = 0;
  1760. while (v >= 1024 && i < units.length - 1) {
  1761. v /= 1024;
  1762. i += 1;
  1763. }
  1764. const fixed = i === 0 ? 0 : v >= 10 ? 1 : 2;
  1765. return `${v.toFixed(fixed)} ${units[i]}`;
  1766. }
  1767. async function copyText(text) {
  1768. const s = String(text || "");
  1769. if (!s) return;
  1770. try {
  1771. await navigator.clipboard.writeText(s);
  1772. showToastSuccess("已复制链接");
  1773. return;
  1774. } catch (e) {}
  1775. const ta = el("textarea", { style: "position:fixed; left:-9999px; top:-9999px;" }, s);
  1776. document.body.appendChild(ta);
  1777. ta.select();
  1778. try {
  1779. document.execCommand("copy");
  1780. showToastSuccess("已复制链接");
  1781. } catch (e) {
  1782. showToastError("复制失败");
  1783. } finally {
  1784. ta.remove();
  1785. }
  1786. }
  1787. function setUploadsFilter(next) {
  1788. uploadsState.filter = next;
  1789. [uploadsFilterAll, uploadsFilterUnused, uploadsFilterUsed].forEach((b) => b.classList.remove("active"));
  1790. if (next === "unused") uploadsFilterUnused.classList.add("active");
  1791. else if (next === "used") uploadsFilterUsed.classList.add("active");
  1792. else uploadsFilterAll.classList.add("active");
  1793. }
  1794. async function loadUploads() {
  1795. uploadsStats.textContent = "";
  1796. uploadTbody.innerHTML = "";
  1797. try {
  1798. const params = new URLSearchParams();
  1799. const q = (uploadsQ.value || "").trim();
  1800. if (q) params.set("q", q);
  1801. if (uploadsState.filter === "unused") params.set("used", "unused");
  1802. if (uploadsState.filter === "used") params.set("used", "used");
  1803. const resp = await apiFetch(`/admin/uploads?${params.toString()}`);
  1804. const s = resp.stats || {};
  1805. uploadsStats.textContent = [
  1806. `共 ${s.totalCount ?? 0} 个文件(${formatBytes(s.totalBytes ?? 0)})`,
  1807. `已引用 ${s.usedCount ?? 0} 个(${formatBytes(s.usedBytes ?? 0)})`,
  1808. `未引用 ${s.unusedCount ?? 0} 个(${formatBytes(s.unusedBytes ?? 0)})`,
  1809. ].join(" / ");
  1810. const items = Array.isArray(resp.items) ? resp.items : [];
  1811. items.forEach((it) => {
  1812. const name = String(it.name || "");
  1813. const url = String(it.url || "");
  1814. const used = Boolean(it.used);
  1815. const kind = String(it.kind || "file");
  1816. const preview =
  1817. kind === "image"
  1818. ? el("img", { class: "upload-thumb", src: url, alt: name, loading: "lazy" })
  1819. : kind === "video"
  1820. ? badge("视频", "badge-warning")
  1821. : badge("文件");
  1822. const usedBadge = used ? badge("已引用", "badge-success") : badge("未引用", "badge");
  1823. const tr = el(
  1824. "tr",
  1825. {},
  1826. el("td", {}, preview),
  1827. el("td", {}, el("div", { class: "upload-name" }, name), el("div", { class: "muted upload-url" }, url)),
  1828. el("td", {}, formatBytes(it.bytes || 0)),
  1829. el("td", {}, formatDateTime(it.mtime || 0)),
  1830. el("td", {}, usedBadge),
  1831. el(
  1832. "td",
  1833. {},
  1834. btnGroup(
  1835. el("button", { class: "btn btn-sm", onclick: () => copyText(url) }, "复制链接"),
  1836. el(
  1837. "button",
  1838. {
  1839. class: "btn btn-sm btn-danger",
  1840. onclick: async () => {
  1841. const r = await Swal.fire({
  1842. title: "删除文件?",
  1843. text: `将删除:${name}`,
  1844. icon: "warning",
  1845. showCancelButton: true,
  1846. confirmButtonText: "删除",
  1847. cancelButtonText: "取消",
  1848. confirmButtonColor: "var(--danger)",
  1849. });
  1850. if (!r.isConfirmed) return;
  1851. await apiFetch(`/admin/uploads/${encodeURIComponent(name)}`, { method: "DELETE" });
  1852. showToastSuccess("已删除");
  1853. await loadUploads();
  1854. },
  1855. },
  1856. "删除"
  1857. )
  1858. )
  1859. )
  1860. );
  1861. uploadTbody.appendChild(tr);
  1862. });
  1863. if (!items.length) renderEmptyRow(uploadTbody, 6, "暂无数据");
  1864. } catch (e) {
  1865. uploadsStats.textContent = `加载失败:${e.detail?.error || e.status || "unknown"}`;
  1866. if (e.status === 401) window.location.href = "/ui/admin/login";
  1867. }
  1868. }
  1869. async function loadSettings() {
  1870. settingsMsg.textContent = "";
  1871. cfgGogsToken.value = "";
  1872. cfgClearGogsToken.checked = false;
  1873. cfgPayApiKey.value = "";
  1874. cfgClearPayApiKey.checked = false;
  1875. if (cfgAlipayPrivateKey) cfgAlipayPrivateKey.value = "";
  1876. if (cfgClearAlipayPrivateKey) cfgClearAlipayPrivateKey.checked = false;
  1877. if (cfgShowAlipayPrivateKey) cfgShowAlipayPrivateKey.checked = false;
  1878. if (cfgAlipayPrivateKey) cfgAlipayPrivateKey.classList.remove("is-revealed");
  1879. if (cfgAlipayPublicKey) cfgAlipayPublicKey.value = "";
  1880. if (cfgClearAlipayPublicKey) cfgClearAlipayPublicKey.checked = false;
  1881. if (cfgShowAlipayPublicKey) cfgShowAlipayPublicKey.checked = false;
  1882. if (cfgAlipayPublicKey) cfgAlipayPublicKey.classList.remove("is-revealed");
  1883. cfgLlmApiKey.value = "";
  1884. cfgClearLlmApiKey.checked = false;
  1885. if (cfgMysqlPassword) cfgMysqlPassword.value = "";
  1886. if (cfgClearMysqlPassword) cfgClearMysqlPassword.checked = false;
  1887. try {
  1888. const resp = await apiFetch("/admin/settings");
  1889. lastSettingsSnapshot = resp;
  1890. cfgGogsBaseUrl.value = (resp.gogsBaseUrl || "").trim();
  1891. if (resp.hasGogsToken) cfgGogsToken.placeholder = "已配置,留空保持不变";
  1892. else cfgGogsToken.placeholder = "未配置,填写后保存";
  1893. cfgPayProvider.value = (resp.payment?.provider || "MOCK").toUpperCase();
  1894. cfgEnableMockPay.checked = Boolean(resp.payment?.enableMockPay);
  1895. if (resp.payment?.hasApiKey) cfgPayApiKey.placeholder = "已配置,留空保持不变";
  1896. else cfgPayApiKey.placeholder = "未配置,填写后保存";
  1897. if (cfgAlipayAppId) cfgAlipayAppId.value = (resp.payment?.alipay?.appId || "").trim();
  1898. if (cfgAlipayGateway) cfgAlipayGateway.value = (resp.payment?.alipay?.gateway || "").trim();
  1899. if (cfgAlipayNotifyUrl) cfgAlipayNotifyUrl.value = (resp.payment?.alipay?.notifyUrl || "").trim();
  1900. if (cfgAlipayReturnUrl) cfgAlipayReturnUrl.value = (resp.payment?.alipay?.returnUrl || "").trim();
  1901. if (cfgAlipayPrivateKey) {
  1902. if (resp.payment?.alipay?.hasPrivateKey) cfgAlipayPrivateKey.placeholder = "已配置,留空保持不变";
  1903. else cfgAlipayPrivateKey.placeholder = "未配置,填写后保存";
  1904. }
  1905. if (cfgAlipayPublicKey) {
  1906. if (resp.payment?.alipay?.hasPublicKey) cfgAlipayPublicKey.placeholder = "已配置,留空保持不变";
  1907. else cfgAlipayPublicKey.placeholder = "未配置,填写后保存";
  1908. }
  1909. cfgLlmProvider.value = (resp.llm?.provider || "").trim();
  1910. cfgLlmBaseUrl.value = (resp.llm?.baseUrl || "").trim();
  1911. cfgLlmModel.value = (resp.llm?.model || "").trim();
  1912. if (resp.llm?.hasApiKey) cfgLlmApiKey.placeholder = "已配置,留空保持不变";
  1913. else cfgLlmApiKey.placeholder = "未配置,填写后保存";
  1914. if (cfgMysqlHost) cfgMysqlHost.value = (resp.db?.mysql?.host || "").trim();
  1915. if (cfgMysqlPort) cfgMysqlPort.value = String(resp.db?.mysql?.port ?? "").trim();
  1916. if (cfgMysqlUser) cfgMysqlUser.value = (resp.db?.mysql?.user || "").trim();
  1917. if (cfgMysqlDatabase) cfgMysqlDatabase.value = (resp.db?.mysql?.database || "").trim();
  1918. if (cfgMysqlPassword) {
  1919. if (resp.db?.mysql?.hasPassword) cfgMysqlPassword.placeholder = "已配置,留空保持不变";
  1920. else cfgMysqlPassword.placeholder = "未配置,填写后保存";
  1921. }
  1922. if (cfgDbActive) cfgDbActive.textContent = `当前连接:${resp.db?.active || "-"}`;
  1923. settingsMsg.textContent = [
  1924. `Gogs Token:${resp.hasGogsToken ? "已配置" : "未配置"}`,
  1925. `支付 Key:${resp.payment?.hasApiKey ? "已配置" : "未配置"}`,
  1926. `支付宝私钥:${resp.payment?.alipay?.hasPrivateKey ? "已配置" : "未配置"}`,
  1927. `支付宝公钥:${resp.payment?.alipay?.hasPublicKey ? "已配置" : "未配置"}`,
  1928. `大模型 Key:${resp.llm?.hasApiKey ? "已配置" : "未配置"}`,
  1929. `MySQL Password:${resp.db?.mysql?.hasPassword ? "已配置" : "未配置"}`,
  1930. ].join(" / ");
  1931. updatePayProviderVisibility();
  1932. applySettingsFilter();
  1933. } catch (e) {
  1934. settingsMsg.textContent = `加载失败:${e.detail?.error || e.status || "unknown"}`;
  1935. if (e.status === 401) window.location.href = "/ui/admin/login";
  1936. }
  1937. }
  1938. function updatePayProviderVisibility() {
  1939. if (!cfgAlipayFields || !cfgPayProvider) return;
  1940. const p = String(cfgPayProvider.value || "").trim().toUpperCase();
  1941. cfgAlipayFields.style.display = p === "ALIPAY" ? "" : "none";
  1942. }
  1943. function listSettingGroups() {
  1944. if (!settingsGroupsWrap) return [];
  1945. return Array.from(settingsGroupsWrap.querySelectorAll(".collapse.settings-group"));
  1946. }
  1947. function listVisibleSettingGroups() {
  1948. const groups = listSettingGroups();
  1949. if (!settingsGroupsWrap) return groups;
  1950. if (settingsGroupsWrap.classList.contains("is-tabs")) {
  1951. const active = groups.find((g) => g.classList.contains("is-active"));
  1952. return active ? [active] : [];
  1953. }
  1954. return groups.filter((g) => g.style.display !== "none");
  1955. }
  1956. function setSettingGroupOpen(groupEl, open) {
  1957. if (!groupEl) return;
  1958. groupEl.setAttribute("data-open", open ? "1" : "0");
  1959. }
  1960. function setActiveSettingsGroup(targetSel, opts) {
  1961. if (!settingsGroupsWrap) return;
  1962. const target = String(targetSel || "").trim();
  1963. if (!target) return;
  1964. const groups = listSettingGroups();
  1965. groups.forEach((g) => {
  1966. g.classList.remove("is-active");
  1967. g.style.display = "";
  1968. });
  1969. const el = document.querySelector(target);
  1970. if (!el) return;
  1971. settingsGroupsWrap.classList.add("is-tabs");
  1972. settingsGroupsWrap.classList.remove("is-searching");
  1973. el.classList.add("is-active");
  1974. setSettingNavActive(target);
  1975. try {
  1976. localStorage.setItem("adminSettingsActiveGroup", target);
  1977. } catch (e) {}
  1978. if (opts && opts.open) setSettingGroupOpen(el, true);
  1979. if (opts && opts.scroll) el.scrollIntoView({ block: "start", behavior: "smooth" });
  1980. }
  1981. function getActiveSettingsGroupSel() {
  1982. try {
  1983. const v = localStorage.getItem("adminSettingsActiveGroup");
  1984. if (v && document.querySelector(v)) return v;
  1985. } catch (e) {}
  1986. const first = listSettingGroups().find((g) => g && g.id);
  1987. return first ? `#${first.id}` : "#cfgGroupGogs";
  1988. }
  1989. function setSettingNavActive(targetSel) {
  1990. if (!cfgGroupNav) return;
  1991. cfgGroupNav.querySelectorAll(".btn").forEach((b) => b.classList.remove("active"));
  1992. const btn = cfgGroupNav.querySelector(`.btn[data-target="${targetSel}"]`);
  1993. if (btn) btn.classList.add("active");
  1994. }
  1995. function applySettingsFilter() {
  1996. const q = (cfgSearch && cfgSearch.value ? cfgSearch.value : "").trim().toLowerCase();
  1997. const groups = listSettingGroups();
  1998. if (!settingsGroupsWrap) return;
  1999. if (!q) {
  2000. settingsGroupsWrap.classList.remove("is-searching");
  2001. setActiveSettingsGroup(getActiveSettingsGroupSel(), { open: true, scroll: false });
  2002. return;
  2003. }
  2004. settingsGroupsWrap.classList.remove("is-tabs");
  2005. settingsGroupsWrap.classList.add("is-searching");
  2006. groups.forEach((g) => {
  2007. g.classList.remove("is-active");
  2008. const text = (g.textContent || "").toLowerCase();
  2009. const show = text.includes(q);
  2010. g.style.display = show ? "" : "none";
  2011. if (show) setSettingGroupOpen(g, true);
  2012. });
  2013. }
  2014. async function saveSettings() {
  2015. settingsMsg.textContent = "";
  2016. try {
  2017. const mysqlPayload =
  2018. cfgMysqlHost || cfgMysqlPort || cfgMysqlUser || cfgMysqlPassword || cfgMysqlDatabase || cfgClearMysqlPassword
  2019. ? {
  2020. host: cfgMysqlHost ? cfgMysqlHost.value.trim() : "",
  2021. port: cfgMysqlPort ? cfgMysqlPort.value.trim() : "",
  2022. user: cfgMysqlUser ? cfgMysqlUser.value.trim() : "",
  2023. password: cfgMysqlPassword ? cfgMysqlPassword.value.trim() : "",
  2024. clearPassword: cfgClearMysqlPassword ? cfgClearMysqlPassword.checked : false,
  2025. database: cfgMysqlDatabase ? cfgMysqlDatabase.value.trim() : "",
  2026. }
  2027. : null;
  2028. await apiFetch("/admin/settings", {
  2029. method: "PUT",
  2030. body: Object.assign(
  2031. {
  2032. gogsBaseUrl: cfgGogsBaseUrl.value.trim(),
  2033. gogsToken: cfgGogsToken.value.trim(),
  2034. clearGogsToken: cfgClearGogsToken.checked,
  2035. payment: {
  2036. provider: cfgPayProvider.value,
  2037. enableMockPay: cfgEnableMockPay.checked,
  2038. apiKey: cfgPayApiKey.value.trim(),
  2039. clearApiKey: cfgClearPayApiKey.checked,
  2040. alipay: {
  2041. appId: cfgAlipayAppId ? cfgAlipayAppId.value.trim() : "",
  2042. gateway: cfgAlipayGateway ? cfgAlipayGateway.value.trim() : "",
  2043. notifyUrl: cfgAlipayNotifyUrl ? cfgAlipayNotifyUrl.value.trim() : "",
  2044. returnUrl: cfgAlipayReturnUrl ? cfgAlipayReturnUrl.value.trim() : "",
  2045. privateKey: cfgAlipayPrivateKey ? cfgAlipayPrivateKey.value.trim() : "",
  2046. clearPrivateKey: cfgClearAlipayPrivateKey ? cfgClearAlipayPrivateKey.checked : false,
  2047. publicKey: cfgAlipayPublicKey ? cfgAlipayPublicKey.value.trim() : "",
  2048. clearPublicKey: cfgClearAlipayPublicKey ? cfgClearAlipayPublicKey.checked : false,
  2049. },
  2050. },
  2051. llm: {
  2052. provider: cfgLlmProvider.value.trim(),
  2053. baseUrl: cfgLlmBaseUrl.value.trim(),
  2054. model: cfgLlmModel.value.trim(),
  2055. apiKey: cfgLlmApiKey.value.trim(),
  2056. clearApiKey: cfgClearLlmApiKey.checked,
  2057. },
  2058. },
  2059. mysqlPayload ? { mysql: mysqlPayload } : {}
  2060. ),
  2061. });
  2062. await loadSettings();
  2063. settingsMsg.textContent = "保存成功";
  2064. } catch (e) {
  2065. settingsMsg.textContent = `保存失败:${e.detail?.error || e.status || "unknown"}`;
  2066. if (e.status === 401) window.location.href = "/ui/admin/login";
  2067. }
  2068. }
  2069. async function saveSettingsPartial(body) {
  2070. settingsMsg.textContent = "";
  2071. try {
  2072. await apiFetch("/admin/settings", { method: "PUT", body });
  2073. await loadSettings();
  2074. settingsMsg.textContent = "保存成功";
  2075. } catch (e) {
  2076. settingsMsg.textContent = `保存失败:${e.detail?.error || e.status || "unknown"}`;
  2077. if (e.status === 401) window.location.href = "/ui/admin/login";
  2078. }
  2079. }
  2080. async function testMysqlConnection() {
  2081. settingsMsg.textContent = "";
  2082. try {
  2083. const body = {
  2084. host: cfgMysqlHost ? cfgMysqlHost.value.trim() : "",
  2085. port: cfgMysqlPort ? cfgMysqlPort.value.trim() : "",
  2086. user: cfgMysqlUser ? cfgMysqlUser.value.trim() : "",
  2087. database: cfgMysqlDatabase ? cfgMysqlDatabase.value.trim() : "",
  2088. };
  2089. const pwd = cfgMysqlPassword ? cfgMysqlPassword.value.trim() : "";
  2090. if (pwd) body.password = pwd;
  2091. const resp = await apiFetch("/admin/mysql/test", { method: "POST", body });
  2092. if (resp.ok && resp.createdDatabase) {
  2093. settingsMsg.textContent = "MySQL:连接成功(已自动创建库)";
  2094. } else {
  2095. settingsMsg.textContent = resp.ok ? "MySQL:连接成功" : "MySQL:连接失败";
  2096. }
  2097. } catch (e) {
  2098. const errno = e.detail?.errno ? ` errno=${e.detail.errno}` : "";
  2099. settingsMsg.textContent = `MySQL:连接失败(${e.detail?.error || e.status || "unknown"}${errno})`;
  2100. if (e.status === 401) window.location.href = "/ui/admin/login";
  2101. }
  2102. }
  2103. async function switchDatabase(target, force) {
  2104. settingsMsg.textContent = "";
  2105. if (target === "mysql") {
  2106. const host = cfgMysqlHost ? cfgMysqlHost.value.trim() : "";
  2107. const user = cfgMysqlUser ? cfgMysqlUser.value.trim() : "";
  2108. const database = cfgMysqlDatabase ? cfgMysqlDatabase.value.trim() : "";
  2109. if (!host || !user || !database) {
  2110. await Swal.fire({
  2111. title: "MySQL 参数不完整",
  2112. text: "请先填写 Host / User / Database(可选填写 Port / Password),再切换",
  2113. icon: "error",
  2114. });
  2115. return;
  2116. }
  2117. }
  2118. const r = await Swal.fire({
  2119. title: "切换数据库?",
  2120. text: target === "mysql" ? "将迁移数据到 MySQL,并切换读写到 MySQL" : "将迁移数据到 SQLite,并切换读写到 SQLite",
  2121. icon: "warning",
  2122. showCancelButton: true,
  2123. confirmButtonText: "继续",
  2124. cancelButtonText: "取消",
  2125. confirmButtonColor: "var(--danger)",
  2126. });
  2127. if (!r.isConfirmed) return;
  2128. try {
  2129. const body = { target, force: Boolean(force) };
  2130. if (target === "mysql") {
  2131. const mysql = {
  2132. host: cfgMysqlHost ? cfgMysqlHost.value.trim() : "",
  2133. port: cfgMysqlPort ? cfgMysqlPort.value.trim() : "",
  2134. user: cfgMysqlUser ? cfgMysqlUser.value.trim() : "",
  2135. database: cfgMysqlDatabase ? cfgMysqlDatabase.value.trim() : "",
  2136. clearPassword: cfgClearMysqlPassword ? cfgClearMysqlPassword.checked : false,
  2137. };
  2138. const pwd = cfgMysqlPassword ? cfgMysqlPassword.value.trim() : "";
  2139. if (pwd) mysql.password = pwd;
  2140. body.mysql = mysql;
  2141. }
  2142. const resp = await apiFetch("/admin/db/switch", { method: "POST", body });
  2143. settingsMsg.textContent = `切换成功:${resp.from} → ${resp.to}`;
  2144. await loadSettings();
  2145. } catch (e) {
  2146. if (e.detail?.error === "target_not_empty") {
  2147. const r2 = await Swal.fire({
  2148. title: "目标库非空,是否覆盖?",
  2149. text: "继续将清空目标库的表数据,然后迁移并切换(不可逆)",
  2150. icon: "warning",
  2151. showCancelButton: true,
  2152. confirmButtonText: "覆盖并切换",
  2153. cancelButtonText: "取消",
  2154. confirmButtonColor: "var(--danger)",
  2155. });
  2156. if (!r2.isConfirmed) return;
  2157. await switchDatabase(target, true);
  2158. return;
  2159. }
  2160. settingsMsg.textContent = `切换失败:${e.detail?.error || e.status || "unknown"}`;
  2161. if (e.status === 401) window.location.href = "/ui/admin/login";
  2162. }
  2163. }
  2164. async function fillRefSelect(owner, repo, selectEl, prefer) {
  2165. selectEl.innerHTML = "";
  2166. selectEl.appendChild(el("option", { value: "AUTO" }, "AUTO(默认分支)"));
  2167. const [branches, tags] = await Promise.all([
  2168. apiFetch(`/admin/gogs/branches?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`),
  2169. apiFetch(`/admin/gogs/tags?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`),
  2170. ]);
  2171. const branchGroup = document.createElement("optgroup");
  2172. branchGroup.label = "分支";
  2173. (branches.items || []).forEach((b) => {
  2174. branchGroup.appendChild(el("option", { value: b.name }, b.name));
  2175. });
  2176. selectEl.appendChild(branchGroup);
  2177. const tagGroup = document.createElement("optgroup");
  2178. tagGroup.label = "标签";
  2179. (tags.items || []).forEach((t) => {
  2180. tagGroup.appendChild(el("option", { value: t.name }, t.name));
  2181. });
  2182. selectEl.appendChild(tagGroup);
  2183. if (prefer) selectEl.value = prefer;
  2184. }
  2185. async function closeModal(force) {
  2186. const isForce = force === true;
  2187. if (!isForce && currentModalBeforeClose) {
  2188. try {
  2189. const ok = await currentModalBeforeClose();
  2190. if (!ok) return;
  2191. } catch (e) {}
  2192. }
  2193. if (currentModalOnKeydown) {
  2194. try {
  2195. document.removeEventListener("keydown", currentModalOnKeydown, true);
  2196. } catch (e) {}
  2197. }
  2198. modalBackdrop.style.display = "none";
  2199. modalTitle.textContent = "";
  2200. modalBody.innerHTML = "";
  2201. modalFooter.innerHTML = "";
  2202. if (modalHeaderActions) modalHeaderActions.innerHTML = "";
  2203. if (modalEl) modalEl.removeAttribute("data-size");
  2204. currentModalOnResize = null;
  2205. currentModalBeforeClose = null;
  2206. currentModalOnKeydown = null;
  2207. }
  2208. function openModal(title, bodyNodes, footerNodes, icon = "ri-settings-4-line", opts = {}) {
  2209. modalTitle.innerHTML = "";
  2210. modalTitle.appendChild(el("i", { class: icon }));
  2211. modalTitle.appendChild(document.createTextNode(title));
  2212. modalBody.innerHTML = "";
  2213. modalFooter.innerHTML = "";
  2214. if (modalHeaderActions) modalHeaderActions.innerHTML = "";
  2215. if (modalEl) modalEl.removeAttribute("data-size");
  2216. currentModalOnResize = typeof opts.onResize === "function" ? opts.onResize : null;
  2217. currentModalBeforeClose = typeof opts.beforeClose === "function" ? opts.beforeClose : null;
  2218. if (currentModalOnKeydown) {
  2219. try {
  2220. document.removeEventListener("keydown", currentModalOnKeydown, true);
  2221. } catch (e) {}
  2222. currentModalOnKeydown = null;
  2223. }
  2224. if (typeof opts.onKeydown === "function") {
  2225. currentModalOnKeydown = (evt) => opts.onKeydown(evt);
  2226. document.addEventListener("keydown", currentModalOnKeydown, true);
  2227. }
  2228. bodyNodes.forEach((n) => modalBody.appendChild(n));
  2229. footerNodes.forEach((n) => modalFooter.appendChild(n));
  2230. modalBackdrop.style.display = "";
  2231. if (modalEl && opts.resizable && modalHeaderActions) {
  2232. let preferredSize = (opts.size || "").toString().trim();
  2233. if (!preferredSize) {
  2234. try {
  2235. preferredSize = (localStorage.getItem("adminModalSize") || "").toString().trim();
  2236. } catch (e) {}
  2237. }
  2238. if (!preferredSize) preferredSize = "sm";
  2239. const btnSm = el("button", { type: "button", class: "btn btn-sm btn-toggle" }, "小");
  2240. const btnLg = el("button", { type: "button", class: "btn btn-sm btn-toggle" }, "大");
  2241. function applySize(size) {
  2242. const s = size === "sm" ? "sm" : "lg";
  2243. modalEl.setAttribute("data-size", s);
  2244. btnSm.classList.toggle("active", s === "sm");
  2245. btnLg.classList.toggle("active", s === "lg");
  2246. try {
  2247. localStorage.setItem("adminModalSize", s);
  2248. } catch (e) {}
  2249. if (currentModalOnResize) currentModalOnResize(s);
  2250. }
  2251. btnSm.addEventListener("click", () => applySize("sm"));
  2252. btnLg.addEventListener("click", () => applySize("lg"));
  2253. modalHeaderActions.appendChild(el("div", { class: "btn-group" }, btnSm, btnLg));
  2254. applySize(preferredSize);
  2255. } else if (modalEl && currentModalOnResize) {
  2256. const s = (modalEl.getAttribute("data-size") || "sm").toString();
  2257. currentModalOnResize(s);
  2258. } else if (modalEl && opts.size) {
  2259. modalEl.setAttribute("data-size", String(opts.size));
  2260. }
  2261. }
  2262. modalClose.addEventListener("click", () => closeModal());
  2263. modalBackdrop.addEventListener("click", (evt) => {
  2264. if (evt.target === modalBackdrop) closeModal();
  2265. });
  2266. function insertAtCursor(textarea, text) {
  2267. const start = textarea.selectionStart || 0;
  2268. const end = textarea.selectionEnd || 0;
  2269. const before = textarea.value.slice(0, start);
  2270. const after = textarea.value.slice(end);
  2271. textarea.value = `${before}${text}${after}`;
  2272. const pos = start + text.length;
  2273. textarea.setSelectionRange(pos, pos);
  2274. textarea.focus();
  2275. try {
  2276. textarea.dispatchEvent(new Event("input", { bubbles: true }));
  2277. } catch (e) {}
  2278. }
  2279. function parseRepoInput(raw) {
  2280. let s = (raw || "").trim();
  2281. if (!s) return null;
  2282. s = s.replace(/\.git$/i, "");
  2283. if (s.includes("://")) {
  2284. try {
  2285. const u = new URL(s);
  2286. s = (u.pathname || "").replace(/^\/+/, "");
  2287. } catch (e) {
  2288. return null;
  2289. }
  2290. }
  2291. const sshIdx = s.indexOf(":");
  2292. if (s.startsWith("git@") && sshIdx !== -1) {
  2293. s = s.slice(sshIdx + 1);
  2294. }
  2295. s = s.replace(/^\/+/, "");
  2296. const parts = s.split("/").filter(Boolean);
  2297. if (parts.length < 2) return null;
  2298. return { owner: parts[0], repo: parts[1] };
  2299. }
  2300. function buildMarkdownEditor({ initialValue, msgEl }) {
  2301. const summaryInput = el("textarea", {
  2302. class: "input md-editor-input",
  2303. style: "min-height:260px; resize:vertical",
  2304. placeholder: "简介(Markdown,支持粘贴/拖拽上传图片/视频)",
  2305. value: initialValue || "",
  2306. });
  2307. const syncReadme = el("input", { type: "checkbox" });
  2308. syncReadme.checked = true;
  2309. attachPasteUpload(summaryInput, msgEl);
  2310. const tocWrap = el("div", { class: "md-toc", style: "display:none" });
  2311. const tocTitle = el("div", { class: "md-toc-title" }, el("span", {}, "大纲"), el("span", { class: "muted", style: "font-weight:650" }, "点击跳转"));
  2312. const tocItems = el("div", { class: "md-toc-items" });
  2313. tocWrap.appendChild(tocTitle);
  2314. tocWrap.appendChild(tocItems);
  2315. const mdContent = el("div", { html: "" });
  2316. const mdPreview = el("div", { class: "md md-editor-preview", html: "" }, tocWrap, mdContent);
  2317. let showToc = false;
  2318. function slugify(text) {
  2319. const raw = (text || "").toString().trim().toLowerCase();
  2320. const s = raw
  2321. .replace(/[\s]+/g, "-")
  2322. .replace(/[^\u4e00-\u9fa5a-z0-9\-_]/g, "")
  2323. .replace(/-+/g, "-")
  2324. .replace(/^-|-$/g, "");
  2325. return s || "h";
  2326. }
  2327. function buildToc() {
  2328. tocItems.innerHTML = "";
  2329. const headings = Array.from(mdContent.querySelectorAll("h1,h2,h3,h4,h5,h6"));
  2330. if (!showToc || !headings.length) {
  2331. tocWrap.style.display = "none";
  2332. return;
  2333. }
  2334. tocWrap.style.display = "";
  2335. const used = new Map();
  2336. headings.forEach((h) => {
  2337. const level = Number(String(h.tagName || "H2").replace("H", "")) || 2;
  2338. const base = slugify(h.textContent || "");
  2339. const n = (used.get(base) || 0) + 1;
  2340. used.set(base, n);
  2341. const id = n === 1 ? base : `${base}-${n}`;
  2342. if (!h.id) h.id = id;
  2343. const btn = el("button", { type: "button", class: "md-toc-item", style: `padding-left:${Math.max(0, (level - 1) * 12)}px` }, h.textContent || "");
  2344. btn.addEventListener("click", (evt) => {
  2345. evt.preventDefault();
  2346. try {
  2347. h.scrollIntoView({ behavior: "smooth", block: "start" });
  2348. } catch (e) {
  2349. h.scrollIntoView();
  2350. }
  2351. });
  2352. tocItems.appendChild(btn);
  2353. });
  2354. }
  2355. function updateMdPreview() {
  2356. mdContent.innerHTML = renderMarkdown(summaryInput.value);
  2357. buildToc();
  2358. }
  2359. updateMdPreview();
  2360. summaryInput.addEventListener("input", updateMdPreview);
  2361. function wrapSelection(textarea, left, right) {
  2362. const start = textarea.selectionStart || 0;
  2363. const end = textarea.selectionEnd || 0;
  2364. const value = textarea.value || "";
  2365. const selected = value.slice(start, end);
  2366. const next = `${value.slice(0, start)}${left}${selected}${right}${value.slice(end)}`;
  2367. textarea.value = next;
  2368. const nextStart = start + left.length;
  2369. const nextEnd = nextStart + selected.length;
  2370. textarea.setSelectionRange(nextStart, nextEnd);
  2371. textarea.focus();
  2372. try {
  2373. textarea.dispatchEvent(new Event("input", { bubbles: true }));
  2374. } catch (e) {}
  2375. }
  2376. function prefixLines(textarea, prefix) {
  2377. const start = textarea.selectionStart || 0;
  2378. const end = textarea.selectionEnd || 0;
  2379. const value = textarea.value || "";
  2380. const selected = value.slice(start, end);
  2381. const text = selected || "";
  2382. const nextBlock = text
  2383. .split("\n")
  2384. .map((line) => (line ? `${prefix}${line}` : prefix.trimEnd()))
  2385. .join("\n");
  2386. const insertText = selected ? nextBlock : `\n${prefix}`;
  2387. insertAtCursor(textarea, insertText);
  2388. }
  2389. function mdBtn(label, title, onClick) {
  2390. const b = el("button", { type: "button", class: "btn btn-sm", title }, label);
  2391. b.addEventListener("click", (evt) => {
  2392. evt.preventDefault();
  2393. onClick();
  2394. });
  2395. return b;
  2396. }
  2397. function insertSnippet(text, cursorRelStart, cursorRelEnd) {
  2398. const start = summaryInput.selectionStart || 0;
  2399. const end = summaryInput.selectionEnd || 0;
  2400. const before = summaryInput.value.slice(0, start);
  2401. const after = summaryInput.value.slice(end);
  2402. summaryInput.value = `${before}${text}${after}`;
  2403. const s = start + (cursorRelStart == null ? text.length : cursorRelStart);
  2404. const e = start + (cursorRelEnd == null ? (cursorRelStart == null ? text.length : cursorRelStart) : cursorRelEnd);
  2405. summaryInput.setSelectionRange(s, e);
  2406. summaryInput.focus();
  2407. try {
  2408. summaryInput.dispatchEvent(new Event("input", { bubbles: true }));
  2409. } catch (e2) {}
  2410. }
  2411. const viewEditBtn = el("button", { type: "button", class: "btn btn-sm btn-toggle active" }, "编辑");
  2412. const viewPreviewBtn = el("button", { type: "button", class: "btn btn-sm btn-toggle" }, "预览");
  2413. const viewSplitBtn = el("button", { type: "button", class: "btn btn-sm btn-toggle" }, "分屏");
  2414. const tocBtn = el("button", { type: "button", class: "btn btn-sm btn-toggle", title: "按标题生成大纲" }, "大纲");
  2415. const boldBtn = mdBtn("B", "加粗", () => wrapSelection(summaryInput, "**", "**"));
  2416. const italicBtn = mdBtn("I", "斜体", () => wrapSelection(summaryInput, "*", "*"));
  2417. const codeBtn = mdBtn("</>", "行内代码", () => wrapSelection(summaryInput, "`", "`"));
  2418. const h2Btn = mdBtn("H2", "二级标题", () => insertAtCursor(summaryInput, "\n## "));
  2419. const blockCodeBtn = mdBtn("代码块", "代码块", () => insertSnippet("\n```text\n\n```\n", "\n```text\n".length));
  2420. const tableBtn = mdBtn("表格", "表格", () => {
  2421. const t = "\n| 标题 | 内容 |\n| --- | --- |\n| | |\n";
  2422. const cursor = t.lastIndexOf("| |") + 2;
  2423. insertSnippet(t, cursor, cursor);
  2424. });
  2425. const imgLinkBtn = mdBtn("图片链接", "图片链接", () => {
  2426. const t = "\n![]()\n";
  2427. insertSnippet(t, t.indexOf("()") + 1, t.indexOf(")") );
  2428. });
  2429. const quoteBtn = mdBtn("引用", "引用", () => prefixLines(summaryInput, "> "));
  2430. const ulBtn = mdBtn("•", "无序列表", () => insertAtCursor(summaryInput, "\n- "));
  2431. const olBtn = mdBtn("1.", "有序列表", () => insertAtCursor(summaryInput, "\n1. "));
  2432. const linkBtn = mdBtn("链接", "链接", () => {
  2433. const start = summaryInput.selectionStart || 0;
  2434. const end = summaryInput.selectionEnd || 0;
  2435. const selected = (summaryInput.value || "").slice(start, end);
  2436. if (selected) wrapSelection(summaryInput, "[", "](https://)");
  2437. else insertAtCursor(summaryInput, "[](" + "https://)");
  2438. });
  2439. const imgFile = el("input", { type: "file", accept: "image/*", style: "display:none" });
  2440. const videoFile = el("input", { type: "file", accept: "video/*", style: "display:none" });
  2441. const uploadImgBtn = el("button", { class: "btn btn-sm" }, "上传图片");
  2442. const uploadVideoBtn = el("button", { class: "btn btn-sm" }, "上传视频");
  2443. uploadImgBtn.addEventListener("click", () => imgFile.click());
  2444. uploadVideoBtn.addEventListener("click", () => videoFile.click());
  2445. const onPickFile = async (f) => {
  2446. msgEl.textContent = "上传中...";
  2447. try {
  2448. const url = await adminUploadFile(f);
  2449. const syntax = (f.type || "").startsWith("video/") ? `\n@[video](${url})\n` : `\n![](${url})\n`;
  2450. insertAtCursor(summaryInput, syntax);
  2451. msgEl.textContent = "已插入上传内容";
  2452. } catch (e) {
  2453. msgEl.textContent = `上传失败:${e.detail?.error || e.status || "unknown"}`;
  2454. if (e.status === 401) window.location.href = "/ui/admin/login";
  2455. }
  2456. };
  2457. summaryInput.addEventListener("dragover", (evt) => {
  2458. const files = evt.dataTransfer?.files ? Array.from(evt.dataTransfer.files) : [];
  2459. const file = files.find((it) => (it.type || "").startsWith("image/") || (it.type || "").startsWith("video/"));
  2460. if (file) evt.preventDefault();
  2461. });
  2462. summaryInput.addEventListener("drop", async (evt) => {
  2463. const files = evt.dataTransfer?.files ? Array.from(evt.dataTransfer.files) : [];
  2464. const file = files.find((it) => (it.type || "").startsWith("image/") || (it.type || "").startsWith("video/"));
  2465. if (!file) return;
  2466. evt.preventDefault();
  2467. await onPickFile(file);
  2468. });
  2469. imgFile.addEventListener("change", async () => {
  2470. const f = imgFile.files && imgFile.files[0];
  2471. if (f) await onPickFile(f);
  2472. imgFile.value = "";
  2473. });
  2474. videoFile.addEventListener("change", async () => {
  2475. const f = videoFile.files && videoFile.files[0];
  2476. if (f) await onPickFile(f);
  2477. videoFile.value = "";
  2478. });
  2479. const mdToolbar = el(
  2480. "div",
  2481. { class: "toolbar md-editor-toolbar", style: "margin:0" },
  2482. viewEditBtn,
  2483. viewPreviewBtn,
  2484. viewSplitBtn,
  2485. tocBtn,
  2486. boldBtn,
  2487. italicBtn,
  2488. codeBtn,
  2489. h2Btn,
  2490. blockCodeBtn,
  2491. tableBtn,
  2492. imgLinkBtn,
  2493. quoteBtn,
  2494. ulBtn,
  2495. olBtn,
  2496. linkBtn,
  2497. el("div", { style: "flex:1" }),
  2498. uploadImgBtn,
  2499. uploadVideoBtn,
  2500. imgFile,
  2501. videoFile,
  2502. el("label", { class: "checkbox-row", style: "margin:0" }, syncReadme, el("span", { class: "muted" }, "同步 README.md"))
  2503. );
  2504. const mdEditor = el(
  2505. "div",
  2506. { class: "md-editor", "data-view": "edit" },
  2507. mdToolbar,
  2508. el("div", { class: "md-editor-body" }, summaryInput, mdPreview)
  2509. );
  2510. function setMdView(view) {
  2511. mdEditor.setAttribute("data-view", view);
  2512. [viewEditBtn, viewPreviewBtn, viewSplitBtn].forEach((b) => b.classList.remove("active"));
  2513. if (view === "preview") viewPreviewBtn.classList.add("active");
  2514. else if (view === "split") viewSplitBtn.classList.add("active");
  2515. else viewEditBtn.classList.add("active");
  2516. updateMdPreview();
  2517. }
  2518. function setViewByModalSize(size) {
  2519. const s = size === "lg" ? "lg" : "sm";
  2520. const cur = (mdEditor.getAttribute("data-view") || "edit").toString();
  2521. if (s === "lg" && cur === "edit") setMdView("split");
  2522. if (s === "sm" && cur === "split") setMdView("edit");
  2523. updateMdPreview();
  2524. }
  2525. viewEditBtn.addEventListener("click", () => setMdView("edit"));
  2526. viewPreviewBtn.addEventListener("click", () => setMdView("preview"));
  2527. viewSplitBtn.addEventListener("click", () => setMdView("split"));
  2528. tocBtn.addEventListener("click", () => {
  2529. showToc = !showToc;
  2530. tocBtn.classList.toggle("active", showToc);
  2531. updateMdPreview();
  2532. });
  2533. function setText(text) {
  2534. summaryInput.value = String(text || "");
  2535. try {
  2536. summaryInput.dispatchEvent(new Event("input", { bubbles: true }));
  2537. } catch (e) {}
  2538. }
  2539. return { root: mdEditor, textarea: summaryInput, syncReadme, toolbarEl: mdToolbar, setText, setMdView, setViewByModalSize };
  2540. }
  2541. async function adminUploadFileMeta(file) {
  2542. const fd = new FormData();
  2543. fd.append("file", file);
  2544. const headers = {};
  2545. const csrf = getCookie("csrf_token");
  2546. if (csrf) headers["X-CSRF-Token"] = csrf;
  2547. const resp = await fetch("/admin/uploads", { method: "POST", body: fd, headers });
  2548. const contentType = resp.headers.get("content-type") || "";
  2549. const isJson = contentType.includes("application/json");
  2550. const detail = isJson ? await resp.json() : null;
  2551. if (!resp.ok) {
  2552. const err = new Error("upload_failed");
  2553. err.status = resp.status;
  2554. err.detail = detail;
  2555. throw err;
  2556. }
  2557. return detail;
  2558. }
  2559. async function adminUploadFile(file) {
  2560. const detail = await adminUploadFileMeta(file);
  2561. return detail.url;
  2562. }
  2563. function attachPasteUpload(textarea, msgEl) {
  2564. textarea.addEventListener("paste", async (evt) => {
  2565. const items = evt.clipboardData?.items ? Array.from(evt.clipboardData.items) : [];
  2566. const fileItem = items.find((it) => it.kind === "file" && (it.type || "").startsWith("image/")) || items.find((it) => it.kind === "file" && (it.type || "").startsWith("video/"));
  2567. if (!fileItem) return;
  2568. evt.preventDefault();
  2569. const file = fileItem.getAsFile();
  2570. if (!file) return;
  2571. msgEl.textContent = "上传中...";
  2572. try {
  2573. const url = await adminUploadFile(file);
  2574. const syntax = (file.type || "").startsWith("video/") ? `\n@[video](${url})\n` : `\n![](${url})\n`;
  2575. insertAtCursor(textarea, syntax);
  2576. msgEl.textContent = "已插入上传内容";
  2577. } catch (e) {
  2578. msgEl.textContent = `上传失败:${e.detail?.error || e.status || "unknown"}`;
  2579. if (e.status === 401) window.location.href = "/ui/admin/login";
  2580. }
  2581. });
  2582. }
  2583. function openRepoPicker(initialOwner, onPick) {
  2584. const ownerInput = el("input", { class: "input", placeholder: "Owner(可选:留空则列出 Token 可见仓库)", value: initialOwner || "" });
  2585. const qInput = el("input", { class: "input", placeholder: "仓库关键词(可选)" });
  2586. const searchBtn = el("button", { class: "btn" }, "搜索");
  2587. const msg = el("div", { class: "muted" }, "");
  2588. const table = el("table", { class: "table" }, el("thead", {}, el("tr", {}, el("th", {}, "仓库"), el("th", {}, "默认分支"), el("th", {}, "操作"))), el("tbody", {}));
  2589. const tbody = table.querySelector("tbody");
  2590. const tableWrap = el("div", { class: "table-wrap" }, table);
  2591. async function refresh() {
  2592. tbody.innerHTML = "";
  2593. msg.textContent = "";
  2594. try {
  2595. const params = new URLSearchParams();
  2596. if (ownerInput.value.trim()) params.set("owner", ownerInput.value.trim());
  2597. if (qInput.value.trim()) params.set("q", qInput.value.trim());
  2598. const resp = await apiFetch(`/admin/gogs/repos?${params.toString()}`);
  2599. const items = resp.items || [];
  2600. if (!items.length) {
  2601. renderEmptyRow(tbody, 3, "未找到仓库");
  2602. return;
  2603. }
  2604. items.forEach((r) => {
  2605. const ownerName = (r.owner || (r.fullName || "").split("/")[0] || "").trim();
  2606. const tr = el(
  2607. "tr",
  2608. {},
  2609. el("td", {}, r.fullName || r.name),
  2610. el("td", {}, r.defaultBranch || "-"),
  2611. el(
  2612. "td",
  2613. {},
  2614. btnGroup(
  2615. el(
  2616. "button",
  2617. {
  2618. class: "btn",
  2619. onclick: () => {
  2620. onPick({ owner: ownerName, name: r.name, fullName: r.fullName || "", defaultBranch: r.defaultBranch || "" });
  2621. closeModal();
  2622. },
  2623. },
  2624. "选择"
  2625. )
  2626. )
  2627. )
  2628. );
  2629. tbody.appendChild(tr);
  2630. });
  2631. } catch (e) {
  2632. const errCode = e.detail?.error || e.status || "unknown";
  2633. const upstream = e.detail?.status ? `(Gogs: ${e.detail.status})` : "";
  2634. if (e.detail?.error === "gogs_token_required") {
  2635. msg.textContent = "查询失败:未配置 GOGS_TOKEN,请填写 Owner 后再搜索";
  2636. return;
  2637. }
  2638. if (e.detail?.error === "gogs_unreachable" || (e.detail?.error === "gogs_failed" && Number(e.detail?.status || 0) === 599)) {
  2639. const url = (e.detail?.url || "").toString().trim();
  2640. msg.textContent = `查询失败:无法连接 Gogs,请检查 GOGS_BASE_URL/网络${url ? `(${url})` : ""}。若不配置 Token,请填写 Owner 后再搜索。`;
  2641. showToastError("无法连接 Gogs");
  2642. return;
  2643. }
  2644. msg.textContent = `查询失败:${errCode}${upstream}`;
  2645. if (e.status === 401) window.location.href = "/ui/admin/login";
  2646. }
  2647. }
  2648. searchBtn.addEventListener("click", refresh);
  2649. openModal(
  2650. "选择仓库",
  2651. [el("div", { class: "toolbar toolbar-tight" }, ownerInput, qInput, searchBtn), msg, tableWrap],
  2652. [el("button", { class: "btn", onclick: closeModal }, "关闭")],
  2653. "ri-git-repository-line"
  2654. );
  2655. refresh();
  2656. }
  2657. async function loadPlans() {
  2658. const planTbody = document.querySelector("#planTable tbody");
  2659. planTbody.innerHTML = "";
  2660. const plans = await apiFetch("/admin/plans");
  2661. planMap.clear();
  2662. plans.forEach((p) => {
  2663. planMap.set(String(p.id), p);
  2664. const tr = el(
  2665. "tr",
  2666. {},
  2667. el("td", { title: String(p.id) }, String(p.id)),
  2668. el("td", { title: p.name }, p.name),
  2669. el("td", {}, String(p.durationDays)),
  2670. el("td", {}, formatCents(p.priceCents)),
  2671. el("td", {}, p.enabled ? badge("启用", "badge-success") : badge("禁用", "badge-danger")),
  2672. el("td", {}, String(p.sort)),
  2673. el(
  2674. "td",
  2675. { class: "td-actions" },
  2676. btnGroup(
  2677. el("button", { class: "btn", "data-action": "edit-plan", "data-id": String(p.id) }, "编辑"),
  2678. el("button", { class: "btn", "data-action": "del-plan", "data-id": String(p.id) }, "删除")
  2679. )
  2680. )
  2681. );
  2682. planTbody.appendChild(tr);
  2683. });
  2684. if (!plans.length) renderEmptyRow(planTbody, 7, "暂无数据");
  2685. }
  2686. if (settingsGroupsWrap) {
  2687. settingsGroupsWrap.addEventListener("click", (evt) => {
  2688. const head = evt.target.closest(".collapse-head");
  2689. if (!head) return;
  2690. const wrap = head.closest(".collapse");
  2691. if (!wrap) return;
  2692. evt.preventDefault();
  2693. const cur = wrap.getAttribute("data-open") === "1";
  2694. setSettingGroupOpen(wrap, !cur);
  2695. if (wrap.id) setSettingNavActive(`#${wrap.id}`);
  2696. });
  2697. }
  2698. if (cfgGroupNav) {
  2699. cfgGroupNav.addEventListener("click", (evt) => {
  2700. const btn = evt.target.closest(".btn");
  2701. if (!btn) return;
  2702. const targetSel = btn.getAttribute("data-target");
  2703. if (!targetSel) return;
  2704. evt.preventDefault();
  2705. if (cfgSearch && cfgSearch.value.trim()) {
  2706. cfgSearch.value = "";
  2707. applySettingsFilter();
  2708. }
  2709. setActiveSettingsGroup(targetSel, { open: true, scroll: false });
  2710. });
  2711. }
  2712. if (cfgSearch) {
  2713. cfgSearch.addEventListener("input", () => {
  2714. applySettingsFilter();
  2715. });
  2716. cfgSearch.addEventListener("keydown", (evt) => {
  2717. if (evt.key !== "Enter") return;
  2718. evt.preventDefault();
  2719. applySettingsFilter();
  2720. const first = listSettingGroups().find((g) => g.style.display !== "none");
  2721. if (first) {
  2722. setSettingGroupOpen(first, true);
  2723. if (first.id) setSettingNavActive(`#${first.id}`);
  2724. first.scrollIntoView({ block: "start", behavior: "smooth" });
  2725. }
  2726. });
  2727. }
  2728. async function loadResources() {
  2729. const resTbody = document.querySelector("#resourceTable tbody");
  2730. resTbody.innerHTML = "";
  2731. const query = new URLSearchParams();
  2732. if (resQ.value.trim()) query.set("q", resQ.value.trim());
  2733. if (resTypeFilter.value) query.set("type", resTypeFilter.value);
  2734. if (resStatusFilter.value) query.set("status", resStatusFilter.value);
  2735. query.set("page", String(resState.page));
  2736. query.set("pageSize", String(resState.pageSize));
  2737. const resp = await apiFetch(`/admin/resources?${query.toString()}`);
  2738. const resources = resp.items || [];
  2739. resState.total = Number(resp.total || 0);
  2740. resourceMap.clear();
  2741. resources.forEach((r) => {
  2742. resourceMap.set(String(r.id), r);
  2743. const tr = el(
  2744. "tr",
  2745. {},
  2746. el("td", { title: String(r.id) }, String(r.id)),
  2747. el("td", { title: r.title }, r.title),
  2748. el("td", {}, resourceTypeBadge(r.type)),
  2749. el("td", {}, resourceStatusBadge(r.status)),
  2750. el("td", { title: r.defaultRef }, r.defaultRef),
  2751. el("td", { title: `${r.repoOwner}/${r.repoName}` }, `${r.repoOwner}/${r.repoName}`),
  2752. el("td", { title: formatDateTime(r.updatedAt) }, formatDateTime(r.updatedAt)),
  2753. el(
  2754. "td",
  2755. { class: "td-actions" },
  2756. btnGroup(
  2757. el("a", { class: "btn", href: `/ui/resources/${r.id}` }, "查看"),
  2758. el("button", { class: "btn", "data-action": "edit-res", "data-id": String(r.id) }, "编辑"),
  2759. el("button", { class: "btn", "data-action": "del-res", "data-id": String(r.id) }, "删除")
  2760. )
  2761. )
  2762. );
  2763. resTbody.appendChild(tr);
  2764. });
  2765. if (!resources.length) renderEmptyRow(resTbody, 8, "暂无数据");
  2766. const pageCount = Math.max(1, Math.ceil(resState.total / resState.pageSize));
  2767. resPageInfo.textContent = `第 ${resState.page} / ${pageCount} 页,共 ${resState.total} 条`;
  2768. resPrevPage.disabled = resState.page <= 1;
  2769. resNextPage.disabled = resState.page >= pageCount;
  2770. }
  2771. async function loadOrders() {
  2772. const orderTbody = document.querySelector("#orderTable tbody");
  2773. orderTbody.innerHTML = "";
  2774. const query = new URLSearchParams();
  2775. if (orderQ.value.trim()) query.set("q", orderQ.value.trim());
  2776. if (orderStatusFilter.value) query.set("status", orderStatusFilter.value);
  2777. query.set("page", String(orderState.page));
  2778. query.set("pageSize", String(orderState.pageSize));
  2779. const orders = await apiFetch(`/admin/orders?${query.toString()}`);
  2780. orderState.total = Number(orders.total || 0);
  2781. orderMap.clear();
  2782. (orders.items || []).forEach((o) => {
  2783. orderMap.set(String(o.id), o);
  2784. const isLocked = o.status === "PAID";
  2785. const delBtn = el("button", { class: "btn", "data-action": "del-order", "data-id": String(o.id) }, "删除");
  2786. if (isLocked) {
  2787. delBtn.disabled = true;
  2788. }
  2789. const tr = el(
  2790. "tr",
  2791. {},
  2792. el("td", { title: o.id }, o.id),
  2793. el("td", {}, orderStatusBadge(o.status)),
  2794. el("td", {}, formatCents(o.amountCents)),
  2795. el("td", { title: `${o.userId} / ${o.userPhone}` }, `${o.userId} / ${o.userPhone}`),
  2796. el(
  2797. "td",
  2798. { title: `${o.planSnapshot.name}(${o.planSnapshot.durationDays}天 / ${formatCents(o.planSnapshot.priceCents)})` },
  2799. o.planSnapshot.name
  2800. ),
  2801. el("td", { title: formatDateTime(o.createdAt) }, formatDateTime(o.createdAt)),
  2802. el("td", { title: formatDateTime(o.paidAt) }, formatDateTime(o.paidAt)),
  2803. el(
  2804. "td",
  2805. { class: "td-actions" },
  2806. btnGroup(
  2807. el("button", { class: "btn", "data-action": "view-order", "data-id": String(o.id) }, "查看"),
  2808. delBtn
  2809. )
  2810. )
  2811. );
  2812. orderTbody.appendChild(tr);
  2813. });
  2814. if (!(orders.items || []).length) renderEmptyRow(orderTbody, 8, "暂无数据");
  2815. const pageCount = Math.max(1, Math.ceil(orderState.total / orderState.pageSize));
  2816. orderPageInfo.textContent = `第 ${orderState.page} / ${pageCount} 页,共 ${orderState.total} 条`;
  2817. orderPrevPage.disabled = orderState.page <= 1;
  2818. orderNextPage.disabled = orderState.page >= pageCount;
  2819. }
  2820. async function loadUsers() {
  2821. const userTbody = document.querySelector("#userTable tbody");
  2822. userTbody.innerHTML = "";
  2823. const query = new URLSearchParams();
  2824. if (userQ.value.trim()) query.set("q", userQ.value.trim());
  2825. if (userStatusFilter.value) query.set("status", userStatusFilter.value);
  2826. if (userVipFilter && userVipFilter.value) query.set("vip", userVipFilter.value);
  2827. query.set("page", String(userState.page));
  2828. query.set("pageSize", String(userState.pageSize));
  2829. const resp = await apiFetch(`/admin/users?${query.toString()}`);
  2830. const users = resp.items || [];
  2831. userState.total = Number(resp.total || 0);
  2832. userMap.clear();
  2833. users.forEach((u) => {
  2834. userMap.set(String(u.id), u);
  2835. const vipBadge = u.vipActive ? badge("VIP", "badge-vip") : badge("非VIP", "badge");
  2836. const vipDays = u.vipActive && Number.isFinite(Number(u.vipRemainingDays)) ? `剩余 ${Number(u.vipRemainingDays)} 天` : "";
  2837. const vipInfo = el(
  2838. "div",
  2839. { style: "display:flex; align-items:center; gap:8px; white-space:nowrap;" },
  2840. vipBadge,
  2841. vipDays ? el("span", { class: "muted", style: "font-size: inherit;" }, vipDays) : null
  2842. );
  2843. const tr = el(
  2844. "tr",
  2845. {},
  2846. el("td", { title: String(u.id) }, String(u.id)),
  2847. el("td", { title: u.phone }, u.phone),
  2848. el("td", {}, userStatusBadge(u.status)),
  2849. el("td", {}, vipInfo),
  2850. el("td", { title: formatDateTime(u.vipExpireAt) }, formatDateTime(u.vipExpireAt)),
  2851. el("td", { title: formatDateTime(u.createdAt) }, formatDateTime(u.createdAt)),
  2852. el(
  2853. "td",
  2854. { class: "td-actions" },
  2855. btnGroup(
  2856. el("button", { class: "btn", "data-action": "user-actions", "data-id": String(u.id) }, "操作")
  2857. )
  2858. )
  2859. );
  2860. userTbody.appendChild(tr);
  2861. });
  2862. if (!users.length) renderEmptyRow(userTbody, 7, "暂无数据");
  2863. const pageCount = Math.max(1, Math.ceil(userState.total / userState.pageSize));
  2864. userPageInfo.textContent = `第 ${userState.page} / ${pageCount} 页,共 ${userState.total} 条`;
  2865. userPrevPage.disabled = userState.page <= 1;
  2866. userNextPage.disabled = userState.page >= pageCount;
  2867. }
  2868. async function loadDownloadLogs() {
  2869. const tbody = document.querySelector("#downloadLogTable tbody");
  2870. tbody.innerHTML = "";
  2871. const query = new URLSearchParams();
  2872. if (dlQ && dlQ.value.trim()) query.set("q", dlQ.value.trim());
  2873. if (dlTypeFilter && dlTypeFilter.value) query.set("type", dlTypeFilter.value);
  2874. if (dlStateFilter && dlStateFilter.value) query.set("state", dlStateFilter.value);
  2875. query.set("page", String(downloadLogState.page));
  2876. query.set("pageSize", String(downloadLogState.pageSize));
  2877. const resp = await apiFetch(`/admin/download-logs?${query.toString()}`);
  2878. downloadLogState.total = Number(resp.total || 0);
  2879. downloadLogMap.clear();
  2880. (resp.items || []).forEach((it) => {
  2881. downloadLogMap.set(String(it.id), it);
  2882. const stateBadge =
  2883. it.resourceState === "DELETED"
  2884. ? badge("已删除", "badge-danger")
  2885. : it.resourceState === "OFFLINE"
  2886. ? badge("已下架", "badge-warning")
  2887. : badge("在线", "badge-success");
  2888. const typeBadge = it.resourceType === "VIP" ? badge("VIP", "badge-vip") : badge("免费", "badge-free");
  2889. const currentTypeBadge =
  2890. it.currentResourceType === "VIP"
  2891. ? badge("VIP", "badge-vip")
  2892. : it.currentResourceType === "FREE"
  2893. ? badge("免费", "badge-free")
  2894. : badge("-", "badge");
  2895. const userCell = `${it.userId} / ${it.userPhone || "-"}`;
  2896. const titleText = String(it.resourceTitle || "");
  2897. const titleNode =
  2898. it.resourceId && it.resourceState === "ONLINE"
  2899. ? el("a", { href: `/ui/resources/${it.resourceId}`, style: "color: inherit; text-decoration: none;" }, titleText)
  2900. : el("span", { class: "muted" }, titleText);
  2901. const tr = el(
  2902. "tr",
  2903. {},
  2904. el("td", { title: String(it.id) }, String(it.id)),
  2905. el("td", { title: formatDateTime(it.downloadedAt) }, formatDateTime(it.downloadedAt)),
  2906. el("td", { title: userCell }, userCell),
  2907. el("td", { title: titleText }, titleNode),
  2908. el("td", {}, typeBadge),
  2909. el("td", {}, currentTypeBadge),
  2910. el("td", {}, stateBadge),
  2911. el("td", { title: String(it.ip || "") }, String(it.ip || "-")),
  2912. el(
  2913. "td",
  2914. { class: "td-actions" },
  2915. btnGroup(el("button", { class: "btn", "data-action": "view-download-log", "data-id": String(it.id) }, "查看"))
  2916. )
  2917. );
  2918. tbody.appendChild(tr);
  2919. });
  2920. if (!(resp.items || []).length) renderEmptyRow(tbody, 9, "暂无数据");
  2921. const pageCount = Math.max(1, Math.ceil(downloadLogState.total / downloadLogState.pageSize));
  2922. if (dlPageInfo) dlPageInfo.textContent = `第 ${downloadLogState.page} / ${pageCount} 页,共 ${downloadLogState.total} 条`;
  2923. if (dlPrevPage) dlPrevPage.disabled = downloadLogState.page <= 1;
  2924. if (dlNextPage) dlNextPage.disabled = downloadLogState.page >= pageCount;
  2925. }
  2926. async function loadAdminMessages() {
  2927. if (!msgTbody) return;
  2928. msgTbody.innerHTML = "";
  2929. const params = new URLSearchParams();
  2930. params.set("page", String(messageState.page));
  2931. params.set("pageSize", String(messageState.pageSize));
  2932. const q = (msgQ?.value || "").trim();
  2933. if (q) params.set("q", q);
  2934. const read = (msgReadFilter?.value || "").trim();
  2935. if (read) params.set("read", read);
  2936. const senderType = (msgSenderFilter?.value || "").trim();
  2937. if (senderType) params.set("senderType", senderType);
  2938. try {
  2939. const resp = await apiFetch(`/admin/messages?${params.toString()}`);
  2940. messageState.total = parseInt(resp.total || 0, 10) || 0;
  2941. messageMap.clear();
  2942. const items = Array.isArray(resp.items) ? resp.items : [];
  2943. items.forEach((m) => {
  2944. messageMap.set(String(m.id), m);
  2945. const userCell = `${m.userId} / ${m.userPhone || "-"}`;
  2946. const readBadge = m.read ? badge("已读", "badge-success") : badge("未读", "badge-warning");
  2947. const senderBadge = m.senderType === "ADMIN" ? badge("管理员", "badge-info") : badge("系统", "badge");
  2948. const titleText = String(m.title || "");
  2949. const tr = el(
  2950. "tr",
  2951. {},
  2952. el("td", { title: String(m.id) }, String(m.id)),
  2953. el("td", { title: userCell }, userCell),
  2954. el("td", { title: titleText }, titleText),
  2955. el("td", { title: formatDateTime(m.createdAt) }, formatDateTime(m.createdAt)),
  2956. el("td", {}, readBadge),
  2957. el("td", {}, senderBadge),
  2958. el(
  2959. "td",
  2960. { class: "td-actions" },
  2961. btnGroup(
  2962. el("button", { class: "btn", "data-action": "view-message", "data-id": String(m.id) }, "查看"),
  2963. el("button", { class: "btn btn-danger", "data-action": "del-message", "data-id": String(m.id) }, "删除")
  2964. )
  2965. )
  2966. );
  2967. msgTbody.appendChild(tr);
  2968. });
  2969. if (!items.length) renderEmptyRow(msgTbody, 7, "暂无数据");
  2970. const pageCount = Math.max(1, Math.ceil(messageState.total / messageState.pageSize));
  2971. if (msgPageInfo) msgPageInfo.textContent = `第 ${messageState.page} / ${pageCount} 页,共 ${messageState.total} 条`;
  2972. if (msgPrevPage) msgPrevPage.disabled = messageState.page <= 1;
  2973. if (msgNextPage) msgNextPage.disabled = messageState.page >= pageCount;
  2974. } catch (e) {
  2975. if (e.status === 401) window.location.href = "/ui/admin/login";
  2976. renderEmptyRow(msgTbody, 7, `加载失败:${e.detail?.error || e.status || "unknown"}`);
  2977. }
  2978. }
  2979. async function loadAdminOverview() {
  2980. if (!ovUsersTotal || !ovSystemInfo) return;
  2981. try {
  2982. if (overviewUpdatedAt) overviewUpdatedAt.textContent = "加载中…";
  2983. const stats = await apiFetch("/admin/stats");
  2984. if (ovUsersTotal) ovUsersTotal.textContent = String(stats?.users?.total ?? 0);
  2985. if (ovUsersSub) ovUsersSub.textContent = `活跃 ${stats?.users?.active ?? 0},VIP ${stats?.users?.vipActive ?? 0}`;
  2986. if (ovResourcesTotal) ovResourcesTotal.textContent = String(stats?.resources?.total ?? 0);
  2987. if (ovResourcesSub) ovResourcesSub.textContent = `上架 ${stats?.resources?.online ?? 0}`;
  2988. if (ovOrdersTotal) ovOrdersTotal.textContent = String(stats?.orders?.total ?? 0);
  2989. if (ovOrdersSub) ovOrdersSub.textContent = `已付 ${stats?.orders?.paid ?? 0},待付 ${stats?.orders?.pending ?? 0}`;
  2990. if (ovRevenueTotal) ovRevenueTotal.textContent = formatCents(stats?.revenue?.totalCents ?? 0);
  2991. if (ovRevenueSub) ovRevenueSub.textContent = `24h ${formatCents(stats?.revenue?.last24hCents ?? 0)}`;
  2992. if (ovDownloadsTotal) ovDownloadsTotal.textContent = String(stats?.downloads?.total ?? 0);
  2993. if (ovDownloadsSub) ovDownloadsSub.textContent = `24h ${stats?.downloads?.last24h ?? 0}`;
  2994. if (ovMessagesTotal) ovMessagesTotal.textContent = String(stats?.messages?.total ?? 0);
  2995. if (ovMessagesSub) ovMessagesSub.textContent = `24h ${stats?.messages?.last24h ?? 0}`;
  2996. if (ovSystemInfo) ovSystemInfo.textContent = `当前数据库:${stats?.backend || "-"},统计时间:${formatDateTime(stats?.now)}`;
  2997. if (overviewUpdatedAt) overviewUpdatedAt.textContent = `更新时间:${formatDateTime(stats?.now)}`;
  2998. } catch (e) {
  2999. if (e.status === 401) window.location.href = "/ui/admin/login";
  3000. if (overviewUpdatedAt) overviewUpdatedAt.textContent = `加载失败:${e.detail?.error || e.status || "unknown"}`;
  3001. }
  3002. }
  3003. async function activate(section) {
  3004. const effectiveSection = section;
  3005. document.querySelectorAll(".menu-item").forEach((a) => a.classList.remove("active"));
  3006. const link = document.querySelector(`.menu-item[data-section='${section}']`);
  3007. if (link) link.classList.add("active");
  3008. document.querySelectorAll(".content-section").forEach((s) => (s.style.display = "none"));
  3009. const sec = document.getElementById(`sec-${effectiveSection}`);
  3010. if (sec) sec.style.display = "";
  3011. if (effectiveSection === "overview") {
  3012. contentTitle.textContent = "概览";
  3013. await loadAdminOverview();
  3014. } else if (effectiveSection === "plans") {
  3015. contentTitle.textContent = "会员方案";
  3016. await loadPlans();
  3017. } else if (effectiveSection === "resources") {
  3018. contentTitle.textContent = "资源管理";
  3019. await loadResources();
  3020. } else if (effectiveSection === "uploads") {
  3021. contentTitle.textContent = "上传管理";
  3022. await loadUploads();
  3023. } else if (effectiveSection === "orders") {
  3024. contentTitle.textContent = "订单管理";
  3025. orderState.page = 1;
  3026. await loadOrders();
  3027. } else if (effectiveSection === "users") {
  3028. contentTitle.textContent = "用户管理";
  3029. await loadUsers();
  3030. } else if (effectiveSection === "download-logs") {
  3031. contentTitle.textContent = "下载记录";
  3032. downloadLogState.page = 1;
  3033. await loadDownloadLogs();
  3034. } else if (effectiveSection === "messages") {
  3035. contentTitle.textContent = "消息管理";
  3036. messageState.page = 1;
  3037. await loadAdminMessages();
  3038. } else if (effectiveSection === "settings") {
  3039. contentTitle.textContent = "第三方配置";
  3040. await loadSettings();
  3041. }
  3042. }
  3043. menu.addEventListener("click", async (evt) => {
  3044. const a = evt.target.closest(".menu-item");
  3045. if (!a) return;
  3046. evt.preventDefault();
  3047. const sec = a.getAttribute("data-section");
  3048. await activate(sec);
  3049. });
  3050. if (overviewRefreshBtn) {
  3051. overviewRefreshBtn.addEventListener("click", async () => {
  3052. await loadAdminOverview();
  3053. });
  3054. }
  3055. settingsRefreshBtn.addEventListener("click", async () => {
  3056. await loadSettings();
  3057. });
  3058. settingsSaveBtn.addEventListener("click", async () => {
  3059. await saveSettings();
  3060. });
  3061. if (cfgPayProvider) {
  3062. cfgPayProvider.addEventListener("change", () => {
  3063. updatePayProviderVisibility();
  3064. });
  3065. }
  3066. if (cfgAlipayUseCurrentNotify && cfgAlipayNotifyUrl) {
  3067. cfgAlipayUseCurrentNotify.addEventListener("click", () => {
  3068. cfgAlipayNotifyUrl.value = `${window.location.origin}/pay/callback`;
  3069. });
  3070. }
  3071. if (cfgAlipayUseCurrentReturn && cfgAlipayReturnUrl) {
  3072. cfgAlipayUseCurrentReturn.addEventListener("click", () => {
  3073. cfgAlipayReturnUrl.value = `${window.location.origin}/ui/me`;
  3074. });
  3075. }
  3076. if (cfgShowAlipayPrivateKey && cfgAlipayPrivateKey) {
  3077. cfgShowAlipayPrivateKey.addEventListener("change", () => {
  3078. cfgAlipayPrivateKey.classList.toggle("is-revealed", Boolean(cfgShowAlipayPrivateKey.checked));
  3079. });
  3080. }
  3081. if (cfgShowAlipayPublicKey && cfgAlipayPublicKey) {
  3082. cfgShowAlipayPublicKey.addEventListener("change", () => {
  3083. cfgAlipayPublicKey.classList.toggle("is-revealed", Boolean(cfgShowAlipayPublicKey.checked));
  3084. });
  3085. }
  3086. if (cfgGogsSaveBtn) {
  3087. cfgGogsSaveBtn.addEventListener("click", async () => {
  3088. await saveSettingsPartial({
  3089. gogsBaseUrl: cfgGogsBaseUrl.value.trim(),
  3090. gogsToken: cfgGogsToken.value.trim(),
  3091. clearGogsToken: cfgClearGogsToken.checked,
  3092. });
  3093. });
  3094. }
  3095. if (cfgGogsResetBtn) {
  3096. cfgGogsResetBtn.addEventListener("click", async () => {
  3097. await loadSettings();
  3098. const g = document.getElementById("cfgGroupGogs");
  3099. if (g) g.setAttribute("data-open", "1");
  3100. });
  3101. }
  3102. if (cfgPaySaveBtn) {
  3103. cfgPaySaveBtn.addEventListener("click", async () => {
  3104. await saveSettingsPartial({
  3105. payment: {
  3106. provider: cfgPayProvider.value,
  3107. enableMockPay: cfgEnableMockPay.checked,
  3108. apiKey: cfgPayApiKey.value.trim(),
  3109. clearApiKey: cfgClearPayApiKey.checked,
  3110. alipay: {
  3111. appId: cfgAlipayAppId ? cfgAlipayAppId.value.trim() : "",
  3112. gateway: cfgAlipayGateway ? cfgAlipayGateway.value.trim() : "",
  3113. notifyUrl: cfgAlipayNotifyUrl ? cfgAlipayNotifyUrl.value.trim() : "",
  3114. returnUrl: cfgAlipayReturnUrl ? cfgAlipayReturnUrl.value.trim() : "",
  3115. privateKey: cfgAlipayPrivateKey ? cfgAlipayPrivateKey.value.trim() : "",
  3116. clearPrivateKey: cfgClearAlipayPrivateKey ? cfgClearAlipayPrivateKey.checked : false,
  3117. publicKey: cfgAlipayPublicKey ? cfgAlipayPublicKey.value.trim() : "",
  3118. clearPublicKey: cfgClearAlipayPublicKey ? cfgClearAlipayPublicKey.checked : false,
  3119. },
  3120. },
  3121. });
  3122. });
  3123. }
  3124. if (cfgPayResetBtn) {
  3125. cfgPayResetBtn.addEventListener("click", async () => {
  3126. await loadSettings();
  3127. const g = document.getElementById("cfgGroupPay");
  3128. if (g) g.setAttribute("data-open", "1");
  3129. });
  3130. }
  3131. if (cfgLlmSaveBtn) {
  3132. cfgLlmSaveBtn.addEventListener("click", async () => {
  3133. await saveSettingsPartial({
  3134. llm: {
  3135. provider: cfgLlmProvider.value.trim(),
  3136. baseUrl: cfgLlmBaseUrl.value.trim(),
  3137. model: cfgLlmModel.value.trim(),
  3138. apiKey: cfgLlmApiKey.value.trim(),
  3139. clearApiKey: cfgClearLlmApiKey.checked,
  3140. },
  3141. });
  3142. });
  3143. }
  3144. if (cfgLlmResetBtn) {
  3145. cfgLlmResetBtn.addEventListener("click", async () => {
  3146. await loadSettings();
  3147. const g = document.getElementById("cfgGroupLlm");
  3148. if (g) g.setAttribute("data-open", "1");
  3149. });
  3150. }
  3151. if (cfgDbSaveBtn) {
  3152. cfgDbSaveBtn.addEventListener("click", async () => {
  3153. await saveSettingsPartial({
  3154. mysql: {
  3155. host: cfgMysqlHost ? cfgMysqlHost.value.trim() : "",
  3156. port: cfgMysqlPort ? cfgMysqlPort.value.trim() : "",
  3157. user: cfgMysqlUser ? cfgMysqlUser.value.trim() : "",
  3158. password: cfgMysqlPassword ? cfgMysqlPassword.value.trim() : "",
  3159. clearPassword: cfgClearMysqlPassword ? cfgClearMysqlPassword.checked : false,
  3160. database: cfgMysqlDatabase ? cfgMysqlDatabase.value.trim() : "",
  3161. },
  3162. });
  3163. });
  3164. }
  3165. if (cfgDbResetBtn) {
  3166. cfgDbResetBtn.addEventListener("click", async () => {
  3167. await loadSettings();
  3168. const g = document.getElementById("cfgGroupDb");
  3169. if (g) g.setAttribute("data-open", "1");
  3170. });
  3171. }
  3172. if (cfgMysqlTestBtn) {
  3173. cfgMysqlTestBtn.addEventListener("click", async () => {
  3174. await testMysqlConnection();
  3175. });
  3176. }
  3177. if (cfgDbSwitchMysqlBtn) {
  3178. cfgDbSwitchMysqlBtn.addEventListener("click", async () => {
  3179. await switchDatabase("mysql", false);
  3180. });
  3181. }
  3182. if (cfgDbSwitchSqliteBtn) {
  3183. cfgDbSwitchSqliteBtn.addEventListener("click", async () => {
  3184. await switchDatabase("sqlite", false);
  3185. });
  3186. }
  3187. uploadsFilterAll.addEventListener("click", async () => {
  3188. setUploadsFilter("all");
  3189. await loadUploads();
  3190. });
  3191. uploadsFilterUnused.addEventListener("click", async () => {
  3192. setUploadsFilter("unused");
  3193. await loadUploads();
  3194. });
  3195. uploadsFilterUsed.addEventListener("click", async () => {
  3196. setUploadsFilter("used");
  3197. await loadUploads();
  3198. });
  3199. uploadsRefreshBtn.addEventListener("click", async () => {
  3200. await loadUploads();
  3201. });
  3202. uploadsQ.addEventListener("keydown", async (evt) => {
  3203. if (evt.key !== "Enter") return;
  3204. evt.preventDefault();
  3205. await loadUploads();
  3206. });
  3207. uploadsUploadBtn.addEventListener("click", () => {
  3208. uploadsFile.value = "";
  3209. uploadsFile.click();
  3210. });
  3211. uploadsFile.addEventListener("change", async () => {
  3212. const files = uploadsFile.files ? Array.from(uploadsFile.files) : [];
  3213. if (!files.length) return;
  3214. uploadsUploadBtn.disabled = true;
  3215. uploadsCleanupBtn.disabled = true;
  3216. uploadsRefreshBtn.disabled = true;
  3217. try {
  3218. for (const f of files) {
  3219. await adminUploadFileMeta(f);
  3220. }
  3221. showToastSuccess("上传成功");
  3222. await loadUploads();
  3223. } catch (e) {
  3224. showToastError(e.detail?.error || e.status || "上传失败");
  3225. if (e.status === 401) window.location.href = "/ui/admin/login";
  3226. } finally {
  3227. uploadsUploadBtn.disabled = false;
  3228. uploadsCleanupBtn.disabled = false;
  3229. uploadsRefreshBtn.disabled = false;
  3230. }
  3231. });
  3232. uploadsCleanupBtn.addEventListener("click", async () => {
  3233. const r = await Swal.fire({
  3234. title: "一键清理未使用文件?",
  3235. text: "将删除 uploads 目录中所有未被资源引用的文件。",
  3236. icon: "warning",
  3237. showCancelButton: true,
  3238. confirmButtonText: "开始清理",
  3239. cancelButtonText: "取消",
  3240. confirmButtonColor: "var(--danger)",
  3241. });
  3242. if (!r.isConfirmed) return;
  3243. try {
  3244. const resp = await apiFetch("/admin/uploads/cleanup-unused", { method: "POST" });
  3245. showToastSuccess(`已清理 ${resp.deletedCount || 0} 个文件`);
  3246. await loadUploads();
  3247. } catch (e) {
  3248. showToastError(e.detail?.error || e.status || "清理失败");
  3249. if (e.status === 401) window.location.href = "/ui/admin/login";
  3250. }
  3251. });
  3252. createPlanOpenBtn.addEventListener("click", () => {
  3253. const nameInput = el("input", { class: "input", placeholder: "名称" });
  3254. const daysInput = el("input", { class: "input", placeholder: "时长(天)" });
  3255. const priceInput = el("input", { class: "input", placeholder: "价格(分)" });
  3256. const enabledSelect = el("select", { class: "input" }, el("option", { value: "1" }, "启用"), el("option", { value: "0" }, "禁用"));
  3257. const sortInput = el("input", { class: "input", placeholder: "排序,默认 0", value: "0" });
  3258. const msg = el("div", { class: "muted" }, "");
  3259. openModal(
  3260. "新增方案",
  3261. [
  3262. el("label", { class: "label" }, "名称"),
  3263. nameInput,
  3264. el("label", { class: "label" }, "时长(天)"),
  3265. daysInput,
  3266. el("label", { class: "label" }, "价格(分)"),
  3267. priceInput,
  3268. el("label", { class: "label" }, "启用"),
  3269. enabledSelect,
  3270. el("label", { class: "label" }, "排序"),
  3271. sortInput,
  3272. msg,
  3273. ],
  3274. [
  3275. el("button", { class: "btn", onclick: closeModal }, "取消"),
  3276. el(
  3277. "button",
  3278. {
  3279. class: "btn btn-primary",
  3280. onclick: async () => {
  3281. msg.textContent = "";
  3282. try {
  3283. await apiFetch("/admin/plans", {
  3284. method: "POST",
  3285. body: {
  3286. name: nameInput.value.trim(),
  3287. durationDays: Number(daysInput.value),
  3288. priceCents: Number(priceInput.value),
  3289. enabled: enabledSelect.value === "1",
  3290. sort: Number(sortInput.value || "0"),
  3291. },
  3292. });
  3293. closeModal();
  3294. await loadPlans();
  3295. } catch (e) {
  3296. msg.textContent = `创建失败:${e.detail?.error || e.status || "unknown"}`;
  3297. if (e.status === 401) window.location.href = "/ui/admin/login";
  3298. }
  3299. },
  3300. },
  3301. "创建"
  3302. ),
  3303. ],
  3304. "ri-add-circle-line"
  3305. );
  3306. });
  3307. document.addEventListener("click", async (evt) => {
  3308. const btn = evt.target.closest("button[data-action]");
  3309. if (!btn) return;
  3310. const action = btn.getAttribute("data-action");
  3311. const id = btn.getAttribute("data-id");
  3312. const openVipAdjustModal = (u) => {
  3313. const daysInput = el("input", { class: "input", value: "30" });
  3314. const msg = el("div", { class: "muted" }, "");
  3315. openModal(
  3316. `调整会员 #${u.id}`,
  3317. [
  3318. el("div", { class: "muted" }, `手机号:${u.phone},当前到期:${formatDateTime(u.vipExpireAt)}`),
  3319. el("label", { class: "label" }, "增加天数(可为负数)"),
  3320. daysInput,
  3321. msg,
  3322. ],
  3323. [
  3324. el("button", { class: "btn", onclick: closeModal }, "取消"),
  3325. el(
  3326. "button",
  3327. {
  3328. class: "btn btn-primary",
  3329. onclick: async () => {
  3330. msg.textContent = "";
  3331. try {
  3332. await apiFetch(`/admin/users/${u.id}/vip-adjust`, { method: "POST", body: { addDays: Number(daysInput.value) } });
  3333. closeModal();
  3334. await loadUsers();
  3335. } catch (e) {
  3336. msg.textContent = `保存失败:${e.detail?.error || e.status || "unknown"}`;
  3337. if (e.status === 401) window.location.href = "/ui/admin/login";
  3338. }
  3339. },
  3340. },
  3341. "保存"
  3342. ),
  3343. ],
  3344. "ri-vip-crown-line"
  3345. );
  3346. };
  3347. const openResetUserPasswordModal = (u) => {
  3348. const msg = el("div", { class: "muted" }, "");
  3349. const passwordInput = el("input", { class: "input", type: "password" });
  3350. const confirmInput = el("input", { class: "input", type: "password" });
  3351. const generate = () => `${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`.slice(0, 12);
  3352. passwordInput.value = generate();
  3353. confirmInput.value = passwordInput.value;
  3354. openModal(
  3355. `重置密码 #${u.id}`,
  3356. [
  3357. el("div", { class: "muted" }, `手机号:${u.phone}`),
  3358. el("label", { class: "label" }, "新密码(至少 6 位)"),
  3359. passwordInput,
  3360. el("label", { class: "label" }, "确认新密码"),
  3361. confirmInput,
  3362. msg,
  3363. ],
  3364. [
  3365. el("button", { class: "btn", onclick: closeModal }, "取消"),
  3366. el(
  3367. "button",
  3368. {
  3369. class: "btn",
  3370. onclick: () => {
  3371. passwordInput.value = generate();
  3372. confirmInput.value = passwordInput.value;
  3373. },
  3374. },
  3375. "随机生成"
  3376. ),
  3377. el("button", { class: "btn", onclick: () => copyText(passwordInput.value) }, "复制密码"),
  3378. el(
  3379. "button",
  3380. {
  3381. class: "btn btn-primary",
  3382. onclick: async () => {
  3383. msg.textContent = "";
  3384. const p1 = passwordInput.value || "";
  3385. const p2 = confirmInput.value || "";
  3386. if (p1.length < 6) {
  3387. msg.textContent = "新密码至少 6 位";
  3388. return;
  3389. }
  3390. if (p1 !== p2) {
  3391. msg.textContent = "两次输入不一致";
  3392. return;
  3393. }
  3394. try {
  3395. await apiFetch(`/admin/users/${u.id}/password-reset`, { method: "POST", body: { password: p1 } });
  3396. closeModal();
  3397. showToastSuccess("已重置密码");
  3398. } catch (e) {
  3399. msg.textContent = `重置失败:${e.detail?.error || e.status || "unknown"}`;
  3400. if (e.status === 401) window.location.href = "/ui/admin/login";
  3401. }
  3402. },
  3403. },
  3404. "确认重置"
  3405. ),
  3406. ],
  3407. "ri-key-2-line"
  3408. );
  3409. };
  3410. if (action === "del-plan") {
  3411. try {
  3412. await apiFetch(`/admin/plans/${id}`, { method: "DELETE" });
  3413. await loadPlans();
  3414. } catch (e) {
  3415. if (e.status === 401) window.location.href = "/ui/admin/login";
  3416. }
  3417. }
  3418. if (action === "edit-plan") {
  3419. const plan = planMap.get(String(id));
  3420. if (!plan) return;
  3421. const nameInput = el("input", { class: "input", value: plan.name });
  3422. const daysInput = el("input", { class: "input", value: String(plan.durationDays) });
  3423. const priceInput = el("input", { class: "input", value: String(plan.priceCents) });
  3424. const enabledSelect = el(
  3425. "select",
  3426. { class: "input" },
  3427. el("option", { value: "1" }, "启用"),
  3428. el("option", { value: "0" }, "禁用")
  3429. );
  3430. enabledSelect.value = plan.enabled ? "1" : "0";
  3431. const sortInput = el("input", { class: "input", value: String(plan.sort) });
  3432. const msg = el("div", { class: "muted" }, "");
  3433. openModal(
  3434. `编辑方案 #${plan.id}`,
  3435. [
  3436. el("label", { class: "label" }, "名称"),
  3437. nameInput,
  3438. el("label", { class: "label" }, "时长(天)"),
  3439. daysInput,
  3440. el("label", { class: "label" }, "价格(分)"),
  3441. priceInput,
  3442. el("label", { class: "label" }, "启用"),
  3443. enabledSelect,
  3444. el("label", { class: "label" }, "排序"),
  3445. sortInput,
  3446. msg,
  3447. ],
  3448. [
  3449. el("button", { class: "btn", onclick: closeModal }, "取消"),
  3450. el(
  3451. "button",
  3452. {
  3453. class: "btn btn-primary",
  3454. onclick: async () => {
  3455. msg.textContent = "";
  3456. try {
  3457. await apiFetch(`/admin/plans/${plan.id}`, {
  3458. method: "PUT",
  3459. body: {
  3460. name: nameInput.value.trim(),
  3461. durationDays: Number(daysInput.value),
  3462. priceCents: Number(priceInput.value),
  3463. enabled: enabledSelect.value === "1",
  3464. sort: Number(sortInput.value),
  3465. },
  3466. });
  3467. closeModal();
  3468. await loadPlans();
  3469. } catch (e) {
  3470. msg.textContent = `保存失败:${e.detail?.error || e.status || "unknown"}`;
  3471. if (e.status === 401) window.location.href = "/ui/admin/login";
  3472. }
  3473. },
  3474. },
  3475. "保存"
  3476. ),
  3477. ],
  3478. "ri-edit-circle-line"
  3479. );
  3480. }
  3481. if (action === "del-res") {
  3482. try {
  3483. await apiFetch(`/admin/resources/${id}`, { method: "DELETE" });
  3484. await loadResources();
  3485. } catch (e) {
  3486. if (e.status === 401) window.location.href = "/ui/admin/login";
  3487. }
  3488. }
  3489. if (action === "edit-res") {
  3490. const res = resourceMap.get(String(id));
  3491. if (!res) return;
  3492. openResourceEditorModal({ mode: "edit", res });
  3493. }
  3494. if (action === "toggle-user") {
  3495. const next = btn.getAttribute("data-next");
  3496. try {
  3497. await apiFetch(`/admin/users/${id}`, { method: "PUT", body: { status: next } });
  3498. await loadUsers();
  3499. } catch (e) {
  3500. if (e.status === 401) window.location.href = "/ui/admin/login";
  3501. }
  3502. }
  3503. if (action === "vip-user") {
  3504. const u = userMap.get(String(id));
  3505. if (!u) return;
  3506. openVipAdjustModal(u);
  3507. }
  3508. if (action === "reset-user-pass") {
  3509. const u = userMap.get(String(id));
  3510. if (!u) return;
  3511. openResetUserPasswordModal(u);
  3512. }
  3513. if (action === "user-actions") {
  3514. const u = userMap.get(String(id));
  3515. if (!u) return;
  3516. const nextStatus = u.status === "ACTIVE" ? "DISABLED" : "ACTIVE";
  3517. openModal(
  3518. `用户操作 #${u.id}`,
  3519. [el("div", { class: "muted" }, `手机号:${u.phone}`)],
  3520. [
  3521. el("button", { class: "btn", onclick: closeModal }, "关闭"),
  3522. el(
  3523. "button",
  3524. {
  3525. class: "btn",
  3526. onclick: async () => {
  3527. try {
  3528. await apiFetch(`/admin/users/${u.id}`, { method: "PUT", body: { status: nextStatus } });
  3529. closeModal();
  3530. await loadUsers();
  3531. } catch (e) {
  3532. if (e.status === 401) window.location.href = "/ui/admin/login";
  3533. }
  3534. },
  3535. },
  3536. nextStatus === "DISABLED" ? "禁用" : "启用"
  3537. ),
  3538. el(
  3539. "button",
  3540. {
  3541. class: "btn",
  3542. onclick: () => {
  3543. openResetUserPasswordModal(u);
  3544. },
  3545. },
  3546. "重置密码"
  3547. ),
  3548. el(
  3549. "button",
  3550. {
  3551. class: "btn",
  3552. onclick: () => {
  3553. openVipAdjustModal(u);
  3554. },
  3555. },
  3556. "调整会员"
  3557. ),
  3558. ],
  3559. "ri-settings-3-line"
  3560. );
  3561. }
  3562. if (action === "view-order") {
  3563. const box = el("div", {});
  3564. const msg = el("div", { class: "muted" }, "加载中…");
  3565. box.appendChild(msg);
  3566. openModal("订单详情", [box], [el("button", { class: "btn", onclick: closeModal }, "关闭")], "ri-file-list-3-line");
  3567. try {
  3568. const o = await apiFetch(`/admin/orders/${id}`);
  3569. box.innerHTML = "";
  3570. box.appendChild(el("div", { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" }, el("div", { class: "muted" }, "订单号"), el("div", {}, String(o.id))));
  3571. box.appendChild(
  3572. el(
  3573. "div",
  3574. { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" },
  3575. el("div", { class: "muted" }, "状态"),
  3576. el("div", {}, orderStatusBadge(o.status))
  3577. )
  3578. );
  3579. box.appendChild(el("div", { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" }, el("div", { class: "muted" }, "金额"), el("div", {}, formatCents(o.amountCents))));
  3580. 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}`)));
  3581. box.appendChild(
  3582. el(
  3583. "div",
  3584. { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" },
  3585. el("div", { class: "muted" }, "方案"),
  3586. el("div", {}, `${o.planSnapshot?.name || "-"}(${o.planSnapshot?.durationDays || "-"}天 / ${formatCents(o.planSnapshot?.priceCents || 0)})`)
  3587. )
  3588. );
  3589. box.appendChild(el("div", { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" }, el("div", { class: "muted" }, "创建时间"), el("div", {}, formatDateTime(o.createdAt))));
  3590. box.appendChild(el("div", { class: "card", style: "padding:14px; border-radius: 10px;" }, el("div", { class: "muted" }, "支付时间"), el("div", {}, formatDateTime(o.paidAt))));
  3591. } catch (e) {
  3592. msg.textContent = `加载失败:${e.detail?.error || e.status || "unknown"}`;
  3593. if (e.status === 401) window.location.href = "/ui/admin/login";
  3594. }
  3595. }
  3596. if (action === "view-download-log") {
  3597. const it = downloadLogMap.get(String(id));
  3598. if (!it) return;
  3599. const userText = `${it.userId} / ${it.userPhone || "-"}`;
  3600. const resText = `${it.resourceId || "-"} / ${it.resourceTitle || "-"}`;
  3601. const stateText = it.resourceState === "DELETED" ? "资源已删除" : it.resourceState === "OFFLINE" ? "资源已下架" : "资源在线";
  3602. const typeText = it.resourceType === "VIP" ? "VIP" : "免费";
  3603. const currentTypeText = it.currentResourceType === "VIP" ? "VIP" : it.currentResourceType === "FREE" ? "免费" : "-";
  3604. const driftText = it.currentResourceType && it.currentResourceType !== it.resourceType ? "(类型已变更)" : "";
  3605. openModal(
  3606. "下载记录详情",
  3607. [
  3608. el(
  3609. "div",
  3610. { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" },
  3611. el("div", { class: "muted" }, "下载时间"),
  3612. el("div", {}, formatDateTime(it.downloadedAt))
  3613. ),
  3614. el("div", { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" }, el("div", { class: "muted" }, "用户"), el("div", {}, userText)),
  3615. el(
  3616. "div",
  3617. { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" },
  3618. el("div", { class: "muted" }, "资源"),
  3619. el("div", {}, resText)
  3620. ),
  3621. el(
  3622. "div",
  3623. { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" },
  3624. el("div", { class: "muted" }, "类型"),
  3625. el("div", {}, `下载时:${typeText} / 当前:${currentTypeText}${driftText}`)
  3626. ),
  3627. el("div", { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" }, el("div", { class: "muted" }, "资源状态"), el("div", {}, stateText)),
  3628. el("div", { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" }, el("div", { class: "muted" }, "Ref"), el("div", {}, String(it.ref || "-"))),
  3629. el("div", { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" }, el("div", { class: "muted" }, "IP"), el("div", {}, String(it.ip || "-"))),
  3630. el(
  3631. "div",
  3632. { class: "card", style: "padding:14px; border-radius: 10px;" },
  3633. el("div", { class: "muted" }, "User-Agent"),
  3634. el("div", {}, String(it.userAgent || "-"))
  3635. ),
  3636. ],
  3637. [el("button", { class: "btn", onclick: closeModal }, "关闭")],
  3638. "ri-download-cloud-line"
  3639. );
  3640. }
  3641. if (action === "view-message") {
  3642. const m = messageMap.get(String(id));
  3643. if (!m) return;
  3644. const header = el(
  3645. "div",
  3646. { class: "muted" },
  3647. `用户:${m.userId} / ${m.userPhone || "-"} · 来源:${m.senderType === "ADMIN" ? "管理员" : "系统"} · 发送:${formatDateTime(m.createdAt)} · 已读:${m.read ? formatDateTime(m.readAt) : "未读"}`
  3648. );
  3649. const titleEl = el("div", { style: "font-weight: 650; margin-top: 6px;" }, String(m.title || ""));
  3650. const contentEl = el("pre", { class: "code", style: "white-space: pre-wrap;" }, formatMessageText(m.content || ""));
  3651. openModal(
  3652. `消息 #${m.id}`,
  3653. [header, titleEl, contentEl],
  3654. [
  3655. el("button", { class: "btn", onclick: () => copyText(m.content || "") }, "复制内容"),
  3656. el("button", { class: "btn", onclick: closeModal }, "关闭"),
  3657. ],
  3658. "ri-mail-open-line",
  3659. { resizable: true, size: "lg" }
  3660. );
  3661. }
  3662. if (action === "del-message") {
  3663. const m = messageMap.get(String(id));
  3664. if (!m) return;
  3665. const r = await Swal.fire({
  3666. title: "删除消息?",
  3667. text: `将删除消息 #${m.id}(用户:${m.userPhone || m.userId})`,
  3668. icon: "warning",
  3669. showCancelButton: true,
  3670. confirmButtonText: "删除",
  3671. cancelButtonText: "取消",
  3672. confirmButtonColor: "var(--danger)",
  3673. });
  3674. if (!r.isConfirmed) return;
  3675. try {
  3676. await apiFetch(`/admin/messages/${m.id}`, { method: "DELETE" });
  3677. showToastSuccess("已删除");
  3678. await loadAdminMessages();
  3679. } catch (e) {
  3680. showToastError(e.detail?.error || e.status || "删除失败");
  3681. if (e.status === 401) window.location.href = "/ui/admin/login";
  3682. }
  3683. }
  3684. if (action === "del-order") {
  3685. Swal.fire({
  3686. title: "删除订单?",
  3687. text: `订单号:${id}`,
  3688. icon: "warning",
  3689. showCancelButton: true,
  3690. confirmButtonText: "删除",
  3691. cancelButtonText: "取消",
  3692. confirmButtonColor: "var(--danger)",
  3693. }).then(async (r) => {
  3694. if (!r.isConfirmed) return;
  3695. try {
  3696. await apiFetch(`/admin/orders/${id}`, { method: "DELETE" });
  3697. await loadOrders();
  3698. } catch (e) {
  3699. if (e.status === 401) window.location.href = "/ui/admin/login";
  3700. Swal.fire({ icon: "error", title: "删除失败", text: e.detail?.error || e.status || "未知错误" });
  3701. }
  3702. });
  3703. }
  3704. });
  3705. function openResourceEditorModal({ mode, res }) {
  3706. const isEdit = mode === "edit";
  3707. const field = (labelText, inputEl) => el("div", {}, el("div", { class: "label" }, labelText), inputEl);
  3708. const msg = el("div", { class: "form-msg muted" }, "");
  3709. const titleInput = el("input", { class: "input", placeholder: "标题", value: isEdit ? res.title : "" });
  3710. const keywordsInput = el("input", { class: "input", placeholder: "关键字(逗号分隔,可选)", value: isEdit && Array.isArray(res.tags) ? res.tags.join(",") : "" });
  3711. function makeSegmented(items, initialValue, { disabled, onChange } = {}) {
  3712. let value = String(initialValue || items[0]?.value || "");
  3713. const wrap = el("div", { class: "segmented", role: "group" });
  3714. function apply(v) {
  3715. value = String(v);
  3716. Array.from(wrap.querySelectorAll("button")).forEach((b) => b.classList.toggle("active", b.getAttribute("data-value") === value));
  3717. if (typeof onChange === "function") onChange(value);
  3718. }
  3719. items.forEach((it) => {
  3720. const b = el("button", { type: "button", class: "btn btn-sm", "data-value": String(it.value) }, String(it.label));
  3721. if (disabled) b.disabled = true;
  3722. b.addEventListener("click", (evt) => {
  3723. evt.preventDefault();
  3724. if (disabled) return;
  3725. apply(it.value);
  3726. });
  3727. wrap.appendChild(b);
  3728. });
  3729. apply(value);
  3730. return {
  3731. root: wrap,
  3732. getValue: () => value,
  3733. setValue: (v) => apply(v),
  3734. setInvalid: (bad) => wrap.classList.toggle("is-invalid", Boolean(bad)),
  3735. };
  3736. }
  3737. const typeHelp = el("div", { class: "help" }, "");
  3738. function refreshTypeHelp(v) {
  3739. const val = String(v || "");
  3740. typeHelp.textContent = val === "VIP" ? "VIP:仅会员可访问。" : "FREE:所有用户可访问。";
  3741. }
  3742. const typeSeg = makeSegmented(
  3743. [
  3744. { value: "FREE", label: "FREE" },
  3745. { value: "VIP", label: "VIP" },
  3746. ],
  3747. isEdit ? res.type : "FREE",
  3748. { onChange: refreshTypeHelp }
  3749. );
  3750. refreshTypeHelp(typeSeg.getValue());
  3751. const statusHelp = el("div", { class: "help" }, "");
  3752. function refreshStatusHelp(v) {
  3753. const val = String(v || "");
  3754. statusHelp.textContent = val === "ONLINE" ? "上线:前台可见。" : val === "OFFLINE" ? "下线:前台不可见。" : "草稿:用于编辑中,前台不可见。";
  3755. }
  3756. const statusSeg = makeSegmented(
  3757. [
  3758. { value: "ONLINE", label: "上线" },
  3759. { value: "OFFLINE", label: "下线" },
  3760. { value: "DRAFT", label: "草稿" },
  3761. ],
  3762. isEdit ? res.status : "ONLINE",
  3763. { onChange: refreshStatusHelp }
  3764. );
  3765. statusSeg.root.classList.add("nowrap");
  3766. refreshStatusHelp(statusSeg.getValue());
  3767. const defaultCoverUrl = "/static/images/resources/default.png";
  3768. const tempCoverUploads = new Set();
  3769. function extractUploadNameFromUrl(value) {
  3770. const m = String(value || "").match(/\/static\/uploads\/([0-9a-f]{32}(?:\.[a-z0-9]+)?)$/i);
  3771. return m ? String(m[1] || "") : "";
  3772. }
  3773. async function deleteUploadByName(name) {
  3774. const n = String(name || "").trim();
  3775. if (!n) return;
  3776. try {
  3777. await apiFetch(`/admin/uploads/${encodeURIComponent(n)}`, { method: "DELETE" });
  3778. } catch (e) {}
  3779. }
  3780. async function cleanupTempCoverUploads(keepUrl) {
  3781. const keepName = extractUploadNameFromUrl(keepUrl);
  3782. const tasks = [];
  3783. for (const name of Array.from(tempCoverUploads)) {
  3784. if (keepName && name.toLowerCase() === keepName.toLowerCase()) continue;
  3785. tasks.push(deleteUploadByName(name));
  3786. }
  3787. tempCoverUploads.clear();
  3788. if (tasks.length) await Promise.allSettled(tasks);
  3789. }
  3790. const coverUrlInput = el("input", { class: "input", placeholder: "封面图 URL(可选)", value: isEdit ? res.coverUrl || "" : "" });
  3791. const coverPreview = el("img", {
  3792. class: "resource-detail-cover cover-picker-img",
  3793. src: (coverUrlInput.value || "").trim() ? coverUrlInput.value : defaultCoverUrl,
  3794. alt: "cover",
  3795. role: "button",
  3796. tabindex: "0",
  3797. });
  3798. const coverFile = el("input", { type: "file", accept: "image/*", style: "display:none" });
  3799. coverPreview.addEventListener("click", () => coverFile.click());
  3800. coverPreview.addEventListener("keydown", (evt) => {
  3801. if (evt.key === "Enter" || evt.key === " ") {
  3802. evt.preventDefault();
  3803. coverFile.click();
  3804. }
  3805. });
  3806. coverFile.addEventListener("change", async () => {
  3807. const f = coverFile.files && coverFile.files[0];
  3808. if (!f) return;
  3809. msg.textContent = "上传中...";
  3810. try {
  3811. const detail = await adminUploadFileMeta(f);
  3812. const url = detail?.url || "";
  3813. if (detail?.name) tempCoverUploads.add(String(detail.name));
  3814. coverUrlInput.value = url;
  3815. coverPreview.src = url;
  3816. coverPreview.classList.remove("is-placeholder");
  3817. msg.textContent = "封面已更新";
  3818. } catch (e) {
  3819. msg.textContent = `上传失败:${e.detail?.error || e.status || "unknown"}`;
  3820. if (e.status === 401) window.location.href = "/ui/admin/login";
  3821. } finally {
  3822. coverFile.value = "";
  3823. }
  3824. });
  3825. let coverPreviewTimer = null;
  3826. function refreshCoverPreview() {
  3827. const url = coverUrlInput.value.trim();
  3828. if (!url) {
  3829. coverPreview.src = defaultCoverUrl;
  3830. coverPreview.classList.add("is-placeholder");
  3831. return;
  3832. }
  3833. coverPreview.src = url;
  3834. coverPreview.classList.remove("is-placeholder");
  3835. }
  3836. coverUrlInput.addEventListener("input", () => {
  3837. if (coverPreviewTimer) clearTimeout(coverPreviewTimer);
  3838. coverPreviewTimer = setTimeout(refreshCoverPreview, 250);
  3839. });
  3840. coverUrlInput.addEventListener("blur", refreshCoverPreview);
  3841. refreshCoverPreview();
  3842. function normalizeKeywordsValue(raw) {
  3843. const text = (raw || "").toString();
  3844. const parts = text
  3845. .split(/[,,\n\r\t]+/g)
  3846. .map((s) => s.trim())
  3847. .filter(Boolean);
  3848. const uniq = [];
  3849. const seen = new Set();
  3850. parts.forEach((p) => {
  3851. const key = p.toLowerCase();
  3852. if (seen.has(key)) return;
  3853. seen.add(key);
  3854. uniq.push(p);
  3855. });
  3856. return uniq.join(",");
  3857. }
  3858. keywordsInput.addEventListener("blur", () => {
  3859. keywordsInput.value = normalizeKeywordsValue(keywordsInput.value);
  3860. });
  3861. const md = buildMarkdownEditor({ initialValue: isEdit ? res.summary || "" : "", msgEl: msg });
  3862. const modeSeg = makeSegmented(
  3863. [
  3864. { value: "CREATE", label: "创建仓库" },
  3865. { value: "BIND", label: "绑定仓库" },
  3866. ],
  3867. isEdit ? "BIND" : "CREATE",
  3868. { disabled: isEdit }
  3869. );
  3870. const modeHelp = el("div", { class: "help" }, isEdit ? "编辑模式下仓库模式固定为绑定仓库。" : "创建仓库会初始化 README.md;绑定仓库可选择分支/标签。");
  3871. const createOwnerInput = el("input", { class: "input", placeholder: "仓库 Owner(可选,留空则创建到 Token 用户)" });
  3872. const createRepoInput = el("input", { class: "input", placeholder: "仓库名称(可选,留空则自动生成)" });
  3873. const createPrivateSeg = makeSegmented(
  3874. [
  3875. { value: "0", label: "公开" },
  3876. { value: "1", label: "私有" },
  3877. ],
  3878. "0"
  3879. );
  3880. const repoFullInput = el("input", { class: "input", placeholder: "仓库(owner/repo 或 URL/SSH 地址)" });
  3881. const refInput = el("input", { class: "input", placeholder: "默认引用(AUTO/分支/标签)", value: "AUTO" });
  3882. const refPickSelect = el("select", { class: "input" }, el("option", { value: "" }, "选择分支/标签(可选)"));
  3883. refPickSelect.addEventListener("change", () => {
  3884. if (refPickSelect.value) refInput.value = refPickSelect.value;
  3885. });
  3886. const pickRepoBtn = el("button", { class: "btn" }, "选择仓库");
  3887. const refreshRefBtn = el("button", { class: "btn btn-ghost" }, "刷新分支/标签");
  3888. const repoHint = el("div", { class: "help" }, "");
  3889. async function loadRepoAndRefs(prefer) {
  3890. const parsed = parseRepoInput(repoFullInput.value);
  3891. if (!parsed) {
  3892. repoHint.textContent = "请填写正确的仓库格式:owner/repo(或直接粘贴仓库地址)";
  3893. return;
  3894. }
  3895. try {
  3896. const info = await apiFetch(`/admin/gogs/repo?owner=${encodeURIComponent(parsed.owner)}&repo=${encodeURIComponent(parsed.repo)}`);
  3897. const wanted = (prefer || refInput.value || "AUTO").toString().trim() || "AUTO";
  3898. await fillRefSelect(parsed.owner, parsed.repo, refPickSelect, wanted);
  3899. if (!refInput.value.trim()) refInput.value = wanted;
  3900. repoHint.textContent = `仓库已识别:${info.fullName || `${parsed.owner}/${parsed.repo}`};默认分支:${(info.defaultBranch || "master").trim()}`;
  3901. } catch (e) {
  3902. const errCode = e.detail?.error || e.status || "unknown";
  3903. const upstream = e.detail?.status ? `(Gogs: ${e.detail.status})` : "";
  3904. repoHint.textContent = `仓库加载失败:${errCode}${upstream}`;
  3905. showToastError(`仓库加载失败:${errCode}`);
  3906. if (e.status === 401) window.location.href = "/ui/admin/login";
  3907. }
  3908. }
  3909. pickRepoBtn.addEventListener("click", () => {
  3910. const parsed = parseRepoInput(repoFullInput.value);
  3911. const initialOwner = parsed ? parsed.owner : "";
  3912. openRepoPicker(initialOwner, async ({ owner, name, fullName }) => {
  3913. if (fullName) repoFullInput.value = fullName;
  3914. else if (owner && name) repoFullInput.value = `${owner}/${name}`;
  3915. await loadRepoAndRefs("AUTO");
  3916. });
  3917. });
  3918. refreshRefBtn.addEventListener("click", async () => {
  3919. await loadRepoAndRefs(refInput.value.trim() || "AUTO");
  3920. });
  3921. repoFullInput.addEventListener("blur", async () => {
  3922. if (!repoFullInput.value.trim()) return;
  3923. await loadRepoAndRefs(refInput.value.trim() || "AUTO");
  3924. });
  3925. const createOwnerWrap = field("仓库 Owner(可选)", createOwnerInput);
  3926. const createRepoWrap = field("仓库名称(可选)", createRepoInput);
  3927. const createPrivateWrap = el("div", {}, el("div", { class: "label" }, "公开/私有"), createPrivateSeg.root, el("div", { class: "help" }, "创建仓库时生效。"));
  3928. const repoFullWrap = el("div", { style: "grid-column: 1 / -1" }, el("div", { class: "label" }, "仓库(owner/repo 或 URL/SSH)"), repoFullInput);
  3929. const refWrap = field("默认引用", refInput);
  3930. const refPickWrap = field("选择分支/标签", refPickSelect);
  3931. const repoActionsWrap = el(
  3932. "div",
  3933. { style: "grid-column: 1 / -1" },
  3934. el("div", { class: "toolbar", style: "margin:0" }, pickRepoBtn, refreshRefBtn)
  3935. );
  3936. function refreshMode() {
  3937. const isCreate = modeSeg.getValue() === "CREATE";
  3938. [createOwnerWrap, createRepoWrap, createPrivateWrap].forEach((n) => (n.style.display = isCreate ? "" : "none"));
  3939. [repoFullWrap, refWrap, refPickWrap, repoActionsWrap].forEach((n) => (n.style.display = isCreate ? "none" : ""));
  3940. repoHint.style.display = isCreate ? "none" : "";
  3941. }
  3942. modeSeg.root.addEventListener("click", refreshMode);
  3943. if (isEdit) {
  3944. repoFullInput.value = `${res.repoOwner}/${res.repoName}`;
  3945. refInput.value = (res.defaultRef || "AUTO").toString().trim() || "AUTO";
  3946. refreshMode();
  3947. setTimeout(() => loadRepoAndRefs(refInput.value.trim() || "AUTO"), 0);
  3948. } else {
  3949. refreshMode();
  3950. }
  3951. function makeCollapse(title, bodyNodes, open) {
  3952. const icon = el("i", { class: "ri-arrow-down-s-line collapse-icon" });
  3953. const head = el("button", { type: "button", class: "collapse-head" }, el("span", {}, title), icon);
  3954. const body = el("div", { class: "collapse-body" }, ...bodyNodes);
  3955. const wrap = el("div", { class: "collapse", "data-open": open ? "1" : "0" }, head, body);
  3956. function setOpen(next) {
  3957. wrap.setAttribute("data-open", next ? "1" : "0");
  3958. }
  3959. head.addEventListener("click", (evt) => {
  3960. evt.preventDefault();
  3961. const cur = wrap.getAttribute("data-open") === "1";
  3962. setOpen(!cur);
  3963. });
  3964. return { root: wrap, setOpen };
  3965. }
  3966. const baseSection = makeCollapse(
  3967. "基础属性",
  3968. [
  3969. el(
  3970. "div",
  3971. { class: "form-grid" },
  3972. el("div", { style: "grid-column: 1 / -1" }, el("div", { class: "label" }, "标题"), titleInput),
  3973. el("div", {}, el("div", { class: "label" }, "类型"), typeSeg.root, typeHelp),
  3974. el("div", {}, el("div", { class: "label" }, "状态"), statusSeg.root, statusHelp),
  3975. el("div", { style: "grid-column: 1 / -1" }, el("div", { class: "label" }, "关键字(逗号分隔,可选)"), keywordsInput, el("div", { class: "help" }, "支持中英文逗号/换行分隔,失焦时自动去重规范化。"))
  3976. ),
  3977. ],
  3978. true
  3979. );
  3980. const clearCoverBtn = el("button", { type: "button", class: "btn btn-ghost" }, "清空");
  3981. clearCoverBtn.addEventListener("click", (evt) => {
  3982. evt.preventDefault();
  3983. coverUrlInput.value = "";
  3984. try {
  3985. coverUrlInput.dispatchEvent(new Event("input", { bubbles: true }));
  3986. } catch (e) {}
  3987. refreshCoverPreview();
  3988. });
  3989. const coverSection = makeCollapse(
  3990. "封面",
  3991. [
  3992. el(
  3993. "div",
  3994. { class: "form-grid" },
  3995. el("div", { style: "grid-column: 1 / -1" }, el("div", { class: "help" }, "点击图片选择并上传封面")),
  3996. el("div", { style: "grid-column: 1 / -1" }, coverPreview),
  3997. el("div", { style: "grid-column: 1 / -1" }, el("div", { class: "label" }, "封面图 URL(可选)"), coverUrlInput),
  3998. el("div", { style: "grid-column: 1 / -1" }, el("div", { class: "toolbar", style: "margin:0" }, clearCoverBtn, coverFile))
  3999. ),
  4000. ],
  4001. false
  4002. );
  4003. const repoModeSection = makeCollapse(
  4004. "仓库",
  4005. [
  4006. el(
  4007. "div",
  4008. { class: "form-grid" },
  4009. el("div", { style: "grid-column: 1 / -1" }, el("div", { class: "label" }, "仓库模式"), modeSeg.root, modeHelp),
  4010. el("div", { style: "grid-column: 1 / -1" }, repoHint),
  4011. createOwnerWrap,
  4012. createRepoWrap,
  4013. createPrivateWrap,
  4014. repoFullWrap,
  4015. refWrap,
  4016. refPickWrap,
  4017. repoActionsWrap
  4018. ),
  4019. ],
  4020. false
  4021. );
  4022. const contentSection = el("div", { class: "form-section" }, el("div", { class: "form-section-title" }, "内容编辑"), md.root);
  4023. const side = el("div", { class: "res-form-side" }, baseSection.root, coverSection.root, repoModeSection.root);
  4024. const main = el("div", { class: "res-form-main" }, contentSection, msg);
  4025. const layout = el("div", { class: "res-form-layout" }, side, main);
  4026. function clearInvalid() {
  4027. [titleInput, keywordsInput, coverUrlInput, repoFullInput, refInput].forEach((x) => x.classList.remove("is-invalid"));
  4028. typeSeg.setInvalid(false);
  4029. statusSeg.setInvalid(false);
  4030. modeSeg.setInvalid(false);
  4031. createPrivateSeg.setInvalid(false);
  4032. }
  4033. function invalid(elOrSeg, text) {
  4034. if (elOrSeg === titleInput || elOrSeg === keywordsInput || elOrSeg === typeSeg || elOrSeg === statusSeg) baseSection.setOpen(true);
  4035. if (elOrSeg === coverUrlInput) coverSection.setOpen(true);
  4036. if (elOrSeg === repoFullInput || elOrSeg === refInput || elOrSeg === refPickSelect || elOrSeg === modeSeg || elOrSeg === createPrivateSeg) repoModeSection.setOpen(true);
  4037. if (elOrSeg && typeof elOrSeg.setInvalid === "function") elOrSeg.setInvalid(true);
  4038. else if (elOrSeg && elOrSeg.classList) elOrSeg.classList.add("is-invalid");
  4039. msg.textContent = String(text || "");
  4040. showToastError(text || "请检查填写内容");
  4041. try {
  4042. if (elOrSeg && elOrSeg.focus) elOrSeg.focus();
  4043. } catch (e) {}
  4044. }
  4045. async function fetchReadmeText() {
  4046. const parsed = parseRepoInput(repoFullInput.value);
  4047. if (!parsed) throw Object.assign(new Error("invalid_repo"), { detail: { error: "invalid_repo" } });
  4048. const ref = refInput.value.trim() || "AUTO";
  4049. const url = `/admin/gogs/file-text?owner=${encodeURIComponent(parsed.owner)}&repo=${encodeURIComponent(parsed.repo)}&ref=${encodeURIComponent(ref)}&path=${encodeURIComponent("README.md")}`;
  4050. const resp = await apiFetch(url);
  4051. return (resp.text || "").toString();
  4052. }
  4053. async function loadReadmeIntoEditor() {
  4054. msg.textContent = "";
  4055. const cur = (md.textarea.value || "").toString();
  4056. if (cur.trim()) {
  4057. try {
  4058. const r = await Swal.fire({
  4059. title: "用 README.md 覆盖当前内容?",
  4060. text: "覆盖后当前未保存内容将丢失。",
  4061. icon: "warning",
  4062. showCancelButton: true,
  4063. confirmButtonText: "覆盖",
  4064. cancelButtonText: "取消",
  4065. confirmButtonColor: "var(--danger)",
  4066. });
  4067. if (!r.isConfirmed) return;
  4068. } catch (e) {}
  4069. }
  4070. try {
  4071. msg.textContent = "加载 README.md 中...";
  4072. const text = await fetchReadmeText();
  4073. md.setText(text);
  4074. md.syncReadme.checked = true;
  4075. msg.textContent = "已从 README.md 导入";
  4076. showToastSuccess("README.md 已导入");
  4077. } catch (e) {
  4078. const code = e.detail?.error || e.status || e.message || "unknown";
  4079. msg.textContent = `README.md 加载失败:${code}`;
  4080. showToastError(`README.md 加载失败:${code}`);
  4081. if (e.status === 401) window.location.href = "/ui/admin/login";
  4082. }
  4083. }
  4084. const loadReadmeBtn = el("button", { type: "button", class: "btn btn-sm" }, "加载 README.md");
  4085. loadReadmeBtn.addEventListener("click", async (evt) => {
  4086. evt.preventDefault();
  4087. await loadReadmeIntoEditor();
  4088. });
  4089. function refreshReadmeBtn() {
  4090. const allow = isEdit || modeSeg.getValue() === "BIND";
  4091. const hasRepo = Boolean(parseRepoInput(repoFullInput.value));
  4092. loadReadmeBtn.disabled = !(allow && hasRepo);
  4093. }
  4094. repoFullInput.addEventListener("input", refreshReadmeBtn);
  4095. modeSeg.root.addEventListener("click", refreshReadmeBtn);
  4096. refreshReadmeBtn();
  4097. const spacer = Array.from(md.toolbarEl.children).find((n) => n && n.style && n.style.flex === "1");
  4098. if (spacer) md.toolbarEl.insertBefore(loadReadmeBtn, spacer);
  4099. else md.toolbarEl.appendChild(loadReadmeBtn);
  4100. if (isEdit && !(md.textarea.value || "").trim()) {
  4101. setTimeout(() => loadReadmeIntoEditor(), 0);
  4102. }
  4103. async function submitAndMaybeView(openAfter) {
  4104. msg.textContent = "";
  4105. clearInvalid();
  4106. const title = titleInput.value.trim();
  4107. if (!title) {
  4108. invalid(titleInput, "请填写标题");
  4109. return;
  4110. }
  4111. if (isEdit) {
  4112. const parsed = parseRepoInput(repoFullInput.value);
  4113. if (!parsed) {
  4114. invalid(repoFullInput, "请填写正确的仓库格式:owner/repo(或直接粘贴仓库地址)");
  4115. return;
  4116. }
  4117. msg.textContent = "保存中,请稍候...";
  4118. try {
  4119. await apiFetch(`/admin/resources/${res.id}`, {
  4120. method: "PUT",
  4121. body: {
  4122. title,
  4123. summary: md.textarea.value.trim(),
  4124. keywords: normalizeKeywordsValue(keywordsInput.value),
  4125. coverUrl: coverUrlInput.value.trim(),
  4126. type: typeSeg.getValue(),
  4127. status: statusSeg.getValue(),
  4128. repoOwner: parsed.owner,
  4129. repoName: parsed.repo,
  4130. defaultRef: refInput.value.trim() || "AUTO",
  4131. syncReadme: md.syncReadme.checked,
  4132. },
  4133. });
  4134. await cleanupTempCoverUploads(coverUrlInput.value.trim());
  4135. await closeModal(true);
  4136. await loadResources();
  4137. if (openAfter) window.open(`/ui/resources/${res.id}`, "_blank");
  4138. showToastSuccess("已保存");
  4139. } catch (e) {
  4140. msg.textContent = `保存失败:${e.detail?.error || e.status || "unknown"}`;
  4141. showToastError(e.detail?.error || e.status || "保存失败");
  4142. if (e.status === 401) window.location.href = "/ui/admin/login";
  4143. }
  4144. return;
  4145. }
  4146. msg.textContent = "创建中,请稍候...";
  4147. const body = {
  4148. title,
  4149. summary: md.textarea.value.trim(),
  4150. keywords: normalizeKeywordsValue(keywordsInput.value),
  4151. coverUrl: coverUrlInput.value.trim(),
  4152. type: typeSeg.getValue(),
  4153. status: statusSeg.getValue(),
  4154. syncReadme: md.syncReadme.checked,
  4155. };
  4156. if (modeSeg.getValue() === "CREATE") {
  4157. body.createRepo = true;
  4158. body.repoOwner = createOwnerInput.value.trim();
  4159. body.repoName = createRepoInput.value.trim();
  4160. body.repoPrivate = createPrivateSeg.getValue() === "1";
  4161. } else {
  4162. const parsed = parseRepoInput(repoFullInput.value);
  4163. if (!parsed) {
  4164. invalid(repoFullInput, "请填写正确的仓库格式:owner/repo(或直接粘贴仓库地址)");
  4165. return;
  4166. }
  4167. body.createRepo = false;
  4168. body.repoOwner = parsed.owner;
  4169. body.repoName = parsed.repo;
  4170. body.defaultRef = refInput.value.trim() || "AUTO";
  4171. }
  4172. try {
  4173. const resp = await apiFetch("/admin/resources", { method: "POST", body });
  4174. await cleanupTempCoverUploads(coverUrlInput.value.trim());
  4175. await closeModal(true);
  4176. await loadResources();
  4177. if (openAfter) window.open(`/ui/resources/${resp.id}`, "_blank");
  4178. showToastSuccess("已创建");
  4179. } catch (e) {
  4180. const code = e.detail?.error || e.status || "unknown";
  4181. const upstream = e.detail?.status ? `(Gogs: ${e.detail.status})` : "";
  4182. const detailMsg = e.detail?.message ? `\n${e.detail.message}` : "";
  4183. const detailUrl = e.detail?.url ? `\n${e.detail.url}` : "";
  4184. msg.textContent = `${isEdit ? "保存" : "创建"}失败:${code}${upstream}${detailMsg}${detailUrl}`;
  4185. showToastError(`${isEdit ? "保存" : "创建"}失败:${code}`);
  4186. if (e.status === 401) window.location.href = "/ui/admin/login";
  4187. }
  4188. }
  4189. const primaryBtn = el("button", { class: "btn btn-primary" }, isEdit ? "保存" : "创建");
  4190. const viewBtn = el("button", { class: "btn" }, isEdit ? "保存并查看" : "创建并查看");
  4191. primaryBtn.addEventListener("click", async () => {
  4192. primaryBtn.disabled = true;
  4193. viewBtn.disabled = true;
  4194. try {
  4195. await submitAndMaybeView(false);
  4196. } finally {
  4197. primaryBtn.disabled = false;
  4198. viewBtn.disabled = false;
  4199. }
  4200. });
  4201. viewBtn.addEventListener("click", async () => {
  4202. primaryBtn.disabled = true;
  4203. viewBtn.disabled = true;
  4204. try {
  4205. await submitAndMaybeView(true);
  4206. } finally {
  4207. primaryBtn.disabled = false;
  4208. viewBtn.disabled = false;
  4209. }
  4210. });
  4211. const initialDraft = JSON.stringify({
  4212. title: titleInput.value,
  4213. keywords: keywordsInput.value,
  4214. type: typeSeg.getValue(),
  4215. status: statusSeg.getValue(),
  4216. coverUrl: coverUrlInput.value,
  4217. summary: md.textarea.value,
  4218. syncReadme: md.syncReadme.checked,
  4219. repoMode: modeSeg.getValue(),
  4220. createOwner: createOwnerInput.value,
  4221. createRepo: createRepoInput.value,
  4222. createPrivate: createPrivateSeg.getValue(),
  4223. repoFull: repoFullInput.value,
  4224. ref: refInput.value,
  4225. });
  4226. function isDirty() {
  4227. const now = JSON.stringify({
  4228. title: titleInput.value,
  4229. keywords: keywordsInput.value,
  4230. type: typeSeg.getValue(),
  4231. status: statusSeg.getValue(),
  4232. coverUrl: coverUrlInput.value,
  4233. summary: md.textarea.value,
  4234. syncReadme: md.syncReadme.checked,
  4235. repoMode: modeSeg.getValue(),
  4236. createOwner: createOwnerInput.value,
  4237. createRepo: createRepoInput.value,
  4238. createPrivate: createPrivateSeg.getValue(),
  4239. repoFull: repoFullInput.value,
  4240. ref: refInput.value,
  4241. });
  4242. return now !== initialDraft;
  4243. }
  4244. openModal(isEdit ? `编辑资源 #${res.id}` : "新增资源", [layout], [el("button", { class: "btn", onclick: closeModal }, "取消"), viewBtn, primaryBtn], isEdit ? "ri-edit-circle-line" : "ri-add-box-line", {
  4245. resizable: true,
  4246. size: "lg",
  4247. onResize: (size) => md.setViewByModalSize(size),
  4248. onKeydown: (evt) => {
  4249. const key = String(evt.key || "");
  4250. if ((evt.ctrlKey || evt.metaKey) && key.toLowerCase() === "s") {
  4251. evt.preventDefault();
  4252. primaryBtn.click();
  4253. return;
  4254. }
  4255. if ((evt.ctrlKey || evt.metaKey) && key === "Enter") {
  4256. evt.preventDefault();
  4257. primaryBtn.click();
  4258. return;
  4259. }
  4260. if (key === "Escape") {
  4261. evt.preventDefault();
  4262. closeModal();
  4263. }
  4264. },
  4265. beforeClose: async () => {
  4266. if (!isDirty()) {
  4267. await cleanupTempCoverUploads("");
  4268. return true;
  4269. }
  4270. try {
  4271. const r = await Swal.fire({
  4272. title: "放弃未保存的修改?",
  4273. text: "当前内容尚未保存,关闭后将丢失。",
  4274. icon: "warning",
  4275. showCancelButton: true,
  4276. confirmButtonText: "放弃修改",
  4277. cancelButtonText: "继续编辑",
  4278. confirmButtonColor: "var(--danger)",
  4279. });
  4280. if (!r.isConfirmed) return false;
  4281. await cleanupTempCoverUploads("");
  4282. return true;
  4283. } catch (e) {
  4284. await cleanupTempCoverUploads("");
  4285. return true;
  4286. }
  4287. },
  4288. });
  4289. }
  4290. createResOpenBtn.addEventListener("click", () => {
  4291. openResourceEditorModal({ mode: "create" });
  4292. });
  4293. resSearchBtn.addEventListener("click", async () => {
  4294. resState.page = 1;
  4295. await loadResources();
  4296. });
  4297. resPrevPage.addEventListener("click", async () => {
  4298. resState.page = Math.max(1, resState.page - 1);
  4299. await loadResources();
  4300. });
  4301. resNextPage.addEventListener("click", async () => {
  4302. resState.page = resState.page + 1;
  4303. await loadResources();
  4304. });
  4305. userSearchBtn.addEventListener("click", async () => {
  4306. userState.page = 1;
  4307. await loadUsers();
  4308. });
  4309. if (dlSearchBtn) {
  4310. dlSearchBtn.addEventListener("click", async () => {
  4311. downloadLogState.page = 1;
  4312. await loadDownloadLogs();
  4313. });
  4314. }
  4315. if (dlQ) {
  4316. dlQ.addEventListener("keydown", async (evt) => {
  4317. if (evt.key !== "Enter") return;
  4318. evt.preventDefault();
  4319. downloadLogState.page = 1;
  4320. await loadDownloadLogs();
  4321. });
  4322. }
  4323. if (dlPrevPage) {
  4324. dlPrevPage.addEventListener("click", async () => {
  4325. downloadLogState.page = Math.max(1, downloadLogState.page - 1);
  4326. await loadDownloadLogs();
  4327. });
  4328. }
  4329. if (dlNextPage) {
  4330. dlNextPage.addEventListener("click", async () => {
  4331. downloadLogState.page += 1;
  4332. await loadDownloadLogs();
  4333. });
  4334. }
  4335. if (msgSearchBtn) {
  4336. msgSearchBtn.addEventListener("click", async () => {
  4337. messageState.page = 1;
  4338. await loadAdminMessages();
  4339. });
  4340. }
  4341. if (msgQ) {
  4342. msgQ.addEventListener("keydown", async (evt) => {
  4343. if (evt.key !== "Enter") return;
  4344. evt.preventDefault();
  4345. messageState.page = 1;
  4346. await loadAdminMessages();
  4347. });
  4348. }
  4349. if (msgPrevPage) {
  4350. msgPrevPage.addEventListener("click", async () => {
  4351. messageState.page = Math.max(1, messageState.page - 1);
  4352. await loadAdminMessages();
  4353. });
  4354. }
  4355. if (msgNextPage) {
  4356. msgNextPage.addEventListener("click", async () => {
  4357. messageState.page += 1;
  4358. await loadAdminMessages();
  4359. });
  4360. }
  4361. if (msgSendBtn) {
  4362. msgSendBtn.addEventListener("click", async () => {
  4363. const phoneInput = el("input", { class: "input", placeholder: "用户手机号(已注册)" });
  4364. const userIdInput = el("input", { class: "input", placeholder: "用户ID(可选,优先于手机号)" });
  4365. const titleInput = el("input", { class: "input", placeholder: "标题(必填)" });
  4366. const contentInput = el("textarea", { class: "input", style: "min-height: 180px; resize: vertical;", placeholder: "内容(必填)" });
  4367. const msg = el("div", { class: "muted" }, "");
  4368. openModal(
  4369. "发送消息",
  4370. [
  4371. el("div", { class: "muted" }, "发送给单个用户。填写用户ID或手机号即可。"),
  4372. el("label", { class: "label" }, "用户ID"),
  4373. userIdInput,
  4374. el("label", { class: "label" }, "手机号"),
  4375. phoneInput,
  4376. el("label", { class: "label" }, "标题"),
  4377. titleInput,
  4378. el("label", { class: "label" }, "内容"),
  4379. contentInput,
  4380. msg,
  4381. ],
  4382. [
  4383. el("button", { class: "btn", onclick: closeModal }, "取消"),
  4384. el(
  4385. "button",
  4386. {
  4387. class: "btn btn-primary",
  4388. onclick: async () => {
  4389. msg.textContent = "";
  4390. try {
  4391. const userId = Number((userIdInput.value || "").trim() || 0) || 0;
  4392. await apiFetch("/admin/messages/send", {
  4393. method: "POST",
  4394. body: { userId, phone: phoneInput.value.trim(), title: titleInput.value.trim(), content: contentInput.value },
  4395. });
  4396. closeModal();
  4397. showToastSuccess("发送成功");
  4398. await loadAdminMessages();
  4399. } catch (e) {
  4400. msg.textContent = `发送失败:${e.detail?.error || e.status || "unknown"}`;
  4401. if (e.status === 401) window.location.href = "/ui/admin/login";
  4402. }
  4403. },
  4404. },
  4405. "发送"
  4406. ),
  4407. ],
  4408. "ri-notification-3-line"
  4409. );
  4410. });
  4411. }
  4412. if (msgBroadcastBtn) {
  4413. msgBroadcastBtn.addEventListener("click", async () => {
  4414. const audienceSelect = el(
  4415. "select",
  4416. { class: "input" },
  4417. el("option", { value: "ALL" }, "全部用户"),
  4418. el("option", { value: "VIP" }, "仅 VIP 用户"),
  4419. el("option", { value: "NONVIP" }, "仅非 VIP 用户")
  4420. );
  4421. const titleInput = el("input", { class: "input", placeholder: "标题(必填)" });
  4422. const contentInput = el("textarea", { class: "input", style: "min-height: 200px; resize: vertical;", placeholder: "内容(必填)" });
  4423. const msg = el("div", { class: "muted" }, "");
  4424. openModal(
  4425. "群发消息",
  4426. [
  4427. el("div", { class: "muted" }, "将为符合条件的每个用户生成一条站内消息。"),
  4428. el("label", { class: "label" }, "发送范围"),
  4429. audienceSelect,
  4430. el("label", { class: "label" }, "标题"),
  4431. titleInput,
  4432. el("label", { class: "label" }, "内容"),
  4433. contentInput,
  4434. msg,
  4435. ],
  4436. [
  4437. el("button", { class: "btn", onclick: closeModal }, "取消"),
  4438. el(
  4439. "button",
  4440. {
  4441. class: "btn btn-primary",
  4442. onclick: async () => {
  4443. msg.textContent = "";
  4444. try {
  4445. const r = await Swal.fire({
  4446. title: "确认群发?",
  4447. text: "将立即发送站内消息给符合条件的用户。",
  4448. icon: "warning",
  4449. showCancelButton: true,
  4450. confirmButtonText: "确认发送",
  4451. cancelButtonText: "取消",
  4452. confirmButtonColor: "var(--brand)",
  4453. });
  4454. if (!r.isConfirmed) return;
  4455. const resp = await apiFetch("/admin/messages/broadcast", {
  4456. method: "POST",
  4457. body: { audience: audienceSelect.value, title: titleInput.value.trim(), content: contentInput.value },
  4458. });
  4459. closeModal();
  4460. showToastSuccess(`已发送 ${resp.count || 0} 条`);
  4461. await loadAdminMessages();
  4462. } catch (e) {
  4463. msg.textContent = `发送失败:${e.detail?.error || e.status || "unknown"}`;
  4464. if (e.status === 401) window.location.href = "/ui/admin/login";
  4465. }
  4466. },
  4467. },
  4468. "发送"
  4469. ),
  4470. ],
  4471. "ri-megaphone-line"
  4472. );
  4473. });
  4474. }
  4475. userPrevPage.addEventListener("click", async () => {
  4476. userState.page = Math.max(1, userState.page - 1);
  4477. await loadUsers();
  4478. });
  4479. userNextPage.addEventListener("click", async () => {
  4480. userState.page = userState.page + 1;
  4481. await loadUsers();
  4482. });
  4483. orderRefreshBtn.addEventListener("click", async () => {
  4484. orderState.page = 1;
  4485. await loadOrders();
  4486. });
  4487. orderCreateBtn.addEventListener("click", async () => {
  4488. const phoneInput = el("input", { class: "input", placeholder: "用户手机号(必须已注册)" });
  4489. const planSelect = el("select", { class: "input" }, el("option", { value: "" }, "加载中..."));
  4490. const statusSelect = el(
  4491. "select",
  4492. { class: "input" },
  4493. el("option", { value: "PENDING" }, "待支付"),
  4494. el("option", { value: "PAID" }, "已支付"),
  4495. el("option", { value: "CLOSED" }, "已关闭"),
  4496. el("option", { value: "FAILED" }, "失败")
  4497. );
  4498. const msg = el("div", { class: "muted" }, "");
  4499. openModal(
  4500. "新建订单",
  4501. [el("label", { class: "label" }, "用户手机号"), phoneInput, el("label", { class: "label" }, "方案"), planSelect, el("label", { class: "label" }, "状态"), statusSelect, el("div", { class: "muted" }, "设置为“已支付”会自动延长该用户会员。"), msg],
  4502. [
  4503. el("button", { class: "btn", onclick: closeModal }, "取消"),
  4504. el(
  4505. "button",
  4506. {
  4507. class: "btn btn-primary",
  4508. onclick: async () => {
  4509. msg.textContent = "";
  4510. const phone = phoneInput.value.trim();
  4511. const planId = Number(planSelect.value || "0");
  4512. if (!phone || planId <= 0) {
  4513. msg.textContent = "请填写手机号并选择方案";
  4514. return;
  4515. }
  4516. try {
  4517. await apiFetch("/admin/orders", { method: "POST", body: { userPhone: phone, planId, status: statusSelect.value } });
  4518. closeModal();
  4519. orderState.page = 1;
  4520. await loadOrders();
  4521. } catch (e) {
  4522. msg.textContent = `创建失败:${e.detail?.error || e.status || "unknown"}`;
  4523. if (e.status === 401) window.location.href = "/ui/admin/login";
  4524. }
  4525. },
  4526. },
  4527. "创建"
  4528. ),
  4529. ],
  4530. "ri-add-circle-line"
  4531. );
  4532. try {
  4533. const plans = await apiFetch("/admin/plans");
  4534. planSelect.innerHTML = "";
  4535. (plans || []).filter((p) => p && p.enabled).forEach((p) => {
  4536. planSelect.appendChild(el("option", { value: String(p.id) }, `${p.name}(${p.durationDays}天 / ${formatCents(p.priceCents)})`));
  4537. });
  4538. if (!planSelect.children.length) planSelect.appendChild(el("option", { value: "" }, "暂无可用方案"));
  4539. } catch (e) {
  4540. planSelect.innerHTML = "";
  4541. planSelect.appendChild(el("option", { value: "" }, "方案加载失败"));
  4542. if (e.status === 401) window.location.href = "/ui/admin/login";
  4543. }
  4544. });
  4545. orderStatusFilter.addEventListener("change", async () => {
  4546. orderState.page = 1;
  4547. await loadOrders();
  4548. });
  4549. orderPrevPage.addEventListener("click", async () => {
  4550. orderState.page = Math.max(1, orderState.page - 1);
  4551. await loadOrders();
  4552. });
  4553. orderNextPage.addEventListener("click", async () => {
  4554. orderState.page = orderState.page + 1;
  4555. await loadOrders();
  4556. });
  4557. /* inline creation handlers removed; now using modal-based creation */
  4558. adminLogoutBtn.addEventListener("click", async () => {
  4559. await apiFetch("/admin/auth/logout", { method: "POST" });
  4560. window.location.href = "/ui/admin/login";
  4561. });
  4562. try {
  4563. await activate("overview");
  4564. } catch (e) {
  4565. if (e.status === 401) window.location.href = "/ui/admin/login";
  4566. }
  4567. }
  4568. async function main() {
  4569. const page = document.body.getAttribute("data-page") || "";
  4570. try {
  4571. await initTopbar();
  4572. if (page === "index") await pageIndex();
  4573. if (page === "resources") await pageIndex();
  4574. if (page === "login") await pageLogin();
  4575. if (page === "register") await pageRegister();
  4576. if (page === "me") await pageMe();
  4577. if (page === "messages") await pageMessages();
  4578. if (page === "vip") await pageVip();
  4579. if (page === "resource_detail") await pageResourceDetail();
  4580. if (page === "admin_login") await pageAdminLogin();
  4581. if (page === "admin") await pageAdmin();
  4582. } catch (e) {
  4583. showToastError(e?.detail?.error || e?.status || e?.message || "页面初始化失败");
  4584. }
  4585. }
  4586. main();