| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549 |
- 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 cfgCacheSaveBtn = document.getElementById("cfgCacheSaveBtn");
- const cfgCacheResetBtn = document.getElementById("cfgCacheResetBtn");
- const cfgRedisUrl = document.getElementById("cfgRedisUrl");
- const cfgClearRedisUrl = document.getElementById("cfgClearRedisUrl");
- const cfgRedisTestBtn = document.getElementById("cfgRedisTestBtn");
- const cfgCacheMsg = document.getElementById("cfgCacheMsg");
- const cfgStorageSaveBtn = document.getElementById("cfgStorageSaveBtn");
- const cfgStorageResetBtn = document.getElementById("cfgStorageResetBtn");
- const cfgStorageProvider = document.getElementById("cfgStorageProvider");
- const cfgOssEndpoint = document.getElementById("cfgOssEndpoint");
- const cfgOssBucket = document.getElementById("cfgOssBucket");
- const cfgOssAccessKeyId = document.getElementById("cfgOssAccessKeyId");
- const cfgOssAccessKeySecret = document.getElementById("cfgOssAccessKeySecret");
- const cfgClearOssAccessKeySecret = document.getElementById("cfgClearOssAccessKeySecret");
- const cfgOssUploadPrefix = document.getElementById("cfgOssUploadPrefix");
- const cfgOssPublicBaseUrl = document.getElementById("cfgOssPublicBaseUrl");
- 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 = "";
- if (cfgCacheMsg) cfgCacheMsg.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 (cfgRedisUrl) cfgRedisUrl.value = "";
- if (cfgClearRedisUrl) cfgClearRedisUrl.checked = false;
- if (cfgStorageProvider) cfgStorageProvider.value = "AUTO";
- if (cfgOssEndpoint) cfgOssEndpoint.value = "";
- if (cfgOssBucket) cfgOssBucket.value = "";
- if (cfgOssAccessKeyId) cfgOssAccessKeyId.value = "";
- if (cfgOssAccessKeySecret) cfgOssAccessKeySecret.value = "";
- if (cfgClearOssAccessKeySecret) cfgClearOssAccessKeySecret.checked = false;
- if (cfgOssUploadPrefix) cfgOssUploadPrefix.value = "";
- if (cfgOssPublicBaseUrl) cfgOssPublicBaseUrl.value = "";
- 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 (cfgRedisUrl) cfgRedisUrl.value = "";
- if (cfgClearRedisUrl) cfgClearRedisUrl.checked = false;
- if (cfgRedisUrl) {
- const safe = String(resp.cache?.redisUrl || "").trim();
- if (resp.cache?.hasRedisUrl) cfgRedisUrl.placeholder = safe ? `已配置,留空保持不变(${safe})` : "已配置,留空保持不变";
- else cfgRedisUrl.placeholder = "例如:redis://127.0.0.1:6379/0(无密码)或 redis://:password@127.0.0.1:6379/0(有密码)";
- }
- if (cfgStorageProvider) cfgStorageProvider.value = String(resp.storage?.provider || "AUTO").trim().toUpperCase() || "AUTO";
- if (cfgOssEndpoint) cfgOssEndpoint.value = String(resp.storage?.oss?.endpoint || "").trim();
- if (cfgOssBucket) cfgOssBucket.value = String(resp.storage?.oss?.bucket || "").trim();
- if (cfgOssAccessKeyId) cfgOssAccessKeyId.value = String(resp.storage?.oss?.accessKeyId || "").trim();
- if (cfgOssUploadPrefix) cfgOssUploadPrefix.value = String(resp.storage?.oss?.uploadPrefix || "").trim();
- if (cfgOssPublicBaseUrl) cfgOssPublicBaseUrl.value = String(resp.storage?.oss?.publicBaseUrl || "").trim();
- if (cfgOssAccessKeySecret) {
- if (resp.storage?.oss?.hasAccessKeySecret) cfgOssAccessKeySecret.placeholder = "已配置,留空保持不变";
- else cfgOssAccessKeySecret.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 = "未配置,填写后保存";
- }
- const configActiveDb = String(resp.db?.active || "").trim().toLowerCase();
- let effectiveDb = configActiveDb;
- let connectOk = true;
- let connectErr = "";
- try {
- const st = await apiFetch("/admin/db/status");
- effectiveDb = String(st.probe?.effective || configActiveDb || "").trim().toLowerCase() || configActiveDb;
- connectOk = Boolean(st.probe?.connectOk);
- connectErr = String(st.probe?.error || "").trim();
- } catch (e) {}
- if (cfgDbActive) {
- const suffix = connectOk ? "(OK)" : connectErr ? `(失败:${connectErr})` : "(失败)";
- cfgDbActive.textContent = `当前连接:${effectiveDb || "-"}${effectiveDb ? suffix : ""}`;
- }
- const mysqlConfigured = Boolean(
- String(resp.db?.mysql?.host || "").trim() &&
- String(resp.db?.mysql?.user || "").trim() &&
- String(resp.db?.mysql?.database || "").trim()
- );
- const hints = [];
- if (configActiveDb === "sqlite" && mysqlConfigured) {
- hints.push("提示:已配置 MySQL,但当前仍在 SQLite。点击“切换到 MySQL(迁移)”才会生效。");
- }
- if (configActiveDb && effectiveDb && configActiveDb !== effectiveDb) {
- hints.push(`提示:配置显示为 ${configActiveDb},但实际连接为 ${effectiveDb},请检查 MySQL 连接是否正常。`);
- }
- if (resp.cache?.hasRedisUrl) hints.push("缓存:已配置 Redis(共享缓存已启用)");
- else hints.push("缓存:未配置 Redis(使用本机进程内缓存)");
- 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 ? "已配置" : "未配置"}`,
- ...hints,
- ]
- .filter(Boolean)
- .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,
- },
- cache: {
- redisUrl: cfgRedisUrl ? cfgRedisUrl.value.trim() : "",
- clearRedisUrl: cfgClearRedisUrl ? cfgClearRedisUrl.checked : false,
- },
- storage: {
- provider: cfgStorageProvider ? cfgStorageProvider.value : "AUTO",
- oss: {
- endpoint: cfgOssEndpoint ? cfgOssEndpoint.value.trim() : "",
- bucket: cfgOssBucket ? cfgOssBucket.value.trim() : "",
- accessKeyId: cfgOssAccessKeyId ? cfgOssAccessKeyId.value.trim() : "",
- accessKeySecret: cfgOssAccessKeySecret ? cfgOssAccessKeySecret.value.trim() : "",
- clearAccessKeySecret: cfgClearOssAccessKeySecret ? cfgClearOssAccessKeySecret.checked : false,
- uploadPrefix: cfgOssUploadPrefix ? cfgOssUploadPrefix.value.trim() : "",
- publicBaseUrl: cfgOssPublicBaseUrl ? cfgOssPublicBaseUrl.value.trim() : "",
- },
- },
- },
- 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 testRedisConnection() {
- if (cfgCacheMsg) cfgCacheMsg.textContent = "";
- try {
- const url = cfgRedisUrl ? cfgRedisUrl.value.trim() : "";
- const resp = await apiFetch("/admin/redis/test", { method: "POST", body: url ? { url } : {} });
- if (resp.ok) {
- if (cfgCacheMsg) cfgCacheMsg.textContent = "Redis:连接成功";
- showToastSuccess("Redis:连接成功");
- } else {
- if (cfgCacheMsg) cfgCacheMsg.textContent = "Redis:连接失败";
- showToastError("Redis:连接失败");
- }
- } catch (e) {
- const err = e.detail?.error || e.status || "unknown";
- if (cfgCacheMsg) cfgCacheMsg.textContent = `Redis:连接失败(${err})`;
- showToastError(`Redis:连接失败(${err})`);
- 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);
- });
- }
- async function updateMdPreview() {
- if (typeof renderMarkdown !== "function") {
- await loadScriptOnce("/static/app_markdown.js");
- }
- 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 cacheCell = el("td", { id: `res-cache-${r.id}` }, badge("加载中", "badge"));
- const repoRefTitle = `${r.repoOwner}/${r.repoName} @ ${r.defaultRef}`;
- const repoRefCell = el(
- "td",
- { title: repoRefTitle },
- el("div", {}, `${r.repoOwner}/${r.repoName}`),
- el("div", { class: "muted" }, String(r.defaultRef || ""))
- );
- 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)),
- repoRefCell,
- el("td", { title: formatDateTime(r.updatedAt) }, formatDateTime(r.updatedAt)),
- cacheCell,
- el(
- "td",
- { class: "td-actions" },
- btnGroup(
- el("a", { class: "btn", href: `/ui/resources/${r.id}` }, "查看"),
- el("button", { class: "btn", "data-action": "cache-res", "data-id": String(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, "暂无数据");
- await loadResourceCacheSummaries(resources);
- 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;
- }
- function cacheSummaryBadge(summary) {
- if (!summary || !summary.ok) return badge("-", "badge");
- const jobs = Array.isArray(summary.jobs) ? summary.jobs : [];
- if (jobs.some((j) => j && j.state === "building")) return badge("生成中", "badge-warning");
- const count = Number(summary.count || 0);
- if (count <= 0) return badge("无", "badge");
- return badge(`${count}份`, "badge-success");
- }
- async function loadResourceCacheSummaries(resources) {
- const items = Array.isArray(resources) ? resources : [];
- const tasks = items.map((r) => async () => {
- const cell = document.getElementById(`res-cache-${r.id}`);
- if (!cell) return;
- try {
- const summary = await apiFetch(`/admin/resources/${r.id}/download-cache/summary`);
- cell.innerHTML = "";
- cell.appendChild(cacheSummaryBadge(summary));
- } catch (e) {
- cell.innerHTML = "";
- cell.appendChild(badge("失败", "badge-danger"));
- if (e.status === 401) window.location.href = "/ui/admin/login";
- }
- });
- const limit = 4;
- let idx = 0;
- const workers = new Array(Math.min(limit, tasks.length)).fill(0).map(async () => {
- while (idx < tasks.length) {
- const i = idx;
- idx += 1;
- await tasks[i]();
- }
- });
- await Promise.all(workers);
- }
- function openResourceDownloadCacheModal(res) {
- const refInput = el("input", { class: "input", value: String(res.defaultRef || "") });
- const msg = el("div", { class: "muted" }, "");
- const summaryBox = el("div", { class: "muted" }, "加载中…");
- const statusBox = el("div", { class: "muted" }, "");
- const listBox = el("div", {}, "");
- async function refreshAll() {
- msg.textContent = "";
- summaryBox.textContent = "加载中…";
- statusBox.textContent = "";
- listBox.innerHTML = "";
- try {
- const [summary, status, list] = await Promise.all([
- apiFetch(`/admin/resources/${res.id}/download-cache/summary`),
- apiFetch(`/admin/resources/${res.id}/download-cache/status?${new URLSearchParams({ ref: refInput.value.trim() || String(res.defaultRef || "") }).toString()}`),
- apiFetch(`/admin/resources/${res.id}/download-cache/list`),
- ]);
- const jobs = Array.isArray(summary.jobs) ? summary.jobs : [];
- const latest = summary.latest || null;
- summaryBox.textContent = [
- `缓存文件:${Number(summary.count || 0)} 份`,
- latest && latest.mtime ? `最近更新:${formatDateTime(latest.mtime)}` : "最近更新:-",
- jobs.some((j) => j && j.state === "building") ? "生成中" : "",
- ]
- .filter(Boolean)
- .join(" / ");
- if (status && status.ready) {
- statusBox.textContent = `当前ref已缓存:${String(status.commit || "").slice(0, 12) || "-"}(${String(status.ref || "")})`;
- } else if (status && status.state) {
- statusBox.textContent = `当前ref状态:${status.state}${status.error ? ` / ${status.error}` : ""}`;
- } else {
- statusBox.textContent = "当前ref状态:-";
- }
- const items = Array.isArray(list.items) ? list.items : [];
- if (!items.length) {
- listBox.appendChild(el("div", { class: "muted" }, "暂无缓存文件"));
- return;
- }
- const thead = el(
- "thead",
- {},
- el(
- "tr",
- {},
- el("th", {}, "commit"),
- el("th", {}, "ref"),
- el("th", {}, "大小"),
- el("th", {}, "更新时间"),
- el("th", {}, "TTL"),
- el("th", {}, "操作")
- )
- );
- const tbody = el("tbody", {}, "");
- items.forEach((it) => {
- const commit = String(it.commit || "");
- const ref = String(it.ref || "");
- const metaText = it.meta ? JSON.stringify(it.meta, null, 2) : "";
- const tr = el(
- "tr",
- {},
- el("td", { title: commit }, commit ? commit.slice(0, 12) : "-"),
- el("td", { title: ref }, ref || "-"),
- el("td", {}, it.bytes != null ? formatBytes(it.bytes) : "-"),
- el("td", {}, it.mtime ? formatDateTime(it.mtime) : "-"),
- el("td", {}, it.ttlRemainingSeconds == null ? "-" : `${Math.max(0, Number(it.ttlRemainingSeconds || 0))}s`),
- el(
- "td",
- {},
- btnGroup(
- el(
- "button",
- {
- class: "btn btn-sm",
- onclick: () => {
- if (!metaText) return;
- openModal("缓存元信息", [el("pre", { class: "pre" }, metaText)], [el("button", { class: "btn", onclick: closeModal }, "关闭")]);
- },
- },
- "查看"
- ),
- el(
- "a",
- {
- class: "btn btn-sm",
- href: commit ? `/admin/resources/${res.id}/download-cache/file?${new URLSearchParams({ commit }).toString()}` : "#",
- target: "_blank",
- rel: "noreferrer",
- onclick: (evt) => {
- if (!commit) evt.preventDefault();
- },
- },
- "下载"
- ),
- el(
- "button",
- {
- class: "btn btn-sm btn-danger",
- onclick: async () => {
- if (!commit) return;
- const r = await Swal.fire({
- title: "清理该缓存?",
- text: `commit:${commit.slice(0, 12)}`,
- icon: "warning",
- showCancelButton: true,
- confirmButtonText: "清理",
- cancelButtonText: "取消",
- confirmButtonColor: "var(--danger)",
- });
- if (!r.isConfirmed) return;
- await apiFetch(`/admin/resources/${res.id}/download-cache?${new URLSearchParams({ commit }).toString()}`, { method: "DELETE" });
- showToastSuccess("已清理");
- await refreshAll();
- await loadResources();
- },
- },
- "清理"
- )
- )
- )
- );
- tbody.appendChild(tr);
- });
- listBox.appendChild(el("table", { class: "table" }, thead, tbody));
- } catch (e) {
- msg.textContent = `加载失败:${e.detail?.error || e.status || "unknown"}`;
- if (e.status === 401) window.location.href = "/ui/admin/login";
- }
- }
- openModal(
- `下载缓存 #${res.id}`,
- [
- el("div", { class: "muted" }, `仓库:${res.repoOwner}/${res.repoName}`),
- el("label", { class: "label" }, "ref(用于检查/刷新)"),
- refInput,
- summaryBox,
- statusBox,
- msg,
- listBox,
- ],
- [
- el("button", { class: "btn", onclick: closeModal }, "关闭"),
- el(
- "button",
- {
- class: "btn",
- onclick: async () => {
- await refreshAll();
- },
- },
- "刷新"
- ),
- el(
- "button",
- {
- class: "btn",
- onclick: async () => {
- msg.textContent = "";
- try {
- const resp = await apiFetch(`/admin/resources/${res.id}/download-cache/refresh`, { method: "POST", body: { ref: refInput.value.trim() || String(res.defaultRef || "") } });
- showToastSuccess(resp.ready ? "已生成" : "已开始生成");
- await refreshAll();
- await loadResources();
- } catch (e) {
- msg.textContent = `刷新失败:${e.detail?.error || e.status || "unknown"}`;
- if (e.status === 401) window.location.href = "/ui/admin/login";
- }
- },
- },
- "强制刷新"
- ),
- el(
- "button",
- {
- class: "btn btn-danger",
- onclick: async () => {
- const r = await Swal.fire({
- title: "清理全部缓存?",
- text: "将删除该资源所有下载缓存文件。",
- icon: "warning",
- showCancelButton: true,
- confirmButtonText: "清理",
- cancelButtonText: "取消",
- confirmButtonColor: "var(--danger)",
- });
- if (!r.isConfirmed) return;
- try {
- await apiFetch(`/admin/resources/${res.id}/download-cache?${new URLSearchParams({ all: "1" }).toString()}`, { method: "DELETE" });
- showToastSuccess("已清理");
- await refreshAll();
- await loadResources();
- } catch (e) {
- msg.textContent = `清理失败:${e.detail?.error || e.status || "unknown"}`;
- if (e.status === 401) window.location.href = "/ui/admin/login";
- }
- },
- },
- "清理全部"
- ),
- ],
- "ri-archive-line"
- );
- refreshAll();
- }
- 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 (cfgCacheSaveBtn) {
- cfgCacheSaveBtn.addEventListener("click", async () => {
- await saveSettingsPartial({
- cache: {
- redisUrl: cfgRedisUrl ? cfgRedisUrl.value.trim() : "",
- clearRedisUrl: cfgClearRedisUrl ? cfgClearRedisUrl.checked : false,
- },
- });
- });
- }
- if (cfgCacheResetBtn) {
- cfgCacheResetBtn.addEventListener("click", async () => {
- await loadSettings();
- const g = document.getElementById("cfgGroupCache");
- if (g) g.setAttribute("data-open", "1");
- });
- }
- if (cfgRedisTestBtn) {
- cfgRedisTestBtn.addEventListener("click", async () => {
- await testRedisConnection();
- });
- }
- if (cfgStorageSaveBtn) {
- cfgStorageSaveBtn.addEventListener("click", async () => {
- await saveSettingsPartial({
- storage: {
- provider: cfgStorageProvider ? cfgStorageProvider.value : "AUTO",
- oss: {
- endpoint: cfgOssEndpoint ? cfgOssEndpoint.value.trim() : "",
- bucket: cfgOssBucket ? cfgOssBucket.value.trim() : "",
- accessKeyId: cfgOssAccessKeyId ? cfgOssAccessKeyId.value.trim() : "",
- accessKeySecret: cfgOssAccessKeySecret ? cfgOssAccessKeySecret.value.trim() : "",
- clearAccessKeySecret: cfgClearOssAccessKeySecret ? cfgClearOssAccessKeySecret.checked : false,
- uploadPrefix: cfgOssUploadPrefix ? cfgOssUploadPrefix.value.trim() : "",
- publicBaseUrl: cfgOssPublicBaseUrl ? cfgOssPublicBaseUrl.value.trim() : "",
- },
- },
- });
- });
- }
- if (cfgStorageResetBtn) {
- cfgStorageResetBtn.addEventListener("click", async () => {
- await loadSettings();
- const g = document.getElementById("cfgGroupStorage");
- 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 === "cache-res") {
- const res = resourceMap.get(String(id));
- if (!res) return;
- openResourceDownloadCacheModal(res);
- }
- 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",
- });
- ensureImgFallback(coverPreview, defaultCoverUrl, "is-placeholder");
- 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 === "admin_login") await pageAdminLogin();
- if (page === "admin") await pageAdmin();
- } catch (e) {
- showToastError(e?.detail?.error || e?.status || e?.message || "页面初始化失败");
- }
- }
- main();
|