| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132413341344135413641374138413941404141414241434144414541464147414841494150415141524153415441554156415741584159416041614162416341644165416641674168416941704171417241734174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198419942004201420242034204420542064207420842094210421142124213421442154216421742184219422042214222422342244225422642274228422942304231423242334234423542364237423842394240424142424243424442454246424742484249425042514252425342544255425642574258425942604261426242634264426542664267426842694270427142724273427442754276427742784279428042814282428342844285428642874288428942904291429242934294429542964297429842994300430143024303430443054306430743084309431043114312431343144315431643174318431943204321432243234324432543264327432843294330433143324333433443354336433743384339434043414342434343444345434643474348434943504351435243534354435543564357435843594360436143624363436443654366436743684369437043714372437343744375437643774378437943804381438243834384438543864387438843894390439143924393439443954396439743984399440044014402440344044405440644074408440944104411441244134414441544164417441844194420442144224423442444254426442744284429443044314432443344344435443644374438443944404441444244434444444544464447444844494450445144524453445444554456445744584459446044614462446344644465446644674468446944704471447244734474447544764477447844794480448144824483448444854486448744884489449044914492449344944495449644974498449945004501450245034504450545064507450845094510451145124513451445154516451745184519452045214522452345244525452645274528452945304531453245334534453545364537453845394540454145424543454445454546454745484549455045514552455345544555455645574558455945604561456245634564456545664567456845694570457145724573457445754576457745784579458045814582458345844585458645874588458945904591459245934594459545964597459845994600460146024603460446054606460746084609461046114612461346144615461646174618461946204621462246234624462546264627462846294630463146324633463446354636463746384639464046414642464346444645464646474648464946504651465246534654465546564657465846594660466146624663466446654666466746684669467046714672467346744675467646774678467946804681468246834684468546864687468846894690469146924693469446954696469746984699470047014702470347044705470647074708470947104711471247134714471547164717471847194720472147224723472447254726472747284729473047314732473347344735473647374738473947404741474247434744474547464747474847494750475147524753475447554756475747584759476047614762476347644765476647674768476947704771477247734774477547764777477847794780478147824783478447854786478747884789479047914792479347944795479647974798479948004801480248034804480548064807480848094810481148124813481448154816481748184819482048214822482348244825482648274828482948304831483248334834483548364837483848394840484148424843 |
- function getCookie(name) {
- const raw = document.cookie || "";
- const parts = raw.split(";");
- for (const p of parts) {
- const s = p.trim();
- if (!s) continue;
- const idx = s.indexOf("=");
- if (idx <= 0) continue;
- const k = s.slice(0, idx).trim();
- if (k !== name) continue;
- return decodeURIComponent(s.slice(idx + 1));
- }
- return "";
- }
- async function apiFetch(url, { method = "GET", body } = {}) {
- const init = { method, headers: {} };
- if (!["GET", "HEAD", "OPTIONS", "TRACE"].includes(String(method || "GET").toUpperCase())) {
- const csrf = getCookie("csrf_token");
- if (csrf) init.headers["X-CSRF-Token"] = csrf;
- }
- if (body !== undefined) {
- init.headers["Content-Type"] = "application/json";
- init.body = JSON.stringify(body);
- }
- const resp = await fetch(url, init);
- const contentType = resp.headers.get("content-type") || "";
- const isJson = contentType.includes("application/json");
- if (!resp.ok) {
- let detail = null;
- if (isJson) {
- try {
- detail = await resp.json();
- } catch (e) {
- detail = null;
- }
- }
- const err = new Error("request_failed");
- err.status = resp.status;
- err.detail = detail;
- throw err;
- }
- if (isJson) return await resp.json();
- return resp;
- }
- const Toast = Swal.mixin({
- toast: true,
- position: 'top-end',
- showConfirmButton: false,
- timer: 3000,
- timerProgressBar: true,
- didOpen: (toast) => {
- toast.addEventListener('mouseenter', Swal.stopTimer)
- toast.addEventListener('mouseleave', Swal.resumeTimer)
- }
- });
- function showToastError(text) {
- Toast.fire({
- icon: 'error',
- title: String(text || "未知错误")
- });
- }
- function showToastSuccess(text) {
- Toast.fire({
- icon: 'success',
- title: String(text || "操作成功")
- });
- }
- function currentNextParam() {
- return encodeURIComponent(window.location.pathname + window.location.search);
- }
- function nextFromQuery() {
- const p = new URLSearchParams(window.location.search || "");
- const next = (p.get("next") || "").trim();
- if (!next) return "";
- if (!next.startsWith("/")) return "";
- if (next.startsWith("//")) return "";
- return next;
- }
- async function initTopbar() {
- const navAuth = document.getElementById("navAuth");
- const navLogin = document.getElementById("navLogin");
- const navRegister = document.getElementById("navRegister");
- if (navLogin) navLogin.setAttribute("href", `/ui/login?next=${currentNextParam()}`);
- if (navRegister) navRegister.setAttribute("href", `/ui/register?next=${currentNextParam()}`);
- if (!navAuth) return;
- let msgBadge = null;
- async function refreshMessageBadge() {
- if (!msgBadge) return;
- try {
- const data = await apiFetch("/me/messages?page=1&pageSize=1");
- const cnt = parseInt(data?.unreadCount || 0, 10) || 0;
- if (cnt > 0) {
- msgBadge.style.display = "inline-flex";
- msgBadge.textContent = cnt > 99 ? "99+" : String(cnt);
- } else {
- msgBadge.style.display = "none";
- msgBadge.textContent = "";
- }
- } catch (e) {
- msgBadge.style.display = "none";
- msgBadge.textContent = "";
- }
- }
- let me = null;
- try {
- me = await apiFetch("/me");
- } catch (e) {
- me = { user: null };
- }
- const user = me && me.user ? me.user : null;
- if (!user) return;
- navAuth.innerHTML = "";
- const userEl = el(
- "a",
- { class: "nav-user", href: "/ui/me", style: "display:flex; align-items:center; gap:4px;" },
- el("i", { class: "ri-user-smile-line" }),
- el("span", {}, String(user.phone || "我的"))
- );
- if (user.vipActive) userEl.appendChild(el("span", { class: "nav-badge nav-badge-vip" }, "VIP"));
- msgBadge = el("span", { class: "nav-msg-badge" }, "");
- const msgLink = el("a", { href: "/ui/messages", class: "btn-ghost nav-msg", title: "消息通知" }, el("i", { class: "ri-notification-3-line" }), msgBadge);
- 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"}), "退出");
- logout.addEventListener("click", async (evt) => {
- evt.preventDefault();
- try {
- await apiFetch("/auth/logout", { method: "POST" });
- } catch (e) {}
- window.location.href = "/";
- });
- navAuth.appendChild(userEl);
- navAuth.appendChild(msgLink);
- navAuth.appendChild(logout);
- window.__refreshMessageBadge = refreshMessageBadge;
- await refreshMessageBadge();
- }
- window.addEventListener("error", (evt) => {
- const msg = evt?.error?.message || evt?.message || "页面脚本错误";
- showToastError(msg);
- });
- window.addEventListener("unhandledrejection", (evt) => {
- const msg = evt?.reason?.message || String(evt?.reason || "异步错误");
- showToastError(msg);
- });
- function el(tag, attrs = {}, ...children) {
- const node = document.createElement(tag);
- Object.entries(attrs).forEach(([k, v]) => {
- if (k === "class") node.className = v;
- else if (k === "html") node.innerHTML = v;
- else if (k.startsWith("on") && typeof v === "function") node.addEventListener(k.slice(2), v);
- else node.setAttribute(k, v);
- });
- children.forEach((c) => {
- if (c === null || c === undefined) return;
- if (typeof c === "string") node.appendChild(document.createTextNode(c));
- else node.appendChild(c);
- });
- return node;
- }
- function btnGroup(...children) {
- return el("div", { class: "btn-group" }, ...children);
- }
- function renderEmptyRow(tbody, colCount, text) {
- tbody.appendChild(el("tr", {}, el("td", { colspan: String(colCount), class: "table-empty muted" }, text)));
- }
- function badge(text, variantClass = "") {
- const cls = ["badge", variantClass].filter(Boolean).join(" ");
- return el("span", { class: cls }, text);
- }
- function resourceTypeBadge(type) {
- if (type === "VIP") return badge("VIP", "badge-vip");
- return badge("免费", "badge-free");
- }
- function resourceStatusBadge(status) {
- if (status === "ONLINE") return badge("上架", "badge-success");
- if (status === "OFFLINE") return badge("下架", "badge-danger");
- return badge("草稿", "badge");
- }
- function orderStatusBadge(status) {
- if (status === "PAID") return badge("已支付", "badge-success");
- if (status === "PENDING") return badge("待支付", "badge-warning");
- if (status === "FAILED") return badge("失败", "badge-danger");
- return badge("已关闭", "badge");
- }
- function userStatusBadge(status) {
- if (status === "ACTIVE") return badge("启用", "badge-success");
- return badge("禁用", "badge-danger");
- }
- function formatCents(cents) {
- return `¥${(cents / 100).toFixed(2)}`;
- }
- function formatDateTime(value) {
- if (value === null || value === undefined) return "-";
- if (value === "-") return "-";
- if (typeof value === "number") {
- const ms = value < 1e12 ? value * 1000 : value;
- const d = new Date(ms);
- if (Number.isNaN(d.getTime())) return String(value);
- const pad2 = (n) => String(n).padStart(2, "0");
- return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
- }
- const s = String(value).trim();
- if (!s) return "-";
- if (/^\d+$/.test(s)) return formatDateTime(Number(s));
- const d = new Date(s);
- if (Number.isNaN(d.getTime())) return s;
- const pad2 = (n) => String(n).padStart(2, "0");
- return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
- }
- function formatMessageText(text) {
- const s = String(text || "");
- const isoRe = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})/g;
- return s.replace(isoRe, (m) => formatDateTime(m));
- }
- function escapeHtml(text) {
- return String(text || "")
- .replace(/&/g, "&")
- .replace(/</g, "<")
- .replace(/>/g, ">")
- .replace(/"/g, """)
- .replace(/'/g, "'");
- }
- function sanitizeMarkdownUrl(url) {
- const s = String(url || "").trim();
- if (!s) return "";
- if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(s)) {
- const scheme = s.split(":", 1)[0].toLowerCase();
- if (!["http", "https", "mailto"].includes(scheme)) return "";
- }
- return s;
- }
- function renderMarkdown(md) {
- const raw = String(md || "");
- const escaped = escapeHtml(raw);
- const blocks = [];
- const placeholder = (i) => `@@BLOCK_${i}@@`;
- const fenced = escaped.replace(/```([\s\S]*?)```/g, (_m, code) => {
- const html = `<pre class="code"><code>${code.replace(/^\n+|\n+$/g, "")}</code></pre>`;
- blocks.push(html);
- return placeholder(blocks.length - 1);
- });
- let html = fenced
- .replace(/^### (.*)$/gm, "<h3>$1</h3>")
- .replace(/^## (.*)$/gm, "<h2>$1</h2>")
- .replace(/^# (.*)$/gm, "<h1>$1</h1>")
- .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_m, alt, url) => {
- const safeUrl = sanitizeMarkdownUrl(url);
- if (!safeUrl) return `<span class="muted">[图片已拦截]</span>`;
- return `<img class="md-img" alt="${alt}" src="${escapeHtml(safeUrl)}" />`;
- })
- .replace(/@\[(video)\]\(([^)]+)\)/g, (_m, _t, url) => {
- const safeUrl = sanitizeMarkdownUrl(url);
- if (!safeUrl) return `<span class="muted">[视频已拦截]</span>`;
- return `<video class="md-video" controls src="${escapeHtml(safeUrl)}"></video>`;
- })
- .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, text, url) => {
- const safeUrl = sanitizeMarkdownUrl(url);
- if (!safeUrl) return `<span>${text}</span>`;
- return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener">${text}</a>`;
- })
- .replace(/`([^`]+)`/g, "<code>$1</code>")
- .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
- .replace(/\*([^*\n]+)\*/g, "<em>$1</em>");
- html = html.replace(/\n{2,}/g, "\n\n");
- html = html
- .split("\n\n")
- .map((p) => {
- if (p.startsWith("@@BLOCK_")) return p;
- if (/^<h[1-3]>/.test(p.trim()) || /^<pre /.test(p.trim())) return p;
- const lines = p.split("\n").join("<br>");
- return `<p>${lines}</p>`;
- })
- .join("\n");
- blocks.forEach((b, i) => {
- html = html.replaceAll(placeholder(i), b);
- });
- return html;
- }
- async function pageIndex() {
- const pageMode = document.body.getAttribute("data-page") || "";
- const isHome = pageMode === "index";
- const qInput = document.getElementById("q");
- const typeSelect = document.getElementById("type");
- const sortSelect = document.getElementById("sort");
- const list = document.getElementById("resourceList");
- const prevBtn = document.getElementById("prevPage");
- const nextBtn = document.getElementById("nextPage");
- const pageInfo = document.getElementById("pageInfo");
- const searchBtn = document.getElementById("searchBtn");
- const pager = document.getElementById("pager");
- const homeMore = document.getElementById("homeMore");
- let page = 1;
- function setLoading(loading) {
- if (searchBtn) searchBtn.disabled = loading;
- if (prevBtn) prevBtn.disabled = loading || page <= 1;
- if (nextBtn) nextBtn.disabled = loading;
- if (qInput) qInput.disabled = loading;
- if (typeSelect) typeSelect.disabled = loading;
- if (sortSelect) sortSelect.disabled = loading;
- }
- function skeletonCard() {
- const cover = el("div", { class: "resource-card-cover skeleton" });
- const line1 = el("div", { class: "skeleton skeleton-line" });
- const line2 = el("div", { class: "skeleton skeleton-line" });
- line1.style.width = "70%";
- line2.style.width = "90%";
- const stats = el("div", { class: "toolbar" }, el("div", { class: "skeleton skeleton-pill" }), el("div", { class: "skeleton skeleton-pill" }));
- return el("div", { class: "card" }, cover, line1, line2, stats);
- }
- function renderSkeleton(count) {
- list.innerHTML = "";
- for (let i = 0; i < count; i += 1) list.appendChild(skeletonCard());
- }
- function renderEmpty(text) {
- list.innerHTML = "";
- list.appendChild(
- el(
- "div",
- { class: "card", style: "grid-column: 1 / -1; text-align:center" },
- el("div", {}, text || "暂无数据"),
- el(
- "div",
- { class: "toolbar", style: "justify-content:center" },
- el(
- "button",
- {
- class: "btn",
- onclick: async () => {
- if (qInput) qInput.value = "";
- if (typeSelect) typeSelect.value = "";
- if (sortSelect) sortSelect.value = "latest";
- page = 1;
- await load();
- },
- },
- "清空筛选"
- )
- )
- )
- );
- }
- async function load() {
- if (!list) return;
- renderSkeleton(isHome ? 6 : 9);
- setLoading(true);
- const q = (qInput ? qInput.value : "").trim();
- const type = (typeSelect ? typeSelect.value : "").trim();
- const sort = (sortSelect ? sortSelect.value : "").trim() || "latest";
- const params = new URLSearchParams();
- if (q) params.set("q", q);
- if (type) params.set("type", type);
- if (!isHome) params.set("sort", sort);
- params.set("page", String(page));
- params.set("pageSize", isHome ? "9" : "12");
- let data = null;
- try {
- data = await apiFetch(`/resources?${params.toString()}`);
- } finally {
- setLoading(false);
- }
- list.innerHTML = "";
- const items = (data && data.items) || [];
- if (!items.length) {
- renderEmpty(q || type ? "没有找到匹配的资源" : "暂无资源");
- if (pageInfo) pageInfo.textContent = "";
- if (pager) pager.style.display = isHome ? "none" : "";
- if (homeMore) homeMore.style.display = isHome ? "" : "none";
- return;
- }
- items.forEach((r) => {
- const badgeClass = r.type === "VIP" ? "badge badge-vip" : "badge badge-free";
- const badgeIcon = r.type === "VIP" ? "ri-vip-crown-line" : "ri-price-tag-3-line";
- const cover = r.coverUrl
- ? el("div", { class: "resource-card-cover" }, el("img", { class: "resource-card-cover-img", src: r.coverUrl, alt: r.title }))
- : null;
- const tags = Array.isArray(r.tags) ? r.tags.slice(0, 4) : [];
- const tagRow = tags.length ? el("div", { class: "resource-card-tags" }, ...tags.map((t) => badge(t, "badge"))) : null;
- list.appendChild(
- el(
- "div",
- { class: "card", style: "display:flex; flex-direction:column; height:100%;" },
- cover,
- el(
- "div",
- { style: "flex:1;" },
- el("span", { class: badgeClass, style: "float:right; display:flex; align-items:center; gap:4px;" }, el("i", {class: badgeIcon}), r.type === "VIP" ? "VIP" : "FREE"),
- el("h3", { style: "margin-top:0;" }, r.title)
- ),
- tagRow,
- el("div", { class: "muted", style: "margin-bottom:16px; flex:1;" }, (r.summary || "").slice(0, 80)),
- el(
- "div",
- { class: "toolbar", style: "margin-top:auto;" },
- 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"}), "查看详情"),
- el("span", { class: "muted", style: "display:flex; align-items:center; gap:8px; font-size:0.9rem;" },
- el("span", {style:"display:flex; align-items:center; gap:2px;"}, el("i", {class: "ri-bar-chart-box-line"}), String(r.viewCount)),
- el("span", {style:"display:flex; align-items:center; gap:2px;"}, el("i", {class: "ri-download-cloud-2-line"}), String(r.downloadCount))
- )
- )
- )
- );
- });
- const totalPages = Math.max(Math.ceil((data.total || 0) / (data.pageSize || 12)), 1);
- if (pageInfo) pageInfo.textContent = isHome ? "" : `第 ${data.page} / ${totalPages} 页,共 ${data.total} 条`;
- if (prevBtn) prevBtn.disabled = isHome || page <= 1;
- if (nextBtn) nextBtn.disabled = isHome || page >= totalPages;
- if (pager) pager.style.display = isHome ? "none" : "";
- if (homeMore) homeMore.style.display = isHome ? "" : "none";
- }
- if (prevBtn)
- prevBtn.addEventListener("click", async () => {
- page = Math.max(page - 1, 1);
- await load();
- });
- if (nextBtn)
- nextBtn.addEventListener("click", async () => {
- page += 1;
- await load();
- });
- if (searchBtn)
- searchBtn.addEventListener("click", async () => {
- page = 1;
- await load();
- });
- if (sortSelect)
- sortSelect.addEventListener("change", async () => {
- page = 1;
- await load();
- });
- if (qInput)
- qInput.addEventListener("keydown", async (e) => {
- if (e.key !== "Enter") return;
- page = 1;
- await load();
- });
- await load();
- }
- async function pageLogin() {
- const phone = document.getElementById("phone");
- const password = document.getElementById("password");
- const btn = document.getElementById("loginBtn");
- const msg = document.getElementById("msg");
- const toRegister = document.getElementById("toRegister");
- const next = nextFromQuery();
- if (toRegister) toRegister.setAttribute("href", `/ui/register?next=${next ? encodeURIComponent(next) : currentNextParam()}`);
- function setMsg(text) {
- if (!msg) return;
- if (!text) {
- msg.style.display = "none";
- msg.textContent = "";
- return;
- }
- msg.style.display = "";
- msg.textContent = String(text);
- }
- async function submit() {
- setMsg("");
- const phoneVal = String(phone.value || "").trim();
- const passwordVal = String(password.value || "");
- if (!/^\d{6,20}$/.test(phoneVal)) {
- setMsg("请输入正确的手机号");
- phone.focus();
- return;
- }
- if (!passwordVal) {
- setMsg("请输入密码");
- password.focus();
- return;
- }
- const original = btn.textContent;
- btn.disabled = true;
- btn.textContent = "登录中…";
- try {
- await apiFetch("/auth/login", {
- method: "POST",
- body: { phone: phoneVal, password: passwordVal },
- });
- showToastSuccess("登录成功");
- window.location.href = next || "/ui/me";
- } catch (e) {
- setMsg(`登录失败:${e.detail?.error || e.status || "unknown"}`);
- } finally {
- btn.disabled = false;
- btn.textContent = original;
- }
- }
- try {
- const me = await apiFetch("/me");
- if (me && me.user) {
- window.location.href = next || "/ui/me";
- return;
- }
- } catch (e) {}
- btn.addEventListener("click", submit);
- phone.addEventListener("keydown", (e) => {
- if (e.key === "Enter") submit();
- });
- password.addEventListener("keydown", (e) => {
- if (e.key === "Enter") submit();
- });
- }
- async function pageRegister() {
- const phone = document.getElementById("phone");
- const password = document.getElementById("password");
- const btn = document.getElementById("registerBtn");
- const msg = document.getElementById("msg");
- const toLogin = document.getElementById("toLogin");
- const next = nextFromQuery();
- if (toLogin) toLogin.setAttribute("href", `/ui/login?next=${next ? encodeURIComponent(next) : currentNextParam()}`);
- function setMsg(text) {
- if (!msg) return;
- if (!text) {
- msg.style.display = "none";
- msg.textContent = "";
- return;
- }
- msg.style.display = "";
- msg.textContent = String(text);
- }
- async function submit() {
- setMsg("");
- const phoneVal = String(phone.value || "").trim();
- const passwordVal = String(password.value || "");
- if (!/^\d{6,20}$/.test(phoneVal)) {
- setMsg("请输入正确的手机号");
- phone.focus();
- return;
- }
- if (String(passwordVal).length < 6) {
- setMsg("密码至少 6 位");
- password.focus();
- return;
- }
- const original = btn.textContent;
- btn.disabled = true;
- btn.textContent = "注册中…";
- try {
- await apiFetch("/auth/register", {
- method: "POST",
- body: { phone: phoneVal, password: passwordVal },
- });
- showToastSuccess("注册成功");
- window.location.href = next || "/ui/me";
- } catch (e) {
- setMsg(`注册失败:${e.detail?.error || e.status || "unknown"}`);
- } finally {
- btn.disabled = false;
- btn.textContent = original;
- }
- }
- btn.addEventListener("click", submit);
- phone.addEventListener("keydown", (e) => {
- if (e.key === "Enter") submit();
- });
- password.addEventListener("keydown", (e) => {
- if (e.key === "Enter") submit();
- });
- }
- async function pageMe() {
- const meInfo = document.getElementById("meInfo");
- const orderList = document.getElementById("orderList");
- const logoutBtn = document.getElementById("logoutBtn");
- const orderSection = document.getElementById("orderSection");
- const downloadSection = document.getElementById("downloadSection");
- const downloadList = document.getElementById("downloadList");
- const downloadPager = document.getElementById("downloadPager");
- const meMsg = document.getElementById("meMsg");
- let downloadPage = 1;
- const downloadPageSize = 10;
- async function loadDownloads(page) {
- if (!downloadList) return;
- downloadPage = Math.max(1, parseInt(page || 1, 10) || 1);
- downloadList.innerHTML = "";
- downloadList.appendChild(el("div", { class: "card skeleton", style: "height: 72px;" }));
- downloadList.appendChild(el("div", { class: "card skeleton", style: "height: 72px;" }));
- if (downloadPager) downloadPager.innerHTML = "";
- const q = new URLSearchParams();
- q.set("page", String(downloadPage));
- q.set("pageSize", String(downloadPageSize));
- const data = await apiFetch(`/me/downloads?${q.toString()}`);
- const items = (data && data.items) || [];
- const total = parseInt(data.total || 0, 10) || 0;
- const totalPages = Math.max(1, Math.ceil(total / downloadPageSize));
- downloadList.innerHTML = "";
- if (!items.length) {
- downloadList.appendChild(
- el(
- "div",
- { style: "text-align: center; padding: 28px 16px; color: var(--muted);" },
- el("i", { class: "ri-inbox-line", style: "font-size: 2.2rem; margin-bottom: 12px; opacity: 0.5;" }),
- el("div", { style: "font-size: 1.05rem; margin-bottom: 6px;" }, "暂无下载记录"),
- el("div", { style: "font-size: 0.9rem;" }, "下载过资源后会显示在这里。")
- )
- );
- } else {
- items.forEach((it) => {
- let stateBadge = el("span", { class: "badge badge-success" }, "可用");
- if (it.resourceState === "DELETED") stateBadge = el("span", { class: "badge badge-danger" }, "资源已删除");
- else if (it.resourceState === "OFFLINE") stateBadge = el("span", { class: "badge badge-warning" }, "资源已下架");
- const typeBadge = el(
- "span",
- { class: `badge ${it.resourceType === "VIP" ? "badge-vip" : "badge-free"}` },
- `下载时:${it.resourceType === "VIP" ? "VIP" : "免费"}`
- );
- const currentType = it.currentResourceType || "";
- const currentTypeBadge =
- currentType && it.resourceState !== "DELETED"
- ? el(
- "span",
- { class: `badge ${currentType === "VIP" ? "badge-vip" : "badge-free"}` },
- `当前:${currentType === "VIP" ? "VIP" : "免费"}`
- )
- : null;
- const driftBadge =
- currentType && it.resourceType && currentType !== it.resourceType && it.resourceState === "ONLINE"
- ? el("span", { class: "badge badge-warning" }, "类型已变更")
- : null;
- const canOpenDetail = !!it.resourceId && it.resourceState === "ONLINE";
- const titleNode = canOpenDetail
- ? el(
- "a",
- { href: `/ui/resources/${it.resourceId}`, style: "color: inherit; text-decoration: none;" },
- it.resourceTitle || ""
- )
- : el("span", { class: "muted" }, it.resourceTitle || "");
- downloadList.appendChild(
- el(
- "div",
- { style: "border: 1px solid var(--border); border-radius: 12px; padding: 16px; background: var(--bg); display: flex; flex-direction: column; gap: 10px;" },
- el(
- "div",
- { style: "display: flex; justify-content: space-between; align-items: center; gap: 12px;" },
- el("div", { style: "font-weight: 500; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" }, titleNode),
- stateBadge
- ),
- el(
- "div",
- { class: "muted", style: "display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 0.9rem;" },
- typeBadge,
- currentTypeBadge,
- driftBadge,
- el("span", {}, `Ref:${it.ref || ""}`),
- el("span", {}, `下载:${formatDateTime(it.downloadedAt)}`)
- )
- )
- );
- });
- }
- if (downloadPager) {
- const prevBtn = el("button", { class: "btn", disabled: downloadPage <= 1 }, "上一页");
- const nextBtn = el("button", { class: "btn", disabled: downloadPage >= totalPages }, "下一页");
- prevBtn.addEventListener("click", async () => loadDownloads(downloadPage - 1));
- nextBtn.addEventListener("click", async () => loadDownloads(downloadPage + 1));
- downloadPager.appendChild(el("div", { class: "muted" }, `第 ${downloadPage} / ${totalPages} 页 · 共 ${total} 条`));
- downloadPager.appendChild(el("div", { style: "display: flex; gap: 8px;" }, prevBtn, nextBtn));
- }
- }
- async function load() {
- if (meMsg) {
- meMsg.style.display = "none";
- meMsg.textContent = "";
- }
- meInfo.innerHTML = "";
- meInfo.appendChild(el("div", { class: "skeleton skeleton-line", style: "width: 60%;" }));
- meInfo.appendChild(el("div", { class: "skeleton skeleton-line", style: "width: 45%;" }));
- if (orderList) {
- orderList.innerHTML = "";
- orderList.appendChild(el("div", { class: "card skeleton", style: "height: 96px;" }));
- orderList.appendChild(el("div", { class: "card skeleton", style: "height: 96px;" }));
- }
- if (downloadList) {
- downloadList.innerHTML = "";
- downloadList.appendChild(el("div", { class: "card skeleton", style: "height: 72px;" }));
- downloadList.appendChild(el("div", { class: "card skeleton", style: "height: 72px;" }));
- }
- if (downloadPager) downloadPager.innerHTML = "";
- const data = await apiFetch("/me");
- if (!data.user) {
- meInfo.innerHTML = "";
- meInfo.appendChild(el("div", {}, "未登录"));
- meInfo.appendChild(el("div", { class: "muted" }, "登录后可查看会员状态与订单记录。"));
- 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()}` }, "去注册")));
- if (orderSection) orderSection.style.display = "none";
- if (logoutBtn) logoutBtn.style.display = "none";
- if (downloadSection) downloadSection.style.display = "none";
- if (meMsg) {
- meMsg.style.display = "";
- meMsg.textContent = "提示:未登录";
- }
- if (orderList) orderList.innerHTML = "";
- if (downloadList) downloadList.innerHTML = "";
- if (downloadPager) downloadPager.innerHTML = "";
- return;
- }
- if (orderSection) orderSection.style.display = "";
- if (logoutBtn) logoutBtn.style.display = "";
- if (downloadSection) downloadSection.style.display = "";
- meInfo.innerHTML = "";
- 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}`));
- 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 有效" : "无/已过期")));
- 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)}`));
- const orders = await apiFetch("/orders");
- orderList.innerHTML = "";
- const items = (orders && orders.items) || [];
- if (!items.length) {
- orderList.appendChild(
- el("div", { style: "text-align: center; padding: 40px 20px; color: var(--muted);" },
- el("i", { class: "ri-inbox-line", style: "font-size: 3rem; margin-bottom: 16px; opacity: 0.5;" }),
- el("div", { style: "font-size: 1.1rem; margin-bottom: 8px;" }, "暂无订单"),
- el("div", { style: "font-size: 0.9rem;" }, "购买会员后将显示订单记录。")
- )
- );
- }
- if (items.length) {
- items.forEach((o) => {
- let statusBadge;
- if (o.status === "PAID") statusBadge = el("span", { class: "badge badge-success" }, "已支付");
- else if (o.status === "CLOSED") statusBadge = el("span", { class: "badge badge-danger" }, "已关闭");
- else statusBadge = el("span", { class: "badge badge-info" }, o.status);
- orderList.appendChild(
- el(
- "div",
- { 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;" },
- el("div", { style: "display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border); padding-bottom: 12px;" },
- el("div", { style: "font-weight: 500;" }, `订单号:${o.id}`),
- statusBadge
- ),
- el("div", { style: "display: flex; justify-content: space-between; align-items: center;" },
- 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} 天)`)),
- el("div", { style: "font-weight: bold; color: var(--brand); font-size: 1.1rem;" }, formatCents(o.amountCents))
- ),
- el("div", { class: "muted", style: "font-size: 0.9rem; display: flex; justify-content: space-between; margin-top: 4px;" },
- el("span", { style: "display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-time-line" }), `创建:${formatDateTime(o.createdAt)}`),
- el("span", { style: "display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-check-double-line" }), `支付:${formatDateTime(o.paidAt)}`)
- )
- )
- );
- });
- }
- await loadDownloads(1);
- }
- logoutBtn.addEventListener("click", async () => {
- await apiFetch("/auth/logout", { method: "POST" });
- window.location.reload();
- });
- await load();
- }
- async function pageMessages() {
- const messageList = document.getElementById("messageList");
- const messagePager = document.getElementById("messagePager");
- const messageUnreadBadge = document.getElementById("messageUnreadBadge");
- const messageUnreadOnlyBtn = document.getElementById("messageUnreadOnlyBtn");
- const messageAllBtn = document.getElementById("messageAllBtn");
- const messageMsg = document.getElementById("messageMsg");
- let page = 1;
- const pageSize = 10;
- let unreadOnly = false;
- function setMsg(text, isError = true) {
- if (!messageMsg) return;
- messageMsg.style.display = "";
- messageMsg.className = `form-msg ${isError ? "form-msg-error" : "form-msg-success"}`;
- messageMsg.textContent = String(text || "");
- }
- function clearMsg() {
- if (!messageMsg) return;
- messageMsg.style.display = "none";
- messageMsg.textContent = "";
- }
- function updateUnreadBadge(cnt) {
- if (!messageUnreadBadge) return;
- const n = parseInt(cnt || 0, 10) || 0;
- if (n > 0) {
- messageUnreadBadge.style.display = "";
- messageUnreadBadge.textContent = `${n} 未读`;
- } else {
- messageUnreadBadge.style.display = "none";
- messageUnreadBadge.textContent = "";
- }
- }
- function updateToggleButtons() {
- if (messageUnreadOnlyBtn) messageUnreadOnlyBtn.className = `btn btn-sm ${unreadOnly ? "btn-primary" : ""}`.trim();
- if (messageAllBtn) messageAllBtn.className = `btn btn-sm ${!unreadOnly ? "btn-primary" : ""}`.trim();
- }
- async function loadMessages(targetPage) {
- if (!messageList) return;
- page = Math.max(1, parseInt(targetPage || 1, 10) || 1);
- clearMsg();
- messageList.innerHTML = "";
- messageList.appendChild(el("div", { class: "card skeleton", style: "height: 72px;" }));
- messageList.appendChild(el("div", { class: "card skeleton", style: "height: 72px;" }));
- if (messagePager) messagePager.innerHTML = "";
- const q = new URLSearchParams();
- q.set("page", String(page));
- q.set("pageSize", String(pageSize));
- if (unreadOnly) q.set("unread", "1");
- let data;
- try {
- data = await apiFetch(`/me/messages?${q.toString()}`);
- } catch (e) {
- if (e.status === 401) {
- setMsg("未登录,无法查看消息。");
- messageList.innerHTML = "";
- messageList.appendChild(el("div", { class: "toolbar" }, el("a", { class: "btn btn-primary", href: `/ui/login?next=${currentNextParam()}` }, "去登录")));
- return;
- }
- setMsg(e.detail?.error || e.status || "消息加载失败");
- messageList.innerHTML = "";
- return;
- }
- const items = (data && data.items) || [];
- const total = parseInt(data.total || 0, 10) || 0;
- const unreadCount = parseInt(data.unreadCount || 0, 10) || 0;
- const totalPages = Math.max(1, Math.ceil(total / pageSize));
- updateUnreadBadge(unreadCount);
- updateToggleButtons();
- if (typeof window.__refreshMessageBadge === "function") {
- try {
- await window.__refreshMessageBadge();
- } catch (e) {}
- }
- messageList.innerHTML = "";
- if (!items.length) {
- messageList.appendChild(
- el(
- "div",
- { style: "text-align: center; padding: 28px 16px; color: var(--muted);" },
- el("i", { class: "ri-inbox-line", style: "font-size: 2.2rem; margin-bottom: 12px; opacity: 0.5;" }),
- el("div", { style: "font-size: 1.05rem; margin-bottom: 6px;" }, unreadOnly ? "暂无未读消息" : "暂无消息"),
- el("div", { style: "font-size: 0.9rem;" }, "系统通知会显示在这里。")
- )
- );
- } else {
- items.forEach((m) => {
- const read = !!m.read;
- const statusBadge = read ? el("span", { class: "badge" }, "已读") : el("span", { class: "badge badge-warning" }, "未读");
- const actions = !read
- ? el(
- "button",
- {
- class: "btn btn-sm",
- onclick: async () => {
- try {
- await apiFetch(`/me/messages/${m.id}/read`, { method: "PUT" });
- await loadMessages(page);
- } catch (e) {
- showToastError(e?.detail?.error || e?.status || "标记失败");
- }
- },
- },
- "标记已读"
- )
- : null;
- messageList.appendChild(
- el(
- "div",
- { style: "border: 1px solid var(--border); border-radius: 12px; padding: 16px; background: var(--bg); display: flex; flex-direction: column; gap: 10px;" },
- el(
- "div",
- { style: "display: flex; justify-content: space-between; align-items: center; gap: 12px;" },
- el("div", { style: "font-weight: 500; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" }, m.title || "通知"),
- el("div", { style: "display: flex; align-items: center; gap: 8px; flex: 0 0 auto;" }, statusBadge, actions)
- ),
- el("div", { class: "muted", style: "white-space: pre-wrap;" }, formatMessageText(m.content || "")),
- el("div", { class: "muted", style: "font-size: 0.9rem;" }, `发送时间:${formatDateTime(m.createdAt)}`)
- )
- );
- });
- }
- if (messagePager) {
- const prevBtn = el("button", { class: "btn", disabled: page <= 1 }, "上一页");
- const nextBtn = el("button", { class: "btn", disabled: page >= totalPages }, "下一页");
- prevBtn.addEventListener("click", async () => loadMessages(page - 1));
- nextBtn.addEventListener("click", async () => loadMessages(page + 1));
- messagePager.appendChild(el("div", { class: "muted" }, `第 ${page} / ${totalPages} 页 · 共 ${total} 条`));
- messagePager.appendChild(el("div", { style: "display: flex; gap: 8px;" }, prevBtn, nextBtn));
- }
- }
- if (messageUnreadOnlyBtn) {
- messageUnreadOnlyBtn.addEventListener("click", async () => {
- unreadOnly = true;
- await loadMessages(1);
- });
- }
- if (messageAllBtn) {
- messageAllBtn.addEventListener("click", async () => {
- unreadOnly = false;
- await loadMessages(1);
- });
- }
- updateToggleButtons();
- await loadMessages(1);
- }
- async function pageVip() {
- const planList = document.getElementById("planList");
- const vipMsg = document.getElementById("vipMsg");
- const vipStatus = document.getElementById("vipStatus");
- let me = null;
- try {
- me = await apiFetch("/me");
- } catch (e) {
- me = { user: null };
- }
- const user = me && me.user ? me.user : null;
- if (vipStatus) {
- vipStatus.style.display = "";
- vipStatus.innerHTML = "";
- if (!user) {
- vipStatus.appendChild(
- el("div", { style: "display: flex; flex-direction: column; align-items: center; padding: 16px;" },
- el("div", { style: "margin-bottom: 16px; font-size: 1.1rem; color: var(--brand);" }, "未登录。登录后可购买/续费会员,并查看权益状态。"),
- 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;" },
- el("i", { class: "ri-login-box-line" }), "去登录"
- )
- )
- );
- } else {
- vipStatus.appendChild(
- el("div", { style: "display: flex; justify-content: space-around; flex-wrap: wrap; gap: 16px; padding: 16px;" },
- el("div", { style: "display: flex; flex-direction: column; align-items: center; gap: 8px;" },
- el("div", { class: "muted", style: "font-size: 0.9rem;" }, "当前账号"),
- 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)
- ),
- el("div", { style: "display: flex; flex-direction: column; align-items: center; gap: 8px;" },
- el("div", { class: "muted", style: "font-size: 0.9rem;" }, "会员状态"),
- el("div", { style: "font-weight: 500; display: flex; align-items: center; gap: 4px;" },
- el("i", { class: user.vipActive ? "ri-vip-crown-fill" : "ri-vip-crown-line", style: user.vipActive ? "color: #ffd700;" : "color: var(--muted);" }),
- el("span", { class: user.vipActive ? "badge badge-success" : "badge badge-danger", style: "margin-left: 4px;" }, user.vipActive ? "VIP 有效" : "无/已过期")
- )
- ),
- el("div", { style: "display: flex; flex-direction: column; align-items: center; gap: 8px;" },
- el("div", { class: "muted", style: "font-size: 0.9rem;" }, "到期时间"),
- 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))
- )
- )
- );
- }
- }
- planList.innerHTML = "";
- planList.appendChild(el("div", { class: "card skeleton", style: "grid-column: 1 / -1; height: 120px;" }));
- const plans = await apiFetch("/plans");
- planList.innerHTML = "";
- plans.forEach((p) => {
- const isRecommended = plans[0] && p.id === plans[0].id;
- planList.appendChild(
- el(
- "div",
- { 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%;` },
- 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,
- el(
- "div",
- { style: "text-align: center; margin-bottom: 24px;" },
- el("h3", { style: "margin: 0 0 8px 0; font-size: 1.5rem; color: var(--text);" }, p.name),
- el("div", { class: "muted" }, `有效期 ${p.durationDays} 天`)
- ),
- el(
- "div",
- { style: "text-align: center; margin-bottom: 32px;" },
- el("span", { style: "font-size: 1.25rem; color: var(--brand); font-weight: bold;" }, "¥ "),
- el("span", { style: "font-size: 2.5rem; color: var(--brand); font-weight: bold; line-height: 1;" }, (p.priceCents / 100).toFixed(2))
- ),
- el(
- "div",
- { style: "margin-top: auto;" },
- el(
- "button",
- {
- class: isRecommended ? "btn btn-primary" : "btn",
- style: "width: 100%; height: 48px; font-size: 1.1rem; border-radius: 24px; display: flex; justify-content: center; align-items: center; gap: 8px;",
- "data-plan-id": String(p.id),
- },
- el("i", { class: "ri-shopping-cart-2-line" }),
- "立即开通"
- )
- )
- )
- );
- });
- planList.addEventListener("click", async (evt) => {
- const btn = evt.target.closest("button[data-plan-id]");
- if (!btn) return;
- vipMsg.textContent = "";
- const originalText = btn.textContent;
- btn.disabled = true;
- btn.textContent = "处理中…";
- const planId = Number(btn.getAttribute("data-plan-id"));
- try {
- const order = await apiFetch("/orders", { method: "POST", body: { planId } });
- const payResp = await apiFetch(`/orders/${order.id}/pay`, { method: "POST" });
- if (payResp && payResp.payUrl) {
- vipMsg.textContent = "已发起支付宝支付,正在跳转…";
- window.location.href = payResp.payUrl;
- return;
- }
- vipMsg.textContent = "支付成功(模拟),已发放会员权益。";
- showToastSuccess("支付成功,会员权益已生效");
- setTimeout(() => {
- window.location.href = "/ui/me";
- }, 300);
- } catch (e) {
- if (e.status === 401) window.location.href = `/ui/login?next=${currentNextParam()}`;
- vipMsg.textContent = `下单/支付失败:${e.detail?.error || e.status || "unknown"}`;
- } finally {
- btn.disabled = false;
- btn.textContent = originalText;
- }
- });
- }
- async function pageResourceDetail() {
- const root = document.getElementById("resourceDetail");
- const resourceId = Number(root.getAttribute("data-resource-id"));
- const descRoot = document.getElementById("resourceDescription");
- const refSelect = document.getElementById("refSelect");
- const reloadRepo = document.getElementById("reloadRepo");
- const treeEl = document.getElementById("tree");
- const fileContent = document.getElementById("fileContent");
- const breadcrumb = document.getElementById("breadcrumb");
- const downloadBtn = document.getElementById("downloadBtn");
- const repoModalBackdrop = document.getElementById("repoModalBackdrop");
- const repoModalTitle = document.getElementById("repoModalTitle");
- const repoModalClose = document.getElementById("repoModalClose");
- const repoModalBody = document.getElementById("repoModalBody");
- const repoModalFooter = document.getElementById("repoModalFooter");
- let currentRef = "";
- let currentPath = "";
- let canEditRepo = false;
- let refKinds = { branches: new Set(), tags: new Set() };
- let selectedFilePath = "";
- let selectedFileContent = "";
- let detail = null;
- let me = null;
- let inlineDownloadBtn = null;
- const repoWriteActionsEnabled = false;
- function closeRepoModal() {
- repoModalBackdrop.style.display = "none";
- repoModalTitle.textContent = "";
- repoModalBody.innerHTML = "";
- repoModalFooter.innerHTML = "";
- }
- function openRepoModal(title, bodyNodes, footerNodes, icon = "ri-code-line") {
- repoModalTitle.innerHTML = "";
- repoModalTitle.appendChild(el("i", { class: icon }));
- repoModalTitle.appendChild(document.createTextNode(title));
- repoModalBody.innerHTML = "";
- repoModalFooter.innerHTML = "";
- bodyNodes.forEach((n) => repoModalBody.appendChild(n));
- footerNodes.forEach((n) => repoModalFooter.appendChild(n));
- repoModalBackdrop.style.display = "";
- }
- repoModalClose.addEventListener("click", closeRepoModal);
- repoModalBackdrop.addEventListener("click", (evt) => {
- if (evt.target === repoModalBackdrop) closeRepoModal();
- });
- function isBranchRef(ref) {
- return refKinds.branches.has(ref);
- }
- function setDownloadButtonLabel(btn, label) {
- if (!btn) return;
- const text = String(label || "").trim() || "下载 ZIP";
- const iconClass = text.includes("开通会员") ? "ri-vip-crown-line" : "ri-download-cloud-2-line";
- btn.innerHTML = "";
- btn.appendChild(el("i", { class: iconClass }));
- btn.appendChild(document.createTextNode(text));
- }
- function updateDownloadButton() {
- const user = me && me.user ? me.user : null;
- if (!user) {
- setDownloadButtonLabel(downloadBtn, "下载 ZIP");
- setDownloadButtonLabel(inlineDownloadBtn, "下载 ZIP");
- return;
- }
- if (detail && detail.type === "VIP" && !user.vipActive) {
- setDownloadButtonLabel(downloadBtn, "开通会员下载");
- setDownloadButtonLabel(inlineDownloadBtn, "开通会员下载");
- return;
- }
- setDownloadButtonLabel(downloadBtn, "下载 ZIP");
- setDownloadButtonLabel(inlineDownloadBtn, "下载 ZIP");
- }
- async function loadMe() {
- try {
- me = await apiFetch("/me");
- } catch (e) {
- me = null;
- }
- updateDownloadButton();
- }
- function setBreadcrumb(path) {
- breadcrumb.innerHTML = "";
- const parts = path ? path.split("/") : [];
- const items = [{ name: "根目录", path: "" }];
- let acc = "";
- parts.forEach((p) => {
- acc = acc ? `${acc}/${p}` : p;
- items.push({ name: p, path: acc });
- });
- items.forEach((it, idx) => {
- const a = el("a", { href: "#" }, it.name);
- a.addEventListener("click", async (e) => {
- e.preventDefault();
- currentPath = it.path;
- await loadTree();
- });
- breadcrumb.appendChild(a);
- if (idx < items.length - 1) breadcrumb.appendChild(el("span", { class: "muted" }, "/"));
- });
- }
- async function loadDetail() {
- const r = await apiFetch(`/resources/${resourceId}`);
- detail = r;
- inlineDownloadBtn = null;
- root.innerHTML = "";
- if (descRoot) descRoot.innerHTML = "";
-
- 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;
-
- inlineDownloadBtn = el(
- "button",
- { class: "btn btn-primary", style: "margin-top: 14px; border-radius: 10px; display: inline-flex; align-items: center; gap: 6px; width: fit-content;" },
- el("i", { class: "ri-download-cloud-2-line" }),
- "下载 ZIP"
- );
- inlineDownloadBtn.addEventListener("click", downloadZip);
- const metaCol = el(
- "div",
- { style: "flex: 1; display: flex; flex-direction: column;" },
- el("h1", { style: "margin: 0 0 16px 0; font-size: 2rem; color: var(--text);" }, r.title),
- el(
- "div",
- { style: "display: flex; gap: 12px; margin-bottom: 24px; flex-wrap: wrap;" },
- 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;" }), "免费资源"),
- 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}`),
- ...((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)))
- ),
- el("div", { class: "muted", style: "display: flex; gap: 16px; margin-bottom: 8px;" },
- el("span", { style: "display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-eye-line" }), `浏览 ${r.viewCount}`),
- el("span", { style: "display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-download-cloud-2-line" }), `下载 ${r.downloadCount}`)
- ),
- 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 ? " (私有)" : ""}`),
- inlineDownloadBtn
- );
- const headerRow = el("div", { style: "display: flex; gap: 32px; flex-wrap: wrap; margin-bottom: 32px;" }, coverCol, metaCol);
-
- root.appendChild(
- el(
- "div",
- { class: "card", style: "padding: 32px; border-radius: 16px; box-shadow: var(--shadow-md);" },
- headerRow
- )
- );
- if (descRoot) {
- const summary = el("div", { class: "md", style: "line-height: 1.8; color: var(--text-light);", html: renderMarkdown(r.summary || "暂无描述") });
- descRoot.appendChild(
- el(
- "div",
- { class: "card", style: "margin-top:24px; padding: 32px; border-radius: 16px; box-shadow: var(--shadow-md);" },
- el("h3", { style: "display: flex; align-items: center; gap: 8px; margin-bottom: 16px;" }, el("i", { class: "ri-file-text-line", style: "color: var(--brand);" }), "资源描述"),
- summary
- )
- );
- }
- updateDownloadButton();
- }
- async function loadRefs() {
- const refs = await apiFetch(`/resources/${resourceId}/repo/refs`);
- refSelect.innerHTML = "";
- refKinds = { branches: new Set(), tags: new Set() };
- const branchGroup = document.createElement("optgroup");
- branchGroup.label = "分支";
- (refs.branches || []).forEach((b) => {
- const name = (b.name || "").trim();
- if (!name) return;
- refKinds.branches.add(name);
- branchGroup.appendChild(el("option", { value: name }, name));
- });
- const tagGroup = document.createElement("optgroup");
- tagGroup.label = "标签";
- (refs.tags || []).forEach((t) => {
- const name = (t.name || "").trim();
- if (!name) return;
- refKinds.tags.add(name);
- tagGroup.appendChild(el("option", { value: name }, name));
- });
- if (branchGroup.children.length) refSelect.appendChild(branchGroup);
- if (tagGroup.children.length) refSelect.appendChild(tagGroup);
- currentRef = refSelect.value;
- }
- async function loadTree() {
- fileContent.textContent = "";
- selectedFilePath = "";
- selectedFileContent = "";
- setBreadcrumb(currentPath);
- treeEl.innerHTML = "";
- const params = new URLSearchParams();
- params.set("ref", currentRef);
- params.set("path", currentPath);
- const data = await apiFetch(`/resources/${resourceId}/repo/tree?${params.toString()}`);
- data.items.forEach((it) => {
- const rightText = String(it.path || "");
- const isLocked = it.type !== "dir" && it.guestAllowed === false;
- const rightNode = isLocked
- ? el(
- "div",
- { class: "muted tree-locked", style: "font-size: 0.85rem; display: flex; align-items: center; gap: 6px;" },
- el("i", { class: "ri-lock-2-line" }),
- "需登录"
- )
- : rightText && rightText !== it.name
- ? el("div", { class: "muted", style: "font-size: 0.85rem;" }, rightText)
- : null;
- const row = el(
- "div",
- { class: `card${isLocked ? " is-locked" : ""}` },
- el("div", { style: "display: flex; align-items: center; gap: 8px; font-weight: 500;" },
- 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)"};` }),
- it.name
- ),
- rightNode
- );
-
- row.addEventListener("click", async () => {
- if (it.type === "dir") {
- currentPath = it.path;
- await loadTree();
- return;
- }
- if (it.guestAllowed === false) {
- Swal.fire({
- title: '需要登录',
- text: '未登录仅可预览文档/配置等普通文本文件',
- icon: 'info',
- showCancelButton: true,
- confirmButtonText: '去登录',
- cancelButtonText: '取消',
- confirmButtonColor: 'var(--brand)'
- }).then((result) => {
- if (result.isConfirmed) {
- window.location.href = `/ui/login?next=${currentNextParam()}`;
- }
- });
- return;
- }
- const p = new URLSearchParams();
- p.set("ref", currentRef);
- p.set("path", it.path);
- try {
- const f = await apiFetch(`/resources/${resourceId}/repo/file?${p.toString()}`);
- fileContent.textContent = f.content;
- selectedFilePath = it.path;
- selectedFileContent = f.content;
- } catch (e) {
- if (e.status === 401 && e.detail?.error === "login_required") {
- Swal.fire({
- title: '需要登录',
- text: '未登录仅可预览文档/配置等普通文本文件',
- icon: 'info',
- showCancelButton: true,
- confirmButtonText: '去登录',
- cancelButtonText: '取消',
- confirmButtonColor: 'var(--brand)'
- }).then((result) => {
- if (result.isConfirmed) {
- window.location.href = `/ui/login?next=${currentNextParam()}`;
- }
- });
- return;
- }
- fileContent.textContent = `无法预览:${e.detail?.error || e.status || "unknown"}`;
- }
- });
- treeEl.appendChild(row);
- });
- }
- async function downloadZip() {
- const user = me && me.user ? me.user : null;
- if (!user) {
- Swal.fire({
- title: '未登录',
- text: '下载资源需要先登录账户',
- icon: 'info',
- showCancelButton: true,
- confirmButtonText: '去登录',
- cancelButtonText: '取消',
- confirmButtonColor: 'var(--brand)'
- }).then((result) => {
- if (result.isConfirmed) {
- window.location.href = `/ui/login?next=${currentNextParam()}`;
- }
- });
- return;
- }
- if (detail && detail.type === "VIP" && !user.vipActive) {
- Swal.fire({
- title: 'VIP 专享资源',
- text: '该资源为 VIP 专属,需要开通会员后才能下载。',
- icon: 'warning',
- showCancelButton: true,
- confirmButtonText: '去开通会员',
- cancelButtonText: '取消',
- confirmButtonColor: '#ffd700',
- iconColor: '#ffd700'
- }).then((result) => {
- if (result.isConfirmed) {
- window.location.href = "/ui/vip";
- }
- });
- return;
- }
- if (downloadBtn) downloadBtn.disabled = true;
- if (inlineDownloadBtn) inlineDownloadBtn.disabled = true;
- Swal.fire({
- title: "正在准备下载",
- text: "请稍候…",
- allowOutsideClick: false,
- allowEscapeKey: false,
- showConfirmButton: false,
- didOpen: () => {
- Swal.showLoading();
- },
- });
- try {
- const resp = await apiFetch(`/resources/${resourceId}/download`, {
- method: "POST",
- body: { ref: currentRef },
- });
- const blob = await resp.blob();
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = `resource-${resourceId}-${currentRef}.zip`;
- document.body.appendChild(a);
- a.click();
- a.remove();
- URL.revokeObjectURL(url);
- Swal.close();
- } catch (e) {
- Swal.close();
- if (e.status === 401) window.location.href = `/ui/login?next=${currentNextParam()}`;
- if (e.status === 403 && e.detail?.error === "vip_required") {
- Swal.fire({
- title: 'VIP 专享资源',
- text: '该资源为 VIP 专属,需要开通会员后才能下载。',
- icon: 'warning',
- showCancelButton: true,
- confirmButtonText: '去开通会员',
- cancelButtonText: '取消',
- confirmButtonColor: '#ffd700',
- iconColor: '#ffd700'
- }).then((result) => {
- if (result.isConfirmed) {
- window.location.href = "/ui/vip";
- }
- });
- return;
- }
- Swal.fire({
- icon: 'error',
- title: '下载失败',
- text: e.detail?.error || e.status || "未知错误"
- });
- } finally {
- if (downloadBtn) downloadBtn.disabled = false;
- if (inlineDownloadBtn) inlineDownloadBtn.disabled = false;
- }
- }
- refSelect.addEventListener("change", async () => {
- currentRef = refSelect.value;
- currentPath = "";
- await loadTree();
- });
- reloadRepo.addEventListener("click", async () => {
- await loadRefs();
- currentPath = "";
- await loadTree();
- });
- downloadBtn.addEventListener("click", downloadZip);
- const toolbar = downloadBtn.closest(".toolbar");
- const commitsBtn = el("button", { class: "btn", style: "border-radius: 8px; display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-history-line" }), "提交历史");
- toolbar.insertBefore(commitsBtn, downloadBtn);
- async function showCommits() {
- const p = new URLSearchParams();
- p.set("ref", currentRef);
- const focusPath = selectedFilePath || currentPath || "";
- if (focusPath) p.set("path", focusPath);
- p.set("limit", "20");
- const msg = el("div", { class: "muted" }, "加载中…");
- openRepoModal("提交历史", [msg], [el("button", { class: "btn", onclick: closeRepoModal }, "关闭")], "ri-history-line");
- try {
- const data = await apiFetch(`/resources/${resourceId}/repo/commits?${p.toString()}`);
- const items = data.items || [];
- if (!items.length) {
- msg.textContent = "没有找到提交记录";
- return;
- }
- msg.remove();
- const list = el("div", {});
- items.forEach((it) => {
- const sha = String(it.sha || "");
- list.appendChild(
- el(
- "div",
- { class: "card", style: "margin-bottom:12px; padding: 16px; border-left: 3px solid var(--brand); border-radius: 8px;" },
- el("div", { style: "display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;" },
- el("div", { style: "font-weight: 500; font-size: 1.05rem;" }, String(it.subject || "")),
- el("span", { class: "badge", style: "font-family: monospace; font-size: 0.85rem;" }, sha.slice(0, 7))
- ),
- el("div", { class: "muted", style: "display: flex; align-items: center; gap: 8px; font-size: 0.9rem;" },
- el("span", { style: "display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-user-line" }), it.authorName || ""),
- el("span", { style: "display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-time-line" }), formatDateTime(it.authorDate))
- )
- )
- );
- });
- repoModalBody.appendChild(list);
- } catch (e) {
- msg.textContent = `加载失败:${e.detail?.error || e.status || "unknown"}${e.detail?.message ? `\n${e.detail.message}` : ""}`;
- }
- }
- commitsBtn.addEventListener("click", showCommits);
- try {
- await apiFetch("/admin/settings");
- canEditRepo = true;
- } catch (e) {
- canEditRepo = false;
- }
- if (canEditRepo && repoWriteActionsEnabled) {
- const createBtn = el("button", { class: "btn", style: "border-radius: 8px; display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-file-add-line" }), "新建文件");
- const editBtn = el("button", { class: "btn", style: "border-radius: 8px; display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-edit-line" }), "在线编辑");
- 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" }), "删除");
- function requireBranchOrToast() {
- if (isBranchRef(currentRef)) return true;
- Swal.fire({
- icon: 'warning',
- title: '操作受限',
- text: '仅支持在分支上进行编辑或提交操作'
- });
- return false;
- }
- function requireSelectedFileOrToast() {
- if (selectedFilePath) return true;
- Swal.fire({
- icon: 'info',
- title: '未选择文件',
- text: '请先在左侧目录结构中选择一个文件'
- });
- return false;
- }
- async function createFile() {
- if (!requireBranchOrToast()) return;
- const pathInput = el("input", { class: "input", placeholder: "例如:README.md 或 docs/intro.md" });
- const defaultPath = currentPath ? `${currentPath.replace(/\\/g, "/").replace(/\/+$/, "")}/new-file.txt` : "new-file.txt";
- pathInput.value = defaultPath;
- const msgInput = el("input", { class: "input", placeholder: "提交信息,例如:Add new file" });
- const ta = el("textarea", { class: "input", style: "min-height:260px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;" });
- const msg = el("div", { class: "muted" });
- const saveBtn = el("button", { class: "btn btn-primary" }, "提交");
- saveBtn.addEventListener("click", async () => {
- msg.textContent = "";
- saveBtn.disabled = true;
- try {
- const res = await apiFetch(`/resources/${resourceId}/repo/file`, {
- method: "POST",
- body: { ref: currentRef, path: pathInput.value.trim(), content: ta.value, message: msgInput.value.trim() },
- });
- msg.textContent = `提交成功:${String(res.commit || "").slice(0, 10)}`;
- await loadTree();
- setTimeout(closeRepoModal, 600);
- } catch (e) {
- msg.textContent = `提交失败:${e.detail?.error || e.status || "unknown"}${e.detail?.message ? `\n${e.detail.message}` : ""}`;
- } finally {
- saveBtn.disabled = false;
- }
- });
- openRepoModal(
- "新建文件",
- [
- el("div", { style: "display: flex; flex-direction: column; gap: 16px;" },
- el("div", { style: "display: flex; flex-direction: column; gap: 8px;" }, el("div", { class: "label", style: "font-weight: 600;" }, "文件路径"), pathInput),
- el("div", { style: "display: flex; flex-direction: column; gap: 8px;" }, el("div", { class: "label", style: "font-weight: 600;" }, "提交信息"), msgInput),
- el("div", { style: "display: flex; flex-direction: column; gap: 8px;" }, el("div", { class: "label", style: "font-weight: 600;" }, "文件内容"), ta),
- msg
- )
- ],
- [el("button", { class: "btn", onclick: closeRepoModal }, "取消"), saveBtn],
- "ri-file-add-line"
- );
- }
- async function editFile() {
- if (!requireBranchOrToast()) return;
- if (!requireSelectedFileOrToast()) return;
- const pathText = el("input", { class: "input", value: selectedFilePath, disabled: true, style: "background: #f1f5f9; color: var(--muted); cursor: not-allowed;" });
- const msgInput = el("input", { class: "input", placeholder: "提交信息,例如:Update README" });
- const ta = el("textarea", { class: "input", style: "min-height:320px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;" }, "");
- ta.value = selectedFileContent || "";
- const msg = el("div", { class: "muted" });
- const saveBtn = el("button", { class: "btn btn-primary" }, "提交");
- saveBtn.addEventListener("click", async () => {
- msg.textContent = "";
- saveBtn.disabled = true;
- try {
- const res = await apiFetch(`/resources/${resourceId}/repo/file`, {
- method: "PUT",
- body: { ref: currentRef, path: selectedFilePath, content: ta.value, message: msgInput.value.trim() },
- });
- selectedFileContent = ta.value;
- msg.textContent = `提交成功:${String(res.commit || "").slice(0, 10)}`;
- await loadTree();
- setTimeout(closeRepoModal, 600);
- } catch (e) {
- msg.textContent = `提交失败:${e.detail?.error || e.status || "unknown"}${e.detail?.message ? `\n${e.detail.message}` : ""}`;
- } finally {
- saveBtn.disabled = false;
- }
- });
- openRepoModal(
- "在线编辑",
- [
- el("div", { style: "display: flex; flex-direction: column; gap: 16px;" },
- el("div", { style: "display: flex; flex-direction: column; gap: 8px;" }, el("div", { class: "label", style: "font-weight: 600;" }, "文件路径"), pathText),
- el("div", { style: "display: flex; flex-direction: column; gap: 8px;" }, el("div", { class: "label", style: "font-weight: 600;" }, "提交信息"), msgInput),
- el("div", { style: "display: flex; flex-direction: column; gap: 8px;" }, el("div", { class: "label", style: "font-weight: 600;" }, "文件内容"), ta),
- msg
- )
- ],
- [el("button", { class: "btn", onclick: closeRepoModal }, "取消"), saveBtn],
- "ri-edit-line"
- );
- }
- async function deleteFile() {
- if (!requireBranchOrToast()) return;
- if (!requireSelectedFileOrToast()) return;
- Swal.fire({
- title: '确认删除?',
- text: `您即将删除文件:${selectedFilePath}`,
- icon: 'warning',
- input: 'text',
- inputPlaceholder: '提交信息,例如:Delete file',
- showCancelButton: true,
- confirmButtonColor: '#d33',
- cancelButtonColor: 'var(--border)',
- confirmButtonText: '<i class="ri-delete-bin-line"></i> 确认删除',
- cancelButtonText: '取消',
- showLoaderOnConfirm: true,
- customClass: {
- cancelButton: 'btn',
- confirmButton: 'btn btn-danger'
- },
- preConfirm: async (message) => {
- try {
- const res = await apiFetch(`/resources/${resourceId}/repo/file`, {
- method: "DELETE",
- body: { ref: currentRef, path: selectedFilePath, message: (message || "").trim() },
- });
- return res;
- } catch (e) {
- Swal.showValidationMessage(`删除失败:${e.detail?.error || e.status || "unknown"}${e.detail?.message ? `<br>${e.detail.message}` : ""}`);
- }
- },
- allowOutsideClick: () => !Swal.isLoading()
- }).then(async (result) => {
- if (result.isConfirmed) {
- selectedFilePath = "";
- selectedFileContent = "";
- Swal.fire({
- title: '删除成功!',
- text: `提交 ID:${String(result.value.commit || "").slice(0, 10)}`,
- icon: 'success',
- timer: 1500,
- showConfirmButton: false
- });
- await loadTree();
- }
- });
- }
- createBtn.addEventListener("click", createFile);
- editBtn.addEventListener("click", editFile);
- delBtn.addEventListener("click", deleteFile);
- toolbar.insertBefore(btnGroup(createBtn, editBtn, delBtn), commitsBtn);
- }
- try {
- await loadDetail();
- } catch (e) {
- root.innerHTML = "";
- root.appendChild(el("div", { class: "card" }, `加载失败:${e.detail?.error || e.status || "unknown"}`));
- return;
- }
- await loadMe();
- try {
- await loadRefs();
- await loadTree();
- } catch (e) {
- breadcrumb.textContent = "仓库加载失败";
- treeEl.innerHTML = "";
- treeEl.appendChild(el("div", { class: "card", style: "margin: 8px; padding: 16px; border-radius: 12px;" }, `加载失败:${e.detail?.error || e.status || "unknown"}`));
- fileContent.textContent = "";
- }
- }
- async function pageAdminLogin() {
- const username = document.getElementById("username");
- const password = document.getElementById("password");
- const btn = document.getElementById("adminLoginBtn");
- const msg = document.getElementById("msg");
- btn.addEventListener("click", async () => {
- msg.textContent = "";
- try {
- await apiFetch("/admin/auth/login", {
- method: "POST",
- body: { username: username.value.trim(), password: password.value },
- });
- window.location.href = "/ui/admin";
- } catch (e) {
- msg.textContent = `登录失败:${e.detail?.error || e.status || "unknown"}`;
- }
- });
- }
- function renderJsonCard(title, obj) {
- return el("div", { class: "card" }, el("div", {}, title), el("pre", { class: "code" }, JSON.stringify(obj, null, 2)));
- }
- async function pageAdmin() {
- const overviewRefreshBtn = document.getElementById("overviewRefreshBtn");
- const overviewUpdatedAt = document.getElementById("overviewUpdatedAt");
- const ovUsersTotal = document.getElementById("ovUsersTotal");
- const ovUsersSub = document.getElementById("ovUsersSub");
- const ovResourcesTotal = document.getElementById("ovResourcesTotal");
- const ovResourcesSub = document.getElementById("ovResourcesSub");
- const ovOrdersTotal = document.getElementById("ovOrdersTotal");
- const ovOrdersSub = document.getElementById("ovOrdersSub");
- const ovRevenueTotal = document.getElementById("ovRevenueTotal");
- const ovRevenueSub = document.getElementById("ovRevenueSub");
- const ovDownloadsTotal = document.getElementById("ovDownloadsTotal");
- const ovDownloadsSub = document.getElementById("ovDownloadsSub");
- const ovMessagesTotal = document.getElementById("ovMessagesTotal");
- const ovMessagesSub = document.getElementById("ovMessagesSub");
- const ovSystemInfo = document.getElementById("ovSystemInfo");
- const createPlanOpenBtn = document.getElementById("createPlanOpenBtn");
- const createResOpenBtn = document.getElementById("createResOpenBtn");
- const resQ = document.getElementById("resQ");
- const resTypeFilter = document.getElementById("resTypeFilter");
- const resStatusFilter = document.getElementById("resStatusFilter");
- const resSearchBtn = document.getElementById("resSearchBtn");
- const resPrevPage = document.getElementById("resPrevPage");
- const resNextPage = document.getElementById("resNextPage");
- const resPageInfo = document.getElementById("resPageInfo");
- const uploadsQ = document.getElementById("uploadsQ");
- const uploadsFilterAll = document.getElementById("uploadsFilterAll");
- const uploadsFilterUnused = document.getElementById("uploadsFilterUnused");
- const uploadsFilterUsed = document.getElementById("uploadsFilterUsed");
- const uploadsRefreshBtn = document.getElementById("uploadsRefreshBtn");
- const uploadsUploadBtn = document.getElementById("uploadsUploadBtn");
- const uploadsFile = document.getElementById("uploadsFile");
- const uploadsCleanupBtn = document.getElementById("uploadsCleanupBtn");
- const uploadsStats = document.getElementById("uploadsStats");
- const uploadTbody = document.querySelector("#uploadTable tbody");
- const orderQ = document.getElementById("orderQ");
- const orderStatusFilter = document.getElementById("orderStatusFilter");
- const orderCreateBtn = document.getElementById("orderCreateBtn");
- const orderRefreshBtn = document.getElementById("orderRefreshBtn");
- const orderPrevPage = document.getElementById("orderPrevPage");
- const orderNextPage = document.getElementById("orderNextPage");
- const orderPageInfo = document.getElementById("orderPageInfo");
- const userQ = document.getElementById("userQ");
- const userStatusFilter = document.getElementById("userStatusFilter");
- const userVipFilter = document.getElementById("userVipFilter");
- const userSearchBtn = document.getElementById("userSearchBtn");
- const userPrevPage = document.getElementById("userPrevPage");
- const userNextPage = document.getElementById("userNextPage");
- const userPageInfo = document.getElementById("userPageInfo");
- const dlQ = document.getElementById("dlQ");
- const dlTypeFilter = document.getElementById("dlTypeFilter");
- const dlStateFilter = document.getElementById("dlStateFilter");
- const dlSearchBtn = document.getElementById("dlSearchBtn");
- const dlPrevPage = document.getElementById("dlPrevPage");
- const dlNextPage = document.getElementById("dlNextPage");
- const dlPageInfo = document.getElementById("dlPageInfo");
- const msgQ = document.getElementById("msgQ");
- const msgReadFilter = document.getElementById("msgReadFilter");
- const msgSenderFilter = document.getElementById("msgSenderFilter");
- const msgSearchBtn = document.getElementById("msgSearchBtn");
- const msgSendBtn = document.getElementById("msgSendBtn");
- const msgBroadcastBtn = document.getElementById("msgBroadcastBtn");
- const msgPrevPage = document.getElementById("msgPrevPage");
- const msgNextPage = document.getElementById("msgNextPage");
- const msgPageInfo = document.getElementById("msgPageInfo");
- const msgTbody = document.querySelector("#msgTable tbody");
- const settingsRefreshBtn = document.getElementById("settingsRefreshBtn");
- const settingsSaveBtn = document.getElementById("settingsSaveBtn");
- const cfgGogsSaveBtn = document.getElementById("cfgGogsSaveBtn");
- const cfgGogsResetBtn = document.getElementById("cfgGogsResetBtn");
- const cfgGogsBaseUrl = document.getElementById("cfgGogsBaseUrl");
- const cfgGogsToken = document.getElementById("cfgGogsToken");
- const cfgClearGogsToken = document.getElementById("cfgClearGogsToken");
- const cfgPaySaveBtn = document.getElementById("cfgPaySaveBtn");
- const cfgPayResetBtn = document.getElementById("cfgPayResetBtn");
- const cfgPayProvider = document.getElementById("cfgPayProvider");
- const cfgEnableMockPay = document.getElementById("cfgEnableMockPay");
- const cfgPayApiKey = document.getElementById("cfgPayApiKey");
- const cfgClearPayApiKey = document.getElementById("cfgClearPayApiKey");
- const cfgAlipayFields = document.getElementById("cfgAlipayFields");
- const cfgAlipayAppId = document.getElementById("cfgAlipayAppId");
- const cfgAlipayGateway = document.getElementById("cfgAlipayGateway");
- const cfgAlipayNotifyUrl = document.getElementById("cfgAlipayNotifyUrl");
- const cfgAlipayReturnUrl = document.getElementById("cfgAlipayReturnUrl");
- const cfgAlipayUseCurrentNotify = document.getElementById("cfgAlipayUseCurrentNotify");
- const cfgAlipayUseCurrentReturn = document.getElementById("cfgAlipayUseCurrentReturn");
- const cfgAlipayPrivateKey = document.getElementById("cfgAlipayPrivateKey");
- const cfgClearAlipayPrivateKey = document.getElementById("cfgClearAlipayPrivateKey");
- const cfgAlipayPublicKey = document.getElementById("cfgAlipayPublicKey");
- const cfgClearAlipayPublicKey = document.getElementById("cfgClearAlipayPublicKey");
- const cfgShowAlipayPrivateKey = document.getElementById("cfgShowAlipayPrivateKey");
- const cfgShowAlipayPublicKey = document.getElementById("cfgShowAlipayPublicKey");
- const cfgLlmSaveBtn = document.getElementById("cfgLlmSaveBtn");
- const cfgLlmResetBtn = document.getElementById("cfgLlmResetBtn");
- const cfgLlmProvider = document.getElementById("cfgLlmProvider");
- const cfgLlmBaseUrl = document.getElementById("cfgLlmBaseUrl");
- const cfgLlmModel = document.getElementById("cfgLlmModel");
- const cfgLlmApiKey = document.getElementById("cfgLlmApiKey");
- const cfgClearLlmApiKey = document.getElementById("cfgClearLlmApiKey");
- const cfgDbActive = document.getElementById("cfgDbActive");
- const cfgDbSaveBtn = document.getElementById("cfgDbSaveBtn");
- const cfgDbResetBtn = document.getElementById("cfgDbResetBtn");
- const cfgMysqlHost = document.getElementById("cfgMysqlHost");
- const cfgMysqlPort = document.getElementById("cfgMysqlPort");
- const cfgMysqlUser = document.getElementById("cfgMysqlUser");
- const cfgMysqlPassword = document.getElementById("cfgMysqlPassword");
- const cfgClearMysqlPassword = document.getElementById("cfgClearMysqlPassword");
- const cfgMysqlDatabase = document.getElementById("cfgMysqlDatabase");
- const cfgMysqlTestBtn = document.getElementById("cfgMysqlTestBtn");
- const cfgDbSwitchMysqlBtn = document.getElementById("cfgDbSwitchMysqlBtn");
- const cfgDbSwitchSqliteBtn = document.getElementById("cfgDbSwitchSqliteBtn");
- const settingsMsg = document.getElementById("settingsMsg");
- const cfgSearch = document.getElementById("cfgSearch");
- const cfgGroupNav = document.getElementById("cfgGroupNav");
- const settingsGroupsWrap = document.getElementById("settingsGroups");
- const adminLogoutBtn = document.getElementById("adminLogoutBtn");
- const menu = document.getElementById("adminMenu");
- const contentTitle = document.getElementById("contentTitle");
- const modalBackdrop = document.getElementById("adminModalBackdrop");
- const modalTitle = document.getElementById("adminModalTitle");
- const modalHeaderActions = document.getElementById("adminModalHeaderActions");
- const modalClose = document.getElementById("adminModalClose");
- const modalBody = document.getElementById("adminModalBody");
- const modalFooter = document.getElementById("adminModalFooter");
- const modalEl = modalBackdrop ? modalBackdrop.querySelector(".modal") : null;
- let currentModalOnResize = null;
- let currentModalBeforeClose = null;
- let currentModalOnKeydown = null;
- const planMap = new Map();
- const resourceMap = new Map();
- const userMap = new Map();
- const orderMap = new Map();
- const downloadLogMap = new Map();
- const messageMap = new Map();
- const resState = { page: 1, pageSize: 20, total: 0 };
- const userState = { page: 1, pageSize: 20, total: 0 };
- const orderState = { page: 1, pageSize: 20, total: 0 };
- const downloadLogState = { page: 1, pageSize: 20, total: 0 };
- const messageState = { page: 1, pageSize: 20, total: 0 };
- const uploadsState = { filter: "all" };
- let lastSettingsSnapshot = null;
- function formatBytes(bytes) {
- const n = Number(bytes || 0);
- if (!Number.isFinite(n) || n <= 0) return "0 B";
- const units = ["B", "KB", "MB", "GB", "TB"];
- let v = n;
- let i = 0;
- while (v >= 1024 && i < units.length - 1) {
- v /= 1024;
- i += 1;
- }
- const fixed = i === 0 ? 0 : v >= 10 ? 1 : 2;
- return `${v.toFixed(fixed)} ${units[i]}`;
- }
- async function copyText(text) {
- const s = String(text || "");
- if (!s) return;
- try {
- await navigator.clipboard.writeText(s);
- showToastSuccess("已复制链接");
- return;
- } catch (e) {}
- const ta = el("textarea", { style: "position:fixed; left:-9999px; top:-9999px;" }, s);
- document.body.appendChild(ta);
- ta.select();
- try {
- document.execCommand("copy");
- showToastSuccess("已复制链接");
- } catch (e) {
- showToastError("复制失败");
- } finally {
- ta.remove();
- }
- }
- function setUploadsFilter(next) {
- uploadsState.filter = next;
- [uploadsFilterAll, uploadsFilterUnused, uploadsFilterUsed].forEach((b) => b.classList.remove("active"));
- if (next === "unused") uploadsFilterUnused.classList.add("active");
- else if (next === "used") uploadsFilterUsed.classList.add("active");
- else uploadsFilterAll.classList.add("active");
- }
- async function loadUploads() {
- uploadsStats.textContent = "";
- uploadTbody.innerHTML = "";
- try {
- const params = new URLSearchParams();
- const q = (uploadsQ.value || "").trim();
- if (q) params.set("q", q);
- if (uploadsState.filter === "unused") params.set("used", "unused");
- if (uploadsState.filter === "used") params.set("used", "used");
- const resp = await apiFetch(`/admin/uploads?${params.toString()}`);
- const s = resp.stats || {};
- uploadsStats.textContent = [
- `共 ${s.totalCount ?? 0} 个文件(${formatBytes(s.totalBytes ?? 0)})`,
- `已引用 ${s.usedCount ?? 0} 个(${formatBytes(s.usedBytes ?? 0)})`,
- `未引用 ${s.unusedCount ?? 0} 个(${formatBytes(s.unusedBytes ?? 0)})`,
- ].join(" / ");
- const items = Array.isArray(resp.items) ? resp.items : [];
- items.forEach((it) => {
- const name = String(it.name || "");
- const url = String(it.url || "");
- const used = Boolean(it.used);
- const kind = String(it.kind || "file");
- const preview =
- kind === "image"
- ? el("img", { class: "upload-thumb", src: url, alt: name, loading: "lazy" })
- : kind === "video"
- ? badge("视频", "badge-warning")
- : badge("文件");
- const usedBadge = used ? badge("已引用", "badge-success") : badge("未引用", "badge");
- const tr = el(
- "tr",
- {},
- el("td", {}, preview),
- el("td", {}, el("div", { class: "upload-name" }, name), el("div", { class: "muted upload-url" }, url)),
- el("td", {}, formatBytes(it.bytes || 0)),
- el("td", {}, formatDateTime(it.mtime || 0)),
- el("td", {}, usedBadge),
- el(
- "td",
- {},
- btnGroup(
- el("button", { class: "btn btn-sm", onclick: () => copyText(url) }, "复制链接"),
- el(
- "button",
- {
- class: "btn btn-sm btn-danger",
- onclick: async () => {
- const r = await Swal.fire({
- title: "删除文件?",
- text: `将删除:${name}`,
- icon: "warning",
- showCancelButton: true,
- confirmButtonText: "删除",
- cancelButtonText: "取消",
- confirmButtonColor: "var(--danger)",
- });
- if (!r.isConfirmed) return;
- await apiFetch(`/admin/uploads/${encodeURIComponent(name)}`, { method: "DELETE" });
- showToastSuccess("已删除");
- await loadUploads();
- },
- },
- "删除"
- )
- )
- )
- );
- uploadTbody.appendChild(tr);
- });
- if (!items.length) renderEmptyRow(uploadTbody, 6, "暂无数据");
- } catch (e) {
- uploadsStats.textContent = `加载失败:${e.detail?.error || e.status || "unknown"}`;
- if (e.status === 401) window.location.href = "/ui/admin/login";
- }
- }
- async function loadSettings() {
- settingsMsg.textContent = "";
- cfgGogsToken.value = "";
- cfgClearGogsToken.checked = false;
- cfgPayApiKey.value = "";
- cfgClearPayApiKey.checked = false;
- if (cfgAlipayPrivateKey) cfgAlipayPrivateKey.value = "";
- if (cfgClearAlipayPrivateKey) cfgClearAlipayPrivateKey.checked = false;
- if (cfgShowAlipayPrivateKey) cfgShowAlipayPrivateKey.checked = false;
- if (cfgAlipayPrivateKey) cfgAlipayPrivateKey.classList.remove("is-revealed");
- if (cfgAlipayPublicKey) cfgAlipayPublicKey.value = "";
- if (cfgClearAlipayPublicKey) cfgClearAlipayPublicKey.checked = false;
- if (cfgShowAlipayPublicKey) cfgShowAlipayPublicKey.checked = false;
- if (cfgAlipayPublicKey) cfgAlipayPublicKey.classList.remove("is-revealed");
- cfgLlmApiKey.value = "";
- cfgClearLlmApiKey.checked = false;
- if (cfgMysqlPassword) cfgMysqlPassword.value = "";
- if (cfgClearMysqlPassword) cfgClearMysqlPassword.checked = false;
- try {
- const resp = await apiFetch("/admin/settings");
- lastSettingsSnapshot = resp;
- cfgGogsBaseUrl.value = (resp.gogsBaseUrl || "").trim();
- if (resp.hasGogsToken) cfgGogsToken.placeholder = "已配置,留空保持不变";
- else cfgGogsToken.placeholder = "未配置,填写后保存";
- cfgPayProvider.value = (resp.payment?.provider || "MOCK").toUpperCase();
- cfgEnableMockPay.checked = Boolean(resp.payment?.enableMockPay);
- if (resp.payment?.hasApiKey) cfgPayApiKey.placeholder = "已配置,留空保持不变";
- else cfgPayApiKey.placeholder = "未配置,填写后保存";
- if (cfgAlipayAppId) cfgAlipayAppId.value = (resp.payment?.alipay?.appId || "").trim();
- if (cfgAlipayGateway) cfgAlipayGateway.value = (resp.payment?.alipay?.gateway || "").trim();
- if (cfgAlipayNotifyUrl) cfgAlipayNotifyUrl.value = (resp.payment?.alipay?.notifyUrl || "").trim();
- if (cfgAlipayReturnUrl) cfgAlipayReturnUrl.value = (resp.payment?.alipay?.returnUrl || "").trim();
- if (cfgAlipayPrivateKey) {
- if (resp.payment?.alipay?.hasPrivateKey) cfgAlipayPrivateKey.placeholder = "已配置,留空保持不变";
- else cfgAlipayPrivateKey.placeholder = "未配置,填写后保存";
- }
- if (cfgAlipayPublicKey) {
- if (resp.payment?.alipay?.hasPublicKey) cfgAlipayPublicKey.placeholder = "已配置,留空保持不变";
- else cfgAlipayPublicKey.placeholder = "未配置,填写后保存";
- }
- cfgLlmProvider.value = (resp.llm?.provider || "").trim();
- cfgLlmBaseUrl.value = (resp.llm?.baseUrl || "").trim();
- cfgLlmModel.value = (resp.llm?.model || "").trim();
- if (resp.llm?.hasApiKey) cfgLlmApiKey.placeholder = "已配置,留空保持不变";
- else cfgLlmApiKey.placeholder = "未配置,填写后保存";
- if (cfgMysqlHost) cfgMysqlHost.value = (resp.db?.mysql?.host || "").trim();
- if (cfgMysqlPort) cfgMysqlPort.value = String(resp.db?.mysql?.port ?? "").trim();
- if (cfgMysqlUser) cfgMysqlUser.value = (resp.db?.mysql?.user || "").trim();
- if (cfgMysqlDatabase) cfgMysqlDatabase.value = (resp.db?.mysql?.database || "").trim();
- if (cfgMysqlPassword) {
- if (resp.db?.mysql?.hasPassword) cfgMysqlPassword.placeholder = "已配置,留空保持不变";
- else cfgMysqlPassword.placeholder = "未配置,填写后保存";
- }
- if (cfgDbActive) cfgDbActive.textContent = `当前连接:${resp.db?.active || "-"}`;
- settingsMsg.textContent = [
- `Gogs Token:${resp.hasGogsToken ? "已配置" : "未配置"}`,
- `支付 Key:${resp.payment?.hasApiKey ? "已配置" : "未配置"}`,
- `支付宝私钥:${resp.payment?.alipay?.hasPrivateKey ? "已配置" : "未配置"}`,
- `支付宝公钥:${resp.payment?.alipay?.hasPublicKey ? "已配置" : "未配置"}`,
- `大模型 Key:${resp.llm?.hasApiKey ? "已配置" : "未配置"}`,
- `MySQL Password:${resp.db?.mysql?.hasPassword ? "已配置" : "未配置"}`,
- ].join(" / ");
- updatePayProviderVisibility();
- applySettingsFilter();
- } catch (e) {
- settingsMsg.textContent = `加载失败:${e.detail?.error || e.status || "unknown"}`;
- if (e.status === 401) window.location.href = "/ui/admin/login";
- }
- }
- function updatePayProviderVisibility() {
- if (!cfgAlipayFields || !cfgPayProvider) return;
- const p = String(cfgPayProvider.value || "").trim().toUpperCase();
- cfgAlipayFields.style.display = p === "ALIPAY" ? "" : "none";
- }
- function listSettingGroups() {
- if (!settingsGroupsWrap) return [];
- return Array.from(settingsGroupsWrap.querySelectorAll(".collapse.settings-group"));
- }
- function listVisibleSettingGroups() {
- const groups = listSettingGroups();
- if (!settingsGroupsWrap) return groups;
- if (settingsGroupsWrap.classList.contains("is-tabs")) {
- const active = groups.find((g) => g.classList.contains("is-active"));
- return active ? [active] : [];
- }
- return groups.filter((g) => g.style.display !== "none");
- }
- function setSettingGroupOpen(groupEl, open) {
- if (!groupEl) return;
- groupEl.setAttribute("data-open", open ? "1" : "0");
- }
- function setActiveSettingsGroup(targetSel, opts) {
- if (!settingsGroupsWrap) return;
- const target = String(targetSel || "").trim();
- if (!target) return;
- const groups = listSettingGroups();
- groups.forEach((g) => {
- g.classList.remove("is-active");
- g.style.display = "";
- });
- const el = document.querySelector(target);
- if (!el) return;
- settingsGroupsWrap.classList.add("is-tabs");
- settingsGroupsWrap.classList.remove("is-searching");
- el.classList.add("is-active");
- setSettingNavActive(target);
- try {
- localStorage.setItem("adminSettingsActiveGroup", target);
- } catch (e) {}
- if (opts && opts.open) setSettingGroupOpen(el, true);
- if (opts && opts.scroll) el.scrollIntoView({ block: "start", behavior: "smooth" });
- }
- function getActiveSettingsGroupSel() {
- try {
- const v = localStorage.getItem("adminSettingsActiveGroup");
- if (v && document.querySelector(v)) return v;
- } catch (e) {}
- const first = listSettingGroups().find((g) => g && g.id);
- return first ? `#${first.id}` : "#cfgGroupGogs";
- }
- function setSettingNavActive(targetSel) {
- if (!cfgGroupNav) return;
- cfgGroupNav.querySelectorAll(".btn").forEach((b) => b.classList.remove("active"));
- const btn = cfgGroupNav.querySelector(`.btn[data-target="${targetSel}"]`);
- if (btn) btn.classList.add("active");
- }
- function applySettingsFilter() {
- const q = (cfgSearch && cfgSearch.value ? cfgSearch.value : "").trim().toLowerCase();
- const groups = listSettingGroups();
- if (!settingsGroupsWrap) return;
- if (!q) {
- settingsGroupsWrap.classList.remove("is-searching");
- setActiveSettingsGroup(getActiveSettingsGroupSel(), { open: true, scroll: false });
- return;
- }
- settingsGroupsWrap.classList.remove("is-tabs");
- settingsGroupsWrap.classList.add("is-searching");
- groups.forEach((g) => {
- g.classList.remove("is-active");
- const text = (g.textContent || "").toLowerCase();
- const show = text.includes(q);
- g.style.display = show ? "" : "none";
- if (show) setSettingGroupOpen(g, true);
- });
- }
- async function saveSettings() {
- settingsMsg.textContent = "";
- try {
- const mysqlPayload =
- cfgMysqlHost || cfgMysqlPort || cfgMysqlUser || cfgMysqlPassword || cfgMysqlDatabase || cfgClearMysqlPassword
- ? {
- host: cfgMysqlHost ? cfgMysqlHost.value.trim() : "",
- port: cfgMysqlPort ? cfgMysqlPort.value.trim() : "",
- user: cfgMysqlUser ? cfgMysqlUser.value.trim() : "",
- password: cfgMysqlPassword ? cfgMysqlPassword.value.trim() : "",
- clearPassword: cfgClearMysqlPassword ? cfgClearMysqlPassword.checked : false,
- database: cfgMysqlDatabase ? cfgMysqlDatabase.value.trim() : "",
- }
- : null;
- await apiFetch("/admin/settings", {
- method: "PUT",
- body: Object.assign(
- {
- gogsBaseUrl: cfgGogsBaseUrl.value.trim(),
- gogsToken: cfgGogsToken.value.trim(),
- clearGogsToken: cfgClearGogsToken.checked,
- payment: {
- provider: cfgPayProvider.value,
- enableMockPay: cfgEnableMockPay.checked,
- apiKey: cfgPayApiKey.value.trim(),
- clearApiKey: cfgClearPayApiKey.checked,
- alipay: {
- appId: cfgAlipayAppId ? cfgAlipayAppId.value.trim() : "",
- gateway: cfgAlipayGateway ? cfgAlipayGateway.value.trim() : "",
- notifyUrl: cfgAlipayNotifyUrl ? cfgAlipayNotifyUrl.value.trim() : "",
- returnUrl: cfgAlipayReturnUrl ? cfgAlipayReturnUrl.value.trim() : "",
- privateKey: cfgAlipayPrivateKey ? cfgAlipayPrivateKey.value.trim() : "",
- clearPrivateKey: cfgClearAlipayPrivateKey ? cfgClearAlipayPrivateKey.checked : false,
- publicKey: cfgAlipayPublicKey ? cfgAlipayPublicKey.value.trim() : "",
- clearPublicKey: cfgClearAlipayPublicKey ? cfgClearAlipayPublicKey.checked : false,
- },
- },
- llm: {
- provider: cfgLlmProvider.value.trim(),
- baseUrl: cfgLlmBaseUrl.value.trim(),
- model: cfgLlmModel.value.trim(),
- apiKey: cfgLlmApiKey.value.trim(),
- clearApiKey: cfgClearLlmApiKey.checked,
- },
- },
- mysqlPayload ? { mysql: mysqlPayload } : {}
- ),
- });
- await loadSettings();
- settingsMsg.textContent = "保存成功";
- } catch (e) {
- settingsMsg.textContent = `保存失败:${e.detail?.error || e.status || "unknown"}`;
- if (e.status === 401) window.location.href = "/ui/admin/login";
- }
- }
- async function saveSettingsPartial(body) {
- settingsMsg.textContent = "";
- try {
- await apiFetch("/admin/settings", { method: "PUT", body });
- await loadSettings();
- settingsMsg.textContent = "保存成功";
- } catch (e) {
- settingsMsg.textContent = `保存失败:${e.detail?.error || e.status || "unknown"}`;
- if (e.status === 401) window.location.href = "/ui/admin/login";
- }
- }
- async function testMysqlConnection() {
- settingsMsg.textContent = "";
- try {
- const body = {
- host: cfgMysqlHost ? cfgMysqlHost.value.trim() : "",
- port: cfgMysqlPort ? cfgMysqlPort.value.trim() : "",
- user: cfgMysqlUser ? cfgMysqlUser.value.trim() : "",
- database: cfgMysqlDatabase ? cfgMysqlDatabase.value.trim() : "",
- };
- const pwd = cfgMysqlPassword ? cfgMysqlPassword.value.trim() : "";
- if (pwd) body.password = pwd;
- const resp = await apiFetch("/admin/mysql/test", { method: "POST", body });
- if (resp.ok && resp.createdDatabase) {
- settingsMsg.textContent = "MySQL:连接成功(已自动创建库)";
- } else {
- settingsMsg.textContent = resp.ok ? "MySQL:连接成功" : "MySQL:连接失败";
- }
- } catch (e) {
- const errno = e.detail?.errno ? ` errno=${e.detail.errno}` : "";
- settingsMsg.textContent = `MySQL:连接失败(${e.detail?.error || e.status || "unknown"}${errno})`;
- if (e.status === 401) window.location.href = "/ui/admin/login";
- }
- }
- async function switchDatabase(target, force) {
- settingsMsg.textContent = "";
- if (target === "mysql") {
- const host = cfgMysqlHost ? cfgMysqlHost.value.trim() : "";
- const user = cfgMysqlUser ? cfgMysqlUser.value.trim() : "";
- const database = cfgMysqlDatabase ? cfgMysqlDatabase.value.trim() : "";
- if (!host || !user || !database) {
- await Swal.fire({
- title: "MySQL 参数不完整",
- text: "请先填写 Host / User / Database(可选填写 Port / Password),再切换",
- icon: "error",
- });
- return;
- }
- }
- const r = await Swal.fire({
- title: "切换数据库?",
- text: target === "mysql" ? "将迁移数据到 MySQL,并切换读写到 MySQL" : "将迁移数据到 SQLite,并切换读写到 SQLite",
- icon: "warning",
- showCancelButton: true,
- confirmButtonText: "继续",
- cancelButtonText: "取消",
- confirmButtonColor: "var(--danger)",
- });
- if (!r.isConfirmed) return;
- try {
- const body = { target, force: Boolean(force) };
- if (target === "mysql") {
- const mysql = {
- host: cfgMysqlHost ? cfgMysqlHost.value.trim() : "",
- port: cfgMysqlPort ? cfgMysqlPort.value.trim() : "",
- user: cfgMysqlUser ? cfgMysqlUser.value.trim() : "",
- database: cfgMysqlDatabase ? cfgMysqlDatabase.value.trim() : "",
- clearPassword: cfgClearMysqlPassword ? cfgClearMysqlPassword.checked : false,
- };
- const pwd = cfgMysqlPassword ? cfgMysqlPassword.value.trim() : "";
- if (pwd) mysql.password = pwd;
- body.mysql = mysql;
- }
- const resp = await apiFetch("/admin/db/switch", { method: "POST", body });
- settingsMsg.textContent = `切换成功:${resp.from} → ${resp.to}`;
- await loadSettings();
- } catch (e) {
- if (e.detail?.error === "target_not_empty") {
- const r2 = await Swal.fire({
- title: "目标库非空,是否覆盖?",
- text: "继续将清空目标库的表数据,然后迁移并切换(不可逆)",
- icon: "warning",
- showCancelButton: true,
- confirmButtonText: "覆盖并切换",
- cancelButtonText: "取消",
- confirmButtonColor: "var(--danger)",
- });
- if (!r2.isConfirmed) return;
- await switchDatabase(target, true);
- return;
- }
- settingsMsg.textContent = `切换失败:${e.detail?.error || e.status || "unknown"}`;
- if (e.status === 401) window.location.href = "/ui/admin/login";
- }
- }
- async function fillRefSelect(owner, repo, selectEl, prefer) {
- selectEl.innerHTML = "";
- selectEl.appendChild(el("option", { value: "AUTO" }, "AUTO(默认分支)"));
- const [branches, tags] = await Promise.all([
- apiFetch(`/admin/gogs/branches?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`),
- apiFetch(`/admin/gogs/tags?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`),
- ]);
- const branchGroup = document.createElement("optgroup");
- branchGroup.label = "分支";
- (branches.items || []).forEach((b) => {
- branchGroup.appendChild(el("option", { value: b.name }, b.name));
- });
- selectEl.appendChild(branchGroup);
- const tagGroup = document.createElement("optgroup");
- tagGroup.label = "标签";
- (tags.items || []).forEach((t) => {
- tagGroup.appendChild(el("option", { value: t.name }, t.name));
- });
- selectEl.appendChild(tagGroup);
- if (prefer) selectEl.value = prefer;
- }
- async function closeModal(force) {
- const isForce = force === true;
- if (!isForce && currentModalBeforeClose) {
- try {
- const ok = await currentModalBeforeClose();
- if (!ok) return;
- } catch (e) {}
- }
- if (currentModalOnKeydown) {
- try {
- document.removeEventListener("keydown", currentModalOnKeydown, true);
- } catch (e) {}
- }
- modalBackdrop.style.display = "none";
- modalTitle.textContent = "";
- modalBody.innerHTML = "";
- modalFooter.innerHTML = "";
- if (modalHeaderActions) modalHeaderActions.innerHTML = "";
- if (modalEl) modalEl.removeAttribute("data-size");
- currentModalOnResize = null;
- currentModalBeforeClose = null;
- currentModalOnKeydown = null;
- }
- function openModal(title, bodyNodes, footerNodes, icon = "ri-settings-4-line", opts = {}) {
- modalTitle.innerHTML = "";
- modalTitle.appendChild(el("i", { class: icon }));
- modalTitle.appendChild(document.createTextNode(title));
- modalBody.innerHTML = "";
- modalFooter.innerHTML = "";
- if (modalHeaderActions) modalHeaderActions.innerHTML = "";
- if (modalEl) modalEl.removeAttribute("data-size");
- currentModalOnResize = typeof opts.onResize === "function" ? opts.onResize : null;
- currentModalBeforeClose = typeof opts.beforeClose === "function" ? opts.beforeClose : null;
- if (currentModalOnKeydown) {
- try {
- document.removeEventListener("keydown", currentModalOnKeydown, true);
- } catch (e) {}
- currentModalOnKeydown = null;
- }
- if (typeof opts.onKeydown === "function") {
- currentModalOnKeydown = (evt) => opts.onKeydown(evt);
- document.addEventListener("keydown", currentModalOnKeydown, true);
- }
- bodyNodes.forEach((n) => modalBody.appendChild(n));
- footerNodes.forEach((n) => modalFooter.appendChild(n));
- modalBackdrop.style.display = "";
- if (modalEl && opts.resizable && modalHeaderActions) {
- let preferredSize = (opts.size || "").toString().trim();
- if (!preferredSize) {
- try {
- preferredSize = (localStorage.getItem("adminModalSize") || "").toString().trim();
- } catch (e) {}
- }
- if (!preferredSize) preferredSize = "sm";
- const btnSm = el("button", { type: "button", class: "btn btn-sm btn-toggle" }, "小");
- const btnLg = el("button", { type: "button", class: "btn btn-sm btn-toggle" }, "大");
- function applySize(size) {
- const s = size === "sm" ? "sm" : "lg";
- modalEl.setAttribute("data-size", s);
- btnSm.classList.toggle("active", s === "sm");
- btnLg.classList.toggle("active", s === "lg");
- try {
- localStorage.setItem("adminModalSize", s);
- } catch (e) {}
- if (currentModalOnResize) currentModalOnResize(s);
- }
- btnSm.addEventListener("click", () => applySize("sm"));
- btnLg.addEventListener("click", () => applySize("lg"));
- modalHeaderActions.appendChild(el("div", { class: "btn-group" }, btnSm, btnLg));
- applySize(preferredSize);
- } else if (modalEl && currentModalOnResize) {
- const s = (modalEl.getAttribute("data-size") || "sm").toString();
- currentModalOnResize(s);
- } else if (modalEl && opts.size) {
- modalEl.setAttribute("data-size", String(opts.size));
- }
- }
- modalClose.addEventListener("click", () => closeModal());
- modalBackdrop.addEventListener("click", (evt) => {
- if (evt.target === modalBackdrop) closeModal();
- });
- function insertAtCursor(textarea, text) {
- const start = textarea.selectionStart || 0;
- const end = textarea.selectionEnd || 0;
- const before = textarea.value.slice(0, start);
- const after = textarea.value.slice(end);
- textarea.value = `${before}${text}${after}`;
- const pos = start + text.length;
- textarea.setSelectionRange(pos, pos);
- textarea.focus();
- try {
- textarea.dispatchEvent(new Event("input", { bubbles: true }));
- } catch (e) {}
- }
- function parseRepoInput(raw) {
- let s = (raw || "").trim();
- if (!s) return null;
- s = s.replace(/\.git$/i, "");
- if (s.includes("://")) {
- try {
- const u = new URL(s);
- s = (u.pathname || "").replace(/^\/+/, "");
- } catch (e) {
- return null;
- }
- }
- const sshIdx = s.indexOf(":");
- if (s.startsWith("git@") && sshIdx !== -1) {
- s = s.slice(sshIdx + 1);
- }
- s = s.replace(/^\/+/, "");
- const parts = s.split("/").filter(Boolean);
- if (parts.length < 2) return null;
- return { owner: parts[0], repo: parts[1] };
- }
- function buildMarkdownEditor({ initialValue, msgEl }) {
- const summaryInput = el("textarea", {
- class: "input md-editor-input",
- style: "min-height:260px; resize:vertical",
- placeholder: "简介(Markdown,支持粘贴/拖拽上传图片/视频)",
- value: initialValue || "",
- });
- const syncReadme = el("input", { type: "checkbox" });
- syncReadme.checked = true;
- attachPasteUpload(summaryInput, msgEl);
- const tocWrap = el("div", { class: "md-toc", style: "display:none" });
- const tocTitle = el("div", { class: "md-toc-title" }, el("span", {}, "大纲"), el("span", { class: "muted", style: "font-weight:650" }, "点击跳转"));
- const tocItems = el("div", { class: "md-toc-items" });
- tocWrap.appendChild(tocTitle);
- tocWrap.appendChild(tocItems);
- const mdContent = el("div", { html: "" });
- const mdPreview = el("div", { class: "md md-editor-preview", html: "" }, tocWrap, mdContent);
- let showToc = false;
- function slugify(text) {
- const raw = (text || "").toString().trim().toLowerCase();
- const s = raw
- .replace(/[\s]+/g, "-")
- .replace(/[^\u4e00-\u9fa5a-z0-9\-_]/g, "")
- .replace(/-+/g, "-")
- .replace(/^-|-$/g, "");
- return s || "h";
- }
- function buildToc() {
- tocItems.innerHTML = "";
- const headings = Array.from(mdContent.querySelectorAll("h1,h2,h3,h4,h5,h6"));
- if (!showToc || !headings.length) {
- tocWrap.style.display = "none";
- return;
- }
- tocWrap.style.display = "";
- const used = new Map();
- headings.forEach((h) => {
- const level = Number(String(h.tagName || "H2").replace("H", "")) || 2;
- const base = slugify(h.textContent || "");
- const n = (used.get(base) || 0) + 1;
- used.set(base, n);
- const id = n === 1 ? base : `${base}-${n}`;
- if (!h.id) h.id = id;
- const btn = el("button", { type: "button", class: "md-toc-item", style: `padding-left:${Math.max(0, (level - 1) * 12)}px` }, h.textContent || "");
- btn.addEventListener("click", (evt) => {
- evt.preventDefault();
- try {
- h.scrollIntoView({ behavior: "smooth", block: "start" });
- } catch (e) {
- h.scrollIntoView();
- }
- });
- tocItems.appendChild(btn);
- });
- }
- function updateMdPreview() {
- mdContent.innerHTML = renderMarkdown(summaryInput.value);
- buildToc();
- }
- updateMdPreview();
- summaryInput.addEventListener("input", updateMdPreview);
- function wrapSelection(textarea, left, right) {
- const start = textarea.selectionStart || 0;
- const end = textarea.selectionEnd || 0;
- const value = textarea.value || "";
- const selected = value.slice(start, end);
- const next = `${value.slice(0, start)}${left}${selected}${right}${value.slice(end)}`;
- textarea.value = next;
- const nextStart = start + left.length;
- const nextEnd = nextStart + selected.length;
- textarea.setSelectionRange(nextStart, nextEnd);
- textarea.focus();
- try {
- textarea.dispatchEvent(new Event("input", { bubbles: true }));
- } catch (e) {}
- }
- function prefixLines(textarea, prefix) {
- const start = textarea.selectionStart || 0;
- const end = textarea.selectionEnd || 0;
- const value = textarea.value || "";
- const selected = value.slice(start, end);
- const text = selected || "";
- const nextBlock = text
- .split("\n")
- .map((line) => (line ? `${prefix}${line}` : prefix.trimEnd()))
- .join("\n");
- const insertText = selected ? nextBlock : `\n${prefix}`;
- insertAtCursor(textarea, insertText);
- }
- function mdBtn(label, title, onClick) {
- const b = el("button", { type: "button", class: "btn btn-sm", title }, label);
- b.addEventListener("click", (evt) => {
- evt.preventDefault();
- onClick();
- });
- return b;
- }
- function insertSnippet(text, cursorRelStart, cursorRelEnd) {
- const start = summaryInput.selectionStart || 0;
- const end = summaryInput.selectionEnd || 0;
- const before = summaryInput.value.slice(0, start);
- const after = summaryInput.value.slice(end);
- summaryInput.value = `${before}${text}${after}`;
- const s = start + (cursorRelStart == null ? text.length : cursorRelStart);
- const e = start + (cursorRelEnd == null ? (cursorRelStart == null ? text.length : cursorRelStart) : cursorRelEnd);
- summaryInput.setSelectionRange(s, e);
- summaryInput.focus();
- try {
- summaryInput.dispatchEvent(new Event("input", { bubbles: true }));
- } catch (e2) {}
- }
- const viewEditBtn = el("button", { type: "button", class: "btn btn-sm btn-toggle active" }, "编辑");
- const viewPreviewBtn = el("button", { type: "button", class: "btn btn-sm btn-toggle" }, "预览");
- const viewSplitBtn = el("button", { type: "button", class: "btn btn-sm btn-toggle" }, "分屏");
- const tocBtn = el("button", { type: "button", class: "btn btn-sm btn-toggle", title: "按标题生成大纲" }, "大纲");
- const boldBtn = mdBtn("B", "加粗", () => wrapSelection(summaryInput, "**", "**"));
- const italicBtn = mdBtn("I", "斜体", () => wrapSelection(summaryInput, "*", "*"));
- const codeBtn = mdBtn("</>", "行内代码", () => wrapSelection(summaryInput, "`", "`"));
- const h2Btn = mdBtn("H2", "二级标题", () => insertAtCursor(summaryInput, "\n## "));
- const blockCodeBtn = mdBtn("代码块", "代码块", () => insertSnippet("\n```text\n\n```\n", "\n```text\n".length));
- const tableBtn = mdBtn("表格", "表格", () => {
- const t = "\n| 标题 | 内容 |\n| --- | --- |\n| | |\n";
- const cursor = t.lastIndexOf("| |") + 2;
- insertSnippet(t, cursor, cursor);
- });
- const imgLinkBtn = mdBtn("图片链接", "图片链接", () => {
- const t = "\n![]()\n";
- insertSnippet(t, t.indexOf("()") + 1, t.indexOf(")") );
- });
- const quoteBtn = mdBtn("引用", "引用", () => prefixLines(summaryInput, "> "));
- const ulBtn = mdBtn("•", "无序列表", () => insertAtCursor(summaryInput, "\n- "));
- const olBtn = mdBtn("1.", "有序列表", () => insertAtCursor(summaryInput, "\n1. "));
- const linkBtn = mdBtn("链接", "链接", () => {
- const start = summaryInput.selectionStart || 0;
- const end = summaryInput.selectionEnd || 0;
- const selected = (summaryInput.value || "").slice(start, end);
- if (selected) wrapSelection(summaryInput, "[", "](https://)");
- else insertAtCursor(summaryInput, "[](" + "https://)");
- });
- const imgFile = el("input", { type: "file", accept: "image/*", style: "display:none" });
- const videoFile = el("input", { type: "file", accept: "video/*", style: "display:none" });
- const uploadImgBtn = el("button", { class: "btn btn-sm" }, "上传图片");
- const uploadVideoBtn = el("button", { class: "btn btn-sm" }, "上传视频");
- uploadImgBtn.addEventListener("click", () => imgFile.click());
- uploadVideoBtn.addEventListener("click", () => videoFile.click());
- const onPickFile = async (f) => {
- msgEl.textContent = "上传中...";
- try {
- const url = await adminUploadFile(f);
- const syntax = (f.type || "").startsWith("video/") ? `\n@[video](${url})\n` : `\n\n`;
- insertAtCursor(summaryInput, syntax);
- msgEl.textContent = "已插入上传内容";
- } catch (e) {
- msgEl.textContent = `上传失败:${e.detail?.error || e.status || "unknown"}`;
- if (e.status === 401) window.location.href = "/ui/admin/login";
- }
- };
- summaryInput.addEventListener("dragover", (evt) => {
- const files = evt.dataTransfer?.files ? Array.from(evt.dataTransfer.files) : [];
- const file = files.find((it) => (it.type || "").startsWith("image/") || (it.type || "").startsWith("video/"));
- if (file) evt.preventDefault();
- });
- summaryInput.addEventListener("drop", async (evt) => {
- const files = evt.dataTransfer?.files ? Array.from(evt.dataTransfer.files) : [];
- const file = files.find((it) => (it.type || "").startsWith("image/") || (it.type || "").startsWith("video/"));
- if (!file) return;
- evt.preventDefault();
- await onPickFile(file);
- });
- imgFile.addEventListener("change", async () => {
- const f = imgFile.files && imgFile.files[0];
- if (f) await onPickFile(f);
- imgFile.value = "";
- });
- videoFile.addEventListener("change", async () => {
- const f = videoFile.files && videoFile.files[0];
- if (f) await onPickFile(f);
- videoFile.value = "";
- });
- const mdToolbar = el(
- "div",
- { class: "toolbar md-editor-toolbar", style: "margin:0" },
- viewEditBtn,
- viewPreviewBtn,
- viewSplitBtn,
- tocBtn,
- boldBtn,
- italicBtn,
- codeBtn,
- h2Btn,
- blockCodeBtn,
- tableBtn,
- imgLinkBtn,
- quoteBtn,
- ulBtn,
- olBtn,
- linkBtn,
- el("div", { style: "flex:1" }),
- uploadImgBtn,
- uploadVideoBtn,
- imgFile,
- videoFile,
- el("label", { class: "checkbox-row", style: "margin:0" }, syncReadme, el("span", { class: "muted" }, "同步 README.md"))
- );
- const mdEditor = el(
- "div",
- { class: "md-editor", "data-view": "edit" },
- mdToolbar,
- el("div", { class: "md-editor-body" }, summaryInput, mdPreview)
- );
- function setMdView(view) {
- mdEditor.setAttribute("data-view", view);
- [viewEditBtn, viewPreviewBtn, viewSplitBtn].forEach((b) => b.classList.remove("active"));
- if (view === "preview") viewPreviewBtn.classList.add("active");
- else if (view === "split") viewSplitBtn.classList.add("active");
- else viewEditBtn.classList.add("active");
- updateMdPreview();
- }
- function setViewByModalSize(size) {
- const s = size === "lg" ? "lg" : "sm";
- const cur = (mdEditor.getAttribute("data-view") || "edit").toString();
- if (s === "lg" && cur === "edit") setMdView("split");
- if (s === "sm" && cur === "split") setMdView("edit");
- updateMdPreview();
- }
- viewEditBtn.addEventListener("click", () => setMdView("edit"));
- viewPreviewBtn.addEventListener("click", () => setMdView("preview"));
- viewSplitBtn.addEventListener("click", () => setMdView("split"));
- tocBtn.addEventListener("click", () => {
- showToc = !showToc;
- tocBtn.classList.toggle("active", showToc);
- updateMdPreview();
- });
- function setText(text) {
- summaryInput.value = String(text || "");
- try {
- summaryInput.dispatchEvent(new Event("input", { bubbles: true }));
- } catch (e) {}
- }
- return { root: mdEditor, textarea: summaryInput, syncReadme, toolbarEl: mdToolbar, setText, setMdView, setViewByModalSize };
- }
- async function adminUploadFileMeta(file) {
- const fd = new FormData();
- fd.append("file", file);
- const headers = {};
- const csrf = getCookie("csrf_token");
- if (csrf) headers["X-CSRF-Token"] = csrf;
- const resp = await fetch("/admin/uploads", { method: "POST", body: fd, headers });
- const contentType = resp.headers.get("content-type") || "";
- const isJson = contentType.includes("application/json");
- const detail = isJson ? await resp.json() : null;
- if (!resp.ok) {
- const err = new Error("upload_failed");
- err.status = resp.status;
- err.detail = detail;
- throw err;
- }
- return detail;
- }
- async function adminUploadFile(file) {
- const detail = await adminUploadFileMeta(file);
- return detail.url;
- }
- function attachPasteUpload(textarea, msgEl) {
- textarea.addEventListener("paste", async (evt) => {
- const items = evt.clipboardData?.items ? Array.from(evt.clipboardData.items) : [];
- const fileItem = items.find((it) => it.kind === "file" && (it.type || "").startsWith("image/")) || items.find((it) => it.kind === "file" && (it.type || "").startsWith("video/"));
- if (!fileItem) return;
- evt.preventDefault();
- const file = fileItem.getAsFile();
- if (!file) return;
- msgEl.textContent = "上传中...";
- try {
- const url = await adminUploadFile(file);
- const syntax = (file.type || "").startsWith("video/") ? `\n@[video](${url})\n` : `\n\n`;
- insertAtCursor(textarea, syntax);
- msgEl.textContent = "已插入上传内容";
- } catch (e) {
- msgEl.textContent = `上传失败:${e.detail?.error || e.status || "unknown"}`;
- if (e.status === 401) window.location.href = "/ui/admin/login";
- }
- });
- }
- function openRepoPicker(initialOwner, onPick) {
- const ownerInput = el("input", { class: "input", placeholder: "Owner(可选:留空则列出 Token 可见仓库)", value: initialOwner || "" });
- const qInput = el("input", { class: "input", placeholder: "仓库关键词(可选)" });
- const searchBtn = el("button", { class: "btn" }, "搜索");
- const msg = el("div", { class: "muted" }, "");
- const table = el("table", { class: "table" }, el("thead", {}, el("tr", {}, el("th", {}, "仓库"), el("th", {}, "默认分支"), el("th", {}, "操作"))), el("tbody", {}));
- const tbody = table.querySelector("tbody");
- const tableWrap = el("div", { class: "table-wrap" }, table);
- async function refresh() {
- tbody.innerHTML = "";
- msg.textContent = "";
- try {
- const params = new URLSearchParams();
- if (ownerInput.value.trim()) params.set("owner", ownerInput.value.trim());
- if (qInput.value.trim()) params.set("q", qInput.value.trim());
- const resp = await apiFetch(`/admin/gogs/repos?${params.toString()}`);
- const items = resp.items || [];
- if (!items.length) {
- renderEmptyRow(tbody, 3, "未找到仓库");
- return;
- }
- items.forEach((r) => {
- const ownerName = (r.owner || (r.fullName || "").split("/")[0] || "").trim();
- const tr = el(
- "tr",
- {},
- el("td", {}, r.fullName || r.name),
- el("td", {}, r.defaultBranch || "-"),
- el(
- "td",
- {},
- btnGroup(
- el(
- "button",
- {
- class: "btn",
- onclick: () => {
- onPick({ owner: ownerName, name: r.name, fullName: r.fullName || "", defaultBranch: r.defaultBranch || "" });
- closeModal();
- },
- },
- "选择"
- )
- )
- )
- );
- tbody.appendChild(tr);
- });
- } catch (e) {
- const errCode = e.detail?.error || e.status || "unknown";
- const upstream = e.detail?.status ? `(Gogs: ${e.detail.status})` : "";
- if (e.detail?.error === "gogs_token_required") {
- msg.textContent = "查询失败:未配置 GOGS_TOKEN,请填写 Owner 后再搜索";
- return;
- }
- if (e.detail?.error === "gogs_unreachable" || (e.detail?.error === "gogs_failed" && Number(e.detail?.status || 0) === 599)) {
- const url = (e.detail?.url || "").toString().trim();
- msg.textContent = `查询失败:无法连接 Gogs,请检查 GOGS_BASE_URL/网络${url ? `(${url})` : ""}。若不配置 Token,请填写 Owner 后再搜索。`;
- showToastError("无法连接 Gogs");
- return;
- }
- msg.textContent = `查询失败:${errCode}${upstream}`;
- if (e.status === 401) window.location.href = "/ui/admin/login";
- }
- }
- searchBtn.addEventListener("click", refresh);
- openModal(
- "选择仓库",
- [el("div", { class: "toolbar toolbar-tight" }, ownerInput, qInput, searchBtn), msg, tableWrap],
- [el("button", { class: "btn", onclick: closeModal }, "关闭")],
- "ri-git-repository-line"
- );
- refresh();
- }
- async function loadPlans() {
- const planTbody = document.querySelector("#planTable tbody");
- planTbody.innerHTML = "";
- const plans = await apiFetch("/admin/plans");
- planMap.clear();
- plans.forEach((p) => {
- planMap.set(String(p.id), p);
- const tr = el(
- "tr",
- {},
- el("td", { title: String(p.id) }, String(p.id)),
- el("td", { title: p.name }, p.name),
- el("td", {}, String(p.durationDays)),
- el("td", {}, formatCents(p.priceCents)),
- el("td", {}, p.enabled ? badge("启用", "badge-success") : badge("禁用", "badge-danger")),
- el("td", {}, String(p.sort)),
- el(
- "td",
- { class: "td-actions" },
- btnGroup(
- el("button", { class: "btn", "data-action": "edit-plan", "data-id": String(p.id) }, "编辑"),
- el("button", { class: "btn", "data-action": "del-plan", "data-id": String(p.id) }, "删除")
- )
- )
- );
- planTbody.appendChild(tr);
- });
- if (!plans.length) renderEmptyRow(planTbody, 7, "暂无数据");
- }
- if (settingsGroupsWrap) {
- settingsGroupsWrap.addEventListener("click", (evt) => {
- const head = evt.target.closest(".collapse-head");
- if (!head) return;
- const wrap = head.closest(".collapse");
- if (!wrap) return;
- evt.preventDefault();
- const cur = wrap.getAttribute("data-open") === "1";
- setSettingGroupOpen(wrap, !cur);
- if (wrap.id) setSettingNavActive(`#${wrap.id}`);
- });
- }
- if (cfgGroupNav) {
- cfgGroupNav.addEventListener("click", (evt) => {
- const btn = evt.target.closest(".btn");
- if (!btn) return;
- const targetSel = btn.getAttribute("data-target");
- if (!targetSel) return;
- evt.preventDefault();
- if (cfgSearch && cfgSearch.value.trim()) {
- cfgSearch.value = "";
- applySettingsFilter();
- }
- setActiveSettingsGroup(targetSel, { open: true, scroll: false });
- });
- }
- if (cfgSearch) {
- cfgSearch.addEventListener("input", () => {
- applySettingsFilter();
- });
- cfgSearch.addEventListener("keydown", (evt) => {
- if (evt.key !== "Enter") return;
- evt.preventDefault();
- applySettingsFilter();
- const first = listSettingGroups().find((g) => g.style.display !== "none");
- if (first) {
- setSettingGroupOpen(first, true);
- if (first.id) setSettingNavActive(`#${first.id}`);
- first.scrollIntoView({ block: "start", behavior: "smooth" });
- }
- });
- }
- async function loadResources() {
- const resTbody = document.querySelector("#resourceTable tbody");
- resTbody.innerHTML = "";
- const query = new URLSearchParams();
- if (resQ.value.trim()) query.set("q", resQ.value.trim());
- if (resTypeFilter.value) query.set("type", resTypeFilter.value);
- if (resStatusFilter.value) query.set("status", resStatusFilter.value);
- query.set("page", String(resState.page));
- query.set("pageSize", String(resState.pageSize));
- const resp = await apiFetch(`/admin/resources?${query.toString()}`);
- const resources = resp.items || [];
- resState.total = Number(resp.total || 0);
- resourceMap.clear();
- resources.forEach((r) => {
- resourceMap.set(String(r.id), r);
- const tr = el(
- "tr",
- {},
- el("td", { title: String(r.id) }, String(r.id)),
- el("td", { title: r.title }, r.title),
- el("td", {}, resourceTypeBadge(r.type)),
- el("td", {}, resourceStatusBadge(r.status)),
- el("td", { title: r.defaultRef }, r.defaultRef),
- el("td", { title: `${r.repoOwner}/${r.repoName}` }, `${r.repoOwner}/${r.repoName}`),
- el("td", { title: formatDateTime(r.updatedAt) }, formatDateTime(r.updatedAt)),
- el(
- "td",
- { class: "td-actions" },
- btnGroup(
- el("a", { class: "btn", href: `/ui/resources/${r.id}` }, "查看"),
- el("button", { class: "btn", "data-action": "edit-res", "data-id": String(r.id) }, "编辑"),
- el("button", { class: "btn", "data-action": "del-res", "data-id": String(r.id) }, "删除")
- )
- )
- );
- resTbody.appendChild(tr);
- });
- if (!resources.length) renderEmptyRow(resTbody, 8, "暂无数据");
- const pageCount = Math.max(1, Math.ceil(resState.total / resState.pageSize));
- resPageInfo.textContent = `第 ${resState.page} / ${pageCount} 页,共 ${resState.total} 条`;
- resPrevPage.disabled = resState.page <= 1;
- resNextPage.disabled = resState.page >= pageCount;
- }
- async function loadOrders() {
- const orderTbody = document.querySelector("#orderTable tbody");
- orderTbody.innerHTML = "";
- const query = new URLSearchParams();
- if (orderQ.value.trim()) query.set("q", orderQ.value.trim());
- if (orderStatusFilter.value) query.set("status", orderStatusFilter.value);
- query.set("page", String(orderState.page));
- query.set("pageSize", String(orderState.pageSize));
- const orders = await apiFetch(`/admin/orders?${query.toString()}`);
- orderState.total = Number(orders.total || 0);
- orderMap.clear();
- (orders.items || []).forEach((o) => {
- orderMap.set(String(o.id), o);
- const isLocked = o.status === "PAID";
- const delBtn = el("button", { class: "btn", "data-action": "del-order", "data-id": String(o.id) }, "删除");
- if (isLocked) {
- delBtn.disabled = true;
- }
- const tr = el(
- "tr",
- {},
- el("td", { title: o.id }, o.id),
- el("td", {}, orderStatusBadge(o.status)),
- el("td", {}, formatCents(o.amountCents)),
- el("td", { title: `${o.userId} / ${o.userPhone}` }, `${o.userId} / ${o.userPhone}`),
- el(
- "td",
- { title: `${o.planSnapshot.name}(${o.planSnapshot.durationDays}天 / ${formatCents(o.planSnapshot.priceCents)})` },
- o.planSnapshot.name
- ),
- el("td", { title: formatDateTime(o.createdAt) }, formatDateTime(o.createdAt)),
- el("td", { title: formatDateTime(o.paidAt) }, formatDateTime(o.paidAt)),
- el(
- "td",
- { class: "td-actions" },
- btnGroup(
- el("button", { class: "btn", "data-action": "view-order", "data-id": String(o.id) }, "查看"),
- delBtn
- )
- )
- );
- orderTbody.appendChild(tr);
- });
- if (!(orders.items || []).length) renderEmptyRow(orderTbody, 8, "暂无数据");
- const pageCount = Math.max(1, Math.ceil(orderState.total / orderState.pageSize));
- orderPageInfo.textContent = `第 ${orderState.page} / ${pageCount} 页,共 ${orderState.total} 条`;
- orderPrevPage.disabled = orderState.page <= 1;
- orderNextPage.disabled = orderState.page >= pageCount;
- }
- async function loadUsers() {
- const userTbody = document.querySelector("#userTable tbody");
- userTbody.innerHTML = "";
- const query = new URLSearchParams();
- if (userQ.value.trim()) query.set("q", userQ.value.trim());
- if (userStatusFilter.value) query.set("status", userStatusFilter.value);
- if (userVipFilter && userVipFilter.value) query.set("vip", userVipFilter.value);
- query.set("page", String(userState.page));
- query.set("pageSize", String(userState.pageSize));
- const resp = await apiFetch(`/admin/users?${query.toString()}`);
- const users = resp.items || [];
- userState.total = Number(resp.total || 0);
- userMap.clear();
- users.forEach((u) => {
- userMap.set(String(u.id), u);
- const vipBadge = u.vipActive ? badge("VIP", "badge-vip") : badge("非VIP", "badge");
- const vipDays = u.vipActive && Number.isFinite(Number(u.vipRemainingDays)) ? `剩余 ${Number(u.vipRemainingDays)} 天` : "";
- const vipInfo = el(
- "div",
- { style: "display:flex; align-items:center; gap:8px; white-space:nowrap;" },
- vipBadge,
- vipDays ? el("span", { class: "muted", style: "font-size: inherit;" }, vipDays) : null
- );
- const tr = el(
- "tr",
- {},
- el("td", { title: String(u.id) }, String(u.id)),
- el("td", { title: u.phone }, u.phone),
- el("td", {}, userStatusBadge(u.status)),
- el("td", {}, vipInfo),
- el("td", { title: formatDateTime(u.vipExpireAt) }, formatDateTime(u.vipExpireAt)),
- el("td", { title: formatDateTime(u.createdAt) }, formatDateTime(u.createdAt)),
- el(
- "td",
- { class: "td-actions" },
- btnGroup(
- el("button", { class: "btn", "data-action": "user-actions", "data-id": String(u.id) }, "操作")
- )
- )
- );
- userTbody.appendChild(tr);
- });
- if (!users.length) renderEmptyRow(userTbody, 7, "暂无数据");
- const pageCount = Math.max(1, Math.ceil(userState.total / userState.pageSize));
- userPageInfo.textContent = `第 ${userState.page} / ${pageCount} 页,共 ${userState.total} 条`;
- userPrevPage.disabled = userState.page <= 1;
- userNextPage.disabled = userState.page >= pageCount;
- }
- async function loadDownloadLogs() {
- const tbody = document.querySelector("#downloadLogTable tbody");
- tbody.innerHTML = "";
- const query = new URLSearchParams();
- if (dlQ && dlQ.value.trim()) query.set("q", dlQ.value.trim());
- if (dlTypeFilter && dlTypeFilter.value) query.set("type", dlTypeFilter.value);
- if (dlStateFilter && dlStateFilter.value) query.set("state", dlStateFilter.value);
- query.set("page", String(downloadLogState.page));
- query.set("pageSize", String(downloadLogState.pageSize));
- const resp = await apiFetch(`/admin/download-logs?${query.toString()}`);
- downloadLogState.total = Number(resp.total || 0);
- downloadLogMap.clear();
- (resp.items || []).forEach((it) => {
- downloadLogMap.set(String(it.id), it);
- const stateBadge =
- it.resourceState === "DELETED"
- ? badge("已删除", "badge-danger")
- : it.resourceState === "OFFLINE"
- ? badge("已下架", "badge-warning")
- : badge("在线", "badge-success");
- const typeBadge = it.resourceType === "VIP" ? badge("VIP", "badge-vip") : badge("免费", "badge-free");
- const currentTypeBadge =
- it.currentResourceType === "VIP"
- ? badge("VIP", "badge-vip")
- : it.currentResourceType === "FREE"
- ? badge("免费", "badge-free")
- : badge("-", "badge");
- const userCell = `${it.userId} / ${it.userPhone || "-"}`;
- const titleText = String(it.resourceTitle || "");
- const titleNode =
- it.resourceId && it.resourceState === "ONLINE"
- ? el("a", { href: `/ui/resources/${it.resourceId}`, style: "color: inherit; text-decoration: none;" }, titleText)
- : el("span", { class: "muted" }, titleText);
- const tr = el(
- "tr",
- {},
- el("td", { title: String(it.id) }, String(it.id)),
- el("td", { title: formatDateTime(it.downloadedAt) }, formatDateTime(it.downloadedAt)),
- el("td", { title: userCell }, userCell),
- el("td", { title: titleText }, titleNode),
- el("td", {}, typeBadge),
- el("td", {}, currentTypeBadge),
- el("td", {}, stateBadge),
- el("td", { title: String(it.ip || "") }, String(it.ip || "-")),
- el(
- "td",
- { class: "td-actions" },
- btnGroup(el("button", { class: "btn", "data-action": "view-download-log", "data-id": String(it.id) }, "查看"))
- )
- );
- tbody.appendChild(tr);
- });
- if (!(resp.items || []).length) renderEmptyRow(tbody, 9, "暂无数据");
- const pageCount = Math.max(1, Math.ceil(downloadLogState.total / downloadLogState.pageSize));
- if (dlPageInfo) dlPageInfo.textContent = `第 ${downloadLogState.page} / ${pageCount} 页,共 ${downloadLogState.total} 条`;
- if (dlPrevPage) dlPrevPage.disabled = downloadLogState.page <= 1;
- if (dlNextPage) dlNextPage.disabled = downloadLogState.page >= pageCount;
- }
- async function loadAdminMessages() {
- if (!msgTbody) return;
- msgTbody.innerHTML = "";
- const params = new URLSearchParams();
- params.set("page", String(messageState.page));
- params.set("pageSize", String(messageState.pageSize));
- const q = (msgQ?.value || "").trim();
- if (q) params.set("q", q);
- const read = (msgReadFilter?.value || "").trim();
- if (read) params.set("read", read);
- const senderType = (msgSenderFilter?.value || "").trim();
- if (senderType) params.set("senderType", senderType);
- try {
- const resp = await apiFetch(`/admin/messages?${params.toString()}`);
- messageState.total = parseInt(resp.total || 0, 10) || 0;
- messageMap.clear();
- const items = Array.isArray(resp.items) ? resp.items : [];
- items.forEach((m) => {
- messageMap.set(String(m.id), m);
- const userCell = `${m.userId} / ${m.userPhone || "-"}`;
- const readBadge = m.read ? badge("已读", "badge-success") : badge("未读", "badge-warning");
- const senderBadge = m.senderType === "ADMIN" ? badge("管理员", "badge-info") : badge("系统", "badge");
- const titleText = String(m.title || "");
- const tr = el(
- "tr",
- {},
- el("td", { title: String(m.id) }, String(m.id)),
- el("td", { title: userCell }, userCell),
- el("td", { title: titleText }, titleText),
- el("td", { title: formatDateTime(m.createdAt) }, formatDateTime(m.createdAt)),
- el("td", {}, readBadge),
- el("td", {}, senderBadge),
- el(
- "td",
- { class: "td-actions" },
- btnGroup(
- el("button", { class: "btn", "data-action": "view-message", "data-id": String(m.id) }, "查看"),
- el("button", { class: "btn btn-danger", "data-action": "del-message", "data-id": String(m.id) }, "删除")
- )
- )
- );
- msgTbody.appendChild(tr);
- });
- if (!items.length) renderEmptyRow(msgTbody, 7, "暂无数据");
- const pageCount = Math.max(1, Math.ceil(messageState.total / messageState.pageSize));
- if (msgPageInfo) msgPageInfo.textContent = `第 ${messageState.page} / ${pageCount} 页,共 ${messageState.total} 条`;
- if (msgPrevPage) msgPrevPage.disabled = messageState.page <= 1;
- if (msgNextPage) msgNextPage.disabled = messageState.page >= pageCount;
- } catch (e) {
- if (e.status === 401) window.location.href = "/ui/admin/login";
- renderEmptyRow(msgTbody, 7, `加载失败:${e.detail?.error || e.status || "unknown"}`);
- }
- }
- async function loadAdminOverview() {
- if (!ovUsersTotal || !ovSystemInfo) return;
- try {
- if (overviewUpdatedAt) overviewUpdatedAt.textContent = "加载中…";
- const stats = await apiFetch("/admin/stats");
- if (ovUsersTotal) ovUsersTotal.textContent = String(stats?.users?.total ?? 0);
- if (ovUsersSub) ovUsersSub.textContent = `活跃 ${stats?.users?.active ?? 0},VIP ${stats?.users?.vipActive ?? 0}`;
- if (ovResourcesTotal) ovResourcesTotal.textContent = String(stats?.resources?.total ?? 0);
- if (ovResourcesSub) ovResourcesSub.textContent = `上架 ${stats?.resources?.online ?? 0}`;
- if (ovOrdersTotal) ovOrdersTotal.textContent = String(stats?.orders?.total ?? 0);
- if (ovOrdersSub) ovOrdersSub.textContent = `已付 ${stats?.orders?.paid ?? 0},待付 ${stats?.orders?.pending ?? 0}`;
- if (ovRevenueTotal) ovRevenueTotal.textContent = formatCents(stats?.revenue?.totalCents ?? 0);
- if (ovRevenueSub) ovRevenueSub.textContent = `24h ${formatCents(stats?.revenue?.last24hCents ?? 0)}`;
- if (ovDownloadsTotal) ovDownloadsTotal.textContent = String(stats?.downloads?.total ?? 0);
- if (ovDownloadsSub) ovDownloadsSub.textContent = `24h ${stats?.downloads?.last24h ?? 0}`;
- if (ovMessagesTotal) ovMessagesTotal.textContent = String(stats?.messages?.total ?? 0);
- if (ovMessagesSub) ovMessagesSub.textContent = `24h ${stats?.messages?.last24h ?? 0}`;
- if (ovSystemInfo) ovSystemInfo.textContent = `当前数据库:${stats?.backend || "-"},统计时间:${formatDateTime(stats?.now)}`;
- if (overviewUpdatedAt) overviewUpdatedAt.textContent = `更新时间:${formatDateTime(stats?.now)}`;
- } catch (e) {
- if (e.status === 401) window.location.href = "/ui/admin/login";
- if (overviewUpdatedAt) overviewUpdatedAt.textContent = `加载失败:${e.detail?.error || e.status || "unknown"}`;
- }
- }
- async function activate(section) {
- const effectiveSection = section;
- document.querySelectorAll(".menu-item").forEach((a) => a.classList.remove("active"));
- const link = document.querySelector(`.menu-item[data-section='${section}']`);
- if (link) link.classList.add("active");
- document.querySelectorAll(".content-section").forEach((s) => (s.style.display = "none"));
- const sec = document.getElementById(`sec-${effectiveSection}`);
- if (sec) sec.style.display = "";
- if (effectiveSection === "overview") {
- contentTitle.textContent = "概览";
- await loadAdminOverview();
- } else if (effectiveSection === "plans") {
- contentTitle.textContent = "会员方案";
- await loadPlans();
- } else if (effectiveSection === "resources") {
- contentTitle.textContent = "资源管理";
- await loadResources();
- } else if (effectiveSection === "uploads") {
- contentTitle.textContent = "上传管理";
- await loadUploads();
- } else if (effectiveSection === "orders") {
- contentTitle.textContent = "订单管理";
- orderState.page = 1;
- await loadOrders();
- } else if (effectiveSection === "users") {
- contentTitle.textContent = "用户管理";
- await loadUsers();
- } else if (effectiveSection === "download-logs") {
- contentTitle.textContent = "下载记录";
- downloadLogState.page = 1;
- await loadDownloadLogs();
- } else if (effectiveSection === "messages") {
- contentTitle.textContent = "消息管理";
- messageState.page = 1;
- await loadAdminMessages();
- } else if (effectiveSection === "settings") {
- contentTitle.textContent = "第三方配置";
- await loadSettings();
- }
- }
- menu.addEventListener("click", async (evt) => {
- const a = evt.target.closest(".menu-item");
- if (!a) return;
- evt.preventDefault();
- const sec = a.getAttribute("data-section");
- await activate(sec);
- });
- if (overviewRefreshBtn) {
- overviewRefreshBtn.addEventListener("click", async () => {
- await loadAdminOverview();
- });
- }
- settingsRefreshBtn.addEventListener("click", async () => {
- await loadSettings();
- });
- settingsSaveBtn.addEventListener("click", async () => {
- await saveSettings();
- });
- if (cfgPayProvider) {
- cfgPayProvider.addEventListener("change", () => {
- updatePayProviderVisibility();
- });
- }
- if (cfgAlipayUseCurrentNotify && cfgAlipayNotifyUrl) {
- cfgAlipayUseCurrentNotify.addEventListener("click", () => {
- cfgAlipayNotifyUrl.value = `${window.location.origin}/pay/callback`;
- });
- }
- if (cfgAlipayUseCurrentReturn && cfgAlipayReturnUrl) {
- cfgAlipayUseCurrentReturn.addEventListener("click", () => {
- cfgAlipayReturnUrl.value = `${window.location.origin}/ui/me`;
- });
- }
- if (cfgShowAlipayPrivateKey && cfgAlipayPrivateKey) {
- cfgShowAlipayPrivateKey.addEventListener("change", () => {
- cfgAlipayPrivateKey.classList.toggle("is-revealed", Boolean(cfgShowAlipayPrivateKey.checked));
- });
- }
- if (cfgShowAlipayPublicKey && cfgAlipayPublicKey) {
- cfgShowAlipayPublicKey.addEventListener("change", () => {
- cfgAlipayPublicKey.classList.toggle("is-revealed", Boolean(cfgShowAlipayPublicKey.checked));
- });
- }
- if (cfgGogsSaveBtn) {
- cfgGogsSaveBtn.addEventListener("click", async () => {
- await saveSettingsPartial({
- gogsBaseUrl: cfgGogsBaseUrl.value.trim(),
- gogsToken: cfgGogsToken.value.trim(),
- clearGogsToken: cfgClearGogsToken.checked,
- });
- });
- }
- if (cfgGogsResetBtn) {
- cfgGogsResetBtn.addEventListener("click", async () => {
- await loadSettings();
- const g = document.getElementById("cfgGroupGogs");
- if (g) g.setAttribute("data-open", "1");
- });
- }
- if (cfgPaySaveBtn) {
- cfgPaySaveBtn.addEventListener("click", async () => {
- await saveSettingsPartial({
- payment: {
- provider: cfgPayProvider.value,
- enableMockPay: cfgEnableMockPay.checked,
- apiKey: cfgPayApiKey.value.trim(),
- clearApiKey: cfgClearPayApiKey.checked,
- alipay: {
- appId: cfgAlipayAppId ? cfgAlipayAppId.value.trim() : "",
- gateway: cfgAlipayGateway ? cfgAlipayGateway.value.trim() : "",
- notifyUrl: cfgAlipayNotifyUrl ? cfgAlipayNotifyUrl.value.trim() : "",
- returnUrl: cfgAlipayReturnUrl ? cfgAlipayReturnUrl.value.trim() : "",
- privateKey: cfgAlipayPrivateKey ? cfgAlipayPrivateKey.value.trim() : "",
- clearPrivateKey: cfgClearAlipayPrivateKey ? cfgClearAlipayPrivateKey.checked : false,
- publicKey: cfgAlipayPublicKey ? cfgAlipayPublicKey.value.trim() : "",
- clearPublicKey: cfgClearAlipayPublicKey ? cfgClearAlipayPublicKey.checked : false,
- },
- },
- });
- });
- }
- if (cfgPayResetBtn) {
- cfgPayResetBtn.addEventListener("click", async () => {
- await loadSettings();
- const g = document.getElementById("cfgGroupPay");
- if (g) g.setAttribute("data-open", "1");
- });
- }
- if (cfgLlmSaveBtn) {
- cfgLlmSaveBtn.addEventListener("click", async () => {
- await saveSettingsPartial({
- llm: {
- provider: cfgLlmProvider.value.trim(),
- baseUrl: cfgLlmBaseUrl.value.trim(),
- model: cfgLlmModel.value.trim(),
- apiKey: cfgLlmApiKey.value.trim(),
- clearApiKey: cfgClearLlmApiKey.checked,
- },
- });
- });
- }
- if (cfgLlmResetBtn) {
- cfgLlmResetBtn.addEventListener("click", async () => {
- await loadSettings();
- const g = document.getElementById("cfgGroupLlm");
- if (g) g.setAttribute("data-open", "1");
- });
- }
- if (cfgDbSaveBtn) {
- cfgDbSaveBtn.addEventListener("click", async () => {
- await saveSettingsPartial({
- mysql: {
- host: cfgMysqlHost ? cfgMysqlHost.value.trim() : "",
- port: cfgMysqlPort ? cfgMysqlPort.value.trim() : "",
- user: cfgMysqlUser ? cfgMysqlUser.value.trim() : "",
- password: cfgMysqlPassword ? cfgMysqlPassword.value.trim() : "",
- clearPassword: cfgClearMysqlPassword ? cfgClearMysqlPassword.checked : false,
- database: cfgMysqlDatabase ? cfgMysqlDatabase.value.trim() : "",
- },
- });
- });
- }
- if (cfgDbResetBtn) {
- cfgDbResetBtn.addEventListener("click", async () => {
- await loadSettings();
- const g = document.getElementById("cfgGroupDb");
- if (g) g.setAttribute("data-open", "1");
- });
- }
- if (cfgMysqlTestBtn) {
- cfgMysqlTestBtn.addEventListener("click", async () => {
- await testMysqlConnection();
- });
- }
- if (cfgDbSwitchMysqlBtn) {
- cfgDbSwitchMysqlBtn.addEventListener("click", async () => {
- await switchDatabase("mysql", false);
- });
- }
- if (cfgDbSwitchSqliteBtn) {
- cfgDbSwitchSqliteBtn.addEventListener("click", async () => {
- await switchDatabase("sqlite", false);
- });
- }
- uploadsFilterAll.addEventListener("click", async () => {
- setUploadsFilter("all");
- await loadUploads();
- });
- uploadsFilterUnused.addEventListener("click", async () => {
- setUploadsFilter("unused");
- await loadUploads();
- });
- uploadsFilterUsed.addEventListener("click", async () => {
- setUploadsFilter("used");
- await loadUploads();
- });
- uploadsRefreshBtn.addEventListener("click", async () => {
- await loadUploads();
- });
- uploadsQ.addEventListener("keydown", async (evt) => {
- if (evt.key !== "Enter") return;
- evt.preventDefault();
- await loadUploads();
- });
- uploadsUploadBtn.addEventListener("click", () => {
- uploadsFile.value = "";
- uploadsFile.click();
- });
- uploadsFile.addEventListener("change", async () => {
- const files = uploadsFile.files ? Array.from(uploadsFile.files) : [];
- if (!files.length) return;
- uploadsUploadBtn.disabled = true;
- uploadsCleanupBtn.disabled = true;
- uploadsRefreshBtn.disabled = true;
- try {
- for (const f of files) {
- await adminUploadFileMeta(f);
- }
- showToastSuccess("上传成功");
- await loadUploads();
- } catch (e) {
- showToastError(e.detail?.error || e.status || "上传失败");
- if (e.status === 401) window.location.href = "/ui/admin/login";
- } finally {
- uploadsUploadBtn.disabled = false;
- uploadsCleanupBtn.disabled = false;
- uploadsRefreshBtn.disabled = false;
- }
- });
- uploadsCleanupBtn.addEventListener("click", async () => {
- const r = await Swal.fire({
- title: "一键清理未使用文件?",
- text: "将删除 uploads 目录中所有未被资源引用的文件。",
- icon: "warning",
- showCancelButton: true,
- confirmButtonText: "开始清理",
- cancelButtonText: "取消",
- confirmButtonColor: "var(--danger)",
- });
- if (!r.isConfirmed) return;
- try {
- const resp = await apiFetch("/admin/uploads/cleanup-unused", { method: "POST" });
- showToastSuccess(`已清理 ${resp.deletedCount || 0} 个文件`);
- await loadUploads();
- } catch (e) {
- showToastError(e.detail?.error || e.status || "清理失败");
- if (e.status === 401) window.location.href = "/ui/admin/login";
- }
- });
- createPlanOpenBtn.addEventListener("click", () => {
- const nameInput = el("input", { class: "input", placeholder: "名称" });
- const daysInput = el("input", { class: "input", placeholder: "时长(天)" });
- const priceInput = el("input", { class: "input", placeholder: "价格(分)" });
- const enabledSelect = el("select", { class: "input" }, el("option", { value: "1" }, "启用"), el("option", { value: "0" }, "禁用"));
- const sortInput = el("input", { class: "input", placeholder: "排序,默认 0", value: "0" });
- const msg = el("div", { class: "muted" }, "");
- openModal(
- "新增方案",
- [
- el("label", { class: "label" }, "名称"),
- nameInput,
- el("label", { class: "label" }, "时长(天)"),
- daysInput,
- el("label", { class: "label" }, "价格(分)"),
- priceInput,
- el("label", { class: "label" }, "启用"),
- enabledSelect,
- el("label", { class: "label" }, "排序"),
- sortInput,
- msg,
- ],
- [
- el("button", { class: "btn", onclick: closeModal }, "取消"),
- el(
- "button",
- {
- class: "btn btn-primary",
- onclick: async () => {
- msg.textContent = "";
- try {
- await apiFetch("/admin/plans", {
- method: "POST",
- body: {
- name: nameInput.value.trim(),
- durationDays: Number(daysInput.value),
- priceCents: Number(priceInput.value),
- enabled: enabledSelect.value === "1",
- sort: Number(sortInput.value || "0"),
- },
- });
- closeModal();
- await loadPlans();
- } catch (e) {
- msg.textContent = `创建失败:${e.detail?.error || e.status || "unknown"}`;
- if (e.status === 401) window.location.href = "/ui/admin/login";
- }
- },
- },
- "创建"
- ),
- ],
- "ri-add-circle-line"
- );
- });
- document.addEventListener("click", async (evt) => {
- const btn = evt.target.closest("button[data-action]");
- if (!btn) return;
- const action = btn.getAttribute("data-action");
- const id = btn.getAttribute("data-id");
- const openVipAdjustModal = (u) => {
- const daysInput = el("input", { class: "input", value: "30" });
- const msg = el("div", { class: "muted" }, "");
- openModal(
- `调整会员 #${u.id}`,
- [
- el("div", { class: "muted" }, `手机号:${u.phone},当前到期:${formatDateTime(u.vipExpireAt)}`),
- el("label", { class: "label" }, "增加天数(可为负数)"),
- daysInput,
- msg,
- ],
- [
- el("button", { class: "btn", onclick: closeModal }, "取消"),
- el(
- "button",
- {
- class: "btn btn-primary",
- onclick: async () => {
- msg.textContent = "";
- try {
- await apiFetch(`/admin/users/${u.id}/vip-adjust`, { method: "POST", body: { addDays: Number(daysInput.value) } });
- closeModal();
- await loadUsers();
- } catch (e) {
- msg.textContent = `保存失败:${e.detail?.error || e.status || "unknown"}`;
- if (e.status === 401) window.location.href = "/ui/admin/login";
- }
- },
- },
- "保存"
- ),
- ],
- "ri-vip-crown-line"
- );
- };
- const openResetUserPasswordModal = (u) => {
- const msg = el("div", { class: "muted" }, "");
- const passwordInput = el("input", { class: "input", type: "password" });
- const confirmInput = el("input", { class: "input", type: "password" });
- const generate = () => `${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`.slice(0, 12);
- passwordInput.value = generate();
- confirmInput.value = passwordInput.value;
- openModal(
- `重置密码 #${u.id}`,
- [
- el("div", { class: "muted" }, `手机号:${u.phone}`),
- el("label", { class: "label" }, "新密码(至少 6 位)"),
- passwordInput,
- el("label", { class: "label" }, "确认新密码"),
- confirmInput,
- msg,
- ],
- [
- el("button", { class: "btn", onclick: closeModal }, "取消"),
- el(
- "button",
- {
- class: "btn",
- onclick: () => {
- passwordInput.value = generate();
- confirmInput.value = passwordInput.value;
- },
- },
- "随机生成"
- ),
- el("button", { class: "btn", onclick: () => copyText(passwordInput.value) }, "复制密码"),
- el(
- "button",
- {
- class: "btn btn-primary",
- onclick: async () => {
- msg.textContent = "";
- const p1 = passwordInput.value || "";
- const p2 = confirmInput.value || "";
- if (p1.length < 6) {
- msg.textContent = "新密码至少 6 位";
- return;
- }
- if (p1 !== p2) {
- msg.textContent = "两次输入不一致";
- return;
- }
- try {
- await apiFetch(`/admin/users/${u.id}/password-reset`, { method: "POST", body: { password: p1 } });
- closeModal();
- showToastSuccess("已重置密码");
- } catch (e) {
- msg.textContent = `重置失败:${e.detail?.error || e.status || "unknown"}`;
- if (e.status === 401) window.location.href = "/ui/admin/login";
- }
- },
- },
- "确认重置"
- ),
- ],
- "ri-key-2-line"
- );
- };
- if (action === "del-plan") {
- try {
- await apiFetch(`/admin/plans/${id}`, { method: "DELETE" });
- await loadPlans();
- } catch (e) {
- if (e.status === 401) window.location.href = "/ui/admin/login";
- }
- }
- if (action === "edit-plan") {
- const plan = planMap.get(String(id));
- if (!plan) return;
- const nameInput = el("input", { class: "input", value: plan.name });
- const daysInput = el("input", { class: "input", value: String(plan.durationDays) });
- const priceInput = el("input", { class: "input", value: String(plan.priceCents) });
- const enabledSelect = el(
- "select",
- { class: "input" },
- el("option", { value: "1" }, "启用"),
- el("option", { value: "0" }, "禁用")
- );
- enabledSelect.value = plan.enabled ? "1" : "0";
- const sortInput = el("input", { class: "input", value: String(plan.sort) });
- const msg = el("div", { class: "muted" }, "");
- openModal(
- `编辑方案 #${plan.id}`,
- [
- el("label", { class: "label" }, "名称"),
- nameInput,
- el("label", { class: "label" }, "时长(天)"),
- daysInput,
- el("label", { class: "label" }, "价格(分)"),
- priceInput,
- el("label", { class: "label" }, "启用"),
- enabledSelect,
- el("label", { class: "label" }, "排序"),
- sortInput,
- msg,
- ],
- [
- el("button", { class: "btn", onclick: closeModal }, "取消"),
- el(
- "button",
- {
- class: "btn btn-primary",
- onclick: async () => {
- msg.textContent = "";
- try {
- await apiFetch(`/admin/plans/${plan.id}`, {
- method: "PUT",
- body: {
- name: nameInput.value.trim(),
- durationDays: Number(daysInput.value),
- priceCents: Number(priceInput.value),
- enabled: enabledSelect.value === "1",
- sort: Number(sortInput.value),
- },
- });
- closeModal();
- await loadPlans();
- } catch (e) {
- msg.textContent = `保存失败:${e.detail?.error || e.status || "unknown"}`;
- if (e.status === 401) window.location.href = "/ui/admin/login";
- }
- },
- },
- "保存"
- ),
- ],
- "ri-edit-circle-line"
- );
- }
- if (action === "del-res") {
- try {
- await apiFetch(`/admin/resources/${id}`, { method: "DELETE" });
- await loadResources();
- } catch (e) {
- if (e.status === 401) window.location.href = "/ui/admin/login";
- }
- }
- if (action === "edit-res") {
- const res = resourceMap.get(String(id));
- if (!res) return;
- openResourceEditorModal({ mode: "edit", res });
- }
- if (action === "toggle-user") {
- const next = btn.getAttribute("data-next");
- try {
- await apiFetch(`/admin/users/${id}`, { method: "PUT", body: { status: next } });
- await loadUsers();
- } catch (e) {
- if (e.status === 401) window.location.href = "/ui/admin/login";
- }
- }
- if (action === "vip-user") {
- const u = userMap.get(String(id));
- if (!u) return;
- openVipAdjustModal(u);
- }
- if (action === "reset-user-pass") {
- const u = userMap.get(String(id));
- if (!u) return;
- openResetUserPasswordModal(u);
- }
- if (action === "user-actions") {
- const u = userMap.get(String(id));
- if (!u) return;
- const nextStatus = u.status === "ACTIVE" ? "DISABLED" : "ACTIVE";
- openModal(
- `用户操作 #${u.id}`,
- [el("div", { class: "muted" }, `手机号:${u.phone}`)],
- [
- el("button", { class: "btn", onclick: closeModal }, "关闭"),
- el(
- "button",
- {
- class: "btn",
- onclick: async () => {
- try {
- await apiFetch(`/admin/users/${u.id}`, { method: "PUT", body: { status: nextStatus } });
- closeModal();
- await loadUsers();
- } catch (e) {
- if (e.status === 401) window.location.href = "/ui/admin/login";
- }
- },
- },
- nextStatus === "DISABLED" ? "禁用" : "启用"
- ),
- el(
- "button",
- {
- class: "btn",
- onclick: () => {
- openResetUserPasswordModal(u);
- },
- },
- "重置密码"
- ),
- el(
- "button",
- {
- class: "btn",
- onclick: () => {
- openVipAdjustModal(u);
- },
- },
- "调整会员"
- ),
- ],
- "ri-settings-3-line"
- );
- }
- if (action === "view-order") {
- const box = el("div", {});
- const msg = el("div", { class: "muted" }, "加载中…");
- box.appendChild(msg);
- openModal("订单详情", [box], [el("button", { class: "btn", onclick: closeModal }, "关闭")], "ri-file-list-3-line");
- try {
- const o = await apiFetch(`/admin/orders/${id}`);
- box.innerHTML = "";
- box.appendChild(el("div", { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" }, el("div", { class: "muted" }, "订单号"), el("div", {}, String(o.id))));
- box.appendChild(
- el(
- "div",
- { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" },
- el("div", { class: "muted" }, "状态"),
- el("div", {}, orderStatusBadge(o.status))
- )
- );
- box.appendChild(el("div", { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" }, el("div", { class: "muted" }, "金额"), el("div", {}, formatCents(o.amountCents))));
- 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}`)));
- box.appendChild(
- el(
- "div",
- { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" },
- el("div", { class: "muted" }, "方案"),
- el("div", {}, `${o.planSnapshot?.name || "-"}(${o.planSnapshot?.durationDays || "-"}天 / ${formatCents(o.planSnapshot?.priceCents || 0)})`)
- )
- );
- box.appendChild(el("div", { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" }, el("div", { class: "muted" }, "创建时间"), el("div", {}, formatDateTime(o.createdAt))));
- box.appendChild(el("div", { class: "card", style: "padding:14px; border-radius: 10px;" }, el("div", { class: "muted" }, "支付时间"), el("div", {}, formatDateTime(o.paidAt))));
- } catch (e) {
- msg.textContent = `加载失败:${e.detail?.error || e.status || "unknown"}`;
- if (e.status === 401) window.location.href = "/ui/admin/login";
- }
- }
- if (action === "view-download-log") {
- const it = downloadLogMap.get(String(id));
- if (!it) return;
- const userText = `${it.userId} / ${it.userPhone || "-"}`;
- const resText = `${it.resourceId || "-"} / ${it.resourceTitle || "-"}`;
- const stateText = it.resourceState === "DELETED" ? "资源已删除" : it.resourceState === "OFFLINE" ? "资源已下架" : "资源在线";
- const typeText = it.resourceType === "VIP" ? "VIP" : "免费";
- const currentTypeText = it.currentResourceType === "VIP" ? "VIP" : it.currentResourceType === "FREE" ? "免费" : "-";
- const driftText = it.currentResourceType && it.currentResourceType !== it.resourceType ? "(类型已变更)" : "";
- openModal(
- "下载记录详情",
- [
- el(
- "div",
- { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" },
- el("div", { class: "muted" }, "下载时间"),
- el("div", {}, formatDateTime(it.downloadedAt))
- ),
- el("div", { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" }, el("div", { class: "muted" }, "用户"), el("div", {}, userText)),
- el(
- "div",
- { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" },
- el("div", { class: "muted" }, "资源"),
- el("div", {}, resText)
- ),
- el(
- "div",
- { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" },
- el("div", { class: "muted" }, "类型"),
- el("div", {}, `下载时:${typeText} / 当前:${currentTypeText}${driftText}`)
- ),
- el("div", { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" }, el("div", { class: "muted" }, "资源状态"), el("div", {}, stateText)),
- el("div", { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" }, el("div", { class: "muted" }, "Ref"), el("div", {}, String(it.ref || "-"))),
- el("div", { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" }, el("div", { class: "muted" }, "IP"), el("div", {}, String(it.ip || "-"))),
- el(
- "div",
- { class: "card", style: "padding:14px; border-radius: 10px;" },
- el("div", { class: "muted" }, "User-Agent"),
- el("div", {}, String(it.userAgent || "-"))
- ),
- ],
- [el("button", { class: "btn", onclick: closeModal }, "关闭")],
- "ri-download-cloud-line"
- );
- }
- if (action === "view-message") {
- const m = messageMap.get(String(id));
- if (!m) return;
- const header = el(
- "div",
- { class: "muted" },
- `用户:${m.userId} / ${m.userPhone || "-"} · 来源:${m.senderType === "ADMIN" ? "管理员" : "系统"} · 发送:${formatDateTime(m.createdAt)} · 已读:${m.read ? formatDateTime(m.readAt) : "未读"}`
- );
- const titleEl = el("div", { style: "font-weight: 650; margin-top: 6px;" }, String(m.title || ""));
- const contentEl = el("pre", { class: "code", style: "white-space: pre-wrap;" }, formatMessageText(m.content || ""));
- openModal(
- `消息 #${m.id}`,
- [header, titleEl, contentEl],
- [
- el("button", { class: "btn", onclick: () => copyText(m.content || "") }, "复制内容"),
- el("button", { class: "btn", onclick: closeModal }, "关闭"),
- ],
- "ri-mail-open-line",
- { resizable: true, size: "lg" }
- );
- }
- if (action === "del-message") {
- const m = messageMap.get(String(id));
- if (!m) return;
- const r = await Swal.fire({
- title: "删除消息?",
- text: `将删除消息 #${m.id}(用户:${m.userPhone || m.userId})`,
- icon: "warning",
- showCancelButton: true,
- confirmButtonText: "删除",
- cancelButtonText: "取消",
- confirmButtonColor: "var(--danger)",
- });
- if (!r.isConfirmed) return;
- try {
- await apiFetch(`/admin/messages/${m.id}`, { method: "DELETE" });
- showToastSuccess("已删除");
- await loadAdminMessages();
- } catch (e) {
- showToastError(e.detail?.error || e.status || "删除失败");
- if (e.status === 401) window.location.href = "/ui/admin/login";
- }
- }
- if (action === "del-order") {
- Swal.fire({
- title: "删除订单?",
- text: `订单号:${id}`,
- icon: "warning",
- showCancelButton: true,
- confirmButtonText: "删除",
- cancelButtonText: "取消",
- confirmButtonColor: "var(--danger)",
- }).then(async (r) => {
- if (!r.isConfirmed) return;
- try {
- await apiFetch(`/admin/orders/${id}`, { method: "DELETE" });
- await loadOrders();
- } catch (e) {
- if (e.status === 401) window.location.href = "/ui/admin/login";
- Swal.fire({ icon: "error", title: "删除失败", text: e.detail?.error || e.status || "未知错误" });
- }
- });
- }
- });
- function openResourceEditorModal({ mode, res }) {
- const isEdit = mode === "edit";
- const field = (labelText, inputEl) => el("div", {}, el("div", { class: "label" }, labelText), inputEl);
- const msg = el("div", { class: "form-msg muted" }, "");
- const titleInput = el("input", { class: "input", placeholder: "标题", value: isEdit ? res.title : "" });
- const keywordsInput = el("input", { class: "input", placeholder: "关键字(逗号分隔,可选)", value: isEdit && Array.isArray(res.tags) ? res.tags.join(",") : "" });
- function makeSegmented(items, initialValue, { disabled, onChange } = {}) {
- let value = String(initialValue || items[0]?.value || "");
- const wrap = el("div", { class: "segmented", role: "group" });
- function apply(v) {
- value = String(v);
- Array.from(wrap.querySelectorAll("button")).forEach((b) => b.classList.toggle("active", b.getAttribute("data-value") === value));
- if (typeof onChange === "function") onChange(value);
- }
- items.forEach((it) => {
- const b = el("button", { type: "button", class: "btn btn-sm", "data-value": String(it.value) }, String(it.label));
- if (disabled) b.disabled = true;
- b.addEventListener("click", (evt) => {
- evt.preventDefault();
- if (disabled) return;
- apply(it.value);
- });
- wrap.appendChild(b);
- });
- apply(value);
- return {
- root: wrap,
- getValue: () => value,
- setValue: (v) => apply(v),
- setInvalid: (bad) => wrap.classList.toggle("is-invalid", Boolean(bad)),
- };
- }
- const typeHelp = el("div", { class: "help" }, "");
- function refreshTypeHelp(v) {
- const val = String(v || "");
- typeHelp.textContent = val === "VIP" ? "VIP:仅会员可访问。" : "FREE:所有用户可访问。";
- }
- const typeSeg = makeSegmented(
- [
- { value: "FREE", label: "FREE" },
- { value: "VIP", label: "VIP" },
- ],
- isEdit ? res.type : "FREE",
- { onChange: refreshTypeHelp }
- );
- refreshTypeHelp(typeSeg.getValue());
- const statusHelp = el("div", { class: "help" }, "");
- function refreshStatusHelp(v) {
- const val = String(v || "");
- statusHelp.textContent = val === "ONLINE" ? "上线:前台可见。" : val === "OFFLINE" ? "下线:前台不可见。" : "草稿:用于编辑中,前台不可见。";
- }
- const statusSeg = makeSegmented(
- [
- { value: "ONLINE", label: "上线" },
- { value: "OFFLINE", label: "下线" },
- { value: "DRAFT", label: "草稿" },
- ],
- isEdit ? res.status : "ONLINE",
- { onChange: refreshStatusHelp }
- );
- statusSeg.root.classList.add("nowrap");
- refreshStatusHelp(statusSeg.getValue());
- const defaultCoverUrl = "/static/images/resources/default.png";
- const tempCoverUploads = new Set();
- function extractUploadNameFromUrl(value) {
- const m = String(value || "").match(/\/static\/uploads\/([0-9a-f]{32}(?:\.[a-z0-9]+)?)$/i);
- return m ? String(m[1] || "") : "";
- }
- async function deleteUploadByName(name) {
- const n = String(name || "").trim();
- if (!n) return;
- try {
- await apiFetch(`/admin/uploads/${encodeURIComponent(n)}`, { method: "DELETE" });
- } catch (e) {}
- }
- async function cleanupTempCoverUploads(keepUrl) {
- const keepName = extractUploadNameFromUrl(keepUrl);
- const tasks = [];
- for (const name of Array.from(tempCoverUploads)) {
- if (keepName && name.toLowerCase() === keepName.toLowerCase()) continue;
- tasks.push(deleteUploadByName(name));
- }
- tempCoverUploads.clear();
- if (tasks.length) await Promise.allSettled(tasks);
- }
- const coverUrlInput = el("input", { class: "input", placeholder: "封面图 URL(可选)", value: isEdit ? res.coverUrl || "" : "" });
- const coverPreview = el("img", {
- class: "resource-detail-cover cover-picker-img",
- src: (coverUrlInput.value || "").trim() ? coverUrlInput.value : defaultCoverUrl,
- alt: "cover",
- role: "button",
- tabindex: "0",
- });
- const coverFile = el("input", { type: "file", accept: "image/*", style: "display:none" });
- coverPreview.addEventListener("click", () => coverFile.click());
- coverPreview.addEventListener("keydown", (evt) => {
- if (evt.key === "Enter" || evt.key === " ") {
- evt.preventDefault();
- coverFile.click();
- }
- });
- coverFile.addEventListener("change", async () => {
- const f = coverFile.files && coverFile.files[0];
- if (!f) return;
- msg.textContent = "上传中...";
- try {
- const detail = await adminUploadFileMeta(f);
- const url = detail?.url || "";
- if (detail?.name) tempCoverUploads.add(String(detail.name));
- coverUrlInput.value = url;
- coverPreview.src = url;
- coverPreview.classList.remove("is-placeholder");
- msg.textContent = "封面已更新";
- } catch (e) {
- msg.textContent = `上传失败:${e.detail?.error || e.status || "unknown"}`;
- if (e.status === 401) window.location.href = "/ui/admin/login";
- } finally {
- coverFile.value = "";
- }
- });
- let coverPreviewTimer = null;
- function refreshCoverPreview() {
- const url = coverUrlInput.value.trim();
- if (!url) {
- coverPreview.src = defaultCoverUrl;
- coverPreview.classList.add("is-placeholder");
- return;
- }
- coverPreview.src = url;
- coverPreview.classList.remove("is-placeholder");
- }
- coverUrlInput.addEventListener("input", () => {
- if (coverPreviewTimer) clearTimeout(coverPreviewTimer);
- coverPreviewTimer = setTimeout(refreshCoverPreview, 250);
- });
- coverUrlInput.addEventListener("blur", refreshCoverPreview);
- refreshCoverPreview();
- function normalizeKeywordsValue(raw) {
- const text = (raw || "").toString();
- const parts = text
- .split(/[,,\n\r\t]+/g)
- .map((s) => s.trim())
- .filter(Boolean);
- const uniq = [];
- const seen = new Set();
- parts.forEach((p) => {
- const key = p.toLowerCase();
- if (seen.has(key)) return;
- seen.add(key);
- uniq.push(p);
- });
- return uniq.join(",");
- }
- keywordsInput.addEventListener("blur", () => {
- keywordsInput.value = normalizeKeywordsValue(keywordsInput.value);
- });
- const md = buildMarkdownEditor({ initialValue: isEdit ? res.summary || "" : "", msgEl: msg });
- const modeSeg = makeSegmented(
- [
- { value: "CREATE", label: "创建仓库" },
- { value: "BIND", label: "绑定仓库" },
- ],
- isEdit ? "BIND" : "CREATE",
- { disabled: isEdit }
- );
- const modeHelp = el("div", { class: "help" }, isEdit ? "编辑模式下仓库模式固定为绑定仓库。" : "创建仓库会初始化 README.md;绑定仓库可选择分支/标签。");
- const createOwnerInput = el("input", { class: "input", placeholder: "仓库 Owner(可选,留空则创建到 Token 用户)" });
- const createRepoInput = el("input", { class: "input", placeholder: "仓库名称(可选,留空则自动生成)" });
- const createPrivateSeg = makeSegmented(
- [
- { value: "0", label: "公开" },
- { value: "1", label: "私有" },
- ],
- "0"
- );
- const repoFullInput = el("input", { class: "input", placeholder: "仓库(owner/repo 或 URL/SSH 地址)" });
- const refInput = el("input", { class: "input", placeholder: "默认引用(AUTO/分支/标签)", value: "AUTO" });
- const refPickSelect = el("select", { class: "input" }, el("option", { value: "" }, "选择分支/标签(可选)"));
- refPickSelect.addEventListener("change", () => {
- if (refPickSelect.value) refInput.value = refPickSelect.value;
- });
- const pickRepoBtn = el("button", { class: "btn" }, "选择仓库");
- const refreshRefBtn = el("button", { class: "btn btn-ghost" }, "刷新分支/标签");
- const repoHint = el("div", { class: "help" }, "");
- async function loadRepoAndRefs(prefer) {
- const parsed = parseRepoInput(repoFullInput.value);
- if (!parsed) {
- repoHint.textContent = "请填写正确的仓库格式:owner/repo(或直接粘贴仓库地址)";
- return;
- }
- try {
- const info = await apiFetch(`/admin/gogs/repo?owner=${encodeURIComponent(parsed.owner)}&repo=${encodeURIComponent(parsed.repo)}`);
- const wanted = (prefer || refInput.value || "AUTO").toString().trim() || "AUTO";
- await fillRefSelect(parsed.owner, parsed.repo, refPickSelect, wanted);
- if (!refInput.value.trim()) refInput.value = wanted;
- repoHint.textContent = `仓库已识别:${info.fullName || `${parsed.owner}/${parsed.repo}`};默认分支:${(info.defaultBranch || "master").trim()}`;
- } catch (e) {
- const errCode = e.detail?.error || e.status || "unknown";
- const upstream = e.detail?.status ? `(Gogs: ${e.detail.status})` : "";
- repoHint.textContent = `仓库加载失败:${errCode}${upstream}`;
- showToastError(`仓库加载失败:${errCode}`);
- if (e.status === 401) window.location.href = "/ui/admin/login";
- }
- }
- pickRepoBtn.addEventListener("click", () => {
- const parsed = parseRepoInput(repoFullInput.value);
- const initialOwner = parsed ? parsed.owner : "";
- openRepoPicker(initialOwner, async ({ owner, name, fullName }) => {
- if (fullName) repoFullInput.value = fullName;
- else if (owner && name) repoFullInput.value = `${owner}/${name}`;
- await loadRepoAndRefs("AUTO");
- });
- });
- refreshRefBtn.addEventListener("click", async () => {
- await loadRepoAndRefs(refInput.value.trim() || "AUTO");
- });
- repoFullInput.addEventListener("blur", async () => {
- if (!repoFullInput.value.trim()) return;
- await loadRepoAndRefs(refInput.value.trim() || "AUTO");
- });
- const createOwnerWrap = field("仓库 Owner(可选)", createOwnerInput);
- const createRepoWrap = field("仓库名称(可选)", createRepoInput);
- const createPrivateWrap = el("div", {}, el("div", { class: "label" }, "公开/私有"), createPrivateSeg.root, el("div", { class: "help" }, "创建仓库时生效。"));
- const repoFullWrap = el("div", { style: "grid-column: 1 / -1" }, el("div", { class: "label" }, "仓库(owner/repo 或 URL/SSH)"), repoFullInput);
- const refWrap = field("默认引用", refInput);
- const refPickWrap = field("选择分支/标签", refPickSelect);
- const repoActionsWrap = el(
- "div",
- { style: "grid-column: 1 / -1" },
- el("div", { class: "toolbar", style: "margin:0" }, pickRepoBtn, refreshRefBtn)
- );
- function refreshMode() {
- const isCreate = modeSeg.getValue() === "CREATE";
- [createOwnerWrap, createRepoWrap, createPrivateWrap].forEach((n) => (n.style.display = isCreate ? "" : "none"));
- [repoFullWrap, refWrap, refPickWrap, repoActionsWrap].forEach((n) => (n.style.display = isCreate ? "none" : ""));
- repoHint.style.display = isCreate ? "none" : "";
- }
- modeSeg.root.addEventListener("click", refreshMode);
- if (isEdit) {
- repoFullInput.value = `${res.repoOwner}/${res.repoName}`;
- refInput.value = (res.defaultRef || "AUTO").toString().trim() || "AUTO";
- refreshMode();
- setTimeout(() => loadRepoAndRefs(refInput.value.trim() || "AUTO"), 0);
- } else {
- refreshMode();
- }
- function makeCollapse(title, bodyNodes, open) {
- const icon = el("i", { class: "ri-arrow-down-s-line collapse-icon" });
- const head = el("button", { type: "button", class: "collapse-head" }, el("span", {}, title), icon);
- const body = el("div", { class: "collapse-body" }, ...bodyNodes);
- const wrap = el("div", { class: "collapse", "data-open": open ? "1" : "0" }, head, body);
- function setOpen(next) {
- wrap.setAttribute("data-open", next ? "1" : "0");
- }
- head.addEventListener("click", (evt) => {
- evt.preventDefault();
- const cur = wrap.getAttribute("data-open") === "1";
- setOpen(!cur);
- });
- return { root: wrap, setOpen };
- }
- const baseSection = makeCollapse(
- "基础属性",
- [
- el(
- "div",
- { class: "form-grid" },
- el("div", { style: "grid-column: 1 / -1" }, el("div", { class: "label" }, "标题"), titleInput),
- el("div", {}, el("div", { class: "label" }, "类型"), typeSeg.root, typeHelp),
- el("div", {}, el("div", { class: "label" }, "状态"), statusSeg.root, statusHelp),
- el("div", { style: "grid-column: 1 / -1" }, el("div", { class: "label" }, "关键字(逗号分隔,可选)"), keywordsInput, el("div", { class: "help" }, "支持中英文逗号/换行分隔,失焦时自动去重规范化。"))
- ),
- ],
- true
- );
- const clearCoverBtn = el("button", { type: "button", class: "btn btn-ghost" }, "清空");
- clearCoverBtn.addEventListener("click", (evt) => {
- evt.preventDefault();
- coverUrlInput.value = "";
- try {
- coverUrlInput.dispatchEvent(new Event("input", { bubbles: true }));
- } catch (e) {}
- refreshCoverPreview();
- });
- const coverSection = makeCollapse(
- "封面",
- [
- el(
- "div",
- { class: "form-grid" },
- el("div", { style: "grid-column: 1 / -1" }, el("div", { class: "help" }, "点击图片选择并上传封面")),
- el("div", { style: "grid-column: 1 / -1" }, coverPreview),
- el("div", { style: "grid-column: 1 / -1" }, el("div", { class: "label" }, "封面图 URL(可选)"), coverUrlInput),
- el("div", { style: "grid-column: 1 / -1" }, el("div", { class: "toolbar", style: "margin:0" }, clearCoverBtn, coverFile))
- ),
- ],
- false
- );
- const repoModeSection = makeCollapse(
- "仓库",
- [
- el(
- "div",
- { class: "form-grid" },
- el("div", { style: "grid-column: 1 / -1" }, el("div", { class: "label" }, "仓库模式"), modeSeg.root, modeHelp),
- el("div", { style: "grid-column: 1 / -1" }, repoHint),
- createOwnerWrap,
- createRepoWrap,
- createPrivateWrap,
- repoFullWrap,
- refWrap,
- refPickWrap,
- repoActionsWrap
- ),
- ],
- false
- );
- const contentSection = el("div", { class: "form-section" }, el("div", { class: "form-section-title" }, "内容编辑"), md.root);
- const side = el("div", { class: "res-form-side" }, baseSection.root, coverSection.root, repoModeSection.root);
- const main = el("div", { class: "res-form-main" }, contentSection, msg);
- const layout = el("div", { class: "res-form-layout" }, side, main);
- function clearInvalid() {
- [titleInput, keywordsInput, coverUrlInput, repoFullInput, refInput].forEach((x) => x.classList.remove("is-invalid"));
- typeSeg.setInvalid(false);
- statusSeg.setInvalid(false);
- modeSeg.setInvalid(false);
- createPrivateSeg.setInvalid(false);
- }
- function invalid(elOrSeg, text) {
- if (elOrSeg === titleInput || elOrSeg === keywordsInput || elOrSeg === typeSeg || elOrSeg === statusSeg) baseSection.setOpen(true);
- if (elOrSeg === coverUrlInput) coverSection.setOpen(true);
- if (elOrSeg === repoFullInput || elOrSeg === refInput || elOrSeg === refPickSelect || elOrSeg === modeSeg || elOrSeg === createPrivateSeg) repoModeSection.setOpen(true);
- if (elOrSeg && typeof elOrSeg.setInvalid === "function") elOrSeg.setInvalid(true);
- else if (elOrSeg && elOrSeg.classList) elOrSeg.classList.add("is-invalid");
- msg.textContent = String(text || "");
- showToastError(text || "请检查填写内容");
- try {
- if (elOrSeg && elOrSeg.focus) elOrSeg.focus();
- } catch (e) {}
- }
- async function fetchReadmeText() {
- const parsed = parseRepoInput(repoFullInput.value);
- if (!parsed) throw Object.assign(new Error("invalid_repo"), { detail: { error: "invalid_repo" } });
- const ref = refInput.value.trim() || "AUTO";
- const url = `/admin/gogs/file-text?owner=${encodeURIComponent(parsed.owner)}&repo=${encodeURIComponent(parsed.repo)}&ref=${encodeURIComponent(ref)}&path=${encodeURIComponent("README.md")}`;
- const resp = await apiFetch(url);
- return (resp.text || "").toString();
- }
- async function loadReadmeIntoEditor() {
- msg.textContent = "";
- const cur = (md.textarea.value || "").toString();
- if (cur.trim()) {
- try {
- const r = await Swal.fire({
- title: "用 README.md 覆盖当前内容?",
- text: "覆盖后当前未保存内容将丢失。",
- icon: "warning",
- showCancelButton: true,
- confirmButtonText: "覆盖",
- cancelButtonText: "取消",
- confirmButtonColor: "var(--danger)",
- });
- if (!r.isConfirmed) return;
- } catch (e) {}
- }
- try {
- msg.textContent = "加载 README.md 中...";
- const text = await fetchReadmeText();
- md.setText(text);
- md.syncReadme.checked = true;
- msg.textContent = "已从 README.md 导入";
- showToastSuccess("README.md 已导入");
- } catch (e) {
- const code = e.detail?.error || e.status || e.message || "unknown";
- msg.textContent = `README.md 加载失败:${code}`;
- showToastError(`README.md 加载失败:${code}`);
- if (e.status === 401) window.location.href = "/ui/admin/login";
- }
- }
- const loadReadmeBtn = el("button", { type: "button", class: "btn btn-sm" }, "加载 README.md");
- loadReadmeBtn.addEventListener("click", async (evt) => {
- evt.preventDefault();
- await loadReadmeIntoEditor();
- });
- function refreshReadmeBtn() {
- const allow = isEdit || modeSeg.getValue() === "BIND";
- const hasRepo = Boolean(parseRepoInput(repoFullInput.value));
- loadReadmeBtn.disabled = !(allow && hasRepo);
- }
- repoFullInput.addEventListener("input", refreshReadmeBtn);
- modeSeg.root.addEventListener("click", refreshReadmeBtn);
- refreshReadmeBtn();
- const spacer = Array.from(md.toolbarEl.children).find((n) => n && n.style && n.style.flex === "1");
- if (spacer) md.toolbarEl.insertBefore(loadReadmeBtn, spacer);
- else md.toolbarEl.appendChild(loadReadmeBtn);
- if (isEdit && !(md.textarea.value || "").trim()) {
- setTimeout(() => loadReadmeIntoEditor(), 0);
- }
- async function submitAndMaybeView(openAfter) {
- msg.textContent = "";
- clearInvalid();
- const title = titleInput.value.trim();
- if (!title) {
- invalid(titleInput, "请填写标题");
- return;
- }
- if (isEdit) {
- const parsed = parseRepoInput(repoFullInput.value);
- if (!parsed) {
- invalid(repoFullInput, "请填写正确的仓库格式:owner/repo(或直接粘贴仓库地址)");
- return;
- }
- msg.textContent = "保存中,请稍候...";
- try {
- await apiFetch(`/admin/resources/${res.id}`, {
- method: "PUT",
- body: {
- title,
- summary: md.textarea.value.trim(),
- keywords: normalizeKeywordsValue(keywordsInput.value),
- coverUrl: coverUrlInput.value.trim(),
- type: typeSeg.getValue(),
- status: statusSeg.getValue(),
- repoOwner: parsed.owner,
- repoName: parsed.repo,
- defaultRef: refInput.value.trim() || "AUTO",
- syncReadme: md.syncReadme.checked,
- },
- });
- await cleanupTempCoverUploads(coverUrlInput.value.trim());
- await closeModal(true);
- await loadResources();
- if (openAfter) window.open(`/ui/resources/${res.id}`, "_blank");
- showToastSuccess("已保存");
- } catch (e) {
- msg.textContent = `保存失败:${e.detail?.error || e.status || "unknown"}`;
- showToastError(e.detail?.error || e.status || "保存失败");
- if (e.status === 401) window.location.href = "/ui/admin/login";
- }
- return;
- }
- msg.textContent = "创建中,请稍候...";
- const body = {
- title,
- summary: md.textarea.value.trim(),
- keywords: normalizeKeywordsValue(keywordsInput.value),
- coverUrl: coverUrlInput.value.trim(),
- type: typeSeg.getValue(),
- status: statusSeg.getValue(),
- syncReadme: md.syncReadme.checked,
- };
- if (modeSeg.getValue() === "CREATE") {
- body.createRepo = true;
- body.repoOwner = createOwnerInput.value.trim();
- body.repoName = createRepoInput.value.trim();
- body.repoPrivate = createPrivateSeg.getValue() === "1";
- } else {
- const parsed = parseRepoInput(repoFullInput.value);
- if (!parsed) {
- invalid(repoFullInput, "请填写正确的仓库格式:owner/repo(或直接粘贴仓库地址)");
- return;
- }
- body.createRepo = false;
- body.repoOwner = parsed.owner;
- body.repoName = parsed.repo;
- body.defaultRef = refInput.value.trim() || "AUTO";
- }
- try {
- const resp = await apiFetch("/admin/resources", { method: "POST", body });
- await cleanupTempCoverUploads(coverUrlInput.value.trim());
- await closeModal(true);
- await loadResources();
- if (openAfter) window.open(`/ui/resources/${resp.id}`, "_blank");
- showToastSuccess("已创建");
- } catch (e) {
- const code = e.detail?.error || e.status || "unknown";
- const upstream = e.detail?.status ? `(Gogs: ${e.detail.status})` : "";
- const detailMsg = e.detail?.message ? `\n${e.detail.message}` : "";
- const detailUrl = e.detail?.url ? `\n${e.detail.url}` : "";
- msg.textContent = `${isEdit ? "保存" : "创建"}失败:${code}${upstream}${detailMsg}${detailUrl}`;
- showToastError(`${isEdit ? "保存" : "创建"}失败:${code}`);
- if (e.status === 401) window.location.href = "/ui/admin/login";
- }
- }
- const primaryBtn = el("button", { class: "btn btn-primary" }, isEdit ? "保存" : "创建");
- const viewBtn = el("button", { class: "btn" }, isEdit ? "保存并查看" : "创建并查看");
- primaryBtn.addEventListener("click", async () => {
- primaryBtn.disabled = true;
- viewBtn.disabled = true;
- try {
- await submitAndMaybeView(false);
- } finally {
- primaryBtn.disabled = false;
- viewBtn.disabled = false;
- }
- });
- viewBtn.addEventListener("click", async () => {
- primaryBtn.disabled = true;
- viewBtn.disabled = true;
- try {
- await submitAndMaybeView(true);
- } finally {
- primaryBtn.disabled = false;
- viewBtn.disabled = false;
- }
- });
- const initialDraft = JSON.stringify({
- title: titleInput.value,
- keywords: keywordsInput.value,
- type: typeSeg.getValue(),
- status: statusSeg.getValue(),
- coverUrl: coverUrlInput.value,
- summary: md.textarea.value,
- syncReadme: md.syncReadme.checked,
- repoMode: modeSeg.getValue(),
- createOwner: createOwnerInput.value,
- createRepo: createRepoInput.value,
- createPrivate: createPrivateSeg.getValue(),
- repoFull: repoFullInput.value,
- ref: refInput.value,
- });
- function isDirty() {
- const now = JSON.stringify({
- title: titleInput.value,
- keywords: keywordsInput.value,
- type: typeSeg.getValue(),
- status: statusSeg.getValue(),
- coverUrl: coverUrlInput.value,
- summary: md.textarea.value,
- syncReadme: md.syncReadme.checked,
- repoMode: modeSeg.getValue(),
- createOwner: createOwnerInput.value,
- createRepo: createRepoInput.value,
- createPrivate: createPrivateSeg.getValue(),
- repoFull: repoFullInput.value,
- ref: refInput.value,
- });
- return now !== initialDraft;
- }
- openModal(isEdit ? `编辑资源 #${res.id}` : "新增资源", [layout], [el("button", { class: "btn", onclick: closeModal }, "取消"), viewBtn, primaryBtn], isEdit ? "ri-edit-circle-line" : "ri-add-box-line", {
- resizable: true,
- size: "lg",
- onResize: (size) => md.setViewByModalSize(size),
- onKeydown: (evt) => {
- const key = String(evt.key || "");
- if ((evt.ctrlKey || evt.metaKey) && key.toLowerCase() === "s") {
- evt.preventDefault();
- primaryBtn.click();
- return;
- }
- if ((evt.ctrlKey || evt.metaKey) && key === "Enter") {
- evt.preventDefault();
- primaryBtn.click();
- return;
- }
- if (key === "Escape") {
- evt.preventDefault();
- closeModal();
- }
- },
- beforeClose: async () => {
- if (!isDirty()) {
- await cleanupTempCoverUploads("");
- return true;
- }
- try {
- const r = await Swal.fire({
- title: "放弃未保存的修改?",
- text: "当前内容尚未保存,关闭后将丢失。",
- icon: "warning",
- showCancelButton: true,
- confirmButtonText: "放弃修改",
- cancelButtonText: "继续编辑",
- confirmButtonColor: "var(--danger)",
- });
- if (!r.isConfirmed) return false;
- await cleanupTempCoverUploads("");
- return true;
- } catch (e) {
- await cleanupTempCoverUploads("");
- return true;
- }
- },
- });
- }
- createResOpenBtn.addEventListener("click", () => {
- openResourceEditorModal({ mode: "create" });
- });
- resSearchBtn.addEventListener("click", async () => {
- resState.page = 1;
- await loadResources();
- });
- resPrevPage.addEventListener("click", async () => {
- resState.page = Math.max(1, resState.page - 1);
- await loadResources();
- });
- resNextPage.addEventListener("click", async () => {
- resState.page = resState.page + 1;
- await loadResources();
- });
- userSearchBtn.addEventListener("click", async () => {
- userState.page = 1;
- await loadUsers();
- });
- if (dlSearchBtn) {
- dlSearchBtn.addEventListener("click", async () => {
- downloadLogState.page = 1;
- await loadDownloadLogs();
- });
- }
- if (dlQ) {
- dlQ.addEventListener("keydown", async (evt) => {
- if (evt.key !== "Enter") return;
- evt.preventDefault();
- downloadLogState.page = 1;
- await loadDownloadLogs();
- });
- }
- if (dlPrevPage) {
- dlPrevPage.addEventListener("click", async () => {
- downloadLogState.page = Math.max(1, downloadLogState.page - 1);
- await loadDownloadLogs();
- });
- }
- if (dlNextPage) {
- dlNextPage.addEventListener("click", async () => {
- downloadLogState.page += 1;
- await loadDownloadLogs();
- });
- }
- if (msgSearchBtn) {
- msgSearchBtn.addEventListener("click", async () => {
- messageState.page = 1;
- await loadAdminMessages();
- });
- }
- if (msgQ) {
- msgQ.addEventListener("keydown", async (evt) => {
- if (evt.key !== "Enter") return;
- evt.preventDefault();
- messageState.page = 1;
- await loadAdminMessages();
- });
- }
- if (msgPrevPage) {
- msgPrevPage.addEventListener("click", async () => {
- messageState.page = Math.max(1, messageState.page - 1);
- await loadAdminMessages();
- });
- }
- if (msgNextPage) {
- msgNextPage.addEventListener("click", async () => {
- messageState.page += 1;
- await loadAdminMessages();
- });
- }
- if (msgSendBtn) {
- msgSendBtn.addEventListener("click", async () => {
- const phoneInput = el("input", { class: "input", placeholder: "用户手机号(已注册)" });
- const userIdInput = el("input", { class: "input", placeholder: "用户ID(可选,优先于手机号)" });
- const titleInput = el("input", { class: "input", placeholder: "标题(必填)" });
- const contentInput = el("textarea", { class: "input", style: "min-height: 180px; resize: vertical;", placeholder: "内容(必填)" });
- const msg = el("div", { class: "muted" }, "");
- openModal(
- "发送消息",
- [
- el("div", { class: "muted" }, "发送给单个用户。填写用户ID或手机号即可。"),
- el("label", { class: "label" }, "用户ID"),
- userIdInput,
- el("label", { class: "label" }, "手机号"),
- phoneInput,
- el("label", { class: "label" }, "标题"),
- titleInput,
- el("label", { class: "label" }, "内容"),
- contentInput,
- msg,
- ],
- [
- el("button", { class: "btn", onclick: closeModal }, "取消"),
- el(
- "button",
- {
- class: "btn btn-primary",
- onclick: async () => {
- msg.textContent = "";
- try {
- const userId = Number((userIdInput.value || "").trim() || 0) || 0;
- await apiFetch("/admin/messages/send", {
- method: "POST",
- body: { userId, phone: phoneInput.value.trim(), title: titleInput.value.trim(), content: contentInput.value },
- });
- closeModal();
- showToastSuccess("发送成功");
- await loadAdminMessages();
- } catch (e) {
- msg.textContent = `发送失败:${e.detail?.error || e.status || "unknown"}`;
- if (e.status === 401) window.location.href = "/ui/admin/login";
- }
- },
- },
- "发送"
- ),
- ],
- "ri-notification-3-line"
- );
- });
- }
- if (msgBroadcastBtn) {
- msgBroadcastBtn.addEventListener("click", async () => {
- const audienceSelect = el(
- "select",
- { class: "input" },
- el("option", { value: "ALL" }, "全部用户"),
- el("option", { value: "VIP" }, "仅 VIP 用户"),
- el("option", { value: "NONVIP" }, "仅非 VIP 用户")
- );
- const titleInput = el("input", { class: "input", placeholder: "标题(必填)" });
- const contentInput = el("textarea", { class: "input", style: "min-height: 200px; resize: vertical;", placeholder: "内容(必填)" });
- const msg = el("div", { class: "muted" }, "");
- openModal(
- "群发消息",
- [
- el("div", { class: "muted" }, "将为符合条件的每个用户生成一条站内消息。"),
- el("label", { class: "label" }, "发送范围"),
- audienceSelect,
- el("label", { class: "label" }, "标题"),
- titleInput,
- el("label", { class: "label" }, "内容"),
- contentInput,
- msg,
- ],
- [
- el("button", { class: "btn", onclick: closeModal }, "取消"),
- el(
- "button",
- {
- class: "btn btn-primary",
- onclick: async () => {
- msg.textContent = "";
- try {
- const r = await Swal.fire({
- title: "确认群发?",
- text: "将立即发送站内消息给符合条件的用户。",
- icon: "warning",
- showCancelButton: true,
- confirmButtonText: "确认发送",
- cancelButtonText: "取消",
- confirmButtonColor: "var(--brand)",
- });
- if (!r.isConfirmed) return;
- const resp = await apiFetch("/admin/messages/broadcast", {
- method: "POST",
- body: { audience: audienceSelect.value, title: titleInput.value.trim(), content: contentInput.value },
- });
- closeModal();
- showToastSuccess(`已发送 ${resp.count || 0} 条`);
- await loadAdminMessages();
- } catch (e) {
- msg.textContent = `发送失败:${e.detail?.error || e.status || "unknown"}`;
- if (e.status === 401) window.location.href = "/ui/admin/login";
- }
- },
- },
- "发送"
- ),
- ],
- "ri-megaphone-line"
- );
- });
- }
- userPrevPage.addEventListener("click", async () => {
- userState.page = Math.max(1, userState.page - 1);
- await loadUsers();
- });
- userNextPage.addEventListener("click", async () => {
- userState.page = userState.page + 1;
- await loadUsers();
- });
- orderRefreshBtn.addEventListener("click", async () => {
- orderState.page = 1;
- await loadOrders();
- });
- orderCreateBtn.addEventListener("click", async () => {
- const phoneInput = el("input", { class: "input", placeholder: "用户手机号(必须已注册)" });
- const planSelect = el("select", { class: "input" }, el("option", { value: "" }, "加载中..."));
- const statusSelect = el(
- "select",
- { class: "input" },
- el("option", { value: "PENDING" }, "待支付"),
- el("option", { value: "PAID" }, "已支付"),
- el("option", { value: "CLOSED" }, "已关闭"),
- el("option", { value: "FAILED" }, "失败")
- );
- const msg = el("div", { class: "muted" }, "");
- openModal(
- "新建订单",
- [el("label", { class: "label" }, "用户手机号"), phoneInput, el("label", { class: "label" }, "方案"), planSelect, el("label", { class: "label" }, "状态"), statusSelect, el("div", { class: "muted" }, "设置为“已支付”会自动延长该用户会员。"), msg],
- [
- el("button", { class: "btn", onclick: closeModal }, "取消"),
- el(
- "button",
- {
- class: "btn btn-primary",
- onclick: async () => {
- msg.textContent = "";
- const phone = phoneInput.value.trim();
- const planId = Number(planSelect.value || "0");
- if (!phone || planId <= 0) {
- msg.textContent = "请填写手机号并选择方案";
- return;
- }
- try {
- await apiFetch("/admin/orders", { method: "POST", body: { userPhone: phone, planId, status: statusSelect.value } });
- closeModal();
- orderState.page = 1;
- await loadOrders();
- } catch (e) {
- msg.textContent = `创建失败:${e.detail?.error || e.status || "unknown"}`;
- if (e.status === 401) window.location.href = "/ui/admin/login";
- }
- },
- },
- "创建"
- ),
- ],
- "ri-add-circle-line"
- );
- try {
- const plans = await apiFetch("/admin/plans");
- planSelect.innerHTML = "";
- (plans || []).filter((p) => p && p.enabled).forEach((p) => {
- planSelect.appendChild(el("option", { value: String(p.id) }, `${p.name}(${p.durationDays}天 / ${formatCents(p.priceCents)})`));
- });
- if (!planSelect.children.length) planSelect.appendChild(el("option", { value: "" }, "暂无可用方案"));
- } catch (e) {
- planSelect.innerHTML = "";
- planSelect.appendChild(el("option", { value: "" }, "方案加载失败"));
- if (e.status === 401) window.location.href = "/ui/admin/login";
- }
- });
- orderStatusFilter.addEventListener("change", async () => {
- orderState.page = 1;
- await loadOrders();
- });
- orderPrevPage.addEventListener("click", async () => {
- orderState.page = Math.max(1, orderState.page - 1);
- await loadOrders();
- });
- orderNextPage.addEventListener("click", async () => {
- orderState.page = orderState.page + 1;
- await loadOrders();
- });
- /* inline creation handlers removed; now using modal-based creation */
- adminLogoutBtn.addEventListener("click", async () => {
- await apiFetch("/admin/auth/logout", { method: "POST" });
- window.location.href = "/ui/admin/login";
- });
- try {
- await activate("overview");
- } catch (e) {
- if (e.status === 401) window.location.href = "/ui/admin/login";
- }
- }
- async function main() {
- const page = document.body.getAttribute("data-page") || "";
- try {
- await initTopbar();
- if (page === "index") await pageIndex();
- if (page === "resources") await pageIndex();
- if (page === "login") await pageLogin();
- if (page === "register") await pageRegister();
- if (page === "me") await pageMe();
- if (page === "messages") await pageMessages();
- if (page === "vip") await pageVip();
- if (page === "resource_detail") await pageResourceDetail();
- if (page === "admin_login") await pageAdminLogin();
- if (page === "admin") await pageAdmin();
- } catch (e) {
- showToastError(e?.detail?.error || e?.status || e?.message || "页面初始化失败");
- }
- }
- main();
|