02-源代码文档.txt 100 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234
  1. 盛果智能应用软件 V1.0 源代码文档
  2. 软件全称:盛果智能应用软件
  3. 软件简称:盛果
  4. 版本号:V1.0
  5. # 前端源代码
  6. // ====== pages/create/index.vue ======
  7. <template>
  8. <view class="page">
  9. <!-- 主内容区:只有文本输入 -->
  10. <view class="main-content">
  11. <!-- 文本输入 -->
  12. <view class="input-area">
  13. <textarea
  14. v-model="text"
  15. class="text-input"
  16. placeholder="请输入要转换的文本内容..."
  17. :maxlength="2000"
  18. auto-height
  19. />
  20. <!-- 字数统计 -->
  21. <text class="word-count" :class="{ warning: text.length > 1800, error: text.length >= 2000 }">
  22. {{ text.length }}/2000
  23. </text>
  24. <!-- 已上传文档信息 -->
  25. <view v-if="uploadedDoc.fileName && !uploading" class="doc-info-bar">
  26. <text class="doc-format-badge">{{ uploadedDoc.format?.toUpperCase() || 'DOC' }}</text>
  27. <text class="doc-filename">{{ uploadedDoc.fileName }}</text>
  28. <text class="doc-meta">{{ uploadedDoc.wordCount }} 字</text>
  29. <text v-if="uploadedDoc.truncated" class="doc-truncated-hint">⚠ 已截取</text>
  30. <text class="doc-remove-btn" @click="clearDoc">✕</text>
  31. </view>
  32. <!-- 上传中 -->
  33. <view v-if="uploading" class="doc-uploading-bar">
  34. <text>正在解析文档,请稍候...</text>
  35. </view>
  36. <!-- 工具按钮行 -->
  37. <view class="tool-row">
  38. <view class="tool-btn" @click="handleUploadDoc">
  39. <text>{{ uploadedDoc.fileName ? '📄 重新上传' : '📄 上传文档' }}</text>
  40. </view>
  41. <view class="tool-btn" @click="goToAIGenerate">
  42. <text>🤖 AI 生成</text>
  43. </view>
  44. <view class="tool-btn" @click="pasteText">
  45. <text>📋 粘贴</text>
  46. </view>
  47. <view class="tool-btn" @click="clearText">
  48. <text>🗑 清空</text>
  49. </view>
  50. </view>
  51. <!-- 专辑选择(必填) -->
  52. <view class="album-selector" @click="showAlbumListPanel = true">
  53. <text class="album-selector-label">专辑</text>
  54. <text class="album-selector-value" :class="{ placeholder: !selectedAlbum }">
  55. {{ selectedAlbum ? selectedAlbum.title : '请选择专辑(必填)' }}
  56. </text>
  57. <text class="album-selector-arrow">▼</text>
  58. </view>
  59. </view>
  60. </view>
  61. <!-- 情感检测预览卡片(文本长度≥10时显示) -->
  62. <view v-if="text.length >= 10" class="emotion-card">
  63. <view class="emotion-card-header">
  64. <text class="emotion-card-icon">🎭</text>
  65. <text class="emotion-card-title">内容分析</text>
  66. <text v-if="detecting" class="emotion-detecting">检测中...</text>
  67. </view>
  68. <!-- 检测成功:显示推荐 -->
  69. <view v-if="detectedEmotion && !detecting" class="emotion-result">
  70. <view class="emotion-row">
  71. <text class="emotion-label">情感</text>
  72. <text class="emotion-value">{{ detectedEmotionLabel }}</text>
  73. </view>
  74. <view v-if="detectedVoiceName" class="emotion-row">
  75. <text class="emotion-label">推荐音色</text>
  76. <text class="emotion-value voice-value">{{ detectedVoiceName }}</text>
  77. </view>
  78. <view v-if="detectedScene" class="emotion-row">
  79. <text class="emotion-label">场景</text>
  80. <text class="emotion-value scene-value">{{ detectedScene }}</text>
  81. </view>
  82. <view v-if="detectedIdentity" class="emotion-row">
  83. <text class="emotion-label">身份</text>
  84. <text class="emotion-value identity-value">{{ detectedIdentity }}</text>
  85. </view>
  86. <view v-if="detectedRole" class="emotion-row">
  87. <text class="emotion-label">角色</text>
  88. <text class="emotion-value role-value">{{ detectedRole }}</text>
  89. </view>
  90. <!-- 情感覆盖选择器 -->
  91. <view class="emotion-override">
  92. <text class="emotion-override-label">手动调整:</text>
  93. <scroll-view scroll-x class="emotion-scroll">
  94. <view class="emotion-chips">
  95. <view
  96. v-for="emo in emotionOptions"
  97. :key="emo.key"
  98. class="emotion-chip"
  99. :class="{ active: selectedEmotion === emo.key }"
  100. @click="selectEmotion(emo.key)"
  101. >
  102. <text>{{ emo.label }}</text>
  103. </view>
  104. </view>
  105. </scroll-view>
  106. </view>
  107. </view>
  108. <!-- 未检测到特征:仅显示兜底情感 -->
  109. <view v-if="!detectedEmotion && !detecting && emotionDetectAttempted" class="emotion-result">
  110. <view class="emotion-row">
  111. <text class="emotion-label">情感</text>
  112. <text class="emotion-value emotion-fallback">{{ detectedEmotionLabel || '中性' }}</text>
  113. </view>
  114. <view v-if="detectedScene" class="emotion-row">
  115. <text class="emotion-label">场景</text>
  116. <text class="emotion-value scene-value">{{ detectedScene }}</text>
  117. </view>
  118. <text class="emotion-hint">未匹配到特定内容类型,使用通用音色</text>
  119. <!-- 情感覆盖选择器 -->
  120. <view class="emotion-override">
  121. <text class="emotion-override-label">手动调整:</text>
  122. <scroll-view scroll-x class="emotion-scroll">
  123. <view class="emotion-chips">
  124. <view
  125. v-for="emo in emotionOptions"
  126. :key="emo.key"
  127. class="emotion-chip"
  128. :class="{ active: selectedEmotion === emo.key }"
  129. @click="selectEmotion(emo.key)"
  130. >
  131. <text>{{ emo.label }}</text>
  132. </view>
  133. </view>
  134. </scroll-view>
  135. </view>
  136. </view>
  137. </view>
  138. <!-- 底部固定区:Tab 工具栏 -->
  139. <view class="bottom-bar">
  140. <!-- 主操作行:生成按钮 + 快捷信息 -->
  141. <view class="bottom-action-row">
  142. <view class="quick-info">
  143. <text class="quick-info-text">🎤 {{ currentVoiceName || '默认音色' }}</text>
  144. </view>
  145. <view class="generate-btn-tab" :class="{ disabled: !canGenerate }" @click="handleGenerate">
  146. <text class="generate-btn-tab-text">{{ generating ? '提交中...' : '开始生成' }}</text>
  147. </view>
  148. </view>
  149. <!-- 图标 Tab 栏(等宽分布,图标+文字居中) -->
  150. <view class="tab-toolbar">
  151. <view class="tab-item" :class="{ active: showVoicePanel }" @click="showVoicePanel = true">
  152. <text class="tab-icon">🎤</text>
  153. <text class="tab-text">音色</text>
  154. </view>
  155. <view class="tab-item" @click="showParamsPanel = true">
  156. <text class="tab-icon">⚙️</text>
  157. <text class="tab-text">参数</text>
  158. </view>
  159. </view>
  160. </view>
  161. <!-- 音色面板(暗色主题) -->
  162. <view v-if="showVoicePanel" class="bottom-panel-overlay" @click="showVoicePanel = false">
  163. <view class="bottom-panel voice-panel-dark" @click.stop>
  164. <!-- 分类标签栏 -->
  165. <view class="voice-tabs">
  166. <scroll-view scroll-x :show-scrollbar="false" class="voice-tabs-scroll">
  167. <view class="voice-tabs-inner">
  168. <text
  169. v-for="(cat, idx) in voiceCategories"
  170. :key="cat.key"
  171. class="voice-tab"
  172. :class="{ active: voiceCategory === cat.key }"
  173. @click="voiceCategory = cat.key"
  174. >{{ cat.label }}</text>
  175. </view>
  176. </scroll-view>
  177. </view>
  178. <!-- 音色网格 -->
  179. <scroll-view scroll-y class="voice-grid-scroll">
  180. <view class="voice-card-grid">
  181. <view
  182. v-for="voice in filteredVoices"
  183. :key="voice.id"
  184. class="voice-card"
  185. :class="{ active: selectedVoice === voice.id }"
  186. @click="selectVoiceAndClose(voice.id)"
  187. >
  188. <view class="voice-card-icon">
  189. <text>{{ voice.gender === 'female' ? '👩' : '👨' }}</text>
  190. </view>
  191. <text class="voice-card-name">{{ voice.name }}</text>
  192. <view v-if="selectedVoice === voice.id" class="voice-card-check">✓</view>
  193. <view class="voice-card-play" @click.stop="previewVoice(voice.id)">
  194. <text>{{ previewingVoice === voice.id ? '⏹' : '▶' }}</text>
  195. </view>
  196. </view>
  197. </view>
  198. </scroll-view>
  199. <!-- 关闭按钮 -->
  200. <view class="panel-close-bar" @click="showVoicePanel = false">
  201. <view class="panel-close-bar-line"></view>
  202. </view>
  203. </view>
  204. </view>
  205. <!-- 参数面板 -->
  206. <view v-if="showParamsPanel" class="bottom-panel-overlay" @click="showParamsPanel = false">
  207. <view class="bottom-panel" @click.stop>
  208. <view class="panel-header">
  209. <text class="panel-title">参数调节</text>
  210. <text class="panel-close" @click="showParamsPanel = false">✕</text>
  211. </view>
  212. <scroll-view scroll-y class="panel-body">
  213. <view class="param-item">
  214. <view class="param-header">
  215. <text class="param-label">语速</text>
  216. <text class="param-value">{{ voiceParams.speed.toFixed(1) }}x</text>
  217. </view>
  218. <slider
  219. :value="(voiceParams.speed - 0.5) * 100 / 1.5"
  220. :min="0" :max="100"
  221. @changing="(e: any) => voiceParams.speed = Number((0.5 + (e.detail.value / 100) * 1.5).toFixed(1))"
  222. activeColor="#4F46E5" backgroundColor="#e5e7eb" block-size="20"
  223. />
  224. <view class="param-range"><text>0.5x</text><text>2.0x</text></view>
  225. </view>
  226. <view class="param-item">
  227. <view class="param-header">
  228. <text class="param-label">音调</text>
  229. <text class="param-value">{{ voiceParams.pitch > 0 ? '+' : '' }}{{ voiceParams.pitch }}</text>
  230. </view>
  231. <slider
  232. :value="(voiceParams.pitch + 500) / 10"
  233. :min="0" :max="100"
  234. @changing="(e: any) => voiceParams.pitch = Math.round(e.detail.value * 10 - 500)"
  235. activeColor="#4F46E5" backgroundColor="#e5e7eb" block-size="20"
  236. />
  237. <view class="param-range"><text>-500</text><text>+500</text></view>
  238. </view>
  239. <view class="param-item">
  240. <view class="param-header">
  241. <text class="param-label">音量</text>
  242. <text class="param-value">{{ voiceParams.volume }}%</text>
  243. </view>
  244. <slider
  245. :value="voiceParams.volume"
  246. :min="0" :max="100"
  247. @changing="(e: any) => voiceParams.volume = e.detail.value"
  248. activeColor="#4F46E5" backgroundColor="#e5e7eb" block-size="20"
  249. />
  250. <view class="param-range"><text>0%</text><text>100%</text></view>
  251. </view>
  252. </scroll-view>
  253. </view>
  254. </view>
  255. <!-- 额度用尽弹窗 -->
  256. <view v-if="showQuotaModal" class="quota-modal" @click="showQuotaModal = false">
  257. <view class="quota-content" @click.stop>
  258. <view class="quota-icon">👑</view>
  259. <text class="quota-title">今日免费额度已用完</text>
  260. <text class="quota-desc">开通会员解锁更多生成次数</text>
  261. <view class="quota-actions">
  262. <button class="quota-btn primary" @click="goToMember"><text>开通会员</text></button>
  263. <button class="quota-btn secondary" @click="showQuotaModal = false"><text>稍后再说</text></button>
  264. </view>
  265. </view>
  266. </view>
  267. <!-- 专辑列表面板 -->
  268. <view v-if="showAlbumListPanel" class="bottom-panel-overlay" @click="showAlbumListPanel = false">
  269. <view class="bottom-panel" @click.stop>
  270. <view class="panel-header">
  271. <text class="panel-title">选择专辑</text>
  272. <text class="panel-close" @click="showAlbumListPanel = false">✕</text>
  273. </view>
  274. <scroll-view scroll-y class="panel-body">
  275. <view
  276. v-for="album in albums" :key="album.id"
  277. class="album-item" :class="{ active: selectedAlbum?.id === album.id }"
  278. @click="selectAlbum(album)"
  279. >
  280. <view class="album-item-content">
  281. <text class="album-item-title">{{ album.title }}</text>
  282. <text class="album-item-desc">{{ album.totalChapters }} 个章节</text>
  283. </view>
  284. <text v-if="selectedAlbum?.id === album.id" class="album-check">✓</text>
  285. </view>
  286. <view v-if="albums.length === 0" class="album-empty"><text>暂无专辑</text></view>
  287. </scroll-view>
  288. <button class="panel-action-btn" @click="showAlbumListPanel = false; showAlbumPanel = true">
  289. <text>+ 新建专辑</text>
  290. </button>
  291. </view>
  292. </view>
  293. <!-- 新建专辑面板 -->
  294. <view v-if="showAlbumPanel" class="bottom-panel-overlay" @click="showAlbumPanel = false">
  295. <view class="bottom-panel" @click.stop>
  296. <view class="panel-header">
  297. <text class="panel-title">新建专辑</text>
  298. <text class="panel-close" @click="showAlbumPanel = false">✕</text>
  299. </view>
  300. <view class="panel-body">
  301. <view class="form-item">
  302. <text class="form-label">专辑名称 *</text>
  303. <input v-model="newAlbumTitle" class="form-input" placeholder="例如:我的有声书" maxlength="50" />
  304. </view>
  305. <view class="form-item">
  306. <text class="form-label">描述(可选)</text>
  307. <textarea v-model="newAlbumDescription" class="form-textarea" placeholder="简单描述这个专辑..." maxlength="200" />
  308. </view>
  309. <button class="panel-action-btn" :disabled="!newAlbumTitle.trim()" @click="createAlbum">
  310. <text>创建专辑</text>
  311. </button>
  312. </view>
  313. </view>
  314. </view>
  315. <!-- 全局底部 Tab Bar -->
  316. <CustomTabBar />
  317. </view>
  318. </template>
  319. <script setup lang="ts">
  320. import { ref, computed, onMounted, watch } from 'vue';
  321. import { onShow } from '@dcloudio/uni-app';
  322. import { useUserStore } from '../../store/user';
  323. import { useAudioStore } from '../../store/audio';
  324. import { get, post, uploadFile } from '../../utils/request';
  325. import type { VoiceParams } from '../../types';
  326. import CustomTabBar from '../../components/CustomTabBar.vue';
  327. const userStore = useUserStore();
  328. const audioStore = useAudioStore();
  329. const text = ref('');
  330. const selectedVoice = ref('voice_01');
  331. const voiceParams = ref<VoiceParams>({ speed: 1.0, pitch: 0, volume: 50 });
  332. const generating = ref(false);
  333. const previewingVoice = ref<string | null>(null);
  334. let previewAudio: HTMLAudioElement | null = null;
  335. // ============ 情感检测 ============
  336. const detecting = ref(false);
  337. const detectedEmotion = ref(false);
  338. const detectedEmotionLabel = ref('');
  339. const detectedVoiceName = ref('');
  340. const detectedScene = ref(''); // 检测到的场景
  341. const detectedRole = ref(''); // 检测到的角色
  342. const detectedIdentity = ref(''); // 检测到的身份
  343. const emotionDetectAttempted = ref(false);
  344. const selectedEmotion = ref(''); // 用户手动选择的情感,空=不覆盖
  345. const emotionOptions = [
  346. { key: 'neutral', label: '😐 中性' },
  347. { key: 'happy', label: '😊 快乐' },
  348. { key: 'sad', label: '😢 悲伤' },
  349. { key: 'angry', label: '😠 愤怒' },
  350. { key: 'fearful', label: '😨 恐惧' },
  351. { key: 'surprised', label: '😲 惊喜' },
  352. { key: 'disgusted', label: '🤢 厌恶' },
  353. ];
  354. let detectTimer: ReturnType<typeof setTimeout> | null = null;
  355. function selectEmotion(key: string) {
  356. selectedEmotion.value = selectedEmotion.value === key ? '' : key;
  357. }
  358. // 防抖检测文本情感
  359. function debouncedDetect() {
  360. if (detectTimer) clearTimeout(detectTimer);
  361. detectTimer = setTimeout(async () => {
  362. if (text.value.trim().length < 10) {
  363. detectedEmotion.value = false;
  364. detectedEmotionLabel.value = '';
  365. detectedVoiceName.value = '';
  366. emotionDetectAttempted.value = false;
  367. return;
  368. }
  369. detecting.value = true;
  370. emotionDetectAttempted.value = true;
  371. try {
  372. const res = await post<{
  373. detected: boolean;
  374. voiceId?: string;
  375. voiceName?: string;
  376. emotion?: string;
  377. emotionLabel?: string;
  378. hint?: string;
  379. scene?: string;
  380. role?: string | null;
  381. identity?: string | null;
  382. }>('/tts/detect-emotion', { text: text.value });
  383. detectedEmotion.value = res.detected || false;
  384. detectedEmotionLabel.value = res.emotionLabel || '';
  385. detectedVoiceName.value = res.voiceName || '';
  386. detectedScene.value = res.scene || '';
  387. detectedRole.value = res.role || '';
  388. detectedIdentity.value = res.identity || '';
  389. if (res.emotion && !selectedEmotion.value) {
  390. // 首次检测到情感时,自动选中(但不覆盖用户手动选择)
  391. }
  392. } catch (e) {
  393. /* 静默失败 */
  394. } finally {
  395. detecting.value = false;
  396. }
  397. }, 800);
  398. }
  399. // 监听文本变化,触发检测
  400. watch(text, () => {
  401. selectedEmotion.value = ''; // 文本变化时重置手动选择
  402. debouncedDetect();
  403. });
  404. // 文档上传
  405. interface UploadedDoc { fileName: string; wordCount: number; fileSize: number; format: string; truncated: boolean; }
  406. const uploadedDoc = ref<UploadedDoc>({ fileName: '', wordCount: 0, fileSize: 0, format: '', truncated: false });
  407. const uploading = ref(false);
  408. // 面板状态
  409. const showVoicePanel = ref(false);
  410. const showParamsPanel = ref(false);
  411. const voiceCategory = ref('all');
  412. const voiceCategories = [
  413. { key: 'all', label: '全部' },
  414. { key: 'hot', label: '热门' },
  415. { key: 'female', label: '女声' },
  416. { key: 'male', label: '男声' },
  417. ];
  418. // 专辑
  419. const selectedAlbum = ref<{ id: string; title: string } | null>(null);
  420. const albums = ref<any[]>([]);
  421. const showAlbumListPanel = ref(false);
  422. const showAlbumPanel = ref(false);
  423. const newAlbumTitle = ref('');
  424. const newAlbumDescription = ref('');
  425. const showQuotaModal = ref(false);
  426. const canGenerate = computed(() => text.value.trim().length > 0 && !!selectedAlbum.value && !generating.value);
  427. const currentVoiceName = computed(() => {
  428. const v = audioStore.voices.find(item => item.id === selectedVoice.value);
  429. return v ? v.name : '';
  430. });
  431. const filteredVoices = computed(() => {
  432. if (voiceCategory.value === 'all') return audioStore.voices;
  433. if (voiceCategory.value === 'female') return audioStore.voices.filter(v => v.gender === 'female');
  434. if (voiceCategory.value === 'male') return audioStore.voices.filter(v => v.gender === 'male');
  435. // hot: return all for now (could be flagged by backend)
  436. return audioStore.voices;
  437. });
  438. function selectVoiceAndClose(id: string) { selectedVoice.value = id; showVoicePanel.value = false; }
  439. async function fetchAlbums() {
  440. try {
  441. const result = await get<{ albums: any[] }>('/book-generator/books');
  442. albums.value = result.albums || [];
  443. } catch (error) { console.error('获取专辑列表失败:', error); }
  444. }
  445. function selectAlbum(album: any) { selectedAlbum.value = { id: album.id, title: album.title }; showAlbumListPanel.value = false; }
  446. async function createAlbum() {
  447. if (!newAlbumTitle.value.trim()) return;
  448. try {
  449. const result = await post<{ id: number | string; title: string }>('/book-generator/books', { title: newAlbumTitle.value.trim(), description: newAlbumDescription.value.trim() });
  450. if (result && result.id) {
  451. selectedAlbum.value = { id: String(result.id), title: result.title };
  452. await fetchAlbums();
  453. showAlbumPanel.value = false;
  454. newAlbumTitle.value = '';
  455. newAlbumDescription.value = '';
  456. uni.showToast({ title: '专辑创建成功', icon: 'success' });
  457. }
  458. } catch (error) { uni.showToast({ title: '创建失败', icon: 'none' }); }
  459. }
  460. async function loadUserPreferences() {
  461. try {
  462. const localPrefs = uni.getStorageSync('userPreferences');
  463. if (localPrefs) {
  464. if (localPrefs.defaultVoiceId) selectedVoice.value = localPrefs.defaultVoiceId;
  465. if (localPrefs.defaultVolume !== undefined) voiceParams.value.volume = localPrefs.defaultVolume;
  466. }
  467. const result = await get<any>('/user/preferences');
  468. if (result) {
  469. if (result.defaultVoiceId) selectedVoice.value = result.defaultVoiceId;
  470. if (result.defaultVolume !== undefined) voiceParams.value.volume = result.defaultVolume;
  471. uni.setStorageSync('userPreferences', result);
  472. }
  473. } catch (error) { /* ignore */ }
  474. }
  475. onMounted(async () => {
  476. await audioStore.fetchVoices();
  477. await fetchAlbums();
  478. await loadUserPreferences();
  479. });
  480. function goToAIGenerate() { uni.navigateTo({ url: '/pages/ai-generate/index' }); }
  481. onShow(() => {
  482. const aiText = uni.getStorageSync('ai_generated_text');
  483. if (aiText) { text.value = aiText; uni.removeStorageSync('ai_generated_text'); uni.showToast({ title: '已填充 AI 生成的文本', icon: 'success' }); }
  484. });
  485. function clearText() { text.value = ''; }
  486. async function pasteText() { const content = await uni.getClipboardData(); if (content.data) text.value = content.data; }
  487. // 上传文档(保持原逻辑)
  488. async function handleUploadDoc() {
  489. if (uploading.value) return;
  490. try {
  491. // #ifdef H5
  492. const input = document.createElement('input'); input.type = 'file'; input.accept = '.txt,.docx,.pdf';
  493. input.onchange = async (e: any) => { const file = e.target.files?.[0]; if (!file) return; await processUploadedFile(file); };
  494. input.click(); return;
  495. // #endif
  496. // #ifdef APP-PLUS
  497. const result = await (uni as any).chooseFile({ extension: ['.txt', '.docx', '.pdf'], type: 'file' });
  498. if (result?.tempFiles?.[0]) await processUploadedFile(result.tempFiles[0]);
  499. // #endif
  500. // #ifdef MP-WEIXIN
  501. uni.showToast({ title: '小程序暂不支持文档上传', icon: 'none' });
  502. // #endif
  503. } catch (error: any) { if (error.message && !error.message.includes('cancel')) uni.showToast({ title: '选择文件失败', icon: 'none' }); }
  504. }
  505. async function processUploadedFile(file: any) {
  506. uploading.value = true;
  507. if (file.size && file.size > 20 * 1024 * 1024) { uni.showToast({ title: '文件过大,最大支持20MB', icon: 'none' }); uploading.value = false; return; }
  508. const name = (file.name || '').toLowerCase();
  509. if (!name.endsWith('.txt') && !name.endsWith('.docx') && !name.endsWith('.pdf')) { uni.showToast({ title: '不支持的格式', icon: 'none' }); uploading.value = false; return; }
  510. uni.showLoading({ title: '解析文档中...', mask: true });
  511. try {
  512. let result: any;
  513. // #ifdef H5
  514. const formData = new FormData(); formData.append('file', file);
  515. const baseUrl = (await import('../../utils/config')).getApiBaseUrl();
  516. const token = uni.getStorageSync('token');
  517. const headers: Record<string, string> = {}; if (token) headers['Authorization'] = `Bearer ${token}`;
  518. const response = await fetch(baseUrl + '/tts/upload-document', { method: 'POST', headers, body: formData });
  519. const json = await response.json();
  520. if (json.code === 0) result = json.data; else throw new Error(json.message || '解析失败');
  521. // #endif
  522. // #ifdef APP-PLUS
  523. result = await uploadFile('/tts/upload-document', file.path || file.tempFilePath || file);
  524. // #endif
  525. if (result && result.text) {
  526. 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 };
  527. let displayText = result.text;
  528. if (displayText.length > 2000) { displayText = displayText.substring(0, 2000); uploadedDoc.value.truncated = true; }
  529. text.value = displayText;
  530. uni.hideLoading();
  531. uni.showToast({ title: `解析成功,${uploadedDoc.value.wordCount}字`, icon: 'success' });
  532. } else { throw new Error('文档内容为空'); }
  533. } catch (error: any) { uni.hideLoading(); uni.showToast({ title: error.message || '文档解析失败', icon: 'none', duration: 3000 }); }
  534. finally { uploading.value = false; }
  535. }
  536. function clearDoc() { uploadedDoc.value = { fileName: '', wordCount: 0, fileSize: 0, format: '', truncated: false }; }
  537. async function previewVoice(voiceId: string) {
  538. if (previewingVoice.value === voiceId) { stopPreview(); return; }
  539. stopPreview();
  540. try {
  541. previewingVoice.value = voiceId; uni.showLoading({ title: '生成试听音频...' });
  542. const res = await post<{ audioUrl: string }>('/tts/preview', { voiceId, voiceParams: voiceParams.value });
  543. uni.hideLoading();
  544. if (res.audioUrl) {
  545. // #ifdef H5
  546. previewAudio = new Audio(res.audioUrl); previewAudio.play();
  547. previewAudio.onended = () => { previewingVoice.value = null; previewAudio = null; };
  548. // #endif
  549. // #ifndef H5
  550. uni.showToast({ title: '试听已播放', icon: 'success' }); setTimeout(() => { previewingVoice.value = null; }, 2000);
  551. // #endif
  552. }
  553. } catch (error: any) { uni.hideLoading(); uni.showToast({ title: '试听生成失败', icon: 'none' }); previewingVoice.value = null; }
  554. }
  555. function stopPreview() { if (previewAudio) { previewAudio.pause(); previewAudio = null; } previewingVoice.value = null; }
  556. async function handleGenerate() {
  557. if (!canGenerate.value) return;
  558. const quota = userStore.memberStatus?.quota;
  559. if (quota && quota.dailyRemaining !== -1 && quota.dailyRemaining <= 0) { showQuotaModal.value = true; return; }
  560. const bookId = selectedAlbum.value?.id;
  561. generating.value = true;
  562. try {
  563. // 如果用户手动选择了情感,注入到 voiceParams
  564. const params = { ...voiceParams.value };
  565. if (selectedEmotion.value) {
  566. (params as any).emotion = selectedEmotion.value;
  567. }
  568. const result = await audioStore.generateAudio(text.value, selectedVoice.value, params, { bookId });
  569. text.value = '';
  570. uni.showToast({ title: '任务已提交', icon: 'success' });
  571. setTimeout(() => { uni.navigateTo({ url: `/pages/book-generator/detail?id=${result.bookId}` }); }, 800);
  572. } catch (error: any) {
  573. if (error.message?.includes('额度') || error.message?.includes('quota')) showQuotaModal.value = true;
  574. else uni.showToast({ title: error.message || '提交失败,请重试', icon: 'none' });
  575. } finally { generating.value = false; }
  576. }
  577. function goToMember() { showQuotaModal.value = false; uni.navigateTo({ url: '/pages/member/index' }); }
  578. </script>
  579. <style scoped>
  580. .page { min-height: 100vh; background: #f5f5f5; display: flex; flex-direction: column; }
  581. /* 主内容区 */
  582. .main-content { flex: 1; padding: 16rpx 24rpx 0; overflow-y: auto; }
  583. .input-area { background: #ffffff; border-radius: 16rpx; padding: 24rpx; }
  584. /* 情感检测卡片 */
  585. .emotion-card {
  586. margin: 16rpx 0;
  587. padding: 20rpx 24rpx;
  588. background: linear-gradient(135deg, #f0f4ff 0%, #faf5ff 100%);
  589. border-radius: 16rpx;
  590. border: 1rpx solid #e0e7ff;
  591. }
  592. .emotion-card-header {
  593. display: flex;
  594. align-items: center;
  595. gap: 8rpx;
  596. margin-bottom: 12rpx;
  597. }
  598. .emotion-card-icon { font-size: 28rpx; }
  599. .emotion-card-title { font-size: 24rpx; font-weight: 600; color: #4f46e5; }
  600. .emotion-detecting { font-size: 20rpx; color: #9ca3af; margin-left: auto; }
  601. .emotion-result {}
  602. .emotion-row {
  603. display: flex;
  604. align-items: center;
  605. gap: 16rpx;
  606. padding: 8rpx 0;
  607. }
  608. .emotion-label { font-size: 22rpx; color: #6b7280; width: 120rpx; flex-shrink: 0; }
  609. .emotion-value { font-size: 26rpx; font-weight: 600; color: #1f2937; }
  610. .emotion-value.voice-value { color: #4f46e5; }
  611. .emotion-value.emotion-fallback { color: #9ca3af; }
  612. .emotion-value.scene-value { color: #0891b2; }
  613. .emotion-value.identity-value { color: #7c3aed; }
  614. .emotion-value.role-value { color: #059669; }
  615. .emotion-hint { font-size: 20rpx; color: #9ca3af; margin-top: 4rpx; }
  616. /* 情感覆盖选择器 */
  617. .emotion-override {
  618. margin-top: 16rpx;
  619. padding-top: 12rpx;
  620. border-top: 1rpx solid #e0e7ff;
  621. }
  622. .emotion-override-label { font-size: 22rpx; color: #6b7280; }
  623. .emotion-scroll { white-space: nowrap; margin-top: 8rpx; }
  624. .emotion-chips { display: flex; gap: 10rpx; }
  625. .emotion-chip {
  626. flex-shrink: 0;
  627. padding: 8rpx 16rpx;
  628. background: #ffffff;
  629. border: 1rpx solid #d1d5db;
  630. border-radius: 20rpx;
  631. font-size: 22rpx;
  632. color: #374151;
  633. transition: all 0.2s;
  634. }
  635. .emotion-chip.active {
  636. background: #4f46e5;
  637. border-color: #4f46e5;
  638. color: #ffffff;
  639. font-weight: 600;
  640. }
  641. /* 专辑选择器(页面内下拉) */
  642. .album-selector {
  643. display: flex;
  644. align-items: center;
  645. gap: 12rpx;
  646. padding: 20rpx 24rpx;
  647. background: #f3f4f6;
  648. border-radius: 12rpx;
  649. border: 2rpx solid #e5e7eb;
  650. margin-top: 20rpx;
  651. }
  652. .album-selector:active { opacity: 0.7; }
  653. .album-selector-label { font-size: 26rpx; color: #6b7280; flex-shrink: 0; }
  654. .album-selector-value {
  655. flex: 1;
  656. font-size: 26rpx;
  657. color: #1f2937;
  658. font-weight: 500;
  659. overflow: hidden;
  660. text-overflow: ellipsis;
  661. white-space: nowrap;
  662. }
  663. .album-selector-value.placeholder { color: #9ca3af; font-weight: 400; }
  664. .album-selector-arrow { font-size: 20rpx; color: #9ca3af; flex-shrink: 0; }
  665. .text-input { width: 100%; min-height: 600rpx; font-size: 30rpx; color: #1f2937; line-height: 1.7; }
  666. .word-count { display: block; text-align: right; font-size: 22rpx; color: #9ca3af; margin-top: 8rpx; }
  667. .word-count.warning { color: #f59e0b; }
  668. .word-count.error { color: #ef4444; font-weight: 600; }
  669. /* 文档信息 */
  670. .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; }
  671. .doc-format-badge { flex-shrink: 0; font-size: 20rpx; font-weight: 700; color: #fff; background: #4f46e5; padding: 4rpx 10rpx; border-radius: 6rpx; }
  672. .doc-filename { flex: 1; font-size: 24rpx; font-weight: 600; color: #1f2937; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  673. .doc-meta { font-size: 20rpx; color: #6b7280; flex-shrink: 0; }
  674. .doc-truncated-hint { font-size: 18rpx; color: #f59e0b; flex-shrink: 0; }
  675. .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; }
  676. .doc-uploading-bar { padding: 16rpx; text-align: center; background: #fefce8; border-radius: 10rpx; font-size: 24rpx; color: #a16207; margin-top: 12rpx; }
  677. /* 工具按钮行 */
  678. .tool-row { display: flex; gap: 12rpx; margin-top: 16rpx; }
  679. .tool-btn { flex: 1; font-size: 24rpx; color: #6b7280; background: #f3f4f6; padding: 14rpx 0; border-radius: 10rpx; text-align: center; }
  680. .tool-btn:active { opacity: 0.7; }
  681. /* 底部固定区:Tab 工具栏风格 */
  682. .bottom-bar { background: #ffffff; box-shadow: 0 -2rpx 16rpx rgba(0,0,0,0.08); padding-bottom: calc(env(safe-area-inset-bottom) + 120rpx); }
  683. /* 主操作行:左侧快捷信息 + 右侧生成按钮 */
  684. .bottom-action-row { display: flex; align-items: center; gap: 16rpx; padding: 16rpx 24rpx; }
  685. .quick-info { flex: 1; min-width: 0; }
  686. .quick-info-text { font-size: 24rpx; color: #6b7280; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
  687. /* 生成按钮(紧凑 Tab 风格) */
  688. .generate-btn-tab { flex-shrink: 0; padding: 18rpx 36rpx; background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%); border-radius: 40rpx; }
  689. .generate-btn-tab:active { opacity: 0.8; transform: scale(0.96); }
  690. .generate-btn-tab.disabled { opacity: 0.35; pointer-events: none; }
  691. .generate-btn-tab-text { font-size: 28rpx; font-weight: 700; color: #ffffff; white-space: nowrap; }
  692. /* 图标 Tab 栏(等宽分布,居中) */
  693. .tab-toolbar { display: flex; border-top: 1rpx solid #f3f4f6; padding: 10rpx 0 16rpx; }
  694. .tab-item { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 4rpx; padding: 6rpx 0; border-radius: 12rpx; transition: all 0.15s; }
  695. .tab-item:active { background: #f9fafb; }
  696. .tab-item.active .tab-icon { transform: scale(1.15); }
  697. .tab-item.active .tab-text { color: #4f46e5; font-weight: 600; }
  698. .tab-icon { font-size: 40rpx; line-height: 1; transition: transform 0.15s; }
  699. .tab-text { font-size: 22rpx; color: #9ca3af; transition: color 0.15s; }
  700. /* 底部面板 */
  701. .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; }
  702. .bottom-panel { width: 100%; max-height: 70vh; background: #ffffff; border-radius: 24rpx 24rpx 0 0; display: flex; flex-direction: column; }
  703. .panel-header { display: flex; justify-content: space-between; align-items: center; padding: 32rpx 32rpx 16rpx; }
  704. .panel-title { font-size: 34rpx; font-weight: 600; color: #1f2937; }
  705. .panel-close { font-size: 36rpx; color: #9ca3af; padding: 8rpx; }
  706. .panel-body { padding: 0 32rpx 32rpx; max-height: 55vh; }
  707. /* 音色面板(亮色,与整体风格统一) */
  708. .voice-panel-dark { background: #ffffff; border-radius: 24rpx 24rpx 0 0; max-height: 75vh; }
  709. /* 分类标签栏 */
  710. .voice-tabs { padding: 24rpx 28rpx 8rpx; border-bottom: 1rpx solid #f3f4f6; }
  711. .voice-tabs-scroll { white-space: nowrap; }
  712. .voice-tabs-inner { display: inline-flex; gap: 16rpx; }
  713. .voice-tab { font-size: 26rpx; color: #6b7280; padding: 10rpx 22rpx; border-radius: 20rpx; white-space: nowrap; flex-shrink: 0; background: #f9fafb; }
  714. .voice-tab.active { color: #ffffff; background: #4f46e5; font-weight: 600; }
  715. /* 音色网格(5列紧凑卡片) */
  716. .voice-grid-scroll { max-height: 56vh; }
  717. .voice-card-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12rpx; padding: 20rpx 24rpx 32rpx; }
  718. .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; }
  719. .voice-card.active { background: #eef2ff; border-color: #4f46e5; }
  720. .voice-card:active { opacity: 0.7; transform: scale(0.96); }
  721. .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; }
  722. .voice-card.active .voice-card-icon { background: rgba(79,70,229,0.12); }
  723. .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%; }
  724. .voice-card.active .voice-card-name { color: #4f46e5; font-weight: 600; }
  725. .voice-card-check { position: absolute; top: 6rpx; right: 6rpx; font-size: 20rpx; color: #4f46e5; font-weight: 700; }
  726. .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; }
  727. .voice-card.active .voice-card-play { display: none; }
  728. /* 面板底部关闭条 */
  729. .panel-close-bar { padding: 16rpx 0 28rpx; display: flex; justify-content: center; }
  730. .panel-close-bar-line { width: 80rpx; height: 6rpx; border-radius: 3rpx; background: #e5e7eb; }
  731. /* 参数面板 */
  732. .param-item { margin-bottom: 32rpx; }
  733. .param-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12rpx; }
  734. .param-label { font-size: 28rpx; color: #374151; }
  735. .param-value { font-size: 28rpx; font-weight: 600; color: #4f46e5; background: #eef2ff; padding: 4rpx 16rpx; border-radius: 8rpx; }
  736. .param-range { display: flex; justify-content: space-between; margin-top: 8rpx; }
  737. .param-range text { font-size: 22rpx; color: #9ca3af; }
  738. /* 专辑 */
  739. .album-item { display: flex; justify-content: space-between; align-items: center; padding: 20rpx 24rpx; border-radius: 14rpx; background: #f9fafb; margin-bottom: 12rpx; }
  740. .album-item.active { background: #eef2ff; border: 2rpx solid #4f46e5; }
  741. .album-item-content { flex: 1; }
  742. .album-item-title { display: block; font-size: 28rpx; font-weight: 600; color: #1f2937; margin-bottom: 6rpx; }
  743. .album-item-desc { display: block; font-size: 24rpx; color: #6b7280; }
  744. .album-check { font-size: 32rpx; color: #4f46e5; }
  745. .album-empty { text-align: center; padding: 60rpx 0; color: #9ca3af; font-size: 28rpx; }
  746. .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; }
  747. .panel-action-btn::after { border: none; }
  748. .panel-action-btn[disabled] { opacity: 0.4; }
  749. /* 表单 */
  750. .form-item { margin-bottom: 24rpx; }
  751. .form-label { display: block; font-size: 28rpx; color: #1f2937; font-weight: 500; margin-bottom: 12rpx; }
  752. .form-input { width: 100%; height: 88rpx; background: #f9fafb; border-radius: 14rpx; padding: 0 24rpx; font-size: 28rpx; color: #1f2937; }
  753. .form-textarea { width: 100%; min-height: 140rpx; background: #f9fafb; border-radius: 14rpx; padding: 20rpx 24rpx; font-size: 28rpx; color: #1f2937; line-height: 1.6; }
  754. /* 额度弹窗 */
  755. .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; }
  756. .quota-content { width: 600rpx; background: #ffffff; border-radius: 32rpx; padding: 60rpx 40rpx; display: flex; flex-direction: column; align-items: center; }
  757. .quota-icon { font-size: 100rpx; margin-bottom: 24rpx; }
  758. .quota-title { font-size: 36rpx; font-weight: 600; color: #1f2937; margin-bottom: 12rpx; }
  759. .quota-desc { font-size: 28rpx; color: #6b7280; margin-bottom: 40rpx; }
  760. .quota-actions { width: 100%; display: flex; flex-direction: column; gap: 16rpx; }
  761. .quota-btn { width: 100%; height: 88rpx; border-radius: 44rpx; display: flex; align-items: center; justify-content: center; font-size: 30rpx; border: none; }
  762. .quota-btn::after { border: none; }
  763. .quota-btn.primary { background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: #fff; }
  764. .quota-btn.secondary { background: #f3f4f6; color: #6b7280; }
  765. </style>
  766. // ====== pages/player/index.vue ======
  767. <template>
  768. <view class="page">
  769. <!-- 顶部导航 -->
  770. <view class="nav-bar">
  771. <view class="nav-btn" @click="goBack">
  772. <text class="nav-icon">⋁</text>
  773. </view>
  774. <view class="nav-center">
  775. <text class="nav-title">{{ audio?.title || '正在播放' }}</text>
  776. <view v-if="sleepTimer" class="sleep-timer-badge" @click="showSleepTimerPicker = true">
  777. <text class="sleep-timer-text">{{ formatSleepTimer(sleepTimer.remaining) }}</text>
  778. </view>
  779. </view>
  780. <view class="nav-actions">
  781. <view class="nav-btn" @click="showSleepTimerPicker = true">
  782. <text class="nav-icon">⏰</text>
  783. </view>
  784. <view class="nav-btn nav-btn-lyrics" @click="showLyrics = !showLyrics">
  785. <text class="nav-icon">{{ showLyrics ? '📖' : '📕' }}</text>
  786. <text class="nav-btn-label">{{ showLyrics ? '隐藏歌词' : '显示歌词' }}</text>
  787. </view>
  788. </view>
  789. </view>
  790. <!-- 无音频提示 -->
  791. <view v-if="!audio?.audioUrl" class="empty-state">
  792. <text class="empty-icon">🎵</text>
  793. <text class="empty-title">暂无音频</text>
  794. <text class="empty-desc">该章节还没有生成音频</text>
  795. <button class="empty-btn" @click="goBack">返回</button>
  796. </view>
  797. <!-- 正常播放界面 -->
  798. <template v-else>
  799. <!-- 封面和标题(始终显示,不受歌词开关影响) -->
  800. <view class="cover-section">
  801. <view class="cover">
  802. <text class="cover-icon">🎵</text>
  803. </view>
  804. <text class="title">{{ audio?.title || '未知标题' }}</text>
  805. </view>
  806. <!-- 文本预览(歌词同步) -->
  807. <scroll-view
  808. v-show="showLyrics"
  809. class="text-preview"
  810. scroll-y
  811. :style="{ maxHeight: '550rpx' }"
  812. :scroll-into-view="scrollIntoViewId"
  813. scroll-with-animation
  814. >
  815. <view class="lyrics-container">
  816. <view
  817. v-for="(line, index) in lyrics"
  818. :key="index"
  819. :id="'lyric-' + index"
  820. class="lyric-line"
  821. :class="{ active: index === currentLyricIndex }"
  822. >
  823. <text class="lyric-text">{{ line.text }}</text>
  824. </view>
  825. </view>
  826. </scroll-view>
  827. <!-- 进度条 -->
  828. <view class="progress-section">
  829. <text class="time">{{ formatDuration(currentTime) }}</text>
  830. <slider
  831. :value="currentTime"
  832. :max="duration || 100"
  833. @change="handleSeek"
  834. activeColor="#4F46E5"
  835. backgroundColor="#e5e7eb"
  836. block-size="16"
  837. class="progress-slider"
  838. />
  839. <text class="time">{{ formatDuration(duration) }}</text>
  840. </view>
  841. <!-- 播放控制 -->
  842. <view class="controls">
  843. <!-- 左侧按钮:播放速度、下载 -->
  844. <view class="controls-side">
  845. <view class="control-btn speed-display-btn" @click="showRatePicker = true">
  846. <text class="speed-label">倍速</text>
  847. <text class="speed-value">{{ playRate }}x</text>
  848. </view>
  849. <AudioDownload
  850. :url="getFullUrl(audio?.audioUrl || '')"
  851. :filename="`${audio?.title || 'audio'}.mp3`"
  852. :audio-id="String(audio?.id || '')"
  853. >
  854. <view class="control-btn">
  855. <text class="control-icon">⬇</text>
  856. </view>
  857. </AudioDownload>
  858. </view>
  859. <!-- 中间按钮:上一首、播放/暂停、下一首 -->
  860. <view class="controls-center">
  861. <view class="control-btn" :class="{ disabled: !hasPrev }" @click="playPrev">
  862. <text class="control-icon">⏮</text>
  863. </view>
  864. <view class="play-btn" @click="togglePlay">
  865. <text class="play-icon">{{ isPlaying ? '⏸' : '▶' }}</text>
  866. </view>
  867. <view class="control-btn" :class="{ disabled: !hasNext }" @click="playNext">
  868. <text class="control-icon">⏭</text>
  869. </view>
  870. </view>
  871. <!-- 右侧按钮:收藏、分享 -->
  872. <view class="controls-side">
  873. <view class="control-btn" @click="toggleFavorite">
  874. <text class="control-icon">{{ audio?.isFavorite ? '❤️' : '🤍' }}</text>
  875. </view>
  876. <view class="control-btn share-btn" @click="handleShare">
  877. <text class="control-icon">📤</text>
  878. <text class="share-label">分享得时长</text>
  879. </view>
  880. </view>
  881. </view>
  882. <!-- 播放列表 - 移动端设计,直接在界面中显示 -->
  883. <view class="playlist-section-mobile">
  884. <view class="playlist-header">
  885. <view class="playlist-header-left">
  886. <text class="playlist-title">播放列表</text>
  887. <text class="playlist-count">{{ playlist.length }}首</text>
  888. </view>
  889. <view class="playlist-header-right" @click="togglePlayMode">
  890. <text class="playlist-mode-icon">{{ getPlayModeIcon() }}</text>
  891. <text class="playlist-mode-text">{{ getPlayModeText() }}</text>
  892. </view>
  893. </view>
  894. <scroll-view class="playlist-list-mobile" scroll-y>
  895. <view
  896. v-for="(item, index) in playlist"
  897. :key="item.id"
  898. :class="['playlist-item', { active: isCurrentTrack(item) }]"
  899. @click="playFromPlaylist(index)"
  900. >
  901. <view class="playlist-index">{{ index + 1 }}</view>
  902. <view class="playlist-info">
  903. <text class="playlist-item-title">{{ item.title }}</text>
  904. <text class="playlist-item-meta">{{ formatDuration(item.audioDuration || 0) }}</text>
  905. </view>
  906. <view v-if="isCurrentTrack(item)" class="playing-icon">▶</view>
  907. </view>
  908. </scroll-view>
  909. </view>
  910. </template>
  911. <!-- 播放速度选择器 -->
  912. <view v-if="showRatePicker" class="rate-picker" @click="showRatePicker = false">
  913. <view class="rate-content" @click.stop>
  914. <text class="rate-title">播放速度</text>
  915. <view class="rate-options">
  916. <view
  917. v-for="rate in [0.5, 0.75, 0.9, 1, 1.25, 1.5, 2]"
  918. :key="rate"
  919. class="rate-item"
  920. :class="{ active: playRate === rate }"
  921. @click="handleSetRate(rate)"
  922. >
  923. <text>{{ rate }}x</text>
  924. </view>
  925. </view>
  926. </view>
  927. </view>
  928. <!-- 睡眠定时选择器 -->
  929. <view v-if="showSleepTimerPicker" class="rate-picker" @click="showSleepTimerPicker = false">
  930. <view class="rate-content" @click.stop>
  931. <text class="rate-title">睡眠定时</text>
  932. <view class="rate-options">
  933. <view
  934. v-for="option in sleepTimerOptions"
  935. :key="option.value"
  936. class="rate-item"
  937. :class="{ active: sleepTimer && sleepTimer.total === option.value }"
  938. @click="handleSetSleepTimer(option.value)"
  939. >
  940. <text>{{ option.label }}</text>
  941. </view>
  942. </view>
  943. <view v-if="sleepTimer" class="sleep-timer-cancel" @click="cancelSleepTimer">
  944. <text>取消定时</text>
  945. </view>
  946. </view>
  947. </view>
  948. </view>
  949. </template>
  950. <script setup lang="ts">
  951. import { ref, watch, onMounted, onUnmounted } from 'vue';
  952. import { onShow } from '@dcloudio/uni-app';
  953. import { onLoad } from '@dcloudio/uni-app';
  954. import { useAudioStore } from '../../store/audio';
  955. import { storeToRefs } from 'pinia';
  956. import { get, put, post, getFullUrl } from '../../utils/request';
  957. import { trackEvent, AnalyticsEvents } from '../../utils/analytics';
  958. import type { AudioItem } from '../../types';
  959. import AudioDownload from '../../components/AudioDownload.vue';
  960. // 分享数据接口
  961. interface ShareCardData {
  962. title: string;
  963. description: string;
  964. imageUrl: string;
  965. shareUrl: string;
  966. shareCode: string;
  967. chapterId: number;
  968. wordCount: number;
  969. audioDuration: number;
  970. }
  971. const audioStore = useAudioStore();
  972. // 状态
  973. const audioId = ref('');
  974. const audio = ref<AudioItem | null>(null);
  975. const showRatePicker = ref(false);
  976. const showSleepTimerPicker = ref(false);
  977. const showLyrics = ref(true);
  978. const lyrics = ref<{ text: string; start: number; end: number }[]>([]);
  979. const scrollIntoViewId = ref('');
  980. // 分享数据
  981. const shareData = ref<ShareCardData | null>(null);
  982. // 睡眠定时
  983. const sleepTimer = ref<{ remaining: number; total: number } | null>(null);
  984. let sleepTimerInterval: ReturnType<typeof setInterval> | null = null;
  985. // 睡眠定时选项
  986. const sleepTimerOptions = [
  987. { label: '15分钟', value: 15 * 60 },
  988. { label: '30分钟', value: 30 * 60 },
  989. { label: '60分钟', value: 60 * 60 },
  990. { label: '当前播放结束', value: -1 },
  991. ];
  992. // 解析 LRC 格式歌词(兼容换行分隔和单行连续两种格式)
  993. function parseLrc(lrcText: string): { text: string; start: number; end: number }[] {
  994. if (!lrcText) return [];
  995. const timeline: { text: string; start: number; end: number }[] = [];
  996. // 优先按换行分割,如果只有1行则用全局匹配提取所有时间戳
  997. const lines = lrcText.split('\n');
  998. if (lines.length <= 1) {
  999. // 单行格式:用正则提取所有 [MM:SS.XX]text 对
  1000. const matches = lrcText.matchAll(/\[(\d{2}):(\d{2})\.(\d{2})\]\s*([^[]*)/g);
  1001. for (const m of matches) {
  1002. const minutes = parseInt(m[1]);
  1003. const seconds = parseInt(m[2]);
  1004. const cs = parseInt(m[3]);
  1005. const text = m[4].trim();
  1006. if (text) {
  1007. timeline.push({ text, start: minutes * 60 + seconds + cs / 100, end: 0 });
  1008. }
  1009. }
  1010. } else {
  1011. // 标准格式:按行处理
  1012. for (const line of lines) {
  1013. const match = line.match(/^\[(\d{2}):(\d{2})\.(\d{2})\]\s*(.*)$/);
  1014. if (match) {
  1015. const text = match[4].trim();
  1016. if (text) {
  1017. timeline.push({
  1018. text,
  1019. start: parseInt(match[1]) * 60 + parseInt(match[2]) + parseInt(match[3]) / 100,
  1020. end: 0,
  1021. });
  1022. }
  1023. }
  1024. }
  1025. }
  1026. // 计算 end 时间(下一句的开始时间)
  1027. for (let i = 0; i < timeline.length - 1; i++) {
  1028. timeline[i].end = timeline[i + 1].start;
  1029. }
  1030. if (timeline.length > 0) {
  1031. const last = timeline[timeline.length - 1];
  1032. last.end = last.start + 5;
  1033. }
  1034. return timeline;
  1035. }
  1036. // 生成歌词时间线(按句子切割,实现逐句高亮跟随)
  1037. function generateLyricsTimeline(text: string, totalDuration: number): { text: string; start: number; end: number }[] {
  1038. if (!text || totalDuration <= 0) return [];
  1039. // 按 Markdown 结构分段
  1040. const sections: string[] = [];
  1041. const h1Parts = text.split(/(?=^# )/m);
  1042. for (const h1Part of h1Parts) {
  1043. if (!h1Part.trim()) continue;
  1044. const h2Parts = h1Part.split(/(?=^## )/m);
  1045. for (const h2Part of h2Parts) {
  1046. const trimmed = h2Part.trim();
  1047. if (!trimmed) continue;
  1048. const isHeading = /^#{1,2}\s/.test(trimmed);
  1049. if (isHeading) {
  1050. // 提取标题行和正文,分别处理(避免将标题+正文当作一整条歌词)
  1051. const titleMatch = trimmed.match(/^(#{1,2}\s+.+?)(\n|$)/);
  1052. if (titleMatch) {
  1053. sections.push(titleMatch[1].trim());
  1054. const rest = trimmed.substring(titleMatch[0].length).trim();
  1055. if (rest) {
  1056. const paras = rest.split(/\n{2,}/).filter(p => p.trim());
  1057. for (const para of paras) {
  1058. const sentences = para.split(/(?<=[。!?;])/).filter(s => s.trim());
  1059. sections.push(...sentences);
  1060. }
  1061. }
  1062. } else {
  1063. sections.push(trimmed);
  1064. }
  1065. } else {
  1066. // 先按段落切,再按句子切 → 实现逐句级歌词跟随
  1067. const paras = trimmed.split(/\n{2,}/).filter(p => p.trim());
  1068. for (const para of paras) {
  1069. const sentences = para.split(/(?<=[。!?;])/).filter(s => s.trim());
  1070. sections.push(...sentences);
  1071. }
  1072. }
  1073. }
  1074. }
  1075. // 降级:纯文本没有标题时,直接按句子分段
  1076. if (sections.length <= 1) {
  1077. const sentences = text.split(/(?<=[。!?;])/).filter(s => s.trim());
  1078. sections.length = 0;
  1079. sections.push(...sentences);
  1080. }
  1081. // 权重分配:标题 0.3x,正文 1.0x
  1082. const weights = sections.map(s => /^#{1,2}\s/.test(s) ? 0.3 : 1.0);
  1083. const weightedChars = sections.map((s, i) => {
  1084. // 统计所有可见字符(含标点、数字、符号),不只汉字字母
  1085. const chars = s.replace(/\s/g, '').length;
  1086. return chars * weights[i];
  1087. });
  1088. const totalWeighted = weightedChars.reduce((a, b) => a + b, 0);
  1089. if (totalWeighted === 0) return [];
  1090. const timeline: { text: string; start: number; end: number }[] = [];
  1091. let currentTime = 0;
  1092. for (let i = 0; i < sections.length; i++) {
  1093. const ratio = weightedChars[i] / totalWeighted;
  1094. const segDuration = ratio * totalDuration;
  1095. const start = currentTime;
  1096. const end = currentTime + segDuration;
  1097. timeline.push({ text: sections[i], start, end });
  1098. currentTime += segDuration;
  1099. // 标题后加停顿
  1100. if (i < sections.length - 1 && /^#{1,2}\s/.test(sections[i])) {
  1101. currentTime = Math.min(currentTime + 0.5, totalDuration);
  1102. }
  1103. }
  1104. // 修正最后一段
  1105. if (timeline.length > 0) {
  1106. timeline[timeline.length - 1].end = totalDuration;
  1107. }
  1108. return timeline;
  1109. }
  1110. // 当前高亮的歌词索引
  1111. const currentLyricIndex = ref(-1);
  1112. // 直接监听 store 中的 currentTime,驱动高亮和滚动
  1113. watch(() => audioStore.currentTime, (time) => {
  1114. if (!lyrics.value.length) return;
  1115. const index = lyrics.value.findIndex(l => time >= l.start && time < l.end);
  1116. currentLyricIndex.value = index;
  1117. if (index >= 0) {
  1118. // 滚动到前一行(最小为 0),使高亮行上下都有可见文本作为缓冲
  1119. const scrollIndex = Math.max(0, index - 1);
  1120. const targetId = 'lyric-' + scrollIndex;
  1121. if (scrollIntoViewId.value !== targetId) {
  1122. scrollIntoViewId.value = targetId;
  1123. }
  1124. }
  1125. });
  1126. // 从 store 获取状态
  1127. const { isPlaying, currentTime, duration, playRate, hasPrev, hasNext, playlist } = storeToRefs(audioStore);
  1128. // 页面加载
  1129. onMounted(async () => {
  1130. const pages = getCurrentPages();
  1131. const currentPage = pages[pages.length - 1] as any;
  1132. // #ifdef APP-PLUS
  1133. audioId.value = currentPage?.$page?.options?.id || '';
  1134. // #endif
  1135. // #ifndef APP-PLUS
  1136. audioId.value = currentPage?.options?.id || '';
  1137. // #endif
  1138. if (audioId.value) {
  1139. await fetchAudio();
  1140. } else {
  1141. uni.showToast({ title: '没有音频ID参数', icon: 'none' });
  1142. }
  1143. });
  1144. // 页面显示时刷新音频状态
  1145. onShow(() => {
  1146. // 刷新 store 中的当前音频信息
  1147. if (audioStore.currentAudio) {
  1148. audio.value = audioStore.currentAudio;
  1149. // currentTime/duration 是 computed,自动从 store 读取,无需手动赋值
  1150. }
  1151. });
  1152. // 监听 store 当前音频变化(自动切歌时同步标题和歌词)
  1153. watch(() => audioStore.currentAudio, (newAudio) => {
  1154. if (newAudio && newAudio.id !== audio.value?.id) {
  1155. audio.value = newAudio;
  1156. updateLyricsForAudio(newAudio);
  1157. }
  1158. });
  1159. // 页面卸载
  1160. onUnmounted(() => {
  1161. // 清除睡眠定时器
  1162. if (sleepTimerInterval) {
  1163. clearInterval(sleepTimerInterval);
  1164. }
  1165. // 不销毁音频上下文,保持后台播放
  1166. });
  1167. // 获取音频详情
  1168. async function fetchAudio() {
  1169. try {
  1170. // 获取音频详情
  1171. const result = await get<AudioItem>(`/player/audio/${audioId.value}`);
  1172. audio.value = result;
  1173. // 检查是否有音频URL
  1174. if (!result.audioUrl) {
  1175. uni.showToast({
  1176. title: '该章节没有音频,请先生成音频',
  1177. icon: 'none',
  1178. duration: 2000
  1179. });
  1180. return;
  1181. }
  1182. // 获取播放列表(使用专辑章节列表)
  1183. await fetchAlbumPlaylist(result);
  1184. // 生成歌词时间线(始终使用前端按句子切割,比后端 LRC 更可靠)
  1185. const audioDuration = result.audioDuration || audioStore.duration || 60;
  1186. lyrics.value = generateLyricsTimeline(result.text || '', audioDuration);
  1187. // 在歌词列表前插入空行作为视觉缓冲,确保高亮当前行时前后文都可见
  1188. lyrics.value = [{ text: '', start: 0, end: 0 }, ...lyrics.value];
  1189. // 开始播放(store 中已初始化 audioContext)
  1190. audioStore.play(result);
  1191. } catch (error: any) {
  1192. console.error('获取音频失败:', error);
  1193. uni.showToast({ title: error.message || '音频不存在', icon: 'none' });
  1194. }
  1195. }
  1196. // 获取专辑播放列表
  1197. async function fetchAlbumPlaylist(currentAudio: AudioItem) {
  1198. try {
  1199. // 从音频信息中获取albumId(书籍ID) - 优先从音频对象获取
  1200. let albumId = (currentAudio as any).albumId || (currentAudio as any).bookId;
  1201. // 如果当前音频没有albumId,则尝试从章节信息获取
  1202. if (!albumId) {
  1203. // 由于 we're using chapter ID as audioId, try to get album info from chapters API
  1204. // We'll use the current audioId to get chapter details and then album ID
  1205. try {
  1206. const chapterDetail = await get<any>(`/book-generator/books/chapters/${audioId.value}`);
  1207. albumId = chapterDetail?.bookId; // The chapter detail now includes bookId
  1208. } catch (err) {
  1209. console.warn('无法通过章节ID获取专辑信息:', err);
  1210. }
  1211. }
  1212. if (!albumId) {
  1213. // 如果还是没有albumId,使用通用播放列表
  1214. await fetchGenericPlaylist();
  1215. return;
  1216. }
  1217. // 获取专辑章节列表(章→节→小节 三层嵌套)
  1218. const data = await get<{ chapters: any[] }>(`/book-generator/books/${albumId}/chapters`);
  1219. const chapters = data?.chapters || [];
  1220. // 展平为线性播放列表:按目录顺序(深度优先,父→子)输出所有可播放叶子。
  1221. // - level=1 章:若有直接音频则播放章;若下面有可播放的节/小节,则章本身不入列表(避免重复项)
  1222. // - level=2 节:若自己挂音频则入列表;若只有 level=3 子小节有音频,则节本身不入
  1223. // - level=3 小节:有音频则入列表
  1224. const flatItems: any[] = [];
  1225. const collectLeaves = (nodes: any[]): any[] => {
  1226. const out: any[] = [];
  1227. nodes.forEach((n: any) => {
  1228. const children = (n.subsections || []).filter((c: any) => c);
  1229. const childLeaves = collectLeaves(children);
  1230. const hasOwnAudio = !!(n.audioUrl && String(n.audioUrl).trim() !== '');
  1231. if (childLeaves.length > 0) {
  1232. out.push(...childLeaves);
  1233. } else if (hasOwnAudio && n.level !== undefined) {
  1234. out.push(n);
  1235. }
  1236. });
  1237. return out;
  1238. };
  1239. // 但 level=1 章本身若有音频且无子项,collectLeaves 会返回该章,已涵盖。
  1240. // 如果某章 level=1 自己有音频,同时 level=2 子节也有音频,按"只取叶子"会把子节列出、章不列,符合预期。
  1241. flatItems.push(...collectLeaves(chapters));
  1242. // 转换为播放列表格式
  1243. const playlist = flatItems.map((c: any) => ({
  1244. _id: c.id,
  1245. id: c.id,
  1246. title: c.title,
  1247. audioUrl: c.audioUrl,
  1248. audioDuration: c.audioDuration || 0,
  1249. wordCount: c.wordCount || 0,
  1250. }));
  1251. // 找到当前音频在列表中的位置(audioId 可能是数字或字符串)
  1252. const targetId = String(audioId.value);
  1253. const currentIndex = playlist.findIndex(item => String(item.id) === targetId);
  1254. if (playlist.length > 0) {
  1255. audioStore.setPlaylist(playlist, currentIndex >= 0 ? currentIndex : 0, true);
  1256. }
  1257. } catch (error) {
  1258. console.error('获取专辑播放列表失败:', error);
  1259. // 失败时使用通用播放列表
  1260. await fetchGenericPlaylist();
  1261. }
  1262. }
  1263. // 获取通用播放列表
  1264. async function fetchGenericPlaylist() {
  1265. try {
  1266. const result = await get<{ list: AudioItem[] }>('/player/audio/list', {
  1267. page: 1,
  1268. pageSize: 100,
  1269. });
  1270. const index = result.list.findIndex(item => item._id === audioId.value);
  1271. if (index >= 0) {
  1272. audioStore.setPlaylist(result.list, index, true);
  1273. }
  1274. } catch (error) {
  1275. console.error('获取播放列表失败:', error);
  1276. }
  1277. }
  1278. // 格式化时长
  1279. function formatDuration(seconds: number): string {
  1280. const mins = Math.floor(seconds / 60);
  1281. const secs = Math.floor(seconds % 60);
  1282. return `${mins}:${secs.toString().padStart(2, '0')}`;
  1283. }
  1284. // 判断播放列表项是否为当前播放
  1285. function isCurrentTrack(item: AudioItem): boolean {
  1286. const currentId = String(audio.value?._id || audio.value?.id);
  1287. return String(item.id) === currentId || String(item._id) === currentId;
  1288. }
  1289. /**
  1290. * 获取分享卡片数据(从后端)
  1291. */
  1292. async function fetchShareCard() {
  1293. if (!audio.value?._id) return;
  1294. try {
  1295. const res = await get<ShareCardData>(`/share/card/${audio.value._id}`);
  1296. shareData.value = res;
  1297. } catch (e) {
  1298. console.error('获取分享卡片失败', e);
  1299. }
  1300. }
  1301. /**
  1302. * 处理分享(增强版)
  1303. */
  1304. async function handleShare() {
  1305. trackEvent(AnalyticsEvents.SHARE_CLICK, { audioId: audio.value?._id });
  1306. // 先获取最新的分享卡片数据
  1307. await fetchShareCard();
  1308. const shareTitle = shareData.value?.title || '声工坊有声作品';
  1309. const shareDesc = shareData.value?.description || '用AI生成的高品质有声书,快来听!';
  1310. const shareUrl = shareData.value?.shareUrl || copyShareLink();
  1311. // #ifdef H5
  1312. if (navigator.share) {
  1313. try {
  1314. await navigator.share({
  1315. title: shareTitle,
  1316. text: shareDesc,
  1317. url: shareUrl,
  1318. });
  1319. await trackShareComplete('h5_share_api');
  1320. } catch (e) {
  1321. // 用户取消分享
  1322. if ((e as Error).name !== 'AbortError') {
  1323. await copyToClipboard(shareUrl);
  1324. }
  1325. }
  1326. return;
  1327. }
  1328. // #endif
  1329. // 小程序/App:复制链接
  1330. await copyToClipboard(shareUrl);
  1331. }
  1332. /**
  1333. * 向后端上报分享完成
  1334. */
  1335. async function trackShareComplete(platform: string) {
  1336. try {
  1337. await post('/share/track', {
  1338. audioId: audio.value?._id,
  1339. platform,
  1340. shareCode: shareData.value?.shareCode,
  1341. });
  1342. trackEvent(AnalyticsEvents.SHARE_COMPLETE, {
  1343. audioId: audio.value?._id,
  1344. platform,
  1345. });
  1346. } catch (e) {
  1347. // 静默失败
  1348. }
  1349. }
  1350. /**
  1351. * 复制到剪贴板(带分享码链接)
  1352. */
  1353. async function copyToClipboard(url: string) {
  1354. try {
  1355. await uni.setClipboardData({ data: url });
  1356. uni.showToast({ title: '链接已复制,分享给朋友吧!', icon: 'success' });
  1357. await trackShareComplete('copy_link');
  1358. } catch (e) {
  1359. uni.showToast({ title: '请截图分享', icon: 'none' });
  1360. }
  1361. }
  1362. // 页面加载时,检测分享码来源
  1363. onLoad((options: any) => {
  1364. const shareCode = options?.sc;
  1365. if (shareCode) {
  1366. // 上报分享访问
  1367. get(`/share/visit?code=${shareCode}`).catch(() => {});
  1368. trackEvent(AnalyticsEvents.INVITE_ACCEPT, { shareCode });
  1369. // 存储分享码,用于后续注册绑定
  1370. uni.setStorageSync('invite_code', shareCode);
  1371. }
  1372. });
  1373. // 返回
  1374. function goBack() {
  1375. const pages = getCurrentPages();
  1376. if (pages.length > 1) {
  1377. uni.navigateBack();
  1378. } else {
  1379. uni.switchTab({ url: '/pages/index/index' });
  1380. }
  1381. }
  1382. // 跳转进度
  1383. function handleSeek(e: any) {
  1384. audioStore.seek(e.detail.value);
  1385. }
  1386. // 切换播放/暂停
  1387. function togglePlay() {
  1388. audioStore.togglePlay();
  1389. }
  1390. // 播放模式
  1391. function togglePlayMode() {
  1392. audioStore.togglePlayMode();
  1393. }
  1394. function getPlayModeIcon() {
  1395. const icons = {
  1396. sequence: '🔂',
  1397. loop: '🔁',
  1398. single: '🔂',
  1399. random: '🔀',
  1400. };
  1401. return icons[audioStore.playMode] || '🔂';
  1402. }
  1403. function getPlayModeText() {
  1404. const texts = {
  1405. sequence: '顺序',
  1406. loop: '循环',
  1407. single: '单曲',
  1408. random: '随机',
  1409. };
  1410. return texts[audioStore.playMode] || '顺序';
  1411. }
  1412. // 更新歌词文本(切换章节时调用)
  1413. async function updateLyricsForAudio(audioItem: AudioItem) {
  1414. let text = audioItem?.text || '';
  1415. let lrc = (audioItem as any)?.lrcLyrics || '';
  1416. const ad = audioItem?.audioDuration || audioStore.duration || 60;
  1417. if (!text && !lrc) {
  1418. try {
  1419. const detail = await get<any>(`/book-generator/books/chapters/${audioItem.id}`);
  1420. text = detail?.content || '';
  1421. lrc = detail?.lrcLyrics || '';
  1422. } catch (e) {
  1423. console.warn('获取章节文本失败:', e);
  1424. }
  1425. }
  1426. lyrics.value = generateLyricsTimeline(text, ad);
  1427. lyrics.value = [{ text: '', start: 0, end: 0 }, ...lyrics.value];
  1428. }
  1429. // 上一首
  1430. function playPrev() {
  1431. if (hasPrev.value) {
  1432. audioStore.playPrev();
  1433. audio.value = audioStore.currentAudio;
  1434. if (audio.value) updateLyricsForAudio(audio.value);
  1435. }
  1436. }
  1437. // 下一首
  1438. function playNext() {
  1439. if (hasNext.value) {
  1440. audioStore.playNext();
  1441. audio.value = audioStore.currentAudio;
  1442. if (audio.value) updateLyricsForAudio(audio.value);
  1443. }
  1444. }
  1445. // 从播放列表选择
  1446. function playFromPlaylist(index: number) {
  1447. audioStore.playAt(index);
  1448. audio.value = playlist.value[index];
  1449. if (audio.value) updateLyricsForAudio(audio.value);
  1450. }
  1451. // 设置播放速度
  1452. function handleSetRate(rate: number) {
  1453. audioStore.setPlayRate(rate);
  1454. showRatePicker.value = false;
  1455. }
  1456. // 睡眠定时格式化
  1457. function formatSleepTimer(seconds: number): string {
  1458. const mins = Math.floor(seconds / 60);
  1459. const secs = seconds % 60;
  1460. return `${mins}:${secs.toString().padStart(2, '0')}`;
  1461. }
  1462. // 设置睡眠定时
  1463. function handleSetSleepTimer(seconds: number) {
  1464. showSleepTimerPicker.value = false;
  1465. if (seconds === -1) {
  1466. // 当前播放结束
  1467. const remaining = duration.value - currentTime.value;
  1468. if (remaining > 0) {
  1469. startSleepTimer(Math.ceil(remaining));
  1470. } else {
  1471. uni.showToast({ title: '当前播放即将结束', icon: 'none' });
  1472. return;
  1473. }
  1474. } else {
  1475. startSleepTimer(seconds);
  1476. }
  1477. }
  1478. // 开始睡眠定时
  1479. function startSleepTimer(seconds: number) {
  1480. // 清除之前的定时器
  1481. if (sleepTimerInterval) {
  1482. clearInterval(sleepTimerInterval);
  1483. }
  1484. sleepTimer.value = { remaining: seconds, total: seconds };
  1485. sleepTimerInterval = setInterval(() => {
  1486. if (sleepTimer.value) {
  1487. sleepTimer.value.remaining--;
  1488. if (sleepTimer.value.remaining <= 0) {
  1489. clearInterval(sleepTimerInterval!);
  1490. sleepTimerInterval = null;
  1491. // 暂停播放(currentTime已保存,下次打开可继续)
  1492. audioStore.pause();
  1493. sleepTimer.value = null;
  1494. uni.showToast({ title: '睡眠定时已到,自动暂停', icon: 'none' });
  1495. }
  1496. }
  1497. }, 1000);
  1498. }
  1499. // 取消睡眠定时
  1500. function cancelSleepTimer() {
  1501. showSleepTimerPicker.value = false;
  1502. if (sleepTimerInterval) {
  1503. clearInterval(sleepTimerInterval);
  1504. sleepTimerInterval = null;
  1505. }
  1506. sleepTimer.value = null;
  1507. uni.showToast({ title: '已取消睡眠定时', icon: 'none' });
  1508. }
  1509. // 切换收藏
  1510. async function toggleFavorite() {
  1511. if (!audio.value) return;
  1512. try {
  1513. const result = await put<{ isFavorite: boolean }>(`/audio/${audio.value._id}/favorite`);
  1514. audio.value.isFavorite = result.isFavorite;
  1515. uni.showToast({
  1516. title: result.isFavorite ? '已收藏' : '已取消收藏',
  1517. icon: 'success',
  1518. });
  1519. } catch (error) {
  1520. console.error('操作失败:', error);
  1521. }
  1522. }
  1523. /**
  1524. * 复制分享链接(旧版本兼容)
  1525. */
  1526. function copyShareLink(): string {
  1527. if (!audio.value) return '';
  1528. // #ifdef H5
  1529. const shareUrl = `${window.location.origin}/#/pages/player/index?id=${audio.value._id}`;
  1530. // #endif
  1531. // #ifndef H5
  1532. // App 端使用 pages.json 中配置的原生分享或直接显示页面路径
  1533. const shareUrl = `pages/player/index?id=${audio.value._id}`;
  1534. // #endif
  1535. return shareUrl;
  1536. }
  1537. </script>
  1538. <style scoped>
  1539. .page {
  1540. height: 100vh;
  1541. background: linear-gradient(180deg, #1f2937 0%, #111827 100%);
  1542. padding-bottom: constant(safe-area-inset-bottom);
  1543. padding-bottom: env(safe-area-inset-bottom);
  1544. width: 100%;
  1545. box-sizing: border-box;
  1546. overflow: hidden;
  1547. display: flex;
  1548. flex-direction: column;
  1549. }
  1550. /* 空状态 */
  1551. .empty-state {
  1552. display: flex;
  1553. flex-direction: column;
  1554. align-items: center;
  1555. justify-content: center;
  1556. padding: 200rpx 40rpx;
  1557. text-align: center;
  1558. }
  1559. .empty-icon {
  1560. font-size: 120rpx;
  1561. margin-bottom: 32rpx;
  1562. opacity: 0.5;
  1563. }
  1564. .empty-title {
  1565. font-size: 36rpx;
  1566. font-weight: 600;
  1567. color: #ffffff;
  1568. margin-bottom: 16rpx;
  1569. }
  1570. .empty-desc {
  1571. font-size: 28rpx;
  1572. color: #9ca3af;
  1573. margin-bottom: 48rpx;
  1574. }
  1575. .empty-btn {
  1576. padding: 24rpx 64rpx;
  1577. background: linear-gradient(135deg, #4F46E5 0%, #7C3AED 100%);
  1578. color: #ffffff;
  1579. border-radius: 48rpx;
  1580. font-size: 28rpx;
  1581. border: none;
  1582. }
  1583. .nav-bar {
  1584. display: flex;
  1585. align-items: center;
  1586. justify-content: space-between;
  1587. padding: 20rpx 32rpx 12rpx;
  1588. }
  1589. .nav-center {
  1590. display: flex;
  1591. flex-direction: column;
  1592. align-items: center;
  1593. }
  1594. .nav-actions {
  1595. display: flex;
  1596. align-items: center;
  1597. gap: 8rpx;
  1598. }
  1599. .nav-btn {
  1600. width: 64rpx;
  1601. height: 64rpx;
  1602. display: flex;
  1603. align-items: center;
  1604. justify-content: center;
  1605. transition: transform 0.15s ease;
  1606. }
  1607. .nav-btn:active {
  1608. transform: scale(0.85);
  1609. }
  1610. .sleep-timer-badge {
  1611. display: flex;
  1612. align-items: center;
  1613. background: rgba(79, 70, 229, 0.3);
  1614. padding: 8rpx 16rpx;
  1615. border-radius: 24rpx;
  1616. margin-top: 8rpx;
  1617. }
  1618. .sleep-timer-icon {
  1619. font-size: 20rpx;
  1620. margin-right: 6rpx;
  1621. }
  1622. .sleep-timer-text {
  1623. font-size: 22rpx;
  1624. color: #a5b4fc;
  1625. font-weight: 500;
  1626. }
  1627. .nav-icon {
  1628. font-size: 36rpx;
  1629. color: #ffffff;
  1630. }
  1631. .nav-btn-lyrics {
  1632. width: auto;
  1633. padding: 0 16rpx;
  1634. gap: 4rpx;
  1635. }
  1636. .nav-btn-label {
  1637. font-size: 20rpx;
  1638. color: #a5b4fc;
  1639. white-space: nowrap;
  1640. }
  1641. .nav-title {
  1642. font-size: 32rpx;
  1643. font-weight: 600;
  1644. color: #ffffff;
  1645. }
  1646. .cover-section {
  1647. display: flex;
  1648. flex-direction: column;
  1649. align-items: center;
  1650. padding: 24rpx 0 16rpx;
  1651. }
  1652. .cover {
  1653. width: 180rpx;
  1654. height: 180rpx;
  1655. background: linear-gradient(135deg, #4f46e5 0%, #818cf8 100%);
  1656. border-radius: 24rpx;
  1657. display: flex;
  1658. align-items: center;
  1659. justify-content: center;
  1660. margin-bottom: 16rpx;
  1661. box-shadow: 0 8rpx 24rpx rgba(79, 70, 229, 0.4);
  1662. }
  1663. .cover-icon {
  1664. font-size: 64rpx;
  1665. }
  1666. .title {
  1667. font-size: 32rpx;
  1668. font-weight: 600;
  1669. color: #ffffff;
  1670. text-align: center;
  1671. margin-bottom: 8rpx;
  1672. max-width: 600rpx;
  1673. overflow: hidden;
  1674. text-overflow: ellipsis;
  1675. white-space: nowrap;
  1676. }
  1677. .meta {
  1678. font-size: 24rpx;
  1679. color: #9ca3af;
  1680. }
  1681. .text-preview {
  1682. margin: 0 32rpx;
  1683. padding: 24rpx;
  1684. background: rgba(255, 255, 255, 0.05);
  1685. border-radius: 16rpx;
  1686. width: calc(100% - 64rpx);
  1687. box-sizing: border-box;
  1688. overflow: hidden;
  1689. }
  1690. .lyrics-container {
  1691. display: flex;
  1692. flex-direction: column;
  1693. width: 100%;
  1694. box-sizing: border-box;
  1695. }
  1696. .lyric-line {
  1697. padding: 8rpx 16rpx;
  1698. border-radius: 12rpx;
  1699. opacity: 0.5;
  1700. width: 100%;
  1701. box-sizing: border-box;
  1702. margin-bottom: 8rpx;
  1703. }
  1704. .lyric-line.active {
  1705. background: rgba(79, 70, 229, 0.2);
  1706. opacity: 1;
  1707. }
  1708. .lyric-text {
  1709. font-size: 24rpx;
  1710. color: #d1d5db;
  1711. line-height: 1.6;
  1712. white-space: pre-wrap;
  1713. word-break: break-word;
  1714. width: 100%;
  1715. box-sizing: border-box;
  1716. }
  1717. .lyric-line.active .lyric-text {
  1718. color: #ffffff;
  1719. font-weight: 500;
  1720. }
  1721. .progress-section {
  1722. display: flex;
  1723. align-items: center;
  1724. padding: 32rpx;
  1725. }
  1726. .time {
  1727. font-size: 22rpx;
  1728. color: #9ca3af;
  1729. width: 80rpx;
  1730. text-align: center;
  1731. }
  1732. .progress-slider {
  1733. flex: 1;
  1734. margin: 0 16rpx;
  1735. }
  1736. .controls {
  1737. display: flex;
  1738. align-items: center;
  1739. justify-content: space-between;
  1740. padding: 24rpx 32rpx;
  1741. }
  1742. .controls-side,
  1743. .controls-center {
  1744. display: flex;
  1745. align-items: center;
  1746. }
  1747. .controls-side {
  1748. margin-right: 16rpx;
  1749. gap: 12rpx;
  1750. }
  1751. .controls-center {
  1752. margin-right: 24rpx;
  1753. }
  1754. .control-btn {
  1755. width: 70rpx;
  1756. height: 70rpx;
  1757. display: flex;
  1758. align-items: center;
  1759. justify-content: center;
  1760. border-radius: 50%;
  1761. background: rgba(255, 255, 255, 0.15);
  1762. }
  1763. .control-btn.disabled {
  1764. opacity: 0.4;
  1765. }
  1766. .control-btn.share-btn {
  1767. flex-direction: column;
  1768. width: 100rpx;
  1769. height: 80rpx;
  1770. background: linear-gradient(135deg, rgba(79, 70, 229, 0.3) 0%, rgba(129, 140, 248, 0.3) 100%);
  1771. }
  1772. .share-label {
  1773. font-size: 18rpx;
  1774. color: #a5b4fc;
  1775. margin-top: 4rpx;
  1776. }
  1777. .control-text {
  1778. font-size: 24rpx;
  1779. color: #ffffff;
  1780. font-weight: 600;
  1781. }
  1782. /* 播放速度显示按钮 */
  1783. .speed-display-btn {
  1784. width: auto;
  1785. min-width: 120rpx;
  1786. height: 70rpx;
  1787. padding: 0 20rpx;
  1788. border-radius: 35rpx;
  1789. gap: 6rpx;
  1790. background: linear-gradient(135deg, rgba(79, 70, 229, 0.35) 0%, rgba(129, 140, 248, 0.25) 100%);
  1791. border: 2rpx solid rgba(129, 140, 248, 0.3);
  1792. }
  1793. .speed-label {
  1794. font-size: 20rpx;
  1795. color: #a5b4fc;
  1796. font-weight: 500;
  1797. }
  1798. .speed-value {
  1799. font-size: 28rpx;
  1800. color: #ffffff;
  1801. font-weight: 700;
  1802. }
  1803. .control-icon {
  1804. font-size: 36rpx;
  1805. color: #ffffff;
  1806. }
  1807. .play-btn {
  1808. width: 110rpx;
  1809. height: 110rpx;
  1810. background: linear-gradient(135deg, #4f46e5 0%, #818cf8 100%);
  1811. border-radius: 50%;
  1812. display: flex;
  1813. align-items: center;
  1814. justify-content: center;
  1815. box-shadow: 0 8rpx 24rpx rgba(79, 70, 229, 0.5);
  1816. border: 4rpx solid rgba(255, 255, 255, 0.3);
  1817. }
  1818. .play-icon {
  1819. font-size: 44rpx;
  1820. color: #ffffff;
  1821. margin-left: 6rpx;
  1822. }
  1823. .bottom-actions {
  1824. display: flex;
  1825. justify-content: center;
  1826. padding: 48rpx;
  1827. }
  1828. .bottom-actions .action-btn {
  1829. margin: 0 40rpx;
  1830. }
  1831. .action-btn {
  1832. display: flex;
  1833. flex-direction: column;
  1834. align-items: center;
  1835. background: transparent;
  1836. border: none;
  1837. padding: 0;
  1838. border-radius: 50%;
  1839. padding: 16rpx;
  1840. background: rgba(255, 255, 255, 0.1);
  1841. }
  1842. .action-icon {
  1843. font-size: 48rpx;
  1844. margin-bottom: 8rpx;
  1845. }
  1846. .action-text {
  1847. font-size: 24rpx;
  1848. color: #9ca3af;
  1849. }
  1850. .rate-picker {
  1851. position: fixed;
  1852. top: 0;
  1853. left: 0;
  1854. right: 0;
  1855. bottom: 0;
  1856. background: rgba(0, 0, 0, 0.5);
  1857. display: flex;
  1858. align-items: flex-end;
  1859. z-index: 1000;
  1860. }
  1861. .rate-content {
  1862. width: 100%;
  1863. background: #1f2937;
  1864. border-radius: 32rpx 32rpx 0 0;
  1865. padding: 48rpx;
  1866. }
  1867. .rate-title {
  1868. font-size: 32rpx;
  1869. font-weight: 600;
  1870. color: #ffffff;
  1871. text-align: center;
  1872. margin-bottom: 32rpx;
  1873. }
  1874. .rate-options {
  1875. display: flex;
  1876. flex-wrap: wrap;
  1877. }
  1878. .rate-item {
  1879. flex: 1;
  1880. min-width: 180rpx;
  1881. height: 80rpx;
  1882. display: flex;
  1883. align-items: center;
  1884. justify-content: center;
  1885. background: #374151;
  1886. border-radius: 16rpx;
  1887. border: 2rpx solid transparent;
  1888. margin: 12rpx;
  1889. }
  1890. .rate-item.active {
  1891. background: #4f46e5;
  1892. border-color: #6366f1;
  1893. }
  1894. .rate-item text {
  1895. font-size: 28rpx;
  1896. color: #ffffff;
  1897. }
  1898. .sleep-timer-cancel {
  1899. margin-top: 24rpx;
  1900. padding: 24rpx;
  1901. background: rgba(239, 68, 68, 0.2);
  1902. border-radius: 16rpx;
  1903. display: flex;
  1904. align-items: center;
  1905. justify-content: center;
  1906. }
  1907. .sleep-timer-cancel text {
  1908. font-size: 28rpx;
  1909. color: #fca5a5;
  1910. }
  1911. /* 移动端播放列表设计 - 直接嵌入界面 */
  1912. .playlist-section-mobile {
  1913. flex: 1;
  1914. display: flex;
  1915. flex-direction: column;
  1916. min-height: 0;
  1917. background: rgba(31, 41, 55, 0.8);
  1918. border-top-left-radius: 32rpx;
  1919. border-top-right-radius: 32rpx;
  1920. padding: 20rpx 0 0;
  1921. box-shadow: 0 -8rpx 16rpx rgba(0, 0, 0, 0.1);
  1922. }
  1923. .playlist-header {
  1924. display: flex;
  1925. align-items: center;
  1926. justify-content: space-between;
  1927. padding: 0 32rpx 20rpx;
  1928. border-bottom: 1rpx solid rgba(255, 255, 255, 0.1);
  1929. margin-bottom: 16rpx;
  1930. }
  1931. .playlist-header-left {
  1932. display: flex;
  1933. align-items: center;
  1934. }
  1935. .playlist-header-left .playlist-mode-icon,
  1936. .playlist-header-left .playlist-count {
  1937. margin-left: 16rpx;
  1938. }
  1939. .playlist-header-right {
  1940. display: flex;
  1941. align-items: center;
  1942. padding: 12rpx 20rpx;
  1943. background: rgba(255, 255, 255, 0.1);
  1944. border-radius: 40rpx;
  1945. }
  1946. .playlist-title {
  1947. font-size: 32rpx;
  1948. font-weight: 600;
  1949. color: #ffffff;
  1950. }
  1951. .playlist-count {
  1952. font-size: 24rpx;
  1953. color: #9ca3af;
  1954. }
  1955. .playlist-mode-icon {
  1956. font-size: 28rpx;
  1957. }
  1958. .playlist-mode-text {
  1959. font-size: 24rpx;
  1960. color: #ffffff;
  1961. font-weight: 500;
  1962. }
  1963. .playlist-list-mobile {
  1964. flex: 1;
  1965. min-height: 0;
  1966. max-height: 60vh;
  1967. padding: 0 16rpx 16rpx;
  1968. box-sizing: border-box;
  1969. }
  1970. .playlist-item {
  1971. display: flex;
  1972. align-items: center;
  1973. padding: 24rpx 32rpx;
  1974. border-radius: 16rpx;
  1975. }
  1976. .playlist-item .playlist-info {
  1977. margin-left: 20rpx;
  1978. }
  1979. .playlist-item .playing-icon {
  1980. margin-left: auto;
  1981. }
  1982. .playlist-item.active {
  1983. background: rgba(79, 70, 229, 0.2);
  1984. }
  1985. .playlist-index {
  1986. width: 50rpx;
  1987. height: 50rpx;
  1988. display: flex;
  1989. align-items: center;
  1990. justify-content: center;
  1991. background: rgba(255, 255, 255, 0.15); /* 更明显的背景 */
  1992. border-radius: 50%;
  1993. font-size: 24rpx;
  1994. color: #ffffff; /* 白色文字 */
  1995. flex-shrink: 0;
  1996. font-weight: 600; /* 加粗 */
  1997. }
  1998. .playlist-item.active .playlist-index {
  1999. background: #4f46e5;
  2000. color: #ffffff;
  2001. }
  2002. .playlist-info {
  2003. flex: 1;
  2004. min-width: 0;
  2005. }
  2006. .playlist-item-title {
  2007. font-size: 28rpx;
  2008. color: #ffffff;
  2009. display: block;
  2010. overflow: hidden;
  2011. text-overflow: ellipsis;
  2012. white-space: nowrap;
  2013. margin-bottom: 6rpx;
  2014. font-weight: 500;
  2015. }
  2016. .playlist-item-meta {
  2017. font-size: 24rpx;
  2018. color: #9ca3af;
  2019. }
  2020. .playing-icon {
  2021. width: 50rpx;
  2022. height: 50rpx;
  2023. display: flex;
  2024. align-items: center;
  2025. justify-content: center;
  2026. font-size: 24rpx;
  2027. color: #4f46e5;
  2028. background: rgba(79, 70, 229, 0.2); /* 背景使其更明显 */
  2029. border-radius: 50%; /* 圆形背景 */
  2030. flex-shrink: 0;
  2031. }
  2032. </style>
  2033. // ====== pages/payment-confirm/index.vue ======
  2034. <template>
  2035. <view class="page">
  2036. <!-- 顶部导航栏 -->
  2037. <view class="nav-bar">
  2038. <view class="nav-content">
  2039. <view class="nav-left" @click="goBack">
  2040. <text class="back-icon">←</text>
  2041. </view>
  2042. <text class="page-title">确认支付</text>
  2043. </view>
  2044. </view>
  2045. <!-- 支付内容 -->
  2046. <view class="content">
  2047. <!-- 订单信息 -->
  2048. <view class="order-card">
  2049. <text class="order-title">订单信息</text>
  2050. <view class="order-info">
  2051. <text class="info-label">商品</text>
  2052. <text class="info-value">{{ orderInfo.planName || '-' }}</text>
  2053. </view>
  2054. <view class="order-info">
  2055. <text class="info-label">订单号</text>
  2056. <text class="info-value">{{ orderInfo.orderNo || '-' }}</text>
  2057. </view>
  2058. <view class="order-info highlight">
  2059. <text class="info-label">应付金额</text>
  2060. <text class="info-value price">¥{{ orderInfo.amount || 0 }}</text>
  2061. </view>
  2062. </view>
  2063. <!-- 支付方式选择 -->
  2064. <view class="payment-section">
  2065. <text class="section-title">选择支付方式</text>
  2066. <!-- 公众号环境提示 -->
  2067. <view class="wechat-notice" v-if="inWechat">
  2068. <text class="wechat-notice-icon">💚</text>
  2069. <text class="wechat-notice-text">检测到微信环境,将自动使用微信支付</text>
  2070. </view>
  2071. <view class="payment-methods">
  2072. <view
  2073. class="payment-method"
  2074. :class="{ active: paymentMethod === 'alipay' }"
  2075. @click="paymentMethod = 'alipay'"
  2076. >
  2077. <view class="method-radio">
  2078. <view class="radio-inner" v-if="paymentMethod === 'alipay'"></view>
  2079. </view>
  2080. <text class="method-icon">💙</text>
  2081. <text class="method-name">支付宝</text>
  2082. </view>
  2083. <view
  2084. class="payment-method"
  2085. :class="{ active: paymentMethod === 'wechat' }"
  2086. @click="paymentMethod = 'wechat'"
  2087. >
  2088. <view class="method-radio">
  2089. <view class="radio-inner" v-if="paymentMethod === 'wechat'"></view>
  2090. </view>
  2091. <text class="method-icon">🟢</text>
  2092. <text class="method-name">微信支付</text>
  2093. <text class="method-badge" v-if="inWechat">推荐</text>
  2094. </view>
  2095. </view>
  2096. </view>
  2097. <!-- 操作按钮 -->
  2098. <view class="actions">
  2099. <button class="btn-secondary" @click="goBack">取消</button>
  2100. <button class="btn-primary" @click="handlePay">确认支付</button>
  2101. </view>
  2102. <!-- 提示 -->
  2103. <view class="tips">
  2104. <text class="tips-title">温馨提示</text>
  2105. <text class="tips-text">• 请在24小时内完成支付</text>
  2106. <text class="tips-text">• 支付成功后权益将自动到账</text>
  2107. <text class="tips-text">• 如支付遇到问题,请联系客服</text>
  2108. </view>
  2109. </view>
  2110. </view>
  2111. </template>
  2112. <script setup lang="ts">
  2113. import { ref, onMounted } from 'vue';
  2114. import { post, get } from '../../utils/request';
  2115. // ==================== 微信浏览器检测 ====================
  2116. function isInWechat(): boolean {
  2117. // #ifdef H5
  2118. const ua = navigator.userAgent.toLowerCase();
  2119. return ua.includes('micromessenger');
  2120. // #endif
  2121. // #ifndef H5
  2122. return false;
  2123. // #endif
  2124. }
  2125. // 获取不含 hash 的基础URL(用于OAuth redirect_uri)
  2126. function getBaseUrl(): string {
  2127. // #ifdef H5
  2128. return window.location.origin + window.location.pathname;
  2129. // #endif
  2130. return '';
  2131. }
  2132. // 保存当前页面上下文到 sessionStorage(OAuth 来回会丢失 hash 路由状态)
  2133. function savePageContext() {
  2134. try {
  2135. sessionStorage.setItem('wx_oauth_return', window.location.href);
  2136. } catch (_) {}
  2137. }
  2138. // 恢复页面上下文
  2139. function restorePageContext(): string | null {
  2140. try {
  2141. return sessionStorage.getItem('wx_oauth_return');
  2142. } catch (_) { return null; }
  2143. }
  2144. const orderInfo = ref<{
  2145. orderNo: string;
  2146. amount: number;
  2147. planName: string;
  2148. }>({
  2149. orderNo: '',
  2150. amount: 0,
  2151. planName: ''
  2152. });
  2153. const planId = ref<number>(0);
  2154. const productType = ref<string>(''); // 'subscription' | 'token-pack'
  2155. const tokenPackQuantity = ref<number>(0);
  2156. const paymentMethod = ref<'alipay' | 'wechat'>('alipay');
  2157. const inWechat = ref(false);
  2158. const openid = ref('');
  2159. const paying = ref(false);
  2160. const lastPayClick = ref(0); // 防重:记录上次点击时间
  2161. let checkTimer: ReturnType<typeof setInterval> | null = null;
  2162. // ==================== 微信 OAuth 处理 ====================
  2163. async function handleWechatOAuth() {
  2164. // #ifdef H5
  2165. // OAuth 回调已在 App.vue 全局处理,这里只需读取缓存的 openid
  2166. const cachedOpenid = localStorage.getItem('wx_openid');
  2167. if (cachedOpenid) {
  2168. openid.value = cachedOpenid;
  2169. console.log('[WeChat Pay] 使用缓存的 openid');
  2170. return;
  2171. }
  2172. // 双重保险:检查 URL search 中的 code(如果 App.vue 未拦截到)
  2173. const searchParams = new URLSearchParams(window.location.search);
  2174. const code = searchParams.get('code');
  2175. if (code) {
  2176. uni.showLoading({ title: '授权中...' });
  2177. try {
  2178. const result = await post<{ openid: string }>('/payment/wechat/openid', { code });
  2179. openid.value = result.openid;
  2180. localStorage.setItem('wx_openid', result.openid);
  2181. console.log('[WeChat Pay] openid 获取成功,自动重新发起支付');
  2182. // 清除 URL 中的 code/state
  2183. searchParams.delete('code');
  2184. searchParams.delete('state');
  2185. const newSearch = searchParams.toString();
  2186. const newUrl = window.location.origin + window.location.pathname
  2187. + (newSearch ? '?' + newSearch : '');
  2188. window.history.replaceState({}, '', newUrl);
  2189. // 自动重新发起支付(不用用户再点一次)
  2190. setTimeout(() => handleWechatJsapiPay(), 100);
  2191. } catch (e) {
  2192. console.error('[WeChat Pay] 获取 openid 失败:', e);
  2193. } finally {
  2194. uni.hideLoading();
  2195. }
  2196. }
  2197. // #endif
  2198. }
  2199. // 跳转到微信 OAuth 授权
  2200. async function redirectToWechatOAuth() {
  2201. // #ifdef H5
  2202. try {
  2203. // 保存当前完整 URL(含 hash 路由参数),OAuth 回来时恢复
  2204. savePageContext();
  2205. // 用不含 # 的 baseUrl 作为 redirect_uri,避免微信报"非法字符"
  2206. const baseUrl = getBaseUrl();
  2207. const result = await get<{ oauthUrl: string; state: string }>('/payment/wechat/oauth-url', {
  2208. redirect: baseUrl
  2209. });
  2210. window.location.href = result.oauthUrl;
  2211. } catch (e) {
  2212. console.error('[WeChat Pay] 获取OAuth链接失败:', e);
  2213. }
  2214. // #endif
  2215. }
  2216. onMounted(async () => {
  2217. const pages = getCurrentPages();
  2218. const currentPage = pages[pages.length - 1] as any;
  2219. // #ifdef APP-PLUS
  2220. const options = currentPage?.$page?.options || {};
  2221. // #endif
  2222. // #ifndef APP-PLUS
  2223. const options = currentPage?.options || {};
  2224. // #endif
  2225. // 检查产品类型
  2226. productType.value = options.productType || 'subscription';
  2227. if (productType.value === 'token-pack') {
  2228. // 积分包支付
  2229. tokenPackQuantity.value = parseInt(options.quantity) || 1;
  2230. orderInfo.value.planName = decodeURIComponent(options.planName || '积分包');
  2231. orderInfo.value.amount = parseFloat(options.amount) || 0;
  2232. } else if (options.planId) {
  2233. planId.value = parseInt(options.planId);
  2234. orderInfo.value.planName = decodeURIComponent(options.planName || '会员订阅');
  2235. orderInfo.value.amount = parseFloat(options.amount) || 0;
  2236. }
  2237. // 检测是否在微信浏览器中
  2238. if (isInWechat()) {
  2239. inWechat.value = true;
  2240. // 默认选中微信支付
  2241. paymentMethod.value = 'wechat';
  2242. // 处理 OAuth 回调
  2243. await handleWechatOAuth();
  2244. // 🔧 如果刚从 OAuth 回来 + openid 已缓存,自动触发支付
  2245. try {
  2246. const justDidOAuth = sessionStorage.getItem('wx_oauth_just_done');
  2247. if (justDidOAuth === '1') {
  2248. sessionStorage.removeItem('wx_oauth_just_done');
  2249. const cachedOpenid = localStorage.getItem('wx_openid');
  2250. if (cachedOpenid) {
  2251. console.log('[WeChat Pay] 检测到 OAuth 刚完成,自动触发支付');
  2252. // 延迟执行,确保页面已完全渲染
  2253. setTimeout(() => {
  2254. openid.value = cachedOpenid;
  2255. handleWechatJsapiPay();
  2256. }, 500);
  2257. }
  2258. }
  2259. } catch (_) {}
  2260. // 微信环境不预创建订单(JSAPI 支付时另外创建)
  2261. } else {
  2262. // 非微信环境:进入页面时直接创建订单
  2263. if (productType.value === 'token-pack' || planId.value) {
  2264. await createOrder();
  2265. }
  2266. }
  2267. });
  2268. async function createOrder() {
  2269. try {
  2270. uni.showLoading({ title: '创建订单...' });
  2271. const returnUrl = `${window.location.origin}/#/pages/payment-result/index`;
  2272. let result: any;
  2273. if (productType.value === 'token-pack') {
  2274. // 积分包订单
  2275. result = await post<{
  2276. orderNo: string;
  2277. amount: number;
  2278. paymentUrl?: string;
  2279. qrcode?: string;
  2280. planName?: string;
  2281. }>('/payment/token-packs/create', {
  2282. quantity: tokenPackQuantity.value,
  2283. paymentMethod: paymentMethod.value,
  2284. returnUrl
  2285. });
  2286. } else {
  2287. // 套餐订阅订单
  2288. result = await post<{
  2289. orderNo: string;
  2290. amount: number;
  2291. paymentUrl?: string;
  2292. qrcode?: string;
  2293. planName?: string;
  2294. }>('/payment/create', {
  2295. planId: planId.value,
  2296. paymentMethod: paymentMethod.value,
  2297. returnUrl
  2298. });
  2299. }
  2300. orderInfo.value.orderNo = result.orderNo;
  2301. uni.hideLoading();
  2302. } catch (error) {
  2303. uni.hideLoading();
  2304. console.error('创建订单失败:', error);
  2305. }
  2306. }
  2307. // 查询微信订单状态(用于支付后确认)
  2308. async function checkWechatOrderStatus(orderNo: string): Promise<string | null> {
  2309. try {
  2310. const result = await get<any>(`/payment/wechat/query/${orderNo}`);
  2311. const state = result?.data?.trade_state || result?.trade_state || null;
  2312. console.log('[WeChat Pay] 订单查询结果:', orderNo, state);
  2313. return state;
  2314. } catch (e) {
  2315. console.error('[WeChat Pay] 订单查询失败:', e);
  2316. return null;
  2317. }
  2318. }
  2319. // ==================== 微信 JSAPI 支付(公众号内) ====================
  2320. // 使用 WeixinJSBridge.invoke 直接调起(官方 V3 推荐方式,无需 JSSDK 配置)
  2321. // 参考: https://pay.weixin.qq.com/doc/v3/merchant/4012791870
  2322. // 常见失败原因检查清单:
  2323. // 1. timeStamp 必须是字符串(不是数字) - 后端已修复
  2324. // 2. 微信商户平台需配置「支付授权目录」
  2325. // 3. 公众号后台需配置「网页授权域名」
  2326. // 4. 使用正确的公众号 appId(非小程序 appId)
  2327. let pendingPayResult: any = null; // 用于 WeixinJSBridge 异步注入场景
  2328. async function handleWechatJsapiPay() {
  2329. // 如果没有 openid,需要先授权
  2330. if (!openid.value) {
  2331. console.log('[WeChat Pay] 无 openid,跳转 OAuth 授权');
  2332. await redirectToWechatOAuth();
  2333. return;
  2334. }
  2335. paying.value = true;
  2336. uni.showLoading({ title: '创建订单...' });
  2337. try {
  2338. // 创建 JSAPI 支付订单
  2339. let result: any;
  2340. if (productType.value === 'token-pack') {
  2341. result = await post<any>('/payment/wechat/jsapi', {
  2342. productType: 'token-pack',
  2343. quantity: tokenPackQuantity.value,
  2344. openid: openid.value
  2345. });
  2346. } else {
  2347. result = await post<any>('/payment/wechat/jsapi', {
  2348. planId: planId.value,
  2349. openid: openid.value
  2350. });
  2351. }
  2352. // 注意:post 函数返回的是 response.data,即 { code, message, data: {...} }
  2353. const responseData = result.data || result;
  2354. orderInfo.value.orderNo = responseData.orderNo;
  2355. // 🔧 保存待调用结果,用于 WeixinJSBridge 异步注入场景
  2356. pendingPayResult = responseData;
  2357. console.log('[WeChat Pay] 订单创建成功,准备调起支付');
  2358. console.log('[WeChat Pay] payParams 详情:', JSON.stringify({
  2359. appId: responseData.payParams?.appId,
  2360. timeStamp: `${responseData.payParams?.timeStamp} (type:${typeof responseData.payParams?.timeStamp})`,
  2361. nonceStr: responseData.payParams?.nonceStr,
  2362. package: responseData.payParams?.package,
  2363. signType: responseData.payParams?.signType,
  2364. paySign_length: responseData.payParams?.paySign?.length,
  2365. }));
  2366. uni.hideLoading();
  2367. uni.showLoading({ title: '拉起支付...' });
  2368. // 调用 WeixinJSBridge(带自动重试)
  2369. tryInvokeWeixinPay(responseData);
  2370. } catch (error: any) {
  2371. paying.value = false;
  2372. uni.hideLoading();
  2373. console.error('[WeChat Pay] 创建JSAPI支付失败:', error);
  2374. const errMsg = error?.message || String(error);
  2375. if (errMsg.includes('openid')) {
  2376. localStorage.removeItem('wx_openid');
  2377. openid.value = '';
  2378. await redirectToWechatOAuth();
  2379. return;
  2380. }
  2381. // 更友好的错误提示
  2382. let tip = '支付失败';
  2383. if (errMsg.includes('appid')) tip = '微信AppID配置错误';
  2384. else if (errMsg.includes('mchid')) tip = '商户号配置错误';
  2385. else if (errMsg.includes('PARAM_ERROR')) tip = '支付参数错误,请检查配置';
  2386. else if (errMsg.includes('SIGN_ERROR')) tip = '签名验证失败,请检查证书';
  2387. else if (errMsg.includes('NOT_ENOUGH')) tip = '商户余额不足';
  2388. else tip = errMsg.substring(0, 50);
  2389. uni.showModal({
  2390. title: '支付失败',
  2391. content: `错误:${tip}\n\n请截图发给客服排查`,
  2392. showCancel: false,
  2393. confirmText: '知道了'
  2394. });
  2395. }
  2396. }
  2397. // 尝试调起微信支付(带 WeixinJSBridge 检测和重试)
  2398. function tryInvokeWeixinPay(payResult: any, retryCount: number = 0) {
  2399. const MAX_RETRY = 30; // 最多等 3 秒(100ms × 30)
  2400. const wjsBridge = (window as any).WeixinJSBridge;
  2401. if (wjsBridge && typeof wjsBridge.invoke === 'function') {
  2402. // ✅ WeixinJSBridge 已就绪,直接调起
  2403. console.log('[WeChat Pay] WeixinJSBridge 就绪,调起支付');
  2404. invokeWeixinJSBridge(payResult);
  2405. return;
  2406. }
  2407. // WeixinJSBridge 未就绪,等待注入
  2408. if (retryCount === 0) {
  2409. console.log('[WeChat Pay] WeixinJSBridge 未就绪,开始等待注入...');
  2410. // 方式 1:监听官方注入事件
  2411. if (document.addEventListener) {
  2412. document.addEventListener('WeixinJSBridgeReady', () => {
  2413. console.log('[WeChat Pay] 收到 WeixinJSBridgeReady 事件');
  2414. invokeWeixinJSBridge(pendingPayResult || payResult);
  2415. }, { once: true });
  2416. }
  2417. // 方式 2:兼容旧版 IE
  2418. if ((document as any).attachEvent) {
  2419. (document as any).attachEvent('onWeixinJSBridgeReady', () => {
  2420. console.log('[WeChat Pay] 收到 onWeixinJSBridgeReady 事件');
  2421. invokeWeixinJSBridge(pendingPayResult || payResult);
  2422. });
  2423. }
  2424. }
  2425. // 方式 3:轮询检测(兜底机制,解决事件丢失问题)
  2426. if (retryCount < MAX_RETRY) {
  2427. setTimeout(() => tryInvokeWeixinPay(payResult, retryCount + 1), 100);
  2428. } else {
  2429. // 超时:WeixinJSBridge 始终未注入
  2430. paying.value = false;
  2431. uni.hideLoading();
  2432. console.error('[WeChat Pay] ❌ WeixinJSBridge 注入超时(等待3秒)');
  2433. uni.showModal({
  2434. title: '环境异常',
  2435. content: '请在微信内打开此页面进行支付\n\n提示:\n1. 确认在微信浏览器中访问\n2. 确认域名已在公众号配置',
  2436. showCancel: false,
  2437. });
  2438. }
  2439. }
  2440. // 调用 WeixinJSBridge.invoke
  2441. function invokeWeixinJSBridge(payResult: any) {
  2442. const payParams = payResult?.payParams;
  2443. if (!payParams || !payParams.package) {
  2444. console.error('[WeChat Pay] ❌ payParams 异常:', JSON.stringify(payResult));
  2445. paying.value = false;
  2446. uni.hideLoading();
  2447. uni.showModal({
  2448. title: '参数异常',
  2449. content: '支付参数不完整,请重试',
  2450. showCancel: false,
  2451. });
  2452. return;
  2453. }
  2454. // 🔧 关键校验:确保所有字段都是字符串类型
  2455. const wxp = {
  2456. appId: String(payParams.appId || ''),
  2457. timeStamp: String(payParams.timeStamp || ''), // ⚠️ 必须是字符串!
  2458. nonceStr: String(payParams.nonceStr || ''),
  2459. package: String(payParams.package || ''), // prepay_id=wx...
  2460. signType: String(payParams.signType || 'RSA'),
  2461. paySign: String(payParams.paySign || ''),
  2462. };
  2463. // 校验必填字段
  2464. if (!wxp.appId || !wxp.timeStamp || !wxp.nonceStr || !wxp.package || !wxp.paySign) {
  2465. console.error('[WeChat Pay] ❌ 支付参数不完整:', JSON.stringify(wxp));
  2466. paying.value = false;
  2467. uni.hideLoading();
  2468. uni.showModal({
  2469. title: '参数不完整',
  2470. content: `缺少字段:${[
  2471. !wxp.appId && 'appId',
  2472. !wxp.timeStamp && 'timeStamp',
  2473. !wxp.nonceStr && 'nonceStr',
  2474. !wxp.package && 'package',
  2475. !wxp.paySign && 'paySign',
  2476. ].filter(Boolean).join(', ')}`,
  2477. showCancel: false,
  2478. });
  2479. return;
  2480. }
  2481. console.log('[WeChat Pay] 🚀 调用 WeixinJSBridge.invoke');
  2482. console.log('[WeChat Pay] 参数:', JSON.stringify({
  2483. ...wxp,
  2484. paySign: wxp.paySign.substring(0, 30) + '...',
  2485. package_length: wxp.package.length,
  2486. }));
  2487. (window as any).WeixinJSBridge.invoke(
  2488. 'getBrandWCPayRequest',
  2489. wxp,
  2490. async (res: any) => {
  2491. uni.hideLoading();
  2492. paying.value = false;
  2493. console.log('[WeChat Pay] 回调结果:', JSON.stringify(res));
  2494. // 处理回调
  2495. await handleWechatPayCallback(res, payResult.orderNo);
  2496. }
  2497. );
  2498. }
  2499. // 统一处理微信支付回调结果
  2500. async function handleWechatPayCallback(res: any, orderNo: string) {
  2501. // err_msg 可能的值:
  2502. // get_brand_wcpay_request:ok - 支付成功
  2503. // get_brand_wcpay_request:cancel - 用户取消
  2504. // get_brand_wcpay_request:fail - 支付失败
  2505. const errMsg = res?.err_msg || res?.errMsg || '';
  2506. if (errMsg.indexOf(':ok') !== -1) {
  2507. // 前端返回成功,查询服务端确认
  2508. console.log('[WeChat Pay] ✅ 前端返回成功,查询服务端确认...');
  2509. const orderStatus = await checkWechatOrderStatus(orderNo);
  2510. if (orderStatus === 'SUCCESS') {
  2511. uni.showModal({
  2512. title: '支付成功',
  2513. content: productType.value === 'token-pack' ? '积分包购买成功!' : '恭喜您订阅成功!',
  2514. showCancel: false,
  2515. success: () => uni.switchTab({ url: '/pages/mine/index' })
  2516. });
  2517. } else {
  2518. // 可能回调还没到服务端
  2519. uni.showToast({ title: '订单确认中,请稍后查看', icon: 'none', duration: 3000 });
  2520. }
  2521. return;
  2522. }
  2523. // 支付未成功
  2524. console.log('[WeChat Pay] ❌ 支付未成功:', errMsg);
  2525. // 官方建议:无论如何都查询一次
  2526. const orderStatus = await checkWechatOrderStatus(orderNo);
  2527. if (orderStatus === 'SUCCESS') {
  2528. uni.showModal({
  2529. title: '支付成功',
  2530. content: productType.value === 'token-pack' ? '积分包购买成功!' : '恭喜您订阅成功!',
  2531. showCancel: false,
  2532. success: () => uni.switchTab({ url: '/pages/mine/index' })
  2533. });
  2534. return;
  2535. }
  2536. // 真正失败 - 显示具体错误和解决建议
  2537. const isCancel = errMsg.indexOf(':cancel') !== -1;
  2538. if (isCancel) {
  2539. uni.showToast({ title: '已取消支付', icon: 'none' });
  2540. return;
  2541. }
  2542. // 🔍 构造详细错误诊断
  2543. let diagnosis = '';
  2544. if (errMsg.includes('no_balance')) diagnosis = '\n原因:商户余额不足';
  2545. else if (errMsg.includes('bank_error')) diagnosis = '\n原因:银行卡异常';
  2546. else if (errMsg.includes('not_sufficient_funds')) diagnosis = '\n原因:余额不足';
  2547. else if (errMsg.includes('param_error') || errMsg.includes('sign')) diagnosis = '\n原因:支付参数错误(请检查配置)';
  2548. else diagnosis = `\n错误码:${errMsg}`;
  2549. uni.showModal({
  2550. title: '支付失败',
  2551. content: `支付未完成${diagnosis}\n\n如持续失败,请截图联系客服`,
  2552. showCancel: true,
  2553. confirmText: '重新支付',
  2554. cancelText: '关闭',
  2555. success: (modalRes: any) => {
  2556. if (modalRes.confirm) {
  2557. handleWechatJsapiPay();
  2558. }
  2559. }
  2560. });
  2561. }
  2562. async function handlePay() {
  2563. // 防重处理:2秒内禁止重复点击(官方要求按钮防抖)
  2564. const now = Date.now();
  2565. if (now - lastPayClick.value < 2000) {
  2566. console.log('[Payment] 防重:忽略重复点击');
  2567. return;
  2568. }
  2569. lastPayClick.value = now;
  2570. if (!planId.value && productType.value !== 'token-pack') {
  2571. uni.showToast({ title: '参数错误', icon: 'none' });
  2572. return;
  2573. }
  2574. // 公众号内微信支付 → 走 JSAPI
  2575. if (inWechat.value && paymentMethod.value === 'wechat') {
  2576. await handleWechatJsapiPay();
  2577. return;
  2578. }
  2579. try {
  2580. uni.showLoading({ title: '创建订单...' });
  2581. // #ifdef H5
  2582. const returnUrl = `${window.location.origin}/#/pages/payment-result/index`;
  2583. // #endif
  2584. // #ifndef H5
  2585. // App 端使用相对路径
  2586. const returnUrl = `/#/pages/payment-result/index`;
  2587. // #endif
  2588. let orderResult: any;
  2589. if (productType.value === 'token-pack') {
  2590. orderResult = await post<any>('/payment/token-packs/create', {
  2591. quantity: tokenPackQuantity.value,
  2592. paymentMethod: paymentMethod.value,
  2593. returnUrl
  2594. });
  2595. } else {
  2596. orderResult = await post<any>('/payment/create', {
  2597. planId: planId.value,
  2598. paymentMethod: paymentMethod.value,
  2599. returnUrl
  2600. });
  2601. }
  2602. uni.hideLoading();
  2603. orderInfo.value.orderNo = orderResult.orderNo;
  2604. // 微信支付 - 显示二维码
  2605. if (paymentMethod.value === 'wechat' && orderResult.qrcode) {
  2606. uni.navigateTo({
  2607. url: `/pages/alipay-qrcode/index?orderNo=${orderResult.orderNo}&amount=${orderResult.amount}&planName=${encodeURIComponent(orderResult.planName || orderInfo.value.planName)}&qrcode=${encodeURIComponent(orderResult.qrcode)}&method=wechat`
  2608. });
  2609. return;
  2610. }
  2611. // 支付宝 H5 跳转
  2612. // #ifdef H5
  2613. if (orderResult.paymentUrl) {
  2614. uni.showToast({ title: '即将跳转支付宝...', icon: 'none' });
  2615. setTimeout(() => {
  2616. window.location.href = orderResult.paymentUrl!;
  2617. }, 1000);
  2618. }
  2619. // #endif
  2620. // 开发环境模拟
  2621. // #ifndef H5
  2622. uni.showModal({
  2623. title: '模拟支付',
  2624. content: `确认支付 ¥${orderResult.amount}?\n(开发环境模拟)`,
  2625. success: async (res) => {
  2626. if (res.confirm) {
  2627. await mockPay(orderResult.orderNo);
  2628. }
  2629. },
  2630. });
  2631. // #endif
  2632. } catch (error: any) {
  2633. uni.hideLoading();
  2634. console.error('创建订单失败:', error);
  2635. uni.showToast({ title: error.message || '创建订单失败', icon: 'none' });
  2636. }
  2637. }
  2638. async function mockPay(orderNo: string) {
  2639. try {
  2640. uni.showLoading({ title: '支付中...' });
  2641. await post('/payment/mock', { orderNo });
  2642. uni.hideLoading();
  2643. uni.showModal({
  2644. title: '支付成功',
  2645. content: productType.value === 'token-pack' ? '积分包购买成功!' : '恭喜您订阅成功!',
  2646. showCancel: false,
  2647. success: () => {
  2648. uni.switchTab({ url: '/pages/mine/index' });
  2649. }
  2650. });
  2651. } catch (error) {
  2652. uni.hideLoading();
  2653. uni.showToast({ title: '支付失败', icon: 'none' });
  2654. }
  2655. }
  2656. function goBack() {
  2657. const pages = getCurrentPages();
  2658. if (pages.length > 1) {
  2659. uni.navigateBack();
  2660. } else {
  2661. uni.switchTab({ url: '/pages/member/index' });
  2662. }
  2663. }
  2664. </script>
  2665. <style scoped>
  2666. .page {
  2667. min-height: 100vh;
  2668. background: #f5f5f5;
  2669. }
  2670. .nav-bar {
  2671. position: fixed;
  2672. top: 0;
  2673. left: 0;
  2674. right: 0;
  2675. height: 88rpx;
  2676. padding-top: env(safe-area-inset-top);
  2677. background: #ffffff;
  2678. border-bottom: 1px solid #e5e7eb;
  2679. z-index: 100;
  2680. }
  2681. .nav-content {
  2682. height: 100%;
  2683. display: flex;
  2684. align-items: center;
  2685. padding: 0 24rpx;
  2686. }
  2687. .nav-left {
  2688. width: 80rpx;
  2689. display: flex;
  2690. align-items: center;
  2691. }
  2692. .back-icon {
  2693. font-size: 36rpx;
  2694. color: #333;
  2695. }
  2696. .page-title {
  2697. flex: 1;
  2698. text-align: center;
  2699. font-size: 32rpx;
  2700. font-weight: 500;
  2701. color: #333;
  2702. margin-right: 80rpx;
  2703. }
  2704. .content {
  2705. padding-top: calc(120rpx + env(safe-area-inset-top));
  2706. padding-left: 32rpx;
  2707. padding-right: 32rpx;
  2708. }
  2709. .order-card {
  2710. background: #ffffff;
  2711. border-radius: 16rpx;
  2712. padding: 32rpx;
  2713. margin-bottom: 32rpx;
  2714. }
  2715. .order-title {
  2716. font-size: 28rpx;
  2717. font-weight: 600;
  2718. color: #1f2937;
  2719. margin-bottom: 24rpx;
  2720. display: block;
  2721. }
  2722. .order-info {
  2723. display: flex;
  2724. justify-content: space-between;
  2725. align-items: center;
  2726. padding: 16rpx 0;
  2727. border-bottom: 1px solid #f3f4f6;
  2728. }
  2729. .order-info:last-child {
  2730. border-bottom: none;
  2731. }
  2732. .order-info.highlight {
  2733. margin-top: 16rpx;
  2734. padding-top: 24rpx;
  2735. border-top: 2rpx dashed #e5e7eb;
  2736. border-bottom: none;
  2737. }
  2738. .info-label {
  2739. font-size: 26rpx;
  2740. color: #9ca3af;
  2741. }
  2742. .info-value {
  2743. font-size: 26rpx;
  2744. color: #1f2937;
  2745. }
  2746. .info-value.price {
  2747. font-size: 36rpx;
  2748. font-weight: 700;
  2749. color: #ef4444;
  2750. }
  2751. .payment-section {
  2752. background: #ffffff;
  2753. border-radius: 16rpx;
  2754. padding: 32rpx;
  2755. margin-bottom: 32rpx;
  2756. }
  2757. .section-title {
  2758. font-size: 28rpx;
  2759. font-weight: 600;
  2760. color: #1f2937;
  2761. margin-bottom: 24rpx;
  2762. display: block;
  2763. }
  2764. .payment-methods {
  2765. display: flex;
  2766. flex-direction: column;
  2767. gap: 16rpx;
  2768. }
  2769. .payment-method {
  2770. display: flex;
  2771. align-items: center;
  2772. padding: 24rpx;
  2773. background: #f9fafb;
  2774. border: 3rpx solid #e5e7eb;
  2775. border-radius: 12rpx;
  2776. transition: all 0.3s;
  2777. }
  2778. .payment-method.active {
  2779. border-color: #667eea;
  2780. background: #f0f2ff;
  2781. }
  2782. .method-radio {
  2783. width: 40rpx;
  2784. height: 40rpx;
  2785. border: 3rpx solid #d1d5db;
  2786. border-radius: 50%;
  2787. margin-right: 16rpx;
  2788. display: flex;
  2789. align-items: center;
  2790. justify-content: center;
  2791. }
  2792. .payment-method.active .method-radio {
  2793. border-color: #667eea;
  2794. }
  2795. .radio-inner {
  2796. width: 24rpx;
  2797. height: 24rpx;
  2798. background: #667eea;
  2799. border-radius: 50%;
  2800. }
  2801. .method-icon {
  2802. font-size: 36rpx;
  2803. margin-right: 12rpx;
  2804. }
  2805. .method-name {
  2806. font-size: 28rpx;
  2807. color: #1f2937;
  2808. flex: 1;
  2809. }
  2810. .method-badge {
  2811. font-size: 22rpx;
  2812. color: #ffffff;
  2813. background: #10b981;
  2814. padding: 4rpx 12rpx;
  2815. border-radius: 8rpx;
  2816. margin-left: 8rpx;
  2817. }
  2818. .wechat-notice {
  2819. display: flex;
  2820. align-items: center;
  2821. padding: 16rpx 20rpx;
  2822. background: #ecfdf5;
  2823. border: 1rpx solid #a7f3d0;
  2824. border-radius: 10rpx;
  2825. margin-bottom: 20rpx;
  2826. }
  2827. .wechat-notice-icon {
  2828. font-size: 32rpx;
  2829. margin-right: 12rpx;
  2830. }
  2831. .wechat-notice-text {
  2832. font-size: 24rpx;
  2833. color: #065f46;
  2834. }
  2835. .actions {
  2836. display: flex;
  2837. gap: 24rpx;
  2838. margin-bottom: 32rpx;
  2839. }
  2840. .btn-primary,
  2841. .btn-secondary {
  2842. flex: 1;
  2843. height: 88rpx;
  2844. border-radius: 44rpx;
  2845. display: flex;
  2846. align-items: center;
  2847. justify-content: center;
  2848. font-size: 30rpx;
  2849. border: none;
  2850. }
  2851. .btn-primary {
  2852. background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
  2853. color: #ffffff;
  2854. }
  2855. .btn-secondary {
  2856. background: #ffffff;
  2857. color: #6b7280;
  2858. border: 2rpx solid #e5e7eb;
  2859. }
  2860. .tips {
  2861. background: #fef3c7;
  2862. border-radius: 16rpx;
  2863. padding: 24rpx;
  2864. margin-bottom: 48rpx;
  2865. }
  2866. .tips-title {
  2867. font-size: 24rpx;
  2868. font-weight: 600;
  2869. color: #92400e;
  2870. display: block;
  2871. margin-bottom: 12rpx;
  2872. }
  2873. .tips-text {
  2874. font-size: 22rpx;
  2875. color: #78350f;
  2876. display: block;
  2877. margin-bottom: 6rpx;
  2878. }
  2879. </style>