| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824 |
- ==============================================================================
- 智音软件 V1.0 源代码文档
- ==============================================================================
- 软件全称:智音软件
- 软件简称:智音
- 版 本 号:V1.0
- 说明:本文档包含软件前30页和后30页源代码,每页50行。
- 前30页为软件前端代码,后30页为软件后端代码。
- ##############################################################################
- # 第一部分:前端源代码(前30页)
- ##############################################################################
- // ═══════════════════════════════════════════════════════════════
- // 文件: my-uniapp-vue3/src/main.ts
- // ═══════════════════════════════════════════════════════════════
- import { createSSRApp } from 'vue';
- import { createPinia } from 'pinia';
- import App from './App.vue';
- import { useUserStore } from './store/user';
- // #ifdef H5
- import VConsole from 'vconsole';
- // #endif
- export function createApp() {
- const app = createSSRApp(App);
- const pinia = createPinia();
- app.use(pinia);
- // 初始化用户状态
- const userStore = useUserStore();
- userStore.initUser();
- // #ifdef H5
- // vConsole 开启,用于调试微信支付问题
- new VConsole();
- // #endif
- return {
- app,
- pinia,
- };
- }
- // ═══════════════════════════════════════════════════════════════
- // 文件: my-uniapp-vue3/src/App.vue
- // ═══════════════════════════════════════════════════════════════
- <script setup lang="ts">
- import { onLaunch, onShow, onHide } from '@dcloudio/uni-app';
- import { createPinia } from 'pinia';
- import { useUserStore } from './store/user';
- import { initDebug } from './utils/debug';
- import { initAnalytics } from './utils/analytics';
- import MiniPlayer from './components/MiniPlayer.vue';
- import CreditInsufficientModal from './components/CreditInsufficientModal.vue';
- // 初始化全局调试工具
- initDebug();
- // ==================== 微信环境检测 ====================
- function isInWechatBrowser(): boolean {
- // #ifdef H5
- const ua = navigator.userAgent.toLowerCase();
- return ua.includes('micromessenger');
- // #endif
- return false;
- }
- // ==================== 微信 OAuth 回调处理 ====================
- async function handleWechatOAuthCallback() {
- // 只在 H5 环境处理
- // #ifdef H5
- const searchParams = new URLSearchParams(window.location.search);
- const code = searchParams.get('code');
- const state = searchParams.get('state');
- console.log('[App] handleWechatOAuthCallback 触发', { code: !!code, fullUrl: window.location.href });
- if (!code) {
- console.log('[App] 无 code 参数,跳过 OAuth 处理');
- return; // 不是 OAuth 回调,跳过
- }
- console.log('[App] 检测到微信 OAuth 回调,处理 code...');
- uni.showLoading({ title: '授权中...' });
- try {
- const { post } = await import('./utils/request');
- const result = await post<{ openid: string }>('/payment/wechat/openid', { code });
- if (result.openid) {
- console.log('[App] OpenID 获取成功');
- // 先尝试根据 openid 自动登录
- try {
- const userStore = useUserStore();
- await userStore.loginByOpenid(result.openid);
- console.log('[App] OpenID 已绑定,自动登录成功');
- // 清除 URL 参数,停留在当前页
- searchParams.delete('code');
- searchParams.delete('state');
- const newSearch = searchParams.toString();
- const newUrl = window.location.origin + window.location.pathname
- + (newSearch ? '?' + newSearch : '') + window.location.hash;
- window.history.replaceState({}, '', newUrl);
- uni.hideLoading();
- uni.showToast({ title: '自动登录成功', icon: 'success' });
- return;
- } catch (e: any) {
- if (e?.message?.includes('未绑定') || e?.code === -1) {
- // 自动登录失败(未绑定账号),继续正常流程
- console.log('[App] OpenID 未绑定账号,将存储待登录');
- } else {
- console.error('[App] 自动登录异常:', e);
- }
- }
- // 未绑定账号或自动登录失败,存储 openid 等待手机号登录绑定
- localStorage.setItem('wx_openid', result.openid);
- // 🔧 标记刚从 OAuth 回来,页面组件可据此自动触发支付
- try { sessionStorage.setItem('wx_oauth_just_done', '1'); } catch (_) {}
- // 恢复之前保存的页面上下文
- let returnUrl: string | null = null;
- try {
- returnUrl = sessionStorage.getItem('wx_oauth_return');
- sessionStorage.removeItem('wx_oauth_return');
- } catch (_) {}
- if (returnUrl) {
- // 跳回原始页面(保留完整 hash 路由)
- // 用 location.replace 避免产生多余的历史记录
- window.location.replace(returnUrl);
- } else {
- // 没有上下文,清除 URL 参数后停留在当前页
- searchParams.delete('code');
- searchParams.delete('state');
- const newSearch = searchParams.toString();
- const newUrl = window.location.origin + window.location.pathname
- // ═══════════════════════════════════════════════════════════════
- // 文件: my-uniapp-vue3/src/config.ts
- // ═══════════════════════════════════════════════════════════════
- /**
- * 全局配置
- */
- import { getApiBaseUrl } from './utils/config'
- export const BASE_URL = getApiBaseUrl()
- // ═══════════════════════════════════════════════════════════════
- // 文件: my-uniapp-vue3/src/store/user.ts
- // ═══════════════════════════════════════════════════════════════
- import { defineStore } from 'pinia';
- import { ref, computed } from 'vue';
- import { getToken, setToken, removeToken, getUserInfo, setUserInfo, removeUserInfo } from '../utils/storage';
- import { get, post } from '../utils/request';
- import type { UserInfo, MemberStatus } from '../types';
- export const useUserStore = defineStore('user', () => {
- // 状态
- const token = ref<string | null>(getToken());
- const userInfo = ref<UserInfo | null>(getUserInfo<UserInfo>());
- const memberStatus = ref<MemberStatus | null>(null);
- // 计算属性
- const isLoggedIn = computed(() => !!token.value && !!userInfo.value);
- const isMember = computed(() => memberStatus.value?.isValid && memberStatus.value.level > 0);
- // 初始化用户状态
- function initUser() {
- // 检查是否有保存的登录状态
- const savedToken = getToken();
- const savedUserInfo = getUserInfo<UserInfo>();
- // 如果有有效的登录状态,使用它
- if (savedToken && savedUserInfo && savedToken !== 'test-token-for-dev') {
- token.value = savedToken;
- userInfo.value = savedUserInfo;
- fetchMemberStatus();
- }
- // 否则不设置默认用户,等待用户主动登录
- }
- // 登录
- async function login(phone: string, code: string) {
- const inviteCode = uni.getStorageSync('invite_code') || '';
- // 读取微信 openid(从公众号进入时已存储在 localStorage)
- const wxOpenid = (typeof localStorage !== 'undefined' && localStorage.getItem('wx_openid')) || '';
- const result = await post<{
- token: string;
- user: UserInfo;
- }>('/auth/login', { phone, code, inviteCode, openid: wxOpenid });
- token.value = result.token;
- userInfo.value = result.user;
- setToken(result.token);
- setUserInfo(result.user);
- // 清除已使用的邀请码
- if (inviteCode) {
- uni.removeStorageSync('invite_code');
- }
- // 绑定成功后清除 localStorage 中的 openid(避免后续登录重复携带)
- if (wxOpenid && typeof localStorage !== 'undefined') {
- localStorage.removeItem('wx_openid');
- }
- await fetchMemberStatus();
- return result;
- }
- // 发送验证码
- async function sendCode(phone: string) {
- return post('/auth/send-code', { phone });
- }
- // 获取用户信息
- async function fetchUserInfo() {
- const info = await get<UserInfo>('/auth/user-info');
- userInfo.value = info;
- setUserInfo(info);
- return info;
- }
- // 获取会员状态
- async function fetchMemberStatus() {
- try {
- const status = await get<MemberStatus>('/member/status');
- memberStatus.value = status;
- return status;
- } catch {
- memberStatus.value = null;
- return null;
- }
- }
- // 登出
- function logout() {
- token.value = null;
- userInfo.value = null;
- memberStatus.value = null;
- removeToken();
- removeUserInfo();
- }
- // ═══════════════════════════════════════════════════════════════
- // 文件: my-uniapp-vue3/src/store/audio.ts
- // ═══════════════════════════════════════════════════════════════
- import { defineStore } from 'pinia';
- import { ref, computed } from 'vue';
- import { get, post, getFullUrl } from '../utils/request';
- import type { AudioItem, Voice, VoiceParams } from '../types';
- export const useAudioStore = defineStore('audio', () => {
- // 状态
- const voices = ref<Voice[]>([]);
- const currentAudio = ref<AudioItem | null>(null);
- const playlist = ref<AudioItem[]>([]);
- const currentIndex = ref(-1);
- const isPlaying = ref(false);
- const currentTime = ref(0);
- const duration = ref(0);
- const playRate = ref(1);
- // 播放模式: 'sequence' | 'loop' | 'single' | 'random'
- const playMode = ref<'sequence' | 'loop' | 'single' | 'random'>('sequence');
- // 音频上下文 - 双模式:BackgroundAudioManager(App后台播放)+ InnerAudioContext(H5降级)
- let audioContext: UniApp.InnerAudioContext | null = null;
- let bgAudioManager: any = null;
- // H5 环境不支持 BackgroundAudioManager,使用 InnerAudioContext
- const useBackgroundAudio: boolean = (function() {
- // #ifdef H5
- return false;
- // #endif
- // #ifndef H5
- return true;
- // #endif
- })();
- // 计算属性
- const hasPlaylist = computed(() => playlist.value.length > 0);
- const hasNext = computed(() => currentIndex.value < playlist.value.length - 1);
- const hasPrev = computed(() => currentIndex.value > 0);
- // 获取当前活跃的音频上下文(背景模式优先)
- function getActiveContext() {
- if (useBackgroundAudio && bgAudioManager) return bgAudioManager;
- return audioContext;
- }
- // 初始化音频上下文
- function initAudioContext() {
- if (useBackgroundAudio) {
- if (!bgAudioManager) {
- try {
- bgAudioManager = uni.getBackgroundAudioManager();
- setupBackgroundAudioEvents();
- console.log('[Audio] 使用 BackgroundAudioManager 支持后台播放');
- } catch (e) {
- console.warn('[Audio] BackgroundAudioManager 不可用,降级到 InnerAudioContext');
- initInnerAudioContext();
- }
- }
- return;
- }
- initInnerAudioContext();
- }
- function initInnerAudioContext() {
- if (audioContext) return;
- audioContext = uni.createInnerAudioContext();
- audioContext.onPlay(() => {
- isPlaying.value = true;
- });
- audioContext.onPause(() => {
- isPlaying.value = false;
- });
- audioContext.onEnded(() => {
- isPlaying.value = false;
- if (hasNext.value) {
- playNext();
- } else {
- handlePlayMode();
- }
- });
- audioContext.onTimeUpdate(() => {
- currentTime.value = audioContext?.currentTime || 0;
- const d = audioContext?.duration || 0;
- if (d > 0 && d < 3600) {
- if (duration.value === 0 || duration.value > 3600 || Math.abs(duration.value - d) < 1) {
- duration.value = d;
- }
- }
- });
- // ═══════════════════════════════════════════════════════════════
- // 文件: my-uniapp-vue3/src/store/notification.ts
- // ═══════════════════════════════════════════════════════════════
- import { defineStore } from 'pinia';
- import { ref, computed } from 'vue';
- export interface Notification {
- id: string;
- type: 'audio_complete' | 'video_complete' | 'content_complete' | 'error';
- title: string;
- message: string;
- bookId?: string | number;
- timestamp: number;
- read: boolean;
- }
- const STORAGE_KEY = 'app_notifications';
- function loadFromStorage(): Notification[] {
- try {
- const data = uni.getStorageSync(STORAGE_KEY);
- return data ? JSON.parse(data) : [];
- } catch {
- return [];
- }
- }
- function saveToStorage(notifications: Notification[]) {
- try {
- uni.setStorageSync(STORAGE_KEY, JSON.stringify(notifications));
- } catch (e) {
- console.error('[Notification] 保存失败:', e);
- }
- }
- export const useNotificationStore = defineStore('notification', () => {
- const notifications = ref<Notification[]>(loadFromStorage());
- const unreadCount = computed(() => notifications.value.filter(n => !n.read).length);
- function add(notif: Omit<Notification, 'id' | 'timestamp' | 'read'>) {
- const id = `notif_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`;
- const notification: Notification = {
- ...notif,
- id,
- timestamp: Date.now(),
- read: false,
- };
- notifications.value.unshift(notification);
- // 最多保留 50 条
- if (notifications.value.length > 50) {
- notifications.value = notifications.value.slice(0, 50);
- }
- saveToStorage(notifications.value);
- }
- function markRead(id: string) {
- const n = notifications.value.find(item => item.id === id);
- if (n) {
- n.read = true;
- saveToStorage(notifications.value);
- }
- }
- function markAllRead() {
- notifications.value.forEach(n => { n.read = true; });
- saveToStorage(notifications.value);
- }
- function remove(id: string) {
- notifications.value = notifications.value.filter(n => n.id !== id);
- saveToStorage(notifications.value);
- }
- function clear() {
- notifications.value = [];
- saveToStorage(notifications.value);
- }
- return {
- notifications,
- unreadCount,
- add,
- markRead,
- markAllRead,
- remove,
- clear,
- };
- });
- // ═══════════════════════════════════════════════════════════════
- // 文件: my-uniapp-vue3/src/pages/index/index.vue
- // ═══════════════════════════════════════════════════════════════
- <template>
- <view class="page">
- <!-- 搜索栏 -->
- <view class="search-bar" @click="goToSearch">
- <text class="search-icon">🔍</text>
- <text class="search-placeholder">搜索专辑...</text>
- <!-- 通知铃铛 -->
- <view class="notif-bell" @click.stop="showNotifications">
- <text class="bell-icon">🔔</text>
- <view v-if="notifStore.unreadCount > 0" class="bell-badge">{{ notifStore.unreadCount > 99 ? '99+' : notifStore.unreadCount }}</view>
- </view>
- </view>
- <!-- 骨架屏加载状态 -->
- <scroll-view scroll-y class="album-list" @scrolltolower="loadMore" v-if="loading && bookList.length === 0" :scroll-top="scrollTop">
- <!-- 最近收听骨架屏 -->
- <view v-if="userStore.isLoggedIn" class="recent-section">
- <view class="skeleton-section-title"></view>
- <scroll-view scroll-x class="recent-scroll" enable-flex>
- <view v-for="i in 4" :key="i" class="recent-card skeleton-recent-card">
- <view class="skeleton-recent-cover"></view>
- <view class="skeleton-recent-title"></view>
- <view class="skeleton-recent-progress"></view>
- </view>
- </scroll-view>
- </view>
- <view class="skeleton-grid">
- <view v-for="i in 6" :key="i" class="skeleton-card">
- <view class="skeleton-cover"></view>
- <view class="skeleton-info">
- <view class="skeleton-title"></view>
- <view class="skeleton-meta"></view>
- </view>
- </view>
- </view>
- </scroll-view>
- <!-- 书籍列表 -->
- <scroll-view scroll-y class="album-list" @scrolltolower="loadMore" v-else :scroll-top="scrollTop">
- <!-- 最近收听 -->
- <view v-if="userStore.isLoggedIn && recentAudios.length > 0" class="recent-section">
- <text class="section-title">最近收听</text>
- <scroll-view scroll-x class="recent-scroll" enable-flex>
- <view
- v-for="item in recentAudios"
- :key="item.id"
- class="recent-card"
- @click="goToPlayer(item)"
- >
- <view class="recent-cover" :style="{ background: getRecentGradient(item.id) }">
- <image v-if="item.coverUrl" :src="item.coverUrl" mode="aspectFill" class="cover-img" />
- <text v-else class="cover-icon">📖</text>
- <view class="recent-progress-bar">
- <view class="progress-fill" :style="{ width: item.progress + '%' }"></view>
- </view>
- </view>
- <text class="recent-title">{{ item.title }}</text>
- <text class="recent-progress">{{ formatProgress(item.progress) }}</text>
- </view>
- </scroll-view>
- </view>
- <!-- 冷启动引导(新用户无内容时) -->
- <view v-if="bookList.length === 0 && !loading && recentAudios.length === 0" class="onboarding">
- <view class="onboarding-card">
- <text class="onboarding-icon">🎧</text>
- <text class="onboarding-title">欢迎来到有声书 AI</text>
- <text class="onboarding-desc">AI 为你创作专属有声书,从选题到配音全自动完成</text>
- <button class="onboarding-btn" @click="quickCreateBook">
- ✨ 创建我的第一本有声书
- </button>
- </view>
- <view class="template-section">
- <text class="template-title">💡 快速模板</text>
- <view class="template-list">
- <view
- v-for="tpl in quickTemplates"
- :key="tpl.title"
- class="template-card"
- @click="useQuickTemplate(tpl)"
- >
- <text class="template-icon">{{ tpl.icon }}</text>
- <text class="template-name">{{ tpl.title }}</text>
- // ═══════════════════════════════════════════════════════════════
- // 文件: my-uniapp-vue3/src/pages/create/index.vue
- // ═══════════════════════════════════════════════════════════════
- <template>
- <view class="page">
- <!-- 主内容区:只有文本输入 -->
- <view class="main-content">
- <!-- 文本输入 -->
- <view class="input-area">
- <textarea
- v-model="text"
- class="text-input"
- placeholder="请输入要转换的文本内容..."
- :maxlength="2000"
- auto-height
- />
- <!-- 字数统计 -->
- <text class="word-count" :class="{ warning: text.length > 1800, error: text.length >= 2000 }">
- {{ text.length }}/2000
- </text>
- <!-- 已上传文档信息 -->
- <view v-if="uploadedDoc.fileName && !uploading" class="doc-info-bar">
- <text class="doc-format-badge">{{ uploadedDoc.format?.toUpperCase() || 'DOC' }}</text>
- <text class="doc-filename">{{ uploadedDoc.fileName }}</text>
- <text class="doc-meta">{{ uploadedDoc.wordCount }} 字</text>
- <text v-if="uploadedDoc.truncated" class="doc-truncated-hint">⚠ 已截取</text>
- <text class="doc-remove-btn" @click="clearDoc">✕</text>
- </view>
- <!-- 上传中 -->
- <view v-if="uploading" class="doc-uploading-bar">
- <text>正在解析文档,请稍候...</text>
- </view>
- <!-- 工具按钮行 -->
- <view class="tool-row">
- <view class="tool-btn" @click="handleUploadDoc">
- <text>{{ uploadedDoc.fileName ? '📄 重新上传' : '📄 上传文档' }}</text>
- </view>
- <view class="tool-btn" @click="goToAIGenerate">
- <text>🤖 AI 生成</text>
- </view>
- <view class="tool-btn" @click="pasteText">
- <text>📋 粘贴</text>
- </view>
- <view class="tool-btn" @click="clearText">
- <text>🗑 清空</text>
- </view>
- </view>
- <!-- 专辑选择(必填) -->
- <view class="album-selector" @click="showAlbumListPanel = true">
- <text class="album-selector-label">专辑</text>
- <text class="album-selector-value" :class="{ placeholder: !selectedAlbum }">
- {{ selectedAlbum ? selectedAlbum.title : '请选择专辑(必填)' }}
- </text>
- <text class="album-selector-arrow">▼</text>
- </view>
- </view>
- </view>
- <!-- 底部固定区:Tab 工具栏 -->
- <view class="bottom-bar">
- <!-- 主操作行:生成按钮 + 快捷信息 -->
- <view class="bottom-action-row">
- <view class="quick-info">
- <text class="quick-info-text">🎤 {{ currentVoiceName || '默认音色' }}</text>
- </view>
- <view class="generate-btn-tab" :class="{ disabled: !canGenerate }" @click="handleGenerate">
- <text class="generate-btn-tab-text">{{ generating ? '提交中...' : '开始生成' }}</text>
- </view>
- </view>
- <!-- 图标 Tab 栏(等宽分布,图标+文字居中) -->
- <view class="tab-toolbar">
- <view class="tab-item" :class="{ active: showVoicePanel }" @click="showVoicePanel = true">
- <text class="tab-icon">🎤</text>
- <text class="tab-text">音色</text>
- </view>
- <view class="tab-item" @click="showParamsPanel = true">
- <text class="tab-icon">⚙️</text>
- <text class="tab-text">参数</text>
- </view>
- </view>
- </view>
- <!-- 音色面板(暗色主题) -->
- <view v-if="showVoicePanel" class="bottom-panel-overlay" @click="showVoicePanel = false">
- <view class="bottom-panel voice-panel-dark" @click.stop>
- // ═══════════════════════════════════════════════════════════════
- // 文件: my-uniapp-vue3/src/pages/book-generator/create.vue
- // ═══════════════════════════════════════════════════════════════
- <template>
- <view class="page">
- <!-- 顶部导航栏 -->
- <view class="nav-bar">
- <view class="nav-content">
- <view class="nav-left" @click="goBack">
- <text class="back-icon">←</text>
- </view>
- <text class="page-title">{{ isEditMode ? '编辑书籍' : '创建有声书' }}</text>
- </view>
- </view>
- <!-- 主内容区 -->
- <view class="main-content">
- <!-- ===== 区域 1:描述你想要的书籍 ===== -->
- <view class="card hero-card">
- <view class="section-label">
- <text class="label-text">描述你想要的有声书</text>
- <text class="label-required">*</text>
- </view>
- <text class="section-hint">用自然语言描述主题、风格和读者,越具体效果越好</text>
- <!-- 示例提示词 -->
- <view class="examples-row">
- <text class="examples-label">试试:</text>
- <view class="example-chips">
- <text
- v-for="ex in examplePrompts"
- :key="ex"
- class="example-chip"
- @click="newBook.description = ex"
- >{{ ex }}</text>
- </view>
- </view>
- <!-- 文本域 -->
- <view class="textarea-wrapper">
- <textarea
- v-model="newBook.description"
- class="form-textarea"
- :class="{ 'textarea-error': attemptedCreate && !newBook.description.trim() }"
- placeholder="用一句话描述你想要的书籍,AI 会自动分析并推荐最佳配置..."
- :maxlength="2000"
- @blur="detectScaleFromDesc"
- />
- <view class="textarea-footer">
- <text class="char-count">{{ newBook.description.length }}/2000</text>
- <text v-if="newBook.description.trim()" class="clear-btn" @click="newBook.description = ''">清空</text>
- </view>
- </view>
- <!-- AI 智能推荐按钮 -->
- <view v-if="!isEditMode" class="ai-btn-row">
- <button
- class="btn-ai-recommend"
- :class="{ 'btn-ai-loading': isRecommending }"
- :disabled="isRecommending || !newBook.description.trim()"
- @click="requestSmartRecommend"
- >
- <text>{{ isRecommending ? 'AI 分析中...' : 'AI 智能推荐配置' }}</text>
- </button>
- <text class="ai-btn-hint">AI 会分析描述并自动填充下方所有配置</text>
- </view>
- </view>
- <!-- ===== 区域 2:AI 推荐结果(有配置时显示) ===== -->
- <view v-if="!isEditMode && hasAnyConfig" class="card config-summary-card">
- <view class="section-label">
- <text class="label-text">当前配置</text>
- <text v-if="recommendSource === 'ai'" class="config-source-tag">AI 推荐</text>
- <text v-else-if="recommendSource === 'template'" class="config-source-tag template-tag">模板</text>
- </view>
- <view class="config-tags">
- <view v-if="newBook.bookScale" class="config-tag scale-tag" @click="scrollToSection('scale')">
- <text class="config-tag-label">规模</text>
- <text class="config-tag-value">{{ getScaleShortLabel(newBook.bookScale) }}</text>
- <text class="config-tag-edit">修改</text>
- </view>
- <view v-if="newBook.targetAudience" class="config-tag" @click="scrollToSection('audience')">
- <text class="config-tag-value">{{ getAudienceLabel(newBook.targetAudience) }}</text>
- <text class="config-tag-edit">修改</text>
- </view>
- <view v-if="newBook.knowledgeLevel" class="config-tag" @click="scrollToSection('knowledge')">
- <text class="config-tag-value">{{ getKnowledgeLevelLabel(newBook.knowledgeLevel) }}</text>
- <text class="config-tag-edit">修改</text>
- // ═══════════════════════════════════════════════════════════════
- // 文件: my-uniapp-vue3/src/pages/book-generator/detail.vue
- // ═══════════════════════════════════════════════════════════════
- <template>
- <view class="page">
- <!-- 顶部导航栏 -->
- <view class="nav-bar">
- <view class="nav-content">
- <view class="nav-left" @click="goBack">
- <text class="back-icon">←</text>
- </view>
- <text class="page-title">{{ isTitleGenerating ? 'AI 生成标题中...' : (currentBook?.title || '书籍详情') }}</text>
- <view class="nav-right">
- <text v-if="canEdit" class="nav-edit-btn" @click="goToEdit">编辑</text>
- </view>
- </view>
- </view>
- <!-- 主内容区 -->
- <view class="main-content">
- <!-- 书籍信息 -->
- <view class="card book-info">
- <view class="book-title-row">
- <text class="book-title-large">{{ isTitleGenerating ? '✨ AI 正在生成标题...' : currentBook?.title }}</text>
- <GenerationStatusBadge :status="currentBook?.genStage || 'draft'"></GenerationStatusBadge>
- </view>
- <text v-if="currentBook?.subtitle" class="book-subtitle">{{ currentBook.subtitle }}</text>
- <text class="book-desc">{{ currentBook?.description }}</text>
- <view class="book-meta-row">
- <text>章节:{{ currentBook?.totalChapters }} 章</text>
- <text>预估:{{ currentBook?.estimatedWords || 0 }} 字</text>
- </view>
- </view>
- <!-- 进度显示 -->
- <view v-if="currentBook" class="card progress-card">
- <text class="card-title">📊 生成进度</text>
- <view class="progress-display">
- <text class="progress-text" :class="{ 'progress-failed': isGenerationFailed }">{{ isGenerationFailed ? '失败' : currentBook.progress }}%</text>
- <text class="progress-detail">
- {{ completedChapters }}/{{ currentBook.totalChapters }} 章
- </text>
- </view>
- <view class="progress-bar-large">
- <view class="progress-fill" :class="{ 'progress-fill-failed': isGenerationFailed }" :style="{ width: (isGenerationFailed ? 0 : currentBook.progress) + '%' }"></view>
- </view>
- <!-- 失败状态 -->
- <view v-if="isGenerationFailed" class="failed-tip">
- <text class="failed-icon">❌</text>
- <text class="failed-text">生成失败</text>
- <text v-if="currentBook.error" class="failed-error">{{ currentBook.error }}</text>
- <text class="failed-hint">请检查网络或稍后重试</text>
- <button class="retry-btn" @click="handleRetryGenerate">🔄 重新生成</button>
- </view>
- <!-- 实时生成状态面板 -->
- <view v-if="isCurrentlyGenerating && !isGenerationFailed" class="generation-status">
- <view class="status-header">
- <text class="status-icon">⚡</text>
- <text class="status-title">正在生成中...【时间较长,请耐心等待,可关闭页面,后台会继续生成】</text>
- </view>
- <view class="status-stage">
- <text class="stage-label">当前阶段:</text>
- <text class="stage-value">{{ getCurrentStage() }}</text>
- </view>
- <view v-if="currentGeneratingChapter" class="current-chapter-info">
- <text class="chapter-label">正在处理:</text>
- <text class="chapter-name">第{{ currentGeneratingChapter.number }}{{ currentGeneratingChapter.levelLabel }} {{ currentGeneratingChapter.title }}</text>
- </view>
- <view v-if="currentBook.error" class="status-error">
- <text class="error-icon">⚠️</text>
- <text class="error-text">{{ currentBook.error }}</text>
- </view>
- <view v-if="currentBook.error && currentBook.error.includes('自动恢复')" class="status-recovery">
- <text class="recovery-icon">🔄</text>
- <text class="recovery-text">系统正在尝试自动恢复...</text>
- </view>
- <view class="status-tip">
- <text class="tip-text">💡 生成过程可能需要几分钟,请耐心等待</text>
- </view>
- </view>
- <!-- 实时配额显示 -->
- <view v-if="isCurrentlyGenerating" class="quota-monitor">
- <text class="quota-monitor-title">🎧 额度监控</text>
- <view class="quota-monitor-row">
- <text>已用:{{ usedAudioMinutes }}分钟</text>
- // ═══════════════════════════════════════════════════════════════
- // 文件: my-uniapp-vue3/src/pages/book-generator/interactive.vue
- // ═══════════════════════════════════════════════════════════════
- <template>
- <view class="page">
- <!-- 顶部导航栏 -->
- <view class="nav-bar">
- <view class="nav-content">
- <view class="nav-left" @click="goBack">
- <text class="back-icon">←</text>
- </view>
- <text class="page-title">交互式创建</text>
- </view>
- <!-- 模式切换独立行 -->
- <view class="mode-bar">
- <text class="mode-switch" @click="switchToSimple">切换到一键模式</text>
- </view>
- </view>
- <!-- 步骤指示器 -->
- <view class="steps-bar">
- <view v-for="(step, i) in steps" :key="i" :class="['step-dot', currentStep >= i ? 'active' : '', currentStep > i ? 'done' : '']">
- <view class="dot-num">{{ currentStep > i ? '✓' : step.num }}</view>
- <text class="dot-label">{{ step.label }}</text>
- </view>
- <view class="step-line" :style="{ width: (currentStep / (steps.length - 1) * 100) + '%' }"></view>
- </view>
- <!-- 主内容区 -->
- <view class="main-content">
- <!-- ============ Step 1: 信息输入 ============ -->
- <view v-if="currentStep === 0" class="card">
- <view class="card-header">
- <text class="card-title">📝 第一步:基本信息</text>
- </view>
- <view class="form-item">
- <text class="form-label">书名 *</text>
- <input v-model="form.title" class="form-input" placeholder="例如:《时间是什么》" />
- </view>
- <view class="form-item">
- <text class="form-label">内容描述 *</text>
- <textarea v-model="form.description" class="form-textarea" placeholder="描述这本书的内容、主题、写作目的..." :maxlength="500" />
- <view class="ai-recommend-row">
- <button class="btn-ai-recommend" :disabled="!form.description.trim() || recommendLoading" @click="doSmartRecommend">
- {{ recommendLoading ? 'AI分析中...' : '🤖 AI智能推荐' }}
- </button>
- <text class="ai-recommend-hint">根据描述自动填充目标人群和书籍类型</text>
- </view>
- </view>
- <view class="form-item">
- <text class="form-label">书籍规模 *</text>
- <view class="scale-picker">
- <view
- v-for="scale in bookScales"
- :key="scale.value"
- :class="['scale-chip', form.bookScale === scale.value ? 'active' : '']"
- @click="form.bookScale = scale.value"
- >
- <text class="scale-label">{{ scale.label }}</text>
- <text class="scale-desc">{{ scale.chapters }} · {{ scale.audio }}</text>
- </view>
- </view>
- </view>
- <!-- 目标人群 -->
- <view class="form-item">
- <view class="label-with-badge">
- <text class="form-label">目标人群</text>
- <view class="required-badge">核心</view>
- </view>
- <text class="form-hint-small">指定读者,AI会据此调整语言风格和深度</text>
- <view class="chip-group" style="margin-top: 8rpx;">
- <view
- v-for="a in audiences"
- :key="a.value"
- :class="['chip', form.targetAudience === a.value ? 'active' : '']"
- @click="form.targetAudience = a.value"
- >
- <text class="chip-icon">{{ a.icon }}</text>
- <text class="chip-text">{{ a.label }}</text>
- <text class="chip-desc" style="font-size: 20rpx; margin-left: 4rpx;">{{ a.desc }}</text>
- </view>
- </view>
- </view>
- <view class="form-item">
- <text class="form-label">书籍类型</text>
- // ═══════════════════════════════════════════════════════════════
- // 文件: my-uniapp-vue3/src/pages/book-generator/chapter-detail.vue
- // ═══════════════════════════════════════════════════════════════
- <template>
- <view class="page">
- <!-- 顶部导航栏 -->
- <view class="nav-bar">
- <view class="nav-content">
- <view class="nav-left" @click="goBack">
- <text class="back-icon">←</text>
- </view>
- <text class="page-title">{{ chapterTitle || '章节详情' }}</text>
- <view class="nav-right"></view>
- </view>
- </view>
- <!-- 章节内容 -->
- <scroll-view
- class="chapter-content"
- scroll-y="true"
- :scroll-top="scrollTop"
- :style="{ transform: `translateX(${swipeOffset.value}px)`, transition: swipeTransition.value }"
- @touchstart="onTouchStart"
- @touchmove="onTouchMove"
- @touchend="onTouchEnd"
- >
- <!-- 章节头部信息 -->
- <view class="chapter-header">
- <text class="chapter-num-badge">
- 第{{ chapterParentNumber || chapterNumber }}{{ chapterLevel === 2 ? '章 第' + chapterNumber + '节' : '章' }}
- </text>
- <text class="chapter-title-large">{{ chapterTitle }}</text>
- <view class="chapter-meta">
- <text class="word-count">{{ chapterWordCount }}字</text>
- <GenerationStatusBadge v-if="chapterGenStage" :status="chapterGenStage" />
- <view v-if="chapterAudioUrl" class="media-badge audio-badge">
- <text>🎵 音频</text>
- </view>
- <view v-if="chapterVideoUrl" class="media-badge video-badge">
- <text>🎬 视频</text>
- </view>
- <view v-if="isLeafNode" class="edit-toggle-btn" @click="toggleEditMode">
- <text>{{ isEditMode ? '✓ 完成' : '✏️ 编辑' }}</text>
- </view>
- <view v-if="isLeafNode && chapterGenStage !== 'idle' && chapterGenStage !== 'content_generating'" class="regenerate-btn" @click="handleRegenerateContent">
- <text>{{ regeneratingContent ? '生成中...' : '🔄 重新生成' }}</text>
- </view>
- </view>
- </view>
- <!-- 章节内容(level=3小节 或 level=1短文章节) -->
- <!-- 加载中 -->
- <view v-if="isLoading && !chapterContent" class="chapter-body">
- <view class="level-hint-large">
- <text class="hint-icon-large">⏳</text>
- <text class="hint-text-large">正在加载章节内容...</text>
- </view>
- </view>
- <!-- 加载失败 -->
- <view v-else-if="loadError" class="chapter-body">
- <view class="level-hint-large">
- <text class="hint-icon-large">❌</text>
- <text class="hint-text-large">加载章节失败</text>
- <text class="hint-subtext">{{ loadError }}</text>
- <button class="retry-btn" @click="retryLoad">重试</button>
- </view>
- </view>
- <view v-else-if="isLeafNode || (chapterLevel === 1 && chapterContent)" class="chapter-body">
- <!-- 短文类章标识 -->
- <view v-if="chapterLevel === 1 && isLeafNode" class="article-badge">
- <text>📄 短文正文</text>
- </view>
- <!-- 编辑模式 -->
- <view v-if="isEditMode" class="edit-mode">
- <textarea
- v-model="editContent"
- class="content-editor"
- placeholder="编辑章节内容..."
- :maxlength="-1"
- />
- <view class="edit-actions">
- <button class="edit-btn cancel" @click="cancelEdit">取消</button>
- <button class="edit-btn save" @click="saveContent" :disabled="savingContent">
- {{ savingContent ? '保存中...' : '保存' }}
- </button>
- // ═══════════════════════════════════════════════════════════════
- // 文件: my-uniapp-vue3/src/pages/player/index.vue
- // ═══════════════════════════════════════════════════════════════
- <template>
- <view class="page">
- <!-- 顶部导航 -->
- <view class="nav-bar">
- <view class="nav-btn" @click="goBack">
- <text class="nav-icon">⋁</text>
- </view>
- <view class="nav-center">
- <text class="nav-title">{{ audio?.title || '正在播放' }}</text>
- <view v-if="sleepTimer" class="sleep-timer-badge" @click="showSleepTimerPicker = true">
- <text class="sleep-timer-text">{{ formatSleepTimer(sleepTimer.remaining) }}</text>
- </view>
- </view>
- <view class="nav-actions">
- <view class="nav-btn" @click="showSleepTimerPicker = true">
- <text class="nav-icon">⏰</text>
- </view>
- <view class="nav-btn" @click="showLyrics = !showLyrics">
- <text class="nav-icon">{{ showLyrics ? '📖' : '📕' }}</text>
- </view>
- </view>
- </view>
- <!-- 无音频提示 -->
- <view v-if="!audio?.audioUrl" class="empty-state">
- <text class="empty-icon">🎵</text>
- <text class="empty-title">暂无音频</text>
- <text class="empty-desc">该章节还没有生成音频</text>
- <button class="empty-btn" @click="goBack">返回</button>
- </view>
- <!-- 正常播放界面 -->
- <template v-else>
- <!-- 封面和标题 -->
- <view class="cover-section" v-show="showLyrics">
- <view class="cover">
- <text class="cover-icon">🎵</text>
- </view>
- <text class="title">{{ audio?.title || '未知标题' }}</text>
- </view>
- <!-- 文本预览(歌词同步) -->
- <scroll-view
- v-show="showLyrics"
- class="text-preview"
- scroll-y
- :style="{ maxHeight: '550rpx' }"
- :scroll-into-view="scrollIntoViewId"
- scroll-with-animation
- >
- <view class="lyrics-container">
- <view
- v-for="(line, index) in lyrics"
- :key="index"
- :id="'lyric-' + index"
- class="lyric-line"
- :class="{ active: index === currentLyricIndex }"
- >
- <text class="lyric-text">{{ line.text }}</text>
- </view>
- </view>
- </scroll-view>
- <!-- 进度条 -->
- <view class="progress-section">
- <text class="time">{{ formatDuration(currentTime) }}</text>
- <slider
- :value="currentTime"
- :max="duration || 100"
- @change="handleSeek"
- activeColor="#4F46E5"
- backgroundColor="#e5e7eb"
- block-size="16"
- class="progress-slider"
- />
- <text class="time">{{ formatDuration(duration) }}</text>
- </view>
- <!-- 播放控制 -->
- <view class="controls">
- <!-- 左侧按钮:播放速度、下载 -->
- <view class="controls-side">
- <view class="control-btn" @click="showRatePicker = true">
- <text class="control-text">{{ playRate }}x</text>
- </view>
- // ═══════════════════════════════════════════════════════════════
- // 文件: my-uniapp-vue3/src/pages/member/index.vue
- // ═══════════════════════════════════════════════════════════════
- <template>
- <view class="page">
- <view class="nav-bar">
- <view class="nav-btn" @click="goBack">
- <text class="nav-icon">←</text>
- </view>
- <text class="nav-title">订阅套餐</text>
- <view class="nav-btn" />
- </view>
- <view class="balance-section" v-if="isLoggedIn">
- <view class="balance-info">
- <text class="balance-label">剩余积分</text>
- <text class="balance-value">{{ formatNumber(tokenBalance.remainingTokens) }}</text>
- <text class="balance-unit"> / {{ tokenBalance.isUnlimited ? '无限' : formatNumber(tokenBalance.totalTokens) }}</text>
- </view>
- <view class="balance-bar">
- <view class="balance-progress" :style="{ width: progressWidth + '%' }"></view>
- </view>
- </view>
- <view class="plans-section">
- <text class="section-title">选择您的套餐</text>
- <view v-if="loading" class="loading-container">
- <text class="loading-text">加载中...</text>
- </view>
- <view v-else class="plans-list">
- <view
- v-for="plan in plans"
- :key="plan.id"
- class="plan-card"
- :class="{ active: selectedPlanId === plan.id, free: plan.level === 0 }"
- @click="selectedPlanId = plan.id"
- >
- <view v-if="plan.level === 0" class="free-badge">免费</view>
- <view class="plan-header">
- <text class="plan-name">{{ plan.name }}</text>
- <view class="plan-pricing">
- <text class="price-symbol">¥</text>
- <text class="price-value">{{ plan.priceMonthly }}</text>
- <text class="price-unit">/月</text>
- </view>
- </view>
- <text class="plan-description">{{ plan.description }}</text>
- <view class="plan-features">
- <view v-for="(feature, idx) in plan.features" :key="idx" class="feature-item">
- <text class="feature-icon">✓</text>
- <text class="feature-text">{{ feature }}</text>
- </view>
- </view>
- <view class="select-btn" :class="{ selected: selectedPlanId === plan.id }">
- <text>{{ selectedPlanId === plan.id ? '✓ 已选择' : '选择此套餐' }}</text>
- </view>
- </view>
- </view>
- </view>
- <view class="bottom-section">
- <view class="total-info">
- <text class="total-label">应付金额</text>
- <text class="total-value">¥{{ selectedPlan?.priceMonthly || 0 }}</text>
- </view>
- <button class="subscribe-btn" @click="handleSubscribe" :disabled="!selectedPlan || selectedPlan.level === 0">
- <text>{{ selectedPlan?.level === 0 ? '免费套餐' : '立即订阅' }}</text>
- </button>
- </view>
- </view>
- </template>
- <script setup lang="ts">
- import { ref, computed, onMounted } from 'vue';
- import { useUserStore } from '../../store/user';
- import { get, post } from '../../utils/request';
- import { formatNumber } from '../../utils/format';
- const userStore = useUserStore();
- const plans = ref<any[]>([]);
- const selectedPlanId = ref<number>(17);
- const loading = ref(false);
- const tokenBalance = ref({ totalTokens: 0, usedTokens: 0, remainingTokens: 0, isUnlimited: false });
- const selectedPlan = computed(() => plans.value.find(p => p.id === selectedPlanId.value));
- const isLoggedIn = computed(() => userStore.isLoggedIn);
- const progressWidth = computed(() => {
- if (tokenBalance.value.isUnlimited) return 100;
- if (tokenBalance.value.totalTokens === 0) return 0;
- // ═══════════════════════════════════════════════════════════════
- // 文件: my-uniapp-vue3/src/pages/mine/index.vue
- // ═══════════════════════════════════════════════════════════════
- <template>
- <view class="page">
- <!-- 用户信息卡片 -->
- <view class="user-card">
- <view class="user-info">
- <image v-if="userStore.userInfo?.avatar" :src="userStore.userInfo.avatar" class="avatar" mode="aspectFill" />
- <view v-else class="avatar-placeholder">
- <text class="avatar-text">👤</text>
- </view>
- <view class="user-text">
- <text class="nickname">{{ userStore.userInfo?.nickname || '未登录' }}</text>
- <view class="member-badge" :class="memberClass">
- <text class="member-text">{{ memberText }}</text>
- </view>
- </view>
- </view>
- <!-- 积分余额(两行展示) -->
- <view class="credits-section" v-if="tokenBalance">
- <view class="credit-row">
- <view class="credit-item">
- <text class="credit-label">📋 订阅积分</text>
- <text class="credit-value">{{ formatNumber(tokenBalance.subscriptionRemaining ?? tokenBalance.remainingTokens) }}</text>
- <text class="credit-sub" v-if="tokenBalance.subscriptionTokens">/ {{ formatNumber(tokenBalance.subscriptionTokens) }} 总量</text>
- </view>
- <view class="credit-divider" />
- <view class="credit-item">
- <text class="credit-label">🎁 积分包</text>
- <text class="credit-value">{{ formatNumber(tokenBalance.packRemaining ?? 0) }}</text>
- <text class="credit-sub" v-if="tokenBalance.packTokens > 0">/ {{ formatNumber(tokenBalance.packTokens) }} 总量</text>
- <text class="credit-sub" v-else>暂无</text>
- </view>
- </view>
- <!-- 进度条 -->
- <view class="credit-bar-row">
- <view class="credit-bar" :style="{ flex: tokenBalance.subscriptionTokens || 1 }">
- <view class="credit-bar-fill sub" :style="{ width: subscriptionPercent + '%' }"></view>
- </view>
- <view class="credit-bar-gap" />
- <view class="credit-bar" :style="{ flex: tokenBalance.packTokens || 1 }">
- <view class="credit-bar-fill pack" :style="{ width: packPercent + '%' }"></view>
- </view>
- </view>
- </view>
- <!-- 使用统计 -->
- <view class="stats-row" v-if="userStore.memberStatus">
- <view class="stat-item">
- <text class="stat-value">{{ userStore.memberStatus.quota.dailyRemaining }}</text>
- <text class="stat-label">今日次数</text>
- </view>
- <view class="stat-divider" />
- <view class="stat-item">
- <text class="stat-value">{{ userStore.memberStatus.quota.wordLimit === 0 ? '∞' : userStore.memberStatus.quota.wordLimit }}</text>
- <text class="stat-label">字数上限</text>
- </view>
- </view>
- </view>
- <!-- 今日创作概况 -->
- <view class="today-card" v-if="userStore.isLoggedIn">
- <view class="today-header">
- <text class="today-title">今日创作概况</text>
- <text class="today-date">{{ todayDate }}</text>
- </view>
- <view class="today-stats">
- <view class="today-stat-item">
- <text class="today-stat-value">{{ todayStats.audioCount }}</text>
- <text class="today-stat-label">生成音频</text>
- </view>
- <view class="today-stat-divider" />
- <view class="today-stat-item">
- <text class="today-stat-value">{{ todayStats.bookCount }}</text>
- <text class="today-stat-label">生成书籍</text>
- </view>
- <view class="today-stat-divider" />
- <view class="today-stat-item">
- <text class="today-stat-value">{{ todayStats.usageQuota }}</text>
- <text class="today-stat-label">使用额度</text>
- </view>
- </view>
- </view>
- <!-- 最近的创作 -->
- // ═══════════════════════════════════════════════════════════════
- // 文件: my-uniapp-vue3/src/pages/search/index.vue
- // ═══════════════════════════════════════════════════════════════
- <template>
- <view class="page">
- <!-- 搜索栏 -->
- <view class="search-bar">
- <input
- v-model="searchQuery"
- class="search-input"
- placeholder="搜索音频..."
- @confirm="handleSearch"
- />
- <button class="search-btn" @click="handleSearch">搜索</button>
- </view>
- <!-- 骨架屏加载状态 -->
- <scroll-view scroll-y class="results" v-if="loading" :scroll-top="scrollTop">
- <SkeletonList layout="search-result" :count="5" />
- </scroll-view>
- <!-- 搜索结果 -->
- <scroll-view scroll-y class="results" v-else-if="searchResults.length > 0" :scroll-top="scrollTop">
- <view class="result-list">
- <view
- v-for="item in searchResults"
- :key="item.id"
- class="result-item"
- @click="playAudio(item)"
- >
- <view class="result-cover" :style="{ background: getCoverGradient(item.voiceId) }">
- <text class="cover-letter">{{ getTitleLetter(item.title) }}</text>
- <text class="cover-title-small">{{ item.title }}</text>
- </view>
- <view class="result-info">
- <text class="result-title" v-html="highlightText(item.title, searchQuery)"></text>
- <text class="result-desc" v-if="(item as any).description" v-html="highlightText((item as any).description.slice(0, 50) + '...', searchQuery)"></text>
- <text class="result-meta">{{ item.wordCount || 0 }}字</text>
- </view>
- </view>
- </view>
- </scroll-view>
- <!-- 无结果 -->
- <view v-else-if="hasSearched && searchResults.length === 0" class="no-result">
- <text class="no-result-icon">🔍</text>
- <text class="no-result-text">未找到相关音频</text>
- </view>
- <!-- 初始状态:搜索历史 + 热门推荐 -->
- <view v-else class="initial-state">
- <!-- 搜索历史 -->
- <view class="section" v-if="searchHistory.length > 0">
- <view class="section-header">
- <text class="section-title">搜索历史</text>
- <text class="clear-btn" @click="clearHistory">清空</text>
- </view>
- <view class="tag-list">
- <view
- v-for="(item, index) in searchHistory"
- :key="index"
- class="tag history-tag"
- @click="clickHistory(item.keyword)"
- >
- <text class="tag-text">{{ item.keyword }}</text>
- <text class="delete-icon" @click.stop="deleteHistoryItem(item.keyword)">×</text>
- </view>
- </view>
- </view>
- <!-- 热门推荐 -->
- <view class="section" v-if="hotSearches.length > 0">
- <view class="section-header">
- <text class="section-title">🔥 热门推荐</text>
- </view>
- <view class="tag-list">
- <view
- v-for="(item, index) in hotSearches"
- :key="item.id"
- class="tag hot-tag"
- @click="clickHotSearch(item.keyword)"
- >
- <text class="tag-rank">{{ index + 1 }}</text>
- <text class="tag-text">{{ item.keyword }}</text>
- </view>
- </view>
- </view>
- <!-- 空状态提示 -->
- // ═══════════════════════════════════════════════════════════════
- // 文件: my-uniapp-vue3/src/pages/history/index.vue
- // ═══════════════════════════════════════════════════════════════
- <template>
- <view class="page">
- <!-- 顶部搜索栏 -->
- <view class="search-bar">
- <view class="search-inner">
- <text class="search-icon">🔍</text>
- <input
- class="search-input"
- v-model="searchKeyword"
- placeholder="搜索历史记录..."
- @confirm="handleSearch"
- @clear="handleClearSearch"
- />
- <text v-if="searchKeyword" class="clear-btn" @click="handleClearSearch">✕</text>
- </view>
- </view>
- <!-- 编辑栏 -->
- <view class="edit-bar">
- <view v-if="!isEditing" class="edit-btn" @click="isEditing = true">
- <text>编辑</text>
- </view>
- <view v-else class="edit-actions">
- <view class="action-btn" @click="toggleSelectAll">
- <text>{{ isAllSelected ? '取消全选' : '全选' }}</text>
- </view>
- <view class="action-btn download" @click="handleBatchDownload" :disabled="selectedIds.length === 0">
- <text>下载 ({{ selectedIds.length }})</text>
- </view>
- <view class="action-btn delete" @click="handleBatchDelete" :disabled="selectedIds.length === 0">
- <text>删除 ({{ selectedIds.length }})</text>
- </view>
- <view class="action-btn cancel" @click="cancelEdit">
- <text>取消</text>
- </view>
- </view>
- </view>
- <!-- 标签栏 -->
- <view class="tab-bar">
- <scroll-view class="tab-scroll" scroll-x="true" :show-scrollbar="false">
- <view class="tab-list">
- <view
- v-for="tab in tabs"
- :key="tab.id"
- class="tab-item"
- :class="{ active: selectedTab === tab.id }"
- @click="selectTab(tab.id)"
- >
- <text>{{ tab.name }}</text>
- </view>
- </view>
- </scroll-view>
- <view class="group-toggle" @click="groupByBook = !groupByBook">
- <text class="group-toggle-text">{{ groupByBook ? '📋 列表' : '📚 聚合' }}</text>
- </view>
- </view>
- <!-- 音频列表 -->
- <scroll-view scroll-y class="audio-list" @scrolltolower="loadMore" :scroll-top="scrollTop">
- <!-- 骨架屏加载状态 -->
- <SkeletonList v-if="loading && audioList.length === 0" layout="history" :count="6" />
- <!-- 空状态 -->
- <view v-else-if="audioList.length === 0 && !loading" class="empty">
- <text class="empty-icon">📚</text>
- <text class="empty-text">暂无历史记录</text>
- <text class="empty-hint">快去生成第一个音频吧</text>
- </view>
- <!-- 音频列表 -->
- <view v-else class="audio-grid">
- <!-- 分组模式 -->
- <view v-if="groupByBook" v-for="group in displayList" :key="group._id" class="group-card">
- <view class="group-header" @click="goToBookDetail(group.bookId)">
- <text class="group-title">{{ group.bookId ? '📖 书籍 #' + group.bookId : '🎵 其他音频' }}</text>
- <text class="group-count">{{ group.items.length }} 个音频 · {{ formatDuration(group.totalDuration) }}</text>
- </view>
- <view
- v-for="item in group.items"
- :key="item._id"
- class="audio-card group-item"
- :class="{ selected: selectedIds.includes(item._id) }"
- @click="isEditing ? toggleSelect(item._id) : playAudio(item)"
- >
- // ═══════════════════════════════════════════════════════════════
- // 文件: my-uniapp-vue3/src/pages/favorites/index.vue
- // ═══════════════════════════════════════════════════════════════
- <template>
- <view class="page">
- <!-- 顶部导航 -->
- <view class="nav-bar">
- <view class="nav-btn" @click="goBack">
- <text class="nav-icon">←</text>
- </view>
- <text class="nav-title">我的收藏</text>
- <view class="nav-btn" />
- </view>
- <!-- 加载状态 -->
- <view v-if="loading" class="loading">
- <text>加载中...</text>
- </view>
- <!-- 空状态 -->
- <view v-else-if="favorites.length === 0" class="empty">
- <text class="empty-icon">❤️</text>
- <text class="empty-text">暂无收藏</text>
- <text class="empty-hint">去听书页面收藏喜欢的音频吧</text>
- </view>
- <!-- 收藏列表 -->
- <view v-else class="list">
- <view
- v-for="item in favorites"
- :key="item.id"
- class="item"
- @click="playAudio(item.audio)"
- >
- <view class="item-cover">
- <text class="cover-icon">🎵</text>
- </view>
- <view class="item-info">
- <text class="item-title">{{ item.audio?.title || '未知标题' }}</text>
- <text class="item-meta">{{ item.audio?.wordCount || 0 }}字</text>
- </view>
- <view class="item-action" @click.stop="removeFavorite(item.audioId)">
- <text class="action-icon">❤️</text>
- </view>
- </view>
- </view>
- </view>
- </template>
- <script setup lang="ts">
- import { ref, onMounted } from 'vue';
- import { get, del } from '../../utils/request';
- interface FavoriteItem {
- id: number;
- audioId: number;
- createdAt: string;
- audio?: {
- id: number;
- title: string;
- text: string;
- audioUrl: string;
- audioDuration: number;
- wordCount: number;
- };
- }
- const favorites = ref<FavoriteItem[]>([]);
- const loading = ref(true);
- onMounted(async () => {
- await fetchFavorites();
- });
- async function fetchFavorites() {
- loading.value = true;
- try {
- const result = await get<any>('/favorites');
- favorites.value = result || [];
- } catch (error) {
- console.error('获取收藏列表失败:', error);
- } finally {
- loading.value = false;
- }
- }
- async function removeFavorite(audioId: number) {
- try {
- await del(`/favorites/${audioId}`);
- uni.showToast({ title: '已取消收藏', icon: 'success' });
- // 从列表中移除
- favorites.value = favorites.value.filter(item => item.audioId !== audioId);
- // ═══════════════════════════════════════════════════════════════
- // 文件: my-uniapp-vue3/src/pages/login/index.vue
- // ═══════════════════════════════════════════════════════════════
- <template>
- <view class="page">
- <!-- 顶部背景 -->
- <view class="header">
- <text class="title">声工坊</text>
- <text class="subtitle">让文字变成声音</text>
- </view>
- <!-- 登录表单 -->
- <view class="form-card">
- <text class="form-title">手机号登录</text>
- <!-- 手机号输入 -->
- <view class="input-group">
- <text class="input-label">手机号</text>
- <input
- v-model="phone"
- type="number"
- class="input"
- placeholder="请输入手机号"
- maxlength="11"
- />
- </view>
- <!-- 验证码输入 -->
- <view class="input-group">
- <text class="input-label">验证码</text>
- <view class="code-row">
- <input
- v-model="code"
- type="number"
- class="input code-input"
- placeholder="请输入验证码"
- maxlength="6"
- />
- <button
- class="code-btn"
- :disabled="countdown > 0 || !isPhoneValid"
- @click="handleSendCode"
- >
- <text class="code-btn-text">{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}</text>
- </button>
- </view>
- </view>
- <!-- 登录按钮 -->
- <button class="login-btn" :disabled="!canLogin" @click="handleLogin">
- <text class="login-btn-text">登录</text>
- </button>
- <!-- 提示 -->
- <text class="tip">登录即表示同意《用户协议》和《隐私政策》</text>
- </view>
- </view>
- </template>
- <script setup lang="ts">
- import { ref, computed, onUnmounted } from 'vue';
- import { useUserStore } from '../../store/user';
- const userStore = useUserStore();
- // 状态
- const phone = ref('');
- const code = ref('');
- const countdown = ref(0);
- const loading = ref(false);
- let timer: number | null = null;
- // 计算属性
- const isPhoneValid = computed(() => /^1[3-9]\d{9}$/.test(phone.value));
- // 开发环境免密登录:验证码可以为空
- const canLogin = computed(() => isPhoneValid.value && !loading.value);
- // 发送验证码
- async function handleSendCode() {
- if (countdown.value > 0 || !isPhoneValid.value) return;
- try {
- loading.value = true;
- await userStore.sendCode(phone.value);
- uni.showToast({ title: '验证码已发送', icon: 'success' });
- // 开始倒计时
- countdown.value = 60;
- timer = setInterval(() => {
- countdown.value--;
- if (countdown.value <= 0 && timer) {
- clearInterval(timer);
- timer = null;
- }
- }, 1000) as unknown as number;
- // ═══════════════════════════════════════════════════════════════
- // 文件: my-uniapp-vue3/src/pages/settings/index.vue
- // ═══════════════════════════════════════════════════════════════
- <template>
- <view class="page">
- <!-- 顶部导航 -->
- <view class="nav-bar">
- <view class="nav-btn" @click="goBack">
- <text class="nav-icon">←</text>
- </view>
- <text class="nav-title">设置</text>
- <view class="nav-btn" />
- </view>
- <!-- 设置列表 -->
- <scroll-view scroll-y class="settings-list" :scroll-top="scrollTop">
- <!-- 播放设置 -->
- <view class="settings-section">
- <text class="section-title">播放</text>
- <!-- 默认音色 -->
- <view class="settings-item" @click="showVoicePicker = true">
- <text class="item-icon">🎤</text>
- <text class="item-text">默认音色</text>
- <view class="item-value">
- <text class="value-text">{{ getVoiceLabel(preferences.defaultVoiceId) }}</text>
- <text class="item-arrow">›</text>
- </view>
- </view>
- <!-- 默认音量 -->
- <view class="settings-item">
- <text class="item-icon">🔊</text>
- <text class="item-text">默认音量</text>
- <view class="item-value">
- <text class="value-text">{{ preferences.defaultVolume }}%</text>
- <slider
- class="volume-slider"
- :value="preferences.defaultVolume"
- @change="onVolumeChange"
- min="0"
- max="100"
- step="5"
- activeColor="#4F46E5"
- backgroundColor="#E5E7EB"
- block-size="20"
- />
- </view>
- </view>
- <!-- 自动播放下一首 -->
- <view class="settings-item">
- <text class="item-icon">▶️</text>
- <text class="item-text">自动播放下一首</text>
- <switch
- :checked="preferences.autoPlayNext"
- @change="onAutoPlayChange"
- activeColor="#4F46E5"
- />
- </view>
- </view>
- <!-- 主题设置 -->
- <view class="settings-section">
- <text class="section-title">外观</text>
- <view class="settings-item" @click="showThemePicker = true">
- <text class="item-icon">🎨</text>
- <text class="item-text">主题</text>
- <view class="item-value">
- <text class="value-text">{{ themeLabels[theme] }}</text>
- <text class="item-arrow">›</text>
- </view>
- </view>
- </view>
- <!-- 下载设置 -->
- <view class="settings-section">
- <text class="section-title">下载</text>
- <view class="settings-item">
- <text class="item-icon">📶</text>
- <text class="item-text">仅WiFi下载</text>
- <switch
- :checked="preferences.wifiOnlyDownload"
- @change="onWifiOnlyChange"
- activeColor="#4F46E5"
- />
- </view>
- </view>
- <!-- 存储设置 -->
- // ═══════════════════════════════════════════════════════════════
- // 文件: my-uniapp-vue3/src/pages/billing/index.vue
- // ═══════════════════════════════════════════════════════════════
- <template>
- <view class="page">
- <view class="header">
- <text class="title">消费账单</text>
- </view>
- <!-- 汇总卡片 -->
- <view class="summary-card">
- <view class="summary-item">
- <text class="summary-value">¥{{ summary.totalCost }}</text>
- <text class="summary-label">总费用</text>
- </view>
- <view class="summary-divider" />
- <view class="summary-item">
- <text class="summary-value">{{ summary.totalCalls }}</text>
- <text class="summary-label">总调用次数</text>
- </view>
- <view class="summary-divider" />
- <view class="summary-item">
- <text class="summary-value">{{ formatNumber(summary.totalTokens) }}</text>
- <text class="summary-label">总积分消耗</text>
- </view>
- </view>
- <!-- 分类统计 -->
- <view class="type-stats" v-if="summary.byType.length">
- <view class="type-item" v-for="t in summary.byType" :key="t.type">
- <text class="type-name">{{ typeLabel(t.type) }}</text>
- <view class="type-data">
- <text class="type-count">{{ t.count }}次</text>
- <text class="type-cost">¥{{ t.cost.toFixed(4) }}</text>
- </view>
- </view>
- </view>
- <!-- 明细列表 -->
- <view class="section-title">调用明细</view>
- <view class="record-list">
- <view class="record-card" v-for="r in records" :key="r.id">
- <view class="record-top">
- <text :class="['call-badge', r.callType.startsWith('llm') ? 'badge-llm' : 'badge-tts']">
- {{ typeLabel(r.callType) }}
- </text>
- <text class="record-time">{{ formatTime(r.time) }}</text>
- </view>
- <view class="record-info">
- <text class="record-model">{{ r.model }}</text>
- </view>
- <view class="record-tokens" v-if="r.inputTokens || r.outputTokens">
- <text v-if="r.callType.startsWith('llm')">
- in: {{ r.inputTokens }}积分 / out: {{ r.outputTokens }}积分
- </text>
- <text v-else>
- 字符: {{ r.textLen || r.outputTokens }}
- </text>
- </view>
- <view class="record-bottom">
- <text class="record-cost">¥{{ r.cost }}</text>
- <text class="record-dur" v-if="r.duration">{{ (r.duration / 1000).toFixed(1) }}s</text>
- <text :class="['record-status', r.success ? 'status-ok' : 'status-fail']">
- {{ r.success ? '✓' : '✗' }}
- </text>
- </view>
- </view>
- </view>
- <view class="empty" v-if="loading">
- <text>加载中...</text>
- </view>
- <view class="empty" v-else-if="!records.length">
- <text>暂无消费记录</text>
- </view>
- </view>
- </template>
- <script setup lang="ts">
- import { ref, onMounted } from 'vue';
- import { request } from '@/utils/request';
- import { formatNumber } from '@/utils/format';
- interface LogRecord {
- id: number; callType: string; provider: string; model: string;
- textLen: number; inputTokens: number; outputTokens: number;
- cost: number; duration: number; success: boolean; bookId: number; time: string;
- }
- const records = ref<LogRecord[]>([]);
- // ═══════════════════════════════════════════════════════════════
- // 文件: my-uniapp-vue3/src/pages/notifications/index.vue
- // ═══════════════════════════════════════════════════════════════
- <template>
- <view class="page">
- <view class="nav-bar">
- <view class="nav-btn" @click="goBack">
- <text class="nav-icon">←</text>
- </view>
- <text class="nav-title">消息通知</text>
- <view class="nav-btn" @click="markAllRead">
- <text class="read-all">全部已读</text>
- </view>
- </view>
- <scroll-view scroll-y class="content" :scroll-top="scrollTop">
- <view v-if="notifications.length === 0 && !loading" class="empty">
- <text class="empty-icon">🔔</text>
- <text class="empty-text">暂无通知</text>
- </view>
- <view v-else class="notification-list">
- <view
- v-for="item in notifications"
- :key="item.id"
- class="notification-item"
- :class="{ unread: !item.isRead }"
- @click="markAsRead(item)"
- >
- <view class="notification-icon" :class="getIconClass(item.type)">
- <text>{{ getIcon(item.type) }}</text>
- </view>
- <view class="notification-content">
- <text class="notification-title">{{ item.title }}</text>
- <text class="notification-desc">{{ item.content }}</text>
- <text class="notification-time">{{ formatTime(item.createdAt) }}</text>
- </view>
- <view v-if="!item.isRead" class="unread-dot"></view>
- </view>
- </view>
- <view v-if="loading" class="loading">
- <text>加载中...</text>
- </view>
- </scroll-view>
- </view>
- </template>
- <script setup lang="ts">
- import { ref, onMounted } from 'vue';
- import { get, put } from '../../utils/request';
- // scroll-top 用于避免 scrollTop 错误
- const scrollTop = ref(0);
- const notifications = ref<any[]>([]);
- const loading = ref(false);
- function goBack() {
- const pages = getCurrentPages();
- if (pages.length > 1) {
- uni.navigateBack();
- } else {
- uni.switchTab({ url: '/pages/index/index' });
- }
- }
- function getIcon(type: string): string {
- const icons: Record<string, string> = {
- audio_completed: '🎵',
- book_completed: '📖',
- video_completed: '🎬',
- member_expiring: '⚠️',
- member_expired: '❌',
- daily_reminder: '📅',
- };
- return icons[type] || '🔔';
- }
- function getIconClass(type: string): string {
- if (type.includes('completed')) return 'success';
- if (type.includes('expiring')) return 'warning';
- if (type.includes('expired')) return 'error';
- return 'info';
- }
- function formatTime(dateStr: string): string {
- const date = new Date(dateStr);
- const now = new Date();
- const diff = now.getTime() - date.getTime();
- const minutes = Math.floor(diff / (1000 * 60));
- const hours = Math.floor(diff / (1000 * 60 * 60));
- const days = Math.floor(diff / (1000 * 60 * 60 * 24));
- // ═══════════════════════════════════════════════════════════════
- // 文件: my-uniapp-vue3/src/pages/album/index.vue
- // ═══════════════════════════════════════════════════════════════
- <template>
- <view class="page">
- <!-- 顶部导航 -->
- <view class="nav-bar">
- <view class="nav-btn" @click="goBack">
- <text class="nav-icon">←</text>
- </view>
- <text class="nav-title">书籍详情</text>
- <view class="nav-actions" v-if="book">
- <view class="nav-btn" @click="showSharePanel = true">
- <text class="nav-icon">📤</text>
- </view>
- <view class="nav-btn" @click="showRenameBookModal = true">
- <text class="nav-icon">✏️</text>
- </view>
- </view>
- </view>
- <!-- 骨架屏加载状态 -->
- <view v-if="loading && !book" class="skeleton-container">
- <view class="skeleton-cover"></view>
- <view class="skeleton-info">
- <view class="skeleton-title"></view>
- <view class="skeleton-desc"></view>
- </view>
- </view>
- <!-- 书籍信息 -->
- <view v-else-if="book" class="album-header">
- <view class="album-cover" :style="{ background: getCoverGradient() }">
- <text class="cover-letter">{{ getTitleLetter(book?.title || '') }}</text>
- <text class="cover-title-small">{{ book?.title }}</text>
- </view>
- <view class="album-info">
- <text class="album-title">{{ book.title }}</text>
- <text class="album-meta">{{ book.totalChapters }}章 · {{ completedChapters }}章已完成</text>
- <text class="album-desc">{{ book.description || '暂无描述' }}</text>
- </view>
- </view>
- <!-- 操作按钮 -->
- <view v-if="book" class="action-bar">
- <button class="action-btn" :class="{ subscribed: isFavorited }" @click="toggleFavorite">
- <text>{{ isFavorited ? '已收藏' : '收藏' }}</text>
- </button>
- <button class="action-btn primary" @click="playAll">
- <text>播放全部</text>
- </button>
- </view>
- <!-- 章节列表 -->
- <view v-if="!loading" class="audio-section">
- <text class="section-title">章节列表</text>
- <view v-if="chapters.length === 0" class="empty">
- <text class="empty-icon">📖</text>
- <text class="empty-text">暂无章节</text>
- </view>
- <view v-else class="audio-list">
- <view
- v-for="(item, index) in chapters"
- :key="item.id"
- :class="['audio-item', { 'has-audio': item.audioUrl, 'has-video': item.videoUrl }]"
- :style="{ animationDelay: (index * 0.04) + 's' }"
- @click="playChapter(item, index)"
- @longpress="showChapterMenu(item)"
- >
- <view class="audio-index">{{ index + 1 }}</view>
- <view class="audio-content">
- <text class="audio-title">{{ item.title }}</text>
- <text class="audio-meta">
- {{ item.audioUrl ? formatDuration(item.audioDuration) + ' · ' : '' }}
- {{ item.wordCount || 0 }}字
- <span v-if="item.audioUrl" class="audio-tag">🎵</span>
- <span v-if="item.videoUrl" class="video-tag">🎬</span>
- </text>
- </view>
- <view class="audio-actions">
- <!-- 视频播放按钮 -->
- <view v-if="item.videoUrl" class="video-play-btn" @click.stop="playVideo(item)">
- <text class="video-play-icon">▶</text>
- </view>
- <!-- 音频播放按钮 -->
- <view class="audio-play">
- <text class="play-icon">▶</text>
- // ═══════════════════════════════════════════════════════════════
- // 文件: my-uniapp-vue3/src/pages/albums/index.vue
- // ═══════════════════════════════════════════════════════════════
- <template>
- <view class="page">
- <!-- 顶部导航 -->
- <view class="nav-bar">
- <view class="nav-btn" @click="goBack">
- <text class="nav-icon">←</text>
- </view>
- <text class="nav-title">专辑</text>
- <view class="nav-btn" />
- </view>
- <!-- 骨架屏加载状态 -->
- <view v-if="loading && albumList.length === 0" class="skeleton-grid">
- <view v-for="i in 6" :key="i" class="skeleton-card">
- <view class="skeleton-cover"></view>
- <view class="skeleton-info">
- <view class="skeleton-title"></view>
- <view class="skeleton-meta"></view>
- </view>
- </view>
- </view>
- <!-- 专辑列表 - 使用 v-show 避免 scroll-top 初始化的 DOM 未就绪问题 -->
- <scroll-view scroll-y class="album-list" @scrolltolower="loadMore" :style="{ display: (loading && albumList.length === 0) ? 'none' : 'block' }" :scroll-top="scrollTop">
- <view v-if="albumList.length === 0 && !loading" class="empty">
- <text class="empty-icon">📚</text>
- <text class="empty-text">暂无专辑</text>
- <text class="empty-hint">创建你的第一个专辑吧</text>
- </view>
- <view v-else class="album-grid">
- <view
- v-for="item in albumList"
- :key="item.id"
- class="album-card"
- @click="goToAlbumDetail(item.id)"
- >
- <view class="album-cover" :style="{ background: getCoverGradient(item) }">
- <text class="cover-letter">{{ getTitleLetter(item.name) }}</text>
- <text class="cover-title-small">{{ item.name }}</text>
- <view class="album-count">{{ item.subscriberCount || 0 }}人订阅</view>
- </view>
- <view class="album-info">
- <text class="album-title">{{ item.name }}</text>
- <text class="album-desc">{{ item.description || '暂无描述' }}</text>
- </view>
- </view>
- </view>
- <view v-if="loading" class="loading">
- <text>加载中...</text>
- </view>
- <view v-if="!hasMore && albumList.length > 0" class="no-more">
- <text>没有更多了</text>
- </view>
- </scroll-view>
- </view>
- </template>
- <script setup lang="ts">
- import { ref, onMounted } from 'vue';
- import { get } from '../../utils/request';
- import { getBookGradient, getTitleLetter } from '../../composables/useCoverStyle';
- // scroll-top 用于避免 scrollTop 错误
- const scrollTop = ref(0);
- interface Album {
- id: number;
- name: string;
- description?: string;
- coverUrl?: string;
- subscriberCount: number;
- }
- const albumList = ref<Album[]>([]);
- const page = ref(1);
- const pageSize = 20;
- const total = ref(0);
- const loading = ref(false);
- const hasMore = ref(true);
- // 获取封面渐变色
- function getCoverGradient(item: Album): string {
- return getBookGradient(item.id);
- }
- // 返回
- function goBack() {
- const pages = getCurrentPages();
- // ═══════════════════════════════════════════════════════════════
- // 文件: my-uniapp-vue3/src/pages/pixelle-video/index.vue
- // ═══════════════════════════════════════════════════════════════
- <template>
- <view class="container">
- <!-- 顶部导航 -->
- <view class="header">
- <view class="back-btn" @click="goBack">
- <text class="icon">←</text>
- </view>
- <text class="title">AI 视频生成</text>
- <view class="placeholder"></view>
- </view>
- <!-- 服务状态 -->
- <view class="status-bar" :class="serviceStatus">
- <text>{{ statusText }}</text>
- </view>
- <!-- 输入区域 -->
- <view class="input-section">
- <view class="input-group">
- <text class="label">视频主题</text>
- <textarea
- v-model="topic"
- class="textarea"
- placeholder="例如:人工智能的未来发展趋势"
- :disabled="isGenerating"
- ></textarea>
- </view>
- <view class="input-group">
- <text class="label">视频风格</text>
- <view class="style-grid">
- <view
- v-for="style in styles"
- :key="style"
- class="style-item"
- :class="{ active: selectedStyle === style }"
- @click="selectedStyle = style"
- >
- {{ style }}
- </view>
- </view>
- </view>
- <view class="input-group">
- <text class="label">画面比例</text>
- <view class="ratio-group">
- <view
- class="ratio-item"
- :class="{ active: aspectRatio === '9:16' }"
- @click="aspectRatio = '9:16'"
- >
- <text class="ratio-icon">📱</text>
- <text>竖屏 9:16</text>
- </view>
- <view
- class="ratio-item"
- :class="{ active: aspectRatio === '16:9' }"
- @click="aspectRatio = '16:9'"
- >
- <text class="ratio-icon">🖥️</text>
- <text>横屏 16:9</text>
- </view>
- <view
- class="ratio-item"
- :class="{ active: aspectRatio === '1:1' }"
- @click="aspectRatio = '1:1'"
- >
- <text class="ratio-icon">⬜</text>
- <text>方形 1:1</text>
- </view>
- </view>
- </view>
- </view>
- <!-- 生成按钮 -->
- <view class="action-section">
- <button
- class="generate-btn"
- :disabled="!canGenerate || isGenerating"
- @click="generateContent"
- >
- <text v-if="isGenerating">生成中...</text>
- <text v-else>开始生成</text>
- </button>
- </view>
- // ═══════════════════════════════════════════════════════════════
- // 文件: my-uniapp-vue3/src/components/MiniPlayer.vue
- // ═══════════════════════════════════════════════════════════════
- <template>
- <view v-if="showMiniPlayer" class="mini-player" @click="goToPlayer">
- <!-- 封面 -->
- <view class="mini-cover" :style="{ background: coverGradient }">
- <text class="cover-icon">{{ getTitleLetter(audioStore.currentAudio?.title || '') }}</text>
- </view>
- <!-- 信息 -->
- <view class="mini-info">
- <text class="mini-title">{{ audioStore.currentAudio?.title || '未知音频' }}</text>
- <text class="mini-subtitle">{{ formatTime(audioStore.currentTime) }} / {{ formatTime(audioStore.duration) }}</text>
- </view>
- <!-- 控制按钮 -->
- <view class="mini-controls">
- <view class="control-btn" @click.stop="togglePlay">
- <text>{{ audioStore.isPlaying ? '⏸' : '▶' }}</text>
- </view>
- <view class="control-btn" @click.stop="playNext">
- <text>⏭</text>
- </view>
- </view>
- </view>
- </template>
- <script setup lang="ts">
- import { computed } from 'vue';
- import { onShow } from '@dcloudio/uni-app';
- import { useAudioStore } from '../store/audio';
- import { getCoverGradient, getTitleLetter } from '../composables/useCoverStyle';
- const audioStore = useAudioStore();
- const showMiniPlayer = computed(() => {
- // 获取当前页面路径
- const pages = getCurrentPages();
- const currentPage = pages[pages.length - 1];
- const currentPath = currentPage?.route || '';
- // 如果在播放器页面,不显示迷你播放器
- if (currentPath.includes('player')) {
- return false;
- }
- // 如果有当前音频,显示迷你播放器
- return !!audioStore.currentAudio;
- });
- // 封面颜色
- const coverGradient = computed(() => {
- const voiceId = audioStore.currentAudio?.voiceId || 'cherry';
- return getCoverGradient(voiceId);
- });
- // 格式化时间
- function formatTime(seconds: number): string {
- if (!seconds || isNaN(seconds)) return '0:00';
- const mins = Math.floor(seconds / 60);
- const secs = Math.floor(seconds % 60);
- return `${mins}:${secs.toString().padStart(2, '0')}`;
- }
- // 切换播放
- function togglePlay() {
- audioStore.togglePlay();
- }
- // 播放下一首
- function playNext() {
- audioStore.playNext();
- }
- // 跳转到播放器页面
- function goToPlayer() {
- uni.navigateTo({ url: '/pages/player/index' });
- }
- </script>
- <style scoped>
- .mini-player {
- position: fixed;
- bottom: 120rpx;
- left: 16rpx;
- right: 16rpx;
- height: 96rpx;
- background: rgba(255, 255, 255, 0.9);
- backdrop-filter: blur(28px);
- -webkit-backdrop-filter: blur(28px);
- border-radius: 48rpx;
- box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.12), 0 0 0 0.5rpx rgba(0, 0, 0, 0.06);
- display: flex;
- align-items: center;
- padding: 0 16rpx;
- // ═══════════════════════════════════════════════════════════════
- // 文件: my-uniapp-vue3/src/components/AudioDownload.vue
- // ═══════════════════════════════════════════════════════════════
- <template>
- <view class="download-btn" @click="handleDownload">
- <slot>
- <view class="default-btn" :class="{ downloading: downloading }">
- <text class="icon">{{ downloading ? '⏳' : '⬇️' }}</text>
- <text class="text">{{ downloading ? `下载中 ${progress}%` : '下载音频' }}</text>
- <view v-if="downloading" class="progress-bar">
- <view class="progress-fill" :style="{ width: progress + '%' }"></view>
- </view>
- </view>
- </slot>
- </view>
- </template>
- <script setup lang="ts">
- import { ref } from 'vue';
- interface Props {
- url: string;
- filename?: string;
- audioId?: string;
- }
- const props = withDefaults(defineProps<Props>(), {
- filename: 'audio.mp3',
- audioId: '',
- });
- const downloading = ref(false);
- const progress = ref(0);
- const retryCount = ref(0);
- const maxRetries = 2;
- async function handleDownload() {
- if (downloading.value) return;
- try {
- downloading.value = true;
- progress.value = 0;
- retryCount.value = 0;
- await downloadWithRetry();
- } catch (error) {
- console.error('[Download] 下载失败:', error);
- }
- }
- async function downloadWithRetry() {
- while (retryCount.value <= maxRetries) {
- try {
- await executeDownload();
- return; // 下载成功,退出重试循环
- } catch (error) {
- retryCount.value++;
- if (retryCount.value > maxRetries) {
- uni.hideLoading();
- downloading.value = false;
- uni.showModal({
- title: '下载失败',
- content: `下载失败,已重试${maxRetries}次。请检查网络连接后重试。`,
- showCancel: false,
- });
- throw error;
- }
- // 等待一段时间后重试
- await new Promise(resolve => setTimeout(resolve, 1000 * retryCount.value));
- }
- }
- }
- async function executeDownload() {
- return new Promise<void>((resolve, reject) => {
- uni.showLoading({
- title: '下载中...',
- mask: true,
- });
- // 下载文件
- const downloadTask = uni.downloadFile({
- url: props.url,
- success: (res) => {
- if (res.statusCode === 200) {
- // 保存到本地
- uni.saveFile({
- tempFilePath: res.tempFilePath,
- success: (saveRes) => {
- uni.hideLoading();
- downloading.value = false;
- progress.value = 100;
- uni.showToast({
- // ═══════════════════════════════════════════════════════════════
- // 文件: my-uniapp-vue3/src/components/SharePanel.vue
- // ═══════════════════════════════════════════════════════════════
- <template>
- <view v-if="visible" class="share-overlay" @click="close">
- <view class="share-mask" />
- <view class="share-panel" :class="{ 'h5-panel': platform === 'h5' }" @click.stop>
- <!-- 面板头部 -->
- <view class="panel-header">
- <text class="panel-title">分享到</text>
- <view class="panel-close" @click="close">
- <text>✕</text>
- </view>
- </view>
- <!-- 分享渠道列表 -->
- <view class="share-channels">
- <!-- H5 端:浏览器原生分享 -->
- <view v-if="platform === 'h5'" class="channel-grid">
- <view class="channel-item" @click="doWebShare">
- <view class="channel-icon" style="background: linear-gradient(135deg, #667eea, #764ba2)">
- <text class="icon-emoji">📤</text>
- </view>
- <text class="channel-label">分享给好友</text>
- </view>
- <view class="channel-item" @click="doCopyLink">
- <view class="channel-icon" style="background: #6b7280">
- <text class="icon-emoji">🔗</text>
- </view>
- <text class="channel-label">复制链接</text>
- </view>
- <view v-if="showPosterBtn" class="channel-item" @click="$emit('generatePoster')">
- <view class="channel-icon" style="background: linear-gradient(135deg, #f093fb, #f5576c)">
- <text class="icon-emoji">🖼️</text>
- </view>
- <text class="channel-label">生成海报</text>
- </view>
- </view>
- <!-- App 端:原生分享渠道 -->
- <view v-if="platform === 'app'" class="channel-grid">
- <view class="channel-item" @click="doShareToWechat">
- <view class="channel-icon wechat">
- <text class="icon-emoji">💬</text>
- </view>
- <text class="channel-label">微信好友</text>
- </view>
- <view class="channel-item" @click="doShareToTimeline">
- <view class="channel-icon timeline">
- <text class="icon-emoji">🌐</text>
- </view>
- <text class="channel-label">朋友圈</text>
- </view>
- <view class="channel-item" @click="doShareToQQ">
- <view class="channel-icon qq">
- <text class="icon-emoji">🐧</text>
- </view>
- <text class="channel-label">QQ好友</text>
- </view>
- <view class="channel-item" @click="doShareWithSystem">
- <view class="channel-icon" style="background: #374151">
- <text class="icon-emoji">📲</text>
- </view>
- <text class="channel-label">更多</text>
- </view>
- <view class="channel-item" @click="doCopyLink">
- <view class="channel-icon" style="background: #6b7280">
- <text class="icon-emoji">🔗</text>
- </view>
- <text class="channel-label">复制链接</text>
- </view>
- <view v-if="showPosterBtn" class="channel-item" @click="$emit('generatePoster')">
- <view class="channel-icon poster">
- <text class="icon-emoji">🖼️</text>
- </view>
- <text class="channel-label">生成海报</text>
- </view>
- </view>
- <!-- 小程序端:分享消息卡片 + 复制链接 -->
- <view v-if="platform === 'mp-weixin'" class="channel-grid">
- <!-- 小程序分享消息 -->
- <button class="channel-item mp-share-btn" open-type="share" @click="onMiniProgramShare">
- <view class="channel-icon wechat">
- <text class="icon-emoji">💬</text>
- </view>
- // ═══════════════════════════════════════════════════════════════
- // 文件: my-uniapp-vue3/src/components/CreditInsufficientModal.vue
- // ═══════════════════════════════════════════════════════════════
- <template>
- <Teleport to="body">
- <view v-if="visible" class="credit-modal-mask" @click="handleClose">
- <view class="credit-modal-sheet" :class="{ show: visible }" @click.stop>
- <!-- 关闭按钮 -->
- <view class="sheet-handle" @click="handleClose"></view>
- <!-- 图标和标题 -->
- <view class="modal-header">
- <view class="credit-icon">⚡</view>
- <text class="modal-title">积分不足</text>
- <text class="modal-desc" v-if="currentCredits !== null && requiredCredits !== null">
- 当前 {{ currentCredits }} 积分,还差 {{ requiredCredits - currentCredits }}
- </text>
- <text class="modal-desc" v-else>
- 请充值积分继续使用
- </text>
- </view>
- <!-- 最低消费引导 -->
- <view class="entry-point" v-if="lowestPrice">
- <text class="entry-tip">最低 </text>
- <text class="entry-price">¥{{ lowestPrice }}</text>
- <text class="entry-tip"> 即可开始</text>
- </view>
- <!-- 操作按钮 -->
- <view class="action-buttons">
- <!-- VIP订阅 -->
- <view class="action-card vip-action" @click="handleVipSubscribe">
- <view class="action-icon">👑</view>
- <view class="action-content">
- <text class="action-title">开通VIP会员</text>
- <text class="action-desc">每月享更多积分权益</text>
- </view>
- <text class="action-arrow">→</text>
- </view>
- <!-- 购买积分包 -->
- <view class="action-card pack-action" @click="handleBuyPack">
- <view class="action-icon">🎁</view>
- <view class="action-content">
- <text class="action-title">购买积分包</text>
- <text class="action-desc">100分钟 ¥4.8 起</text>
- </view>
- <text class="action-arrow">→</text>
- </view>
- </view>
- <!-- 底部留白 -->
- <view class="sheet-footer"></view>
- </view>
- </view>
- </Teleport>
- </template>
- <script setup lang="ts">
- import { getCreditModalState } from '../composables/useCreditInsufficient';
- const { visible, currentCredits, requiredCredits, lowestPrice } = getCreditModalState();
- function handleClose() {
- visible.value = false;
- }
- function handleVipSubscribe() {
- uni.navigateTo({ url: '/pages/member/index' });
- handleClose();
- }
- function handleBuyPack() {
- uni.navigateTo({ url: '/pages/token-packs/index' });
- handleClose();
- }
- </script>
- <style scoped>
- .credit-modal-mask {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(0, 0, 0, 0.5);
- z-index: 9999;
- display: flex;
- align-items: flex-end;
- justify-content: center;
- opacity: 0;
- transition: opacity 0.25s ease;
- }
- // ═══════════════════════════════════════════════════════════════
- // 文件: my-uniapp-vue3/src/components/GenerationStatusBadge.vue
- // ═══════════════════════════════════════════════════════════════
- <template>
- <view :class="['gen-badge', status]">
- <text class="gen-icon">{{ icon }}</text>
- <text class="gen-label">{{ label }}</text>
- </view>
- </template>
- <script setup lang="ts">
- import { computed } from 'vue';
- const props = withDefaults(defineProps<{
- status: string;
- // 可覆盖默认映射
- customMap?: Record<string, { icon: string; label: string }>;
- }>(), {
- customMap: () => ({}),
- });
- const statusDisplayMap: Record<string, { icon: string; label: string }> = {
- // 统一状态(推荐)
- idle: { icon: '○', label: '未开始' },
- queued: { icon: '⏳', label: '排队中' },
- processing: { icon: '⚡', label: '生成中' },
- retrying: { icon: '🔄', label: '重试中' },
- completed: { icon: '✓', label: '已完成' },
- failed: { icon: '✗', label: '失败' },
- cancelled: { icon: '—', label: '已取消' },
- // 兼容旧状态名
- draft: { icon: '📝', label: '草稿' },
- planning: { icon: '📋', label: '规划中' },
- pending: { icon: '○', label: '待处理' },
- generating: { icon: '⚡', label: '生成中' },
- running: { icon: '⚡', label: '运行中' },
- success: { icon: '✓', label: '成功' },
- paid: { icon: '✓', label: '已支付' },
- refunded: { icon: '↩', label: '已退款' },
- uploading: { icon: '↑', label: '上传中' },
- published: { icon: '🌐', label: '已公开' },
- paused: { icon: '⏸', label: '已暂停' },
- interrupted: { icon: '⏹', label: '已中断' },
- // 线性阶段状态(genStage)- Book级别
- outlining: { icon: '⚡', label: '生成大纲' },
- outline_completed: { icon: '✓', label: '大纲完成' },
- content_generating:{ icon: '⚡', label: '生成内容' },
- content_completed: { icon: '✓', label: '内容完成' },
- audio_generating: { icon: '🎵', label: '生成音频' },
- audio_completed: { icon: '✓', label: '音频完成' },
- video_generating: { icon: '🎬', label: '生成视频' },
- video_completed: { icon: '✓', label: '全部完成' },
- // 线性阶段状态 - Chapter级别(复用部分)
- content_completed_chapter: { icon: '✓', label: '内容完成' },
- audio_generating_chapter: { icon: '⚡', label: '音频生成' },
- audio_completed_chapter: { icon: '✓', label: '音频完成' },
- video_generating_chapter: { icon: '⚡', label: '视频生成' },
- video_completed_chapter: { icon: '✓', label: '已完成' },
- };
- const merged = computed(() => ({ ...statusDisplayMap, ...props.customMap }));
- const display = computed(() => {
- const s = props.status || '';
- return merged.value[s] || { icon: '?', label: s };
- });
- const icon = computed(() => display.value.icon);
- const label = computed(() => display.value.label);
- </script>
- <style scoped>
- .gen-badge {
- display: inline-flex;
- align-items: center;
- gap: 6rpx;
- padding: 6rpx 16rpx;
- border-radius: 20rpx;
- font-size: 22rpx;
- font-weight: 500;
- white-space: nowrap;
- transition: all 0.3s ease;
- user-select: none;
- }
- .gen-icon {
- font-size: 20rpx;
- }
- .gen-label {
- /* 由父级控制文字颜色 */
- }
- ##############################################################################
- # 第二部分:后端源代码(后30页)
- ##############################################################################
- // ═══════════════════════════════════════════════════════════════
- // 文件: server/src/app.ts
- // ═══════════════════════════════════════════════════════════════
- // Auto-deploy test comment
- // 设置进程时区(从.env读取,确保日志和数据库时间一致)
- process.env.TZ = process.env.TZ || 'Asia/Shanghai';
- import Koa from 'koa';
- import cors from '@koa/cors';
- import Router from '@koa/router';
- import bodyParser from '@koa/bodyparser';
- import koaBody from 'koa-body';
- import serve from 'koa-static';
- import mount from 'koa-mount';
- import path from 'path';
- import fs from 'fs';
- import http from 'http';
- import { config } from './config';
- import { connectDatabase } from './models';
- import { errorHandler } from './middleware/errorHandler';
- import { requestLogger } from './services/requestLogger';
- import { httpLogger } from './services/logger.service';
- import { redisService } from './services/redis.service';
- import { ossService } from './services/oss.service';
- import { storageService } from './services/storage.service';
- import { FFmpegProcessor } from './services/ffmpeg.processor';
- import { resumeInterruptedTasks } from './modules/book-generator/book-queue.processor';
- import { startTtsQueue, stopTtsQueue } from './modules/book-generator/tts-queue';
- import { startAudioScanner, stopAudioScanner } from './modules/book-generator/audio-scanner';
- import { queueService } from './services/queue.service';
- import { validateModelsJsonStructure, printValidationReport } from './config/models-validator';
- import { initSentry, sentryErrorHandler } from './services/sentry.service';
- import { xssProtection, sqlInjectionProtection } from './middleware/security';
- import { performanceMonitor, getMetrics } from './middleware/performance';
- import { apiRateLimiter } from './middleware/rate-limiter';
- import logRoutes from './services/log.controller';
- import authRoutes from './modules/auth/auth.controller';
- import ttsRoutes from './modules/tts/tts.controller';
- import memberRoutes from './modules/member/member.controller';
- import shareRoutes from './modules/share/share.controller';
- import playerRoutes from './modules/player/player.controller';
- import favoritesRoutes from './modules/favorites/favorites.controller';
- import preferencesRoutes from './modules/preferences/preferences.controller';
- import searchRoutes from './modules/search/search.controller';
- import categoriesRoutes from './modules/categories/categories.controller';
- import commentsRoutes from './modules/comments/comments.controller';
- import notificationsRoutes from './modules/notifications/notifications.controller';
- import bgmRoutes from './modules/bgm/bgm.controller';
- import audioEditRoutes from './modules/audioedit/audioedit.controller';
- import langGraphRoutes from './modules/book-generator/langgraph-controller';
- import aiGenerateRoutes from './modules/book-generator/ai-generate-controller';
- import albumRoutes from './modules/book-generator/album-controller';
- import albumManagementRoutes from './modules/book-generator/album-management.controller';
- import bookGeneratorRoutes from './modules/book-generator/book-generator.controller';
- import playlistRoutes from './modules/player/playlist.controller';
- import draftsRoutes from './modules/drafts/drafts.controller';
- import videoGeneratorRoutes from './modules/video-generator/video-generator.controller';
- import publishRoutes from './modules/publish/publish.controller';
- import signRoutes from './modules/sign/sign.controller';
- import subscriptionRoutes from './modules/subscription/subscription.controller';
- import paymentRoutes from './modules/payment/payment.controller';
- import historyRoutes from './modules/history/history.controller';
- import historyBatchRoutes from './modules/history/history-batch.controller';
- import feedbackRoutes from './modules/feedback/feedback.controller';
- import queueRoutes from './modules/queue/queue.controller';
- import inviteRoutes from './modules/invite/invite.controller';
- import textToVideoRoutes from './modules/text-to-video/text-to-video.controller';
- import pixelleVideoRoutes from './modules/pixelle-video/pixelle-video.controller';
- import { initializePlans } from './modules/subscription/subscription.service';
- import { initWebSocket } from './services/websocket.service.js';
- const app = new Koa();
- const router = new Router();
- // 创建 HTTP 服务器
- const server = http.createServer(app.callback());
- // 中间件
- app.use(errorHandler);
- app.use(sentryErrorHandler()); // Sentry 错误监控
- app.use(performanceMonitor()); // 性能监控
- app.use(httpLogger); // Winston 日志
- app.use(xssProtection()); // XSS 防护
- app.use(sqlInjectionProtection()); // SQL 注入防护
- app.use(cors({
- origin: '*',
- allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
- // ═══════════════════════════════════════════════════════════════
- // 文件: server/src/config/index.ts
- // ═══════════════════════════════════════════════════════════════
- import dotenv from 'dotenv';
- import path from 'path';
- import fs from 'fs';
- // 使用 process.cwd() 获取项目根目录(server目录)
- const projectRoot = process.cwd();
- dotenv.config({ path: path.resolve(projectRoot, '.env') });
- // 加载模型配置
- const modelsConfig = JSON.parse(fs.readFileSync(path.join(__dirname, 'models.json'), 'utf-8'));
- // 获取所有模型(扁平化)
- function getAllModels() {
- const models: any[] = [];
- for (const [vendorKey, vendor] of Object.entries(modelsConfig.vendors) as [string, any][]) {
- for (const model of vendor.models) {
- models.push({
- ...model,
- vendor: vendorKey,
- vendorName: vendor.name,
- baseUrl: vendor.baseUrl,
- apiKey: vendor.apiKey,
- apiType: vendor.apiType,
- });
- }
- }
- return models;
- }
- // 获取启用的模型
- function getEnabledModels() {
- return getAllModels().filter((m: any) => m.enabled);
- }
- // 根据 ID 获取模型配置
- function getModel(id: string) {
- return getAllModels().find((m: any) => m.id === id);
- }
- // 根据类型获取模型列表 (text, tts, image, video)
- function getModelsByType(type: string) {
- return getAllModels().filter((m: any) => m.input?.includes(type) && m.enabled);
- }
- // 检查模型是否可切换(根据错误类型判断是否需要切换)
- function shouldSwitchModel(error: any): boolean {
- if (!error) return false;
- const message = (error?.message || error?.error?.message || '').toLowerCase();
- const status = error?.status || error?.response?.status || 0;
- // 不可切换的错误:认证/权限/参数问题,换供应商也没用
- const nonSwitchablePatterns = [
- 'invalid api key', 'invalid api-key', 'authentication', 'unauthorized',
- 'invalid token', 'token expired',
- 'permission denied', 'access denied',
- 'invalid request', 'bad request',
- 'invalidparameter', 'invalid_parameter', // TTS API 参数错误(如文本过短)
- ];
- if (nonSwitchablePatterns.some(p => message.includes(p))) return false;
- if (status === 401) return false; // 认证失败
- // 可切换的错误:限流、余额不足、服务不可用、模型不存在
- const switchablePatterns = [
- 'rate limit', 'rate_limit', 'too many requests', '请求过于频繁',
- 'quota', 'balance', 'insufficient', 'usage limit',
- 'model not found', 'model not support', 'does not exist', 'invalid model',
- 'service unavailable', 'bad gateway', 'gateway timeout',
- 'internal server error',
- ];
- if (switchablePatterns.some(p => message.includes(p))) return true;
- // HTTP 状态码判断
- if ([429, 502, 503, 504, 500].includes(status)) return true;
- if (status === 403) return true; // 403 多数是配额/权限,换Key可能有效
- if (status === 404) return true; // 模型不存在,换供应商
- // 状态码文本匹配(兜底)
- if (['429', '502', '503', '504'].some(c => message.includes(c))) return true;
- return false;
- }
- // 获取下一个可用模型(用于自动切换)
- function getNextModel(currentModelId: string, type: string): string | null {
- const models = getModelsByType(type);
- const currentIndex = models.findIndex((m: any) => m.id === currentModelId);
- if (currentIndex === -1 || currentIndex >= models.length - 1) {
- return null; // 没有下一个模型
- }
- return models[currentIndex + 1].id;
- }
- export const config = {
- port: parseInt(process.env.PORT || process.env.SERVER_PORT || '3000', 10),
- // ═══════════════════════════════════════════════════════════════
- // 文件: server/src/models/index.ts
- // ═══════════════════════════════════════════════════════════════
- import { PrismaClient } from '@prisma/client';
- const prisma = new PrismaClient({
- datasources: {
- db: {
- url: process.env.DATABASE_URL,
- },
- },
- });
- export async function connectDatabase(): Promise<void> {
- try {
- await prisma.$connect();
- console.log(`📦 MySQL 连接成功 (${process.env.TZ || 'Asia/Shanghai'})`);
- } catch (error) {
- console.error('📦 MySQL 连接失败:', error);
- throw error;
- }
- }
- export { prisma };
- // ═══════════════════════════════════════════════════════════════
- // 文件: server/src/middleware/auth.ts
- // ═══════════════════════════════════════════════════════════════
- import { Context, Next } from 'koa';
- import jwt from 'jsonwebtoken';
- import { config } from '../config';
- import { UnauthorizedError, AppError } from './errorHandler';
- import { JwtPayload } from '../types';
- import { safeParseInt } from '../utils/safe-parse';
- export async function authMiddleware(ctx: Context, next: Next): Promise<void> {
- // 开发阶段默认跳过认证(除非显式设置 AUTH_ENABLED=true)
- const authEnabled = process.env.AUTH_ENABLED === 'true';
- if (!authEnabled) {
- ctx.state.user = {
- userId: '1',
- phone: 'test',
- memberLevel: 99,
- };
- await next();
- return;
- }
- const authHeader = ctx.get('Authorization');
- if (!authHeader) {
- throw new UnauthorizedError('缺少 Authorization 头');
- }
- const parts = authHeader.split(' ');
- if (parts.length !== 2 || parts[0] !== 'Bearer') {
- throw new UnauthorizedError('Authorization 格式错误');
- }
- const token = parts[1];
- try {
- const secret = config.jwt.secret;
- const payload = jwt.verify(token, secret) as JwtPayload;
- ctx.state.user = payload;
- await next();
- } catch (err: unknown) {
- const error = err as Error;
- if (error.name === 'TokenExpiredError') {
- throw new UnauthorizedError('Token 已过期');
- }
- if (error.name === 'JsonWebTokenError') {
- throw new UnauthorizedError('Token 无效');
- }
- throw new AppError('认证失败', 401, 401);
- }
- }
- // 可选认证(允许未登录访问,自动使用测试用户)
- export async function optionalAuth(ctx: Context, next: Next): Promise<void> {
- const authHeader = ctx.get('Authorization');
- if (authHeader) {
- const parts = authHeader.split(' ');
- if (parts.length === 2 && parts[0] === 'Bearer') {
- try {
- const payload = jwt.verify(parts[1], config.jwt.secret) as JwtPayload;
- ctx.state.user = payload;
- } catch {
- // 忽略错误,使用测试用户
- ctx.state.user = {
- userId: '1',
- phone: 'test',
- memberLevel: 99, // 超级VIP
- };
- }
- }
- } else {
- // 没有认证头,使用测试用户(超级VIP)
- ctx.state.user = {
- userId: '1',
- phone: 'test',
- memberLevel: 99, // 超级VIP
- };
- }
- await next();
- }
- // ═══════════════════════════════════════════════════════════════
- // 文件: server/src/middleware/security.ts
- // ═══════════════════════════════════════════════════════════════
- /**
- * 安全中间件
- * 包含:XSS 防护、SQL 注入防护、敏感数据脱敏
- */
- // XSS 防护
- export function xssProtection() {
- return async (ctx: any, next: any) => {
- // 过滤请求体中的 XSS
- if (ctx.request.body && typeof ctx.request.body === 'object') {
- sanitizeObject(ctx.request.body);
- }
- // 过滤查询参数中的 XSS
- if (ctx.request.query && typeof ctx.request.query === 'object') {
- sanitizeObject(ctx.request.query);
- }
- // 设置安全响应头
- ctx.set('X-XSS-Protection', '1; mode=block');
- ctx.set('X-Content-Type-Options', 'nosniff');
- ctx.set('X-Frame-Options', 'DENY');
- ctx.set('Content-Security-Policy', "default-src 'self'");
- await next();
- };
- }
- /**
- * 递归清理对象中的 XSS
- */
- function sanitizeObject(obj: any): void {
- for (const key in obj) {
- if (typeof obj[key] === 'string') {
- obj[key] = sanitizeString(obj[key]);
- } else if (typeof obj[key] === 'object' && obj[key] !== null) {
- sanitizeObject(obj[key]);
- }
- }
- }
- /**
- * 清理字符串中的 XSS 攻击代码
- */
- function sanitizeString(str: string): string {
- return str
- .replace(/</g, '<')
- .replace(/>/g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''')
- .replace(/\//g, '/')
- .replace(/javascript:/gi, '')
- .replace(/on\w+=/gi, '')
- .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
- .replace(/<iframe[^>]*>[\s\S]*?<\/iframe>/gi, '')
- .replace(/<object[^>]*>[\s\S]*?<\/object>/gi, '')
- .replace(/<embed[^>]*>[\s\S]*?<\/embed>/gi, '');
- }
- // SQL 注入防护
- export function sqlInjectionProtection() {
- return async (ctx: any, next: any) => {
- // ===== DEBUG: 记录所有请求 =====
- if (ctx.path?.includes('/wechat/jsapi')) {
- console.log('[SECURITY-DEBUG] JSAPI请求到达security中间件');
- console.log('[SECURITY-DEBUG] method:', ctx.method);
- console.log('[SECURITY-DEBUG] path:', ctx.path);
- console.log('[SECURITY-DEBUG] query:', JSON.stringify(ctx.request.query));
- console.log('[SECURITY-DEBUG] body:', JSON.stringify(ctx.request.body));
- console.log('[SECURITY-DEBUG] headers[content-type]:', ctx.request.headers['content-type']);
- }
- // 检查请求参数
- const params = {
- ...ctx.request.query,
- ...(ctx.request.body || {}),
- };
- for (const key in params) {
- if (typeof params[key] === 'string') {
- if (detectSQLInjection(params[key])) {
- console.log('[SECURITY-DEBUG] ❌ JSAPI请求被SQL注入拦截, key:', key, 'value:', params[key].substring(0, 100));
- ctx.status = 400;
- ctx.body = {
- code: 400,
- message: '请求包含非法字符',
- };
- return;
- }
- // ═══════════════════════════════════════════════════════════════
- // 文件: server/src/middleware/errorHandler.ts
- // ═══════════════════════════════════════════════════════════════
- import { Context, Next } from 'koa';
- export async function errorHandler(ctx: Context, next: Next): Promise<void> {
- try {
- await next();
- } catch (err: unknown) {
- const error = err as Error & { status?: number; code?: number };
- console.error('❌ Error:', error.message);
- console.error('Stack:', error.stack);
- ctx.status = error.status || 500;
- ctx.body = {
- code: error.code || 500,
- message: error.message || '服务器内部错误',
- data: null,
- };
- // 开发环境返回详细错误
- if (process.env.NODE_ENV === 'development') {
- (ctx.body as Record<string, unknown>).stack = error.stack;
- }
- }
- }
- // 自定义错误类
- export class AppError extends Error {
- public code: number;
- public status: number;
- constructor(message: string, code: number = 500, status: number = 500) {
- super(message);
- this.code = code;
- this.status = status;
- this.name = 'AppError';
- }
- }
- export class UnauthorizedError extends AppError {
- constructor(message: string = '未授权访问') {
- super(message, 401, 401);
- }
- }
- export class ForbiddenError extends AppError {
- constructor(message: string = '禁止访问') {
- super(message, 403, 403);
- }
- }
- export class NotFoundError extends AppError {
- constructor(message: string = '资源不存在') {
- super(message, 404, 404);
- }
- }
- export class BadRequestError extends AppError {
- constructor(message: string = '请求参数错误') {
- super(message, 400, 400);
- }
- }
- export class QuotaExceededError extends AppError {
- constructor(message: string = '使用次数已达上限') {
- super(message, 429, 429);
- }
- }
- // ═══════════════════════════════════════════════════════════════
- // 文件: server/src/middleware/rate-limiter.ts
- // ═══════════════════════════════════════════════════════════════
- import { RateLimiterMemory, RateLimiterRedis } from 'rate-limiter-flexible';
- import { redisService } from '../services/redis.service';
- interface RateLimitOptions {
- points: number; // 限流点数
- duration: number; // 时间窗口(秒)
- blockDuration?: number; // 封锁时间(秒)
- keyGenerator?: (ctx: any) => string; // 自定义键生成器
- }
- // 内存限流器(Redis 不可用时使用)
- const memoryLimiters = new Map<string, RateLimiterMemory>();
- // Redis 限流器
- const redisLimiters = new Map<string, RateLimiterRedis>();
- /**
- * 获取或创建限流器
- */
- function getLimiter(name: string, options: RateLimitOptions) {
- if (redisService.isAvailable()) {
- if (!redisLimiters.has(name)) {
- const limiter = new RateLimiterRedis({
- storeClient: redisService['client'],
- keyPrefix: `rate_limit:${name}:`,
- points: options.points,
- duration: options.duration,
- blockDuration: options.blockDuration || options.duration * 2,
- });
- redisLimiters.set(name, limiter);
- }
- return redisLimiters.get(name)!;
- } else {
- if (!memoryLimiters.has(name)) {
- const limiter = new RateLimiterMemory({
- points: options.points,
- duration: options.duration,
- });
- memoryLimiters.set(name, limiter);
- }
- return memoryLimiters.get(name)!;
- }
- }
- /**
- * 创建限流中间件
- * @param options 限流配置
- */
- export function createRateLimiter(options: RateLimitOptions) {
- const limiter = getLimiter('default', options);
- return async (ctx: any, next: any) => {
- try {
- const key = options.keyGenerator
- ? options.keyGenerator(ctx)
- : ctx.ip;
- await limiter.consume(key, 1);
- return await next();
- } catch (rejRes: any) {
- const retrySecs = Math.round(rejRes.msBeforeNext / 1000) || 1;
- ctx.set('Retry-After', String(retrySecs));
- ctx.status = 429;
- ctx.body = {
- code: 429,
- message: '请求过于频繁,请稍后再试',
- retryAfter: retrySecs,
- };
- }
- };
- }
- /**
- * API 全局限流(每分钟 100 次)
- */
- export const apiRateLimiter = createRateLimiter({
- points: 100,
- duration: 60,
- keyGenerator: (ctx) => `api:${ctx.ip}`,
- });
- /**
- * 登录接口限流(每分钟 5 次)
- */
- export const loginRateLimiter = createRateLimiter({
- points: 5,
- duration: 60,
- blockDuration: 300, // 封锁 5 分钟
- keyGenerator: (ctx) => `login:${ctx.ip}`,
- // ═══════════════════════════════════════════════════════════════
- // 文件: server/src/modules/auth/auth.controller.ts
- // ═══════════════════════════════════════════════════════════════
- import Router from '@koa/router';
- import { Context } from 'koa';
- import * as AuthService from './auth.service';
- import { BadRequestError } from '../../middleware/errorHandler';
- import { authMiddleware } from '../../middleware/auth';
- import { prisma } from '../../models';
- import { safeParseInt } from '../../utils/safe-parse';
- const router = new Router();
- // 发送验证码
- router.post('/send-code', async (ctx: Context) => {
- const { phone } = ctx.request.body as { phone: string };
- if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
- throw new BadRequestError('请输入正确的手机号');
- }
- const code = AuthService.generateSmsCode(phone);
- // 开发环境直接返回验证码
- ctx.body = {
- code: 0,
- message: '验证码发送成功',
- data: {
- phone,
- // 总是返回验证码(方便测试)
- code,
- },
- };
- });
- // 手机号登录
- router.post('/login', async (ctx: Context) => {
- const { phone, code, inviteCode, openid } = ctx.request.body as { phone: string; code?: string; inviteCode?: string; openid?: string };
- if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
- throw new BadRequestError('请输入正确的手机号');
- }
- // 关闭验证码验证(线上线下都关闭)
- if (!code || !/^\d{4,6}$/.test(code)) {
- // 跳过验证码检查,直接登录
- }
- const result = await AuthService.loginWithPhone(phone, code, inviteCode, openid);
- ctx.body = {
- code: 0,
- message: '登录成功',
- data: result,
- };
- });
- // 根据 openid 自动登录(公众号场景)
- router.post('/login-by-openid', async (ctx: Context) => {
- const { openid } = ctx.request.body as { openid: string };
- if (!openid) {
- throw new BadRequestError('openid 不能为空');
- }
- const result = await AuthService.loginWithOpenid(openid);
- if (!result) {
- ctx.body = {
- code: -1,
- message: '该微信未绑定账号,请先登录绑定',
- data: null,
- };
- return;
- }
- ctx.body = {
- code: 0,
- message: '自动登录成功',
- data: result,
- };
- });
- // 获取用户信息
- router.get('/user-info', authMiddleware, async (ctx: Context) => {
- const userId = ctx.state.user.userId;
- const userInfo = await AuthService.getUserInfo(userId);
- ctx.body = {
- code: 0,
- message: 'success',
- data: userInfo,
- };
- });
- // 更新用户信息
- router.put('/user-info', authMiddleware, async (ctx: Context) => {
- const userId = ctx.state.user.userId;
- const uid = safeParseInt(userId);
- const { nickname, avatar } = ctx.request.body as { nickname?: string; avatar?: string };
- const user = await prisma.user.findUnique({ where: { id: uid } });
- // ═══════════════════════════════════════════════════════════════
- // 文件: server/src/modules/auth/auth.service.ts
- // ═══════════════════════════════════════════════════════════════
- import jwt from 'jsonwebtoken';
- import { v4 as uuidv4 } from 'uuid';
- import { config } from '../../config';
- import { prisma } from '../../models';
- import { JwtPayload } from '../../types';
- import { handleInvite } from '../invite/invite.service';
- import { grantSignupBonus } from '../subscription/subscription.service';
- // 验证码存储(生产环境应使用 Redis)
- const smsCodes = new Map<string, { code: string; expireAt: number }>();
- // 生成验证码
- export function generateSmsCode(phone: string): string {
- const code = Math.random().toString().slice(2, 6);
- smsCodes.set(phone, {
- code,
- expireAt: Date.now() + 5 * 60 * 1000, // 5分钟有效
- });
- console.log(`📱 验证码已生成: ${phone} -> ${code}`);
- return code;
- }
- // 验证验证码
- export function verifySmsCode(phone: string, code: string): boolean {
- const stored = smsCodes.get(phone);
- if (!stored) return false;
- if (Date.now() > stored.expireAt) {
- smsCodes.delete(phone);
- return false;
- }
- if (stored.code !== code) return false;
- smsCodes.delete(phone);
- return true;
- }
- // 生成 JWT Token
- export function generateToken(userId: string, phone?: string): string {
- const secret = config.jwt.secret;
- const payload: Omit<JwtPayload, 'iat' | 'exp'> = { userId, phone };
- return jwt.sign(payload, secret, {
- expiresIn: '7d',
- });
- }
- // 手机号登录/注册
- export async function loginWithPhone(phone: string, code?: string, inviteCode?: string, openid?: string): Promise<{
- token: string;
- user: {
- id: string;
- phone: string;
- nickname: string;
- avatar: string;
- memberLevel: number;
- isNewUser: boolean;
- };
- }> {
- // 免密登录:code 为空或为"123456"时直接登录(线上线下都关闭验证)
- const skipVerify = !code || code === '123456';
- if (!skipVerify) {
- // 验证验证码
- const isValid = verifySmsCode(phone, code || '');
- if (!isValid) {
- throw new Error('验证码错误或已过期');
- }
- }
- // 查找或创建用户
- let user = await prisma.user.findFirst({ where: { phone } });
- let isNewUser = false;
- if (!user) {
- user = await prisma.user.create({
- data: {
- phone,
- openid: openid || null, // 首次注册时绑定 openid
- nickname: `用户${phone.slice(-4)}`,
- avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${phone}`,
- memberLevel: 0,
- dailyUsage: 0,
- lastUsageDate: '',
- },
- });
- isNewUser = true;
- // 新用户注册赠送积分
- await grantSignupBonus(user.id);
- // 处理邀请关系
- if (inviteCode) {
- // ═══════════════════════════════════════════════════════════════
- // 文件: server/src/modules/book-generator/book-generator.controller.ts
- // ═══════════════════════════════════════════════════════════════
- /**
- * 书籍生成 - API 路由
- * 包含一键完整生成 API
- */
- import Router from '@koa/router';
- import { Context } from 'koa';
- import { bookStore } from './book-generator.store';
- import {
- createBatchGenerationTask,
- setCancellationFlag,
- GenerationStep
- } from './book-generator.service';
- // 任务存储(用于取消操作)
- const runningTasks = new Map<string, { taskId: string; bookId: string }>();
- const router = new Router();
- /**
- * POST /api/book-generator/books/:id/batch-generate
- * 一键完整生成书籍(内容、音频、合并、生成视频、合并视频)
- */
- router.post('/books/:id/batch-generate', async (ctx: Context) => {
- try {
- const bookId = ctx.params.id as string;
- const body = ctx.request.body as {
- steps?: GenerationStep[];
- };
- // 验证书籍存在
- const book = await bookStore.getById(bookId);
- if (!book) {
- ctx.status = 404;
- ctx.body = { code: 1, message: '书籍不存在' };
- return;
- }
- // 默认执行全部步骤
- const steps: GenerationStep[] = body.steps || [
- 'generate_content',
- 'generate_audio',
- 'merge_audio',
- 'generate_video',
- 'merge_video'
- ];
- // 验证步骤
- const validSteps: GenerationStep[] = [
- 'generate_content',
- 'generate_audio',
- 'merge_audio',
- 'generate_video',
- 'merge_video'
- ];
- for (const step of steps) {
- if (!validSteps.includes(step)) {
- ctx.status = 400;
- ctx.body = { code: 1, message: `无效的步骤: ${step}` };
- return;
- }
- }
- // 检查是否有正在运行的批量生成任务
- const existingTask = runningTasks.get(bookId);
- if (existingTask) {
- ctx.status = 400;
- ctx.body = {
- code: 1,
- message: '书籍已有批量生成任务在运行,请先取消后再试',
- data: { taskId: existingTask.taskId }
- };
- return;
- }
- // 创建批量生成任务
- const { taskId, orchestrator } = await createBatchGenerationTask(bookId, steps);
- // 存储任务信息
- runningTasks.set(bookId, { taskId, bookId });
- // 后台执行任务
- (async () => {
- try {
- const result = await orchestrator.execute();
- console.log(`[BatchGen][${taskId}] 任务完成:`, result);
- // 发送完成消息
- const { pushBatchGenerationProgress } = await import('../../services/websocket.service.js');
- pushBatchGenerationProgress(taskId, 'completed', 100);
- } catch (error: any) {
- console.error(`[BatchGen][${taskId}] 任务异常:`, error);
- // ═══════════════════════════════════════════════════════════════
- // 文件: server/src/modules/book-generator/book-generator.service.ts
- // ═══════════════════════════════════════════════════════════════
- /**
- * 书籍生成编排服务
- * 负责按顺序执行生成步骤并推送进度
- */
- import { bookStore } from './book-generator.store';
- import { pushBatchGenerationProgress } from '../../services/websocket.service.js';
- import { createVideoProjectFromBook, generateVideoForProject } from '../video-generator/video-generator.service';
- import { mergeChapterAudios } from '../player/player.service';
- import { prisma } from '../../models';
- import { advanceChapter, regenerateChapter } from './stage-manager';
- import { estimateBookWords, estimateAudioMinutesFromWords, atomicReserveQuota, releaseQuota, getUserMonthlyCost, AUDIO_BILLING_CONFIG } from '../subscription/subscription.service';
- // 步骤类型
- export type GenerationStep = 'generate_content' | 'generate_audio' | 'merge_audio' | 'generate_video' | 'merge_video';
- // 取消标志
- const cancellationFlags = new Map<string, boolean>();
- /**
- * 设置取消标志
- */
- export function setCancellationFlag(taskId: string): void {
- cancellationFlags.set(taskId, true);
- }
- /**
- * 清除取消标志
- */
- export function clearCancellationFlag(taskId: string): void {
- cancellationFlags.delete(taskId);
- }
- /**
- * 检查是否已取消
- */
- export function isTaskCancelled(taskId: string): boolean {
- return cancellationFlags.get(taskId) === true;
- }
- /**
- * 批量生成编排器
- */
- export class BatchGenerationOrchestrator {
- private taskId: string;
- private bookId: string;
- private steps: GenerationStep[];
- constructor(taskId: string, bookId: string, steps: GenerationStep[]) {
- this.taskId = taskId;
- this.bookId = bookId;
- this.steps = steps;
- }
- /**
- * 推送进度
- */
- private pushProgress(step: string, progress: number, message: string): void {
- pushBatchGenerationProgress(this.taskId, step, progress);
- console.log(`[BatchGen][${this.taskId}] ${step}: ${progress}% - ${message}`);
- }
- /**
- * 检查是否已取消
- */
- private checkCancellation(): void {
- if (isTaskCancelled(this.taskId)) {
- console.log(`[BatchGen][${this.taskId}] 任务已取消`);
- throw new Error('TASK_CANCELLED');
- }
- }
- /**
- * 执行所有步骤
- */
- async execute(): Promise<{ success: boolean; completedSteps: GenerationStep[]; failedStep?: string; error?: string }> {
- const completedSteps: GenerationStep[] = [];
- try {
- // 获取书籍信息
- const book = await bookStore.getById(this.bookId);
- if (!book) {
- return { success: false, completedSteps, failedStep: 'init', error: '书籍不存在' };
- }
- // 执行每个步骤
- for (let i = 0; i < this.steps.length; i++) {
- this.checkCancellation();
- const step = this.steps[i];
- const stepProgress = Math.round((i / this.steps.length) * 100);
- this.pushProgress(step, stepProgress, '开始执行');
- try {
- switch (step) {
- // ═══════════════════════════════════════════════════════════════
- // 文件: server/src/modules/book-generator/langgraph-controller.ts
- // ═══════════════════════════════════════════════════════════════
- /**
- * LangGraph 书籍生成 - API 路由
- * 支持书籍创建时的预估显示
- */
- import Router from '@koa/router';
- import { Context } from 'koa';
- import { langGraphGenerator, getScaleConfig, resolveGenLevel, mapBookTypeToGenLevel } from './index';
- import { bookStore, cancelAudioGeneration } from './book-generator.store';
- import { prisma } from '../../models';
- import { estimateBookWords, estimateAudioMinutesFromWords, checkBookGenerationQuota, checkAudioQuota, atomicReserveQuota, releaseQuota, AUDIO_BILLING_CONFIG } from '../subscription/subscription.service';
- import { optionalAuth } from '../../middleware/auth';
- import { getAllBookTypes, getDetectableTypes, getBookTypeConfig, BOOK_TYPE_CONFIG, DETECTABLE_TYPES } from './book-type-config';
- import { callLLMWithMessages, callLLM, ChatMessage } from '../../services/llm';
- import { cleanLlmShortText, extractJsonFromResponse } from '../../services/llm/response-cleaner';
- import { runWithContext } from '../../services/llm-context';
- import { createVideoProjectFromBook, generateVideoForProject } from '../video-generator/video-generator.service';
- import { mergeChapterAudios } from '../player/player.service';
- import { exec } from 'child_process';
- import { promisify } from 'util';
- import fs from 'fs/promises';
- import path from 'path';
- import { advanceChapter, regenerateChapter } from './stage-manager';
- import { deepPlanBookNode } from './nodes/deep-plan.node';
- import { richOutlineNode } from './nodes/rich-outline.node';
- import { writeChaptersParallelNode } from './nodes/content.node';
- import { continuityEditNode } from './nodes/continuity-edit.node';
- import { GraphState } from './graph';
- const execPromise = promisify(exec);
- // 开发环境测试用户ID
- const TEST_USER_ID = '1';
- const router = new Router();
- /**
- * GET /api/book-generator/langgraph/estimate
- * 获取书籍规模预估信息
- */
- router.get('/estimate', async (ctx: Context) => {
- const { scale, userId, bookType } = ctx.query as { scale?: string; userId?: string; bookType?: string };
- if (!scale) {
- ctx.status = 400;
- ctx.body = { code: 1, message: '请提供书籍规模' };
- return;
- }
- const scaleConfig = getScaleConfig(scale);
- const totalWords = scaleConfig.totalWords;
- const audioMinutes = estimateAudioMinutesFromWords(totalWords);
- // 章节数
- const estimatedChapters = scaleConfig.isShortArticle ? 1 : scaleConfig.chapters;
- // 大纲层级(优先使用书籍类型映射)
- const genLevel = bookType && bookType !== 'auto'
- ? mapBookTypeToGenLevel(bookType)
- : resolveGenLevel(scale);
- const genLevelDescriptions: Record<number, { label: string; desc: string }> = {
- 1: { label: '仅章', desc: '无节和小节,适合短文、小说' },
- 2: { label: '章→节', desc: '有节无小节,适合科普、商业书' },
- 3: { label: '章→节→小节', desc: '完整三级结构,适合教材、技术书' },
- };
- const result: any = {
- scale,
- scaleConfig,
- audioMinutes: {
- min: estimateAudioMinutesFromWords(Math.round(totalWords * 0.8)),
- max: estimateAudioMinutesFromWords(Math.round(totalWords * 1.2)),
- avg: audioMinutes
- },
- estimatedChapters,
- defaultGenLevel: genLevel,
- genLevelInfo: genLevelDescriptions[genLevel] || { label: '章→节', desc: '' },
- };
- // 如果提供了 userId,同时检查用户配额
- if (userId) {
- const quotaCheck = await checkBookGenerationQuota(parseInt(userId), scale);
- result.quotaCheck = quotaCheck;
- }
- ctx.body = {
- code: 0,
- message: 'success',
- data: result
- };
- });
- /**
- // ═══════════════════════════════════════════════════════════════
- // 文件: server/src/modules/book-generator/book-generator.store.ts
- // ═══════════════════════════════════════════════════════════════
- /**
- * 书籍生成模块 - Prisma 数据库存储
- */
- import crypto from 'crypto';
- import { prisma } from '../../models';
- import { Book, BookOutline, Chapter, ChapterGenStage, BookGenStage } from './book-generator.types';
- import { Prisma } from '@prisma/client';
- import { generateAudio } from '../tts/tts.service';
- import { callLLMWithMessages, callLLMWithTools, ChatMessage } from '../../services/llm';
- import { createBookTools } from '../../services/llm/book-tools';
- import { SUBSECTION_CONTENT_SYSTEM_PROMPT } from './prompts/templates';
- import { countWords } from './utils';
- import { cleanThinkingText } from './utils/content-cleaner';
- import { advanceChapter, regenerateChapter, safeTransitionChapter } from './stage-manager';
- import { mergeChapterAudios } from '../player/player.service';
- import { consumeAudioMinutes, canUseTtsProvider } from '../subscription/subscription.service';
- import { runWithContext } from '../../services/llm-context';
- /**
- * 取消书籍所有章节的音频生成(将 pending/processing 任务标记为 cancelled,回退章节阶段)
- */
- export async function cancelAudioGeneration(bookId: string): Promise<{ cancelledCount: number; rolledBackChapters: number[] }> {
- const chapters = await prisma.bookChapter.findMany({
- where: { bookId: Number(bookId) },
- select: { id: true, level: true },
- });
- if (chapters.length === 0) {
- return { cancelledCount: 0, rolledBackChapters: [] };
- }
- const maxLevel = Math.max(...chapters.map(c => c.level || 0));
- const leafChapterIds = chapters.filter(c => c.level === maxLevel).map(c => c.id);
- // 找出所有 pending/processing 状态的 TTS 任务
- const activeTasks = await prisma.ttsTask.findMany({
- where: {
- chapterId: { in: leafChapterIds },
- taskType: 'tts',
- status: { in: ['pending', 'processing'] },
- },
- select: { id: true, chapterId: true },
- });
- if (activeTasks.length === 0) {
- return { cancelledCount: 0, rolledBackChapters: [] };
- }
- const taskIds = activeTasks.map(t => t.id);
- const affectedChapterIds = [...new Set(activeTasks.map(t => t.chapterId))];
- // 批量标记任务为 cancelled
- await prisma.ttsTask.updateMany({
- where: { id: { in: taskIds } },
- data: { status: 'cancelled' },
- });
- // 回退受影响章节的 genStage 到 content_completed
- await prisma.bookChapter.updateMany({
- where: { id: { in: affectedChapterIds }, genStage: 'audio_generating' },
- data: { genStage: 'content_completed' },
- });
- return {
- cancelledCount: taskIds.length,
- rolledBackChapters: affectedChapterIds,
- };
- }
- // ============ 删除书籍 ============
- /**
- * 根据所有章节状态计算书籍阶段
- * 书籍阶段 = 所有章节中最低的阶段(最落后的章节决定了书籍的进度)
- */
- function computeBookGenStage(chapters: { genStage: string }[]): BookGenStage {
- if (chapters.length === 0) return 'draft';
- // 章节阶段顺序(索引越大越"后")
- const stageOrder = ['idle', 'outline_completed', 'content_generating', 'content_completed', 'audio_generating', 'audio_completed', 'video_generating', 'video_completed', 'failed'];
- // 找出最低阶段的索引
- let minIdx = stageOrder.length; // 默认最大
- for (const ch of chapters) {
- const idx = stageOrder.indexOf(ch.genStage);
- if (idx === -1) {
- console.warn(`[BookStore] 未知章节阶段: chapterId=${(ch as any).id}, genStage="${ch.genStage}",跳过该章节`);
- continue;
- }
- if (idx < minIdx) {
- minIdx = idx;
- }
- }
- // ═══════════════════════════════════════════════════════════════
- // 文件: server/src/modules/book-generator/book-generator.types.ts
- // ═══════════════════════════════════════════════════════════════
- /**
- * 书籍生成模块 - 类型定义
- * 定义书籍、章节、任务的数据结构
- */
- // ============ 核心类型 ============
- /** 书籍生成阶段 */
- export type BookGenStage =
- | 'draft'
- | 'outlining'
- | 'outline_completed'
- | 'content_generating'
- | 'content_completed'
- | 'audio_generating'
- | 'audio_completed'
- | 'video_generating'
- | 'video_completed'
- | 'failed';
- /** 章节生成阶段(唯一类型定义,stage-manager.ts 从此文件导入) */
- export type ChapterGenStage =
- | 'idle'
- | 'outline_completed'
- | 'content_generating'
- | 'content_completed'
- | 'audio_generating'
- | 'audio_completed'
- | 'video_generating'
- | 'video_completed'
- | 'failed';
- /** 书籍基础信息 */
- export interface BookBase {
- id: string;
- title: string; // 书名
- subtitle?: string; // 副标题
- description: string; // 书籍描述/用户输入
- targetAudience: string; // 目标受众
- style: string; // 写作风格
- bookScale: string; // 书籍规模:纯数字字符串,如 1000, 2000, 130000, 340000 等
- totalChapters: number; // 总章节数
- estimatedWords: number; // 预估总字数
- genStage?: BookGenStage; // 线性阶段状态
- failedStage?: string; // 失败阶段
- createdAt: Date;
- updatedAt: Date;
- }
- /** 书籍完整信息 */
- export interface Book extends BookBase {
- userId?: number; // 用户ID(用于配额检查)
- progress: number; // 生成进度 0-100
- isPublished: boolean; // 是否已公开
- chapters: Chapter[]; // 章节列表
- outline?: BookOutline; // 书籍大纲
- metadata?: BookMetadata; // 元数据(作者、前言、后记等)
- error?: string; // 错误信息
- bookAnalysis?: string; // AI 书籍规划结果(planBookNode 输出)
- }
- /** 书籍大纲 */
- export interface BookOutline {
- bookType?: 'textbook' | 'novel' | 'popular' | 'essay'; // 书籍类型(AI自主判断)
- mainTheme: string; // 主题主线
- structureLogic: string; // 结构逻辑
- chapters: OutlineChapter[]; // 章节大纲
- }
- /** 章节大纲(规划阶段)*/
- export interface OutlineChapter {
- number: number;
- title: string;
- summary: string; // 章节概述
- keyPoints: string[]; // 核心知识点
- estimatedWords: number; // 预估字数
- stories?: string[]; // 故事/案例
- sections?: { // 节(章下第一级)
- number: number; // 节序号
- title: string;
- summary?: string; // 本节概述
- keyPoints?: string[]; // 本节要点
- estimatedWords?: number; // 本节预估字数
- subsections?: { // 小节(节下第二级)
- number: number; // 小节序号
- title: string;
- summary?: string; // 本小节概述
- // ═══════════════════════════════════════════════════════════════
- // 文件: server/src/modules/book-generator/graph.ts
- // ═══════════════════════════════════════════════════════════════
- /**
- * LangGraph 状态定义和工作流
- */
- import { Annotation, StateGraph, END } from '@langchain/langgraph';
- // ============ 状态定义(借鉴 OpenMAIC Annotation 模式)============
- /**
- * 进度 reducer:只增不减,防止中间步骤回退导致进度丢失
- */
- const maxReducer = (prev: number, update: number) => Math.max(prev, update);
- /**
- * 章节完成数 reducer:累加而非覆盖
- */
- const appendReducer = <T>(prev: T[], update: T | T[] | undefined) => {
- if (!update) return prev;
- const items = Array.isArray(update) ? update : [update];
- return [...prev, ...items];
- };
- export const GraphState = Annotation.Root({
- bookId: Annotation<string>({
- reducer: (_prev, update) => update ?? _prev,
- default: () => '' as string,
- }),
- userId: Annotation<string | number>({
- reducer: (_prev, update) => update ?? _prev,
- default: () => '' as string,
- }),
- topic: Annotation<string>({
- reducer: (_prev, update) => update ?? _prev,
- default: () => '' as string,
- }),
- bookScale: Annotation<string>({
- reducer: (_prev, update) => update ?? _prev,
- default: () => '1000' as string,
- }),
- /**
- * 大纲层级:1=仅章,2=章→节,3=章→节→小节。
- * reducer: update ?? _prev — 仅当 update 非 null/undefined 时才覆盖旧值。
- * 0/false 被视为 falsy 会保留旧值;合法的 genLevel 值是 1/2/3,不受此限制。
- */
- genLevel: Annotation<number>({
- reducer: (_prev, update) => update ?? _prev,
- default: () => 2,
- }),
- /** 用户明确指定的大纲层级(undefined=未指定/自动,1/2/3=用户主动选择)。
- * 用于区分"用户主动选了2"和"系统默认2",防止 AI 规划时误覆盖。 */
- userSpecifiedGenLevel: Annotation<number | undefined>({
- reducer: (_prev, update) => update ?? _prev,
- default: () => undefined as number | undefined,
- }),
- description: Annotation<string>({
- reducer: (_prev, update) => update ?? _prev,
- default: () => '' as string,
- }),
- /** AI 书籍规划结果(planBookNode 输出) */
- bookPlan: Annotation<string | undefined>({
- reducer: (_prev, update) => update ?? _prev,
- default: () => undefined,
- }),
- /** 当前正在处理的章节号 */
- currentChapter: Annotation<number>({
- reducer: maxReducer,
- default: () => 0,
- }),
- /** 已成功完成的章节数(只增不减) */
- completedChapters: Annotation<number[]>({
- reducer: appendReducer,
- default: () => [] as number[],
- }),
- finished: Annotation<boolean>({
- reducer: (_prev, update) => update ?? _prev,
- default: () => false,
- }),
- error: Annotation<string | undefined>({
- reducer: (_prev, update) => update ?? _prev,
- default: () => undefined,
- }),
- /** 生成进度 0-100,只增不减 */
- progress: Annotation<number>({
- reducer: maxReducer,
- default: () => 0,
- // ═══════════════════════════════════════════════════════════════
- // 文件: server/src/modules/book-generator/nodes/content.node.ts
- // ═══════════════════════════════════════════════════════════════
- /**
- * 内容生成节点
- * 为所有叶节点(没有子节点的节点)生成实际内容
- */
- import { GraphState } from '../graph';
- import { bookStore } from '../book-generator.store';
- import { prisma } from '../../../models';
- import { createBookTools } from '../../../services/llm/book-tools';
- import { checkQuotaForWords, markGenerationInterrupted, getGeneratedWordCount } from '../../subscription/subscription.service';
- import { PROGRESS, countWords } from '../utils';
- import { advanceChapter, regenerateChapter } from '../stage-manager';
- import { getBookWordLimit, getWordUpperLimit } from '../book-type-config';
- import { AsyncPool } from '../utils/async-pool';
- import { cleanThinkingText, truncateAtBoundary } from '../utils/content-cleaner';
- import { safeTransitionChapter } from '../stage-manager';
- import { BookConsistencyTracker } from '../utils/content-consistency';
- import { withGlobalLLMConcurrency, getBookConcurrency } from '../utils/llm-concurrency';
- /** 全局术语一致性跟踪器(单例,跨整本书的章节共享) */
- const globalConsistencyTracker = new BookConsistencyTracker();
- /**
- * 为被截断的内容补一个自然结尾句,
- * 避免截断后看起来像没写完。
- */
- function appendNaturalClosure(content: string): string {
- if (!content || content.length < 50) return content;
- const trimmed = content.trimEnd();
- // 已有完整结尾标点的不补
- const naturalEndings = /[。!?.!?)」"”']\s*$/;
- if (naturalEndings.test(trimmed)) return trimmed;
- // 从内容末尾提取关键词,生成一个简短收尾句
- const lastSentences = trimmed.split(/[。!?.!?]/).filter(Boolean);
- const lastTopic = lastSentences.length > 0
- ? lastSentences[lastSentences.length - 1].substring(0, 30).trim()
- : '';
- if (lastTopic.length > 3) {
- return trimmed + `。以上就是关于${lastTopic}的讨论。`;
- }
- return trimmed + '。以上就是本章的主要内容。';
- }
- /**
- * 安全更新父章节状态:仅在父节点下所有子节点都已完成时,才推进父节点状态。
- * 替代原先的 updateMany 批量操作,避免失败子节点污染已完成父节点状态。
- */
- async function safelyCompleteParentChapters(bookIdNum: number): Promise<void> {
- const parents = await prisma.bookChapter.findMany({
- where: { bookId: bookIdNum, level: { in: [1, 2] } },
- select: { id: true, genStage: true },
- });
- const allChildren = await prisma.bookChapter.findMany({
- where: { bookId: bookIdNum, parentId: { in: parents.map(p => p.id) } },
- select: { id: true, genStage: true, parentId: true },
- });
- // 按 parentId 分组
- const childrenByParent = new Map<number, typeof allChildren>();
- for (const child of allChildren) {
- if (!child.parentId) continue;
- if (!childrenByParent.has(child.parentId)) childrenByParent.set(child.parentId, []);
- childrenByParent.get(child.parentId)!.push(child);
- }
- for (const parent of parents) {
- const children = childrenByParent.get(parent.id) || [];
- if (children.length === 0) continue; // 无子节点(单层大纲),跳过
- const allChildrenDone = children.every(
- c => c.genStage === 'content_completed' || c.genStage === 'audio_generating'
- || c.genStage === 'audio_completed' || c.genStage === 'video_generating'
- || c.genStage === 'video_completed'
- );
- if (allChildrenDone && parent.genStage !== 'content_completed') {
- try {
- await safeTransitionChapter(parent.id, parent.genStage as any, 'content_completed');
- } catch (err: any) {
- console.warn(`[Content] 父节点 #${parent.id} 状态转移失败:`, err.message);
- }
- }
- }
- }
- /**
- * 内容生成节点
- * 为所有叶节点(没有子节点的节点)生成实际内容
- */
- // ═══════════════════════════════════════════════════════════════
- // 文件: server/src/modules/book-generator/nodes/plan.node.ts
- // ═══════════════════════════════════════════════════════════════
- /**
- * 书籍规划节点(planBookNode)
- * 在生成任何内容之前,由 AI 像职业作家一样对整本书做详细规划:
- * - 分析书籍类型、目标读者、内容深度
- * - 决定大纲层级(1/2/3层)
- * - 规划写作风格和结构逻辑
- * - 输出完整的写作方案
- *
- * 该节点的输出(planResult)会被存储到 GraphState,供后续节点参考。
- */
- import { GraphState } from '../graph';
- import { bookStore } from '../book-generator.store';
- import { callLLMWithMessages, ChatMessage } from '../../../services/llm';
- import { PROGRESS } from '../utils';
- import { getScaleConfig } from '../book-type-config';
- /**
- * 书籍规划输出接口
- */
- export interface BookPlan {
- /** 最终确定的大纲层级 */
- genLevel: number;
- /** 书籍类型分析 */
- bookTypeAnalysis: string;
- /** 推荐的写作风格 */
- writingStyle: string;
- /** 结构逻辑说明 */
- structureLogic: string;
- /** 内容深度评估 */
- contentDepth: string;
- /** 目标读者分析 */
- targetAudienceAnalysis: string;
- /** 规划理由 */
- reasoning: string;
- }
- /**
- * 从描述中提取明确的字数要求
- * 如 "生成200字文本" → 200
- */
- function extractWordCountFromDescription(description: string): number | null {
- const text = description.toLowerCase();
- // 匹配 "200字"、"500字" 等
- const match = text.match(/(\d+)\s*字/i);
- if (match) {
- const n = parseInt(match[1]);
- return isNaN(n) ? null : n;
- }
- return null;
- }
- /**
- * 构建规划提示词
- */
- function buildPlanPrompt(
- title: string,
- description: string,
- bookScale: string
- ): string {
- const config = getScaleConfig(bookScale);
- const chapters = config?.chapters || 17;
- // 如果 description 里有明确字数要求,优先用它
- const descWordCount = extractWordCountFromDescription(description);
- const totalWords = descWordCount !== null
- ? descWordCount
- : (config?.totalWords || 130000);
- return `你是一位经验丰富的图书策划编辑。请对以下书籍做全面规划。
- ## 书籍信息
- - 书名:${title}
- - 字数规模:约${totalWords}字,约${chapters}章
- - 描述:${description}
- ## 你的职责
- 像职业作家出书前一样,先对整本书做完整的规划分析。
- ## 分析维度
- ### 1. 内容类型分析
- 判断这本书属于以下哪种类型:
- - 教材/学术类:系统教学、理论知识体系完整
- - 技术教程类:有操作步骤、代码、实践指南
- - 小说/文学类:叙事性、故事性内容
- - 商业/经管类:管理、营销、投资、创业
- - 科普/大众类:普及科学知识,通俗易懂
- - 其他类型
- ### 2. 大纲层级决策
- // ═══════════════════════════════════════════════════════════════
- // 文件: server/src/modules/book-generator/nodes/outline.node.ts
- // ═══════════════════════════════════════════════════════════════
- /**
- * 大纲生成节点(集成容错机制)
- */
- import { GraphState } from '../graph';
- import { bookStore } from '../book-generator.store';
- import { callLLMWithMessages } from '../../../services/llm';
- import { parseOutline } from '../parsers/outline.parser';
- import { buildOutlineMessages, SCALE_CHAPTER_RANGE } from '../prompts/builder';
- import { PROGRESS } from '../utils';
- import { callLLMWithRetry, executeNodeWithTimeout, FAULT_TOLERANCE_CONFIG } from '../fault-tolerance';
- import { getChapterRange, calcChapters } from '../book-type-config';
- export async function generateOutlineNode(state: typeof GraphState.State): Promise<Partial<typeof GraphState.State>> {
- console.log('[LangGraph] 生成大纲, bookId:', state.bookId, 'scale:', state.bookScale);
- // 读取前序规划节点的分析结果,用于指导大纲生成
- let bookPlan: any = null;
- if (state.bookPlan) {
- try {
- bookPlan = JSON.parse(state.bookPlan);
- console.log('[LangGraph] 使用规划结果指导大纲:', bookPlan.structureLogic, bookPlan.writingStyle);
- } catch {
- console.warn('[LangGraph] bookPlan 解析失败,忽略');
- }
- }
- try {
- // 使用容错包装器执行AI调用
- const response = await executeNodeWithTimeout(
- state.bookId,
- 'generate_outline',
- async () => {
- return callLLMWithRetry(
- buildOutlineMessages(state.topic, state.bookScale, state.description, bookPlan),
- undefined,
- {
- bookId: state.bookId,
- nodeId: 'generate_outline',
- attempt: 0,
- maxAttempts: FAULT_TOLERANCE_CONFIG.aiRetry.maxRetries,
- }
- );
- },
- FAULT_TOLERANCE_CONFIG.nodeTimeout.generate_outline
- );
- const outline = parseOutline(response);
- if (!outline) throw new Error('大纲解析失败');
- // 校验章节数:允许 ±20% 浮动,超出才截断/补充
- const targetChapters = SCALE_CHAPTER_RANGE[state.bookScale as keyof typeof SCALE_CHAPTER_RANGE];
- if (targetChapters) {
- const { min: minAllowed, max: maxAllowed } = getChapterRange(targetChapters);
- const actual = outline.chapters.length;
- if (actual > maxAllowed) {
- console.warn(`[LangGraph] AI 返回章节数 ${actual} 超出上限 ${maxAllowed},截断`);
- outline.chapters = outline.chapters.slice(0, maxAllowed);
- } else if (actual < minAllowed) {
- console.warn(`[LangGraph] AI 返回章节数 ${actual} 少于下限 ${minAllowed},使用话题相关的默认章节补充`);
- const topicHint = state.topic?.substring(0, 30) || '';
- const perChapterWords = Math.round(parseInt(state.bookScale) / minAllowed);
- const gapTopics = [
- `概述与背景`,
- `核心概念`,
- `深入探究`,
- `应用与实践`,
- `总结与展望`,
- ];
- while (outline.chapters.length < minAllowed) {
- const idx = outline.chapters.length;
- const gapTitle = gapTopics[idx % gapTopics.length];
- outline.chapters.push({
- number: idx + 1,
- title: topicHint ? `第${idx + 1}章 ${topicHint} - ${gapTitle}` : `第${idx + 1}章 ${gapTitle}`,
- summary: `本章围绕"${topicHint || gapTitle}"展开,介绍${gapTitle}相关内容。`,
- keyPoints: [gapTitle, '关键知识点', '实践要点'],
- estimatedWords: perChapterWords,
- });
- }
- }
- // 在范围内(±20%)不做任何处理,让 AI 自己决定
- }
- // 短文类至少1个章节
- if (outline.chapters.length === 0) {
- console.warn('[LangGraph] AI 返回章节数为0,使用话题创建默认章节');
- // ═══════════════════════════════════════════════════════════════
- // 文件: server/src/modules/book-generator/nodes/quality-check.node.ts
- // ═══════════════════════════════════════════════════════════════
- /**
- * 质量校验节点(qualityCheckNode)
- *
- * 在正文内容并行生成完成后,对所有章节做四维质量评估:
- * - 通顺性(fluency)
- * - 逻辑性(logic)
- * - 是否跑题(relevance)
- * - 是否达标(completeness)
- *
- * 输出每个章节的评分和问题列表,决定是否进入重写环节。
- * 与 rewrite.node.ts 组成质量闭环:校验→不通过→重写→再校验
- */
- import { GraphState } from '../graph';
- import { bookStore } from '../book-generator.store';
- import { callLLMWithMessages, ChatMessage } from '../../../services/llm';
- import { callLLMWithRetry, executeNodeWithTimeout, FAULT_TOLERANCE_CONFIG } from '../fault-tolerance';
- import { QUALITY_CHECK_SYSTEM_PROMPT } from '../prompts/templates';
- import { PROGRESS, countWords } from '../utils';
- import { prisma } from '../../../models';
- // ============ 类型定义 ============
- export interface QualityScore {
- fluency: number; // 通顺 0-25
- logic: number; // 逻辑 0-25
- relevance: number; // 跑题 0-25
- completeness: number; // 达标 0-25
- }
- export interface QualityIssue {
- dimension: 'fluency' | 'logic' | 'relevance' | 'completeness';
- severity: 'high' | 'medium' | 'low';
- description: string;
- location: string;
- suggestion: string;
- }
- export interface FailedChapter {
- chapterNumber: number;
- chapterTitle: string;
- scores: QualityScore;
- totalScore: number;
- issues: QualityIssue[];
- rewriteInstructions: string;
- }
- export interface QualityCheckResult {
- overallScore: number;
- overallAssessment: string;
- passedChapters: number[];
- failedChapters: FailedChapter[];
- }
- // ============ 常量 ============
- /** 合格分数线 */
- const PASS_THRESHOLD = 80;
- /** 每批最大发送字符数(避免 token 超限)*/
- const MAX_BATCH_CHARS = 60000;
- // ============ 核心逻辑 ============
- /**
- * 构建单批质量校验消息
- */
- function buildBatchQualityCheckMessages(
- bookTitle: string,
- bookTopic: string,
- batchChapters: Array<{ number: number; title: string; content: string; summary?: string; estimatedWords?: number }>
- ): ChatMessage[] {
- let chaptersText = '';
- for (const ch of batchChapters) {
- const header = `\n## 第${ch.number}章:${ch.title}\n> 概述:${ch.summary || '无'}\n> 预估字数:${ch.estimatedWords || 0} | 实际字数:${countWords(ch.content)}\n\n`;
- const content = ch.content || '(空)';
- chaptersText += header + content;
- }
- return [
- { role: 'system', content: QUALITY_CHECK_SYSTEM_PROMPT },
- {
- role: 'user',
- content: `书名:《${bookTitle}》
- 主题:${bookTopic}
- 以下是 ${batchChapters.length} 个章节的完整内容,请逐一评估质量:
- ${chaptersText}
- 请输出 JSON 格式的质量评估报告。`,
- },
- ];
- }
- /**
- // ═══════════════════════════════════════════════════════════════
- // 文件: server/src/modules/tts/tts.service.ts
- // ═══════════════════════════════════════════════════════════════
- import path from 'path';
- import fs from 'fs';
- import { v4 as uuidv4 } from 'uuid';
- import { config } from '../../config';
- import { prisma } from '../../models';
- import { VoiceParams, Voice } from '../../types';
- import { AudioMerger } from './audio-merger';
- import { aiSummaryService } from './ai-summary.service';
- import { storageService } from '../../services/storage.service';
- import { getTtsRegistry, getAvailableTtsProvider, startTtsHealthCheck } from './provider.registry';
- import { ITtsProvider } from './provider.interface';
- import { CircuitBreakerOpenError } from '../../common/circuit-breaker';
- import { ProviderNode } from '../../common/provider-registry';
- import { ttsLogger } from './tts-logger';
- import axios from 'axios';
- // ============ 统一音色定义(10个固定音色,前端使用)============
- // 前端使用统一 ID,后端根据 Provider 类型映射到真实音色
- const UNIFIED_VOICES: Voice[] = [
- { id: 'voice_01', name: '温柔女声', gender: 'female', description: '柔和温暖,适合情感故事' },
- { id: 'voice_02', name: '磁性男声', gender: 'male', description: '低沉有力,适合悬疑推理' },
- { id: 'voice_03', name: '活泼女声', gender: 'female', description: '清新明亮,适合儿童故事' },
- { id: 'voice_04', name: '知性女声', gender: 'female', description: '知性稳重,适合科普知识' },
- { id: 'voice_05', name: '阳光男声', gender: 'male', description: '阳光活力,适合校园青春' },
- { id: 'voice_06', name: '沧桑男声', gender: 'male', description: '成熟沧桑,适合历史军事' },
- { id: 'voice_07', name: '甜美女声', gender: 'female', description: '甜美可爱,适合爱情都市' },
- { id: 'voice_08', name: '清朗男声', gender: 'male', description: '清朗干练,适合职场商战' },
- { id: 'voice_09', name: '亲切女声', gender: 'female', description: '亲切自然,适合日常叙事' },
- { id: 'voice_10', name: '稚嫩童声', gender: 'female', description: '稚嫩天真,适合童话寓言' },
- ];
- // 统一音色 → 阿里云 真实音色映射
- const ALIYUN_VOICE_MAP: Record<string, string> = {
- voice_01: 'longanyang', voice_02: 'longsanshu_v3', voice_03: 'longhuhu_v3',
- voice_04: 'longyue_v3', voice_05: 'longyichen_v3', voice_06: 'longlaobo_v3',
- voice_07: 'longmiao_v3', voice_08: 'longshuo_v3', voice_09: 'longwan_v3',
- voice_10: 'longhuhu_v3',
- };
- // 统一音色 → Edge-TTS 真实音色映射
- const EDGE_VOICE_MAP: Record<string, string> = {
- voice_01: 'zh-CN-XiaoxiaoNeural', // 温柔女声 → 晓晓
- voice_02: 'zh-CN-YunxiNeural', // 磁性男声 → 云希
- voice_03: 'zh-CN-XiaoyiNeural', // 活泼女声 → 晓依
- voice_04: 'zh-CN-YunyangNeural', // 知性女声 → 云扬(新闻风格)
- voice_05: 'zh-CN-YunjianNeural', // 阳光男声 → 云健
- voice_06: 'zh-CN-YunyangNeural', // 沧桑男声 → 云扬
- voice_07: 'zh-CN-XiaoxiaoNeural', // 甜美女声 → 晓晓
- voice_08: 'zh-CN-YunxiNeural', // 清朗男声 → 云希
- voice_09: 'zh-CN-XiaoyiNeural', // 亲切女声 → 晓依
- voice_10: 'zh-CN-XiaoshuangNeural', // 稚嫩童声 → 晓双(童声)
- };
- function mapToProviderVoice(unifiedVoiceId: string, providerVendor: string): string {
- // Edge-TTS: 使用微软免费音色
- if (providerVendor === 'edge') {
- const mapped = EDGE_VOICE_MAP[unifiedVoiceId];
- if (mapped) return mapped;
- console.warn(`⚠️ [VoiceMap] Edge 未识别的音色ID: "${unifiedVoiceId}",降级使用默认音色`);
- return 'zh-CN-XiaoxiaoNeural';
- }
- // 默认: 阿里云百炼 CosyVoice
- const mapped = ALIYUN_VOICE_MAP[unifiedVoiceId];
- if (mapped) return mapped;
- // 不在映射表中(如遗留的 'cherry' 等旧 MiniMax 音色)→ 用 CosyVoice 默认音色
- console.warn(`⚠️ [VoiceMap] 未识别的音色ID: "${unifiedVoiceId}",降级使用默认音色 longyingling_v3`);
- return 'longyingling_v3';
- }
- // ============ Aliyun Instruct 情感/场景控制 ============
- // 后期优化功能,暂不启用。启用时改为 true
- const INSTRUCT_ENABLED = false;
- interface EmotionScene {
- emotion: string; // neutral | fearful | angry | sad | surprised | happy | disgusted
- scene: string; // 闲聊互动 | 新闻播报 | 广告促销 | 比赛解说 | 一些儿童内容解说 | 语音导航 | 脱口秀表演
- }
- // 情感关键词库
- const EMOTION_KEYWORDS: { emotion: string; keywords: string[] }[] = [
- { emotion: 'fearful', keywords: ['恐怖', '可怕', '惊悚', '恐惧', '阴森', '黑暗', '鬼', '死亡', '谋杀', '悬疑', '危险', '深渊', '噩梦'] },
- { emotion: 'sad', keywords: ['悲伤', '难过', '哭泣', '眼泪', '心痛', '遗憾', '孤独', '寂寞', '失落', '离别', '思念', '哀伤', '去世', '失去'] },
- { emotion: 'angry', keywords: ['愤怒', '生气', '怒火', '仇恨', '战斗', '厮杀', '复仇', '战争', '侵略', '暴怒'] },
- { emotion: 'happy', keywords: ['快乐', '开心', '幸福', '欢笑', '庆祝', '美好', '甜蜜', '温暖', '阳光', '喜悦', '高兴', '浪漫', '恋爱', '美好'] },
- { emotion: 'surprised', keywords: ['惊奇', '惊喜', '意外', '奇迹', '神奇', '魔法', '童话', '幻想', '奇妙'] },
- { emotion: 'disgusted', keywords: ['恶心', '厌恶', '肮脏', '丑陋', '卑鄙'] },
- ];
- // ═══════════════════════════════════════════════════════════════
- // 文件: server/src/modules/player/player.controller.ts
- // ═══════════════════════════════════════════════════════════════
- import Router from '@koa/router';
- import { Context } from 'koa';
- import * as PlayerService from './player.service';
- import { BadRequestError } from '../../middleware/errorHandler';
- import { optionalAuth } from '../../middleware/auth';
- import { prisma } from '../../models';
- // 测试用户ID(开发环境使用)
- const TEST_USER_ID = '1';
- const router = new Router();
- // 获取播放进度列表
- router.get('/progress', optionalAuth, async (ctx: Context) => {
- // 开发环境使用测试用户ID
- const userId = ctx.state.user?.userId || TEST_USER_ID;
- const { audioId } = ctx.query as { audioId?: string };
- const records = await PlayerService.getPlayProgress(
- userId,
- audioId ? parseInt(audioId) : undefined
- );
- ctx.body = {
- code: 0,
- message: 'success',
- data: records,
- };
- });
- // 保存播放进度
- router.post('/progress', optionalAuth, async (ctx: Context) => {
- // 开发环境使用测试用户ID
- const userId = ctx.state.user?.userId || TEST_USER_ID;
- const body = ctx.request.body as {
- audioId: number;
- progress: number;
- duration: number;
- };
- const { audioId, progress, duration } = body;
- if (!audioId || typeof progress !== 'number' || typeof duration !== 'number') {
- throw new BadRequestError('参数错误');
- }
- const record = await PlayerService.savePlayProgress(userId, audioId, progress, duration);
- ctx.body = {
- code: 0,
- message: 'success',
- data: record,
- };
- });
- // 更新播放进度 (在 DELETE 路由之前定义)
- router.put('/progress/:audioId', optionalAuth, async (ctx: Context) => {
- // 开发环境使用测试用户ID
- const userId = ctx.state.user?.userId || TEST_USER_ID;
- const audioId = parseInt(ctx.params.audioId as string);
- const body = ctx.request.body as { progress: number; duration?: number };
- const { progress, duration } = body;
- if (typeof progress !== 'number') {
- throw new BadRequestError('进度参数错误');
- }
- const record = await PlayerService.updatePlayProgress(userId, audioId, progress, duration);
- ctx.body = {
- code: 0,
- message: 'success',
- data: record,
- };
- });
- // 删除播放记录
- router.delete('/progress/:audioId', optionalAuth, async (ctx: Context) => {
- // 开发环境使用测试用户ID
- const userId = ctx.state.user?.userId || TEST_USER_ID;
- const audioId = parseInt(ctx.params.audioId as string);
- await PlayerService.deletePlayRecord(userId, audioId);
- ctx.body = {
- code: 0,
- message: '删除成功',
- };
- });
- // 批量删除播放记录
- router.delete('/progress/batch', optionalAuth, async (ctx: Context) => {
- const userId = ctx.state.user?.userId || TEST_USER_ID;
- const { audioIds } = ctx.request.body as { audioIds: number[] };
- if (!audioIds || !Array.isArray(audioIds)) {
- throw new BadRequestError('参数错误');
- }
- for (const audioId of audioIds) {
- // ═══════════════════════════════════════════════════════════════
- // 文件: server/src/modules/member/member.controller.ts
- // ═══════════════════════════════════════════════════════════════
- import Router from '@koa/router';
- import { Context } from 'koa';
- import * as MemberService from './member.service';
- import { BadRequestError, NotFoundError } from '../../middleware/errorHandler';
- import { authMiddleware } from '../../middleware/auth';
- const router = new Router();
- // 获取会员权益信息
- router.get('/benefits', async (ctx: Context) => {
- const benefits = MemberService.getMemberBenefits();
- ctx.body = {
- code: 0,
- message: 'success',
- data: benefits,
- };
- });
- // 获取用户会员信息(兼容前端 /api/member/info)
- router.get('/info', authMiddleware, async (ctx: Context) => {
- const userId = ctx.state.user.userId;
- const status = await MemberService.getMemberStatus(userId);
- ctx.body = {
- code: 0,
- message: 'success',
- data: status,
- };
- });
- // 获取用户会员状态
- router.get('/status', authMiddleware, async (ctx: Context) => {
- const userId = ctx.state.user.userId;
- const status = await MemberService.getMemberStatus(userId);
- ctx.body = {
- code: 0,
- message: 'success',
- data: status,
- };
- });
- // 创建订单
- router.post('/order', authMiddleware, async (ctx: Context) => {
- const userId = ctx.state.user.userId;
- const { productType } = ctx.request.body as { productType: 'monthly' | 'yearly' };
- if (!['monthly', 'yearly'].includes(productType)) {
- throw new BadRequestError('无效的产品类型');
- }
- const result = await MemberService.createOrder(userId, productType);
- ctx.body = {
- code: 0,
- message: '订单创建成功',
- data: result,
- };
- });
- // 模拟支付(仅开发环境)
- router.post('/pay/mock', authMiddleware, async (ctx: Context) => {
- if (process.env.NODE_ENV === 'production') {
- throw new BadRequestError('生产环境不可用');
- }
- const userId = ctx.state.user.userId;
- const { orderNo } = ctx.request.body as { orderNo: string };
- if (!orderNo) {
- throw new BadRequestError('订单号不能为空');
- }
- const result = await MemberService.mockPaymentSuccess(orderNo, userId);
- ctx.body = {
- code: 0,
- message: '支付成功',
- data: result,
- };
- });
- // 获取订单列表
- router.get('/orders', authMiddleware, async (ctx: Context) => {
- const userId = ctx.state.user.userId;
- const { page = 1, pageSize = 10 } = ctx.query as { page?: string; pageSize?: string };
- const result = await MemberService.getOrders(
- userId,
- Number(page) || 1,
- Number(pageSize) || 10
- );
- ctx.body = {
- code: 0,
- message: 'success',
- data: result,
- };
- // ═══════════════════════════════════════════════════════════════
- // 文件: server/src/modules/subscription/subscription.controller.ts
- // ═══════════════════════════════════════════════════════════════
- import Router from '@koa/router';
- import { Context } from 'koa';
- import * as SubscriptionService from './subscription.service';
- import { BadRequestError } from '../../middleware/errorHandler';
- import { authMiddleware, optionalAuth } from '../../middleware/auth';
- import { prisma } from '../../models';
- const router = new Router();
- // 获取所有套餐列表
- router.get('/plans', async (ctx: Context) => {
- const plans = await SubscriptionService.getPlans();
- ctx.body = {
- code: 0,
- message: 'success',
- data: { plans }
- };
- });
- // 获取单个套餐详情
- router.get('/plans/:id', async (ctx: Context) => {
- const planId = parseInt(ctx.params.id);
- const plan = await SubscriptionService.getPlanById(planId);
- ctx.body = {
- code: 0,
- message: 'success',
- data: { plan }
- };
- });
- // 获取用户订阅信息
- router.get('/subscription', authMiddleware, async (ctx: Context) => {
- const userId = parseInt(ctx.state.user.userId);
- const subscription = await SubscriptionService.getUserSubscription(userId);
- ctx.body = {
- code: 0,
- message: 'success',
- data: { subscription }
- };
- });
- // 获取用户Token余额
- router.get('/balance', authMiddleware, async (ctx: Context) => {
- const userId = parseInt(ctx.state.user.userId);
- const balance = await SubscriptionService.getUserTokenBalance(userId);
- ctx.body = {
- code: 0,
- message: 'success',
- data: balance
- };
- });
- // 获取Token使用记录
- router.get('/usage', authMiddleware, async (ctx: Context) => {
- const userId = parseInt(ctx.state.user.userId);
- const { page = '1', pageSize = '20' } = ctx.query as { page?: string; pageSize?: string };
- const result = await SubscriptionService.getTokenUsageList(
- userId,
- Number(page) || 1,
- Number(pageSize) || 20
- );
- ctx.body = {
- code: 0,
- message: 'success',
- data: result
- };
- });
- // 获取用户配额(兼容旧接口)
- router.get('/quota', authMiddleware, async (ctx: Context) => {
- const userId = parseInt(ctx.state.user.userId);
- const quota = await SubscriptionService.getUserQuota(userId);
- ctx.body = {
- code: 0,
- message: 'success',
- data: quota
- };
- });
- // 检查配额
- router.post('/check-quota', authMiddleware, async (ctx: Context) => {
- const userId = parseInt(ctx.state.user.userId);
- const { tokens } = ctx.request.body as { tokens: number };
- if (!tokens || tokens <= 0) {
- throw new BadRequestError('请提供正确的Token数量');
- }
- const result = await SubscriptionService.checkQuota(userId, tokens);
- ctx.body = {
- // ═══════════════════════════════════════════════════════════════
- // 文件: server/src/modules/payment/payment.controller.ts
- // ═══════════════════════════════════════════════════════════════
- import Router from '@koa/router';
- import { Context } from 'koa';
- import * as PaymentService from './payment.service';
- import { BadRequestError } from '../../middleware/errorHandler';
- import { authMiddleware } from '../../middleware/auth';
- import { prisma } from '../../models';
- import { safeParseInt } from '../../utils/safe-parse';
- const router = new Router();
- // 创建支付订单
- router.post('/create', authMiddleware, async (ctx: Context) => {
- const userId = safeParseInt(ctx.state.user.userId);
- const { planId, paymentMethod, period = 'monthly', returnUrl } = ctx.request.body as {
- planId: number;
- paymentMethod: 'alipay' | 'wechat' | 'mock';
- period?: 'monthly' | 'yearly';
- returnUrl?: string;
- };
- if (!planId) {
- throw new BadRequestError('请选择套餐');
- }
- if (!['alipay', 'wechat', 'mock'].includes(paymentMethod)) {
- throw new BadRequestError('请选择支付方式');
- }
- const result = await PaymentService.createPaymentOrder(userId, planId, paymentMethod, period, returnUrl);
- ctx.body = {
- code: 0,
- message: '订单创建成功',
- data: result
- };
- });
- // 创建 Token 包支付订单
- router.post('/token-packs/create', authMiddleware, async (ctx: Context) => {
- const userId = safeParseInt(ctx.state.user.userId);
- const { quantity, paymentMethod, returnUrl } = ctx.request.body as {
- quantity: number;
- paymentMethod: 'alipay' | 'wechat' | 'mock';
- returnUrl?: string;
- };
- if (!quantity || quantity < 1) {
- throw new BadRequestError('购买数量至少为1');
- }
- if (!['alipay', 'wechat', 'mock'].includes(paymentMethod)) {
- throw new BadRequestError('请选择支付方式');
- }
- const result = await PaymentService.createTokenPackOrder(userId, quantity, paymentMethod, returnUrl);
- ctx.body = {
- code: 0,
- message: '订单创建成功',
- data: result
- };
- });
- // 模拟支付(仅开发环境)
- router.post('/mock', authMiddleware, async (ctx: Context) => {
- if (process.env.NODE_ENV === 'production') {
- throw new BadRequestError('生产环境不可用');
- }
- const userId = safeParseInt(ctx.state.user.userId);
- const { orderNo } = ctx.request.body as { orderNo: string };
- if (!orderNo) {
- throw new BadRequestError('订单号不能为空');
- }
- const result = await PaymentService.mockPaymentSuccess(orderNo, userId);
- ctx.body = {
- code: 0,
- message: result.message,
- data: result
- };
- });
- // 支付宝异步通知回调
- router.post('/alipay/notify', async (ctx: Context) => {
- const params = ctx.request.body as Record<string, string>;
- console.log('[Alipay Notify] 收到异步通知:', params);
- // 验证签名
- const signVerified = PaymentService.verifyAlipaySign(params);
- if (!signVerified) {
- console.error('[Alipay Notify] 签名验证失败');
- ctx.status = 400;
- ctx.body = 'fail';
- return;
- }
- // ═══════════════════════════════════════════════════════════════
- // 文件: server/src/modules/favorites/favorites.controller.ts
- // ═══════════════════════════════════════════════════════════════
- import Router from '@koa/router';
- import { Context } from 'koa';
- import * as FavoritesService from './favorites.service';
- import { BadRequestError } from '../../middleware/errorHandler';
- import { optionalAuth } from '../../middleware/auth';
- // 测试用户ID(开发环境使用)
- const TEST_USER_ID = '1';
- const router = new Router();
- // 获取收藏列表(兼容前端 /api/favorites/list)
- router.get('/list', optionalAuth, async (ctx: Context) => {
- // 开发环境使用测试用户ID
- const userId = ctx.state.user?.userId || TEST_USER_ID;
- const favorites = await FavoritesService.getFavorites(userId);
- ctx.body = {
- code: 0,
- message: 'success',
- data: favorites,
- };
- });
- // 获取收藏列表
- router.get('/', optionalAuth, async (ctx: Context) => {
- // 开发环境使用测试用户ID
- const userId = ctx.state.user?.userId || TEST_USER_ID;
- const favorites = await FavoritesService.getFavorites(userId);
- ctx.body = {
- code: 0,
- message: 'success',
- data: favorites,
- };
- });
- // 添加收藏
- router.post('/', optionalAuth, async (ctx: Context) => {
- // 开发环境使用测试用户ID
- const userId = ctx.state.user?.userId || TEST_USER_ID;
- const body = ctx.request.body as { audioId: number };
- const { audioId } = body;
- if (!audioId) {
- throw new BadRequestError('音频ID不能为空');
- }
- const favorite = await FavoritesService.addFavorite(userId, audioId);
- ctx.body = {
- code: 0,
- message: '收藏成功',
- data: favorite,
- };
- });
- // 取消收藏
- router.delete('/:audioId', optionalAuth, async (ctx: Context) => {
- // 开发环境使用测试用户ID
- const userId = ctx.state.user?.userId || TEST_USER_ID;
- const audioId = parseInt(ctx.params.audioId as string);
- await FavoritesService.removeFavorite(userId, audioId);
- ctx.body = {
- code: 0,
- message: '已取消收藏',
- };
- });
- // 检查是否已收藏
- router.get('/check/:audioId', optionalAuth, async (ctx: Context) => {
- // 开发环境使用测试用户ID
- const userId = ctx.state.user?.userId || TEST_USER_ID;
- const audioId = parseInt(ctx.params.audioId as string);
- const isFavorited = await FavoritesService.isFavorited(userId, audioId);
- ctx.body = {
- code: 0,
- message: 'success',
- data: { isFavorited },
- };
- });
- export default router;
- // ═══════════════════════════════════════════════════════════════
- // 文件: server/src/modules/history/history.controller.ts
- // ═══════════════════════════════════════════════════════════════
- import Router from '@koa/router';
- import { Context } from 'koa';
- import { optionalAuth } from '../../middleware/auth';
- import { prisma } from '../../models';
- const TEST_USER_ID = '1';
- const router = new Router();
- // 获取音频生成历史列表(兼容前端 /api/history/list)
- router.get('/list', optionalAuth, async (ctx: Context) => {
- // 开发环境使用测试用户ID
- const userId = ctx.state.user?.userId || TEST_USER_ID;
- const page = parseInt(ctx.query.page as string) || 1;
- const pageSize = parseInt(ctx.query.pageSize as string) || 20;
- const startDate = ctx.query.startDate as string;
- const where: any = {};
- if (startDate) {
- where.createdAt = { gte: new Date(startDate) };
- }
- const [records, total] = await Promise.all([
- prisma.audioRecord.findMany({
- where,
- orderBy: { createdAt: 'desc' },
- skip: (page - 1) * pageSize,
- take: pageSize,
- }),
- prisma.audioRecord.count({ where }),
- ]);
- const list = records.map((r) => ({
- _id: r.audioId,
- id: r.audioId,
- title: r.title,
- text: r.text || '',
- summary: '',
- tags: [],
- audioUrl: r.audioUrl || '',
- audioDuration: r.audioDuration,
- audioSize: r.audioSize,
- wordCount: r.wordCount,
- voiceId: r.voiceId,
- voiceParams: r.voiceParams ? JSON.parse(r.voiceParams) : { speed: 1, pitch: 0, volume: 50 },
- status: r.status,
- isFavorite: false,
- bookId: r.bookId,
- createdAt: r.createdAt.toISOString(),
- updatedAt: r.updatedAt.toISOString(),
- }));
- ctx.body = {
- code: 0,
- message: 'success',
- data: {
- list,
- total,
- page,
- pageSize,
- totalPages: Math.ceil(total / pageSize),
- },
- };
- });
- // 获取音频生成历史列表
- router.get('/', optionalAuth, async (ctx: Context) => {
- // 开发环境使用测试用户ID
- const userId = ctx.state.user?.userId || TEST_USER_ID;
- const page = parseInt(ctx.query.page as string) || 1;
- const pageSize = parseInt(ctx.query.pageSize as string) || 20;
- const startDate = ctx.query.startDate as string;
- const where: any = {};
- if (startDate) {
- where.createdAt = { gte: new Date(startDate) };
- }
- const [records, total] = await Promise.all([
- prisma.audioRecord.findMany({
- where,
- orderBy: { createdAt: 'desc' },
- skip: (page - 1) * pageSize,
- take: pageSize,
- }),
- prisma.audioRecord.count({ where }),
- ]);
- const list = records.map((r) => ({
- _id: r.audioId,
- id: r.audioId,
- // ═══════════════════════════════════════════════════════════════
- // 文件: server/src/modules/search/search.controller.ts
- // ═══════════════════════════════════════════════════════════════
- import Router from '@koa/router';
- import { searchService } from './search.service';
- const router = new Router();
- /**
- * 搜索音频
- * GET /api/search?q=关键词
- */
- router.get('/', async (ctx) => {
- try {
- const { q, limit } = ctx.query as { q?: string; limit?: string };
- if (!q || q.trim() === '') {
- ctx.body = {
- code: 0,
- message: 'success',
- data: [],
- };
- return;
- }
- const results = await searchService.searchAudio(q, parseInt(limit || '20'));
- ctx.body = {
- code: 0,
- message: 'success',
- data: results,
- };
- } catch (error: any) {
- ctx.body = {
- code: 400,
- message: error.message || '搜索失败',
- };
- }
- });
- /**
- * 获取热门搜索词
- * GET /api/search/hot
- */
- router.get('/hot', async (ctx) => {
- try {
- const { limit } = ctx.query as { limit?: string };
- const hotSearches = await searchService.getHotSearches(parseInt(limit || '10'));
- ctx.body = {
- code: 0,
- message: 'success',
- data: hotSearches,
- };
- } catch (error: any) {
- ctx.body = {
- code: 400,
- message: error.message || '获取热门搜索失败',
- };
- }
- });
- /**
- * 获取搜索历史
- * GET /api/search/history?userId=1
- */
- router.get('/history', async (ctx) => {
- try {
- const { userId, limit } = ctx.query as { userId?: string; limit?: string };
- const userIdNum = parseInt(userId || '0') || 0;
- const history = await searchService.getSearchHistory(userIdNum, parseInt(limit || '5'));
- ctx.body = {
- code: 0,
- message: 'success',
- data: history,
- };
- } catch (error: any) {
- ctx.body = {
- code: 400,
- message: error.message || '获取搜索历史失败',
- };
- }
- });
- /**
- * 保存搜索历史
- * POST /api/search/history
- */
- router.post('/history', async (ctx) => {
- try {
- const { userId, keyword } = ctx.request.body as { userId?: number; keyword?: string };
- if (!keyword || keyword.trim() === '') {
- // ═══════════════════════════════════════════════════════════════
- // 文件: server/src/services/oss.service.ts
- // ═══════════════════════════════════════════════════════════════
- import OSS from 'ali-oss';
- import path from 'path';
- import fs from 'fs';
- interface OSSConfig {
- region: string;
- accessKeyId: string;
- accessKeySecret: string;
- bucket: string;
- endpoint?: string;
- }
- class OSSService {
- private client: OSS;
- private bucket: string;
- private cdnDomain?: string;
- constructor() {
- const config: OSSConfig = {
- region: process.env.OSS_REGION || 'oss-cn-hangzhou',
- accessKeyId: process.env.OSS_ACCESS_KEY_ID || '',
- accessKeySecret: process.env.OSS_ACCESS_KEY_SECRET || '',
- bucket: process.env.OSS_BUCKET_NAME || '',
- endpoint: process.env.OSS_ENDPOINT || 'oss-cn-hangzhou.aliyuncs.com',
- };
- this.client = new OSS(config);
- this.bucket = config.bucket;
- this.cdnDomain = process.env.OSS_CDN_DOMAIN;
- }
- /**
- * 上传文件到 OSS
- * @param localPath 本地文件路径
- * @param objectKey OSS 对象键(路径)
- * @returns OSS 文件 URL
- */
- async uploadFile(localPath: string, objectKey: string): Promise<string> {
- try {
- // 确保 objectKey 格式正确
- const normalizedKey = objectKey.replace(/\\/g, '/').replace(/^\/+/, '');
- const result = await this.client.put(normalizedKey, localPath, {
- headers: {
- 'Content-Type': this.getContentType(normalizedKey),
- },
- });
- console.log(`[OSS] 文件上传成功: ${normalizedKey}`);
- // 返回文件 URL
- return this.getFileUrl(normalizedKey);
- } catch (error) {
- console.error('[OSS] 文件上传失败:', error);
- throw new Error(`OSS 上传失败: ${(error as Error).message}`);
- }
- }
- /**
- * 上传 Buffer 到 OSS
- * @param buffer 文件 Buffer
- * @param objectKey OSS 对象键
- * @param contentType 内容类型
- * @returns OSS 文件 URL
- */
- async uploadBuffer(buffer: Buffer, objectKey: string, contentType?: string): Promise<string> {
- try {
- const normalizedKey = objectKey.replace(/\\/g, '/').replace(/^\/+/, '');
- const result = await this.client.put(normalizedKey, buffer, {
- headers: {
- 'Content-Type': contentType || this.getContentType(normalizedKey),
- },
- });
- console.log(`[OSS] Buffer 上传成功: ${normalizedKey}`);
- return this.getFileUrl(normalizedKey);
- } catch (error) {
- console.error('[OSS] Buffer 上传失败:', error);
- throw new Error(`OSS Buffer 上传失败: ${(error as Error).message}`);
- }
- }
- /**
- * 上传音频文件
- * @param localPath 本地音频文件路径
- * @param audioId 音频 ID
- * @returns OSS 音频 URL
- */
- async uploadAudio(localPath: string, audioId: string): Promise<string> {
- const objectKey = `audio/${audioId}/${path.basename(localPath)}`;
- return this.uploadFile(localPath, objectKey);
- // ═══════════════════════════════════════════════════════════════
- // 文件: server/src/services/queue.service.ts
- // ═══════════════════════════════════════════════════════════════
- /**
- * 队列服务
- *
- * 【架构职责】
- * 队列只负责:排队 + 并发控制
- *
- * 【职责边界】
- * - ✅ 做:任务排队、并发限制、任务分发
- * - ❌ 不做:失败重试、超时管理、业务逻辑
- *
- * 【设计原则】
- * - 单一职责:队列只管排队,不管其他
- * - 失败重试 → 容错层(fault-tolerance.ts)
- * - 超时管理 → AI服务层(llm.service.ts)
- * - 业务逻辑 → 领域层(langgraph-generator.ts)
- */
- import Queue from 'bull';
- import { redisService } from './redis.service';
- import { memoryQueue } from './memory-queue';
- // 任务队列类型
- export enum QueueType {
- AUDIO_GENERATION = 'audio:generation',
- VIDEO_GENERATION = 'video:generation',
- BOOK_GENERATION = 'book:generation',
- EMAIL_SEND = 'email:send',
- }
- // 任务状态
- export enum TaskStatus {
- WAITING = 'waiting',
- ACTIVE = 'active',
- COMPLETED = 'completed',
- FAILED = 'failed',
- DELAYED = 'delayed',
- }
- // 任务数据接口
- interface TaskData {
- userId?: number;
- [key: string]: any;
- }
- // 任务进度回调
- export type ProgressCallback = (progress: number, data?: any) => void;
- class QueueService {
- private queues: Map<string, Queue.Queue> = new Map();
- private progressCallbacks: Map<string, ProgressCallback> = new Map();
- private queueAvailable: boolean = true;
- constructor() {
- // 如果 Redis 不可用,记录警告但不阻止程序运行
- if (!redisService.isAvailable()) {
- console.warn('[Queue] Redis 不可用,任务队列将无法正常工作');
- this.queueAvailable = false;
- }
- }
- /**
- * 检查队列是否可用
- */
- isQueueAvailable(): boolean {
- return this.queueAvailable && redisService.isAvailable();
- }
- /**
- * 获取或创建队列
- * @param queueName 队列名称
- */
- private getQueue(queueName: string): Queue.Queue | null {
- if (!this.isQueueAvailable()) {
- return null;
- }
- if (!this.queues.has(queueName)) {
- try {
- const queue = new Queue(queueName, {
- redis: {
- host: process.env.REDIS_HOST || 'localhost',
- port: parseInt(process.env.REDIS_PORT || '6379'),
- password: process.env.REDIS_PASSWORD || undefined,
- db: parseInt(process.env.REDIS_DB || '0'),
- maxRetriesPerRequest: null,
- },
- defaultJobOptions: {
- removeOnComplete: 100,
- removeOnFail: 50,
- },
- // ═══════════════════════════════════════════════════════════════
- // 文件: server/src/services/websocket.service.ts
- // ═══════════════════════════════════════════════════════════════
- /**
- * WebSocket 服务
- * 用于推送音频/视频生成完成事件
- */
- import { Server as HttpServer } from 'http';
- import WebSocket, { WebSocketServer } from 'ws';
- const wss = new WebSocketServer({ noServer: true });
- // 客户端连接管理
- const clients = new Map<string, WebSocket>();
- // ============ 客户端管理 ============
- /**
- * 注册客户端连接
- */
- export function addClient(clientId: string, ws: WebSocket) {
- clients.set(clientId, ws);
- console.log(`[WS] 客户端连接: ${clientId}, 当前在线: ${clients.size}`);
- }
- /**
- * 移除客户端连接
- */
- export function removeClient(clientId: string) {
- clients.delete(clientId);
- console.log(`[WS] 客户端断开: ${clientId}, 当前在线: ${clients.size}`);
- }
- /**
- * 通过 clientId 发送消息
- */
- export function sendToClient(clientId: string, event: string, data: any): boolean {
- const ws = clients.get(clientId);
- if (!ws || ws.readyState !== WebSocket.OPEN) {
- return false;
- }
- try {
- ws.send(JSON.stringify({ event, data }));
- return true;
- } catch (error) {
- console.error(`[WS] 发送消息失败: ${clientId}`, error);
- return false;
- }
- }
- /**
- * 广播消息到所有客户端
- */
- export function broadcast(event: string, data: any) {
- const message = JSON.stringify({ event, data });
- clients.forEach((ws, clientId) => {
- if (ws.readyState === WebSocket.OPEN) {
- try {
- ws.send(message);
- } catch (error) {
- console.error(`[WS] 广播失败: ${clientId}`, error);
- }
- }
- });
- }
- // ============ 事件推送 ============
- /**
- * 推送音频生成完成事件
- */
- export function pushAudioGenerationComplete(bookId: string, chapterId: number, status: 'completed' | 'failed') {
- const event = 'audio_generation_complete';
- const data = { bookId, chapterId, status };
- console.log(`[WS] 推送 ${event}:`, data);
- // 广播给所有客户端(前端可根据 bookId 过滤)
- broadcast(event, data);
- }
- /**
- * 推送视频生成完成事件
- */
- export function pushVideoGenerationComplete(bookId: string, chapterId: number, status: 'completed' | 'failed') {
- const event = 'video_generation_complete';
- const data = { bookId, chapterId, status };
- console.log(`[WS] 推送 ${event}:`, data);
- broadcast(event, data);
- }
- /**
- * 推送批量生成进度
- */
- export function pushBatchGenerationProgress(taskId: string, step: string, progress: number) {
- const event = 'batch_generation_progress';
- ##############################################################################
- # 统计信息
- ##############################################################################
- # 前端文件数: 30
- # 后端文件数: 30
- # 文档总行数: 4751
|