02-源代码文档.txt 196 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824
  1. ==============================================================================
  2. 智音软件 V1.0 源代码文档
  3. ==============================================================================
  4. 软件全称:智音软件
  5. 软件简称:智音
  6. 版 本 号:V1.0
  7. 说明:本文档包含软件前30页和后30页源代码,每页50行。
  8. 前30页为软件前端代码,后30页为软件后端代码。
  9. ##############################################################################
  10. # 第一部分:前端源代码(前30页)
  11. ##############################################################################
  12. // ═══════════════════════════════════════════════════════════════
  13. // 文件: my-uniapp-vue3/src/main.ts
  14. // ═══════════════════════════════════════════════════════════════
  15. import { createSSRApp } from 'vue';
  16. import { createPinia } from 'pinia';
  17. import App from './App.vue';
  18. import { useUserStore } from './store/user';
  19. // #ifdef H5
  20. import VConsole from 'vconsole';
  21. // #endif
  22. export function createApp() {
  23. const app = createSSRApp(App);
  24. const pinia = createPinia();
  25. app.use(pinia);
  26. // 初始化用户状态
  27. const userStore = useUserStore();
  28. userStore.initUser();
  29. // #ifdef H5
  30. // vConsole 开启,用于调试微信支付问题
  31. new VConsole();
  32. // #endif
  33. return {
  34. app,
  35. pinia,
  36. };
  37. }
  38. // ═══════════════════════════════════════════════════════════════
  39. // 文件: my-uniapp-vue3/src/App.vue
  40. // ═══════════════════════════════════════════════════════════════
  41. <script setup lang="ts">
  42. import { onLaunch, onShow, onHide } from '@dcloudio/uni-app';
  43. import { createPinia } from 'pinia';
  44. import { useUserStore } from './store/user';
  45. import { initDebug } from './utils/debug';
  46. import { initAnalytics } from './utils/analytics';
  47. import MiniPlayer from './components/MiniPlayer.vue';
  48. import CreditInsufficientModal from './components/CreditInsufficientModal.vue';
  49. // 初始化全局调试工具
  50. initDebug();
  51. // ==================== 微信环境检测 ====================
  52. function isInWechatBrowser(): boolean {
  53. // #ifdef H5
  54. const ua = navigator.userAgent.toLowerCase();
  55. return ua.includes('micromessenger');
  56. // #endif
  57. return false;
  58. }
  59. // ==================== 微信 OAuth 回调处理 ====================
  60. async function handleWechatOAuthCallback() {
  61. // 只在 H5 环境处理
  62. // #ifdef H5
  63. const searchParams = new URLSearchParams(window.location.search);
  64. const code = searchParams.get('code');
  65. const state = searchParams.get('state');
  66. console.log('[App] handleWechatOAuthCallback 触发', { code: !!code, fullUrl: window.location.href });
  67. if (!code) {
  68. console.log('[App] 无 code 参数,跳过 OAuth 处理');
  69. return; // 不是 OAuth 回调,跳过
  70. }
  71. console.log('[App] 检测到微信 OAuth 回调,处理 code...');
  72. uni.showLoading({ title: '授权中...' });
  73. try {
  74. const { post } = await import('./utils/request');
  75. const result = await post<{ openid: string }>('/payment/wechat/openid', { code });
  76. if (result.openid) {
  77. console.log('[App] OpenID 获取成功');
  78. // 先尝试根据 openid 自动登录
  79. try {
  80. const userStore = useUserStore();
  81. await userStore.loginByOpenid(result.openid);
  82. console.log('[App] OpenID 已绑定,自动登录成功');
  83. // 清除 URL 参数,停留在当前页
  84. searchParams.delete('code');
  85. searchParams.delete('state');
  86. const newSearch = searchParams.toString();
  87. const newUrl = window.location.origin + window.location.pathname
  88. + (newSearch ? '?' + newSearch : '') + window.location.hash;
  89. window.history.replaceState({}, '', newUrl);
  90. uni.hideLoading();
  91. uni.showToast({ title: '自动登录成功', icon: 'success' });
  92. return;
  93. } catch (e: any) {
  94. if (e?.message?.includes('未绑定') || e?.code === -1) {
  95. // 自动登录失败(未绑定账号),继续正常流程
  96. console.log('[App] OpenID 未绑定账号,将存储待登录');
  97. } else {
  98. console.error('[App] 自动登录异常:', e);
  99. }
  100. }
  101. // 未绑定账号或自动登录失败,存储 openid 等待手机号登录绑定
  102. localStorage.setItem('wx_openid', result.openid);
  103. // 🔧 标记刚从 OAuth 回来,页面组件可据此自动触发支付
  104. try { sessionStorage.setItem('wx_oauth_just_done', '1'); } catch (_) {}
  105. // 恢复之前保存的页面上下文
  106. let returnUrl: string | null = null;
  107. try {
  108. returnUrl = sessionStorage.getItem('wx_oauth_return');
  109. sessionStorage.removeItem('wx_oauth_return');
  110. } catch (_) {}
  111. if (returnUrl) {
  112. // 跳回原始页面(保留完整 hash 路由)
  113. // 用 location.replace 避免产生多余的历史记录
  114. window.location.replace(returnUrl);
  115. } else {
  116. // 没有上下文,清除 URL 参数后停留在当前页
  117. searchParams.delete('code');
  118. searchParams.delete('state');
  119. const newSearch = searchParams.toString();
  120. const newUrl = window.location.origin + window.location.pathname
  121. // ═══════════════════════════════════════════════════════════════
  122. // 文件: my-uniapp-vue3/src/config.ts
  123. // ═══════════════════════════════════════════════════════════════
  124. /**
  125. * 全局配置
  126. */
  127. import { getApiBaseUrl } from './utils/config'
  128. export const BASE_URL = getApiBaseUrl()
  129. // ═══════════════════════════════════════════════════════════════
  130. // 文件: my-uniapp-vue3/src/store/user.ts
  131. // ═══════════════════════════════════════════════════════════════
  132. import { defineStore } from 'pinia';
  133. import { ref, computed } from 'vue';
  134. import { getToken, setToken, removeToken, getUserInfo, setUserInfo, removeUserInfo } from '../utils/storage';
  135. import { get, post } from '../utils/request';
  136. import type { UserInfo, MemberStatus } from '../types';
  137. export const useUserStore = defineStore('user', () => {
  138. // 状态
  139. const token = ref<string | null>(getToken());
  140. const userInfo = ref<UserInfo | null>(getUserInfo<UserInfo>());
  141. const memberStatus = ref<MemberStatus | null>(null);
  142. // 计算属性
  143. const isLoggedIn = computed(() => !!token.value && !!userInfo.value);
  144. const isMember = computed(() => memberStatus.value?.isValid && memberStatus.value.level > 0);
  145. // 初始化用户状态
  146. function initUser() {
  147. // 检查是否有保存的登录状态
  148. const savedToken = getToken();
  149. const savedUserInfo = getUserInfo<UserInfo>();
  150. // 如果有有效的登录状态,使用它
  151. if (savedToken && savedUserInfo && savedToken !== 'test-token-for-dev') {
  152. token.value = savedToken;
  153. userInfo.value = savedUserInfo;
  154. fetchMemberStatus();
  155. }
  156. // 否则不设置默认用户,等待用户主动登录
  157. }
  158. // 登录
  159. async function login(phone: string, code: string) {
  160. const inviteCode = uni.getStorageSync('invite_code') || '';
  161. // 读取微信 openid(从公众号进入时已存储在 localStorage)
  162. const wxOpenid = (typeof localStorage !== 'undefined' && localStorage.getItem('wx_openid')) || '';
  163. const result = await post<{
  164. token: string;
  165. user: UserInfo;
  166. }>('/auth/login', { phone, code, inviteCode, openid: wxOpenid });
  167. token.value = result.token;
  168. userInfo.value = result.user;
  169. setToken(result.token);
  170. setUserInfo(result.user);
  171. // 清除已使用的邀请码
  172. if (inviteCode) {
  173. uni.removeStorageSync('invite_code');
  174. }
  175. // 绑定成功后清除 localStorage 中的 openid(避免后续登录重复携带)
  176. if (wxOpenid && typeof localStorage !== 'undefined') {
  177. localStorage.removeItem('wx_openid');
  178. }
  179. await fetchMemberStatus();
  180. return result;
  181. }
  182. // 发送验证码
  183. async function sendCode(phone: string) {
  184. return post('/auth/send-code', { phone });
  185. }
  186. // 获取用户信息
  187. async function fetchUserInfo() {
  188. const info = await get<UserInfo>('/auth/user-info');
  189. userInfo.value = info;
  190. setUserInfo(info);
  191. return info;
  192. }
  193. // 获取会员状态
  194. async function fetchMemberStatus() {
  195. try {
  196. const status = await get<MemberStatus>('/member/status');
  197. memberStatus.value = status;
  198. return status;
  199. } catch {
  200. memberStatus.value = null;
  201. return null;
  202. }
  203. }
  204. // 登出
  205. function logout() {
  206. token.value = null;
  207. userInfo.value = null;
  208. memberStatus.value = null;
  209. removeToken();
  210. removeUserInfo();
  211. }
  212. // ═══════════════════════════════════════════════════════════════
  213. // 文件: my-uniapp-vue3/src/store/audio.ts
  214. // ═══════════════════════════════════════════════════════════════
  215. import { defineStore } from 'pinia';
  216. import { ref, computed } from 'vue';
  217. import { get, post, getFullUrl } from '../utils/request';
  218. import type { AudioItem, Voice, VoiceParams } from '../types';
  219. export const useAudioStore = defineStore('audio', () => {
  220. // 状态
  221. const voices = ref<Voice[]>([]);
  222. const currentAudio = ref<AudioItem | null>(null);
  223. const playlist = ref<AudioItem[]>([]);
  224. const currentIndex = ref(-1);
  225. const isPlaying = ref(false);
  226. const currentTime = ref(0);
  227. const duration = ref(0);
  228. const playRate = ref(1);
  229. // 播放模式: 'sequence' | 'loop' | 'single' | 'random'
  230. const playMode = ref<'sequence' | 'loop' | 'single' | 'random'>('sequence');
  231. // 音频上下文 - 双模式:BackgroundAudioManager(App后台播放)+ InnerAudioContext(H5降级)
  232. let audioContext: UniApp.InnerAudioContext | null = null;
  233. let bgAudioManager: any = null;
  234. // H5 环境不支持 BackgroundAudioManager,使用 InnerAudioContext
  235. const useBackgroundAudio: boolean = (function() {
  236. // #ifdef H5
  237. return false;
  238. // #endif
  239. // #ifndef H5
  240. return true;
  241. // #endif
  242. })();
  243. // 计算属性
  244. const hasPlaylist = computed(() => playlist.value.length > 0);
  245. const hasNext = computed(() => currentIndex.value < playlist.value.length - 1);
  246. const hasPrev = computed(() => currentIndex.value > 0);
  247. // 获取当前活跃的音频上下文(背景模式优先)
  248. function getActiveContext() {
  249. if (useBackgroundAudio && bgAudioManager) return bgAudioManager;
  250. return audioContext;
  251. }
  252. // 初始化音频上下文
  253. function initAudioContext() {
  254. if (useBackgroundAudio) {
  255. if (!bgAudioManager) {
  256. try {
  257. bgAudioManager = uni.getBackgroundAudioManager();
  258. setupBackgroundAudioEvents();
  259. console.log('[Audio] 使用 BackgroundAudioManager 支持后台播放');
  260. } catch (e) {
  261. console.warn('[Audio] BackgroundAudioManager 不可用,降级到 InnerAudioContext');
  262. initInnerAudioContext();
  263. }
  264. }
  265. return;
  266. }
  267. initInnerAudioContext();
  268. }
  269. function initInnerAudioContext() {
  270. if (audioContext) return;
  271. audioContext = uni.createInnerAudioContext();
  272. audioContext.onPlay(() => {
  273. isPlaying.value = true;
  274. });
  275. audioContext.onPause(() => {
  276. isPlaying.value = false;
  277. });
  278. audioContext.onEnded(() => {
  279. isPlaying.value = false;
  280. if (hasNext.value) {
  281. playNext();
  282. } else {
  283. handlePlayMode();
  284. }
  285. });
  286. audioContext.onTimeUpdate(() => {
  287. currentTime.value = audioContext?.currentTime || 0;
  288. const d = audioContext?.duration || 0;
  289. if (d > 0 && d < 3600) {
  290. if (duration.value === 0 || duration.value > 3600 || Math.abs(duration.value - d) < 1) {
  291. duration.value = d;
  292. }
  293. }
  294. });
  295. // ═══════════════════════════════════════════════════════════════
  296. // 文件: my-uniapp-vue3/src/store/notification.ts
  297. // ═══════════════════════════════════════════════════════════════
  298. import { defineStore } from 'pinia';
  299. import { ref, computed } from 'vue';
  300. export interface Notification {
  301. id: string;
  302. type: 'audio_complete' | 'video_complete' | 'content_complete' | 'error';
  303. title: string;
  304. message: string;
  305. bookId?: string | number;
  306. timestamp: number;
  307. read: boolean;
  308. }
  309. const STORAGE_KEY = 'app_notifications';
  310. function loadFromStorage(): Notification[] {
  311. try {
  312. const data = uni.getStorageSync(STORAGE_KEY);
  313. return data ? JSON.parse(data) : [];
  314. } catch {
  315. return [];
  316. }
  317. }
  318. function saveToStorage(notifications: Notification[]) {
  319. try {
  320. uni.setStorageSync(STORAGE_KEY, JSON.stringify(notifications));
  321. } catch (e) {
  322. console.error('[Notification] 保存失败:', e);
  323. }
  324. }
  325. export const useNotificationStore = defineStore('notification', () => {
  326. const notifications = ref<Notification[]>(loadFromStorage());
  327. const unreadCount = computed(() => notifications.value.filter(n => !n.read).length);
  328. function add(notif: Omit<Notification, 'id' | 'timestamp' | 'read'>) {
  329. const id = `notif_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`;
  330. const notification: Notification = {
  331. ...notif,
  332. id,
  333. timestamp: Date.now(),
  334. read: false,
  335. };
  336. notifications.value.unshift(notification);
  337. // 最多保留 50 条
  338. if (notifications.value.length > 50) {
  339. notifications.value = notifications.value.slice(0, 50);
  340. }
  341. saveToStorage(notifications.value);
  342. }
  343. function markRead(id: string) {
  344. const n = notifications.value.find(item => item.id === id);
  345. if (n) {
  346. n.read = true;
  347. saveToStorage(notifications.value);
  348. }
  349. }
  350. function markAllRead() {
  351. notifications.value.forEach(n => { n.read = true; });
  352. saveToStorage(notifications.value);
  353. }
  354. function remove(id: string) {
  355. notifications.value = notifications.value.filter(n => n.id !== id);
  356. saveToStorage(notifications.value);
  357. }
  358. function clear() {
  359. notifications.value = [];
  360. saveToStorage(notifications.value);
  361. }
  362. return {
  363. notifications,
  364. unreadCount,
  365. add,
  366. markRead,
  367. markAllRead,
  368. remove,
  369. clear,
  370. };
  371. });
  372. // ═══════════════════════════════════════════════════════════════
  373. // 文件: my-uniapp-vue3/src/pages/index/index.vue
  374. // ═══════════════════════════════════════════════════════════════
  375. <template>
  376. <view class="page">
  377. <!-- 搜索栏 -->
  378. <view class="search-bar" @click="goToSearch">
  379. <text class="search-icon">🔍</text>
  380. <text class="search-placeholder">搜索专辑...</text>
  381. <!-- 通知铃铛 -->
  382. <view class="notif-bell" @click.stop="showNotifications">
  383. <text class="bell-icon">🔔</text>
  384. <view v-if="notifStore.unreadCount > 0" class="bell-badge">{{ notifStore.unreadCount > 99 ? '99+' : notifStore.unreadCount }}</view>
  385. </view>
  386. </view>
  387. <!-- 骨架屏加载状态 -->
  388. <scroll-view scroll-y class="album-list" @scrolltolower="loadMore" v-if="loading && bookList.length === 0" :scroll-top="scrollTop">
  389. <!-- 最近收听骨架屏 -->
  390. <view v-if="userStore.isLoggedIn" class="recent-section">
  391. <view class="skeleton-section-title"></view>
  392. <scroll-view scroll-x class="recent-scroll" enable-flex>
  393. <view v-for="i in 4" :key="i" class="recent-card skeleton-recent-card">
  394. <view class="skeleton-recent-cover"></view>
  395. <view class="skeleton-recent-title"></view>
  396. <view class="skeleton-recent-progress"></view>
  397. </view>
  398. </scroll-view>
  399. </view>
  400. <view class="skeleton-grid">
  401. <view v-for="i in 6" :key="i" class="skeleton-card">
  402. <view class="skeleton-cover"></view>
  403. <view class="skeleton-info">
  404. <view class="skeleton-title"></view>
  405. <view class="skeleton-meta"></view>
  406. </view>
  407. </view>
  408. </view>
  409. </scroll-view>
  410. <!-- 书籍列表 -->
  411. <scroll-view scroll-y class="album-list" @scrolltolower="loadMore" v-else :scroll-top="scrollTop">
  412. <!-- 最近收听 -->
  413. <view v-if="userStore.isLoggedIn && recentAudios.length > 0" class="recent-section">
  414. <text class="section-title">最近收听</text>
  415. <scroll-view scroll-x class="recent-scroll" enable-flex>
  416. <view
  417. v-for="item in recentAudios"
  418. :key="item.id"
  419. class="recent-card"
  420. @click="goToPlayer(item)"
  421. >
  422. <view class="recent-cover" :style="{ background: getRecentGradient(item.id) }">
  423. <image v-if="item.coverUrl" :src="item.coverUrl" mode="aspectFill" class="cover-img" />
  424. <text v-else class="cover-icon">📖</text>
  425. <view class="recent-progress-bar">
  426. <view class="progress-fill" :style="{ width: item.progress + '%' }"></view>
  427. </view>
  428. </view>
  429. <text class="recent-title">{{ item.title }}</text>
  430. <text class="recent-progress">{{ formatProgress(item.progress) }}</text>
  431. </view>
  432. </scroll-view>
  433. </view>
  434. <!-- 冷启动引导(新用户无内容时) -->
  435. <view v-if="bookList.length === 0 && !loading && recentAudios.length === 0" class="onboarding">
  436. <view class="onboarding-card">
  437. <text class="onboarding-icon">🎧</text>
  438. <text class="onboarding-title">欢迎来到有声书 AI</text>
  439. <text class="onboarding-desc">AI 为你创作专属有声书,从选题到配音全自动完成</text>
  440. <button class="onboarding-btn" @click="quickCreateBook">
  441. ✨ 创建我的第一本有声书
  442. </button>
  443. </view>
  444. <view class="template-section">
  445. <text class="template-title">💡 快速模板</text>
  446. <view class="template-list">
  447. <view
  448. v-for="tpl in quickTemplates"
  449. :key="tpl.title"
  450. class="template-card"
  451. @click="useQuickTemplate(tpl)"
  452. >
  453. <text class="template-icon">{{ tpl.icon }}</text>
  454. <text class="template-name">{{ tpl.title }}</text>
  455. // ═══════════════════════════════════════════════════════════════
  456. // 文件: my-uniapp-vue3/src/pages/create/index.vue
  457. // ═══════════════════════════════════════════════════════════════
  458. <template>
  459. <view class="page">
  460. <!-- 主内容区:只有文本输入 -->
  461. <view class="main-content">
  462. <!-- 文本输入 -->
  463. <view class="input-area">
  464. <textarea
  465. v-model="text"
  466. class="text-input"
  467. placeholder="请输入要转换的文本内容..."
  468. :maxlength="2000"
  469. auto-height
  470. />
  471. <!-- 字数统计 -->
  472. <text class="word-count" :class="{ warning: text.length > 1800, error: text.length >= 2000 }">
  473. {{ text.length }}/2000
  474. </text>
  475. <!-- 已上传文档信息 -->
  476. <view v-if="uploadedDoc.fileName && !uploading" class="doc-info-bar">
  477. <text class="doc-format-badge">{{ uploadedDoc.format?.toUpperCase() || 'DOC' }}</text>
  478. <text class="doc-filename">{{ uploadedDoc.fileName }}</text>
  479. <text class="doc-meta">{{ uploadedDoc.wordCount }} 字</text>
  480. <text v-if="uploadedDoc.truncated" class="doc-truncated-hint">⚠ 已截取</text>
  481. <text class="doc-remove-btn" @click="clearDoc">✕</text>
  482. </view>
  483. <!-- 上传中 -->
  484. <view v-if="uploading" class="doc-uploading-bar">
  485. <text>正在解析文档,请稍候...</text>
  486. </view>
  487. <!-- 工具按钮行 -->
  488. <view class="tool-row">
  489. <view class="tool-btn" @click="handleUploadDoc">
  490. <text>{{ uploadedDoc.fileName ? '📄 重新上传' : '📄 上传文档' }}</text>
  491. </view>
  492. <view class="tool-btn" @click="goToAIGenerate">
  493. <text>🤖 AI 生成</text>
  494. </view>
  495. <view class="tool-btn" @click="pasteText">
  496. <text>📋 粘贴</text>
  497. </view>
  498. <view class="tool-btn" @click="clearText">
  499. <text>🗑 清空</text>
  500. </view>
  501. </view>
  502. <!-- 专辑选择(必填) -->
  503. <view class="album-selector" @click="showAlbumListPanel = true">
  504. <text class="album-selector-label">专辑</text>
  505. <text class="album-selector-value" :class="{ placeholder: !selectedAlbum }">
  506. {{ selectedAlbum ? selectedAlbum.title : '请选择专辑(必填)' }}
  507. </text>
  508. <text class="album-selector-arrow">▼</text>
  509. </view>
  510. </view>
  511. </view>
  512. <!-- 底部固定区:Tab 工具栏 -->
  513. <view class="bottom-bar">
  514. <!-- 主操作行:生成按钮 + 快捷信息 -->
  515. <view class="bottom-action-row">
  516. <view class="quick-info">
  517. <text class="quick-info-text">🎤 {{ currentVoiceName || '默认音色' }}</text>
  518. </view>
  519. <view class="generate-btn-tab" :class="{ disabled: !canGenerate }" @click="handleGenerate">
  520. <text class="generate-btn-tab-text">{{ generating ? '提交中...' : '开始生成' }}</text>
  521. </view>
  522. </view>
  523. <!-- 图标 Tab 栏(等宽分布,图标+文字居中) -->
  524. <view class="tab-toolbar">
  525. <view class="tab-item" :class="{ active: showVoicePanel }" @click="showVoicePanel = true">
  526. <text class="tab-icon">🎤</text>
  527. <text class="tab-text">音色</text>
  528. </view>
  529. <view class="tab-item" @click="showParamsPanel = true">
  530. <text class="tab-icon">⚙️</text>
  531. <text class="tab-text">参数</text>
  532. </view>
  533. </view>
  534. </view>
  535. <!-- 音色面板(暗色主题) -->
  536. <view v-if="showVoicePanel" class="bottom-panel-overlay" @click="showVoicePanel = false">
  537. <view class="bottom-panel voice-panel-dark" @click.stop>
  538. // ═══════════════════════════════════════════════════════════════
  539. // 文件: my-uniapp-vue3/src/pages/book-generator/create.vue
  540. // ═══════════════════════════════════════════════════════════════
  541. <template>
  542. <view class="page">
  543. <!-- 顶部导航栏 -->
  544. <view class="nav-bar">
  545. <view class="nav-content">
  546. <view class="nav-left" @click="goBack">
  547. <text class="back-icon">←</text>
  548. </view>
  549. <text class="page-title">{{ isEditMode ? '编辑书籍' : '创建有声书' }}</text>
  550. </view>
  551. </view>
  552. <!-- 主内容区 -->
  553. <view class="main-content">
  554. <!-- ===== 区域 1:描述你想要的书籍 ===== -->
  555. <view class="card hero-card">
  556. <view class="section-label">
  557. <text class="label-text">描述你想要的有声书</text>
  558. <text class="label-required">*</text>
  559. </view>
  560. <text class="section-hint">用自然语言描述主题、风格和读者,越具体效果越好</text>
  561. <!-- 示例提示词 -->
  562. <view class="examples-row">
  563. <text class="examples-label">试试:</text>
  564. <view class="example-chips">
  565. <text
  566. v-for="ex in examplePrompts"
  567. :key="ex"
  568. class="example-chip"
  569. @click="newBook.description = ex"
  570. >{{ ex }}</text>
  571. </view>
  572. </view>
  573. <!-- 文本域 -->
  574. <view class="textarea-wrapper">
  575. <textarea
  576. v-model="newBook.description"
  577. class="form-textarea"
  578. :class="{ 'textarea-error': attemptedCreate && !newBook.description.trim() }"
  579. placeholder="用一句话描述你想要的书籍,AI 会自动分析并推荐最佳配置..."
  580. :maxlength="2000"
  581. @blur="detectScaleFromDesc"
  582. />
  583. <view class="textarea-footer">
  584. <text class="char-count">{{ newBook.description.length }}/2000</text>
  585. <text v-if="newBook.description.trim()" class="clear-btn" @click="newBook.description = ''">清空</text>
  586. </view>
  587. </view>
  588. <!-- AI 智能推荐按钮 -->
  589. <view v-if="!isEditMode" class="ai-btn-row">
  590. <button
  591. class="btn-ai-recommend"
  592. :class="{ 'btn-ai-loading': isRecommending }"
  593. :disabled="isRecommending || !newBook.description.trim()"
  594. @click="requestSmartRecommend"
  595. >
  596. <text>{{ isRecommending ? 'AI 分析中...' : 'AI 智能推荐配置' }}</text>
  597. </button>
  598. <text class="ai-btn-hint">AI 会分析描述并自动填充下方所有配置</text>
  599. </view>
  600. </view>
  601. <!-- ===== 区域 2:AI 推荐结果(有配置时显示) ===== -->
  602. <view v-if="!isEditMode && hasAnyConfig" class="card config-summary-card">
  603. <view class="section-label">
  604. <text class="label-text">当前配置</text>
  605. <text v-if="recommendSource === 'ai'" class="config-source-tag">AI 推荐</text>
  606. <text v-else-if="recommendSource === 'template'" class="config-source-tag template-tag">模板</text>
  607. </view>
  608. <view class="config-tags">
  609. <view v-if="newBook.bookScale" class="config-tag scale-tag" @click="scrollToSection('scale')">
  610. <text class="config-tag-label">规模</text>
  611. <text class="config-tag-value">{{ getScaleShortLabel(newBook.bookScale) }}</text>
  612. <text class="config-tag-edit">修改</text>
  613. </view>
  614. <view v-if="newBook.targetAudience" class="config-tag" @click="scrollToSection('audience')">
  615. <text class="config-tag-value">{{ getAudienceLabel(newBook.targetAudience) }}</text>
  616. <text class="config-tag-edit">修改</text>
  617. </view>
  618. <view v-if="newBook.knowledgeLevel" class="config-tag" @click="scrollToSection('knowledge')">
  619. <text class="config-tag-value">{{ getKnowledgeLevelLabel(newBook.knowledgeLevel) }}</text>
  620. <text class="config-tag-edit">修改</text>
  621. // ═══════════════════════════════════════════════════════════════
  622. // 文件: my-uniapp-vue3/src/pages/book-generator/detail.vue
  623. // ═══════════════════════════════════════════════════════════════
  624. <template>
  625. <view class="page">
  626. <!-- 顶部导航栏 -->
  627. <view class="nav-bar">
  628. <view class="nav-content">
  629. <view class="nav-left" @click="goBack">
  630. <text class="back-icon">←</text>
  631. </view>
  632. <text class="page-title">{{ isTitleGenerating ? 'AI 生成标题中...' : (currentBook?.title || '书籍详情') }}</text>
  633. <view class="nav-right">
  634. <text v-if="canEdit" class="nav-edit-btn" @click="goToEdit">编辑</text>
  635. </view>
  636. </view>
  637. </view>
  638. <!-- 主内容区 -->
  639. <view class="main-content">
  640. <!-- 书籍信息 -->
  641. <view class="card book-info">
  642. <view class="book-title-row">
  643. <text class="book-title-large">{{ isTitleGenerating ? '✨ AI 正在生成标题...' : currentBook?.title }}</text>
  644. <GenerationStatusBadge :status="currentBook?.genStage || 'draft'"></GenerationStatusBadge>
  645. </view>
  646. <text v-if="currentBook?.subtitle" class="book-subtitle">{{ currentBook.subtitle }}</text>
  647. <text class="book-desc">{{ currentBook?.description }}</text>
  648. <view class="book-meta-row">
  649. <text>章节:{{ currentBook?.totalChapters }} 章</text>
  650. <text>预估:{{ currentBook?.estimatedWords || 0 }} 字</text>
  651. </view>
  652. </view>
  653. <!-- 进度显示 -->
  654. <view v-if="currentBook" class="card progress-card">
  655. <text class="card-title">📊 生成进度</text>
  656. <view class="progress-display">
  657. <text class="progress-text" :class="{ 'progress-failed': isGenerationFailed }">{{ isGenerationFailed ? '失败' : currentBook.progress }}%</text>
  658. <text class="progress-detail">
  659. {{ completedChapters }}/{{ currentBook.totalChapters }} 章
  660. </text>
  661. </view>
  662. <view class="progress-bar-large">
  663. <view class="progress-fill" :class="{ 'progress-fill-failed': isGenerationFailed }" :style="{ width: (isGenerationFailed ? 0 : currentBook.progress) + '%' }"></view>
  664. </view>
  665. <!-- 失败状态 -->
  666. <view v-if="isGenerationFailed" class="failed-tip">
  667. <text class="failed-icon">❌</text>
  668. <text class="failed-text">生成失败</text>
  669. <text v-if="currentBook.error" class="failed-error">{{ currentBook.error }}</text>
  670. <text class="failed-hint">请检查网络或稍后重试</text>
  671. <button class="retry-btn" @click="handleRetryGenerate">🔄 重新生成</button>
  672. </view>
  673. <!-- 实时生成状态面板 -->
  674. <view v-if="isCurrentlyGenerating && !isGenerationFailed" class="generation-status">
  675. <view class="status-header">
  676. <text class="status-icon">⚡</text>
  677. <text class="status-title">正在生成中...【时间较长,请耐心等待,可关闭页面,后台会继续生成】</text>
  678. </view>
  679. <view class="status-stage">
  680. <text class="stage-label">当前阶段:</text>
  681. <text class="stage-value">{{ getCurrentStage() }}</text>
  682. </view>
  683. <view v-if="currentGeneratingChapter" class="current-chapter-info">
  684. <text class="chapter-label">正在处理:</text>
  685. <text class="chapter-name">第{{ currentGeneratingChapter.number }}{{ currentGeneratingChapter.levelLabel }} {{ currentGeneratingChapter.title }}</text>
  686. </view>
  687. <view v-if="currentBook.error" class="status-error">
  688. <text class="error-icon">⚠️</text>
  689. <text class="error-text">{{ currentBook.error }}</text>
  690. </view>
  691. <view v-if="currentBook.error && currentBook.error.includes('自动恢复')" class="status-recovery">
  692. <text class="recovery-icon">🔄</text>
  693. <text class="recovery-text">系统正在尝试自动恢复...</text>
  694. </view>
  695. <view class="status-tip">
  696. <text class="tip-text">💡 生成过程可能需要几分钟,请耐心等待</text>
  697. </view>
  698. </view>
  699. <!-- 实时配额显示 -->
  700. <view v-if="isCurrentlyGenerating" class="quota-monitor">
  701. <text class="quota-monitor-title">🎧 额度监控</text>
  702. <view class="quota-monitor-row">
  703. <text>已用:{{ usedAudioMinutes }}分钟</text>
  704. // ═══════════════════════════════════════════════════════════════
  705. // 文件: my-uniapp-vue3/src/pages/book-generator/interactive.vue
  706. // ═══════════════════════════════════════════════════════════════
  707. <template>
  708. <view class="page">
  709. <!-- 顶部导航栏 -->
  710. <view class="nav-bar">
  711. <view class="nav-content">
  712. <view class="nav-left" @click="goBack">
  713. <text class="back-icon">←</text>
  714. </view>
  715. <text class="page-title">交互式创建</text>
  716. </view>
  717. <!-- 模式切换独立行 -->
  718. <view class="mode-bar">
  719. <text class="mode-switch" @click="switchToSimple">切换到一键模式</text>
  720. </view>
  721. </view>
  722. <!-- 步骤指示器 -->
  723. <view class="steps-bar">
  724. <view v-for="(step, i) in steps" :key="i" :class="['step-dot', currentStep >= i ? 'active' : '', currentStep > i ? 'done' : '']">
  725. <view class="dot-num">{{ currentStep > i ? '✓' : step.num }}</view>
  726. <text class="dot-label">{{ step.label }}</text>
  727. </view>
  728. <view class="step-line" :style="{ width: (currentStep / (steps.length - 1) * 100) + '%' }"></view>
  729. </view>
  730. <!-- 主内容区 -->
  731. <view class="main-content">
  732. <!-- ============ Step 1: 信息输入 ============ -->
  733. <view v-if="currentStep === 0" class="card">
  734. <view class="card-header">
  735. <text class="card-title">📝 第一步:基本信息</text>
  736. </view>
  737. <view class="form-item">
  738. <text class="form-label">书名 *</text>
  739. <input v-model="form.title" class="form-input" placeholder="例如:《时间是什么》" />
  740. </view>
  741. <view class="form-item">
  742. <text class="form-label">内容描述 *</text>
  743. <textarea v-model="form.description" class="form-textarea" placeholder="描述这本书的内容、主题、写作目的..." :maxlength="500" />
  744. <view class="ai-recommend-row">
  745. <button class="btn-ai-recommend" :disabled="!form.description.trim() || recommendLoading" @click="doSmartRecommend">
  746. {{ recommendLoading ? 'AI分析中...' : '🤖 AI智能推荐' }}
  747. </button>
  748. <text class="ai-recommend-hint">根据描述自动填充目标人群和书籍类型</text>
  749. </view>
  750. </view>
  751. <view class="form-item">
  752. <text class="form-label">书籍规模 *</text>
  753. <view class="scale-picker">
  754. <view
  755. v-for="scale in bookScales"
  756. :key="scale.value"
  757. :class="['scale-chip', form.bookScale === scale.value ? 'active' : '']"
  758. @click="form.bookScale = scale.value"
  759. >
  760. <text class="scale-label">{{ scale.label }}</text>
  761. <text class="scale-desc">{{ scale.chapters }} · {{ scale.audio }}</text>
  762. </view>
  763. </view>
  764. </view>
  765. <!-- 目标人群 -->
  766. <view class="form-item">
  767. <view class="label-with-badge">
  768. <text class="form-label">目标人群</text>
  769. <view class="required-badge">核心</view>
  770. </view>
  771. <text class="form-hint-small">指定读者,AI会据此调整语言风格和深度</text>
  772. <view class="chip-group" style="margin-top: 8rpx;">
  773. <view
  774. v-for="a in audiences"
  775. :key="a.value"
  776. :class="['chip', form.targetAudience === a.value ? 'active' : '']"
  777. @click="form.targetAudience = a.value"
  778. >
  779. <text class="chip-icon">{{ a.icon }}</text>
  780. <text class="chip-text">{{ a.label }}</text>
  781. <text class="chip-desc" style="font-size: 20rpx; margin-left: 4rpx;">{{ a.desc }}</text>
  782. </view>
  783. </view>
  784. </view>
  785. <view class="form-item">
  786. <text class="form-label">书籍类型</text>
  787. // ═══════════════════════════════════════════════════════════════
  788. // 文件: my-uniapp-vue3/src/pages/book-generator/chapter-detail.vue
  789. // ═══════════════════════════════════════════════════════════════
  790. <template>
  791. <view class="page">
  792. <!-- 顶部导航栏 -->
  793. <view class="nav-bar">
  794. <view class="nav-content">
  795. <view class="nav-left" @click="goBack">
  796. <text class="back-icon">←</text>
  797. </view>
  798. <text class="page-title">{{ chapterTitle || '章节详情' }}</text>
  799. <view class="nav-right"></view>
  800. </view>
  801. </view>
  802. <!-- 章节内容 -->
  803. <scroll-view
  804. class="chapter-content"
  805. scroll-y="true"
  806. :scroll-top="scrollTop"
  807. :style="{ transform: `translateX(${swipeOffset.value}px)`, transition: swipeTransition.value }"
  808. @touchstart="onTouchStart"
  809. @touchmove="onTouchMove"
  810. @touchend="onTouchEnd"
  811. >
  812. <!-- 章节头部信息 -->
  813. <view class="chapter-header">
  814. <text class="chapter-num-badge">
  815. 第{{ chapterParentNumber || chapterNumber }}{{ chapterLevel === 2 ? '章 第' + chapterNumber + '节' : '章' }}
  816. </text>
  817. <text class="chapter-title-large">{{ chapterTitle }}</text>
  818. <view class="chapter-meta">
  819. <text class="word-count">{{ chapterWordCount }}字</text>
  820. <GenerationStatusBadge v-if="chapterGenStage" :status="chapterGenStage" />
  821. <view v-if="chapterAudioUrl" class="media-badge audio-badge">
  822. <text>🎵 音频</text>
  823. </view>
  824. <view v-if="chapterVideoUrl" class="media-badge video-badge">
  825. <text>🎬 视频</text>
  826. </view>
  827. <view v-if="isLeafNode" class="edit-toggle-btn" @click="toggleEditMode">
  828. <text>{{ isEditMode ? '✓ 完成' : '✏️ 编辑' }}</text>
  829. </view>
  830. <view v-if="isLeafNode && chapterGenStage !== 'idle' && chapterGenStage !== 'content_generating'" class="regenerate-btn" @click="handleRegenerateContent">
  831. <text>{{ regeneratingContent ? '生成中...' : '🔄 重新生成' }}</text>
  832. </view>
  833. </view>
  834. </view>
  835. <!-- 章节内容(level=3小节 或 level=1短文章节) -->
  836. <!-- 加载中 -->
  837. <view v-if="isLoading && !chapterContent" class="chapter-body">
  838. <view class="level-hint-large">
  839. <text class="hint-icon-large">⏳</text>
  840. <text class="hint-text-large">正在加载章节内容...</text>
  841. </view>
  842. </view>
  843. <!-- 加载失败 -->
  844. <view v-else-if="loadError" class="chapter-body">
  845. <view class="level-hint-large">
  846. <text class="hint-icon-large">❌</text>
  847. <text class="hint-text-large">加载章节失败</text>
  848. <text class="hint-subtext">{{ loadError }}</text>
  849. <button class="retry-btn" @click="retryLoad">重试</button>
  850. </view>
  851. </view>
  852. <view v-else-if="isLeafNode || (chapterLevel === 1 && chapterContent)" class="chapter-body">
  853. <!-- 短文类章标识 -->
  854. <view v-if="chapterLevel === 1 && isLeafNode" class="article-badge">
  855. <text>📄 短文正文</text>
  856. </view>
  857. <!-- 编辑模式 -->
  858. <view v-if="isEditMode" class="edit-mode">
  859. <textarea
  860. v-model="editContent"
  861. class="content-editor"
  862. placeholder="编辑章节内容..."
  863. :maxlength="-1"
  864. />
  865. <view class="edit-actions">
  866. <button class="edit-btn cancel" @click="cancelEdit">取消</button>
  867. <button class="edit-btn save" @click="saveContent" :disabled="savingContent">
  868. {{ savingContent ? '保存中...' : '保存' }}
  869. </button>
  870. // ═══════════════════════════════════════════════════════════════
  871. // 文件: my-uniapp-vue3/src/pages/player/index.vue
  872. // ═══════════════════════════════════════════════════════════════
  873. <template>
  874. <view class="page">
  875. <!-- 顶部导航 -->
  876. <view class="nav-bar">
  877. <view class="nav-btn" @click="goBack">
  878. <text class="nav-icon">⋁</text>
  879. </view>
  880. <view class="nav-center">
  881. <text class="nav-title">{{ audio?.title || '正在播放' }}</text>
  882. <view v-if="sleepTimer" class="sleep-timer-badge" @click="showSleepTimerPicker = true">
  883. <text class="sleep-timer-text">{{ formatSleepTimer(sleepTimer.remaining) }}</text>
  884. </view>
  885. </view>
  886. <view class="nav-actions">
  887. <view class="nav-btn" @click="showSleepTimerPicker = true">
  888. <text class="nav-icon">⏰</text>
  889. </view>
  890. <view class="nav-btn" @click="showLyrics = !showLyrics">
  891. <text class="nav-icon">{{ showLyrics ? '📖' : '📕' }}</text>
  892. </view>
  893. </view>
  894. </view>
  895. <!-- 无音频提示 -->
  896. <view v-if="!audio?.audioUrl" class="empty-state">
  897. <text class="empty-icon">🎵</text>
  898. <text class="empty-title">暂无音频</text>
  899. <text class="empty-desc">该章节还没有生成音频</text>
  900. <button class="empty-btn" @click="goBack">返回</button>
  901. </view>
  902. <!-- 正常播放界面 -->
  903. <template v-else>
  904. <!-- 封面和标题 -->
  905. <view class="cover-section" v-show="showLyrics">
  906. <view class="cover">
  907. <text class="cover-icon">🎵</text>
  908. </view>
  909. <text class="title">{{ audio?.title || '未知标题' }}</text>
  910. </view>
  911. <!-- 文本预览(歌词同步) -->
  912. <scroll-view
  913. v-show="showLyrics"
  914. class="text-preview"
  915. scroll-y
  916. :style="{ maxHeight: '550rpx' }"
  917. :scroll-into-view="scrollIntoViewId"
  918. scroll-with-animation
  919. >
  920. <view class="lyrics-container">
  921. <view
  922. v-for="(line, index) in lyrics"
  923. :key="index"
  924. :id="'lyric-' + index"
  925. class="lyric-line"
  926. :class="{ active: index === currentLyricIndex }"
  927. >
  928. <text class="lyric-text">{{ line.text }}</text>
  929. </view>
  930. </view>
  931. </scroll-view>
  932. <!-- 进度条 -->
  933. <view class="progress-section">
  934. <text class="time">{{ formatDuration(currentTime) }}</text>
  935. <slider
  936. :value="currentTime"
  937. :max="duration || 100"
  938. @change="handleSeek"
  939. activeColor="#4F46E5"
  940. backgroundColor="#e5e7eb"
  941. block-size="16"
  942. class="progress-slider"
  943. />
  944. <text class="time">{{ formatDuration(duration) }}</text>
  945. </view>
  946. <!-- 播放控制 -->
  947. <view class="controls">
  948. <!-- 左侧按钮:播放速度、下载 -->
  949. <view class="controls-side">
  950. <view class="control-btn" @click="showRatePicker = true">
  951. <text class="control-text">{{ playRate }}x</text>
  952. </view>
  953. // ═══════════════════════════════════════════════════════════════
  954. // 文件: my-uniapp-vue3/src/pages/member/index.vue
  955. // ═══════════════════════════════════════════════════════════════
  956. <template>
  957. <view class="page">
  958. <view class="nav-bar">
  959. <view class="nav-btn" @click="goBack">
  960. <text class="nav-icon">←</text>
  961. </view>
  962. <text class="nav-title">订阅套餐</text>
  963. <view class="nav-btn" />
  964. </view>
  965. <view class="balance-section" v-if="isLoggedIn">
  966. <view class="balance-info">
  967. <text class="balance-label">剩余积分</text>
  968. <text class="balance-value">{{ formatNumber(tokenBalance.remainingTokens) }}</text>
  969. <text class="balance-unit"> / {{ tokenBalance.isUnlimited ? '无限' : formatNumber(tokenBalance.totalTokens) }}</text>
  970. </view>
  971. <view class="balance-bar">
  972. <view class="balance-progress" :style="{ width: progressWidth + '%' }"></view>
  973. </view>
  974. </view>
  975. <view class="plans-section">
  976. <text class="section-title">选择您的套餐</text>
  977. <view v-if="loading" class="loading-container">
  978. <text class="loading-text">加载中...</text>
  979. </view>
  980. <view v-else class="plans-list">
  981. <view
  982. v-for="plan in plans"
  983. :key="plan.id"
  984. class="plan-card"
  985. :class="{ active: selectedPlanId === plan.id, free: plan.level === 0 }"
  986. @click="selectedPlanId = plan.id"
  987. >
  988. <view v-if="plan.level === 0" class="free-badge">免费</view>
  989. <view class="plan-header">
  990. <text class="plan-name">{{ plan.name }}</text>
  991. <view class="plan-pricing">
  992. <text class="price-symbol">¥</text>
  993. <text class="price-value">{{ plan.priceMonthly }}</text>
  994. <text class="price-unit">/月</text>
  995. </view>
  996. </view>
  997. <text class="plan-description">{{ plan.description }}</text>
  998. <view class="plan-features">
  999. <view v-for="(feature, idx) in plan.features" :key="idx" class="feature-item">
  1000. <text class="feature-icon">✓</text>
  1001. <text class="feature-text">{{ feature }}</text>
  1002. </view>
  1003. </view>
  1004. <view class="select-btn" :class="{ selected: selectedPlanId === plan.id }">
  1005. <text>{{ selectedPlanId === plan.id ? '✓ 已选择' : '选择此套餐' }}</text>
  1006. </view>
  1007. </view>
  1008. </view>
  1009. </view>
  1010. <view class="bottom-section">
  1011. <view class="total-info">
  1012. <text class="total-label">应付金额</text>
  1013. <text class="total-value">¥{{ selectedPlan?.priceMonthly || 0 }}</text>
  1014. </view>
  1015. <button class="subscribe-btn" @click="handleSubscribe" :disabled="!selectedPlan || selectedPlan.level === 0">
  1016. <text>{{ selectedPlan?.level === 0 ? '免费套餐' : '立即订阅' }}</text>
  1017. </button>
  1018. </view>
  1019. </view>
  1020. </template>
  1021. <script setup lang="ts">
  1022. import { ref, computed, onMounted } from 'vue';
  1023. import { useUserStore } from '../../store/user';
  1024. import { get, post } from '../../utils/request';
  1025. import { formatNumber } from '../../utils/format';
  1026. const userStore = useUserStore();
  1027. const plans = ref<any[]>([]);
  1028. const selectedPlanId = ref<number>(17);
  1029. const loading = ref(false);
  1030. const tokenBalance = ref({ totalTokens: 0, usedTokens: 0, remainingTokens: 0, isUnlimited: false });
  1031. const selectedPlan = computed(() => plans.value.find(p => p.id === selectedPlanId.value));
  1032. const isLoggedIn = computed(() => userStore.isLoggedIn);
  1033. const progressWidth = computed(() => {
  1034. if (tokenBalance.value.isUnlimited) return 100;
  1035. if (tokenBalance.value.totalTokens === 0) return 0;
  1036. // ═══════════════════════════════════════════════════════════════
  1037. // 文件: my-uniapp-vue3/src/pages/mine/index.vue
  1038. // ═══════════════════════════════════════════════════════════════
  1039. <template>
  1040. <view class="page">
  1041. <!-- 用户信息卡片 -->
  1042. <view class="user-card">
  1043. <view class="user-info">
  1044. <image v-if="userStore.userInfo?.avatar" :src="userStore.userInfo.avatar" class="avatar" mode="aspectFill" />
  1045. <view v-else class="avatar-placeholder">
  1046. <text class="avatar-text">👤</text>
  1047. </view>
  1048. <view class="user-text">
  1049. <text class="nickname">{{ userStore.userInfo?.nickname || '未登录' }}</text>
  1050. <view class="member-badge" :class="memberClass">
  1051. <text class="member-text">{{ memberText }}</text>
  1052. </view>
  1053. </view>
  1054. </view>
  1055. <!-- 积分余额(两行展示) -->
  1056. <view class="credits-section" v-if="tokenBalance">
  1057. <view class="credit-row">
  1058. <view class="credit-item">
  1059. <text class="credit-label">📋 订阅积分</text>
  1060. <text class="credit-value">{{ formatNumber(tokenBalance.subscriptionRemaining ?? tokenBalance.remainingTokens) }}</text>
  1061. <text class="credit-sub" v-if="tokenBalance.subscriptionTokens">/ {{ formatNumber(tokenBalance.subscriptionTokens) }} 总量</text>
  1062. </view>
  1063. <view class="credit-divider" />
  1064. <view class="credit-item">
  1065. <text class="credit-label">🎁 积分包</text>
  1066. <text class="credit-value">{{ formatNumber(tokenBalance.packRemaining ?? 0) }}</text>
  1067. <text class="credit-sub" v-if="tokenBalance.packTokens > 0">/ {{ formatNumber(tokenBalance.packTokens) }} 总量</text>
  1068. <text class="credit-sub" v-else>暂无</text>
  1069. </view>
  1070. </view>
  1071. <!-- 进度条 -->
  1072. <view class="credit-bar-row">
  1073. <view class="credit-bar" :style="{ flex: tokenBalance.subscriptionTokens || 1 }">
  1074. <view class="credit-bar-fill sub" :style="{ width: subscriptionPercent + '%' }"></view>
  1075. </view>
  1076. <view class="credit-bar-gap" />
  1077. <view class="credit-bar" :style="{ flex: tokenBalance.packTokens || 1 }">
  1078. <view class="credit-bar-fill pack" :style="{ width: packPercent + '%' }"></view>
  1079. </view>
  1080. </view>
  1081. </view>
  1082. <!-- 使用统计 -->
  1083. <view class="stats-row" v-if="userStore.memberStatus">
  1084. <view class="stat-item">
  1085. <text class="stat-value">{{ userStore.memberStatus.quota.dailyRemaining }}</text>
  1086. <text class="stat-label">今日次数</text>
  1087. </view>
  1088. <view class="stat-divider" />
  1089. <view class="stat-item">
  1090. <text class="stat-value">{{ userStore.memberStatus.quota.wordLimit === 0 ? '∞' : userStore.memberStatus.quota.wordLimit }}</text>
  1091. <text class="stat-label">字数上限</text>
  1092. </view>
  1093. </view>
  1094. </view>
  1095. <!-- 今日创作概况 -->
  1096. <view class="today-card" v-if="userStore.isLoggedIn">
  1097. <view class="today-header">
  1098. <text class="today-title">今日创作概况</text>
  1099. <text class="today-date">{{ todayDate }}</text>
  1100. </view>
  1101. <view class="today-stats">
  1102. <view class="today-stat-item">
  1103. <text class="today-stat-value">{{ todayStats.audioCount }}</text>
  1104. <text class="today-stat-label">生成音频</text>
  1105. </view>
  1106. <view class="today-stat-divider" />
  1107. <view class="today-stat-item">
  1108. <text class="today-stat-value">{{ todayStats.bookCount }}</text>
  1109. <text class="today-stat-label">生成书籍</text>
  1110. </view>
  1111. <view class="today-stat-divider" />
  1112. <view class="today-stat-item">
  1113. <text class="today-stat-value">{{ todayStats.usageQuota }}</text>
  1114. <text class="today-stat-label">使用额度</text>
  1115. </view>
  1116. </view>
  1117. </view>
  1118. <!-- 最近的创作 -->
  1119. // ═══════════════════════════════════════════════════════════════
  1120. // 文件: my-uniapp-vue3/src/pages/search/index.vue
  1121. // ═══════════════════════════════════════════════════════════════
  1122. <template>
  1123. <view class="page">
  1124. <!-- 搜索栏 -->
  1125. <view class="search-bar">
  1126. <input
  1127. v-model="searchQuery"
  1128. class="search-input"
  1129. placeholder="搜索音频..."
  1130. @confirm="handleSearch"
  1131. />
  1132. <button class="search-btn" @click="handleSearch">搜索</button>
  1133. </view>
  1134. <!-- 骨架屏加载状态 -->
  1135. <scroll-view scroll-y class="results" v-if="loading" :scroll-top="scrollTop">
  1136. <SkeletonList layout="search-result" :count="5" />
  1137. </scroll-view>
  1138. <!-- 搜索结果 -->
  1139. <scroll-view scroll-y class="results" v-else-if="searchResults.length > 0" :scroll-top="scrollTop">
  1140. <view class="result-list">
  1141. <view
  1142. v-for="item in searchResults"
  1143. :key="item.id"
  1144. class="result-item"
  1145. @click="playAudio(item)"
  1146. >
  1147. <view class="result-cover" :style="{ background: getCoverGradient(item.voiceId) }">
  1148. <text class="cover-letter">{{ getTitleLetter(item.title) }}</text>
  1149. <text class="cover-title-small">{{ item.title }}</text>
  1150. </view>
  1151. <view class="result-info">
  1152. <text class="result-title" v-html="highlightText(item.title, searchQuery)"></text>
  1153. <text class="result-desc" v-if="(item as any).description" v-html="highlightText((item as any).description.slice(0, 50) + '...', searchQuery)"></text>
  1154. <text class="result-meta">{{ item.wordCount || 0 }}字</text>
  1155. </view>
  1156. </view>
  1157. </view>
  1158. </scroll-view>
  1159. <!-- 无结果 -->
  1160. <view v-else-if="hasSearched && searchResults.length === 0" class="no-result">
  1161. <text class="no-result-icon">🔍</text>
  1162. <text class="no-result-text">未找到相关音频</text>
  1163. </view>
  1164. <!-- 初始状态:搜索历史 + 热门推荐 -->
  1165. <view v-else class="initial-state">
  1166. <!-- 搜索历史 -->
  1167. <view class="section" v-if="searchHistory.length > 0">
  1168. <view class="section-header">
  1169. <text class="section-title">搜索历史</text>
  1170. <text class="clear-btn" @click="clearHistory">清空</text>
  1171. </view>
  1172. <view class="tag-list">
  1173. <view
  1174. v-for="(item, index) in searchHistory"
  1175. :key="index"
  1176. class="tag history-tag"
  1177. @click="clickHistory(item.keyword)"
  1178. >
  1179. <text class="tag-text">{{ item.keyword }}</text>
  1180. <text class="delete-icon" @click.stop="deleteHistoryItem(item.keyword)">×</text>
  1181. </view>
  1182. </view>
  1183. </view>
  1184. <!-- 热门推荐 -->
  1185. <view class="section" v-if="hotSearches.length > 0">
  1186. <view class="section-header">
  1187. <text class="section-title">🔥 热门推荐</text>
  1188. </view>
  1189. <view class="tag-list">
  1190. <view
  1191. v-for="(item, index) in hotSearches"
  1192. :key="item.id"
  1193. class="tag hot-tag"
  1194. @click="clickHotSearch(item.keyword)"
  1195. >
  1196. <text class="tag-rank">{{ index + 1 }}</text>
  1197. <text class="tag-text">{{ item.keyword }}</text>
  1198. </view>
  1199. </view>
  1200. </view>
  1201. <!-- 空状态提示 -->
  1202. // ═══════════════════════════════════════════════════════════════
  1203. // 文件: my-uniapp-vue3/src/pages/history/index.vue
  1204. // ═══════════════════════════════════════════════════════════════
  1205. <template>
  1206. <view class="page">
  1207. <!-- 顶部搜索栏 -->
  1208. <view class="search-bar">
  1209. <view class="search-inner">
  1210. <text class="search-icon">🔍</text>
  1211. <input
  1212. class="search-input"
  1213. v-model="searchKeyword"
  1214. placeholder="搜索历史记录..."
  1215. @confirm="handleSearch"
  1216. @clear="handleClearSearch"
  1217. />
  1218. <text v-if="searchKeyword" class="clear-btn" @click="handleClearSearch">✕</text>
  1219. </view>
  1220. </view>
  1221. <!-- 编辑栏 -->
  1222. <view class="edit-bar">
  1223. <view v-if="!isEditing" class="edit-btn" @click="isEditing = true">
  1224. <text>编辑</text>
  1225. </view>
  1226. <view v-else class="edit-actions">
  1227. <view class="action-btn" @click="toggleSelectAll">
  1228. <text>{{ isAllSelected ? '取消全选' : '全选' }}</text>
  1229. </view>
  1230. <view class="action-btn download" @click="handleBatchDownload" :disabled="selectedIds.length === 0">
  1231. <text>下载 ({{ selectedIds.length }})</text>
  1232. </view>
  1233. <view class="action-btn delete" @click="handleBatchDelete" :disabled="selectedIds.length === 0">
  1234. <text>删除 ({{ selectedIds.length }})</text>
  1235. </view>
  1236. <view class="action-btn cancel" @click="cancelEdit">
  1237. <text>取消</text>
  1238. </view>
  1239. </view>
  1240. </view>
  1241. <!-- 标签栏 -->
  1242. <view class="tab-bar">
  1243. <scroll-view class="tab-scroll" scroll-x="true" :show-scrollbar="false">
  1244. <view class="tab-list">
  1245. <view
  1246. v-for="tab in tabs"
  1247. :key="tab.id"
  1248. class="tab-item"
  1249. :class="{ active: selectedTab === tab.id }"
  1250. @click="selectTab(tab.id)"
  1251. >
  1252. <text>{{ tab.name }}</text>
  1253. </view>
  1254. </view>
  1255. </scroll-view>
  1256. <view class="group-toggle" @click="groupByBook = !groupByBook">
  1257. <text class="group-toggle-text">{{ groupByBook ? '📋 列表' : '📚 聚合' }}</text>
  1258. </view>
  1259. </view>
  1260. <!-- 音频列表 -->
  1261. <scroll-view scroll-y class="audio-list" @scrolltolower="loadMore" :scroll-top="scrollTop">
  1262. <!-- 骨架屏加载状态 -->
  1263. <SkeletonList v-if="loading && audioList.length === 0" layout="history" :count="6" />
  1264. <!-- 空状态 -->
  1265. <view v-else-if="audioList.length === 0 && !loading" class="empty">
  1266. <text class="empty-icon">📚</text>
  1267. <text class="empty-text">暂无历史记录</text>
  1268. <text class="empty-hint">快去生成第一个音频吧</text>
  1269. </view>
  1270. <!-- 音频列表 -->
  1271. <view v-else class="audio-grid">
  1272. <!-- 分组模式 -->
  1273. <view v-if="groupByBook" v-for="group in displayList" :key="group._id" class="group-card">
  1274. <view class="group-header" @click="goToBookDetail(group.bookId)">
  1275. <text class="group-title">{{ group.bookId ? '📖 书籍 #' + group.bookId : '🎵 其他音频' }}</text>
  1276. <text class="group-count">{{ group.items.length }} 个音频 · {{ formatDuration(group.totalDuration) }}</text>
  1277. </view>
  1278. <view
  1279. v-for="item in group.items"
  1280. :key="item._id"
  1281. class="audio-card group-item"
  1282. :class="{ selected: selectedIds.includes(item._id) }"
  1283. @click="isEditing ? toggleSelect(item._id) : playAudio(item)"
  1284. >
  1285. // ═══════════════════════════════════════════════════════════════
  1286. // 文件: my-uniapp-vue3/src/pages/favorites/index.vue
  1287. // ═══════════════════════════════════════════════════════════════
  1288. <template>
  1289. <view class="page">
  1290. <!-- 顶部导航 -->
  1291. <view class="nav-bar">
  1292. <view class="nav-btn" @click="goBack">
  1293. <text class="nav-icon">←</text>
  1294. </view>
  1295. <text class="nav-title">我的收藏</text>
  1296. <view class="nav-btn" />
  1297. </view>
  1298. <!-- 加载状态 -->
  1299. <view v-if="loading" class="loading">
  1300. <text>加载中...</text>
  1301. </view>
  1302. <!-- 空状态 -->
  1303. <view v-else-if="favorites.length === 0" class="empty">
  1304. <text class="empty-icon">❤️</text>
  1305. <text class="empty-text">暂无收藏</text>
  1306. <text class="empty-hint">去听书页面收藏喜欢的音频吧</text>
  1307. </view>
  1308. <!-- 收藏列表 -->
  1309. <view v-else class="list">
  1310. <view
  1311. v-for="item in favorites"
  1312. :key="item.id"
  1313. class="item"
  1314. @click="playAudio(item.audio)"
  1315. >
  1316. <view class="item-cover">
  1317. <text class="cover-icon">🎵</text>
  1318. </view>
  1319. <view class="item-info">
  1320. <text class="item-title">{{ item.audio?.title || '未知标题' }}</text>
  1321. <text class="item-meta">{{ item.audio?.wordCount || 0 }}字</text>
  1322. </view>
  1323. <view class="item-action" @click.stop="removeFavorite(item.audioId)">
  1324. <text class="action-icon">❤️</text>
  1325. </view>
  1326. </view>
  1327. </view>
  1328. </view>
  1329. </template>
  1330. <script setup lang="ts">
  1331. import { ref, onMounted } from 'vue';
  1332. import { get, del } from '../../utils/request';
  1333. interface FavoriteItem {
  1334. id: number;
  1335. audioId: number;
  1336. createdAt: string;
  1337. audio?: {
  1338. id: number;
  1339. title: string;
  1340. text: string;
  1341. audioUrl: string;
  1342. audioDuration: number;
  1343. wordCount: number;
  1344. };
  1345. }
  1346. const favorites = ref<FavoriteItem[]>([]);
  1347. const loading = ref(true);
  1348. onMounted(async () => {
  1349. await fetchFavorites();
  1350. });
  1351. async function fetchFavorites() {
  1352. loading.value = true;
  1353. try {
  1354. const result = await get<any>('/favorites');
  1355. favorites.value = result || [];
  1356. } catch (error) {
  1357. console.error('获取收藏列表失败:', error);
  1358. } finally {
  1359. loading.value = false;
  1360. }
  1361. }
  1362. async function removeFavorite(audioId: number) {
  1363. try {
  1364. await del(`/favorites/${audioId}`);
  1365. uni.showToast({ title: '已取消收藏', icon: 'success' });
  1366. // 从列表中移除
  1367. favorites.value = favorites.value.filter(item => item.audioId !== audioId);
  1368. // ═══════════════════════════════════════════════════════════════
  1369. // 文件: my-uniapp-vue3/src/pages/login/index.vue
  1370. // ═══════════════════════════════════════════════════════════════
  1371. <template>
  1372. <view class="page">
  1373. <!-- 顶部背景 -->
  1374. <view class="header">
  1375. <text class="title">声工坊</text>
  1376. <text class="subtitle">让文字变成声音</text>
  1377. </view>
  1378. <!-- 登录表单 -->
  1379. <view class="form-card">
  1380. <text class="form-title">手机号登录</text>
  1381. <!-- 手机号输入 -->
  1382. <view class="input-group">
  1383. <text class="input-label">手机号</text>
  1384. <input
  1385. v-model="phone"
  1386. type="number"
  1387. class="input"
  1388. placeholder="请输入手机号"
  1389. maxlength="11"
  1390. />
  1391. </view>
  1392. <!-- 验证码输入 -->
  1393. <view class="input-group">
  1394. <text class="input-label">验证码</text>
  1395. <view class="code-row">
  1396. <input
  1397. v-model="code"
  1398. type="number"
  1399. class="input code-input"
  1400. placeholder="请输入验证码"
  1401. maxlength="6"
  1402. />
  1403. <button
  1404. class="code-btn"
  1405. :disabled="countdown > 0 || !isPhoneValid"
  1406. @click="handleSendCode"
  1407. >
  1408. <text class="code-btn-text">{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}</text>
  1409. </button>
  1410. </view>
  1411. </view>
  1412. <!-- 登录按钮 -->
  1413. <button class="login-btn" :disabled="!canLogin" @click="handleLogin">
  1414. <text class="login-btn-text">登录</text>
  1415. </button>
  1416. <!-- 提示 -->
  1417. <text class="tip">登录即表示同意《用户协议》和《隐私政策》</text>
  1418. </view>
  1419. </view>
  1420. </template>
  1421. <script setup lang="ts">
  1422. import { ref, computed, onUnmounted } from 'vue';
  1423. import { useUserStore } from '../../store/user';
  1424. const userStore = useUserStore();
  1425. // 状态
  1426. const phone = ref('');
  1427. const code = ref('');
  1428. const countdown = ref(0);
  1429. const loading = ref(false);
  1430. let timer: number | null = null;
  1431. // 计算属性
  1432. const isPhoneValid = computed(() => /^1[3-9]\d{9}$/.test(phone.value));
  1433. // 开发环境免密登录:验证码可以为空
  1434. const canLogin = computed(() => isPhoneValid.value && !loading.value);
  1435. // 发送验证码
  1436. async function handleSendCode() {
  1437. if (countdown.value > 0 || !isPhoneValid.value) return;
  1438. try {
  1439. loading.value = true;
  1440. await userStore.sendCode(phone.value);
  1441. uni.showToast({ title: '验证码已发送', icon: 'success' });
  1442. // 开始倒计时
  1443. countdown.value = 60;
  1444. timer = setInterval(() => {
  1445. countdown.value--;
  1446. if (countdown.value <= 0 && timer) {
  1447. clearInterval(timer);
  1448. timer = null;
  1449. }
  1450. }, 1000) as unknown as number;
  1451. // ═══════════════════════════════════════════════════════════════
  1452. // 文件: my-uniapp-vue3/src/pages/settings/index.vue
  1453. // ═══════════════════════════════════════════════════════════════
  1454. <template>
  1455. <view class="page">
  1456. <!-- 顶部导航 -->
  1457. <view class="nav-bar">
  1458. <view class="nav-btn" @click="goBack">
  1459. <text class="nav-icon">←</text>
  1460. </view>
  1461. <text class="nav-title">设置</text>
  1462. <view class="nav-btn" />
  1463. </view>
  1464. <!-- 设置列表 -->
  1465. <scroll-view scroll-y class="settings-list" :scroll-top="scrollTop">
  1466. <!-- 播放设置 -->
  1467. <view class="settings-section">
  1468. <text class="section-title">播放</text>
  1469. <!-- 默认音色 -->
  1470. <view class="settings-item" @click="showVoicePicker = true">
  1471. <text class="item-icon">🎤</text>
  1472. <text class="item-text">默认音色</text>
  1473. <view class="item-value">
  1474. <text class="value-text">{{ getVoiceLabel(preferences.defaultVoiceId) }}</text>
  1475. <text class="item-arrow">›</text>
  1476. </view>
  1477. </view>
  1478. <!-- 默认音量 -->
  1479. <view class="settings-item">
  1480. <text class="item-icon">🔊</text>
  1481. <text class="item-text">默认音量</text>
  1482. <view class="item-value">
  1483. <text class="value-text">{{ preferences.defaultVolume }}%</text>
  1484. <slider
  1485. class="volume-slider"
  1486. :value="preferences.defaultVolume"
  1487. @change="onVolumeChange"
  1488. min="0"
  1489. max="100"
  1490. step="5"
  1491. activeColor="#4F46E5"
  1492. backgroundColor="#E5E7EB"
  1493. block-size="20"
  1494. />
  1495. </view>
  1496. </view>
  1497. <!-- 自动播放下一首 -->
  1498. <view class="settings-item">
  1499. <text class="item-icon">▶️</text>
  1500. <text class="item-text">自动播放下一首</text>
  1501. <switch
  1502. :checked="preferences.autoPlayNext"
  1503. @change="onAutoPlayChange"
  1504. activeColor="#4F46E5"
  1505. />
  1506. </view>
  1507. </view>
  1508. <!-- 主题设置 -->
  1509. <view class="settings-section">
  1510. <text class="section-title">外观</text>
  1511. <view class="settings-item" @click="showThemePicker = true">
  1512. <text class="item-icon">🎨</text>
  1513. <text class="item-text">主题</text>
  1514. <view class="item-value">
  1515. <text class="value-text">{{ themeLabels[theme] }}</text>
  1516. <text class="item-arrow">›</text>
  1517. </view>
  1518. </view>
  1519. </view>
  1520. <!-- 下载设置 -->
  1521. <view class="settings-section">
  1522. <text class="section-title">下载</text>
  1523. <view class="settings-item">
  1524. <text class="item-icon">📶</text>
  1525. <text class="item-text">仅WiFi下载</text>
  1526. <switch
  1527. :checked="preferences.wifiOnlyDownload"
  1528. @change="onWifiOnlyChange"
  1529. activeColor="#4F46E5"
  1530. />
  1531. </view>
  1532. </view>
  1533. <!-- 存储设置 -->
  1534. // ═══════════════════════════════════════════════════════════════
  1535. // 文件: my-uniapp-vue3/src/pages/billing/index.vue
  1536. // ═══════════════════════════════════════════════════════════════
  1537. <template>
  1538. <view class="page">
  1539. <view class="header">
  1540. <text class="title">消费账单</text>
  1541. </view>
  1542. <!-- 汇总卡片 -->
  1543. <view class="summary-card">
  1544. <view class="summary-item">
  1545. <text class="summary-value">¥{{ summary.totalCost }}</text>
  1546. <text class="summary-label">总费用</text>
  1547. </view>
  1548. <view class="summary-divider" />
  1549. <view class="summary-item">
  1550. <text class="summary-value">{{ summary.totalCalls }}</text>
  1551. <text class="summary-label">总调用次数</text>
  1552. </view>
  1553. <view class="summary-divider" />
  1554. <view class="summary-item">
  1555. <text class="summary-value">{{ formatNumber(summary.totalTokens) }}</text>
  1556. <text class="summary-label">总积分消耗</text>
  1557. </view>
  1558. </view>
  1559. <!-- 分类统计 -->
  1560. <view class="type-stats" v-if="summary.byType.length">
  1561. <view class="type-item" v-for="t in summary.byType" :key="t.type">
  1562. <text class="type-name">{{ typeLabel(t.type) }}</text>
  1563. <view class="type-data">
  1564. <text class="type-count">{{ t.count }}次</text>
  1565. <text class="type-cost">¥{{ t.cost.toFixed(4) }}</text>
  1566. </view>
  1567. </view>
  1568. </view>
  1569. <!-- 明细列表 -->
  1570. <view class="section-title">调用明细</view>
  1571. <view class="record-list">
  1572. <view class="record-card" v-for="r in records" :key="r.id">
  1573. <view class="record-top">
  1574. <text :class="['call-badge', r.callType.startsWith('llm') ? 'badge-llm' : 'badge-tts']">
  1575. {{ typeLabel(r.callType) }}
  1576. </text>
  1577. <text class="record-time">{{ formatTime(r.time) }}</text>
  1578. </view>
  1579. <view class="record-info">
  1580. <text class="record-model">{{ r.model }}</text>
  1581. </view>
  1582. <view class="record-tokens" v-if="r.inputTokens || r.outputTokens">
  1583. <text v-if="r.callType.startsWith('llm')">
  1584. in: {{ r.inputTokens }}积分 / out: {{ r.outputTokens }}积分
  1585. </text>
  1586. <text v-else>
  1587. 字符: {{ r.textLen || r.outputTokens }}
  1588. </text>
  1589. </view>
  1590. <view class="record-bottom">
  1591. <text class="record-cost">¥{{ r.cost }}</text>
  1592. <text class="record-dur" v-if="r.duration">{{ (r.duration / 1000).toFixed(1) }}s</text>
  1593. <text :class="['record-status', r.success ? 'status-ok' : 'status-fail']">
  1594. {{ r.success ? '✓' : '✗' }}
  1595. </text>
  1596. </view>
  1597. </view>
  1598. </view>
  1599. <view class="empty" v-if="loading">
  1600. <text>加载中...</text>
  1601. </view>
  1602. <view class="empty" v-else-if="!records.length">
  1603. <text>暂无消费记录</text>
  1604. </view>
  1605. </view>
  1606. </template>
  1607. <script setup lang="ts">
  1608. import { ref, onMounted } from 'vue';
  1609. import { request } from '@/utils/request';
  1610. import { formatNumber } from '@/utils/format';
  1611. interface LogRecord {
  1612. id: number; callType: string; provider: string; model: string;
  1613. textLen: number; inputTokens: number; outputTokens: number;
  1614. cost: number; duration: number; success: boolean; bookId: number; time: string;
  1615. }
  1616. const records = ref<LogRecord[]>([]);
  1617. // ═══════════════════════════════════════════════════════════════
  1618. // 文件: my-uniapp-vue3/src/pages/notifications/index.vue
  1619. // ═══════════════════════════════════════════════════════════════
  1620. <template>
  1621. <view class="page">
  1622. <view class="nav-bar">
  1623. <view class="nav-btn" @click="goBack">
  1624. <text class="nav-icon">←</text>
  1625. </view>
  1626. <text class="nav-title">消息通知</text>
  1627. <view class="nav-btn" @click="markAllRead">
  1628. <text class="read-all">全部已读</text>
  1629. </view>
  1630. </view>
  1631. <scroll-view scroll-y class="content" :scroll-top="scrollTop">
  1632. <view v-if="notifications.length === 0 && !loading" class="empty">
  1633. <text class="empty-icon">🔔</text>
  1634. <text class="empty-text">暂无通知</text>
  1635. </view>
  1636. <view v-else class="notification-list">
  1637. <view
  1638. v-for="item in notifications"
  1639. :key="item.id"
  1640. class="notification-item"
  1641. :class="{ unread: !item.isRead }"
  1642. @click="markAsRead(item)"
  1643. >
  1644. <view class="notification-icon" :class="getIconClass(item.type)">
  1645. <text>{{ getIcon(item.type) }}</text>
  1646. </view>
  1647. <view class="notification-content">
  1648. <text class="notification-title">{{ item.title }}</text>
  1649. <text class="notification-desc">{{ item.content }}</text>
  1650. <text class="notification-time">{{ formatTime(item.createdAt) }}</text>
  1651. </view>
  1652. <view v-if="!item.isRead" class="unread-dot"></view>
  1653. </view>
  1654. </view>
  1655. <view v-if="loading" class="loading">
  1656. <text>加载中...</text>
  1657. </view>
  1658. </scroll-view>
  1659. </view>
  1660. </template>
  1661. <script setup lang="ts">
  1662. import { ref, onMounted } from 'vue';
  1663. import { get, put } from '../../utils/request';
  1664. // scroll-top 用于避免 scrollTop 错误
  1665. const scrollTop = ref(0);
  1666. const notifications = ref<any[]>([]);
  1667. const loading = ref(false);
  1668. function goBack() {
  1669. const pages = getCurrentPages();
  1670. if (pages.length > 1) {
  1671. uni.navigateBack();
  1672. } else {
  1673. uni.switchTab({ url: '/pages/index/index' });
  1674. }
  1675. }
  1676. function getIcon(type: string): string {
  1677. const icons: Record<string, string> = {
  1678. audio_completed: '🎵',
  1679. book_completed: '📖',
  1680. video_completed: '🎬',
  1681. member_expiring: '⚠️',
  1682. member_expired: '❌',
  1683. daily_reminder: '📅',
  1684. };
  1685. return icons[type] || '🔔';
  1686. }
  1687. function getIconClass(type: string): string {
  1688. if (type.includes('completed')) return 'success';
  1689. if (type.includes('expiring')) return 'warning';
  1690. if (type.includes('expired')) return 'error';
  1691. return 'info';
  1692. }
  1693. function formatTime(dateStr: string): string {
  1694. const date = new Date(dateStr);
  1695. const now = new Date();
  1696. const diff = now.getTime() - date.getTime();
  1697. const minutes = Math.floor(diff / (1000 * 60));
  1698. const hours = Math.floor(diff / (1000 * 60 * 60));
  1699. const days = Math.floor(diff / (1000 * 60 * 60 * 24));
  1700. // ═══════════════════════════════════════════════════════════════
  1701. // 文件: my-uniapp-vue3/src/pages/album/index.vue
  1702. // ═══════════════════════════════════════════════════════════════
  1703. <template>
  1704. <view class="page">
  1705. <!-- 顶部导航 -->
  1706. <view class="nav-bar">
  1707. <view class="nav-btn" @click="goBack">
  1708. <text class="nav-icon">←</text>
  1709. </view>
  1710. <text class="nav-title">书籍详情</text>
  1711. <view class="nav-actions" v-if="book">
  1712. <view class="nav-btn" @click="showSharePanel = true">
  1713. <text class="nav-icon">📤</text>
  1714. </view>
  1715. <view class="nav-btn" @click="showRenameBookModal = true">
  1716. <text class="nav-icon">✏️</text>
  1717. </view>
  1718. </view>
  1719. </view>
  1720. <!-- 骨架屏加载状态 -->
  1721. <view v-if="loading && !book" class="skeleton-container">
  1722. <view class="skeleton-cover"></view>
  1723. <view class="skeleton-info">
  1724. <view class="skeleton-title"></view>
  1725. <view class="skeleton-desc"></view>
  1726. </view>
  1727. </view>
  1728. <!-- 书籍信息 -->
  1729. <view v-else-if="book" class="album-header">
  1730. <view class="album-cover" :style="{ background: getCoverGradient() }">
  1731. <text class="cover-letter">{{ getTitleLetter(book?.title || '') }}</text>
  1732. <text class="cover-title-small">{{ book?.title }}</text>
  1733. </view>
  1734. <view class="album-info">
  1735. <text class="album-title">{{ book.title }}</text>
  1736. <text class="album-meta">{{ book.totalChapters }}章 · {{ completedChapters }}章已完成</text>
  1737. <text class="album-desc">{{ book.description || '暂无描述' }}</text>
  1738. </view>
  1739. </view>
  1740. <!-- 操作按钮 -->
  1741. <view v-if="book" class="action-bar">
  1742. <button class="action-btn" :class="{ subscribed: isFavorited }" @click="toggleFavorite">
  1743. <text>{{ isFavorited ? '已收藏' : '收藏' }}</text>
  1744. </button>
  1745. <button class="action-btn primary" @click="playAll">
  1746. <text>播放全部</text>
  1747. </button>
  1748. </view>
  1749. <!-- 章节列表 -->
  1750. <view v-if="!loading" class="audio-section">
  1751. <text class="section-title">章节列表</text>
  1752. <view v-if="chapters.length === 0" class="empty">
  1753. <text class="empty-icon">📖</text>
  1754. <text class="empty-text">暂无章节</text>
  1755. </view>
  1756. <view v-else class="audio-list">
  1757. <view
  1758. v-for="(item, index) in chapters"
  1759. :key="item.id"
  1760. :class="['audio-item', { 'has-audio': item.audioUrl, 'has-video': item.videoUrl }]"
  1761. :style="{ animationDelay: (index * 0.04) + 's' }"
  1762. @click="playChapter(item, index)"
  1763. @longpress="showChapterMenu(item)"
  1764. >
  1765. <view class="audio-index">{{ index + 1 }}</view>
  1766. <view class="audio-content">
  1767. <text class="audio-title">{{ item.title }}</text>
  1768. <text class="audio-meta">
  1769. {{ item.audioUrl ? formatDuration(item.audioDuration) + ' · ' : '' }}
  1770. {{ item.wordCount || 0 }}字
  1771. <span v-if="item.audioUrl" class="audio-tag">🎵</span>
  1772. <span v-if="item.videoUrl" class="video-tag">🎬</span>
  1773. </text>
  1774. </view>
  1775. <view class="audio-actions">
  1776. <!-- 视频播放按钮 -->
  1777. <view v-if="item.videoUrl" class="video-play-btn" @click.stop="playVideo(item)">
  1778. <text class="video-play-icon">▶</text>
  1779. </view>
  1780. <!-- 音频播放按钮 -->
  1781. <view class="audio-play">
  1782. <text class="play-icon">▶</text>
  1783. // ═══════════════════════════════════════════════════════════════
  1784. // 文件: my-uniapp-vue3/src/pages/albums/index.vue
  1785. // ═══════════════════════════════════════════════════════════════
  1786. <template>
  1787. <view class="page">
  1788. <!-- 顶部导航 -->
  1789. <view class="nav-bar">
  1790. <view class="nav-btn" @click="goBack">
  1791. <text class="nav-icon">←</text>
  1792. </view>
  1793. <text class="nav-title">专辑</text>
  1794. <view class="nav-btn" />
  1795. </view>
  1796. <!-- 骨架屏加载状态 -->
  1797. <view v-if="loading && albumList.length === 0" class="skeleton-grid">
  1798. <view v-for="i in 6" :key="i" class="skeleton-card">
  1799. <view class="skeleton-cover"></view>
  1800. <view class="skeleton-info">
  1801. <view class="skeleton-title"></view>
  1802. <view class="skeleton-meta"></view>
  1803. </view>
  1804. </view>
  1805. </view>
  1806. <!-- 专辑列表 - 使用 v-show 避免 scroll-top 初始化的 DOM 未就绪问题 -->
  1807. <scroll-view scroll-y class="album-list" @scrolltolower="loadMore" :style="{ display: (loading && albumList.length === 0) ? 'none' : 'block' }" :scroll-top="scrollTop">
  1808. <view v-if="albumList.length === 0 && !loading" class="empty">
  1809. <text class="empty-icon">📚</text>
  1810. <text class="empty-text">暂无专辑</text>
  1811. <text class="empty-hint">创建你的第一个专辑吧</text>
  1812. </view>
  1813. <view v-else class="album-grid">
  1814. <view
  1815. v-for="item in albumList"
  1816. :key="item.id"
  1817. class="album-card"
  1818. @click="goToAlbumDetail(item.id)"
  1819. >
  1820. <view class="album-cover" :style="{ background: getCoverGradient(item) }">
  1821. <text class="cover-letter">{{ getTitleLetter(item.name) }}</text>
  1822. <text class="cover-title-small">{{ item.name }}</text>
  1823. <view class="album-count">{{ item.subscriberCount || 0 }}人订阅</view>
  1824. </view>
  1825. <view class="album-info">
  1826. <text class="album-title">{{ item.name }}</text>
  1827. <text class="album-desc">{{ item.description || '暂无描述' }}</text>
  1828. </view>
  1829. </view>
  1830. </view>
  1831. <view v-if="loading" class="loading">
  1832. <text>加载中...</text>
  1833. </view>
  1834. <view v-if="!hasMore && albumList.length > 0" class="no-more">
  1835. <text>没有更多了</text>
  1836. </view>
  1837. </scroll-view>
  1838. </view>
  1839. </template>
  1840. <script setup lang="ts">
  1841. import { ref, onMounted } from 'vue';
  1842. import { get } from '../../utils/request';
  1843. import { getBookGradient, getTitleLetter } from '../../composables/useCoverStyle';
  1844. // scroll-top 用于避免 scrollTop 错误
  1845. const scrollTop = ref(0);
  1846. interface Album {
  1847. id: number;
  1848. name: string;
  1849. description?: string;
  1850. coverUrl?: string;
  1851. subscriberCount: number;
  1852. }
  1853. const albumList = ref<Album[]>([]);
  1854. const page = ref(1);
  1855. const pageSize = 20;
  1856. const total = ref(0);
  1857. const loading = ref(false);
  1858. const hasMore = ref(true);
  1859. // 获取封面渐变色
  1860. function getCoverGradient(item: Album): string {
  1861. return getBookGradient(item.id);
  1862. }
  1863. // 返回
  1864. function goBack() {
  1865. const pages = getCurrentPages();
  1866. // ═══════════════════════════════════════════════════════════════
  1867. // 文件: my-uniapp-vue3/src/pages/pixelle-video/index.vue
  1868. // ═══════════════════════════════════════════════════════════════
  1869. <template>
  1870. <view class="container">
  1871. <!-- 顶部导航 -->
  1872. <view class="header">
  1873. <view class="back-btn" @click="goBack">
  1874. <text class="icon">←</text>
  1875. </view>
  1876. <text class="title">AI 视频生成</text>
  1877. <view class="placeholder"></view>
  1878. </view>
  1879. <!-- 服务状态 -->
  1880. <view class="status-bar" :class="serviceStatus">
  1881. <text>{{ statusText }}</text>
  1882. </view>
  1883. <!-- 输入区域 -->
  1884. <view class="input-section">
  1885. <view class="input-group">
  1886. <text class="label">视频主题</text>
  1887. <textarea
  1888. v-model="topic"
  1889. class="textarea"
  1890. placeholder="例如:人工智能的未来发展趋势"
  1891. :disabled="isGenerating"
  1892. ></textarea>
  1893. </view>
  1894. <view class="input-group">
  1895. <text class="label">视频风格</text>
  1896. <view class="style-grid">
  1897. <view
  1898. v-for="style in styles"
  1899. :key="style"
  1900. class="style-item"
  1901. :class="{ active: selectedStyle === style }"
  1902. @click="selectedStyle = style"
  1903. >
  1904. {{ style }}
  1905. </view>
  1906. </view>
  1907. </view>
  1908. <view class="input-group">
  1909. <text class="label">画面比例</text>
  1910. <view class="ratio-group">
  1911. <view
  1912. class="ratio-item"
  1913. :class="{ active: aspectRatio === '9:16' }"
  1914. @click="aspectRatio = '9:16'"
  1915. >
  1916. <text class="ratio-icon">📱</text>
  1917. <text>竖屏 9:16</text>
  1918. </view>
  1919. <view
  1920. class="ratio-item"
  1921. :class="{ active: aspectRatio === '16:9' }"
  1922. @click="aspectRatio = '16:9'"
  1923. >
  1924. <text class="ratio-icon">🖥️</text>
  1925. <text>横屏 16:9</text>
  1926. </view>
  1927. <view
  1928. class="ratio-item"
  1929. :class="{ active: aspectRatio === '1:1' }"
  1930. @click="aspectRatio = '1:1'"
  1931. >
  1932. <text class="ratio-icon">⬜</text>
  1933. <text>方形 1:1</text>
  1934. </view>
  1935. </view>
  1936. </view>
  1937. </view>
  1938. <!-- 生成按钮 -->
  1939. <view class="action-section">
  1940. <button
  1941. class="generate-btn"
  1942. :disabled="!canGenerate || isGenerating"
  1943. @click="generateContent"
  1944. >
  1945. <text v-if="isGenerating">生成中...</text>
  1946. <text v-else>开始生成</text>
  1947. </button>
  1948. </view>
  1949. // ═══════════════════════════════════════════════════════════════
  1950. // 文件: my-uniapp-vue3/src/components/MiniPlayer.vue
  1951. // ═══════════════════════════════════════════════════════════════
  1952. <template>
  1953. <view v-if="showMiniPlayer" class="mini-player" @click="goToPlayer">
  1954. <!-- 封面 -->
  1955. <view class="mini-cover" :style="{ background: coverGradient }">
  1956. <text class="cover-icon">{{ getTitleLetter(audioStore.currentAudio?.title || '') }}</text>
  1957. </view>
  1958. <!-- 信息 -->
  1959. <view class="mini-info">
  1960. <text class="mini-title">{{ audioStore.currentAudio?.title || '未知音频' }}</text>
  1961. <text class="mini-subtitle">{{ formatTime(audioStore.currentTime) }} / {{ formatTime(audioStore.duration) }}</text>
  1962. </view>
  1963. <!-- 控制按钮 -->
  1964. <view class="mini-controls">
  1965. <view class="control-btn" @click.stop="togglePlay">
  1966. <text>{{ audioStore.isPlaying ? '⏸' : '▶' }}</text>
  1967. </view>
  1968. <view class="control-btn" @click.stop="playNext">
  1969. <text>⏭</text>
  1970. </view>
  1971. </view>
  1972. </view>
  1973. </template>
  1974. <script setup lang="ts">
  1975. import { computed } from 'vue';
  1976. import { onShow } from '@dcloudio/uni-app';
  1977. import { useAudioStore } from '../store/audio';
  1978. import { getCoverGradient, getTitleLetter } from '../composables/useCoverStyle';
  1979. const audioStore = useAudioStore();
  1980. const showMiniPlayer = computed(() => {
  1981. // 获取当前页面路径
  1982. const pages = getCurrentPages();
  1983. const currentPage = pages[pages.length - 1];
  1984. const currentPath = currentPage?.route || '';
  1985. // 如果在播放器页面,不显示迷你播放器
  1986. if (currentPath.includes('player')) {
  1987. return false;
  1988. }
  1989. // 如果有当前音频,显示迷你播放器
  1990. return !!audioStore.currentAudio;
  1991. });
  1992. // 封面颜色
  1993. const coverGradient = computed(() => {
  1994. const voiceId = audioStore.currentAudio?.voiceId || 'cherry';
  1995. return getCoverGradient(voiceId);
  1996. });
  1997. // 格式化时间
  1998. function formatTime(seconds: number): string {
  1999. if (!seconds || isNaN(seconds)) return '0:00';
  2000. const mins = Math.floor(seconds / 60);
  2001. const secs = Math.floor(seconds % 60);
  2002. return `${mins}:${secs.toString().padStart(2, '0')}`;
  2003. }
  2004. // 切换播放
  2005. function togglePlay() {
  2006. audioStore.togglePlay();
  2007. }
  2008. // 播放下一首
  2009. function playNext() {
  2010. audioStore.playNext();
  2011. }
  2012. // 跳转到播放器页面
  2013. function goToPlayer() {
  2014. uni.navigateTo({ url: '/pages/player/index' });
  2015. }
  2016. </script>
  2017. <style scoped>
  2018. .mini-player {
  2019. position: fixed;
  2020. bottom: 120rpx;
  2021. left: 16rpx;
  2022. right: 16rpx;
  2023. height: 96rpx;
  2024. background: rgba(255, 255, 255, 0.9);
  2025. backdrop-filter: blur(28px);
  2026. -webkit-backdrop-filter: blur(28px);
  2027. border-radius: 48rpx;
  2028. box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.12), 0 0 0 0.5rpx rgba(0, 0, 0, 0.06);
  2029. display: flex;
  2030. align-items: center;
  2031. padding: 0 16rpx;
  2032. // ═══════════════════════════════════════════════════════════════
  2033. // 文件: my-uniapp-vue3/src/components/AudioDownload.vue
  2034. // ═══════════════════════════════════════════════════════════════
  2035. <template>
  2036. <view class="download-btn" @click="handleDownload">
  2037. <slot>
  2038. <view class="default-btn" :class="{ downloading: downloading }">
  2039. <text class="icon">{{ downloading ? '⏳' : '⬇️' }}</text>
  2040. <text class="text">{{ downloading ? `下载中 ${progress}%` : '下载音频' }}</text>
  2041. <view v-if="downloading" class="progress-bar">
  2042. <view class="progress-fill" :style="{ width: progress + '%' }"></view>
  2043. </view>
  2044. </view>
  2045. </slot>
  2046. </view>
  2047. </template>
  2048. <script setup lang="ts">
  2049. import { ref } from 'vue';
  2050. interface Props {
  2051. url: string;
  2052. filename?: string;
  2053. audioId?: string;
  2054. }
  2055. const props = withDefaults(defineProps<Props>(), {
  2056. filename: 'audio.mp3',
  2057. audioId: '',
  2058. });
  2059. const downloading = ref(false);
  2060. const progress = ref(0);
  2061. const retryCount = ref(0);
  2062. const maxRetries = 2;
  2063. async function handleDownload() {
  2064. if (downloading.value) return;
  2065. try {
  2066. downloading.value = true;
  2067. progress.value = 0;
  2068. retryCount.value = 0;
  2069. await downloadWithRetry();
  2070. } catch (error) {
  2071. console.error('[Download] 下载失败:', error);
  2072. }
  2073. }
  2074. async function downloadWithRetry() {
  2075. while (retryCount.value <= maxRetries) {
  2076. try {
  2077. await executeDownload();
  2078. return; // 下载成功,退出重试循环
  2079. } catch (error) {
  2080. retryCount.value++;
  2081. if (retryCount.value > maxRetries) {
  2082. uni.hideLoading();
  2083. downloading.value = false;
  2084. uni.showModal({
  2085. title: '下载失败',
  2086. content: `下载失败,已重试${maxRetries}次。请检查网络连接后重试。`,
  2087. showCancel: false,
  2088. });
  2089. throw error;
  2090. }
  2091. // 等待一段时间后重试
  2092. await new Promise(resolve => setTimeout(resolve, 1000 * retryCount.value));
  2093. }
  2094. }
  2095. }
  2096. async function executeDownload() {
  2097. return new Promise<void>((resolve, reject) => {
  2098. uni.showLoading({
  2099. title: '下载中...',
  2100. mask: true,
  2101. });
  2102. // 下载文件
  2103. const downloadTask = uni.downloadFile({
  2104. url: props.url,
  2105. success: (res) => {
  2106. if (res.statusCode === 200) {
  2107. // 保存到本地
  2108. uni.saveFile({
  2109. tempFilePath: res.tempFilePath,
  2110. success: (saveRes) => {
  2111. uni.hideLoading();
  2112. downloading.value = false;
  2113. progress.value = 100;
  2114. uni.showToast({
  2115. // ═══════════════════════════════════════════════════════════════
  2116. // 文件: my-uniapp-vue3/src/components/SharePanel.vue
  2117. // ═══════════════════════════════════════════════════════════════
  2118. <template>
  2119. <view v-if="visible" class="share-overlay" @click="close">
  2120. <view class="share-mask" />
  2121. <view class="share-panel" :class="{ 'h5-panel': platform === 'h5' }" @click.stop>
  2122. <!-- 面板头部 -->
  2123. <view class="panel-header">
  2124. <text class="panel-title">分享到</text>
  2125. <view class="panel-close" @click="close">
  2126. <text>✕</text>
  2127. </view>
  2128. </view>
  2129. <!-- 分享渠道列表 -->
  2130. <view class="share-channels">
  2131. <!-- H5 端:浏览器原生分享 -->
  2132. <view v-if="platform === 'h5'" class="channel-grid">
  2133. <view class="channel-item" @click="doWebShare">
  2134. <view class="channel-icon" style="background: linear-gradient(135deg, #667eea, #764ba2)">
  2135. <text class="icon-emoji">📤</text>
  2136. </view>
  2137. <text class="channel-label">分享给好友</text>
  2138. </view>
  2139. <view class="channel-item" @click="doCopyLink">
  2140. <view class="channel-icon" style="background: #6b7280">
  2141. <text class="icon-emoji">🔗</text>
  2142. </view>
  2143. <text class="channel-label">复制链接</text>
  2144. </view>
  2145. <view v-if="showPosterBtn" class="channel-item" @click="$emit('generatePoster')">
  2146. <view class="channel-icon" style="background: linear-gradient(135deg, #f093fb, #f5576c)">
  2147. <text class="icon-emoji">🖼️</text>
  2148. </view>
  2149. <text class="channel-label">生成海报</text>
  2150. </view>
  2151. </view>
  2152. <!-- App 端:原生分享渠道 -->
  2153. <view v-if="platform === 'app'" class="channel-grid">
  2154. <view class="channel-item" @click="doShareToWechat">
  2155. <view class="channel-icon wechat">
  2156. <text class="icon-emoji">💬</text>
  2157. </view>
  2158. <text class="channel-label">微信好友</text>
  2159. </view>
  2160. <view class="channel-item" @click="doShareToTimeline">
  2161. <view class="channel-icon timeline">
  2162. <text class="icon-emoji">🌐</text>
  2163. </view>
  2164. <text class="channel-label">朋友圈</text>
  2165. </view>
  2166. <view class="channel-item" @click="doShareToQQ">
  2167. <view class="channel-icon qq">
  2168. <text class="icon-emoji">🐧</text>
  2169. </view>
  2170. <text class="channel-label">QQ好友</text>
  2171. </view>
  2172. <view class="channel-item" @click="doShareWithSystem">
  2173. <view class="channel-icon" style="background: #374151">
  2174. <text class="icon-emoji">📲</text>
  2175. </view>
  2176. <text class="channel-label">更多</text>
  2177. </view>
  2178. <view class="channel-item" @click="doCopyLink">
  2179. <view class="channel-icon" style="background: #6b7280">
  2180. <text class="icon-emoji">🔗</text>
  2181. </view>
  2182. <text class="channel-label">复制链接</text>
  2183. </view>
  2184. <view v-if="showPosterBtn" class="channel-item" @click="$emit('generatePoster')">
  2185. <view class="channel-icon poster">
  2186. <text class="icon-emoji">🖼️</text>
  2187. </view>
  2188. <text class="channel-label">生成海报</text>
  2189. </view>
  2190. </view>
  2191. <!-- 小程序端:分享消息卡片 + 复制链接 -->
  2192. <view v-if="platform === 'mp-weixin'" class="channel-grid">
  2193. <!-- 小程序分享消息 -->
  2194. <button class="channel-item mp-share-btn" open-type="share" @click="onMiniProgramShare">
  2195. <view class="channel-icon wechat">
  2196. <text class="icon-emoji">💬</text>
  2197. </view>
  2198. // ═══════════════════════════════════════════════════════════════
  2199. // 文件: my-uniapp-vue3/src/components/CreditInsufficientModal.vue
  2200. // ═══════════════════════════════════════════════════════════════
  2201. <template>
  2202. <Teleport to="body">
  2203. <view v-if="visible" class="credit-modal-mask" @click="handleClose">
  2204. <view class="credit-modal-sheet" :class="{ show: visible }" @click.stop>
  2205. <!-- 关闭按钮 -->
  2206. <view class="sheet-handle" @click="handleClose"></view>
  2207. <!-- 图标和标题 -->
  2208. <view class="modal-header">
  2209. <view class="credit-icon">⚡</view>
  2210. <text class="modal-title">积分不足</text>
  2211. <text class="modal-desc" v-if="currentCredits !== null && requiredCredits !== null">
  2212. 当前 {{ currentCredits }} 积分,还差 {{ requiredCredits - currentCredits }}
  2213. </text>
  2214. <text class="modal-desc" v-else>
  2215. 请充值积分继续使用
  2216. </text>
  2217. </view>
  2218. <!-- 最低消费引导 -->
  2219. <view class="entry-point" v-if="lowestPrice">
  2220. <text class="entry-tip">最低 </text>
  2221. <text class="entry-price">¥{{ lowestPrice }}</text>
  2222. <text class="entry-tip"> 即可开始</text>
  2223. </view>
  2224. <!-- 操作按钮 -->
  2225. <view class="action-buttons">
  2226. <!-- VIP订阅 -->
  2227. <view class="action-card vip-action" @click="handleVipSubscribe">
  2228. <view class="action-icon">👑</view>
  2229. <view class="action-content">
  2230. <text class="action-title">开通VIP会员</text>
  2231. <text class="action-desc">每月享更多积分权益</text>
  2232. </view>
  2233. <text class="action-arrow">→</text>
  2234. </view>
  2235. <!-- 购买积分包 -->
  2236. <view class="action-card pack-action" @click="handleBuyPack">
  2237. <view class="action-icon">🎁</view>
  2238. <view class="action-content">
  2239. <text class="action-title">购买积分包</text>
  2240. <text class="action-desc">100分钟 ¥4.8 起</text>
  2241. </view>
  2242. <text class="action-arrow">→</text>
  2243. </view>
  2244. </view>
  2245. <!-- 底部留白 -->
  2246. <view class="sheet-footer"></view>
  2247. </view>
  2248. </view>
  2249. </Teleport>
  2250. </template>
  2251. <script setup lang="ts">
  2252. import { getCreditModalState } from '../composables/useCreditInsufficient';
  2253. const { visible, currentCredits, requiredCredits, lowestPrice } = getCreditModalState();
  2254. function handleClose() {
  2255. visible.value = false;
  2256. }
  2257. function handleVipSubscribe() {
  2258. uni.navigateTo({ url: '/pages/member/index' });
  2259. handleClose();
  2260. }
  2261. function handleBuyPack() {
  2262. uni.navigateTo({ url: '/pages/token-packs/index' });
  2263. handleClose();
  2264. }
  2265. </script>
  2266. <style scoped>
  2267. .credit-modal-mask {
  2268. position: fixed;
  2269. top: 0;
  2270. left: 0;
  2271. right: 0;
  2272. bottom: 0;
  2273. background: rgba(0, 0, 0, 0.5);
  2274. z-index: 9999;
  2275. display: flex;
  2276. align-items: flex-end;
  2277. justify-content: center;
  2278. opacity: 0;
  2279. transition: opacity 0.25s ease;
  2280. }
  2281. // ═══════════════════════════════════════════════════════════════
  2282. // 文件: my-uniapp-vue3/src/components/GenerationStatusBadge.vue
  2283. // ═══════════════════════════════════════════════════════════════
  2284. <template>
  2285. <view :class="['gen-badge', status]">
  2286. <text class="gen-icon">{{ icon }}</text>
  2287. <text class="gen-label">{{ label }}</text>
  2288. </view>
  2289. </template>
  2290. <script setup lang="ts">
  2291. import { computed } from 'vue';
  2292. const props = withDefaults(defineProps<{
  2293. status: string;
  2294. // 可覆盖默认映射
  2295. customMap?: Record<string, { icon: string; label: string }>;
  2296. }>(), {
  2297. customMap: () => ({}),
  2298. });
  2299. const statusDisplayMap: Record<string, { icon: string; label: string }> = {
  2300. // 统一状态(推荐)
  2301. idle: { icon: '○', label: '未开始' },
  2302. queued: { icon: '⏳', label: '排队中' },
  2303. processing: { icon: '⚡', label: '生成中' },
  2304. retrying: { icon: '🔄', label: '重试中' },
  2305. completed: { icon: '✓', label: '已完成' },
  2306. failed: { icon: '✗', label: '失败' },
  2307. cancelled: { icon: '—', label: '已取消' },
  2308. // 兼容旧状态名
  2309. draft: { icon: '📝', label: '草稿' },
  2310. planning: { icon: '📋', label: '规划中' },
  2311. pending: { icon: '○', label: '待处理' },
  2312. generating: { icon: '⚡', label: '生成中' },
  2313. running: { icon: '⚡', label: '运行中' },
  2314. success: { icon: '✓', label: '成功' },
  2315. paid: { icon: '✓', label: '已支付' },
  2316. refunded: { icon: '↩', label: '已退款' },
  2317. uploading: { icon: '↑', label: '上传中' },
  2318. published: { icon: '🌐', label: '已公开' },
  2319. paused: { icon: '⏸', label: '已暂停' },
  2320. interrupted: { icon: '⏹', label: '已中断' },
  2321. // 线性阶段状态(genStage)- Book级别
  2322. outlining: { icon: '⚡', label: '生成大纲' },
  2323. outline_completed: { icon: '✓', label: '大纲完成' },
  2324. content_generating:{ icon: '⚡', label: '生成内容' },
  2325. content_completed: { icon: '✓', label: '内容完成' },
  2326. audio_generating: { icon: '🎵', label: '生成音频' },
  2327. audio_completed: { icon: '✓', label: '音频完成' },
  2328. video_generating: { icon: '🎬', label: '生成视频' },
  2329. video_completed: { icon: '✓', label: '全部完成' },
  2330. // 线性阶段状态 - Chapter级别(复用部分)
  2331. content_completed_chapter: { icon: '✓', label: '内容完成' },
  2332. audio_generating_chapter: { icon: '⚡', label: '音频生成' },
  2333. audio_completed_chapter: { icon: '✓', label: '音频完成' },
  2334. video_generating_chapter: { icon: '⚡', label: '视频生成' },
  2335. video_completed_chapter: { icon: '✓', label: '已完成' },
  2336. };
  2337. const merged = computed(() => ({ ...statusDisplayMap, ...props.customMap }));
  2338. const display = computed(() => {
  2339. const s = props.status || '';
  2340. return merged.value[s] || { icon: '?', label: s };
  2341. });
  2342. const icon = computed(() => display.value.icon);
  2343. const label = computed(() => display.value.label);
  2344. </script>
  2345. <style scoped>
  2346. .gen-badge {
  2347. display: inline-flex;
  2348. align-items: center;
  2349. gap: 6rpx;
  2350. padding: 6rpx 16rpx;
  2351. border-radius: 20rpx;
  2352. font-size: 22rpx;
  2353. font-weight: 500;
  2354. white-space: nowrap;
  2355. transition: all 0.3s ease;
  2356. user-select: none;
  2357. }
  2358. .gen-icon {
  2359. font-size: 20rpx;
  2360. }
  2361. .gen-label {
  2362. /* 由父级控制文字颜色 */
  2363. }
  2364. ##############################################################################
  2365. # 第二部分:后端源代码(后30页)
  2366. ##############################################################################
  2367. // ═══════════════════════════════════════════════════════════════
  2368. // 文件: server/src/app.ts
  2369. // ═══════════════════════════════════════════════════════════════
  2370. // Auto-deploy test comment
  2371. // 设置进程时区(从.env读取,确保日志和数据库时间一致)
  2372. process.env.TZ = process.env.TZ || 'Asia/Shanghai';
  2373. import Koa from 'koa';
  2374. import cors from '@koa/cors';
  2375. import Router from '@koa/router';
  2376. import bodyParser from '@koa/bodyparser';
  2377. import koaBody from 'koa-body';
  2378. import serve from 'koa-static';
  2379. import mount from 'koa-mount';
  2380. import path from 'path';
  2381. import fs from 'fs';
  2382. import http from 'http';
  2383. import { config } from './config';
  2384. import { connectDatabase } from './models';
  2385. import { errorHandler } from './middleware/errorHandler';
  2386. import { requestLogger } from './services/requestLogger';
  2387. import { httpLogger } from './services/logger.service';
  2388. import { redisService } from './services/redis.service';
  2389. import { ossService } from './services/oss.service';
  2390. import { storageService } from './services/storage.service';
  2391. import { FFmpegProcessor } from './services/ffmpeg.processor';
  2392. import { resumeInterruptedTasks } from './modules/book-generator/book-queue.processor';
  2393. import { startTtsQueue, stopTtsQueue } from './modules/book-generator/tts-queue';
  2394. import { startAudioScanner, stopAudioScanner } from './modules/book-generator/audio-scanner';
  2395. import { queueService } from './services/queue.service';
  2396. import { validateModelsJsonStructure, printValidationReport } from './config/models-validator';
  2397. import { initSentry, sentryErrorHandler } from './services/sentry.service';
  2398. import { xssProtection, sqlInjectionProtection } from './middleware/security';
  2399. import { performanceMonitor, getMetrics } from './middleware/performance';
  2400. import { apiRateLimiter } from './middleware/rate-limiter';
  2401. import logRoutes from './services/log.controller';
  2402. import authRoutes from './modules/auth/auth.controller';
  2403. import ttsRoutes from './modules/tts/tts.controller';
  2404. import memberRoutes from './modules/member/member.controller';
  2405. import shareRoutes from './modules/share/share.controller';
  2406. import playerRoutes from './modules/player/player.controller';
  2407. import favoritesRoutes from './modules/favorites/favorites.controller';
  2408. import preferencesRoutes from './modules/preferences/preferences.controller';
  2409. import searchRoutes from './modules/search/search.controller';
  2410. import categoriesRoutes from './modules/categories/categories.controller';
  2411. import commentsRoutes from './modules/comments/comments.controller';
  2412. import notificationsRoutes from './modules/notifications/notifications.controller';
  2413. import bgmRoutes from './modules/bgm/bgm.controller';
  2414. import audioEditRoutes from './modules/audioedit/audioedit.controller';
  2415. import langGraphRoutes from './modules/book-generator/langgraph-controller';
  2416. import aiGenerateRoutes from './modules/book-generator/ai-generate-controller';
  2417. import albumRoutes from './modules/book-generator/album-controller';
  2418. import albumManagementRoutes from './modules/book-generator/album-management.controller';
  2419. import bookGeneratorRoutes from './modules/book-generator/book-generator.controller';
  2420. import playlistRoutes from './modules/player/playlist.controller';
  2421. import draftsRoutes from './modules/drafts/drafts.controller';
  2422. import videoGeneratorRoutes from './modules/video-generator/video-generator.controller';
  2423. import publishRoutes from './modules/publish/publish.controller';
  2424. import signRoutes from './modules/sign/sign.controller';
  2425. import subscriptionRoutes from './modules/subscription/subscription.controller';
  2426. import paymentRoutes from './modules/payment/payment.controller';
  2427. import historyRoutes from './modules/history/history.controller';
  2428. import historyBatchRoutes from './modules/history/history-batch.controller';
  2429. import feedbackRoutes from './modules/feedback/feedback.controller';
  2430. import queueRoutes from './modules/queue/queue.controller';
  2431. import inviteRoutes from './modules/invite/invite.controller';
  2432. import textToVideoRoutes from './modules/text-to-video/text-to-video.controller';
  2433. import pixelleVideoRoutes from './modules/pixelle-video/pixelle-video.controller';
  2434. import { initializePlans } from './modules/subscription/subscription.service';
  2435. import { initWebSocket } from './services/websocket.service.js';
  2436. const app = new Koa();
  2437. const router = new Router();
  2438. // 创建 HTTP 服务器
  2439. const server = http.createServer(app.callback());
  2440. // 中间件
  2441. app.use(errorHandler);
  2442. app.use(sentryErrorHandler()); // Sentry 错误监控
  2443. app.use(performanceMonitor()); // 性能监控
  2444. app.use(httpLogger); // Winston 日志
  2445. app.use(xssProtection()); // XSS 防护
  2446. app.use(sqlInjectionProtection()); // SQL 注入防护
  2447. app.use(cors({
  2448. origin: '*',
  2449. allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
  2450. // ═══════════════════════════════════════════════════════════════
  2451. // 文件: server/src/config/index.ts
  2452. // ═══════════════════════════════════════════════════════════════
  2453. import dotenv from 'dotenv';
  2454. import path from 'path';
  2455. import fs from 'fs';
  2456. // 使用 process.cwd() 获取项目根目录(server目录)
  2457. const projectRoot = process.cwd();
  2458. dotenv.config({ path: path.resolve(projectRoot, '.env') });
  2459. // 加载模型配置
  2460. const modelsConfig = JSON.parse(fs.readFileSync(path.join(__dirname, 'models.json'), 'utf-8'));
  2461. // 获取所有模型(扁平化)
  2462. function getAllModels() {
  2463. const models: any[] = [];
  2464. for (const [vendorKey, vendor] of Object.entries(modelsConfig.vendors) as [string, any][]) {
  2465. for (const model of vendor.models) {
  2466. models.push({
  2467. ...model,
  2468. vendor: vendorKey,
  2469. vendorName: vendor.name,
  2470. baseUrl: vendor.baseUrl,
  2471. apiKey: vendor.apiKey,
  2472. apiType: vendor.apiType,
  2473. });
  2474. }
  2475. }
  2476. return models;
  2477. }
  2478. // 获取启用的模型
  2479. function getEnabledModels() {
  2480. return getAllModels().filter((m: any) => m.enabled);
  2481. }
  2482. // 根据 ID 获取模型配置
  2483. function getModel(id: string) {
  2484. return getAllModels().find((m: any) => m.id === id);
  2485. }
  2486. // 根据类型获取模型列表 (text, tts, image, video)
  2487. function getModelsByType(type: string) {
  2488. return getAllModels().filter((m: any) => m.input?.includes(type) && m.enabled);
  2489. }
  2490. // 检查模型是否可切换(根据错误类型判断是否需要切换)
  2491. function shouldSwitchModel(error: any): boolean {
  2492. if (!error) return false;
  2493. const message = (error?.message || error?.error?.message || '').toLowerCase();
  2494. const status = error?.status || error?.response?.status || 0;
  2495. // 不可切换的错误:认证/权限/参数问题,换供应商也没用
  2496. const nonSwitchablePatterns = [
  2497. 'invalid api key', 'invalid api-key', 'authentication', 'unauthorized',
  2498. 'invalid token', 'token expired',
  2499. 'permission denied', 'access denied',
  2500. 'invalid request', 'bad request',
  2501. 'invalidparameter', 'invalid_parameter', // TTS API 参数错误(如文本过短)
  2502. ];
  2503. if (nonSwitchablePatterns.some(p => message.includes(p))) return false;
  2504. if (status === 401) return false; // 认证失败
  2505. // 可切换的错误:限流、余额不足、服务不可用、模型不存在
  2506. const switchablePatterns = [
  2507. 'rate limit', 'rate_limit', 'too many requests', '请求过于频繁',
  2508. 'quota', 'balance', 'insufficient', 'usage limit',
  2509. 'model not found', 'model not support', 'does not exist', 'invalid model',
  2510. 'service unavailable', 'bad gateway', 'gateway timeout',
  2511. 'internal server error',
  2512. ];
  2513. if (switchablePatterns.some(p => message.includes(p))) return true;
  2514. // HTTP 状态码判断
  2515. if ([429, 502, 503, 504, 500].includes(status)) return true;
  2516. if (status === 403) return true; // 403 多数是配额/权限,换Key可能有效
  2517. if (status === 404) return true; // 模型不存在,换供应商
  2518. // 状态码文本匹配(兜底)
  2519. if (['429', '502', '503', '504'].some(c => message.includes(c))) return true;
  2520. return false;
  2521. }
  2522. // 获取下一个可用模型(用于自动切换)
  2523. function getNextModel(currentModelId: string, type: string): string | null {
  2524. const models = getModelsByType(type);
  2525. const currentIndex = models.findIndex((m: any) => m.id === currentModelId);
  2526. if (currentIndex === -1 || currentIndex >= models.length - 1) {
  2527. return null; // 没有下一个模型
  2528. }
  2529. return models[currentIndex + 1].id;
  2530. }
  2531. export const config = {
  2532. port: parseInt(process.env.PORT || process.env.SERVER_PORT || '3000', 10),
  2533. // ═══════════════════════════════════════════════════════════════
  2534. // 文件: server/src/models/index.ts
  2535. // ═══════════════════════════════════════════════════════════════
  2536. import { PrismaClient } from '@prisma/client';
  2537. const prisma = new PrismaClient({
  2538. datasources: {
  2539. db: {
  2540. url: process.env.DATABASE_URL,
  2541. },
  2542. },
  2543. });
  2544. export async function connectDatabase(): Promise<void> {
  2545. try {
  2546. await prisma.$connect();
  2547. console.log(`📦 MySQL 连接成功 (${process.env.TZ || 'Asia/Shanghai'})`);
  2548. } catch (error) {
  2549. console.error('📦 MySQL 连接失败:', error);
  2550. throw error;
  2551. }
  2552. }
  2553. export { prisma };
  2554. // ═══════════════════════════════════════════════════════════════
  2555. // 文件: server/src/middleware/auth.ts
  2556. // ═══════════════════════════════════════════════════════════════
  2557. import { Context, Next } from 'koa';
  2558. import jwt from 'jsonwebtoken';
  2559. import { config } from '../config';
  2560. import { UnauthorizedError, AppError } from './errorHandler';
  2561. import { JwtPayload } from '../types';
  2562. import { safeParseInt } from '../utils/safe-parse';
  2563. export async function authMiddleware(ctx: Context, next: Next): Promise<void> {
  2564. // 开发阶段默认跳过认证(除非显式设置 AUTH_ENABLED=true)
  2565. const authEnabled = process.env.AUTH_ENABLED === 'true';
  2566. if (!authEnabled) {
  2567. ctx.state.user = {
  2568. userId: '1',
  2569. phone: 'test',
  2570. memberLevel: 99,
  2571. };
  2572. await next();
  2573. return;
  2574. }
  2575. const authHeader = ctx.get('Authorization');
  2576. if (!authHeader) {
  2577. throw new UnauthorizedError('缺少 Authorization 头');
  2578. }
  2579. const parts = authHeader.split(' ');
  2580. if (parts.length !== 2 || parts[0] !== 'Bearer') {
  2581. throw new UnauthorizedError('Authorization 格式错误');
  2582. }
  2583. const token = parts[1];
  2584. try {
  2585. const secret = config.jwt.secret;
  2586. const payload = jwt.verify(token, secret) as JwtPayload;
  2587. ctx.state.user = payload;
  2588. await next();
  2589. } catch (err: unknown) {
  2590. const error = err as Error;
  2591. if (error.name === 'TokenExpiredError') {
  2592. throw new UnauthorizedError('Token 已过期');
  2593. }
  2594. if (error.name === 'JsonWebTokenError') {
  2595. throw new UnauthorizedError('Token 无效');
  2596. }
  2597. throw new AppError('认证失败', 401, 401);
  2598. }
  2599. }
  2600. // 可选认证(允许未登录访问,自动使用测试用户)
  2601. export async function optionalAuth(ctx: Context, next: Next): Promise<void> {
  2602. const authHeader = ctx.get('Authorization');
  2603. if (authHeader) {
  2604. const parts = authHeader.split(' ');
  2605. if (parts.length === 2 && parts[0] === 'Bearer') {
  2606. try {
  2607. const payload = jwt.verify(parts[1], config.jwt.secret) as JwtPayload;
  2608. ctx.state.user = payload;
  2609. } catch {
  2610. // 忽略错误,使用测试用户
  2611. ctx.state.user = {
  2612. userId: '1',
  2613. phone: 'test',
  2614. memberLevel: 99, // 超级VIP
  2615. };
  2616. }
  2617. }
  2618. } else {
  2619. // 没有认证头,使用测试用户(超级VIP)
  2620. ctx.state.user = {
  2621. userId: '1',
  2622. phone: 'test',
  2623. memberLevel: 99, // 超级VIP
  2624. };
  2625. }
  2626. await next();
  2627. }
  2628. // ═══════════════════════════════════════════════════════════════
  2629. // 文件: server/src/middleware/security.ts
  2630. // ═══════════════════════════════════════════════════════════════
  2631. /**
  2632. * 安全中间件
  2633. * 包含:XSS 防护、SQL 注入防护、敏感数据脱敏
  2634. */
  2635. // XSS 防护
  2636. export function xssProtection() {
  2637. return async (ctx: any, next: any) => {
  2638. // 过滤请求体中的 XSS
  2639. if (ctx.request.body && typeof ctx.request.body === 'object') {
  2640. sanitizeObject(ctx.request.body);
  2641. }
  2642. // 过滤查询参数中的 XSS
  2643. if (ctx.request.query && typeof ctx.request.query === 'object') {
  2644. sanitizeObject(ctx.request.query);
  2645. }
  2646. // 设置安全响应头
  2647. ctx.set('X-XSS-Protection', '1; mode=block');
  2648. ctx.set('X-Content-Type-Options', 'nosniff');
  2649. ctx.set('X-Frame-Options', 'DENY');
  2650. ctx.set('Content-Security-Policy', "default-src 'self'");
  2651. await next();
  2652. };
  2653. }
  2654. /**
  2655. * 递归清理对象中的 XSS
  2656. */
  2657. function sanitizeObject(obj: any): void {
  2658. for (const key in obj) {
  2659. if (typeof obj[key] === 'string') {
  2660. obj[key] = sanitizeString(obj[key]);
  2661. } else if (typeof obj[key] === 'object' && obj[key] !== null) {
  2662. sanitizeObject(obj[key]);
  2663. }
  2664. }
  2665. }
  2666. /**
  2667. * 清理字符串中的 XSS 攻击代码
  2668. */
  2669. function sanitizeString(str: string): string {
  2670. return str
  2671. .replace(/</g, '&lt;')
  2672. .replace(/>/g, '&gt;')
  2673. .replace(/"/g, '&quot;')
  2674. .replace(/'/g, '&#x27;')
  2675. .replace(/\//g, '&#x2F;')
  2676. .replace(/javascript:/gi, '')
  2677. .replace(/on\w+=/gi, '')
  2678. .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
  2679. .replace(/<iframe[^>]*>[\s\S]*?<\/iframe>/gi, '')
  2680. .replace(/<object[^>]*>[\s\S]*?<\/object>/gi, '')
  2681. .replace(/<embed[^>]*>[\s\S]*?<\/embed>/gi, '');
  2682. }
  2683. // SQL 注入防护
  2684. export function sqlInjectionProtection() {
  2685. return async (ctx: any, next: any) => {
  2686. // ===== DEBUG: 记录所有请求 =====
  2687. if (ctx.path?.includes('/wechat/jsapi')) {
  2688. console.log('[SECURITY-DEBUG] JSAPI请求到达security中间件');
  2689. console.log('[SECURITY-DEBUG] method:', ctx.method);
  2690. console.log('[SECURITY-DEBUG] path:', ctx.path);
  2691. console.log('[SECURITY-DEBUG] query:', JSON.stringify(ctx.request.query));
  2692. console.log('[SECURITY-DEBUG] body:', JSON.stringify(ctx.request.body));
  2693. console.log('[SECURITY-DEBUG] headers[content-type]:', ctx.request.headers['content-type']);
  2694. }
  2695. // 检查请求参数
  2696. const params = {
  2697. ...ctx.request.query,
  2698. ...(ctx.request.body || {}),
  2699. };
  2700. for (const key in params) {
  2701. if (typeof params[key] === 'string') {
  2702. if (detectSQLInjection(params[key])) {
  2703. console.log('[SECURITY-DEBUG] ❌ JSAPI请求被SQL注入拦截, key:', key, 'value:', params[key].substring(0, 100));
  2704. ctx.status = 400;
  2705. ctx.body = {
  2706. code: 400,
  2707. message: '请求包含非法字符',
  2708. };
  2709. return;
  2710. }
  2711. // ═══════════════════════════════════════════════════════════════
  2712. // 文件: server/src/middleware/errorHandler.ts
  2713. // ═══════════════════════════════════════════════════════════════
  2714. import { Context, Next } from 'koa';
  2715. export async function errorHandler(ctx: Context, next: Next): Promise<void> {
  2716. try {
  2717. await next();
  2718. } catch (err: unknown) {
  2719. const error = err as Error & { status?: number; code?: number };
  2720. console.error('❌ Error:', error.message);
  2721. console.error('Stack:', error.stack);
  2722. ctx.status = error.status || 500;
  2723. ctx.body = {
  2724. code: error.code || 500,
  2725. message: error.message || '服务器内部错误',
  2726. data: null,
  2727. };
  2728. // 开发环境返回详细错误
  2729. if (process.env.NODE_ENV === 'development') {
  2730. (ctx.body as Record<string, unknown>).stack = error.stack;
  2731. }
  2732. }
  2733. }
  2734. // 自定义错误类
  2735. export class AppError extends Error {
  2736. public code: number;
  2737. public status: number;
  2738. constructor(message: string, code: number = 500, status: number = 500) {
  2739. super(message);
  2740. this.code = code;
  2741. this.status = status;
  2742. this.name = 'AppError';
  2743. }
  2744. }
  2745. export class UnauthorizedError extends AppError {
  2746. constructor(message: string = '未授权访问') {
  2747. super(message, 401, 401);
  2748. }
  2749. }
  2750. export class ForbiddenError extends AppError {
  2751. constructor(message: string = '禁止访问') {
  2752. super(message, 403, 403);
  2753. }
  2754. }
  2755. export class NotFoundError extends AppError {
  2756. constructor(message: string = '资源不存在') {
  2757. super(message, 404, 404);
  2758. }
  2759. }
  2760. export class BadRequestError extends AppError {
  2761. constructor(message: string = '请求参数错误') {
  2762. super(message, 400, 400);
  2763. }
  2764. }
  2765. export class QuotaExceededError extends AppError {
  2766. constructor(message: string = '使用次数已达上限') {
  2767. super(message, 429, 429);
  2768. }
  2769. }
  2770. // ═══════════════════════════════════════════════════════════════
  2771. // 文件: server/src/middleware/rate-limiter.ts
  2772. // ═══════════════════════════════════════════════════════════════
  2773. import { RateLimiterMemory, RateLimiterRedis } from 'rate-limiter-flexible';
  2774. import { redisService } from '../services/redis.service';
  2775. interface RateLimitOptions {
  2776. points: number; // 限流点数
  2777. duration: number; // 时间窗口(秒)
  2778. blockDuration?: number; // 封锁时间(秒)
  2779. keyGenerator?: (ctx: any) => string; // 自定义键生成器
  2780. }
  2781. // 内存限流器(Redis 不可用时使用)
  2782. const memoryLimiters = new Map<string, RateLimiterMemory>();
  2783. // Redis 限流器
  2784. const redisLimiters = new Map<string, RateLimiterRedis>();
  2785. /**
  2786. * 获取或创建限流器
  2787. */
  2788. function getLimiter(name: string, options: RateLimitOptions) {
  2789. if (redisService.isAvailable()) {
  2790. if (!redisLimiters.has(name)) {
  2791. const limiter = new RateLimiterRedis({
  2792. storeClient: redisService['client'],
  2793. keyPrefix: `rate_limit:${name}:`,
  2794. points: options.points,
  2795. duration: options.duration,
  2796. blockDuration: options.blockDuration || options.duration * 2,
  2797. });
  2798. redisLimiters.set(name, limiter);
  2799. }
  2800. return redisLimiters.get(name)!;
  2801. } else {
  2802. if (!memoryLimiters.has(name)) {
  2803. const limiter = new RateLimiterMemory({
  2804. points: options.points,
  2805. duration: options.duration,
  2806. });
  2807. memoryLimiters.set(name, limiter);
  2808. }
  2809. return memoryLimiters.get(name)!;
  2810. }
  2811. }
  2812. /**
  2813. * 创建限流中间件
  2814. * @param options 限流配置
  2815. */
  2816. export function createRateLimiter(options: RateLimitOptions) {
  2817. const limiter = getLimiter('default', options);
  2818. return async (ctx: any, next: any) => {
  2819. try {
  2820. const key = options.keyGenerator
  2821. ? options.keyGenerator(ctx)
  2822. : ctx.ip;
  2823. await limiter.consume(key, 1);
  2824. return await next();
  2825. } catch (rejRes: any) {
  2826. const retrySecs = Math.round(rejRes.msBeforeNext / 1000) || 1;
  2827. ctx.set('Retry-After', String(retrySecs));
  2828. ctx.status = 429;
  2829. ctx.body = {
  2830. code: 429,
  2831. message: '请求过于频繁,请稍后再试',
  2832. retryAfter: retrySecs,
  2833. };
  2834. }
  2835. };
  2836. }
  2837. /**
  2838. * API 全局限流(每分钟 100 次)
  2839. */
  2840. export const apiRateLimiter = createRateLimiter({
  2841. points: 100,
  2842. duration: 60,
  2843. keyGenerator: (ctx) => `api:${ctx.ip}`,
  2844. });
  2845. /**
  2846. * 登录接口限流(每分钟 5 次)
  2847. */
  2848. export const loginRateLimiter = createRateLimiter({
  2849. points: 5,
  2850. duration: 60,
  2851. blockDuration: 300, // 封锁 5 分钟
  2852. keyGenerator: (ctx) => `login:${ctx.ip}`,
  2853. // ═══════════════════════════════════════════════════════════════
  2854. // 文件: server/src/modules/auth/auth.controller.ts
  2855. // ═══════════════════════════════════════════════════════════════
  2856. import Router from '@koa/router';
  2857. import { Context } from 'koa';
  2858. import * as AuthService from './auth.service';
  2859. import { BadRequestError } from '../../middleware/errorHandler';
  2860. import { authMiddleware } from '../../middleware/auth';
  2861. import { prisma } from '../../models';
  2862. import { safeParseInt } from '../../utils/safe-parse';
  2863. const router = new Router();
  2864. // 发送验证码
  2865. router.post('/send-code', async (ctx: Context) => {
  2866. const { phone } = ctx.request.body as { phone: string };
  2867. if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
  2868. throw new BadRequestError('请输入正确的手机号');
  2869. }
  2870. const code = AuthService.generateSmsCode(phone);
  2871. // 开发环境直接返回验证码
  2872. ctx.body = {
  2873. code: 0,
  2874. message: '验证码发送成功',
  2875. data: {
  2876. phone,
  2877. // 总是返回验证码(方便测试)
  2878. code,
  2879. },
  2880. };
  2881. });
  2882. // 手机号登录
  2883. router.post('/login', async (ctx: Context) => {
  2884. const { phone, code, inviteCode, openid } = ctx.request.body as { phone: string; code?: string; inviteCode?: string; openid?: string };
  2885. if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
  2886. throw new BadRequestError('请输入正确的手机号');
  2887. }
  2888. // 关闭验证码验证(线上线下都关闭)
  2889. if (!code || !/^\d{4,6}$/.test(code)) {
  2890. // 跳过验证码检查,直接登录
  2891. }
  2892. const result = await AuthService.loginWithPhone(phone, code, inviteCode, openid);
  2893. ctx.body = {
  2894. code: 0,
  2895. message: '登录成功',
  2896. data: result,
  2897. };
  2898. });
  2899. // 根据 openid 自动登录(公众号场景)
  2900. router.post('/login-by-openid', async (ctx: Context) => {
  2901. const { openid } = ctx.request.body as { openid: string };
  2902. if (!openid) {
  2903. throw new BadRequestError('openid 不能为空');
  2904. }
  2905. const result = await AuthService.loginWithOpenid(openid);
  2906. if (!result) {
  2907. ctx.body = {
  2908. code: -1,
  2909. message: '该微信未绑定账号,请先登录绑定',
  2910. data: null,
  2911. };
  2912. return;
  2913. }
  2914. ctx.body = {
  2915. code: 0,
  2916. message: '自动登录成功',
  2917. data: result,
  2918. };
  2919. });
  2920. // 获取用户信息
  2921. router.get('/user-info', authMiddleware, async (ctx: Context) => {
  2922. const userId = ctx.state.user.userId;
  2923. const userInfo = await AuthService.getUserInfo(userId);
  2924. ctx.body = {
  2925. code: 0,
  2926. message: 'success',
  2927. data: userInfo,
  2928. };
  2929. });
  2930. // 更新用户信息
  2931. router.put('/user-info', authMiddleware, async (ctx: Context) => {
  2932. const userId = ctx.state.user.userId;
  2933. const uid = safeParseInt(userId);
  2934. const { nickname, avatar } = ctx.request.body as { nickname?: string; avatar?: string };
  2935. const user = await prisma.user.findUnique({ where: { id: uid } });
  2936. // ═══════════════════════════════════════════════════════════════
  2937. // 文件: server/src/modules/auth/auth.service.ts
  2938. // ═══════════════════════════════════════════════════════════════
  2939. import jwt from 'jsonwebtoken';
  2940. import { v4 as uuidv4 } from 'uuid';
  2941. import { config } from '../../config';
  2942. import { prisma } from '../../models';
  2943. import { JwtPayload } from '../../types';
  2944. import { handleInvite } from '../invite/invite.service';
  2945. import { grantSignupBonus } from '../subscription/subscription.service';
  2946. // 验证码存储(生产环境应使用 Redis)
  2947. const smsCodes = new Map<string, { code: string; expireAt: number }>();
  2948. // 生成验证码
  2949. export function generateSmsCode(phone: string): string {
  2950. const code = Math.random().toString().slice(2, 6);
  2951. smsCodes.set(phone, {
  2952. code,
  2953. expireAt: Date.now() + 5 * 60 * 1000, // 5分钟有效
  2954. });
  2955. console.log(`📱 验证码已生成: ${phone} -> ${code}`);
  2956. return code;
  2957. }
  2958. // 验证验证码
  2959. export function verifySmsCode(phone: string, code: string): boolean {
  2960. const stored = smsCodes.get(phone);
  2961. if (!stored) return false;
  2962. if (Date.now() > stored.expireAt) {
  2963. smsCodes.delete(phone);
  2964. return false;
  2965. }
  2966. if (stored.code !== code) return false;
  2967. smsCodes.delete(phone);
  2968. return true;
  2969. }
  2970. // 生成 JWT Token
  2971. export function generateToken(userId: string, phone?: string): string {
  2972. const secret = config.jwt.secret;
  2973. const payload: Omit<JwtPayload, 'iat' | 'exp'> = { userId, phone };
  2974. return jwt.sign(payload, secret, {
  2975. expiresIn: '7d',
  2976. });
  2977. }
  2978. // 手机号登录/注册
  2979. export async function loginWithPhone(phone: string, code?: string, inviteCode?: string, openid?: string): Promise<{
  2980. token: string;
  2981. user: {
  2982. id: string;
  2983. phone: string;
  2984. nickname: string;
  2985. avatar: string;
  2986. memberLevel: number;
  2987. isNewUser: boolean;
  2988. };
  2989. }> {
  2990. // 免密登录:code 为空或为"123456"时直接登录(线上线下都关闭验证)
  2991. const skipVerify = !code || code === '123456';
  2992. if (!skipVerify) {
  2993. // 验证验证码
  2994. const isValid = verifySmsCode(phone, code || '');
  2995. if (!isValid) {
  2996. throw new Error('验证码错误或已过期');
  2997. }
  2998. }
  2999. // 查找或创建用户
  3000. let user = await prisma.user.findFirst({ where: { phone } });
  3001. let isNewUser = false;
  3002. if (!user) {
  3003. user = await prisma.user.create({
  3004. data: {
  3005. phone,
  3006. openid: openid || null, // 首次注册时绑定 openid
  3007. nickname: `用户${phone.slice(-4)}`,
  3008. avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${phone}`,
  3009. memberLevel: 0,
  3010. dailyUsage: 0,
  3011. lastUsageDate: '',
  3012. },
  3013. });
  3014. isNewUser = true;
  3015. // 新用户注册赠送积分
  3016. await grantSignupBonus(user.id);
  3017. // 处理邀请关系
  3018. if (inviteCode) {
  3019. // ═══════════════════════════════════════════════════════════════
  3020. // 文件: server/src/modules/book-generator/book-generator.controller.ts
  3021. // ═══════════════════════════════════════════════════════════════
  3022. /**
  3023. * 书籍生成 - API 路由
  3024. * 包含一键完整生成 API
  3025. */
  3026. import Router from '@koa/router';
  3027. import { Context } from 'koa';
  3028. import { bookStore } from './book-generator.store';
  3029. import {
  3030. createBatchGenerationTask,
  3031. setCancellationFlag,
  3032. GenerationStep
  3033. } from './book-generator.service';
  3034. // 任务存储(用于取消操作)
  3035. const runningTasks = new Map<string, { taskId: string; bookId: string }>();
  3036. const router = new Router();
  3037. /**
  3038. * POST /api/book-generator/books/:id/batch-generate
  3039. * 一键完整生成书籍(内容、音频、合并、生成视频、合并视频)
  3040. */
  3041. router.post('/books/:id/batch-generate', async (ctx: Context) => {
  3042. try {
  3043. const bookId = ctx.params.id as string;
  3044. const body = ctx.request.body as {
  3045. steps?: GenerationStep[];
  3046. };
  3047. // 验证书籍存在
  3048. const book = await bookStore.getById(bookId);
  3049. if (!book) {
  3050. ctx.status = 404;
  3051. ctx.body = { code: 1, message: '书籍不存在' };
  3052. return;
  3053. }
  3054. // 默认执行全部步骤
  3055. const steps: GenerationStep[] = body.steps || [
  3056. 'generate_content',
  3057. 'generate_audio',
  3058. 'merge_audio',
  3059. 'generate_video',
  3060. 'merge_video'
  3061. ];
  3062. // 验证步骤
  3063. const validSteps: GenerationStep[] = [
  3064. 'generate_content',
  3065. 'generate_audio',
  3066. 'merge_audio',
  3067. 'generate_video',
  3068. 'merge_video'
  3069. ];
  3070. for (const step of steps) {
  3071. if (!validSteps.includes(step)) {
  3072. ctx.status = 400;
  3073. ctx.body = { code: 1, message: `无效的步骤: ${step}` };
  3074. return;
  3075. }
  3076. }
  3077. // 检查是否有正在运行的批量生成任务
  3078. const existingTask = runningTasks.get(bookId);
  3079. if (existingTask) {
  3080. ctx.status = 400;
  3081. ctx.body = {
  3082. code: 1,
  3083. message: '书籍已有批量生成任务在运行,请先取消后再试',
  3084. data: { taskId: existingTask.taskId }
  3085. };
  3086. return;
  3087. }
  3088. // 创建批量生成任务
  3089. const { taskId, orchestrator } = await createBatchGenerationTask(bookId, steps);
  3090. // 存储任务信息
  3091. runningTasks.set(bookId, { taskId, bookId });
  3092. // 后台执行任务
  3093. (async () => {
  3094. try {
  3095. const result = await orchestrator.execute();
  3096. console.log(`[BatchGen][${taskId}] 任务完成:`, result);
  3097. // 发送完成消息
  3098. const { pushBatchGenerationProgress } = await import('../../services/websocket.service.js');
  3099. pushBatchGenerationProgress(taskId, 'completed', 100);
  3100. } catch (error: any) {
  3101. console.error(`[BatchGen][${taskId}] 任务异常:`, error);
  3102. // ═══════════════════════════════════════════════════════════════
  3103. // 文件: server/src/modules/book-generator/book-generator.service.ts
  3104. // ═══════════════════════════════════════════════════════════════
  3105. /**
  3106. * 书籍生成编排服务
  3107. * 负责按顺序执行生成步骤并推送进度
  3108. */
  3109. import { bookStore } from './book-generator.store';
  3110. import { pushBatchGenerationProgress } from '../../services/websocket.service.js';
  3111. import { createVideoProjectFromBook, generateVideoForProject } from '../video-generator/video-generator.service';
  3112. import { mergeChapterAudios } from '../player/player.service';
  3113. import { prisma } from '../../models';
  3114. import { advanceChapter, regenerateChapter } from './stage-manager';
  3115. import { estimateBookWords, estimateAudioMinutesFromWords, atomicReserveQuota, releaseQuota, getUserMonthlyCost, AUDIO_BILLING_CONFIG } from '../subscription/subscription.service';
  3116. // 步骤类型
  3117. export type GenerationStep = 'generate_content' | 'generate_audio' | 'merge_audio' | 'generate_video' | 'merge_video';
  3118. // 取消标志
  3119. const cancellationFlags = new Map<string, boolean>();
  3120. /**
  3121. * 设置取消标志
  3122. */
  3123. export function setCancellationFlag(taskId: string): void {
  3124. cancellationFlags.set(taskId, true);
  3125. }
  3126. /**
  3127. * 清除取消标志
  3128. */
  3129. export function clearCancellationFlag(taskId: string): void {
  3130. cancellationFlags.delete(taskId);
  3131. }
  3132. /**
  3133. * 检查是否已取消
  3134. */
  3135. export function isTaskCancelled(taskId: string): boolean {
  3136. return cancellationFlags.get(taskId) === true;
  3137. }
  3138. /**
  3139. * 批量生成编排器
  3140. */
  3141. export class BatchGenerationOrchestrator {
  3142. private taskId: string;
  3143. private bookId: string;
  3144. private steps: GenerationStep[];
  3145. constructor(taskId: string, bookId: string, steps: GenerationStep[]) {
  3146. this.taskId = taskId;
  3147. this.bookId = bookId;
  3148. this.steps = steps;
  3149. }
  3150. /**
  3151. * 推送进度
  3152. */
  3153. private pushProgress(step: string, progress: number, message: string): void {
  3154. pushBatchGenerationProgress(this.taskId, step, progress);
  3155. console.log(`[BatchGen][${this.taskId}] ${step}: ${progress}% - ${message}`);
  3156. }
  3157. /**
  3158. * 检查是否已取消
  3159. */
  3160. private checkCancellation(): void {
  3161. if (isTaskCancelled(this.taskId)) {
  3162. console.log(`[BatchGen][${this.taskId}] 任务已取消`);
  3163. throw new Error('TASK_CANCELLED');
  3164. }
  3165. }
  3166. /**
  3167. * 执行所有步骤
  3168. */
  3169. async execute(): Promise<{ success: boolean; completedSteps: GenerationStep[]; failedStep?: string; error?: string }> {
  3170. const completedSteps: GenerationStep[] = [];
  3171. try {
  3172. // 获取书籍信息
  3173. const book = await bookStore.getById(this.bookId);
  3174. if (!book) {
  3175. return { success: false, completedSteps, failedStep: 'init', error: '书籍不存在' };
  3176. }
  3177. // 执行每个步骤
  3178. for (let i = 0; i < this.steps.length; i++) {
  3179. this.checkCancellation();
  3180. const step = this.steps[i];
  3181. const stepProgress = Math.round((i / this.steps.length) * 100);
  3182. this.pushProgress(step, stepProgress, '开始执行');
  3183. try {
  3184. switch (step) {
  3185. // ═══════════════════════════════════════════════════════════════
  3186. // 文件: server/src/modules/book-generator/langgraph-controller.ts
  3187. // ═══════════════════════════════════════════════════════════════
  3188. /**
  3189. * LangGraph 书籍生成 - API 路由
  3190. * 支持书籍创建时的预估显示
  3191. */
  3192. import Router from '@koa/router';
  3193. import { Context } from 'koa';
  3194. import { langGraphGenerator, getScaleConfig, resolveGenLevel, mapBookTypeToGenLevel } from './index';
  3195. import { bookStore, cancelAudioGeneration } from './book-generator.store';
  3196. import { prisma } from '../../models';
  3197. import { estimateBookWords, estimateAudioMinutesFromWords, checkBookGenerationQuota, checkAudioQuota, atomicReserveQuota, releaseQuota, AUDIO_BILLING_CONFIG } from '../subscription/subscription.service';
  3198. import { optionalAuth } from '../../middleware/auth';
  3199. import { getAllBookTypes, getDetectableTypes, getBookTypeConfig, BOOK_TYPE_CONFIG, DETECTABLE_TYPES } from './book-type-config';
  3200. import { callLLMWithMessages, callLLM, ChatMessage } from '../../services/llm';
  3201. import { cleanLlmShortText, extractJsonFromResponse } from '../../services/llm/response-cleaner';
  3202. import { runWithContext } from '../../services/llm-context';
  3203. import { createVideoProjectFromBook, generateVideoForProject } from '../video-generator/video-generator.service';
  3204. import { mergeChapterAudios } from '../player/player.service';
  3205. import { exec } from 'child_process';
  3206. import { promisify } from 'util';
  3207. import fs from 'fs/promises';
  3208. import path from 'path';
  3209. import { advanceChapter, regenerateChapter } from './stage-manager';
  3210. import { deepPlanBookNode } from './nodes/deep-plan.node';
  3211. import { richOutlineNode } from './nodes/rich-outline.node';
  3212. import { writeChaptersParallelNode } from './nodes/content.node';
  3213. import { continuityEditNode } from './nodes/continuity-edit.node';
  3214. import { GraphState } from './graph';
  3215. const execPromise = promisify(exec);
  3216. // 开发环境测试用户ID
  3217. const TEST_USER_ID = '1';
  3218. const router = new Router();
  3219. /**
  3220. * GET /api/book-generator/langgraph/estimate
  3221. * 获取书籍规模预估信息
  3222. */
  3223. router.get('/estimate', async (ctx: Context) => {
  3224. const { scale, userId, bookType } = ctx.query as { scale?: string; userId?: string; bookType?: string };
  3225. if (!scale) {
  3226. ctx.status = 400;
  3227. ctx.body = { code: 1, message: '请提供书籍规模' };
  3228. return;
  3229. }
  3230. const scaleConfig = getScaleConfig(scale);
  3231. const totalWords = scaleConfig.totalWords;
  3232. const audioMinutes = estimateAudioMinutesFromWords(totalWords);
  3233. // 章节数
  3234. const estimatedChapters = scaleConfig.isShortArticle ? 1 : scaleConfig.chapters;
  3235. // 大纲层级(优先使用书籍类型映射)
  3236. const genLevel = bookType && bookType !== 'auto'
  3237. ? mapBookTypeToGenLevel(bookType)
  3238. : resolveGenLevel(scale);
  3239. const genLevelDescriptions: Record<number, { label: string; desc: string }> = {
  3240. 1: { label: '仅章', desc: '无节和小节,适合短文、小说' },
  3241. 2: { label: '章→节', desc: '有节无小节,适合科普、商业书' },
  3242. 3: { label: '章→节→小节', desc: '完整三级结构,适合教材、技术书' },
  3243. };
  3244. const result: any = {
  3245. scale,
  3246. scaleConfig,
  3247. audioMinutes: {
  3248. min: estimateAudioMinutesFromWords(Math.round(totalWords * 0.8)),
  3249. max: estimateAudioMinutesFromWords(Math.round(totalWords * 1.2)),
  3250. avg: audioMinutes
  3251. },
  3252. estimatedChapters,
  3253. defaultGenLevel: genLevel,
  3254. genLevelInfo: genLevelDescriptions[genLevel] || { label: '章→节', desc: '' },
  3255. };
  3256. // 如果提供了 userId,同时检查用户配额
  3257. if (userId) {
  3258. const quotaCheck = await checkBookGenerationQuota(parseInt(userId), scale);
  3259. result.quotaCheck = quotaCheck;
  3260. }
  3261. ctx.body = {
  3262. code: 0,
  3263. message: 'success',
  3264. data: result
  3265. };
  3266. });
  3267. /**
  3268. // ═══════════════════════════════════════════════════════════════
  3269. // 文件: server/src/modules/book-generator/book-generator.store.ts
  3270. // ═══════════════════════════════════════════════════════════════
  3271. /**
  3272. * 书籍生成模块 - Prisma 数据库存储
  3273. */
  3274. import crypto from 'crypto';
  3275. import { prisma } from '../../models';
  3276. import { Book, BookOutline, Chapter, ChapterGenStage, BookGenStage } from './book-generator.types';
  3277. import { Prisma } from '@prisma/client';
  3278. import { generateAudio } from '../tts/tts.service';
  3279. import { callLLMWithMessages, callLLMWithTools, ChatMessage } from '../../services/llm';
  3280. import { createBookTools } from '../../services/llm/book-tools';
  3281. import { SUBSECTION_CONTENT_SYSTEM_PROMPT } from './prompts/templates';
  3282. import { countWords } from './utils';
  3283. import { cleanThinkingText } from './utils/content-cleaner';
  3284. import { advanceChapter, regenerateChapter, safeTransitionChapter } from './stage-manager';
  3285. import { mergeChapterAudios } from '../player/player.service';
  3286. import { consumeAudioMinutes, canUseTtsProvider } from '../subscription/subscription.service';
  3287. import { runWithContext } from '../../services/llm-context';
  3288. /**
  3289. * 取消书籍所有章节的音频生成(将 pending/processing 任务标记为 cancelled,回退章节阶段)
  3290. */
  3291. export async function cancelAudioGeneration(bookId: string): Promise<{ cancelledCount: number; rolledBackChapters: number[] }> {
  3292. const chapters = await prisma.bookChapter.findMany({
  3293. where: { bookId: Number(bookId) },
  3294. select: { id: true, level: true },
  3295. });
  3296. if (chapters.length === 0) {
  3297. return { cancelledCount: 0, rolledBackChapters: [] };
  3298. }
  3299. const maxLevel = Math.max(...chapters.map(c => c.level || 0));
  3300. const leafChapterIds = chapters.filter(c => c.level === maxLevel).map(c => c.id);
  3301. // 找出所有 pending/processing 状态的 TTS 任务
  3302. const activeTasks = await prisma.ttsTask.findMany({
  3303. where: {
  3304. chapterId: { in: leafChapterIds },
  3305. taskType: 'tts',
  3306. status: { in: ['pending', 'processing'] },
  3307. },
  3308. select: { id: true, chapterId: true },
  3309. });
  3310. if (activeTasks.length === 0) {
  3311. return { cancelledCount: 0, rolledBackChapters: [] };
  3312. }
  3313. const taskIds = activeTasks.map(t => t.id);
  3314. const affectedChapterIds = [...new Set(activeTasks.map(t => t.chapterId))];
  3315. // 批量标记任务为 cancelled
  3316. await prisma.ttsTask.updateMany({
  3317. where: { id: { in: taskIds } },
  3318. data: { status: 'cancelled' },
  3319. });
  3320. // 回退受影响章节的 genStage 到 content_completed
  3321. await prisma.bookChapter.updateMany({
  3322. where: { id: { in: affectedChapterIds }, genStage: 'audio_generating' },
  3323. data: { genStage: 'content_completed' },
  3324. });
  3325. return {
  3326. cancelledCount: taskIds.length,
  3327. rolledBackChapters: affectedChapterIds,
  3328. };
  3329. }
  3330. // ============ 删除书籍 ============
  3331. /**
  3332. * 根据所有章节状态计算书籍阶段
  3333. * 书籍阶段 = 所有章节中最低的阶段(最落后的章节决定了书籍的进度)
  3334. */
  3335. function computeBookGenStage(chapters: { genStage: string }[]): BookGenStage {
  3336. if (chapters.length === 0) return 'draft';
  3337. // 章节阶段顺序(索引越大越"后")
  3338. const stageOrder = ['idle', 'outline_completed', 'content_generating', 'content_completed', 'audio_generating', 'audio_completed', 'video_generating', 'video_completed', 'failed'];
  3339. // 找出最低阶段的索引
  3340. let minIdx = stageOrder.length; // 默认最大
  3341. for (const ch of chapters) {
  3342. const idx = stageOrder.indexOf(ch.genStage);
  3343. if (idx === -1) {
  3344. console.warn(`[BookStore] 未知章节阶段: chapterId=${(ch as any).id}, genStage="${ch.genStage}",跳过该章节`);
  3345. continue;
  3346. }
  3347. if (idx < minIdx) {
  3348. minIdx = idx;
  3349. }
  3350. }
  3351. // ═══════════════════════════════════════════════════════════════
  3352. // 文件: server/src/modules/book-generator/book-generator.types.ts
  3353. // ═══════════════════════════════════════════════════════════════
  3354. /**
  3355. * 书籍生成模块 - 类型定义
  3356. * 定义书籍、章节、任务的数据结构
  3357. */
  3358. // ============ 核心类型 ============
  3359. /** 书籍生成阶段 */
  3360. export type BookGenStage =
  3361. | 'draft'
  3362. | 'outlining'
  3363. | 'outline_completed'
  3364. | 'content_generating'
  3365. | 'content_completed'
  3366. | 'audio_generating'
  3367. | 'audio_completed'
  3368. | 'video_generating'
  3369. | 'video_completed'
  3370. | 'failed';
  3371. /** 章节生成阶段(唯一类型定义,stage-manager.ts 从此文件导入) */
  3372. export type ChapterGenStage =
  3373. | 'idle'
  3374. | 'outline_completed'
  3375. | 'content_generating'
  3376. | 'content_completed'
  3377. | 'audio_generating'
  3378. | 'audio_completed'
  3379. | 'video_generating'
  3380. | 'video_completed'
  3381. | 'failed';
  3382. /** 书籍基础信息 */
  3383. export interface BookBase {
  3384. id: string;
  3385. title: string; // 书名
  3386. subtitle?: string; // 副标题
  3387. description: string; // 书籍描述/用户输入
  3388. targetAudience: string; // 目标受众
  3389. style: string; // 写作风格
  3390. bookScale: string; // 书籍规模:纯数字字符串,如 1000, 2000, 130000, 340000 等
  3391. totalChapters: number; // 总章节数
  3392. estimatedWords: number; // 预估总字数
  3393. genStage?: BookGenStage; // 线性阶段状态
  3394. failedStage?: string; // 失败阶段
  3395. createdAt: Date;
  3396. updatedAt: Date;
  3397. }
  3398. /** 书籍完整信息 */
  3399. export interface Book extends BookBase {
  3400. userId?: number; // 用户ID(用于配额检查)
  3401. progress: number; // 生成进度 0-100
  3402. isPublished: boolean; // 是否已公开
  3403. chapters: Chapter[]; // 章节列表
  3404. outline?: BookOutline; // 书籍大纲
  3405. metadata?: BookMetadata; // 元数据(作者、前言、后记等)
  3406. error?: string; // 错误信息
  3407. bookAnalysis?: string; // AI 书籍规划结果(planBookNode 输出)
  3408. }
  3409. /** 书籍大纲 */
  3410. export interface BookOutline {
  3411. bookType?: 'textbook' | 'novel' | 'popular' | 'essay'; // 书籍类型(AI自主判断)
  3412. mainTheme: string; // 主题主线
  3413. structureLogic: string; // 结构逻辑
  3414. chapters: OutlineChapter[]; // 章节大纲
  3415. }
  3416. /** 章节大纲(规划阶段)*/
  3417. export interface OutlineChapter {
  3418. number: number;
  3419. title: string;
  3420. summary: string; // 章节概述
  3421. keyPoints: string[]; // 核心知识点
  3422. estimatedWords: number; // 预估字数
  3423. stories?: string[]; // 故事/案例
  3424. sections?: { // 节(章下第一级)
  3425. number: number; // 节序号
  3426. title: string;
  3427. summary?: string; // 本节概述
  3428. keyPoints?: string[]; // 本节要点
  3429. estimatedWords?: number; // 本节预估字数
  3430. subsections?: { // 小节(节下第二级)
  3431. number: number; // 小节序号
  3432. title: string;
  3433. summary?: string; // 本小节概述
  3434. // ═══════════════════════════════════════════════════════════════
  3435. // 文件: server/src/modules/book-generator/graph.ts
  3436. // ═══════════════════════════════════════════════════════════════
  3437. /**
  3438. * LangGraph 状态定义和工作流
  3439. */
  3440. import { Annotation, StateGraph, END } from '@langchain/langgraph';
  3441. // ============ 状态定义(借鉴 OpenMAIC Annotation 模式)============
  3442. /**
  3443. * 进度 reducer:只增不减,防止中间步骤回退导致进度丢失
  3444. */
  3445. const maxReducer = (prev: number, update: number) => Math.max(prev, update);
  3446. /**
  3447. * 章节完成数 reducer:累加而非覆盖
  3448. */
  3449. const appendReducer = <T>(prev: T[], update: T | T[] | undefined) => {
  3450. if (!update) return prev;
  3451. const items = Array.isArray(update) ? update : [update];
  3452. return [...prev, ...items];
  3453. };
  3454. export const GraphState = Annotation.Root({
  3455. bookId: Annotation<string>({
  3456. reducer: (_prev, update) => update ?? _prev,
  3457. default: () => '' as string,
  3458. }),
  3459. userId: Annotation<string | number>({
  3460. reducer: (_prev, update) => update ?? _prev,
  3461. default: () => '' as string,
  3462. }),
  3463. topic: Annotation<string>({
  3464. reducer: (_prev, update) => update ?? _prev,
  3465. default: () => '' as string,
  3466. }),
  3467. bookScale: Annotation<string>({
  3468. reducer: (_prev, update) => update ?? _prev,
  3469. default: () => '1000' as string,
  3470. }),
  3471. /**
  3472. * 大纲层级:1=仅章,2=章→节,3=章→节→小节。
  3473. * reducer: update ?? _prev — 仅当 update 非 null/undefined 时才覆盖旧值。
  3474. * 0/false 被视为 falsy 会保留旧值;合法的 genLevel 值是 1/2/3,不受此限制。
  3475. */
  3476. genLevel: Annotation<number>({
  3477. reducer: (_prev, update) => update ?? _prev,
  3478. default: () => 2,
  3479. }),
  3480. /** 用户明确指定的大纲层级(undefined=未指定/自动,1/2/3=用户主动选择)。
  3481. * 用于区分"用户主动选了2"和"系统默认2",防止 AI 规划时误覆盖。 */
  3482. userSpecifiedGenLevel: Annotation<number | undefined>({
  3483. reducer: (_prev, update) => update ?? _prev,
  3484. default: () => undefined as number | undefined,
  3485. }),
  3486. description: Annotation<string>({
  3487. reducer: (_prev, update) => update ?? _prev,
  3488. default: () => '' as string,
  3489. }),
  3490. /** AI 书籍规划结果(planBookNode 输出) */
  3491. bookPlan: Annotation<string | undefined>({
  3492. reducer: (_prev, update) => update ?? _prev,
  3493. default: () => undefined,
  3494. }),
  3495. /** 当前正在处理的章节号 */
  3496. currentChapter: Annotation<number>({
  3497. reducer: maxReducer,
  3498. default: () => 0,
  3499. }),
  3500. /** 已成功完成的章节数(只增不减) */
  3501. completedChapters: Annotation<number[]>({
  3502. reducer: appendReducer,
  3503. default: () => [] as number[],
  3504. }),
  3505. finished: Annotation<boolean>({
  3506. reducer: (_prev, update) => update ?? _prev,
  3507. default: () => false,
  3508. }),
  3509. error: Annotation<string | undefined>({
  3510. reducer: (_prev, update) => update ?? _prev,
  3511. default: () => undefined,
  3512. }),
  3513. /** 生成进度 0-100,只增不减 */
  3514. progress: Annotation<number>({
  3515. reducer: maxReducer,
  3516. default: () => 0,
  3517. // ═══════════════════════════════════════════════════════════════
  3518. // 文件: server/src/modules/book-generator/nodes/content.node.ts
  3519. // ═══════════════════════════════════════════════════════════════
  3520. /**
  3521. * 内容生成节点
  3522. * 为所有叶节点(没有子节点的节点)生成实际内容
  3523. */
  3524. import { GraphState } from '../graph';
  3525. import { bookStore } from '../book-generator.store';
  3526. import { prisma } from '../../../models';
  3527. import { createBookTools } from '../../../services/llm/book-tools';
  3528. import { checkQuotaForWords, markGenerationInterrupted, getGeneratedWordCount } from '../../subscription/subscription.service';
  3529. import { PROGRESS, countWords } from '../utils';
  3530. import { advanceChapter, regenerateChapter } from '../stage-manager';
  3531. import { getBookWordLimit, getWordUpperLimit } from '../book-type-config';
  3532. import { AsyncPool } from '../utils/async-pool';
  3533. import { cleanThinkingText, truncateAtBoundary } from '../utils/content-cleaner';
  3534. import { safeTransitionChapter } from '../stage-manager';
  3535. import { BookConsistencyTracker } from '../utils/content-consistency';
  3536. import { withGlobalLLMConcurrency, getBookConcurrency } from '../utils/llm-concurrency';
  3537. /** 全局术语一致性跟踪器(单例,跨整本书的章节共享) */
  3538. const globalConsistencyTracker = new BookConsistencyTracker();
  3539. /**
  3540. * 为被截断的内容补一个自然结尾句,
  3541. * 避免截断后看起来像没写完。
  3542. */
  3543. function appendNaturalClosure(content: string): string {
  3544. if (!content || content.length < 50) return content;
  3545. const trimmed = content.trimEnd();
  3546. // 已有完整结尾标点的不补
  3547. const naturalEndings = /[。!?.!?)」"”']\s*$/;
  3548. if (naturalEndings.test(trimmed)) return trimmed;
  3549. // 从内容末尾提取关键词,生成一个简短收尾句
  3550. const lastSentences = trimmed.split(/[。!?.!?]/).filter(Boolean);
  3551. const lastTopic = lastSentences.length > 0
  3552. ? lastSentences[lastSentences.length - 1].substring(0, 30).trim()
  3553. : '';
  3554. if (lastTopic.length > 3) {
  3555. return trimmed + `。以上就是关于${lastTopic}的讨论。`;
  3556. }
  3557. return trimmed + '。以上就是本章的主要内容。';
  3558. }
  3559. /**
  3560. * 安全更新父章节状态:仅在父节点下所有子节点都已完成时,才推进父节点状态。
  3561. * 替代原先的 updateMany 批量操作,避免失败子节点污染已完成父节点状态。
  3562. */
  3563. async function safelyCompleteParentChapters(bookIdNum: number): Promise<void> {
  3564. const parents = await prisma.bookChapter.findMany({
  3565. where: { bookId: bookIdNum, level: { in: [1, 2] } },
  3566. select: { id: true, genStage: true },
  3567. });
  3568. const allChildren = await prisma.bookChapter.findMany({
  3569. where: { bookId: bookIdNum, parentId: { in: parents.map(p => p.id) } },
  3570. select: { id: true, genStage: true, parentId: true },
  3571. });
  3572. // 按 parentId 分组
  3573. const childrenByParent = new Map<number, typeof allChildren>();
  3574. for (const child of allChildren) {
  3575. if (!child.parentId) continue;
  3576. if (!childrenByParent.has(child.parentId)) childrenByParent.set(child.parentId, []);
  3577. childrenByParent.get(child.parentId)!.push(child);
  3578. }
  3579. for (const parent of parents) {
  3580. const children = childrenByParent.get(parent.id) || [];
  3581. if (children.length === 0) continue; // 无子节点(单层大纲),跳过
  3582. const allChildrenDone = children.every(
  3583. c => c.genStage === 'content_completed' || c.genStage === 'audio_generating'
  3584. || c.genStage === 'audio_completed' || c.genStage === 'video_generating'
  3585. || c.genStage === 'video_completed'
  3586. );
  3587. if (allChildrenDone && parent.genStage !== 'content_completed') {
  3588. try {
  3589. await safeTransitionChapter(parent.id, parent.genStage as any, 'content_completed');
  3590. } catch (err: any) {
  3591. console.warn(`[Content] 父节点 #${parent.id} 状态转移失败:`, err.message);
  3592. }
  3593. }
  3594. }
  3595. }
  3596. /**
  3597. * 内容生成节点
  3598. * 为所有叶节点(没有子节点的节点)生成实际内容
  3599. */
  3600. // ═══════════════════════════════════════════════════════════════
  3601. // 文件: server/src/modules/book-generator/nodes/plan.node.ts
  3602. // ═══════════════════════════════════════════════════════════════
  3603. /**
  3604. * 书籍规划节点(planBookNode)
  3605. * 在生成任何内容之前,由 AI 像职业作家一样对整本书做详细规划:
  3606. * - 分析书籍类型、目标读者、内容深度
  3607. * - 决定大纲层级(1/2/3层)
  3608. * - 规划写作风格和结构逻辑
  3609. * - 输出完整的写作方案
  3610. *
  3611. * 该节点的输出(planResult)会被存储到 GraphState,供后续节点参考。
  3612. */
  3613. import { GraphState } from '../graph';
  3614. import { bookStore } from '../book-generator.store';
  3615. import { callLLMWithMessages, ChatMessage } from '../../../services/llm';
  3616. import { PROGRESS } from '../utils';
  3617. import { getScaleConfig } from '../book-type-config';
  3618. /**
  3619. * 书籍规划输出接口
  3620. */
  3621. export interface BookPlan {
  3622. /** 最终确定的大纲层级 */
  3623. genLevel: number;
  3624. /** 书籍类型分析 */
  3625. bookTypeAnalysis: string;
  3626. /** 推荐的写作风格 */
  3627. writingStyle: string;
  3628. /** 结构逻辑说明 */
  3629. structureLogic: string;
  3630. /** 内容深度评估 */
  3631. contentDepth: string;
  3632. /** 目标读者分析 */
  3633. targetAudienceAnalysis: string;
  3634. /** 规划理由 */
  3635. reasoning: string;
  3636. }
  3637. /**
  3638. * 从描述中提取明确的字数要求
  3639. * 如 "生成200字文本" → 200
  3640. */
  3641. function extractWordCountFromDescription(description: string): number | null {
  3642. const text = description.toLowerCase();
  3643. // 匹配 "200字"、"500字" 等
  3644. const match = text.match(/(\d+)\s*字/i);
  3645. if (match) {
  3646. const n = parseInt(match[1]);
  3647. return isNaN(n) ? null : n;
  3648. }
  3649. return null;
  3650. }
  3651. /**
  3652. * 构建规划提示词
  3653. */
  3654. function buildPlanPrompt(
  3655. title: string,
  3656. description: string,
  3657. bookScale: string
  3658. ): string {
  3659. const config = getScaleConfig(bookScale);
  3660. const chapters = config?.chapters || 17;
  3661. // 如果 description 里有明确字数要求,优先用它
  3662. const descWordCount = extractWordCountFromDescription(description);
  3663. const totalWords = descWordCount !== null
  3664. ? descWordCount
  3665. : (config?.totalWords || 130000);
  3666. return `你是一位经验丰富的图书策划编辑。请对以下书籍做全面规划。
  3667. ## 书籍信息
  3668. - 书名:${title}
  3669. - 字数规模:约${totalWords}字,约${chapters}章
  3670. - 描述:${description}
  3671. ## 你的职责
  3672. 像职业作家出书前一样,先对整本书做完整的规划分析。
  3673. ## 分析维度
  3674. ### 1. 内容类型分析
  3675. 判断这本书属于以下哪种类型:
  3676. - 教材/学术类:系统教学、理论知识体系完整
  3677. - 技术教程类:有操作步骤、代码、实践指南
  3678. - 小说/文学类:叙事性、故事性内容
  3679. - 商业/经管类:管理、营销、投资、创业
  3680. - 科普/大众类:普及科学知识,通俗易懂
  3681. - 其他类型
  3682. ### 2. 大纲层级决策
  3683. // ═══════════════════════════════════════════════════════════════
  3684. // 文件: server/src/modules/book-generator/nodes/outline.node.ts
  3685. // ═══════════════════════════════════════════════════════════════
  3686. /**
  3687. * 大纲生成节点(集成容错机制)
  3688. */
  3689. import { GraphState } from '../graph';
  3690. import { bookStore } from '../book-generator.store';
  3691. import { callLLMWithMessages } from '../../../services/llm';
  3692. import { parseOutline } from '../parsers/outline.parser';
  3693. import { buildOutlineMessages, SCALE_CHAPTER_RANGE } from '../prompts/builder';
  3694. import { PROGRESS } from '../utils';
  3695. import { callLLMWithRetry, executeNodeWithTimeout, FAULT_TOLERANCE_CONFIG } from '../fault-tolerance';
  3696. import { getChapterRange, calcChapters } from '../book-type-config';
  3697. export async function generateOutlineNode(state: typeof GraphState.State): Promise<Partial<typeof GraphState.State>> {
  3698. console.log('[LangGraph] 生成大纲, bookId:', state.bookId, 'scale:', state.bookScale);
  3699. // 读取前序规划节点的分析结果,用于指导大纲生成
  3700. let bookPlan: any = null;
  3701. if (state.bookPlan) {
  3702. try {
  3703. bookPlan = JSON.parse(state.bookPlan);
  3704. console.log('[LangGraph] 使用规划结果指导大纲:', bookPlan.structureLogic, bookPlan.writingStyle);
  3705. } catch {
  3706. console.warn('[LangGraph] bookPlan 解析失败,忽略');
  3707. }
  3708. }
  3709. try {
  3710. // 使用容错包装器执行AI调用
  3711. const response = await executeNodeWithTimeout(
  3712. state.bookId,
  3713. 'generate_outline',
  3714. async () => {
  3715. return callLLMWithRetry(
  3716. buildOutlineMessages(state.topic, state.bookScale, state.description, bookPlan),
  3717. undefined,
  3718. {
  3719. bookId: state.bookId,
  3720. nodeId: 'generate_outline',
  3721. attempt: 0,
  3722. maxAttempts: FAULT_TOLERANCE_CONFIG.aiRetry.maxRetries,
  3723. }
  3724. );
  3725. },
  3726. FAULT_TOLERANCE_CONFIG.nodeTimeout.generate_outline
  3727. );
  3728. const outline = parseOutline(response);
  3729. if (!outline) throw new Error('大纲解析失败');
  3730. // 校验章节数:允许 ±20% 浮动,超出才截断/补充
  3731. const targetChapters = SCALE_CHAPTER_RANGE[state.bookScale as keyof typeof SCALE_CHAPTER_RANGE];
  3732. if (targetChapters) {
  3733. const { min: minAllowed, max: maxAllowed } = getChapterRange(targetChapters);
  3734. const actual = outline.chapters.length;
  3735. if (actual > maxAllowed) {
  3736. console.warn(`[LangGraph] AI 返回章节数 ${actual} 超出上限 ${maxAllowed},截断`);
  3737. outline.chapters = outline.chapters.slice(0, maxAllowed);
  3738. } else if (actual < minAllowed) {
  3739. console.warn(`[LangGraph] AI 返回章节数 ${actual} 少于下限 ${minAllowed},使用话题相关的默认章节补充`);
  3740. const topicHint = state.topic?.substring(0, 30) || '';
  3741. const perChapterWords = Math.round(parseInt(state.bookScale) / minAllowed);
  3742. const gapTopics = [
  3743. `概述与背景`,
  3744. `核心概念`,
  3745. `深入探究`,
  3746. `应用与实践`,
  3747. `总结与展望`,
  3748. ];
  3749. while (outline.chapters.length < minAllowed) {
  3750. const idx = outline.chapters.length;
  3751. const gapTitle = gapTopics[idx % gapTopics.length];
  3752. outline.chapters.push({
  3753. number: idx + 1,
  3754. title: topicHint ? `第${idx + 1}章 ${topicHint} - ${gapTitle}` : `第${idx + 1}章 ${gapTitle}`,
  3755. summary: `本章围绕"${topicHint || gapTitle}"展开,介绍${gapTitle}相关内容。`,
  3756. keyPoints: [gapTitle, '关键知识点', '实践要点'],
  3757. estimatedWords: perChapterWords,
  3758. });
  3759. }
  3760. }
  3761. // 在范围内(±20%)不做任何处理,让 AI 自己决定
  3762. }
  3763. // 短文类至少1个章节
  3764. if (outline.chapters.length === 0) {
  3765. console.warn('[LangGraph] AI 返回章节数为0,使用话题创建默认章节');
  3766. // ═══════════════════════════════════════════════════════════════
  3767. // 文件: server/src/modules/book-generator/nodes/quality-check.node.ts
  3768. // ═══════════════════════════════════════════════════════════════
  3769. /**
  3770. * 质量校验节点(qualityCheckNode)
  3771. *
  3772. * 在正文内容并行生成完成后,对所有章节做四维质量评估:
  3773. * - 通顺性(fluency)
  3774. * - 逻辑性(logic)
  3775. * - 是否跑题(relevance)
  3776. * - 是否达标(completeness)
  3777. *
  3778. * 输出每个章节的评分和问题列表,决定是否进入重写环节。
  3779. * 与 rewrite.node.ts 组成质量闭环:校验→不通过→重写→再校验
  3780. */
  3781. import { GraphState } from '../graph';
  3782. import { bookStore } from '../book-generator.store';
  3783. import { callLLMWithMessages, ChatMessage } from '../../../services/llm';
  3784. import { callLLMWithRetry, executeNodeWithTimeout, FAULT_TOLERANCE_CONFIG } from '../fault-tolerance';
  3785. import { QUALITY_CHECK_SYSTEM_PROMPT } from '../prompts/templates';
  3786. import { PROGRESS, countWords } from '../utils';
  3787. import { prisma } from '../../../models';
  3788. // ============ 类型定义 ============
  3789. export interface QualityScore {
  3790. fluency: number; // 通顺 0-25
  3791. logic: number; // 逻辑 0-25
  3792. relevance: number; // 跑题 0-25
  3793. completeness: number; // 达标 0-25
  3794. }
  3795. export interface QualityIssue {
  3796. dimension: 'fluency' | 'logic' | 'relevance' | 'completeness';
  3797. severity: 'high' | 'medium' | 'low';
  3798. description: string;
  3799. location: string;
  3800. suggestion: string;
  3801. }
  3802. export interface FailedChapter {
  3803. chapterNumber: number;
  3804. chapterTitle: string;
  3805. scores: QualityScore;
  3806. totalScore: number;
  3807. issues: QualityIssue[];
  3808. rewriteInstructions: string;
  3809. }
  3810. export interface QualityCheckResult {
  3811. overallScore: number;
  3812. overallAssessment: string;
  3813. passedChapters: number[];
  3814. failedChapters: FailedChapter[];
  3815. }
  3816. // ============ 常量 ============
  3817. /** 合格分数线 */
  3818. const PASS_THRESHOLD = 80;
  3819. /** 每批最大发送字符数(避免 token 超限)*/
  3820. const MAX_BATCH_CHARS = 60000;
  3821. // ============ 核心逻辑 ============
  3822. /**
  3823. * 构建单批质量校验消息
  3824. */
  3825. function buildBatchQualityCheckMessages(
  3826. bookTitle: string,
  3827. bookTopic: string,
  3828. batchChapters: Array<{ number: number; title: string; content: string; summary?: string; estimatedWords?: number }>
  3829. ): ChatMessage[] {
  3830. let chaptersText = '';
  3831. for (const ch of batchChapters) {
  3832. const header = `\n## 第${ch.number}章:${ch.title}\n> 概述:${ch.summary || '无'}\n> 预估字数:${ch.estimatedWords || 0} | 实际字数:${countWords(ch.content)}\n\n`;
  3833. const content = ch.content || '(空)';
  3834. chaptersText += header + content;
  3835. }
  3836. return [
  3837. { role: 'system', content: QUALITY_CHECK_SYSTEM_PROMPT },
  3838. {
  3839. role: 'user',
  3840. content: `书名:《${bookTitle}》
  3841. 主题:${bookTopic}
  3842. 以下是 ${batchChapters.length} 个章节的完整内容,请逐一评估质量:
  3843. ${chaptersText}
  3844. 请输出 JSON 格式的质量评估报告。`,
  3845. },
  3846. ];
  3847. }
  3848. /**
  3849. // ═══════════════════════════════════════════════════════════════
  3850. // 文件: server/src/modules/tts/tts.service.ts
  3851. // ═══════════════════════════════════════════════════════════════
  3852. import path from 'path';
  3853. import fs from 'fs';
  3854. import { v4 as uuidv4 } from 'uuid';
  3855. import { config } from '../../config';
  3856. import { prisma } from '../../models';
  3857. import { VoiceParams, Voice } from '../../types';
  3858. import { AudioMerger } from './audio-merger';
  3859. import { aiSummaryService } from './ai-summary.service';
  3860. import { storageService } from '../../services/storage.service';
  3861. import { getTtsRegistry, getAvailableTtsProvider, startTtsHealthCheck } from './provider.registry';
  3862. import { ITtsProvider } from './provider.interface';
  3863. import { CircuitBreakerOpenError } from '../../common/circuit-breaker';
  3864. import { ProviderNode } from '../../common/provider-registry';
  3865. import { ttsLogger } from './tts-logger';
  3866. import axios from 'axios';
  3867. // ============ 统一音色定义(10个固定音色,前端使用)============
  3868. // 前端使用统一 ID,后端根据 Provider 类型映射到真实音色
  3869. const UNIFIED_VOICES: Voice[] = [
  3870. { id: 'voice_01', name: '温柔女声', gender: 'female', description: '柔和温暖,适合情感故事' },
  3871. { id: 'voice_02', name: '磁性男声', gender: 'male', description: '低沉有力,适合悬疑推理' },
  3872. { id: 'voice_03', name: '活泼女声', gender: 'female', description: '清新明亮,适合儿童故事' },
  3873. { id: 'voice_04', name: '知性女声', gender: 'female', description: '知性稳重,适合科普知识' },
  3874. { id: 'voice_05', name: '阳光男声', gender: 'male', description: '阳光活力,适合校园青春' },
  3875. { id: 'voice_06', name: '沧桑男声', gender: 'male', description: '成熟沧桑,适合历史军事' },
  3876. { id: 'voice_07', name: '甜美女声', gender: 'female', description: '甜美可爱,适合爱情都市' },
  3877. { id: 'voice_08', name: '清朗男声', gender: 'male', description: '清朗干练,适合职场商战' },
  3878. { id: 'voice_09', name: '亲切女声', gender: 'female', description: '亲切自然,适合日常叙事' },
  3879. { id: 'voice_10', name: '稚嫩童声', gender: 'female', description: '稚嫩天真,适合童话寓言' },
  3880. ];
  3881. // 统一音色 → 阿里云 真实音色映射
  3882. const ALIYUN_VOICE_MAP: Record<string, string> = {
  3883. voice_01: 'longanyang', voice_02: 'longsanshu_v3', voice_03: 'longhuhu_v3',
  3884. voice_04: 'longyue_v3', voice_05: 'longyichen_v3', voice_06: 'longlaobo_v3',
  3885. voice_07: 'longmiao_v3', voice_08: 'longshuo_v3', voice_09: 'longwan_v3',
  3886. voice_10: 'longhuhu_v3',
  3887. };
  3888. // 统一音色 → Edge-TTS 真实音色映射
  3889. const EDGE_VOICE_MAP: Record<string, string> = {
  3890. voice_01: 'zh-CN-XiaoxiaoNeural', // 温柔女声 → 晓晓
  3891. voice_02: 'zh-CN-YunxiNeural', // 磁性男声 → 云希
  3892. voice_03: 'zh-CN-XiaoyiNeural', // 活泼女声 → 晓依
  3893. voice_04: 'zh-CN-YunyangNeural', // 知性女声 → 云扬(新闻风格)
  3894. voice_05: 'zh-CN-YunjianNeural', // 阳光男声 → 云健
  3895. voice_06: 'zh-CN-YunyangNeural', // 沧桑男声 → 云扬
  3896. voice_07: 'zh-CN-XiaoxiaoNeural', // 甜美女声 → 晓晓
  3897. voice_08: 'zh-CN-YunxiNeural', // 清朗男声 → 云希
  3898. voice_09: 'zh-CN-XiaoyiNeural', // 亲切女声 → 晓依
  3899. voice_10: 'zh-CN-XiaoshuangNeural', // 稚嫩童声 → 晓双(童声)
  3900. };
  3901. function mapToProviderVoice(unifiedVoiceId: string, providerVendor: string): string {
  3902. // Edge-TTS: 使用微软免费音色
  3903. if (providerVendor === 'edge') {
  3904. const mapped = EDGE_VOICE_MAP[unifiedVoiceId];
  3905. if (mapped) return mapped;
  3906. console.warn(`⚠️ [VoiceMap] Edge 未识别的音色ID: "${unifiedVoiceId}",降级使用默认音色`);
  3907. return 'zh-CN-XiaoxiaoNeural';
  3908. }
  3909. // 默认: 阿里云百炼 CosyVoice
  3910. const mapped = ALIYUN_VOICE_MAP[unifiedVoiceId];
  3911. if (mapped) return mapped;
  3912. // 不在映射表中(如遗留的 'cherry' 等旧 MiniMax 音色)→ 用 CosyVoice 默认音色
  3913. console.warn(`⚠️ [VoiceMap] 未识别的音色ID: "${unifiedVoiceId}",降级使用默认音色 longyingling_v3`);
  3914. return 'longyingling_v3';
  3915. }
  3916. // ============ Aliyun Instruct 情感/场景控制 ============
  3917. // 后期优化功能,暂不启用。启用时改为 true
  3918. const INSTRUCT_ENABLED = false;
  3919. interface EmotionScene {
  3920. emotion: string; // neutral | fearful | angry | sad | surprised | happy | disgusted
  3921. scene: string; // 闲聊互动 | 新闻播报 | 广告促销 | 比赛解说 | 一些儿童内容解说 | 语音导航 | 脱口秀表演
  3922. }
  3923. // 情感关键词库
  3924. const EMOTION_KEYWORDS: { emotion: string; keywords: string[] }[] = [
  3925. { emotion: 'fearful', keywords: ['恐怖', '可怕', '惊悚', '恐惧', '阴森', '黑暗', '鬼', '死亡', '谋杀', '悬疑', '危险', '深渊', '噩梦'] },
  3926. { emotion: 'sad', keywords: ['悲伤', '难过', '哭泣', '眼泪', '心痛', '遗憾', '孤独', '寂寞', '失落', '离别', '思念', '哀伤', '去世', '失去'] },
  3927. { emotion: 'angry', keywords: ['愤怒', '生气', '怒火', '仇恨', '战斗', '厮杀', '复仇', '战争', '侵略', '暴怒'] },
  3928. { emotion: 'happy', keywords: ['快乐', '开心', '幸福', '欢笑', '庆祝', '美好', '甜蜜', '温暖', '阳光', '喜悦', '高兴', '浪漫', '恋爱', '美好'] },
  3929. { emotion: 'surprised', keywords: ['惊奇', '惊喜', '意外', '奇迹', '神奇', '魔法', '童话', '幻想', '奇妙'] },
  3930. { emotion: 'disgusted', keywords: ['恶心', '厌恶', '肮脏', '丑陋', '卑鄙'] },
  3931. ];
  3932. // ═══════════════════════════════════════════════════════════════
  3933. // 文件: server/src/modules/player/player.controller.ts
  3934. // ═══════════════════════════════════════════════════════════════
  3935. import Router from '@koa/router';
  3936. import { Context } from 'koa';
  3937. import * as PlayerService from './player.service';
  3938. import { BadRequestError } from '../../middleware/errorHandler';
  3939. import { optionalAuth } from '../../middleware/auth';
  3940. import { prisma } from '../../models';
  3941. // 测试用户ID(开发环境使用)
  3942. const TEST_USER_ID = '1';
  3943. const router = new Router();
  3944. // 获取播放进度列表
  3945. router.get('/progress', optionalAuth, async (ctx: Context) => {
  3946. // 开发环境使用测试用户ID
  3947. const userId = ctx.state.user?.userId || TEST_USER_ID;
  3948. const { audioId } = ctx.query as { audioId?: string };
  3949. const records = await PlayerService.getPlayProgress(
  3950. userId,
  3951. audioId ? parseInt(audioId) : undefined
  3952. );
  3953. ctx.body = {
  3954. code: 0,
  3955. message: 'success',
  3956. data: records,
  3957. };
  3958. });
  3959. // 保存播放进度
  3960. router.post('/progress', optionalAuth, async (ctx: Context) => {
  3961. // 开发环境使用测试用户ID
  3962. const userId = ctx.state.user?.userId || TEST_USER_ID;
  3963. const body = ctx.request.body as {
  3964. audioId: number;
  3965. progress: number;
  3966. duration: number;
  3967. };
  3968. const { audioId, progress, duration } = body;
  3969. if (!audioId || typeof progress !== 'number' || typeof duration !== 'number') {
  3970. throw new BadRequestError('参数错误');
  3971. }
  3972. const record = await PlayerService.savePlayProgress(userId, audioId, progress, duration);
  3973. ctx.body = {
  3974. code: 0,
  3975. message: 'success',
  3976. data: record,
  3977. };
  3978. });
  3979. // 更新播放进度 (在 DELETE 路由之前定义)
  3980. router.put('/progress/:audioId', optionalAuth, async (ctx: Context) => {
  3981. // 开发环境使用测试用户ID
  3982. const userId = ctx.state.user?.userId || TEST_USER_ID;
  3983. const audioId = parseInt(ctx.params.audioId as string);
  3984. const body = ctx.request.body as { progress: number; duration?: number };
  3985. const { progress, duration } = body;
  3986. if (typeof progress !== 'number') {
  3987. throw new BadRequestError('进度参数错误');
  3988. }
  3989. const record = await PlayerService.updatePlayProgress(userId, audioId, progress, duration);
  3990. ctx.body = {
  3991. code: 0,
  3992. message: 'success',
  3993. data: record,
  3994. };
  3995. });
  3996. // 删除播放记录
  3997. router.delete('/progress/:audioId', optionalAuth, async (ctx: Context) => {
  3998. // 开发环境使用测试用户ID
  3999. const userId = ctx.state.user?.userId || TEST_USER_ID;
  4000. const audioId = parseInt(ctx.params.audioId as string);
  4001. await PlayerService.deletePlayRecord(userId, audioId);
  4002. ctx.body = {
  4003. code: 0,
  4004. message: '删除成功',
  4005. };
  4006. });
  4007. // 批量删除播放记录
  4008. router.delete('/progress/batch', optionalAuth, async (ctx: Context) => {
  4009. const userId = ctx.state.user?.userId || TEST_USER_ID;
  4010. const { audioIds } = ctx.request.body as { audioIds: number[] };
  4011. if (!audioIds || !Array.isArray(audioIds)) {
  4012. throw new BadRequestError('参数错误');
  4013. }
  4014. for (const audioId of audioIds) {
  4015. // ═══════════════════════════════════════════════════════════════
  4016. // 文件: server/src/modules/member/member.controller.ts
  4017. // ═══════════════════════════════════════════════════════════════
  4018. import Router from '@koa/router';
  4019. import { Context } from 'koa';
  4020. import * as MemberService from './member.service';
  4021. import { BadRequestError, NotFoundError } from '../../middleware/errorHandler';
  4022. import { authMiddleware } from '../../middleware/auth';
  4023. const router = new Router();
  4024. // 获取会员权益信息
  4025. router.get('/benefits', async (ctx: Context) => {
  4026. const benefits = MemberService.getMemberBenefits();
  4027. ctx.body = {
  4028. code: 0,
  4029. message: 'success',
  4030. data: benefits,
  4031. };
  4032. });
  4033. // 获取用户会员信息(兼容前端 /api/member/info)
  4034. router.get('/info', authMiddleware, async (ctx: Context) => {
  4035. const userId = ctx.state.user.userId;
  4036. const status = await MemberService.getMemberStatus(userId);
  4037. ctx.body = {
  4038. code: 0,
  4039. message: 'success',
  4040. data: status,
  4041. };
  4042. });
  4043. // 获取用户会员状态
  4044. router.get('/status', authMiddleware, async (ctx: Context) => {
  4045. const userId = ctx.state.user.userId;
  4046. const status = await MemberService.getMemberStatus(userId);
  4047. ctx.body = {
  4048. code: 0,
  4049. message: 'success',
  4050. data: status,
  4051. };
  4052. });
  4053. // 创建订单
  4054. router.post('/order', authMiddleware, async (ctx: Context) => {
  4055. const userId = ctx.state.user.userId;
  4056. const { productType } = ctx.request.body as { productType: 'monthly' | 'yearly' };
  4057. if (!['monthly', 'yearly'].includes(productType)) {
  4058. throw new BadRequestError('无效的产品类型');
  4059. }
  4060. const result = await MemberService.createOrder(userId, productType);
  4061. ctx.body = {
  4062. code: 0,
  4063. message: '订单创建成功',
  4064. data: result,
  4065. };
  4066. });
  4067. // 模拟支付(仅开发环境)
  4068. router.post('/pay/mock', authMiddleware, async (ctx: Context) => {
  4069. if (process.env.NODE_ENV === 'production') {
  4070. throw new BadRequestError('生产环境不可用');
  4071. }
  4072. const userId = ctx.state.user.userId;
  4073. const { orderNo } = ctx.request.body as { orderNo: string };
  4074. if (!orderNo) {
  4075. throw new BadRequestError('订单号不能为空');
  4076. }
  4077. const result = await MemberService.mockPaymentSuccess(orderNo, userId);
  4078. ctx.body = {
  4079. code: 0,
  4080. message: '支付成功',
  4081. data: result,
  4082. };
  4083. });
  4084. // 获取订单列表
  4085. router.get('/orders', authMiddleware, async (ctx: Context) => {
  4086. const userId = ctx.state.user.userId;
  4087. const { page = 1, pageSize = 10 } = ctx.query as { page?: string; pageSize?: string };
  4088. const result = await MemberService.getOrders(
  4089. userId,
  4090. Number(page) || 1,
  4091. Number(pageSize) || 10
  4092. );
  4093. ctx.body = {
  4094. code: 0,
  4095. message: 'success',
  4096. data: result,
  4097. };
  4098. // ═══════════════════════════════════════════════════════════════
  4099. // 文件: server/src/modules/subscription/subscription.controller.ts
  4100. // ═══════════════════════════════════════════════════════════════
  4101. import Router from '@koa/router';
  4102. import { Context } from 'koa';
  4103. import * as SubscriptionService from './subscription.service';
  4104. import { BadRequestError } from '../../middleware/errorHandler';
  4105. import { authMiddleware, optionalAuth } from '../../middleware/auth';
  4106. import { prisma } from '../../models';
  4107. const router = new Router();
  4108. // 获取所有套餐列表
  4109. router.get('/plans', async (ctx: Context) => {
  4110. const plans = await SubscriptionService.getPlans();
  4111. ctx.body = {
  4112. code: 0,
  4113. message: 'success',
  4114. data: { plans }
  4115. };
  4116. });
  4117. // 获取单个套餐详情
  4118. router.get('/plans/:id', async (ctx: Context) => {
  4119. const planId = parseInt(ctx.params.id);
  4120. const plan = await SubscriptionService.getPlanById(planId);
  4121. ctx.body = {
  4122. code: 0,
  4123. message: 'success',
  4124. data: { plan }
  4125. };
  4126. });
  4127. // 获取用户订阅信息
  4128. router.get('/subscription', authMiddleware, async (ctx: Context) => {
  4129. const userId = parseInt(ctx.state.user.userId);
  4130. const subscription = await SubscriptionService.getUserSubscription(userId);
  4131. ctx.body = {
  4132. code: 0,
  4133. message: 'success',
  4134. data: { subscription }
  4135. };
  4136. });
  4137. // 获取用户Token余额
  4138. router.get('/balance', authMiddleware, async (ctx: Context) => {
  4139. const userId = parseInt(ctx.state.user.userId);
  4140. const balance = await SubscriptionService.getUserTokenBalance(userId);
  4141. ctx.body = {
  4142. code: 0,
  4143. message: 'success',
  4144. data: balance
  4145. };
  4146. });
  4147. // 获取Token使用记录
  4148. router.get('/usage', authMiddleware, async (ctx: Context) => {
  4149. const userId = parseInt(ctx.state.user.userId);
  4150. const { page = '1', pageSize = '20' } = ctx.query as { page?: string; pageSize?: string };
  4151. const result = await SubscriptionService.getTokenUsageList(
  4152. userId,
  4153. Number(page) || 1,
  4154. Number(pageSize) || 20
  4155. );
  4156. ctx.body = {
  4157. code: 0,
  4158. message: 'success',
  4159. data: result
  4160. };
  4161. });
  4162. // 获取用户配额(兼容旧接口)
  4163. router.get('/quota', authMiddleware, async (ctx: Context) => {
  4164. const userId = parseInt(ctx.state.user.userId);
  4165. const quota = await SubscriptionService.getUserQuota(userId);
  4166. ctx.body = {
  4167. code: 0,
  4168. message: 'success',
  4169. data: quota
  4170. };
  4171. });
  4172. // 检查配额
  4173. router.post('/check-quota', authMiddleware, async (ctx: Context) => {
  4174. const userId = parseInt(ctx.state.user.userId);
  4175. const { tokens } = ctx.request.body as { tokens: number };
  4176. if (!tokens || tokens <= 0) {
  4177. throw new BadRequestError('请提供正确的Token数量');
  4178. }
  4179. const result = await SubscriptionService.checkQuota(userId, tokens);
  4180. ctx.body = {
  4181. // ═══════════════════════════════════════════════════════════════
  4182. // 文件: server/src/modules/payment/payment.controller.ts
  4183. // ═══════════════════════════════════════════════════════════════
  4184. import Router from '@koa/router';
  4185. import { Context } from 'koa';
  4186. import * as PaymentService from './payment.service';
  4187. import { BadRequestError } from '../../middleware/errorHandler';
  4188. import { authMiddleware } from '../../middleware/auth';
  4189. import { prisma } from '../../models';
  4190. import { safeParseInt } from '../../utils/safe-parse';
  4191. const router = new Router();
  4192. // 创建支付订单
  4193. router.post('/create', authMiddleware, async (ctx: Context) => {
  4194. const userId = safeParseInt(ctx.state.user.userId);
  4195. const { planId, paymentMethod, period = 'monthly', returnUrl } = ctx.request.body as {
  4196. planId: number;
  4197. paymentMethod: 'alipay' | 'wechat' | 'mock';
  4198. period?: 'monthly' | 'yearly';
  4199. returnUrl?: string;
  4200. };
  4201. if (!planId) {
  4202. throw new BadRequestError('请选择套餐');
  4203. }
  4204. if (!['alipay', 'wechat', 'mock'].includes(paymentMethod)) {
  4205. throw new BadRequestError('请选择支付方式');
  4206. }
  4207. const result = await PaymentService.createPaymentOrder(userId, planId, paymentMethod, period, returnUrl);
  4208. ctx.body = {
  4209. code: 0,
  4210. message: '订单创建成功',
  4211. data: result
  4212. };
  4213. });
  4214. // 创建 Token 包支付订单
  4215. router.post('/token-packs/create', authMiddleware, async (ctx: Context) => {
  4216. const userId = safeParseInt(ctx.state.user.userId);
  4217. const { quantity, paymentMethod, returnUrl } = ctx.request.body as {
  4218. quantity: number;
  4219. paymentMethod: 'alipay' | 'wechat' | 'mock';
  4220. returnUrl?: string;
  4221. };
  4222. if (!quantity || quantity < 1) {
  4223. throw new BadRequestError('购买数量至少为1');
  4224. }
  4225. if (!['alipay', 'wechat', 'mock'].includes(paymentMethod)) {
  4226. throw new BadRequestError('请选择支付方式');
  4227. }
  4228. const result = await PaymentService.createTokenPackOrder(userId, quantity, paymentMethod, returnUrl);
  4229. ctx.body = {
  4230. code: 0,
  4231. message: '订单创建成功',
  4232. data: result
  4233. };
  4234. });
  4235. // 模拟支付(仅开发环境)
  4236. router.post('/mock', authMiddleware, async (ctx: Context) => {
  4237. if (process.env.NODE_ENV === 'production') {
  4238. throw new BadRequestError('生产环境不可用');
  4239. }
  4240. const userId = safeParseInt(ctx.state.user.userId);
  4241. const { orderNo } = ctx.request.body as { orderNo: string };
  4242. if (!orderNo) {
  4243. throw new BadRequestError('订单号不能为空');
  4244. }
  4245. const result = await PaymentService.mockPaymentSuccess(orderNo, userId);
  4246. ctx.body = {
  4247. code: 0,
  4248. message: result.message,
  4249. data: result
  4250. };
  4251. });
  4252. // 支付宝异步通知回调
  4253. router.post('/alipay/notify', async (ctx: Context) => {
  4254. const params = ctx.request.body as Record<string, string>;
  4255. console.log('[Alipay Notify] 收到异步通知:', params);
  4256. // 验证签名
  4257. const signVerified = PaymentService.verifyAlipaySign(params);
  4258. if (!signVerified) {
  4259. console.error('[Alipay Notify] 签名验证失败');
  4260. ctx.status = 400;
  4261. ctx.body = 'fail';
  4262. return;
  4263. }
  4264. // ═══════════════════════════════════════════════════════════════
  4265. // 文件: server/src/modules/favorites/favorites.controller.ts
  4266. // ═══════════════════════════════════════════════════════════════
  4267. import Router from '@koa/router';
  4268. import { Context } from 'koa';
  4269. import * as FavoritesService from './favorites.service';
  4270. import { BadRequestError } from '../../middleware/errorHandler';
  4271. import { optionalAuth } from '../../middleware/auth';
  4272. // 测试用户ID(开发环境使用)
  4273. const TEST_USER_ID = '1';
  4274. const router = new Router();
  4275. // 获取收藏列表(兼容前端 /api/favorites/list)
  4276. router.get('/list', optionalAuth, async (ctx: Context) => {
  4277. // 开发环境使用测试用户ID
  4278. const userId = ctx.state.user?.userId || TEST_USER_ID;
  4279. const favorites = await FavoritesService.getFavorites(userId);
  4280. ctx.body = {
  4281. code: 0,
  4282. message: 'success',
  4283. data: favorites,
  4284. };
  4285. });
  4286. // 获取收藏列表
  4287. router.get('/', optionalAuth, async (ctx: Context) => {
  4288. // 开发环境使用测试用户ID
  4289. const userId = ctx.state.user?.userId || TEST_USER_ID;
  4290. const favorites = await FavoritesService.getFavorites(userId);
  4291. ctx.body = {
  4292. code: 0,
  4293. message: 'success',
  4294. data: favorites,
  4295. };
  4296. });
  4297. // 添加收藏
  4298. router.post('/', optionalAuth, async (ctx: Context) => {
  4299. // 开发环境使用测试用户ID
  4300. const userId = ctx.state.user?.userId || TEST_USER_ID;
  4301. const body = ctx.request.body as { audioId: number };
  4302. const { audioId } = body;
  4303. if (!audioId) {
  4304. throw new BadRequestError('音频ID不能为空');
  4305. }
  4306. const favorite = await FavoritesService.addFavorite(userId, audioId);
  4307. ctx.body = {
  4308. code: 0,
  4309. message: '收藏成功',
  4310. data: favorite,
  4311. };
  4312. });
  4313. // 取消收藏
  4314. router.delete('/:audioId', optionalAuth, async (ctx: Context) => {
  4315. // 开发环境使用测试用户ID
  4316. const userId = ctx.state.user?.userId || TEST_USER_ID;
  4317. const audioId = parseInt(ctx.params.audioId as string);
  4318. await FavoritesService.removeFavorite(userId, audioId);
  4319. ctx.body = {
  4320. code: 0,
  4321. message: '已取消收藏',
  4322. };
  4323. });
  4324. // 检查是否已收藏
  4325. router.get('/check/:audioId', optionalAuth, async (ctx: Context) => {
  4326. // 开发环境使用测试用户ID
  4327. const userId = ctx.state.user?.userId || TEST_USER_ID;
  4328. const audioId = parseInt(ctx.params.audioId as string);
  4329. const isFavorited = await FavoritesService.isFavorited(userId, audioId);
  4330. ctx.body = {
  4331. code: 0,
  4332. message: 'success',
  4333. data: { isFavorited },
  4334. };
  4335. });
  4336. export default router;
  4337. // ═══════════════════════════════════════════════════════════════
  4338. // 文件: server/src/modules/history/history.controller.ts
  4339. // ═══════════════════════════════════════════════════════════════
  4340. import Router from '@koa/router';
  4341. import { Context } from 'koa';
  4342. import { optionalAuth } from '../../middleware/auth';
  4343. import { prisma } from '../../models';
  4344. const TEST_USER_ID = '1';
  4345. const router = new Router();
  4346. // 获取音频生成历史列表(兼容前端 /api/history/list)
  4347. router.get('/list', optionalAuth, async (ctx: Context) => {
  4348. // 开发环境使用测试用户ID
  4349. const userId = ctx.state.user?.userId || TEST_USER_ID;
  4350. const page = parseInt(ctx.query.page as string) || 1;
  4351. const pageSize = parseInt(ctx.query.pageSize as string) || 20;
  4352. const startDate = ctx.query.startDate as string;
  4353. const where: any = {};
  4354. if (startDate) {
  4355. where.createdAt = { gte: new Date(startDate) };
  4356. }
  4357. const [records, total] = await Promise.all([
  4358. prisma.audioRecord.findMany({
  4359. where,
  4360. orderBy: { createdAt: 'desc' },
  4361. skip: (page - 1) * pageSize,
  4362. take: pageSize,
  4363. }),
  4364. prisma.audioRecord.count({ where }),
  4365. ]);
  4366. const list = records.map((r) => ({
  4367. _id: r.audioId,
  4368. id: r.audioId,
  4369. title: r.title,
  4370. text: r.text || '',
  4371. summary: '',
  4372. tags: [],
  4373. audioUrl: r.audioUrl || '',
  4374. audioDuration: r.audioDuration,
  4375. audioSize: r.audioSize,
  4376. wordCount: r.wordCount,
  4377. voiceId: r.voiceId,
  4378. voiceParams: r.voiceParams ? JSON.parse(r.voiceParams) : { speed: 1, pitch: 0, volume: 50 },
  4379. status: r.status,
  4380. isFavorite: false,
  4381. bookId: r.bookId,
  4382. createdAt: r.createdAt.toISOString(),
  4383. updatedAt: r.updatedAt.toISOString(),
  4384. }));
  4385. ctx.body = {
  4386. code: 0,
  4387. message: 'success',
  4388. data: {
  4389. list,
  4390. total,
  4391. page,
  4392. pageSize,
  4393. totalPages: Math.ceil(total / pageSize),
  4394. },
  4395. };
  4396. });
  4397. // 获取音频生成历史列表
  4398. router.get('/', optionalAuth, async (ctx: Context) => {
  4399. // 开发环境使用测试用户ID
  4400. const userId = ctx.state.user?.userId || TEST_USER_ID;
  4401. const page = parseInt(ctx.query.page as string) || 1;
  4402. const pageSize = parseInt(ctx.query.pageSize as string) || 20;
  4403. const startDate = ctx.query.startDate as string;
  4404. const where: any = {};
  4405. if (startDate) {
  4406. where.createdAt = { gte: new Date(startDate) };
  4407. }
  4408. const [records, total] = await Promise.all([
  4409. prisma.audioRecord.findMany({
  4410. where,
  4411. orderBy: { createdAt: 'desc' },
  4412. skip: (page - 1) * pageSize,
  4413. take: pageSize,
  4414. }),
  4415. prisma.audioRecord.count({ where }),
  4416. ]);
  4417. const list = records.map((r) => ({
  4418. _id: r.audioId,
  4419. id: r.audioId,
  4420. // ═══════════════════════════════════════════════════════════════
  4421. // 文件: server/src/modules/search/search.controller.ts
  4422. // ═══════════════════════════════════════════════════════════════
  4423. import Router from '@koa/router';
  4424. import { searchService } from './search.service';
  4425. const router = new Router();
  4426. /**
  4427. * 搜索音频
  4428. * GET /api/search?q=关键词
  4429. */
  4430. router.get('/', async (ctx) => {
  4431. try {
  4432. const { q, limit } = ctx.query as { q?: string; limit?: string };
  4433. if (!q || q.trim() === '') {
  4434. ctx.body = {
  4435. code: 0,
  4436. message: 'success',
  4437. data: [],
  4438. };
  4439. return;
  4440. }
  4441. const results = await searchService.searchAudio(q, parseInt(limit || '20'));
  4442. ctx.body = {
  4443. code: 0,
  4444. message: 'success',
  4445. data: results,
  4446. };
  4447. } catch (error: any) {
  4448. ctx.body = {
  4449. code: 400,
  4450. message: error.message || '搜索失败',
  4451. };
  4452. }
  4453. });
  4454. /**
  4455. * 获取热门搜索词
  4456. * GET /api/search/hot
  4457. */
  4458. router.get('/hot', async (ctx) => {
  4459. try {
  4460. const { limit } = ctx.query as { limit?: string };
  4461. const hotSearches = await searchService.getHotSearches(parseInt(limit || '10'));
  4462. ctx.body = {
  4463. code: 0,
  4464. message: 'success',
  4465. data: hotSearches,
  4466. };
  4467. } catch (error: any) {
  4468. ctx.body = {
  4469. code: 400,
  4470. message: error.message || '获取热门搜索失败',
  4471. };
  4472. }
  4473. });
  4474. /**
  4475. * 获取搜索历史
  4476. * GET /api/search/history?userId=1
  4477. */
  4478. router.get('/history', async (ctx) => {
  4479. try {
  4480. const { userId, limit } = ctx.query as { userId?: string; limit?: string };
  4481. const userIdNum = parseInt(userId || '0') || 0;
  4482. const history = await searchService.getSearchHistory(userIdNum, parseInt(limit || '5'));
  4483. ctx.body = {
  4484. code: 0,
  4485. message: 'success',
  4486. data: history,
  4487. };
  4488. } catch (error: any) {
  4489. ctx.body = {
  4490. code: 400,
  4491. message: error.message || '获取搜索历史失败',
  4492. };
  4493. }
  4494. });
  4495. /**
  4496. * 保存搜索历史
  4497. * POST /api/search/history
  4498. */
  4499. router.post('/history', async (ctx) => {
  4500. try {
  4501. const { userId, keyword } = ctx.request.body as { userId?: number; keyword?: string };
  4502. if (!keyword || keyword.trim() === '') {
  4503. // ═══════════════════════════════════════════════════════════════
  4504. // 文件: server/src/services/oss.service.ts
  4505. // ═══════════════════════════════════════════════════════════════
  4506. import OSS from 'ali-oss';
  4507. import path from 'path';
  4508. import fs from 'fs';
  4509. interface OSSConfig {
  4510. region: string;
  4511. accessKeyId: string;
  4512. accessKeySecret: string;
  4513. bucket: string;
  4514. endpoint?: string;
  4515. }
  4516. class OSSService {
  4517. private client: OSS;
  4518. private bucket: string;
  4519. private cdnDomain?: string;
  4520. constructor() {
  4521. const config: OSSConfig = {
  4522. region: process.env.OSS_REGION || 'oss-cn-hangzhou',
  4523. accessKeyId: process.env.OSS_ACCESS_KEY_ID || '',
  4524. accessKeySecret: process.env.OSS_ACCESS_KEY_SECRET || '',
  4525. bucket: process.env.OSS_BUCKET_NAME || '',
  4526. endpoint: process.env.OSS_ENDPOINT || 'oss-cn-hangzhou.aliyuncs.com',
  4527. };
  4528. this.client = new OSS(config);
  4529. this.bucket = config.bucket;
  4530. this.cdnDomain = process.env.OSS_CDN_DOMAIN;
  4531. }
  4532. /**
  4533. * 上传文件到 OSS
  4534. * @param localPath 本地文件路径
  4535. * @param objectKey OSS 对象键(路径)
  4536. * @returns OSS 文件 URL
  4537. */
  4538. async uploadFile(localPath: string, objectKey: string): Promise<string> {
  4539. try {
  4540. // 确保 objectKey 格式正确
  4541. const normalizedKey = objectKey.replace(/\\/g, '/').replace(/^\/+/, '');
  4542. const result = await this.client.put(normalizedKey, localPath, {
  4543. headers: {
  4544. 'Content-Type': this.getContentType(normalizedKey),
  4545. },
  4546. });
  4547. console.log(`[OSS] 文件上传成功: ${normalizedKey}`);
  4548. // 返回文件 URL
  4549. return this.getFileUrl(normalizedKey);
  4550. } catch (error) {
  4551. console.error('[OSS] 文件上传失败:', error);
  4552. throw new Error(`OSS 上传失败: ${(error as Error).message}`);
  4553. }
  4554. }
  4555. /**
  4556. * 上传 Buffer 到 OSS
  4557. * @param buffer 文件 Buffer
  4558. * @param objectKey OSS 对象键
  4559. * @param contentType 内容类型
  4560. * @returns OSS 文件 URL
  4561. */
  4562. async uploadBuffer(buffer: Buffer, objectKey: string, contentType?: string): Promise<string> {
  4563. try {
  4564. const normalizedKey = objectKey.replace(/\\/g, '/').replace(/^\/+/, '');
  4565. const result = await this.client.put(normalizedKey, buffer, {
  4566. headers: {
  4567. 'Content-Type': contentType || this.getContentType(normalizedKey),
  4568. },
  4569. });
  4570. console.log(`[OSS] Buffer 上传成功: ${normalizedKey}`);
  4571. return this.getFileUrl(normalizedKey);
  4572. } catch (error) {
  4573. console.error('[OSS] Buffer 上传失败:', error);
  4574. throw new Error(`OSS Buffer 上传失败: ${(error as Error).message}`);
  4575. }
  4576. }
  4577. /**
  4578. * 上传音频文件
  4579. * @param localPath 本地音频文件路径
  4580. * @param audioId 音频 ID
  4581. * @returns OSS 音频 URL
  4582. */
  4583. async uploadAudio(localPath: string, audioId: string): Promise<string> {
  4584. const objectKey = `audio/${audioId}/${path.basename(localPath)}`;
  4585. return this.uploadFile(localPath, objectKey);
  4586. // ═══════════════════════════════════════════════════════════════
  4587. // 文件: server/src/services/queue.service.ts
  4588. // ═══════════════════════════════════════════════════════════════
  4589. /**
  4590. * 队列服务
  4591. *
  4592. * 【架构职责】
  4593. * 队列只负责:排队 + 并发控制
  4594. *
  4595. * 【职责边界】
  4596. * - ✅ 做:任务排队、并发限制、任务分发
  4597. * - ❌ 不做:失败重试、超时管理、业务逻辑
  4598. *
  4599. * 【设计原则】
  4600. * - 单一职责:队列只管排队,不管其他
  4601. * - 失败重试 → 容错层(fault-tolerance.ts)
  4602. * - 超时管理 → AI服务层(llm.service.ts)
  4603. * - 业务逻辑 → 领域层(langgraph-generator.ts)
  4604. */
  4605. import Queue from 'bull';
  4606. import { redisService } from './redis.service';
  4607. import { memoryQueue } from './memory-queue';
  4608. // 任务队列类型
  4609. export enum QueueType {
  4610. AUDIO_GENERATION = 'audio:generation',
  4611. VIDEO_GENERATION = 'video:generation',
  4612. BOOK_GENERATION = 'book:generation',
  4613. EMAIL_SEND = 'email:send',
  4614. }
  4615. // 任务状态
  4616. export enum TaskStatus {
  4617. WAITING = 'waiting',
  4618. ACTIVE = 'active',
  4619. COMPLETED = 'completed',
  4620. FAILED = 'failed',
  4621. DELAYED = 'delayed',
  4622. }
  4623. // 任务数据接口
  4624. interface TaskData {
  4625. userId?: number;
  4626. [key: string]: any;
  4627. }
  4628. // 任务进度回调
  4629. export type ProgressCallback = (progress: number, data?: any) => void;
  4630. class QueueService {
  4631. private queues: Map<string, Queue.Queue> = new Map();
  4632. private progressCallbacks: Map<string, ProgressCallback> = new Map();
  4633. private queueAvailable: boolean = true;
  4634. constructor() {
  4635. // 如果 Redis 不可用,记录警告但不阻止程序运行
  4636. if (!redisService.isAvailable()) {
  4637. console.warn('[Queue] Redis 不可用,任务队列将无法正常工作');
  4638. this.queueAvailable = false;
  4639. }
  4640. }
  4641. /**
  4642. * 检查队列是否可用
  4643. */
  4644. isQueueAvailable(): boolean {
  4645. return this.queueAvailable && redisService.isAvailable();
  4646. }
  4647. /**
  4648. * 获取或创建队列
  4649. * @param queueName 队列名称
  4650. */
  4651. private getQueue(queueName: string): Queue.Queue | null {
  4652. if (!this.isQueueAvailable()) {
  4653. return null;
  4654. }
  4655. if (!this.queues.has(queueName)) {
  4656. try {
  4657. const queue = new Queue(queueName, {
  4658. redis: {
  4659. host: process.env.REDIS_HOST || 'localhost',
  4660. port: parseInt(process.env.REDIS_PORT || '6379'),
  4661. password: process.env.REDIS_PASSWORD || undefined,
  4662. db: parseInt(process.env.REDIS_DB || '0'),
  4663. maxRetriesPerRequest: null,
  4664. },
  4665. defaultJobOptions: {
  4666. removeOnComplete: 100,
  4667. removeOnFail: 50,
  4668. },
  4669. // ═══════════════════════════════════════════════════════════════
  4670. // 文件: server/src/services/websocket.service.ts
  4671. // ═══════════════════════════════════════════════════════════════
  4672. /**
  4673. * WebSocket 服务
  4674. * 用于推送音频/视频生成完成事件
  4675. */
  4676. import { Server as HttpServer } from 'http';
  4677. import WebSocket, { WebSocketServer } from 'ws';
  4678. const wss = new WebSocketServer({ noServer: true });
  4679. // 客户端连接管理
  4680. const clients = new Map<string, WebSocket>();
  4681. // ============ 客户端管理 ============
  4682. /**
  4683. * 注册客户端连接
  4684. */
  4685. export function addClient(clientId: string, ws: WebSocket) {
  4686. clients.set(clientId, ws);
  4687. console.log(`[WS] 客户端连接: ${clientId}, 当前在线: ${clients.size}`);
  4688. }
  4689. /**
  4690. * 移除客户端连接
  4691. */
  4692. export function removeClient(clientId: string) {
  4693. clients.delete(clientId);
  4694. console.log(`[WS] 客户端断开: ${clientId}, 当前在线: ${clients.size}`);
  4695. }
  4696. /**
  4697. * 通过 clientId 发送消息
  4698. */
  4699. export function sendToClient(clientId: string, event: string, data: any): boolean {
  4700. const ws = clients.get(clientId);
  4701. if (!ws || ws.readyState !== WebSocket.OPEN) {
  4702. return false;
  4703. }
  4704. try {
  4705. ws.send(JSON.stringify({ event, data }));
  4706. return true;
  4707. } catch (error) {
  4708. console.error(`[WS] 发送消息失败: ${clientId}`, error);
  4709. return false;
  4710. }
  4711. }
  4712. /**
  4713. * 广播消息到所有客户端
  4714. */
  4715. export function broadcast(event: string, data: any) {
  4716. const message = JSON.stringify({ event, data });
  4717. clients.forEach((ws, clientId) => {
  4718. if (ws.readyState === WebSocket.OPEN) {
  4719. try {
  4720. ws.send(message);
  4721. } catch (error) {
  4722. console.error(`[WS] 广播失败: ${clientId}`, error);
  4723. }
  4724. }
  4725. });
  4726. }
  4727. // ============ 事件推送 ============
  4728. /**
  4729. * 推送音频生成完成事件
  4730. */
  4731. export function pushAudioGenerationComplete(bookId: string, chapterId: number, status: 'completed' | 'failed') {
  4732. const event = 'audio_generation_complete';
  4733. const data = { bookId, chapterId, status };
  4734. console.log(`[WS] 推送 ${event}:`, data);
  4735. // 广播给所有客户端(前端可根据 bookId 过滤)
  4736. broadcast(event, data);
  4737. }
  4738. /**
  4739. * 推送视频生成完成事件
  4740. */
  4741. export function pushVideoGenerationComplete(bookId: string, chapterId: number, status: 'completed' | 'failed') {
  4742. const event = 'video_generation_complete';
  4743. const data = { bookId, chapterId, status };
  4744. console.log(`[WS] 推送 ${event}:`, data);
  4745. broadcast(event, data);
  4746. }
  4747. /**
  4748. * 推送批量生成进度
  4749. */
  4750. export function pushBatchGenerationProgress(taskId: string, step: string, progress: number) {
  4751. const event = 'batch_generation_progress';
  4752. ##############################################################################
  4753. # 统计信息
  4754. ##############################################################################
  4755. # 前端文件数: 30
  4756. # 后端文件数: 30
  4757. # 文档总行数: 4751