| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234 |
- 盛果智能应用软件 V1.0 源代码文档
- 软件全称:盛果智能应用软件
- 软件简称:盛果
- 版本号:V1.0
- # 前端源代码
- // ====== 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>
- <!-- 情感检测预览卡片(文本长度≥10时显示) -->
- <view v-if="text.length >= 10" class="emotion-card">
- <view class="emotion-card-header">
- <text class="emotion-card-icon">🎭</text>
- <text class="emotion-card-title">内容分析</text>
- <text v-if="detecting" class="emotion-detecting">检测中...</text>
- </view>
- <!-- 检测成功:显示推荐 -->
- <view v-if="detectedEmotion && !detecting" class="emotion-result">
- <view class="emotion-row">
- <text class="emotion-label">情感</text>
- <text class="emotion-value">{{ detectedEmotionLabel }}</text>
- </view>
- <view v-if="detectedVoiceName" class="emotion-row">
- <text class="emotion-label">推荐音色</text>
- <text class="emotion-value voice-value">{{ detectedVoiceName }}</text>
- </view>
- <view v-if="detectedScene" class="emotion-row">
- <text class="emotion-label">场景</text>
- <text class="emotion-value scene-value">{{ detectedScene }}</text>
- </view>
- <view v-if="detectedIdentity" class="emotion-row">
- <text class="emotion-label">身份</text>
- <text class="emotion-value identity-value">{{ detectedIdentity }}</text>
- </view>
- <view v-if="detectedRole" class="emotion-row">
- <text class="emotion-label">角色</text>
- <text class="emotion-value role-value">{{ detectedRole }}</text>
- </view>
- <!-- 情感覆盖选择器 -->
- <view class="emotion-override">
- <text class="emotion-override-label">手动调整:</text>
- <scroll-view scroll-x class="emotion-scroll">
- <view class="emotion-chips">
- <view
- v-for="emo in emotionOptions"
- :key="emo.key"
- class="emotion-chip"
- :class="{ active: selectedEmotion === emo.key }"
- @click="selectEmotion(emo.key)"
- >
- <text>{{ emo.label }}</text>
- </view>
- </view>
- </scroll-view>
- </view>
- </view>
- <!-- 未检测到特征:仅显示兜底情感 -->
- <view v-if="!detectedEmotion && !detecting && emotionDetectAttempted" class="emotion-result">
- <view class="emotion-row">
- <text class="emotion-label">情感</text>
- <text class="emotion-value emotion-fallback">{{ detectedEmotionLabel || '中性' }}</text>
- </view>
- <view v-if="detectedScene" class="emotion-row">
- <text class="emotion-label">场景</text>
- <text class="emotion-value scene-value">{{ detectedScene }}</text>
- </view>
- <text class="emotion-hint">未匹配到特定内容类型,使用通用音色</text>
- <!-- 情感覆盖选择器 -->
- <view class="emotion-override">
- <text class="emotion-override-label">手动调整:</text>
- <scroll-view scroll-x class="emotion-scroll">
- <view class="emotion-chips">
- <view
- v-for="emo in emotionOptions"
- :key="emo.key"
- class="emotion-chip"
- :class="{ active: selectedEmotion === emo.key }"
- @click="selectEmotion(emo.key)"
- >
- <text>{{ emo.label }}</text>
- </view>
- </view>
- </scroll-view>
- </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>
- <!-- 分类标签栏 -->
- <view class="voice-tabs">
- <scroll-view scroll-x :show-scrollbar="false" class="voice-tabs-scroll">
- <view class="voice-tabs-inner">
- <text
- v-for="(cat, idx) in voiceCategories"
- :key="cat.key"
- class="voice-tab"
- :class="{ active: voiceCategory === cat.key }"
- @click="voiceCategory = cat.key"
- >{{ cat.label }}</text>
- </view>
- </scroll-view>
- </view>
- <!-- 音色网格 -->
- <scroll-view scroll-y class="voice-grid-scroll">
- <view class="voice-card-grid">
- <view
- v-for="voice in filteredVoices"
- :key="voice.id"
- class="voice-card"
- :class="{ active: selectedVoice === voice.id }"
- @click="selectVoiceAndClose(voice.id)"
- >
- <view class="voice-card-icon">
- <text>{{ voice.gender === 'female' ? '👩' : '👨' }}</text>
- </view>
- <text class="voice-card-name">{{ voice.name }}</text>
- <view v-if="selectedVoice === voice.id" class="voice-card-check">✓</view>
- <view class="voice-card-play" @click.stop="previewVoice(voice.id)">
- <text>{{ previewingVoice === voice.id ? '⏹' : '▶' }}</text>
- </view>
- </view>
- </view>
- </scroll-view>
- <!-- 关闭按钮 -->
- <view class="panel-close-bar" @click="showVoicePanel = false">
- <view class="panel-close-bar-line"></view>
- </view>
- </view>
- </view>
- <!-- 参数面板 -->
- <view v-if="showParamsPanel" class="bottom-panel-overlay" @click="showParamsPanel = false">
- <view class="bottom-panel" @click.stop>
- <view class="panel-header">
- <text class="panel-title">参数调节</text>
- <text class="panel-close" @click="showParamsPanel = false">✕</text>
- </view>
- <scroll-view scroll-y class="panel-body">
- <view class="param-item">
- <view class="param-header">
- <text class="param-label">语速</text>
- <text class="param-value">{{ voiceParams.speed.toFixed(1) }}x</text>
- </view>
- <slider
- :value="(voiceParams.speed - 0.5) * 100 / 1.5"
- :min="0" :max="100"
- @changing="(e: any) => voiceParams.speed = Number((0.5 + (e.detail.value / 100) * 1.5).toFixed(1))"
- activeColor="#4F46E5" backgroundColor="#e5e7eb" block-size="20"
- />
- <view class="param-range"><text>0.5x</text><text>2.0x</text></view>
- </view>
- <view class="param-item">
- <view class="param-header">
- <text class="param-label">音调</text>
- <text class="param-value">{{ voiceParams.pitch > 0 ? '+' : '' }}{{ voiceParams.pitch }}</text>
- </view>
- <slider
- :value="(voiceParams.pitch + 500) / 10"
- :min="0" :max="100"
- @changing="(e: any) => voiceParams.pitch = Math.round(e.detail.value * 10 - 500)"
- activeColor="#4F46E5" backgroundColor="#e5e7eb" block-size="20"
- />
- <view class="param-range"><text>-500</text><text>+500</text></view>
- </view>
- <view class="param-item">
- <view class="param-header">
- <text class="param-label">音量</text>
- <text class="param-value">{{ voiceParams.volume }}%</text>
- </view>
- <slider
- :value="voiceParams.volume"
- :min="0" :max="100"
- @changing="(e: any) => voiceParams.volume = e.detail.value"
- activeColor="#4F46E5" backgroundColor="#e5e7eb" block-size="20"
- />
- <view class="param-range"><text>0%</text><text>100%</text></view>
- </view>
- </scroll-view>
- </view>
- </view>
- <!-- 额度用尽弹窗 -->
- <view v-if="showQuotaModal" class="quota-modal" @click="showQuotaModal = false">
- <view class="quota-content" @click.stop>
- <view class="quota-icon">👑</view>
- <text class="quota-title">今日免费额度已用完</text>
- <text class="quota-desc">开通会员解锁更多生成次数</text>
- <view class="quota-actions">
- <button class="quota-btn primary" @click="goToMember"><text>开通会员</text></button>
- <button class="quota-btn secondary" @click="showQuotaModal = false"><text>稍后再说</text></button>
- </view>
- </view>
- </view>
- <!-- 专辑列表面板 -->
- <view v-if="showAlbumListPanel" class="bottom-panel-overlay" @click="showAlbumListPanel = false">
- <view class="bottom-panel" @click.stop>
- <view class="panel-header">
- <text class="panel-title">选择专辑</text>
- <text class="panel-close" @click="showAlbumListPanel = false">✕</text>
- </view>
- <scroll-view scroll-y class="panel-body">
- <view
- v-for="album in albums" :key="album.id"
- class="album-item" :class="{ active: selectedAlbum?.id === album.id }"
- @click="selectAlbum(album)"
- >
- <view class="album-item-content">
- <text class="album-item-title">{{ album.title }}</text>
- <text class="album-item-desc">{{ album.totalChapters }} 个章节</text>
- </view>
- <text v-if="selectedAlbum?.id === album.id" class="album-check">✓</text>
- </view>
- <view v-if="albums.length === 0" class="album-empty"><text>暂无专辑</text></view>
- </scroll-view>
- <button class="panel-action-btn" @click="showAlbumListPanel = false; showAlbumPanel = true">
- <text>+ 新建专辑</text>
- </button>
- </view>
- </view>
- <!-- 新建专辑面板 -->
- <view v-if="showAlbumPanel" class="bottom-panel-overlay" @click="showAlbumPanel = false">
- <view class="bottom-panel" @click.stop>
- <view class="panel-header">
- <text class="panel-title">新建专辑</text>
- <text class="panel-close" @click="showAlbumPanel = false">✕</text>
- </view>
- <view class="panel-body">
- <view class="form-item">
- <text class="form-label">专辑名称 *</text>
- <input v-model="newAlbumTitle" class="form-input" placeholder="例如:我的有声书" maxlength="50" />
- </view>
- <view class="form-item">
- <text class="form-label">描述(可选)</text>
- <textarea v-model="newAlbumDescription" class="form-textarea" placeholder="简单描述这个专辑..." maxlength="200" />
- </view>
- <button class="panel-action-btn" :disabled="!newAlbumTitle.trim()" @click="createAlbum">
- <text>创建专辑</text>
- </button>
- </view>
- </view>
- </view>
- <!-- 全局底部 Tab Bar -->
- <CustomTabBar />
- </view>
- </template>
- <script setup lang="ts">
- import { ref, computed, onMounted, watch } from 'vue';
- import { onShow } from '@dcloudio/uni-app';
- import { useUserStore } from '../../store/user';
- import { useAudioStore } from '../../store/audio';
- import { get, post, uploadFile } from '../../utils/request';
- import type { VoiceParams } from '../../types';
- import CustomTabBar from '../../components/CustomTabBar.vue';
- const userStore = useUserStore();
- const audioStore = useAudioStore();
- const text = ref('');
- const selectedVoice = ref('voice_01');
- const voiceParams = ref<VoiceParams>({ speed: 1.0, pitch: 0, volume: 50 });
- const generating = ref(false);
- const previewingVoice = ref<string | null>(null);
- let previewAudio: HTMLAudioElement | null = null;
- // ============ 情感检测 ============
- const detecting = ref(false);
- const detectedEmotion = ref(false);
- const detectedEmotionLabel = ref('');
- const detectedVoiceName = ref('');
- const detectedScene = ref(''); // 检测到的场景
- const detectedRole = ref(''); // 检测到的角色
- const detectedIdentity = ref(''); // 检测到的身份
- const emotionDetectAttempted = ref(false);
- const selectedEmotion = ref(''); // 用户手动选择的情感,空=不覆盖
- const emotionOptions = [
- { key: 'neutral', label: '😐 中性' },
- { key: 'happy', label: '😊 快乐' },
- { key: 'sad', label: '😢 悲伤' },
- { key: 'angry', label: '😠 愤怒' },
- { key: 'fearful', label: '😨 恐惧' },
- { key: 'surprised', label: '😲 惊喜' },
- { key: 'disgusted', label: '🤢 厌恶' },
- ];
- let detectTimer: ReturnType<typeof setTimeout> | null = null;
- function selectEmotion(key: string) {
- selectedEmotion.value = selectedEmotion.value === key ? '' : key;
- }
- // 防抖检测文本情感
- function debouncedDetect() {
- if (detectTimer) clearTimeout(detectTimer);
- detectTimer = setTimeout(async () => {
- if (text.value.trim().length < 10) {
- detectedEmotion.value = false;
- detectedEmotionLabel.value = '';
- detectedVoiceName.value = '';
- emotionDetectAttempted.value = false;
- return;
- }
- detecting.value = true;
- emotionDetectAttempted.value = true;
- try {
- const res = await post<{
- detected: boolean;
- voiceId?: string;
- voiceName?: string;
- emotion?: string;
- emotionLabel?: string;
- hint?: string;
- scene?: string;
- role?: string | null;
- identity?: string | null;
- }>('/tts/detect-emotion', { text: text.value });
- detectedEmotion.value = res.detected || false;
- detectedEmotionLabel.value = res.emotionLabel || '';
- detectedVoiceName.value = res.voiceName || '';
- detectedScene.value = res.scene || '';
- detectedRole.value = res.role || '';
- detectedIdentity.value = res.identity || '';
- if (res.emotion && !selectedEmotion.value) {
- // 首次检测到情感时,自动选中(但不覆盖用户手动选择)
- }
- } catch (e) {
- /* 静默失败 */
- } finally {
- detecting.value = false;
- }
- }, 800);
- }
- // 监听文本变化,触发检测
- watch(text, () => {
- selectedEmotion.value = ''; // 文本变化时重置手动选择
- debouncedDetect();
- });
- // 文档上传
- interface UploadedDoc { fileName: string; wordCount: number; fileSize: number; format: string; truncated: boolean; }
- const uploadedDoc = ref<UploadedDoc>({ fileName: '', wordCount: 0, fileSize: 0, format: '', truncated: false });
- const uploading = ref(false);
- // 面板状态
- const showVoicePanel = ref(false);
- const showParamsPanel = ref(false);
- const voiceCategory = ref('all');
- const voiceCategories = [
- { key: 'all', label: '全部' },
- { key: 'hot', label: '热门' },
- { key: 'female', label: '女声' },
- { key: 'male', label: '男声' },
- ];
- // 专辑
- const selectedAlbum = ref<{ id: string; title: string } | null>(null);
- const albums = ref<any[]>([]);
- const showAlbumListPanel = ref(false);
- const showAlbumPanel = ref(false);
- const newAlbumTitle = ref('');
- const newAlbumDescription = ref('');
- const showQuotaModal = ref(false);
- const canGenerate = computed(() => text.value.trim().length > 0 && !!selectedAlbum.value && !generating.value);
- const currentVoiceName = computed(() => {
- const v = audioStore.voices.find(item => item.id === selectedVoice.value);
- return v ? v.name : '';
- });
- const filteredVoices = computed(() => {
- if (voiceCategory.value === 'all') return audioStore.voices;
- if (voiceCategory.value === 'female') return audioStore.voices.filter(v => v.gender === 'female');
- if (voiceCategory.value === 'male') return audioStore.voices.filter(v => v.gender === 'male');
- // hot: return all for now (could be flagged by backend)
- return audioStore.voices;
- });
- function selectVoiceAndClose(id: string) { selectedVoice.value = id; showVoicePanel.value = false; }
- async function fetchAlbums() {
- try {
- const result = await get<{ albums: any[] }>('/book-generator/books');
- albums.value = result.albums || [];
- } catch (error) { console.error('获取专辑列表失败:', error); }
- }
- function selectAlbum(album: any) { selectedAlbum.value = { id: album.id, title: album.title }; showAlbumListPanel.value = false; }
- async function createAlbum() {
- if (!newAlbumTitle.value.trim()) return;
- try {
- const result = await post<{ id: number | string; title: string }>('/book-generator/books', { title: newAlbumTitle.value.trim(), description: newAlbumDescription.value.trim() });
- if (result && result.id) {
- selectedAlbum.value = { id: String(result.id), title: result.title };
- await fetchAlbums();
- showAlbumPanel.value = false;
- newAlbumTitle.value = '';
- newAlbumDescription.value = '';
- uni.showToast({ title: '专辑创建成功', icon: 'success' });
- }
- } catch (error) { uni.showToast({ title: '创建失败', icon: 'none' }); }
- }
- async function loadUserPreferences() {
- try {
- const localPrefs = uni.getStorageSync('userPreferences');
- if (localPrefs) {
- if (localPrefs.defaultVoiceId) selectedVoice.value = localPrefs.defaultVoiceId;
- if (localPrefs.defaultVolume !== undefined) voiceParams.value.volume = localPrefs.defaultVolume;
- }
- const result = await get<any>('/user/preferences');
- if (result) {
- if (result.defaultVoiceId) selectedVoice.value = result.defaultVoiceId;
- if (result.defaultVolume !== undefined) voiceParams.value.volume = result.defaultVolume;
- uni.setStorageSync('userPreferences', result);
- }
- } catch (error) { /* ignore */ }
- }
- onMounted(async () => {
- await audioStore.fetchVoices();
- await fetchAlbums();
- await loadUserPreferences();
- });
- function goToAIGenerate() { uni.navigateTo({ url: '/pages/ai-generate/index' }); }
- onShow(() => {
- const aiText = uni.getStorageSync('ai_generated_text');
- if (aiText) { text.value = aiText; uni.removeStorageSync('ai_generated_text'); uni.showToast({ title: '已填充 AI 生成的文本', icon: 'success' }); }
- });
- function clearText() { text.value = ''; }
- async function pasteText() { const content = await uni.getClipboardData(); if (content.data) text.value = content.data; }
- // 上传文档(保持原逻辑)
- async function handleUploadDoc() {
- if (uploading.value) return;
- try {
- // #ifdef H5
- const input = document.createElement('input'); input.type = 'file'; input.accept = '.txt,.docx,.pdf';
- input.onchange = async (e: any) => { const file = e.target.files?.[0]; if (!file) return; await processUploadedFile(file); };
- input.click(); return;
- // #endif
- // #ifdef APP-PLUS
- const result = await (uni as any).chooseFile({ extension: ['.txt', '.docx', '.pdf'], type: 'file' });
- if (result?.tempFiles?.[0]) await processUploadedFile(result.tempFiles[0]);
- // #endif
- // #ifdef MP-WEIXIN
- uni.showToast({ title: '小程序暂不支持文档上传', icon: 'none' });
- // #endif
- } catch (error: any) { if (error.message && !error.message.includes('cancel')) uni.showToast({ title: '选择文件失败', icon: 'none' }); }
- }
- async function processUploadedFile(file: any) {
- uploading.value = true;
- if (file.size && file.size > 20 * 1024 * 1024) { uni.showToast({ title: '文件过大,最大支持20MB', icon: 'none' }); uploading.value = false; return; }
- const name = (file.name || '').toLowerCase();
- if (!name.endsWith('.txt') && !name.endsWith('.docx') && !name.endsWith('.pdf')) { uni.showToast({ title: '不支持的格式', icon: 'none' }); uploading.value = false; return; }
- uni.showLoading({ title: '解析文档中...', mask: true });
- try {
- let result: any;
- // #ifdef H5
- const formData = new FormData(); formData.append('file', file);
- const baseUrl = (await import('../../utils/config')).getApiBaseUrl();
- const token = uni.getStorageSync('token');
- const headers: Record<string, string> = {}; if (token) headers['Authorization'] = `Bearer ${token}`;
- const response = await fetch(baseUrl + '/tts/upload-document', { method: 'POST', headers, body: formData });
- const json = await response.json();
- if (json.code === 0) result = json.data; else throw new Error(json.message || '解析失败');
- // #endif
- // #ifdef APP-PLUS
- result = await uploadFile('/tts/upload-document', file.path || file.tempFilePath || file);
- // #endif
- if (result && result.text) {
- uploadedDoc.value = { fileName: result.fileName || file.name, wordCount: result.wordCount || result.text.length, fileSize: result.fileSize || file.size || 0, format: result.format || '', truncated: result.truncated || false };
- let displayText = result.text;
- if (displayText.length > 2000) { displayText = displayText.substring(0, 2000); uploadedDoc.value.truncated = true; }
- text.value = displayText;
- uni.hideLoading();
- uni.showToast({ title: `解析成功,${uploadedDoc.value.wordCount}字`, icon: 'success' });
- } else { throw new Error('文档内容为空'); }
- } catch (error: any) { uni.hideLoading(); uni.showToast({ title: error.message || '文档解析失败', icon: 'none', duration: 3000 }); }
- finally { uploading.value = false; }
- }
- function clearDoc() { uploadedDoc.value = { fileName: '', wordCount: 0, fileSize: 0, format: '', truncated: false }; }
- async function previewVoice(voiceId: string) {
- if (previewingVoice.value === voiceId) { stopPreview(); return; }
- stopPreview();
- try {
- previewingVoice.value = voiceId; uni.showLoading({ title: '生成试听音频...' });
- const res = await post<{ audioUrl: string }>('/tts/preview', { voiceId, voiceParams: voiceParams.value });
- uni.hideLoading();
- if (res.audioUrl) {
- // #ifdef H5
- previewAudio = new Audio(res.audioUrl); previewAudio.play();
- previewAudio.onended = () => { previewingVoice.value = null; previewAudio = null; };
- // #endif
- // #ifndef H5
- uni.showToast({ title: '试听已播放', icon: 'success' }); setTimeout(() => { previewingVoice.value = null; }, 2000);
- // #endif
- }
- } catch (error: any) { uni.hideLoading(); uni.showToast({ title: '试听生成失败', icon: 'none' }); previewingVoice.value = null; }
- }
- function stopPreview() { if (previewAudio) { previewAudio.pause(); previewAudio = null; } previewingVoice.value = null; }
- async function handleGenerate() {
- if (!canGenerate.value) return;
- const quota = userStore.memberStatus?.quota;
- if (quota && quota.dailyRemaining !== -1 && quota.dailyRemaining <= 0) { showQuotaModal.value = true; return; }
- const bookId = selectedAlbum.value?.id;
- generating.value = true;
- try {
- // 如果用户手动选择了情感,注入到 voiceParams
- const params = { ...voiceParams.value };
- if (selectedEmotion.value) {
- (params as any).emotion = selectedEmotion.value;
- }
- const result = await audioStore.generateAudio(text.value, selectedVoice.value, params, { bookId });
- text.value = '';
- uni.showToast({ title: '任务已提交', icon: 'success' });
- setTimeout(() => { uni.navigateTo({ url: `/pages/book-generator/detail?id=${result.bookId}` }); }, 800);
- } catch (error: any) {
- if (error.message?.includes('额度') || error.message?.includes('quota')) showQuotaModal.value = true;
- else uni.showToast({ title: error.message || '提交失败,请重试', icon: 'none' });
- } finally { generating.value = false; }
- }
- function goToMember() { showQuotaModal.value = false; uni.navigateTo({ url: '/pages/member/index' }); }
- </script>
- <style scoped>
- .page { min-height: 100vh; background: #f5f5f5; display: flex; flex-direction: column; }
- /* 主内容区 */
- .main-content { flex: 1; padding: 16rpx 24rpx 0; overflow-y: auto; }
- .input-area { background: #ffffff; border-radius: 16rpx; padding: 24rpx; }
- /* 情感检测卡片 */
- .emotion-card {
- margin: 16rpx 0;
- padding: 20rpx 24rpx;
- background: linear-gradient(135deg, #f0f4ff 0%, #faf5ff 100%);
- border-radius: 16rpx;
- border: 1rpx solid #e0e7ff;
- }
- .emotion-card-header {
- display: flex;
- align-items: center;
- gap: 8rpx;
- margin-bottom: 12rpx;
- }
- .emotion-card-icon { font-size: 28rpx; }
- .emotion-card-title { font-size: 24rpx; font-weight: 600; color: #4f46e5; }
- .emotion-detecting { font-size: 20rpx; color: #9ca3af; margin-left: auto; }
- .emotion-result {}
- .emotion-row {
- display: flex;
- align-items: center;
- gap: 16rpx;
- padding: 8rpx 0;
- }
- .emotion-label { font-size: 22rpx; color: #6b7280; width: 120rpx; flex-shrink: 0; }
- .emotion-value { font-size: 26rpx; font-weight: 600; color: #1f2937; }
- .emotion-value.voice-value { color: #4f46e5; }
- .emotion-value.emotion-fallback { color: #9ca3af; }
- .emotion-value.scene-value { color: #0891b2; }
- .emotion-value.identity-value { color: #7c3aed; }
- .emotion-value.role-value { color: #059669; }
- .emotion-hint { font-size: 20rpx; color: #9ca3af; margin-top: 4rpx; }
- /* 情感覆盖选择器 */
- .emotion-override {
- margin-top: 16rpx;
- padding-top: 12rpx;
- border-top: 1rpx solid #e0e7ff;
- }
- .emotion-override-label { font-size: 22rpx; color: #6b7280; }
- .emotion-scroll { white-space: nowrap; margin-top: 8rpx; }
- .emotion-chips { display: flex; gap: 10rpx; }
- .emotion-chip {
- flex-shrink: 0;
- padding: 8rpx 16rpx;
- background: #ffffff;
- border: 1rpx solid #d1d5db;
- border-radius: 20rpx;
- font-size: 22rpx;
- color: #374151;
- transition: all 0.2s;
- }
- .emotion-chip.active {
- background: #4f46e5;
- border-color: #4f46e5;
- color: #ffffff;
- font-weight: 600;
- }
- /* 专辑选择器(页面内下拉) */
- .album-selector {
- display: flex;
- align-items: center;
- gap: 12rpx;
- padding: 20rpx 24rpx;
- background: #f3f4f6;
- border-radius: 12rpx;
- border: 2rpx solid #e5e7eb;
- margin-top: 20rpx;
- }
- .album-selector:active { opacity: 0.7; }
- .album-selector-label { font-size: 26rpx; color: #6b7280; flex-shrink: 0; }
- .album-selector-value {
- flex: 1;
- font-size: 26rpx;
- color: #1f2937;
- font-weight: 500;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .album-selector-value.placeholder { color: #9ca3af; font-weight: 400; }
- .album-selector-arrow { font-size: 20rpx; color: #9ca3af; flex-shrink: 0; }
- .text-input { width: 100%; min-height: 600rpx; font-size: 30rpx; color: #1f2937; line-height: 1.7; }
- .word-count { display: block; text-align: right; font-size: 22rpx; color: #9ca3af; margin-top: 8rpx; }
- .word-count.warning { color: #f59e0b; }
- .word-count.error { color: #ef4444; font-weight: 600; }
- /* 文档信息 */
- .doc-info-bar { display: flex; align-items: center; gap: 12rpx; padding: 12rpx 16rpx; background: #f0fdf4; border-radius: 10rpx; border: 1rpx solid #86efac; margin-top: 12rpx; }
- .doc-format-badge { flex-shrink: 0; font-size: 20rpx; font-weight: 700; color: #fff; background: #4f46e5; padding: 4rpx 10rpx; border-radius: 6rpx; }
- .doc-filename { flex: 1; font-size: 24rpx; font-weight: 600; color: #1f2937; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
- .doc-meta { font-size: 20rpx; color: #6b7280; flex-shrink: 0; }
- .doc-truncated-hint { font-size: 18rpx; color: #f59e0b; flex-shrink: 0; }
- .doc-remove-btn { flex-shrink: 0; width: 36rpx; height: 36rpx; border-radius: 50%; background: #fef2f2; color: #ef4444; font-size: 24rpx; display: flex; align-items: center; justify-content: center; }
- .doc-uploading-bar { padding: 16rpx; text-align: center; background: #fefce8; border-radius: 10rpx; font-size: 24rpx; color: #a16207; margin-top: 12rpx; }
- /* 工具按钮行 */
- .tool-row { display: flex; gap: 12rpx; margin-top: 16rpx; }
- .tool-btn { flex: 1; font-size: 24rpx; color: #6b7280; background: #f3f4f6; padding: 14rpx 0; border-radius: 10rpx; text-align: center; }
- .tool-btn:active { opacity: 0.7; }
- /* 底部固定区:Tab 工具栏风格 */
- .bottom-bar { background: #ffffff; box-shadow: 0 -2rpx 16rpx rgba(0,0,0,0.08); padding-bottom: calc(env(safe-area-inset-bottom) + 120rpx); }
- /* 主操作行:左侧快捷信息 + 右侧生成按钮 */
- .bottom-action-row { display: flex; align-items: center; gap: 16rpx; padding: 16rpx 24rpx; }
- .quick-info { flex: 1; min-width: 0; }
- .quick-info-text { font-size: 24rpx; color: #6b7280; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
- /* 生成按钮(紧凑 Tab 风格) */
- .generate-btn-tab { flex-shrink: 0; padding: 18rpx 36rpx; background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%); border-radius: 40rpx; }
- .generate-btn-tab:active { opacity: 0.8; transform: scale(0.96); }
- .generate-btn-tab.disabled { opacity: 0.35; pointer-events: none; }
- .generate-btn-tab-text { font-size: 28rpx; font-weight: 700; color: #ffffff; white-space: nowrap; }
- /* 图标 Tab 栏(等宽分布,居中) */
- .tab-toolbar { display: flex; border-top: 1rpx solid #f3f4f6; padding: 10rpx 0 16rpx; }
- .tab-item { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 4rpx; padding: 6rpx 0; border-radius: 12rpx; transition: all 0.15s; }
- .tab-item:active { background: #f9fafb; }
- .tab-item.active .tab-icon { transform: scale(1.15); }
- .tab-item.active .tab-text { color: #4f46e5; font-weight: 600; }
- .tab-icon { font-size: 40rpx; line-height: 1; transition: transform 0.15s; }
- .tab-text { font-size: 22rpx; color: #9ca3af; transition: color 0.15s; }
- /* 底部面板 */
- .bottom-panel-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 10000; display: flex; align-items: flex-end; }
- .bottom-panel { width: 100%; max-height: 70vh; background: #ffffff; border-radius: 24rpx 24rpx 0 0; display: flex; flex-direction: column; }
- .panel-header { display: flex; justify-content: space-between; align-items: center; padding: 32rpx 32rpx 16rpx; }
- .panel-title { font-size: 34rpx; font-weight: 600; color: #1f2937; }
- .panel-close { font-size: 36rpx; color: #9ca3af; padding: 8rpx; }
- .panel-body { padding: 0 32rpx 32rpx; max-height: 55vh; }
- /* 音色面板(亮色,与整体风格统一) */
- .voice-panel-dark { background: #ffffff; border-radius: 24rpx 24rpx 0 0; max-height: 75vh; }
- /* 分类标签栏 */
- .voice-tabs { padding: 24rpx 28rpx 8rpx; border-bottom: 1rpx solid #f3f4f6; }
- .voice-tabs-scroll { white-space: nowrap; }
- .voice-tabs-inner { display: inline-flex; gap: 16rpx; }
- .voice-tab { font-size: 26rpx; color: #6b7280; padding: 10rpx 22rpx; border-radius: 20rpx; white-space: nowrap; flex-shrink: 0; background: #f9fafb; }
- .voice-tab.active { color: #ffffff; background: #4f46e5; font-weight: 600; }
- /* 音色网格(5列紧凑卡片) */
- .voice-grid-scroll { max-height: 56vh; }
- .voice-card-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12rpx; padding: 20rpx 24rpx 32rpx; }
- .voice-card { position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 18rpx 4rpx 14rpx; border-radius: 14rpx; background: #f9fafb; border: 2rpx solid transparent; min-height: 120rpx; }
- .voice-card.active { background: #eef2ff; border-color: #4f46e5; }
- .voice-card:active { opacity: 0.7; transform: scale(0.96); }
- .voice-card-icon { width: 64rpx; height: 64rpx; border-radius: 50%; background: #f3f4f6; display: flex; align-items: center; justify-content: center; margin-bottom: 6rpx; font-size: 30rpx; }
- .voice-card.active .voice-card-icon { background: rgba(79,70,229,0.12); }
- .voice-card-name { font-size: 20rpx; color: #374151; text-align: center; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
- .voice-card.active .voice-card-name { color: #4f46e5; font-weight: 600; }
- .voice-card-check { position: absolute; top: 6rpx; right: 6rpx; font-size: 20rpx; color: #4f46e5; font-weight: 700; }
- .voice-card-play { position: absolute; top: 4rpx; right: 4rpx; width: 36rpx; height: 36rpx; border-radius: 50%; background: rgba(79,70,229,0.1); display: flex; align-items: center; justify-content: center; font-size: 16rpx; color: #4f46e5; }
- .voice-card.active .voice-card-play { display: none; }
- /* 面板底部关闭条 */
- .panel-close-bar { padding: 16rpx 0 28rpx; display: flex; justify-content: center; }
- .panel-close-bar-line { width: 80rpx; height: 6rpx; border-radius: 3rpx; background: #e5e7eb; }
- /* 参数面板 */
- .param-item { margin-bottom: 32rpx; }
- .param-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12rpx; }
- .param-label { font-size: 28rpx; color: #374151; }
- .param-value { font-size: 28rpx; font-weight: 600; color: #4f46e5; background: #eef2ff; padding: 4rpx 16rpx; border-radius: 8rpx; }
- .param-range { display: flex; justify-content: space-between; margin-top: 8rpx; }
- .param-range text { font-size: 22rpx; color: #9ca3af; }
- /* 专辑 */
- .album-item { display: flex; justify-content: space-between; align-items: center; padding: 20rpx 24rpx; border-radius: 14rpx; background: #f9fafb; margin-bottom: 12rpx; }
- .album-item.active { background: #eef2ff; border: 2rpx solid #4f46e5; }
- .album-item-content { flex: 1; }
- .album-item-title { display: block; font-size: 28rpx; font-weight: 600; color: #1f2937; margin-bottom: 6rpx; }
- .album-item-desc { display: block; font-size: 24rpx; color: #6b7280; }
- .album-check { font-size: 32rpx; color: #4f46e5; }
- .album-empty { text-align: center; padding: 60rpx 0; color: #9ca3af; font-size: 28rpx; }
- .panel-action-btn { width: 100%; height: 96rpx; background: linear-gradient(135deg, #4f46e5 0%, #818cf8 100%); border-radius: 16rpx; border: none; display: flex; align-items: center; justify-content: center; font-size: 32rpx; font-weight: 600; color: #ffffff; margin-top: 16rpx; }
- .panel-action-btn::after { border: none; }
- .panel-action-btn[disabled] { opacity: 0.4; }
- /* 表单 */
- .form-item { margin-bottom: 24rpx; }
- .form-label { display: block; font-size: 28rpx; color: #1f2937; font-weight: 500; margin-bottom: 12rpx; }
- .form-input { width: 100%; height: 88rpx; background: #f9fafb; border-radius: 14rpx; padding: 0 24rpx; font-size: 28rpx; color: #1f2937; }
- .form-textarea { width: 100%; min-height: 140rpx; background: #f9fafb; border-radius: 14rpx; padding: 20rpx 24rpx; font-size: 28rpx; color: #1f2937; line-height: 1.6; }
- /* 额度弹窗 */
- .quota-modal { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.6); z-index: 3000; display: flex; align-items: center; justify-content: center; }
- .quota-content { width: 600rpx; background: #ffffff; border-radius: 32rpx; padding: 60rpx 40rpx; display: flex; flex-direction: column; align-items: center; }
- .quota-icon { font-size: 100rpx; margin-bottom: 24rpx; }
- .quota-title { font-size: 36rpx; font-weight: 600; color: #1f2937; margin-bottom: 12rpx; }
- .quota-desc { font-size: 28rpx; color: #6b7280; margin-bottom: 40rpx; }
- .quota-actions { width: 100%; display: flex; flex-direction: column; gap: 16rpx; }
- .quota-btn { width: 100%; height: 88rpx; border-radius: 44rpx; display: flex; align-items: center; justify-content: center; font-size: 30rpx; border: none; }
- .quota-btn::after { border: none; }
- .quota-btn.primary { background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: #fff; }
- .quota-btn.secondary { background: #f3f4f6; color: #6b7280; }
- </style>
- // ====== 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 nav-btn-lyrics" @click="showLyrics = !showLyrics">
- <text class="nav-icon">{{ showLyrics ? '📖' : '📕' }}</text>
- <text class="nav-btn-label">{{ 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">
- <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 speed-display-btn" @click="showRatePicker = true">
- <text class="speed-label">倍速</text>
- <text class="speed-value">{{ playRate }}x</text>
- </view>
- <AudioDownload
- :url="getFullUrl(audio?.audioUrl || '')"
- :filename="`${audio?.title || 'audio'}.mp3`"
- :audio-id="String(audio?.id || '')"
- >
- <view class="control-btn">
- <text class="control-icon">⬇</text>
- </view>
- </AudioDownload>
- </view>
- <!-- 中间按钮:上一首、播放/暂停、下一首 -->
- <view class="controls-center">
- <view class="control-btn" :class="{ disabled: !hasPrev }" @click="playPrev">
- <text class="control-icon">⏮</text>
- </view>
- <view class="play-btn" @click="togglePlay">
- <text class="play-icon">{{ isPlaying ? '⏸' : '▶' }}</text>
- </view>
- <view class="control-btn" :class="{ disabled: !hasNext }" @click="playNext">
- <text class="control-icon">⏭</text>
- </view>
- </view>
- <!-- 右侧按钮:收藏、分享 -->
- <view class="controls-side">
- <view class="control-btn" @click="toggleFavorite">
- <text class="control-icon">{{ audio?.isFavorite ? '❤️' : '🤍' }}</text>
- </view>
- <view class="control-btn share-btn" @click="handleShare">
- <text class="control-icon">📤</text>
- <text class="share-label">分享得时长</text>
- </view>
- </view>
- </view>
- <!-- 播放列表 - 移动端设计,直接在界面中显示 -->
- <view class="playlist-section-mobile">
- <view class="playlist-header">
- <view class="playlist-header-left">
- <text class="playlist-title">播放列表</text>
- <text class="playlist-count">{{ playlist.length }}首</text>
- </view>
- <view class="playlist-header-right" @click="togglePlayMode">
- <text class="playlist-mode-icon">{{ getPlayModeIcon() }}</text>
- <text class="playlist-mode-text">{{ getPlayModeText() }}</text>
- </view>
- </view>
- <scroll-view class="playlist-list-mobile" scroll-y>
- <view
- v-for="(item, index) in playlist"
- :key="item.id"
- :class="['playlist-item', { active: isCurrentTrack(item) }]"
- @click="playFromPlaylist(index)"
- >
- <view class="playlist-index">{{ index + 1 }}</view>
- <view class="playlist-info">
- <text class="playlist-item-title">{{ item.title }}</text>
- <text class="playlist-item-meta">{{ formatDuration(item.audioDuration || 0) }}</text>
- </view>
- <view v-if="isCurrentTrack(item)" class="playing-icon">▶</view>
- </view>
- </scroll-view>
- </view>
- </template>
- <!-- 播放速度选择器 -->
- <view v-if="showRatePicker" class="rate-picker" @click="showRatePicker = false">
- <view class="rate-content" @click.stop>
- <text class="rate-title">播放速度</text>
- <view class="rate-options">
- <view
- v-for="rate in [0.5, 0.75, 0.9, 1, 1.25, 1.5, 2]"
- :key="rate"
- class="rate-item"
- :class="{ active: playRate === rate }"
- @click="handleSetRate(rate)"
- >
- <text>{{ rate }}x</text>
- </view>
- </view>
- </view>
- </view>
- <!-- 睡眠定时选择器 -->
- <view v-if="showSleepTimerPicker" class="rate-picker" @click="showSleepTimerPicker = false">
- <view class="rate-content" @click.stop>
- <text class="rate-title">睡眠定时</text>
- <view class="rate-options">
- <view
- v-for="option in sleepTimerOptions"
- :key="option.value"
- class="rate-item"
- :class="{ active: sleepTimer && sleepTimer.total === option.value }"
- @click="handleSetSleepTimer(option.value)"
- >
- <text>{{ option.label }}</text>
- </view>
- </view>
- <view v-if="sleepTimer" class="sleep-timer-cancel" @click="cancelSleepTimer">
- <text>取消定时</text>
- </view>
- </view>
- </view>
- </view>
- </template>
- <script setup lang="ts">
- import { ref, watch, onMounted, onUnmounted } from 'vue';
- import { onShow } from '@dcloudio/uni-app';
- import { onLoad } from '@dcloudio/uni-app';
- import { useAudioStore } from '../../store/audio';
- import { storeToRefs } from 'pinia';
- import { get, put, post, getFullUrl } from '../../utils/request';
- import { trackEvent, AnalyticsEvents } from '../../utils/analytics';
- import type { AudioItem } from '../../types';
- import AudioDownload from '../../components/AudioDownload.vue';
- // 分享数据接口
- interface ShareCardData {
- title: string;
- description: string;
- imageUrl: string;
- shareUrl: string;
- shareCode: string;
- chapterId: number;
- wordCount: number;
- audioDuration: number;
- }
- const audioStore = useAudioStore();
- // 状态
- const audioId = ref('');
- const audio = ref<AudioItem | null>(null);
- const showRatePicker = ref(false);
- const showSleepTimerPicker = ref(false);
- const showLyrics = ref(true);
- const lyrics = ref<{ text: string; start: number; end: number }[]>([]);
- const scrollIntoViewId = ref('');
- // 分享数据
- const shareData = ref<ShareCardData | null>(null);
- // 睡眠定时
- const sleepTimer = ref<{ remaining: number; total: number } | null>(null);
- let sleepTimerInterval: ReturnType<typeof setInterval> | null = null;
- // 睡眠定时选项
- const sleepTimerOptions = [
- { label: '15分钟', value: 15 * 60 },
- { label: '30分钟', value: 30 * 60 },
- { label: '60分钟', value: 60 * 60 },
- { label: '当前播放结束', value: -1 },
- ];
- // 解析 LRC 格式歌词(兼容换行分隔和单行连续两种格式)
- function parseLrc(lrcText: string): { text: string; start: number; end: number }[] {
- if (!lrcText) return [];
- const timeline: { text: string; start: number; end: number }[] = [];
- // 优先按换行分割,如果只有1行则用全局匹配提取所有时间戳
- const lines = lrcText.split('\n');
- if (lines.length <= 1) {
- // 单行格式:用正则提取所有 [MM:SS.XX]text 对
- const matches = lrcText.matchAll(/\[(\d{2}):(\d{2})\.(\d{2})\]\s*([^[]*)/g);
- for (const m of matches) {
- const minutes = parseInt(m[1]);
- const seconds = parseInt(m[2]);
- const cs = parseInt(m[3]);
- const text = m[4].trim();
- if (text) {
- timeline.push({ text, start: minutes * 60 + seconds + cs / 100, end: 0 });
- }
- }
- } else {
- // 标准格式:按行处理
- for (const line of lines) {
- const match = line.match(/^\[(\d{2}):(\d{2})\.(\d{2})\]\s*(.*)$/);
- if (match) {
- const text = match[4].trim();
- if (text) {
- timeline.push({
- text,
- start: parseInt(match[1]) * 60 + parseInt(match[2]) + parseInt(match[3]) / 100,
- end: 0,
- });
- }
- }
- }
- }
- // 计算 end 时间(下一句的开始时间)
- for (let i = 0; i < timeline.length - 1; i++) {
- timeline[i].end = timeline[i + 1].start;
- }
- if (timeline.length > 0) {
- const last = timeline[timeline.length - 1];
- last.end = last.start + 5;
- }
- return timeline;
- }
- // 生成歌词时间线(按句子切割,实现逐句高亮跟随)
- function generateLyricsTimeline(text: string, totalDuration: number): { text: string; start: number; end: number }[] {
- if (!text || totalDuration <= 0) return [];
- // 按 Markdown 结构分段
- const sections: string[] = [];
- const h1Parts = text.split(/(?=^# )/m);
- for (const h1Part of h1Parts) {
- if (!h1Part.trim()) continue;
- const h2Parts = h1Part.split(/(?=^## )/m);
- for (const h2Part of h2Parts) {
- const trimmed = h2Part.trim();
- if (!trimmed) continue;
- const isHeading = /^#{1,2}\s/.test(trimmed);
- if (isHeading) {
- // 提取标题行和正文,分别处理(避免将标题+正文当作一整条歌词)
- const titleMatch = trimmed.match(/^(#{1,2}\s+.+?)(\n|$)/);
- if (titleMatch) {
- sections.push(titleMatch[1].trim());
- const rest = trimmed.substring(titleMatch[0].length).trim();
- if (rest) {
- const paras = rest.split(/\n{2,}/).filter(p => p.trim());
- for (const para of paras) {
- const sentences = para.split(/(?<=[。!?;])/).filter(s => s.trim());
- sections.push(...sentences);
- }
- }
- } else {
- sections.push(trimmed);
- }
- } else {
- // 先按段落切,再按句子切 → 实现逐句级歌词跟随
- const paras = trimmed.split(/\n{2,}/).filter(p => p.trim());
- for (const para of paras) {
- const sentences = para.split(/(?<=[。!?;])/).filter(s => s.trim());
- sections.push(...sentences);
- }
- }
- }
- }
- // 降级:纯文本没有标题时,直接按句子分段
- if (sections.length <= 1) {
- const sentences = text.split(/(?<=[。!?;])/).filter(s => s.trim());
- sections.length = 0;
- sections.push(...sentences);
- }
- // 权重分配:标题 0.3x,正文 1.0x
- const weights = sections.map(s => /^#{1,2}\s/.test(s) ? 0.3 : 1.0);
- const weightedChars = sections.map((s, i) => {
- // 统计所有可见字符(含标点、数字、符号),不只汉字字母
- const chars = s.replace(/\s/g, '').length;
- return chars * weights[i];
- });
- const totalWeighted = weightedChars.reduce((a, b) => a + b, 0);
- if (totalWeighted === 0) return [];
- const timeline: { text: string; start: number; end: number }[] = [];
- let currentTime = 0;
- for (let i = 0; i < sections.length; i++) {
- const ratio = weightedChars[i] / totalWeighted;
- const segDuration = ratio * totalDuration;
- const start = currentTime;
- const end = currentTime + segDuration;
- timeline.push({ text: sections[i], start, end });
- currentTime += segDuration;
- // 标题后加停顿
- if (i < sections.length - 1 && /^#{1,2}\s/.test(sections[i])) {
- currentTime = Math.min(currentTime + 0.5, totalDuration);
- }
- }
- // 修正最后一段
- if (timeline.length > 0) {
- timeline[timeline.length - 1].end = totalDuration;
- }
- return timeline;
- }
- // 当前高亮的歌词索引
- const currentLyricIndex = ref(-1);
- // 直接监听 store 中的 currentTime,驱动高亮和滚动
- watch(() => audioStore.currentTime, (time) => {
- if (!lyrics.value.length) return;
- const index = lyrics.value.findIndex(l => time >= l.start && time < l.end);
- currentLyricIndex.value = index;
- if (index >= 0) {
- // 滚动到前一行(最小为 0),使高亮行上下都有可见文本作为缓冲
- const scrollIndex = Math.max(0, index - 1);
- const targetId = 'lyric-' + scrollIndex;
- if (scrollIntoViewId.value !== targetId) {
- scrollIntoViewId.value = targetId;
- }
- }
- });
- // 从 store 获取状态
- const { isPlaying, currentTime, duration, playRate, hasPrev, hasNext, playlist } = storeToRefs(audioStore);
- // 页面加载
- onMounted(async () => {
- const pages = getCurrentPages();
- const currentPage = pages[pages.length - 1] as any;
- // #ifdef APP-PLUS
- audioId.value = currentPage?.$page?.options?.id || '';
- // #endif
- // #ifndef APP-PLUS
- audioId.value = currentPage?.options?.id || '';
- // #endif
- if (audioId.value) {
- await fetchAudio();
- } else {
- uni.showToast({ title: '没有音频ID参数', icon: 'none' });
- }
- });
- // 页面显示时刷新音频状态
- onShow(() => {
- // 刷新 store 中的当前音频信息
- if (audioStore.currentAudio) {
- audio.value = audioStore.currentAudio;
- // currentTime/duration 是 computed,自动从 store 读取,无需手动赋值
- }
- });
- // 监听 store 当前音频变化(自动切歌时同步标题和歌词)
- watch(() => audioStore.currentAudio, (newAudio) => {
- if (newAudio && newAudio.id !== audio.value?.id) {
- audio.value = newAudio;
- updateLyricsForAudio(newAudio);
- }
- });
- // 页面卸载
- onUnmounted(() => {
- // 清除睡眠定时器
- if (sleepTimerInterval) {
- clearInterval(sleepTimerInterval);
- }
- // 不销毁音频上下文,保持后台播放
- });
- // 获取音频详情
- async function fetchAudio() {
- try {
- // 获取音频详情
- const result = await get<AudioItem>(`/player/audio/${audioId.value}`);
- audio.value = result;
- // 检查是否有音频URL
- if (!result.audioUrl) {
- uni.showToast({
- title: '该章节没有音频,请先生成音频',
- icon: 'none',
- duration: 2000
- });
- return;
- }
- // 获取播放列表(使用专辑章节列表)
- await fetchAlbumPlaylist(result);
- // 生成歌词时间线(始终使用前端按句子切割,比后端 LRC 更可靠)
- const audioDuration = result.audioDuration || audioStore.duration || 60;
- lyrics.value = generateLyricsTimeline(result.text || '', audioDuration);
- // 在歌词列表前插入空行作为视觉缓冲,确保高亮当前行时前后文都可见
- lyrics.value = [{ text: '', start: 0, end: 0 }, ...lyrics.value];
- // 开始播放(store 中已初始化 audioContext)
- audioStore.play(result);
- } catch (error: any) {
- console.error('获取音频失败:', error);
- uni.showToast({ title: error.message || '音频不存在', icon: 'none' });
- }
- }
- // 获取专辑播放列表
- async function fetchAlbumPlaylist(currentAudio: AudioItem) {
- try {
- // 从音频信息中获取albumId(书籍ID) - 优先从音频对象获取
- let albumId = (currentAudio as any).albumId || (currentAudio as any).bookId;
- // 如果当前音频没有albumId,则尝试从章节信息获取
- if (!albumId) {
- // 由于 we're using chapter ID as audioId, try to get album info from chapters API
- // We'll use the current audioId to get chapter details and then album ID
- try {
- const chapterDetail = await get<any>(`/book-generator/books/chapters/${audioId.value}`);
- albumId = chapterDetail?.bookId; // The chapter detail now includes bookId
- } catch (err) {
- console.warn('无法通过章节ID获取专辑信息:', err);
- }
- }
- if (!albumId) {
- // 如果还是没有albumId,使用通用播放列表
- await fetchGenericPlaylist();
- return;
- }
- // 获取专辑章节列表(章→节→小节 三层嵌套)
- const data = await get<{ chapters: any[] }>(`/book-generator/books/${albumId}/chapters`);
- const chapters = data?.chapters || [];
- // 展平为线性播放列表:按目录顺序(深度优先,父→子)输出所有可播放叶子。
- // - level=1 章:若有直接音频则播放章;若下面有可播放的节/小节,则章本身不入列表(避免重复项)
- // - level=2 节:若自己挂音频则入列表;若只有 level=3 子小节有音频,则节本身不入
- // - level=3 小节:有音频则入列表
- const flatItems: any[] = [];
- const collectLeaves = (nodes: any[]): any[] => {
- const out: any[] = [];
- nodes.forEach((n: any) => {
- const children = (n.subsections || []).filter((c: any) => c);
- const childLeaves = collectLeaves(children);
- const hasOwnAudio = !!(n.audioUrl && String(n.audioUrl).trim() !== '');
- if (childLeaves.length > 0) {
- out.push(...childLeaves);
- } else if (hasOwnAudio && n.level !== undefined) {
- out.push(n);
- }
- });
- return out;
- };
- // 但 level=1 章本身若有音频且无子项,collectLeaves 会返回该章,已涵盖。
- // 如果某章 level=1 自己有音频,同时 level=2 子节也有音频,按"只取叶子"会把子节列出、章不列,符合预期。
- flatItems.push(...collectLeaves(chapters));
- // 转换为播放列表格式
- const playlist = flatItems.map((c: any) => ({
- _id: c.id,
- id: c.id,
- title: c.title,
- audioUrl: c.audioUrl,
- audioDuration: c.audioDuration || 0,
- wordCount: c.wordCount || 0,
- }));
- // 找到当前音频在列表中的位置(audioId 可能是数字或字符串)
- const targetId = String(audioId.value);
- const currentIndex = playlist.findIndex(item => String(item.id) === targetId);
- if (playlist.length > 0) {
- audioStore.setPlaylist(playlist, currentIndex >= 0 ? currentIndex : 0, true);
- }
- } catch (error) {
- console.error('获取专辑播放列表失败:', error);
- // 失败时使用通用播放列表
- await fetchGenericPlaylist();
- }
- }
- // 获取通用播放列表
- async function fetchGenericPlaylist() {
- try {
- const result = await get<{ list: AudioItem[] }>('/player/audio/list', {
- page: 1,
- pageSize: 100,
- });
- const index = result.list.findIndex(item => item._id === audioId.value);
- if (index >= 0) {
- audioStore.setPlaylist(result.list, index, true);
- }
- } catch (error) {
- console.error('获取播放列表失败:', error);
- }
- }
- // 格式化时长
- function formatDuration(seconds: number): string {
- const mins = Math.floor(seconds / 60);
- const secs = Math.floor(seconds % 60);
- return `${mins}:${secs.toString().padStart(2, '0')}`;
- }
- // 判断播放列表项是否为当前播放
- function isCurrentTrack(item: AudioItem): boolean {
- const currentId = String(audio.value?._id || audio.value?.id);
- return String(item.id) === currentId || String(item._id) === currentId;
- }
- /**
- * 获取分享卡片数据(从后端)
- */
- async function fetchShareCard() {
- if (!audio.value?._id) return;
- try {
- const res = await get<ShareCardData>(`/share/card/${audio.value._id}`);
- shareData.value = res;
- } catch (e) {
- console.error('获取分享卡片失败', e);
- }
- }
- /**
- * 处理分享(增强版)
- */
- async function handleShare() {
- trackEvent(AnalyticsEvents.SHARE_CLICK, { audioId: audio.value?._id });
- // 先获取最新的分享卡片数据
- await fetchShareCard();
- const shareTitle = shareData.value?.title || '声工坊有声作品';
- const shareDesc = shareData.value?.description || '用AI生成的高品质有声书,快来听!';
- const shareUrl = shareData.value?.shareUrl || copyShareLink();
- // #ifdef H5
- if (navigator.share) {
- try {
- await navigator.share({
- title: shareTitle,
- text: shareDesc,
- url: shareUrl,
- });
- await trackShareComplete('h5_share_api');
- } catch (e) {
- // 用户取消分享
- if ((e as Error).name !== 'AbortError') {
- await copyToClipboard(shareUrl);
- }
- }
- return;
- }
- // #endif
- // 小程序/App:复制链接
- await copyToClipboard(shareUrl);
- }
- /**
- * 向后端上报分享完成
- */
- async function trackShareComplete(platform: string) {
- try {
- await post('/share/track', {
- audioId: audio.value?._id,
- platform,
- shareCode: shareData.value?.shareCode,
- });
- trackEvent(AnalyticsEvents.SHARE_COMPLETE, {
- audioId: audio.value?._id,
- platform,
- });
- } catch (e) {
- // 静默失败
- }
- }
- /**
- * 复制到剪贴板(带分享码链接)
- */
- async function copyToClipboard(url: string) {
- try {
- await uni.setClipboardData({ data: url });
- uni.showToast({ title: '链接已复制,分享给朋友吧!', icon: 'success' });
- await trackShareComplete('copy_link');
- } catch (e) {
- uni.showToast({ title: '请截图分享', icon: 'none' });
- }
- }
- // 页面加载时,检测分享码来源
- onLoad((options: any) => {
- const shareCode = options?.sc;
- if (shareCode) {
- // 上报分享访问
- get(`/share/visit?code=${shareCode}`).catch(() => {});
- trackEvent(AnalyticsEvents.INVITE_ACCEPT, { shareCode });
- // 存储分享码,用于后续注册绑定
- uni.setStorageSync('invite_code', shareCode);
- }
- });
- // 返回
- function goBack() {
- const pages = getCurrentPages();
- if (pages.length > 1) {
- uni.navigateBack();
- } else {
- uni.switchTab({ url: '/pages/index/index' });
- }
- }
- // 跳转进度
- function handleSeek(e: any) {
- audioStore.seek(e.detail.value);
- }
- // 切换播放/暂停
- function togglePlay() {
- audioStore.togglePlay();
- }
- // 播放模式
- function togglePlayMode() {
- audioStore.togglePlayMode();
- }
- function getPlayModeIcon() {
- const icons = {
- sequence: '🔂',
- loop: '🔁',
- single: '🔂',
- random: '🔀',
- };
- return icons[audioStore.playMode] || '🔂';
- }
- function getPlayModeText() {
- const texts = {
- sequence: '顺序',
- loop: '循环',
- single: '单曲',
- random: '随机',
- };
- return texts[audioStore.playMode] || '顺序';
- }
- // 更新歌词文本(切换章节时调用)
- async function updateLyricsForAudio(audioItem: AudioItem) {
- let text = audioItem?.text || '';
- let lrc = (audioItem as any)?.lrcLyrics || '';
- const ad = audioItem?.audioDuration || audioStore.duration || 60;
- if (!text && !lrc) {
- try {
- const detail = await get<any>(`/book-generator/books/chapters/${audioItem.id}`);
- text = detail?.content || '';
- lrc = detail?.lrcLyrics || '';
- } catch (e) {
- console.warn('获取章节文本失败:', e);
- }
- }
- lyrics.value = generateLyricsTimeline(text, ad);
- lyrics.value = [{ text: '', start: 0, end: 0 }, ...lyrics.value];
- }
- // 上一首
- function playPrev() {
- if (hasPrev.value) {
- audioStore.playPrev();
- audio.value = audioStore.currentAudio;
- if (audio.value) updateLyricsForAudio(audio.value);
- }
- }
- // 下一首
- function playNext() {
- if (hasNext.value) {
- audioStore.playNext();
- audio.value = audioStore.currentAudio;
- if (audio.value) updateLyricsForAudio(audio.value);
- }
- }
- // 从播放列表选择
- function playFromPlaylist(index: number) {
- audioStore.playAt(index);
- audio.value = playlist.value[index];
- if (audio.value) updateLyricsForAudio(audio.value);
- }
- // 设置播放速度
- function handleSetRate(rate: number) {
- audioStore.setPlayRate(rate);
- showRatePicker.value = false;
- }
- // 睡眠定时格式化
- function formatSleepTimer(seconds: number): string {
- const mins = Math.floor(seconds / 60);
- const secs = seconds % 60;
- return `${mins}:${secs.toString().padStart(2, '0')}`;
- }
- // 设置睡眠定时
- function handleSetSleepTimer(seconds: number) {
- showSleepTimerPicker.value = false;
- if (seconds === -1) {
- // 当前播放结束
- const remaining = duration.value - currentTime.value;
- if (remaining > 0) {
- startSleepTimer(Math.ceil(remaining));
- } else {
- uni.showToast({ title: '当前播放即将结束', icon: 'none' });
- return;
- }
- } else {
- startSleepTimer(seconds);
- }
- }
- // 开始睡眠定时
- function startSleepTimer(seconds: number) {
- // 清除之前的定时器
- if (sleepTimerInterval) {
- clearInterval(sleepTimerInterval);
- }
- sleepTimer.value = { remaining: seconds, total: seconds };
- sleepTimerInterval = setInterval(() => {
- if (sleepTimer.value) {
- sleepTimer.value.remaining--;
- if (sleepTimer.value.remaining <= 0) {
- clearInterval(sleepTimerInterval!);
- sleepTimerInterval = null;
- // 暂停播放(currentTime已保存,下次打开可继续)
- audioStore.pause();
- sleepTimer.value = null;
- uni.showToast({ title: '睡眠定时已到,自动暂停', icon: 'none' });
- }
- }
- }, 1000);
- }
- // 取消睡眠定时
- function cancelSleepTimer() {
- showSleepTimerPicker.value = false;
- if (sleepTimerInterval) {
- clearInterval(sleepTimerInterval);
- sleepTimerInterval = null;
- }
- sleepTimer.value = null;
- uni.showToast({ title: '已取消睡眠定时', icon: 'none' });
- }
- // 切换收藏
- async function toggleFavorite() {
- if (!audio.value) return;
- try {
- const result = await put<{ isFavorite: boolean }>(`/audio/${audio.value._id}/favorite`);
- audio.value.isFavorite = result.isFavorite;
- uni.showToast({
- title: result.isFavorite ? '已收藏' : '已取消收藏',
- icon: 'success',
- });
- } catch (error) {
- console.error('操作失败:', error);
- }
- }
- /**
- * 复制分享链接(旧版本兼容)
- */
- function copyShareLink(): string {
- if (!audio.value) return '';
- // #ifdef H5
- const shareUrl = `${window.location.origin}/#/pages/player/index?id=${audio.value._id}`;
- // #endif
- // #ifndef H5
- // App 端使用 pages.json 中配置的原生分享或直接显示页面路径
- const shareUrl = `pages/player/index?id=${audio.value._id}`;
- // #endif
- return shareUrl;
- }
- </script>
- <style scoped>
- .page {
- height: 100vh;
- background: linear-gradient(180deg, #1f2937 0%, #111827 100%);
- padding-bottom: constant(safe-area-inset-bottom);
- padding-bottom: env(safe-area-inset-bottom);
- width: 100%;
- box-sizing: border-box;
- overflow: hidden;
- display: flex;
- flex-direction: column;
- }
- /* 空状态 */
- .empty-state {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- padding: 200rpx 40rpx;
- text-align: center;
- }
- .empty-icon {
- font-size: 120rpx;
- margin-bottom: 32rpx;
- opacity: 0.5;
- }
- .empty-title {
- font-size: 36rpx;
- font-weight: 600;
- color: #ffffff;
- margin-bottom: 16rpx;
- }
- .empty-desc {
- font-size: 28rpx;
- color: #9ca3af;
- margin-bottom: 48rpx;
- }
- .empty-btn {
- padding: 24rpx 64rpx;
- background: linear-gradient(135deg, #4F46E5 0%, #7C3AED 100%);
- color: #ffffff;
- border-radius: 48rpx;
- font-size: 28rpx;
- border: none;
- }
- .nav-bar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 20rpx 32rpx 12rpx;
- }
- .nav-center {
- display: flex;
- flex-direction: column;
- align-items: center;
- }
- .nav-actions {
- display: flex;
- align-items: center;
- gap: 8rpx;
- }
- .nav-btn {
- width: 64rpx;
- height: 64rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- transition: transform 0.15s ease;
- }
- .nav-btn:active {
- transform: scale(0.85);
- }
- .sleep-timer-badge {
- display: flex;
- align-items: center;
- background: rgba(79, 70, 229, 0.3);
- padding: 8rpx 16rpx;
- border-radius: 24rpx;
- margin-top: 8rpx;
- }
- .sleep-timer-icon {
- font-size: 20rpx;
- margin-right: 6rpx;
- }
- .sleep-timer-text {
- font-size: 22rpx;
- color: #a5b4fc;
- font-weight: 500;
- }
- .nav-icon {
- font-size: 36rpx;
- color: #ffffff;
- }
- .nav-btn-lyrics {
- width: auto;
- padding: 0 16rpx;
- gap: 4rpx;
- }
- .nav-btn-label {
- font-size: 20rpx;
- color: #a5b4fc;
- white-space: nowrap;
- }
- .nav-title {
- font-size: 32rpx;
- font-weight: 600;
- color: #ffffff;
- }
- .cover-section {
- display: flex;
- flex-direction: column;
- align-items: center;
- padding: 24rpx 0 16rpx;
- }
- .cover {
- width: 180rpx;
- height: 180rpx;
- background: linear-gradient(135deg, #4f46e5 0%, #818cf8 100%);
- border-radius: 24rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- margin-bottom: 16rpx;
- box-shadow: 0 8rpx 24rpx rgba(79, 70, 229, 0.4);
- }
- .cover-icon {
- font-size: 64rpx;
- }
- .title {
- font-size: 32rpx;
- font-weight: 600;
- color: #ffffff;
- text-align: center;
- margin-bottom: 8rpx;
- max-width: 600rpx;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .meta {
- font-size: 24rpx;
- color: #9ca3af;
- }
- .text-preview {
- margin: 0 32rpx;
- padding: 24rpx;
- background: rgba(255, 255, 255, 0.05);
- border-radius: 16rpx;
- width: calc(100% - 64rpx);
- box-sizing: border-box;
- overflow: hidden;
- }
- .lyrics-container {
- display: flex;
- flex-direction: column;
- width: 100%;
- box-sizing: border-box;
- }
- .lyric-line {
- padding: 8rpx 16rpx;
- border-radius: 12rpx;
- opacity: 0.5;
- width: 100%;
- box-sizing: border-box;
- margin-bottom: 8rpx;
- }
- .lyric-line.active {
- background: rgba(79, 70, 229, 0.2);
- opacity: 1;
- }
- .lyric-text {
- font-size: 24rpx;
- color: #d1d5db;
- line-height: 1.6;
- white-space: pre-wrap;
- word-break: break-word;
- width: 100%;
- box-sizing: border-box;
- }
- .lyric-line.active .lyric-text {
- color: #ffffff;
- font-weight: 500;
- }
- .progress-section {
- display: flex;
- align-items: center;
- padding: 32rpx;
- }
- .time {
- font-size: 22rpx;
- color: #9ca3af;
- width: 80rpx;
- text-align: center;
- }
- .progress-slider {
- flex: 1;
- margin: 0 16rpx;
- }
- .controls {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 24rpx 32rpx;
- }
- .controls-side,
- .controls-center {
- display: flex;
- align-items: center;
- }
- .controls-side {
- margin-right: 16rpx;
- gap: 12rpx;
- }
- .controls-center {
- margin-right: 24rpx;
- }
- .control-btn {
- width: 70rpx;
- height: 70rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: 50%;
- background: rgba(255, 255, 255, 0.15);
- }
- .control-btn.disabled {
- opacity: 0.4;
- }
- .control-btn.share-btn {
- flex-direction: column;
- width: 100rpx;
- height: 80rpx;
- background: linear-gradient(135deg, rgba(79, 70, 229, 0.3) 0%, rgba(129, 140, 248, 0.3) 100%);
- }
- .share-label {
- font-size: 18rpx;
- color: #a5b4fc;
- margin-top: 4rpx;
- }
- .control-text {
- font-size: 24rpx;
- color: #ffffff;
- font-weight: 600;
- }
- /* 播放速度显示按钮 */
- .speed-display-btn {
- width: auto;
- min-width: 120rpx;
- height: 70rpx;
- padding: 0 20rpx;
- border-radius: 35rpx;
- gap: 6rpx;
- background: linear-gradient(135deg, rgba(79, 70, 229, 0.35) 0%, rgba(129, 140, 248, 0.25) 100%);
- border: 2rpx solid rgba(129, 140, 248, 0.3);
- }
- .speed-label {
- font-size: 20rpx;
- color: #a5b4fc;
- font-weight: 500;
- }
- .speed-value {
- font-size: 28rpx;
- color: #ffffff;
- font-weight: 700;
- }
- .control-icon {
- font-size: 36rpx;
- color: #ffffff;
- }
- .play-btn {
- width: 110rpx;
- height: 110rpx;
- background: linear-gradient(135deg, #4f46e5 0%, #818cf8 100%);
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- box-shadow: 0 8rpx 24rpx rgba(79, 70, 229, 0.5);
- border: 4rpx solid rgba(255, 255, 255, 0.3);
- }
- .play-icon {
- font-size: 44rpx;
- color: #ffffff;
- margin-left: 6rpx;
- }
- .bottom-actions {
- display: flex;
- justify-content: center;
- padding: 48rpx;
- }
- .bottom-actions .action-btn {
- margin: 0 40rpx;
- }
- .action-btn {
- display: flex;
- flex-direction: column;
- align-items: center;
- background: transparent;
- border: none;
- padding: 0;
- border-radius: 50%;
- padding: 16rpx;
- background: rgba(255, 255, 255, 0.1);
- }
- .action-icon {
- font-size: 48rpx;
- margin-bottom: 8rpx;
- }
- .action-text {
- font-size: 24rpx;
- color: #9ca3af;
- }
- .rate-picker {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(0, 0, 0, 0.5);
- display: flex;
- align-items: flex-end;
- z-index: 1000;
- }
- .rate-content {
- width: 100%;
- background: #1f2937;
- border-radius: 32rpx 32rpx 0 0;
- padding: 48rpx;
- }
- .rate-title {
- font-size: 32rpx;
- font-weight: 600;
- color: #ffffff;
- text-align: center;
- margin-bottom: 32rpx;
- }
- .rate-options {
- display: flex;
- flex-wrap: wrap;
- }
- .rate-item {
- flex: 1;
- min-width: 180rpx;
- height: 80rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- background: #374151;
- border-radius: 16rpx;
- border: 2rpx solid transparent;
- margin: 12rpx;
- }
- .rate-item.active {
- background: #4f46e5;
- border-color: #6366f1;
- }
- .rate-item text {
- font-size: 28rpx;
- color: #ffffff;
- }
- .sleep-timer-cancel {
- margin-top: 24rpx;
- padding: 24rpx;
- background: rgba(239, 68, 68, 0.2);
- border-radius: 16rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .sleep-timer-cancel text {
- font-size: 28rpx;
- color: #fca5a5;
- }
- /* 移动端播放列表设计 - 直接嵌入界面 */
- .playlist-section-mobile {
- flex: 1;
- display: flex;
- flex-direction: column;
- min-height: 0;
- background: rgba(31, 41, 55, 0.8);
- border-top-left-radius: 32rpx;
- border-top-right-radius: 32rpx;
- padding: 20rpx 0 0;
- box-shadow: 0 -8rpx 16rpx rgba(0, 0, 0, 0.1);
- }
- .playlist-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0 32rpx 20rpx;
- border-bottom: 1rpx solid rgba(255, 255, 255, 0.1);
- margin-bottom: 16rpx;
- }
- .playlist-header-left {
- display: flex;
- align-items: center;
- }
- .playlist-header-left .playlist-mode-icon,
- .playlist-header-left .playlist-count {
- margin-left: 16rpx;
- }
- .playlist-header-right {
- display: flex;
- align-items: center;
- padding: 12rpx 20rpx;
- background: rgba(255, 255, 255, 0.1);
- border-radius: 40rpx;
- }
- .playlist-title {
- font-size: 32rpx;
- font-weight: 600;
- color: #ffffff;
- }
- .playlist-count {
- font-size: 24rpx;
- color: #9ca3af;
- }
- .playlist-mode-icon {
- font-size: 28rpx;
- }
- .playlist-mode-text {
- font-size: 24rpx;
- color: #ffffff;
- font-weight: 500;
- }
- .playlist-list-mobile {
- flex: 1;
- min-height: 0;
- max-height: 60vh;
- padding: 0 16rpx 16rpx;
- box-sizing: border-box;
- }
- .playlist-item {
- display: flex;
- align-items: center;
- padding: 24rpx 32rpx;
- border-radius: 16rpx;
- }
- .playlist-item .playlist-info {
- margin-left: 20rpx;
- }
- .playlist-item .playing-icon {
- margin-left: auto;
- }
- .playlist-item.active {
- background: rgba(79, 70, 229, 0.2);
- }
- .playlist-index {
- width: 50rpx;
- height: 50rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- background: rgba(255, 255, 255, 0.15); /* 更明显的背景 */
- border-radius: 50%;
- font-size: 24rpx;
- color: #ffffff; /* 白色文字 */
- flex-shrink: 0;
- font-weight: 600; /* 加粗 */
- }
- .playlist-item.active .playlist-index {
- background: #4f46e5;
- color: #ffffff;
- }
- .playlist-info {
- flex: 1;
- min-width: 0;
- }
- .playlist-item-title {
- font-size: 28rpx;
- color: #ffffff;
- display: block;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- margin-bottom: 6rpx;
- font-weight: 500;
- }
- .playlist-item-meta {
- font-size: 24rpx;
- color: #9ca3af;
- }
- .playing-icon {
- width: 50rpx;
- height: 50rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 24rpx;
- color: #4f46e5;
- background: rgba(79, 70, 229, 0.2); /* 背景使其更明显 */
- border-radius: 50%; /* 圆形背景 */
- flex-shrink: 0;
- }
- </style>
- // ====== pages/payment-confirm/index.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>
- <!-- 支付内容 -->
- <view class="content">
- <!-- 订单信息 -->
- <view class="order-card">
- <text class="order-title">订单信息</text>
- <view class="order-info">
- <text class="info-label">商品</text>
- <text class="info-value">{{ orderInfo.planName || '-' }}</text>
- </view>
- <view class="order-info">
- <text class="info-label">订单号</text>
- <text class="info-value">{{ orderInfo.orderNo || '-' }}</text>
- </view>
- <view class="order-info highlight">
- <text class="info-label">应付金额</text>
- <text class="info-value price">¥{{ orderInfo.amount || 0 }}</text>
- </view>
- </view>
- <!-- 支付方式选择 -->
- <view class="payment-section">
- <text class="section-title">选择支付方式</text>
- <!-- 公众号环境提示 -->
- <view class="wechat-notice" v-if="inWechat">
- <text class="wechat-notice-icon">💚</text>
- <text class="wechat-notice-text">检测到微信环境,将自动使用微信支付</text>
- </view>
- <view class="payment-methods">
- <view
- class="payment-method"
- :class="{ active: paymentMethod === 'alipay' }"
- @click="paymentMethod = 'alipay'"
- >
- <view class="method-radio">
- <view class="radio-inner" v-if="paymentMethod === 'alipay'"></view>
- </view>
- <text class="method-icon">💙</text>
- <text class="method-name">支付宝</text>
- </view>
- <view
- class="payment-method"
- :class="{ active: paymentMethod === 'wechat' }"
- @click="paymentMethod = 'wechat'"
- >
- <view class="method-radio">
- <view class="radio-inner" v-if="paymentMethod === 'wechat'"></view>
- </view>
- <text class="method-icon">🟢</text>
- <text class="method-name">微信支付</text>
- <text class="method-badge" v-if="inWechat">推荐</text>
- </view>
- </view>
- </view>
- <!-- 操作按钮 -->
- <view class="actions">
- <button class="btn-secondary" @click="goBack">取消</button>
- <button class="btn-primary" @click="handlePay">确认支付</button>
- </view>
- <!-- 提示 -->
- <view class="tips">
- <text class="tips-title">温馨提示</text>
- <text class="tips-text">• 请在24小时内完成支付</text>
- <text class="tips-text">• 支付成功后权益将自动到账</text>
- <text class="tips-text">• 如支付遇到问题,请联系客服</text>
- </view>
- </view>
- </view>
- </template>
- <script setup lang="ts">
- import { ref, onMounted } from 'vue';
- import { post, get } from '../../utils/request';
- // ==================== 微信浏览器检测 ====================
- function isInWechat(): boolean {
- // #ifdef H5
- const ua = navigator.userAgent.toLowerCase();
- return ua.includes('micromessenger');
- // #endif
- // #ifndef H5
- return false;
- // #endif
- }
- // 获取不含 hash 的基础URL(用于OAuth redirect_uri)
- function getBaseUrl(): string {
- // #ifdef H5
- return window.location.origin + window.location.pathname;
- // #endif
- return '';
- }
- // 保存当前页面上下文到 sessionStorage(OAuth 来回会丢失 hash 路由状态)
- function savePageContext() {
- try {
- sessionStorage.setItem('wx_oauth_return', window.location.href);
- } catch (_) {}
- }
- // 恢复页面上下文
- function restorePageContext(): string | null {
- try {
- return sessionStorage.getItem('wx_oauth_return');
- } catch (_) { return null; }
- }
- const orderInfo = ref<{
- orderNo: string;
- amount: number;
- planName: string;
- }>({
- orderNo: '',
- amount: 0,
- planName: ''
- });
- const planId = ref<number>(0);
- const productType = ref<string>(''); // 'subscription' | 'token-pack'
- const tokenPackQuantity = ref<number>(0);
- const paymentMethod = ref<'alipay' | 'wechat'>('alipay');
- const inWechat = ref(false);
- const openid = ref('');
- const paying = ref(false);
- const lastPayClick = ref(0); // 防重:记录上次点击时间
- let checkTimer: ReturnType<typeof setInterval> | null = null;
- // ==================== 微信 OAuth 处理 ====================
- async function handleWechatOAuth() {
- // #ifdef H5
- // OAuth 回调已在 App.vue 全局处理,这里只需读取缓存的 openid
- const cachedOpenid = localStorage.getItem('wx_openid');
- if (cachedOpenid) {
- openid.value = cachedOpenid;
- console.log('[WeChat Pay] 使用缓存的 openid');
- return;
- }
- // 双重保险:检查 URL search 中的 code(如果 App.vue 未拦截到)
- const searchParams = new URLSearchParams(window.location.search);
- const code = searchParams.get('code');
- if (code) {
- uni.showLoading({ title: '授权中...' });
- try {
- const result = await post<{ openid: string }>('/payment/wechat/openid', { code });
- openid.value = result.openid;
- localStorage.setItem('wx_openid', result.openid);
- console.log('[WeChat Pay] openid 获取成功,自动重新发起支付');
- // 清除 URL 中的 code/state
- searchParams.delete('code');
- searchParams.delete('state');
- const newSearch = searchParams.toString();
- const newUrl = window.location.origin + window.location.pathname
- + (newSearch ? '?' + newSearch : '');
- window.history.replaceState({}, '', newUrl);
- // 自动重新发起支付(不用用户再点一次)
- setTimeout(() => handleWechatJsapiPay(), 100);
- } catch (e) {
- console.error('[WeChat Pay] 获取 openid 失败:', e);
- } finally {
- uni.hideLoading();
- }
- }
- // #endif
- }
- // 跳转到微信 OAuth 授权
- async function redirectToWechatOAuth() {
- // #ifdef H5
- try {
- // 保存当前完整 URL(含 hash 路由参数),OAuth 回来时恢复
- savePageContext();
- // 用不含 # 的 baseUrl 作为 redirect_uri,避免微信报"非法字符"
- const baseUrl = getBaseUrl();
- const result = await get<{ oauthUrl: string; state: string }>('/payment/wechat/oauth-url', {
- redirect: baseUrl
- });
- window.location.href = result.oauthUrl;
- } catch (e) {
- console.error('[WeChat Pay] 获取OAuth链接失败:', e);
- }
- // #endif
- }
- onMounted(async () => {
- const pages = getCurrentPages();
- const currentPage = pages[pages.length - 1] as any;
- // #ifdef APP-PLUS
- const options = currentPage?.$page?.options || {};
- // #endif
- // #ifndef APP-PLUS
- const options = currentPage?.options || {};
- // #endif
- // 检查产品类型
- productType.value = options.productType || 'subscription';
- if (productType.value === 'token-pack') {
- // 积分包支付
- tokenPackQuantity.value = parseInt(options.quantity) || 1;
- orderInfo.value.planName = decodeURIComponent(options.planName || '积分包');
- orderInfo.value.amount = parseFloat(options.amount) || 0;
- } else if (options.planId) {
- planId.value = parseInt(options.planId);
- orderInfo.value.planName = decodeURIComponent(options.planName || '会员订阅');
- orderInfo.value.amount = parseFloat(options.amount) || 0;
- }
- // 检测是否在微信浏览器中
- if (isInWechat()) {
- inWechat.value = true;
- // 默认选中微信支付
- paymentMethod.value = 'wechat';
- // 处理 OAuth 回调
- await handleWechatOAuth();
- // 🔧 如果刚从 OAuth 回来 + openid 已缓存,自动触发支付
- try {
- const justDidOAuth = sessionStorage.getItem('wx_oauth_just_done');
- if (justDidOAuth === '1') {
- sessionStorage.removeItem('wx_oauth_just_done');
- const cachedOpenid = localStorage.getItem('wx_openid');
- if (cachedOpenid) {
- console.log('[WeChat Pay] 检测到 OAuth 刚完成,自动触发支付');
- // 延迟执行,确保页面已完全渲染
- setTimeout(() => {
- openid.value = cachedOpenid;
- handleWechatJsapiPay();
- }, 500);
- }
- }
- } catch (_) {}
- // 微信环境不预创建订单(JSAPI 支付时另外创建)
- } else {
- // 非微信环境:进入页面时直接创建订单
- if (productType.value === 'token-pack' || planId.value) {
- await createOrder();
- }
- }
- });
- async function createOrder() {
- try {
- uni.showLoading({ title: '创建订单...' });
- const returnUrl = `${window.location.origin}/#/pages/payment-result/index`;
- let result: any;
- if (productType.value === 'token-pack') {
- // 积分包订单
- result = await post<{
- orderNo: string;
- amount: number;
- paymentUrl?: string;
- qrcode?: string;
- planName?: string;
- }>('/payment/token-packs/create', {
- quantity: tokenPackQuantity.value,
- paymentMethod: paymentMethod.value,
- returnUrl
- });
- } else {
- // 套餐订阅订单
- result = await post<{
- orderNo: string;
- amount: number;
- paymentUrl?: string;
- qrcode?: string;
- planName?: string;
- }>('/payment/create', {
- planId: planId.value,
- paymentMethod: paymentMethod.value,
- returnUrl
- });
- }
- orderInfo.value.orderNo = result.orderNo;
- uni.hideLoading();
- } catch (error) {
- uni.hideLoading();
- console.error('创建订单失败:', error);
- }
- }
- // 查询微信订单状态(用于支付后确认)
- async function checkWechatOrderStatus(orderNo: string): Promise<string | null> {
- try {
- const result = await get<any>(`/payment/wechat/query/${orderNo}`);
- const state = result?.data?.trade_state || result?.trade_state || null;
- console.log('[WeChat Pay] 订单查询结果:', orderNo, state);
- return state;
- } catch (e) {
- console.error('[WeChat Pay] 订单查询失败:', e);
- return null;
- }
- }
- // ==================== 微信 JSAPI 支付(公众号内) ====================
- // 使用 WeixinJSBridge.invoke 直接调起(官方 V3 推荐方式,无需 JSSDK 配置)
- // 参考: https://pay.weixin.qq.com/doc/v3/merchant/4012791870
- // 常见失败原因检查清单:
- // 1. timeStamp 必须是字符串(不是数字) - 后端已修复
- // 2. 微信商户平台需配置「支付授权目录」
- // 3. 公众号后台需配置「网页授权域名」
- // 4. 使用正确的公众号 appId(非小程序 appId)
- let pendingPayResult: any = null; // 用于 WeixinJSBridge 异步注入场景
- async function handleWechatJsapiPay() {
- // 如果没有 openid,需要先授权
- if (!openid.value) {
- console.log('[WeChat Pay] 无 openid,跳转 OAuth 授权');
- await redirectToWechatOAuth();
- return;
- }
- paying.value = true;
- uni.showLoading({ title: '创建订单...' });
- try {
- // 创建 JSAPI 支付订单
- let result: any;
- if (productType.value === 'token-pack') {
- result = await post<any>('/payment/wechat/jsapi', {
- productType: 'token-pack',
- quantity: tokenPackQuantity.value,
- openid: openid.value
- });
- } else {
- result = await post<any>('/payment/wechat/jsapi', {
- planId: planId.value,
- openid: openid.value
- });
- }
- // 注意:post 函数返回的是 response.data,即 { code, message, data: {...} }
- const responseData = result.data || result;
- orderInfo.value.orderNo = responseData.orderNo;
- // 🔧 保存待调用结果,用于 WeixinJSBridge 异步注入场景
- pendingPayResult = responseData;
- console.log('[WeChat Pay] 订单创建成功,准备调起支付');
- console.log('[WeChat Pay] payParams 详情:', JSON.stringify({
- appId: responseData.payParams?.appId,
- timeStamp: `${responseData.payParams?.timeStamp} (type:${typeof responseData.payParams?.timeStamp})`,
- nonceStr: responseData.payParams?.nonceStr,
- package: responseData.payParams?.package,
- signType: responseData.payParams?.signType,
- paySign_length: responseData.payParams?.paySign?.length,
- }));
- uni.hideLoading();
- uni.showLoading({ title: '拉起支付...' });
- // 调用 WeixinJSBridge(带自动重试)
- tryInvokeWeixinPay(responseData);
- } catch (error: any) {
- paying.value = false;
- uni.hideLoading();
- console.error('[WeChat Pay] 创建JSAPI支付失败:', error);
- const errMsg = error?.message || String(error);
- if (errMsg.includes('openid')) {
- localStorage.removeItem('wx_openid');
- openid.value = '';
- await redirectToWechatOAuth();
- return;
- }
- // 更友好的错误提示
- let tip = '支付失败';
- if (errMsg.includes('appid')) tip = '微信AppID配置错误';
- else if (errMsg.includes('mchid')) tip = '商户号配置错误';
- else if (errMsg.includes('PARAM_ERROR')) tip = '支付参数错误,请检查配置';
- else if (errMsg.includes('SIGN_ERROR')) tip = '签名验证失败,请检查证书';
- else if (errMsg.includes('NOT_ENOUGH')) tip = '商户余额不足';
- else tip = errMsg.substring(0, 50);
- uni.showModal({
- title: '支付失败',
- content: `错误:${tip}\n\n请截图发给客服排查`,
- showCancel: false,
- confirmText: '知道了'
- });
- }
- }
- // 尝试调起微信支付(带 WeixinJSBridge 检测和重试)
- function tryInvokeWeixinPay(payResult: any, retryCount: number = 0) {
- const MAX_RETRY = 30; // 最多等 3 秒(100ms × 30)
- const wjsBridge = (window as any).WeixinJSBridge;
- if (wjsBridge && typeof wjsBridge.invoke === 'function') {
- // ✅ WeixinJSBridge 已就绪,直接调起
- console.log('[WeChat Pay] WeixinJSBridge 就绪,调起支付');
- invokeWeixinJSBridge(payResult);
- return;
- }
- // WeixinJSBridge 未就绪,等待注入
- if (retryCount === 0) {
- console.log('[WeChat Pay] WeixinJSBridge 未就绪,开始等待注入...');
- // 方式 1:监听官方注入事件
- if (document.addEventListener) {
- document.addEventListener('WeixinJSBridgeReady', () => {
- console.log('[WeChat Pay] 收到 WeixinJSBridgeReady 事件');
- invokeWeixinJSBridge(pendingPayResult || payResult);
- }, { once: true });
- }
- // 方式 2:兼容旧版 IE
- if ((document as any).attachEvent) {
- (document as any).attachEvent('onWeixinJSBridgeReady', () => {
- console.log('[WeChat Pay] 收到 onWeixinJSBridgeReady 事件');
- invokeWeixinJSBridge(pendingPayResult || payResult);
- });
- }
- }
- // 方式 3:轮询检测(兜底机制,解决事件丢失问题)
- if (retryCount < MAX_RETRY) {
- setTimeout(() => tryInvokeWeixinPay(payResult, retryCount + 1), 100);
- } else {
- // 超时:WeixinJSBridge 始终未注入
- paying.value = false;
- uni.hideLoading();
- console.error('[WeChat Pay] ❌ WeixinJSBridge 注入超时(等待3秒)');
- uni.showModal({
- title: '环境异常',
- content: '请在微信内打开此页面进行支付\n\n提示:\n1. 确认在微信浏览器中访问\n2. 确认域名已在公众号配置',
- showCancel: false,
- });
- }
- }
- // 调用 WeixinJSBridge.invoke
- function invokeWeixinJSBridge(payResult: any) {
- const payParams = payResult?.payParams;
- if (!payParams || !payParams.package) {
- console.error('[WeChat Pay] ❌ payParams 异常:', JSON.stringify(payResult));
- paying.value = false;
- uni.hideLoading();
- uni.showModal({
- title: '参数异常',
- content: '支付参数不完整,请重试',
- showCancel: false,
- });
- return;
- }
- // 🔧 关键校验:确保所有字段都是字符串类型
- const wxp = {
- appId: String(payParams.appId || ''),
- timeStamp: String(payParams.timeStamp || ''), // ⚠️ 必须是字符串!
- nonceStr: String(payParams.nonceStr || ''),
- package: String(payParams.package || ''), // prepay_id=wx...
- signType: String(payParams.signType || 'RSA'),
- paySign: String(payParams.paySign || ''),
- };
- // 校验必填字段
- if (!wxp.appId || !wxp.timeStamp || !wxp.nonceStr || !wxp.package || !wxp.paySign) {
- console.error('[WeChat Pay] ❌ 支付参数不完整:', JSON.stringify(wxp));
- paying.value = false;
- uni.hideLoading();
- uni.showModal({
- title: '参数不完整',
- content: `缺少字段:${[
- !wxp.appId && 'appId',
- !wxp.timeStamp && 'timeStamp',
- !wxp.nonceStr && 'nonceStr',
- !wxp.package && 'package',
- !wxp.paySign && 'paySign',
- ].filter(Boolean).join(', ')}`,
- showCancel: false,
- });
- return;
- }
- console.log('[WeChat Pay] 🚀 调用 WeixinJSBridge.invoke');
- console.log('[WeChat Pay] 参数:', JSON.stringify({
- ...wxp,
- paySign: wxp.paySign.substring(0, 30) + '...',
- package_length: wxp.package.length,
- }));
- (window as any).WeixinJSBridge.invoke(
- 'getBrandWCPayRequest',
- wxp,
- async (res: any) => {
- uni.hideLoading();
- paying.value = false;
- console.log('[WeChat Pay] 回调结果:', JSON.stringify(res));
- // 处理回调
- await handleWechatPayCallback(res, payResult.orderNo);
- }
- );
- }
- // 统一处理微信支付回调结果
- async function handleWechatPayCallback(res: any, orderNo: string) {
- // err_msg 可能的值:
- // get_brand_wcpay_request:ok - 支付成功
- // get_brand_wcpay_request:cancel - 用户取消
- // get_brand_wcpay_request:fail - 支付失败
- const errMsg = res?.err_msg || res?.errMsg || '';
- if (errMsg.indexOf(':ok') !== -1) {
- // 前端返回成功,查询服务端确认
- console.log('[WeChat Pay] ✅ 前端返回成功,查询服务端确认...');
- const orderStatus = await checkWechatOrderStatus(orderNo);
- if (orderStatus === 'SUCCESS') {
- uni.showModal({
- title: '支付成功',
- content: productType.value === 'token-pack' ? '积分包购买成功!' : '恭喜您订阅成功!',
- showCancel: false,
- success: () => uni.switchTab({ url: '/pages/mine/index' })
- });
- } else {
- // 可能回调还没到服务端
- uni.showToast({ title: '订单确认中,请稍后查看', icon: 'none', duration: 3000 });
- }
- return;
- }
- // 支付未成功
- console.log('[WeChat Pay] ❌ 支付未成功:', errMsg);
- // 官方建议:无论如何都查询一次
- const orderStatus = await checkWechatOrderStatus(orderNo);
- if (orderStatus === 'SUCCESS') {
- uni.showModal({
- title: '支付成功',
- content: productType.value === 'token-pack' ? '积分包购买成功!' : '恭喜您订阅成功!',
- showCancel: false,
- success: () => uni.switchTab({ url: '/pages/mine/index' })
- });
- return;
- }
- // 真正失败 - 显示具体错误和解决建议
- const isCancel = errMsg.indexOf(':cancel') !== -1;
- if (isCancel) {
- uni.showToast({ title: '已取消支付', icon: 'none' });
- return;
- }
- // 🔍 构造详细错误诊断
- let diagnosis = '';
- if (errMsg.includes('no_balance')) diagnosis = '\n原因:商户余额不足';
- else if (errMsg.includes('bank_error')) diagnosis = '\n原因:银行卡异常';
- else if (errMsg.includes('not_sufficient_funds')) diagnosis = '\n原因:余额不足';
- else if (errMsg.includes('param_error') || errMsg.includes('sign')) diagnosis = '\n原因:支付参数错误(请检查配置)';
- else diagnosis = `\n错误码:${errMsg}`;
- uni.showModal({
- title: '支付失败',
- content: `支付未完成${diagnosis}\n\n如持续失败,请截图联系客服`,
- showCancel: true,
- confirmText: '重新支付',
- cancelText: '关闭',
- success: (modalRes: any) => {
- if (modalRes.confirm) {
- handleWechatJsapiPay();
- }
- }
- });
- }
- async function handlePay() {
- // 防重处理:2秒内禁止重复点击(官方要求按钮防抖)
- const now = Date.now();
- if (now - lastPayClick.value < 2000) {
- console.log('[Payment] 防重:忽略重复点击');
- return;
- }
- lastPayClick.value = now;
- if (!planId.value && productType.value !== 'token-pack') {
- uni.showToast({ title: '参数错误', icon: 'none' });
- return;
- }
- // 公众号内微信支付 → 走 JSAPI
- if (inWechat.value && paymentMethod.value === 'wechat') {
- await handleWechatJsapiPay();
- return;
- }
- try {
- uni.showLoading({ title: '创建订单...' });
- // #ifdef H5
- const returnUrl = `${window.location.origin}/#/pages/payment-result/index`;
- // #endif
- // #ifndef H5
- // App 端使用相对路径
- const returnUrl = `/#/pages/payment-result/index`;
- // #endif
- let orderResult: any;
- if (productType.value === 'token-pack') {
- orderResult = await post<any>('/payment/token-packs/create', {
- quantity: tokenPackQuantity.value,
- paymentMethod: paymentMethod.value,
- returnUrl
- });
- } else {
- orderResult = await post<any>('/payment/create', {
- planId: planId.value,
- paymentMethod: paymentMethod.value,
- returnUrl
- });
- }
- uni.hideLoading();
- orderInfo.value.orderNo = orderResult.orderNo;
- // 微信支付 - 显示二维码
- if (paymentMethod.value === 'wechat' && orderResult.qrcode) {
- uni.navigateTo({
- url: `/pages/alipay-qrcode/index?orderNo=${orderResult.orderNo}&amount=${orderResult.amount}&planName=${encodeURIComponent(orderResult.planName || orderInfo.value.planName)}&qrcode=${encodeURIComponent(orderResult.qrcode)}&method=wechat`
- });
- return;
- }
- // 支付宝 H5 跳转
- // #ifdef H5
- if (orderResult.paymentUrl) {
- uni.showToast({ title: '即将跳转支付宝...', icon: 'none' });
- setTimeout(() => {
- window.location.href = orderResult.paymentUrl!;
- }, 1000);
- }
- // #endif
- // 开发环境模拟
- // #ifndef H5
- uni.showModal({
- title: '模拟支付',
- content: `确认支付 ¥${orderResult.amount}?\n(开发环境模拟)`,
- success: async (res) => {
- if (res.confirm) {
- await mockPay(orderResult.orderNo);
- }
- },
- });
- // #endif
- } catch (error: any) {
- uni.hideLoading();
- console.error('创建订单失败:', error);
- uni.showToast({ title: error.message || '创建订单失败', icon: 'none' });
- }
- }
- async function mockPay(orderNo: string) {
- try {
- uni.showLoading({ title: '支付中...' });
- await post('/payment/mock', { orderNo });
- uni.hideLoading();
- uni.showModal({
- title: '支付成功',
- content: productType.value === 'token-pack' ? '积分包购买成功!' : '恭喜您订阅成功!',
- showCancel: false,
- success: () => {
- uni.switchTab({ url: '/pages/mine/index' });
- }
- });
- } catch (error) {
- uni.hideLoading();
- uni.showToast({ title: '支付失败', icon: 'none' });
- }
- }
- function goBack() {
- const pages = getCurrentPages();
- if (pages.length > 1) {
- uni.navigateBack();
- } else {
- uni.switchTab({ url: '/pages/member/index' });
- }
- }
- </script>
- <style scoped>
- .page {
- min-height: 100vh;
- background: #f5f5f5;
- }
- .nav-bar {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- height: 88rpx;
- padding-top: env(safe-area-inset-top);
- background: #ffffff;
- border-bottom: 1px solid #e5e7eb;
- z-index: 100;
- }
- .nav-content {
- height: 100%;
- display: flex;
- align-items: center;
- padding: 0 24rpx;
- }
- .nav-left {
- width: 80rpx;
- display: flex;
- align-items: center;
- }
- .back-icon {
- font-size: 36rpx;
- color: #333;
- }
- .page-title {
- flex: 1;
- text-align: center;
- font-size: 32rpx;
- font-weight: 500;
- color: #333;
- margin-right: 80rpx;
- }
- .content {
- padding-top: calc(120rpx + env(safe-area-inset-top));
- padding-left: 32rpx;
- padding-right: 32rpx;
- }
- .order-card {
- background: #ffffff;
- border-radius: 16rpx;
- padding: 32rpx;
- margin-bottom: 32rpx;
- }
- .order-title {
- font-size: 28rpx;
- font-weight: 600;
- color: #1f2937;
- margin-bottom: 24rpx;
- display: block;
- }
- .order-info {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 16rpx 0;
- border-bottom: 1px solid #f3f4f6;
- }
- .order-info:last-child {
- border-bottom: none;
- }
- .order-info.highlight {
- margin-top: 16rpx;
- padding-top: 24rpx;
- border-top: 2rpx dashed #e5e7eb;
- border-bottom: none;
- }
- .info-label {
- font-size: 26rpx;
- color: #9ca3af;
- }
- .info-value {
- font-size: 26rpx;
- color: #1f2937;
- }
- .info-value.price {
- font-size: 36rpx;
- font-weight: 700;
- color: #ef4444;
- }
- .payment-section {
- background: #ffffff;
- border-radius: 16rpx;
- padding: 32rpx;
- margin-bottom: 32rpx;
- }
- .section-title {
- font-size: 28rpx;
- font-weight: 600;
- color: #1f2937;
- margin-bottom: 24rpx;
- display: block;
- }
- .payment-methods {
- display: flex;
- flex-direction: column;
- gap: 16rpx;
- }
- .payment-method {
- display: flex;
- align-items: center;
- padding: 24rpx;
- background: #f9fafb;
- border: 3rpx solid #e5e7eb;
- border-radius: 12rpx;
- transition: all 0.3s;
- }
- .payment-method.active {
- border-color: #667eea;
- background: #f0f2ff;
- }
- .method-radio {
- width: 40rpx;
- height: 40rpx;
- border: 3rpx solid #d1d5db;
- border-radius: 50%;
- margin-right: 16rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .payment-method.active .method-radio {
- border-color: #667eea;
- }
- .radio-inner {
- width: 24rpx;
- height: 24rpx;
- background: #667eea;
- border-radius: 50%;
- }
- .method-icon {
- font-size: 36rpx;
- margin-right: 12rpx;
- }
- .method-name {
- font-size: 28rpx;
- color: #1f2937;
- flex: 1;
- }
- .method-badge {
- font-size: 22rpx;
- color: #ffffff;
- background: #10b981;
- padding: 4rpx 12rpx;
- border-radius: 8rpx;
- margin-left: 8rpx;
- }
- .wechat-notice {
- display: flex;
- align-items: center;
- padding: 16rpx 20rpx;
- background: #ecfdf5;
- border: 1rpx solid #a7f3d0;
- border-radius: 10rpx;
- margin-bottom: 20rpx;
- }
- .wechat-notice-icon {
- font-size: 32rpx;
- margin-right: 12rpx;
- }
- .wechat-notice-text {
- font-size: 24rpx;
- color: #065f46;
- }
- .actions {
- display: flex;
- gap: 24rpx;
- margin-bottom: 32rpx;
- }
- .btn-primary,
- .btn-secondary {
- flex: 1;
- height: 88rpx;
- border-radius: 44rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 30rpx;
- border: none;
- }
- .btn-primary {
- background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
- color: #ffffff;
- }
- .btn-secondary {
- background: #ffffff;
- color: #6b7280;
- border: 2rpx solid #e5e7eb;
- }
- .tips {
- background: #fef3c7;
- border-radius: 16rpx;
- padding: 24rpx;
- margin-bottom: 48rpx;
- }
- .tips-title {
- font-size: 24rpx;
- font-weight: 600;
- color: #92400e;
- display: block;
- margin-bottom: 12rpx;
- }
- .tips-text {
- font-size: 22rpx;
- color: #78350f;
- display: block;
- margin-bottom: 6rpx;
- }
- </style>
|